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}