From c7b2bbb0bce5d6ad16e6247f0368a4f33fc97ec5 Mon Sep 17 00:00:00 2001 From: Timon Vonk Date: Tue, 30 Sep 2025 17:08:05 +0200 Subject: [PATCH 1/7] feat: Create a copy of executors in a subdirectory --- Cargo.toml | 6 +- swiftide-agents/src/default_context.rs | 12 ++-- swiftide-agents/src/tasks/impls.rs | 6 +- swiftide-agents/src/tools/local_executor.rs | 64 +++++++++++++++++++ swiftide-core/src/agent_traits.rs | 70 ++++++++++++++++----- 5 files changed, 134 insertions(+), 24 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a4c572a53d..2b93e81b89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,11 @@ dyn-clone = { version = "1.0", default-features = false } convert_case = { version = "0.8", default-features = false } # Mcp -rmcp = { version = "0.5", default-features = false } +rmcp = { version = "0.5", default-features = false, features = [ + "base64", + "macros", + "server", +] } schemars = { version = "1.0", default-features = false } # Integrations diff --git a/swiftide-agents/src/default_context.rs b/swiftide-agents/src/default_context.rs index e06dd4d1d9..d3df572d69 100644 --- a/swiftide-agents/src/default_context.rs +++ b/swiftide-agents/src/default_context.rs @@ -18,7 +18,7 @@ use std::{ use anyhow::Result; use async_trait::async_trait; use swiftide_core::{ - AgentContext, Command, CommandError, CommandOutput, MessageHistory, ToolExecutor, + AgentContext, Command, CommandError, CommandOutput, DynToolExecutor, MessageHistory, }; use swiftide_core::{ ToolFeedback, @@ -42,7 +42,7 @@ pub struct DefaultContext { current_completions_ptr: Arc, /// The executor used to run tools. I.e. local, remote, docker - tool_executor: Arc, + tool_executor: Arc, /// Stop if last message is from the assistant stop_on_assistant: bool, @@ -56,7 +56,7 @@ impl Default for DefaultContext { message_history: Arc::new(Mutex::new(Vec::new())), completions_ptr: Arc::new(AtomicUsize::new(0)), current_completions_ptr: Arc::new(AtomicUsize::new(0)), - tool_executor: Arc::new(LocalExecutor::default()) as Arc, + tool_executor: Arc::new(LocalExecutor::default()) as Arc, stop_on_assistant: true, feedback_received: Arc::new(Mutex::new(HashMap::new())), } @@ -69,7 +69,7 @@ impl std::fmt::Debug for DefaultContext { .field("completion_history", &self.message_history) .field("completions_ptr", &self.completions_ptr) .field("current_completions_ptr", &self.current_completions_ptr) - .field("tool_executor", &"Arc") + .field("tool_executor", &"Arc") .field("stop_on_assistant", &self.stop_on_assistant) .finish() } @@ -77,7 +77,7 @@ impl std::fmt::Debug for DefaultContext { impl DefaultContext { /// Create a new context with a custom executor - pub fn from_executor>>(executor: T) -> DefaultContext { + pub fn from_executor>>(executor: T) -> DefaultContext { DefaultContext { tool_executor: executor.into(), ..Default::default() @@ -203,7 +203,7 @@ impl AgentContext for DefaultContext { self.tool_executor.exec_cmd(cmd).await } - fn executor(&self) -> &Arc { + fn executor(&self) -> &Arc { &self.tool_executor } diff --git a/swiftide-agents/src/tasks/impls.rs b/swiftide-agents/src/tasks/impls.rs index 366f8cfffb..7d153c1b9d 100644 --- a/swiftide-agents/src/tasks/impls.rs +++ b/swiftide-agents/src/tasks/impls.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use async_trait::async_trait; use swiftide_core::{ - ChatCompletion, Command, CommandError, CommandOutput, SimplePrompt, ToolExecutor, + ChatCompletion, Command, CommandError, CommandOutput, DynToolExecutor, SimplePrompt, chat_completion::{ChatCompletionRequest, ChatCompletionResponse, errors::LanguageModelError}, prompt::Prompt, }; @@ -123,7 +123,7 @@ impl TaskNode for Arc { } #[async_trait] -impl TaskNode for Box { +impl TaskNode for Box { type Input = Command; type Output = CommandOutput; @@ -142,7 +142,7 @@ impl TaskNode for Box { } #[async_trait] -impl TaskNode for Arc { +impl TaskNode for Arc { type Input = Command; type Output = CommandOutput; diff --git a/swiftide-agents/src/tools/local_executor.rs b/swiftide-agents/src/tools/local_executor.rs index 49cfee88d1..a1aea42a72 100644 --- a/swiftide-agents/src/tools/local_executor.rs +++ b/swiftide-agents/src/tools/local_executor.rs @@ -213,6 +213,8 @@ impl LocalExecutor { } #[async_trait] impl ToolExecutor for LocalExecutor { + type Owned = LocalExecutor; + /// Execute a `Command` on the local machine #[tracing::instrument(skip_self)] async fn exec_cmd(&self, cmd: &Command) -> Result { @@ -237,6 +239,19 @@ impl ToolExecutor for LocalExecutor { Ok(loader.into_stream()) } + + fn current_dir(&self, path: &Path) -> Result { + let new_workdir = if path.is_absolute() { + path.to_path_buf() + } else { + self.workdir.join(path) + }; + + let mut executor = self.clone(); + executor.workdir = new_workdir; + + Ok(executor) + } } #[cfg(test)] @@ -244,6 +259,7 @@ mod tests { use super::*; use futures_util::StreamExt as _; use indoc::indoc; + use std::{path::Path, sync::Arc}; use swiftide_core::{Command, ToolExecutor}; use temp_dir::TempDir; @@ -584,4 +600,52 @@ print(1 + 2)"#; Ok(()) } + + #[tokio::test] + async fn test_local_executor_current_dir() -> anyhow::Result<()> { + let temp_dir = TempDir::new()?; + let base_path = temp_dir.path(); + + let executor = LocalExecutor { + workdir: base_path.to_path_buf(), + ..Default::default() + }; + + let nested = executor.current_dir(Path::new("nested"))?; + nested + .exec_cmd(&Command::WriteFile("file.txt".into(), "hello".into())) + .await?; + + assert!(!base_path.join("file.txt").exists()); + assert!(base_path.join("nested").join("file.txt").exists()); + assert_eq!(executor.workdir, base_path); + + Ok(()) + } + + #[tokio::test] + async fn test_local_executor_current_dir_dyn() -> anyhow::Result<()> { + let temp_dir = TempDir::new()?; + let base_path = temp_dir.path(); + + let executor = LocalExecutor { + workdir: base_path.to_path_buf(), + ..Default::default() + }; + + let dyn_exec: Arc = Arc::new(executor.clone()); + let nested = dyn_exec.current_dir(Path::new("nested"))?; + + nested + .exec_cmd(&Command::WriteFile( + "nested_file.txt".into(), + "hello".into(), + )) + .await?; + + assert!(base_path.join("nested").join("nested_file.txt").exists()); + assert!(!base_path.join("nested_file.txt").exists()); + + Ok(()) + } } diff --git a/swiftide-core/src/agent_traits.rs b/swiftide-core/src/agent_traits.rs index 00ea6f9958..0f42c3b593 100644 --- a/swiftide-core/src/agent_traits.rs +++ b/swiftide-core/src/agent_traits.rs @@ -26,6 +26,9 @@ use thiserror::Error; /// Additionally, the executor can be used stream files files for indexing. #[async_trait] pub trait ToolExecutor: Send + Sync + DynClone { + /// Owned executor type returned by `current_dir`. + type Owned: ToolExecutor + Send + Sync + DynClone + 'static; + /// Execute a command in the executor async fn exec_cmd(&self, cmd: &Command) -> Result; @@ -35,14 +38,33 @@ pub trait ToolExecutor: Send + Sync + DynClone { path: &Path, extensions: Option>, ) -> Result>; + + /// Create a new executor that operates relative to the provided directory. + fn current_dir(&self, path: &Path) -> Result; +} + +#[async_trait] +pub trait DynToolExecutor: Send + Sync + DynClone { + async fn exec_cmd(&self, cmd: &Command) -> Result; + + async fn stream_files( + &self, + path: &Path, + extensions: Option>, + ) -> Result>; + + fn current_dir(&self, path: &Path) -> Result, CommandError>; } -dyn_clone::clone_trait_object!(ToolExecutor); +dyn_clone::clone_trait_object!(DynToolExecutor); #[async_trait] -impl ToolExecutor for &T { +impl DynToolExecutor for T +where + T: ToolExecutor + Send + Sync + DynClone + 'static, +{ async fn exec_cmd(&self, cmd: &Command) -> Result { - (*self).exec_cmd(cmd).await + ToolExecutor::exec_cmd(self, cmd).await } async fn stream_files( @@ -50,15 +72,21 @@ impl ToolExecutor for &T { path: &Path, extensions: Option>, ) -> Result> { - (*self).stream_files(path, extensions).await + ToolExecutor::stream_files(self, path, extensions).await + } + + fn current_dir(&self, path: &Path) -> Result, CommandError> { + let owned = ToolExecutor::current_dir(self, path)?; + Ok(Arc::new(owned) as Arc) } } #[async_trait] -impl ToolExecutor for Arc { +impl DynToolExecutor for Arc { async fn exec_cmd(&self, cmd: &Command) -> Result { self.as_ref().exec_cmd(cmd).await } + async fn stream_files( &self, path: &Path, @@ -66,13 +94,18 @@ impl ToolExecutor for Arc { ) -> Result> { self.as_ref().stream_files(path, extensions).await } + + fn current_dir(&self, path: &Path) -> Result, CommandError> { + self.as_ref().current_dir(path) + } } #[async_trait] -impl ToolExecutor for Box { +impl DynToolExecutor for Box { async fn exec_cmd(&self, cmd: &Command) -> Result { self.as_ref().exec_cmd(cmd).await } + async fn stream_files( &self, path: &Path, @@ -80,19 +113,28 @@ impl ToolExecutor for Box { ) -> Result> { self.as_ref().stream_files(path, extensions).await } + + fn current_dir(&self, path: &Path) -> Result, CommandError> { + self.as_ref().current_dir(path) + } } #[async_trait] -impl ToolExecutor for &dyn ToolExecutor { +impl DynToolExecutor for &dyn DynToolExecutor { async fn exec_cmd(&self, cmd: &Command) -> Result { - (*self).exec_cmd(cmd).await + (**self).exec_cmd(cmd).await } + async fn stream_files( &self, path: &Path, extensions: Option>, ) -> Result> { - (*self).stream_files(path, extensions).await + (**self).stream_files(path, extensions).await + } + + fn current_dir(&self, path: &Path) -> Result, CommandError> { + (**self).current_dir(path) } } @@ -254,7 +296,7 @@ pub trait AgentContext: Send + Sync { #[deprecated(note = "use executor instead")] async fn exec_cmd(&self, cmd: &Command) -> Result; - fn executor(&self) -> &Arc; + fn executor(&self) -> &Arc; async fn history(&self) -> Result>; @@ -294,7 +336,7 @@ impl AgentContext for Box { (**self).exec_cmd(cmd).await } - fn executor(&self) -> &Arc { + fn executor(&self) -> &Arc { (**self).executor() } @@ -338,7 +380,7 @@ impl AgentContext for Arc { (**self).exec_cmd(cmd).await } - fn executor(&self) -> &Arc { + fn executor(&self) -> &Arc { (**self).executor() } @@ -382,7 +424,7 @@ impl AgentContext for &dyn AgentContext { (**self).exec_cmd(cmd).await } - fn executor(&self) -> &Arc { + fn executor(&self) -> &Arc { (**self).executor() } @@ -430,7 +472,7 @@ impl AgentContext for () { ))) } - fn executor(&self) -> &Arc { + fn executor(&self) -> &Arc { unimplemented!("Empty agent context does not have a tool executor") } From 9f66323f3c736fac2c41bb74e8e63880f39a8a72 Mon Sep 17 00:00:00 2001 From: Timon Vonk Date: Tue, 30 Sep 2025 17:23:31 +0200 Subject: [PATCH 2/7] feat: Optional workdir on command --- swiftide-agents/src/tools/local_executor.rs | 122 +++++++++++++++----- swiftide-core/src/agent_traits.rs | 46 +++++++- 2 files changed, 133 insertions(+), 35 deletions(-) diff --git a/swiftide-agents/src/tools/local_executor.rs b/swiftide-agents/src/tools/local_executor.rs index a1aea42a72..c3955f5d07 100644 --- a/swiftide-agents/src/tools/local_executor.rs +++ b/swiftide-agents/src/tools/local_executor.rs @@ -10,7 +10,7 @@ use std::{ use anyhow::{Context as _, Result}; use async_trait::async_trait; use derive_builder::Builder; -use swiftide_core::{Command, CommandError, CommandOutput, Loader, ToolExecutor}; +use swiftide_core::{Command, CommandError, CommandKind, CommandOutput, Loader, ToolExecutor}; use swiftide_indexing::loaders::FileLoader; use tokio::{ io::{AsyncBufReadExt as _, AsyncWriteExt as _}, @@ -58,8 +58,16 @@ impl LocalExecutor { LocalExecutorBuilder::default() } + fn resolve_workdir(&self, cmd: &Command) -> PathBuf { + match cmd.current_dir() { + Some(path) if path.is_absolute() => path.to_path_buf(), + Some(path) => self.workdir.join(path), + None => self.workdir.clone(), + } + } + #[allow(clippy::too_many_lines)] - async fn exec_shell(&self, cmd: &str) -> Result { + async fn exec_shell(&self, cmd: &str, workdir: &Path) -> Result { let lines: Vec<&str> = cmd.lines().collect(); let mut child = if let Some(first_line) = lines.first() && first_line.starts_with("#!") @@ -85,7 +93,7 @@ impl LocalExecutor { } let mut child = command - .current_dir(&self.workdir) + .current_dir(workdir) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -103,7 +111,7 @@ impl LocalExecutor { let mut command = tokio::process::Command::new("sh"); // Treat as shell command - command.arg("-c").arg(cmd).current_dir(&self.workdir); + command.arg("-c").arg(cmd).current_dir(workdir); if self.env_clear { tracing::info!("clearing environment variables"); @@ -120,7 +128,7 @@ impl LocalExecutor { command.env(key, value); } command - .current_dir(&self.workdir) + .current_dir(workdir) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -188,8 +196,16 @@ impl LocalExecutor { } } - async fn exec_read_file(&self, path: &Path) -> Result { - let path = self.workdir.join(path); + async fn exec_read_file( + &self, + workdir: &Path, + path: &Path, + ) -> Result { + let path = if path.is_absolute() { + path.to_path_buf() + } else { + workdir.join(path) + }; let output = fs_err::tokio::read(&path).await?; Ok(String::from_utf8(output) @@ -199,10 +215,15 @@ impl LocalExecutor { async fn exec_write_file( &self, + workdir: &Path, path: &Path, content: &str, ) -> Result { - let path = self.workdir.join(path); + let path = if path.is_absolute() { + path.to_path_buf() + } else { + workdir.join(path) + }; if let Some(parent) = path.parent() { let _ = fs_err::tokio::create_dir_all(parent).await; } @@ -218,10 +239,13 @@ impl ToolExecutor for LocalExecutor { /// Execute a `Command` on the local machine #[tracing::instrument(skip_self)] async fn exec_cmd(&self, cmd: &Command) -> Result { - match cmd { - Command::Shell(cmd) => __self.exec_shell(cmd).await, - Command::ReadFile(path) => __self.exec_read_file(path).await, - Command::WriteFile(path, content) => __self.exec_write_file(path, content).await, + let workdir = __self.resolve_workdir(cmd); + match cmd.kind() { + CommandKind::Shell(command) => __self.exec_shell(command, &workdir).await, + CommandKind::ReadFile(path) => __self.exec_read_file(&workdir, path).await, + CommandKind::WriteFile(path, content) => { + __self.exec_write_file(&workdir, path, content).await + } _ => unimplemented!("Unsupported command: {cmd:?}"), } } @@ -281,7 +305,7 @@ mod tests { // Write a shell command to create a file with the specified content let write_cmd = - Command::Shell(format!("echo '{}' > {}", file_content, file_path.display())); + Command::shell(format!("echo '{}' > {}", file_content, file_path.display())); // Execute the write command executor.exec_cmd(&write_cmd).await?; @@ -290,7 +314,7 @@ mod tests { assert!(file_path.exists()); // Write a shell command to read the file's content - let read_cmd = Command::Shell(format!("cat {}", file_path.display())); + let read_cmd = Command::shell(format!("cat {}", file_path.display())); // Execute the read command let output = executor.exec_cmd(&read_cmd).await?; @@ -320,7 +344,7 @@ mod tests { }; // Define the echo command - let echo_cmd = Command::Shell("echo 'hello world'".to_string()); + let echo_cmd = Command::shell("echo 'hello world'"); // Execute the echo command let output = executor.exec_cmd(&echo_cmd).await?; @@ -345,7 +369,7 @@ mod tests { }; // Define the echo command - let echo_cmd = Command::Shell("printenv".to_string()); + let echo_cmd = Command::shell("printenv"); // Execute the echo command let output = executor.exec_cmd(&echo_cmd).await?.to_string(); @@ -371,7 +395,7 @@ mod tests { }; // Define the echo command - let echo_cmd = Command::Shell("printenv".to_string()); + let echo_cmd = Command::shell("printenv"); // Execute the echo command let output = executor.exec_cmd(&echo_cmd).await?.to_string(); @@ -399,7 +423,7 @@ mod tests { }; // Define the echo command - let echo_cmd = Command::Shell("printenv".to_string()); + let echo_cmd = Command::shell("printenv"); // Execute the echo command let output = executor.exec_cmd(&echo_cmd).await?.to_string(); @@ -461,13 +485,13 @@ print(1 + 2)"#; "#}; // Write a shell command to create a file with the specified content - let write_cmd = Command::Shell(format!("echo '{file_content}' > {file_path}")); + let write_cmd = Command::shell(format!("echo '{file_content}' > {file_path}")); // Execute the write command executor.exec_cmd(&write_cmd).await?; // Write a shell command to read the file's content - let read_cmd = Command::Shell(format!("cat {file_path}")); + let read_cmd = Command::shell(format!("cat {file_path}")); // Execute the read command let output = executor.exec_cmd(&read_cmd).await?; @@ -495,7 +519,7 @@ print(1 + 2)"#; let file_content = "Hello, world!"; // Assert that the file does not exist and it gives the correct error - let cmd = Command::ReadFile(file_path.clone()); + let cmd = Command::read_file(file_path.clone()); let result = executor.exec_cmd(&cmd).await; if let Err(err) = result { @@ -505,7 +529,7 @@ print(1 + 2)"#; } // Create a write command - let write_cmd = Command::WriteFile(file_path.clone(), file_content.to_string()); + let write_cmd = Command::write_file(file_path.clone(), file_content.to_string()); // Execute the write command executor.exec_cmd(&write_cmd).await?; @@ -514,7 +538,7 @@ print(1 + 2)"#; assert!(file_path.exists()); // Create a read command - let read_cmd = Command::ReadFile(file_path.clone()); + let read_cmd = Command::read_file(file_path.clone()); // Execute the read command let output = executor.exec_cmd(&read_cmd).await?.output; @@ -574,7 +598,7 @@ print(1 + 2)"#; }; // 2. Run a shell command in workdir and check output is workdir - let pwd_cmd = Command::Shell("pwd".to_string()); + let pwd_cmd = Command::shell("pwd"); let pwd_output = executor.exec_cmd(&pwd_cmd).await?.to_string(); let pwd_path = std::fs::canonicalize(pwd_output.trim())?; let temp_path = std::fs::canonicalize(temp_path)?; @@ -582,7 +606,7 @@ print(1 + 2)"#; // 3. Write a file using WriteFile (should land in workdir) let fname = "workdir_check.txt"; - let write_cmd = Command::WriteFile(fname.into(), "test123".into()); + let write_cmd = Command::write_file(fname, "test123"); executor.exec_cmd(&write_cmd).await?; // 4. Assert file exists in workdir, not current dir @@ -591,7 +615,7 @@ print(1 + 2)"#; assert!(!Path::new(fname).exists()); // 5. Write/read using ReadFile - let read_cmd = Command::ReadFile(fname.into()); + let read_cmd = Command::read_file(fname); let read_output = executor.exec_cmd(&read_cmd).await?.to_string(); assert_eq!(read_output.trim(), "test123"); @@ -601,6 +625,45 @@ print(1 + 2)"#; Ok(()) } + #[tokio::test] + async fn test_local_executor_command_current_dir() -> anyhow::Result<()> { + use std::fs; + use temp_dir::TempDir; + + let temp_dir = TempDir::new()?; + let base_path = temp_dir.path(); + + let executor = LocalExecutor { + workdir: base_path.to_path_buf(), + ..Default::default() + }; + + let nested_dir = base_path.join("nested"); + fs::create_dir_all(&nested_dir)?; + + let pwd_cmd = Command::shell("pwd").with_current_dir(Path::new("nested")); + let pwd_output = executor.exec_cmd(&pwd_cmd).await?.to_string(); + let pwd_path = std::fs::canonicalize(pwd_output.trim())?; + assert_eq!(pwd_path, std::fs::canonicalize(&nested_dir)?); + + executor + .exec_cmd( + &Command::write_file("file.txt", "hello").with_current_dir(Path::new("nested")), + ) + .await?; + + assert!(!base_path.join("file.txt").exists()); + assert!(nested_dir.join("file.txt").exists()); + + let read_output = executor + .exec_cmd(&Command::read_file("file.txt").with_current_dir(Path::new("nested"))) + .await? + .to_string(); + assert_eq!(read_output.trim(), "hello"); + + Ok(()) + } + #[tokio::test] async fn test_local_executor_current_dir() -> anyhow::Result<()> { let temp_dir = TempDir::new()?; @@ -613,7 +676,7 @@ print(1 + 2)"#; let nested = executor.current_dir(Path::new("nested"))?; nested - .exec_cmd(&Command::WriteFile("file.txt".into(), "hello".into())) + .exec_cmd(&Command::write_file("file.txt", "hello")) .await?; assert!(!base_path.join("file.txt").exists()); @@ -637,10 +700,7 @@ print(1 + 2)"#; let nested = dyn_exec.current_dir(Path::new("nested"))?; nested - .exec_cmd(&Command::WriteFile( - "nested_file.txt".into(), - "hello".into(), - )) + .exec_cmd(&Command::write_file("nested_file.txt", "hello")) .await?; assert!(base_path.join("nested").join("nested_file.txt").exists()); diff --git a/swiftide-core/src/agent_traits.rs b/swiftide-core/src/agent_traits.rs index 0f42c3b593..91f3a02955 100644 --- a/swiftide-core/src/agent_traits.rs +++ b/swiftide-core/src/agent_traits.rs @@ -162,9 +162,20 @@ impl From for CommandError { /// There is an ongoing consideration to make this an associated type on the executor /// /// TODO: Should be able to borrow everything? +/// Commands that can be executed by the executor. +/// +/// Use the constructor helpers (e.g. [`Command::shell`]) and then chain +/// configuration methods such as [`Command::with_current_dir`] for +/// builder-style ergonomics. +#[derive(Debug, Clone)] +pub struct Command { + kind: CommandKind, + current_dir: Option, +} + #[non_exhaustive] #[derive(Debug, Clone)] -pub enum Command { +pub enum CommandKind { Shell(String), ReadFile(PathBuf), WriteFile(PathBuf, String), @@ -172,15 +183,42 @@ pub enum Command { impl Command { pub fn shell>(cmd: S) -> Self { - Command::Shell(cmd.into()) + Self { + kind: CommandKind::Shell(cmd.into()), + current_dir: None, + } } pub fn read_file>(path: P) -> Self { - Command::ReadFile(path.into()) + Self { + kind: CommandKind::ReadFile(path.into()), + current_dir: None, + } } pub fn write_file, S: Into>(path: P, content: S) -> Self { - Command::WriteFile(path.into(), content.into()) + Self { + kind: CommandKind::WriteFile(path.into(), content.into()), + current_dir: None, + } + } + + /// Override the working directory used when executing this command. + /// + /// Executors may interpret relative paths in the context of their own + /// working directory. + pub fn with_current_dir>(mut self, path: P) -> Self { + self.current_dir = Some(path.into()); + self + } + + pub fn current_dir(&self) -> Option<&Path> { + self.current_dir.as_deref() + } + + /// Access the underlying command payload. + pub fn kind(&self) -> &CommandKind { + &self.kind } } From 9ce231c303a859d14d4c58428784a7e11b9932f5 Mon Sep 17 00:00:00 2001 From: Timon Vonk Date: Tue, 30 Sep 2025 18:56:28 +0200 Subject: [PATCH 3/7] Use builder lite --- swiftide-agents/src/tools/local_executor.rs | 30 ++++---- swiftide-core/src/agent_traits.rs | 79 ++++++++++++++------- 2 files changed, 67 insertions(+), 42 deletions(-) diff --git a/swiftide-agents/src/tools/local_executor.rs b/swiftide-agents/src/tools/local_executor.rs index c3955f5d07..21fed17dc3 100644 --- a/swiftide-agents/src/tools/local_executor.rs +++ b/swiftide-agents/src/tools/local_executor.rs @@ -10,7 +10,7 @@ use std::{ use anyhow::{Context as _, Result}; use async_trait::async_trait; use derive_builder::Builder; -use swiftide_core::{Command, CommandError, CommandKind, CommandOutput, Loader, ToolExecutor}; +use swiftide_core::{Command, CommandError, CommandOutput, Loader, ToolExecutor}; use swiftide_indexing::loaders::FileLoader; use tokio::{ io::{AsyncBufReadExt as _, AsyncWriteExt as _}, @@ -59,7 +59,7 @@ impl LocalExecutor { } fn resolve_workdir(&self, cmd: &Command) -> PathBuf { - match cmd.current_dir() { + match cmd.current_dir_path() { Some(path) if path.is_absolute() => path.to_path_buf(), Some(path) => self.workdir.join(path), None => self.workdir.clone(), @@ -240,10 +240,10 @@ impl ToolExecutor for LocalExecutor { #[tracing::instrument(skip_self)] async fn exec_cmd(&self, cmd: &Command) -> Result { let workdir = __self.resolve_workdir(cmd); - match cmd.kind() { - CommandKind::Shell(command) => __self.exec_shell(command, &workdir).await, - CommandKind::ReadFile(path) => __self.exec_read_file(&workdir, path).await, - CommandKind::WriteFile(path, content) => { + match cmd { + Command::Shell { command, .. } => __self.exec_shell(command, &workdir).await, + Command::ReadFile { path, .. } => __self.exec_read_file(&workdir, path).await, + Command::WriteFile { path, content, .. } => { __self.exec_write_file(&workdir, path, content).await } _ => unimplemented!("Unsupported command: {cmd:?}"), @@ -641,24 +641,22 @@ print(1 + 2)"#; let nested_dir = base_path.join("nested"); fs::create_dir_all(&nested_dir)?; - let pwd_cmd = Command::shell("pwd").with_current_dir(Path::new("nested")); + let mut pwd_cmd = Command::shell("pwd"); + pwd_cmd.current_dir(Path::new("nested")); let pwd_output = executor.exec_cmd(&pwd_cmd).await?.to_string(); let pwd_path = std::fs::canonicalize(pwd_output.trim())?; assert_eq!(pwd_path, std::fs::canonicalize(&nested_dir)?); - executor - .exec_cmd( - &Command::write_file("file.txt", "hello").with_current_dir(Path::new("nested")), - ) - .await?; + let mut write_cmd = Command::write_file("file.txt", "hello"); + write_cmd.current_dir(Path::new("nested")); + executor.exec_cmd(&write_cmd).await?; assert!(!base_path.join("file.txt").exists()); assert!(nested_dir.join("file.txt").exists()); - let read_output = executor - .exec_cmd(&Command::read_file("file.txt").with_current_dir(Path::new("nested"))) - .await? - .to_string(); + let mut read_cmd = Command::read_file("file.txt"); + read_cmd.current_dir(Path::new("nested")); + let read_output = executor.exec_cmd(&read_cmd).await?.to_string(); assert_eq!(read_output.trim(), "hello"); Ok(()) diff --git a/swiftide-core/src/agent_traits.rs b/swiftide-core/src/agent_traits.rs index 91f3a02955..eb651279d7 100644 --- a/swiftide-core/src/agent_traits.rs +++ b/swiftide-core/src/agent_traits.rs @@ -162,43 +162,46 @@ impl From for CommandError { /// There is an ongoing consideration to make this an associated type on the executor /// /// TODO: Should be able to borrow everything? -/// Commands that can be executed by the executor. /// -/// Use the constructor helpers (e.g. [`Command::shell`]) and then chain -/// configuration methods such as [`Command::with_current_dir`] for -/// builder-style ergonomics. +/// Use the constructor helpers (e.g. [`Command::shell`]) and then chain configuration methods +/// such as [`Command::with_current_dir`] or [`Command::current_dir`] for builder-style ergonomics. #[derive(Debug, Clone)] -pub struct Command { - kind: CommandKind, - current_dir: Option, -} - #[non_exhaustive] -#[derive(Debug, Clone)] -pub enum CommandKind { - Shell(String), - ReadFile(PathBuf), - WriteFile(PathBuf, String), +pub enum Command { + Shell { + command: String, + current_dir: Option, + }, + ReadFile { + path: PathBuf, + current_dir: Option, + }, + WriteFile { + path: PathBuf, + content: String, + current_dir: Option, + }, } impl Command { pub fn shell>(cmd: S) -> Self { - Self { - kind: CommandKind::Shell(cmd.into()), + Command::Shell { + command: cmd.into(), current_dir: None, } } pub fn read_file>(path: P) -> Self { - Self { - kind: CommandKind::ReadFile(path.into()), + Command::ReadFile { + path: path.into(), current_dir: None, } } pub fn write_file, S: Into>(path: P, content: S) -> Self { - Self { - kind: CommandKind::WriteFile(path.into(), content.into()), + Command::WriteFile { + path: path.into(), + content: content.into(), current_dir: None, } } @@ -208,17 +211,41 @@ impl Command { /// Executors may interpret relative paths in the context of their own /// working directory. pub fn with_current_dir>(mut self, path: P) -> Self { - self.current_dir = Some(path.into()); + self.current_dir(path); self } - pub fn current_dir(&self) -> Option<&Path> { - self.current_dir.as_deref() + /// Override the working directory using the `std::process::Command` + /// builder-lite style API. + pub fn current_dir>(&mut self, path: P) -> &mut Self { + let dir = Some(path.into()); + match self { + Command::Shell { current_dir, .. } + | Command::ReadFile { current_dir, .. } + | Command::WriteFile { current_dir, .. } => { + *current_dir = dir; + } + } + self } - /// Access the underlying command payload. - pub fn kind(&self) -> &CommandKind { - &self.kind + pub fn clear_current_dir(&mut self) -> &mut Self { + match self { + Command::Shell { current_dir, .. } + | Command::ReadFile { current_dir, .. } + | Command::WriteFile { current_dir, .. } => { + *current_dir = None; + } + } + self + } + + pub fn current_dir_path(&self) -> Option<&Path> { + match self { + Command::Shell { current_dir, .. } + | Command::ReadFile { current_dir, .. } + | Command::WriteFile { current_dir, .. } => current_dir.as_deref(), + } } } From be7569a445399b1206492000164a724d2957d363 Mon Sep 17 00:00:00 2001 From: Timon Vonk Date: Tue, 30 Sep 2025 21:00:19 +0200 Subject: [PATCH 4/7] Fix clippy --- swiftide-core/src/agent_traits.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/swiftide-core/src/agent_traits.rs b/swiftide-core/src/agent_traits.rs index eb651279d7..6e3399511c 100644 --- a/swiftide-core/src/agent_traits.rs +++ b/swiftide-core/src/agent_traits.rs @@ -40,6 +40,10 @@ pub trait ToolExecutor: Send + Sync + DynClone { ) -> Result>; /// Create a new executor that operates relative to the provided directory. + /// + /// # Errors + /// + /// Returns an error when the executor fails to adjust to the requested directory. fn current_dir(&self, path: &Path) -> Result; } @@ -53,6 +57,11 @@ pub trait DynToolExecutor: Send + Sync + DynClone { extensions: Option>, ) -> Result>; + /// Create a new executor scoped to `path`. + /// + /// # Errors + /// + /// Propagates failures encountered while preparing the new executor instance. fn current_dir(&self, path: &Path) -> Result, CommandError>; } @@ -210,6 +219,7 @@ impl Command { /// /// Executors may interpret relative paths in the context of their own /// working directory. + #[must_use] pub fn with_current_dir>(mut self, path: P) -> Self { self.current_dir(path); self From c8ee2b312c4ec955004654f91a50e446bc17f606 Mon Sep 17 00:00:00 2001 From: Timon Vonk Date: Sun, 19 Oct 2025 17:40:54 +0200 Subject: [PATCH 5/7] Scoped executor --- swiftide-agents/src/default_context.rs | 12 +- swiftide-agents/src/tasks/impls.rs | 6 +- swiftide-agents/src/tools/local_executor.rs | 23 +--- swiftide-core/src/agent_traits.rs | 128 +++++++++++--------- 4 files changed, 85 insertions(+), 84 deletions(-) diff --git a/swiftide-agents/src/default_context.rs b/swiftide-agents/src/default_context.rs index d3df572d69..e06dd4d1d9 100644 --- a/swiftide-agents/src/default_context.rs +++ b/swiftide-agents/src/default_context.rs @@ -18,7 +18,7 @@ use std::{ use anyhow::Result; use async_trait::async_trait; use swiftide_core::{ - AgentContext, Command, CommandError, CommandOutput, DynToolExecutor, MessageHistory, + AgentContext, Command, CommandError, CommandOutput, MessageHistory, ToolExecutor, }; use swiftide_core::{ ToolFeedback, @@ -42,7 +42,7 @@ pub struct DefaultContext { current_completions_ptr: Arc, /// The executor used to run tools. I.e. local, remote, docker - tool_executor: Arc, + tool_executor: Arc, /// Stop if last message is from the assistant stop_on_assistant: bool, @@ -56,7 +56,7 @@ impl Default for DefaultContext { message_history: Arc::new(Mutex::new(Vec::new())), completions_ptr: Arc::new(AtomicUsize::new(0)), current_completions_ptr: Arc::new(AtomicUsize::new(0)), - tool_executor: Arc::new(LocalExecutor::default()) as Arc, + tool_executor: Arc::new(LocalExecutor::default()) as Arc, stop_on_assistant: true, feedback_received: Arc::new(Mutex::new(HashMap::new())), } @@ -69,7 +69,7 @@ impl std::fmt::Debug for DefaultContext { .field("completion_history", &self.message_history) .field("completions_ptr", &self.completions_ptr) .field("current_completions_ptr", &self.current_completions_ptr) - .field("tool_executor", &"Arc") + .field("tool_executor", &"Arc") .field("stop_on_assistant", &self.stop_on_assistant) .finish() } @@ -77,7 +77,7 @@ impl std::fmt::Debug for DefaultContext { impl DefaultContext { /// Create a new context with a custom executor - pub fn from_executor>>(executor: T) -> DefaultContext { + pub fn from_executor>>(executor: T) -> DefaultContext { DefaultContext { tool_executor: executor.into(), ..Default::default() @@ -203,7 +203,7 @@ impl AgentContext for DefaultContext { self.tool_executor.exec_cmd(cmd).await } - fn executor(&self) -> &Arc { + fn executor(&self) -> &Arc { &self.tool_executor } diff --git a/swiftide-agents/src/tasks/impls.rs b/swiftide-agents/src/tasks/impls.rs index 7d153c1b9d..366f8cfffb 100644 --- a/swiftide-agents/src/tasks/impls.rs +++ b/swiftide-agents/src/tasks/impls.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use async_trait::async_trait; use swiftide_core::{ - ChatCompletion, Command, CommandError, CommandOutput, DynToolExecutor, SimplePrompt, + ChatCompletion, Command, CommandError, CommandOutput, SimplePrompt, ToolExecutor, chat_completion::{ChatCompletionRequest, ChatCompletionResponse, errors::LanguageModelError}, prompt::Prompt, }; @@ -123,7 +123,7 @@ impl TaskNode for Arc { } #[async_trait] -impl TaskNode for Box { +impl TaskNode for Box { type Input = Command; type Output = CommandOutput; @@ -142,7 +142,7 @@ impl TaskNode for Box { } #[async_trait] -impl TaskNode for Arc { +impl TaskNode for Arc { type Input = Command; type Output = CommandOutput; diff --git a/swiftide-agents/src/tools/local_executor.rs b/swiftide-agents/src/tools/local_executor.rs index 21fed17dc3..0357424ac8 100644 --- a/swiftide-agents/src/tools/local_executor.rs +++ b/swiftide-agents/src/tools/local_executor.rs @@ -234,8 +234,6 @@ impl LocalExecutor { } #[async_trait] impl ToolExecutor for LocalExecutor { - type Owned = LocalExecutor; - /// Execute a `Command` on the local machine #[tracing::instrument(skip_self)] async fn exec_cmd(&self, cmd: &Command) -> Result { @@ -263,19 +261,6 @@ impl ToolExecutor for LocalExecutor { Ok(loader.into_stream()) } - - fn current_dir(&self, path: &Path) -> Result { - let new_workdir = if path.is_absolute() { - path.to_path_buf() - } else { - self.workdir.join(path) - }; - - let mut executor = self.clone(); - executor.workdir = new_workdir; - - Ok(executor) - } } #[cfg(test)] @@ -284,7 +269,7 @@ mod tests { use futures_util::StreamExt as _; use indoc::indoc; use std::{path::Path, sync::Arc}; - use swiftide_core::{Command, ToolExecutor}; + use swiftide_core::{Command, ExecutorExt, ToolExecutor}; use temp_dir::TempDir; #[tokio::test] @@ -672,7 +657,7 @@ print(1 + 2)"#; ..Default::default() }; - let nested = executor.current_dir(Path::new("nested"))?; + let nested = executor.clone().scoped("nested"); nested .exec_cmd(&Command::write_file("file.txt", "hello")) .await?; @@ -694,8 +679,8 @@ print(1 + 2)"#; ..Default::default() }; - let dyn_exec: Arc = Arc::new(executor.clone()); - let nested = dyn_exec.current_dir(Path::new("nested"))?; + let dyn_exec: Arc = Arc::new(executor.clone()); + let nested = dyn_exec.clone().scoped("nested"); nested .exec_cmd(&Command::write_file("nested_file.txt", "hello")) diff --git a/swiftide-core/src/agent_traits.rs b/swiftide-core/src/agent_traits.rs index 6e3399511c..06a24fcebb 100644 --- a/swiftide-core/src/agent_traits.rs +++ b/swiftide-core/src/agent_traits.rs @@ -26,9 +26,6 @@ use thiserror::Error; /// Additionally, the executor can be used stream files files for indexing. #[async_trait] pub trait ToolExecutor: Send + Sync + DynClone { - /// Owned executor type returned by `current_dir`. - type Owned: ToolExecutor + Send + Sync + DynClone + 'static; - /// Execute a command in the executor async fn exec_cmd(&self, cmd: &Command) -> Result; @@ -38,42 +35,66 @@ pub trait ToolExecutor: Send + Sync + DynClone { path: &Path, extensions: Option>, ) -> Result>; +} - /// Create a new executor that operates relative to the provided directory. - /// - /// # Errors - /// - /// Returns an error when the executor fails to adjust to the requested directory. - fn current_dir(&self, path: &Path) -> Result; +dyn_clone::clone_trait_object!(ToolExecutor); + +#[derive(Debug, Clone)] +pub struct ScopedExecutor { + executor: E, + scope: PathBuf, } -#[async_trait] -pub trait DynToolExecutor: Send + Sync + DynClone { - async fn exec_cmd(&self, cmd: &Command) -> Result; +impl ScopedExecutor { + pub fn new(executor: E, scope: impl Into) -> Self { + Self { + executor, + scope: scope.into(), + } + } - async fn stream_files( - &self, - path: &Path, - extensions: Option>, - ) -> Result>; + fn apply_scope(&self, cmd: &Command) -> Command { + let mut scoped = cmd.clone(); + match scoped.current_dir_path() { + Some(path) if path.is_absolute() => scoped, + Some(path) => { + scoped.current_dir(self.scope.join(path)); + scoped + } + None => { + if !self.scope.as_os_str().is_empty() { + scoped.current_dir(self.scope.clone()); + } + scoped + } + } + } - /// Create a new executor scoped to `path`. - /// - /// # Errors - /// - /// Propagates failures encountered while preparing the new executor instance. - fn current_dir(&self, path: &Path) -> Result, CommandError>; -} + fn scoped_path(&self, path: &Path) -> PathBuf { + if path.is_absolute() || self.scope.as_os_str().is_empty() { + path.to_path_buf() + } else { + self.scope.join(path) + } + } + + pub fn inner(&self) -> &E { + &self.executor + } -dyn_clone::clone_trait_object!(DynToolExecutor); + pub fn scope(&self) -> &Path { + &self.scope + } +} #[async_trait] -impl DynToolExecutor for T +impl ToolExecutor for ScopedExecutor where - T: ToolExecutor + Send + Sync + DynClone + 'static, + E: ToolExecutor + Send + Sync + Clone, { async fn exec_cmd(&self, cmd: &Command) -> Result { - ToolExecutor::exec_cmd(self, cmd).await + let scoped_cmd = self.apply_scope(cmd); + self.executor.exec_cmd(&scoped_cmd).await } async fn stream_files( @@ -81,19 +102,26 @@ where path: &Path, extensions: Option>, ) -> Result> { - ToolExecutor::stream_files(self, path, extensions).await + let scoped_path = self.scoped_path(path); + self.executor.stream_files(&scoped_path, extensions).await } +} - fn current_dir(&self, path: &Path) -> Result, CommandError> { - let owned = ToolExecutor::current_dir(self, path)?; - Ok(Arc::new(owned) as Arc) +pub trait ExecutorExt: ToolExecutor + Sized { + fn scoped(self, path: impl Into) -> ScopedExecutor { + ScopedExecutor::new(self, path) } } +impl ExecutorExt for T where T: ToolExecutor + Sized {} + #[async_trait] -impl DynToolExecutor for Arc { +impl ToolExecutor for &T +where + T: ToolExecutor + ?Sized, +{ async fn exec_cmd(&self, cmd: &Command) -> Result { - self.as_ref().exec_cmd(cmd).await + (**self).exec_cmd(cmd).await } async fn stream_files( @@ -101,16 +129,12 @@ impl DynToolExecutor for Arc { path: &Path, extensions: Option>, ) -> Result> { - self.as_ref().stream_files(path, extensions).await - } - - fn current_dir(&self, path: &Path) -> Result, CommandError> { - self.as_ref().current_dir(path) + (**self).stream_files(path, extensions).await } } #[async_trait] -impl DynToolExecutor for Box { +impl ToolExecutor for Arc { async fn exec_cmd(&self, cmd: &Command) -> Result { self.as_ref().exec_cmd(cmd).await } @@ -122,16 +146,12 @@ impl DynToolExecutor for Box { ) -> Result> { self.as_ref().stream_files(path, extensions).await } - - fn current_dir(&self, path: &Path) -> Result, CommandError> { - self.as_ref().current_dir(path) - } } #[async_trait] -impl DynToolExecutor for &dyn DynToolExecutor { +impl ToolExecutor for Box { async fn exec_cmd(&self, cmd: &Command) -> Result { - (**self).exec_cmd(cmd).await + self.as_ref().exec_cmd(cmd).await } async fn stream_files( @@ -139,11 +159,7 @@ impl DynToolExecutor for &dyn DynToolExecutor { path: &Path, extensions: Option>, ) -> Result> { - (**self).stream_files(path, extensions).await - } - - fn current_dir(&self, path: &Path) -> Result, CommandError> { - (**self).current_dir(path) + self.as_ref().stream_files(path, extensions).await } } @@ -371,7 +387,7 @@ pub trait AgentContext: Send + Sync { #[deprecated(note = "use executor instead")] async fn exec_cmd(&self, cmd: &Command) -> Result; - fn executor(&self) -> &Arc; + fn executor(&self) -> &Arc; async fn history(&self) -> Result>; @@ -411,7 +427,7 @@ impl AgentContext for Box { (**self).exec_cmd(cmd).await } - fn executor(&self) -> &Arc { + fn executor(&self) -> &Arc { (**self).executor() } @@ -455,7 +471,7 @@ impl AgentContext for Arc { (**self).exec_cmd(cmd).await } - fn executor(&self) -> &Arc { + fn executor(&self) -> &Arc { (**self).executor() } @@ -499,7 +515,7 @@ impl AgentContext for &dyn AgentContext { (**self).exec_cmd(cmd).await } - fn executor(&self) -> &Arc { + fn executor(&self) -> &Arc { (**self).executor() } @@ -547,7 +563,7 @@ impl AgentContext for () { ))) } - fn executor(&self) -> &Arc { + fn executor(&self) -> &Arc { unimplemented!("Empty agent context does not have a tool executor") } From d61f3d83098a94ebc5ad415d8271e1cad487f99c Mon Sep 17 00:00:00 2001 From: Timon Vonk Date: Sun, 19 Oct 2025 17:52:23 +0200 Subject: [PATCH 6/7] Scoped ref --- swiftide-agents/src/tools/local_executor.rs | 4 +- swiftide-core/src/agent_traits.rs | 50 ++++++++++++--------- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/swiftide-agents/src/tools/local_executor.rs b/swiftide-agents/src/tools/local_executor.rs index 0357424ac8..aa7a0c479e 100644 --- a/swiftide-agents/src/tools/local_executor.rs +++ b/swiftide-agents/src/tools/local_executor.rs @@ -657,7 +657,7 @@ print(1 + 2)"#; ..Default::default() }; - let nested = executor.clone().scoped("nested"); + let nested = executor.scoped("nested"); nested .exec_cmd(&Command::write_file("file.txt", "hello")) .await?; @@ -680,7 +680,7 @@ print(1 + 2)"#; }; let dyn_exec: Arc = Arc::new(executor.clone()); - let nested = dyn_exec.clone().scoped("nested"); + let nested = dyn_exec.scoped("nested"); nested .exec_cmd(&Command::write_file("nested_file.txt", "hello")) diff --git a/swiftide-core/src/agent_traits.rs b/swiftide-core/src/agent_traits.rs index 06a24fcebb..63f90dea04 100644 --- a/swiftide-core/src/agent_traits.rs +++ b/swiftide-core/src/agent_traits.rs @@ -1,4 +1,5 @@ use std::{ + borrow::Cow, path::{Path, PathBuf}, sync::{Arc, Mutex}, }; @@ -53,28 +54,30 @@ impl ScopedExecutor { } } - fn apply_scope(&self, cmd: &Command) -> Command { - let mut scoped = cmd.clone(); - match scoped.current_dir_path() { - Some(path) if path.is_absolute() => scoped, + fn apply_scope<'a>(&'a self, cmd: &'a Command) -> Cow<'a, Command> { + match cmd.current_dir_path() { + Some(path) if path.is_absolute() || self.scope.as_os_str().is_empty() => { + Cow::Borrowed(cmd) + } Some(path) => { + let mut scoped = cmd.clone(); scoped.current_dir(self.scope.join(path)); - scoped + Cow::Owned(scoped) } + None if self.scope.as_os_str().is_empty() => Cow::Borrowed(cmd), None => { - if !self.scope.as_os_str().is_empty() { - scoped.current_dir(self.scope.clone()); - } - scoped + let mut scoped = cmd.clone(); + scoped.current_dir(self.scope.clone()); + Cow::Owned(scoped) } } } - fn scoped_path(&self, path: &Path) -> PathBuf { + fn scoped_path<'a>(&'a self, path: &'a Path) -> Cow<'a, Path> { if path.is_absolute() || self.scope.as_os_str().is_empty() { - path.to_path_buf() + Cow::Borrowed(path) } else { - self.scope.join(path) + Cow::Owned(self.scope.join(path)) } } @@ -88,13 +91,13 @@ impl ScopedExecutor { } #[async_trait] -impl ToolExecutor for ScopedExecutor +impl<'a, E> ToolExecutor for ScopedExecutor<&'a E> where - E: ToolExecutor + Send + Sync + Clone, + E: ToolExecutor + Send + Sync + 'a, { async fn exec_cmd(&self, cmd: &Command) -> Result { let scoped_cmd = self.apply_scope(cmd); - self.executor.exec_cmd(&scoped_cmd).await + self.executor.exec_cmd(scoped_cmd.as_ref()).await } async fn stream_files( @@ -103,18 +106,25 @@ where extensions: Option>, ) -> Result> { let scoped_path = self.scoped_path(path); - self.executor.stream_files(&scoped_path, extensions).await + self.executor + .stream_files(scoped_path.as_ref(), extensions) + .await } } -pub trait ExecutorExt: ToolExecutor + Sized { - fn scoped(self, path: impl Into) -> ScopedExecutor { +pub trait ExecutorExt { + fn scoped(&self, path: impl Into) -> ScopedExecutor<&Self>; +} + +impl ExecutorExt for T +where + T: ToolExecutor + ?Sized, +{ + fn scoped(&self, path: impl Into) -> ScopedExecutor<&Self> { ScopedExecutor::new(self, path) } } -impl ExecutorExt for T where T: ToolExecutor + Sized {} - #[async_trait] impl ToolExecutor for &T where From 78c6090e556b9a07c853a8e14ffd01e5a17d42d6 Mon Sep 17 00:00:00 2001 From: Timon Vonk Date: Sun, 19 Oct 2025 18:05:57 +0200 Subject: [PATCH 7/7] Rust docs --- swiftide-core/src/agent_traits.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/swiftide-core/src/agent_traits.rs b/swiftide-core/src/agent_traits.rs index 63f90dea04..fc292e03b2 100644 --- a/swiftide-core/src/agent_traits.rs +++ b/swiftide-core/src/agent_traits.rs @@ -40,6 +40,10 @@ pub trait ToolExecutor: Send + Sync + DynClone { dyn_clone::clone_trait_object!(ToolExecutor); +/// Lightweight executor wrapper that applies a default working directory to forwarded commands. +/// +/// Most callers should construct this via [`ExecutorExt::scoped`], which borrows the underlying +/// executor and only clones commands/paths when the scope actually changes their resolution. #[derive(Debug, Clone)] pub struct ScopedExecutor { executor: E, @@ -47,6 +51,7 @@ pub struct ScopedExecutor { } impl ScopedExecutor { + /// Build a new wrapper around `executor` that prefixes relative paths with `scope`. pub fn new(executor: E, scope: impl Into) -> Self { Self { executor, @@ -54,6 +59,7 @@ impl ScopedExecutor { } } + /// Returns either the original command or a scoped clone depending on the current directory. fn apply_scope<'a>(&'a self, cmd: &'a Command) -> Cow<'a, Command> { match cmd.current_dir_path() { Some(path) if path.is_absolute() || self.scope.as_os_str().is_empty() => { @@ -73,6 +79,7 @@ impl ScopedExecutor { } } + /// Returns a path adjusted for the scope when the provided path is relative. fn scoped_path<'a>(&'a self, path: &'a Path) -> Cow<'a, Path> { if path.is_absolute() || self.scope.as_os_str().is_empty() { Cow::Borrowed(path) @@ -81,10 +88,12 @@ impl ScopedExecutor { } } + /// Access the inner executor. pub fn inner(&self) -> &E { &self.executor } + /// Expose the scope that will be applied to relative paths. pub fn scope(&self) -> &Path { &self.scope } @@ -112,7 +121,9 @@ where } } +/// Convenience methods for scoping executors without cloning them. pub trait ExecutorExt { + /// Borrow `self` and return a wrapper that resolves relative operations inside `path`. fn scoped(&self, path: impl Into) -> ScopedExecutor<&Self>; }