Skip to content

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:

Terminal window
cargo add swiftide@0.34 --features swiftide-agents
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:

  • workdir sets the directory commands start in. It defaults to .. LocalExecutor::new only sets this field.
  • default_timeout takes an Option<Duration> for commands without a timeout of their own.
  • env_remove drops variables, here secrets that the model’s commands do not need.
  • envs sets variables. They apply after env_clear and env_remove.
  • env_clear(true) (not shown) starts every command with an empty environment. That also drops PATH and HOME, so pass the ones your commands need back through envs.

The agent gets the executor through its context. Context and message history shows how DefaultContext::from_executor swaps it in.

ToolExecutor has three methods:

  • exec_cmd runs a Command and returns its CommandOutput or a CommandError.
  • exec_cmd_streaming also hands each piece of output to a closure. The default implementation reports all output once the command has finished.
  • stream_files returns the files below a path as an IndexingStream for an indexing pipeline; see Loading.

A tool reaches the executor with context.executor(). AgentContext::exec_cmd is deprecated.

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.

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.

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_dir sets the working directory of the command.
  • with_timeout sets the timeout of the command. It wins over default_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.

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::NonZeroExit holds 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 becomes NonZeroExit, with the error message as output.
  • CommandError::TimedOut holds 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::ExecutorError means 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.

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.

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_dir joins onto the scope. The local executor then joins the result onto its workdir.
  • An absolute current_dir is left alone, and so is an absolute file path. A scope does not keep a tool inside it.
  • stream_files joins a relative path onto the scope. The local executor does not add its workdir to that path; see the gotchas below.

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.

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.

  • DefaultContext::from_executor takes Into<Arc<dyn ToolExecutor>>. A bare LocalExecutor does not convert, so wrap it in an Arc annotated as Arc<dyn ToolExecutor>, as the scoped example does.
  • LocalExecutor::stream_files uses the path as given, not relative to workdir. 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.

Lifecycle hooks