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::codex_namespaces::{split_qualified_name, tool_namespaces};
21use switchyard_protocol::{ContentBlock, Request, Role, WireFormat};
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 EDITOR_TOOL_NAMES: &[&str] = &["str_replace_based_edit_tool", "text_editor"];
112
113static WRITE_TOOL_NAMES: &[&str] = &["write", "create_file", "new_file", "write_file"];
114
115static BASH_WRITE_PATTERNS: &[&str] = &[
119 "cat >",
120 "cat >>",
121 "echo >",
122 "echo >>",
123 "tee ",
124 "printf >",
125 "printf >>",
126 "> /",
127 ">> /",
128 "<< 'eof'",
129 "<<eof",
130 "<<'eof'",
131 "<< eof",
132];
133
134static PYTHON_WRITE_PATTERNS: &[&str] = &["write_text(", "writelines(", ".write("];
137
138static JAVASCRIPT_WRITE_PATTERNS: &[&str] = &[
139 "writefilesync(",
140 "writefile(",
141 "appendfilesync(",
142 "appendfile(",
143];
144
145static BASH_EDIT_PATTERNS: &[&str] = &[
146 "sed -i",
147 "sed --in-place",
148 "awk -i inplace",
149 "awk 'inplace=1'",
150 "patch ",
151 "patch -p",
152 "perl -i",
153 "perl -p -i",
154 "perl -pi",
155];
156
157static BASH_READ_PATTERNS: &[&str] = &[
160 "cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
161 "diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
162];
163
164static BASH_READ_COMMANDS: &[&str] = &[
167 "cat", "rg", "nl", "jq", "pwd", "tree", "sed", "grep", "ls", "find", "head", "tail", "wc",
168 "diff", "which", "ps", "df", "du", "stat", "file", "less", "more", "readlink", "realpath",
169 "basename", "dirname", "printenv",
170];
171
172static GIT_READ_SUBCOMMANDS: &[&str] = &[
173 "status",
174 "diff",
175 "log",
176 "show",
177 "show-ref",
178 "rev-parse",
179 "ls-files",
180 "ls-remote",
181 "ls-tree",
182 "grep",
183 "blame",
184 "merge-base",
185 "check-ignore",
186 "tag",
187];
188
189static READ_TOOL_NAMES: &[&str] = &[
190 "read",
191 "view",
192 "read_file",
193 "search_files",
194 "glob",
195 "grep",
196 "find",
197 "ls",
198];
199
200static PLAN_TOOL_NAMES: &[&str] = &[
203 "todowrite",
204 "todo_write",
205 "todo",
206 "update_plan",
207 "todo_list",
208];
209
210static BASH_TOOL_NAMES: &[&str] = &[
215 "bash",
216 "shell_command",
217 "shell",
218 "local_shell_call",
219 "terminal",
220 "exec_command", "exec", "powershell", ];
224
225static TEST_PASS_PHRASES: &[&str] = &[
228 " passed",
229 "passed in",
230 "tests passed",
231 "all tests passed",
232 "test ok",
233 "test result: ok",
234 "passed.\n",
235 "tests pass",
236 "\nok ", "✓ ",
238];
239
240static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
245
246static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
250
251pub const DEFAULT_RECENT_WINDOW: usize = 3;
258
259#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
265#[serde(default, deny_unknown_fields)]
266pub struct ToolSemantics {
267 pub observe: Vec<String>,
269 pub mutate: Vec<String>,
271 pub plan: Vec<String>,
273 pub new: Vec<String>,
275}
276
277impl ToolSemantics {
278 pub fn validate(&self) -> Result<()> {
280 let mut seen: Vec<(String, &'static str)> = Vec::new();
281 for (category, names) in [
282 ("observe", &self.observe),
283 ("mutate", &self.mutate),
284 ("plan", &self.plan),
285 ("new", &self.new),
286 ] {
287 for name in names {
288 if name.trim().is_empty() {
289 return Err(tool_semantics_error(format!(
290 "tool_semantics.{category} contains an empty tool name"
291 )));
292 }
293 let normalized = name.to_ascii_lowercase();
294 if is_builtin_tool_name(&name.to_lowercase()) {
295 return Err(tool_semantics_error(format!(
296 "tool {name:?} already has built-in semantics and cannot be reclassified"
297 )));
298 }
299 if let Some((_, previous)) = seen.iter().find(|(seen, _)| seen == &normalized) {
300 return Err(tool_semantics_error(format!(
301 "tool {name:?} appears in both tool_semantics.{previous} and tool_semantics.{category}"
302 )));
303 }
304 seen.push((normalized, category));
305 }
306 }
307 Ok(())
308 }
309
310 fn classify(&self, name: &str) -> Option<ToolSemantic> {
311 if contains_name(&self.observe, name) {
312 Some(ToolSemantic::Observe)
313 } else if contains_name(&self.mutate, name) {
314 Some(ToolSemantic::Mutate(MutationKind::Write))
317 } else if contains_name(&self.plan, name) {
318 Some(ToolSemantic::Plan)
319 } else if contains_name(&self.new, name) {
320 Some(ToolSemantic::New)
321 } else {
322 None
323 }
324 }
325}
326
327fn contains_name(names: &[String], candidate: &str) -> bool {
328 names
329 .iter()
330 .any(|name| name.eq_ignore_ascii_case(candidate))
331}
332
333fn tool_semantics_error(message: String) -> LibsyError {
334 LibsyError::AlgorithmError { message }
335}
336
337#[derive(Clone, Debug, Default)]
346pub struct ToolSignals {
347 pub severity: f32,
352 pub repeated_failure: bool,
355 pub no_error_streak: u32,
357 pub edit_count: u32,
359 pub write_count: u32,
361 pub read_count: u32,
363 pub todowrite_count: u32,
366 pub recent_edit_count: u32,
368 pub recent_write_count: u32,
370 pub recent_read_count: u32,
372 pub recent_todowrite_count: u32,
374 pub new_count: u32,
376 pub recent_new_count: u32,
378 pub pure_bash_streak: u32,
381 pub tests_passed: bool,
383 pub tool_result_count: u32,
386 pub assistant_turn_count: u32,
389 pub turn_depth: u32,
393 pub compacted: bool,
399}
400
401impl ToolSignals {
402 pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
407 Self::from_request_with_semantics(request, window_size, &ToolSemantics::default())
408 }
409
410 pub fn from_request_with_semantics(
412 request: &Request,
413 window_size: Option<usize>,
414 semantics: &ToolSemantics,
415 ) -> Self {
416 extract_tool_signals_with_window_and_semantics(
417 request,
418 window_size.unwrap_or(DEFAULT_RECENT_WINDOW),
419 semantics,
420 )
421 }
422}
423
424#[derive(Debug, Clone)]
427struct ObservedToolCall<'a> {
428 name: String,
429 bare_name: Option<&'a str>,
430 command: Option<String>,
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
434enum MutationKind {
435 Write,
436 Edit,
437}
438
439#[derive(Debug, Clone, Copy, PartialEq, Eq)]
441enum ToolSemantic {
442 Mutate(MutationKind),
443 Observe,
444 Plan,
445 New,
446 Unknown,
447}
448
449#[derive(Debug, Clone)]
452pub struct ToolSignalProcessor {
453 pub recent_window: usize,
456 pub tool_semantics: ToolSemantics,
458}
459
460impl Default for ToolSignalProcessor {
461 fn default() -> Self {
462 Self {
463 recent_window: DEFAULT_RECENT_WINDOW,
464 tool_semantics: ToolSemantics::default(),
465 }
466 }
467}
468
469#[async_trait]
470impl Processor<State> for ToolSignalProcessor {
471 async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
472 if let Event::Request { request: req, .. } = event {
473 let tool_signal = ToolSignals::from_request_with_semantics(
474 req,
475 Some(self.recent_window),
476 &self.tool_semantics,
477 );
478 state.tool_signals = Some(tool_signal);
479 }
480 Ok(())
481 }
482}
483
484fn classify_tool_call(name: &str, command: Option<&str>) -> ToolSemantic {
485 classify_tool_call_with_semantics(name, command, &ToolSemantics::default())
486}
487
488fn classify_tool_call_with_semantics(
489 name: &str,
490 command: Option<&str>,
491 semantics: &ToolSemantics,
492) -> ToolSemantic {
493 let lower = name.to_lowercase();
495 if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
496 return ToolSemantic::Mutate(MutationKind::Write);
497 }
498 if EDITOR_TOOL_NAMES.contains(&lower.as_str()) && command == Some("view") {
499 return ToolSemantic::Observe;
500 }
501 if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
502 return ToolSemantic::Mutate(MutationKind::Edit);
503 }
504 if READ_TOOL_NAMES.contains(&lower.as_str()) {
505 return ToolSemantic::Observe;
506 }
507 if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
508 return ToolSemantic::Plan;
509 }
510 if BASH_TOOL_NAMES.contains(&lower.as_str())
511 && let Some(cmd) = command
512 {
513 if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_write(cmd) {
515 return ToolSemantic::Mutate(MutationKind::Write);
516 }
517 if cmd.contains("python") && PYTHON_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
518 return ToolSemantic::Mutate(MutationKind::Write);
519 }
520 if shell_invokes_program(cmd, "node")
521 && JAVASCRIPT_WRITE_PATTERNS
522 .iter()
523 .any(|pattern| cmd.contains(pattern))
524 {
525 return ToolSemantic::Mutate(MutationKind::Write);
526 }
527 if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_edit(cmd) {
528 return ToolSemantic::Mutate(MutationKind::Edit);
529 }
530 if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) || shell_command_is_read(cmd) {
531 return ToolSemantic::Observe;
532 }
533 }
534 semantics.classify(name).unwrap_or(ToolSemantic::Unknown)
535}
536
537fn is_builtin_tool_name(lower: &str) -> bool {
538 WRITE_TOOL_NAMES.contains(&lower)
539 || EDIT_TOOL_NAMES.contains(&lower)
540 || READ_TOOL_NAMES.contains(&lower)
541 || PLAN_TOOL_NAMES.contains(&lower)
542 || BASH_TOOL_NAMES.contains(&lower)
543}
544
545fn shell_segments(command: &str) -> impl Iterator<Item = &str> {
549 let mut chars = command.char_indices();
550 let mut start = 0usize;
551 let mut quote = None;
552 let mut escaped = false;
553 let mut finished = false;
554
555 std::iter::from_fn(move || {
556 loop {
557 for (index, character) in chars.by_ref() {
558 if escaped {
559 escaped = false;
560 } else if character == '\\' && quote != Some('\'') {
561 escaped = true;
562 } else if quote == Some(character) {
563 quote = None;
564 } else if quote.is_none() && matches!(character, '\'' | '"') {
565 quote = Some(character);
566 } else if quote.is_none() && matches!(character, '\n' | ';' | '|' | '&') {
567 let segment = command[start..index].trim();
568 start = index + character.len_utf8();
569 if !segment.is_empty() {
570 return Some(segment);
571 }
572 }
573 }
574
575 if finished {
576 return None;
577 }
578 finished = true;
579 let segment = command[start..].trim();
580 if !segment.is_empty() {
581 return Some(segment);
582 }
583 }
584 })
585}
586
587fn shell_words(segment: &str) -> std::iter::Peekable<std::str::SplitAsciiWhitespace<'_>> {
588 let mut words = segment.split_ascii_whitespace().peekable();
589
590 if words.peek().copied() == Some("env") {
591 words.next();
592 while words.peek().is_some_and(|word| word.starts_with('-')) {
593 words.next();
594 }
595 }
596 while words
597 .peek()
598 .is_some_and(|word| word.contains('=') && !word.starts_with('='))
599 {
600 words.next();
601 }
602
603 words
604}
605
606fn program_name(word: &str) -> &str {
607 Path::new(word)
608 .file_name()
609 .and_then(|name| name.to_str())
610 .unwrap_or(word)
611}
612
613fn shell_invokes_program(command: &str, expected: &str) -> bool {
614 shell_segments(command).any(|segment| {
615 shell_words(segment)
616 .next()
617 .is_some_and(|word| program_name(word) == expected)
618 })
619}
620
621fn shell_command_is_write(command: &str) -> bool {
622 shell_segments(command).any(|segment| {
623 let mut words = shell_words(segment);
624 let Some(program) = words.next().map(program_name) else {
625 return false;
626 };
627 if matches!(program, "cp" | "mkdir" | "touch" | "install") {
628 return true;
629 }
630
631 let redirects_output = words.any(|word| matches!(word, ">" | ">>"));
632 redirects_output
633 && (matches!(program, "echo" | "printf" | "git")
634 || BASH_READ_COMMANDS.contains(&program))
635 })
636}
637
638fn shell_command_is_edit(command: &str) -> bool {
639 shell_segments(command).any(|segment| {
640 let mut words = shell_words(segment);
641 let Some(program) = words.next().map(program_name) else {
642 return false;
643 };
644 let has_arg = |arg: &str| words.clone().any(|word| word == arg);
645
646 match program {
647 "mv" | "rm" => true,
648 "perl" => words
649 .take_while(|word| word.starts_with('-'))
650 .any(|option| {
651 option
652 .trim_start_matches('-')
653 .chars()
654 .any(|flag| flag == 'i')
655 }),
656 "git" => words
657 .next()
658 .is_some_and(|subcommand| matches!(subcommand, "apply" | "am" | "restore")),
659 "gofmt" => has_arg("-w"),
660 "cargo" => words.clone().next() == Some("fmt") && !has_arg("--check"),
661 "ruff" => {
662 let subcommand = words.clone().next();
663 (subcommand == Some("format") && !has_arg("--check"))
664 || (subcommand == Some("check") && has_arg("--fix"))
665 }
666 "prettier" => has_arg("--write"),
667 "black" => !has_arg("--check"),
668 _ => {
669 (words.clone().any(|word| program_name(word) == "prettier") && has_arg("--write"))
670 || (words.clone().any(|word| program_name(word) == "ruff")
671 && ((has_arg("format") && !has_arg("--check"))
672 || (has_arg("check") && has_arg("--fix"))))
673 }
674 }
675 })
676}
677
678fn shell_command_is_read(command: &str) -> bool {
679 shell_segments(command).any(|segment| {
680 if segment == "env" {
681 return true;
682 }
683 let mut words = shell_words(segment);
684 let Some(program) = words.next().map(program_name) else {
685 return false;
686 };
687
688 if BASH_READ_COMMANDS.contains(&program) {
689 return true;
690 }
691 if program == "command" && words.next() == Some("-v") {
692 return true;
693 }
694 if program == "type" {
695 return true;
696 }
697 if program != "git" {
698 return false;
699 }
700
701 match words.next() {
702 Some("branch") => words.next().is_none_or(|arg| arg.starts_with('-')),
703 Some("remote") => words
704 .next()
705 .is_none_or(|arg| arg.starts_with('-') || arg == "get-url"),
706 Some("config") => words
707 .next()
708 .is_some_and(|arg| matches!(arg, "--get" | "--get-all" | "--list" | "-l")),
709 Some(subcommand) => GIT_READ_SUBCOMMANDS.contains(&subcommand),
710 None => false,
711 }
712 })
713}
714
715fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
722 extract_tool_signals_with_window_and_semantics(
723 request,
724 recent_window,
725 &ToolSemantics::default(),
726 )
727}
728
729fn extract_tool_signals_with_window_and_semantics(
730 request: &Request,
731 recent_window: usize,
732 semantics: &ToolSemantics,
733) -> ToolSignals {
734 let messages = &request.llm_request.messages;
736 let namespaces = tool_namespaces(&request.llm_request.extensions);
737 let mut tool_texts: Vec<(String, bool)> = Vec::new();
738 let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
739 let mut compacted = false;
740 let mut tool_result_count = 0usize;
741 let mut assistant_turn_count = 0usize;
742
743 for message in messages {
744 if message.role == Role::Assistant {
745 assistant_turn_count += 1;
746 }
747 for block in &message.content {
748 match block {
749 ContentBlock::ToolCall(call) => {
750 let bare_name = namespaces
752 .and_then(|namespaces| split_qualified_name(namespaces, &call.name))
753 .map(|(tool, _)| tool)
754 .or_else(|| mcp_tool_name(&call.name));
755 tool_calls.push(ObservedToolCall {
756 name: call.name.clone(),
757 bare_name,
758 command: command_of(&call.arguments),
759 });
760 }
761 ContentBlock::ToolResult(result) => {
762 tool_result_count += 1;
764 let text = result
765 .content
766 .iter()
767 .filter_map(text_of)
768 .collect::<Vec<_>>()
769 .join("\n");
770 let is_error = result.is_error == Some(true);
771 if !text.is_empty() || is_error {
773 tool_texts.push((text, is_error));
774 }
775 }
776 ContentBlock::Unknown { provider, raw }
778 if provider.as_str() == WireFormat::OpenAiResponses.as_str()
779 && raw.get("type").and_then(Value::as_str)
780 == Some("apply_patch_call_output") =>
781 {
782 tool_result_count += 1;
783 let text = raw
784 .get("output")
785 .and_then(Value::as_str)
786 .unwrap_or_default();
787 let is_error = raw.get("status").and_then(Value::as_str) == Some("failed");
788 tool_texts.push((text.to_owned(), is_error));
789 }
790 ContentBlock::Unknown { provider, raw }
791 if provider.as_str() == WireFormat::OpenAiResponses.as_str()
792 && raw.get("type").and_then(Value::as_str) == Some("shell_call_output") =>
793 {
794 tool_result_count += 1;
795 let mut texts = Vec::new();
796 let mut is_error = false;
797 if let Some(outputs) = raw.get("output").and_then(Value::as_array) {
798 for output in outputs {
799 for field in ["stdout", "stderr"] {
800 if let Some(text) = output.get(field).and_then(Value::as_str)
801 && !text.is_empty()
802 {
803 texts.push(text);
804 }
805 }
806 if let Some(outcome) = output.get("outcome") {
807 is_error |= match outcome.get("type").and_then(Value::as_str) {
808 Some("timeout") => true,
809 Some("exit")
810 if outcome
811 .get("exit_code")
812 .and_then(Value::as_i64)
813 .is_some_and(|code| code != 0) =>
814 {
815 texts.push("returned non-zero");
817 output
818 .get("stderr")
819 .and_then(Value::as_str)
820 .is_some_and(|text| !text.trim().is_empty())
821 }
822 _ => false,
823 };
824 }
825 }
826 }
827 let text = texts.join("\n");
828 tool_texts.push((text, is_error));
829 }
830 ContentBlock::Text { text } => {
834 compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
835 }
836 _ => {}
837 }
838 }
839 }
840
841 let mut signal = build_signal(
842 tool_texts,
843 tool_calls,
844 messages.len() as u32,
845 recent_window,
846 semantics,
847 );
848 signal.compacted = compacted;
849 signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
850 signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
851 signal
852}
853
854const COMPACTION_MARKER: &str = "session is being continued";
857
858fn mcp_tool_name(name: &str) -> Option<&str> {
862 let (_server, tool) = name.strip_prefix("mcp__")?.split_once("__")?;
863 (!tool.is_empty()).then_some(tool)
864}
865
866fn command_of(arguments: &Value) -> Option<String> {
869 let decoded = arguments
871 .as_str()
872 .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
873 let object = decoded.as_ref().unwrap_or(arguments);
874
875 ["command", "cmd", "input"]
876 .iter()
877 .filter_map(|key| object.get(*key))
878 .find_map(command_text)
879}
880
881fn command_text(value: &Value) -> Option<String> {
883 match value {
884 Value::String(text) => Some(text.to_lowercase()),
885 Value::Array(parts) => {
886 let joined = parts
887 .iter()
888 .filter_map(Value::as_str)
889 .collect::<Vec<_>>()
890 .join(" ");
891 (!joined.is_empty()).then(|| joined.to_lowercase())
892 }
893 _ => None,
894 }
895}
896
897fn text_of(block: &ContentBlock) -> Option<&str> {
899 match block {
900 ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
901 _ => None,
902 }
903}
904
905fn build_signal(
906 tool_texts: Vec<(String, bool)>,
907 tool_calls: Vec<ObservedToolCall>,
908 turn_depth: u32,
909 recent_window: usize,
910 semantics: &ToolSemantics,
911) -> ToolSignals {
912 let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
918 let mut severity = 0.0f32;
919 let mut failure_fingerprints = Vec::new();
920 let mut repeated_failure = false;
921 for (text, is_error) in &tool_texts[sev_start..] {
922 let (sev, _patterns) = classify_text(text);
923 let sev = if *is_error { sev.max(HARD) } else { sev };
925 if sev > severity {
926 severity = sev;
927 }
928 if let Some(fingerprint) = failure_fingerprint(text, *is_error) {
929 repeated_failure |= failure_fingerprints.contains(&fingerprint);
930 failure_fingerprints.push(fingerprint);
931 }
932 }
933
934 let no_error_streak = compute_no_error_streak(&tool_texts);
935
936 let recent_start = tool_calls.len().saturating_sub(recent_window);
940 let mut write_count = 0u32;
941 let mut edit_count = 0u32;
942 let mut read_count = 0u32;
943 let mut todowrite_count = 0u32;
944 let mut recent_write_count = 0u32;
945 let mut recent_edit_count = 0u32;
946 let mut recent_read_count = 0u32;
947 let mut recent_todowrite_count = 0u32;
948 let mut new_count = 0u32;
949 let mut recent_new_count = 0u32;
950 let mut pure_bash_streak = 0u32;
951 let mut streak_open = true;
952 for (i, tc) in tool_calls.iter().enumerate().rev() {
953 let mut cat = classify_tool_call_with_semantics(&tc.name, tc.command.as_deref(), semantics);
955 if matches!(cat, ToolSemantic::Unknown)
956 && let Some(bare_name) = tc.bare_name
957 {
958 cat = classify_tool_call_with_semantics(bare_name, tc.command.as_deref(), semantics);
959 }
960 if streak_open {
961 if matches!(cat, ToolSemantic::Unknown) {
962 pure_bash_streak += 1;
963 } else {
964 streak_open = false;
965 }
966 }
967 match cat {
968 ToolSemantic::Mutate(MutationKind::Write) => {
969 write_count += 1;
970 if i >= recent_start {
971 recent_write_count += 1;
972 }
973 }
974 ToolSemantic::Mutate(MutationKind::Edit) => {
975 edit_count += 1;
976 if i >= recent_start {
977 recent_edit_count += 1;
978 }
979 }
980 ToolSemantic::Observe => {
981 read_count += 1;
982 if i >= recent_start {
983 recent_read_count += 1;
984 }
985 }
986 ToolSemantic::Plan => {
987 todowrite_count += 1;
988 if i >= recent_start {
989 recent_todowrite_count += 1;
990 }
991 }
992 ToolSemantic::New => {
993 new_count += 1;
994 if i >= recent_start {
995 recent_new_count += 1;
996 }
997 }
998 ToolSemantic::Unknown => {}
999 }
1000 }
1001
1002 let tests_passed = detect_tests_passed(&tool_texts, recent_window);
1003
1004 ToolSignals {
1005 severity,
1006 repeated_failure,
1007 no_error_streak,
1008 edit_count,
1009 write_count,
1010 read_count,
1011 todowrite_count,
1012 recent_edit_count,
1013 recent_write_count,
1014 recent_read_count,
1015 recent_todowrite_count,
1016 new_count,
1017 recent_new_count,
1018 pure_bash_streak,
1019 tests_passed,
1020 turn_depth,
1021 tool_result_count: 0,
1025 assistant_turn_count: 0,
1026 compacted: false,
1027 }
1028}
1029
1030fn content_to_text(content: Option<&Value>) -> Option<String> {
1034 match content? {
1035 Value::String(s) => Some(s.clone()),
1036 Value::Array(blocks) => {
1037 let parts: Vec<&str> = blocks
1038 .iter()
1039 .filter_map(|b| {
1040 b.as_object()
1041 .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
1042 .and_then(|o| o.get("text"))
1043 .and_then(Value::as_str)
1044 })
1045 .collect();
1046 if parts.is_empty() {
1047 None
1048 } else {
1049 Some(parts.join("\n"))
1050 }
1051 }
1052 _ => None,
1053 }
1054}
1055
1056pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
1060 let lower = text.to_lowercase();
1061 let mut patterns = Vec::new();
1062 let mut severity: f32 = 0.0;
1063 for (name, sev, substrings) in ERROR_PATTERNS {
1064 if substrings.iter().any(|sub| lower.contains(sub)) {
1065 patterns.push(name.to_string());
1066 severity = severity.max(*sev);
1067 }
1068 }
1069 if has_nonzero_exit_status(&lower) && !patterns.iter().any(|p| p == "exit_nonzero") {
1070 patterns.push("exit_nonzero".to_string());
1071 severity = severity.max(SOFT);
1072 }
1073 for (name, matched) in [
1074 ("compile_error", has_compiler_diagnostic(&lower)),
1075 ("runtime_exception", has_runtime_exception(&lower)),
1076 ("runtime_panic", has_runtime_panic(&lower)),
1077 ("patch_error", has_patch_failure(&lower)),
1078 ] {
1079 if matched && !patterns.iter().any(|pattern| pattern == name) {
1080 patterns.push(name.to_string());
1081 severity = severity.max(HARD);
1082 }
1083 }
1084 (severity, patterns)
1085}
1086
1087fn failure_fingerprint(text: &str, is_error: bool) -> Option<String> {
1090 let (severity, patterns) = classify_text(text);
1091 if severity < HARD && !is_error {
1092 return None;
1093 }
1094
1095 let lower = text.to_lowercase();
1096 let diagnostic = lower
1097 .lines()
1098 .find(|line| is_failure_diagnostic(line))
1099 .or_else(|| lower.lines().find(|line| !line.trim().is_empty()))
1100 .unwrap_or_default();
1101 let normalized = normalize_failure_text(diagnostic);
1102 Some(format!("{}|{normalized}", patterns.join(",")))
1103}
1104
1105fn is_failure_diagnostic(line: &str) -> bool {
1106 let line = line.trim();
1107 [
1108 "error",
1109 "exception",
1110 "panic",
1111 "failed",
1112 "timed out",
1113 "timeout",
1114 "connection refused",
1115 "cannot allocate memory",
1116 "out of memory",
1117 "not found",
1118 ]
1119 .iter()
1120 .any(|marker| line.contains(marker))
1121}
1122
1123fn normalize_failure_text(text: &str) -> String {
1126 let mut normalized = String::new();
1127 for word in text.split_whitespace() {
1128 if !normalized.is_empty() {
1129 normalized.push(' ');
1130 }
1131 let mut in_digits = false;
1132 if word.starts_with('/') || word.contains("/src/") || word.contains("/tmp/") {
1133 normalized.push_str("<path>");
1134 continue;
1135 }
1136 for character in word.chars() {
1137 if character.is_ascii_digit() {
1138 if !in_digits {
1139 normalized.push('#');
1140 in_digits = true;
1141 }
1142 } else {
1143 normalized.push(character);
1144 in_digits = false;
1145 }
1146 }
1147 }
1148 normalized.chars().take(240).collect()
1149}
1150
1151fn has_compiler_diagnostic(lower: &str) -> bool {
1152 lower.lines().any(|line| {
1153 let line = line.trim_start();
1154 if matches!(
1155 line,
1156 "compilation failed" | "error: compilation failed" | "error: could not compile"
1157 ) || line.starts_with("error: could not compile ")
1158 {
1159 return true;
1160 }
1161
1162 let Some(rest) = line.strip_prefix("error[e") else {
1163 return false;
1164 };
1165 let Some((code, _)) = rest.split_once("]:") else {
1166 return false;
1167 };
1168 !code.is_empty() && code.chars().all(|character| character.is_ascii_digit())
1169 })
1170}
1171
1172fn has_runtime_exception(lower: &str) -> bool {
1173 let has_exception_line = lower.lines().any(|line| {
1174 let line = line.trim_start();
1175 [
1176 "typeerror:",
1177 "referenceerror:",
1178 "rangeerror:",
1179 "runtimeerror:",
1180 "keyerror:",
1181 "attributeerror:",
1182 ]
1183 .iter()
1184 .any(|prefix| line.starts_with(prefix))
1185 });
1186 has_exception_line && (lower.contains("\n at ") || lower.contains("\n at "))
1187}
1188
1189fn has_runtime_panic(lower: &str) -> bool {
1190 lower
1191 .lines()
1192 .any(|line| line.trim_start().starts_with("panic: runtime error:"))
1193 && (lower.contains("\ngoroutine ") || lower.contains("[signal sig"))
1194}
1195
1196fn has_patch_failure(lower: &str) -> bool {
1197 lower.lines().any(|line| {
1198 let line = line.trim_start();
1199 line.starts_with("error: patch failed:")
1200 || line.starts_with("patch failed:")
1201 || line.contains(": patch does not apply")
1202 || line.starts_with("invalid context")
1203 })
1204}
1205
1206fn has_nonzero_exit_status(lower: &str) -> bool {
1212 NONZERO_EXIT_PHRASES
1213 .iter()
1214 .any(|phrase| phrase_followed_by_nonzero_integer(lower, phrase))
1215}
1216
1217fn phrase_followed_by_nonzero_integer(lower: &str, phrase: &str) -> bool {
1219 let mut cursor = 0usize;
1220 while let Some(rel) = lower[cursor..].find(phrase) {
1221 let value_start = cursor + rel + phrase.len();
1222 let rest = lower[value_start..].trim_start_matches(|c: char| {
1223 c.is_ascii_whitespace() || matches!(c, ':' | '=' | '\'' | '"' | '`')
1224 });
1225 let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1226 if !digits.is_empty() && digits.chars().any(|d| d != '0') {
1227 return true;
1228 }
1229 cursor = value_start;
1230 }
1231 false
1232}
1233
1234fn compute_no_error_streak(tool_texts: &[(String, bool)]) -> u32 {
1235 let mut streak = 0u32;
1236 for (text, is_error) in tool_texts.iter().rev() {
1237 let (sev, _) = classify_text(text);
1238 if *is_error || sev > 0.0 {
1239 break;
1240 }
1241 streak += 1;
1242 }
1243 streak
1244}
1245
1246fn detect_tests_passed(tool_texts: &[(String, bool)], recent_window: usize) -> bool {
1247 let start = tool_texts.len().saturating_sub(recent_window.max(1));
1248 let recent = &tool_texts[start..];
1249 let after_latest_failure = recent
1250 .iter()
1251 .rposition(|(text, is_error)| *is_error || classify_text(text).0 > 0.0)
1252 .map_or(recent, |index| &recent[index + 1..]);
1253 after_latest_failure.iter().any(|(text, _)| {
1254 let lower = text.to_lowercase();
1255 TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
1256 && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
1257 && !has_nonzero_failure_count(&lower)
1258 })
1259}
1260
1261fn has_nonzero_failure_count(lower: &str) -> bool {
1267 for kw in NUMERIC_FAILURE_KEYWORDS {
1268 let mut cursor = 0usize;
1269 while let Some(rel) = lower[cursor..].find(kw) {
1270 let kw_start = cursor + rel;
1271 let kw_end = kw_start + kw.len();
1272 let boundary_after = lower[kw_end..]
1275 .chars()
1276 .next()
1277 .is_none_or(|c| !c.is_ascii_alphanumeric());
1278 if boundary_after {
1279 let prefix = &lower[..kw_start];
1280 let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
1281 let digits_rev: String = trimmed
1282 .chars()
1283 .rev()
1284 .take_while(|c| c.is_ascii_digit())
1285 .collect();
1286 if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
1287 return true;
1288 }
1289 }
1290 cursor = kw_start + kw.len();
1291 }
1292 }
1293 false
1294}
1295
1296#[cfg(test)]
1299mod tests {
1300 use super::*;
1301 use crate::algorithms::util::stage::score_signal;
1302 use serde_json::json;
1303 use switchyard_protocol::codex_namespaces::TOOL_NAMESPACES_KEY;
1304 use switchyard_protocol::{
1305 ContentBlock, LlmRequest, Message, Metadata, Role, ToolCall, ToolResult,
1306 };
1307
1308 fn with_messages(messages: Vec<Message>) -> Request {
1309 Request {
1310 llm_request: LlmRequest {
1311 messages,
1312 ..LlmRequest::default()
1313 },
1314 raw_request: None,
1315 metadata: None,
1316 }
1317 }
1318
1319 fn tc(name: &str) -> Message {
1321 Message {
1322 role: Role::Assistant,
1323 content: vec![ContentBlock::ToolCall(ToolCall {
1324 id: String::new(),
1325 name: name.to_string(),
1326 arguments: json!({}),
1327 })],
1328 }
1329 }
1330
1331 fn bash(command: &str) -> Message {
1333 Message {
1334 role: Role::Assistant,
1335 content: vec![ContentBlock::ToolCall(ToolCall {
1336 id: String::new(),
1337 name: "Bash".to_string(),
1338 arguments: json!({"command": command}),
1339 })],
1340 }
1341 }
1342
1343 fn tr(text: &str) -> Message {
1345 Message {
1346 role: Role::User,
1347 content: vec![ContentBlock::ToolResult(ToolResult {
1348 tool_call_id: String::new(),
1349 content: vec![ContentBlock::Text {
1350 text: text.to_string(),
1351 }],
1352 is_error: None,
1353 })],
1354 }
1355 }
1356
1357 #[test]
1358 fn clean_text_has_zero_severity() {
1359 let (sev, patterns) = classify_text("everything went fine");
1360 assert_eq!(sev, 0.0);
1361 assert!(patterns.is_empty());
1362 }
1363
1364 #[test]
1365 fn traceback_is_hard() {
1366 let (sev, patterns) = classify_text("Traceback (most recent call last):\n ValueError");
1367 assert_eq!(sev, HARD);
1368 assert!(patterns.contains(&"traceback".to_string()));
1369 }
1370
1371 #[test]
1372 fn oom_is_critical() {
1373 let (sev, _) = classify_text("Out of memory: kill process 1234");
1374 assert_eq!(sev, CRITICAL);
1375 }
1376
1377 #[test]
1378 fn connection_refused_is_hard() {
1379 let (severity, _) = classify_text("Connection refused on port 8000");
1380 assert_eq!(severity, HARD);
1381 }
1382
1383 #[test]
1384 fn repeated_failure_ignores_volatile_paths_and_numbers() {
1385 let request = with_messages(vec![
1386 tr("error[E0308]: mismatched types at /tmp/a/src/lib.rs:12"),
1387 tr("error[E0308]: mismatched types at /tmp/b/src/lib.rs:47"),
1388 ]);
1389 assert!(ToolSignals::from_request(&request, None).repeated_failure);
1390 }
1391
1392 #[test]
1393 fn different_failures_are_not_repeated() {
1394 let request = with_messages(vec![
1395 tr("error[E0308]: mismatched types"),
1396 tr("error[E0509]: cannot move out"),
1397 ]);
1398 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1399 }
1400
1401 #[test]
1402 fn one_material_failure_is_not_repeated() {
1403 let request = with_messages(vec![tr("Connection refused on port 8000")]);
1404 assert!(!ToolSignals::from_request(&request, None).repeated_failure);
1405 }
1406
1407 #[test]
1409 fn structured_tool_failures_feed_error_and_recovery_signals() {
1410 for text in ["Dependency unavailable", "", "5 passed in 0.12s"] {
1411 let mut failed = tr(text);
1412 let ContentBlock::ToolResult(result) = &mut failed.content[0] else {
1413 panic!("expected tool result");
1414 };
1415 result.is_error = Some(true);
1416 let mut request = with_messages(vec![tr("5 passed in 0.12s"), failed.clone()]);
1417 let signals = ToolSignals::from_request(&request, Some(3));
1418 assert_eq!(signals.severity, HARD);
1419 assert!(!signals.repeated_failure);
1420 assert_eq!(signals.no_error_streak, 0);
1421 assert!(!signals.tests_passed);
1422
1423 request.llm_request.messages.push(failed);
1424 assert!(ToolSignals::from_request(&request, Some(3)).repeated_failure);
1425 request.llm_request.messages.push(tr("5 passed in 0.12s"));
1426 let recovered = ToolSignals::from_request(&request, Some(1));
1427 assert_eq!(recovered.severity, 0.0);
1428 assert!(!recovered.repeated_failure);
1429 assert_eq!(recovered.no_error_streak, 1);
1430 assert!(recovered.tests_passed);
1431 }
1432 }
1433
1434 #[test]
1435 fn severity_is_max_across_patterns() {
1436 let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
1438 assert_eq!(sev, HARD);
1439 }
1440
1441 #[test]
1442 fn codex_process_exit_zero_stays_clean() {
1443 let (sev, patterns) =
1444 classify_text("Chunk ID: abc\nProcess exited with code 0\nOutput:\nok");
1445 assert_eq!(sev, 0.0);
1446 assert!(!patterns.contains(&"exit_nonzero".to_string()));
1447 }
1448
1449 #[test]
1450 fn nonzero_exit_codes_are_soft_errors() {
1451 let cases = [
1452 "Process exited with code 1",
1453 "Process exited with code 127",
1454 "exit code: 2",
1455 "exit status 3",
1456 "exited with status 9",
1457 ];
1458 for case in cases {
1459 let (sev, patterns) = classify_text(case);
1460 assert_eq!(sev, SOFT, "expected soft severity for {case}");
1461 assert!(patterns.contains(&"exit_nonzero".to_string()));
1462 }
1463 }
1464
1465 #[test]
1466 fn partial_process_failures_are_hard_errors() {
1467 let cases = [
1468 (
1469 "Process running with session ID 12\nOutput:\nerror[E0509]: cannot move out",
1470 "compile_error",
1471 ),
1472 (
1473 "Process exited with code 0\nOutput:\nTypeError: value is undefined\n at main.js:1:2",
1474 "runtime_exception",
1475 ),
1476 (
1477 "Process running with session ID 13\nOutput:\npanic: runtime error: index out of range\n\ngoroutine 6 [running]:",
1478 "runtime_panic",
1479 ),
1480 (
1481 "Process exited with code 0\nOutput:\nerror: patch failed: src/lib.rs:4\nerror: src/lib.rs: patch does not apply",
1482 "patch_error",
1483 ),
1484 ];
1485 for (text, expected_pattern) in cases {
1486 let (severity, patterns) = classify_text(text);
1487 assert_eq!(severity, HARD, "expected hard severity for {text}");
1488 assert!(patterns.iter().any(|pattern| pattern == expected_pattern));
1489 }
1490 }
1491
1492 #[test]
1493 fn source_text_that_names_exceptions_stays_clean() {
1494 let text =
1495 "pub enum TypeError: this is documentation\nlet sample = 'panic: runtime error:';";
1496 assert_eq!(classify_text(text).0, 0.0);
1497 }
1498
1499 #[test]
1500 fn file_does_not_exist_is_hard() {
1501 let (sev, patterns) =
1503 classify_text("Error: File does not exist. Note: current working directory is /app.");
1504 assert_eq!(sev, HARD);
1505 assert!(patterns.contains(&"no_such_file".to_string()));
1506 }
1507
1508 #[test]
1509 fn bare_does_not_exist_stays_clean() {
1510 let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
1513 assert_eq!(sev, 0.0);
1514 }
1515
1516 #[test]
1517 fn no_error_streak_all_clean() {
1518 let texts = vec![("ok".to_string(), false), ("all good".to_string(), false)];
1519 assert_eq!(compute_no_error_streak(&texts), 2);
1520 }
1521
1522 #[test]
1523 fn no_error_streak_stops_at_error() {
1524 let texts = vec![
1525 ("Traceback (most recent call last):".to_string(), false),
1526 ("ok".to_string(), false),
1527 ("ok".to_string(), false),
1528 ];
1529 assert_eq!(compute_no_error_streak(&texts), 2);
1530 }
1531
1532 #[test]
1533 fn tests_passed_detects_pytest_output() {
1534 assert!(detect_tests_passed(
1535 &[("====== 5 passed in 0.12s ======".to_string(), false)],
1536 DEFAULT_RECENT_WINDOW
1537 ));
1538 }
1539
1540 #[test]
1541 fn tests_passed_ignores_partial_failures() {
1542 assert!(!detect_tests_passed(
1543 &[("2 failed, 5 passed in 0.56s".to_string(), false)],
1544 DEFAULT_RECENT_WINDOW
1545 ));
1546 }
1547
1548 #[test]
1549 fn tests_passed_must_follow_the_latest_failure() {
1550 assert!(!detect_tests_passed(
1551 &[
1552 ("5 passed in 0.12s".to_string(), false),
1553 (
1554 "Traceback (most recent call last):\nValueError".to_string(),
1555 false
1556 ),
1557 ("edit applied".to_string(), false),
1558 ],
1559 DEFAULT_RECENT_WINDOW
1560 ));
1561 assert!(detect_tests_passed(
1562 &[
1563 (
1564 "Traceback (most recent call last):\nValueError".to_string(),
1565 false
1566 ),
1567 ("5 passed in 0.12s".to_string(), false),
1568 ],
1569 DEFAULT_RECENT_WINDOW
1570 ));
1571 }
1572
1573 #[test]
1574 fn severity_is_windowed_over_recent_results() {
1575 let request = with_messages(vec![
1577 tr("Traceback (most recent call last):\n ValueError"),
1578 tr("ok"),
1579 tr("ok"),
1580 ]);
1581 assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
1583 assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
1585 }
1586
1587 #[test]
1588 fn extract_openai_chat_tool_results() {
1589 let request = with_messages(vec![
1590 Message::text(Role::User, "do something"),
1591 tc("Edit"),
1592 tr("Traceback (most recent call last):\n ValueError"),
1593 ]);
1594 let sig = ToolSignals::from_request(&request, None);
1595 assert_eq!(sig.severity, HARD);
1596 assert_eq!(sig.edit_count, 1);
1597 assert_eq!(sig.turn_depth, 3);
1598 }
1599
1600 #[test]
1601 fn extract_anthropic_tool_results() {
1602 let request = with_messages(vec![tr("Traceback (most recent call last):\n ValueError")]);
1603 let sig = ToolSignals::from_request(&request, None);
1604 assert_eq!(sig.severity, HARD);
1605 }
1606
1607 #[test]
1608 fn extract_responses_api_tool_results() {
1609 let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
1610 let sig = ToolSignals::from_request(&request, None);
1611 assert_eq!(sig.severity, 0.0);
1612 assert_eq!(sig.write_count, 1);
1613 }
1614
1615 #[test]
1616 fn responses_builtin_tool_failures_escalate() {
1617 use crate::algorithms::util::stage::{PickOutcome, PickerMode, Tier, pick_tier};
1618
1619 let mut cases = Vec::new();
1620 for (status, output) in [
1621 (
1622 "failed",
1623 "Synthetic dependency unavailable; retry with the recovery path.",
1624 ),
1625 (
1626 "completed",
1627 "Synthetic dependency unavailable; retry with the recovery path.",
1628 ),
1629 ("failed", ""),
1630 ] {
1631 cases.push((
1632 json!({
1633 "type": "apply_patch_call_output",
1634 "status": status,
1635 "output": output,
1636 }),
1637 if status == "failed" { HARD } else { 0.0 },
1638 ));
1639 }
1640 for (outcome, stdout, stderr, severity) in [
1641 (json!({"type": "exit", "exit_code": 1}), "", "", SOFT),
1642 (json!({"type": "exit", "exit_code": 1}), "", " \n", SOFT),
1643 (
1644 json!({"type": "exit", "exit_code": 1}),
1645 "",
1646 "command failed",
1647 HARD,
1648 ),
1649 (json!({"type": "timeout"}), "", "", HARD),
1650 (json!({"type": "exit", "exit_code": 0}), "done", "", 0.0),
1651 (
1652 json!({"type": "exit", "exit_code": 0}),
1653 "Traceback (most recent call last):",
1654 "",
1655 HARD,
1656 ),
1657 (
1658 json!({"type": "exit", "exit_code": 0}),
1659 "",
1660 "Traceback (most recent call last):",
1661 HARD,
1662 ),
1663 ] {
1664 cases.push((
1665 json!({
1666 "type": "shell_call_output",
1667 "output": [
1668 {"stdout": stdout, "stderr": stderr, "outcome": outcome},
1669 {"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}
1670 ],
1671 }),
1672 severity,
1673 ));
1674 }
1675 for (raw, severity) in cases {
1676 let is_error = severity >= HARD;
1677 let mut request = with_messages(
1678 ["call_1", "call_2"]
1679 .into_iter()
1680 .map(|call_id| {
1681 let mut raw = raw.clone();
1682 raw["call_id"] = json!(call_id);
1683 Message {
1684 role: Role::User,
1685 content: vec![ContentBlock::Unknown {
1686 provider: WireFormat::OpenAiResponses.into(),
1687 raw,
1688 }],
1689 }
1690 })
1691 .collect(),
1692 );
1693 let signal = ToolSignals::from_request(&request, Some(3));
1694 assert_eq!(signal.severity, severity, "{raw}");
1695 assert_eq!(signal.repeated_failure, is_error, "{raw}");
1696 assert_eq!(signal.tool_result_count, 2);
1697 assert_eq!(
1698 matches!(
1699 pick_tier(&signal, PickerMode::EfficientFirst, 0.5),
1700 PickOutcome::Resolved {
1701 tier: Tier::Capable,
1702 ..
1703 }
1704 ),
1705 is_error,
1706 "{raw}"
1707 );
1708
1709 let mut success = match raw["type"].as_str() {
1710 Some("apply_patch_call_output") => json!({
1711 "type": "apply_patch_call_output", "status": "completed", "output": ""
1712 }),
1713 Some("shell_call_output") => json!({
1714 "type": "shell_call_output",
1715 "output": [{"stdout": "", "stderr": "", "outcome": {"type": "exit", "exit_code": 0}}]
1716 }),
1717 _ => unreachable!(),
1718 };
1719 for index in 0..3 {
1720 success["call_id"] = json!(format!("success_{index}"));
1721 request.llm_request.messages.push(Message {
1722 role: Role::User,
1723 content: vec![ContentBlock::Unknown {
1724 provider: WireFormat::OpenAiResponses.into(),
1725 raw: success.clone(),
1726 }],
1727 });
1728 }
1729 let recovered = ToolSignals::from_request(&request, Some(3));
1730 assert_eq!(recovered.severity, 0.0, "{raw}");
1731 assert!(!recovered.repeated_failure, "{raw}");
1732 assert!(recovered.no_error_streak >= 3, "{raw}");
1733 assert_eq!(recovered.tool_result_count, 5);
1734 }
1735 }
1736
1737 #[test]
1738 fn conversation_counts_are_per_block_and_role_aware() {
1739 let result = |content: Vec<ContentBlock>| {
1743 ContentBlock::ToolResult(ToolResult {
1744 tool_call_id: String::new(),
1745 content,
1746 is_error: None,
1747 })
1748 };
1749 let request = with_messages(vec![
1750 Message::text(Role::User, "do something"),
1751 Message::text(Role::Assistant, "working"),
1752 Message {
1753 role: Role::User,
1754 content: vec![
1755 result(vec![ContentBlock::Text {
1756 text: "ok".to_string(),
1757 }]),
1758 result(Vec::new()),
1759 ],
1760 },
1761 tc("Bash"),
1762 ]);
1763 let sig = ToolSignals::from_request(&request, None);
1764 assert_eq!(sig.tool_result_count, 2);
1765 assert_eq!(sig.assistant_turn_count, 2);
1766 assert_eq!(sig.turn_depth, 4);
1767 }
1768
1769 #[test]
1770 fn recent_window_counts_only_last_default_window_tool_calls() {
1771 let request = with_messages(vec![
1774 tc("Write"),
1775 tr("ok"),
1776 tc("Write"),
1777 tr("ok"),
1778 tc("Write"),
1779 tr("ok"),
1780 tc("Write"),
1781 tr("ok"),
1782 tc("Write"),
1783 tr("ok"),
1784 tc("Edit"),
1785 tr("ok"),
1786 ]);
1787 let sig = ToolSignals::from_request(&request, None);
1788 assert_eq!(sig.write_count, 5);
1789 assert_eq!(sig.edit_count, 1);
1790 assert_eq!(sig.recent_write_count, 2);
1791 assert_eq!(sig.recent_edit_count, 1);
1792 }
1793
1794 #[test]
1795 fn codex_apply_patch_counts_as_an_edit() {
1796 let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
1797 let sig = ToolSignals::from_request(&request, None);
1798 assert_eq!(sig.edit_count, 1);
1799 assert_eq!(sig.recent_edit_count, 1);
1800 }
1801
1802 fn exec_command(cmd: Value) -> Message {
1803 Message {
1804 role: Role::Assistant,
1805 content: vec![ContentBlock::ToolCall(ToolCall {
1806 id: String::new(),
1807 name: "exec_command".to_string(),
1808 arguments: cmd,
1809 })],
1810 }
1811 }
1812
1813 #[test]
1814 fn codex_exec_command_is_classified() {
1815 let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
1817 let request = with_messages(vec![exec_command(args), tr("ok")]);
1818 assert_eq!(
1819 ToolSignals::from_request(&request, None).recent_edit_count,
1820 1
1821 );
1822 }
1823
1824 #[test]
1825 fn python_write_expressions_need_a_python_command() {
1826 let write = with_messages(vec![
1827 exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
1828 tr("ok"),
1829 ]);
1830 assert_eq!(
1831 ToolSignals::from_request(&write, None).recent_write_count,
1832 1
1833 );
1834
1835 let search = with_messages(vec![
1836 exec_command(json!({"cmd": "grep -R '.write(' src"})),
1837 tr("ok"),
1838 ]);
1839 assert_eq!(
1840 ToolSignals::from_request(&search, None).recent_write_count,
1841 0
1842 );
1843 }
1844
1845 #[test]
1846 fn recent_window_size_is_caller_overridable() {
1847 let request = with_messages(vec![
1851 tc("Write"),
1852 tr("ok"),
1853 tc("Write"),
1854 tr("ok"),
1855 tc("Write"),
1856 tr("ok"),
1857 tc("Write"),
1858 tr("ok"),
1859 tc("Write"),
1860 tr("ok"),
1861 tc("Edit"),
1862 tr("ok"),
1863 ]);
1864 let narrow = extract_tool_signals_with_window(&request, 3);
1865 assert_eq!(narrow.recent_write_count, 2);
1866 assert_eq!(narrow.recent_edit_count, 1);
1867
1868 let wide = extract_tool_signals_with_window(&request, 6);
1869 assert_eq!(wide.recent_write_count, 5);
1870 assert_eq!(wide.recent_edit_count, 1);
1871 }
1872
1873 #[test]
1874 fn compaction_marker_sets_compacted() {
1875 let request = with_messages(vec![
1877 Message::text(
1878 Role::User,
1879 "This session is being continued from a previous conversation that ran out of context.",
1880 ),
1881 bash("ls"),
1882 ]);
1883 assert!(ToolSignals::from_request(&request, None).compacted);
1884 }
1885
1886 #[test]
1887 fn codex_compaction_metadata_stays_on_parent_route() {
1888 let mut request = with_messages(vec![bash("ls")]);
1889 request.metadata = Some(Metadata {
1890 is_subagent: true,
1891 agent_kind: Some("compact".to_string()),
1892 ..Default::default()
1893 });
1894 assert!(!ToolSignals::from_request(&request, None).compacted);
1895 }
1896
1897 #[test]
1898 fn no_compaction_marker_stays_uncompacted() {
1899 let request = with_messages(vec![
1900 Message::text(Role::User, "Write a script that parses the log file."),
1901 bash("ls"),
1902 ]);
1903 assert!(!ToolSignals::from_request(&request, None).compacted);
1904 }
1905
1906 #[test]
1907 fn bash_heredoc_counts_as_write() {
1908 let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
1910 let sig = ToolSignals::from_request(&request, None);
1911 assert_eq!(
1912 sig.write_count, 1,
1913 "Bash heredoc should bucket into write_count"
1914 );
1915 assert_eq!(sig.edit_count, 0);
1916 }
1917
1918 #[test]
1919 fn bash_sed_inplace_counts_as_edit() {
1920 let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
1921 let sig = ToolSignals::from_request(&request, None);
1922 assert_eq!(
1923 sig.edit_count, 1,
1924 "Bash sed -i should bucket into edit_count"
1925 );
1926 assert_eq!(sig.write_count, 0);
1927 }
1928
1929 #[test]
1930 fn bash_non_mutating_does_not_count() {
1931 let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
1933 let sig = ToolSignals::from_request(&request, None);
1934 assert_eq!(sig.write_count, 0);
1935 assert_eq!(sig.edit_count, 0);
1936 }
1937
1938 #[test]
1939 fn tests_passed_detects_pytest_with_failure_block() {
1940 assert!(!detect_tests_passed(
1942 &[("2 failed, 5 passed in 0.56s".to_string(), false)],
1943 DEFAULT_RECENT_WINDOW
1944 ));
1945 }
1946
1947 #[test]
1948 fn tests_passed_accepts_cargo_clean_summary() {
1949 assert!(detect_tests_passed(
1952 &[(
1953 "running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string(),
1954 false
1955 )],
1956 DEFAULT_RECENT_WINDOW
1957 ));
1958 }
1959
1960 #[test]
1961 fn tests_passed_rejects_cargo_real_failure() {
1962 assert!(!detect_tests_passed(
1964 &[(
1965 "running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string(),
1966 false
1967 )],
1968 DEFAULT_RECENT_WINDOW
1969 ));
1970 }
1971
1972 #[test]
1973 fn tests_passed_accepts_go_clean_summary() {
1974 assert!(detect_tests_passed(
1976 &[(
1977 "ok github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string(),
1978 false
1979 )],
1980 DEFAULT_RECENT_WINDOW
1981 ));
1982 }
1983
1984 #[test]
1985 fn tests_passed_accepts_pytest_zero_errors() {
1986 assert!(detect_tests_passed(
1988 &[("5 passed, 0 errors in 0.30s".to_string(), false)],
1989 DEFAULT_RECENT_WINDOW
1990 ));
1991 }
1992
1993 #[test]
1994 fn tests_passed_detects_diy_checkmark() {
1995 assert!(detect_tests_passed(
1996 &[("✓ all checks passed".to_string(), false)],
1997 DEFAULT_RECENT_WINDOW
1998 ));
1999 }
2000
2001 #[test]
2002 fn anthropic_bash_heredoc_extracts_command() {
2003 let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
2005 let sig = ToolSignals::from_request(&request, None);
2006 assert_eq!(
2007 sig.write_count, 1,
2008 "Anthropic Bash heredoc must also be detected"
2009 );
2010 }
2011
2012 #[test]
2013 fn recent_window_falls_back_to_full_history_when_short() {
2014 let request = with_messages(vec![tc("Write")]);
2015 let sig = ToolSignals::from_request(&request, None);
2016 assert_eq!(sig.recent_write_count, 1);
2017 assert_eq!(sig.recent_edit_count, 0);
2018 }
2019
2020 #[test]
2021 fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
2022 let request = with_messages(vec![tr("output ok"), tr("another ok")]);
2023 let sig = ToolSignals::from_request(&request, None);
2024 assert_eq!(sig.severity, 0.0);
2025 assert_eq!(sig.no_error_streak, 2);
2026 }
2027
2028 #[test]
2031 fn todowrite_classifies_as_plan() {
2032 assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
2033 assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
2034 }
2035
2036 #[test]
2037 fn codex_update_plan_classifies_as_plan() {
2038 assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
2039 }
2040
2041 #[test]
2042 fn codex_shell_command_runs_bash_pattern_match() {
2043 assert_eq!(
2045 classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
2046 ToolSemantic::Mutate(MutationKind::Write),
2047 );
2048 assert_eq!(
2050 classify_tool_call("shell_command", Some("ls /app")),
2051 ToolSemantic::Observe,
2052 );
2053 assert_eq!(
2055 classify_tool_call("shell_command", Some("./run_tests.sh")),
2056 ToolSemantic::Unknown,
2057 );
2058 }
2059
2060 #[test]
2061 fn text_editor_view_is_a_read() {
2062 for name in ["str_replace_based_edit_tool", "text_editor"] {
2063 assert_eq!(
2064 classify_tool_call(name, Some("view")),
2065 ToolSemantic::Observe
2066 );
2067 for command in [
2068 Some("create"),
2069 Some("insert"),
2070 Some("str_replace"),
2071 Some("undo_edit"),
2072 None,
2073 ] {
2074 assert_eq!(
2075 classify_tool_call(name, command),
2076 ToolSemantic::Mutate(MutationKind::Edit),
2077 );
2078 }
2079 }
2080
2081 let arguments = [
2082 json!({"command": "view", "path": "/app/main.py"}),
2083 json!(r#"{"command":"view","path":"/app/main.py"}"#),
2085 ];
2086 for arguments in arguments {
2087 let call = Message {
2088 role: Role::Assistant,
2089 content: vec![ContentBlock::ToolCall(ToolCall {
2090 id: String::new(),
2091 name: "str_replace_based_edit_tool".to_string(),
2092 arguments,
2093 })],
2094 };
2095 let request = with_messages(vec![call, tr("print('hi')")]);
2096 let sig = ToolSignals::from_request(&request, None);
2097 assert_eq!(sig.read_count, 1);
2098 assert_eq!(sig.recent_read_count, 1);
2099 assert_eq!(sig.edit_count, 0);
2100 }
2101 }
2102
2103 #[test]
2104 fn read_tool_classifies_as_read() {
2105 assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
2106 assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
2107 }
2108
2109 #[test]
2110 fn hermes_tool_names_classify() {
2111 assert_eq!(
2113 classify_tool_call("write_file", None),
2114 ToolSemantic::Mutate(MutationKind::Write)
2115 );
2116 assert_eq!(
2117 classify_tool_call("patch", None),
2118 ToolSemantic::Mutate(MutationKind::Edit)
2119 );
2120 assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
2121 assert_eq!(
2122 classify_tool_call("search_files", None),
2123 ToolSemantic::Observe
2124 );
2125 assert_eq!(
2128 classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
2129 ToolSemantic::Mutate(MutationKind::Edit),
2130 );
2131 assert_eq!(
2132 classify_tool_call("terminal", Some("grep foo /app")),
2133 ToolSemantic::Observe,
2134 );
2135 assert_eq!(
2136 classify_tool_call("terminal", Some("./run_tests.sh")),
2137 ToolSemantic::Unknown,
2138 );
2139 }
2140
2141 #[test]
2142 fn bash_read_patterns_classify_as_read() {
2143 let cases = [
2144 "cat /etc/passwd",
2145 "grep foo bar.txt",
2146 "ls /app",
2147 "find . -name '*.py'",
2148 ];
2149 for cmd in cases {
2150 assert_eq!(
2151 classify_tool_call("Bash", Some(cmd)),
2152 ToolSemantic::Observe,
2153 "expected Read for {cmd}"
2154 );
2155 }
2156 }
2157
2158 #[test]
2159 fn codex_inspection_commands_classify_as_read() {
2160 let cases = [
2161 "sed -n '1,80p' src/lib.rs",
2162 "rg -n 'needle' src",
2163 "nl -ba src/lib.rs",
2164 "cat package.json",
2165 "jq '.scripts' package.json",
2166 "git status --short",
2167 "git log --oneline -5",
2168 "git show HEAD:src/lib.rs",
2169 "git branch --show-current",
2170 "git remote -v",
2171 "git config --get remote.origin.url",
2172 ];
2173 for command in cases {
2174 assert_eq!(
2175 classify_tool_call("exec_command", Some(command)),
2176 ToolSemantic::Observe,
2177 "expected Read for {command}"
2178 );
2179 }
2180 }
2181
2182 #[test]
2183 fn quoted_shell_separators_do_not_create_commands() {
2184 for command in ["rg 'foo|rm obsolete.rs'", "rg \"foo; rm obsolete.rs\""] {
2185 assert_eq!(
2186 classify_tool_call("exec_command", Some(command)),
2187 ToolSemantic::Observe,
2188 "quoted text must not be parsed as a command: {command}"
2189 );
2190 }
2191 }
2192
2193 #[test]
2194 fn codex_shell_mutations_classify_as_production() {
2195 let writes = [
2196 "cp source.rs destination.rs",
2197 "mkdir -p src/generated",
2198 "touch src/generated/mod.rs",
2199 "git show HEAD:file.rs > file.rs",
2200 "node <<'node'\nfs.writefilesync('file.js', text)\nnode",
2201 ];
2202 for command in writes {
2203 assert_eq!(
2204 classify_tool_call("exec_command", Some(command)),
2205 ToolSemantic::Mutate(MutationKind::Write),
2206 "expected Write for {command}"
2207 );
2208 }
2209
2210 let edits = [
2211 "mv old.rs new.rs",
2212 "rm obsolete.rs",
2213 "gofmt -w main.go",
2214 "cargo fmt",
2215 "ruff check --fix src",
2216 "perl -0pi -e 's/old/new/' src/lib.rs",
2217 "npx prettier --write src/lib.ts",
2218 "uv run ruff format src",
2219 "git apply fix.patch",
2220 ];
2221 for command in edits {
2222 assert_eq!(
2223 classify_tool_call("exec_command", Some(command)),
2224 ToolSemantic::Mutate(MutationKind::Edit),
2225 "expected Edit for {command}"
2226 );
2227 }
2228 }
2229
2230 #[test]
2231 fn formatter_checks_are_not_edits() {
2232 for command in [
2233 "cargo fmt --check",
2234 "ruff format --check src",
2235 "black --check src",
2236 ] {
2237 assert_ne!(
2238 classify_tool_call("exec_command", Some(command)),
2239 ToolSemantic::Mutate(MutationKind::Edit),
2240 "read-only formatter check must not be Edit: {command}"
2241 );
2242 }
2243 }
2244
2245 #[test]
2246 fn embedded_comparison_is_not_a_shell_write() {
2247 let command = "node <<'node'\nif (index > 0) console.log(index)\nnode";
2248 assert_eq!(
2249 classify_tool_call("exec_command", Some(command)),
2250 ToolSemantic::Unknown
2251 );
2252 }
2253
2254 #[test]
2255 fn bash_write_precedence_over_read() {
2256 assert_eq!(
2259 classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
2260 ToolSemantic::Mutate(MutationKind::Write),
2261 );
2262 }
2263
2264 #[test]
2265 fn pure_bash_streak_counts_trailing_other() {
2266 let request = with_messages(vec![
2268 bash("make"),
2269 tr("ok"),
2270 bash("./configure"),
2271 tr("ok"),
2272 bash("make install"),
2273 tr("ok"),
2274 bash("./run.sh"),
2275 tr("ok"),
2276 bash("./test"),
2277 tr("ok"),
2278 ]);
2279 let sig = ToolSignals::from_request(&request, None);
2280 assert_eq!(sig.pure_bash_streak, 5);
2281 assert_eq!(sig.write_count, 0);
2282 assert_eq!(sig.read_count, 0);
2283 }
2284
2285 #[test]
2286 fn pure_bash_streak_resets_on_write() {
2287 let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
2288 let sig = ToolSignals::from_request(&request, None);
2289 assert_eq!(sig.pure_bash_streak, 0);
2290 assert_eq!(sig.write_count, 1);
2291 }
2292
2293 #[test]
2294 fn recent_window_tracks_todowrite_and_read() {
2295 let request = with_messages(vec![
2297 bash("make"),
2298 tr("ok"),
2299 tc("TodoWrite"),
2300 tr("ok"),
2301 tc("Read"),
2302 tr("ok"),
2303 tc("TodoWrite"),
2304 tr("ok"),
2305 ]);
2306 let sig = ToolSignals::from_request(&request, None);
2307 assert_eq!(sig.todowrite_count, 2);
2308 assert_eq!(sig.recent_todowrite_count, 2);
2309 assert_eq!(sig.read_count, 1);
2310 assert_eq!(sig.recent_read_count, 1);
2311 }
2312
2313 #[test]
2314 fn configured_tool_semantics_extend_the_builtin_vocabulary() {
2315 let semantics = ToolSemantics {
2316 observe: vec!["KB_search".to_string()],
2317 mutate: vec!["send_payment_request".to_string()],
2318 plan: vec!["create_research_plan".to_string()],
2319 new: vec!["send_message_to_user".to_string()],
2320 };
2321 semantics.validate().expect("valid additive semantics");
2322 let request = with_messages(vec![
2323 tc("Read"),
2324 tc("Write"),
2325 tc("TodoWrite"),
2326 tc("kb_SEARCH"),
2327 tc("send_payment_request"),
2328 tc("create_research_plan"),
2329 tc("send_message_to_user"),
2330 tc("unlisted_tool"),
2331 ]);
2332
2333 let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
2334
2335 assert_eq!(signal.read_count, 2);
2336 assert_eq!(signal.write_count, 2);
2337 assert_eq!(signal.todowrite_count, 2);
2338 assert_eq!(signal.new_count, 1);
2339 assert_eq!(signal.recent_new_count, 1);
2340 assert_eq!(signal.pure_bash_streak, 1);
2341 }
2342
2343 #[test]
2344 fn configured_tool_semantics_match_namespaced_and_mcp_tools() {
2345 let mut request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
2347 request.llm_request.extensions.fields.insert(
2348 TOOL_NAMESPACES_KEY.to_string(),
2349 json!({"mcp__billing__send_payment_request": "mcp__billing"}),
2350 );
2351
2352 let claude_request = with_messages(vec![tc("mcp__billing__send_payment_request")]);
2354
2355 for request in [&request, &claude_request] {
2356 for name in ["send_payment_request", "mcp__billing__send_payment_request"] {
2357 let semantics = ToolSemantics {
2358 mutate: vec![name.to_string()],
2359 ..Default::default()
2360 };
2361 let signal = ToolSignals::from_request_with_semantics(request, None, &semantics);
2362 assert_eq!(signal.write_count, 1, "{name}");
2363 }
2364 }
2365 }
2366
2367 #[test]
2368 fn configured_tool_semantics_only_fold_ascii_case() {
2369 let semantics = ToolSemantics {
2370 observe: vec!["kb_search".to_string()],
2371 ..Default::default()
2372 };
2373
2374 assert_eq!(
2375 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2376 ToolSemantic::Observe
2377 );
2378 assert_eq!(
2381 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
2382 ToolSemantic::Unknown
2383 );
2384 }
2385
2386 #[test]
2387 fn custom_semantics_preserve_builtin_unicode_lowercasing() {
2388 let semantics = ToolSemantics {
2389 observe: vec!["lookup_customer".to_string()],
2390 ..Default::default()
2391 };
2392
2393 assert_eq!(
2395 classify_tool_call_with_semantics("notebooKedit", None, &semantics),
2396 ToolSemantic::Mutate(MutationKind::Edit)
2397 );
2398 }
2399
2400 #[test]
2401 fn configured_semantics_never_replace_builtin_classifications() {
2402 let semantics = ToolSemantics {
2403 observe: vec!["lookup_customer".to_string()],
2404 mutate: vec!["send_payment".to_string()],
2405 plan: vec!["create_workflow".to_string()],
2406 new: vec!["send_message".to_string()],
2407 };
2408
2409 for name in WRITE_TOOL_NAMES {
2410 assert_eq!(
2411 classify_tool_call_with_semantics(name, None, &semantics),
2412 ToolSemantic::Mutate(MutationKind::Write),
2413 "write tool {name:?} changed classification"
2414 );
2415 }
2416 for name in EDIT_TOOL_NAMES {
2417 assert_eq!(
2418 classify_tool_call_with_semantics(name, None, &semantics),
2419 ToolSemantic::Mutate(MutationKind::Edit),
2420 "edit tool {name:?} changed classification"
2421 );
2422 }
2423 for name in READ_TOOL_NAMES {
2424 assert_eq!(
2425 classify_tool_call_with_semantics(name, None, &semantics),
2426 ToolSemantic::Observe,
2427 "read tool {name:?} changed classification"
2428 );
2429 }
2430 for name in PLAN_TOOL_NAMES {
2431 assert_eq!(
2432 classify_tool_call_with_semantics(name, None, &semantics),
2433 ToolSemantic::Plan,
2434 "plan tool {name:?} changed classification"
2435 );
2436 }
2437
2438 for (command, expected) in [
2439 ("cat /tmp/input", ToolSemantic::Observe),
2440 (
2441 "cat /tmp/input > /tmp/output",
2442 ToolSemantic::Mutate(MutationKind::Write),
2443 ),
2444 (
2445 "sed -i 's/a/b/' /tmp/file",
2446 ToolSemantic::Mutate(MutationKind::Edit),
2447 ),
2448 ("./run_tests.sh", ToolSemantic::Unknown),
2449 ] {
2450 assert_eq!(
2451 classify_tool_call_with_semantics("BASH", Some(command), &semantics),
2452 expected,
2453 "bash command {command:?} changed classification"
2454 );
2455 }
2456 }
2457
2458 #[test]
2459 fn configured_semantics_score_like_their_builtin_equivalents() {
2460 let semantics = ToolSemantics {
2461 observe: vec!["lookup_customer".to_string()],
2462 mutate: vec!["send_payment".to_string()],
2463 plan: vec!["create_workflow".to_string()],
2464 ..Default::default()
2465 };
2466
2467 for (builtin, configured) in [
2468 ("Read", "lookup_customer"),
2469 ("Write", "send_payment"),
2470 ("TodoWrite", "create_workflow"),
2471 ] {
2472 let messages_before_tool = || {
2473 vec![
2474 Message::text(Role::User, "start"),
2475 Message::text(Role::Assistant, "working"),
2476 Message::text(Role::User, "continue"),
2477 Message::text(Role::Assistant, "working"),
2478 Message::text(Role::User, "continue"),
2479 Message::text(Role::Assistant, "working"),
2480 Message::text(Role::User, "continue"),
2481 ]
2482 };
2483 let mut builtin_messages = messages_before_tool();
2484 builtin_messages.push(tc(builtin));
2485 let mut configured_messages = messages_before_tool();
2486 configured_messages.push(tc(configured));
2487
2488 let builtin_score = score_signal(&ToolSignals::from_request(
2489 &with_messages(builtin_messages),
2490 None,
2491 ));
2492 let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
2493 &with_messages(configured_messages),
2494 None,
2495 &semantics,
2496 ));
2497
2498 assert_ne!(
2499 builtin_score.score, 0.0,
2500 "the {builtin:?} control must exercise a scoring dimension"
2501 );
2502 assert_eq!(
2503 configured_score, builtin_score,
2504 "configured tool {configured:?} must score exactly like {builtin:?}"
2505 );
2506 }
2507 }
2508
2509 #[test]
2510 fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
2511 let duplicate = ToolSemantics {
2512 observe: vec!["lookup".to_string()],
2513 mutate: vec!["LOOKUP".to_string()],
2514 ..Default::default()
2515 };
2516 assert!(
2517 duplicate
2518 .validate()
2519 .expect_err("duplicate should fail")
2520 .to_string()
2521 .contains("appears in both")
2522 );
2523
2524 let builtin = ToolSemantics {
2525 new: vec!["write_file".to_string()],
2526 ..Default::default()
2527 };
2528 assert!(
2529 builtin
2530 .validate()
2531 .expect_err("built-in should fail")
2532 .to_string()
2533 .contains("built-in semantics")
2534 );
2535
2536 let empty = ToolSemantics {
2537 observe: vec![" \t".to_string()],
2538 ..Default::default()
2539 };
2540 assert!(
2541 empty
2542 .validate()
2543 .expect_err("empty name should fail")
2544 .to_string()
2545 .contains("empty tool name")
2546 );
2547 }
2548}