Skip to content

Lifecycle hooks

You end up with hooks that print each tool call, change a completion request before it goes out, trim tool output before the model reads it, and report why an agent stopped.

A hook is a closure that the agent calls at a fixed point in its loop. The closure returns a boxed future that the agent awaits. Hooks need the swiftide-agents feature flag. The release-notes showcase prints one line per tool call and one per result:

examples/release-notes-agent/src/progress.rs
/// Prints each tool call and its result on one line.
pub fn report_tool_calls(builder: &mut AgentBuilder) {
builder
.before_tool(|_agent, tool_call| {
println!(" → {}", describe_call(tool_call));
Box::pin(async { Ok(()) })
})
.after_tool(|_agent, tool_call, result| {
println!(" {} {}", tool_call.name(), describe_result(result));
Box::pin(async { Ok(()) })
});
}

Source: examples/release-notes-agent/src/progress.rs#L16-L27

You pass a closure to an AgentBuilder method such as before_tool. The closure returns Box::pin(async { Ok(()) }), which resolves to anyhow::Result<()>. An async closure does not satisfy the hook traits.

The closure must be Clone, Send and Sync. Share state through an Arc. Capturing a value that is not Clone, such as an mpsc::Receiver, fails to compile.

Hooks of one kind run in the order you register them. add_hook appends a Hook value, and hooks replaces all earlier hooks.

This is the start of a real run on the Swiftide repository, with claude-sonnet-5-5 behind 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
→ git_diff swiftide-integrations/Cargo.toml 3b1ad22e^..3b1ad22e
→ git_diff …/src/chat_completion/chat_completion_response.rs 90bcadc7^..90bcadc7
→ git_diff swiftide-agents-contrib/Cargo.toml 71fa4587^..71fa4587
→ git_diff Cargo.toml 71fa4587^..71fa4587
→ git_diff swiftide-integrations/src/aws_bedrock_v2/mod.rs 3b1ad22e^..3b1ad22e
→ git_diff swiftide/Cargo.toml 71fa4587^..71fa4587
git_diff ✓ 15 lines
git_diff ✓ 72 lines
git_diff ✓ 75 lines
git_diff ✓ No changes to Cargo.toml in 71fa4587^..71fa4587.
git_diff ✓ 18 lines
git_diff ✓ 32 lines

The → lines come from before_tool and the ✓ lines from after_tool. The agent runs before_tool for a call and then starts that call, so the tool calls of one completion run concurrently. An earlier tool can already be running while before_tool runs for a later one. It then waits for the results and runs after_tool in call order.

Builder methodWhen it runsWhat it receives
before_allOnce, on the first run, before the system prompt renders and toolboxes load&mut Agent
on_startOn every query or run, before its first completion&mut Agent
before_completionBefore each completion request&mut Agent, &mut ChatCompletionRequest
on_streamFor each stream chunk, when the agent streams&Agent, &ChatCompletionResponse
after_completionAfter each completion, before the agent acts on it&mut Agent, &mut ChatCompletionResponse
on_new_messageWhen Agent::add_message adds a message&Agent, &mut ChatMessage
before_toolBefore each tool call starts&mut Agent, &ToolCall
after_toolAfter each tool call, before its output is recorded&mut Agent, &ToolCall, &mut Result<ToolOutput, ToolError>
after_eachAfter a completion and its tool calls&mut Agent
on_stopWhen the agent records a stop reason&mut Agent, StopReason, Option<&AgentError>

Since 0.33, most hooks receive &mut Agent, so they can stop the agent, read its history or change its tools:

let mut agent = Agent::builder()
.llm(&openai)
.after_each(|agent: &mut Agent| {
Box::pin(async move {
if agent.history().await?.len() > 40 {
agent.stop("history too long").await;
}
Ok(())
})
})
.build()?;

Source: snippets/src/reference/upgrading.rs#L68-L78

Only the on_stream builder method turns streaming on. A Hook::OnStream passed to add_hook or hooks never fires unless streaming is on. Streaming covers stream chunks.

let agent = Agent::builder()
.llm(openai)
.before_completion(|_agent, request| {
println!(
"sending {} messages and {} tools",
request.messages().len(),
request.tools_spec().len()
);
// Only this request gets the reminder; the message history stays as it is.
request
.messages
.to_mut()
.push(ChatMessage::new_user("Reply in at most three sentences."));
Box::pin(async { Ok(()) })
})
.after_completion(|_agent, response| {
if let Some(usage) = &response.usage {
println!("used {} tokens", usage.total_tokens);
}
Box::pin(async { Ok(()) })
})
.build()?;

