wire/reading.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//! Reading one ledger payload into the wire's own values.
7//!
8//! **Why it is here and not on the server.** Both ends need it. The
9//! server folds a session into rounds and answers `Query::Rounds` with
10//! them; a client folds the pushed event stream forward into what it
11//! believes, and reading "what the model said" out of a `model_returned`
12//! payload is the same rule in both places (ARCHITECTURE.md section 5,
13//! step 12: the same fold, on both sides of the wire). One authority,
14//! two callers.
15//!
16//! Pure and fail-open: a field this build cannot read produces `None`
17//! rather than an error, and a note whose payload will not read back as
18//! its kind becomes a `Note::Unreadable` naming the failure, because a
19//! view that hid a record it could not parse would be a view that lies
20//! about what happened.
21
22use kernel::event::record::{
23 CheckpointCommitted, FileDiscarded, FiredAction, ProviderDegraded, WatchdogFired,
24};
25use kernel::{AxCode, AxError, EventKind, EventRecord, Seq};
26
27use crate::answer::{Note, Output, Used};
28
29/// The most lines of one tool's output a row carries.
30pub const OUTPUT_LINES: usize = 12;
31
32/// A payload field as a string, when it is one.
33#[must_use]
34pub fn text(value: Option<&serde_json::Value>) -> Option<String> {
35 value.and_then(|held| held.as_str()).map(str::to_owned)
36}
37
38/// What the model said, out of the message `runtime::turn` recorded.
39#[must_use]
40pub fn said_in(message: &serde_json::Value) -> Option<String> {
41 blocks_of(message, "text", "text")
42}
43
44/// What the model thought, out of the same message.
45///
46/// **Answered, although the blocks look like transport.** Thinking
47/// blocks are carried end to end because the provider verifies the
48/// signature it issued against them. For a model that spends most of a
49/// call reasoning, withholding them leaves a person watching an empty
50/// thread for minutes and then reading two sentences - so the reasoning
51/// is answered as its own field, and the page folds it away beside the
52/// prose rather than mixing the two.
53///
54/// `RedactedThinking` stays out: its payload is encrypted, so there is
55/// nothing in it a person could read.
56#[must_use]
57pub fn thought_in(message: &serde_json::Value) -> Option<String> {
58 blocks_of(message, "thinking", "thinking")
59}
60
61/// The blocks of one kind, joined, or nothing when there are none.
62fn blocks_of(message: &serde_json::Value, kind: &str, field: &str) -> Option<String> {
63 let blocks = message.as_object()?.get("content")?.as_array()?;
64 let found: Vec<&str> = blocks
65 .iter()
66 .filter_map(|block| {
67 let map = block.as_object()?;
68 (map.get("kind")?.as_str()? == kind).then(|| map.get(field)?.as_str())?
69 })
70 .collect();
71 (!found.is_empty()).then(|| found.join("\n"))
72}
73
74/// The counters `ModelUsage` carries, read through its own
75/// deserialisation so a row written under an older meaning of
76/// `input_tokens` is converted by the one reader that knows the versions.
77/// Absent when the row carries no usage it can read, which is a
78/// different fact from having spent nothing.
79#[must_use]
80pub fn used_in(usage: &serde_json::Value) -> Option<Used> {
81 let usage = <kernel::ModelUsage as serde::Deserialize>::deserialize(usage).ok()?;
82 Some(Used {
83 input: usage.input_tokens,
84 output: usage.output_tokens,
85 cached: usage.cache_read_tokens,
86 cache_write: Some(usage.cache_write_tokens),
87 })
88}
89
90/// What a tool said, cut to [`OUTPUT_LINES`] with the remainder counted.
91///
92/// A result too large to carry was replaced upstream by
93/// `runtime::offload`, whose substitute already states its own size and
94/// names the `Locator` holding the rest. This never reads that line: the
95/// substitute's format has one authority and it is not this module.
96#[must_use]
97pub fn output_in(said: &serde_json::Value) -> Option<Output> {
98 match said {
99 serde_json::Value::String(text) => bounded(text),
100 other @ (serde_json::Value::Null
101 | serde_json::Value::Bool(_)
102 | serde_json::Value::Number(_)
103 | serde_json::Value::Array(_)
104 | serde_json::Value::Object(_)) => bounded(&other.to_string()),
105 }
106}
107
108/// What a call was asked for, laid out over lines and cut at the same
109/// limit as what it answered.
110///
111/// Laid out rather than compact: the bound is counted in lines, and a
112/// whole argument object printed on one line would satisfy a line limit
113/// while staying unreadable — the window has to cut something a person
114/// would otherwise have read. A value that will not format falls back to
115/// its compact form, because an unreadable argument is still worth more
116/// than an absent one.
117#[must_use]
118pub fn arguments_in(args: &serde_json::Value) -> Option<Output> {
119 if args.is_null() {
120 return None;
121 }
122 let laid_out = serde_json::to_string_pretty(args).unwrap_or_else(|_| args.to_string());
123 bounded(&laid_out)
124}
125
126/// The one place [`OUTPUT_LINES`] is applied.
127///
128/// Shared by what a call was asked for and what it answered, so the two
129/// halves of a row are cut by the same rule and a reader comparing them
130/// is comparing equal windows.
131fn bounded(whole: &str) -> Option<Output> {
132 if whole.trim().is_empty() {
133 return None;
134 }
135 let head: Vec<&str> = whole.lines().take(OUTPUT_LINES).collect();
136 let cut = whole.lines().count().saturating_sub(head.len());
137 Some(Output {
138 head: head.join("\n"),
139 cut,
140 pinned: None,
141 })
142}
143
144/// Whether this kind changed what the turn did or what it waits on, and
145/// what to say about it if so.
146///
147/// Exhaustive over the kinds that earn a note and closed against the
148/// rest: an enum that grew an arm per event would be the event stream
149/// with extra steps.
150#[must_use]
151#[expect(
152 clippy::wildcard_enum_match_arm,
153 reason = "an arm per event kind would be the event stream with extra steps"
154)]
155pub fn note_of(kind: EventKind, record: &EventRecord) -> Option<Note> {
156 let at = record.seq();
157 let map = record.data().as_map();
158 match kind {
159 // The carrier table in `kernel::error` decides which codes land
160 // under which kind, and `runtime::run` writes the error flat
161 // into the payload. A payload that will not read back as one is
162 // kept as the failure to read it rather than rendered as a
163 // refusal this build invented, or dropped as if nothing refused.
164 EventKind::GateDenied | EventKind::BudgetLimit => {
165 Some(match record.data().read::<AxError>() {
166 Ok(error) => Note::Refused { error, at },
167 Err(err) => unreadable(kind, &err, at),
168 })
169 }
170 // The vault's fallback to session memory shares this kind and
171 // changed nothing a turn did.
172 EventKind::ProviderDegraded => match record.data().read() {
173 Ok(ProviderDegraded::Refused(error)) => Some(Note::Refused { error, at }),
174 Ok(ProviderDegraded::VaultFellBack(_)) => None,
175 Err(err) => Some(unreadable(kind, &err, at)),
176 },
177 EventKind::WatchdogFired => match record.data().read::<WatchdogFired>() {
178 Ok(fired) => backed_off(fired.action, at),
179 Err(err) => Some(unreadable(kind, &err, at)),
180 },
181 // When the person answered is recorded under the city's own run,
182 // which this one record cannot see; the rounds fold pairs it.
183 EventKind::ApprovalRequested => Some(Note::Waiting {
184 at,
185 t: record.t(),
186 answered: None,
187 }),
188 // The job pin that opens a dispatch is a `checkpoint_committed`
189 // naming no commit, and the record type says so rather than
190 // leaving this reader to infer it from a missing key.
191 EventKind::CheckpointCommitted => match record.data().read() {
192 Ok(CheckpointCommitted::Committed(commit)) => Some(Note::Checkpointed {
193 oid: commit.oid,
194 at,
195 }),
196 Ok(CheckpointCommitted::JobPinned { .. }) => None,
197 Err(err) => Some(unreadable(kind, &err, at)),
198 },
199 EventKind::SteerReceived | EventKind::SignalConsumed => Some(Note::Arrived {
200 from: text(map.get("source"))
201 .or_else(|| text(map.get("from")))
202 .unwrap_or_else(|| record.who().to_owned()),
203 said: text(map.get("text")).unwrap_or_default(),
204 at,
205 }),
206 EventKind::FileDiscarded => Some(match record.data().read::<FileDiscarded>() {
207 Ok(discarded) => Note::Discarded {
208 count: discarded.paths.len(),
209 at,
210 },
211 Err(err) => unreadable(kind, &err, at),
212 }),
213 _ => None,
214 }
215}
216
217/// A back-off is the provider's refusal, waited out: the line keeps the
218/// failure's stable code and subject, and they are shown as that
219/// refusal. The line records only the code and the subject, so the
220/// action and the recovery are composed here from the kind of line it is,
221/// not read back. A steer and a freeze earn no note of their own, because
222/// the steer and the frozen run are lines of their own.
223fn backed_off(action: FiredAction, at: Seq) -> Option<Note> {
224 match action {
225 FiredAction::BackOff {
226 until_ms,
227 code,
228 subject,
229 } => Some(match AxCode::parse(&code) {
230 Some(code) => Note::Refused {
231 error: AxError::failure(code, "call the provider", subject).with_recovery(format!(
232 "the watchdog calls again no earlier than {until_ms} ms"
233 )),
234 at,
235 },
236 None => unreadable(
237 EventKind::WatchdogFired,
238 &format!("unknown code {code}"),
239 at,
240 ),
241 }),
242 FiredAction::Steer { .. } | FiredAction::Freeze { .. } => None,
243 }
244}
245
246/// The note a payload leaves when it will not read back as its kind.
247fn unreadable(kind: EventKind, err: &dyn std::fmt::Display, at: Seq) -> Note {
248 Note::Unreadable {
249 cause: format!("{kind:?} payload did not read back: {err}"),
250 at,
251 }
252}