1#![allow(dead_code)]
14
15use async_trait::async_trait;
16use serde::Deserialize;
17use serde_json::Value;
18use switchyard_protocol::{ContentBlock, Request, Role};
19
20use crate::{LibsyError, Result};
21
22use crate::core::processor::{Event, Processor};
23use crate::core::state::State;
24
25const SOFT: f32 = 0.3;
28const HARD: f32 = 0.7;
29const CRITICAL: f32 = 1.0;
30
31static ERROR_PATTERNS: &[(&str, f32, &[&str])] = &[
35 (
36 "oom",
37 CRITICAL,
38 &["out of memory", "memoryerror", "cannot allocate memory"],
39 ),
40 (
41 "connection_refused",
42 CRITICAL,
43 &[
44 "connection refused",
45 "connectionrefusederror",
46 "econnrefused",
47 ],
48 ),
49 ("traceback", HARD, &["traceback (most recent call last)"]),
50 (
51 "import_error",
52 HARD,
53 &["modulenotfounderror:", "importerror:", "no module named "],
54 ),
55 (
56 "cmd_not_found",
57 HARD,
58 &["command not found", "not found\n", "/usr/bin/env: "],
59 ),
60 ("assertion", HARD, &["assertionerror"]),
61 ("value_error", HARD, &["valueerror:"]),
62 ("syntax_error", HARD, &["syntaxerror:"]),
63 (
64 "timeout",
65 HARD,
66 &[
67 "timed out",
68 "timeouterror",
69 "timeout expired",
70 "deadline exceeded",
71 ],
72 ),
73 (
74 "no_such_file",
75 HARD,
76 &[
77 "filenotfounderror:",
78 "no such file or directory",
79 "file does not exist",
83 ],
84 ),
85 (
87 "exit_nonzero",
88 SOFT,
89 &[
90 "exit code 1",
91 "exit code 2",
92 "exit status 1",
93 "returned non-zero",
94 "exited with code",
95 ],
96 ),
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 "> /",
126 ">> /",
127 "<< 'eof'",
128 "<<eof",
129 "<<'eof'",
130 "<< eof",
131];
132
133static PYTHON_WRITE_PATTERNS: &[&str] = &["write_text(", "writelines(", ".write("];
136
137static BASH_EDIT_PATTERNS: &[&str] = &[
138 "sed -i",
139 "sed --in-place",
140 "awk -i inplace",
141 "awk 'inplace=1'",
142 "patch ",
143 "patch -p",
144 "perl -i",
145 "perl -p -i",
146 "perl -pi",
147];
148
149static BASH_READ_PATTERNS: &[&str] = &[
152 "cat /", "cat ./", "cat ../", "grep ", "ls ", "ls -", "find ", "head ", "tail ", "wc ",
153 "diff ", "which ", "ps ", "df ", "du ", "stat ", "file ", "less ", "more ",
154];
155
156static READ_TOOL_NAMES: &[&str] = &["read", "view", "read_file", "search_files"];
157
158static PLAN_TOOL_NAMES: &[&str] = &["todowrite", "todo_write", "todo", "update_plan"];
161
162static BASH_TOOL_NAMES: &[&str] = &[
167 "bash",
168 "shell_command",
169 "shell",
170 "local_shell_call",
171 "terminal",
172 "exec_command", ];
174
175static TEST_PASS_PHRASES: &[&str] = &[
178 " passed",
179 "passed in",
180 "tests passed",
181 "all tests passed",
182 "test ok",
183 "test result: ok",
184 "passed.\n",
185 "tests pass",
186 "\nok ", "✓ ",
188];
189
190static TEST_FAILURE_LITERAL: &[&str] = &["✗ ", "fatal:", "assertionerror", "error:"];
195
196static NUMERIC_FAILURE_KEYWORDS: &[&str] = &["failed", "failure", "failures", "errors", "error"];
200
201pub const DEFAULT_RECENT_WINDOW: usize = 3;
208
209#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
214#[serde(default, deny_unknown_fields)]
215pub struct ToolSemantics {
216 pub observe: Vec<String>,
218 pub mutate: Vec<String>,
220 pub plan: Vec<String>,
222 pub new: Vec<String>,
224}
225
226impl ToolSemantics {
227 pub fn validate(&self) -> Result<()> {
229 let mut seen: Vec<(String, &'static str)> = Vec::new();
230 for (category, names) in [
231 ("observe", &self.observe),
232 ("mutate", &self.mutate),
233 ("plan", &self.plan),
234 ("new", &self.new),
235 ] {
236 for name in names {
237 if name.trim().is_empty() {
238 return Err(tool_semantics_error(format!(
239 "tool_semantics.{category} contains an empty tool name"
240 )));
241 }
242 let normalized = name.to_ascii_lowercase();
243 if is_builtin_tool_name(&name.to_lowercase()) {
244 return Err(tool_semantics_error(format!(
245 "tool {name:?} already has built-in semantics and cannot be reclassified"
246 )));
247 }
248 if let Some((_, previous)) = seen.iter().find(|(seen, _)| seen == &normalized) {
249 return Err(tool_semantics_error(format!(
250 "tool {name:?} appears in both tool_semantics.{previous} and tool_semantics.{category}"
251 )));
252 }
253 seen.push((normalized, category));
254 }
255 }
256 Ok(())
257 }
258
259 fn classify(&self, name: &str) -> Option<ToolSemantic> {
260 if contains_name(&self.observe, name) {
261 Some(ToolSemantic::Observe)
262 } else if contains_name(&self.mutate, name) {
263 Some(ToolSemantic::Mutate(MutationKind::Write))
266 } else if contains_name(&self.plan, name) {
267 Some(ToolSemantic::Plan)
268 } else if contains_name(&self.new, name) {
269 Some(ToolSemantic::New)
270 } else {
271 None
272 }
273 }
274}
275
276fn contains_name(names: &[String], candidate: &str) -> bool {
277 names
278 .iter()
279 .any(|name| name.eq_ignore_ascii_case(candidate))
280}
281
282fn tool_semantics_error(message: String) -> LibsyError {
283 LibsyError::AlgorithmError { message }
284}
285
286#[derive(Clone, Debug, Default)]
295pub struct ToolSignals {
296 pub severity: f32,
301 pub no_error_streak: u32,
303 pub edit_count: u32,
305 pub write_count: u32,
307 pub read_count: u32,
309 pub todowrite_count: u32,
312 pub recent_edit_count: u32,
314 pub recent_write_count: u32,
316 pub recent_read_count: u32,
318 pub recent_todowrite_count: u32,
320 pub new_count: u32,
322 pub recent_new_count: u32,
324 pub pure_bash_streak: u32,
327 pub tests_passed: bool,
329 pub tool_result_count: u32,
332 pub assistant_turn_count: u32,
335 pub turn_depth: u32,
339 pub compacted: bool,
345}
346
347impl ToolSignals {
348 pub fn from_request(request: &Request, window_size: Option<usize>) -> Self {
353 Self::from_request_with_semantics(request, window_size, &ToolSemantics::default())
354 }
355
356 pub fn from_request_with_semantics(
358 request: &Request,
359 window_size: Option<usize>,
360 semantics: &ToolSemantics,
361 ) -> Self {
362 extract_tool_signals_with_window_and_semantics(
363 request,
364 window_size.unwrap_or(DEFAULT_RECENT_WINDOW),
365 semantics,
366 )
367 }
368}
369
370#[derive(Debug, Clone)]
372struct ObservedToolCall {
373 name: String,
374 command: Option<String>,
375}
376
377#[derive(Debug, Clone, Copy, PartialEq, Eq)]
378enum MutationKind {
379 Write,
380 Edit,
381}
382
383#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385enum ToolSemantic {
386 Mutate(MutationKind),
387 Observe,
388 Plan,
389 New,
390 Unknown,
391}
392
393#[derive(Debug, Clone)]
396pub struct ToolSignalProcessor {
397 pub recent_window: usize,
400 pub tool_semantics: ToolSemantics,
402}
403
404impl Default for ToolSignalProcessor {
405 fn default() -> Self {
406 Self {
407 recent_window: DEFAULT_RECENT_WINDOW,
408 tool_semantics: ToolSemantics::default(),
409 }
410 }
411}
412
413#[async_trait]
414impl Processor<State> for ToolSignalProcessor {
415 async fn process(&self, state: &mut State, event: Event<'_>) -> Result<()> {
416 if let Event::Request { request: req, .. } = event {
417 let tool_signal = ToolSignals::from_request_with_semantics(
418 req,
419 Some(self.recent_window),
420 &self.tool_semantics,
421 );
422 state.tool_signals = Some(tool_signal);
423 }
424 Ok(())
425 }
426}
427
428fn classify_tool_call(name: &str, command: Option<&str>) -> ToolSemantic {
429 classify_tool_call_with_semantics(name, command, &ToolSemantics::default())
430}
431
432fn classify_tool_call_with_semantics(
433 name: &str,
434 command: Option<&str>,
435 semantics: &ToolSemantics,
436) -> ToolSemantic {
437 let lower = name.to_lowercase();
439 if WRITE_TOOL_NAMES.contains(&lower.as_str()) {
440 return ToolSemantic::Mutate(MutationKind::Write);
441 }
442 if EDIT_TOOL_NAMES.contains(&lower.as_str()) {
443 return ToolSemantic::Mutate(MutationKind::Edit);
444 }
445 if READ_TOOL_NAMES.contains(&lower.as_str()) {
446 return ToolSemantic::Observe;
447 }
448 if PLAN_TOOL_NAMES.contains(&lower.as_str()) {
449 return ToolSemantic::Plan;
450 }
451 if BASH_TOOL_NAMES.contains(&lower.as_str())
452 && let Some(cmd) = command
453 {
454 if BASH_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
456 return ToolSemantic::Mutate(MutationKind::Write);
457 }
458 if cmd.contains("python") && PYTHON_WRITE_PATTERNS.iter().any(|p| cmd.contains(p)) {
459 return ToolSemantic::Mutate(MutationKind::Write);
460 }
461 if BASH_EDIT_PATTERNS.iter().any(|p| cmd.contains(p)) {
462 return ToolSemantic::Mutate(MutationKind::Edit);
463 }
464 if BASH_READ_PATTERNS.iter().any(|p| cmd.contains(p)) {
465 return ToolSemantic::Observe;
466 }
467 }
468 semantics.classify(name).unwrap_or(ToolSemantic::Unknown)
469}
470
471fn is_builtin_tool_name(lower: &str) -> bool {
472 WRITE_TOOL_NAMES.contains(&lower)
473 || EDIT_TOOL_NAMES.contains(&lower)
474 || READ_TOOL_NAMES.contains(&lower)
475 || PLAN_TOOL_NAMES.contains(&lower)
476 || BASH_TOOL_NAMES.contains(&lower)
477}
478
479fn extract_tool_signals_with_window(request: &Request, recent_window: usize) -> ToolSignals {
486 extract_tool_signals_with_window_and_semantics(
487 request,
488 recent_window,
489 &ToolSemantics::default(),
490 )
491}
492
493fn extract_tool_signals_with_window_and_semantics(
494 request: &Request,
495 recent_window: usize,
496 semantics: &ToolSemantics,
497) -> ToolSignals {
498 let messages = &request.llm_request.messages;
502 let mut tool_texts: Vec<String> = Vec::new();
503 let mut tool_calls: Vec<ObservedToolCall> = Vec::new();
504 let mut compacted = false;
505 let mut tool_result_count = 0usize;
506 let mut assistant_turn_count = 0usize;
507
508 for message in messages {
509 if message.role == Role::Assistant {
510 assistant_turn_count += 1;
511 }
512 for block in &message.content {
513 match block {
514 ContentBlock::ToolCall(call) => {
515 tool_calls.push(ObservedToolCall {
516 name: call.name.clone(),
517 command: command_of(&call.arguments),
518 });
519 }
520 ContentBlock::ToolResult(result) => {
521 tool_result_count += 1;
523 let text = result
524 .content
525 .iter()
526 .filter_map(text_of)
527 .collect::<Vec<_>>()
528 .join("\n");
529 if !text.is_empty() {
530 tool_texts.push(text);
531 }
532 }
533 ContentBlock::Text { text } => {
537 compacted |= text.to_lowercase().contains(COMPACTION_MARKER);
538 }
539 _ => {}
540 }
541 }
542 }
543
544 let mut signal = build_signal(
545 tool_texts,
546 tool_calls,
547 messages.len() as u32,
548 recent_window,
549 semantics,
550 );
551 signal.compacted = compacted;
552 signal.tool_result_count = u32::try_from(tool_result_count).unwrap_or(u32::MAX);
553 signal.assistant_turn_count = u32::try_from(assistant_turn_count).unwrap_or(u32::MAX);
554 signal
555}
556
557const COMPACTION_MARKER: &str = "session is being continued";
560
561fn command_of(arguments: &Value) -> Option<String> {
564 let decoded = arguments
566 .as_str()
567 .and_then(|raw| serde_json::from_str::<Value>(raw).ok());
568 let object = decoded.as_ref().unwrap_or(arguments);
569
570 ["command", "cmd", "input"]
571 .iter()
572 .filter_map(|key| object.get(*key))
573 .find_map(command_text)
574}
575
576fn command_text(value: &Value) -> Option<String> {
578 match value {
579 Value::String(text) => Some(text.to_lowercase()),
580 Value::Array(parts) => {
581 let joined = parts
582 .iter()
583 .filter_map(Value::as_str)
584 .collect::<Vec<_>>()
585 .join(" ");
586 (!joined.is_empty()).then(|| joined.to_lowercase())
587 }
588 _ => None,
589 }
590}
591
592fn text_of(block: &ContentBlock) -> Option<&str> {
594 match block {
595 ContentBlock::Text { text } | ContentBlock::Refusal { text } => Some(text.as_str()),
596 _ => None,
597 }
598}
599
600fn build_signal(
601 tool_texts: Vec<String>,
602 tool_calls: Vec<ObservedToolCall>,
603 turn_depth: u32,
604 recent_window: usize,
605 semantics: &ToolSemantics,
606) -> ToolSignals {
607 let sev_start = tool_texts.len().saturating_sub(recent_window.max(1));
613 let mut severity = 0.0f32;
614 for text in &tool_texts[sev_start..] {
615 let (sev, _patterns) = classify_text(text);
616 if sev > severity {
617 severity = sev;
618 }
619 }
620
621 let no_error_streak = compute_no_error_streak(&tool_texts);
622
623 let recent_start = tool_calls.len().saturating_sub(recent_window);
627 let mut write_count = 0u32;
628 let mut edit_count = 0u32;
629 let mut read_count = 0u32;
630 let mut todowrite_count = 0u32;
631 let mut recent_write_count = 0u32;
632 let mut recent_edit_count = 0u32;
633 let mut recent_read_count = 0u32;
634 let mut recent_todowrite_count = 0u32;
635 let mut new_count = 0u32;
636 let mut recent_new_count = 0u32;
637 let mut pure_bash_streak = 0u32;
638 let mut streak_open = true;
639 for (i, tc) in tool_calls.iter().enumerate().rev() {
640 let cat = classify_tool_call_with_semantics(&tc.name, tc.command.as_deref(), semantics);
641 if streak_open {
642 if matches!(cat, ToolSemantic::Unknown) {
643 pure_bash_streak += 1;
644 } else {
645 streak_open = false;
646 }
647 }
648 match cat {
649 ToolSemantic::Mutate(MutationKind::Write) => {
650 write_count += 1;
651 if i >= recent_start {
652 recent_write_count += 1;
653 }
654 }
655 ToolSemantic::Mutate(MutationKind::Edit) => {
656 edit_count += 1;
657 if i >= recent_start {
658 recent_edit_count += 1;
659 }
660 }
661 ToolSemantic::Observe => {
662 read_count += 1;
663 if i >= recent_start {
664 recent_read_count += 1;
665 }
666 }
667 ToolSemantic::Plan => {
668 todowrite_count += 1;
669 if i >= recent_start {
670 recent_todowrite_count += 1;
671 }
672 }
673 ToolSemantic::New => {
674 new_count += 1;
675 if i >= recent_start {
676 recent_new_count += 1;
677 }
678 }
679 ToolSemantic::Unknown => {}
680 }
681 }
682
683 let tests_passed = detect_tests_passed(&tool_texts, recent_window);
684
685 ToolSignals {
686 severity,
687 no_error_streak,
688 edit_count,
689 write_count,
690 read_count,
691 todowrite_count,
692 recent_edit_count,
693 recent_write_count,
694 recent_read_count,
695 recent_todowrite_count,
696 new_count,
697 recent_new_count,
698 pure_bash_streak,
699 tests_passed,
700 turn_depth,
701 tool_result_count: 0,
705 assistant_turn_count: 0,
706 compacted: false,
707 }
708}
709
710fn content_to_text(content: Option<&Value>) -> Option<String> {
714 match content? {
715 Value::String(s) => Some(s.clone()),
716 Value::Array(blocks) => {
717 let parts: Vec<&str> = blocks
718 .iter()
719 .filter_map(|b| {
720 b.as_object()
721 .filter(|o| o.get("type").and_then(Value::as_str) == Some("text"))
722 .and_then(|o| o.get("text"))
723 .and_then(Value::as_str)
724 })
725 .collect();
726 if parts.is_empty() {
727 None
728 } else {
729 Some(parts.join("\n"))
730 }
731 }
732 _ => None,
733 }
734}
735
736pub(crate) fn classify_text(text: &str) -> (f32, Vec<String>) {
740 let lower = text.to_lowercase();
741 let mut patterns = Vec::new();
742 let mut severity: f32 = 0.0;
743 for (name, sev, substrings) in ERROR_PATTERNS {
744 if substrings.iter().any(|sub| lower.contains(sub)) {
745 patterns.push(name.to_string());
746 severity = severity.max(*sev);
747 }
748 }
749 (severity, patterns)
750}
751
752fn compute_no_error_streak(tool_texts: &[String]) -> u32 {
753 let mut streak = 0u32;
754 for text in tool_texts.iter().rev() {
755 let (sev, _) = classify_text(text);
756 if sev > 0.0 {
757 break;
758 }
759 streak += 1;
760 }
761 streak
762}
763
764fn detect_tests_passed(tool_texts: &[String], recent_window: usize) -> bool {
765 let start = tool_texts.len().saturating_sub(recent_window.max(1));
766 tool_texts[start..].iter().any(|text| {
767 let lower = text.to_lowercase();
768 TEST_PASS_PHRASES.iter().any(|p| lower.contains(p))
769 && !TEST_FAILURE_LITERAL.iter().any(|p| lower.contains(p))
770 && !has_nonzero_failure_count(&lower)
771 })
772}
773
774fn has_nonzero_failure_count(lower: &str) -> bool {
780 for kw in NUMERIC_FAILURE_KEYWORDS {
781 let mut cursor = 0usize;
782 while let Some(rel) = lower[cursor..].find(kw) {
783 let kw_start = cursor + rel;
784 let kw_end = kw_start + kw.len();
785 let boundary_after = lower[kw_end..]
788 .chars()
789 .next()
790 .is_none_or(|c| !c.is_ascii_alphanumeric());
791 if boundary_after {
792 let prefix = &lower[..kw_start];
793 let trimmed = prefix.trim_end_matches(|c: char| c.is_whitespace());
794 let digits_rev: String = trimmed
795 .chars()
796 .rev()
797 .take_while(|c| c.is_ascii_digit())
798 .collect();
799 if !digits_rev.is_empty() && digits_rev.chars().any(|d| d != '0') {
800 return true;
801 }
802 }
803 cursor = kw_start + kw.len();
804 }
805 }
806 false
807}
808
809#[cfg(test)]
812mod tests {
813 use super::*;
814 use crate::algorithms::util::stage::score_signal;
815 use serde_json::json;
816 use switchyard_protocol::{ContentBlock, LlmRequest, Message, Role, ToolCall, ToolResult};
817
818 fn with_messages(messages: Vec<Message>) -> Request {
819 Request {
820 llm_request: LlmRequest {
821 messages,
822 ..LlmRequest::default()
823 },
824 raw_request: None,
825 metadata: None,
826 }
827 }
828
829 fn tc(name: &str) -> Message {
831 Message {
832 role: Role::Assistant,
833 content: vec![ContentBlock::ToolCall(ToolCall {
834 id: String::new(),
835 name: name.to_string(),
836 arguments: json!({}),
837 })],
838 }
839 }
840
841 fn bash(command: &str) -> Message {
843 Message {
844 role: Role::Assistant,
845 content: vec![ContentBlock::ToolCall(ToolCall {
846 id: String::new(),
847 name: "Bash".to_string(),
848 arguments: json!({"command": command}),
849 })],
850 }
851 }
852
853 fn tr(text: &str) -> Message {
855 Message {
856 role: Role::User,
857 content: vec![ContentBlock::ToolResult(ToolResult {
858 tool_call_id: String::new(),
859 content: vec![ContentBlock::Text {
860 text: text.to_string(),
861 }],
862 is_error: None,
863 })],
864 }
865 }
866
867 #[test]
868 fn clean_text_has_zero_severity() {
869 let (sev, patterns) = classify_text("everything went fine");
870 assert_eq!(sev, 0.0);
871 assert!(patterns.is_empty());
872 }
873
874 #[test]
875 fn traceback_is_hard() {
876 let (sev, patterns) = classify_text("Traceback (most recent call last):\n ValueError");
877 assert_eq!(sev, HARD);
878 assert!(patterns.contains(&"traceback".to_string()));
879 }
880
881 #[test]
882 fn oom_is_critical() {
883 let (sev, _) = classify_text("Out of memory: kill process 1234");
884 assert_eq!(sev, CRITICAL);
885 }
886
887 #[test]
888 fn severity_is_max_across_patterns() {
889 let (sev, _) = classify_text("exit code 1\nTraceback (most recent call last):");
891 assert_eq!(sev, HARD);
892 }
893
894 #[test]
895 fn file_does_not_exist_is_hard() {
896 let (sev, patterns) =
898 classify_text("Error: File does not exist. Note: current working directory is /app.");
899 assert_eq!(sev, HARD);
900 assert!(patterns.contains(&"no_such_file".to_string()));
901 }
902
903 #[test]
904 fn bare_does_not_exist_stays_clean() {
905 let (sev, _) = classify_text("The directory does not exist yet, creating it now.");
908 assert_eq!(sev, 0.0);
909 }
910
911 #[test]
912 fn no_error_streak_all_clean() {
913 let texts = vec!["ok".to_string(), "all good".to_string()];
914 assert_eq!(compute_no_error_streak(&texts), 2);
915 }
916
917 #[test]
918 fn no_error_streak_stops_at_error() {
919 let texts = vec![
920 "Traceback (most recent call last):".to_string(),
921 "ok".to_string(),
922 "ok".to_string(),
923 ];
924 assert_eq!(compute_no_error_streak(&texts), 2);
925 }
926
927 #[test]
928 fn tests_passed_detects_pytest_output() {
929 assert!(detect_tests_passed(
930 &["====== 5 passed in 0.12s ======".to_string()],
931 DEFAULT_RECENT_WINDOW
932 ));
933 }
934
935 #[test]
936 fn tests_passed_ignores_partial_failures() {
937 assert!(!detect_tests_passed(
938 &["2 failed, 5 passed in 0.56s".to_string()],
939 DEFAULT_RECENT_WINDOW
940 ));
941 }
942
943 #[test]
944 fn severity_is_windowed_over_recent_results() {
945 let request = with_messages(vec![
947 tr("Traceback (most recent call last):\n ValueError"),
948 tr("ok"),
949 tr("ok"),
950 ]);
951 assert_eq!(extract_tool_signals_with_window(&request, 3).severity, HARD);
953 assert_eq!(extract_tool_signals_with_window(&request, 1).severity, 0.0);
955 }
956
957 #[test]
958 fn extract_openai_chat_tool_results() {
959 let request = with_messages(vec![
960 Message::text(Role::User, "do something"),
961 tc("Edit"),
962 tr("Traceback (most recent call last):\n ValueError"),
963 ]);
964 let sig = ToolSignals::from_request(&request, None);
965 assert_eq!(sig.severity, HARD);
966 assert_eq!(sig.edit_count, 1);
967 assert_eq!(sig.turn_depth, 3);
968 }
969
970 #[test]
971 fn extract_anthropic_tool_results() {
972 let request = with_messages(vec![tr("Traceback (most recent call last):\n ValueError")]);
973 let sig = ToolSignals::from_request(&request, None);
974 assert_eq!(sig.severity, HARD);
975 }
976
977 #[test]
978 fn extract_responses_api_tool_results() {
979 let request = with_messages(vec![tc("Write"), tr("file written successfully")]);
980 let sig = ToolSignals::from_request(&request, None);
981 assert_eq!(sig.severity, 0.0);
982 assert_eq!(sig.write_count, 1);
983 }
984
985 #[test]
986 fn conversation_counts_are_per_block_and_role_aware() {
987 let result = |content: Vec<ContentBlock>| {
991 ContentBlock::ToolResult(ToolResult {
992 tool_call_id: String::new(),
993 content,
994 is_error: None,
995 })
996 };
997 let request = with_messages(vec![
998 Message::text(Role::User, "do something"),
999 Message::text(Role::Assistant, "working"),
1000 Message {
1001 role: Role::User,
1002 content: vec![
1003 result(vec![ContentBlock::Text {
1004 text: "ok".to_string(),
1005 }]),
1006 result(Vec::new()),
1007 ],
1008 },
1009 tc("Bash"),
1010 ]);
1011 let sig = ToolSignals::from_request(&request, None);
1012 assert_eq!(sig.tool_result_count, 2);
1013 assert_eq!(sig.assistant_turn_count, 2);
1014 assert_eq!(sig.turn_depth, 4);
1015 }
1016
1017 #[test]
1018 fn recent_window_counts_only_last_default_window_tool_calls() {
1019 let request = with_messages(vec![
1022 tc("Write"),
1023 tr("ok"),
1024 tc("Write"),
1025 tr("ok"),
1026 tc("Write"),
1027 tr("ok"),
1028 tc("Write"),
1029 tr("ok"),
1030 tc("Write"),
1031 tr("ok"),
1032 tc("Edit"),
1033 tr("ok"),
1034 ]);
1035 let sig = ToolSignals::from_request(&request, None);
1036 assert_eq!(sig.write_count, 5);
1037 assert_eq!(sig.edit_count, 1);
1038 assert_eq!(sig.recent_write_count, 2);
1039 assert_eq!(sig.recent_edit_count, 1);
1040 }
1041
1042 #[test]
1043 fn codex_apply_patch_counts_as_an_edit() {
1044 let request = with_messages(vec![tc("apply_patch"), tr("Success. Updated the file")]);
1045 let sig = ToolSignals::from_request(&request, None);
1046 assert_eq!(sig.edit_count, 1);
1047 assert_eq!(sig.recent_edit_count, 1);
1048 }
1049
1050 fn exec_command(cmd: Value) -> Message {
1051 Message {
1052 role: Role::Assistant,
1053 content: vec![ContentBlock::ToolCall(ToolCall {
1054 id: String::new(),
1055 name: "exec_command".to_string(),
1056 arguments: cmd,
1057 })],
1058 }
1059 }
1060
1061 #[test]
1062 fn codex_exec_command_is_classified() {
1063 let args = json!(r#"{"cmd":"sed -i s/a/b/ src/lib.rs","workdir":"/x"}"#);
1065 let request = with_messages(vec![exec_command(args), tr("ok")]);
1066 assert_eq!(
1067 ToolSignals::from_request(&request, None).recent_edit_count,
1068 1
1069 );
1070 }
1071
1072 #[test]
1073 fn python_write_expressions_need_a_python_command() {
1074 let write = with_messages(vec![
1075 exec_command(json!({"cmd": "python3 - <<'PY'\np.write_text(s)\nPY"})),
1076 tr("ok"),
1077 ]);
1078 assert_eq!(
1079 ToolSignals::from_request(&write, None).recent_write_count,
1080 1
1081 );
1082
1083 let search = with_messages(vec![
1084 exec_command(json!({"cmd": "grep -R '.write(' src"})),
1085 tr("ok"),
1086 ]);
1087 assert_eq!(
1088 ToolSignals::from_request(&search, None).recent_write_count,
1089 0
1090 );
1091 }
1092
1093 #[test]
1094 fn recent_window_size_is_caller_overridable() {
1095 let request = with_messages(vec![
1099 tc("Write"),
1100 tr("ok"),
1101 tc("Write"),
1102 tr("ok"),
1103 tc("Write"),
1104 tr("ok"),
1105 tc("Write"),
1106 tr("ok"),
1107 tc("Write"),
1108 tr("ok"),
1109 tc("Edit"),
1110 tr("ok"),
1111 ]);
1112 let narrow = extract_tool_signals_with_window(&request, 3);
1113 assert_eq!(narrow.recent_write_count, 2);
1114 assert_eq!(narrow.recent_edit_count, 1);
1115
1116 let wide = extract_tool_signals_with_window(&request, 6);
1117 assert_eq!(wide.recent_write_count, 5);
1118 assert_eq!(wide.recent_edit_count, 1);
1119 }
1120
1121 #[test]
1122 fn compaction_marker_sets_compacted() {
1123 let request = with_messages(vec![
1125 Message::text(
1126 Role::User,
1127 "This session is being continued from a previous conversation that ran out of context.",
1128 ),
1129 bash("ls"),
1130 ]);
1131 assert!(ToolSignals::from_request(&request, None).compacted);
1132 }
1133
1134 #[test]
1135 fn no_compaction_marker_stays_uncompacted() {
1136 let request = with_messages(vec![
1137 Message::text(Role::User, "Write a script that parses the log file."),
1138 bash("ls"),
1139 ]);
1140 assert!(!ToolSignals::from_request(&request, None).compacted);
1141 }
1142
1143 #[test]
1144 fn bash_heredoc_counts_as_write() {
1145 let request = with_messages(vec![bash("cat > /tmp/test.py <<'EOF'\nprint(1)\nEOF")]);
1147 let sig = ToolSignals::from_request(&request, None);
1148 assert_eq!(
1149 sig.write_count, 1,
1150 "Bash heredoc should bucket into write_count"
1151 );
1152 assert_eq!(sig.edit_count, 0);
1153 }
1154
1155 #[test]
1156 fn bash_redirection_after_arguments_counts_as_write() {
1157 let request = with_messages(vec![bash("printf 'completed\\n' > task.txt")]);
1158 let sig = ToolSignals::from_request(&request, None);
1159 assert_eq!(sig.write_count, 1);
1160 assert_eq!(sig.edit_count, 0);
1161 }
1162
1163 #[test]
1164 fn bash_sed_inplace_counts_as_edit() {
1165 let request = with_messages(vec![bash("sed -i 's/foo/bar/g' /app/file.py")]);
1166 let sig = ToolSignals::from_request(&request, None);
1167 assert_eq!(
1168 sig.edit_count, 1,
1169 "Bash sed -i should bucket into edit_count"
1170 );
1171 assert_eq!(sig.write_count, 0);
1172 }
1173
1174 #[test]
1175 fn bash_non_mutating_does_not_count() {
1176 let request = with_messages(vec![bash("ls -la /app"), bash("cat /app/main.py")]);
1178 let sig = ToolSignals::from_request(&request, None);
1179 assert_eq!(sig.write_count, 0);
1180 assert_eq!(sig.edit_count, 0);
1181 }
1182
1183 #[test]
1184 fn tests_passed_detects_pytest_with_failure_block() {
1185 assert!(!detect_tests_passed(
1187 &["2 failed, 5 passed in 0.56s".to_string()],
1188 DEFAULT_RECENT_WINDOW
1189 ));
1190 }
1191
1192 #[test]
1193 fn tests_passed_accepts_cargo_clean_summary() {
1194 assert!(detect_tests_passed(
1197 &["running 3 tests\ntest result: ok. 3 passed; 0 failed; 0 ignored".to_string()],
1198 DEFAULT_RECENT_WINDOW
1199 ));
1200 }
1201
1202 #[test]
1203 fn tests_passed_rejects_cargo_real_failure() {
1204 assert!(!detect_tests_passed(
1206 &["running 3 tests\ntest result: FAILED. 2 passed; 1 failed; 0 ignored".to_string()],
1207 DEFAULT_RECENT_WINDOW
1208 ));
1209 }
1210
1211 #[test]
1212 fn tests_passed_accepts_go_clean_summary() {
1213 assert!(detect_tests_passed(
1215 &["ok github.com/foo/bar\t0.012s (5 passed, 0 errors)".to_string()],
1216 DEFAULT_RECENT_WINDOW
1217 ));
1218 }
1219
1220 #[test]
1221 fn tests_passed_accepts_pytest_zero_errors() {
1222 assert!(detect_tests_passed(
1224 &["5 passed, 0 errors in 0.30s".to_string()],
1225 DEFAULT_RECENT_WINDOW
1226 ));
1227 }
1228
1229 #[test]
1230 fn tests_passed_detects_diy_checkmark() {
1231 assert!(detect_tests_passed(
1232 &["✓ all checks passed".to_string()],
1233 DEFAULT_RECENT_WINDOW
1234 ));
1235 }
1236
1237 #[test]
1238 fn anthropic_bash_heredoc_extracts_command() {
1239 let request = with_messages(vec![bash("cat > /tmp/foo.txt << 'EOF'\nhi\nEOF")]);
1241 let sig = ToolSignals::from_request(&request, None);
1242 assert_eq!(
1243 sig.write_count, 1,
1244 "Anthropic Bash heredoc must also be detected"
1245 );
1246 }
1247
1248 #[test]
1249 fn recent_window_falls_back_to_full_history_when_short() {
1250 let request = with_messages(vec![tc("Write")]);
1251 let sig = ToolSignals::from_request(&request, None);
1252 assert_eq!(sig.recent_write_count, 1);
1253 assert_eq!(sig.recent_edit_count, 0);
1254 }
1255
1256 #[test]
1257 fn clean_tool_result_has_zero_severity_and_non_empty_streak() {
1258 let request = with_messages(vec![tr("output ok"), tr("another ok")]);
1259 let sig = ToolSignals::from_request(&request, None);
1260 assert_eq!(sig.severity, 0.0);
1261 assert_eq!(sig.no_error_streak, 2);
1262 }
1263
1264 #[test]
1267 fn todowrite_classifies_as_plan() {
1268 assert_eq!(classify_tool_call("TodoWrite", None), ToolSemantic::Plan);
1269 assert_eq!(classify_tool_call("todo_write", None), ToolSemantic::Plan);
1270 }
1271
1272 #[test]
1273 fn codex_update_plan_classifies_as_plan() {
1274 assert_eq!(classify_tool_call("update_plan", None), ToolSemantic::Plan);
1275 }
1276
1277 #[test]
1278 fn codex_shell_command_runs_bash_pattern_match() {
1279 assert_eq!(
1281 classify_tool_call("shell_command", Some("cat > /app/foo.py <<'eof'\nx=1\neof")),
1282 ToolSemantic::Mutate(MutationKind::Write),
1283 );
1284 assert_eq!(
1286 classify_tool_call("shell_command", Some("ls /app")),
1287 ToolSemantic::Observe,
1288 );
1289 assert_eq!(
1291 classify_tool_call("shell_command", Some("./run_tests.sh")),
1292 ToolSemantic::Unknown,
1293 );
1294 }
1295
1296 #[test]
1297 fn read_tool_classifies_as_read() {
1298 assert_eq!(classify_tool_call("Read", None), ToolSemantic::Observe);
1299 assert_eq!(classify_tool_call("View", None), ToolSemantic::Observe);
1300 }
1301
1302 #[test]
1303 fn hermes_tool_names_classify() {
1304 assert_eq!(
1306 classify_tool_call("write_file", None),
1307 ToolSemantic::Mutate(MutationKind::Write)
1308 );
1309 assert_eq!(
1310 classify_tool_call("patch", None),
1311 ToolSemantic::Mutate(MutationKind::Edit)
1312 );
1313 assert_eq!(classify_tool_call("read_file", None), ToolSemantic::Observe);
1314 assert_eq!(
1315 classify_tool_call("search_files", None),
1316 ToolSemantic::Observe
1317 );
1318 assert_eq!(
1321 classify_tool_call("terminal", Some("sed -i 's/a/b/' /app/x.py")),
1322 ToolSemantic::Mutate(MutationKind::Edit),
1323 );
1324 assert_eq!(
1325 classify_tool_call("terminal", Some("grep foo /app")),
1326 ToolSemantic::Observe,
1327 );
1328 assert_eq!(
1329 classify_tool_call("terminal", Some("./run_tests.sh")),
1330 ToolSemantic::Unknown,
1331 );
1332 }
1333
1334 #[test]
1335 fn bash_read_patterns_classify_as_read() {
1336 let cases = [
1337 "cat /etc/passwd",
1338 "grep foo bar.txt",
1339 "ls /app",
1340 "find . -name '*.py'",
1341 ];
1342 for cmd in cases {
1343 assert_eq!(
1344 classify_tool_call("Bash", Some(cmd)),
1345 ToolSemantic::Observe,
1346 "expected Read for {cmd}"
1347 );
1348 }
1349 }
1350
1351 #[test]
1352 fn bash_write_precedence_over_read() {
1353 assert_eq!(
1356 classify_tool_call("Bash", Some("cat /etc/hosts > /tmp/out")),
1357 ToolSemantic::Mutate(MutationKind::Write),
1358 );
1359 }
1360
1361 #[test]
1362 fn pure_bash_streak_counts_trailing_other() {
1363 let request = with_messages(vec![
1365 bash("make"),
1366 tr("ok"),
1367 bash("./configure"),
1368 tr("ok"),
1369 bash("make install"),
1370 tr("ok"),
1371 bash("./run.sh"),
1372 tr("ok"),
1373 bash("./test"),
1374 tr("ok"),
1375 ]);
1376 let sig = ToolSignals::from_request(&request, None);
1377 assert_eq!(sig.pure_bash_streak, 5);
1378 assert_eq!(sig.write_count, 0);
1379 assert_eq!(sig.read_count, 0);
1380 }
1381
1382 #[test]
1383 fn pure_bash_streak_resets_on_write() {
1384 let request = with_messages(vec![bash("make"), tr("ok"), tc("Write"), tr("ok")]);
1385 let sig = ToolSignals::from_request(&request, None);
1386 assert_eq!(sig.pure_bash_streak, 0);
1387 assert_eq!(sig.write_count, 1);
1388 }
1389
1390 #[test]
1391 fn recent_window_tracks_todowrite_and_read() {
1392 let request = with_messages(vec![
1394 bash("make"),
1395 tr("ok"),
1396 tc("TodoWrite"),
1397 tr("ok"),
1398 tc("Read"),
1399 tr("ok"),
1400 tc("TodoWrite"),
1401 tr("ok"),
1402 ]);
1403 let sig = ToolSignals::from_request(&request, None);
1404 assert_eq!(sig.todowrite_count, 2);
1405 assert_eq!(sig.recent_todowrite_count, 2);
1406 assert_eq!(sig.read_count, 1);
1407 assert_eq!(sig.recent_read_count, 1);
1408 }
1409
1410 #[test]
1411 fn configured_tool_semantics_extend_the_builtin_vocabulary() {
1412 let semantics = ToolSemantics {
1413 observe: vec!["KB_search".to_string()],
1414 mutate: vec!["send_payment_request".to_string()],
1415 plan: vec!["create_research_plan".to_string()],
1416 new: vec!["send_message_to_user".to_string()],
1417 };
1418 semantics.validate().expect("valid additive semantics");
1419 let request = with_messages(vec![
1420 tc("Read"),
1421 tc("Write"),
1422 tc("TodoWrite"),
1423 tc("kb_SEARCH"),
1424 tc("send_payment_request"),
1425 tc("create_research_plan"),
1426 tc("send_message_to_user"),
1427 tc("unlisted_tool"),
1428 ]);
1429
1430 let signal = ToolSignals::from_request_with_semantics(&request, None, &semantics);
1431
1432 assert_eq!(signal.read_count, 2);
1433 assert_eq!(signal.write_count, 2);
1434 assert_eq!(signal.todowrite_count, 2);
1435 assert_eq!(signal.new_count, 1);
1436 assert_eq!(signal.recent_new_count, 1);
1437 assert_eq!(signal.pure_bash_streak, 1);
1438 }
1439
1440 #[test]
1441 fn configured_tool_semantics_only_fold_ascii_case() {
1442 let semantics = ToolSemantics {
1443 observe: vec!["kb_search".to_string()],
1444 ..Default::default()
1445 };
1446
1447 assert_eq!(
1448 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
1449 ToolSemantic::Observe
1450 );
1451 assert_eq!(
1454 classify_tool_call_with_semantics("KB_SEARCH", None, &semantics),
1455 ToolSemantic::Unknown
1456 );
1457 }
1458
1459 #[test]
1460 fn custom_semantics_preserve_builtin_unicode_lowercasing() {
1461 let semantics = ToolSemantics {
1462 observe: vec!["lookup_customer".to_string()],
1463 ..Default::default()
1464 };
1465
1466 assert_eq!(
1468 classify_tool_call_with_semantics("notebooKedit", None, &semantics),
1469 ToolSemantic::Mutate(MutationKind::Edit)
1470 );
1471 }
1472
1473 #[test]
1474 fn configured_semantics_never_replace_builtin_classifications() {
1475 let semantics = ToolSemantics {
1476 observe: vec!["lookup_customer".to_string()],
1477 mutate: vec!["send_payment".to_string()],
1478 plan: vec!["create_workflow".to_string()],
1479 new: vec!["send_message".to_string()],
1480 };
1481
1482 for name in WRITE_TOOL_NAMES {
1483 assert_eq!(
1484 classify_tool_call_with_semantics(name, None, &semantics),
1485 ToolSemantic::Mutate(MutationKind::Write),
1486 "write tool {name:?} changed classification"
1487 );
1488 }
1489 for name in EDIT_TOOL_NAMES {
1490 assert_eq!(
1491 classify_tool_call_with_semantics(name, None, &semantics),
1492 ToolSemantic::Mutate(MutationKind::Edit),
1493 "edit tool {name:?} changed classification"
1494 );
1495 }
1496 for name in READ_TOOL_NAMES {
1497 assert_eq!(
1498 classify_tool_call_with_semantics(name, None, &semantics),
1499 ToolSemantic::Observe,
1500 "read tool {name:?} changed classification"
1501 );
1502 }
1503 for name in PLAN_TOOL_NAMES {
1504 assert_eq!(
1505 classify_tool_call_with_semantics(name, None, &semantics),
1506 ToolSemantic::Plan,
1507 "plan tool {name:?} changed classification"
1508 );
1509 }
1510
1511 for (command, expected) in [
1512 ("cat /tmp/input", ToolSemantic::Observe),
1513 (
1514 "cat /tmp/input > /tmp/output",
1515 ToolSemantic::Mutate(MutationKind::Write),
1516 ),
1517 (
1518 "sed -i 's/a/b/' /tmp/file",
1519 ToolSemantic::Mutate(MutationKind::Edit),
1520 ),
1521 ("./run_tests.sh", ToolSemantic::Unknown),
1522 ] {
1523 assert_eq!(
1524 classify_tool_call_with_semantics("BASH", Some(command), &semantics),
1525 expected,
1526 "bash command {command:?} changed classification"
1527 );
1528 }
1529 }
1530
1531 #[test]
1532 fn configured_semantics_score_like_their_builtin_equivalents() {
1533 let semantics = ToolSemantics {
1534 observe: vec!["lookup_customer".to_string()],
1535 mutate: vec!["send_payment".to_string()],
1536 plan: vec!["create_workflow".to_string()],
1537 ..Default::default()
1538 };
1539
1540 for (builtin, configured) in [
1541 ("Read", "lookup_customer"),
1542 ("Write", "send_payment"),
1543 ("TodoWrite", "create_workflow"),
1544 ] {
1545 let messages_before_tool = || {
1546 vec![
1547 Message::text(Role::User, "start"),
1548 Message::text(Role::Assistant, "working"),
1549 Message::text(Role::User, "continue"),
1550 Message::text(Role::Assistant, "working"),
1551 Message::text(Role::User, "continue"),
1552 Message::text(Role::Assistant, "working"),
1553 Message::text(Role::User, "continue"),
1554 ]
1555 };
1556 let mut builtin_messages = messages_before_tool();
1557 builtin_messages.push(tc(builtin));
1558 let mut configured_messages = messages_before_tool();
1559 configured_messages.push(tc(configured));
1560
1561 let builtin_score = score_signal(&ToolSignals::from_request(
1562 &with_messages(builtin_messages),
1563 None,
1564 ));
1565 let configured_score = score_signal(&ToolSignals::from_request_with_semantics(
1566 &with_messages(configured_messages),
1567 None,
1568 &semantics,
1569 ));
1570
1571 assert_ne!(
1572 builtin_score.score, 0.0,
1573 "the {builtin:?} control must exercise a scoring dimension"
1574 );
1575 assert_eq!(
1576 configured_score, builtin_score,
1577 "configured tool {configured:?} must score exactly like {builtin:?}"
1578 );
1579 }
1580 }
1581
1582 #[test]
1583 fn tool_semantics_reject_duplicates_and_builtin_reclassification() {
1584 let duplicate = ToolSemantics {
1585 observe: vec!["lookup".to_string()],
1586 mutate: vec!["LOOKUP".to_string()],
1587 ..Default::default()
1588 };
1589 assert!(
1590 duplicate
1591 .validate()
1592 .expect_err("duplicate should fail")
1593 .to_string()
1594 .contains("appears in both")
1595 );
1596
1597 let builtin = ToolSemantics {
1598 new: vec!["write_file".to_string()],
1599 ..Default::default()
1600 };
1601 assert!(
1602 builtin
1603 .validate()
1604 .expect_err("built-in should fail")
1605 .to_string()
1606 .contains("built-in semantics")
1607 );
1608
1609 let empty = ToolSemantics {
1610 observe: vec![" \t".to_string()],
1611 ..Default::default()
1612 };
1613 assert!(
1614 empty
1615 .validate()
1616 .expect_err("empty name should fail")
1617 .to_string()
1618 .contains("empty tool name")
1619 );
1620 }
1621}