Skip to main content

car_server_core/assistant/
value_store.rs

1//! Session-scoped tool-result values, and the bounded previews that stand in
2//! for them in the transcript (Parslee-ai/car#813).
3//!
4//! # Why this exists
5//!
6//! The assistant loop used to hard-truncate every tool observation: a 100-row
7//! result became ~2 rows and rows 3-100 were *gone*, with no way to reach them
8//! short of re-running the tool and hoping the second result fit. Truncation
9//! and a bounded preview look identical in the transcript and behave nothing
10//! alike — the first destroys data, the second keeps the value live and puts a
11//! description of it in the context.
12//!
13//! Three costs were being paid at once: data loss on any result over the cap;
14//! token cost, because full results are re-serialized into history every turn
15//! until compaction evicts them (and compaction then rewrites the prefix,
16//! invalidating provider-side prefix caching); and silent wrong answers, since
17//! a model reasoning over a clipped table has no signal beyond a marker it
18//! routinely ignores.
19//!
20//! # What a preview must carry
21//!
22//! Enough *shape* that the model does not need the bytes to plan its next call:
23//! the concrete type, the true length, and a head/tail sample. NVIDIA's OO
24//! Agents work ([arXiv:2607.20709](https://arxiv.org/abs/2607.20709)) reports
25//! 82.2% on SWE-bench Verified at ~28 calls / 1.1M tokens per task against
26//! 78.2% at 66 calls / 2.2M tokens for the same backend re-serializing full
27//! results — higher pass rate at roughly half the tokens and 40% of the calls.
28//! Their `agentdoc/_pformat.py` is ~2k lines and they note that finding preview
29//! formats that are obvious to an LLM is still open work, so this module makes
30//! no claim to be finished. It handles the shapes CAR's own tools return and
31//! says plainly, in [`render_preview`], what it does not special-case.
32//!
33//! # Lifetime
34//!
35//! The store lives for one loop run and nothing persists past it. Handles are
36//! `r1`, `r2`, … — short, because they are typed by the model into subsequent
37//! tool arguments, and a handle nobody can type is a handle nobody uses.
38
39use std::collections::HashMap;
40
41use serde_json::Value;
42
43/// How much of a string preview to show at each end.
44const TEXT_HEAD_BYTES: usize = 400;
45const TEXT_TAIL_BYTES: usize = 200;
46/// How many elements of a sequence to sample at each end.
47const SEQ_HEAD_ITEMS: usize = 3;
48const SEQ_TAIL_ITEMS: usize = 2;
49/// How much of a single sampled element to show before eliding it.
50const ELEMENT_BUDGET: usize = 120;
51
52/// Per-run store of full tool results, addressed by short handles.
53///
54/// Deliberately not thread-shared: the loop is sequential and one store per run
55/// is the whole lifetime story. A shared store would raise a question this
56/// design does not want to answer — whether one run can read another's values.
57#[derive(Default, Debug)]
58pub struct SessionValues {
59    values: HashMap<String, Value>,
60    next: usize,
61}
62
63impl SessionValues {
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Retain `value` and return its handle.
69    pub fn put(&mut self, value: Value) -> String {
70        self.next += 1;
71        let handle = format!("r{}", self.next);
72        self.values.insert(handle.clone(), value);
73        handle
74    }
75
76    pub fn get(&self, handle: &str) -> Option<&Value> {
77        self.values.get(handle)
78    }
79
80    pub fn len(&self) -> usize {
81        self.values.len()
82    }
83
84    pub fn is_empty(&self) -> bool {
85        self.values.is_empty()
86    }
87
88    /// Substitute `$rN` references in tool arguments with the values they name.
89    ///
90    /// Runs BEFORE parameter validation, which is not an implementation detail:
91    /// `car-validator` checks parameters against the tool's JSON Schema, and
92    /// `"$r3"` is a string where the schema may demand an array. Resolving
93    /// first means the validator sees the real value and its guarantees are
94    /// unweakened — the alternative, teaching every schema to also admit a
95    /// reference form, would punch a hole in all of them.
96    ///
97    /// Only *whole* string values are treated as references. A string that
98    /// merely contains `$r3` is left alone: tool arguments legitimately carry
99    /// shell snippets and regexes, and interpolating into the middle of one
100    /// would corrupt commands that have nothing to do with this feature.
101    ///
102    /// An unknown handle is left untouched rather than erased or errored. The
103    /// model gets the literal string back and the tool's own validation
104    /// reports it, which is a better failure than silently substituting null.
105    /// Returns the handles that were resolved, for logging.
106    pub fn resolve_refs(&self, params: &mut Value) -> Vec<String> {
107        let mut resolved = Vec::new();
108        self.resolve_into(params, &mut resolved);
109        resolved
110    }
111
112    /// Resolve `$rN`, or `$rN.field.sub` / `$rN.0` into a value.
113    ///
114    /// Field addressing is not a nicety. Tools return *envelopes* — CAR's
115    /// `read_file` yields `{content, path, size_bytes, total_lines}` — so a
116    /// handle that could only name the whole envelope would force the model to
117    /// pass a JSON object everywhere a string was wanted, and the feature would
118    /// be unusable for the very case it was built for.
119    ///
120    /// `None` for an unknown handle or a path that does not exist, which the
121    /// caller turns into "leave the literal alone".
122    fn lookup(&self, reference: &str) -> Option<&Value> {
123        let mut parts = reference.split('.');
124        let mut cursor = self.get(parts.next()?)?;
125        for part in parts {
126            cursor = match cursor {
127                Value::Object(map) => map.get(part)?,
128                // A numeric segment indexes an array, so a row of a result set
129                // is addressable without a separate syntax.
130                Value::Array(items) => items.get(part.parse::<usize>().ok()?)?,
131                _ => return None,
132            };
133        }
134        Some(cursor)
135    }
136
137    fn resolve_into(&self, node: &mut Value, resolved: &mut Vec<String>) {
138        match node {
139            Value::String(s) => {
140                if let Some(reference) = s.strip_prefix('$') {
141                    if let Some(value) = self.lookup(reference) {
142                        resolved.push(reference.to_string());
143                        *node = value.clone();
144                    }
145                }
146            }
147            Value::Array(items) => {
148                for item in items {
149                    self.resolve_into(item, resolved);
150                }
151            }
152            Value::Object(map) => {
153                for (_, v) in map.iter_mut() {
154                    self.resolve_into(v, resolved);
155                }
156            }
157            _ => {}
158        }
159    }
160}
161
162/// Render a bounded, typed preview of `value`, naming the handle that holds it.
163///
164/// The shapes handled are the ones CAR's own tools return: JSON arrays and
165/// objects, and text (shell stdout, file contents, log output). Everything else
166/// — numbers, booleans, null — is small enough to render whole, so there is
167/// nothing to preview.
168///
169/// **Not special-cased**, and worth knowing before trusting a preview to carry
170/// enough shape: tabular text (CSV/TSV output is previewed as text, so the
171/// model sees a row count only if it counts lines itself), binary payloads
172/// (rendered as their JSON escaping), and deeply nested objects (only top-level
173/// keys are listed). Each is a candidate for the design pass #813 asks for.
174pub fn render_preview(handle: &str, value: &Value) -> String {
175    match value {
176        Value::Array(items) => render_array(handle, items),
177        Value::Object(map) => render_object(handle, map),
178        Value::String(s) => render_text(handle, s),
179        // Scalars are already their own best preview.
180        other => format!("{handle} = {other}"),
181    }
182}
183
184fn render_array(handle: &str, items: &[Value]) -> String {
185    let len = items.len();
186    if len <= SEQ_HEAD_ITEMS + SEQ_TAIL_ITEMS {
187        let all: Vec<String> = items.iter().map(summarize_element).collect();
188        return format!("{handle} = array(len={len}, [{}])", all.join(", "));
189    }
190    let head: Vec<String> = items
191        .iter()
192        .take(SEQ_HEAD_ITEMS)
193        .map(summarize_element)
194        .collect();
195    let tail: Vec<String> = items[len - SEQ_TAIL_ITEMS..]
196        .iter()
197        .map(summarize_element)
198        .collect();
199    format!(
200        "{handle} = array(len={len}, [:{}]=[{}], [-{}:]=[{}])",
201        SEQ_HEAD_ITEMS,
202        head.join(", "),
203        SEQ_TAIL_ITEMS,
204        tail.join(", ")
205    )
206}
207
208fn render_object(handle: &str, map: &serde_json::Map<String, Value>) -> String {
209    let bytes = Value::Object(map.clone()).to_string().len();
210    // Keys ALONE are not enough shape, and this is the case that proves it:
211    // CAR's `read_file` returns `{content, path, size_bytes, total_lines}`, so
212    // a key list tells the model there is a `content` and nothing whatsoever
213    // about the 55 KB inside it — strictly less useful than the text preview
214    // the same payload would have got on its own. Each value is therefore
215    // summarized by type and size, so a large field announces itself and the
216    // model knows which one to address.
217    if map.len() > 24 {
218        let keys: Vec<&str> = map.keys().take(8).map(String::as_str).collect();
219        return format!(
220            "{handle} = json(keys={} total, first=[{}, …], bytes={bytes})",
221            map.len(),
222            keys.join(", ")
223        );
224    }
225    let fields: Vec<String> = map
226        .iter()
227        .map(|(k, v)| format!("{k}: {}", describe_field(v)))
228        .collect();
229    format!("{handle} = json({}, bytes={bytes})", fields.join(", "))
230}
231
232/// One field of an object: type and size for anything big, the literal value
233/// for anything small enough to be its own best description.
234fn describe_field(value: &Value) -> String {
235    match value {
236        Value::String(s) if s.len() > ELEMENT_BUDGET => {
237            format!("text(len={}, lines={})", human(s.len()), s.lines().count())
238        }
239        Value::String(s) => format!("{s:?}"),
240        Value::Array(items) => format!("array(len={})", items.len()),
241        Value::Object(inner) => format!("json(keys={})", inner.len()),
242        other => other.to_string(),
243    }
244}
245
246fn render_text(handle: &str, s: &str) -> String {
247    let bytes = s.len();
248    let lines = s.lines().count();
249    if bytes <= TEXT_HEAD_BYTES + TEXT_TAIL_BYTES {
250        return format!("{handle} = text(len={}, lines={lines}) {s:?}", human(bytes));
251    }
252    let head = clip(s, TEXT_HEAD_BYTES, true);
253    let tail = clip(s, TEXT_TAIL_BYTES, false);
254    format!(
255        "{handle} = text(len={}, lines={lines}, head={head:?}, tail={tail:?})",
256        human(bytes)
257    )
258}
259
260/// One element of a sequence, bounded so a long row cannot blow the preview.
261fn summarize_element(value: &Value) -> String {
262    match value {
263        Value::Object(map) => {
264            let inner: Vec<String> = map
265                .iter()
266                .take(2)
267                .map(|(k, v)| format!("{k}:{}", clip_str(&scalar_or_type(v), 24)))
268                .collect();
269            let more = if map.len() > 2 { ", …" } else { "" };
270            format!("{{{}{more}}}", inner.join(","))
271        }
272        Value::Array(items) => format!("array(len={})", items.len()),
273        Value::String(s) => format!("{:?}", clip_str(s, ELEMENT_BUDGET)),
274        other => clip_str(&other.to_string(), ELEMENT_BUDGET),
275    }
276}
277
278fn scalar_or_type(value: &Value) -> String {
279    match value {
280        Value::Object(_) => "{…}".to_string(),
281        Value::Array(items) => format!("array({})", items.len()),
282        Value::String(s) => s.clone(),
283        other => other.to_string(),
284    }
285}
286
287/// Clip to a byte budget on a char boundary, marking the elision.
288///
289/// `pub(crate)` because `events_query` bounds its event payloads the same way
290/// (#815) — one clipper, so the two model-facing surfaces cannot disagree about
291/// what an elision looks like.
292pub(crate) fn clip_str(s: &str, budget: usize) -> String {
293    if s.len() <= budget {
294        return s.to_string();
295    }
296    let mut end = budget;
297    while !s.is_char_boundary(end) {
298        end -= 1;
299    }
300    format!("{}…", &s[..end])
301}
302
303/// Take `budget` bytes from the head or tail, respecting char boundaries.
304fn clip(s: &str, budget: usize, from_head: bool) -> String {
305    if s.len() <= budget {
306        return s.to_string();
307    }
308    if from_head {
309        let mut end = budget;
310        while !s.is_char_boundary(end) {
311            end -= 1;
312        }
313        s[..end].to_string()
314    } else {
315        let mut start = s.len() - budget;
316        while !s.is_char_boundary(start) {
317            start += 1;
318        }
319        s[start..].to_string()
320    }
321}
322
323/// Byte counts a human can compare at a glance — the difference between a 2 KB
324/// and a 2 MB result is the whole point of showing the true length.
325fn human(bytes: usize) -> String {
326    const KB: usize = 1024;
327    const MB: usize = KB * 1024;
328    if bytes >= MB {
329        format!("{:.1}MB", bytes as f64 / MB as f64)
330    } else if bytes >= KB {
331        format!("{:.1}KB", bytes as f64 / KB as f64)
332    } else {
333        format!("{bytes}B")
334    }
335}
336
337/// The line appended to a preview telling the model the value is still reachable.
338///
339/// Without this the preview is just a prettier truncation: the model has to
340/// know the reference form exists, and it will not infer `$r3` from seeing
341/// `r3 = …`.
342pub fn reference_hint(handle: &str) -> String {
343    format!(
344        "\n[full value retained this run — pass \"${handle}\" as a tool argument \
345         to operate on all of it, or \"${handle}.<field>\" for one field]"
346    )
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use serde_json::json;
353
354    #[test]
355    fn handles_are_short_and_sequential() {
356        let mut store = SessionValues::new();
357        assert_eq!(store.put(json!(1)), "r1");
358        assert_eq!(store.put(json!(2)), "r2");
359        assert_eq!(store.get("r1"), Some(&json!(1)));
360        assert_eq!(store.get("nope"), None);
361        assert_eq!(store.len(), 2);
362    }
363
364    /// The property #813 is actually about: rows 3-100 must remain reachable.
365    #[test]
366    fn the_elided_data_is_recoverable_through_the_handle() {
367        let mut store = SessionValues::new();
368        let rows: Vec<Value> = (0..100).map(|i| json!({"id": i})).collect();
369        let handle = store.put(json!(rows));
370
371        let preview = render_preview(&handle, store.get(&handle).unwrap());
372        assert!(
373            preview.contains("len=100"),
374            "true length must survive: {preview}"
375        );
376
377        // The whole value is still there — this is the difference from cap().
378        let recovered = store.get(&handle).unwrap().as_array().unwrap();
379        assert_eq!(recovered.len(), 100);
380        assert_eq!(recovered[57], json!({"id": 57}));
381    }
382
383    #[test]
384    fn array_preview_carries_type_length_and_both_ends() {
385        let items: Vec<Value> = (0..100).map(|i| json!(i)).collect();
386        let p = render_preview("r1", &json!(items));
387        assert!(p.starts_with("r1 = array(len=100"), "{p}");
388        assert!(p.contains("[:3]=[0, 1, 2]"), "head sample missing: {p}");
389        assert!(p.contains("[-2:]=[98, 99]"), "tail sample missing: {p}");
390    }
391
392    /// A short sequence needs no elision — showing it whole is strictly more
393    /// useful than showing two ends of five items.
394    #[test]
395    fn short_arrays_render_whole() {
396        let p = render_preview("r1", &json!([1, 2, 3]));
397        assert_eq!(p, "r1 = array(len=3, [1, 2, 3])");
398    }
399
400    #[test]
401    fn object_preview_describes_fields_not_just_key_names() {
402        let p = render_preview("r2", &json!({"name": "car", "version": "0.46.1"}));
403        assert!(
404            p.contains(r#"name: "car""#),
405            "small values shown literally: {p}"
406        );
407        assert!(p.contains("bytes="), "true size must be stated: {p}");
408    }
409
410    /// The case that exposed key-lists-only as insufficient: CAR's `read_file`
411    /// returns an envelope, and a preview naming `content` without describing
412    /// it tells the model less about the payload than a bare text preview of
413    /// the same bytes would have.
414    #[test]
415    fn a_large_field_inside_an_envelope_announces_itself() {
416        let body = format!("HEAD\n{}\nTAIL", "z".repeat(50_000));
417        let value = json!({
418            "content": body,
419            "path": "./big.txt",
420            "size_bytes": 50_010,
421            "total_lines": 3,
422        });
423        let p = render_preview("r1", &value);
424        assert!(
425            p.contains("content: text(len=") && p.contains("lines=3"),
426            "the big field must state its size and shape: {p}"
427        );
428        assert!(
429            p.contains(r#"path: "./big.txt""#),
430            "small fields stay literal: {p}"
431        );
432        assert!(
433            p.len() < 400,
434            "preview must stay bounded: {} bytes",
435            p.len()
436        );
437    }
438
439    /// Tools return envelopes, so a handle that could only name the whole
440    /// envelope would force a JSON object into every slot that wants a string.
441    #[test]
442    fn dotted_paths_address_a_field_of_a_retained_value() {
443        let mut store = SessionValues::new();
444        let h = store.put(json!({"content": "the payload", "path": "./f.txt"}));
445
446        let mut params = json!({"content": format!("${h}.content")});
447        let resolved = store.resolve_refs(&mut params);
448        assert_eq!(resolved, vec![format!("{h}.content")]);
449        assert_eq!(params["content"], json!("the payload"));
450
451        // Numeric segments index arrays, so one row of a result set is
452        // addressable without inventing a second syntax.
453        let rows = store.put(json!([{"id": 1}, {"id": 2}, {"id": 3}]));
454        let mut p2 = json!({"row": format!("${rows}.1.id")});
455        store.resolve_refs(&mut p2);
456        assert_eq!(p2["row"], json!(2));
457
458        // A path that does not exist stays literal rather than becoming null —
459        // a model typo must not silently change what the tool receives.
460        let mut p3 = json!({"x": format!("${h}.nope")});
461        assert!(store.resolve_refs(&mut p3).is_empty());
462        assert_eq!(p3["x"], json!(format!("${h}.nope")));
463    }
464
465    #[test]
466    fn text_preview_reports_size_lines_and_both_ends() {
467        let body = format!("FIRST LINE\n{}\nLAST LINE", "x".repeat(5_000));
468        let p = render_preview("r3", &json!(body));
469        assert!(p.contains("text(len="), "{p}");
470        assert!(p.contains("lines=3"), "line count missing: {p}");
471        assert!(p.contains("FIRST LINE"), "head missing: {p}");
472        assert!(p.contains("LAST LINE"), "tail missing: {p}");
473        // The preview must be far smaller than the value it describes.
474        assert!(p.len() < 1_200, "preview not bounded: {} bytes", p.len());
475    }
476
477    /// Multi-byte characters must not panic the clipper — the old `cap()` had
478    /// to walk back to a char boundary for exactly this reason.
479    #[test]
480    fn multibyte_text_does_not_panic() {
481        let body = "€".repeat(5_000);
482        let p = render_preview("r1", &json!(body));
483        assert!(p.contains("text(len="), "{p}");
484    }
485
486    #[test]
487    fn whole_string_references_resolve() {
488        let mut store = SessionValues::new();
489        let handle = store.put(json!([1, 2, 3]));
490        let mut params = json!({"rows": format!("${handle}"), "limit": 10});
491        let resolved = store.resolve_refs(&mut params);
492        assert_eq!(resolved, vec![handle]);
493        assert_eq!(params["rows"], json!([1, 2, 3]));
494        assert_eq!(params["limit"], json!(10));
495    }
496
497    /// A reference nested inside an array or object must resolve too — tool
498    /// arguments are structured, not flat.
499    #[test]
500    fn nested_references_resolve() {
501        let mut store = SessionValues::new();
502        let h = store.put(json!("hello"));
503        let mut params = json!({"outer": {"inner": [format!("${h}")]}});
504        store.resolve_refs(&mut params);
505        assert_eq!(params["outer"]["inner"][0], json!("hello"));
506    }
507
508    /// Only WHOLE string values are references. Tool arguments legitimately
509    /// carry shell snippets and regexes; interpolating into the middle of one
510    /// would corrupt commands that have nothing to do with this feature.
511    #[test]
512    fn partial_matches_are_left_alone() {
513        let mut store = SessionValues::new();
514        let h = store.put(json!("VALUE"));
515        let mut params = json!({"cmd": format!("echo ${h} > out.txt"), "re": "^\\$r1$"});
516        let resolved = store.resolve_refs(&mut params);
517        assert!(
518            resolved.is_empty(),
519            "a substring must not trigger substitution: {params}"
520        );
521        assert_eq!(params["cmd"], json!(format!("echo ${h} > out.txt")));
522    }
523
524    /// An unknown handle stays literal. Erasing it or substituting null would
525    /// turn a model typo into a silently different tool call; leaving it lets
526    /// the tool's own validation report it.
527    #[test]
528    fn unknown_handles_are_left_untouched() {
529        let store = SessionValues::new();
530        let mut params = json!({"rows": "$r99"});
531        let resolved = store.resolve_refs(&mut params);
532        assert!(resolved.is_empty());
533        assert_eq!(params["rows"], json!("$r99"));
534    }
535
536    #[test]
537    fn reference_hint_names_the_typed_form() {
538        let hint = reference_hint("r7");
539        assert!(
540            hint.contains("\"$r7\""),
541            "the model must see the exact form: {hint}"
542        );
543    }
544}