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 error_count: u32,
342 pub tool_results: u32,
344 pub tool_tail_clean: bool,
346 pub edit_count: u32,
348 pub write_count: u32,
350 pub read_count: u32,
352 pub todowrite_count: u32,
355 pub recent_edit_count: u32,
357 pub recent_write_count: u32,
359 pub recent_read_count: u32,
361 pub recent_todowrite_count: u32,
363 pub new_count: u32,
365 pub recent_new_count: u32,
367 pub pure_bash_streak: u32,
370 pub tests_passed: bool,
372 pub tool_result_count: u32,
375 pub assistant_turn_count: u32,
378 pub turn_depth: u32,
382 pub compacted: bool,
388}
389
390impl ToolSignals {
391 pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
396 Self::from_request_with_semantics(request, window_size, &ToolSemantics::default())
397 }
398
399 pub fn from_request_with_semantics(
401 request: &Request,
402 window_size: Option<usize>,
403 semantics: &ToolSemantics,
404 ) -> Self {
405 extract_tool_signals_with_window_and_semantics(
406 request,
407 window_size.unwrap_or(DEFAULT_RECENT_WINDOW),
408 semantics,
409 )
410 }
411}
412
413#[derive(Debug, Clone)]
415struct ObservedToolCall {
416 name: String,
417 command: Option<String>,
418}
419
420#[derive(Debug, Clone, Copy, PartialEq, Eq)]
421enum MutationKind {
422 Write,
423 Edit,
424}
425
426#[derive(Debug, Clone, Copy, PartialEq, Eq)]
428enum ToolSemantic {
429 Mutate(MutationKind),
430 Observe,
431 Plan,
432 New,
433 Unknown,
434}
435
436#[derive(Debug, Clone)]
439pub struct ToolSignalProcessor {
440 pub recent_window: usize,
443 pub tool_semantics: ToolSemantics,
445}
446
447impl Default for ToolSignalProcessor {
448 fn default() -> Self {
449 Self {
450 recent_window: DEFAULT_RECENT_WINDOW,
451 tool_semantics: ToolSemantics::default(),
452 }
453 }
454}
455
456#[async_trait]
457impl Processor<State> for ToolSignalProcessor {
458 async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
459 if let Event::Request { request: req, .. } = event {
460 let tool_signal = ToolSignals::from_request_with_semantics(
461 req,
462 Some(self.recent_window),
463 &self.tool_semantics,
464 );
465 state.tool_signals = Some(tool_signal);
466 }
467 Ok(())
468 }
469}
470
471fn classify_tool_call(name: &str, command: Option<&str>) -> ToolSemantic {
472 classify_tool_call_with_semantics(name, command, &ToolSemantics::default())
473}
474
475fn classify_tool_call_with_semantics(
476 name: &str,
477 command: Option<&str>,
478 semantics: &ToolSemantics,
479) -> ToolSemantic {
480 let lower = name.to_lowercase();
482 if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
483 return ToolSemantic::Mutate(MutationKind::Write);
484 }
485 if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
486 return ToolSemantic::Mutate(MutationKind::Edit);
487 }
488 if READ_TOOL_NAMES.contains(&lower.as_str()) {
489 return ToolSemantic::Observe;
490 }
491 if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
492 return ToolSemantic::Plan;
493 }
494 if BASH_TOOL_NAMES.contains(&lower.as_str())
495 && let Some(cmd) = command
496 {
497 if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_write(cmd) {
499 return ToolSemantic::Mutate(MutationKind::Write);
500 }
501 if cmd.contains("python") && PYTHON_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
502 return ToolSemantic::Mutate(MutationKind::Write);
503 }
504 if shell_invokes_program(cmd, "node")
505 && JAVASCRIPT_WRITE_PATTERNS
506 .iter()
507 .any(|pattern| cmd.contains(pattern))
508 {
509 return ToolSemantic::Mutate(MutationKind::Write);
510 }
511 if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_edit(cmd) {
512 return ToolSemantic::Mutate(MutationKind::Edit);
513 }
514 if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_read(cmd) {
515 return ToolSemantic::Observe;
516 }
517 }
518 semantics.classify(name).unwrap_or(ToolSemantic::Unknown)
519}
520
521fn is_builtin_tool_name(lower: &str) -> bool {
522 WRITE_TOOL_NAMES.contains(&lower)
523 || EDIT_TOOL_NAMES.contains(&lower)
524 || READ_TOOL_NAMES.contains(&lower)
525 || PLAN_TOOL_NAMES.contains(&lower)
526 || BASH_TOOL_NAMES.contains(&lower)
527}
528
529fn shell_segments(command: &str) -> impl Iterator<Item = &str> {
533 let mut chars = command.char_indices();
534 let mut start = 0usize;
535 let mut quote = None;
536 let mut escaped = false;
537 let mut finished = false;
538
539 std::iter::from_fn(move || {
540 loop {
541 for (index, character) in chars.by_ref() {
542 if escaped {
543 escaped = false;
544 } else if character == '\\' && quote != Some('\'') {
545 escaped = true;
546 } else if quote == Some(character) {
547 quote = None;
548 } else if quote.is_none() && matches!(character, '\'' | '"') {
549 quote = Some(character);
550 } else if quote.is_none() && matches!(character, '\n' | ';' | '|' | '&') {
551 let segment = command[start..index].trim();
552 start = index + character.len_utf8();
553 if !segment.is_empty() {
554 return Some(segment);
555 }
556 }
557 }
558
559 if finished {
560 return None;
561 }
562 finished = true;
563 let segment = command[start..].trim();
564 if !segment.is_empty() {
565 return Some(segment);
566 }
567 }
568 })
569}
570
571fn shell_words(segment: &str) -> std::iter::Peekable<std::str::SplitAsciiWhitespace<'_>> {
572 let mut words = segment.split_ascii_whitespace().peekable();
573
574 if words.peek().copied() == Some("env") {
575 words.next();
576 while words.peek().is_some_and(|word| word.starts_with('-')) {
577 words.next();
578 }
579 }
580 while words
581 .peek()
582 .is_some_and(|word| word.contains('=') && !word.starts_with('='))
583 {
584 words.next();
585 }
586
587 words
588}
589
590fn program_name(word: &str) -> &str {
591 Path::new(word)
592 .file_name()
593 .and_then(|name| name.to_str())
594 .unwrap_or(word)
595}
596
597fn shell_invokes_program(command: &str, expected: &str) -> bool {
598 shell_segments(command).any(|segment| {
599 shell_words(segment)
600 .next()
601 .is_some_and(|word| program_name(word) == expected)
602 })
603}
604
605fn shell_command_is_write(command: &str) -> bool {
606 shell_segments(command).any(|segment| {
607 let mut words = shell_words(segment);
608 let Some(program) = words.next().map(program_name) else {
609 return false;
610 };
611 if matches!(program, "cp" | "mkdir" | "touch" | "install") {
612 return true;
613 }
614
615 let redirects_output = words.any(|word| matches!(word, ">" | ">>"));
616 redirects_output
617 && (matches!(program, "echo" | "printf" | "git")
618 || BASH_READ_COMMANDS.contains(&program))
619 })
620}
621
622fn shell_command_is_edit(command: &str) -> bool {
623 shell_segments(command).any(|segment| {
624 let mut words = shell_words(segment);
625 let Some(program) = words.next().map(program_name) else {
626 return false;
627 };
628 let has_arg = |arg: &str| words.clone().any(|word| word == arg);
629
630 match program {
631 "mv" | "rm" => true,
632 "perl" => words
633 .take_while(|word| word.starts_with('-'))
634 .any(|option| {
635 option
636 .trim_start_matches('-')
637 .chars()
638 .any(|flag| flag == 'i')
639 }),
640 "git" => words
641 .next()
642 .is_some_and(|subcommand| matches!(subcommand, "apply" | "am" | "restore")),
643 "gofmt" => has_arg("-w"),
644 "cargo" => words.clone().next() == Some("fmt") && !has_arg("--check"),
645 "ruff" => {
646 let subcommand = words.clone().next();
647 (subcommand == Some("format") && !has_arg("--check"))
648 || (subcommand == Some("check") && has_arg("--fix"))
649 }
650 "prettier" => has_arg("--write"),
651 "black" => !has_arg("--check"),
652 _ => {
653 (words.clone().any(|word| program_name(word) == "prettier") && has_arg("--write"))
654 || (words.clone().any(|word| program_name(word) == "ruff")
655 && ((has_arg("format") && !has_arg("--check"))
656 || (has_arg("check") && has_arg("--fix"))))
657 }
658 }
659 })
660}
661
662fn shell_command_is_read(command: &str) -> bool {
663 shell_segments(command).any(|segment| {
664 if segment == "env" {
665 return true;
666 }
667 let mut words = shell_words(segment);
668 let Some(program) = words.next().map(program_name) else {
669 return false;
670 };
671
672 if BASH_READ_COMMANDS.contains(&program) {
673 return true;
674 }
675 if program == "command" && words.next() == Some("-v") {
676 return true;
677 }
678 if program == "type" {
679 return true;
680 }
681 if program != "git" {
682 return false;
683 }
684
685 match words.next() {
686 Some("branch") => words.next().is_none_or(|arg| arg.starts_with('-')),
687 Some("remote") => words
688 .next()
689 .is_none_or(|arg| arg.starts_with('-') || arg == "get-url"),
690 Some("config") => words
691 .next()
692 .is_some_and(|arg| matches!(arg, "--get" | "--get-all" | "--list" | "-l")),
693 Some(subcommand) => GIT_READ_SUBCOMMANDS.contains(&subcommand),
694 None => false,
695 }
696 })
697}
698
699fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
706 extract_tool_signals_with_window_and_semantics(
707 request,
708 recent_window,
709 &ToolSemantics::default(),
710 )
711}
712
713fn extract_tool_signals_with_window_and_semantics(
714 request: &Request,
715 recent_window: usize,
716 semantics: &ToolSemantics,
717) -> ToolSignals {
718 let messages = &request.llm_request.messages;
722 let mut tool_texts: Vec<String> = Vec::new();
723 let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
724 let mut tool_results = 0u32;
725 let mut tool_tail_clean = false;
726 let mut error_count = 0u32;
727 let mut compacted = false;
728 let mut tool_result_count = 0usize;
729 let mut assistant_turn_count = 0usize;
730
731 for message in messages {
732 if message.role == Role::Assistant {
733 assistant_turn_count += 1;
734 }
735 for block in &message.content {
736 match block {
737 ContentBlock::ToolCall(call) => {
738 tool_calls.push(ObservedToolCall {
739 name: call.name.clone(),
740 command: command_of(&call.arguments),
741 });
742 }
743 ContentBlock::ToolResult(result) => {
744 tool_result_count += 1;
746 tool_results = tool_results.saturating_add(1);
747 let text = result
748 .content
749 .iter()
750 .filter_map(text_of)
751 .collect::<Vec<_>>()
752 .join("\n");
753 let is_error = result.is_error == Some(true)
754 || structured_error(&text)
755 || classify_text(&text).0 > 0.0;
756 tool_tail_clean = !is_error;
757 error_count = error_count.saturating_add(u32::from(is_error));
758 if !text.is_empty() {
759 tool_texts.push(text);
760 }
761 }
762 ContentBlock::Text { text } => {
766 compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
767 }
768 _ => {}
769 }
770 }
771 }
772
773 let mut signal = build_signal(
774 tool_texts,
775 tool_calls,
776 messages.len() as u32,
777 recent_window,
778 semantics,
779 );
780 signal.error_count = error_count;
781 signal.tool_results = tool_results;
782 signal.tool_tail_clean = tool_tail_clean;
783 signal.compacted = compacted;
784 signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
785 signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
786 signal
787}
788
789const COMPACTION_MARKER: &str = "session is being continued";
792
793fn command_of(arguments: &Value) -> Option<String> {
796 let decoded = arguments
798 .as_str()
799 .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
800 let object = decoded.as_ref().unwrap_or(arguments);
801
802 ["command", "cmd", "input"]
803 .iter()
804 .filter_map(|key| object.get(*key))
805 .find_map(command_text)
806}
807
808fn command_text(value: &Value) -> Option<String> {
810 match value {
811 Value::String(text) => Some(text.to_lowercase()),
812 Value::Array(parts) => {
813 let joined = parts
814 .iter()
815 .filter_map(Value::as_str)
816 .collect::<Vec<_>>()
817 .join(" ");
818 (!joined.is_empty()).then(|| joined.to_lowercase())
819 }
820 _ => None,
821 }
822}
823
824fn text_of(block: &ContentBlock) -> Option<&str> {
826 match block {
827 ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
828 _ => None,
829 }
830}
831
832fn build_signal(
833 tool_texts: Vec<String>,
834 tool_calls: Vec<ObservedToolCall>,
835 turn_depth: u32,
836 recent_window: usize,
837 semantics: &ToolSemantics,
838) -> ToolSignals {
839 let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
845 let mut severity = 0.0f32;
846 let mut failure_fingerprints = Vec::new();
847 let mut repeated_failure = false;
848 for text in &tool_texts[sev_start..] {
849 let (sev, _patterns) = classify_text(text);
850 if sev > severity {
851 severity = sev;
852 }
853 if let Some(fingerprint) = failure_fingerprint(text) {
854 repeated_failure |= failure_fingerprints.contains(&fingerprint);
855 failure_fingerprints.push(fingerprint);
856 }
857 }
858
859 let no_error_streak = compute_no_error_streak(&tool_texts);
860
861 let recent_start = tool_calls.len().saturating_sub(recent_window);
865 let mut write_count = 0u32;
866 let mut edit_count = 0u32;
867 let mut read_count = 0u32;
868 let mut todowrite_count = 0u32;
869 let mut recent_write_count = 0u32;
870 let mut recent_edit_count = 0u32;
871 let mut recent_read_count = 0u32;
872 let mut recent_todowrite_count = 0u32;
873 let mut new_count = 0u32;
874 let mut recent_new_count = 0u32;
875 let mut pure_bash_streak = 0u32;
876 let mut streak_open = true;
877 for (i, tc) in tool_calls.iter().enumerate().rev() {
878 let cat = classify_tool_call_with_semantics(&tc.name, tc.command.as_deref(), semantics);
879 if streak_open {
880 if matches!(cat, ToolSemantic::Unknown) {
881 pure_bash_streak += 1;
882 } else {
883 streak_open = false;
884 }
885 }
886 match cat {
887 ToolSemantic::Mutate(MutationKind::Write) => {
888 write_count += 1;
889 if i >= recent_start {
890 recent_write_count += 1;
891 }
892 }
893 ToolSemantic::Mutate(MutationKind::Edit) => {
894 edit_count += 1;
895 if i >= recent_start {
896 recent_edit_count += 1;
897 }
898 }
899 ToolSemantic::Observe => {
900 read_count += 1;
901 if i >= recent_start {
902 recent_read_count += 1;
903 }
904 }
905 ToolSemantic::Plan => {
906 todowrite_count += 1;
907 if i >= recent_start {
908 recent_todowrite_count += 1;
909 }
910 }
911 ToolSemantic::New => {
912 new_count += 1;
913 if i >= recent_start {
914 recent_new_count += 1;
915 }
916 }
917 ToolSemantic::Unknown => {}
918 }
919 }
920
921 let tests_passed = detect_tests_passed(&tool_texts, recent_window);
922
923 ToolSignals {
924 severity,
925 repeated_failure,
926 no_error_streak,
927 error_count: 0,
928 tool_results: 0,
929 tool_tail_clean: false,
930 edit_count,
931 write_count,
932 read_count,
933 todowrite_count,
934 recent_edit_count,
935 recent_write_count,
936 recent_read_count,
937 recent_todowrite_count,
938 new_count,
939 recent_new_count,
940 pure_bash_streak,
941 tests_passed,
942 turn_depth,
943 tool_result_count: 0,
947 assistant_turn_count: 0,
948 compacted: false,
949 }
950}
951
952fn content_to_text(content: Option<&Value>) -> Option<String> {
956 match content? {
957 Value::String(s) => Some(s.clone()),
958 Value::Array(blocks) => {
959 let parts: Vec<&str> = blocks
960 .iter()
961 .filter_map(|b| {
962 b.as_object()
963 .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
964 .and_then(|o| o.get("text"))
965 .and_then(Value::as_str)
966 })
967 .collect();
968 if parts.is_empty() {
969 None
970 } else {
971 Some(parts.join("\n"))
972 }
973 }
974 _ => None,
975 }
976}
977
978pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
982 let lower = text.to_lowercase();
983 let mut patterns = Vec::new();
984 let mut severity: f32 = 0.0;
985 for (name, sev, substrings) in ERROR_PATTERNS {
986 if substrings.iter().any(|sub| lower.contains(sub)) {
987 patterns.push(name.to_string());
988 severity = severity.max(*sev);
989 }
990 }
991 if has_nonzero_exit_status(&lower) && !patterns.iter().any(|p| p == "exit_nonzero") {
992 patterns.push("exit_nonzero".to_string());
993 severity = severity.max(SOFT);
994 }
995 for (name, matched) in [
996 ("compile_error", has_compiler_diagnostic(&lower)),
997 ("runtime_exception", has_runtime_exception(&lower)),
998 ("runtime_panic", has_runtime_panic(&lower)),
999 ("patch_error", has_patch_failure(&lower)),
1000 ] {
1001 if matched && !patterns.iter().any(|pattern| pattern == name) {
1002 patterns.push(name.to_string());
1003 severity = severity.max(HARD);
1004 }
1005 }
1006 (severity, patterns)
1007}
1008
1009fn failure_fingerprint(text: &str) -> Option<String> {
1012 let (severity, patterns) = classify_text(text);
1013 if severity < HARD {
1014 return None;
1015 }
1016
1017 let lower = text.to_lowercase();
1018 let diagnostic = lower
1019 .lines()
1020 .find(|line| is_failure_diagnostic(line))
1021 .or_else(|| lower.lines().find(|line| !line.trim().is_empty()))
1022 .unwrap_or_default();
1023 let normalized = normalize_failure_text(diagnostic);
1024 Some(format!("{}|{normalized}", patterns.join(",")))
1025}
1026
1027fn is_failure_diagnostic(line: &str) -> bool {
1028 let line = line.trim();
1029 [
1030 "error",
1031 "exception",
1032 "panic",
1033 "failed",
1034 "timed out",
1035 "timeout",
1036 "connection refused",
1037 "cannot allocate memory",
1038 "out of memory",
1039 "not found",
1040 ]
1041 .iter()
1042 .any(|marker| line.contains(marker))
1043}
1044
1045fn normalize_failure_text(text: &str) -> String {
1048 let mut normalized = String::new();
1049 for word in text.split_whitespace() {
1050 if !normalized.is_empty() {
1051 normalized.push(' ');
1052 }
1053 let mut in_digits = false;
1054 if word.starts_with('/') || word.contains("/src/") || word.contains("/tmp/") {
1055 normalized.push_str("<path>");
1056 continue;
1057 }
1058 for character in word.chars() {
1059 if character.is_ascii_digit() {
1060 if !in_digits {
1061 normalized.push('#');
1062 in_digits = true;
1063 }
1064 } else {
1065 normalized.push(character);
1066 in_digits = false;
1067 }
1068 }
1069 }
1070 normalized.chars().take(240).collect()
1071}
1072
1073fn has_compiler_diagnostic(lower: &str) -> bool {
1074 lower.lines().any(|line| {
1075 let line = line.trim_start();
1076 if matches!(
1077 line,
1078 "compilation failed" | "error: compilation failed" | "error: could not compile"
1079 ) || line.starts_with("error: could not compile ")
1080 {
1081 return true;
1082 }
1083
1084 let Some(rest) = line.strip_prefix("error[e") else {
1085 return false;
1086 };
1087 let Some((code, _)) = rest.split_once("]:") else {
1088 return false;
1089 };
1090 !code.is_empty() && code.chars().all(|character| character.is_ascii_digit())
1091 })
1092}
1093
1094fn has_runtime_exception(lower: &str) -> bool {
1095 let has_exception_line = lower.lines().any(|line| {
1096 let line = line.trim_start();
1097 [
1098 "typeerror:",
1099 "referenceerror:",
1100 "rangeerror:",
1101 "runtimeerror:",
1102 "keyerror:",
1103 "attributeerror:",
1104 ]
1105 .iter()
1106 .any(|prefix| line.starts_with(prefix))
1107 });
1108 has_exception_line && (lower.contains("\n at ") || lower.contains("\n at "))
1109}
1110
1111fn has_runtime_panic(lower: &str) -> bool {
1112 lower
1113 .lines()
1114 .any(|line| line.trim_start().starts_with("panic: runtime error:"))
1115 && (lower.contains("\ngoroutine ") || lower.contains("[signal sig"))
1116}
1117
1118fn has_patch_failure(lower: &str) -> bool {
1119 lower.lines().any(|line| {
1120 let line = line.trim_start();
1121 line.starts_with("error: patch failed:")
1122 || line.starts_with("patch failed:")
1123 || line.contains(": patch does not apply")
1124 || line.starts_with("invalid context")
1125 })
1126}
1127
1128fn has_nonzero_exit_status(lower: &str) -> bool {
1134 NONZERO_EXIT_PHRASES
1135 .iter()
1136 .any(|phrase| phrase_followed_by_nonzero_integer(lower, phrase))
1137}
1138
1139fn phrase_followed_by_nonzero_integer(lower: &str, phrase: &str) -> bool {
1141 let mut cursor = 0usize;
1142 while let Some(rel) = lower[cursor..].find(phrase) {
1143 let value_start = cursor + rel + phrase.len();
1144 let rest = lower[value_start..].trim_start_matches(|c: char| {
1145 c.is_ascii_whitespace() || matches!(c, ':' | '=' | '\'' | '"' | '`')
1146 });
1147 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1148 if !digits.is_empty() && digits.chars().any(|d| d != '0') {
1149 return true;
1150 }
1151 cursor = value_start;
1152 }
1153 false
1154}
1155
1156fn structured_error(text: &str) -> bool {
1157 serde_json::from_str::<Value>(text)
1158 .ok()
1159 .and_then(|value| value.as_object().map(|object| object.contains_key("error")))
1160 .unwrap_or(false)
1161}
1162
1163fn compute_no_error_streak(tool_texts: &[String]) -> u32 {
1164 let mut streak = 0u32;
1165 for text in tool_texts.iter().rev() {
1166 let (sev, _) = classify_text(text);
1167 if sev > 0.0 {
1168 break;
1169 }
1170 streak += 1;
1171 }
1172 streak
1173}
1174
1175fn detect_tests_passed(tool_texts: &[String], recent_window: usize) -> bool {
1176 let start = tool_texts.len().saturating_sub(recent_window.max(1));
1177 let recent = &tool_texts[start..];
1178 let after_latest_failure = recent
1179 .iter()
1180 .rposition(|text| classify_text(text).0 > 0.0)
1181 .map_or(recent, |index| &recent[index + 1..]);
1182 after_latest_failure.iter().any(|text| {
1183 let lower = text.to_lowercase();
1184 TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
1185 && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
1186 && !has_nonzero_failure_count(&lower)
1187 })
1188}
1189
1190fn has_nonzero_failure_count(lower: &str) -> bool {
1196 for kw in NUMERIC_FAILURE_KEYWORDS {
1197 let mut cursor = 0usize;
1198 while let Some(rel) = lower[cursor..].find(kw) {
1199 let kw_start = cursor + rel;
1200 let kw_end = kw_start + kw.len();
1201 let boundary_after = lower[kw_end..]
1204 .chars()
1205 .next()
1206 .is_none_or(|c| !c.is_ascii_alphanumeric());
1207 if boundary_after {
1208 let prefix = &lower[..kw_start];
1209 let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
1210 let digits_rev: String = trimmed
1211 .chars()
1212 .rev()
1213 .take_while(|c| c.is_ascii_digit())
1214 .collect();
1215 if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
1216 return true;
1217 }
1218 }
1219 cursor = kw_start + kw.len();
1220 }
1221 }
1222 false
1223}
1224
1225#[cfg(test)]
1228mod tests {
1229 use super::*;
1230 use crate::algorithms::util::stage::score_signal;
1231 use serde_json::json;
1232 use switchyard_protocol::{
1233 ContentBlock, LlmRequest, Message, Metadata, Role, ToolCall, ToolResult,
1234 };
1235
1236 fn with_messages(messages: Vec<Message>) -> Request {
1237 Request {
1238 llm_request: LlmRequest {
1239 messages,
1240 ..LlmRequest::default()
1241 },
1242 raw_request: None,
1243 metadata: None,
1244 }
1245 }
1246
1247 fn tc(name: &str) -> Message {
1249 Message {
1250 role: Role::Assistant,
1251 content: vec![ContentBlock::ToolCall(ToolCall {
1252 id: String::new(),
1253 name: name.to_string(),
1254 arguments: json!({}),
1255 })],
1256 }
1257 }
1258
1259 fn bash(command: &str) -> Message {
1261 Message {
1262 role: Role::Assistant,
1263 content: vec![ContentBlock::ToolCall(ToolCall {
1264 id: String::new(),
1265 name: "Bash".to_string(),
1266 arguments: json!({"command": command}),
1267 })],
1268 }
1269 }
1270
1271 fn tr(text: &str) -> Message {
1273 Message {
1274 role: Role::User,
1275 content: vec![ContentBlock::ToolResult(ToolResult {
1276 tool_call_id: String::new(),
1277 content: vec![ContentBlock::Text {
1278 text: text.to_string(),
1279 }],
1280 is_error: None,
1281 })],
1282 }
1283 }
1284
1285 fn tr_with_error_status(text: &str) -> Message {
1286 let mut message = tr(text);
1287 if let ContentBlock::ToolResult(result) = &mut message.content[0] {
1288 result.is_error = Some(true);
1289 }
1290 message
1291 }
1292
1293 #[test]
1294 fn clean_text_has_zero_severity() {
1295 let (sev, patterns) = classify_text("everything went fine");
1296 assert_eq!(sev, 0.0);
1297 assert!(patterns.is_empty());
1298 }
1299
1300 #[test]
1301 fn traceback_is_hard() {
1302 let (sev, patterns) = classify_text("Traceback (most recent call last):\n ValueError");
1303 assert_eq!(sev, HARD);
1304 assert!(patterns.contains(&"traceback".to_string()));
1305 }
1306
1307 #[test]
1308 fn oom_is_critical() {
1309 let (sev, _) = classify_text("Out of memory: kill process 1234");
1310 assert_eq!(sev, CRITICAL);
1311 }
1312
1313 #[test]
1314 fn connection_refused_is_hard() {
1315 let (severity, _) = classify_text("Connection refused on port 8000");
1316 assert_eq!(severity, HARD);
1317 }
1318
1319 #[test]
1320 fn repeated_failure_ignores_volatile_paths_and_numbers() {
1321 let request = with_messages(vec![
1322 tr("error[E0308]: mismatched types at /tmp/a/src/lib.rs:12"),
1323 tr("error[E0308]: mismatched types at /tmp/b/src/lib.rs:47"),
1324 ]);
1325 assert!(ToolSignals::from_request(&request, None).repeated_failure);
1326 }
1327
1328 #[test]
1329 fn different_failures_are_not_repeated() {
1330 let request = with_messages(vec![
1331 tr("error[E0308]: mismatched types"),
1332 tr("error[E0509]: cannot move out"),
1333 ]);
1334 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1335 }
1336
1337 #[test]
1338 fn one_material_failure_is_not_repeated() {
1339 let request = with_messages(vec![tr("Connection refused on port 8000")]);
1340 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1341 }
1342
1343 #[test]
1344 fn severity_is_max_across_patterns() {
1345 let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
1347 assert_eq!(sev, HARD);
1348 }
1349
1350 #[test]
1351 fn codex_process_exit_zero_stays_clean() {
1352 let (sev, patterns) =
1353 classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
1354 assert_eq!(sev, 0.0);
1355 assert!(!patterns.contains(&"exit_nonzero".to_string()));
1356 }
1357
1358 #[test]
1359 fn nonzero_exit_codes_are_soft_errors() {
1360 let cases = [
1361 "Process exited with code 1",
1362 "Process exited with code 127",
1363 "exit code: 2",
1364 "exit status 3",
1365 "exited with status 9",
1366 ];
1367 for case in cases {
1368 let (sev, patterns) = classify_text(case);
1369 assert_eq!(sev, SOFT, "expected soft severity for {case}");
1370 assert!(patterns.contains(&"exit_nonzero".to_string()));
1371 }
1372 }
1373
1374 #[test]
1375 fn partial_process_failures_are_hard_errors() {
1376 let cases = [
1377 (
1378 "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
1379 "compile_error",
1380 ),
1381 (
1382 "Process exited with code 0\nOutput:\nTypeError: value is undefined\n at main.js:1:2",
1383 "runtime_exception",
1384 ),
1385 (
1386 "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
1387 "runtime_panic",
1388 ),
1389 (
1390 "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
1391 "patch_error",
1392 ),
1393 ];
1394 for (text, expected_pattern) in cases {
1395 let (severity, patterns) = classify_text(text);
1396 assert_eq!(severity, HARD, "expected hard severity for {text}");
1397 assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
1398 }
1399 }
1400
1401 #[test]
1402 fn source_text_that_names_exceptions_stays_clean() {
1403 let text =
1404 "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
1405 assert_eq!(classify_text(text).0, 0.0);
1406 }
1407
1408 #[test]
1409 fn file_does_not_exist_is_hard() {
1410 let (sev, patterns) =
1412 classify_text("Error: File does not exist. Note: current working directory is /app.");
1413 assert_eq!(sev, HARD);
1414 assert!(patterns.contains(&"no_such_file".to_string()));
1415 }
1416
1417 #[test]
1418 fn bare_does_not_exist_stays_clean() {
1419 let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
1422 assert_eq!(sev, 0.0);
1423 }
1424
1425 #[test]
1426 fn no_error_streak_all_clean() {
1427 let texts = vec!["ok".to_string(), "all good".to_string()];
1428 assert_eq!(compute_no_error_streak(&texts), 2);
1429 }
1430
1431 #[test]
1432 fn no_error_streak_stops_at_error() {
1433 let texts = vec![
1434 "Traceback (most recent call last):".to_string(),
1435 "ok".to_string(),
1436 "ok".to_string(),
1437 ];
1438 assert_eq!(compute_no_error_streak(&texts), 2);
1439 }
1440
1441 #[test]
1442 fn tests_passed_detects_pytest_output() {
1443 assert!(detect_tests_passed(
1444 &["====== 5 passed in 0.12s ======".to_string()],
1445 DEFAULT_RECENT_WINDOW
1446 ));
1447 }
1448
1449 #[test]
1450 fn tests_passed_ignores_partial_failures() {
1451 assert!(!detect_tests_passed(
1452 &["2 failed, 5 passed in 0.56s".to_string()],
1453 DEFAULT_RECENT_WINDOW
1454 ));
1455 }
1456
1457 #[test]
1458 fn tests_passed_must_follow_the_latest_failure() {
1459 assert!(!detect_tests_passed(
1460 &[
1461 "5 passed in 0.12s".to_string(),
1462 "Traceback (most recent call last):\nValueError".to_string(),
1463 "edit applied".to_string(),
1464 ],
1465 DEFAULT_RECENT_WINDOW
1466 ));
1467 assert!(detect_tests_passed(
1468 &[
1469 "Traceback (most recent call last):\nValueError".to_string(),
1470 "5 passed in 0.12s".to_string(),
1471 ],
1472 DEFAULT_RECENT_WINDOW
1473 ));
1474 }
1475
1476 #[test]
1477 fn severity_is_windowed_over_recent_results() {
1478 let request = with_messages(vec![
1480 tr("Traceback (most recent call last):\n ValueError"),
1481 tr("ok"),
1482 tr("ok"),
1483 ]);
1484 assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
1486 assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
1488 }
1489
1490 #[test]
1491 fn extract_openai_chat_tool_results() {
1492 let request = with_messages(vec![
1493 Message::text(Role::User, "do something"),
1494 tc("Edit"),
1495 tr("Traceback (most recent call last):\n ValueError"),
1496 ]);
1497 let sig = ToolSignals::from_request(&request, None);
1498 assert_eq!(sig.severity, HARD);
1499 assert_eq!(sig.edit_count, 1);
1500 assert_eq!(sig.turn_depth, 3);
1501 }
1502
1503 #[test]
1504 fn extract_anthropic_tool_results() {
1505 let request = with_messages(vec![tr("Traceback (most recent call last):\n ValueError")]);
1506 let sig = ToolSignals::from_request(&request, None);
1507 assert_eq!(sig.severity, HARD);
1508 }
1509
1510 #[test]
1511 fn extract_responses_api_tool_results() {
1512 let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
1513 let sig = ToolSignals::from_request(&request, None);
1514 assert_eq!(sig.severity, 0.0);
1515 assert_eq!(sig.write_count, 1);
1516 }
1517
1518 #[test]
1519 fn structured_tool_errors_contribute_to_the_execution_summary() {
1520 let request = with_messages(vec![
1521 tr(r#"{"error":"record not found"}"#),
1522 tr_with_error_status("request failed"),
1523 tr("recovered"),
1524 ]);
1525 let sig = ToolSignals::from_request(&request, None);
1526 assert_eq!(sig.error_count, 2);
1527 assert!(sig.tool_tail_clean);
1528 }
1529
1530 #[test]
1531 fn conversation_counts_are_per_block_and_role_aware() {
1532 let result = |content: Vec<ContentBlock>| {
1536 ContentBlock::ToolResult(ToolResult {
1537 tool_call_id: String::new(),
1538 content,
1539 is_error: None,
1540 })
1541 };
1542 let request = with_messages(vec![
1543 Message::text(Role::User, "do something"),
1544 Message::text(Role::Assistant, "working"),
1545 Message {
1546 role: Role::User,
1547 content: vec![
1548 result(vec![ContentBlock::Text {
1549 text: "ok".to_string(),
1550 }]),
1551 result(Vec::new()),
1552 ],
1553 },
1554 tc("Bash"),
1555 ]);
1556 let sig = ToolSignals::from_request(&request, None);
1557 assert_eq!(sig.tool_result_count, 2);
1558 assert_eq!(sig.assistant_turn_count, 2);
1559 assert_eq!(sig.turn_depth, 4);
1560 }
1561
1562 #[test]
1563 fn recent_window_counts_only_last_default_window_tool_calls() {
1564 let request = with_messages(vec![
1567 tc("Write"),
1568 tr("ok"),
1569 tc("Write"),
1570 tr("ok"),
1571 tc("Write"),
1572 tr("ok"),
1573 tc("Write"),
1574 tr("ok"),
1575 tc("Write"),
1576 tr("ok"),
1577 tc("Edit"),
1578 tr("ok"),
1579 ]);
1580 let sig = ToolSignals::from_request(&request, None);
1581 assert_eq!(sig.write_count, 5);
1582 assert_eq!(sig.edit_count, 1);
1583 assert_eq!(sig.recent_write_count, 2);
1584 assert_eq!(sig.recent_edit_count, 1);
1585 }
1586
1587 #[test]
1588 fn codex_apply_patch_counts_as_an_edit() {
1589 let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
1590 let sig = ToolSignals::from_request(&request, None);
1591 assert_eq!(sig.edit_count, 1);
1592 assert_eq!(sig.recent_edit_count, 1);
1593 }
1594
1595 fn exec_command(cmd: Value) -> Message {
1596 Message {
1597 role: Role::Assistant,
1598 content: vec![ContentBlock::ToolCall(ToolCall {
1599 id: String::new(),
1600 name: "exec_command".to_string(),
1601 arguments: cmd,
1602 })],
1603 }
1604 }
1605
1606 #[test]
1607 fn codex_exec_command_is_classified() {
1608 let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
1610 let request = with_messages(vec![exec_command(args), tr("ok")]);
1611 assert_eq!(
1612 ToolSignals::from_request(&request, None).recent_edit_count,
1613 1
1614 );
1615 }
1616
1617 #[test]
1618 fn python_write_expressions_need_a_python_command() {
1619 let write = with_messages(vec![
1620 exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
1621 tr("ok"),
1622 ]);
1623 assert_eq!(
1624 ToolSignals::from_request(&write, None).recent_write_count,
1625 1
1626 );
1627
1628 let search = with_messages(vec![
1629 exec_command(json!({"cmd": "grep -R '.write(' src"})),
1630 tr("ok"),
1631 ]);
1632 assert_eq!(
1633 ToolSignals::from_request(&search, None).recent_write_count,
1634 0
1635 );
1636 }
1637
1638 #[test]
1639 fn recent_window_size_is_caller_overridable() {
1640 let request = with_messages(vec![
1644 tc("Write"),
1645 tr("ok"),
1646 tc("Write"),
1647 tr("ok"),
1648 tc("Write"),
1649 tr("ok"),
1650 tc("Write"),
1651 tr("ok"),
1652 tc("Write"),
1653 tr("ok"),
1654 tc("Edit"),
1655 tr("ok"),
1656 ]);
1657 let narrow = extract_tool_signals_with_window(&request, 3);
1658 assert_eq!(narrow.recent_write_count, 2);
1659 assert_eq!(narrow.recent_edit_count, 1);
1660
1661 let wide = extract_tool_signals_with_window(&request, 6);
1662 assert_eq!(wide.recent_write_count, 5);
1663 assert_eq!(wide.recent_edit_count, 1);
1664 }
1665
1666 #[test]
1667 fn compaction_marker_sets_compacted() {
1668 let request = with_messages(vec![
1670 Message::text(
1671 Role::User,
1672 "This session is being continued from a previous conversation that ran out of context.",
1673 ),
1674 bash("ls"),
1675 ]);
1676 assert!(ToolSignals::from_request(&request, None).compacted);
1677 }
1678
1679 #[test]
1680 fn codex_compaction_metadata_stays_on_parent_route() {
1681 let mut request = with_messages(vec![bash("ls")]);
1682 request.metadata = Some(Metadata {
1683 is_subagent: true,
1684 agent_kind: Some("compact".to_string()),
1685 ..Default::default()
1686 });
1687 assert!(!ToolSignals::from_request(&request, None).compacted);
1688 }
1689
1690 #[test]
1691 fn no_compaction_marker_stays_uncompacted() {
1692 let request = with_messages(vec![
1693 Message::text(Role::User, "Write a script that parses the log file."),
1694 bash("ls"),
1695 ]);
1696 assert!(!ToolSignals::from_request(&request, None).compacted);
1697 }
1698
1699 #[test]
1700 fn bash_heredoc_counts_as_write() {
1701 let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
1703 let sig = ToolSignals::from_request(&request, None);
1704 assert_eq!(
1705 sig.write_count, 1,
1706 "Bash heredoc should bucket into write_count"
1707 );
1708 assert_eq!(sig.edit_count, 0);
1709 }
1710
1711 #[test]
1712 fn bash_sed_inplace_counts_as_edit() {
1713 let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
1714 let sig = ToolSignals::from_request(&request, None);
1715 assert_eq!(
1716 sig.edit_count, 1,
1717 "Bash sed -i should bucket into edit_count"
1718 );
1719 assert_eq!(sig.write_count, 0);
1720 }
1721
1722 #[test]
1723 fn bash_non_mutating_does_not_count() {
1724 let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
1726 let sig = ToolSignals::from_request(&request, None);
1727 assert_eq!(sig.write_count, 0);
1728 assert_eq!(sig.edit_count, 0);
1729 }
1730
1731 #[test]
1732 fn tests_passed_detects_pytest_with_failure_block() {
1733 assert!(!detect_tests_passed(
1735 &["2 failed, 5 passed in 0.56s".to_string()],
1736 DEFAULT_RECENT_WINDOW
1737 ));
1738 }
1739
1740 #[test]
1741 fn tests_passed_accepts_cargo_clean_summary() {
1742 assert!(detect_tests_passed(
1745 &["running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string()],
1746 DEFAULT_RECENT_WINDOW
1747 ));
1748 }
1749
1750 #[test]
1751 fn tests_passed_rejects_cargo_real_failure() {
1752 assert!(!detect_tests_passed(
1754 &["running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string()],
1755 DEFAULT_RECENT_WINDOW
1756 ));
1757 }
1758
1759 #[test]
1760 fn tests_passed_accepts_go_clean_summary() {
1761 assert!(detect_tests_passed(
1763 &["ok github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string()],
1764 DEFAULT_RECENT_WINDOW
1765 ));
1766 }
1767
1768 #[test]
1769 fn tests_passed_accepts_pytest_zero_errors() {
1770 assert!(detect_tests_passed(
1772 &["5 passed, 0 errors in 0.30s".to_string()],
1773 DEFAULT_RECENT_WINDOW
1774 ));
1775 }
1776
1777 #[test]
1778 fn tests_passed_detects_diy_checkmark() {
1779 assert!(detect_tests_passed(
1780 &["✓ all checks passed".to_string()],
1781 DEFAULT_RECENT_WINDOW
1782 ));
1783 }
1784
1785 #[test]
1786 fn anthropic_bash_heredoc_extracts_command() {
1787 let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
1789 let sig = ToolSignals::from_request(&request, None);
1790 assert_eq!(
1791 sig.write_count, 1,
1792 "Anthropic Bash heredoc must also be detected"
1793 );
1794 }
1795
1796 #[test]
1797 fn recent_window_falls_back_to_full_history_when_short() {
1798 let request = with_messages(vec![tc("Write")]);
1799 let sig = ToolSignals::from_request(&request, None);
1800 assert_eq!(sig.recent_write_count, 1);
1801 assert_eq!(sig.recent_edit_count, 0);
1802 }
1803
1804 #[test]
1805 fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
1806 let request = with_messages(vec![tr("output ok"), tr("another ok")]);
1807 let sig = ToolSignals::from_request(&request, None);
1808 assert_eq!(sig.severity, 0.0);
1809 assert_eq!(sig.no_error_streak, 2);
1810 }
1811
1812 #[test]
1815 fn todowrite_classifies_as_plan() {
1816 assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
1817 assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
1818 }
1819
1820 #[test]
1821 fn codex_update_plan_classifies_as_plan() {
1822 assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
1823 }
1824
1825 #[test]
1826 fn codex_shell_command_runs_bash_pattern_match() {
1827 assert_eq!(
1829 classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
1830 ToolSemantic::Mutate(MutationKind::Write),
1831 );
1832 assert_eq!(
1834 classify_tool_call("shell_command", Some("ls /app")),
1835 ToolSemantic::Observe,
1836 );
1837 assert_eq!(
1839 classify_tool_call("shell_command", Some("./run_tests.sh")),
1840 ToolSemantic::Unknown,
1841 );
1842 }
1843
1844 #[test]
1845 fn read_tool_classifies_as_read() {
1846 assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
1847 assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
1848 }
1849
1850 #[test]
1851 fn hermes_tool_names_classify() {
1852 assert_eq!(
1854 classify_tool_call("write_file", None),
1855 ToolSemantic::Mutate(MutationKind::Write)
1856 );
1857 assert_eq!(
1858 classify_tool_call("patch", None),
1859 ToolSemantic::Mutate(MutationKind::Edit)
1860 );
1861 assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
1862 assert_eq!(
1863 classify_tool_call("search_files", None),
1864 ToolSemantic::Observe
1865 );
1866 assert_eq!(
1869 classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
1870 ToolSemantic::Mutate(MutationKind::Edit),
1871 );
1872 assert_eq!(
1873 classify_tool_call("terminal", Some("grep foo /app")),
1874 ToolSemantic::Observe,
1875 );
1876 assert_eq!(
1877 classify_tool_call("terminal", Some("./run_tests.sh")),
1878 ToolSemantic::Unknown,
1879 );
1880 }
1881
1882 #[test]
1883 fn bash_read_patterns_classify_as_read() {
1884 let cases = [
1885 "cat /etc/passwd",
1886 "grep foo bar.txt",
1887 "ls /app",
1888 "find . -name '*.py'",
1889 ];
1890 for cmd in cases {
1891 assert_eq!(
1892 classify_tool_call("Bash", Some(cmd)),
1893 ToolSemantic::Observe,
1894 "expected Read for {cmd}"
1895 );
1896 }
1897 }
1898
1899 #[test]
1900 fn codex_inspection_commands_classify_as_read() {
1901 let cases = [
1902 "sed -n '1,80p' src/lib.rs",
1903 "rg -n 'needle' src",
1904 "nl -ba src/lib.rs",
1905 "cat package.json",
1906 "jq '.scripts' package.json",
1907 "git status --short",
1908 "git log --oneline -5",
1909 "git show HEAD:src/lib.rs",
1910 "git branch --show-current",
1911 "git remote -v",
1912 "git config --get remote.origin.url",
1913 ];
1914 for command in cases {
1915 assert_eq!(
1916 classify_tool_call("exec_command", Some(command)),
1917 ToolSemantic::Observe,
1918 "expected Read for {command}"
1919 );
1920 }
1921 }
1922
1923 #[test]
1924 fn quoted_shell_separators_do_not_create_commands() {
1925 for command in ["rg 'foo|rm obsolete.rs'", "rg \"foo; rm obsolete.rs\""] {
1926 assert_eq!(
1927 classify_tool_call("exec_command", Some(command)),
1928 ToolSemantic::Observe,
1929 "quoted text must not be parsed as a command: {command}"
1930 );
1931 }
1932 }
1933
1934 #[test]
1935 fn codex_shell_mutations_classify_as_production() {
1936 let writes = [
1937 "cp source.rs destination.rs",
1938 "mkdir -p src/generated",
1939 "touch src/generated/mod.rs",
1940 "git show HEAD:file.rs > file.rs",
1941 "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
1942 ];
1943 for command in writes {
1944 assert_eq!(
1945 classify_tool_call("exec_command", Some(command)),
1946 ToolSemantic::Mutate(MutationKind::Write),
1947 "expected Write for {command}"
1948 );
1949 }
1950
1951 let edits = [
1952 "mv old.rs new.rs",
1953 "rm obsolete.rs",
1954 "gofmt -w main.go",
1955 "cargo fmt",
1956 "ruff check --fix src",
1957 "perl -0pi -e 's/old/new/' src/lib.rs",
1958 "npx prettier --write src/lib.ts",
1959 "uv run ruff format src",
1960 "git apply fix.patch",
1961 ];
1962 for command in edits {
1963 assert_eq!(
1964 classify_tool_call("exec_command", Some(command)),
1965 ToolSemantic::Mutate(MutationKind::Edit),
1966 "expected Edit for {command}"
1967 );
1968 }
1969 }
1970
1971 #[test]
1972 fn formatter_checks_are_not_edits() {
1973 for command in [
1974 "cargo fmt --check",
1975 "ruff format --check src",
1976 "black --check src",
1977 ] {
1978 assert_ne!(
1979 classify_tool_call("exec_command", Some(command)),
1980 ToolSemantic::Mutate(MutationKind::Edit),
1981 "read-only formatter check must not be Edit: {command}"
1982 );
1983 }
1984 }
1985
1986 #[test]
1987 fn embedded_comparison_is_not_a_shell_write() {
1988 let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
1989 assert_eq!(
1990 classify_tool_call("exec_command", Some(command)),
1991 ToolSemantic::Unknown
1992 );
1993 }
1994
1995 #[test]
1996 fn bash_write_precedence_over_read() {
1997 assert_eq!(
2000 classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
2001 ToolSemantic::Mutate(MutationKind::Write),
2002 );
2003 }
2004
2005 #[test]
2006 fn pure_bash_streak_counts_trailing_other() {
2007 let request = with_messages(vec![
2009 bash("make"),
2010 tr("ok"),
2011 bash("./configure"),
2012 tr("ok"),
2013 bash("make install"),
2014 tr("ok"),
2015 bash("./run.sh"),
2016 tr("ok"),
2017 bash("./test"),
2018 tr("ok"),
2019 ]);
2020 let sig = ToolSignals::from_request(&request, None);
2021 assert_eq!(sig.pure_bash_streak, 5);
2022 assert_eq!(sig.write_count, 0);
2023 assert_eq!(sig.read_count, 0);
2024 }
2025
2026 #[test]
2027 fn pure_bash_streak_resets_on_write() {
2028 let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
2029 let sig = ToolSignals::from_request(&request, None);
2030 assert_eq!(sig.pure_bash_streak, 0);
2031 assert_eq!(sig.write_count, 1);
2032 }
2033
2034 #[test]
2035 fn recent_window_tracks_todowrite_and_read() {
2036 let request = with_messages(vec![
2038 bash("make"),
2039 tr("ok"),
2040 tc("TodoWrite"),
2041 tr("ok"),
2042 tc("Read"),
2043 tr("ok"),
2044 tc("TodoWrite"),
2045 tr("ok"),
2046 ]);
2047 let sig = ToolSignals::from_request(&request, None);
2048 assert_eq!(sig.todowrite_count, 2);
2049 assert_eq!(sig.recent_todowrite_count, 2);
2050 assert_eq!(sig.read_count, 1);
2051 assert_eq!(sig.recent_read_count, 1);
2052 }
2053
2054 #[test]
2055 fn configured_tool_semantics_extend_the_builtin_vocabulary() {
2056 let semantics = ToolSemantics {
2057 observe: vec!["KB_search".to_string()],
2058 mutate: vec!["send_payment_request".to_string()],
2059 plan: vec!["create_research_plan".to_string()],
2060 new: vec!["send_message_to_user".to_string()],
2061 };
2062 semantics.validate().expect("valid additive semantics");
2063 let request = with_messages(vec![
2064 tc("Read"),
2065 tc("Write"),
2066 tc("TodoWrite"),
2067 tc("kb_SEARCH"),
2068 tc("send_payment_request"),
2069 tc("create_research_plan"),
2070 tc("send_message_to_user"),
2071 tc("unlisted_tool"),
2072 ]);
2073
2074 let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
2075
2076 assert_eq!(signal.read_count, 2);
2077 assert_eq!(signal.write_count, 2);
2078 assert_eq!(signal.todowrite_count, 2);
2079 assert_eq!(signal.new_count, 1);
2080 assert_eq!(signal.recent_new_count, 1);
2081 assert_eq!(signal.pure_bash_streak, 1);
2082 }
2083
2084 #[test]
2085 fn configured_tool_semantics_only_fold_ascii_case() {
2086 let semantics = ToolSemantics {
2087 observe: vec!["kb_search".to_string()],
2088 ..Default::default()
2089 };
2090
2091 assert_eq!(
2092 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2093 ToolSemantic::Observe
2094 );
2095 assert_eq!(
2098 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2099 ToolSemantic::Unknown
2100 );
2101 }
2102
2103 #[test]
2104 fn custom_semantics_preserve_builtin_unicode_lowercasing() {
2105 let semantics = ToolSemantics {
2106 observe: vec!["lookup_customer".to_string()],
2107 ..Default::default()
2108 };
2109
2110 assert_eq!(
2112 classify_tool_call_with_semantics("notebooKedit", None, &semantics),
2113 ToolSemantic::Mutate(MutationKind::Edit)
2114 );
2115 }
2116
2117 #[test]
2118 fn configured_semantics_never_replace_builtin_classifications() {
2119 let semantics = ToolSemantics {
2120 observe: vec!["lookup_customer".to_string()],
2121 mutate: vec!["send_payment".to_string()],
2122 plan: vec!["create_workflow".to_string()],
2123 new: vec!["send_message".to_string()],
2124 };
2125
2126 for name in WRITE_TOOL_NAMES {
2127 assert_eq!(
2128 classify_tool_call_with_semantics(name, None, &semantics),
2129 ToolSemantic::Mutate(MutationKind::Write),
2130 "write tool {name:?} changed classification"
2131 );
2132 }
2133 for name in EDIT_TOOL_NAMES {
2134 assert_eq!(
2135 classify_tool_call_with_semantics(name, None, &semantics),
2136 ToolSemantic::Mutate(MutationKind::Edit),
2137 "edit tool {name:?} changed classification"
2138 );
2139 }
2140 for name in READ_TOOL_NAMES {
2141 assert_eq!(
2142 classify_tool_call_with_semantics(name, None, &semantics),
2143 ToolSemantic::Observe,
2144 "read tool {name:?} changed classification"
2145 );
2146 }
2147 for name in PLAN_TOOL_NAMES {
2148 assert_eq!(
2149 classify_tool_call_with_semantics(name, None, &semantics),
2150 ToolSemantic::Plan,
2151 "plan tool {name:?} changed classification"
2152 );
2153 }
2154
2155 for (command, expected) in [
2156 ("cat /tmp/input", ToolSemantic::Observe),
2157 (
2158 "cat /tmp/input > /tmp/output",
2159 ToolSemantic::Mutate(MutationKind::Write),
2160 ),
2161 (
2162 "sed -i 's/a/b/' /tmp/file",
2163 ToolSemantic::Mutate(MutationKind::Edit),
2164 ),
2165 ("./run_tests.sh", ToolSemantic::Unknown),
2166 ] {
2167 assert_eq!(
2168 classify_tool_call_with_semantics("BASH", Some(command), &semantics),
2169 expected,
2170 "bash command {command:?} changed classification"
2171 );
2172 }
2173 }
2174
2175 #[test]
2176 fn configured_semantics_score_like_their_builtin_equivalents() {
2177 let semantics = ToolSemantics {
2178 observe: vec!["lookup_customer".to_string()],
2179 mutate: vec!["send_payment".to_string()],
2180 plan: vec!["create_workflow".to_string()],
2181 ..Default::default()
2182 };
2183
2184 for (builtin, configured) in [
2185 ("Read", "lookup_customer"),
2186 ("Write", "send_payment"),
2187 ("TodoWrite", "create_workflow"),
2188 ] {
2189 let messages_before_tool = || {
2190 vec![
2191 Message::text(Role::User, "start"),
2192 Message::text(Role::Assistant, "working"),
2193 Message::text(Role::User, "continue"),
2194 Message::text(Role::Assistant, "working"),
2195 Message::text(Role::User, "continue"),
2196 Message::text(Role::Assistant, "working"),
2197 Message::text(Role::User, "continue"),
2198 ]
2199 };
2200 let mut builtin_messages = messages_before_tool();
2201 builtin_messages.push(tc(builtin));
2202 let mut configured_messages = messages_before_tool();
2203 configured_messages.push(tc(configured));
2204
2205 let builtin_score = score_signal(&ToolSignals::from_request(
2206 &with_messages(builtin_messages),
2207 None,
2208 ));
2209 let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
2210 &with_messages(configured_messages),
2211 None,
2212 &semantics,
2213 ));
2214
2215 assert_ne!(
2216 builtin_score.score, 0.0,
2217 "the {builtin:?} control must exercise a scoring dimension"
2218 );
2219 assert_eq!(
2220 configured_score, builtin_score,
2221 "configured tool {configured:?} must score exactly like {builtin:?}"
2222 );
2223 }
2224 }
2225
2226 #[test]
2227 fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
2228 let duplicate = ToolSemantics {
2229 observe: vec!["lookup".to_string()],
2230 mutate: vec!["LOOKUP".to_string()],
2231 ..Default::default()
2232 };
2233 assert!(
2234 duplicate
2235 .validate()
2236 .expect_err("duplicate should fail")
2237 .to_string()
2238 .contains("appears in both")
2239 );
2240
2241 let builtin = ToolSemantics {
2242 new: vec!["write_file".to_string()],
2243 ..Default::default()
2244 };
2245 assert!(
2246 builtin
2247 .validate()
2248 .expect_err("built-in should fail")
2249 .to_string()
2250 .contains("built-in semantics")
2251 );
2252
2253 let empty = ToolSemantics {
2254 observe: vec![" \t".to_string()],
2255 ..Default::default()
2256 };
2257 assert!(
2258 empty
2259 .validate()
2260 .expect_err("empty name should fail")
2261 .to_string()
2262 .contains("empty tool name")
2263 );
2264 }
2265}