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