Skip to main content

runtime/turn/
report.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//! What a completed turn hands the run loop, and the frozen `[model]`
7//! section the call is shaped by.
8
9use kernel::{AxCode, AxError, Ceiling, ContentBlock, EventRef, ModelUsage, StopReason};
10
11/// What a completed turn hands the run loop. `assistant` and
12/// `wave_results` are the window-folding material — the turn's
13/// exchange, compacted at the closing boundary of `record`. The records
14/// are what the compaction works from: `model_returned.data.content`
15/// keeps the reply whole, and each `tool_result` is the payload the
16/// window text was printed from. `runtime::fork` rebuilds both from
17/// those records through the same compaction at the same boundary, so a
18/// rebuild and a live fold still share one source.
19#[derive(Debug, Clone, PartialEq)]
20pub struct TurnReport {
21    pub(super) refs: Vec<EventRef>,
22    pub(super) redacted: u32,
23    pub(super) model_returned: EventRef,
24    pub(super) calls_made: usize,
25    pub(super) assistant: Vec<ContentBlock>,
26    pub(super) wave_results: Vec<ContentBlock>,
27    pub(super) usage: Option<ModelUsage>,
28    pub(super) stop: Option<StopReason>,
29}
30
31impl TurnReport {
32    /// What the provider counted for this call, when it said. The
33    /// context gauge reads `input_tokens` from here and nowhere else.
34    pub fn usage(&self) -> Option<&ModelUsage> {
35        self.usage.as_ref()
36    }
37
38    pub fn refs(&self) -> &[EventRef] {
39        &self.refs
40    }
41
42    /// How many secret-shaped spans this turn replaced before its model
43    /// and tool events reached the ledger. A diagnostic line can say
44    /// this number without saying what was found.
45    pub fn redacted(&self) -> u32 {
46        self.redacted
47    }
48
49    /// The in-window evidence candidate for `Completion::Done`.
50    pub fn model_returned(&self) -> &EventRef {
51        &self.model_returned
52    }
53
54    pub fn calls_made(&self) -> usize {
55        self.calls_made
56    }
57
58    pub fn assistant(&self) -> &[ContentBlock] {
59        &self.assistant
60    }
61
62    pub fn wave_results(&self) -> &[ContentBlock] {
63        &self.wave_results
64    }
65
66    /// Why the provider stopped, when it said. A reply that stopped at
67    /// the ceiling is a reply that was cut off, and the run loop reads
68    /// that here rather than inferring it from what the reply contains.
69    pub fn stop(&self) -> Option<StopReason> {
70        self.stop
71    }
72}
73
74/// Which model this run calls, how much it may say, and how hard it may
75/// think — the frozen `[model]` config section as the turn sees it.
76/// `effort` comes from [`kernel::FrozenConfig`] and cannot change within
77/// the run: the provider renders it into the cached prompt prefix.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub struct CallShape {
80    pub model: String,
81    /// The model's own ceiling, from the endpoint book. `None` when no
82    /// catalogue row states one: the request then carries no ceiling and
83    /// the wire that requires one refuses the call.
84    pub max_tokens: Option<Ceiling>,
85    pub effort: Option<kernel::Effort>,
86    /// The model's window, from the endpoint book. Zero when the book
87    /// does not say, in which case the context reminder stays silent.
88    pub context_tokens: u64,
89}
90
91impl CallShape {
92    /// Refuses a shape that differs from the one a session froze.
93    ///
94    /// The model, its output ceiling and its effort are the fields that
95    /// reach the provider's wire; changing any of them mid-session
96    /// invalidates the message cache breakpoints the frozen prefix is
97    /// paid for, which is why they freeze with the session rather than
98    /// with a form. `context_tokens` is not one of them: it sizes this
99    /// machine's own reminder and never leaves it.
100    ///
101    /// This is the runtime's half of the interception the dispatch face
102    /// owes: `Command::Dispatch { effort }` writes a room's effort, and
103    /// whoever lets that write through asks here first.
104    ///
105    /// # Errors
106    /// `ConfigInvalid`, naming the field that moved and the two ways
107    /// out.
108    pub fn verified_against(&self, frozen: &CallShape) -> Result<(), AxError> {
109        if self.model != frozen.model {
110            return Err(shape_moved(format!(
111                "the model changed from `{}` to `{}`",
112                frozen.model, self.model
113            )));
114        }
115        if self.effort != frozen.effort {
116            return Err(shape_moved(format!(
117                "the effort changed from {:?} to {:?}",
118                frozen.effort, self.effort
119            )));
120        }
121        if self.max_tokens != frozen.max_tokens {
122            return Err(shape_moved("the output ceiling changed".to_owned()));
123        }
124        Ok(())
125    }
126}
127
128/// The refusal a call shape that moved under a running session earns.
129///
130/// The recovery names what a person can do from the page in front of
131/// them: put the field the subject names back, or leave this session
132/// by addressing another room. The second way out is spelled in one
133/// place (`prefix::segment::ANOTHER_ADDRESS`), so no refusal here can
134/// name a verb the wire does not carry.
135fn shape_moved(subject: String) -> AxError {
136    AxError::failure(AxCode::ConfigInvalid, "dispatch a turn", subject).with_recovery(format!(
137        "put the field the subject names back to what this session froze, or \
138         {}: the model, its ceiling and its effort freeze with the session, \
139         and a mid-run change would invalidate the prefix the run is paying to cache",
140        crate::prefix::ANOTHER_ADDRESS
141    ))
142}