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