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.
Dependencies
Section titled “Dependencies”Start from the crate on Installation, or create a new one with the same dependencies plus serde:
cargo new first-agentcd first-agentcargo add swiftide@0.34 --features swiftide-agents,openaicargo add tokio --features macros,rt-multi-threadcargo add anyhow serdeswiftide-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.
Program
Section titled “Program”The tool and the imports go at the top of 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:
#[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:
export OPENAI_API_KEY=sk-...git clone https://github.com/bosun-ai/swiftidecd swiftidecargo run --manifest-path ../first-agent/Cargo.toml -- v0.33.0The 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 loop
Section titled “Agent loop”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 returnsStopReason::RequestedByTool. - The model answers without calling a tool. The default context,
DefaultContext, ends the loop on such an answer, andstop_reason()returnsStopReason::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.
Showcase version
Section titled “Showcase version”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 linesIt also adds the timeout your program lacks. Every command runs at the repository root, with a one-minute default_timeout:
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.
Variations
Section titled “Variations”To print results too, add after_tool. It receives &mut Result<ToolOutput, ToolError> and can replace what the model sees.
Common mistakes
Section titled “Common mistakes”- 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 calladd_toolafter the last.tools(..). - Your own stop tool is dropped. Tools are kept by name, and a typed stop tool such as
StopWithArgsis also namedstop..tools(..)inserts the defaultstopfirst, so yours is discarded and the model sees only the default. Callwithout_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 perquery; the showcase uses 40.
Related pages
Section titled “Related pages”- Tools:
#[derive(Tool)]for tools with state, and argument types. - Hooks: every hook and what it receives.
- Executors:
LocalExecutor, timeouts and other executors. - Stopping and structured results: a stop tool that returns typed data.