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    let conversation = switchyard_translation::conversation(&request.llm_request);
1059    let messages = &conversation.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 {
1770            responses: None,
1771            role: Role::Assistant,
1772            content: vec![ContentBlock::ToolCall(ToolCall {
1773                id: String::new(),
1774                name: name.to_string(),
1775                arguments: json!({}),
1776            })],
1777        }
1778    }
1779
1780    // assistant Bash message carrying `command`
1781    fn bash(command: &str) -> Message {
1782        Message {
1783            responses: None,
1784            role: Role::Assistant,
1785            content: vec![ContentBlock::ToolCall(ToolCall {
1786                id: String::new(),
1787                name: "Bash".to_string(),
1788                arguments: json!({"command": command}),
1789            })],
1790        }
1791    }
1792
1793    // a tool result message (goes in a user-role message, as in Anthropic's normalised form)
1794    fn tr(text: &str) -> Message {
1795        Message {
1796            responses: None,
1797            role: Role::User,
1798            content: vec![ContentBlock::ToolResult(ToolResult {
1799                tool_call_id: String::new(),
1800                content: vec![ContentBlock::Text {
1801                    responses: None,
1802                    text: text.to_string(),
1803                }],
1804                is_error: None,
1805            })],
1806        }
1807    }
1808
1809    #[test]
1810    fn user_turn_signals_exclude_old_errors_and_keep_user_role_tool_results() {
1811        let mut user = Message::text(Role::User, "next");
1812        user.content.extend(tr("out of memory").content);
1813        let request = with_messages(vec![tr("out of memory"), user, tr("ok")]);
1814        let signals = ToolSignalProcessor::default().for_user_turn(&request);
1815        assert_eq!(signals.tool_result_count, 1);
1816        assert_eq!(signals.severity, 0.0);
1817        assert_eq!(signals.turn_depth, 2);
1818    }
1819
1820    #[test]
1821    fn clean_text_has_zero_severity() {
1822        let (sev, patterns) = classify_text("everything went fine");
1823        assert_eq!(sev, 0.0);
1824        assert!(patterns.is_empty());
1825    }
1826
1827    #[test]
1828    fn structured_tool_failures_affect_recovery_signals() {
1829        for (text, severity) in [
1830            (r#"{"output":"","exit_code":7,"error":null}"#, SOFT),
1831            (r#"{"output":"","exit_code":-1,"error":null}"#, SOFT),
1832            (r#"{"success":false,"error":"No matching text"}"#, HARD),
1833            (r#"{"success":false,"error":null}"#, HARD),
1834            (
1835                r#"{"error":"Overwrite refused","stale_write_blocked":true}"#,
1836                HARD,
1837            ),
1838            (r#"{"output":"out of memory","exit_code":1}"#, CRITICAL),
1839            (r#"{"output":"done","exit_code":0,"error":null}"#, 0.0),
1840            (r#"{"success":true,"error":"  "}"#, 0.0),
1841            (r#"{"output":"running","exit_code":null,"error":null}"#, 0.0),
1842            (r#"{"output":{"success":false},"exit_code":0}"#, 0.0),
1843        ] {
1844            let warned = format!("{text}\n\n[Tool loop warning: repeated identical call]");
1845            let request = with_messages(vec![tr("5 passed in 0.12s"), tr(text), tr(&warned)]);
1846            let signal = ToolSignals::from_request(&request, None);
1847            let clean = severity == 0.0;
1848            assert_eq!(signal.severity, severity, "{text}");
1849            assert_eq!(signal.no_error_streak, if clean { 3 } else { 0 }, "{text}");
1850            assert_eq!(signal.repeated_failure, severity >= HARD, "{text}");
1851            assert_eq!(signal.tests_passed, clean, "{text}");
1852        }
1853    }
1854
1855    #[test]
1856    fn traceback_is_hard() {
1857        let (sev, patterns) = classify_text("Traceback (most recent call last):\n  ValueError");
1858        assert_eq!(sev, HARD);
1859        assert!(patterns.contains(&"traceback".to_string()));
1860    }
1861
1862    #[test]
1863    fn oom_is_critical() {
1864        let (sev, _) = classify_text("Out of memory: kill process 1234");
1865        assert_eq!(sev, CRITICAL);
1866    }
1867
1868    #[test]
1869    fn connection_refused_is_hard() {
1870        let (severity, _) = classify_text("Connection refused on port 8000");
1871        assert_eq!(severity, HARD);
1872    }
1873
1874    #[test]
1875    fn repeated_failure_ignores_volatile_paths_and_numbers() {
1876        let request = with_messages(vec![
1877            tr("error[E0308]: mismatched types at /tmp/a/src/lib.rs:12"),
1878            tr("error[E0308]: mismatched types at /tmp/b/src/lib.rs:47"),
1879        ]);
1880        assert!(ToolSignals::from_request(&request, None).repeated_failure);
1881    }
1882
1883    #[test]
1884    fn different_failures_are_not_repeated() {
1885        let request = with_messages(vec![
1886            tr("error[E0308]: mismatched types"),
1887            tr("error[E0509]: cannot move out"),
1888        ]);
1889        assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1890    }
1891
1892    #[test]
1893    fn one_material_failure_is_not_repeated() {
1894        let request = with_messages(vec![tr("Connection refused on port 8000")]);
1895        assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1896    }
1897
1898    /// Explicit failures count even without diagnostic text and cannot signal recovery.
1899    #[test]
1900    fn structured_tool_failures_feed_error_and_recovery_signals() {
1901        for text in ["Dependency unavailable", "", "5 passed in 0.12s"] {
1902            let mut failed = tr(text);
1903            let ContentBlock::ToolResult(result) = &mut failed.content[0] else {
1904                panic!("expected tool result");
1905            };
1906            result.is_error = Some(true);
1907            let mut request = with_messages(vec![tr("5 passed in 0.12s"), failed.clone()]);
1908            let signals = ToolSignals::from_request(&request, Some(3));
1909            assert_eq!(signals.severity, HARD);
1910            assert!(!signals.repeated_failure);
1911            assert_eq!(signals.no_error_streak, 0);
1912            assert!(!signals.tests_passed);
1913
1914            request.llm_request.messages.push(failed);
1915            assert!(ToolSignals::from_request(&request, Some(3)).repeated_failure);
1916            request.llm_request.messages.push(tr("5 passed in 0.12s"));
1917            let recovered = ToolSignals::from_request(&request, Some(1));
1918            assert_eq!(recovered.severity, 0.0);
1919            assert!(!recovered.repeated_failure);
1920            assert_eq!(recovered.no_error_streak, 1);
1921            assert!(recovered.tests_passed);
1922        }
1923    }
1924
1925    #[test]
1926    fn severity_is_max_across_patterns() {
1927        // exit_nonzero (SOFT) + traceback (HARD) → HARD.
1928        let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
1929        assert_eq!(sev, HARD);
1930    }
1931
1932    #[test]
1933    fn codex_process_exit_zero_stays_clean() {
1934        let (sev, patterns) =
1935            classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
1936        assert_eq!(sev, 0.0);
1937        assert!(!patterns.contains(&"exit_nonzero".to_string()));
1938    }
1939
1940    #[test]
1941    fn nonzero_exit_codes_are_soft_errors() {
1942        let cases = [
1943            "Process exited with code 1",
1944            "Process exited with code 127",
1945            "exit code: 2",
1946            "exit status 3",
1947            "exited with status 9",
1948        ];
1949        for case in cases {
1950            let (sev, patterns) = classify_text(case);
1951            assert_eq!(sev, SOFT, "expected soft severity for {case}");
1952            assert!(patterns.contains(&"exit_nonzero".to_string()));
1953        }
1954    }
1955
1956    #[test]
1957    fn partial_process_failures_are_hard_errors() {
1958        let cases = [
1959            (
1960                "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
1961                "compile_error",
1962            ),
1963            (
1964                "Process exited with code 0\nOutput:\nTypeError: value is undefined\n    at main.js:1:2",
1965                "runtime_exception",
1966            ),
1967            (
1968                "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
1969                "runtime_panic",
1970            ),
1971            (
1972                "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
1973                "patch_error",
1974            ),
1975        ];
1976        for (text, expected_pattern) in cases {
1977            let (severity, patterns) = classify_text(text);
1978            assert_eq!(severity, HARD, "expected hard severity for {text}");
1979            assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
1980        }
1981    }
1982
1983    /// A harness that strips the trailing newline from captured output must not
1984    /// silently drop a shell "not found" error to severity 0.0.
1985    #[test]
1986    fn trimmed_command_not_found_is_still_hard() {
1987        let cases = [
1988            "git: not found",
1989            "sh: 1: git: not found",
1990            "/bin/sh: 1: git: not found",
1991        ];
1992        for text in cases {
1993            let (sev, patterns) = classify_text(text);
1994            assert_eq!(sev, HARD, "expected hard severity for {text}");
1995            assert!(patterns.contains(&"cmd_not_found".to_string()));
1996        }
1997    }
1998
1999    /// Precision guard: "not found" must end the line to count as a shell error,
2000    /// so prose that merely mentions it does not trip a false error.
2001    #[test]
2002    fn not_found_mid_sentence_stays_clean() {
2003        let (sev, _) =
2004            classify_text("Looking for config key, not found in environment, using default.");
2005        assert_eq!(sev, 0.0);
2006    }
2007
2008    /// Precision guard: a line may end in "not found" and still report success.
2009    /// Only the shell's `<name>: not found` shape counts.
2010    #[test]
2011    fn not_found_without_shell_separator_stays_clean() {
2012        for text in [
2013            "HTTP/1.1 404 Not Found",
2014            "lookup finished, key not found",
2015            "GET /healthz -> 404 Not Found",
2016        ] {
2017            let (sev, patterns) = classify_text(text);
2018            assert_eq!(sev, 0.0, "expected clean classification for {text}");
2019            assert!(!patterns.contains(&"cmd_not_found".to_string()));
2020        }
2021    }
2022
2023    #[test]
2024    fn source_text_that_names_exceptions_stays_clean() {
2025        let text =
2026            "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
2027        assert_eq!(classify_text(text).0, 0.0);
2028    }
2029
2030    #[test]
2031    fn file_does_not_exist_is_hard() {
2032        // Claude Code Read-tool miss. Trace-mined addition (22 true / 2 false positives).
2033        let (sev, patterns) =
2034            classify_text("Error: File does not exist. Note: current working directory is /app.");
2035        assert_eq!(sev, HARD);
2036        assert!(patterns.contains(&"no_such_file".to_string()));
2037    }
2038
2039    #[test]
2040    fn bare_does_not_exist_stays_clean() {
2041        // Precision guard: only the anchored "file does not exist" fires, so a bare
2042        // "does not exist" in prose or directory output must not trip a false error.
2043        let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
2044        assert_eq!(sev, 0.0);
2045    }
2046
2047    #[test]
2048    fn no_error_streak_all_clean() {
2049        let texts = vec![("ok".to_string(), false), ("all good".to_string(), false)];
2050        assert_eq!(compute_no_error_streak(&texts), 2);
2051    }
2052
2053    #[test]
2054    fn no_error_streak_stops_at_error() {
2055        let texts = vec![
2056            ("Traceback (most recent call last):".to_string(), false),
2057            ("ok".to_string(), false),
2058            ("ok".to_string(), false),
2059        ];
2060        assert_eq!(compute_no_error_streak(&texts), 2);
2061    }
2062
2063    #[test]
2064    fn tests_passed_detects_pytest_output() {
2065        assert!(detect_tests_passed(
2066            &[("====== 5 passed in 0.12s ======".to_string(), false)],
2067            DEFAULT_RECENT_WINDOW
2068        ));
2069    }
2070
2071    #[test]
2072    fn tests_passed_ignores_partial_failures() {
2073        assert!(!detect_tests_passed(
2074            &[("2 failed, 5 passed in 0.56s".to_string(), false)],
2075            DEFAULT_RECENT_WINDOW
2076        ));
2077    }
2078
2079    #[test]
2080    fn tests_passed_must_follow_the_latest_failure() {
2081        assert!(!detect_tests_passed(
2082            &[
2083                ("5 passed in 0.12s".to_string(), false),
2084                (
2085                    "Traceback (most recent call last):\nValueError".to_string(),
2086                    false
2087                ),
2088                ("edit applied".to_string(), false),
2089            ],
2090            DEFAULT_RECENT_WINDOW
2091        ));
2092        assert!(detect_tests_passed(
2093            &[
2094                (
2095                    "Traceback (most recent call last):\nValueError".to_string(),
2096                    false
2097                ),
2098                ("5 passed in 0.12s".to_string(), false),
2099            ],
2100            DEFAULT_RECENT_WINDOW
2101        ));
2102    }
2103
2104    #[test]
2105    fn retrieved_file_contents_are_ignored() {
2106        let call = |id: &str, name: &str, arguments: Value| Message {
2107            responses: None,
2108            role: Role::Assistant,
2109            content: vec![ContentBlock::ToolCall(ToolCall {
2110                id: id.to_string(),
2111                name: name.to_string(),
2112                arguments,
2113            })],
2114        };
2115        let result = |id: &str, text: &str| Message {
2116            responses: None,
2117            role: Role::User,
2118            content: vec![ContentBlock::ToolResult(ToolResult {
2119                tool_call_id: id.to_string(),
2120                content: vec![ContentBlock::Text {
2121                    responses: None,
2122                    text: text.to_string(),
2123                }],
2124                is_error: None,
2125            })],
2126        };
2127        let signal = extract_tool_signals_with_window(
2128            &with_messages(vec![
2129                call("a", "Bash", json!({"command": "pytest"})),
2130                result("a", "Traceback (most recent call last):\nValueError"),
2131                call("b", "Read", json!({"file_path": "notes.md"})),
2132                result(
2133                    "b",
2134                    r#"{"success":false,"error":"out of memory","exit_code":7}"#,
2135                ),
2136                call("c", "Grep", json!({"pattern": "passed"})),
2137                result("c", "CHANGELOG.md: all tests passed"),
2138            ]),
2139            DEFAULT_RECENT_WINDOW,
2140        );
2141        // Only the real pytest run counts.
2142        assert_eq!(signal.severity, HARD);
2143        assert!(!signal.tests_passed);
2144        assert_eq!(signal.tool_result_count, 3);
2145
2146        for (command, text, severity) in [
2147            ("cat config.json", r#"{"error":"fixture data"}"#, 0.0),
2148            (
2149                "cat missing.json",
2150                r#"{"output":"","exit_code":7,"error":null}"#,
2151                SOFT,
2152            ),
2153            (
2154                "cat config.json && python check.py",
2155                r#"{"error":"check rejected"}"#,
2156                HARD,
2157            ),
2158        ] {
2159            let request = with_messages(vec![
2160                call("shell", "Bash", json!({"command": command})),
2161                result("shell", text),
2162            ]);
2163            assert_eq!(
2164                ToolSignals::from_request(&request, None).severity,
2165                severity,
2166                "{text}"
2167            );
2168        }
2169    }
2170
2171    // A request whose tool result answers the tool call.
2172    fn call_with_result(mut call: Message, mut result: Message) -> Request {
2173        if let ContentBlock::ToolCall(call) = &mut call.content[0] {
2174            call.id = "call-1".into();
2175        }
2176        if let ContentBlock::ToolResult(result) = &mut result.content[0] {
2177            result.tool_call_id = "call-1".into();
2178        }
2179        with_messages(vec![call, result])
2180    }
2181
2182    #[test]
2183    fn shell_reads_are_ignored_but_keep_their_window_slot() {
2184        for command in [
2185            "/usr/bin/cat logfile.txt",
2186            "cd /repo && tail -n 100 logfile.txt | grep MemoryError",
2187            "env MODE=debug timeout 5s rg 'MemoryError' .",
2188        ] {
2189            let mut request = call_with_result(bash(command), tr("MemoryError\nall tests passed"));
2190            request.llm_request.messages.splice(
2191                0..0,
2192                [bash("pytest"), tr("Traceback (most recent call last):")],
2193            );
2194            // The read adds no error and no test pass, so the earlier failure stands.
2195            let signal = ToolSignals::from_request(&request, Some(2));
2196            assert_eq!(signal.severity, HARD, "{command}");
2197            assert!(!signal.tests_passed, "{command}");
2198            // The read still fills a window slot, so the failure can age out.
2199            let signal = ToolSignals::from_request(&request, Some(1));
2200            assert_eq!(signal.severity, 0.0, "{command}");
2201        }
2202    }
2203
2204    #[test]
2205    fn shell_commands_that_run_code_keep_signals() {
2206        let oversized = format!("cat {}", "a".repeat(MAX_RETRIEVAL_COMMAND_BYTES));
2207        for command in [
2208            "cat logfile.txt; pytest",
2209            "echo $(pytest)",
2210            "find . -name '*.py' -exec pytest ';'",
2211            oversized.as_str(),
2212        ] {
2213            let request = call_with_result(bash(command), tr("MemoryError"));
2214            let signal = ToolSignals::from_request(&request, None);
2215            assert_eq!(signal.severity, CRITICAL, "{command}");
2216        }
2217    }
2218
2219    #[test]
2220    fn failed_reads_signal_unless_the_file_is_missing() {
2221        let failed = |text: &str| {
2222            let mut result = tr(text);
2223            if let ContentBlock::ToolResult(result) = &mut result.content[0] {
2224                result.is_error = Some(true);
2225            }
2226            result
2227        };
2228        let missing = "missing.txt: No such file or directory";
2229        let severities = [
2230            call_with_result(bash("cat missing.txt"), failed(missing)),
2231            call_with_result(tc("Read"), failed("File does not exist.")),
2232            // A read that fails for another reason still counts.
2233            call_with_result(bash("cat logfile.txt"), failed("MemoryError")),
2234            // Only reads are exempt.
2235            call_with_result(bash("python missing.txt"), failed(missing)),
2236        ]
2237        .map(|request| ToolSignals::from_request(&request, None).severity);
2238        assert_eq!(severities, [0.0, 0.0, CRITICAL, HARD]);
2239
2240        let request = call_with_result(
2241            bash("cat missing.json"),
2242            tr(r#"{"output":"No such file or directory","exit_code":1}"#),
2243        );
2244        assert_eq!(ToolSignals::from_request(&request, None).severity, 0.0);
2245    }
2246
2247    #[test]
2248    fn codex_read_status_comes_from_the_header() {
2249        let read = |header: &str| {
2250            let output = format!(
2251                "{header}\nOutput:\nTraceback (most recent call last):\nMemoryError\nProcess exited with code 1\n"
2252            );
2253            let call = exec_command(json!({"cmd": "cat run.log"}));
2254            ToolSignals::from_request(&call_with_result(call, tr(&output)), None).severity
2255        };
2256        // The exit line belongs to the log that was read.
2257        assert_eq!(read("Chunk ID: abc\nProcess exited with code 0"), 0.0);
2258        // The read itself failed, so its output counts.
2259        assert_eq!(read("Chunk ID: abc\nProcess exited with code 1"), CRITICAL);
2260    }
2261
2262    #[test]
2263    fn hermes_read_status_comes_from_exit_code() {
2264        let read = |exit_code: i64| {
2265            let output = json!({
2266                "output": "Traceback (most recent call last):\nMemoryError\nProcess exited with code 1\n",
2267                "exit_code": exit_code,
2268                "error": null,
2269            });
2270            let call = Message {
2271                responses: None,
2272                role: Role::Assistant,
2273                content: vec![ContentBlock::ToolCall(ToolCall {
2274                    id: String::new(),
2275                    name: "terminal".to_string(),
2276                    arguments: json!({"command": "cat run.log"}),
2277                })],
2278            };
2279            ToolSignals::from_request(&call_with_result(call, tr(&output.to_string())), None)
2280                .severity
2281        };
2282        // The exit line belongs to the log that was read.
2283        assert_eq!(read(0), 0.0);
2284        // The read itself failed, so its output counts.
2285        assert_eq!(read(1), CRITICAL);
2286    }
2287
2288    #[test]
2289    fn argv_commands_keep_word_boundaries() {
2290        let signal = |argv: Value| {
2291            let call = exec_command(json!({"cmd": argv}));
2292            ToolSignals::from_request(&call_with_result(call, tr("MemoryError")), None)
2293        };
2294        let mut many_args = vec![""; MAX_RETRIEVAL_COMMAND_BYTES];
2295        many_args[0] = "cat";
2296        let severities = [
2297            json!(["bash", "-lc", "cd repo && rg foo"]),
2298            json!(["bash", "-lc", "cat log; pytest"]),
2299            // One argv word is one file name, even with a separator in it.
2300            json!(["cat", "log; pytest"]),
2301            // Over the byte budget, by size and by argument count.
2302            json!(["cat", "a".repeat(MAX_RETRIEVAL_COMMAND_BYTES)]),
2303            json!(many_args),
2304        ]
2305        .map(|argv| signal(argv).severity);
2306        assert_eq!(severities, [0.0, CRITICAL, 0.0, CRITICAL, CRITICAL]);
2307        // A read is an observation, even as a whole `-lc` script.
2308        let read = signal(json!(["bash", "-lc", "sed -n 1,200p x"]));
2309        assert_eq!(read.read_count, 1);
2310        // Other commands are counted inside the script too.
2311        let write = signal(json!(["bash", "-lc", "cd repo && mkdir x"]));
2312        assert_eq!(write.write_count, 1);
2313    }
2314
2315    #[test]
2316    fn severity_is_windowed_over_recent_results() {
2317        // An error two results back, then two clean results.
2318        let request = with_messages(vec![
2319            tr("Traceback (most recent call last):\n  ValueError"),
2320            tr("ok"),
2321            tr("ok"),
2322        ]);
2323        // window covers the error → severity persists (max over the window)
2324        assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
2325        // window of 1 sees only the last (clean) result → severity has decayed out
2326        assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
2327    }
2328
2329    #[test]
2330    fn extract_openai_chat_tool_results() {
2331        let request = with_messages(vec![
2332            Message::text(Role::User, "do something"),
2333            tc("Edit"),
2334            tr("Traceback (most recent call last):\n  ValueError"),
2335        ]);
2336        let sig = ToolSignals::from_request(&request, None);
2337        assert_eq!(sig.severity, HARD);
2338        assert_eq!(sig.edit_count, 1);
2339        assert_eq!(sig.turn_depth, 3);
2340    }
2341
2342    #[test]
2343    fn extract_anthropic_tool_results() {
2344        let request = with_messages(vec![tr("Traceback (most recent call last):\n  ValueError")]);
2345        let sig = ToolSignals::from_request(&request, None);
2346        assert_eq!(sig.severity, HARD);
2347    }
2348
2349    #[test]
2350    fn extract_responses_api_tool_results() {
2351        let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
2352        let sig = ToolSignals::from_request(&request, None);
2353        assert_eq!(sig.severity, 0.0);
2354        assert_eq!(sig.write_count, 1);
2355    }
2356
2357    #[test]
2358    fn responses_builtin_tool_failures_escalate() {
2359        use crate::algorithms::util::stage::{PickOutcome, PickerMode, Tier, pick_tier};
2360
2361        let mut cases = Vec::new();
2362        for (status, output) in [
2363            (
2364                "failed",
2365                "Synthetic dependency unavailable; retry with the recovery path.",
2366            ),
2367            (
2368                "completed",
2369                "Synthetic dependency unavailable; retry with the recovery path.",
2370            ),
2371            ("failed", ""),
2372        ] {
2373            cases.push((
2374                json!({
2375                    "type": "apply_patch_call_output",
2376                    "status": status,
2377                    "output": output,
2378                }),
2379                if status == "failed" { HARD } else { 0.0 },
2380            ));
2381        }
2382        for (outcome, stdout, stderr, severity) in [
2383            (json!({"type": "exit", "exit_code": 1}), "", "", SOFT),
2384            (json!({"type": "exit", "exit_code": 1}), "", " \n", SOFT),
2385            (
2386                json!({"type": "exit", "exit_code": 1}),
2387                "",
2388                "command failed",
2389                HARD,
2390            ),
2391            (json!({"type": "timeout"}), "", "", HARD),
2392            (json!({"type": "exit", "exit_code": 0}), "done", "", 0.0),
2393            (
2394                json!({"type": "exit", "exit_code": 0}),
2395                "Traceback (most recent call last):",
2396                "",
2397                HARD,
2398            ),
2399            (
2400                json!({"type": "exit", "exit_code": 0}),
2401                "",
2402                "Traceback (most recent call last):",
2403                HARD,
2404            ),
2405        ] {
2406            cases.push((
2407                json!({
2408                    "type": "shell_call_output",
2409                    "output": [
2410                        {"stdout": stdout, "stderr": stderr, "outcome": outcome},
2411                        {"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}
2412                    ],
2413                }),
2414                severity,
2415            ));
2416        }
2417        for (raw, severity) in cases {
2418            let is_error = severity >= HARD;
2419            let mut request = with_messages(
2420                ["call_1", "call_2"]
2421                    .into_iter()
2422                    .map(|call_id| {
2423                        let mut raw = raw.clone();
2424                        raw["call_id"] = json!(call_id);
2425                        Message {
2426                            responses: None,
2427                            role: Role::User,
2428                            content: vec![ContentBlock::Unknown {
2429                                provider: WireFormat::OpenAiResponses.into(),
2430                                raw,
2431                            }],
2432                        }
2433                    })
2434                    .collect(),
2435            );
2436            let signal = ToolSignals::from_request(&request, Some(3));
2437            assert_eq!(signal.severity, severity, "{raw}");
2438            assert_eq!(signal.repeated_failure, is_error, "{raw}");
2439            assert_eq!(signal.tool_result_count, 2);
2440            assert_eq!(
2441                matches!(
2442                    pick_tier(&signal, PickerMode::EfficientFirst, 0.5),
2443                    PickOutcome::Resolved {
2444                        tier: Tier::Capable,
2445                        ..
2446                    }
2447                ),
2448                is_error,
2449                "{raw}"
2450            );
2451
2452            let mut success = match raw["type"].as_str() {
2453                Some("apply_patch_call_output") => json!({
2454                    "type": "apply_patch_call_output", "status": "completed", "output": ""
2455                }),
2456                Some("shell_call_output") => json!({
2457                    "type": "shell_call_output",
2458                    "output": [{"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}]
2459                }),
2460                _ => unreachable!(),
2461            };
2462            for index in 0..3 {
2463                success["call_id"] = json!(format!("success_{index}"));
2464                request.llm_request.messages.push(Message {
2465                    responses: None,
2466                    role: Role::User,
2467                    content: vec![ContentBlock::Unknown {
2468                        provider: WireFormat::OpenAiResponses.into(),
2469                        raw: success.clone(),
2470                    }],
2471                });
2472            }
2473            let recovered = ToolSignals::from_request(&request, Some(3));
2474            assert_eq!(recovered.severity, 0.0, "{raw}");
2475            assert!(!recovered.repeated_failure, "{raw}");
2476            assert!(recovered.no_error_streak >= 3, "{raw}");
2477            assert_eq!(recovered.tool_result_count, 5);
2478        }
2479    }
2480
2481    #[test]
2482    fn conversation_counts_are_per_block_and_role_aware() {
2483        // A batched user message (Anthropic shape) contributes one count per
2484        // ToolResult block, empty-content results included. assistant_turn_count
2485        // tracks Role::Assistant only, while turn_depth counts every message.
2486        let result = |content: Vec<ContentBlock>| {
2487            ContentBlock::ToolResult(ToolResult {
2488                tool_call_id: String::new(),
2489                content,
2490                is_error: None,
2491            })
2492        };
2493        let request = with_messages(vec![
2494            Message::text(Role::User, "do something"),
2495            Message::text(Role::Assistant, "working"),
2496            Message {
2497                responses: None,
2498                role: Role::User,
2499                content: vec![
2500                    result(vec![ContentBlock::Text {
2501                        responses: None,
2502                        text: "ok".to_string(),
2503                    }]),
2504                    result(Vec::new()),
2505                ],
2506            },
2507            tc("Bash"),
2508        ]);
2509        let sig = ToolSignals::from_request(&request, None);
2510        assert_eq!(sig.tool_result_count, 2);
2511        assert_eq!(sig.assistant_turn_count, 2);
2512        assert_eq!(sig.turn_depth, 4);
2513    }
2514
2515    #[test]
2516    fn recent_window_counts_only_last_default_window_tool_calls() {
2517        // 5 writes + 1 edit at the end → the default window (3) should see
2518        // the last 3 calls: 1 edit + 2 writes (not all 6 calls).
2519        let request = with_messages(vec![
2520            tc("Write"),
2521            tr("ok"),
2522            tc("Write"),
2523            tr("ok"),
2524            tc("Write"),
2525            tr("ok"),
2526            tc("Write"),
2527            tr("ok"),
2528            tc("Write"),
2529            tr("ok"),
2530            tc("Edit"),
2531            tr("ok"),
2532        ]);
2533        let sig = ToolSignals::from_request(&request, None);
2534        assert_eq!(sig.write_count, 5);
2535        assert_eq!(sig.edit_count, 1);
2536        assert_eq!(sig.recent_write_count, 2);
2537        assert_eq!(sig.recent_edit_count, 1);
2538    }
2539
2540    #[test]
2541    fn codex_apply_patch_counts_as_an_edit() {
2542        let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
2543        let sig = ToolSignals::from_request(&request, None);
2544        assert_eq!(sig.edit_count, 1);
2545        assert_eq!(sig.recent_edit_count, 1);
2546    }
2547
2548    fn exec_command(cmd: Value) -> Message {
2549        Message {
2550            responses: None,
2551            role: Role::Assistant,
2552            content: vec![ContentBlock::ToolCall(ToolCall {
2553                id: String::new(),
2554                name: "exec_command".to_string(),
2555                arguments: cmd,
2556            })],
2557        }
2558    }
2559
2560    #[test]
2561    fn codex_exec_command_is_classified() {
2562        // arguments arrive as a JSON string, with the command under `cmd`
2563        let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
2564        let request = with_messages(vec![exec_command(args), tr("ok")]);
2565        assert_eq!(
2566            ToolSignals::from_request(&request, None).recent_edit_count,
2567            1
2568        );
2569    }
2570
2571    #[test]
2572    fn python_write_expressions_need_a_python_command() {
2573        let write = with_messages(vec![
2574            exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
2575            tr("ok"),
2576        ]);
2577        assert_eq!(
2578            ToolSignals::from_request(&write, None).recent_write_count,
2579            1
2580        );
2581
2582        let search = with_messages(vec![
2583            exec_command(json!({"cmd": "grep -R '.write(' src"})),
2584            tr("ok"),
2585        ]);
2586        assert_eq!(
2587            ToolSignals::from_request(&search, None).recent_write_count,
2588            0
2589        );
2590    }
2591
2592    #[test]
2593    fn recent_window_size_is_caller_overridable() {
2594        // Same six tool calls (1 edit at the end, 5 writes before).
2595        // With recent_window=3 → recent_writes=2, recent_edits=1.
2596        // With recent_window=6 → recent_writes=5, recent_edits=1 (all calls).
2597        let request = with_messages(vec![
2598            tc("Write"),
2599            tr("ok"),
2600            tc("Write"),
2601            tr("ok"),
2602            tc("Write"),
2603            tr("ok"),
2604            tc("Write"),
2605            tr("ok"),
2606            tc("Write"),
2607            tr("ok"),
2608            tc("Edit"),
2609            tr("ok"),
2610        ]);
2611        let narrow = extract_tool_signals_with_window(&request, 3);
2612        assert_eq!(narrow.recent_write_count, 2);
2613        assert_eq!(narrow.recent_edit_count, 1);
2614
2615        let wide = extract_tool_signals_with_window(&request, 6);
2616        assert_eq!(wide.recent_write_count, 5);
2617        assert_eq!(wide.recent_edit_count, 1);
2618    }
2619
2620    #[test]
2621    fn compaction_marker_sets_compacted() {
2622        // The compaction summary is a user message carrying Claude Code's preamble.
2623        let request = with_messages(vec![
2624            Message::text(
2625                Role::User,
2626                "This session is being continued from a previous conversation that ran out of context.",
2627            ),
2628            Message::text(Role::User, "Continue with the next task"),
2629            bash("ls"),
2630        ]);
2631        assert!(ToolSignals::from_request(&request, None).compacted);
2632        assert!(
2633            ToolSignalProcessor::default()
2634                .for_user_turn(&request)
2635                .compacted
2636        );
2637    }
2638
2639    #[test]
2640    fn hermes_compaction_headers_set_compacted() {
2641        for header in [
2642            "[CONTEXT COMPACTION \u{2014} REFERENCE ONLY] Earlier turns were compacted into the summary below.",
2643            "[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.]",
2644        ] {
2645            for role in [Role::User, Role::Assistant] {
2646                let request = with_messages(vec![
2647                    Message::text(role, format!("{header}\nContinue the task.")),
2648                    bash("ls"),
2649                ]);
2650                assert!(ToolSignals::from_request(&request, None).compacted);
2651            }
2652            let request = with_messages(vec![Message::text(
2653                Role::User,
2654                format!("Prior context.\n\n  {header}\nContinue the task."),
2655            )]);
2656            assert!(ToolSignals::from_request(&request, None).compacted);
2657            let request = with_messages(vec![tr(header)]);
2658            assert!(!ToolSignals::from_request(&request, None).compacted);
2659        }
2660    }
2661
2662    #[test]
2663    fn ordinary_compaction_text_stays_uncompacted() {
2664        for text in [
2665            "The task is still in progress after the compaction boundary.",
2666            "Explain [CONTEXT COMPACTION \u{2014} REFERENCE ONLY] in the docs.",
2667            "[STILL IN PROGRESS] Continue the task.",
2668        ] {
2669            let request = with_messages(vec![Message::text(Role::User, text)]);
2670            assert!(!ToolSignals::from_request(&request, None).compacted);
2671        }
2672    }
2673
2674    #[test]
2675    fn codex_compaction_metadata_stays_on_parent_route() {
2676        let mut request = with_messages(vec![bash("ls")]);
2677        request.metadata = Some(Metadata {
2678            is_subagent: true,
2679            agent_kind: Some("compact".to_string()),
2680            ..Default::default()
2681        });
2682        assert!(!ToolSignals::from_request(&request, None).compacted);
2683    }
2684
2685    #[test]
2686    fn no_compaction_marker_stays_uncompacted() {
2687        let request = with_messages(vec![
2688            Message::text(Role::User, "Write a script that parses the log file."),
2689            bash("ls"),
2690        ]);
2691        assert!(!ToolSignals::from_request(&request, None).compacted);
2692    }
2693
2694    #[test]
2695    fn bash_heredoc_counts_as_write() {
2696        // Claude Code's pattern on TB 2.0 — write a scratch file via heredoc.
2697        let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
2698        let sig = ToolSignals::from_request(&request, None);
2699        assert_eq!(
2700            sig.write_count, 1,
2701            "Bash heredoc should bucket into write_count"
2702        );
2703        assert_eq!(sig.edit_count, 0);
2704    }
2705
2706    #[test]
2707    fn bash_sed_inplace_counts_as_edit() {
2708        let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
2709        let sig = ToolSignals::from_request(&request, None);
2710        assert_eq!(
2711            sig.edit_count, 1,
2712            "Bash sed -i should bucket into edit_count"
2713        );
2714        assert_eq!(sig.write_count, 0);
2715    }
2716
2717    #[test]
2718    fn bash_non_mutating_does_not_count() {
2719        // ls, cat, grep — should not increment either counter.
2720        let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
2721        let sig = ToolSignals::from_request(&request, None);
2722        assert_eq!(sig.write_count, 0);
2723        assert_eq!(sig.edit_count, 0);
2724    }
2725
2726    #[test]
2727    fn tests_passed_detects_pytest_with_failure_block() {
2728        // Mixed pytest run: 2 failed + 5 passed → NOT considered tests_passed.
2729        assert!(!detect_tests_passed(
2730            &[("2 failed, 5 passed in 0.56s".to_string(), false)],
2731            DEFAULT_RECENT_WINDOW
2732        ));
2733    }
2734
2735    #[test]
2736    fn tests_passed_accepts_cargo_clean_summary() {
2737        // Cargo's clean-run summary contains "0 failed" — must not trip the
2738        // failure list (regression: previously substring-matched "failed").
2739        assert!(detect_tests_passed(
2740            &[(
2741                "running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string(),
2742                false
2743            )],
2744            DEFAULT_RECENT_WINDOW
2745        ));
2746    }
2747
2748    #[test]
2749    fn tests_passed_rejects_cargo_real_failure() {
2750        // Cargo's actual-failure summary: nonzero count before "failed".
2751        assert!(!detect_tests_passed(
2752            &[(
2753                "running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string(),
2754                false
2755            )],
2756            DEFAULT_RECENT_WINDOW
2757        ));
2758    }
2759
2760    #[test]
2761    fn tests_passed_accepts_go_clean_summary() {
2762        // Go test's clean-run "0 errors" must not trip (regression).
2763        assert!(detect_tests_passed(
2764            &[(
2765                "ok  github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string(),
2766                false
2767            )],
2768            DEFAULT_RECENT_WINDOW
2769        ));
2770    }
2771
2772    #[test]
2773    fn tests_passed_accepts_pytest_zero_errors() {
2774        // Pytest long-form: "0 errors in 0.3s" on a clean run.
2775        assert!(detect_tests_passed(
2776            &[("5 passed, 0 errors in 0.30s".to_string(), false)],
2777            DEFAULT_RECENT_WINDOW
2778        ));
2779    }
2780
2781    #[test]
2782    fn tests_passed_detects_diy_checkmark() {
2783        assert!(detect_tests_passed(
2784            &[("✓ all checks passed".to_string(), false)],
2785            DEFAULT_RECENT_WINDOW
2786        ));
2787    }
2788
2789    #[test]
2790    fn anthropic_bash_heredoc_extracts_command() {
2791        // Anthropic format: tool_use.input is an object, not a JSON string.
2792        let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
2793        let sig = ToolSignals::from_request(&request, None);
2794        assert_eq!(
2795            sig.write_count, 1,
2796            "Anthropic Bash heredoc must also be detected"
2797        );
2798    }
2799
2800    #[test]
2801    fn recent_window_falls_back_to_full_history_when_short() {
2802        let request = with_messages(vec![tc("Write")]);
2803        let sig = ToolSignals::from_request(&request, None);
2804        assert_eq!(sig.recent_write_count, 1);
2805        assert_eq!(sig.recent_edit_count, 0);
2806    }
2807
2808    #[test]
2809    fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
2810        let request = with_messages(vec![tr("output ok"), tr("another ok")]);
2811        let sig = ToolSignals::from_request(&request, None);
2812        assert_eq!(sig.severity, 0.0);
2813        assert_eq!(sig.no_error_streak, 2);
2814    }
2815
2816    // ─── asymmetric-signal extensions ────────────────────────────────────
2817
2818    #[test]
2819    fn todowrite_classifies_as_plan() {
2820        assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
2821        assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
2822    }
2823
2824    #[test]
2825    fn codex_update_plan_classifies_as_plan() {
2826        assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
2827    }
2828
2829    #[test]
2830    fn codex_shell_command_runs_bash_pattern_match() {
2831        // shell_command + heredoc -> Write.
2832        assert_eq!(
2833            classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
2834            ToolSemantic::Mutate(MutationKind::Write),
2835        );
2836        // shell_command + read-like inspection -> Read.
2837        assert_eq!(
2838            classify_tool_call("shell_command", Some("ls /app")),
2839            ToolSemantic::Observe,
2840        );
2841        // shell_command without matching patterns -> Unknown.
2842        assert_eq!(
2843            classify_tool_call("shell_command", Some("./run_tests.sh")),
2844            ToolSemantic::Unknown,
2845        );
2846    }
2847
2848    #[test]
2849    fn text_editor_view_is_a_read() {
2850        for name in ["str_replace_based_edit_tool", "text_editor"] {
2851            assert_eq!(
2852                classify_tool_call(name, Some("view")),
2853                ToolSemantic::Observe
2854            );
2855            for command in [
2856                Some("create"),
2857                Some("insert"),
2858                Some("str_replace"),
2859                Some("undo_edit"),
2860                None,
2861            ] {
2862                assert_eq!(
2863                    classify_tool_call(name, command),
2864                    ToolSemantic::Mutate(MutationKind::Edit),
2865                );
2866            }
2867        }
2868
2869        let arguments = [
2870            json!({"command": "view", "path": "/app/main.py"}),
2871            // the Responses wire format sends arguments as a JSON string
2872            json!(r#"{"command":"view","path":"/app/main.py"}"#),
2873        ];
2874        for arguments in arguments {
2875            let call = Message {
2876                responses: None,
2877                role: Role::Assistant,
2878                content: vec![ContentBlock::ToolCall(ToolCall {
2879                    id: String::new(),
2880                    name: "str_replace_based_edit_tool".to_string(),
2881                    arguments,
2882                })],
2883            };
2884            let request = with_messages(vec![call, tr("print('hi')")]);
2885            let sig = ToolSignals::from_request(&request, None);
2886            assert_eq!(sig.read_count, 1);
2887            assert_eq!(sig.recent_read_count, 1);
2888            assert_eq!(sig.edit_count, 0);
2889        }
2890    }
2891
2892    #[test]
2893    fn read_tool_classifies_as_read() {
2894        assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
2895        assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
2896    }
2897
2898    #[test]
2899    fn hermes_tool_names_classify() {
2900        // Hermes (NousResearch) file tools route by name.
2901        assert_eq!(
2902            classify_tool_call("write_file", None),
2903            ToolSemantic::Mutate(MutationKind::Write)
2904        );
2905        assert_eq!(
2906            classify_tool_call("patch", None),
2907            ToolSemantic::Mutate(MutationKind::Edit)
2908        );
2909        assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
2910        assert_eq!(
2911            classify_tool_call("search_files", None),
2912            ToolSemantic::Observe
2913        );
2914        // Hermes runs shell through `terminal`, which carries a `command` arg,
2915        // so its intent comes from the Bash-pattern match like codex's shell_command.
2916        assert_eq!(
2917            classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
2918            ToolSemantic::Mutate(MutationKind::Edit),
2919        );
2920        assert_eq!(
2921            classify_tool_call("terminal", Some("grep foo /app")),
2922            ToolSemantic::Observe,
2923        );
2924        assert_eq!(
2925            classify_tool_call("terminal", Some("./run_tests.sh")),
2926            ToolSemantic::Unknown,
2927        );
2928    }
2929
2930    #[test]
2931    fn bash_read_patterns_classify_as_read() {
2932        let cases = [
2933            "cat /etc/passwd",
2934            "grep foo bar.txt",
2935            "ls /app",
2936            "find . -name '*.py'",
2937        ];
2938        for cmd in cases {
2939            assert_eq!(
2940                classify_tool_call("Bash", Some(cmd)),
2941                ToolSemantic::Observe,
2942                "expected Read for {cmd}"
2943            );
2944        }
2945    }
2946
2947    #[test]
2948    fn codex_inspection_commands_classify_as_read() {
2949        let cases = [
2950            "sed -n '1,80p' src/lib.rs",
2951            "rg -n 'needle' src",
2952            "nl -ba src/lib.rs",
2953            "cat package.json",
2954            "jq '.scripts' package.json",
2955            "git status --short",
2956            "git log --oneline -5",
2957            "git show HEAD:src/lib.rs",
2958            "git branch --show-current",
2959            "git remote -v",
2960            "git config --get remote.origin.url",
2961        ];
2962        for command in cases {
2963            assert_eq!(
2964                classify_tool_call("exec_command", Some(command)),
2965                ToolSemantic::Observe,
2966                "expected Read for {command}"
2967            );
2968        }
2969    }
2970
2971    #[test]
2972    fn quoted_shell_separators_do_not_create_commands() {
2973        for command in ["rg 'foo|rm obsolete.rs'", "rg \"foo; rm obsolete.rs\""] {
2974            assert_eq!(
2975                classify_tool_call("exec_command", Some(command)),
2976                ToolSemantic::Observe,
2977                "quoted text must not be parsed as a command: {command}"
2978            );
2979        }
2980    }
2981
2982    #[test]
2983    fn codex_shell_mutations_classify_as_production() {
2984        let writes = [
2985            "cp source.rs destination.rs",
2986            "mkdir -p src/generated",
2987            "touch src/generated/mod.rs",
2988            "git show HEAD:file.rs > file.rs",
2989            "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
2990        ];
2991        for command in writes {
2992            assert_eq!(
2993                classify_tool_call("exec_command", Some(command)),
2994                ToolSemantic::Mutate(MutationKind::Write),
2995                "expected Write for {command}"
2996            );
2997        }
2998
2999        let edits = [
3000            "mv old.rs new.rs",
3001            "rm obsolete.rs",
3002            "gofmt -w main.go",
3003            "cargo fmt",
3004            "ruff check --fix src",
3005            "perl -0pi -e 's/old/new/' src/lib.rs",
3006            "npx prettier --write src/lib.ts",
3007            "uv run ruff format src",
3008            "git apply fix.patch",
3009        ];
3010        for command in edits {
3011            assert_eq!(
3012                classify_tool_call("exec_command", Some(command)),
3013                ToolSemantic::Mutate(MutationKind::Edit),
3014                "expected Edit for {command}"
3015            );
3016        }
3017    }
3018
3019    #[test]
3020    fn formatter_checks_are_not_edits() {
3021        for command in [
3022            "cargo fmt --check",
3023            "ruff format --check src",
3024            "black --check src",
3025        ] {
3026            assert_ne!(
3027                classify_tool_call("exec_command", Some(command)),
3028                ToolSemantic::Mutate(MutationKind::Edit),
3029                "read-only formatter check must not be Edit: {command}"
3030            );
3031        }
3032    }
3033
3034    #[test]
3035    fn embedded_comparison_is_not_a_shell_write() {
3036        let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
3037        assert_eq!(
3038            classify_tool_call("exec_command", Some(command)),
3039            ToolSemantic::Unknown
3040        );
3041    }
3042
3043    #[test]
3044    fn bash_write_precedence_over_read() {
3045        // `cat /file > out` contains both `cat /` (read) and ` > ` (write);
3046        // write redirection must win.
3047        assert_eq!(
3048            classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
3049            ToolSemantic::Mutate(MutationKind::Write),
3050        );
3051    }
3052
3053    #[test]
3054    fn pure_bash_streak_counts_trailing_other() {
3055        // 5 trailing non-classified Bash calls → streak == 5.
3056        let request = with_messages(vec![
3057            bash("make"),
3058            tr("ok"),
3059            bash("./configure"),
3060            tr("ok"),
3061            bash("make install"),
3062            tr("ok"),
3063            bash("./run.sh"),
3064            tr("ok"),
3065            bash("./test"),
3066            tr("ok"),
3067        ]);
3068        let sig = ToolSignals::from_request(&request, None);
3069        assert_eq!(sig.pure_bash_streak, 5);
3070        assert_eq!(sig.write_count, 0);
3071        assert_eq!(sig.read_count, 0);
3072    }
3073
3074    #[test]
3075    fn pure_bash_streak_resets_on_write() {
3076        let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
3077        let sig = ToolSignals::from_request(&request, None);
3078        assert_eq!(sig.pure_bash_streak, 0);
3079        assert_eq!(sig.write_count, 1);
3080    }
3081
3082    #[test]
3083    fn recent_window_tracks_todowrite_and_read() {
3084        // Final 3 tool calls: TodoWrite, Read, TodoWrite.
3085        let request = with_messages(vec![
3086            bash("make"),
3087            tr("ok"),
3088            tc("TodoWrite"),
3089            tr("ok"),
3090            tc("Read"),
3091            tr("ok"),
3092            tc("TodoWrite"),
3093            tr("ok"),
3094        ]);
3095        let sig = ToolSignals::from_request(&request, None);
3096        assert_eq!(sig.todowrite_count, 2);
3097        assert_eq!(sig.recent_todowrite_count, 2);
3098        assert_eq!(sig.read_count, 1);
3099        assert_eq!(sig.recent_read_count, 1);
3100    }
3101
3102    #[test]
3103    fn configured_tool_semantics_extend_the_builtin_vocabulary() {
3104        let semantics = ToolSemantics {
3105            observe: vec!["KB_search".to_string()],
3106            mutate: vec!["send_payment_request".to_string()],
3107            plan: vec!["create_research_plan".to_string()],
3108            new: vec!["send_message_to_user".to_string()],
3109        };
3110        semantics.validate().expect("valid additive semantics");
3111        let request = with_messages(vec![
3112            tc("Read"),
3113            tc("Write"),
3114            tc("TodoWrite"),
3115            tc("kb_SEARCH"),
3116            tc("send_payment_request"),
3117            tc("create_research_plan"),
3118            tc("send_message_to_user"),
3119            tc("unlisted_tool"),
3120        ]);
3121
3122        let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
3123
3124        assert_eq!(signal.read_count, 2);
3125        assert_eq!(signal.write_count, 2);
3126        assert_eq!(signal.todowrite_count, 2);
3127        assert_eq!(signal.new_count, 1);
3128        assert_eq!(signal.recent_new_count, 1);
3129        assert_eq!(signal.pure_bash_streak, 1);
3130    }
3131
3132    #[test]
3133    fn configured_tool_semantics_match_namespaced_and_mcp_tools() {
3134        // The Responses decoder flattens namespaced tools and records the mapping.
3135        let mut request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
3136        request.llm_request.extensions.fields.insert(
3137            TOOL_NAMESPACES_KEY.to_string(),
3138            json!({"mcp__billing__send_payment_request": "mcp__billing"}),
3139        );
3140
3141        // Claude Code sends MCP tools flat, with no namespace mapping.
3142        let claude_request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
3143
3144        for request in [&request, &claude_request] {
3145            for name in ["send_payment_request", "mcp__billing__send_payment_request"] {
3146                let semantics = ToolSemantics {
3147                    mutate: vec![name.to_string()],
3148                    ..Default::default()
3149                };
3150                let signal = ToolSignals::from_request_with_semantics(request, None, &semantics);
3151                assert_eq!(signal.write_count, 1, "{name}");
3152            }
3153        }
3154    }
3155
3156    #[test]
3157    fn configured_tool_semantics_only_fold_ascii_case() {
3158        let semantics = ToolSemantics {
3159            observe: vec!["kb_search".to_string()],
3160            ..Default::default()
3161        };
3162
3163        assert_eq!(
3164            classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
3165            ToolSemantic::Observe
3166        );
3167        // U+212A lowercases to ASCII `k` under Unicode rules, but custom names
3168        // intentionally ignore only ASCII case.
3169        assert_eq!(
3170            classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
3171            ToolSemantic::Unknown
3172        );
3173    }
3174
3175    #[test]
3176    fn custom_semantics_preserve_builtin_unicode_lowercasing() {
3177        let semantics = ToolSemantics {
3178            observe: vec!["lookup_customer".to_string()],
3179            ..Default::default()
3180        };
3181
3182        // This matched the built-in `notebookedit` before custom semantics existed.
3183        assert_eq!(
3184            classify_tool_call_with_semantics("notebooKedit", None, &semantics),
3185            ToolSemantic::Mutate(MutationKind::Edit)
3186        );
3187    }
3188
3189    #[test]
3190    fn configured_semantics_never_replace_builtin_classifications() {
3191        let semantics = ToolSemantics {
3192            observe: vec!["lookup_customer".to_string()],
3193            mutate: vec!["send_payment".to_string()],
3194            plan: vec!["create_workflow".to_string()],
3195            new: vec!["send_message".to_string()],
3196        };
3197
3198        for name in WRITE_TOOL_NAMES {
3199            assert_eq!(
3200                classify_tool_call_with_semantics(name, None, &semantics),
3201                ToolSemantic::Mutate(MutationKind::Write),
3202                "write tool {name:?} changed classification"
3203            );
3204        }
3205        for name in EDIT_TOOL_NAMES {
3206            assert_eq!(
3207                classify_tool_call_with_semantics(name, None, &semantics),
3208                ToolSemantic::Mutate(MutationKind::Edit),
3209                "edit tool {name:?} changed classification"
3210            );
3211        }
3212        for name in READ_TOOL_NAMES {
3213            assert_eq!(
3214                classify_tool_call_with_semantics(name, None, &semantics),
3215                ToolSemantic::Observe,
3216                "read tool {name:?} changed classification"
3217            );
3218        }
3219        for name in PLAN_TOOL_NAMES {
3220            assert_eq!(
3221                classify_tool_call_with_semantics(name, None, &semantics),
3222                ToolSemantic::Plan,
3223                "plan tool {name:?} changed classification"
3224            );
3225        }
3226
3227        for (command, expected) in [
3228            ("cat /tmp/input", ToolSemantic::Observe),
3229            (
3230                "cat /tmp/input > /tmp/output",
3231                ToolSemantic::Mutate(MutationKind::Write),
3232            ),
3233            (
3234                "sed -i 's/a/b/' /tmp/file",
3235                ToolSemantic::Mutate(MutationKind::Edit),
3236            ),
3237            ("./run_tests.sh", ToolSemantic::Unknown),
3238        ] {
3239            assert_eq!(
3240                classify_tool_call_with_semantics("BASH", Some(command), &semantics),
3241                expected,
3242                "bash command {command:?} changed classification"
3243            );
3244        }
3245    }
3246
3247    #[test]
3248    fn configured_semantics_score_like_their_builtin_equivalents() {
3249        let semantics = ToolSemantics {
3250            observe: vec!["lookup_customer".to_string()],
3251            mutate: vec!["send_payment".to_string()],
3252            plan: vec!["create_workflow".to_string()],
3253            ..Default::default()
3254        };
3255
3256        for (builtin, configured) in [
3257            ("Read", "lookup_customer"),
3258            ("Write", "send_payment"),
3259            ("TodoWrite", "create_workflow"),
3260        ] {
3261            let messages_before_tool = || {
3262                vec![
3263                    Message::text(Role::User, "start"),
3264                    Message::text(Role::Assistant, "working"),
3265                    Message::text(Role::User, "continue"),
3266                    Message::text(Role::Assistant, "working"),
3267                    Message::text(Role::User, "continue"),
3268                    Message::text(Role::Assistant, "working"),
3269                    Message::text(Role::User, "continue"),
3270                ]
3271            };
3272            let mut builtin_messages = messages_before_tool();
3273            builtin_messages.push(tc(builtin));
3274            let mut configured_messages = messages_before_tool();
3275            configured_messages.push(tc(configured));
3276
3277            let builtin_score = score_signal(&ToolSignals::from_request(
3278                &with_messages(builtin_messages),
3279                None,
3280            ));
3281            let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
3282                &with_messages(configured_messages),
3283                None,
3284                &semantics,
3285            ));
3286
3287            assert_ne!(
3288                builtin_score.score, 0.0,
3289                "the {builtin:?} control must exercise a scoring dimension"
3290            );
3291            assert_eq!(
3292                configured_score, builtin_score,
3293                "configured tool {configured:?} must score exactly like {builtin:?}"
3294            );
3295        }
3296    }
3297
3298    #[test]
3299    fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
3300        let duplicate = ToolSemantics {
3301            observe: vec!["lookup".to_string()],
3302            mutate: vec!["LOOKUP".to_string()],
3303            ..Default::default()
3304        };
3305        assert!(
3306            duplicate
3307                .validate()
3308                .expect_err("duplicate should fail")
3309                .to_string()
3310                .contains("appears in both")
3311        );
3312
3313        let builtin = ToolSemantics {
3314            new: vec!["write_file".to_string()],
3315            ..Default::default()
3316        };
3317        assert!(
3318            builtin
3319                .validate()
3320                .expect_err("built-in should fail")
3321                .to_string()
3322                .contains("built-in semantics")
3323        );
3324
3325        let empty = ToolSemantics {
3326            observe: vec![" \t".to_string()],
3327            ..Default::default()
3328        };
3329        assert!(
3330            empty
3331                .validate()
3332                .expect_err("empty name should fail")
3333                .to_string()
3334                .contains("empty tool name")
3335        );
3336    }
3337}