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; 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}