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    /// Conflicting facts detected for the same ID.
48    #[error(
49        "conflict detected for fact '{id}': existing content '{existing}' vs new content '{new}'"
50    )]
51    Conflict {
52        /// ID of the conflicting fact.
53        id: String,
54        /// Existing content.
55        existing: String,
56        /// New conflicting content.
57        new: String,
58        /// Final context state. Boxed to reduce error size.
59        context: Box<ContextState>,
60    },
61}
62
63impl ConvergeError {
64    /// Returns a reference to the context if this error variant carries one.
65    #[must_use]
66    pub fn context(&self) -> Option<&ContextState> {
67        match self {
68            Self::InvariantViolation { context, .. } | Self::Conflict { context, .. } => {
69                Some(context)
70            }
71            Self::BudgetExhausted { .. }
72            | Self::AgentFailed { .. }
73            | Self::InvalidResume { .. } => None,
74        }
75    }
76
77    /// Convert this error into a platform-level stop reason.
78    #[must_use]
79    pub fn stop_reason(&self) -> StopReason {
80        match self {
81            Self::BudgetExhausted { kind } => StopReason::Error {
82                message: format!("budget exhausted: {kind}"),
83                category: crate::gates::ErrorCategory::Resource,
84            },
85            Self::InvariantViolation {
86                name,
87                class,
88                reason,
89                ..
90            } => StopReason::invariant_violated(*class, name.clone(), reason.clone()),
91            Self::AgentFailed { agent_id } => StopReason::AgentRefused {
92                agent_id: agent_id.clone(),
93                reason: "agent execution failed".to_string(),
94            },
95            Self::InvalidResume { reason } => StopReason::Error {
96                message: format!("invalid gate resume: {reason}"),
97                category: crate::gates::ErrorCategory::Internal,
98            },
99            Self::Conflict {
100                id, existing, new, ..
101            } => StopReason::Error {
102                message: format!("conflict for fact '{id}': existing '{existing}' vs new '{new}'"),
103                category: crate::gates::ErrorCategory::Internal,
104            },
105        }
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    fn empty_context() -> ContextState {
114        ContextState::default()
115    }
116
117    #[test]
118    fn budget_exhausted_display() {
119        let err = ConvergeError::BudgetExhausted {
120            kind: "cycles".into(),
121        };
122        assert_eq!(err.to_string(), "budget exhausted: cycles");
123    }
124
125    #[test]
126    fn budget_exhausted_has_no_context() {
127        let err = ConvergeError::BudgetExhausted {
128            kind: "tokens".into(),
129        };
130        assert!(err.context().is_none());
131    }
132
133    #[test]
134    fn agent_failed_display() {
135        let err = ConvergeError::AgentFailed {
136            agent_id: "agent-x".into(),
137        };
138        assert_eq!(err.to_string(), "agent failed: agent-x");
139    }
140
141    #[test]
142    fn agent_failed_has_no_context() {
143        let err = ConvergeError::AgentFailed {
144            agent_id: "a".into(),
145        };
146        assert!(err.context().is_none());
147    }
148
149    #[test]
150    fn invariant_violation_has_context() {
151        let err = ConvergeError::InvariantViolation {
152            name: "no_empty".into(),
153            class: InvariantClass::Structural,
154            reason: "empty found".into(),
155            context: Box::new(empty_context()),
156        };
157        assert!(err.context().is_some());
158    }
159
160    #[test]
161    fn invariant_violation_display() {
162        let err = ConvergeError::InvariantViolation {
163            name: "no_empty".into(),
164            class: InvariantClass::Semantic,
165            reason: "bad".into(),
166            context: Box::new(empty_context()),
167        };
168        assert_eq!(
169            err.to_string(),
170            "Semantic invariant 'no_empty' violated: bad"
171        );
172    }
173
174    #[test]
175    fn conflict_has_context() {
176        let err = ConvergeError::Conflict {
177            id: "fact-1".into(),
178            existing: "old".into(),
179            new: "new".into(),
180            context: Box::new(empty_context()),
181        };
182        assert!(err.context().is_some());
183    }
184
185    #[test]
186    fn conflict_display() {
187        let err = ConvergeError::Conflict {
188            id: "f".into(),
189            existing: "a".into(),
190            new: "b".into(),
191            context: Box::new(empty_context()),
192        };
193        assert!(err.to_string().contains("conflict detected for fact 'f'"));
194    }
195
196    #[test]
197    fn stop_reason_budget_exhausted() {
198        let err = ConvergeError::BudgetExhausted {
199            kind: "time".into(),
200        };
201        let reason = err.stop_reason();
202        assert!(matches!(reason, StopReason::Error { .. }));
203    }
204
205    #[test]
206    fn stop_reason_invariant_violated() {
207        let err = ConvergeError::InvariantViolation {
208            name: "inv".into(),
209            class: InvariantClass::Acceptance,
210            reason: "fail".into(),
211            context: Box::new(empty_context()),
212        };
213        let reason = err.stop_reason();
214        assert!(matches!(reason, StopReason::InvariantViolated { .. }));
215    }
216
217    #[test]
218    fn stop_reason_agent_refused() {
219        let err = ConvergeError::AgentFailed {
220            agent_id: "bot".into(),
221        };
222        let reason = err.stop_reason();
223        assert!(matches!(reason, StopReason::AgentRefused { .. }));
224    }
225
226    #[test]
227    fn invalid_resume_display() {
228        let err = ConvergeError::InvalidResume {
229            reason: "gate_id mismatch".into(),
230        };
231        assert_eq!(err.to_string(), "invalid gate resume: gate_id mismatch");
232    }
233
234    #[test]
235    fn invalid_resume_has_no_context() {
236        let err = ConvergeError::InvalidResume {
237            reason: "test".into(),
238        };
239        assert!(err.context().is_none());
240    }
241
242    #[test]
243    fn stop_reason_invalid_resume() {
244        let err = ConvergeError::InvalidResume {
245            reason: "wrong gate".into(),
246        };
247        let reason = err.stop_reason();
248        assert!(matches!(reason, StopReason::Error { .. }));
249    }
250
251    #[test]
252    fn stop_reason_conflict() {
253        let err = ConvergeError::Conflict {
254            id: "x".into(),
255            existing: "old".into(),
256            new: "new".into(),
257            context: Box::new(empty_context()),
258        };
259        let reason = err.stop_reason();
260        assert!(matches!(reason, StopReason::Error { .. }));
261    }
262}