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 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                        // Codex reports the exit status in a header before `Output:`.
1143                        // An exit line after it is part of what was read.
1144                        let status = text
1145                            .split_once("\nOutput:\n")
1146                            .map_or(text.as_str(), |(header, _)| header);
1147                        // Hermes reports it in fields, so its output is not scanned.
1148                        let shell_failed = match &shell_result {
1149                            Some(value) => {
1150                                value["exit_code"].as_i64().is_some_and(|code| code != 0)
1151                                    || value["success"].as_bool() == Some(false)
1152                                    || value["error"]
1153                                        .as_str()
1154                                        .is_some_and(|error| !error.trim().is_empty())
1155                            }
1156                            None => has_nonzero_exit_status(&status.to_lowercase()),
1157                        };
1158                        if is_missing_file(&text) || (!is_error && !shell_failed) {
1159                            // Keep the window slot after dropping retrieved content.
1160                            if !text.is_empty() {
1161                                tool_texts.push((String::new(), false));
1162                            }
1163                            continue;
1164                        }
1165                    }
1166                    // An explicit failure remains a signal even without text.
1167                    if !text.is_empty() || is_error {
1168                        tool_texts.push((text, is_error));
1169                    }
1170                }
1171                // Built-in tool history stays opaque so it can be replayed unchanged.
1172                ContentBlock::Unknown { provider, raw }
1173                    if provider.as_str() == WireFormat::OpenAiResponses.as_str()
1174                        && raw.get("type").and_then(Value::as_str)
1175                            == Some("apply_patch_call_output") =>
1176                {
1177                    tool_result_count += 1;
1178                    let text = raw
1179                        .get("output")
1180                        .and_then(Value::as_str)
1181                        .unwrap_or_default();
1182                    let is_error = raw.get("status").and_then(Value::as_str) == Some("failed");
1183                    tool_texts.push((text.to_owned(), is_error));
1184                }
1185                ContentBlock::Unknown { provider, raw }
1186                    if provider.as_str() == WireFormat::OpenAiResponses.as_str()
1187                        && raw.get("type").and_then(Value::as_str) == Some("shell_call_output") =>
1188                {
1189                    tool_result_count += 1;
1190                    let mut texts = Vec::new();
1191                    let mut is_error = false;
1192                    if let Some(outputs) = raw.get("output").and_then(Value::as_array) {
1193                        for output in outputs {
1194                            for field in ["stdout", "stderr"] {
1195                                if let Some(text) = output.get(field).and_then(Value::as_str)
1196                                    && !text.is_empty()
1197                                {
1198                                    texts.push(text);
1199                                }
1200                            }
1201                            if let Some(outcome) = output.get("outcome") {
1202                                is_error |= match outcome.get("type").and_then(Value::as_str) {
1203                                    Some("timeout") => true,
1204                                    Some("exit")
1205                                        if outcome
1206                                            .get("exit_code")
1207                                            .and_then(Value::as_i64)
1208                                            .is_some_and(|code| code != 0) =>
1209                                    {
1210                                        // Keep plain nonzero exits SOFT, including empty output.
1211                                        texts.push("returned non-zero");
1212                                        output
1213                                            .get("stderr")
1214                                            .and_then(Value::as_str)
1215                                            .is_some_and(|text| !text.trim().is_empty())
1216                                    }
1217                                    _ => false,
1218                                };
1219                            }
1220                        }
1221                    }
1222                    let text = texts.join("\n");
1223                    tool_texts.push((text, is_error));
1224                }
1225                // Compaction is detected anywhere in the conversation: the summary
1226                // stays in the prefix on every later turn, so this self-latches
1227                // once it fires.
1228                ContentBlock::Text { text } => {
1229                    let text = text.to_lowercase();
1230                    compacted |= text.contains(COMPACTION_MARKER)
1231                        || text.lines().any(|line| {
1232                            HERMES_COMPACTION_MARKERS
1233                                .iter()
1234                                .any(|marker| line.trim_start().starts_with(marker))
1235                        });
1236                }
1237                _ => {}
1238            }
1239        }
1240    }
1241
1242    let mut signal = build_signal(
1243        tool_texts,
1244        tool_calls,
1245        messages.len() as u32,
1246        recent_window,
1247        semantics,
1248    );
1249    signal.compacted = compacted;
1250    signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
1251    signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
1252    signal
1253}
1254
1255/// Distinctive preamble Claude Code injects as a user message when it compacts an
1256/// overflowed context. Matched case-insensitively; normal task text never contains it.
1257const COMPACTION_MARKER: &str = "session is being continued";
1258
1259// Hermes can merge the summary into an existing message or restate an active task.
1260const HERMES_COMPACTION_MARKERS: &[&str] = &[
1261    "[context compaction \u{2014} reference only]",
1262    "[still in progress \u{2014} this is the active request, restated after the compaction boundary",
1263];
1264
1265/// The tool part of an `mcp__<server>__<tool>` name, the form Claude Code uses
1266/// for MCP tools. The server name is assumed not to contain `__`; the tool name
1267/// may.
1268fn mcp_tool_name(name: &str) -> Option<&str> {
1269    let (_server, tool) = name.strip_prefix("mcp__")?.split_once("__")?;
1270    (!tool.is_empty()).then_some(tool)
1271}
1272
1273/// The command field a tool call carries, when it has one. Harnesses name it
1274/// `command`, `cmd` or `input`; anything else is a tool whose category comes
1275/// from its name.
1276fn command_of(arguments: &Value) -> Option<&Value> {
1277    ["command", "cmd", "input"]
1278        .iter()
1279        .find_map(|key| arguments.get(*key))
1280}
1281
1282/// A command field as lowercase text, from a string or an argv array.
1283fn command_text(value: &Value) -> Option<String> {
1284    match value {
1285        Value::String(text) => Some(text.to_lowercase()),
1286        Value::Array(parts) => {
1287            let joined = parts
1288                .iter()
1289                .filter_map(Value::as_str)
1290                .collect::<Vec<_>>()
1291                .join(" ");
1292            (!joined.is_empty()).then(|| joined.to_lowercase())
1293        }
1294        _ => None,
1295    }
1296}
1297
1298/// Text carried by a content block, ignoring the non-textual kinds.
1299fn text_of(block: &ContentBlock) -> Option<&str> {
1300    match block {
1301        ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
1302        _ => None,
1303    }
1304}
1305
1306/// Reads of missing paths happen as often on tasks that succeed as on tasks
1307/// that fail, so they are not a failure signal.
1308fn is_missing_file(text: &str) -> bool {
1309    let lower = text.to_lowercase();
1310    // The OS error from shell tools, and Claude Code's Read tool message.
1311    lower.contains("no such file or directory") || lower.contains("file does not exist")
1312}
1313
1314fn build_signal(
1315    tool_texts: Vec<(String, bool)>,
1316    tool_calls: Vec<ObservedToolCall>,
1317    turn_depth: u32,
1318    recent_window: usize,
1319    semantics: &ToolSemantics,
1320) -> ToolSignals {
1321    // Windowed severity: take the MAX severity across the last `recent_window` tool
1322    // results rather than only the last one. An error's severity then persists for
1323    // the recent window and decays out of it — parallel to the windowed `recent_*`
1324    // counts — so a fix written a couple of turns after an error still routes on the
1325    // error signal instead of the router flapping straight back to the weak tier.
1326    let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
1327    let mut severity = 0.0f32;
1328    let mut failure_fingerprints = Vec::new();
1329    let mut repeated_failure = false;
1330    for (text, is_error) in &tool_texts[sev_start..] {
1331        let (sev, _patterns) = classify_text(text);
1332        // Explicit failure is at least hard; retain stronger text diagnostics.
1333        let sev = if *is_error { sev.max(HARD) } else { sev };
1334        if sev > severity {
1335            severity = sev;
1336        }
1337        if let Some(fingerprint) = failure_fingerprint(text, *is_error) {
1338            repeated_failure |= failure_fingerprints.contains(&fingerprint);
1339            failure_fingerprints.push(fingerprint);
1340        }
1341    }
1342
1343    let no_error_streak = compute_no_error_streak(&tool_texts);
1344
1345    // Single pass: cumulative + sliding-window counters together. Also tracks
1346    // the trailing pure-bash streak (consecutive `Unknown` calls back
1347    // from the end) — the build-pit proxy.
1348    let recent_start = tool_calls.len().saturating_sub(recent_window);
1349    let mut write_count = 0u32;
1350    let mut edit_count = 0u32;
1351    let mut read_count = 0u32;
1352    let mut todowrite_count = 0u32;
1353    let mut recent_write_count = 0u32;
1354    let mut recent_edit_count = 0u32;
1355    let mut recent_read_count = 0u32;
1356    let mut recent_todowrite_count = 0u32;
1357    let mut new_count = 0u32;
1358    let mut recent_new_count = 0u32;
1359    let mut pure_bash_streak = 0u32;
1360    let mut streak_open = true;
1361    for (i, tc) in tool_calls.iter().enumerate().rev() {
1362        // Every read, found or missed, is an observation. The read check parses
1363        // the command, so it beats the substring patterns. Otherwise the joined
1364        // name wins, so configs that list it keep working.
1365        let mut cat = if let Some(semantic) = tc.code_semantic {
1366            semantic
1367        } else if tc.is_retrieval {
1368            ToolSemantic::Observe
1369        } else {
1370            classify_tool_call_with_semantics(&tc.name, tc.command.as_deref(), semantics)
1371        };
1372        if matches!(cat, ToolSemantic::Unknown)
1373            && tc.code_semantic.is_none()
1374            && let Some(bare_name) = tc.bare_name
1375        {
1376            cat = classify_tool_call_with_semantics(bare_name, tc.command.as_deref(), semantics);
1377        }
1378        if streak_open {
1379            if matches!(cat, ToolSemantic::Unknown) {
1380                pure_bash_streak += 1;
1381            } else {
1382                streak_open = false;
1383            }
1384        }
1385        match cat {
1386            ToolSemantic::Mutate(MutationKind::Write) => {
1387                write_count += 1;
1388                if i >= recent_start {
1389                    recent_write_count += 1;
1390                }
1391            }
1392            ToolSemantic::Mutate(MutationKind::Edit) => {
1393                edit_count += 1;
1394                if i >= recent_start {
1395                    recent_edit_count += 1;
1396                }
1397            }
1398            ToolSemantic::Observe => {
1399                read_count += 1;
1400                if i >= recent_start {
1401                    recent_read_count += 1;
1402                }
1403            }
1404            ToolSemantic::Plan => {
1405                todowrite_count += 1;
1406                if i >= recent_start {
1407                    recent_todowrite_count += 1;
1408                }
1409            }
1410            ToolSemantic::New => {
1411                new_count += 1;
1412                if i >= recent_start {
1413                    recent_new_count += 1;
1414                }
1415            }
1416            ToolSemantic::Unknown => {}
1417        }
1418    }
1419
1420    let tests_passed = detect_tests_passed(&tool_texts, recent_window);
1421
1422    ToolSignals {
1423        severity,
1424        repeated_failure,
1425        no_error_streak,
1426        edit_count,
1427        write_count,
1428        read_count,
1429        todowrite_count,
1430        recent_edit_count,
1431        recent_write_count,
1432        recent_read_count,
1433        recent_todowrite_count,
1434        new_count,
1435        recent_new_count,
1436        pure_bash_streak,
1437        tests_passed,
1438        turn_depth,
1439        // Set by extract_tool_signals_with_window after the format-specific extract,
1440        // which scans all message contents for the compaction marker and tallies
1441        // the raw conversation-shape counts.
1442        tool_result_count: 0,
1443        assistant_turn_count: 0,
1444        compacted: false,
1445    }
1446}
1447
1448// ─── pure helpers ─────────────────────────────────────────────────────────────
1449
1450/// Normalise a JSON tool-result content value to a plain string.
1451fn content_to_text(content: Option<&Value>) -> Option<String> {
1452    match content? {
1453        Value::String(s) => Some(s.clone()),
1454        Value::Array(blocks) => {
1455            let parts: Vec<&str> = blocks
1456                .iter()
1457                .filter_map(|b| {
1458                    b.as_object()
1459                        .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
1460                        .and_then(|o| o.get("text"))
1461                        .and_then(Value::as_str)
1462                })
1463                .collect();
1464            if parts.is_empty() {
1465                None
1466            } else {
1467                Some(parts.join("\n"))
1468            }
1469        }
1470        _ => None,
1471    }
1472}
1473
1474/// Match tool text and structured result fields against error patterns.
1475///
1476/// Returns `(max_severity, matched_pattern_names)`.
1477pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
1478    let lower = text.to_lowercase();
1479    let mut patterns = Vec::new();
1480    let mut severity: f32 = 0.0;
1481    for (name, sev, substrings) in ERROR_PATTERNS {
1482        if substrings.iter().any(|sub| lower.contains(sub)) {
1483            patterns.push(name.to_string());
1484            severity = severity.max(*sev);
1485        }
1486    }
1487    // Hermes can append a loop warning after the JSON result.
1488    let result = serde_json::Deserializer::from_str(text)
1489        .into_iter::<Value>()
1490        .next()
1491        .and_then(|result| result.ok())
1492        .unwrap_or_default();
1493    let nonzero_exit = result["exit_code"].as_i64().is_some_and(|code| code != 0);
1494    let tool_error = result["success"].as_bool() == Some(false)
1495        || result["error"]
1496            .as_str()
1497            .is_some_and(|error| !error.trim().is_empty());
1498    if (nonzero_exit || has_nonzero_exit_status(&lower))
1499        && !patterns.iter().any(|p| p == "exit_nonzero")
1500    {
1501        patterns.push("exit_nonzero".to_string());
1502        severity = severity.max(SOFT);
1503    }
1504    for (name, matched) in [
1505        ("tool_error", tool_error),
1506        ("compile_error", has_compiler_diagnostic(&lower)),
1507        ("runtime_exception", has_runtime_exception(&lower)),
1508        ("runtime_panic", has_runtime_panic(&lower)),
1509        ("patch_error", has_patch_failure(&lower)),
1510    ] {
1511        if matched && !patterns.iter().any(|pattern| pattern == name) {
1512            patterns.push(name.to_string());
1513            severity = severity.max(HARD);
1514        }
1515    }
1516    (severity, patterns)
1517}
1518
1519/// Stable identity for a material failure. Soft non-zero exits need an explicit
1520/// failure flag to count as a repeated mistake.
1521fn failure_fingerprint(text: &str, is_error: bool) -> Option<String> {
1522    let (severity, patterns) = classify_text(text);
1523    if severity < HARD && !is_error {
1524        return None;
1525    }
1526
1527    let lower = text.to_lowercase();
1528    let diagnostic = lower
1529        .lines()
1530        .find(|line| is_failure_diagnostic(line))
1531        .or_else(|| lower.lines().find(|line| !line.trim().is_empty()))
1532        .unwrap_or_default();
1533    let normalized = normalize_failure_text(diagnostic);
1534    Some(format!("{}|{normalized}", patterns.join(",")))
1535}
1536
1537fn is_failure_diagnostic(line: &str) -> bool {
1538    let line = line.trim();
1539    [
1540        "error",
1541        "exception",
1542        "panic",
1543        "failed",
1544        "timed out",
1545        "timeout",
1546        "connection refused",
1547        "cannot allocate memory",
1548        "out of memory",
1549        "not found",
1550    ]
1551    .iter()
1552    .any(|marker| line.contains(marker))
1553}
1554
1555/// Removes values that normally change between retries while retaining the
1556/// diagnostic wording that distinguishes one failure from another.
1557fn normalize_failure_text(text: &str) -> String {
1558    let mut normalized = String::new();
1559    for word in text.split_whitespace() {
1560        if !normalized.is_empty() {
1561            normalized.push(' ');
1562        }
1563        let mut in_digits = false;
1564        if word.starts_with('/') || word.contains("/src/") || word.contains("/tmp/") {
1565            normalized.push_str("<path>");
1566            continue;
1567        }
1568        for character in word.chars() {
1569            if character.is_ascii_digit() {
1570                if !in_digits {
1571                    normalized.push('#');
1572                    in_digits = true;
1573                }
1574            } else {
1575                normalized.push(character);
1576                in_digits = false;
1577            }
1578        }
1579    }
1580    normalized.chars().take(240).collect()
1581}
1582
1583fn has_compiler_diagnostic(lower: &str) -> bool {
1584    lower.lines().any(|line| {
1585        let line = line.trim_start();
1586        if matches!(
1587            line,
1588            "compilation failed" | "error: compilation failed" | "error: could not compile"
1589        ) || line.starts_with("error: could not compile ")
1590        {
1591            return true;
1592        }
1593
1594        let Some(rest) = line.strip_prefix("error[e") else {
1595            return false;
1596        };
1597        let Some((code, _)) = rest.split_once("]:") else {
1598            return false;
1599        };
1600        !code.is_empty() && code.chars().all(|character| character.is_ascii_digit())
1601    })
1602}
1603
1604fn has_runtime_exception(lower: &str) -> bool {
1605    let has_exception_line = lower.lines().any(|line| {
1606        let line = line.trim_start();
1607        [
1608            "typeerror:",
1609            "referenceerror:",
1610            "rangeerror:",
1611            "runtimeerror:",
1612            "keyerror:",
1613            "attributeerror:",
1614        ]
1615        .iter()
1616        .any(|prefix| line.starts_with(prefix))
1617    });
1618    has_exception_line && (lower.contains("\n    at ") || lower.contains("\n  at "))
1619}
1620
1621fn has_runtime_panic(lower: &str) -> bool {
1622    lower
1623        .lines()
1624        .any(|line| line.trim_start().starts_with("panic: runtime error:"))
1625        && (lower.contains("\ngoroutine ") || lower.contains("[signal sig"))
1626}
1627
1628fn has_patch_failure(lower: &str) -> bool {
1629    lower.lines().any(|line| {
1630        let line = line.trim_start();
1631        line.starts_with("error: patch failed:")
1632            || line.starts_with("patch failed:")
1633            || line.contains(": patch does not apply")
1634            || line.starts_with("invalid context")
1635    })
1636}
1637
1638/// Detects `exit_nonzero` only when a supported exit phrase is followed by a
1639/// nonzero decimal status.
1640///
1641/// Codex includes "Process exited with code 0" on clean tool results, so exit
1642/// phrases must parse their numeric status instead of matching the phrase alone.
1643fn has_nonzero_exit_status(lower: &str) -> bool {
1644    NONZERO_EXIT_PHRASES
1645        .iter()
1646        .any(|phrase| phrase_followed_by_nonzero_integer(lower, phrase))
1647}
1648
1649/// Matches common "exit code/status N" spellings after optional separators.
1650fn phrase_followed_by_nonzero_integer(lower: &str, phrase: &str) -> bool {
1651    let mut cursor = 0usize;
1652    while let Some(rel) = lower[cursor..].find(phrase) {
1653        let value_start = cursor + rel + phrase.len();
1654        let rest = lower[value_start..].trim_start_matches(|c: char| {
1655            c.is_ascii_whitespace() || matches!(c, ':' | '=' | '\'' | '"' | '`')
1656        });
1657        let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1658        if !digits.is_empty() && digits.chars().any(|d| d != '0') {
1659            return true;
1660        }
1661        cursor = value_start;
1662    }
1663    false
1664}
1665
1666fn compute_no_error_streak(tool_texts: &[(String, bool)]) -> u32 {
1667    let mut streak = 0u32;
1668    for (text, is_error) in tool_texts.iter().rev() {
1669        let (sev, _) = classify_text(text);
1670        if *is_error || sev > 0.0 {
1671            break;
1672        }
1673        streak += 1;
1674    }
1675    streak
1676}
1677
1678fn detect_tests_passed(tool_texts: &[(String, bool)], recent_window: usize) -> bool {
1679    let start = tool_texts.len().saturating_sub(recent_window.max(1));
1680    let recent = &tool_texts[start..];
1681    let after_latest_failure = recent
1682        .iter()
1683        .rposition(|(text, is_error)| *is_error || classify_text(text).0 > 0.0)
1684        .map_or(recent, |index| &recent[index + 1..]);
1685    after_latest_failure.iter().any(|(text, _)| {
1686        let lower = text.to_lowercase();
1687        TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
1688            && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
1689            && !has_nonzero_failure_count(&lower)
1690    })
1691}
1692
1693// True iff `lower` contains a `NUMERIC_FAILURE_KEYWORDS` token preceded
1694// (modulo whitespace) by a nonzero integer. The "modulo whitespace" lets
1695// "1 failed", "1\nfailed", and "1  failed" all trip; the nonzero guard
1696// keeps cargo's "0 failed" / go's "0 errors" / pytest's "0 errors in"
1697// summaries from being misread as failures on a clean run.
1698fn has_nonzero_failure_count(lower: &str) -> bool {
1699    for kw in NUMERIC_FAILURE_KEYWORDS {
1700        let mut cursor = 0usize;
1701        while let Some(rel) = lower[cursor..].find(kw) {
1702            let kw_start = cursor + rel;
1703            let kw_end = kw_start + kw.len();
1704            // Word boundary AFTER the keyword — "errors" mid-word (e.g.
1705            // "errored") shouldn't count as a failure-count site.
1706            let boundary_after = lower[kw_end..]
1707                .chars()
1708                .next()
1709                .is_none_or(|c| !c.is_ascii_alphanumeric());
1710            if boundary_after {
1711                let prefix = &lower[..kw_start];
1712                let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
1713                let digits_rev: String = trimmed
1714                    .chars()
1715                    .rev()
1716                    .take_while(|c| c.is_ascii_digit())
1717                    .collect();
1718                if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
1719                    return true;
1720                }
1721            }
1722            cursor = kw_start + kw.len();
1723        }
1724    }
1725    false
1726}
1727
1728// ─── tests ───────────────────────────────────────────────────────────────────
1729
1730#[cfg(test)]
1731mod tests {
1732    use super::*;
1733    use crate::algorithms::util::stage::score_signal;
1734    use serde_json::json;
1735    use switchyard_protocol::codex_namespaces::TOOL_NAMESPACES_KEY;
1736    use switchyard_protocol::{
1737        ContentBlock, LlmRequest, Message, Metadata, Role, ToolCall, ToolResult,
1738    };
1739
1740    fn with_messages(messages: Vec<Message>) -> Request {
1741        Request {
1742            llm_request: LlmRequest {
1743                messages,
1744                ..LlmRequest::default()
1745            },
1746            raw_request: None,
1747            metadata: None,
1748        }
1749    }
1750
1751    // assistant message with a single named tool call
1752    fn tc(name: &str) -> Message {
1753        Message {
1754            role: Role::Assistant,
1755            content: vec![ContentBlock::ToolCall(ToolCall {
1756                id: String::new(),
1757                name: name.to_string(),
1758                arguments: json!({}),
1759            })],
1760        }
1761    }
1762
1763    // assistant Bash message carrying `command`
1764    fn bash(command: &str) -> Message {
1765        Message {
1766            role: Role::Assistant,
1767            content: vec![ContentBlock::ToolCall(ToolCall {
1768                id: String::new(),
1769                name: "Bash".to_string(),
1770                arguments: json!({"command": command}),
1771            })],
1772        }
1773    }
1774
1775    // a tool result message (goes in a user-role message, as in Anthropic's normalised form)
1776    fn tr(text: &str) -> Message {
1777        Message {
1778            role: Role::User,
1779            content: vec![ContentBlock::ToolResult(ToolResult {
1780                tool_call_id: String::new(),
1781                content: vec![ContentBlock::Text {
1782                    text: text.to_string(),
1783                }],
1784                is_error: None,
1785            })],
1786        }
1787    }
1788
1789    #[test]
1790    fn clean_text_has_zero_severity() {
1791        let (sev, patterns) = classify_text("everything went fine");
1792        assert_eq!(sev, 0.0);
1793        assert!(patterns.is_empty());
1794    }
1795
1796    #[test]
1797    fn structured_tool_failures_affect_recovery_signals() {
1798        for (text, severity) in [
1799            (r#"{"output":"","exit_code":7,"error":null}"#, SOFT),
1800            (r#"{"output":"","exit_code":-1,"error":null}"#, SOFT),
1801            (r#"{"success":false,"error":"No matching text"}"#, HARD),
1802            (r#"{"success":false,"error":null}"#, HARD),
1803            (
1804                r#"{"error":"Overwrite refused","stale_write_blocked":true}"#,
1805                HARD,
1806            ),
1807            (r#"{"output":"out of memory","exit_code":1}"#, CRITICAL),
1808            (r#"{"output":"done","exit_code":0,"error":null}"#, 0.0),
1809            (r#"{"success":true,"error":"  "}"#, 0.0),
1810            (r#"{"output":"running","exit_code":null,"error":null}"#, 0.0),
1811            (r#"{"output":{"success":false},"exit_code":0}"#, 0.0),
1812        ] {
1813            let warned = format!("{text}\n\n[Tool loop warning: repeated identical call]");
1814            let request = with_messages(vec![tr("5 passed in 0.12s"), tr(text), tr(&warned)]);
1815            let signal = ToolSignals::from_request(&request, None);
1816            let clean = severity == 0.0;
1817            assert_eq!(signal.severity, severity, "{text}");
1818            assert_eq!(signal.no_error_streak, if clean { 3 } else { 0 }, "{text}");
1819            assert_eq!(signal.repeated_failure, severity >= HARD, "{text}");
1820            assert_eq!(signal.tests_passed, clean, "{text}");
1821        }
1822    }
1823
1824    #[test]
1825    fn traceback_is_hard() {
1826        let (sev, patterns) = classify_text("Traceback (most recent call last):\n  ValueError");
1827        assert_eq!(sev, HARD);
1828        assert!(patterns.contains(&"traceback".to_string()));
1829    }
1830
1831    #[test]
1832    fn oom_is_critical() {
1833        let (sev, _) = classify_text("Out of memory: kill process 1234");
1834        assert_eq!(sev, CRITICAL);
1835    }
1836
1837    #[test]
1838    fn connection_refused_is_hard() {
1839        let (severity, _) = classify_text("Connection refused on port 8000");
1840        assert_eq!(severity, HARD);
1841    }
1842
1843    #[test]
1844    fn repeated_failure_ignores_volatile_paths_and_numbers() {
1845        let request = with_messages(vec![
1846            tr("error[E0308]: mismatched types at /tmp/a/src/lib.rs:12"),
1847            tr("error[E0308]: mismatched types at /tmp/b/src/lib.rs:47"),
1848        ]);
1849        assert!(ToolSignals::from_request(&request, None).repeated_failure);
1850    }
1851
1852    #[test]
1853    fn different_failures_are_not_repeated() {
1854        let request = with_messages(vec![
1855            tr("error[E0308]: mismatched types"),
1856            tr("error[E0509]: cannot move out"),
1857        ]);
1858        assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1859    }
1860
1861    #[test]
1862    fn one_material_failure_is_not_repeated() {
1863        let request = with_messages(vec![tr("Connection refused on port 8000")]);
1864        assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1865    }
1866
1867    /// Explicit failures count even without diagnostic text and cannot signal recovery.
1868    #[test]
1869    fn structured_tool_failures_feed_error_and_recovery_signals() {
1870        for text in ["Dependency unavailable", "", "5 passed in 0.12s"] {
1871            let mut failed = tr(text);
1872            let ContentBlock::ToolResult(result) = &mut failed.content[0] else {
1873                panic!("expected tool result");
1874            };
1875            result.is_error = Some(true);
1876            let mut request = with_messages(vec![tr("5 passed in 0.12s"), failed.clone()]);
1877            let signals = ToolSignals::from_request(&request, Some(3));
1878            assert_eq!(signals.severity, HARD);
1879            assert!(!signals.repeated_failure);
1880            assert_eq!(signals.no_error_streak, 0);
1881            assert!(!signals.tests_passed);
1882
1883            request.llm_request.messages.push(failed);
1884            assert!(ToolSignals::from_request(&request, Some(3)).repeated_failure);
1885            request.llm_request.messages.push(tr("5 passed in 0.12s"));
1886            let recovered = ToolSignals::from_request(&request, Some(1));
1887            assert_eq!(recovered.severity, 0.0);
1888            assert!(!recovered.repeated_failure);
1889            assert_eq!(recovered.no_error_streak, 1);
1890            assert!(recovered.tests_passed);
1891        }
1892    }
1893
1894    #[test]
1895    fn severity_is_max_across_patterns() {
1896        // exit_nonzero (SOFT) + traceback (HARD) → HARD.
1897        let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
1898        assert_eq!(sev, HARD);
1899    }
1900
1901    #[test]
1902    fn codex_process_exit_zero_stays_clean() {
1903        let (sev, patterns) =
1904            classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
1905        assert_eq!(sev, 0.0);
1906        assert!(!patterns.contains(&"exit_nonzero".to_string()));
1907    }
1908
1909    #[test]
1910    fn nonzero_exit_codes_are_soft_errors() {
1911        let cases = [
1912            "Process exited with code 1",
1913            "Process exited with code 127",
1914            "exit code: 2",
1915            "exit status 3",
1916            "exited with status 9",
1917        ];
1918        for case in cases {
1919            let (sev, patterns) = classify_text(case);
1920            assert_eq!(sev, SOFT, "expected soft severity for {case}");
1921            assert!(patterns.contains(&"exit_nonzero".to_string()));
1922        }
1923    }
1924
1925    #[test]
1926    fn partial_process_failures_are_hard_errors() {
1927        let cases = [
1928            (
1929                "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
1930                "compile_error",
1931            ),
1932            (
1933                "Process exited with code 0\nOutput:\nTypeError: value is undefined\n    at main.js:1:2",
1934                "runtime_exception",
1935            ),
1936            (
1937                "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
1938                "runtime_panic",
1939            ),
1940            (
1941                "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
1942                "patch_error",
1943            ),
1944        ];
1945        for (text, expected_pattern) in cases {
1946            let (severity, patterns) = classify_text(text);
1947            assert_eq!(severity, HARD, "expected hard severity for {text}");
1948            assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
1949        }
1950    }
1951
1952    #[test]
1953    fn source_text_that_names_exceptions_stays_clean() {
1954        let text =
1955            "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
1956        assert_eq!(classify_text(text).0, 0.0);
1957    }
1958
1959    #[test]
1960    fn file_does_not_exist_is_hard() {
1961        // Claude Code Read-tool miss. Trace-mined addition (22 true / 2 false positives).
1962        let (sev, patterns) =
1963            classify_text("Error: File does not exist. Note: current working directory is /app.");
1964        assert_eq!(sev, HARD);
1965        assert!(patterns.contains(&"no_such_file".to_string()));
1966    }
1967
1968    #[test]
1969    fn bare_does_not_exist_stays_clean() {
1970        // Precision guard: only the anchored "file does not exist" fires, so a bare
1971        // "does not exist" in prose or directory output must not trip a false error.
1972        let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
1973        assert_eq!(sev, 0.0);
1974    }
1975
1976    #[test]
1977    fn no_error_streak_all_clean() {
1978        let texts = vec![("ok".to_string(), false), ("all good".to_string(), false)];
1979        assert_eq!(compute_no_error_streak(&texts), 2);
1980    }
1981
1982    #[test]
1983    fn no_error_streak_stops_at_error() {
1984        let texts = vec![
1985            ("Traceback (most recent call last):".to_string(), false),
1986            ("ok".to_string(), false),
1987            ("ok".to_string(), false),
1988        ];
1989        assert_eq!(compute_no_error_streak(&texts), 2);
1990    }
1991
1992    #[test]
1993    fn tests_passed_detects_pytest_output() {
1994        assert!(detect_tests_passed(
1995            &[("====== 5 passed in 0.12s ======".to_string(), false)],
1996            DEFAULT_RECENT_WINDOW
1997        ));
1998    }
1999
2000    #[test]
2001    fn tests_passed_ignores_partial_failures() {
2002        assert!(!detect_tests_passed(
2003            &[("2 failed, 5 passed in 0.56s".to_string(), false)],
2004            DEFAULT_RECENT_WINDOW
2005        ));
2006    }
2007
2008    #[test]
2009    fn tests_passed_must_follow_the_latest_failure() {
2010        assert!(!detect_tests_passed(
2011            &[
2012                ("5 passed in 0.12s".to_string(), false),
2013                (
2014                    "Traceback (most recent call last):\nValueError".to_string(),
2015                    false
2016                ),
2017                ("edit applied".to_string(), false),
2018            ],
2019            DEFAULT_RECENT_WINDOW
2020        ));
2021        assert!(detect_tests_passed(
2022            &[
2023                (
2024                    "Traceback (most recent call last):\nValueError".to_string(),
2025                    false
2026                ),
2027                ("5 passed in 0.12s".to_string(), false),
2028            ],
2029            DEFAULT_RECENT_WINDOW
2030        ));
2031    }
2032
2033    #[test]
2034    fn retrieved_file_contents_are_ignored() {
2035        let call = |id: &str, name: &str, arguments: Value| Message {
2036            role: Role::Assistant,
2037            content: vec![ContentBlock::ToolCall(ToolCall {
2038                id: id.to_string(),
2039                name: name.to_string(),
2040                arguments,
2041            })],
2042        };
2043        let result = |id: &str, text: &str| Message {
2044            role: Role::User,
2045            content: vec![ContentBlock::ToolResult(ToolResult {
2046                tool_call_id: id.to_string(),
2047                content: vec![ContentBlock::Text {
2048                    text: text.to_string(),
2049                }],
2050                is_error: None,
2051            })],
2052        };
2053        let signal = extract_tool_signals_with_window(
2054            &with_messages(vec![
2055                call("a", "Bash", json!({"command": "pytest"})),
2056                result("a", "Traceback (most recent call last):\nValueError"),
2057                call("b", "Read", json!({"file_path": "notes.md"})),
2058                result(
2059                    "b",
2060                    r#"{"success":false,"error":"out of memory","exit_code":7}"#,
2061                ),
2062                call("c", "Grep", json!({"pattern": "passed"})),
2063                result("c", "CHANGELOG.md: all tests passed"),
2064            ]),
2065            DEFAULT_RECENT_WINDOW,
2066        );
2067        // Only the real pytest run counts.
2068        assert_eq!(signal.severity, HARD);
2069        assert!(!signal.tests_passed);
2070        assert_eq!(signal.tool_result_count, 3);
2071
2072        for (command, text, severity) in [
2073            ("cat config.json", r#"{"error":"fixture data"}"#, 0.0),
2074            (
2075                "cat missing.json",
2076                r#"{"output":"","exit_code":7,"error":null}"#,
2077                SOFT,
2078            ),
2079            (
2080                "cat config.json && python check.py",
2081                r#"{"error":"check rejected"}"#,
2082                HARD,
2083            ),
2084        ] {
2085            let request = with_messages(vec![
2086                call("shell", "Bash", json!({"command": command})),
2087                result("shell", text),
2088            ]);
2089            assert_eq!(
2090                ToolSignals::from_request(&request, None).severity,
2091                severity,
2092                "{text}"
2093            );
2094        }
2095    }
2096
2097    // A request whose tool result answers the tool call.
2098    fn call_with_result(mut call: Message, mut result: Message) -> Request {
2099        if let ContentBlock::ToolCall(call) = &mut call.content[0] {
2100            call.id = "call-1".into();
2101        }
2102        if let ContentBlock::ToolResult(result) = &mut result.content[0] {
2103            result.tool_call_id = "call-1".into();
2104        }
2105        with_messages(vec![call, result])
2106    }
2107
2108    #[test]
2109    fn shell_reads_are_ignored_but_keep_their_window_slot() {
2110        for command in [
2111            "/usr/bin/cat logfile.txt",
2112            "cd /repo && tail -n 100 logfile.txt | grep MemoryError",
2113            "env MODE=debug timeout 5s rg 'MemoryError' .",
2114        ] {
2115            let mut request = call_with_result(bash(command), tr("MemoryError\nall tests passed"));
2116            request.llm_request.messages.splice(
2117                0..0,
2118                [bash("pytest"), tr("Traceback (most recent call last):")],
2119            );
2120            // The read adds no error and no test pass, so the earlier failure stands.
2121            let signal = ToolSignals::from_request(&request, Some(2));
2122            assert_eq!(signal.severity, HARD, "{command}");
2123            assert!(!signal.tests_passed, "{command}");
2124            // The read still fills a window slot, so the failure can age out.
2125            let signal = ToolSignals::from_request(&request, Some(1));
2126            assert_eq!(signal.severity, 0.0, "{command}");
2127        }
2128    }
2129
2130    #[test]
2131    fn shell_commands_that_run_code_keep_signals() {
2132        let oversized = format!("cat {}", "a".repeat(MAX_RETRIEVAL_COMMAND_BYTES));
2133        for command in [
2134            "cat logfile.txt; pytest",
2135            "echo $(pytest)",
2136            "find . -name '*.py' -exec pytest ';'",
2137            oversized.as_str(),
2138        ] {
2139            let request = call_with_result(bash(command), tr("MemoryError"));
2140            let signal = ToolSignals::from_request(&request, None);
2141            assert_eq!(signal.severity, CRITICAL, "{command}");
2142        }
2143    }
2144
2145    #[test]
2146    fn failed_reads_signal_unless_the_file_is_missing() {
2147        let failed = |text: &str| {
2148            let mut result = tr(text);
2149            if let ContentBlock::ToolResult(result) = &mut result.content[0] {
2150                result.is_error = Some(true);
2151            }
2152            result
2153        };
2154        let missing = "missing.txt: No such file or directory";
2155        let severities = [
2156            call_with_result(bash("cat missing.txt"), failed(missing)),
2157            call_with_result(tc("Read"), failed("File does not exist.")),
2158            // A read that fails for another reason still counts.
2159            call_with_result(bash("cat logfile.txt"), failed("MemoryError")),
2160            // Only reads are exempt.
2161            call_with_result(bash("python missing.txt"), failed(missing)),
2162        ]
2163        .map(|request| ToolSignals::from_request(&request, None).severity);
2164        assert_eq!(severities, [0.0, 0.0, CRITICAL, HARD]);
2165
2166        let request = call_with_result(
2167            bash("cat missing.json"),
2168            tr(r#"{"output":"No such file or directory","exit_code":1}"#),
2169        );
2170        assert_eq!(ToolSignals::from_request(&request, None).severity, 0.0);
2171    }
2172
2173    #[test]
2174    fn codex_read_status_comes_from_the_header() {
2175        let read = |header: &str| {
2176            let output = format!(
2177                "{header}\nOutput:\nTraceback (most recent call last):\nMemoryError\nProcess exited with code 1\n"
2178            );
2179            let call = exec_command(json!({"cmd": "cat run.log"}));
2180            ToolSignals::from_request(&call_with_result(call, tr(&output)), None).severity
2181        };
2182        // The exit line belongs to the log that was read.
2183        assert_eq!(read("Chunk ID: abc\nProcess exited with code 0"), 0.0);
2184        // The read itself failed, so its output counts.
2185        assert_eq!(read("Chunk ID: abc\nProcess exited with code 1"), CRITICAL);
2186    }
2187
2188    #[test]
2189    fn hermes_read_status_comes_from_exit_code() {
2190        let read = |exit_code: i64| {
2191            let output = json!({
2192                "output": "Traceback (most recent call last):\nMemoryError\nProcess exited with code 1\n",
2193                "exit_code": exit_code,
2194                "error": null,
2195            });
2196            let call = Message {
2197                role: Role::Assistant,
2198                content: vec![ContentBlock::ToolCall(ToolCall {
2199                    id: String::new(),
2200                    name: "terminal".to_string(),
2201                    arguments: json!({"command": "cat run.log"}),
2202                })],
2203            };
2204            ToolSignals::from_request(&call_with_result(call, tr(&output.to_string())), None)
2205                .severity
2206        };
2207        // The exit line belongs to the log that was read.
2208        assert_eq!(read(0), 0.0);
2209        // The read itself failed, so its output counts.
2210        assert_eq!(read(1), CRITICAL);
2211    }
2212
2213    #[test]
2214    fn argv_commands_keep_word_boundaries() {
2215        let signal = |argv: Value| {
2216            let call = exec_command(json!({"cmd": argv}));
2217            ToolSignals::from_request(&call_with_result(call, tr("MemoryError")), None)
2218        };
2219        let mut many_args = vec![""; MAX_RETRIEVAL_COMMAND_BYTES];
2220        many_args[0] = "cat";
2221        let severities = [
2222            json!(["bash", "-lc", "cd repo && rg foo"]),
2223            json!(["bash", "-lc", "cat log; pytest"]),
2224            // One argv word is one file name, even with a separator in it.
2225            json!(["cat", "log; pytest"]),
2226            // Over the byte budget, by size and by argument count.
2227            json!(["cat", "a".repeat(MAX_RETRIEVAL_COMMAND_BYTES)]),
2228            json!(many_args),
2229        ]
2230        .map(|argv| signal(argv).severity);
2231        assert_eq!(severities, [0.0, CRITICAL, 0.0, CRITICAL, CRITICAL]);
2232        // A read is an observation, even as a whole `-lc` script.
2233        let read = signal(json!(["bash", "-lc", "sed -n 1,200p x"]));
2234        assert_eq!(read.read_count, 1);
2235        // Other commands are counted inside the script too.
2236        let write = signal(json!(["bash", "-lc", "cd repo && mkdir x"]));
2237        assert_eq!(write.write_count, 1);
2238    }
2239
2240    #[test]
2241    fn severity_is_windowed_over_recent_results() {
2242        // An error two results back, then two clean results.
2243        let request = with_messages(vec![
2244            tr("Traceback (most recent call last):\n  ValueError"),
2245            tr("ok"),
2246            tr("ok"),
2247        ]);
2248        // window covers the error → severity persists (max over the window)
2249        assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
2250        // window of 1 sees only the last (clean) result → severity has decayed out
2251        assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
2252    }
2253
2254    #[test]
2255    fn extract_openai_chat_tool_results() {
2256        let request = with_messages(vec![
2257            Message::text(Role::User, "do something"),
2258            tc("Edit"),
2259            tr("Traceback (most recent call last):\n  ValueError"),
2260        ]);
2261        let sig = ToolSignals::from_request(&request, None);
2262        assert_eq!(sig.severity, HARD);
2263        assert_eq!(sig.edit_count, 1);
2264        assert_eq!(sig.turn_depth, 3);
2265    }
2266
2267    #[test]
2268    fn extract_anthropic_tool_results() {
2269        let request = with_messages(vec![tr("Traceback (most recent call last):\n  ValueError")]);
2270        let sig = ToolSignals::from_request(&request, None);
2271        assert_eq!(sig.severity, HARD);
2272    }
2273
2274    #[test]
2275    fn extract_responses_api_tool_results() {
2276        let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
2277        let sig = ToolSignals::from_request(&request, None);
2278        assert_eq!(sig.severity, 0.0);
2279        assert_eq!(sig.write_count, 1);
2280    }
2281
2282    #[test]
2283    fn responses_builtin_tool_failures_escalate() {
2284        use crate::algorithms::util::stage::{PickOutcome, PickerMode, Tier, pick_tier};
2285
2286        let mut cases = Vec::new();
2287        for (status, output) in [
2288            (
2289                "failed",
2290                "Synthetic dependency unavailable; retry with the recovery path.",
2291            ),
2292            (
2293                "completed",
2294                "Synthetic dependency unavailable; retry with the recovery path.",
2295            ),
2296            ("failed", ""),
2297        ] {
2298            cases.push((
2299                json!({
2300                    "type": "apply_patch_call_output",
2301                    "status": status,
2302                    "output": output,
2303                }),
2304                if status == "failed" { HARD } else { 0.0 },
2305            ));
2306        }
2307        for (outcome, stdout, stderr, severity) in [
2308            (json!({"type": "exit", "exit_code": 1}), "", "", SOFT),
2309            (json!({"type": "exit", "exit_code": 1}), "", " \n", SOFT),
2310            (
2311                json!({"type": "exit", "exit_code": 1}),
2312                "",
2313                "command failed",
2314                HARD,
2315            ),
2316            (json!({"type": "timeout"}), "", "", HARD),
2317            (json!({"type": "exit", "exit_code": 0}), "done", "", 0.0),
2318            (
2319                json!({"type": "exit", "exit_code": 0}),
2320                "Traceback (most recent call last):",
2321                "",
2322                HARD,
2323            ),
2324            (
2325                json!({"type": "exit", "exit_code": 0}),
2326                "",
2327                "Traceback (most recent call last):",
2328                HARD,
2329            ),
2330        ] {
2331            cases.push((
2332                json!({
2333                    "type": "shell_call_output",
2334                    "output": [
2335                        {"stdout": stdout, "stderr": stderr, "outcome": outcome},
2336                        {"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}
2337                    ],
2338                }),
2339                severity,
2340            ));
2341        }
2342        for (raw, severity) in cases {
2343            let is_error = severity >= HARD;
2344            let mut request = with_messages(
2345                ["call_1", "call_2"]
2346                    .into_iter()
2347                    .map(|call_id| {
2348                        let mut raw = raw.clone();
2349                        raw["call_id"] = json!(call_id);
2350                        Message {
2351                            role: Role::User,
2352                            content: vec![ContentBlock::Unknown {
2353                                provider: WireFormat::OpenAiResponses.into(),
2354                                raw,
2355                            }],
2356                        }
2357                    })
2358                    .collect(),
2359            );
2360            let signal = ToolSignals::from_request(&request, Some(3));
2361            assert_eq!(signal.severity, severity, "{raw}");
2362            assert_eq!(signal.repeated_failure, is_error, "{raw}");
2363            assert_eq!(signal.tool_result_count, 2);
2364            assert_eq!(
2365                matches!(
2366                    pick_tier(&signal, PickerMode::EfficientFirst, 0.5),
2367                    PickOutcome::Resolved {
2368                        tier: Tier::Capable,
2369                        ..
2370                    }
2371                ),
2372                is_error,
2373                "{raw}"
2374            );
2375
2376            let mut success = match raw["type"].as_str() {
2377                Some("apply_patch_call_output") => json!({
2378                    "type": "apply_patch_call_output", "status": "completed", "output": ""
2379                }),
2380                Some("shell_call_output") => json!({
2381                    "type": "shell_call_output",
2382                    "output": [{"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}]
2383                }),
2384                _ => unreachable!(),
2385            };
2386            for index in 0..3 {
2387                success["call_id"] = json!(format!("success_{index}"));
2388                request.llm_request.messages.push(Message {
2389                    role: Role::User,
2390                    content: vec![ContentBlock::Unknown {
2391                        provider: WireFormat::OpenAiResponses.into(),
2392                        raw: success.clone(),
2393                    }],
2394                });
2395            }
2396            let recovered = ToolSignals::from_request(&request, Some(3));
2397            assert_eq!(recovered.severity, 0.0, "{raw}");
2398            assert!(!recovered.repeated_failure, "{raw}");
2399            assert!(recovered.no_error_streak >= 3, "{raw}");
2400            assert_eq!(recovered.tool_result_count, 5);
2401        }
2402    }
2403
2404    #[test]
2405    fn conversation_counts_are_per_block_and_role_aware() {
2406        // A batched user message (Anthropic shape) contributes one count per
2407        // ToolResult block, empty-content results included. assistant_turn_count
2408        // tracks Role::Assistant only, while turn_depth counts every message.
2409        let result = |content: Vec<ContentBlock>| {
2410            ContentBlock::ToolResult(ToolResult {
2411                tool_call_id: String::new(),
2412                content,
2413                is_error: None,
2414            })
2415        };
2416        let request = with_messages(vec![
2417            Message::text(Role::User, "do something"),
2418            Message::text(Role::Assistant, "working"),
2419            Message {
2420                role: Role::User,
2421                content: vec![
2422                    result(vec![ContentBlock::Text {
2423                        text: "ok".to_string(),
2424                    }]),
2425                    result(Vec::new()),
2426                ],
2427            },
2428            tc("Bash"),
2429        ]);
2430        let sig = ToolSignals::from_request(&request, None);
2431        assert_eq!(sig.tool_result_count, 2);
2432        assert_eq!(sig.assistant_turn_count, 2);
2433        assert_eq!(sig.turn_depth, 4);
2434    }
2435
2436    #[test]
2437    fn recent_window_counts_only_last_default_window_tool_calls() {
2438        // 5 writes + 1 edit at the end → the default window (3) should see
2439        // the last 3 calls: 1 edit + 2 writes (not all 6 calls).
2440        let request = with_messages(vec![
2441            tc("Write"),
2442            tr("ok"),
2443            tc("Write"),
2444            tr("ok"),
2445            tc("Write"),
2446            tr("ok"),
2447            tc("Write"),
2448            tr("ok"),
2449            tc("Write"),
2450            tr("ok"),
2451            tc("Edit"),
2452            tr("ok"),
2453        ]);
2454        let sig = ToolSignals::from_request(&request, None);
2455        assert_eq!(sig.write_count, 5);
2456        assert_eq!(sig.edit_count, 1);
2457        assert_eq!(sig.recent_write_count, 2);
2458        assert_eq!(sig.recent_edit_count, 1);
2459    }
2460
2461    #[test]
2462    fn codex_apply_patch_counts_as_an_edit() {
2463        let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
2464        let sig = ToolSignals::from_request(&request, None);
2465        assert_eq!(sig.edit_count, 1);
2466        assert_eq!(sig.recent_edit_count, 1);
2467    }
2468
2469    fn exec_command(cmd: Value) -> Message {
2470        Message {
2471            role: Role::Assistant,
2472            content: vec![ContentBlock::ToolCall(ToolCall {
2473                id: String::new(),
2474                name: "exec_command".to_string(),
2475                arguments: cmd,
2476            })],
2477        }
2478    }
2479
2480    #[test]
2481    fn codex_exec_command_is_classified() {
2482        // arguments arrive as a JSON string, with the command under `cmd`
2483        let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
2484        let request = with_messages(vec![exec_command(args), tr("ok")]);
2485        assert_eq!(
2486            ToolSignals::from_request(&request, None).recent_edit_count,
2487            1
2488        );
2489    }
2490
2491    #[test]
2492    fn python_write_expressions_need_a_python_command() {
2493        let write = with_messages(vec![
2494            exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
2495            tr("ok"),
2496        ]);
2497        assert_eq!(
2498            ToolSignals::from_request(&write, None).recent_write_count,
2499            1
2500        );
2501
2502        let search = with_messages(vec![
2503            exec_command(json!({"cmd": "grep -R '.write(' src"})),
2504            tr("ok"),
2505        ]);
2506        assert_eq!(
2507            ToolSignals::from_request(&search, None).recent_write_count,
2508            0
2509        );
2510    }
2511
2512    #[test]
2513    fn recent_window_size_is_caller_overridable() {
2514        // Same six tool calls (1 edit at the end, 5 writes before).
2515        // With recent_window=3 → recent_writes=2, recent_edits=1.
2516        // With recent_window=6 → recent_writes=5, recent_edits=1 (all calls).
2517        let request = with_messages(vec![
2518            tc("Write"),
2519            tr("ok"),
2520            tc("Write"),
2521            tr("ok"),
2522            tc("Write"),
2523            tr("ok"),
2524            tc("Write"),
2525            tr("ok"),
2526            tc("Write"),
2527            tr("ok"),
2528            tc("Edit"),
2529            tr("ok"),
2530        ]);
2531        let narrow = extract_tool_signals_with_window(&request, 3);
2532        assert_eq!(narrow.recent_write_count, 2);
2533        assert_eq!(narrow.recent_edit_count, 1);
2534
2535        let wide = extract_tool_signals_with_window(&request, 6);
2536        assert_eq!(wide.recent_write_count, 5);
2537        assert_eq!(wide.recent_edit_count, 1);
2538    }
2539
2540    #[test]
2541    fn compaction_marker_sets_compacted() {
2542        // The compaction summary is a user message carrying Claude Code's preamble.
2543        let request = with_messages(vec![
2544            Message::text(
2545                Role::User,
2546                "This session is being continued from a previous conversation that ran out of context.",
2547            ),
2548            bash("ls"),
2549        ]);
2550        assert!(ToolSignals::from_request(&request, None).compacted);
2551    }
2552
2553    #[test]
2554    fn hermes_compaction_headers_set_compacted() {
2555        for header in [
2556            "[CONTEXT COMPACTION \u{2014} REFERENCE ONLY] Earlier turns were compacted into the summary below.",
2557            "[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.]",
2558        ] {
2559            for role in [Role::User, Role::Assistant] {
2560                let request = with_messages(vec![
2561                    Message::text(role, format!("{header}\nContinue the task.")),
2562                    bash("ls"),
2563                ]);
2564                assert!(ToolSignals::from_request(&request, None).compacted);
2565            }
2566            let request = with_messages(vec![Message::text(
2567                Role::User,
2568                format!("Prior context.\n\n  {header}\nContinue the task."),
2569            )]);
2570            assert!(ToolSignals::from_request(&request, None).compacted);
2571            let request = with_messages(vec![tr(header)]);
2572            assert!(!ToolSignals::from_request(&request, None).compacted);
2573        }
2574    }
2575
2576    #[test]
2577    fn ordinary_compaction_text_stays_uncompacted() {
2578        for text in [
2579            "The task is still in progress after the compaction boundary.",
2580            "Explain [CONTEXT COMPACTION \u{2014} REFERENCE ONLY] in the docs.",
2581            "[STILL IN PROGRESS] Continue the task.",
2582        ] {
2583            let request = with_messages(vec![Message::text(Role::User, text)]);
2584            assert!(!ToolSignals::from_request(&request, None).compacted);
2585        }
2586    }
2587
2588    #[test]
2589    fn codex_compaction_metadata_stays_on_parent_route() {
2590        let mut request = with_messages(vec![bash("ls")]);
2591        request.metadata = Some(Metadata {
2592            is_subagent: true,
2593            agent_kind: Some("compact".to_string()),
2594            ..Default::default()
2595        });
2596        assert!(!ToolSignals::from_request(&request, None).compacted);
2597    }
2598
2599    #[test]
2600    fn no_compaction_marker_stays_uncompacted() {
2601        let request = with_messages(vec![
2602            Message::text(Role::User, "Write a script that parses the log file."),
2603            bash("ls"),
2604        ]);
2605        assert!(!ToolSignals::from_request(&request, None).compacted);
2606    }
2607
2608    #[test]
2609    fn bash_heredoc_counts_as_write() {
2610        // Claude Code's pattern on TB 2.0 — write a scratch file via heredoc.
2611        let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
2612        let sig = ToolSignals::from_request(&request, None);
2613        assert_eq!(
2614            sig.write_count, 1,
2615            "Bash heredoc should bucket into write_count"
2616        );
2617        assert_eq!(sig.edit_count, 0);
2618    }
2619
2620    #[test]
2621    fn bash_sed_inplace_counts_as_edit() {
2622        let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
2623        let sig = ToolSignals::from_request(&request, None);
2624        assert_eq!(
2625            sig.edit_count, 1,
2626            "Bash sed -i should bucket into edit_count"
2627        );
2628        assert_eq!(sig.write_count, 0);
2629    }
2630
2631    #[test]
2632    fn bash_non_mutating_does_not_count() {
2633        // ls, cat, grep — should not increment either counter.
2634        let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
2635        let sig = ToolSignals::from_request(&request, None);
2636        assert_eq!(sig.write_count, 0);
2637        assert_eq!(sig.edit_count, 0);
2638    }
2639
2640    #[test]
2641    fn tests_passed_detects_pytest_with_failure_block() {
2642        // Mixed pytest run: 2 failed + 5 passed → NOT considered tests_passed.
2643        assert!(!detect_tests_passed(
2644            &[("2 failed, 5 passed in 0.56s".to_string(), false)],
2645            DEFAULT_RECENT_WINDOW
2646        ));
2647    }
2648
2649    #[test]
2650    fn tests_passed_accepts_cargo_clean_summary() {
2651        // Cargo's clean-run summary contains "0 failed" — must not trip the
2652        // failure list (regression: previously substring-matched "failed").
2653        assert!(detect_tests_passed(
2654            &[(
2655                "running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string(),
2656                false
2657            )],
2658            DEFAULT_RECENT_WINDOW
2659        ));
2660    }
2661
2662    #[test]
2663    fn tests_passed_rejects_cargo_real_failure() {
2664        // Cargo's actual-failure summary: nonzero count before "failed".
2665        assert!(!detect_tests_passed(
2666            &[(
2667                "running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string(),
2668                false
2669            )],
2670            DEFAULT_RECENT_WINDOW
2671        ));
2672    }
2673
2674    #[test]
2675    fn tests_passed_accepts_go_clean_summary() {
2676        // Go test's clean-run "0 errors" must not trip (regression).
2677        assert!(detect_tests_passed(
2678            &[(
2679                "ok  github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string(),
2680                false
2681            )],
2682            DEFAULT_RECENT_WINDOW
2683        ));
2684    }
2685
2686    #[test]
2687    fn tests_passed_accepts_pytest_zero_errors() {
2688        // Pytest long-form: "0 errors in 0.3s" on a clean run.
2689        assert!(detect_tests_passed(
2690            &[("5 passed, 0 errors in 0.30s".to_string(), false)],
2691            DEFAULT_RECENT_WINDOW
2692        ));
2693    }
2694
2695    #[test]
2696    fn tests_passed_detects_diy_checkmark() {
2697        assert!(detect_tests_passed(
2698            &[("✓ all checks passed".to_string(), false)],
2699            DEFAULT_RECENT_WINDOW
2700        ));
2701    }
2702
2703    #[test]
2704    fn anthropic_bash_heredoc_extracts_command() {
2705        // Anthropic format: tool_use.input is an object, not a JSON string.
2706        let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
2707        let sig = ToolSignals::from_request(&request, None);
2708        assert_eq!(
2709            sig.write_count, 1,
2710            "Anthropic Bash heredoc must also be detected"
2711        );
2712    }
2713
2714    #[test]
2715    fn recent_window_falls_back_to_full_history_when_short() {
2716        let request = with_messages(vec![tc("Write")]);
2717        let sig = ToolSignals::from_request(&request, None);
2718        assert_eq!(sig.recent_write_count, 1);
2719        assert_eq!(sig.recent_edit_count, 0);
2720    }
2721
2722    #[test]
2723    fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
2724        let request = with_messages(vec![tr("output ok"), tr("another ok")]);
2725        let sig = ToolSignals::from_request(&request, None);
2726        assert_eq!(sig.severity, 0.0);
2727        assert_eq!(sig.no_error_streak, 2);
2728    }
2729
2730    // ─── asymmetric-signal extensions ────────────────────────────────────
2731
2732    #[test]
2733    fn todowrite_classifies_as_plan() {
2734        assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
2735        assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
2736    }
2737
2738    #[test]
2739    fn codex_update_plan_classifies_as_plan() {
2740        assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
2741    }
2742
2743    #[test]
2744    fn codex_shell_command_runs_bash_pattern_match() {
2745        // shell_command + heredoc -> Write.
2746        assert_eq!(
2747            classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
2748            ToolSemantic::Mutate(MutationKind::Write),
2749        );
2750        // shell_command + read-like inspection -> Read.
2751        assert_eq!(
2752            classify_tool_call("shell_command", Some("ls /app")),
2753            ToolSemantic::Observe,
2754        );
2755        // shell_command without matching patterns -> Unknown.
2756        assert_eq!(
2757            classify_tool_call("shell_command", Some("./run_tests.sh")),
2758            ToolSemantic::Unknown,
2759        );
2760    }
2761
2762    #[test]
2763    fn text_editor_view_is_a_read() {
2764        for name in ["str_replace_based_edit_tool", "text_editor"] {
2765            assert_eq!(
2766                classify_tool_call(name, Some("view")),
2767                ToolSemantic::Observe
2768            );
2769            for command in [
2770                Some("create"),
2771                Some("insert"),
2772                Some("str_replace"),
2773                Some("undo_edit"),
2774                None,
2775            ] {
2776                assert_eq!(
2777                    classify_tool_call(name, command),
2778                    ToolSemantic::Mutate(MutationKind::Edit),
2779                );
2780            }
2781        }
2782
2783        let arguments = [
2784            json!({"command": "view", "path": "/app/main.py"}),
2785            // the Responses wire format sends arguments as a JSON string
2786            json!(r#"{"command":"view","path":"/app/main.py"}"#),
2787        ];
2788        for arguments in arguments {
2789            let call = Message {
2790                role: Role::Assistant,
2791                content: vec![ContentBlock::ToolCall(ToolCall {
2792                    id: String::new(),
2793                    name: "str_replace_based_edit_tool".to_string(),
2794                    arguments,
2795                })],
2796            };
2797            let request = with_messages(vec![call, tr("print('hi')")]);
2798            let sig = ToolSignals::from_request(&request, None);
2799            assert_eq!(sig.read_count, 1);
2800            assert_eq!(sig.recent_read_count, 1);
2801            assert_eq!(sig.edit_count, 0);
2802        }
2803    }
2804
2805    #[test]
2806    fn read_tool_classifies_as_read() {
2807        assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
2808        assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
2809    }
2810
2811    #[test]
2812    fn hermes_tool_names_classify() {
2813        // Hermes (NousResearch) file tools route by name.
2814        assert_eq!(
2815            classify_tool_call("write_file", None),
2816            ToolSemantic::Mutate(MutationKind::Write)
2817        );
2818        assert_eq!(
2819            classify_tool_call("patch", None),
2820            ToolSemantic::Mutate(MutationKind::Edit)
2821        );
2822        assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
2823        assert_eq!(
2824            classify_tool_call("search_files", None),
2825            ToolSemantic::Observe
2826        );
2827        // Hermes runs shell through `terminal`, which carries a `command` arg,
2828        // so its intent comes from the Bash-pattern match like codex's shell_command.
2829        assert_eq!(
2830            classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
2831            ToolSemantic::Mutate(MutationKind::Edit),
2832        );
2833        assert_eq!(
2834            classify_tool_call("terminal", Some("grep foo /app")),
2835            ToolSemantic::Observe,
2836        );
2837        assert_eq!(
2838            classify_tool_call("terminal", Some("./run_tests.sh")),
2839            ToolSemantic::Unknown,
2840        );
2841    }
2842
2843    #[test]
2844    fn bash_read_patterns_classify_as_read() {
2845        let cases = [
2846            "cat /etc/passwd",
2847            "grep foo bar.txt",
2848            "ls /app",
2849            "find . -name '*.py'",
2850        ];
2851        for cmd in cases {
2852            assert_eq!(
2853                classify_tool_call("Bash", Some(cmd)),
2854                ToolSemantic::Observe,
2855                "expected Read for {cmd}"
2856            );
2857        }
2858    }
2859
2860    #[test]
2861    fn codex_inspection_commands_classify_as_read() {
2862        let cases = [
2863            "sed -n '1,80p' src/lib.rs",
2864            "rg -n 'needle' src",
2865            "nl -ba src/lib.rs",
2866            "cat package.json",
2867            "jq '.scripts' package.json",
2868            "git status --short",
2869            "git log --oneline -5",
2870            "git show HEAD:src/lib.rs",
2871            "git branch --show-current",
2872            "git remote -v",
2873            "git config --get remote.origin.url",
2874        ];
2875        for command in cases {
2876            assert_eq!(
2877                classify_tool_call("exec_command", Some(command)),
2878                ToolSemantic::Observe,
2879                "expected Read for {command}"
2880            );
2881        }
2882    }
2883
2884    #[test]
2885    fn quoted_shell_separators_do_not_create_commands() {
2886        for command in ["rg 'foo|rm obsolete.rs'", "rg \"foo; rm obsolete.rs\""] {
2887            assert_eq!(
2888                classify_tool_call("exec_command", Some(command)),
2889                ToolSemantic::Observe,
2890                "quoted text must not be parsed as a command: {command}"
2891            );
2892        }
2893    }
2894
2895    #[test]
2896    fn codex_shell_mutations_classify_as_production() {
2897        let writes = [
2898            "cp source.rs destination.rs",
2899            "mkdir -p src/generated",
2900            "touch src/generated/mod.rs",
2901            "git show HEAD:file.rs > file.rs",
2902            "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
2903        ];
2904        for command in writes {
2905            assert_eq!(
2906                classify_tool_call("exec_command", Some(command)),
2907                ToolSemantic::Mutate(MutationKind::Write),
2908                "expected Write for {command}"
2909            );
2910        }
2911
2912        let edits = [
2913            "mv old.rs new.rs",
2914            "rm obsolete.rs",
2915            "gofmt -w main.go",
2916            "cargo fmt",
2917            "ruff check --fix src",
2918            "perl -0pi -e 's/old/new/' src/lib.rs",
2919            "npx prettier --write src/lib.ts",
2920            "uv run ruff format src",
2921            "git apply fix.patch",
2922        ];
2923        for command in edits {
2924            assert_eq!(
2925                classify_tool_call("exec_command", Some(command)),
2926                ToolSemantic::Mutate(MutationKind::Edit),
2927                "expected Edit for {command}"
2928            );
2929        }
2930    }
2931
2932    #[test]
2933    fn formatter_checks_are_not_edits() {
2934        for command in [
2935            "cargo fmt --check",
2936            "ruff format --check src",
2937            "black --check src",
2938        ] {
2939            assert_ne!(
2940                classify_tool_call("exec_command", Some(command)),
2941                ToolSemantic::Mutate(MutationKind::Edit),
2942                "read-only formatter check must not be Edit: {command}"
2943            );
2944        }
2945    }
2946
2947    #[test]
2948    fn embedded_comparison_is_not_a_shell_write() {
2949        let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
2950        assert_eq!(
2951            classify_tool_call("exec_command", Some(command)),
2952            ToolSemantic::Unknown
2953        );
2954    }
2955
2956    #[test]
2957    fn bash_write_precedence_over_read() {
2958        // `cat /file > out` contains both `cat /` (read) and ` > ` (write);
2959        // write redirection must win.
2960        assert_eq!(
2961            classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
2962            ToolSemantic::Mutate(MutationKind::Write),
2963        );
2964    }
2965
2966    #[test]
2967    fn pure_bash_streak_counts_trailing_other() {
2968        // 5 trailing non-classified Bash calls → streak == 5.
2969        let request = with_messages(vec![
2970            bash("make"),
2971            tr("ok"),
2972            bash("./configure"),
2973            tr("ok"),
2974            bash("make install"),
2975            tr("ok"),
2976            bash("./run.sh"),
2977            tr("ok"),
2978            bash("./test"),
2979            tr("ok"),
2980        ]);
2981        let sig = ToolSignals::from_request(&request, None);
2982        assert_eq!(sig.pure_bash_streak, 5);
2983        assert_eq!(sig.write_count, 0);
2984        assert_eq!(sig.read_count, 0);
2985    }
2986
2987    #[test]
2988    fn pure_bash_streak_resets_on_write() {
2989        let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
2990        let sig = ToolSignals::from_request(&request, None);
2991        assert_eq!(sig.pure_bash_streak, 0);
2992        assert_eq!(sig.write_count, 1);
2993    }
2994
2995    #[test]
2996    fn recent_window_tracks_todowrite_and_read() {
2997        // Final 3 tool calls: TodoWrite, Read, TodoWrite.
2998        let request = with_messages(vec![
2999            bash("make"),
3000            tr("ok"),
3001            tc("TodoWrite"),
3002            tr("ok"),
3003            tc("Read"),
3004            tr("ok"),
3005            tc("TodoWrite"),
3006            tr("ok"),
3007        ]);
3008        let sig = ToolSignals::from_request(&request, None);
3009        assert_eq!(sig.todowrite_count, 2);
3010        assert_eq!(sig.recent_todowrite_count, 2);
3011        assert_eq!(sig.read_count, 1);
3012        assert_eq!(sig.recent_read_count, 1);
3013    }
3014
3015    #[test]
3016    fn configured_tool_semantics_extend_the_builtin_vocabulary() {
3017        let semantics = ToolSemantics {
3018            observe: vec!["KB_search".to_string()],
3019            mutate: vec!["send_payment_request".to_string()],
3020            plan: vec!["create_research_plan".to_string()],
3021            new: vec!["send_message_to_user".to_string()],
3022        };
3023        semantics.validate().expect("valid additive semantics");
3024        let request = with_messages(vec![
3025            tc("Read"),
3026            tc("Write"),
3027            tc("TodoWrite"),
3028            tc("kb_SEARCH"),
3029            tc("send_payment_request"),
3030            tc("create_research_plan"),
3031            tc("send_message_to_user"),
3032            tc("unlisted_tool"),
3033        ]);
3034
3035        let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
3036
3037        assert_eq!(signal.read_count, 2);
3038        assert_eq!(signal.write_count, 2);
3039        assert_eq!(signal.todowrite_count, 2);
3040        assert_eq!(signal.new_count, 1);
3041        assert_eq!(signal.recent_new_count, 1);
3042        assert_eq!(signal.pure_bash_streak, 1);
3043    }
3044
3045    #[test]
3046    fn configured_tool_semantics_match_namespaced_and_mcp_tools() {
3047        // The Responses decoder flattens namespaced tools and records the mapping.
3048        let mut request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
3049        request.llm_request.extensions.fields.insert(
3050            TOOL_NAMESPACES_KEY.to_string(),
3051            json!({"mcp__billing__send_payment_request": "mcp__billing"}),
3052        );
3053
3054        // Claude Code sends MCP tools flat, with no namespace mapping.
3055        let claude_request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
3056
3057        for request in [&request, &claude_request] {
3058            for name in ["send_payment_request", "mcp__billing__send_payment_request"] {
3059                let semantics = ToolSemantics {
3060                    mutate: vec![name.to_string()],
3061                    ..Default::default()
3062                };
3063                let signal = ToolSignals::from_request_with_semantics(request, None, &semantics);
3064                assert_eq!(signal.write_count, 1, "{name}");
3065            }
3066        }
3067    }
3068
3069    #[test]
3070    fn configured_tool_semantics_only_fold_ascii_case() {
3071        let semantics = ToolSemantics {
3072            observe: vec!["kb_search".to_string()],
3073            ..Default::default()
3074        };
3075
3076        assert_eq!(
3077            classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
3078            ToolSemantic::Observe
3079        );
3080        // U+212A lowercases to ASCII `k` under Unicode rules, but custom names
3081        // intentionally ignore only ASCII case.
3082        assert_eq!(
3083            classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
3084            ToolSemantic::Unknown
3085        );
3086    }
3087
3088    #[test]
3089    fn custom_semantics_preserve_builtin_unicode_lowercasing() {
3090        let semantics = ToolSemantics {
3091            observe: vec!["lookup_customer".to_string()],
3092            ..Default::default()
3093        };
3094
3095        // This matched the built-in `notebookedit` before custom semantics existed.
3096        assert_eq!(
3097            classify_tool_call_with_semantics("notebooKedit", None, &semantics),
3098            ToolSemantic::Mutate(MutationKind::Edit)
3099        );
3100    }
3101
3102    #[test]
3103    fn configured_semantics_never_replace_builtin_classifications() {
3104        let semantics = ToolSemantics {
3105            observe: vec!["lookup_customer".to_string()],
3106            mutate: vec!["send_payment".to_string()],
3107            plan: vec!["create_workflow".to_string()],
3108            new: vec!["send_message".to_string()],
3109        };
3110
3111        for name in WRITE_TOOL_NAMES {
3112            assert_eq!(
3113                classify_tool_call_with_semantics(name, None, &semantics),
3114                ToolSemantic::Mutate(MutationKind::Write),
3115                "write tool {name:?} changed classification"
3116            );
3117        }
3118        for name in EDIT_TOOL_NAMES {
3119            assert_eq!(
3120                classify_tool_call_with_semantics(name, None, &semantics),
3121                ToolSemantic::Mutate(MutationKind::Edit),
3122                "edit tool {name:?} changed classification"
3123            );
3124        }
3125        for name in READ_TOOL_NAMES {
3126            assert_eq!(
3127                classify_tool_call_with_semantics(name, None, &semantics),
3128                ToolSemantic::Observe,
3129                "read tool {name:?} changed classification"
3130            );
3131        }
3132        for name in PLAN_TOOL_NAMES {
3133            assert_eq!(
3134                classify_tool_call_with_semantics(name, None, &semantics),
3135                ToolSemantic::Plan,
3136                "plan tool {name:?} changed classification"
3137            );
3138        }
3139
3140        for (command, expected) in [
3141            ("cat /tmp/input", ToolSemantic::Observe),
3142            (
3143                "cat /tmp/input > /tmp/output",
3144                ToolSemantic::Mutate(MutationKind::Write),
3145            ),
3146            (
3147                "sed -i 's/a/b/' /tmp/file",
3148                ToolSemantic::Mutate(MutationKind::Edit),
3149            ),
3150            ("./run_tests.sh", ToolSemantic::Unknown),
3151        ] {
3152            assert_eq!(
3153                classify_tool_call_with_semantics("BASH", Some(command), &semantics),
3154                expected,
3155                "bash command {command:?} changed classification"
3156            );
3157        }
3158    }
3159
3160    #[test]
3161    fn configured_semantics_score_like_their_builtin_equivalents() {
3162        let semantics = ToolSemantics {
3163            observe: vec!["lookup_customer".to_string()],
3164            mutate: vec!["send_payment".to_string()],
3165            plan: vec!["create_workflow".to_string()],
3166            ..Default::default()
3167        };
3168
3169        for (builtin, configured) in [
3170            ("Read", "lookup_customer"),
3171            ("Write", "send_payment"),
3172            ("TodoWrite", "create_workflow"),
3173        ] {
3174            let messages_before_tool = || {
3175                vec![
3176                    Message::text(Role::User, "start"),
3177                    Message::text(Role::Assistant, "working"),
3178                    Message::text(Role::User, "continue"),
3179                    Message::text(Role::Assistant, "working"),
3180                    Message::text(Role::User, "continue"),
3181                    Message::text(Role::Assistant, "working"),
3182                    Message::text(Role::User, "continue"),
3183                ]
3184            };
3185            let mut builtin_messages = messages_before_tool();
3186            builtin_messages.push(tc(builtin));
3187            let mut configured_messages = messages_before_tool();
3188            configured_messages.push(tc(configured));
3189
3190            let builtin_score = score_signal(&ToolSignals::from_request(
3191                &with_messages(builtin_messages),
3192                None,
3193            ));
3194            let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
3195                &with_messages(configured_messages),
3196                None,
3197                &semantics,
3198            ));
3199
3200            assert_ne!(
3201                builtin_score.score, 0.0,
3202                "the {builtin:?} control must exercise a scoring dimension"
3203            );
3204            assert_eq!(
3205                configured_score, builtin_score,
3206                "configured tool {configured:?} must score exactly like {builtin:?}"
3207            );
3208        }
3209    }
3210
3211    #[test]
3212    fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
3213        let duplicate = ToolSemantics {
3214            observe: vec!["lookup".to_string()],
3215            mutate: vec!["LOOKUP".to_string()],
3216            ..Default::default()
3217        };
3218        assert!(
3219            duplicate
3220                .validate()
3221                .expect_err("duplicate should fail")
3222                .to_string()
3223                .contains("appears in both")
3224        );
3225
3226        let builtin = ToolSemantics {
3227            new: vec!["write_file".to_string()],
3228            ..Default::default()
3229        };
3230        assert!(
3231            builtin
3232                .validate()
3233                .expect_err("built-in should fail")
3234                .to_string()
3235                .contains("built-in semantics")
3236        );
3237
3238        let empty = ToolSemantics {
3239            observe: vec![" \t".to_string()],
3240            ..Default::default()
3241        };
3242        assert!(
3243            empty
3244                .validate()
3245                .expect_err("empty name should fail")
3246                .to_string()
3247                .contains("empty tool name")
3248        );
3249    }
3250}