Source: snippets/src/agents/hooks.rs#L18-L39

A change to messages or tools_spec applies to this one request. The reminder reaches the model but never enters the message history. Removing an entry from tools_spec hides a tool for one turn.

In after_completion, changing response.message or response.tool_calls changes what the agent records and which tools it runs. response.usage is None when the provider reports no usage.

const KEEP_LINES: usize = 200;
let agent = Agent::builder()
.llm(openai)
.after_tool(|_agent, tool_call, result| {
if let Ok(ToolOutput::Text(text)) = result {
let lines: Vec<&str> = text.lines().collect();
if lines.len() > KEEP_LINES {
let dropped = lines.len() - KEEP_LINES;
let tail = lines[dropped..].join("\n");
*text = format!(
"[{dropped} lines of {} output cut]\n{tail}",
tool_call.name()
);
}
}
Box::pin(async { Ok(()) })
})
.build()?;

Source: snippets/src/agents/hooks.rs#L47-L65

Whatever you leave in the result is what the agent records in the message history and its tracing spans. The hook runs before the agent looks at errors. Replacing an Err with Ok(ToolOutput::fail(..)) still tells the model what went wrong, but no longer counts toward tool_retry_limit. Tool errors and retries explains the retry limit.

let agent = Agent::builder()
.llm(openai)
.on_stop(|_agent, reason, error| {
match error {
Some(error) => eprintln!("agent failed: {error}"),
None => println!("agent stopped: {reason:?}"),
}
Box::pin(async { Ok(()) })
})
.build()?;

Source: snippets/src/agents/hooks.rs#L73-L82

on_stop runs once per stop and before the agent records its new state, so read the reason from the argument. Stopping and structured results lists every stop reason.

on_stop is not a failure signal. The error is Some only when a completion step fails. Errors before the loop, such as a failed toolbox load, skip on_stop. A tool over tool_retry_limit fires on_stop with StopReason::ToolCallsOverLimit and no error, yet query returns Err. Check the result of query for failures.

/// Counts tool calls per tool, shared between the hook and the code that reads it.
#[derive(Clone, Default)]
pub struct ToolCallCounter {
counts: Arc<Mutex<HashMap<String, usize>>>,
}
impl ToolCallCounter {
/// `use<>` keeps `&self` out of the returned type, so the hook is `'static`.
pub fn hook(&self) -> impl AfterToolFn + use<> {
let counts = Arc::clone(&self.counts);
move |_agent, tool_call, _result| {
let mut counts = counts.lock().unwrap_or_else(PoisonError::into_inner);
*counts.entry(tool_call.name().to_owned()).or_default() += 1;
Box::pin(async { Ok(()) })
}
}
pub fn count(&self, tool_name: &str) -> usize {
let counts = self.counts.lock().unwrap_or_else(PoisonError::into_inner);
counts.get(tool_name).copied().unwrap_or_default()
}
}
pub async fn count_tool_calls(openai: &OpenAI) -> anyhow::Result<()> {
let counter = ToolCallCounter::default();
let mut agent = Agent::builder()
.llm(openai)
.after_tool(counter.hook())
.build()?;
agent.query("List the files in this directory").await?;
println!("stop was called {} times", counter.count("stop"));
Ok(())
}

Source: snippets/src/agents/hooks.rs#L88-L121

Builder methods only accept 'static hooks. In Rust 2024, fn hook(&self) -> impl AfterToolFn captures the lifetime of &self. Clone the Arc into the closure and add use<> to the return type.

  • The future may only borrow the agent. Touch the request, response, tool call or message before Box::pin. The after_tool result is the exception.
  • A hook that returns Err is logged with tracing::error! and the agent carries on.
  • agent.stop(..) lets the current step finish. A stop in before_completion still sends that request, and one in before_tool still runs the tool. In before_all and on_start, the agent fires on_stop but runs the loop anyway.
  • In before_all, agent.tools() does not yet hold Model Context Protocol (MCP) toolbox tools. It can fill template values through system_prompt_mut, as System prompts shows.
  • on_new_message skips the system prompt and user queries, because the agent writes those to the context directly.
  • A resumed tool call that waited for approval runs again, so a counter like the one above counts it twice. Its tool hooks run before on_start while the agent is still stopped, so a stop from them is ignored. Human in the loop covers approvals.
  • A call to a tool the agent does not have is skipped, and no tool hook runs for it.

System prompts