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:
cargo add swiftide@0.34 --features openai,swiftide-agentscargo add schemars@1 serde serde_json --features serde/derivecargo add tokio --features fullcargo add anyhow async-traitEvery 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:
git clone https://github.com/bosun-ai/swiftide && cd swiftideexport 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
Section titled “Resumed agents”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 agentlet 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.
cargo run -p swiftide-examples --example agents-resumeMessage history backends
Section titled “Message history backends”DefaultContext::with_message_history takes any MessageHistory backend, so you do not have to serialize the history yourself. Redis storage shows the Redis backend.
Typed stop payloads
Section titled “Typed stop payloads”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.
Payload after the run
Section titled “Payload after the run”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
Typed failure payloads
Section titled “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:
#[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.
cargo run -p swiftide-examples --example agent-can-fail-custom-schemaHand-written tool schemas
Section titled “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:
#[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:
cargo run -p swiftide-examples --example tool-custom-schemaReference
Section titled “Reference”Agent,Agent::historyandAgent::stop_reasonAgentBuilder:without_default_stop_tool,no_system_prompt,add_toolDefaultContext:with_existing_messages,with_message_historyMessageHistoryandRedisChatMessageStopReasonStopWithArgsandAgentCanFailToolandTool::boxed,ToolSpecandToolSpecBuilderToolCallandToolOutput