1#![allow(dead_code)]
14
15use std::path::Path;
16
17use async_trait::async_trait;
18use serde::Deserialize;
19use serde_json::Value;
20use switchyard_protocol::{ContentBlock, Request, Role};
21
22use crate::{LibsyError, Result};
23
24use crate::core::processor::{Event, Processor};
25use crate::core::state::State;
26
27const SOFT: f32 = 0.3;
30const HARD: f32 = 0.7;
31const CRITICAL: f32 = 1.0;
32
33static ERROR_PATTERNS: &[(&str, f32, &[&str])] = &[
37 (
38 "oom",
39 CRITICAL,
40 &["out of memory", "memoryerror", "cannot allocate memory"],
41 ),
42 (
43 "connection_refused",
44 HARD,
45 &[
46 "connection refused",
47 "connectionrefusederror",
48 "econnrefused",
49 ],
50 ),
51 ("traceback", HARD, &["traceback (most recent call last)"]),
52 (
53 "import_error",
54 HARD,
55 &["modulenotfounderror:", "importerror:", "no module named "],
56 ),
57 (
58 "cmd_not_found",
59 HARD,
60 &["command not found", "not found\n", "/usr/bin/env: "],
61 ),
62 ("assertion", HARD, &["assertionerror"]),
63 ("value_error", HARD, &["valueerror:"]),
64 ("syntax_error", HARD, &["syntaxerror:"]),
65 (
66 "timeout",
67 HARD,
68 &[
69 "timed out",
70 "timeouterror",
71 "timeout expired",
72 "deadline exceeded",
73 ],
74 ),
75 (
76 "no_such_file",
77 HARD,
78 &[
79 "filenotfounderror:",
80 "no such file or directory",
81 "file does not exist",
85 ],
86 ),
87 ("exit_nonzero", SOFT, &["returned non-zero"]),
89];
90
91static NONZERO_EXIT_PHRASES: &[&str] = &[
92 "exit code",
93 "exit status",
94 "exited with code",
95 "exited with status",
96];
97
98static EDIT_TOOL_NAMES: &[&str] = &[
99 "edit",
100 "multiedit",
101 "notebookedit",
102 "str_replace",
103 "str_replace_based_edit_tool",
104 "apply_patch", "text_editor",
106 "patch", ];
108
109static WRITE_TOOL_NAMES: &[&str] = &["write", "create_file", "new_file", "write_file"];
110
111static BASH_WRITE_PATTERNS: &[&str] = &[
115 "cat >",
116 "cat >>",
117 "echo >",
118 "echo >>",
119 "tee ",
120 "printf >",
121 "printf >>",
122 "> /",
123 ">> /",
124 "<< 'eof'",
125 "<<eof",
126 "<<'eof'",
127 "<< eof",
128];
129
130static PYTHON_WRITE_PATTERNS: &[&str] = &["write_text(", "writelines(", ".write("];
133
134static JAVASCRIPT_WRITE_PATTERNS: &[&str] = &[
135 "writefilesync(",
136 "writefile(",
137 "appendfilesync(",
138 "appendfile(",
139];
140
141static BASH_EDIT_PATTERNS: &[&str] = &[
142 "sed -i",
143 "sed --in-place",
144 "awk -i inplace",
145 "awk 'inplace=1'",
146 "patch ",
147 "patch -p",
148 "perl -i",
149 "perl -p -i",
150 "perl -pi",
151];
152
153static BASH_READ_PATTERNS: &[&str] = &[
156 "cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
157 "diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
158];
159
160static BASH_READ_COMMANDS: &[&str] = &[
163 "cat", "rg", "nl", "jq", "pwd", "tree", "sed", "grep", "ls", "find", "head", "tail", "wc",
164 "diff", "which", "ps", "df", "du", "stat", "file", "less", "more", "readlink", "realpath",
165 "basename", "dirname", "printenv",
166];
167
168static GIT_READ_SUBCOMMANDS: &[&str] = &[
169 "status",
170 "diff",
171 "log",
172 "show",
173 "show-ref",
174 "rev-parse",
175 "ls-files",
176 "ls-remote",
177 "ls-tree",
178 "grep",
179 "blame",
180 "merge-base",
181 "check-ignore",
182 "tag",
183];
184
185static READ_TOOL_NAMES: &[&str] = &["read", "view", "read_file", "search_files"];
186
187static PLAN_TOOL_NAMES: &[&str] = &["todowrite", "todo_write", "todo", "update_plan"];
190
191static BASH_TOOL_NAMES: &[&str] = &[
196 "bash",
197 "shell_command",
198 "shell",
199 "local_shell_call",
200 "terminal",
201 "exec_command", ];
203
204static TEST_PASS_PHRASES: &[&str] = &[
207 " passed",
208 "passed in",
209 "tests passed",
210 "all tests passed",
211 "test ok",
212 "test result: ok",
213 "passed.\n",
214 "tests pass",
215 "\nok ", "✓ ",
217];
218
219static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
224
225static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
229
230pub const DEFAULT_RECENT_WINDOW: usize = 3;
237
238#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
243#[serde(default, deny_unknown_fields)]
244pub struct ToolSemantics {
245 pub observe: Vec<String>,
247 pub mutate: Vec<String>,
249 pub plan: Vec<String>,
251 pub new: Vec<String>,
253}
254
255impl ToolSemantics {
256 pub fn validate(&self) -> Result<()> {
258 let mut seen: Vec<(String, &'static str)> = Vec::new();
259 for (category, names) in [
260 ("observe", &self.observe),
261 ("mutate", &self.mutate),
262 ("plan", &self.plan),
263 ("new", &self.new),
264 ] {
265 for name in names {
266 if name.trim().is_empty() {
267 return Err(tool_semantics_error(format!(
268 "tool_semantics.{category} contains an empty tool name"
269 )));
270 }
271 let normalized = name.to_ascii_lowercase();
272 if is_builtin_tool_name(&name.to_lowercase()) {
273 return Err(tool_semantics_error(format!(
274 "tool {name:?} already has built-in semantics and cannot be reclassified"
275 )));
276 }
277 if let Some((_, previous)) = seen.iter().find(|(seen, _)| seen == &normalized) {
278 return Err(tool_semantics_error(format!(
279 "tool {name:?} appears in both tool_semantics.{previous} and tool_semantics.{category}"
280 )));
281 }
282 seen.push((normalized, category));
283 }
284 }
285 Ok(())
286 }
287
288 fn classify(&self, name: &str) -> Option<ToolSemantic> {
289 if contains_name(&self.observe, name) {
290 Some(ToolSemantic::Observe)
291 } else if contains_name(&self.mutate, name) {
292 Some(ToolSemantic::Mutate(MutationKind::Write))
295 } else if contains_name(&self.plan, name) {
296 Some(ToolSemantic::Plan)
297 } else if contains_name(&self.new, name) {
298 Some(ToolSemantic::New)
299 } else {
300 None
301 }
302 }
303}
304
305fn contains_name(names: &[String], candidate: &str) -> bool {
306 names
307 .iter()
308 .any(|name| name.eq_ignore_ascii_case(candidate))
309}
310
311fn tool_semantics_error(message: String) -> LibsyError {
312 LibsyError::AlgorithmError { message }
313}
314
315#[derive(Clone, Debug, Default)]
324pub struct ToolSignals {
325 pub severity: f32,
330 pub repeated_failure: bool,
333 pub no_error_streak: u32,
335 pub edit_count: u32,
337 pub write_count: u32,
339 pub read_count: u32,
341 pub todowrite_count: u32,
344 pub recent_edit_count: u32,
346 pub recent_write_count: u32,
348 pub recent_read_count: u32,
350 pub recent_todowrite_count: u32,
352 pub new_count: u32,
354 pub recent_new_count: u32,
356 pub pure_bash_streak: u32,
359 pub tests_passed: bool,
361 pub tool_result_count: u32,
364 pub assistant_turn_count: u32,
367 pub turn_depth: u32,
371 pub compacted: bool,
377}
378
379impl ToolSignals {
380 pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
385 Self::from_request_with_semantics(request, window_size, &ToolSemantics::default())
386 }
387
388 pub fn from_request_with_semantics(
390 request: &Request,
391 window_size: Option<usize>,
392 semantics: &ToolSemantics,
393 ) -> Self {
394 extract_tool_signals_with_window_and_semantics(
395 request,
396 window_size.unwrap_or(DEFAULT_RECENT_WINDOW),
397 semantics,
398 )
399 }
400}
401
402#[derive(Debug, Clone)]
404struct ObservedToolCall {
405 name: String,
406 command: Option<String>,
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410enum MutationKind {
411 Write,
412 Edit,
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
417enum ToolSemantic {
418 Mutate(MutationKind),
419 Observe,
420 Plan,
421 New,
422 Unknown,
423}
424
425#[derive(Debug, Clone)]
428pub struct ToolSignalProcessor {
429 pub recent_window: usize,
432 pub tool_semantics: ToolSemantics,
434}
435
436impl Default for ToolSignalProcessor {
437 fn default() -> Self {
438 Self {
439 recent_window: DEFAULT_RECENT_WINDOW,
440 tool_semantics: ToolSemantics::default(),
441 }
442 }
443}
444
445#[async_trait]
446impl Processor<State> for ToolSignalProcessor {
447 async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
448 if let Event::Request { request: req, .. } = event {
449 let tool_signal = ToolSignals::from_request_with_semantics(
450 req,
451 Some(self.recent_window),
452 &self.tool_semantics,
453 );
454 state.tool_signals = Some(tool_signal);
455 }
456 Ok(())
457 }
458}
459
460fn classify_tool_call(name: &str, command: Option<&str>) -> ToolSemantic {
461 classify_tool_call_with_semantics(name, command, &ToolSemantics::default())
462}
463
464fn classify_tool_call_with_semantics(
465 name: &str,
466 command: Option<&str>,
467 semantics: &ToolSemantics,
468) -> ToolSemantic {
469 let lower = name.to_lowercase();
471 if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
472 return ToolSemantic::Mutate(MutationKind::Write);
473 }
474 if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
475 return ToolSemantic::Mutate(MutationKind::Edit);
476 }
477 if READ_TOOL_NAMES.contains(&lower.as_str()) {
478 return ToolSemantic::Observe;
479 }
480 if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
481 return ToolSemantic::Plan;
482 }
483 if BASH_TOOL_NAMES.contains(&lower.as_str())
484 && let Some(cmd) = command
485 {
486 if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_write(cmd) {
488 return ToolSemantic::Mutate(MutationKind::Write);
489 }
490 if cmd.contains("python") && PYTHON_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
491 return ToolSemantic::Mutate(MutationKind::Write);
492 }
493 if shell_invokes_program(cmd, "node")
494 && JAVASCRIPT_WRITE_PATTERNS
495 .iter()
496 .any(|pattern| cmd.contains(pattern))
497 {
498 return ToolSemantic::Mutate(MutationKind::Write);
499 }
500 if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_edit(cmd) {
501 return ToolSemantic::Mutate(MutationKind::Edit);
502 }
503 if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_read(cmd) {
504 return ToolSemantic::Observe;
505 }
506 }
507 semantics.classify(name).unwrap_or(ToolSemantic::Unknown)
508}
509
510fn is_builtin_tool_name(lower: &str) -> bool {
511 WRITE_TOOL_NAMES.contains(&lower)
512 || EDIT_TOOL_NAMES.contains(&lower)
513 || READ_TOOL_NAMES.contains(&lower)
514 || PLAN_TOOL_NAMES.contains(&lower)
515 || BASH_TOOL_NAMES.contains(&lower)
516}
517
518fn shell_segments(command: &str) -> impl Iterator<Item = &str> {
522 let mut chars = command.char_indices();
523 let mut start = 0usize;
524 let mut quote = None;
525 let mut escaped = false;
526 let mut finished = false;
527
528 std::iter::from_fn(move || {
529 loop {
530 for (index, character) in chars.by_ref() {
531 if escaped {
532 escaped = false;
533 } else if character == '\\' && quote != Some('\'') {
534 escaped = true;
535 } else if quote == Some(character) {
536 quote = None;
537 } else if quote.is_none() && matches!(character, '\'' | '"') {
538 quote = Some(character);
539 } else if quote.is_none() && matches!(character, '\n' | ';' | '|' | '&') {
540 let segment = command[start..index].trim();
541 start = index + character.len_utf8();
542 if !segment.is_empty() {
543 return Some(segment);
544 }
545 }
546 }
547
548 if finished {
549 return None;
550 }
551 finished = true;
552 let segment = command[start..].trim();
553 if !segment.is_empty() {
554 return Some(segment);
555 }
556 }
557 })
558}
559
560fn shell_words(segment: &str) -> std::iter::Peekable<std::str::SplitAsciiWhitespace<'_>> {
561 let mut words = segment.split_ascii_whitespace().peekable();
562
563 if words.peek().copied() == Some("env") {
564 words.next();
565 while words.peek().is_some_and(|word| word.starts_with('-')) {
566 words.next();
567 }
568 }
569 while words
570 .peek()
571 .is_some_and(|word| word.contains('=') && !word.starts_with('='))
572 {
573 words.next();
574 }
575
576 words
577}
578
579fn program_name(word: &str) -> &str {
580 Path::new(word)
581 .file_name()
582 .and_then(|name| name.to_str())
583 .unwrap_or(word)
584}
585
586fn shell_invokes_program(command: &str, expected: &str) -> bool {
587 shell_segments(command).any(|segment| {
588 shell_words(segment)
589 .next()
590 .is_some_and(|word| program_name(word) == expected)
591 })
592}
593
594fn shell_command_is_write(command: &str) -> bool {
595 shell_segments(command).any(|segment| {
596 let mut words = shell_words(segment);
597 let Some(program) = words.next().map(program_name) else {
598 return false;
599 };
600 if matches!(program, "cp" | "mkdir" | "touch" | "install") {
601 return true;
602 }
603
604 let redirects_output = words.any(|word| matches!(word, ">" | ">>"));
605 redirects_output
606 && (matches!(program, "echo" | "printf" | "git")
607 || BASH_READ_COMMANDS.contains(&program))
608 })
609}
610
611fn shell_command_is_edit(command: &str) -> bool {
612 shell_segments(command).any(|segment| {
613 let mut words = shell_words(segment);
614 let Some(program) = words.next().map(program_name) else {
615 return false;
616 };
617 let has_arg = |arg: &str| words.clone().any(|word| word == arg);
618
619 match program {
620 "mv" | "rm" => true,
621 "perl" => words
622 .take_while(|word| word.starts_with('-'))
623 .any(|option| {
624 option
625 .trim_start_matches('-')
626 .chars()
627 .any(|flag| flag == 'i')
628 }),
629 "git" => words
630 .next()
631 .is_some_and(|subcommand| matches!(subcommand, "apply" | "am" | "restore")),
632 "gofmt" => has_arg("-w"),
633 "cargo" => words.clone().next() == Some("fmt") && !has_arg("--check"),
634 "ruff" => {
635 let subcommand = words.clone().next();
636 (subcommand == Some("format") && !has_arg("--check"))
637 || (subcommand == Some("check") && has_arg("--fix"))
638 }
639 "prettier" => has_arg("--write"),
640 "black" => !has_arg("--check"),
641 _ => {
642 (words.clone().any(|word| program_name(word) == "prettier") && has_arg("--write"))
643 || (words.clone().any(|word| program_name(word) == "ruff")
644 && ((has_arg("format") && !has_arg("--check"))
645 || (has_arg("check") && has_arg("--fix"))))
646 }
647 }
648 })
649}
650
651fn shell_command_is_read(command: &str) -> bool {
652 shell_segments(command).any(|segment| {
653 if segment == "env" {
654 return true;
655 }
656 let mut words = shell_words(segment);
657 let Some(program) = words.next().map(program_name) else {
658 return false;
659 };
660
661 if BASH_READ_COMMANDS.contains(&program) {
662 return true;
663 }
664 if program == "command" && words.next() == Some("-v") {
665 return true;
666 }
667 if program == "type" {
668 return true;
669 }
670 if program != "git" {
671 return false;
672 }
673
674 match words.next() {
675 Some("branch") => words.next().is_none_or(|arg| arg.starts_with('-')),
676 Some("remote") => words
677 .next()
678 .is_none_or(|arg| arg.starts_with('-') || arg == "get-url"),
679 Some("config") => words
680 .next()
681 .is_some_and(|arg| matches!(arg, "--get" | "--get-all" | "--list" | "-l")),
682 Some(subcommand) => GIT_READ_SUBCOMMANDS.contains(&subcommand),
683 None => false,
684 }
685 })
686}
687
688fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
695 extract_tool_signals_with_window_and_semantics(
696 request,
697 recent_window,
698 &ToolSemantics::default(),
699 )
700}
701
702fn extract_tool_signals_with_window_and_semantics(
703 request: &Request,
704 recent_window: usize,
705 semantics: &ToolSemantics,
706) -> ToolSignals {
707 let messages = &request.llm_request.messages;
711 let mut tool_texts: Vec<(String, bool)> = Vec::new();
712 let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
713 let mut compacted = false;
714 let mut tool_result_count = 0usize;
715 let mut assistant_turn_count = 0usize;
716
717 for message in messages {
718 if message.role == Role::Assistant {
719 assistant_turn_count += 1;
720 }
721 for block in &message.content {
722 match block {
723 ContentBlock::ToolCall(call) => {
724 tool_calls.push(ObservedToolCall {
725 name: call.name.clone(),
726 command: command_of(&call.arguments),
727 });
728 }
729 ContentBlock::ToolResult(result) => {
730 tool_result_count += 1;
732 let text = result
733 .content
734 .iter()
735 .filter_map(text_of)
736 .collect::<Vec<_>>()
737 .join("\n");
738 let is_error = result.is_error == Some(true);
739 if !text.is_empty() || is_error {
741 tool_texts.push((text, is_error));
742 }
743 }
744 ContentBlock::Text { text } => {
748 compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
749 }
750 _ => {}
751 }
752 }
753 }
754
755 let mut signal = build_signal(
756 tool_texts,
757 tool_calls,
758 messages.len() as u32,
759 recent_window,
760 semantics,
761 );
762 signal.compacted = compacted;
763 signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
764 signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
765 signal
766}
767
768const COMPACTION_MARKER: &str = "session is being continued";
771
772fn command_of(arguments: &Value) -> Option<String> {
775 let decoded = arguments
777 .as_str()
778 .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
779 let object = decoded.as_ref().unwrap_or(arguments);
780
781 ["command", "cmd", "input"]
782 .iter()
783 .filter_map(|key| object.get(*key))
784 .find_map(command_text)
785}
786
787fn command_text(value: &Value) -> Option<String> {
789 match value {
790 Value::String(text) => Some(text.to_lowercase()),
791 Value::Array(parts) => {
792 let joined = parts
793 .iter()
794 .filter_map(Value::as_str)
795 .collect::<Vec<_>>()
796 .join(" ");
797 (!joined.is_empty()).then(|| joined.to_lowercase())
798 }
799 _ => None,
800 }
801}
802
803fn text_of(block: &ContentBlock) -> Option<&str> {
805 match block {
806 ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
807 _ => None,
808 }
809}
810
811fn build_signal(
812 tool_texts: Vec<(String, bool)>,
813 tool_calls: Vec<ObservedToolCall>,
814 turn_depth: u32,
815 recent_window: usize,
816 semantics: &ToolSemantics,
817) -> ToolSignals {
818 let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
824 let mut severity = 0.0f32;
825 let mut failure_fingerprints = Vec::new();
826 let mut repeated_failure = false;
827 for (text, is_error) in &tool_texts[sev_start..] {
828 let (sev, _patterns) = classify_text(text);
829 let sev = if *is_error { sev.max(HARD) } else { sev };
831 if sev > severity {
832 severity = sev;
833 }
834 if let Some(fingerprint) = failure_fingerprint(text, *is_error) {
835 repeated_failure |= failure_fingerprints.contains(&fingerprint);
836 failure_fingerprints.push(fingerprint);
837 }
838 }
839
840 let no_error_streak = compute_no_error_streak(&tool_texts);
841
842 let recent_start = tool_calls.len().saturating_sub(recent_window);
846 let mut write_count = 0u32;
847 let mut edit_count = 0u32;
848 let mut read_count = 0u32;
849 let mut todowrite_count = 0u32;
850 let mut recent_write_count = 0u32;
851 let mut recent_edit_count = 0u32;
852 let mut recent_read_count = 0u32;
853 let mut recent_todowrite_count = 0u32;
854 let mut new_count = 0u32;
855 let mut recent_new_count = 0u32;
856 let mut pure_bash_streak = 0u32;
857 let mut streak_open = true;
858 for (i, tc) in tool_calls.iter().enumerate().rev() {
859 let cat = classify_tool_call_with_semantics(&tc.name, tc.command.as_deref(), semantics);
860 if streak_open {
861 if matches!(cat, ToolSemantic::Unknown) {
862 pure_bash_streak += 1;
863 } else {
864 streak_open = false;
865 }
866 }
867 match cat {
868 ToolSemantic::Mutate(MutationKind::Write) => {
869 write_count += 1;
870 if i >= recent_start {
871 recent_write_count += 1;
872 }
873 }
874 ToolSemantic::Mutate(MutationKind::Edit) => {
875 edit_count += 1;
876 if i >= recent_start {
877 recent_edit_count += 1;
878 }
879 }
880 ToolSemantic::Observe => {
881 read_count += 1;
882 if i >= recent_start {
883 recent_read_count += 1;
884 }
885 }
886 ToolSemantic::Plan => {
887 todowrite_count += 1;
888 if i >= recent_start {
889 recent_todowrite_count += 1;
890 }
891 }
892 ToolSemantic::New => {
893 new_count += 1;
894 if i >= recent_start {
895 recent_new_count += 1;
896 }
897 }
898 ToolSemantic::Unknown => {}
899 }
900 }
901
902 let tests_passed = detect_tests_passed(&tool_texts, recent_window);
903
904 ToolSignals {
905 severity,
906 repeated_failure,
907 no_error_streak,
908 edit_count,
909 write_count,
910 read_count,
911 todowrite_count,
912 recent_edit_count,
913 recent_write_count,
914 recent_read_count,
915 recent_todowrite_count,
916 new_count,
917 recent_new_count,
918 pure_bash_streak,
919 tests_passed,
920 turn_depth,
921 tool_result_count: 0,
925 assistant_turn_count: 0,
926 compacted: false,
927 }
928}
929
930fn content_to_text(content: Option<&Value>) -> Option<String> {
934 match content? {
935 Value::String(s) => Some(s.clone()),
936 Value::Array(blocks) => {
937 let parts: Vec<&str> = blocks
938 .iter()
939 .filter_map(|b| {
940 b.as_object()
941 .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
942 .and_then(|o| o.get("text"))
943 .and_then(Value::as_str)
944 })
945 .collect();
946 if parts.is_empty() {
947 None
948 } else {
949 Some(parts.join("\n"))
950 }
951 }
952 _ => None,
953 }
954}
955
956pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
960 let lower = text.to_lowercase();
961 let mut patterns = Vec::new();
962 let mut severity: f32 = 0.0;
963 for (name, sev, substrings) in ERROR_PATTERNS {
964 if substrings.iter().any(|sub| lower.contains(sub)) {
965 patterns.push(name.to_string());
966 severity = severity.max(*sev);
967 }
968 }
969 if has_nonzero_exit_status(&lower) && !patterns.iter().any(|p| p == "exit_nonzero") {
970 patterns.push("exit_nonzero".to_string());
971 severity = severity.max(SOFT);
972 }
973 for (name, matched) in [
974 ("compile_error", has_compiler_diagnostic(&lower)),
975 ("runtime_exception", has_runtime_exception(&lower)),
976 ("runtime_panic", has_runtime_panic(&lower)),
977 ("patch_error", has_patch_failure(&lower)),
978 ] {
979 if matched && !patterns.iter().any(|pattern| pattern == name) {
980 patterns.push(name.to_string());
981 severity = severity.max(HARD);
982 }
983 }
984 (severity, patterns)
985}
986
987fn failure_fingerprint(text: &str, is_error: bool) -> Option<String> {
990 let (severity, patterns) = classify_text(text);
991 if severity < HARD && !is_error {
992 return None;
993 }
994
995 let lower = text.to_lowercase();
996 let diagnostic = lower
997 .lines()
998 .find(|line| is_failure_diagnostic(line))
999 .or_else(|| lower.lines().find(|line| !line.trim().is_empty()))
1000 .unwrap_or_default();
1001 let normalized = normalize_failure_text(diagnostic);
1002 Some(format!("{}|{normalized}", patterns.join(",")))
1003}
1004
1005fn is_failure_diagnostic(line: &str) -> bool {
1006 let line = line.trim();
1007 [
1008 "error",
1009 "exception",
1010 "panic",
1011 "failed",
1012 "timed out",
1013 "timeout",
1014 "connection refused",
1015 "cannot allocate memory",
1016 "out of memory",
1017 "not found",
1018 ]
1019 .iter()
1020 .any(|marker| line.contains(marker))
1021}
1022
1023fn normalize_failure_text(text: &str) -> String {
1026 let mut normalized = String::new();
1027 for word in text.split_whitespace() {
1028 if !normalized.is_empty() {
1029 normalized.push(' ');
1030 }
1031 let mut in_digits = false;
1032 if word.starts_with('/') || word.contains("/src/") || word.contains("/tmp/") {
1033 normalized.push_str("<path>");
1034 continue;
1035 }
1036 for character in word.chars() {
1037 if character.is_ascii_digit() {
1038 if !in_digits {
1039 normalized.push('#');
1040 in_digits = true;
1041 }
1042 } else {
1043 normalized.push(character);
1044 in_digits = false;
1045 }
1046 }
1047 }
1048 normalized.chars().take(240).collect()
1049}
1050
1051fn has_compiler_diagnostic(lower: &str) -> bool {
1052 lower.lines().any(|line| {
1053 let line = line.trim_start();
1054 if matches!(
1055 line,
1056 "compilation failed" | "error: compilation failed" | "error: could not compile"
1057 ) || line.starts_with("error: could not compile ")
1058 {
1059 return true;
1060 }
1061
1062 let Some(rest) = line.strip_prefix("error[e") else {
1063 return false;
1064 };
1065 let Some((code, _)) = rest.split_once("]:") else {
1066 return false;
1067 };
1068 !code.is_empty() && code.chars().all(|character| character.is_ascii_digit())
1069 })
1070}
1071
1072fn has_runtime_exception(lower: &str) -> bool {
1073 let has_exception_line = lower.lines().any(|line| {
1074 let line = line.trim_start();
1075 [
1076 "typeerror:",
1077 "referenceerror:",
1078 "rangeerror:",
1079 "runtimeerror:",
1080 "keyerror:",
1081 "attributeerror:",
1082 ]
1083 .iter()
1084 .any(|prefix| line.starts_with(prefix))
1085 });
1086 has_exception_line && (lower.contains("\n at ") || lower.contains("\n at "))
1087}
1088
1089fn has_runtime_panic(lower: &str) -> bool {
1090 lower
1091 .lines()
1092 .any(|line| line.trim_start().starts_with("panic: runtime error:"))
1093 && (lower.contains("\ngoroutine ") || lower.contains("[signal sig"))
1094}
1095
1096fn has_patch_failure(lower: &str) -> bool {
1097 lower.lines().any(|line| {
1098 let line = line.trim_start();
1099 line.starts_with("error: patch failed:")
1100 || line.starts_with("patch failed:")
1101 || line.contains(": patch does not apply")
1102 || line.starts_with("invalid context")
1103 })
1104}
1105
1106fn has_nonzero_exit_status(lower: &str) -> bool {
1112 NONZERO_EXIT_PHRASES
1113 .iter()
1114 .any(|phrase| phrase_followed_by_nonzero_integer(lower, phrase))
1115}
1116
1117fn phrase_followed_by_nonzero_integer(lower: &str, phrase: &str) -> bool {
1119 let mut cursor = 0usize;
1120 while let Some(rel) = lower[cursor..].find(phrase) {
1121 let value_start = cursor + rel + phrase.len();
1122 let rest = lower[value_start..].trim_start_matches(|c: char| {
1123 c.is_ascii_whitespace() || matches!(c, ':' | '=' | '\'' | '"' | '`')
1124 });
1125 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1126 if !digits.is_empty() && digits.chars().any(|d| d != '0') {
1127 return true;
1128 }
1129 cursor = value_start;
1130 }
1131 false
1132}
1133
1134fn compute_no_error_streak(tool_texts: &[(String, bool)]) -> u32 {
1135 let mut streak = 0u32;
1136 for (text, is_error) in tool_texts.iter().rev() {
1137 let (sev, _) = classify_text(text);
1138 if *is_error || sev > 0.0 {
1139 break;
1140 }
1141 streak += 1;
1142 }
1143 streak
1144}
1145
1146fn detect_tests_passed(tool_texts: &[(String, bool)], recent_window: usize) -> bool {
1147 let start = tool_texts.len().saturating_sub(recent_window.max(1));
1148 let recent = &tool_texts[start..];
1149 let after_latest_failure = recent
1150 .iter()
1151 .rposition(|(text, is_error)| *is_error || classify_text(text).0 > 0.0)
1152 .map_or(recent, |index| &recent[index + 1..]);
1153 after_latest_failure.iter().any(|(text, _)| {
1154 let lower = text.to_lowercase();
1155 TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
1156 && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
1157 && !has_nonzero_failure_count(&lower)
1158 })
1159}
1160
1161fn has_nonzero_failure_count(lower: &str) -> bool {
1167 for kw in NUMERIC_FAILURE_KEYWORDS {
1168 let mut cursor = 0usize;
1169 while let Some(rel) = lower[cursor..].find(kw) {
1170 let kw_start = cursor + rel;
1171 let kw_end = kw_start + kw.len();
1172 let boundary_after = lower[kw_end..]
1175 .chars()
1176 .next()
1177 .is_none_or(|c| !c.is_ascii_alphanumeric());
1178 if boundary_after {
1179 let prefix = &lower[..kw_start];
1180 let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
1181 let digits_rev: String = trimmed
1182 .chars()
1183 .rev()
1184 .take_while(|c| c.is_ascii_digit())
1185 .collect();
1186 if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
1187 return true;
1188 }
1189 }
1190 cursor = kw_start + kw.len();
1191 }
1192 }
1193 false
1194}
1195
1196#[cfg(test)]
1199mod tests {
1200 use super::*;
1201 use crate::algorithms::util::stage::score_signal;
1202 use serde_json::json;
1203 use switchyard_protocol::{
1204 ContentBlock, LlmRequest, Message, Metadata, Role, ToolCall, ToolResult,
1205 };
1206
1207 fn with_messages(messages: Vec<Message>) -> Request {
1208 Request {
1209 llm_request: LlmRequest {
1210 messages,
1211 ..LlmRequest::default()
1212 },
1213 raw_request: None,
1214 metadata: None,
1215 }
1216 }
1217
1218 fn tc(name: &str) -> Message {
1220 Message {
1221 role: Role::Assistant,
1222 content: vec![ContentBlock::ToolCall(ToolCall {
1223 id: String::new(),
1224 name: name.to_string(),
1225 arguments: json!({}),
1226 })],
1227 }
1228 }
1229
1230 fn bash(command: &str) -> Message {
1232 Message {
1233 role: Role::Assistant,
1234 content: vec![ContentBlock::ToolCall(ToolCall {
1235 id: String::new(),
1236 name: "Bash".to_string(),
1237 arguments: json!({"command": command}),
1238 })],
1239 }
1240 }
1241
1242 fn tr(text: &str) -> Message {
1244 Message {
1245 role: Role::User,
1246 content: vec![ContentBlock::ToolResult(ToolResult {
1247 tool_call_id: String::new(),
1248 content: vec![ContentBlock::Text {
1249 text: text.to_string(),
1250 }],
1251 is_error: None,
1252 })],
1253 }
1254 }
1255
1256 #[test]
1257 fn clean_text_has_zero_severity() {
1258 let (sev, patterns) = classify_text("everything went fine");
1259 assert_eq!(sev, 0.0);
1260 assert!(patterns.is_empty());
1261 }
1262
1263 #[test]
1264 fn traceback_is_hard() {
1265 let (sev, patterns) = classify_text("Traceback (most recent call last):\n ValueError");
1266 assert_eq!(sev, HARD);
1267 assert!(patterns.contains(&"traceback".to_string()));
1268 }
1269
1270 #[test]
1271 fn oom_is_critical() {
1272 let (sev, _) = classify_text("Out of memory: kill process 1234");
1273 assert_eq!(sev, CRITICAL);
1274 }
1275
1276 #[test]
1277 fn connection_refused_is_hard() {
1278 let (severity, _) = classify_text("Connection refused on port 8000");
1279 assert_eq!(severity, HARD);
1280 }
1281
1282 #[test]
1283 fn repeated_failure_ignores_volatile_paths_and_numbers() {
1284 let request = with_messages(vec![
1285 tr("error[E0308]: mismatched types at /tmp/a/src/lib.rs:12"),
1286 tr("error[E0308]: mismatched types at /tmp/b/src/lib.rs:47"),
1287 ]);
1288 assert!(ToolSignals::from_request(&request, None).repeated_failure);
1289 }
1290
1291 #[test]
1292 fn different_failures_are_not_repeated() {
1293 let request = with_messages(vec![
1294 tr("error[E0308]: mismatched types"),
1295 tr("error[E0509]: cannot move out"),
1296 ]);
1297 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1298 }
1299
1300 #[test]
1301 fn one_material_failure_is_not_repeated() {
1302 let request = with_messages(vec![tr("Connection refused on port 8000")]);
1303 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1304 }
1305
1306 #[test]
1308 fn structured_tool_failures_feed_error_and_recovery_signals() {
1309 for text in ["Dependency unavailable", "", "5 passed in 0.12s"] {
1310 let mut failed = tr(text);
1311 let ContentBlock::ToolResult(result) = &mut failed.content[0] else {
1312 panic!("expected tool result");
1313 };
1314 result.is_error = Some(true);
1315 let mut request = with_messages(vec![tr("5 passed in 0.12s"), failed.clone()]);
1316 let signals = ToolSignals::from_request(&request, Some(3));
1317 assert_eq!(signals.severity, HARD);
1318 assert!(!signals.repeated_failure);
1319 assert_eq!(signals.no_error_streak, 0);
1320 assert!(!signals.tests_passed);
1321
1322 request.llm_request.messages.push(failed);
1323 assert!(ToolSignals::from_request(&request, Some(3)).repeated_failure);
1324 request.llm_request.messages.push(tr("5 passed in 0.12s"));
1325 let recovered = ToolSignals::from_request(&request, Some(1));
1326 assert_eq!(recovered.severity, 0.0);
1327 assert!(!recovered.repeated_failure);
1328 assert_eq!(recovered.no_error_streak, 1);
1329 assert!(recovered.tests_passed);
1330 }
1331 }
1332
1333 #[test]
1334 fn severity_is_max_across_patterns() {
1335 let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
1337 assert_eq!(sev, HARD);
1338 }
1339
1340 #[test]
1341 fn codex_process_exit_zero_stays_clean() {
1342 let (sev, patterns) =
1343 classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
1344 assert_eq!(sev, 0.0);
1345 assert!(!patterns.contains(&"exit_nonzero".to_string()));
1346 }
1347
1348 #[test]
1349 fn nonzero_exit_codes_are_soft_errors() {
1350 let cases = [
1351 "Process exited with code 1",
1352 "Process exited with code 127",
1353 "exit code: 2",
1354 "exit status 3",
1355 "exited with status 9",
1356 ];
1357 for case in cases {
1358 let (sev, patterns) = classify_text(case);
1359 assert_eq!(sev, SOFT, "expected soft severity for {case}");
1360 assert!(patterns.contains(&"exit_nonzero".to_string()));
1361 }
1362 }
1363
1364 #[test]
1365 fn partial_process_failures_are_hard_errors() {
1366 let cases = [
1367 (
1368 "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
1369 "compile_error",
1370 ),
1371 (
1372 "Process exited with code 0\nOutput:\nTypeError: value is undefined\n at main.js:1:2",
1373 "runtime_exception",
1374 ),
1375 (
1376 "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
1377 "runtime_panic",
1378 ),
1379 (
1380 "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
1381 "patch_error",
1382 ),
1383 ];
1384 for (text, expected_pattern) in cases {
1385 let (severity, patterns) = classify_text(text);
1386 assert_eq!(severity, HARD, "expected hard severity for {text}");
1387 assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
1388 }
1389 }
1390
1391 #[test]
1392 fn source_text_that_names_exceptions_stays_clean() {
1393 let text =
1394 "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
1395 assert_eq!(classify_text(text).0, 0.0);
1396 }
1397
1398 #[test]
1399 fn file_does_not_exist_is_hard() {
1400 let (sev, patterns) =
1402 classify_text("Error: File does not exist. Note: current working directory is /app.");
1403 assert_eq!(sev, HARD);
1404 assert!(patterns.contains(&"no_such_file".to_string()));
1405 }
1406
1407 #[test]
1408 fn bare_does_not_exist_stays_clean() {
1409 let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
1412 assert_eq!(sev, 0.0);
1413 }
1414
1415 #[test]
1416 fn no_error_streak_all_clean() {
1417 let texts = vec![("ok".to_string(), false), ("all good".to_string(), false)];
1418 assert_eq!(compute_no_error_streak(&texts), 2);
1419 }
1420
1421 #[test]
1422 fn no_error_streak_stops_at_error() {
1423 let texts = vec![
1424 ("Traceback (most recent call last):".to_string(), false),
1425 ("ok".to_string(), false),
1426 ("ok".to_string(), false),
1427 ];
1428 assert_eq!(compute_no_error_streak(&texts), 2);
1429 }
1430
1431 #[test]
1432 fn tests_passed_detects_pytest_output() {
1433 assert!(detect_tests_passed(
1434 &[("====== 5 passed in 0.12s ======".to_string(), false)],
1435 DEFAULT_RECENT_WINDOW
1436 ));
1437 }
1438
1439 #[test]
1440 fn tests_passed_ignores_partial_failures() {
1441 assert!(!detect_tests_passed(
1442 &[("2 failed, 5 passed in 0.56s".to_string(), false)],
1443 DEFAULT_RECENT_WINDOW
1444 ));
1445 }
1446
1447 #[test]
1448 fn tests_passed_must_follow_the_latest_failure() {
1449 assert!(!detect_tests_passed(
1450 &[
1451 ("5 passed in 0.12s".to_string(), false),
1452 (
1453 "Traceback (most recent call last):\nValueError".to_string(),
1454 false
1455 ),
1456 ("edit applied".to_string(), false),
1457 ],
1458 DEFAULT_RECENT_WINDOW
1459 ));
1460 assert!(detect_tests_passed(
1461 &[
1462 (
1463 "Traceback (most recent call last):\nValueError".to_string(),
1464 false
1465 ),
1466 ("5 passed in 0.12s".to_string(), false),
1467 ],
1468 DEFAULT_RECENT_WINDOW
1469 ));
1470 }
1471
1472 #[test]
1473 fn severity_is_windowed_over_recent_results() {
1474 let request = with_messages(vec![
1476 tr("Traceback (most recent call last):\n ValueError"),
1477 tr("ok"),
1478 tr("ok"),
1479 ]);
1480 assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
1482 assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
1484 }
1485
1486 #[test]
1487 fn extract_openai_chat_tool_results() {
1488 let request = with_messages(vec![
1489 Message::text(Role::User, "do something"),
1490 tc("Edit"),
1491 tr("Traceback (most recent call last):\n ValueError"),
1492 ]);
1493 let sig = ToolSignals::from_request(&request, None);
1494 assert_eq!(sig.severity, HARD);
1495 assert_eq!(sig.edit_count, 1);
1496 assert_eq!(sig.turn_depth, 3);
1497 }
1498
1499 #[test]
1500 fn extract_anthropic_tool_results() {
1501 let request = with_messages(vec![tr("Traceback (most recent call last):\n ValueError")]);
1502 let sig = ToolSignals::from_request(&request, None);
1503 assert_eq!(sig.severity, HARD);
1504 }
1505
1506 #[test]
1507 fn extract_responses_api_tool_results() {
1508 let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
1509 let sig = ToolSignals::from_request(&request, None);
1510 assert_eq!(sig.severity, 0.0);
1511 assert_eq!(sig.write_count, 1);
1512 }
1513
1514 #[test]
1515 fn conversation_counts_are_per_block_and_role_aware() {
1516 let result = |content: Vec<ContentBlock>| {
1520 ContentBlock::ToolResult(ToolResult {
1521 tool_call_id: String::new(),
1522 content,
1523 is_error: None,
1524 })
1525 };
1526 let request = with_messages(vec![
1527 Message::text(Role::User, "do something"),
1528 Message::text(Role::Assistant, "working"),
1529 Message {
1530 role: Role::User,
1531 content: vec![
1532 result(vec![ContentBlock::Text {
1533 text: "ok".to_string(),
1534 }]),
1535 result(Vec::new()),
1536 ],
1537 },
1538 tc("Bash"),
1539 ]);
1540 let sig = ToolSignals::from_request(&request, None);
1541 assert_eq!(sig.tool_result_count, 2);
1542 assert_eq!(sig.assistant_turn_count, 2);
1543 assert_eq!(sig.turn_depth, 4);
1544 }
1545
1546 #[test]
1547 fn recent_window_counts_only_last_default_window_tool_calls() {
1548 let request = with_messages(vec![
1551 tc("Write"),
1552 tr("ok"),
1553 tc("Write"),
1554 tr("ok"),
1555 tc("Write"),
1556 tr("ok"),
1557 tc("Write"),
1558 tr("ok"),
1559 tc("Write"),
1560 tr("ok"),
1561 tc("Edit"),
1562 tr("ok"),
1563 ]);
1564 let sig = ToolSignals::from_request(&request, None);
1565 assert_eq!(sig.write_count, 5);
1566 assert_eq!(sig.edit_count, 1);
1567 assert_eq!(sig.recent_write_count, 2);
1568 assert_eq!(sig.recent_edit_count, 1);
1569 }
1570
1571 #[test]
1572 fn codex_apply_patch_counts_as_an_edit() {
1573 let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
1574 let sig = ToolSignals::from_request(&request, None);
1575 assert_eq!(sig.edit_count, 1);
1576 assert_eq!(sig.recent_edit_count, 1);
1577 }
1578
1579 fn exec_command(cmd: Value) -> Message {
1580 Message {
1581 role: Role::Assistant,
1582 content: vec![ContentBlock::ToolCall(ToolCall {
1583 id: String::new(),
1584 name: "exec_command".to_string(),
1585 arguments: cmd,
1586 })],
1587 }
1588 }
1589
1590 #[test]
1591 fn codex_exec_command_is_classified() {
1592 let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
1594 let request = with_messages(vec![exec_command(args), tr("ok")]);
1595 assert_eq!(
1596 ToolSignals::from_request(&request, None).recent_edit_count,
1597 1
1598 );
1599 }
1600
1601 #[test]
1602 fn python_write_expressions_need_a_python_command() {
1603 let write = with_messages(vec![
1604 exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
1605 tr("ok"),
1606 ]);
1607 assert_eq!(
1608 ToolSignals::from_request(&write, None).recent_write_count,
1609 1
1610 );
1611
1612 let search = with_messages(vec![
1613 exec_command(json!({"cmd": "grep -R '.write(' src"})),
1614 tr("ok"),
1615 ]);
1616 assert_eq!(
1617 ToolSignals::from_request(&search, None).recent_write_count,
1618 0
1619 );
1620 }
1621
1622 #[test]
1623 fn recent_window_size_is_caller_overridable() {
1624 let request = with_messages(vec![
1628 tc("Write"),
1629 tr("ok"),
1630 tc("Write"),
1631 tr("ok"),
1632 tc("Write"),
1633 tr("ok"),
1634 tc("Write"),
1635 tr("ok"),
1636 tc("Write"),
1637 tr("ok"),
1638 tc("Edit"),
1639 tr("ok"),
1640 ]);
1641 let narrow = extract_tool_signals_with_window(&request, 3);
1642 assert_eq!(narrow.recent_write_count, 2);
1643 assert_eq!(narrow.recent_edit_count, 1);
1644
1645 let wide = extract_tool_signals_with_window(&request, 6);
1646 assert_eq!(wide.recent_write_count, 5);
1647 assert_eq!(wide.recent_edit_count, 1);
1648 }
1649
1650 #[test]
1651 fn compaction_marker_sets_compacted() {
1652 let request = with_messages(vec![
1654 Message::text(
1655 Role::User,
1656 "This session is being continued from a previous conversation that ran out of context.",
1657 ),
1658 bash("ls"),
1659 ]);
1660 assert!(ToolSignals::from_request(&request, None).compacted);
1661 }
1662
1663 #[test]
1664 fn codex_compaction_metadata_stays_on_parent_route() {
1665 let mut request = with_messages(vec![bash("ls")]);
1666 request.metadata = Some(Metadata {
1667 is_subagent: true,
1668 agent_kind: Some("compact".to_string()),
1669 ..Default::default()
1670 });
1671 assert!(!ToolSignals::from_request(&request, None).compacted);
1672 }
1673
1674 #[test]
1675 fn no_compaction_marker_stays_uncompacted() {
1676 let request = with_messages(vec![
1677 Message::text(Role::User, "Write a script that parses the log file."),
1678 bash("ls"),
1679 ]);
1680 assert!(!ToolSignals::from_request(&request, None).compacted);
1681 }
1682
1683 #[test]
1684 fn bash_heredoc_counts_as_write() {
1685 let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
1687 let sig = ToolSignals::from_request(&request, None);
1688 assert_eq!(
1689 sig.write_count, 1,
1690 "Bash heredoc should bucket into write_count"
1691 );
1692 assert_eq!(sig.edit_count, 0);
1693 }
1694
1695 #[test]
1696 fn bash_sed_inplace_counts_as_edit() {
1697 let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
1698 let sig = ToolSignals::from_request(&request, None);
1699 assert_eq!(
1700 sig.edit_count, 1,
1701 "Bash sed -i should bucket into edit_count"
1702 );
1703 assert_eq!(sig.write_count, 0);
1704 }
1705
1706 #[test]
1707 fn bash_non_mutating_does_not_count() {
1708 let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
1710 let sig = ToolSignals::from_request(&request, None);
1711 assert_eq!(sig.write_count, 0);
1712 assert_eq!(sig.edit_count, 0);
1713 }
1714
1715 #[test]
1716 fn tests_passed_detects_pytest_with_failure_block() {
1717 assert!(!detect_tests_passed(
1719 &[("2 failed, 5 passed in 0.56s".to_string(), false)],
1720 DEFAULT_RECENT_WINDOW
1721 ));
1722 }
1723
1724 #[test]
1725 fn tests_passed_accepts_cargo_clean_summary() {
1726 assert!(detect_tests_passed(
1729 &[(
1730 "running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string(),
1731 false
1732 )],
1733 DEFAULT_RECENT_WINDOW
1734 ));
1735 }
1736
1737 #[test]
1738 fn tests_passed_rejects_cargo_real_failure() {
1739 assert!(!detect_tests_passed(
1741 &[(
1742 "running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string(),
1743 false
1744 )],
1745 DEFAULT_RECENT_WINDOW
1746 ));
1747 }
1748
1749 #[test]
1750 fn tests_passed_accepts_go_clean_summary() {
1751 assert!(detect_tests_passed(
1753 &[(
1754 "ok github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string(),
1755 false
1756 )],
1757 DEFAULT_RECENT_WINDOW
1758 ));
1759 }
1760
1761 #[test]
1762 fn tests_passed_accepts_pytest_zero_errors() {
1763 assert!(detect_tests_passed(
1765 &[("5 passed, 0 errors in 0.30s".to_string(), false)],
1766 DEFAULT_RECENT_WINDOW
1767 ));
1768 }
1769
1770 #[test]
1771 fn tests_passed_detects_diy_checkmark() {
1772 assert!(detect_tests_passed(
1773 &[("✓ all checks passed".to_string(), false)],
1774 DEFAULT_RECENT_WINDOW
1775 ));
1776 }
1777
1778 #[test]
1779 fn anthropic_bash_heredoc_extracts_command() {
1780 let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
1782 let sig = ToolSignals::from_request(&request, None);
1783 assert_eq!(
1784 sig.write_count, 1,
1785 "Anthropic Bash heredoc must also be detected"
1786 );
1787 }
1788
1789 #[test]
1790 fn recent_window_falls_back_to_full_history_when_short() {
1791 let request = with_messages(vec![tc("Write")]);
1792 let sig = ToolSignals::from_request(&request, None);
1793 assert_eq!(sig.recent_write_count, 1);
1794 assert_eq!(sig.recent_edit_count, 0);
1795 }
1796
1797 #[test]
1798 fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
1799 let request = with_messages(vec![tr("output ok"), tr("another ok")]);
1800 let sig = ToolSignals::from_request(&request, None);
1801 assert_eq!(sig.severity, 0.0);
1802 assert_eq!(sig.no_error_streak, 2);
1803 }
1804
1805 #[test]
1808 fn todowrite_classifies_as_plan() {
1809 assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
1810 assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
1811 }
1812
1813 #[test]
1814 fn codex_update_plan_classifies_as_plan() {
1815 assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
1816 }
1817
1818 #[test]
1819 fn codex_shell_command_runs_bash_pattern_match() {
1820 assert_eq!(
1822 classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
1823 ToolSemantic::Mutate(MutationKind::Write),
1824 );
1825 assert_eq!(
1827 classify_tool_call("shell_command", Some("ls /app")),
1828 ToolSemantic::Observe,
1829 );
1830 assert_eq!(
1832 classify_tool_call("shell_command", Some("./run_tests.sh")),
1833 ToolSemantic::Unknown,
1834 );
1835 }
1836
1837 #[test]
1838 fn read_tool_classifies_as_read() {
1839 assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
1840 assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
1841 }
1842
1843 #[test]
1844 fn hermes_tool_names_classify() {
1845 assert_eq!(
1847 classify_tool_call("write_file", None),
1848 ToolSemantic::Mutate(MutationKind::Write)
1849 );
1850 assert_eq!(
1851 classify_tool_call("patch", None),
1852 ToolSemantic::Mutate(MutationKind::Edit)
1853 );
1854 assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
1855 assert_eq!(
1856 classify_tool_call("search_files", None),
1857 ToolSemantic::Observe
1858 );
1859 assert_eq!(
1862 classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
1863 ToolSemantic::Mutate(MutationKind::Edit),
1864 );
1865 assert_eq!(
1866 classify_tool_call("terminal", Some("grep foo /app")),
1867 ToolSemantic::Observe,
1868 );
1869 assert_eq!(
1870 classify_tool_call("terminal", Some("./run_tests.sh")),
1871 ToolSemantic::Unknown,
1872 );
1873 }
1874
1875 #[test]
1876 fn bash_read_patterns_classify_as_read() {
1877 let cases = [
1878 "cat /etc/passwd",
1879 "grep foo bar.txt",
1880 "ls /app",
1881 "find . -name '*.py'",
1882 ];
1883 for cmd in cases {
1884 assert_eq!(
1885 classify_tool_call("Bash", Some(cmd)),
1886 ToolSemantic::Observe,
1887 "expected Read for {cmd}"
1888 );
1889 }
1890 }
1891
1892 #[test]
1893 fn codex_inspection_commands_classify_as_read() {
1894 let cases = [
1895 "sed -n '1,80p' src/lib.rs",
1896 "rg -n 'needle' src",
1897 "nl -ba src/lib.rs",
1898 "cat package.json",
1899 "jq '.scripts' package.json",
1900 "git status --short",
1901 "git log --oneline -5",
1902 "git show HEAD:src/lib.rs",
1903 "git branch --show-current",
1904 "git remote -v",
1905 "git config --get remote.origin.url",
1906 ];
1907 for command in cases {
1908 assert_eq!(
1909 classify_tool_call("exec_command", Some(command)),
1910 ToolSemantic::Observe,
1911 "expected Read for {command}"
1912 );
1913 }
1914 }
1915
1916 #[test]
1917 fn quoted_shell_separators_do_not_create_commands() {
1918 for command in ["rg 'foo|rm obsolete.rs'", "rg \"foo; rm obsolete.rs\""] {
1919 assert_eq!(
1920 classify_tool_call("exec_command", Some(command)),
1921 ToolSemantic::Observe,
1922 "quoted text must not be parsed as a command: {command}"
1923 );
1924 }
1925 }
1926
1927 #[test]
1928 fn codex_shell_mutations_classify_as_production() {
1929 let writes = [
1930 "cp source.rs destination.rs",
1931 "mkdir -p src/generated",
1932 "touch src/generated/mod.rs",
1933 "git show HEAD:file.rs > file.rs",
1934 "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
1935 ];
1936 for command in writes {
1937 assert_eq!(
1938 classify_tool_call("exec_command", Some(command)),
1939 ToolSemantic::Mutate(MutationKind::Write),
1940 "expected Write for {command}"
1941 );
1942 }
1943
1944 let edits = [
1945 "mv old.rs new.rs",
1946 "rm obsolete.rs",
1947 "gofmt -w main.go",
1948 "cargo fmt",
1949 "ruff check --fix src",
1950 "perl -0pi -e 's/old/new/' src/lib.rs",
1951 "npx prettier --write src/lib.ts",
1952 "uv run ruff format src",
1953 "git apply fix.patch",
1954 ];
1955 for command in edits {
1956 assert_eq!(
1957 classify_tool_call("exec_command", Some(command)),
1958 ToolSemantic::Mutate(MutationKind::Edit),
1959 "expected Edit for {command}"
1960 );
1961 }
1962 }
1963
1964 #[test]
1965 fn formatter_checks_are_not_edits() {
1966 for command in [
1967 "cargo fmt --check",
1968 "ruff format --check src",
1969 "black --check src",
1970 ] {
1971 assert_ne!(
1972 classify_tool_call("exec_command", Some(command)),
1973 ToolSemantic::Mutate(MutationKind::Edit),
1974 "read-only formatter check must not be Edit: {command}"
1975 );
1976 }
1977 }
1978
1979 #[test]
1980 fn embedded_comparison_is_not_a_shell_write() {
1981 let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
1982 assert_eq!(
1983 classify_tool_call("exec_command", Some(command)),
1984 ToolSemantic::Unknown
1985 );
1986 }
1987
1988 #[test]
1989 fn bash_write_precedence_over_read() {
1990 assert_eq!(
1993 classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
1994 ToolSemantic::Mutate(MutationKind::Write),
1995 );
1996 }
1997
1998 #[test]
1999 fn pure_bash_streak_counts_trailing_other() {
2000 let request = with_messages(vec![
2002 bash("make"),
2003 tr("ok"),
2004 bash("./configure"),
2005 tr("ok"),
2006 bash("make install"),
2007 tr("ok"),
2008 bash("./run.sh"),
2009 tr("ok"),
2010 bash("./test"),
2011 tr("ok"),
2012 ]);
2013 let sig = ToolSignals::from_request(&request, None);
2014 assert_eq!(sig.pure_bash_streak, 5);
2015 assert_eq!(sig.write_count, 0);
2016 assert_eq!(sig.read_count, 0);
2017 }
2018
2019 #[test]
2020 fn pure_bash_streak_resets_on_write() {
2021 let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
2022 let sig = ToolSignals::from_request(&request, None);
2023 assert_eq!(sig.pure_bash_streak, 0);
2024 assert_eq!(sig.write_count, 1);
2025 }
2026
2027 #[test]
2028 fn recent_window_tracks_todowrite_and_read() {
2029 let request = with_messages(vec![
2031 bash("make"),
2032 tr("ok"),
2033 tc("TodoWrite"),
2034 tr("ok"),
2035 tc("Read"),
2036 tr("ok"),
2037 tc("TodoWrite"),
2038 tr("ok"),
2039 ]);
2040 let sig = ToolSignals::from_request(&request, None);
2041 assert_eq!(sig.todowrite_count, 2);
2042 assert_eq!(sig.recent_todowrite_count, 2);
2043 assert_eq!(sig.read_count, 1);
2044 assert_eq!(sig.recent_read_count, 1);
2045 }
2046
2047 #[test]
2048 fn configured_tool_semantics_extend_the_builtin_vocabulary() {
2049 let semantics = ToolSemantics {
2050 observe: vec!["KB_search".to_string()],
2051 mutate: vec!["send_payment_request".to_string()],
2052 plan: vec!["create_research_plan".to_string()],
2053 new: vec!["send_message_to_user".to_string()],
2054 };
2055 semantics.validate().expect("valid additive semantics");
2056 let request = with_messages(vec![
2057 tc("Read"),
2058 tc("Write"),
2059 tc("TodoWrite"),
2060 tc("kb_SEARCH"),
2061 tc("send_payment_request"),
2062 tc("create_research_plan"),
2063 tc("send_message_to_user"),
2064 tc("unlisted_tool"),
2065 ]);
2066
2067 let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
2068
2069 assert_eq!(signal.read_count, 2);
2070 assert_eq!(signal.write_count, 2);
2071 assert_eq!(signal.todowrite_count, 2);
2072 assert_eq!(signal.new_count, 1);
2073 assert_eq!(signal.recent_new_count, 1);
2074 assert_eq!(signal.pure_bash_streak, 1);
2075 }
2076
2077 #[test]
2078 fn configured_tool_semantics_only_fold_ascii_case() {
2079 let semantics = ToolSemantics {
2080 observe: vec!["kb_search".to_string()],
2081 ..Default::default()
2082 };
2083
2084 assert_eq!(
2085 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2086 ToolSemantic::Observe
2087 );
2088 assert_eq!(
2091 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2092 ToolSemantic::Unknown
2093 );
2094 }
2095
2096 #[test]
2097 fn custom_semantics_preserve_builtin_unicode_lowercasing() {
2098 let semantics = ToolSemantics {
2099 observe: vec!["lookup_customer".to_string()],
2100 ..Default::default()
2101 };
2102
2103 assert_eq!(
2105 classify_tool_call_with_semantics("notebooKedit", None, &semantics),
2106 ToolSemantic::Mutate(MutationKind::Edit)
2107 );
2108 }
2109
2110 #[test]
2111 fn configured_semantics_never_replace_builtin_classifications() {
2112 let semantics = ToolSemantics {
2113 observe: vec!["lookup_customer".to_string()],
2114 mutate: vec!["send_payment".to_string()],
2115 plan: vec!["create_workflow".to_string()],
2116 new: vec!["send_message".to_string()],
2117 };
2118
2119 for name in WRITE_TOOL_NAMES {
2120 assert_eq!(
2121 classify_tool_call_with_semantics(name, None, &semantics),
2122 ToolSemantic::Mutate(MutationKind::Write),
2123 "write tool {name:?} changed classification"
2124 );
2125 }
2126 for name in EDIT_TOOL_NAMES {
2127 assert_eq!(
2128 classify_tool_call_with_semantics(name, None, &semantics),
2129 ToolSemantic::Mutate(MutationKind::Edit),
2130 "edit tool {name:?} changed classification"
2131 );
2132 }
2133 for name in READ_TOOL_NAMES {
2134 assert_eq!(
2135 classify_tool_call_with_semantics(name, None, &semantics),
2136 ToolSemantic::Observe,
2137 "read tool {name:?} changed classification"
2138 );
2139 }
2140 for name in PLAN_TOOL_NAMES {
2141 assert_eq!(
2142 classify_tool_call_with_semantics(name, None, &semantics),
2143 ToolSemantic::Plan,
2144 "plan tool {name:?} changed classification"
2145 );
2146 }
2147
2148 for (command, expected) in [
2149 ("cat /tmp/input", ToolSemantic::Observe),
2150 (
2151 "cat /tmp/input > /tmp/output",
2152 ToolSemantic::Mutate(MutationKind::Write),
2153 ),
2154 (
2155 "sed -i 's/a/b/' /tmp/file",
2156 ToolSemantic::Mutate(MutationKind::Edit),
2157 ),
2158 ("./run_tests.sh", ToolSemantic::Unknown),
2159 ] {
2160 assert_eq!(
2161 classify_tool_call_with_semantics("BASH", Some(command), &semantics),
2162 expected,
2163 "bash command {command:?} changed classification"
2164 );
2165 }
2166 }
2167
2168 #[test]
2169 fn configured_semantics_score_like_their_builtin_equivalents() {
2170 let semantics = ToolSemantics {
2171 observe: vec!["lookup_customer".to_string()],
2172 mutate: vec!["send_payment".to_string()],
2173 plan: vec!["create_workflow".to_string()],
2174 ..Default::default()
2175 };
2176
2177 for (builtin, configured) in [
2178 ("Read", "lookup_customer"),
2179 ("Write", "send_payment"),
2180 ("TodoWrite", "create_workflow"),
2181 ] {
2182 let messages_before_tool = || {
2183 vec![
2184 Message::text(Role::User, "start"),
2185 Message::text(Role::Assistant, "working"),
2186 Message::text(Role::User, "continue"),
2187 Message::text(Role::Assistant, "working"),
2188 Message::text(Role::User, "continue"),
2189 Message::text(Role::Assistant, "working"),
2190 Message::text(Role::User, "continue"),
2191 ]
2192 };
2193 let mut builtin_messages = messages_before_tool();
2194 builtin_messages.push(tc(builtin));
2195 let mut configured_messages = messages_before_tool();
2196 configured_messages.push(tc(configured));
2197
2198 let builtin_score = score_signal(&ToolSignals::from_request(
2199 &with_messages(builtin_messages),
2200 None,
2201 ));
2202 let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
2203 &with_messages(configured_messages),
2204 None,
2205 &semantics,
2206 ));
2207
2208 assert_ne!(
2209 builtin_score.score, 0.0,
2210 "the {builtin:?} control must exercise a scoring dimension"
2211 );
2212 assert_eq!(
2213 configured_score, builtin_score,
2214 "configured tool {configured:?} must score exactly like {builtin:?}"
2215 );
2216 }
2217 }
2218
2219 #[test]
2220 fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
2221 let duplicate = ToolSemantics {
2222 observe: vec!["lookup".to_string()],
2223 mutate: vec!["LOOKUP".to_string()],
2224 ..Default::default()
2225 };
2226 assert!(
2227 duplicate
2228 .validate()
2229 .expect_err("duplicate should fail")
2230 .to_string()
2231 .contains("appears in both")
2232 );
2233
2234 let builtin = ToolSemantics {
2235 new: vec!["write_file".to_string()],
2236 ..Default::default()
2237 };
2238 assert!(
2239 builtin
2240 .validate()
2241 .expect_err("built-in should fail")
2242 .to_string()
2243 .contains("built-in semantics")
2244 );
2245
2246 let empty = ToolSemantics {
2247 observe: vec![" \t".to_string()],
2248 ..Default::default()
2249 };
2250 assert!(
2251 empty
2252 .validate()
2253 .expect_err("empty name should fail")
2254 .to_string()
2255 .contains("empty tool name")
2256 );
2257 }
2258}