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;
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::grants::{PermissionGrants, SecretRequest};
29use crate::state::SubAgentState;
30
31/// Parent-derived state propagated to a spawned sub-agent at spawn time.
32///
33/// All fields default to empty/`None`, preserving existing behavior when callers
34/// pass `SpawnContext::default()`.
35///
36/// # Constraint propagation
37///
38/// [`max_trust_level`][Self::max_trust_level] and
39/// [`inherited_tool_allowlist`][Self::inherited_tool_allowlist] implement transitive
40/// constraint propagation: safety constraints set at orchestration time are enforced on
41/// every sub-agent in the spawn chain, regardless of nesting depth.
42///
43/// When a sub-agent spawns its own sub-agents it must forward these fields downward so
44/// that grandchild agents cannot silently receive more privileges than the original
45/// orchestration policy allowed.
46///
47/// # Examples
48///
49/// ```rust
50/// use zeph_subagent::manager::SpawnContext;
51///
52/// // Minimal context — all fields use their defaults.
53/// let ctx = SpawnContext::default();
54/// assert!(ctx.parent_messages.is_empty());
55/// assert_eq!(ctx.spawn_depth, 0);
56/// assert!(ctx.max_trust_level.is_none());
57/// assert!(ctx.inherited_tool_allowlist.is_none());
58/// ```
59#[derive(Default)]
60pub struct SpawnContext {
61    /// Recent parent conversation messages (last N turns).
62    pub parent_messages: Vec<Message>,
63    /// Parent's cancellation token for linked cancellation (foreground spawns).
64    pub parent_cancel: Option<CancellationToken>,
65    /// Parent's active provider name (for context propagation).
66    pub parent_provider_name: Option<String>,
67    /// Current spawn depth (0 = top-level agent).
68    pub spawn_depth: u32,
69    /// MCP tool names available in the parent's tool executor (for diagnostics).
70    pub mcp_tool_names: Vec<String>,
71    /// Seeded trajectory risk score from the parent sentinel (spec 050 §4).
72    ///
73    /// When `Some`, the subagent's `TrajectorySentinel` starts with this pre-seeded score
74    /// rather than `0.0`, preventing a subagent spawn from acting as a free risk reset.
75    /// The subagent loop applies this via `TrajectorySentinel::seed_score` after build.
76    pub seed_trajectory_score: Option<f32>,
77    /// Parent's content isolation config, propagated so the subagent loop can run the
78    /// same sanitizer settings on hook-replaced tool output.
79    pub content_isolation: ContentIsolationConfig,
80    /// Name of the orchestrator that spawned this subagent.
81    ///
82    /// When set, the subagent's system prompt includes an identity header naming the
83    /// orchestrator, so the subagent can validate that instructions are consistent with
84    /// the expected authority.
85    pub orchestrator_name: Option<String>,
86    /// Role or task label of the orchestrating agent (e.g., `"planner"`, `"tool-router"`).
87    ///
88    /// Injected alongside [`orchestrator_name`][Self::orchestrator_name] when both are set.
89    /// Omitted from the identity header when only `orchestrator_name` is provided.
90    pub orchestrator_role: Option<String>,
91    /// Per-session MCP servers to inject into this subagent's tool name annotations.
92    ///
93    /// The parent is responsible for connecting these servers and including them in the
94    /// `tool_executor` passed to [`SubAgentManager::spawn`]. This field only carries the
95    /// server metadata so the subagent's system prompt lists the additional tool names.
96    pub session_mcp_servers: Vec<McpServerConfig>,
97    /// Maximum trust level cap inherited from the parent agent or orchestration policy.
98    ///
99    /// When `Some(cap)`, the spawned sub-agent's effective trust level is clamped to
100    /// `min(own_trust, cap)` so that sub-agents can never receive higher privileges than
101    /// the orchestration policy originally allowed.
102    ///
103    /// # Caller responsibility for nested spawns
104    ///
105    /// This field does **not** propagate automatically. When a sub-agent itself spawns a
106    /// grandchild, it must copy this field from its own received `SpawnContext` into the
107    /// grandchild's `SpawnContext`. Passing `None` (the default) at that point means the
108    /// grandchild receives **no cap**, which is a privilege escalation if the parent was
109    /// constrained. Only the top-level session (spawned by `build_spawn_context`) correctly
110    /// leaves this `None` — that represents an unconstrained top-level entry point.
111    ///
112    /// `None` means no cap is imposed by the parent (the sub-agent's own definition
113    /// determines its trust level).
114    pub max_trust_level: Option<SkillTrustLevel>,
115    /// Tool names that this sub-agent is allowed to invoke, inherited from the parent.
116    ///
117    /// When `Some(set)`, the effective tool allowlist for the spawned agent is the
118    /// intersection of `set` and the agent's own definition policy. This prevents a
119    /// sub-agent from accessing tools that the parent is itself not allowed to use.
120    ///
121    /// # Caller responsibility for nested spawns
122    ///
123    /// Like [`max_trust_level`][Self::max_trust_level], this field does **not** propagate
124    /// automatically. When a constrained sub-agent spawns its own children, it must copy
125    /// this field from its received `SpawnContext` into the child's `SpawnContext`.
126    /// Passing `None` at that point would grant the grandchild unrestricted tool access,
127    /// defeating the original orchestration policy.
128    ///
129    /// `None` means no additional allowlist restriction is imposed by the parent
130    /// (the agent's definition policy applies without narrowing).
131    pub inherited_tool_allowlist: Option<HashSet<String>>,
132
133    /// Durable resolver seat for promise-based subagent spawn/await (spec-064 §P4, INV-9).
134    ///
135    /// When `Some`, the spawned background task resolves the parent's durable promise after the
136    /// agent loop terminates. The seat carries the resolver token and MUST NOT be forwarded to
137    /// the child's tool executor or LLM surface — only the background task wrapper consumes it.
138    ///
139    /// `None` when `durable.enabled && durable.subagent` is false (plain spawn/collect path).
140    pub durable_resolver: Option<DurableResolverSeat>,
141}
142
143/// Live status snapshot of a running sub-agent.
144///
145/// Values are updated by the background agent loop via a [`tokio::sync::watch`] channel.
146/// Callers receive snapshots via [`SubAgentManager::statuses`].
147#[derive(Debug, Clone)]
148pub struct SubAgentStatus {
149    /// Current lifecycle state of the agent task.
150    pub state: SubAgentState,
151    /// Last message content from the agent (trimmed for display).
152    pub last_message: Option<String>,
153    /// Number of LLM turns consumed so far.
154    pub turns_used: u32,
155    /// Monotonic timestamp recorded at spawn time.
156    pub started_at: Instant,
157}
158
159/// Handle to a spawned sub-agent task, owned by [`SubAgentManager`].
160///
161/// Fields are public to allow test harnesses in downstream crates to construct handles
162/// without going through the full spawn lifecycle. Production code must not mutate
163/// grants or the cancellation state directly — use the [`SubAgentManager`] API instead.
164///
165/// The `Drop` implementation cancels the task and revokes all grants as a safety net.
166pub struct SubAgentHandle {
167    /// Short display ID (same as `task_id` for non-resumed sessions).
168    pub id: String,
169    /// The definition that was used to spawn this agent.
170    pub def: SubAgentDef,
171    /// UUID assigned at spawn time (currently identical to `id`; separated for future use).
172    pub task_id: String,
173    /// Cached state — may lag the background task by one watch broadcast.
174    pub state: SubAgentState,
175    /// Supervised handle for the background agent loop task.
176    pub join_handle: Option<BlockingHandle<Result<String, SubAgentError>>>,
177    /// Cancellation token; cancelled on [`SubAgentManager::cancel`] or drop.
178    pub cancel: CancellationToken,
179    /// Watch receiver for live status updates from the agent loop.
180    pub status_rx: watch::Receiver<SubAgentStatus>,
181    /// Zero-trust TTL-bounded grants for this agent session.
182    pub grants: PermissionGrants,
183    /// Receives secret requests from the sub-agent loop.
184    pub pending_secret_rx: mpsc::Receiver<SecretRequest>,
185    /// Delivers approval outcome to the sub-agent loop: `None` = denied, `Some(_)` = approved.
186    pub secret_tx: mpsc::Sender<Option<String>>,
187    /// ISO 8601 UTC timestamp recorded when the agent was spawned or resumed.
188    pub started_at_str: String,
189    /// Resolved transcript directory at spawn time; `None` if transcripts were disabled.
190    pub transcript_dir: Option<PathBuf>,
191    /// MCP tool names available at spawn time, persisted for transcript meta on collect.
192    pub mcp_tool_names: Vec<String>,
193}
194
195impl SubAgentHandle {
196    /// Construct a minimal [`SubAgentHandle`] for use in unit tests.
197    ///
198    /// The returned handle has a no-op cancel token, closed channels, and no grants.
199    /// It must not be spawned or collected — it is only valid for inspection logic
200    /// that operates on the handle's metadata fields (id, def, state, etc.).
201    #[cfg(test)]
202    pub fn for_test(id: impl Into<String>, def: SubAgentDef) -> Self {
203        let initial_status = SubAgentStatus {
204            state: SubAgentState::Working,
205            last_message: None,
206            turns_used: 0,
207            started_at: Instant::now(),
208        };
209        let (status_tx, status_rx) = watch::channel(initial_status);
210        drop(status_tx);
211        let (pending_secret_rx_tx, pending_secret_rx) = mpsc::channel(1);
212        drop(pending_secret_rx_tx);
213        let (secret_tx, _) = mpsc::channel(1);
214        let id_str = id.into();
215        Self {
216            task_id: id_str.clone(),
217            id: id_str,
218            def,
219            state: SubAgentState::Working,
220            join_handle: None,
221            cancel: CancellationToken::new(),
222            status_rx,
223            grants: PermissionGrants::default(),
224            pending_secret_rx,
225            secret_tx,
226            started_at_str: String::new(),
227            transcript_dir: None,
228            mcp_tool_names: Vec::new(),
229        }
230    }
231}
232
233impl std::fmt::Debug for SubAgentHandle {
234    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
235        f.debug_struct("SubAgentHandle")
236            .field("id", &self.id)
237            .field("task_id", &self.task_id)
238            .field("state", &self.state)
239            .field("def_name", &self.def.name)
240            .finish_non_exhaustive()
241    }
242}
243
244impl Drop for SubAgentHandle {
245    fn drop(&mut self) {
246        // Defense-in-depth: cancel the task and revoke grants on drop even if
247        // cancel() or collect() was not called (e.g., on panic or early return).
248        self.cancel.cancel();
249        if !self.grants.is_empty_grants() {
250            tracing::warn!(
251                id = %self.id,
252                "SubAgentHandle dropped without explicit cleanup — revoking grants"
253            );
254        }
255        self.grants.revoke_all();
256    }
257}
258
259/// Manages sub-agent lifecycle: definitions, spawning, cancellation, and result collection.
260///
261/// `SubAgentManager` is the central coordinator for all sub-agent tasks. It tracks active
262/// [`SubAgentHandle`]s, enforces the global concurrency limit, and stores loaded
263/// [`SubAgentDef`]s.
264///
265/// # Concurrency model
266///
267/// The concurrency limit counts agents whose [`SubAgentState`] is `Submitted` or `Working`.
268/// Reserved slots (via [`reserve_slots`][Self::reserve_slots]) also count against this limit
269/// to allow orchestration schedulers to guarantee capacity before spawning.
270///
271/// # Examples
272///
273/// ```rust
274/// use zeph_subagent::SubAgentManager;
275///
276/// let manager = SubAgentManager::new(4);
277/// assert_eq!(manager.definitions().len(), 0);
278/// ```
279pub struct SubAgentManager {
280    definitions: Vec<SubAgentDef>,
281    agents: HashMap<String, SubAgentHandle>,
282    max_concurrent: usize,
283    /// Number of slots soft-reserved by the orchestration scheduler.
284    ///
285    /// Reserved slots count against the concurrency limit so that the scheduler can
286    /// guarantee capacity for tasks it is about to spawn, preventing a planning-phase
287    /// sub-agent from exhausting the pool and causing a deadlock.
288    reserved_slots: usize,
289    /// Config-level `SubagentStop` hooks, cached so `cancel()` and `collect()` can fire them.
290    stop_hooks: Vec<super::hooks::HookDef>,
291    /// Directory for JSONL transcripts and meta sidecars.
292    transcript_dir: Option<PathBuf>,
293    /// Maximum number of transcript files to keep (0 = unlimited).
294    transcript_max_files: usize,
295    /// Optional fleet registry for registering sub-agents in the fleet dashboard.
296    ///
297    /// When `None`, fleet registration is skipped silently. Inject via
298    /// [`set_fleet_registry`][Self::set_fleet_registry].
299    fleet_registry: Option<SharedFleetRegistry>,
300    /// Tracks fire-and-forget hook and fleet-registry tasks to prevent silent panic swallowing.
301    ///
302    /// Completed and panicked tasks are drained before each new spawn. On graceful shutdown,
303    /// [`shutdown_all`][Self::shutdown_all] aborts all outstanding tasks via
304    /// [`JoinSet::shutdown`].
305    hook_tasks: JoinSet<()>,
306    /// Maximum number of concurrent hook tasks allowed in [`hook_tasks`][Self::hook_tasks].
307    ///
308    /// When the limit is reached, new fire-and-forget tasks are dropped with a warning instead
309    /// of growing the set unboundedly under high-throughput spawning.
310    max_hook_tasks: usize,
311    /// Optional worktree manager; `Some` iff `worktree.enabled = true` in config.
312    ///
313    /// When set, every [`spawn`][Self::spawn] acquires [`cwd_lock`][Self::cwd_lock] for
314    /// its full run so that plain agents cannot observe a stale cwd mutated by a worktree
315    /// agent (INV-1). Only agents with `permissions.worktree = true` and a non-`None`
316    /// `bg_isolation` actually get a dedicated worktree.
317    worktree_manager: Option<Arc<zeph_worktree::DefaultWorktreeManager>>,
318    /// Process-level serialisation mutex for working-directory mutations (INV-1).
319    ///
320    /// Acquired by every spawned task when `worktree_manager.is_some()`.  The
321    /// `OwnedMutexGuard` is held for the full duration of `run_agent_loop` via the
322    /// `CwdRestoreGuard` RAII wrapper.
323    cwd_lock: Arc<tokio::sync::Mutex<()>>,
324    /// Optional supervisor for subagent lifecycle tasks.
325    ///
326    /// When set, each spawned agent loop task is registered under its task ID so it is
327    /// visible to TUI status panels and shutdown is coordinated through the supervisor.
328    task_supervisor: Option<TaskSupervisor>,
329}
330
331impl std::fmt::Debug for SubAgentManager {
332    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333        f.debug_struct("SubAgentManager")
334            .field("definitions_count", &self.definitions.len())
335            .field("active_agents", &self.agents.len())
336            .field("max_concurrent", &self.max_concurrent)
337            .field("reserved_slots", &self.reserved_slots)
338            .field("stop_hooks_count", &self.stop_hooks.len())
339            .field("transcript_dir", &self.transcript_dir)
340            .field("transcript_max_files", &self.transcript_max_files)
341            .field("fleet_registry", &self.fleet_registry.is_some())
342            .field("hook_tasks_len", &self.hook_tasks.len())
343            .field("max_hook_tasks", &self.max_hook_tasks)
344            .field("worktree_manager", &self.worktree_manager.is_some())
345            .field("cwd_lock", &"<Mutex>")
346            .field("task_supervisor", &self.task_supervisor.is_some())
347            .finish()
348    }
349}
350
351impl SubAgentManager {
352    /// Create a new manager with the given concurrency limit.
353    #[must_use]
354    pub fn new(max_concurrent: usize) -> Self {
355        Self {
356            definitions: Vec::new(),
357            agents: HashMap::new(),
358            max_concurrent,
359            reserved_slots: 0,
360            stop_hooks: Vec::new(),
361            transcript_dir: None,
362            transcript_max_files: 50,
363            fleet_registry: None,
364            hook_tasks: JoinSet::new(),
365            max_hook_tasks: 64,
366            worktree_manager: None,
367            cwd_lock: Arc::new(tokio::sync::Mutex::new(())),
368            task_supervisor: None,
369        }
370    }
371
372    /// Inject a [`TaskSupervisor`] so subagent lifecycle tasks are registered and visible.
373    ///
374    /// Must be called before the first [`spawn`][Self::spawn]. When set, each spawned agent
375    /// loop task is registered under its task ID and is observable in TUI status panels and
376    /// [`TaskSupervisor::snapshot`].
377    pub fn set_task_supervisor(&mut self, supervisor: TaskSupervisor) {
378        self.task_supervisor = Some(supervisor);
379    }
380
381    /// Inject a [`DefaultWorktreeManager`][zeph_worktree::DefaultWorktreeManager] into the
382    /// manager.
383    ///
384    /// Must be called at most once, before the first [`spawn`][Self::spawn].  When set,
385    /// every spawned task acquires the process-level cwd mutex (INV-1) and agents with
386    /// `permissions.worktree = true` receive a dedicated git worktree.
387    pub fn set_worktree_manager(&mut self, wm: Arc<zeph_worktree::DefaultWorktreeManager>) {
388        self.worktree_manager = Some(wm);
389    }
390
391    /// Drain completed hook tasks and spawn a new one if below the limit.
392    ///
393    /// Polls [`hook_tasks`][Self::hook_tasks] for finished entries so the set does not
394    /// accumulate stale handles. When the set is at capacity, logs a warning and skips
395    /// the spawn rather than growing unboundedly.
396    fn spawn_hook_task<F>(&mut self, future: F)
397    where
398        F: std::future::Future<Output = ()> + Send + 'static,
399    {
400        // Drain completed/panicked tasks before checking capacity.
401        while self.hook_tasks.try_join_next().is_some() {}
402        if self.hook_tasks.len() >= self.max_hook_tasks {
403            tracing::warn!(
404                limit = self.max_hook_tasks,
405                "hook task limit reached — dropping fire-and-forget task"
406            );
407            return;
408        }
409        self.hook_tasks.spawn(future);
410    }
411
412    /// Spawns a named subagent task under the session [`TaskSupervisor`] if one is configured,
413    /// making the task visible in TUI status and abortable on shutdown via
414    /// [`TaskSupervisor::shutdown_all`].
415    ///
416    /// Falls back to a transient local supervisor when no session supervisor has been wired via
417    /// [`SubAgentManager::set_task_supervisor`] — the task runs but is not tracked globally.
418    /// The returned [`BlockingHandle`] type is identical in both cases so call sites are uniform.
419    pub(crate) fn spawn_agent_task<F, Fut, R>(
420        &self,
421        name: Arc<str>,
422        factory: F,
423    ) -> BlockingHandle<R>
424    where
425        F: FnOnce() -> Fut + Send + 'static,
426        Fut: std::future::Future<Output = R> + Send + 'static,
427        R: Send + 'static,
428    {
429        if let Some(ref sup) = self.task_supervisor {
430            sup.spawn_oneshot(name, factory)
431        } else {
432            let local = TaskSupervisor::new(CancellationToken::new());
433            local.spawn_oneshot(name, factory)
434        }
435    }
436
437    /// Reserve `n` concurrency slots for the orchestration scheduler.
438    ///
439    /// Reserved slots count against the concurrency limit in [`spawn`](Self::spawn) so that
440    /// the scheduler can guarantee capacity for tasks it is about to launch. Call
441    /// [`release_reservation`](Self::release_reservation) when the scheduler finishes.
442    pub fn reserve_slots(&mut self, n: usize) {
443        self.reserved_slots = self.reserved_slots.saturating_add(n);
444    }
445
446    /// Release `n` previously reserved concurrency slots.
447    pub fn release_reservation(&mut self, n: usize) {
448        self.reserved_slots = self.reserved_slots.saturating_sub(n);
449    }
450
451    /// Configure transcript storage settings.
452    pub fn set_transcript_config(&mut self, dir: Option<PathBuf>, max_files: usize) {
453        self.transcript_dir = dir;
454        self.transcript_max_files = max_files;
455    }
456
457    /// Set config-level lifecycle stop hooks (fired when any agent finishes or is cancelled).
458    pub fn set_stop_hooks(&mut self, hooks: Vec<super::hooks::HookDef>) {
459        self.stop_hooks = hooks;
460    }
461
462    /// Inject a fleet registry so spawned sub-agents appear in the fleet dashboard.
463    ///
464    /// When set, [`spawn`][Self::spawn] registers the session as `Active` and
465    /// [`collect`][Self::collect] / [`cancel`][Self::cancel] mark it terminal.
466    /// Errors from the registry are logged at `warn` level and never propagate to callers.
467    pub fn set_fleet_registry(&mut self, registry: SharedFleetRegistry) {
468        self.fleet_registry = Some(registry);
469    }
470
471    /// Load sub-agent definitions from the given directories.
472    ///
473    /// Higher-priority directories should appear first. Name conflicts are resolved
474    /// by keeping the first occurrence. Non-existent directories are silently skipped.
475    ///
476    /// # Errors
477    ///
478    /// Returns [`SubAgentError`] if any definition file fails to parse.
479    pub fn load_definitions(&mut self, dirs: &[PathBuf]) -> Result<(), SubAgentError> {
480        let defs = SubAgentDef::load_all(dirs)?;
481
482        // Security gate: non-Default permission_mode is forbidden when the user-level
483        // agents directory (~/.zeph/agents/) is one of the load sources. This prevents
484        // a crafted agent file from escalating its own privileges.
485        // Validation happens here (in the manager) because this is the only place
486        // that has full context about which directories were searched.
487        //
488        // FIX-5: fail-closed — if user_agents_dir is in dirs and a definition has
489        // non-Default permission_mode, we cannot verify it did not originate from the
490        // user-level dir (SubAgentDef no longer stores source_path), so we reject it.
491        let user_agents_dir = dirs::home_dir().map(|h| h.join(".zeph").join("agents"));
492        let loads_user_dir = user_agents_dir.as_ref().is_some_and(|user_dir| {
493            // FIX-8: log and treat as non-user-level if canonicalize fails.
494            match std::fs::canonicalize(user_dir) {
495                Ok(canonical_user) => dirs
496                    .iter()
497                    .filter_map(|d| std::fs::canonicalize(d).ok())
498                    .any(|d| d == canonical_user),
499                Err(e) => {
500                    tracing::warn!(
501                        dir = %user_dir.display(),
502                        error = %e,
503                        "could not canonicalize user agents dir, treating as non-user-level"
504                    );
505                    false
506                }
507            }
508        });
509
510        if loads_user_dir {
511            for def in &defs {
512                if def.permissions.permission_mode != PermissionMode::Default {
513                    return Err(SubAgentError::Invalid(format!(
514                        "sub-agent '{}': non-default permission_mode is not allowed for \
515                         user-level definitions (~/.zeph/agents/)",
516                        def.name
517                    )));
518                }
519            }
520        }
521
522        self.definitions = defs;
523        tracing::info!(
524            count = self.definitions.len(),
525            "sub-agent definitions loaded"
526        );
527        Ok(())
528    }
529
530    /// Load definitions with full scope context for source tracking and security checks.
531    ///
532    /// The blocking filesystem scan runs on a dedicated thread via
533    /// `tokio::task::spawn_blocking` so the tokio worker thread is not stalled (#5108).
534    ///
535    /// # Errors
536    ///
537    /// Returns [`SubAgentError`] if a CLI-sourced definition file fails to parse.
538    #[tracing::instrument(name = "subagent.manager.load_definitions_with_sources", skip_all)]
539    pub async fn load_definitions_with_sources(
540        &mut self,
541        ordered_paths: &[PathBuf],
542        cli_agents: &[PathBuf],
543        config_user_dir: Option<&PathBuf>,
544        extra_dirs: &[PathBuf],
545    ) -> Result<(), SubAgentError> {
546        // Clone inputs so they can be moved into spawn_blocking ('static bound).
547        let ordered = ordered_paths.to_vec();
548        let cli = cli_agents.to_vec();
549        let user_dir = config_user_dir.cloned();
550        let extra = extra_dirs.to_vec();
551
552        let defs = tokio::task::spawn_blocking(move || {
553            SubAgentDef::load_all_with_sources(&ordered, &cli, user_dir.as_ref(), &extra)
554        })
555        .await
556        .map_err(|e| SubAgentError::TaskPanic(format!("load_definitions_with_sources: {e}")))?;
557
558        self.definitions = defs?;
559        tracing::info!(
560            count = self.definitions.len(),
561            "sub-agent definitions loaded"
562        );
563        Ok(())
564    }
565
566    /// Return all loaded definitions.
567    #[must_use]
568    pub fn definitions(&self) -> &[SubAgentDef] {
569        &self.definitions
570    }
571
572    /// Return mutable access to the loaded definitions list.
573    ///
574    /// Intended for test harnesses and dynamic definition registration. Production code
575    /// should prefer [`load_definitions`][Self::load_definitions].
576    pub fn definitions_mut(&mut self) -> &mut Vec<SubAgentDef> {
577        &mut self.definitions
578    }
579
580    /// Insert a pre-built handle directly into the active agents map.
581    ///
582    /// Used in tests to simulate an agent that has already run and left a pending secret
583    /// request in its channel without going through the full spawn lifecycle.
584    pub fn insert_handle_for_test(&mut self, id: String, handle: SubAgentHandle) {
585        self.agents.insert(id, handle);
586    }
587}
588
589#[cfg(test)]
590mod tests;