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    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;