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}