# Lifecycle hooks

> Register agent hooks that log tool calls, change completion requests, rewrite tool output and report why an agent stopped.

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:

```rust title="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`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/release-notes-agent/src/progress.rs#L16-L27)

## Hook registration

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:

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

## Hook points

| Builder method      | When it runs                                                                | What it receives                                                |
| ------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `before_all`        | Once, on the first run, before the system prompt renders and toolboxes load | `&mut Agent`                                                    |
| `on_start`          | On every `query` or `run`, before its first completion                      | `&mut Agent`                                                    |
| `before_completion` | Before each completion request                                              | `&mut Agent`, `&mut ChatCompletionRequest`                      |
| `on_stream`         | For each stream chunk, when the agent streams                               | `&Agent`, `&ChatCompletionResponse`                             |
| `after_completion`  | After each completion, before the agent acts on it                          | `&mut Agent`, `&mut ChatCompletionResponse`                     |
| `on_new_message`    | When `Agent::add_message` adds a message                                    | `&Agent`, `&mut ChatMessage`                                    |
| `before_tool`       | Before each tool call starts                                                | `&mut Agent`, `&ToolCall`                                       |
| `after_tool`        | After each tool call, before its output is recorded                         | `&mut Agent`, `&ToolCall`, `&mut Result<ToolOutput, ToolError>` |
| `after_each`        | After a completion and its tool calls                                       | `&mut Agent`                                                    |
| `on_stop`           | When 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:

```rust
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`](https://github.com/bosun-ai/swiftide-website/blob/master/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](/agents/streaming/) covers stream chunks.

## Completion request and response edits

```rust
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`](https://github.com/bosun-ai/swiftide-website/blob/master/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.

## Tool output rewrites

```rust
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`](https://github.com/bosun-ai/swiftide-website/blob/master/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](/agents/tools/#tool-errors-and-retries) explains the retry limit.

## Stop notifications

```rust
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`](https://github.com/bosun-ai/swiftide-website/blob/master/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](/agents/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.

## Hooks from methods

```rust
/// 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`](https://github.com/bosun-ai/swiftide-website/blob/master/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.

## Gotchas

- 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](/agents/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](/agents/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.

## Reference

- [`hooks` module](https://docs.rs/swiftide/0.34/swiftide/agents/hooks/index.html)
- [`Hook`](https://docs.rs/swiftide/0.34/swiftide/agents/hooks/enum.Hook.html)
- [`AfterToolFn`](https://docs.rs/swiftide/0.34/swiftide/agents/hooks/trait.AfterToolFn.html)
- [`AgentBuilder`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html)
- [`Agent`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.Agent.html)
- [`ChatCompletionRequest`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ChatCompletionRequest.html)
- [`ChatCompletionResponse`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ChatCompletionResponse.html)
- [`ChatMessage`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/enum.ChatMessage.html)
- [`ToolCall`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolCall.html)
- [`ToolOutput`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/enum.ToolOutput.html)
- [`ToolError`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/errors/enum.ToolError.html)
- [`StopReason`](https://docs.rs/swiftide/0.34/swiftide/agents/enum.StopReason.html)
- [`AgentError`](https://docs.rs/swiftide/0.34/swiftide/agents/errors/enum.AgentError.html)
- [Feature flags](/reference/feature-flags/)

## Next

[System prompts](/agents/system-prompts/)