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<State> 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 shell_failed = shell_result.as_ref().is_some_and(|value| {
1143 value["exit_code"].as_i64().is_some_and(|code| code != 0)
1144 || value["success"].as_bool() == Some(false)
1145 || value["error"]
1146 .as_str()
1147 .is_some_and(|error| !error.trim().is_empty())
1148 }) || has_nonzero_exit_status(&text.to_lowercase());
1149 if is_missing_file(&text) || (!is_error && !shell_failed) {
1150 if !text.is_empty() {
1152 tool_texts.push((String::new(), false));
1153 }
1154 continue;
1155 }
1156 }
1157 if !text.is_empty() || is_error {
1159 tool_texts.push((text, is_error));
1160 }
1161 }
1162 ContentBlock::Unknown { provider, raw }
1164 if provider.as_str() == WireFormat::OpenAiResponses.as_str()
1165 && raw.get("type").and_then(Value::as_str)
1166 == Some("apply_patch_call_output") =>
1167 {
1168 tool_result_count += 1;
1169 let text = raw
1170 .get("output")
1171 .and_then(Value::as_str)
1172 .unwrap_or_default();
1173 let is_error = raw.get("status").and_then(Value::as_str) == Some("failed");
1174 tool_texts.push((text.to_owned(), is_error));
1175 }
1176 ContentBlock::Unknown { provider, raw }
1177 if provider.as_str() == WireFormat::OpenAiResponses.as_str()
1178 && raw.get("type").and_then(Value::as_str) == Some("shell_call_output") =>
1179 {
1180 tool_result_count += 1;
1181 let mut texts = Vec::new();
1182 let mut is_error = false;
1183 if let Some(outputs) = raw.get("output").and_then(Value::as_array) {
1184 for output in outputs {
1185 for field in ["stdout", "stderr"] {
1186 if let Some(text) = output.get(field).and_then(Value::as_str)
1187 && !text.is_empty()
1188 {
1189 texts.push(text);
1190 }
1191 }
1192 if let Some(outcome) = output.get("outcome") {
1193 is_error |= match outcome.get("type").and_then(Value::as_str) {
1194 Some("timeout") => true,
1195 Some("exit")
1196 if outcome
1197 .get("exit_code")
1198 .and_then(Value::as_i64)
1199 .is_some_and(|code| code != 0) =>
1200 {
1201 texts.push("returned non-zero");
1203 output
1204 .get("stderr")
1205 .and_then(Value::as_str)
1206 .is_some_and(|text| !text.trim().is_empty())
1207 }
1208 _ => false,
1209 };
1210 }
1211 }
1212 }
1213 let text = texts.join("\n");
1214 tool_texts.push((text, is_error));
1215 }
1216 ContentBlock::Text { text } => {
1220 let text = text.to_lowercase();
1221 compacted |= text.contains(COMPACTION_MARKER)
1222 || text.lines().any(|line| {
1223 HERMES_COMPACTION_MARKERS
1224 .iter()
1225 .any(|marker| line.trim_start().starts_with(marker))
1226 });
1227 }
1228 _ => {}
1229 }
1230 }
1231 }
1232
1233 let mut signal = build_signal(
1234 tool_texts,
1235 tool_calls,
1236 messages.len() as u32,
1237 recent_window,
1238 semantics,
1239 );
1240 signal.compacted = compacted;
1241 signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
1242 signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
1243 signal
1244}
1245
1246const COMPACTION_MARKER: &str = "session is being continued";
1249
1250const HERMES_COMPACTION_MARKERS: &[&str] = &[
1252 "[context compaction \u{2014} reference only]",
1253 "[still in progress \u{2014} this is the active request, restated after the compaction boundary",
1254];
1255
1256fn mcp_tool_name(name: &str) -> Option<&str> {
1260 let (_server, tool) = name.strip_prefix("mcp__")?.split_once("__")?;
1261 (!tool.is_empty()).then_some(tool)
1262}
1263
1264fn command_of(arguments: &Value) -> Option<&Value> {
1268 ["command", "cmd", "input"]
1269 .iter()
1270 .find_map(|key| arguments.get(*key))
1271}
1272
1273fn command_text(value: &Value) -> Option<String> {
1275 match value {
1276 Value::String(text) => Some(text.to_lowercase()),
1277 Value::Array(parts) => {
1278 let joined = parts
1279 .iter()
1280 .filter_map(Value::as_str)
1281 .collect::<Vec<_>>()
1282 .join(" ");
1283 (!joined.is_empty()).then(|| joined.to_lowercase())
1284 }
1285 _ => None,
1286 }
1287}
1288
1289fn text_of(block: &ContentBlock) -> Option<&str> {
1291 match block {
1292 ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
1293 _ => None,
1294 }
1295}
1296
1297fn is_missing_file(text: &str) -> bool {
1300 let lower = text.to_lowercase();
1301 lower.contains("no such file or directory") || lower.contains("file does not exist")
1303}
1304
1305fn build_signal(
1306 tool_texts: Vec<(String, bool)>,
1307 tool_calls: Vec<ObservedToolCall>,
1308 turn_depth: u32,
1309 recent_window: usize,
1310 semantics: &ToolSemantics,
1311) -> ToolSignals {
1312 let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
1318 let mut severity = 0.0f32;
1319 let mut failure_fingerprints = Vec::new();
1320 let mut repeated_failure = false;
1321 for (text, is_error) in &tool_texts[sev_start..] {
1322 let (sev, _patterns) = classify_text(text);
1323 let sev = if *is_error { sev.max(HARD) } else { sev };
1325 if sev > severity {
1326 severity = sev;
1327 }
1328 if let Some(fingerprint) = failure_fingerprint(text, *is_error) {
1329 repeated_failure |= failure_fingerprints.contains(&fingerprint);
1330 failure_fingerprints.push(fingerprint);
1331 }
1332 }
1333
1334 let no_error_streak = compute_no_error_streak(&tool_texts);
1335
1336 let recent_start = tool_calls.len().saturating_sub(recent_window);
1340 let mut write_count = 0u32;
1341 let mut edit_count = 0u32;
1342 let mut read_count = 0u32;
1343 let mut todowrite_count = 0u32;
1344 let mut recent_write_count = 0u32;
1345 let mut recent_edit_count = 0u32;
1346 let mut recent_read_count = 0u32;
1347 let mut recent_todowrite_count = 0u32;
1348 let mut new_count = 0u32;
1349 let mut recent_new_count = 0u32;
1350 let mut pure_bash_streak = 0u32;
1351 let mut streak_open = true;
1352 for (i, tc) in tool_calls.iter().enumerate().rev() {
1353 let mut cat = if let Some(semantic) = tc.code_semantic {
1357 semantic
1358 } else if tc.is_retrieval {
1359 ToolSemantic::Observe
1360 } else {
1361 classify_tool_call_with_semantics(&tc.name, tc.command.as_deref(), semantics)
1362 };
1363 if matches!(cat, ToolSemantic::Unknown)
1364 && tc.code_semantic.is_none()
1365 && let Some(bare_name) = tc.bare_name
1366 {
1367 cat = classify_tool_call_with_semantics(bare_name, tc.command.as_deref(), semantics);
1368 }
1369 if streak_open {
1370 if matches!(cat, ToolSemantic::Unknown) {
1371 pure_bash_streak += 1;
1372 } else {
1373 streak_open = false;
1374 }
1375 }
1376 match cat {
1377 ToolSemantic::Mutate(MutationKind::Write) => {
1378 write_count += 1;
1379 if i >= recent_start {
1380 recent_write_count += 1;
1381 }
1382 }
1383 ToolSemantic::Mutate(MutationKind::Edit) => {
1384 edit_count += 1;
1385 if i >= recent_start {
1386 recent_edit_count += 1;
1387 }
1388 }
1389 ToolSemantic::Observe => {
1390 read_count += 1;
1391 if i >= recent_start {
1392 recent_read_count += 1;
1393 }
1394 }
1395 ToolSemantic::Plan => {
1396 todowrite_count += 1;
1397 if i >= recent_start {
1398 recent_todowrite_count += 1;
1399 }
1400 }
1401 ToolSemantic::New => {
1402 new_count += 1;
1403 if i >= recent_start {
1404 recent_new_count += 1;
1405 }
1406 }
1407 ToolSemantic::Unknown => {}
1408 }
1409 }
1410
1411 let tests_passed = detect_tests_passed(&tool_texts, recent_window);
1412
1413 ToolSignals {
1414 severity,
1415 repeated_failure,
1416 no_error_streak,
1417 edit_count,
1418 write_count,
1419 read_count,
1420 todowrite_count,
1421 recent_edit_count,
1422 recent_write_count,
1423 recent_read_count,
1424 recent_todowrite_count,
1425 new_count,
1426 recent_new_count,
1427 pure_bash_streak,
1428 tests_passed,
1429 turn_depth,
1430 tool_result_count: 0,
1434 assistant_turn_count: 0,
1435 compacted: false,
1436 }
1437}
1438
1439fn content_to_text(content: Option<&Value>) -> Option<String> {
1443 match content? {
1444 Value::String(s) => Some(s.clone()),
1445 Value::Array(blocks) => {
1446 let parts: Vec<&str> = blocks
1447 .iter()
1448 .filter_map(|b| {
1449 b.as_object()
1450 .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
1451 .and_then(|o| o.get("text"))
1452 .and_then(Value::as_str)
1453 })
1454 .collect();
1455 if parts.is_empty() {
1456 None
1457 } else {
1458 Some(parts.join("\n"))
1459 }
1460 }
1461 _ => None,
1462 }
1463}
1464
1465pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
1469 let lower = text.to_lowercase();
1470 let mut patterns = Vec::new();
1471 let mut severity: f32 = 0.0;
1472 for (name, sev, substrings) in ERROR_PATTERNS {
1473 if substrings.iter().any(|sub| lower.contains(sub)) {
1474 patterns.push(name.to_string());
1475 severity = severity.max(*sev);
1476 }
1477 }
1478 let result = serde_json::Deserializer::from_str(text)
1480 .into_iter::<Value>()
1481 .next()
1482 .and_then(|result| result.ok())
1483 .unwrap_or_default();
1484 let nonzero_exit = result["exit_code"].as_i64().is_some_and(|code| code != 0);
1485 let tool_error = result["success"].as_bool() == Some(false)
1486 || result["error"]
1487 .as_str()
1488 .is_some_and(|error| !error.trim().is_empty());
1489 if (nonzero_exit || has_nonzero_exit_status(&lower))
1490 && !patterns.iter().any(|p| p == "exit_nonzero")
1491 {
1492 patterns.push("exit_nonzero".to_string());
1493 severity = severity.max(SOFT);
1494 }
1495 for (name, matched) in [
1496 ("tool_error", tool_error),
1497 ("compile_error", has_compiler_diagnostic(&lower)),
1498 ("runtime_exception", has_runtime_exception(&lower)),
1499 ("runtime_panic", has_runtime_panic(&lower)),
1500 ("patch_error", has_patch_failure(&lower)),
1501 ] {
1502 if matched && !patterns.iter().any(|pattern| pattern == name) {
1503 patterns.push(name.to_string());
1504 severity = severity.max(HARD);
1505 }
1506 }
1507 (severity, patterns)
1508}
1509
1510fn failure_fingerprint(text: &str, is_error: bool) -> Option<String> {
1513 let (severity, patterns) = classify_text(text);
1514 if severity < HARD && !is_error {
1515 return None;
1516 }
1517
1518 let lower = text.to_lowercase();
1519 let diagnostic = lower
1520 .lines()
1521 .find(|line| is_failure_diagnostic(line))
1522 .or_else(|| lower.lines().find(|line| !line.trim().is_empty()))
1523 .unwrap_or_default();
1524 let normalized = normalize_failure_text(diagnostic);
1525 Some(format!("{}|{normalized}", patterns.join(",")))
1526}
1527
1528fn is_failure_diagnostic(line: &str) -> bool {
1529 let line = line.trim();
1530 [
1531 "error",
1532 "exception",
1533 "panic",
1534 "failed",
1535 "timed out",
1536 "timeout",
1537 "connection refused",
1538 "cannot allocate memory",
1539 "out of memory",
1540 "not found",
1541 ]
1542 .iter()
1543 .any(|marker| line.contains(marker))
1544}
1545
1546fn normalize_failure_text(text: &str) -> String {
1549 let mut normalized = String::new();
1550 for word in text.split_whitespace() {
1551 if !normalized.is_empty() {
1552 normalized.push(' ');
1553 }
1554 let mut in_digits = false;
1555 if word.starts_with('/') || word.contains("/src/") || word.contains("/tmp/") {
1556 normalized.push_str("<path>");
1557 continue;
1558 }
1559 for character in word.chars() {
1560 if character.is_ascii_digit() {
1561 if !in_digits {
1562 normalized.push('#');
1563 in_digits = true;
1564 }
1565 } else {
1566 normalized.push(character);
1567 in_digits = false;
1568 }
1569 }
1570 }
1571 normalized.chars().take(240).collect()
1572}
1573
1574fn has_compiler_diagnostic(lower: &str) -> bool {
1575 lower.lines().any(|line| {
1576 let line = line.trim_start();
1577 if matches!(
1578 line,
1579 "compilation failed" | "error: compilation failed" | "error: could not compile"
1580 ) || line.starts_with("error: could not compile ")
1581 {
1582 return true;
1583 }
1584
1585 let Some(rest) = line.strip_prefix("error[e") else {
1586 return false;
1587 };
1588 let Some((code, _)) = rest.split_once("]:") else {
1589 return false;
1590 };
1591 !code.is_empty() && code.chars().all(|character| character.is_ascii_digit())
1592 })
1593}
1594
1595fn has_runtime_exception(lower: &str) -> bool {
1596 let has_exception_line = lower.lines().any(|line| {
1597 let line = line.trim_start();
1598 [
1599 "typeerror:",
1600 "referenceerror:",
1601 "rangeerror:",
1602 "runtimeerror:",
1603 "keyerror:",
1604 "attributeerror:",
1605 ]
1606 .iter()
1607 .any(|prefix| line.starts_with(prefix))
1608 });
1609 has_exception_line && (lower.contains("\n at ") || lower.contains("\n at "))
1610}
1611
1612fn has_runtime_panic(lower: &str) -> bool {
1613 lower
1614 .lines()
1615 .any(|line| line.trim_start().starts_with("panic: runtime error:"))
1616 && (lower.contains("\ngoroutine ") || lower.contains("[signal sig"))
1617}
1618
1619fn has_patch_failure(lower: &str) -> bool {
1620 lower.lines().any(|line| {
1621 let line = line.trim_start();
1622 line.starts_with("error: patch failed:")
1623 || line.starts_with("patch failed:")
1624 || line.contains(": patch does not apply")
1625 || line.starts_with("invalid context")
1626 })
1627}
1628
1629fn has_nonzero_exit_status(lower: &str) -> bool {
1635 NONZERO_EXIT_PHRASES
1636 .iter()
1637 .any(|phrase| phrase_followed_by_nonzero_integer(lower, phrase))
1638}
1639
1640fn phrase_followed_by_nonzero_integer(lower: &str, phrase: &str) -> bool {
1642 let mut cursor = 0usize;
1643 while let Some(rel) = lower[cursor..].find(phrase) {
1644 let value_start = cursor + rel + phrase.len();
1645 let rest = lower[value_start..].trim_start_matches(|c: char| {
1646 c.is_ascii_whitespace() || matches!(c, ':' | '=' | '\'' | '"' | '`')
1647 });
1648 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1649 if !digits.is_empty() && digits.chars().any(|d| d != '0') {
1650 return true;
1651 }
1652 cursor = value_start;
1653 }
1654 false
1655}
1656
1657fn compute_no_error_streak(tool_texts: &[(String, bool)]) -> u32 {
1658 let mut streak = 0u32;
1659 for (text, is_error) in tool_texts.iter().rev() {
1660 let (sev, _) = classify_text(text);
1661 if *is_error || sev > 0.0 {
1662 break;
1663 }
1664 streak += 1;
1665 }
1666 streak
1667}
1668
1669fn detect_tests_passed(tool_texts: &[(String, bool)], recent_window: usize) -> bool {
1670 let start = tool_texts.len().saturating_sub(recent_window.max(1));
1671 let recent = &tool_texts[start..];
1672 let after_latest_failure = recent
1673 .iter()
1674 .rposition(|(text, is_error)| *is_error || classify_text(text).0 > 0.0)
1675 .map_or(recent, |index| &recent[index + 1..]);
1676 after_latest_failure.iter().any(|(text, _)| {
1677 let lower = text.to_lowercase();
1678 TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
1679 && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
1680 && !has_nonzero_failure_count(&lower)
1681 })
1682}
1683
1684fn has_nonzero_failure_count(lower: &str) -> bool {
1690 for kw in NUMERIC_FAILURE_KEYWORDS {
1691 let mut cursor = 0usize;
1692 while let Some(rel) = lower[cursor..].find(kw) {
1693 let kw_start = cursor + rel;
1694 let kw_end = kw_start + kw.len();
1695 let boundary_after = lower[kw_end..]
1698 .chars()
1699 .next()
1700 .is_none_or(|c| !c.is_ascii_alphanumeric());
1701 if boundary_after {
1702 let prefix = &lower[..kw_start];
1703 let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
1704 let digits_rev: String = trimmed
1705 .chars()
1706 .rev()
1707 .take_while(|c| c.is_ascii_digit())
1708 .collect();
1709 if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
1710 return true;
1711 }
1712 }
1713 cursor = kw_start + kw.len();
1714 }
1715 }
1716 false
1717}
1718
1719#[cfg(test)]
1722mod tests {
1723 use super::*;
1724 use crate::algorithms::util::stage::score_signal;
1725 use serde_json::json;
1726 use switchyard_protocol::codex_namespaces::TOOL_NAMESPACES_KEY;
1727 use switchyard_protocol::{
1728 ContentBlock, LlmRequest, Message, Metadata, Role, ToolCall, ToolResult,
1729 };
1730
1731 fn with_messages(messages: Vec<Message>) -> Request {
1732 Request {
1733 llm_request: LlmRequest {
1734 messages,
1735 ..LlmRequest::default()
1736 },
1737 raw_request: None,
1738 metadata: None,
1739 }
1740 }
1741
1742 fn tc(name: &str) -> Message {
1744 Message {
1745 role: Role::Assistant,
1746 content: vec![ContentBlock::ToolCall(ToolCall {
1747 id: String::new(),
1748 name: name.to_string(),
1749 arguments: json!({}),
1750 })],
1751 }
1752 }
1753
1754 fn bash(command: &str) -> Message {
1756 Message {
1757 role: Role::Assistant,
1758 content: vec![ContentBlock::ToolCall(ToolCall {
1759 id: String::new(),
1760 name: "Bash".to_string(),
1761 arguments: json!({"command": command}),
1762 })],
1763 }
1764 }
1765
1766 fn tr(text: &str) -> Message {
1768 Message {
1769 role: Role::User,
1770 content: vec![ContentBlock::ToolResult(ToolResult {
1771 tool_call_id: String::new(),
1772 content: vec![ContentBlock::Text {
1773 text: text.to_string(),
1774 }],
1775 is_error: None,
1776 })],
1777 }
1778 }
1779
1780 #[test]
1781 fn clean_text_has_zero_severity() {
1782 let (sev, patterns) = classify_text("everything went fine");
1783 assert_eq!(sev, 0.0);
1784 assert!(patterns.is_empty());
1785 }
1786
1787 #[test]
1788 fn structured_tool_failures_affect_recovery_signals() {
1789 for (text, severity) in [
1790 (r#"{"output":"","exit_code":7,"error":null}"#, SOFT),
1791 (r#"{"output":"","exit_code":-1,"error":null}"#, SOFT),
1792 (r#"{"success":false,"error":"No matching text"}"#, HARD),
1793 (r#"{"success":false,"error":null}"#, HARD),
1794 (
1795 r#"{"error":"Overwrite refused","stale_write_blocked":true}"#,
1796 HARD,
1797 ),
1798 (r#"{"output":"out of memory","exit_code":1}"#, CRITICAL),
1799 (r#"{"output":"done","exit_code":0,"error":null}"#, 0.0),
1800 (r#"{"success":true,"error":" "}"#, 0.0),
1801 (r#"{"output":"running","exit_code":null,"error":null}"#, 0.0),
1802 (r#"{"output":{"success":false},"exit_code":0}"#, 0.0),
1803 ] {
1804 let warned = format!("{text}\n\n[Tool loop warning: repeated identical call]");
1805 let request = with_messages(vec![tr("5 passed in 0.12s"), tr(text), tr(&warned)]);
1806 let signal = ToolSignals::from_request(&request, None);
1807 let clean = severity == 0.0;
1808 assert_eq!(signal.severity, severity, "{text}");
1809 assert_eq!(signal.no_error_streak, if clean { 3 } else { 0 }, "{text}");
1810 assert_eq!(signal.repeated_failure, severity >= HARD, "{text}");
1811 assert_eq!(signal.tests_passed, clean, "{text}");
1812 }
1813 }
1814
1815 #[test]
1816 fn traceback_is_hard() {
1817 let (sev, patterns) = classify_text("Traceback (most recent call last):\n ValueError");
1818 assert_eq!(sev, HARD);
1819 assert!(patterns.contains(&"traceback".to_string()));
1820 }
1821
1822 #[test]
1823 fn oom_is_critical() {
1824 let (sev, _) = classify_text("Out of memory: kill process 1234");
1825 assert_eq!(sev, CRITICAL);
1826 }
1827
1828 #[test]
1829 fn connection_refused_is_hard() {
1830 let (severity, _) = classify_text("Connection refused on port 8000");
1831 assert_eq!(severity, HARD);
1832 }
1833
1834 #[test]
1835 fn repeated_failure_ignores_volatile_paths_and_numbers() {
1836 let request = with_messages(vec![
1837 tr("error[E0308]: mismatched types at /tmp/a/src/lib.rs:12"),
1838 tr("error[E0308]: mismatched types at /tmp/b/src/lib.rs:47"),
1839 ]);
1840 assert!(ToolSignals::from_request(&request, None).repeated_failure);
1841 }
1842
1843 #[test]
1844 fn different_failures_are_not_repeated() {
1845 let request = with_messages(vec![
1846 tr("error[E0308]: mismatched types"),
1847 tr("error[E0509]: cannot move out"),
1848 ]);
1849 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1850 }
1851
1852 #[test]
1853 fn one_material_failure_is_not_repeated() {
1854 let request = with_messages(vec![tr("Connection refused on port 8000")]);
1855 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1856 }
1857
1858 #[test]
1860 fn structured_tool_failures_feed_error_and_recovery_signals() {
1861 for text in ["Dependency unavailable", "", "5 passed in 0.12s"] {
1862 let mut failed = tr(text);
1863 let ContentBlock::ToolResult(result) = &mut failed.content[0] else {
1864 panic!("expected tool result");
1865 };
1866 result.is_error = Some(true);
1867 let mut request = with_messages(vec![tr("5 passed in 0.12s"), failed.clone()]);
1868 let signals = ToolSignals::from_request(&request, Some(3));
1869 assert_eq!(signals.severity, HARD);
1870 assert!(!signals.repeated_failure);
1871 assert_eq!(signals.no_error_streak, 0);
1872 assert!(!signals.tests_passed);
1873
1874 request.llm_request.messages.push(failed);
1875 assert!(ToolSignals::from_request(&request, Some(3)).repeated_failure);
1876 request.llm_request.messages.push(tr("5 passed in 0.12s"));
1877 let recovered = ToolSignals::from_request(&request, Some(1));
1878 assert_eq!(recovered.severity, 0.0);
1879 assert!(!recovered.repeated_failure);
1880 assert_eq!(recovered.no_error_streak, 1);
1881 assert!(recovered.tests_passed);
1882 }
1883 }
1884
1885 #[test]
1886 fn severity_is_max_across_patterns() {
1887 let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
1889 assert_eq!(sev, HARD);
1890 }
1891
1892 #[test]
1893 fn codex_process_exit_zero_stays_clean() {
1894 let (sev, patterns) =
1895 classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
1896 assert_eq!(sev, 0.0);
1897 assert!(!patterns.contains(&"exit_nonzero".to_string()));
1898 }
1899
1900 #[test]
1901 fn nonzero_exit_codes_are_soft_errors() {
1902 let cases = [
1903 "Process exited with code 1",
1904 "Process exited with code 127",
1905 "exit code: 2",
1906 "exit status 3",
1907 "exited with status 9",
1908 ];
1909 for case in cases {
1910 let (sev, patterns) = classify_text(case);
1911 assert_eq!(sev, SOFT, "expected soft severity for {case}");
1912 assert!(patterns.contains(&"exit_nonzero".to_string()));
1913 }
1914 }
1915
1916 #[test]
1917 fn partial_process_failures_are_hard_errors() {
1918 let cases = [
1919 (
1920 "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
1921 "compile_error",
1922 ),
1923 (
1924 "Process exited with code 0\nOutput:\nTypeError: value is undefined\n at main.js:1:2",
1925 "runtime_exception",
1926 ),
1927 (
1928 "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
1929 "runtime_panic",
1930 ),
1931 (
1932 "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
1933 "patch_error",
1934 ),
1935 ];
1936 for (text, expected_pattern) in cases {
1937 let (severity, patterns) = classify_text(text);
1938 assert_eq!(severity, HARD, "expected hard severity for {text}");
1939 assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
1940 }
1941 }
1942
1943 #[test]
1944 fn source_text_that_names_exceptions_stays_clean() {
1945 let text =
1946 "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
1947 assert_eq!(classify_text(text).0, 0.0);
1948 }
1949
1950 #[test]
1951 fn file_does_not_exist_is_hard() {
1952 let (sev, patterns) =
1954 classify_text("Error: File does not exist. Note: current working directory is /app.");
1955 assert_eq!(sev, HARD);
1956 assert!(patterns.contains(&"no_such_file".to_string()));
1957 }
1958
1959 #[test]
1960 fn bare_does_not_exist_stays_clean() {
1961 let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
1964 assert_eq!(sev, 0.0);
1965 }
1966
1967 #[test]
1968 fn no_error_streak_all_clean() {
1969 let texts = vec![("ok".to_string(), false), ("all good".to_string(), false)];
1970 assert_eq!(compute_no_error_streak(&texts), 2);
1971 }
1972
1973 #[test]
1974 fn no_error_streak_stops_at_error() {
1975 let texts = vec![
1976 ("Traceback (most recent call last):".to_string(), false),
1977 ("ok".to_string(), false),
1978 ("ok".to_string(), false),
1979 ];
1980 assert_eq!(compute_no_error_streak(&texts), 2);
1981 }
1982
1983 #[test]
1984 fn tests_passed_detects_pytest_output() {
1985 assert!(detect_tests_passed(
1986 &[("====== 5 passed in 0.12s ======".to_string(), false)],
1987 DEFAULT_RECENT_WINDOW
1988 ));
1989 }
1990
1991 #[test]
1992 fn tests_passed_ignores_partial_failures() {
1993 assert!(!detect_tests_passed(
1994 &[("2 failed, 5 passed in 0.56s".to_string(), false)],
1995 DEFAULT_RECENT_WINDOW
1996 ));
1997 }
1998
1999 #[test]
2000 fn tests_passed_must_follow_the_latest_failure() {
2001 assert!(!detect_tests_passed(
2002 &[
2003 ("5 passed in 0.12s".to_string(), false),
2004 (
2005 "Traceback (most recent call last):\nValueError".to_string(),
2006 false
2007 ),
2008 ("edit applied".to_string(), false),
2009 ],
2010 DEFAULT_RECENT_WINDOW
2011 ));
2012 assert!(detect_tests_passed(
2013 &[
2014 (
2015 "Traceback (most recent call last):\nValueError".to_string(),
2016 false
2017 ),
2018 ("5 passed in 0.12s".to_string(), false),
2019 ],
2020 DEFAULT_RECENT_WINDOW
2021 ));
2022 }
2023
2024 #[test]
2025 fn retrieved_file_contents_are_ignored() {
2026 let call = |id: &str, name: &str, arguments: Value| Message {
2027 role: Role::Assistant,
2028 content: vec![ContentBlock::ToolCall(ToolCall {
2029 id: id.to_string(),
2030 name: name.to_string(),
2031 arguments,
2032 })],
2033 };
2034 let result = |id: &str, text: &str| Message {
2035 role: Role::User,
2036 content: vec![ContentBlock::ToolResult(ToolResult {
2037 tool_call_id: id.to_string(),
2038 content: vec![ContentBlock::Text {
2039 text: text.to_string(),
2040 }],
2041 is_error: None,
2042 })],
2043 };
2044 let signal = extract_tool_signals_with_window(
2045 &with_messages(vec![
2046 call("a", "Bash", json!({"command": "pytest"})),
2047 result("a", "Traceback (most recent call last):\nValueError"),
2048 call("b", "Read", json!({"file_path": "notes.md"})),
2049 result(
2050 "b",
2051 r#"{"success":false,"error":"out of memory","exit_code":7}"#,
2052 ),
2053 call("c", "Grep", json!({"pattern": "passed"})),
2054 result("c", "CHANGELOG.md: all tests passed"),
2055 ]),
2056 DEFAULT_RECENT_WINDOW,
2057 );
2058 assert_eq!(signal.severity, HARD);
2060 assert!(!signal.tests_passed);
2061 assert_eq!(signal.tool_result_count, 3);
2062
2063 for (command, text, severity) in [
2064 ("cat config.json", r#"{"error":"fixture data"}"#, 0.0),
2065 (
2066 "cat missing.json",
2067 r#"{"output":"","exit_code":7,"error":null}"#,
2068 SOFT,
2069 ),
2070 (
2071 "cat config.json && python check.py",
2072 r#"{"error":"check rejected"}"#,
2073 HARD,
2074 ),
2075 ] {
2076 let request = with_messages(vec![
2077 call("shell", "Bash", json!({"command": command})),
2078 result("shell", text),
2079 ]);
2080 assert_eq!(
2081 ToolSignals::from_request(&request, None).severity,
2082 severity,
2083 "{text}"
2084 );
2085 }
2086 }
2087
2088 fn call_with_result(mut call: Message, mut result: Message) -> Request {
2090 if let ContentBlock::ToolCall(call) = &mut call.content[0] {
2091 call.id = "call-1".into();
2092 }
2093 if let ContentBlock::ToolResult(result) = &mut result.content[0] {
2094 result.tool_call_id = "call-1".into();
2095 }
2096 with_messages(vec![call, result])
2097 }
2098
2099 #[test]
2100 fn shell_reads_are_ignored_but_keep_their_window_slot() {
2101 for command in [
2102 "/usr/bin/cat logfile.txt",
2103 "cd /repo && tail -n 100 logfile.txt | grep MemoryError",
2104 "env MODE=debug timeout 5s rg 'MemoryError' .",
2105 ] {
2106 let mut request = call_with_result(bash(command), tr("MemoryError\nall tests passed"));
2107 request.llm_request.messages.splice(
2108 0..0,
2109 [bash("pytest"), tr("Traceback (most recent call last):")],
2110 );
2111 let signal = ToolSignals::from_request(&request, Some(2));
2113 assert_eq!(signal.severity, HARD, "{command}");
2114 assert!(!signal.tests_passed, "{command}");
2115 let signal = ToolSignals::from_request(&request, Some(1));
2117 assert_eq!(signal.severity, 0.0, "{command}");
2118 }
2119 }
2120
2121 #[test]
2122 fn shell_commands_that_run_code_keep_signals() {
2123 let oversized = format!("cat {}", "a".repeat(MAX_RETRIEVAL_COMMAND_BYTES));
2124 for command in [
2125 "cat logfile.txt; pytest",
2126 "echo $(pytest)",
2127 "find . -name '*.py' -exec pytest ';'",
2128 oversized.as_str(),
2129 ] {
2130 let request = call_with_result(bash(command), tr("MemoryError"));
2131 let signal = ToolSignals::from_request(&request, None);
2132 assert_eq!(signal.severity, CRITICAL, "{command}");
2133 }
2134 }
2135
2136 #[test]
2137 fn failed_reads_signal_unless_the_file_is_missing() {
2138 let failed = |text: &str| {
2139 let mut result = tr(text);
2140 if let ContentBlock::ToolResult(result) = &mut result.content[0] {
2141 result.is_error = Some(true);
2142 }
2143 result
2144 };
2145 let missing = "missing.txt: No such file or directory";
2146 let severities = [
2147 call_with_result(bash("cat missing.txt"), failed(missing)),
2148 call_with_result(tc("Read"), failed("File does not exist.")),
2149 call_with_result(bash("cat logfile.txt"), failed("MemoryError")),
2151 call_with_result(bash("python missing.txt"), failed(missing)),
2153 ]
2154 .map(|request| ToolSignals::from_request(&request, None).severity);
2155 assert_eq!(severities, [0.0, 0.0, CRITICAL, HARD]);
2156
2157 let request = call_with_result(
2158 bash("cat missing.json"),
2159 tr(r#"{"output":"No such file or directory","exit_code":1}"#),
2160 );
2161 assert_eq!(ToolSignals::from_request(&request, None).severity, 0.0);
2162 }
2163
2164 #[test]
2165 fn argv_commands_keep_word_boundaries() {
2166 let signal = |argv: Value| {
2167 let call = exec_command(json!({"cmd": argv}));
2168 ToolSignals::from_request(&call_with_result(call, tr("MemoryError")), None)
2169 };
2170 let mut many_args = vec![""; MAX_RETRIEVAL_COMMAND_BYTES];
2171 many_args[0] = "cat";
2172 let severities = [
2173 json!(["bash", "-lc", "cd repo && rg foo"]),
2174 json!(["bash", "-lc", "cat log; pytest"]),
2175 json!(["cat", "log; pytest"]),
2177 json!(["cat", "a".repeat(MAX_RETRIEVAL_COMMAND_BYTES)]),
2179 json!(many_args),
2180 ]
2181 .map(|argv| signal(argv).severity);
2182 assert_eq!(severities, [0.0, CRITICAL, 0.0, CRITICAL, CRITICAL]);
2183 let read = signal(json!(["bash", "-lc", "sed -n 1,200p x"]));
2185 assert_eq!(read.read_count, 1);
2186 let write = signal(json!(["bash", "-lc", "cd repo && mkdir x"]));
2188 assert_eq!(write.write_count, 1);
2189 }
2190
2191 #[test]
2192 fn severity_is_windowed_over_recent_results() {
2193 let request = with_messages(vec![
2195 tr("Traceback (most recent call last):\n ValueError"),
2196 tr("ok"),
2197 tr("ok"),
2198 ]);
2199 assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
2201 assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
2203 }
2204
2205 #[test]
2206 fn extract_openai_chat_tool_results() {
2207 let request = with_messages(vec![
2208 Message::text(Role::User, "do something"),
2209 tc("Edit"),
2210 tr("Traceback (most recent call last):\n ValueError"),
2211 ]);
2212 let sig = ToolSignals::from_request(&request, None);
2213 assert_eq!(sig.severity, HARD);
2214 assert_eq!(sig.edit_count, 1);
2215 assert_eq!(sig.turn_depth, 3);
2216 }
2217
2218 #[test]
2219 fn extract_anthropic_tool_results() {
2220 let request = with_messages(vec![tr("Traceback (most recent call last):\n ValueError")]);
2221 let sig = ToolSignals::from_request(&request, None);
2222 assert_eq!(sig.severity, HARD);
2223 }
2224
2225 #[test]
2226 fn extract_responses_api_tool_results() {
2227 let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
2228 let sig = ToolSignals::from_request(&request, None);
2229 assert_eq!(sig.severity, 0.0);
2230 assert_eq!(sig.write_count, 1);
2231 }
2232
2233 #[test]
2234 fn responses_builtin_tool_failures_escalate() {
2235 use crate::algorithms::util::stage::{PickOutcome, PickerMode, Tier, pick_tier};
2236
2237 let mut cases = Vec::new();
2238 for (status, output) in [
2239 (
2240 "failed",
2241 "Synthetic dependency unavailable; retry with the recovery path.",
2242 ),
2243 (
2244 "completed",
2245 "Synthetic dependency unavailable; retry with the recovery path.",
2246 ),
2247 ("failed", ""),
2248 ] {
2249 cases.push((
2250 json!({
2251 "type": "apply_patch_call_output",
2252 "status": status,
2253 "output": output,
2254 }),
2255 if status == "failed" { HARD } else { 0.0 },
2256 ));
2257 }
2258 for (outcome, stdout, stderr, severity) in [
2259 (json!({"type": "exit", "exit_code": 1}), "", "", SOFT),
2260 (json!({"type": "exit", "exit_code": 1}), "", " \n", SOFT),
2261 (
2262 json!({"type": "exit", "exit_code": 1}),
2263 "",
2264 "command failed",
2265 HARD,
2266 ),
2267 (json!({"type": "timeout"}), "", "", HARD),
2268 (json!({"type": "exit", "exit_code": 0}), "done", "", 0.0),
2269 (
2270 json!({"type": "exit", "exit_code": 0}),
2271 "Traceback (most recent call last):",
2272 "",
2273 HARD,
2274 ),
2275 (
2276 json!({"type": "exit", "exit_code": 0}),
2277 "",
2278 "Traceback (most recent call last):",
2279 HARD,
2280 ),
2281 ] {
2282 cases.push((
2283 json!({
2284 "type": "shell_call_output",
2285 "output": [
2286 {"stdout": stdout, "stderr": stderr, "outcome": outcome},
2287 {"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}
2288 ],
2289 }),
2290 severity,
2291 ));
2292 }
2293 for (raw, severity) in cases {
2294 let is_error = severity >= HARD;
2295 let mut request = with_messages(
2296 ["call_1", "call_2"]
2297 .into_iter()
2298 .map(|call_id| {
2299 let mut raw = raw.clone();
2300 raw["call_id"] = json!(call_id);
2301 Message {
2302 role: Role::User,
2303 content: vec![ContentBlock::Unknown {
2304 provider: WireFormat::OpenAiResponses.into(),
2305 raw,
2306 }],
2307 }
2308 })
2309 .collect(),
2310 );
2311 let signal = ToolSignals::from_request(&request, Some(3));
2312 assert_eq!(signal.severity, severity, "{raw}");
2313 assert_eq!(signal.repeated_failure, is_error, "{raw}");
2314 assert_eq!(signal.tool_result_count, 2);
2315 assert_eq!(
2316 matches!(
2317 pick_tier(&signal, PickerMode::EfficientFirst, 0.5),
2318 PickOutcome::Resolved {
2319 tier: Tier::Capable,
2320 ..
2321 }
2322 ),
2323 is_error,
2324 "{raw}"
2325 );
2326
2327 let mut success = match raw["type"].as_str() {
2328 Some("apply_patch_call_output") => json!({
2329 "type": "apply_patch_call_output", "status": "completed", "output": ""
2330 }),
2331 Some("shell_call_output") => json!({
2332 "type": "shell_call_output",
2333 "output": [{"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}]
2334 }),
2335 _ => unreachable!(),
2336 };
2337 for index in 0..3 {
2338 success["call_id"] = json!(format!("success_{index}"));
2339 request.llm_request.messages.push(Message {
2340 role: Role::User,
2341 content: vec![ContentBlock::Unknown {
2342 provider: WireFormat::OpenAiResponses.into(),
2343 raw: success.clone(),
2344 }],
2345 });
2346 }
2347 let recovered = ToolSignals::from_request(&request, Some(3));
2348 assert_eq!(recovered.severity, 0.0, "{raw}");
2349 assert!(!recovered.repeated_failure, "{raw}");
2350 assert!(recovered.no_error_streak >= 3, "{raw}");
2351 assert_eq!(recovered.tool_result_count, 5);
2352 }
2353 }
2354
2355 #[test]
2356 fn conversation_counts_are_per_block_and_role_aware() {
2357 let result = |content: Vec<ContentBlock>| {
2361 ContentBlock::ToolResult(ToolResult {
2362 tool_call_id: String::new(),
2363 content,
2364 is_error: None,
2365 })
2366 };
2367 let request = with_messages(vec![
2368 Message::text(Role::User, "do something"),
2369 Message::text(Role::Assistant, "working"),
2370 Message {
2371 role: Role::User,
2372 content: vec![
2373 result(vec![ContentBlock::Text {
2374 text: "ok".to_string(),
2375 }]),
2376 result(Vec::new()),
2377 ],
2378 },
2379 tc("Bash"),
2380 ]);
2381 let sig = ToolSignals::from_request(&request, None);
2382 assert_eq!(sig.tool_result_count, 2);
2383 assert_eq!(sig.assistant_turn_count, 2);
2384 assert_eq!(sig.turn_depth, 4);
2385 }
2386
2387 #[test]
2388 fn recent_window_counts_only_last_default_window_tool_calls() {
2389 let request = with_messages(vec![
2392 tc("Write"),
2393 tr("ok"),
2394 tc("Write"),
2395 tr("ok"),
2396 tc("Write"),
2397 tr("ok"),
2398 tc("Write"),
2399 tr("ok"),
2400 tc("Write"),
2401 tr("ok"),
2402 tc("Edit"),
2403 tr("ok"),
2404 ]);
2405 let sig = ToolSignals::from_request(&request, None);
2406 assert_eq!(sig.write_count, 5);
2407 assert_eq!(sig.edit_count, 1);
2408 assert_eq!(sig.recent_write_count, 2);
2409 assert_eq!(sig.recent_edit_count, 1);
2410 }
2411
2412 #[test]
2413 fn codex_apply_patch_counts_as_an_edit() {
2414 let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
2415 let sig = ToolSignals::from_request(&request, None);
2416 assert_eq!(sig.edit_count, 1);
2417 assert_eq!(sig.recent_edit_count, 1);
2418 }
2419
2420 fn exec_command(cmd: Value) -> Message {
2421 Message {
2422 role: Role::Assistant,
2423 content: vec![ContentBlock::ToolCall(ToolCall {
2424 id: String::new(),
2425 name: "exec_command".to_string(),
2426 arguments: cmd,
2427 })],
2428 }
2429 }
2430
2431 #[test]
2432 fn codex_exec_command_is_classified() {
2433 let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
2435 let request = with_messages(vec![exec_command(args), tr("ok")]);
2436 assert_eq!(
2437 ToolSignals::from_request(&request, None).recent_edit_count,
2438 1
2439 );
2440 }
2441
2442 #[test]
2443 fn python_write_expressions_need_a_python_command() {
2444 let write = with_messages(vec![
2445 exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
2446 tr("ok"),
2447 ]);
2448 assert_eq!(
2449 ToolSignals::from_request(&write, None).recent_write_count,
2450 1
2451 );
2452
2453 let search = with_messages(vec![
2454 exec_command(json!({"cmd": "grep -R '.write(' src"})),
2455 tr("ok"),
2456 ]);
2457 assert_eq!(
2458 ToolSignals::from_request(&search, None).recent_write_count,
2459 0
2460 );
2461 }
2462
2463 #[test]
2464 fn recent_window_size_is_caller_overridable() {
2465 let request = with_messages(vec![
2469 tc("Write"),
2470 tr("ok"),
2471 tc("Write"),
2472 tr("ok"),
2473 tc("Write"),
2474 tr("ok"),
2475 tc("Write"),
2476 tr("ok"),
2477 tc("Write"),
2478 tr("ok"),
2479 tc("Edit"),
2480 tr("ok"),
2481 ]);
2482 let narrow = extract_tool_signals_with_window(&request, 3);
2483 assert_eq!(narrow.recent_write_count, 2);
2484 assert_eq!(narrow.recent_edit_count, 1);
2485
2486 let wide = extract_tool_signals_with_window(&request, 6);
2487 assert_eq!(wide.recent_write_count, 5);
2488 assert_eq!(wide.recent_edit_count, 1);
2489 }
2490
2491 #[test]
2492 fn compaction_marker_sets_compacted() {
2493 let request = with_messages(vec![
2495 Message::text(
2496 Role::User,
2497 "This session is being continued from a previous conversation that ran out of context.",
2498 ),
2499 bash("ls"),
2500 ]);
2501 assert!(ToolSignals::from_request(&request, None).compacted);
2502 }
2503
2504 #[test]
2505 fn hermes_compaction_headers_set_compacted() {
2506 for header in [
2507 "[CONTEXT COMPACTION \u{2014} REFERENCE ONLY] Earlier turns were compacted into the summary below.",
2508 "[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.]",
2509 ] {
2510 for role in [Role::User, Role::Assistant] {
2511 let request = with_messages(vec![
2512 Message::text(role, format!("{header}\nContinue the task.")),
2513 bash("ls"),
2514 ]);
2515 assert!(ToolSignals::from_request(&request, None).compacted);
2516 }
2517 let request = with_messages(vec![Message::text(
2518 Role::User,
2519 format!("Prior context.\n\n {header}\nContinue the task."),
2520 )]);
2521 assert!(ToolSignals::from_request(&request, None).compacted);
2522 let request = with_messages(vec![tr(header)]);
2523 assert!(!ToolSignals::from_request(&request, None).compacted);
2524 }
2525 }
2526
2527 #[test]
2528 fn ordinary_compaction_text_stays_uncompacted() {
2529 for text in [
2530 "The task is still in progress after the compaction boundary.",
2531 "Explain [CONTEXT COMPACTION \u{2014} REFERENCE ONLY] in the docs.",
2532 "[STILL IN PROGRESS] Continue the task.",
2533 ] {
2534 let request = with_messages(vec![Message::text(Role::User, text)]);
2535 assert!(!ToolSignals::from_request(&request, None).compacted);
2536 }
2537 }
2538
2539 #[test]
2540 fn codex_compaction_metadata_stays_on_parent_route() {
2541 let mut request = with_messages(vec![bash("ls")]);
2542 request.metadata = Some(Metadata {
2543 is_subagent: true,
2544 agent_kind: Some("compact".to_string()),
2545 ..Default::default()
2546 });
2547 assert!(!ToolSignals::from_request(&request, None).compacted);
2548 }
2549
2550 #[test]
2551 fn no_compaction_marker_stays_uncompacted() {
2552 let request = with_messages(vec![
2553 Message::text(Role::User, "Write a script that parses the log file."),
2554 bash("ls"),
2555 ]);
2556 assert!(!ToolSignals::from_request(&request, None).compacted);
2557 }
2558
2559 #[test]
2560 fn bash_heredoc_counts_as_write() {
2561 let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
2563 let sig = ToolSignals::from_request(&request, None);
2564 assert_eq!(
2565 sig.write_count, 1,
2566 "Bash heredoc should bucket into write_count"
2567 );
2568 assert_eq!(sig.edit_count, 0);
2569 }
2570
2571 #[test]
2572 fn bash_sed_inplace_counts_as_edit() {
2573 let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
2574 let sig = ToolSignals::from_request(&request, None);
2575 assert_eq!(
2576 sig.edit_count, 1,
2577 "Bash sed -i should bucket into edit_count"
2578 );
2579 assert_eq!(sig.write_count, 0);
2580 }
2581
2582 #[test]
2583 fn bash_non_mutating_does_not_count() {
2584 let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
2586 let sig = ToolSignals::from_request(&request, None);
2587 assert_eq!(sig.write_count, 0);
2588 assert_eq!(sig.edit_count, 0);
2589 }
2590
2591 #[test]
2592 fn tests_passed_detects_pytest_with_failure_block() {
2593 assert!(!detect_tests_passed(
2595 &[("2 failed, 5 passed in 0.56s".to_string(), false)],
2596 DEFAULT_RECENT_WINDOW
2597 ));
2598 }
2599
2600 #[test]
2601 fn tests_passed_accepts_cargo_clean_summary() {
2602 assert!(detect_tests_passed(
2605 &[(
2606 "running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string(),
2607 false
2608 )],
2609 DEFAULT_RECENT_WINDOW
2610 ));
2611 }
2612
2613 #[test]
2614 fn tests_passed_rejects_cargo_real_failure() {
2615 assert!(!detect_tests_passed(
2617 &[(
2618 "running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string(),
2619 false
2620 )],
2621 DEFAULT_RECENT_WINDOW
2622 ));
2623 }
2624
2625 #[test]
2626 fn tests_passed_accepts_go_clean_summary() {
2627 assert!(detect_tests_passed(
2629 &[(
2630 "ok github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string(),
2631 false
2632 )],
2633 DEFAULT_RECENT_WINDOW
2634 ));
2635 }
2636
2637 #[test]
2638 fn tests_passed_accepts_pytest_zero_errors() {
2639 assert!(detect_tests_passed(
2641 &[("5 passed, 0 errors in 0.30s".to_string(), false)],
2642 DEFAULT_RECENT_WINDOW
2643 ));
2644 }
2645
2646 #[test]
2647 fn tests_passed_detects_diy_checkmark() {
2648 assert!(detect_tests_passed(
2649 &[("✓ all checks passed".to_string(), false)],
2650 DEFAULT_RECENT_WINDOW
2651 ));
2652 }
2653
2654 #[test]
2655 fn anthropic_bash_heredoc_extracts_command() {
2656 let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
2658 let sig = ToolSignals::from_request(&request, None);
2659 assert_eq!(
2660 sig.write_count, 1,
2661 "Anthropic Bash heredoc must also be detected"
2662 );
2663 }
2664
2665 #[test]
2666 fn recent_window_falls_back_to_full_history_when_short() {
2667 let request = with_messages(vec![tc("Write")]);
2668 let sig = ToolSignals::from_request(&request, None);
2669 assert_eq!(sig.recent_write_count, 1);
2670 assert_eq!(sig.recent_edit_count, 0);
2671 }
2672
2673 #[test]
2674 fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
2675 let request = with_messages(vec![tr("output ok"), tr("another ok")]);
2676 let sig = ToolSignals::from_request(&request, None);
2677 assert_eq!(sig.severity, 0.0);
2678 assert_eq!(sig.no_error_streak, 2);
2679 }
2680
2681 #[test]
2684 fn todowrite_classifies_as_plan() {
2685 assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
2686 assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
2687 }
2688
2689 #[test]
2690 fn codex_update_plan_classifies_as_plan() {
2691 assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
2692 }
2693
2694 #[test]
2695 fn codex_shell_command_runs_bash_pattern_match() {
2696 assert_eq!(
2698 classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
2699 ToolSemantic::Mutate(MutationKind::Write),
2700 );
2701 assert_eq!(
2703 classify_tool_call("shell_command", Some("ls /app")),
2704 ToolSemantic::Observe,
2705 );
2706 assert_eq!(
2708 classify_tool_call("shell_command", Some("./run_tests.sh")),
2709 ToolSemantic::Unknown,
2710 );
2711 }
2712
2713 #[test]
2714 fn text_editor_view_is_a_read() {
2715 for name in ["str_replace_based_edit_tool", "text_editor"] {
2716 assert_eq!(
2717 classify_tool_call(name, Some("view")),
2718 ToolSemantic::Observe
2719 );
2720 for command in [
2721 Some("create"),
2722 Some("insert"),
2723 Some("str_replace"),
2724 Some("undo_edit"),
2725 None,
2726 ] {
2727 assert_eq!(
2728 classify_tool_call(name, command),
2729 ToolSemantic::Mutate(MutationKind::Edit),
2730 );
2731 }
2732 }
2733
2734 let arguments = [
2735 json!({"command": "view", "path": "/app/main.py"}),
2736 json!(r#"{"command":"view","path":"/app/main.py"}"#),
2738 ];
2739 for arguments in arguments {
2740 let call = Message {
2741 role: Role::Assistant,
2742 content: vec![ContentBlock::ToolCall(ToolCall {
2743 id: String::new(),
2744 name: "str_replace_based_edit_tool".to_string(),
2745 arguments,
2746 })],
2747 };
2748 let request = with_messages(vec![call, tr("print('hi')")]);
2749 let sig = ToolSignals::from_request(&request, None);
2750 assert_eq!(sig.read_count, 1);
2751 assert_eq!(sig.recent_read_count, 1);
2752 assert_eq!(sig.edit_count, 0);
2753 }
2754 }
2755
2756 #[test]
2757 fn read_tool_classifies_as_read() {
2758 assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
2759 assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
2760 }
2761
2762 #[test]
2763 fn hermes_tool_names_classify() {
2764 assert_eq!(
2766 classify_tool_call("write_file", None),
2767 ToolSemantic::Mutate(MutationKind::Write)
2768 );
2769 assert_eq!(
2770 classify_tool_call("patch", None),
2771 ToolSemantic::Mutate(MutationKind::Edit)
2772 );
2773 assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
2774 assert_eq!(
2775 classify_tool_call("search_files", None),
2776 ToolSemantic::Observe
2777 );
2778 assert_eq!(
2781 classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
2782 ToolSemantic::Mutate(MutationKind::Edit),
2783 );
2784 assert_eq!(
2785 classify_tool_call("terminal", Some("grep foo /app")),
2786 ToolSemantic::Observe,
2787 );
2788 assert_eq!(
2789 classify_tool_call("terminal", Some("./run_tests.sh")),
2790 ToolSemantic::Unknown,
2791 );
2792 }
2793
2794 #[test]
2795 fn bash_read_patterns_classify_as_read() {
2796 let cases = [
2797 "cat /etc/passwd",
2798 "grep foo bar.txt",
2799 "ls /app",
2800 "find . -name '*.py'",
2801 ];
2802 for cmd in cases {
2803 assert_eq!(
2804 classify_tool_call("Bash", Some(cmd)),
2805 ToolSemantic::Observe,
2806 "expected Read for {cmd}"
2807 );
2808 }
2809 }
2810
2811 #[test]
2812 fn codex_inspection_commands_classify_as_read() {
2813 let cases = [
2814 "sed -n '1,80p' src/lib.rs",
2815 "rg -n 'needle' src",
2816 "nl -ba src/lib.rs",
2817 "cat package.json",
2818 "jq '.scripts' package.json",
2819 "git status --short",
2820 "git log --oneline -5",
2821 "git show HEAD:src/lib.rs",
2822 "git branch --show-current",
2823 "git remote -v",
2824 "git config --get remote.origin.url",
2825 ];
2826 for command in cases {
2827 assert_eq!(
2828 classify_tool_call("exec_command", Some(command)),
2829 ToolSemantic::Observe,
2830 "expected Read for {command}"
2831 );
2832 }
2833 }
2834
2835 #[test]
2836 fn quoted_shell_separators_do_not_create_commands() {
2837 for command in ["rg 'foo|rm obsolete.rs'", "rg \"foo; rm obsolete.rs\""] {
2838 assert_eq!(
2839 classify_tool_call("exec_command", Some(command)),
2840 ToolSemantic::Observe,
2841 "quoted text must not be parsed as a command: {command}"
2842 );
2843 }
2844 }
2845
2846 #[test]
2847 fn codex_shell_mutations_classify_as_production() {
2848 let writes = [
2849 "cp source.rs destination.rs",
2850 "mkdir -p src/generated",
2851 "touch src/generated/mod.rs",
2852 "git show HEAD:file.rs > file.rs",
2853 "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
2854 ];
2855 for command in writes {
2856 assert_eq!(
2857 classify_tool_call("exec_command", Some(command)),
2858 ToolSemantic::Mutate(MutationKind::Write),
2859 "expected Write for {command}"
2860 );
2861 }
2862
2863 let edits = [
2864 "mv old.rs new.rs",
2865 "rm obsolete.rs",
2866 "gofmt -w main.go",
2867 "cargo fmt",
2868 "ruff check --fix src",
2869 "perl -0pi -e 's/old/new/' src/lib.rs",
2870 "npx prettier --write src/lib.ts",
2871 "uv run ruff format src",
2872 "git apply fix.patch",
2873 ];
2874 for command in edits {
2875 assert_eq!(
2876 classify_tool_call("exec_command", Some(command)),
2877 ToolSemantic::Mutate(MutationKind::Edit),
2878 "expected Edit for {command}"
2879 );
2880 }
2881 }
2882
2883 #[test]
2884 fn formatter_checks_are_not_edits() {
2885 for command in [
2886 "cargo fmt --check",
2887 "ruff format --check src",
2888 "black --check src",
2889 ] {
2890 assert_ne!(
2891 classify_tool_call("exec_command", Some(command)),
2892 ToolSemantic::Mutate(MutationKind::Edit),
2893 "read-only formatter check must not be Edit: {command}"
2894 );
2895 }
2896 }
2897
2898 #[test]
2899 fn embedded_comparison_is_not_a_shell_write() {
2900 let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
2901 assert_eq!(
2902 classify_tool_call("exec_command", Some(command)),
2903 ToolSemantic::Unknown
2904 );
2905 }
2906
2907 #[test]
2908 fn bash_write_precedence_over_read() {
2909 assert_eq!(
2912 classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
2913 ToolSemantic::Mutate(MutationKind::Write),
2914 );
2915 }
2916
2917 #[test]
2918 fn pure_bash_streak_counts_trailing_other() {
2919 let request = with_messages(vec![
2921 bash("make"),
2922 tr("ok"),
2923 bash("./configure"),
2924 tr("ok"),
2925 bash("make install"),
2926 tr("ok"),
2927 bash("./run.sh"),
2928 tr("ok"),
2929 bash("./test"),
2930 tr("ok"),
2931 ]);
2932 let sig = ToolSignals::from_request(&request, None);
2933 assert_eq!(sig.pure_bash_streak, 5);
2934 assert_eq!(sig.write_count, 0);
2935 assert_eq!(sig.read_count, 0);
2936 }
2937
2938 #[test]
2939 fn pure_bash_streak_resets_on_write() {
2940 let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
2941 let sig = ToolSignals::from_request(&request, None);
2942 assert_eq!(sig.pure_bash_streak, 0);
2943 assert_eq!(sig.write_count, 1);
2944 }
2945
2946 #[test]
2947 fn recent_window_tracks_todowrite_and_read() {
2948 let request = with_messages(vec![
2950 bash("make"),
2951 tr("ok"),
2952 tc("TodoWrite"),
2953 tr("ok"),
2954 tc("Read"),
2955 tr("ok"),
2956 tc("TodoWrite"),
2957 tr("ok"),
2958 ]);
2959 let sig = ToolSignals::from_request(&request, None);
2960 assert_eq!(sig.todowrite_count, 2);
2961 assert_eq!(sig.recent_todowrite_count, 2);
2962 assert_eq!(sig.read_count, 1);
2963 assert_eq!(sig.recent_read_count, 1);
2964 }
2965
2966 #[test]
2967 fn configured_tool_semantics_extend_the_builtin_vocabulary() {
2968 let semantics = ToolSemantics {
2969 observe: vec!["KB_search".to_string()],
2970 mutate: vec!["send_payment_request".to_string()],
2971 plan: vec!["create_research_plan".to_string()],
2972 new: vec!["send_message_to_user".to_string()],
2973 };
2974 semantics.validate().expect("valid additive semantics");
2975 let request = with_messages(vec![
2976 tc("Read"),
2977 tc("Write"),
2978 tc("TodoWrite"),
2979 tc("kb_SEARCH"),
2980 tc("send_payment_request"),
2981 tc("create_research_plan"),
2982 tc("send_message_to_user"),
2983 tc("unlisted_tool"),
2984 ]);
2985
2986 let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
2987
2988 assert_eq!(signal.read_count, 2);
2989 assert_eq!(signal.write_count, 2);
2990 assert_eq!(signal.todowrite_count, 2);
2991 assert_eq!(signal.new_count, 1);
2992 assert_eq!(signal.recent_new_count, 1);
2993 assert_eq!(signal.pure_bash_streak, 1);
2994 }
2995
2996 #[test]
2997 fn configured_tool_semantics_match_namespaced_and_mcp_tools() {
2998 let mut request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
3000 request.llm_request.extensions.fields.insert(
3001 TOOL_NAMESPACES_KEY.to_string(),
3002 json!({"mcp__billing__send_payment_request": "mcp__billing"}),
3003 );
3004
3005 let claude_request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
3007
3008 for request in [&request, &claude_request] {
3009 for name in ["send_payment_request", "mcp__billing__send_payment_request"] {
3010 let semantics = ToolSemantics {
3011 mutate: vec![name.to_string()],
3012 ..Default::default()
3013 };
3014 let signal = ToolSignals::from_request_with_semantics(request, None, &semantics);
3015 assert_eq!(signal.write_count, 1, "{name}");
3016 }
3017 }
3018 }
3019
3020 #[test]
3021 fn configured_tool_semantics_only_fold_ascii_case() {
3022 let semantics = ToolSemantics {
3023 observe: vec!["kb_search".to_string()],
3024 ..Default::default()
3025 };
3026
3027 assert_eq!(
3028 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
3029 ToolSemantic::Observe
3030 );
3031 assert_eq!(
3034 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
3035 ToolSemantic::Unknown
3036 );
3037 }
3038
3039 #[test]
3040 fn custom_semantics_preserve_builtin_unicode_lowercasing() {
3041 let semantics = ToolSemantics {
3042 observe: vec!["lookup_customer".to_string()],
3043 ..Default::default()
3044 };
3045
3046 assert_eq!(
3048 classify_tool_call_with_semantics("notebooKedit", None, &semantics),
3049 ToolSemantic::Mutate(MutationKind::Edit)
3050 );
3051 }
3052
3053 #[test]
3054 fn configured_semantics_never_replace_builtin_classifications() {
3055 let semantics = ToolSemantics {
3056 observe: vec!["lookup_customer".to_string()],
3057 mutate: vec!["send_payment".to_string()],
3058 plan: vec!["create_workflow".to_string()],
3059 new: vec!["send_message".to_string()],
3060 };
3061
3062 for name in WRITE_TOOL_NAMES {
3063 assert_eq!(
3064 classify_tool_call_with_semantics(name, None, &semantics),
3065 ToolSemantic::Mutate(MutationKind::Write),
3066 "write tool {name:?} changed classification"
3067 );
3068 }
3069 for name in EDIT_TOOL_NAMES {
3070 assert_eq!(
3071 classify_tool_call_with_semantics(name, None, &semantics),
3072 ToolSemantic::Mutate(MutationKind::Edit),
3073 "edit tool {name:?} changed classification"
3074 );
3075 }
3076 for name in READ_TOOL_NAMES {
3077 assert_eq!(
3078 classify_tool_call_with_semantics(name, None, &semantics),
3079 ToolSemantic::Observe,
3080 "read tool {name:?} changed classification"
3081 );
3082 }
3083 for name in PLAN_TOOL_NAMES {
3084 assert_eq!(
3085 classify_tool_call_with_semantics(name, None, &semantics),
3086 ToolSemantic::Plan,
3087 "plan tool {name:?} changed classification"
3088 );
3089 }
3090
3091 for (command, expected) in [
3092 ("cat /tmp/input", ToolSemantic::Observe),
3093 (
3094 "cat /tmp/input > /tmp/output",
3095 ToolSemantic::Mutate(MutationKind::Write),
3096 ),
3097 (
3098 "sed -i 's/a/b/' /tmp/file",
3099 ToolSemantic::Mutate(MutationKind::Edit),
3100 ),
3101 ("./run_tests.sh", ToolSemantic::Unknown),
3102 ] {
3103 assert_eq!(
3104 classify_tool_call_with_semantics("BASH", Some(command), &semantics),
3105 expected,
3106 "bash command {command:?} changed classification"
3107 );
3108 }
3109 }
3110
3111 #[test]
3112 fn configured_semantics_score_like_their_builtin_equivalents() {
3113 let semantics = ToolSemantics {
3114 observe: vec!["lookup_customer".to_string()],
3115 mutate: vec!["send_payment".to_string()],
3116 plan: vec!["create_workflow".to_string()],
3117 ..Default::default()
3118 };
3119
3120 for (builtin, configured) in [
3121 ("Read", "lookup_customer"),
3122 ("Write", "send_payment"),
3123 ("TodoWrite", "create_workflow"),
3124 ] {
3125 let messages_before_tool = || {
3126 vec![
3127 Message::text(Role::User, "start"),
3128 Message::text(Role::Assistant, "working"),
3129 Message::text(Role::User, "continue"),
3130 Message::text(Role::Assistant, "working"),
3131 Message::text(Role::User, "continue"),
3132 Message::text(Role::Assistant, "working"),
3133 Message::text(Role::User, "continue"),
3134 ]
3135 };
3136 let mut builtin_messages = messages_before_tool();
3137 builtin_messages.push(tc(builtin));
3138 let mut configured_messages = messages_before_tool();
3139 configured_messages.push(tc(configured));
3140
3141 let builtin_score = score_signal(&ToolSignals::from_request(
3142 &with_messages(builtin_messages),
3143 None,
3144 ));
3145 let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
3146 &with_messages(configured_messages),
3147 None,
3148 &semantics,
3149 ));
3150
3151 assert_ne!(
3152 builtin_score.score, 0.0,
3153 "the {builtin:?} control must exercise a scoring dimension"
3154 );
3155 assert_eq!(
3156 configured_score, builtin_score,
3157 "configured tool {configured:?} must score exactly like {builtin:?}"
3158 );
3159 }
3160 }
3161
3162 #[test]
3163 fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
3164 let duplicate = ToolSemantics {
3165 observe: vec!["lookup".to_string()],
3166 mutate: vec!["LOOKUP".to_string()],
3167 ..Default::default()
3168 };
3169 assert!(
3170 duplicate
3171 .validate()
3172 .expect_err("duplicate should fail")
3173 .to_string()
3174 .contains("appears in both")
3175 );
3176
3177 let builtin = ToolSemantics {
3178 new: vec!["write_file".to_string()],
3179 ..Default::default()
3180 };
3181 assert!(
3182 builtin
3183 .validate()
3184 .expect_err("built-in should fail")
3185 .to_string()
3186 .contains("built-in semantics")
3187 );
3188
3189 let empty = ToolSemantics {
3190 observe: vec![" \t".to_string()],
3191 ..Default::default()
3192 };
3193 assert!(
3194 empty
3195 .validate()
3196 .expect_err("empty name should fail")
3197 .to_string()
3198 .contains("empty tool name")
3199 );
3200 }
3201}