Skip to main content

zeph_core/agent/
error.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4#[non_exhaustive]
5/// Typed orchestration failure.
6///
7/// Wraps errors from DAG scheduling, planning, and config verification. Each variant
8/// preserves the upstream error string because the upstream types (from `zeph-orchestration`)
9/// are heterogeneous — they do not share a common `std::error::Error` implementation that
10/// would allow `#[from]` chains without loss of information.
11#[derive(Debug, thiserror::Error)]
12pub enum OrchestrationFailure {
13    /// DAG scheduler failed to initialize or resume.
14    #[error("scheduler error: {0}")]
15    SchedulerInit(String),
16
17    /// Provider/task config verification failed.
18    #[error("config verification error: {0}")]
19    VerifyConfig(String),
20
21    /// Planner failed to produce a valid task graph.
22    #[error("planner error: {0}")]
23    PlannerError(String),
24
25    /// DAG reset for retry failed.
26    #[error("retry reset error: {0}")]
27    RetryReset(String),
28
29    /// Catch-all for orchestration errors not yet mapped to a specific variant.
30    #[error("{0}")]
31    Generic(String),
32}
33
34#[non_exhaustive]
35/// Typed skill file operation failure.
36///
37/// Returned when skill name validation or skill directory lookup fails.
38#[derive(Debug, thiserror::Error)]
39pub enum SkillOperationFailure {
40    /// Skill name contains path-traversal characters (`/`, `\`, `..`).
41    #[error("invalid skill name: {0}")]
42    InvalidName(String),
43
44    /// No skill directory found for the given name in any configured path.
45    #[error("skill directory not found: {0}")]
46    DirectoryNotFound(String),
47
48    /// Catch-all for skill operation errors not yet mapped to a specific variant.
49    #[error("{0}")]
50    Generic(String),
51}
52
53#[non_exhaustive]
54/// Top-level error type for the agent loop.
55///
56/// All fallible agent operations return `Result<T, AgentError>`. Variants are kept
57/// typed where the upstream error has a known shape; string-bearing variants only
58/// exist where the upstream is a heterogeneous `dyn Error` that cannot be boxed
59/// without breaking existing bounds.
60#[derive(Debug, thiserror::Error)]
61pub enum AgentError {
62    #[error(transparent)]
63    Llm(#[from] zeph_llm::LlmError),
64
65    #[error(transparent)]
66    Channel(#[from] crate::channel::ChannelError),
67
68    #[error(transparent)]
69    Memory(#[from] zeph_memory::MemoryError),
70
71    #[error(transparent)]
72    Skill(#[from] zeph_skills::SkillError),
73
74    #[error(transparent)]
75    Tool(#[from] zeph_tools::executor::ToolError),
76
77    #[error("I/O error: {0}")]
78    Io(#[from] std::io::Error),
79
80    /// A `tokio::task::spawn_blocking` call failed to complete (task panicked or was cancelled).
81    #[error("blocking task failed: {0}")]
82    SpawnBlocking(#[from] tokio::task::JoinError),
83
84    /// Agent received a shutdown signal and exited the run loop cleanly.
85    #[error("agent shut down")]
86    Shutdown,
87
88    /// The context window was exhausted and could not be compacted further.
89    #[error("context exhausted: {0}")]
90    ContextExhausted(String),
91
92    /// A tool call exceeded its configured timeout.
93    #[error("tool timed out: {tool_name}")]
94    ToolTimeout { tool_name: zeph_common::ToolName },
95
96    /// Structured output did not conform to the expected JSON schema.
97    #[error("schema validation failed: {0}")]
98    SchemaValidation(String),
99
100    /// An orchestration or DAG planning operation failed.
101    #[error("orchestration error: {0}")]
102    OrchestrationError(#[from] OrchestrationFailure),
103
104    /// An unknown slash command or subcommand was received.
105    #[error("unknown command: {0}")]
106    UnknownCommand(String),
107
108    /// Skill file operation failed (invalid name or skill not found).
109    #[error("skill error: {0}")]
110    SkillOperation(#[from] SkillOperationFailure),
111
112    /// Context assembly or index retrieval failed.
113    #[error("context error: {0}")]
114    ContextError(String),
115
116    /// A database operation in the agent subsystem failed.
117    #[error(transparent)]
118    Db(#[from] zeph_db::DbError),
119
120    /// A durable session event-log operation failed (spec-068, #5343) — event log replay, fork,
121    /// or `SessionStore` metadata read/write.
122    #[error(transparent)]
123    Session(#[from] zeph_session::SessionError),
124
125    /// A `/worktree list`/`/worktree clean` operation failed (git reconciliation, removal, or
126    /// registry pruning).
127    #[error(transparent)]
128    Worktree(#[from] zeph_worktree::WorktreeError),
129}
130
131impl AgentError {
132    /// Returns true if this error originates from a context length exceeded condition.
133    #[must_use]
134    pub fn is_context_length_error(&self) -> bool {
135        if let Self::Llm(e) = self {
136            return e.is_context_length_error();
137        }
138        false
139    }
140
141    /// Returns true if this error indicates that a beta header was rejected by the API.
142    #[must_use]
143    pub fn is_beta_header_rejected(&self) -> bool {
144        if let Self::Llm(e) = self {
145            return e.is_beta_header_rejected();
146        }
147        false
148    }
149
150    /// Returns true if this error is `LlmError::NoProviders` (all configured backends unavailable).
151    #[must_use]
152    pub fn is_no_providers(&self) -> bool {
153        matches!(self, Self::Llm(zeph_llm::LlmError::NoProviders))
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn agent_error_detects_context_length_from_llm() {
163        let e = AgentError::Llm(zeph_llm::LlmError::ContextLengthExceeded);
164        assert!(e.is_context_length_error());
165    }
166
167    #[test]
168    fn agent_error_detects_context_length_from_typed_variant() {
169        // Providers must return ContextLengthExceeded directly, not Other.
170        let e = AgentError::Llm(zeph_llm::LlmError::ContextLengthExceeded);
171        assert!(e.is_context_length_error());
172    }
173
174    #[test]
175    fn agent_error_other_with_context_message_not_detected() {
176        // The `Other` path no longer triggers context-length classification;
177        // providers are responsible for returning ContextLengthExceeded directly.
178        let e = AgentError::Llm(zeph_llm::LlmError::Other("context length exceeded".into()));
179        assert!(!e.is_context_length_error());
180    }
181
182    #[test]
183    fn agent_error_non_llm_variant_not_detected() {
184        let e = AgentError::ContextError("something went wrong".into());
185        assert!(!e.is_context_length_error());
186    }
187
188    #[test]
189    fn shutdown_variant_display() {
190        let e = AgentError::Shutdown;
191        assert_eq!(e.to_string(), "agent shut down");
192    }
193
194    #[test]
195    fn context_exhausted_variant_display() {
196        let e = AgentError::ContextExhausted("no space left".into());
197        assert!(e.to_string().contains("no space left"));
198    }
199
200    #[test]
201    fn tool_timeout_variant_display() {
202        let e = AgentError::ToolTimeout {
203            tool_name: "bash".into(),
204        };
205        assert!(e.to_string().contains("bash"));
206    }
207
208    #[test]
209    fn schema_validation_variant_display() {
210        let e = AgentError::SchemaValidation("missing field".into());
211        assert!(e.to_string().contains("missing field"));
212    }
213
214    #[test]
215    fn agent_error_detects_beta_header_rejected() {
216        let e = AgentError::Llm(zeph_llm::LlmError::BetaHeaderRejected {
217            header: "compact-2026-01-12".into(),
218        });
219        assert!(e.is_beta_header_rejected());
220    }
221
222    #[test]
223    fn agent_error_non_llm_variant_not_beta_rejected() {
224        let e = AgentError::ContextError("something went wrong".into());
225        assert!(!e.is_beta_header_rejected());
226    }
227
228    #[test]
229    fn agent_error_detects_no_providers() {
230        let e = AgentError::Llm(zeph_llm::LlmError::NoProviders);
231        assert!(e.is_no_providers());
232    }
233
234    #[test]
235    fn agent_error_non_no_providers_returns_false() {
236        let e = AgentError::ContextError("other".into());
237        assert!(!e.is_no_providers());
238    }
239
240    #[test]
241    fn orchestration_error_display() {
242        let e =
243            AgentError::OrchestrationError(OrchestrationFailure::Generic("planner failed".into()));
244        assert!(e.to_string().contains("planner failed"));
245    }
246
247    #[test]
248    fn orchestration_failure_variants_display() {
249        assert!(
250            OrchestrationFailure::SchedulerInit("dag error".into())
251                .to_string()
252                .contains("dag error")
253        );
254        assert!(
255            OrchestrationFailure::VerifyConfig("bad config".into())
256                .to_string()
257                .contains("bad config")
258        );
259        assert!(
260            OrchestrationFailure::PlannerError("plan failed".into())
261                .to_string()
262                .contains("plan failed")
263        );
264        assert!(
265            OrchestrationFailure::RetryReset("reset failed".into())
266                .to_string()
267                .contains("reset failed")
268        );
269    }
270
271    #[test]
272    fn unknown_command_display() {
273        let e = AgentError::UnknownCommand("/foo".into());
274        assert!(e.to_string().contains("/foo"));
275    }
276
277    #[test]
278    fn skill_operation_display() {
279        let e =
280            AgentError::SkillOperation(SkillOperationFailure::DirectoryNotFound("my-skill".into()));
281        assert!(e.to_string().contains("my-skill"));
282    }
283
284    #[test]
285    fn skill_operation_failure_variants_display() {
286        assert!(
287            SkillOperationFailure::InvalidName("bad/name".into())
288                .to_string()
289                .contains("bad/name")
290        );
291        assert!(
292            SkillOperationFailure::DirectoryNotFound("foo".into())
293                .to_string()
294                .contains("foo")
295        );
296    }
297}