Skip to main content

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}