Skip to main content

lean_ctx/proxy/
tool_kind.rs

1//! Classifies what produced a `tool_result` so the proxy never lossy-compresses
2//! a file/source-code read the model still needs (e.g. mid-refactor).
3//!
4//! The request body only carries the tool *result* plus an id linking it to the
5//! originating tool *call*. We resolve that id → tool name from the assistant's
6//! `tool_use` / `tool_calls` / `function_call` items, then map the name to a
7//! [`ToolResultKind`]. A content heuristic ([`looks_like_source_code`]) is the
8//! fallback for unknown/custom tools so a file read through a non-standard tool
9//! is still protected.
10
11use std::collections::HashMap;
12
13use serde_json::Value;
14
15/// What kind of tool produced a `tool_result`.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum ToolResultKind {
18    /// A file/source read — must reach the model intact (it is what gets edited).
19    FileRead,
20    /// Shell/command output — safe to run through the pattern compressors.
21    Shell,
22    /// Search/listing output — safe to compress.
23    Search,
24    /// Unknown — fall back to the content heuristic before compressing.
25    Other,
26}
27
28/// Maps a tool name (from any agent) to a [`ToolResultKind`].
29///
30/// Matching is case-insensitive and substring-based so vendor prefixes
31/// (`mcp__fs__read_file`, `functions.read`) and casing variants are covered.
32pub fn classify_tool_name(name: &str) -> ToolResultKind {
33    let n = name.to_ascii_lowercase();
34
35    // Order matters: a "read_file" must not be caught by a generic "file".
36    const FILE_READ: &[&str] = &[
37        "read_file",
38        "readfile",
39        "file_read",
40        "fsread",
41        "fs_read",
42        "view_file",
43        "viewfile",
44        "open_file",
45        "notebookread",
46        "notebook_read",
47        "cat_file",
48        "get_file",
49        "fetch_file",
50        "ctx_read",
51        "ctx_multi_read",
52        "multi_read",
53        "multiread",
54        "read_many", // Gemini CLI `read_many_files`
55        "read_files",
56        "str_replace_editor", // view sub-mode returns file content
57        "ctx_patch",          // #818: diff previews are code — never abbreviate
58        "ctx_refactor",       // symbol edits return code diffs
59        "ctx_callgraph",      // callers/callees contain source snippets
60    ];
61    if FILE_READ.iter().any(|k| n.contains(k)) {
62        return ToolResultKind::FileRead;
63    }
64    // Bare "read"/"view"/"cat" as a whole token (Claude Code `Read`, Pi `read`).
65    if matches!(n.as_str(), "read" | "view" | "cat" | "open") {
66        return ToolResultKind::FileRead;
67    }
68
69    const SEARCH: &[&str] = &[
70        "grep",
71        "ripgrep",
72        "search",
73        "find",
74        "glob",
75        "list_dir",
76        "listdir",
77        "list_files",
78        "listfiles",
79        "ls",
80        "codebase_search",
81        "ctx_search",
82        "ctx_tree",
83    ];
84    if SEARCH.iter().any(|k| n.contains(k)) {
85        return ToolResultKind::Search;
86    }
87
88    const SHELL: &[&str] = &[
89        "bash",
90        "shell",
91        "terminal",
92        "run_command",
93        "run_terminal",
94        "runterminal",
95        "execute_command",
96        "exec_command",
97        "command_exec",
98        "ctx_shell",
99    ];
100    if SHELL.iter().any(|k| n.contains(k)) {
101        return ToolResultKind::Shell;
102    }
103    if matches!(n.as_str(), "run" | "exec" | "execute" | "command" | "sh") {
104        return ToolResultKind::Shell;
105    }
106
107    // Vendor-prefix fallback. Foreign harnesses namespace their tools
108    // (`forge_read`, `pi.shell`, `fs:grep`), which the substring lists above
109    // miss. Matching the name's path-like *segments* as whole words catches
110    // those without the false positives a bare substring would cause
111    // (`thread`, `research`, `already`). FileRead is checked first so a read is
112    // never misclassified as compressible.
113    for seg in n.split(|c: char| !c.is_ascii_alphanumeric()) {
114        match seg {
115            "read" | "view" | "cat" | "open" => return ToolResultKind::FileRead,
116            "grep" | "search" | "find" | "glob" | "ls" | "rg" => return ToolResultKind::Search,
117            "shell" | "bash" | "exec" | "run" | "terminal" | "cmd" => return ToolResultKind::Shell,
118            _ => {}
119        }
120    }
121
122    ToolResultKind::Other
123}
124
125/// Builds a `tool_use_id → tool_name` map from Anthropic `messages`.
126///
127/// Scans every assistant content block of `type:"tool_use"`.
128pub fn anthropic_tool_names(messages: &[Value]) -> HashMap<String, String> {
129    let mut map = HashMap::new();
130    for msg in messages {
131        let Some(blocks) = msg.get("content").and_then(|c| c.as_array()) else {
132            continue;
133        };
134        for block in blocks {
135            if block.get("type").and_then(|t| t.as_str()) != Some("tool_use") {
136                continue;
137            }
138            if let (Some(id), Some(name)) = (
139                block.get("id").and_then(|v| v.as_str()),
140                block.get("name").and_then(|v| v.as_str()),
141            ) {
142                map.insert(id.to_string(), name.to_string());
143            }
144        }
145    }
146    map
147}
148
149/// Builds a `tool_call_id → function_name` map from OpenAI Chat Completions
150/// `messages` (assistant `tool_calls[]`).
151pub fn openai_tool_names(messages: &[Value]) -> HashMap<String, String> {
152    let mut map = HashMap::new();
153    for msg in messages {
154        let Some(calls) = msg.get("tool_calls").and_then(|c| c.as_array()) else {
155            continue;
156        };
157        for call in calls {
158            let id = call.get("id").and_then(|v| v.as_str());
159            let name = call
160                .get("function")
161                .and_then(|f| f.get("name"))
162                .and_then(|v| v.as_str());
163            if let (Some(id), Some(name)) = (id, name) {
164                map.insert(id.to_string(), name.to_string());
165            }
166        }
167    }
168    map
169}
170
171/// Builds a `call_id → name` map from OpenAI Responses `input` items
172/// (`type:"function_call"`).
173pub fn responses_tool_names(input: &[Value]) -> HashMap<String, String> {
174    let mut map = HashMap::new();
175    for item in input {
176        if item.get("type").and_then(|t| t.as_str()) != Some("function_call") {
177            continue;
178        }
179        if let (Some(id), Some(name)) = (
180            item.get("call_id").and_then(|v| v.as_str()),
181            item.get("name").and_then(|v| v.as_str()),
182        ) {
183            map.insert(id.to_string(), name.to_string());
184        }
185    }
186    map
187}
188
189/// Whether a `tool_result` with the given resolved kind and content must be
190/// preserved intact (never lossy-compressed) by the proxy.
191///
192/// File reads are always protected; unknown tools are protected only when the
193/// content heuristically looks like source code. Shell/search output is never
194/// protected here — it flows through the normal pattern compressors.
195pub fn should_protect(kind: ToolResultKind, content: &str) -> bool {
196    match kind {
197        ToolResultKind::FileRead => true,
198        ToolResultKind::Other => looks_like_source_code(content),
199        ToolResultKind::Shell | ToolResultKind::Search => false,
200    }
201}
202
203/// Heuristic fallback: does this text look like source code (vs command output)?
204///
205/// Deliberately conservative — it only returns `true` when code signals clearly
206/// dominate and shell/log signals are essentially absent, so genuine logs and
207/// build output are still compressed. Used only when the tool name is unknown.
208///
209/// GH #628: the previous version under-counted real source — a decorative
210/// separator comment (`// ————`, `// ====`) and the call-shaped scaffolding of a
211/// test file (`describe(…) {`, `});`) scored as non-code, so a genuine source
212/// read routed through an unrecognized tool was lossy-compressed and silently
213/// lost those separator lines, breaking the model's subsequent exact-match edit.
214/// The fix: comment lines are *neutral* (never dilute the ratio) and top-level
215/// call/closer shapes count as code even at column 0. The shell-signal veto is
216/// untouched, so genuine logs and build output are still compressed.
217pub fn looks_like_source_code(content: &str) -> bool {
218    let mut code_signals = 0usize;
219    let mut shell_signals = 0usize;
220    let mut considered = 0usize;
221
222    for raw in content.lines().take(200) {
223        let line = raw.trim_end();
224        let trimmed = line.trim_start();
225        if trimmed.is_empty() {
226            continue;
227        }
228
229        // Comment lines are part of source but carry no compress-vs-keep signal
230        // on their own, and a decorative separator (`// ————`, `// ====`) or a
231        // doc-block continuation (` * @param`) must never *dilute* the code ratio
232        // — that false-negative is what let the proxy strip those lines (#628).
233        // Treat C-style comments as neutral: skip without counting. `#` is
234        // deliberately excluded (ambiguous with shell prompts / log levels /
235        // Python) so genuine shell output is still detected and compressed.
236        if trimmed.starts_with("//")
237            || trimmed.starts_with("/*")
238            || trimmed.starts_with("*/")
239            || trimmed.starts_with("* ")
240        {
241            continue;
242        }
243
244        considered += 1;
245
246        // Command/log markers — strong evidence this is NOT a file read.
247        if trimmed.starts_with("$ ")
248            || trimmed.starts_with("% ")
249            || trimmed.starts_with(">>> ")
250            || trimmed.starts_with("warning:")
251            || trimmed.starts_with("error:")
252            || trimmed.starts_with("error[")
253            || trimmed.starts_with("INFO ")
254            || trimmed.starts_with("WARN ")
255            || trimmed.starts_with("DEBUG ")
256            || trimmed.starts_with("ERROR ")
257            || trimmed.starts_with("Compiling ")
258            || trimmed.starts_with("Downloaded ")
259            || trimmed.starts_with("test result:")
260        {
261            shell_signals += 1;
262            continue;
263        }
264
265        // Code markers.
266        let is_indented = line.len() != trimmed.len();
267        let has_code_punct = trimmed.ends_with('{')
268            || trimmed.ends_with('}')
269            || trimmed.ends_with(';')
270            || trimmed.ends_with("=>")
271            || trimmed.ends_with("->")
272            || trimmed.ends_with(':');
273        // Top-level declarations, call statements and block closers carry code
274        // punctuation even at column 0 (`describe("x", () => {`, `});`), so the
275        // bare `is_indented && has_code_punct` test missed them — the exact
276        // test-DSL shape (`describe`/`it`/`expect`) behind the #628 false-negative.
277        // A call/closer *shape* with code punctuation is a strong code signal
278        // regardless of indentation.
279        let is_call_or_closer = (trimmed.contains('(') && trimmed.contains(')'))
280            || trimmed.starts_with('}')
281            || trimmed.starts_with(')');
282        let has_keyword = [
283            "fn ",
284            "def ",
285            "class ",
286            "import ",
287            "from ",
288            "function ",
289            "func ",
290            "pub ",
291            "const ",
292            "let ",
293            "var ",
294            "package ",
295            "public ",
296            "private ",
297            "struct ",
298            "enum ",
299            "impl ",
300            "#include",
301            "return ",
302            "async ",
303            "export ",
304        ]
305        .iter()
306        .any(|k| trimmed.starts_with(k) || trimmed.contains(k));
307
308        // Code punctuation counts when the line is indented (a statement in a
309        // block) OR is a top-level call/closer shape (`describe(…) {`, `});`).
310        let has_code_shape = has_code_punct && (is_indented || is_call_or_closer);
311        if has_code_shape || has_keyword {
312            code_signals += 1;
313        }
314    }
315
316    if considered < 5 || shell_signals > 0 {
317        return false;
318    }
319    // Require a clear majority of code-shaped lines.
320    code_signals * 2 >= considered
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn classifies_file_read_tools() {
329        for name in [
330            "Read",
331            "read_file",
332            "view_file",
333            "ctx_read",
334            "mcp__fs__readFile",
335            // Multi-file reads return file content and must be protected too.
336            "ctx_multi_read",
337            "read_many_files",
338        ] {
339            assert_eq!(
340                classify_tool_name(name),
341                ToolResultKind::FileRead,
342                "{name} should be FileRead"
343            );
344        }
345    }
346
347    #[test]
348    fn classifies_shell_and_search() {
349        assert_eq!(classify_tool_name("Bash"), ToolResultKind::Shell);
350        assert_eq!(
351            classify_tool_name("run_terminal_cmd"),
352            ToolResultKind::Shell
353        );
354        assert_eq!(classify_tool_name("Grep"), ToolResultKind::Search);
355        assert_eq!(
356            classify_tool_name("codebase_search"),
357            ToolResultKind::Search
358        );
359    }
360
361    #[test]
362    fn unknown_tool_is_other() {
363        assert_eq!(classify_tool_name("submit_pr"), ToolResultKind::Other);
364    }
365
366    #[test]
367    fn classifies_vendor_prefixed_foreign_tools() {
368        // Foreign harnesses (forge / pi) namespace their tools; the segment
369        // fallback must still route them so source reads stay protected and
370        // shell/search output stays compressible.
371        assert_eq!(classify_tool_name("forge_read"), ToolResultKind::FileRead);
372        assert_eq!(classify_tool_name("pi.read"), ToolResultKind::FileRead);
373        assert_eq!(classify_tool_name("forge_shell"), ToolResultKind::Shell);
374        assert_eq!(classify_tool_name("forge_exec"), ToolResultKind::Shell);
375        assert_eq!(classify_tool_name("fs:grep"), ToolResultKind::Search);
376    }
377
378    #[test]
379    fn segment_fallback_has_no_substring_false_positives() {
380        // Whole-word segments only: "thread" contains "read", "spread" contains
381        // "read" — neither may be misclassified as a file read.
382        assert_eq!(classify_tool_name("thread_create"), ToolResultKind::Other);
383        assert_eq!(classify_tool_name("spread_values"), ToolResultKind::Other);
384        assert_eq!(
385            classify_tool_name("readme_generator"),
386            ToolResultKind::Other
387        );
388        assert_eq!(classify_tool_name("submit_pull"), ToolResultKind::Other);
389    }
390
391    #[test]
392    fn anthropic_names_resolve_from_tool_use() {
393        let messages = vec![
394            serde_json::json!({
395                "role": "assistant",
396                "content": [
397                    {"type": "text", "text": "reading"},
398                    {"type": "tool_use", "id": "toolu_1", "name": "Read", "input": {}}
399                ]
400            }),
401            serde_json::json!({
402                "role": "user",
403                "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "x"}]
404            }),
405        ];
406        let names = anthropic_tool_names(&messages);
407        assert_eq!(names.get("toolu_1").map(String::as_str), Some("Read"));
408    }
409
410    #[test]
411    fn openai_names_resolve_from_tool_calls() {
412        let messages = vec![serde_json::json!({
413            "role": "assistant",
414            "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "read_file"}}]
415        })];
416        let names = openai_tool_names(&messages);
417        assert_eq!(names.get("call_1").map(String::as_str), Some("read_file"));
418    }
419
420    #[test]
421    fn responses_names_resolve_from_function_call() {
422        let input = vec![serde_json::json!({
423            "type": "function_call", "call_id": "call_1", "name": "Read", "arguments": "{}"
424        })];
425        let names = responses_tool_names(&input);
426        assert_eq!(names.get("call_1").map(String::as_str), Some("Read"));
427    }
428
429    #[test]
430    fn source_code_detected() {
431        let code = "pub fn build(cfg: &Config) -> Result<App> {\n    let mut app = App::new();\n    app.configure(cfg);\n    for route in cfg.routes() {\n        app.register(route);\n    }\n    Ok(app)\n}";
432        assert!(looks_like_source_code(code));
433    }
434
435    #[test]
436    fn command_output_not_code() {
437        let log = "$ cargo build\n   Compiling foo v0.1.0\n   Compiling bar v0.2.0\nwarning: unused variable\n    Finished dev target\nerror: could not compile";
438        assert!(!looks_like_source_code(log));
439    }
440
441    #[test]
442    fn plain_prose_not_code() {
443        let prose = "This is a normal paragraph of text.\nIt has several sentences.\nNone of them are code.\nThey are just words on lines.\nMore words follow here.";
444        assert!(!looks_like_source_code(prose));
445    }
446
447    /// Regression for GH #628: a real test file whose only "non-code-shaped"
448    /// lines are decorative separator comments must be recognized as source, so
449    /// the proxy protects it (when routed through an unrecognized tool) instead
450    /// of lossy-compressing it and silently dropping the `// ————` separators —
451    /// the exact divergence that made the model's `ctx_edit` fail on a whitespace
452    /// mismatch.
453    #[test]
454    fn test_file_with_separator_comments_is_source() {
455        let code = "import { describe, it, expect } from \"vitest\";\n\
456            \n\
457            // ————————————————————————————————————————————————————————\n\
458            // Section: arithmetic\n\
459            // ————————————————————————————————————————————————————————\n\
460            describe(\"add\", () => {\n\
461            \x20 it(\"adds\", () => {\n\
462            \x20   expect(1 + 1).toBe(2);\n\
463            \x20 });\n\
464            });\n\
465            \n\
466            // ----------------------------------------------------------\n\
467            // Section: strings\n\
468            // ----------------------------------------------------------\n\
469            describe(\"concat\", () => {\n\
470            \x20 it(\"joins\", () => {\n\
471            \x20   expect(\"a\" + \"b\").toBe(\"ab\");\n\
472            \x20 });\n\
473            });\n";
474        assert!(
475            looks_like_source_code(code),
476            "a .test.ts with decorative separator comments must read as source"
477        );
478        assert!(
479            should_protect(ToolResultKind::Other, code),
480            "an unrecognized tool returning this source must still be protected"
481        );
482    }
483
484    /// A comment-heavy source file (license header, doc block) is still source:
485    /// the neutral-comment rule must not let comments dilute the code ratio.
486    #[test]
487    fn comment_heavy_source_still_detected() {
488        let code = "/*\n\
489            \x20* Copyright (c) 2026. All rights reserved.\n\
490            \x20* This module wires the request pipeline.\n\
491            \x20*/\n\
492            export function build(cfg) {\n\
493            \x20 const app = create();\n\
494            \x20 app.use(cfg);\n\
495            \x20 return app;\n\
496            }\n";
497        assert!(looks_like_source_code(code));
498    }
499
500    /// The loosened call/closer signal must NOT start treating parenthesized log
501    /// output as code — the shell-signal veto and the punctuation requirement keep
502    /// genuine command output compressible.
503    #[test]
504    fn parenthesized_log_output_still_not_code() {
505        let log = "INFO  starting worker (pid=4211)\n\
506            processing batch (size=128) ok\n\
507            processing batch (size=64) ok\n\
508            WARN  slow response (842ms) from upstream\n\
509            done in 3.2s (0 errors)\n";
510        assert!(!looks_like_source_code(log));
511    }
512
513    /// #818: ctx_patch and ctx_refactor must be classified as FileRead
514    /// so their diff previews are never abbreviation-compressed.
515    #[test]
516    fn ctx_patch_and_refactor_classified_as_file_read() {
517        assert_eq!(classify_tool_name("ctx_patch"), ToolResultKind::FileRead);
518        assert_eq!(classify_tool_name("ctx_refactor"), ToolResultKind::FileRead);
519        assert_eq!(
520            classify_tool_name("ctx_callgraph"),
521            ToolResultKind::FileRead
522        );
523    }
524
525    /// #818: diff preview with - /+ prefixes must be protected.
526    #[test]
527    fn diff_preview_is_protected_when_kind_is_file_read() {
528        let diff = "--- src/main.rs\n\
529            - fn check_all_segments(command: &str, allowlist: &[String]) -> Result<(), ShellError> {\n\
530            -     if allowlist.is_empty() {\n\
531            -         return Ok(());\n\
532            + fn check_all_segments(cmd: &str, list: &[String]) -> Result<(), ShellError> {\n\
533            +     if list.is_empty() {\n\
534            +         return Ok(());\n";
535        assert!(
536            should_protect(ToolResultKind::FileRead, diff),
537            "diff preview must be protected when kind is FileRead"
538        );
539    }
540}