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