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