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;