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