Stopping and structured results
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
Section titled “Structured results”The examples need the swiftide-agents feature flag and your own schemars 1.x, serde and serde_json:
cargo add swiftide@0.34 --features openai,swiftide-agentscargo add schemars@1 serde serde_json --features serde/deriveThe release-notes-agent showcase 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:
/// 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
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 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()):
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
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:
→ 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
Section titled “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:
/// 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
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
Section titled “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:
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
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 explains how failures are counted.
Each variant means something different for your code:
RequestedByTool(tool_call, payload): a tool returnedToolOutput::Stop, usually astoptool.payloadisNonefor the default tool, theoutputvalue forStopWithArgs::default(), and the argument object for a custom schema. Treat it as success.AgentFailed(payload): a tool returnedToolOutput::AgentFailed, usuallytask_failed. Show the reason to a person, or retry with more context.ToolCallsOverLimit(tool_call): one call failed too often.queryalso returns the error. Fix the tool or its description, or raisetool_retry_limit.FeedbackRequired { tool_call, payload }: a tool waits for approval. Record aToolFeedbackon the context and callrun, as Human in the loop shows.Error: a step failed, for example a completion request, andqueryreturns theAgentError.NoNewMessages: the model answered without a tool call, sent an empty reply, or the run reachedlimit.query_onceandrun_oncealso end with it after one step, unless a tool stopped the agent first. A newquerycontinues the same message history.Other(text): your code stopped the agent.agent.stop("text")accepts any string. Hooks that receive&mut Agent, such asafter_eachandafter_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
Section titled “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:
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
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
Section titled “Gotchas”- To tell the cases of
NoNewMessagesapart, check whether the last assistant message has tool calls, or turn off the text-answer stop withwith_stop_on_assistant(false)(see Context and history). without_default_stop_tooltakes effect when you calltools, so call it first.- A
StopWithArgsorAgentCanFailcall whose arguments are not valid JSON is a tool error. It goes back to the model and counts towardtool_retry_limit. The defaultStopignores its arguments. to_string_prettyon anAgentCanFailpayload prints a quoted, escaped string; parse it withserde_json::from_strfirst.- Once the agent has stopped,
Agent::stopdoes nothing. The first stop reason stays, andon_stopfires once. Tools the model called in the same completion asstopstill run. - The payload is whatever the model sent. Handle a failed deserialization, as the showcase does.