1#![allow(dead_code)]
14
15use std::collections::HashSet;
16use std::path::Path;
17use std::sync::LazyLock;
18
19use async_trait::async_trait;
20use regex::Regex;
21use serde::Deserialize;
22use serde_json::Value;
23use switchyard_protocol::codex_namespaces::{split_qualified_name, tool_namespaces};
24use switchyard_protocol::{ContentBlock, Request, Role, WireFormat};
25
26use super::affinity::is_user_turn;
27use crate::{LibsyError, Result};
28
29use crate::core::processor::{Event, Processor};
30use crate::core::state::State;
31
32const SOFT: f32 = 0.3;
35const HARD: f32 = 0.7;
36const CRITICAL: f32 = 1.0;
37
38static ERROR_PATTERNS: &[(&str, f32, &[&str])] = &[
42 (
43 "oom",
44 CRITICAL,
45 &["out of memory", "memoryerror", "cannot allocate memory"],
46 ),
47 (
48 "connection_refused",
49 HARD,
50 &[
51 "connection refused",
52 "connectionrefusederror",
53 "econnrefused",
54 ],
55 ),
56 ("traceback", HARD, &["traceback (most recent call last)"]),
57 (
58 "import_error",
59 HARD,
60 &["modulenotfounderror:", "importerror:", "no module named "],
61 ),
62 (
63 "cmd_not_found",
64 HARD,
65 &["command not found", "/usr/bin/env: "],
66 ),
67 ("assertion", HARD, &["assertionerror"]),
68 ("value_error", HARD, &["valueerror:"]),
69 ("syntax_error", HARD, &["syntaxerror:"]),
70 (
71 "timeout",
72 HARD,
73 &[
74 "timed out",
75 "timeouterror",
76 "timeout expired",
77 "deadline exceeded",
78 ],
79 ),
80 (
81 "no_such_file",
82 HARD,
83 &[
84 "filenotfounderror:",
85 "no such file or directory",
86 "file does not exist",
91 ],
92 ),
93 ("exit_nonzero", SOFT, &["returned non-zero"]),
95];
96
97static NONZERO_EXIT_PHRASES: &[&str] = &[
98 "exit code",
99 "exit status",
100 "exited with code",
101 "exited with status",
102];
103
104static EDIT_TOOL_NAMES: &[&str] = &[
105 "edit",
106 "multiedit",
107 "notebookedit",
108 "str_replace",
109 "str_replace_based_edit_tool",
110 "apply_patch", "text_editor",
112 "patch", ];
114
115static EDITOR_TOOL_NAMES: &[&str] = &["str_replace_based_edit_tool", "text_editor"];
117
118static WRITE_TOOL_NAMES: &[&str] = &["write", "create_file", "new_file", "write_file"];
119
120static BASH_WRITE_PATTERNS: &[&str] = &[
124 "cat >",
125 "cat >>",
126 "echo >",
127 "echo >>",
128 "tee ",
129 "printf >",
130 "printf >>",
131 "> /",
132 ">> /",
133 "<< 'eof'",
134 "<<eof",
135 "<<'eof'",
136 "<< eof",
137];
138
139static PYTHON_WRITE_PATTERNS: &[&str] = &["write_text(", "writelines(", ".write("];
142
143static JAVASCRIPT_WRITE_PATTERNS: &[&str] = &[
144 "writefilesync(",
145 "writefile(",
146 "appendfilesync(",
147 "appendfile(",
148];
149
150static BASH_EDIT_PATTERNS: &[&str] = &[
151 "sed -i",
152 "sed --in-place",
153 "awk -i inplace",
154 "awk 'inplace=1'",
155 "patch ",
156 "patch -p",
157 "perl -i",
158 "perl -p -i",
159 "perl -pi",
160];
161
162static BASH_READ_PATTERNS: &[&str] = &[
165 "cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
166 "diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
167];
168
169static BASH_READ_COMMANDS: &[&str] = &[
172 "cat", "rg", "nl", "jq", "pwd", "tree", "sed", "grep", "ls", "find", "head", "tail", "wc",
173 "diff", "which", "ps", "df", "du", "stat", "file", "less", "more", "readlink", "realpath",
174 "basename", "dirname", "printenv",
175];
176
177static GIT_READ_SUBCOMMANDS: &[&str] = &[
178 "status",
179 "diff",
180 "log",
181 "show",
182 "show-ref",
183 "rev-parse",
184 "ls-files",
185 "ls-remote",
186 "ls-tree",
187 "grep",
188 "blame",
189 "merge-base",
190 "check-ignore",
191 "tag",
192];
193
194static READ_TOOL_NAMES: &[&str] = &[
195 "read",
196 "view",
197 "read_file",
198 "search_files",
199 "glob",
200 "grep",
201 "find",
202 "ls",
203];
204
205static PLAN_TOOL_NAMES: &[&str] = &[
208 "todowrite",
209 "todo_write",
210 "todo",
211 "update_plan",
212 "todo_list",
213];
214
215static BASH_TOOL_NAMES: &[&str] = &[
220 "bash",
221 "shell_command",
222 "shell",
223 "local_shell_call",
224 "terminal",
225 "exec_command", "exec", "powershell", ];
229
230static TEST_PASS_PHRASES: &[&str] = &[
233 " passed",
234 "passed in",
235 "tests passed",
236 "all tests passed",
237 "test ok",
238 "test result: ok",
239 "passed.\n",
240 "tests pass",
241 "\nok ", "✓ ",
243];
244
245static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
250
251static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
255
256pub const DEFAULT_RECENT_WINDOW: usize = 3;
263
264#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
270#[serde(default, deny_unknown_fields)]
271pub struct ToolSemantics {
272 pub observe: Vec<String>,
274 pub mutate: Vec<String>,
276 pub plan: Vec<String>,
278 pub new: Vec<String>,
280}
281
282impl ToolSemantics {
283 pub fn validate(&self) -> Result<()> {
285 let mut seen: Vec<(String, &'static str)> = Vec::new();
286 for (category, names) in [
287 ("observe", &self.observe),
288 ("mutate", &self.mutate),
289 ("plan", &self.plan),
290 ("new", &self.new),
291 ] {
292 for name in names {
293 if name.trim().is_empty() {
294 return Err(tool_semantics_error(format!(
295 "tool_semantics.{category} contains an empty tool name"
296 )));
297 }
298 let normalized = name.to_ascii_lowercase();
299 if is_builtin_tool_name(&name.to_lowercase()) {
300 return Err(tool_semantics_error(format!(
301 "tool {name:?} already has built-in semantics and cannot be reclassified"
302 )));
303 }
304 if let Some((_, previous)) = seen.iter().find(|(seen, _)| seen == &normalized) {
305 return Err(tool_semantics_error(format!(
306 "tool {name:?} appears in both tool_semantics.{previous} and tool_semantics.{category}"
307 )));
308 }
309 seen.push((normalized, category));
310 }
311 }
312 Ok(())
313 }
314
315 fn classify(&self, name: &str) -> Option<ToolSemantic> {
316 if contains_name(&self.observe, name) {
317 Some(ToolSemantic::Observe)
318 } else if contains_name(&self.mutate, name) {
319 Some(ToolSemantic::Mutate(MutationKind::Write))
322 } else if contains_name(&self.plan, name) {
323 Some(ToolSemantic::Plan)
324 } else if contains_name(&self.new, name) {
325 Some(ToolSemantic::New)
326 } else {
327 None
328 }
329 }
330}
331
332fn contains_name(names: &[String], candidate: &str) -> bool {
333 names
334 .iter()
335 .any(|name| name.eq_ignore_ascii_case(candidate))
336}
337
338fn tool_semantics_error(message: String) -> LibsyError {
339 LibsyError::AlgorithmError { message }
340}
341
342#[derive(Clone, Debug, Default)]
351pub struct ToolSignals {
352 pub severity: f32,
357 pub repeated_failure: bool,
360 pub no_error_streak: u32,
362 pub edit_count: u32,
364 pub write_count: u32,
366 pub read_count: u32,
368 pub todowrite_count: u32,
371 pub recent_edit_count: u32,
373 pub recent_write_count: u32,
375 pub recent_read_count: u32,
377 pub recent_todowrite_count: u32,
379 pub new_count: u32,
381 pub recent_new_count: u32,
383 pub pure_bash_streak: u32,
386 pub tests_passed: bool,
388 pub tool_result_count: u32,
391 pub assistant_turn_count: u32,
394 pub turn_depth: u32,
398 pub compacted: bool,
404}
405
406impl ToolSignals {
407 pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
412 Self::from_request_with_semantics(request, window_size, &ToolSemantics::default())
413 }
414
415 pub fn from_request_with_semantics(
417 request: &Request,
418 window_size: Option<usize>,
419 semantics: &ToolSemantics,
420 ) -> Self {
421 extract_tool_signals_with_window_and_semantics(
422 request,
423 window_size.unwrap_or(DEFAULT_RECENT_WINDOW),
424 semantics,
425 false,
426 )
427 }
428}
429
430#[derive(Debug, Clone)]
434struct ObservedToolCall<'a> {
435 name: String,
436 bare_name: Option<&'a str>,
437 command: Option<String>,
438 is_retrieval: bool,
439 code_semantic: Option<ToolSemantic>,
440}
441
442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
443enum MutationKind {
444 Write,
445 Edit,
446}
447
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450enum ToolSemantic {
451 Mutate(MutationKind),
452 Observe,
453 Plan,
454 New,
455 Unknown,
456}
457
458#[derive(Debug, Clone)]
461pub struct ToolSignalProcessor {
462 pub recent_window: usize,
465 pub tool_semantics: ToolSemantics,
467}
468
469impl Default for ToolSignalProcessor {
470 fn default() -> Self {
471 Self {
472 recent_window: DEFAULT_RECENT_WINDOW,
473 tool_semantics: ToolSemantics::default(),
474 }
475 }
476}
477
478impl ToolSignalProcessor {
479 pub(crate) fn for_user_turn(&self, request: &Request) -> ToolSignals {
480 extract_tool_signals_with_window_and_semantics(
481 request,
482 self.recent_window,
483 &self.tool_semantics,
484 true,
485 )
486 }
487}
488
489#[async_trait]
490impl Processor for ToolSignalProcessor {
491 async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
492 if let Event::Request { request: req, .. } = event {
493 let tool_signal = ToolSignals::from_request_with_semantics(
494 req,
495 Some(self.recent_window),
496 &self.tool_semantics,
497 );
498 state.tool_signals = Some(tool_signal);
499 }
500 Ok(())
501 }
502}
503
504fn classify_tool_call(name: &str, command: Option<&str>) -> ToolSemantic {
505 classify_tool_call_with_semantics(name, command, &ToolSemantics::default())
506}
507
508fn code_mode_has_patch(mut input: &str) -> bool {
511 static STATEMENT: LazyLock<Regex> = LazyLock::new(|| {
512 let identifier = r"[a-zA-Z_$][a-zA-Z0-9_$]*";
513 let literal = r#"(?:"(?:[^"\\\r\n]|\\[^\r\n])*"|'(?:[^'\\\r\n]|\\[^\r\n])*'|`(?:[^`\\$]|\\[^\r\n])*`)"#;
514 let argument = format!(r"(?:{literal}|{identifier})");
515 let patch = format!(r"await\s+tools\s*\.\s*apply_patch\s*\(\s*{argument}\s*\)");
516 Regex::new(&format!(
518 r"(?x)\A(?://[^\r\n]*|(?s:/\*.*?\*/)|(?:
519 (?:const|let)\s+(?P<binding>{identifier})\s*=\s*{literal}|
520 (?P<patch>{patch}|text\s*\(\s*{patch}\s*\))|
521 text\s*\(\s*{argument}\s*\)
522 )[\t\x20]*(?:;|\r?\n|\z))"
523 ))
524 .expect("constant code-mode statement pattern")
525 });
526 let mut has_patch = false;
527 while !input.trim_start().is_empty() {
528 input = input.trim_start();
529 let Some(statement) = STATEMENT.captures(input) else {
530 return false;
531 };
532 if statement
533 .name("binding")
534 .is_some_and(|binding| matches!(binding.as_str(), "tools" | "text"))
535 {
536 return false;
537 }
538 has_patch |= statement.name("patch").is_some();
539 input = &input[statement[0].len()..];
540 }
541 has_patch
542}
543
544fn classify_tool_call_with_semantics(
545 name: &str,
546 command: Option<&str>,
547 semantics: &ToolSemantics,
548) -> ToolSemantic {
549 let lower = name.to_lowercase();
551 if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
552 return ToolSemantic::Mutate(MutationKind::Write);
553 }
554 if EDITOR_TOOL_NAMES.contains(&lower.as_str()) && command == Some("view") {
555 return ToolSemantic::Observe;
556 }
557 if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
558 return ToolSemantic::Mutate(MutationKind::Edit);
559 }
560 if READ_TOOL_NAMES.contains(&lower.as_str()) {
561 return ToolSemantic::Observe;
562 }
563 if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
564 return ToolSemantic::Plan;
565 }
566 if BASH_TOOL_NAMES.contains(&lower.as_str())
567 && let Some(cmd) = command
568 {
569 if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_write(cmd) {
571 return ToolSemantic::Mutate(MutationKind::Write);
572 }
573 if cmd.contains("python") && PYTHON_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
574 return ToolSemantic::Mutate(MutationKind::Write);
575 }
576 if shell_invokes_program(cmd, "node")
577 && JAVASCRIPT_WRITE_PATTERNS
578 .iter()
579 .any(|pattern| cmd.contains(pattern))
580 {
581 return ToolSemantic::Mutate(MutationKind::Write);
582 }
583 if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_edit(cmd) {
584 return ToolSemantic::Mutate(MutationKind::Edit);
585 }
586 if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_read(cmd) {
587 return ToolSemantic::Observe;
588 }
589 }
590 semantics.classify(name).unwrap_or(ToolSemantic::Unknown)
591}
592
593fn is_retrieval_tool(name: &str, command: Option<&Value>) -> bool {
595 let lower = name.to_lowercase();
596 READ_TOOL_NAMES.contains(&lower.as_str())
597 || (EDITOR_TOOL_NAMES.contains(&lower.as_str())
598 && command
599 .and_then(Value::as_str)
600 .is_some_and(|command| command.eq_ignore_ascii_case("view")))
601 || (lower != "powershell"
603 && BASH_TOOL_NAMES.contains(&lower.as_str())
604 && command.is_some_and(command_is_retrieval))
605}
606
607fn command_is_retrieval(command: &Value) -> bool {
609 match command {
610 Value::String(line) => shell_is_retrieval(line, 0),
611 Value::Array(argv) => {
612 argv.iter()
615 .try_fold(0, |size, word| {
616 let size = size + word.as_str()?.len() + 1;
617 (size <= MAX_RETRIEVAL_COMMAND_BYTES).then_some(size)
618 })
619 .is_some()
620 && retrieval_command(
621 &argv.iter().filter_map(Value::as_str).collect::<Vec<_>>(),
622 0,
623 )
624 }
625 _ => false,
626 }
627}
628
629const MAX_RETRIEVAL_COMMAND_BYTES: usize = 16 * 1024;
632
633fn shell_is_retrieval(command: &str, depth: usize) -> bool {
637 if command.len() > MAX_RETRIEVAL_COMMAND_BYTES {
638 return false;
639 }
640 if quoted_chars(command).any(|(_, c, quote)| {
641 (quote != Some('\'') && matches!(c, '$' | '`'))
642 || (quote.is_none() && matches!(c, '<' | '>' | '(' | ')' | '{' | '}' | '#'))
643 }) {
644 return false;
645 }
646 let mut segments = shell_segments(command).peekable();
647 segments.peek().is_some()
648 && segments.all(|segment| {
649 shlex::split(segment).is_some_and(|words| {
651 retrieval_command(&words.iter().map(String::as_str).collect::<Vec<_>>(), depth)
652 })
653 })
654}
655
656fn skip_shell_options<'a>(mut args: &'a [&'a str], takes_value: &[&str]) -> &'a [&'a str] {
657 while let Some((option, rest)) = args.split_first() {
658 if !option.starts_with('-') || *option == "-" {
659 break;
660 }
661 args = rest;
662 if *option == "--" {
663 break;
664 }
665 if takes_value.contains(option) {
666 args = args.get(1..).unwrap_or_default();
667 }
668 }
669 args
670}
671
672fn has_short_flag(word: &str, flags: &[char]) -> bool {
674 word.starts_with('-') && !word.starts_with("--") && word.contains(flags)
675}
676
677const MAX_COMMAND_DEPTH: usize = 20;
679
680fn retrieval_command(mut words: &[&str], depth: usize) -> bool {
681 if depth > MAX_COMMAND_DEPTH {
682 return false;
683 }
684 while words.first().is_some_and(|word| {
685 word.split_once('=').is_some_and(|(name, _)| {
686 !name.is_empty()
687 && name.bytes().enumerate().all(|(i, c)| {
688 c == b'_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())
689 })
690 })
691 }) {
692 words = &words[1..];
693 }
694 let Some((&program, args)) = words.split_first() else {
695 return false;
696 };
697 let program = [
700 "/bin/",
701 "/sbin/",
702 "/usr/bin/",
703 "/usr/sbin/",
704 "/usr/local/bin/",
705 "/opt/homebrew/bin/",
706 ]
707 .iter()
708 .find_map(|dir| program.strip_prefix(dir))
709 .unwrap_or(program);
710 let wrapper_options: Option<&[&str]> = match program {
712 "sudo" => Some(&[
713 "-u", "-g", "-h", "-p", "-C", "-T", "--user", "--group", "--host",
714 ]),
715 "env" => Some(&["-u", "--unset", "-C", "--chdir"]),
716 "command" | "nohup" => Some(&[]),
717 "exec" => Some(&["-a"]),
718 "time" => Some(&["-f", "--format", "-o", "--output"]),
719 "timeout" => Some(&["-s", "--signal", "-k", "--kill-after"]),
720 "xargs" => Some(&[
721 "-n",
722 "--max-args",
723 "-P",
724 "--max-procs",
725 "-I",
726 "--replace",
727 "-d",
728 "--delimiter",
729 "-L",
730 "--max-lines",
731 "-s",
732 "--max-chars",
733 ]),
734 _ => None,
735 };
736 if let Some(options) = wrapper_options {
737 if program == "env"
739 && args
740 .iter()
741 .any(|arg| arg.starts_with("--split-string") || has_short_flag(arg, &['S']))
742 {
743 return false;
744 }
745 let mut rest = skip_shell_options(args, options);
746 if program == "timeout" {
747 rest = rest.get(1..).unwrap_or_default();
748 }
749 return (program == "env" && rest.is_empty()) || retrieval_command(rest, depth + 1);
750 }
751 if matches!(program, "bash" | "sh" | "dash" | "zsh" | "ksh") {
752 return args
753 .first()
754 .is_some_and(|option| has_short_flag(option, &['c']))
755 && args
756 .get(1)
757 .is_some_and(|command| shell_is_retrieval(command, depth + 1));
758 }
759 if program == "git" {
760 return git_is_retrieval(args);
761 }
762 if matches!(args, ["--help" | "--version"]) {
763 return true;
764 }
765 match program {
766 "find" => !args.iter().any(|arg| {
767 matches!(
768 *arg,
769 "-delete"
770 | "-exec"
771 | "-execdir"
772 | "-ok"
773 | "-okdir"
774 | "-fprint"
775 | "-fprint0"
776 | "-fprintf"
777 )
778 }),
779 "fd" => !args
781 .iter()
782 .any(|arg| arg.starts_with("--exec") || has_short_flag(arg, &['x', 'X'])),
783 "sed" => {
785 args.len() >= 2
786 && args[0] == "-n"
787 && args[2..].iter().all(|arg| !arg.starts_with('-'))
788 && args[1].strip_suffix('p').is_some_and(|range| {
789 !range.is_empty()
790 && range
791 .bytes()
792 .all(|c| c.is_ascii_digit() || matches!(c, b',' | b'$'))
793 })
794 }
795 "sort" => !args.iter().any(|arg| {
796 arg.starts_with("--compress-program")
797 || arg.starts_with("--output")
798 || arg.starts_with("-o")
799 }),
800 "rg" => !args.iter().any(|arg| arg.starts_with("--pre")),
801 "xxd" => !args.contains(&"-r") && !args.contains(&"-revert"),
802 "go" => {
803 matches!(args.first(), Some(&"list" | &"doc" | &"env" | &"version"))
804 && !args.iter().any(|arg| matches!(*arg, "-w" | "-u"))
805 }
806 "docker" | "podman" => matches!(args.first(), Some(&"ps" | &"version")),
807 "cat" | "grep" | "ls" | "nl" | "head" | "tail" | "wc" | "pwd" | "stat" | "file" | "du"
808 | "df" | "which" | "type" | "diff" | "cmp" | "jq" | "uniq" | "cut" | "readlink"
809 | "realpath" | "tree" | "basename" | "dirname" | "printenv" | "echo" | "printf"
810 | "less" | "more" | "test" | "[" | "ps" | "pgrep" | "pkg-config" | "strings" | "uname"
811 | "od" | "true" | ":" | "cd" | "pstree" | "sha256sum" | "sha1sum" | "md5sum" | "lsof"
812 | "tr" | "free" | "id" | "namei" | "whoami" | "paste" | "ss" | "getent" => true,
813 _ => false,
814 }
815}
816
817fn git_is_retrieval(args: &[&str]) -> bool {
818 let args = skip_shell_options(
819 args,
820 &["-C", "-c", "--git-dir", "--work-tree", "--namespace"],
821 );
822 let Some((subcommand, rest)) = args.split_first() else {
823 return false;
824 };
825 match *subcommand {
826 "diff" => !rest.contains(&"--check"),
827 "branch" => {
828 rest.is_empty()
829 || rest.iter().all(|arg| {
830 matches!(
831 *arg,
832 "-a" | "-r"
833 | "--all"
834 | "--list"
835 | "--show-current"
836 | "-v"
837 | "-vv"
838 | "--verbose"
839 )
840 })
841 }
842 "remote" => {
843 rest.is_empty()
844 || matches!(rest, ["-v" | "--verbose"])
845 || rest.first() == Some(&"get-url")
846 }
847 "tag" => rest.is_empty() || matches!(rest[0], "-l" | "--list"),
848 "config" => matches!(
849 rest.first(),
850 Some(&"--get" | &"--get-all" | &"--list" | &"-l")
851 ),
852 "worktree" => rest.first() == Some(&"list"),
853 "submodule" => rest.first() == Some(&"status"),
854 "status" | "log" | "show" | "blame" | "ls-files" | "ls-remote" | "rev-parse"
855 | "merge-base" | "grep" | "describe" | "show-ref" | "check-ignore" | "rev-list"
856 | "ls-tree" | "diff-tree" | "version" => true,
857 _ => false,
858 }
859}
860
861fn is_builtin_tool_name(lower: &str) -> bool {
862 WRITE_TOOL_NAMES.contains(&lower)
863 || EDIT_TOOL_NAMES.contains(&lower)
864 || READ_TOOL_NAMES.contains(&lower)
865 || PLAN_TOOL_NAMES.contains(&lower)
866 || BASH_TOOL_NAMES.contains(&lower)
867}
868
869fn shell_segments(command: &str) -> impl Iterator<Item = &str> {
873 let mut start = 0;
874 quoted_chars(command)
875 .filter(|&(_, c, quote)| quote.is_none() && matches!(c, '\n' | ';' | '|' | '&'))
876 .map(|(index, _, _)| index)
877 .chain([command.len()])
878 .filter_map(move |end| {
879 let segment = command[start..end].trim();
880 start = end + 1;
882 (!segment.is_empty()).then_some(segment)
883 })
884}
885
886fn quoted_chars(command: &str) -> impl Iterator<Item = (usize, char, Option<char>)> {
888 let mut quote = None;
889 let mut escaped = false;
890 command.char_indices().filter_map(move |(index, c)| {
891 if escaped {
892 escaped = false;
893 return None;
894 }
895 if c == '\\' && quote != Some('\'') {
896 escaped = true;
897 return None;
898 }
899 let around = quote;
900 if quote == Some(c) {
901 quote = None;
902 } else if quote.is_none() && matches!(c, '\'' | '"') {
903 quote = Some(c);
904 }
905 Some((index, c, around))
906 })
907}
908
909fn shell_words(segment: &str) -> std::iter::Peekable<std::str::SplitAsciiWhitespace<'_>> {
910 let mut words = segment.split_ascii_whitespace().peekable();
911
912 if words.peek().copied() == Some("env") {
913 words.next();
914 while words.peek().is_some_and(|word| word.starts_with('-')) {
915 words.next();
916 }
917 }
918 while words
919 .peek()
920 .is_some_and(|word| word.contains('=') && !word.starts_with('='))
921 {
922 words.next();
923 }
924
925 words
926}
927
928fn program_name(word: &str) -> &str {
929 Path::new(word)
930 .file_name()
931 .and_then(|name| name.to_str())
932 .unwrap_or(word)
933}
934
935fn shell_invokes_program(command: &str, expected: &str) -> bool {
936 shell_segments(command).any(|segment| {
937 shell_words(segment)
938 .next()
939 .is_some_and(|word| program_name(word) == expected)
940 })
941}
942
943fn shell_command_is_write(command: &str) -> bool {
944 shell_segments(command).any(|segment| {
945 let mut words = shell_words(segment);
946 let Some(program) = words.next().map(program_name) else {
947 return false;
948 };
949 if matches!(program, "cp" | "mkdir" | "touch" | "install") {
950 return true;
951 }
952
953 let redirects_output = words.any(|word| matches!(word, ">" | ">>"));
954 redirects_output
955 && (matches!(program, "echo" | "printf" | "git")
956 || BASH_READ_COMMANDS.contains(&program))
957 })
958}
959
960fn shell_command_is_edit(command: &str) -> bool {
961 shell_segments(command).any(|segment| {
962 let mut words = shell_words(segment);
963 let Some(program) = words.next().map(program_name) else {
964 return false;
965 };
966 let has_arg = |arg: &str| words.clone().any(|word| word == arg);
967
968 match program {
969 "mv" | "rm" => true,
970 "perl" => words
971 .take_while(|word| word.starts_with('-'))
972 .any(|option| {
973 option
974 .trim_start_matches('-')
975 .chars()
976 .any(|flag| flag == 'i')
977 }),
978 "git" => words
979 .next()
980 .is_some_and(|subcommand| matches!(subcommand, "apply" | "am" | "restore")),
981 "gofmt" => has_arg("-w"),
982 "cargo" => words.clone().next() == Some("fmt") && !has_arg("--check"),
983 "ruff" => {
984 let subcommand = words.clone().next();
985 (subcommand == Some("format") && !has_arg("--check"))
986 || (subcommand == Some("check") && has_arg("--fix"))
987 }
988 "prettier" => has_arg("--write"),
989 "black" => !has_arg("--check"),
990 _ => {
991 (words.clone().any(|word| program_name(word) == "prettier") && has_arg("--write"))
992 || (words.clone().any(|word| program_name(word) == "ruff")
993 && ((has_arg("format") && !has_arg("--check"))
994 || (has_arg("check") && has_arg("--fix"))))
995 }
996 }
997 })
998}
999
1000fn shell_command_is_read(command: &str) -> bool {
1001 shell_segments(command).any(|segment| {
1002 if segment == "env" {
1003 return true;
1004 }
1005 let mut words = shell_words(segment);
1006 let Some(program) = words.next().map(program_name) else {
1007 return false;
1008 };
1009
1010 if BASH_READ_COMMANDS.contains(&program) {
1011 return true;
1012 }
1013 if program == "command" && words.next() == Some("-v") {
1014 return true;
1015 }
1016 if program == "type" {
1017 return true;
1018 }
1019 if program != "git" {
1020 return false;
1021 }
1022
1023 match words.next() {
1024 Some("branch") => words.next().is_none_or(|arg| arg.starts_with('-')),
1025 Some("remote") => words
1026 .next()
1027 .is_none_or(|arg| arg.starts_with('-') || arg == "get-url"),
1028 Some("config") => words
1029 .next()
1030 .is_some_and(|arg| matches!(arg, "--get" | "--get-all" | "--list" | "-l")),
1031 Some(subcommand) => GIT_READ_SUBCOMMANDS.contains(&subcommand),
1032 None => false,
1033 }
1034 })
1035}
1036
1037fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
1044 extract_tool_signals_with_window_and_semantics(
1045 request,
1046 recent_window,
1047 &ToolSemantics::default(),
1048 false,
1049 )
1050}
1051
1052fn extract_tool_signals_with_window_and_semantics(
1053 request: &Request,
1054 recent_window: usize,
1055 semantics: &ToolSemantics,
1056 current_user_turn: bool,
1057) -> ToolSignals {
1058 let messages = &request.llm_request.messages;
1060 let boundary = if current_user_turn {
1061 messages.iter().rposition(is_user_turn)
1062 } else {
1063 None
1064 };
1065 let start = boundary.map_or(0, |index| index + 1);
1066 let namespaces = tool_namespaces(&request.llm_request.extensions);
1067 let mut tool_texts: Vec<(String, bool)> = Vec::new();
1068 let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
1069 let mut retrieval_calls: HashSet<&str> = HashSet::new();
1071 let mut tool_result_count = 0usize;
1072 let mut assistant_turn_count = 0usize;
1073
1074 for (index, message) in messages.iter().enumerate() {
1075 if index >= start && message.role == Role::Assistant {
1076 assistant_turn_count += 1;
1077 }
1078 for block in &message.content {
1079 match block {
1080 _ if index < start => continue,
1081 ContentBlock::ToolCall(call) => {
1082 let bare_name = namespaces
1084 .and_then(|namespaces| split_qualified_name(namespaces, &call.name))
1085 .map(|(tool, _)| tool)
1086 .or_else(|| mcp_tool_name(&call.name));
1087 let decoded = call
1089 .arguments
1090 .as_str()
1091 .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
1092 let arguments = decoded.as_ref().unwrap_or(&call.arguments);
1093 let command_field = command_of(arguments);
1094 let code_semantic = (bare_name.unwrap_or(&call.name) == "exec"
1097 && arguments.get("command").is_none()
1098 && arguments.get("cmd").is_none())
1099 .then(|| arguments.get("input").and_then(Value::as_str))
1100 .flatten()
1101 .map(|input| {
1102 if code_mode_has_patch(input) {
1103 ToolSemantic::Mutate(MutationKind::Edit)
1104 } else {
1105 ToolSemantic::Unknown
1106 }
1107 });
1108 let command = command_field.and_then(command_text);
1109 let full = classify_tool_call_with_semantics(
1113 &call.name,
1114 command.as_deref(),
1115 semantics,
1116 );
1117 let name = match (full, bare_name) {
1118 (ToolSemantic::Unknown | ToolSemantic::Observe, Some(bare_name)) => {
1119 bare_name
1120 }
1121 _ => call.name.as_str(),
1122 };
1123 let is_retrieval =
1124 code_semantic.is_none() && is_retrieval_tool(name, command_field);
1125 if !call.id.is_empty() {
1126 if is_retrieval {
1128 retrieval_calls.insert(call.id.as_str());
1129 } else {
1130 retrieval_calls.remove(call.id.as_str());
1131 }
1132 }
1133 tool_calls.push(ObservedToolCall {
1134 name: call.name.clone(),
1135 bare_name,
1136 command,
1137 is_retrieval,
1138 code_semantic,
1139 });
1140 }
1141 ContentBlock::ToolResult(result) => {
1142 tool_result_count += 1;
1144 let is_error = result.is_error == Some(true);
1145 let text = result
1146 .content
1147 .iter()
1148 .filter_map(text_of)
1149 .collect::<Vec<_>>()
1150 .join("\n");
1151 let is_retrieval_result =
1152 retrieval_calls.contains(result.tool_call_id.as_str());
1153 if is_retrieval_result {
1154 let shell_result = serde_json::Deserializer::from_str(&text)
1157 .into_iter::<Value>()
1158 .next()
1159 .and_then(|result| result.ok())
1160 .filter(|value| {
1161 value["output"].is_string() && value.get("exit_code").is_some()
1162 });
1163 let status = text
1166 .split_once("\nOutput:\n")
1167 .map_or(text.as_str(), |(header, _)| header);
1168 let shell_failed = match &shell_result {
1170 Some(value) => {
1171 value["exit_code"].as_i64().is_some_and(|code| code != 0)
1172 || value["success"].as_bool() == Some(false)
1173 || value["error"]
1174 .as_str()
1175 .is_some_and(|error| !error.trim().is_empty())
1176 }
1177 None => has_nonzero_exit_status(&status.to_lowercase()),
1178 };
1179 if is_missing_file(&text) || (!is_error && !shell_failed) {
1180 if !text.is_empty() {
1182 tool_texts.push((String::new(), false));
1183 }
1184 continue;
1185 }
1186 }
1187 if !text.is_empty() || is_error {
1189 tool_texts.push((text, is_error));
1190 }
1191 }
1192 ContentBlock::Unknown { provider, raw }
1194 if provider.as_str() == WireFormat::OpenAiResponses.as_str()
1195 && raw.get("type").and_then(Value::as_str)
1196 == Some("apply_patch_call_output") =>
1197 {
1198 tool_result_count += 1;
1199 let text = raw
1200 .get("output")
1201 .and_then(Value::as_str)
1202 .unwrap_or_default();
1203 let is_error = raw.get("status").and_then(Value::as_str) == Some("failed");
1204 tool_texts.push((text.to_owned(), is_error));
1205 }
1206 ContentBlock::Unknown { provider, raw }
1207 if provider.as_str() == WireFormat::OpenAiResponses.as_str()
1208 && raw.get("type").and_then(Value::as_str) == Some("shell_call_output") =>
1209 {
1210 tool_result_count += 1;
1211 let mut texts = Vec::new();
1212 let mut is_error = false;
1213 if let Some(outputs) = raw.get("output").and_then(Value::as_array) {
1214 for output in outputs {
1215 for field in ["stdout", "stderr"] {
1216 if let Some(text) = output.get(field).and_then(Value::as_str)
1217 && !text.is_empty()
1218 {
1219 texts.push(text);
1220 }
1221 }
1222 if let Some(outcome) = output.get("outcome") {
1223 is_error |= match outcome.get("type").and_then(Value::as_str) {
1224 Some("timeout") => true,
1225 Some("exit")
1226 if outcome
1227 .get("exit_code")
1228 .and_then(Value::as_i64)
1229 .is_some_and(|code| code != 0) =>
1230 {
1231 texts.push("returned non-zero");
1233 output
1234 .get("stderr")
1235 .and_then(Value::as_str)
1236 .is_some_and(|text| !text.trim().is_empty())
1237 }
1238 _ => false,
1239 };
1240 }
1241 }
1242 }
1243 let text = texts.join("\n");
1244 tool_texts.push((text, is_error));
1245 }
1246 _ => {}
1247 }
1248 }
1249 }
1250
1251 let mut signal = build_signal(
1252 tool_texts,
1253 tool_calls,
1254 (messages.len() - boundary.unwrap_or(0)) as u32,
1255 recent_window,
1256 semantics,
1257 );
1258 signal.compacted = request.llm_request.has_compaction;
1259 signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
1260 signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
1261 signal
1262}
1263
1264fn mcp_tool_name(name: &str) -> Option<&str> {
1268 let (_server, tool) = name.strip_prefix("mcp__")?.split_once("__")?;
1269 (!tool.is_empty()).then_some(tool)
1270}
1271
1272fn command_of(arguments: &Value) -> Option<&Value> {
1276 ["command", "cmd", "input"]
1277 .iter()
1278 .find_map(|key| arguments.get(*key))
1279}
1280
1281fn command_text(value: &Value) -> Option<String> {
1283 match value {
1284 Value::String(text) => Some(text.to_lowercase()),
1285 Value::Array(parts) => {
1286 let joined = parts
1287 .iter()
1288 .filter_map(Value::as_str)
1289 .collect::<Vec<_>>()
1290 .join(" ");
1291 (!joined.is_empty()).then(|| joined.to_lowercase())
1292 }
1293 _ => None,
1294 }
1295}
1296
1297fn text_of(block: &ContentBlock) -> Option<&str> {
1299 match block {
1300 ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
1301 _ => None,
1302 }
1303}
1304
1305fn is_missing_file(text: &str) -> bool {
1308 let lower = text.to_lowercase();
1309 lower.contains("no such file or directory") || lower.contains("file does not exist")
1311}
1312
1313fn build_signal(
1314 tool_texts: Vec<(String, bool)>,
1315 tool_calls: Vec<ObservedToolCall>,
1316 turn_depth: u32,
1317 recent_window: usize,
1318 semantics: &ToolSemantics,
1319) -> ToolSignals {
1320 let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
1326 let mut severity = 0.0f32;
1327 let mut failure_fingerprints = Vec::new();
1328 let mut repeated_failure = false;
1329 for (text, is_error) in &tool_texts[sev_start..] {
1330 let (sev, _patterns) = classify_text(text);
1331 let sev = if *is_error { sev.max(HARD) } else { sev };
1333 if sev > severity {
1334 severity = sev;
1335 }
1336 if let Some(fingerprint) = failure_fingerprint(text, *is_error) {
1337 repeated_failure |= failure_fingerprints.contains(&fingerprint);
1338 failure_fingerprints.push(fingerprint);
1339 }
1340 }
1341
1342 let no_error_streak = compute_no_error_streak(&tool_texts);
1343
1344 let recent_start = tool_calls.len().saturating_sub(recent_window);
1348 let mut write_count = 0u32;
1349 let mut edit_count = 0u32;
1350 let mut read_count = 0u32;
1351 let mut todowrite_count = 0u32;
1352 let mut recent_write_count = 0u32;
1353 let mut recent_edit_count = 0u32;
1354 let mut recent_read_count = 0u32;
1355 let mut recent_todowrite_count = 0u32;
1356 let mut new_count = 0u32;
1357 let mut recent_new_count = 0u32;
1358 let mut pure_bash_streak = 0u32;
1359 let mut streak_open = true;
1360 for (i, tc) in tool_calls.iter().enumerate().rev() {
1361 let mut cat = if let Some(semantic) = tc.code_semantic {
1365 semantic
1366 } else if tc.is_retrieval {
1367 ToolSemantic::Observe
1368 } else {
1369 classify_tool_call_with_semantics(&tc.name, tc.command.as_deref(), semantics)
1370 };
1371 if matches!(cat, ToolSemantic::Unknown)
1372 && tc.code_semantic.is_none()
1373 && let Some(bare_name) = tc.bare_name
1374 {
1375 cat = classify_tool_call_with_semantics(bare_name, tc.command.as_deref(), semantics);
1376 }
1377 if streak_open {
1378 if matches!(cat, ToolSemantic::Unknown) {
1379 pure_bash_streak += 1;
1380 } else {
1381 streak_open = false;
1382 }
1383 }
1384 match cat {
1385 ToolSemantic::Mutate(MutationKind::Write) => {
1386 write_count += 1;
1387 if i >= recent_start {
1388 recent_write_count += 1;
1389 }
1390 }
1391 ToolSemantic::Mutate(MutationKind::Edit) => {
1392 edit_count += 1;
1393 if i >= recent_start {
1394 recent_edit_count += 1;
1395 }
1396 }
1397 ToolSemantic::Observe => {
1398 read_count += 1;
1399 if i >= recent_start {
1400 recent_read_count += 1;
1401 }
1402 }
1403 ToolSemantic::Plan => {
1404 todowrite_count += 1;
1405 if i >= recent_start {
1406 recent_todowrite_count += 1;
1407 }
1408 }
1409 ToolSemantic::New => {
1410 new_count += 1;
1411 if i >= recent_start {
1412 recent_new_count += 1;
1413 }
1414 }
1415 ToolSemantic::Unknown => {}
1416 }
1417 }
1418
1419 let tests_passed = detect_tests_passed(&tool_texts, recent_window);
1420
1421 ToolSignals {
1422 severity,
1423 repeated_failure,
1424 no_error_streak,
1425 edit_count,
1426 write_count,
1427 read_count,
1428 todowrite_count,
1429 recent_edit_count,
1430 recent_write_count,
1431 recent_read_count,
1432 recent_todowrite_count,
1433 new_count,
1434 recent_new_count,
1435 pure_bash_streak,
1436 tests_passed,
1437 turn_depth,
1438 tool_result_count: 0,
1442 assistant_turn_count: 0,
1443 compacted: false,
1444 }
1445}
1446
1447fn content_to_text(content: Option<&Value>) -> Option<String> {
1451 match content? {
1452 Value::String(s) => Some(s.clone()),
1453 Value::Array(blocks) => {
1454 let parts: Vec<&str> = blocks
1455 .iter()
1456 .filter_map(|b| {
1457 b.as_object()
1458 .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
1459 .and_then(|o| o.get("text"))
1460 .and_then(Value::as_str)
1461 })
1462 .collect();
1463 if parts.is_empty() {
1464 None
1465 } else {
1466 Some(parts.join("\n"))
1467 }
1468 }
1469 _ => None,
1470 }
1471}
1472
1473pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
1477 let lower = text.to_lowercase();
1478 let mut patterns = Vec::new();
1479 let mut severity: f32 = 0.0;
1480 for (name, sev, substrings) in ERROR_PATTERNS {
1481 if substrings.iter().any(|sub| lower.contains(sub)) {
1482 patterns.push(name.to_string());
1483 severity = severity.max(*sev);
1484 }
1485 }
1486 let result = serde_json::Deserializer::from_str(text)
1488 .into_iter::<Value>()
1489 .next()
1490 .and_then(|result| result.ok())
1491 .unwrap_or_default();
1492 let nonzero_exit = result["exit_code"].as_i64().is_some_and(|code| code != 0);
1493 let tool_error = result["success"].as_bool() == Some(false)
1494 || result["error"]
1495 .as_str()
1496 .is_some_and(|error| !error.trim().is_empty());
1497 if (nonzero_exit || has_nonzero_exit_status(&lower))
1498 && !patterns.iter().any(|p| p == "exit_nonzero")
1499 {
1500 patterns.push("exit_nonzero".to_string());
1501 severity = severity.max(SOFT);
1502 }
1503 for (name, matched) in [
1504 ("tool_error", tool_error),
1505 ("compile_error", has_compiler_diagnostic(&lower)),
1506 ("runtime_exception", has_runtime_exception(&lower)),
1507 ("runtime_panic", has_runtime_panic(&lower)),
1508 ("patch_error", has_patch_failure(&lower)),
1509 ("cmd_not_found", ends_with_not_found(&lower)),
1510 ] {
1511 if matched && !patterns.iter().any(|pattern| pattern == name) {
1512 patterns.push(name.to_string());
1513 severity = severity.max(HARD);
1514 }
1515 }
1516 (severity, patterns)
1517}
1518
1519fn failure_fingerprint(text: &str, is_error: bool) -> Option<String> {
1522 let (severity, patterns) = classify_text(text);
1523 if severity < HARD && !is_error {
1524 return None;
1525 }
1526
1527 let lower = text.to_lowercase();
1528 let diagnostic = lower
1529 .lines()
1530 .find(|line| is_failure_diagnostic(line))
1531 .or_else(|| lower.lines().find(|line| !line.trim().is_empty()))
1532 .unwrap_or_default();
1533 let normalized = normalize_failure_text(diagnostic);
1534 Some(format!("{}|{normalized}", patterns.join(",")))
1535}
1536
1537fn is_failure_diagnostic(line: &str) -> bool {
1538 let line = line.trim();
1539 [
1540 "error",
1541 "exception",
1542 "panic",
1543 "failed",
1544 "timed out",
1545 "timeout",
1546 "connection refused",
1547 "cannot allocate memory",
1548 "out of memory",
1549 "not found",
1550 ]
1551 .iter()
1552 .any(|marker| line.contains(marker))
1553}
1554
1555fn normalize_failure_text(text: &str) -> String {
1558 let mut normalized = String::new();
1559 for word in text.split_whitespace() {
1560 if !normalized.is_empty() {
1561 normalized.push(' ');
1562 }
1563 let mut in_digits = false;
1564 if word.starts_with('/') || word.contains("/src/") || word.contains("/tmp/") {
1565 normalized.push_str("<path>");
1566 continue;
1567 }
1568 for character in word.chars() {
1569 if character.is_ascii_digit() {
1570 if !in_digits {
1571 normalized.push('#');
1572 in_digits = true;
1573 }
1574 } else {
1575 normalized.push(character);
1576 in_digits = false;
1577 }
1578 }
1579 }
1580 normalized.chars().take(240).collect()
1581}
1582
1583fn has_compiler_diagnostic(lower: &str) -> bool {
1584 lower.lines().any(|line| {
1585 let line = line.trim_start();
1586 if matches!(
1587 line,
1588 "compilation failed" | "error: compilation failed" | "error: could not compile"
1589 ) || line.starts_with("error: could not compile ")
1590 {
1591 return true;
1592 }
1593
1594 let Some(rest) = line.strip_prefix("error[e") else {
1595 return false;
1596 };
1597 let Some((code, _)) = rest.split_once("]:") else {
1598 return false;
1599 };
1600 !code.is_empty() && code.chars().all(|character| character.is_ascii_digit())
1601 })
1602}
1603
1604fn has_runtime_exception(lower: &str) -> bool {
1605 let has_exception_line = lower.lines().any(|line| {
1606 let line = line.trim_start();
1607 [
1608 "typeerror:",
1609 "referenceerror:",
1610 "rangeerror:",
1611 "runtimeerror:",
1612 "keyerror:",
1613 "attributeerror:",
1614 ]
1615 .iter()
1616 .any(|prefix| line.starts_with(prefix))
1617 });
1618 has_exception_line && (lower.contains("\n at ") || lower.contains("\n at "))
1619}
1620
1621fn has_runtime_panic(lower: &str) -> bool {
1622 lower
1623 .lines()
1624 .any(|line| line.trim_start().starts_with("panic: runtime error:"))
1625 && (lower.contains("\ngoroutine ") || lower.contains("[signal sig"))
1626}
1627
1628fn has_patch_failure(lower: &str) -> bool {
1629 lower.lines().any(|line| {
1630 let line = line.trim_start();
1631 line.starts_with("error: patch failed:")
1632 || line.starts_with("patch failed:")
1633 || line.contains(": patch does not apply")
1634 || line.starts_with("invalid context")
1635 })
1636}
1637
1638fn ends_with_not_found(lower: &str) -> bool {
1647 lower
1648 .lines()
1649 .any(|line| line.trim_end().ends_with(": not found"))
1650}
1651
1652fn has_nonzero_exit_status(lower: &str) -> bool {
1658 NONZERO_EXIT_PHRASES
1659 .iter()
1660 .any(|phrase| phrase_followed_by_nonzero_integer(lower, phrase))
1661}
1662
1663fn phrase_followed_by_nonzero_integer(lower: &str, phrase: &str) -> bool {
1665 let mut cursor = 0usize;
1666 while let Some(rel) = lower[cursor..].find(phrase) {
1667 let value_start = cursor + rel + phrase.len();
1668 let rest = lower[value_start..].trim_start_matches(|c: char| {
1669 c.is_ascii_whitespace() || matches!(c, ':' | '=' | '\'' | '"' | '`')
1670 });
1671 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1672 if !digits.is_empty() && digits.chars().any(|d| d != '0') {
1673 return true;
1674 }
1675 cursor = value_start;
1676 }
1677 false
1678}
1679
1680fn compute_no_error_streak(tool_texts: &[(String, bool)]) -> u32 {
1681 let mut streak = 0u32;
1682 for (text, is_error) in tool_texts.iter().rev() {
1683 let (sev, _) = classify_text(text);
1684 if *is_error || sev > 0.0 {
1685 break;
1686 }
1687 streak += 1;
1688 }
1689 streak
1690}
1691
1692fn detect_tests_passed(tool_texts: &[(String, bool)], recent_window: usize) -> bool {
1693 let start = tool_texts.len().saturating_sub(recent_window.max(1));
1694 let recent = &tool_texts[start..];
1695 let after_latest_failure = recent
1696 .iter()
1697 .rposition(|(text, is_error)| *is_error || classify_text(text).0 > 0.0)
1698 .map_or(recent, |index| &recent[index + 1..]);
1699 after_latest_failure.iter().any(|(text, _)| {
1700 let lower = text.to_lowercase();
1701 TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
1702 && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
1703 && !has_nonzero_failure_count(&lower)
1704 })
1705}
1706
1707fn has_nonzero_failure_count(lower: &str) -> bool {
1713 for kw in NUMERIC_FAILURE_KEYWORDS {
1714 let mut cursor = 0usize;
1715 while let Some(rel) = lower[cursor..].find(kw) {
1716 let kw_start = cursor + rel;
1717 let kw_end = kw_start + kw.len();
1718 let boundary_after = lower[kw_end..]
1721 .chars()
1722 .next()
1723 .is_none_or(|c| !c.is_ascii_alphanumeric());
1724 if boundary_after {
1725 let prefix = &lower[..kw_start];
1726 let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
1727 let digits_rev: String = trimmed
1728 .chars()
1729 .rev()
1730 .take_while(|c| c.is_ascii_digit())
1731 .collect();
1732 if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
1733 return true;
1734 }
1735 }
1736 cursor = kw_start + kw.len();
1737 }
1738 }
1739 false
1740}
1741
1742#[cfg(test)]
1745mod tests {
1746 use super::*;
1747 use crate::algorithms::util::stage::score_signal;
1748 use serde_json::json;
1749 use switchyard_protocol::codex_namespaces::TOOL_NAMESPACES_KEY;
1750 use switchyard_protocol::{
1751 ContentBlock, LlmRequest, Message, Metadata, Role, ToolCall, ToolResult,
1752 };
1753
1754 fn with_messages(messages: Vec<Message>) -> Request {
1755 let mut request = Request {
1756 llm_request: LlmRequest {
1757 messages,
1758 ..LlmRequest::default()
1759 },
1760 raw_request: None,
1761 metadata: None,
1762 };
1763 switchyard_translation::normalize_compaction(&mut request.llm_request, None);
1764 request
1765 }
1766
1767 fn tc(name: &str) -> Message {
1769 Message {
1770 role: Role::Assistant,
1771 content: vec![ContentBlock::ToolCall(ToolCall {
1772 id: String::new(),
1773 name: name.to_string(),
1774 arguments: json!({}),
1775 })],
1776 }
1777 }
1778
1779 fn bash(command: &str) -> Message {
1781 Message {
1782 role: Role::Assistant,
1783 content: vec![ContentBlock::ToolCall(ToolCall {
1784 id: String::new(),
1785 name: "Bash".to_string(),
1786 arguments: json!({"command": command}),
1787 })],
1788 }
1789 }
1790
1791 fn tr(text: &str) -> Message {
1793 Message {
1794 role: Role::User,
1795 content: vec![ContentBlock::ToolResult(ToolResult {
1796 tool_call_id: String::new(),
1797 content: vec![ContentBlock::Text {
1798 text: text.to_string(),
1799 }],
1800 is_error: None,
1801 })],
1802 }
1803 }
1804
1805 #[test]
1806 fn user_turn_signals_exclude_old_errors_and_keep_user_role_tool_results() {
1807 let mut user = Message::text(Role::User, "next");
1808 user.content.extend(tr("out of memory").content);
1809 let request = with_messages(vec![tr("out of memory"), user, tr("ok")]);
1810 let signals = ToolSignalProcessor::default().for_user_turn(&request);
1811 assert_eq!(signals.tool_result_count, 1);
1812 assert_eq!(signals.severity, 0.0);
1813 assert_eq!(signals.turn_depth, 2);
1814 }
1815
1816 #[test]
1817 fn clean_text_has_zero_severity() {
1818 let (sev, patterns) = classify_text("everything went fine");
1819 assert_eq!(sev, 0.0);
1820 assert!(patterns.is_empty());
1821 }
1822
1823 #[test]
1824 fn structured_tool_failures_affect_recovery_signals() {
1825 for (text, severity) in [
1826 (r#"{"output":"","exit_code":7,"error":null}"#, SOFT),
1827 (r#"{"output":"","exit_code":-1,"error":null}"#, SOFT),
1828 (r#"{"success":false,"error":"No matching text"}"#, HARD),
1829 (r#"{"success":false,"error":null}"#, HARD),
1830 (
1831 r#"{"error":"Overwrite refused","stale_write_blocked":true}"#,
1832 HARD,
1833 ),
1834 (r#"{"output":"out of memory","exit_code":1}"#, CRITICAL),
1835 (r#"{"output":"done","exit_code":0,"error":null}"#, 0.0),
1836 (r#"{"success":true,"error":" "}"#, 0.0),
1837 (r#"{"output":"running","exit_code":null,"error":null}"#, 0.0),
1838 (r#"{"output":{"success":false},"exit_code":0}"#, 0.0),
1839 ] {
1840 let warned = format!("{text}\n\n[Tool loop warning: repeated identical call]");
1841 let request = with_messages(vec![tr("5 passed in 0.12s"), tr(text), tr(&warned)]);
1842 let signal = ToolSignals::from_request(&request, None);
1843 let clean = severity == 0.0;
1844 assert_eq!(signal.severity, severity, "{text}");
1845 assert_eq!(signal.no_error_streak, if clean { 3 } else { 0 }, "{text}");
1846 assert_eq!(signal.repeated_failure, severity >= HARD, "{text}");
1847 assert_eq!(signal.tests_passed, clean, "{text}");
1848 }
1849 }
1850
1851 #[test]
1852 fn traceback_is_hard() {
1853 let (sev, patterns) = classify_text("Traceback (most recent call last):\n ValueError");
1854 assert_eq!(sev, HARD);
1855 assert!(patterns.contains(&"traceback".to_string()));
1856 }
1857
1858 #[test]
1859 fn oom_is_critical() {
1860 let (sev, _) = classify_text("Out of memory: kill process 1234");
1861 assert_eq!(sev, CRITICAL);
1862 }
1863
1864 #[test]
1865 fn connection_refused_is_hard() {
1866 let (severity, _) = classify_text("Connection refused on port 8000");
1867 assert_eq!(severity, HARD);
1868 }
1869
1870 #[test]
1871 fn repeated_failure_ignores_volatile_paths_and_numbers() {
1872 let request = with_messages(vec![
1873 tr("error[E0308]: mismatched types at /tmp/a/src/lib.rs:12"),
1874 tr("error[E0308]: mismatched types at /tmp/b/src/lib.rs:47"),
1875 ]);
1876 assert!(ToolSignals::from_request(&request, None).repeated_failure);
1877 }
1878
1879 #[test]
1880 fn different_failures_are_not_repeated() {
1881 let request = with_messages(vec![
1882 tr("error[E0308]: mismatched types"),
1883 tr("error[E0509]: cannot move out"),
1884 ]);
1885 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1886 }
1887
1888 #[test]
1889 fn one_material_failure_is_not_repeated() {
1890 let request = with_messages(vec![tr("Connection refused on port 8000")]);
1891 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1892 }
1893
1894 #[test]
1896 fn structured_tool_failures_feed_error_and_recovery_signals() {
1897 for text in ["Dependency unavailable", "", "5 passed in 0.12s"] {
1898 let mut failed = tr(text);
1899 let ContentBlock::ToolResult(result) = &mut failed.content[0] else {
1900 panic!("expected tool result");
1901 };
1902 result.is_error = Some(true);
1903 let mut request = with_messages(vec![tr("5 passed in 0.12s"), failed.clone()]);
1904 let signals = ToolSignals::from_request(&request, Some(3));
1905 assert_eq!(signals.severity, HARD);
1906 assert!(!signals.repeated_failure);
1907 assert_eq!(signals.no_error_streak, 0);
1908 assert!(!signals.tests_passed);
1909
1910 request.llm_request.messages.push(failed);
1911 assert!(ToolSignals::from_request(&request, Some(3)).repeated_failure);
1912 request.llm_request.messages.push(tr("5 passed in 0.12s"));
1913 let recovered = ToolSignals::from_request(&request, Some(1));
1914 assert_eq!(recovered.severity, 0.0);
1915 assert!(!recovered.repeated_failure);
1916 assert_eq!(recovered.no_error_streak, 1);
1917 assert!(recovered.tests_passed);
1918 }
1919 }
1920
1921 #[test]
1922 fn severity_is_max_across_patterns() {
1923 let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
1925 assert_eq!(sev, HARD);
1926 }
1927
1928 #[test]
1929 fn codex_process_exit_zero_stays_clean() {
1930 let (sev, patterns) =
1931 classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
1932 assert_eq!(sev, 0.0);
1933 assert!(!patterns.contains(&"exit_nonzero".to_string()));
1934 }
1935
1936 #[test]
1937 fn nonzero_exit_codes_are_soft_errors() {
1938 let cases = [
1939 "Process exited with code 1",
1940 "Process exited with code 127",
1941 "exit code: 2",
1942 "exit status 3",
1943 "exited with status 9",
1944 ];
1945 for case in cases {
1946 let (sev, patterns) = classify_text(case);
1947 assert_eq!(sev, SOFT, "expected soft severity for {case}");
1948 assert!(patterns.contains(&"exit_nonzero".to_string()));
1949 }
1950 }
1951
1952 #[test]
1953 fn partial_process_failures_are_hard_errors() {
1954 let cases = [
1955 (
1956 "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
1957 "compile_error",
1958 ),
1959 (
1960 "Process exited with code 0\nOutput:\nTypeError: value is undefined\n at main.js:1:2",
1961 "runtime_exception",
1962 ),
1963 (
1964 "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
1965 "runtime_panic",
1966 ),
1967 (
1968 "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
1969 "patch_error",
1970 ),
1971 ];
1972 for (text, expected_pattern) in cases {
1973 let (severity, patterns) = classify_text(text);
1974 assert_eq!(severity, HARD, "expected hard severity for {text}");
1975 assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
1976 }
1977 }
1978
1979 #[test]
1982 fn trimmed_command_not_found_is_still_hard() {
1983 let cases = [
1984 "git: not found",
1985 "sh: 1: git: not found",
1986 "/bin/sh: 1: git: not found",
1987 ];
1988 for text in cases {
1989 let (sev, patterns) = classify_text(text);
1990 assert_eq!(sev, HARD, "expected hard severity for {text}");
1991 assert!(patterns.contains(&"cmd_not_found".to_string()));
1992 }
1993 }
1994
1995 #[test]
1998 fn not_found_mid_sentence_stays_clean() {
1999 let (sev, _) =
2000 classify_text("Looking for config key, not found in environment, using default.");
2001 assert_eq!(sev, 0.0);
2002 }
2003
2004 #[test]
2007 fn not_found_without_shell_separator_stays_clean() {
2008 for text in [
2009 "HTTP/1.1 404 Not Found",
2010 "lookup finished, key not found",
2011 "GET /healthz -> 404 Not Found",
2012 ] {
2013 let (sev, patterns) = classify_text(text);
2014 assert_eq!(sev, 0.0, "expected clean classification for {text}");
2015 assert!(!patterns.contains(&"cmd_not_found".to_string()));
2016 }
2017 }
2018
2019 #[test]
2020 fn source_text_that_names_exceptions_stays_clean() {
2021 let text =
2022 "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
2023 assert_eq!(classify_text(text).0, 0.0);
2024 }
2025
2026 #[test]
2027 fn file_does_not_exist_is_hard() {
2028 let (sev, patterns) =
2030 classify_text("Error: File does not exist. Note: current working directory is /app.");
2031 assert_eq!(sev, HARD);
2032 assert!(patterns.contains(&"no_such_file".to_string()));
2033 }
2034
2035 #[test]
2036 fn bare_does_not_exist_stays_clean() {
2037 let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
2040 assert_eq!(sev, 0.0);
2041 }
2042
2043 #[test]
2044 fn no_error_streak_all_clean() {
2045 let texts = vec![("ok".to_string(), false), ("all good".to_string(), false)];
2046 assert_eq!(compute_no_error_streak(&texts), 2);
2047 }
2048
2049 #[test]
2050 fn no_error_streak_stops_at_error() {
2051 let texts = vec![
2052 ("Traceback (most recent call last):".to_string(), false),
2053 ("ok".to_string(), false),
2054 ("ok".to_string(), false),
2055 ];
2056 assert_eq!(compute_no_error_streak(&texts), 2);
2057 }
2058
2059 #[test]
2060 fn tests_passed_detects_pytest_output() {
2061 assert!(detect_tests_passed(
2062 &[("====== 5 passed in 0.12s ======".to_string(), false)],
2063 DEFAULT_RECENT_WINDOW
2064 ));
2065 }
2066
2067 #[test]
2068 fn tests_passed_ignores_partial_failures() {
2069 assert!(!detect_tests_passed(
2070 &[("2 failed, 5 passed in 0.56s".to_string(), false)],
2071 DEFAULT_RECENT_WINDOW
2072 ));
2073 }
2074
2075 #[test]
2076 fn tests_passed_must_follow_the_latest_failure() {
2077 assert!(!detect_tests_passed(
2078 &[
2079 ("5 passed in 0.12s".to_string(), false),
2080 (
2081 "Traceback (most recent call last):\nValueError".to_string(),
2082 false
2083 ),
2084 ("edit applied".to_string(), false),
2085 ],
2086 DEFAULT_RECENT_WINDOW
2087 ));
2088 assert!(detect_tests_passed(
2089 &[
2090 (
2091 "Traceback (most recent call last):\nValueError".to_string(),
2092 false
2093 ),
2094 ("5 passed in 0.12s".to_string(), false),
2095 ],
2096 DEFAULT_RECENT_WINDOW
2097 ));
2098 }
2099
2100 #[test]
2101 fn retrieved_file_contents_are_ignored() {
2102 let call = |id: &str, name: &str, arguments: Value| Message {
2103 role: Role::Assistant,
2104 content: vec![ContentBlock::ToolCall(ToolCall {
2105 id: id.to_string(),
2106 name: name.to_string(),
2107 arguments,
2108 })],
2109 };
2110 let result = |id: &str, text: &str| Message {
2111 role: Role::User,
2112 content: vec![ContentBlock::ToolResult(ToolResult {
2113 tool_call_id: id.to_string(),
2114 content: vec![ContentBlock::Text {
2115 text: text.to_string(),
2116 }],
2117 is_error: None,
2118 })],
2119 };
2120 let signal = extract_tool_signals_with_window(
2121 &with_messages(vec![
2122 call("a", "Bash", json!({"command": "pytest"})),
2123 result("a", "Traceback (most recent call last):\nValueError"),
2124 call("b", "Read", json!({"file_path": "notes.md"})),
2125 result(
2126 "b",
2127 r#"{"success":false,"error":"out of memory","exit_code":7}"#,
2128 ),
2129 call("c", "Grep", json!({"pattern": "passed"})),
2130 result("c", "CHANGELOG.md: all tests passed"),
2131 ]),
2132 DEFAULT_RECENT_WINDOW,
2133 );
2134 assert_eq!(signal.severity, HARD);
2136 assert!(!signal.tests_passed);
2137 assert_eq!(signal.tool_result_count, 3);
2138
2139 for (command, text, severity) in [
2140 ("cat config.json", r#"{"error":"fixture data"}"#, 0.0),
2141 (
2142 "cat missing.json",
2143 r#"{"output":"","exit_code":7,"error":null}"#,
2144 SOFT,
2145 ),
2146 (
2147 "cat config.json && python check.py",
2148 r#"{"error":"check rejected"}"#,
2149 HARD,
2150 ),
2151 ] {
2152 let request = with_messages(vec![
2153 call("shell", "Bash", json!({"command": command})),
2154 result("shell", text),
2155 ]);
2156 assert_eq!(
2157 ToolSignals::from_request(&request, None).severity,
2158 severity,
2159 "{text}"
2160 );
2161 }
2162 }
2163
2164 fn call_with_result(mut call: Message, mut result: Message) -> Request {
2166 if let ContentBlock::ToolCall(call) = &mut call.content[0] {
2167 call.id = "call-1".into();
2168 }
2169 if let ContentBlock::ToolResult(result) = &mut result.content[0] {
2170 result.tool_call_id = "call-1".into();
2171 }
2172 with_messages(vec![call, result])
2173 }
2174
2175 #[test]
2176 fn shell_reads_are_ignored_but_keep_their_window_slot() {
2177 for command in [
2178 "/usr/bin/cat logfile.txt",
2179 "cd /repo && tail -n 100 logfile.txt | grep MemoryError",
2180 "env MODE=debug timeout 5s rg 'MemoryError' .",
2181 ] {
2182 let mut request = call_with_result(bash(command), tr("MemoryError\nall tests passed"));
2183 request.llm_request.messages.splice(
2184 0..0,
2185 [bash("pytest"), tr("Traceback (most recent call last):")],
2186 );
2187 let signal = ToolSignals::from_request(&request, Some(2));
2189 assert_eq!(signal.severity, HARD, "{command}");
2190 assert!(!signal.tests_passed, "{command}");
2191 let signal = ToolSignals::from_request(&request, Some(1));
2193 assert_eq!(signal.severity, 0.0, "{command}");
2194 }
2195 }
2196
2197 #[test]
2198 fn shell_commands_that_run_code_keep_signals() {
2199 let oversized = format!("cat {}", "a".repeat(MAX_RETRIEVAL_COMMAND_BYTES));
2200 for command in [
2201 "cat logfile.txt; pytest",
2202 "echo $(pytest)",
2203 "find . -name '*.py' -exec pytest ';'",
2204 oversized.as_str(),
2205 ] {
2206 let request = call_with_result(bash(command), tr("MemoryError"));
2207 let signal = ToolSignals::from_request(&request, None);
2208 assert_eq!(signal.severity, CRITICAL, "{command}");
2209 }
2210 }
2211
2212 #[test]
2213 fn failed_reads_signal_unless_the_file_is_missing() {
2214 let failed = |text: &str| {
2215 let mut result = tr(text);
2216 if let ContentBlock::ToolResult(result) = &mut result.content[0] {
2217 result.is_error = Some(true);
2218 }
2219 result
2220 };
2221 let missing = "missing.txt: No such file or directory";
2222 let severities = [
2223 call_with_result(bash("cat missing.txt"), failed(missing)),
2224 call_with_result(tc("Read"), failed("File does not exist.")),
2225 call_with_result(bash("cat logfile.txt"), failed("MemoryError")),
2227 call_with_result(bash("python missing.txt"), failed(missing)),
2229 ]
2230 .map(|request| ToolSignals::from_request(&request, None).severity);
2231 assert_eq!(severities, [0.0, 0.0, CRITICAL, HARD]);
2232
2233 let request = call_with_result(
2234 bash("cat missing.json"),
2235 tr(r#"{"output":"No such file or directory","exit_code":1}"#),
2236 );
2237 assert_eq!(ToolSignals::from_request(&request, None).severity, 0.0);
2238 }
2239
2240 #[test]
2241 fn codex_read_status_comes_from_the_header() {
2242 let read = |header: &str| {
2243 let output = format!(
2244 "{header}\nOutput:\nTraceback (most recent call last):\nMemoryError\nProcess exited with code 1\n"
2245 );
2246 let call = exec_command(json!({"cmd": "cat run.log"}));
2247 ToolSignals::from_request(&call_with_result(call, tr(&output)), None).severity
2248 };
2249 assert_eq!(read("Chunk ID: abc\nProcess exited with code 0"), 0.0);
2251 assert_eq!(read("Chunk ID: abc\nProcess exited with code 1"), CRITICAL);
2253 }
2254
2255 #[test]
2256 fn hermes_read_status_comes_from_exit_code() {
2257 let read = |exit_code: i64| {
2258 let output = json!({
2259 "output": "Traceback (most recent call last):\nMemoryError\nProcess exited with code 1\n",
2260 "exit_code": exit_code,
2261 "error": null,
2262 });
2263 let call = Message {
2264 role: Role::Assistant,
2265 content: vec![ContentBlock::ToolCall(ToolCall {
2266 id: String::new(),
2267 name: "terminal".to_string(),
2268 arguments: json!({"command": "cat run.log"}),
2269 })],
2270 };
2271 ToolSignals::from_request(&call_with_result(call, tr(&output.to_string())), None)
2272 .severity
2273 };
2274 assert_eq!(read(0), 0.0);
2276 assert_eq!(read(1), CRITICAL);
2278 }
2279
2280 #[test]
2281 fn argv_commands_keep_word_boundaries() {
2282 let signal = |argv: Value| {
2283 let call = exec_command(json!({"cmd": argv}));
2284 ToolSignals::from_request(&call_with_result(call, tr("MemoryError")), None)
2285 };
2286 let mut many_args = vec![""; MAX_RETRIEVAL_COMMAND_BYTES];
2287 many_args[0] = "cat";
2288 let severities = [
2289 json!(["bash", "-lc", "cd repo && rg foo"]),
2290 json!(["bash", "-lc", "cat log; pytest"]),
2291 json!(["cat", "log; pytest"]),
2293 json!(["cat", "a".repeat(MAX_RETRIEVAL_COMMAND_BYTES)]),
2295 json!(many_args),
2296 ]
2297 .map(|argv| signal(argv).severity);
2298 assert_eq!(severities, [0.0, CRITICAL, 0.0, CRITICAL, CRITICAL]);
2299 let read = signal(json!(["bash", "-lc", "sed -n 1,200p x"]));
2301 assert_eq!(read.read_count, 1);
2302 let write = signal(json!(["bash", "-lc", "cd repo && mkdir x"]));
2304 assert_eq!(write.write_count, 1);
2305 }
2306
2307 #[test]
2308 fn severity_is_windowed_over_recent_results() {
2309 let request = with_messages(vec![
2311 tr("Traceback (most recent call last):\n ValueError"),
2312 tr("ok"),
2313 tr("ok"),
2314 ]);
2315 assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
2317 assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
2319 }
2320
2321 #[test]
2322 fn extract_openai_chat_tool_results() {
2323 let request = with_messages(vec![
2324 Message::text(Role::User, "do something"),
2325 tc("Edit"),
2326 tr("Traceback (most recent call last):\n ValueError"),
2327 ]);
2328 let sig = ToolSignals::from_request(&request, None);
2329 assert_eq!(sig.severity, HARD);
2330 assert_eq!(sig.edit_count, 1);
2331 assert_eq!(sig.turn_depth, 3);
2332 }
2333
2334 #[test]
2335 fn extract_anthropic_tool_results() {
2336 let request = with_messages(vec![tr("Traceback (most recent call last):\n ValueError")]);
2337 let sig = ToolSignals::from_request(&request, None);
2338 assert_eq!(sig.severity, HARD);
2339 }
2340
2341 #[test]
2342 fn extract_responses_api_tool_results() {
2343 let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
2344 let sig = ToolSignals::from_request(&request, None);
2345 assert_eq!(sig.severity, 0.0);
2346 assert_eq!(sig.write_count, 1);
2347 }
2348
2349 #[test]
2350 fn responses_builtin_tool_failures_escalate() {
2351 use crate::algorithms::util::stage::{PickOutcome, PickerMode, Tier, pick_tier};
2352
2353 let mut cases = Vec::new();
2354 for (status, output) in [
2355 (
2356 "failed",
2357 "Synthetic dependency unavailable; retry with the recovery path.",
2358 ),
2359 (
2360 "completed",
2361 "Synthetic dependency unavailable; retry with the recovery path.",
2362 ),
2363 ("failed", ""),
2364 ] {
2365 cases.push((
2366 json!({
2367 "type": "apply_patch_call_output",
2368 "status": status,
2369 "output": output,
2370 }),
2371 if status == "failed" { HARD } else { 0.0 },
2372 ));
2373 }
2374 for (outcome, stdout, stderr, severity) in [
2375 (json!({"type": "exit", "exit_code": 1}), "", "", SOFT),
2376 (json!({"type": "exit", "exit_code": 1}), "", " \n", SOFT),
2377 (
2378 json!({"type": "exit", "exit_code": 1}),
2379 "",
2380 "command failed",
2381 HARD,
2382 ),
2383 (json!({"type": "timeout"}), "", "", HARD),
2384 (json!({"type": "exit", "exit_code": 0}), "done", "", 0.0),
2385 (
2386 json!({"type": "exit", "exit_code": 0}),
2387 "Traceback (most recent call last):",
2388 "",
2389 HARD,
2390 ),
2391 (
2392 json!({"type": "exit", "exit_code": 0}),
2393 "",
2394 "Traceback (most recent call last):",
2395 HARD,
2396 ),
2397 ] {
2398 cases.push((
2399 json!({
2400 "type": "shell_call_output",
2401 "output": [
2402 {"stdout": stdout, "stderr": stderr, "outcome": outcome},
2403 {"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}
2404 ],
2405 }),
2406 severity,
2407 ));
2408 }
2409 for (raw, severity) in cases {
2410 let is_error = severity >= HARD;
2411 let mut request = with_messages(
2412 ["call_1", "call_2"]
2413 .into_iter()
2414 .map(|call_id| {
2415 let mut raw = raw.clone();
2416 raw["call_id"] = json!(call_id);
2417 Message {
2418 role: Role::User,
2419 content: vec![ContentBlock::Unknown {
2420 provider: WireFormat::OpenAiResponses.into(),
2421 raw,
2422 }],
2423 }
2424 })
2425 .collect(),
2426 );
2427 let signal = ToolSignals::from_request(&request, Some(3));
2428 assert_eq!(signal.severity, severity, "{raw}");
2429 assert_eq!(signal.repeated_failure, is_error, "{raw}");
2430 assert_eq!(signal.tool_result_count, 2);
2431 assert_eq!(
2432 matches!(
2433 pick_tier(&signal, PickerMode::EfficientFirst, 0.5),
2434 PickOutcome::Resolved {
2435 tier: Tier::Capable,
2436 ..
2437 }
2438 ),
2439 is_error,
2440 "{raw}"
2441 );
2442
2443 let mut success = match raw["type"].as_str() {
2444 Some("apply_patch_call_output") => json!({
2445 "type": "apply_patch_call_output", "status": "completed", "output": ""
2446 }),
2447 Some("shell_call_output") => json!({
2448 "type": "shell_call_output",
2449 "output": [{"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}]
2450 }),
2451 _ => unreachable!(),
2452 };
2453 for index in 0..3 {
2454 success["call_id"] = json!(format!("success_{index}"));
2455 request.llm_request.messages.push(Message {
2456 role: Role::User,
2457 content: vec![ContentBlock::Unknown {
2458 provider: WireFormat::OpenAiResponses.into(),
2459 raw: success.clone(),
2460 }],
2461 });
2462 }
2463 let recovered = ToolSignals::from_request(&request, Some(3));
2464 assert_eq!(recovered.severity, 0.0, "{raw}");
2465 assert!(!recovered.repeated_failure, "{raw}");
2466 assert!(recovered.no_error_streak >= 3, "{raw}");
2467 assert_eq!(recovered.tool_result_count, 5);
2468 }
2469 }
2470
2471 #[test]
2472 fn conversation_counts_are_per_block_and_role_aware() {
2473 let result = |content: Vec<ContentBlock>| {
2477 ContentBlock::ToolResult(ToolResult {
2478 tool_call_id: String::new(),
2479 content,
2480 is_error: None,
2481 })
2482 };
2483 let request = with_messages(vec![
2484 Message::text(Role::User, "do something"),
2485 Message::text(Role::Assistant, "working"),
2486 Message {
2487 role: Role::User,
2488 content: vec![
2489 result(vec![ContentBlock::Text {
2490 text: "ok".to_string(),
2491 }]),
2492 result(Vec::new()),
2493 ],
2494 },
2495 tc("Bash"),
2496 ]);
2497 let sig = ToolSignals::from_request(&request, None);
2498 assert_eq!(sig.tool_result_count, 2);
2499 assert_eq!(sig.assistant_turn_count, 2);
2500 assert_eq!(sig.turn_depth, 4);
2501 }
2502
2503 #[test]
2504 fn recent_window_counts_only_last_default_window_tool_calls() {
2505 let request = with_messages(vec![
2508 tc("Write"),
2509 tr("ok"),
2510 tc("Write"),
2511 tr("ok"),
2512 tc("Write"),
2513 tr("ok"),
2514 tc("Write"),
2515 tr("ok"),
2516 tc("Write"),
2517 tr("ok"),
2518 tc("Edit"),
2519 tr("ok"),
2520 ]);
2521 let sig = ToolSignals::from_request(&request, None);
2522 assert_eq!(sig.write_count, 5);
2523 assert_eq!(sig.edit_count, 1);
2524 assert_eq!(sig.recent_write_count, 2);
2525 assert_eq!(sig.recent_edit_count, 1);
2526 }
2527
2528 #[test]
2529 fn codex_apply_patch_counts_as_an_edit() {
2530 let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
2531 let sig = ToolSignals::from_request(&request, None);
2532 assert_eq!(sig.edit_count, 1);
2533 assert_eq!(sig.recent_edit_count, 1);
2534 }
2535
2536 fn exec_command(cmd: Value) -> Message {
2537 Message {
2538 role: Role::Assistant,
2539 content: vec![ContentBlock::ToolCall(ToolCall {
2540 id: String::new(),
2541 name: "exec_command".to_string(),
2542 arguments: cmd,
2543 })],
2544 }
2545 }
2546
2547 #[test]
2548 fn codex_exec_command_is_classified() {
2549 let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
2551 let request = with_messages(vec![exec_command(args), tr("ok")]);
2552 assert_eq!(
2553 ToolSignals::from_request(&request, None).recent_edit_count,
2554 1
2555 );
2556 }
2557
2558 #[test]
2559 fn python_write_expressions_need_a_python_command() {
2560 let write = with_messages(vec![
2561 exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
2562 tr("ok"),
2563 ]);
2564 assert_eq!(
2565 ToolSignals::from_request(&write, None).recent_write_count,
2566 1
2567 );
2568
2569 let search = with_messages(vec![
2570 exec_command(json!({"cmd": "grep -R '.write(' src"})),
2571 tr("ok"),
2572 ]);
2573 assert_eq!(
2574 ToolSignals::from_request(&search, None).recent_write_count,
2575 0
2576 );
2577 }
2578
2579 #[test]
2580 fn recent_window_size_is_caller_overridable() {
2581 let request = with_messages(vec![
2585 tc("Write"),
2586 tr("ok"),
2587 tc("Write"),
2588 tr("ok"),
2589 tc("Write"),
2590 tr("ok"),
2591 tc("Write"),
2592 tr("ok"),
2593 tc("Write"),
2594 tr("ok"),
2595 tc("Edit"),
2596 tr("ok"),
2597 ]);
2598 let narrow = extract_tool_signals_with_window(&request, 3);
2599 assert_eq!(narrow.recent_write_count, 2);
2600 assert_eq!(narrow.recent_edit_count, 1);
2601
2602 let wide = extract_tool_signals_with_window(&request, 6);
2603 assert_eq!(wide.recent_write_count, 5);
2604 assert_eq!(wide.recent_edit_count, 1);
2605 }
2606
2607 #[test]
2608 fn compaction_marker_sets_compacted() {
2609 let request = with_messages(vec![
2611 Message::text(
2612 Role::User,
2613 "This session is being continued from a previous conversation that ran out of context.",
2614 ),
2615 Message::text(Role::User, "Continue with the next task"),
2616 bash("ls"),
2617 ]);
2618 assert!(ToolSignals::from_request(&request, None).compacted);
2619 assert!(
2620 ToolSignalProcessor::default()
2621 .for_user_turn(&request)
2622 .compacted
2623 );
2624 }
2625
2626 #[test]
2627 fn hermes_compaction_headers_set_compacted() {
2628 for header in [
2629 "[CONTEXT COMPACTION \u{2014} REFERENCE ONLY] Earlier turns were compacted into the summary below.",
2630 "[STILL IN PROGRESS \u{2014} this is the active request, restated after the compaction boundary because it was not finished yet. Continue it\u{3b} do not start over.]",
2631 ] {
2632 for role in [Role::User, Role::Assistant] {
2633 let request = with_messages(vec![
2634 Message::text(role, format!("{header}\nContinue the task.")),
2635 bash("ls"),
2636 ]);
2637 assert!(ToolSignals::from_request(&request, None).compacted);
2638 }
2639 let request = with_messages(vec![Message::text(
2640 Role::User,
2641 format!("Prior context.\n\n {header}\nContinue the task."),
2642 )]);
2643 assert!(ToolSignals::from_request(&request, None).compacted);
2644 let request = with_messages(vec![tr(header)]);
2645 assert!(!ToolSignals::from_request(&request, None).compacted);
2646 }
2647 }
2648
2649 #[test]
2650 fn ordinary_compaction_text_stays_uncompacted() {
2651 for text in [
2652 "The task is still in progress after the compaction boundary.",
2653 "Explain [CONTEXT COMPACTION \u{2014} REFERENCE ONLY] in the docs.",
2654 "[STILL IN PROGRESS] Continue the task.",
2655 ] {
2656 let request = with_messages(vec![Message::text(Role::User, text)]);
2657 assert!(!ToolSignals::from_request(&request, None).compacted);
2658 }
2659 }
2660
2661 #[test]
2662 fn codex_compaction_metadata_stays_on_parent_route() {
2663 let mut request = with_messages(vec![bash("ls")]);
2664 request.metadata = Some(Metadata {
2665 is_subagent: true,
2666 agent_kind: Some("compact".to_string()),
2667 ..Default::default()
2668 });
2669 assert!(!ToolSignals::from_request(&request, None).compacted);
2670 }
2671
2672 #[test]
2673 fn no_compaction_marker_stays_uncompacted() {
2674 let request = with_messages(vec![
2675 Message::text(Role::User, "Write a script that parses the log file."),
2676 bash("ls"),
2677 ]);
2678 assert!(!ToolSignals::from_request(&request, None).compacted);
2679 }
2680
2681 #[test]
2682 fn bash_heredoc_counts_as_write() {
2683 let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
2685 let sig = ToolSignals::from_request(&request, None);
2686 assert_eq!(
2687 sig.write_count, 1,
2688 "Bash heredoc should bucket into write_count"
2689 );
2690 assert_eq!(sig.edit_count, 0);
2691 }
2692
2693 #[test]
2694 fn bash_sed_inplace_counts_as_edit() {
2695 let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
2696 let sig = ToolSignals::from_request(&request, None);
2697 assert_eq!(
2698 sig.edit_count, 1,
2699 "Bash sed -i should bucket into edit_count"
2700 );
2701 assert_eq!(sig.write_count, 0);
2702 }
2703
2704 #[test]
2705 fn bash_non_mutating_does_not_count() {
2706 let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
2708 let sig = ToolSignals::from_request(&request, None);
2709 assert_eq!(sig.write_count, 0);
2710 assert_eq!(sig.edit_count, 0);
2711 }
2712
2713 #[test]
2714 fn tests_passed_detects_pytest_with_failure_block() {
2715 assert!(!detect_tests_passed(
2717 &[("2 failed, 5 passed in 0.56s".to_string(), false)],
2718 DEFAULT_RECENT_WINDOW
2719 ));
2720 }
2721
2722 #[test]
2723 fn tests_passed_accepts_cargo_clean_summary() {
2724 assert!(detect_tests_passed(
2727 &[(
2728 "running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string(),
2729 false
2730 )],
2731 DEFAULT_RECENT_WINDOW
2732 ));
2733 }
2734
2735 #[test]
2736 fn tests_passed_rejects_cargo_real_failure() {
2737 assert!(!detect_tests_passed(
2739 &[(
2740 "running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string(),
2741 false
2742 )],
2743 DEFAULT_RECENT_WINDOW
2744 ));
2745 }
2746
2747 #[test]
2748 fn tests_passed_accepts_go_clean_summary() {
2749 assert!(detect_tests_passed(
2751 &[(
2752 "ok github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string(),
2753 false
2754 )],
2755 DEFAULT_RECENT_WINDOW
2756 ));
2757 }
2758
2759 #[test]
2760 fn tests_passed_accepts_pytest_zero_errors() {
2761 assert!(detect_tests_passed(
2763 &[("5 passed, 0 errors in 0.30s".to_string(), false)],
2764 DEFAULT_RECENT_WINDOW
2765 ));
2766 }
2767
2768 #[test]
2769 fn tests_passed_detects_diy_checkmark() {
2770 assert!(detect_tests_passed(
2771 &[("✓ all checks passed".to_string(), false)],
2772 DEFAULT_RECENT_WINDOW
2773 ));
2774 }
2775
2776 #[test]
2777 fn anthropic_bash_heredoc_extracts_command() {
2778 let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
2780 let sig = ToolSignals::from_request(&request, None);
2781 assert_eq!(
2782 sig.write_count, 1,
2783 "Anthropic Bash heredoc must also be detected"
2784 );
2785 }
2786
2787 #[test]
2788 fn recent_window_falls_back_to_full_history_when_short() {
2789 let request = with_messages(vec![tc("Write")]);
2790 let sig = ToolSignals::from_request(&request, None);
2791 assert_eq!(sig.recent_write_count, 1);
2792 assert_eq!(sig.recent_edit_count, 0);
2793 }
2794
2795 #[test]
2796 fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
2797 let request = with_messages(vec![tr("output ok"), tr("another ok")]);
2798 let sig = ToolSignals::from_request(&request, None);
2799 assert_eq!(sig.severity, 0.0);
2800 assert_eq!(sig.no_error_streak, 2);
2801 }
2802
2803 #[test]
2806 fn todowrite_classifies_as_plan() {
2807 assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
2808 assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
2809 }
2810
2811 #[test]
2812 fn codex_update_plan_classifies_as_plan() {
2813 assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
2814 }
2815
2816 #[test]
2817 fn codex_shell_command_runs_bash_pattern_match() {
2818 assert_eq!(
2820 classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
2821 ToolSemantic::Mutate(MutationKind::Write),
2822 );
2823 assert_eq!(
2825 classify_tool_call("shell_command", Some("ls /app")),
2826 ToolSemantic::Observe,
2827 );
2828 assert_eq!(
2830 classify_tool_call("shell_command", Some("./run_tests.sh")),
2831 ToolSemantic::Unknown,
2832 );
2833 }
2834
2835 #[test]
2836 fn text_editor_view_is_a_read() {
2837 for name in ["str_replace_based_edit_tool", "text_editor"] {
2838 assert_eq!(
2839 classify_tool_call(name, Some("view")),
2840 ToolSemantic::Observe
2841 );
2842 for command in [
2843 Some("create"),
2844 Some("insert"),
2845 Some("str_replace"),
2846 Some("undo_edit"),
2847 None,
2848 ] {
2849 assert_eq!(
2850 classify_tool_call(name, command),
2851 ToolSemantic::Mutate(MutationKind::Edit),
2852 );
2853 }
2854 }
2855
2856 let arguments = [
2857 json!({"command": "view", "path": "/app/main.py"}),
2858 json!(r#"{"command":"view","path":"/app/main.py"}"#),
2860 ];
2861 for arguments in arguments {
2862 let call = Message {
2863 role: Role::Assistant,
2864 content: vec![ContentBlock::ToolCall(ToolCall {
2865 id: String::new(),
2866 name: "str_replace_based_edit_tool".to_string(),
2867 arguments,
2868 })],
2869 };
2870 let request = with_messages(vec![call, tr("print('hi')")]);
2871 let sig = ToolSignals::from_request(&request, None);
2872 assert_eq!(sig.read_count, 1);
2873 assert_eq!(sig.recent_read_count, 1);
2874 assert_eq!(sig.edit_count, 0);
2875 }
2876 }
2877
2878 #[test]
2879 fn read_tool_classifies_as_read() {
2880 assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
2881 assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
2882 }
2883
2884 #[test]
2885 fn hermes_tool_names_classify() {
2886 assert_eq!(
2888 classify_tool_call("write_file", None),
2889 ToolSemantic::Mutate(MutationKind::Write)
2890 );
2891 assert_eq!(
2892 classify_tool_call("patch", None),
2893 ToolSemantic::Mutate(MutationKind::Edit)
2894 );
2895 assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
2896 assert_eq!(
2897 classify_tool_call("search_files", None),
2898 ToolSemantic::Observe
2899 );
2900 assert_eq!(
2903 classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
2904 ToolSemantic::Mutate(MutationKind::Edit),
2905 );
2906 assert_eq!(
2907 classify_tool_call("terminal", Some("grep foo /app")),
2908 ToolSemantic::Observe,
2909 );
2910 assert_eq!(
2911 classify_tool_call("terminal", Some("./run_tests.sh")),
2912 ToolSemantic::Unknown,
2913 );
2914 }
2915
2916 #[test]
2917 fn bash_read_patterns_classify_as_read() {
2918 let cases = [
2919 "cat /etc/passwd",
2920 "grep foo bar.txt",
2921 "ls /app",
2922 "find . -name '*.py'",
2923 ];
2924 for cmd in cases {
2925 assert_eq!(
2926 classify_tool_call("Bash", Some(cmd)),
2927 ToolSemantic::Observe,
2928 "expected Read for {cmd}"
2929 );
2930 }
2931 }
2932
2933 #[test]
2934 fn codex_inspection_commands_classify_as_read() {
2935 let cases = [
2936 "sed -n '1,80p' src/lib.rs",
2937 "rg -n 'needle' src",
2938 "nl -ba src/lib.rs",
2939 "cat package.json",
2940 "jq '.scripts' package.json",
2941 "git status --short",
2942 "git log --oneline -5",
2943 "git show HEAD:src/lib.rs",
2944 "git branch --show-current",
2945 "git remote -v",
2946 "git config --get remote.origin.url",
2947 ];
2948 for command in cases {
2949 assert_eq!(
2950 classify_tool_call("exec_command", Some(command)),
2951 ToolSemantic::Observe,
2952 "expected Read for {command}"
2953 );
2954 }
2955 }
2956
2957 #[test]
2958 fn quoted_shell_separators_do_not_create_commands() {
2959 for command in ["rg 'foo|rm obsolete.rs'", "rg \"foo; rm obsolete.rs\""] {
2960 assert_eq!(
2961 classify_tool_call("exec_command", Some(command)),
2962 ToolSemantic::Observe,
2963 "quoted text must not be parsed as a command: {command}"
2964 );
2965 }
2966 }
2967
2968 #[test]
2969 fn codex_shell_mutations_classify_as_production() {
2970 let writes = [
2971 "cp source.rs destination.rs",
2972 "mkdir -p src/generated",
2973 "touch src/generated/mod.rs",
2974 "git show HEAD:file.rs > file.rs",
2975 "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
2976 ];
2977 for command in writes {
2978 assert_eq!(
2979 classify_tool_call("exec_command", Some(command)),
2980 ToolSemantic::Mutate(MutationKind::Write),
2981 "expected Write for {command}"
2982 );
2983 }
2984
2985 let edits = [
2986 "mv old.rs new.rs",
2987 "rm obsolete.rs",
2988 "gofmt -w main.go",
2989 "cargo fmt",
2990 "ruff check --fix src",
2991 "perl -0pi -e 's/old/new/' src/lib.rs",
2992 "npx prettier --write src/lib.ts",
2993 "uv run ruff format src",
2994 "git apply fix.patch",
2995 ];
2996 for command in edits {
2997 assert_eq!(
2998 classify_tool_call("exec_command", Some(command)),
2999 ToolSemantic::Mutate(MutationKind::Edit),
3000 "expected Edit for {command}"
3001 );
3002 }
3003 }
3004
3005 #[test]
3006 fn formatter_checks_are_not_edits() {
3007 for command in [
3008 "cargo fmt --check",
3009 "ruff format --check src",
3010 "black --check src",
3011 ] {
3012 assert_ne!(
3013 classify_tool_call("exec_command", Some(command)),
3014 ToolSemantic::Mutate(MutationKind::Edit),
3015 "read-only formatter check must not be Edit: {command}"
3016 );
3017 }
3018 }
3019
3020 #[test]
3021 fn embedded_comparison_is_not_a_shell_write() {
3022 let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
3023 assert_eq!(
3024 classify_tool_call("exec_command", Some(command)),
3025 ToolSemantic::Unknown
3026 );
3027 }
3028
3029 #[test]
3030 fn bash_write_precedence_over_read() {
3031 assert_eq!(
3034 classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
3035 ToolSemantic::Mutate(MutationKind::Write),
3036 );
3037 }
3038
3039 #[test]
3040 fn pure_bash_streak_counts_trailing_other() {
3041 let request = with_messages(vec![
3043 bash("make"),
3044 tr("ok"),
3045 bash("./configure"),
3046 tr("ok"),
3047 bash("make install"),
3048 tr("ok"),
3049 bash("./run.sh"),
3050 tr("ok"),
3051 bash("./test"),
3052 tr("ok"),
3053 ]);
3054 let sig = ToolSignals::from_request(&request, None);
3055 assert_eq!(sig.pure_bash_streak, 5);
3056 assert_eq!(sig.write_count, 0);
3057 assert_eq!(sig.read_count, 0);
3058 }
3059
3060 #[test]
3061 fn pure_bash_streak_resets_on_write() {
3062 let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
3063 let sig = ToolSignals::from_request(&request, None);
3064 assert_eq!(sig.pure_bash_streak, 0);
3065 assert_eq!(sig.write_count, 1);
3066 }
3067
3068 #[test]
3069 fn recent_window_tracks_todowrite_and_read() {
3070 let request = with_messages(vec![
3072 bash("make"),
3073 tr("ok"),
3074 tc("TodoWrite"),
3075 tr("ok"),
3076 tc("Read"),
3077 tr("ok"),
3078 tc("TodoWrite"),
3079 tr("ok"),
3080 ]);
3081 let sig = ToolSignals::from_request(&request, None);
3082 assert_eq!(sig.todowrite_count, 2);
3083 assert_eq!(sig.recent_todowrite_count, 2);
3084 assert_eq!(sig.read_count, 1);
3085 assert_eq!(sig.recent_read_count, 1);
3086 }
3087
3088 #[test]
3089 fn configured_tool_semantics_extend_the_builtin_vocabulary() {
3090 let semantics = ToolSemantics {
3091 observe: vec!["KB_search".to_string()],
3092 mutate: vec!["send_payment_request".to_string()],
3093 plan: vec!["create_research_plan".to_string()],
3094 new: vec!["send_message_to_user".to_string()],
3095 };
3096 semantics.validate().expect("valid additive semantics");
3097 let request = with_messages(vec![
3098 tc("Read"),
3099 tc("Write"),
3100 tc("TodoWrite"),
3101 tc("kb_SEARCH"),
3102 tc("send_payment_request"),
3103 tc("create_research_plan"),
3104 tc("send_message_to_user"),
3105 tc("unlisted_tool"),
3106 ]);
3107
3108 let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
3109
3110 assert_eq!(signal.read_count, 2);
3111 assert_eq!(signal.write_count, 2);
3112 assert_eq!(signal.todowrite_count, 2);
3113 assert_eq!(signal.new_count, 1);
3114 assert_eq!(signal.recent_new_count, 1);
3115 assert_eq!(signal.pure_bash_streak, 1);
3116 }
3117
3118 #[test]
3119 fn configured_tool_semantics_match_namespaced_and_mcp_tools() {
3120 let mut request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
3122 request.llm_request.extensions.fields.insert(
3123 TOOL_NAMESPACES_KEY.to_string(),
3124 json!({"mcp__billing__send_payment_request": "mcp__billing"}),
3125 );
3126
3127 let claude_request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
3129
3130 for request in [&request, &claude_request] {
3131 for name in ["send_payment_request", "mcp__billing__send_payment_request"] {
3132 let semantics = ToolSemantics {
3133 mutate: vec![name.to_string()],
3134 ..Default::default()
3135 };
3136 let signal = ToolSignals::from_request_with_semantics(request, None, &semantics);
3137 assert_eq!(signal.write_count, 1, "{name}");
3138 }
3139 }
3140 }
3141
3142 #[test]
3143 fn configured_tool_semantics_only_fold_ascii_case() {
3144 let semantics = ToolSemantics {
3145 observe: vec!["kb_search".to_string()],
3146 ..Default::default()
3147 };
3148
3149 assert_eq!(
3150 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
3151 ToolSemantic::Observe
3152 );
3153 assert_eq!(
3156 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
3157 ToolSemantic::Unknown
3158 );
3159 }
3160
3161 #[test]
3162 fn custom_semantics_preserve_builtin_unicode_lowercasing() {
3163 let semantics = ToolSemantics {
3164 observe: vec!["lookup_customer".to_string()],
3165 ..Default::default()
3166 };
3167
3168 assert_eq!(
3170 classify_tool_call_with_semantics("notebooKedit", None, &semantics),
3171 ToolSemantic::Mutate(MutationKind::Edit)
3172 );
3173 }
3174
3175 #[test]
3176 fn configured_semantics_never_replace_builtin_classifications() {
3177 let semantics = ToolSemantics {
3178 observe: vec!["lookup_customer".to_string()],
3179 mutate: vec!["send_payment".to_string()],
3180 plan: vec!["create_workflow".to_string()],
3181 new: vec!["send_message".to_string()],
3182 };
3183
3184 for name in WRITE_TOOL_NAMES {
3185 assert_eq!(
3186 classify_tool_call_with_semantics(name, None, &semantics),
3187 ToolSemantic::Mutate(MutationKind::Write),
3188 "write tool {name:?} changed classification"
3189 );
3190 }
3191 for name in EDIT_TOOL_NAMES {
3192 assert_eq!(
3193 classify_tool_call_with_semantics(name, None, &semantics),
3194 ToolSemantic::Mutate(MutationKind::Edit),
3195 "edit tool {name:?} changed classification"
3196 );
3197 }
3198 for name in READ_TOOL_NAMES {
3199 assert_eq!(
3200 classify_tool_call_with_semantics(name, None, &semantics),
3201 ToolSemantic::Observe,
3202 "read tool {name:?} changed classification"
3203 );
3204 }
3205 for name in PLAN_TOOL_NAMES {
3206 assert_eq!(
3207 classify_tool_call_with_semantics(name, None, &semantics),
3208 ToolSemantic::Plan,
3209 "plan tool {name:?} changed classification"
3210 );
3211 }
3212
3213 for (command, expected) in [
3214 ("cat /tmp/input", ToolSemantic::Observe),
3215 (
3216 "cat /tmp/input > /tmp/output",
3217 ToolSemantic::Mutate(MutationKind::Write),
3218 ),
3219 (
3220 "sed -i 's/a/b/' /tmp/file",
3221 ToolSemantic::Mutate(MutationKind::Edit),
3222 ),
3223 ("./run_tests.sh", ToolSemantic::Unknown),
3224 ] {
3225 assert_eq!(
3226 classify_tool_call_with_semantics("BASH", Some(command), &semantics),
3227 expected,
3228 "bash command {command:?} changed classification"
3229 );
3230 }
3231 }
3232
3233 #[test]
3234 fn configured_semantics_score_like_their_builtin_equivalents() {
3235 let semantics = ToolSemantics {
3236 observe: vec!["lookup_customer".to_string()],
3237 mutate: vec!["send_payment".to_string()],
3238 plan: vec!["create_workflow".to_string()],
3239 ..Default::default()
3240 };
3241
3242 for (builtin, configured) in [
3243 ("Read", "lookup_customer"),
3244 ("Write", "send_payment"),
3245 ("TodoWrite", "create_workflow"),
3246 ] {
3247 let messages_before_tool = || {
3248 vec![
3249 Message::text(Role::User, "start"),
3250 Message::text(Role::Assistant, "working"),
3251 Message::text(Role::User, "continue"),
3252 Message::text(Role::Assistant, "working"),
3253 Message::text(Role::User, "continue"),
3254 Message::text(Role::Assistant, "working"),
3255 Message::text(Role::User, "continue"),
3256 ]
3257 };
3258 let mut builtin_messages = messages_before_tool();
3259 builtin_messages.push(tc(builtin));
3260 let mut configured_messages = messages_before_tool();
3261 configured_messages.push(tc(configured));
3262
3263 let builtin_score = score_signal(&ToolSignals::from_request(
3264 &with_messages(builtin_messages),
3265 None,
3266 ));
3267 let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
3268 &with_messages(configured_messages),
3269 None,
3270 &semantics,
3271 ));
3272
3273 assert_ne!(
3274 builtin_score.score, 0.0,
3275 "the {builtin:?} control must exercise a scoring dimension"
3276 );
3277 assert_eq!(
3278 configured_score, builtin_score,
3279 "configured tool {configured:?} must score exactly like {builtin:?}"
3280 );
3281 }
3282 }
3283
3284 #[test]
3285 fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
3286 let duplicate = ToolSemantics {
3287 observe: vec!["lookup".to_string()],
3288 mutate: vec!["LOOKUP".to_string()],
3289 ..Default::default()
3290 };
3291 assert!(
3292 duplicate
3293 .validate()
3294 .expect_err("duplicate should fail")
3295 .to_string()
3296 .contains("appears in both")
3297 );
3298
3299 let builtin = ToolSemantics {
3300 new: vec!["write_file".to_string()],
3301 ..Default::default()
3302 };
3303 assert!(
3304 builtin
3305 .validate()
3306 .expect_err("built-in should fail")
3307 .to_string()
3308 .contains("built-in semantics")
3309 );
3310
3311 let empty = ToolSemantics {
3312 observe: vec![" \t".to_string()],
3313 ..Default::default()
3314 };
3315 assert!(
3316 empty
3317 .validate()
3318 .expect_err("empty name should fail")
3319 .to_string()
3320 .contains("empty tool name")
3321 );
3322 }
3323}