Skip to main content

sqlite_graphrag/agent_surface/
mod.rs

1//! GAP-SG-142: agent-native reshaping of the JSON envelope.
2//!
3//! Every subcommand used to hand its whole envelope back to the caller, so an
4//! agent had to keep a `jaq` filter in its prompt just to read one field. This
5//! module gives the CLI the projection / filter / sort / dedup / limit /
6//! truncation surface the sibling tools already expose, applied at a **single**
7//! point: [`crate::output`] serializes the response, hands the resulting
8//! [`serde_json::Value`] to [`apply`], and writes what comes back.
9//!
10//! Working on the serialized value rather than on each command's response
11//! struct is what keeps this DRY — one implementation covers the whole CLI and
12//! no subcommand needs to know the surface exists.
13//!
14//! # Invariants
15//!
16//! * **Failures always reach the caller.** An envelope carrying `error: true`
17//!   or `ok: false` is emitted verbatim; `--filter` shapes result rows, never
18//!   the error contract.
19//! * **JSON Schema documents are never shaped.** `--print-schema` output is
20//!   recognised by its `$schema` member and passes through untouched.
21//! * **Truncation is never silent.** Anything that removes data records it
22//!   under the `agent_surface` member and raises a top-level `truncated` flag.
23//! * **Derived arrays never survive a reshape.** Members that merely restate
24//!   the reshaped array (`memories`, `entities`, `direct_matches`,
25//!   `graph_matches`, `related_memories`) are dropped and listed under
26//!   `aliases_removed`. Without a knob the surface is inert, so the envelope
27//!   stays byte-for-byte identical to the pre-v1.2.2 output and the v1.0.66
28//!   alias contract is untouched.
29//! * **NDJSON streams bypass the surface.** Line-oriented emitters keep one
30//!   record per line; reshaping them would change the stream contract.
31//!
32//! # Scope of each knob (GAP-SG-191)
33//!
34//! An envelope may carry more than one array, and the three ceilings do NOT all
35//! reach the same members. The split is deliberate, and it follows from what
36//! each knob removes:
37//!
38//! | knob | reaches | why |
39//! | --- | --- | --- |
40//! | `--max-output-bytes` | every array | it removes whole elements to hit a byte budget the caller set for the envelope as a whole |
41//! | `--max-items` | every array | same: it removes whole elements, so a secondary member simply gets the same cap |
42//! | `--select`, `--filter`, `--sort`, `--dedupe-by` | primary array only | they act on the *fields* or the *ordering* of elements |
43//!
44//! A secondary array is a different collection, not a restatement of the primary
45//! one: `graph` pairs `nodes` with `edges`. Projecting `id` over `edges` would
46//! rewrite every element to `{}` and erase `source`/`target` — the projection
47//! would destroy the collection rather than narrow it. Filtering and sorting
48//! fail the same way, on keys that member never had.
49//!
50//! Until v1.2.4 `--max-items` also stopped at the primary array, so
51//! `graph --select id --max-items 2` answered with two nodes and all 59 066
52//! edges: 4.55 MB for a request that asked for two items. Members shortened by
53//! the cap are listed under `agent_surface.secondary_capped`.
54//!
55//! Precedence for every numeric knob is the crate-wide one: CLI flag > XDG
56//! `config set` > named constant. No product environment variable is read.
57
58pub mod budget;
59pub mod filter;
60pub mod shape;
61
62#[cfg(test)]
63mod tests;
64
65use filter::FilterExpr;
66use serde_json::{json, Map, Value};
67use std::sync::OnceLock;
68
69/// Resolved output-shaping request for the current process.
70#[derive(Debug, Clone, Default)]
71pub struct AgentSurface {
72    /// Subcommand that emitted the envelope, as
73    /// [`crate::cli::Commands::agent_surface_slug`] reports it.
74    ///
75    /// CONTEXT, never a knob: it tells alias suppression which subcommand's
76    /// contract applies, and therefore takes no part in [`Self::is_noop`]. A
77    /// surface carrying only a command name still changes nothing.
78    pub command: Option<String>,
79    /// Keys kept by `--select` / `--fields`, in the requested order.
80    pub select: Vec<String>,
81    /// Predicates from `--filter`, conjoined with AND.
82    pub filters: Vec<FilterExpr>,
83    /// Sort key from `--sort`.
84    pub sort: Option<String>,
85    /// Dedup key from `--dedupe-by`.
86    pub dedupe_by: Option<String>,
87    /// Cap on emitted result elements (`--max-items`); `0` disables it.
88    pub max_items: usize,
89    /// Replace the payload with a count (`--count-only`).
90    pub count_only: bool,
91    /// Cap on string length in characters (`--truncate-content`); `0` disables it.
92    pub truncate_content: usize,
93    /// Cap on the serialized envelope in bytes (`--max-output-bytes`); `0` disables it.
94    pub max_output_bytes: usize,
95}
96
97impl AgentSurface {
98    /// `true` when no knob is set and [`apply`] must be a no-op.
99    pub fn is_noop(&self) -> bool {
100        self.select.is_empty()
101            && self.filters.is_empty()
102            && self.sort.is_none()
103            && self.dedupe_by.is_none()
104            && self.max_items == 0
105            && !self.count_only
106            && self.truncate_content == 0
107            && self.max_output_bytes == 0
108    }
109}
110
111static SURFACE: OnceLock<AgentSurface> = OnceLock::new();
112
113/// Installs the process-wide surface. Idempotent, first call wins.
114pub fn init(surface: AgentSurface) {
115    let _ = SURFACE.set(surface);
116}
117
118/// Borrows the installed surface, or an inert one when `init` never ran.
119pub fn get() -> &'static AgentSurface {
120    static INERT: OnceLock<AgentSurface> = OnceLock::new();
121    SURFACE
122        .get()
123        .unwrap_or_else(|| INERT.get_or_init(AgentSurface::default))
124}
125
126/// `true` when the installed surface would change an envelope.
127///
128/// Callers use it to skip the extra `Value` round-trip on the hot path.
129pub fn active() -> bool {
130    !get().is_noop()
131}
132
133/// Applies the installed surface to `value`.
134pub fn apply_global(value: Value) -> Value {
135    apply(get(), value)
136}
137
138/// Member holding the record of what the surface did.
139const META_KEY: &str = "agent_surface";
140
141/// Member raised whenever data was removed.
142const TRUNCATED_KEY: &str = "truncated";
143
144/// Applies `surface` to `value`, honouring the invariants documented above.
145pub fn apply(surface: &AgentSurface, mut value: Value) -> Value {
146    if surface.is_noop() || is_passthrough(&value) {
147        return value;
148    }
149
150    let array_key = locate_result_array(&value);
151    let aliases_removed = suppress_alias_arrays(surface, &mut value, array_key.as_deref());
152    let items = take_items(&mut value, array_key.as_deref());
153
154    let (payload, mut meta) = match items {
155        Some(items) => shape_items(surface, value, array_key.as_deref(), items),
156        None => shape_scalar_envelope(surface, value),
157    };
158
159    if !aliases_removed.is_empty() {
160        meta.insert("aliases_removed".into(), json!(aliases_removed));
161    }
162
163    finalize(surface, payload, array_key.as_deref(), meta)
164}
165
166/// Drops the derived arrays that merely restate the member being reshaped.
167///
168/// The surface shapes exactly one array per envelope. Keeping a clone of it
169/// under another name would hand the caller an unfiltered, unsorted,
170/// unprojected copy of the very rows it asked to narrow, and would blow the
171/// byte ceiling for a payload that is redundant by construction. Mappings come
172/// from [`crate::constants::AGENT_SURFACE_ALIAS_ARRAYS`].
173///
174/// A member is derived only for the subcommand that declared it so, so both the
175/// subcommand and the canonical member must match. `results` is a concatenation
176/// in `recall` and a clone in `related`, while in `hybrid-search` it is disjoint
177/// from `graph_matches` — suppressing there deleted required data. An unknown or
178/// absent subcommand suppresses nothing.
179///
180/// Returns the removed member names in declaration order, so [`apply`] can
181/// record them; an empty vector means nothing was dropped. Only members that
182/// are actually arrays are removed, so an envelope that reuses one of these
183/// names for a scalar keeps it, and a declared derived member the envelope
184/// never carried is a silent no-op that is never reported as removed.
185fn suppress_alias_arrays(
186    surface: &AgentSurface,
187    value: &mut Value,
188    array_key: Option<&str>,
189) -> Vec<String> {
190    let Some(canonical) = array_key else {
191        return Vec::new();
192    };
193    let Some(command) = surface.command.as_deref() else {
194        return Vec::new();
195    };
196    let Some((_, _, aliases)) = crate::constants::AGENT_SURFACE_ALIAS_ARRAYS
197        .iter()
198        .find(|(cmd, key, _)| *cmd == command && *key == canonical)
199    else {
200        return Vec::new();
201    };
202    let Some(map) = value.as_object_mut() else {
203        return Vec::new();
204    };
205    let mut removed = Vec::new();
206    for alias in *aliases {
207        if map.get(*alias).is_some_and(Value::is_array) {
208            map.remove(*alias);
209            removed.push((*alias).to_string());
210        }
211    }
212    removed
213}
214
215/// Envelopes that must never be reshaped.
216fn is_passthrough(value: &Value) -> bool {
217    let Some(map) = value.as_object() else {
218        return false;
219    };
220    // A JSON Schema document is a contract, not a result set.
221    if map.contains_key("$schema") {
222        return true;
223    }
224    // Failure envelopes reach the caller intact, always.
225    if map.get("error") == Some(&Value::Bool(true)) {
226        return true;
227    }
228    map.get("ok") == Some(&Value::Bool(false))
229}
230
231/// Finds the member holding the primary result array.
232///
233/// Well-known names from [`crate::constants::AGENT_SURFACE_RESULT_KEYS`] are
234/// tried in order; otherwise the first member that is an array wins. Returns
235/// `None` when `value` is itself an array or carries no array at all.
236fn locate_result_array(value: &Value) -> Option<String> {
237    let map = value.as_object()?;
238    for candidate in crate::constants::AGENT_SURFACE_RESULT_KEYS {
239        if map.get(*candidate).is_some_and(Value::is_array) {
240            return Some((*candidate).to_string());
241        }
242    }
243    map.iter()
244        .find(|(_, v)| v.is_array())
245        .map(|(k, _)| k.clone())
246}
247
248/// Removes the result array from `value` so it can be reshaped in place.
249fn take_items(value: &mut Value, array_key: Option<&str>) -> Option<Vec<Value>> {
250    match array_key {
251        Some(key) => match value.as_object_mut()?.get_mut(key)? {
252            Value::Array(items) => Some(std::mem::take(items)),
253            _ => None,
254        },
255        None => match value {
256            Value::Array(items) => Some(std::mem::take(items)),
257            _ => None,
258        },
259    }
260}
261
262/// Runs the array pipeline and puts the result back into the envelope.
263fn shape_items(
264    surface: &AgentSurface,
265    mut envelope: Value,
266    array_key: Option<&str>,
267    items: Vec<Value>,
268) -> (Value, Map<String, Value>) {
269    let input_count = items.len();
270    let mut items = shape::filter(items, &surface.filters);
271    if let Some(key) = &surface.sort {
272        items = shape::sort(items, key);
273    }
274    if let Some(key) = &surface.dedupe_by {
275        items = shape::dedupe(items, key);
276    }
277    items = shape::limit(items, surface.max_items);
278    items = shape::project(items, &surface.select);
279    let output_count = items.len();
280
281    if surface.count_only {
282        let mut meta = base_meta(surface, input_count, output_count);
283        meta.insert("count_only".into(), Value::Bool(true));
284        return (json!({ "count": output_count }), meta);
285    }
286
287    // Applied while the primary member still holds the emptied array left by
288    // `take_items`, so the loop below cannot reach it: its length is zero and
289    // it is neither truncated nor reported.
290    let secondary_capped = cap_secondary_arrays(&mut envelope, surface.max_items);
291
292    match array_key {
293        Some(key) => {
294            if let Some(map) = envelope.as_object_mut() {
295                map.insert(key.to_string(), Value::Array(items));
296            }
297        }
298        None => envelope = Value::Array(items),
299    }
300    let mut meta = base_meta(surface, input_count, output_count);
301    if !secondary_capped.is_empty() {
302        meta.insert("secondary_capped".into(), json!(secondary_capped));
303    }
304    (envelope, meta)
305}
306
307/// Applies `--max-items` to every array member other than the primary one.
308///
309/// GAP-SG-191: the cap used to bind the primary array alone, so
310/// `graph --select id --max-items 2` answered with two nodes and all 59 066
311/// edges — 4.55 MiB for a request that asked for two items. `--max-output-bytes`
312/// already reached these members; `--max-items` did not, and nothing documented
313/// the asymmetry.
314///
315/// `--select` deliberately does NOT follow: a secondary array holds a different
316/// collection, not a restatement of the primary one, so projecting `id` over
317/// `edges` would rewrite every element to `{}` and erase `source`/`target`
318/// instead of shrinking them. Capping is safe because it removes whole
319/// elements, never fields inside one.
320///
321/// Returns the member names that were actually shortened, in envelope order.
322fn cap_secondary_arrays(envelope: &mut Value, max_items: usize) -> Vec<String> {
323    if max_items == 0 {
324        return Vec::new();
325    }
326    let Some(map) = envelope.as_object_mut() else {
327        return Vec::new();
328    };
329    let mut capped = Vec::new();
330    for (key, value) in map.iter_mut() {
331        if let Value::Array(items) = value {
332            if items.len() > max_items {
333                items.truncate(max_items);
334                capped.push(key.clone());
335            }
336        }
337    }
338    capped
339}
340
341/// Handles envelopes with no result array: projection applies to the object.
342fn shape_scalar_envelope(surface: &AgentSurface, envelope: Value) -> (Value, Map<String, Value>) {
343    if surface.count_only {
344        let mut meta = base_meta(surface, 1, 1);
345        meta.insert("count_only".into(), Value::Bool(true));
346        return (json!({ "count": 1 }), meta);
347    }
348    let projected = shape::project_one(envelope, &surface.select);
349    (projected, base_meta(surface, 1, 1))
350}
351
352/// Builds the `agent_surface` record shared by both shaping paths.
353fn base_meta(surface: &AgentSurface, input: usize, output: usize) -> Map<String, Value> {
354    let mut meta = Map::new();
355    meta.insert("input_count".into(), json!(input));
356    meta.insert("output_count".into(), json!(output));
357    if !surface.select.is_empty() {
358        meta.insert("select".into(), json!(surface.select));
359    }
360    if !surface.filters.is_empty() {
361        meta.insert("filters".into(), json!(surface.filters.len()));
362    }
363    if let Some(key) = &surface.sort {
364        meta.insert("sort".into(), json!(key));
365    }
366    if let Some(key) = &surface.dedupe_by {
367        meta.insert("dedupe_by".into(), json!(key));
368    }
369    if surface.max_items > 0 {
370        meta.insert("max_items".into(), json!(surface.max_items));
371    }
372    meta
373}
374
375/// Applies the content and byte ceilings, then attaches the record.
376fn finalize(
377    surface: &AgentSurface,
378    mut payload: Value,
379    array_key: Option<&str>,
380    mut meta: Map<String, Value>,
381) -> Value {
382    let content_truncated = shape::truncate_strings(&mut payload, surface.truncate_content);
383    if content_truncated {
384        meta.insert("content_truncated".into(), Value::Bool(true));
385        meta.insert("truncate_content".into(), json!(surface.truncate_content));
386    }
387
388    attach_meta(&mut payload, &meta, content_truncated);
389
390    // Recording the ceiling's verdict makes the envelope grow, so the ceiling
391    // has to be enforced against a budget that already accounts for the
392    // record. Enforcing first and annotating afterwards would either exceed
393    // the ceiling or force a second pass that collapses the envelope into the
394    // stub purely because of its own annotation.
395    let headroom = budget_headroom(surface, &payload, &meta);
396    let effective_max = match surface.max_output_bytes {
397        0 => 0,
398        max => max.saturating_sub(headroom).max(1),
399    };
400
401    let outcome = budget::enforce(&mut payload, array_key, effective_max);
402    if outcome.truncated && !outcome.stub {
403        meta.insert("output_truncated".into(), Value::Bool(true));
404        meta.insert("dropped".into(), json!(outcome.dropped));
405        meta.insert("max_output_bytes".into(), json!(surface.max_output_bytes));
406        // `output_count` was measured by the shaping stage, before the ceiling
407        // existed. Left alone it reports the pre-budget length, so a caller
408        // reading `output_count: 30` beside eleven elements concludes its own
409        // parse lost nineteen. Re-measuring is safe for the reservation above:
410        // the surviving length can only be smaller than the shaped one, so its
411        // decimal form never grows and the headroom can never fall short.
412        if let Some(surviving) = surviving_len(&payload, array_key) {
413            meta.insert("output_count".into(), json!(surviving));
414        }
415        attach_meta(&mut payload, &meta, true);
416    }
417    if outcome.stub {
418        // The stub is built inside `budget::enforce`, which only knows the
419        // budget it was handed — `effective_max`, already reduced by the
420        // headroom above. Reporting that number told a caller who asked for 400
421        // that the ceiling was 340, a figure it never chose and cannot act on.
422        // The non-stub branch above always reported the requested value; this
423        // aligns the two.
424        if let Some(map) = payload.as_object_mut() {
425            map.insert("max_output_bytes".into(), json!(surface.max_output_bytes));
426        }
427    }
428    payload
429}
430
431/// Length of the result array as it stands after the ceiling was enforced.
432///
433/// Returns `None` when the payload no longer carries an array, which is the
434/// stub path; callers guard on that before asking.
435fn surviving_len(payload: &Value, array_key: Option<&str>) -> Option<usize> {
436    match array_key {
437        Some(key) => payload.get(key)?.as_array().map(Vec::len),
438        None => payload.as_array().map(Vec::len),
439    }
440}
441
442/// Bytes the budget record will add to the envelope once the ceiling fires.
443///
444/// Measured rather than guessed: the members are inserted into a throwaway copy
445/// of the record and the two serializations are compared. `dropped` is measured
446/// at its widest possible value, so the reservation can never fall short.
447fn budget_headroom(surface: &AgentSurface, payload: &Value, meta: &Map<String, Value>) -> usize {
448    if surface.max_output_bytes == 0 {
449        return 0;
450    }
451    let widest_dropped = meta
452        .get("input_count")
453        .and_then(Value::as_u64)
454        .unwrap_or_default();
455    let mut annotated = meta.clone();
456    annotated.insert("output_truncated".into(), Value::Bool(true));
457    annotated.insert("dropped".into(), json!(widest_dropped));
458    annotated.insert("max_output_bytes".into(), json!(surface.max_output_bytes));
459
460    let before = encoded_len(&Value::Object(meta.clone()));
461    let after = encoded_len(&Value::Object(annotated));
462    let mut extra = after.saturating_sub(before);
463
464    if payload
465        .as_object()
466        .is_some_and(|map| !map.contains_key(TRUNCATED_KEY))
467    {
468        // `,"truncated":true`
469        extra += TRUNCATED_KEY.len() + r#","":true"#.len();
470    }
471    extra
472}
473
474/// Compact serialized length, or `0` when the value cannot be serialized.
475fn encoded_len(value: &Value) -> usize {
476    serde_json::to_string(value).map_or(0, |s| s.len())
477}
478
479/// Writes the record into an object envelope, raising `truncated` when needed.
480///
481/// Array envelopes have nowhere to carry the record; the shaping still applied,
482/// it is simply not annotated. Existing members are never overwritten.
483fn attach_meta(payload: &mut Value, meta: &Map<String, Value>, truncated: bool) {
484    let Some(map) = payload.as_object_mut() else {
485        return;
486    };
487    map.insert(META_KEY.to_string(), Value::Object(meta.clone()));
488    if truncated && !map.contains_key(TRUNCATED_KEY) {
489        map.insert(TRUNCATED_KEY.to_string(), Value::Bool(true));
490    }
491}