Skip to main content

vtcode_core/core/agent/
result_reducers.rs

1//! Token-efficient tool result reducers.
2//!
3//! When tool results are too large for the context window, reducers truncate
4//! them to keep only high-signal information. This follows the context
5//! engineering principle: "return only summaries or a small number of results
6//! to the model."
7
8use std::borrow::Cow;
9
10use serde_json::Value;
11
12use crate::config::constants::tools;
13use crate::tools::tool_intent;
14
15/// Reduce a tool result to be more token-efficient.
16///
17/// Dispatches to the appropriate reducer based on the tool name.
18pub fn reduce_tool_result(tool_name: &str, result: Value) -> Value {
19    let canonical_tool_name = tool_intent::canonical_command_session_tool_name(tool_name).unwrap_or(tool_name);
20    match canonical_tool_name {
21        tools::READ_FILE => reduce_read_file_result(result),
22        tools::UNIFIED_EXEC => reduce_command_result(result),
23        _ => result,
24    }
25}
26
27/// Strip TUI-only display fields from a tool result before it enters model
28/// context.
29///
30/// The `view` field produced by `task_tracker` (and its planning-workflow
31/// variant) is rendered only by the TUI — branch symbols, status icons, and
32/// per-item display lines that duplicate the structured `checklist` already
33/// present in the same payload. Sending it to the model wastes tokens on every
34/// tracker call, and the waste grows as items accumulate `outcome`/`verify`
35/// metadata (observed growing from ~5 KB to ~12 KB per call in session logs,
36/// with `view` accounting for ~3 KB of each).
37///
38/// The TUI reads `view` from the original tool-output `Value` via the
39/// pipeline-output / event path, not from the model-facing string this is
40/// applied to, so removing it here is display-safe.
41///
42/// Returns a borrowed value when no stripping is needed (non-tracker tools or
43/// results without a `view` field) so the common path pays zero allocation.
44pub fn strip_tui_display_fields<'a>(tool_name: &str, value: &'a Value) -> Cow<'a, Value> {
45    let canonical = tool_intent::canonical_command_session_tool_name(tool_name).unwrap_or(tool_name);
46    if canonical != tools::TASK_TRACKER && canonical != tools::MATRIX {
47        return Cow::Borrowed(value);
48    }
49    let Some(obj) = value.as_object() else {
50        return Cow::Borrowed(value);
51    };
52    if !obj.contains_key("view") {
53        return Cow::Borrowed(value);
54    }
55    let mut stripped = obj.clone();
56    stripped.remove("view");
57    Cow::Owned(Value::Object(stripped))
58}
59
60/// Project an indexed tracker update without repeating unchanged task details.
61/// Full results remain available to persistence, events, and explicit list calls.
62pub fn project_model_tool_result<'a>(tool_name: &str, args: &Value, value: &'a Value) -> Cow<'a, Value> {
63    let mut projected = strip_tui_display_fields(tool_name, value);
64    if tool_name != tools::TASK_TRACKER
65        || args.get("action").and_then(Value::as_str) != Some("update")
66        || args.get("items").is_some()
67    {
68        return projected;
69    }
70    let index_path = args
71        .get("index_path")
72        .and_then(Value::as_str)
73        .map(str::to_owned)
74        .or_else(|| args.get("index").and_then(Value::as_u64).map(|index| index.to_string()));
75    let Some(index_path) = index_path else {
76        return projected;
77    };
78    let Some(changed_item) = value
79        .get("checklist")
80        .and_then(|checklist| checklist.get("items"))
81        .and_then(Value::as_array)
82        .and_then(|items| {
83            items.iter().find(|item| {
84                item.get("index_path")
85                    .and_then(Value::as_str)
86                    .is_some_and(|path| path == index_path)
87                    || item
88                        .get("index")
89                        .and_then(Value::as_u64)
90                        .is_some_and(|index| index.to_string() == index_path)
91            })
92        })
93        .cloned()
94    else {
95        return projected;
96    };
97    if let Some(object) = projected.to_mut().as_object_mut() {
98        if let Some(checklist) = object.get_mut("checklist").and_then(Value::as_object_mut) {
99            checklist.remove("items");
100        }
101        object.insert("changed_item".to_owned(), changed_item);
102    }
103    projected
104}
105
106/// Hard byte cap on model-visible content. Line truncation alone does not
107/// bound output with very long lines (minified bundles, generated data), so
108/// the provider-visible preview contract needs a byte budget as well.
109const MAX_RESULT_BYTES: usize = 32 * 1024;
110
111fn truncate_utf8_bytes(text: &str, max_bytes: usize) -> (String, bool) {
112    if text.len() <= max_bytes {
113        return (text.to_string(), false);
114    }
115    (vtcode_commons::formatting::truncate_utf8_prefix(text, max_bytes).to_string(), true)
116}
117
118fn reduce_read_file_result(result: Value) -> Value {
119    const MAX_FILE_LINES: usize = 2000;
120
121    let Some(obj) = result.as_object() else {
122        return result;
123    };
124    let Some(content) = obj.get("content").and_then(Value::as_str) else {
125        return result;
126    };
127
128    let (line_truncated, content) = match truncate_lines(content, MAX_FILE_LINES) {
129        Some((truncated, _)) => (true, truncated),
130        None => (false, content.to_string()),
131    };
132    let (content, byte_truncated) = truncate_utf8_bytes(&content, MAX_RESULT_BYTES);
133    let is_truncated = line_truncated || byte_truncated;
134    if !is_truncated {
135        // Keep the full payload: read results carry continuation fields
136        // (`has_more`, `next_read_args`, `spool_path`, …) that a whitelist
137        // rebuild would drop and break paging.
138        return result;
139    }
140
141    let mut reduced = obj.clone();
142    reduced.insert("content".to_string(), Value::String(content));
143    reduced.insert("is_truncated".to_string(), Value::Bool(true));
144    // next_read_args.offset still points past the *original* chunk. Without
145    // this flag the model would assume the page is complete and skip the
146    // byte-capped tail.
147    if reduced.contains_key("next_read_args") || reduced.get("has_more").and_then(Value::as_bool) == Some(true) {
148        reduced.insert("chunk_tail_omitted".to_string(), Value::Bool(true));
149    }
150    if line_truncated {
151        reduced.insert("note".to_string(), Value::String("File content truncated for context economy.".to_string()));
152    }
153
154    Value::Object(reduced)
155}
156
157fn reduce_command_result(result: Value) -> Value {
158    const MAX_FILE_LINES: usize = 2000;
159
160    let Some(obj) = result.as_object() else {
161        return result;
162    };
163    let stream_key = if obj.get("stdout").and_then(Value::as_str).is_some() {
164        "stdout"
165    } else {
166        "output"
167    };
168    let Some(stream) = obj.get(stream_key).and_then(Value::as_str) else {
169        return result;
170    };
171
172    let (line_truncated, lines_count, stream) = match truncate_lines(stream, MAX_FILE_LINES) {
173        Some((truncated, lines_count)) => (true, lines_count, truncated),
174        None => (false, 0, stream.to_string()),
175    };
176    let (stream, byte_truncated) = truncate_utf8_bytes(&stream, MAX_RESULT_BYTES);
177    if !line_truncated && !byte_truncated {
178        return result;
179    }
180
181    let mut reduced = obj.clone();
182    reduced.insert(stream_key.to_string(), Value::String(stream));
183    reduced.insert("is_truncated".to_string(), Value::Bool(true));
184    if line_truncated {
185        reduced.insert("original_lines".to_string(), Value::Number(serde_json::Number::from(lines_count as u64)));
186    }
187    reduced.insert("note".to_string(), Value::String("Command output truncated for context economy.".to_string()));
188    Value::Object(reduced)
189}
190
191pub fn truncate_lines(text: &str, max_lines: usize) -> Option<(String, usize)> {
192    if max_lines == 0 {
193        return Some((String::new(), text.lines().count()));
194    }
195
196    let mut lines = text.lines();
197    let mut total = 0usize;
198    let mut out = String::new();
199    while let Some(line) = lines.next() {
200        total += 1;
201        if total <= max_lines {
202            if total > 1 {
203                out.push('\n');
204            }
205            out.push_str(line);
206            continue;
207        }
208        total += lines.count();
209        return Some((out, total));
210    }
211    None
212}
213
214#[cfg(test)]
215mod tests {
216    #[test]
217    fn tracker_projection_keeps_only_the_changed_hierarchical_item() {
218        let original = serde_json::json!({
219            "status": "updated",
220            "checklist": {"total": 2, "completed": 1, "items": [
221                {"index_path": "1", "description": "Parent", "status": "pending"},
222                {"index_path": "1.1", "description": "Child", "status": "completed", "verify": ["cargo check"]}
223            ]},
224            "view": {"lines": ["Parent", "Child"]}
225        });
226        let projected = project_model_tool_result(
227            "task_tracker",
228            &serde_json::json!({"action": "update", "index_path": "1.1"}),
229            &original,
230        );
231        assert_eq!(projected["changed_item"]["description"], "Child");
232        assert_eq!(projected["changed_item"]["verify"], serde_json::json!(["cargo check"]));
233        assert_eq!(projected["checklist"]["total"], 2);
234        assert!(projected["checklist"].get("items").is_none());
235        assert!(projected.get("view").is_none());
236        assert_eq!(original["checklist"]["items"].as_array().unwrap().len(), 2);
237        assert!(original.get("view").is_some());
238        assert!(projected.to_string().len() < original.to_string().len());
239        for args in [
240            serde_json::json!({"action": "list"}),
241            serde_json::json!({"action": "create"}),
242            serde_json::json!({"action": "update", "items": ["Replacement"]}),
243            serde_json::json!({"action": "update", "index_path": "9"}),
244        ] {
245            let projected = project_model_tool_result("task_tracker", &args, &original);
246            assert_eq!(projected["checklist"]["items"], original["checklist"]["items"]);
247            assert!(projected.get("changed_item").is_none());
248        }
249    }
250
251    #[test]
252    fn tracker_projection_supports_standard_indices() {
253        let result = serde_json::json!({"checklist": {"items": [
254            {"index": 1, "description": "First"}, {"index": 2, "description": "Second"}
255        ]}});
256        let projected =
257            project_model_tool_result("task_tracker", &serde_json::json!({"action": "update", "index": 2}), &result);
258        assert_eq!(projected["changed_item"]["description"], "Second");
259        assert_eq!(project_model_tool_result("exec_command", &serde_json::json!({}), &result).as_ref(), &result);
260    }
261    use super::*;
262    use serde_json::json;
263
264    #[test]
265    fn strip_view_removes_view_from_task_tracker_result() {
266        let result = json!({
267            "status": "updated",
268            "message": "Item 1 status changed: pending → completed",
269            "checklist": { "title": "Demo", "total": 2, "completed": 1, "items": [] },
270            "view": { "title": "Demo", "lines": [{ "display": "└ [x] step one" }] },
271        });
272        let stripped = strip_tui_display_fields("task_tracker", &result);
273        assert!(stripped.get("view").is_none(), "view should be removed");
274        assert!(stripped.get("checklist").is_some(), "checklist must remain for the model");
275        assert_eq!(stripped["status"], "updated");
276    }
277
278    #[test]
279    fn strip_view_borrows_non_tracker_tools_unchanged() {
280        let result = json!({ "status": "ok", "output": "hello" });
281        let stripped = strip_tui_display_fields("exec_command", &result);
282        assert!(matches!(stripped, Cow::Borrowed(_)), "non-tracker tools should borrow without allocation");
283        assert_eq!(stripped.as_ref(), &result);
284    }
285
286    #[test]
287    fn strip_view_borrows_tracker_result_without_view_field() {
288        let result = json!({ "status": "empty", "message": "No active checklist." });
289        let stripped = strip_tui_display_fields("task_tracker", &result);
290        assert!(
291            matches!(stripped, Cow::Borrowed(_)),
292            "tracker results without `view` should borrow without allocation"
293        );
294        assert_eq!(stripped.as_ref(), &result);
295    }
296
297    #[test]
298    fn strip_view_preserves_checklist_items_and_metadata() {
299        let result = json!({
300            "status": "ok",
301            "checklist": {
302                "title": "Plan",
303                "total": 2,
304                "completed": 0,
305                "items": [
306                    { "index": 1, "description": "Step A", "status": "pending", "files": ["a.rs"], "outcome": null, "verify": ["cargo check"] },
307                    { "index": 2, "description": "Step B", "status": "pending" },
308                ],
309            },
310            "view": { "title": "Plan", "lines": [{ "display": "├ [ ] Step A" }] },
311        });
312        let stripped = strip_tui_display_fields("task_tracker", &result);
313        let items = stripped["checklist"]["items"].as_array().unwrap();
314        assert_eq!(items.len(), 2);
315        assert_eq!(items[0]["verify"][0], "cargo check");
316        assert_eq!(items[1]["description"], "Step B");
317        assert!(stripped.get("view").is_none());
318    }
319
320    #[test]
321    fn strip_view_handles_non_object_result() {
322        let result = json!("not an object");
323        let stripped = strip_tui_display_fields("task_tracker", &result);
324        assert_eq!(stripped.as_ref(), &result);
325    }
326
327    #[test]
328    fn reduce_read_file_preserves_continuation_fields_when_not_truncated() {
329        let result = json!({
330            "success": true,
331            "content": "short body",
332            "path": "src/lib.rs",
333            "has_more": true,
334            "next_read_args": { "path": "src/lib.rs", "offset": 80 },
335            "spool_path": ".vtcode/context/tool_outputs/x",
336            "spooled_bytes": 999,
337        });
338        let reduced = reduce_tool_result("read_file", result.clone());
339        assert_eq!(reduced, result, "untruncated read results must stay intact for paging");
340    }
341
342    #[test]
343    fn reduce_read_file_truncates_large_content_but_keeps_next_read_args() {
344        let content = "a\n".repeat(2_500);
345        let result = json!({
346            "success": true,
347            "content": content,
348            "path": "src/lib.rs",
349            "has_more": true,
350            "next_read_args": { "path": "src/lib.rs", "offset": 2500 },
351        });
352        let reduced = reduce_tool_result("read_file", result);
353        assert_eq!(reduced["is_truncated"], json!(true));
354        assert!(reduced["content"].as_str().unwrap().lines().count() < 2_500, "content must be line-capped");
355        assert_eq!(reduced["has_more"], json!(true), "paging fields survive truncation");
356        assert!(reduced.get("next_read_args").is_some(), "next_read_args must survive truncation");
357        assert_eq!(
358            reduced["chunk_tail_omitted"],
359            json!(true),
360            "byte-capped page must warn that the chunk tail is not in content"
361        );
362    }
363}