runtime/turn.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//! The turn typestate: Assembling -> Calling ->
7//! ToolWave -> Recording, phase changes carrying `&mut dyn Ledger`.
8//! Interrupts are consumed *only* at phase boundaries — the boundary
9//! snapshot is a parameter of every transition, and no method exists that
10//! could observe one mid-phase. That absence is A9's structural half.
11//! A wave is many effects rather than one, so every call in it is a
12//! boundary of the same kind: the executor is asked before each, and a
13//! halt ends the wave there instead of after its last call.
14//! Steer consumes at a boundary too, but advances: it is an addition to
15//! the window, not an ending.
16//!
17//! Lines that record a moment the turn waited for - `model_called`,
18//! `model_returned`, `tool_called`, `tool_result` - carry that moment,
19//! read from the clock the driver handed to [`Turn::begin`]; every other
20//! line of the turn carries the turn's stamp. Order is `seq`'s business.
21//!
22//! `crates/runtime/spec/Turn.lean` proves what the boundaries must hold
23//! (§8-3): a cancel before call k leaves exactly the first k calls
24//! accounted, and a steer never ends a turn.
25
26use std::borrow::Cow;
27
28use kernel::event::record::{CancelReceived, ModelReturned, SteerReceived};
29use kernel::model::content_from_message;
30use kernel::{
31 Address, AxCode, AxError, B3Hash, BuildingPolicy, ChatRequest, ContentBlock, Ledger, Model,
32 ModelRequest, ModelReturn, ModelUsage, Payload, StopReason, TimeMs, ToolCall, ToolDef,
33};
34
35use crate::compaction::Exchange;
36use crate::conversation::Conversation;
37
38mod boundary;
39mod ledger;
40mod prompt;
41mod recovery;
42mod report;
43mod speculation;
44mod wave;
45
46pub(crate) use ledger::RunLine;
47use ledger::{Authored, Carried, Journal};
48pub use ledger::{Entry, HeldLines};
49
50pub use boundary::{Interrupt, NextCall, PhaseOutcome, TurnCancelled};
51pub use prompt::{PromptRecord, RunPrompt};
52pub use report::{CallShape, TurnReport};
53pub use speculation::Generating;
54pub use wave::{Admitted, ConcurrentInvoke};
55
56/// The typestate carrier. Phase data lives in `S` and is private to this
57/// module: a phase literal cannot be forged, a phase cannot be skipped,
58/// and no method returns an earlier phase. Everything the turn writes
59/// goes through `journal`, which is the module's only ledger door.
60#[derive(Debug)]
61pub struct Turn<'h, S> {
62 journal: Journal<'h>,
63 state: S,
64}
65
66#[derive(Debug)]
67pub struct Assembling(());
68
69#[derive(Debug)]
70pub struct Calling<'c> {
71 segments: [B3Hash; 4],
72 chat: ChatRequest<'c>,
73 /// The watchdog whose account round this call belongs to; the repair
74 /// segment asks it before sending again. `None` outside a run's
75 /// driver, where no round is open.
76 round: Option<&'c mut crate::Watchdog>,
77}
78
79#[derive(Debug)]
80pub struct ToolWave {
81 calls: Vec<ToolCall>,
82 speculated: speculation::Speculated,
83 model_returned: Entry,
84 assistant: Vec<ContentBlock>,
85 usage: Option<ModelUsage>,
86 stop: Option<StopReason>,
87}
88
89#[derive(Debug)]
90pub struct Recording {
91 model_returned: Entry,
92 calls_made: usize,
93 exchange: Exchange,
94 usage: Option<ModelUsage>,
95 stop: Option<StopReason>,
96}
97
98impl<'h> Turn<'h, Assembling> {
99 /// Opens a turn on the run's `lines`. `t` is the turn's stamp, which
100 /// every line of this turn carries but the four it waited for; the
101 /// executor advances it between turns (determinism rule 2). `now` is
102 /// read for those four moments only, on this thread, and never by a
103 /// tool. Lines an earlier turn held are still held, and go down at
104 /// this turn's first barrier ahead of its own (runtime D36).
105 pub fn begin(
106 lines: &'h mut HeldLines,
107 t: TimeMs,
108 now: &'h mut dyn FnMut() -> Result<TimeMs, AxError>,
109 ) -> Turn<'h, Assembling> {
110 Turn {
111 journal: Journal::open(lines, t, now),
112 state: Assembling(()),
113 }
114 }
115
116 /// Records how long this turn's model call and tool calls took, in
117 /// microseconds read off `monotonic_us` (kernel D20). A turn opened
118 /// without it records the moments and no durations.
119 #[must_use]
120 pub fn timed(mut self, monotonic_us: &'h mut dyn FnMut() -> u64) -> Turn<'h, Assembling> {
121 self.journal.time_with(monotonic_us);
122 self
123 }
124
125 /// Boundary 1 (before assembly). Builds the canonical request from
126 /// the frozen prefix (system blocks), the window (messages) and the
127 /// catalog's tool defs; appends `prompt_assembled` with the prefix's
128 /// full source notes unless the run has already recorded that same
129 /// payload.
130 pub fn assemble<'c>(
131 mut self,
132 interrupt: Interrupt,
133 ledger: &mut dyn Ledger,
134 prompt: RunPrompt<'_>,
135 conversation: &'c Conversation,
136 tools: &'c [ToolDef],
137 shape: &CallShape,
138 ) -> Result<PhaseOutcome<Turn<'h, Calling<'c>>>, AxError> {
139 if let Some(cancelled) = self.consume_boundary(interrupt, ledger)? {
140 return Ok(PhaseOutcome::Cancelled(cancelled));
141 }
142 // The per-turn digest, recomputed from the bytes this request
143 // will carry and checked against what the session froze. It
144 // comes before `prompt_assembled` is written: a refusal must not
145 // leave a line describing a prefix the city will not send.
146 let segments = prompt.prefix.verified_segment_hashes()?;
147 // One plan decides every breakpoint: the system blocks, the tail
148 // message and the record all read it, so the record names only
149 // breakpoints this request carries.
150 let plan = crate::prefix::BreakpointPlan::for_conversation(conversation.messages());
151 let payload = prompt.prefix.prompt_payload(&plan)?;
152 if !prompt.recorded.holds(&payload) {
153 self.journal
154 .append_authored(Authored::PromptAssembled, payload.clone());
155 prompt.recorded.remember(payload);
156 }
157 let chat = ChatRequest {
158 model: shape.model.clone(),
159 max_tokens: shape.max_tokens,
160 system: prompt.prefix.system_blocks()?,
161 messages: Cow::Borrowed(conversation.messages()),
162 tools: Cow::Borrowed(tools),
163 breakpoint: plan.message_breakpoint(),
164 shared: prompt.prefix.shared_part(),
165 effort: shape.effort,
166 };
167 Ok(PhaseOutcome::Advanced(Turn {
168 journal: self.journal,
169 state: Calling {
170 segments,
171 chat,
172 round: None,
173 },
174 }))
175 }
176}
177
178impl<'h, 'c> Turn<'h, Calling<'c>> {
179 /// Places this call in the account round `watchdog` holds, so a
180 /// repair that sends it again is one more send on the round's
181 /// current account (`crates/runtime/spec/Turn/Recovery.lean` §8-49).
182 #[must_use]
183 pub fn under(self, watchdog: &'c mut crate::Watchdog) -> Turn<'h, Calling<'c>> {
184 Turn {
185 journal: self.journal,
186 state: Calling {
187 round: Some(watchdog),
188 ..self.state
189 },
190 }
191 }
192}
193
194impl<'h> Turn<'h, Calling<'_>> {
195 /// Boundary 2 (before the provider call). Appends `model_called` and
196 /// `model_returned`; a provider Err propagates after nothing but the
197 /// boundary consumption touched the ledger. The reads `generating`
198 /// starts early are not events: they ride to the wave, which uses
199 /// each only for the settled call equal to the one started.
200 /// `'sink` is named rather than elided because the caller holds the
201 /// sink for the whole run and hands it to every turn: with an elided
202 /// lifetime the reborrow would have to shrink the trait object's own
203 /// lifetime, which `&mut` does not permit.
204 pub fn call<'sink>(
205 mut self,
206 interrupt: Interrupt,
207 ledger: &mut dyn Ledger,
208 model: &mut dyn Model,
209 policy: &BuildingPolicy,
210 generating: Generating<'_, 'sink>,
211 ) -> Result<PhaseOutcome<Turn<'h, ToolWave>>, AxError> {
212 if let Some(cancelled) = self.consume_boundary(interrupt, ledger)? {
213 return Ok(PhaseOutcome::Cancelled(cancelled));
214 }
215 let Calling {
216 segments,
217 chat,
218 round,
219 } = self.state;
220 // The hashes above describe the prefix; these describe the four
221 // system blocks that will actually go on the wire. They are the
222 // second per-turn digest, and they disagree only if the request
223 // was built from something other than the prefix it names.
224 let segments = crate::prefix::verified_system_hashes(&chat.system, &segments)?;
225 let request = ModelRequest {
226 policy: policy.clone(),
227 segments,
228 chat,
229 };
230 // The door `generating` picks. Every door returns the same
231 // `ModelReturn`, and the record below is written from that return
232 // in each case - so what a page sees arriving and what the ledger
233 // keeps cannot come from two different readings of one reply. A failure goes to the
234 // recovery pipeline before it leaves this phase (`crates/runtime/spec/Turn/Recovery.lean`
235 // §8-49), and every attempt - first or repaired - is recorded
236 // before it is made.
237 let mut call = recovery::ModelCall::open(&mut self.journal, ledger, model, &request);
238 // Outside a driver no round is open, and a watchdog with no round
239 // admits a repair as one always could.
240 let mut unheld = crate::Watchdog::default();
241 let mut repair = recovery::BlockingResend {
242 round: round.unwrap_or(&mut unheld),
243 };
244 let recovery::Settled {
245 returned: returned_value,
246 speculated,
247 first_at,
248 first_us,
249 sent_us,
250 } = call.ask(&mut [&mut repair], generating)?;
251 let arrived = self.journal.read_moment()?;
252 let ModelReturn {
253 message,
254 calls,
255 usage,
256 stop,
257 billed_usd_micros,
258 } = returned_value;
259 let assistant = content_from_message(&message)?;
260 let calls_len = u64::try_from(calls.len()).map_err(|_| {
261 AxError::failure(AxCode::InvalidArgs, "encode model return", "wave too large")
262 .with_recovery(
263 "lower this model's max output tokens so it asks for fewer tool \
264 calls in one reply",
265 )
266 })?;
267 let returned = ModelReturned {
268 message,
269 calls: calls_len,
270 usage,
271 stop,
272 billed_usd_micros,
273 first_at,
274 first_us,
275 took_us: arrived.since(sent_us),
276 };
277 let model_returned = self.journal.append_redacted(
278 Carried::ModelReturned { at: arrived.at },
279 Payload::of(&returned)?,
280 )?;
281 Ok(PhaseOutcome::Advanced(Turn {
282 journal: self.journal,
283 state: ToolWave {
284 calls,
285 speculated,
286 model_returned,
287 assistant,
288 usage,
289 stop,
290 },
291 }))
292 }
293}
294
295impl Turn<'_, Recording> {
296 /// Closes the turn. Recording is the accounting boundary: nothing
297 /// extra is appended here, because every effect is already on the
298 /// ledger. What the phase exists for is the fourth boundary — the
299 /// last moment before the run acts on what this turn decided,
300 /// including the work it handed down — and for the one compaction
301 /// door a turn has. The wave is whole by now, so this is the one
302 /// moment its snapshot may be replaced; a compactor meeting a
303 /// half-landed wave would group what this boundary groups once, and
304 /// `runtime::fork` rebuilding one turn at a time would answer
305 /// different bytes for the same history.
306 ///
307 /// It pays no barrier: what the wave held waits in the run's lines
308 /// for the next turn's model call, or for the run's freeze, and the
309 /// report names `model_returned` by its [`Entry`] until then
310 /// (runtime D36).
311 ///
312 /// # Errors
313 /// Propagates the ledger's refusal to record the boundary event and
314 /// a text the compaction cannot count.
315 pub fn record(
316 mut self,
317 interrupt: Interrupt,
318 ledger: &mut dyn Ledger,
319 ) -> Result<PhaseOutcome<TurnReport>, AxError> {
320 if let Some(cancelled) = self.consume_boundary(interrupt, ledger)? {
321 return Ok(PhaseOutcome::Cancelled(cancelled));
322 }
323 let Recording {
324 model_returned,
325 calls_made,
326 mut exchange,
327 usage,
328 stop,
329 } = self.state;
330 exchange.compact()?;
331 Ok(PhaseOutcome::Advanced(TurnReport {
332 redacted: self.journal.redacted(),
333 model_returned,
334 calls_made,
335 assistant: exchange.assistant().to_vec(),
336 wave_results: exchange.results().to_vec(),
337 usage,
338 stop,
339 }))
340 }
341}
342
343impl<S> Turn<'_, S> {
344 /// Holds a line the run writes at `addr` between two phases, in its
345 /// place among the turn's lines: it reaches the ledger at the next
346 /// barrier with them, so the run's line costs no barrier of its own
347 /// and the ledger's order is still the order of appending.
348 pub(crate) fn hold_run_line(&mut self, line: RunLine, addr: Address, data: Payload) {
349 self.journal.append_run_line(line, addr, data);
350 }
351
352 /// Ends the turn where it stands: `cancel_received`, one barrier that
353 /// carries it with every line the run still held, and the refs of
354 /// every line the run wrote so far.
355 ///
356 /// Reached from the boundary consumer below and from a wave that was
357 /// halted between two calls, so both endings are one line written in
358 /// one place.
359 fn cancel_here(&mut self, ledger: &mut dyn Ledger) -> Result<TurnCancelled, AxError> {
360 self.journal
361 .append_authored(Authored::CancelReceived, Payload::of(&CancelReceived {})?);
362 let lines = self.journal.lines();
363 lines.barrier(ledger)?;
364 Ok(TurnCancelled {
365 refs: lines.refs().to_vec(),
366 })
367 }
368
369 /// The one interrupt consumer. Cancel appends `cancel_received` and
370 /// ends the turn; Steer appends `steer_received` and advances — the
371 /// executor folds the text into its window for the next assembly.
372 fn consume_boundary(
373 &mut self,
374 interrupt: Interrupt,
375 ledger: &mut dyn Ledger,
376 ) -> Result<Option<TurnCancelled>, AxError> {
377 match interrupt {
378 Interrupt::None | Interrupt::Policy { .. } => Ok(None),
379 Interrupt::Cancel => Ok(Some(self.cancel_here(ledger)?)),
380 Interrupt::Steer { speaker, text } => {
381 let steer = SteerReceived {
382 source: speaker.recorded(),
383 text,
384 };
385 self.journal
386 .append_authored(Authored::SteerReceived, Payload::of(&steer)?);
387 Ok(None)
388 }
389 }
390 }
391}
392
393#[cfg(test)]
394#[allow(
395 clippy::unwrap_used,
396 clippy::expect_used,
397 clippy::panic,
398 clippy::indexing_slicing,
399 clippy::wildcard_enum_match_arm,
400 clippy::let_underscore_must_use,
401 clippy::let_underscore_untyped,
402 reason = "test code"
403)]
404mod tests;