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, 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}