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}