Skip to main content

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;