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; sleep parks on a timer; failure compacts into
22//! the tool_result)
23//! append tool results to the conversation
24//! } else {
25//! ctx.complete_run(final text)
26//! }
27//! }
28//! ```
29//!
30//! # Structured output
31//!
32//! [`drive_loop_structured`] runs the same loop under a declared output
33//! schema. The request then carries one synthetic tool beyond the agent's
34//! own, [`ANSWER_TOOL`], whose `input_schema` IS the declared schema, and a
35//! `tool_choice` of `any`, so a bare-text terminal turn cannot happen by API
36//! contract. The loop ends when that tool is called alone in a turn and its
37//! input passes [`crate::validate_against_schema`]; the input, verbatim, is
38//! the loop's output. Anything else feeds back and the loop asks again: an
39//! answer called beside real tool work gets a `tool_error` saying so (the
40//! real calls run normally), a violating answer gets a `tool_error` naming
41//! the violation, and a turn with no tool call at all (providers vary) gets
42//! a re-ask. There is no retry counter: the steps budget is the bound, and a
43//! crossing mid-re-ask parks like any other.
44//!
45//! Nothing has to ask for that path by name. An [`Agent`] that declares an
46//! [`output_schema`](Agent::output_schema) drives it automatically through
47//! [`drive`], so a schema written into an `agent.toml` reaches every
48//! server-side loop that runs the agent; [`drive_loop_structured`] is the
49//! same thing for a caller driving a schema of its own, which is how a
50//! graph's `agent` node overrides its agent's default.
51//!
52//! # Determinism inventory
53//!
54//! Everything the loop feeds forward is a pure function of recorded data:
55//! the conversation is rebuilt from replayed model responses, tool outputs,
56//! and resume inputs; budget observations come from recorded usage and
57//! recorded `now` observations; idempotency keys derive from recorded
58//! random bits; error compaction is a pure function of recorded failures; a
59//! woken tool call's result is a fixed shape over the wake instant its own
60//! recorded completion holds, never a clock read.
61//! So a replayed drive makes bit-identical requests (hashes match) and
62//! takes identical branches.
63//!
64//! Two deliberately unrecorded edges, both derived from recorded data and
65//! therefore still deterministic: an unknown tool name (the model asked for
66//! a tool the agent does not have) becomes an error `tool_result` without
67//! any event, and the count-based repeat summary replaces repeated error
68//! text without changing the log.
69//!
70//! Structured output adds no third edge: every string it feeds back is one of
71//! this module's fixed templates plus, for a violation, the verdict string
72//! [`crate::validate_against_schema`] returned. That verdict decides whether
73//! another hashed model call happens, which is exactly why it comes from this
74//! repo's own validator and not a library whose message text is free to
75//! change under a version bump.
76
77use salvor_llm::{ContentBlock, Message, MessageRequest, Tool, ToolChoice};
78use serde_json::Value;
79
80use crate::agent::Agent;
81use crate::compact::FailureTracker;
82use crate::ctx::{Resumption, RunCtx, ToolCallResult, Waking};
83use crate::error::RuntimeError;
84use crate::runtime::ParkReason;
85use crate::validate::validate_against_schema;
86use crate::wire::{content_string, slept_output};
87use salvor_core::Effect;
88use salvor_core::{BudgetExtensions, BudgetObservations};
89use time::OffsetDateTime;
90
91/// The name of the synthetic tool a structured-output loop answers through.
92///
93/// An agent that offers a real tool under this name cannot run under a
94/// declared output schema: the two calls would be indistinguishable in the
95/// response, so [`drive_loop_structured`] refuses with
96/// [`RuntimeError::AnswerToolNameTaken`] before any model call.
97pub const ANSWER_TOOL: &str = "salvor_answer";
98
99/// The answer tool's description. Fixed text, like every other string this
100/// loop puts in a request: it is hashed into `ModelCallRequested`.
101const ANSWER_TOOL_DESCRIPTION: &str = "Deliver your final reply by calling this tool; its input is \
102 the reply itself, and nothing else you write is read as the answer.";
103
104/// Fed back when the answer call shared a turn with real tool calls.
105const ANSWER_NOT_ALONE: &str = "`salvor_answer` was called alongside other tools; it ends the turn, \
106 so call it alone once the tool work it depends on has come back.";
107
108/// Fed back when a turn carried no tool call at all, which `tool_choice` was
109/// supposed to make impossible. Providers vary; the loop asks again rather
110/// than reading a bare-text turn as an answer it never validated.
111const NO_TOOL_CALL_REASK: &str = "That turn called no tool. Call a tool, or deliver your final \
112 reply by calling `salvor_answer`.";
113
114/// The content a schema violation feeds back: our validator's own verdict,
115/// wrapped in a fixed template naming what to do about it.
116fn violation_content(violation: &str) -> String {
117 format!(
118 "`salvor_answer` was called with input that does not match its schema: {violation}. Call \
119 it again with input in the declared shape."
120 )
121}
122
123/// How one drive of the loop ended: it produced a final output, or it parked
124/// and the process should stop driving it.
125///
126/// [`Completed`](LoopOutcome::Completed) carries the loop's final output but
127/// does **not** mean the run's terminal `RunCompleted` has been recorded:
128/// [`drive_loop`] deliberately leaves that to its caller. [`drive`] records it
129/// straight away, preserving the built-in loop's log byte for byte; the graph
130/// engine records it once, after its last node, so an agent loop can run as one
131/// node among many inside a single graph log without each node closing the run.
132#[derive(Debug, Clone)]
133pub enum LoopOutcome {
134 /// The loop produced this final output. The caller records the terminal
135 /// `RunCompleted`.
136 Completed(Value),
137 /// The run is parked durably; resume it later with input.
138 Parked(ParkReason),
139}
140
141/// Drives one run (fresh, recovering, or resuming; the `ctx` knows which)
142/// to a final output or a park.
143///
144/// This is exactly [`begin`] followed by the loop, with the terminal
145/// `RunCompleted` recorded here on completion. Splitting those two halves out
146/// is what lets the graph engine run [`drive_loop`] against a `RunCtx` whose
147/// log it already opened with `GraphRunStarted`: the agent node contributes its
148/// model and tool events without a second run head and without closing the run.
149///
150/// Which loop runs is the agent's own decision: an agent that declares an
151/// [`output_schema`](Agent::output_schema) drives the structured path, and one
152/// that declares none drives the plain one. So the declaration in an
153/// `agent.toml` reaches `salvor run`, `Runtime::start`, `recover`, and
154/// `resume` without any of them being told about it a second time. (A
155/// structured drive whose agent already owns a real `salvor_answer` tool still
156/// fails with [`RuntimeError::AnswerToolNameTaken`], but here the head is
157/// already recorded when it does, exactly as for any other failure on the
158/// first step.)
159pub(crate) async fn drive(
160 ctx: &mut RunCtx,
161 agent: &Agent,
162 initial_input: &Value,
163) -> Result<LoopOutcome, RuntimeError> {
164 let input = begin(ctx, agent, initial_input).await?;
165 let outcome = drive_loop_inner(ctx, agent, &input, agent.output_schema()).await?;
166 // The built-in path records the terminal itself, in the same position and
167 // with the same output the loop used to record inline. Moving the call here
168 // changes no bytes: `begin`, the loop's events, then `RunCompleted`, in that
169 // order, exactly as before the split.
170 if let LoopOutcome::Completed(output) = &outcome {
171 ctx.complete_run(output).await?;
172 }
173 Ok(outcome)
174}
175
176/// Records (or replays) the run's head and returns the input the loop drives
177/// on. The first half of [`drive`], split out so [`drive_loop`] can be driven
178/// against a run whose head was opened some other way.
179pub(crate) async fn begin(
180 ctx: &mut RunCtx,
181 agent: &Agent,
182 initial_input: &Value,
183) -> Result<Value, RuntimeError> {
184 ctx.begin(agent.def_hash(), initial_input).await
185}
186
187/// Runs the built-in agent loop over an already-begun run, returning the final
188/// output (a [`LoopOutcome::Completed`]) or a park, but **not** recording the
189/// terminal `RunCompleted`.
190///
191/// The second half of [`drive`], made public so an external driver can run an
192/// agent loop inside a run it opened itself. The graph engine uses exactly
193/// this: it opens the log with `GraphRunStarted`, records `NodeEntered`, calls
194/// `drive_loop` (whose model and tool events land in the same log), records
195/// `NodeExited`, and moves to the next node, recording the single terminal
196/// `RunCompleted` only after its last node. Leaving the terminal to the caller
197/// is the whole reason the completion moved out of the loop and into [`drive`].
198///
199/// `input` is the already-begun run's input (what [`begin`] returned).
200///
201/// # Errors
202///
203/// Whatever the `RunCtx` operations surface: [`RuntimeError::Replay`] on
204/// divergence, [`RuntimeError::Model`] on a live provider failure,
205/// [`RuntimeError::Store`] on a persistence failure.
206pub async fn drive_loop(
207 ctx: &mut RunCtx,
208 agent: &Agent,
209 input: &Value,
210) -> Result<LoopOutcome, RuntimeError> {
211 drive_loop_inner(ctx, agent, input, None).await
212}
213
214/// Runs the built-in agent loop under a declared output schema, returning a
215/// [`LoopOutcome::Completed`] whose value is the model's structured answer
216/// (never a string of prose) or a park.
217///
218/// Same loop, same events, same caller contract as [`drive_loop`]: the
219/// difference is how the loop is allowed to end. The request offers
220/// [`ANSWER_TOOL`] beside the agent's own tools with `schema` as its input
221/// schema and forces some tool call, and the loop ends only when that tool is
222/// called alone and its input satisfies `schema` under
223/// [`crate::validate_against_schema`]. See the module docs for what each other
224/// shape of turn feeds back.
225///
226/// # Errors
227///
228/// Everything [`drive_loop`] surfaces, plus
229/// [`RuntimeError::AnswerToolNameTaken`] when the agent already offers a real
230/// tool named [`ANSWER_TOOL`]. That one is checked before the first model
231/// call, so a refused drive records nothing.
232pub async fn drive_loop_structured(
233 ctx: &mut RunCtx,
234 agent: &Agent,
235 input: &Value,
236 schema: &Value,
237) -> Result<LoopOutcome, RuntimeError> {
238 drive_loop_inner(ctx, agent, input, Some(schema)).await
239}
240
241/// The one implementation behind [`drive_loop`] and
242/// [`drive_loop_structured`]; `output_schema` is what separates them.
243async fn drive_loop_inner(
244 ctx: &mut RunCtx,
245 agent: &Agent,
246 input: &Value,
247 output_schema: Option<&Value>,
248) -> Result<LoopOutcome, RuntimeError> {
249 let mut conversation: Vec<Message> = vec![Message::user(content_string(input))];
250 let mut llm_tools: Vec<Tool> = agent
251 .tools()
252 .descriptors()
253 .into_iter()
254 .map(|descriptor| Tool {
255 name: descriptor.name,
256 description: Some(descriptor.description),
257 input_schema: descriptor.input_schema,
258 })
259 .collect();
260
261 if let Some(schema) = output_schema {
262 // Before anything is recorded: two tools under one name would make the
263 // answer call unreadable in the response.
264 if llm_tools.iter().any(|tool| tool.name == ANSWER_TOOL) {
265 return Err(RuntimeError::AnswerToolNameTaken);
266 }
267 llm_tools.push(Tool {
268 name: ANSWER_TOOL.to_owned(),
269 description: Some(ANSWER_TOOL_DESCRIPTION.to_owned()),
270 input_schema: schema.clone(),
271 });
272 }
273
274 let mut steps: u64 = 0;
275 let mut input_tokens: u64 = 0;
276 let mut output_tokens: u64 = 0;
277 let mut started_at: Option<OffsetDateTime> = None;
278 let mut extensions = BudgetExtensions::default();
279 let mut failures = FailureTracker::new();
280
281 loop {
282 // One recorded clock observation per iteration; the first doubles as
283 // the wall-time baseline. Never the ambient clock: the identical
284 // elapsed value must be observable on replay.
285 let now = ctx.now().await?;
286 let start = *started_at.get_or_insert(now);
287
288 // Budget checks run between events, before the model call, over
289 // replayed data only. A crossing parks exactly like a suspension; a
290 // recorded resume may extend the budget and the check re-runs.
291 loop {
292 let observations = BudgetObservations {
293 steps,
294 input_tokens,
295 output_tokens,
296 elapsed_seconds: (now - start).as_seconds_f64(),
297 };
298 let Some((budget, observed)) =
299 agent
300 .budgets()
301 .first_crossing(&extensions, agent.pricing(), &observations)
302 else {
303 break;
304 };
305 ctx.budget_exceeded(budget, observed).await?;
306 match ctx.await_resume().await? {
307 Resumption::Parked => {
308 return Ok(LoopOutcome::Parked(ParkReason::BudgetExceeded {
309 budget,
310 observed,
311 }));
312 }
313 Resumption::Resumed(resume_input) => extensions.absorb(&resume_input),
314 }
315 }
316
317 let mut request = MessageRequest::new(agent.model(), agent.max_response_tokens())
318 .with_messages(conversation.clone());
319 if let Some(system) = agent.system_prompt() {
320 request = request.with_system(system);
321 }
322 if !llm_tools.is_empty() {
323 request = request.with_tools(llm_tools.clone());
324 }
325 if output_schema.is_some() {
326 // Some tool, the model's pick: the answer tool is one of them, so
327 // the turn that ends the loop is a tool call like any other and a
328 // bare-text terminal turn is off the table by API contract.
329 request = request.with_tool_choice(ToolChoice::any());
330 }
331
332 let turn = ctx.model_call(agent.client(), &request).await?;
333 steps += 1;
334 input_tokens = input_tokens.saturating_add(u64::from(turn.usage.input_tokens));
335 output_tokens = output_tokens.saturating_add(u64::from(turn.usage.output_tokens));
336
337 let tool_uses: Vec<(String, String, Value)> = turn
338 .response
339 .tool_uses()
340 .into_iter()
341 .map(|(id, name, tool_input)| (id.to_owned(), name.to_owned(), tool_input.clone()))
342 .collect();
343
344 conversation.push(Message::assistant_blocks(turn.response.content.clone()));
345
346 // No tool calls. Unstructured, the text is the final answer: the loop
347 // returns it without recording the terminal; the caller records
348 // `RunCompleted` (`drive` straight away, the graph engine once after
349 // its last node). Structured, `tool_choice` asked for a call and none
350 // came, so the loop asks again rather than reading prose as an answer
351 // it never validated.
352 if tool_uses.is_empty() {
353 if output_schema.is_none() {
354 let output = Value::String(turn.response.text());
355 return Ok(LoopOutcome::Completed(output));
356 }
357 conversation.push(Message::user(NO_TOOL_CALL_REASK));
358 continue;
359 }
360
361 // The one way a structured loop ends: the answer tool alone in its
362 // turn, carrying input the declared schema accepts. The input is the
363 // output, verbatim.
364 if let Some(schema) = output_schema
365 && let [(tool_use_id, name, answer)] = tool_uses.as_slice()
366 && name == ANSWER_TOOL
367 {
368 match validate_against_schema(answer, schema) {
369 Ok(()) => return Ok(LoopOutcome::Completed(answer.clone())),
370 Err(violation) => {
371 // A violation is a failed call like any other, streak
372 // collapse included: an answer that keeps missing the
373 // shape the same way stops re-sending the same wall of
374 // text back.
375 let content =
376 failures.content_for_failure(ANSWER_TOOL, &violation_content(&violation));
377 conversation.push(Message::user_blocks(vec![ContentBlock::tool_error(
378 tool_use_id.clone(),
379 content,
380 )]));
381 continue;
382 }
383 }
384 }
385
386 let mut result_blocks: Vec<ContentBlock> = Vec::with_capacity(tool_uses.len());
387 for (tool_use_id, name, tool_input) in tool_uses {
388 // An answer call that got here shared its turn with other calls
389 // (or repeated itself). The real calls run; this one is told to
390 // come back alone, so the answer is always the whole turn.
391 if output_schema.is_some() && name == ANSWER_TOOL {
392 result_blocks.push(ContentBlock::tool_error(tool_use_id, ANSWER_NOT_ALONE));
393 continue;
394 }
395 let Some(tool) = agent.tools().get(&name) else {
396 // The model named a tool the agent does not have. This is
397 // derived purely from the recorded response, so it needs no
398 // event of its own; the error content is deterministic.
399 result_blocks.push(ContentBlock::tool_error(
400 tool_use_id,
401 format!("unknown tool `{name}`"),
402 ));
403 continue;
404 };
405
406 // Idempotent calls get a key derived from recorded randomness,
407 // so the same key reappears on replay and on a post-crash retry.
408 let idempotency_key = match tool.effect() {
409 Effect::Idempotent => Some(format!("{:016x}", ctx.random().await?)),
410 Effect::Read | Effect::Write => None,
411 };
412
413 match ctx
414 .tool_call(tool, &tool_input, idempotency_key.as_deref())
415 .await?
416 {
417 ToolCallResult::Output(output) => {
418 failures.record_success();
419 result_blocks.push(ContentBlock::tool_result(
420 tool_use_id,
421 content_string(&output),
422 ));
423 }
424 ToolCallResult::Failed(failure) => {
425 // The full error is already in the event log; the model
426 // sees the compacted or collapsed form only.
427 let content = failures.content_for_failure(&name, &failure.message);
428 result_blocks.push(ContentBlock::tool_error(tool_use_id, content));
429 }
430 ToolCallResult::Suspended(suspension) => {
431 // The tool's discriminator is recorded and then carried
432 // out to the caller unchanged. A tool that parked the run
433 // on a webhook has said so, and a park report that turned
434 // that back into a human gate would send an operator
435 // looking for an approval nobody is asking them for.
436 ctx.suspend_with_kind(
437 &suspension.reason,
438 &suspension.input_schema,
439 suspension.kind,
440 )
441 .await?;
442 match ctx.await_resume().await? {
443 Resumption::Parked => {
444 return Ok(LoopOutcome::Parked(ParkReason::Suspended {
445 reason: suspension.reason,
446 input_schema: suspension.input_schema,
447 kind: suspension.kind,
448 }));
449 }
450 Resumption::Resumed(resume_input) => {
451 // The recorded resume input is the tool's answer.
452 failures.record_success();
453 result_blocks.push(ContentBlock::tool_result(
454 tool_use_id,
455 content_string(&resume_input),
456 ));
457 }
458 }
459 }
460 // The timer counterpart of the arm above, and deliberately its
461 // twin: park, and on a later drive carry on from the recorded
462 // events. The call is already settled when this arm runs (its
463 // completion carried the request), so the sleep that starts
464 // here holds no idempotency claim, however long it lasts.
465 ToolCallResult::Sleeping(sleep) => {
466 ctx.sleep_until(sleep.wake_at).await?;
467 match ctx.await_wake().await? {
468 Waking::Asleep { wake_at } => {
469 return Ok(LoopOutcome::Parked(ParkReason::Sleeping { wake_at }));
470 }
471 Waking::Woken => {
472 // The tool returned a deadline rather than a value,
473 // so the result is derived from the recorded wake
474 // instant. Deterministic: the instant comes from
475 // the completion, not from a clock read here.
476 failures.record_success();
477 result_blocks.push(ContentBlock::tool_result(
478 tool_use_id,
479 content_string(&slept_output(sleep.wake_at)),
480 ));
481 }
482 }
483 }
484 }
485 }
486 conversation.push(Message::user_blocks(result_blocks));
487 }
488}