Skip to main content

runtime/turn/
wave.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Boundary 3: the tool wave, accounted in call order.
7//!
8//! The reads a turn started while the model was still generating
9//! (`super::speculation`) are accounted here like every other call, each
10//! taking its cached result; `crates/runtime/spec/Turn/Speculation.lean` is the
11//! authority on why that leaves the lines, in the order and with the
12//! payloads, that serial execution writes.
13//!
14//! Each call's two lines carry their own moments: the start is read once
15//! the call is admitted and before its tool runs, the answer before the
16//! tool face packages it, so a stamp the face renders is the answer's
17//! moment (runtime D8). The turn reads both; the face holds none.
18
19use kernel::event::record::{ToolAnswer, ToolCalled, ToolResult};
20use kernel::{
21    AxCode, AxError, ContentBlock, Effect, Ledger, ModelUsage, Payload, RenderIntent, TimeMs, Tool,
22    ToolCall, ToolMeta, ToolOutcome,
23};
24
25use crate::bench::Ticket;
26
27use crate::compaction::Exchange;
28
29use super::{Carried, Interrupt, PhaseOutcome, Recording, ToolWave, Turn};
30
31mod closure;
32mod reorder;
33
34use reorder::all_at_once;
35pub(super) use reorder::lost_answer;
36
37/// What the model reads back as the text of a tool result: the same
38/// payload the ledger keeps, printed once so the two cannot disagree.
39fn printed(payload: &Payload, action: &'static str) -> Result<String, AxError> {
40    serde_json::to_string(payload).map_err(|err| {
41        AxError::failure(AxCode::InvalidArgs, action, err.to_string()).with_recovery(
42            "report this against runtime::turn::wave: a payload refused JSON after \
43             its construction already accepted it",
44        )
45    })
46}
47
48/// A wave's tools in three stages, so the calls that only read can run
49/// at once while everything they decide stays in call order.
50///
51/// The leading run of calls whose tool declares `Effect::Read` is
52/// admitted one call at a time, in call order; the tools of the calls it
53/// clears then run at once, each on its own scoped thread; and every
54/// answer is accounted one call at a time, in call order. The first
55/// call with any other effect waits until every one of them has
56/// answered, and from there the wave is serial. The Ledger sees the same
57/// lines in the same order either way: results wait in a reorder buffer
58/// and are written in call order.
59///
60/// A trait rather than three closures: admitting and accounting write
61/// the same dedup table and taint that `tool` lends out, and three
62/// closures cannot each borrow one bench.
63pub trait ConcurrentInvoke {
64    /// The registration of the call's tool: its effect decides whether
65    /// the call is read-only, and `tool_called` copies its effect and
66    /// render (`crates/runtime/spec/Turn.lean` §8-51). `None` is a tool the bench does not
67    /// know, which is not read-only.
68    fn meta_of(&self, call: &ToolCall) -> Option<&ToolMeta>;
69    /// The call behind a call through `call`, resolved before anything
70    /// reads a registration (`crates/runtime/Spec.lean` §8-61).
71    fn resolve_call(&self, call: ToolCall) -> ToolCall {
72        call
73    }
74    /// The tool a read-only call would run on, lent out before the call
75    /// is admitted so it can start while the model is still generating.
76    /// What it returns reaches the model only if `admit` later clears the
77    /// same call. `None`, the default, starts nothing early.
78    fn ahead(&self, _call: &ToolCall) -> Option<&dyn Tool> {
79        None
80    }
81    /// Stage one, serial in call order: whether the call may run, at the
82    /// turn's stamp. An answer that needs no tool (a replay, a door's
83    /// refusal) comes back here.
84    fn admit(&mut self, call: &ToolCall, t: TimeMs) -> Admitted;
85    /// Stage two: the tool a cleared call runs on. It takes `&self`, so
86    /// every cleared call's tool is lent out at once and invoked from
87    /// several threads; a tool is `Sync` where the implementor need not be.
88    fn tool(&self, ticket: &Ticket) -> Result<&dyn Tool, AxError>;
89    /// Stage three, serial in call order: records what the tool answered
90    /// and returns what the model reads back.
91    fn account(
92        &mut self,
93        call: &ToolCall,
94        ticket: Ticket,
95        answered: Result<ToolOutcome, AxError>,
96    ) -> Result<ToolOutcome, AxError>;
97}
98
99/// What admitting one call decided.
100#[derive(Debug)]
101pub enum Admitted {
102    /// Answered without running a tool; the answer goes to the model.
103    Answered(Result<ToolOutcome, AxError>),
104    /// The call may run: `tool` and then `account` take the ticket.
105    Cleared(Ticket),
106}
107
108/// Whether the call's tool is registered as only reading.
109pub(super) fn reads_only(tools: &dyn ConcurrentInvoke, call: &ToolCall) -> bool {
110    matches!(tools.meta_of(call), Some(meta) if meta.effect == Effect::Read)
111}
112
113/// A call, the moments it started (after admission, before its tool
114/// ran) and answered (before the tool face packaged the answer), and its
115/// tool's registration, which `tool_called` copies.
116struct Timed<'c> {
117    call: &'c ToolCall,
118    started: TimeMs,
119    answered: TimeMs,
120    effect: Option<Effect>,
121    render: Option<RenderIntent>,
122}
123
124impl<'c> Timed<'c> {
125    fn of(
126        call: &'c ToolCall,
127        started: TimeMs,
128        answered: TimeMs,
129        tools: &dyn ConcurrentInvoke,
130    ) -> Timed<'c> {
131        let meta = tools.meta_of(call);
132        Timed {
133            call,
134            started,
135            answered,
136            effect: meta.map(|registered| registered.effect.clone()),
137            render: meta.map(|registered| registered.render.clone()),
138        }
139    }
140}
141
142impl<'h> Turn<'h, ToolWave> {
143    /// The calls this wave is about to make, in call order.
144    pub(crate) fn calls(&self) -> &[ToolCall] {
145        &self.state.calls
146    }
147
148    /// What the provider counted for the call that asked for this wave.
149    pub(crate) fn usage(&self) -> Option<&ModelUsage> {
150        self.state.usage.as_ref()
151    }
152
153    /// Boundary 3 (before tool execution), accounted in call order, with
154    /// the leading read-only calls run at once (see [`ConcurrentInvoke`]).
155    /// A tool Err is not a turn Err — it lands in `tool_result` and goes
156    /// back to the model (the model is the recovery subject).
157    ///
158    /// Both tool events reach the ledger through `append_redacted`: a
159    /// tool's arguments and its result are the two payloads most likely
160    /// to quote a credential the work just read.
161    ///
162    /// `still_going` is asked before every call, with that call's index.
163    /// One question per wave left a halted scope running whatever the
164    /// model asked for in one reply: eight edits are eight effects, and
165    /// the person who stopped the city waited for all of them. What it
166    /// answers is consumed through the one boundary consumer, so a steer
167    /// between two calls is recorded here, before the next assembly
168    /// hands it to the model. For the leading reads it is asked for every
169    /// call before any of them starts, in call order, and a cancel starts
170    /// only the calls before it: the calls a serial wave would have made
171    /// before the same cancel. Each answer is consumed just before its
172    /// call is accounted, so a steer lands on the ledger where a serial
173    /// wave writes it.
174    pub fn execute_concurrent(
175        mut self,
176        interrupt: Interrupt,
177        ledger: &mut dyn Ledger,
178        tools: &mut dyn ConcurrentInvoke,
179        still_going: &mut dyn FnMut(u32) -> Interrupt,
180    ) -> Result<PhaseOutcome<Turn<'h, Recording>>, AxError> {
181        if let Some(cancelled) = self.consume_boundary(interrupt, ledger)? {
182            return Ok(PhaseOutcome::Cancelled(cancelled));
183        }
184        let calls: Vec<ToolCall> = std::mem::take(&mut self.state.calls)
185            .into_iter()
186            .map(|call| tools.resolve_call(call))
187            .collect();
188        let t = self.journal.stamp();
189        let mut exchange = self.open_exchange();
190        let reads = calls
191            .iter()
192            .take_while(|call| reads_only(tools, call))
193            .count();
194        let mut going = Vec::with_capacity(reads);
195        let mut halt = Interrupt::None;
196        for index in (0..=u32::MAX).take(reads) {
197            match still_going(index) {
198                Interrupt::Cancel => {
199                    halt = Interrupt::Cancel;
200                    break;
201                }
202                standing @ (Interrupt::None | Interrupt::Steer { .. }) => going.push(standing),
203            }
204        }
205        let leading: Vec<&ToolCall> = calls.iter().take(going.len()).collect();
206        let mut admitted = Vec::with_capacity(leading.len());
207        let mut started = Vec::with_capacity(leading.len());
208        for call in &leading {
209            admitted.push(tools.admit(call, t));
210            started.push(self.journal.read_clock()?);
211        }
212        let early = std::mem::take(&mut self.state.speculated).answers_for(&leading);
213        let mut answers = all_at_once(&*tools, &leading, &admitted, &early).into_iter();
214        for ((((call, at), standing), admission), cached) in leading
215            .iter()
216            .zip(started)
217            .zip(going)
218            .zip(admitted)
219            .zip(early)
220        {
221            if let Some(cancelled) = self.consume_boundary(standing, ledger)? {
222                return Ok(PhaseOutcome::Cancelled(cancelled));
223            }
224            let answered_at = self.journal.read_clock()?;
225            let answered = match admission {
226                Admitted::Answered(answered) => answered,
227                Admitted::Cleared(ticket) => {
228                    let ran = cached
229                        .unwrap_or_else(|| answers.next().unwrap_or_else(|| Err(lost_answer())));
230                    tools.account(call, ticket, ran)
231                }
232            };
233            let timed = Timed::of(call, at, answered_at, &*tools);
234            self.account(ledger, &mut exchange, timed, answered)?;
235        }
236        if let Some(cancelled) = self.consume_boundary(halt, ledger)? {
237            return Ok(PhaseOutcome::Cancelled(cancelled));
238        }
239        for (index, call) in (0..=u32::MAX).zip(&calls).skip(leading.len()) {
240            if let Some(cancelled) = self.consume_boundary(still_going(index), ledger)? {
241                return Ok(PhaseOutcome::Cancelled(cancelled));
242            }
243            let (timed, answered) = self.alone(tools, call)?;
244            self.account(ledger, &mut exchange, timed, answered)?;
245        }
246        Ok(self.recorded(calls, exchange))
247    }
248
249    fn open_exchange(&mut self) -> Exchange {
250        let mut exchange = Exchange::new();
251        exchange.push_assistant(std::mem::take(&mut self.state.assistant));
252        exchange
253    }
254
255    /// One call through all three stages on this thread: how every call
256    /// after the leading reads runs. The start is read once the call is
257    /// admitted; the answer before the face's `account` packages it.
258    fn alone<'c>(
259        &mut self,
260        tools: &mut dyn ConcurrentInvoke,
261        call: &'c ToolCall,
262    ) -> Result<(Timed<'c>, Result<ToolOutcome, AxError>), AxError> {
263        let admission = tools.admit(call, self.journal.stamp());
264        let started = self.journal.read_clock()?;
265        let (answered_at, answered) = match admission {
266            Admitted::Answered(answered) => (self.journal.read_clock()?, answered),
267            Admitted::Cleared(ticket) => {
268                let ran = tools.tool(&ticket).and_then(|tool| tool.invoke(call));
269                let answered_at = self.journal.read_clock()?;
270                (answered_at, tools.account(call, ticket, ran))
271            }
272        };
273        Ok((Timed::of(call, started, answered_at, &*tools), answered))
274    }
275
276    /// Writes one call's `tool_called` and `tool_result` lines and its
277    /// result block: the one place a wave's accounting happens, whichever
278    /// way the call was run.
279    fn account(
280        &mut self,
281        ledger: &mut dyn Ledger,
282        exchange: &mut Exchange,
283        timed: Timed<'_>,
284        answered: Result<ToolOutcome, AxError>,
285    ) -> Result<(), AxError> {
286        let Timed {
287            call,
288            started,
289            answered: answered_at,
290            effect,
291            render,
292        } = timed;
293        let called = ToolCalled {
294            id: call.id.clone(),
295            name: call.name.clone(),
296            subject: ToolCalled::subject_of(&call.args),
297            args: call.args.clone(),
298            effect,
299            render,
300        };
301        self.journal.append_redacted(
302            ledger,
303            Carried::ToolCalled { at: started },
304            Payload::of(&called)?,
305        )?;
306        let mut pictures = Vec::new();
307        let (answer, content, is_error) = match answered {
308            Ok(ToolOutcome {
309                result: outcome,
310                attachments,
311            }) => {
312                pictures = attachments;
313                (
314                    ToolAnswer::Answered {
315                        result: outcome.clone(),
316                    },
317                    printed(&outcome, "encode tool result")?,
318                    false,
319                )
320            }
321            Err(tool_err) => {
322                let error = Payload::of(&tool_err)?;
323                (
324                    ToolAnswer::Failed {
325                        error: error.clone(),
326                    },
327                    printed(&error, "encode tool error")?,
328                    true,
329                )
330            }
331        };
332        let result = ToolResult {
333            tool_use_id: call.id.clone(),
334            name: call.name.clone(),
335            answer,
336        };
337        exchange.push_result(ContentBlock::ToolResult {
338            tool_use_id: call.id.clone(),
339            content,
340            is_error,
341            // What the tool produced, carried through unchanged: the
342            // bytes are already in the content store, so this is a
343            // reference and four integers whatever the picture is.
344            attachments: pictures,
345        });
346        self.journal.append_redacted(
347            ledger,
348            Carried::ToolResult { at: answered_at },
349            Payload::of(&result)?,
350        )?;
351        Ok(())
352    }
353
354    fn recorded(
355        self,
356        calls: Vec<ToolCall>,
357        exchange: Exchange,
358    ) -> PhaseOutcome<Turn<'h, Recording>> {
359        PhaseOutcome::Advanced(Turn {
360            journal: self.journal,
361            state: Recording {
362                model_returned: self.state.model_returned,
363                calls_made: calls.len(),
364                exchange,
365                usage: self.state.usage,
366                stop: self.state.stop,
367            },
368        })
369    }
370}