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 gate;
61pub mod shape;
62pub mod stream;
63pub mod target;
64pub mod universe;
65pub mod vocabulary;
66
67#[cfg(test)]
68mod tests;
69
70use crate::errors::AppError;
71use filter::FilterExpr;
72use serde_json::{json, Map, Value};
73use std::sync::OnceLock;
74
75/// Resolved output-shaping request for the current process.
76#[derive(Debug, Clone, Default)]
77pub struct AgentSurface {
78    /// Subcommand that emitted the envelope, as
79    /// [`crate::cli::Commands::agent_surface_slug`] reports it.
80    ///
81    /// CONTEXT, never a knob: it tells alias suppression which subcommand's
82    /// contract applies, and therefore takes no part in [`Self::is_noop`]. A
83    /// surface carrying only a command name still changes nothing.
84    pub command: Option<String>,
85    /// Whether the subcommand can change durable state, as
86    /// [`crate::cli::Commands::mutates`] reports it.
87    ///
88    /// CONTEXT, never a knob, so it takes no part in [`Self::is_noop`]. The gate
89    /// reads it to stay silent after a write: refusing at output time would
90    /// report failure for an operation that already succeeded.
91    pub mutates: bool,
92    /// Escape hatch for `--allow-unknown-keys`: tolerate a key nothing carries.
93    ///
94    /// CONTEXT, never a knob. On its own it changes no envelope; it only widens
95    /// what the gate accepts.
96    pub allow_unknown_keys: bool,
97    /// What `--filter-scope` declared the predicate may observe.
98    ///
99    /// CONTEXT, never a knob: on its own it changes no envelope, it only tells
100    /// the gate which reading the caller meant.
101    pub filter_scope: Option<universe::FilterScope>,
102    /// Whether `--use-active` accepted an ambient target on purpose.
103    ///
104    /// CONTEXT, never a knob, so it takes no part in [`Self::is_noop`]. It
105    /// dispenses a mutating verb from naming its target in the argv, and
106    /// [`target`] records that it was used.
107    pub use_active: bool,
108    /// Keys kept by `--select` / `--fields`, in the requested order.
109    pub select: Vec<String>,
110    /// Predicates from `--filter`, conjoined with AND.
111    pub filters: Vec<FilterExpr>,
112    /// Sort key from `--sort`.
113    pub sort: Option<String>,
114    /// Dedup key from `--dedupe-by`.
115    pub dedupe_by: Option<String>,
116    /// Cap on emitted result elements (`--max-items`); `0` disables it.
117    pub max_items: usize,
118    /// Replace the payload with a count (`--count-only`).
119    pub count_only: bool,
120    /// Whether the subcommand emits one self-contained record per line.
121    ///
122    /// GAP-SG-209: [`crate::cli::Commands::streams`] reports it, and [`gate`]
123    /// refuses the knobs that would otherwise be applied once per record.
124    pub streamed: bool,
125    /// Whether the subcommand actually persists, so its envelope is a receipt.
126    ///
127    /// CONTEXT, never a knob. GAP-SG-206: [`crate::cli::Commands::persists`]
128    /// reports it, and `--count-only` is suppressed rather than honoured when it
129    /// is set. DISTINCT from [`Self::mutates`], which answers `true` for
130    /// `config list-keys` and every other command that merely failed to make the
131    /// read-only list.
132    pub writes_receipt: bool,
133    /// Cap on string length in characters (`--truncate-content`); `0` disables it.
134    pub truncate_content: usize,
135    /// Cap on the serialized envelope in bytes (`--max-output-bytes`); `0` disables it.
136    pub max_output_bytes: usize,
137}
138
139impl AgentSurface {
140    /// `true` when no knob is set and `apply` must be a no-op.
141    pub fn is_noop(&self) -> bool {
142        self.select.is_empty()
143            && self.filters.is_empty()
144            && self.sort.is_none()
145            && self.dedupe_by.is_none()
146            && self.max_items == 0
147            && !self.count_only
148            && self.truncate_content == 0
149            && self.max_output_bytes == 0
150    }
151}
152
153static SURFACE: OnceLock<AgentSurface> = OnceLock::new();
154
155/// Installs the process-wide surface. Idempotent, first call wins.
156pub fn init(surface: AgentSurface) {
157    let _ = SURFACE.set(surface);
158}
159
160/// Borrows the installed surface, or an inert one when `init` never ran.
161pub fn get() -> &'static AgentSurface {
162    static INERT: OnceLock<AgentSurface> = OnceLock::new();
163    SURFACE
164        .get()
165        .unwrap_or_else(|| INERT.get_or_init(AgentSurface::default))
166}
167
168/// `true` when the installed surface would change an envelope.
169///
170/// Callers use it to skip the extra `Value` round-trip on the hot path.
171pub fn active() -> bool {
172    !get().is_noop()
173}
174
175/// Applies the installed surface to `value`.
176///
177/// # Errors
178/// Propagates the refusal raised by `apply` when the request is incoherent.
179pub fn apply_global(value: Value) -> Result<Value, AppError> {
180    apply(get(), value)
181}
182
183/// Member holding the record of what the surface did.
184const META_KEY: &str = "agent_surface";
185
186/// Member raised whenever data was removed.
187const TRUNCATED_KEY: &str = "truncated";
188
189/// Applies `surface` to `value`, honouring the invariants documented above.
190///
191/// # Errors
192/// Returns [`AppError::Usage`] when the request cannot be honoured as asked —
193/// see [`gate`] for the three shapes that earns.
194pub fn apply(surface: &AgentSurface, value: Value) -> Result<Value, AppError> {
195    // The ONE place that reads both process-wide cells. Everything downstream
196    // takes them as arguments, which is what makes the whole surface testable.
197    let ceiling = universe::get();
198    apply_with_premises(surface, value, target::record(surface, ceiling), ceiling)
199}
200
201/// The body of `apply`, with the resolved target supplied rather than read.
202///
203/// The target lives in a process-wide `OnceLock`, which is correct for a
204/// one-shot binary and wrong for a test binary: `paths` and `agent_surface` unit
205/// tests share one process, so whether a target exists would depend on which
206/// test ran first. Taking it as an argument makes every test state its own
207/// premise, and keeps the production call site the only place that reads global
208/// state.
209///
210/// # Errors
211/// Returns [`AppError::Usage`] when the request cannot be honoured as asked.
212pub fn apply_with_target(
213    surface: &AgentSurface,
214    value: Value,
215    target: Option<Map<String, Value>>,
216) -> Result<Value, AppError> {
217    apply_with_premises(surface, value, target, universe::get())
218}
219
220/// The body of `apply`, with BOTH ambient facts supplied rather than read.
221///
222/// GAP-SG-201 shipped a refusal that no test could reach, and this signature is
223/// why it could not. The query ceiling lives in a second process-wide `OnceLock`,
224/// and `OnceLock` offers a `static` no reset at all — `take` and every `get_mut`
225/// require `&mut self` — so two tests in one binary could never state different
226/// ceilings. Nothing in the family had a test, the compiler was the only reader
227/// left, and `dead_code` cannot see a `pub` item in a lib crate. A guard was
228/// therefore written, translated, reviewed and never called.
229///
230/// [`apply_with_target`] keeps its own shape because the seventeen tests that
231/// only care about the target should not have to state a ceiling they have no
232/// opinion about.
233///
234/// # Errors
235/// Returns [`AppError::Usage`] when the request cannot be honoured as asked.
236pub fn apply_with_premises(
237    surface: &AgentSurface,
238    mut value: Value,
239    target: Option<Map<String, Value>>,
240    ceiling: Option<&universe::QueryCeiling>,
241) -> Result<Value, AppError> {
242    // Checked FIRST, and separately from the no-op case below: a schema document
243    // is a contract rather than a result, and a failure envelope carries its own
244    // target record from `crate::output::error_envelope`, so neither is
245    // annotated here.
246    if is_passthrough(&value) {
247        return Ok(value);
248    }
249    if surface.is_noop() {
250        // No knob is set, so nothing is reshaped — and yet the resolved target
251        // is still reported. Until v1.2.6 this branch returned unconditionally,
252        // which is precisely how a universal contract ended up visible only to
253        // callers who had already set an unrelated flag. See [`target`].
254        if let Some(meta) = target {
255            attach_meta(&mut value, &meta, false);
256        }
257        return Ok(value);
258    }
259
260    let array_key = locate_result_array(&value);
261    let aliases_removed = suppress_alias_arrays(surface, &mut value, array_key.as_deref());
262    let items = take_items(&mut value, array_key.as_deref());
263
264    // Resolution runs on the lifted elements and on what is LEFT of the
265    // envelope, which is exactly the split GAP-SG-203 turns on: a key found only
266    // on the remainder is a key the predicate would never have reached.
267    const NO_ELEMENTS: &[Value] = &[];
268    let findings = gate::evaluate(
269        surface,
270        &vocabulary::Scope::new(items.as_deref().unwrap_or(NO_ELEMENTS), &value)
271            .with_command(surface.command.as_deref()),
272        array_key.as_deref(),
273        items.is_some(),
274        ceiling,
275    )?;
276
277    let (payload, mut meta) = match items {
278        Some(items) => shape_items(surface, value, array_key.as_deref(), items, ceiling),
279        None => shape_scalar_envelope(surface, value, ceiling),
280    };
281
282    // GAP-SG-205: merged at the ONE point both shaping paths converge on, so the
283    // shaped and the inert envelopes can never report different things about the
284    // same process.
285    if let Some(record) = target {
286        meta.extend(record);
287    }
288
289    if !aliases_removed.is_empty() {
290        meta.insert("aliases_removed".into(), json!(aliases_removed));
291    }
292    if findings.is_partial() {
293        // A projection that dropped part of what was asked for says so, so a
294        // caller never reads a missing field as a missing value.
295        meta.insert("unresolved_keys".into(), json!(findings.unresolved_keys));
296        meta.insert("resolved_keys".into(), json!(findings.resolved_keys));
297        meta.insert("key_resolution".into(), json!("partial"));
298        if !findings.key_suggestions.is_empty() {
299            meta.insert("key_suggestions".into(), json!(findings.key_suggestions));
300        }
301        if findings.vocabulary_partial {
302            meta.insert("vocabulary_partial".into(), Value::Bool(true));
303        }
304    }
305    // Which member the reshaping actually acted on, and whether the CLI named it
306    // or the surface guessed. A caller that asked about a top-level key and got a
307    // narrowed array deserves to see that its request was redirected.
308    if let Some(key) = array_key.as_deref() {
309        let source = if is_declared_result_array(key) {
310            ARRAY_SOURCE_DECLARED
311        } else {
312            ARRAY_SOURCE_FALLBACK
313        };
314        meta.insert("result_array_source".into(), json!(source));
315    }
316
317    Ok(finalize(surface, payload, array_key.as_deref(), meta))
318}
319
320/// Drops the derived arrays that merely restate the member being reshaped.
321///
322/// The surface shapes exactly one array per envelope. Keeping a clone of it
323/// under another name would hand the caller an unfiltered, unsorted,
324/// unprojected copy of the very rows it asked to narrow, and would blow the
325/// byte ceiling for a payload that is redundant by construction. Mappings come
326/// from [`crate::constants::AGENT_SURFACE_ALIAS_ARRAYS`].
327///
328/// A member is derived only for the subcommand that declared it so, so both the
329/// subcommand and the canonical member must match. `results` is a concatenation
330/// in `recall` and a clone in `related`, while in `hybrid-search` it is disjoint
331/// from `graph_matches` — suppressing there deleted required data. An unknown or
332/// absent subcommand suppresses nothing.
333///
334/// Returns the removed member names in declaration order, so `apply` can
335/// record them; an empty vector means nothing was dropped. Only members that
336/// are actually arrays are removed, so an envelope that reuses one of these
337/// names for a scalar keeps it, and a declared derived member the envelope
338/// never carried is a silent no-op that is never reported as removed.
339fn suppress_alias_arrays(
340    surface: &AgentSurface,
341    value: &mut Value,
342    array_key: Option<&str>,
343) -> Vec<String> {
344    let Some(canonical) = array_key else {
345        return Vec::new();
346    };
347    let Some(command) = surface.command.as_deref() else {
348        return Vec::new();
349    };
350    let Some((_, _, aliases)) = crate::constants::AGENT_SURFACE_ALIAS_ARRAYS
351        .iter()
352        .find(|(cmd, key, _)| *cmd == command && *key == canonical)
353    else {
354        return Vec::new();
355    };
356    let Some(map) = value.as_object_mut() else {
357        return Vec::new();
358    };
359    let mut removed = Vec::new();
360    for alias in *aliases {
361        if map.get(*alias).is_some_and(Value::is_array) {
362            map.remove(*alias);
363            removed.push((*alias).to_string());
364        }
365    }
366    removed
367}
368
369/// Envelopes that must never be reshaped.
370fn is_passthrough(value: &Value) -> bool {
371    let Some(map) = value.as_object() else {
372        return false;
373    };
374    // A JSON Schema document is a contract, not a result set.
375    if map.contains_key("$schema") {
376        return true;
377    }
378    // Failure envelopes reach the caller intact, always.
379    if map.get("error") == Some(&Value::Bool(true)) {
380        return true;
381    }
382    map.get("ok") == Some(&Value::Bool(false))
383}
384
385/// Finds the member holding the primary result array.
386///
387/// Well-known names from [`crate::constants::AGENT_SURFACE_RESULT_KEYS`] are
388/// tried in order; otherwise the first member that is an array wins. Returns
389/// `None` when `value` is itself an array or carries no array at all.
390fn locate_result_array(value: &Value) -> Option<String> {
391    let map = value.as_object()?;
392    for candidate in crate::constants::AGENT_SURFACE_RESULT_KEYS {
393        if map.get(*candidate).is_some_and(Value::is_array) {
394            return Some((*candidate).to_string());
395        }
396    }
397    map.iter()
398        .find(|(_, v)| v.is_array())
399        .map(|(k, _)| k.clone())
400}
401
402/// Whether `key` is a member the CLI declared as a result set.
403///
404/// The distinction is load-bearing, not cosmetic. [`locate_result_array`] falls
405/// back to "the first member that is an array" when no known name matches, which
406/// is a guess: `stats` carries `namespaces`, so the fallback elected it and
407/// `--select total_memories` — a documented, top-level key — became unresolvable.
408/// An array chosen by heuristic is not a declared collection, and the gate and
409/// the record both have to say so. One implementation, two readers.
410fn is_declared_result_array(key: &str) -> bool {
411    crate::constants::AGENT_SURFACE_RESULT_KEYS.contains(&key)
412}
413
414/// Wire spelling for an array the CLI named as its result set.
415const ARRAY_SOURCE_DECLARED: &str = "declared";
416
417/// Wire spelling for an array the surface elected for want of a declared one.
418const ARRAY_SOURCE_FALLBACK: &str = "fallback";
419
420/// Removes the result array from `value` so it can be reshaped in place.
421fn take_items(value: &mut Value, array_key: Option<&str>) -> Option<Vec<Value>> {
422    match array_key {
423        Some(key) => match value.as_object_mut()?.get_mut(key)? {
424            Value::Array(items) => Some(std::mem::take(items)),
425            _ => None,
426        },
427        None => match value {
428            Value::Array(items) => Some(std::mem::take(items)),
429            _ => None,
430        },
431    }
432}
433
434/// Runs the array pipeline and puts the result back into the envelope.
435fn shape_items(
436    surface: &AgentSurface,
437    mut envelope: Value,
438    array_key: Option<&str>,
439    items: Vec<Value>,
440    ceiling: Option<&universe::QueryCeiling>,
441) -> (Value, Map<String, Value>) {
442    let input_count = items.len();
443    // GAP-SG-274: the same slug the gate resolved keys under, so a key admitted
444    // through a mode-scoped synonym is read back under that very synonym.
445    let command = surface.command.as_deref();
446    let mut items = shape::filter(items, &surface.filters, command);
447    if let Some(key) = &surface.sort {
448        items = shape::sort(items, key, command);
449    }
450    if let Some(key) = &surface.dedupe_by {
451        items = shape::dedupe(items, key, command);
452    }
453    // Measured BEFORE the output ceiling, because that is the only point where
454    // "how many rows satisfied the predicate" still has an answer.
455    let matched_count = items.len();
456    items = shape::limit(items, surface.max_items);
457    items = shape::project(items, &surface.select, command);
458    let output_count = items.len();
459
460    // GAP-SG-206. A write receipt reaches HERE, not only the scalar branch:
461    // `remember` carries `entities_created`, and no member of that envelope is a
462    // declared result key, so the surface elects the first array by fallback and
463    // counts it. Scoping the guard to the scalar branch therefore protected
464    // nothing — the receipt this exists to save took this path all along.
465    if surface.count_only && !surface.writes_receipt {
466        let mut meta = base_meta(surface, input_count, output_count, ceiling);
467        meta.insert("count_only".into(), Value::Bool(true));
468        meta.insert(
469            "count_scope".into(),
470            json!(universe::count_scope(output_count, matched_count, ceiling)),
471        );
472        return (json!({ "count": output_count }), meta);
473    }
474
475    // Applied while the primary member still holds the emptied array left by
476    // `take_items`, so the loop below cannot reach it: its length is zero and
477    // it is neither truncated nor reported.
478    let secondary_capped = cap_secondary_arrays(&mut envelope, surface.max_items);
479
480    match array_key {
481        Some(key) => {
482            if let Some(map) = envelope.as_object_mut() {
483                map.insert(key.to_string(), Value::Array(items));
484            }
485        }
486        None => envelope = Value::Array(items),
487    }
488    let mut meta = base_meta(surface, input_count, output_count, ceiling);
489    if surface.count_only {
490        // Reached only through the receipt branch above. Named so a caller that
491        // asked for a count and received an envelope learns which of the two
492        // happened instead of reading the full payload as a bug.
493        meta.insert("count_only_suppressed".into(), Value::Bool(true));
494    }
495    if !secondary_capped.is_empty() {
496        meta.insert("secondary_capped".into(), json!(secondary_capped));
497    }
498    (envelope, meta)
499}
500
501/// Applies `--max-items` to every array member other than the primary one.
502///
503/// GAP-SG-191: the cap used to bind the primary array alone, so
504/// `graph --select id --max-items 2` answered with two nodes and all 59 066
505/// edges — 4.55 MiB for a request that asked for two items. `--max-output-bytes`
506/// already reached these members; `--max-items` did not, and nothing documented
507/// the asymmetry.
508///
509/// `--select` deliberately does NOT follow: a secondary array holds a different
510/// collection, not a restatement of the primary one, so projecting `id` over
511/// `edges` would rewrite every element to `{}` and erase `source`/`target`
512/// instead of shrinking them. Capping is safe because it removes whole
513/// elements, never fields inside one.
514///
515/// Returns the member names that were actually shortened, in ascending key
516/// order.
517///
518/// NOT insertion order, and the difference is observable: `serde_json` is built
519/// here without the `preserve_order` feature, so a [`Map`] is a `BTreeMap` and
520/// iteration follows the key's `Ord`. Saying "envelope order" invited a caller
521/// to read a position that never encoded anything. Ascending order is also what
522/// makes this list identical on Linux, macOS and Windows.
523fn cap_secondary_arrays(envelope: &mut Value, max_items: usize) -> Vec<String> {
524    if max_items == 0 {
525        return Vec::new();
526    }
527    let Some(map) = envelope.as_object_mut() else {
528        return Vec::new();
529    };
530    let mut capped = Vec::new();
531    for (key, value) in map.iter_mut() {
532        if let Value::Array(items) = value {
533            if items.len() > max_items {
534                items.truncate(max_items);
535                capped.push(key.clone());
536            }
537        }
538    }
539    capped
540}
541
542/// Handles envelopes with no result array: projection applies to the object.
543///
544/// # GAP-SG-206: a count must not eat a write receipt
545///
546/// [`gate`] deliberately refuses nothing after a write, because reporting failure
547/// for a succeeded `remember` makes a retrying caller write twice. That fence
548/// stops the surface from FAILING the command; nothing stopped it from emptying
549/// the answer — `--count-only` replaced the envelope with a number, discarding
550/// `memory_id`, `entities_created` and `enrich_recommended`, the receipt callers
551/// are told to parse, for an operation that cannot be replayed.
552///
553/// The guard turns on [`AgentSurface::writes_receipt`] and NOT on
554/// [`AgentSurface::mutates`]. The second reports `true` for `config list-keys`,
555/// which writes nothing but merely failed to make the read-only list, and keying
556/// the suppression there took a working answer away — the integration suite
557/// caught it. The first is [`crate::cli::Commands::persists`], the same question
558/// the write policy already asks about naming a target.
559fn shape_scalar_envelope(
560    surface: &AgentSurface,
561    envelope: Value,
562    ceiling: Option<&universe::QueryCeiling>,
563) -> (Value, Map<String, Value>) {
564    if surface.count_only && !surface.writes_receipt {
565        let mut meta = base_meta(surface, 1, 1, ceiling);
566        meta.insert("count_only".into(), Value::Bool(true));
567        // An envelope with no result array is one thing, and no ceiling can make
568        // it fewer, so the count always describes what matched.
569        meta.insert("count_scope".into(), json!(universe::COUNT_SCOPE_SCALAR));
570        return (json!({ "count": 1 }), meta);
571    }
572    let projected = shape::project_one(envelope, &surface.select, surface.command.as_deref());
573    let mut meta = base_meta(surface, 1, 1, ceiling);
574    if surface.count_only {
575        meta.insert("count_only_suppressed".into(), Value::Bool(true));
576    }
577    (projected, meta)
578}
579
580/// Builds the `agent_surface` record shared by both shaping paths.
581fn base_meta(
582    surface: &AgentSurface,
583    input: usize,
584    output: usize,
585    ceiling: Option<&universe::QueryCeiling>,
586) -> Map<String, Value> {
587    let mut meta = Map::new();
588    meta.insert("input_count".into(), json!(input));
589    meta.insert("output_count".into(), json!(output));
590    if !surface.select.is_empty() {
591        meta.insert("select".into(), json!(surface.select));
592    }
593    if !surface.filters.is_empty() {
594        meta.insert("filters".into(), json!(surface.filters.len()));
595    }
596    if let Some(key) = &surface.sort {
597        meta.insert("sort".into(), json!(key));
598    }
599    if let Some(key) = &surface.dedupe_by {
600        meta.insert("dedupe_by".into(), json!(key));
601    }
602    if surface.max_items > 0 {
603        meta.insert("max_items".into(), json!(surface.max_items));
604    }
605    // GAP-SG-205 is deliberately NOT here: the target is merged by
606    // [`apply_with_target`], which is the one point the shaped and the inert
607    // paths share. Attaching it inside this function is what made it invisible
608    // whenever the surface was inert.
609    universe::insert_query_ceiling(&mut meta, ceiling);
610    meta
611}
612
613/// Applies the content and byte ceilings, then attaches the record.
614fn finalize(
615    surface: &AgentSurface,
616    mut payload: Value,
617    array_key: Option<&str>,
618    mut meta: Map<String, Value>,
619) -> Value {
620    let content_truncated = shape::truncate_strings(&mut payload, surface.truncate_content);
621    if content_truncated {
622        meta.insert("content_truncated".into(), Value::Bool(true));
623        meta.insert("truncate_content".into(), json!(surface.truncate_content));
624    }
625
626    attach_meta(&mut payload, &meta, content_truncated);
627
628    // Recording the ceiling's verdict makes the envelope grow, so the ceiling
629    // has to be enforced against a budget that already accounts for the
630    // record. Enforcing first and annotating afterwards would either exceed
631    // the ceiling or force a second pass that collapses the envelope into the
632    // stub purely because of its own annotation.
633    let headroom = budget_headroom(surface, &payload, &meta);
634    let effective_max = match surface.max_output_bytes {
635        0 => 0,
636        max => max.saturating_sub(headroom).max(1),
637    };
638
639    let outcome = budget::enforce(&mut payload, array_key, effective_max);
640    if outcome.truncated && !outcome.stub {
641        meta.insert("output_truncated".into(), Value::Bool(true));
642        meta.insert("dropped".into(), json!(outcome.dropped));
643        meta.insert("max_output_bytes".into(), json!(surface.max_output_bytes));
644        // `output_count` was measured by the shaping stage, before the ceiling
645        // existed. Left alone it reports the pre-budget length, so a caller
646        // reading `output_count: 30` beside eleven elements concludes its own
647        // parse lost nineteen. Re-measuring is safe for the reservation above:
648        // the surviving length can only be smaller than the shaped one, so its
649        // decimal form never grows and the headroom can never fall short.
650        if let Some(surviving) = surviving_len(&payload, array_key) {
651            meta.insert("output_count".into(), json!(surviving));
652        }
653        attach_meta(&mut payload, &meta, true);
654    }
655    if outcome.stub {
656        // The stub is built inside `budget::enforce`, which only knows the
657        // budget it was handed — `effective_max`, already reduced by the
658        // headroom above. Reporting that number told a caller who asked for 400
659        // that the ceiling was 340, a figure it never chose and cannot act on.
660        // The non-stub branch above always reported the requested value; this
661        // aligns the two.
662        if let Some(map) = payload.as_object_mut() {
663            map.insert("max_output_bytes".into(), json!(surface.max_output_bytes));
664        }
665    }
666    payload
667}
668
669/// Length of the result array as it stands after the ceiling was enforced.
670///
671/// Returns `None` when the payload no longer carries an array, which is the
672/// stub path; callers guard on that before asking.
673fn surviving_len(payload: &Value, array_key: Option<&str>) -> Option<usize> {
674    match array_key {
675        Some(key) => payload.get(key)?.as_array().map(Vec::len),
676        None => payload.as_array().map(Vec::len),
677    }
678}
679
680/// Bytes the budget record will add to the envelope once the ceiling fires.
681///
682/// Measured rather than guessed: the members are inserted into a throwaway copy
683/// of the record and the two serializations are compared. `dropped` is measured
684/// at its widest possible value, so the reservation can never fall short.
685fn budget_headroom(surface: &AgentSurface, payload: &Value, meta: &Map<String, Value>) -> usize {
686    if surface.max_output_bytes == 0 {
687        return 0;
688    }
689    let widest_dropped = meta
690        .get("input_count")
691        .and_then(Value::as_u64)
692        .unwrap_or_default();
693    let mut annotated = meta.clone();
694    annotated.insert("output_truncated".into(), Value::Bool(true));
695    annotated.insert("dropped".into(), json!(widest_dropped));
696    annotated.insert("max_output_bytes".into(), json!(surface.max_output_bytes));
697
698    let before = encoded_len(&Value::Object(meta.clone()));
699    let after = encoded_len(&Value::Object(annotated));
700    let mut extra = after.saturating_sub(before);
701
702    if payload
703        .as_object()
704        .is_some_and(|map| !map.contains_key(TRUNCATED_KEY))
705    {
706        // `,"truncated":true`
707        extra += TRUNCATED_KEY.len() + r#","":true"#.len();
708    }
709    extra
710}
711
712/// Compact serialized length, or `0` when the value cannot be serialized.
713fn encoded_len(value: &Value) -> usize {
714    serde_json::to_string(value).map_or(0, |s| s.len())
715}
716
717/// Writes the record into an object envelope, raising `truncated` when needed.
718///
719/// Array envelopes have nowhere to carry the record; the shaping still applied,
720/// it is simply not annotated.
721///
722/// The flag is raised even over an existing `false`. Until v1.2.6 the write was
723/// guarded by `!map.contains_key`, which sounds conservative and was not: `list`
724/// serializes `truncated` unconditionally (`ListResponse` declares it as a plain
725/// `bool`), so the member was ALWAYS present and the surface could never raise
726/// it on the most used command in the binary. The module has always promised
727/// that removing data is never silent; that promise was quietly false wherever
728/// the command shipped its own flag.
729///
730/// Raising it is also monotonic — `true` is never written back to `false` — so
731/// a command that already truncated its own rows keeps saying so.
732fn attach_meta(payload: &mut Value, meta: &Map<String, Value>, truncated: bool) {
733    let Some(map) = payload.as_object_mut() else {
734        return;
735    };
736    map.insert(META_KEY.to_string(), Value::Object(meta.clone()));
737    if truncated {
738        map.insert(TRUNCATED_KEY.to_string(), Value::Bool(true));
739    }
740}