Skip to content

Your first agent

This page builds a command-line program that asks a model to summarize the commits since a git tag. It is the first step of the release-notes-agent showcase: an agent with one tool that runs git log and one hook that prints each tool call.

Start from the crate on Installation, or create a new one with the same dependencies plus serde:

Terminal window
cargo new first-agent
cd first-agent
cargo add swiftide@0.34 --features swiftide-agents,openai
cargo add tokio --features macros,rt-multi-thread
cargo add anyhow serde

swiftide-agents adds swiftide::agents. The tool attribute comes from macros, which is on by default. Its generated code derives serde’s traits for the tool arguments, so your crate depends on serde.

The tool and the imports go at the top of src/main.rs:

src/main.rs
use anyhow::Context as _;
use swiftide::{
agents::Agent,
chat_completion::{ChatMessage, ToolOutput, errors::ToolError},
integrations::openai::OpenAI,
traits::{AgentContext, Command},
};
/// Lists the commits since a git tag, newest first, one per line.
///
/// # Arguments
/// - `tag`: The tag to start from, e.g. `v0.33.0`.
#[swiftide::tool]
async fn git_log_since(context: &dyn AgentContext, tag: &str) -> Result<ToolOutput, ToolError> {
// The model chooses the tag; refuse anything that is not a plain tag name.
let plain = |c: char| c.is_ascii_alphanumeric() || "._-/".contains(c);
if tag.starts_with('-') || !tag.chars().all(plain) {
return Ok(ToolOutput::fail(format!("`{tag}` is not a tag name")));
}
let log = Command::shell(format!("git log --oneline --end-of-options {tag}..HEAD"));
Ok(context.executor().exec_cmd(&log).await?.into())
}

Source: snippets/src/start/your_first_agent.rs#L4-L26

main builds the agent and prints its answer:

src/main.rs
#[tokio::main]
pub async fn main() -> anyhow::Result<()> {
let tag = std::env::args()
.nth(1)
.context("usage: first-agent <tag>")?;
// Reads OPENAI_API_KEY from the environment.
let openai = OpenAI::builder()
.default_prompt_model("gpt-5-mini")
.build()?;
let mut agent = Agent::builder()
.llm(&openai)
.system_prompt("You summarize git history for release notes. Be brief.")
.tools([git_log_since()])
.before_tool(|_agent, tool_call| {
println!(
" → {} {}",
tool_call.name(),
tool_call.args().unwrap_or_default()
);
Box::pin(async { Ok(()) })
})
.build()?;
agent
.query(format!("Summarize the commits since {tag}."))
.await?;
let history = agent.history().await?;
let summary = history.iter().rev().find_map(|message| match message {
ChatMessage::Assistant(Some(text), _) => Some(text.as_str()),
_ => None,
});
println!("{}", summary.unwrap_or("(no summary)"));
println!("stopped: {:?}", agent.stop_reason());
Ok(())
}

Source: snippets/src/start/your_first_agent.rs#L31-L68

The tool runs git in the directory you start the program from. That directory needs a git repository with the tag you pass. The repository cargo new created has no commits and no tags, so run the program from another repository, such as a clone of swiftide:

Terminal window
export OPENAI_API_KEY=sk-...
git clone https://github.com/bosun-ai/swiftide
cd swiftide
cargo run --manifest-path ../first-agent/Cargo.toml -- v0.33.0

The program prints one line per tool call, such as → git_log_since {"tag":"v0.33.0"}. Then it prints the model’s summary. The last line is stopped: Some(NoNewMessages) or stopped: Some(RequestedByTool(..)), because stop_reason() returns an Option.

#[swiftide::tool] turns the async function into a tool named git_log_since. The macro replaces your function: git_log_since() becomes a constructor that returns the tool as a Box<dyn Tool>, and your body moves to a method on a generated struct, so you cannot call the function directly any more. .tools([git_log_since()]) hands the tool to the agent. The doc comment becomes the tool description the model reads. The # Arguments list becomes the description of each argument.

Every tool takes &dyn AgentContext as its first argument. context.executor() returns the tool executor, the part that runs commands. Command::shell describes a shell command, and exec_cmd runs it.

The model chooses tag, so the tool checks it before it reaches the shell. A tag that starts with -, or holds anything but letters, digits, ., _, - and /, gets ToolOutput::fail. The model gets that message as the tool’s output and can try again. --end-of-options also stops git from reading the tag as a flag.

When git exits with a non-zero status, exec_cmd returns a CommandError, and ? turns it into a ToolError. The agent also sends that error back to the model. When a call with the same arguments fails more than tool_retry_limit times (3 by default), the agent stops with StopReason::ToolCallsOverLimit and query returns the error. On success, .into() turns the CommandOutput into ToolOutput::Text, with stdout and stderr in the order the executor saw them.

A hook is a closure that runs at a fixed point in the agent loop. before_tool runs before each tool call and receives &mut Agent and the ToolCall. Every hook returns a boxed future, written Box::pin(async { Ok(()) }). When a hook returns an error, the agent logs it with tracing and carries on.

Agent::builder() starts with one tool already registered: stop, which the model calls when it is done. .llm(&openai) clones the client into the agent; build fails without it. system_prompt takes anything that converts into a Prompt, such as a &str.

query adds your message to the message history and loops. Each completion either calls tools, whose outputs go into the history for the next completion, or answers in text. In a normal run the loop ends in one of two ways:

  • The model calls stop. stop_reason() then returns StopReason::RequestedByTool.
  • The model answers without calling a tool. The default context, DefaultContext, ends the loop on such an answer, and stop_reason() returns StopReason::NoNewMessages.

Errors, failed tool calls and limits end it with other stop reasons. history() returns every ChatMessage of the run. The program prints the last ChatMessage::Assistant that has text.

DefaultContext runs commands through a LocalExecutor in your working directory, without a sandbox: the tool can reach anything your user can. It sets no timeout either, so a git command that waits for a pager or a credential prompt blocks the agent.

The showcase prints its tool calls with the same kind of hook. This is the start of a real run on swiftide’s own history, from its OUTPUT.md. It used claude-sonnet-5-5 through an OpenAI-compatible gateway:

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

It also adds the timeout your program lacks. Every command runs at the repository root, with a one-minute default_timeout:

examples/release-notes-agent/src/main.rs
let root = Git::new(&LocalExecutor::new(&cli.repo))
.top_level()
.await
.with_context(|| format!("{} is not a git repository", cli.repo.display()))?;
// Every tool the agent runs, runs with the repository as its working directory.
let executor: Arc<dyn ToolExecutor> = Arc::new(
LocalExecutor::builder()
.workdir(&root)
.default_timeout(Some(Duration::from_mins(1)))
.build()?,
);

Source: examples/release-notes-agent/src/main.rs#L93-L103

The agent gets this executor through DefaultContext::from_executor. Use the same pattern to run your program against a fixed repository instead of the current directory.

To print results too, add after_tool. It receives &mut Result<ToolOutput, ToolError> and can replace what the model sees.

  • A second .tools(..) call drops the first list. .tools(..) replaces the whole set with the default tools plus the ones you pass. Pass every tool in one call, or call add_tool after the last .tools(..).
  • Your own stop tool is dropped. Tools are kept by name, and a typed stop tool such as StopWithArgs is also named stop. .tools(..) inserts the default stop first, so yours is discarded and the model sees only the default. Call without_default_stop_tool() before .tools(..), as the showcase does.
  • The model passes an argument you did not expect. Check model-chosen arguments before they reach a shell or a path.
  • The agent loops for a long time. .limit(n) caps the completions per query; the showcase uses 40.

Your first pipeline