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