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