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//! pattern-matches their text against a curated 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::path::Path;
16
17use async_trait::async_trait;
18use serde::Deserialize;
19use serde_json::Value;
20use switchyard_protocol::{ContentBlock, Request, Role};
21
22use crate::{LibsyError, Result};
23
24use crate::core::processor::{Event, Processor};
25use crate::core::state::State;
26
27// ─── severity constants ───────────────────────────────────────────────────────
28
29const SOFT: f32 = 0.3;
30const HARD: f32 = 0.7;
31const CRITICAL: f32 = 1.0;
32
33// ─── pattern table ────────────────────────────────────────────────────────────
34
35/// (name, severity, lower-cased substrings — any hit fires the pattern)
36static ERROR_PATTERNS: &[(&str, f32, &[&str])] = &[
37    (
38        "oom",
39        CRITICAL,
40        &["out of memory", "memoryerror", "cannot allocate memory"],
41    ),
42    (
43        "connection_refused",
44        HARD,
45        &[
46            "connection refused",
47            "connectionrefusederror",
48            "econnrefused",
49        ],
50    ),
51    ("traceback", HARD, &["traceback (most recent call last)"]),
52    (
53        "import_error",
54        HARD,
55        &["modulenotfounderror:", "importerror:", "no module named "],
56    ),
57    (
58        "cmd_not_found",
59        HARD,
60        &["command not found", "not found\n", "/usr/bin/env: "],
61    ),
62    ("assertion", HARD, &["assertionerror"]),
63    ("value_error", HARD, &["valueerror:"]),
64    ("syntax_error", HARD, &["syntaxerror:"]),
65    (
66        "timeout",
67        HARD,
68        &[
69            "timed out",
70            "timeouterror",
71            "timeout expired",
72            "deadline exceeded",
73        ],
74    ),
75    (
76        "no_such_file",
77        HARD,
78        &[
79            "filenotfounderror:",
80            "no such file or directory",
81            // Claude Code Read-tool miss. Anchored as "file does not exist" (not a
82            // bare "does not exist", which fires on `ls` output and prose) — trace-
83            // mined across 1006 local trajectories at 22 true / 2 false positives.
84            "file does not exist",
85        ],
86    ),
87    // SOFT: plain non-zero exit without a recognisable exception traceback.
88    ("exit_nonzero", SOFT, &["returned non-zero"]),
89];
90
91static NONZERO_EXIT_PHRASES: &[&str] = &[
92    "exit code",
93    "exit status",
94    "exited with code",
95    "exited with status",
96];
97
98static EDIT_TOOL_NAMES: &[&str] = &[
99    "edit",
100    "multiedit",
101    "notebookedit",
102    "str_replace",
103    "str_replace_based_edit_tool",
104    "apply_patch", // codex's edit tool
105    "text_editor",
106    "patch", // hermes's str_replace-style edit tool
107];
108
109static WRITE_TOOL_NAMES: &[&str] = &["write", "create_file", "new_file", "write_file"];
110
111// Bash subcommand patterns. Lowercased; callers must lowercase the command
112// before matching. Bucketed into write_count / edit_count alongside the
113// dedicated `Write` / `Edit` tools.
114static BASH_WRITE_PATTERNS: &[&str] = &[
115    "cat >",
116    "cat >>",
117    "echo >",
118    "echo >>",
119    "tee ",
120    "printf >",
121    "printf >>",
122    "> /",
123    ">> /",
124    "<< 'eof'",
125    "<<eof",
126    "<<'eof'",
127    "<< eof",
128];
129
130/// Python file-write expressions, which only indicate a write when an
131/// interpreter is running them rather than a search looking for them.
132static PYTHON_WRITE_PATTERNS: &[&str] = &["write_text(", "writelines(", ".write("];
133
134static JAVASCRIPT_WRITE_PATTERNS: &[&str] = &[
135    "writefilesync(",
136    "writefile(",
137    "appendfilesync(",
138    "appendfile(",
139];
140
141static BASH_EDIT_PATTERNS: &[&str] = &[
142    "sed -i",
143    "sed --in-place",
144    "awk -i inplace",
145    "awk 'inplace=1'",
146    "patch ",
147    "patch -p",
148    "perl -i",
149    "perl -p -i",
150    "perl -pi",
151];
152
153// Read-like Bash inspections. Match only when none of the write/edit patterns
154// fire (redirection / in-place edit trumps the read intent of the command).
155static BASH_READ_PATTERNS: &[&str] = &[
156    "cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
157    "diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
158];
159
160/// Read-only shell programs seen in Codex trajectories. Matching is limited to
161/// command-segment starts so prose and arguments do not masquerade as actions.
162static BASH_READ_COMMANDS: &[&str] = &[
163    "cat", "rg", "nl", "jq", "pwd", "tree", "sed", "grep", "ls", "find", "head", "tail", "wc",
164    "diff", "which", "ps", "df", "du", "stat", "file", "less", "more", "readlink", "realpath",
165    "basename", "dirname", "printenv",
166];
167
168static GIT_READ_SUBCOMMANDS: &[&str] = &[
169    "status",
170    "diff",
171    "log",
172    "show",
173    "show-ref",
174    "rev-parse",
175    "ls-files",
176    "ls-remote",
177    "ls-tree",
178    "grep",
179    "blame",
180    "merge-base",
181    "check-ignore",
182    "tag",
183];
184
185static READ_TOOL_NAMES: &[&str] = &["read", "view", "read_file", "search_files"];
186
187// Planning / scratchpad tool calls — investigative (non-producing) activity.
188// `update_plan` is codex's equivalent of `todowrite`.
189static PLAN_TOOL_NAMES: &[&str] = &["todowrite", "todo_write", "todo", "update_plan"];
190
191// Tool names that route through Bash-command pattern matching. `bash` is
192// claude-code's name; `shell_command` is codex's; `shell` / `local_shell_call`
193// are seen on some OpenAI-derived harnesses; `terminal` is hermes's (it carries
194// a `command` arg like the others, so its intent comes from the pattern match).
195static BASH_TOOL_NAMES: &[&str] = &[
196    "bash",
197    "shell_command",
198    "shell",
199    "local_shell_call",
200    "terminal",
201    "exec_command", // codex
202];
203
204// Prefer false negatives: tests_passed clears a capable hold, so a false positive
205// could hand an unfinished task back too early.
206static TEST_PASS_PHRASES: &[&str] = &[
207    " passed",
208    "passed in",
209    "tests passed",
210    "all tests passed",
211    "test ok",
212    "test result: ok",
213    "passed.\n",
214    "tests pass",
215    "\nok ", // go test; newline-anchored to avoid "...lookup..." mid-text
216    "✓ ",
217];
218
219// Literal failure phrases that cannot appear inside a clean run. Substring
220// matched as-is. Patterns that pair with a count (e.g. "failed", "errors")
221// are handled separately by `has_nonzero_failure_count` so "0 failed" /
222// "0 errors" do not trigger a false negative.
223static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
224
225// Count-prefixed failure keywords. Trip only when a nonzero integer precedes
226// the keyword (modulo whitespace), so cargo's "0 failed" and go's
227// "0 errors" summaries on a clean run are not misread as failures.
228static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
229
230/// Default sliding-window size for `recent_*` counts and windowed severity.
231///
232/// A short horizon captures "what is the agent doing right now" while keeping
233/// signals sticky — an error or stall persists a few recovery turns instead of
234/// flickering off the moment one clean result lands. Override per request by
235/// passing a window to [`ToolSignals::from_request`].
236pub const DEFAULT_RECENT_WINDOW: usize = 3;
237
238/// Exact tool-name semantics added to the stage router's built-in vocabulary.
239///
240/// Matching is ASCII case-insensitive. These lists are additive: built-in tool
241/// names cannot be reclassified.
242#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
243#[serde(default, deny_unknown_fields)]
244pub struct ToolSemantics {
245    /// Read-only lookup or inspection tools.
246    pub observe: Vec<String>,
247    /// Tools that change task or external state.
248    pub mutate: Vec<String>,
249    /// Explicit planning or task-decomposition tools.
250    pub plan: Vec<String>,
251    /// Tools that demonstrate new forward activity without favoring either tier.
252    pub new: Vec<String>,
253}
254
255impl ToolSemantics {
256    /// Rejects ambiguous mappings and attempts to reclassify built-in tools.
257    pub fn validate(&self) -> Result<()> {
258        let mut seen: Vec<(String, &'static str)> = Vec::new();
259        for (category, names) in [
260            ("observe", &self.observe),
261            ("mutate", &self.mutate),
262            ("plan", &self.plan),
263            ("new", &self.new),
264        ] {
265            for name in names {
266                if name.trim().is_empty() {
267                    return Err(tool_semantics_error(format!(
268                        "tool_semantics.{category} contains an empty tool name"
269                    )));
270                }
271                let normalized = name.to_ascii_lowercase();
272                if is_builtin_tool_name(&name.to_lowercase()) {
273                    return Err(tool_semantics_error(format!(
274                        "tool {name:?} already has built-in semantics and cannot be reclassified"
275                    )));
276                }
277                if let Some((_, previous)) = seen.iter().find(|(seen, _)| seen == &normalized) {
278                    return Err(tool_semantics_error(format!(
279                        "tool {name:?} appears in both tool_semantics.{previous} and tool_semantics.{category}"
280                    )));
281                }
282                seen.push((normalized, category));
283            }
284        }
285        Ok(())
286    }
287
288    fn classify(&self, name: &str) -> Option<ToolSemantic> {
289        if contains_name(&self.observe, name) {
290            Some(ToolSemantic::Observe)
291        } else if contains_name(&self.mutate, name) {
292            // The stage scorer treats writes and edits identically. Custom
293            // mutations use the write counter to preserve the public signal shape.
294            Some(ToolSemantic::Mutate(MutationKind::Write))
295        } else if contains_name(&self.plan, name) {
296            Some(ToolSemantic::Plan)
297        } else if contains_name(&self.new, name) {
298            Some(ToolSemantic::New)
299        } else {
300            None
301        }
302    }
303}
304
305fn contains_name(names: &[String], candidate: &str) -> bool {
306    names
307        .iter()
308        .any(|name| name.eq_ignore_ascii_case(candidate))
309}
310
311fn tool_semantics_error(message: String) -> LibsyError {
312    LibsyError::AlgorithmError { message }
313}
314
315// ─── output type ─────────────────────────────────────────────────────────────
316
317/// Tool-execution signals extracted from a normalized [`Request`].
318///
319/// A request-side processor stores these signals in [`State`](crate::State) for
320/// [`crate::StageRouter`] and its classifier to consume. The advisor gate's
321/// request-side guards read the conversation-shape counts directly via
322/// [`ToolSignals::from_request`].
323#[derive(Clone, Debug, Default)]
324pub struct ToolSignals {
325    /// Max severity across the recent window (last `recent_window` tool results):
326    /// `0.0` clean · `0.3` soft (exit_nonzero) · `0.7` hard · `1.0` critical.
327    /// Windowed so an error persists through the recovery turns instead of clearing
328    /// the instant the next result is clean.
329    pub severity: f32,
330    /// The same hard-or-critical failure appeared at least twice in the recent
331    /// tool-result window.
332    pub repeated_failure: bool,
333    /// Consecutive clean tool results back from the most recent. `0` if the last failed.
334    pub no_error_streak: u32,
335    /// Total edit-style tool calls in the request.
336    pub edit_count: u32,
337    /// Total write-style tool calls in the request.
338    pub write_count: u32,
339    /// Read-type calls (Read tool + read-like Bash). Used by the build-pit gate.
340    pub read_count: u32,
341    /// TodoWrite / planning tool calls. Investigative (non-producing) activity —
342    /// recent todowrites distinguish `exploring` from `spinning` in the scorer.
343    pub todowrite_count: u32,
344    /// Edit-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
345    pub recent_edit_count: u32,
346    /// Write-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
347    pub recent_write_count: u32,
348    /// Read-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
349    pub recent_read_count: u32,
350    /// TodoWrite calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
351    pub recent_todowrite_count: u32,
352    /// Configured `new` tool calls across the full request history.
353    pub new_count: u32,
354    /// Configured `new` tool calls within the recent window.
355    pub recent_new_count: u32,
356    /// Consecutive trailing tool calls in the `Unknown` category (no Write/Edit/Read/
357    /// Plan match). Surfaced in the classifier state summary; not scored directly.
358    pub pure_bash_streak: u32,
359    /// A tool result after the latest recent failure matched a test-pass pattern.
360    pub tests_passed: bool,
361    /// Total `ToolResult` blocks, counted per block (a message batching N
362    /// results contributes N) and including empty-content results.
363    pub tool_result_count: u32,
364    /// Messages with `Role::Assistant`, unlike [`ToolSignals::turn_depth`],
365    /// which counts every message regardless of role.
366    pub assistant_turn_count: u32,
367    /// Message-count proxy for turn depth. Wire-format dependent (Anthropic batches
368    /// tool results into fewer messages than OpenAI-chat), so gates keyed on it are
369    /// approximate across request origins.
370    pub turn_depth: u32,
371    /// The request carries a context-compaction summary (the agent's context was
372    /// summarised after overflowing). Compaction resets the router's accumulated
373    /// signals, so a task that was on the strong tier de-escalates back to weak — the
374    /// picker uses this to force + hold the strong tier. Self-latching: the summary
375    /// stays in the context prefix on every subsequent turn.
376    pub compacted: bool,
377}
378
379impl ToolSignals {
380    /// Extracts tool and activity signals from `request`.
381    ///
382    /// `window_size` limits recent counters to the newest tool results. `None`
383    /// uses [`DEFAULT_RECENT_WINDOW`].
384    pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
385        Self::from_request_with_semantics(request, window_size, &ToolSemantics::default())
386    }
387
388    /// Extracts signals using the built-in vocabulary plus additive semantics.
389    pub fn from_request_with_semantics(
390        request: &Request,
391        window_size: Option<usize>,
392        semantics: &ToolSemantics,
393    ) -> Self {
394        extract_tool_signals_with_window_and_semantics(
395            request,
396            window_size.unwrap_or(DEFAULT_RECENT_WINDOW),
397            semantics,
398        )
399    }
400}
401
402// `command` is the lowercased Bash command line; None for non-Bash tools.
403#[derive(Debug, Clone)]
404struct ObservedToolCall {
405    name: String,
406    command: Option<String>,
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410enum MutationKind {
411    Write,
412    Edit,
413}
414
415/// Domain-neutral meaning assigned to an observed tool call.
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417enum ToolSemantic {
418    Mutate(MutationKind),
419    Observe,
420    Plan,
421    New,
422    Unknown,
423}
424
425/// Request-side processor that extracts tool-result signals from each request
426/// and stores them on the request `State` for downstream routing.
427#[derive(Debug, Clone)]
428pub struct ToolSignalProcessor {
429    /// Number of trailing tool results the `recent_*` counts and windowed
430    /// severity are computed over.
431    pub recent_window: usize,
432    /// Route-scoped additions to the built-in tool vocabulary.
433    pub tool_semantics: ToolSemantics,
434}
435
436impl Default for ToolSignalProcessor {
437    fn default() -> Self {
438        Self {
439            recent_window: DEFAULT_RECENT_WINDOW,
440            tool_semantics: ToolSemantics::default(),
441        }
442    }
443}
444
445#[async_trait]
446impl Processor<State> for ToolSignalProcessor {
447    async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
448        if let Event::Request { request: req, .. } = event {
449            let tool_signal = ToolSignals::from_request_with_semantics(
450                req,
451                Some(self.recent_window),
452                &self.tool_semantics,
453            );
454            state.tool_signals = Some(tool_signal);
455        }
456        Ok(())
457    }
458}
459
460fn classify_tool_call(name: &str, command: Option<&str>) -> ToolSemantic {
461    classify_tool_call_with_semantics(name, command, &ToolSemantics::default())
462}
463
464fn classify_tool_call_with_semantics(
465    name: &str,
466    command: Option<&str>,
467    semantics: &ToolSemantics,
468) -> ToolSemantic {
469    // Built-in names and Bash command inference take precedence over route-scoped mappings.
470    let lower = name.to_lowercase();
471    if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
472        return ToolSemantic::Mutate(MutationKind::Write);
473    }
474    if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
475        return ToolSemantic::Mutate(MutationKind::Edit);
476    }
477    if READ_TOOL_NAMES.contains(&lower.as_str()) {
478        return ToolSemantic::Observe;
479    }
480    if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
481        return ToolSemantic::Plan;
482    }
483    if BASH_TOOL_NAMES.contains(&lower.as_str())
484        && let Some(cmd) = command
485    {
486        // Write/edit redirection trumps read-like operands.
487        if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_write(cmd) {
488            return ToolSemantic::Mutate(MutationKind::Write);
489        }
490        if cmd.contains("python") && PYTHON_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
491            return ToolSemantic::Mutate(MutationKind::Write);
492        }
493        if shell_invokes_program(cmd, "node")
494            && JAVASCRIPT_WRITE_PATTERNS
495                .iter()
496                .any(|pattern| cmd.contains(pattern))
497        {
498            return ToolSemantic::Mutate(MutationKind::Write);
499        }
500        if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_edit(cmd) {
501            return ToolSemantic::Mutate(MutationKind::Edit);
502        }
503        if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_read(cmd) {
504            return ToolSemantic::Observe;
505        }
506    }
507    semantics.classify(name).unwrap_or(ToolSemantic::Unknown)
508}
509
510fn is_builtin_tool_name(lower: &str) -> bool {
511    WRITE_TOOL_NAMES.contains(&lower)
512        || EDIT_TOOL_NAMES.contains(&lower)
513        || READ_TOOL_NAMES.contains(&lower)
514        || PLAN_TOOL_NAMES.contains(&lower)
515        || BASH_TOOL_NAMES.contains(&lower)
516}
517
518/// Split a shell line at unquoted command separators. This intentionally avoids
519/// pretending to be a full shell parser; only the leading program and flags of
520/// each segment are inspected below.
521fn shell_segments(command: &str) -> impl Iterator<Item = &str> {
522    let mut chars = command.char_indices();
523    let mut start = 0usize;
524    let mut quote = None;
525    let mut escaped = false;
526    let mut finished = false;
527
528    std::iter::from_fn(move || {
529        loop {
530            for (index, character) in chars.by_ref() {
531                if escaped {
532                    escaped = false;
533                } else if character == '\\' && quote != Some('\'') {
534                    escaped = true;
535                } else if quote == Some(character) {
536                    quote = None;
537                } else if quote.is_none() && matches!(character, '\'' | '"') {
538                    quote = Some(character);
539                } else if quote.is_none() && matches!(character, '\n' | ';' | '|' | '&') {
540                    let segment = command[start..index].trim();
541                    start = index + character.len_utf8();
542                    if !segment.is_empty() {
543                        return Some(segment);
544                    }
545                }
546            }
547
548            if finished {
549                return None;
550            }
551            finished = true;
552            let segment = command[start..].trim();
553            if !segment.is_empty() {
554                return Some(segment);
555            }
556        }
557    })
558}
559
560fn shell_words(segment: &str) -> std::iter::Peekable<std::str::SplitAsciiWhitespace<'_>> {
561    let mut words = segment.split_ascii_whitespace().peekable();
562
563    if words.peek().copied() == Some("env") {
564        words.next();
565        while words.peek().is_some_and(|word| word.starts_with('-')) {
566            words.next();
567        }
568    }
569    while words
570        .peek()
571        .is_some_and(|word| word.contains('=') && !word.starts_with('='))
572    {
573        words.next();
574    }
575
576    words
577}
578
579fn program_name(word: &str) -> &str {
580    Path::new(word)
581        .file_name()
582        .and_then(|name| name.to_str())
583        .unwrap_or(word)
584}
585
586fn shell_invokes_program(command: &str, expected: &str) -> bool {
587    shell_segments(command).any(|segment| {
588        shell_words(segment)
589            .next()
590            .is_some_and(|word| program_name(word) == expected)
591    })
592}
593
594fn shell_command_is_write(command: &str) -> bool {
595    shell_segments(command).any(|segment| {
596        let mut words = shell_words(segment);
597        let Some(program) = words.next().map(program_name) else {
598            return false;
599        };
600        if matches!(program, "cp" | "mkdir" | "touch" | "install") {
601            return true;
602        }
603
604        let redirects_output = words.any(|word| matches!(word, ">" | ">>"));
605        redirects_output
606            && (matches!(program, "echo" | "printf" | "git")
607                || BASH_READ_COMMANDS.contains(&program))
608    })
609}
610
611fn shell_command_is_edit(command: &str) -> bool {
612    shell_segments(command).any(|segment| {
613        let mut words = shell_words(segment);
614        let Some(program) = words.next().map(program_name) else {
615            return false;
616        };
617        let has_arg = |arg: &str| words.clone().any(|word| word == arg);
618
619        match program {
620            "mv" | "rm" => true,
621            "perl" => words
622                .take_while(|word| word.starts_with('-'))
623                .any(|option| {
624                    option
625                        .trim_start_matches('-')
626                        .chars()
627                        .any(|flag| flag == 'i')
628                }),
629            "git" => words
630                .next()
631                .is_some_and(|subcommand| matches!(subcommand, "apply" | "am" | "restore")),
632            "gofmt" => has_arg("-w"),
633            "cargo" => words.clone().next() == Some("fmt") && !has_arg("--check"),
634            "ruff" => {
635                let subcommand = words.clone().next();
636                (subcommand == Some("format") && !has_arg("--check"))
637                    || (subcommand == Some("check") && has_arg("--fix"))
638            }
639            "prettier" => has_arg("--write"),
640            "black" => !has_arg("--check"),
641            _ => {
642                (words.clone().any(|word| program_name(word) == "prettier") && has_arg("--write"))
643                    || (words.clone().any(|word| program_name(word) == "ruff")
644                        && ((has_arg("format") && !has_arg("--check"))
645                            || (has_arg("check") && has_arg("--fix"))))
646            }
647        }
648    })
649}
650
651fn shell_command_is_read(command: &str) -> bool {
652    shell_segments(command).any(|segment| {
653        if segment == "env" {
654            return true;
655        }
656        let mut words = shell_words(segment);
657        let Some(program) = words.next().map(program_name) else {
658            return false;
659        };
660
661        if BASH_READ_COMMANDS.contains(&program) {
662            return true;
663        }
664        if program == "command" && words.next() == Some("-v") {
665            return true;
666        }
667        if program == "type" {
668            return true;
669        }
670        if program != "git" {
671            return false;
672        }
673
674        match words.next() {
675            Some("branch") => words.next().is_none_or(|arg| arg.starts_with('-')),
676            Some("remote") => words
677                .next()
678                .is_none_or(|arg| arg.starts_with('-') || arg == "get-url"),
679            Some("config") => words
680                .next()
681                .is_some_and(|arg| matches!(arg, "--get" | "--get-all" | "--list" | "-l")),
682            Some(subcommand) => GIT_READ_SUBCOMMANDS.contains(&subcommand),
683            None => false,
684        }
685    })
686}
687
688// ─── extraction entry point ───────────────────────────────────────────────────
689
690/// Extract all tool-execution signals from a normalized [`Request`].
691///
692/// Returns [`ToolSignals::default()`] when the message history contains no tool
693/// activity, so callers can always inspect the signal fields.
694fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
695    extract_tool_signals_with_window_and_semantics(
696        request,
697        recent_window,
698        &ToolSemantics::default(),
699    )
700}
701
702fn extract_tool_signals_with_window_and_semantics(
703    request: &Request,
704    recent_window: usize,
705    semantics: &ToolSemantics,
706) -> ToolSignals {
707    // Read the decoded conversation, not the raw body: every inbound format lands
708    // in the same shape here, so the signals do not depend on knowing which one it
709    // arrived as.
710    let messages = &request.llm_request.messages;
711    let mut tool_texts: Vec<String> = Vec::new();
712    let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
713    let mut compacted = false;
714    let mut tool_result_count = 0usize;
715    let mut assistant_turn_count = 0usize;
716
717    for message in messages {
718        if message.role == Role::Assistant {
719            assistant_turn_count += 1;
720        }
721        for block in &message.content {
722            match block {
723                ContentBlock::ToolCall(call) => {
724                    tool_calls.push(ObservedToolCall {
725                        name: call.name.clone(),
726                        command: command_of(&call.arguments),
727                    });
728                }
729                ContentBlock::ToolResult(result) => {
730                    // Before the empty-text filter: empty results still count.
731                    tool_result_count += 1;
732                    let text = result
733                        .content
734                        .iter()
735                        .filter_map(text_of)
736                        .collect::<Vec<_>>()
737                        .join("\n");
738                    if !text.is_empty() {
739                        tool_texts.push(text);
740                    }
741                }
742                // Compaction is detected anywhere in the conversation: the summary
743                // stays in the prefix on every later turn, so this self-latches
744                // once it fires.
745                ContentBlock::Text { text } => {
746                    compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
747                }
748                _ => {}
749            }
750        }
751    }
752
753    let mut signal = build_signal(
754        tool_texts,
755        tool_calls,
756        messages.len() as u32,
757        recent_window,
758        semantics,
759    );
760    signal.compacted = compacted;
761    signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
762    signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
763    signal
764}
765
766/// Distinctive preamble Claude Code injects as a user message when it compacts an
767/// overflowed context. Matched case-insensitively; normal task text never contains it.
768const COMPACTION_MARKER: &str = "session is being continued";
769
770/// The shell command a tool call carries, when it has one. Harnesses name the
771/// field `command`; anything else is a tool whose category comes from its name.
772fn command_of(arguments: &Value) -> Option<String> {
773    // the Responses wire format sends arguments as a JSON string, not an object
774    let decoded = arguments
775        .as_str()
776        .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
777    let object = decoded.as_ref().unwrap_or(arguments);
778
779    ["command", "cmd", "input"]
780        .iter()
781        .filter_map(|key| object.get(*key))
782        .find_map(command_text)
783}
784
785/// A command field as lowercase text, from a string or an argv array.
786fn command_text(value: &Value) -> Option<String> {
787    match value {
788        Value::String(text) => Some(text.to_lowercase()),
789        Value::Array(parts) => {
790            let joined = parts
791                .iter()
792                .filter_map(Value::as_str)
793                .collect::<Vec<_>>()
794                .join(" ");
795            (!joined.is_empty()).then(|| joined.to_lowercase())
796        }
797        _ => None,
798    }
799}
800
801/// Text carried by a content block, ignoring the non-textual kinds.
802fn text_of(block: &ContentBlock) -> Option<&str> {
803    match block {
804        ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
805        _ => None,
806    }
807}
808
809fn build_signal(
810    tool_texts: Vec<String>,
811    tool_calls: Vec<ObservedToolCall>,
812    turn_depth: u32,
813    recent_window: usize,
814    semantics: &ToolSemantics,
815) -> ToolSignals {
816    // Windowed severity: take the MAX severity across the last `recent_window` tool
817    // results rather than only the last one. An error's severity then persists for
818    // the recent window and decays out of it — parallel to the windowed `recent_*`
819    // counts — so a fix written a couple of turns after an error still routes on the
820    // error signal instead of the router flapping straight back to the weak tier.
821    let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
822    let mut severity = 0.0f32;
823    let mut failure_fingerprints = Vec::new();
824    let mut repeated_failure = false;
825    for text in &tool_texts[sev_start..] {
826        let (sev, _patterns) = classify_text(text);
827        if sev > severity {
828            severity = sev;
829        }
830        if let Some(fingerprint) = failure_fingerprint(text) {
831            repeated_failure |= failure_fingerprints.contains(&fingerprint);
832            failure_fingerprints.push(fingerprint);
833        }
834    }
835
836    let no_error_streak = compute_no_error_streak(&tool_texts);
837
838    // Single pass: cumulative + sliding-window counters together. Also tracks
839    // the trailing pure-bash streak (consecutive `Unknown` calls back
840    // from the end) — the build-pit proxy.
841    let recent_start = tool_calls.len().saturating_sub(recent_window);
842    let mut write_count = 0u32;
843    let mut edit_count = 0u32;
844    let mut read_count = 0u32;
845    let mut todowrite_count = 0u32;
846    let mut recent_write_count = 0u32;
847    let mut recent_edit_count = 0u32;
848    let mut recent_read_count = 0u32;
849    let mut recent_todowrite_count = 0u32;
850    let mut new_count = 0u32;
851    let mut recent_new_count = 0u32;
852    let mut pure_bash_streak = 0u32;
853    let mut streak_open = true;
854    for (i, tc) in tool_calls.iter().enumerate().rev() {
855        let cat = classify_tool_call_with_semantics(&tc.name, tc.command.as_deref(), semantics);
856        if streak_open {
857            if matches!(cat, ToolSemantic::Unknown) {
858                pure_bash_streak += 1;
859            } else {
860                streak_open = false;
861            }
862        }
863        match cat {
864            ToolSemantic::Mutate(MutationKind::Write) => {
865                write_count += 1;
866                if i >= recent_start {
867                    recent_write_count += 1;
868                }
869            }
870            ToolSemantic::Mutate(MutationKind::Edit) => {
871                edit_count += 1;
872                if i >= recent_start {
873                    recent_edit_count += 1;
874                }
875            }
876            ToolSemantic::Observe => {
877                read_count += 1;
878                if i >= recent_start {
879                    recent_read_count += 1;
880                }
881            }
882            ToolSemantic::Plan => {
883                todowrite_count += 1;
884                if i >= recent_start {
885                    recent_todowrite_count += 1;
886                }
887            }
888            ToolSemantic::New => {
889                new_count += 1;
890                if i >= recent_start {
891                    recent_new_count += 1;
892                }
893            }
894            ToolSemantic::Unknown => {}
895        }
896    }
897
898    let tests_passed = detect_tests_passed(&tool_texts, recent_window);
899
900    ToolSignals {
901        severity,
902        repeated_failure,
903        no_error_streak,
904        edit_count,
905        write_count,
906        read_count,
907        todowrite_count,
908        recent_edit_count,
909        recent_write_count,
910        recent_read_count,
911        recent_todowrite_count,
912        new_count,
913        recent_new_count,
914        pure_bash_streak,
915        tests_passed,
916        turn_depth,
917        // Set by extract_tool_signals_with_window after the format-specific extract,
918        // which scans all message contents for the compaction marker and tallies
919        // the raw conversation-shape counts.
920        tool_result_count: 0,
921        assistant_turn_count: 0,
922        compacted: false,
923    }
924}
925
926// ─── pure helpers ─────────────────────────────────────────────────────────────
927
928/// Normalise a JSON tool-result content value to a plain string.
929fn content_to_text(content: Option<&Value>) -> Option<String> {
930    match content? {
931        Value::String(s) => Some(s.clone()),
932        Value::Array(blocks) => {
933            let parts: Vec<&str> = blocks
934                .iter()
935                .filter_map(|b| {
936                    b.as_object()
937                        .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
938                        .and_then(|o| o.get("text"))
939                        .and_then(Value::as_str)
940                })
941                .collect();
942            if parts.is_empty() {
943                None
944            } else {
945                Some(parts.join("\n"))
946            }
947        }
948        _ => None,
949    }
950}
951
952/// Match `text` against the error pattern table.
953///
954/// Returns `(max_severity, matched_pattern_names)`.
955pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
956    let lower = text.to_lowercase();
957    let mut patterns = Vec::new();
958    let mut severity: f32 = 0.0;
959    for (name, sev, substrings) in ERROR_PATTERNS {
960        if substrings.iter().any(|sub| lower.contains(sub)) {
961            patterns.push(name.to_string());
962            severity = severity.max(*sev);
963        }
964    }
965    if has_nonzero_exit_status(&lower) && !patterns.iter().any(|p| p == "exit_nonzero") {
966        patterns.push("exit_nonzero".to_string());
967        severity = severity.max(SOFT);
968    }
969    for (name, matched) in [
970        ("compile_error", has_compiler_diagnostic(&lower)),
971        ("runtime_exception", has_runtime_exception(&lower)),
972        ("runtime_panic", has_runtime_panic(&lower)),
973        ("patch_error", has_patch_failure(&lower)),
974    ] {
975        if matched && !patterns.iter().any(|pattern| pattern == name) {
976            patterns.push(name.to_string());
977            severity = severity.max(HARD);
978        }
979    }
980    (severity, patterns)
981}
982
983/// Stable identity for a material failure. Soft non-zero exits are excluded:
984/// they are too generic to prove that an agent is repeating the same mistake.
985fn failure_fingerprint(text: &str) -> Option<String> {
986    let (severity, patterns) = classify_text(text);
987    if severity < HARD {
988        return None;
989    }
990
991    let lower = text.to_lowercase();
992    let diagnostic = lower
993        .lines()
994        .find(|line| is_failure_diagnostic(line))
995        .or_else(|| lower.lines().find(|line| !line.trim().is_empty()))
996        .unwrap_or_default();
997    let normalized = normalize_failure_text(diagnostic);
998    Some(format!("{}|{normalized}", patterns.join(",")))
999}
1000
1001fn is_failure_diagnostic(line: &str) -> bool {
1002    let line = line.trim();
1003    [
1004        "error",
1005        "exception",
1006        "panic",
1007        "failed",
1008        "timed out",
1009        "timeout",
1010        "connection refused",
1011        "cannot allocate memory",
1012        "out of memory",
1013        "not found",
1014    ]
1015    .iter()
1016    .any(|marker| line.contains(marker))
1017}
1018
1019/// Removes values that normally change between retries while retaining the
1020/// diagnostic wording that distinguishes one failure from another.
1021fn normalize_failure_text(text: &str) -> String {
1022    let mut normalized = String::new();
1023    for word in text.split_whitespace() {
1024        if !normalized.is_empty() {
1025            normalized.push(' ');
1026        }
1027        let mut in_digits = false;
1028        if word.starts_with('/') || word.contains("/src/") || word.contains("/tmp/") {
1029            normalized.push_str("<path>");
1030            continue;
1031        }
1032        for character in word.chars() {
1033            if character.is_ascii_digit() {
1034                if !in_digits {
1035                    normalized.push('#');
1036                    in_digits = true;
1037                }
1038            } else {
1039                normalized.push(character);
1040                in_digits = false;
1041            }
1042        }
1043    }
1044    normalized.chars().take(240).collect()
1045}
1046
1047fn has_compiler_diagnostic(lower: &str) -> bool {
1048    lower.lines().any(|line| {
1049        let line = line.trim_start();
1050        if matches!(
1051            line,
1052            "compilation failed" | "error: compilation failed" | "error: could not compile"
1053        ) || line.starts_with("error: could not compile ")
1054        {
1055            return true;
1056        }
1057
1058        let Some(rest) = line.strip_prefix("error[e") else {
1059            return false;
1060        };
1061        let Some((code, _)) = rest.split_once("]:") else {
1062            return false;
1063        };
1064        !code.is_empty() && code.chars().all(|character| character.is_ascii_digit())
1065    })
1066}
1067
1068fn has_runtime_exception(lower: &str) -> bool {
1069    let has_exception_line = lower.lines().any(|line| {
1070        let line = line.trim_start();
1071        [
1072            "typeerror:",
1073            "referenceerror:",
1074            "rangeerror:",
1075            "runtimeerror:",
1076            "keyerror:",
1077            "attributeerror:",
1078        ]
1079        .iter()
1080        .any(|prefix| line.starts_with(prefix))
1081    });
1082    has_exception_line && (lower.contains("\n    at ") || lower.contains("\n  at "))
1083}
1084
1085fn has_runtime_panic(lower: &str) -> bool {
1086    lower
1087        .lines()
1088        .any(|line| line.trim_start().starts_with("panic: runtime error:"))
1089        && (lower.contains("\ngoroutine ") || lower.contains("[signal sig"))
1090}
1091
1092fn has_patch_failure(lower: &str) -> bool {
1093    lower.lines().any(|line| {
1094        let line = line.trim_start();
1095        line.starts_with("error: patch failed:")
1096            || line.starts_with("patch failed:")
1097            || line.contains(": patch does not apply")
1098            || line.starts_with("invalid context")
1099    })
1100}
1101
1102/// Detects `exit_nonzero` only when a supported exit phrase is followed by a
1103/// nonzero decimal status.
1104///
1105/// Codex includes "Process exited with code 0" on clean tool results, so exit
1106/// phrases must parse their numeric status instead of matching the phrase alone.
1107fn has_nonzero_exit_status(lower: &str) -> bool {
1108    NONZERO_EXIT_PHRASES
1109        .iter()
1110        .any(|phrase| phrase_followed_by_nonzero_integer(lower, phrase))
1111}
1112
1113/// Matches common "exit code/status N" spellings after optional separators.
1114fn phrase_followed_by_nonzero_integer(lower: &str, phrase: &str) -> bool {
1115    let mut cursor = 0usize;
1116    while let Some(rel) = lower[cursor..].find(phrase) {
1117        let value_start = cursor + rel + phrase.len();
1118        let rest = lower[value_start..].trim_start_matches(|c: char| {
1119            c.is_ascii_whitespace() || matches!(c, ':' | '=' | '\'' | '"' | '`')
1120        });
1121        let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1122        if !digits.is_empty() && digits.chars().any(|d| d != '0') {
1123            return true;
1124        }
1125        cursor = value_start;
1126    }
1127    false
1128}
1129
1130fn compute_no_error_streak(tool_texts: &[String]) -> u32 {
1131    let mut streak = 0u32;
1132    for text in tool_texts.iter().rev() {
1133        let (sev, _) = classify_text(text);
1134        if sev > 0.0 {
1135            break;
1136        }
1137        streak += 1;
1138    }
1139    streak
1140}
1141
1142fn detect_tests_passed(tool_texts: &[String], recent_window: usize) -> bool {
1143    let start = tool_texts.len().saturating_sub(recent_window.max(1));
1144    let recent = &tool_texts[start..];
1145    let after_latest_failure = recent
1146        .iter()
1147        .rposition(|text| classify_text(text).0 > 0.0)
1148        .map_or(recent, |index| &recent[index + 1..]);
1149    after_latest_failure.iter().any(|text| {
1150        let lower = text.to_lowercase();
1151        TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
1152            && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
1153            && !has_nonzero_failure_count(&lower)
1154    })
1155}
1156
1157// True iff `lower` contains a `NUMERIC_FAILURE_KEYWORDS` token preceded
1158// (modulo whitespace) by a nonzero integer. The "modulo whitespace" lets
1159// "1 failed", "1\nfailed", and "1  failed" all trip; the nonzero guard
1160// keeps cargo's "0 failed" / go's "0 errors" / pytest's "0 errors in"
1161// summaries from being misread as failures on a clean run.
1162fn has_nonzero_failure_count(lower: &str) -> bool {
1163    for kw in NUMERIC_FAILURE_KEYWORDS {
1164        let mut cursor = 0usize;
1165        while let Some(rel) = lower[cursor..].find(kw) {
1166            let kw_start = cursor + rel;
1167            let kw_end = kw_start + kw.len();
1168            // Word boundary AFTER the keyword — "errors" mid-word (e.g.
1169            // "errored") shouldn't count as a failure-count site.
1170            let boundary_after = lower[kw_end..]
1171                .chars()
1172                .next()
1173                .is_none_or(|c| !c.is_ascii_alphanumeric());
1174            if boundary_after {
1175                let prefix = &lower[..kw_start];
1176                let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
1177                let digits_rev: String = trimmed
1178                    .chars()
1179                    .rev()
1180                    .take_while(|c| c.is_ascii_digit())
1181                    .collect();
1182                if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
1183                    return true;
1184                }
1185            }
1186            cursor = kw_start + kw.len();
1187        }
1188    }
1189    false
1190}
1191
1192// ─── tests ───────────────────────────────────────────────────────────────────
1193
1194#[cfg(test)]
1195mod tests {
1196    use super::*;
1197    use crate::algorithms::util::stage::score_signal;
1198    use serde_json::json;
1199    use switchyard_protocol::{
1200        ContentBlock, LlmRequest, Message, Metadata, Role, ToolCall, ToolResult,
1201    };
1202
1203    fn with_messages(messages: Vec<Message>) -> Request {
1204        Request {
1205            llm_request: LlmRequest {
1206                messages,
1207                ..LlmRequest::default()
1208            },
1209            raw_request: None,
1210            metadata: None,
1211        }
1212    }
1213
1214    // assistant message with a single named tool call
1215    fn tc(name: &str) -> Message {
1216        Message {
1217            role: Role::Assistant,
1218            content: vec![ContentBlock::ToolCall(ToolCall {
1219                id: String::new(),
1220                name: name.to_string(),
1221                arguments: json!({}),
1222            })],
1223        }
1224    }
1225
1226    // assistant Bash message carrying `command`
1227    fn bash(command: &str) -> Message {
1228        Message {
1229            role: Role::Assistant,
1230            content: vec![ContentBlock::ToolCall(ToolCall {
1231                id: String::new(),
1232                name: "Bash".to_string(),
1233                arguments: json!({"command": command}),
1234            })],
1235        }
1236    }
1237
1238    // a tool result message (goes in a user-role message, as in Anthropic's normalised form)
1239    fn tr(text: &str) -> Message {
1240        Message {
1241            role: Role::User,
1242            content: vec![ContentBlock::ToolResult(ToolResult {
1243                tool_call_id: String::new(),
1244                content: vec![ContentBlock::Text {
1245                    text: text.to_string(),
1246                }],
1247                is_error: None,
1248            })],
1249        }
1250    }
1251
1252    #[test]
1253    fn clean_text_has_zero_severity() {
1254        let (sev, patterns) = classify_text("everything went fine");
1255        assert_eq!(sev, 0.0);
1256        assert!(patterns.is_empty());
1257    }
1258
1259    #[test]
1260    fn traceback_is_hard() {
1261        let (sev, patterns) = classify_text("Traceback (most recent call last):\n  ValueError");
1262        assert_eq!(sev, HARD);
1263        assert!(patterns.contains(&"traceback".to_string()));
1264    }
1265
1266    #[test]
1267    fn oom_is_critical() {
1268        let (sev, _) = classify_text("Out of memory: kill process 1234");
1269        assert_eq!(sev, CRITICAL);
1270    }
1271
1272    #[test]
1273    fn connection_refused_is_hard() {
1274        let (severity, _) = classify_text("Connection refused on port 8000");
1275        assert_eq!(severity, HARD);
1276    }
1277
1278    #[test]
1279    fn repeated_failure_ignores_volatile_paths_and_numbers() {
1280        let request = with_messages(vec![
1281            tr("error[E0308]: mismatched types at /tmp/a/src/lib.rs:12"),
1282            tr("error[E0308]: mismatched types at /tmp/b/src/lib.rs:47"),
1283        ]);
1284        assert!(ToolSignals::from_request(&request, None).repeated_failure);
1285    }
1286
1287    #[test]
1288    fn different_failures_are_not_repeated() {
1289        let request = with_messages(vec![
1290            tr("error[E0308]: mismatched types"),
1291            tr("error[E0509]: cannot move out"),
1292        ]);
1293        assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1294    }
1295
1296    #[test]
1297    fn one_material_failure_is_not_repeated() {
1298        let request = with_messages(vec![tr("Connection refused on port 8000")]);
1299        assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1300    }
1301
1302    #[test]
1303    fn severity_is_max_across_patterns() {
1304        // exit_nonzero (SOFT) + traceback (HARD) → HARD.
1305        let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
1306        assert_eq!(sev, HARD);
1307    }
1308
1309    #[test]
1310    fn codex_process_exit_zero_stays_clean() {
1311        let (sev, patterns) =
1312            classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
1313        assert_eq!(sev, 0.0);
1314        assert!(!patterns.contains(&"exit_nonzero".to_string()));
1315    }
1316
1317    #[test]
1318    fn nonzero_exit_codes_are_soft_errors() {
1319        let cases = [
1320            "Process exited with code 1",
1321            "Process exited with code 127",
1322            "exit code: 2",
1323            "exit status 3",
1324            "exited with status 9",
1325        ];
1326        for case in cases {
1327            let (sev, patterns) = classify_text(case);
1328            assert_eq!(sev, SOFT, "expected soft severity for {case}");
1329            assert!(patterns.contains(&"exit_nonzero".to_string()));
1330        }
1331    }
1332
1333    #[test]
1334    fn partial_process_failures_are_hard_errors() {
1335        let cases = [
1336            (
1337                "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
1338                "compile_error",
1339            ),
1340            (
1341                "Process exited with code 0\nOutput:\nTypeError: value is undefined\n    at main.js:1:2",
1342                "runtime_exception",
1343            ),
1344            (
1345                "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
1346                "runtime_panic",
1347            ),
1348            (
1349                "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
1350                "patch_error",
1351            ),
1352        ];
1353        for (text, expected_pattern) in cases {
1354            let (severity, patterns) = classify_text(text);
1355            assert_eq!(severity, HARD, "expected hard severity for {text}");
1356            assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
1357        }
1358    }
1359
1360    #[test]
1361    fn source_text_that_names_exceptions_stays_clean() {
1362        let text =
1363            "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
1364        assert_eq!(classify_text(text).0, 0.0);
1365    }
1366
1367    #[test]
1368    fn file_does_not_exist_is_hard() {
1369        // Claude Code Read-tool miss. Trace-mined addition (22 true / 2 false positives).
1370        let (sev, patterns) =
1371            classify_text("Error: File does not exist. Note: current working directory is /app.");
1372        assert_eq!(sev, HARD);
1373        assert!(patterns.contains(&"no_such_file".to_string()));
1374    }
1375
1376    #[test]
1377    fn bare_does_not_exist_stays_clean() {
1378        // Precision guard: only the anchored "file does not exist" fires, so a bare
1379        // "does not exist" in prose or directory output must not trip a false error.
1380        let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
1381        assert_eq!(sev, 0.0);
1382    }
1383
1384    #[test]
1385    fn no_error_streak_all_clean() {
1386        let texts = vec!["ok".to_string(), "all good".to_string()];
1387        assert_eq!(compute_no_error_streak(&texts), 2);
1388    }
1389
1390    #[test]
1391    fn no_error_streak_stops_at_error() {
1392        let texts = vec![
1393            "Traceback (most recent call last):".to_string(),
1394            "ok".to_string(),
1395            "ok".to_string(),
1396        ];
1397        assert_eq!(compute_no_error_streak(&texts), 2);
1398    }
1399
1400    #[test]
1401    fn tests_passed_detects_pytest_output() {
1402        assert!(detect_tests_passed(
1403            &["====== 5 passed in 0.12s ======".to_string()],
1404            DEFAULT_RECENT_WINDOW
1405        ));
1406    }
1407
1408    #[test]
1409    fn tests_passed_ignores_partial_failures() {
1410        assert!(!detect_tests_passed(
1411            &["2 failed, 5 passed in 0.56s".to_string()],
1412            DEFAULT_RECENT_WINDOW
1413        ));
1414    }
1415
1416    #[test]
1417    fn tests_passed_must_follow_the_latest_failure() {
1418        assert!(!detect_tests_passed(
1419            &[
1420                "5 passed in 0.12s".to_string(),
1421                "Traceback (most recent call last):\nValueError".to_string(),
1422                "edit applied".to_string(),
1423            ],
1424            DEFAULT_RECENT_WINDOW
1425        ));
1426        assert!(detect_tests_passed(
1427            &[
1428                "Traceback (most recent call last):\nValueError".to_string(),
1429                "5 passed in 0.12s".to_string(),
1430            ],
1431            DEFAULT_RECENT_WINDOW
1432        ));
1433    }
1434
1435    #[test]
1436    fn severity_is_windowed_over_recent_results() {
1437        // An error two results back, then two clean results.
1438        let request = with_messages(vec![
1439            tr("Traceback (most recent call last):\n  ValueError"),
1440            tr("ok"),
1441            tr("ok"),
1442        ]);
1443        // window covers the error → severity persists (max over the window)
1444        assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
1445        // window of 1 sees only the last (clean) result → severity has decayed out
1446        assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
1447    }
1448
1449    #[test]
1450    fn extract_openai_chat_tool_results() {
1451        let request = with_messages(vec![
1452            Message::text(Role::User, "do something"),
1453            tc("Edit"),
1454            tr("Traceback (most recent call last):\n  ValueError"),
1455        ]);
1456        let sig = ToolSignals::from_request(&request, None);
1457        assert_eq!(sig.severity, HARD);
1458        assert_eq!(sig.edit_count, 1);
1459        assert_eq!(sig.turn_depth, 3);
1460    }
1461
1462    #[test]
1463    fn extract_anthropic_tool_results() {
1464        let request = with_messages(vec![tr("Traceback (most recent call last):\n  ValueError")]);
1465        let sig = ToolSignals::from_request(&request, None);
1466        assert_eq!(sig.severity, HARD);
1467    }
1468
1469    #[test]
1470    fn extract_responses_api_tool_results() {
1471        let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
1472        let sig = ToolSignals::from_request(&request, None);
1473        assert_eq!(sig.severity, 0.0);
1474        assert_eq!(sig.write_count, 1);
1475    }
1476
1477    #[test]
1478    fn conversation_counts_are_per_block_and_role_aware() {
1479        // A batched user message (Anthropic shape) contributes one count per
1480        // ToolResult block, empty-content results included. assistant_turn_count
1481        // tracks Role::Assistant only, while turn_depth counts every message.
1482        let result = |content: Vec<ContentBlock>| {
1483            ContentBlock::ToolResult(ToolResult {
1484                tool_call_id: String::new(),
1485                content,
1486                is_error: None,
1487            })
1488        };
1489        let request = with_messages(vec![
1490            Message::text(Role::User, "do something"),
1491            Message::text(Role::Assistant, "working"),
1492            Message {
1493                role: Role::User,
1494                content: vec![
1495                    result(vec![ContentBlock::Text {
1496                        text: "ok".to_string(),
1497                    }]),
1498                    result(Vec::new()),
1499                ],
1500            },
1501            tc("Bash"),
1502        ]);
1503        let sig = ToolSignals::from_request(&request, None);
1504        assert_eq!(sig.tool_result_count, 2);
1505        assert_eq!(sig.assistant_turn_count, 2);
1506        assert_eq!(sig.turn_depth, 4);
1507    }
1508
1509    #[test]
1510    fn recent_window_counts_only_last_default_window_tool_calls() {
1511        // 5 writes + 1 edit at the end → the default window (3) should see
1512        // the last 3 calls: 1 edit + 2 writes (not all 6 calls).
1513        let request = with_messages(vec![
1514            tc("Write"),
1515            tr("ok"),
1516            tc("Write"),
1517            tr("ok"),
1518            tc("Write"),
1519            tr("ok"),
1520            tc("Write"),
1521            tr("ok"),
1522            tc("Write"),
1523            tr("ok"),
1524            tc("Edit"),
1525            tr("ok"),
1526        ]);
1527        let sig = ToolSignals::from_request(&request, None);
1528        assert_eq!(sig.write_count, 5);
1529        assert_eq!(sig.edit_count, 1);
1530        assert_eq!(sig.recent_write_count, 2);
1531        assert_eq!(sig.recent_edit_count, 1);
1532    }
1533
1534    #[test]
1535    fn codex_apply_patch_counts_as_an_edit() {
1536        let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
1537        let sig = ToolSignals::from_request(&request, None);
1538        assert_eq!(sig.edit_count, 1);
1539        assert_eq!(sig.recent_edit_count, 1);
1540    }
1541
1542    fn exec_command(cmd: Value) -> Message {
1543        Message {
1544            role: Role::Assistant,
1545            content: vec![ContentBlock::ToolCall(ToolCall {
1546                id: String::new(),
1547                name: "exec_command".to_string(),
1548                arguments: cmd,
1549            })],
1550        }
1551    }
1552
1553    #[test]
1554    fn codex_exec_command_is_classified() {
1555        // arguments arrive as a JSON string, with the command under `cmd`
1556        let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
1557        let request = with_messages(vec![exec_command(args), tr("ok")]);
1558        assert_eq!(
1559            ToolSignals::from_request(&request, None).recent_edit_count,
1560            1
1561        );
1562    }
1563
1564    #[test]
1565    fn python_write_expressions_need_a_python_command() {
1566        let write = with_messages(vec![
1567            exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
1568            tr("ok"),
1569        ]);
1570        assert_eq!(
1571            ToolSignals::from_request(&write, None).recent_write_count,
1572            1
1573        );
1574
1575        let search = with_messages(vec![
1576            exec_command(json!({"cmd": "grep -R '.write(' src"})),
1577            tr("ok"),
1578        ]);
1579        assert_eq!(
1580            ToolSignals::from_request(&search, None).recent_write_count,
1581            0
1582        );
1583    }
1584
1585    #[test]
1586    fn recent_window_size_is_caller_overridable() {
1587        // Same six tool calls (1 edit at the end, 5 writes before).
1588        // With recent_window=3 → recent_writes=2, recent_edits=1.
1589        // With recent_window=6 → recent_writes=5, recent_edits=1 (all calls).
1590        let request = with_messages(vec![
1591            tc("Write"),
1592            tr("ok"),
1593            tc("Write"),
1594            tr("ok"),
1595            tc("Write"),
1596            tr("ok"),
1597            tc("Write"),
1598            tr("ok"),
1599            tc("Write"),
1600            tr("ok"),
1601            tc("Edit"),
1602            tr("ok"),
1603        ]);
1604        let narrow = extract_tool_signals_with_window(&request, 3);
1605        assert_eq!(narrow.recent_write_count, 2);
1606        assert_eq!(narrow.recent_edit_count, 1);
1607
1608        let wide = extract_tool_signals_with_window(&request, 6);
1609        assert_eq!(wide.recent_write_count, 5);
1610        assert_eq!(wide.recent_edit_count, 1);
1611    }
1612
1613    #[test]
1614    fn compaction_marker_sets_compacted() {
1615        // The compaction summary is a user message carrying Claude Code's preamble.
1616        let request = with_messages(vec![
1617            Message::text(
1618                Role::User,
1619                "This session is being continued from a previous conversation that ran out of context.",
1620            ),
1621            bash("ls"),
1622        ]);
1623        assert!(ToolSignals::from_request(&request, None).compacted);
1624    }
1625
1626    #[test]
1627    fn codex_compaction_metadata_stays_on_parent_route() {
1628        let mut request = with_messages(vec![bash("ls")]);
1629        request.metadata = Some(Metadata {
1630            is_subagent: true,
1631            agent_kind: Some("compact".to_string()),
1632            ..Default::default()
1633        });
1634        assert!(!ToolSignals::from_request(&request, None).compacted);
1635    }
1636
1637    #[test]
1638    fn no_compaction_marker_stays_uncompacted() {
1639        let request = with_messages(vec![
1640            Message::text(Role::User, "Write a script that parses the log file."),
1641            bash("ls"),
1642        ]);
1643        assert!(!ToolSignals::from_request(&request, None).compacted);
1644    }
1645
1646    #[test]
1647    fn bash_heredoc_counts_as_write() {
1648        // Claude Code's pattern on TB 2.0 — write a scratch file via heredoc.
1649        let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
1650        let sig = ToolSignals::from_request(&request, None);
1651        assert_eq!(
1652            sig.write_count, 1,
1653            "Bash heredoc should bucket into write_count"
1654        );
1655        assert_eq!(sig.edit_count, 0);
1656    }
1657
1658    #[test]
1659    fn bash_sed_inplace_counts_as_edit() {
1660        let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
1661        let sig = ToolSignals::from_request(&request, None);
1662        assert_eq!(
1663            sig.edit_count, 1,
1664            "Bash sed -i should bucket into edit_count"
1665        );
1666        assert_eq!(sig.write_count, 0);
1667    }
1668
1669    #[test]
1670    fn bash_non_mutating_does_not_count() {
1671        // ls, cat, grep — should not increment either counter.
1672        let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
1673        let sig = ToolSignals::from_request(&request, None);
1674        assert_eq!(sig.write_count, 0);
1675        assert_eq!(sig.edit_count, 0);
1676    }
1677
1678    #[test]
1679    fn tests_passed_detects_pytest_with_failure_block() {
1680        // Mixed pytest run: 2 failed + 5 passed → NOT considered tests_passed.
1681        assert!(!detect_tests_passed(
1682            &["2 failed, 5 passed in 0.56s".to_string()],
1683            DEFAULT_RECENT_WINDOW
1684        ));
1685    }
1686
1687    #[test]
1688    fn tests_passed_accepts_cargo_clean_summary() {
1689        // Cargo's clean-run summary contains "0 failed" — must not trip the
1690        // failure list (regression: previously substring-matched "failed").
1691        assert!(detect_tests_passed(
1692            &["running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string()],
1693            DEFAULT_RECENT_WINDOW
1694        ));
1695    }
1696
1697    #[test]
1698    fn tests_passed_rejects_cargo_real_failure() {
1699        // Cargo's actual-failure summary: nonzero count before "failed".
1700        assert!(!detect_tests_passed(
1701            &["running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string()],
1702            DEFAULT_RECENT_WINDOW
1703        ));
1704    }
1705
1706    #[test]
1707    fn tests_passed_accepts_go_clean_summary() {
1708        // Go test's clean-run "0 errors" must not trip (regression).
1709        assert!(detect_tests_passed(
1710            &["ok  github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string()],
1711            DEFAULT_RECENT_WINDOW
1712        ));
1713    }
1714
1715    #[test]
1716    fn tests_passed_accepts_pytest_zero_errors() {
1717        // Pytest long-form: "0 errors in 0.3s" on a clean run.
1718        assert!(detect_tests_passed(
1719            &["5 passed, 0 errors in 0.30s".to_string()],
1720            DEFAULT_RECENT_WINDOW
1721        ));
1722    }
1723
1724    #[test]
1725    fn tests_passed_detects_diy_checkmark() {
1726        assert!(detect_tests_passed(
1727            &["✓ all checks passed".to_string()],
1728            DEFAULT_RECENT_WINDOW
1729        ));
1730    }
1731
1732    #[test]
1733    fn anthropic_bash_heredoc_extracts_command() {
1734        // Anthropic format: tool_use.input is an object, not a JSON string.
1735        let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
1736        let sig = ToolSignals::from_request(&request, None);
1737        assert_eq!(
1738            sig.write_count, 1,
1739            "Anthropic Bash heredoc must also be detected"
1740        );
1741    }
1742
1743    #[test]
1744    fn recent_window_falls_back_to_full_history_when_short() {
1745        let request = with_messages(vec![tc("Write")]);
1746        let sig = ToolSignals::from_request(&request, None);
1747        assert_eq!(sig.recent_write_count, 1);
1748        assert_eq!(sig.recent_edit_count, 0);
1749    }
1750
1751    #[test]
1752    fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
1753        let request = with_messages(vec![tr("output ok"), tr("another ok")]);
1754        let sig = ToolSignals::from_request(&request, None);
1755        assert_eq!(sig.severity, 0.0);
1756        assert_eq!(sig.no_error_streak, 2);
1757    }
1758
1759    // ─── asymmetric-signal extensions ────────────────────────────────────
1760
1761    #[test]
1762    fn todowrite_classifies_as_plan() {
1763        assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
1764        assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
1765    }
1766
1767    #[test]
1768    fn codex_update_plan_classifies_as_plan() {
1769        assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
1770    }
1771
1772    #[test]
1773    fn codex_shell_command_runs_bash_pattern_match() {
1774        // shell_command + heredoc -> Write.
1775        assert_eq!(
1776            classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
1777            ToolSemantic::Mutate(MutationKind::Write),
1778        );
1779        // shell_command + read-like inspection -> Read.
1780        assert_eq!(
1781            classify_tool_call("shell_command", Some("ls /app")),
1782            ToolSemantic::Observe,
1783        );
1784        // shell_command without matching patterns -> Unknown.
1785        assert_eq!(
1786            classify_tool_call("shell_command", Some("./run_tests.sh")),
1787            ToolSemantic::Unknown,
1788        );
1789    }
1790
1791    #[test]
1792    fn read_tool_classifies_as_read() {
1793        assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
1794        assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
1795    }
1796
1797    #[test]
1798    fn hermes_tool_names_classify() {
1799        // Hermes (NousResearch) file tools route by name.
1800        assert_eq!(
1801            classify_tool_call("write_file", None),
1802            ToolSemantic::Mutate(MutationKind::Write)
1803        );
1804        assert_eq!(
1805            classify_tool_call("patch", None),
1806            ToolSemantic::Mutate(MutationKind::Edit)
1807        );
1808        assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
1809        assert_eq!(
1810            classify_tool_call("search_files", None),
1811            ToolSemantic::Observe
1812        );
1813        // Hermes runs shell through `terminal`, which carries a `command` arg,
1814        // so its intent comes from the Bash-pattern match like codex's shell_command.
1815        assert_eq!(
1816            classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
1817            ToolSemantic::Mutate(MutationKind::Edit),
1818        );
1819        assert_eq!(
1820            classify_tool_call("terminal", Some("grep foo /app")),
1821            ToolSemantic::Observe,
1822        );
1823        assert_eq!(
1824            classify_tool_call("terminal", Some("./run_tests.sh")),
1825            ToolSemantic::Unknown,
1826        );
1827    }
1828
1829    #[test]
1830    fn bash_read_patterns_classify_as_read() {
1831        let cases = [
1832            "cat /etc/passwd",
1833            "grep foo bar.txt",
1834            "ls /app",
1835            "find . -name '*.py'",
1836        ];
1837        for cmd in cases {
1838            assert_eq!(
1839                classify_tool_call("Bash", Some(cmd)),
1840                ToolSemantic::Observe,
1841                "expected Read for {cmd}"
1842            );
1843        }
1844    }
1845
1846    #[test]
1847    fn codex_inspection_commands_classify_as_read() {
1848        let cases = [
1849            "sed -n '1,80p' src/lib.rs",
1850            "rg -n 'needle' src",
1851            "nl -ba src/lib.rs",
1852            "cat package.json",
1853            "jq '.scripts' package.json",
1854            "git status --short",
1855            "git log --oneline -5",
1856            "git show HEAD:src/lib.rs",
1857            "git branch --show-current",
1858            "git remote -v",
1859            "git config --get remote.origin.url",
1860        ];
1861        for command in cases {
1862            assert_eq!(
1863                classify_tool_call("exec_command", Some(command)),
1864                ToolSemantic::Observe,
1865                "expected Read for {command}"
1866            );
1867        }
1868    }
1869
1870    #[test]
1871    fn quoted_shell_separators_do_not_create_commands() {
1872        for command in ["rg 'foo|rm obsolete.rs'", "rg \"foo; rm obsolete.rs\""] {
1873            assert_eq!(
1874                classify_tool_call("exec_command", Some(command)),
1875                ToolSemantic::Observe,
1876                "quoted text must not be parsed as a command: {command}"
1877            );
1878        }
1879    }
1880
1881    #[test]
1882    fn codex_shell_mutations_classify_as_production() {
1883        let writes = [
1884            "cp source.rs destination.rs",
1885            "mkdir -p src/generated",
1886            "touch src/generated/mod.rs",
1887            "git show HEAD:file.rs > file.rs",
1888            "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
1889        ];
1890        for command in writes {
1891            assert_eq!(
1892                classify_tool_call("exec_command", Some(command)),
1893                ToolSemantic::Mutate(MutationKind::Write),
1894                "expected Write for {command}"
1895            );
1896        }
1897
1898        let edits = [
1899            "mv old.rs new.rs",
1900            "rm obsolete.rs",
1901            "gofmt -w main.go",
1902            "cargo fmt",
1903            "ruff check --fix src",
1904            "perl -0pi -e 's/old/new/' src/lib.rs",
1905            "npx prettier --write src/lib.ts",
1906            "uv run ruff format src",
1907            "git apply fix.patch",
1908        ];
1909        for command in edits {
1910            assert_eq!(
1911                classify_tool_call("exec_command", Some(command)),
1912                ToolSemantic::Mutate(MutationKind::Edit),
1913                "expected Edit for {command}"
1914            );
1915        }
1916    }
1917
1918    #[test]
1919    fn formatter_checks_are_not_edits() {
1920        for command in [
1921            "cargo fmt --check",
1922            "ruff format --check src",
1923            "black --check src",
1924        ] {
1925            assert_ne!(
1926                classify_tool_call("exec_command", Some(command)),
1927                ToolSemantic::Mutate(MutationKind::Edit),
1928                "read-only formatter check must not be Edit: {command}"
1929            );
1930        }
1931    }
1932
1933    #[test]
1934    fn embedded_comparison_is_not_a_shell_write() {
1935        let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
1936        assert_eq!(
1937            classify_tool_call("exec_command", Some(command)),
1938            ToolSemantic::Unknown
1939        );
1940    }
1941
1942    #[test]
1943    fn bash_write_precedence_over_read() {
1944        // `cat /file > out` contains both `cat /` (read) and ` > ` (write);
1945        // write redirection must win.
1946        assert_eq!(
1947            classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
1948            ToolSemantic::Mutate(MutationKind::Write),
1949        );
1950    }
1951
1952    #[test]
1953    fn pure_bash_streak_counts_trailing_other() {
1954        // 5 trailing non-classified Bash calls → streak == 5.
1955        let request = with_messages(vec![
1956            bash("make"),
1957            tr("ok"),
1958            bash("./configure"),
1959            tr("ok"),
1960            bash("make install"),
1961            tr("ok"),
1962            bash("./run.sh"),
1963            tr("ok"),
1964            bash("./test"),
1965            tr("ok"),
1966        ]);
1967        let sig = ToolSignals::from_request(&request, None);
1968        assert_eq!(sig.pure_bash_streak, 5);
1969        assert_eq!(sig.write_count, 0);
1970        assert_eq!(sig.read_count, 0);
1971    }
1972
1973    #[test]
1974    fn pure_bash_streak_resets_on_write() {
1975        let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
1976        let sig = ToolSignals::from_request(&request, None);
1977        assert_eq!(sig.pure_bash_streak, 0);
1978        assert_eq!(sig.write_count, 1);
1979    }
1980
1981    #[test]
1982    fn recent_window_tracks_todowrite_and_read() {
1983        // Final 3 tool calls: TodoWrite, Read, TodoWrite.
1984        let request = with_messages(vec![
1985            bash("make"),
1986            tr("ok"),
1987            tc("TodoWrite"),
1988            tr("ok"),
1989            tc("Read"),
1990            tr("ok"),
1991            tc("TodoWrite"),
1992            tr("ok"),
1993        ]);
1994        let sig = ToolSignals::from_request(&request, None);
1995        assert_eq!(sig.todowrite_count, 2);
1996        assert_eq!(sig.recent_todowrite_count, 2);
1997        assert_eq!(sig.read_count, 1);
1998        assert_eq!(sig.recent_read_count, 1);
1999    }
2000
2001    #[test]
2002    fn configured_tool_semantics_extend_the_builtin_vocabulary() {
2003        let semantics = ToolSemantics {
2004            observe: vec!["KB_search".to_string()],
2005            mutate: vec!["send_payment_request".to_string()],
2006            plan: vec!["create_research_plan".to_string()],
2007            new: vec!["send_message_to_user".to_string()],
2008        };
2009        semantics.validate().expect("valid additive semantics");
2010        let request = with_messages(vec![
2011            tc("Read"),
2012            tc("Write"),
2013            tc("TodoWrite"),
2014            tc("kb_SEARCH"),
2015            tc("send_payment_request"),
2016            tc("create_research_plan"),
2017            tc("send_message_to_user"),
2018            tc("unlisted_tool"),
2019        ]);
2020
2021        let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
2022
2023        assert_eq!(signal.read_count, 2);
2024        assert_eq!(signal.write_count, 2);
2025        assert_eq!(signal.todowrite_count, 2);
2026        assert_eq!(signal.new_count, 1);
2027        assert_eq!(signal.recent_new_count, 1);
2028        assert_eq!(signal.pure_bash_streak, 1);
2029    }
2030
2031    #[test]
2032    fn configured_tool_semantics_only_fold_ascii_case() {
2033        let semantics = ToolSemantics {
2034            observe: vec!["kb_search".to_string()],
2035            ..Default::default()
2036        };
2037
2038        assert_eq!(
2039            classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2040            ToolSemantic::Observe
2041        );
2042        // U+212A lowercases to ASCII `k` under Unicode rules, but custom names
2043        // intentionally ignore only ASCII case.
2044        assert_eq!(
2045            classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2046            ToolSemantic::Unknown
2047        );
2048    }
2049
2050    #[test]
2051    fn custom_semantics_preserve_builtin_unicode_lowercasing() {
2052        let semantics = ToolSemantics {
2053            observe: vec!["lookup_customer".to_string()],
2054            ..Default::default()
2055        };
2056
2057        // This matched the built-in `notebookedit` before custom semantics existed.
2058        assert_eq!(
2059            classify_tool_call_with_semantics("notebooKedit", None, &semantics),
2060            ToolSemantic::Mutate(MutationKind::Edit)
2061        );
2062    }
2063
2064    #[test]
2065    fn configured_semantics_never_replace_builtin_classifications() {
2066        let semantics = ToolSemantics {
2067            observe: vec!["lookup_customer".to_string()],
2068            mutate: vec!["send_payment".to_string()],
2069            plan: vec!["create_workflow".to_string()],
2070            new: vec!["send_message".to_string()],
2071        };
2072
2073        for name in WRITE_TOOL_NAMES {
2074            assert_eq!(
2075                classify_tool_call_with_semantics(name, None, &semantics),
2076                ToolSemantic::Mutate(MutationKind::Write),
2077                "write tool {name:?} changed classification"
2078            );
2079        }
2080        for name in EDIT_TOOL_NAMES {
2081            assert_eq!(
2082                classify_tool_call_with_semantics(name, None, &semantics),
2083                ToolSemantic::Mutate(MutationKind::Edit),
2084                "edit tool {name:?} changed classification"
2085            );
2086        }
2087        for name in READ_TOOL_NAMES {
2088            assert_eq!(
2089                classify_tool_call_with_semantics(name, None, &semantics),
2090                ToolSemantic::Observe,
2091                "read tool {name:?} changed classification"
2092            );
2093        }
2094        for name in PLAN_TOOL_NAMES {
2095            assert_eq!(
2096                classify_tool_call_with_semantics(name, None, &semantics),
2097                ToolSemantic::Plan,
2098                "plan tool {name:?} changed classification"
2099            );
2100        }
2101
2102        for (command, expected) in [
2103            ("cat /tmp/input", ToolSemantic::Observe),
2104            (
2105                "cat /tmp/input > /tmp/output",
2106                ToolSemantic::Mutate(MutationKind::Write),
2107            ),
2108            (
2109                "sed -i 's/a/b/' /tmp/file",
2110                ToolSemantic::Mutate(MutationKind::Edit),
2111            ),
2112            ("./run_tests.sh", ToolSemantic::Unknown),
2113        ] {
2114            assert_eq!(
2115                classify_tool_call_with_semantics("BASH", Some(command), &semantics),
2116                expected,
2117                "bash command {command:?} changed classification"
2118            );
2119        }
2120    }
2121
2122    #[test]
2123    fn configured_semantics_score_like_their_builtin_equivalents() {
2124        let semantics = ToolSemantics {
2125            observe: vec!["lookup_customer".to_string()],
2126            mutate: vec!["send_payment".to_string()],
2127            plan: vec!["create_workflow".to_string()],
2128            ..Default::default()
2129        };
2130
2131        for (builtin, configured) in [
2132            ("Read", "lookup_customer"),
2133            ("Write", "send_payment"),
2134            ("TodoWrite", "create_workflow"),
2135        ] {
2136            let messages_before_tool = || {
2137                vec![
2138                    Message::text(Role::User, "start"),
2139                    Message::text(Role::Assistant, "working"),
2140                    Message::text(Role::User, "continue"),
2141                    Message::text(Role::Assistant, "working"),
2142                    Message::text(Role::User, "continue"),
2143                    Message::text(Role::Assistant, "working"),
2144                    Message::text(Role::User, "continue"),
2145                ]
2146            };
2147            let mut builtin_messages = messages_before_tool();
2148            builtin_messages.push(tc(builtin));
2149            let mut configured_messages = messages_before_tool();
2150            configured_messages.push(tc(configured));
2151
2152            let builtin_score = score_signal(&ToolSignals::from_request(
2153                &with_messages(builtin_messages),
2154                None,
2155            ));
2156            let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
2157                &with_messages(configured_messages),
2158                None,
2159                &semantics,
2160            ));
2161
2162            assert_ne!(
2163                builtin_score.score, 0.0,
2164                "the {builtin:?} control must exercise a scoring dimension"
2165            );
2166            assert_eq!(
2167                configured_score, builtin_score,
2168                "configured tool {configured:?} must score exactly like {builtin:?}"
2169            );
2170        }
2171    }
2172
2173    #[test]
2174    fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
2175        let duplicate = ToolSemantics {
2176            observe: vec!["lookup".to_string()],
2177            mutate: vec!["LOOKUP".to_string()],
2178            ..Default::default()
2179        };
2180        assert!(
2181            duplicate
2182                .validate()
2183                .expect_err("duplicate should fail")
2184                .to_string()
2185                .contains("appears in both")
2186        );
2187
2188        let builtin = ToolSemantics {
2189            new: vec!["write_file".to_string()],
2190            ..Default::default()
2191        };
2192        assert!(
2193            builtin
2194                .validate()
2195                .expect_err("built-in should fail")
2196                .to_string()
2197                .contains("built-in semantics")
2198        );
2199
2200        let empty = ToolSemantics {
2201            observe: vec![" \t".to_string()],
2202            ..Default::default()
2203        };
2204        assert!(
2205            empty
2206                .validate()
2207                .expect_err("empty name should fail")
2208                .to_string()
2209                .contains("empty tool name")
2210        );
2211    }
2212}