Skip to main content

runifold_agent/
error.rs

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/// Failure of an agent run.
15#[derive(Debug, Error)]
16#[non_exhaustive]
17pub enum AgentError {
18    /// Model invocation failed.
19    #[error("model invocation failed: {0}")]
20    Model(#[from] ModelError),
21    /// Tool execution failed.
22    #[error("tool execution failed: {0}")]
23    Tool(#[from] ToolError),
24    /// Static or dynamic context retrieval failed.
25    #[error("agent retrieval failed: {0}")]
26    Retrieval(#[from] RetrievalError),
27    /// A shared run-tree budget was exceeded.
28    #[error("agent budget exceeded: {0}")]
29    Budget(#[from] BudgetExceeded),
30    /// Agent delegation failed.
31    #[error("agent delegation failed: {0}")]
32    Gateway(#[from] GatewayError),
33    /// Structured event recording failed.
34    #[error("agent observability failed: {0}")]
35    Journal(#[from] JournalError),
36    /// Checkpoint persistence or validation failed.
37    #[error("agent checkpoint failed: {0}")]
38    Checkpoint(#[from] CheckpointError),
39    /// Write-ahead effect coordination failed.
40    #[error("agent effect failed: {0}")]
41    Effect(#[from] EffectExecutorError),
42    /// Recovery would silently retry a possibly partial external turn.
43    #[error("checkpoint contains an ambiguous in-flight turn {turn}")]
44    AmbiguousCheckpoint {
45        /// One-based interrupted turn number.
46        turn: u32,
47    },
48    /// Agent configuration is invalid.
49    #[error("invalid agent configuration: {0}")]
50    InvalidConfig(String),
51    /// Model output violated the agent-loop protocol.
52    #[error("agent protocol error: {0}")]
53    Protocol(String),
54    /// The configured local turn bound was reached.
55    #[error("agent exceeded its local maximum of {max_turns} turns")]
56    MaxTurns {
57        /// Configured local turn bound.
58        max_turns: u32,
59    },
60    /// The model terminated before the successful local Tool-call minimum.
61    #[error(
62        "agent completed only {successful} successful local Tool calls; at least {required} required"
63    )]
64    ToolRequirementUnsatisfied {
65        /// Required successful local Tool calls.
66        required: u32,
67        /// Successful local Tool calls observed in this execution.
68        successful: u32,
69    },
70    /// The remaining shared Tool-call budget cannot satisfy the local minimum.
71    #[error(
72        "agent requires {required} successful local Tool calls but only {remaining} Tool calls remain in the shared budget"
73    )]
74    ToolRequirementExceedsBudget {
75        /// Additional successful local Tool calls still required.
76        required: u32,
77        /// Remaining shared Tool-call budget.
78        remaining: u64,
79    },
80    /// The provider exhausted bounded repairs without producing usable content.
81    #[error("model produced no usable terminal content after {attempts} repair attempts")]
82    EmptyTerminalResponse {
83        /// Repair turns completed before failing.
84        attempts: u32,
85    },
86    /// The provider exhausted bounded repairs without satisfying the Rust type.
87    #[error(
88        "structured terminal output remained unsatisfied after {attempts} repair attempts: {kind:?}"
89    )]
90    StructuredOutputUnsatisfied {
91        /// Repair turns completed before failing.
92        attempts: u32,
93        /// Stable local structured-output failure category.
94        kind: StructuredOutputErrorKind,
95        /// One-based JSON line, when available.
96        line: Option<usize>,
97        /// One-based JSON column, when available.
98        column: Option<usize>,
99    },
100    /// A tool produced output that policy forbids exposing to the model.
101    #[error("tool `{tool}` returned host-only output")]
102    ToolOutputNotVisible {
103        /// Tool name.
104        tool: String,
105    },
106}
107
108impl AgentError {
109    /// Returns the stable run-level failure category for business policy.
110    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    /// Returns whether retrying this failed Agent run is known to be safe.
200    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    /// Normalizes this failure into the public run-level policy contract.
213    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}