Skip to main content

lean_ctx_proxy/
tool_kind.rs

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