Skip to main content

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, SignalConsumed,
24    SignalWaitStarted, SteerReceived, WatchdogFired,
25};
26use kernel::{AxCode, AxError, EventKind, EventRecord, Seq, TimeMs};
27
28use crate::answer::{Note, Output, Speaker, Used};
29
30/// The most lines of one tool's output a row carries.
31pub const OUTPUT_LINES: usize = 12;
32
33/// A payload field as a string, when it is one.
34#[must_use]
35pub fn text(value: Option<&serde_json::Value>) -> Option<String> {
36    value.and_then(|held| held.as_str()).map(str::to_owned)
37}
38
39/// What the model said, out of the message `runtime::turn` recorded.
40#[must_use]
41pub fn said_in(message: &serde_json::Value) -> Option<String> {
42    blocks_of(message, "text", "text")
43}
44
45/// What the model thought, out of the same message.
46///
47/// **Answered, although the blocks look like transport.** Thinking
48/// blocks are carried end to end because the provider verifies the
49/// signature it issued against them. For a model that spends most of a
50/// call reasoning, withholding them leaves a person watching an empty
51/// thread for minutes and then reading two sentences - so the reasoning
52/// is answered as its own field, and the page folds it away beside the
53/// prose rather than mixing the two.
54///
55/// `RedactedThinking` stays out: its payload is encrypted, so there is
56/// nothing in it a person could read.
57#[must_use]
58pub fn thought_in(message: &serde_json::Value) -> Option<String> {
59    blocks_of(message, "thinking", "thinking")
60}
61
62/// The blocks of one kind, joined, or nothing when there are none.
63fn blocks_of(message: &serde_json::Value, kind: &str, field: &str) -> Option<String> {
64    let blocks = message.as_object()?.get("content")?.as_array()?;
65    let found: Vec<&str> = blocks
66        .iter()
67        .filter_map(|block| {
68            let map = block.as_object()?;
69            (map.get("kind")?.as_str()? == kind).then(|| map.get(field)?.as_str())?
70        })
71        .collect();
72    (!found.is_empty()).then(|| found.join("\n"))
73}
74
75/// The counters `ModelUsage` carries, read through its own
76/// deserialisation so a row written under an older meaning of
77/// `input_tokens` is converted by the one reader that knows the versions.
78/// Absent when the row carries no usage it can read, which is a
79/// different fact from having spent nothing.
80/// The two durations are not usage: `model_returned` keeps them beside
81/// it, and the fold that holds the whole line fills them in.
82#[must_use]
83pub fn used_in(usage: &serde_json::Value) -> Option<Used> {
84    let usage = <kernel::ModelUsage as serde::Deserialize>::deserialize(usage).ok()?;
85    Some(Used {
86        input: usage.input_tokens,
87        output: usage.output_tokens,
88        cached: usage.cache_read_tokens.reported(),
89        cache_write: usage.cache_write_tokens.reported(),
90        first_us: None,
91        took_us: None,
92    })
93}
94
95/// What a tool said, cut to [`OUTPUT_LINES`] with the remainder counted.
96///
97/// A result too large to carry was replaced upstream by
98/// `runtime::offload`, whose substitute already states its own size and
99/// names the `Locator` holding the rest. This never reads that line: the
100/// substitute's format has one authority and it is not this module.
101#[must_use]
102pub fn output_in(said: &serde_json::Value) -> Option<Output> {
103    match said {
104        serde_json::Value::String(text) => bounded(text),
105        other @ (serde_json::Value::Null
106        | serde_json::Value::Bool(_)
107        | serde_json::Value::Number(_)
108        | serde_json::Value::Array(_)
109        | serde_json::Value::Object(_)) => bounded(&other.to_string()),
110    }
111}
112
113/// What a call was asked for, laid out over lines and cut at the same
114/// limit as what it answered.
115///
116/// Laid out rather than compact: the bound is counted in lines, and a
117/// whole argument object printed on one line would satisfy a line limit
118/// while staying unreadable — the window has to cut something a person
119/// would otherwise have read. A value that will not format falls back to
120/// its compact form, because an unreadable argument is still worth more
121/// than an absent one.
122#[must_use]
123pub fn arguments_in(args: &serde_json::Value) -> Option<Output> {
124    if args.is_null() {
125        return None;
126    }
127    let laid_out = serde_json::to_string_pretty(args).unwrap_or_else(|_| args.to_string());
128    bounded(&laid_out)
129}
130
131/// The one place [`OUTPUT_LINES`] is applied.
132///
133/// Shared by what a call was asked for and what it answered, so the two
134/// halves of a row are cut by the same rule and a reader comparing them
135/// is comparing equal windows.
136fn bounded(whole: &str) -> Option<Output> {
137    if whole.trim().is_empty() {
138        return None;
139    }
140    let head: Vec<&str> = whole.lines().take(OUTPUT_LINES).collect();
141    let cut = whole.lines().count().saturating_sub(head.len());
142    Some(Output {
143        head: head.join("\n"),
144        cut,
145        pinned: None,
146    })
147}
148
149/// Whether this kind changed what the turn did or what it waits on, and
150/// what to say about it if so.
151///
152/// Exhaustive over the kinds that earn a note and closed against the
153/// rest: an enum that grew an arm per event would be the event stream
154/// with extra steps.
155#[must_use]
156#[expect(
157    clippy::wildcard_enum_match_arm,
158    reason = "an arm per event kind would be the event stream with extra steps"
159)]
160pub fn note_of(kind: EventKind, record: &EventRecord) -> Option<Note> {
161    let at = record.seq();
162    match kind {
163        // The carrier table in `kernel::error` decides which codes land
164        // under which kind, and `runtime::run` writes the error flat
165        // into the payload. A payload that will not read back as one is
166        // kept as the failure to read it rather than rendered as a
167        // refusal this build invented, or dropped as if nothing refused.
168        EventKind::GateDenied | EventKind::BudgetLimit => {
169            Some(match record.data().read::<AxError>() {
170                Ok(error) => Note::Refused { error, at },
171                Err(err) => unreadable(kind, &err, at),
172            })
173        }
174        // The vault's fallback to session memory shares this kind and
175        // changed nothing a turn did.
176        EventKind::ProviderDegraded => match record.data().read() {
177            Ok(ProviderDegraded::Refused(error)) => Some(Note::Refused { error, at }),
178            Ok(ProviderDegraded::VaultFellBack(_)) => None,
179            Err(err) => Some(unreadable(kind, &err, at)),
180        },
181        EventKind::WatchdogFired => match record.data().read::<WatchdogFired>() {
182            Ok(fired) => backed_off(fired.action, at),
183            Err(err) => Some(unreadable(kind, &err, at)),
184        },
185        // When the person answered is recorded under the city's own run,
186        // which this one record cannot see; the rounds fold pairs it.
187        EventKind::ApprovalRequested => Some(Note::Waiting {
188            at,
189            t: record.t(),
190            answered: None,
191        }),
192        // The job pin that opens a dispatch is a `checkpoint_committed`
193        // naming no commit, and the record type says so rather than
194        // leaving this reader to infer it from a missing key.
195        EventKind::CheckpointCommitted => match record.data().read() {
196            Ok(CheckpointCommitted::Committed(commit)) => Some(Note::Checkpointed {
197                oid: commit.oid,
198                at,
199            }),
200            Ok(CheckpointCommitted::JobPinned { .. }) => None,
201            Err(err) => Some(unreadable(kind, &err, at)),
202        },
203        EventKind::SteerReceived => Some(match record.data().read::<SteerReceived>() {
204            Ok(steer) => Note::Arrived {
205                by: speaker_of(&steer.source),
206                // A resident's source carries its envelope after the
207                // address (`@<room> run=… kind=… sender=…`, collab D16);
208                // the note names only who spoke.
209                from: steer.source.split(' ').next().map(str::to_owned),
210                said: Some(steer.text),
211                t: record.t(),
212                handback: None,
213                kind: None,
214                session: None,
215                at,
216            },
217            Err(err) => unreadable(kind, &err, at),
218        }),
219        // The words are on the sending line, under the sender's run;
220        // the server's fold pairs it by id (D36). The User speaks only
221        // through a steer, so a signal is always a resident's.
222        EventKind::SignalConsumed => Some(match record.data().read::<SignalConsumed>() {
223            Ok(_taken) => Note::Arrived {
224                from: None,
225                said: None,
226                by: Speaker::Resident,
227                t: record.t(),
228                handback: None,
229                kind: None,
230                session: None,
231                at,
232            },
233            Err(err) => unreadable(kind, &err, at),
234        }),
235        // Its end is a line of its own in the same session, paired by
236        // the server's fold (D37).
237        EventKind::SignalWaitStarted => Some(match record.data().read::<SignalWaitStarted>() {
238            Ok(started) => Note::AwaitingReply {
239                on: started.on,
240                until: TimeMs::new(started.deadline_ms),
241                t: record.t(),
242                ended: None,
243                at,
244            },
245            Err(err) => unreadable(kind, &err, at),
246        }),
247        EventKind::FileDiscarded => Some(match record.data().read::<FileDiscarded>() {
248            Ok(discarded) => Note::Discarded {
249                count: discarded.paths.len(),
250                at,
251            },
252            Err(err) => unreadable(kind, &err, at),
253        }),
254        _ => None,
255    }
256}
257
258/// A back-off is the provider's refusal, waited out, and a switch is the
259/// same refusal answered on another account: the line keeps the
260/// failure's stable code and subject, and they are shown as that
261/// refusal. The line records only the code and the subject, so the
262/// action and the recovery are composed here from the kind of line it is,
263/// not read back. A steer and a freeze earn no note of their own, because
264/// the steer and the frozen run are lines of their own.
265/// Who spoke a steer, read from the spellings the kernel defines for
266/// `steer_received.source` (D41); anything else is a resident's `@<room>`.
267fn speaker_of(source: &str) -> Speaker {
268    match source {
269        SteerReceived::PERSON_SOURCE => Speaker::User,
270        SteerReceived::CITY_SOURCE => Speaker::City,
271        _resident => Speaker::Resident,
272    }
273}
274
275fn backed_off(action: FiredAction, at: Seq) -> Option<Note> {
276    let (code, subject, recovery) = match action {
277        FiredAction::BackOff {
278            until_ms,
279            code,
280            subject,
281        } => (
282            code,
283            subject,
284            format!("the watchdog calls again no earlier than {until_ms} ms"),
285        ),
286        FiredAction::Switch { to, code, subject } => (
287            code,
288            subject,
289            format!("the watchdog calls again at once on the account {to}"),
290        ),
291        FiredAction::Steer { .. } | FiredAction::Freeze { .. } => return None,
292    };
293    Some(match AxCode::parse(&code) {
294        Some(code) => Note::Refused {
295            error: AxError::failure(code, "call the provider", subject).with_recovery(recovery),
296            at,
297        },
298        None => unreadable(
299            EventKind::WatchdogFired,
300            &format!("unknown code {code}"),
301            at,
302        ),
303    })
304}
305
306/// The note a payload leaves when it will not read back as its kind.
307fn unreadable(kind: EventKind, err: &dyn std::fmt::Display, at: Seq) -> Note {
308    Note::Unreadable {
309        cause: format!("{kind:?} payload did not read back: {err}"),
310        at,
311    }
312}