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("command blocked by policy: {command}")]
388 BlockedWithFix {
389 command: String,
390 suggestion: Option<crate::shell::SafeFixSuggestion>,
391 },
392
393 #[error("path not allowed by sandbox: {path}")]
394 SandboxViolation { path: String },
395
396 #[error("command requires confirmation: {command}")]
397 ConfirmationRequired { command: String },
398
399 #[error("command timed out after {timeout_secs}s")]
400 Timeout { timeout_secs: u64 },
401
402 #[error("operation cancelled")]
403 Cancelled,
404
405 #[error("invalid tool parameters: {message}")]
406 InvalidParams { message: String },
407
408 #[error("execution failed: {0}")]
409 Execution(#[from] std::io::Error),
410
411 #[error("HTTP error {status}: {message}")]
416 Http { status: u16, message: String },
417
418 #[error("shell error (exit {exit_code}): {message}")]
424 Shell {
425 exit_code: i32,
426 category: crate::error_taxonomy::ToolErrorCategory,
427 message: String,
428 },
429
430 #[error("snapshot failed: {reason}")]
431 SnapshotFailed { reason: String },
432
433 #[error("tool call denied by policy")]
439 OutOfScope {
440 tool_id: String,
442 task_type: Option<String>,
444 },
445
446 #[error("tool call denied by safety probe: {reason}")]
452 SafetyDenied {
453 reason: String,
455 },
456}
457
458impl ToolError {
459 #[must_use]
464 pub fn category(&self) -> crate::error_taxonomy::ToolErrorCategory {
465 use crate::error_taxonomy::{ToolErrorCategory, classify_http_status, classify_io_error};
466 match self {
467 Self::Blocked { .. } | Self::BlockedWithFix { .. } | Self::SandboxViolation { .. } => {
468 ToolErrorCategory::PolicyBlocked
469 }
470 Self::ConfirmationRequired { .. } => ToolErrorCategory::ConfirmationRequired,
471 Self::Timeout { .. } => ToolErrorCategory::Timeout,
472 Self::Cancelled => ToolErrorCategory::Cancelled,
473 Self::InvalidParams { .. } => ToolErrorCategory::InvalidParameters,
474 Self::Http { status, .. } => classify_http_status(*status),
475 Self::Execution(io_err) => classify_io_error(io_err),
476 Self::Shell { category, .. } => *category,
477 Self::SnapshotFailed { .. } => ToolErrorCategory::PermanentFailure,
478 Self::OutOfScope { .. } | Self::SafetyDenied { .. } => ToolErrorCategory::PolicyBlocked,
479 }
480 }
481
482 #[must_use]
490 pub fn kind(&self) -> ErrorKind {
491 use crate::error_taxonomy::ToolErrorCategoryExt;
492 self.category().error_kind()
493 }
494}
495
496pub fn deserialize_params<T: serde::de::DeserializeOwned>(
502 params: &serde_json::Map<String, serde_json::Value>,
503) -> Result<T, ToolError> {
504 let obj = serde_json::Value::Object(params.clone());
505 serde_json::from_value(obj).map_err(|e| ToolError::InvalidParams {
506 message: e.to_string(),
507 })
508}
509
510pub trait ToolExecutor: Send + Sync {
595 fn execute(
604 &self,
605 response: &str,
606 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send;
607
608 fn execute_confirmed(
617 &self,
618 response: &str,
619 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
620 self.execute(response)
621 }
622
623 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
628 vec![]
629 }
630
631 fn execute_tool_call(
637 &self,
638 _call: &ToolCall,
639 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
640 std::future::ready(Ok(None))
641 }
642
643 fn execute_tool_call_confirmed(
652 &self,
653 call: &ToolCall,
654 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
655 self.execute_tool_call(call)
656 }
657
658 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
663
664 fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
668
669 fn is_tool_retryable(&self, _tool_id: &str) -> bool {
675 false
676 }
677
678 fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
705 false
706 }
707
708 fn requires_confirmation(&self, _call: &ToolCall) -> bool {
716 false
717 }
718}
719
720pub trait ErasedToolExecutor: Send + Sync {
729 fn execute_erased<'a>(
730 &'a self,
731 response: &'a str,
732 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
733
734 fn execute_confirmed_erased<'a>(
735 &'a self,
736 response: &'a str,
737 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
738
739 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef>;
740
741 fn execute_tool_call_erased<'a>(
742 &'a self,
743 call: &'a ToolCall,
744 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
745
746 fn execute_tool_call_confirmed_erased<'a>(
747 &'a self,
748 call: &'a ToolCall,
749 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
750 {
751 self.execute_tool_call_erased(call)
755 }
756
757 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
759
760 fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
762
763 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool;
765
766 fn is_tool_speculatable_erased(&self, _tool_id: &str) -> bool {
770 false
771 }
772
773 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
782 true
783 }
784}
785
786impl<T: ToolExecutor> ErasedToolExecutor for T {
787 fn execute_erased<'a>(
788 &'a self,
789 response: &'a str,
790 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
791 {
792 Box::pin(self.execute(response))
793 }
794
795 fn execute_confirmed_erased<'a>(
796 &'a self,
797 response: &'a str,
798 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
799 {
800 Box::pin(self.execute_confirmed(response))
801 }
802
803 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
804 self.tool_definitions()
805 }
806
807 fn execute_tool_call_erased<'a>(
808 &'a self,
809 call: &'a ToolCall,
810 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
811 {
812 Box::pin(self.execute_tool_call(call))
813 }
814
815 fn execute_tool_call_confirmed_erased<'a>(
816 &'a self,
817 call: &'a ToolCall,
818 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
819 {
820 Box::pin(self.execute_tool_call_confirmed(call))
821 }
822
823 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
824 ToolExecutor::set_skill_env(self, env);
825 }
826
827 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
828 ToolExecutor::set_effective_trust(self, level);
829 }
830
831 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
832 ToolExecutor::is_tool_retryable(self, tool_id)
833 }
834
835 fn is_tool_speculatable_erased(&self, tool_id: &str) -> bool {
836 ToolExecutor::is_tool_speculatable(self, tool_id)
837 }
838
839 fn requires_confirmation_erased(&self, call: &ToolCall) -> bool {
840 ToolExecutor::requires_confirmation(self, call)
841 }
842}
843
844pub struct DynExecutor(pub std::sync::Arc<dyn ErasedToolExecutor>);
848
849impl ToolExecutor for DynExecutor {
850 fn execute(
851 &self,
852 response: &str,
853 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
854 let inner = std::sync::Arc::clone(&self.0);
856 let response = response.to_owned();
857 async move { inner.execute_erased(&response).await }
858 }
859
860 fn execute_confirmed(
861 &self,
862 response: &str,
863 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
864 let inner = std::sync::Arc::clone(&self.0);
865 let response = response.to_owned();
866 async move { inner.execute_confirmed_erased(&response).await }
867 }
868
869 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
870 self.0.tool_definitions_erased()
871 }
872
873 fn execute_tool_call(
874 &self,
875 call: &ToolCall,
876 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
877 let inner = std::sync::Arc::clone(&self.0);
878 let call = call.clone();
879 async move { inner.execute_tool_call_erased(&call).await }
880 }
881
882 fn execute_tool_call_confirmed(
883 &self,
884 call: &ToolCall,
885 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
886 let inner = std::sync::Arc::clone(&self.0);
887 let call = call.clone();
888 async move { inner.execute_tool_call_confirmed_erased(&call).await }
889 }
890
891 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
892 ErasedToolExecutor::set_skill_env(self.0.as_ref(), env);
893 }
894
895 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
896 ErasedToolExecutor::set_effective_trust(self.0.as_ref(), level);
897 }
898
899 fn is_tool_retryable(&self, tool_id: &str) -> bool {
900 self.0.is_tool_retryable_erased(tool_id)
901 }
902
903 fn is_tool_speculatable(&self, tool_id: &str) -> bool {
904 self.0.is_tool_speculatable_erased(tool_id)
905 }
906
907 fn requires_confirmation(&self, call: &ToolCall) -> bool {
908 self.0.requires_confirmation_erased(call)
909 }
910}
911
912#[must_use]
916pub fn extract_fenced_blocks<'a>(text: &'a str, lang: &str) -> Vec<&'a str> {
917 let marker = format!("```{lang}");
918 let marker_len = marker.len();
919 let mut blocks = Vec::new();
920 let mut rest = text;
921
922 let mut search_from = 0;
923 while let Some(rel) = rest[search_from..].find(&marker) {
924 let start = search_from + rel;
925 let after = &rest[start + marker_len..];
926 let boundary_ok = after
930 .chars()
931 .next()
932 .is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '-');
933 if !boundary_ok {
934 search_from = start + marker_len;
935 continue;
936 }
937 if let Some(end) = after.find("```") {
938 blocks.push(after[..end].trim());
939 rest = &after[end + 3..];
940 search_from = 0;
941 } else {
942 break;
943 }
944 }
945
946 blocks
947}
948
949#[cfg(test)]
950mod tests {
951 use super::*;
952
953 #[test]
954 fn tool_output_display() {
955 let output = ToolOutput {
956 tool_name: ToolName::new("bash"),
957 summary: "$ echo hello\nhello".to_owned(),
958 blocks_executed: 1,
959 filter_stats: None,
960 diff: None,
961 streamed: false,
962 terminal_id: None,
963 locations: None,
964 raw_response: None,
965 claim_source: None,
966 };
967 assert_eq!(output.to_string(), "$ echo hello\nhello");
968 }
969
970 #[test]
971 fn tool_error_blocked_display() {
972 let err = ToolError::Blocked {
973 command: "rm -rf /".to_owned(),
974 };
975 assert_eq!(err.to_string(), "command blocked by policy: rm -rf /");
976 }
977
978 #[test]
979 fn tool_error_sandbox_violation_display() {
980 let err = ToolError::SandboxViolation {
981 path: "/etc/shadow".to_owned(),
982 };
983 assert_eq!(err.to_string(), "path not allowed by sandbox: /etc/shadow");
984 }
985
986 #[test]
987 fn tool_error_confirmation_required_display() {
988 let err = ToolError::ConfirmationRequired {
989 command: "rm -rf /tmp".to_owned(),
990 };
991 assert_eq!(
992 err.to_string(),
993 "command requires confirmation: rm -rf /tmp"
994 );
995 }
996
997 #[test]
998 fn tool_error_timeout_display() {
999 let err = ToolError::Timeout { timeout_secs: 30 };
1000 assert_eq!(err.to_string(), "command timed out after 30s");
1001 }
1002
1003 #[test]
1004 fn tool_error_invalid_params_display() {
1005 let err = ToolError::InvalidParams {
1006 message: "missing field `command`".to_owned(),
1007 };
1008 assert_eq!(
1009 err.to_string(),
1010 "invalid tool parameters: missing field `command`"
1011 );
1012 }
1013
1014 #[test]
1015 fn deserialize_params_valid() {
1016 #[derive(Debug, serde::Deserialize, PartialEq)]
1017 struct P {
1018 name: String,
1019 count: u32,
1020 }
1021 let mut map = serde_json::Map::new();
1022 map.insert("name".to_owned(), serde_json::json!("test"));
1023 map.insert("count".to_owned(), serde_json::json!(42));
1024 let p: P = deserialize_params(&map).unwrap();
1025 assert_eq!(
1026 p,
1027 P {
1028 name: "test".to_owned(),
1029 count: 42
1030 }
1031 );
1032 }
1033
1034 #[test]
1035 fn deserialize_params_missing_required_field() {
1036 #[derive(Debug, serde::Deserialize)]
1037 #[allow(dead_code)]
1038 struct P {
1039 name: String,
1040 }
1041 let map = serde_json::Map::new();
1042 let err = deserialize_params::<P>(&map).unwrap_err();
1043 assert!(matches!(err, ToolError::InvalidParams { .. }));
1044 }
1045
1046 #[test]
1047 fn deserialize_params_wrong_type() {
1048 #[derive(Debug, serde::Deserialize)]
1049 #[allow(dead_code)]
1050 struct P {
1051 count: u32,
1052 }
1053 let mut map = serde_json::Map::new();
1054 map.insert("count".to_owned(), serde_json::json!("not a number"));
1055 let err = deserialize_params::<P>(&map).unwrap_err();
1056 assert!(matches!(err, ToolError::InvalidParams { .. }));
1057 }
1058
1059 #[test]
1060 fn deserialize_params_all_optional_empty() {
1061 #[derive(Debug, serde::Deserialize, PartialEq)]
1062 struct P {
1063 name: Option<String>,
1064 }
1065 let map = serde_json::Map::new();
1066 let p: P = deserialize_params(&map).unwrap();
1067 assert_eq!(p, P { name: None });
1068 }
1069
1070 #[test]
1071 fn deserialize_params_ignores_extra_fields() {
1072 #[derive(Debug, serde::Deserialize, PartialEq)]
1073 struct P {
1074 name: String,
1075 }
1076 let mut map = serde_json::Map::new();
1077 map.insert("name".to_owned(), serde_json::json!("test"));
1078 map.insert("extra".to_owned(), serde_json::json!(true));
1079 let p: P = deserialize_params(&map).unwrap();
1080 assert_eq!(
1081 p,
1082 P {
1083 name: "test".to_owned()
1084 }
1085 );
1086 }
1087
1088 #[test]
1089 fn tool_error_execution_display() {
1090 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash not found");
1091 let err = ToolError::Execution(io_err);
1092 assert!(err.to_string().starts_with("execution failed:"));
1093 assert!(err.to_string().contains("bash not found"));
1094 }
1095
1096 #[test]
1098 fn error_kind_timeout_is_transient() {
1099 let err = ToolError::Timeout { timeout_secs: 30 };
1100 assert_eq!(err.kind(), ErrorKind::Transient);
1101 }
1102
1103 #[test]
1104 fn error_kind_blocked_is_permanent() {
1105 let err = ToolError::Blocked {
1106 command: "rm -rf /".to_owned(),
1107 };
1108 assert_eq!(err.kind(), ErrorKind::Permanent);
1109 }
1110
1111 #[test]
1112 fn error_kind_sandbox_violation_is_permanent() {
1113 let err = ToolError::SandboxViolation {
1114 path: "/etc/shadow".to_owned(),
1115 };
1116 assert_eq!(err.kind(), ErrorKind::Permanent);
1117 }
1118
1119 #[test]
1120 fn error_kind_cancelled_is_permanent() {
1121 assert_eq!(ToolError::Cancelled.kind(), ErrorKind::Permanent);
1122 }
1123
1124 #[test]
1125 fn error_kind_invalid_params_is_permanent() {
1126 let err = ToolError::InvalidParams {
1127 message: "bad arg".to_owned(),
1128 };
1129 assert_eq!(err.kind(), ErrorKind::Permanent);
1130 }
1131
1132 #[test]
1133 fn error_kind_confirmation_required_is_permanent() {
1134 let err = ToolError::ConfirmationRequired {
1135 command: "rm /tmp/x".to_owned(),
1136 };
1137 assert_eq!(err.kind(), ErrorKind::Permanent);
1138 }
1139
1140 #[test]
1141 fn error_kind_execution_timed_out_is_transient() {
1142 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1143 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1144 }
1145
1146 #[test]
1147 fn error_kind_execution_interrupted_is_transient() {
1148 let io_err = std::io::Error::new(std::io::ErrorKind::Interrupted, "interrupted");
1149 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1150 }
1151
1152 #[test]
1153 fn error_kind_execution_connection_reset_is_transient() {
1154 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
1155 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1156 }
1157
1158 #[test]
1159 fn error_kind_execution_broken_pipe_is_transient() {
1160 let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
1161 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1162 }
1163
1164 #[test]
1165 fn error_kind_execution_would_block_is_transient() {
1166 let io_err = std::io::Error::new(std::io::ErrorKind::WouldBlock, "would block");
1167 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1168 }
1169
1170 #[test]
1171 fn error_kind_execution_connection_aborted_is_transient() {
1172 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionAborted, "aborted");
1173 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1174 }
1175
1176 #[test]
1177 fn error_kind_execution_not_found_is_permanent() {
1178 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
1179 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1180 }
1181
1182 #[test]
1183 fn error_kind_execution_permission_denied_is_permanent() {
1184 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
1185 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1186 }
1187
1188 #[test]
1189 fn error_kind_execution_other_is_permanent() {
1190 let io_err = std::io::Error::other("some other error");
1191 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1192 }
1193
1194 #[test]
1195 fn error_kind_execution_already_exists_is_permanent() {
1196 let io_err = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists");
1197 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1198 }
1199
1200 #[test]
1201 fn error_kind_display() {
1202 assert_eq!(ErrorKind::Transient.to_string(), "transient");
1203 assert_eq!(ErrorKind::Permanent.to_string(), "permanent");
1204 }
1205
1206 #[test]
1207 fn truncate_tool_output_short_passthrough() {
1208 let short = "hello world";
1209 assert_eq!(truncate_tool_output(short), short);
1210 }
1211
1212 #[test]
1213 fn truncate_tool_output_exact_limit() {
1214 let exact = "a".repeat(MAX_TOOL_OUTPUT_CHARS);
1215 assert_eq!(truncate_tool_output(&exact), exact);
1216 }
1217
1218 #[test]
1219 fn truncate_tool_output_long_split() {
1220 let long = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
1221 let result = truncate_tool_output(&long);
1222 assert!(result.contains("truncated"));
1223 assert!(result.len() < long.len());
1224 }
1225
1226 #[test]
1227 fn truncate_tool_output_notice_contains_count() {
1228 let long = "y".repeat(MAX_TOOL_OUTPUT_CHARS + 2000);
1229 let result = truncate_tool_output(&long);
1230 assert!(result.contains("truncated"));
1231 assert!(result.contains("chars"));
1232 }
1233
1234 #[derive(Debug)]
1235 struct DefaultExecutor;
1236 impl ToolExecutor for DefaultExecutor {
1237 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1238 Ok(None)
1239 }
1240 }
1241
1242 #[tokio::test]
1243 async fn execute_tool_call_default_returns_none() {
1244 let exec = DefaultExecutor;
1245 let call = ToolCall {
1246 tool_id: ToolName::new("anything"),
1247 params: serde_json::Map::new(),
1248 caller_id: None,
1249 context: None,
1250
1251 tool_call_id: String::new(),
1252 };
1253 let result = exec.execute_tool_call(&call).await.unwrap();
1254 assert!(result.is_none());
1255 }
1256
1257 #[test]
1258 fn filter_stats_savings_pct() {
1259 let fs = FilterStats {
1260 raw_chars: 1000,
1261 filtered_chars: 200,
1262 ..Default::default()
1263 };
1264 assert!((fs.savings_pct() - 80.0).abs() < 0.01);
1265 }
1266
1267 #[test]
1268 fn filter_stats_savings_pct_zero() {
1269 let fs = FilterStats::default();
1270 assert!((fs.savings_pct()).abs() < 0.01);
1271 }
1272
1273 #[test]
1274 fn filter_stats_estimated_tokens_saved() {
1275 let fs = FilterStats {
1276 raw_chars: 1000,
1277 filtered_chars: 200,
1278 ..Default::default()
1279 };
1280 assert_eq!(fs.estimated_tokens_saved(), 200); }
1282
1283 #[test]
1284 fn filter_stats_format_inline() {
1285 let fs = FilterStats {
1286 raw_chars: 1000,
1287 filtered_chars: 200,
1288 raw_lines: 342,
1289 filtered_lines: 28,
1290 ..Default::default()
1291 };
1292 let line = fs.format_inline("shell");
1293 assert_eq!(line, "[shell] 342 lines \u{2192} 28 lines, 80.0% filtered");
1294 }
1295
1296 #[test]
1297 fn filter_stats_format_inline_zero() {
1298 let fs = FilterStats::default();
1299 let line = fs.format_inline("bash");
1300 assert_eq!(line, "[bash] 0 lines \u{2192} 0 lines, 0.0% filtered");
1301 }
1302
1303 struct FixedExecutor {
1306 tool_id: &'static str,
1307 output: &'static str,
1308 }
1309
1310 impl ToolExecutor for FixedExecutor {
1311 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1312 Ok(Some(ToolOutput {
1313 tool_name: ToolName::new(self.tool_id),
1314 summary: self.output.to_owned(),
1315 blocks_executed: 1,
1316 filter_stats: None,
1317 diff: None,
1318 streamed: false,
1319 terminal_id: None,
1320 locations: None,
1321 raw_response: None,
1322 claim_source: None,
1323 }))
1324 }
1325
1326 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
1327 vec![]
1328 }
1329
1330 async fn execute_tool_call(
1331 &self,
1332 _call: &ToolCall,
1333 ) -> Result<Option<ToolOutput>, ToolError> {
1334 Ok(Some(ToolOutput {
1335 tool_name: ToolName::new(self.tool_id),
1336 summary: self.output.to_owned(),
1337 blocks_executed: 1,
1338 filter_stats: None,
1339 diff: None,
1340 streamed: false,
1341 terminal_id: None,
1342 locations: None,
1343 raw_response: None,
1344 claim_source: None,
1345 }))
1346 }
1347 }
1348
1349 #[tokio::test]
1350 async fn dyn_executor_execute_delegates() {
1351 let inner = std::sync::Arc::new(FixedExecutor {
1352 tool_id: "bash",
1353 output: "hello",
1354 });
1355 let exec = DynExecutor(inner);
1356 let result = exec.execute("```bash\necho hello\n```").await.unwrap();
1357 assert!(result.is_some());
1358 assert_eq!(result.unwrap().summary, "hello");
1359 }
1360
1361 #[tokio::test]
1362 async fn dyn_executor_execute_confirmed_delegates() {
1363 let inner = std::sync::Arc::new(FixedExecutor {
1364 tool_id: "bash",
1365 output: "confirmed",
1366 });
1367 let exec = DynExecutor(inner);
1368 let result = exec.execute_confirmed("...").await.unwrap();
1369 assert!(result.is_some());
1370 assert_eq!(result.unwrap().summary, "confirmed");
1371 }
1372
1373 #[test]
1374 fn dyn_executor_tool_definitions_delegates() {
1375 let inner = std::sync::Arc::new(FixedExecutor {
1376 tool_id: "my_tool",
1377 output: "",
1378 });
1379 let exec = DynExecutor(inner);
1380 let defs = exec.tool_definitions();
1382 assert!(defs.is_empty());
1383 }
1384
1385 #[tokio::test]
1386 async fn dyn_executor_execute_tool_call_delegates() {
1387 let inner = std::sync::Arc::new(FixedExecutor {
1388 tool_id: "bash",
1389 output: "tool_call_result",
1390 });
1391 let exec = DynExecutor(inner);
1392 let call = ToolCall {
1393 tool_id: ToolName::new("bash"),
1394 params: serde_json::Map::new(),
1395 caller_id: None,
1396 context: None,
1397
1398 tool_call_id: String::new(),
1399 };
1400 let result = exec.execute_tool_call(&call).await.unwrap();
1401 assert!(result.is_some());
1402 assert_eq!(result.unwrap().summary, "tool_call_result");
1403 }
1404
1405 #[test]
1406 fn dyn_executor_set_effective_trust_delegates() {
1407 use std::sync::atomic::{AtomicU8, Ordering};
1408
1409 struct TrustCapture(AtomicU8);
1410 impl ToolExecutor for TrustCapture {
1411 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1412 Ok(None)
1413 }
1414 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
1415 let v = match level {
1417 crate::SkillTrustLevel::Trusted => 0u8,
1418 crate::SkillTrustLevel::Verified => 1,
1419 crate::SkillTrustLevel::Quarantined => 2,
1420 crate::SkillTrustLevel::Blocked => 3,
1421 };
1422 self.0.store(v, Ordering::Relaxed);
1423 }
1424 }
1425
1426 let inner = std::sync::Arc::new(TrustCapture(AtomicU8::new(0)));
1427 let exec =
1428 DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
1429 ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Quarantined);
1430 assert_eq!(inner.0.load(Ordering::Relaxed), 2);
1431
1432 ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Blocked);
1433 assert_eq!(inner.0.load(Ordering::Relaxed), 3);
1434 }
1435
1436 #[test]
1437 fn extract_fenced_blocks_no_prefix_match() {
1438 assert!(extract_fenced_blocks("```bashrc\nfoo\n```", "bash").is_empty());
1440 assert_eq!(
1442 extract_fenced_blocks("```bash\nfoo\n```", "bash"),
1443 vec!["foo"]
1444 );
1445 assert_eq!(
1447 extract_fenced_blocks("```bash \nfoo\n```", "bash"),
1448 vec!["foo"]
1449 );
1450 }
1451
1452 #[test]
1455 fn tool_error_http_400_category_is_invalid_parameters() {
1456 use crate::error_taxonomy::ToolErrorCategory;
1457 let err = ToolError::Http {
1458 status: 400,
1459 message: "bad request".to_owned(),
1460 };
1461 assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1462 }
1463
1464 #[test]
1465 fn tool_error_http_401_category_is_policy_blocked() {
1466 use crate::error_taxonomy::ToolErrorCategory;
1467 let err = ToolError::Http {
1468 status: 401,
1469 message: "unauthorized".to_owned(),
1470 };
1471 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1472 }
1473
1474 #[test]
1475 fn tool_error_http_403_category_is_policy_blocked() {
1476 use crate::error_taxonomy::ToolErrorCategory;
1477 let err = ToolError::Http {
1478 status: 403,
1479 message: "forbidden".to_owned(),
1480 };
1481 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1482 }
1483
1484 #[test]
1485 fn tool_error_http_404_category_is_permanent_failure() {
1486 use crate::error_taxonomy::ToolErrorCategory;
1487 let err = ToolError::Http {
1488 status: 404,
1489 message: "not found".to_owned(),
1490 };
1491 assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1492 }
1493
1494 #[test]
1495 fn tool_error_http_429_category_is_rate_limited() {
1496 use crate::error_taxonomy::ToolErrorCategory;
1497 let err = ToolError::Http {
1498 status: 429,
1499 message: "too many requests".to_owned(),
1500 };
1501 assert_eq!(err.category(), ToolErrorCategory::RateLimited);
1502 }
1503
1504 #[test]
1505 fn tool_error_http_500_category_is_server_error() {
1506 use crate::error_taxonomy::ToolErrorCategory;
1507 let err = ToolError::Http {
1508 status: 500,
1509 message: "internal server error".to_owned(),
1510 };
1511 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1512 }
1513
1514 #[test]
1515 fn tool_error_http_502_category_is_server_error() {
1516 use crate::error_taxonomy::ToolErrorCategory;
1517 let err = ToolError::Http {
1518 status: 502,
1519 message: "bad gateway".to_owned(),
1520 };
1521 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1522 }
1523
1524 #[test]
1525 fn tool_error_http_503_category_is_server_error() {
1526 use crate::error_taxonomy::ToolErrorCategory;
1527 let err = ToolError::Http {
1528 status: 503,
1529 message: "service unavailable".to_owned(),
1530 };
1531 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1532 }
1533
1534 #[test]
1535 fn tool_error_http_503_is_transient_triggers_phase2_retry() {
1536 let err = ToolError::Http {
1539 status: 503,
1540 message: "service unavailable".to_owned(),
1541 };
1542 assert_eq!(
1543 err.kind(),
1544 ErrorKind::Transient,
1545 "HTTP 503 must be Transient so Phase 2 retry fires"
1546 );
1547 }
1548
1549 #[test]
1550 fn tool_error_blocked_category_is_policy_blocked() {
1551 use crate::error_taxonomy::ToolErrorCategory;
1552 let err = ToolError::Blocked {
1553 command: "rm -rf /".to_owned(),
1554 };
1555 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1556 }
1557
1558 #[test]
1559 fn tool_error_sandbox_violation_category_is_policy_blocked() {
1560 use crate::error_taxonomy::ToolErrorCategory;
1561 let err = ToolError::SandboxViolation {
1562 path: "/etc/shadow".to_owned(),
1563 };
1564 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1565 }
1566
1567 #[test]
1568 fn tool_error_confirmation_required_category() {
1569 use crate::error_taxonomy::ToolErrorCategory;
1570 let err = ToolError::ConfirmationRequired {
1571 command: "rm /tmp/x".to_owned(),
1572 };
1573 assert_eq!(err.category(), ToolErrorCategory::ConfirmationRequired);
1574 }
1575
1576 #[test]
1577 fn tool_error_timeout_category() {
1578 use crate::error_taxonomy::ToolErrorCategory;
1579 let err = ToolError::Timeout { timeout_secs: 30 };
1580 assert_eq!(err.category(), ToolErrorCategory::Timeout);
1581 }
1582
1583 #[test]
1584 fn tool_error_cancelled_category() {
1585 use crate::error_taxonomy::ToolErrorCategory;
1586 assert_eq!(
1587 ToolError::Cancelled.category(),
1588 ToolErrorCategory::Cancelled
1589 );
1590 }
1591
1592 #[test]
1593 fn tool_error_invalid_params_category() {
1594 use crate::error_taxonomy::ToolErrorCategory;
1595 let err = ToolError::InvalidParams {
1596 message: "missing field".to_owned(),
1597 };
1598 assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1599 }
1600
1601 #[test]
1603 fn tool_error_execution_not_found_category_is_permanent_failure() {
1604 use crate::error_taxonomy::ToolErrorCategory;
1605 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash: not found");
1606 let err = ToolError::Execution(io_err);
1607 let cat = err.category();
1608 assert_ne!(
1609 cat,
1610 ToolErrorCategory::ToolNotFound,
1611 "Execution(NotFound) must NOT map to ToolNotFound"
1612 );
1613 assert_eq!(cat, ToolErrorCategory::PermanentFailure);
1614 }
1615
1616 #[test]
1617 fn tool_error_execution_timed_out_category_is_timeout() {
1618 use crate::error_taxonomy::ToolErrorCategory;
1619 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
1620 assert_eq!(
1621 ToolError::Execution(io_err).category(),
1622 ToolErrorCategory::Timeout
1623 );
1624 }
1625
1626 #[test]
1627 fn tool_error_execution_connection_refused_category_is_network_error() {
1628 use crate::error_taxonomy::ToolErrorCategory;
1629 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1630 assert_eq!(
1631 ToolError::Execution(io_err).category(),
1632 ToolErrorCategory::NetworkError
1633 );
1634 }
1635
1636 #[test]
1638 fn b4_tool_error_http_429_not_quality_failure() {
1639 let err = ToolError::Http {
1640 status: 429,
1641 message: "rate limited".to_owned(),
1642 };
1643 assert!(
1644 !err.category().is_quality_failure(),
1645 "RateLimited must not be a quality failure"
1646 );
1647 }
1648
1649 #[test]
1650 fn b4_tool_error_http_503_not_quality_failure() {
1651 let err = ToolError::Http {
1652 status: 503,
1653 message: "service unavailable".to_owned(),
1654 };
1655 assert!(
1656 !err.category().is_quality_failure(),
1657 "ServerError must not be a quality failure"
1658 );
1659 }
1660
1661 #[test]
1662 fn b4_tool_error_execution_timed_out_not_quality_failure() {
1663 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1664 assert!(
1665 !ToolError::Execution(io_err).category().is_quality_failure(),
1666 "Timeout must not be a quality failure"
1667 );
1668 }
1669
1670 #[test]
1673 fn tool_error_shell_exit126_is_policy_blocked() {
1674 use crate::error_taxonomy::ToolErrorCategory;
1675 let err = ToolError::Shell {
1676 exit_code: 126,
1677 category: ToolErrorCategory::PolicyBlocked,
1678 message: "permission denied".to_owned(),
1679 };
1680 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1681 }
1682
1683 #[test]
1684 fn tool_error_shell_exit127_is_permanent_failure() {
1685 use crate::error_taxonomy::ToolErrorCategory;
1686 let err = ToolError::Shell {
1687 exit_code: 127,
1688 category: ToolErrorCategory::PermanentFailure,
1689 message: "command not found".to_owned(),
1690 };
1691 assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1692 assert!(!err.category().is_retryable());
1693 }
1694
1695 #[test]
1696 fn tool_error_shell_not_quality_failure() {
1697 use crate::error_taxonomy::ToolErrorCategory;
1698 let err = ToolError::Shell {
1699 exit_code: 127,
1700 category: ToolErrorCategory::PermanentFailure,
1701 message: "command not found".to_owned(),
1702 };
1703 assert!(!err.category().is_quality_failure());
1705 }
1706
1707 struct StubExecutor;
1711 impl ToolExecutor for StubExecutor {
1712 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1713 Ok(None)
1714 }
1715 }
1716
1717 struct ConfirmingExecutor;
1719 impl ToolExecutor for ConfirmingExecutor {
1720 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1721 Ok(None)
1722 }
1723 fn requires_confirmation(&self, _call: &ToolCall) -> bool {
1724 true
1725 }
1726 }
1727
1728 fn dummy_call() -> ToolCall {
1729 ToolCall {
1730 tool_id: ToolName::new("test"),
1731 params: serde_json::Map::new(),
1732 caller_id: None,
1733 context: None,
1734
1735 tool_call_id: String::new(),
1736 }
1737 }
1738
1739 #[test]
1740 fn requires_confirmation_default_is_false_on_tool_executor() {
1741 let exec = StubExecutor;
1742 assert!(
1743 !exec.requires_confirmation(&dummy_call()),
1744 "ToolExecutor default requires_confirmation must be false"
1745 );
1746 }
1747
1748 #[test]
1749 fn requires_confirmation_erased_delegates_to_tool_executor_default() {
1750 let exec = StubExecutor;
1752 assert!(
1753 !ErasedToolExecutor::requires_confirmation_erased(&exec, &dummy_call()),
1754 "requires_confirmation_erased via blanket impl must return false for stub executor"
1755 );
1756 }
1757
1758 #[test]
1759 fn requires_confirmation_erased_delegates_override() {
1760 let exec = ConfirmingExecutor;
1763 assert!(
1764 ErasedToolExecutor::requires_confirmation_erased(&exec, &dummy_call()),
1765 "requires_confirmation_erased must return true when ToolExecutor override returns true"
1766 );
1767 }
1768
1769 #[test]
1770 fn requires_confirmation_erased_default_on_erased_trait_is_true() {
1771 struct ManualErased;
1776 impl ErasedToolExecutor for ManualErased {
1777 fn execute_erased<'a>(
1778 &'a self,
1779 _response: &'a str,
1780 ) -> std::pin::Pin<
1781 Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>,
1782 > {
1783 Box::pin(std::future::ready(Ok(None)))
1784 }
1785 fn execute_confirmed_erased<'a>(
1786 &'a self,
1787 _response: &'a str,
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 tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
1794 vec![]
1795 }
1796 fn execute_tool_call_erased<'a>(
1797 &'a self,
1798 _call: &'a ToolCall,
1799 ) -> std::pin::Pin<
1800 Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>,
1801 > {
1802 Box::pin(std::future::ready(Ok(None)))
1803 }
1804 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
1805 false
1806 }
1807 }
1809 let exec = ManualErased;
1810 assert!(
1811 exec.requires_confirmation_erased(&dummy_call()),
1812 "ErasedToolExecutor trait-level default for requires_confirmation_erased must be true"
1813 );
1814 }
1815
1816 #[test]
1819 fn dyn_executor_requires_confirmation_delegates() {
1820 let inner = std::sync::Arc::new(ConfirmingExecutor);
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 delegate requires_confirmation to inner executor"
1826 );
1827 }
1828
1829 #[test]
1830 fn dyn_executor_requires_confirmation_default_false() {
1831 let inner = std::sync::Arc::new(StubExecutor);
1832 let exec =
1833 DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
1834 assert!(
1835 !ToolExecutor::requires_confirmation(&exec, &dummy_call()),
1836 "DynExecutor must return false when inner executor does not require confirmation"
1837 );
1838 }
1839}