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