Skip to content

Tools

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:

Terminal window
cargo add swiftide@0.34 --features swiftide-agents,openai
cargo add serde --features derive
cargo add schemars@1 async-trait
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

#[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.

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

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

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

/// 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

  • &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.

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

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

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.

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

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

Then return that schema from tool_spec:

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

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

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.
  • FeedbackRequired(Option<Value>): stops the agent with StopReason::FeedbackRequired and leaves the tool call open. See Human in the loop.
  • AgentFailed(Option<Value>): stops the agent with StopReason::AgentFailed.

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

/// 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

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:

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

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.

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

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

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

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

Context and message history