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