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};
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("invalid agent configuration: {0}")]
50 InvalidConfig(String),
51 #[error("agent protocol error: {0}")]
53 Protocol(String),
54 #[error("agent exceeded its local maximum of {max_turns} turns")]
56 MaxTurns {
57 max_turns: u32,
59 },
60 #[error(
62 "agent completed only {successful} successful local Tool calls; at least {required} required"
63 )]
64 ToolRequirementUnsatisfied {
65 required: u32,
67 successful: u32,
69 },
70 #[error(
72 "agent requires {required} successful local Tool calls but only {remaining} Tool calls remain in the shared budget"
73 )]
74 ToolRequirementExceedsBudget {
75 required: u32,
77 remaining: u64,
79 },
80 #[error("model produced no usable terminal content after {attempts} repair attempts")]
82 EmptyTerminalResponse {
83 attempts: u32,
85 },
86 #[error(
88 "structured terminal output remained unsatisfied after {attempts} repair attempts: {kind:?}"
89 )]
90 StructuredOutputUnsatisfied {
91 attempts: u32,
93 kind: StructuredOutputErrorKind,
95 line: Option<usize>,
97 column: Option<usize>,
99 },
100 #[error("tool `{tool}` returned host-only output")]
102 ToolOutputNotVisible {
103 tool: String,
105 },
106}
107
108impl AgentError {
109 pub fn run_error_kind(&self) -> RunErrorKind {
111 match self {
112 Self::Model(error) => match error.kind {
113 runifold_model::ModelErrorKind::InvalidRequest
114 | runifold_model::ModelErrorKind::UnsupportedFeature => RunErrorKind::InvalidInput,
115 runifold_model::ModelErrorKind::Transport => RunErrorKind::Transport,
116 runifold_model::ModelErrorKind::Cancelled => RunErrorKind::Cancelled,
117 runifold_model::ModelErrorKind::DeadlineExceeded => RunErrorKind::DeadlineExceeded,
118 runifold_model::ModelErrorKind::Protocol
119 | runifold_model::ModelErrorKind::StreamState
120 | runifold_model::ModelErrorKind::MalformedToolArguments => RunErrorKind::Protocol,
121 _ => RunErrorKind::Invocation,
122 },
123 Self::Tool(error) => match error.kind {
124 ToolErrorKind::InvalidInput => RunErrorKind::InvalidInput,
125 ToolErrorKind::CapabilityDenied => RunErrorKind::CapabilityDenied,
126 ToolErrorKind::Cancelled => RunErrorKind::Cancelled,
127 ToolErrorKind::DeadlineExceeded => RunErrorKind::DeadlineExceeded,
128 _ => RunErrorKind::Invocation,
129 },
130 Self::Retrieval(error) => match error {
131 RetrievalError::EmptyDocumentId
132 | RetrievalError::EmptyDocumentText { .. }
133 | RetrievalError::EmptyQuery
134 | RetrievalError::ZeroLimit
135 | RetrievalError::EmptyEmbedding
136 | RetrievalError::NonFiniteEmbedding { .. }
137 | RetrievalError::EmbeddingCoordinateOutOfRange { .. }
138 | RetrievalError::ZeroNormEmbedding
139 | RetrievalError::DimensionMismatch { .. }
140 | RetrievalError::EmbeddingCountMismatch { .. }
141 | RetrievalError::EmptyEmbeddingInput { .. }
142 | RetrievalError::DuplicateDocument(_) => RunErrorKind::InvalidInput,
143 RetrievalError::UsageOverflow => RunErrorKind::BudgetExceeded,
144 RetrievalError::CapabilityDenied { .. } => RunErrorKind::CapabilityDenied,
145 RetrievalError::Cancelled => RunErrorKind::Cancelled,
146 RetrievalError::DeadlineExceeded => RunErrorKind::DeadlineExceeded,
147 _ => RunErrorKind::Invocation,
148 },
149 Self::Budget(_) | Self::MaxTurns { .. } | Self::ToolRequirementExceedsBudget { .. } => {
150 RunErrorKind::BudgetExceeded
151 }
152 Self::Gateway(error) => match error.kind {
153 GatewayErrorKind::CapabilityDenied
154 | GatewayErrorKind::AuthorityEscalation
155 | GatewayErrorKind::PolicyDenied => RunErrorKind::CapabilityDenied,
156 GatewayErrorKind::BudgetExceeded | GatewayErrorKind::MaxDepth => {
157 RunErrorKind::BudgetExceeded
158 }
159 GatewayErrorKind::Cancelled => RunErrorKind::Cancelled,
160 GatewayErrorKind::DeadlineExceeded => RunErrorKind::DeadlineExceeded,
161 GatewayErrorKind::InvalidInput => RunErrorKind::InvalidInput,
162 GatewayErrorKind::NotFound | GatewayErrorKind::ChildFailed => {
163 RunErrorKind::Invocation
164 }
165 GatewayErrorKind::ObservabilityFailed => {
166 RunErrorKind::Extension("runifold.observability".into())
167 }
168 },
169 Self::InvalidConfig(_) => RunErrorKind::InvalidInput,
170 Self::Protocol(_)
171 | Self::ToolRequirementUnsatisfied { .. }
172 | Self::EmptyTerminalResponse { .. }
173 | Self::StructuredOutputUnsatisfied { .. }
174 | Self::ToolOutputNotVisible { .. } => RunErrorKind::Protocol,
175 Self::Journal(_) => RunErrorKind::Extension("runifold.observability".into()),
176 Self::Checkpoint(_) | Self::AmbiguousCheckpoint { .. } => {
177 RunErrorKind::Extension("runifold.checkpoint".into())
178 }
179 Self::Effect(error) => match error.kind {
180 EffectExecutorErrorKind::CapabilityDenied => RunErrorKind::CapabilityDenied,
181 EffectExecutorErrorKind::Cancelled => RunErrorKind::Cancelled,
182 EffectExecutorErrorKind::DeadlineExceeded => RunErrorKind::DeadlineExceeded,
183 EffectExecutorErrorKind::IdempotencyConflict
184 | EffectExecutorErrorKind::Protocol => RunErrorKind::Protocol,
185 EffectExecutorErrorKind::Handler => error
186 .source_error
187 .as_ref()
188 .map_or(RunErrorKind::Invocation, |error| error.kind.clone()),
189 EffectExecutorErrorKind::Ambiguous
190 | EffectExecutorErrorKind::Store
191 | EffectExecutorErrorKind::Observability => {
192 RunErrorKind::Extension("runifold.effect".into())
193 }
194 _ => RunErrorKind::Extension("runifold.effect".into()),
195 },
196 }
197 }
198
199 pub fn retry_safety(&self) -> RetrySafety {
201 match self {
202 Self::Model(error) => error.retry_safety,
203 Self::Tool(error) => error.retry_safety,
204 Self::Effect(error) => error
205 .source_error
206 .as_ref()
207 .map_or(RetrySafety::Unknown, |error| error.retry_safety),
208 _ => RetrySafety::Unknown,
209 }
210 }
211
212 pub fn to_run_error(&self) -> RunError {
214 let metadata = match self {
215 Self::Model(error) => error.metadata.clone(),
216 _ => BTreeMap::new(),
217 };
218 RunError {
219 kind: self.run_error_kind(),
220 message: self.to_string(),
221 retry_safety: self.retry_safety(),
222 metadata,
223 }
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use runifold_core::{RetrySafety, RunErrorKind};
230 use runifold_model::{ModelError, ModelErrorKind};
231
232 use super::AgentError;
233
234 #[test]
235 fn model_failure_normalization_preserves_kind_and_retry_safety() {
236 let mut model = ModelError::local(ModelErrorKind::MalformedToolArguments, "invalid JSON");
237 model.retry_safety = RetrySafety::Safe;
238 let error = AgentError::Model(model);
239
240 let normalized = error.to_run_error();
241
242 assert_eq!(normalized.kind, RunErrorKind::Protocol);
243 assert_eq!(normalized.retry_safety, RetrySafety::Safe);
244 }
245
246 #[test]
247 fn local_agent_limits_have_a_stable_budget_classification() {
248 let error = AgentError::MaxTurns { max_turns: 3 };
249
250 assert_eq!(error.run_error_kind(), RunErrorKind::BudgetExceeded);
251 assert_eq!(error.retry_safety(), RetrySafety::Unknown);
252 }
253}