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
121impl AgentError {
122    /// Returns true if this error originates from a context length exceeded condition.
123    #[must_use]
124    pub fn is_context_length_error(&self) -> bool {
125        if let Self::Llm(e) = self {
126            return e.is_context_length_error();
127        }
128        false
129    }
130
131    /// Returns true if this error indicates that a beta header was rejected by the API.
132    #[must_use]
133    pub fn is_beta_header_rejected(&self) -> bool {
134        if let Self::Llm(e) = self {
135            return e.is_beta_header_rejected();
136        }
137        false
138    }
139
140    /// Returns true if this error is `LlmError::NoProviders` (all configured backends unavailable).
141    #[must_use]
142    pub fn is_no_providers(&self) -> bool {
143        matches!(self, Self::Llm(zeph_llm::LlmError::NoProviders))
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn agent_error_detects_context_length_from_llm() {
153        let e = AgentError::Llm(zeph_llm::LlmError::ContextLengthExceeded);
154        assert!(e.is_context_length_error());
155    }
156
157    #[test]
158    fn agent_error_detects_context_length_from_typed_variant() {
159        // Providers must return ContextLengthExceeded directly, not Other.
160        let e = AgentError::Llm(zeph_llm::LlmError::ContextLengthExceeded);
161        assert!(e.is_context_length_error());
162    }
163
164    #[test]
165    fn agent_error_other_with_context_message_not_detected() {
166        // The `Other` path no longer triggers context-length classification;
167        // providers are responsible for returning ContextLengthExceeded directly.
168        let e = AgentError::Llm(zeph_llm::LlmError::Other("context length exceeded".into()));
169        assert!(!e.is_context_length_error());
170    }
171
172    #[test]
173    fn agent_error_non_llm_variant_not_detected() {
174        let e = AgentError::ContextError("something went wrong".into());
175        assert!(!e.is_context_length_error());
176    }
177
178    #[test]
179    fn shutdown_variant_display() {
180        let e = AgentError::Shutdown;
181        assert_eq!(e.to_string(), "agent shut down");
182    }
183
184    #[test]
185    fn context_exhausted_variant_display() {
186        let e = AgentError::ContextExhausted("no space left".into());
187        assert!(e.to_string().contains("no space left"));
188    }
189
190    #[test]
191    fn tool_timeout_variant_display() {
192        let e = AgentError::ToolTimeout {
193            tool_name: "bash".into(),
194        };
195        assert!(e.to_string().contains("bash"));
196    }
197
198    #[test]
199    fn schema_validation_variant_display() {
200        let e = AgentError::SchemaValidation("missing field".into());
201        assert!(e.to_string().contains("missing field"));
202    }
203
204    #[test]
205    fn agent_error_detects_beta_header_rejected() {
206        let e = AgentError::Llm(zeph_llm::LlmError::BetaHeaderRejected {
207            header: "compact-2026-01-12".into(),
208        });
209        assert!(e.is_beta_header_rejected());
210    }
211
212    #[test]
213    fn agent_error_non_llm_variant_not_beta_rejected() {
214        let e = AgentError::ContextError("something went wrong".into());
215        assert!(!e.is_beta_header_rejected());
216    }
217
218    #[test]
219    fn agent_error_detects_no_providers() {
220        let e = AgentError::Llm(zeph_llm::LlmError::NoProviders);
221        assert!(e.is_no_providers());
222    }
223
224    #[test]
225    fn agent_error_non_no_providers_returns_false() {
226        let e = AgentError::ContextError("other".into());
227        assert!(!e.is_no_providers());
228    }
229
230    #[test]
231    fn orchestration_error_display() {
232        let e =
233            AgentError::OrchestrationError(OrchestrationFailure::Generic("planner failed".into()));
234        assert!(e.to_string().contains("planner failed"));
235    }
236
237    #[test]
238    fn orchestration_failure_variants_display() {
239        assert!(
240            OrchestrationFailure::SchedulerInit("dag error".into())
241                .to_string()
242                .contains("dag error")
243        );
244        assert!(
245            OrchestrationFailure::VerifyConfig("bad config".into())
246                .to_string()
247                .contains("bad config")
248        );
249        assert!(
250            OrchestrationFailure::PlannerError("plan failed".into())
251                .to_string()
252                .contains("plan failed")
253        );
254        assert!(
255            OrchestrationFailure::RetryReset("reset failed".into())
256                .to_string()
257                .contains("reset failed")
258        );
259    }
260
261    #[test]
262    fn unknown_command_display() {
263        let e = AgentError::UnknownCommand("/foo".into());
264        assert!(e.to_string().contains("/foo"));
265    }
266
267    #[test]
268    fn skill_operation_display() {
269        let e =
270            AgentError::SkillOperation(SkillOperationFailure::DirectoryNotFound("my-skill".into()));
271        assert!(e.to_string().contains("my-skill"));
272    }
273
274    #[test]
275    fn skill_operation_failure_variants_display() {
276        assert!(
277            SkillOperationFailure::InvalidName("bad/name".into())
278                .to_string()
279                .contains("bad/name")
280        );
281        assert!(
282            SkillOperationFailure::DirectoryNotFound("foo".into())
283                .to_string()
284                .contains("foo")
285        );
286    }
287}