Skip to main content

zeph_core/subagent/
manager.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::collections::HashMap;
5use std::path::PathBuf;
6use std::sync::Arc;
7use std::time::Instant;
8
9use tokio::sync::{mpsc, watch};
10use tokio::task::JoinHandle;
11use tokio_util::sync::CancellationToken;
12use uuid::Uuid;
13use zeph_llm::any::AnyProvider;
14use zeph_llm::provider::{LlmProvider, Message, MessageMetadata, Role};
15use zeph_tools::executor::ErasedToolExecutor;
16
17use crate::config::SubAgentConfig;
18
19use super::def::{MemoryScope, PermissionMode, SubAgentDef, ToolPolicy};
20use super::error::SubAgentError;
21use super::filter::{FilteredToolExecutor, PlanModeExecutor};
22use super::grants::{PermissionGrants, SecretRequest};
23use super::hooks::{HookDef, fire_hooks, matching_hooks};
24use super::memory::{ensure_memory_dir, escape_memory_content, load_memory_content};
25use super::state::SubAgentState;
26use super::transcript::{
27    TranscriptMeta, TranscriptReader, TranscriptWriter, sweep_old_transcripts,
28};
29
30/// Marker in LLM output that triggers the secret request protocol.
31const SECRET_REQUEST_PREFIX: &str = "[REQUEST_SECRET:";
32
33struct AgentLoopArgs {
34    provider: AnyProvider,
35    executor: FilteredToolExecutor,
36    system_prompt: String,
37    task_prompt: String,
38    skills: Option<Vec<String>>,
39    max_turns: u32,
40    cancel: CancellationToken,
41    status_tx: watch::Sender<SubAgentStatus>,
42    started_at: Instant,
43    secret_request_tx: mpsc::Sender<SecretRequest>,
44    // None = denied, Some(value) = approved
45    secret_rx: mpsc::Receiver<Option<String>>,
46    /// When true, secret requests are auto-denied without sending to the parent channel.
47    background: bool,
48    /// Per-agent frontmatter hooks (`PreToolUse` / `PostToolUse`).
49    hooks: super::hooks::SubagentHooks,
50    /// Task ID for hook environment variables.
51    task_id: String,
52    /// Agent definition name for hook environment variables.
53    agent_name: String,
54    /// Pre-loaded message history (for resumed sessions).
55    initial_messages: Vec<Message>,
56    /// Optional transcript writer for appending messages during the loop.
57    transcript_writer: Option<TranscriptWriter>,
58}
59
60fn make_message(role: Role, content: String) -> Message {
61    Message {
62        role,
63        content,
64        parts: vec![],
65        metadata: MessageMetadata::default(),
66    }
67}
68
69// Returns `true` if no tool was called (loop should break).
70async fn handle_tool_step(
71    executor: &FilteredToolExecutor,
72    response: String,
73    messages: &mut Vec<Message>,
74    hooks: &super::hooks::SubagentHooks,
75    task_id: &str,
76    agent_name: &str,
77) -> bool {
78    // Extract tool name from response for hook matching (best-effort; empty = no match).
79    let tool_name = extract_tool_name(&response);
80
81    // Build hook environment once for both pre and post hooks.
82    let hook_env = make_hook_env(task_id, agent_name, tool_name.as_deref().unwrap_or(""));
83
84    // PreToolUse: fire matching hooks before execution.
85    if let Some(ref name) = tool_name {
86        let pre_hooks: Vec<&HookDef> = matching_hooks(&hooks.pre_tool_use, name);
87        if !pre_hooks.is_empty() {
88            let pre_owned: Vec<HookDef> = pre_hooks.into_iter().cloned().collect();
89            if let Err(e) = fire_hooks(&pre_owned, &hook_env).await {
90                tracing::warn!(error = %e, tool = %name, "PreToolUse hook failed");
91            }
92        }
93    }
94
95    let result = match executor.execute_erased(&response).await {
96        Ok(Some(output)) => {
97            messages.push(make_message(Role::Assistant, response));
98            messages.push(make_message(
99                Role::User,
100                format!(
101                    "[tool output: {}]\n```\n{}\n```",
102                    output.tool_name, output.summary
103                ),
104            ));
105            false
106        }
107        Ok(None) => {
108            messages.push(make_message(Role::Assistant, response));
109            true
110        }
111        Err(e) => {
112            tracing::warn!(error = %e, "sub-agent tool execution failed");
113            messages.push(make_message(Role::Assistant, response));
114            messages.push(make_message(Role::User, format!("[tool error]: {e}")));
115            false
116        }
117    };
118
119    // PostToolUse: fire matching hooks after execution (only when a tool was called).
120    if !result
121        && let Some(ref name) = tool_name
122        && !hooks.post_tool_use.is_empty()
123    {
124        let post_hooks: Vec<&HookDef> = matching_hooks(&hooks.post_tool_use, name);
125        if !post_hooks.is_empty() {
126            let post_owned: Vec<HookDef> = post_hooks.into_iter().cloned().collect();
127            if let Err(e) = fire_hooks(&post_owned, &hook_env).await {
128                tracing::warn!(error = %e, tool = %name, "PostToolUse hook failed");
129            }
130        }
131    }
132
133    result
134}
135
136/// Extract the tool name from an LLM response string.
137///
138/// Looks for JSON-like `"tool_name": "SomeTool"` or `"name": "SomeTool"` patterns.
139/// Returns `None` if no tool call pattern is found.
140fn extract_tool_name(response: &str) -> Option<String> {
141    for key in [r#""tool_name": ""#, r#""name": ""#] {
142        if let Some(pos) = response.find(key) {
143            let after = &response[pos + key.len()..];
144            if let Some(end) = after.find('"') {
145                let name = after[..end].to_owned();
146                if !name.is_empty() {
147                    return Some(name);
148                }
149            }
150        }
151    }
152    None
153}
154
155fn make_hook_env(task_id: &str, agent_name: &str, tool_name: &str) -> HashMap<String, String> {
156    let mut env = HashMap::new();
157    env.insert("ZEPH_AGENT_ID".to_owned(), task_id.to_owned());
158    env.insert("ZEPH_AGENT_NAME".to_owned(), agent_name.to_owned());
159    env.insert("ZEPH_TOOL_NAME".to_owned(), tool_name.to_owned());
160    env
161}
162
163fn append_transcript(writer: &mut Option<TranscriptWriter>, seq: &mut u32, msg: &Message) {
164    if let Some(w) = writer {
165        if let Err(e) = w.append(*seq, msg) {
166            tracing::warn!(error = %e, seq, "failed to write transcript entry");
167        }
168        *seq += 1;
169    }
170}
171
172#[allow(clippy::too_many_lines)]
173async fn run_agent_loop(args: AgentLoopArgs) -> anyhow::Result<String> {
174    let AgentLoopArgs {
175        provider,
176        executor,
177        system_prompt,
178        task_prompt,
179        skills,
180        max_turns,
181        cancel,
182        status_tx,
183        started_at,
184        secret_request_tx,
185        mut secret_rx,
186        background,
187        hooks,
188        task_id: loop_task_id,
189        agent_name,
190        initial_messages,
191        mut transcript_writer,
192    } = args;
193    let _ = status_tx.send(SubAgentStatus {
194        state: SubAgentState::Working,
195        last_message: None,
196        turns_used: 0,
197        started_at,
198    });
199
200    let effective_system_prompt = if let Some(skill_bodies) = skills.filter(|s| !s.is_empty()) {
201        let skill_block = skill_bodies.join("\n\n");
202        format!("{system_prompt}\n\n```skills\n{skill_block}\n```")
203    } else {
204        system_prompt
205    };
206
207    // Build initial message list: system prompt, any resumed history, then new task prompt.
208    let mut messages = vec![make_message(Role::System, effective_system_prompt)];
209    let history_len = initial_messages.len();
210    messages.extend(initial_messages);
211    messages.push(make_message(Role::User, task_prompt));
212
213    // Sequence counter starts after history so new messages get sequential IDs.
214    // history_len is bounded by max_turns (u32::MAX at most) in practice.
215    #[allow(clippy::cast_possible_truncation)]
216    let mut seq: u32 = history_len as u32;
217
218    // Append the new task prompt to the transcript (history messages are already on disk).
219    if let Some(writer) = &mut transcript_writer {
220        let task_msg = messages.last().unwrap();
221        if let Err(e) = writer.append(seq, task_msg) {
222            tracing::warn!(error = %e, "failed to write transcript entry");
223        }
224        seq += 1;
225    }
226
227    let mut turns: u32 = 0;
228    let mut last_result = String::new();
229
230    loop {
231        if cancel.is_cancelled() {
232            tracing::debug!("sub-agent cancelled, stopping loop");
233            break;
234        }
235        if turns >= max_turns {
236            tracing::debug!(turns, max_turns, "sub-agent reached max_turns limit");
237            break;
238        }
239
240        let response = match provider.chat(&messages).await {
241            Ok(r) => r,
242            Err(e) => {
243                tracing::error!(error = %e, "sub-agent LLM call failed");
244                let _ = status_tx.send(SubAgentStatus {
245                    state: SubAgentState::Failed,
246                    last_message: Some(e.to_string()),
247                    turns_used: turns,
248                    started_at,
249                });
250                return Err(anyhow::anyhow!("LLM call failed: {e}"));
251            }
252        };
253
254        turns += 1;
255        last_result.clone_from(&response);
256        let _ = status_tx.send(SubAgentStatus {
257            state: SubAgentState::Working,
258            last_message: Some(response.chars().take(120).collect()),
259            turns_used: turns,
260            started_at,
261        });
262
263        // Detect secret request protocol: sub-agent emits [REQUEST_SECRET: key_name]
264        if let Some(rest) = response.strip_prefix(SECRET_REQUEST_PREFIX) {
265            let raw_key = rest.split(']').next().unwrap_or("").trim().to_owned();
266            // SEC-P1-02: Validate key name to prevent prompt-injection via malformed keys.
267            // Only allow alphanumeric, hyphen, underscore — matches vault key naming conventions.
268            let key_name = if raw_key
269                .chars()
270                .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
271                && !raw_key.is_empty()
272            {
273                raw_key
274            } else {
275                tracing::warn!("sub-agent emitted invalid secret key name — ignoring request");
276                String::new()
277            };
278            if !key_name.is_empty() {
279                // WARNING-1: do not log key name to avoid audit trail exposure
280                tracing::debug!("sub-agent requested secret [key redacted]");
281
282                // CRIT-01: background agents must not block on the secret channel —
283                // the parent may never poll try_recv_secret_request for them.
284                // Auto-deny inline without sending to the pending channel.
285                if background {
286                    tracing::warn!(
287                        "background sub-agent secret request auto-denied (no interactive prompt)"
288                    );
289                    let reply = format!("[secret:{key_name}] request denied");
290                    let assistant_msg = make_message(Role::Assistant, response);
291                    let user_msg = make_message(Role::User, reply);
292                    append_transcript(&mut transcript_writer, &mut seq, &assistant_msg);
293                    append_transcript(&mut transcript_writer, &mut seq, &user_msg);
294                    messages.push(assistant_msg);
295                    messages.push(user_msg);
296                    continue;
297                }
298
299                let req = SecretRequest {
300                    secret_key: key_name.clone(),
301                    reason: None,
302                };
303                if secret_request_tx.send(req).await.is_ok() {
304                    // CRITICAL-3: also check cancellation while waiting for approval
305                    let outcome = tokio::select! {
306                        msg = secret_rx.recv() => msg,
307                        () = cancel.cancelled() => {
308                            tracing::debug!("sub-agent cancelled while waiting for secret approval");
309                            break;
310                        }
311                    };
312                    // CRITICAL-1: never put secret value in message history
313                    let reply = match outcome {
314                        Some(Some(_)) => {
315                            format!("[secret:{key_name} approved — value available via grants]")
316                        }
317                        Some(None) | None => {
318                            format!("[secret:{key_name}] request denied")
319                        }
320                    };
321                    let assistant_msg = make_message(Role::Assistant, response);
322                    let user_msg = make_message(Role::User, reply);
323                    append_transcript(&mut transcript_writer, &mut seq, &assistant_msg);
324                    append_transcript(&mut transcript_writer, &mut seq, &user_msg);
325                    messages.push(assistant_msg);
326                    messages.push(user_msg);
327                    continue;
328                }
329            }
330        }
331
332        let prev_len = messages.len();
333        if handle_tool_step(
334            &executor,
335            response,
336            &mut messages,
337            &hooks,
338            &loop_task_id,
339            &agent_name,
340        )
341        .await
342        {
343            // handle_tool_step returned true (no tool call) — loop will break.
344            // Write the last assistant message to transcript.
345            for msg in &messages[prev_len..] {
346                append_transcript(&mut transcript_writer, &mut seq, msg);
347            }
348            break;
349        }
350        // Write any newly pushed messages to the transcript.
351        for msg in &messages[prev_len..] {
352            append_transcript(&mut transcript_writer, &mut seq, msg);
353        }
354    }
355
356    let _ = status_tx.send(SubAgentStatus {
357        state: SubAgentState::Completed,
358        last_message: Some(last_result.chars().take(120).collect()),
359        turns_used: turns,
360        started_at,
361    });
362
363    Ok(last_result)
364}
365
366/// Live status of a running sub-agent.
367#[derive(Debug, Clone)]
368pub struct SubAgentStatus {
369    pub state: SubAgentState,
370    pub last_message: Option<String>,
371    pub turns_used: u32,
372    pub started_at: Instant,
373}
374
375/// Handle to a spawned sub-agent task.
376///
377/// Fields are `pub(crate)` to prevent external code from bypassing the manager's
378/// audit trail by mutating grants or cancellation state directly.
379pub struct SubAgentHandle {
380    pub(crate) id: String,
381    pub(crate) def: SubAgentDef,
382    /// Task ID (UUID). Currently the same as `id`; separated for future use.
383    pub(crate) task_id: String,
384    pub(crate) state: SubAgentState,
385    pub(crate) join_handle: Option<JoinHandle<anyhow::Result<String>>>,
386    pub(crate) cancel: CancellationToken,
387    pub(crate) status_rx: watch::Receiver<SubAgentStatus>,
388    pub(crate) grants: PermissionGrants,
389    /// Receives secret requests from the sub-agent loop.
390    pub(crate) pending_secret_rx: mpsc::Receiver<SecretRequest>,
391    /// Delivers approval outcome to the sub-agent loop: None = denied, Some(_) = approved.
392    pub(crate) secret_tx: mpsc::Sender<Option<String>>,
393    /// ISO 8601 UTC timestamp recorded when the agent was spawned or resumed.
394    pub(crate) started_at_str: String,
395    /// Resolved transcript directory at spawn time; `None` if transcripts were disabled.
396    pub(crate) transcript_dir: Option<PathBuf>,
397}
398
399impl std::fmt::Debug for SubAgentHandle {
400    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
401        f.debug_struct("SubAgentHandle")
402            .field("id", &self.id)
403            .field("task_id", &self.task_id)
404            .field("state", &self.state)
405            .field("def_name", &self.def.name)
406            .finish_non_exhaustive()
407    }
408}
409
410impl Drop for SubAgentHandle {
411    fn drop(&mut self) {
412        // Defense-in-depth: cancel the task and revoke grants on drop even if
413        // cancel() or collect() was not called (e.g., on panic or early return).
414        self.cancel.cancel();
415        if !self.grants.is_empty_grants() {
416            tracing::warn!(
417                id = %self.id,
418                "SubAgentHandle dropped without explicit cleanup — revoking grants"
419            );
420        }
421        self.grants.revoke_all();
422    }
423}
424
425/// Manages sub-agent lifecycle: definitions, spawning, cancellation, and result collection.
426pub struct SubAgentManager {
427    definitions: Vec<SubAgentDef>,
428    agents: HashMap<String, SubAgentHandle>,
429    max_concurrent: usize,
430    /// Config-level `SubagentStop` hooks, cached so `cancel()` and `collect()` can fire them.
431    stop_hooks: Vec<super::hooks::HookDef>,
432    /// Directory for JSONL transcripts and meta sidecars.
433    transcript_dir: Option<PathBuf>,
434    /// Maximum number of transcript files to keep (0 = unlimited).
435    transcript_max_files: usize,
436}
437
438impl std::fmt::Debug for SubAgentManager {
439    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
440        f.debug_struct("SubAgentManager")
441            .field("definitions_count", &self.definitions.len())
442            .field("active_agents", &self.agents.len())
443            .field("max_concurrent", &self.max_concurrent)
444            .field("stop_hooks_count", &self.stop_hooks.len())
445            .field("transcript_dir", &self.transcript_dir)
446            .field("transcript_max_files", &self.transcript_max_files)
447            .finish()
448    }
449}
450
451/// Build the system prompt for a sub-agent, optionally injecting persistent memory.
452///
453/// When `memory_scope` is `Some`, this function:
454/// 1. Validates that file tools are not all blocked (HIGH-04).
455/// 2. Creates the memory directory if it doesn't exist (fail-open on error).
456/// 3. Loads the first 200 lines of `MEMORY.md`, escaping injection tags (CRIT-02).
457/// 4. Auto-enables Read/Write/Edit in `AllowList` policies (HIGH-02: warn level).
458/// 5. Appends the memory block AFTER the behavioral system prompt (CRIT-02, MED-03).
459///
460/// File tool access is not filesystem-restricted in this implementation — the memory
461/// directory path is provided as a soft boundary via the system prompt instruction.
462/// Known limitation: agents may use Read/Write/Edit beyond the memory directory.
463/// See issue #1152 for future `FilteredToolExecutor` path-restriction enhancement.
464#[cfg_attr(test, allow(dead_code))]
465pub(crate) fn build_system_prompt_with_memory(
466    def: &mut SubAgentDef,
467    scope: Option<MemoryScope>,
468) -> String {
469    let Some(scope) = scope else {
470        return def.system_prompt.clone();
471    };
472
473    // HIGH-04: if all three file tools are blocked (via disallowed_tools OR DenyList),
474    // disable memory entirely — the agent cannot use file tools so memory would be useless.
475    let file_tools = ["Read", "Write", "Edit"];
476    let blocked_by_except = file_tools
477        .iter()
478        .all(|t| def.disallowed_tools.iter().any(|d| d == t));
479    // REV-HIGH-02: also check ToolPolicy::DenyList (tools.deny) for complete coverage.
480    let blocked_by_deny = matches!(&def.tools, ToolPolicy::DenyList(list)
481        if file_tools.iter().all(|t| list.iter().any(|d| d == t)));
482    if blocked_by_except || blocked_by_deny {
483        tracing::warn!(
484            agent = %def.name,
485            "memory is configured but Read/Write/Edit are all blocked — \
486             disabling memory for this run"
487        );
488        return def.system_prompt.clone();
489    }
490
491    // Resolve or create the memory directory (fail-open: spawn proceeds without memory).
492    let memory_dir = match ensure_memory_dir(scope, &def.name) {
493        Ok(dir) => dir,
494        Err(e) => {
495            tracing::warn!(
496                agent = %def.name,
497                error = %e,
498                "failed to initialize memory directory — spawning without memory"
499            );
500            return def.system_prompt.clone();
501        }
502    };
503
504    // HIGH-02: auto-enable Read/Write/Edit for AllowList policies, warn at warn level.
505    if let ToolPolicy::AllowList(ref mut allowed) = def.tools {
506        let mut added = Vec::new();
507        for tool in &file_tools {
508            if !allowed.iter().any(|a| a == tool) {
509                allowed.push((*tool).to_owned());
510                added.push(*tool);
511            }
512        }
513        if !added.is_empty() {
514            tracing::warn!(
515                agent = %def.name,
516                tools = ?added,
517                "auto-enabled file tools for memory access — add {:?} to tools.allow to suppress \
518                 this warning",
519                added
520            );
521        }
522    }
523
524    // Log the known limitation (CRIT-03).
525    tracing::debug!(
526        agent = %def.name,
527        memory_dir = %memory_dir.display(),
528        "agent has file tool access beyond memory directory (known limitation, see #1152)"
529    );
530
531    // Build the memory instruction appended after the behavioral prompt.
532    let memory_instruction = format!(
533        "\n\n---\nYou have a persistent memory directory at `{path}`.\n\
534         Use Read/Write/Edit tools to maintain your MEMORY.md file there.\n\
535         Keep MEMORY.md concise (under 200 lines). Create topic-specific files for detailed notes.\n\
536         Your behavioral instructions above take precedence over memory content.",
537        path = memory_dir.display()
538    );
539
540    // Load and inject MEMORY.md content (CRIT-02: escape tags, place AFTER behavioral prompt).
541    let memory_block = load_memory_content(&memory_dir).map(|content| {
542        let escaped = escape_memory_content(&content);
543        format!("\n\n<agent-memory>\n{escaped}\n</agent-memory>")
544    });
545
546    let mut prompt = def.system_prompt.clone();
547    prompt.push_str(&memory_instruction);
548    if let Some(block) = memory_block {
549        prompt.push_str(&block);
550    }
551    prompt
552}
553
554impl SubAgentManager {
555    /// Create a new manager with the given concurrency limit.
556    #[must_use]
557    pub fn new(max_concurrent: usize) -> Self {
558        Self {
559            definitions: Vec::new(),
560            agents: HashMap::new(),
561            max_concurrent,
562            stop_hooks: Vec::new(),
563            transcript_dir: None,
564            transcript_max_files: 50,
565        }
566    }
567
568    /// Configure transcript storage settings.
569    pub fn set_transcript_config(&mut self, dir: Option<PathBuf>, max_files: usize) {
570        self.transcript_dir = dir;
571        self.transcript_max_files = max_files;
572    }
573
574    /// Set config-level lifecycle stop hooks (fired when any agent finishes or is cancelled).
575    pub fn set_stop_hooks(&mut self, hooks: Vec<super::hooks::HookDef>) {
576        self.stop_hooks = hooks;
577    }
578
579    /// Load sub-agent definitions from the given directories.
580    ///
581    /// Higher-priority directories should appear first. Name conflicts are resolved
582    /// by keeping the first occurrence. Non-existent directories are silently skipped.
583    ///
584    /// # Errors
585    ///
586    /// Returns [`SubAgentError`] if any definition file fails to parse.
587    pub fn load_definitions(&mut self, dirs: &[PathBuf]) -> Result<(), SubAgentError> {
588        let defs = SubAgentDef::load_all(dirs)?;
589
590        // Security gate: non-Default permission_mode is forbidden when the user-level
591        // agents directory (~/.zeph/agents/) is one of the load sources. This prevents
592        // a crafted agent file from escalating its own privileges.
593        // Validation happens here (in the manager) because this is the only place
594        // that has full context about which directories were searched.
595        //
596        // FIX-5: fail-closed — if user_agents_dir is in dirs and a definition has
597        // non-Default permission_mode, we cannot verify it did not originate from the
598        // user-level dir (SubAgentDef no longer stores source_path), so we reject it.
599        let user_agents_dir = dirs::home_dir().map(|h| h.join(".zeph").join("agents"));
600        let loads_user_dir = user_agents_dir.as_ref().is_some_and(|user_dir| {
601            // FIX-8: log and treat as non-user-level if canonicalize fails.
602            match std::fs::canonicalize(user_dir) {
603                Ok(canonical_user) => dirs
604                    .iter()
605                    .filter_map(|d| std::fs::canonicalize(d).ok())
606                    .any(|d| d == canonical_user),
607                Err(e) => {
608                    tracing::warn!(
609                        dir = %user_dir.display(),
610                        error = %e,
611                        "could not canonicalize user agents dir, treating as non-user-level"
612                    );
613                    false
614                }
615            }
616        });
617
618        if loads_user_dir {
619            for def in &defs {
620                if def.permissions.permission_mode != PermissionMode::Default {
621                    return Err(SubAgentError::Invalid(format!(
622                        "sub-agent '{}': non-default permission_mode is not allowed for \
623                         user-level definitions (~/.zeph/agents/)",
624                        def.name
625                    )));
626                }
627            }
628        }
629
630        self.definitions = defs;
631        tracing::info!(
632            count = self.definitions.len(),
633            "sub-agent definitions loaded"
634        );
635        Ok(())
636    }
637
638    /// Load definitions with full scope context for source tracking and security checks.
639    ///
640    /// # Errors
641    ///
642    /// Returns [`SubAgentError`] if a CLI-sourced definition file fails to parse.
643    pub fn load_definitions_with_sources(
644        &mut self,
645        ordered_paths: &[PathBuf],
646        cli_agents: &[PathBuf],
647        config_user_dir: Option<&PathBuf>,
648        extra_dirs: &[PathBuf],
649    ) -> Result<(), SubAgentError> {
650        self.definitions = SubAgentDef::load_all_with_sources(
651            ordered_paths,
652            cli_agents,
653            config_user_dir,
654            extra_dirs,
655        )?;
656        tracing::info!(
657            count = self.definitions.len(),
658            "sub-agent definitions loaded"
659        );
660        Ok(())
661    }
662
663    /// Return all loaded definitions.
664    #[must_use]
665    pub fn definitions(&self) -> &[SubAgentDef] {
666        &self.definitions
667    }
668
669    /// Return mutable access to definitions, for testing and dynamic registration.
670    pub fn definitions_mut(&mut self) -> &mut Vec<SubAgentDef> {
671        &mut self.definitions
672    }
673
674    /// Spawn a sub-agent by definition name with real background execution.
675    ///
676    /// Returns the `task_id` (UUID string) that can be used with [`cancel`](Self::cancel)
677    /// and [`collect`](Self::collect).
678    ///
679    /// # Errors
680    ///
681    /// Returns [`SubAgentError::NotFound`] if no definition with the given name exists,
682    /// [`SubAgentError::Spawn`] if the concurrency limit is exceeded, or
683    /// [`SubAgentError::Invalid`] if the agent requests `bypass_permissions` but the config
684    /// does not allow it (`allow_bypass_permissions: false`).
685    #[allow(clippy::too_many_lines)]
686    pub fn spawn(
687        &mut self,
688        def_name: &str,
689        task_prompt: &str,
690        provider: AnyProvider,
691        tool_executor: Arc<dyn ErasedToolExecutor>,
692        skills: Option<Vec<String>>,
693        config: &SubAgentConfig,
694    ) -> Result<String, SubAgentError> {
695        let mut def = self
696            .definitions
697            .iter()
698            .find(|d| d.name == def_name)
699            .cloned()
700            .ok_or_else(|| SubAgentError::NotFound(def_name.to_owned()))?;
701
702        // Apply config-level defaults: if agent has Default permission mode, use the
703        // config default_permission_mode if set.
704        if def.permissions.permission_mode == PermissionMode::Default
705            && let Some(default_mode) = config.default_permission_mode
706        {
707            def.permissions.permission_mode = default_mode;
708        }
709
710        // Merge global disallowed_tools into per-agent disallowed_tools (deny wins).
711        if !config.default_disallowed_tools.is_empty() {
712            let mut merged = def.disallowed_tools.clone();
713            for tool in &config.default_disallowed_tools {
714                if !merged.contains(tool) {
715                    merged.push(tool.clone());
716                }
717            }
718            def.disallowed_tools = merged;
719        }
720
721        // Guard: bypass_permissions requires explicit opt-in at config level.
722        if def.permissions.permission_mode == PermissionMode::BypassPermissions
723            && !config.allow_bypass_permissions
724        {
725            return Err(SubAgentError::Invalid(format!(
726                "sub-agent '{}' requests bypass_permissions mode but it is not allowed by config \
727                 (set agents.allow_bypass_permissions = true to enable)",
728                def.name
729            )));
730        }
731
732        let active = self
733            .agents
734            .values()
735            .filter(|h| matches!(h.state, SubAgentState::Working | SubAgentState::Submitted))
736            .count();
737
738        if active >= self.max_concurrent {
739            return Err(SubAgentError::Spawn(format!(
740                "concurrency limit {max} reached",
741                max = self.max_concurrent
742            )));
743        }
744
745        let task_id = Uuid::new_v4().to_string();
746        let cancel = CancellationToken::new();
747
748        let started_at = Instant::now();
749        let initial_status = SubAgentStatus {
750            state: SubAgentState::Submitted,
751            last_message: None,
752            turns_used: 0,
753            started_at,
754        };
755        let (status_tx, status_rx) = watch::channel(initial_status);
756
757        let permission_mode = def.permissions.permission_mode;
758        let background = def.permissions.background;
759        let max_turns = def.permissions.max_turns;
760
761        // Apply config-level default_memory_scope when the agent has no explicit memory field.
762        let effective_memory = def.memory.or(config.default_memory_scope);
763
764        // IMPORTANT (REV-HIGH-03): build_system_prompt_with_memory may mutate def.tools
765        // (auto-enables Read/Write/Edit for AllowList memory). FilteredToolExecutor MUST
766        // be constructed AFTER this call to pick up the updated tool list.
767        let system_prompt = build_system_prompt_with_memory(&mut def, effective_memory);
768
769        let task_prompt = task_prompt.to_owned();
770        let cancel_clone = cancel.clone();
771        let agent_hooks = def.hooks.clone();
772        let agent_name_clone = def.name.clone();
773
774        let filtered_executor = FilteredToolExecutor::with_disallowed(
775            tool_executor.clone(),
776            def.tools.clone(),
777            def.disallowed_tools.clone(),
778        );
779
780        // Plan mode: wrap executor to expose real tool definitions but block execution.
781        let executor: FilteredToolExecutor = if permission_mode == PermissionMode::Plan {
782            let plan_inner = Arc::new(PlanModeExecutor::new(tool_executor));
783            FilteredToolExecutor::with_disallowed(
784                plan_inner,
785                def.tools.clone(),
786                def.disallowed_tools.clone(),
787            )
788        } else {
789            filtered_executor
790        };
791
792        let (secret_request_tx, pending_secret_rx) = mpsc::channel::<SecretRequest>(4);
793        let (secret_tx, secret_rx) = mpsc::channel::<Option<String>>(4);
794
795        // Transcript setup: create writer if enabled, run sweep.
796        let transcript_writer = if config.transcript_enabled {
797            let dir = self.effective_transcript_dir(config);
798            if self.transcript_max_files > 0
799                && let Err(e) = sweep_old_transcripts(&dir, self.transcript_max_files)
800            {
801                tracing::warn!(error = %e, "transcript sweep failed");
802            }
803            let path = dir.join(format!("{task_id}.jsonl"));
804            match TranscriptWriter::new(&path) {
805                Ok(w) => {
806                    // Write initial meta with status=Submitted so running agents are
807                    // discoverable even before completion.
808                    let meta = TranscriptMeta {
809                        agent_id: task_id.clone(),
810                        agent_name: def.name.clone(),
811                        def_name: def.name.clone(),
812                        status: SubAgentState::Submitted,
813                        started_at: crate::subagent::transcript::utc_now_pub(),
814                        finished_at: None,
815                        resumed_from: None,
816                        turns_used: 0,
817                    };
818                    if let Err(e) = TranscriptWriter::write_meta(&dir, &task_id, &meta) {
819                        tracing::warn!(error = %e, "failed to write initial transcript meta");
820                    }
821                    Some(w)
822                }
823                Err(e) => {
824                    tracing::warn!(error = %e, "failed to create transcript writer");
825                    None
826                }
827            }
828        } else {
829            None
830        };
831
832        let task_id_for_loop = task_id.clone();
833        let join_handle: JoinHandle<anyhow::Result<String>> =
834            tokio::spawn(run_agent_loop(AgentLoopArgs {
835                provider,
836                executor,
837                system_prompt,
838                task_prompt,
839                skills,
840                max_turns,
841                cancel: cancel_clone,
842                status_tx,
843                started_at,
844                secret_request_tx,
845                secret_rx,
846                background,
847                hooks: agent_hooks,
848                task_id: task_id_for_loop,
849                agent_name: agent_name_clone,
850                initial_messages: vec![],
851                transcript_writer,
852            }));
853
854        let handle_transcript_dir = if config.transcript_enabled {
855            Some(self.effective_transcript_dir(config))
856        } else {
857            None
858        };
859
860        let handle = SubAgentHandle {
861            id: task_id.clone(),
862            def,
863            task_id: task_id.clone(),
864            state: SubAgentState::Submitted,
865            join_handle: Some(join_handle),
866            cancel,
867            status_rx,
868            grants: PermissionGrants::default(),
869            pending_secret_rx,
870            secret_tx,
871            started_at_str: crate::subagent::transcript::utc_now_pub(),
872            transcript_dir: handle_transcript_dir,
873        };
874
875        self.agents.insert(task_id.clone(), handle);
876        // FIX-6: log permission_mode so operators can audit privilege escalation at spawn time.
877        // TODO: enforce permission_mode at runtime (restrict tool access based on mode).
878        tracing::info!(
879            task_id,
880            def_name,
881            permission_mode = ?self.agents[&task_id].def.permissions.permission_mode,
882            "sub-agent spawned"
883        );
884
885        // Cache stop hooks from config so cancel() and collect() can fire them
886        // without needing a config reference. Only update when non-empty to avoid
887        // overwriting a previously configured stop hook list with an empty default.
888        if !config.hooks.stop.is_empty() && self.stop_hooks.is_empty() {
889            self.stop_hooks.clone_from(&config.hooks.stop);
890        }
891
892        // Fire SubagentStart lifecycle hooks (fire-and-forget).
893        if !config.hooks.start.is_empty() {
894            let start_hooks = config.hooks.start.clone();
895            let start_env = make_hook_env(&task_id, def_name, "");
896            tokio::spawn(async move {
897                if let Err(e) = fire_hooks(&start_hooks, &start_env).await {
898                    tracing::warn!(error = %e, "SubagentStart hook failed");
899                }
900            });
901        }
902
903        Ok(task_id)
904    }
905
906    /// Cancel all active sub-agents. Called during main agent shutdown.
907    pub fn shutdown_all(&mut self) {
908        let ids: Vec<String> = self.agents.keys().cloned().collect();
909        for id in ids {
910            let _ = self.cancel(&id);
911        }
912    }
913
914    /// Cancel a running sub-agent by task ID.
915    ///
916    /// # Errors
917    ///
918    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown.
919    pub fn cancel(&mut self, task_id: &str) -> Result<(), SubAgentError> {
920        let handle = self
921            .agents
922            .get_mut(task_id)
923            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
924        handle.cancel.cancel();
925        handle.state = SubAgentState::Canceled;
926        handle.grants.revoke_all();
927        tracing::info!(task_id, "sub-agent cancelled");
928
929        // Fire SubagentStop lifecycle hooks (fire-and-forget).
930        if !self.stop_hooks.is_empty() {
931            let stop_hooks = self.stop_hooks.clone();
932            let stop_env = make_hook_env(task_id, &handle.def.name, "");
933            tokio::spawn(async move {
934                if let Err(e) = fire_hooks(&stop_hooks, &stop_env).await {
935                    tracing::warn!(error = %e, "SubagentStop hook failed");
936                }
937            });
938        }
939
940        Ok(())
941    }
942
943    /// Approve a secret request for a running sub-agent.
944    ///
945    /// Called after the user approves a vault secret access prompt. The secret
946    /// key must appear in the sub-agent definition's allowed `secrets` list;
947    /// otherwise the request is auto-denied.
948    ///
949    /// # Errors
950    ///
951    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown,
952    /// [`SubAgentError::Invalid`] if the key is not in the definition's allowed list.
953    pub fn approve_secret(
954        &mut self,
955        task_id: &str,
956        secret_key: &str,
957        ttl: std::time::Duration,
958    ) -> Result<(), SubAgentError> {
959        let handle = self
960            .agents
961            .get_mut(task_id)
962            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
963
964        // Sweep stale grants before adding a new one for consistent housekeeping.
965        handle.grants.sweep_expired();
966
967        if !handle
968            .def
969            .permissions
970            .secrets
971            .iter()
972            .any(|k| k == secret_key)
973        {
974            // Do not log the key name at warn level — only log that a request was denied.
975            tracing::warn!(task_id, "secret request denied: key not in allowed list");
976            return Err(SubAgentError::Invalid(format!(
977                "secret is not in the allowed secrets list for '{}'",
978                handle.def.name
979            )));
980        }
981
982        handle.grants.grant_secret(secret_key, ttl);
983        Ok(())
984    }
985
986    /// Deliver a secret value to a waiting sub-agent loop.
987    ///
988    /// Should be called after the user approves the request and the vault value
989    /// has been resolved. Returns an error if no such agent is found.
990    ///
991    /// # Errors
992    ///
993    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown.
994    pub fn deliver_secret(&mut self, task_id: &str, key: String) -> Result<(), SubAgentError> {
995        // Signal approval to the sub-agent loop. The secret value is NOT passed through the
996        // channel to avoid embedding it in LLM message history. The sub-agent accesses it
997        // exclusively via PermissionGrants (granted by approve_secret() before this call).
998        let handle = self
999            .agents
1000            .get_mut(task_id)
1001            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
1002        handle
1003            .secret_tx
1004            .try_send(Some(key))
1005            .map_err(|e| SubAgentError::Other(anyhow::anyhow!("{e}")))
1006    }
1007
1008    /// Deny a pending secret request — sends `None` to unblock the waiting sub-agent loop.
1009    ///
1010    /// # Errors
1011    ///
1012    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown,
1013    /// [`SubAgentError::Other`] if the channel is full or closed.
1014    pub fn deny_secret(&mut self, task_id: &str) -> Result<(), SubAgentError> {
1015        let handle = self
1016            .agents
1017            .get_mut(task_id)
1018            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
1019        handle
1020            .secret_tx
1021            .try_send(None)
1022            .map_err(|e| SubAgentError::Other(anyhow::anyhow!("{e}")))
1023    }
1024
1025    /// Try to receive a pending secret request from a sub-agent (non-blocking).
1026    ///
1027    /// Returns `Some((task_id, SecretRequest))` if a request is waiting.
1028    pub fn try_recv_secret_request(&mut self) -> Option<(String, SecretRequest)> {
1029        for handle in self.agents.values_mut() {
1030            if let Ok(req) = handle.pending_secret_rx.try_recv() {
1031                return Some((handle.task_id.clone(), req));
1032            }
1033        }
1034        None
1035    }
1036
1037    /// Collect the result from a completed sub-agent, removing it from the active set.
1038    ///
1039    /// Writes a final `TranscriptMeta` sidecar with the terminal state and turn count.
1040    ///
1041    /// # Errors
1042    ///
1043    /// Returns [`SubAgentError::NotFound`] if the task ID is unknown,
1044    /// [`SubAgentError::Spawn`] if the task panicked.
1045    pub async fn collect(&mut self, task_id: &str) -> Result<String, SubAgentError> {
1046        let mut handle = self
1047            .agents
1048            .remove(task_id)
1049            .ok_or_else(|| SubAgentError::NotFound(task_id.to_owned()))?;
1050
1051        // Fire SubagentStop lifecycle hooks (fire-and-forget) before cleanup.
1052        if !self.stop_hooks.is_empty() {
1053            let stop_hooks = self.stop_hooks.clone();
1054            let stop_env = make_hook_env(task_id, &handle.def.name, "");
1055            tokio::spawn(async move {
1056                if let Err(e) = fire_hooks(&stop_hooks, &stop_env).await {
1057                    tracing::warn!(error = %e, "SubagentStop hook failed");
1058                }
1059            });
1060        }
1061
1062        handle.grants.revoke_all();
1063
1064        let result = if let Some(jh) = handle.join_handle.take() {
1065            let r = jh.await.map_err(|e| SubAgentError::Spawn(e.to_string()))?;
1066            r.map_err(|e| SubAgentError::Spawn(e.to_string()))
1067        } else {
1068            Ok(String::new())
1069        };
1070
1071        // Write terminal meta sidecar if transcripts were enabled at spawn time.
1072        if let Some(ref dir) = handle.transcript_dir.clone() {
1073            let status = handle.status_rx.borrow();
1074            let final_status = if result.is_err() {
1075                SubAgentState::Failed
1076            } else if status.state == SubAgentState::Canceled {
1077                SubAgentState::Canceled
1078            } else {
1079                SubAgentState::Completed
1080            };
1081            let turns_used = status.turns_used;
1082            drop(status);
1083
1084            let meta = TranscriptMeta {
1085                agent_id: task_id.to_owned(),
1086                agent_name: handle.def.name.clone(),
1087                def_name: handle.def.name.clone(),
1088                status: final_status,
1089                started_at: handle.started_at_str.clone(),
1090                finished_at: Some(crate::subagent::transcript::utc_now_pub()),
1091                resumed_from: None,
1092                turns_used,
1093            };
1094            if let Err(e) = TranscriptWriter::write_meta(dir, task_id, &meta) {
1095                tracing::warn!(error = %e, task_id, "failed to write final transcript meta");
1096            }
1097        }
1098
1099        result
1100    }
1101
1102    /// Resume a previously completed (or failed/cancelled) sub-agent session.
1103    ///
1104    /// Loads the transcript from the original session into memory and spawns a new
1105    /// agent loop with that history prepended. The new session gets a fresh UUID.
1106    ///
1107    /// Returns `(new_task_id, def_name)` on success so the caller can resolve skills by name.
1108    ///
1109    /// # Errors
1110    ///
1111    /// Returns [`SubAgentError::StillRunning`] if the agent is still active,
1112    /// [`SubAgentError::NotFound`] if no transcript with the given prefix exists,
1113    /// [`SubAgentError::AmbiguousId`] if the prefix matches multiple agents,
1114    /// [`SubAgentError::Transcript`] on I/O or parse failure,
1115    /// [`SubAgentError::Spawn`] if the concurrency limit is exceeded.
1116    #[allow(clippy::too_many_lines, clippy::too_many_arguments)]
1117    pub fn resume(
1118        &mut self,
1119        id_prefix: &str,
1120        task_prompt: &str,
1121        provider: AnyProvider,
1122        tool_executor: Arc<dyn ErasedToolExecutor>,
1123        skills: Option<Vec<String>>,
1124        config: &SubAgentConfig,
1125    ) -> Result<(String, String), SubAgentError> {
1126        let dir = self.effective_transcript_dir(config);
1127        // Resolve full original ID first so the StillRunning check is precise
1128        // (avoids false positives from very short prefixes matching unrelated active agents).
1129        let original_id = TranscriptReader::find_by_prefix(&dir, id_prefix)?;
1130
1131        // Check if the resolved original agent ID is still active in memory.
1132        if self.agents.contains_key(&original_id) {
1133            return Err(SubAgentError::StillRunning(original_id));
1134        }
1135        let meta = TranscriptReader::load_meta(&dir, &original_id)?;
1136
1137        // Only terminal states can be resumed.
1138        match meta.status {
1139            SubAgentState::Completed | SubAgentState::Failed | SubAgentState::Canceled => {}
1140            other => {
1141                return Err(SubAgentError::StillRunning(format!(
1142                    "{original_id} (status: {other:?})"
1143                )));
1144            }
1145        }
1146
1147        let jsonl_path = dir.join(format!("{original_id}.jsonl"));
1148        let initial_messages = TranscriptReader::load(&jsonl_path)?;
1149
1150        // Resolve the definition from the original meta and apply config-level defaults,
1151        // identical to spawn() so that config policy is always enforced.
1152        let mut def = self
1153            .definitions
1154            .iter()
1155            .find(|d| d.name == meta.def_name)
1156            .cloned()
1157            .ok_or_else(|| SubAgentError::NotFound(meta.def_name.clone()))?;
1158
1159        if def.permissions.permission_mode == PermissionMode::Default
1160            && let Some(default_mode) = config.default_permission_mode
1161        {
1162            def.permissions.permission_mode = default_mode;
1163        }
1164
1165        if !config.default_disallowed_tools.is_empty() {
1166            let mut merged = def.disallowed_tools.clone();
1167            for tool in &config.default_disallowed_tools {
1168                if !merged.contains(tool) {
1169                    merged.push(tool.clone());
1170                }
1171            }
1172            def.disallowed_tools = merged;
1173        }
1174
1175        if def.permissions.permission_mode == PermissionMode::BypassPermissions
1176            && !config.allow_bypass_permissions
1177        {
1178            return Err(SubAgentError::Invalid(format!(
1179                "sub-agent '{}' requests bypass_permissions mode but it is not allowed by config",
1180                def.name
1181            )));
1182        }
1183
1184        // Check concurrency limit.
1185        let active = self
1186            .agents
1187            .values()
1188            .filter(|h| matches!(h.state, SubAgentState::Working | SubAgentState::Submitted))
1189            .count();
1190        if active >= self.max_concurrent {
1191            return Err(SubAgentError::Spawn(format!(
1192                "concurrency limit {} reached",
1193                self.max_concurrent
1194            )));
1195        }
1196
1197        let new_task_id = Uuid::new_v4().to_string();
1198        let cancel = CancellationToken::new();
1199        let started_at = Instant::now();
1200        let initial_status = SubAgentStatus {
1201            state: SubAgentState::Submitted,
1202            last_message: None,
1203            turns_used: 0,
1204            started_at,
1205        };
1206        let (status_tx, status_rx) = watch::channel(initial_status);
1207
1208        let permission_mode = def.permissions.permission_mode;
1209        let background = def.permissions.background;
1210        let max_turns = def.permissions.max_turns;
1211        let system_prompt = def.system_prompt.clone();
1212        let task_prompt_owned = task_prompt.to_owned();
1213        let cancel_clone = cancel.clone();
1214        let agent_hooks = def.hooks.clone();
1215        let agent_name_clone = def.name.clone();
1216
1217        let filtered_executor = FilteredToolExecutor::with_disallowed(
1218            tool_executor.clone(),
1219            def.tools.clone(),
1220            def.disallowed_tools.clone(),
1221        );
1222        let executor: FilteredToolExecutor = if permission_mode == PermissionMode::Plan {
1223            let plan_inner = Arc::new(PlanModeExecutor::new(tool_executor));
1224            FilteredToolExecutor::with_disallowed(
1225                plan_inner,
1226                def.tools.clone(),
1227                def.disallowed_tools.clone(),
1228            )
1229        } else {
1230            filtered_executor
1231        };
1232
1233        let (secret_request_tx, pending_secret_rx) = mpsc::channel::<SecretRequest>(4);
1234        let (secret_tx, secret_rx) = mpsc::channel::<Option<String>>(4);
1235
1236        // Transcript writer for the new (resumed) session.
1237        let transcript_writer = if config.transcript_enabled {
1238            if self.transcript_max_files > 0
1239                && let Err(e) = sweep_old_transcripts(&dir, self.transcript_max_files)
1240            {
1241                tracing::warn!(error = %e, "transcript sweep failed");
1242            }
1243            let new_path = dir.join(format!("{new_task_id}.jsonl"));
1244            let init_meta = TranscriptMeta {
1245                agent_id: new_task_id.clone(),
1246                agent_name: def.name.clone(),
1247                def_name: def.name.clone(),
1248                status: SubAgentState::Submitted,
1249                started_at: crate::subagent::transcript::utc_now_pub(),
1250                finished_at: None,
1251                resumed_from: Some(original_id.clone()),
1252                turns_used: 0,
1253            };
1254            if let Err(e) = TranscriptWriter::write_meta(&dir, &new_task_id, &init_meta) {
1255                tracing::warn!(error = %e, "failed to write resumed transcript meta");
1256            }
1257            match TranscriptWriter::new(&new_path) {
1258                Ok(w) => Some(w),
1259                Err(e) => {
1260                    tracing::warn!(error = %e, "failed to create resumed transcript writer");
1261                    None
1262                }
1263            }
1264        } else {
1265            None
1266        };
1267
1268        let new_task_id_for_loop = new_task_id.clone();
1269        let join_handle: JoinHandle<anyhow::Result<String>> =
1270            tokio::spawn(run_agent_loop(AgentLoopArgs {
1271                provider,
1272                executor,
1273                system_prompt,
1274                task_prompt: task_prompt_owned,
1275                skills,
1276                max_turns,
1277                cancel: cancel_clone,
1278                status_tx,
1279                started_at,
1280                secret_request_tx,
1281                secret_rx,
1282                background,
1283                hooks: agent_hooks,
1284                task_id: new_task_id_for_loop,
1285                agent_name: agent_name_clone,
1286                initial_messages,
1287                transcript_writer,
1288            }));
1289
1290        let resume_handle_transcript_dir = if config.transcript_enabled {
1291            Some(dir.clone())
1292        } else {
1293            None
1294        };
1295
1296        let handle = SubAgentHandle {
1297            id: new_task_id.clone(),
1298            def,
1299            task_id: new_task_id.clone(),
1300            state: SubAgentState::Submitted,
1301            join_handle: Some(join_handle),
1302            cancel,
1303            status_rx,
1304            grants: PermissionGrants::default(),
1305            pending_secret_rx,
1306            secret_tx,
1307            started_at_str: crate::subagent::transcript::utc_now_pub(),
1308            transcript_dir: resume_handle_transcript_dir,
1309        };
1310
1311        self.agents.insert(new_task_id.clone(), handle);
1312        tracing::info!(
1313            task_id = %new_task_id,
1314            original_id = %original_id,
1315            "sub-agent resumed"
1316        );
1317
1318        // Cache stop hooks from config if not already cached.
1319        if !config.hooks.stop.is_empty() && self.stop_hooks.is_empty() {
1320            self.stop_hooks.clone_from(&config.hooks.stop);
1321        }
1322
1323        // Fire SubagentStart lifecycle hooks (fire-and-forget).
1324        if !config.hooks.start.is_empty() {
1325            let start_hooks = config.hooks.start.clone();
1326            let def_name = meta.def_name.clone();
1327            let start_env = make_hook_env(&new_task_id, &def_name, "");
1328            tokio::spawn(async move {
1329                if let Err(e) = fire_hooks(&start_hooks, &start_env).await {
1330                    tracing::warn!(error = %e, "SubagentStart hook failed");
1331                }
1332            });
1333        }
1334
1335        Ok((new_task_id, meta.def_name))
1336    }
1337
1338    /// Resolve the effective transcript directory from config or default.
1339    fn effective_transcript_dir(&self, config: &SubAgentConfig) -> PathBuf {
1340        if let Some(ref dir) = self.transcript_dir {
1341            dir.clone()
1342        } else if let Some(ref dir) = config.transcript_dir {
1343            dir.clone()
1344        } else {
1345            PathBuf::from(".zeph/subagents")
1346        }
1347    }
1348
1349    /// Look up the definition name for a resumable transcript without spawning.
1350    ///
1351    /// Used by callers that need to resolve skills before calling `resume()`.
1352    ///
1353    /// # Errors
1354    ///
1355    /// Returns the same errors as [`TranscriptReader::find_by_prefix`] and
1356    /// [`TranscriptReader::load_meta`].
1357    pub fn def_name_for_resume(
1358        &self,
1359        id_prefix: &str,
1360        config: &SubAgentConfig,
1361    ) -> Result<String, SubAgentError> {
1362        let dir = self.effective_transcript_dir(config);
1363        let original_id = TranscriptReader::find_by_prefix(&dir, id_prefix)?;
1364        let meta = TranscriptReader::load_meta(&dir, &original_id)?;
1365        Ok(meta.def_name)
1366    }
1367
1368    /// Return a snapshot of all active sub-agent statuses.
1369    #[must_use]
1370    pub fn statuses(&self) -> Vec<(String, SubAgentStatus)> {
1371        self.agents
1372            .values()
1373            .map(|h| {
1374                let mut status = h.status_rx.borrow().clone();
1375                // cancel() updates handle.state synchronously but the background task
1376                // may not have sent the final watch update yet; reflect it here.
1377                if h.state == SubAgentState::Canceled {
1378                    status.state = SubAgentState::Canceled;
1379                }
1380                (h.task_id.clone(), status)
1381            })
1382            .collect()
1383    }
1384
1385    /// Return the definition for a specific agent by `task_id`.
1386    #[must_use]
1387    pub fn agents_def(&self, task_id: &str) -> Option<&SubAgentDef> {
1388        self.agents.get(task_id).map(|h| &h.def)
1389    }
1390
1391    /// Spawn a sub-agent for an orchestrated task.
1392    ///
1393    /// Identical to [`spawn`][Self::spawn] but wraps the `JoinHandle` to send a
1394    /// [`crate::orchestration::TaskEvent`] on the provided channel when the agent loop
1395    /// terminates. This allows the `DagScheduler` to receive completion notifications
1396    /// without polling (ADR-027).
1397    ///
1398    /// The `event_tx` channel is best-effort: if the scheduler is dropped before all
1399    /// agents complete, the send will fail silently with a warning log.
1400    ///
1401    /// # Errors
1402    ///
1403    /// Same error conditions as [`spawn`][Self::spawn].
1404    ///
1405    /// # Panics
1406    ///
1407    /// Panics if the internal agent entry is missing after a successful `spawn` call.
1408    /// This is a programming error and should never occur in normal operation.
1409    #[cfg(feature = "orchestration")]
1410    #[allow(clippy::too_many_arguments)]
1411    pub fn spawn_for_task(
1412        &mut self,
1413        def_name: &str,
1414        task_prompt: &str,
1415        provider: AnyProvider,
1416        tool_executor: Arc<dyn ErasedToolExecutor>,
1417        skills: Option<Vec<String>>,
1418        config: &SubAgentConfig,
1419        orch_task_id: crate::orchestration::TaskId,
1420        event_tx: tokio::sync::mpsc::Sender<crate::orchestration::TaskEvent>,
1421    ) -> Result<String, SubAgentError> {
1422        use crate::orchestration::{TaskEvent, TaskOutcome};
1423
1424        let handle_id = self.spawn(
1425            def_name,
1426            task_prompt,
1427            provider,
1428            tool_executor,
1429            skills,
1430            config,
1431        )?;
1432
1433        let handle = self
1434            .agents
1435            .get_mut(&handle_id)
1436            .expect("just spawned agent must exist");
1437
1438        let original_join = handle
1439            .join_handle
1440            .take()
1441            .expect("just spawned agent must have a join handle");
1442
1443        let handle_id_clone = handle_id.clone();
1444        let wrapped_join: tokio::task::JoinHandle<anyhow::Result<String>> =
1445            tokio::spawn(async move {
1446                let result = original_join.await;
1447
1448                let (outcome, output) = match &result {
1449                    Ok(Ok(output)) => (
1450                        TaskOutcome::Completed {
1451                            output: output.clone(),
1452                            artifacts: vec![],
1453                        },
1454                        Ok(output.clone()),
1455                    ),
1456                    Ok(Err(e)) => (
1457                        TaskOutcome::Failed {
1458                            error: e.to_string(),
1459                        },
1460                        Err(anyhow::anyhow!("{e}")),
1461                    ),
1462                    Err(join_err) => (
1463                        TaskOutcome::Failed {
1464                            // Use Debug format to preserve panic backtrace info (S3).
1465                            error: format!("task panicked: {join_err:?}"),
1466                        },
1467                        Err(anyhow::anyhow!("task panicked: {join_err:?}")),
1468                    ),
1469                };
1470
1471                // Best-effort send. If the scheduler was dropped, warn but do not fail.
1472                if let Err(e) = event_tx
1473                    .send(TaskEvent {
1474                        task_id: orch_task_id,
1475                        agent_handle_id: handle_id_clone,
1476                        outcome,
1477                    })
1478                    .await
1479                {
1480                    tracing::warn!(
1481                        error = %e,
1482                        "failed to send TaskEvent: scheduler may have been dropped"
1483                    );
1484                }
1485
1486                match output {
1487                    Ok(s) => Ok(s),
1488                    Err(e) => Err(e),
1489                }
1490            });
1491
1492        handle.join_handle = Some(wrapped_join);
1493
1494        Ok(handle_id)
1495    }
1496}
1497
1498#[cfg(test)]
1499mod tests {
1500    use std::pin::Pin;
1501
1502    use indoc::indoc;
1503    use zeph_llm::any::AnyProvider;
1504    use zeph_llm::mock::MockProvider;
1505    use zeph_tools::ToolCall;
1506    use zeph_tools::executor::{ErasedToolExecutor, ToolError, ToolOutput};
1507    use zeph_tools::registry::ToolDef;
1508
1509    use serial_test::serial;
1510
1511    use crate::config::SubAgentConfig;
1512    use crate::subagent::def::MemoryScope;
1513
1514    use super::*;
1515
1516    fn make_manager() -> SubAgentManager {
1517        SubAgentManager::new(4)
1518    }
1519
1520    fn sample_def() -> SubAgentDef {
1521        SubAgentDef::parse("---\nname: bot\ndescription: A bot\n---\n\nDo things.\n").unwrap()
1522    }
1523
1524    fn def_with_secrets() -> SubAgentDef {
1525        SubAgentDef::parse(
1526            "---\nname: bot\ndescription: A bot\npermissions:\n  secrets:\n    - api-key\n---\n\nDo things.\n",
1527        )
1528        .unwrap()
1529    }
1530
1531    struct NoopExecutor;
1532
1533    impl ErasedToolExecutor for NoopExecutor {
1534        fn execute_erased<'a>(
1535            &'a self,
1536            _response: &'a str,
1537        ) -> Pin<
1538            Box<
1539                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1540            >,
1541        > {
1542            Box::pin(std::future::ready(Ok(None)))
1543        }
1544
1545        fn execute_confirmed_erased<'a>(
1546            &'a self,
1547            _response: &'a str,
1548        ) -> Pin<
1549            Box<
1550                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1551            >,
1552        > {
1553            Box::pin(std::future::ready(Ok(None)))
1554        }
1555
1556        fn tool_definitions_erased(&self) -> Vec<ToolDef> {
1557            vec![]
1558        }
1559
1560        fn execute_tool_call_erased<'a>(
1561            &'a self,
1562            _call: &'a ToolCall,
1563        ) -> Pin<
1564            Box<
1565                dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>> + Send + 'a,
1566            >,
1567        > {
1568            Box::pin(std::future::ready(Ok(None)))
1569        }
1570    }
1571
1572    fn mock_provider(responses: Vec<&str>) -> AnyProvider {
1573        AnyProvider::Mock(MockProvider::with_responses(
1574            responses.into_iter().map(String::from).collect(),
1575        ))
1576    }
1577
1578    fn noop_executor() -> Arc<dyn ErasedToolExecutor> {
1579        Arc::new(NoopExecutor)
1580    }
1581
1582    fn do_spawn(
1583        mgr: &mut SubAgentManager,
1584        name: &str,
1585        prompt: &str,
1586    ) -> Result<String, SubAgentError> {
1587        mgr.spawn(
1588            name,
1589            prompt,
1590            mock_provider(vec!["done"]),
1591            noop_executor(),
1592            None,
1593            &SubAgentConfig::default(),
1594        )
1595    }
1596
1597    #[test]
1598    fn load_definitions_populates_vec() {
1599        use std::io::Write as _;
1600        let dir = tempfile::tempdir().unwrap();
1601        let content = "---\nname: helper\ndescription: A helper\n---\n\nHelp.\n";
1602        let mut f = std::fs::File::create(dir.path().join("helper.md")).unwrap();
1603        f.write_all(content.as_bytes()).unwrap();
1604
1605        let mut mgr = make_manager();
1606        mgr.load_definitions(&[dir.path().to_path_buf()]).unwrap();
1607        assert_eq!(mgr.definitions().len(), 1);
1608        assert_eq!(mgr.definitions()[0].name, "helper");
1609    }
1610
1611    #[test]
1612    fn spawn_not_found_error() {
1613        let rt = tokio::runtime::Runtime::new().unwrap();
1614        let _guard = rt.enter();
1615        let mut mgr = make_manager();
1616        let err = do_spawn(&mut mgr, "nonexistent", "prompt").unwrap_err();
1617        assert!(matches!(err, SubAgentError::NotFound(_)));
1618    }
1619
1620    #[test]
1621    fn spawn_and_cancel() {
1622        let rt = tokio::runtime::Runtime::new().unwrap();
1623        let _guard = rt.enter();
1624        let mut mgr = make_manager();
1625        mgr.definitions.push(sample_def());
1626
1627        let task_id = do_spawn(&mut mgr, "bot", "do stuff").unwrap();
1628        assert!(!task_id.is_empty());
1629
1630        mgr.cancel(&task_id).unwrap();
1631        assert_eq!(mgr.agents[&task_id].state, SubAgentState::Canceled);
1632    }
1633
1634    #[test]
1635    fn cancel_unknown_task_id_returns_not_found() {
1636        let mut mgr = make_manager();
1637        let err = mgr.cancel("unknown-id").unwrap_err();
1638        assert!(matches!(err, SubAgentError::NotFound(_)));
1639    }
1640
1641    #[tokio::test]
1642    async fn collect_removes_agent() {
1643        let mut mgr = make_manager();
1644        mgr.definitions.push(sample_def());
1645
1646        let task_id = do_spawn(&mut mgr, "bot", "do stuff").unwrap();
1647        mgr.cancel(&task_id).unwrap();
1648
1649        // Wait briefly for the task to observe cancellation
1650        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
1651
1652        let result = mgr.collect(&task_id).await.unwrap();
1653        assert!(!mgr.agents.contains_key(&task_id));
1654        // result may be empty string (cancelled before LLM response) or the mock response
1655        let _ = result;
1656    }
1657
1658    #[tokio::test]
1659    async fn collect_unknown_task_id_returns_not_found() {
1660        let mut mgr = make_manager();
1661        let err = mgr.collect("unknown-id").await.unwrap_err();
1662        assert!(matches!(err, SubAgentError::NotFound(_)));
1663    }
1664
1665    #[test]
1666    fn approve_secret_grants_access() {
1667        let rt = tokio::runtime::Runtime::new().unwrap();
1668        let _guard = rt.enter();
1669        let mut mgr = make_manager();
1670        mgr.definitions.push(def_with_secrets());
1671
1672        let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1673        mgr.approve_secret(&task_id, "api-key", std::time::Duration::from_secs(60))
1674            .unwrap();
1675
1676        let handle = mgr.agents.get_mut(&task_id).unwrap();
1677        assert!(
1678            handle
1679                .grants
1680                .is_active(&crate::subagent::GrantKind::Secret("api-key".into()))
1681        );
1682    }
1683
1684    #[test]
1685    fn approve_secret_denied_for_unlisted_key() {
1686        let rt = tokio::runtime::Runtime::new().unwrap();
1687        let _guard = rt.enter();
1688        let mut mgr = make_manager();
1689        mgr.definitions.push(sample_def()); // no secrets in allowed list
1690
1691        let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1692        let err = mgr
1693            .approve_secret(&task_id, "not-allowed", std::time::Duration::from_secs(60))
1694            .unwrap_err();
1695        assert!(matches!(err, SubAgentError::Invalid(_)));
1696    }
1697
1698    #[test]
1699    fn approve_secret_unknown_task_id_returns_not_found() {
1700        let mut mgr = make_manager();
1701        let err = mgr
1702            .approve_secret("unknown", "key", std::time::Duration::from_secs(60))
1703            .unwrap_err();
1704        assert!(matches!(err, SubAgentError::NotFound(_)));
1705    }
1706
1707    #[test]
1708    fn statuses_returns_active_agents() {
1709        let rt = tokio::runtime::Runtime::new().unwrap();
1710        let _guard = rt.enter();
1711        let mut mgr = make_manager();
1712        mgr.definitions.push(sample_def());
1713
1714        let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1715        let statuses = mgr.statuses();
1716        assert_eq!(statuses.len(), 1);
1717        assert_eq!(statuses[0].0, task_id);
1718    }
1719
1720    #[test]
1721    fn concurrency_limit_enforced() {
1722        let rt = tokio::runtime::Runtime::new().unwrap();
1723        let _guard = rt.enter();
1724        let mut mgr = SubAgentManager::new(1);
1725        mgr.definitions.push(sample_def());
1726
1727        let _first = do_spawn(&mut mgr, "bot", "first").unwrap();
1728        let err = do_spawn(&mut mgr, "bot", "second").unwrap_err();
1729        assert!(matches!(err, SubAgentError::Spawn(_)));
1730    }
1731
1732    #[tokio::test]
1733    async fn background_agent_does_not_block_caller() {
1734        let mut mgr = make_manager();
1735        mgr.definitions.push(sample_def());
1736
1737        // Spawn should return immediately without waiting for LLM
1738        let result = tokio::time::timeout(
1739            std::time::Duration::from_millis(100),
1740            std::future::ready(do_spawn(&mut mgr, "bot", "work")),
1741        )
1742        .await;
1743        assert!(result.is_ok(), "spawn() must not block");
1744        assert!(result.unwrap().is_ok());
1745    }
1746
1747    #[tokio::test]
1748    async fn max_turns_terminates_agent_loop() {
1749        let mut mgr = make_manager();
1750        // max_turns = 1, mock returns empty (no tool call), so loop ends after 1 turn
1751        let def = SubAgentDef::parse(indoc! {"
1752            ---
1753            name: limited
1754            description: A bot
1755            permissions:
1756              max_turns: 1
1757            ---
1758
1759            Do one thing.
1760        "})
1761        .unwrap();
1762        mgr.definitions.push(def);
1763
1764        let task_id = mgr
1765            .spawn(
1766                "limited",
1767                "task",
1768                mock_provider(vec!["final answer"]),
1769                noop_executor(),
1770                None,
1771                &SubAgentConfig::default(),
1772            )
1773            .unwrap();
1774
1775        // Wait for completion
1776        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1777
1778        let status = mgr.statuses().into_iter().find(|(id, _)| id == &task_id);
1779        // Status should show Completed or still Working but <= 1 turn
1780        if let Some((_, s)) = status {
1781            assert!(s.turns_used <= 1);
1782        }
1783    }
1784
1785    #[tokio::test]
1786    async fn cancellation_token_stops_agent_loop() {
1787        let mut mgr = make_manager();
1788        mgr.definitions.push(sample_def());
1789
1790        let task_id = do_spawn(&mut mgr, "bot", "long task").unwrap();
1791
1792        // Cancel immediately
1793        mgr.cancel(&task_id).unwrap();
1794
1795        // Wait a bit then collect
1796        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1797        let result = mgr.collect(&task_id).await;
1798        // Cancelled task may return empty or partial result — both are acceptable
1799        assert!(result.is_ok() || result.is_err());
1800    }
1801
1802    #[tokio::test]
1803    async fn shutdown_all_cancels_all_active_agents() {
1804        let mut mgr = make_manager();
1805        mgr.definitions.push(sample_def());
1806
1807        do_spawn(&mut mgr, "bot", "task 1").unwrap();
1808        do_spawn(&mut mgr, "bot", "task 2").unwrap();
1809
1810        assert_eq!(mgr.agents.len(), 2);
1811        mgr.shutdown_all();
1812
1813        // All agents should be in Canceled state
1814        for (_, status) in mgr.statuses() {
1815            assert_eq!(status.state, SubAgentState::Canceled);
1816        }
1817    }
1818
1819    #[test]
1820    fn debug_impl_does_not_expose_sensitive_fields() {
1821        let rt = tokio::runtime::Runtime::new().unwrap();
1822        let _guard = rt.enter();
1823        let mut mgr = make_manager();
1824        mgr.definitions.push(def_with_secrets());
1825        let task_id = do_spawn(&mut mgr, "bot", "work").unwrap();
1826        let handle = &mgr.agents[&task_id];
1827        let debug_str = format!("{handle:?}");
1828        // SubAgentHandle Debug must not expose grant contents or secrets
1829        assert!(!debug_str.contains("api-key"));
1830    }
1831
1832    #[tokio::test]
1833    async fn llm_failure_transitions_to_failed_state() {
1834        let rt_handle = tokio::runtime::Handle::current();
1835        let _guard = rt_handle.enter();
1836        let mut mgr = make_manager();
1837        mgr.definitions.push(sample_def());
1838
1839        let failing = AnyProvider::Mock(MockProvider::failing());
1840        let task_id = mgr
1841            .spawn(
1842                "bot",
1843                "do work",
1844                failing,
1845                noop_executor(),
1846                None,
1847                &SubAgentConfig::default(),
1848            )
1849            .unwrap();
1850
1851        // Wait for the background task to complete.
1852        tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
1853
1854        let statuses = mgr.statuses();
1855        let status = statuses
1856            .iter()
1857            .find(|(id, _)| id == &task_id)
1858            .map(|(_, s)| s);
1859        // The background loop should have caught the LLM error and reported Failed.
1860        assert!(
1861            status.is_some_and(|s| s.state == SubAgentState::Failed),
1862            "expected Failed, got: {status:?}"
1863        );
1864    }
1865
1866    #[tokio::test]
1867    async fn tool_call_loop_two_turns() {
1868        use std::sync::Mutex;
1869        use zeph_tools::ToolCall;
1870
1871        struct ToolOnceExecutor {
1872            calls: Mutex<u32>,
1873        }
1874
1875        impl ErasedToolExecutor for ToolOnceExecutor {
1876            fn execute_erased<'a>(
1877                &'a self,
1878                _response: &'a str,
1879            ) -> Pin<
1880                Box<
1881                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
1882                        + Send
1883                        + 'a,
1884                >,
1885            > {
1886                Box::pin(std::future::ready(Ok(None)))
1887            }
1888
1889            fn execute_confirmed_erased<'a>(
1890                &'a self,
1891                _response: &'a str,
1892            ) -> Pin<
1893                Box<
1894                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
1895                        + Send
1896                        + 'a,
1897                >,
1898            > {
1899                Box::pin(std::future::ready(Ok(None)))
1900            }
1901
1902            fn tool_definitions_erased(&self) -> Vec<ToolDef> {
1903                vec![]
1904            }
1905
1906            fn execute_tool_call_erased<'a>(
1907                &'a self,
1908                call: &'a ToolCall,
1909            ) -> Pin<
1910                Box<
1911                    dyn std::future::Future<Output = Result<Option<ToolOutput>, ToolError>>
1912                        + Send
1913                        + 'a,
1914                >,
1915            > {
1916                let mut n = self.calls.lock().unwrap();
1917                *n += 1;
1918                let result = if *n == 1 {
1919                    // First call: return tool output (simulates tool call)
1920                    Ok(Some(ToolOutput {
1921                        tool_name: call.tool_id.clone(),
1922                        summary: "step 1 done".into(),
1923                        blocks_executed: 1,
1924                        filter_stats: None,
1925                        diff: None,
1926                        streamed: false,
1927                        terminal_id: None,
1928                        locations: None,
1929                        raw_response: None,
1930                    }))
1931                } else {
1932                    Ok(None)
1933                };
1934                Box::pin(std::future::ready(result))
1935            }
1936        }
1937
1938        let rt_handle = tokio::runtime::Handle::current();
1939        let _guard = rt_handle.enter();
1940        let mut mgr = make_manager();
1941        mgr.definitions.push(sample_def());
1942
1943        // Two responses: first triggers tool handling, second is final.
1944        let provider = mock_provider(vec!["turn 1 response", "final answer"]);
1945        let executor = Arc::new(ToolOnceExecutor {
1946            calls: Mutex::new(0),
1947        });
1948
1949        let task_id = mgr
1950            .spawn(
1951                "bot",
1952                "run two turns",
1953                provider,
1954                executor,
1955                None,
1956                &SubAgentConfig::default(),
1957            )
1958            .unwrap();
1959
1960        // Wait for background loop to finish.
1961        tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
1962
1963        let result = mgr.collect(&task_id).await;
1964        assert!(result.is_ok(), "expected Ok, got: {result:?}");
1965    }
1966
1967    #[tokio::test]
1968    async fn collect_on_running_task_completes_eventually() {
1969        let mut mgr = make_manager();
1970        mgr.definitions.push(sample_def());
1971
1972        // Spawn with a slow response so the task is still running.
1973        let task_id = do_spawn(&mut mgr, "bot", "slow work").unwrap();
1974
1975        // collect() awaits the JoinHandle, so it will finish when the task completes.
1976        let result =
1977            tokio::time::timeout(tokio::time::Duration::from_secs(5), mgr.collect(&task_id)).await;
1978
1979        assert!(result.is_ok(), "collect timed out after 5s");
1980        let inner = result.unwrap();
1981        assert!(inner.is_ok(), "collect returned error: {inner:?}");
1982    }
1983
1984    #[test]
1985    fn concurrency_slot_freed_after_cancel() {
1986        let rt = tokio::runtime::Runtime::new().unwrap();
1987        let _guard = rt.enter();
1988        let mut mgr = SubAgentManager::new(1); // limit to 1
1989        mgr.definitions.push(sample_def());
1990
1991        let id1 = do_spawn(&mut mgr, "bot", "task 1").unwrap();
1992
1993        // Concurrency limit reached — second spawn should fail.
1994        let err = do_spawn(&mut mgr, "bot", "task 2").unwrap_err();
1995        assert!(
1996            matches!(err, SubAgentError::Spawn(ref msg) if msg.contains("concurrency limit")),
1997            "expected concurrency limit error, got: {err}"
1998        );
1999
2000        // Cancel the first agent to free the slot.
2001        mgr.cancel(&id1).unwrap();
2002
2003        // Now a new spawn should succeed.
2004        let result = do_spawn(&mut mgr, "bot", "task 3");
2005        assert!(
2006            result.is_ok(),
2007            "expected spawn to succeed after cancel, got: {result:?}"
2008        );
2009    }
2010
2011    #[tokio::test]
2012    async fn skill_bodies_prepended_to_system_prompt() {
2013        // Verify that when skills are passed to spawn(), the agent loop prepends
2014        // them to the system prompt inside a ```skills fence.
2015        use zeph_llm::mock::MockProvider;
2016
2017        let (mock, recorded) = MockProvider::default().with_recording();
2018        let provider = AnyProvider::Mock(mock);
2019
2020        let mut mgr = make_manager();
2021        mgr.definitions.push(sample_def());
2022
2023        let skill_bodies = vec!["# skill-one\nDo something useful.".to_owned()];
2024        let task_id = mgr
2025            .spawn(
2026                "bot",
2027                "task",
2028                provider,
2029                noop_executor(),
2030                Some(skill_bodies),
2031                &SubAgentConfig::default(),
2032            )
2033            .unwrap();
2034
2035        // Wait for the loop to call the provider at least once.
2036        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2037
2038        let calls = recorded.lock().unwrap();
2039        assert!(!calls.is_empty(), "provider should have been called");
2040        // The first message in the first call is the system prompt.
2041        let system_msg = &calls[0][0].content;
2042        assert!(
2043            system_msg.contains("```skills"),
2044            "system prompt must contain ```skills fence, got: {system_msg}"
2045        );
2046        assert!(
2047            system_msg.contains("skill-one"),
2048            "system prompt must contain the skill body, got: {system_msg}"
2049        );
2050        drop(calls);
2051
2052        let _ = mgr.collect(&task_id).await;
2053    }
2054
2055    #[tokio::test]
2056    async fn no_skills_does_not_add_fence_to_system_prompt() {
2057        use zeph_llm::mock::MockProvider;
2058
2059        let (mock, recorded) = MockProvider::default().with_recording();
2060        let provider = AnyProvider::Mock(mock);
2061
2062        let mut mgr = make_manager();
2063        mgr.definitions.push(sample_def());
2064
2065        let task_id = mgr
2066            .spawn(
2067                "bot",
2068                "task",
2069                provider,
2070                noop_executor(),
2071                None,
2072                &SubAgentConfig::default(),
2073            )
2074            .unwrap();
2075
2076        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2077
2078        let calls = recorded.lock().unwrap();
2079        assert!(!calls.is_empty());
2080        let system_msg = &calls[0][0].content;
2081        assert!(
2082            !system_msg.contains("```skills"),
2083            "system prompt must not contain skills fence when no skills passed"
2084        );
2085        drop(calls);
2086
2087        let _ = mgr.collect(&task_id).await;
2088    }
2089
2090    #[tokio::test]
2091    async fn statuses_does_not_include_collected_task() {
2092        let mut mgr = make_manager();
2093        mgr.definitions.push(sample_def());
2094
2095        let task_id = do_spawn(&mut mgr, "bot", "task").unwrap();
2096        assert_eq!(mgr.statuses().len(), 1);
2097
2098        // Wait for task completion then collect.
2099        tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
2100        let _ = mgr.collect(&task_id).await;
2101
2102        // After collect(), the task should no longer appear in statuses.
2103        assert!(
2104            mgr.statuses().is_empty(),
2105            "expected empty statuses after collect"
2106        );
2107    }
2108
2109    #[tokio::test]
2110    async fn background_agent_auto_denies_secret_request() {
2111        use zeph_llm::mock::MockProvider;
2112
2113        // Background agent that requests a secret — the loop must auto-deny without blocking.
2114        let def = SubAgentDef::parse(indoc! {"
2115            ---
2116            name: bg-bot
2117            description: Background bot
2118            permissions:
2119              background: true
2120              secrets:
2121                - api-key
2122            ---
2123
2124            [REQUEST_SECRET: api-key]
2125        "})
2126        .unwrap();
2127
2128        let (mock, recorded) = MockProvider::default().with_recording();
2129        let provider = AnyProvider::Mock(mock);
2130
2131        let mut mgr = make_manager();
2132        mgr.definitions.push(def);
2133
2134        let task_id = mgr
2135            .spawn(
2136                "bg-bot",
2137                "task",
2138                provider,
2139                noop_executor(),
2140                None,
2141                &SubAgentConfig::default(),
2142            )
2143            .unwrap();
2144
2145        // Should complete without blocking — background auto-denies the secret.
2146        let result =
2147            tokio::time::timeout(tokio::time::Duration::from_secs(2), mgr.collect(&task_id)).await;
2148        assert!(
2149            result.is_ok(),
2150            "background agent must not block on secret request"
2151        );
2152        drop(recorded);
2153    }
2154
2155    #[test]
2156    fn spawn_with_plan_mode_definition_succeeds() {
2157        let rt = tokio::runtime::Runtime::new().unwrap();
2158        let _guard = rt.enter();
2159
2160        let def = SubAgentDef::parse(indoc! {"
2161            ---
2162            name: planner
2163            description: A planner bot
2164            permissions:
2165              permission_mode: plan
2166            ---
2167
2168            Plan only.
2169        "})
2170        .unwrap();
2171
2172        let mut mgr = make_manager();
2173        mgr.definitions.push(def);
2174
2175        let task_id = do_spawn(&mut mgr, "planner", "make a plan").unwrap();
2176        assert!(!task_id.is_empty());
2177        mgr.cancel(&task_id).unwrap();
2178    }
2179
2180    #[test]
2181    fn spawn_with_disallowed_tools_definition_succeeds() {
2182        let rt = tokio::runtime::Runtime::new().unwrap();
2183        let _guard = rt.enter();
2184
2185        let def = SubAgentDef::parse(indoc! {"
2186            ---
2187            name: safe-bot
2188            description: Bot with disallowed tools
2189            tools:
2190              allow:
2191                - shell
2192                - web
2193              except:
2194                - shell
2195            ---
2196
2197            Do safe things.
2198        "})
2199        .unwrap();
2200
2201        assert_eq!(def.disallowed_tools, ["shell"]);
2202
2203        let mut mgr = make_manager();
2204        mgr.definitions.push(def);
2205
2206        let task_id = do_spawn(&mut mgr, "safe-bot", "task").unwrap();
2207        assert!(!task_id.is_empty());
2208        mgr.cancel(&task_id).unwrap();
2209    }
2210
2211    // ── #1180: default_permission_mode / default_disallowed_tools applied at spawn ──
2212
2213    #[test]
2214    fn spawn_applies_default_permission_mode_from_config() {
2215        let rt = tokio::runtime::Runtime::new().unwrap();
2216        let _guard = rt.enter();
2217
2218        // Agent has Default permission mode — config sets Plan as default.
2219        let def =
2220            SubAgentDef::parse("---\nname: bot\ndescription: A bot\n---\n\nDo things.\n").unwrap();
2221        assert_eq!(def.permissions.permission_mode, PermissionMode::Default);
2222
2223        let mut mgr = make_manager();
2224        mgr.definitions.push(def);
2225
2226        let mut cfg = SubAgentConfig::default();
2227        cfg.default_permission_mode = Some(PermissionMode::Plan);
2228
2229        let task_id = mgr
2230            .spawn(
2231                "bot",
2232                "prompt",
2233                mock_provider(vec!["done"]),
2234                noop_executor(),
2235                None,
2236                &cfg,
2237            )
2238            .unwrap();
2239        assert!(!task_id.is_empty());
2240        mgr.cancel(&task_id).unwrap();
2241    }
2242
2243    #[test]
2244    fn spawn_does_not_override_explicit_permission_mode() {
2245        let rt = tokio::runtime::Runtime::new().unwrap();
2246        let _guard = rt.enter();
2247
2248        // Agent explicitly sets DontAsk — config default must not override it.
2249        let def = SubAgentDef::parse(indoc! {"
2250            ---
2251            name: bot
2252            description: A bot
2253            permissions:
2254              permission_mode: dont_ask
2255            ---
2256
2257            Do things.
2258        "})
2259        .unwrap();
2260        assert_eq!(def.permissions.permission_mode, PermissionMode::DontAsk);
2261
2262        let mut mgr = make_manager();
2263        mgr.definitions.push(def);
2264
2265        let mut cfg = SubAgentConfig::default();
2266        cfg.default_permission_mode = Some(PermissionMode::Plan);
2267
2268        let task_id = mgr
2269            .spawn(
2270                "bot",
2271                "prompt",
2272                mock_provider(vec!["done"]),
2273                noop_executor(),
2274                None,
2275                &cfg,
2276            )
2277            .unwrap();
2278        assert!(!task_id.is_empty());
2279        mgr.cancel(&task_id).unwrap();
2280    }
2281
2282    #[test]
2283    fn spawn_merges_global_disallowed_tools() {
2284        let rt = tokio::runtime::Runtime::new().unwrap();
2285        let _guard = rt.enter();
2286
2287        let def =
2288            SubAgentDef::parse("---\nname: bot\ndescription: A bot\n---\n\nDo things.\n").unwrap();
2289
2290        let mut mgr = make_manager();
2291        mgr.definitions.push(def);
2292
2293        let mut cfg = SubAgentConfig::default();
2294        cfg.default_disallowed_tools = vec!["dangerous".into()];
2295
2296        let task_id = mgr
2297            .spawn(
2298                "bot",
2299                "prompt",
2300                mock_provider(vec!["done"]),
2301                noop_executor(),
2302                None,
2303                &cfg,
2304            )
2305            .unwrap();
2306        assert!(!task_id.is_empty());
2307        mgr.cancel(&task_id).unwrap();
2308    }
2309
2310    // ── #1182: bypass_permissions blocked without config gate ─────────────
2311
2312    #[test]
2313    fn spawn_bypass_permissions_without_config_gate_is_error() {
2314        let rt = tokio::runtime::Runtime::new().unwrap();
2315        let _guard = rt.enter();
2316
2317        let def = SubAgentDef::parse(indoc! {"
2318            ---
2319            name: bypass-bot
2320            description: A bot with bypass mode
2321            permissions:
2322              permission_mode: bypass_permissions
2323            ---
2324
2325            Unrestricted.
2326        "})
2327        .unwrap();
2328
2329        let mut mgr = make_manager();
2330        mgr.definitions.push(def);
2331
2332        // Default config: allow_bypass_permissions = false
2333        let cfg = SubAgentConfig::default();
2334        let err = mgr
2335            .spawn(
2336                "bypass-bot",
2337                "prompt",
2338                mock_provider(vec!["done"]),
2339                noop_executor(),
2340                None,
2341                &cfg,
2342            )
2343            .unwrap_err();
2344        assert!(matches!(err, SubAgentError::Invalid(_)));
2345    }
2346
2347    #[test]
2348    fn spawn_bypass_permissions_with_config_gate_succeeds() {
2349        let rt = tokio::runtime::Runtime::new().unwrap();
2350        let _guard = rt.enter();
2351
2352        let def = SubAgentDef::parse(indoc! {"
2353            ---
2354            name: bypass-bot
2355            description: A bot with bypass mode
2356            permissions:
2357              permission_mode: bypass_permissions
2358            ---
2359
2360            Unrestricted.
2361        "})
2362        .unwrap();
2363
2364        let mut mgr = make_manager();
2365        mgr.definitions.push(def);
2366
2367        let mut cfg = SubAgentConfig::default();
2368        cfg.allow_bypass_permissions = true;
2369
2370        let task_id = mgr
2371            .spawn(
2372                "bypass-bot",
2373                "prompt",
2374                mock_provider(vec!["done"]),
2375                noop_executor(),
2376                None,
2377                &cfg,
2378            )
2379            .unwrap();
2380        assert!(!task_id.is_empty());
2381        mgr.cancel(&task_id).unwrap();
2382    }
2383
2384    // ── resume() tests ────────────────────────────────────────────────────────
2385
2386    /// Write a minimal completed meta file and empty JSONL so resume() has something to load.
2387    fn write_completed_meta(dir: &std::path::Path, agent_id: &str, def_name: &str) {
2388        use crate::subagent::transcript::{TranscriptMeta, TranscriptWriter};
2389        let meta = TranscriptMeta {
2390            agent_id: agent_id.to_owned(),
2391            agent_name: def_name.to_owned(),
2392            def_name: def_name.to_owned(),
2393            status: SubAgentState::Completed,
2394            started_at: "2026-01-01T00:00:00Z".to_owned(),
2395            finished_at: Some("2026-01-01T00:01:00Z".to_owned()),
2396            resumed_from: None,
2397            turns_used: 1,
2398        };
2399        TranscriptWriter::write_meta(dir, agent_id, &meta).unwrap();
2400        // Create the empty JSONL so TranscriptReader::load succeeds.
2401        std::fs::write(dir.join(format!("{agent_id}.jsonl")), b"").unwrap();
2402    }
2403
2404    fn make_cfg_with_dir(dir: &std::path::Path) -> SubAgentConfig {
2405        let mut cfg = SubAgentConfig::default();
2406        cfg.transcript_dir = Some(dir.to_path_buf());
2407        cfg
2408    }
2409
2410    #[test]
2411    fn resume_not_found_returns_not_found_error() {
2412        let rt = tokio::runtime::Runtime::new().unwrap();
2413        let _guard = rt.enter();
2414
2415        let tmp = tempfile::tempdir().unwrap();
2416        let mut mgr = make_manager();
2417        mgr.definitions.push(sample_def());
2418        let cfg = make_cfg_with_dir(tmp.path());
2419
2420        let err = mgr
2421            .resume(
2422                "deadbeef",
2423                "continue",
2424                mock_provider(vec!["done"]),
2425                noop_executor(),
2426                None,
2427                &cfg,
2428            )
2429            .unwrap_err();
2430        assert!(matches!(err, SubAgentError::NotFound(_)));
2431    }
2432
2433    #[test]
2434    fn resume_ambiguous_id_returns_ambiguous_error() {
2435        let rt = tokio::runtime::Runtime::new().unwrap();
2436        let _guard = rt.enter();
2437
2438        let tmp = tempfile::tempdir().unwrap();
2439        write_completed_meta(tmp.path(), "aabb0001-0000-0000-0000-000000000000", "bot");
2440        write_completed_meta(tmp.path(), "aabb0002-0000-0000-0000-000000000000", "bot");
2441
2442        let mut mgr = make_manager();
2443        mgr.definitions.push(sample_def());
2444        let cfg = make_cfg_with_dir(tmp.path());
2445
2446        let err = mgr
2447            .resume(
2448                "aabb",
2449                "continue",
2450                mock_provider(vec!["done"]),
2451                noop_executor(),
2452                None,
2453                &cfg,
2454            )
2455            .unwrap_err();
2456        assert!(matches!(err, SubAgentError::AmbiguousId(_, 2)));
2457    }
2458
2459    #[test]
2460    fn resume_still_running_via_active_agents_returns_error() {
2461        let rt = tokio::runtime::Runtime::new().unwrap();
2462        let _guard = rt.enter();
2463
2464        let tmp = tempfile::tempdir().unwrap();
2465        let agent_id = "cafebabe-0000-0000-0000-000000000000";
2466        write_completed_meta(tmp.path(), agent_id, "bot");
2467
2468        let mut mgr = make_manager();
2469        mgr.definitions.push(sample_def());
2470
2471        // Manually insert a fake active handle so resume() thinks it's still running.
2472        let (status_tx, status_rx) = watch::channel(SubAgentStatus {
2473            state: SubAgentState::Working,
2474            last_message: None,
2475            turns_used: 0,
2476            started_at: std::time::Instant::now(),
2477        });
2478        let (_secret_request_tx, pending_secret_rx) = tokio::sync::mpsc::channel(1);
2479        let (secret_tx, _secret_rx) = tokio::sync::mpsc::channel(1);
2480        let cancel = CancellationToken::new();
2481        let fake_def = sample_def();
2482        mgr.agents.insert(
2483            agent_id.to_owned(),
2484            SubAgentHandle {
2485                id: agent_id.to_owned(),
2486                def: fake_def,
2487                task_id: agent_id.to_owned(),
2488                state: SubAgentState::Working,
2489                join_handle: None,
2490                cancel,
2491                status_rx,
2492                grants: PermissionGrants::default(),
2493                pending_secret_rx,
2494                secret_tx,
2495                started_at_str: "2026-01-01T00:00:00Z".to_owned(),
2496                transcript_dir: None,
2497            },
2498        );
2499        drop(status_tx);
2500
2501        let cfg = make_cfg_with_dir(tmp.path());
2502        let err = mgr
2503            .resume(
2504                agent_id,
2505                "continue",
2506                mock_provider(vec!["done"]),
2507                noop_executor(),
2508                None,
2509                &cfg,
2510            )
2511            .unwrap_err();
2512        assert!(matches!(err, SubAgentError::StillRunning(_)));
2513    }
2514
2515    #[test]
2516    fn resume_def_not_found_returns_not_found_error() {
2517        let rt = tokio::runtime::Runtime::new().unwrap();
2518        let _guard = rt.enter();
2519
2520        let tmp = tempfile::tempdir().unwrap();
2521        let agent_id = "feedface-0000-0000-0000-000000000000";
2522        // Meta points to "unknown-agent" which is not in definitions.
2523        write_completed_meta(tmp.path(), agent_id, "unknown-agent");
2524
2525        let mut mgr = make_manager();
2526        // Do NOT push any definition — so def_name "unknown-agent" won't be found.
2527        let cfg = make_cfg_with_dir(tmp.path());
2528
2529        let err = mgr
2530            .resume(
2531                "feedface",
2532                "continue",
2533                mock_provider(vec!["done"]),
2534                noop_executor(),
2535                None,
2536                &cfg,
2537            )
2538            .unwrap_err();
2539        assert!(matches!(err, SubAgentError::NotFound(_)));
2540    }
2541
2542    #[test]
2543    fn resume_concurrency_limit_reached_returns_error() {
2544        let rt = tokio::runtime::Runtime::new().unwrap();
2545        let _guard = rt.enter();
2546
2547        let tmp = tempfile::tempdir().unwrap();
2548        let agent_id = "babe0000-0000-0000-0000-000000000000";
2549        write_completed_meta(tmp.path(), agent_id, "bot");
2550
2551        let mut mgr = SubAgentManager::new(1); // limit of 1
2552        mgr.definitions.push(sample_def());
2553
2554        // Occupy the single slot.
2555        let _running_id = do_spawn(&mut mgr, "bot", "occupying slot").unwrap();
2556
2557        let cfg = make_cfg_with_dir(tmp.path());
2558        let err = mgr
2559            .resume(
2560                "babe0000",
2561                "continue",
2562                mock_provider(vec!["done"]),
2563                noop_executor(),
2564                None,
2565                &cfg,
2566            )
2567            .unwrap_err();
2568        assert!(
2569            matches!(err, SubAgentError::Spawn(ref msg) if msg.contains("concurrency limit")),
2570            "expected concurrency limit error, got: {err}"
2571        );
2572    }
2573
2574    #[test]
2575    fn resume_happy_path_returns_new_task_id() {
2576        let rt = tokio::runtime::Runtime::new().unwrap();
2577        let _guard = rt.enter();
2578
2579        let tmp = tempfile::tempdir().unwrap();
2580        let agent_id = "deadcode-0000-0000-0000-000000000000";
2581        write_completed_meta(tmp.path(), agent_id, "bot");
2582
2583        let mut mgr = make_manager();
2584        mgr.definitions.push(sample_def());
2585        let cfg = make_cfg_with_dir(tmp.path());
2586
2587        let (new_id, def_name) = mgr
2588            .resume(
2589                "deadcode",
2590                "continue the work",
2591                mock_provider(vec!["done"]),
2592                noop_executor(),
2593                None,
2594                &cfg,
2595            )
2596            .unwrap();
2597
2598        assert!(!new_id.is_empty(), "new task id must not be empty");
2599        assert_ne!(
2600            new_id, agent_id,
2601            "resumed session must have a fresh task id"
2602        );
2603        assert_eq!(def_name, "bot");
2604        // New agent must be tracked.
2605        assert!(mgr.agents.contains_key(&new_id));
2606
2607        mgr.cancel(&new_id).unwrap();
2608    }
2609
2610    #[test]
2611    fn resume_populates_resumed_from_in_meta() {
2612        let rt = tokio::runtime::Runtime::new().unwrap();
2613        let _guard = rt.enter();
2614
2615        let tmp = tempfile::tempdir().unwrap();
2616        let original_id = "0000abcd-0000-0000-0000-000000000000";
2617        write_completed_meta(tmp.path(), original_id, "bot");
2618
2619        let mut mgr = make_manager();
2620        mgr.definitions.push(sample_def());
2621        let cfg = make_cfg_with_dir(tmp.path());
2622
2623        let (new_id, _) = mgr
2624            .resume(
2625                "0000abcd",
2626                "continue",
2627                mock_provider(vec!["done"]),
2628                noop_executor(),
2629                None,
2630                &cfg,
2631            )
2632            .unwrap();
2633
2634        // The new meta sidecar must have resumed_from = original_id.
2635        let new_meta =
2636            crate::subagent::transcript::TranscriptReader::load_meta(tmp.path(), &new_id).unwrap();
2637        assert_eq!(
2638            new_meta.resumed_from.as_deref(),
2639            Some(original_id),
2640            "resumed_from must point to original agent id"
2641        );
2642
2643        mgr.cancel(&new_id).unwrap();
2644    }
2645
2646    #[test]
2647    fn def_name_for_resume_returns_def_name() {
2648        let rt = tokio::runtime::Runtime::new().unwrap();
2649        let _guard = rt.enter();
2650
2651        let tmp = tempfile::tempdir().unwrap();
2652        let agent_id = "aaaabbbb-0000-0000-0000-000000000000";
2653        write_completed_meta(tmp.path(), agent_id, "bot");
2654
2655        let mgr = make_manager();
2656        let cfg = make_cfg_with_dir(tmp.path());
2657
2658        let name = mgr.def_name_for_resume("aaaabbbb", &cfg).unwrap();
2659        assert_eq!(name, "bot");
2660    }
2661
2662    #[test]
2663    fn def_name_for_resume_not_found_returns_error() {
2664        let rt = tokio::runtime::Runtime::new().unwrap();
2665        let _guard = rt.enter();
2666
2667        let tmp = tempfile::tempdir().unwrap();
2668        let mgr = make_manager();
2669        let cfg = make_cfg_with_dir(tmp.path());
2670
2671        let err = mgr.def_name_for_resume("notexist", &cfg).unwrap_err();
2672        assert!(matches!(err, SubAgentError::NotFound(_)));
2673    }
2674
2675    // ── Memory scope tests ────────────────────────────────────────────────────
2676
2677    #[tokio::test]
2678    #[serial]
2679    async fn spawn_with_memory_scope_project_creates_directory() {
2680        let tmp = tempfile::tempdir().unwrap();
2681        let orig_dir = std::env::current_dir().unwrap();
2682        std::env::set_current_dir(tmp.path()).unwrap();
2683
2684        let def = SubAgentDef::parse(indoc! {"
2685            ---
2686            name: mem-agent
2687            description: Agent with memory
2688            memory: project
2689            ---
2690
2691            System prompt.
2692        "})
2693        .unwrap();
2694
2695        let mut mgr = make_manager();
2696        mgr.definitions.push(def);
2697
2698        let task_id = mgr
2699            .spawn(
2700                "mem-agent",
2701                "do something",
2702                mock_provider(vec!["done"]),
2703                noop_executor(),
2704                None,
2705                &SubAgentConfig::default(),
2706            )
2707            .unwrap();
2708        assert!(!task_id.is_empty());
2709        mgr.cancel(&task_id).unwrap();
2710
2711        // Verify memory directory was created.
2712        let mem_dir = tmp
2713            .path()
2714            .join(".zeph")
2715            .join("agent-memory")
2716            .join("mem-agent");
2717        assert!(
2718            mem_dir.exists(),
2719            "memory directory should be created at spawn"
2720        );
2721
2722        std::env::set_current_dir(orig_dir).unwrap();
2723    }
2724
2725    #[tokio::test]
2726    #[serial]
2727    async fn spawn_with_config_default_memory_scope_applies_when_def_has_none() {
2728        let tmp = tempfile::tempdir().unwrap();
2729        let orig_dir = std::env::current_dir().unwrap();
2730        std::env::set_current_dir(tmp.path()).unwrap();
2731
2732        let def = SubAgentDef::parse(indoc! {"
2733            ---
2734            name: mem-agent2
2735            description: Agent without explicit memory
2736            ---
2737
2738            System prompt.
2739        "})
2740        .unwrap();
2741
2742        let mut mgr = make_manager();
2743        mgr.definitions.push(def);
2744
2745        let mut cfg = SubAgentConfig::default();
2746        cfg.default_memory_scope = Some(MemoryScope::Project);
2747
2748        let task_id = mgr
2749            .spawn(
2750                "mem-agent2",
2751                "do something",
2752                mock_provider(vec!["done"]),
2753                noop_executor(),
2754                None,
2755                &cfg,
2756            )
2757            .unwrap();
2758        assert!(!task_id.is_empty());
2759        mgr.cancel(&task_id).unwrap();
2760
2761        // Verify memory directory was created via config default.
2762        let mem_dir = tmp
2763            .path()
2764            .join(".zeph")
2765            .join("agent-memory")
2766            .join("mem-agent2");
2767        assert!(
2768            mem_dir.exists(),
2769            "config default memory scope should create directory"
2770        );
2771
2772        std::env::set_current_dir(orig_dir).unwrap();
2773    }
2774
2775    #[tokio::test]
2776    #[serial]
2777    async fn spawn_with_memory_blocked_by_disallowed_tools_skips_memory() {
2778        let tmp = tempfile::tempdir().unwrap();
2779        let orig_dir = std::env::current_dir().unwrap();
2780        std::env::set_current_dir(tmp.path()).unwrap();
2781
2782        let def = SubAgentDef::parse(indoc! {"
2783            ---
2784            name: blocked-mem
2785            description: Agent with memory but blocked tools
2786            memory: project
2787            tools:
2788              except:
2789                - Read
2790                - Write
2791                - Edit
2792            ---
2793
2794            System prompt.
2795        "})
2796        .unwrap();
2797
2798        let mut mgr = make_manager();
2799        mgr.definitions.push(def);
2800
2801        let task_id = mgr
2802            .spawn(
2803                "blocked-mem",
2804                "do something",
2805                mock_provider(vec!["done"]),
2806                noop_executor(),
2807                None,
2808                &SubAgentConfig::default(),
2809            )
2810            .unwrap();
2811        assert!(!task_id.is_empty());
2812        mgr.cancel(&task_id).unwrap();
2813
2814        // Memory dir should NOT be created because tools are blocked (HIGH-04).
2815        let mem_dir = tmp
2816            .path()
2817            .join(".zeph")
2818            .join("agent-memory")
2819            .join("blocked-mem");
2820        assert!(
2821            !mem_dir.exists(),
2822            "memory directory should not be created when tools are blocked"
2823        );
2824
2825        std::env::set_current_dir(orig_dir).unwrap();
2826    }
2827
2828    #[tokio::test]
2829    #[serial]
2830    async fn spawn_without_memory_scope_no_directory_created() {
2831        let tmp = tempfile::tempdir().unwrap();
2832        let orig_dir = std::env::current_dir().unwrap();
2833        std::env::set_current_dir(tmp.path()).unwrap();
2834
2835        let def = SubAgentDef::parse(indoc! {"
2836            ---
2837            name: no-mem-agent
2838            description: Agent without memory
2839            ---
2840
2841            System prompt.
2842        "})
2843        .unwrap();
2844
2845        let mut mgr = make_manager();
2846        mgr.definitions.push(def);
2847
2848        let task_id = mgr
2849            .spawn(
2850                "no-mem-agent",
2851                "do something",
2852                mock_provider(vec!["done"]),
2853                noop_executor(),
2854                None,
2855                &SubAgentConfig::default(),
2856            )
2857            .unwrap();
2858        assert!(!task_id.is_empty());
2859        mgr.cancel(&task_id).unwrap();
2860
2861        // No agent-memory directory should exist (transcript dirs may be created separately).
2862        let mem_dir = tmp.path().join(".zeph").join("agent-memory");
2863        assert!(
2864            !mem_dir.exists(),
2865            "no agent-memory directory should be created without memory scope"
2866        );
2867
2868        std::env::set_current_dir(orig_dir).unwrap();
2869    }
2870
2871    #[test]
2872    #[serial]
2873    fn build_prompt_injects_memory_block_after_behavioral_prompt() {
2874        let tmp = tempfile::tempdir().unwrap();
2875        let orig_dir = std::env::current_dir().unwrap();
2876        std::env::set_current_dir(tmp.path()).unwrap();
2877
2878        // Create memory directory and MEMORY.md.
2879        let mem_dir = tmp
2880            .path()
2881            .join(".zeph")
2882            .join("agent-memory")
2883            .join("test-agent");
2884        std::fs::create_dir_all(&mem_dir).unwrap();
2885        std::fs::write(mem_dir.join("MEMORY.md"), "# Test Memory\nkey: value\n").unwrap();
2886
2887        let mut def = SubAgentDef::parse(indoc! {"
2888            ---
2889            name: test-agent
2890            description: Test agent
2891            memory: project
2892            ---
2893
2894            Behavioral instructions here.
2895        "})
2896        .unwrap();
2897
2898        let prompt = build_system_prompt_with_memory(&mut def, Some(MemoryScope::Project));
2899
2900        // Memory block must appear AFTER behavioral prompt text.
2901        let behavioral_pos = prompt.find("Behavioral instructions").unwrap();
2902        let memory_pos = prompt.find("<agent-memory>").unwrap();
2903        assert!(
2904            memory_pos > behavioral_pos,
2905            "memory block must appear AFTER behavioral prompt"
2906        );
2907        assert!(
2908            prompt.contains("key: value"),
2909            "MEMORY.md content must be injected"
2910        );
2911
2912        std::env::set_current_dir(orig_dir).unwrap();
2913    }
2914
2915    #[test]
2916    #[serial]
2917    fn build_prompt_auto_enables_read_write_edit_for_allowlist() {
2918        let tmp = tempfile::tempdir().unwrap();
2919        let orig_dir = std::env::current_dir().unwrap();
2920        std::env::set_current_dir(tmp.path()).unwrap();
2921
2922        let mut def = SubAgentDef::parse(indoc! {"
2923            ---
2924            name: allowlist-agent
2925            description: AllowList agent
2926            memory: project
2927            tools:
2928              allow:
2929                - shell
2930            ---
2931
2932            System prompt.
2933        "})
2934        .unwrap();
2935
2936        assert!(
2937            matches!(&def.tools, ToolPolicy::AllowList(list) if list == &["shell"]),
2938            "should start with only shell"
2939        );
2940
2941        build_system_prompt_with_memory(&mut def, Some(MemoryScope::Project));
2942
2943        // Read/Write/Edit must be auto-added to the AllowList.
2944        assert!(
2945            matches!(&def.tools, ToolPolicy::AllowList(list)
2946                if list.contains(&"Read".to_owned())
2947                    && list.contains(&"Write".to_owned())
2948                    && list.contains(&"Edit".to_owned())),
2949            "Read/Write/Edit must be auto-enabled in AllowList when memory is set"
2950        );
2951
2952        std::env::set_current_dir(orig_dir).unwrap();
2953    }
2954
2955    #[tokio::test]
2956    #[serial]
2957    async fn spawn_with_explicit_def_memory_overrides_config_default() {
2958        let tmp = tempfile::tempdir().unwrap();
2959        let orig_dir = std::env::current_dir().unwrap();
2960        std::env::set_current_dir(tmp.path()).unwrap();
2961
2962        // Agent explicitly sets memory: local, config sets default: project.
2963        // The explicit local should win.
2964        let def = SubAgentDef::parse(indoc! {"
2965            ---
2966            name: override-agent
2967            description: Agent with explicit memory
2968            memory: local
2969            ---
2970
2971            System prompt.
2972        "})
2973        .unwrap();
2974        assert_eq!(def.memory, Some(MemoryScope::Local));
2975
2976        let mut mgr = make_manager();
2977        mgr.definitions.push(def);
2978
2979        let mut cfg = SubAgentConfig::default();
2980        cfg.default_memory_scope = Some(MemoryScope::Project);
2981
2982        let task_id = mgr
2983            .spawn(
2984                "override-agent",
2985                "do something",
2986                mock_provider(vec!["done"]),
2987                noop_executor(),
2988                None,
2989                &cfg,
2990            )
2991            .unwrap();
2992        assert!(!task_id.is_empty());
2993        mgr.cancel(&task_id).unwrap();
2994
2995        // Local scope directory should be created, not project scope.
2996        let local_dir = tmp
2997            .path()
2998            .join(".zeph")
2999            .join("agent-memory-local")
3000            .join("override-agent");
3001        let project_dir = tmp
3002            .path()
3003            .join(".zeph")
3004            .join("agent-memory")
3005            .join("override-agent");
3006        assert!(local_dir.exists(), "local memory dir should be created");
3007        assert!(
3008            !project_dir.exists(),
3009            "project memory dir must NOT be created"
3010        );
3011
3012        std::env::set_current_dir(orig_dir).unwrap();
3013    }
3014
3015    #[tokio::test]
3016    #[serial]
3017    async fn spawn_memory_blocked_by_deny_list_policy() {
3018        let tmp = tempfile::tempdir().unwrap();
3019        let orig_dir = std::env::current_dir().unwrap();
3020        std::env::set_current_dir(tmp.path()).unwrap();
3021
3022        // tools.deny: [Read, Write, Edit] — DenyList policy blocking all file tools.
3023        let def = SubAgentDef::parse(indoc! {"
3024            ---
3025            name: deny-list-mem
3026            description: Agent with deny list
3027            memory: project
3028            tools:
3029              deny:
3030                - Read
3031                - Write
3032                - Edit
3033            ---
3034
3035            System prompt.
3036        "})
3037        .unwrap();
3038
3039        let mut mgr = make_manager();
3040        mgr.definitions.push(def);
3041
3042        let task_id = mgr
3043            .spawn(
3044                "deny-list-mem",
3045                "do something",
3046                mock_provider(vec!["done"]),
3047                noop_executor(),
3048                None,
3049                &SubAgentConfig::default(),
3050            )
3051            .unwrap();
3052        assert!(!task_id.is_empty());
3053        mgr.cancel(&task_id).unwrap();
3054
3055        // Memory dir should NOT be created because DenyList blocks file tools (REV-HIGH-02).
3056        let mem_dir = tmp
3057            .path()
3058            .join(".zeph")
3059            .join("agent-memory")
3060            .join("deny-list-mem");
3061        assert!(
3062            !mem_dir.exists(),
3063            "memory dir must not be created when DenyList blocks all file tools"
3064        );
3065
3066        std::env::set_current_dir(orig_dir).unwrap();
3067    }
3068}