Skip to main content

switchyard_libsy/algorithms/util/
tool_signals.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Tool-result context signals extracted from the conversation history.
5//!
6//! The extractor walks normalized messages, finds tool calls and results,
7//! reads explicit failure flags, matches text against an error table, and aggregates
8//! conversation-history metrics used by [`crate::StageRouter`] and the
9//! advisor gate's request-side guards.
10//!
11//! All logic is pure and deterministic — no I/O, no shared state.
12
13#![allow(dead_code)]
14
15use std::collections::HashSet;
16use std::path::Path;
17
18use async_trait::async_trait;
19use serde::Deserialize;
20use serde_json::Value;
21use switchyard_protocol::codex_namespaces::{split_qualified_name, tool_namespaces};
22use switchyard_protocol::{ContentBlock, Request, Role, WireFormat};
23
24use crate::{LibsyError, Result};
25
26use crate::core::processor::{Event, Processor};
27use crate::core::state::State;
28
29// ─── severity constants ───────────────────────────────────────────────────────
30
31const SOFT: f32 = 0.3;
32const HARD: f32 = 0.7;
33const CRITICAL: f32 = 1.0;
34
35// ─── pattern table ────────────────────────────────────────────────────────────
36
37/// (name, severity, lower-cased substrings — any hit fires the pattern)
38static ERROR_PATTERNS: &[(&str, f32, &[&str])] = &[
39    (
40        "oom",
41        CRITICAL,
42        &["out of memory", "memoryerror", "cannot allocate memory"],
43    ),
44    (
45        "connection_refused",
46        HARD,
47        &[
48            "connection refused",
49            "connectionrefusederror",
50            "econnrefused",
51        ],
52    ),
53    ("traceback", HARD, &["traceback (most recent call last)"]),
54    (
55        "import_error",
56        HARD,
57        &["modulenotfounderror:", "importerror:", "no module named "],
58    ),
59    (
60        "cmd_not_found",
61        HARD,
62        &["command not found", "not found\n", "/usr/bin/env: "],
63    ),
64    ("assertion", HARD, &["assertionerror"]),
65    ("value_error", HARD, &["valueerror:"]),
66    ("syntax_error", HARD, &["syntaxerror:"]),
67    (
68        "timeout",
69        HARD,
70        &[
71            "timed out",
72            "timeouterror",
73            "timeout expired",
74            "deadline exceeded",
75        ],
76    ),
77    (
78        "no_such_file",
79        HARD,
80        &[
81            "filenotfounderror:",
82            "no such file or directory",
83            // Claude Code's missing-file message. Reads are exempt (`is_missing_file`).
84            // Anchored as "file does not exist" (not a bare "does not exist", which
85            // fires on `ls` output and prose) — trace-mined across 1006 local
86            // trajectories at 22 true / 2 false positives.
87            "file does not exist",
88        ],
89    ),
90    // SOFT: plain non-zero exit without a recognisable exception traceback.
91    ("exit_nonzero", SOFT, &["returned non-zero"]),
92];
93
94static NONZERO_EXIT_PHRASES: &[&str] = &[
95    "exit code",
96    "exit status",
97    "exited with code",
98    "exited with status",
99];
100
101static EDIT_TOOL_NAMES: &[&str] = &[
102    "edit",
103    "multiedit",
104    "notebookedit",
105    "str_replace",
106    "str_replace_based_edit_tool",
107    "apply_patch", // codex's edit tool
108    "text_editor",
109    "patch", // hermes's str_replace-style edit tool
110];
111
112/// Editor tools whose `command` argument picks the action. `view` only reads.
113static EDITOR_TOOL_NAMES: &[&str] = &["str_replace_based_edit_tool", "text_editor"];
114
115static WRITE_TOOL_NAMES: &[&str] = &["write", "create_file", "new_file", "write_file"];
116
117// Bash subcommand patterns. Lowercased; callers must lowercase the command
118// before matching. Bucketed into write_count / edit_count alongside the
119// dedicated `Write` / `Edit` tools.
120static BASH_WRITE_PATTERNS: &[&str] = &[
121    "cat >",
122    "cat >>",
123    "echo >",
124    "echo >>",
125    "tee ",
126    "printf >",
127    "printf >>",
128    "> /",
129    ">> /",
130    "<< 'eof'",
131    "<<eof",
132    "<<'eof'",
133    "<< eof",
134];
135
136/// Python file-write expressions, which only indicate a write when an
137/// interpreter is running them rather than a search looking for them.
138static PYTHON_WRITE_PATTERNS: &[&str] = &["write_text(", "writelines(", ".write("];
139
140static JAVASCRIPT_WRITE_PATTERNS: &[&str] = &[
141    "writefilesync(",
142    "writefile(",
143    "appendfilesync(",
144    "appendfile(",
145];
146
147static BASH_EDIT_PATTERNS: &[&str] = &[
148    "sed -i",
149    "sed --in-place",
150    "awk -i inplace",
151    "awk 'inplace=1'",
152    "patch ",
153    "patch -p",
154    "perl -i",
155    "perl -p -i",
156    "perl -pi",
157];
158
159// Read-like Bash inspections. Match only when none of the write/edit patterns
160// fire (redirection / in-place edit trumps the read intent of the command).
161static BASH_READ_PATTERNS: &[&str] = &[
162    "cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
163    "diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
164];
165
166/// Read-only shell programs seen in Codex trajectories. Matching is limited to
167/// command-segment starts so prose and arguments do not masquerade as actions.
168static BASH_READ_COMMANDS: &[&str] = &[
169    "cat", "rg", "nl", "jq", "pwd", "tree", "sed", "grep", "ls", "find", "head", "tail", "wc",
170    "diff", "which", "ps", "df", "du", "stat", "file", "less", "more", "readlink", "realpath",
171    "basename", "dirname", "printenv",
172];
173
174static GIT_READ_SUBCOMMANDS: &[&str] = &[
175    "status",
176    "diff",
177    "log",
178    "show",
179    "show-ref",
180    "rev-parse",
181    "ls-files",
182    "ls-remote",
183    "ls-tree",
184    "grep",
185    "blame",
186    "merge-base",
187    "check-ignore",
188    "tag",
189];
190
191static READ_TOOL_NAMES: &[&str] = &[
192    "read",
193    "view",
194    "read_file",
195    "search_files",
196    "glob",
197    "grep",
198    "find",
199    "ls",
200];
201
202// Planning / scratchpad tool calls — investigative (non-producing) activity.
203// `update_plan` is codex's equivalent of `todowrite`.
204static PLAN_TOOL_NAMES: &[&str] = &[
205    "todowrite",
206    "todo_write",
207    "todo",
208    "update_plan",
209    "todo_list",
210];
211
212// Tool names that route through Bash-command pattern matching. `bash` is
213// claude-code's name; `shell_command` is codex's; `shell` / `local_shell_call`
214// are seen on some OpenAI-derived harnesses; `terminal` is hermes's (it carries
215// a `command` arg like the others, so its intent comes from the pattern match).
216static BASH_TOOL_NAMES: &[&str] = &[
217    "bash",
218    "shell_command",
219    "shell",
220    "local_shell_call",
221    "terminal",
222    "exec_command", // codex
223    "exec",         // openclaw
224    "powershell",   // pi on Windows
225];
226
227// Prefer false negatives: tests_passed clears a capable hold, so a false positive
228// could hand an unfinished task back too early.
229static TEST_PASS_PHRASES: &[&str] = &[
230    " passed",
231    "passed in",
232    "tests passed",
233    "all tests passed",
234    "test ok",
235    "test result: ok",
236    "passed.\n",
237    "tests pass",
238    "\nok ", // go test; newline-anchored to avoid "...lookup..." mid-text
239    "✓ ",
240];
241
242// Literal failure phrases that cannot appear inside a clean run. Substring
243// matched as-is. Patterns that pair with a count (e.g. "failed", "errors")
244// are handled separately by `has_nonzero_failure_count` so "0 failed" /
245// "0 errors" do not trigger a false negative.
246static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
247
248// Count-prefixed failure keywords. Trip only when a nonzero integer precedes
249// the keyword (modulo whitespace), so cargo's "0 failed" and go's
250// "0 errors" summaries on a clean run are not misread as failures.
251static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
252
253/// Default sliding-window size for `recent_*` counts and windowed severity.
254///
255/// A short horizon captures "what is the agent doing right now" while keeping
256/// signals sticky — an error or stall persists a few recovery turns instead of
257/// flickering off the moment one clean result lands. Override per request by
258/// passing a window to [`ToolSignals::from_request`].
259pub const DEFAULT_RECENT_WINDOW: usize = 3;
260
261/// Exact tool-name semantics added to the stage router's built-in vocabulary.
262///
263/// Matching is ASCII case-insensitive. An MCP or Codex namespaced tool also
264/// matches by its bare tool name. These lists are additive: built-in tool names
265/// cannot be reclassified.
266#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
267#[serde(default, deny_unknown_fields)]
268pub struct ToolSemantics {
269    /// Read-only lookup or inspection tools.
270    pub observe: Vec<String>,
271    /// Tools that change task or external state.
272    pub mutate: Vec<String>,
273    /// Explicit planning or task-decomposition tools.
274    pub plan: Vec<String>,
275    /// Tools that demonstrate new forward activity without favoring either tier.
276    pub new: Vec<String>,
277}
278
279impl ToolSemantics {
280    /// Rejects ambiguous mappings and attempts to reclassify built-in tools.
281    pub fn validate(&self) -> Result<()> {
282        let mut seen: Vec<(String, &'static str)> = Vec::new();
283        for (category, names) in [
284            ("observe", &self.observe),
285            ("mutate", &self.mutate),
286            ("plan", &self.plan),
287            ("new", &self.new),
288        ] {
289            for name in names {
290                if name.trim().is_empty() {
291                    return Err(tool_semantics_error(format!(
292                        "tool_semantics.{category} contains an empty tool name"
293                    )));
294                }
295                let normalized = name.to_ascii_lowercase();
296                if is_builtin_tool_name(&name.to_lowercase()) {
297                    return Err(tool_semantics_error(format!(
298                        "tool {name:?} already has built-in semantics and cannot be reclassified"
299                    )));
300                }
301                if let Some((_, previous)) = seen.iter().find(|(seen, _)| seen == &normalized) {
302                    return Err(tool_semantics_error(format!(
303                        "tool {name:?} appears in both tool_semantics.{previous} and tool_semantics.{category}"
304                    )));
305                }
306                seen.push((normalized, category));
307            }
308        }
309        Ok(())
310    }
311
312    fn classify(&self, name: &str) -> Option<ToolSemantic> {
313        if contains_name(&self.observe, name) {
314            Some(ToolSemantic::Observe)
315        } else if contains_name(&self.mutate, name) {
316            // The stage scorer treats writes and edits identically. Custom
317            // mutations use the write counter to preserve the public signal shape.
318            Some(ToolSemantic::Mutate(MutationKind::Write))
319        } else if contains_name(&self.plan, name) {
320            Some(ToolSemantic::Plan)
321        } else if contains_name(&self.new, name) {
322            Some(ToolSemantic::New)
323        } else {
324            None
325        }
326    }
327}
328
329fn contains_name(names: &[String], candidate: &str) -> bool {
330    names
331        .iter()
332        .any(|name| name.eq_ignore_ascii_case(candidate))
333}
334
335fn tool_semantics_error(message: String) -> LibsyError {
336    LibsyError::AlgorithmError { message }
337}
338
339// ─── output type ─────────────────────────────────────────────────────────────
340
341/// Tool-execution signals extracted from a normalized [`Request`].
342///
343/// A request-side processor stores these signals in [`State`](crate::State) for
344/// [`crate::StageRouter`] and its classifier to consume. The advisor gate's
345/// request-side guards read the conversation-shape counts directly via
346/// [`ToolSignals::from_request`].
347#[derive(Clone, Debug, Default)]
348pub struct ToolSignals {
349    /// Max severity across the recent window (last `recent_window` tool results):
350    /// `0.0` clean · `0.3` soft (exit_nonzero) · `0.7` hard · `1.0` critical.
351    /// Windowed so an error persists through the recovery turns instead of clearing
352    /// the instant the next result is clean.
353    pub severity: f32,
354    /// The same hard-or-critical failure appeared at least twice in the recent
355    /// tool-result window.
356    pub repeated_failure: bool,
357    /// Consecutive clean tool results back from the most recent. `0` if the last failed.
358    pub no_error_streak: u32,
359    /// Total edit-style tool calls in the request.
360    pub edit_count: u32,
361    /// Total write-style tool calls in the request.
362    pub write_count: u32,
363    /// Read-type calls (Read tool + read-like Bash). Used by the build-pit gate.
364    pub read_count: u32,
365    /// TodoWrite / planning tool calls. Investigative (non-producing) activity —
366    /// recent todowrites distinguish `exploring` from `spinning` in the scorer.
367    pub todowrite_count: u32,
368    /// Edit-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
369    pub recent_edit_count: u32,
370    /// Write-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
371    pub recent_write_count: u32,
372    /// Read-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
373    pub recent_read_count: u32,
374    /// TodoWrite calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
375    pub recent_todowrite_count: u32,
376    /// Configured `new` tool calls across the full request history.
377    pub new_count: u32,
378    /// Configured `new` tool calls within the recent window.
379    pub recent_new_count: u32,
380    /// Consecutive trailing tool calls in the `Unknown` category (no Write/Edit/Read/
381    /// Plan match). Surfaced in the classifier state summary; not scored directly.
382    pub pure_bash_streak: u32,
383    /// A tool result after the latest recent failure matched a test-pass pattern.
384    pub tests_passed: bool,
385    /// Total `ToolResult` blocks, counted per block (a message batching N
386    /// results contributes N) and including empty-content results.
387    pub tool_result_count: u32,
388    /// Messages with `Role::Assistant`, unlike [`ToolSignals::turn_depth`],
389    /// which counts every message regardless of role.
390    pub assistant_turn_count: u32,
391    /// Message-count proxy for turn depth. Wire-format dependent (Anthropic batches
392    /// tool results into fewer messages than OpenAI-chat), so gates keyed on it are
393    /// approximate across request origins.
394    pub turn_depth: u32,
395    /// The request carries a context-compaction summary (the agent's context was
396    /// summarised after overflowing). Compaction resets the router's accumulated
397    /// signals, so a task that was on the strong tier de-escalates back to weak — the
398    /// picker uses this to force + hold the strong tier. Self-latching: the summary
399    /// stays in the context prefix on every subsequent turn.
400    pub compacted: bool,
401}
402
403impl ToolSignals {
404    /// Extracts tool and activity signals from `request`.
405    ///
406    /// `window_size` limits recent counters to the newest tool results. `None`
407    /// uses [`DEFAULT_RECENT_WINDOW`].
408    pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
409        Self::from_request_with_semantics(request, window_size, &ToolSemantics::default())
410    }
411
412    /// Extracts signals using the built-in vocabulary plus additive semantics.
413    pub fn from_request_with_semantics(
414        request: &Request,
415        window_size: Option<usize>,
416        semantics: &ToolSemantics,
417    ) -> Self {
418        extract_tool_signals_with_window_and_semantics(
419            request,
420            window_size.unwrap_or(DEFAULT_RECENT_WINDOW),
421            semantics,
422        )
423    }
424}
425
426// `command` is the lowercased Bash command line; None for non-Bash tools.
427// `bare_name` is the tool's own name when `name` joins it to a namespace or MCP server.
428// `is_retrieval` marks a call that only reads; it counts as an observation.
429#[derive(Debug, Clone)]
430struct ObservedToolCall<'a> {
431    name: String,
432    bare_name: Option<&'a str>,
433    command: Option<String>,
434    is_retrieval: bool,
435}
436
437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
438enum MutationKind {
439    Write,
440    Edit,
441}
442
443/// Domain-neutral meaning assigned to an observed tool call.
444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
445enum ToolSemantic {
446    Mutate(MutationKind),
447    Observe,
448    Plan,
449    New,
450    Unknown,
451}
452
453/// Request-side processor that extracts tool-result signals from each request
454/// and stores them on the request `State` for downstream routing.
455#[derive(Debug, Clone)]
456pub struct ToolSignalProcessor {
457    /// Number of trailing tool results the `recent_*` counts and windowed
458    /// severity are computed over.
459    pub recent_window: usize,
460    /// Route-scoped additions to the built-in tool vocabulary.
461    pub tool_semantics: ToolSemantics,
462}
463
464impl Default for ToolSignalProcessor {
465    fn default() -> Self {
466        Self {
467            recent_window: DEFAULT_RECENT_WINDOW,
468            tool_semantics: ToolSemantics::default(),
469        }
470    }
471}
472
473#[async_trait]
474impl Processor<State> for ToolSignalProcessor {
475    async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
476        if let Event::Request { request: req, .. } = event {
477            let tool_signal = ToolSignals::from_request_with_semantics(
478                req,
479                Some(self.recent_window),
480                &self.tool_semantics,
481            );
482            state.tool_signals = Some(tool_signal);
483        }
484        Ok(())
485    }
486}
487
488fn classify_tool_call(name: &str, command: Option<&str>) -> ToolSemantic {
489    classify_tool_call_with_semantics(name, command, &ToolSemantics::default())
490}
491
492fn classify_tool_call_with_semantics(
493    name: &str,
494    command: Option<&str>,
495    semantics: &ToolSemantics,
496) -> ToolSemantic {
497    // Built-in names and Bash command inference take precedence over route-scoped mappings.
498    let lower = name.to_lowercase();
499    if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
500        return ToolSemantic::Mutate(MutationKind::Write);
501    }
502    if EDITOR_TOOL_NAMES.contains(&lower.as_str()) && command == Some("view") {
503        return ToolSemantic::Observe;
504    }
505    if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
506        return ToolSemantic::Mutate(MutationKind::Edit);
507    }
508    if READ_TOOL_NAMES.contains(&lower.as_str()) {
509        return ToolSemantic::Observe;
510    }
511    if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
512        return ToolSemantic::Plan;
513    }
514    if BASH_TOOL_NAMES.contains(&lower.as_str())
515        && let Some(cmd) = command
516    {
517        // Write/edit redirection trumps read-like operands.
518        if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_write(cmd) {
519            return ToolSemantic::Mutate(MutationKind::Write);
520        }
521        if cmd.contains("python") && PYTHON_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
522            return ToolSemantic::Mutate(MutationKind::Write);
523        }
524        if shell_invokes_program(cmd, "node")
525            && JAVASCRIPT_WRITE_PATTERNS
526                .iter()
527                .any(|pattern| cmd.contains(pattern))
528        {
529            return ToolSemantic::Mutate(MutationKind::Write);
530        }
531        if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_edit(cmd) {
532            return ToolSemantic::Mutate(MutationKind::Edit);
533        }
534        if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_read(cmd) {
535            return ToolSemantic::Observe;
536        }
537    }
538    semantics.classify(name).unwrap_or(ToolSemantic::Unknown)
539}
540
541/// Tools whose successful output is retrieved content or inspection data.
542fn is_retrieval_tool(name: &str, command: Option<&Value>) -> bool {
543    let lower = name.to_lowercase();
544    READ_TOOL_NAMES.contains(&lower.as_str())
545        || (EDITOR_TOOL_NAMES.contains(&lower.as_str())
546            && command
547                .and_then(Value::as_str)
548                .is_some_and(|command| command.eq_ignore_ascii_case("view")))
549        // PowerShell quoting and escapes do not follow the POSIX rules below.
550        || (lower != "powershell"
551            && BASH_TOOL_NAMES.contains(&lower.as_str())
552            && command.is_some_and(command_is_retrieval))
553}
554
555/// A shell command field given as one line or as argv words.
556fn command_is_retrieval(command: &Value) -> bool {
557    match command {
558        Value::String(line) => shell_is_retrieval(line, 0),
559        Value::Array(argv) => {
560            // Charge each word a separator byte, as in the one-line form, so the
561            // budget also bounds the argument count.
562            argv.iter()
563                .try_fold(0, |size, word| {
564                    let size = size + word.as_str()?.len() + 1;
565                    (size <= MAX_RETRIEVAL_COMMAND_BYTES).then_some(size)
566                })
567                .is_some()
568                && retrieval_command(
569                    &argv.iter().filter_map(Value::as_str).collect::<Vec<_>>(),
570                    0,
571                )
572        }
573        _ => false,
574    }
575}
576
577// Keep shell parsing bounded on the request thread. Larger commands retain
578// ordinary error scanning, just like unrecognized shell syntax.
579const MAX_RETRIEVAL_COMMAND_BYTES: usize = 16 * 1024;
580
581/// Only suppress output when every shell segment is a recognized inspection.
582/// Observation counters can count mixed commands; suppressing errors needs all
583/// commands to qualify. Substitutions and redirects keep their error signals.
584fn shell_is_retrieval(command: &str, depth: usize) -> bool {
585    if command.len() > MAX_RETRIEVAL_COMMAND_BYTES {
586        return false;
587    }
588    if quoted_chars(command).any(|(_, c, quote)| {
589        (quote != Some('\'') && matches!(c, '$' | '`'))
590            || (quote.is_none() && matches!(c, '<' | '>' | '(' | ')' | '{' | '}' | '#'))
591    }) {
592        return false;
593    }
594    let mut segments = shell_segments(command).peekable();
595    segments.peek().is_some()
596        && segments.all(|segment| {
597            // `split` also rejects an unclosed quote or a trailing backslash.
598            shlex::split(segment).is_some_and(|words| {
599                retrieval_command(&words.iter().map(String::as_str).collect::<Vec<_>>(), depth)
600            })
601        })
602}
603
604fn skip_shell_options<'a>(mut args: &'a [&'a str], takes_value: &[&str]) -> &'a [&'a str] {
605    while let Some((option, rest)) = args.split_first() {
606        if !option.starts_with('-') || *option == "-" {
607            break;
608        }
609        args = rest;
610        if *option == "--" {
611            break;
612        }
613        if takes_value.contains(option) {
614            args = args.get(1..).unwrap_or_default();
615        }
616    }
617    args
618}
619
620/// A short-option word such as `-Hx` that sets any of `flags`.
621fn has_short_flag(word: &str, flags: &[char]) -> bool {
622    word.starts_with('-') && !word.starts_with("--") && word.contains(flags)
623}
624
625// Bounds recursion through nested wrappers and `sh -c` scripts.
626const MAX_COMMAND_DEPTH: usize = 20;
627
628fn retrieval_command(mut words: &[&str], depth: usize) -> bool {
629    if depth > MAX_COMMAND_DEPTH {
630        return false;
631    }
632    while words.first().is_some_and(|word| {
633        word.split_once('=').is_some_and(|(name, _)| {
634            !name.is_empty()
635                && name.bytes().enumerate().all(|(i, c)| {
636                    c == b'_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())
637                })
638        })
639    }) {
640        words = &words[1..];
641    }
642    let Some((&program, args)) = words.split_first() else {
643        return false;
644    };
645    // The standard bin dirs hold the real programs. Any other path, such as
646    // `./test`, names a local script.
647    let program = [
648        "/bin/",
649        "/sbin/",
650        "/usr/bin/",
651        "/usr/sbin/",
652        "/usr/local/bin/",
653        "/opt/homebrew/bin/",
654    ]
655    .iter()
656    .find_map(|dir| program.strip_prefix(dir))
657    .unwrap_or(program);
658    // Unwrap launchers before inspecting the actual command and its flags.
659    let wrapper_options: Option<&[&str]> = match program {
660        "sudo" => Some(&[
661            "-u", "-g", "-h", "-p", "-C", "-T", "--user", "--group", "--host",
662        ]),
663        "env" => Some(&["-u", "--unset", "-C", "--chdir"]),
664        "command" | "nohup" => Some(&[]),
665        "exec" => Some(&["-a"]),
666        "time" => Some(&["-f", "--format", "-o", "--output"]),
667        "timeout" => Some(&["-s", "--signal", "-k", "--kill-after"]),
668        "xargs" => Some(&[
669            "-n",
670            "--max-args",
671            "-P",
672            "--max-procs",
673            "-I",
674            "--replace",
675            "-d",
676            "--delimiter",
677            "-L",
678            "--max-lines",
679            "-s",
680            "--max-chars",
681        ]),
682        _ => None,
683    };
684    if let Some(options) = wrapper_options {
685        // `env -S` splits its argument into a new command line.
686        if program == "env"
687            && args
688                .iter()
689                .any(|arg| arg.starts_with("--split-string") || has_short_flag(arg, &['S']))
690        {
691            return false;
692        }
693        let mut rest = skip_shell_options(args, options);
694        if program == "timeout" {
695            rest = rest.get(1..).unwrap_or_default();
696        }
697        return (program == "env" && rest.is_empty()) || retrieval_command(rest, depth + 1);
698    }
699    if matches!(program, "bash" | "sh" | "dash" | "zsh" | "ksh") {
700        return args
701            .first()
702            .is_some_and(|option| has_short_flag(option, &['c']))
703            && args
704                .get(1)
705                .is_some_and(|command| shell_is_retrieval(command, depth + 1));
706    }
707    if program == "git" {
708        return git_is_retrieval(args);
709    }
710    if matches!(args, ["--help" | "--version"]) {
711        return true;
712    }
713    match program {
714        "find" => !args.iter().any(|arg| {
715            matches!(
716                *arg,
717                "-delete"
718                    | "-exec"
719                    | "-execdir"
720                    | "-ok"
721                    | "-okdir"
722                    | "-fprint"
723                    | "-fprint0"
724                    | "-fprintf"
725            )
726        }),
727        // fd joins short flags, so `-Hx` runs a command too.
728        "fd" => !args
729            .iter()
730            .any(|arg| arg.starts_with("--exec") || has_short_flag(arg, &['x', 'X'])),
731        // Only the common line-range printing form; sed scripts can execute commands.
732        "sed" => {
733            args.len() >= 2
734                && args[0] == "-n"
735                && args[2..].iter().all(|arg| !arg.starts_with('-'))
736                && args[1].strip_suffix('p').is_some_and(|range| {
737                    !range.is_empty()
738                        && range
739                            .bytes()
740                            .all(|c| c.is_ascii_digit() || matches!(c, b',' | b'$'))
741                })
742        }
743        "sort" => !args.iter().any(|arg| {
744            arg.starts_with("--compress-program")
745                || arg.starts_with("--output")
746                || arg.starts_with("-o")
747        }),
748        "rg" => !args.iter().any(|arg| arg.starts_with("--pre")),
749        "xxd" => !args.contains(&"-r") && !args.contains(&"-revert"),
750        "go" => {
751            matches!(args.first(), Some(&"list" | &"doc" | &"env" | &"version"))
752                && !args.iter().any(|arg| matches!(*arg, "-w" | "-u"))
753        }
754        "docker" | "podman" => matches!(args.first(), Some(&"ps" | &"version")),
755        "cat" | "grep" | "ls" | "nl" | "head" | "tail" | "wc" | "pwd" | "stat" | "file" | "du"
756        | "df" | "which" | "type" | "diff" | "cmp" | "jq" | "uniq" | "cut" | "readlink"
757        | "realpath" | "tree" | "basename" | "dirname" | "printenv" | "echo" | "printf"
758        | "less" | "more" | "test" | "[" | "ps" | "pgrep" | "pkg-config" | "strings" | "uname"
759        | "od" | "true" | ":" | "cd" | "pstree" | "sha256sum" | "sha1sum" | "md5sum" | "lsof"
760        | "tr" | "free" | "id" | "namei" | "whoami" | "paste" | "ss" | "getent" => true,
761        _ => false,
762    }
763}
764
765fn git_is_retrieval(args: &[&str]) -> bool {
766    let args = skip_shell_options(
767        args,
768        &["-C", "-c", "--git-dir", "--work-tree", "--namespace"],
769    );
770    let Some((subcommand, rest)) = args.split_first() else {
771        return false;
772    };
773    match *subcommand {
774        "diff" => !rest.contains(&"--check"),
775        "branch" => {
776            rest.is_empty()
777                || rest.iter().all(|arg| {
778                    matches!(
779                        *arg,
780                        "-a" | "-r"
781                            | "--all"
782                            | "--list"
783                            | "--show-current"
784                            | "-v"
785                            | "-vv"
786                            | "--verbose"
787                    )
788                })
789        }
790        "remote" => {
791            rest.is_empty()
792                || matches!(rest, ["-v" | "--verbose"])
793                || rest.first() == Some(&"get-url")
794        }
795        "tag" => rest.is_empty() || matches!(rest[0], "-l" | "--list"),
796        "config" => matches!(
797            rest.first(),
798            Some(&"--get" | &"--get-all" | &"--list" | &"-l")
799        ),
800        "worktree" => rest.first() == Some(&"list"),
801        "submodule" => rest.first() == Some(&"status"),
802        "status" | "log" | "show" | "blame" | "ls-files" | "ls-remote" | "rev-parse"
803        | "merge-base" | "grep" | "describe" | "show-ref" | "check-ignore" | "rev-list"
804        | "ls-tree" | "diff-tree" | "version" => true,
805        _ => false,
806    }
807}
808
809fn is_builtin_tool_name(lower: &str) -> bool {
810    WRITE_TOOL_NAMES.contains(&lower)
811        || EDIT_TOOL_NAMES.contains(&lower)
812        || READ_TOOL_NAMES.contains(&lower)
813        || PLAN_TOOL_NAMES.contains(&lower)
814        || BASH_TOOL_NAMES.contains(&lower)
815}
816
817/// Split a shell line at unquoted command separators. This intentionally avoids
818/// pretending to be a full shell parser; only the leading program and flags of
819/// each segment are inspected below.
820fn shell_segments(command: &str) -> impl Iterator<Item = &str> {
821    let mut start = 0;
822    quoted_chars(command)
823        .filter(|&(_, c, quote)| quote.is_none() && matches!(c, '\n' | ';' | '|' | '&'))
824        .map(|(index, _, _)| index)
825        .chain([command.len()])
826        .filter_map(move |end| {
827            let segment = command[start..end].trim();
828            // Separators are one byte.
829            start = end + 1;
830            (!segment.is_empty()).then_some(segment)
831        })
832}
833
834/// Unescaped characters with their byte index and the quote around them.
835fn quoted_chars(command: &str) -> impl Iterator<Item = (usize, char, Option<char>)> {
836    let mut quote = None;
837    let mut escaped = false;
838    command.char_indices().filter_map(move |(index, c)| {
839        if escaped {
840            escaped = false;
841            return None;
842        }
843        if c == '\\' && quote != Some('\'') {
844            escaped = true;
845            return None;
846        }
847        let around = quote;
848        if quote == Some(c) {
849            quote = None;
850        } else if quote.is_none() && matches!(c, '\'' | '"') {
851            quote = Some(c);
852        }
853        Some((index, c, around))
854    })
855}
856
857fn shell_words(segment: &str) -> std::iter::Peekable<std::str::SplitAsciiWhitespace<'_>> {
858    let mut words = segment.split_ascii_whitespace().peekable();
859
860    if words.peek().copied() == Some("env") {
861        words.next();
862        while words.peek().is_some_and(|word| word.starts_with('-')) {
863            words.next();
864        }
865    }
866    while words
867        .peek()
868        .is_some_and(|word| word.contains('=') && !word.starts_with('='))
869    {
870        words.next();
871    }
872
873    words
874}
875
876fn program_name(word: &str) -> &str {
877    Path::new(word)
878        .file_name()
879        .and_then(|name| name.to_str())
880        .unwrap_or(word)
881}
882
883fn shell_invokes_program(command: &str, expected: &str) -> bool {
884    shell_segments(command).any(|segment| {
885        shell_words(segment)
886            .next()
887            .is_some_and(|word| program_name(word) == expected)
888    })
889}
890
891fn shell_command_is_write(command: &str) -> bool {
892    shell_segments(command).any(|segment| {
893        let mut words = shell_words(segment);
894        let Some(program) = words.next().map(program_name) else {
895            return false;
896        };
897        if matches!(program, "cp" | "mkdir" | "touch" | "install") {
898            return true;
899        }
900
901        let redirects_output = words.any(|word| matches!(word, ">" | ">>"));
902        redirects_output
903            && (matches!(program, "echo" | "printf" | "git")
904                || BASH_READ_COMMANDS.contains(&program))
905    })
906}
907
908fn shell_command_is_edit(command: &str) -> bool {
909    shell_segments(command).any(|segment| {
910        let mut words = shell_words(segment);
911        let Some(program) = words.next().map(program_name) else {
912            return false;
913        };
914        let has_arg = |arg: &str| words.clone().any(|word| word == arg);
915
916        match program {
917            "mv" | "rm" => true,
918            "perl" => words
919                .take_while(|word| word.starts_with('-'))
920                .any(|option| {
921                    option
922                        .trim_start_matches('-')
923                        .chars()
924                        .any(|flag| flag == 'i')
925                }),
926            "git" => words
927                .next()
928                .is_some_and(|subcommand| matches!(subcommand, "apply" | "am" | "restore")),
929            "gofmt" => has_arg("-w"),
930            "cargo" => words.clone().next() == Some("fmt") && !has_arg("--check"),
931            "ruff" => {
932                let subcommand = words.clone().next();
933                (subcommand == Some("format") && !has_arg("--check"))
934                    || (subcommand == Some("check") && has_arg("--fix"))
935            }
936            "prettier" => has_arg("--write"),
937            "black" => !has_arg("--check"),
938            _ => {
939                (words.clone().any(|word| program_name(word) == "prettier") && has_arg("--write"))
940                    || (words.clone().any(|word| program_name(word) == "ruff")
941                        && ((has_arg("format") && !has_arg("--check"))
942                            || (has_arg("check") && has_arg("--fix"))))
943            }
944        }
945    })
946}
947
948fn shell_command_is_read(command: &str) -> bool {
949    shell_segments(command).any(|segment| {
950        if segment == "env" {
951            return true;
952        }
953        let mut words = shell_words(segment);
954        let Some(program) = words.next().map(program_name) else {
955            return false;
956        };
957
958        if BASH_READ_COMMANDS.contains(&program) {
959            return true;
960        }
961        if program == "command" && words.next() == Some("-v") {
962            return true;
963        }
964        if program == "type" {
965            return true;
966        }
967        if program != "git" {
968            return false;
969        }
970
971        match words.next() {
972            Some("branch") => words.next().is_none_or(|arg| arg.starts_with('-')),
973            Some("remote") => words
974                .next()
975                .is_none_or(|arg| arg.starts_with('-') || arg == "get-url"),
976            Some("config") => words
977                .next()
978                .is_some_and(|arg| matches!(arg, "--get" | "--get-all" | "--list" | "-l")),
979            Some(subcommand) => GIT_READ_SUBCOMMANDS.contains(&subcommand),
980            None => false,
981        }
982    })
983}
984
985// ─── extraction entry point ───────────────────────────────────────────────────
986
987/// Extract all tool-execution signals from a normalized [`Request`].
988///
989/// Returns [`ToolSignals::default()`] when the message history contains no tool
990/// activity, so callers can always inspect the signal fields.
991fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
992    extract_tool_signals_with_window_and_semantics(
993        request,
994        recent_window,
995        &ToolSemantics::default(),
996    )
997}
998
999fn extract_tool_signals_with_window_and_semantics(
1000    request: &Request,
1001    recent_window: usize,
1002    semantics: &ToolSemantics,
1003) -> ToolSignals {
1004    // Read the decoded conversation, including preserved built-in tool outputs.
1005    let messages = &request.llm_request.messages;
1006    let namespaces = tool_namespaces(&request.llm_request.extensions);
1007    let mut tool_texts: Vec<(String, bool)> = Vec::new();
1008    let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
1009    // IDs whose latest call is a retrieval tool.
1010    let mut retrieval_calls: HashSet<&str> = HashSet::new();
1011    let mut compacted = false;
1012    let mut tool_result_count = 0usize;
1013    let mut assistant_turn_count = 0usize;
1014
1015    for message in messages {
1016        if message.role == Role::Assistant {
1017            assistant_turn_count += 1;
1018        }
1019        for block in &message.content {
1020            match block {
1021                ContentBlock::ToolCall(call) => {
1022                    // Responses namespaced tools arrive as `<namespace>__<tool>`.
1023                    let bare_name = namespaces
1024                        .and_then(|namespaces| split_qualified_name(namespaces, &call.name))
1025                        .map(|(tool, _)| tool)
1026                        .or_else(|| mcp_tool_name(&call.name));
1027                    // The Responses wire format sends arguments as a JSON string.
1028                    let decoded = call
1029                        .arguments
1030                        .as_str()
1031                        .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
1032                    let command_field = command_of(decoded.as_ref().unwrap_or(&call.arguments));
1033                    let command = command_field.and_then(command_text);
1034                    // The joined name wins, as in `build_signal`. A joined name
1035                    // configured as observe still counts when its bare name is a
1036                    // retrieval tool, such as `mcp__files__read`.
1037                    let full = classify_tool_call_with_semantics(
1038                        &call.name,
1039                        command.as_deref(),
1040                        semantics,
1041                    );
1042                    let name = match (full, bare_name) {
1043                        (ToolSemantic::Unknown | ToolSemantic::Observe, Some(bare_name)) => {
1044                            bare_name
1045                        }
1046                        _ => call.name.as_str(),
1047                    };
1048                    let is_retrieval = is_retrieval_tool(name, command_field);
1049                    if !call.id.is_empty() {
1050                        // A reused ID links to its latest call.
1051                        if is_retrieval {
1052                            retrieval_calls.insert(call.id.as_str());
1053                        } else {
1054                            retrieval_calls.remove(call.id.as_str());
1055                        }
1056                    }
1057                    tool_calls.push(ObservedToolCall {
1058                        name: call.name.clone(),
1059                        bare_name,
1060                        command,
1061                        is_retrieval,
1062                    });
1063                }
1064                ContentBlock::ToolResult(result) => {
1065                    // Before the empty-text filter: empty results still count.
1066                    tool_result_count += 1;
1067                    let is_error = result.is_error == Some(true);
1068                    let is_retrieval_result = retrieval_calls
1069                        .contains(result.tool_call_id.as_str())
1070                        && (!is_error
1071                            || result
1072                                .content
1073                                .iter()
1074                                .filter_map(text_of)
1075                                .any(is_missing_file));
1076                    let texts = result.content.iter().filter_map(text_of);
1077                    let has_text = texts.clone().any(|text| !text.is_empty());
1078                    if is_retrieval_result {
1079                        // Keep the window slot without copying file contents.
1080                        if has_text {
1081                            tool_texts.push((String::new(), false));
1082                        }
1083                        continue;
1084                    }
1085                    // An explicit failure remains a signal even without text.
1086                    if has_text || is_error {
1087                        tool_texts.push((texts.collect::<Vec<_>>().join("\n"), is_error));
1088                    }
1089                }
1090                // Built-in tool history stays opaque so it can be replayed unchanged.
1091                ContentBlock::Unknown { provider, raw }
1092                    if provider.as_str() == WireFormat::OpenAiResponses.as_str()
1093                        && raw.get("type").and_then(Value::as_str)
1094                            == Some("apply_patch_call_output") =>
1095                {
1096                    tool_result_count += 1;
1097                    let text = raw
1098                        .get("output")
1099                        .and_then(Value::as_str)
1100                        .unwrap_or_default();
1101                    let is_error = raw.get("status").and_then(Value::as_str) == Some("failed");
1102                    tool_texts.push((text.to_owned(), is_error));
1103                }
1104                ContentBlock::Unknown { provider, raw }
1105                    if provider.as_str() == WireFormat::OpenAiResponses.as_str()
1106                        && raw.get("type").and_then(Value::as_str) == Some("shell_call_output") =>
1107                {
1108                    tool_result_count += 1;
1109                    let mut texts = Vec::new();
1110                    let mut is_error = false;
1111                    if let Some(outputs) = raw.get("output").and_then(Value::as_array) {
1112                        for output in outputs {
1113                            for field in ["stdout", "stderr"] {
1114                                if let Some(text) = output.get(field).and_then(Value::as_str)
1115                                    && !text.is_empty()
1116                                {
1117                                    texts.push(text);
1118                                }
1119                            }
1120                            if let Some(outcome) = output.get("outcome") {
1121                                is_error |= match outcome.get("type").and_then(Value::as_str) {
1122                                    Some("timeout") => true,
1123                                    Some("exit")
1124                                        if outcome
1125                                            .get("exit_code")
1126                                            .and_then(Value::as_i64)
1127                                            .is_some_and(|code| code != 0) =>
1128                                    {
1129                                        // Keep plain nonzero exits SOFT, including empty output.
1130                                        texts.push("returned non-zero");
1131                                        output
1132                                            .get("stderr")
1133                                            .and_then(Value::as_str)
1134                                            .is_some_and(|text| !text.trim().is_empty())
1135                                    }
1136                                    _ => false,
1137                                };
1138                            }
1139                        }
1140                    }
1141                    let text = texts.join("\n");
1142                    tool_texts.push((text, is_error));
1143                }
1144                // Compaction is detected anywhere in the conversation: the summary
1145                // stays in the prefix on every later turn, so this self-latches
1146                // once it fires.
1147                ContentBlock::Text { text } => {
1148                    compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
1149                }
1150                _ => {}
1151            }
1152        }
1153    }
1154
1155    let mut signal = build_signal(
1156        tool_texts,
1157        tool_calls,
1158        messages.len() as u32,
1159        recent_window,
1160        semantics,
1161    );
1162    signal.compacted = compacted;
1163    signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
1164    signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
1165    signal
1166}
1167
1168/// Distinctive preamble Claude Code injects as a user message when it compacts an
1169/// overflowed context. Matched case-insensitively; normal task text never contains it.
1170const COMPACTION_MARKER: &str = "session is being continued";
1171
1172/// The tool part of an `mcp__<server>__<tool>` name, the form Claude Code uses
1173/// for MCP tools. The server name is assumed not to contain `__`; the tool name
1174/// may.
1175fn mcp_tool_name(name: &str) -> Option<&str> {
1176    let (_server, tool) = name.strip_prefix("mcp__")?.split_once("__")?;
1177    (!tool.is_empty()).then_some(tool)
1178}
1179
1180/// The command field a tool call carries, when it has one. Harnesses name it
1181/// `command`, `cmd` or `input`; anything else is a tool whose category comes
1182/// from its name.
1183fn command_of(arguments: &Value) -> Option<&Value> {
1184    ["command", "cmd", "input"]
1185        .iter()
1186        .find_map(|key| arguments.get(*key))
1187}
1188
1189/// A command field as lowercase text, from a string or an argv array.
1190fn command_text(value: &Value) -> Option<String> {
1191    match value {
1192        Value::String(text) => Some(text.to_lowercase()),
1193        Value::Array(parts) => {
1194            let joined = parts
1195                .iter()
1196                .filter_map(Value::as_str)
1197                .collect::<Vec<_>>()
1198                .join(" ");
1199            (!joined.is_empty()).then(|| joined.to_lowercase())
1200        }
1201        _ => None,
1202    }
1203}
1204
1205/// Text carried by a content block, ignoring the non-textual kinds.
1206fn text_of(block: &ContentBlock) -> Option<&str> {
1207    match block {
1208        ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
1209        _ => None,
1210    }
1211}
1212
1213/// Reads of missing paths happen as often on tasks that succeed as on tasks
1214/// that fail, so they are not a failure signal.
1215fn is_missing_file(text: &str) -> bool {
1216    let lower = text.to_lowercase();
1217    // The OS error from shell tools, and Claude Code's Read tool message.
1218    lower.contains("no such file or directory") || lower.contains("file does not exist")
1219}
1220
1221fn build_signal(
1222    tool_texts: Vec<(String, bool)>,
1223    tool_calls: Vec<ObservedToolCall>,
1224    turn_depth: u32,
1225    recent_window: usize,
1226    semantics: &ToolSemantics,
1227) -> ToolSignals {
1228    // Windowed severity: take the MAX severity across the last `recent_window` tool
1229    // results rather than only the last one. An error's severity then persists for
1230    // the recent window and decays out of it — parallel to the windowed `recent_*`
1231    // counts — so a fix written a couple of turns after an error still routes on the
1232    // error signal instead of the router flapping straight back to the weak tier.
1233    let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
1234    let mut severity = 0.0f32;
1235    let mut failure_fingerprints = Vec::new();
1236    let mut repeated_failure = false;
1237    for (text, is_error) in &tool_texts[sev_start..] {
1238        let (sev, _patterns) = classify_text(text);
1239        // Explicit failure is at least hard; retain stronger text diagnostics.
1240        let sev = if *is_error { sev.max(HARD) } else { sev };
1241        if sev > severity {
1242            severity = sev;
1243        }
1244        if let Some(fingerprint) = failure_fingerprint(text, *is_error) {
1245            repeated_failure |= failure_fingerprints.contains(&fingerprint);
1246            failure_fingerprints.push(fingerprint);
1247        }
1248    }
1249
1250    let no_error_streak = compute_no_error_streak(&tool_texts);
1251
1252    // Single pass: cumulative + sliding-window counters together. Also tracks
1253    // the trailing pure-bash streak (consecutive `Unknown` calls back
1254    // from the end) — the build-pit proxy.
1255    let recent_start = tool_calls.len().saturating_sub(recent_window);
1256    let mut write_count = 0u32;
1257    let mut edit_count = 0u32;
1258    let mut read_count = 0u32;
1259    let mut todowrite_count = 0u32;
1260    let mut recent_write_count = 0u32;
1261    let mut recent_edit_count = 0u32;
1262    let mut recent_read_count = 0u32;
1263    let mut recent_todowrite_count = 0u32;
1264    let mut new_count = 0u32;
1265    let mut recent_new_count = 0u32;
1266    let mut pure_bash_streak = 0u32;
1267    let mut streak_open = true;
1268    for (i, tc) in tool_calls.iter().enumerate().rev() {
1269        // Every read, found or missed, is an observation. The read check parses
1270        // the command, so it beats the substring patterns. Otherwise the joined
1271        // name wins, so configs that list it keep working.
1272        let mut cat = if tc.is_retrieval {
1273            ToolSemantic::Observe
1274        } else {
1275            classify_tool_call_with_semantics(&tc.name, tc.command.as_deref(), semantics)
1276        };
1277        if matches!(cat, ToolSemantic::Unknown)
1278            && let Some(bare_name) = tc.bare_name
1279        {
1280            cat = classify_tool_call_with_semantics(bare_name, tc.command.as_deref(), semantics);
1281        }
1282        if streak_open {
1283            if matches!(cat, ToolSemantic::Unknown) {
1284                pure_bash_streak += 1;
1285            } else {
1286                streak_open = false;
1287            }
1288        }
1289        match cat {
1290            ToolSemantic::Mutate(MutationKind::Write) => {
1291                write_count += 1;
1292                if i >= recent_start {
1293                    recent_write_count += 1;
1294                }
1295            }
1296            ToolSemantic::Mutate(MutationKind::Edit) => {
1297                edit_count += 1;
1298                if i >= recent_start {
1299                    recent_edit_count += 1;
1300                }
1301            }
1302            ToolSemantic::Observe => {
1303                read_count += 1;
1304                if i >= recent_start {
1305                    recent_read_count += 1;
1306                }
1307            }
1308            ToolSemantic::Plan => {
1309                todowrite_count += 1;
1310                if i >= recent_start {
1311                    recent_todowrite_count += 1;
1312                }
1313            }
1314            ToolSemantic::New => {
1315                new_count += 1;
1316                if i >= recent_start {
1317                    recent_new_count += 1;
1318                }
1319            }
1320            ToolSemantic::Unknown => {}
1321        }
1322    }
1323
1324    let tests_passed = detect_tests_passed(&tool_texts, recent_window);
1325
1326    ToolSignals {
1327        severity,
1328        repeated_failure,
1329        no_error_streak,
1330        edit_count,
1331        write_count,
1332        read_count,
1333        todowrite_count,
1334        recent_edit_count,
1335        recent_write_count,
1336        recent_read_count,
1337        recent_todowrite_count,
1338        new_count,
1339        recent_new_count,
1340        pure_bash_streak,
1341        tests_passed,
1342        turn_depth,
1343        // Set by extract_tool_signals_with_window after the format-specific extract,
1344        // which scans all message contents for the compaction marker and tallies
1345        // the raw conversation-shape counts.
1346        tool_result_count: 0,
1347        assistant_turn_count: 0,
1348        compacted: false,
1349    }
1350}
1351
1352// ─── pure helpers ─────────────────────────────────────────────────────────────
1353
1354/// Normalise a JSON tool-result content value to a plain string.
1355fn content_to_text(content: Option<&Value>) -> Option<String> {
1356    match content? {
1357        Value::String(s) => Some(s.clone()),
1358        Value::Array(blocks) => {
1359            let parts: Vec<&str> = blocks
1360                .iter()
1361                .filter_map(|b| {
1362                    b.as_object()
1363                        .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
1364                        .and_then(|o| o.get("text"))
1365                        .and_then(Value::as_str)
1366                })
1367                .collect();
1368            if parts.is_empty() {
1369                None
1370            } else {
1371                Some(parts.join("\n"))
1372            }
1373        }
1374        _ => None,
1375    }
1376}
1377
1378/// Match `text` against the error pattern table.
1379///
1380/// Returns `(max_severity, matched_pattern_names)`.
1381pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
1382    let lower = text.to_lowercase();
1383    let mut patterns = Vec::new();
1384    let mut severity: f32 = 0.0;
1385    for (name, sev, substrings) in ERROR_PATTERNS {
1386        if substrings.iter().any(|sub| lower.contains(sub)) {
1387            patterns.push(name.to_string());
1388            severity = severity.max(*sev);
1389        }
1390    }
1391    if has_nonzero_exit_status(&lower) && !patterns.iter().any(|p| p == "exit_nonzero") {
1392        patterns.push("exit_nonzero".to_string());
1393        severity = severity.max(SOFT);
1394    }
1395    for (name, matched) in [
1396        ("compile_error", has_compiler_diagnostic(&lower)),
1397        ("runtime_exception", has_runtime_exception(&lower)),
1398        ("runtime_panic", has_runtime_panic(&lower)),
1399        ("patch_error", has_patch_failure(&lower)),
1400    ] {
1401        if matched && !patterns.iter().any(|pattern| pattern == name) {
1402            patterns.push(name.to_string());
1403            severity = severity.max(HARD);
1404        }
1405    }
1406    (severity, patterns)
1407}
1408
1409/// Stable identity for a material failure. Soft non-zero exits need an explicit
1410/// failure flag to count as a repeated mistake.
1411fn failure_fingerprint(text: &str, is_error: bool) -> Option<String> {
1412    let (severity, patterns) = classify_text(text);
1413    if severity < HARD && !is_error {
1414        return None;
1415    }
1416
1417    let lower = text.to_lowercase();
1418    let diagnostic = lower
1419        .lines()
1420        .find(|line| is_failure_diagnostic(line))
1421        .or_else(|| lower.lines().find(|line| !line.trim().is_empty()))
1422        .unwrap_or_default();
1423    let normalized = normalize_failure_text(diagnostic);
1424    Some(format!("{}|{normalized}", patterns.join(",")))
1425}
1426
1427fn is_failure_diagnostic(line: &str) -> bool {
1428    let line = line.trim();
1429    [
1430        "error",
1431        "exception",
1432        "panic",
1433        "failed",
1434        "timed out",
1435        "timeout",
1436        "connection refused",
1437        "cannot allocate memory",
1438        "out of memory",
1439        "not found",
1440    ]
1441    .iter()
1442    .any(|marker| line.contains(marker))
1443}
1444
1445/// Removes values that normally change between retries while retaining the
1446/// diagnostic wording that distinguishes one failure from another.
1447fn normalize_failure_text(text: &str) -> String {
1448    let mut normalized = String::new();
1449    for word in text.split_whitespace() {
1450        if !normalized.is_empty() {
1451            normalized.push(' ');
1452        }
1453        let mut in_digits = false;
1454        if word.starts_with('/') || word.contains("/src/") || word.contains("/tmp/") {
1455            normalized.push_str("<path>");
1456            continue;
1457        }
1458        for character in word.chars() {
1459            if character.is_ascii_digit() {
1460                if !in_digits {
1461                    normalized.push('#');
1462                    in_digits = true;
1463                }
1464            } else {
1465                normalized.push(character);
1466                in_digits = false;
1467            }
1468        }
1469    }
1470    normalized.chars().take(240).collect()
1471}
1472
1473fn has_compiler_diagnostic(lower: &str) -> bool {
1474    lower.lines().any(|line| {
1475        let line = line.trim_start();
1476        if matches!(
1477            line,
1478            "compilation failed" | "error: compilation failed" | "error: could not compile"
1479        ) || line.starts_with("error: could not compile ")
1480        {
1481            return true;
1482        }
1483
1484        let Some(rest) = line.strip_prefix("error[e") else {
1485            return false;
1486        };
1487        let Some((code, _)) = rest.split_once("]:") else {
1488            return false;
1489        };
1490        !code.is_empty() && code.chars().all(|character| character.is_ascii_digit())
1491    })
1492}
1493
1494fn has_runtime_exception(lower: &str) -> bool {
1495    let has_exception_line = lower.lines().any(|line| {
1496        let line = line.trim_start();
1497        [
1498            "typeerror:",
1499            "referenceerror:",
1500            "rangeerror:",
1501            "runtimeerror:",
1502            "keyerror:",
1503            "attributeerror:",
1504        ]
1505        .iter()
1506        .any(|prefix| line.starts_with(prefix))
1507    });
1508    has_exception_line && (lower.contains("\n    at ") || lower.contains("\n  at "))
1509}
1510
1511fn has_runtime_panic(lower: &str) -> bool {
1512    lower
1513        .lines()
1514        .any(|line| line.trim_start().starts_with("panic: runtime error:"))
1515        && (lower.contains("\ngoroutine ") || lower.contains("[signal sig"))
1516}
1517
1518fn has_patch_failure(lower: &str) -> bool {
1519    lower.lines().any(|line| {
1520        let line = line.trim_start();
1521        line.starts_with("error: patch failed:")
1522            || line.starts_with("patch failed:")
1523            || line.contains(": patch does not apply")
1524            || line.starts_with("invalid context")
1525    })
1526}
1527
1528/// Detects `exit_nonzero` only when a supported exit phrase is followed by a
1529/// nonzero decimal status.
1530///
1531/// Codex includes "Process exited with code 0" on clean tool results, so exit
1532/// phrases must parse their numeric status instead of matching the phrase alone.
1533fn has_nonzero_exit_status(lower: &str) -> bool {
1534    NONZERO_EXIT_PHRASES
1535        .iter()
1536        .any(|phrase| phrase_followed_by_nonzero_integer(lower, phrase))
1537}
1538
1539/// Matches common "exit code/status N" spellings after optional separators.
1540fn phrase_followed_by_nonzero_integer(lower: &str, phrase: &str) -> bool {
1541    let mut cursor = 0usize;
1542    while let Some(rel) = lower[cursor..].find(phrase) {
1543        let value_start = cursor + rel + phrase.len();
1544        let rest = lower[value_start..].trim_start_matches(|c: char| {
1545            c.is_ascii_whitespace() || matches!(c, ':' | '=' | '\'' | '"' | '`')
1546        });
1547        let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1548        if !digits.is_empty() && digits.chars().any(|d| d != '0') {
1549            return true;
1550        }
1551        cursor = value_start;
1552    }
1553    false
1554}
1555
1556fn compute_no_error_streak(tool_texts: &[(String, bool)]) -> u32 {
1557    let mut streak = 0u32;
1558    for (text, is_error) in tool_texts.iter().rev() {
1559        let (sev, _) = classify_text(text);
1560        if *is_error || sev > 0.0 {
1561            break;
1562        }
1563        streak += 1;
1564    }
1565    streak
1566}
1567
1568fn detect_tests_passed(tool_texts: &[(String, bool)], recent_window: usize) -> bool {
1569    let start = tool_texts.len().saturating_sub(recent_window.max(1));
1570    let recent = &tool_texts[start..];
1571    let after_latest_failure = recent
1572        .iter()
1573        .rposition(|(text, is_error)| *is_error || classify_text(text).0 > 0.0)
1574        .map_or(recent, |index| &recent[index + 1..]);
1575    after_latest_failure.iter().any(|(text, _)| {
1576        let lower = text.to_lowercase();
1577        TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
1578            && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
1579            && !has_nonzero_failure_count(&lower)
1580    })
1581}
1582
1583// True iff `lower` contains a `NUMERIC_FAILURE_KEYWORDS` token preceded
1584// (modulo whitespace) by a nonzero integer. The "modulo whitespace" lets
1585// "1 failed", "1\nfailed", and "1  failed" all trip; the nonzero guard
1586// keeps cargo's "0 failed" / go's "0 errors" / pytest's "0 errors in"
1587// summaries from being misread as failures on a clean run.
1588fn has_nonzero_failure_count(lower: &str) -> bool {
1589    for kw in NUMERIC_FAILURE_KEYWORDS {
1590        let mut cursor = 0usize;
1591        while let Some(rel) = lower[cursor..].find(kw) {
1592            let kw_start = cursor + rel;
1593            let kw_end = kw_start + kw.len();
1594            // Word boundary AFTER the keyword — "errors" mid-word (e.g.
1595            // "errored") shouldn't count as a failure-count site.
1596            let boundary_after = lower[kw_end..]
1597                .chars()
1598                .next()
1599                .is_none_or(|c| !c.is_ascii_alphanumeric());
1600            if boundary_after {
1601                let prefix = &lower[..kw_start];
1602                let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
1603                let digits_rev: String = trimmed
1604                    .chars()
1605                    .rev()
1606                    .take_while(|c| c.is_ascii_digit())
1607                    .collect();
1608                if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
1609                    return true;
1610                }
1611            }
1612            cursor = kw_start + kw.len();
1613        }
1614    }
1615    false
1616}
1617
1618// ─── tests ───────────────────────────────────────────────────────────────────
1619
1620#[cfg(test)]
1621mod tests {
1622    use super::*;
1623    use crate::algorithms::util::stage::score_signal;
1624    use serde_json::json;
1625    use switchyard_protocol::codex_namespaces::TOOL_NAMESPACES_KEY;
1626    use switchyard_protocol::{
1627        ContentBlock, LlmRequest, Message, Metadata, Role, ToolCall, ToolResult,
1628    };
1629
1630    fn with_messages(messages: Vec<Message>) -> Request {
1631        Request {
1632            llm_request: LlmRequest {
1633                messages,
1634                ..LlmRequest::default()
1635            },
1636            raw_request: None,
1637            metadata: None,
1638        }
1639    }
1640
1641    // assistant message with a single named tool call
1642    fn tc(name: &str) -> Message {
1643        Message {
1644            role: Role::Assistant,
1645            content: vec![ContentBlock::ToolCall(ToolCall {
1646                id: String::new(),
1647                name: name.to_string(),
1648                arguments: json!({}),
1649            })],
1650        }
1651    }
1652
1653    // assistant Bash message carrying `command`
1654    fn bash(command: &str) -> Message {
1655        Message {
1656            role: Role::Assistant,
1657            content: vec![ContentBlock::ToolCall(ToolCall {
1658                id: String::new(),
1659                name: "Bash".to_string(),
1660                arguments: json!({"command": command}),
1661            })],
1662        }
1663    }
1664
1665    // a tool result message (goes in a user-role message, as in Anthropic's normalised form)
1666    fn tr(text: &str) -> Message {
1667        Message {
1668            role: Role::User,
1669            content: vec![ContentBlock::ToolResult(ToolResult {
1670                tool_call_id: String::new(),
1671                content: vec![ContentBlock::Text {
1672                    text: text.to_string(),
1673                }],
1674                is_error: None,
1675            })],
1676        }
1677    }
1678
1679    #[test]
1680    fn clean_text_has_zero_severity() {
1681        let (sev, patterns) = classify_text("everything went fine");
1682        assert_eq!(sev, 0.0);
1683        assert!(patterns.is_empty());
1684    }
1685
1686    #[test]
1687    fn traceback_is_hard() {
1688        let (sev, patterns) = classify_text("Traceback (most recent call last):\n  ValueError");
1689        assert_eq!(sev, HARD);
1690        assert!(patterns.contains(&"traceback".to_string()));
1691    }
1692
1693    #[test]
1694    fn oom_is_critical() {
1695        let (sev, _) = classify_text("Out of memory: kill process 1234");
1696        assert_eq!(sev, CRITICAL);
1697    }
1698
1699    #[test]
1700    fn connection_refused_is_hard() {
1701        let (severity, _) = classify_text("Connection refused on port 8000");
1702        assert_eq!(severity, HARD);
1703    }
1704
1705    #[test]
1706    fn repeated_failure_ignores_volatile_paths_and_numbers() {
1707        let request = with_messages(vec![
1708            tr("error[E0308]: mismatched types at /tmp/a/src/lib.rs:12"),
1709            tr("error[E0308]: mismatched types at /tmp/b/src/lib.rs:47"),
1710        ]);
1711        assert!(ToolSignals::from_request(&request, None).repeated_failure);
1712    }
1713
1714    #[test]
1715    fn different_failures_are_not_repeated() {
1716        let request = with_messages(vec![
1717            tr("error[E0308]: mismatched types"),
1718            tr("error[E0509]: cannot move out"),
1719        ]);
1720        assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1721    }
1722
1723    #[test]
1724    fn one_material_failure_is_not_repeated() {
1725        let request = with_messages(vec![tr("Connection refused on port 8000")]);
1726        assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1727    }
1728
1729    /// Explicit failures count even without diagnostic text and cannot signal recovery.
1730    #[test]
1731    fn structured_tool_failures_feed_error_and_recovery_signals() {
1732        for text in ["Dependency unavailable", "", "5 passed in 0.12s"] {
1733            let mut failed = tr(text);
1734            let ContentBlock::ToolResult(result) = &mut failed.content[0] else {
1735                panic!("expected tool result");
1736            };
1737            result.is_error = Some(true);
1738            let mut request = with_messages(vec![tr("5 passed in 0.12s"), failed.clone()]);
1739            let signals = ToolSignals::from_request(&request, Some(3));
1740            assert_eq!(signals.severity, HARD);
1741            assert!(!signals.repeated_failure);
1742            assert_eq!(signals.no_error_streak, 0);
1743            assert!(!signals.tests_passed);
1744
1745            request.llm_request.messages.push(failed);
1746            assert!(ToolSignals::from_request(&request, Some(3)).repeated_failure);
1747            request.llm_request.messages.push(tr("5 passed in 0.12s"));
1748            let recovered = ToolSignals::from_request(&request, Some(1));
1749            assert_eq!(recovered.severity, 0.0);
1750            assert!(!recovered.repeated_failure);
1751            assert_eq!(recovered.no_error_streak, 1);
1752            assert!(recovered.tests_passed);
1753        }
1754    }
1755
1756    #[test]
1757    fn severity_is_max_across_patterns() {
1758        // exit_nonzero (SOFT) + traceback (HARD) → HARD.
1759        let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
1760        assert_eq!(sev, HARD);
1761    }
1762
1763    #[test]
1764    fn codex_process_exit_zero_stays_clean() {
1765        let (sev, patterns) =
1766            classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
1767        assert_eq!(sev, 0.0);
1768        assert!(!patterns.contains(&"exit_nonzero".to_string()));
1769    }
1770
1771    #[test]
1772    fn nonzero_exit_codes_are_soft_errors() {
1773        let cases = [
1774            "Process exited with code 1",
1775            "Process exited with code 127",
1776            "exit code: 2",
1777            "exit status 3",
1778            "exited with status 9",
1779        ];
1780        for case in cases {
1781            let (sev, patterns) = classify_text(case);
1782            assert_eq!(sev, SOFT, "expected soft severity for {case}");
1783            assert!(patterns.contains(&"exit_nonzero".to_string()));
1784        }
1785    }
1786
1787    #[test]
1788    fn partial_process_failures_are_hard_errors() {
1789        let cases = [
1790            (
1791                "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
1792                "compile_error",
1793            ),
1794            (
1795                "Process exited with code 0\nOutput:\nTypeError: value is undefined\n    at main.js:1:2",
1796                "runtime_exception",
1797            ),
1798            (
1799                "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
1800                "runtime_panic",
1801            ),
1802            (
1803                "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
1804                "patch_error",
1805            ),
1806        ];
1807        for (text, expected_pattern) in cases {
1808            let (severity, patterns) = classify_text(text);
1809            assert_eq!(severity, HARD, "expected hard severity for {text}");
1810            assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
1811        }
1812    }
1813
1814    #[test]
1815    fn source_text_that_names_exceptions_stays_clean() {
1816        let text =
1817            "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
1818        assert_eq!(classify_text(text).0, 0.0);
1819    }
1820
1821    #[test]
1822    fn file_does_not_exist_is_hard() {
1823        // Claude Code Read-tool miss. Trace-mined addition (22 true / 2 false positives).
1824        let (sev, patterns) =
1825            classify_text("Error: File does not exist. Note: current working directory is /app.");
1826        assert_eq!(sev, HARD);
1827        assert!(patterns.contains(&"no_such_file".to_string()));
1828    }
1829
1830    #[test]
1831    fn bare_does_not_exist_stays_clean() {
1832        // Precision guard: only the anchored "file does not exist" fires, so a bare
1833        // "does not exist" in prose or directory output must not trip a false error.
1834        let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
1835        assert_eq!(sev, 0.0);
1836    }
1837
1838    #[test]
1839    fn no_error_streak_all_clean() {
1840        let texts = vec![("ok".to_string(), false), ("all good".to_string(), false)];
1841        assert_eq!(compute_no_error_streak(&texts), 2);
1842    }
1843
1844    #[test]
1845    fn no_error_streak_stops_at_error() {
1846        let texts = vec![
1847            ("Traceback (most recent call last):".to_string(), false),
1848            ("ok".to_string(), false),
1849            ("ok".to_string(), false),
1850        ];
1851        assert_eq!(compute_no_error_streak(&texts), 2);
1852    }
1853
1854    #[test]
1855    fn tests_passed_detects_pytest_output() {
1856        assert!(detect_tests_passed(
1857            &[("====== 5 passed in 0.12s ======".to_string(), false)],
1858            DEFAULT_RECENT_WINDOW
1859        ));
1860    }
1861
1862    #[test]
1863    fn tests_passed_ignores_partial_failures() {
1864        assert!(!detect_tests_passed(
1865            &[("2 failed, 5 passed in 0.56s".to_string(), false)],
1866            DEFAULT_RECENT_WINDOW
1867        ));
1868    }
1869
1870    #[test]
1871    fn tests_passed_must_follow_the_latest_failure() {
1872        assert!(!detect_tests_passed(
1873            &[
1874                ("5 passed in 0.12s".to_string(), false),
1875                (
1876                    "Traceback (most recent call last):\nValueError".to_string(),
1877                    false
1878                ),
1879                ("edit applied".to_string(), false),
1880            ],
1881            DEFAULT_RECENT_WINDOW
1882        ));
1883        assert!(detect_tests_passed(
1884            &[
1885                (
1886                    "Traceback (most recent call last):\nValueError".to_string(),
1887                    false
1888                ),
1889                ("5 passed in 0.12s".to_string(), false),
1890            ],
1891            DEFAULT_RECENT_WINDOW
1892        ));
1893    }
1894
1895    #[test]
1896    fn retrieved_file_contents_are_ignored() {
1897        let call = |id: &str, name: &str, arguments: Value| Message {
1898            role: Role::Assistant,
1899            content: vec![ContentBlock::ToolCall(ToolCall {
1900                id: id.to_string(),
1901                name: name.to_string(),
1902                arguments,
1903            })],
1904        };
1905        let result = |id: &str, text: &str| Message {
1906            role: Role::User,
1907            content: vec![ContentBlock::ToolResult(ToolResult {
1908                tool_call_id: id.to_string(),
1909                content: vec![ContentBlock::Text {
1910                    text: text.to_string(),
1911                }],
1912                is_error: None,
1913            })],
1914        };
1915        let signal = extract_tool_signals_with_window(
1916            &with_messages(vec![
1917                call("a", "Bash", json!({"command": "pytest"})),
1918                result("a", "Traceback (most recent call last):\nValueError"),
1919                call("b", "Read", json!({"file_path": "notes.md"})),
1920                result("b", "the worker ran out of memory"),
1921                call("c", "Grep", json!({"pattern": "passed"})),
1922                result("c", "CHANGELOG.md: all tests passed"),
1923            ]),
1924            DEFAULT_RECENT_WINDOW,
1925        );
1926        // Only the real pytest run counts.
1927        assert_eq!(signal.severity, HARD);
1928        assert!(!signal.tests_passed);
1929        assert_eq!(signal.tool_result_count, 3);
1930    }
1931
1932    // A request whose tool result answers the tool call.
1933    fn call_with_result(mut call: Message, mut result: Message) -> Request {
1934        if let ContentBlock::ToolCall(call) = &mut call.content[0] {
1935            call.id = "call-1".into();
1936        }
1937        if let ContentBlock::ToolResult(result) = &mut result.content[0] {
1938            result.tool_call_id = "call-1".into();
1939        }
1940        with_messages(vec![call, result])
1941    }
1942
1943    #[test]
1944    fn shell_reads_are_ignored_but_keep_their_window_slot() {
1945        for command in [
1946            "/usr/bin/cat logfile.txt",
1947            "cd /repo && tail -n 100 logfile.txt | grep MemoryError",
1948            "env MODE=debug timeout 5s rg 'MemoryError' .",
1949        ] {
1950            let mut request = call_with_result(bash(command), tr("MemoryError\nall tests passed"));
1951            request.llm_request.messages.splice(
1952                0..0,
1953                [bash("pytest"), tr("Traceback (most recent call last):")],
1954            );
1955            // The read adds no error and no test pass, so the earlier failure stands.
1956            let signal = ToolSignals::from_request(&request, Some(2));
1957            assert_eq!(signal.severity, HARD, "{command}");
1958            assert!(!signal.tests_passed, "{command}");
1959            // The read still fills a window slot, so the failure can age out.
1960            let signal = ToolSignals::from_request(&request, Some(1));
1961            assert_eq!(signal.severity, 0.0, "{command}");
1962        }
1963    }
1964
1965    #[test]
1966    fn shell_commands_that_run_code_keep_signals() {
1967        let oversized = format!("cat {}", "a".repeat(MAX_RETRIEVAL_COMMAND_BYTES));
1968        for command in [
1969            "cat logfile.txt; pytest",
1970            "echo $(pytest)",
1971            "find . -name '*.py' -exec pytest ';'",
1972            oversized.as_str(),
1973        ] {
1974            let request = call_with_result(bash(command), tr("MemoryError"));
1975            let signal = ToolSignals::from_request(&request, None);
1976            assert_eq!(signal.severity, CRITICAL, "{command}");
1977        }
1978    }
1979
1980    #[test]
1981    fn failed_reads_signal_unless_the_file_is_missing() {
1982        let failed = |text: &str| {
1983            let mut result = tr(text);
1984            if let ContentBlock::ToolResult(result) = &mut result.content[0] {
1985                result.is_error = Some(true);
1986            }
1987            result
1988        };
1989        let missing = "missing.txt: No such file or directory";
1990        let severities = [
1991            call_with_result(bash("cat missing.txt"), failed(missing)),
1992            call_with_result(tc("Read"), failed("File does not exist.")),
1993            // A read that fails for another reason still counts.
1994            call_with_result(bash("cat logfile.txt"), failed("MemoryError")),
1995            // Only reads are exempt.
1996            call_with_result(bash("python missing.txt"), failed(missing)),
1997        ]
1998        .map(|request| ToolSignals::from_request(&request, None).severity);
1999        assert_eq!(severities, [0.0, 0.0, CRITICAL, HARD]);
2000    }
2001
2002    #[test]
2003    fn argv_commands_keep_word_boundaries() {
2004        let signal = |argv: Value| {
2005            let call = exec_command(json!({"cmd": argv}));
2006            ToolSignals::from_request(&call_with_result(call, tr("MemoryError")), None)
2007        };
2008        let mut many_args = vec![""; MAX_RETRIEVAL_COMMAND_BYTES];
2009        many_args[0] = "cat";
2010        let severities = [
2011            json!(["bash", "-lc", "cd repo && rg foo"]),
2012            json!(["bash", "-lc", "cat log; pytest"]),
2013            // One argv word is one file name, even with a separator in it.
2014            json!(["cat", "log; pytest"]),
2015            // Over the byte budget, by size and by argument count.
2016            json!(["cat", "a".repeat(MAX_RETRIEVAL_COMMAND_BYTES)]),
2017            json!(many_args),
2018        ]
2019        .map(|argv| signal(argv).severity);
2020        assert_eq!(severities, [0.0, CRITICAL, 0.0, CRITICAL, CRITICAL]);
2021        // A read is an observation, even as a whole `-lc` script.
2022        let read = signal(json!(["bash", "-lc", "sed -n 1,200p x"]));
2023        assert_eq!(read.read_count, 1);
2024        // Other commands are counted inside the script too.
2025        let write = signal(json!(["bash", "-lc", "cd repo && mkdir x"]));
2026        assert_eq!(write.write_count, 1);
2027    }
2028
2029    #[test]
2030    fn severity_is_windowed_over_recent_results() {
2031        // An error two results back, then two clean results.
2032        let request = with_messages(vec![
2033            tr("Traceback (most recent call last):\n  ValueError"),
2034            tr("ok"),
2035            tr("ok"),
2036        ]);
2037        // window covers the error → severity persists (max over the window)
2038        assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
2039        // window of 1 sees only the last (clean) result → severity has decayed out
2040        assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
2041    }
2042
2043    #[test]
2044    fn extract_openai_chat_tool_results() {
2045        let request = with_messages(vec![
2046            Message::text(Role::User, "do something"),
2047            tc("Edit"),
2048            tr("Traceback (most recent call last):\n  ValueError"),
2049        ]);
2050        let sig = ToolSignals::from_request(&request, None);
2051        assert_eq!(sig.severity, HARD);
2052        assert_eq!(sig.edit_count, 1);
2053        assert_eq!(sig.turn_depth, 3);
2054    }
2055
2056    #[test]
2057    fn extract_anthropic_tool_results() {
2058        let request = with_messages(vec![tr("Traceback (most recent call last):\n  ValueError")]);
2059        let sig = ToolSignals::from_request(&request, None);
2060        assert_eq!(sig.severity, HARD);
2061    }
2062
2063    #[test]
2064    fn extract_responses_api_tool_results() {
2065        let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
2066        let sig = ToolSignals::from_request(&request, None);
2067        assert_eq!(sig.severity, 0.0);
2068        assert_eq!(sig.write_count, 1);
2069    }
2070
2071    #[test]
2072    fn responses_builtin_tool_failures_escalate() {
2073        use crate::algorithms::util::stage::{PickOutcome, PickerMode, Tier, pick_tier};
2074
2075        let mut cases = Vec::new();
2076        for (status, output) in [
2077            (
2078                "failed",
2079                "Synthetic dependency unavailable; retry with the recovery path.",
2080            ),
2081            (
2082                "completed",
2083                "Synthetic dependency unavailable; retry with the recovery path.",
2084            ),
2085            ("failed", ""),
2086        ] {
2087            cases.push((
2088                json!({
2089                    "type": "apply_patch_call_output",
2090                    "status": status,
2091                    "output": output,
2092                }),
2093                if status == "failed" { HARD } else { 0.0 },
2094            ));
2095        }
2096        for (outcome, stdout, stderr, severity) in [
2097            (json!({"type": "exit", "exit_code": 1}), "", "", SOFT),
2098            (json!({"type": "exit", "exit_code": 1}), "", " \n", SOFT),
2099            (
2100                json!({"type": "exit", "exit_code": 1}),
2101                "",
2102                "command failed",
2103                HARD,
2104            ),
2105            (json!({"type": "timeout"}), "", "", HARD),
2106            (json!({"type": "exit", "exit_code": 0}), "done", "", 0.0),
2107            (
2108                json!({"type": "exit", "exit_code": 0}),
2109                "Traceback (most recent call last):",
2110                "",
2111                HARD,
2112            ),
2113            (
2114                json!({"type": "exit", "exit_code": 0}),
2115                "",
2116                "Traceback (most recent call last):",
2117                HARD,
2118            ),
2119        ] {
2120            cases.push((
2121                json!({
2122                    "type": "shell_call_output",
2123                    "output": [
2124                        {"stdout": stdout, "stderr": stderr, "outcome": outcome},
2125                        {"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}
2126                    ],
2127                }),
2128                severity,
2129            ));
2130        }
2131        for (raw, severity) in cases {
2132            let is_error = severity >= HARD;
2133            let mut request = with_messages(
2134                ["call_1", "call_2"]
2135                    .into_iter()
2136                    .map(|call_id| {
2137                        let mut raw = raw.clone();
2138                        raw["call_id"] = json!(call_id);
2139                        Message {
2140                            role: Role::User,
2141                            content: vec![ContentBlock::Unknown {
2142                                provider: WireFormat::OpenAiResponses.into(),
2143                                raw,
2144                            }],
2145                        }
2146                    })
2147                    .collect(),
2148            );
2149            let signal = ToolSignals::from_request(&request, Some(3));
2150            assert_eq!(signal.severity, severity, "{raw}");
2151            assert_eq!(signal.repeated_failure, is_error, "{raw}");
2152            assert_eq!(signal.tool_result_count, 2);
2153            assert_eq!(
2154                matches!(
2155                    pick_tier(&signal, PickerMode::EfficientFirst, 0.5),
2156                    PickOutcome::Resolved {
2157                        tier: Tier::Capable,
2158                        ..
2159                    }
2160                ),
2161                is_error,
2162                "{raw}"
2163            );
2164
2165            let mut success = match raw["type"].as_str() {
2166                Some("apply_patch_call_output") => json!({
2167                    "type": "apply_patch_call_output", "status": "completed", "output": ""
2168                }),
2169                Some("shell_call_output") => json!({
2170                    "type": "shell_call_output",
2171                    "output": [{"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}]
2172                }),
2173                _ => unreachable!(),
2174            };
2175            for index in 0..3 {
2176                success["call_id"] = json!(format!("success_{index}"));
2177                request.llm_request.messages.push(Message {
2178                    role: Role::User,
2179                    content: vec![ContentBlock::Unknown {
2180                        provider: WireFormat::OpenAiResponses.into(),
2181                        raw: success.clone(),
2182                    }],
2183                });
2184            }
2185            let recovered = ToolSignals::from_request(&request, Some(3));
2186            assert_eq!(recovered.severity, 0.0, "{raw}");
2187            assert!(!recovered.repeated_failure, "{raw}");
2188            assert!(recovered.no_error_streak >= 3, "{raw}");
2189            assert_eq!(recovered.tool_result_count, 5);
2190        }
2191    }
2192
2193    #[test]
2194    fn conversation_counts_are_per_block_and_role_aware() {
2195        // A batched user message (Anthropic shape) contributes one count per
2196        // ToolResult block, empty-content results included. assistant_turn_count
2197        // tracks Role::Assistant only, while turn_depth counts every message.
2198        let result = |content: Vec<ContentBlock>| {
2199            ContentBlock::ToolResult(ToolResult {
2200                tool_call_id: String::new(),
2201                content,
2202                is_error: None,
2203            })
2204        };
2205        let request = with_messages(vec![
2206            Message::text(Role::User, "do something"),
2207            Message::text(Role::Assistant, "working"),
2208            Message {
2209                role: Role::User,
2210                content: vec![
2211                    result(vec![ContentBlock::Text {
2212                        text: "ok".to_string(),
2213                    }]),
2214                    result(Vec::new()),
2215                ],
2216            },
2217            tc("Bash"),
2218        ]);
2219        let sig = ToolSignals::from_request(&request, None);
2220        assert_eq!(sig.tool_result_count, 2);
2221        assert_eq!(sig.assistant_turn_count, 2);
2222        assert_eq!(sig.turn_depth, 4);
2223    }
2224
2225    #[test]
2226    fn recent_window_counts_only_last_default_window_tool_calls() {
2227        // 5 writes + 1 edit at the end → the default window (3) should see
2228        // the last 3 calls: 1 edit + 2 writes (not all 6 calls).
2229        let request = with_messages(vec![
2230            tc("Write"),
2231            tr("ok"),
2232            tc("Write"),
2233            tr("ok"),
2234            tc("Write"),
2235            tr("ok"),
2236            tc("Write"),
2237            tr("ok"),
2238            tc("Write"),
2239            tr("ok"),
2240            tc("Edit"),
2241            tr("ok"),
2242        ]);
2243        let sig = ToolSignals::from_request(&request, None);
2244        assert_eq!(sig.write_count, 5);
2245        assert_eq!(sig.edit_count, 1);
2246        assert_eq!(sig.recent_write_count, 2);
2247        assert_eq!(sig.recent_edit_count, 1);
2248    }
2249
2250    #[test]
2251    fn codex_apply_patch_counts_as_an_edit() {
2252        let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
2253        let sig = ToolSignals::from_request(&request, None);
2254        assert_eq!(sig.edit_count, 1);
2255        assert_eq!(sig.recent_edit_count, 1);
2256    }
2257
2258    fn exec_command(cmd: Value) -> Message {
2259        Message {
2260            role: Role::Assistant,
2261            content: vec![ContentBlock::ToolCall(ToolCall {
2262                id: String::new(),
2263                name: "exec_command".to_string(),
2264                arguments: cmd,
2265            })],
2266        }
2267    }
2268
2269    #[test]
2270    fn codex_exec_command_is_classified() {
2271        // arguments arrive as a JSON string, with the command under `cmd`
2272        let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
2273        let request = with_messages(vec![exec_command(args), tr("ok")]);
2274        assert_eq!(
2275            ToolSignals::from_request(&request, None).recent_edit_count,
2276            1
2277        );
2278    }
2279
2280    #[test]
2281    fn python_write_expressions_need_a_python_command() {
2282        let write = with_messages(vec![
2283            exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
2284            tr("ok"),
2285        ]);
2286        assert_eq!(
2287            ToolSignals::from_request(&write, None).recent_write_count,
2288            1
2289        );
2290
2291        let search = with_messages(vec![
2292            exec_command(json!({"cmd": "grep -R '.write(' src"})),
2293            tr("ok"),
2294        ]);
2295        assert_eq!(
2296            ToolSignals::from_request(&search, None).recent_write_count,
2297            0
2298        );
2299    }
2300
2301    #[test]
2302    fn recent_window_size_is_caller_overridable() {
2303        // Same six tool calls (1 edit at the end, 5 writes before).
2304        // With recent_window=3 → recent_writes=2, recent_edits=1.
2305        // With recent_window=6 → recent_writes=5, recent_edits=1 (all calls).
2306        let request = with_messages(vec![
2307            tc("Write"),
2308            tr("ok"),
2309            tc("Write"),
2310            tr("ok"),
2311            tc("Write"),
2312            tr("ok"),
2313            tc("Write"),
2314            tr("ok"),
2315            tc("Write"),
2316            tr("ok"),
2317            tc("Edit"),
2318            tr("ok"),
2319        ]);
2320        let narrow = extract_tool_signals_with_window(&request, 3);
2321        assert_eq!(narrow.recent_write_count, 2);
2322        assert_eq!(narrow.recent_edit_count, 1);
2323
2324        let wide = extract_tool_signals_with_window(&request, 6);
2325        assert_eq!(wide.recent_write_count, 5);
2326        assert_eq!(wide.recent_edit_count, 1);
2327    }
2328
2329    #[test]
2330    fn compaction_marker_sets_compacted() {
2331        // The compaction summary is a user message carrying Claude Code's preamble.
2332        let request = with_messages(vec![
2333            Message::text(
2334                Role::User,
2335                "This session is being continued from a previous conversation that ran out of context.",
2336            ),
2337            bash("ls"),
2338        ]);
2339        assert!(ToolSignals::from_request(&request, None).compacted);
2340    }
2341
2342    #[test]
2343    fn codex_compaction_metadata_stays_on_parent_route() {
2344        let mut request = with_messages(vec![bash("ls")]);
2345        request.metadata = Some(Metadata {
2346            is_subagent: true,
2347            agent_kind: Some("compact".to_string()),
2348            ..Default::default()
2349        });
2350        assert!(!ToolSignals::from_request(&request, None).compacted);
2351    }
2352
2353    #[test]
2354    fn no_compaction_marker_stays_uncompacted() {
2355        let request = with_messages(vec![
2356            Message::text(Role::User, "Write a script that parses the log file."),
2357            bash("ls"),
2358        ]);
2359        assert!(!ToolSignals::from_request(&request, None).compacted);
2360    }
2361
2362    #[test]
2363    fn bash_heredoc_counts_as_write() {
2364        // Claude Code's pattern on TB 2.0 — write a scratch file via heredoc.
2365        let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
2366        let sig = ToolSignals::from_request(&request, None);
2367        assert_eq!(
2368            sig.write_count, 1,
2369            "Bash heredoc should bucket into write_count"
2370        );
2371        assert_eq!(sig.edit_count, 0);
2372    }
2373
2374    #[test]
2375    fn bash_sed_inplace_counts_as_edit() {
2376        let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
2377        let sig = ToolSignals::from_request(&request, None);
2378        assert_eq!(
2379            sig.edit_count, 1,
2380            "Bash sed -i should bucket into edit_count"
2381        );
2382        assert_eq!(sig.write_count, 0);
2383    }
2384
2385    #[test]
2386    fn bash_non_mutating_does_not_count() {
2387        // ls, cat, grep — should not increment either counter.
2388        let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
2389        let sig = ToolSignals::from_request(&request, None);
2390        assert_eq!(sig.write_count, 0);
2391        assert_eq!(sig.edit_count, 0);
2392    }
2393
2394    #[test]
2395    fn tests_passed_detects_pytest_with_failure_block() {
2396        // Mixed pytest run: 2 failed + 5 passed → NOT considered tests_passed.
2397        assert!(!detect_tests_passed(
2398            &[("2 failed, 5 passed in 0.56s".to_string(), false)],
2399            DEFAULT_RECENT_WINDOW
2400        ));
2401    }
2402
2403    #[test]
2404    fn tests_passed_accepts_cargo_clean_summary() {
2405        // Cargo's clean-run summary contains "0 failed" — must not trip the
2406        // failure list (regression: previously substring-matched "failed").
2407        assert!(detect_tests_passed(
2408            &[(
2409                "running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string(),
2410                false
2411            )],
2412            DEFAULT_RECENT_WINDOW
2413        ));
2414    }
2415
2416    #[test]
2417    fn tests_passed_rejects_cargo_real_failure() {
2418        // Cargo's actual-failure summary: nonzero count before "failed".
2419        assert!(!detect_tests_passed(
2420            &[(
2421                "running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string(),
2422                false
2423            )],
2424            DEFAULT_RECENT_WINDOW
2425        ));
2426    }
2427
2428    #[test]
2429    fn tests_passed_accepts_go_clean_summary() {
2430        // Go test's clean-run "0 errors" must not trip (regression).
2431        assert!(detect_tests_passed(
2432            &[(
2433                "ok  github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string(),
2434                false
2435            )],
2436            DEFAULT_RECENT_WINDOW
2437        ));
2438    }
2439
2440    #[test]
2441    fn tests_passed_accepts_pytest_zero_errors() {
2442        // Pytest long-form: "0 errors in 0.3s" on a clean run.
2443        assert!(detect_tests_passed(
2444            &[("5 passed, 0 errors in 0.30s".to_string(), false)],
2445            DEFAULT_RECENT_WINDOW
2446        ));
2447    }
2448
2449    #[test]
2450    fn tests_passed_detects_diy_checkmark() {
2451        assert!(detect_tests_passed(
2452            &[("✓ all checks passed".to_string(), false)],
2453            DEFAULT_RECENT_WINDOW
2454        ));
2455    }
2456
2457    #[test]
2458    fn anthropic_bash_heredoc_extracts_command() {
2459        // Anthropic format: tool_use.input is an object, not a JSON string.
2460        let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
2461        let sig = ToolSignals::from_request(&request, None);
2462        assert_eq!(
2463            sig.write_count, 1,
2464            "Anthropic Bash heredoc must also be detected"
2465        );
2466    }
2467
2468    #[test]
2469    fn recent_window_falls_back_to_full_history_when_short() {
2470        let request = with_messages(vec![tc("Write")]);
2471        let sig = ToolSignals::from_request(&request, None);
2472        assert_eq!(sig.recent_write_count, 1);
2473        assert_eq!(sig.recent_edit_count, 0);
2474    }
2475
2476    #[test]
2477    fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
2478        let request = with_messages(vec![tr("output ok"), tr("another ok")]);
2479        let sig = ToolSignals::from_request(&request, None);
2480        assert_eq!(sig.severity, 0.0);
2481        assert_eq!(sig.no_error_streak, 2);
2482    }
2483
2484    // ─── asymmetric-signal extensions ────────────────────────────────────
2485
2486    #[test]
2487    fn todowrite_classifies_as_plan() {
2488        assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
2489        assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
2490    }
2491
2492    #[test]
2493    fn codex_update_plan_classifies_as_plan() {
2494        assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
2495    }
2496
2497    #[test]
2498    fn codex_shell_command_runs_bash_pattern_match() {
2499        // shell_command + heredoc -> Write.
2500        assert_eq!(
2501            classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
2502            ToolSemantic::Mutate(MutationKind::Write),
2503        );
2504        // shell_command + read-like inspection -> Read.
2505        assert_eq!(
2506            classify_tool_call("shell_command", Some("ls /app")),
2507            ToolSemantic::Observe,
2508        );
2509        // shell_command without matching patterns -> Unknown.
2510        assert_eq!(
2511            classify_tool_call("shell_command", Some("./run_tests.sh")),
2512            ToolSemantic::Unknown,
2513        );
2514    }
2515
2516    #[test]
2517    fn text_editor_view_is_a_read() {
2518        for name in ["str_replace_based_edit_tool", "text_editor"] {
2519            assert_eq!(
2520                classify_tool_call(name, Some("view")),
2521                ToolSemantic::Observe
2522            );
2523            for command in [
2524                Some("create"),
2525                Some("insert"),
2526                Some("str_replace"),
2527                Some("undo_edit"),
2528                None,
2529            ] {
2530                assert_eq!(
2531                    classify_tool_call(name, command),
2532                    ToolSemantic::Mutate(MutationKind::Edit),
2533                );
2534            }
2535        }
2536
2537        let arguments = [
2538            json!({"command": "view", "path": "/app/main.py"}),
2539            // the Responses wire format sends arguments as a JSON string
2540            json!(r#"{"command":"view","path":"/app/main.py"}"#),
2541        ];
2542        for arguments in arguments {
2543            let call = Message {
2544                role: Role::Assistant,
2545                content: vec![ContentBlock::ToolCall(ToolCall {
2546                    id: String::new(),
2547                    name: "str_replace_based_edit_tool".to_string(),
2548                    arguments,
2549                })],
2550            };
2551            let request = with_messages(vec![call, tr("print('hi')")]);
2552            let sig = ToolSignals::from_request(&request, None);
2553            assert_eq!(sig.read_count, 1);
2554            assert_eq!(sig.recent_read_count, 1);
2555            assert_eq!(sig.edit_count, 0);
2556        }
2557    }
2558
2559    #[test]
2560    fn read_tool_classifies_as_read() {
2561        assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
2562        assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
2563    }
2564
2565    #[test]
2566    fn hermes_tool_names_classify() {
2567        // Hermes (NousResearch) file tools route by name.
2568        assert_eq!(
2569            classify_tool_call("write_file", None),
2570            ToolSemantic::Mutate(MutationKind::Write)
2571        );
2572        assert_eq!(
2573            classify_tool_call("patch", None),
2574            ToolSemantic::Mutate(MutationKind::Edit)
2575        );
2576        assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
2577        assert_eq!(
2578            classify_tool_call("search_files", None),
2579            ToolSemantic::Observe
2580        );
2581        // Hermes runs shell through `terminal`, which carries a `command` arg,
2582        // so its intent comes from the Bash-pattern match like codex's shell_command.
2583        assert_eq!(
2584            classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
2585            ToolSemantic::Mutate(MutationKind::Edit),
2586        );
2587        assert_eq!(
2588            classify_tool_call("terminal", Some("grep foo /app")),
2589            ToolSemantic::Observe,
2590        );
2591        assert_eq!(
2592            classify_tool_call("terminal", Some("./run_tests.sh")),
2593            ToolSemantic::Unknown,
2594        );
2595    }
2596
2597    #[test]
2598    fn bash_read_patterns_classify_as_read() {
2599        let cases = [
2600            "cat /etc/passwd",
2601            "grep foo bar.txt",
2602            "ls /app",
2603            "find . -name '*.py'",
2604        ];
2605        for cmd in cases {
2606            assert_eq!(
2607                classify_tool_call("Bash", Some(cmd)),
2608                ToolSemantic::Observe,
2609                "expected Read for {cmd}"
2610            );
2611        }
2612    }
2613
2614    #[test]
2615    fn codex_inspection_commands_classify_as_read() {
2616        let cases = [
2617            "sed -n '1,80p' src/lib.rs",
2618            "rg -n 'needle' src",
2619            "nl -ba src/lib.rs",
2620            "cat package.json",
2621            "jq '.scripts' package.json",
2622            "git status --short",
2623            "git log --oneline -5",
2624            "git show HEAD:src/lib.rs",
2625            "git branch --show-current",
2626            "git remote -v",
2627            "git config --get remote.origin.url",
2628        ];
2629        for command in cases {
2630            assert_eq!(
2631                classify_tool_call("exec_command", Some(command)),
2632                ToolSemantic::Observe,
2633                "expected Read for {command}"
2634            );
2635        }
2636    }
2637
2638    #[test]
2639    fn quoted_shell_separators_do_not_create_commands() {
2640        for command in ["rg 'foo|rm obsolete.rs'", "rg \"foo; rm obsolete.rs\""] {
2641            assert_eq!(
2642                classify_tool_call("exec_command", Some(command)),
2643                ToolSemantic::Observe,
2644                "quoted text must not be parsed as a command: {command}"
2645            );
2646        }
2647    }
2648
2649    #[test]
2650    fn codex_shell_mutations_classify_as_production() {
2651        let writes = [
2652            "cp source.rs destination.rs",
2653            "mkdir -p src/generated",
2654            "touch src/generated/mod.rs",
2655            "git show HEAD:file.rs > file.rs",
2656            "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
2657        ];
2658        for command in writes {
2659            assert_eq!(
2660                classify_tool_call("exec_command", Some(command)),
2661                ToolSemantic::Mutate(MutationKind::Write),
2662                "expected Write for {command}"
2663            );
2664        }
2665
2666        let edits = [
2667            "mv old.rs new.rs",
2668            "rm obsolete.rs",
2669            "gofmt -w main.go",
2670            "cargo fmt",
2671            "ruff check --fix src",
2672            "perl -0pi -e 's/old/new/' src/lib.rs",
2673            "npx prettier --write src/lib.ts",
2674            "uv run ruff format src",
2675            "git apply fix.patch",
2676        ];
2677        for command in edits {
2678            assert_eq!(
2679                classify_tool_call("exec_command", Some(command)),
2680                ToolSemantic::Mutate(MutationKind::Edit),
2681                "expected Edit for {command}"
2682            );
2683        }
2684    }
2685
2686    #[test]
2687    fn formatter_checks_are_not_edits() {
2688        for command in [
2689            "cargo fmt --check",
2690            "ruff format --check src",
2691            "black --check src",
2692        ] {
2693            assert_ne!(
2694                classify_tool_call("exec_command", Some(command)),
2695                ToolSemantic::Mutate(MutationKind::Edit),
2696                "read-only formatter check must not be Edit: {command}"
2697            );
2698        }
2699    }
2700
2701    #[test]
2702    fn embedded_comparison_is_not_a_shell_write() {
2703        let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
2704        assert_eq!(
2705            classify_tool_call("exec_command", Some(command)),
2706            ToolSemantic::Unknown
2707        );
2708    }
2709
2710    #[test]
2711    fn bash_write_precedence_over_read() {
2712        // `cat /file > out` contains both `cat /` (read) and ` > ` (write);
2713        // write redirection must win.
2714        assert_eq!(
2715            classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
2716            ToolSemantic::Mutate(MutationKind::Write),
2717        );
2718    }
2719
2720    #[test]
2721    fn pure_bash_streak_counts_trailing_other() {
2722        // 5 trailing non-classified Bash calls → streak == 5.
2723        let request = with_messages(vec![
2724            bash("make"),
2725            tr("ok"),
2726            bash("./configure"),
2727            tr("ok"),
2728            bash("make install"),
2729            tr("ok"),
2730            bash("./run.sh"),
2731            tr("ok"),
2732            bash("./test"),
2733            tr("ok"),
2734        ]);
2735        let sig = ToolSignals::from_request(&request, None);
2736        assert_eq!(sig.pure_bash_streak, 5);
2737        assert_eq!(sig.write_count, 0);
2738        assert_eq!(sig.read_count, 0);
2739    }
2740
2741    #[test]
2742    fn pure_bash_streak_resets_on_write() {
2743        let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
2744        let sig = ToolSignals::from_request(&request, None);
2745        assert_eq!(sig.pure_bash_streak, 0);
2746        assert_eq!(sig.write_count, 1);
2747    }
2748
2749    #[test]
2750    fn recent_window_tracks_todowrite_and_read() {
2751        // Final 3 tool calls: TodoWrite, Read, TodoWrite.
2752        let request = with_messages(vec![
2753            bash("make"),
2754            tr("ok"),
2755            tc("TodoWrite"),
2756            tr("ok"),
2757            tc("Read"),
2758            tr("ok"),
2759            tc("TodoWrite"),
2760            tr("ok"),
2761        ]);
2762        let sig = ToolSignals::from_request(&request, None);
2763        assert_eq!(sig.todowrite_count, 2);
2764        assert_eq!(sig.recent_todowrite_count, 2);
2765        assert_eq!(sig.read_count, 1);
2766        assert_eq!(sig.recent_read_count, 1);
2767    }
2768
2769    #[test]
2770    fn configured_tool_semantics_extend_the_builtin_vocabulary() {
2771        let semantics = ToolSemantics {
2772            observe: vec!["KB_search".to_string()],
2773            mutate: vec!["send_payment_request".to_string()],
2774            plan: vec!["create_research_plan".to_string()],
2775            new: vec!["send_message_to_user".to_string()],
2776        };
2777        semantics.validate().expect("valid additive semantics");
2778        let request = with_messages(vec![
2779            tc("Read"),
2780            tc("Write"),
2781            tc("TodoWrite"),
2782            tc("kb_SEARCH"),
2783            tc("send_payment_request"),
2784            tc("create_research_plan"),
2785            tc("send_message_to_user"),
2786            tc("unlisted_tool"),
2787        ]);
2788
2789        let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
2790
2791        assert_eq!(signal.read_count, 2);
2792        assert_eq!(signal.write_count, 2);
2793        assert_eq!(signal.todowrite_count, 2);
2794        assert_eq!(signal.new_count, 1);
2795        assert_eq!(signal.recent_new_count, 1);
2796        assert_eq!(signal.pure_bash_streak, 1);
2797    }
2798
2799    #[test]
2800    fn configured_tool_semantics_match_namespaced_and_mcp_tools() {
2801        // The Responses decoder flattens namespaced tools and records the mapping.
2802        let mut request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
2803        request.llm_request.extensions.fields.insert(
2804            TOOL_NAMESPACES_KEY.to_string(),
2805            json!({"mcp__billing__send_payment_request": "mcp__billing"}),
2806        );
2807
2808        // Claude Code sends MCP tools flat, with no namespace mapping.
2809        let claude_request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
2810
2811        for request in [&request, &claude_request] {
2812            for name in ["send_payment_request", "mcp__billing__send_payment_request"] {
2813                let semantics = ToolSemantics {
2814                    mutate: vec![name.to_string()],
2815                    ..Default::default()
2816                };
2817                let signal = ToolSignals::from_request_with_semantics(request, None, &semantics);
2818                assert_eq!(signal.write_count, 1, "{name}");
2819            }
2820        }
2821    }
2822
2823    #[test]
2824    fn configured_tool_semantics_only_fold_ascii_case() {
2825        let semantics = ToolSemantics {
2826            observe: vec!["kb_search".to_string()],
2827            ..Default::default()
2828        };
2829
2830        assert_eq!(
2831            classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2832            ToolSemantic::Observe
2833        );
2834        // U+212A lowercases to ASCII `k` under Unicode rules, but custom names
2835        // intentionally ignore only ASCII case.
2836        assert_eq!(
2837            classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2838            ToolSemantic::Unknown
2839        );
2840    }
2841
2842    #[test]
2843    fn custom_semantics_preserve_builtin_unicode_lowercasing() {
2844        let semantics = ToolSemantics {
2845            observe: vec!["lookup_customer".to_string()],
2846            ..Default::default()
2847        };
2848
2849        // This matched the built-in `notebookedit` before custom semantics existed.
2850        assert_eq!(
2851            classify_tool_call_with_semantics("notebooKedit", None, &semantics),
2852            ToolSemantic::Mutate(MutationKind::Edit)
2853        );
2854    }
2855
2856    #[test]
2857    fn configured_semantics_never_replace_builtin_classifications() {
2858        let semantics = ToolSemantics {
2859            observe: vec!["lookup_customer".to_string()],
2860            mutate: vec!["send_payment".to_string()],
2861            plan: vec!["create_workflow".to_string()],
2862            new: vec!["send_message".to_string()],
2863        };
2864
2865        for name in WRITE_TOOL_NAMES {
2866            assert_eq!(
2867                classify_tool_call_with_semantics(name, None, &semantics),
2868                ToolSemantic::Mutate(MutationKind::Write),
2869                "write tool {name:?} changed classification"
2870            );
2871        }
2872        for name in EDIT_TOOL_NAMES {
2873            assert_eq!(
2874                classify_tool_call_with_semantics(name, None, &semantics),
2875                ToolSemantic::Mutate(MutationKind::Edit),
2876                "edit tool {name:?} changed classification"
2877            );
2878        }
2879        for name in READ_TOOL_NAMES {
2880            assert_eq!(
2881                classify_tool_call_with_semantics(name, None, &semantics),
2882                ToolSemantic::Observe,
2883                "read tool {name:?} changed classification"
2884            );
2885        }
2886        for name in PLAN_TOOL_NAMES {
2887            assert_eq!(
2888                classify_tool_call_with_semantics(name, None, &semantics),
2889                ToolSemantic::Plan,
2890                "plan tool {name:?} changed classification"
2891            );
2892        }
2893
2894        for (command, expected) in [
2895            ("cat /tmp/input", ToolSemantic::Observe),
2896            (
2897                "cat /tmp/input > /tmp/output",
2898                ToolSemantic::Mutate(MutationKind::Write),
2899            ),
2900            (
2901                "sed -i 's/a/b/' /tmp/file",
2902                ToolSemantic::Mutate(MutationKind::Edit),
2903            ),
2904            ("./run_tests.sh", ToolSemantic::Unknown),
2905        ] {
2906            assert_eq!(
2907                classify_tool_call_with_semantics("BASH", Some(command), &semantics),
2908                expected,
2909                "bash command {command:?} changed classification"
2910            );
2911        }
2912    }
2913
2914    #[test]
2915    fn configured_semantics_score_like_their_builtin_equivalents() {
2916        let semantics = ToolSemantics {
2917            observe: vec!["lookup_customer".to_string()],
2918            mutate: vec!["send_payment".to_string()],
2919            plan: vec!["create_workflow".to_string()],
2920            ..Default::default()
2921        };
2922
2923        for (builtin, configured) in [
2924            ("Read", "lookup_customer"),
2925            ("Write", "send_payment"),
2926            ("TodoWrite", "create_workflow"),
2927        ] {
2928            let messages_before_tool = || {
2929                vec![
2930                    Message::text(Role::User, "start"),
2931                    Message::text(Role::Assistant, "working"),
2932                    Message::text(Role::User, "continue"),
2933                    Message::text(Role::Assistant, "working"),
2934                    Message::text(Role::User, "continue"),
2935                    Message::text(Role::Assistant, "working"),
2936                    Message::text(Role::User, "continue"),
2937                ]
2938            };
2939            let mut builtin_messages = messages_before_tool();
2940            builtin_messages.push(tc(builtin));
2941            let mut configured_messages = messages_before_tool();
2942            configured_messages.push(tc(configured));
2943
2944            let builtin_score = score_signal(&ToolSignals::from_request(
2945                &with_messages(builtin_messages),
2946                None,
2947            ));
2948            let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
2949                &with_messages(configured_messages),
2950                None,
2951                &semantics,
2952            ));
2953
2954            assert_ne!(
2955                builtin_score.score, 0.0,
2956                "the {builtin:?} control must exercise a scoring dimension"
2957            );
2958            assert_eq!(
2959                configured_score, builtin_score,
2960                "configured tool {configured:?} must score exactly like {builtin:?}"
2961            );
2962        }
2963    }
2964
2965    #[test]
2966    fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
2967        let duplicate = ToolSemantics {
2968            observe: vec!["lookup".to_string()],
2969            mutate: vec!["LOOKUP".to_string()],
2970            ..Default::default()
2971        };
2972        assert!(
2973            duplicate
2974                .validate()
2975                .expect_err("duplicate should fail")
2976                .to_string()
2977                .contains("appears in both")
2978        );
2979
2980        let builtin = ToolSemantics {
2981            new: vec!["write_file".to_string()],
2982            ..Default::default()
2983        };
2984        assert!(
2985            builtin
2986                .validate()
2987                .expect_err("built-in should fail")
2988                .to_string()
2989                .contains("built-in semantics")
2990        );
2991
2992        let empty = ToolSemantics {
2993            observe: vec![" \t".to_string()],
2994            ..Default::default()
2995        };
2996        assert!(
2997            empty
2998                .validate()
2999                .expect_err("empty name should fail")
3000                .to_string()
3001                .contains("empty tool name")
3002        );
3003    }
3004}