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