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> = 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 if !text.is_empty() {
739 tool_texts.push(text);
740 }
741 }
742 ContentBlock::Text { text } => {
746 compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
747 }
748 _ => {}
749 }
750 }
751 }
752
753 let mut signal = build_signal(
754 tool_texts,
755 tool_calls,
756 messages.len() as u32,
757 recent_window,
758 semantics,
759 );
760 signal.compacted = compacted;
761 signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
762 signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
763 signal
764}
765
766const COMPACTION_MARKER: &str = "session is being continued";
769
770fn command_of(arguments: &Value) -> Option<String> {
773 let decoded = arguments
775 .as_str()
776 .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
777 let object = decoded.as_ref().unwrap_or(arguments);
778
779 ["command", "cmd", "input"]
780 .iter()
781 .filter_map(|key| object.get(*key))
782 .find_map(command_text)
783}
784
785fn command_text(value: &Value) -> Option<String> {
787 match value {
788 Value::String(text) => Some(text.to_lowercase()),
789 Value::Array(parts) => {
790 let joined = parts
791 .iter()
792 .filter_map(Value::as_str)
793 .collect::<Vec<_>>()
794 .join(" ");
795 (!joined.is_empty()).then(|| joined.to_lowercase())
796 }
797 _ => None,
798 }
799}
800
801fn text_of(block: &ContentBlock) -> Option<&str> {
803 match block {
804 ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
805 _ => None,
806 }
807}
808
809fn build_signal(
810 tool_texts: Vec<String>,
811 tool_calls: Vec<ObservedToolCall>,
812 turn_depth: u32,
813 recent_window: usize,
814 semantics: &ToolSemantics,
815) -> ToolSignals {
816 let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
822 let mut severity = 0.0f32;
823 let mut failure_fingerprints = Vec::new();
824 let mut repeated_failure = false;
825 for text in &tool_texts[sev_start..] {
826 let (sev, _patterns) = classify_text(text);
827 if sev > severity {
828 severity = sev;
829 }
830 if let Some(fingerprint) = failure_fingerprint(text) {
831 repeated_failure |= failure_fingerprints.contains(&fingerprint);
832 failure_fingerprints.push(fingerprint);
833 }
834 }
835
836 let no_error_streak = compute_no_error_streak(&tool_texts);
837
838 let recent_start = tool_calls.len().saturating_sub(recent_window);
842 let mut write_count = 0u32;
843 let mut edit_count = 0u32;
844 let mut read_count = 0u32;
845 let mut todowrite_count = 0u32;
846 let mut recent_write_count = 0u32;
847 let mut recent_edit_count = 0u32;
848 let mut recent_read_count = 0u32;
849 let mut recent_todowrite_count = 0u32;
850 let mut new_count = 0u32;
851 let mut recent_new_count = 0u32;
852 let mut pure_bash_streak = 0u32;
853 let mut streak_open = true;
854 for (i, tc) in tool_calls.iter().enumerate().rev() {
855 let cat = classify_tool_call_with_semantics(&tc.name, tc.command.as_deref(), semantics);
856 if streak_open {
857 if matches!(cat, ToolSemantic::Unknown) {
858 pure_bash_streak += 1;
859 } else {
860 streak_open = false;
861 }
862 }
863 match cat {
864 ToolSemantic::Mutate(MutationKind::Write) => {
865 write_count += 1;
866 if i >= recent_start {
867 recent_write_count += 1;
868 }
869 }
870 ToolSemantic::Mutate(MutationKind::Edit) => {
871 edit_count += 1;
872 if i >= recent_start {
873 recent_edit_count += 1;
874 }
875 }
876 ToolSemantic::Observe => {
877 read_count += 1;
878 if i >= recent_start {
879 recent_read_count += 1;
880 }
881 }
882 ToolSemantic::Plan => {
883 todowrite_count += 1;
884 if i >= recent_start {
885 recent_todowrite_count += 1;
886 }
887 }
888 ToolSemantic::New => {
889 new_count += 1;
890 if i >= recent_start {
891 recent_new_count += 1;
892 }
893 }
894 ToolSemantic::Unknown => {}
895 }
896 }
897
898 let tests_passed = detect_tests_passed(&tool_texts, recent_window);
899
900 ToolSignals {
901 severity,
902 repeated_failure,
903 no_error_streak,
904 edit_count,
905 write_count,
906 read_count,
907 todowrite_count,
908 recent_edit_count,
909 recent_write_count,
910 recent_read_count,
911 recent_todowrite_count,
912 new_count,
913 recent_new_count,
914 pure_bash_streak,
915 tests_passed,
916 turn_depth,
917 tool_result_count: 0,
921 assistant_turn_count: 0,
922 compacted: false,
923 }
924}
925
926fn content_to_text(content: Option<&Value>) -> Option<String> {
930 match content? {
931 Value::String(s) => Some(s.clone()),
932 Value::Array(blocks) => {
933 let parts: Vec<&str> = blocks
934 .iter()
935 .filter_map(|b| {
936 b.as_object()
937 .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
938 .and_then(|o| o.get("text"))
939 .and_then(Value::as_str)
940 })
941 .collect();
942 if parts.is_empty() {
943 None
944 } else {
945 Some(parts.join("\n"))
946 }
947 }
948 _ => None,
949 }
950}
951
952pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
956 let lower = text.to_lowercase();
957 let mut patterns = Vec::new();
958 let mut severity: f32 = 0.0;
959 for (name, sev, substrings) in ERROR_PATTERNS {
960 if substrings.iter().any(|sub| lower.contains(sub)) {
961 patterns.push(name.to_string());
962 severity = severity.max(*sev);
963 }
964 }
965 if has_nonzero_exit_status(&lower) && !patterns.iter().any(|p| p == "exit_nonzero") {
966 patterns.push("exit_nonzero".to_string());
967 severity = severity.max(SOFT);
968 }
969 for (name, matched) in [
970 ("compile_error", has_compiler_diagnostic(&lower)),
971 ("runtime_exception", has_runtime_exception(&lower)),
972 ("runtime_panic", has_runtime_panic(&lower)),
973 ("patch_error", has_patch_failure(&lower)),
974 ] {
975 if matched && !patterns.iter().any(|pattern| pattern == name) {
976 patterns.push(name.to_string());
977 severity = severity.max(HARD);
978 }
979 }
980 (severity, patterns)
981}
982
983fn failure_fingerprint(text: &str) -> Option<String> {
986 let (severity, patterns) = classify_text(text);
987 if severity < HARD {
988 return None;
989 }
990
991 let lower = text.to_lowercase();
992 let diagnostic = lower
993 .lines()
994 .find(|line| is_failure_diagnostic(line))
995 .or_else(|| lower.lines().find(|line| !line.trim().is_empty()))
996 .unwrap_or_default();
997 let normalized = normalize_failure_text(diagnostic);
998 Some(format!("{}|{normalized}", patterns.join(",")))
999}
1000
1001fn is_failure_diagnostic(line: &str) -> bool {
1002 let line = line.trim();
1003 [
1004 "error",
1005 "exception",
1006 "panic",
1007 "failed",
1008 "timed out",
1009 "timeout",
1010 "connection refused",
1011 "cannot allocate memory",
1012 "out of memory",
1013 "not found",
1014 ]
1015 .iter()
1016 .any(|marker| line.contains(marker))
1017}
1018
1019fn normalize_failure_text(text: &str) -> String {
1022 let mut normalized = String::new();
1023 for word in text.split_whitespace() {
1024 if !normalized.is_empty() {
1025 normalized.push(' ');
1026 }
1027 let mut in_digits = false;
1028 if word.starts_with('/') || word.contains("/src/") || word.contains("/tmp/") {
1029 normalized.push_str("<path>");
1030 continue;
1031 }
1032 for character in word.chars() {
1033 if character.is_ascii_digit() {
1034 if !in_digits {
1035 normalized.push('#');
1036 in_digits = true;
1037 }
1038 } else {
1039 normalized.push(character);
1040 in_digits = false;
1041 }
1042 }
1043 }
1044 normalized.chars().take(240).collect()
1045}
1046
1047fn has_compiler_diagnostic(lower: &str) -> bool {
1048 lower.lines().any(|line| {
1049 let line = line.trim_start();
1050 if matches!(
1051 line,
1052 "compilation failed" | "error: compilation failed" | "error: could not compile"
1053 ) || line.starts_with("error: could not compile ")
1054 {
1055 return true;
1056 }
1057
1058 let Some(rest) = line.strip_prefix("error[e") else {
1059 return false;
1060 };
1061 let Some((code, _)) = rest.split_once("]:") else {
1062 return false;
1063 };
1064 !code.is_empty() && code.chars().all(|character| character.is_ascii_digit())
1065 })
1066}
1067
1068fn has_runtime_exception(lower: &str) -> bool {
1069 let has_exception_line = lower.lines().any(|line| {
1070 let line = line.trim_start();
1071 [
1072 "typeerror:",
1073 "referenceerror:",
1074 "rangeerror:",
1075 "runtimeerror:",
1076 "keyerror:",
1077 "attributeerror:",
1078 ]
1079 .iter()
1080 .any(|prefix| line.starts_with(prefix))
1081 });
1082 has_exception_line && (lower.contains("\n at ") || lower.contains("\n at "))
1083}
1084
1085fn has_runtime_panic(lower: &str) -> bool {
1086 lower
1087 .lines()
1088 .any(|line| line.trim_start().starts_with("panic: runtime error:"))
1089 && (lower.contains("\ngoroutine ") || lower.contains("[signal sig"))
1090}
1091
1092fn has_patch_failure(lower: &str) -> bool {
1093 lower.lines().any(|line| {
1094 let line = line.trim_start();
1095 line.starts_with("error: patch failed:")
1096 || line.starts_with("patch failed:")
1097 || line.contains(": patch does not apply")
1098 || line.starts_with("invalid context")
1099 })
1100}
1101
1102fn has_nonzero_exit_status(lower: &str) -> bool {
1108 NONZERO_EXIT_PHRASES
1109 .iter()
1110 .any(|phrase| phrase_followed_by_nonzero_integer(lower, phrase))
1111}
1112
1113fn phrase_followed_by_nonzero_integer(lower: &str, phrase: &str) -> bool {
1115 let mut cursor = 0usize;
1116 while let Some(rel) = lower[cursor..].find(phrase) {
1117 let value_start = cursor + rel + phrase.len();
1118 let rest = lower[value_start..].trim_start_matches(|c: char| {
1119 c.is_ascii_whitespace() || matches!(c, ':' | '=' | '\'' | '"' | '`')
1120 });
1121 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1122 if !digits.is_empty() && digits.chars().any(|d| d != '0') {
1123 return true;
1124 }
1125 cursor = value_start;
1126 }
1127 false
1128}
1129
1130fn compute_no_error_streak(tool_texts: &[String]) -> u32 {
1131 let mut streak = 0u32;
1132 for text in tool_texts.iter().rev() {
1133 let (sev, _) = classify_text(text);
1134 if sev > 0.0 {
1135 break;
1136 }
1137 streak += 1;
1138 }
1139 streak
1140}
1141
1142fn detect_tests_passed(tool_texts: &[String], recent_window: usize) -> bool {
1143 let start = tool_texts.len().saturating_sub(recent_window.max(1));
1144 let recent = &tool_texts[start..];
1145 let after_latest_failure = recent
1146 .iter()
1147 .rposition(|text| classify_text(text).0 > 0.0)
1148 .map_or(recent, |index| &recent[index + 1..]);
1149 after_latest_failure.iter().any(|text| {
1150 let lower = text.to_lowercase();
1151 TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
1152 && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
1153 && !has_nonzero_failure_count(&lower)
1154 })
1155}
1156
1157fn has_nonzero_failure_count(lower: &str) -> bool {
1163 for kw in NUMERIC_FAILURE_KEYWORDS {
1164 let mut cursor = 0usize;
1165 while let Some(rel) = lower[cursor..].find(kw) {
1166 let kw_start = cursor + rel;
1167 let kw_end = kw_start + kw.len();
1168 let boundary_after = lower[kw_end..]
1171 .chars()
1172 .next()
1173 .is_none_or(|c| !c.is_ascii_alphanumeric());
1174 if boundary_after {
1175 let prefix = &lower[..kw_start];
1176 let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
1177 let digits_rev: String = trimmed
1178 .chars()
1179 .rev()
1180 .take_while(|c| c.is_ascii_digit())
1181 .collect();
1182 if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
1183 return true;
1184 }
1185 }
1186 cursor = kw_start + kw.len();
1187 }
1188 }
1189 false
1190}
1191
1192#[cfg(test)]
1195mod tests {
1196 use super::*;
1197 use crate::algorithms::util::stage::score_signal;
1198 use serde_json::json;
1199 use switchyard_protocol::{
1200 ContentBlock, LlmRequest, Message, Metadata, Role, ToolCall, ToolResult,
1201 };
1202
1203 fn with_messages(messages: Vec<Message>) -> Request {
1204 Request {
1205 llm_request: LlmRequest {
1206 messages,
1207 ..LlmRequest::default()
1208 },
1209 raw_request: None,
1210 metadata: None,
1211 }
1212 }
1213
1214 fn tc(name: &str) -> Message {
1216 Message {
1217 role: Role::Assistant,
1218 content: vec![ContentBlock::ToolCall(ToolCall {
1219 id: String::new(),
1220 name: name.to_string(),
1221 arguments: json!({}),
1222 })],
1223 }
1224 }
1225
1226 fn bash(command: &str) -> Message {
1228 Message {
1229 role: Role::Assistant,
1230 content: vec![ContentBlock::ToolCall(ToolCall {
1231 id: String::new(),
1232 name: "Bash".to_string(),
1233 arguments: json!({"command": command}),
1234 })],
1235 }
1236 }
1237
1238 fn tr(text: &str) -> Message {
1240 Message {
1241 role: Role::User,
1242 content: vec![ContentBlock::ToolResult(ToolResult {
1243 tool_call_id: String::new(),
1244 content: vec![ContentBlock::Text {
1245 text: text.to_string(),
1246 }],
1247 is_error: None,
1248 })],
1249 }
1250 }
1251
1252 #[test]
1253 fn clean_text_has_zero_severity() {
1254 let (sev, patterns) = classify_text("everything went fine");
1255 assert_eq!(sev, 0.0);
1256 assert!(patterns.is_empty());
1257 }
1258
1259 #[test]
1260 fn traceback_is_hard() {
1261 let (sev, patterns) = classify_text("Traceback (most recent call last):\n ValueError");
1262 assert_eq!(sev, HARD);
1263 assert!(patterns.contains(&"traceback".to_string()));
1264 }
1265
1266 #[test]
1267 fn oom_is_critical() {
1268 let (sev, _) = classify_text("Out of memory: kill process 1234");
1269 assert_eq!(sev, CRITICAL);
1270 }
1271
1272 #[test]
1273 fn connection_refused_is_hard() {
1274 let (severity, _) = classify_text("Connection refused on port 8000");
1275 assert_eq!(severity, HARD);
1276 }
1277
1278 #[test]
1279 fn repeated_failure_ignores_volatile_paths_and_numbers() {
1280 let request = with_messages(vec![
1281 tr("error[E0308]: mismatched types at /tmp/a/src/lib.rs:12"),
1282 tr("error[E0308]: mismatched types at /tmp/b/src/lib.rs:47"),
1283 ]);
1284 assert!(ToolSignals::from_request(&request, None).repeated_failure);
1285 }
1286
1287 #[test]
1288 fn different_failures_are_not_repeated() {
1289 let request = with_messages(vec![
1290 tr("error[E0308]: mismatched types"),
1291 tr("error[E0509]: cannot move out"),
1292 ]);
1293 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1294 }
1295
1296 #[test]
1297 fn one_material_failure_is_not_repeated() {
1298 let request = with_messages(vec![tr("Connection refused on port 8000")]);
1299 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1300 }
1301
1302 #[test]
1303 fn severity_is_max_across_patterns() {
1304 let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
1306 assert_eq!(sev, HARD);
1307 }
1308
1309 #[test]
1310 fn codex_process_exit_zero_stays_clean() {
1311 let (sev, patterns) =
1312 classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
1313 assert_eq!(sev, 0.0);
1314 assert!(!patterns.contains(&"exit_nonzero".to_string()));
1315 }
1316
1317 #[test]
1318 fn nonzero_exit_codes_are_soft_errors() {
1319 let cases = [
1320 "Process exited with code 1",
1321 "Process exited with code 127",
1322 "exit code: 2",
1323 "exit status 3",
1324 "exited with status 9",
1325 ];
1326 for case in cases {
1327 let (sev, patterns) = classify_text(case);
1328 assert_eq!(sev, SOFT, "expected soft severity for {case}");
1329 assert!(patterns.contains(&"exit_nonzero".to_string()));
1330 }
1331 }
1332
1333 #[test]
1334 fn partial_process_failures_are_hard_errors() {
1335 let cases = [
1336 (
1337 "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
1338 "compile_error",
1339 ),
1340 (
1341 "Process exited with code 0\nOutput:\nTypeError: value is undefined\n at main.js:1:2",
1342 "runtime_exception",
1343 ),
1344 (
1345 "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
1346 "runtime_panic",
1347 ),
1348 (
1349 "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
1350 "patch_error",
1351 ),
1352 ];
1353 for (text, expected_pattern) in cases {
1354 let (severity, patterns) = classify_text(text);
1355 assert_eq!(severity, HARD, "expected hard severity for {text}");
1356 assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
1357 }
1358 }
1359
1360 #[test]
1361 fn source_text_that_names_exceptions_stays_clean() {
1362 let text =
1363 "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
1364 assert_eq!(classify_text(text).0, 0.0);
1365 }
1366
1367 #[test]
1368 fn file_does_not_exist_is_hard() {
1369 let (sev, patterns) =
1371 classify_text("Error: File does not exist. Note: current working directory is /app.");
1372 assert_eq!(sev, HARD);
1373 assert!(patterns.contains(&"no_such_file".to_string()));
1374 }
1375
1376 #[test]
1377 fn bare_does_not_exist_stays_clean() {
1378 let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
1381 assert_eq!(sev, 0.0);
1382 }
1383
1384 #[test]
1385 fn no_error_streak_all_clean() {
1386 let texts = vec!["ok".to_string(), "all good".to_string()];
1387 assert_eq!(compute_no_error_streak(&texts), 2);
1388 }
1389
1390 #[test]
1391 fn no_error_streak_stops_at_error() {
1392 let texts = vec![
1393 "Traceback (most recent call last):".to_string(),
1394 "ok".to_string(),
1395 "ok".to_string(),
1396 ];
1397 assert_eq!(compute_no_error_streak(&texts), 2);
1398 }
1399
1400 #[test]
1401 fn tests_passed_detects_pytest_output() {
1402 assert!(detect_tests_passed(
1403 &["====== 5 passed in 0.12s ======".to_string()],
1404 DEFAULT_RECENT_WINDOW
1405 ));
1406 }
1407
1408 #[test]
1409 fn tests_passed_ignores_partial_failures() {
1410 assert!(!detect_tests_passed(
1411 &["2 failed, 5 passed in 0.56s".to_string()],
1412 DEFAULT_RECENT_WINDOW
1413 ));
1414 }
1415
1416 #[test]
1417 fn tests_passed_must_follow_the_latest_failure() {
1418 assert!(!detect_tests_passed(
1419 &[
1420 "5 passed in 0.12s".to_string(),
1421 "Traceback (most recent call last):\nValueError".to_string(),
1422 "edit applied".to_string(),
1423 ],
1424 DEFAULT_RECENT_WINDOW
1425 ));
1426 assert!(detect_tests_passed(
1427 &[
1428 "Traceback (most recent call last):\nValueError".to_string(),
1429 "5 passed in 0.12s".to_string(),
1430 ],
1431 DEFAULT_RECENT_WINDOW
1432 ));
1433 }
1434
1435 #[test]
1436 fn severity_is_windowed_over_recent_results() {
1437 let request = with_messages(vec![
1439 tr("Traceback (most recent call last):\n ValueError"),
1440 tr("ok"),
1441 tr("ok"),
1442 ]);
1443 assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
1445 assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
1447 }
1448
1449 #[test]
1450 fn extract_openai_chat_tool_results() {
1451 let request = with_messages(vec![
1452 Message::text(Role::User, "do something"),
1453 tc("Edit"),
1454 tr("Traceback (most recent call last):\n ValueError"),
1455 ]);
1456 let sig = ToolSignals::from_request(&request, None);
1457 assert_eq!(sig.severity, HARD);
1458 assert_eq!(sig.edit_count, 1);
1459 assert_eq!(sig.turn_depth, 3);
1460 }
1461
1462 #[test]
1463 fn extract_anthropic_tool_results() {
1464 let request = with_messages(vec![tr("Traceback (most recent call last):\n ValueError")]);
1465 let sig = ToolSignals::from_request(&request, None);
1466 assert_eq!(sig.severity, HARD);
1467 }
1468
1469 #[test]
1470 fn extract_responses_api_tool_results() {
1471 let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
1472 let sig = ToolSignals::from_request(&request, None);
1473 assert_eq!(sig.severity, 0.0);
1474 assert_eq!(sig.write_count, 1);
1475 }
1476
1477 #[test]
1478 fn conversation_counts_are_per_block_and_role_aware() {
1479 let result = |content: Vec<ContentBlock>| {
1483 ContentBlock::ToolResult(ToolResult {
1484 tool_call_id: String::new(),
1485 content,
1486 is_error: None,
1487 })
1488 };
1489 let request = with_messages(vec![
1490 Message::text(Role::User, "do something"),
1491 Message::text(Role::Assistant, "working"),
1492 Message {
1493 role: Role::User,
1494 content: vec![
1495 result(vec![ContentBlock::Text {
1496 text: "ok".to_string(),
1497 }]),
1498 result(Vec::new()),
1499 ],
1500 },
1501 tc("Bash"),
1502 ]);
1503 let sig = ToolSignals::from_request(&request, None);
1504 assert_eq!(sig.tool_result_count, 2);
1505 assert_eq!(sig.assistant_turn_count, 2);
1506 assert_eq!(sig.turn_depth, 4);
1507 }
1508
1509 #[test]
1510 fn recent_window_counts_only_last_default_window_tool_calls() {
1511 let request = with_messages(vec![
1514 tc("Write"),
1515 tr("ok"),
1516 tc("Write"),
1517 tr("ok"),
1518 tc("Write"),
1519 tr("ok"),
1520 tc("Write"),
1521 tr("ok"),
1522 tc("Write"),
1523 tr("ok"),
1524 tc("Edit"),
1525 tr("ok"),
1526 ]);
1527 let sig = ToolSignals::from_request(&request, None);
1528 assert_eq!(sig.write_count, 5);
1529 assert_eq!(sig.edit_count, 1);
1530 assert_eq!(sig.recent_write_count, 2);
1531 assert_eq!(sig.recent_edit_count, 1);
1532 }
1533
1534 #[test]
1535 fn codex_apply_patch_counts_as_an_edit() {
1536 let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
1537 let sig = ToolSignals::from_request(&request, None);
1538 assert_eq!(sig.edit_count, 1);
1539 assert_eq!(sig.recent_edit_count, 1);
1540 }
1541
1542 fn exec_command(cmd: Value) -> Message {
1543 Message {
1544 role: Role::Assistant,
1545 content: vec![ContentBlock::ToolCall(ToolCall {
1546 id: String::new(),
1547 name: "exec_command".to_string(),
1548 arguments: cmd,
1549 })],
1550 }
1551 }
1552
1553 #[test]
1554 fn codex_exec_command_is_classified() {
1555 let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
1557 let request = with_messages(vec![exec_command(args), tr("ok")]);
1558 assert_eq!(
1559 ToolSignals::from_request(&request, None).recent_edit_count,
1560 1
1561 );
1562 }
1563
1564 #[test]
1565 fn python_write_expressions_need_a_python_command() {
1566 let write = with_messages(vec![
1567 exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
1568 tr("ok"),
1569 ]);
1570 assert_eq!(
1571 ToolSignals::from_request(&write, None).recent_write_count,
1572 1
1573 );
1574
1575 let search = with_messages(vec![
1576 exec_command(json!({"cmd": "grep -R '.write(' src"})),
1577 tr("ok"),
1578 ]);
1579 assert_eq!(
1580 ToolSignals::from_request(&search, None).recent_write_count,
1581 0
1582 );
1583 }
1584
1585 #[test]
1586 fn recent_window_size_is_caller_overridable() {
1587 let request = with_messages(vec![
1591 tc("Write"),
1592 tr("ok"),
1593 tc("Write"),
1594 tr("ok"),
1595 tc("Write"),
1596 tr("ok"),
1597 tc("Write"),
1598 tr("ok"),
1599 tc("Write"),
1600 tr("ok"),
1601 tc("Edit"),
1602 tr("ok"),
1603 ]);
1604 let narrow = extract_tool_signals_with_window(&request, 3);
1605 assert_eq!(narrow.recent_write_count, 2);
1606 assert_eq!(narrow.recent_edit_count, 1);
1607
1608 let wide = extract_tool_signals_with_window(&request, 6);
1609 assert_eq!(wide.recent_write_count, 5);
1610 assert_eq!(wide.recent_edit_count, 1);
1611 }
1612
1613 #[test]
1614 fn compaction_marker_sets_compacted() {
1615 let request = with_messages(vec![
1617 Message::text(
1618 Role::User,
1619 "This session is being continued from a previous conversation that ran out of context.",
1620 ),
1621 bash("ls"),
1622 ]);
1623 assert!(ToolSignals::from_request(&request, None).compacted);
1624 }
1625
1626 #[test]
1627 fn codex_compaction_metadata_stays_on_parent_route() {
1628 let mut request = with_messages(vec![bash("ls")]);
1629 request.metadata = Some(Metadata {
1630 is_subagent: true,
1631 agent_kind: Some("compact".to_string()),
1632 ..Default::default()
1633 });
1634 assert!(!ToolSignals::from_request(&request, None).compacted);
1635 }
1636
1637 #[test]
1638 fn no_compaction_marker_stays_uncompacted() {
1639 let request = with_messages(vec![
1640 Message::text(Role::User, "Write a script that parses the log file."),
1641 bash("ls"),
1642 ]);
1643 assert!(!ToolSignals::from_request(&request, None).compacted);
1644 }
1645
1646 #[test]
1647 fn bash_heredoc_counts_as_write() {
1648 let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
1650 let sig = ToolSignals::from_request(&request, None);
1651 assert_eq!(
1652 sig.write_count, 1,
1653 "Bash heredoc should bucket into write_count"
1654 );
1655 assert_eq!(sig.edit_count, 0);
1656 }
1657
1658 #[test]
1659 fn bash_sed_inplace_counts_as_edit() {
1660 let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
1661 let sig = ToolSignals::from_request(&request, None);
1662 assert_eq!(
1663 sig.edit_count, 1,
1664 "Bash sed -i should bucket into edit_count"
1665 );
1666 assert_eq!(sig.write_count, 0);
1667 }
1668
1669 #[test]
1670 fn bash_non_mutating_does_not_count() {
1671 let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
1673 let sig = ToolSignals::from_request(&request, None);
1674 assert_eq!(sig.write_count, 0);
1675 assert_eq!(sig.edit_count, 0);
1676 }
1677
1678 #[test]
1679 fn tests_passed_detects_pytest_with_failure_block() {
1680 assert!(!detect_tests_passed(
1682 &["2 failed, 5 passed in 0.56s".to_string()],
1683 DEFAULT_RECENT_WINDOW
1684 ));
1685 }
1686
1687 #[test]
1688 fn tests_passed_accepts_cargo_clean_summary() {
1689 assert!(detect_tests_passed(
1692 &["running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string()],
1693 DEFAULT_RECENT_WINDOW
1694 ));
1695 }
1696
1697 #[test]
1698 fn tests_passed_rejects_cargo_real_failure() {
1699 assert!(!detect_tests_passed(
1701 &["running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string()],
1702 DEFAULT_RECENT_WINDOW
1703 ));
1704 }
1705
1706 #[test]
1707 fn tests_passed_accepts_go_clean_summary() {
1708 assert!(detect_tests_passed(
1710 &["ok github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string()],
1711 DEFAULT_RECENT_WINDOW
1712 ));
1713 }
1714
1715 #[test]
1716 fn tests_passed_accepts_pytest_zero_errors() {
1717 assert!(detect_tests_passed(
1719 &["5 passed, 0 errors in 0.30s".to_string()],
1720 DEFAULT_RECENT_WINDOW
1721 ));
1722 }
1723
1724 #[test]
1725 fn tests_passed_detects_diy_checkmark() {
1726 assert!(detect_tests_passed(
1727 &["✓ all checks passed".to_string()],
1728 DEFAULT_RECENT_WINDOW
1729 ));
1730 }
1731
1732 #[test]
1733 fn anthropic_bash_heredoc_extracts_command() {
1734 let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
1736 let sig = ToolSignals::from_request(&request, None);
1737 assert_eq!(
1738 sig.write_count, 1,
1739 "Anthropic Bash heredoc must also be detected"
1740 );
1741 }
1742
1743 #[test]
1744 fn recent_window_falls_back_to_full_history_when_short() {
1745 let request = with_messages(vec![tc("Write")]);
1746 let sig = ToolSignals::from_request(&request, None);
1747 assert_eq!(sig.recent_write_count, 1);
1748 assert_eq!(sig.recent_edit_count, 0);
1749 }
1750
1751 #[test]
1752 fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
1753 let request = with_messages(vec![tr("output ok"), tr("another ok")]);
1754 let sig = ToolSignals::from_request(&request, None);
1755 assert_eq!(sig.severity, 0.0);
1756 assert_eq!(sig.no_error_streak, 2);
1757 }
1758
1759 #[test]
1762 fn todowrite_classifies_as_plan() {
1763 assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
1764 assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
1765 }
1766
1767 #[test]
1768 fn codex_update_plan_classifies_as_plan() {
1769 assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
1770 }
1771
1772 #[test]
1773 fn codex_shell_command_runs_bash_pattern_match() {
1774 assert_eq!(
1776 classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
1777 ToolSemantic::Mutate(MutationKind::Write),
1778 );
1779 assert_eq!(
1781 classify_tool_call("shell_command", Some("ls /app")),
1782 ToolSemantic::Observe,
1783 );
1784 assert_eq!(
1786 classify_tool_call("shell_command", Some("./run_tests.sh")),
1787 ToolSemantic::Unknown,
1788 );
1789 }
1790
1791 #[test]
1792 fn read_tool_classifies_as_read() {
1793 assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
1794 assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
1795 }
1796
1797 #[test]
1798 fn hermes_tool_names_classify() {
1799 assert_eq!(
1801 classify_tool_call("write_file", None),
1802 ToolSemantic::Mutate(MutationKind::Write)
1803 );
1804 assert_eq!(
1805 classify_tool_call("patch", None),
1806 ToolSemantic::Mutate(MutationKind::Edit)
1807 );
1808 assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
1809 assert_eq!(
1810 classify_tool_call("search_files", None),
1811 ToolSemantic::Observe
1812 );
1813 assert_eq!(
1816 classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
1817 ToolSemantic::Mutate(MutationKind::Edit),
1818 );
1819 assert_eq!(
1820 classify_tool_call("terminal", Some("grep foo /app")),
1821 ToolSemantic::Observe,
1822 );
1823 assert_eq!(
1824 classify_tool_call("terminal", Some("./run_tests.sh")),
1825 ToolSemantic::Unknown,
1826 );
1827 }
1828
1829 #[test]
1830 fn bash_read_patterns_classify_as_read() {
1831 let cases = [
1832 "cat /etc/passwd",
1833 "grep foo bar.txt",
1834 "ls /app",
1835 "find . -name '*.py'",
1836 ];
1837 for cmd in cases {
1838 assert_eq!(
1839 classify_tool_call("Bash", Some(cmd)),
1840 ToolSemantic::Observe,
1841 "expected Read for {cmd}"
1842 );
1843 }
1844 }
1845
1846 #[test]
1847 fn codex_inspection_commands_classify_as_read() {
1848 let cases = [
1849 "sed -n '1,80p' src/lib.rs",
1850 "rg -n 'needle' src",
1851 "nl -ba src/lib.rs",
1852 "cat package.json",
1853 "jq '.scripts' package.json",
1854 "git status --short",
1855 "git log --oneline -5",
1856 "git show HEAD:src/lib.rs",
1857 "git branch --show-current",
1858 "git remote -v",
1859 "git config --get remote.origin.url",
1860 ];
1861 for command in cases {
1862 assert_eq!(
1863 classify_tool_call("exec_command", Some(command)),
1864 ToolSemantic::Observe,
1865 "expected Read for {command}"
1866 );
1867 }
1868 }
1869
1870 #[test]
1871 fn quoted_shell_separators_do_not_create_commands() {
1872 for command in ["rg 'foo|rm obsolete.rs'", "rg \"foo; rm obsolete.rs\""] {
1873 assert_eq!(
1874 classify_tool_call("exec_command", Some(command)),
1875 ToolSemantic::Observe,
1876 "quoted text must not be parsed as a command: {command}"
1877 );
1878 }
1879 }
1880
1881 #[test]
1882 fn codex_shell_mutations_classify_as_production() {
1883 let writes = [
1884 "cp source.rs destination.rs",
1885 "mkdir -p src/generated",
1886 "touch src/generated/mod.rs",
1887 "git show HEAD:file.rs > file.rs",
1888 "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
1889 ];
1890 for command in writes {
1891 assert_eq!(
1892 classify_tool_call("exec_command", Some(command)),
1893 ToolSemantic::Mutate(MutationKind::Write),
1894 "expected Write for {command}"
1895 );
1896 }
1897
1898 let edits = [
1899 "mv old.rs new.rs",
1900 "rm obsolete.rs",
1901 "gofmt -w main.go",
1902 "cargo fmt",
1903 "ruff check --fix src",
1904 "perl -0pi -e 's/old/new/' src/lib.rs",
1905 "npx prettier --write src/lib.ts",
1906 "uv run ruff format src",
1907 "git apply fix.patch",
1908 ];
1909 for command in edits {
1910 assert_eq!(
1911 classify_tool_call("exec_command", Some(command)),
1912 ToolSemantic::Mutate(MutationKind::Edit),
1913 "expected Edit for {command}"
1914 );
1915 }
1916 }
1917
1918 #[test]
1919 fn formatter_checks_are_not_edits() {
1920 for command in [
1921 "cargo fmt --check",
1922 "ruff format --check src",
1923 "black --check src",
1924 ] {
1925 assert_ne!(
1926 classify_tool_call("exec_command", Some(command)),
1927 ToolSemantic::Mutate(MutationKind::Edit),
1928 "read-only formatter check must not be Edit: {command}"
1929 );
1930 }
1931 }
1932
1933 #[test]
1934 fn embedded_comparison_is_not_a_shell_write() {
1935 let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
1936 assert_eq!(
1937 classify_tool_call("exec_command", Some(command)),
1938 ToolSemantic::Unknown
1939 );
1940 }
1941
1942 #[test]
1943 fn bash_write_precedence_over_read() {
1944 assert_eq!(
1947 classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
1948 ToolSemantic::Mutate(MutationKind::Write),
1949 );
1950 }
1951
1952 #[test]
1953 fn pure_bash_streak_counts_trailing_other() {
1954 let request = with_messages(vec![
1956 bash("make"),
1957 tr("ok"),
1958 bash("./configure"),
1959 tr("ok"),
1960 bash("make install"),
1961 tr("ok"),
1962 bash("./run.sh"),
1963 tr("ok"),
1964 bash("./test"),
1965 tr("ok"),
1966 ]);
1967 let sig = ToolSignals::from_request(&request, None);
1968 assert_eq!(sig.pure_bash_streak, 5);
1969 assert_eq!(sig.write_count, 0);
1970 assert_eq!(sig.read_count, 0);
1971 }
1972
1973 #[test]
1974 fn pure_bash_streak_resets_on_write() {
1975 let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
1976 let sig = ToolSignals::from_request(&request, None);
1977 assert_eq!(sig.pure_bash_streak, 0);
1978 assert_eq!(sig.write_count, 1);
1979 }
1980
1981 #[test]
1982 fn recent_window_tracks_todowrite_and_read() {
1983 let request = with_messages(vec![
1985 bash("make"),
1986 tr("ok"),
1987 tc("TodoWrite"),
1988 tr("ok"),
1989 tc("Read"),
1990 tr("ok"),
1991 tc("TodoWrite"),
1992 tr("ok"),
1993 ]);
1994 let sig = ToolSignals::from_request(&request, None);
1995 assert_eq!(sig.todowrite_count, 2);
1996 assert_eq!(sig.recent_todowrite_count, 2);
1997 assert_eq!(sig.read_count, 1);
1998 assert_eq!(sig.recent_read_count, 1);
1999 }
2000
2001 #[test]
2002 fn configured_tool_semantics_extend_the_builtin_vocabulary() {
2003 let semantics = ToolSemantics {
2004 observe: vec!["KB_search".to_string()],
2005 mutate: vec!["send_payment_request".to_string()],
2006 plan: vec!["create_research_plan".to_string()],
2007 new: vec!["send_message_to_user".to_string()],
2008 };
2009 semantics.validate().expect("valid additive semantics");
2010 let request = with_messages(vec![
2011 tc("Read"),
2012 tc("Write"),
2013 tc("TodoWrite"),
2014 tc("kb_SEARCH"),
2015 tc("send_payment_request"),
2016 tc("create_research_plan"),
2017 tc("send_message_to_user"),
2018 tc("unlisted_tool"),
2019 ]);
2020
2021 let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
2022
2023 assert_eq!(signal.read_count, 2);
2024 assert_eq!(signal.write_count, 2);
2025 assert_eq!(signal.todowrite_count, 2);
2026 assert_eq!(signal.new_count, 1);
2027 assert_eq!(signal.recent_new_count, 1);
2028 assert_eq!(signal.pure_bash_streak, 1);
2029 }
2030
2031 #[test]
2032 fn configured_tool_semantics_only_fold_ascii_case() {
2033 let semantics = ToolSemantics {
2034 observe: vec!["kb_search".to_string()],
2035 ..Default::default()
2036 };
2037
2038 assert_eq!(
2039 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2040 ToolSemantic::Observe
2041 );
2042 assert_eq!(
2045 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2046 ToolSemantic::Unknown
2047 );
2048 }
2049
2050 #[test]
2051 fn custom_semantics_preserve_builtin_unicode_lowercasing() {
2052 let semantics = ToolSemantics {
2053 observe: vec!["lookup_customer".to_string()],
2054 ..Default::default()
2055 };
2056
2057 assert_eq!(
2059 classify_tool_call_with_semantics("notebooKedit", None, &semantics),
2060 ToolSemantic::Mutate(MutationKind::Edit)
2061 );
2062 }
2063
2064 #[test]
2065 fn configured_semantics_never_replace_builtin_classifications() {
2066 let semantics = ToolSemantics {
2067 observe: vec!["lookup_customer".to_string()],
2068 mutate: vec!["send_payment".to_string()],
2069 plan: vec!["create_workflow".to_string()],
2070 new: vec!["send_message".to_string()],
2071 };
2072
2073 for name in WRITE_TOOL_NAMES {
2074 assert_eq!(
2075 classify_tool_call_with_semantics(name, None, &semantics),
2076 ToolSemantic::Mutate(MutationKind::Write),
2077 "write tool {name:?} changed classification"
2078 );
2079 }
2080 for name in EDIT_TOOL_NAMES {
2081 assert_eq!(
2082 classify_tool_call_with_semantics(name, None, &semantics),
2083 ToolSemantic::Mutate(MutationKind::Edit),
2084 "edit tool {name:?} changed classification"
2085 );
2086 }
2087 for name in READ_TOOL_NAMES {
2088 assert_eq!(
2089 classify_tool_call_with_semantics(name, None, &semantics),
2090 ToolSemantic::Observe,
2091 "read tool {name:?} changed classification"
2092 );
2093 }
2094 for name in PLAN_TOOL_NAMES {
2095 assert_eq!(
2096 classify_tool_call_with_semantics(name, None, &semantics),
2097 ToolSemantic::Plan,
2098 "plan tool {name:?} changed classification"
2099 );
2100 }
2101
2102 for (command, expected) in [
2103 ("cat /tmp/input", ToolSemantic::Observe),
2104 (
2105 "cat /tmp/input > /tmp/output",
2106 ToolSemantic::Mutate(MutationKind::Write),
2107 ),
2108 (
2109 "sed -i 's/a/b/' /tmp/file",
2110 ToolSemantic::Mutate(MutationKind::Edit),
2111 ),
2112 ("./run_tests.sh", ToolSemantic::Unknown),
2113 ] {
2114 assert_eq!(
2115 classify_tool_call_with_semantics("BASH", Some(command), &semantics),
2116 expected,
2117 "bash command {command:?} changed classification"
2118 );
2119 }
2120 }
2121
2122 #[test]
2123 fn configured_semantics_score_like_their_builtin_equivalents() {
2124 let semantics = ToolSemantics {
2125 observe: vec!["lookup_customer".to_string()],
2126 mutate: vec!["send_payment".to_string()],
2127 plan: vec!["create_workflow".to_string()],
2128 ..Default::default()
2129 };
2130
2131 for (builtin, configured) in [
2132 ("Read", "lookup_customer"),
2133 ("Write", "send_payment"),
2134 ("TodoWrite", "create_workflow"),
2135 ] {
2136 let messages_before_tool = || {
2137 vec![
2138 Message::text(Role::User, "start"),
2139 Message::text(Role::Assistant, "working"),
2140 Message::text(Role::User, "continue"),
2141 Message::text(Role::Assistant, "working"),
2142 Message::text(Role::User, "continue"),
2143 Message::text(Role::Assistant, "working"),
2144 Message::text(Role::User, "continue"),
2145 ]
2146 };
2147 let mut builtin_messages = messages_before_tool();
2148 builtin_messages.push(tc(builtin));
2149 let mut configured_messages = messages_before_tool();
2150 configured_messages.push(tc(configured));
2151
2152 let builtin_score = score_signal(&ToolSignals::from_request(
2153 &with_messages(builtin_messages),
2154 None,
2155 ));
2156 let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
2157 &with_messages(configured_messages),
2158 None,
2159 &semantics,
2160 ));
2161
2162 assert_ne!(
2163 builtin_score.score, 0.0,
2164 "the {builtin:?} control must exercise a scoring dimension"
2165 );
2166 assert_eq!(
2167 configured_score, builtin_score,
2168 "configured tool {configured:?} must score exactly like {builtin:?}"
2169 );
2170 }
2171 }
2172
2173 #[test]
2174 fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
2175 let duplicate = ToolSemantics {
2176 observe: vec!["lookup".to_string()],
2177 mutate: vec!["LOOKUP".to_string()],
2178 ..Default::default()
2179 };
2180 assert!(
2181 duplicate
2182 .validate()
2183 .expect_err("duplicate should fail")
2184 .to_string()
2185 .contains("appears in both")
2186 );
2187
2188 let builtin = ToolSemantics {
2189 new: vec!["write_file".to_string()],
2190 ..Default::default()
2191 };
2192 assert!(
2193 builtin
2194 .validate()
2195 .expect_err("built-in should fail")
2196 .to_string()
2197 .contains("built-in semantics")
2198 );
2199
2200 let empty = ToolSemantics {
2201 observe: vec![" \t".to_string()],
2202 ..Default::default()
2203 };
2204 assert!(
2205 empty
2206 .validate()
2207 .expect_err("empty name should fail")
2208 .to_string()
2209 .contains("empty tool name")
2210 );
2211 }
2212}