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