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