sqlite_graphrag/agent_surface/budget.rs
1//! Hard ceiling on the serialized size of one envelope (`--max-output-bytes`).
2//!
3//! Agents pay for every byte they read back. The ceiling is enforced by
4//! dropping trailing elements of the result array until the compact
5//! serialization fits, never by cutting the JSON text — a byte-sliced envelope
6//! would not parse.
7//!
8//! When even the envelope without any element exceeds the ceiling, the payload
9//! is replaced by a small, always-parseable stub that says so.
10
11use serde_json::{json, Value};
12
13/// What the ceiling did to an envelope.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15pub struct BudgetOutcome {
16 /// `true` when at least one element was dropped or the stub was emitted.
17 pub truncated: bool,
18 /// Number of result elements removed to fit the ceiling.
19 pub dropped: usize,
20 /// `true` when the envelope itself did not fit and was replaced.
21 pub stub: bool,
22}
23
24/// Compact serialized length of `value`, or `usize::MAX` when it cannot be
25/// serialized (treated as "does not fit" so the ceiling degrades safely).
26fn encoded_len(value: &Value) -> usize {
27 serde_json::to_string(value).map_or(usize::MAX, |s| s.len())
28}
29
30/// Borrows the result array addressed by `array_key`.
31///
32/// `None` selects `value` itself when it is an array.
33fn array_mut<'a>(value: &'a mut Value, array_key: Option<&str>) -> Option<&'a mut Vec<Value>> {
34 match array_key {
35 None => value.as_array_mut(),
36 Some(key) => value.as_object_mut()?.get_mut(key)?.as_array_mut(),
37 }
38}
39
40/// Names every array member of `value` other than the one held by `array_key`.
41///
42/// GAP-SG-171: an envelope may carry arrays the surface does not reshape and
43/// that are not aliases either — `graph` pairs `nodes` with `edges`, which is a
44/// different collection, not a restatement of the same one. Emptying only the
45/// canonical array then left `edges` alone, the envelope stayed over budget, and
46/// the stub replaced everything. Trimming these before giving up keeps the
47/// caller with data instead of a placeholder.
48fn secondary_array_keys(value: &Value, array_key: Option<&str>) -> Vec<String> {
49 let Some(map) = value.as_object() else {
50 return Vec::new();
51 };
52 map.iter()
53 .filter(|(k, v)| v.is_array() && Some(k.as_str()) != array_key)
54 .map(|(k, _)| k.clone())
55 .collect()
56}
57
58/// Lifts the result array out of `value`, leaving an empty array in its place.
59///
60/// Moving instead of copying is what lets [`enforce`] measure the envelope
61/// without the elements while still owning them.
62fn take_array(value: &mut Value, array_key: Option<&str>) -> Option<Vec<Value>> {
63 array_mut(value, array_key).map(std::mem::take)
64}
65
66/// Puts `items` back where [`take_array`] found them.
67///
68/// A missing slot means the caller reshaped the envelope in between, in which
69/// case the elements are dropped rather than reattached somewhere they do not
70/// belong.
71fn put_array(value: &mut Value, array_key: Option<&str>, items: Vec<Value>) {
72 if let Some(slot) = array_mut(value, array_key) {
73 *slot = items;
74 }
75}
76
77/// Empties the secondary arrays of `value`, returning how many entries went.
78fn drain_secondary_arrays(value: &mut Value, keys: &[String]) -> usize {
79 let Some(map) = value.as_object_mut() else {
80 return 0;
81 };
82 let mut dropped = 0usize;
83 for key in keys {
84 if let Some(Value::Array(items)) = map.get_mut(key) {
85 dropped += items.len();
86 items.clear();
87 }
88 }
89 dropped
90}
91
92/// Shrinks `value` until its compact form is at most `max` bytes.
93///
94/// `max == 0` disables the ceiling. Returns what had to be sacrificed so the
95/// caller can record it in the envelope; truncation is never silent.
96pub fn enforce(value: &mut Value, array_key: Option<&str>, max: usize) -> BudgetOutcome {
97 if max == 0 || encoded_len(value) <= max {
98 return BudgetOutcome::default();
99 }
100
101 // Lifting the elements out first serves both steps below: the envelope left
102 // behind IS the "primary array emptied" probe the secondary check needs, and
103 // it is the skeleton the prefix scan measures against. Until v1.2.4 each of
104 // those was a full `value.clone()`, and the search cloned once per
105 // iteration — on a 7.6 MB envelope with 59 066 edges that was ~16 whole-tree
106 // copies, over 100 MB of churn, to answer how many elements fit.
107 let mut items = take_array(value, array_key).unwrap_or_default();
108 let original_len = items.len();
109
110 let secondary = secondary_array_keys(value, array_key);
111 let mut dropped_secondary = 0usize;
112 if !secondary.is_empty() && encoded_len(value) > max {
113 dropped_secondary = drain_secondary_arrays(value, &secondary);
114 }
115
116 if original_len > 0 {
117 // A compact JSON array is `[`, the elements, `,` between each, `]`. The
118 // brackets are already inside `skeleton` because the emptied array
119 // serialized as `[]`, so a prefix of k elements costs the sum of their
120 // own lengths plus k-1 separators. Each element is serialized exactly
121 // once and the running sum is monotonic, so the longest fitting prefix
122 // falls out of a single forward scan — no probe, no clone.
123 let skeleton = encoded_len(value);
124 let mut used = 0usize;
125 let mut best = 0usize;
126 for (index, item) in items.iter().enumerate() {
127 let separator = usize::from(index > 0);
128 let cost = encoded_len(item).saturating_add(separator);
129 if skeleton.saturating_add(used).saturating_add(cost) > max {
130 break;
131 }
132 used = used.saturating_add(cost);
133 best = index + 1;
134 }
135 if best > 0 {
136 items.truncate(best);
137 put_array(value, array_key, items);
138 return BudgetOutcome {
139 truncated: true,
140 dropped: original_len - best + dropped_secondary,
141 stub: false,
142 };
143 }
144 }
145
146 // Every array is empty by now. If the scaffolding alone fits, the caller
147 // still gets a parseable envelope with its scalar fields intact, which is
148 // strictly more than the stub would leave.
149 if encoded_len(value) <= max {
150 return BudgetOutcome {
151 truncated: true,
152 dropped: original_len + dropped_secondary,
153 stub: false,
154 };
155 }
156
157 *value = json!({
158 "truncated": true,
159 "truncated_reason": "max_output_bytes",
160 "max_output_bytes": max,
161 });
162 BudgetOutcome {
163 truncated: true,
164 dropped: original_len + dropped_secondary,
165 stub: true,
166 }
167}