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