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