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