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}