Skip to main content

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}