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.
205pub fn looks_like_source_code(content: &str) -> bool {
206    let mut code_signals = 0usize;
207    let mut shell_signals = 0usize;
208    let mut considered = 0usize;
209
210    for raw in content.lines().take(200) {
211        let line = raw.trim_end();
212        let trimmed = line.trim_start();
213        if trimmed.is_empty() {
214            continue;
215        }
216        considered += 1;
217
218        // Command/log markers — strong evidence this is NOT a file read.
219        if trimmed.starts_with("$ ")
220            || trimmed.starts_with("% ")
221            || trimmed.starts_with(">>> ")
222            || trimmed.starts_with("warning:")
223            || trimmed.starts_with("error:")
224            || trimmed.starts_with("error[")
225            || trimmed.starts_with("INFO ")
226            || trimmed.starts_with("WARN ")
227            || trimmed.starts_with("DEBUG ")
228            || trimmed.starts_with("ERROR ")
229            || trimmed.starts_with("Compiling ")
230            || trimmed.starts_with("Downloaded ")
231            || trimmed.starts_with("test result:")
232        {
233            shell_signals += 1;
234            continue;
235        }
236
237        // Code markers.
238        let is_indented = line.len() != trimmed.len();
239        let has_code_punct = trimmed.ends_with('{')
240            || trimmed.ends_with('}')
241            || trimmed.ends_with(';')
242            || trimmed.ends_with("=>")
243            || trimmed.ends_with("->")
244            || trimmed.ends_with(':');
245        let has_keyword = [
246            "fn ",
247            "def ",
248            "class ",
249            "import ",
250            "from ",
251            "function ",
252            "func ",
253            "pub ",
254            "const ",
255            "let ",
256            "var ",
257            "package ",
258            "public ",
259            "private ",
260            "struct ",
261            "enum ",
262            "impl ",
263            "#include",
264            "return ",
265            "async ",
266            "export ",
267        ]
268        .iter()
269        .any(|k| trimmed.starts_with(k) || trimmed.contains(k));
270
271        if (is_indented && has_code_punct) || has_keyword {
272            code_signals += 1;
273        }
274    }
275
276    if considered < 5 || shell_signals > 0 {
277        return false;
278    }
279    // Require a clear majority of code-shaped lines.
280    code_signals * 2 >= considered
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn classifies_file_read_tools() {
289        for name in [
290            "Read",
291            "read_file",
292            "view_file",
293            "ctx_read",
294            "mcp__fs__readFile",
295            // Multi-file reads return file content and must be protected too.
296            "ctx_multi_read",
297            "read_many_files",
298        ] {
299            assert_eq!(
300                classify_tool_name(name),
301                ToolResultKind::FileRead,
302                "{name} should be FileRead"
303            );
304        }
305    }
306
307    #[test]
308    fn classifies_shell_and_search() {
309        assert_eq!(classify_tool_name("Bash"), ToolResultKind::Shell);
310        assert_eq!(
311            classify_tool_name("run_terminal_cmd"),
312            ToolResultKind::Shell
313        );
314        assert_eq!(classify_tool_name("Grep"), ToolResultKind::Search);
315        assert_eq!(
316            classify_tool_name("codebase_search"),
317            ToolResultKind::Search
318        );
319    }
320
321    #[test]
322    fn unknown_tool_is_other() {
323        assert_eq!(classify_tool_name("submit_pr"), ToolResultKind::Other);
324    }
325
326    #[test]
327    fn classifies_vendor_prefixed_foreign_tools() {
328        // Foreign harnesses (forge / pi) namespace their tools; the segment
329        // fallback must still route them so source reads stay protected and
330        // shell/search output stays compressible.
331        assert_eq!(classify_tool_name("forge_read"), ToolResultKind::FileRead);
332        assert_eq!(classify_tool_name("pi.read"), ToolResultKind::FileRead);
333        assert_eq!(classify_tool_name("forge_shell"), ToolResultKind::Shell);
334        assert_eq!(classify_tool_name("forge_exec"), ToolResultKind::Shell);
335        assert_eq!(classify_tool_name("fs:grep"), ToolResultKind::Search);
336    }
337
338    #[test]
339    fn segment_fallback_has_no_substring_false_positives() {
340        // Whole-word segments only: "thread" contains "read", "spread" contains
341        // "read" — neither may be misclassified as a file read.
342        assert_eq!(classify_tool_name("thread_create"), ToolResultKind::Other);
343        assert_eq!(classify_tool_name("spread_values"), ToolResultKind::Other);
344        assert_eq!(
345            classify_tool_name("readme_generator"),
346            ToolResultKind::Other
347        );
348        assert_eq!(classify_tool_name("submit_pull"), ToolResultKind::Other);
349    }
350
351    #[test]
352    fn anthropic_names_resolve_from_tool_use() {
353        let messages = vec![
354            serde_json::json!({
355                "role": "assistant",
356                "content": [
357                    {"type": "text", "text": "reading"},
358                    {"type": "tool_use", "id": "toolu_1", "name": "Read", "input": {}}
359                ]
360            }),
361            serde_json::json!({
362                "role": "user",
363                "content": [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "x"}]
364            }),
365        ];
366        let names = anthropic_tool_names(&messages);
367        assert_eq!(names.get("toolu_1").map(String::as_str), Some("Read"));
368    }
369
370    #[test]
371    fn openai_names_resolve_from_tool_calls() {
372        let messages = vec![serde_json::json!({
373            "role": "assistant",
374            "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "read_file"}}]
375        })];
376        let names = openai_tool_names(&messages);
377        assert_eq!(names.get("call_1").map(String::as_str), Some("read_file"));
378    }
379
380    #[test]
381    fn responses_names_resolve_from_function_call() {
382        let input = vec![serde_json::json!({
383            "type": "function_call", "call_id": "call_1", "name": "Read", "arguments": "{}"
384        })];
385        let names = responses_tool_names(&input);
386        assert_eq!(names.get("call_1").map(String::as_str), Some("Read"));
387    }
388
389    #[test]
390    fn source_code_detected() {
391        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}";
392        assert!(looks_like_source_code(code));
393    }
394
395    #[test]
396    fn command_output_not_code() {
397        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";
398        assert!(!looks_like_source_code(log));
399    }
400
401    #[test]
402    fn plain_prose_not_code() {
403        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.";
404        assert!(!looks_like_source_code(prose));
405    }
406}