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