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 run_error_kind(&self) -> RunErrorKind {
165 match self {
166 Self::Model(error) => match error.kind {
167 runifold_model::ModelErrorKind::InvalidRequest
168 | runifold_model::ModelErrorKind::UnsupportedFeature => RunErrorKind::InvalidInput,
169 runifold_model::ModelErrorKind::Transport => RunErrorKind::Transport,
170 runifold_model::ModelErrorKind::Cancelled => RunErrorKind::Cancelled,
171 runifold_model::ModelErrorKind::DeadlineExceeded => RunErrorKind::DeadlineExceeded,
172 runifold_model::ModelErrorKind::Protocol
173 | runifold_model::ModelErrorKind::StreamState
174 | runifold_model::ModelErrorKind::MalformedToolArguments => RunErrorKind::Protocol,
175 _ => RunErrorKind::Invocation,
176 },
177 Self::Tool(error) => match error.kind {
178 ToolErrorKind::InvalidInput => RunErrorKind::InvalidInput,
179 ToolErrorKind::CapabilityDenied => RunErrorKind::CapabilityDenied,
180 ToolErrorKind::Cancelled => RunErrorKind::Cancelled,
181 ToolErrorKind::DeadlineExceeded => RunErrorKind::DeadlineExceeded,
182 _ => RunErrorKind::Invocation,
183 },
184 Self::Retrieval(error) => match error {
185 RetrievalError::EmptyDocumentId
186 | RetrievalError::EmptyDocumentText { .. }
187 | RetrievalError::EmptyQuery
188 | RetrievalError::ZeroLimit
189 | RetrievalError::EmptyEmbedding
190 | RetrievalError::NonFiniteEmbedding { .. }
191 | RetrievalError::EmbeddingCoordinateOutOfRange { .. }
192 | RetrievalError::ZeroNormEmbedding
193 | RetrievalError::DimensionMismatch { .. }
194 | RetrievalError::EmbeddingCountMismatch { .. }
195 | RetrievalError::EmptyEmbeddingInput { .. }
196 | RetrievalError::DuplicateDocument(_) => RunErrorKind::InvalidInput,
197 RetrievalError::UsageOverflow => RunErrorKind::BudgetExceeded,
198 RetrievalError::CapabilityDenied { .. } => RunErrorKind::CapabilityDenied,
199 RetrievalError::Cancelled => RunErrorKind::Cancelled,
200 RetrievalError::DeadlineExceeded => RunErrorKind::DeadlineExceeded,
201 _ => RunErrorKind::Invocation,
202 },
203 Self::Budget(_) | Self::MaxTurns { .. } | Self::ToolRequirementExceedsBudget { .. } => {
204 RunErrorKind::BudgetExceeded
205 }
206 Self::Gateway(error) => match error.kind {
207 GatewayErrorKind::CapabilityDenied
208 | GatewayErrorKind::AuthorityEscalation
209 | GatewayErrorKind::PolicyDenied => RunErrorKind::CapabilityDenied,
210 GatewayErrorKind::BudgetExceeded | GatewayErrorKind::MaxDepth => {
211 RunErrorKind::BudgetExceeded
212 }
213 GatewayErrorKind::Cancelled => RunErrorKind::Cancelled,
214 GatewayErrorKind::DeadlineExceeded => RunErrorKind::DeadlineExceeded,
215 GatewayErrorKind::InvalidInput => RunErrorKind::InvalidInput,
216 GatewayErrorKind::NotFound | GatewayErrorKind::ChildFailed => {
217 RunErrorKind::Invocation
218 }
219 GatewayErrorKind::ObservabilityFailed => {
220 RunErrorKind::Extension("runifold.observability".into())
221 }
222 },
223 Self::InvalidConfig(_) => RunErrorKind::InvalidInput,
224 Self::TerminalReviewAuthorityEscalation { .. }
225 | Self::TurnReviewAuthorityEscalation { .. } => RunErrorKind::CapabilityDenied,
226 Self::TerminalReview(error) | Self::TurnReview(error) => review_error_kind(error),
227 Self::TerminalReviewRejected { .. }
228 | Self::TerminalReviewExhausted { .. }
229 | Self::TurnReviewRejected { .. }
230 | Self::TurnReviewExhausted { .. }
231 | Self::Protocol(_)
232 | Self::ToolRequirementUnsatisfied { .. }
233 | Self::EmptyTerminalResponse { .. }
234 | Self::StructuredOutputUnsatisfied { .. }
235 | Self::ToolOutputNotVisible { .. } => RunErrorKind::Protocol,
236 Self::Journal(_) => RunErrorKind::Extension("runifold.observability".into()),
237 Self::Checkpoint(_)
238 | Self::AmbiguousCheckpoint { .. }
239 | Self::AmbiguousTerminalReview { .. }
240 | Self::AmbiguousTurnReview { .. } => {
241 RunErrorKind::Extension("runifold.checkpoint".into())
242 }
243 Self::Effect(error) => match error.kind {
244 EffectExecutorErrorKind::CapabilityDenied => RunErrorKind::CapabilityDenied,
245 EffectExecutorErrorKind::Cancelled => RunErrorKind::Cancelled,
246 EffectExecutorErrorKind::DeadlineExceeded => RunErrorKind::DeadlineExceeded,
247 EffectExecutorErrorKind::IdempotencyConflict
248 | EffectExecutorErrorKind::Protocol => RunErrorKind::Protocol,
249 EffectExecutorErrorKind::Handler => error
250 .source_error
251 .as_ref()
252 .map_or(RunErrorKind::Invocation, |error| error.kind.clone()),
253 EffectExecutorErrorKind::Ambiguous
254 | EffectExecutorErrorKind::Store
255 | EffectExecutorErrorKind::Observability => {
256 RunErrorKind::Extension("runifold.effect".into())
257 }
258 _ => RunErrorKind::Extension("runifold.effect".into()),
259 },
260 }
261 }
262
263 pub fn retry_safety(&self) -> RetrySafety {
265 match self {
266 Self::Model(error) => error.retry_safety,
267 Self::Tool(error) => error.retry_safety,
268 Self::Effect(error) => error
269 .source_error
270 .as_ref()
271 .map_or(RetrySafety::Unknown, |error| error.retry_safety),
272 _ => RetrySafety::Unknown,
273 }
274 }
275
276 pub fn to_run_error(&self) -> RunError {
278 let metadata = match self {
279 Self::Model(error) => error.metadata.clone(),
280 _ => BTreeMap::new(),
281 };
282 RunError {
283 kind: self.run_error_kind(),
284 message: self.to_string(),
285 retry_safety: self.retry_safety(),
286 metadata,
287 }
288 }
289}
290
291fn review_error_kind(error: &TerminalReviewError) -> RunErrorKind {
292 match error {
293 TerminalReviewError::InvalidConfiguration(_)
294 | TerminalReviewError::RequestTooLarge { .. } => RunErrorKind::InvalidInput,
295 TerminalReviewError::Execution(_) => RunErrorKind::Invocation,
296 TerminalReviewError::InvalidVerdict(_) => RunErrorKind::Protocol,
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use runifold_core::{RetrySafety, RunErrorKind};
303 use runifold_model::{ModelError, ModelErrorKind};
304
305 use super::AgentError;
306
307 #[test]
308 fn model_failure_normalization_preserves_kind_and_retry_safety() {
309 let mut model = ModelError::local(ModelErrorKind::MalformedToolArguments, "invalid JSON");
310 model.retry_safety = RetrySafety::Safe;
311 let error = AgentError::Model(model);
312
313 let normalized = error.to_run_error();
314
315 assert_eq!(normalized.kind, RunErrorKind::Protocol);
316 assert_eq!(normalized.retry_safety, RetrySafety::Safe);
317 }
318
319 #[test]
320 fn local_agent_limits_have_a_stable_budget_classification() {
321 let error = AgentError::MaxTurns { max_turns: 3 };
322
323 assert_eq!(error.run_error_kind(), RunErrorKind::BudgetExceeded);
324 assert_eq!(error.retry_safety(), RetrySafety::Unknown);
325 }
326}