# Stopping and structured results

> Replace the stop tool with StopWithArgs and a JSON schema to get a typed result, report failures with AgentCanFail, cap runs with limit and tool_retry_limit, and handle every StopReason.

Your agent ends its run by handing your code a typed value, such as `ReleaseNotes`, or a typed report of why it failed. Your code can also tell why any other run ended.

`Agent::builder()` starts with one tool, named `stop`, which takes no arguments and ends the run with `StopReason::RequestedByTool(tool_call, None)`. With the default context, an answer without a tool call also ends the run, with `StopReason::NoNewMessages`.

## Structured results

The examples need the `swiftide-agents` feature flag and your own `schemars` 1.x, `serde` and `serde_json`:

```sh
cargo add swiftide@0.34 --features openai,swiftide-agents
cargo add schemars@1 serde serde_json --features serde/derive
```

The [release-notes-agent showcase](/showcases/release-notes-agent/) returns its notes as data. It describes them as a type and turns the type's JSON (JavaScript Object Notation) Schema into a stop tool:

```rust title="examples/release-notes-agent/src/notes.rs"
/// Structured release notes. The agent hands these over when it calls `stop`.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct ReleaseNotes {
    /// The version being released, without a leading `v`.
    pub version: String,
    /// One or two sentences on what this release is about, written for users.
    pub summary: String,
    /// New features and notable improvements.
    pub highlights: Vec<Item>,
    /// Changes that require users to update their code. Empty if there are none.
    pub breaking_changes: Vec<Item>,
    /// Bug fixes users may notice.
    pub fixes: Vec<Item>,
}

#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct Item {
    /// Short, user-facing title.
    pub title: String,
    /// What changed and why it matters, in one or two sentences.
    pub details: String,
    /// Pull requests or commits as plain text, e.g. `#1207` or `90bcadc7`, not Markdown links.
    pub references: Vec<String>,
}

impl ReleaseNotes {
    /// A `stop` tool whose arguments must match [`ReleaseNotes`].
    ///
    /// With a custom schema the arguments are forwarded as-is, and end up in
    /// `StopReason::RequestedByTool`.
    pub fn stop_tool() -> StopWithArgs {
        StopWithArgs::with_parameters_schema(schema_for!(ReleaseNotes))
    }
}
```

Source: [`examples/release-notes-agent/src/notes.rs#L12-L47`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/release-notes-agent/src/notes.rs#L12-L47)

`StopWithArgs::with_parameters_schema` builds a tool that is also named `stop`. The model calls it with arguments that follow the schema. The doc comments on the fields end up in the schema, so the model reads them as instructions. `deny_unknown_fields` gives both objects `additionalProperties: false`.

With a custom schema, the tool passes the whole argument object on as the payload. The agent stops with `StopReason::RequestedByTool(tool_call, Some(payload))`, where `payload` is a `serde_json::Value`. Match that arm and turn the payload into your type with `ReleaseNotes::deserialize(payload)` (a `&serde_json::Value` is a deserializer) or `serde_json::from_value(payload.clone())`. [Human in the loop](/agents/human-in-the-loop/) shows the whole loop.

`StopWithArgs::default()` is the other form. Its schema has a single `output` string, and the payload is that string.

`tools` keeps the default `stop`, so call `without_default_stop_tool` before `tools`, or add yours with `add_tool`. In this example, `stop_tool` is `StopWithArgs::with_parameters_schema(stop_schema())`:

```rust
let mut builder = Agent::builder();
builder
    .llm(&openai)
    .system_prompt(
        "You are a workflow finisher. Summarize completed work and recommend next actions. \
         When done, call the `stop` tool with the provided JSON schema. Always include \
         `details`, using null when there is nothing to add. Never fabricate task status \
         values outside the schema.",
    )
    .without_default_stop_tool()
    .tools([stop_tool.clone()])
    .on_stop(|_, reason, _| {
        Box::pin(async move {
            if let StopReason::RequestedByTool(_, payload) = reason
                && let Some(payload) = payload
            {
                println!(
                    "agent stopped with structured payload:\n{}",
                    to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string()),
                );
            }
            Ok(())
        })
    });
```

