1use crate::provider::{ProviderError, ProviderId};
2use crate::runtime::control::is_transient_provider_error;
3use thiserror::Error;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ErrorCategory {
8 Retryable,
10 Terminal,
12 Degraded,
14}
15
16#[derive(Debug, Error)]
18pub enum RuntimeError {
19 #[error("{message}", message = provider_not_found_message(.0))]
20 ProviderNotFound(Option<ProviderId>),
21 #[error("provider '{0}' did not return any models")]
22 NoModelsAvailable(ProviderId),
23 #[error("failed to send provider request: {0}")]
24 FailedToSendRequest(#[source] ProviderError),
25 #[error("failed to list provider models: {0}")]
26 FailedToListModels(#[source] ProviderError),
27 #[error("failed to create a fresh provider session scope: {0}")]
28 FailedToCreateProviderSessionScope(#[source] ProviderError),
29 #[error(
30 "fresh provider session scope changed provider identity from '{expected}' to '{actual}'"
31 )]
32 ProviderSessionScopeIdentityMismatch {
33 expected: ProviderId,
34 actual: ProviderId,
35 },
36 #[error("failed to stream provider response: {0}")]
37 FailedToStreamResponse(#[source] ProviderError),
38 #[error("failed to compact history: {0}")]
39 FailedToCompactHistory(#[source] ProviderError),
40 #[error("failed to persist transcript: {0}")]
41 FailedToPersistTranscript(#[source] std::io::Error),
42 #[error("failed to serialize transcript: {0}")]
43 FailedToSerializeTranscript(#[source] serde_json::Error),
44 #[error("failed to load tasks: {0}")]
45 FailedToLoadTasks(#[source] std::io::Error),
46 #[error("failed to write tasks: {0}")]
47 FailedToWriteTasks(#[source] std::io::Error),
48 #[error("failed to serialize tasks: {0}")]
49 FailedToSerializeTasks(#[source] serde_json::Error),
50 #[error("failed to restore tasks: {0}")]
51 FailedToRestoreTasks(#[source] std::io::Error),
52 #[error("failed to load team state: {0}")]
53 FailedToLoadTeam(#[source] std::io::Error),
54 #[error("failed to write team state: {0}")]
55 FailedToWriteTeam(#[source] std::io::Error),
56 #[error("failed to serialize team state: {0}")]
57 FailedToSerializeTeam(#[source] serde_json::Error),
58 #[error("failed to deserialize team state: {0}")]
59 FailedToDeserializeTeam(#[source] serde_json::Error),
60 #[error("invalid task state: {0}")]
61 InvalidTask(String),
62 #[error("invalid team state: {0}")]
63 InvalidTeam(String),
64 #[error("operation denied: {0}")]
65 OperationDenied(String),
66 #[error("runtime store error: {0}")]
67 Store(String),
68 #[error("cannot branch: {0}")]
69 Branch(#[source] crate::transcript::BranchError),
70 #[error("lease unavailable: {0}")]
71 LeaseUnavailable(String),
72 #[error("operation cancelled")]
73 Cancelled,
74 #[error("deadline exceeded")]
75 DeadlineExceeded,
76 #[error("tool budget exceeded at {0} call(s)")]
77 ToolBudgetExceeded(usize),
78 #[error("model budget exceeded at {0} request(s)")]
79 ModelBudgetExceeded(usize),
80 #[error("max rounds exceeded at {0}")]
81 MaxRoundsExceeded(usize),
82 #[error("run completed without a final assistant message")]
83 EmptyAssistantResponse,
84 #[error("no resumable user turn is available")]
85 NoResumableTurn,
86 #[error("no interrupted turn is available to continue")]
89 NoInterruptedTurn,
90 #[error("invalid tool input for '{name}' ({id}): {source}")]
91 InvalidToolUseInput {
92 id: String,
93 name: String,
94 #[source]
95 source: serde_json::Error,
96 },
97 #[error("malformed provider event: {0}")]
98 MalformedProviderEvent(String),
99 #[error(
100 "subagent template built from '{template_source}' cannot be spawned by '{receiver}': \
101 a template is only spawnable by the agent (on the same runtime) it was built from"
102 )]
103 SubagentTemplateMismatch {
104 template_source: String,
105 receiver: String,
106 },
107}
108
109impl RuntimeError {
110 pub fn category(&self) -> ErrorCategory {
113 match self {
114 Self::FailedToSendRequest(source)
116 | Self::FailedToStreamResponse(source)
117 | Self::FailedToCompactHistory(source) => {
118 if is_transient_provider_error(source) {
119 ErrorCategory::Retryable
120 } else {
121 ErrorCategory::Terminal
122 }
123 }
124
125 Self::FailedToListModels(_) => ErrorCategory::Terminal,
127
128 Self::ProviderNotFound(_)
130 | Self::NoModelsAvailable(_)
131 | Self::FailedToCreateProviderSessionScope(_)
132 | Self::ProviderSessionScopeIdentityMismatch { .. }
133 | Self::OperationDenied(_)
134 | Self::Cancelled
135 | Self::DeadlineExceeded
136 | Self::ToolBudgetExceeded(_)
137 | Self::ModelBudgetExceeded(_)
138 | Self::MaxRoundsExceeded(_)
139 | Self::EmptyAssistantResponse
140 | Self::NoResumableTurn
141 | Self::NoInterruptedTurn
142 | Self::InvalidToolUseInput { .. }
143 | Self::MalformedProviderEvent(_)
144 | Self::InvalidTask(_)
145 | Self::InvalidTeam(_)
146 | Self::SubagentTemplateMismatch { .. }
147 | Self::Branch(_) => ErrorCategory::Terminal,
150
151 Self::FailedToPersistTranscript(_)
153 | Self::FailedToSerializeTranscript(_)
154 | Self::FailedToLoadTasks(_)
155 | Self::FailedToWriteTasks(_)
156 | Self::FailedToSerializeTasks(_)
157 | Self::FailedToRestoreTasks(_)
158 | Self::FailedToLoadTeam(_)
159 | Self::FailedToWriteTeam(_)
160 | Self::FailedToSerializeTeam(_)
161 | Self::FailedToDeserializeTeam(_)
162 | Self::Store(_)
163 | Self::LeaseUnavailable(_) => ErrorCategory::Degraded,
164 }
165 }
166}
167
168fn provider_not_found_message(provider: &Option<ProviderId>) -> String {
169 match provider {
170 Some(provider) => format!("provider '{provider}' is not registered"),
171 None => "no providers are registered".to_string(),
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use std::error::Error;
178
179 use super::{ErrorCategory, RuntimeError};
180 use crate::provider::{ProviderError, ProviderId};
181
182 #[test]
183 fn display_mentions_missing_provider() {
184 let error = RuntimeError::ProviderNotFound(Some(ProviderId::new("custom")));
185
186 assert_eq!(error.to_string(), "provider 'custom' is not registered");
187 }
188
189 #[test]
190 fn display_mentions_missing_models() {
191 let error = RuntimeError::NoModelsAvailable(ProviderId::new("custom"));
192
193 assert_eq!(
194 error.to_string(),
195 "provider 'custom' did not return any models"
196 );
197 }
198
199 #[test]
200 fn source_is_exposed_for_wrapped_errors() {
201 let error = RuntimeError::FailedToSerializeTasks(
202 serde_json::from_str::<serde_json::Value>("{").expect_err("invalid json"),
203 );
204
205 assert!(error.source().is_some());
206 }
207
208 #[test]
209 fn empty_assistant_response_has_clear_display_text() {
210 assert_eq!(
211 RuntimeError::EmptyAssistantResponse.to_string(),
212 "run completed without a final assistant message"
213 );
214 }
215
216 #[test]
217 fn transient_provider_error_is_retryable() {
218 let error = RuntimeError::FailedToSendRequest(ProviderError::Retryable {
219 message: "rate limited".into(),
220 delay: None,
221 });
222
223 assert_eq!(error.category(), ErrorCategory::Retryable);
224 }
225
226 #[test]
227 fn permanent_provider_error_is_terminal() {
228 let error =
229 RuntimeError::FailedToSendRequest(ProviderError::InvalidRequest("bad body".into()));
230
231 assert_eq!(error.category(), ErrorCategory::Terminal);
232 }
233
234 #[test]
235 fn budget_exceeded_is_terminal() {
236 assert_eq!(
237 RuntimeError::ToolBudgetExceeded(100).category(),
238 ErrorCategory::Terminal,
239 );
240 assert_eq!(
241 RuntimeError::ModelBudgetExceeded(50).category(),
242 ErrorCategory::Terminal,
243 );
244 }
245
246 #[test]
247 fn display_names_both_parties_of_a_subagent_template_mismatch() {
248 let error = RuntimeError::SubagentTemplateMismatch {
249 template_source: "agent-a".to_string(),
250 receiver: "agent-b".to_string(),
251 };
252
253 let message = error.to_string();
254 assert!(message.contains("agent-a"));
255 assert!(message.contains("agent-b"));
256 }
257
258 #[test]
259 fn subagent_template_mismatch_is_terminal() {
260 assert_eq!(
261 RuntimeError::SubagentTemplateMismatch {
262 template_source: "agent-a".to_string(),
263 receiver: "agent-b".to_string(),
264 }
265 .category(),
266 ErrorCategory::Terminal,
267 );
268 }
269
270 #[test]
271 fn persistence_io_error_is_degraded() {
272 let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "disk full");
273 let error = RuntimeError::FailedToPersistTranscript(io_err);
274
275 assert_eq!(error.category(), ErrorCategory::Degraded);
276 }
277}