use std::fmt;
use zeph_common::ToolName;
#[derive(Debug, Clone)]
pub struct DiffData {
pub file_path: String,
pub old_content: String,
pub new_content: String,
}
#[derive(Debug, Clone)]
pub struct ToolCall {
pub tool_id: ToolName,
pub params: serde_json::Map<String, serde_json::Value>,
pub caller_id: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct FilterStats {
pub raw_chars: usize,
pub filtered_chars: usize,
pub raw_lines: usize,
pub filtered_lines: usize,
pub confidence: Option<crate::FilterConfidence>,
pub command: Option<String>,
pub kept_lines: Vec<usize>,
}
impl FilterStats {
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn savings_pct(&self) -> f64 {
if self.raw_chars == 0 {
return 0.0;
}
(1.0 - self.filtered_chars as f64 / self.raw_chars as f64) * 100.0
}
#[must_use]
pub fn estimated_tokens_saved(&self) -> usize {
self.raw_chars.saturating_sub(self.filtered_chars) / 4
}
#[must_use]
pub fn format_inline(&self, tool_name: &str) -> String {
let cmd_label = self
.command
.as_deref()
.map(|c| {
let trimmed = c.trim();
if trimmed.len() > 60 {
format!(" `{}…`", &trimmed[..57])
} else {
format!(" `{trimmed}`")
}
})
.unwrap_or_default();
format!(
"[{tool_name}]{cmd_label} {} lines \u{2192} {} lines, {:.1}% filtered",
self.raw_lines,
self.filtered_lines,
self.savings_pct()
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClaimSource {
Shell,
FileSystem,
WebScrape,
Mcp,
A2a,
CodeSearch,
Diagnostics,
Memory,
}
#[derive(Debug, Clone)]
pub struct ToolOutput {
pub tool_name: ToolName,
pub summary: String,
pub blocks_executed: u32,
pub filter_stats: Option<FilterStats>,
pub diff: Option<DiffData>,
pub streamed: bool,
pub terminal_id: Option<String>,
pub locations: Option<Vec<String>>,
pub raw_response: Option<serde_json::Value>,
pub claim_source: Option<ClaimSource>,
}
impl fmt::Display for ToolOutput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.summary)
}
}
pub const MAX_TOOL_OUTPUT_CHARS: usize = 30_000;
#[must_use]
pub fn truncate_tool_output(output: &str) -> String {
truncate_tool_output_at(output, MAX_TOOL_OUTPUT_CHARS)
}
#[must_use]
pub fn truncate_tool_output_at(output: &str, max_chars: usize) -> String {
if output.len() <= max_chars {
return output.to_string();
}
let half = max_chars / 2;
let head_end = output.floor_char_boundary(half);
let tail_start = output.ceil_char_boundary(output.len() - half);
let head = &output[..head_end];
let tail = &output[tail_start..];
let truncated = output.len() - head_end - (output.len() - tail_start);
format!(
"{head}\n\n... [truncated {truncated} chars, showing first and last ~{half} chars] ...\n\n{tail}"
)
}
#[derive(Debug, Clone)]
pub enum ToolEvent {
Started {
tool_name: ToolName,
command: String,
sandbox_profile: Option<String>,
},
OutputChunk {
tool_name: ToolName,
command: String,
chunk: String,
},
Completed {
tool_name: ToolName,
command: String,
output: String,
success: bool,
filter_stats: Option<FilterStats>,
diff: Option<DiffData>,
},
Rollback {
tool_name: ToolName,
command: String,
restored_count: usize,
deleted_count: usize,
},
}
pub type ToolEventTx = tokio::sync::mpsc::UnboundedSender<ToolEvent>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum ErrorKind {
Transient,
Permanent,
}
impl std::fmt::Display for ErrorKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Transient => f.write_str("transient"),
Self::Permanent => f.write_str("permanent"),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ToolError {
#[error("command blocked by policy: {command}")]
Blocked { command: String },
#[error("path not allowed by sandbox: {path}")]
SandboxViolation { path: String },
#[error("command requires confirmation: {command}")]
ConfirmationRequired { command: String },
#[error("command timed out after {timeout_secs}s")]
Timeout { timeout_secs: u64 },
#[error("operation cancelled")]
Cancelled,
#[error("invalid tool parameters: {message}")]
InvalidParams { message: String },
#[error("execution failed: {0}")]
Execution(#[from] std::io::Error),
#[error("HTTP error {status}: {message}")]
Http { status: u16, message: String },
#[error("shell error (exit {exit_code}): {message}")]
Shell {
exit_code: i32,
category: crate::error_taxonomy::ToolErrorCategory,
message: String,
},
#[error("snapshot failed: {reason}")]
SnapshotFailed { reason: String },
}
impl ToolError {
#[must_use]
pub fn category(&self) -> crate::error_taxonomy::ToolErrorCategory {
use crate::error_taxonomy::{ToolErrorCategory, classify_http_status, classify_io_error};
match self {
Self::Blocked { .. } | Self::SandboxViolation { .. } => {
ToolErrorCategory::PolicyBlocked
}
Self::ConfirmationRequired { .. } => ToolErrorCategory::ConfirmationRequired,
Self::Timeout { .. } => ToolErrorCategory::Timeout,
Self::Cancelled => ToolErrorCategory::Cancelled,
Self::InvalidParams { .. } => ToolErrorCategory::InvalidParameters,
Self::Http { status, .. } => classify_http_status(*status),
Self::Execution(io_err) => classify_io_error(io_err),
Self::Shell { category, .. } => *category,
Self::SnapshotFailed { .. } => ToolErrorCategory::PermanentFailure,
}
}
#[must_use]
pub fn kind(&self) -> ErrorKind {
use crate::error_taxonomy::ToolErrorCategoryExt;
self.category().error_kind()
}
}
pub fn deserialize_params<T: serde::de::DeserializeOwned>(
params: &serde_json::Map<String, serde_json::Value>,
) -> Result<T, ToolError> {
let obj = serde_json::Value::Object(params.clone());
serde_json::from_value(obj).map_err(|e| ToolError::InvalidParams {
message: e.to_string(),
})
}
pub trait ToolExecutor: Send + Sync {
fn execute(
&self,
response: &str,
) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send;
fn execute_confirmed(
&self,
response: &str,
) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
self.execute(response)
}
fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
vec![]
}
fn execute_tool_call(
&self,
_call: &ToolCall,
) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
std::future::ready(Ok(None))
}
fn execute_tool_call_confirmed(
&self,
call: &ToolCall,
) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
self.execute_tool_call(call)
}
fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
fn is_tool_retryable(&self, _tool_id: &str) -> bool {
false
}
fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
false
}
}
pub trait ErasedToolExecutor: Send + Sync {
fn execute_erased<'a>(
&'a self,
response: &'a str,
) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
fn execute_confirmed_erased<'a>(
&'a self,
response: &'a str,
) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef>;
fn execute_tool_call_erased<'a>(
&'a self,
call: &'a ToolCall,
) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>;
fn execute_tool_call_confirmed_erased<'a>(
&'a self,
call: &'a ToolCall,
) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
{
self.execute_tool_call_erased(call)
}
fn set_skill_env(&self, _env: Option<std::collections::HashMap<String, String>>) {}
fn set_effective_trust(&self, _level: crate::SkillTrustLevel) {}
fn is_tool_retryable_erased(&self, tool_id: &str) -> bool;
fn is_tool_speculatable_erased(&self, _tool_id: &str) -> bool {
false
}
fn requires_confirmation_erased(&self, _call: &ToolCall) -> bool {
false
}
}
impl<T: ToolExecutor> ErasedToolExecutor for T {
fn execute_erased<'a>(
&'a self,
response: &'a str,
) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
{
Box::pin(self.execute(response))
}
fn execute_confirmed_erased<'a>(
&'a self,
response: &'a str,
) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
{
Box::pin(self.execute_confirmed(response))
}
fn tool_definitions_erased(&self) -> Vec<crate::registry::ToolDef> {
self.tool_definitions()
}
fn execute_tool_call_erased<'a>(
&'a self,
call: &'a ToolCall,
) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
{
Box::pin(self.execute_tool_call(call))
}
fn execute_tool_call_confirmed_erased<'a>(
&'a self,
call: &'a ToolCall,
) -> std::pin::Pin<Box<dyn Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a>>
{
Box::pin(self.execute_tool_call_confirmed(call))
}
fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
ToolExecutor::set_skill_env(self, env);
}
fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
ToolExecutor::set_effective_trust(self, level);
}
fn is_tool_retryable_erased(&self, tool_id: &str) -> bool {
ToolExecutor::is_tool_retryable(self, tool_id)
}
fn is_tool_speculatable_erased(&self, tool_id: &str) -> bool {
ToolExecutor::is_tool_speculatable(self, tool_id)
}
}
pub struct DynExecutor(pub std::sync::Arc<dyn ErasedToolExecutor>);
impl ToolExecutor for DynExecutor {
fn execute(
&self,
response: &str,
) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
let inner = std::sync::Arc::clone(&self.0);
let response = response.to_owned();
async move { inner.execute_erased(&response).await }
}
fn execute_confirmed(
&self,
response: &str,
) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
let inner = std::sync::Arc::clone(&self.0);
let response = response.to_owned();
async move { inner.execute_confirmed_erased(&response).await }
}
fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
self.0.tool_definitions_erased()
}
fn execute_tool_call(
&self,
call: &ToolCall,
) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
let inner = std::sync::Arc::clone(&self.0);
let call = call.clone();
async move { inner.execute_tool_call_erased(&call).await }
}
fn execute_tool_call_confirmed(
&self,
call: &ToolCall,
) -> impl Future<Output = Result<Option<ToolOutput>, ToolError>> + Send {
let inner = std::sync::Arc::clone(&self.0);
let call = call.clone();
async move { inner.execute_tool_call_confirmed_erased(&call).await }
}
fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
ErasedToolExecutor::set_skill_env(self.0.as_ref(), env);
}
fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
ErasedToolExecutor::set_effective_trust(self.0.as_ref(), level);
}
fn is_tool_retryable(&self, tool_id: &str) -> bool {
self.0.is_tool_retryable_erased(tool_id)
}
fn is_tool_speculatable(&self, tool_id: &str) -> bool {
self.0.is_tool_speculatable_erased(tool_id)
}
}
#[must_use]
pub fn extract_fenced_blocks<'a>(text: &'a str, lang: &str) -> Vec<&'a str> {
let marker = format!("```{lang}");
let marker_len = marker.len();
let mut blocks = Vec::new();
let mut rest = text;
let mut search_from = 0;
while let Some(rel) = rest[search_from..].find(&marker) {
let start = search_from + rel;
let after = &rest[start + marker_len..];
let boundary_ok = after
.chars()
.next()
.is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '-');
if !boundary_ok {
search_from = start + marker_len;
continue;
}
if let Some(end) = after.find("```") {
blocks.push(after[..end].trim());
rest = &after[end + 3..];
search_from = 0;
} else {
break;
}
}
blocks
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_output_display() {
let output = ToolOutput {
tool_name: ToolName::new("bash"),
summary: "$ echo hello\nhello".to_owned(),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
};
assert_eq!(output.to_string(), "$ echo hello\nhello");
}
#[test]
fn tool_error_blocked_display() {
let err = ToolError::Blocked {
command: "rm -rf /".to_owned(),
};
assert_eq!(err.to_string(), "command blocked by policy: rm -rf /");
}
#[test]
fn tool_error_sandbox_violation_display() {
let err = ToolError::SandboxViolation {
path: "/etc/shadow".to_owned(),
};
assert_eq!(err.to_string(), "path not allowed by sandbox: /etc/shadow");
}
#[test]
fn tool_error_confirmation_required_display() {
let err = ToolError::ConfirmationRequired {
command: "rm -rf /tmp".to_owned(),
};
assert_eq!(
err.to_string(),
"command requires confirmation: rm -rf /tmp"
);
}
#[test]
fn tool_error_timeout_display() {
let err = ToolError::Timeout { timeout_secs: 30 };
assert_eq!(err.to_string(), "command timed out after 30s");
}
#[test]
fn tool_error_invalid_params_display() {
let err = ToolError::InvalidParams {
message: "missing field `command`".to_owned(),
};
assert_eq!(
err.to_string(),
"invalid tool parameters: missing field `command`"
);
}
#[test]
fn deserialize_params_valid() {
#[derive(Debug, serde::Deserialize, PartialEq)]
struct P {
name: String,
count: u32,
}
let mut map = serde_json::Map::new();
map.insert("name".to_owned(), serde_json::json!("test"));
map.insert("count".to_owned(), serde_json::json!(42));
let p: P = deserialize_params(&map).unwrap();
assert_eq!(
p,
P {
name: "test".to_owned(),
count: 42
}
);
}
#[test]
fn deserialize_params_missing_required_field() {
#[derive(Debug, serde::Deserialize)]
#[allow(dead_code)]
struct P {
name: String,
}
let map = serde_json::Map::new();
let err = deserialize_params::<P>(&map).unwrap_err();
assert!(matches!(err, ToolError::InvalidParams { .. }));
}
#[test]
fn deserialize_params_wrong_type() {
#[derive(Debug, serde::Deserialize)]
#[allow(dead_code)]
struct P {
count: u32,
}
let mut map = serde_json::Map::new();
map.insert("count".to_owned(), serde_json::json!("not a number"));
let err = deserialize_params::<P>(&map).unwrap_err();
assert!(matches!(err, ToolError::InvalidParams { .. }));
}
#[test]
fn deserialize_params_all_optional_empty() {
#[derive(Debug, serde::Deserialize, PartialEq)]
struct P {
name: Option<String>,
}
let map = serde_json::Map::new();
let p: P = deserialize_params(&map).unwrap();
assert_eq!(p, P { name: None });
}
#[test]
fn deserialize_params_ignores_extra_fields() {
#[derive(Debug, serde::Deserialize, PartialEq)]
struct P {
name: String,
}
let mut map = serde_json::Map::new();
map.insert("name".to_owned(), serde_json::json!("test"));
map.insert("extra".to_owned(), serde_json::json!(true));
let p: P = deserialize_params(&map).unwrap();
assert_eq!(
p,
P {
name: "test".to_owned()
}
);
}
#[test]
fn tool_error_execution_display() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash not found");
let err = ToolError::Execution(io_err);
assert!(err.to_string().starts_with("execution failed:"));
assert!(err.to_string().contains("bash not found"));
}
#[test]
fn error_kind_timeout_is_transient() {
let err = ToolError::Timeout { timeout_secs: 30 };
assert_eq!(err.kind(), ErrorKind::Transient);
}
#[test]
fn error_kind_blocked_is_permanent() {
let err = ToolError::Blocked {
command: "rm -rf /".to_owned(),
};
assert_eq!(err.kind(), ErrorKind::Permanent);
}
#[test]
fn error_kind_sandbox_violation_is_permanent() {
let err = ToolError::SandboxViolation {
path: "/etc/shadow".to_owned(),
};
assert_eq!(err.kind(), ErrorKind::Permanent);
}
#[test]
fn error_kind_cancelled_is_permanent() {
assert_eq!(ToolError::Cancelled.kind(), ErrorKind::Permanent);
}
#[test]
fn error_kind_invalid_params_is_permanent() {
let err = ToolError::InvalidParams {
message: "bad arg".to_owned(),
};
assert_eq!(err.kind(), ErrorKind::Permanent);
}
#[test]
fn error_kind_confirmation_required_is_permanent() {
let err = ToolError::ConfirmationRequired {
command: "rm /tmp/x".to_owned(),
};
assert_eq!(err.kind(), ErrorKind::Permanent);
}
#[test]
fn error_kind_execution_timed_out_is_transient() {
let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
}
#[test]
fn error_kind_execution_interrupted_is_transient() {
let io_err = std::io::Error::new(std::io::ErrorKind::Interrupted, "interrupted");
assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
}
#[test]
fn error_kind_execution_connection_reset_is_transient() {
let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionReset, "reset");
assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
}
#[test]
fn error_kind_execution_broken_pipe_is_transient() {
let io_err = std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken");
assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
}
#[test]
fn error_kind_execution_would_block_is_transient() {
let io_err = std::io::Error::new(std::io::ErrorKind::WouldBlock, "would block");
assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
}
#[test]
fn error_kind_execution_connection_aborted_is_transient() {
let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionAborted, "aborted");
assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Transient);
}
#[test]
fn error_kind_execution_not_found_is_permanent() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
}
#[test]
fn error_kind_execution_permission_denied_is_permanent() {
let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
}
#[test]
fn error_kind_execution_other_is_permanent() {
let io_err = std::io::Error::other("some other error");
assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
}
#[test]
fn error_kind_execution_already_exists_is_permanent() {
let io_err = std::io::Error::new(std::io::ErrorKind::AlreadyExists, "exists");
assert_eq!(ToolError::Execution(io_err).kind(), ErrorKind::Permanent);
}
#[test]
fn error_kind_display() {
assert_eq!(ErrorKind::Transient.to_string(), "transient");
assert_eq!(ErrorKind::Permanent.to_string(), "permanent");
}
#[test]
fn truncate_tool_output_short_passthrough() {
let short = "hello world";
assert_eq!(truncate_tool_output(short), short);
}
#[test]
fn truncate_tool_output_exact_limit() {
let exact = "a".repeat(MAX_TOOL_OUTPUT_CHARS);
assert_eq!(truncate_tool_output(&exact), exact);
}
#[test]
fn truncate_tool_output_long_split() {
let long = "x".repeat(MAX_TOOL_OUTPUT_CHARS + 1000);
let result = truncate_tool_output(&long);
assert!(result.contains("truncated"));
assert!(result.len() < long.len());
}
#[test]
fn truncate_tool_output_notice_contains_count() {
let long = "y".repeat(MAX_TOOL_OUTPUT_CHARS + 2000);
let result = truncate_tool_output(&long);
assert!(result.contains("truncated"));
assert!(result.contains("chars"));
}
#[derive(Debug)]
struct DefaultExecutor;
impl ToolExecutor for DefaultExecutor {
async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
Ok(None)
}
}
#[tokio::test]
async fn execute_tool_call_default_returns_none() {
let exec = DefaultExecutor;
let call = ToolCall {
tool_id: ToolName::new("anything"),
params: serde_json::Map::new(),
caller_id: None,
};
let result = exec.execute_tool_call(&call).await.unwrap();
assert!(result.is_none());
}
#[test]
fn filter_stats_savings_pct() {
let fs = FilterStats {
raw_chars: 1000,
filtered_chars: 200,
..Default::default()
};
assert!((fs.savings_pct() - 80.0).abs() < 0.01);
}
#[test]
fn filter_stats_savings_pct_zero() {
let fs = FilterStats::default();
assert!((fs.savings_pct()).abs() < 0.01);
}
#[test]
fn filter_stats_estimated_tokens_saved() {
let fs = FilterStats {
raw_chars: 1000,
filtered_chars: 200,
..Default::default()
};
assert_eq!(fs.estimated_tokens_saved(), 200); }
#[test]
fn filter_stats_format_inline() {
let fs = FilterStats {
raw_chars: 1000,
filtered_chars: 200,
raw_lines: 342,
filtered_lines: 28,
..Default::default()
};
let line = fs.format_inline("shell");
assert_eq!(line, "[shell] 342 lines \u{2192} 28 lines, 80.0% filtered");
}
#[test]
fn filter_stats_format_inline_zero() {
let fs = FilterStats::default();
let line = fs.format_inline("bash");
assert_eq!(line, "[bash] 0 lines \u{2192} 0 lines, 0.0% filtered");
}
struct FixedExecutor {
tool_id: &'static str,
output: &'static str,
}
impl ToolExecutor for FixedExecutor {
async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
Ok(Some(ToolOutput {
tool_name: ToolName::new(self.tool_id),
summary: self.output.to_owned(),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
}))
}
fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
vec![]
}
async fn execute_tool_call(
&self,
_call: &ToolCall,
) -> Result<Option<ToolOutput>, ToolError> {
Ok(Some(ToolOutput {
tool_name: ToolName::new(self.tool_id),
summary: self.output.to_owned(),
blocks_executed: 1,
filter_stats: None,
diff: None,
streamed: false,
terminal_id: None,
locations: None,
raw_response: None,
claim_source: None,
}))
}
}
#[tokio::test]
async fn dyn_executor_execute_delegates() {
let inner = std::sync::Arc::new(FixedExecutor {
tool_id: "bash",
output: "hello",
});
let exec = DynExecutor(inner);
let result = exec.execute("```bash\necho hello\n```").await.unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().summary, "hello");
}
#[tokio::test]
async fn dyn_executor_execute_confirmed_delegates() {
let inner = std::sync::Arc::new(FixedExecutor {
tool_id: "bash",
output: "confirmed",
});
let exec = DynExecutor(inner);
let result = exec.execute_confirmed("...").await.unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().summary, "confirmed");
}
#[test]
fn dyn_executor_tool_definitions_delegates() {
let inner = std::sync::Arc::new(FixedExecutor {
tool_id: "my_tool",
output: "",
});
let exec = DynExecutor(inner);
let defs = exec.tool_definitions();
assert!(defs.is_empty());
}
#[tokio::test]
async fn dyn_executor_execute_tool_call_delegates() {
let inner = std::sync::Arc::new(FixedExecutor {
tool_id: "bash",
output: "tool_call_result",
});
let exec = DynExecutor(inner);
let call = ToolCall {
tool_id: ToolName::new("bash"),
params: serde_json::Map::new(),
caller_id: None,
};
let result = exec.execute_tool_call(&call).await.unwrap();
assert!(result.is_some());
assert_eq!(result.unwrap().summary, "tool_call_result");
}
#[test]
fn dyn_executor_set_effective_trust_delegates() {
use std::sync::atomic::{AtomicU8, Ordering};
struct TrustCapture(AtomicU8);
impl ToolExecutor for TrustCapture {
async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
Ok(None)
}
fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
let v = match level {
crate::SkillTrustLevel::Trusted => 0u8,
crate::SkillTrustLevel::Verified => 1,
crate::SkillTrustLevel::Quarantined => 2,
crate::SkillTrustLevel::Blocked => 3,
};
self.0.store(v, Ordering::Relaxed);
}
}
let inner = std::sync::Arc::new(TrustCapture(AtomicU8::new(0)));
let exec =
DynExecutor(std::sync::Arc::clone(&inner) as std::sync::Arc<dyn ErasedToolExecutor>);
ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Quarantined);
assert_eq!(inner.0.load(Ordering::Relaxed), 2);
ToolExecutor::set_effective_trust(&exec, crate::SkillTrustLevel::Blocked);
assert_eq!(inner.0.load(Ordering::Relaxed), 3);
}
#[test]
fn extract_fenced_blocks_no_prefix_match() {
assert!(extract_fenced_blocks("```bashrc\nfoo\n```", "bash").is_empty());
assert_eq!(
extract_fenced_blocks("```bash\nfoo\n```", "bash"),
vec!["foo"]
);
assert_eq!(
extract_fenced_blocks("```bash \nfoo\n```", "bash"),
vec!["foo"]
);
}
#[test]
fn tool_error_http_400_category_is_invalid_parameters() {
use crate::error_taxonomy::ToolErrorCategory;
let err = ToolError::Http {
status: 400,
message: "bad request".to_owned(),
};
assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
}
#[test]
fn tool_error_http_401_category_is_policy_blocked() {
use crate::error_taxonomy::ToolErrorCategory;
let err = ToolError::Http {
status: 401,
message: "unauthorized".to_owned(),
};
assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
}
#[test]
fn tool_error_http_403_category_is_policy_blocked() {
use crate::error_taxonomy::ToolErrorCategory;
let err = ToolError::Http {
status: 403,
message: "forbidden".to_owned(),
};
assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
}
#[test]
fn tool_error_http_404_category_is_permanent_failure() {
use crate::error_taxonomy::ToolErrorCategory;
let err = ToolError::Http {
status: 404,
message: "not found".to_owned(),
};
assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
}
#[test]
fn tool_error_http_429_category_is_rate_limited() {
use crate::error_taxonomy::ToolErrorCategory;
let err = ToolError::Http {
status: 429,
message: "too many requests".to_owned(),
};
assert_eq!(err.category(), ToolErrorCategory::RateLimited);
}
#[test]
fn tool_error_http_500_category_is_server_error() {
use crate::error_taxonomy::ToolErrorCategory;
let err = ToolError::Http {
status: 500,
message: "internal server error".to_owned(),
};
assert_eq!(err.category(), ToolErrorCategory::ServerError);
}
#[test]
fn tool_error_http_502_category_is_server_error() {
use crate::error_taxonomy::ToolErrorCategory;
let err = ToolError::Http {
status: 502,
message: "bad gateway".to_owned(),
};
assert_eq!(err.category(), ToolErrorCategory::ServerError);
}
#[test]
fn tool_error_http_503_category_is_server_error() {
use crate::error_taxonomy::ToolErrorCategory;
let err = ToolError::Http {
status: 503,
message: "service unavailable".to_owned(),
};
assert_eq!(err.category(), ToolErrorCategory::ServerError);
}
#[test]
fn tool_error_http_503_is_transient_triggers_phase2_retry() {
let err = ToolError::Http {
status: 503,
message: "service unavailable".to_owned(),
};
assert_eq!(
err.kind(),
ErrorKind::Transient,
"HTTP 503 must be Transient so Phase 2 retry fires"
);
}
#[test]
fn tool_error_blocked_category_is_policy_blocked() {
use crate::error_taxonomy::ToolErrorCategory;
let err = ToolError::Blocked {
command: "rm -rf /".to_owned(),
};
assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
}
#[test]
fn tool_error_sandbox_violation_category_is_policy_blocked() {
use crate::error_taxonomy::ToolErrorCategory;
let err = ToolError::SandboxViolation {
path: "/etc/shadow".to_owned(),
};
assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
}
#[test]
fn tool_error_confirmation_required_category() {
use crate::error_taxonomy::ToolErrorCategory;
let err = ToolError::ConfirmationRequired {
command: "rm /tmp/x".to_owned(),
};
assert_eq!(err.category(), ToolErrorCategory::ConfirmationRequired);
}
#[test]
fn tool_error_timeout_category() {
use crate::error_taxonomy::ToolErrorCategory;
let err = ToolError::Timeout { timeout_secs: 30 };
assert_eq!(err.category(), ToolErrorCategory::Timeout);
}
#[test]
fn tool_error_cancelled_category() {
use crate::error_taxonomy::ToolErrorCategory;
assert_eq!(
ToolError::Cancelled.category(),
ToolErrorCategory::Cancelled
);
}
#[test]
fn tool_error_invalid_params_category() {
use crate::error_taxonomy::ToolErrorCategory;
let err = ToolError::InvalidParams {
message: "missing field".to_owned(),
};
assert_eq!(err.category(), ToolErrorCategory::InvalidParameters);
}
#[test]
fn tool_error_execution_not_found_category_is_permanent_failure() {
use crate::error_taxonomy::ToolErrorCategory;
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "bash: not found");
let err = ToolError::Execution(io_err);
let cat = err.category();
assert_ne!(
cat,
ToolErrorCategory::ToolNotFound,
"Execution(NotFound) must NOT map to ToolNotFound"
);
assert_eq!(cat, ToolErrorCategory::PermanentFailure);
}
#[test]
fn tool_error_execution_timed_out_category_is_timeout() {
use crate::error_taxonomy::ToolErrorCategory;
let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timed out");
assert_eq!(
ToolError::Execution(io_err).category(),
ToolErrorCategory::Timeout
);
}
#[test]
fn tool_error_execution_connection_refused_category_is_network_error() {
use crate::error_taxonomy::ToolErrorCategory;
let io_err = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
assert_eq!(
ToolError::Execution(io_err).category(),
ToolErrorCategory::NetworkError
);
}
#[test]
fn b4_tool_error_http_429_not_quality_failure() {
let err = ToolError::Http {
status: 429,
message: "rate limited".to_owned(),
};
assert!(
!err.category().is_quality_failure(),
"RateLimited must not be a quality failure"
);
}
#[test]
fn b4_tool_error_http_503_not_quality_failure() {
let err = ToolError::Http {
status: 503,
message: "service unavailable".to_owned(),
};
assert!(
!err.category().is_quality_failure(),
"ServerError must not be a quality failure"
);
}
#[test]
fn b4_tool_error_execution_timed_out_not_quality_failure() {
let io_err = std::io::Error::new(std::io::ErrorKind::TimedOut, "timeout");
assert!(
!ToolError::Execution(io_err).category().is_quality_failure(),
"Timeout must not be a quality failure"
);
}
#[test]
fn tool_error_shell_exit126_is_policy_blocked() {
use crate::error_taxonomy::ToolErrorCategory;
let err = ToolError::Shell {
exit_code: 126,
category: ToolErrorCategory::PolicyBlocked,
message: "permission denied".to_owned(),
};
assert_eq!(err.category(), ToolErrorCategory::PolicyBlocked);
}
#[test]
fn tool_error_shell_exit127_is_permanent_failure() {
use crate::error_taxonomy::ToolErrorCategory;
let err = ToolError::Shell {
exit_code: 127,
category: ToolErrorCategory::PermanentFailure,
message: "command not found".to_owned(),
};
assert_eq!(err.category(), ToolErrorCategory::PermanentFailure);
assert!(!err.category().is_retryable());
}
#[test]
fn tool_error_shell_not_quality_failure() {
use crate::error_taxonomy::ToolErrorCategory;
let err = ToolError::Shell {
exit_code: 127,
category: ToolErrorCategory::PermanentFailure,
message: "command not found".to_owned(),
};
assert!(!err.category().is_quality_failure());
}
}