1use thiserror::Error;
2
3#[derive(Error, Debug)]
4pub enum SoulError {
5 #[error("Provider error: {0}")]
6 Provider(String),
7
8 #[error("Provider rate limited: {provider}, retry after {retry_after_ms}ms")]
9 RateLimited {
10 provider: String,
11 retry_after_ms: u64,
12 },
13
14 #[error("Auth error: {0}")]
15 Auth(String),
16
17 #[error("Tool execution error: tool={tool_name}, {message}")]
18 ToolExecution { tool_name: String, message: String },
19
20 #[error("Context overflow: {used_tokens} tokens used, {max_tokens} max")]
21 ContextOverflow {
22 used_tokens: usize,
23 max_tokens: usize,
24 },
25
26 #[error("Compaction failed: {0}")]
27 CompactionFailed(String),
28
29 #[error("Session error: {0}")]
30 Session(String),
31
32 #[error("Serialization error: {0}")]
33 Serialization(#[from] serde_json::Error),
34
35 #[error("IO error: {0}")]
36 Io(#[from] std::io::Error),
37
38 #[error("HTTP error: {0}")]
39 Http(#[from] reqwest::Error),
40
41 #[error("Agent interrupted")]
42 Interrupted,
43
44 #[error("Failover exhausted: tried {attempts} providers")]
45 FailoverExhausted { attempts: usize },
46
47 #[error("Permission denied: tool={tool_name}, {reason}")]
48 PermissionDenied { tool_name: String, reason: String },
49
50 #[error("Budget exceeded: {message}")]
51 BudgetExceeded { message: String },
52
53 #[error("MCP error: server={server}, {message}")]
54 Mcp { server: String, message: String },
55
56 #[error("JSON-RPC error: code={code}, {message}")]
57 JsonRpc { code: i32, message: String },
58
59 #[error("Skill parse error: {message}")]
60 SkillParse { message: String },
61
62 #[error("Executor not found: {name}")]
63 ExecutorNotFound { name: String },
64
65 #[error("{0}")]
66 Other(#[from] anyhow::Error),
67}
68
69pub type SoulResult<T> = Result<T, SoulError>;
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74
75 #[test]
76 fn error_display_formats() {
77 let err = SoulError::Provider("connection refused".into());
78 assert_eq!(err.to_string(), "Provider error: connection refused");
79
80 let err = SoulError::RateLimited {
81 provider: "anthropic".into(),
82 retry_after_ms: 5000,
83 };
84 assert!(err.to_string().contains("5000ms"));
85
86 let err = SoulError::ContextOverflow {
87 used_tokens: 200_000,
88 max_tokens: 180_000,
89 };
90 assert!(err.to_string().contains("200000"));
91
92 let err = SoulError::ToolExecution {
93 tool_name: "bash".into(),
94 message: "command not found".into(),
95 };
96 assert!(err.to_string().contains("bash"));
97 }
98
99 #[test]
100 fn error_is_send_sync() {
101 fn assert_send_sync<T: Send + Sync>() {}
102 assert_send_sync::<SoulError>();
103 }
104
105 #[test]
106 fn io_error_converts() {
107 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
108 let soul_err: SoulError = io_err.into();
109 assert!(matches!(soul_err, SoulError::Io(_)));
110 }
111
112 #[test]
113 fn json_error_converts() {
114 let json_err = serde_json::from_str::<String>("invalid").unwrap_err();
115 let soul_err: SoulError = json_err.into();
116 assert!(matches!(soul_err, SoulError::Serialization(_)));
117 }
118}