Tool executors
Your tools run shell commands and file operations through a tool executor instead of spawning processes themselves. You end up with one that works in a chosen directory, stops slow commands, streams their output, and only receives arguments your code has checked.
The local executor needs the swiftide-agents feature flag:
cargo add swiftide@0.34 --features swiftide-agentsLocal executor
Section titled “Local executor”let executor = LocalExecutor::builder() .workdir("/srv/checkout") .default_timeout(Some(Duration::from_secs(30))) .env_remove(vec!["OPENAI_API_KEY".to_owned(), "GITHUB_TOKEN".to_owned()]) .envs(HashMap::from([( "CARGO_TERM_COLOR".to_owned(), "never".to_owned(), )])) .build()?;Source: snippets/src/agents/executors.rs#L27-L35
The builder sets where and how commands run:
workdirsets the directory commands start in. It defaults to..LocalExecutor::newonly sets this field.default_timeouttakes anOption<Duration>for commands without a timeout of their own.env_removedrops variables, here secrets that the model’s commands do not need.envssets variables. They apply afterenv_clearandenv_remove.env_clear(true)(not shown) starts every command with an empty environment. That also dropsPATHandHOME, so pass the ones your commands need back throughenvs.
The agent gets the executor through its context. Context and message history shows how DefaultContext::from_executor swaps it in.
Tool executor trait
Section titled “Tool executor trait”ToolExecutor has three methods:
exec_cmdruns aCommandand returns itsCommandOutputor aCommandError.exec_cmd_streamingalso hands each piece of output to a closure. The default implementation reports all output once the command has finished.stream_filesreturns the files below a path as anIndexingStreamfor an indexing pipeline; see Loading.
A tool reaches the executor with context.executor(). AgentContext::exec_cmd is deprecated.
Custom executor
Section titled “Custom executor”You write your own tool executor to run commands somewhere else, such as a remote machine, or to put a policy in front of another executor. This one wraps any executor and refuses file writes:
/// Runs commands through another executor, but refuses to write files.#[derive(Clone)]pub struct ReadOnly<E> { inner: E,}
impl<E> ReadOnly<E> { fn check(cmd: &Command) -> Result<(), CommandError> { if matches!(cmd, Command::WriteFile { .. }) { return Err(CommandError::ExecutorError(anyhow::anyhow!( "this executor is read-only" ))); } Ok(()) }}
#[async_trait]impl<E: ToolExecutor + Clone> ToolExecutor for ReadOnly<E> { async fn exec_cmd(&self, cmd: &Command) -> Result<CommandOutput, CommandError> { Self::check(cmd)?; self.inner.exec_cmd(cmd).await }
async fn exec_cmd_streaming( &self, cmd: &Command, output: &mut CommandOutputSink<'_>, ) -> Result<CommandOutput, CommandError> { Self::check(cmd)?; self.inner.exec_cmd_streaming(cmd, output).await }
async fn stream_files( &self, path: &Path, extensions: Option<Vec<String>>, ) -> anyhow::Result<IndexingStream<String>> { self.inner.stream_files(path, extensions).await }}Source: snippets/src/agents/executors.rs#L142-L182
The trait uses #[async_trait], so add the async-trait crate. The trait extends DynClone, so your type must implement Clone. exec_cmd and stream_files are required. ReadOnly forwards exec_cmd_streaming as well; without it, the default would run exec_cmd and report the output only at the end.
Command is non-exhaustive, so match it with a wildcard arm or matches!. Return CommandError::ExecutorError when the executor refuses or fails, and NonZeroExit when the command ran and failed. An executor that cannot stream can return IndexingStream::from(anyhow::Error), so a loader reports the error instead of panicking.
Give the agent your executor the same way as a local one:
let executor: Arc<dyn ToolExecutor> = Arc::new(ReadOnly { inner: LocalExecutor::new("/srv/checkout"),});let agent = Agent::builder() .llm(&openai) .context(DefaultContext::from_executor(executor)) .build()?;Source: snippets/src/agents/executors.rs#L188-L194
ReadOnly only blocks Command::WriteFile. A shell string such as echo hi > notes.md still writes, so a real policy also has to check or forbid Command::Shell.
Output streaming
Section titled “Output streaming”let (chunks, mut received) = tokio::sync::mpsc::unbounded_channel();let printer = tokio::spawn(async move { while let Some(chunk) = received.recv().await { match chunk { CommandOutputChunk::Stdout(bytes) => print!("{}", String::from_utf8_lossy(&bytes)), CommandOutputChunk::Stderr(bytes) => eprint!("{}", String::from_utf8_lossy(&bytes)), } }});
let cmd = Command::shell("cargo test").with_timeout(Duration::from_secs(600));let result = executor .exec_cmd_streaming(&cmd, &mut |chunk: &CommandOutputChunk| { // The sink runs inside the executor's read loop: hand the chunk off, never block. let _ = chunks.send(chunk.clone()); }) .await;drop(chunks);printer.await?;
let output = result?;println!("{} bytes of output", output.len());Source: snippets/src/agents/executors.rs#L73-L94
exec_cmd_streaming passes each chunk to a sink while the command runs. The sink is a CommandOutputSink: a FnMut(&CommandOutputChunk) + Send closure. The executor calls the sink from its read loop, so hand each chunk off instead of doing work in it. The local executor streams shell commands and reports file commands once they finish. The return value matches exec_cmd.
Commands
Section titled “Commands”You build a Command with Command::shell, Command::read_file or Command::write_file, as in the streaming example above and the tool further down. Command::shell runs a string with sh -c. The file commands skip the shell, so a path never needs quoting.
Two methods set options on one command:
with_current_dirsets the working directory of the command.with_timeoutsets the timeout of the command. It wins overdefault_timeout, also when it is longer.
How a relative directory or path resolves is up to the executor. The local executor joins a relative current_dir onto its workdir and uses an absolute one as it is. File paths resolve against the result. Its write_file creates missing parent directories and replaces the file’s content.
Command output
Section titled “Command output”let cmd = Command::shell("cargo test").with_current_dir("crates/core");match context.executor().exec_cmd(&cmd).await { Ok(output) => { let stdout: Vec<u8> = output.stdout().flat_map(|b| b.iter().copied()).collect(); println!("{}", String::from_utf8_lossy(&stdout)); } Err(CommandError::NonZeroExit(output)) => { for chunk in output.stderr() { eprintln!("{}", String::from_utf8_lossy(chunk)); } } Err(other) => return Err(other.into()),}Source: snippets/src/reference/upgrading.rs#L87-L99
CommandOutput keeps stdout and stderr apart. It holds CommandOutputChunk::Stdout and CommandOutputChunk::Stderr chunks in the order the executor read them. stdout() and stderr() iterate over one stream; to_string_lossy() and Display join both. A CommandOutput converts into a ToolOutput with the joined text.
A failed command returns an error that still carries its output:
CommandError::NonZeroExitholds the output of a command that exited with a non-zero code. An input/output (I/O) error, such as reading a missing file, also becomesNonZeroExit, with the error message as output.CommandError::TimedOutholds the timeout and the output read so far. For a shell command, the local executor kills the process (its process group on Unix). A file command that times out returns empty output.CommandError::ExecutorErrormeans the executor itself failed.
A tool that returns such an error with ? sends its text to the model and counts toward the tool retry limit. See Tool errors and retries. Match NonZeroExit yourself when failing is a normal answer, like a failing test.
Shebang scripts
Section titled “Shebang scripts”let script = "#!/usr/bin/env python3\nimport sys\nprint(sys.version_info.major)";let output = executor.exec_cmd(&Command::shell(script)).await?;println!("python {}", output.to_string_lossy().trim());Source: snippets/src/agents/executors.rs#L43-L45
When a shell command starts with #!, the local executor starts the interpreter named on that line and writes the rest of the string to its stdin. Everything after the interpreter on the first line is passed as one argument, so #!/usr/bin/env python3 runs /usr/bin/env with python3. A model can send a multi-line script without escaping it for sh -c.
Scoped executors
Section titled “Scoped executors”let checkout = LocalExecutor::new("/srv/checkout");
// Borrowed: one command in /srv/checkout/docs.let docs = checkout.scoped("docs");let listing = docs.exec_cmd(&Command::shell("ls")).await?;println!("{listing}");
// Owned: commands without an absolute directory or path run in /srv/checkout/crates/core.let executor: Arc<dyn ToolExecutor> = Arc::new(checkout.scoped_owned("crates/core"));let agent = Agent::builder() .llm(&openai) .context(DefaultContext::from_executor(executor)) .build()?;Source: snippets/src/agents/executors.rs#L53-L65
ExecutorExt adds scoped, which borrows the executor, and scoped_owned, which takes it so it can live as long as an agent. Both return a ScopedExecutor:
- A command without a working directory runs in the scope.
- A relative
current_dirjoins onto the scope. The local executor then joins the result onto itsworkdir. - An absolute
current_diris left alone, and so is an absolute file path. A scope does not keep a tool inside it. stream_filesjoins a relative path onto the scope. The local executor does not add itsworkdirto that path; see the gotchas below.
Argument validation
Section titled “Argument validation”A working directory is not a sandbox: it only chooses where a command starts. An absolute path, a .. or a ; in a shell string still reaches the rest of the machine. Parse model arguments into a type that cannot hold a bad value first. The release-notes-agent showcase validates every argument with Revision and RepoPath in src/git.rs. The same pattern for your own tool:
/// A path inside the executor's working directory: relative and without `..`.#[derive(Debug, Clone)]pub struct WorkspacePath(PathBuf);
impl FromStr for WorkspacePath { type Err = anyhow::Error;
fn from_str(raw: &str) -> anyhow::Result<Self> { let path = Path::new(raw.trim()); ensure!(!path.as_os_str().is_empty(), "path is empty"); for component in path.components() { match component { Component::Normal(_) | Component::CurDir => {} Component::ParentDir => bail!("`{raw}` must not contain `..`"), Component::RootDir | Component::Prefix(_) => { bail!("`{raw}` must be relative to the workspace root") } } } Ok(Self(path.to_path_buf())) }}
/// Counts the lines of a file in the workspace.////// # Arguments/// - `path`: File path relative to the workspace root.#[swiftide::tool]pub async fn count_lines(context: &dyn AgentContext, path: &str) -> Result<ToolOutput, ToolError> { let path: WorkspacePath = path.parse()?; let content = context .executor() .exec_cmd(&Command::read_file(&path.0)) .await?;
Ok(ToolOutput::text( content.to_string_lossy().lines().count().to_string(), ))}Source: snippets/src/agents/executors.rs#L100-L138
Prefer Command::read_file and Command::write_file over cat or > in a shell string: no shell ever sees the path.
Docker executor
Section titled “Docker executor”For isolation, the Docker executor runs the commands in a container. It is a separate crate; pass it to DefaultContext::from_executor, and check that its swiftide version matches yours.
Gotchas
Section titled “Gotchas”DefaultContext::from_executortakesInto<Arc<dyn ToolExecutor>>. A bareLocalExecutordoes not convert, so wrap it in anArcannotated asArc<dyn ToolExecutor>, as the scoped example does.LocalExecutor::stream_filesuses the path as given, not relative toworkdir. A relative path resolves against your process’s current directory, also through a scope. Scope it with an absolute path or pass an absolute path.- Symlinks inside the working directory can still point elsewhere. Validated paths do not change that; a container does.