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, Default)]
50pub struct ToolCall {
51 pub tool_id: ToolName,
53 pub params: serde_json::Map<String, serde_json::Value>,
55 pub caller_id: Option<String>,
58 pub context: Option<crate::ExecutionContext>,
61 pub tool_call_id: String,
64 pub skill_name: Option<Vec<String>>,
71}
72
73#[derive(Debug, Clone, Default)]
78pub struct FilterStats {
79 pub raw_chars: usize,
81 pub filtered_chars: usize,
83 pub raw_lines: usize,
85 pub filtered_lines: usize,
87 pub confidence: Option<crate::FilterConfidence>,
89 pub command: Option<String>,
91 pub kept_lines: Vec<usize>,
93}
94
95impl FilterStats {
96 #[must_use]
100 #[allow(clippy::cast_precision_loss)]
101 pub fn savings_pct(&self) -> f64 {
102 if self.raw_chars == 0 {
103 return 0.0;
104 }
105 (1.0 - self.filtered_chars as f64 / self.raw_chars as f64) * 100.0
106 }
107
108 #[must_use]
113 pub fn estimated_tokens_saved(&self) -> usize {
114 self.raw_chars.saturating_sub(self.filtered_chars) / 4
115 }
116
117 #[must_use]
136 pub fn format_inline(&self, tool_name: &str) -> String {
137 let cmd_label = self
138 .command
139 .as_deref()
140 .map(|c| {
141 let trimmed = c.trim();
142 if trimmed.len() > 60 {
143 format!(" `{}…`", &trimmed[..57])
144 } else {
145 format!(" `{trimmed}`")
146 }
147 })
148 .unwrap_or_default();
149 format!(
150 "[{tool_name}]{cmd_label} {} lines \u{2192} {} lines, {:.1}% filtered",
151 self.raw_lines,
152 self.filtered_lines,
153 self.savings_pct()
154 )
155 }
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
164#[serde(rename_all = "snake_case")]
165#[non_exhaustive]
166pub enum ClaimSource {
167 Shell,
169 FileSystem,
171 WebScrape,
173 Mcp,
175 A2a,
177 CodeSearch,
179 Diagnostics,
181 Memory,
183 Moderation,
185}
186
187#[derive(Debug, Clone)]
213pub struct ToolOutput {
214 pub tool_name: ToolName,
216 pub summary: String,
218 pub blocks_executed: u32,
220 pub filter_stats: Option<FilterStats>,
222 pub diff: Option<DiffData>,
224 pub streamed: bool,
226 pub terminal_id: Option<String>,
228 pub locations: Option<Vec<String>>,
230 pub raw_response: Option<serde_json::Value>,
232 pub claim_source: Option<ClaimSource>,
235}
236
237impl fmt::Display for ToolOutput {
238 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
239 f.write_str(&self.summary)
240 }
241}
242
243pub const MAX_TOOL_OUTPUT_CHARS: usize = 30_000;
248
249#[must_use]
262pub fn truncate_tool_output(output: &str) -> String {
263 truncate_tool_output_at(output, MAX_TOOL_OUTPUT_CHARS)
264}
265
266#[must_use]
282pub fn truncate_tool_output_at(output: &str, max_chars: usize) -> String {
283 if output.len() <= max_chars {
284 return output.to_string();
285 }
286
287 let half = max_chars / 2;
288 let head_end = output.floor_char_boundary(half);
289 let tail_start = output.ceil_char_boundary(output.len() - half);
290 let head = &output[..head_end];
291 let tail = &output[tail_start..];
292 let truncated = output.len() - head_end - (output.len() - tail_start);
293
294 format!(
295 "{head}\n\n... [truncated {truncated} chars, showing first and last ~{half} chars] ...\n\n{tail}"
296 )
297}
298
299#[derive(Debug, Clone)]
304#[non_exhaustive]
305pub enum ToolEvent {
306 Started {
308 tool_name: ToolName,
309 command: String,
310 sandbox_profile: Option<String>,
312 resolved_cwd: Option<String>,
315 execution_env: Option<String>,
318 },
319 OutputChunk {
321 tool_name: ToolName,
322 command: String,
323 chunk: String,
324 tool_call_id: String,
327 skill_name: Option<Vec<String>>,
329 },
330 Completed {
332 tool_name: ToolName,
333 command: String,
334 output: String,
336 success: bool,
338 filter_stats: Option<FilterStats>,
339 diff: Option<DiffData>,
340 run_id: Option<RunId>,
342 },
343 Rollback {
345 tool_name: ToolName,
346 command: String,
347 restored_count: usize,
349 deleted_count: usize,
351 },
352}
353
354pub type ToolEventTx = tokio::sync::mpsc::Sender<ToolEvent>;
362
363pub type ToolEventRx = tokio::sync::mpsc::Receiver<ToolEvent>;
365
366pub const TOOL_EVENT_CHANNEL_CAP: usize = 1024;
368
369#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
374#[non_exhaustive]
375pub enum ErrorKind {
376 Transient,
377 Permanent,
378}
379
380impl std::fmt::Display for ErrorKind {
381 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382 match self {
383 Self::Transient => f.write_str("transient"),
384 Self::Permanent => f.write_str("permanent"),
385 }
386 }
387}
388
389#[non_exhaustive]
390#[derive(Debug, thiserror::Error)]
392pub enum ToolError {
393 #[error("command blocked by policy: {command}")]
394 Blocked { command: String },
395
396 #[error("command blocked by policy: {command}")]
402 BlockedWithFix {
403 command: String,
404 suggestion: Option<crate::shell::SafeFixSuggestion>,
405 },
406
407 #[error("path not allowed by sandbox: {path}")]
408 SandboxViolation { path: String },
409
410 #[error("command requires confirmation: {command}")]
411 ConfirmationRequired { command: String },
412
413 #[error("command timed out after {timeout_secs}s")]
414 Timeout { timeout_secs: u64 },
415
416 #[error("operation cancelled")]
417 Cancelled,
418
419 #[error("invalid tool parameters: {message}")]
420 InvalidParams { message: String },
421
422 #[error("execution failed: {0}")]
423 Execution(#[from] std::io::Error),
424
425 #[error("HTTP error {status}: {message}")]
430 Http { status: u16, message: String },
431
432 #[error("shell error (exit {exit_code}): {message}")]
438 Shell {
439 exit_code: i32,
440 category: crate::error_taxonomy::ToolErrorCategory,
441 message: String,
442 },
443
444 #[error("snapshot failed: {reason}")]
445 SnapshotFailed { reason: String },
446
447 #[error("tool call denied by policy")]
453 OutOfScope {
454 tool_id: String,
456 task_type: Option<String>,
458 },
459
460 #[error("tool call denied by safety probe: {reason}")]
466 SafetyDenied {
467 reason: String,
469 },
470
471 #[error("tool call blocked: trajectory risk {score:.3} exceeds threshold")]
476 TrajectoryRiskExceeded {
477 score: f64,
479 top_signals: Vec<String>,
481 },
482}
483
484impl ToolError {
485 #[must_use]
490 pub fn category(&self) -> crate::error_taxonomy::ToolErrorCategory {
491 use crate::error_taxonomy::{ToolErrorCategory, classify_http_status, classify_io_error};
492 match self {
493 Self::Blocked { .. } | Self::BlockedWithFix { .. } | Self::SandboxViolation { .. } => {
494 ToolErrorCategory::PolicyBlocked
495 }
496 Self::ConfirmationRequired { .. } => ToolErrorCategory::ConfirmationRequired,
497 Self::Timeout { .. } => ToolErrorCategory::Timeout,
498 Self::Cancelled => ToolErrorCategory::Cancelled,
499 Self::InvalidParams { .. } => ToolErrorCategory::InvalidParameters,
500 Self::Http { status, .. } => classify_http_status(*status),
501 Self::Execution(io_err) => classify_io_error(io_err),
502 Self::Shell { category, .. } => *category,
503 Self::SnapshotFailed { .. } => ToolErrorCategory::PermanentFailure,
504 Self::OutOfScope { .. }
505 | Self::SafetyDenied { .. }
506 | Self::TrajectoryRiskExceeded { .. } => ToolErrorCategory::PolicyBlocked,
507 }
508 }
509
510 #[must_use]
518 pub fn kind(&self) -> ErrorKind {
519 use crate::error_taxonomy::ToolErrorCategoryExt;
520 self.category().error_kind()
521 }
522}
523
524pub fn deserialize_params<T: serde::de::DeserializeOwned>(
530 params: &serde_json::Map<String, serde_json::Value>,
531) -> Result<T, ToolError> {
532 let obj = serde_json::Value::Object(params.clone());
533 serde_json::from_value(obj).map_err(|e| ToolError::InvalidParams {
534 message: e.to_string(),
535 })
536}
537
538pub trait ToolExecutor: Send + Sync {
623 fn execute(
632 &self,
633 response: &str,
634 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send;
635
636 fn execute_confirmed(
645 &self,
646 response: &str,
647 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
648 self.execute(response)
649 }
650
651 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
656 vec![]
657 }
658
659 fn execute_tool_call(
665 &self,
666 _call: &ToolCall,
667 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
668 std::future::ready(Ok(None))
669 }
670
671 fn execute_tool_call_confirmed(
680 &self,
681 call: &ToolCall,
682 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
683 self.execute_tool_call(call)
684 }
685
686 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
691
692 fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
696
697 fn is_tool_retryable(&self, _tool_id: &str) -> bool {
703 false
704 }
705
706 fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
733 false
734 }
735
736 fn requires_confirmation(&self, _call: &ToolCall) -> bool {
744 false
745 }
746}
747
748pub trait ErasedToolExecutor: Send + Sync {
757 fn execute_erased<'a>(
758 &'a self,
759 response: &'a str,
760 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
761
762 fn execute_confirmed_erased<'a>(
763 &'a self,
764 response: &'a str,
765 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
766
767 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef>;
768
769 fn execute_tool_call_erased<'a>(
770 &'a self,
771 call: &'a ToolCall,
772 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
773
774 fn execute_tool_call_confirmed_erased<'a>(
775 &'a self,
776 call: &'a ToolCall,
777 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
778 {
779 self.execute_tool_call_erased(call)
783 }
784
785 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
787
788 fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
790
791 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool;
793
794 fn is_tool_speculatable_erased(&self, _tool_id: &str) -> bool {
798 false
799 }
800
801 fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
810 true
811 }
812}
813
814impl<T: ToolExecutor> ErasedToolExecutor for T {
815 fn execute_erased<'a>(
816 &'a self,
817 response: &'a str,
818 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
819 {
820 Box::pin(self.execute(response))
821 }
822
823 fn execute_confirmed_erased<'a>(
824 &'a self,
825 response: &'a str,
826 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
827 {
828 Box::pin(self.execute_confirmed(response))
829 }
830
831 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
832 self.tool_definitions()
833 }
834
835 fn execute_tool_call_erased<'a>(
836 &'a self,
837 call: &'a ToolCall,
838 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
839 {
840 Box::pin(self.execute_tool_call(call))
841 }
842
843 fn execute_tool_call_confirmed_erased<'a>(
844 &'a self,
845 call: &'a ToolCall,
846 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
847 {
848 Box::pin(self.execute_tool_call_confirmed(call))
849 }
850
851 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
852 ToolExecutor::set_skill_env(self, env);
853 }
854
855 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
856 ToolExecutor::set_effective_trust(self, level);
857 }
858
859 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
860 ToolExecutor::is_tool_retryable(self, tool_id)
861 }
862
863 fn is_tool_speculatable_erased(&self, tool_id: &str) -> bool {
864 ToolExecutor::is_tool_speculatable(self, tool_id)
865 }
866
867 fn requires_confirmation_erased(&self, call: &ToolCall) -> bool {
868 ToolExecutor::requires_confirmation(self, call)
869 }
870}
871
872pub struct DynExecutor(pub std::sync::Arc<dyn ErasedToolExecutor>);
876
877impl ToolExecutor for DynExecutor {
878 fn execute(
879 &self,
880 response: &str,
881 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
882 let inner = std::sync::Arc::clone(&self.0);
884 let response = response.to_owned();
885 async move { inner.execute_erased(&response).await }
886 }
887
888 fn execute_confirmed(
889 &self,
890 response: &str,
891 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
892 let inner = std::sync::Arc::clone(&self.0);
893 let response = response.to_owned();
894 async move { inner.execute_confirmed_erased(&response).await }
895 }
896
897 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
898 self.0.tool_definitions_erased()
899 }
900
901 fn execute_tool_call(
902 &self,
903 call: &ToolCall,
904 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
905 let inner = std::sync::Arc::clone(&self.0);
906 let call = call.clone();
907 async move { inner.execute_tool_call_erased(&call).await }
908 }
909
910 fn execute_tool_call_confirmed(
911 &self,
912 call: &ToolCall,
913 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
914 let inner = std::sync::Arc::clone(&self.0);
915 let call = call.clone();
916 async move { inner.execute_tool_call_confirmed_erased(&call).await }
917 }
918
919 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
920 ErasedToolExecutor::set_skill_env(self.0.as_ref(), env);
921 }
922
923 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
924 ErasedToolExecutor::set_effective_trust(self.0.as_ref(), level);
925 }
926
927 fn is_tool_retryable(&self, tool_id: &str) -> bool {
928 self.0.is_tool_retryable_erased(tool_id)
929 }
930
931 fn is_tool_speculatable(&self, tool_id: &str) -> bool {
932 self.0.is_tool_speculatable_erased(tool_id)
933 }
934
935 fn requires_confirmation(&self, call: &ToolCall) -> bool {
936 self.0.requires_confirmation_erased(call)
937 }
938}
939
940#[must_use]
944pub fn extract_fenced_blocks<'a>(text: &'a str, lang: &str) -> Vec<&'a str> {
945 let marker = format!("```{lang}");
946 let marker_len = marker.len();
947 let mut blocks = Vec::new();
948 let mut rest = text;
949
950 let mut search_from = 0;
951 while let Some(rel) = rest[search_from..].find(&marker) {
952 let start = search_from + rel;
953 let after = &rest[start + marker_len..];
954 let boundary_ok = after
958 .chars()
959 .next()
960 .is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '-');
961 if !boundary_ok {
962 search_from = start + marker_len;
963 continue;
964 }
965 if let Some(end) = after.find("```") {
966 blocks.push(after[..end].trim());
967 rest = &after[end + 3..];
968 search_from = 0;
969 } else {
970 break;
971 }
972 }
973
974 blocks
975}
976
977#[cfg(test)]
978mod tests {
979 use super::*;
980
981 #[test]
982 fn tool_output_display() {
983 let output = ToolOutput {
984 tool_name: ToolName::new("bash"),
985 summary: "$ echo hello\nhello".to_owned(),
986 blocks_executed: 1,
987 filter_stats: None,
988 diff: None,
989 streamed: false,
990 terminal_id: None,
991 locations: None,
992 raw_response: None,
993 claim_source: None,
994 };
995 assert_eq!(output.to_string(), "$ echo hello\nhello");
996 }
997
998 #[test]
999 fn tool_error_blocked_display() {
1000 let err = ToolError::Blocked {
1001 command: "rm -rf /".to_owned(),
1002 };
1003 assert_eq!(err.to_string(), "command blocked by policy: rm -rf /");
1004 }
1005
1006 #[test]
1007 fn tool_error_sandbox_violation_display() {
1008 let err = ToolError::SandboxViolation {
1009 path: "/etc/shadow".to_owned(),
1010 };
1011 assert_eq!(err.to_string(), "path not allowed by sandbox: /etc/shadow");
1012 }
1013
1014 #[test]
1015 fn tool_error_confirmation_required_display() {
1016 let err = ToolError::ConfirmationRequired {
1017 command: "rm -rf /tmp".to_owned(),
1018 };
1019 assert_eq!(
1020 err.to_string(),
1021 "command requires confirmation: rm -rf /tmp"
1022 );
1023 }
1024
1025 #[test]
1026 fn tool_error_timeout_display() {
1027 let err = ToolError::Timeout { timeout_secs: 30 };
1028 assert_eq!(err.to_string(), "command timed out after 30s");
1029 }
1030
1031 #[test]
1032 fn tool_error_invalid_params_display() {
1033 let err = ToolError::InvalidParams {
1034 message: "missing field `command`".to_owned(),
1035 };
1036 assert_eq!(
1037 err.to_string(),
1038 "invalid tool parameters: missing field `command`"
1039 );
1040 }
1041
1042 #[test]
1043 fn deserialize_params_valid() {
1044 #[derive(Debug, serde::Deserialize, PartialEq)]
1045 struct P {
1046 name: String,
1047 count: u32,
1048 }
1049 let mut map = serde_json::Map::new();
1050 map.insert("name".to_owned(), serde_json::json!("test"));
1051 map.insert("count".to_owned(), serde_json::json!(42));
1052 let p: P = deserialize_params(&map).unwrap();
1053 assert_eq!(
1054 p,
1055 P {
1056 name: "test".to_owned(),
1057 count: 42
1058 }
1059 );
1060 }
1061
1062 #[test]
1063 fn deserialize_params_missing_required_field() {
1064 #[derive(Debug, serde::Deserialize)]
1065 #[allow(dead_code)]
1066 struct P {
1067 name: String,
1068 }
1069 let map = serde_json::Map::new();
1070 let err = deserialize_params::<P>(&map).unwrap_err();
1071 assert!(matches!(err, ToolError::InvalidParams { .. }));
1072 }
1073
1074 #[test]
1075 fn deserialize_params_wrong_type() {
1076 #[derive(Debug, serde::Deserialize)]
1077 #[allow(dead_code)]
1078 struct P {
1079 count: u32,
1080 }
1081 let mut map = serde_json::Map::new();
1082 map.insert("count".to_owned(), serde_json::json!("not a number"));
1083 let err = deserialize_params::<P>(&map).unwrap_err();
1084 assert!(matches!(err, ToolError::InvalidParams { .. }));
1085 }
1086
1087 #[test]
1088 fn deserialize_params_all_optional_empty() {
1089 #[derive(Debug, serde::Deserialize, PartialEq)]
1090 struct P {
1091 name: Option<String>,
1092 }
1093 let map = serde_json::Map::new();
1094 let p: P = deserialize_params(&map).unwrap();
1095 assert_eq!(p, P { name: None });
1096 }
1097
1098 #[test]
1099 fn deserialize_params_ignores_extra_fields() {
1100 #[derive(Debug, serde::Deserialize, PartialEq)]
1101 struct P {
1102 name: String,
1103 }
1104 let mut map = serde_json::Map::new();
1105 map.insert("name".to_owned(), serde_json::json!("test"));
1106 map.insert("extra".to_owned(), serde_json::json!(true));
1107 let p: P = deserialize_params(&map).unwrap();
1108 assert_eq!(
1109 p,
1110 P {
1111 name: "test".to_owned()
1112 }
1113 );
1114 }
1115
1116 #[test]
1117 fn tool_error_execution_display() {
1118 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash not found");
1119 let err = ToolError::Execution(io_err);
1120 assert!(err.to_string().starts_with("execution failed:"));
1121 assert!(err.to_string().contains("bash not found"));
1122 }
1123
1124 #[test]
1126 fn error_kind_timeout_is_transient() {
1127 let err = ToolError::Timeout { timeout_secs: 30 };
1128 assert_eq!(err.kind(), ErrorKind::Transient);
1129 }
1130
1131 #[test]
1132 fn error_kind_blocked_is_permanent() {
1133 let err = ToolError::Blocked {
1134 command: "rm -rf /".to_owned(),
1135 };
1136 assert_eq!(err.kind(), ErrorKind::Permanent);
1137 }
1138
1139 #[test]
1140 fn error_kind_sandbox_violation_is_permanent() {
1141 let err = ToolError::SandboxViolation {
1142 path: "/etc/shadow".to_owned(),
1143 };
1144 assert_eq!(err.kind(), ErrorKind::Permanent);
1145 }
1146
1147 #[test]
1148 fn error_kind_cancelled_is_permanent() {
1149 assert_eq!(ToolError::Cancelled.kind(), ErrorKind::Permanent);
1150 }
1151
1152 #[test]
1153 fn error_kind_invalid_params_is_permanent() {
1154 let err = ToolError::InvalidParams {
1155 message: "bad arg".to_owned(),
1156 };
1157 assert_eq!(err.kind(), ErrorKind::Permanent);
1158 }
1159
1160 #[test]
1161 fn error_kind_confirmation_required_is_permanent() {
1162 let err = ToolError::ConfirmationRequired {
1163 command: "rm /tmp/x".to_owned(),
1164 };
1165 assert_eq!(err.kind(), ErrorKind::Permanent);
1166 }
1167
1168 #[test]
1169 fn error_kind_execution_timed_out_is_transient() {
1170 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1171 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1172 }
1173
1174 #[test]
1175 fn error_kind_execution_interrupted_is_transient() {
1176 let io_err = std::io::Error::new(std::io::ErrorKind::Interrupted, "interrupted");
1177 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1178 }
1179
1180 #[test]
1181 fn error_kind_execution_connection_reset_is_transient() {
1182 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
1183 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1184 }
1185
1186 #[test]
1187 fn error_kind_execution_broken_pipe_is_transient() {
1188 let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
1189 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1190 }
1191
1192 #[test]
1193 fn error_kind_execution_would_block_is_transient() {
1194 let io_err = std::io::Error::new(std::io::ErrorKind::WouldBlock, "would block");
1195 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1196 }
1197
1198 #[test]
1199 fn error_kind_execution_connection_aborted_is_transient() {
1200 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionAborted, "aborted");
1201 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
1202 }
1203
1204 #[test]
1205 fn error_kind_execution_not_found_is_permanent() {
1206 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
1207 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1208 }
1209
1210 #[test]
1211 fn error_kind_execution_permission_denied_is_permanent() {
1212 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
1213 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1214 }
1215
1216 #[test]
1217 fn error_kind_execution_other_is_permanent() {
1218 let io_err = std::io::Error::other("some other error");
1219 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1220 }
1221
1222 #[test]
1223 fn error_kind_execution_already_exists_is_permanent() {
1224 let io_err = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists");
1225 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
1226 }
1227
1228 #[test]
1229 fn error_kind_display() {
1230 assert_eq!(ErrorKind::Transient.to_string(), "transient");
1231 assert_eq!(ErrorKind::Permanent.to_string(), "permanent");
1232 }
1233
1234 #[test]
1235 fn truncate_tool_output_short_passthrough() {
1236 let short = "hello world";
1237 assert_eq!(truncate_tool_output(short), short);
1238 }
1239
1240 #[test]
1241 fn truncate_tool_output_exact_limit() {
1242 let exact = "a".repeat(MAX_TOOL_OUTPUT_CHARS);
1243 assert_eq!(truncate_tool_output(&exact), exact);
1244 }
1245
1246 #[test]
1247 fn truncate_tool_output_long_split() {
1248 let long = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
1249 let result = truncate_tool_output(&long);
1250 assert!(result.contains("truncated"));
1251 assert!(result.len() < long.len());
1252 }
1253
1254 #[test]
1255 fn truncate_tool_output_notice_contains_count() {
1256 let long = "y".repeat(MAX_TOOL_OUTPUT_CHARS + 2000);
1257 let result = truncate_tool_output(&long);
1258 assert!(result.contains("truncated"));
1259 assert!(result.contains("chars"));
1260 }
1261
1262 #[derive(Debug)]
1263 struct DefaultExecutor;
1264 impl ToolExecutor for DefaultExecutor {
1265 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1266 Ok(None)
1267 }
1268 }
1269
1270 #[tokio::test]
1271 async fn execute_tool_call_default_returns_none() {
1272 let exec = DefaultExecutor;
1273 let call = ToolCall {
1274 tool_id: ToolName::new("anything"),
1275 params: serde_json::Map::new(),
1276 caller_id: None,
1277 context: None,
1278
1279 tool_call_id: String::new(),
1280 skill_name: None,
1281 };
1282 let result = exec.execute_tool_call(&call).await.unwrap();
1283 assert!(result.is_none());
1284 }
1285
1286 #[test]
1287 fn filter_stats_savings_pct() {
1288 let fs = FilterStats {
1289 raw_chars: 1000,
1290 filtered_chars: 200,
1291 ..Default::default()
1292 };
1293 assert!((fs.savings_pct() - 80.0).abs() < 0.01);
1294 }
1295
1296 #[test]
1297 fn filter_stats_savings_pct_zero() {
1298 let fs = FilterStats::default();
1299 assert!((fs.savings_pct()).abs() < 0.01);
1300 }
1301
1302 #[test]
1303 fn filter_stats_estimated_tokens_saved() {
1304 let fs = FilterStats {
1305 raw_chars: 1000,
1306 filtered_chars: 200,
1307 ..Default::default()
1308 };
1309 assert_eq!(fs.estimated_tokens_saved(), 200); }
1311
1312 #[test]
1313 fn filter_stats_format_inline() {
1314 let fs = FilterStats {
1315 raw_chars: 1000,
1316 filtered_chars: 200,
1317 raw_lines: 342,
1318 filtered_lines: 28,
1319 ..Default::default()
1320 };
1321 let line = fs.format_inline("shell");
1322 assert_eq!(line, "[shell] 342 lines \u{2192} 28 lines, 80.0% filtered");
1323 }
1324
1325 #[test]
1326 fn filter_stats_format_inline_zero() {
1327 let fs = FilterStats::default();
1328 let line = fs.format_inline("bash");
1329 assert_eq!(line, "[bash] 0 lines \u{2192} 0 lines, 0.0% filtered");
1330 }
1331
1332 struct FixedExecutor {
1335 tool_id: &'static str,
1336 output: &'static str,
1337 }
1338
1339 impl ToolExecutor for FixedExecutor {
1340 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
1341 Ok(Some(ToolOutput {
1342 tool_name: ToolName::new(self.tool_id),
1343 summary: self.output.to_owned(),
1344 blocks_executed: 1,
1345 filter_stats: None,
1346 diff: None,
1347 streamed: false,
1348 terminal_id: None,
1349 locations: None,
1350 raw_response: None,
1351 claim_source: None,
1352 }))
1353 }
1354
1355 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
1356 vec![]
1357 }
1358
1359 async fn execute_tool_call(
1360 &self,
1361 _call: &ToolCall,
1362 ) -> Result<Option<ToolOutput>, ToolError> {
1363 Ok(Some(ToolOutput {
1364 tool_name: ToolName::new(self.tool_id),
1365 summary: self.output.to_owned(),
1366 blocks_executed: 1,
1367 filter_stats: None,
1368 diff: None,
1369 streamed: false,
1370 terminal_id: None,
1371 locations: None,
1372 raw_response: None,
1373 claim_source: None,
1374 }))
1375 }
1376 }
1377
1378 #[tokio::test]
1379 async fn dyn_executor_execute_delegates() {
1380 let inner = std::sync::Arc::new(FixedExecutor {
1381 tool_id: "bash",
1382 output: "hello",
1383 });
1384 let exec = DynExecutor(inner);
1385 let result = exec.execute("```bash\necho hello\n```").await.unwrap();
1386 assert!(result.is_some());
1387 assert_eq!(result.unwrap().summary, "hello");
1388 }
1389
1390 #[tokio::test]
1391 async fn dyn_executor_execute_confirmed_delegates() {
1392 let inner = std::sync::Arc::new(FixedExecutor {
1393 tool_id: "bash",
1394 output: "confirmed",
1395 });
1396 let exec = DynExecutor(inner);
1397 let result = exec.execute_confirmed("...").await.unwrap();
1398 assert!(result.is_some());
1399 assert_eq!(result.unwrap().summary, "confirmed");
1400 }
1401
1402 #[test]
1403 fn dyn_executor_tool_definitions_delegates() {
1404 let inner = std::sync::Arc::new(FixedExecutor {
1405 tool_id: "my_tool",
1406 output: "",
1407 });
1408 let exec = DynExecutor(inner);
1409 let defs = exec.tool_definitions();
1411 assert!(defs.is_empty());
1412 }
1413
1414 #[tokio::test]
1415 async fn dyn_executor_execute_tool_call_delegates() {
1416 let inner = std::sync::Arc::new(FixedExecutor {
1417 tool_id: "bash",
1418 output: "tool_call_result",
1419 });
1420 let exec = DynExecutor(inner);
1421 let call = ToolCall {
1422 tool_id: ToolName::new("bash"),
1423 params: serde_json::Map::new(),
1424 caller_id: None,
1425 context: None,
1426
1427 tool_call_id: String::new(),
1428 skill_name: None,
1429 };
1430 let result = exec.execute_tool_call(&call).await.unwrap();
1431 assert!(result.is_some());
1432 assert_eq!(result.unwrap().summary, "tool_call_result");
1433 }
1434
1435 #[test]
1436 fn dyn_executor_set_effective_trust_delegates() {
1437 use std::sync::atomic::{AtomicU8, Ordering};
1438
1439 struct TrustCapture(AtomicU8);
1440 impl ToolExecutor for TrustCapture {
1441 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1442 Ok(None)
1443 }
1444 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
1445 let v = match level {
1447 crate::SkillTrustLevel::Trusted => 0u8,
1448 crate::SkillTrustLevel::Verified => 1,
1449 crate::SkillTrustLevel::Quarantined => 2,
1450 _ => 3,
1451 };
1452 self.0.store(v, Ordering::Relaxed);
1453 }
1454 }
1455
1456 let inner = std::sync::Arc::new(TrustCapture(AtomicU8::new(0)));
1457 let exec =
1458 DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
1459 ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Quarantined);
1460 assert_eq!(inner.0.load(Ordering::Relaxed), 2);
1461
1462 ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Blocked);
1463 assert_eq!(inner.0.load(Ordering::Relaxed), 3);
1464 }
1465
1466 #[test]
1467 fn extract_fenced_blocks_no_prefix_match() {
1468 assert!(extract_fenced_blocks("```bashrc\nfoo\n```", "bash").is_empty());
1470 assert_eq!(
1472 extract_fenced_blocks("```bash\nfoo\n```", "bash"),
1473 vec!["foo"]
1474 );
1475 assert_eq!(
1477 extract_fenced_blocks("```bash \nfoo\n```", "bash"),
1478 vec!["foo"]
1479 );
1480 }
1481
1482 #[test]
1485 fn tool_error_http_400_category_is_invalid_parameters() {
1486 use crate::error_taxonomy::ToolErrorCategory;
1487 let err = ToolError::Http {
1488 status: 400,
1489 message: "bad request".to_owned(),
1490 };
1491 assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1492 }
1493
1494 #[test]
1495 fn tool_error_http_401_category_is_policy_blocked() {
1496 use crate::error_taxonomy::ToolErrorCategory;
1497 let err = ToolError::Http {
1498 status: 401,
1499 message: "unauthorized".to_owned(),
1500 };
1501 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1502 }
1503
1504 #[test]
1505 fn tool_error_http_403_category_is_policy_blocked() {
1506 use crate::error_taxonomy::ToolErrorCategory;
1507 let err = ToolError::Http {
1508 status: 403,
1509 message: "forbidden".to_owned(),
1510 };
1511 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1512 }
1513
1514 #[test]
1515 fn tool_error_http_404_category_is_permanent_failure() {
1516 use crate::error_taxonomy::ToolErrorCategory;
1517 let err = ToolError::Http {
1518 status: 404,
1519 message: "not found".to_owned(),
1520 };
1521 assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1522 }
1523
1524 #[test]
1525 fn tool_error_http_429_category_is_rate_limited() {
1526 use crate::error_taxonomy::ToolErrorCategory;
1527 let err = ToolError::Http {
1528 status: 429,
1529 message: "too many requests".to_owned(),
1530 };
1531 assert_eq!(err.category(), ToolErrorCategory::RateLimited);
1532 }
1533
1534 #[test]
1535 fn tool_error_http_500_category_is_server_error() {
1536 use crate::error_taxonomy::ToolErrorCategory;
1537 let err = ToolError::Http {
1538 status: 500,
1539 message: "internal server error".to_owned(),
1540 };
1541 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1542 }
1543
1544 #[test]
1545 fn tool_error_http_502_category_is_server_error() {
1546 use crate::error_taxonomy::ToolErrorCategory;
1547 let err = ToolError::Http {
1548 status: 502,
1549 message: "bad gateway".to_owned(),
1550 };
1551 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1552 }
1553
1554 #[test]
1555 fn tool_error_http_503_category_is_server_error() {
1556 use crate::error_taxonomy::ToolErrorCategory;
1557 let err = ToolError::Http {
1558 status: 503,
1559 message: "service unavailable".to_owned(),
1560 };
1561 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1562 }
1563
1564 #[test]
1565 fn tool_error_http_503_is_transient_triggers_phase2_retry() {
1566 let err = ToolError::Http {
1569 status: 503,
1570 message: "service unavailable".to_owned(),
1571 };
1572 assert_eq!(
1573 err.kind(),
1574 ErrorKind::Transient,
1575 "HTTP 503 must be Transient so Phase 2 retry fires"
1576 );
1577 }
1578
1579 #[test]
1580 fn tool_error_blocked_category_is_policy_blocked() {
1581 use crate::error_taxonomy::ToolErrorCategory;
1582 let err = ToolError::Blocked {
1583 command: "rm -rf /".to_owned(),
1584 };
1585 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1586 }
1587
1588 #[test]
1589 fn tool_error_sandbox_violation_category_is_policy_blocked() {
1590 use crate::error_taxonomy::ToolErrorCategory;
1591 let err = ToolError::SandboxViolation {
1592 path: "/etc/shadow".to_owned(),
1593 };
1594 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1595 }
1596
1597 #[test]
1598 fn tool_error_confirmation_required_category() {
1599 use crate::error_taxonomy::ToolErrorCategory;
1600 let err = ToolError::ConfirmationRequired {
1601 command: "rm /tmp/x".to_owned(),
1602 };
1603 assert_eq!(err.category(), ToolErrorCategory::ConfirmationRequired);
1604 }
1605
1606 #[test]
1607 fn tool_error_timeout_category() {
1608 use crate::error_taxonomy::ToolErrorCategory;
1609 let err = ToolError::Timeout { timeout_secs: 30 };
1610 assert_eq!(err.category(), ToolErrorCategory::Timeout);
1611 }
1612
1613 #[test]
1614 fn tool_error_cancelled_category() {
1615 use crate::error_taxonomy::ToolErrorCategory;
1616 assert_eq!(
1617 ToolError::Cancelled.category(),
1618 ToolErrorCategory::Cancelled
1619 );
1620 }
1621
1622 #[test]
1623 fn tool_error_invalid_params_category() {
1624 use crate::error_taxonomy::ToolErrorCategory;
1625 let err = ToolError::InvalidParams {
1626 message: "missing field".to_owned(),
1627 };
1628 assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1629 }
1630
1631 #[test]
1633 fn tool_error_execution_not_found_category_is_permanent_failure() {
1634 use crate::error_taxonomy::ToolErrorCategory;
1635 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash: not found");
1636 let err = ToolError::Execution(io_err);
1637 let cat = err.category();
1638 assert_ne!(
1639 cat,
1640 ToolErrorCategory::ToolNotFound,
1641 "Execution(NotFound) must NOT map to ToolNotFound"
1642 );
1643 assert_eq!(cat, ToolErrorCategory::PermanentFailure);
1644 }
1645
1646 #[test]
1647 fn tool_error_execution_timed_out_category_is_timeout() {
1648 use crate::error_taxonomy::ToolErrorCategory;
1649 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
1650 assert_eq!(
1651 ToolError::Execution(io_err).category(),
1652 ToolErrorCategory::Timeout
1653 );
1654 }
1655
1656 #[test]
1657 fn tool_error_execution_connection_refused_category_is_network_error() {
1658 use crate::error_taxonomy::ToolErrorCategory;
1659 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1660 assert_eq!(
1661 ToolError::Execution(io_err).category(),
1662 ToolErrorCategory::NetworkError
1663 );
1664 }
1665
1666 #[test]
1668 fn b4_tool_error_http_429_not_quality_failure() {
1669 let err = ToolError::Http {
1670 status: 429,
1671 message: "rate limited".to_owned(),
1672 };
1673 assert!(
1674 !err.category().is_quality_failure(),
1675 "RateLimited must not be a quality failure"
1676 );
1677 }
1678
1679 #[test]
1680 fn b4_tool_error_http_503_not_quality_failure() {
1681 let err = ToolError::Http {
1682 status: 503,
1683 message: "service unavailable".to_owned(),
1684 };
1685 assert!(
1686 !err.category().is_quality_failure(),
1687 "ServerError must not be a quality failure"
1688 );
1689 }
1690
1691 #[test]
1692 fn b4_tool_error_execution_timed_out_not_quality_failure() {
1693 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1694 assert!(
1695 !ToolError::Execution(io_err).category().is_quality_failure(),
1696 "Timeout must not be a quality failure"
1697 );
1698 }
1699
1700 #[test]
1703 fn tool_error_shell_exit126_is_policy_blocked() {
1704 use crate::error_taxonomy::ToolErrorCategory;
1705 let err = ToolError::Shell {
1706 exit_code: 126,
1707 category: ToolErrorCategory::PolicyBlocked,
1708 message: "permission denied".to_owned(),
1709 };
1710 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1711 }
1712
1713 #[test]
1714 fn tool_error_shell_exit127_is_permanent_failure() {
1715 use crate::error_taxonomy::ToolErrorCategory;
1716 let err = ToolError::Shell {
1717 exit_code: 127,
1718 category: ToolErrorCategory::PermanentFailure,
1719 message: "command not found".to_owned(),
1720 };
1721 assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1722 assert!(!err.category().is_retryable());
1723 }
1724
1725 #[test]
1726 fn tool_error_shell_not_quality_failure() {
1727 use crate::error_taxonomy::ToolErrorCategory;
1728 let err = ToolError::Shell {
1729 exit_code: 127,
1730 category: ToolErrorCategory::PermanentFailure,
1731 message: "command not found".to_owned(),
1732 };
1733 assert!(!err.category().is_quality_failure());
1735 }
1736
1737 struct StubExecutor;
1741 impl ToolExecutor for StubExecutor {
1742 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1743 Ok(None)
1744 }
1745 }
1746
1747 struct ConfirmingExecutor;
1749 impl ToolExecutor for ConfirmingExecutor {
1750 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1751 Ok(None)
1752 }
1753 fn requires_confirmation(&self, _call: &ToolCall) -> bool {
1754 true
1755 }
1756 }
1757
1758 fn dummy_call() -> ToolCall {
1759 ToolCall {
1760 tool_id: ToolName::new("test"),
1761 params: serde_json::Map::new(),
1762 caller_id: None,
1763 context: None,
1764
1765 tool_call_id: String::new(),
1766 skill_name: None,
1767 }
1768 }
1769
1770 #[test]
1771 fn requires_confirmation_default_is_false_on_tool_executor() {
1772 let exec = StubExecutor;
1773 assert!(
1774 !exec.requires_confirmation(&dummy_call()),
1775 "ToolExecutor default requires_confirmation must be false"
1776 );
1777 }
1778
1779 #[test]
1780 fn requires_confirmation_erased_delegates_to_tool_executor_default() {
1781 let exec = StubExecutor;
1783 assert!(
1784 !ErasedToolExecutor::requires_confirmation_erased(&exec, &dummy_call()),
1785 "requires_confirmation_erased via blanket impl must return false for stub executor"
1786 );
1787 }
1788
1789 #[test]
1790 fn requires_confirmation_erased_delegates_override() {
1791 let exec = ConfirmingExecutor;
1794 assert!(
1795 ErasedToolExecutor::requires_confirmation_erased(&exec, &dummy_call()),
1796 "requires_confirmation_erased must return true when ToolExecutor override returns true"
1797 );
1798 }
1799
1800 #[test]
1801 fn requires_confirmation_erased_default_on_erased_trait_is_true() {
1802 struct ManualErased;
1807 impl ErasedToolExecutor for ManualErased {
1808 fn execute_erased<'a>(
1809 &'a self,
1810 _response: &'a str,
1811 ) -> std::pin::Pin<
1812 Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>,
1813 > {
1814 Box::pin(std::future::ready(Ok(None)))
1815 }
1816 fn execute_confirmed_erased<'a>(
1817 &'a self,
1818 _response: &'a str,
1819 ) -> std::pin::Pin<
1820 Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>,
1821 > {
1822 Box::pin(std::future::ready(Ok(None)))
1823 }
1824 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
1825 vec![]
1826 }
1827 fn execute_tool_call_erased<'a>(
1828 &'a self,
1829 _call: &'a ToolCall,
1830 ) -> std::pin::Pin<
1831 Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>,
1832 > {
1833 Box::pin(std::future::ready(Ok(None)))
1834 }
1835 fn is_tool_retryable_erased(&self, _tool_id: &str) -> bool {
1836 false
1837 }
1838 }
1840 let exec = ManualErased;
1841 assert!(
1842 exec.requires_confirmation_erased(&dummy_call()),
1843 "ErasedToolExecutor trait-level default for requires_confirmation_erased must be true"
1844 );
1845 }
1846
1847 #[test]
1850 fn dyn_executor_requires_confirmation_delegates() {
1851 let inner = std::sync::Arc::new(ConfirmingExecutor);
1852 let exec =
1853 DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
1854 assert!(
1855 ToolExecutor::requires_confirmation(&exec, &dummy_call()),
1856 "DynExecutor must delegate requires_confirmation to inner executor"
1857 );
1858 }
1859
1860 #[test]
1861 fn dyn_executor_requires_confirmation_default_false() {
1862 let inner = std::sync::Arc::new(StubExecutor);
1863 let exec =
1864 DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
1865 assert!(
1866 !ToolExecutor::requires_confirmation(&exec, &dummy_call()),
1867 "DynExecutor must return false when inner executor does not require confirmation"
1868 );
1869 }
1870}