salvor_runtime/driver.rs
1//! The built-in agent loop: model call, tool dispatch, repeat until a final
2//! answer or a budget crossing.
3//!
4//! This module is deliberately unprivileged. It consumes only this crate's
5//! **public** API ([`RunCtx`] and the other exported types), so it doubles
6//! as the reference example for the library-first tier: everything it does,
7//! an outside crate can do (the `custom_loop` integration test proves it).
8//! If this loop ever needed a private hook, the API design would be wrong.
9//!
10//! # Shape
11//!
12//! ```text
13//! begin
14//! loop {
15//! observe ctx.now() (one recorded observation per iteration)
16//! budget checks (steps, tokens, cost, wall time; park on crossing)
17//! build MessageRequest (system prompt, tools, conversation so far)
18//! ctx.model_call
19//! if the response has tool_use blocks {
20//! dispatch each through ctx.tool_call
21//! (suspension parks; failure compacts into the tool_result)
22//! append tool results to the conversation
23//! } else {
24//! ctx.complete_run(final text)
25//! }
26//! }
27//! ```
28//!
29//! # Determinism inventory
30//!
31//! Everything the loop feeds forward is a pure function of recorded data:
32//! the conversation is rebuilt from replayed model responses, tool outputs,
33//! and resume inputs; budget observations come from recorded usage and
34//! recorded `now` observations; idempotency keys derive from recorded
35//! random bits; error compaction is a pure function of recorded failures.
36//! So a replayed drive makes bit-identical requests (hashes match) and
37//! takes identical branches.
38//!
39//! Two deliberately unrecorded edges, both derived from recorded data and
40//! therefore still deterministic: an unknown tool name (the model asked for
41//! a tool the agent does not have) becomes an error `tool_result` without
42//! any event, and the count-based repeat summary replaces repeated error
43//! text without changing the log.
44
45use salvor_llm::{ContentBlock, Message, MessageRequest, Tool};
46use serde_json::Value;
47
48use crate::agent::Agent;
49use crate::budgets::{BudgetExtensions, BudgetObservations};
50use crate::compact::FailureTracker;
51use crate::ctx::{Resumption, RunCtx, ToolCallResult};
52use crate::error::RuntimeError;
53use crate::runtime::ParkReason;
54use crate::wire::content_string;
55use salvor_core::Effect;
56use time::OffsetDateTime;
57
58/// How one drive of the loop ended: it produced a final output, or it parked
59/// and the process should stop driving it.
60///
61/// [`Completed`](LoopOutcome::Completed) carries the loop's final output but
62/// does **not** mean the run's terminal `RunCompleted` has been recorded:
63/// [`drive_loop`] deliberately leaves that to its caller. [`drive`] records it
64/// straight away, preserving the built-in loop's log byte for byte; the graph
65/// engine records it once, after its last node, so an agent loop can run as one
66/// node among many inside a single graph log without each node closing the run.
67#[derive(Debug, Clone)]
68pub enum LoopOutcome {
69 /// The loop produced this final output. The caller records the terminal
70 /// `RunCompleted`.
71 Completed(Value),
72 /// The run is parked durably; resume it later with input.
73 Parked(ParkReason),
74}
75
76/// Drives one run (fresh, recovering, or resuming; the `ctx` knows which)
77/// to a final output or a park.
78///
79/// This is exactly [`begin`] followed by [`drive_loop`], with the terminal
80/// `RunCompleted` recorded here on completion. Splitting those two halves out
81/// is what lets the graph engine run [`drive_loop`] against a `RunCtx` whose
82/// log it already opened with `GraphRunStarted`: the agent node contributes its
83/// model and tool events without a second run head and without closing the run.
84pub(crate) async fn drive(
85 ctx: &mut RunCtx,
86 agent: &Agent,
87 initial_input: &Value,
88) -> Result<LoopOutcome, RuntimeError> {
89 let input = begin(ctx, agent, initial_input).await?;
90 let outcome = drive_loop(ctx, agent, &input).await?;
91 // The built-in path records the terminal itself, in the same position and
92 // with the same output the loop used to record inline. Moving the call here
93 // changes no bytes: `begin`, the loop's events, then `RunCompleted`, in that
94 // order, exactly as before the split.
95 if let LoopOutcome::Completed(output) = &outcome {
96 ctx.complete_run(output).await?;
97 }
98 Ok(outcome)
99}
100
101/// Records (or replays) the run's head and returns the input the loop drives
102/// on. The first half of [`drive`], split out so [`drive_loop`] can be driven
103/// against a run whose head was opened some other way.
104pub(crate) async fn begin(
105 ctx: &mut RunCtx,
106 agent: &Agent,
107 initial_input: &Value,
108) -> Result<Value, RuntimeError> {
109 ctx.begin(agent.def_hash(), initial_input).await
110}
111
112/// Runs the built-in agent loop over an already-begun run, returning the final
113/// output (a [`LoopOutcome::Completed`]) or a park — but **not** recording the
114/// terminal `RunCompleted`.
115///
116/// The second half of [`drive`], made public so an external driver can run an
117/// agent loop inside a run it opened itself. The graph engine uses exactly
118/// this: it opens the log with `GraphRunStarted`, records `NodeEntered`, calls
119/// `drive_loop` (whose model and tool events land in the same log), records
120/// `NodeExited`, and moves to the next node — recording the single terminal
121/// `RunCompleted` only after its last node. Leaving the terminal to the caller
122/// is the whole reason the completion moved out of the loop and into [`drive`].
123///
124/// `input` is the already-begun run's input (what [`begin`] returned).
125///
126/// # Errors
127///
128/// Whatever the `RunCtx` operations surface: [`RuntimeError::Replay`] on
129/// divergence, [`RuntimeError::Model`] on a live provider failure,
130/// [`RuntimeError::Store`] on a persistence failure.
131pub async fn drive_loop(
132 ctx: &mut RunCtx,
133 agent: &Agent,
134 input: &Value,
135) -> Result<LoopOutcome, RuntimeError> {
136 let mut conversation: Vec<Message> = vec![Message::user(content_string(input))];
137 let llm_tools: Vec<Tool> = agent
138 .tools()
139 .descriptors()
140 .into_iter()
141 .map(|descriptor| Tool {
142 name: descriptor.name,
143 description: Some(descriptor.description),
144 input_schema: descriptor.input_schema,
145 })
146 .collect();
147
148 let mut steps: u64 = 0;
149 let mut input_tokens: u64 = 0;
150 let mut output_tokens: u64 = 0;
151 let mut started_at: Option<OffsetDateTime> = None;
152 let mut extensions = BudgetExtensions::default();
153 let mut failures = FailureTracker::new();
154
155 loop {
156 // One recorded clock observation per iteration; the first doubles as
157 // the wall-time baseline. Never the ambient clock: the identical
158 // elapsed value must be observable on replay.
159 let now = ctx.now().await?;
160 let start = *started_at.get_or_insert(now);
161
162 // Budget checks run between events, before the model call, over
163 // replayed data only. A crossing parks exactly like a suspension; a
164 // recorded resume may extend the budget and the check re-runs.
165 loop {
166 let observations = BudgetObservations {
167 steps,
168 input_tokens,
169 output_tokens,
170 elapsed_seconds: (now - start).as_seconds_f64(),
171 };
172 let Some((budget, observed)) =
173 agent
174 .budgets()
175 .first_crossing(&extensions, agent.pricing(), &observations)
176 else {
177 break;
178 };
179 ctx.budget_exceeded(budget, observed).await?;
180 match ctx.await_resume().await? {
181 Resumption::Parked => {
182 return Ok(LoopOutcome::Parked(ParkReason::BudgetExceeded {
183 budget,
184 observed,
185 }));
186 }
187 Resumption::Resumed(resume_input) => extensions.absorb(&resume_input),
188 }
189 }
190
191 let mut request = MessageRequest::new(agent.model(), agent.max_response_tokens())
192 .with_messages(conversation.clone());
193 if let Some(system) = agent.system_prompt() {
194 request = request.with_system(system);
195 }
196 if !llm_tools.is_empty() {
197 request = request.with_tools(llm_tools.clone());
198 }
199
200 let turn = ctx.model_call(agent.client(), &request).await?;
201 steps += 1;
202 input_tokens = input_tokens.saturating_add(u64::from(turn.usage.input_tokens));
203 output_tokens = output_tokens.saturating_add(u64::from(turn.usage.output_tokens));
204
205 let tool_uses: Vec<(String, String, Value)> = turn
206 .response
207 .tool_uses()
208 .into_iter()
209 .map(|(id, name, tool_input)| (id.to_owned(), name.to_owned(), tool_input.clone()))
210 .collect();
211
212 conversation.push(Message::assistant_blocks(turn.response.content.clone()));
213
214 // No tool calls: the text is the final answer. The loop returns it
215 // without recording the terminal; the caller records `RunCompleted`
216 // (`drive` straight away, the graph engine once after its last node).
217 if tool_uses.is_empty() {
218 let output = Value::String(turn.response.text());
219 return Ok(LoopOutcome::Completed(output));
220 }
221
222 let mut result_blocks: Vec<ContentBlock> = Vec::with_capacity(tool_uses.len());
223 for (tool_use_id, name, tool_input) in tool_uses {
224 let Some(tool) = agent.tools().get(&name) else {
225 // The model named a tool the agent does not have. This is
226 // derived purely from the recorded response, so it needs no
227 // event of its own; the error content is deterministic.
228 result_blocks.push(ContentBlock::tool_error(
229 tool_use_id,
230 format!("unknown tool `{name}`"),
231 ));
232 continue;
233 };
234
235 // Idempotent calls get a key derived from recorded randomness,
236 // so the same key reappears on replay and on a post-crash retry.
237 let idempotency_key = match tool.effect() {
238 Effect::Idempotent => Some(format!("{:016x}", ctx.random().await?)),
239 Effect::Read | Effect::Write => None,
240 };
241
242 match ctx
243 .tool_call(tool, &tool_input, idempotency_key.as_deref())
244 .await?
245 {
246 ToolCallResult::Output(output) => {
247 failures.record_success();
248 result_blocks.push(ContentBlock::tool_result(
249 tool_use_id,
250 content_string(&output),
251 ));
252 }
253 ToolCallResult::Failed(failure) => {
254 // The full error is already in the event log; the model
255 // sees the compacted or collapsed form only.
256 let content = failures.content_for_failure(&name, &failure.message);
257 result_blocks.push(ContentBlock::tool_error(tool_use_id, content));
258 }
259 ToolCallResult::Suspended(suspension) => {
260 ctx.suspend(&suspension.reason, &suspension.input_schema)
261 .await?;
262 match ctx.await_resume().await? {
263 Resumption::Parked => {
264 return Ok(LoopOutcome::Parked(ParkReason::Suspended {
265 reason: suspension.reason,
266 input_schema: suspension.input_schema,
267 }));
268 }
269 Resumption::Resumed(resume_input) => {
270 // The recorded resume input is the tool's answer.
271 failures.record_success();
272 result_blocks.push(ContentBlock::tool_result(
273 tool_use_id,
274 content_string(&resume_input),
275 ));
276 }
277 }
278 }
279 }
280 }
281 conversation.push(Message::user_blocks(result_blocks));
282 }
283}