Durable runs
Under every agent run sits AgentRun: a serializable state machine that tracks the prompt, the
history, the turn budget, and what has to happen next. It does no I/O itself. It doesn’t own a model,
tools, memory, or hooks; it tells a driver what to do, and the driver feeds the results back.
agent.prompt(..) is one such driver: the AgentRunner steps an
AgentRun for you, calling the model and running tools and hooks along the way.
Step an AgentRun yourself when a run has to outlive the task that started it. The whole run is
Serialize + Deserialize between steps, so you can stop while tool calls are waiting, write the run to
a database or file, and pick it up hours later in another process — after a person has approved the
calls, for example.
The stepping protocol
Section titled “The stepping protocol”Create a run with AgentRun::new(prompt), set its budget with .max_turns(n) (and earlier messages
with .with_history(..)), then call next_step() in a loop. Each step is one of three things:
| Step | The driver should |
|---|---|
AgentRunStep::CallModel { prompt, history, turn } | Send a completion request and pass the response to run.model_response(..). |
AgentRunStep::CallTools { calls } | Execute the tool calls and pass the results to run.tool_results(..). |
AgentRunStep::Done(response) | Stop: response is the same PromptResponse an awaited agent returns. |
model_response returns a ModelTurnOutcome. NeedsResolution(context) means the model called a tool
it can’t call; answer with run.resolve_invalid_tool_call(action) using the same InvalidToolCallAction
values a hook would return. The run enforces the turn budget and the protocol itself: running out of
turns returns PromptError::MaxTurns, and calling a method out of order or answering a tool call that
isn’t pending returns an error.
Pausing for approval
Section titled “Pausing for approval”This program stops whenever the model wants to call tools, saves the run to a file, then loads it back and asks a person to approve each call. Everything between the save and the load could be another process, a web request handler, or the next day: the reloaded run re-emits its pending calls purely from the saved state.
use std::collections::BTreeSet;use std::io::BufRead;
use rig::completion::CompletionRequest;use rig::message::{ToolResultContent, UserContent};use rig::prelude::*;use rig::providers::openai::{self, OpenAI};use rig::run::{AgentRun, AgentRunStep, ModelTurn, ModelTurnOutcome};use rig::run::InvalidToolCallAction;use rig::tool::ToolSet;
const PREAMBLE: &str = "You are a banking assistant. Use the tools to carry out the request.";
#[tokio::main]async fn main() -> anyhow::Result<()> { let model = OpenAI::from_env()?.completion(openai::GPT_5_5); let mut tools = ToolSet::default(); tools.add_tool(TransferFunds); let definitions = tools.tool_definitions(); let tool_names: BTreeSet<String> = definitions.iter().map(|d| d.name.to_string()).collect(); let checkpoint = std::path::Path::new("run.json");
let mut run = AgentRun::new("Transfer $500 to account B-2.").max_turns(4);
loop { match run.next_step()? { AgentRunStep::CallModel { prompt, history, .. } => { let response = model .call( CompletionRequest::new(prompt) .messages(history) .preamble(PREAMBLE.to_string()) .tools(definitions.clone()), ) .await?; let turn = ModelTurn::from_response_parts(&response, tool_names.clone(), tool_names.clone()); let mut outcome = run.model_response(turn)?; while let ModelTurnOutcome::NeedsResolution(_) = outcome { outcome = run.resolve_invalid_tool_call(InvalidToolCallAction::fail())?; } }
AgentRunStep::CallTools { .. } => { // Pause: persist the whole run while its tool calls wait for a decision. std::fs::write(checkpoint, serde_json::to_vec(&run)?)?;
// ...later, possibly in another process: load it and get the calls back. let mut resumed: AgentRun = serde_json::from_slice(&std::fs::read(checkpoint)?)?; let AgentRunStep::CallTools { calls } = resumed.next_step()? else { anyhow::bail!("a resumed run re-emits its pending tool calls"); };
let mut results = Vec::new(); for call in calls { // Calls already answered by invalid-call recovery must not run. if let Some(result) = call.preresolved_result { results.push(result); continue; } let id = call.tool_call.id.clone(); let name = call.tool_call.function.name.clone(); let args = call.tool_call.function.arguments_value().to_string();
println!("approve {name}({args})? [y/N]"); let mut answer = String::new(); std::io::stdin().lock().read_line(&mut answer)?;
let content = if answer.trim() == "y" { let result = tools.execute(&name, args, &mut ToolContext::new()).await; result.output().clone().into_content() } else { // Fail-closed: anything but an explicit yes is a denial the model sees. vec![ToolResultContent::text("denied by the reviewer")] }; results.push(UserContent::tool_result(id, name, content)); } resumed.tool_results(results)?; run = resumed; }
AgentRunStep::Done(response) => { println!("{}", response.output()); return Ok(()); } } }}A few rules the driver must follow:
- Answer every pending call exactly once, in any order. To deny a call, answer it with a tool result that says so; the model reads it and can adapt. To edit a call, execute the tool with different arguments. To abort, simply stop driving the run.
- A call with a
preresolved_resultwas already settled by invalid tool-call recovery: return that result without executing anything. - When you call the model yourself, the request is yours to build. The run supplies the prompt and
history; the preamble, tools, and sampling settings come from your driver. Report the tools you
advertised in the
ModelTurnso the run can tell valid calls from invalid ones.
Rig’s agent_run_stepping and agent_with_durable_approval examples are complete, runnable versions
of this loop, including edit and abort choices.
Resuming under an agent
Section titled “Resuming under an agent”You don’t have to drive a persisted run to the end by hand. agent.resume(run) returns an
AgentRunner that continues it with the agent’s model, tools, hooks, and request settings: pending tool
calls execute first, then the loop carries on, and you can .await or .stream() it like any other run.
let run: rig::AgentRun = serde_json::from_slice(&saved)?;let response = agent.resume(run).await?;println!("{}", response.output());The run is authoritative for what it saved — its prompt, history, turn budget, and invalid tool-call
settings — so history(..) and max_turns(..) on the resumed runner have no effect. A resumed run
doesn’t load or save conversation memory: append response.messages to your store yourself.
Persisted state
Section titled “Persisted state”- Format: a serialized run records its format version (
rig::run::RUN_FORMAT, currently2), and deserialization refuses any other version, or unknown fields, by name rather than guessing. Resume runs with the same Rig version that saved them. - Your own records:
run.append_entry(RunEntry { kind, turn, value })stores host data inside the run — an approval decision, a ticket id — andentries_of(kind)/last_entry_of(kind)read it back. Entries are never sent to the model. Hooks write and read the same entries throughHookContext::append_entryandentries, so a hook on a resumed run can see what happened before the pause. - What’s not included: models, tools, hooks, memory backends, and the tool context. Re-create them when you resume; keep secrets out of entries, since the run may be stored anywhere.
See also
Section titled “See also”- AgentRunner — the driver
agent.prompt(..)returns. - Hooks — in-process approvals, guardrails, and steering.
- Tools —
ToolSetand tool execution. - Completions — calling a model directly with
CompletionRequest.
