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