Skip to main content

edgecrab_types/
error.rs

1//! Error types for the EdgeCrab agent.
2//!
3//! Strategy: `thiserror` for all library crates (structured, matchable),
4//! `anyhow` only in binary crate entry points.
5
6use serde_json;
7
8/// Top-level agent error — covers every failure mode documented in the spec.
9///
10/// Each variant maps to a specific recovery strategy in the conversation loop:
11/// - Retryable errors trigger exponential backoff
12/// - Budget/interrupt errors break the loop cleanly
13/// - Tool errors are fed back to the LLM as JSON
14#[derive(Debug, thiserror::Error)]
15pub enum AgentError {
16    #[error("LLM API error: {0}")]
17    Llm(String),
18
19    #[error("Tool execution failed: {tool} — {message}")]
20    ToolExecution { tool: String, message: String },
21
22    #[error("Context limit exceeded: {used}/{limit} tokens")]
23    ContextLimit { used: usize, limit: usize },
24
25    #[error("Budget exhausted: {used}/{max} iterations")]
26    BudgetExhausted { used: u32, max: u32 },
27
28    #[error("Interrupted by user")]
29    Interrupted,
30
31    #[error("Configuration error: {0}")]
32    Config(String),
33
34    #[error("Database error: {0}")]
35    Database(String),
36
37    #[error("IO error: {0}")]
38    Io(#[from] std::io::Error),
39
40    #[error("Serialization error: {0}")]
41    Serde(#[from] serde_json::Error),
42
43    #[error("Provider rate limited: retry after {retry_after_ms}ms")]
44    RateLimited {
45        provider: String,
46        retry_after_ms: u64,
47    },
48
49    #[error("Context compression failed: {0}")]
50    CompressionFailed(String),
51
52    #[error("API refusal: {0}")]
53    ApiRefusal(String),
54
55    #[error("Malformed tool call from LLM: {0}")]
56    MalformedToolCall(String),
57
58    #[error("Plugin error in {plugin}: {message}")]
59    Plugin { plugin: String, message: String },
60
61    #[error("Gateway delivery failed to {platform}: {message}")]
62    GatewayDelivery { platform: String, message: String },
63
64    #[error("Migration error: {0}")]
65    Migration(String),
66
67    #[error("Security violation: {0}")]
68    Security(String),
69
70    #[error("Validation error: {0}")]
71    Validation(String),
72}
73
74/// Per-tool-call error record accumulated in `ConversationResult.tool_errors`.
75///
76/// Mirrors hermes-agent's `ToolError` dataclass (used in `AgentResult.tool_errors`).
77/// Provides first-class error observability without requiring callers to parse raw
78/// message history — enables RL training signal extraction and structured logging.
79///
80/// Fields:
81/// - `turn`        — API call index within the conversation (1-based).
82/// - `tool_name`   — Name of the tool that was called.
83/// - `arguments`   — Raw JSON arguments string passed to the tool.
84/// - `error`       — Human-readable error description.
85/// - `tool_result` — Full tool result string returned to the LLM.
86#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
87pub struct ToolErrorRecord {
88    pub turn: u32,
89    pub tool_name: String,
90    pub arguments: String,
91    pub error: String,
92    pub tool_result: String,
93}
94
95/// Tool-specific errors with retry strategy metadata.
96///
97/// These are converted to JSON and sent back to the LLM so it can
98/// self-correct (e.g. fix a bad path, retry with different args).
99#[derive(Debug, thiserror::Error)]
100pub enum ToolError {
101    #[error("Unknown tool: {0}")]
102    NotFound(String),
103
104    #[error("Invalid arguments for {tool}: {message}")]
105    InvalidArgs { tool: String, message: String },
106
107    #[error("Tool {tool} unavailable: {reason}")]
108    Unavailable { tool: String, reason: String },
109
110    #[error("Execution timeout after {seconds}s: {tool}")]
111    Timeout { tool: String, seconds: u64 },
112
113    #[error("Permission denied: {0}")]
114    PermissionDenied(String),
115
116    #[error("Execution failed in {tool}: {message}")]
117    ExecutionFailed { tool: String, message: String },
118
119    #[error("{message}")]
120    CapabilityDenied {
121        tool: String,
122        code: String,
123        message: String,
124        suppression_key: Option<String>,
125        suggested_tool: Option<String>,
126        suggested_action: Option<String>,
127    },
128
129    #[error("{0}")]
130    Other(String),
131}
132
133#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
134pub struct ToolErrorResponse {
135    #[serde(rename = "type")]
136    pub response_type: String,
137    pub category: String,
138    pub code: String,
139    pub error: String,
140    pub retryable: bool,
141    pub suppress_retry: bool,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub suppression_key: Option<String>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub tool: Option<String>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub suggested_tool: Option<String>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub suggested_action: Option<String>,
150}
151
152impl ToolError {
153    pub fn capability_denied(
154        tool: impl Into<String>,
155        code: impl Into<String>,
156        message: impl Into<String>,
157    ) -> Self {
158        Self::CapabilityDenied {
159            tool: tool.into(),
160            code: code.into(),
161            message: message.into(),
162            suppression_key: None,
163            suggested_tool: None,
164            suggested_action: None,
165        }
166    }
167
168    pub fn with_suppression_key(self, suppression_key: impl Into<String>) -> Self {
169        match self {
170            Self::CapabilityDenied {
171                tool,
172                code,
173                message,
174                suggested_tool,
175                suggested_action,
176                ..
177            } => Self::CapabilityDenied {
178                tool,
179                code,
180                message,
181                suppression_key: Some(suppression_key.into()),
182                suggested_tool,
183                suggested_action,
184            },
185            other => other,
186        }
187    }
188
189    pub fn with_suggested_tool(self, suggested_tool: impl Into<String>) -> Self {
190        match self {
191            Self::CapabilityDenied {
192                tool,
193                code,
194                message,
195                suppression_key,
196                suggested_action,
197                ..
198            } => Self::CapabilityDenied {
199                tool,
200                code,
201                message,
202                suppression_key,
203                suggested_tool: Some(suggested_tool.into()),
204                suggested_action,
205            },
206            other => other,
207        }
208    }
209
210    pub fn with_suggested_action(self, suggested_action: impl Into<String>) -> Self {
211        match self {
212            Self::CapabilityDenied {
213                tool,
214                code,
215                message,
216                suppression_key,
217                suggested_tool,
218                ..
219            } => Self::CapabilityDenied {
220                tool,
221                code,
222                message,
223                suppression_key,
224                suggested_tool,
225                suggested_action: Some(suggested_action.into()),
226            },
227            other => other,
228        }
229    }
230
231    pub fn to_llm_payload(&self) -> ToolErrorResponse {
232        ToolErrorResponse {
233            response_type: "tool_error".into(),
234            category: self.category().into(),
235            code: self.code().into(),
236            error: self.to_string(),
237            retryable: self.is_retryable(),
238            suppress_retry: self.should_suppress_retry(),
239            suppression_key: self.suppression_key(),
240            tool: self.tool_name().map(str::to_string),
241            suggested_tool: self.suggested_tool().map(str::to_string),
242            suggested_action: self.suggested_action().map(str::to_string),
243        }
244    }
245
246    /// Convert to a JSON string suitable for the LLM to parse.
247    pub fn to_llm_response(&self) -> String {
248        serde_json::to_string(&self.to_llm_payload()).expect("tool error payload serializes")
249    }
250
251    /// Whether the LLM should retry with different parameters.
252    pub fn is_retryable(&self) -> bool {
253        matches!(
254            self,
255            ToolError::Timeout { .. } | ToolError::Unavailable { .. }
256        )
257    }
258
259    pub fn should_suppress_retry(&self) -> bool {
260        matches!(
261            self,
262            ToolError::Unavailable { .. }
263                | ToolError::PermissionDenied(_)
264                | ToolError::CapabilityDenied { .. }
265        )
266    }
267
268    pub fn category(&self) -> &'static str {
269        match self {
270            ToolError::NotFound(_) => "resolution",
271            ToolError::InvalidArgs { .. } => "arguments",
272            ToolError::Unavailable { .. } => "availability",
273            ToolError::Timeout { .. } => "timeout",
274            ToolError::PermissionDenied(_) => "permission",
275            ToolError::ExecutionFailed { .. } => "execution",
276            ToolError::CapabilityDenied { .. } => "capability",
277            ToolError::Other(_) => "other",
278        }
279    }
280
281    pub fn code(&self) -> &str {
282        match self {
283            ToolError::NotFound(_) => "tool_not_found",
284            ToolError::InvalidArgs { .. } => "invalid_arguments",
285            ToolError::Unavailable { .. } => "tool_unavailable",
286            ToolError::Timeout { .. } => "tool_timeout",
287            ToolError::PermissionDenied(_) => "permission_denied",
288            ToolError::ExecutionFailed { .. } => "execution_failed",
289            ToolError::CapabilityDenied { code, .. } => code,
290            ToolError::Other(_) => "tool_error",
291        }
292    }
293
294    pub fn tool_name(&self) -> Option<&str> {
295        match self {
296            ToolError::InvalidArgs { tool, .. }
297            | ToolError::Unavailable { tool, .. }
298            | ToolError::Timeout { tool, .. }
299            | ToolError::ExecutionFailed { tool, .. }
300            | ToolError::CapabilityDenied { tool, .. } => Some(tool),
301            ToolError::NotFound(_) | ToolError::PermissionDenied(_) | ToolError::Other(_) => None,
302        }
303    }
304
305    pub fn suggested_tool(&self) -> Option<&str> {
306        match self {
307            ToolError::CapabilityDenied { suggested_tool, .. } => suggested_tool.as_deref(),
308            _ => None,
309        }
310    }
311
312    pub fn suppression_key(&self) -> Option<String> {
313        match self {
314            ToolError::Unavailable { tool, .. } => Some(format!("{tool}:{}", self.code())),
315            ToolError::PermissionDenied(_) => Some(self.code().to_string()),
316            ToolError::CapabilityDenied {
317                tool,
318                code,
319                suppression_key,
320                ..
321            } => Some(
322                suppression_key
323                    .clone()
324                    .unwrap_or_else(|| format!("{tool}:{code}")),
325            ),
326            _ => None,
327        }
328    }
329
330    pub fn suggested_action(&self) -> Option<&str> {
331        match self {
332            ToolError::CapabilityDenied {
333                suggested_action, ..
334            } => suggested_action.as_deref(),
335            _ => None,
336        }
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn agent_error_display() {
346        let err = AgentError::BudgetExhausted { used: 90, max: 90 };
347        assert_eq!(err.to_string(), "Budget exhausted: 90/90 iterations");
348    }
349
350    #[test]
351    fn tool_error_to_llm_response_retryable() {
352        let err = ToolError::Timeout {
353            tool: "terminal".into(),
354            seconds: 30,
355        };
356        let json: serde_json::Value =
357            serde_json::from_str(&err.to_llm_response()).expect("valid json");
358        assert_eq!(json["retryable"], true);
359        assert_eq!(json["category"], "timeout");
360        assert_eq!(json["code"], "tool_timeout");
361        assert_eq!(json["tool"], "terminal");
362    }
363
364    #[test]
365    fn tool_error_to_llm_response_not_retryable() {
366        let err = ToolError::NotFound("nonexistent".into());
367        let json: serde_json::Value =
368            serde_json::from_str(&err.to_llm_response()).expect("valid json");
369        assert_eq!(json["retryable"], false);
370        assert_eq!(json["suppress_retry"], false);
371    }
372
373    #[test]
374    fn capability_error_serializes_with_suggestions() {
375        let err = ToolError::capability_denied(
376            "terminal",
377            "macos_automation_unknown",
378            "Automation consent could not be determined.",
379        )
380        .with_suggested_tool("clarify")
381        .with_suppression_key("terminal:macos_automation_unknown:notes")
382        .with_suggested_action("Open Notes.app, run /permissions bootstrap, then retry.");
383
384        let json: serde_json::Value =
385            serde_json::from_str(&err.to_llm_response()).expect("valid json");
386        assert_eq!(json["type"], "tool_error");
387        assert_eq!(json["category"], "capability");
388        assert_eq!(json["code"], "macos_automation_unknown");
389        assert_eq!(json["retryable"], false);
390        assert_eq!(json["suppress_retry"], true);
391        assert_eq!(
392            json["suppression_key"],
393            "terminal:macos_automation_unknown:notes"
394        );
395        assert_eq!(json["tool"], "terminal");
396        assert_eq!(json["suggested_tool"], "clarify");
397        assert_eq!(
398            json["suggested_action"],
399            "Open Notes.app, run /permissions bootstrap, then retry."
400        );
401    }
402
403    #[test]
404    fn tool_error_invalid_args() {
405        let err = ToolError::InvalidArgs {
406            tool: "read_file".into(),
407            message: "path is required".into(),
408        };
409        assert_eq!(
410            err.to_string(),
411            "Invalid arguments for read_file: path is required"
412        );
413        assert!(!err.is_retryable());
414    }
415
416    #[test]
417    fn agent_error_from_io() {
418        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
419        let agent_err: AgentError = io_err.into();
420        assert!(agent_err.to_string().contains("file not found"));
421    }
422
423    #[test]
424    fn agent_error_from_serde() {
425        let serde_err =
426            serde_json::from_str::<serde_json::Value>("bad json").expect_err("should fail");
427        let agent_err: AgentError = serde_err.into();
428        assert!(agent_err.to_string().contains("Serialization"));
429    }
430}