Skip to main content

meerkat_core/
error.rs

1//! Core error types for Meerkat
2
3use crate::hooks::{HookPoint, HookReasonCode};
4use crate::types::SessionId;
5
6#[derive(Debug, Clone, PartialEq)]
7pub enum LlmFailureReason {
8    RateLimited {
9        retry_after: Option<std::time::Duration>,
10    },
11    ContextExceeded {
12        max: u32,
13        requested: u32,
14    },
15    AuthError,
16    InvalidModel(String),
17    ProviderError(serde_json::Value),
18}
19
20/// Errors that can occur during tool validation
21#[derive(Debug, Clone, thiserror::Error, PartialEq)]
22pub enum ToolValidationError {
23    /// The requested tool was not found
24    #[error("Tool not found: {name}")]
25    NotFound { name: String },
26    /// The tool arguments failed validation
27    #[error("Invalid arguments for tool '{name}': {reason}")]
28    InvalidArguments { name: String, reason: String },
29}
30
31impl ToolValidationError {
32    pub fn not_found(name: impl Into<String>) -> Self {
33        Self::NotFound { name: name.into() }
34    }
35    pub fn invalid_arguments(name: impl Into<String>, reason: impl Into<String>) -> Self {
36        Self::InvalidArguments {
37            name: name.into(),
38            reason: reason.into(),
39        }
40    }
41}
42
43/// Error returned by tool dispatch operations.
44#[derive(Debug, Clone, thiserror::Error)]
45pub enum ToolError {
46    /// The requested tool was not found
47    #[error("Tool not found: {name}")]
48    NotFound { name: String },
49
50    /// The tool exists but is currently unavailable
51    #[error("Tool '{name}' is currently unavailable: {reason}")]
52    Unavailable { name: String, reason: String },
53
54    /// The tool arguments failed validation
55    #[error("Invalid arguments for tool '{name}': {reason}")]
56    InvalidArguments { name: String, reason: String },
57
58    /// The tool execution failed
59    #[error("Tool execution failed: {message}")]
60    ExecutionFailed { message: String },
61
62    /// The tool execution timed out
63    #[error("Tool '{name}' timed out after {timeout_ms}ms")]
64    Timeout { name: String, timeout_ms: u64 },
65
66    /// Tool access was denied by policy
67    #[error("Tool '{name}' is not allowed by policy")]
68    AccessDenied { name: String },
69
70    /// A generic tool error with a message
71    #[error("{0}")]
72    Other(String),
73
74    /// Tool call must be routed externally (callback pending)
75    ///
76    /// This variant signals that a tool call cannot be handled internally
77    /// and must be routed to an external handler. The payload contains
78    /// serialized information about the pending tool call.
79    #[error("Callback pending for tool '{tool_name}'")]
80    CallbackPending {
81        tool_name: String,
82        args: serde_json::Value,
83    },
84}
85
86impl ToolError {
87    pub fn error_code(&self) -> &'static str {
88        match self {
89            Self::NotFound { .. } => "tool_not_found",
90            Self::Unavailable { .. } => "tool_unavailable",
91            Self::InvalidArguments { .. } => "invalid_arguments",
92            Self::ExecutionFailed { .. } => "execution_failed",
93            Self::Timeout { .. } => "timeout",
94            Self::AccessDenied { .. } => "access_denied",
95            Self::Other(_) => "tool_error",
96            Self::CallbackPending { .. } => "callback_pending",
97        }
98    }
99
100    pub fn to_error_payload(&self) -> serde_json::Value {
101        serde_json::json!({
102            "error": self.error_code(),
103            "message": self.to_string(),
104        })
105    }
106
107    pub fn not_found(name: impl Into<String>) -> Self {
108        Self::NotFound { name: name.into() }
109    }
110    pub fn unavailable(name: impl Into<String>, reason: impl Into<String>) -> Self {
111        Self::Unavailable {
112            name: name.into(),
113            reason: reason.into(),
114        }
115    }
116    pub fn invalid_arguments(name: impl Into<String>, reason: impl Into<String>) -> Self {
117        Self::InvalidArguments {
118            name: name.into(),
119            reason: reason.into(),
120        }
121    }
122    pub fn execution_failed(message: impl Into<String>) -> Self {
123        Self::ExecutionFailed {
124            message: message.into(),
125        }
126    }
127    pub fn timeout(name: impl Into<String>, timeout_ms: u64) -> Self {
128        Self::Timeout {
129            name: name.into(),
130            timeout_ms,
131        }
132    }
133    pub fn access_denied(name: impl Into<String>) -> Self {
134        Self::AccessDenied { name: name.into() }
135    }
136    pub fn other(message: impl Into<String>) -> Self {
137        Self::Other(message.into())
138    }
139
140    /// Create a callback pending error for external tool routing
141    pub fn callback_pending(tool_name: impl Into<String>, args: serde_json::Value) -> Self {
142        Self::CallbackPending {
143            tool_name: tool_name.into(),
144            args,
145        }
146    }
147
148    /// Check if this is a callback pending error
149    pub fn is_callback_pending(&self) -> bool {
150        matches!(self, Self::CallbackPending { .. })
151    }
152
153    /// Extract callback pending info if this is a CallbackPending error
154    pub fn as_callback_pending(&self) -> Option<(&str, &serde_json::Value)> {
155        match self {
156            Self::CallbackPending { tool_name, args } => Some((tool_name, args)),
157            _ => None,
158        }
159    }
160}
161
162impl From<String> for ToolError {
163    fn from(s: String) -> Self {
164        Self::Other(s)
165    }
166}
167impl From<&str> for ToolError {
168    fn from(s: &str) -> Self {
169        Self::Other(s.to_string())
170    }
171}
172
173/// Errors that can occur during agent execution
174#[derive(Debug, thiserror::Error)]
175#[non_exhaustive]
176pub enum AgentError {
177    #[error("LLM error ({provider}): {message}")]
178    Llm {
179        provider: &'static str,
180        reason: LlmFailureReason,
181        message: String,
182    },
183    #[error("Storage error: {0}")]
184    StoreError(String),
185    #[error("Tool error: {0}")]
186    ToolError(String),
187    #[error("MCP error: {0}")]
188    McpError(String),
189    #[error("Session not found: {0}")]
190    SessionNotFound(SessionId),
191    #[error("Token budget exceeded: used {used}, limit {limit}")]
192    TokenBudgetExceeded { used: u64, limit: u64 },
193    #[error("Time budget exceeded: {elapsed_secs}s > {limit_secs}s")]
194    TimeBudgetExceeded { elapsed_secs: u64, limit_secs: u64 },
195    #[error("Tool call budget exceeded: {count} calls > {limit} limit")]
196    ToolCallBudgetExceeded { count: usize, limit: usize },
197    #[error("Max tokens reached on turn {turn}, partial output: {partial}")]
198    MaxTokensReached { turn: u32, partial: String },
199    #[error("Content filtered on turn {turn}")]
200    ContentFiltered { turn: u32 },
201    #[error("Max turns reached: {turns}")]
202    MaxTurnsReached { turns: u32 },
203    #[error("Run was cancelled")]
204    Cancelled,
205    #[error("Invalid state transition: {from} -> {to}")]
206    InvalidStateTransition { from: String, to: String },
207    #[error("Operation not found: {0}")]
208    OperationNotFound(String),
209    #[error("Depth limit exceeded: {depth} > {max}")]
210    DepthLimitExceeded { depth: u32, max: u32 },
211    #[error("Concurrency limit exceeded")]
212    ConcurrencyLimitExceeded,
213    #[error("Configuration error: {0}")]
214    ConfigError(String),
215    #[error("Sub-agent limit exceeded: max {limit} concurrent sub-agents")]
216    SubAgentLimitExceeded { limit: usize },
217    #[error("Sub-agent not found: {id}")]
218    SubAgentNotFound { id: String },
219    #[error("Sub-agent {id} not running (state: {state})")]
220    SubAgentNotRunning { id: String, state: String },
221    #[error("Invalid tool in access policy: {tool}")]
222    InvalidToolAccess { tool: String },
223    #[error("Sub-agent spawn failed: {reason}")]
224    SubAgentSpawnFailed { reason: String },
225    #[error("Internal error: {0}")]
226    InternalError(String),
227
228    /// A tool call must be routed externally (callback pending)
229    #[error("Callback pending for tool '{tool_name}'")]
230    CallbackPending {
231        tool_name: String,
232        args: serde_json::Value,
233    },
234
235    /// Structured output validation failed after retries
236    #[error("Structured output validation failed after {attempts} attempts: {reason}")]
237    StructuredOutputValidationFailed {
238        attempts: u32,
239        reason: String,
240        last_output: String,
241    },
242
243    /// Invalid output schema provided
244    #[error("Invalid output schema: {0}")]
245    InvalidOutputSchema(String),
246
247    #[error("Hook denied at {point:?}: {reason_code:?} - {message}")]
248    HookDenied {
249        point: HookPoint,
250        reason_code: HookReasonCode,
251        message: String,
252        payload: Option<serde_json::Value>,
253    },
254
255    #[error("Hook '{hook_id}' timed out after {timeout_ms}ms")]
256    HookTimeout { hook_id: String, timeout_ms: u64 },
257
258    #[error("Hook execution failed for '{hook_id}': {reason}")]
259    HookExecutionFailed { hook_id: String, reason: String },
260
261    #[error("Hook configuration invalid: {reason}")]
262    HookConfigInvalid { reason: String },
263}
264
265impl AgentError {
266    pub fn llm(
267        provider: &'static str,
268        reason: LlmFailureReason,
269        message: impl Into<String>,
270    ) -> Self {
271        Self::Llm {
272            provider,
273            reason,
274            message: message.into(),
275        }
276    }
277    pub fn is_graceful(&self) -> bool {
278        matches!(
279            self,
280            Self::TokenBudgetExceeded { .. }
281                | Self::TimeBudgetExceeded { .. }
282                | Self::ToolCallBudgetExceeded { .. }
283                | Self::MaxTurnsReached { .. }
284        )
285    }
286    pub fn is_recoverable(&self) -> bool {
287        match self {
288            Self::Llm { reason, .. } => match reason {
289                LlmFailureReason::RateLimited { .. } => true,
290                LlmFailureReason::ProviderError(value) => {
291                    value.get("retryable").and_then(serde_json::Value::as_bool) == Some(true)
292                }
293                _ => false,
294            },
295            _ => false,
296        }
297    }
298}
299
300pub fn store_error(err: impl std::fmt::Display) -> AgentError {
301    AgentError::StoreError(store_error_message(err))
302}
303pub fn invalid_session_id(err: impl std::fmt::Display) -> AgentError {
304    AgentError::StoreError(invalid_session_id_message(err))
305}
306pub fn store_error_message(err: impl std::fmt::Display) -> String {
307    err.to_string()
308}
309pub fn invalid_session_id_message(err: impl std::fmt::Display) -> String {
310    format!("Invalid session ID: {err}")
311}