# Agent control recipes

> Resume an agent from saved message history, end a run with a typed stop or failure payload, and give a tool a hand-written JSON Schema.

Four recipes give you an agent that continues a saved conversation, an agent that ends with a typed report or a typed failure, and a tool whose argument schema you write yourself.

## Setup

Add Swiftide with the OpenAI integration and agents, plus the crates the recipes use:

```sh
cargo add swiftide@0.34 --features openai,swiftide-agents
cargo add schemars@1 serde serde_json --features serde/derive
cargo add tokio --features full
cargo add anyhow async-trait
```

Every agent below borrows an OpenAI client built like this:

```rust
let openai = swiftide::integrations::openai::OpenAI::builder()
    .default_embed_model("text-embeddings-3-small")
    .default_prompt_model("gpt-4o-mini")
    .build()?;
```

Source: [`examples/agents_resume.rs#L10-L13`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/agents_resume.rs#L10-L13)

Most recipes are examples in the swiftide repository. To run them, clone it and set an OpenAI key:

```sh
git clone https://github.com/bosun-ai/swiftide && cd swiftide
export OPENAI_API_KEY=sk-...
```

The examples crate turns on most feature flags, so the first build takes several minutes. Each recipe ends with its run command.

## Resumed agents

A conversation that has to survive a restart needs its message history stored. The first agent answers one query:

```rust
let mut first_agent = agents::Agent::builder().llm(&openai).build()?;

first_agent.query("Say hello!").await?;
```

Source: [`examples/agents_resume.rs#L17-L19`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/agents_resume.rs#L17-L19)

Then the example stores its history and loads it into a new context:

```rust
// Let's store the messages in a database, retrieve them back, and start a new agent
let stored_history = serde_json::to_string(&first_agent.history().await?)?;
let retrieved_history: Vec<_> = serde_json::from_str(&stored_history)?;

let restored_context = DefaultContext::default()
    .with_existing_messages(retrieved_history)
    .await?
    .to_owned();
```

Source: [`examples/agents_resume.rs#L23-L30`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/agents_resume.rs#L23-L30)

A second agent continues from that context:

```rust
let mut second_agent = agents::Agent::builder()
    .llm(&openai)
    .context(restored_context)
    // We'll use the one from the first agent, alternatively we could also pop it from the
    // previous history and add a new one here
    .no_system_prompt()
    .build()?;

second_agent.query("What did you say?").await?;
```

Source: [`examples/agents_resume.rs#L34-L42`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/agents_resume.rs#L34-L42)

`Agent::history` returns the message history as a `Vec<ChatMessage>`, and `ChatMessage` round-trips through JSON (JavaScript Object Notation). `DefaultContext::with_existing_messages` replaces the context's message history with the messages you pass. [Message history](/agents/context-and-history/#message-history) explains where those messages live.

A fresh agent appends its system prompt to the context on its first run, after the restored messages. If the stored history already starts with a system message, that adds a second one. The example's first agent has no system prompt, and `no_system_prompt()` keeps the second agent the same. The comment in the `resume` excerpt dates from before 0.33, when agents had a default system prompt.

```sh
cargo run -p swiftide-examples --example agents-resume
```

### Message history backends

`DefaultContext::with_message_history` takes any `MessageHistory` backend, so you do not have to serialize the history yourself. [Redis storage](/agents/context-and-history/#redis-storage) shows the `Redis` backend.

## Typed stop payloads

When the caller needs a structured final answer, describe it as a type:

```rust
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum TaskStatus {
    Succeeded,
    Failed,
    Cancelled,
}

#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
struct StopPayload {
    status: TaskStatus,
    summary: String,
    /// Extra context for the reader, or null when there is nothing to add.
    details: Option<String>,
}
```

Source: [`snippets/src/cookbook/agent_control.rs#L17-L32`](https://github.com/bosun-ai/swiftide-website/blob/master/snippets/src/cookbook/agent_control.rs#L17-L32)

Turn its schema into the stop tool, register it in place of the default one and read the payload when the agent stops:

```rust
let stop_tool = StopWithArgs::with_parameters_schema(schema_for!(StopPayload));

let mut agent = Agent::builder()
    .llm(&openai)
    .system_prompt(
        "Summarize the completed work. When done, call `stop` with the JSON schema. \
         Use null for `details` when there is nothing to add.",
    )
    .without_default_stop_tool()
    .tools([stop_tool])
    .on_stop(|_agent, reason, _| {
        Box::pin(async move {
            if let StopReason::RequestedByTool(_call, Some(payload)) = reason {
                let report = StopPayload::deserialize(&payload)?;
                println!("{:?}: {}", report.status, report.summary);
                if let Some(details) = report.details {
                    println!("{details}");
                }
            }
            Ok(())
        })
    })
    .build()?;

agent
    .query_once("You onboarded five merchants today. Write the handoff report and stop.")
    .await?;
```

Source: [`snippets/src/cookbook/agent_control.rs#L38-L64`](https://github.com/bosun-ai/swiftide-website/blob/master/snippets/src/cookbook/agent_control.rs#L38-L64)

`StopWithArgs::with_parameters_schema` builds a tool named `stop` and forwards the whole argument object as the payload. The `on_stop` hook receives `StopReason::RequestedByTool(tool_call, Some(payload))`. A `&serde_json::Value` is a deserializer, so `StopPayload::deserialize(&payload)` turns it into your type. Why `without_default_stop_tool()` comes before `tools`: [Stopping and structured results](/agents/stopping-and-structured-results/#structured-results).

The OpenAI client marks every property as required when it sends a tool schema. The model sends `details` even when it is optional, which is why the system prompt asks for `null`. `query_once` runs one completion and the tool calls it returns, then hands control back.

Every field needs a schema that is an object. A field of type `serde_json::Value` or `Option<serde_json::Value>` without a description produces the schema `true`, and `tool_spec()` panics with `NodeMustBeObject`. The `stop_with_args_custom_schema.rs` example in the repository declares `details` that way, so at this version it panics before it reaches the model. Use a concrete type, or add `#[schemars(description = "...")]` to the field.

### Payload after the run

You can also read the payload after `query_once` returns. `Agent::stop_reason` returns the reason of the last stop:

```rust
#[derive(Debug, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
struct HandoffReport {
    summary: String,
    next_actions: Vec<String>,
}

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

agent
    .query_once("Write the handoff report for today's onboarding and stop.")
    .await?;

if let Some(StopReason::RequestedByTool(_call, Some(payload))) = agent.stop_reason() {
    let report: HandoffReport = serde_json::from_value(payload.clone())?;
    println!("{}: {:?}", report.summary, report.next_actions);
}
```

Source: [`snippets/src/cookbook/agent_control.rs#L72-L94`](https://github.com/bosun-ai/swiftide-website/blob/master/snippets/src/cookbook/agent_control.rs#L72-L94)

## Typed failure payloads

A model that has to give up can hand your code a report to route, for example to an on-call queue. Describe the report:

```rust
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum FailureCategory {
    MissingDependency,
    PermissionDenied,
    UnexpectedRegression,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
enum RemediationStatus {
    Planned,
    Blocked,
    Complete,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[schemars(deny_unknown_fields)]
struct FailureReport {
    category: FailureCategory,
    summary: String,
    impact: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    recommended_actions: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    remediation_status: Option<RemediationStatus>,
}

fn failure_schema() -> Schema {
    schema_for!(FailureReport)
}
```

Source: [`examples/agent_can_fail_custom_schema.rs#L16-L46`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/agent_can_fail_custom_schema.rs#L16-L46)

`AgentCanFail::with_parameters_schema` turns that schema into the arguments of the `task_failed` tool. The example prints the resulting tool spec:

```rust
let schema = failure_schema();
let failure_tool = AgentCanFail::with_parameters_schema(schema.clone());

println!(
    "task_failed tool schema:\n{}",
    to_string_pretty(&failure_tool.tool_spec())?,
);
```

Source: [`examples/agent_can_fail_custom_schema.rs#L54-L60`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/agent_can_fail_custom_schema.rs#L54-L60)

The agent keeps the default stop tool and adds `task_failed`, so the model can either finish or fail. The `on_stop` hook prints the failure payload:

```rust
let mut builder = Agent::builder();
builder
    .llm(&openai)
    .system_prompt(
        "You are an incident response coordinator. If the task cannot be completed, call \
         `task_failed` with the provided JSON schema. Populate every required field, include \
         at least one `recommended_actions` entry, and document the impact. Do not claim \
         success while blockers remain unresolved.",
    )
    .tools([failure_tool.clone()])
    .on_stop(|_, reason, _| {
        Box::pin(async move {
            if let StopReason::AgentFailed(Some(payload)) = reason {
                let json = to_string_pretty(&payload).unwrap();
                println!("agent reported failure:\n{json}");
            }
            Ok(())
        })
    });

let mut agent = builder.build()?;
```

Source: [`examples/agent_can_fail_custom_schema.rs#L71-L91`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/agent_can_fail_custom_schema.rs#L71-L91)

```rust
agent
    .query_once(
        "You must restore last night's database backup, but the only backup file is corrupted and no redundant copy exists. Report the failure.",
    )
    .await?;
```

Source: [`examples/agent_can_fail_custom_schema.rs#L95-L99`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/agent_can_fail_custom_schema.rs#L95-L99)

The run returns `Ok` even when the agent fails, and the payload is JSON text inside a `Value::String` ([Failure reports](/agents/stopping-and-structured-results/#failure-reports) explains both). Decode it after the run:

```rust
#[derive(Debug, Deserialize)]
struct FailureReport {
    summary: String,
    impact: 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, report.impact);
}
```

Source: [`snippets/src/cookbook/agent_control.rs#L102-L111`](https://github.com/bosun-ai/swiftide-website/blob/master/snippets/src/cookbook/agent_control.rs#L102-L111)

Your reading type can hold fewer fields than the schema type, because serde ignores unknown fields unless you deny them.

```sh
cargo run -p swiftide-examples --example agent-can-fail-custom-schema
```

## Hand-written tool schemas

`#[swiftide::tool]` cannot express every argument shape, and it gives you no say over the schema title, field descriptions or enum values. A hand-written tool can. Describe the arguments:

```rust
#[derive(Clone)]
struct WorkflowTool;

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[schemars(
    title = "WorkflowInstruction",
    description = "Choose a workflow action and optional payload",
    deny_unknown_fields
)]
struct WorkflowInstruction {
    #[schemars(description = "Which workflow action to execute")]
    action: WorkflowAction,
    #[schemars(description = "Optional payload forwarded to the workflow engine")]
    #[serde(default, skip_serializing_if = "Option::is_none")]
    payload: Option<Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
enum WorkflowAction {
    Start,
    Stop,
    Status,
}
```

Source: [`examples/tool_custom_schema.rs#L11-L34`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/tool_custom_schema.rs#L11-L34)

Implement `Tool` and return the schema from `tool_spec`:

```rust
#[swiftide::reexports::async_trait::async_trait]
impl Tool for WorkflowTool {
    async fn invoke(
        &self,
        _agent_context: &dyn AgentContext,
        _tool_call: &ToolCall,
    ) -> Result<ToolOutput, ToolError> {
        Ok(ToolOutput::text(
            "Workflow execution not implemented in this example",
        ))
    }

    fn name<'tool>(&'tool self) -> Cow<'tool, str> {
        Cow::Borrowed("workflow_tool")
    }

    fn tool_spec(&self) -> ToolSpec {
        ToolSpec::builder()
            .name("workflow_tool")
            .description("Executes a workflow action with strict input choices")
            .parameters_schema(workflow_schema())
            .build()
            .expect("tool spec should be valid")
    }
}

fn workflow_schema() -> Schema {
    schema_for!(WorkflowInstruction)
}
```

Source: [`examples/tool_custom_schema.rs#L38-L66`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/tool_custom_schema.rs#L38-L66)

```rust
let tool = WorkflowTool;
let spec = tool.tool_spec();

println!(
    "{}",
    serde_json::to_string_pretty(&spec).expect("tool spec should serialize"),
);
```

Source: [`examples/tool_custom_schema.rs#L71-L77`](https://github.com/bosun-ai/swiftide/blob/e377c4a2e76fe2559629ae0efdae3c6e0fb4d829/examples/tool_custom_schema.rs#L71-L77)

The `#[schemars(description = ...)]` on `payload` matters here. Without it, the `serde_json::Value` field gets the schema `true` and `ToolSpec::builder().build()` returns an error. [Hand-written tools](/agents/tools/#hand-written-tools) lists the other schema rules and why `name()` must match the spec name.

Swiftide has no blanket conversion from a `Tool` into `Box<dyn Tool>`, so `.tools([WorkflowTool])` does not compile. Register a hand-written tool with `add_tool`, or box it yourself:

```rust
#[derive(Clone)]
struct CurrentBranch;

#[async_trait::async_trait]
impl Tool for CurrentBranch {
    async fn invoke(
        &self,
        _context: &dyn AgentContext,
        _tool_call: &ToolCall,
    ) -> Result<ToolOutput, ToolError> {
        Ok(ToolOutput::text("main"))
    }

    fn name(&self) -> Cow<'_, str> {
        "current_branch".into()
    }

    fn tool_spec(&self) -> ToolSpec {
        ToolSpec::builder()
            .name("current_branch")
            .description("Returns the name of the checked out git branch")
            .build()
            .expect("tool spec should be valid")
    }
}

let agent = Agent::builder()
    .llm(&openai)
    .add_tool(CurrentBranch)
    .build()?;

let same_agent = Agent::builder()
    .llm(&openai)
    .tools([CurrentBranch.boxed()])
    .build()?;
```

Source: [`snippets/src/cookbook/agent_control.rs#L119-L153`](https://github.com/bosun-ai/swiftide-website/blob/master/snippets/src/cookbook/agent_control.rs#L119-L153)

This example needs no API key. It prints the tool spec as JSON:

```sh
cargo run -p swiftide-examples --example tool-custom-schema
```

## Reference

- [`Agent`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.Agent.html), [`Agent::history`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.Agent.html#method.history) and [`Agent::stop_reason`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.Agent.html#method.stop_reason)
- [`AgentBuilder`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html): [`without_default_stop_tool`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html#method.without_default_stop_tool), [`no_system_prompt`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html#method.no_system_prompt), [`add_tool`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.AgentBuilder.html#method.add_tool)
- [`DefaultContext`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.DefaultContext.html): [`with_existing_messages`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.DefaultContext.html#method.with_existing_messages), [`with_message_history`](https://docs.rs/swiftide/0.34/swiftide/agents/struct.DefaultContext.html#method.with_message_history)
- [`MessageHistory`](https://docs.rs/swiftide/0.34/swiftide/traits/trait.MessageHistory.html) and [`Redis`](https://docs.rs/swiftide/0.34/swiftide/integrations/redis/struct.Redis.html)
- [`ChatMessage`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/enum.ChatMessage.html)
- [`StopReason`](https://docs.rs/swiftide/0.34/swiftide/agents/enum.StopReason.html)
- [`StopWithArgs`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.StopWithArgs.html) and [`AgentCanFail`](https://docs.rs/swiftide/0.34/swiftide/agents/tools/control/struct.AgentCanFail.html)
- [`Tool`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/trait.Tool.html) and [`Tool::boxed`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/trait.Tool.html#method.boxed), [`ToolSpec`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolSpec.html) and [`ToolSpecBuilder`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolSpecBuilder.html)
- [`ToolCall`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/struct.ToolCall.html) and [`ToolOutput`](https://docs.rs/swiftide/0.34/swiftide/chat_completion/enum.ToolOutput.html)

## Next

[OpenAI Responses API and reasoning](/cookbook/responses-api-and-reasoning/)