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 effort: shape.effort,
165 };
166 Ok(PhaseOutcome::Advanced(Turn {
167 journal: self.journal,
168 state: Calling {
169 segments,
170 chat,
171 round: None,
172 },
173 }))
174 }
175}
176
177impl<'h, 'c> Turn<'h, Calling<'c>> {
178 /// Places this call in the account round `watchdog` holds, so a
179 /// repair that sends it again is one more send on the round's
180 /// current account (`crates/runtime/spec/Turn/Recovery.lean` §8-49).
181 #[must_use]
182 pub fn under(self, watchdog: &'c mut crate::Watchdog) -> Turn<'h, Calling<'c>> {
183 Turn {
184 journal: self.journal,
185 state: Calling {
186 round: Some(watchdog),
187 ..self.state
188 },
189 }
190 }
191}
192
193impl<'h> Turn<'h, Calling<'_>> {
194 /// Boundary 2 (before the provider call). Appends `model_called` and
195 /// `model_returned`; a provider Err propagates after nothing but the
196 /// boundary consumption touched the ledger. The reads `generating`
197 /// starts early are not events: they ride to the wave, which uses
198 /// each only for the settled call equal to the one started.
199 /// `'sink` is named rather than elided because the caller holds the
200 /// sink for the whole run and hands it to every turn: with an elided
201 /// lifetime the reborrow would have to shrink the trait object's own
202 /// lifetime, which `&mut` does not permit.
203 pub fn call<'sink>(
204 mut self,
205 interrupt: Interrupt,
206 ledger: &mut dyn Ledger,
207 model: &mut dyn Model,
208 policy: &BuildingPolicy,
209 generating: Generating<'_, 'sink>,
210 ) -> Result<PhaseOutcome<Turn<'h, ToolWave>>, AxError> {
211 if let Some(cancelled) = self.consume_boundary(interrupt, ledger)? {
212 return Ok(PhaseOutcome::Cancelled(cancelled));
213 }
214 let Calling {
215 segments,
216 chat,
217 round,
218 } = self.state;
219 // The hashes above describe the prefix; these describe the four
220 // system blocks that will actually go on the wire. They are the
221 // second per-turn digest, and they disagree only if the request
222 // was built from something other than the prefix it names.
223 let segments = crate::prefix::verified_system_hashes(&chat.system, &segments)?;
224 let request = ModelRequest {
225 policy: policy.clone(),
226 segments,
227 chat,
228 };
229 // The door `generating` picks. Every door returns the same
230 // `ModelReturn`, and the record below is written from that return
231 // in each case - so what a page sees arriving and what the ledger
232 // keeps cannot come from two different readings of one reply. A failure goes to the
233 // recovery pipeline before it leaves this phase (`crates/runtime/spec/Turn/Recovery.lean`
234 // §8-49), and every attempt - first or repaired - is recorded
235 // before it is made.
236 let mut call = recovery::ModelCall::open(&mut self.journal, ledger, model, &request);
237 // Outside a driver no round is open, and a watchdog with no round
238 // admits a repair as one always could.
239 let mut unheld = crate::Watchdog::default();
240 let mut repair = recovery::BlockingResend {
241 round: round.unwrap_or(&mut unheld),
242 };
243 let recovery::Settled {
244 returned: returned_value,
245 speculated,
246 first_at,
247 first_us,
248 sent_us,
249 } = call.ask(&mut [&mut repair], generating)?;
250 let arrived = self.journal.read_moment()?;
251 let ModelReturn {
252 message,
253 calls,
254 usage,
255 stop,
256 billed_usd_micros,
257 } = returned_value;
258 let assistant = content_from_message(&message)?;
259 let calls_len = u64::try_from(calls.len()).map_err(|_| {
260 AxError::failure(AxCode::InvalidArgs, "encode model return", "wave too large")
261 .with_recovery(
262 "lower this model's max output tokens so it asks for fewer tool \
263 calls in one reply",
264 )
265 })?;
266 let returned = ModelReturned {
267 message,
268 calls: calls_len,
269 usage,
270 stop,
271 billed_usd_micros,
272 first_at,
273 first_us,
274 took_us: arrived.since(sent_us),
275 };
276 let model_returned = self.journal.append_redacted(
277 Carried::ModelReturned { at: arrived.at },
278 Payload::of(&returned)?,
279 )?;
280 Ok(PhaseOutcome::Advanced(Turn {
281 journal: self.journal,
282 state: ToolWave {
283 calls,
284 speculated,
285 model_returned,
286 assistant,
287 usage,
288 stop,
289 },
290 }))
291 }
292}
293
294impl Turn<'_, Recording> {
295 /// Closes the turn. Recording is the accounting boundary: nothing
296 /// extra is appended here, because every effect is already on the
297 /// ledger. What the phase exists for is the fourth boundary — the
298 /// last moment before the run acts on what this turn decided,
299 /// including the work it handed down — and for the one compaction
300 /// door a turn has. The wave is whole by now, so this is the one
301 /// moment its snapshot may be replaced; a compactor meeting a
302 /// half-landed wave would group what this boundary groups once, and
303 /// `runtime::fork` rebuilding one turn at a time would answer
304 /// different bytes for the same history.
305 ///
306 /// It pays no barrier: what the wave held waits in the run's lines
307 /// for the next turn's model call, or for the run's freeze, and the
308 /// report names `model_returned` by its [`Entry`] until then
309 /// (runtime D36).
310 ///
311 /// # Errors
312 /// Propagates the ledger's refusal to record the boundary event and
313 /// a text the compaction cannot count.
314 pub fn record(
315 mut self,
316 interrupt: Interrupt,
317 ledger: &mut dyn Ledger,
318 ) -> Result<PhaseOutcome<TurnReport>, AxError> {
319 if let Some(cancelled) = self.consume_boundary(interrupt, ledger)? {
320 return Ok(PhaseOutcome::Cancelled(cancelled));
321 }
322 let Recording {
323 model_returned,
324 calls_made,
325 mut exchange,
326 usage,
327 stop,
328 } = self.state;
329 exchange.compact()?;
330 Ok(PhaseOutcome::Advanced(TurnReport {
331 redacted: self.journal.redacted(),
332 model_returned,
333 calls_made,
334 assistant: exchange.assistant().to_vec(),
335 wave_results: exchange.results().to_vec(),
336 usage,
337 stop,
338 }))
339 }
340}
341
342impl<S> Turn<'_, S> {
343 /// Holds a line the run writes at `addr` between two phases, in its
344 /// place among the turn's lines: it reaches the ledger at the next
345 /// barrier with them, so the run's line costs no barrier of its own
346 /// and the ledger's order is still the order of appending.
347 pub(crate) fn hold_run_line(&mut self, line: RunLine, addr: Address, data: Payload) {
348 self.journal.append_run_line(line, addr, data);
349 }
350
351 /// Ends the turn where it stands: `cancel_received`, one barrier that
352 /// carries it with every line the run still held, and the refs of
353 /// every line the run wrote so far.
354 ///
355 /// Reached from the boundary consumer below and from a wave that was
356 /// halted between two calls, so both endings are one line written in
357 /// one place.
358 fn cancel_here(&mut self, ledger: &mut dyn Ledger) -> Result<TurnCancelled, AxError> {
359 self.journal
360 .append_authored(Authored::CancelReceived, Payload::of(&CancelReceived {})?);
361 let lines = self.journal.lines();
362 lines.barrier(ledger)?;
363 Ok(TurnCancelled {
364 refs: lines.refs().to_vec(),
365 })
366 }
367
368 /// The one interrupt consumer. Cancel appends `cancel_received` and
369 /// ends the turn; Steer appends `steer_received` and advances — the
370 /// executor folds the text into its window for the next assembly.
371 fn consume_boundary(
372 &mut self,
373 interrupt: Interrupt,
374 ledger: &mut dyn Ledger,
375 ) -> Result<Option<TurnCancelled>, AxError> {
376 match interrupt {
377 Interrupt::None | Interrupt::Policy { .. } => Ok(None),
378 Interrupt::Cancel => Ok(Some(self.cancel_here(ledger)?)),
379 Interrupt::Steer { speaker, text } => {
380 let steer = SteerReceived {
381 source: speaker.recorded(),
382 text,
383 };
384 self.journal
385 .append_authored(Authored::SteerReceived, Payload::of(&steer)?);
386 Ok(None)
387 }
388 }
389 }
390}
391
392#[cfg(test)]
393#[allow(
394 clippy::unwrap_used,
395 clippy::expect_used,
396 clippy::panic,
397 clippy::indexing_slicing,
398 clippy::wildcard_enum_match_arm,
399 clippy::let_underscore_must_use,
400 clippy::let_underscore_untyped,
401 reason = "test code"
402)]
403mod tests;