Skip to main content

switchyard_libsy/algorithms/util/
tool_signals.rs

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