# Tools

> Write agent tools as async functions with #[swiftide::tool], as stateful structs with #[derive(Tool)], or by implementing Tool by hand with your own schema, and control how tool errors are retried.

You end up with three kinds of tool: async functions the model can call, a tool with its own state, and a hand-written tool with a custom argument schema. The examples come from the release notes agent in `examples/release-notes-agent`, which drafts release notes from git history.

Add the dependencies the macros expect:

```sh
cargo add swiftide@0.34 --features swiftide-agents,openai
cargo add serde --features derive
cargo add schemars@1 async-trait
```

```rust title="examples/release-notes-agent/src/tools.rs"
/// Lists every commit since a release tag, oldest first: hash, subject, body and the files it
/// changed.
///
/// # Arguments
/// - `tag`: Tag to start from, e.g. `v0.33.0`. Use null for the latest tag.
#[swiftide::tool]
pub async fn git_log_since(
    context: &dyn AgentContext,
    tag: &Option<String>,
) -> Result<ToolOutput, ToolError> {
    let git = Git::new(context.executor().as_ref());
    let since = match tag {
        Some(tag) => tag.parse()?,
        None => git.latest_tag().await?,
    };
    let log = git.log_since(&since).await?;

    Ok(ToolOutput::text(clip(
        log,
        MAX_LOG_LINES,
        "Use git_diff on a specific path for details.",
    )))
}

/// Shows the diff of one file or directory over a revision range.
///
/// # Arguments
/// - `range`: Revision range, e.g. `v0.33.0..HEAD`, or a single commit hash.
/// - `path`: File or directory relative to the repository root.
#[swiftide::tool]
pub async fn git_diff(
    context: &dyn AgentContext,
    range: &str,
    path: &str,
) -> Result<ToolOutput, ToolError> {
    let (range, path): (Revision, RepoPath) = (range.parse()?, path.parse()?);
    let diff = Git::new(context.executor().as_ref())
        .diff(&range, &path)
        .await?;
    if diff.trim().is_empty() {
        return Ok(ToolOutput::text(format!(
            "No changes to {path} in {range}."
        )));
    }

    Ok(ToolOutput::text(clip(
        diff,
        MAX_DIFF_LINES,
        "Diff a narrower path to see the rest.",
    )))
}

/// Reads a file from the repository, e.g. to check a public API or the style of the changelog.
///
/// # Arguments
/// - `path`: File path relative to the repository root.
#[swiftide::tool]
pub async fn read_file(context: &dyn AgentContext, path: &str) -> Result<ToolOutput, ToolError> {
    let path: RepoPath = path.parse()?;
    let content = context
        .executor()
        .exec_cmd(&Command::read_file(path.as_path()))
        .await?;

    Ok(ToolOutput::text(clip(
        content.into_string_lossy(),
        MAX_FILE_LINES,
        "The file is longer than shown.",
    )))
}
```

