# Tool executors

> Run agent tool commands through a ToolExecutor: a LocalExecutor with a working directory, timeout and environment, scoped executors, streamed output, and model arguments validated before they reach a shell.

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:

```sh
cargo add swiftide@0.34 --features swiftide-agents
```

## Local executor

```rust
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`](https://github.com/bosun-ai/swiftide-website/blob/master/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](/agents/context-and-history/) shows how `DefaultContext::from_executor` swaps it in.

## Tool executor trait

`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](/indexing/loading/).

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

### 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:

```rust
/// 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`](https://github.com/bosun-ai/swiftide-website/blob/master/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:

```rust
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`](https://github.com/bosun-ai/swiftide-website/blob/master/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

```rust
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`](https://github.com/bosun-ai/swiftide-website/blob/master/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

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.

## Command output

```rust
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`](https://github.com/bosun-ai/swiftide-website/blob/master/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](/agents/tools/#tool-errors-and-retries). Match `NonZeroExit` yourself when failing is a normal answer, like a failing test.

## Shebang scripts

```rust
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`](https://github.com/bosun-ai/swiftide-website/blob/master/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

```rust
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`](https://github.com/bosun-ai/swiftide-website/blob/master/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.

## 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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/release-notes-agent/src/git.rs). The same pattern for your own tool:

```rust
/// 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`](https://github.com/bosun-ai/swiftide-website/blob/master/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

For isolation, the [Docker executor](https://github.com/bosun-ai/swiftide-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

- `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.

## Reference

- [`ToolExecutor`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ToolExecutor.html)
- [`IndexingStream`](https://docs.rs/swiftide/0.34/swiftide/indexing/struct.IndexingStream.html)
- [`Command`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.Command.html)
- [`CommandOutput`](https://docs.rs/swiftide/0.34/swiftide/traits/struct.CommandOutput.html)
- [`CommandOutputChunk`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.CommandOutputChunk.html)
- [`CommandOutputSink`](https://docs.rs/swiftide/0.34/swiftide/traits/type.CommandOutputSink.html)
- [`CommandError`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.CommandError.html)
- [`ExecutorExt`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.ExecutorExt.html)
- [`ScopedExecutor`](https://docs.rs/swiftide/0.34/swiftide/traits/struct.ScopedExecutor.html)
- [`LocalExecutor`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/local_executor/struct.LocalExecutor.html)
- [`DefaultContext::from_executor`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.DefaultContext.html#method.from_executor)
- [`AgentContext::executor`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.AgentContext.html#tymethod.executor)

## Next

[Lifecycle hooks](/agents/hooks/)