Skip to main content

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