Skip to main content

sqlite_graphrag/agent_surface/
stream.rs

1//! GAP-SG-215: the NDJSON stream contract, decided.
2//!
3//! [`super`] is defined over one complete envelope. `export` and `ingest` emit
4//! something else — N self-contained records followed by a summary — and until
5//! v1.2.8 they reached that envelope machinery once per LINE, through
6//! `crate::output::emit_json_compact`. Three defects followed, all measured:
7//!
8//! * `--select name export --limit 3` emitted three correctly projected records
9//!   and then `exit 2` on the fourth line. The summary carries `namespace`, not
10//!   `name`, so the projection that resolved for every record failed on the one
11//!   line that is not a record — after stdout had already been written to.
12//! * `--select namespace export` did the mirror of that in SILENCE, `exit 0`:
13//!   the key resolved, so the summary was projected down to `{"namespace":…}`
14//!   and lost `summary: true`, the only end-of-stream signal a consumer has. A
15//!   truncated export then looks exactly like a complete one.
16//! * With NO knob at all, every line carried a 278-byte `agent_surface` record —
17//!   measured over 200 lines — restating one fact about the PROCESS once per
18//!   memory, absolute database path included. At the default `--limit 100000`
19//!   that is ~27.8 MB, written into the file `docs/AGENTS.md` recommends
20//!   creating with `export > backup.ndjson`.
21//!
22//! # The contract
23//!
24//! * **A record line carries the record and nothing else.** No `agent_surface`,
25//!   no `truncated`. This is the invariant `super`'s module docs have declared
26//!   since GAP-SG-142 — "NDJSON streams bypass the surface" — restored to being
27//!   true. The NDJSON specification is explicit that the format carries no
28//!   per-line header, metadata or schema; a stream is data, and the frame around
29//!   it belongs somewhere else.
30//! * **Only per-record knobs act.** `--select` and `--truncate-content` are
31//!   stateless per record and mean the same thing whether a record arrives alone
32//!   or in a stream. `crate::output::stream` named exactly that pair as the safe
33//!   extension and asked for a contract decision before wiring it; this module is
34//!   that decision. Everything else is refused by [`super::gate::evaluate_stream`]
35//!   BEFORE the first byte, so a refusal never leaves a half-written stream.
36//! * **The trailer is never shaped and carries the one record.** The summary
37//!   line is already about the stream rather than about a memory, so the resolved
38//!   target, the query ceiling and the projection findings ride there — once.
39//!
40//! The published schemas allow all three: `docs/schemas/export-memory-line` and
41//! `export-summary` both declare `agent_surface` OPTIONAL, so dropping it from
42//! the record and keeping it on the summary breaks no contract. What the old
43//! behaviour did break was `export-summary`'s `required` list, every time a
44//! projection deleted `summary`, `exported` or `elapsed_ms`.
45//!
46//! # Why the state is a cell and the decisions are not
47//!
48//! One process runs one subcommand and emits one stream, so a process-wide cell
49//! is the single fact about that stream rather than ambient state — the same
50//! reasoning [`super::universe`] documents. But GAP-SG-201 shipped a refusal no
51//! test could reach precisely because the DECISION read the cell from inside
52//! itself. So every function here that decides anything takes its premises as
53//! arguments, and the cell is read at exactly one place: the emitters in
54//! `crate::output::stream`.
55
56use super::gate::{self, Findings};
57use super::vocabulary::Scope;
58use super::{shape, AgentSurface};
59use crate::errors::AppError;
60use serde_json::{json, Map, Value};
61use std::sync::atomic::{AtomicUsize, Ordering};
62use std::sync::OnceLock;
63
64/// Member marking an `agent_surface` record as describing a stream.
65///
66/// A consumer that reads the block off a summary line needs to know the counts
67/// in it are about N lines rather than about the one it is holding.
68const STREAM_KEY: &str = "stream";
69
70/// Member counting the records `--truncate-content` actually shortened.
71const RECORDS_TRUNCATED_KEY: &str = "records_truncated";
72
73/// What one stream resolved before its first line, and what it did after.
74///
75/// Built once by [`open_with`] and read by every emission. The projection paths
76/// are compiled HERE rather than per line for the same reason
77/// [`shape::project`] compiles them once for a `Vec`: splitting a dotted key
78/// inside the emission loop would allocate a `Vec<String>`, plus a `String` per
79/// segment, for every record times every key. A stream has no `Vec` to hoist the
80/// work out of, so the hoisting has to be the stream's own state.
81#[derive(Debug)]
82pub struct StreamState {
83    /// What `--select` resolved against the record vocabulary, decided once.
84    findings: Findings,
85    /// `--select` keys pre-split into lookup paths.
86    select_paths: Vec<Vec<String>>,
87    /// How many records `--truncate-content` shortened.
88    ///
89    /// Atomic rather than a `Cell` because the emitters take `&'static
90    /// StreamState` out of a `OnceLock`, which is `Sync` only if its contents
91    /// are. Uninteresting cost: the counter is touched only on a record that was
92    /// actually cut.
93    shortened: AtomicUsize,
94}
95
96impl StreamState {
97    /// The state of a stream that was never opened.
98    ///
99    /// Emitting through this shapes nothing and refuses nothing, which is the
100    /// right failure mode for a stream whose command forgot to call [`open`]:
101    /// records go out verbatim, which is the contract, and the trailer still
102    /// carries the process record. `tests/stream_contract_gate.rs` is what makes
103    /// forgetting visible rather than merely harmless.
104    fn inert() -> &'static Self {
105        static INERT: OnceLock<StreamState> = OnceLock::new();
106        INERT.get_or_init(|| StreamState {
107            findings: Findings::default(),
108            select_paths: Vec::new(),
109            shortened: AtomicUsize::new(0),
110        })
111    }
112}
113
114/// Resolves a stream's request against its records, before anything is emitted.
115///
116/// `sample` is a bounded prefix of the records the command is about to write,
117/// and `total` is how many there really are. See [`gate::evaluate_stream`] for
118/// why it is a prefix and not the whole set.
119///
120/// `total` exists so the bound gets DECLARED. [`Scope::vocabulary_is_partial`]
121/// compares the elements it was handed against its own sampling constant, and a
122/// prefix of exactly that size compares equal — so a 100 000-record export judged
123/// on 64 records would have reported a complete vocabulary. Passing the real
124/// count is what turns "I judged a prefix" from an implementation detail into a
125/// field on the trailer.
126///
127/// # Errors
128/// Returns [`AppError::Usage`] — exit `2` — when a knob cannot act on a stream,
129/// or when `--select` names nothing any record carries. Both happen with stdout
130/// still untouched, which is the whole point of resolving up front.
131pub fn open_with(
132    surface: &AgentSurface,
133    sample: &[Value],
134    total: usize,
135) -> Result<StreamState, AppError> {
136    // A stream has no envelope for a key to resolve against instead of the
137    // records, and `Scope` wants one, so it gets the empty value. That is not a
138    // placeholder: it states, correctly, that nothing here is envelope-only.
139    let no_envelope = Value::Null;
140    let mut findings = gate::evaluate_stream(
141        surface,
142        &Scope::new(sample, &no_envelope).with_command(surface.command.as_deref()),
143    )?;
144    // Scoped to `--select`, because the field qualifies an ANSWER about field
145    // names and there is no such answer without a projection. Raising it
146    // unconditionally reported `vocabulary_partial: true` on a plain `export`,
147    // where the sample is empty by design and nothing was ever judged — a true
148    // statement about the sample, and a misleading one about the run.
149    if !surface.select.is_empty() {
150        findings.vocabulary_partial |= total > sample.len();
151    }
152    Ok(StreamState {
153        select_paths: shape::compile_paths(&surface.select),
154        findings,
155        shortened: AtomicUsize::new(0),
156    })
157}
158
159/// Applies the per-record knobs to one line. Never annotates it.
160///
161/// The absence of an `agent_surface` insertion here is the contract, not an
162/// omission — see the module docs.
163#[must_use]
164pub fn shape_record_with(state: &StreamState, surface: &AgentSurface, value: Value) -> Value {
165    let mut value = if surface.select.is_empty() {
166        value
167    } else {
168        shape::project_with(
169            value,
170            &surface.select,
171            &state.select_paths,
172            surface.command.as_deref(),
173        )
174    };
175    if shape::truncate_strings(&mut value, surface.truncate_content) {
176        // Release pairs with the Acquire in `trailer_with`: the trailer is the
177        // one reader, and it must see every increment that happened before it.
178        state.shortened.fetch_add(1, Ordering::Release);
179    }
180    value
181}
182
183/// Annotates the trailer with the one record for the whole stream.
184///
185/// Deliberately does NOT project, filter or cap. The summary line is the stream
186/// describing itself; a `--select` aimed at the records has no business either
187/// failing on it or rewriting it, and both of those were measured defects.
188#[must_use]
189pub fn trailer_with(
190    state: &StreamState,
191    surface: &AgentSurface,
192    target: Option<Map<String, Value>>,
193    mut value: Value,
194) -> Value {
195    let mut meta = Map::new();
196    meta.insert(STREAM_KEY.into(), Value::Bool(true));
197    if !surface.select.is_empty() {
198        meta.insert("select".into(), json!(surface.select));
199    }
200    let shortened = state.shortened.load(Ordering::Acquire);
201    if shortened > 0 {
202        meta.insert(RECORDS_TRUNCATED_KEY.into(), json!(shortened));
203    }
204    if state.findings.is_partial() {
205        meta.insert(
206            "unresolved_keys".into(),
207            json!(state.findings.unresolved_keys),
208        );
209        meta.insert("resolved_keys".into(), json!(state.findings.resolved_keys));
210        meta.insert("key_resolution".into(), json!("partial"));
211        if !state.findings.key_suggestions.is_empty() {
212            meta.insert(
213                "key_suggestions".into(),
214                json!(state.findings.key_suggestions),
215            );
216        }
217    }
218    // Reported whatever the verdict, because it qualifies the whole stream and
219    // not just a partial projection: it says the vocabulary came from a prefix
220    // of the records rather than from all of them.
221    if state.findings.vocabulary_partial {
222        meta.insert("vocabulary_partial".into(), Value::Bool(true));
223    }
224    if let Some(record) = target {
225        meta.extend(record);
226    }
227    // `truncated` rides the trailer for the same reason the counts do. The
228    // module has always promised that removing data is never silent, and with
229    // record lines now unannotated the trailer is the only place left to keep
230    // that promise.
231    super::attach_meta(&mut value, &meta, shortened > 0);
232    value
233}
234
235static STREAM: OnceLock<StreamState> = OnceLock::new();
236
237/// Opens the process's stream. First call wins.
238///
239/// # Errors
240/// Propagates the refusal from [`open_with`], so a streaming command can fail
241/// before it writes its first record simply by using `?`.
242pub fn open(surface: &AgentSurface, sample: &[Value], total: usize) -> Result<(), AppError> {
243    let state = open_with(surface, sample, total)?;
244    let _ = STREAM.set(state);
245    Ok(())
246}
247
248/// How many records [`open`] needs to see to resolve a projection.
249///
250/// The surface's own sampling constant, reused rather than restated: judging a
251/// stream's vocabulary and suggesting names for a failed key are the same
252/// question about the same records, and two constants for one question is how
253/// they drift.
254pub const SAMPLE_RECORDS: usize = crate::constants::K_VOCABULARY_SAMPLE_ELEMENTS;
255
256/// The open stream, or an inert one when the command never opened it.
257pub fn get() -> &'static StreamState {
258    STREAM.get().unwrap_or_else(StreamState::inert)
259}