1#![allow(dead_code)]
14
15use std::collections::HashSet;
16use std::path::Path;
17
18use async_trait::async_trait;
19use serde::Deserialize;
20use serde_json::Value;
21use switchyard_protocol::codex_namespaces::{split_qualified_name, tool_namespaces};
22use switchyard_protocol::{ContentBlock, Request, Role, WireFormat};
23
24use crate::{LibsyError, Result};
25
26use crate::core::processor::{Event, Processor};
27use crate::core::state::State;
28
29const SOFT: f32 = 0.3;
32const HARD: f32 = 0.7;
33const CRITICAL: f32 = 1.0;
34
35static ERROR_PATTERNS: &[(&str, f32, &[&str])] = &[
39 (
40 "oom",
41 CRITICAL,
42 &["out of memory", "memoryerror", "cannot allocate memory"],
43 ),
44 (
45 "connection_refused",
46 HARD,
47 &[
48 "connection refused",
49 "connectionrefusederror",
50 "econnrefused",
51 ],
52 ),
53 ("traceback", HARD, &["traceback (most recent call last)"]),
54 (
55 "import_error",
56 HARD,
57 &["modulenotfounderror:", "importerror:", "no module named "],
58 ),
59 (
60 "cmd_not_found",
61 HARD,
62 &["command not found", "not found\n", "/usr/bin/env: "],
63 ),
64 ("assertion", HARD, &["assertionerror"]),
65 ("value_error", HARD, &["valueerror:"]),
66 ("syntax_error", HARD, &["syntaxerror:"]),
67 (
68 "timeout",
69 HARD,
70 &[
71 "timed out",
72 "timeouterror",
73 "timeout expired",
74 "deadline exceeded",
75 ],
76 ),
77 (
78 "no_such_file",
79 HARD,
80 &[
81 "filenotfounderror:",
82 "no such file or directory",
83 "file does not exist",
87 ],
88 ),
89 ("exit_nonzero", SOFT, &["returned non-zero"]),
91];
92
93static NONZERO_EXIT_PHRASES: &[&str] = &[
94 "exit code",
95 "exit status",
96 "exited with code",
97 "exited with status",
98];
99
100static EDIT_TOOL_NAMES: &[&str] = &[
101 "edit",
102 "multiedit",
103 "notebookedit",
104 "str_replace",
105 "str_replace_based_edit_tool",
106 "apply_patch", "text_editor",
108 "patch", ];
110
111static EDITOR_TOOL_NAMES: &[&str] = &["str_replace_based_edit_tool", "text_editor"];
113
114static WRITE_TOOL_NAMES: &[&str] = &["write", "create_file", "new_file", "write_file"];
115
116static BASH_WRITE_PATTERNS: &[&str] = &[
120 "cat >",
121 "cat >>",
122 "echo >",
123 "echo >>",
124 "tee ",
125 "printf >",
126 "printf >>",
127 "> /",
128 ">> /",
129 "<< 'eof'",
130 "<<eof",
131 "<<'eof'",
132 "<< eof",
133];
134
135static PYTHON_WRITE_PATTERNS: &[&str] = &["write_text(", "writelines(", ".write("];
138
139static JAVASCRIPT_WRITE_PATTERNS: &[&str] = &[
140 "writefilesync(",
141 "writefile(",
142 "appendfilesync(",
143 "appendfile(",
144];
145
146static BASH_EDIT_PATTERNS: &[&str] = &[
147 "sed -i",
148 "sed --in-place",
149 "awk -i inplace",
150 "awk 'inplace=1'",
151 "patch ",
152 "patch -p",
153 "perl -i",
154 "perl -p -i",
155 "perl -pi",
156];
157
158static BASH_READ_PATTERNS: &[&str] = &[
161 "cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
162 "diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
163];
164
165static BASH_READ_COMMANDS: &[&str] = &[
168 "cat", "rg", "nl", "jq", "pwd", "tree", "sed", "grep", "ls", "find", "head", "tail", "wc",
169 "diff", "which", "ps", "df", "du", "stat", "file", "less", "more", "readlink", "realpath",
170 "basename", "dirname", "printenv",
171];
172
173static GIT_READ_SUBCOMMANDS: &[&str] = &[
174 "status",
175 "diff",
176 "log",
177 "show",
178 "show-ref",
179 "rev-parse",
180 "ls-files",
181 "ls-remote",
182 "ls-tree",
183 "grep",
184 "blame",
185 "merge-base",
186 "check-ignore",
187 "tag",
188];
189
190static READ_TOOL_NAMES: &[&str] = &[
191 "read",
192 "view",
193 "read_file",
194 "search_files",
195 "glob",
196 "grep",
197 "find",
198 "ls",
199];
200
201static PLAN_TOOL_NAMES: &[&str] = &[
204 "todowrite",
205 "todo_write",
206 "todo",
207 "update_plan",
208 "todo_list",
209];
210
211static BASH_TOOL_NAMES: &[&str] = &[
216 "bash",
217 "shell_command",
218 "shell",
219 "local_shell_call",
220 "terminal",
221 "exec_command", "exec", "powershell", ];
225
226static TEST_PASS_PHRASES: &[&str] = &[
229 " passed",
230 "passed in",
231 "tests passed",
232 "all tests passed",
233 "test ok",
234 "test result: ok",
235 "passed.\n",
236 "tests pass",
237 "\nok ", "✓ ",
239];
240
241static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
246
247static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
251
252pub const DEFAULT_RECENT_WINDOW: usize = 3;
259
260#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
266#[serde(default, deny_unknown_fields)]
267pub struct ToolSemantics {
268 pub observe: Vec<String>,
270 pub mutate: Vec<String>,
272 pub plan: Vec<String>,
274 pub new: Vec<String>,
276}
277
278impl ToolSemantics {
279 pub fn validate(&self) -> Result<()> {
281 let mut seen: Vec<(String, &'static str)> = Vec::new();
282 for (category, names) in [
283 ("observe", &self.observe),
284 ("mutate", &self.mutate),
285 ("plan", &self.plan),
286 ("new", &self.new),
287 ] {
288 for name in names {
289 if name.trim().is_empty() {
290 return Err(tool_semantics_error(format!(
291 "tool_semantics.{category} contains an empty tool name"
292 )));
293 }
294 let normalized = name.to_ascii_lowercase();
295 if is_builtin_tool_name(&name.to_lowercase()) {
296 return Err(tool_semantics_error(format!(
297 "tool {name:?} already has built-in semantics and cannot be reclassified"
298 )));
299 }
300 if let Some((_, previous)) = seen.iter().find(|(seen, _)| seen == &normalized) {
301 return Err(tool_semantics_error(format!(
302 "tool {name:?} appears in both tool_semantics.{previous} and tool_semantics.{category}"
303 )));
304 }
305 seen.push((normalized, category));
306 }
307 }
308 Ok(())
309 }
310
311 fn classify(&self, name: &str) -> Option<ToolSemantic> {
312 if contains_name(&self.observe, name) {
313 Some(ToolSemantic::Observe)
314 } else if contains_name(&self.mutate, name) {
315 Some(ToolSemantic::Mutate(MutationKind::Write))
318 } else if contains_name(&self.plan, name) {
319 Some(ToolSemantic::Plan)
320 } else if contains_name(&self.new, name) {
321 Some(ToolSemantic::New)
322 } else {
323 None
324 }
325 }
326}
327
328fn contains_name(names: &[String], candidate: &str) -> bool {
329 names
330 .iter()
331 .any(|name| name.eq_ignore_ascii_case(candidate))
332}
333
334fn tool_semantics_error(message: String) -> LibsyError {
335 LibsyError::AlgorithmError { message }
336}
337
338#[derive(Clone, Debug, Default)]
347pub struct ToolSignals {
348 pub severity: f32,
353 pub repeated_failure: bool,
356 pub no_error_streak: u32,
358 pub edit_count: u32,
360 pub write_count: u32,
362 pub read_count: u32,
364 pub todowrite_count: u32,
367 pub recent_edit_count: u32,
369 pub recent_write_count: u32,
371 pub recent_read_count: u32,
373 pub recent_todowrite_count: u32,
375 pub new_count: u32,
377 pub recent_new_count: u32,
379 pub pure_bash_streak: u32,
382 pub tests_passed: bool,
384 pub tool_result_count: u32,
387 pub assistant_turn_count: u32,
390 pub turn_depth: u32,
394 pub compacted: bool,
400}
401
402impl ToolSignals {
403 pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
408 Self::from_request_with_semantics(request, window_size, &ToolSemantics::default())
409 }
410
411 pub fn from_request_with_semantics(
413 request: &Request,
414 window_size: Option<usize>,
415 semantics: &ToolSemantics,
416 ) -> Self {
417 extract_tool_signals_with_window_and_semantics(
418 request,
419 window_size.unwrap_or(DEFAULT_RECENT_WINDOW),
420 semantics,
421 )
422 }
423}
424
425#[derive(Debug, Clone)]
428struct ObservedToolCall<'a> {
429 name: String,
430 bare_name: Option<&'a str>,
431 command: Option<String>,
432}
433
434#[derive(Debug, Clone, Copy, PartialEq, Eq)]
435enum MutationKind {
436 Write,
437 Edit,
438}
439
440#[derive(Debug, Clone, Copy, PartialEq, Eq)]
442enum ToolSemantic {
443 Mutate(MutationKind),
444 Observe,
445 Plan,
446 New,
447 Unknown,
448}
449
450#[derive(Debug, Clone)]
453pub struct ToolSignalProcessor {
454 pub recent_window: usize,
457 pub tool_semantics: ToolSemantics,
459}
460
461impl Default for ToolSignalProcessor {
462 fn default() -> Self {
463 Self {
464 recent_window: DEFAULT_RECENT_WINDOW,
465 tool_semantics: ToolSemantics::default(),
466 }
467 }
468}
469
470#[async_trait]
471impl Processor<State> for ToolSignalProcessor {
472 async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
473 if let Event::Request { request: req, .. } = event {
474 let tool_signal = ToolSignals::from_request_with_semantics(
475 req,
476 Some(self.recent_window),
477 &self.tool_semantics,
478 );
479 state.tool_signals = Some(tool_signal);
480 }
481 Ok(())
482 }
483}
484
485fn classify_tool_call(name: &str, command: Option<&str>) -> ToolSemantic {
486 classify_tool_call_with_semantics(name, command, &ToolSemantics::default())
487}
488
489fn classify_tool_call_with_semantics(
490 name: &str,
491 command: Option<&str>,
492 semantics: &ToolSemantics,
493) -> ToolSemantic {
494 let lower = name.to_lowercase();
496 if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
497 return ToolSemantic::Mutate(MutationKind::Write);
498 }
499 if EDITOR_TOOL_NAMES.contains(&lower.as_str()) && command == Some("view") {
500 return ToolSemantic::Observe;
501 }
502 if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
503 return ToolSemantic::Mutate(MutationKind::Edit);
504 }
505 if READ_TOOL_NAMES.contains(&lower.as_str()) {
506 return ToolSemantic::Observe;
507 }
508 if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
509 return ToolSemantic::Plan;
510 }
511 if BASH_TOOL_NAMES.contains(&lower.as_str())
512 && let Some(cmd) = command
513 {
514 if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_write(cmd) {
516 return ToolSemantic::Mutate(MutationKind::Write);
517 }
518 if cmd.contains("python") && PYTHON_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
519 return ToolSemantic::Mutate(MutationKind::Write);
520 }
521 if shell_invokes_program(cmd, "node")
522 && JAVASCRIPT_WRITE_PATTERNS
523 .iter()
524 .any(|pattern| cmd.contains(pattern))
525 {
526 return ToolSemantic::Mutate(MutationKind::Write);
527 }
528 if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_edit(cmd) {
529 return ToolSemantic::Mutate(MutationKind::Edit);
530 }
531 if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_read(cmd) {
532 return ToolSemantic::Observe;
533 }
534 }
535 semantics.classify(name).unwrap_or(ToolSemantic::Unknown)
536}
537
538fn is_retrieval_tool(name: &str, command: Option<&str>) -> bool {
540 let lower = name.to_lowercase();
541 READ_TOOL_NAMES.contains(&lower.as_str())
542 || (EDITOR_TOOL_NAMES.contains(&lower.as_str()) && command == Some("view"))
543}
544
545fn is_builtin_tool_name(lower: &str) -> bool {
546 WRITE_TOOL_NAMES.contains(&lower)
547 || EDIT_TOOL_NAMES.contains(&lower)
548 || READ_TOOL_NAMES.contains(&lower)
549 || PLAN_TOOL_NAMES.contains(&lower)
550 || BASH_TOOL_NAMES.contains(&lower)
551}
552
553fn shell_segments(command: &str) -> impl Iterator<Item = &str> {
557 let mut chars = command.char_indices();
558 let mut start = 0usize;
559 let mut quote = None;
560 let mut escaped = false;
561 let mut finished = false;
562
563 std::iter::from_fn(move || {
564 loop {
565 for (index, character) in chars.by_ref() {
566 if escaped {
567 escaped = false;
568 } else if character == '\\' && quote != Some('\'') {
569 escaped = true;
570 } else if quote == Some(character) {
571 quote = None;
572 } else if quote.is_none() && matches!(character, '\'' | '"') {
573 quote = Some(character);
574 } else if quote.is_none() && matches!(character, '\n' | ';' | '|' | '&') {
575 let segment = command[start..index].trim();
576 start = index + character.len_utf8();
577 if !segment.is_empty() {
578 return Some(segment);
579 }
580 }
581 }
582
583 if finished {
584 return None;
585 }
586 finished = true;
587 let segment = command[start..].trim();
588 if !segment.is_empty() {
589 return Some(segment);
590 }
591 }
592 })
593}
594
595fn shell_words(segment: &str) -> std::iter::Peekable<std::str::SplitAsciiWhitespace<'_>> {
596 let mut words = segment.split_ascii_whitespace().peekable();
597
598 if words.peek().copied() == Some("env") {
599 words.next();
600 while words.peek().is_some_and(|word| word.starts_with('-')) {
601 words.next();
602 }
603 }
604 while words
605 .peek()
606 .is_some_and(|word| word.contains('=') && !word.starts_with('='))
607 {
608 words.next();
609 }
610
611 words
612}
613
614fn program_name(word: &str) -> &str {
615 Path::new(word)
616 .file_name()
617 .and_then(|name| name.to_str())
618 .unwrap_or(word)
619}
620
621fn shell_invokes_program(command: &str, expected: &str) -> bool {
622 shell_segments(command).any(|segment| {
623 shell_words(segment)
624 .next()
625 .is_some_and(|word| program_name(word) == expected)
626 })
627}
628
629fn shell_command_is_write(command: &str) -> bool {
630 shell_segments(command).any(|segment| {
631 let mut words = shell_words(segment);
632 let Some(program) = words.next().map(program_name) else {
633 return false;
634 };
635 if matches!(program, "cp" | "mkdir" | "touch" | "install") {
636 return true;
637 }
638
639 let redirects_output = words.any(|word| matches!(word, ">" | ">>"));
640 redirects_output
641 && (matches!(program, "echo" | "printf" | "git")
642 || BASH_READ_COMMANDS.contains(&program))
643 })
644}
645
646fn shell_command_is_edit(command: &str) -> bool {
647 shell_segments(command).any(|segment| {
648 let mut words = shell_words(segment);
649 let Some(program) = words.next().map(program_name) else {
650 return false;
651 };
652 let has_arg = |arg: &str| words.clone().any(|word| word == arg);
653
654 match program {
655 "mv" | "rm" => true,
656 "perl" => words
657 .take_while(|word| word.starts_with('-'))
658 .any(|option| {
659 option
660 .trim_start_matches('-')
661 .chars()
662 .any(|flag| flag == 'i')
663 }),
664 "git" => words
665 .next()
666 .is_some_and(|subcommand| matches!(subcommand, "apply" | "am" | "restore")),
667 "gofmt" => has_arg("-w"),
668 "cargo" => words.clone().next() == Some("fmt") && !has_arg("--check"),
669 "ruff" => {
670 let subcommand = words.clone().next();
671 (subcommand == Some("format") && !has_arg("--check"))
672 || (subcommand == Some("check") && has_arg("--fix"))
673 }
674 "prettier" => has_arg("--write"),
675 "black" => !has_arg("--check"),
676 _ => {
677 (words.clone().any(|word| program_name(word) == "prettier") && has_arg("--write"))
678 || (words.clone().any(|word| program_name(word) == "ruff")
679 && ((has_arg("format") && !has_arg("--check"))
680 || (has_arg("check") && has_arg("--fix"))))
681 }
682 }
683 })
684}
685
686fn shell_command_is_read(command: &str) -> bool {
687 shell_segments(command).any(|segment| {
688 if segment == "env" {
689 return true;
690 }
691 let mut words = shell_words(segment);
692 let Some(program) = words.next().map(program_name) else {
693 return false;
694 };
695
696 if BASH_READ_COMMANDS.contains(&program) {
697 return true;
698 }
699 if program == "command" && words.next() == Some("-v") {
700 return true;
701 }
702 if program == "type" {
703 return true;
704 }
705 if program != "git" {
706 return false;
707 }
708
709 match words.next() {
710 Some("branch") => words.next().is_none_or(|arg| arg.starts_with('-')),
711 Some("remote") => words
712 .next()
713 .is_none_or(|arg| arg.starts_with('-') || arg == "get-url"),
714 Some("config") => words
715 .next()
716 .is_some_and(|arg| matches!(arg, "--get" | "--get-all" | "--list" | "-l")),
717 Some(subcommand) => GIT_READ_SUBCOMMANDS.contains(&subcommand),
718 None => false,
719 }
720 })
721}
722
723fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
730 extract_tool_signals_with_window_and_semantics(
731 request,
732 recent_window,
733 &ToolSemantics::default(),
734 )
735}
736
737fn extract_tool_signals_with_window_and_semantics(
738 request: &Request,
739 recent_window: usize,
740 semantics: &ToolSemantics,
741) -> ToolSignals {
742 let messages = &request.llm_request.messages;
744 let namespaces = tool_namespaces(&request.llm_request.extensions);
745 let mut tool_texts: Vec<(String, bool)> = Vec::new();
746 let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
747 let mut retrieval_calls: HashSet<&str> = HashSet::new();
749 let mut shell_read_calls: HashSet<&str> = HashSet::new();
750 let mut compacted = false;
751 let mut tool_result_count = 0usize;
752 let mut assistant_turn_count = 0usize;
753
754 for message in messages {
755 if message.role == Role::Assistant {
756 assistant_turn_count += 1;
757 }
758 for block in &message.content {
759 match block {
760 ContentBlock::ToolCall(call) => {
761 let bare_name = namespaces
763 .and_then(|namespaces| split_qualified_name(namespaces, &call.name))
764 .map(|(tool, _)| tool)
765 .or_else(|| mcp_tool_name(&call.name));
766 let command = command_of(&call.arguments);
767 if !call.id.is_empty() {
768 let full = classify_tool_call_with_semantics(
772 &call.name,
773 command.as_deref(),
774 semantics,
775 );
776 let name = match (full, bare_name) {
777 (ToolSemantic::Unknown | ToolSemantic::Observe, Some(bare_name)) => {
778 bare_name
779 }
780 _ => call.name.as_str(),
781 };
782 if is_retrieval_tool(name, command.as_deref()) {
784 retrieval_calls.insert(call.id.as_str());
785 } else {
786 retrieval_calls.remove(call.id.as_str());
787 }
788 if BASH_TOOL_NAMES.contains(&name.to_lowercase().as_str())
789 && command.as_deref().is_some_and(|command| {
790 !command.trim().is_empty()
791 && shell_segments(command).all(|segment| {
792 classify_tool_call(name, Some(segment))
793 == ToolSemantic::Observe
794 })
795 })
796 {
797 shell_read_calls.insert(call.id.as_str());
798 } else {
799 shell_read_calls.remove(call.id.as_str());
800 }
801 }
802 tool_calls.push(ObservedToolCall {
803 name: call.name.clone(),
804 bare_name,
805 command,
806 });
807 }
808 ContentBlock::ToolResult(result) => {
809 tool_result_count += 1;
811 let text = result
812 .content
813 .iter()
814 .filter_map(text_of)
815 .collect::<Vec<_>>()
816 .join("\n");
817 let is_error = result.is_error == Some(true);
818 let is_shell_read_json = shell_read_calls
821 .contains(result.tool_call_id.as_str())
822 && serde_json::from_str::<Value>(&text).is_ok_and(|value| {
823 value.is_object()
824 && !(value["output"].is_string()
825 && value.get("exit_code").is_some())
826 });
827 let is_retrieval_result = !is_error
828 && (retrieval_calls.contains(result.tool_call_id.as_str())
829 || is_shell_read_json);
830 if !text.is_empty() || is_error {
832 let text = if is_retrieval_result {
835 String::new()
836 } else {
837 text
838 };
839 tool_texts.push((text, is_error));
840 }
841 }
842 ContentBlock::Unknown { provider, raw }
844 if provider.as_str() == WireFormat::OpenAiResponses.as_str()
845 && raw.get("type").and_then(Value::as_str)
846 == Some("apply_patch_call_output") =>
847 {
848 tool_result_count += 1;
849 let text = raw
850 .get("output")
851 .and_then(Value::as_str)
852 .unwrap_or_default();
853 let is_error = raw.get("status").and_then(Value::as_str) == Some("failed");
854 tool_texts.push((text.to_owned(), is_error));
855 }
856 ContentBlock::Unknown { provider, raw }
857 if provider.as_str() == WireFormat::OpenAiResponses.as_str()
858 && raw.get("type").and_then(Value::as_str) == Some("shell_call_output") =>
859 {
860 tool_result_count += 1;
861 let mut texts = Vec::new();
862 let mut is_error = false;
863 if let Some(outputs) = raw.get("output").and_then(Value::as_array) {
864 for output in outputs {
865 for field in ["stdout", "stderr"] {
866 if let Some(text) = output.get(field).and_then(Value::as_str)
867 && !text.is_empty()
868 {
869 texts.push(text);
870 }
871 }
872 if let Some(outcome) = output.get("outcome") {
873 is_error |= match outcome.get("type").and_then(Value::as_str) {
874 Some("timeout") => true,
875 Some("exit")
876 if outcome
877 .get("exit_code")
878 .and_then(Value::as_i64)
879 .is_some_and(|code| code != 0) =>
880 {
881 texts.push("returned non-zero");
883 output
884 .get("stderr")
885 .and_then(Value::as_str)
886 .is_some_and(|text| !text.trim().is_empty())
887 }
888 _ => false,
889 };
890 }
891 }
892 }
893 let text = texts.join("\n");
894 tool_texts.push((text, is_error));
895 }
896 ContentBlock::Text { text } => {
900 let text = text.to_lowercase();
901 compacted |= text.contains(COMPACTION_MARKER)
902 || text.lines().any(|line| {
903 HERMES_COMPACTION_MARKERS
904 .iter()
905 .any(|marker| line.trim_start().starts_with(marker))
906 });
907 }
908 _ => {}
909 }
910 }
911 }
912
913 let mut signal = build_signal(
914 tool_texts,
915 tool_calls,
916 messages.len() as u32,
917 recent_window,
918 semantics,
919 );
920 signal.compacted = compacted;
921 signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
922 signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
923 signal
924}
925
926const COMPACTION_MARKER: &str = "session is being continued";
929
930const HERMES_COMPACTION_MARKERS: &[&str] = &[
932 "[context compaction \u{2014} reference only]",
933 "[still in progress \u{2014} this is the active request, restated after the compaction boundary",
934];
935
936fn mcp_tool_name(name: &str) -> Option<&str> {
940 let (_server, tool) = name.strip_prefix("mcp__")?.split_once("__")?;
941 (!tool.is_empty()).then_some(tool)
942}
943
944fn command_of(arguments: &Value) -> Option<String> {
947 let decoded = arguments
949 .as_str()
950 .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
951 let object = decoded.as_ref().unwrap_or(arguments);
952
953 ["command", "cmd", "input"]
954 .iter()
955 .filter_map(|key| object.get(*key))
956 .find_map(command_text)
957}
958
959fn command_text(value: &Value) -> Option<String> {
961 match value {
962 Value::String(text) => Some(text.to_lowercase()),
963 Value::Array(parts) => {
964 let joined = parts
965 .iter()
966 .filter_map(Value::as_str)
967 .collect::<Vec<_>>()
968 .join(" ");
969 (!joined.is_empty()).then(|| joined.to_lowercase())
970 }
971 _ => None,
972 }
973}
974
975fn text_of(block: &ContentBlock) -> Option<&str> {
977 match block {
978 ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
979 _ => None,
980 }
981}
982
983fn build_signal(
984 tool_texts: Vec<(String, bool)>,
985 tool_calls: Vec<ObservedToolCall>,
986 turn_depth: u32,
987 recent_window: usize,
988 semantics: &ToolSemantics,
989) -> ToolSignals {
990 let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
996 let mut severity = 0.0f32;
997 let mut failure_fingerprints = Vec::new();
998 let mut repeated_failure = false;
999 for (text, is_error) in &tool_texts[sev_start..] {
1000 let (sev, _patterns) = classify_text(text);
1001 let sev = if *is_error { sev.max(HARD) } else { sev };
1003 if sev > severity {
1004 severity = sev;
1005 }
1006 if let Some(fingerprint) = failure_fingerprint(text, *is_error) {
1007 repeated_failure |= failure_fingerprints.contains(&fingerprint);
1008 failure_fingerprints.push(fingerprint);
1009 }
1010 }
1011
1012 let no_error_streak = compute_no_error_streak(&tool_texts);
1013
1014 let recent_start = tool_calls.len().saturating_sub(recent_window);
1018 let mut write_count = 0u32;
1019 let mut edit_count = 0u32;
1020 let mut read_count = 0u32;
1021 let mut todowrite_count = 0u32;
1022 let mut recent_write_count = 0u32;
1023 let mut recent_edit_count = 0u32;
1024 let mut recent_read_count = 0u32;
1025 let mut recent_todowrite_count = 0u32;
1026 let mut new_count = 0u32;
1027 let mut recent_new_count = 0u32;
1028 let mut pure_bash_streak = 0u32;
1029 let mut streak_open = true;
1030 for (i, tc) in tool_calls.iter().enumerate().rev() {
1031 let mut cat = classify_tool_call_with_semantics(&tc.name, tc.command.as_deref(), semantics);
1033 if matches!(cat, ToolSemantic::Unknown)
1034 && let Some(bare_name) = tc.bare_name
1035 {
1036 cat = classify_tool_call_with_semantics(bare_name, tc.command.as_deref(), semantics);
1037 }
1038 if streak_open {
1039 if matches!(cat, ToolSemantic::Unknown) {
1040 pure_bash_streak += 1;
1041 } else {
1042 streak_open = false;
1043 }
1044 }
1045 match cat {
1046 ToolSemantic::Mutate(MutationKind::Write) => {
1047 write_count += 1;
1048 if i >= recent_start {
1049 recent_write_count += 1;
1050 }
1051 }
1052 ToolSemantic::Mutate(MutationKind::Edit) => {
1053 edit_count += 1;
1054 if i >= recent_start {
1055 recent_edit_count += 1;
1056 }
1057 }
1058 ToolSemantic::Observe => {
1059 read_count += 1;
1060 if i >= recent_start {
1061 recent_read_count += 1;
1062 }
1063 }
1064 ToolSemantic::Plan => {
1065 todowrite_count += 1;
1066 if i >= recent_start {
1067 recent_todowrite_count += 1;
1068 }
1069 }
1070 ToolSemantic::New => {
1071 new_count += 1;
1072 if i >= recent_start {
1073 recent_new_count += 1;
1074 }
1075 }
1076 ToolSemantic::Unknown => {}
1077 }
1078 }
1079
1080 let tests_passed = detect_tests_passed(&tool_texts, recent_window);
1081
1082 ToolSignals {
1083 severity,
1084 repeated_failure,
1085 no_error_streak,
1086 edit_count,
1087 write_count,
1088 read_count,
1089 todowrite_count,
1090 recent_edit_count,
1091 recent_write_count,
1092 recent_read_count,
1093 recent_todowrite_count,
1094 new_count,
1095 recent_new_count,
1096 pure_bash_streak,
1097 tests_passed,
1098 turn_depth,
1099 tool_result_count: 0,
1103 assistant_turn_count: 0,
1104 compacted: false,
1105 }
1106}
1107
1108fn content_to_text(content: Option<&Value>) -> Option<String> {
1112 match content? {
1113 Value::String(s) => Some(s.clone()),
1114 Value::Array(blocks) => {
1115 let parts: Vec<&str> = blocks
1116 .iter()
1117 .filter_map(|b| {
1118 b.as_object()
1119 .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
1120 .and_then(|o| o.get("text"))
1121 .and_then(Value::as_str)
1122 })
1123 .collect();
1124 if parts.is_empty() {
1125 None
1126 } else {
1127 Some(parts.join("\n"))
1128 }
1129 }
1130 _ => None,
1131 }
1132}
1133
1134pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
1138 let lower = text.to_lowercase();
1139 let mut patterns = Vec::new();
1140 let mut severity: f32 = 0.0;
1141 for (name, sev, substrings) in ERROR_PATTERNS {
1142 if substrings.iter().any(|sub| lower.contains(sub)) {
1143 patterns.push(name.to_string());
1144 severity = severity.max(*sev);
1145 }
1146 }
1147 let result = serde_json::Deserializer::from_str(text)
1149 .into_iter::<Value>()
1150 .next()
1151 .and_then(|result| result.ok())
1152 .unwrap_or_default();
1153 let nonzero_exit = result["exit_code"].as_i64().is_some_and(|code| code != 0);
1154 let tool_error = result["success"].as_bool() == Some(false)
1155 || result["error"]
1156 .as_str()
1157 .is_some_and(|error| !error.trim().is_empty());
1158 if (nonzero_exit || has_nonzero_exit_status(&lower))
1159 && !patterns.iter().any(|p| p == "exit_nonzero")
1160 {
1161 patterns.push("exit_nonzero".to_string());
1162 severity = severity.max(SOFT);
1163 }
1164 for (name, matched) in [
1165 ("tool_error", tool_error),
1166 ("compile_error", has_compiler_diagnostic(&lower)),
1167 ("runtime_exception", has_runtime_exception(&lower)),
1168 ("runtime_panic", has_runtime_panic(&lower)),
1169 ("patch_error", has_patch_failure(&lower)),
1170 ] {
1171 if matched && !patterns.iter().any(|pattern| pattern == name) {
1172 patterns.push(name.to_string());
1173 severity = severity.max(HARD);
1174 }
1175 }
1176 (severity, patterns)
1177}
1178
1179fn failure_fingerprint(text: &str, is_error: bool) -> Option<String> {
1182 let (severity, patterns) = classify_text(text);
1183 if severity < HARD && !is_error {
1184 return None;
1185 }
1186
1187 let lower = text.to_lowercase();
1188 let diagnostic = lower
1189 .lines()
1190 .find(|line| is_failure_diagnostic(line))
1191 .or_else(|| lower.lines().find(|line| !line.trim().is_empty()))
1192 .unwrap_or_default();
1193 let normalized = normalize_failure_text(diagnostic);
1194 Some(format!("{}|{normalized}", patterns.join(",")))
1195}
1196
1197fn is_failure_diagnostic(line: &str) -> bool {
1198 let line = line.trim();
1199 [
1200 "error",
1201 "exception",
1202 "panic",
1203 "failed",
1204 "timed out",
1205 "timeout",
1206 "connection refused",
1207 "cannot allocate memory",
1208 "out of memory",
1209 "not found",
1210 ]
1211 .iter()
1212 .any(|marker| line.contains(marker))
1213}
1214
1215fn normalize_failure_text(text: &str) -> String {
1218 let mut normalized = String::new();
1219 for word in text.split_whitespace() {
1220 if !normalized.is_empty() {
1221 normalized.push(' ');
1222 }
1223 let mut in_digits = false;
1224 if word.starts_with('/') || word.contains("/src/") || word.contains("/tmp/") {
1225 normalized.push_str("<path>");
1226 continue;
1227 }
1228 for character in word.chars() {
1229 if character.is_ascii_digit() {
1230 if !in_digits {
1231 normalized.push('#');
1232 in_digits = true;
1233 }
1234 } else {
1235 normalized.push(character);
1236 in_digits = false;
1237 }
1238 }
1239 }
1240 normalized.chars().take(240).collect()
1241}
1242
1243fn has_compiler_diagnostic(lower: &str) -> bool {
1244 lower.lines().any(|line| {
1245 let line = line.trim_start();
1246 if matches!(
1247 line,
1248 "compilation failed" | "error: compilation failed" | "error: could not compile"
1249 ) || line.starts_with("error: could not compile ")
1250 {
1251 return true;
1252 }
1253
1254 let Some(rest) = line.strip_prefix("error[e") else {
1255 return false;
1256 };
1257 let Some((code, _)) = rest.split_once("]:") else {
1258 return false;
1259 };
1260 !code.is_empty() && code.chars().all(|character| character.is_ascii_digit())
1261 })
1262}
1263
1264fn has_runtime_exception(lower: &str) -> bool {
1265 let has_exception_line = lower.lines().any(|line| {
1266 let line = line.trim_start();
1267 [
1268 "typeerror:",
1269 "referenceerror:",
1270 "rangeerror:",
1271 "runtimeerror:",
1272 "keyerror:",
1273 "attributeerror:",
1274 ]
1275 .iter()
1276 .any(|prefix| line.starts_with(prefix))
1277 });
1278 has_exception_line && (lower.contains("\n at ") || lower.contains("\n at "))
1279}
1280
1281fn has_runtime_panic(lower: &str) -> bool {
1282 lower
1283 .lines()
1284 .any(|line| line.trim_start().starts_with("panic: runtime error:"))
1285 && (lower.contains("\ngoroutine ") || lower.contains("[signal sig"))
1286}
1287
1288fn has_patch_failure(lower: &str) -> bool {
1289 lower.lines().any(|line| {
1290 let line = line.trim_start();
1291 line.starts_with("error: patch failed:")
1292 || line.starts_with("patch failed:")
1293 || line.contains(": patch does not apply")
1294 || line.starts_with("invalid context")
1295 })
1296}
1297
1298fn has_nonzero_exit_status(lower: &str) -> bool {
1304 NONZERO_EXIT_PHRASES
1305 .iter()
1306 .any(|phrase| phrase_followed_by_nonzero_integer(lower, phrase))
1307}
1308
1309fn phrase_followed_by_nonzero_integer(lower: &str, phrase: &str) -> bool {
1311 let mut cursor = 0usize;
1312 while let Some(rel) = lower[cursor..].find(phrase) {
1313 let value_start = cursor + rel + phrase.len();
1314 let rest = lower[value_start..].trim_start_matches(|c: char| {
1315 c.is_ascii_whitespace() || matches!(c, ':' | '=' | '\'' | '"' | '`')
1316 });
1317 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1318 if !digits.is_empty() && digits.chars().any(|d| d != '0') {
1319 return true;
1320 }
1321 cursor = value_start;
1322 }
1323 false
1324}
1325
1326fn compute_no_error_streak(tool_texts: &[(String, bool)]) -> u32 {
1327 let mut streak = 0u32;
1328 for (text, is_error) in tool_texts.iter().rev() {
1329 let (sev, _) = classify_text(text);
1330 if *is_error || sev > 0.0 {
1331 break;
1332 }
1333 streak += 1;
1334 }
1335 streak
1336}
1337
1338fn detect_tests_passed(tool_texts: &[(String, bool)], recent_window: usize) -> bool {
1339 let start = tool_texts.len().saturating_sub(recent_window.max(1));
1340 let recent = &tool_texts[start..];
1341 let after_latest_failure = recent
1342 .iter()
1343 .rposition(|(text, is_error)| *is_error || classify_text(text).0 > 0.0)
1344 .map_or(recent, |index| &recent[index + 1..]);
1345 after_latest_failure.iter().any(|(text, _)| {
1346 let lower = text.to_lowercase();
1347 TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
1348 && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
1349 && !has_nonzero_failure_count(&lower)
1350 })
1351}
1352
1353fn has_nonzero_failure_count(lower: &str) -> bool {
1359 for kw in NUMERIC_FAILURE_KEYWORDS {
1360 let mut cursor = 0usize;
1361 while let Some(rel) = lower[cursor..].find(kw) {
1362 let kw_start = cursor + rel;
1363 let kw_end = kw_start + kw.len();
1364 let boundary_after = lower[kw_end..]
1367 .chars()
1368 .next()
1369 .is_none_or(|c| !c.is_ascii_alphanumeric());
1370 if boundary_after {
1371 let prefix = &lower[..kw_start];
1372 let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
1373 let digits_rev: String = trimmed
1374 .chars()
1375 .rev()
1376 .take_while(|c| c.is_ascii_digit())
1377 .collect();
1378 if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
1379 return true;
1380 }
1381 }
1382 cursor = kw_start + kw.len();
1383 }
1384 }
1385 false
1386}
1387
1388#[cfg(test)]
1391mod tests {
1392 use super::*;
1393 use crate::algorithms::util::stage::score_signal;
1394 use serde_json::json;
1395 use switchyard_protocol::codex_namespaces::TOOL_NAMESPACES_KEY;
1396 use switchyard_protocol::{
1397 ContentBlock, LlmRequest, Message, Metadata, Role, ToolCall, ToolResult,
1398 };
1399
1400 fn with_messages(messages: Vec<Message>) -> Request {
1401 Request {
1402 llm_request: LlmRequest {
1403 messages,
1404 ..LlmRequest::default()
1405 },
1406 raw_request: None,
1407 metadata: None,
1408 }
1409 }
1410
1411 fn tc(name: &str) -> Message {
1413 Message {
1414 role: Role::Assistant,
1415 content: vec![ContentBlock::ToolCall(ToolCall {
1416 id: String::new(),
1417 name: name.to_string(),
1418 arguments: json!({}),
1419 })],
1420 }
1421 }
1422
1423 fn bash(command: &str) -> Message {
1425 Message {
1426 role: Role::Assistant,
1427 content: vec![ContentBlock::ToolCall(ToolCall {
1428 id: String::new(),
1429 name: "Bash".to_string(),
1430 arguments: json!({"command": command}),
1431 })],
1432 }
1433 }
1434
1435 fn tr(text: &str) -> Message {
1437 Message {
1438 role: Role::User,
1439 content: vec![ContentBlock::ToolResult(ToolResult {
1440 tool_call_id: String::new(),
1441 content: vec![ContentBlock::Text {
1442 text: text.to_string(),
1443 }],
1444 is_error: None,
1445 })],
1446 }
1447 }
1448
1449 #[test]
1450 fn clean_text_has_zero_severity() {
1451 let (sev, patterns) = classify_text("everything went fine");
1452 assert_eq!(sev, 0.0);
1453 assert!(patterns.is_empty());
1454 }
1455
1456 #[test]
1457 fn structured_tool_failures_affect_recovery_signals() {
1458 for (text, severity) in [
1459 (r#"{"output":"","exit_code":7,"error":null}"#, SOFT),
1460 (r#"{"output":"","exit_code":-1,"error":null}"#, SOFT),
1461 (r#"{"success":false,"error":"No matching text"}"#, HARD),
1462 (r#"{"success":false,"error":null}"#, HARD),
1463 (
1464 r#"{"error":"Overwrite refused","stale_write_blocked":true}"#,
1465 HARD,
1466 ),
1467 (r#"{"output":"out of memory","exit_code":1}"#, CRITICAL),
1468 (r#"{"output":"done","exit_code":0,"error":null}"#, 0.0),
1469 (r#"{"success":true,"error":" "}"#, 0.0),
1470 (r#"{"output":"running","exit_code":null,"error":null}"#, 0.0),
1471 (r#"{"output":{"success":false},"exit_code":0}"#, 0.0),
1472 ] {
1473 let warned = format!("{text}\n\n[Tool loop warning: repeated identical call]");
1474 let request = with_messages(vec![tr("5 passed in 0.12s"), tr(text), tr(&warned)]);
1475 let signal = ToolSignals::from_request(&request, None);
1476 let clean = severity == 0.0;
1477 assert_eq!(signal.severity, severity, "{text}");
1478 assert_eq!(signal.no_error_streak, if clean { 3 } else { 0 }, "{text}");
1479 assert_eq!(signal.repeated_failure, severity >= HARD, "{text}");
1480 assert_eq!(signal.tests_passed, clean, "{text}");
1481 }
1482 }
1483
1484 #[test]
1485 fn traceback_is_hard() {
1486 let (sev, patterns) = classify_text("Traceback (most recent call last):\n ValueError");
1487 assert_eq!(sev, HARD);
1488 assert!(patterns.contains(&"traceback".to_string()));
1489 }
1490
1491 #[test]
1492 fn oom_is_critical() {
1493 let (sev, _) = classify_text("Out of memory: kill process 1234");
1494 assert_eq!(sev, CRITICAL);
1495 }
1496
1497 #[test]
1498 fn connection_refused_is_hard() {
1499 let (severity, _) = classify_text("Connection refused on port 8000");
1500 assert_eq!(severity, HARD);
1501 }
1502
1503 #[test]
1504 fn repeated_failure_ignores_volatile_paths_and_numbers() {
1505 let request = with_messages(vec![
1506 tr("error[E0308]: mismatched types at /tmp/a/src/lib.rs:12"),
1507 tr("error[E0308]: mismatched types at /tmp/b/src/lib.rs:47"),
1508 ]);
1509 assert!(ToolSignals::from_request(&request, None).repeated_failure);
1510 }
1511
1512 #[test]
1513 fn different_failures_are_not_repeated() {
1514 let request = with_messages(vec![
1515 tr("error[E0308]: mismatched types"),
1516 tr("error[E0509]: cannot move out"),
1517 ]);
1518 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1519 }
1520
1521 #[test]
1522 fn one_material_failure_is_not_repeated() {
1523 let request = with_messages(vec![tr("Connection refused on port 8000")]);
1524 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1525 }
1526
1527 #[test]
1529 fn structured_tool_failures_feed_error_and_recovery_signals() {
1530 for text in ["Dependency unavailable", "", "5 passed in 0.12s"] {
1531 let mut failed = tr(text);
1532 let ContentBlock::ToolResult(result) = &mut failed.content[0] else {
1533 panic!("expected tool result");
1534 };
1535 result.is_error = Some(true);
1536 let mut request = with_messages(vec![tr("5 passed in 0.12s"), failed.clone()]);
1537 let signals = ToolSignals::from_request(&request, Some(3));
1538 assert_eq!(signals.severity, HARD);
1539 assert!(!signals.repeated_failure);
1540 assert_eq!(signals.no_error_streak, 0);
1541 assert!(!signals.tests_passed);
1542
1543 request.llm_request.messages.push(failed);
1544 assert!(ToolSignals::from_request(&request, Some(3)).repeated_failure);
1545 request.llm_request.messages.push(tr("5 passed in 0.12s"));
1546 let recovered = ToolSignals::from_request(&request, Some(1));
1547 assert_eq!(recovered.severity, 0.0);
1548 assert!(!recovered.repeated_failure);
1549 assert_eq!(recovered.no_error_streak, 1);
1550 assert!(recovered.tests_passed);
1551 }
1552 }
1553
1554 #[test]
1555 fn severity_is_max_across_patterns() {
1556 let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
1558 assert_eq!(sev, HARD);
1559 }
1560
1561 #[test]
1562 fn codex_process_exit_zero_stays_clean() {
1563 let (sev, patterns) =
1564 classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
1565 assert_eq!(sev, 0.0);
1566 assert!(!patterns.contains(&"exit_nonzero".to_string()));
1567 }
1568
1569 #[test]
1570 fn nonzero_exit_codes_are_soft_errors() {
1571 let cases = [
1572 "Process exited with code 1",
1573 "Process exited with code 127",
1574 "exit code: 2",
1575 "exit status 3",
1576 "exited with status 9",
1577 ];
1578 for case in cases {
1579 let (sev, patterns) = classify_text(case);
1580 assert_eq!(sev, SOFT, "expected soft severity for {case}");
1581 assert!(patterns.contains(&"exit_nonzero".to_string()));
1582 }
1583 }
1584
1585 #[test]
1586 fn partial_process_failures_are_hard_errors() {
1587 let cases = [
1588 (
1589 "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
1590 "compile_error",
1591 ),
1592 (
1593 "Process exited with code 0\nOutput:\nTypeError: value is undefined\n at main.js:1:2",
1594 "runtime_exception",
1595 ),
1596 (
1597 "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
1598 "runtime_panic",
1599 ),
1600 (
1601 "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
1602 "patch_error",
1603 ),
1604 ];
1605 for (text, expected_pattern) in cases {
1606 let (severity, patterns) = classify_text(text);
1607 assert_eq!(severity, HARD, "expected hard severity for {text}");
1608 assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
1609 }
1610 }
1611
1612 #[test]
1613 fn source_text_that_names_exceptions_stays_clean() {
1614 let text =
1615 "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
1616 assert_eq!(classify_text(text).0, 0.0);
1617 }
1618
1619 #[test]
1620 fn file_does_not_exist_is_hard() {
1621 let (sev, patterns) =
1623 classify_text("Error: File does not exist. Note: current working directory is /app.");
1624 assert_eq!(sev, HARD);
1625 assert!(patterns.contains(&"no_such_file".to_string()));
1626 }
1627
1628 #[test]
1629 fn bare_does_not_exist_stays_clean() {
1630 let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
1633 assert_eq!(sev, 0.0);
1634 }
1635
1636 #[test]
1637 fn no_error_streak_all_clean() {
1638 let texts = vec![("ok".to_string(), false), ("all good".to_string(), false)];
1639 assert_eq!(compute_no_error_streak(&texts), 2);
1640 }
1641
1642 #[test]
1643 fn no_error_streak_stops_at_error() {
1644 let texts = vec![
1645 ("Traceback (most recent call last):".to_string(), false),
1646 ("ok".to_string(), false),
1647 ("ok".to_string(), false),
1648 ];
1649 assert_eq!(compute_no_error_streak(&texts), 2);
1650 }
1651
1652 #[test]
1653 fn tests_passed_detects_pytest_output() {
1654 assert!(detect_tests_passed(
1655 &[("====== 5 passed in 0.12s ======".to_string(), false)],
1656 DEFAULT_RECENT_WINDOW
1657 ));
1658 }
1659
1660 #[test]
1661 fn tests_passed_ignores_partial_failures() {
1662 assert!(!detect_tests_passed(
1663 &[("2 failed, 5 passed in 0.56s".to_string(), false)],
1664 DEFAULT_RECENT_WINDOW
1665 ));
1666 }
1667
1668 #[test]
1669 fn tests_passed_must_follow_the_latest_failure() {
1670 assert!(!detect_tests_passed(
1671 &[
1672 ("5 passed in 0.12s".to_string(), false),
1673 (
1674 "Traceback (most recent call last):\nValueError".to_string(),
1675 false
1676 ),
1677 ("edit applied".to_string(), false),
1678 ],
1679 DEFAULT_RECENT_WINDOW
1680 ));
1681 assert!(detect_tests_passed(
1682 &[
1683 (
1684 "Traceback (most recent call last):\nValueError".to_string(),
1685 false
1686 ),
1687 ("5 passed in 0.12s".to_string(), false),
1688 ],
1689 DEFAULT_RECENT_WINDOW
1690 ));
1691 }
1692
1693 #[test]
1694 fn retrieved_file_contents_are_ignored() {
1695 let call = |id: &str, name: &str, arguments: Value| Message {
1696 role: Role::Assistant,
1697 content: vec![ContentBlock::ToolCall(ToolCall {
1698 id: id.to_string(),
1699 name: name.to_string(),
1700 arguments,
1701 })],
1702 };
1703 let result = |id: &str, text: &str| Message {
1704 role: Role::User,
1705 content: vec![ContentBlock::ToolResult(ToolResult {
1706 tool_call_id: id.to_string(),
1707 content: vec![ContentBlock::Text {
1708 text: text.to_string(),
1709 }],
1710 is_error: None,
1711 })],
1712 };
1713 let signal = extract_tool_signals_with_window(
1714 &with_messages(vec![
1715 call("a", "Bash", json!({"command": "pytest"})),
1716 result("a", "Traceback (most recent call last):\nValueError"),
1717 call("b", "Read", json!({"file_path": "notes.md"})),
1718 result(
1719 "b",
1720 r#"{"success":false,"error":"out of memory","exit_code":7}"#,
1721 ),
1722 call("c", "Grep", json!({"pattern": "passed"})),
1723 result("c", "CHANGELOG.md: all tests passed"),
1724 ]),
1725 DEFAULT_RECENT_WINDOW,
1726 );
1727 assert_eq!(signal.severity, HARD);
1729 assert!(!signal.tests_passed);
1730 assert_eq!(signal.tool_result_count, 3);
1731
1732 for (command, text, severity) in [
1733 ("cat config.json", r#"{"error":"fixture data"}"#, 0.0),
1734 (
1735 "cat missing.json",
1736 r#"{"output":"","exit_code":7,"error":null}"#,
1737 SOFT,
1738 ),
1739 (
1740 "cat config.json && python check.py",
1741 r#"{"error":"check rejected"}"#,
1742 HARD,
1743 ),
1744 ] {
1745 let request = with_messages(vec![
1746 call("shell", "Bash", json!({"command": command})),
1747 result("shell", text),
1748 ]);
1749 assert_eq!(
1750 ToolSignals::from_request(&request, None).severity,
1751 severity,
1752 "{text}"
1753 );
1754 }
1755 }
1756
1757 #[test]
1758 fn severity_is_windowed_over_recent_results() {
1759 let request = with_messages(vec![
1761 tr("Traceback (most recent call last):\n ValueError"),
1762 tr("ok"),
1763 tr("ok"),
1764 ]);
1765 assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
1767 assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
1769 }
1770
1771 #[test]
1772 fn extract_openai_chat_tool_results() {
1773 let request = with_messages(vec![
1774 Message::text(Role::User, "do something"),
1775 tc("Edit"),
1776 tr("Traceback (most recent call last):\n ValueError"),
1777 ]);
1778 let sig = ToolSignals::from_request(&request, None);
1779 assert_eq!(sig.severity, HARD);
1780 assert_eq!(sig.edit_count, 1);
1781 assert_eq!(sig.turn_depth, 3);
1782 }
1783
1784 #[test]
1785 fn extract_anthropic_tool_results() {
1786 let request = with_messages(vec![tr("Traceback (most recent call last):\n ValueError")]);
1787 let sig = ToolSignals::from_request(&request, None);
1788 assert_eq!(sig.severity, HARD);
1789 }
1790
1791 #[test]
1792 fn extract_responses_api_tool_results() {
1793 let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
1794 let sig = ToolSignals::from_request(&request, None);
1795 assert_eq!(sig.severity, 0.0);
1796 assert_eq!(sig.write_count, 1);
1797 }
1798
1799 #[test]
1800 fn responses_builtin_tool_failures_escalate() {
1801 use crate::algorithms::util::stage::{PickOutcome, PickerMode, Tier, pick_tier};
1802
1803 let mut cases = Vec::new();
1804 for (status, output) in [
1805 (
1806 "failed",
1807 "Synthetic dependency unavailable; retry with the recovery path.",
1808 ),
1809 (
1810 "completed",
1811 "Synthetic dependency unavailable; retry with the recovery path.",
1812 ),
1813 ("failed", ""),
1814 ] {
1815 cases.push((
1816 json!({
1817 "type": "apply_patch_call_output",
1818 "status": status,
1819 "output": output,
1820 }),
1821 if status == "failed" { HARD } else { 0.0 },
1822 ));
1823 }
1824 for (outcome, stdout, stderr, severity) in [
1825 (json!({"type": "exit", "exit_code": 1}), "", "", SOFT),
1826 (json!({"type": "exit", "exit_code": 1}), "", " \n", SOFT),
1827 (
1828 json!({"type": "exit", "exit_code": 1}),
1829 "",
1830 "command failed",
1831 HARD,
1832 ),
1833 (json!({"type": "timeout"}), "", "", HARD),
1834 (json!({"type": "exit", "exit_code": 0}), "done", "", 0.0),
1835 (
1836 json!({"type": "exit", "exit_code": 0}),
1837 "Traceback (most recent call last):",
1838 "",
1839 HARD,
1840 ),
1841 (
1842 json!({"type": "exit", "exit_code": 0}),
1843 "",
1844 "Traceback (most recent call last):",
1845 HARD,
1846 ),
1847 ] {
1848 cases.push((
1849 json!({
1850 "type": "shell_call_output",
1851 "output": [
1852 {"stdout": stdout, "stderr": stderr, "outcome": outcome},
1853 {"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}
1854 ],
1855 }),
1856 severity,
1857 ));
1858 }
1859 for (raw, severity) in cases {
1860 let is_error = severity >= HARD;
1861 let mut request = with_messages(
1862 ["call_1", "call_2"]
1863 .into_iter()
1864 .map(|call_id| {
1865 let mut raw = raw.clone();
1866 raw["call_id"] = json!(call_id);
1867 Message {
1868 role: Role::User,
1869 content: vec![ContentBlock::Unknown {
1870 provider: WireFormat::OpenAiResponses.into(),
1871 raw,
1872 }],
1873 }
1874 })
1875 .collect(),
1876 );
1877 let signal = ToolSignals::from_request(&request, Some(3));
1878 assert_eq!(signal.severity, severity, "{raw}");
1879 assert_eq!(signal.repeated_failure, is_error, "{raw}");
1880 assert_eq!(signal.tool_result_count, 2);
1881 assert_eq!(
1882 matches!(
1883 pick_tier(&signal, PickerMode::EfficientFirst, 0.5),
1884 PickOutcome::Resolved {
1885 tier: Tier::Capable,
1886 ..
1887 }
1888 ),
1889 is_error,
1890 "{raw}"
1891 );
1892
1893 let mut success = match raw["type"].as_str() {
1894 Some("apply_patch_call_output") => json!({
1895 "type": "apply_patch_call_output", "status": "completed", "output": ""
1896 }),
1897 Some("shell_call_output") => json!({
1898 "type": "shell_call_output",
1899 "output": [{"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}]
1900 }),
1901 _ => unreachable!(),
1902 };
1903 for index in 0..3 {
1904 success["call_id"] = json!(format!("success_{index}"));
1905 request.llm_request.messages.push(Message {
1906 role: Role::User,
1907 content: vec![ContentBlock::Unknown {
1908 provider: WireFormat::OpenAiResponses.into(),
1909 raw: success.clone(),
1910 }],
1911 });
1912 }
1913 let recovered = ToolSignals::from_request(&request, Some(3));
1914 assert_eq!(recovered.severity, 0.0, "{raw}");
1915 assert!(!recovered.repeated_failure, "{raw}");
1916 assert!(recovered.no_error_streak >= 3, "{raw}");
1917 assert_eq!(recovered.tool_result_count, 5);
1918 }
1919 }
1920
1921 #[test]
1922 fn conversation_counts_are_per_block_and_role_aware() {
1923 let result = |content: Vec<ContentBlock>| {
1927 ContentBlock::ToolResult(ToolResult {
1928 tool_call_id: String::new(),
1929 content,
1930 is_error: None,
1931 })
1932 };
1933 let request = with_messages(vec![
1934 Message::text(Role::User, "do something"),
1935 Message::text(Role::Assistant, "working"),
1936 Message {
1937 role: Role::User,
1938 content: vec![
1939 result(vec![ContentBlock::Text {
1940 text: "ok".to_string(),
1941 }]),
1942 result(Vec::new()),
1943 ],
1944 },
1945 tc("Bash"),
1946 ]);
1947 let sig = ToolSignals::from_request(&request, None);
1948 assert_eq!(sig.tool_result_count, 2);
1949 assert_eq!(sig.assistant_turn_count, 2);
1950 assert_eq!(sig.turn_depth, 4);
1951 }
1952
1953 #[test]
1954 fn recent_window_counts_only_last_default_window_tool_calls() {
1955 let request = with_messages(vec![
1958 tc("Write"),
1959 tr("ok"),
1960 tc("Write"),
1961 tr("ok"),
1962 tc("Write"),
1963 tr("ok"),
1964 tc("Write"),
1965 tr("ok"),
1966 tc("Write"),
1967 tr("ok"),
1968 tc("Edit"),
1969 tr("ok"),
1970 ]);
1971 let sig = ToolSignals::from_request(&request, None);
1972 assert_eq!(sig.write_count, 5);
1973 assert_eq!(sig.edit_count, 1);
1974 assert_eq!(sig.recent_write_count, 2);
1975 assert_eq!(sig.recent_edit_count, 1);
1976 }
1977
1978 #[test]
1979 fn codex_apply_patch_counts_as_an_edit() {
1980 let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
1981 let sig = ToolSignals::from_request(&request, None);
1982 assert_eq!(sig.edit_count, 1);
1983 assert_eq!(sig.recent_edit_count, 1);
1984 }
1985
1986 fn exec_command(cmd: Value) -> Message {
1987 Message {
1988 role: Role::Assistant,
1989 content: vec![ContentBlock::ToolCall(ToolCall {
1990 id: String::new(),
1991 name: "exec_command".to_string(),
1992 arguments: cmd,
1993 })],
1994 }
1995 }
1996
1997 #[test]
1998 fn codex_exec_command_is_classified() {
1999 let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
2001 let request = with_messages(vec![exec_command(args), tr("ok")]);
2002 assert_eq!(
2003 ToolSignals::from_request(&request, None).recent_edit_count,
2004 1
2005 );
2006 }
2007
2008 #[test]
2009 fn python_write_expressions_need_a_python_command() {
2010 let write = with_messages(vec![
2011 exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
2012 tr("ok"),
2013 ]);
2014 assert_eq!(
2015 ToolSignals::from_request(&write, None).recent_write_count,
2016 1
2017 );
2018
2019 let search = with_messages(vec![
2020 exec_command(json!({"cmd": "grep -R '.write(' src"})),
2021 tr("ok"),
2022 ]);
2023 assert_eq!(
2024 ToolSignals::from_request(&search, None).recent_write_count,
2025 0
2026 );
2027 }
2028
2029 #[test]
2030 fn recent_window_size_is_caller_overridable() {
2031 let request = with_messages(vec![
2035 tc("Write"),
2036 tr("ok"),
2037 tc("Write"),
2038 tr("ok"),
2039 tc("Write"),
2040 tr("ok"),
2041 tc("Write"),
2042 tr("ok"),
2043 tc("Write"),
2044 tr("ok"),
2045 tc("Edit"),
2046 tr("ok"),
2047 ]);
2048 let narrow = extract_tool_signals_with_window(&request, 3);
2049 assert_eq!(narrow.recent_write_count, 2);
2050 assert_eq!(narrow.recent_edit_count, 1);
2051
2052 let wide = extract_tool_signals_with_window(&request, 6);
2053 assert_eq!(wide.recent_write_count, 5);
2054 assert_eq!(wide.recent_edit_count, 1);
2055 }
2056
2057 #[test]
2058 fn compaction_marker_sets_compacted() {
2059 let request = with_messages(vec![
2061 Message::text(
2062 Role::User,
2063 "This session is being continued from a previous conversation that ran out of context.",
2064 ),
2065 bash("ls"),
2066 ]);
2067 assert!(ToolSignals::from_request(&request, None).compacted);
2068 }
2069
2070 #[test]
2071 fn hermes_compaction_headers_set_compacted() {
2072 for header in [
2073 "[CONTEXT COMPACTION \u{2014} REFERENCE ONLY] Earlier turns were compacted into the summary below.",
2074 "[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.]",
2075 ] {
2076 for role in [Role::User, Role::Assistant] {
2077 let request = with_messages(vec![
2078 Message::text(role, format!("{header}\nContinue the task.")),
2079 bash("ls"),
2080 ]);
2081 assert!(ToolSignals::from_request(&request, None).compacted);
2082 }
2083 let request = with_messages(vec![Message::text(
2084 Role::User,
2085 format!("Prior context.\n\n {header}\nContinue the task."),
2086 )]);
2087 assert!(ToolSignals::from_request(&request, None).compacted);
2088 let request = with_messages(vec![tr(header)]);
2089 assert!(!ToolSignals::from_request(&request, None).compacted);
2090 }
2091 }
2092
2093 #[test]
2094 fn ordinary_compaction_text_stays_uncompacted() {
2095 for text in [
2096 "The task is still in progress after the compaction boundary.",
2097 "Explain [CONTEXT COMPACTION \u{2014} REFERENCE ONLY] in the docs.",
2098 "[STILL IN PROGRESS] Continue the task.",
2099 ] {
2100 let request = with_messages(vec![Message::text(Role::User, text)]);
2101 assert!(!ToolSignals::from_request(&request, None).compacted);
2102 }
2103 }
2104
2105 #[test]
2106 fn codex_compaction_metadata_stays_on_parent_route() {
2107 let mut request = with_messages(vec![bash("ls")]);
2108 request.metadata = Some(Metadata {
2109 is_subagent: true,
2110 agent_kind: Some("compact".to_string()),
2111 ..Default::default()
2112 });
2113 assert!(!ToolSignals::from_request(&request, None).compacted);
2114 }
2115
2116 #[test]
2117 fn no_compaction_marker_stays_uncompacted() {
2118 let request = with_messages(vec![
2119 Message::text(Role::User, "Write a script that parses the log file."),
2120 bash("ls"),
2121 ]);
2122 assert!(!ToolSignals::from_request(&request, None).compacted);
2123 }
2124
2125 #[test]
2126 fn bash_heredoc_counts_as_write() {
2127 let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
2129 let sig = ToolSignals::from_request(&request, None);
2130 assert_eq!(
2131 sig.write_count, 1,
2132 "Bash heredoc should bucket into write_count"
2133 );
2134 assert_eq!(sig.edit_count, 0);
2135 }
2136
2137 #[test]
2138 fn bash_sed_inplace_counts_as_edit() {
2139 let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
2140 let sig = ToolSignals::from_request(&request, None);
2141 assert_eq!(
2142 sig.edit_count, 1,
2143 "Bash sed -i should bucket into edit_count"
2144 );
2145 assert_eq!(sig.write_count, 0);
2146 }
2147
2148 #[test]
2149 fn bash_non_mutating_does_not_count() {
2150 let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
2152 let sig = ToolSignals::from_request(&request, None);
2153 assert_eq!(sig.write_count, 0);
2154 assert_eq!(sig.edit_count, 0);
2155 }
2156
2157 #[test]
2158 fn tests_passed_detects_pytest_with_failure_block() {
2159 assert!(!detect_tests_passed(
2161 &[("2 failed, 5 passed in 0.56s".to_string(), false)],
2162 DEFAULT_RECENT_WINDOW
2163 ));
2164 }
2165
2166 #[test]
2167 fn tests_passed_accepts_cargo_clean_summary() {
2168 assert!(detect_tests_passed(
2171 &[(
2172 "running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string(),
2173 false
2174 )],
2175 DEFAULT_RECENT_WINDOW
2176 ));
2177 }
2178
2179 #[test]
2180 fn tests_passed_rejects_cargo_real_failure() {
2181 assert!(!detect_tests_passed(
2183 &[(
2184 "running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string(),
2185 false
2186 )],
2187 DEFAULT_RECENT_WINDOW
2188 ));
2189 }
2190
2191 #[test]
2192 fn tests_passed_accepts_go_clean_summary() {
2193 assert!(detect_tests_passed(
2195 &[(
2196 "ok github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string(),
2197 false
2198 )],
2199 DEFAULT_RECENT_WINDOW
2200 ));
2201 }
2202
2203 #[test]
2204 fn tests_passed_accepts_pytest_zero_errors() {
2205 assert!(detect_tests_passed(
2207 &[("5 passed, 0 errors in 0.30s".to_string(), false)],
2208 DEFAULT_RECENT_WINDOW
2209 ));
2210 }
2211
2212 #[test]
2213 fn tests_passed_detects_diy_checkmark() {
2214 assert!(detect_tests_passed(
2215 &[("✓ all checks passed".to_string(), false)],
2216 DEFAULT_RECENT_WINDOW
2217 ));
2218 }
2219
2220 #[test]
2221 fn anthropic_bash_heredoc_extracts_command() {
2222 let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
2224 let sig = ToolSignals::from_request(&request, None);
2225 assert_eq!(
2226 sig.write_count, 1,
2227 "Anthropic Bash heredoc must also be detected"
2228 );
2229 }
2230
2231 #[test]
2232 fn recent_window_falls_back_to_full_history_when_short() {
2233 let request = with_messages(vec![tc("Write")]);
2234 let sig = ToolSignals::from_request(&request, None);
2235 assert_eq!(sig.recent_write_count, 1);
2236 assert_eq!(sig.recent_edit_count, 0);
2237 }
2238
2239 #[test]
2240 fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
2241 let request = with_messages(vec![tr("output ok"), tr("another ok")]);
2242 let sig = ToolSignals::from_request(&request, None);
2243 assert_eq!(sig.severity, 0.0);
2244 assert_eq!(sig.no_error_streak, 2);
2245 }
2246
2247 #[test]
2250 fn todowrite_classifies_as_plan() {
2251 assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
2252 assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
2253 }
2254
2255 #[test]
2256 fn codex_update_plan_classifies_as_plan() {
2257 assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
2258 }
2259
2260 #[test]
2261 fn codex_shell_command_runs_bash_pattern_match() {
2262 assert_eq!(
2264 classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
2265 ToolSemantic::Mutate(MutationKind::Write),
2266 );
2267 assert_eq!(
2269 classify_tool_call("shell_command", Some("ls /app")),
2270 ToolSemantic::Observe,
2271 );
2272 assert_eq!(
2274 classify_tool_call("shell_command", Some("./run_tests.sh")),
2275 ToolSemantic::Unknown,
2276 );
2277 }
2278
2279 #[test]
2280 fn text_editor_view_is_a_read() {
2281 for name in ["str_replace_based_edit_tool", "text_editor"] {
2282 assert_eq!(
2283 classify_tool_call(name, Some("view")),
2284 ToolSemantic::Observe
2285 );
2286 for command in [
2287 Some("create"),
2288 Some("insert"),
2289 Some("str_replace"),
2290 Some("undo_edit"),
2291 None,
2292 ] {
2293 assert_eq!(
2294 classify_tool_call(name, command),
2295 ToolSemantic::Mutate(MutationKind::Edit),
2296 );
2297 }
2298 }
2299
2300 let arguments = [
2301 json!({"command": "view", "path": "/app/main.py"}),
2302 json!(r#"{"command":"view","path":"/app/main.py"}"#),
2304 ];
2305 for arguments in arguments {
2306 let call = Message {
2307 role: Role::Assistant,
2308 content: vec![ContentBlock::ToolCall(ToolCall {
2309 id: String::new(),
2310 name: "str_replace_based_edit_tool".to_string(),
2311 arguments,
2312 })],
2313 };
2314 let request = with_messages(vec![call, tr("print('hi')")]);
2315 let sig = ToolSignals::from_request(&request, None);
2316 assert_eq!(sig.read_count, 1);
2317 assert_eq!(sig.recent_read_count, 1);
2318 assert_eq!(sig.edit_count, 0);
2319 }
2320 }
2321
2322 #[test]
2323 fn read_tool_classifies_as_read() {
2324 assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
2325 assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
2326 }
2327
2328 #[test]
2329 fn hermes_tool_names_classify() {
2330 assert_eq!(
2332 classify_tool_call("write_file", None),
2333 ToolSemantic::Mutate(MutationKind::Write)
2334 );
2335 assert_eq!(
2336 classify_tool_call("patch", None),
2337 ToolSemantic::Mutate(MutationKind::Edit)
2338 );
2339 assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
2340 assert_eq!(
2341 classify_tool_call("search_files", None),
2342 ToolSemantic::Observe
2343 );
2344 assert_eq!(
2347 classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
2348 ToolSemantic::Mutate(MutationKind::Edit),
2349 );
2350 assert_eq!(
2351 classify_tool_call("terminal", Some("grep foo /app")),
2352 ToolSemantic::Observe,
2353 );
2354 assert_eq!(
2355 classify_tool_call("terminal", Some("./run_tests.sh")),
2356 ToolSemantic::Unknown,
2357 );
2358 }
2359
2360 #[test]
2361 fn bash_read_patterns_classify_as_read() {
2362 let cases = [
2363 "cat /etc/passwd",
2364 "grep foo bar.txt",
2365 "ls /app",
2366 "find . -name '*.py'",
2367 ];
2368 for cmd in cases {
2369 assert_eq!(
2370 classify_tool_call("Bash", Some(cmd)),
2371 ToolSemantic::Observe,
2372 "expected Read for {cmd}"
2373 );
2374 }
2375 }
2376
2377 #[test]
2378 fn codex_inspection_commands_classify_as_read() {
2379 let cases = [
2380 "sed -n '1,80p' src/lib.rs",
2381 "rg -n 'needle' src",
2382 "nl -ba src/lib.rs",
2383 "cat package.json",
2384 "jq '.scripts' package.json",
2385 "git status --short",
2386 "git log --oneline -5",
2387 "git show HEAD:src/lib.rs",
2388 "git branch --show-current",
2389 "git remote -v",
2390 "git config --get remote.origin.url",
2391 ];
2392 for command in cases {
2393 assert_eq!(
2394 classify_tool_call("exec_command", Some(command)),
2395 ToolSemantic::Observe,
2396 "expected Read for {command}"
2397 );
2398 }
2399 }
2400
2401 #[test]
2402 fn quoted_shell_separators_do_not_create_commands() {
2403 for command in ["rg 'foo|rm obsolete.rs'", "rg \"foo; rm obsolete.rs\""] {
2404 assert_eq!(
2405 classify_tool_call("exec_command", Some(command)),
2406 ToolSemantic::Observe,
2407 "quoted text must not be parsed as a command: {command}"
2408 );
2409 }
2410 }
2411
2412 #[test]
2413 fn codex_shell_mutations_classify_as_production() {
2414 let writes = [
2415 "cp source.rs destination.rs",
2416 "mkdir -p src/generated",
2417 "touch src/generated/mod.rs",
2418 "git show HEAD:file.rs > file.rs",
2419 "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
2420 ];
2421 for command in writes {
2422 assert_eq!(
2423 classify_tool_call("exec_command", Some(command)),
2424 ToolSemantic::Mutate(MutationKind::Write),
2425 "expected Write for {command}"
2426 );
2427 }
2428
2429 let edits = [
2430 "mv old.rs new.rs",
2431 "rm obsolete.rs",
2432 "gofmt -w main.go",
2433 "cargo fmt",
2434 "ruff check --fix src",
2435 "perl -0pi -e 's/old/new/' src/lib.rs",
2436 "npx prettier --write src/lib.ts",
2437 "uv run ruff format src",
2438 "git apply fix.patch",
2439 ];
2440 for command in edits {
2441 assert_eq!(
2442 classify_tool_call("exec_command", Some(command)),
2443 ToolSemantic::Mutate(MutationKind::Edit),
2444 "expected Edit for {command}"
2445 );
2446 }
2447 }
2448
2449 #[test]
2450 fn formatter_checks_are_not_edits() {
2451 for command in [
2452 "cargo fmt --check",
2453 "ruff format --check src",
2454 "black --check src",
2455 ] {
2456 assert_ne!(
2457 classify_tool_call("exec_command", Some(command)),
2458 ToolSemantic::Mutate(MutationKind::Edit),
2459 "read-only formatter check must not be Edit: {command}"
2460 );
2461 }
2462 }
2463
2464 #[test]
2465 fn embedded_comparison_is_not_a_shell_write() {
2466 let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
2467 assert_eq!(
2468 classify_tool_call("exec_command", Some(command)),
2469 ToolSemantic::Unknown
2470 );
2471 }
2472
2473 #[test]
2474 fn bash_write_precedence_over_read() {
2475 assert_eq!(
2478 classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
2479 ToolSemantic::Mutate(MutationKind::Write),
2480 );
2481 }
2482
2483 #[test]
2484 fn pure_bash_streak_counts_trailing_other() {
2485 let request = with_messages(vec![
2487 bash("make"),
2488 tr("ok"),
2489 bash("./configure"),
2490 tr("ok"),
2491 bash("make install"),
2492 tr("ok"),
2493 bash("./run.sh"),
2494 tr("ok"),
2495 bash("./test"),
2496 tr("ok"),
2497 ]);
2498 let sig = ToolSignals::from_request(&request, None);
2499 assert_eq!(sig.pure_bash_streak, 5);
2500 assert_eq!(sig.write_count, 0);
2501 assert_eq!(sig.read_count, 0);
2502 }
2503
2504 #[test]
2505 fn pure_bash_streak_resets_on_write() {
2506 let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
2507 let sig = ToolSignals::from_request(&request, None);
2508 assert_eq!(sig.pure_bash_streak, 0);
2509 assert_eq!(sig.write_count, 1);
2510 }
2511
2512 #[test]
2513 fn recent_window_tracks_todowrite_and_read() {
2514 let request = with_messages(vec![
2516 bash("make"),
2517 tr("ok"),
2518 tc("TodoWrite"),
2519 tr("ok"),
2520 tc("Read"),
2521 tr("ok"),
2522 tc("TodoWrite"),
2523 tr("ok"),
2524 ]);
2525 let sig = ToolSignals::from_request(&request, None);
2526 assert_eq!(sig.todowrite_count, 2);
2527 assert_eq!(sig.recent_todowrite_count, 2);
2528 assert_eq!(sig.read_count, 1);
2529 assert_eq!(sig.recent_read_count, 1);
2530 }
2531
2532 #[test]
2533 fn configured_tool_semantics_extend_the_builtin_vocabulary() {
2534 let semantics = ToolSemantics {
2535 observe: vec!["KB_search".to_string()],
2536 mutate: vec!["send_payment_request".to_string()],
2537 plan: vec!["create_research_plan".to_string()],
2538 new: vec!["send_message_to_user".to_string()],
2539 };
2540 semantics.validate().expect("valid additive semantics");
2541 let request = with_messages(vec![
2542 tc("Read"),
2543 tc("Write"),
2544 tc("TodoWrite"),
2545 tc("kb_SEARCH"),
2546 tc("send_payment_request"),
2547 tc("create_research_plan"),
2548 tc("send_message_to_user"),
2549 tc("unlisted_tool"),
2550 ]);
2551
2552 let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
2553
2554 assert_eq!(signal.read_count, 2);
2555 assert_eq!(signal.write_count, 2);
2556 assert_eq!(signal.todowrite_count, 2);
2557 assert_eq!(signal.new_count, 1);
2558 assert_eq!(signal.recent_new_count, 1);
2559 assert_eq!(signal.pure_bash_streak, 1);
2560 }
2561
2562 #[test]
2563 fn configured_tool_semantics_match_namespaced_and_mcp_tools() {
2564 let mut request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
2566 request.llm_request.extensions.fields.insert(
2567 TOOL_NAMESPACES_KEY.to_string(),
2568 json!({"mcp__billing__send_payment_request": "mcp__billing"}),
2569 );
2570
2571 let claude_request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
2573
2574 for request in [&request, &claude_request] {
2575 for name in ["send_payment_request", "mcp__billing__send_payment_request"] {
2576 let semantics = ToolSemantics {
2577 mutate: vec![name.to_string()],
2578 ..Default::default()
2579 };
2580 let signal = ToolSignals::from_request_with_semantics(request, None, &semantics);
2581 assert_eq!(signal.write_count, 1, "{name}");
2582 }
2583 }
2584 }
2585
2586 #[test]
2587 fn configured_tool_semantics_only_fold_ascii_case() {
2588 let semantics = ToolSemantics {
2589 observe: vec!["kb_search".to_string()],
2590 ..Default::default()
2591 };
2592
2593 assert_eq!(
2594 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2595 ToolSemantic::Observe
2596 );
2597 assert_eq!(
2600 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2601 ToolSemantic::Unknown
2602 );
2603 }
2604
2605 #[test]
2606 fn custom_semantics_preserve_builtin_unicode_lowercasing() {
2607 let semantics = ToolSemantics {
2608 observe: vec!["lookup_customer".to_string()],
2609 ..Default::default()
2610 };
2611
2612 assert_eq!(
2614 classify_tool_call_with_semantics("notebooKedit", None, &semantics),
2615 ToolSemantic::Mutate(MutationKind::Edit)
2616 );
2617 }
2618
2619 #[test]
2620 fn configured_semantics_never_replace_builtin_classifications() {
2621 let semantics = ToolSemantics {
2622 observe: vec!["lookup_customer".to_string()],
2623 mutate: vec!["send_payment".to_string()],
2624 plan: vec!["create_workflow".to_string()],
2625 new: vec!["send_message".to_string()],
2626 };
2627
2628 for name in WRITE_TOOL_NAMES {
2629 assert_eq!(
2630 classify_tool_call_with_semantics(name, None, &semantics),
2631 ToolSemantic::Mutate(MutationKind::Write),
2632 "write tool {name:?} changed classification"
2633 );
2634 }
2635 for name in EDIT_TOOL_NAMES {
2636 assert_eq!(
2637 classify_tool_call_with_semantics(name, None, &semantics),
2638 ToolSemantic::Mutate(MutationKind::Edit),
2639 "edit tool {name:?} changed classification"
2640 );
2641 }
2642 for name in READ_TOOL_NAMES {
2643 assert_eq!(
2644 classify_tool_call_with_semantics(name, None, &semantics),
2645 ToolSemantic::Observe,
2646 "read tool {name:?} changed classification"
2647 );
2648 }
2649 for name in PLAN_TOOL_NAMES {
2650 assert_eq!(
2651 classify_tool_call_with_semantics(name, None, &semantics),
2652 ToolSemantic::Plan,
2653 "plan tool {name:?} changed classification"
2654 );
2655 }
2656
2657 for (command, expected) in [
2658 ("cat /tmp/input", ToolSemantic::Observe),
2659 (
2660 "cat /tmp/input > /tmp/output",
2661 ToolSemantic::Mutate(MutationKind::Write),
2662 ),
2663 (
2664 "sed -i 's/a/b/' /tmp/file",
2665 ToolSemantic::Mutate(MutationKind::Edit),
2666 ),
2667 ("./run_tests.sh", ToolSemantic::Unknown),
2668 ] {
2669 assert_eq!(
2670 classify_tool_call_with_semantics("BASH", Some(command), &semantics),
2671 expected,
2672 "bash command {command:?} changed classification"
2673 );
2674 }
2675 }
2676
2677 #[test]
2678 fn configured_semantics_score_like_their_builtin_equivalents() {
2679 let semantics = ToolSemantics {
2680 observe: vec!["lookup_customer".to_string()],
2681 mutate: vec!["send_payment".to_string()],
2682 plan: vec!["create_workflow".to_string()],
2683 ..Default::default()
2684 };
2685
2686 for (builtin, configured) in [
2687 ("Read", "lookup_customer"),
2688 ("Write", "send_payment"),
2689 ("TodoWrite", "create_workflow"),
2690 ] {
2691 let messages_before_tool = || {
2692 vec![
2693 Message::text(Role::User, "start"),
2694 Message::text(Role::Assistant, "working"),
2695 Message::text(Role::User, "continue"),
2696 Message::text(Role::Assistant, "working"),
2697 Message::text(Role::User, "continue"),
2698 Message::text(Role::Assistant, "working"),
2699 Message::text(Role::User, "continue"),
2700 ]
2701 };
2702 let mut builtin_messages = messages_before_tool();
2703 builtin_messages.push(tc(builtin));
2704 let mut configured_messages = messages_before_tool();
2705 configured_messages.push(tc(configured));
2706
2707 let builtin_score = score_signal(&ToolSignals::from_request(
2708 &with_messages(builtin_messages),
2709 None,
2710 ));
2711 let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
2712 &with_messages(configured_messages),
2713 None,
2714 &semantics,
2715 ));
2716
2717 assert_ne!(
2718 builtin_score.score, 0.0,
2719 "the {builtin:?} control must exercise a scoring dimension"
2720 );
2721 assert_eq!(
2722 configured_score, builtin_score,
2723 "configured tool {configured:?} must score exactly like {builtin:?}"
2724 );
2725 }
2726 }
2727
2728 #[test]
2729 fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
2730 let duplicate = ToolSemantics {
2731 observe: vec!["lookup".to_string()],
2732 mutate: vec!["LOOKUP".to_string()],
2733 ..Default::default()
2734 };
2735 assert!(
2736 duplicate
2737 .validate()
2738 .expect_err("duplicate should fail")
2739 .to_string()
2740 .contains("appears in both")
2741 );
2742
2743 let builtin = ToolSemantics {
2744 new: vec!["write_file".to_string()],
2745 ..Default::default()
2746 };
2747 assert!(
2748 builtin
2749 .validate()
2750 .expect_err("built-in should fail")
2751 .to_string()
2752 .contains("built-in semantics")
2753 );
2754
2755 let empty = ToolSemantics {
2756 observe: vec![" \t".to_string()],
2757 ..Default::default()
2758 };
2759 assert!(
2760 empty
2761 .validate()
2762 .expect_err("empty name should fail")
2763 .to_string()
2764 .contains("empty tool name")
2765 );
2766 }
2767}