Skip to main content

zeph_subagent/
error.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4/// All errors that can arise during sub-agent lifecycle operations.
5///
6/// [`SubAgentError`] is the single error type for the entire `zeph-subagent` crate.
7/// Every fallible public function returns `Result<_, SubAgentError>`.
8///
9/// # Examples
10///
11/// ```rust
12/// use zeph_subagent::{SubAgentDef, SubAgentError};
13///
14/// let err = SubAgentDef::parse("missing frontmatter").unwrap_err();
15/// assert!(matches!(err, SubAgentError::Parse { .. }));
16/// ```
17#[derive(Debug, thiserror::Error)]
18#[non_exhaustive]
19pub enum SubAgentError {
20    /// Frontmatter parsing failed (malformed YAML/TOML or missing delimiters).
21    #[error("parse error in {path}: {reason}")]
22    Parse { path: String, reason: String },
23
24    /// Definition semantics are invalid (e.g. empty name, conflicting tool policies).
25    #[error("invalid definition: {0}")]
26    Invalid(String),
27
28    /// No definition or running agent with the requested name or ID was found.
29    #[error("agent not found: {0}")]
30    NotFound(String),
31
32    /// The background task could not be spawned (OS or tokio error).
33    #[error("spawn failed: {0}")]
34    Spawn(String),
35
36    /// The manager's concurrency limit is exhausted; no new agents can be spawned.
37    #[error("concurrency limit reached (active: {active}, max: {max})")]
38    ConcurrencyLimit { active: usize, max: usize },
39
40    /// The agent loop was cancelled via its [`tokio_util::sync::CancellationToken`].
41    #[error("cancelled")]
42    Cancelled,
43
44    /// A slash-command string (`/agent`, `/agents`) could not be parsed.
45    #[error("invalid command: {0}")]
46    InvalidCommand(String),
47
48    /// An I/O operation on a transcript file failed.
49    #[error("transcript error: {0}")]
50    Transcript(String),
51
52    /// A transcript's hash chain failed to verify (issue #6360): a definite tamper verdict, a
53    /// partial strip of chain metadata, an unverifiable/possibly-re-keyed chain, or a chained
54    /// file read with no history-integrity key configured. Distinct from [`SubAgentError::Transcript`]
55    /// (JSON-syntax/I-O errors) because a chain break always escalates to a hard failure — even
56    /// in `TranscriptReader::load`'s otherwise-lenient mode — since it invalidates trust in
57    /// everything downstream of the break, unlike a single malformed line.
58    #[error("{0}")]
59    Integrity(String),
60
61    /// An ID prefix matched more than one transcript; provide a longer prefix.
62    #[error("ambiguous id prefix '{0}': matches {1} agents")]
63    AmbiguousId(String, usize),
64
65    /// Resume was requested for an agent that is still running.
66    #[error("agent '{0}' is still running; cancel it first or wait for completion")]
67    StillRunning(String),
68
69    /// A memory directory could not be created or resolved.
70    #[error("memory error for agent '{name}': {reason}")]
71    Memory { name: String, reason: String },
72
73    /// A filesystem I/O error unrelated to transcripts.
74    #[error("I/O error at {path}: {reason}")]
75    Io { path: String, reason: String },
76
77    /// The underlying LLM provider returned an error during the agent loop.
78    #[error("LLM call failed: {0}")]
79    Llm(String),
80
81    /// A channel send (status watch, secret approval) failed.
82    #[error("channel send failed: {0}")]
83    Channel(String),
84
85    /// The tokio task panicked and the join handle propagated the panic.
86    #[error("task panicked: {0}")]
87    TaskPanic(String),
88
89    /// The recursion depth for nested sub-agent spawning exceeded the configured limit.
90    #[error("max spawn depth exceeded (depth: {depth}, max: {max})")]
91    MaxDepthExceeded { depth: u32, max: u32 },
92
93    /// Worktree creation or cwd setup failed during agent spawn.
94    ///
95    /// This error is returned when `permissions.worktree = true` and the worktree
96    /// manager fails to create a dedicated worktree or cannot restore the working
97    /// directory.  The agent loop never starts in this case (INV-4).
98    #[error("worktree setup failed: {0}")]
99    WorktreeSetup(String),
100
101    /// The durable promise layer returned an error during subagent spawn or await.
102    ///
103    /// Wraps a [`zeph_durable::DurableError`] string so the crate does not take a hard
104    /// compile-time dependency on `zeph-durable` in code paths where the feature is disabled
105    /// at runtime (the `durable` module is always compiled in but the adapter functions are
106    /// only called when `durable.enabled && durable.subagent`).
107    #[error("durable error: {0}")]
108    Durable(String),
109
110    /// The spawn attempt was rejected by `delegation_mode` (spec
111    /// `042-subagent-delegation-mode-parity`, issue #5857): either `delegation_mode =
112    /// "disabled"` (all spawns rejected) or `delegation_mode = "explicit_request_only"` and
113    /// `origin` was [`SpawnOrigin::Autonomous`](crate::manager::SpawnOrigin). Distinct from
114    /// [`SubAgentError::ConcurrencyLimit`] and [`SubAgentError::MaxDepthExceeded`] so the
115    /// rejection reason is unambiguous in logs (FR-007).
116    #[error(
117        "delegation denied: mode={mode:?} origin={origin:?} agent='{def_name}' \
118         (see [agents].delegation_mode / [agents].enabled in config.toml)"
119    )]
120    DelegationDenied {
121        mode: zeph_config::DelegationMode,
122        origin: crate::manager::SpawnOrigin,
123        def_name: String,
124    },
125
126    /// The session-wide cumulative spawn budget has been exhausted (issue #6545).
127    ///
128    /// Distinct from [`SubAgentError::ConcurrencyLimit`] (bounds in-flight agents) and
129    /// [`SubAgentError::MaxDepthExceeded`] (bounds recursion depth): this bounds the total
130    /// number of subagents spawned over the session's lifetime, independent of both, so a
131    /// shallow, low-concurrency but high-frequency sequential delegation loop is still caught.
132    /// The `Display` string names the config key directly because the only user-visible
133    /// surface for most callers is `format!("Failed to spawn sub-agent: {e}")`.
134    #[error(
135        "session spawn limit reached (spawned: {spawned}, max: {max}) — raise \
136         [agents].max_spawns_per_session in config.toml, or set it to 0 for unlimited"
137    )]
138    SessionSpawnLimit { spawned: usize, max: usize },
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    /// NFR-004 (issue #6545): the only user-visible surface for most callers is
146    /// `format!("Failed to spawn sub-agent: {e}")`, so the remedy must live in `Display`
147    /// itself — verify the config key is named verbatim, not just implied.
148    #[test]
149    fn session_spawn_limit_display_names_config_key() {
150        let err = SubAgentError::SessionSpawnLimit {
151            spawned: 100,
152            max: 100,
153        };
154        let msg = err.to_string();
155        assert!(
156            msg.contains("[agents].max_spawns_per_session"),
157            "Display must name the config key verbatim, got: {msg}"
158        );
159    }
160}