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!(!request.llm_request.messages.iter().any(is_user_turn));
2396 let current = ToolSignalProcessor::default().for_user_turn(&request);
2397 assert_eq!(current.tool_result_count, 2);
2398 assert_eq!(current.severity, severity);
2399 assert_eq!(signal.severity, severity, "{raw}");
2400 assert_eq!(signal.repeated_failure, is_error, "{raw}");
2401 assert_eq!(signal.tool_result_count, 2);
2402 assert_eq!(
2403 matches!(
2404 pick_tier(&signal, PickerMode::EfficientFirst, 0.5),
2405 PickOutcome::Resolved {
2406 tier: Tier::Capable,
2407 ..
2408 }
2409 ),
2410 is_error,
2411 "{raw}"
2412 );
2413
2414 let mut success = match raw["type"].as_str() {
2415 Some("apply_patch_call_output") => json!({
2416 "type": "apply_patch_call_output", "status": "completed", "output": ""
2417 }),
2418 Some("shell_call_output") => json!({
2419 "type": "shell_call_output",
2420 "output": [{"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}]
2421 }),
2422 _ => unreachable!(),
2423 };
2424 for index in 0..3 {
2425 success["call_id"] = json!(format!("success_{index}"));
2426 request.llm_request.messages.push(Message {
2427 role: Role::User,
2428 content: vec![ContentBlock::Unknown {
2429 provider: WireFormat::OpenAiResponses.into(),
2430 raw: success.clone(),
2431 }],
2432 });
2433 }
2434 let recovered = ToolSignals::from_request(&request, Some(3));
2435 assert_eq!(recovered.severity, 0.0, "{raw}");
2436 assert!(!recovered.repeated_failure, "{raw}");
2437 assert!(recovered.no_error_streak >= 3, "{raw}");
2438 assert_eq!(recovered.tool_result_count, 5);
2439 }
2440 }
2441
2442 #[test]
2443 fn conversation_counts_are_per_block_and_role_aware() {
2444 let result = |content: Vec<ContentBlock>| {
2448 ContentBlock::ToolResult(ToolResult {
2449 tool_call_id: String::new(),
2450 content,
2451 is_error: None,
2452 })
2453 };
2454 let request = with_messages(vec![
2455 Message::text(Role::User, "do something"),
2456 Message::text(Role::Assistant, "working"),
2457 Message {
2458 role: Role::User,
2459 content: vec![
2460 result(vec![ContentBlock::Text {
2461 text: "ok".to_string(),
2462 }]),
2463 result(Vec::new()),
2464 ],
2465 },
2466 tc("Bash"),
2467 ]);
2468 let sig = ToolSignals::from_request(&request, None);
2469 assert_eq!(sig.tool_result_count, 2);
2470 assert_eq!(sig.assistant_turn_count, 2);
2471 assert_eq!(sig.turn_depth, 4);
2472 }
2473
2474 #[test]
2475 fn recent_window_counts_only_last_default_window_tool_calls() {
2476 let request = with_messages(vec![
2479 tc("Write"),
2480 tr("ok"),
2481 tc("Write"),
2482 tr("ok"),
2483 tc("Write"),
2484 tr("ok"),
2485 tc("Write"),
2486 tr("ok"),
2487 tc("Write"),
2488 tr("ok"),
2489 tc("Edit"),
2490 tr("ok"),
2491 ]);
2492 let sig = ToolSignals::from_request(&request, None);
2493 assert_eq!(sig.write_count, 5);
2494 assert_eq!(sig.edit_count, 1);
2495 assert_eq!(sig.recent_write_count, 2);
2496 assert_eq!(sig.recent_edit_count, 1);
2497 }
2498
2499 #[test]
2500 fn codex_apply_patch_counts_as_an_edit() {
2501 let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
2502 let sig = ToolSignals::from_request(&request, None);
2503 assert_eq!(sig.edit_count, 1);
2504 assert_eq!(sig.recent_edit_count, 1);
2505 }
2506
2507 fn exec_command(cmd: Value) -> Message {
2508 Message {
2509 role: Role::Assistant,
2510 content: vec![ContentBlock::ToolCall(ToolCall {
2511 id: String::new(),
2512 name: "exec_command".to_string(),
2513 arguments: cmd,
2514 })],
2515 }
2516 }
2517
2518 #[test]
2519 fn codex_exec_command_is_classified() {
2520 let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
2522 let request = with_messages(vec![exec_command(args), tr("ok")]);
2523 assert_eq!(
2524 ToolSignals::from_request(&request, None).recent_edit_count,
2525 1
2526 );
2527 }
2528
2529 #[test]
2530 fn python_write_expressions_need_a_python_command() {
2531 let write = with_messages(vec![
2532 exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
2533 tr("ok"),
2534 ]);
2535 assert_eq!(
2536 ToolSignals::from_request(&write, None).recent_write_count,
2537 1
2538 );
2539
2540 let search = with_messages(vec![
2541 exec_command(json!({"cmd": "grep -R '.write(' src"})),
2542 tr("ok"),
2543 ]);
2544 assert_eq!(
2545 ToolSignals::from_request(&search, None).recent_write_count,
2546 0
2547 );
2548 }
2549
2550 #[test]
2551 fn recent_window_size_is_caller_overridable() {
2552 let request = with_messages(vec![
2556 tc("Write"),
2557 tr("ok"),
2558 tc("Write"),
2559 tr("ok"),
2560 tc("Write"),
2561 tr("ok"),
2562 tc("Write"),
2563 tr("ok"),
2564 tc("Write"),
2565 tr("ok"),
2566 tc("Edit"),
2567 tr("ok"),
2568 ]);
2569 let narrow = extract_tool_signals_with_window(&request, 3);
2570 assert_eq!(narrow.recent_write_count, 2);
2571 assert_eq!(narrow.recent_edit_count, 1);
2572
2573 let wide = extract_tool_signals_with_window(&request, 6);
2574 assert_eq!(wide.recent_write_count, 5);
2575 assert_eq!(wide.recent_edit_count, 1);
2576 }
2577
2578 #[test]
2579 fn compaction_marker_sets_compacted() {
2580 let request = with_messages(vec![
2582 Message::text(
2583 Role::User,
2584 "This session is being continued from a previous conversation that ran out of context.",
2585 ),
2586 Message::text(Role::User, "Continue with the next task"),
2587 bash("ls"),
2588 ]);
2589 assert!(ToolSignals::from_request(&request, None).compacted);
2590 assert!(
2591 ToolSignalProcessor::default()
2592 .for_user_turn(&request)
2593 .compacted
2594 );
2595 }
2596
2597 #[test]
2598 fn hermes_compaction_headers_set_compacted() {
2599 for header in [
2600 "[CONTEXT COMPACTION \u{2014} REFERENCE ONLY] Earlier turns were compacted into the summary below.",
2601 "[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.]",
2602 ] {
2603 for role in [Role::User, Role::Assistant] {
2604 let request = with_messages(vec![
2605 Message::text(role, format!("{header}\nContinue the task.")),
2606 bash("ls"),
2607 ]);
2608 assert!(ToolSignals::from_request(&request, None).compacted);
2609 }
2610 let request = with_messages(vec![Message::text(
2611 Role::User,
2612 format!("Prior context.\n\n {header}\nContinue the task."),
2613 )]);
2614 assert!(ToolSignals::from_request(&request, None).compacted);
2615 let request = with_messages(vec![tr(header)]);
2616 assert!(!ToolSignals::from_request(&request, None).compacted);
2617 }
2618 }
2619
2620 #[test]
2621 fn ordinary_compaction_text_stays_uncompacted() {
2622 for text in [
2623 "The task is still in progress after the compaction boundary.",
2624 "Explain [CONTEXT COMPACTION \u{2014} REFERENCE ONLY] in the docs.",
2625 "[STILL IN PROGRESS] Continue the task.",
2626 ] {
2627 let request = with_messages(vec![Message::text(Role::User, text)]);
2628 assert!(!ToolSignals::from_request(&request, None).compacted);
2629 }
2630 }
2631
2632 #[test]
2633 fn codex_compaction_metadata_stays_on_parent_route() {
2634 let mut request = with_messages(vec![bash("ls")]);
2635 request.metadata = Some(Metadata {
2636 is_subagent: true,
2637 agent_kind: Some("compact".to_string()),
2638 ..Default::default()
2639 });
2640 assert!(!ToolSignals::from_request(&request, None).compacted);
2641 }
2642
2643 #[test]
2644 fn no_compaction_marker_stays_uncompacted() {
2645 let request = with_messages(vec![
2646 Message::text(Role::User, "Write a script that parses the log file."),
2647 bash("ls"),
2648 ]);
2649 assert!(!ToolSignals::from_request(&request, None).compacted);
2650 }
2651
2652 #[test]
2653 fn bash_heredoc_counts_as_write() {
2654 let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
2656 let sig = ToolSignals::from_request(&request, None);
2657 assert_eq!(
2658 sig.write_count, 1,
2659 "Bash heredoc should bucket into write_count"
2660 );
2661 assert_eq!(sig.edit_count, 0);
2662 }
2663
2664 #[test]
2665 fn bash_sed_inplace_counts_as_edit() {
2666 let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
2667 let sig = ToolSignals::from_request(&request, None);
2668 assert_eq!(
2669 sig.edit_count, 1,
2670 "Bash sed -i should bucket into edit_count"
2671 );
2672 assert_eq!(sig.write_count, 0);
2673 }
2674
2675 #[test]
2676 fn bash_non_mutating_does_not_count() {
2677 let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
2679 let sig = ToolSignals::from_request(&request, None);
2680 assert_eq!(sig.write_count, 0);
2681 assert_eq!(sig.edit_count, 0);
2682 }
2683
2684 #[test]
2685 fn tests_passed_detects_pytest_with_failure_block() {
2686 assert!(!detect_tests_passed(
2688 &[("2 failed, 5 passed in 0.56s".to_string(), false)],
2689 DEFAULT_RECENT_WINDOW
2690 ));
2691 }
2692
2693 #[test]
2694 fn tests_passed_accepts_cargo_clean_summary() {
2695 assert!(detect_tests_passed(
2698 &[(
2699 "running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string(),
2700 false
2701 )],
2702 DEFAULT_RECENT_WINDOW
2703 ));
2704 }
2705
2706 #[test]
2707 fn tests_passed_rejects_cargo_real_failure() {
2708 assert!(!detect_tests_passed(
2710 &[(
2711 "running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string(),
2712 false
2713 )],
2714 DEFAULT_RECENT_WINDOW
2715 ));
2716 }
2717
2718 #[test]
2719 fn tests_passed_accepts_go_clean_summary() {
2720 assert!(detect_tests_passed(
2722 &[(
2723 "ok github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string(),
2724 false
2725 )],
2726 DEFAULT_RECENT_WINDOW
2727 ));
2728 }
2729
2730 #[test]
2731 fn tests_passed_accepts_pytest_zero_errors() {
2732 assert!(detect_tests_passed(
2734 &[("5 passed, 0 errors in 0.30s".to_string(), false)],
2735 DEFAULT_RECENT_WINDOW
2736 ));
2737 }
2738
2739 #[test]
2740 fn tests_passed_detects_diy_checkmark() {
2741 assert!(detect_tests_passed(
2742 &[("✓ all checks passed".to_string(), false)],
2743 DEFAULT_RECENT_WINDOW
2744 ));
2745 }
2746
2747 #[test]
2748 fn anthropic_bash_heredoc_extracts_command() {
2749 let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
2751 let sig = ToolSignals::from_request(&request, None);
2752 assert_eq!(
2753 sig.write_count, 1,
2754 "Anthropic Bash heredoc must also be detected"
2755 );
2756 }
2757
2758 #[test]
2759 fn recent_window_falls_back_to_full_history_when_short() {
2760 let request = with_messages(vec![tc("Write")]);
2761 let sig = ToolSignals::from_request(&request, None);
2762 assert_eq!(sig.recent_write_count, 1);
2763 assert_eq!(sig.recent_edit_count, 0);
2764 }
2765
2766 #[test]
2767 fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
2768 let request = with_messages(vec![tr("output ok"), tr("another ok")]);
2769 let sig = ToolSignals::from_request(&request, None);
2770 assert_eq!(sig.severity, 0.0);
2771 assert_eq!(sig.no_error_streak, 2);
2772 }
2773
2774 #[test]
2777 fn todowrite_classifies_as_plan() {
2778 assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
2779 assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
2780 }
2781
2782 #[test]
2783 fn codex_update_plan_classifies_as_plan() {
2784 assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
2785 }
2786
2787 #[test]
2788 fn codex_shell_command_runs_bash_pattern_match() {
2789 assert_eq!(
2791 classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
2792 ToolSemantic::Mutate(MutationKind::Write),
2793 );
2794 assert_eq!(
2796 classify_tool_call("shell_command", Some("ls /app")),
2797 ToolSemantic::Observe,
2798 );
2799 assert_eq!(
2801 classify_tool_call("shell_command", Some("./run_tests.sh")),
2802 ToolSemantic::Unknown,
2803 );
2804 }
2805
2806 #[test]
2807 fn text_editor_view_is_a_read() {
2808 for name in ["str_replace_based_edit_tool", "text_editor"] {
2809 assert_eq!(
2810 classify_tool_call(name, Some("view")),
2811 ToolSemantic::Observe
2812 );
2813 for command in [
2814 Some("create"),
2815 Some("insert"),
2816 Some("str_replace"),
2817 Some("undo_edit"),
2818 None,
2819 ] {
2820 assert_eq!(
2821 classify_tool_call(name, command),
2822 ToolSemantic::Mutate(MutationKind::Edit),
2823 );
2824 }
2825 }
2826
2827 let arguments = [
2828 json!({"command": "view", "path": "/app/main.py"}),
2829 json!(r#"{"command":"view","path":"/app/main.py"}"#),
2831 ];
2832 for arguments in arguments {
2833 let call = Message {
2834 role: Role::Assistant,
2835 content: vec![ContentBlock::ToolCall(ToolCall {
2836 id: String::new(),
2837 name: "str_replace_based_edit_tool".to_string(),
2838 arguments,
2839 })],
2840 };
2841 let request = with_messages(vec![call, tr("print('hi')")]);
2842 let sig = ToolSignals::from_request(&request, None);
2843 assert_eq!(sig.read_count, 1);
2844 assert_eq!(sig.recent_read_count, 1);
2845 assert_eq!(sig.edit_count, 0);
2846 }
2847 }
2848
2849 #[test]
2850 fn read_tool_classifies_as_read() {
2851 assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
2852 assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
2853 }
2854
2855 #[test]
2856 fn hermes_tool_names_classify() {
2857 assert_eq!(
2859 classify_tool_call("write_file", None),
2860 ToolSemantic::Mutate(MutationKind::Write)
2861 );
2862 assert_eq!(
2863 classify_tool_call("patch", None),
2864 ToolSemantic::Mutate(MutationKind::Edit)
2865 );
2866 assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
2867 assert_eq!(
2868 classify_tool_call("search_files", None),
2869 ToolSemantic::Observe
2870 );
2871 assert_eq!(
2874 classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
2875 ToolSemantic::Mutate(MutationKind::Edit),
2876 );
2877 assert_eq!(
2878 classify_tool_call("terminal", Some("grep foo /app")),
2879 ToolSemantic::Observe,
2880 );
2881 assert_eq!(
2882 classify_tool_call("terminal", Some("./run_tests.sh")),
2883 ToolSemantic::Unknown,
2884 );
2885 }
2886
2887 #[test]
2888 fn bash_read_patterns_classify_as_read() {
2889 let cases = [
2890 "cat /etc/passwd",
2891 "grep foo bar.txt",
2892 "ls /app",
2893 "find . -name '*.py'",
2894 ];
2895 for cmd in cases {
2896 assert_eq!(
2897 classify_tool_call("Bash", Some(cmd)),
2898 ToolSemantic::Observe,
2899 "expected Read for {cmd}"
2900 );
2901 }
2902 }
2903
2904 #[test]
2905 fn codex_inspection_commands_classify_as_read() {
2906 let cases = [
2907 "sed -n '1,80p' src/lib.rs",
2908 "rg -n 'needle' src",
2909 "nl -ba src/lib.rs",
2910 "cat package.json",
2911 "jq '.scripts' package.json",
2912 "git status --short",
2913 "git log --oneline -5",
2914 "git show HEAD:src/lib.rs",
2915 "git branch --show-current",
2916 "git remote -v",
2917 "git config --get remote.origin.url",
2918 ];
2919 for command in cases {
2920 assert_eq!(
2921 classify_tool_call("exec_command", Some(command)),
2922 ToolSemantic::Observe,
2923 "expected Read for {command}"
2924 );
2925 }
2926 }
2927
2928 #[test]
2929 fn quoted_shell_separators_do_not_create_commands() {
2930 for command in ["rg 'foo|rm obsolete.rs'", "rg \"foo; rm obsolete.rs\""] {
2931 assert_eq!(
2932 classify_tool_call("exec_command", Some(command)),
2933 ToolSemantic::Observe,
2934 "quoted text must not be parsed as a command: {command}"
2935 );
2936 }
2937 }
2938
2939 #[test]
2940 fn codex_shell_mutations_classify_as_production() {
2941 let writes = [
2942 "cp source.rs destination.rs",
2943 "mkdir -p src/generated",
2944 "touch src/generated/mod.rs",
2945 "git show HEAD:file.rs > file.rs",
2946 "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
2947 ];
2948 for command in writes {
2949 assert_eq!(
2950 classify_tool_call("exec_command", Some(command)),
2951 ToolSemantic::Mutate(MutationKind::Write),
2952 "expected Write for {command}"
2953 );
2954 }
2955
2956 let edits = [
2957 "mv old.rs new.rs",
2958 "rm obsolete.rs",
2959 "gofmt -w main.go",
2960 "cargo fmt",
2961 "ruff check --fix src",
2962 "perl -0pi -e 's/old/new/' src/lib.rs",
2963 "npx prettier --write src/lib.ts",
2964 "uv run ruff format src",
2965 "git apply fix.patch",
2966 ];
2967 for command in edits {
2968 assert_eq!(
2969 classify_tool_call("exec_command", Some(command)),
2970 ToolSemantic::Mutate(MutationKind::Edit),
2971 "expected Edit for {command}"
2972 );
2973 }
2974 }
2975
2976 #[test]
2977 fn formatter_checks_are_not_edits() {
2978 for command in [
2979 "cargo fmt --check",
2980 "ruff format --check src",
2981 "black --check src",
2982 ] {
2983 assert_ne!(
2984 classify_tool_call("exec_command", Some(command)),
2985 ToolSemantic::Mutate(MutationKind::Edit),
2986 "read-only formatter check must not be Edit: {command}"
2987 );
2988 }
2989 }
2990
2991 #[test]
2992 fn embedded_comparison_is_not_a_shell_write() {
2993 let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
2994 assert_eq!(
2995 classify_tool_call("exec_command", Some(command)),
2996 ToolSemantic::Unknown
2997 );
2998 }
2999
3000 #[test]
3001 fn bash_write_precedence_over_read() {
3002 assert_eq!(
3005 classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
3006 ToolSemantic::Mutate(MutationKind::Write),
3007 );
3008 }
3009
3010 #[test]
3011 fn pure_bash_streak_counts_trailing_other() {
3012 let request = with_messages(vec![
3014 bash("make"),
3015 tr("ok"),
3016 bash("./configure"),
3017 tr("ok"),
3018 bash("make install"),
3019 tr("ok"),
3020 bash("./run.sh"),
3021 tr("ok"),
3022 bash("./test"),
3023 tr("ok"),
3024 ]);
3025 let sig = ToolSignals::from_request(&request, None);
3026 assert_eq!(sig.pure_bash_streak, 5);
3027 assert_eq!(sig.write_count, 0);
3028 assert_eq!(sig.read_count, 0);
3029 }
3030
3031 #[test]
3032 fn pure_bash_streak_resets_on_write() {
3033 let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
3034 let sig = ToolSignals::from_request(&request, None);
3035 assert_eq!(sig.pure_bash_streak, 0);
3036 assert_eq!(sig.write_count, 1);
3037 }
3038
3039 #[test]
3040 fn recent_window_tracks_todowrite_and_read() {
3041 let request = with_messages(vec![
3043 bash("make"),
3044 tr("ok"),
3045 tc("TodoWrite"),
3046 tr("ok"),
3047 tc("Read"),
3048 tr("ok"),
3049 tc("TodoWrite"),
3050 tr("ok"),
3051 ]);
3052 let sig = ToolSignals::from_request(&request, None);
3053 assert_eq!(sig.todowrite_count, 2);
3054 assert_eq!(sig.recent_todowrite_count, 2);
3055 assert_eq!(sig.read_count, 1);
3056 assert_eq!(sig.recent_read_count, 1);
3057 }
3058
3059 #[test]
3060 fn configured_tool_semantics_extend_the_builtin_vocabulary() {
3061 let semantics = ToolSemantics {
3062 observe: vec!["KB_search".to_string()],
3063 mutate: vec!["send_payment_request".to_string()],
3064 plan: vec!["create_research_plan".to_string()],
3065 new: vec!["send_message_to_user".to_string()],
3066 };
3067 semantics.validate().expect("valid additive semantics");
3068 let request = with_messages(vec![
3069 tc("Read"),
3070 tc("Write"),
3071 tc("TodoWrite"),
3072 tc("kb_SEARCH"),
3073 tc("send_payment_request"),
3074 tc("create_research_plan"),
3075 tc("send_message_to_user"),
3076 tc("unlisted_tool"),
3077 ]);
3078
3079 let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
3080
3081 assert_eq!(signal.read_count, 2);
3082 assert_eq!(signal.write_count, 2);
3083 assert_eq!(signal.todowrite_count, 2);
3084 assert_eq!(signal.new_count, 1);
3085 assert_eq!(signal.recent_new_count, 1);
3086 assert_eq!(signal.pure_bash_streak, 1);
3087 }
3088
3089 #[test]
3090 fn configured_tool_semantics_match_namespaced_and_mcp_tools() {
3091 let mut request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
3093 request.llm_request.extensions.fields.insert(
3094 TOOL_NAMESPACES_KEY.to_string(),
3095 json!({"mcp__billing__send_payment_request": "mcp__billing"}),
3096 );
3097
3098 let claude_request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
3100
3101 for request in [&request, &claude_request] {
3102 for name in ["send_payment_request", "mcp__billing__send_payment_request"] {
3103 let semantics = ToolSemantics {
3104 mutate: vec![name.to_string()],
3105 ..Default::default()
3106 };
3107 let signal = ToolSignals::from_request_with_semantics(request, None, &semantics);
3108 assert_eq!(signal.write_count, 1, "{name}");
3109 }
3110 }
3111 }
3112
3113 #[test]
3114 fn configured_tool_semantics_only_fold_ascii_case() {
3115 let semantics = ToolSemantics {
3116 observe: vec!["kb_search".to_string()],
3117 ..Default::default()
3118 };
3119
3120 assert_eq!(
3121 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
3122 ToolSemantic::Observe
3123 );
3124 assert_eq!(
3127 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
3128 ToolSemantic::Unknown
3129 );
3130 }
3131
3132 #[test]
3133 fn custom_semantics_preserve_builtin_unicode_lowercasing() {
3134 let semantics = ToolSemantics {
3135 observe: vec!["lookup_customer".to_string()],
3136 ..Default::default()
3137 };
3138
3139 assert_eq!(
3141 classify_tool_call_with_semantics("notebooKedit", None, &semantics),
3142 ToolSemantic::Mutate(MutationKind::Edit)
3143 );
3144 }
3145
3146 #[test]
3147 fn configured_semantics_never_replace_builtin_classifications() {
3148 let semantics = ToolSemantics {
3149 observe: vec!["lookup_customer".to_string()],
3150 mutate: vec!["send_payment".to_string()],
3151 plan: vec!["create_workflow".to_string()],
3152 new: vec!["send_message".to_string()],
3153 };
3154
3155 for name in WRITE_TOOL_NAMES {
3156 assert_eq!(
3157 classify_tool_call_with_semantics(name, None, &semantics),
3158 ToolSemantic::Mutate(MutationKind::Write),
3159 "write tool {name:?} changed classification"
3160 );
3161 }
3162 for name in EDIT_TOOL_NAMES {
3163 assert_eq!(
3164 classify_tool_call_with_semantics(name, None, &semantics),
3165 ToolSemantic::Mutate(MutationKind::Edit),
3166 "edit tool {name:?} changed classification"
3167 );
3168 }
3169 for name in READ_TOOL_NAMES {
3170 assert_eq!(
3171 classify_tool_call_with_semantics(name, None, &semantics),
3172 ToolSemantic::Observe,
3173 "read tool {name:?} changed classification"
3174 );
3175 }
3176 for name in PLAN_TOOL_NAMES {
3177 assert_eq!(
3178 classify_tool_call_with_semantics(name, None, &semantics),
3179 ToolSemantic::Plan,
3180 "plan tool {name:?} changed classification"
3181 );
3182 }
3183
3184 for (command, expected) in [
3185 ("cat /tmp/input", ToolSemantic::Observe),
3186 (
3187 "cat /tmp/input > /tmp/output",
3188 ToolSemantic::Mutate(MutationKind::Write),
3189 ),
3190 (
3191 "sed -i 's/a/b/' /tmp/file",
3192 ToolSemantic::Mutate(MutationKind::Edit),
3193 ),
3194 ("./run_tests.sh", ToolSemantic::Unknown),
3195 ] {
3196 assert_eq!(
3197 classify_tool_call_with_semantics("BASH", Some(command), &semantics),
3198 expected,
3199 "bash command {command:?} changed classification"
3200 );
3201 }
3202 }
3203
3204 #[test]
3205 fn configured_semantics_score_like_their_builtin_equivalents() {
3206 let semantics = ToolSemantics {
3207 observe: vec!["lookup_customer".to_string()],
3208 mutate: vec!["send_payment".to_string()],
3209 plan: vec!["create_workflow".to_string()],
3210 ..Default::default()
3211 };
3212
3213 for (builtin, configured) in [
3214 ("Read", "lookup_customer"),
3215 ("Write", "send_payment"),
3216 ("TodoWrite", "create_workflow"),
3217 ] {
3218 let messages_before_tool = || {
3219 vec![
3220 Message::text(Role::User, "start"),
3221 Message::text(Role::Assistant, "working"),
3222 Message::text(Role::User, "continue"),
3223 Message::text(Role::Assistant, "working"),
3224 Message::text(Role::User, "continue"),
3225 Message::text(Role::Assistant, "working"),
3226 Message::text(Role::User, "continue"),
3227 ]
3228 };
3229 let mut builtin_messages = messages_before_tool();
3230 builtin_messages.push(tc(builtin));
3231 let mut configured_messages = messages_before_tool();
3232 configured_messages.push(tc(configured));
3233
3234 let builtin_score = score_signal(&ToolSignals::from_request(
3235 &with_messages(builtin_messages),
3236 None,
3237 ));
3238 let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
3239 &with_messages(configured_messages),
3240 None,
3241 &semantics,
3242 ));
3243
3244 assert_ne!(
3245 builtin_score.score, 0.0,
3246 "the {builtin:?} control must exercise a scoring dimension"
3247 );
3248 assert_eq!(
3249 configured_score, builtin_score,
3250 "configured tool {configured:?} must score exactly like {builtin:?}"
3251 );
3252 }
3253 }
3254
3255 #[test]
3256 fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
3257 let duplicate = ToolSemantics {
3258 observe: vec!["lookup".to_string()],
3259 mutate: vec!["LOOKUP".to_string()],
3260 ..Default::default()
3261 };
3262 assert!(
3263 duplicate
3264 .validate()
3265 .expect_err("duplicate should fail")
3266 .to_string()
3267 .contains("appears in both")
3268 );
3269
3270 let builtin = ToolSemantics {
3271 new: vec!["write_file".to_string()],
3272 ..Default::default()
3273 };
3274 assert!(
3275 builtin
3276 .validate()
3277 .expect_err("built-in should fail")
3278 .to_string()
3279 .contains("built-in semantics")
3280 );
3281
3282 let empty = ToolSemantics {
3283 observe: vec![" \t".to_string()],
3284 ..Default::default()
3285 };
3286 assert!(
3287 empty
3288 .validate()
3289 .expect_err("empty name should fail")
3290 .to_string()
3291 .contains("empty tool name")
3292 );
3293 }
3294}