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