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