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("{message}")]
136 ContentMismatch {
137 tool: String,
139 path: String,
141 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 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 #[serde(skip_serializing_if = "Option::is_none")]
183 pub required_fields: Option<Vec<String>>,
184 #[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 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 pub fn to_llm_response(&self) -> String {
306 serde_json::to_string(&self.to_llm_payload()).expect("tool error payload serializes")
307 }
308
309 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 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}