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 compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
901 }
902 _ => {}
903 }
904 }
905 }
906
907 let mut signal = build_signal(
908 tool_texts,
909 tool_calls,
910 messages.len() as u32,
911 recent_window,
912 semantics,
913 );
914 signal.compacted = compacted;
915 signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
916 signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
917 signal
918}
919
920const COMPACTION_MARKER: &str = "session is being continued";
923
924fn mcp_tool_name(name: &str) -> Option<&str> {
928 let (_server, tool) = name.strip_prefix("mcp__")?.split_once("__")?;
929 (!tool.is_empty()).then_some(tool)
930}
931
932fn command_of(arguments: &Value) -> Option<String> {
935 let decoded = arguments
937 .as_str()
938 .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
939 let object = decoded.as_ref().unwrap_or(arguments);
940
941 ["command", "cmd", "input"]
942 .iter()
943 .filter_map(|key| object.get(*key))
944 .find_map(command_text)
945}
946
947fn command_text(value: &Value) -> Option<String> {
949 match value {
950 Value::String(text) => Some(text.to_lowercase()),
951 Value::Array(parts) => {
952 let joined = parts
953 .iter()
954 .filter_map(Value::as_str)
955 .collect::<Vec<_>>()
956 .join(" ");
957 (!joined.is_empty()).then(|| joined.to_lowercase())
958 }
959 _ => None,
960 }
961}
962
963fn text_of(block: &ContentBlock) -> Option<&str> {
965 match block {
966 ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
967 _ => None,
968 }
969}
970
971fn build_signal(
972 tool_texts: Vec<(String, bool)>,
973 tool_calls: Vec<ObservedToolCall>,
974 turn_depth: u32,
975 recent_window: usize,
976 semantics: &ToolSemantics,
977) -> ToolSignals {
978 let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
984 let mut severity = 0.0f32;
985 let mut failure_fingerprints = Vec::new();
986 let mut repeated_failure = false;
987 for (text, is_error) in &tool_texts[sev_start..] {
988 let (sev, _patterns) = classify_text(text);
989 let sev = if *is_error { sev.max(HARD) } else { sev };
991 if sev > severity {
992 severity = sev;
993 }
994 if let Some(fingerprint) = failure_fingerprint(text, *is_error) {
995 repeated_failure |= failure_fingerprints.contains(&fingerprint);
996 failure_fingerprints.push(fingerprint);
997 }
998 }
999
1000 let no_error_streak = compute_no_error_streak(&tool_texts);
1001
1002 let recent_start = tool_calls.len().saturating_sub(recent_window);
1006 let mut write_count = 0u32;
1007 let mut edit_count = 0u32;
1008 let mut read_count = 0u32;
1009 let mut todowrite_count = 0u32;
1010 let mut recent_write_count = 0u32;
1011 let mut recent_edit_count = 0u32;
1012 let mut recent_read_count = 0u32;
1013 let mut recent_todowrite_count = 0u32;
1014 let mut new_count = 0u32;
1015 let mut recent_new_count = 0u32;
1016 let mut pure_bash_streak = 0u32;
1017 let mut streak_open = true;
1018 for (i, tc) in tool_calls.iter().enumerate().rev() {
1019 let mut cat = classify_tool_call_with_semantics(&tc.name, tc.command.as_deref(), semantics);
1021 if matches!(cat, ToolSemantic::Unknown)
1022 && let Some(bare_name) = tc.bare_name
1023 {
1024 cat = classify_tool_call_with_semantics(bare_name, tc.command.as_deref(), semantics);
1025 }
1026 if streak_open {
1027 if matches!(cat, ToolSemantic::Unknown) {
1028 pure_bash_streak += 1;
1029 } else {
1030 streak_open = false;
1031 }
1032 }
1033 match cat {
1034 ToolSemantic::Mutate(MutationKind::Write) => {
1035 write_count += 1;
1036 if i >= recent_start {
1037 recent_write_count += 1;
1038 }
1039 }
1040 ToolSemantic::Mutate(MutationKind::Edit) => {
1041 edit_count += 1;
1042 if i >= recent_start {
1043 recent_edit_count += 1;
1044 }
1045 }
1046 ToolSemantic::Observe => {
1047 read_count += 1;
1048 if i >= recent_start {
1049 recent_read_count += 1;
1050 }
1051 }
1052 ToolSemantic::Plan => {
1053 todowrite_count += 1;
1054 if i >= recent_start {
1055 recent_todowrite_count += 1;
1056 }
1057 }
1058 ToolSemantic::New => {
1059 new_count += 1;
1060 if i >= recent_start {
1061 recent_new_count += 1;
1062 }
1063 }
1064 ToolSemantic::Unknown => {}
1065 }
1066 }
1067
1068 let tests_passed = detect_tests_passed(&tool_texts, recent_window);
1069
1070 ToolSignals {
1071 severity,
1072 repeated_failure,
1073 no_error_streak,
1074 edit_count,
1075 write_count,
1076 read_count,
1077 todowrite_count,
1078 recent_edit_count,
1079 recent_write_count,
1080 recent_read_count,
1081 recent_todowrite_count,
1082 new_count,
1083 recent_new_count,
1084 pure_bash_streak,
1085 tests_passed,
1086 turn_depth,
1087 tool_result_count: 0,
1091 assistant_turn_count: 0,
1092 compacted: false,
1093 }
1094}
1095
1096fn content_to_text(content: Option<&Value>) -> Option<String> {
1100 match content? {
1101 Value::String(s) => Some(s.clone()),
1102 Value::Array(blocks) => {
1103 let parts: Vec<&str> = blocks
1104 .iter()
1105 .filter_map(|b| {
1106 b.as_object()
1107 .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
1108 .and_then(|o| o.get("text"))
1109 .and_then(Value::as_str)
1110 })
1111 .collect();
1112 if parts.is_empty() {
1113 None
1114 } else {
1115 Some(parts.join("\n"))
1116 }
1117 }
1118 _ => None,
1119 }
1120}
1121
1122pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
1126 let lower = text.to_lowercase();
1127 let mut patterns = Vec::new();
1128 let mut severity: f32 = 0.0;
1129 for (name, sev, substrings) in ERROR_PATTERNS {
1130 if substrings.iter().any(|sub| lower.contains(sub)) {
1131 patterns.push(name.to_string());
1132 severity = severity.max(*sev);
1133 }
1134 }
1135 let result = serde_json::Deserializer::from_str(text)
1137 .into_iter::<Value>()
1138 .next()
1139 .and_then(|result| result.ok())
1140 .unwrap_or_default();
1141 let nonzero_exit = result["exit_code"].as_i64().is_some_and(|code| code != 0);
1142 let tool_error = result["success"].as_bool() == Some(false)
1143 || result["error"]
1144 .as_str()
1145 .is_some_and(|error| !error.trim().is_empty());
1146 if (nonzero_exit || has_nonzero_exit_status(&lower))
1147 && !patterns.iter().any(|p| p == "exit_nonzero")
1148 {
1149 patterns.push("exit_nonzero".to_string());
1150 severity = severity.max(SOFT);
1151 }
1152 for (name, matched) in [
1153 ("tool_error", tool_error),
1154 ("compile_error", has_compiler_diagnostic(&lower)),
1155 ("runtime_exception", has_runtime_exception(&lower)),
1156 ("runtime_panic", has_runtime_panic(&lower)),
1157 ("patch_error", has_patch_failure(&lower)),
1158 ] {
1159 if matched && !patterns.iter().any(|pattern| pattern == name) {
1160 patterns.push(name.to_string());
1161 severity = severity.max(HARD);
1162 }
1163 }
1164 (severity, patterns)
1165}
1166
1167fn failure_fingerprint(text: &str, is_error: bool) -> Option<String> {
1170 let (severity, patterns) = classify_text(text);
1171 if severity < HARD && !is_error {
1172 return None;
1173 }
1174
1175 let lower = text.to_lowercase();
1176 let diagnostic = lower
1177 .lines()
1178 .find(|line| is_failure_diagnostic(line))
1179 .or_else(|| lower.lines().find(|line| !line.trim().is_empty()))
1180 .unwrap_or_default();
1181 let normalized = normalize_failure_text(diagnostic);
1182 Some(format!("{}|{normalized}", patterns.join(",")))
1183}
1184
1185fn is_failure_diagnostic(line: &str) -> bool {
1186 let line = line.trim();
1187 [
1188 "error",
1189 "exception",
1190 "panic",
1191 "failed",
1192 "timed out",
1193 "timeout",
1194 "connection refused",
1195 "cannot allocate memory",
1196 "out of memory",
1197 "not found",
1198 ]
1199 .iter()
1200 .any(|marker| line.contains(marker))
1201}
1202
1203fn normalize_failure_text(text: &str) -> String {
1206 let mut normalized = String::new();
1207 for word in text.split_whitespace() {
1208 if !normalized.is_empty() {
1209 normalized.push(' ');
1210 }
1211 let mut in_digits = false;
1212 if word.starts_with('/') || word.contains("/src/") || word.contains("/tmp/") {
1213 normalized.push_str("<path>");
1214 continue;
1215 }
1216 for character in word.chars() {
1217 if character.is_ascii_digit() {
1218 if !in_digits {
1219 normalized.push('#');
1220 in_digits = true;
1221 }
1222 } else {
1223 normalized.push(character);
1224 in_digits = false;
1225 }
1226 }
1227 }
1228 normalized.chars().take(240).collect()
1229}
1230
1231fn has_compiler_diagnostic(lower: &str) -> bool {
1232 lower.lines().any(|line| {
1233 let line = line.trim_start();
1234 if matches!(
1235 line,
1236 "compilation failed" | "error: compilation failed" | "error: could not compile"
1237 ) || line.starts_with("error: could not compile ")
1238 {
1239 return true;
1240 }
1241
1242 let Some(rest) = line.strip_prefix("error[e") else {
1243 return false;
1244 };
1245 let Some((code, _)) = rest.split_once("]:") else {
1246 return false;
1247 };
1248 !code.is_empty() && code.chars().all(|character| character.is_ascii_digit())
1249 })
1250}
1251
1252fn has_runtime_exception(lower: &str) -> bool {
1253 let has_exception_line = lower.lines().any(|line| {
1254 let line = line.trim_start();
1255 [
1256 "typeerror:",
1257 "referenceerror:",
1258 "rangeerror:",
1259 "runtimeerror:",
1260 "keyerror:",
1261 "attributeerror:",
1262 ]
1263 .iter()
1264 .any(|prefix| line.starts_with(prefix))
1265 });
1266 has_exception_line && (lower.contains("\n at ") || lower.contains("\n at "))
1267}
1268
1269fn has_runtime_panic(lower: &str) -> bool {
1270 lower
1271 .lines()
1272 .any(|line| line.trim_start().starts_with("panic: runtime error:"))
1273 && (lower.contains("\ngoroutine ") || lower.contains("[signal sig"))
1274}
1275
1276fn has_patch_failure(lower: &str) -> bool {
1277 lower.lines().any(|line| {
1278 let line = line.trim_start();
1279 line.starts_with("error: patch failed:")
1280 || line.starts_with("patch failed:")
1281 || line.contains(": patch does not apply")
1282 || line.starts_with("invalid context")
1283 })
1284}
1285
1286fn has_nonzero_exit_status(lower: &str) -> bool {
1292 NONZERO_EXIT_PHRASES
1293 .iter()
1294 .any(|phrase| phrase_followed_by_nonzero_integer(lower, phrase))
1295}
1296
1297fn phrase_followed_by_nonzero_integer(lower: &str, phrase: &str) -> bool {
1299 let mut cursor = 0usize;
1300 while let Some(rel) = lower[cursor..].find(phrase) {
1301 let value_start = cursor + rel + phrase.len();
1302 let rest = lower[value_start..].trim_start_matches(|c: char| {
1303 c.is_ascii_whitespace() || matches!(c, ':' | '=' | '\'' | '"' | '`')
1304 });
1305 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1306 if !digits.is_empty() && digits.chars().any(|d| d != '0') {
1307 return true;
1308 }
1309 cursor = value_start;
1310 }
1311 false
1312}
1313
1314fn compute_no_error_streak(tool_texts: &[(String, bool)]) -> u32 {
1315 let mut streak = 0u32;
1316 for (text, is_error) in tool_texts.iter().rev() {
1317 let (sev, _) = classify_text(text);
1318 if *is_error || sev > 0.0 {
1319 break;
1320 }
1321 streak += 1;
1322 }
1323 streak
1324}
1325
1326fn detect_tests_passed(tool_texts: &[(String, bool)], recent_window: usize) -> bool {
1327 let start = tool_texts.len().saturating_sub(recent_window.max(1));
1328 let recent = &tool_texts[start..];
1329 let after_latest_failure = recent
1330 .iter()
1331 .rposition(|(text, is_error)| *is_error || classify_text(text).0 > 0.0)
1332 .map_or(recent, |index| &recent[index + 1..]);
1333 after_latest_failure.iter().any(|(text, _)| {
1334 let lower = text.to_lowercase();
1335 TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
1336 && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
1337 && !has_nonzero_failure_count(&lower)
1338 })
1339}
1340
1341fn has_nonzero_failure_count(lower: &str) -> bool {
1347 for kw in NUMERIC_FAILURE_KEYWORDS {
1348 let mut cursor = 0usize;
1349 while let Some(rel) = lower[cursor..].find(kw) {
1350 let kw_start = cursor + rel;
1351 let kw_end = kw_start + kw.len();
1352 let boundary_after = lower[kw_end..]
1355 .chars()
1356 .next()
1357 .is_none_or(|c| !c.is_ascii_alphanumeric());
1358 if boundary_after {
1359 let prefix = &lower[..kw_start];
1360 let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
1361 let digits_rev: String = trimmed
1362 .chars()
1363 .rev()
1364 .take_while(|c| c.is_ascii_digit())
1365 .collect();
1366 if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
1367 return true;
1368 }
1369 }
1370 cursor = kw_start + kw.len();
1371 }
1372 }
1373 false
1374}
1375
1376#[cfg(test)]
1379mod tests {
1380 use super::*;
1381 use crate::algorithms::util::stage::score_signal;
1382 use serde_json::json;
1383 use switchyard_protocol::codex_namespaces::TOOL_NAMESPACES_KEY;
1384 use switchyard_protocol::{
1385 ContentBlock, LlmRequest, Message, Metadata, Role, ToolCall, ToolResult,
1386 };
1387
1388 fn with_messages(messages: Vec<Message>) -> Request {
1389 Request {
1390 llm_request: LlmRequest {
1391 messages,
1392 ..LlmRequest::default()
1393 },
1394 raw_request: None,
1395 metadata: None,
1396 }
1397 }
1398
1399 fn tc(name: &str) -> Message {
1401 Message {
1402 role: Role::Assistant,
1403 content: vec![ContentBlock::ToolCall(ToolCall {
1404 id: String::new(),
1405 name: name.to_string(),
1406 arguments: json!({}),
1407 })],
1408 }
1409 }
1410
1411 fn bash(command: &str) -> Message {
1413 Message {
1414 role: Role::Assistant,
1415 content: vec![ContentBlock::ToolCall(ToolCall {
1416 id: String::new(),
1417 name: "Bash".to_string(),
1418 arguments: json!({"command": command}),
1419 })],
1420 }
1421 }
1422
1423 fn tr(text: &str) -> Message {
1425 Message {
1426 role: Role::User,
1427 content: vec![ContentBlock::ToolResult(ToolResult {
1428 tool_call_id: String::new(),
1429 content: vec![ContentBlock::Text {
1430 text: text.to_string(),
1431 }],
1432 is_error: None,
1433 })],
1434 }
1435 }
1436
1437 #[test]
1438 fn clean_text_has_zero_severity() {
1439 let (sev, patterns) = classify_text("everything went fine");
1440 assert_eq!(sev, 0.0);
1441 assert!(patterns.is_empty());
1442 }
1443
1444 #[test]
1445 fn structured_tool_failures_affect_recovery_signals() {
1446 for (text, severity) in [
1447 (r#"{"output":"","exit_code":7,"error":null}"#, SOFT),
1448 (r#"{"output":"","exit_code":-1,"error":null}"#, SOFT),
1449 (r#"{"success":false,"error":"No matching text"}"#, HARD),
1450 (r#"{"success":false,"error":null}"#, HARD),
1451 (
1452 r#"{"error":"Overwrite refused","stale_write_blocked":true}"#,
1453 HARD,
1454 ),
1455 (r#"{"output":"out of memory","exit_code":1}"#, CRITICAL),
1456 (r#"{"output":"done","exit_code":0,"error":null}"#, 0.0),
1457 (r#"{"success":true,"error":" "}"#, 0.0),
1458 (r#"{"output":"running","exit_code":null,"error":null}"#, 0.0),
1459 (r#"{"output":{"success":false},"exit_code":0}"#, 0.0),
1460 ] {
1461 let warned = format!("{text}\n\n[Tool loop warning: repeated identical call]");
1462 let request = with_messages(vec![tr("5 passed in 0.12s"), tr(text), tr(&warned)]);
1463 let signal = ToolSignals::from_request(&request, None);
1464 let clean = severity == 0.0;
1465 assert_eq!(signal.severity, severity, "{text}");
1466 assert_eq!(signal.no_error_streak, if clean { 3 } else { 0 }, "{text}");
1467 assert_eq!(signal.repeated_failure, severity >= HARD, "{text}");
1468 assert_eq!(signal.tests_passed, clean, "{text}");
1469 }
1470 }
1471
1472 #[test]
1473 fn traceback_is_hard() {
1474 let (sev, patterns) = classify_text("Traceback (most recent call last):\n ValueError");
1475 assert_eq!(sev, HARD);
1476 assert!(patterns.contains(&"traceback".to_string()));
1477 }
1478
1479 #[test]
1480 fn oom_is_critical() {
1481 let (sev, _) = classify_text("Out of memory: kill process 1234");
1482 assert_eq!(sev, CRITICAL);
1483 }
1484
1485 #[test]
1486 fn connection_refused_is_hard() {
1487 let (severity, _) = classify_text("Connection refused on port 8000");
1488 assert_eq!(severity, HARD);
1489 }
1490
1491 #[test]
1492 fn repeated_failure_ignores_volatile_paths_and_numbers() {
1493 let request = with_messages(vec![
1494 tr("error[E0308]: mismatched types at /tmp/a/src/lib.rs:12"),
1495 tr("error[E0308]: mismatched types at /tmp/b/src/lib.rs:47"),
1496 ]);
1497 assert!(ToolSignals::from_request(&request, None).repeated_failure);
1498 }
1499
1500 #[test]
1501 fn different_failures_are_not_repeated() {
1502 let request = with_messages(vec![
1503 tr("error[E0308]: mismatched types"),
1504 tr("error[E0509]: cannot move out"),
1505 ]);
1506 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1507 }
1508
1509 #[test]
1510 fn one_material_failure_is_not_repeated() {
1511 let request = with_messages(vec![tr("Connection refused on port 8000")]);
1512 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1513 }
1514
1515 #[test]
1517 fn structured_tool_failures_feed_error_and_recovery_signals() {
1518 for text in ["Dependency unavailable", "", "5 passed in 0.12s"] {
1519 let mut failed = tr(text);
1520 let ContentBlock::ToolResult(result) = &mut failed.content[0] else {
1521 panic!("expected tool result");
1522 };
1523 result.is_error = Some(true);
1524 let mut request = with_messages(vec![tr("5 passed in 0.12s"), failed.clone()]);
1525 let signals = ToolSignals::from_request(&request, Some(3));
1526 assert_eq!(signals.severity, HARD);
1527 assert!(!signals.repeated_failure);
1528 assert_eq!(signals.no_error_streak, 0);
1529 assert!(!signals.tests_passed);
1530
1531 request.llm_request.messages.push(failed);
1532 assert!(ToolSignals::from_request(&request, Some(3)).repeated_failure);
1533 request.llm_request.messages.push(tr("5 passed in 0.12s"));
1534 let recovered = ToolSignals::from_request(&request, Some(1));
1535 assert_eq!(recovered.severity, 0.0);
1536 assert!(!recovered.repeated_failure);
1537 assert_eq!(recovered.no_error_streak, 1);
1538 assert!(recovered.tests_passed);
1539 }
1540 }
1541
1542 #[test]
1543 fn severity_is_max_across_patterns() {
1544 let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
1546 assert_eq!(sev, HARD);
1547 }
1548
1549 #[test]
1550 fn codex_process_exit_zero_stays_clean() {
1551 let (sev, patterns) =
1552 classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
1553 assert_eq!(sev, 0.0);
1554 assert!(!patterns.contains(&"exit_nonzero".to_string()));
1555 }
1556
1557 #[test]
1558 fn nonzero_exit_codes_are_soft_errors() {
1559 let cases = [
1560 "Process exited with code 1",
1561 "Process exited with code 127",
1562 "exit code: 2",
1563 "exit status 3",
1564 "exited with status 9",
1565 ];
1566 for case in cases {
1567 let (sev, patterns) = classify_text(case);
1568 assert_eq!(sev, SOFT, "expected soft severity for {case}");
1569 assert!(patterns.contains(&"exit_nonzero".to_string()));
1570 }
1571 }
1572
1573 #[test]
1574 fn partial_process_failures_are_hard_errors() {
1575 let cases = [
1576 (
1577 "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
1578 "compile_error",
1579 ),
1580 (
1581 "Process exited with code 0\nOutput:\nTypeError: value is undefined\n at main.js:1:2",
1582 "runtime_exception",
1583 ),
1584 (
1585 "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
1586 "runtime_panic",
1587 ),
1588 (
1589 "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
1590 "patch_error",
1591 ),
1592 ];
1593 for (text, expected_pattern) in cases {
1594 let (severity, patterns) = classify_text(text);
1595 assert_eq!(severity, HARD, "expected hard severity for {text}");
1596 assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
1597 }
1598 }
1599
1600 #[test]
1601 fn source_text_that_names_exceptions_stays_clean() {
1602 let text =
1603 "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
1604 assert_eq!(classify_text(text).0, 0.0);
1605 }
1606
1607 #[test]
1608 fn file_does_not_exist_is_hard() {
1609 let (sev, patterns) =
1611 classify_text("Error: File does not exist. Note: current working directory is /app.");
1612 assert_eq!(sev, HARD);
1613 assert!(patterns.contains(&"no_such_file".to_string()));
1614 }
1615
1616 #[test]
1617 fn bare_does_not_exist_stays_clean() {
1618 let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
1621 assert_eq!(sev, 0.0);
1622 }
1623
1624 #[test]
1625 fn no_error_streak_all_clean() {
1626 let texts = vec![("ok".to_string(), false), ("all good".to_string(), false)];
1627 assert_eq!(compute_no_error_streak(&texts), 2);
1628 }
1629
1630 #[test]
1631 fn no_error_streak_stops_at_error() {
1632 let texts = vec![
1633 ("Traceback (most recent call last):".to_string(), false),
1634 ("ok".to_string(), false),
1635 ("ok".to_string(), false),
1636 ];
1637 assert_eq!(compute_no_error_streak(&texts), 2);
1638 }
1639
1640 #[test]
1641 fn tests_passed_detects_pytest_output() {
1642 assert!(detect_tests_passed(
1643 &[("====== 5 passed in 0.12s ======".to_string(), false)],
1644 DEFAULT_RECENT_WINDOW
1645 ));
1646 }
1647
1648 #[test]
1649 fn tests_passed_ignores_partial_failures() {
1650 assert!(!detect_tests_passed(
1651 &[("2 failed, 5 passed in 0.56s".to_string(), false)],
1652 DEFAULT_RECENT_WINDOW
1653 ));
1654 }
1655
1656 #[test]
1657 fn tests_passed_must_follow_the_latest_failure() {
1658 assert!(!detect_tests_passed(
1659 &[
1660 ("5 passed in 0.12s".to_string(), false),
1661 (
1662 "Traceback (most recent call last):\nValueError".to_string(),
1663 false
1664 ),
1665 ("edit applied".to_string(), false),
1666 ],
1667 DEFAULT_RECENT_WINDOW
1668 ));
1669 assert!(detect_tests_passed(
1670 &[
1671 (
1672 "Traceback (most recent call last):\nValueError".to_string(),
1673 false
1674 ),
1675 ("5 passed in 0.12s".to_string(), false),
1676 ],
1677 DEFAULT_RECENT_WINDOW
1678 ));
1679 }
1680
1681 #[test]
1682 fn retrieved_file_contents_are_ignored() {
1683 let call = |id: &str, name: &str, arguments: Value| Message {
1684 role: Role::Assistant,
1685 content: vec![ContentBlock::ToolCall(ToolCall {
1686 id: id.to_string(),
1687 name: name.to_string(),
1688 arguments,
1689 })],
1690 };
1691 let result = |id: &str, text: &str| Message {
1692 role: Role::User,
1693 content: vec![ContentBlock::ToolResult(ToolResult {
1694 tool_call_id: id.to_string(),
1695 content: vec![ContentBlock::Text {
1696 text: text.to_string(),
1697 }],
1698 is_error: None,
1699 })],
1700 };
1701 let signal = extract_tool_signals_with_window(
1702 &with_messages(vec![
1703 call("a", "Bash", json!({"command": "pytest"})),
1704 result("a", "Traceback (most recent call last):\nValueError"),
1705 call("b", "Read", json!({"file_path": "notes.md"})),
1706 result(
1707 "b",
1708 r#"{"success":false,"error":"out of memory","exit_code":7}"#,
1709 ),
1710 call("c", "Grep", json!({"pattern": "passed"})),
1711 result("c", "CHANGELOG.md: all tests passed"),
1712 ]),
1713 DEFAULT_RECENT_WINDOW,
1714 );
1715 assert_eq!(signal.severity, HARD);
1717 assert!(!signal.tests_passed);
1718 assert_eq!(signal.tool_result_count, 3);
1719
1720 for (command, text, severity) in [
1721 ("cat config.json", r#"{"error":"fixture data"}"#, 0.0),
1722 (
1723 "cat missing.json",
1724 r#"{"output":"","exit_code":7,"error":null}"#,
1725 SOFT,
1726 ),
1727 (
1728 "cat config.json && python check.py",
1729 r#"{"error":"check rejected"}"#,
1730 HARD,
1731 ),
1732 ] {
1733 let request = with_messages(vec![
1734 call("shell", "Bash", json!({"command": command})),
1735 result("shell", text),
1736 ]);
1737 assert_eq!(
1738 ToolSignals::from_request(&request, None).severity,
1739 severity,
1740 "{text}"
1741 );
1742 }
1743 }
1744
1745 #[test]
1746 fn severity_is_windowed_over_recent_results() {
1747 let request = with_messages(vec![
1749 tr("Traceback (most recent call last):\n ValueError"),
1750 tr("ok"),
1751 tr("ok"),
1752 ]);
1753 assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
1755 assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
1757 }
1758
1759 #[test]
1760 fn extract_openai_chat_tool_results() {
1761 let request = with_messages(vec![
1762 Message::text(Role::User, "do something"),
1763 tc("Edit"),
1764 tr("Traceback (most recent call last):\n ValueError"),
1765 ]);
1766 let sig = ToolSignals::from_request(&request, None);
1767 assert_eq!(sig.severity, HARD);
1768 assert_eq!(sig.edit_count, 1);
1769 assert_eq!(sig.turn_depth, 3);
1770 }
1771
1772 #[test]
1773 fn extract_anthropic_tool_results() {
1774 let request = with_messages(vec![tr("Traceback (most recent call last):\n ValueError")]);
1775 let sig = ToolSignals::from_request(&request, None);
1776 assert_eq!(sig.severity, HARD);
1777 }
1778
1779 #[test]
1780 fn extract_responses_api_tool_results() {
1781 let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
1782 let sig = ToolSignals::from_request(&request, None);
1783 assert_eq!(sig.severity, 0.0);
1784 assert_eq!(sig.write_count, 1);
1785 }
1786
1787 #[test]
1788 fn responses_builtin_tool_failures_escalate() {
1789 use crate::algorithms::util::stage::{PickOutcome, PickerMode, Tier, pick_tier};
1790
1791 let mut cases = Vec::new();
1792 for (status, output) in [
1793 (
1794 "failed",
1795 "Synthetic dependency unavailable; retry with the recovery path.",
1796 ),
1797 (
1798 "completed",
1799 "Synthetic dependency unavailable; retry with the recovery path.",
1800 ),
1801 ("failed", ""),
1802 ] {
1803 cases.push((
1804 json!({
1805 "type": "apply_patch_call_output",
1806 "status": status,
1807 "output": output,
1808 }),
1809 if status == "failed" { HARD } else { 0.0 },
1810 ));
1811 }
1812 for (outcome, stdout, stderr, severity) in [
1813 (json!({"type": "exit", "exit_code": 1}), "", "", SOFT),
1814 (json!({"type": "exit", "exit_code": 1}), "", " \n", SOFT),
1815 (
1816 json!({"type": "exit", "exit_code": 1}),
1817 "",
1818 "command failed",
1819 HARD,
1820 ),
1821 (json!({"type": "timeout"}), "", "", HARD),
1822 (json!({"type": "exit", "exit_code": 0}), "done", "", 0.0),
1823 (
1824 json!({"type": "exit", "exit_code": 0}),
1825 "Traceback (most recent call last):",
1826 "",
1827 HARD,
1828 ),
1829 (
1830 json!({"type": "exit", "exit_code": 0}),
1831 "",
1832 "Traceback (most recent call last):",
1833 HARD,
1834 ),
1835 ] {
1836 cases.push((
1837 json!({
1838 "type": "shell_call_output",
1839 "output": [
1840 {"stdout": stdout, "stderr": stderr, "outcome": outcome},
1841 {"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}
1842 ],
1843 }),
1844 severity,
1845 ));
1846 }
1847 for (raw, severity) in cases {
1848 let is_error = severity >= HARD;
1849 let mut request = with_messages(
1850 ["call_1", "call_2"]
1851 .into_iter()
1852 .map(|call_id| {
1853 let mut raw = raw.clone();
1854 raw["call_id"] = json!(call_id);
1855 Message {
1856 role: Role::User,
1857 content: vec![ContentBlock::Unknown {
1858 provider: WireFormat::OpenAiResponses.into(),
1859 raw,
1860 }],
1861 }
1862 })
1863 .collect(),
1864 );
1865 let signal = ToolSignals::from_request(&request, Some(3));
1866 assert_eq!(signal.severity, severity, "{raw}");
1867 assert_eq!(signal.repeated_failure, is_error, "{raw}");
1868 assert_eq!(signal.tool_result_count, 2);
1869 assert_eq!(
1870 matches!(
1871 pick_tier(&signal, PickerMode::EfficientFirst, 0.5),
1872 PickOutcome::Resolved {
1873 tier: Tier::Capable,
1874 ..
1875 }
1876 ),
1877 is_error,
1878 "{raw}"
1879 );
1880
1881 let mut success = match raw["type"].as_str() {
1882 Some("apply_patch_call_output") => json!({
1883 "type": "apply_patch_call_output", "status": "completed", "output": ""
1884 }),
1885 Some("shell_call_output") => json!({
1886 "type": "shell_call_output",
1887 "output": [{"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}]
1888 }),
1889 _ => unreachable!(),
1890 };
1891 for index in 0..3 {
1892 success["call_id"] = json!(format!("success_{index}"));
1893 request.llm_request.messages.push(Message {
1894 role: Role::User,
1895 content: vec![ContentBlock::Unknown {
1896 provider: WireFormat::OpenAiResponses.into(),
1897 raw: success.clone(),
1898 }],
1899 });
1900 }
1901 let recovered = ToolSignals::from_request(&request, Some(3));
1902 assert_eq!(recovered.severity, 0.0, "{raw}");
1903 assert!(!recovered.repeated_failure, "{raw}");
1904 assert!(recovered.no_error_streak >= 3, "{raw}");
1905 assert_eq!(recovered.tool_result_count, 5);
1906 }
1907 }
1908
1909 #[test]
1910 fn conversation_counts_are_per_block_and_role_aware() {
1911 let result = |content: Vec<ContentBlock>| {
1915 ContentBlock::ToolResult(ToolResult {
1916 tool_call_id: String::new(),
1917 content,
1918 is_error: None,
1919 })
1920 };
1921 let request = with_messages(vec![
1922 Message::text(Role::User, "do something"),
1923 Message::text(Role::Assistant, "working"),
1924 Message {
1925 role: Role::User,
1926 content: vec![
1927 result(vec![ContentBlock::Text {
1928 text: "ok".to_string(),
1929 }]),
1930 result(Vec::new()),
1931 ],
1932 },
1933 tc("Bash"),
1934 ]);
1935 let sig = ToolSignals::from_request(&request, None);
1936 assert_eq!(sig.tool_result_count, 2);
1937 assert_eq!(sig.assistant_turn_count, 2);
1938 assert_eq!(sig.turn_depth, 4);
1939 }
1940
1941 #[test]
1942 fn recent_window_counts_only_last_default_window_tool_calls() {
1943 let request = with_messages(vec![
1946 tc("Write"),
1947 tr("ok"),
1948 tc("Write"),
1949 tr("ok"),
1950 tc("Write"),
1951 tr("ok"),
1952 tc("Write"),
1953 tr("ok"),
1954 tc("Write"),
1955 tr("ok"),
1956 tc("Edit"),
1957 tr("ok"),
1958 ]);
1959 let sig = ToolSignals::from_request(&request, None);
1960 assert_eq!(sig.write_count, 5);
1961 assert_eq!(sig.edit_count, 1);
1962 assert_eq!(sig.recent_write_count, 2);
1963 assert_eq!(sig.recent_edit_count, 1);
1964 }
1965
1966 #[test]
1967 fn codex_apply_patch_counts_as_an_edit() {
1968 let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
1969 let sig = ToolSignals::from_request(&request, None);
1970 assert_eq!(sig.edit_count, 1);
1971 assert_eq!(sig.recent_edit_count, 1);
1972 }
1973
1974 fn exec_command(cmd: Value) -> Message {
1975 Message {
1976 role: Role::Assistant,
1977 content: vec![ContentBlock::ToolCall(ToolCall {
1978 id: String::new(),
1979 name: "exec_command".to_string(),
1980 arguments: cmd,
1981 })],
1982 }
1983 }
1984
1985 #[test]
1986 fn codex_exec_command_is_classified() {
1987 let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
1989 let request = with_messages(vec![exec_command(args), tr("ok")]);
1990 assert_eq!(
1991 ToolSignals::from_request(&request, None).recent_edit_count,
1992 1
1993 );
1994 }
1995
1996 #[test]
1997 fn python_write_expressions_need_a_python_command() {
1998 let write = with_messages(vec![
1999 exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
2000 tr("ok"),
2001 ]);
2002 assert_eq!(
2003 ToolSignals::from_request(&write, None).recent_write_count,
2004 1
2005 );
2006
2007 let search = with_messages(vec![
2008 exec_command(json!({"cmd": "grep -R '.write(' src"})),
2009 tr("ok"),
2010 ]);
2011 assert_eq!(
2012 ToolSignals::from_request(&search, None).recent_write_count,
2013 0
2014 );
2015 }
2016
2017 #[test]
2018 fn recent_window_size_is_caller_overridable() {
2019 let request = with_messages(vec![
2023 tc("Write"),
2024 tr("ok"),
2025 tc("Write"),
2026 tr("ok"),
2027 tc("Write"),
2028 tr("ok"),
2029 tc("Write"),
2030 tr("ok"),
2031 tc("Write"),
2032 tr("ok"),
2033 tc("Edit"),
2034 tr("ok"),
2035 ]);
2036 let narrow = extract_tool_signals_with_window(&request, 3);
2037 assert_eq!(narrow.recent_write_count, 2);
2038 assert_eq!(narrow.recent_edit_count, 1);
2039
2040 let wide = extract_tool_signals_with_window(&request, 6);
2041 assert_eq!(wide.recent_write_count, 5);
2042 assert_eq!(wide.recent_edit_count, 1);
2043 }
2044
2045 #[test]
2046 fn compaction_marker_sets_compacted() {
2047 let request = with_messages(vec![
2049 Message::text(
2050 Role::User,
2051 "This session is being continued from a previous conversation that ran out of context.",
2052 ),
2053 bash("ls"),
2054 ]);
2055 assert!(ToolSignals::from_request(&request, None).compacted);
2056 }
2057
2058 #[test]
2059 fn codex_compaction_metadata_stays_on_parent_route() {
2060 let mut request = with_messages(vec![bash("ls")]);
2061 request.metadata = Some(Metadata {
2062 is_subagent: true,
2063 agent_kind: Some("compact".to_string()),
2064 ..Default::default()
2065 });
2066 assert!(!ToolSignals::from_request(&request, None).compacted);
2067 }
2068
2069 #[test]
2070 fn no_compaction_marker_stays_uncompacted() {
2071 let request = with_messages(vec![
2072 Message::text(Role::User, "Write a script that parses the log file."),
2073 bash("ls"),
2074 ]);
2075 assert!(!ToolSignals::from_request(&request, None).compacted);
2076 }
2077
2078 #[test]
2079 fn bash_heredoc_counts_as_write() {
2080 let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
2082 let sig = ToolSignals::from_request(&request, None);
2083 assert_eq!(
2084 sig.write_count, 1,
2085 "Bash heredoc should bucket into write_count"
2086 );
2087 assert_eq!(sig.edit_count, 0);
2088 }
2089
2090 #[test]
2091 fn bash_sed_inplace_counts_as_edit() {
2092 let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
2093 let sig = ToolSignals::from_request(&request, None);
2094 assert_eq!(
2095 sig.edit_count, 1,
2096 "Bash sed -i should bucket into edit_count"
2097 );
2098 assert_eq!(sig.write_count, 0);
2099 }
2100
2101 #[test]
2102 fn bash_non_mutating_does_not_count() {
2103 let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
2105 let sig = ToolSignals::from_request(&request, None);
2106 assert_eq!(sig.write_count, 0);
2107 assert_eq!(sig.edit_count, 0);
2108 }
2109
2110 #[test]
2111 fn tests_passed_detects_pytest_with_failure_block() {
2112 assert!(!detect_tests_passed(
2114 &[("2 failed, 5 passed in 0.56s".to_string(), false)],
2115 DEFAULT_RECENT_WINDOW
2116 ));
2117 }
2118
2119 #[test]
2120 fn tests_passed_accepts_cargo_clean_summary() {
2121 assert!(detect_tests_passed(
2124 &[(
2125 "running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string(),
2126 false
2127 )],
2128 DEFAULT_RECENT_WINDOW
2129 ));
2130 }
2131
2132 #[test]
2133 fn tests_passed_rejects_cargo_real_failure() {
2134 assert!(!detect_tests_passed(
2136 &[(
2137 "running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string(),
2138 false
2139 )],
2140 DEFAULT_RECENT_WINDOW
2141 ));
2142 }
2143
2144 #[test]
2145 fn tests_passed_accepts_go_clean_summary() {
2146 assert!(detect_tests_passed(
2148 &[(
2149 "ok github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string(),
2150 false
2151 )],
2152 DEFAULT_RECENT_WINDOW
2153 ));
2154 }
2155
2156 #[test]
2157 fn tests_passed_accepts_pytest_zero_errors() {
2158 assert!(detect_tests_passed(
2160 &[("5 passed, 0 errors in 0.30s".to_string(), false)],
2161 DEFAULT_RECENT_WINDOW
2162 ));
2163 }
2164
2165 #[test]
2166 fn tests_passed_detects_diy_checkmark() {
2167 assert!(detect_tests_passed(
2168 &[("✓ all checks passed".to_string(), false)],
2169 DEFAULT_RECENT_WINDOW
2170 ));
2171 }
2172
2173 #[test]
2174 fn anthropic_bash_heredoc_extracts_command() {
2175 let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
2177 let sig = ToolSignals::from_request(&request, None);
2178 assert_eq!(
2179 sig.write_count, 1,
2180 "Anthropic Bash heredoc must also be detected"
2181 );
2182 }
2183
2184 #[test]
2185 fn recent_window_falls_back_to_full_history_when_short() {
2186 let request = with_messages(vec![tc("Write")]);
2187 let sig = ToolSignals::from_request(&request, None);
2188 assert_eq!(sig.recent_write_count, 1);
2189 assert_eq!(sig.recent_edit_count, 0);
2190 }
2191
2192 #[test]
2193 fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
2194 let request = with_messages(vec![tr("output ok"), tr("another ok")]);
2195 let sig = ToolSignals::from_request(&request, None);
2196 assert_eq!(sig.severity, 0.0);
2197 assert_eq!(sig.no_error_streak, 2);
2198 }
2199
2200 #[test]
2203 fn todowrite_classifies_as_plan() {
2204 assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
2205 assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
2206 }
2207
2208 #[test]
2209 fn codex_update_plan_classifies_as_plan() {
2210 assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
2211 }
2212
2213 #[test]
2214 fn codex_shell_command_runs_bash_pattern_match() {
2215 assert_eq!(
2217 classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
2218 ToolSemantic::Mutate(MutationKind::Write),
2219 );
2220 assert_eq!(
2222 classify_tool_call("shell_command", Some("ls /app")),
2223 ToolSemantic::Observe,
2224 );
2225 assert_eq!(
2227 classify_tool_call("shell_command", Some("./run_tests.sh")),
2228 ToolSemantic::Unknown,
2229 );
2230 }
2231
2232 #[test]
2233 fn text_editor_view_is_a_read() {
2234 for name in ["str_replace_based_edit_tool", "text_editor"] {
2235 assert_eq!(
2236 classify_tool_call(name, Some("view")),
2237 ToolSemantic::Observe
2238 );
2239 for command in [
2240 Some("create"),
2241 Some("insert"),
2242 Some("str_replace"),
2243 Some("undo_edit"),
2244 None,
2245 ] {
2246 assert_eq!(
2247 classify_tool_call(name, command),
2248 ToolSemantic::Mutate(MutationKind::Edit),
2249 );
2250 }
2251 }
2252
2253 let arguments = [
2254 json!({"command": "view", "path": "/app/main.py"}),
2255 json!(r#"{"command":"view","path":"/app/main.py"}"#),
2257 ];
2258 for arguments in arguments {
2259 let call = Message {
2260 role: Role::Assistant,
2261 content: vec![ContentBlock::ToolCall(ToolCall {
2262 id: String::new(),
2263 name: "str_replace_based_edit_tool".to_string(),
2264 arguments,
2265 })],
2266 };
2267 let request = with_messages(vec![call, tr("print('hi')")]);
2268 let sig = ToolSignals::from_request(&request, None);
2269 assert_eq!(sig.read_count, 1);
2270 assert_eq!(sig.recent_read_count, 1);
2271 assert_eq!(sig.edit_count, 0);
2272 }
2273 }
2274
2275 #[test]
2276 fn read_tool_classifies_as_read() {
2277 assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
2278 assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
2279 }
2280
2281 #[test]
2282 fn hermes_tool_names_classify() {
2283 assert_eq!(
2285 classify_tool_call("write_file", None),
2286 ToolSemantic::Mutate(MutationKind::Write)
2287 );
2288 assert_eq!(
2289 classify_tool_call("patch", None),
2290 ToolSemantic::Mutate(MutationKind::Edit)
2291 );
2292 assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
2293 assert_eq!(
2294 classify_tool_call("search_files", None),
2295 ToolSemantic::Observe
2296 );
2297 assert_eq!(
2300 classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
2301 ToolSemantic::Mutate(MutationKind::Edit),
2302 );
2303 assert_eq!(
2304 classify_tool_call("terminal", Some("grep foo /app")),
2305 ToolSemantic::Observe,
2306 );
2307 assert_eq!(
2308 classify_tool_call("terminal", Some("./run_tests.sh")),
2309 ToolSemantic::Unknown,
2310 );
2311 }
2312
2313 #[test]
2314 fn bash_read_patterns_classify_as_read() {
2315 let cases = [
2316 "cat /etc/passwd",
2317 "grep foo bar.txt",
2318 "ls /app",
2319 "find . -name '*.py'",
2320 ];
2321 for cmd in cases {
2322 assert_eq!(
2323 classify_tool_call("Bash", Some(cmd)),
2324 ToolSemantic::Observe,
2325 "expected Read for {cmd}"
2326 );
2327 }
2328 }
2329
2330 #[test]
2331 fn codex_inspection_commands_classify_as_read() {
2332 let cases = [
2333 "sed -n '1,80p' src/lib.rs",
2334 "rg -n 'needle' src",
2335 "nl -ba src/lib.rs",
2336 "cat package.json",
2337 "jq '.scripts' package.json",
2338 "git status --short",
2339 "git log --oneline -5",
2340 "git show HEAD:src/lib.rs",
2341 "git branch --show-current",
2342 "git remote -v",
2343 "git config --get remote.origin.url",
2344 ];
2345 for command in cases {
2346 assert_eq!(
2347 classify_tool_call("exec_command", Some(command)),
2348 ToolSemantic::Observe,
2349 "expected Read for {command}"
2350 );
2351 }
2352 }
2353
2354 #[test]
2355 fn quoted_shell_separators_do_not_create_commands() {
2356 for command in ["rg 'foo|rm obsolete.rs'", "rg \"foo; rm obsolete.rs\""] {
2357 assert_eq!(
2358 classify_tool_call("exec_command", Some(command)),
2359 ToolSemantic::Observe,
2360 "quoted text must not be parsed as a command: {command}"
2361 );
2362 }
2363 }
2364
2365 #[test]
2366 fn codex_shell_mutations_classify_as_production() {
2367 let writes = [
2368 "cp source.rs destination.rs",
2369 "mkdir -p src/generated",
2370 "touch src/generated/mod.rs",
2371 "git show HEAD:file.rs > file.rs",
2372 "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
2373 ];
2374 for command in writes {
2375 assert_eq!(
2376 classify_tool_call("exec_command", Some(command)),
2377 ToolSemantic::Mutate(MutationKind::Write),
2378 "expected Write for {command}"
2379 );
2380 }
2381
2382 let edits = [
2383 "mv old.rs new.rs",
2384 "rm obsolete.rs",
2385 "gofmt -w main.go",
2386 "cargo fmt",
2387 "ruff check --fix src",
2388 "perl -0pi -e 's/old/new/' src/lib.rs",
2389 "npx prettier --write src/lib.ts",
2390 "uv run ruff format src",
2391 "git apply fix.patch",
2392 ];
2393 for command in edits {
2394 assert_eq!(
2395 classify_tool_call("exec_command", Some(command)),
2396 ToolSemantic::Mutate(MutationKind::Edit),
2397 "expected Edit for {command}"
2398 );
2399 }
2400 }
2401
2402 #[test]
2403 fn formatter_checks_are_not_edits() {
2404 for command in [
2405 "cargo fmt --check",
2406 "ruff format --check src",
2407 "black --check src",
2408 ] {
2409 assert_ne!(
2410 classify_tool_call("exec_command", Some(command)),
2411 ToolSemantic::Mutate(MutationKind::Edit),
2412 "read-only formatter check must not be Edit: {command}"
2413 );
2414 }
2415 }
2416
2417 #[test]
2418 fn embedded_comparison_is_not_a_shell_write() {
2419 let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
2420 assert_eq!(
2421 classify_tool_call("exec_command", Some(command)),
2422 ToolSemantic::Unknown
2423 );
2424 }
2425
2426 #[test]
2427 fn bash_write_precedence_over_read() {
2428 assert_eq!(
2431 classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
2432 ToolSemantic::Mutate(MutationKind::Write),
2433 );
2434 }
2435
2436 #[test]
2437 fn pure_bash_streak_counts_trailing_other() {
2438 let request = with_messages(vec![
2440 bash("make"),
2441 tr("ok"),
2442 bash("./configure"),
2443 tr("ok"),
2444 bash("make install"),
2445 tr("ok"),
2446 bash("./run.sh"),
2447 tr("ok"),
2448 bash("./test"),
2449 tr("ok"),
2450 ]);
2451 let sig = ToolSignals::from_request(&request, None);
2452 assert_eq!(sig.pure_bash_streak, 5);
2453 assert_eq!(sig.write_count, 0);
2454 assert_eq!(sig.read_count, 0);
2455 }
2456
2457 #[test]
2458 fn pure_bash_streak_resets_on_write() {
2459 let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
2460 let sig = ToolSignals::from_request(&request, None);
2461 assert_eq!(sig.pure_bash_streak, 0);
2462 assert_eq!(sig.write_count, 1);
2463 }
2464
2465 #[test]
2466 fn recent_window_tracks_todowrite_and_read() {
2467 let request = with_messages(vec![
2469 bash("make"),
2470 tr("ok"),
2471 tc("TodoWrite"),
2472 tr("ok"),
2473 tc("Read"),
2474 tr("ok"),
2475 tc("TodoWrite"),
2476 tr("ok"),
2477 ]);
2478 let sig = ToolSignals::from_request(&request, None);
2479 assert_eq!(sig.todowrite_count, 2);
2480 assert_eq!(sig.recent_todowrite_count, 2);
2481 assert_eq!(sig.read_count, 1);
2482 assert_eq!(sig.recent_read_count, 1);
2483 }
2484
2485 #[test]
2486 fn configured_tool_semantics_extend_the_builtin_vocabulary() {
2487 let semantics = ToolSemantics {
2488 observe: vec!["KB_search".to_string()],
2489 mutate: vec!["send_payment_request".to_string()],
2490 plan: vec!["create_research_plan".to_string()],
2491 new: vec!["send_message_to_user".to_string()],
2492 };
2493 semantics.validate().expect("valid additive semantics");
2494 let request = with_messages(vec![
2495 tc("Read"),
2496 tc("Write"),
2497 tc("TodoWrite"),
2498 tc("kb_SEARCH"),
2499 tc("send_payment_request"),
2500 tc("create_research_plan"),
2501 tc("send_message_to_user"),
2502 tc("unlisted_tool"),
2503 ]);
2504
2505 let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
2506
2507 assert_eq!(signal.read_count, 2);
2508 assert_eq!(signal.write_count, 2);
2509 assert_eq!(signal.todowrite_count, 2);
2510 assert_eq!(signal.new_count, 1);
2511 assert_eq!(signal.recent_new_count, 1);
2512 assert_eq!(signal.pure_bash_streak, 1);
2513 }
2514
2515 #[test]
2516 fn configured_tool_semantics_match_namespaced_and_mcp_tools() {
2517 let mut request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
2519 request.llm_request.extensions.fields.insert(
2520 TOOL_NAMESPACES_KEY.to_string(),
2521 json!({"mcp__billing__send_payment_request": "mcp__billing"}),
2522 );
2523
2524 let claude_request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
2526
2527 for request in [&request, &claude_request] {
2528 for name in ["send_payment_request", "mcp__billing__send_payment_request"] {
2529 let semantics = ToolSemantics {
2530 mutate: vec![name.to_string()],
2531 ..Default::default()
2532 };
2533 let signal = ToolSignals::from_request_with_semantics(request, None, &semantics);
2534 assert_eq!(signal.write_count, 1, "{name}");
2535 }
2536 }
2537 }
2538
2539 #[test]
2540 fn configured_tool_semantics_only_fold_ascii_case() {
2541 let semantics = ToolSemantics {
2542 observe: vec!["kb_search".to_string()],
2543 ..Default::default()
2544 };
2545
2546 assert_eq!(
2547 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2548 ToolSemantic::Observe
2549 );
2550 assert_eq!(
2553 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2554 ToolSemantic::Unknown
2555 );
2556 }
2557
2558 #[test]
2559 fn custom_semantics_preserve_builtin_unicode_lowercasing() {
2560 let semantics = ToolSemantics {
2561 observe: vec!["lookup_customer".to_string()],
2562 ..Default::default()
2563 };
2564
2565 assert_eq!(
2567 classify_tool_call_with_semantics("notebooKedit", None, &semantics),
2568 ToolSemantic::Mutate(MutationKind::Edit)
2569 );
2570 }
2571
2572 #[test]
2573 fn configured_semantics_never_replace_builtin_classifications() {
2574 let semantics = ToolSemantics {
2575 observe: vec!["lookup_customer".to_string()],
2576 mutate: vec!["send_payment".to_string()],
2577 plan: vec!["create_workflow".to_string()],
2578 new: vec!["send_message".to_string()],
2579 };
2580
2581 for name in WRITE_TOOL_NAMES {
2582 assert_eq!(
2583 classify_tool_call_with_semantics(name, None, &semantics),
2584 ToolSemantic::Mutate(MutationKind::Write),
2585 "write tool {name:?} changed classification"
2586 );
2587 }
2588 for name in EDIT_TOOL_NAMES {
2589 assert_eq!(
2590 classify_tool_call_with_semantics(name, None, &semantics),
2591 ToolSemantic::Mutate(MutationKind::Edit),
2592 "edit tool {name:?} changed classification"
2593 );
2594 }
2595 for name in READ_TOOL_NAMES {
2596 assert_eq!(
2597 classify_tool_call_with_semantics(name, None, &semantics),
2598 ToolSemantic::Observe,
2599 "read tool {name:?} changed classification"
2600 );
2601 }
2602 for name in PLAN_TOOL_NAMES {
2603 assert_eq!(
2604 classify_tool_call_with_semantics(name, None, &semantics),
2605 ToolSemantic::Plan,
2606 "plan tool {name:?} changed classification"
2607 );
2608 }
2609
2610 for (command, expected) in [
2611 ("cat /tmp/input", ToolSemantic::Observe),
2612 (
2613 "cat /tmp/input > /tmp/output",
2614 ToolSemantic::Mutate(MutationKind::Write),
2615 ),
2616 (
2617 "sed -i 's/a/b/' /tmp/file",
2618 ToolSemantic::Mutate(MutationKind::Edit),
2619 ),
2620 ("./run_tests.sh", ToolSemantic::Unknown),
2621 ] {
2622 assert_eq!(
2623 classify_tool_call_with_semantics("BASH", Some(command), &semantics),
2624 expected,
2625 "bash command {command:?} changed classification"
2626 );
2627 }
2628 }
2629
2630 #[test]
2631 fn configured_semantics_score_like_their_builtin_equivalents() {
2632 let semantics = ToolSemantics {
2633 observe: vec!["lookup_customer".to_string()],
2634 mutate: vec!["send_payment".to_string()],
2635 plan: vec!["create_workflow".to_string()],
2636 ..Default::default()
2637 };
2638
2639 for (builtin, configured) in [
2640 ("Read", "lookup_customer"),
2641 ("Write", "send_payment"),
2642 ("TodoWrite", "create_workflow"),
2643 ] {
2644 let messages_before_tool = || {
2645 vec![
2646 Message::text(Role::User, "start"),
2647 Message::text(Role::Assistant, "working"),
2648 Message::text(Role::User, "continue"),
2649 Message::text(Role::Assistant, "working"),
2650 Message::text(Role::User, "continue"),
2651 Message::text(Role::Assistant, "working"),
2652 Message::text(Role::User, "continue"),
2653 ]
2654 };
2655 let mut builtin_messages = messages_before_tool();
2656 builtin_messages.push(tc(builtin));
2657 let mut configured_messages = messages_before_tool();
2658 configured_messages.push(tc(configured));
2659
2660 let builtin_score = score_signal(&ToolSignals::from_request(
2661 &with_messages(builtin_messages),
2662 None,
2663 ));
2664 let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
2665 &with_messages(configured_messages),
2666 None,
2667 &semantics,
2668 ));
2669
2670 assert_ne!(
2671 builtin_score.score, 0.0,
2672 "the {builtin:?} control must exercise a scoring dimension"
2673 );
2674 assert_eq!(
2675 configured_score, builtin_score,
2676 "configured tool {configured:?} must score exactly like {builtin:?}"
2677 );
2678 }
2679 }
2680
2681 #[test]
2682 fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
2683 let duplicate = ToolSemantics {
2684 observe: vec!["lookup".to_string()],
2685 mutate: vec!["LOOKUP".to_string()],
2686 ..Default::default()
2687 };
2688 assert!(
2689 duplicate
2690 .validate()
2691 .expect_err("duplicate should fail")
2692 .to_string()
2693 .contains("appears in both")
2694 );
2695
2696 let builtin = ToolSemantics {
2697 new: vec!["write_file".to_string()],
2698 ..Default::default()
2699 };
2700 assert!(
2701 builtin
2702 .validate()
2703 .expect_err("built-in should fail")
2704 .to_string()
2705 .contains("built-in semantics")
2706 );
2707
2708 let empty = ToolSemantics {
2709 observe: vec![" \t".to_string()],
2710 ..Default::default()
2711 };
2712 assert!(
2713 empty
2714 .validate()
2715 .expect_err("empty name should fail")
2716 .to_string()
2717 .contains("empty tool name")
2718 );
2719 }
2720}