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