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            shared: prompt.prefix.shared_part(),
165            effort: shape.effort,
166        };
167        Ok(PhaseOutcome::Advanced(Turn {
168            journal: self.journal,
169            state: Calling {
170                segments,
171                chat,
172                round: None,
173            },
174        }))
175    }
176}
177
178impl<'h, 'c> Turn<'h, Calling<'c>> {
179    /// Places this call in the account round `watchdog` holds, so a
180    /// repair that sends it again is one more send on the round's
181    /// current account (`crates/runtime/spec/Turn/Recovery.lean` §8-49).
182    #[must_use]
183    pub fn under(self, watchdog: &'c mut crate::Watchdog) -> Turn<'h, Calling<'c>> {
184        Turn {
185            journal: self.journal,
186            state: Calling {
187                round: Some(watchdog),
188                ..self.state
189            },
190        }
191    }
192}
193
194impl<'h> Turn<'h, Calling<'_>> {
195    /// Boundary 2 (before the provider call). Appends `model_called` and
196    /// `model_returned`; a provider Err propagates after nothing but the
197    /// boundary consumption touched the ledger. The reads `generating`
198    /// starts early are not events: they ride to the wave, which uses
199    /// each only for the settled call equal to the one started.
200    /// `'sink` is named rather than elided because the caller holds the
201    /// sink for the whole run and hands it to every turn: with an elided
202    /// lifetime the reborrow would have to shrink the trait object's own
203    /// lifetime, which `&mut` does not permit.
204    pub fn call<'sink>(
205        mut self,
206        interrupt: Interrupt,
207        ledger: &mut dyn Ledger,
208        model: &mut dyn Model,
209        policy: &BuildingPolicy,
210        generating: Generating<'_, 'sink>,
211    ) -> Result<PhaseOutcome<Turn<'h, ToolWave>>, AxError> {
212        if let Some(cancelled) = self.consume_boundary(interrupt, ledger)? {
213            return Ok(PhaseOutcome::Cancelled(cancelled));
214        }
215        let Calling {
216            segments,
217            chat,
218            round,
219        } = self.state;
220        // The hashes above describe the prefix; these describe the four
221        // system blocks that will actually go on the wire. They are the
222        // second per-turn digest, and they disagree only if the request
223        // was built from something other than the prefix it names.
224        let segments = crate::prefix::verified_system_hashes(&chat.system, &segments)?;
225        let request = ModelRequest {
226            policy: policy.clone(),
227            segments,
228            chat,
229        };
230        // The door `generating` picks. Every door returns the same
231        // `ModelReturn`, and the record below is written from that return
232        // in each case - so what a page sees arriving and what the ledger
233        // keeps cannot come from two different readings of one reply. A failure goes to the
234        // recovery pipeline before it leaves this phase (`crates/runtime/spec/Turn/Recovery.lean`
235        // §8-49), and every attempt - first or repaired - is recorded
236        // before it is made.
237        let mut call = recovery::ModelCall::open(&mut self.journal, ledger, model, &request);
238        // Outside a driver no round is open, and a watchdog with no round
239        // admits a repair as one always could.
240        let mut unheld = crate::Watchdog::default();
241        let mut repair = recovery::BlockingResend {
242            round: round.unwrap_or(&mut unheld),
243        };
244        let recovery::Settled {
245            returned: returned_value,
246            speculated,
247            first_at,
248            first_us,
249            sent_us,
250        } = call.ask(&mut [&mut repair], generating)?;
251        let arrived = self.journal.read_moment()?;
252        let ModelReturn {
253            message,
254            calls,
255            usage,
256            stop,
257            billed_usd_micros,
258        } = returned_value;
259        let assistant = content_from_message(&message)?;
260        let calls_len = u64::try_from(calls.len()).map_err(|_| {
261            AxError::failure(AxCode::InvalidArgs, "encode model return", "wave too large")
262                .with_recovery(
263                    "lower this model's max output tokens so it asks for fewer tool \
264                     calls in one reply",
265                )
266        })?;
267        let returned = ModelReturned {
268            message,
269            calls: calls_len,
270            usage,
271            stop,
272            billed_usd_micros,
273            first_at,
274            first_us,
275            took_us: arrived.since(sent_us),
276        };
277        let model_returned = self.journal.append_redacted(
278            Carried::ModelReturned { at: arrived.at },
279            Payload::of(&returned)?,
280        )?;
281        Ok(PhaseOutcome::Advanced(Turn {
282            journal: self.journal,
283            state: ToolWave {
284                calls,
285                speculated,
286                model_returned,
287                assistant,
288                usage,
289                stop,
290            },
291        }))
292    }
293}
294
295impl Turn<'_, Recording> {
296    /// Closes the turn. Recording is the accounting boundary: nothing
297    /// extra is appended here, because every effect is already on the
298    /// ledger. What the phase exists for is the fourth boundary — the
299    /// last moment before the run acts on what this turn decided,
300    /// including the work it handed down — and for the one compaction
301    /// door a turn has. The wave is whole by now, so this is the one
302    /// moment its snapshot may be replaced; a compactor meeting a
303    /// half-landed wave would group what this boundary groups once, and
304    /// `runtime::fork` rebuilding one turn at a time would answer
305    /// different bytes for the same history.
306    ///
307    /// It pays no barrier: what the wave held waits in the run's lines
308    /// for the next turn's model call, or for the run's freeze, and the
309    /// report names `model_returned` by its [`Entry`] until then
310    /// (runtime D36).
311    ///
312    /// # Errors
313    /// Propagates the ledger's refusal to record the boundary event and
314    /// a text the compaction cannot count.
315    pub fn record(
316        mut self,
317        interrupt: Interrupt,
318        ledger: &mut dyn Ledger,
319    ) -> Result<PhaseOutcome<TurnReport>, AxError> {
320        if let Some(cancelled) = self.consume_boundary(interrupt, ledger)? {
321            return Ok(PhaseOutcome::Cancelled(cancelled));
322        }
323        let Recording {
324            model_returned,
325            calls_made,
326            mut exchange,
327            usage,
328            stop,
329        } = self.state;
330        exchange.compact()?;
331        Ok(PhaseOutcome::Advanced(TurnReport {
332            redacted: self.journal.redacted(),
333            model_returned,
334            calls_made,
335            assistant: exchange.assistant().to_vec(),
336            wave_results: exchange.results().to_vec(),
337            usage,
338            stop,
339        }))
340    }
341}
342
343impl<S> Turn<'_, S> {
344    /// Holds a line the run writes at `addr` between two phases, in its
345    /// place among the turn's lines: it reaches the ledger at the next
346    /// barrier with them, so the run's line costs no barrier of its own
347    /// and the ledger's order is still the order of appending.
348    pub(crate) fn hold_run_line(&mut self, line: RunLine, addr: Address, data: Payload) {
349        self.journal.append_run_line(line, addr, data);
350    }
351
352    /// Ends the turn where it stands: `cancel_received`, one barrier that
353    /// carries it with every line the run still held, and the refs of
354    /// every line the run wrote so far.
355    ///
356    /// Reached from the boundary consumer below and from a wave that was
357    /// halted between two calls, so both endings are one line written in
358    /// one place.
359    fn cancel_here(&mut self, ledger: &mut dyn Ledger) -> Result<TurnCancelled, AxError> {
360        self.journal
361            .append_authored(Authored::CancelReceived, Payload::of(&CancelReceived {})?);
362        let lines = self.journal.lines();
363        lines.barrier(ledger)?;
364        Ok(TurnCancelled {
365            refs: lines.refs().to_vec(),
366        })
367    }
368
369    /// The one interrupt consumer. Cancel appends `cancel_received` and
370    /// ends the turn; Steer appends `steer_received` and advances — the
371    /// executor folds the text into its window for the next assembly.
372    fn consume_boundary(
373        &mut self,
374        interrupt: Interrupt,
375        ledger: &mut dyn Ledger,
376    ) -> Result<Option<TurnCancelled>, AxError> {
377        match interrupt {
378            Interrupt::None | Interrupt::Policy { .. } => Ok(None),
379            Interrupt::Cancel => Ok(Some(self.cancel_here(ledger)?)),
380            Interrupt::Steer { speaker, text } => {
381                let steer = SteerReceived {
382                    source: speaker.recorded(),
383                    text,
384                };
385                self.journal
386                    .append_authored(Authored::SteerReceived, Payload::of(&steer)?);
387                Ok(None)
388            }
389        }
390    }
391}
392
393#[cfg(test)]
394#[allow(
395    clippy::unwrap_used,
396    clippy::expect_used,
397    clippy::panic,
398    clippy::indexing_slicing,
399    clippy::wildcard_enum_match_arm,
400    clippy::let_underscore_must_use,
401    clippy::let_underscore_untyped,
402    reason = "test code"
403)]
404mod tests;