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}