Skip to content

Agent control recipes

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.

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

Terminal window
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:

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

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

Terminal window
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.

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

let mut first_agent = agents::Agent::builder().llm(&openai).build()?;
first_agent.query("Say hello!").await?;

Source: examples/agents_resume.rs#L17-L19

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

// 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

A second agent continues from that context:

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

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

Terminal window
cargo run -p swiftide-examples --example agents-resume

DefaultContext::with_message_history takes any MessageHistory backend, so you do not have to serialize the history yourself. Redis storage shows the Redis backend.

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

#[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

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

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

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.

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.

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

#[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

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:

#[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

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

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

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:

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

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

The run returns Ok even when the agent fails, and the payload is JSON text inside a Value::String (Failure reports explains both). Decode it after the run:

#[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

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

Terminal window
cargo run -p swiftide-examples --example agent-can-fail-custom-schema

#[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:

#[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

Implement Tool and return the schema from tool_spec:

#[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

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

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 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:

#[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

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

Terminal window
cargo run -p swiftide-examples --example tool-custom-schema

OpenAI Responses API and reasoning