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 async_trait::async_trait;
16use serde::Deserialize;
17use serde_json::Value;
18use switchyard_protocol::{ContentBlock, Request, Role};
19
20use crate::{LibsyError, Result};
21
22use crate::core::processor::{Event, Processor};
23use crate::core::state::State;
24
25// ─── severity constants ───────────────────────────────────────────────────────
26
27const SOFT: f32 = 0.3;
28const HARD: f32 = 0.7;
29const CRITICAL: f32 = 1.0;
30
31// ─── pattern table ────────────────────────────────────────────────────────────
32
33/// (name, severity, lower-cased substrings — any hit fires the pattern)
34static ERROR_PATTERNS: &[(&str, f32, &[&str])] = &[
35    (
36        "oom",
37        CRITICAL,
38        &["out of memory", "memoryerror", "cannot allocate memory"],
39    ),
40    (
41        "connection_refused",
42        CRITICAL,
43        &[
44            "connection refused",
45            "connectionrefusederror",
46            "econnrefused",
47        ],
48    ),
49    ("traceback", HARD, &["traceback (most recent call last)"]),
50    (
51        "import_error",
52        HARD,
53        &["modulenotfounderror:", "importerror:", "no module named "],
54    ),
55    (
56        "cmd_not_found",
57        HARD,
58        &["command not found", "not found\n", "/usr/bin/env: "],
59    ),
60    ("assertion", HARD, &["assertionerror"]),
61    ("value_error", HARD, &["valueerror:"]),
62    ("syntax_error", HARD, &["syntaxerror:"]),
63    (
64        "timeout",
65        HARD,
66        &[
67            "timed out",
68            "timeouterror",
69            "timeout expired",
70            "deadline exceeded",
71        ],
72    ),
73    (
74        "no_such_file",
75        HARD,
76        &[
77            "filenotfounderror:",
78            "no such file or directory",
79            // Claude Code Read-tool miss. Anchored as "file does not exist" (not a
80            // bare "does not exist", which fires on `ls` output and prose) — trace-
81            // mined across 1006 local trajectories at 22 true / 2 false positives.
82            "file does not exist",
83        ],
84    ),
85    // SOFT: plain non-zero exit without a recognisable exception traceback.
86    (
87        "exit_nonzero",
88        SOFT,
89        &[
90            "exit code 1",
91            "exit code 2",
92            "exit status 1",
93            "returned non-zero",
94            "exited with code",
95        ],
96    ),
97];
98
99static EDIT_TOOL_NAMES: &[&str] = &[
100    "edit",
101    "multiedit",
102    "notebookedit",
103    "str_replace",
104    "str_replace_based_edit_tool",
105    "apply_patch", // codex's edit tool
106    "text_editor",
107    "patch", // hermes's str_replace-style edit tool
108];
109
110static WRITE_TOOL_NAMES: &[&str] = &["write", "create_file", "new_file", "write_file"];
111
112// Bash subcommand patterns. Lowercased; callers must lowercase the command
113// before matching. Bucketed into write_count / edit_count alongside the
114// dedicated `Write` / `Edit` tools.
115static BASH_WRITE_PATTERNS: &[&str] = &[
116    "cat >",
117    "cat >>",
118    "echo >",
119    "echo >>",
120    "tee ",
121    "printf >",
122    "printf >>",
123    " > ",
124    " >> ",
125    "> /",
126    ">> /",
127    "<< 'eof'",
128    "<<eof",
129    "<<'eof'",
130    "<< eof",
131];
132
133/// Python file-write expressions, which only indicate a write when an
134/// interpreter is running them rather than a search looking for them.
135static PYTHON_WRITE_PATTERNS: &[&str] = &["write_text(", "writelines(", ".write("];
136
137static BASH_EDIT_PATTERNS: &[&str] = &[
138    "sed -i",
139    "sed --in-place",
140    "awk -i inplace",
141    "awk 'inplace=1'",
142    "patch ",
143    "patch -p",
144    "perl -i",
145    "perl -p -i",
146    "perl -pi",
147];
148
149// Read-like Bash inspections. Match only when none of the write/edit patterns
150// fire (redirection / in-place edit trumps the read intent of the command).
151static BASH_READ_PATTERNS: &[&str] = &[
152    "cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
153    "diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
154];
155
156static READ_TOOL_NAMES: &[&str] = &["read", "view", "read_file", "search_files"];
157
158// Planning / scratchpad tool calls — investigative (non-producing) activity.
159// `update_plan` is codex's equivalent of `todowrite`.
160static PLAN_TOOL_NAMES: &[&str] = &["todowrite", "todo_write", "todo", "update_plan"];
161
162// Tool names that route through Bash-command pattern matching. `bash` is
163// claude-code's name; `shell_command` is codex's; `shell` / `local_shell_call`
164// are seen on some OpenAI-derived harnesses; `terminal` is hermes's (it carries
165// a `command` arg like the others, so its intent comes from the pattern match).
166static BASH_TOOL_NAMES: &[&str] = &[
167    "bash",
168    "shell_command",
169    "shell",
170    "local_shell_call",
171    "terminal",
172    "exec_command", // codex
173];
174
175// Prefer false negatives: tests_passed routes the picker to EFFICIENT, so a false
176// positive would drop tier on an unfinished task.
177static TEST_PASS_PHRASES: &[&str] = &[
178    " passed",
179    "passed in",
180    "tests passed",
181    "all tests passed",
182    "test ok",
183    "test result: ok",
184    "passed.\n",
185    "tests pass",
186    "\nok ", // go test; newline-anchored to avoid "...lookup..." mid-text
187    "✓ ",
188];
189
190// Literal failure phrases that cannot appear inside a clean run. Substring
191// matched as-is. Patterns that pair with a count (e.g. "failed", "errors")
192// are handled separately by `has_nonzero_failure_count` so "0 failed" /
193// "0 errors" do not trigger a false negative.
194static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
195
196// Count-prefixed failure keywords. Trip only when a nonzero integer precedes
197// the keyword (modulo whitespace), so cargo's "0 failed" and go's
198// "0 errors" summaries on a clean run are not misread as failures.
199static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
200
201/// Default sliding-window size for `recent_*` counts and windowed severity.
202///
203/// A short horizon captures "what is the agent doing right now" while keeping
204/// signals sticky — an error or stall persists a few recovery turns instead of
205/// flickering off the moment one clean result lands. Override per request by
206/// passing a window to [`ToolSignals::from_request`].
207pub const DEFAULT_RECENT_WINDOW: usize = 3;
208
209/// Exact tool-name semantics added to the stage router's built-in vocabulary.
210///
211/// Matching is ASCII case-insensitive. These lists are additive: built-in tool
212/// names cannot be reclassified.
213#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
214#[serde(default, deny_unknown_fields)]
215pub struct ToolSemantics {
216    /// Read-only lookup or inspection tools.
217    pub observe: Vec<String>,
218    /// Tools that change task or external state.
219    pub mutate: Vec<String>,
220    /// Explicit planning or task-decomposition tools.
221    pub plan: Vec<String>,
222    /// Tools that demonstrate new forward activity without favoring either tier.
223    pub new: Vec<String>,
224}
225
226impl ToolSemantics {
227    /// Rejects ambiguous mappings and attempts to reclassify built-in tools.
228    pub fn validate(&self) -> Result<()> {
229        let mut seen: Vec<(String, &'static str)> = Vec::new();
230        for (category, names) in [
231            ("observe", &self.observe),
232            ("mutate", &self.mutate),
233            ("plan", &self.plan),
234            ("new", &self.new),
235        ] {
236            for name in names {
237                if name.trim().is_empty() {
238                    return Err(tool_semantics_error(format!(
239                        "tool_semantics.{category} contains an empty tool name"
240                    )));
241                }
242                let normalized = name.to_ascii_lowercase();
243                if is_builtin_tool_name(&name.to_lowercase()) {
244                    return Err(tool_semantics_error(format!(
245                        "tool {name:?} already has built-in semantics and cannot be reclassified"
246                    )));
247                }
248                if let Some((_, previous)) = seen.iter().find(|(seen, _)| seen == &normalized) {
249                    return Err(tool_semantics_error(format!(
250                        "tool {name:?} appears in both tool_semantics.{previous} and tool_semantics.{category}"
251                    )));
252                }
253                seen.push((normalized, category));
254            }
255        }
256        Ok(())
257    }
258
259    fn classify(&self, name: &str) -> Option<ToolSemantic> {
260        if contains_name(&self.observe, name) {
261            Some(ToolSemantic::Observe)
262        } else if contains_name(&self.mutate, name) {
263            // The stage scorer treats writes and edits identically. Custom
264            // mutations use the write counter to preserve the public signal shape.
265            Some(ToolSemantic::Mutate(MutationKind::Write))
266        } else if contains_name(&self.plan, name) {
267            Some(ToolSemantic::Plan)
268        } else if contains_name(&self.new, name) {
269            Some(ToolSemantic::New)
270        } else {
271            None
272        }
273    }
274}
275
276fn contains_name(names: &[String], candidate: &str) -> bool {
277    names
278        .iter()
279        .any(|name| name.eq_ignore_ascii_case(candidate))
280}
281
282fn tool_semantics_error(message: String) -> LibsyError {
283    LibsyError::AlgorithmError { message }
284}
285
286// ─── output type ─────────────────────────────────────────────────────────────
287
288/// Tool-execution signals extracted from a normalized [`Request`].
289///
290/// A request-side processor stores these signals in [`State`](crate::State) for
291/// [`crate::StageRouter`] and its classifier to consume. The advisor gate's
292/// request-side guards read the conversation-shape counts directly via
293/// [`ToolSignals::from_request`].
294#[derive(Clone, Debug, Default)]
295pub struct ToolSignals {
296    /// Max severity across the recent window (last `recent_window` tool results):
297    /// `0.0` clean · `0.3` soft (exit_nonzero) · `0.7` hard · `1.0` critical.
298    /// Windowed so an error persists through the recovery turns instead of clearing
299    /// the instant the next result is clean.
300    pub severity: f32,
301    /// Consecutive clean tool results back from the most recent. `0` if the last failed.
302    pub no_error_streak: u32,
303    /// Total edit-style tool calls in the request.
304    pub edit_count: u32,
305    /// Total write-style tool calls in the request.
306    pub write_count: u32,
307    /// Read-type calls (Read tool + read-like Bash). Used by the build-pit gate.
308    pub read_count: u32,
309    /// TodoWrite / planning tool calls. Investigative (non-producing) activity —
310    /// recent todowrites distinguish `exploring` from `spinning` in the scorer.
311    pub todowrite_count: u32,
312    /// Edit-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
313    pub recent_edit_count: u32,
314    /// Write-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
315    pub recent_write_count: u32,
316    /// Read-type calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
317    pub recent_read_count: u32,
318    /// TodoWrite calls within the configured recent window (default: [`DEFAULT_RECENT_WINDOW`]).
319    pub recent_todowrite_count: u32,
320    /// Configured `new` tool calls across the full request history.
321    pub new_count: u32,
322    /// Configured `new` tool calls within the recent window.
323    pub recent_new_count: u32,
324    /// Consecutive trailing tool calls in the `Unknown` category (no Write/Edit/Read/
325    /// Plan match). Surfaced in the classifier state summary; not scored directly.
326    pub pure_bash_streak: u32,
327    /// At least one of the last three tool results matched a test-pass pattern.
328    pub tests_passed: bool,
329    /// Total `ToolResult` blocks, counted per block (a message batching N
330    /// results contributes N) and including empty-content results.
331    pub tool_result_count: u32,
332    /// Messages with `Role::Assistant`, unlike [`ToolSignals::turn_depth`],
333    /// which counts every message regardless of role.
334    pub assistant_turn_count: u32,
335    /// Message-count proxy for turn depth. Wire-format dependent (Anthropic batches
336    /// tool results into fewer messages than OpenAI-chat), so gates keyed on it are
337    /// approximate across request origins.
338    pub turn_depth: u32,
339    /// The request carries a context-compaction summary (the agent's context was
340    /// summarised after overflowing). Compaction resets the router's accumulated
341    /// signals, so a task that was on the strong tier de-escalates back to weak — the
342    /// picker uses this to force + hold the strong tier. Self-latching: the summary
343    /// stays in the context prefix on every subsequent turn.
344    pub compacted: bool,
345}
346
347impl ToolSignals {
348    /// Extracts tool and activity signals from `request`.
349    ///
350    /// `window_size` limits recent counters to the newest tool results. `None`
351    /// uses [`DEFAULT_RECENT_WINDOW`].
352    pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
353        Self::from_request_with_semantics(request, window_size, &ToolSemantics::default())
354    }
355
356    /// Extracts signals using the built-in vocabulary plus additive semantics.
357    pub fn from_request_with_semantics(
358        request: &Request,
359        window_size: Option<usize>,
360        semantics: &ToolSemantics,
361    ) -> Self {
362        extract_tool_signals_with_window_and_semantics(
363            request,
364            window_size.unwrap_or(DEFAULT_RECENT_WINDOW),
365            semantics,
366        )
367    }
368}
369
370// `command` is the lowercased Bash command line; None for non-Bash tools.
371#[derive(Debug, Clone)]
372struct ObservedToolCall {
373    name: String,
374    command: Option<String>,
375}
376
377#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378enum MutationKind {
379    Write,
380    Edit,
381}
382
383/// Domain-neutral meaning assigned to an observed tool call.
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385enum ToolSemantic {
386    Mutate(MutationKind),
387    Observe,
388    Plan,
389    New,
390    Unknown,
391}
392
393/// Request-side processor that extracts tool-result signals from each request
394/// and stores them on the request `State` for downstream routing.
395#[derive(Debug, Clone)]
396pub struct ToolSignalProcessor {
397    /// Number of trailing tool results the `recent_*` counts and windowed
398    /// severity are computed over.
399    pub recent_window: usize,
400    /// Route-scoped additions to the built-in tool vocabulary.
401    pub tool_semantics: ToolSemantics,
402}
403
404impl Default for ToolSignalProcessor {
405    fn default() -> Self {
406        Self {
407            recent_window: DEFAULT_RECENT_WINDOW,
408            tool_semantics: ToolSemantics::default(),
409        }
410    }
411}
412
413#[async_trait]
414impl Processor<State> for ToolSignalProcessor {
415    async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
416        if let Event::Request { request: req, .. } = event {
417            let tool_signal = ToolSignals::from_request_with_semantics(
418                req,
419                Some(self.recent_window),
420                &self.tool_semantics,
421            );
422            state.tool_signals = Some(tool_signal);
423        }
424        Ok(())
425    }
426}
427
428fn classify_tool_call(name: &str, command: Option<&str>) -> ToolSemantic {
429    classify_tool_call_with_semantics(name, command, &ToolSemantics::default())
430}
431
432fn classify_tool_call_with_semantics(
433    name: &str,
434    command: Option<&str>,
435    semantics: &ToolSemantics,
436) -> ToolSemantic {
437    // Built-in names and Bash command inference take precedence over route-scoped mappings.
438    let lower = name.to_lowercase();
439    if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
440        return ToolSemantic::Mutate(MutationKind::Write);
441    }
442    if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
443        return ToolSemantic::Mutate(MutationKind::Edit);
444    }
445    if READ_TOOL_NAMES.contains(&lower.as_str()) {
446        return ToolSemantic::Observe;
447    }
448    if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
449        return ToolSemantic::Plan;
450    }
451    if BASH_TOOL_NAMES.contains(&lower.as_str())
452        && let Some(cmd) = command
453    {
454        // Write/edit redirection trumps read-like operands.
455        if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
456            return ToolSemantic::Mutate(MutationKind::Write);
457        }
458        if cmd.contains("python") && PYTHON_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
459            return ToolSemantic::Mutate(MutationKind::Write);
460        }
461        if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) {
462            return ToolSemantic::Mutate(MutationKind::Edit);
463        }
464        if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) {
465            return ToolSemantic::Observe;
466        }
467    }
468    semantics.classify(name).unwrap_or(ToolSemantic::Unknown)
469}
470
471fn is_builtin_tool_name(lower: &str) -> bool {
472    WRITE_TOOL_NAMES.contains(&lower)
473        || EDIT_TOOL_NAMES.contains(&lower)
474        || READ_TOOL_NAMES.contains(&lower)
475        || PLAN_TOOL_NAMES.contains(&lower)
476        || BASH_TOOL_NAMES.contains(&lower)
477}
478
479// ─── extraction entry point ───────────────────────────────────────────────────
480
481/// Extract all tool-execution signals from a normalized [`Request`].
482///
483/// Returns [`ToolSignals::default()`] when the message history contains no tool
484/// activity, so callers can always inspect the signal fields.
485fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
486    extract_tool_signals_with_window_and_semantics(
487        request,
488        recent_window,
489        &ToolSemantics::default(),
490    )
491}
492
493fn extract_tool_signals_with_window_and_semantics(
494    request: &Request,
495    recent_window: usize,
496    semantics: &ToolSemantics,
497) -> ToolSignals {
498    // Read the decoded conversation, not the raw body: every inbound format lands
499    // in the same shape here, so the signals do not depend on knowing which one it
500    // arrived as.
501    let messages = &request.llm_request.messages;
502    let mut tool_texts: Vec<String> = Vec::new();
503    let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
504    let mut compacted = false;
505    let mut tool_result_count = 0usize;
506    let mut assistant_turn_count = 0usize;
507
508    for message in messages {
509        if message.role == Role::Assistant {
510            assistant_turn_count += 1;
511        }
512        for block in &message.content {
513            match block {
514                ContentBlock::ToolCall(call) => {
515                    tool_calls.push(ObservedToolCall {
516                        name: call.name.clone(),
517                        command: command_of(&call.arguments),
518                    });
519                }
520                ContentBlock::ToolResult(result) => {
521                    // Before the empty-text filter: empty results still count.
522                    tool_result_count += 1;
523                    let text = result
524                        .content
525                        .iter()
526                        .filter_map(text_of)
527                        .collect::<Vec<_>>()
528                        .join("\n");
529                    if !text.is_empty() {
530                        tool_texts.push(text);
531                    }
532                }
533                // Compaction is detected anywhere in the conversation: the summary
534                // stays in the prefix on every later turn, so this self-latches
535                // once it fires.
536                ContentBlock::Text { text } => {
537                    compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
538                }
539                _ => {}
540            }
541        }
542    }
543
544    let mut signal = build_signal(
545        tool_texts,
546        tool_calls,
547        messages.len() as u32,
548        recent_window,
549        semantics,
550    );
551    signal.compacted = compacted;
552    signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
553    signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
554    signal
555}
556
557/// Distinctive preamble Claude Code injects as a user message when it compacts an
558/// overflowed context. Matched case-insensitively; normal task text never contains it.
559const COMPACTION_MARKER: &str = "session is being continued";
560
561/// The shell command a tool call carries, when it has one. Harnesses name the
562/// field `command`; anything else is a tool whose category comes from its name.
563fn command_of(arguments: &Value) -> Option<String> {
564    // the Responses wire format sends arguments as a JSON string, not an object
565    let decoded = arguments
566        .as_str()
567        .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
568    let object = decoded.as_ref().unwrap_or(arguments);
569
570    ["command", "cmd", "input"]
571        .iter()
572        .filter_map(|key| object.get(*key))
573        .find_map(command_text)
574}
575
576/// A command field as lowercase text, from a string or an argv array.
577fn command_text(value: &Value) -> Option<String> {
578    match value {
579        Value::String(text) => Some(text.to_lowercase()),
580        Value::Array(parts) => {
581            let joined = parts
582                .iter()
583                .filter_map(Value::as_str)
584                .collect::<Vec<_>>()
585                .join(" ");
586            (!joined.is_empty()).then(|| joined.to_lowercase())
587        }
588        _ => None,
589    }
590}
591
592/// Text carried by a content block, ignoring the non-textual kinds.
593fn text_of(block: &ContentBlock) -> Option<&str> {
594    match block {
595        ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
596        _ => None,
597    }
598}
599
600fn build_signal(
601    tool_texts: Vec<String>,
602    tool_calls: Vec<ObservedToolCall>,
603    turn_depth: u32,
604    recent_window: usize,
605    semantics: &ToolSemantics,
606) -> ToolSignals {
607    // Windowed severity: take the MAX severity across the last `recent_window` tool
608    // results rather than only the last one. An error's severity then persists for
609    // the recent window and decays out of it — parallel to the windowed `recent_*`
610    // counts — so a fix written a couple of turns after an error still routes on the
611    // error signal instead of the router flapping straight back to the weak tier.
612    let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
613    let mut severity = 0.0f32;
614    for text in &tool_texts[sev_start..] {
615        let (sev, _patterns) = classify_text(text);
616        if sev > severity {
617            severity = sev;
618        }
619    }
620
621    let no_error_streak = compute_no_error_streak(&tool_texts);
622
623    // Single pass: cumulative + sliding-window counters together. Also tracks
624    // the trailing pure-bash streak (consecutive `Unknown` calls back
625    // from the end) — the build-pit proxy.
626    let recent_start = tool_calls.len().saturating_sub(recent_window);
627    let mut write_count = 0u32;
628    let mut edit_count = 0u32;
629    let mut read_count = 0u32;
630    let mut todowrite_count = 0u32;
631    let mut recent_write_count = 0u32;
632    let mut recent_edit_count = 0u32;
633    let mut recent_read_count = 0u32;
634    let mut recent_todowrite_count = 0u32;
635    let mut new_count = 0u32;
636    let mut recent_new_count = 0u32;
637    let mut pure_bash_streak = 0u32;
638    let mut streak_open = true;
639    for (i, tc) in tool_calls.iter().enumerate().rev() {
640        let cat = classify_tool_call_with_semantics(&tc.name, tc.command.as_deref(), semantics);
641        if streak_open {
642            if matches!(cat, ToolSemantic::Unknown) {
643                pure_bash_streak += 1;
644            } else {
645                streak_open = false;
646            }
647        }
648        match cat {
649            ToolSemantic::Mutate(MutationKind::Write) => {
650                write_count += 1;
651                if i >= recent_start {
652                    recent_write_count += 1;
653                }
654            }
655            ToolSemantic::Mutate(MutationKind::Edit) => {
656                edit_count += 1;
657                if i >= recent_start {
658                    recent_edit_count += 1;
659                }
660            }
661            ToolSemantic::Observe => {
662                read_count += 1;
663                if i >= recent_start {
664                    recent_read_count += 1;
665                }
666            }
667            ToolSemantic::Plan => {
668                todowrite_count += 1;
669                if i >= recent_start {
670                    recent_todowrite_count += 1;
671                }
672            }
673            ToolSemantic::New => {
674                new_count += 1;
675                if i >= recent_start {
676                    recent_new_count += 1;
677                }
678            }
679            ToolSemantic::Unknown => {}
680        }
681    }
682
683    let tests_passed = detect_tests_passed(&tool_texts, recent_window);
684
685    ToolSignals {
686        severity,
687        no_error_streak,
688        edit_count,
689        write_count,
690        read_count,
691        todowrite_count,
692        recent_edit_count,
693        recent_write_count,
694        recent_read_count,
695        recent_todowrite_count,
696        new_count,
697        recent_new_count,
698        pure_bash_streak,
699        tests_passed,
700        turn_depth,
701        // Set by extract_tool_signals_with_window after the format-specific extract,
702        // which scans all message contents for the compaction marker and tallies
703        // the raw conversation-shape counts.
704        tool_result_count: 0,
705        assistant_turn_count: 0,
706        compacted: false,
707    }
708}
709
710// ─── pure helpers ─────────────────────────────────────────────────────────────
711
712/// Normalise a JSON tool-result content value to a plain string.
713fn content_to_text(content: Option<&Value>) -> Option<String> {
714    match content? {
715        Value::String(s) => Some(s.clone()),
716        Value::Array(blocks) => {
717            let parts: Vec<&str> = blocks
718                .iter()
719                .filter_map(|b| {
720                    b.as_object()
721                        .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
722                        .and_then(|o| o.get("text"))
723                        .and_then(Value::as_str)
724                })
725                .collect();
726            if parts.is_empty() {
727                None
728            } else {
729                Some(parts.join("\n"))
730            }
731        }
732        _ => None,
733    }
734}
735
736/// Match `text` against the error pattern table.
737///
738/// Returns `(max_severity, matched_pattern_names)`.
739pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
740    let lower = text.to_lowercase();
741    let mut patterns = Vec::new();
742    let mut severity: f32 = 0.0;
743    for (name, sev, substrings) in ERROR_PATTERNS {
744        if substrings.iter().any(|sub| lower.contains(sub)) {
745            patterns.push(name.to_string());
746            severity = severity.max(*sev);
747        }
748    }
749    (severity, patterns)
750}
751
752fn compute_no_error_streak(tool_texts: &[String]) -> u32 {
753    let mut streak = 0u32;
754    for text in tool_texts.iter().rev() {
755        let (sev, _) = classify_text(text);
756        if sev > 0.0 {
757            break;
758        }
759        streak += 1;
760    }
761    streak
762}
763
764fn detect_tests_passed(tool_texts: &[String], recent_window: usize) -> bool {
765    let start = tool_texts.len().saturating_sub(recent_window.max(1));
766    tool_texts[start..].iter().any(|text| {
767        let lower = text.to_lowercase();
768        TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
769            && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
770            && !has_nonzero_failure_count(&lower)
771    })
772}
773
774// True iff `lower` contains a `NUMERIC_FAILURE_KEYWORDS` token preceded
775// (modulo whitespace) by a nonzero integer. The "modulo whitespace" lets
776// "1 failed", "1\nfailed", and "1  failed" all trip; the nonzero guard
777// keeps cargo's "0 failed" / go's "0 errors" / pytest's "0 errors in"
778// summaries from being misread as failures on a clean run.
779fn has_nonzero_failure_count(lower: &str) -> bool {
780    for kw in NUMERIC_FAILURE_KEYWORDS {
781        let mut cursor = 0usize;
782        while let Some(rel) = lower[cursor..].find(kw) {
783            let kw_start = cursor + rel;
784            let kw_end = kw_start + kw.len();
785            // Word boundary AFTER the keyword — "errors" mid-word (e.g.
786            // "errored") shouldn't count as a failure-count site.
787            let boundary_after = lower[kw_end..]
788                .chars()
789                .next()
790                .is_none_or(|c| !c.is_ascii_alphanumeric());
791            if boundary_after {
792                let prefix = &lower[..kw_start];
793                let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
794                let digits_rev: String = trimmed
795                    .chars()
796                    .rev()
797                    .take_while(|c| c.is_ascii_digit())
798                    .collect();
799                if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
800                    return true;
801                }
802            }
803            cursor = kw_start + kw.len();
804        }
805    }
806    false
807}
808
809// ─── tests ───────────────────────────────────────────────────────────────────
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814    use crate::algorithms::util::stage::score_signal;
815    use serde_json::json;
816    use switchyard_protocol::{ContentBlock, LlmRequest, Message, Role, ToolCall, ToolResult};
817
818    fn with_messages(messages: Vec<Message>) -> Request {
819        Request {
820            llm_request: LlmRequest {
821                messages,
822                ..LlmRequest::default()
823            },
824            raw_request: None,
825            metadata: None,
826        }
827    }
828
829    // assistant message with a single named tool call
830    fn tc(name: &str) -> Message {
831        Message {
832            role: Role::Assistant,
833            content: vec![ContentBlock::ToolCall(ToolCall {
834                id: String::new(),
835                name: name.to_string(),
836                arguments: json!({}),
837            })],
838        }
839    }
840
841    // assistant Bash message carrying `command`
842    fn bash(command: &str) -> Message {
843        Message {
844            role: Role::Assistant,
845            content: vec![ContentBlock::ToolCall(ToolCall {
846                id: String::new(),
847                name: "Bash".to_string(),
848                arguments: json!({"command": command}),
849            })],
850        }
851    }
852
853    // a tool result message (goes in a user-role message, as in Anthropic's normalised form)
854    fn tr(text: &str) -> Message {
855        Message {
856            role: Role::User,
857            content: vec![ContentBlock::ToolResult(ToolResult {
858                tool_call_id: String::new(),
859                content: vec![ContentBlock::Text {
860                    text: text.to_string(),
861                }],
862                is_error: None,
863            })],
864        }
865    }
866
867    #[test]
868    fn clean_text_has_zero_severity() {
869        let (sev, patterns) = classify_text("everything went fine");
870        assert_eq!(sev, 0.0);
871        assert!(patterns.is_empty());
872    }
873
874    #[test]
875    fn traceback_is_hard() {
876        let (sev, patterns) = classify_text("Traceback (most recent call last):\n  ValueError");
877        assert_eq!(sev, HARD);
878        assert!(patterns.contains(&"traceback".to_string()));
879    }
880
881    #[test]
882    fn oom_is_critical() {
883        let (sev, _) = classify_text("Out of memory: kill process 1234");
884        assert_eq!(sev, CRITICAL);
885    }
886
887    #[test]
888    fn severity_is_max_across_patterns() {
889        // exit_nonzero (SOFT) + traceback (HARD) → HARD.
890        let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
891        assert_eq!(sev, HARD);
892    }
893
894    #[test]
895    fn file_does_not_exist_is_hard() {
896        // Claude Code Read-tool miss. Trace-mined addition (22 true / 2 false positives).
897        let (sev, patterns) =
898            classify_text("Error: File does not exist. Note: current working directory is /app.");
899        assert_eq!(sev, HARD);
900        assert!(patterns.contains(&"no_such_file".to_string()));
901    }
902
903    #[test]
904    fn bare_does_not_exist_stays_clean() {
905        // Precision guard: only the anchored "file does not exist" fires, so a bare
906        // "does not exist" in prose or directory output must not trip a false error.
907        let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
908        assert_eq!(sev, 0.0);
909    }
910
911    #[test]
912    fn no_error_streak_all_clean() {
913        let texts = vec!["ok".to_string(), "all good".to_string()];
914        assert_eq!(compute_no_error_streak(&texts), 2);
915    }
916
917    #[test]
918    fn no_error_streak_stops_at_error() {
919        let texts = vec![
920            "Traceback (most recent call last):".to_string(),
921            "ok".to_string(),
922            "ok".to_string(),
923        ];
924        assert_eq!(compute_no_error_streak(&texts), 2);
925    }
926
927    #[test]
928    fn tests_passed_detects_pytest_output() {
929        assert!(detect_tests_passed(
930            &["====== 5 passed in 0.12s ======".to_string()],
931            DEFAULT_RECENT_WINDOW
932        ));
933    }
934
935    #[test]
936    fn tests_passed_ignores_partial_failures() {
937        assert!(!detect_tests_passed(
938            &["2 failed, 5 passed in 0.56s".to_string()],
939            DEFAULT_RECENT_WINDOW
940        ));
941    }
942
943    #[test]
944    fn severity_is_windowed_over_recent_results() {
945        // An error two results back, then two clean results.
946        let request = with_messages(vec![
947            tr("Traceback (most recent call last):\n  ValueError"),
948            tr("ok"),
949            tr("ok"),
950        ]);
951        // window covers the error → severity persists (max over the window)
952        assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
953        // window of 1 sees only the last (clean) result → severity has decayed out
954        assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
955    }
956
957    #[test]
958    fn extract_openai_chat_tool_results() {
959        let request = with_messages(vec![
960            Message::text(Role::User, "do something"),
961            tc("Edit"),
962            tr("Traceback (most recent call last):\n  ValueError"),
963        ]);
964        let sig = ToolSignals::from_request(&request, None);
965        assert_eq!(sig.severity, HARD);
966        assert_eq!(sig.edit_count, 1);
967        assert_eq!(sig.turn_depth, 3);
968    }
969
970    #[test]
971    fn extract_anthropic_tool_results() {
972        let request = with_messages(vec![tr("Traceback (most recent call last):\n  ValueError")]);
973        let sig = ToolSignals::from_request(&request, None);
974        assert_eq!(sig.severity, HARD);
975    }
976
977    #[test]
978    fn extract_responses_api_tool_results() {
979        let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
980        let sig = ToolSignals::from_request(&request, None);
981        assert_eq!(sig.severity, 0.0);
982        assert_eq!(sig.write_count, 1);
983    }
984
985    #[test]
986    fn conversation_counts_are_per_block_and_role_aware() {
987        // A batched user message (Anthropic shape) contributes one count per
988        // ToolResult block, empty-content results included. assistant_turn_count
989        // tracks Role::Assistant only, while turn_depth counts every message.
990        let result = |content: Vec<ContentBlock>| {
991            ContentBlock::ToolResult(ToolResult {
992                tool_call_id: String::new(),
993                content,
994                is_error: None,
995            })
996        };
997        let request = with_messages(vec![
998            Message::text(Role::User, "do something"),
999            Message::text(Role::Assistant, "working"),
1000            Message {
1001                role: Role::User,
1002                content: vec![
1003                    result(vec![ContentBlock::Text {
1004                        text: "ok".to_string(),
1005                    }]),
1006                    result(Vec::new()),
1007                ],
1008            },
1009            tc("Bash"),
1010        ]);
1011        let sig = ToolSignals::from_request(&request, None);
1012        assert_eq!(sig.tool_result_count, 2);
1013        assert_eq!(sig.assistant_turn_count, 2);
1014        assert_eq!(sig.turn_depth, 4);
1015    }
1016
1017    #[test]
1018    fn recent_window_counts_only_last_default_window_tool_calls() {
1019        // 5 writes + 1 edit at the end → the default window (3) should see
1020        // the last 3 calls: 1 edit + 2 writes (not all 6 calls).
1021        let request = with_messages(vec![
1022            tc("Write"),
1023            tr("ok"),
1024            tc("Write"),
1025            tr("ok"),
1026            tc("Write"),
1027            tr("ok"),
1028            tc("Write"),
1029            tr("ok"),
1030            tc("Write"),
1031            tr("ok"),
1032            tc("Edit"),
1033            tr("ok"),
1034        ]);
1035        let sig = ToolSignals::from_request(&request, None);
1036        assert_eq!(sig.write_count, 5);
1037        assert_eq!(sig.edit_count, 1);
1038        assert_eq!(sig.recent_write_count, 2);
1039        assert_eq!(sig.recent_edit_count, 1);
1040    }
1041
1042    #[test]
1043    fn codex_apply_patch_counts_as_an_edit() {
1044        let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
1045        let sig = ToolSignals::from_request(&request, None);
1046        assert_eq!(sig.edit_count, 1);
1047        assert_eq!(sig.recent_edit_count, 1);
1048    }
1049
1050    fn exec_command(cmd: Value) -> Message {
1051        Message {
1052            role: Role::Assistant,
1053            content: vec![ContentBlock::ToolCall(ToolCall {
1054                id: String::new(),
1055                name: "exec_command".to_string(),
1056                arguments: cmd,
1057            })],
1058        }
1059    }
1060
1061    #[test]
1062    fn codex_exec_command_is_classified() {
1063        // arguments arrive as a JSON string, with the command under `cmd`
1064        let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
1065        let request = with_messages(vec![exec_command(args), tr("ok")]);
1066        assert_eq!(
1067            ToolSignals::from_request(&request, None).recent_edit_count,
1068            1
1069        );
1070    }
1071
1072    #[test]
1073    fn python_write_expressions_need_a_python_command() {
1074        let write = with_messages(vec![
1075            exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
1076            tr("ok"),
1077        ]);
1078        assert_eq!(
1079            ToolSignals::from_request(&write, None).recent_write_count,
1080            1
1081        );
1082
1083        let search = with_messages(vec![
1084            exec_command(json!({"cmd": "grep -R '.write(' src"})),
1085            tr("ok"),
1086        ]);
1087        assert_eq!(
1088            ToolSignals::from_request(&search, None).recent_write_count,
1089            0
1090        );
1091    }
1092
1093    #[test]
1094    fn recent_window_size_is_caller_overridable() {
1095        // Same six tool calls (1 edit at the end, 5 writes before).
1096        // With recent_window=3 → recent_writes=2, recent_edits=1.
1097        // With recent_window=6 → recent_writes=5, recent_edits=1 (all calls).
1098        let request = with_messages(vec![
1099            tc("Write"),
1100            tr("ok"),
1101            tc("Write"),
1102            tr("ok"),
1103            tc("Write"),
1104            tr("ok"),
1105            tc("Write"),
1106            tr("ok"),
1107            tc("Write"),
1108            tr("ok"),
1109            tc("Edit"),
1110            tr("ok"),
1111        ]);
1112        let narrow = extract_tool_signals_with_window(&request, 3);
1113        assert_eq!(narrow.recent_write_count, 2);
1114        assert_eq!(narrow.recent_edit_count, 1);
1115
1116        let wide = extract_tool_signals_with_window(&request, 6);
1117        assert_eq!(wide.recent_write_count, 5);
1118        assert_eq!(wide.recent_edit_count, 1);
1119    }
1120
1121    #[test]
1122    fn compaction_marker_sets_compacted() {
1123        // The compaction summary is a user message carrying Claude Code's preamble.
1124        let request = with_messages(vec![
1125            Message::text(
1126                Role::User,
1127                "This session is being continued from a previous conversation that ran out of context.",
1128            ),
1129            bash("ls"),
1130        ]);
1131        assert!(ToolSignals::from_request(&request, None).compacted);
1132    }
1133
1134    #[test]
1135    fn no_compaction_marker_stays_uncompacted() {
1136        let request = with_messages(vec![
1137            Message::text(Role::User, "Write a script that parses the log file."),
1138            bash("ls"),
1139        ]);
1140        assert!(!ToolSignals::from_request(&request, None).compacted);
1141    }
1142
1143    #[test]
1144    fn bash_heredoc_counts_as_write() {
1145        // Claude Code's pattern on TB 2.0 — write a scratch file via heredoc.
1146        let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
1147        let sig = ToolSignals::from_request(&request, None);
1148        assert_eq!(
1149            sig.write_count, 1,
1150            "Bash heredoc should bucket into write_count"
1151        );
1152        assert_eq!(sig.edit_count, 0);
1153    }
1154
1155    #[test]
1156    fn bash_redirection_after_arguments_counts_as_write() {
1157        let request = with_messages(vec![bash("printf 'completed\\n' > task.txt")]);
1158        let sig = ToolSignals::from_request(&request, None);
1159        assert_eq!(sig.write_count, 1);
1160        assert_eq!(sig.edit_count, 0);
1161    }
1162
1163    #[test]
1164    fn bash_sed_inplace_counts_as_edit() {
1165        let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
1166        let sig = ToolSignals::from_request(&request, None);
1167        assert_eq!(
1168            sig.edit_count, 1,
1169            "Bash sed -i should bucket into edit_count"
1170        );
1171        assert_eq!(sig.write_count, 0);
1172    }
1173
1174    #[test]
1175    fn bash_non_mutating_does_not_count() {
1176        // ls, cat, grep — should not increment either counter.
1177        let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
1178        let sig = ToolSignals::from_request(&request, None);
1179        assert_eq!(sig.write_count, 0);
1180        assert_eq!(sig.edit_count, 0);
1181    }
1182
1183    #[test]
1184    fn tests_passed_detects_pytest_with_failure_block() {
1185        // Mixed pytest run: 2 failed + 5 passed → NOT considered tests_passed.
1186        assert!(!detect_tests_passed(
1187            &["2 failed, 5 passed in 0.56s".to_string()],
1188            DEFAULT_RECENT_WINDOW
1189        ));
1190    }
1191
1192    #[test]
1193    fn tests_passed_accepts_cargo_clean_summary() {
1194        // Cargo's clean-run summary contains "0 failed" — must not trip the
1195        // failure list (regression: previously substring-matched "failed").
1196        assert!(detect_tests_passed(
1197            &["running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string()],
1198            DEFAULT_RECENT_WINDOW
1199        ));
1200    }
1201
1202    #[test]
1203    fn tests_passed_rejects_cargo_real_failure() {
1204        // Cargo's actual-failure summary: nonzero count before "failed".
1205        assert!(!detect_tests_passed(
1206            &["running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string()],
1207            DEFAULT_RECENT_WINDOW
1208        ));
1209    }
1210
1211    #[test]
1212    fn tests_passed_accepts_go_clean_summary() {
1213        // Go test's clean-run "0 errors" must not trip (regression).
1214        assert!(detect_tests_passed(
1215            &["ok  github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string()],
1216            DEFAULT_RECENT_WINDOW
1217        ));
1218    }
1219
1220    #[test]
1221    fn tests_passed_accepts_pytest_zero_errors() {
1222        // Pytest long-form: "0 errors in 0.3s" on a clean run.
1223        assert!(detect_tests_passed(
1224            &["5 passed, 0 errors in 0.30s".to_string()],
1225            DEFAULT_RECENT_WINDOW
1226        ));
1227    }
1228
1229    #[test]
1230    fn tests_passed_detects_diy_checkmark() {
1231        assert!(detect_tests_passed(
1232            &["✓ all checks passed".to_string()],
1233            DEFAULT_RECENT_WINDOW
1234        ));
1235    }
1236
1237    #[test]
1238    fn anthropic_bash_heredoc_extracts_command() {
1239        // Anthropic format: tool_use.input is an object, not a JSON string.
1240        let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
1241        let sig = ToolSignals::from_request(&request, None);
1242        assert_eq!(
1243            sig.write_count, 1,
1244            "Anthropic Bash heredoc must also be detected"
1245        );
1246    }
1247
1248    #[test]
1249    fn recent_window_falls_back_to_full_history_when_short() {
1250        let request = with_messages(vec![tc("Write")]);
1251        let sig = ToolSignals::from_request(&request, None);
1252        assert_eq!(sig.recent_write_count, 1);
1253        assert_eq!(sig.recent_edit_count, 0);
1254    }
1255
1256    #[test]
1257    fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
1258        let request = with_messages(vec![tr("output ok"), tr("another ok")]);
1259        let sig = ToolSignals::from_request(&request, None);
1260        assert_eq!(sig.severity, 0.0);
1261        assert_eq!(sig.no_error_streak, 2);
1262    }
1263
1264    // ─── asymmetric-signal extensions ────────────────────────────────────
1265
1266    #[test]
1267    fn todowrite_classifies_as_plan() {
1268        assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
1269        assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
1270    }
1271
1272    #[test]
1273    fn codex_update_plan_classifies_as_plan() {
1274        assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
1275    }
1276
1277    #[test]
1278    fn codex_shell_command_runs_bash_pattern_match() {
1279        // shell_command + heredoc -> Write.
1280        assert_eq!(
1281            classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
1282            ToolSemantic::Mutate(MutationKind::Write),
1283        );
1284        // shell_command + read-like inspection -> Read.
1285        assert_eq!(
1286            classify_tool_call("shell_command", Some("ls /app")),
1287            ToolSemantic::Observe,
1288        );
1289        // shell_command without matching patterns -> Unknown.
1290        assert_eq!(
1291            classify_tool_call("shell_command", Some("./run_tests.sh")),
1292            ToolSemantic::Unknown,
1293        );
1294    }
1295
1296    #[test]
1297    fn read_tool_classifies_as_read() {
1298        assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
1299        assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
1300    }
1301
1302    #[test]
1303    fn hermes_tool_names_classify() {
1304        // Hermes (NousResearch) file tools route by name.
1305        assert_eq!(
1306            classify_tool_call("write_file", None),
1307            ToolSemantic::Mutate(MutationKind::Write)
1308        );
1309        assert_eq!(
1310            classify_tool_call("patch", None),
1311            ToolSemantic::Mutate(MutationKind::Edit)
1312        );
1313        assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
1314        assert_eq!(
1315            classify_tool_call("search_files", None),
1316            ToolSemantic::Observe
1317        );
1318        // Hermes runs shell through `terminal`, which carries a `command` arg,
1319        // so its intent comes from the Bash-pattern match like codex's shell_command.
1320        assert_eq!(
1321            classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
1322            ToolSemantic::Mutate(MutationKind::Edit),
1323        );
1324        assert_eq!(
1325            classify_tool_call("terminal", Some("grep foo /app")),
1326            ToolSemantic::Observe,
1327        );
1328        assert_eq!(
1329            classify_tool_call("terminal", Some("./run_tests.sh")),
1330            ToolSemantic::Unknown,
1331        );
1332    }
1333
1334    #[test]
1335    fn bash_read_patterns_classify_as_read() {
1336        let cases = [
1337            "cat /etc/passwd",
1338            "grep foo bar.txt",
1339            "ls /app",
1340            "find . -name '*.py'",
1341        ];
1342        for cmd in cases {
1343            assert_eq!(
1344                classify_tool_call("Bash", Some(cmd)),
1345                ToolSemantic::Observe,
1346                "expected Read for {cmd}"
1347            );
1348        }
1349    }
1350
1351    #[test]
1352    fn bash_write_precedence_over_read() {
1353        // `cat /file > out` contains both `cat /` (read) and ` > ` (write);
1354        // write redirection must win.
1355        assert_eq!(
1356            classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
1357            ToolSemantic::Mutate(MutationKind::Write),
1358        );
1359    }
1360
1361    #[test]
1362    fn pure_bash_streak_counts_trailing_other() {
1363        // 5 trailing non-classified Bash calls → streak == 5.
1364        let request = with_messages(vec![
1365            bash("make"),
1366            tr("ok"),
1367            bash("./configure"),
1368            tr("ok"),
1369            bash("make install"),
1370            tr("ok"),
1371            bash("./run.sh"),
1372            tr("ok"),
1373            bash("./test"),
1374            tr("ok"),
1375        ]);
1376        let sig = ToolSignals::from_request(&request, None);
1377        assert_eq!(sig.pure_bash_streak, 5);
1378        assert_eq!(sig.write_count, 0);
1379        assert_eq!(sig.read_count, 0);
1380    }
1381
1382    #[test]
1383    fn pure_bash_streak_resets_on_write() {
1384        let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
1385        let sig = ToolSignals::from_request(&request, None);
1386        assert_eq!(sig.pure_bash_streak, 0);
1387        assert_eq!(sig.write_count, 1);
1388    }
1389
1390    #[test]
1391    fn recent_window_tracks_todowrite_and_read() {
1392        // Final 3 tool calls: TodoWrite, Read, TodoWrite.
1393        let request = with_messages(vec![
1394            bash("make"),
1395            tr("ok"),
1396            tc("TodoWrite"),
1397            tr("ok"),
1398            tc("Read"),
1399            tr("ok"),
1400            tc("TodoWrite"),
1401            tr("ok"),
1402        ]);
1403        let sig = ToolSignals::from_request(&request, None);
1404        assert_eq!(sig.todowrite_count, 2);
1405        assert_eq!(sig.recent_todowrite_count, 2);
1406        assert_eq!(sig.read_count, 1);
1407        assert_eq!(sig.recent_read_count, 1);
1408    }
1409
1410    #[test]
1411    fn configured_tool_semantics_extend_the_builtin_vocabulary() {
1412        let semantics = ToolSemantics {
1413            observe: vec!["KB_search".to_string()],
1414            mutate: vec!["send_payment_request".to_string()],
1415            plan: vec!["create_research_plan".to_string()],
1416            new: vec!["send_message_to_user".to_string()],
1417        };
1418        semantics.validate().expect("valid additive semantics");
1419        let request = with_messages(vec![
1420            tc("Read"),
1421            tc("Write"),
1422            tc("TodoWrite"),
1423            tc("kb_SEARCH"),
1424            tc("send_payment_request"),
1425            tc("create_research_plan"),
1426            tc("send_message_to_user"),
1427            tc("unlisted_tool"),
1428        ]);
1429
1430        let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
1431
1432        assert_eq!(signal.read_count, 2);
1433        assert_eq!(signal.write_count, 2);
1434        assert_eq!(signal.todowrite_count, 2);
1435        assert_eq!(signal.new_count, 1);
1436        assert_eq!(signal.recent_new_count, 1);
1437        assert_eq!(signal.pure_bash_streak, 1);
1438    }
1439
1440    #[test]
1441    fn configured_tool_semantics_only_fold_ascii_case() {
1442        let semantics = ToolSemantics {
1443            observe: vec!["kb_search".to_string()],
1444            ..Default::default()
1445        };
1446
1447        assert_eq!(
1448            classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
1449            ToolSemantic::Observe
1450        );
1451        // U+212A lowercases to ASCII `k` under Unicode rules, but custom names
1452        // intentionally ignore only ASCII case.
1453        assert_eq!(
1454            classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
1455            ToolSemantic::Unknown
1456        );
1457    }
1458
1459    #[test]
1460    fn custom_semantics_preserve_builtin_unicode_lowercasing() {
1461        let semantics = ToolSemantics {
1462            observe: vec!["lookup_customer".to_string()],
1463            ..Default::default()
1464        };
1465
1466        // This matched the built-in `notebookedit` before custom semantics existed.
1467        assert_eq!(
1468            classify_tool_call_with_semantics("notebooKedit", None, &semantics),
1469            ToolSemantic::Mutate(MutationKind::Edit)
1470        );
1471    }
1472
1473    #[test]
1474    fn configured_semantics_never_replace_builtin_classifications() {
1475        let semantics = ToolSemantics {
1476            observe: vec!["lookup_customer".to_string()],
1477            mutate: vec!["send_payment".to_string()],
1478            plan: vec!["create_workflow".to_string()],
1479            new: vec!["send_message".to_string()],
1480        };
1481
1482        for name in WRITE_TOOL_NAMES {
1483            assert_eq!(
1484                classify_tool_call_with_semantics(name, None, &semantics),
1485                ToolSemantic::Mutate(MutationKind::Write),
1486                "write tool {name:?} changed classification"
1487            );
1488        }
1489        for name in EDIT_TOOL_NAMES {
1490            assert_eq!(
1491                classify_tool_call_with_semantics(name, None, &semantics),
1492                ToolSemantic::Mutate(MutationKind::Edit),
1493                "edit tool {name:?} changed classification"
1494            );
1495        }
1496        for name in READ_TOOL_NAMES {
1497            assert_eq!(
1498                classify_tool_call_with_semantics(name, None, &semantics),
1499                ToolSemantic::Observe,
1500                "read tool {name:?} changed classification"
1501            );
1502        }
1503        for name in PLAN_TOOL_NAMES {
1504            assert_eq!(
1505                classify_tool_call_with_semantics(name, None, &semantics),
1506                ToolSemantic::Plan,
1507                "plan tool {name:?} changed classification"
1508            );
1509        }
1510
1511        for (command, expected) in [
1512            ("cat /tmp/input", ToolSemantic::Observe),
1513            (
1514                "cat /tmp/input > /tmp/output",
1515                ToolSemantic::Mutate(MutationKind::Write),
1516            ),
1517            (
1518                "sed -i 's/a/b/' /tmp/file",
1519                ToolSemantic::Mutate(MutationKind::Edit),
1520            ),
1521            ("./run_tests.sh", ToolSemantic::Unknown),
1522        ] {
1523            assert_eq!(
1524                classify_tool_call_with_semantics("BASH", Some(command), &semantics),
1525                expected,
1526                "bash command {command:?} changed classification"
1527            );
1528        }
1529    }
1530
1531    #[test]
1532    fn configured_semantics_score_like_their_builtin_equivalents() {
1533        let semantics = ToolSemantics {
1534            observe: vec!["lookup_customer".to_string()],
1535            mutate: vec!["send_payment".to_string()],
1536            plan: vec!["create_workflow".to_string()],
1537            ..Default::default()
1538        };
1539
1540        for (builtin, configured) in [
1541            ("Read", "lookup_customer"),
1542            ("Write", "send_payment"),
1543            ("TodoWrite", "create_workflow"),
1544        ] {
1545            let messages_before_tool = || {
1546                vec![
1547                    Message::text(Role::User, "start"),
1548                    Message::text(Role::Assistant, "working"),
1549                    Message::text(Role::User, "continue"),
1550                    Message::text(Role::Assistant, "working"),
1551                    Message::text(Role::User, "continue"),
1552                    Message::text(Role::Assistant, "working"),
1553                    Message::text(Role::User, "continue"),
1554                ]
1555            };
1556            let mut builtin_messages = messages_before_tool();
1557            builtin_messages.push(tc(builtin));
1558            let mut configured_messages = messages_before_tool();
1559            configured_messages.push(tc(configured));
1560
1561            let builtin_score = score_signal(&ToolSignals::from_request(
1562                &with_messages(builtin_messages),
1563                None,
1564            ));
1565            let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
1566                &with_messages(configured_messages),
1567                None,
1568                &semantics,
1569            ));
1570
1571            assert_ne!(
1572                builtin_score.score, 0.0,
1573                "the {builtin:?} control must exercise a scoring dimension"
1574            );
1575            assert_eq!(
1576                configured_score, builtin_score,
1577                "configured tool {configured:?} must score exactly like {builtin:?}"
1578            );
1579        }
1580    }
1581
1582    #[test]
1583    fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
1584        let duplicate = ToolSemantics {
1585            observe: vec!["lookup".to_string()],
1586            mutate: vec!["LOOKUP".to_string()],
1587            ..Default::default()
1588        };
1589        assert!(
1590            duplicate
1591                .validate()
1592                .expect_err("duplicate should fail")
1593                .to_string()
1594                .contains("appears in both")
1595        );
1596
1597        let builtin = ToolSemantics {
1598            new: vec!["write_file".to_string()],
1599            ..Default::default()
1600        };
1601        assert!(
1602            builtin
1603                .validate()
1604                .expect_err("built-in should fail")
1605                .to_string()
1606                .contains("built-in semantics")
1607        );
1608
1609        let empty = ToolSemantics {
1610            observe: vec![" \t".to_string()],
1611            ..Default::default()
1612        };
1613        assert!(
1614            empty
1615                .validate()
1616                .expect_err("empty name should fail")
1617                .to_string()
1618                .contains("empty tool name")
1619        );
1620    }
1621}