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