Source: [`examples/release-notes-agent/src/tools.rs#L32-L101`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/release-notes-agent/src/tools.rs#L32-L101)

## Function tools

`#[swiftide::tool]` turns an async function into a tool. The first argument must be `&dyn AgentContext`. Every other argument becomes a tool argument that the model fills in.

The macro generates three items from `git_log_since`:

- a struct `GitLogSince` that implements `Tool`,
- a function `git_log_since()` that returns it as a `Box<dyn Tool>`, ready for `AgentBuilder::tools`,
- an argument struct `GitLogSinceArgs` that derives serde and `JsonSchema`. Its schema forbids extra properties, but deserialization ignores keys it does not know.

The tool name the model sees is the function name, `git_log_since`. Since 0.33, the description is the doc comment text before the first heading. Each item under `# Arguments` describes one argument. A `# Parameters` heading works the same way.

The tools parse the model's strings into `Revision` and `RepoPath` before they call `context.executor()`, because the `LocalExecutor` in this example does not sandbox anything. See [Executors](/agents/executors/).

A hook printed the first tool call of a real run with `claude-sonnet-5-5`:

```text
Drafting release notes for the 7 commits since v0.33.0 with claude-sonnet-5-5 at http://127.0.0.1:8080/v1 (chat completions)
  → git_log_since v0.33.0
  git_log_since ✓ 346 lines
```

## Argument types

An argument type must implement serde's `Serialize` and `Deserialize`, `schemars::JsonSchema` and `Debug`, because the generated argument struct derives all four. The macro builds the JavaScript Object Notation (JSON) Schema of the arguments with `schemars::schema_for!`:

```rust
/// An inclusive range of lines.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct LineRange {
    /// First line, starting at 1
    pub start: u32,
    /// Last line, inclusive
    pub end: u32,
}

/// Reads line ranges from files in the workspace.
///
/// # Arguments
/// - `paths`: Files to read, relative to the workspace root.
/// - `range`: Optional line range to limit the output. Use null for the whole file.
#[swiftide::tool]
async fn read_lines(
    context: &dyn AgentContext,
    paths: Vec<String>,
    range: Option<LineRange>,
) -> Result<ToolOutput, ToolError> {
    let _ = (context, range);
    Ok(ToolOutput::text(paths.join("\n")))
}
```

Source: [`snippets/src/agents/tools.rs#L57-L79`](https://github.com/bosun-ai/swiftide-website/blob/master/snippets/src/agents/tools.rs#L57-L79)

- `&str`, `&[T]`, `&Vec<T>` and `&Option<T>` are deserialized as their owned types. Take any other type, such as `LineRange`, by value.
- An `Option` argument is optional. The OpenAI integration marks every property as required, so there the model sends `null` instead of leaving the argument out. The `range` description tells the model so.
- The doc comments of a struct argument become field descriptions.
- A map type such as `HashMap` compiles, but the generated `tool_spec` panics when the agent builds its first request.

Arguments that do not parse never reach your function: the model gets a `ToolError::WrongArguments` back.

## Stateful tools

When a tool needs configuration, derive `Tool` on a struct:

```rust title="examples/release-notes-agent/src/tools.rs"
/// Prepends the approved release notes to the release notes file.
///
/// The file is fixed by the CLI (`--output`), never chosen by the model. Wrap this tool in
/// `ApprovalRequired` so a human signs off before anything is written.
#[derive(Clone, Tool)]
#[tool(
    description = "Prepends the finished release notes to the release notes file. A maintainer \
                   reviews the notes before they are written.",
    param(
        name = "markdown",
        description = "The complete release notes for this version, as GitHub flavored Markdown"
    )
)]
pub struct WriteChangelog {
    file: PathBuf,
}

impl WriteChangelog {
    pub fn new(file: impl Into<PathBuf>) -> Self {
        Self { file: file.into() }
    }

    async fn write_changelog(
        &self,
        context: &dyn AgentContext,
        markdown: &str,
    ) -> Result<ToolOutput, ToolError> {
        let executor = context.executor();
        // A missing file is started; any other failure must not overwrite the existing notes.
        let existing = if file_exists(executor.as_ref(), &self.file).await? {
            executor
                .exec_cmd(&Command::read_file(&self.file))
                .await
                .map(CommandOutput::into_string_lossy)?
        } else {
            String::new()
        };

        executor
            .exec_cmd(&Command::write_file(
                &self.file,
                prepend_notes(markdown, &existing),
            ))
            .await?;

        Ok(ToolOutput::text(format!("wrote {}", self.file.display())))
    }
}
```

Source: [`examples/release-notes-agent/src/tools.rs#L105-L152`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/release-notes-agent/src/tools.rs#L105-L152)

The derive calls the method named after the struct in snake case, here `write_changelog`. The model never chooses the file: it comes from the struct.

- The derive does not read doc comments. `description` is required, and each argument needs a `param`.
- A `param` without a type is a `String`, so the method takes `&str`.
- The struct must be `Clone`.

Register the tool with `add_tool(WriteChangelog::new(path))`, or pass `WriteChangelog::new(path).into()` to `tools`.

## Hand-written tools

Implement `Tool` yourself to write the tool spec by hand and parse the arguments in your own code. Describe the arguments as a type:

```rust
#[derive(Clone)]
struct WorkflowTool;

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[schemars(
    title = "WorkflowInstruction",
    description = "Choose a workflow action and optional payload",
    deny_unknown_fields
)]
struct WorkflowInstruction {
    #[schemars(description = "Which workflow action to execute")]
    action: WorkflowAction,
    #[schemars(description = "Optional payload forwarded to the workflow engine")]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    payload: Option<Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
enum WorkflowAction {
    Start,
    Stop,
    Status,
}
```

Source: [`examples/tool_custom_schema.rs#L11-L34`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/tool_custom_schema.rs#L11-L34)

Then return that schema from `tool_spec`:

```rust
#[swiftide::reexports::async_trait::async_trait]
impl Tool for WorkflowTool {
    async fn invoke(
        &self,
        _agent_context: &dyn AgentContext,
        _tool_call: &ToolCall,
    ) -> Result<ToolOutput, ToolError> {
        Ok(ToolOutput::text(
            "Workflow execution not implemented in this example",
        ))
    }

    fn name<'tool>(&'tool self) -> Cow<'tool, str> {
        Cow::Borrowed("workflow_tool")
    }

    fn tool_spec(&self) -> ToolSpec {
        ToolSpec::builder()
            .name("workflow_tool")
            .description("Executes a workflow action with strict input choices")
            .parameters_schema(workflow_schema())
            .build()
            .expect("tool spec should be valid")
    }
}

fn workflow_schema() -> Schema {
    schema_for!(WorkflowInstruction)
}
```

Source: [`examples/tool_custom_schema.rs#L38-L66`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/tool_custom_schema.rs#L38-L66)

`invoke` receives the `ToolCall`, whose `args()` is the JSON string of the arguments. The agent drops duplicate keys and re-serializes the JSON first, so the string can differ from what the model sent. `name()` must match the `ToolSpec` name: the agent looks tools up by `name()`, the model calls the spec name.

`ToolSpec::builder().build()` returns a `Result`. It checks the schema against Swiftide's strict contract, so a `HashMap` field fails; see [Tool schema policies](/agents/providers-and-reasoning/#tool-schema-policies). The example calls `expect`, so an invalid schema panics at the first request. Cover it with a unit test that calls `tool_spec()`.

A hand-written tool can wrap another one. `OneEntryPerChange` in the same `tools.rs` delegates to `WriteChangelog` and rejects drafts that list a change twice.

## Tool output

A tool returns `Result<ToolOutput, ToolError>`. `ToolOutput` is non-exhaustive and has these variants:

- `Text(String)`: the result, added to the message history. `&str`, `String` and `CommandOutput` convert into it.
- `Fail(String)`: a failure the model can fix.
- `Stop(Option<Value>)`: stops the agent with `StopReason::RequestedByTool`. See [Stopping and structured results](/agents/stopping-and-structured-results/).
- `FeedbackRequired(Option<Value>)`: stops the agent with `StopReason::FeedbackRequired` and leaves the tool call open. See [Human in the loop](/agents/human-in-the-loop/).
- `AgentFailed(Option<Value>)`: stops the agent with `StopReason::AgentFailed`.

## Tool errors and retries

`ToolError` has four variants: `WrongArguments`, `MissingArguments`, `ExecutionFailed` and `Unknown`. It converts from `serde_json::Error`, `CommandError` and `anyhow::Error`, so `?` works.

```rust
/// Runs the tests of one crate in the workspace and returns their output.
///
/// # Arguments
/// - `package`: Name of the crate to test, e.g. `swiftide-core`.
#[swiftide::tool]
async fn run_tests(context: &dyn AgentContext, package: &str) -> Result<ToolOutput, ToolError> {
    let is_crate_name = !package.is_empty()
        && package
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
    if !is_crate_name {
        return Ok(ToolOutput::fail(format!(
            "`{package}` is not a crate name. Use a package name from Cargo.toml."
        )));
    }

    let command = Command::shell(format!("cargo test --package {package}"));
    match context.executor().exec_cmd(&command).await {
        Ok(output) => Ok(output.into()),
        Err(CommandError::NonZeroExit(output)) => {
            Ok(ToolOutput::text(format!("Tests failed:\n{output}")))
        }
        Err(error) => Err(ToolError::execution_failed(error)),
    }
}
```

Source: [`snippets/src/agents/tools.rs#L11-L35`](https://github.com/bosun-ai/swiftide-website/blob/master/snippets/src/agents/tools.rs#L11-L35)

A bad crate name returns `Ok(ToolOutput::fail(..))`, and the model tries again. An executor error returns `Err`: the agent records it as a `Fail` output and counts the failure.

`tool_retry_limit` sets how many failures of the same call the agent feeds back to the model:

```rust
let mut agent = Agent::builder()
    .llm(openai)
    .tools([run_tests()])
    .tool_retry_limit(2)
    .build()?;

if let Err(error) = agent.query("Run the tests of swiftide-core.").await {
    if let Some(StopReason::ToolCallsOverLimit(call)) = agent.stop_reason() {
        eprintln!("gave up on `{}`: {error}", call.name());
    }
}
```

Source: [`snippets/src/agents/tools.rs#L41-L51`](https://github.com/bosun-ai/swiftide-website/blob/master/snippets/src/agents/tools.rs#L41-L51)

With a limit of 2, the third identical failure stops the agent. The full rules for `tool_retry_limit` and `limit` are on [Stopping and structured results](/agents/stopping-and-structured-results/#stop-reasons).

## Attribute descriptions

Instead of doc comments you can pass `description` and `param` attributes. An explicit attribute wins over the doc comment:

```rust
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct FormatTimestampRequest {
    /// Prefix to prepend to the timestamp.
    prefix: String,
    /// Timestamp to format.
    timestamp: String,
}

#[swiftide::tool(description = "Get the current UTC date and time in RFC3339 format")]
async fn current_utc_time(context: &dyn AgentContext) -> Result<ToolOutput, ToolError> {
    let command_output = context
        .executor()
        .exec_cmd(&Command::shell("date -u +\"%Y-%m-%dT%H:%M:%SZ\""))
        .await?;

    Ok(command_output.into())
}

#[swiftide::tool(
    description = "Format a timestamp with a caller-provided prefix",
    param(name = "request", description = "Timestamp formatting input")
)]
async fn format_timestamp(
    _context: &dyn AgentContext,
    request: FormatTimestampRequest,
) -> Result<ToolOutput, ToolError> {
    Ok(ToolOutput::text(format!(
        "{}{}",
        request.prefix, request.timestamp
    )))
}
```

Source: [`examples/aws_bedrock_agent.rs#L23-L53`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/aws_bedrock_agent.rs#L23-L53)

`description` also accepts a path to a `const`, such as `description = READ_FILE`.

## Gotchas

- The generated code refers to `::swiftide::` and `::serde` paths. The crate that uses the macros needs `swiftide` with the default `macros` feature flag and `serde` with `derive`.
- Every argument after the context must be listed under `# Arguments` or as a `param`. A doc bullet with an empty description is dropped, and a missing argument fails to compile with "parameters are missing from the spec".
- Tools are unique by name. `add_tool` replaces a tool with the same name, and `tools` replaces every tool added before it. To bring your own `stop`, see [Stopping and structured results](/agents/stopping-and-structured-results/).
- The tool calls of one completion run concurrently on separate Tokio tasks, so two calls to a tool that writes the same file can race.

## Reference

- [`#[swiftide::tool]`](https://docs.rs/swiftide/0.34/swiftide/attr.tool.html) and [`#[derive(Tool)]`](https://docs.rs/swiftide/0.34/swiftide/derive.Tool.html)
- [`Tool`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/trait.Tool.html)
- [`ToolSpec`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolSpec.html) and [`ToolSpecBuilder`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolSpecBuilder.html)
- [`ToolCall`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolCall.html)
- [`ToolOutput`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/enum.ToolOutput.html)
- [`ToolError`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/errors/enum.ToolError.html)
- [`AgentContext`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.AgentContext.html)
- [`AgentBuilder::tool_retry_limit`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html#method.tool_retry_limit)
- [`StopReason`](https://docs.rs/swiftide/0.34/swiftide/agents/enum.StopReason.html)

## Next

[Context and message history](/agents/context-and-history/)