Skip to main content

zeph_subagent/manager/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Sub-agent lifecycle management: spawn, cancel, collect, and resume.
5
6mod collect;
7mod secrets;
8mod spawn;
9mod worktree;
10
11use std::collections::{HashMap, HashSet};
12use std::path::PathBuf;
13use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
14use std::time::Instant;
15
16use tokio::sync::{mpsc, watch};
17use tokio::task::JoinSet;
18use tokio_util::sync::CancellationToken;
19use zeph_common::task_supervisor::BlockingHandle;
20use zeph_common::{SkillTrustLevel, TaskSupervisor};
21use zeph_config::{ContentIsolationConfig, McpServerConfig};
22use zeph_llm::provider::Message;
23
24use crate::def::{PermissionMode, SubAgentDef};
25use crate::durable::DurableResolverSeat;
26use crate::error::SubAgentError;
27use crate::fleet::SharedFleetRegistry;
28use crate::forward::ForwardSurfaces;
29use crate::grants::{GrantedSecret, PermissionGrants, SecretRequest};
30use crate::state::SubAgentState;
31
32/// Classifies what triggered a given spawn attempt, for [`DelegationMode`] enforcement
33/// (spec `042-subagent-delegation-mode-parity`, issue #5857).
34///
35/// # Fail-closed default
36///
37/// [`SpawnOrigin::default`] is [`SpawnOrigin::Autonomous`] — the *restrictive* value, not the
38/// permissive one. An untagged or forgotten [`SpawnContext`] therefore reads as `Autonomous`
39/// and is denied under [`DelegationMode::ExplicitRequestOnly`]/[`DelegationMode::Disabled`],
40/// never silently allowed. Only a caller that explicitly sets `origin = SpawnOrigin::Explicit`
41/// (after auditing that the trigger really is a direct, attributable user action) can bypass
42/// the `explicit_request_only` restriction. A mistagged legitimate spawn fails visibly (a
43/// `tracing::warn!` plus a denied spawn); a mistagged illegitimate one can never be silently
44/// let through.
45///
46/// [`DelegationMode`]: zeph_config::DelegationMode
47/// [`DelegationMode::ExplicitRequestOnly`]: zeph_config::DelegationMode::ExplicitRequestOnly
48/// [`DelegationMode::Disabled`]: zeph_config::DelegationMode::Disabled
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum SpawnOrigin {
51    /// A direct, attributable user action in the current turn (e.g. `/agent spawn`,
52    /// `/agent resume`) — always permitted except under `delegation_mode = "disabled"`.
53    Explicit,
54    /// Autonomous planner/scheduler decision-making with no corresponding explicit user
55    /// request in the current turn (e.g. the orchestration scheduler's DAG dispatch). Denied
56    /// under `delegation_mode = "explicit_request_only"` and `"disabled"`.
57    Autonomous,
58}
59
60impl Default for SpawnOrigin {
61    /// Fail-closed: see the type-level doc comment.
62    fn default() -> Self {
63        Self::Autonomous
64    }
65}
66
67/// Parent-derived state propagated to a spawned sub-agent at spawn time.
68///
69/// All fields default to empty/`None`, preserving existing behavior when callers
70/// pass `SpawnContext::default()`.
71///
72/// # Constraint propagation
73///
74/// [`max_trust_level`][Self::max_trust_level] and
75/// [`inherited_tool_allowlist`][Self::inherited_tool_allowlist] implement transitive
76/// constraint propagation: safety constraints set at orchestration time are enforced on
77/// every sub-agent in the spawn chain, regardless of nesting depth.
78///
79/// When a sub-agent spawns its own sub-agents it must forward these fields downward so
80/// that grandchild agents cannot silently receive more privileges than the original
81/// orchestration policy allowed.
82///
83/// # Examples
84///
85/// ```rust
86/// use zeph_subagent::manager::{SpawnContext, SpawnOrigin};
87///
88/// // Minimal context — all fields use their defaults.
89/// let ctx = SpawnContext::default();
90/// assert!(ctx.parent_messages.is_empty());
91/// assert_eq!(ctx.spawn_depth, 0);
92/// assert!(ctx.max_trust_level.is_none());
93/// assert!(ctx.inherited_tool_allowlist.is_none());
94/// // Fail-closed: an untagged context reads as Autonomous, not Explicit.
95/// assert_eq!(ctx.origin, SpawnOrigin::Autonomous);
96/// ```
97#[derive(Default)]
98pub struct SpawnContext {
99    /// Recent parent conversation messages (last N turns).
100    pub parent_messages: Vec<Message>,
101    /// Parent's cancellation token for linked cancellation (foreground spawns).
102    pub parent_cancel: Option<CancellationToken>,
103    /// Parent's active provider name (for context propagation).
104    pub parent_provider_name: Option<String>,
105    /// Current spawn depth (0 = top-level agent).
106    pub spawn_depth: u32,
107    /// MCP tool names available in the parent's tool executor (for diagnostics).
108    pub mcp_tool_names: Vec<String>,
109    /// Seeded trajectory risk score from the parent sentinel (spec 050 §4).
110    ///
111    /// When `Some`, the subagent's `TrajectorySentinel` starts with this pre-seeded score
112    /// rather than `0.0`, preventing a subagent spawn from acting as a free risk reset.
113    /// The subagent loop applies this via `TrajectorySentinel::seed_score` after build.
114    pub seed_trajectory_score: Option<f32>,
115    /// Parent's content isolation config, propagated so the subagent loop can run the
116    /// same sanitizer settings on hook-replaced tool output.
117    pub content_isolation: ContentIsolationConfig,
118    /// Name of the orchestrator that spawned this subagent.
119    ///
120    /// When set, the subagent's system prompt includes an identity header naming the
121    /// orchestrator, so the subagent can validate that instructions are consistent with
122    /// the expected authority.
123    pub orchestrator_name: Option<String>,
124    /// Role or task label of the orchestrating agent (e.g., `"planner"`, `"tool-router"`).
125    ///
126    /// Injected alongside [`orchestrator_name`][Self::orchestrator_name] when both are set.
127    /// Omitted from the identity header when only `orchestrator_name` is provided.
128    pub orchestrator_role: Option<String>,
129    /// Per-session MCP servers to inject into this subagent's tool name annotations.
130    ///
131    /// The parent is responsible for connecting these servers and including them in the
132    /// `tool_executor` passed to [`SubAgentManager::spawn`]. This field only carries the
133    /// server metadata so the subagent's system prompt lists the additional tool names.
134    pub session_mcp_servers: Vec<McpServerConfig>,
135    /// Maximum trust level cap inherited from the parent agent or orchestration policy.
136    ///
137    /// When `Some(cap)`, the spawned sub-agent's effective trust level is clamped to
138    /// `min(own_trust, cap)` so that sub-agents can never receive higher privileges than
139    /// the orchestration policy originally allowed.
140    ///
141    /// # Caller responsibility for nested spawns
142    ///
143    /// This field does **not** propagate automatically. When a sub-agent itself spawns a
144    /// grandchild, it must copy this field from its own received `SpawnContext` into the
145    /// grandchild's `SpawnContext`. Passing `None` (the default) at that point means the
146    /// grandchild receives **no cap**, which is a privilege escalation if the parent was
147    /// constrained. Only the top-level session (spawned by `build_spawn_context`) correctly
148    /// leaves this `None` — that represents an unconstrained top-level entry point.
149    ///
150    /// `None` means no cap is imposed by the parent (the sub-agent's own definition
151    /// determines its trust level).
152    pub max_trust_level: Option<SkillTrustLevel>,
153    /// Shared per-turn trust floor (#6701) the cap in [`max_trust_level`][Self::max_trust_level]
154    /// is applied to.
155    ///
156    /// When `Some`, [`SubAgentManager::spawn`]/resume applies the cap via
157    /// [`zeph_common::TurnTrustFloor::fold`] on this handle directly — a monotonic downgrade
158    /// that can never raise trust — instead of calling `set_effective_trust` on the built
159    /// executor, which would be a full overwrite and could restore trust above a floor
160    /// already lowered earlier in the same task (e.g. by an explicit `invoke_skill` of a
161    /// Quarantined skill). `None` falls back to the pre-#6701 `set_effective_trust` behavior
162    /// (e.g. call sites/tests that construct an executor with no shared floor to fold).
163    ///
164    /// # Caller responsibility for nested spawns
165    ///
166    /// Like [`max_trust_level`][Self::max_trust_level], this field does **not** propagate
167    /// automatically — a sub-agent that spawns its own children must copy this field from
168    /// its received `SpawnContext` into the child's `SpawnContext`.
169    pub turn_trust_floor: Option<zeph_common::TurnTrustFloor>,
170    /// Tool names that this sub-agent is allowed to invoke, inherited from the parent.
171    ///
172    /// When `Some(set)`, the effective tool allowlist for the spawned agent is the
173    /// intersection of `set` and the agent's own definition policy. This prevents a
174    /// sub-agent from accessing tools that the parent is itself not allowed to use.
175    ///
176    /// # Caller responsibility for nested spawns
177    ///
178    /// Like [`max_trust_level`][Self::max_trust_level], this field does **not** propagate
179    /// automatically. When a constrained sub-agent spawns its own children, it must copy
180    /// this field from its received `SpawnContext` into the child's `SpawnContext`.
181    /// Passing `None` at that point would grant the grandchild unrestricted tool access,
182    /// defeating the original orchestration policy.
183    ///
184    /// `None` means no additional allowlist restriction is imposed by the parent
185    /// (the agent's definition policy applies without narrowing).
186    pub inherited_tool_allowlist: Option<HashSet<String>>,
187
188    /// Durable resolver seat for promise-based subagent spawn/await (spec-064 §P4, INV-9).
189    ///
190    /// When `Some`, the spawned background task resolves the parent's durable promise after the
191    /// agent loop terminates. The seat carries the resolver token and MUST NOT be forwarded to
192    /// the child's tool executor or LLM surface — only the background task wrapper consumes it.
193    ///
194    /// `None` when `durable.enabled && durable.subagent` is false (plain spawn/collect path).
195    pub durable_resolver: Option<DurableResolverSeat>,
196
197    /// Deny network egress for this sub-agent's `bash` tool calls.
198    ///
199    /// Set by the orchestration layer when the spawning `TaskNode` carries
200    /// `network_scope: NetworkScope::Deny` (spec `069-threat-model` OQ-1). When `true`,
201    /// `build_filtered_executor` wraps the tool executor with
202    /// [`NetworkDenyToolExecutor`](crate::NetworkDenyToolExecutor), which blocks `bash`
203    /// invocations of `curl`, `wget`, `nc`, `ncat`, and `netcat` for this spawn only —
204    /// sibling tasks and the parent agent's own executor are unaffected.
205    ///
206    /// # Caller responsibility for nested spawns
207    ///
208    /// Like [`max_trust_level`][Self::max_trust_level], this field does **not** propagate
209    /// automatically. A sub-agent that itself spawns a grandchild must copy this field
210    /// from its own received `SpawnContext` into the grandchild's `SpawnContext`, or the
211    /// grandchild spawns with network access regardless of the original task's scope.
212    ///
213    /// `false` (the default) imposes no restriction beyond the executor/global
214    /// `allow_network` default.
215    pub network_denied: bool,
216
217    /// Shared progress heartbeat for idle-timeout detection (issue #6245).
218    ///
219    /// Set by the orchestration driver (`handle_scheduler_spawn_action` in `zeph-core`'s
220    /// `scheduler_loop.rs`) alongside [`network_denied`][Self::network_denied] — same
221    /// post-construction assignment pattern, not part of `build_spawn_context`'s base
222    /// literal. The driver creates the `Arc`, clones it in here, and keeps the original for
223    /// `zeph_orchestration::DagScheduler::record_spawn`'s `last_progress_at` parameter so
224    /// both the running loop and the scheduler observe the same counter.
225    ///
226    /// `None` (the default) for spawns not tracked by a `DagScheduler` — e.g. the standalone
227    /// `/agent run` command — which are never idle-tracked.
228    pub progress_at: Option<Arc<std::sync::atomic::AtomicU64>>,
229
230    /// Cross-crate debug-dump sink, threaded down so sub-agent LLM calls are captured
231    /// through the same pipeline as the top-level agent loop's `--debug-dump` output (#6391).
232    ///
233    /// Set by `zeph-core`'s `build_spawn_context` from `DebugState::debug_dumper`. `None`
234    /// when debug dumps are disabled — no sub-agent dump is written in that case, mirroring
235    /// the top-level `debug_dumper: None` behavior.
236    ///
237    /// # Caller responsibility for nested spawns
238    ///
239    /// Like [`max_trust_level`][Self::max_trust_level], this does **not** propagate
240    /// automatically — a sub-agent that spawns its own children must copy this field from
241    /// its received `SpawnContext` into the child's, or grandchild LLM calls go undumped.
242    pub debug_dump_sink: Option<Arc<dyn zeph_llm::debug_dump::DebugDumpSink>>,
243
244    /// What triggered this spawn attempt — enforced against `delegation_mode` at the top of
245    /// [`SubAgentManager::spawn`] (spec `042-subagent-delegation-mode-parity`, issue #5857).
246    ///
247    /// Defaults to [`SpawnOrigin::Autonomous`] (fail-closed — see [`SpawnOrigin`]'s doc
248    /// comment). `zeph-core`'s `build_spawn_context` (the base used by the explicit
249    /// `/agent spawn`/`/agent resume` commands) sets this to [`SpawnOrigin::Explicit`]; the
250    /// orchestration scheduler overrides it back to `Autonomous` on its own call, mirroring
251    /// the existing post-construction override pattern used for
252    /// [`network_denied`][Self::network_denied] and [`progress_at`][Self::progress_at].
253    pub origin: SpawnOrigin,
254}
255
256/// Intersect two optional tool allowlists, preserving the "narrow only" invariant shared by
257/// [`SpawnContext::inherited_tool_allowlist`] and `TaskNode::tool_allowlist` (#6526/#6527):
258/// `(None, None)` → `None` (no restriction from either source), one side `Some` → that side
259/// (the only source of narrowing), both `Some` → their set intersection. Never produces a
260/// wider set than either input, so composing a parent-derived floor with a per-task
261/// allowlist can only narrow further.
262///
263/// # Examples
264///
265/// ```rust
266/// use std::collections::HashSet;
267/// use zeph_subagent::manager::intersect_allowlists;
268///
269/// assert_eq!(intersect_allowlists(None, None), None);
270///
271/// let a: HashSet<String> = ["read".to_owned(), "grep".to_owned()].into();
272/// assert_eq!(intersect_allowlists(Some(a.clone()), None), Some(a));
273///
274/// let b: HashSet<String> = ["grep".to_owned(), "bash".to_owned()].into();
275/// let want: HashSet<String> = ["grep".to_owned()].into();
276/// let a: HashSet<String> = ["read".to_owned(), "grep".to_owned()].into();
277/// assert_eq!(intersect_allowlists(Some(a), Some(b)), Some(want));
278/// ```
279// Every caller uses the std `RandomState` default hasher (constraint propagation on
280// SpawnContext/TaskNode allowlists, never a caller-supplied HashSet<_, S>), and
281// `HashSet::intersection` requires both sides to share the same hasher type anyway —
282// generalizing over `S` here would add a type parameter with no real call site benefit.
283#[allow(clippy::implicit_hasher)]
284#[must_use]
285pub fn intersect_allowlists(
286    a: Option<HashSet<String>>,
287    b: Option<HashSet<String>>,
288) -> Option<HashSet<String>> {
289    match (a, b) {
290        (None, None) => None,
291        (Some(set), None) | (None, Some(set)) => Some(set),
292        (Some(a), Some(b)) => Some(a.intersection(&b).cloned().collect()),
293    }
294}
295
296/// Live status snapshot of a running sub-agent.
297///
298/// Values are updated by the background agent loop via a [`tokio::sync::watch`] channel.
299/// Callers receive snapshots via [`SubAgentManager::statuses`].
300#[derive(Debug, Clone)]
301pub struct SubAgentStatus {
302    /// Current lifecycle state of the agent task.
303    pub state: SubAgentState,
304    /// Last message content from the agent (trimmed for display).
305    pub last_message: Option<String>,
306    /// Number of LLM turns consumed so far.
307    pub turns_used: u32,
308    /// Monotonic timestamp recorded at spawn time.
309    pub started_at: Instant,
310}
311
312/// Handle to a spawned sub-agent task, owned by [`SubAgentManager`].
313///
314/// Fields are public to allow test harnesses in downstream crates to construct handles
315/// without going through the full spawn lifecycle. Production code must not mutate
316/// grants or the cancellation state directly — use the [`SubAgentManager`] API instead.
317///
318/// The `Drop` implementation cancels the task and revokes all grants as a safety net.
319pub struct SubAgentHandle {
320    /// Short display ID (same as `task_id` for non-resumed sessions).
321    pub id: String,
322    /// The definition that was used to spawn this agent.
323    pub def: SubAgentDef,
324    /// UUID assigned at spawn time (currently identical to `id`; separated for future use).
325    pub task_id: String,
326    /// Cached state — may lag the background task by one watch broadcast.
327    pub state: SubAgentState,
328    /// Supervised handle for the background agent loop task.
329    pub join_handle: Option<BlockingHandle<Result<String, SubAgentError>>>,
330    /// Cancellation token; cancelled on [`SubAgentManager::cancel`] or drop.
331    pub cancel: CancellationToken,
332    /// Watch receiver for live status updates from the agent loop.
333    pub status_rx: watch::Receiver<SubAgentStatus>,
334    /// Zero-trust TTL-bounded grants for this agent session.
335    ///
336    /// Shared with the spawned agent-loop task via `Arc<Mutex<..>>` (issue #6567) so that
337    /// `GrantKind::Tool` enforcement inside `handle_tool_step` observes the same live state
338    /// this handle mutates — in particular so `revoke_all()` called here (task collection,
339    /// cancellation, or drop) is immediately visible to the running loop task without a
340    /// delivery channel. `GrantKind::Secret` values are still delivered separately over
341    /// `secret_tx`/`secret_rx` as a point-in-time snapshot, since secrets are explicitly
342    /// requested per-key rather than checked implicitly on every dispatch.
343    pub grants: Arc<Mutex<PermissionGrants>>,
344    /// Receives secret requests from the sub-agent loop.
345    pub pending_secret_rx: mpsc::Receiver<SecretRequest>,
346    /// Delivers the approval outcome to the sub-agent loop: `None` = denied,
347    /// `Some(value)` = approved, carrying the resolved vault secret value and its
348    /// grant expiry so the loop can re-validate the TTL locally on every tool call.
349    pub secret_tx: mpsc::Sender<Option<GrantedSecret>>,
350    /// ISO 8601 UTC timestamp recorded when the agent was spawned or resumed.
351    pub started_at_str: String,
352    /// Resolved transcript directory at spawn time; `None` if transcripts were disabled.
353    pub transcript_dir: Option<PathBuf>,
354    /// MCP tool names available at spawn time, persisted for transcript meta on collect.
355    pub mcp_tool_names: Vec<String>,
356}
357
358impl SubAgentHandle {
359    /// Construct a minimal [`SubAgentHandle`] for use in unit tests.
360    ///
361    /// The returned handle has a no-op cancel token, closed channels, and no grants.
362    /// It must not be spawned or collected — it is only valid for inspection logic
363    /// that operates on the handle's metadata fields (id, def, state, etc.).
364    #[cfg(test)]
365    pub fn for_test(id: impl Into<String>, def: SubAgentDef) -> Self {
366        let initial_status = SubAgentStatus {
367            state: SubAgentState::Working,
368            last_message: None,
369            turns_used: 0,
370            started_at: Instant::now(),
371        };
372        let (status_tx, status_rx) = watch::channel(initial_status);
373        drop(status_tx);
374        let (pending_secret_rx_tx, pending_secret_rx) = mpsc::channel(1);
375        drop(pending_secret_rx_tx);
376        let (secret_tx, _) = mpsc::channel(1);
377        let id_str = id.into();
378        Self {
379            task_id: id_str.clone(),
380            id: id_str,
381            def,
382            state: SubAgentState::Working,
383            join_handle: None,
384            cancel: CancellationToken::new(),
385            status_rx,
386            grants: Arc::new(Mutex::new(PermissionGrants::default())),
387            pending_secret_rx,
388            secret_tx,
389            started_at_str: String::new(),
390            transcript_dir: None,
391            mcp_tool_names: Vec::new(),
392        }
393    }
394
395    /// Lock the shared, TTL-bounded permission grants for this agent.
396    ///
397    /// Recovers from a poisoned lock (a panic elsewhere while holding it) rather than
398    /// propagating the panic. This is plainly **fail-open**, not fail-closed: a poisoned
399    /// mutex is ignored and the guard is handed back regardless, so this method makes no
400    /// guarantee about the freshness or consistency of the `PermissionGrants` it returns.
401    /// That is safe today because every operation performed under this lock
402    /// (`PermissionGrants::add`/`sweep_expired`/`is_active`/`check_tool_grant`/`revoke_all`)
403    /// is a plain, infallible in-memory `Vec` operation — none of them can panic in normal
404    /// operation, so poisoning can only originate from an unrelated bug elsewhere, and simply
405    /// proceeding with the (still internally consistent) data is the pragmatic choice over
406    /// permanently wedging every subsequent tool dispatch for this sub-agent. The actual
407    /// fail-closed behavior for `GrantKind::Tool` enforcement lives one layer up, in
408    /// `PermissionGrants::check_tool_grant` (issue #6567), which rejects the tool call on an
409    /// expired/revoked grant independent of whether this lock happened to be poisoned.
410    pub(crate) fn grants_lock(&self) -> MutexGuard<'_, PermissionGrants> {
411        self.grants.lock().unwrap_or_else(PoisonError::into_inner)
412    }
413}
414
415impl std::fmt::Debug for SubAgentHandle {
416    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
417        f.debug_struct("SubAgentHandle")
418            .field("id", &self.id)
419            .field("task_id", &self.task_id)
420            .field("state", &self.state)
421            .field("def_name", &self.def.name)
422            .finish_non_exhaustive()
423    }
424}
425
426impl Drop for SubAgentHandle {
427    fn drop(&mut self) {
428        // Defense-in-depth: cancel the task and revoke grants on drop even if
429        // cancel() or collect() was not called (e.g., on panic or early return).
430        self.cancel.cancel();
431        let mut grants = self.grants_lock();
432        if !grants.is_empty_grants() {
433            tracing::warn!(
434                id = %self.id,
435                "SubAgentHandle dropped without explicit cleanup — revoking grants"
436            );
437        }
438        grants.revoke_all();
439    }
440}
441
442/// Manages sub-agent lifecycle: definitions, spawning, cancellation, and result collection.
443///
444/// `SubAgentManager` is the central coordinator for all sub-agent tasks. It tracks active
445/// [`SubAgentHandle`]s, enforces the global concurrency limit, and stores loaded
446/// [`SubAgentDef`]s.
447///
448/// # Concurrency model
449///
450/// The concurrency limit counts agents whose [`SubAgentState`] is `Submitted` or `Working`.
451/// Reserved slots (via [`reserve_slots`][Self::reserve_slots]) also count against this limit
452/// to allow orchestration schedulers to guarantee capacity before spawning.
453///
454/// # Examples
455///
456/// ```rust
457/// use zeph_subagent::SubAgentManager;
458///
459/// let manager = SubAgentManager::new(4);
460/// assert_eq!(manager.definitions().len(), 0);
461/// ```
462pub struct SubAgentManager {
463    definitions: Vec<SubAgentDef>,
464    agents: HashMap<String, SubAgentHandle>,
465    max_concurrent: usize,
466    /// Number of slots soft-reserved by the orchestration scheduler.
467    ///
468    /// Reserved slots count against the concurrency limit so that the scheduler can
469    /// guarantee capacity for tasks it is about to spawn, preventing a planning-phase
470    /// sub-agent from exhausting the pool and causing a deadlock.
471    reserved_slots: usize,
472    /// Config-level `SubagentStop` hooks, cached so `cancel()` and `collect()` can fire them.
473    stop_hooks: Vec<super::hooks::HookDef>,
474    /// Directory for JSONL transcripts and meta sidecars.
475    transcript_dir: Option<PathBuf>,
476    /// Maximum number of transcript files to keep (0 = unlimited).
477    transcript_max_files: usize,
478    /// Optional fleet registry for registering sub-agents in the fleet dashboard.
479    ///
480    /// When `None`, fleet registration is skipped silently. Inject via
481    /// [`set_fleet_registry`][Self::set_fleet_registry].
482    fleet_registry: Option<SharedFleetRegistry>,
483    /// Tracks fire-and-forget hook and fleet-registry tasks to prevent silent panic swallowing.
484    ///
485    /// Completed and panicked tasks are drained before each new spawn. On graceful shutdown,
486    /// [`shutdown_all`][Self::shutdown_all] aborts all outstanding tasks via
487    /// [`JoinSet::shutdown`].
488    hook_tasks: JoinSet<()>,
489    /// Maximum number of concurrent hook tasks allowed in [`hook_tasks`][Self::hook_tasks].
490    ///
491    /// When the limit is reached, new fire-and-forget tasks are dropped with a warning instead
492    /// of growing the set unboundedly under high-throughput spawning.
493    max_hook_tasks: usize,
494    /// Optional worktree manager; `Some` iff `worktree.enabled = true` in config.
495    ///
496    /// When set, every [`spawn`][Self::spawn] acquires [`cwd_lock`][Self::cwd_lock] for
497    /// its full run so that plain agents cannot observe a stale cwd mutated by a worktree
498    /// agent (INV-1). Only agents with `permissions.worktree = true` and a non-`None`
499    /// `bg_isolation` actually get a dedicated worktree.
500    ///
501    /// This is the single live instance shared by the running agent's `/worktree`
502    /// slash command (see [`worktree_manager`][Self::worktree_manager]) — distinct from
503    /// the CLI's `zeph worktree list`/`clean`, which constructs its own fresh manager
504    /// per invocation (`src/commands/worktree.rs`).
505    worktree_manager: Option<Arc<zeph_worktree::DefaultWorktreeManager>>,
506    /// Process-level serialisation mutex for working-directory mutations (INV-1).
507    ///
508    /// Acquired by every spawned task when `worktree_manager.is_some()`.  The
509    /// `OwnedMutexGuard` is held for the full duration of `run_agent_loop` via the
510    /// `CwdRestoreGuard` RAII wrapper.
511    cwd_lock: Arc<tokio::sync::Mutex<()>>,
512    /// Optional supervisor for subagent lifecycle tasks.
513    ///
514    /// When set, each spawned agent loop task is registered under its task ID so it is
515    /// visible to TUI status panels and shutdown is coordinated through the supervisor.
516    task_supervisor: Option<TaskSupervisor>,
517    /// Which forwarding consumer surfaces are active for this session (issue #6359).
518    ///
519    /// Fixed at session start via [`set_forward_surfaces`][Self::set_forward_surfaces].
520    /// `ForwardSurfaces::default()` (all `false`) until then, matching "forwarding
521    /// disabled" byte-for-byte (FR-001/NFR-003).
522    forward_surfaces: ForwardSurfaces,
523    /// Ring buffer of recent sanitized display lines per task ID, owned by each task's
524    /// forwarding drain (issue #6359). Read by [`forwarded_tail`][Self::forwarded_tail]
525    /// for the TUI runtime detail view; entries are evicted by the drain itself shortly
526    /// after that task's terminal chunk.
527    forward_buffer: Arc<crate::forward::ForwardBuffer>,
528    /// Optional secret-mask registry applied to forwarded chunks (issue #6359, security
529    /// Finding 1 / NFR-005) — closes the consistency gap with the analogous outbound-LLM
530    /// egress path (`apply_secret_masking`), which the forwarding pipeline would otherwise
531    /// bypass. `None` (the default) means forwarded content is not masked for known vault
532    /// secrets — set via [`set_secret_registry`][Self::set_secret_registry].
533    secret_registry: Option<Arc<zeph_sanitizer::secret_mask::SecretMaskRegistry>>,
534    /// Optional PII filter applied to forwarded chunks (issue #6359, security Finding 1 /
535    /// NFR-005) — mirrors the optional `PiiFilter` layer sub-agent debug dumps get via
536    /// `PiiScrubbingDumpSink` (#6407). `None` (the default) means no PII scrubbing beyond the
537    /// baseline `ContentSanitizer` pass — set via [`set_pii_filter`][Self::set_pii_filter].
538    pii_filter: Option<zeph_sanitizer::pii::PiiFilter>,
539    /// Effective delegation mode gating every spawn (spec `042-subagent-delegation-mode-parity`,
540    /// issue #5857). Already folds in the `enabled` outer kill switch — the bootstrap caller
541    /// computes `if !config.agents.enabled { Disabled } else { config.agents.delegation_mode }`
542    /// before calling [`set_delegation_mode`][Self::set_delegation_mode], so this field alone
543    /// is authoritative at spawn time; `SubAgentManager` does not re-read `enabled` itself.
544    /// Defaults to [`zeph_config::DelegationMode::default()`] (`Proactive`) until set.
545    delegation_mode: zeph_config::DelegationMode,
546    /// Session-wide cumulative subagent-spawn budget (issue #6545).
547    ///
548    /// This manager owns the origin instance: [`session_budget`][Self::session_budget] exposes
549    /// a `&SessionSpawnBudget` reference so other chokepoints that never touch this manager —
550    /// e.g. the ACP `/subagent spawn` path in `zeph-core`'s `handle_subagent_slash` — can
551    /// enforce the same session-wide cap. See [`SessionSpawnBudget`]'s own doc comment for why
552    /// it is a plain, uncloned `AtomicUsize` newtype rather than a shared `Arc` handle.
553    session_spawn_budget: crate::budget::SessionSpawnBudget,
554}
555
556impl std::fmt::Debug for SubAgentManager {
557    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
558        f.debug_struct("SubAgentManager")
559            .field("definitions_count", &self.definitions.len())
560            .field("active_agents", &self.agents.len())
561            .field("max_concurrent", &self.max_concurrent)
562            .field("reserved_slots", &self.reserved_slots)
563            .field("stop_hooks_count", &self.stop_hooks.len())
564            .field("transcript_dir", &self.transcript_dir)
565            .field("transcript_max_files", &self.transcript_max_files)
566            .field("fleet_registry", &self.fleet_registry.is_some())
567            .field("hook_tasks_len", &self.hook_tasks.len())
568            .field("max_hook_tasks", &self.max_hook_tasks)
569            .field("worktree_manager", &self.worktree_manager.is_some())
570            .field("cwd_lock", &"<Mutex>")
571            .field("task_supervisor", &self.task_supervisor.is_some())
572            .field("forward_surfaces", &self.forward_surfaces)
573            .field("forward_buffer", &"<Mutex>")
574            .field("secret_registry", &self.secret_registry.is_some())
575            .field("pii_filter", &self.pii_filter.is_some())
576            .field("delegation_mode", &self.delegation_mode)
577            .field("session_spawn_budget", &self.session_spawn_budget)
578            .finish()
579    }
580}
581
582impl SubAgentManager {
583    /// Create a new manager with the given concurrency limit.
584    #[must_use]
585    pub fn new(max_concurrent: usize) -> Self {
586        Self {
587            definitions: Vec::new(),
588            agents: HashMap::new(),
589            max_concurrent,
590            reserved_slots: 0,
591            stop_hooks: Vec::new(),
592            transcript_dir: None,
593            transcript_max_files: 50,
594            fleet_registry: None,
595            hook_tasks: JoinSet::new(),
596            max_hook_tasks: 64,
597            worktree_manager: None,
598            cwd_lock: Arc::new(tokio::sync::Mutex::new(())),
599            task_supervisor: None,
600            forward_surfaces: ForwardSurfaces::default(),
601            forward_buffer: crate::forward::new_buffer(),
602            secret_registry: None,
603            pii_filter: None,
604            delegation_mode: zeph_config::DelegationMode::default(),
605            session_spawn_budget: crate::budget::SessionSpawnBudget::default(),
606        }
607    }
608
609    /// The session-wide cumulative subagent-spawn budget this manager originates (issue
610    /// #6545).
611    ///
612    /// Returns a reference to this manager's own budget instance — not a fresh, independent
613    /// one — so a caller reading through this accessor observes the same cumulative count as
614    /// every spawn through this manager. See
615    /// [`SessionSpawnBudget`][crate::budget::SessionSpawnBudget]'s doc comment for why the type
616    /// itself has no `Clone`/`Arc`.
617    #[must_use]
618    pub fn session_budget(&self) -> &crate::budget::SessionSpawnBudget {
619        &self.session_spawn_budget
620    }
621
622    /// Inject a [`TaskSupervisor`] so subagent lifecycle tasks are registered and visible.
623    ///
624    /// Must be called before the first [`spawn`][Self::spawn]. When set, each spawned agent
625    /// loop task is registered under its task ID and is observable in TUI status panels and
626    /// [`TaskSupervisor::snapshot`].
627    pub fn set_task_supervisor(&mut self, supervisor: TaskSupervisor) {
628        self.task_supervisor = Some(supervisor);
629    }
630
631    /// Declare which forwarding consumer surfaces are active for this session (issue #6359).
632    ///
633    /// Fixed at session start — call once during bootstrap, before the first
634    /// [`spawn`][Self::spawn]. When `surfaces.any()` is `false` (the default) or
635    /// `SubAgentConfig::forward_transcript` is `false`, no forwarding sender or drain is ever
636    /// constructed for any subagent (FR-007). A [`TaskSupervisor`] must also be wired via
637    /// [`set_task_supervisor`][Self::set_task_supervisor] — unlike other subagent lifecycle
638    /// tasks, the forward drain never falls back to an untracked spawn (NFR-002).
639    pub fn set_forward_surfaces(&mut self, surfaces: ForwardSurfaces) {
640        self.forward_surfaces = surfaces;
641    }
642
643    /// Set the effective delegation mode gating every future [`spawn`][Self::spawn] call
644    /// (spec `042-subagent-delegation-mode-parity`, issue #5857).
645    ///
646    /// Call during bootstrap, before the first [`spawn`][Self::spawn]. The caller MUST fold
647    /// in the `enabled` outer kill switch before calling this: pass
648    /// `zeph_config::DelegationMode::Disabled` when `config.agents.enabled == false`,
649    /// regardless of `config.agents.delegation_mode`'s configured value (FR-002). The manager
650    /// itself performs no `enabled` check — it only sees the already-resolved effective mode.
651    pub fn set_delegation_mode(&mut self, mode: zeph_config::DelegationMode) {
652        self.delegation_mode = mode;
653    }
654
655    /// The effective delegation mode currently in force, as set by
656    /// [`set_delegation_mode`][Self::set_delegation_mode]. Exposed for status/observability
657    /// surfaces (e.g. `/agent list`) so operators can confirm the active mode without
658    /// inspecting `config.toml` directly (spec 042 NFR-004).
659    #[must_use]
660    pub fn delegation_mode(&self) -> zeph_config::DelegationMode {
661        self.delegation_mode
662    }
663
664    /// Wire the bootstrap-level secret-mask registry into the forwarding pipeline (issue
665    /// #6359, security Finding 1 / NFR-005).
666    ///
667    /// When set, every forwarded `Text`/`Thinking` chunk has known vault secrets replaced
668    /// with opaque placeholders before reaching any sink (TUI ring, `--bare` stdout) — the
669    /// same registry already applied to the outbound-LLM path via `apply_secret_masking`.
670    /// Call during bootstrap, before the first [`spawn`][Self::spawn]; has no effect on
671    /// already-spawned drains.
672    pub fn set_secret_registry(
673        &mut self,
674        registry: Arc<zeph_sanitizer::secret_mask::SecretMaskRegistry>,
675    ) {
676        self.secret_registry = Some(registry);
677    }
678
679    /// Wire a PII filter into the forwarding pipeline (issue #6359, security Finding 1 /
680    /// NFR-005).
681    ///
682    /// When set, every forwarded `Text`/`Thinking` chunk is scrubbed for emails, phone
683    /// numbers, SSNs, etc. before reaching any sink — mirroring the optional `PiiFilter`
684    /// layer sub-agent debug dumps already get via `PiiScrubbingDumpSink` (#6407). The filter
685    /// itself is unconditionally constructible and self-gates on its own `enabled` config
686    /// field, matching the top-level agent's own `PiiFilter` construction convention. Call
687    /// during bootstrap, before the first [`spawn`][Self::spawn].
688    pub fn set_pii_filter(&mut self, filter: zeph_sanitizer::pii::PiiFilter) {
689        self.pii_filter = Some(filter);
690    }
691
692    /// Read the current forwarded-transcript tail for `task_id` (up to the last `n` lines).
693    ///
694    /// Returns an empty vector when forwarding is disabled, no surface is active, or the
695    /// task has not forwarded any lines yet. Backing store for
696    /// [`crate::manager::SubAgentManager`]'s TUI runtime detail view integration
697    /// (FR-005) — see `zeph-core`'s `refresh_subagent_metrics`.
698    #[must_use]
699    pub fn forwarded_tail(&self, task_id: &str, n: usize) -> Vec<String> {
700        crate::forward::forwarded_tail(&self.forward_buffer, task_id, n)
701    }
702
703    /// Build a forwarding sender + spawn its per-task drain for `task_id`, if forwarding is
704    /// active for this session.
705    ///
706    /// Returns `None` (no-op) unless `config.forward_transcript` is set, at least one
707    /// consumer surface is active, and a [`TaskSupervisor`] is wired — the forward drain must
708    /// never fall back to an untracked `tokio::spawn` (NFR-002, P-new-2). The returned
709    /// [`crate::forward::ForwardSender`] must be threaded into `AgentLoopArgs::forward` and
710    /// owned exclusively by that subagent's own turn loop for the run's lifetime (P-new-3).
711    pub(crate) fn maybe_spawn_forward(
712        &self,
713        task_id: &str,
714        def_name: &str,
715        forward_transcript: bool,
716        content_isolation: &ContentIsolationConfig,
717    ) -> Option<crate::forward::ForwardSender> {
718        if !forward_transcript || !self.forward_surfaces.any() {
719            return None;
720        }
721        let Some(ref supervisor) = self.task_supervisor else {
722            tracing::warn!(
723                task_id,
724                "subagent transcript forwarding is enabled but no TaskSupervisor is wired — \
725                 skipping forwarding for this run rather than spawning an untracked drain \
726                 (NFR-002)"
727            );
728            return None;
729        };
730
731        let task_id_arc: Arc<str> = Arc::from(task_id);
732        let def_name_arc: Arc<str> = Arc::from(def_name);
733        let (sender, rx) =
734            crate::forward::new_channel(Arc::clone(&task_id_arc), Arc::clone(&def_name_arc));
735
736        let surfaces = self.forward_surfaces;
737        let buffer = Arc::clone(&self.forward_buffer);
738        let layers = crate::forward::SanitizeLayers {
739            sanitizer: zeph_sanitizer::ContentSanitizer::new(content_isolation),
740            secret_registry: self.secret_registry.clone(),
741            pii_filter: self.pii_filter.clone(),
742        };
743        let span = tracing::info_span!(
744            "subagent.forward.drain",
745            task_id = %task_id_arc,
746            tui = surfaces.tui,
747            bare = surfaces.bare,
748        );
749        let drain_task_id = Arc::clone(&task_id_arc);
750        let drain_def_name = Arc::clone(&def_name_arc);
751        let drain_name: Arc<str> = Arc::from(format!("subagent-forward-drain-{task_id}").as_str());
752        let _handle = supervisor.spawn_oneshot(drain_name, move || {
753            use tracing::Instrument as _;
754            crate::forward::run_forward_drain(
755                drain_task_id,
756                drain_def_name,
757                rx,
758                layers,
759                surfaces,
760                buffer,
761            )
762            .instrument(span)
763        });
764
765        Some(sender)
766    }
767
768    /// Inject a [`DefaultWorktreeManager`][zeph_worktree::DefaultWorktreeManager] into the
769    /// manager.
770    ///
771    /// Must be called at most once, before the first [`spawn`][Self::spawn].  When set,
772    /// every spawned task acquires the process-level cwd mutex (INV-1) and agents with
773    /// `permissions.worktree = true` receive a dedicated git worktree.
774    pub fn set_worktree_manager(&mut self, wm: Arc<zeph_worktree::DefaultWorktreeManager>) {
775        self.worktree_manager = Some(wm);
776    }
777
778    /// Returns the live worktree manager, if the worktree subsystem is enabled for this
779    /// session.
780    ///
781    /// This is the same instance [`spawn`][Self::spawn] uses to create per-subagent
782    /// worktrees, so callers (e.g. the `/worktree` slash command) observe this session's
783    /// actual live state rather than a fresh disk scan. Its own
784    /// `prune_branch_on_remove()` reflects `WorktreeConfig::prune_branch_on_remove` — no
785    /// need to retain a separate copy on `SubAgentManager`.
786    #[must_use]
787    pub fn worktree_manager(&self) -> Option<&Arc<zeph_worktree::DefaultWorktreeManager>> {
788        self.worktree_manager.as_ref()
789    }
790
791    /// Drain completed hook tasks and spawn a new one if below the limit.
792    ///
793    /// Polls [`hook_tasks`][Self::hook_tasks] for finished entries so the set does not
794    /// accumulate stale handles. When the set is at capacity, logs a warning and skips
795    /// the spawn rather than growing unboundedly.
796    fn spawn_hook_task<F>(&mut self, future: F)
797    where
798        F: std::future::Future<Output = ()> + Send + 'static,
799    {
800        // Drain completed/panicked tasks before checking capacity.
801        while self.hook_tasks.try_join_next().is_some() {}
802        if self.hook_tasks.len() >= self.max_hook_tasks {
803            tracing::warn!(
804                limit = self.max_hook_tasks,
805                "hook task limit reached — dropping fire-and-forget task"
806            );
807            return;
808        }
809        self.hook_tasks.spawn(future);
810    }
811
812    /// Spawns a named subagent task under the session [`TaskSupervisor`] if one is configured,
813    /// making the task visible in TUI status and abortable on shutdown via
814    /// [`TaskSupervisor::shutdown_all`].
815    ///
816    /// Falls back to a transient local supervisor when no session supervisor has been wired via
817    /// [`SubAgentManager::set_task_supervisor`] — the task runs but is not tracked globally.
818    /// The returned [`BlockingHandle`] type is identical in both cases so call sites are uniform.
819    ///
820    /// Every agent loop task's future resolves to a `Result<T, E>` (in practice always
821    /// `Result<String, SubAgentError>`), so this classifies via
822    /// [`TaskSupervisor::spawn_oneshot_classified`] rather than plain `spawn_oneshot` — an
823    /// `Err` produced by a genuinely completed task (e.g. a worktree-quota or cwd-guard setup
824    /// failure returned before the agent loop ever starts) is thus classified and logged as a
825    /// supervisor-level failure instead of a normal completion (#6257).
826    pub(crate) fn spawn_agent_task<F, Fut, T, E>(
827        &self,
828        name: Arc<str>,
829        factory: F,
830    ) -> BlockingHandle<Result<T, E>>
831    where
832        F: FnOnce() -> Fut + Send + 'static,
833        Fut: std::future::Future<Output = Result<T, E>> + Send + 'static,
834        T: Send + 'static,
835        E: Send + 'static,
836    {
837        if let Some(ref sup) = self.task_supervisor {
838            sup.spawn_oneshot_classified(name, factory, Result::is_ok)
839        } else {
840            let local = TaskSupervisor::new(CancellationToken::new());
841            local.spawn_oneshot_classified(name, factory, Result::is_ok)
842        }
843    }
844
845    /// Reserve `n` concurrency slots for the orchestration scheduler.
846    ///
847    /// Reserved slots count against the concurrency limit in [`spawn`](Self::spawn) so that
848    /// the scheduler can guarantee capacity for tasks it is about to launch. Call
849    /// [`release_reservation`](Self::release_reservation) when the scheduler finishes.
850    pub fn reserve_slots(&mut self, n: usize) {
851        self.reserved_slots = self.reserved_slots.saturating_add(n);
852    }
853
854    /// Release `n` previously reserved concurrency slots.
855    pub fn release_reservation(&mut self, n: usize) {
856        self.reserved_slots = self.reserved_slots.saturating_sub(n);
857    }
858
859    /// Configure transcript storage settings.
860    pub fn set_transcript_config(&mut self, dir: Option<PathBuf>, max_files: usize) {
861        self.transcript_dir = dir;
862        self.transcript_max_files = max_files;
863    }
864
865    /// Set config-level lifecycle stop hooks (fired when any agent finishes or is cancelled).
866    pub fn set_stop_hooks(&mut self, hooks: Vec<super::hooks::HookDef>) {
867        self.stop_hooks = hooks;
868    }
869
870    /// Inject a fleet registry so spawned sub-agents appear in the fleet dashboard.
871    ///
872    /// When set, [`spawn`][Self::spawn] registers the session as `Active` and
873    /// [`collect`][Self::collect] / [`cancel`][Self::cancel] mark it terminal.
874    /// Errors from the registry are logged at `warn` level and never propagate to callers.
875    pub fn set_fleet_registry(&mut self, registry: SharedFleetRegistry) {
876        self.fleet_registry = Some(registry);
877    }
878
879    /// Load sub-agent definitions from the given directories.
880    ///
881    /// Higher-priority directories should appear first. Name conflicts are resolved
882    /// by keeping the first occurrence. Non-existent directories are silently skipped.
883    ///
884    /// # Errors
885    ///
886    /// Returns [`SubAgentError`] if any definition file fails to parse.
887    pub fn load_definitions(&mut self, dirs: &[PathBuf]) -> Result<(), SubAgentError> {
888        let defs = SubAgentDef::load_all(dirs)?;
889
890        // Security gate: non-Default permission_mode is forbidden when the user-level
891        // agents directory (~/.zeph/agents/) is one of the load sources. This prevents
892        // a crafted agent file from escalating its own privileges.
893        // Validation happens here (in the manager) because this is the only place
894        // that has full context about which directories were searched.
895        //
896        // FIX-5: fail-closed — if user_agents_dir is in dirs and a definition has
897        // non-Default permission_mode, we cannot verify it did not originate from the
898        // user-level dir (SubAgentDef no longer stores source_path), so we reject it.
899        let user_agents_dir = dirs::home_dir().map(|h| h.join(".zeph").join("agents"));
900        let loads_user_dir = user_agents_dir.as_ref().is_some_and(|user_dir| {
901            // FIX-8: log and treat as non-user-level if canonicalize fails.
902            match std::fs::canonicalize(user_dir) {
903                Ok(canonical_user) => dirs
904                    .iter()
905                    .filter_map(|d| std::fs::canonicalize(d).ok())
906                    .any(|d| d == canonical_user),
907                Err(e) => {
908                    tracing::warn!(
909                        dir = %user_dir.display(),
910                        error = %e,
911                        "could not canonicalize user agents dir, treating as non-user-level"
912                    );
913                    false
914                }
915            }
916        });
917
918        if loads_user_dir {
919            for def in &defs {
920                if def.permissions.permission_mode != PermissionMode::Default {
921                    return Err(SubAgentError::Invalid(format!(
922                        "sub-agent '{}': non-default permission_mode is not allowed for \
923                         user-level definitions (~/.zeph/agents/)",
924                        def.name
925                    )));
926                }
927            }
928        }
929
930        self.definitions = defs;
931        tracing::info!(
932            count = self.definitions.len(),
933            "sub-agent definitions loaded"
934        );
935        Ok(())
936    }
937
938    /// Load definitions with full scope context for source tracking and security checks.
939    ///
940    /// The blocking filesystem scan runs on a dedicated thread via
941    /// `tokio::task::spawn_blocking` so the tokio worker thread is not stalled (#5108).
942    ///
943    /// # Errors
944    ///
945    /// Returns [`SubAgentError`] if a CLI-sourced definition file fails to parse.
946    #[tracing::instrument(name = "subagent.manager.load_definitions_with_sources", skip_all)]
947    pub async fn load_definitions_with_sources(
948        &mut self,
949        ordered_paths: &[PathBuf],
950        cli_agents: &[PathBuf],
951        config_user_dir: Option<&PathBuf>,
952        extra_dirs: &[PathBuf],
953    ) -> Result<(), SubAgentError> {
954        // Clone inputs so they can be moved into spawn_blocking ('static bound).
955        let ordered = ordered_paths.to_vec();
956        let cli = cli_agents.to_vec();
957        let user_dir = config_user_dir.cloned();
958        let extra = extra_dirs.to_vec();
959
960        let defs = tokio::task::spawn_blocking(move || {
961            SubAgentDef::load_all_with_sources(&ordered, &cli, user_dir.as_ref(), &extra)
962        })
963        .await
964        .map_err(|e| SubAgentError::TaskPanic(format!("load_definitions_with_sources: {e}")))?;
965
966        self.definitions = defs?;
967        tracing::info!(
968            count = self.definitions.len(),
969            "sub-agent definitions loaded"
970        );
971        Ok(())
972    }
973
974    /// Return all loaded definitions.
975    #[must_use]
976    pub fn definitions(&self) -> &[SubAgentDef] {
977        &self.definitions
978    }
979
980    /// Return mutable access to the loaded definitions list.
981    ///
982    /// Intended for test harnesses and dynamic definition registration. Production code
983    /// should prefer [`load_definitions`][Self::load_definitions].
984    pub fn definitions_mut(&mut self) -> &mut Vec<SubAgentDef> {
985        &mut self.definitions
986    }
987
988    /// Insert a pre-built handle directly into the active agents map.
989    ///
990    /// Used in tests to simulate an agent that has already run and left a pending secret
991    /// request in its channel without going through the full spawn lifecycle.
992    pub fn insert_handle_for_test(&mut self, id: String, handle: SubAgentHandle) {
993        self.agents.insert(id, handle);
994    }
995}
996
997#[cfg(test)]
998mod tests;