Skip to main content

leviath_core/
error.rs

1//! Error types for Leviath Core.
2
3use thiserror::Error;
4
5/// Result type alias using Leviath's Error type.
6pub type Result<T> = std::result::Result<T, Error>;
7
8/// Errors from blueprint, stage, region, and layout validation.
9#[derive(Debug, Clone, Error, PartialEq)]
10pub enum ValidationError {
11    /// Blueprint-level validation failure
12    #[error("Invalid blueprint: {0}")]
13    Blueprint(String),
14
15    /// Stage-level validation failure
16    #[error("Invalid stage '{stage}': {message}")]
17    Stage {
18        /// The offending stage's name.
19        stage: String,
20        /// What is wrong with it.
21        message: String,
22    },
23
24    /// Region-level validation failure
25    #[error("Invalid region '{region}': {message}")]
26    Region {
27        /// The offending region's name.
28        region: String,
29        /// What is wrong with it.
30        message: String,
31    },
32
33    /// Dependency-declaration validation failure
34    #[error("Invalid dependency '{name}': {message}")]
35    Dependency {
36        /// The offending dependency's name.
37        name: String,
38        /// What is wrong with it.
39        message: String,
40    },
41
42    /// Layout-level validation failure
43    #[error("Invalid layout: {0}")]
44    Layout(String),
45
46    /// Graph structure validation failure
47    #[error("Invalid graph: {0}")]
48    Graph(String),
49
50    /// Transition validation failure
51    #[error("Invalid transition from '{from}' to '{to}': {message}")]
52    Transition {
53        /// The stage the edge leaves.
54        from: String,
55        /// The stage the edge names as its target, which may not exist.
56        to: String,
57        /// What is wrong with it.
58        message: String,
59    },
60}
61
62/// Core error types for Leviath.
63#[derive(Error, Debug)]
64pub enum Error {
65    /// Region with the specified name was not found
66    #[error("Region not found: {0}")]
67    RegionNotFound(String),
68
69    /// Region validation failed
70    #[error("Region validation failed: {0}")]
71    ValidationFailed(String),
72
73    /// Content exceeds region's token budget
74    #[error("Content exceeds token budget: {used} > {max}")]
75    TokenBudgetExceeded {
76        /// Tokens the write would have brought the region to.
77        used: usize,
78        /// The region's ceiling.
79        max: usize,
80    },
81
82    /// A region under `admission = "reject"` is full, and the write was
83    /// refused rather than something else being dropped to fit it.
84    ///
85    /// Distinct from [`Error::TokenBudgetExceeded`] because the remedy is
86    /// different: that one says this single write is too big for the region,
87    /// this one says the region is full and the agent has to decide what it is
88    /// finished with.
89    #[error(
90        "Region '{region}' is full ({used}/{max} tokens) and does not evict automatically - \
91         release an entry before adding another"
92    )]
93    RegionFull {
94        /// The region that refused the write.
95        region: String,
96        /// Tokens the region currently holds.
97        used: usize,
98        /// The region's ceiling.
99        max: usize,
100    },
101
102    /// A custom region's `on_write` hook rejected the write.
103    ///
104    /// Only raised for agent-origin writes (`context_write`, `context_append`,
105    /// routed tool results), where the refusal and its reason can be reported
106    /// back to the writer. A framework write that a hook rejects is stored
107    /// unchanged with a warning instead - a script must not be able to delete
108    /// an assistant turn or a system record.
109    #[error("Region '{region}' refused the write: {reason}")]
110    RegionRefusedWrite {
111        /// The region whose hook refused the write.
112        region: String,
113        /// Why, as the hook said it (or a generic phrase when it only
114        /// returned `false`).
115        reason: String,
116    },
117
118    /// Pinned regions alone exceed total token budget
119    #[error("Pinned regions ({pinned_tokens}) exceed total budget ({total_budget})")]
120    PinnedRegionsOverBudget {
121        /// Tokens held by regions that can never be evicted, which is what makes
122        /// this unrecoverable rather than a matter of dropping something.
123        pinned_tokens: usize,
124        /// The whole window's budget.
125        total_budget: usize,
126    },
127
128    /// Blueprint validation failed
129    #[error("Blueprint validation failed: {0}")]
130    BlueprintInvalid(String),
131
132    /// Layout validation failed
133    #[error("Layout validation failed: {0}")]
134    LayoutInvalid(String),
135
136    /// Context transform failed
137    #[error("Context transform failed: {0}")]
138    TransformFailed(String),
139
140    /// Serialization error
141    #[error("Serialization error: {0}")]
142    SerializationError(#[from] serde_json::Error),
143
144    /// Generic error
145    #[error("{0}")]
146    Other(String),
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    // ─── ValidationError Display ────────────────────────────────────────────
154
155    #[test]
156    fn validation_error_blueprint() {
157        let e = ValidationError::Blueprint("missing name".into());
158        assert_eq!(e.to_string(), "Invalid blueprint: missing name");
159    }
160
161    #[test]
162    fn validation_error_stage() {
163        let e = ValidationError::Stage {
164            stage: "init".into(),
165            message: "no prompt".into(),
166        };
167        assert_eq!(e.to_string(), "Invalid stage 'init': no prompt");
168    }
169
170    #[test]
171    fn validation_error_region() {
172        let e = ValidationError::Region {
173            region: "context".into(),
174            message: "too large".into(),
175        };
176        assert_eq!(e.to_string(), "Invalid region 'context': too large");
177    }
178
179    #[test]
180    fn validation_error_layout() {
181        let e = ValidationError::Layout("overlapping regions".into());
182        assert_eq!(e.to_string(), "Invalid layout: overlapping regions");
183    }
184
185    #[test]
186    fn validation_error_graph() {
187        let e = ValidationError::Graph("cycle detected".into());
188        assert_eq!(e.to_string(), "Invalid graph: cycle detected");
189    }
190
191    #[test]
192    fn validation_error_transition() {
193        let e = ValidationError::Transition {
194            from: "A".into(),
195            to: "B".into(),
196            message: "missing condition".into(),
197        };
198        assert_eq!(
199            e.to_string(),
200            "Invalid transition from 'A' to 'B': missing condition"
201        );
202    }
203
204    // ─── Error Display ──────────────────────────────────────────────────────
205
206    #[test]
207    fn error_region_not_found() {
208        let e = Error::RegionNotFound("history".into());
209        assert_eq!(e.to_string(), "Region not found: history");
210    }
211
212    #[test]
213    fn error_validation_failed() {
214        let e = Error::ValidationFailed("bad input".into());
215        assert_eq!(e.to_string(), "Region validation failed: bad input");
216    }
217
218    #[test]
219    fn error_token_budget_exceeded() {
220        let e = Error::TokenBudgetExceeded {
221            used: 500,
222            max: 100,
223        };
224        assert_eq!(e.to_string(), "Content exceeds token budget: 500 > 100");
225    }
226
227    #[test]
228    fn error_region_refused_write() {
229        let e = Error::RegionRefusedWrite {
230            region: "claims".into(),
231            reason: "needs a source line".into(),
232        };
233        assert_eq!(
234            e.to_string(),
235            "Region 'claims' refused the write: needs a source line"
236        );
237    }
238
239    #[test]
240    fn error_pinned_regions_over_budget() {
241        let e = Error::PinnedRegionsOverBudget {
242            pinned_tokens: 2000,
243            total_budget: 1000,
244        };
245        assert_eq!(
246            e.to_string(),
247            "Pinned regions (2000) exceed total budget (1000)"
248        );
249    }
250
251    #[test]
252    fn error_blueprint_invalid() {
253        let e = Error::BlueprintInvalid("parse error".into());
254        assert_eq!(e.to_string(), "Blueprint validation failed: parse error");
255    }
256
257    #[test]
258    fn error_layout_invalid() {
259        let e = Error::LayoutInvalid("bad layout".into());
260        assert_eq!(e.to_string(), "Layout validation failed: bad layout");
261    }
262
263    #[test]
264    fn error_transform_failed() {
265        let e = Error::TransformFailed("script error".into());
266        assert_eq!(e.to_string(), "Context transform failed: script error");
267    }
268
269    #[test]
270    fn error_other() {
271        let e = Error::Other("misc".into());
272        assert_eq!(e.to_string(), "misc");
273    }
274
275    #[test]
276    fn error_from_serde_json() {
277        let json_err = serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
278        let e = Error::from(json_err);
279        assert!(e.to_string().contains("Serialization error"));
280    }
281
282    // ─── Clone for ValidationError ──────────────────────────────────────────
283
284    #[test]
285    fn validation_error_is_cloneable() {
286        let e = ValidationError::Graph("cycle".into());
287        let cloned = e.clone();
288        assert_eq!(e.to_string(), cloned.to_string());
289    }
290}