1use std::fmt;
5
6#[derive(Debug, Clone)]
8pub struct DiffData {
9 pub file_path: String,
10 pub old_content: String,
11 pub new_content: String,
12}
13
14#[derive(Debug, Clone)]
16pub struct ToolCall {
17 pub tool_id: String,
18 pub params: serde_json::Map<String, serde_json::Value>,
19 pub caller_id: Option<String>,
22}
23
24#[derive(Debug, Clone, Default)]
26pub struct FilterStats {
27 pub raw_chars: usize,
28 pub filtered_chars: usize,
29 pub raw_lines: usize,
30 pub filtered_lines: usize,
31 pub confidence: Option<crate::FilterConfidence>,
32 pub command: Option<String>,
33 pub kept_lines: Vec<usize>,
34}
35
36impl FilterStats {
37 #[must_use]
38 #[allow(clippy::cast_precision_loss)]
39 pub fn savings_pct(&self) -> f64 {
40 if self.raw_chars == 0 {
41 return 0.0;
42 }
43 (1.0 - self.filtered_chars as f64 / self.raw_chars as f64) * 100.0
44 }
45
46 #[must_use]
47 pub fn estimated_tokens_saved(&self) -> usize {
48 self.raw_chars.saturating_sub(self.filtered_chars) / 4
49 }
50
51 #[must_use]
52 pub fn format_inline(&self, tool_name: &str) -> String {
53 let cmd_label = self
54 .command
55 .as_deref()
56 .map(|c| {
57 let trimmed = c.trim();
58 if trimmed.len() > 60 {
59 format!(" `{}…`", &trimmed[..57])
60 } else {
61 format!(" `{trimmed}`")
62 }
63 })
64 .unwrap_or_default();
65 format!(
66 "[{tool_name}]{cmd_label} {} lines \u{2192} {} lines, {:.1}% filtered",
67 self.raw_lines,
68 self.filtered_lines,
69 self.savings_pct()
70 )
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
80#[serde(rename_all = "snake_case")]
81pub enum ClaimSource {
82 Shell,
84 FileSystem,
86 WebScrape,
88 Mcp,
90 A2a,
92 CodeSearch,
94 Diagnostics,
96 Memory,
98}
99
100#[derive(Debug, Clone)]
102pub struct ToolOutput {
103 pub tool_name: String,
104 pub summary: String,
105 pub blocks_executed: u32,
106 pub filter_stats: Option<FilterStats>,
107 pub diff: Option<DiffData>,
108 pub streamed: bool,
110 pub terminal_id: Option<String>,
112 pub locations: Option<Vec<String>>,
114 pub raw_response: Option<serde_json::Value>,
116 pub claim_source: Option<ClaimSource>,
119}
120
121impl fmt::Display for ToolOutput {
122 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123 f.write_str(&self.summary)
124 }
125}
126
127pub const MAX_TOOL_OUTPUT_CHARS: usize = 30_000;
128
129#[must_use]
131pub fn truncate_tool_output(output: &str) -> String {
132 truncate_tool_output_at(output, MAX_TOOL_OUTPUT_CHARS)
133}
134
135#[must_use]
137pub fn truncate_tool_output_at(output: &str, max_chars: usize) -> String {
138 if output.len() <= max_chars {
139 return output.to_string();
140 }
141
142 let half = max_chars / 2;
143 let head_end = output.floor_char_boundary(half);
144 let tail_start = output.ceil_char_boundary(output.len() - half);
145 let head = &output[..head_end];
146 let tail = &output[tail_start..];
147 let truncated = output.len() - head_end - (output.len() - tail_start);
148
149 format!(
150 "{head}\n\n... [truncated {truncated} chars, showing first and last ~{half} chars] ...\n\n{tail}"
151 )
152}
153
154#[derive(Debug, Clone)]
156pub enum ToolEvent {
157 Started {
158 tool_name: String,
159 command: String,
160 },
161 OutputChunk {
162 tool_name: String,
163 command: String,
164 chunk: String,
165 },
166 Completed {
167 tool_name: String,
168 command: String,
169 output: String,
170 success: bool,
171 filter_stats: Option<FilterStats>,
172 diff: Option<DiffData>,
173 },
174 Rollback {
175 tool_name: String,
176 command: String,
177 restored_count: usize,
178 deleted_count: usize,
179 },
180}
181
182pub type ToolEventTx = tokio::sync::mpsc::UnboundedSender<ToolEvent>;
183
184#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
189pub enum ErrorKind {
190 Transient,
191 Permanent,
192}
193
194impl std::fmt::Display for ErrorKind {
195 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196 match self {
197 Self::Transient => f.write_str("transient"),
198 Self::Permanent => f.write_str("permanent"),
199 }
200 }
201}
202
203#[derive(Debug, thiserror::Error)]
205pub enum ToolError {
206 #[error("command blocked by policy: {command}")]
207 Blocked { command: String },
208
209 #[error("path not allowed by sandbox: {path}")]
210 SandboxViolation { path: String },
211
212 #[error("command requires confirmation: {command}")]
213 ConfirmationRequired { command: String },
214
215 #[error("command timed out after {timeout_secs}s")]
216 Timeout { timeout_secs: u64 },
217
218 #[error("operation cancelled")]
219 Cancelled,
220
221 #[error("invalid tool parameters: {message}")]
222 InvalidParams { message: String },
223
224 #[error("execution failed: {0}")]
225 Execution(#[from] std::io::Error),
226
227 #[error("HTTP error {status}: {message}")]
232 Http { status: u16, message: String },
233
234 #[error("shell error (exit {exit_code}): {message}")]
240 Shell {
241 exit_code: i32,
242 category: crate::error_taxonomy::ToolErrorCategory,
243 message: String,
244 },
245
246 #[error("snapshot failed: {reason}")]
247 SnapshotFailed { reason: String },
248}
249
250impl ToolError {
251 #[must_use]
256 pub fn category(&self) -> crate::error_taxonomy::ToolErrorCategory {
257 use crate::error_taxonomy::{ToolErrorCategory, classify_http_status, classify_io_error};
258 match self {
259 Self::Blocked { .. } | Self::SandboxViolation { .. } => {
260 ToolErrorCategory::PolicyBlocked
261 }
262 Self::ConfirmationRequired { .. } => ToolErrorCategory::ConfirmationRequired,
263 Self::Timeout { .. } => ToolErrorCategory::Timeout,
264 Self::Cancelled => ToolErrorCategory::Cancelled,
265 Self::InvalidParams { .. } => ToolErrorCategory::InvalidParameters,
266 Self::Http { status, .. } => classify_http_status(*status),
267 Self::Execution(io_err) => classify_io_error(io_err),
268 Self::Shell { category, .. } => *category,
269 Self::SnapshotFailed { .. } => ToolErrorCategory::PermanentFailure,
270 }
271 }
272
273 #[must_use]
281 pub fn kind(&self) -> ErrorKind {
282 self.category().error_kind()
283 }
284}
285
286pub fn deserialize_params<T: serde::de::DeserializeOwned>(
292 params: &serde_json::Map<String, serde_json::Value>,
293) -> Result<T, ToolError> {
294 let obj = serde_json::Value::Object(params.clone());
295 serde_json::from_value(obj).map_err(|e| ToolError::InvalidParams {
296 message: e.to_string(),
297 })
298}
299
300pub trait ToolExecutor: Send + Sync {
305 fn execute(
306 &self,
307 response: &str,
308 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send;
309
310 fn execute_confirmed(
313 &self,
314 response: &str,
315 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
316 self.execute(response)
317 }
318
319 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
321 vec![]
322 }
323
324 fn execute_tool_call(
326 &self,
327 _call: &ToolCall,
328 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
329 std::future::ready(Ok(None))
330 }
331
332 fn execute_tool_call_confirmed(
337 &self,
338 call: &ToolCall,
339 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
340 self.execute_tool_call(call)
341 }
342
343 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
345
346 fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
348
349 fn is_tool_retryable(&self, _tool_id: &str) -> bool {
355 false
356 }
357}
358
359pub trait ErasedToolExecutor: Send + Sync {
364 fn execute_erased<'a>(
365 &'a self,
366 response: &'a str,
367 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
368
369 fn execute_confirmed_erased<'a>(
370 &'a self,
371 response: &'a str,
372 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
373
374 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef>;
375
376 fn execute_tool_call_erased<'a>(
377 &'a self,
378 call: &'a ToolCall,
379 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
380
381 fn execute_tool_call_confirmed_erased<'a>(
382 &'a self,
383 call: &'a ToolCall,
384 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
385 {
386 self.execute_tool_call_erased(call)
390 }
391
392 fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
394
395 fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
397
398 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool;
400}
401
402impl<T: ToolExecutor> ErasedToolExecutor for T {
403 fn execute_erased<'a>(
404 &'a self,
405 response: &'a str,
406 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
407 {
408 Box::pin(self.execute(response))
409 }
410
411 fn execute_confirmed_erased<'a>(
412 &'a self,
413 response: &'a str,
414 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
415 {
416 Box::pin(self.execute_confirmed(response))
417 }
418
419 fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
420 self.tool_definitions()
421 }
422
423 fn execute_tool_call_erased<'a>(
424 &'a self,
425 call: &'a ToolCall,
426 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
427 {
428 Box::pin(self.execute_tool_call(call))
429 }
430
431 fn execute_tool_call_confirmed_erased<'a>(
432 &'a self,
433 call: &'a ToolCall,
434 ) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
435 {
436 Box::pin(self.execute_tool_call_confirmed(call))
437 }
438
439 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
440 ToolExecutor::set_skill_env(self, env);
441 }
442
443 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
444 ToolExecutor::set_effective_trust(self, level);
445 }
446
447 fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
448 ToolExecutor::is_tool_retryable(self, tool_id)
449 }
450}
451
452pub struct DynExecutor(pub std::sync::Arc<dyn ErasedToolExecutor>);
456
457impl ToolExecutor for DynExecutor {
458 fn execute(
459 &self,
460 response: &str,
461 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
462 let inner = std::sync::Arc::clone(&self.0);
464 let response = response.to_owned();
465 async move { inner.execute_erased(&response).await }
466 }
467
468 fn execute_confirmed(
469 &self,
470 response: &str,
471 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
472 let inner = std::sync::Arc::clone(&self.0);
473 let response = response.to_owned();
474 async move { inner.execute_confirmed_erased(&response).await }
475 }
476
477 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
478 self.0.tool_definitions_erased()
479 }
480
481 fn execute_tool_call(
482 &self,
483 call: &ToolCall,
484 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
485 let inner = std::sync::Arc::clone(&self.0);
486 let call = call.clone();
487 async move { inner.execute_tool_call_erased(&call).await }
488 }
489
490 fn execute_tool_call_confirmed(
491 &self,
492 call: &ToolCall,
493 ) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
494 let inner = std::sync::Arc::clone(&self.0);
495 let call = call.clone();
496 async move { inner.execute_tool_call_confirmed_erased(&call).await }
497 }
498
499 fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
500 ErasedToolExecutor::set_skill_env(self.0.as_ref(), env);
501 }
502
503 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
504 ErasedToolExecutor::set_effective_trust(self.0.as_ref(), level);
505 }
506
507 fn is_tool_retryable(&self, tool_id: &str) -> bool {
508 self.0.is_tool_retryable_erased(tool_id)
509 }
510}
511
512#[must_use]
516pub fn extract_fenced_blocks<'a>(text: &'a str, lang: &str) -> Vec<&'a str> {
517 let marker = format!("```{lang}");
518 let marker_len = marker.len();
519 let mut blocks = Vec::new();
520 let mut rest = text;
521
522 let mut search_from = 0;
523 while let Some(rel) = rest[search_from..].find(&marker) {
524 let start = search_from + rel;
525 let after = &rest[start + marker_len..];
526 let boundary_ok = after
530 .chars()
531 .next()
532 .is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '-');
533 if !boundary_ok {
534 search_from = start + marker_len;
535 continue;
536 }
537 if let Some(end) = after.find("```") {
538 blocks.push(after[..end].trim());
539 rest = &after[end + 3..];
540 search_from = 0;
541 } else {
542 break;
543 }
544 }
545
546 blocks
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552
553 #[test]
554 fn tool_output_display() {
555 let output = ToolOutput {
556 tool_name: "bash".to_owned(),
557 summary: "$ echo hello\nhello".to_owned(),
558 blocks_executed: 1,
559 filter_stats: None,
560 diff: None,
561 streamed: false,
562 terminal_id: None,
563 locations: None,
564 raw_response: None,
565 claim_source: None,
566 };
567 assert_eq!(output.to_string(), "$ echo hello\nhello");
568 }
569
570 #[test]
571 fn tool_error_blocked_display() {
572 let err = ToolError::Blocked {
573 command: "rm -rf /".to_owned(),
574 };
575 assert_eq!(err.to_string(), "command blocked by policy: rm -rf /");
576 }
577
578 #[test]
579 fn tool_error_sandbox_violation_display() {
580 let err = ToolError::SandboxViolation {
581 path: "/etc/shadow".to_owned(),
582 };
583 assert_eq!(err.to_string(), "path not allowed by sandbox: /etc/shadow");
584 }
585
586 #[test]
587 fn tool_error_confirmation_required_display() {
588 let err = ToolError::ConfirmationRequired {
589 command: "rm -rf /tmp".to_owned(),
590 };
591 assert_eq!(
592 err.to_string(),
593 "command requires confirmation: rm -rf /tmp"
594 );
595 }
596
597 #[test]
598 fn tool_error_timeout_display() {
599 let err = ToolError::Timeout { timeout_secs: 30 };
600 assert_eq!(err.to_string(), "command timed out after 30s");
601 }
602
603 #[test]
604 fn tool_error_invalid_params_display() {
605 let err = ToolError::InvalidParams {
606 message: "missing field `command`".to_owned(),
607 };
608 assert_eq!(
609 err.to_string(),
610 "invalid tool parameters: missing field `command`"
611 );
612 }
613
614 #[test]
615 fn deserialize_params_valid() {
616 #[derive(Debug, serde::Deserialize, PartialEq)]
617 struct P {
618 name: String,
619 count: u32,
620 }
621 let mut map = serde_json::Map::new();
622 map.insert("name".to_owned(), serde_json::json!("test"));
623 map.insert("count".to_owned(), serde_json::json!(42));
624 let p: P = deserialize_params(&map).unwrap();
625 assert_eq!(
626 p,
627 P {
628 name: "test".to_owned(),
629 count: 42
630 }
631 );
632 }
633
634 #[test]
635 fn deserialize_params_missing_required_field() {
636 #[derive(Debug, serde::Deserialize)]
637 #[allow(dead_code)]
638 struct P {
639 name: String,
640 }
641 let map = serde_json::Map::new();
642 let err = deserialize_params::<P>(&map).unwrap_err();
643 assert!(matches!(err, ToolError::InvalidParams { .. }));
644 }
645
646 #[test]
647 fn deserialize_params_wrong_type() {
648 #[derive(Debug, serde::Deserialize)]
649 #[allow(dead_code)]
650 struct P {
651 count: u32,
652 }
653 let mut map = serde_json::Map::new();
654 map.insert("count".to_owned(), serde_json::json!("not a number"));
655 let err = deserialize_params::<P>(&map).unwrap_err();
656 assert!(matches!(err, ToolError::InvalidParams { .. }));
657 }
658
659 #[test]
660 fn deserialize_params_all_optional_empty() {
661 #[derive(Debug, serde::Deserialize, PartialEq)]
662 struct P {
663 name: Option<String>,
664 }
665 let map = serde_json::Map::new();
666 let p: P = deserialize_params(&map).unwrap();
667 assert_eq!(p, P { name: None });
668 }
669
670 #[test]
671 fn deserialize_params_ignores_extra_fields() {
672 #[derive(Debug, serde::Deserialize, PartialEq)]
673 struct P {
674 name: String,
675 }
676 let mut map = serde_json::Map::new();
677 map.insert("name".to_owned(), serde_json::json!("test"));
678 map.insert("extra".to_owned(), serde_json::json!(true));
679 let p: P = deserialize_params(&map).unwrap();
680 assert_eq!(
681 p,
682 P {
683 name: "test".to_owned()
684 }
685 );
686 }
687
688 #[test]
689 fn tool_error_execution_display() {
690 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash not found");
691 let err = ToolError::Execution(io_err);
692 assert!(err.to_string().starts_with("execution failed:"));
693 assert!(err.to_string().contains("bash not found"));
694 }
695
696 #[test]
698 fn error_kind_timeout_is_transient() {
699 let err = ToolError::Timeout { timeout_secs: 30 };
700 assert_eq!(err.kind(), ErrorKind::Transient);
701 }
702
703 #[test]
704 fn error_kind_blocked_is_permanent() {
705 let err = ToolError::Blocked {
706 command: "rm -rf /".to_owned(),
707 };
708 assert_eq!(err.kind(), ErrorKind::Permanent);
709 }
710
711 #[test]
712 fn error_kind_sandbox_violation_is_permanent() {
713 let err = ToolError::SandboxViolation {
714 path: "/etc/shadow".to_owned(),
715 };
716 assert_eq!(err.kind(), ErrorKind::Permanent);
717 }
718
719 #[test]
720 fn error_kind_cancelled_is_permanent() {
721 assert_eq!(ToolError::Cancelled.kind(), ErrorKind::Permanent);
722 }
723
724 #[test]
725 fn error_kind_invalid_params_is_permanent() {
726 let err = ToolError::InvalidParams {
727 message: "bad arg".to_owned(),
728 };
729 assert_eq!(err.kind(), ErrorKind::Permanent);
730 }
731
732 #[test]
733 fn error_kind_confirmation_required_is_permanent() {
734 let err = ToolError::ConfirmationRequired {
735 command: "rm /tmp/x".to_owned(),
736 };
737 assert_eq!(err.kind(), ErrorKind::Permanent);
738 }
739
740 #[test]
741 fn error_kind_execution_timed_out_is_transient() {
742 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
743 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
744 }
745
746 #[test]
747 fn error_kind_execution_interrupted_is_transient() {
748 let io_err = std::io::Error::new(std::io::ErrorKind::Interrupted, "interrupted");
749 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
750 }
751
752 #[test]
753 fn error_kind_execution_connection_reset_is_transient() {
754 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
755 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
756 }
757
758 #[test]
759 fn error_kind_execution_broken_pipe_is_transient() {
760 let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
761 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
762 }
763
764 #[test]
765 fn error_kind_execution_would_block_is_transient() {
766 let io_err = std::io::Error::new(std::io::ErrorKind::WouldBlock, "would block");
767 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
768 }
769
770 #[test]
771 fn error_kind_execution_connection_aborted_is_transient() {
772 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionAborted, "aborted");
773 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
774 }
775
776 #[test]
777 fn error_kind_execution_not_found_is_permanent() {
778 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
779 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
780 }
781
782 #[test]
783 fn error_kind_execution_permission_denied_is_permanent() {
784 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
785 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
786 }
787
788 #[test]
789 fn error_kind_execution_other_is_permanent() {
790 let io_err = std::io::Error::other("some other error");
791 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
792 }
793
794 #[test]
795 fn error_kind_execution_already_exists_is_permanent() {
796 let io_err = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists");
797 assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
798 }
799
800 #[test]
801 fn error_kind_display() {
802 assert_eq!(ErrorKind::Transient.to_string(), "transient");
803 assert_eq!(ErrorKind::Permanent.to_string(), "permanent");
804 }
805
806 #[test]
807 fn truncate_tool_output_short_passthrough() {
808 let short = "hello world";
809 assert_eq!(truncate_tool_output(short), short);
810 }
811
812 #[test]
813 fn truncate_tool_output_exact_limit() {
814 let exact = "a".repeat(MAX_TOOL_OUTPUT_CHARS);
815 assert_eq!(truncate_tool_output(&exact), exact);
816 }
817
818 #[test]
819 fn truncate_tool_output_long_split() {
820 let long = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
821 let result = truncate_tool_output(&long);
822 assert!(result.contains("truncated"));
823 assert!(result.len() < long.len());
824 }
825
826 #[test]
827 fn truncate_tool_output_notice_contains_count() {
828 let long = "y".repeat(MAX_TOOL_OUTPUT_CHARS + 2000);
829 let result = truncate_tool_output(&long);
830 assert!(result.contains("truncated"));
831 assert!(result.contains("chars"));
832 }
833
834 #[derive(Debug)]
835 struct DefaultExecutor;
836 impl ToolExecutor for DefaultExecutor {
837 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
838 Ok(None)
839 }
840 }
841
842 #[tokio::test]
843 async fn execute_tool_call_default_returns_none() {
844 let exec = DefaultExecutor;
845 let call = ToolCall {
846 tool_id: "anything".to_owned(),
847 params: serde_json::Map::new(),
848 caller_id: None,
849 };
850 let result = exec.execute_tool_call(&call).await.unwrap();
851 assert!(result.is_none());
852 }
853
854 #[test]
855 fn filter_stats_savings_pct() {
856 let fs = FilterStats {
857 raw_chars: 1000,
858 filtered_chars: 200,
859 ..Default::default()
860 };
861 assert!((fs.savings_pct() - 80.0).abs() < 0.01);
862 }
863
864 #[test]
865 fn filter_stats_savings_pct_zero() {
866 let fs = FilterStats::default();
867 assert!((fs.savings_pct()).abs() < 0.01);
868 }
869
870 #[test]
871 fn filter_stats_estimated_tokens_saved() {
872 let fs = FilterStats {
873 raw_chars: 1000,
874 filtered_chars: 200,
875 ..Default::default()
876 };
877 assert_eq!(fs.estimated_tokens_saved(), 200); }
879
880 #[test]
881 fn filter_stats_format_inline() {
882 let fs = FilterStats {
883 raw_chars: 1000,
884 filtered_chars: 200,
885 raw_lines: 342,
886 filtered_lines: 28,
887 ..Default::default()
888 };
889 let line = fs.format_inline("shell");
890 assert_eq!(line, "[shell] 342 lines \u{2192} 28 lines, 80.0% filtered");
891 }
892
893 #[test]
894 fn filter_stats_format_inline_zero() {
895 let fs = FilterStats::default();
896 let line = fs.format_inline("bash");
897 assert_eq!(line, "[bash] 0 lines \u{2192} 0 lines, 0.0% filtered");
898 }
899
900 struct FixedExecutor {
903 tool_id: &'static str,
904 output: &'static str,
905 }
906
907 impl ToolExecutor for FixedExecutor {
908 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
909 Ok(Some(ToolOutput {
910 tool_name: self.tool_id.to_owned(),
911 summary: self.output.to_owned(),
912 blocks_executed: 1,
913 filter_stats: None,
914 diff: None,
915 streamed: false,
916 terminal_id: None,
917 locations: None,
918 raw_response: None,
919 claim_source: None,
920 }))
921 }
922
923 fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
924 vec![]
925 }
926
927 async fn execute_tool_call(
928 &self,
929 _call: &ToolCall,
930 ) -> Result<Option<ToolOutput>, ToolError> {
931 Ok(Some(ToolOutput {
932 tool_name: self.tool_id.to_owned(),
933 summary: self.output.to_owned(),
934 blocks_executed: 1,
935 filter_stats: None,
936 diff: None,
937 streamed: false,
938 terminal_id: None,
939 locations: None,
940 raw_response: None,
941 claim_source: None,
942 }))
943 }
944 }
945
946 #[tokio::test]
947 async fn dyn_executor_execute_delegates() {
948 let inner = std::sync::Arc::new(FixedExecutor {
949 tool_id: "bash",
950 output: "hello",
951 });
952 let exec = DynExecutor(inner);
953 let result = exec.execute("```bash\necho hello\n```").await.unwrap();
954 assert!(result.is_some());
955 assert_eq!(result.unwrap().summary, "hello");
956 }
957
958 #[tokio::test]
959 async fn dyn_executor_execute_confirmed_delegates() {
960 let inner = std::sync::Arc::new(FixedExecutor {
961 tool_id: "bash",
962 output: "confirmed",
963 });
964 let exec = DynExecutor(inner);
965 let result = exec.execute_confirmed("...").await.unwrap();
966 assert!(result.is_some());
967 assert_eq!(result.unwrap().summary, "confirmed");
968 }
969
970 #[test]
971 fn dyn_executor_tool_definitions_delegates() {
972 let inner = std::sync::Arc::new(FixedExecutor {
973 tool_id: "my_tool",
974 output: "",
975 });
976 let exec = DynExecutor(inner);
977 let defs = exec.tool_definitions();
979 assert!(defs.is_empty());
980 }
981
982 #[tokio::test]
983 async fn dyn_executor_execute_tool_call_delegates() {
984 let inner = std::sync::Arc::new(FixedExecutor {
985 tool_id: "bash",
986 output: "tool_call_result",
987 });
988 let exec = DynExecutor(inner);
989 let call = ToolCall {
990 tool_id: "bash".to_owned(),
991 params: serde_json::Map::new(),
992 caller_id: None,
993 };
994 let result = exec.execute_tool_call(&call).await.unwrap();
995 assert!(result.is_some());
996 assert_eq!(result.unwrap().summary, "tool_call_result");
997 }
998
999 #[test]
1000 fn dyn_executor_set_effective_trust_delegates() {
1001 use std::sync::atomic::{AtomicU8, Ordering};
1002
1003 struct TrustCapture(AtomicU8);
1004 impl ToolExecutor for TrustCapture {
1005 async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
1006 Ok(None)
1007 }
1008 fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
1009 let v = match level {
1011 crate::SkillTrustLevel::Trusted => 0u8,
1012 crate::SkillTrustLevel::Verified => 1,
1013 crate::SkillTrustLevel::Quarantined => 2,
1014 crate::SkillTrustLevel::Blocked => 3,
1015 };
1016 self.0.store(v, Ordering::Relaxed);
1017 }
1018 }
1019
1020 let inner = std::sync::Arc::new(TrustCapture(AtomicU8::new(0)));
1021 let exec =
1022 DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
1023 ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Quarantined);
1024 assert_eq!(inner.0.load(Ordering::Relaxed), 2);
1025
1026 ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Blocked);
1027 assert_eq!(inner.0.load(Ordering::Relaxed), 3);
1028 }
1029
1030 #[test]
1031 fn extract_fenced_blocks_no_prefix_match() {
1032 assert!(extract_fenced_blocks("```bashrc\nfoo\n```", "bash").is_empty());
1034 assert_eq!(
1036 extract_fenced_blocks("```bash\nfoo\n```", "bash"),
1037 vec!["foo"]
1038 );
1039 assert_eq!(
1041 extract_fenced_blocks("```bash \nfoo\n```", "bash"),
1042 vec!["foo"]
1043 );
1044 }
1045
1046 #[test]
1049 fn tool_error_http_400_category_is_invalid_parameters() {
1050 use crate::error_taxonomy::ToolErrorCategory;
1051 let err = ToolError::Http {
1052 status: 400,
1053 message: "bad request".to_owned(),
1054 };
1055 assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1056 }
1057
1058 #[test]
1059 fn tool_error_http_401_category_is_policy_blocked() {
1060 use crate::error_taxonomy::ToolErrorCategory;
1061 let err = ToolError::Http {
1062 status: 401,
1063 message: "unauthorized".to_owned(),
1064 };
1065 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1066 }
1067
1068 #[test]
1069 fn tool_error_http_403_category_is_policy_blocked() {
1070 use crate::error_taxonomy::ToolErrorCategory;
1071 let err = ToolError::Http {
1072 status: 403,
1073 message: "forbidden".to_owned(),
1074 };
1075 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1076 }
1077
1078 #[test]
1079 fn tool_error_http_404_category_is_permanent_failure() {
1080 use crate::error_taxonomy::ToolErrorCategory;
1081 let err = ToolError::Http {
1082 status: 404,
1083 message: "not found".to_owned(),
1084 };
1085 assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1086 }
1087
1088 #[test]
1089 fn tool_error_http_429_category_is_rate_limited() {
1090 use crate::error_taxonomy::ToolErrorCategory;
1091 let err = ToolError::Http {
1092 status: 429,
1093 message: "too many requests".to_owned(),
1094 };
1095 assert_eq!(err.category(), ToolErrorCategory::RateLimited);
1096 }
1097
1098 #[test]
1099 fn tool_error_http_500_category_is_server_error() {
1100 use crate::error_taxonomy::ToolErrorCategory;
1101 let err = ToolError::Http {
1102 status: 500,
1103 message: "internal server error".to_owned(),
1104 };
1105 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1106 }
1107
1108 #[test]
1109 fn tool_error_http_502_category_is_server_error() {
1110 use crate::error_taxonomy::ToolErrorCategory;
1111 let err = ToolError::Http {
1112 status: 502,
1113 message: "bad gateway".to_owned(),
1114 };
1115 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1116 }
1117
1118 #[test]
1119 fn tool_error_http_503_category_is_server_error() {
1120 use crate::error_taxonomy::ToolErrorCategory;
1121 let err = ToolError::Http {
1122 status: 503,
1123 message: "service unavailable".to_owned(),
1124 };
1125 assert_eq!(err.category(), ToolErrorCategory::ServerError);
1126 }
1127
1128 #[test]
1129 fn tool_error_http_503_is_transient_triggers_phase2_retry() {
1130 let err = ToolError::Http {
1133 status: 503,
1134 message: "service unavailable".to_owned(),
1135 };
1136 assert_eq!(
1137 err.kind(),
1138 ErrorKind::Transient,
1139 "HTTP 503 must be Transient so Phase 2 retry fires"
1140 );
1141 }
1142
1143 #[test]
1144 fn tool_error_blocked_category_is_policy_blocked() {
1145 use crate::error_taxonomy::ToolErrorCategory;
1146 let err = ToolError::Blocked {
1147 command: "rm -rf /".to_owned(),
1148 };
1149 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1150 }
1151
1152 #[test]
1153 fn tool_error_sandbox_violation_category_is_policy_blocked() {
1154 use crate::error_taxonomy::ToolErrorCategory;
1155 let err = ToolError::SandboxViolation {
1156 path: "/etc/shadow".to_owned(),
1157 };
1158 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1159 }
1160
1161 #[test]
1162 fn tool_error_confirmation_required_category() {
1163 use crate::error_taxonomy::ToolErrorCategory;
1164 let err = ToolError::ConfirmationRequired {
1165 command: "rm /tmp/x".to_owned(),
1166 };
1167 assert_eq!(err.category(), ToolErrorCategory::ConfirmationRequired);
1168 }
1169
1170 #[test]
1171 fn tool_error_timeout_category() {
1172 use crate::error_taxonomy::ToolErrorCategory;
1173 let err = ToolError::Timeout { timeout_secs: 30 };
1174 assert_eq!(err.category(), ToolErrorCategory::Timeout);
1175 }
1176
1177 #[test]
1178 fn tool_error_cancelled_category() {
1179 use crate::error_taxonomy::ToolErrorCategory;
1180 assert_eq!(
1181 ToolError::Cancelled.category(),
1182 ToolErrorCategory::Cancelled
1183 );
1184 }
1185
1186 #[test]
1187 fn tool_error_invalid_params_category() {
1188 use crate::error_taxonomy::ToolErrorCategory;
1189 let err = ToolError::InvalidParams {
1190 message: "missing field".to_owned(),
1191 };
1192 assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
1193 }
1194
1195 #[test]
1197 fn tool_error_execution_not_found_category_is_permanent_failure() {
1198 use crate::error_taxonomy::ToolErrorCategory;
1199 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash: not found");
1200 let err = ToolError::Execution(io_err);
1201 let cat = err.category();
1202 assert_ne!(
1203 cat,
1204 ToolErrorCategory::ToolNotFound,
1205 "Execution(NotFound) must NOT map to ToolNotFound"
1206 );
1207 assert_eq!(cat, ToolErrorCategory::PermanentFailure);
1208 }
1209
1210 #[test]
1211 fn tool_error_execution_timed_out_category_is_timeout() {
1212 use crate::error_taxonomy::ToolErrorCategory;
1213 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
1214 assert_eq!(
1215 ToolError::Execution(io_err).category(),
1216 ToolErrorCategory::Timeout
1217 );
1218 }
1219
1220 #[test]
1221 fn tool_error_execution_connection_refused_category_is_network_error() {
1222 use crate::error_taxonomy::ToolErrorCategory;
1223 let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
1224 assert_eq!(
1225 ToolError::Execution(io_err).category(),
1226 ToolErrorCategory::NetworkError
1227 );
1228 }
1229
1230 #[test]
1232 fn b4_tool_error_http_429_not_quality_failure() {
1233 let err = ToolError::Http {
1234 status: 429,
1235 message: "rate limited".to_owned(),
1236 };
1237 assert!(
1238 !err.category().is_quality_failure(),
1239 "RateLimited must not be a quality failure"
1240 );
1241 }
1242
1243 #[test]
1244 fn b4_tool_error_http_503_not_quality_failure() {
1245 let err = ToolError::Http {
1246 status: 503,
1247 message: "service unavailable".to_owned(),
1248 };
1249 assert!(
1250 !err.category().is_quality_failure(),
1251 "ServerError must not be a quality failure"
1252 );
1253 }
1254
1255 #[test]
1256 fn b4_tool_error_execution_timed_out_not_quality_failure() {
1257 let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
1258 assert!(
1259 !ToolError::Execution(io_err).category().is_quality_failure(),
1260 "Timeout must not be a quality failure"
1261 );
1262 }
1263
1264 #[test]
1267 fn tool_error_shell_exit126_is_policy_blocked() {
1268 use crate::error_taxonomy::ToolErrorCategory;
1269 let err = ToolError::Shell {
1270 exit_code: 126,
1271 category: ToolErrorCategory::PolicyBlocked,
1272 message: "permission denied".to_owned(),
1273 };
1274 assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
1275 }
1276
1277 #[test]
1278 fn tool_error_shell_exit127_is_permanent_failure() {
1279 use crate::error_taxonomy::ToolErrorCategory;
1280 let err = ToolError::Shell {
1281 exit_code: 127,
1282 category: ToolErrorCategory::PermanentFailure,
1283 message: "command not found".to_owned(),
1284 };
1285 assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
1286 assert!(!err.category().is_retryable());
1287 }
1288
1289 #[test]
1290 fn tool_error_shell_not_quality_failure() {
1291 use crate::error_taxonomy::ToolErrorCategory;
1292 let err = ToolError::Shell {
1293 exit_code: 127,
1294 category: ToolErrorCategory::PermanentFailure,
1295 message: "command not found".to_owned(),
1296 };
1297 assert!(!err.category().is_quality_failure());
1299 }
1300}