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