1use serde_json;
7
8#[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#[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#[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 pub fn to_llm_response(&self) -> String {
248 serde_json::to_string(&self.to_llm_payload()).expect("tool error payload serializes")
249 }
250
251 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}