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