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