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