1use std::collections::BTreeMap;
2
3use runifold_core::{
4 BudgetExceeded, CheckpointError, JournalError, RetrySafety, RunError, RunErrorKind,
5};
6use runifold_effect::{EffectExecutorError, EffectExecutorErrorKind};
7use runifold_model::{ModelError, StructuredOutputErrorKind};
8use runifold_retrieval::RetrievalError;
9use runifold_tool::{ToolError, ToolErrorKind};
10use thiserror::Error;
11
12use crate::{GatewayError, GatewayErrorKind, TerminalReviewError};
13
14#[derive(Debug, Error)]
16#[non_exhaustive]
17pub enum AgentError {
18 #[error("model invocation failed: {0}")]
20 Model(#[from] ModelError),
21 #[error("tool execution failed: {0}")]
23 Tool(#[from] ToolError),
24 #[error("agent retrieval failed: {0}")]
26 Retrieval(#[from] RetrievalError),
27 #[error("agent budget exceeded: {0}")]
29 Budget(#[from] BudgetExceeded),
30 #[error("agent delegation failed: {0}")]
32 Gateway(#[from] GatewayError),
33 #[error("agent observability failed: {0}")]
35 Journal(#[from] JournalError),
36 #[error("agent checkpoint failed: {0}")]
38 Checkpoint(#[from] CheckpointError),
39 #[error("agent effect failed: {0}")]
41 Effect(#[from] EffectExecutorError),
42 #[error("checkpoint contains an ambiguous in-flight turn {turn}")]
44 AmbiguousCheckpoint {
45 turn: u32,
47 },
48 #[error("checkpoint contains an ambiguous in-flight terminal review attempt {attempt}")]
50 AmbiguousTerminalReview {
51 attempt: u32,
53 },
54 #[error("checkpoint contains an ambiguous in-flight review for model turn {turn}")]
56 AmbiguousTurnReview {
57 turn: u32,
59 },
60 #[error("terminal reviewer requested unavailable capability `{capability}`")]
62 TerminalReviewAuthorityEscalation {
63 capability: String,
65 },
66 #[error("turn reviewer requested unavailable capability `{capability}`")]
68 TurnReviewAuthorityEscalation {
69 capability: String,
71 },
72 #[error("terminal review failed: {0}")]
74 TerminalReview(#[from] TerminalReviewError),
75 #[error("turn review failed: {0}")]
77 TurnReview(TerminalReviewError),
78 #[error("terminal candidate was rejected by reviewer: {reason}")]
80 TerminalReviewRejected {
81 reason: String,
83 },
84 #[error("terminal review remained unsatisfied after {attempts} repair attempts")]
86 TerminalReviewExhausted {
87 attempts: u32,
89 },
90 #[error("model turn was rejected by reviewer: {reason}")]
92 TurnReviewRejected {
93 reason: String,
95 },
96 #[error("turn review remained unsatisfied after {attempts} repair attempts")]
98 TurnReviewExhausted {
99 attempts: u32,
101 },
102 #[error("invalid agent configuration: {0}")]
104 InvalidConfig(String),
105 #[error("agent protocol error: {0}")]
107 Protocol(String),
108 #[error("agent exceeded its local maximum of {max_turns} turns")]
110 MaxTurns {
111 max_turns: u32,
113 },
114 #[error(
116 "agent completed only {successful} successful local Tool calls; at least {required} required"
117 )]
118 ToolRequirementUnsatisfied {
119 required: u32,
121 successful: u32,
123 },
124 #[error(
126 "agent requires {required} successful local Tool calls but only {remaining} Tool calls remain in the shared budget"
127 )]
128 ToolRequirementExceedsBudget {
129 required: u32,
131 remaining: u64,
133 },
134 #[error("model produced no usable terminal content after {attempts} repair attempts")]
136 EmptyTerminalResponse {
137 attempts: u32,
139 },
140 #[error(
142 "structured terminal output remained unsatisfied after {attempts} repair attempts: {kind:?}"
143 )]
144 StructuredOutputUnsatisfied {
145 attempts: u32,
147 kind: StructuredOutputErrorKind,
149 line: Option<usize>,
151 column: Option<usize>,
153 },
154 #[error("tool `{tool}` returned host-only output")]
156 ToolOutputNotVisible {
157 tool: String,
159 },
160}
161
162impl AgentError {
163 pub fn diagnostic_code(&self) -> &'static str {
166 match self {
167 Self::Model(error) => error.diagnostic_code(),
168 Self::Tool(error) => error.diagnostic_code(),
169 Self::Effect(error) => error.diagnostic_code(),
170 Self::InvalidConfig(_) => "RF-AGENT-001",
171 Self::TerminalReviewAuthorityEscalation { .. }
172 | Self::TurnReviewAuthorityEscalation { .. } => "RF-AGENT-002",
173 Self::AmbiguousCheckpoint { .. }
174 | Self::AmbiguousTerminalReview { .. }
175 | Self::AmbiguousTurnReview { .. } => "RF-AGENT-003",
176 _ => match self.run_error_kind() {
177 RunErrorKind::InvalidInput => "runifold.invalid_input",
178 RunErrorKind::CapabilityDenied => "runifold.capability_denied",
179 RunErrorKind::BudgetExceeded => "runifold.budget_exceeded",
180 RunErrorKind::DeadlineExceeded => "runifold.deadline_exceeded",
181 RunErrorKind::Cancelled => "runifold.cancelled",
182 RunErrorKind::Transport => "runifold.transport",
183 RunErrorKind::Protocol => "runifold.protocol",
184 _ => "runifold.invocation",
185 },
186 }
187 }
188
189 pub fn run_error_kind(&self) -> RunErrorKind {
191 match self {
192 Self::Model(error) => match error.kind {
193 runifold_model::ModelErrorKind::InvalidRequest
194 | runifold_model::ModelErrorKind::UnsupportedFeature => RunErrorKind::InvalidInput,
195 runifold_model::ModelErrorKind::Transport => RunErrorKind::Transport,
196 runifold_model::ModelErrorKind::Cancelled => RunErrorKind::Cancelled,
197 runifold_model::ModelErrorKind::DeadlineExceeded => RunErrorKind::DeadlineExceeded,
198 runifold_model::ModelErrorKind::Protocol
199 | runifold_model::ModelErrorKind::StreamState
200 | runifold_model::ModelErrorKind::MalformedToolArguments => RunErrorKind::Protocol,
201 _ => RunErrorKind::Invocation,
202 },
203 Self::Tool(error) => match error.kind {
204 ToolErrorKind::InvalidInput => RunErrorKind::InvalidInput,
205 ToolErrorKind::CapabilityDenied => RunErrorKind::CapabilityDenied,
206 ToolErrorKind::Cancelled => RunErrorKind::Cancelled,
207 ToolErrorKind::DeadlineExceeded => RunErrorKind::DeadlineExceeded,
208 _ => RunErrorKind::Invocation,
209 },
210 Self::Retrieval(error) => match error {
211 RetrievalError::EmptyDocumentId
212 | RetrievalError::EmptyDocumentText { .. }
213 | RetrievalError::EmptyQuery
214 | RetrievalError::ZeroLimit
215 | RetrievalError::EmptyEmbedding
216 | RetrievalError::NonFiniteEmbedding { .. }
217 | RetrievalError::EmbeddingCoordinateOutOfRange { .. }
218 | RetrievalError::ZeroNormEmbedding
219 | RetrievalError::DimensionMismatch { .. }
220 | RetrievalError::EmbeddingCountMismatch { .. }
221 | RetrievalError::EmptyEmbeddingInput { .. }
222 | RetrievalError::DuplicateDocument(_) => RunErrorKind::InvalidInput,
223 RetrievalError::UsageOverflow => RunErrorKind::BudgetExceeded,
224 RetrievalError::CapabilityDenied { .. } => RunErrorKind::CapabilityDenied,
225 RetrievalError::Cancelled => RunErrorKind::Cancelled,
226 RetrievalError::DeadlineExceeded => RunErrorKind::DeadlineExceeded,
227 _ => RunErrorKind::Invocation,
228 },
229 Self::Budget(_) | Self::MaxTurns { .. } | Self::ToolRequirementExceedsBudget { .. } => {
230 RunErrorKind::BudgetExceeded
231 }
232 Self::Gateway(error) => match error.kind {
233 GatewayErrorKind::CapabilityDenied
234 | GatewayErrorKind::AuthorityEscalation
235 | GatewayErrorKind::PolicyDenied => RunErrorKind::CapabilityDenied,
236 GatewayErrorKind::BudgetExceeded | GatewayErrorKind::MaxDepth => {
237 RunErrorKind::BudgetExceeded
238 }
239 GatewayErrorKind::Cancelled => RunErrorKind::Cancelled,
240 GatewayErrorKind::DeadlineExceeded => RunErrorKind::DeadlineExceeded,
241 GatewayErrorKind::InvalidInput => RunErrorKind::InvalidInput,
242 GatewayErrorKind::NotFound | GatewayErrorKind::ChildFailed => {
243 RunErrorKind::Invocation
244 }
245 GatewayErrorKind::ObservabilityFailed => {
246 RunErrorKind::Extension("runifold.observability".into())
247 }
248 },
249 Self::InvalidConfig(_) => RunErrorKind::InvalidInput,
250 Self::TerminalReviewAuthorityEscalation { .. }
251 | Self::TurnReviewAuthorityEscalation { .. } => RunErrorKind::CapabilityDenied,
252 Self::TerminalReview(error) | Self::TurnReview(error) => review_error_kind(error),
253 Self::TerminalReviewRejected { .. }
254 | Self::TerminalReviewExhausted { .. }
255 | Self::TurnReviewRejected { .. }
256 | Self::TurnReviewExhausted { .. }
257 | Self::Protocol(_)
258 | Self::ToolRequirementUnsatisfied { .. }
259 | Self::EmptyTerminalResponse { .. }
260 | Self::StructuredOutputUnsatisfied { .. }
261 | Self::ToolOutputNotVisible { .. } => RunErrorKind::Protocol,
262 Self::Journal(_) => RunErrorKind::Extension("runifold.observability".into()),
263 Self::Checkpoint(_)
264 | Self::AmbiguousCheckpoint { .. }
265 | Self::AmbiguousTerminalReview { .. }
266 | Self::AmbiguousTurnReview { .. } => {
267 RunErrorKind::Extension("runifold.checkpoint".into())
268 }
269 Self::Effect(error) => match error.kind {
270 EffectExecutorErrorKind::CapabilityDenied => RunErrorKind::CapabilityDenied,
271 EffectExecutorErrorKind::Cancelled => RunErrorKind::Cancelled,
272 EffectExecutorErrorKind::DeadlineExceeded => RunErrorKind::DeadlineExceeded,
273 EffectExecutorErrorKind::IdempotencyConflict
274 | EffectExecutorErrorKind::Protocol => RunErrorKind::Protocol,
275 EffectExecutorErrorKind::Handler => error
276 .source_error
277 .as_ref()
278 .map_or(RunErrorKind::Invocation, |error| error.kind.clone()),
279 EffectExecutorErrorKind::Ambiguous
280 | EffectExecutorErrorKind::Store
281 | EffectExecutorErrorKind::Observability => {
282 RunErrorKind::Extension("runifold.effect".into())
283 }
284 _ => RunErrorKind::Extension("runifold.effect".into()),
285 },
286 }
287 }
288
289 pub fn retry_safety(&self) -> RetrySafety {
291 match self {
292 Self::Model(error) => error.retry_safety,
293 Self::Tool(error) => error.retry_safety,
294 Self::Effect(error) => error
295 .source_error
296 .as_ref()
297 .map_or(RetrySafety::Unknown, |error| error.retry_safety),
298 _ => RetrySafety::Unknown,
299 }
300 }
301
302 pub fn to_run_error(&self) -> RunError {
304 let metadata = match self {
305 Self::Model(error) => error.metadata.clone(),
306 _ => BTreeMap::new(),
307 };
308 RunError {
309 kind: self.run_error_kind(),
310 message: self.to_string(),
311 retry_safety: self.retry_safety(),
312 metadata,
313 }
314 }
315}
316
317fn review_error_kind(error: &TerminalReviewError) -> RunErrorKind {
318 match error {
319 TerminalReviewError::InvalidConfiguration(_)
320 | TerminalReviewError::RequestTooLarge { .. } => RunErrorKind::InvalidInput,
321 TerminalReviewError::Execution(_) => RunErrorKind::Invocation,
322 TerminalReviewError::InvalidVerdict(_) => RunErrorKind::Protocol,
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use runifold_core::{RetrySafety, RunErrorKind};
329 use runifold_model::{ModelError, ModelErrorKind};
330
331 use super::AgentError;
332
333 #[test]
334 fn model_failure_normalization_preserves_kind_and_retry_safety() {
335 let mut model = ModelError::local(ModelErrorKind::MalformedToolArguments, "invalid JSON");
336 model.retry_safety = RetrySafety::Safe;
337 let error = AgentError::Model(model);
338
339 let normalized = error.to_run_error();
340
341 assert_eq!(normalized.kind, RunErrorKind::Protocol);
342 assert_eq!(normalized.retry_safety, RetrySafety::Safe);
343 }
344
345 #[test]
346 fn local_agent_limits_have_a_stable_budget_classification() {
347 let error = AgentError::MaxTurns { max_turns: 3 };
348
349 assert_eq!(error.run_error_kind(), RunErrorKind::BudgetExceeded);
350 assert_eq!(error.retry_safety(), RetrySafety::Unknown);
351 }
352}