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