Skip to main content

converge_core/
error.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Error types for Converge.
5
6use serde::Serialize;
7use thiserror::Error;
8
9use crate::context::ContextState;
10use crate::gates::StopReason;
11use crate::invariant::InvariantClass;
12
13/// Top-level error type for Converge operations.
14///
15/// Note: Context is boxed in error variants to keep the error type small,
16/// as recommended by clippy. Access via `error.context()` method.
17#[derive(Debug, Error, Serialize)]
18pub enum ConvergeError {
19    /// Budget limit exceeded (cycles, facts, or time).
20    #[error("budget exhausted: {kind}")]
21    BudgetExhausted { kind: String },
22
23    /// An invariant was violated during execution.
24    #[error("{class:?} invariant '{name}' violated: {reason}")]
25    InvariantViolation {
26        /// Name of the violated invariant.
27        name: String,
28        /// Class of the invariant (Structural, Semantic, Acceptance).
29        class: InvariantClass,
30        /// Reason for the violation.
31        reason: String,
32        /// Final context state (including diagnostic facts). Boxed to reduce error size.
33        context: Box<ContextState>,
34    },
35
36    /// Suggestor execution failed.
37    #[error("agent failed: {agent_id}")]
38    AgentFailed { agent_id: String },
39
40    /// Invalid HITL gate resume (e.g., gate_id mismatch between decision and pause).
41    #[error("invalid gate resume: {reason}")]
42    InvalidResume {
43        /// What went wrong.
44        reason: String,
45    },
46
47    /// Invalid external observation admission.
48    #[error("invalid admission: {reason}")]
49    InvalidAdmission {
50        /// What went wrong.
51        reason: String,
52    },
53
54    /// Invalid persisted context snapshot.
55    #[error("invalid context snapshot: {reason}")]
56    InvalidSnapshot {
57        /// What went wrong.
58        reason: String,
59    },
60
61    /// Conflicting facts detected for the same ID.
62    #[error(
63        "conflict detected for fact '{id}': existing content '{existing}' vs new content '{new}'"
64    )]
65    Conflict {
66        /// ID of the conflicting fact.
67        id: String,
68        /// Existing content.
69        existing: String,
70        /// New conflicting content.
71        new: String,
72        /// Final context state. Boxed to reduce error size.
73        context: Box<ContextState>,
74    },
75}
76
77impl ConvergeError {
78    /// Returns a reference to the context if this error variant carries one.
79    #[must_use]
80    pub fn context(&self) -> Option<&ContextState> {
81        match self {
82            Self::InvariantViolation { context, .. } | Self::Conflict { context, .. } => {
83                Some(context)
84            }
85            Self::BudgetExhausted { .. }
86            | Self::AgentFailed { .. }
87            | Self::InvalidResume { .. }
88            | Self::InvalidAdmission { .. }
89            | Self::InvalidSnapshot { .. } => None,
90        }
91    }
92
93    /// Convert this error into a platform-level stop reason.
94    #[must_use]
95    pub fn stop_reason(&self) -> StopReason {
96        match self {
97            Self::BudgetExhausted { kind } => StopReason::Error {
98                message: format!("budget exhausted: {kind}"),
99                category: crate::gates::ErrorCategory::Resource,
100            },
101            Self::InvariantViolation {
102                name,
103                class,
104                reason,
105                ..
106            } => StopReason::invariant_violated(*class, name.clone(), reason.clone()),
107            Self::AgentFailed { agent_id } => StopReason::AgentRefused {
108                agent_id: agent_id.clone(),
109                reason: "agent execution failed".to_string(),
110            },
111            Self::InvalidResume { reason } => StopReason::Error {
112                message: format!("invalid gate resume: {reason}"),
113                category: crate::gates::ErrorCategory::Internal,
114            },
115            Self::InvalidAdmission { reason } => StopReason::Error {
116                message: format!("invalid admission: {reason}"),
117                category: crate::gates::ErrorCategory::Configuration,
118            },
119            Self::InvalidSnapshot { reason } => StopReason::Error {
120                message: format!("invalid context snapshot: {reason}"),
121                category: crate::gates::ErrorCategory::Configuration,
122            },
123            Self::Conflict {
124                id, existing, new, ..
125            } => StopReason::Error {
126                message: format!("conflict for fact '{id}': existing '{existing}' vs new '{new}'"),
127                category: crate::gates::ErrorCategory::Internal,
128            },
129        }
130    }
131}
132
133impl From<crate::AdmissionError> for ConvergeError {
134    fn from(value: crate::AdmissionError) -> Self {
135        Self::InvalidAdmission {
136            reason: value.to_string(),
137        }
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn empty_context() -> ContextState {
146        ContextState::default()
147    }
148
149    #[test]
150    fn budget_exhausted_display() {
151        let err = ConvergeError::BudgetExhausted {
152            kind: "cycles".into(),
153        };
154        assert_eq!(err.to_string(), "budget exhausted: cycles");
155    }
156
157    #[test]
158    fn budget_exhausted_has_no_context() {
159        let err = ConvergeError::BudgetExhausted {
160            kind: "tokens".into(),
161        };
162        assert!(err.context().is_none());
163    }
164
165    #[test]
166    fn agent_failed_display() {
167        let err = ConvergeError::AgentFailed {
168            agent_id: "agent-x".into(),
169        };
170        assert_eq!(err.to_string(), "agent failed: agent-x");
171    }
172
173    #[test]
174    fn agent_failed_has_no_context() {
175        let err = ConvergeError::AgentFailed {
176            agent_id: "a".into(),
177        };
178        assert!(err.context().is_none());
179    }
180
181    #[test]
182    fn invariant_violation_has_context() {
183        let err = ConvergeError::InvariantViolation {
184            name: "no_empty".into(),
185            class: InvariantClass::Structural,
186            reason: "empty found".into(),
187            context: Box::new(empty_context()),
188        };
189        assert!(err.context().is_some());
190    }
191
192    #[test]
193    fn invariant_violation_display() {
194        let err = ConvergeError::InvariantViolation {
195            name: "no_empty".into(),
196            class: InvariantClass::Semantic,
197            reason: "bad".into(),
198            context: Box::new(empty_context()),
199        };
200        assert_eq!(
201            err.to_string(),
202            "Semantic invariant 'no_empty' violated: bad"
203        );
204    }
205
206    #[test]
207    fn conflict_has_context() {
208        let err = ConvergeError::Conflict {
209            id: "fact-1".into(),
210            existing: "old".into(),
211            new: "new".into(),
212            context: Box::new(empty_context()),
213        };
214        assert!(err.context().is_some());
215    }
216
217    #[test]
218    fn conflict_display() {
219        let err = ConvergeError::Conflict {
220            id: "f".into(),
221            existing: "a".into(),
222            new: "b".into(),
223            context: Box::new(empty_context()),
224        };
225        assert!(err.to_string().contains("conflict detected for fact 'f'"));
226    }
227
228    #[test]
229    fn stop_reason_budget_exhausted() {
230        let err = ConvergeError::BudgetExhausted {
231            kind: "time".into(),
232        };
233        let reason = err.stop_reason();
234        assert!(matches!(reason, StopReason::Error { .. }));
235    }
236
237    #[test]
238    fn stop_reason_invariant_violated() {
239        let err = ConvergeError::InvariantViolation {
240            name: "inv".into(),
241            class: InvariantClass::Acceptance,
242            reason: "fail".into(),
243            context: Box::new(empty_context()),
244        };
245        let reason = err.stop_reason();
246        assert!(matches!(reason, StopReason::InvariantViolated { .. }));
247    }
248
249    #[test]
250    fn stop_reason_agent_refused() {
251        let err = ConvergeError::AgentFailed {
252            agent_id: "bot".into(),
253        };
254        let reason = err.stop_reason();
255        assert!(matches!(reason, StopReason::AgentRefused { .. }));
256    }
257
258    #[test]
259    fn invalid_resume_display() {
260        let err = ConvergeError::InvalidResume {
261            reason: "gate_id mismatch".into(),
262        };
263        assert_eq!(err.to_string(), "invalid gate resume: gate_id mismatch");
264    }
265
266    #[test]
267    fn invalid_resume_has_no_context() {
268        let err = ConvergeError::InvalidResume {
269            reason: "test".into(),
270        };
271        assert!(err.context().is_none());
272    }
273
274    #[test]
275    fn stop_reason_invalid_resume() {
276        let err = ConvergeError::InvalidResume {
277            reason: "wrong gate".into(),
278        };
279        let reason = err.stop_reason();
280        assert!(matches!(reason, StopReason::Error { .. }));
281    }
282
283    #[test]
284    fn stop_reason_conflict() {
285        let err = ConvergeError::Conflict {
286            id: "x".into(),
287            existing: "old".into(),
288            new: "new".into(),
289            context: Box::new(empty_context()),
290        };
291        let reason = err.stop_reason();
292        assert!(matches!(reason, StopReason::Error { .. }));
293    }
294}