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, TerminalReviewError};
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    /// Recovery would silently retry a possibly partial terminal review.
49    #[error("checkpoint contains an ambiguous in-flight terminal review attempt {attempt}")]
50    AmbiguousTerminalReview {
51        /// One-based interrupted review attempt.
52        attempt: u32,
53    },
54    /// Recovery would silently retry a possibly partial internal turn review.
55    #[error("checkpoint contains an ambiguous in-flight review for model turn {turn}")]
56    AmbiguousTurnReview {
57        /// One-based model turn whose review was interrupted.
58        turn: u32,
59    },
60    /// A reviewer requested authority absent from the parent Run.
61    #[error("terminal reviewer requested unavailable capability `{capability}`")]
62    TerminalReviewAuthorityEscalation {
63        /// Missing capability name.
64        capability: String,
65    },
66    /// An internal reviewer requested authority absent from the parent Run.
67    #[error("turn reviewer requested unavailable capability `{capability}`")]
68    TurnReviewAuthorityEscalation {
69        /// Missing capability name.
70        capability: String,
71    },
72    /// Terminal reviewer execution or verdict validation failed.
73    #[error("terminal review failed: {0}")]
74    TerminalReview(#[from] TerminalReviewError),
75    /// Internal reviewer execution or verdict validation failed.
76    #[error("turn review failed: {0}")]
77    TurnReview(TerminalReviewError),
78    /// The reviewer permanently rejected the terminal candidate.
79    #[error("terminal candidate was rejected by reviewer: {reason}")]
80    TerminalReviewRejected {
81        /// Safe reviewer explanation.
82        reason: String,
83    },
84    /// The reviewer requested another repair after the configured limit.
85    #[error("terminal review remained unsatisfied after {attempts} repair attempts")]
86    TerminalReviewExhausted {
87        /// Review repairs completed before exhaustion.
88        attempts: u32,
89    },
90    /// The internal reviewer permanently rejected a model response.
91    #[error("model turn was rejected by reviewer: {reason}")]
92    TurnReviewRejected {
93        /// Safe reviewer explanation.
94        reason: String,
95    },
96    /// The internal reviewer requested another repair after the configured limit.
97    #[error("turn review remained unsatisfied after {attempts} repair attempts")]
98    TurnReviewExhausted {
99        /// Review repairs completed before exhaustion.
100        attempts: u32,
101    },
102    /// Agent configuration is invalid.
103    #[error("invalid agent configuration: {0}")]
104    InvalidConfig(String),
105    /// Model output violated the agent-loop protocol.
106    #[error("agent protocol error: {0}")]
107    Protocol(String),
108    /// The configured local turn bound was reached.
109    #[error("agent exceeded its local maximum of {max_turns} turns")]
110    MaxTurns {
111        /// Configured local turn bound.
112        max_turns: u32,
113    },
114    /// The model terminated before the successful local Tool-call minimum.
115    #[error(
116        "agent completed only {successful} successful local Tool calls; at least {required} required"
117    )]
118    ToolRequirementUnsatisfied {
119        /// Required successful local Tool calls.
120        required: u32,
121        /// Successful local Tool calls observed in this execution.
122        successful: u32,
123    },
124    /// The remaining shared Tool-call budget cannot satisfy the local minimum.
125    #[error(
126        "agent requires {required} successful local Tool calls but only {remaining} Tool calls remain in the shared budget"
127    )]
128    ToolRequirementExceedsBudget {
129        /// Additional successful local Tool calls still required.
130        required: u32,
131        /// Remaining shared Tool-call budget.
132        remaining: u64,
133    },
134    /// The provider exhausted bounded repairs without producing usable content.
135    #[error("model produced no usable terminal content after {attempts} repair attempts")]
136    EmptyTerminalResponse {
137        /// Repair turns completed before failing.
138        attempts: u32,
139    },
140    /// The provider exhausted bounded repairs without satisfying the Rust type.
141    #[error(
142        "structured terminal output remained unsatisfied after {attempts} repair attempts: {kind:?}"
143    )]
144    StructuredOutputUnsatisfied {
145        /// Repair turns completed before failing.
146        attempts: u32,
147        /// Stable local structured-output failure category.
148        kind: StructuredOutputErrorKind,
149        /// One-based JSON line, when available.
150        line: Option<usize>,
151        /// One-based JSON column, when available.
152        column: Option<usize>,
153    },
154    /// A tool produced output that policy forbids exposing to the model.
155    #[error("tool `{tool}` returned host-only output")]
156    ToolOutputNotVisible {
157        /// Tool name.
158        tool: String,
159    },
160}
161
162impl AgentError {
163    /// Returns the stable run-level failure category for business policy.
164    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    /// Returns whether retrying this failed Agent run is known to be safe.
264    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    /// Normalizes this failure into the public run-level policy contract.
277    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}