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