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