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