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