1use std::fmt;
5
6use zeph_common::ToolName;
7
8use crate::shell::background::RunId;
9
10#[derive(Debug, Clone)]
15pub struct DiffData {
16 pub file_path: String,
18 pub old_content: String,
20 pub new_content: String,
22}
23
24#[derive(Debug, Clone)]
49pub struct ToolCall {
50 pub tool_id: ToolName,
52 pub params: serde_json::Map<String, serde_json::Value>,
54 pub caller_id: Option<String>,
57 pub context: Option<crate::ExecutionContext>,
60 pub tool_call_id: String,
63}
64
65#[derive(Debug, Clone, Default)]
70pub struct FilterStats {
71 pub raw_chars: usize,
73 pub filtered_chars: usize,
75 pub raw_lines: usize,
77 pub filtered_lines: usize,
79 pub confidence: Option<crate::FilterConfidence>,
81 pub command: Option<String>,
83 pub kept_lines: Vec<usize>,
85}
86
87impl FilterStats {
88 #[must_use]
92 #[allow(clippy::cast_precision_loss)]
93 pub fn savings_pct(&self) -> f64 {
94 if self.raw_chars == 0 {
95 return 0.0;
96 }
97 (1.0 - self.filtered_chars as f64 / self.raw_chars as f64) * 100.0
98 }
99
100 #[must_use]
105 pub fn estimated_tokens_saved(&self) -> usize {
106 self.raw_chars.saturating_sub(self.filtered_chars) / 4
107 }
108
109 #[must_use]
128 pub fn format_inline(&self, tool_name: &str) -> String {
129 let cmd_label = self
130 .command
131 .as_deref()
132 .map(|c| {
133 let trimmed = c.trim();
134 if trimmed.len() > 60 {
135 format!(" `{}…`", &trimmed[..57])
136 } else {
137 format!(" `{trimmed}`")
138 }
139 })
140 .unwrap_or_default();
141 format!(
142 "[{tool_name}]{cmd_label} {} lines \u{2192} {} lines, {:.1}% filtered",
143 self.raw_lines,
144 self.filtered_lines,
145 self.savings_pct()
146 )
147 }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
156#[serde(rename_all = "snake_case")]
157pub enum ClaimSource {
158 Shell,
160 FileSystem,
162 WebScrape,
164 Mcp,
166 A2a,
168 CodeSearch,
170 Diagnostics,
172 Memory,
174 Moderation,
176}
177
178#[derive(Debug, Clone)]
204pub struct ToolOutput {
205 pub tool_name: ToolName,
207 pub summary: String,
209 pub blocks_executed: u32,
211 pub filter_stats: Option<FilterStats>,
213 pub diff: Option<DiffData>,
215 pub streamed: bool,
217 pub terminal_id: Option<String>,
219 pub locations: Option<Vec<String>>,
221 pub raw_response: Option<serde_json::Value>,
223 pub claim_source: Option<ClaimSource>,
226}
227
228impl fmt::Display for ToolOutput {
229 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
230 f.write_str(&self.summary)
231 }
232}
233
234pub const MAX_TOOL_OUTPUT_CHARS: usize = 30_000;
239
240#[must_use]
253pub fn truncate_tool_output(output: &str) -> String {
254 truncate_tool_output_at(output, MAX_TOOL_OUTPUT_CHARS)
255}
256
257#[must_use]
273pub fn truncate_tool_output_at(output: &str, max_chars: usize) -> String {
274 if output.len() <= max_chars {
275 return output.to_string();
276 }
277
278 let half = max_chars / 2;
279 let head_end = output.floor_char_boundary(half);
280 let tail_start = output.ceil_char_boundary(output.len() - half);
281 let head = &output[..head_end];
282 let tail = &output[tail_start..];
283 let truncated = output.len() - head_end - (output.len() - tail_start);
284
285 format!(
286 "{head}\n\n... [truncated {truncated} chars, showing first and last ~{half} chars] ...\n\n{tail}"
287 )
288}
289
290#[derive(Debug, Clone)]
295pub enum ToolEvent {
296 Started {
298 tool_name: ToolName,
299 command: String,
300 sandbox_profile: Option<String>,
302 resolved_cwd: Option<String>,
305 execution_env: Option<String>,
308 },
309 OutputChunk {
311 tool_name: ToolName,
312 command: String,
313 chunk: String,
314 tool_call_id: String,
317 },
318 Completed {
320 tool_name: ToolName,
321 command: String,
322 output: String,
324 success: bool,
326 filter_stats: Option<FilterStats>,
327 diff: Option<DiffData>,
328 run_id: Option<RunId>,
330 },
331 Rollback {
333 tool_name: ToolName,
334 command: String,
335 restored_count: usize,
337 deleted_count: usize,
339 },
340}
341
342pub type ToolEventTx = tokio::sync::mpsc::Sender<ToolEvent>;
350
351pub type ToolEventRx = tokio::sync::mpsc::Receiver<ToolEvent>;
353
354pub const TOOL_EVENT_CHANNEL_CAP: usize = 1024;
356
357#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
362pub enum ErrorKind {
363 Transient,
364 Permanent,
365}
366
367impl std::fmt::Display for ErrorKind {
368 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369 match self {
370 Self::Transient => f.write_str("transient"),
371 Self::Permanent => f.write_str("permanent"),
372 }
373 }
374}
375
376#[derive(Debug, thiserror::Error)]
378pub enum ToolError {
379 #[error("command blocked by policy: {command}")]
380 Blocked { command: String },
381
382 #[error("path not allowed by sandbox: {path}")]
383 SandboxViolation { path: String },
384
385 #[error("command requires confirmation: {command}")]
386 ConfirmationRequired { command: String },
387
388 #[error("command timed out after {timeout_secs}s")]
389 Timeout { timeout_secs: u64 },
390
391 #[error("operation cancelled")]
392 Cancelled,
393
394 #[error("invalid tool parameters: {message}")]
395 InvalidParams { message: String },
396
397 #[error("execution failed: {0}")]
398 Execution(#[from] std::io::Error),
399
400 #[error("HTTP error {status}: {message}")]
405 Http { status: u16, message: String },
406
407 #[error("shell error (exit {exit_code}): {message}")]
413 Shell {
414 exit_code: i32,
415 category: crate::error_taxonomy::ToolErrorCategory,
416 message: String,
417 },
418
419 #[error("snapshot failed: {reason}")]
420 SnapshotFailed { reason: String },
421
422 #[error("tool call denied by policy")]
428 OutOfScope {
429 tool_id: String,
431 task_type: Option<String>,
433 },
434
435 #[error("tool call denied by safety probe: {reason}")]
441 SafetyDenied {
442 reason: String,
444 },
445}
446
447impl ToolError {
448 #[must_use]
453 pub fn category(&self) -> crate::error_taxonomy::ToolErrorCategory {
454 use crate::error_taxonomy::{ToolErrorCategory, classify_http_status, classify_io_error};
455 match self {
456 Self::Blocked { .. } | Self::SandboxViolation { .. } => {
457 ToolErrorCategory::PolicyBlocked
458 }
459 Self::ConfirmationRequired { .. } => ToolErrorCategory::ConfirmationRequired,
460 Self::Timeout { .. } => ToolErrorCategory::Timeout,
461 Self::Cancelled => ToolErrorCategory::Cancelled,
462 Self::InvalidParams { .. } => ToolErrorCategory::InvalidParameters,
463 Self::Http { status, .. } => classify_http_status(*status),
464 Self::Execution(io_err) => classify_io_error(io_err),
465 Self::Shell { category, .. } => *category,
466 Self::SnapshotFailed { .. } => ToolErrorCategory::PermanentFailure,
467 Self::OutOfScope { .. } | Self::SafetyDenied { .. } => ToolErrorCategory::PolicyBlocked,
468 }
469 }
470
471 #[must_use]
479 pub fn kind(&self) -> ErrorKind {
480 use crate::error_taxonomy::ToolErrorCategoryExt;
481 self.category().error_kind()
482 }
483}
484
485pub fn deserialize_params<T: serde::de::DeserializeOwned>(
491 params: &serde_json::Map<String, serde_json::Value>,
492) -> Result<T, ToolError> {
493 let obj = serde_json::Value::Object(params.clone());
494 serde_json::from_value(obj).map_err(|e| ToolError::InvalidParams {
495 message: e.to_string(),
496 })
497}
498
499pub trait ToolExecutor: Send + Sync {
584 fn execute(
593 &self,
594 response: &str,
595 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send;
596
597 fn execute_confirmed(
606 &self,
607 response: &str,
608 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
609 self.execute(response)
610 }
611
612 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
617 vec![]
618 }
619
620 fn execute_tool_call(
626 &self,
627 _call: &ToolCall,
628 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
629 std::future::ready(Ok(None))
630 }
631
632 fn execute_tool_call_confirmed(
641 &self,
642 call: &ToolCall,
643 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
644 self.execute_tool_call(call)
645 }
646
647 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
652
653 fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
657
658 fn is_tool_retryable(&self, _tool_id: &str) -> bool {
664 false
665 }
666
667 fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
694 false
695 }
696
697 fn requires_confirmation(&self, _call: &ToolCall) -> bool {
705 false
706 }
707}
708
709pub trait ErasedToolExecutor: Send + Sync {
718 fn execute_erased<'a>(
719 &'a self,
720 response: &'a str,
721 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
722
723 fn execute_confirmed_erased<'a>(
724 &'a self,
725 response: &'a str,
726 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
727
728 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef>;
729
730 fn execute_tool_call_erased<'a>(
731 &'a self,
732 call: &'a ToolCall,
733 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
734
735 fn execute_tool_call_confirmed_erased<'a>(
736 &'a self,
737 call: &'a ToolCall,
738 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
739 {
740 self.execute_tool_call_erased(call)
744 }
745
746 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
748
749 fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
751
752 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool;
754
755 fn is_tool_speculatable_erased(&self, _tool_id: &str) -> bool {
759 false
760 }
761
762 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
771 true
772 }
773}
774
775impl<T: ToolExecutor> ErasedToolExecutor for T {
776 fn execute_erased<'a>(
777 &'a self,
778 response: &'a str,
779 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
780 {
781 Box::pin(self.execute(response))
782 }
783
784 fn execute_confirmed_erased<'a>(
785 &'a self,
786 response: &'a str,
787 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
788 {
789 Box::pin(self.execute_confirmed(response))
790 }
791
792 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
793 self.tool_definitions()
794 }
795
796 fn execute_tool_call_erased<'a>(
797 &'a self,
798 call: &'a ToolCall,
799 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
800 {
801 Box::pin(self.execute_tool_call(call))
802 }
803
804 fn execute_tool_call_confirmed_erased<'a>(
805 &'a self,
806 call: &'a ToolCall,
807 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
808 {
809 Box::pin(self.execute_tool_call_confirmed(call))
810 }
811
812 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
813 ToolExecutor::set_skill_env(self, env);
814 }
815
816 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
817 ToolExecutor::set_effective_trust(self, level);
818 }
819
820 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
821 ToolExecutor::is_tool_retryable(self, tool_id)
822 }
823
824 fn is_tool_speculatable_erased(&self, tool_id: &str) -> bool {
825 ToolExecutor::is_tool_speculatable(self, tool_id)
826 }
827
828 fn requires_confirmation_erased(&self, call: &ToolCall) -> bool {
829 ToolExecutor::requires_confirmation(self, call)
830 }
831}
832
833pub struct DynExecutor(pub std::sync::Arc<dyn ErasedToolExecutor>);
837
838impl ToolExecutor for DynExecutor {
839 fn execute(
840 &self,
841 response: &str,
842 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
843 let inner = std::sync::Arc::clone(&self.0);
845 let response = response.to_owned();
846 async move { inner.execute_erased(&response).await }
847 }
848
849 fn execute_confirmed(
850 &self,
851 response: &str,
852 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
853 let inner = std::sync::Arc::clone(&self.0);
854 let response = response.to_owned();
855 async move { inner.execute_confirmed_erased(&response).await }
856 }
857
858 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
859 self.0.tool_definitions_erased()
860 }
861
862 fn execute_tool_call(
863 &self,
864 call: &ToolCall,
865 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
866 let inner = std::sync::Arc::clone(&self.0);
867 let call = call.clone();
868 async move { inner.execute_tool_call_erased(&call).await }
869 }
870
871 fn execute_tool_call_confirmed(
872 &self,
873 call: &ToolCall,
874 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
875 let inner = std::sync::Arc::clone(&self.0);
876 let call = call.clone();
877 async move { inner.execute_tool_call_confirmed_erased(&call).await }
878 }
879
880 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
881 ErasedToolExecutor::set_skill_env(self.0.as_ref(), env);
882 }
883
884 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
885 ErasedToolExecutor::set_effective_trust(self.0.as_ref(), level);
886 }
887
888 fn is_tool_retryable(&self, tool_id: &str) -> bool {
889 self.0.is_tool_retryable_erased(tool_id)
890 }
891
892 fn is_tool_speculatable(&self, tool_id: &str) -> bool {
893 self.0.is_tool_speculatable_erased(tool_id)
894 }
895
896 fn requires_confirmation(&self, call: &ToolCall) -> bool {
897 self.0.requires_confirmation_erased(call)
898 }
899}
900
901#[must_use]
905pub fn extract_fenced_blocks<'a>(text: &'a str, lang: &str) -> Vec<&'a str> {
906 let marker = format!("```{lang}");
907 let marker_len = marker.len();
908 let mut blocks = Vec::new();
909 let mut rest = text;
910
911 let mut search_from = 0;
912 while let Some(rel) = rest[search_from..].find(&marker) {
913 let start = search_from + rel;
914 let after = &rest[start + marker_len..];
915 let boundary_ok = after
919 .chars()
920 .next()
921 .is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '-');
922 if !boundary_ok {
923 search_from = start + marker_len;
924 continue;
925 }
926 if let Some(end) = after.find("```") {
927 blocks.push(after[..end].trim());
928 rest = &after[end + 3..];
929 search_from = 0;
930 } else {
931 break;
932 }
933 }
934
935 blocks
936}
937
938#[cfg(test)]
939mod tests {
940 use super::*;
941
942 #[test]
943 fn tool_output_display() {
944 let output = ToolOutput {
945 tool_name: ToolName::new("bash"),
946 summary: "$ echo hello\nhello".to_owned(),
947 blocks_executed: 1,
948 filter_stats: None,
949 diff: None,
950 streamed: false,
951 terminal_id: None,
952 locations: None,
953 raw_response: None,
954 claim_source: None,
955 };
956 assert_eq!(output.to_string(), "$ echo hello\nhello");
957 }
958
959 #[test]
960 fn tool_error_blocked_display() {
961 let err = ToolError::Blocked {
962 command: "rm -rf /".to_owned(),
963 };
964 assert_eq!(err.to_string(), "command blocked by policy: rm -rf /");
965 }
966
967 #[test]
968 fn tool_error_sandbox_violation_display() {
969 let err = ToolError::SandboxViolation {
970 path: "/etc/shadow".to_owned(),
971 };
972 assert_eq!(err.to_string(), "path not allowed by sandbox: /etc/shadow");
973 }
974
975 #[test]
976 fn tool_error_confirmation_required_display() {
977 let err = ToolError::ConfirmationRequired {
978 command: "rm -rf /tmp".to_owned(),
979 };
980 assert_eq!(
981 err.to_string(),
982 "command requires confirmation: rm -rf /tmp"
983 );
984 }
985
986 #[test]
987 fn tool_error_timeout_display() {
988 let err = ToolError::Timeout { timeout_secs: 30 };
989 assert_eq!(err.to_string(), "command timed out after 30s");
990 }
991
992 #[test]
993 fn tool_error_invalid_params_display() {
994 let err = ToolError::InvalidParams {
995 message: "missing field `command`".to_owned(),
996 };
997 assert_eq!(
998 err.to_string(),
999 "invalid tool parameters: missing field `command`"
1000 );
1001 }
1002
1003 #[test]
1004 fn deserialize_params_valid() {
1005 #[derive(Debug, serde::Deserialize, PartialEq)]
1006 struct P {
1007 name: String,
1008 count: u32,
1009 }
1010 let mut map = serde_json::Map::new();
1011 map.insert("name".to_owned(), serde_json::json!("test"));
1012 map.insert("count".to_owned(), serde_json::json!(42));
1013 let p: P = deserialize_params(&map).unwrap();
1014 assert_eq!(
1015 p,
1016 P {
1017 name: "test".to_owned(),
1018 count: 42
1019 }
1020 );
1021 }
1022
1023 #[test]
1024 fn deserialize_params_missing_required_field() {
1025 #[derive(Debug, serde::Deserialize)]
1026 #[allow(dead_code)]
1027 struct P {
1028 name: String,
1029 }
1030 let map = serde_json::Map::new();
1031 let err = deserialize_params::<P>(&map).unwrap_err();
1032 assert!(matches!(err, ToolError::InvalidParams { .. }));
1033 }
1034
1035 #[test]
1036 fn deserialize_params_wrong_type() {
1037 #[derive(Debug, serde::Deserialize)]
1038 #[allow(dead_code)]
1039 struct P {
1040 count: u32,
1041 }
1042 let mut map = serde_json::Map::new();
1043 map.insert("count".to_owned(), serde_json::json!("not a number"));
1044 let err = deserialize_params::<P>(&map).unwrap_err();
1045 assert!(matches!(err, ToolError::InvalidParams { .. }));
1046 }
1047
1048 #[test]
1049 fn deserialize_params_all_optional_empty() {
1050 #[derive(Debug, serde::Deserialize, PartialEq)]
1051 struct P {
1052 name: Option<String>,
1053 }
1054 let map = serde_json::Map::new();
1055 let p: P = deserialize_params(&map).unwrap();
1056 assert_eq!(p, P { name: None });
1057 }
1058
1059 #[test]
1060 fn deserialize_params_ignores_extra_fields() {
1061 #[derive(Debug, serde::Deserialize, PartialEq)]
1062 struct P {
1063 name: String,
1064 }
1065 let mut map = serde_json::Map::new();
1066 map.insert("name".to_owned(), serde_json::json!("test"));
1067 map.insert("extra".to_owned(), serde_json::json!(true));
1068 let p: P = deserialize_params(&map).unwrap();
1069 assert_eq!(
1070 p,
1071 P {
1072 name: "test".to_owned()
1073 }
1074 );
1075 }
1076
1077 #[test]
1078 fn tool_error_execution_display() {
1079 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash not found");
1080 let err = ToolError::Execution(io_err);
1081 assert!(err.to_string().starts_with("execution failed:"));
1082 assert!(err.to_string().contains("bash not found"));
1083 }
1084
1085 #[test]
1087 fn error_kind_timeout_is_transient() {
1088 let err = ToolError::Timeout { timeout_secs: 30 };
1089 assert_eq!(err.kind(), ErrorKind::Transient);
1090 }
1091
1092 #[test]
1093 fn error_kind_blocked_is_permanent() {
1094 let err = ToolError::Blocked {
1095 command: "rm -rf /".to_owned(),
1096 };
1097 assert_eq!(err.kind(), ErrorKind::Permanent);
1098 }
1099
1100 #[test]
1101 fn error_kind_sandbox_violation_is_permanent() {
1102 let err = ToolError::SandboxViolation {
1103 path: "/etc/shadow".to_owned(),
1104 };
1105 assert_eq!(err.kind(), ErrorKind::Permanent);
1106 }
1107
1108 #[test]
1109 fn error_kind_cancelled_is_permanent() {
1110 assert_eq!(ToolError::Cancelled.kind(), ErrorKind::Permanent);
1111 }
1112
1113 #[test]
1114 fn error_kind_invalid_params_is_permanent() {
1115 let err = ToolError::InvalidParams {
1116 message: "bad arg".to_owned(),
1117 };
1118 assert_eq!(err.kind(), ErrorKind::Permanent);
1119 }
1120
1121 #[test]
1122 fn error_kind_confirmation_required_is_permanent() {
1123 let err = ToolError::ConfirmationRequired {
1124 command: "rm /tmp/x".to_owned(),
1125 };
1126 assert_eq!(err.kind(), ErrorKind::Permanent);
1127 }
1128
1129 #[test]
1130 fn error_kind_execution_timed_out_is_transient() {
1131 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1132 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1133 }
1134
1135 #[test]
1136 fn error_kind_execution_interrupted_is_transient() {
1137 let io_err = std::io::Error::new(std::io::ErrorKind::Interrupted, "interrupted");
1138 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1139 }
1140
1141 #[test]
1142 fn error_kind_execution_connection_reset_is_transient() {
1143 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
1144 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1145 }
1146
1147 #[test]
1148 fn error_kind_execution_broken_pipe_is_transient() {
1149 let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
1150 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1151 }
1152
1153 #[test]
1154 fn error_kind_execution_would_block_is_transient() {
1155 let io_err = std::io::Error::new(std::io::ErrorKind::WouldBlock, "would block");
1156 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1157 }
1158
1159 #[test]
1160 fn error_kind_execution_connection_aborted_is_transient() {
1161 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionAborted, "aborted");
1162 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1163 }
1164
1165 #[test]
1166 fn error_kind_execution_not_found_is_permanent() {
1167 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
1168 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1169 }
1170
1171 #[test]
1172 fn error_kind_execution_permission_denied_is_permanent() {
1173 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
1174 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1175 }
1176
1177 #[test]
1178 fn error_kind_execution_other_is_permanent() {
1179 let io_err = std::io::Error::other("some other error");
1180 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1181 }
1182
1183 #[test]
1184 fn error_kind_execution_already_exists_is_permanent() {
1185 let io_err = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists");
1186 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1187 }
1188
1189 #[test]
1190 fn error_kind_display() {
1191 assert_eq!(ErrorKind::Transient.to_string(), "transient");
1192 assert_eq!(ErrorKind::Permanent.to_string(), "permanent");
1193 }
1194
1195 #[test]
1196 fn truncate_tool_output_short_passthrough() {
1197 let short = "hello world";
1198 assert_eq!(truncate_tool_output(short), short);
1199 }
1200
1201 #[test]
1202 fn truncate_tool_output_exact_limit() {
1203 let exact = "a".repeat(MAX_TOOL_OUTPUT_CHARS);
1204 assert_eq!(truncate_tool_output(&exact), exact);
1205 }
1206
1207 #[test]
1208 fn truncate_tool_output_long_split() {
1209 let long = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
1210 let result = truncate_tool_output(&long);
1211 assert!(result.contains("truncated"));
1212 assert!(result.len() < long.len());
1213 }
1214
1215 #[test]
1216 fn truncate_tool_output_notice_contains_count() {
1217 let long = "y".repeat(MAX_TOOL_OUTPUT_CHARS + 2000);
1218 let result = truncate_tool_output(&long);
1219 assert!(result.contains("truncated"));
1220 assert!(result.contains("chars"));
1221 }
1222
1223 #[derive(Debug)]
1224 struct DefaultExecutor;
1225 impl ToolExecutor for DefaultExecutor {
1226 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1227 Ok(None)
1228 }
1229 }
1230
1231 #[tokio::test]
1232 async fn execute_tool_call_default_returns_none() {
1233 let exec = DefaultExecutor;
1234 let call = ToolCall {
1235 tool_id: ToolName::new("anything"),
1236 params: serde_json::Map::new(),
1237 caller_id: None,
1238 context: None,
1239
1240 tool_call_id: String::new(),
1241 };
1242 let result = exec.execute_tool_call(&call).await.unwrap();
1243 assert!(result.is_none());
1244 }
1245
1246 #[test]
1247 fn filter_stats_savings_pct() {
1248 let fs = FilterStats {
1249 raw_chars: 1000,
1250 filtered_chars: 200,
1251 ..Default::default()
1252 };
1253 assert!((fs.savings_pct() - 80.0).abs() < 0.01);
1254 }
1255
1256 #[test]
1257 fn filter_stats_savings_pct_zero() {
1258 let fs = FilterStats::default();
1259 assert!((fs.savings_pct()).abs() < 0.01);
1260 }
1261
1262 #[test]
1263 fn filter_stats_estimated_tokens_saved() {
1264 let fs = FilterStats {
1265 raw_chars: 1000,
1266 filtered_chars: 200,
1267 ..Default::default()
1268 };
1269 assert_eq!(fs.estimated_tokens_saved(), 200); }
1271
1272 #[test]
1273 fn filter_stats_format_inline() {
1274 let fs = FilterStats {
1275 raw_chars: 1000,
1276 filtered_chars: 200,
1277 raw_lines: 342,
1278 filtered_lines: 28,
1279 ..Default::default()
1280 };
1281 let line = fs.format_inline("shell");
1282 assert_eq!(line, "[shell] 342 lines \u{2192} 28 lines, 80.0% filtered");
1283 }
1284
1285 #[test]
1286 fn filter_stats_format_inline_zero() {
1287 let fs = FilterStats::default();
1288 let line = fs.format_inline("bash");
1289 assert_eq!(line, "[bash] 0 lines \u{2192} 0 lines, 0.0% filtered");
1290 }
1291
1292 struct FixedExecutor {
1295 tool_id: &'static str,
1296 output: &'static str,
1297 }
1298
1299 impl ToolExecutor for FixedExecutor {
1300 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1301 Ok(Some(ToolOutput {
1302 tool_name: ToolName::new(self.tool_id),
1303 summary: self.output.to_owned(),
1304 blocks_executed: 1,
1305 filter_stats: None,
1306 diff: None,
1307 streamed: false,
1308 terminal_id: None,
1309 locations: None,
1310 raw_response: None,
1311 claim_source: None,
1312 }))
1313 }
1314
1315 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
1316 vec![]
1317 }
1318
1319 async fn execute_tool_call(
1320 &self,
1321 _call: &ToolCall,
1322 ) -> Result<Option<ToolOutput>, ToolError> {
1323 Ok(Some(ToolOutput {
1324 tool_name: ToolName::new(self.tool_id),
1325 summary: self.output.to_owned(),
1326 blocks_executed: 1,
1327 filter_stats: None,
1328 diff: None,
1329 streamed: false,
1330 terminal_id: None,
1331 locations: None,
1332 raw_response: None,
1333 claim_source: None,
1334 }))
1335 }
1336 }
1337
1338 #[tokio::test]
1339 async fn dyn_executor_execute_delegates() {
1340 let inner = std::sync::Arc::new(FixedExecutor {
1341 tool_id: "bash",
1342 output: "hello",
1343 });
1344 let exec = DynExecutor(inner);
1345 let result = exec.execute("```bash\necho hello\n```").await.unwrap();
1346 assert!(result.is_some());
1347 assert_eq!(result.unwrap().summary, "hello");
1348 }
1349
1350 #[tokio::test]
1351 async fn dyn_executor_execute_confirmed_delegates() {
1352 let inner = std::sync::Arc::new(FixedExecutor {
1353 tool_id: "bash",
1354 output: "confirmed",
1355 });
1356 let exec = DynExecutor(inner);
1357 let result = exec.execute_confirmed("...").await.unwrap();
1358 assert!(result.is_some());
1359 assert_eq!(result.unwrap().summary, "confirmed");
1360 }
1361
1362 #[test]
1363 fn dyn_executor_tool_definitions_delegates() {
1364 let inner = std::sync::Arc::new(FixedExecutor {
1365 tool_id: "my_tool",
1366 output: "",
1367 });
1368 let exec = DynExecutor(inner);
1369 let defs = exec.tool_definitions();
1371 assert!(defs.is_empty());
1372 }
1373
1374 #[tokio::test]
1375 async fn dyn_executor_execute_tool_call_delegates() {
1376 let inner = std::sync::Arc::new(FixedExecutor {
1377 tool_id: "bash",
1378 output: "tool_call_result",
1379 });
1380 let exec = DynExecutor(inner);
1381 let call = ToolCall {
1382 tool_id: ToolName::new("bash"),
1383 params: serde_json::Map::new(),
1384 caller_id: None,
1385 context: None,
1386
1387 tool_call_id: String::new(),
1388 };
1389 let result = exec.execute_tool_call(&call).await.unwrap();
1390 assert!(result.is_some());
1391 assert_eq!(result.unwrap().summary, "tool_call_result");
1392 }
1393
1394 #[test]
1395 fn dyn_executor_set_effective_trust_delegates() {
1396 use std::sync::atomic::{AtomicU8, Ordering};
1397
1398 struct TrustCapture(AtomicU8);
1399 impl ToolExecutor for TrustCapture {
1400 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1401 Ok(None)
1402 }
1403 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
1404 let v = match level {
1406 crate::SkillTrustLevel::Trusted => 0u8,
1407 crate::SkillTrustLevel::Verified => 1,
1408 crate::SkillTrustLevel::Quarantined => 2,
1409 crate::SkillTrustLevel::Blocked => 3,
1410 };
1411 self.0.store(v, Ordering::Relaxed);
1412 }
1413 }
1414
1415 let inner = std::sync::Arc::new(TrustCapture(AtomicU8::new(0)));
1416 let exec =
1417 DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
1418 ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Quarantined);
1419 assert_eq!(inner.0.load(Ordering::Relaxed), 2);
1420
1421 ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Blocked);
1422 assert_eq!(inner.0.load(Ordering::Relaxed), 3);
1423 }
1424
1425 #[test]
1426 fn extract_fenced_blocks_no_prefix_match() {
1427 assert!(extract_fenced_blocks("```bashrc\nfoo\n```", "bash").is_empty());
1429 assert_eq!(
1431 extract_fenced_blocks("```bash\nfoo\n```", "bash"),
1432 vec!["foo"]
1433 );
1434 assert_eq!(
1436 extract_fenced_blocks("```bash \nfoo\n```", "bash"),
1437 vec!["foo"]
1438 );
1439 }
1440
1441 #[test]
1444 fn tool_error_http_400_category_is_invalid_parameters() {
1445 use crate::error_taxonomy::ToolErrorCategory;
1446 let err = ToolError::Http {
1447 status: 400,
1448 message: "bad request".to_owned(),
1449 };
1450 assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1451 }
1452
1453 #[test]
1454 fn tool_error_http_401_category_is_policy_blocked() {
1455 use crate::error_taxonomy::ToolErrorCategory;
1456 let err = ToolError::Http {
1457 status: 401,
1458 message: "unauthorized".to_owned(),
1459 };
1460 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1461 }
1462
1463 #[test]
1464 fn tool_error_http_403_category_is_policy_blocked() {
1465 use crate::error_taxonomy::ToolErrorCategory;
1466 let err = ToolError::Http {
1467 status: 403,
1468 message: "forbidden".to_owned(),
1469 };
1470 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1471 }
1472
1473 #[test]
1474 fn tool_error_http_404_category_is_permanent_failure() {
1475 use crate::error_taxonomy::ToolErrorCategory;
1476 let err = ToolError::Http {
1477 status: 404,
1478 message: "not found".to_owned(),
1479 };
1480 assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1481 }
1482
1483 #[test]
1484 fn tool_error_http_429_category_is_rate_limited() {
1485 use crate::error_taxonomy::ToolErrorCategory;
1486 let err = ToolError::Http {
1487 status: 429,
1488 message: "too many requests".to_owned(),
1489 };
1490 assert_eq!(err.category(), ToolErrorCategory::RateLimited);
1491 }
1492
1493 #[test]
1494 fn tool_error_http_500_category_is_server_error() {
1495 use crate::error_taxonomy::ToolErrorCategory;
1496 let err = ToolError::Http {
1497 status: 500,
1498 message: "internal server error".to_owned(),
1499 };
1500 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1501 }
1502
1503 #[test]
1504 fn tool_error_http_502_category_is_server_error() {
1505 use crate::error_taxonomy::ToolErrorCategory;
1506 let err = ToolError::Http {
1507 status: 502,
1508 message: "bad gateway".to_owned(),
1509 };
1510 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1511 }
1512
1513 #[test]
1514 fn tool_error_http_503_category_is_server_error() {
1515 use crate::error_taxonomy::ToolErrorCategory;
1516 let err = ToolError::Http {
1517 status: 503,
1518 message: "service unavailable".to_owned(),
1519 };
1520 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1521 }
1522
1523 #[test]
1524 fn tool_error_http_503_is_transient_triggers_phase2_retry() {
1525 let err = ToolError::Http {
1528 status: 503,
1529 message: "service unavailable".to_owned(),
1530 };
1531 assert_eq!(
1532 err.kind(),
1533 ErrorKind::Transient,
1534 "HTTP 503 must be Transient so Phase 2 retry fires"
1535 );
1536 }
1537
1538 #[test]
1539 fn tool_error_blocked_category_is_policy_blocked() {
1540 use crate::error_taxonomy::ToolErrorCategory;
1541 let err = ToolError::Blocked {
1542 command: "rm -rf /".to_owned(),
1543 };
1544 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1545 }
1546
1547 #[test]
1548 fn tool_error_sandbox_violation_category_is_policy_blocked() {
1549 use crate::error_taxonomy::ToolErrorCategory;
1550 let err = ToolError::SandboxViolation {
1551 path: "/etc/shadow".to_owned(),
1552 };
1553 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1554 }
1555
1556 #[test]
1557 fn tool_error_confirmation_required_category() {
1558 use crate::error_taxonomy::ToolErrorCategory;
1559 let err = ToolError::ConfirmationRequired {
1560 command: "rm /tmp/x".to_owned(),
1561 };
1562 assert_eq!(err.category(), ToolErrorCategory::ConfirmationRequired);
1563 }
1564
1565 #[test]
1566 fn tool_error_timeout_category() {
1567 use crate::error_taxonomy::ToolErrorCategory;
1568 let err = ToolError::Timeout { timeout_secs: 30 };
1569 assert_eq!(err.category(), ToolErrorCategory::Timeout);
1570 }
1571
1572 #[test]
1573 fn tool_error_cancelled_category() {
1574 use crate::error_taxonomy::ToolErrorCategory;
1575 assert_eq!(
1576 ToolError::Cancelled.category(),
1577 ToolErrorCategory::Cancelled
1578 );
1579 }
1580
1581 #[test]
1582 fn tool_error_invalid_params_category() {
1583 use crate::error_taxonomy::ToolErrorCategory;
1584 let err = ToolError::InvalidParams {
1585 message: "missing field".to_owned(),
1586 };
1587 assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1588 }
1589
1590 #[test]
1592 fn tool_error_execution_not_found_category_is_permanent_failure() {
1593 use crate::error_taxonomy::ToolErrorCategory;
1594 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash: not found");
1595 let err = ToolError::Execution(io_err);
1596 let cat = err.category();
1597 assert_ne!(
1598 cat,
1599 ToolErrorCategory::ToolNotFound,
1600 "Execution(NotFound) must NOT map to ToolNotFound"
1601 );
1602 assert_eq!(cat, ToolErrorCategory::PermanentFailure);
1603 }
1604
1605 #[test]
1606 fn tool_error_execution_timed_out_category_is_timeout() {
1607 use crate::error_taxonomy::ToolErrorCategory;
1608 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
1609 assert_eq!(
1610 ToolError::Execution(io_err).category(),
1611 ToolErrorCategory::Timeout
1612 );
1613 }
1614
1615 #[test]
1616 fn tool_error_execution_connection_refused_category_is_network_error() {
1617 use crate::error_taxonomy::ToolErrorCategory;
1618 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1619 assert_eq!(
1620 ToolError::Execution(io_err).category(),
1621 ToolErrorCategory::NetworkError
1622 );
1623 }
1624
1625 #[test]
1627 fn b4_tool_error_http_429_not_quality_failure() {
1628 let err = ToolError::Http {
1629 status: 429,
1630 message: "rate limited".to_owned(),
1631 };
1632 assert!(
1633 !err.category().is_quality_failure(),
1634 "RateLimited must not be a quality failure"
1635 );
1636 }
1637
1638 #[test]
1639 fn b4_tool_error_http_503_not_quality_failure() {
1640 let err = ToolError::Http {
1641 status: 503,
1642 message: "service unavailable".to_owned(),
1643 };
1644 assert!(
1645 !err.category().is_quality_failure(),
1646 "ServerError must not be a quality failure"
1647 );
1648 }
1649
1650 #[test]
1651 fn b4_tool_error_execution_timed_out_not_quality_failure() {
1652 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1653 assert!(
1654 !ToolError::Execution(io_err).category().is_quality_failure(),
1655 "Timeout must not be a quality failure"
1656 );
1657 }
1658
1659 #[test]
1662 fn tool_error_shell_exit126_is_policy_blocked() {
1663 use crate::error_taxonomy::ToolErrorCategory;
1664 let err = ToolError::Shell {
1665 exit_code: 126,
1666 category: ToolErrorCategory::PolicyBlocked,
1667 message: "permission denied".to_owned(),
1668 };
1669 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1670 }
1671
1672 #[test]
1673 fn tool_error_shell_exit127_is_permanent_failure() {
1674 use crate::error_taxonomy::ToolErrorCategory;
1675 let err = ToolError::Shell {
1676 exit_code: 127,
1677 category: ToolErrorCategory::PermanentFailure,
1678 message: "command not found".to_owned(),
1679 };
1680 assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1681 assert!(!err.category().is_retryable());
1682 }
1683
1684 #[test]
1685 fn tool_error_shell_not_quality_failure() {
1686 use crate::error_taxonomy::ToolErrorCategory;
1687 let err = ToolError::Shell {
1688 exit_code: 127,
1689 category: ToolErrorCategory::PermanentFailure,
1690 message: "command not found".to_owned(),
1691 };
1692 assert!(!err.category().is_quality_failure());
1694 }
1695
1696 struct StubExecutor;
1700 impl ToolExecutor for StubExecutor {
1701 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1702 Ok(None)
1703 }
1704 }
1705
1706 struct ConfirmingExecutor;
1708 impl ToolExecutor for ConfirmingExecutor {
1709 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1710 Ok(None)
1711 }
1712 fn requires_confirmation(&self, _call: &ToolCall) -> bool {
1713 true
1714 }
1715 }
1716
1717 fn dummy_call() -> ToolCall {
1718 ToolCall {
1719 tool_id: ToolName::new("test"),
1720 params: serde_json::Map::new(),
1721 caller_id: None,
1722 context: None,
1723
1724 tool_call_id: String::new(),
1725 }
1726 }
1727
1728 #[test]
1729 fn requires_confirmation_default_is_false_on_tool_executor() {
1730 let exec = StubExecutor;
1731 assert!(
1732 !exec.requires_confirmation(&dummy_call()),
1733 "ToolExecutor default requires_confirmation must be false"
1734 );
1735 }
1736
1737 #[test]
1738 fn requires_confirmation_erased_delegates_to_tool_executor_default() {
1739 let exec = StubExecutor;
1741 assert!(
1742 !ErasedToolExecutor::requires_confirmation_erased(&exec, &dummy_call()),
1743 "requires_confirmation_erased via blanket impl must return false for stub executor"
1744 );
1745 }
1746
1747 #[test]
1748 fn requires_confirmation_erased_delegates_override() {
1749 let exec = ConfirmingExecutor;
1752 assert!(
1753 ErasedToolExecutor::requires_confirmation_erased(&exec, &dummy_call()),
1754 "requires_confirmation_erased must return true when ToolExecutor override returns true"
1755 );
1756 }
1757
1758 #[test]
1759 fn requires_confirmation_erased_default_on_erased_trait_is_true() {
1760 struct ManualErased;
1765 impl ErasedToolExecutor for ManualErased {
1766 fn execute_erased<'a>(
1767 &'a self,
1768 _response: &'a str,
1769 ) -> std::pin::Pin<
1770 Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>,
1771 > {
1772 Box::pin(std::future::ready(Ok(None)))
1773 }
1774 fn execute_confirmed_erased<'a>(
1775 &'a self,
1776 _response: &'a str,
1777 ) -> std::pin::Pin<
1778 Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>,
1779 > {
1780 Box::pin(std::future::ready(Ok(None)))
1781 }
1782 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
1783 vec![]
1784 }
1785 fn execute_tool_call_erased<'a>(
1786 &'a self,
1787 _call: &'a ToolCall,
1788 ) -> std::pin::Pin<
1789 Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>,
1790 > {
1791 Box::pin(std::future::ready(Ok(None)))
1792 }
1793 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
1794 false
1795 }
1796 }
1798 let exec = ManualErased;
1799 assert!(
1800 exec.requires_confirmation_erased(&dummy_call()),
1801 "ErasedToolExecutor trait-level default for requires_confirmation_erased must be true"
1802 );
1803 }
1804
1805 #[test]
1808 fn dyn_executor_requires_confirmation_delegates() {
1809 let inner = std::sync::Arc::new(ConfirmingExecutor);
1810 let exec =
1811 DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
1812 assert!(
1813 ToolExecutor::requires_confirmation(&exec, &dummy_call()),
1814 "DynExecutor must delegate requires_confirmation to inner executor"
1815 );
1816 }
1817
1818 #[test]
1819 fn dyn_executor_requires_confirmation_default_false() {
1820 let inner = std::sync::Arc::new(StubExecutor);
1821 let exec =
1822 DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
1823 assert!(
1824 !ToolExecutor::requires_confirmation(&exec, &dummy_call()),
1825 "DynExecutor must return false when inner executor does not require confirmation"
1826 );
1827 }
1828}