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    /// Content-mismatch error — the tool's expected content (e.g. `old_string`) did not
130    /// match the actual file contents, or the file changed between a read and a write
131    /// (TOCTOU). The `message` field embeds a 600-char file preview when available so the
132    /// model can retry with corrected arguments without an extra `read_file` round-trip.
133    ///
134    /// Numeric code: 1008 (`content_mismatch`).
135    #[error("{message}")]
136    ContentMismatch {
137        /// Tool that produced the mismatch (e.g. `"patch"`, `"write_file"`).
138        tool: String,
139        /// Display path of the affected file.
140        path: String,
141        /// Human-readable description, optionally including a content preview.
142        message: String,
143    },
144
145    #[error("{0}")]
146    Other(String),
147}
148
149#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
150pub struct ToolErrorResponse {
151    #[serde(rename = "type")]
152    pub response_type: String,
153    pub category: String,
154    pub code: String,
155    /// Numeric error code for fast loop branching.
156    ///
157    /// | code_num | code string           | variant            |
158    /// |----------|-----------------------|--------------------|
159    /// | 1001     | tool_not_found        | NotFound           |
160    /// | 1002     | invalid_arguments     | InvalidArgs        |
161    /// | 1003     | tool_unavailable      | Unavailable        |
162    /// | 1004     | tool_timeout          | Timeout            |
163    /// | 1005     | permission_denied     | PermissionDenied   |
164    /// | 1006     | execution_failed      | ExecutionFailed    |
165    /// | 1007     | capability_denied     | CapabilityDenied   |
166    /// | 1008     | content_mismatch      | ContentMismatch    |
167    /// | 1099     | tool_error            | Other              |
168    pub code_num: u16,
169    pub error: String,
170    pub retryable: bool,
171    pub suppress_retry: bool,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub suppression_key: Option<String>,
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub tool: Option<String>,
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub suggested_tool: Option<String>,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    pub suggested_action: Option<String>,
180    /// Required parameter names — populated from tool schema on InvalidArgs.
181    /// Gives the LLM a precise checklist of what to fix.
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub required_fields: Option<Vec<String>>,
184    /// One-line corrective hint — e.g. "content must be a non-null string".
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub usage_hint: Option<String>,
187}
188
189impl ToolError {
190    pub fn capability_denied(
191        tool: impl Into<String>,
192        code: impl Into<String>,
193        message: impl Into<String>,
194    ) -> Self {
195        Self::CapabilityDenied {
196            tool: tool.into(),
197            code: code.into(),
198            message: message.into(),
199            suppression_key: None,
200            suggested_tool: None,
201            suggested_action: None,
202        }
203    }
204
205    pub fn with_suppression_key(self, suppression_key: impl Into<String>) -> Self {
206        match self {
207            Self::CapabilityDenied {
208                tool,
209                code,
210                message,
211                suggested_tool,
212                suggested_action,
213                ..
214            } => Self::CapabilityDenied {
215                tool,
216                code,
217                message,
218                suppression_key: Some(suppression_key.into()),
219                suggested_tool,
220                suggested_action,
221            },
222            other => other,
223        }
224    }
225
226    pub fn with_suggested_tool(self, suggested_tool: impl Into<String>) -> Self {
227        match self {
228            Self::CapabilityDenied {
229                tool,
230                code,
231                message,
232                suppression_key,
233                suggested_action,
234                ..
235            } => Self::CapabilityDenied {
236                tool,
237                code,
238                message,
239                suppression_key,
240                suggested_tool: Some(suggested_tool.into()),
241                suggested_action,
242            },
243            other => other,
244        }
245    }
246
247    pub fn with_suggested_action(self, suggested_action: impl Into<String>) -> Self {
248        match self {
249            Self::CapabilityDenied {
250                tool,
251                code,
252                message,
253                suppression_key,
254                suggested_tool,
255                ..
256            } => Self::CapabilityDenied {
257                tool,
258                code,
259                message,
260                suppression_key,
261                suggested_tool,
262                suggested_action: Some(suggested_action.into()),
263            },
264            other => other,
265        }
266    }
267
268    pub fn to_llm_payload(&self) -> ToolErrorResponse {
269        ToolErrorResponse {
270            response_type: "tool_error".into(),
271            category: self.category().into(),
272            code: self.code().into(),
273            code_num: self.code_num(),
274            error: self.to_string(),
275            retryable: self.is_retryable(),
276            suppress_retry: self.should_suppress_retry(),
277            suppression_key: self.suppression_key(),
278            tool: self.tool_name().map(str::to_string),
279            suggested_tool: self.suggested_tool().map(str::to_string),
280            suggested_action: self.suggested_action().map(str::to_string),
281            required_fields: None,
282            usage_hint: None,
283        }
284    }
285
286    /// Build an enriched LLM payload with schema-derived corrective hints.
287    ///
288    /// WHY: When the LLM sends invalid arguments, a bare "missing field X"
289    /// message forces it to guess the full schema from memory. By echoing
290    /// the required fields and a usage hint, we give it a precise checklist.
291    /// Hermes-agent's `coerce_tool_args` + schema echo pattern reduced
292    /// retry loops by ~40% in their production telemetry.
293    pub fn to_llm_payload_enriched(
294        &self,
295        required_fields: Option<Vec<String>>,
296        usage_hint: Option<String>,
297    ) -> ToolErrorResponse {
298        let mut payload = self.to_llm_payload();
299        payload.required_fields = required_fields;
300        payload.usage_hint = usage_hint;
301        payload
302    }
303
304    /// Convert to a JSON string suitable for the LLM to parse.
305    pub fn to_llm_response(&self) -> String {
306        serde_json::to_string(&self.to_llm_payload()).expect("tool error payload serializes")
307    }
308
309    /// Whether the LLM should retry with different parameters.
310    pub fn is_retryable(&self) -> bool {
311        matches!(
312            self,
313            ToolError::Timeout { .. } | ToolError::Unavailable { .. }
314        )
315    }
316
317    pub fn should_suppress_retry(&self) -> bool {
318        matches!(
319            self,
320            ToolError::InvalidArgs { .. }
321                | ToolError::Unavailable { .. }
322                | ToolError::PermissionDenied(_)
323                | ToolError::CapabilityDenied { .. }
324                | ToolError::ContentMismatch { .. }
325        )
326    }
327
328    pub fn category(&self) -> &'static str {
329        match self {
330            ToolError::NotFound(_) => "resolution",
331            ToolError::InvalidArgs { .. } => "arguments",
332            ToolError::Unavailable { .. } => "availability",
333            ToolError::Timeout { .. } => "timeout",
334            ToolError::PermissionDenied(_) => "permission",
335            ToolError::ExecutionFailed { .. } => "execution",
336            ToolError::CapabilityDenied { .. } => "capability",
337            ToolError::ContentMismatch { .. } => "content",
338            ToolError::Other(_) => "other",
339        }
340    }
341
342    pub fn code(&self) -> &str {
343        match self {
344            ToolError::NotFound(_) => "tool_not_found",
345            ToolError::InvalidArgs { .. } => "invalid_arguments",
346            ToolError::Unavailable { .. } => "tool_unavailable",
347            ToolError::Timeout { .. } => "tool_timeout",
348            ToolError::PermissionDenied(_) => "permission_denied",
349            ToolError::ExecutionFailed { .. } => "execution_failed",
350            ToolError::CapabilityDenied { code, .. } => code,
351            ToolError::ContentMismatch { .. } => "content_mismatch",
352            ToolError::Other(_) => "tool_error",
353        }
354    }
355
356    /// Numeric error code for fast loop branching.
357    ///
358    /// Maps each variant to a stable integer that survives refactors to the
359    /// string code. The conversation loop can branch on `code_num` instead of
360    /// `code.as_str()` comparisons for better performance and clarity.
361    pub fn code_num(&self) -> u16 {
362        match self {
363            ToolError::NotFound(_) => 1001,
364            ToolError::InvalidArgs { .. } => 1002,
365            ToolError::Unavailable { .. } => 1003,
366            ToolError::Timeout { .. } => 1004,
367            ToolError::PermissionDenied(_) => 1005,
368            ToolError::ExecutionFailed { .. } => 1006,
369            ToolError::CapabilityDenied { .. } => 1007,
370            ToolError::ContentMismatch { .. } => 1008,
371            ToolError::Other(_) => 1099,
372        }
373    }
374
375    pub fn tool_name(&self) -> Option<&str> {
376        match self {
377            ToolError::InvalidArgs { tool, .. }
378            | ToolError::Unavailable { tool, .. }
379            | ToolError::Timeout { tool, .. }
380            | ToolError::ExecutionFailed { tool, .. }
381            | ToolError::CapabilityDenied { tool, .. }
382            | ToolError::ContentMismatch { tool, .. } => Some(tool),
383            ToolError::NotFound(_) | ToolError::PermissionDenied(_) | ToolError::Other(_) => None,
384        }
385    }
386
387    pub fn suggested_tool(&self) -> Option<&str> {
388        match self {
389            ToolError::CapabilityDenied { suggested_tool, .. } => suggested_tool.as_deref(),
390            _ => None,
391        }
392    }
393
394    pub fn suppression_key(&self) -> Option<String> {
395        match self {
396            ToolError::Unavailable { tool, .. } => Some(format!("{tool}:{}", self.code())),
397            ToolError::PermissionDenied(_) => Some(self.code().to_string()),
398            ToolError::CapabilityDenied {
399                tool,
400                code,
401                suppression_key,
402                ..
403            } => Some(
404                suppression_key
405                    .clone()
406                    .unwrap_or_else(|| format!("{tool}:{code}")),
407            ),
408            ToolError::ContentMismatch { tool, path, .. } => {
409                Some(format!("{tool}:content_mismatch:{path}"))
410            }
411            _ => None,
412        }
413    }
414
415    pub fn suggested_action(&self) -> Option<&str> {
416        match self {
417            ToolError::CapabilityDenied {
418                suggested_action, ..
419            } => suggested_action.as_deref(),
420            _ => None,
421        }
422    }
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428
429    #[test]
430    fn agent_error_display() {
431        let err = AgentError::BudgetExhausted { used: 90, max: 90 };
432        assert_eq!(err.to_string(), "Budget exhausted: 90/90 iterations");
433    }
434
435    #[test]
436    fn tool_error_to_llm_response_retryable() {
437        let err = ToolError::Timeout {
438            tool: "terminal".into(),
439            seconds: 30,
440        };
441        let json: serde_json::Value =
442            serde_json::from_str(&err.to_llm_response()).expect("valid json");
443        assert_eq!(json["retryable"], true);
444        assert_eq!(json["category"], "timeout");
445        assert_eq!(json["code"], "tool_timeout");
446        assert_eq!(json["code_num"], 1004);
447        assert_eq!(json["tool"], "terminal");
448    }
449
450    #[test]
451    fn tool_error_to_llm_response_not_retryable() {
452        let err = ToolError::NotFound("nonexistent".into());
453        let json: serde_json::Value =
454            serde_json::from_str(&err.to_llm_response()).expect("valid json");
455        assert_eq!(json["retryable"], false);
456        assert_eq!(json["suppress_retry"], false);
457        assert_eq!(json["code_num"], 1001);
458    }
459
460    #[test]
461    fn capability_error_serializes_with_suggestions() {
462        let err = ToolError::capability_denied(
463            "terminal",
464            "macos_automation_unknown",
465            "Automation consent could not be determined.",
466        )
467        .with_suggested_tool("clarify")
468        .with_suppression_key("terminal:macos_automation_unknown:notes")
469        .with_suggested_action("Open Notes.app, run /permissions bootstrap, then retry.");
470
471        let json: serde_json::Value =
472            serde_json::from_str(&err.to_llm_response()).expect("valid json");
473        assert_eq!(json["type"], "tool_error");
474        assert_eq!(json["category"], "capability");
475        assert_eq!(json["code"], "macos_automation_unknown");
476        assert_eq!(json["retryable"], false);
477        assert_eq!(json["suppress_retry"], true);
478        assert_eq!(
479            json["suppression_key"],
480            "terminal:macos_automation_unknown:notes"
481        );
482        assert_eq!(json["tool"], "terminal");
483        assert_eq!(json["suggested_tool"], "clarify");
484        assert_eq!(
485            json["suggested_action"],
486            "Open Notes.app, run /permissions bootstrap, then retry."
487        );
488    }
489
490    #[test]
491    fn tool_error_invalid_args() {
492        let err = ToolError::InvalidArgs {
493            tool: "read_file".into(),
494            message: "path is required".into(),
495        };
496        assert_eq!(
497            err.to_string(),
498            "Invalid arguments for read_file: path is required"
499        );
500        assert!(!err.is_retryable());
501        assert!(err.should_suppress_retry());
502    }
503
504    #[test]
505    fn content_mismatch_code_num_and_category() {
506        let err = ToolError::ContentMismatch {
507            tool: "patch".into(),
508            path: "src/main.rs".into(),
509            message: "old_string not found in file".into(),
510        };
511        let json: serde_json::Value =
512            serde_json::from_str(&err.to_llm_response()).expect("valid json");
513        assert_eq!(json["code"], "content_mismatch");
514        assert_eq!(json["code_num"], 1008);
515        assert_eq!(json["category"], "content");
516        assert_eq!(json["tool"], "patch");
517        assert_eq!(json["suppress_retry"], true);
518        assert_eq!(json["retryable"], false);
519    }
520
521    #[test]
522    fn agent_error_from_io() {
523        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
524        let agent_err: AgentError = io_err.into();
525        assert!(agent_err.to_string().contains("file not found"));
526    }
527
528    #[test]
529    fn agent_error_from_serde() {
530        let serde_err =
531            serde_json::from_str::<serde_json::Value>("bad json").expect_err("should fail");
532        let agent_err: AgentError = serde_err.into();
533        assert!(agent_err.to_string().contains("Serialization"));
534    }
535}