Source: [`examples/stop_with_args_custom_schema.rs#L59-L82`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/stop_with_args_custom_schema.rs#L59-L82)

The `on_stop` hook receives the same `StopReason`, so you can read the payload there instead of after `query` returns.

The showcase's output with `claude-sonnet-5-5`, through an OpenAI-compatible gateway, ends with the stop call and the deserialized notes:

```text
  → stop (version 0.34.0: highlights 2, breaking 1, fixes 1)
  stop ■ done

✔ RELEASE_NOTES.md

0.34.0 — This release adds the opt-in `swiftide-agents-contrib` crate and Perl support for tree-sitter. It also fixes duplicated text in OpenAI streaming and resolves security advisories, which removes AWS Console login from the Bedrock client.
  highlights (2)
    • Agent tools, hooks and executors in `swiftide-agents-contrib` [#1207]
    • Perl support for tree-sitter [#1198]
  breaking changes (1)
    • AWS Console login removed from the Bedrock client [#1215]
  fixes (1)
    • Repeated text in OpenAI streaming [#1216]
```

## Failure reports

`AgentCanFail` is a second control tool, named `task_failed`. The agent does not add it unless you pass it to `tools`. `AgentCanFail::default()` takes a `reason` string, and the agent stops with `StopReason::AgentFailed(Some(reason))`. `query` still returns `Ok`, so you check the stop reason.

`AgentCanFail::with_parameters_schema` accepts your own schema:

```rust
/// What the agent reports when it cannot finish.
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
struct FailureReport {
    /// What went wrong, in one sentence.
    summary: String,
    /// What a person has to fix before the task can be retried.
    blocked_by: Vec<String>,
}

let mut agent = Agent::builder()
    .llm(&openai)
    .tools([AgentCanFail::with_parameters_schema(schema_for!(
        FailureReport
    ))])
    .build()?;

agent.query("Restore last night's database backup.").await?;

// With a custom schema the arguments arrive as one JSON-encoded string.
if let Some(StopReason::AgentFailed(Some(Value::String(json)))) = agent.stop_reason() {
    let report: FailureReport = serde_json::from_str(json)?;
    println!("failed: {}", report.summary);
    for blocker in &report.blocked_by {
        println!("  blocked by: {blocker}");
    }
}
```

Source: [`snippets/src/agents/stopping.rs#L57-L83`](https://github.com/bosun-ai/swiftide-website/blob/master/snippets/src/agents/stopping.rs#L57-L83)

With a custom schema the tool serializes the arguments back to text. The payload is a `Value::String` that holds JSON, so you parse it a second time with `serde_json::from_str`.

## Stop reasons

This run caps itself at 20 completions, lets one tool call fail twice, adds `task_failed`, and stops itself from a hook when the message history grows too long:

```rust
let mut agent = Agent::builder()
    .llm(&openai)
    .system_prompt("Check that the release branch builds. Call `stop` when you are done.")
    .tools([AgentCanFail::default()])
    .limit(20)
    .tool_retry_limit(2)
    .after_each(|agent| {
        Box::pin(async move {
            if agent.history().await?.len() > 200 {
                agent.stop("the history grew past 200 messages").await;
            }
            Ok(())
        })
    })
    .build()?;

let result = agent.query("Is the release branch green?").await;

match agent.stop_reason() {
    Some(StopReason::RequestedByTool(_call, payload)) => println!("done: {payload:?}"),
    Some(StopReason::AgentFailed(reason)) => println!("the model gave up: {reason:?}"),
    Some(StopReason::ToolCallsOverLimit(call)) => println!("`{}` kept failing", call.name()),
    Some(StopReason::FeedbackRequired { tool_call, .. }) => {
        println!("`{}` waits for approval", tool_call.name());
    }
    Some(StopReason::Error) => println!("the run failed"),
    Some(StopReason::NoNewMessages) => println!("turn ended without a tool call, or limit hit"),
    Some(StopReason::Other(reason)) => println!("stopped by our hook: {reason}"),
    Some(other) => println!("stopped: {other:?}"),
    None => println!("the agent did not stop; see the returned error"),
}

// `ToolCallsOverLimit` and `Error` also come back as an `Err`.
result?;
```

Source: [`snippets/src/agents/stopping.rs#L16-L49`](https://github.com/bosun-ai/swiftide-website/blob/master/snippets/src/agents/stopping.rs#L16-L49)

`limit` caps the number of completions in one run. The counter starts at zero with every `query` or `run`. When it is reached, the agent logs a warning and stops with `StopReason::NoNewMessages`, and `query` returns `Ok`. No limit is set by default.

`tool_retry_limit` caps how often the same tool call may fail before the agent stops with `StopReason::ToolCallsOverLimit` and `query` returns `AgentError::ToolError`. [Tools](/agents/tools/) explains how failures are counted.

Each variant means something different for your code:

- `RequestedByTool(tool_call, payload)`: a tool returned `ToolOutput::Stop`, usually a `stop` tool. `payload` is `None` for the default tool, the `output` value for `StopWithArgs::default()`, and the argument object for a custom schema. Treat it as success.
- `AgentFailed(payload)`: a tool returned `ToolOutput::AgentFailed`, usually `task_failed`. Show the reason to a person, or retry with more context.
- `ToolCallsOverLimit(tool_call)`: one call failed too often. `query` also returns the error. Fix the tool or its description, or raise `tool_retry_limit`.
- `FeedbackRequired { tool_call, payload }`: a tool waits for approval. Record a `ToolFeedback` on the context and call `run`, as [Human in the loop](/agents/human-in-the-loop/) shows.
- `Error`: a step failed, for example a completion request, and `query` returns the `AgentError`.
- `NoNewMessages`: the model answered without a tool call, sent an empty reply, or the run reached `limit`. `query_once` and `run_once` also end with it after one step, unless a tool stopped the agent first. A new `query` continues the same message history.
- `Other(text)`: your code stopped the agent. `agent.stop("text")` accepts any string. Hooks that receive `&mut Agent`, such as `after_each` and `after_tool`, can call it.

`StopReason` is marked `#[non_exhaustive]`, so a `match` needs a wildcard arm. `agent.stop_reason()` returns `None` while the agent is `Pending` or `Running`. That includes a run that returned an error without recording a stop. Each variant also has an `is_*` method, and most have an `as_*` accessor such as `as_requested_by_tool`.

### Tool outputs

`ToolOutput` has the same kind of accessors. `as_text` and `as_fail` return the text of a `Text` or `Fail` output; `as_stop` and `as_agent_failed` return the payload of a `Stop` or `AgentFailed` output. This `after_tool` hook reads them, and turns a failed `deploy` call into a failed run with `ToolOutput::agent_failed`:

```rust
let agent = Agent::builder()
    .llm(openai)
    .after_tool(|_agent, tool_call, result| {
        if let Ok(output) = result {
            if let Some(text) = output.as_text() {
                println!("`{}` returned {} bytes", tool_call.name(), text.len());
            } else if let Some(error) = output.as_fail().map(str::to_owned) {
                // A failed deploy ends the run instead of going back to the model.
                if tool_call.name() == "deploy" {
                    *output = ToolOutput::agent_failed(json!({ "deploy": error }));
                }
            } else if let Some(payload) = output.as_stop() {
                println!("stopping with {payload}");
            } else if let Some(reason) = output.as_agent_failed() {
                println!("giving up: {reason}");
            }
        }
        Box::pin(async { Ok(()) })
    })
    .build()?;
```

Source: [`snippets/src/agents/stopping.rs#L91-L110`](https://github.com/bosun-ai/swiftide-website/blob/master/snippets/src/agents/stopping.rs#L91-L110)

The agent checks the output after the `after_tool` hooks run, so the rewritten output stops it with `StopReason::AgentFailed(Some(payload))`. `as_stop` and `as_agent_failed` return `None` when the output carries no payload, as with the default `stop` tool; match on `ToolOutput::Stop` or `ToolOutput::AgentFailed` to test the variant itself.

## Gotchas

- To tell the cases of `NoNewMessages` apart, check whether the last assistant message has tool calls, or turn off the text-answer stop with `with_stop_on_assistant(false)` (see [Context and history](/agents/context-and-history/)).
- `without_default_stop_tool` takes effect when you call `tools`, so call it first.
- A `StopWithArgs` or `AgentCanFail` call whose arguments are not valid JSON is a tool error. It goes back to the model and counts toward `tool_retry_limit`. The default `Stop` ignores its arguments.
- `to_string_pretty` on an `AgentCanFail` payload prints a quoted, escaped string; parse it with `serde_json::from_str` first.
- Once the agent has stopped, `Agent::stop` does nothing. The first stop reason stays, and `on_stop` fires once. Tools the model called in the same completion as `stop` still run.
- The payload is whatever the model sent. Handle a failed deserialization, as the showcase does.

## Reference

- [`Agent`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.Agent.html)
- [`AgentBuilder`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html)
- [`DefaultContext`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.DefaultContext.html)
- [`StopReason`](https://docs.rs/swiftide/0.34/swiftide/agents/enum.StopReason.html)
- [`State`](https://docs.rs/swiftide/0.34/swiftide/agents/enum.State.html)
- [`Stop`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.Stop.html)
- [`StopWithArgs`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.StopWithArgs.html)
- [`AgentCanFail`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.AgentCanFail.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)
- [`ToolFeedback`](https://docs.rs/swiftide/0.34/swiftide/traits/enum.ToolFeedback.html)
- [`AgentError`](https://docs.rs/swiftide/0.34/swiftide/agents/errors/enum.AgentError.html)

## Next

[Human in the loop](/agents/human-in-the-loop/)