Skip to content

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.

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:

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

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_result was 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 ModelTurn so 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.

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.

  • Format: a serialized run records its format version (rig::run::RUN_FORMAT, currently 2), 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 — and entries_of(kind) / last_entry_of(kind) read it back. Entries are never sent to the model. Hooks write and read the same entries through HookContext::append_entry and entries, 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.
  • AgentRunner — the driver agent.prompt(..) returns.
  • Hooks — in-process approvals, guardrails, and steering.
  • Tools — ToolSet and tool execution.
  • Completions — calling a model directly with CompletionRequest.