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