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 conversation = switchyard_translation::conversation(&request.llm_request);
1059 let messages = &conversation.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 responses: None,
1771 role: Role::Assistant,
1772 content: vec![ContentBlock::ToolCall(ToolCall {
1773 id: String::new(),
1774 name: name.to_string(),
1775 arguments: json!({}),
1776 })],
1777 }
1778 }
1779
1780 fn bash(command: &str) -> Message {
1782 Message {
1783 responses: None,
1784 role: Role::Assistant,
1785 content: vec![ContentBlock::ToolCall(ToolCall {
1786 id: String::new(),
1787 name: "Bash".to_string(),
1788 arguments: json!({"command": command}),
1789 })],
1790 }
1791 }
1792
1793 fn tr(text: &str) -> Message {
1795 Message {
1796 responses: None,
1797 role: Role::User,
1798 content: vec![ContentBlock::ToolResult(ToolResult {
1799 tool_call_id: String::new(),
1800 content: vec![ContentBlock::Text {
1801 responses: None,
1802 text: text.to_string(),
1803 }],
1804 is_error: None,
1805 })],
1806 }
1807 }
1808
1809 #[test]
1810 fn user_turn_signals_exclude_old_errors_and_keep_user_role_tool_results() {
1811 let mut user = Message::text(Role::User, "next");
1812 user.content.extend(tr("out of memory").content);
1813 let request = with_messages(vec![tr("out of memory"), user, tr("ok")]);
1814 let signals = ToolSignalProcessor::default().for_user_turn(&request);
1815 assert_eq!(signals.tool_result_count, 1);
1816 assert_eq!(signals.severity, 0.0);
1817 assert_eq!(signals.turn_depth, 2);
1818 }
1819
1820 #[test]
1821 fn clean_text_has_zero_severity() {
1822 let (sev, patterns) = classify_text("everything went fine");
1823 assert_eq!(sev, 0.0);
1824 assert!(patterns.is_empty());
1825 }
1826
1827 #[test]
1828 fn structured_tool_failures_affect_recovery_signals() {
1829 for (text, severity) in [
1830 (r#"{"output":"","exit_code":7,"error":null}"#, SOFT),
1831 (r#"{"output":"","exit_code":-1,"error":null}"#, SOFT),
1832 (r#"{"success":false,"error":"No matching text"}"#, HARD),
1833 (r#"{"success":false,"error":null}"#, HARD),
1834 (
1835 r#"{"error":"Overwrite refused","stale_write_blocked":true}"#,
1836 HARD,
1837 ),
1838 (r#"{"output":"out of memory","exit_code":1}"#, CRITICAL),
1839 (r#"{"output":"done","exit_code":0,"error":null}"#, 0.0),
1840 (r#"{"success":true,"error":" "}"#, 0.0),
1841 (r#"{"output":"running","exit_code":null,"error":null}"#, 0.0),
1842 (r#"{"output":{"success":false},"exit_code":0}"#, 0.0),
1843 ] {
1844 let warned = format!("{text}\n\n[Tool loop warning: repeated identical call]");
1845 let request = with_messages(vec![tr("5 passed in 0.12s"), tr(text), tr(&warned)]);
1846 let signal = ToolSignals::from_request(&request, None);
1847 let clean = severity == 0.0;
1848 assert_eq!(signal.severity, severity, "{text}");
1849 assert_eq!(signal.no_error_streak, if clean { 3 } else { 0 }, "{text}");
1850 assert_eq!(signal.repeated_failure, severity >= HARD, "{text}");
1851 assert_eq!(signal.tests_passed, clean, "{text}");
1852 }
1853 }
1854
1855 #[test]
1856 fn traceback_is_hard() {
1857 let (sev, patterns) = classify_text("Traceback (most recent call last):\n ValueError");
1858 assert_eq!(sev, HARD);
1859 assert!(patterns.contains(&"traceback".to_string()));
1860 }
1861
1862 #[test]
1863 fn oom_is_critical() {
1864 let (sev, _) = classify_text("Out of memory: kill process 1234");
1865 assert_eq!(sev, CRITICAL);
1866 }
1867
1868 #[test]
1869 fn connection_refused_is_hard() {
1870 let (severity, _) = classify_text("Connection refused on port 8000");
1871 assert_eq!(severity, HARD);
1872 }
1873
1874 #[test]
1875 fn repeated_failure_ignores_volatile_paths_and_numbers() {
1876 let request = with_messages(vec![
1877 tr("error[E0308]: mismatched types at /tmp/a/src/lib.rs:12"),
1878 tr("error[E0308]: mismatched types at /tmp/b/src/lib.rs:47"),
1879 ]);
1880 assert!(ToolSignals::from_request(&request, None).repeated_failure);
1881 }
1882
1883 #[test]
1884 fn different_failures_are_not_repeated() {
1885 let request = with_messages(vec![
1886 tr("error[E0308]: mismatched types"),
1887 tr("error[E0509]: cannot move out"),
1888 ]);
1889 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1890 }
1891
1892 #[test]
1893 fn one_material_failure_is_not_repeated() {
1894 let request = with_messages(vec![tr("Connection refused on port 8000")]);
1895 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1896 }
1897
1898 #[test]
1900 fn structured_tool_failures_feed_error_and_recovery_signals() {
1901 for text in ["Dependency unavailable", "", "5 passed in 0.12s"] {
1902 let mut failed = tr(text);
1903 let ContentBlock::ToolResult(result) = &mut failed.content[0] else {
1904 panic!("expected tool result");
1905 };
1906 result.is_error = Some(true);
1907 let mut request = with_messages(vec![tr("5 passed in 0.12s"), failed.clone()]);
1908 let signals = ToolSignals::from_request(&request, Some(3));
1909 assert_eq!(signals.severity, HARD);
1910 assert!(!signals.repeated_failure);
1911 assert_eq!(signals.no_error_streak, 0);
1912 assert!(!signals.tests_passed);
1913
1914 request.llm_request.messages.push(failed);
1915 assert!(ToolSignals::from_request(&request, Some(3)).repeated_failure);
1916 request.llm_request.messages.push(tr("5 passed in 0.12s"));
1917 let recovered = ToolSignals::from_request(&request, Some(1));
1918 assert_eq!(recovered.severity, 0.0);
1919 assert!(!recovered.repeated_failure);
1920 assert_eq!(recovered.no_error_streak, 1);
1921 assert!(recovered.tests_passed);
1922 }
1923 }
1924
1925 #[test]
1926 fn severity_is_max_across_patterns() {
1927 let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
1929 assert_eq!(sev, HARD);
1930 }
1931
1932 #[test]
1933 fn codex_process_exit_zero_stays_clean() {
1934 let (sev, patterns) =
1935 classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
1936 assert_eq!(sev, 0.0);
1937 assert!(!patterns.contains(&"exit_nonzero".to_string()));
1938 }
1939
1940 #[test]
1941 fn nonzero_exit_codes_are_soft_errors() {
1942 let cases = [
1943 "Process exited with code 1",
1944 "Process exited with code 127",
1945 "exit code: 2",
1946 "exit status 3",
1947 "exited with status 9",
1948 ];
1949 for case in cases {
1950 let (sev, patterns) = classify_text(case);
1951 assert_eq!(sev, SOFT, "expected soft severity for {case}");
1952 assert!(patterns.contains(&"exit_nonzero".to_string()));
1953 }
1954 }
1955
1956 #[test]
1957 fn partial_process_failures_are_hard_errors() {
1958 let cases = [
1959 (
1960 "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
1961 "compile_error",
1962 ),
1963 (
1964 "Process exited with code 0\nOutput:\nTypeError: value is undefined\n at main.js:1:2",
1965 "runtime_exception",
1966 ),
1967 (
1968 "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
1969 "runtime_panic",
1970 ),
1971 (
1972 "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
1973 "patch_error",
1974 ),
1975 ];
1976 for (text, expected_pattern) in cases {
1977 let (severity, patterns) = classify_text(text);
1978 assert_eq!(severity, HARD, "expected hard severity for {text}");
1979 assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
1980 }
1981 }
1982
1983 #[test]
1986 fn trimmed_command_not_found_is_still_hard() {
1987 let cases = [
1988 "git: not found",
1989 "sh: 1: git: not found",
1990 "/bin/sh: 1: git: not found",
1991 ];
1992 for text in cases {
1993 let (sev, patterns) = classify_text(text);
1994 assert_eq!(sev, HARD, "expected hard severity for {text}");
1995 assert!(patterns.contains(&"cmd_not_found".to_string()));
1996 }
1997 }
1998
1999 #[test]
2002 fn not_found_mid_sentence_stays_clean() {
2003 let (sev, _) =
2004 classify_text("Looking for config key, not found in environment, using default.");
2005 assert_eq!(sev, 0.0);
2006 }
2007
2008 #[test]
2011 fn not_found_without_shell_separator_stays_clean() {
2012 for text in [
2013 "HTTP/1.1 404 Not Found",
2014 "lookup finished, key not found",
2015 "GET /healthz -> 404 Not Found",
2016 ] {
2017 let (sev, patterns) = classify_text(text);
2018 assert_eq!(sev, 0.0, "expected clean classification for {text}");
2019 assert!(!patterns.contains(&"cmd_not_found".to_string()));
2020 }
2021 }
2022
2023 #[test]
2024 fn source_text_that_names_exceptions_stays_clean() {
2025 let text =
2026 "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
2027 assert_eq!(classify_text(text).0, 0.0);
2028 }
2029
2030 #[test]
2031 fn file_does_not_exist_is_hard() {
2032 let (sev, patterns) =
2034 classify_text("Error: File does not exist. Note: current working directory is /app.");
2035 assert_eq!(sev, HARD);
2036 assert!(patterns.contains(&"no_such_file".to_string()));
2037 }
2038
2039 #[test]
2040 fn bare_does_not_exist_stays_clean() {
2041 let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
2044 assert_eq!(sev, 0.0);
2045 }
2046
2047 #[test]
2048 fn no_error_streak_all_clean() {
2049 let texts = vec![("ok".to_string(), false), ("all good".to_string(), false)];
2050 assert_eq!(compute_no_error_streak(&texts), 2);
2051 }
2052
2053 #[test]
2054 fn no_error_streak_stops_at_error() {
2055 let texts = vec![
2056 ("Traceback (most recent call last):".to_string(), false),
2057 ("ok".to_string(), false),
2058 ("ok".to_string(), false),
2059 ];
2060 assert_eq!(compute_no_error_streak(&texts), 2);
2061 }
2062
2063 #[test]
2064 fn tests_passed_detects_pytest_output() {
2065 assert!(detect_tests_passed(
2066 &[("====== 5 passed in 0.12s ======".to_string(), false)],
2067 DEFAULT_RECENT_WINDOW
2068 ));
2069 }
2070
2071 #[test]
2072 fn tests_passed_ignores_partial_failures() {
2073 assert!(!detect_tests_passed(
2074 &[("2 failed, 5 passed in 0.56s".to_string(), false)],
2075 DEFAULT_RECENT_WINDOW
2076 ));
2077 }
2078
2079 #[test]
2080 fn tests_passed_must_follow_the_latest_failure() {
2081 assert!(!detect_tests_passed(
2082 &[
2083 ("5 passed in 0.12s".to_string(), false),
2084 (
2085 "Traceback (most recent call last):\nValueError".to_string(),
2086 false
2087 ),
2088 ("edit applied".to_string(), false),
2089 ],
2090 DEFAULT_RECENT_WINDOW
2091 ));
2092 assert!(detect_tests_passed(
2093 &[
2094 (
2095 "Traceback (most recent call last):\nValueError".to_string(),
2096 false
2097 ),
2098 ("5 passed in 0.12s".to_string(), false),
2099 ],
2100 DEFAULT_RECENT_WINDOW
2101 ));
2102 }
2103
2104 #[test]
2105 fn retrieved_file_contents_are_ignored() {
2106 let call = |id: &str, name: &str, arguments: Value| Message {
2107 responses: None,
2108 role: Role::Assistant,
2109 content: vec![ContentBlock::ToolCall(ToolCall {
2110 id: id.to_string(),
2111 name: name.to_string(),
2112 arguments,
2113 })],
2114 };
2115 let result = |id: &str, text: &str| Message {
2116 responses: None,
2117 role: Role::User,
2118 content: vec![ContentBlock::ToolResult(ToolResult {
2119 tool_call_id: id.to_string(),
2120 content: vec![ContentBlock::Text {
2121 responses: None,
2122 text: text.to_string(),
2123 }],
2124 is_error: None,
2125 })],
2126 };
2127 let signal = extract_tool_signals_with_window(
2128 &with_messages(vec![
2129 call("a", "Bash", json!({"command": "pytest"})),
2130 result("a", "Traceback (most recent call last):\nValueError"),
2131 call("b", "Read", json!({"file_path": "notes.md"})),
2132 result(
2133 "b",
2134 r#"{"success":false,"error":"out of memory","exit_code":7}"#,
2135 ),
2136 call("c", "Grep", json!({"pattern": "passed"})),
2137 result("c", "CHANGELOG.md: all tests passed"),
2138 ]),
2139 DEFAULT_RECENT_WINDOW,
2140 );
2141 assert_eq!(signal.severity, HARD);
2143 assert!(!signal.tests_passed);
2144 assert_eq!(signal.tool_result_count, 3);
2145
2146 for (command, text, severity) in [
2147 ("cat config.json", r#"{"error":"fixture data"}"#, 0.0),
2148 (
2149 "cat missing.json",
2150 r#"{"output":"","exit_code":7,"error":null}"#,
2151 SOFT,
2152 ),
2153 (
2154 "cat config.json && python check.py",
2155 r#"{"error":"check rejected"}"#,
2156 HARD,
2157 ),
2158 ] {
2159 let request = with_messages(vec![
2160 call("shell", "Bash", json!({"command": command})),
2161 result("shell", text),
2162 ]);
2163 assert_eq!(
2164 ToolSignals::from_request(&request, None).severity,
2165 severity,
2166 "{text}"
2167 );
2168 }
2169 }
2170
2171 fn call_with_result(mut call: Message, mut result: Message) -> Request {
2173 if let ContentBlock::ToolCall(call) = &mut call.content[0] {
2174 call.id = "call-1".into();
2175 }
2176 if let ContentBlock::ToolResult(result) = &mut result.content[0] {
2177 result.tool_call_id = "call-1".into();
2178 }
2179 with_messages(vec![call, result])
2180 }
2181
2182 #[test]
2183 fn shell_reads_are_ignored_but_keep_their_window_slot() {
2184 for command in [
2185 "/usr/bin/cat logfile.txt",
2186 "cd /repo && tail -n 100 logfile.txt | grep MemoryError",
2187 "env MODE=debug timeout 5s rg 'MemoryError' .",
2188 ] {
2189 let mut request = call_with_result(bash(command), tr("MemoryError\nall tests passed"));
2190 request.llm_request.messages.splice(
2191 0..0,
2192 [bash("pytest"), tr("Traceback (most recent call last):")],
2193 );
2194 let signal = ToolSignals::from_request(&request, Some(2));
2196 assert_eq!(signal.severity, HARD, "{command}");
2197 assert!(!signal.tests_passed, "{command}");
2198 let signal = ToolSignals::from_request(&request, Some(1));
2200 assert_eq!(signal.severity, 0.0, "{command}");
2201 }
2202 }
2203
2204 #[test]
2205 fn shell_commands_that_run_code_keep_signals() {
2206 let oversized = format!("cat {}", "a".repeat(MAX_RETRIEVAL_COMMAND_BYTES));
2207 for command in [
2208 "cat logfile.txt; pytest",
2209 "echo $(pytest)",
2210 "find . -name '*.py' -exec pytest ';'",
2211 oversized.as_str(),
2212 ] {
2213 let request = call_with_result(bash(command), tr("MemoryError"));
2214 let signal = ToolSignals::from_request(&request, None);
2215 assert_eq!(signal.severity, CRITICAL, "{command}");
2216 }
2217 }
2218
2219 #[test]
2220 fn failed_reads_signal_unless_the_file_is_missing() {
2221 let failed = |text: &str| {
2222 let mut result = tr(text);
2223 if let ContentBlock::ToolResult(result) = &mut result.content[0] {
2224 result.is_error = Some(true);
2225 }
2226 result
2227 };
2228 let missing = "missing.txt: No such file or directory";
2229 let severities = [
2230 call_with_result(bash("cat missing.txt"), failed(missing)),
2231 call_with_result(tc("Read"), failed("File does not exist.")),
2232 call_with_result(bash("cat logfile.txt"), failed("MemoryError")),
2234 call_with_result(bash("python missing.txt"), failed(missing)),
2236 ]
2237 .map(|request| ToolSignals::from_request(&request, None).severity);
2238 assert_eq!(severities, [0.0, 0.0, CRITICAL, HARD]);
2239
2240 let request = call_with_result(
2241 bash("cat missing.json"),
2242 tr(r#"{"output":"No such file or directory","exit_code":1}"#),
2243 );
2244 assert_eq!(ToolSignals::from_request(&request, None).severity, 0.0);
2245 }
2246
2247 #[test]
2248 fn codex_read_status_comes_from_the_header() {
2249 let read = |header: &str| {
2250 let output = format!(
2251 "{header}\nOutput:\nTraceback (most recent call last):\nMemoryError\nProcess exited with code 1\n"
2252 );
2253 let call = exec_command(json!({"cmd": "cat run.log"}));
2254 ToolSignals::from_request(&call_with_result(call, tr(&output)), None).severity
2255 };
2256 assert_eq!(read("Chunk ID: abc\nProcess exited with code 0"), 0.0);
2258 assert_eq!(read("Chunk ID: abc\nProcess exited with code 1"), CRITICAL);
2260 }
2261
2262 #[test]
2263 fn hermes_read_status_comes_from_exit_code() {
2264 let read = |exit_code: i64| {
2265 let output = json!({
2266 "output": "Traceback (most recent call last):\nMemoryError\nProcess exited with code 1\n",
2267 "exit_code": exit_code,
2268 "error": null,
2269 });
2270 let call = Message {
2271 responses: None,
2272 role: Role::Assistant,
2273 content: vec![ContentBlock::ToolCall(ToolCall {
2274 id: String::new(),
2275 name: "terminal".to_string(),
2276 arguments: json!({"command": "cat run.log"}),
2277 })],
2278 };
2279 ToolSignals::from_request(&call_with_result(call, tr(&output.to_string())), None)
2280 .severity
2281 };
2282 assert_eq!(read(0), 0.0);
2284 assert_eq!(read(1), CRITICAL);
2286 }
2287
2288 #[test]
2289 fn argv_commands_keep_word_boundaries() {
2290 let signal = |argv: Value| {
2291 let call = exec_command(json!({"cmd": argv}));
2292 ToolSignals::from_request(&call_with_result(call, tr("MemoryError")), None)
2293 };
2294 let mut many_args = vec![""; MAX_RETRIEVAL_COMMAND_BYTES];
2295 many_args[0] = "cat";
2296 let severities = [
2297 json!(["bash", "-lc", "cd repo && rg foo"]),
2298 json!(["bash", "-lc", "cat log; pytest"]),
2299 json!(["cat", "log; pytest"]),
2301 json!(["cat", "a".repeat(MAX_RETRIEVAL_COMMAND_BYTES)]),
2303 json!(many_args),
2304 ]
2305 .map(|argv| signal(argv).severity);
2306 assert_eq!(severities, [0.0, CRITICAL, 0.0, CRITICAL, CRITICAL]);
2307 let read = signal(json!(["bash", "-lc", "sed -n 1,200p x"]));
2309 assert_eq!(read.read_count, 1);
2310 let write = signal(json!(["bash", "-lc", "cd repo && mkdir x"]));
2312 assert_eq!(write.write_count, 1);
2313 }
2314
2315 #[test]
2316 fn severity_is_windowed_over_recent_results() {
2317 let request = with_messages(vec![
2319 tr("Traceback (most recent call last):\n ValueError"),
2320 tr("ok"),
2321 tr("ok"),
2322 ]);
2323 assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
2325 assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
2327 }
2328
2329 #[test]
2330 fn extract_openai_chat_tool_results() {
2331 let request = with_messages(vec![
2332 Message::text(Role::User, "do something"),
2333 tc("Edit"),
2334 tr("Traceback (most recent call last):\n ValueError"),
2335 ]);
2336 let sig = ToolSignals::from_request(&request, None);
2337 assert_eq!(sig.severity, HARD);
2338 assert_eq!(sig.edit_count, 1);
2339 assert_eq!(sig.turn_depth, 3);
2340 }
2341
2342 #[test]
2343 fn extract_anthropic_tool_results() {
2344 let request = with_messages(vec![tr("Traceback (most recent call last):\n ValueError")]);
2345 let sig = ToolSignals::from_request(&request, None);
2346 assert_eq!(sig.severity, HARD);
2347 }
2348
2349 #[test]
2350 fn extract_responses_api_tool_results() {
2351 let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
2352 let sig = ToolSignals::from_request(&request, None);
2353 assert_eq!(sig.severity, 0.0);
2354 assert_eq!(sig.write_count, 1);
2355 }
2356
2357 #[test]
2358 fn responses_builtin_tool_failures_escalate() {
2359 use crate::algorithms::util::stage::{PickOutcome, PickerMode, Tier, pick_tier};
2360
2361 let mut cases = Vec::new();
2362 for (status, output) in [
2363 (
2364 "failed",
2365 "Synthetic dependency unavailable; retry with the recovery path.",
2366 ),
2367 (
2368 "completed",
2369 "Synthetic dependency unavailable; retry with the recovery path.",
2370 ),
2371 ("failed", ""),
2372 ] {
2373 cases.push((
2374 json!({
2375 "type": "apply_patch_call_output",
2376 "status": status,
2377 "output": output,
2378 }),
2379 if status == "failed" { HARD } else { 0.0 },
2380 ));
2381 }
2382 for (outcome, stdout, stderr, severity) in [
2383 (json!({"type": "exit", "exit_code": 1}), "", "", SOFT),
2384 (json!({"type": "exit", "exit_code": 1}), "", " \n", SOFT),
2385 (
2386 json!({"type": "exit", "exit_code": 1}),
2387 "",
2388 "command failed",
2389 HARD,
2390 ),
2391 (json!({"type": "timeout"}), "", "", HARD),
2392 (json!({"type": "exit", "exit_code": 0}), "done", "", 0.0),
2393 (
2394 json!({"type": "exit", "exit_code": 0}),
2395 "Traceback (most recent call last):",
2396 "",
2397 HARD,
2398 ),
2399 (
2400 json!({"type": "exit", "exit_code": 0}),
2401 "",
2402 "Traceback (most recent call last):",
2403 HARD,
2404 ),
2405 ] {
2406 cases.push((
2407 json!({
2408 "type": "shell_call_output",
2409 "output": [
2410 {"stdout": stdout, "stderr": stderr, "outcome": outcome},
2411 {"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}
2412 ],
2413 }),
2414 severity,
2415 ));
2416 }
2417 for (raw, severity) in cases {
2418 let is_error = severity >= HARD;
2419 let mut request = with_messages(
2420 ["call_1", "call_2"]
2421 .into_iter()
2422 .map(|call_id| {
2423 let mut raw = raw.clone();
2424 raw["call_id"] = json!(call_id);
2425 Message {
2426 responses: None,
2427 role: Role::User,
2428 content: vec![ContentBlock::Unknown {
2429 provider: WireFormat::OpenAiResponses.into(),
2430 raw,
2431 }],
2432 }
2433 })
2434 .collect(),
2435 );
2436 let signal = ToolSignals::from_request(&request, Some(3));
2437 assert_eq!(signal.severity, severity, "{raw}");
2438 assert_eq!(signal.repeated_failure, is_error, "{raw}");
2439 assert_eq!(signal.tool_result_count, 2);
2440 assert_eq!(
2441 matches!(
2442 pick_tier(&signal, PickerMode::EfficientFirst, 0.5),
2443 PickOutcome::Resolved {
2444 tier: Tier::Capable,
2445 ..
2446 }
2447 ),
2448 is_error,
2449 "{raw}"
2450 );
2451
2452 let mut success = match raw["type"].as_str() {
2453 Some("apply_patch_call_output") => json!({
2454 "type": "apply_patch_call_output", "status": "completed", "output": ""
2455 }),
2456 Some("shell_call_output") => json!({
2457 "type": "shell_call_output",
2458 "output": [{"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}]
2459 }),
2460 _ => unreachable!(),
2461 };
2462 for index in 0..3 {
2463 success["call_id"] = json!(format!("success_{index}"));
2464 request.llm_request.messages.push(Message {
2465 responses: None,
2466 role: Role::User,
2467 content: vec![ContentBlock::Unknown {
2468 provider: WireFormat::OpenAiResponses.into(),
2469 raw: success.clone(),
2470 }],
2471 });
2472 }
2473 let recovered = ToolSignals::from_request(&request, Some(3));
2474 assert_eq!(recovered.severity, 0.0, "{raw}");
2475 assert!(!recovered.repeated_failure, "{raw}");
2476 assert!(recovered.no_error_streak >= 3, "{raw}");
2477 assert_eq!(recovered.tool_result_count, 5);
2478 }
2479 }
2480
2481 #[test]
2482 fn conversation_counts_are_per_block_and_role_aware() {
2483 let result = |content: Vec<ContentBlock>| {
2487 ContentBlock::ToolResult(ToolResult {
2488 tool_call_id: String::new(),
2489 content,
2490 is_error: None,
2491 })
2492 };
2493 let request = with_messages(vec![
2494 Message::text(Role::User, "do something"),
2495 Message::text(Role::Assistant, "working"),
2496 Message {
2497 responses: None,
2498 role: Role::User,
2499 content: vec![
2500 result(vec![ContentBlock::Text {
2501 responses: None,
2502 text: "ok".to_string(),
2503 }]),
2504 result(Vec::new()),
2505 ],
2506 },
2507 tc("Bash"),
2508 ]);
2509 let sig = ToolSignals::from_request(&request, None);
2510 assert_eq!(sig.tool_result_count, 2);
2511 assert_eq!(sig.assistant_turn_count, 2);
2512 assert_eq!(sig.turn_depth, 4);
2513 }
2514
2515 #[test]
2516 fn recent_window_counts_only_last_default_window_tool_calls() {
2517 let request = with_messages(vec![
2520 tc("Write"),
2521 tr("ok"),
2522 tc("Write"),
2523 tr("ok"),
2524 tc("Write"),
2525 tr("ok"),
2526 tc("Write"),
2527 tr("ok"),
2528 tc("Write"),
2529 tr("ok"),
2530 tc("Edit"),
2531 tr("ok"),
2532 ]);
2533 let sig = ToolSignals::from_request(&request, None);
2534 assert_eq!(sig.write_count, 5);
2535 assert_eq!(sig.edit_count, 1);
2536 assert_eq!(sig.recent_write_count, 2);
2537 assert_eq!(sig.recent_edit_count, 1);
2538 }
2539
2540 #[test]
2541 fn codex_apply_patch_counts_as_an_edit() {
2542 let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
2543 let sig = ToolSignals::from_request(&request, None);
2544 assert_eq!(sig.edit_count, 1);
2545 assert_eq!(sig.recent_edit_count, 1);
2546 }
2547
2548 fn exec_command(cmd: Value) -> Message {
2549 Message {
2550 responses: None,
2551 role: Role::Assistant,
2552 content: vec![ContentBlock::ToolCall(ToolCall {
2553 id: String::new(),
2554 name: "exec_command".to_string(),
2555 arguments: cmd,
2556 })],
2557 }
2558 }
2559
2560 #[test]
2561 fn codex_exec_command_is_classified() {
2562 let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
2564 let request = with_messages(vec![exec_command(args), tr("ok")]);
2565 assert_eq!(
2566 ToolSignals::from_request(&request, None).recent_edit_count,
2567 1
2568 );
2569 }
2570
2571 #[test]
2572 fn python_write_expressions_need_a_python_command() {
2573 let write = with_messages(vec![
2574 exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
2575 tr("ok"),
2576 ]);
2577 assert_eq!(
2578 ToolSignals::from_request(&write, None).recent_write_count,
2579 1
2580 );
2581
2582 let search = with_messages(vec![
2583 exec_command(json!({"cmd": "grep -R '.write(' src"})),
2584 tr("ok"),
2585 ]);
2586 assert_eq!(
2587 ToolSignals::from_request(&search, None).recent_write_count,
2588 0
2589 );
2590 }
2591
2592 #[test]
2593 fn recent_window_size_is_caller_overridable() {
2594 let request = with_messages(vec![
2598 tc("Write"),
2599 tr("ok"),
2600 tc("Write"),
2601 tr("ok"),
2602 tc("Write"),
2603 tr("ok"),
2604 tc("Write"),
2605 tr("ok"),
2606 tc("Write"),
2607 tr("ok"),
2608 tc("Edit"),
2609 tr("ok"),
2610 ]);
2611 let narrow = extract_tool_signals_with_window(&request, 3);
2612 assert_eq!(narrow.recent_write_count, 2);
2613 assert_eq!(narrow.recent_edit_count, 1);
2614
2615 let wide = extract_tool_signals_with_window(&request, 6);
2616 assert_eq!(wide.recent_write_count, 5);
2617 assert_eq!(wide.recent_edit_count, 1);
2618 }
2619
2620 #[test]
2621 fn compaction_marker_sets_compacted() {
2622 let request = with_messages(vec![
2624 Message::text(
2625 Role::User,
2626 "This session is being continued from a previous conversation that ran out of context.",
2627 ),
2628 Message::text(Role::User, "Continue with the next task"),
2629 bash("ls"),
2630 ]);
2631 assert!(ToolSignals::from_request(&request, None).compacted);
2632 assert!(
2633 ToolSignalProcessor::default()
2634 .for_user_turn(&request)
2635 .compacted
2636 );
2637 }
2638
2639 #[test]
2640 fn hermes_compaction_headers_set_compacted() {
2641 for header in [
2642 "[CONTEXT COMPACTION \u{2014} REFERENCE ONLY] Earlier turns were compacted into the summary below.",
2643 "[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.]",
2644 ] {
2645 for role in [Role::User, Role::Assistant] {
2646 let request = with_messages(vec![
2647 Message::text(role, format!("{header}\nContinue the task.")),
2648 bash("ls"),
2649 ]);
2650 assert!(ToolSignals::from_request(&request, None).compacted);
2651 }
2652 let request = with_messages(vec![Message::text(
2653 Role::User,
2654 format!("Prior context.\n\n {header}\nContinue the task."),
2655 )]);
2656 assert!(ToolSignals::from_request(&request, None).compacted);
2657 let request = with_messages(vec![tr(header)]);
2658 assert!(!ToolSignals::from_request(&request, None).compacted);
2659 }
2660 }
2661
2662 #[test]
2663 fn ordinary_compaction_text_stays_uncompacted() {
2664 for text in [
2665 "The task is still in progress after the compaction boundary.",
2666 "Explain [CONTEXT COMPACTION \u{2014} REFERENCE ONLY] in the docs.",
2667 "[STILL IN PROGRESS] Continue the task.",
2668 ] {
2669 let request = with_messages(vec![Message::text(Role::User, text)]);
2670 assert!(!ToolSignals::from_request(&request, None).compacted);
2671 }
2672 }
2673
2674 #[test]
2675 fn codex_compaction_metadata_stays_on_parent_route() {
2676 let mut request = with_messages(vec![bash("ls")]);
2677 request.metadata = Some(Metadata {
2678 is_subagent: true,
2679 agent_kind: Some("compact".to_string()),
2680 ..Default::default()
2681 });
2682 assert!(!ToolSignals::from_request(&request, None).compacted);
2683 }
2684
2685 #[test]
2686 fn no_compaction_marker_stays_uncompacted() {
2687 let request = with_messages(vec![
2688 Message::text(Role::User, "Write a script that parses the log file."),
2689 bash("ls"),
2690 ]);
2691 assert!(!ToolSignals::from_request(&request, None).compacted);
2692 }
2693
2694 #[test]
2695 fn bash_heredoc_counts_as_write() {
2696 let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
2698 let sig = ToolSignals::from_request(&request, None);
2699 assert_eq!(
2700 sig.write_count, 1,
2701 "Bash heredoc should bucket into write_count"
2702 );
2703 assert_eq!(sig.edit_count, 0);
2704 }
2705
2706 #[test]
2707 fn bash_sed_inplace_counts_as_edit() {
2708 let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
2709 let sig = ToolSignals::from_request(&request, None);
2710 assert_eq!(
2711 sig.edit_count, 1,
2712 "Bash sed -i should bucket into edit_count"
2713 );
2714 assert_eq!(sig.write_count, 0);
2715 }
2716
2717 #[test]
2718 fn bash_non_mutating_does_not_count() {
2719 let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
2721 let sig = ToolSignals::from_request(&request, None);
2722 assert_eq!(sig.write_count, 0);
2723 assert_eq!(sig.edit_count, 0);
2724 }
2725
2726 #[test]
2727 fn tests_passed_detects_pytest_with_failure_block() {
2728 assert!(!detect_tests_passed(
2730 &[("2 failed, 5 passed in 0.56s".to_string(), false)],
2731 DEFAULT_RECENT_WINDOW
2732 ));
2733 }
2734
2735 #[test]
2736 fn tests_passed_accepts_cargo_clean_summary() {
2737 assert!(detect_tests_passed(
2740 &[(
2741 "running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string(),
2742 false
2743 )],
2744 DEFAULT_RECENT_WINDOW
2745 ));
2746 }
2747
2748 #[test]
2749 fn tests_passed_rejects_cargo_real_failure() {
2750 assert!(!detect_tests_passed(
2752 &[(
2753 "running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string(),
2754 false
2755 )],
2756 DEFAULT_RECENT_WINDOW
2757 ));
2758 }
2759
2760 #[test]
2761 fn tests_passed_accepts_go_clean_summary() {
2762 assert!(detect_tests_passed(
2764 &[(
2765 "ok github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string(),
2766 false
2767 )],
2768 DEFAULT_RECENT_WINDOW
2769 ));
2770 }
2771
2772 #[test]
2773 fn tests_passed_accepts_pytest_zero_errors() {
2774 assert!(detect_tests_passed(
2776 &[("5 passed, 0 errors in 0.30s".to_string(), false)],
2777 DEFAULT_RECENT_WINDOW
2778 ));
2779 }
2780
2781 #[test]
2782 fn tests_passed_detects_diy_checkmark() {
2783 assert!(detect_tests_passed(
2784 &[("✓ all checks passed".to_string(), false)],
2785 DEFAULT_RECENT_WINDOW
2786 ));
2787 }
2788
2789 #[test]
2790 fn anthropic_bash_heredoc_extracts_command() {
2791 let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
2793 let sig = ToolSignals::from_request(&request, None);
2794 assert_eq!(
2795 sig.write_count, 1,
2796 "Anthropic Bash heredoc must also be detected"
2797 );
2798 }
2799
2800 #[test]
2801 fn recent_window_falls_back_to_full_history_when_short() {
2802 let request = with_messages(vec![tc("Write")]);
2803 let sig = ToolSignals::from_request(&request, None);
2804 assert_eq!(sig.recent_write_count, 1);
2805 assert_eq!(sig.recent_edit_count, 0);
2806 }
2807
2808 #[test]
2809 fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
2810 let request = with_messages(vec![tr("output ok"), tr("another ok")]);
2811 let sig = ToolSignals::from_request(&request, None);
2812 assert_eq!(sig.severity, 0.0);
2813 assert_eq!(sig.no_error_streak, 2);
2814 }
2815
2816 #[test]
2819 fn todowrite_classifies_as_plan() {
2820 assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
2821 assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
2822 }
2823
2824 #[test]
2825 fn codex_update_plan_classifies_as_plan() {
2826 assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
2827 }
2828
2829 #[test]
2830 fn codex_shell_command_runs_bash_pattern_match() {
2831 assert_eq!(
2833 classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
2834 ToolSemantic::Mutate(MutationKind::Write),
2835 );
2836 assert_eq!(
2838 classify_tool_call("shell_command", Some("ls /app")),
2839 ToolSemantic::Observe,
2840 );
2841 assert_eq!(
2843 classify_tool_call("shell_command", Some("./run_tests.sh")),
2844 ToolSemantic::Unknown,
2845 );
2846 }
2847
2848 #[test]
2849 fn text_editor_view_is_a_read() {
2850 for name in ["str_replace_based_edit_tool", "text_editor"] {
2851 assert_eq!(
2852 classify_tool_call(name, Some("view")),
2853 ToolSemantic::Observe
2854 );
2855 for command in [
2856 Some("create"),
2857 Some("insert"),
2858 Some("str_replace"),
2859 Some("undo_edit"),
2860 None,
2861 ] {
2862 assert_eq!(
2863 classify_tool_call(name, command),
2864 ToolSemantic::Mutate(MutationKind::Edit),
2865 );
2866 }
2867 }
2868
2869 let arguments = [
2870 json!({"command": "view", "path": "/app/main.py"}),
2871 json!(r#"{"command":"view","path":"/app/main.py"}"#),
2873 ];
2874 for arguments in arguments {
2875 let call = Message {
2876 responses: None,
2877 role: Role::Assistant,
2878 content: vec![ContentBlock::ToolCall(ToolCall {
2879 id: String::new(),
2880 name: "str_replace_based_edit_tool".to_string(),
2881 arguments,
2882 })],
2883 };
2884 let request = with_messages(vec![call, tr("print('hi')")]);
2885 let sig = ToolSignals::from_request(&request, None);
2886 assert_eq!(sig.read_count, 1);
2887 assert_eq!(sig.recent_read_count, 1);
2888 assert_eq!(sig.edit_count, 0);
2889 }
2890 }
2891
2892 #[test]
2893 fn read_tool_classifies_as_read() {
2894 assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
2895 assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
2896 }
2897
2898 #[test]
2899 fn hermes_tool_names_classify() {
2900 assert_eq!(
2902 classify_tool_call("write_file", None),
2903 ToolSemantic::Mutate(MutationKind::Write)
2904 );
2905 assert_eq!(
2906 classify_tool_call("patch", None),
2907 ToolSemantic::Mutate(MutationKind::Edit)
2908 );
2909 assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
2910 assert_eq!(
2911 classify_tool_call("search_files", None),
2912 ToolSemantic::Observe
2913 );
2914 assert_eq!(
2917 classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
2918 ToolSemantic::Mutate(MutationKind::Edit),
2919 );
2920 assert_eq!(
2921 classify_tool_call("terminal", Some("grep foo /app")),
2922 ToolSemantic::Observe,
2923 );
2924 assert_eq!(
2925 classify_tool_call("terminal", Some("./run_tests.sh")),
2926 ToolSemantic::Unknown,
2927 );
2928 }
2929
2930 #[test]
2931 fn bash_read_patterns_classify_as_read() {
2932 let cases = [
2933 "cat /etc/passwd",
2934 "grep foo bar.txt",
2935 "ls /app",
2936 "find . -name '*.py'",
2937 ];
2938 for cmd in cases {
2939 assert_eq!(
2940 classify_tool_call("Bash", Some(cmd)),
2941 ToolSemantic::Observe,
2942 "expected Read for {cmd}"
2943 );
2944 }
2945 }
2946
2947 #[test]
2948 fn codex_inspection_commands_classify_as_read() {
2949 let cases = [
2950 "sed -n '1,80p' src/lib.rs",
2951 "rg -n 'needle' src",
2952 "nl -ba src/lib.rs",
2953 "cat package.json",
2954 "jq '.scripts' package.json",
2955 "git status --short",
2956 "git log --oneline -5",
2957 "git show HEAD:src/lib.rs",
2958 "git branch --show-current",
2959 "git remote -v",
2960 "git config --get remote.origin.url",
2961 ];
2962 for command in cases {
2963 assert_eq!(
2964 classify_tool_call("exec_command", Some(command)),
2965 ToolSemantic::Observe,
2966 "expected Read for {command}"
2967 );
2968 }
2969 }
2970
2971 #[test]
2972 fn quoted_shell_separators_do_not_create_commands() {
2973 for command in ["rg 'foo|rm obsolete.rs'", "rg \"foo; rm obsolete.rs\""] {
2974 assert_eq!(
2975 classify_tool_call("exec_command", Some(command)),
2976 ToolSemantic::Observe,
2977 "quoted text must not be parsed as a command: {command}"
2978 );
2979 }
2980 }
2981
2982 #[test]
2983 fn codex_shell_mutations_classify_as_production() {
2984 let writes = [
2985 "cp source.rs destination.rs",
2986 "mkdir -p src/generated",
2987 "touch src/generated/mod.rs",
2988 "git show HEAD:file.rs > file.rs",
2989 "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
2990 ];
2991 for command in writes {
2992 assert_eq!(
2993 classify_tool_call("exec_command", Some(command)),
2994 ToolSemantic::Mutate(MutationKind::Write),
2995 "expected Write for {command}"
2996 );
2997 }
2998
2999 let edits = [
3000 "mv old.rs new.rs",
3001 "rm obsolete.rs",
3002 "gofmt -w main.go",
3003 "cargo fmt",
3004 "ruff check --fix src",
3005 "perl -0pi -e 's/old/new/' src/lib.rs",
3006 "npx prettier --write src/lib.ts",
3007 "uv run ruff format src",
3008 "git apply fix.patch",
3009 ];
3010 for command in edits {
3011 assert_eq!(
3012 classify_tool_call("exec_command", Some(command)),
3013 ToolSemantic::Mutate(MutationKind::Edit),
3014 "expected Edit for {command}"
3015 );
3016 }
3017 }
3018
3019 #[test]
3020 fn formatter_checks_are_not_edits() {
3021 for command in [
3022 "cargo fmt --check",
3023 "ruff format --check src",
3024 "black --check src",
3025 ] {
3026 assert_ne!(
3027 classify_tool_call("exec_command", Some(command)),
3028 ToolSemantic::Mutate(MutationKind::Edit),
3029 "read-only formatter check must not be Edit: {command}"
3030 );
3031 }
3032 }
3033
3034 #[test]
3035 fn embedded_comparison_is_not_a_shell_write() {
3036 let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
3037 assert_eq!(
3038 classify_tool_call("exec_command", Some(command)),
3039 ToolSemantic::Unknown
3040 );
3041 }
3042
3043 #[test]
3044 fn bash_write_precedence_over_read() {
3045 assert_eq!(
3048 classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
3049 ToolSemantic::Mutate(MutationKind::Write),
3050 );
3051 }
3052
3053 #[test]
3054 fn pure_bash_streak_counts_trailing_other() {
3055 let request = with_messages(vec![
3057 bash("make"),
3058 tr("ok"),
3059 bash("./configure"),
3060 tr("ok"),
3061 bash("make install"),
3062 tr("ok"),
3063 bash("./run.sh"),
3064 tr("ok"),
3065 bash("./test"),
3066 tr("ok"),
3067 ]);
3068 let sig = ToolSignals::from_request(&request, None);
3069 assert_eq!(sig.pure_bash_streak, 5);
3070 assert_eq!(sig.write_count, 0);
3071 assert_eq!(sig.read_count, 0);
3072 }
3073
3074 #[test]
3075 fn pure_bash_streak_resets_on_write() {
3076 let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
3077 let sig = ToolSignals::from_request(&request, None);
3078 assert_eq!(sig.pure_bash_streak, 0);
3079 assert_eq!(sig.write_count, 1);
3080 }
3081
3082 #[test]
3083 fn recent_window_tracks_todowrite_and_read() {
3084 let request = with_messages(vec![
3086 bash("make"),
3087 tr("ok"),
3088 tc("TodoWrite"),
3089 tr("ok"),
3090 tc("Read"),
3091 tr("ok"),
3092 tc("TodoWrite"),
3093 tr("ok"),
3094 ]);
3095 let sig = ToolSignals::from_request(&request, None);
3096 assert_eq!(sig.todowrite_count, 2);
3097 assert_eq!(sig.recent_todowrite_count, 2);
3098 assert_eq!(sig.read_count, 1);
3099 assert_eq!(sig.recent_read_count, 1);
3100 }
3101
3102 #[test]
3103 fn configured_tool_semantics_extend_the_builtin_vocabulary() {
3104 let semantics = ToolSemantics {
3105 observe: vec!["KB_search".to_string()],
3106 mutate: vec!["send_payment_request".to_string()],
3107 plan: vec!["create_research_plan".to_string()],
3108 new: vec!["send_message_to_user".to_string()],
3109 };
3110 semantics.validate().expect("valid additive semantics");
3111 let request = with_messages(vec![
3112 tc("Read"),
3113 tc("Write"),
3114 tc("TodoWrite"),
3115 tc("kb_SEARCH"),
3116 tc("send_payment_request"),
3117 tc("create_research_plan"),
3118 tc("send_message_to_user"),
3119 tc("unlisted_tool"),
3120 ]);
3121
3122 let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
3123
3124 assert_eq!(signal.read_count, 2);
3125 assert_eq!(signal.write_count, 2);
3126 assert_eq!(signal.todowrite_count, 2);
3127 assert_eq!(signal.new_count, 1);
3128 assert_eq!(signal.recent_new_count, 1);
3129 assert_eq!(signal.pure_bash_streak, 1);
3130 }
3131
3132 #[test]
3133 fn configured_tool_semantics_match_namespaced_and_mcp_tools() {
3134 let mut request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
3136 request.llm_request.extensions.fields.insert(
3137 TOOL_NAMESPACES_KEY.to_string(),
3138 json!({"mcp__billing__send_payment_request": "mcp__billing"}),
3139 );
3140
3141 let claude_request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
3143
3144 for request in [&request, &claude_request] {
3145 for name in ["send_payment_request", "mcp__billing__send_payment_request"] {
3146 let semantics = ToolSemantics {
3147 mutate: vec![name.to_string()],
3148 ..Default::default()
3149 };
3150 let signal = ToolSignals::from_request_with_semantics(request, None, &semantics);
3151 assert_eq!(signal.write_count, 1, "{name}");
3152 }
3153 }
3154 }
3155
3156 #[test]
3157 fn configured_tool_semantics_only_fold_ascii_case() {
3158 let semantics = ToolSemantics {
3159 observe: vec!["kb_search".to_string()],
3160 ..Default::default()
3161 };
3162
3163 assert_eq!(
3164 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
3165 ToolSemantic::Observe
3166 );
3167 assert_eq!(
3170 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
3171 ToolSemantic::Unknown
3172 );
3173 }
3174
3175 #[test]
3176 fn custom_semantics_preserve_builtin_unicode_lowercasing() {
3177 let semantics = ToolSemantics {
3178 observe: vec!["lookup_customer".to_string()],
3179 ..Default::default()
3180 };
3181
3182 assert_eq!(
3184 classify_tool_call_with_semantics("notebooKedit", None, &semantics),
3185 ToolSemantic::Mutate(MutationKind::Edit)
3186 );
3187 }
3188
3189 #[test]
3190 fn configured_semantics_never_replace_builtin_classifications() {
3191 let semantics = ToolSemantics {
3192 observe: vec!["lookup_customer".to_string()],
3193 mutate: vec!["send_payment".to_string()],
3194 plan: vec!["create_workflow".to_string()],
3195 new: vec!["send_message".to_string()],
3196 };
3197
3198 for name in WRITE_TOOL_NAMES {
3199 assert_eq!(
3200 classify_tool_call_with_semantics(name, None, &semantics),
3201 ToolSemantic::Mutate(MutationKind::Write),
3202 "write tool {name:?} changed classification"
3203 );
3204 }
3205 for name in EDIT_TOOL_NAMES {
3206 assert_eq!(
3207 classify_tool_call_with_semantics(name, None, &semantics),
3208 ToolSemantic::Mutate(MutationKind::Edit),
3209 "edit tool {name:?} changed classification"
3210 );
3211 }
3212 for name in READ_TOOL_NAMES {
3213 assert_eq!(
3214 classify_tool_call_with_semantics(name, None, &semantics),
3215 ToolSemantic::Observe,
3216 "read tool {name:?} changed classification"
3217 );
3218 }
3219 for name in PLAN_TOOL_NAMES {
3220 assert_eq!(
3221 classify_tool_call_with_semantics(name, None, &semantics),
3222 ToolSemantic::Plan,
3223 "plan tool {name:?} changed classification"
3224 );
3225 }
3226
3227 for (command, expected) in [
3228 ("cat /tmp/input", ToolSemantic::Observe),
3229 (
3230 "cat /tmp/input > /tmp/output",
3231 ToolSemantic::Mutate(MutationKind::Write),
3232 ),
3233 (
3234 "sed -i 's/a/b/' /tmp/file",
3235 ToolSemantic::Mutate(MutationKind::Edit),
3236 ),
3237 ("./run_tests.sh", ToolSemantic::Unknown),
3238 ] {
3239 assert_eq!(
3240 classify_tool_call_with_semantics("BASH", Some(command), &semantics),
3241 expected,
3242 "bash command {command:?} changed classification"
3243 );
3244 }
3245 }
3246
3247 #[test]
3248 fn configured_semantics_score_like_their_builtin_equivalents() {
3249 let semantics = ToolSemantics {
3250 observe: vec!["lookup_customer".to_string()],
3251 mutate: vec!["send_payment".to_string()],
3252 plan: vec!["create_workflow".to_string()],
3253 ..Default::default()
3254 };
3255
3256 for (builtin, configured) in [
3257 ("Read", "lookup_customer"),
3258 ("Write", "send_payment"),
3259 ("TodoWrite", "create_workflow"),
3260 ] {
3261 let messages_before_tool = || {
3262 vec![
3263 Message::text(Role::User, "start"),
3264 Message::text(Role::Assistant, "working"),
3265 Message::text(Role::User, "continue"),
3266 Message::text(Role::Assistant, "working"),
3267 Message::text(Role::User, "continue"),
3268 Message::text(Role::Assistant, "working"),
3269 Message::text(Role::User, "continue"),
3270 ]
3271 };
3272 let mut builtin_messages = messages_before_tool();
3273 builtin_messages.push(tc(builtin));
3274 let mut configured_messages = messages_before_tool();
3275 configured_messages.push(tc(configured));
3276
3277 let builtin_score = score_signal(&ToolSignals::from_request(
3278 &with_messages(builtin_messages),
3279 None,
3280 ));
3281 let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
3282 &with_messages(configured_messages),
3283 None,
3284 &semantics,
3285 ));
3286
3287 assert_ne!(
3288 builtin_score.score, 0.0,
3289 "the {builtin:?} control must exercise a scoring dimension"
3290 );
3291 assert_eq!(
3292 configured_score, builtin_score,
3293 "configured tool {configured:?} must score exactly like {builtin:?}"
3294 );
3295 }
3296 }
3297
3298 #[test]
3299 fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
3300 let duplicate = ToolSemantics {
3301 observe: vec!["lookup".to_string()],
3302 mutate: vec!["LOOKUP".to_string()],
3303 ..Default::default()
3304 };
3305 assert!(
3306 duplicate
3307 .validate()
3308 .expect_err("duplicate should fail")
3309 .to_string()
3310 .contains("appears in both")
3311 );
3312
3313 let builtin = ToolSemantics {
3314 new: vec!["write_file".to_string()],
3315 ..Default::default()
3316 };
3317 assert!(
3318 builtin
3319 .validate()
3320 .expect_err("built-in should fail")
3321 .to_string()
3322 .contains("built-in semantics")
3323 );
3324
3325 let empty = ToolSemantics {
3326 observe: vec![" \t".to_string()],
3327 ..Default::default()
3328 };
3329 assert!(
3330 empty
3331 .validate()
3332 .expect_err("empty name should fail")
3333 .to_string()
3334 .contains("empty tool name")
3335 );
3336 }
3337}