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}