Skip to content

How agents work

After this page you can build an agent, run it, continue its conversation, and read why it stopped. The larger example is the release-notes-agent showcase, which drafts release notes from git history. Agents live in swiftide::agents, behind the swiftide-agents feature flag:

Terminal window
cargo add swiftide@0.34 --features openai,swiftide-agents
cargo add tokio --features full
cargo add anyhow
// 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 answer questions about Rust crates in two sentences.")
.limit(10)
.build()?;
// A failed run, including a tool that fails too often, returns `Err` here.
agent.query("What does the tokio crate do?").await?;
assert!(agent.is_stopped());
match agent.stop_reason() {
Some(StopReason::RequestedByTool(..)) => println!("a tool stopped the agent"),
Some(StopReason::NoNewMessages) => println!("no new messages, or the limit was reached"),
other => println!("stopped: {other:?}"),
}
// A second query appends to the same message history and runs the loop again.
agent.query("And how does it compare to async-std?").await?;
println!("{} messages in the history", agent.history().await?.len());

Source: snippets/src/agents/overview.rs#L11-L34

OpenAI::builder() reads OPENAI_API_KEY from the environment.

llm takes any ChatCompletion that is Clone and clones it into the agent. build fails without it. system_prompt takes anything that converts into a Prompt. The agent has no system prompt unless you set one. limit caps the completions in one run.

query adds a user message and loops until the agent stops. A stopped agent keeps its message history, so the second query continues the same conversation. history returns every message in the context.

Agent loop

The agent sends the message history and the spec of every tool to the model. When the completion holds tool calls, the agent runs the tools and records their outputs in the history. Then it asks the model again. Hooks run around every step.

The loop ends when a tool stops the agent, or when the step limit is reached. With DefaultContext, it also ends when the last message is an answer without a tool call. DefaultContext::with_stop_on_assistant(false) turns that off.

A tool that returns ToolOutput::FeedbackRequired stops the agent and leaves its tool call open, without an output. A call to a tool the agent does not have is skipped with a warning and gets no output either.

agent.state() returns a State: Pending before the first run, Running during one, and Stopped with a StopReason after it. agent.stop_reason() tells you why it stopped:

  • a tool returned ToolOutput::Stop (RequestedByTool, which holds that tool call)
  • an answer without a tool call, the step limit, or the end of a query_once or run_once step (NoNewMessages)
  • a pending approval (FeedbackRequired)
  • a failure (ToolCallsOverLimit, AgentFailed, Error)

For ToolCallsOverLimit and Error, query also returns the error, so the ? in the snippet returns before the match. StopReason is marked non-exhaustive, so a match on it needs a wildcard arm. Stopping and structured results covers each reason.

query_once adds the message and runs one step. run and run_once do the same without a new message, which is how you resume after an approval. A run on an agent whose state is Running returns AgentError::AlreadyRunning.

Cloning an agent shares its hooks and context, so both clones write to the same message history. Each clone gets its own copy of the tools, so add_tool on one does not change the other. The clone copies the state and starts with an empty tool retry counter.

To run an agent as one step of a larger workflow, wrap it in a task node; see Agents as task nodes.

The release-notes-agent showcase builds its agent in one function:

examples/release-notes-agent/src/agent.rs
/// Builds the agent. All tools run through `executor`, whose working directory is the repo.
pub fn build(
llm: &(impl ChatCompletion + Clone + 'static),
executor: Arc<dyn ToolExecutor>,
task: &ReleaseTask,
github_tools: Option<impl ToolBox + 'static>,
) -> Result<Agent> {
let mut builder = Agent::builder();
builder
.llm(llm)
.context(DefaultContext::from_executor(executor))
.system_prompt(task.system_prompt(github_tools.is_some()))
// Our own `stop` tool replaces the default one; call this before `tools`.
.without_default_stop_tool()
.tools([
git_log_since(),
git_diff(),
read_file(),
// Drafts that describe a change twice go back to the model before anyone reviews them.
OneEntryPerChange::new(ApprovalRequired::new(WriteChangelog::new(&task.notes_file)))
.into(),
ReleaseNotes::stop_tool().into(),
])
.limit(STEP_LIMIT);
if let Some(github_tools) = github_tools {
builder.add_toolbox(github_tools);
}
progress::report_tool_calls(&mut builder);
progress::stream_draft(&mut builder);
Ok(builder.build()?)
}

Source: examples/release-notes-agent/src/agent.rs#L104-L136

The showcase passes an OpenAI client pointed at any OpenAI-compatible endpoint, but the function accepts any provider.

context sets where the message history lives and which tool executor runs commands. DefaultContext::from_executor keeps the history in memory and uses the executor that run creates:

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

Agent::builder() starts with one tool, stop, which the model calls when it is done. The showcase replaces it with a stop tool whose arguments are the release notes, so it calls without_default_stop_tool before tools. The list mixes tools from #[swiftide::tool] functions, a tool wrapped in ApprovalRequired, and a StopWithArgs built from a JavaScript Object Notation (JSON) schema.

add_toolbox adds the GitHub tools from a Model Context Protocol (MCP) server when you pass --mcp in a repository hosted on GitHub. The MCP toolbox needs the mcp feature flag, which the showcase enables. The two progress functions take &mut AgentBuilder and register hooks, which keeps the builder short. The hooks page shows a captured run of this agent with claude-sonnet-5-5.

AgentBuilder::name labels an agent, and Agent::name() reads the label back:

let agent = Agent::builder()
.llm(openai)
.name("release-checker")
.build()?;
assert_eq!(agent.name(), "release-checker");
// Without `name`, every agent is called "unnamed_agent".
let unnamed = Agent::builder().llm(openai).build()?;
assert_eq!(unnamed.name(), "unnamed_agent");

Source: snippets/src/agents/overview.rs#L42-L50

The name defaults to unnamed_agent. Swiftide only shows it in the agent’s Debug output, so use it in your own logs or to tell agents apart in a multi-agent setup.

Besides running the loop, the agent:

  • renders the system prompt on the first run and appends it to the history, after any messages that BeforeAll hooks or an existing history put there
  • removes duplicate keys from the JSON arguments of a tool call
  • wraps queries, steps, tools and hooks in tracing spans (Tracing)
  • lists toolbox tools when it first starts

You provide the model, the tools, the system prompt, the tool executor, the hooks and, when you want a typed result, the schema of the stop tool.

  • A hook that returns an error is logged with tracing. The agent carries on.
  • When a completion holds only calls to tools the agent does not have, no output is recorded. The next query returns AgentError::UnfulfilledToolCalls.
  • Some errors return from query without setting a stop reason, for example a message history error or a tool task that fails to join. The agent then stays Running, and the next run returns AgentError::AlreadyRunning.
  • A clone of a Pending agent renders the system prompt again on its first run and appends it to the shared history.

Tools