Skip to main content

agent_works/multi_agent/
runtime.rs

1//! Multi-agent runtime — coordinates sub-agent lifecycle, event bridging, and
2//! cancellation.
3//!
4//! The [`MultiAgentRuntime`] is the central coordinator. It is created once during
5//! builder setup and shared via `Arc` to all 6 multi-agent tools.
6
7use std::collections::HashMap;
8use std::sync::{Arc, Mutex};
9
10use agent_base::{
11    AgentBuilder, AgentResult, AgentRuntime, AllowAllApprovalHandler, ApprovalHandler,
12    DenyAllApprovalHandler, DenyAllToolPolicy, Language, ReasoningEffort, RunOutcome, RuntimeEvent,
13    SessionId, StreamClient, Tool, ToolPolicy,
14};
15use tokio::task::JoinSet;
16use tokio_util::sync::CancellationToken;
17
18use super::config::{ChildPermissionMode, MultiAgentConfig};
19use super::mailbox::{ChildMailbox, MailboxHub, MailboxResult, MailboxStatus, MailboxTask};
20use super::path::AgentPath;
21use super::registry::{AgentRegistry, AgentStatus};
22
23// ---------------------------------------------------------------------------
24// MultiAgentRuntime
25// ---------------------------------------------------------------------------
26
27/// Coordinates sub-agent lifecycle, event bridging, and cancellation.
28///
29/// Created once during builder setup and shared via `Arc` to all 6 multi-agent
30/// tools. Each tool calls methods on the runtime to spawn, communicate with, or
31/// close sub-agents.
32pub struct MultiAgentRuntime {
33    /// Agent lifecycle registry (spawn/close/query).
34    registry: Mutex<AgentRegistry>,
35
36    /// Inter-agent message hub.
37    mailbox: Arc<MailboxHub>,
38
39    /// Shared LLM client (from parent agent).
40    client: Arc<dyn StreamClient>,
41
42    /// Business tools to register on child agents (NOT the 6 multi-agent tools).
43    business_tools: Vec<Arc<dyn Tool>>,
44
45    /// Business tool names to exclude from child agents (root-level orchestration
46    /// tools like `decompose`/`merge` that a leaf agent cannot actually use).
47    child_excluded_tools: Vec<String>,
48
49    /// Optional reasoning-effort override applied to every child agent (see
50    /// [`MultiAgentConfig::child_reasoning_effort`]).
51    child_reasoning_effort: Option<ReasoningEffort>,
52
53    /// Whether to nudge children toward read-only behaviour (see
54    /// [`MultiAgentConfig::child_read_only`]).
55    child_read_only: bool,
56
57    /// Channel to the bridge task that emits events on parent's event bus.
58    event_tx: Mutex<Option<tokio::sync::mpsc::UnboundedSender<RuntimeEvent>>>,
59
60    /// Root cancellation token (propagates to all children).
61    root_cancel: CancellationToken,
62
63    /// JoinSet tracking all child agent tasks.
64    join_set: Mutex<JoinSet<()>>,
65
66    /// Per-child cancellation tokens.
67    child_cancels: Mutex<HashMap<AgentPath, CancellationToken>>,
68
69    /// Error recovery strategy (inherited from parent).
70    error_recovery: Option<Arc<dyn agent_base::ToolErrorRecovery>>,
71
72    /// Language preference.
73    language: Language,
74
75    /// Permission mode for spawned child agents (resolved per spawn).
76    child_permission_mode: ChildPermissionMode,
77
78    /// Parent's tool policy, inherited by "no permission" children.
79    tool_policy: Option<Arc<dyn ToolPolicy>>,
80
81    /// Parent's approval handler, delegated to by "no permission" children.
82    ///
83    /// This is the codex-style behaviour (see `build_child_runtime`): a
84    /// restricted sub-agent does not hard-deny its own approval requests — it
85    /// routes the decision up to the parent's handler (human-in-the-loop or
86    /// auto), so `ask`/`deny` semantics stay coherent across the agent tree.
87    approval_handler: Option<Arc<dyn ApprovalHandler>>,
88
89    /// Parent session manager — for fork_history (child context inheritance).
90    session_manager: Mutex<Option<Arc<agent_base::engine::SessionManager>>>,
91}
92
93impl MultiAgentRuntime {
94    /// Create a new multi-agent runtime.
95    ///
96    /// This is called internally by the builder. Tools receive an `Arc<Self>`.
97    #[allow(clippy::too_many_arguments)] // runtime fields are naturally positional
98    pub fn new(
99        config: MultiAgentConfig,
100        client: Arc<dyn StreamClient>,
101        business_tools: Vec<Arc<dyn Tool>>,
102        root_cancel: CancellationToken,
103        error_recovery: Option<Arc<dyn agent_base::ToolErrorRecovery>>,
104        language: Language,
105        tool_policy: Option<Arc<dyn ToolPolicy>>,
106        approval_handler: Option<Arc<dyn ApprovalHandler>>,
107    ) -> Self {
108        let child_permission_mode = config.child_permission_mode;
109        let child_excluded_tools = config.child_excluded_tools.clone();
110        let child_reasoning_effort = config.child_reasoning_effort.clone();
111        let child_read_only = config.child_read_only;
112        Self {
113            registry: Mutex::new(AgentRegistry::new(config)),
114            mailbox: Arc::new(MailboxHub::new()),
115            client,
116            business_tools,
117            child_excluded_tools,
118            child_reasoning_effort,
119            child_read_only,
120            event_tx: Mutex::new(None),
121            root_cancel,
122            join_set: Mutex::new(JoinSet::new()),
123            child_cancels: Mutex::new(HashMap::new()),
124            error_recovery,
125            language,
126            child_permission_mode,
127            tool_policy,
128            approval_handler,
129            session_manager: Mutex::new(None),
130        }
131    }
132
133    /// Set the event sender for bridging child events to parent.
134    ///
135    /// Called by the builder after creating the bridge channel.
136    pub fn set_event_sender(&self, tx: tokio::sync::mpsc::UnboundedSender<RuntimeEvent>) {
137        *self.event_tx.lock().unwrap() = Some(tx);
138    }
139
140    /// Set the parent session manager for fork_history support.
141    ///
142    /// Called by the builder after creating the runtime.
143    pub fn set_session_manager(&self, session_manager: Arc<agent_base::engine::SessionManager>) {
144        *self.session_manager.lock().unwrap() = Some(session_manager);
145    }
146
147    /// Spawn a child agent at the given path with a specific system prompt.
148    ///
149    /// This is called by the `spawn_agent` tool. It:
150    /// 1. Checks spawn limits
151    /// 2. Registers the agent in the registry
152    /// 3. Creates a mailbox
153    /// 4. Builds a child AgentRuntime
154    /// 5. Spawns a tokio task for the child's event loop
155    /// 6. Returns the AgentPath
156    ///
157    /// `parent_messages` optionally provides context from the parent session
158    /// for fork_history support.
159    ///
160    /// # Errors
161    ///
162    /// Returns a string error message if spawning fails (limits exceeded, etc.).
163    pub async fn spawn_child(
164        &self,
165        name: &str,
166        system_prompt: String,
167        depth: i32,
168        tool_count: usize,
169        full_permission: bool,
170        parent_messages: Vec<agent_base::ChatMessage>,
171    ) -> Result<String, String> {
172        let path = AgentPath::root().join(name);
173
174        // 1. Check limits and register
175        {
176            let mut registry = self.registry.lock().unwrap();
177            registry.can_spawn(depth).map_err(|e| e.to_string())?;
178            registry
179                .register(&path, depth, tool_count)
180                .map_err(|e| e.to_string())?;
181        }
182
183        // 2. Create mailbox
184        let child_mailbox = self
185            .mailbox
186            .register(&path)
187            .ok_or_else(|| "mailbox already exists".to_string())?;
188
189        // 3. Build child AgentRuntime (roll back registry+mailbox on failure)
190        let child_runtime = self
191            .build_child_runtime(system_prompt, self.effective_permission(full_permission))
192            .await
193            .map_err(|e| {
194                self.registry.lock().unwrap().close(&path);
195                self.mailbox.unregister(&path);
196                format!("failed to build child runtime: {}", e)
197            })?;
198
199        // 4. Create session for child and pre-fill with parent context
200        let session_id = child_runtime.create_session().await;
201        self.prefill_child_session(&child_runtime, &session_id, &parent_messages)
202            .await
203            .map_err(|e| {
204                self.registry.lock().unwrap().close(&path);
205                self.mailbox.unregister(&path);
206                format!("failed to prefill child session: {}", e)
207            })?;
208
209        // 5. Create child cancellation token
210        let child_cancel = self.root_cancel.child_token();
211        {
212            let mut cancels = self.child_cancels.lock().unwrap();
213            cancels.insert(path.clone(), child_cancel.clone());
214        }
215
216        // 6. Spawn child agent event loop
217        let agent_path = path.clone();
218        let mailbox_for_task = self.mailbox.clone();
219        let mailbox_for_close = self.mailbox.clone();
220        let event_tx = self.event_tx.lock().unwrap().clone();
221        let registry_agent_path = path.clone();
222
223        self.join_set.lock().unwrap().spawn(async move {
224            run_child_loop(
225                child_mailbox,
226                child_runtime,
227                session_id,
228                agent_path.clone(),
229                mailbox_for_task,
230                event_tx,
231                child_cancel,
232            )
233            .await;
234
235            // Post close notification when loop exits
236            mailbox_for_close.post_result(MailboxResult {
237                agent_path,
238                status: MailboxStatus::Closed,
239                result: None,
240                denied_tools: vec![],
241            });
242        });
243
244        self.registry
245            .lock()
246            .unwrap()
247            .set_status(&registry_agent_path, AgentStatus::Idle);
248
249        Ok(path.to_string())
250    }
251
252    /// Spawn a child agent with fork_history support.
253    ///
254    /// `fork_history`: "none" (default), "all", or a number N for last N turns.
255    /// `parent_session_id`: the parent agent's session ID.
256    #[allow(clippy::too_many_arguments)] // spawn config is naturally positional
257    pub async fn spawn_child_with_history(
258        &self,
259        name: &str,
260        system_prompt: String,
261        depth: i32,
262        tool_count: usize,
263        full_permission: bool,
264        fork_history: Option<String>,
265        parent_session_id: &SessionId,
266    ) -> Result<String, String> {
267        let parent_messages = self
268            .resolve_fork_history(fork_history, parent_session_id)
269            .await;
270        self.spawn_child(
271            name,
272            system_prompt,
273            depth,
274            tool_count,
275            full_permission,
276            parent_messages,
277        )
278        .await
279    }
280
281    /// Resolve fork_history parameter into a list of parent ChatMessages.
282    pub(crate) async fn resolve_fork_history(
283        &self,
284        fork_history: Option<String>,
285        parent_session_id: &SessionId,
286    ) -> Vec<agent_base::ChatMessage> {
287        use agent_base::ChatMessage;
288        let mode = match fork_history.as_deref() {
289            None | Some("none") => return vec![],
290            Some(s) => s,
291        };
292
293        let sm = match self.session_manager.lock().unwrap().as_ref() {
294            Some(sm) => sm.clone(),
295            None => {
296                tracing::warn!("fork_history requested but no session_manager set");
297                return vec![];
298            }
299        };
300
301        // Get all messages from parent session
302        let all_messages = match sm.session_or_err(parent_session_id).await {
303            Ok(session) => session.chat_messages().to_vec(),
304            Err(e) => {
305                tracing::warn!(session_id = parent_session_id.id, error = %e, "failed to load parent session for fork_history");
306                return vec![];
307            }
308        };
309
310        if all_messages.is_empty() {
311            return vec![];
312        }
313
314        // Filter out system messages (child has its own system prompt)
315        let non_system: Vec<ChatMessage> = all_messages
316            .into_iter()
317            .filter(|m| !matches!(m, ChatMessage::System { .. }))
318            .collect();
319
320        match mode {
321            "all" => non_system,
322            n_str => {
323                // Parse N: number of recent user/assistant message pairs (turns)
324                let n: usize = match n_str.parse() {
325                    Ok(n) if n > 0 => n,
326                    _ => {
327                        tracing::warn!(
328                            fork_history = n_str,
329                            "invalid fork_history value, treating as 'none'"
330                        );
331                        return vec![];
332                    }
333                };
334
335                // Count turns from the end (each turn = user message followed by response)
336                let mut turns = 0usize;
337                let mut cutoff = non_system.len();
338                for (i, msg) in non_system.iter().enumerate().rev() {
339                    if matches!(msg, ChatMessage::User { .. }) {
340                        turns += 1;
341                        if turns >= n {
342                            cutoff = i;
343                            break;
344                        }
345                    }
346                }
347                non_system[cutoff..].to_vec()
348            }
349        }
350    }
351
352    /// Send a message to a child agent (no execution trigger).
353    ///
354    /// Called by `send_message` tool.
355    pub fn send_message(&self, agent_path: &str, message: String) -> Result<bool, String> {
356        let path = self.parse_path(agent_path)?;
357        Ok(self.mailbox.send_message(&path, message))
358    }
359
360    /// Send a task to a child agent (triggers execution).
361    ///
362    /// Called by `followup_task` tool. Updates status to Running.
363    pub fn send_task(
364        &self,
365        agent_path: &str,
366        task: String,
367        interrupt: bool,
368    ) -> Result<bool, String> {
369        let path = self.parse_path(agent_path)?;
370        if !self.mailbox.contains(&path) {
371            return Err("agent not found".to_string());
372        }
373        let sent = self.mailbox.send_task(&path, task, interrupt);
374        if sent {
375            self.registry
376                .lock()
377                .unwrap()
378                .set_status(&path, AgentStatus::Running);
379        }
380        Ok(sent)
381    }
382
383    /// Wait for a result from any or a specific child agent.
384    ///
385    /// Called by `wait_agent` tool. Blocks until a result arrives or timeout.
386    pub async fn wait_for_result(&self, agent_path: Option<&str>, timeout_ms: u64) -> WaitResult {
387        let filter_path = match agent_path {
388            Some(s) => match AgentPath::parse(s) {
389                Some(p) => Some(p),
390                None => {
391                    return WaitResult {
392                        status: "error".to_string(),
393                        result: Some(format!("invalid agent path: {}", s)),
394                        agent_path: None,
395                        has_more: false,
396                        denied_tools: vec![],
397                    };
398                }
399            },
400            None => None,
401        };
402
403        let mut seq = self.mailbox.subscribe_seq();
404        let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
405
406        loop {
407            // Check for existing results
408            let result = match &filter_path {
409                Some(path) => self.mailbox.try_recv_result(path),
410                None => self.mailbox.try_recv_any(),
411            };
412
413            if let Some(r) = result {
414                let has_more = self.mailbox.total_pending_results() > 0;
415                let (status_str, result_text) = match r.status {
416                    MailboxStatus::Ok => ("ok".to_string(), r.result),
417                    MailboxStatus::Error => ("error".to_string(), r.result),
418                    MailboxStatus::Closed => ("closed".to_string(), r.result),
419                };
420                return WaitResult {
421                    status: status_str,
422                    result: result_text,
423                    agent_path: Some(r.agent_path.to_string()),
424                    has_more,
425                    denied_tools: r.denied_tools,
426                };
427            }
428
429            // Wait for sequence number change or timeout
430            let now = tokio::time::Instant::now();
431            if now >= deadline {
432                return WaitResult {
433                    status: "timeout".to_string(),
434                    result: None,
435                    agent_path: None,
436                    has_more: false,
437                    denied_tools: vec![],
438                };
439            }
440
441            let remaining = deadline - now;
442            tokio::select! {
443                _ = seq.changed() => {
444                    // Sequence changed — loop back to check results
445                    continue;
446                }
447                _ = tokio::time::sleep(remaining) => {
448                    return WaitResult {
449                        status: "timeout".to_string(),
450                        result: None,
451                        agent_path: None,
452                        has_more: false,
453                        denied_tools: vec![],
454                    };
455                }
456            }
457        }
458    }
459
460    /// Close a child agent.
461    ///
462    /// Called by `close_agent` tool. Cancels the child's task, removes from
463    /// registry, and posts a Closed result.
464    pub fn close_agent(&self, agent_path: &str) -> Result<CloseResult, String> {
465        let path = self.parse_path(agent_path)?;
466
467        // Get previous status
468        let previous_status = {
469            let registry = self.registry.lock().unwrap();
470            registry
471                .get(&path)
472                .map(|e| format!("{:?}", e.status).to_lowercase())
473                .unwrap_or_else(|| "unknown".to_string())
474        };
475
476        // Cancel child token
477        {
478            let mut cancels = self.child_cancels.lock().unwrap();
479            if let Some(token) = cancels.remove(&path) {
480                token.cancel();
481            }
482        }
483
484        // Close in registry
485        let existed = { self.registry.lock().unwrap().close(&path).is_some() };
486
487        // Unregister mailbox
488        self.mailbox.unregister(&path);
489
490        Ok(CloseResult {
491            closed: existed,
492            previous_status,
493            message: if existed {
494                "agent closed".to_string()
495            } else {
496                "agent not found".to_string()
497            },
498        })
499    }
500
501    /// List all active sub-agents.
502    ///
503    /// Called by `list_agents` tool.
504    pub fn list_agents(&self) -> Vec<AgentInfo> {
505        let registry = self.registry.lock().unwrap();
506        registry
507            .list()
508            .into_iter()
509            .map(|e| AgentInfo {
510                agent_path: e.path.to_string(),
511                status: format!("{:?}", e.status).to_lowercase(),
512                tool_count: e.tool_count,
513            })
514            .collect()
515    }
516
517    /// Get the mailbox hub (for tools that need it directly).
518    pub fn mailbox(&self) -> &Arc<MailboxHub> {
519        &self.mailbox
520    }
521
522    /// Get reference to the registry.
523    pub fn registry(&self) -> &Mutex<AgentRegistry> {
524        &self.registry
525    }
526
527    /// Cancel all child agents.
528    pub fn cancel_all(&self) {
529        let mut cancels = self.child_cancels.lock().unwrap();
530        for (_, token) in cancels.drain() {
531            token.cancel();
532        }
533    }
534}
535
536impl Drop for MultiAgentRuntime {
537    fn drop(&mut self) {
538        self.cancel_all();
539        // Drain any already-completed join handles to detect panics
540        let mut js = self.join_set.lock().unwrap();
541        while let Some(result) = js.try_join_next() {
542            if let Err(e) = result
543                && e.is_panic()
544            {
545                tracing::error!(
546                    error = %e,
547                    "child agent task panicked"
548                );
549            }
550        }
551    }
552}
553
554impl MultiAgentRuntime {
555    fn parse_path(&self, s: &str) -> Result<AgentPath, String> {
556        AgentPath::parse(s).ok_or_else(|| format!("invalid agent path: '{}'", s))
557    }
558
559    async fn build_child_runtime(
560        &self,
561        system_prompt: String,
562        full_permission: bool,
563    ) -> AgentResult<AgentRuntime> {
564        // Read-only nudge (framework layer). The framework cannot classify which
565        // business tools mutate state, so this is a *suggestion* only — it does
566        // not gate any tool. The business layer hard-gates mutating tools via
567        // `child_excluded_tools` when it needs a guarantee. This is orthogonal to
568        // `full_permission` (approval vs. deny): even a full-permission child is
569        // nudged not to write when `child_read_only` is set.
570        let system_prompt = if self.child_read_only {
571            format!(
572                "{}\n\nYou are a read-only sub-agent: investigate, analyze, and report your findings in your final answer. Do not modify the workspace, mutate state, or run side-effecting commands — the parent agent owns all changes and will apply them based on your report.",
573                system_prompt
574            )
575        } else {
576            system_prompt
577        };
578
579        let (prompt, policy, approval): (
580            String,
581            Option<Arc<dyn ToolPolicy>>,
582            Arc<dyn ApprovalHandler>,
583        ) = if full_permission {
584            // Full: no tool policy → every tool auto-approves (= current behaviour).
585            (system_prompt, None, Arc::new(AllowAllApprovalHandler))
586        } else {
587            // None: policy = parent's (if any) or DenyAllToolPolicy fallback.
588            let note = "If a tool call is rejected for lack of permission, explain in your final answer that you lacked permission for that action.";
589            let policy: Arc<dyn ToolPolicy> = match &self.tool_policy {
590                Some(p) => p.clone(),
591                None => Arc::new(DenyAllToolPolicy),
592            };
593            // Codex-style delegation: rather than hard-denying the child's own
594            // approval requests, route the decision up to the parent's approval
595            // handler (human-in-the-loop / auto). Falls back to DenyAll only when
596            // the parent itself carries no handler — which keeps the "no policy,
597            // no handler → read-only" invariant intact.
598            let approval: Arc<dyn ApprovalHandler> = match &self.approval_handler {
599                Some(h) => h.clone(),
600                None => Arc::new(DenyAllApprovalHandler),
601            };
602            (
603                format!("{}\n\n{}", system_prompt, note),
604                Some(policy),
605                approval,
606            )
607        };
608
609        let mut builder = AgentBuilder::new(self.client.clone())
610            .system_prompt(prompt)
611            .approval_handler(approval)
612            .language(self.language.clone());
613
614        if let Some(p) = policy {
615            builder = builder.tool_policy(p);
616        }
617
618        // Register business tools (NOT multi-agent tools), skipping any
619        // root-level orchestration tools the child should not inherit (e.g.
620        // `decompose`/`merge` — a leaf agent that can't spawn sub-agents would
621        // plan work it cannot execute).
622        for tool in &self.business_tools {
623            if self.child_excluded_tools.contains(&tool.name().to_string()) {
624                tracing::debug!(
625                    tool = tool.name(),
626                    "skipping excluded business tool for child runtime"
627                );
628                continue;
629            }
630            builder = builder.register_tool_arc(tool.clone());
631        }
632
633        if let Some(ref recovery) = self.error_recovery {
634            builder = builder.error_recovery(recovery.clone());
635        }
636
637        let child = builder.build()?;
638        if let Some(effort) = self.child_reasoning_effort.clone() {
639            child.set_reasoning_effort(effort).await;
640        }
641        Ok(child)
642    }
643
644    /// Resolve the effective full-permission flag for a spawn, applying the
645    /// configured [`ChildPermissionMode`]. `Full`/`None` override the LLM-supplied
646    /// flag; `PerSpawn` lets the LLM decide.
647    fn effective_permission(&self, full_permission: bool) -> bool {
648        match self.child_permission_mode {
649            ChildPermissionMode::Full => true,
650            ChildPermissionMode::None => false,
651            ChildPermissionMode::PerSpawn => full_permission,
652        }
653    }
654
655    /// Pre-fill a child session with parent conversation context (fork_history).
656    ///
657    /// Skips system messages and tool-call-only assistant messages. Assistant text
658    /// responses and tool results are stored as system messages with labels so the
659    /// child sees the context without confusing role semantics.
660    pub(crate) async fn prefill_child_session(
661        &self,
662        child_runtime: &AgentRuntime,
663        session_id: &SessionId,
664        parent_messages: &[agent_base::ChatMessage],
665    ) -> AgentResult<()> {
666        use agent_base::ChatMessage;
667
668        for msg in parent_messages {
669            match msg {
670                ChatMessage::User { content, .. } => {
671                    child_runtime.add_user_message(session_id, content).await?;
672                }
673                ChatMessage::Assistant {
674                    content: Some(text),
675                    ..
676                } => {
677                    child_runtime
678                        .add_system_message(
679                            session_id,
680                            format!("[Parent assistant response]: {}", text),
681                        )
682                        .await?;
683                }
684                ChatMessage::Assistant { tool_calls, .. } if tool_calls.is_some() => {
685                    // Skip tool-call-only messages — parent's tool decisions
686                    // don't make sense in the child's context.
687                }
688                ChatMessage::Tool {
689                    tool_call_id,
690                    content,
691                } => {
692                    child_runtime
693                        .add_system_message(
694                            session_id,
695                            format!("[Parent tool result ({}): {}]", tool_call_id, content),
696                        )
697                        .await?;
698                }
699                _ => {} // Skip system messages and empty assistant
700            }
701        }
702
703        Ok(())
704    }
705}
706
707// ---------------------------------------------------------------------------
708// Result types
709// ---------------------------------------------------------------------------
710
711/// Result from `wait_for_result()`.
712#[derive(Clone, Debug)]
713pub struct WaitResult {
714    pub status: String,
715    pub result: Option<String>,
716    pub agent_path: Option<String>,
717    pub has_more: bool,
718    /// Tools the child attempted but was denied permission to call.
719    pub denied_tools: Vec<String>,
720}
721
722/// Result from `close_agent()`.
723#[derive(Clone, Debug)]
724pub struct CloseResult {
725    pub closed: bool,
726    pub previous_status: String,
727    pub message: String,
728}
729
730/// Agent info for `list_agents()`.
731#[derive(Clone, Debug, serde::Serialize)]
732pub struct AgentInfo {
733    pub agent_path: String,
734    pub status: String,
735    pub tool_count: usize,
736}
737
738// ---------------------------------------------------------------------------
739// Child agent event loop
740// ---------------------------------------------------------------------------
741
742/// Run the child agent's main event loop.
743///
744/// This function runs inside a tokio task spawned by [`MultiAgentRuntime::spawn_child`].
745/// It:
746/// 1. Subscribes to child agent events and bridges them to parent
747/// 2. Listens for tasks from the mailbox
748/// 3. Executes each task via `run_turn`
749/// 4. Posts results back via the mailbox
750async fn run_child_loop(
751    child_mailbox: ChildMailbox,
752    child_runtime: AgentRuntime,
753    session_id: SessionId,
754    agent_path: AgentPath,
755    mailbox: Arc<MailboxHub>,
756    event_tx: Option<tokio::sync::mpsc::UnboundedSender<RuntimeEvent>>,
757    child_cancel: CancellationToken,
758) {
759    let mut task_rx = child_mailbox.task_rx;
760
761    // Spawn event bridging: forward child events to parent, tagging agent_id
762    if let Some(tx) = event_tx {
763        let mut child_events = child_runtime.subscribe_runtime_events();
764        let bridge_path = agent_path.to_string();
765        let bridge_cancel = child_cancel.clone();
766
767        tokio::spawn(async move {
768            loop {
769                tokio::select! {
770                    _ = bridge_cancel.cancelled() => break,
771                    event = child_events.recv() => {
772                        match event {
773                            Ok(event) => {
774                                if matches!(event,
775                                    RuntimeEvent::RunFinished { .. }
776                                    | RuntimeEvent::RunCancelled { .. }
777                                    | RuntimeEvent::AwaitingApproval { .. }) {
778                                    continue;
779                                }
780                                let _ = tx.send(event.with_agent_id(bridge_path.as_str()));
781                            }
782                            Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
783                                tracing::warn!(
784                                    subagent = %bridge_path,
785                                    lagged = n,
786                                    "child event bridge lagged"
787                                );
788                            }
789                            Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
790                        }
791                    }
792                }
793            }
794        });
795    }
796
797    // Main task loop
798    loop {
799        tokio::select! {
800            _ = child_cancel.cancelled() => {
801                break;
802            }
803            task = task_rx.recv() => {
804                match task {
805                    Some(task) => {
806                        let input = build_child_input(&task);
807                        let result = child_runtime.run_turn_collect(
808                            session_id.clone(),
809                            &input,
810                        ).await;
811
812                        match result {
813                            Ok((events, outcome)) => {
814                                let result_text = build_child_result(&outcome, &events);
815                                let denied_tools = collect_denied_tools(&events);
816                                mailbox.post_result(MailboxResult {
817                                    agent_path: agent_path.clone(),
818                                    status: MailboxStatus::Ok,
819                                    result: Some(result_text),
820                                    denied_tools,
821                                });
822                            }
823                            Err(e) => {
824                                mailbox.post_result(MailboxResult {
825                                    agent_path: agent_path.clone(),
826                                    status: MailboxStatus::Error,
827                                    result: Some(e.to_string()),
828                                    denied_tools: vec![],
829                                });
830                            }
831                        }
832                    }
833                    None => break, // task channel closed
834                }
835            }
836        }
837    }
838}
839
840/// Build the input text for a child agent from a mailbox task.
841fn build_child_input(task: &MailboxTask) -> String {
842    if task.pending_messages.is_empty() {
843        task.task.clone()
844    } else {
845        let mut parts: Vec<String> = Vec::new();
846        for msg in &task.pending_messages {
847            parts.push(format!("[Message]: {}", msg));
848        }
849        parts.push(format!("[Task]: {}", task.task));
850        parts.join("\n\n")
851    }
852}
853
854/// Extract a human-readable summary from a run outcome.
855fn summarize_outcome(outcome: &RunOutcome) -> String {
856    match outcome {
857        RunOutcome::Completed => "task completed".to_string(),
858        RunOutcome::Failed { error } => format!("task failed: {}", error),
859        RunOutcome::MaxTurnsExceeded { turns } => {
860            format!("max turns exceeded ({} turns)", turns)
861        }
862        RunOutcome::Cancelled => "cancelled".to_string(),
863    }
864}
865
866/// Extract the child agent's own assistant text from its collected events.
867///
868/// Excludes sub-sub-agent text (events tagged with an `agent_id`), so the parent
869/// only sees this child's direct answer.
870fn extract_assistant_text(events: &[RuntimeEvent]) -> String {
871    let mut text = String::new();
872    for event in events {
873        if let RuntimeEvent::TextDelta {
874            text: delta,
875            agent_id,
876            ..
877        } = event
878            && agent_id.is_none()
879        {
880            text.push_str(delta);
881        }
882    }
883    text
884}
885
886/// Collect the names of tools the child attempted but was denied permission to
887/// call, from a run's collected events.
888///
889/// Filters to the child's own events (`agent_id == None`) so grandchild denials
890/// — if children ever gain multi-agent tools — are not mis-attributed to the
891/// child. Only the child's direct denials matter to the parent.
892fn collect_denied_tools(events: &[RuntimeEvent]) -> Vec<String> {
893    events
894        .iter()
895        .filter_map(|e| match e {
896            RuntimeEvent::ToolCallFinished {
897                tool_name,
898                denied: true,
899                agent_id: None,
900                ..
901            } => Some(tool_name.clone()),
902            _ => None,
903        })
904        .collect()
905}
906
907/// Build the result string posted back to the parent via the mailbox.
908///
909/// For a completed task this returns the child's actual final answer (so the
910/// parent learns what the child concluded — e.g. "no permission" reports) rather
911/// than a coarse "task completed". Other outcomes keep the coarse summary.
912fn build_child_result(outcome: &RunOutcome, events: &[RuntimeEvent]) -> String {
913    match outcome {
914        RunOutcome::Completed => {
915            let text = extract_assistant_text(events);
916            if text.trim().is_empty() {
917                summarize_outcome(outcome)
918            } else {
919                text
920            }
921        }
922        _ => summarize_outcome(outcome),
923    }
924}
925
926// ---------------------------------------------------------------------------
927// Tests
928// ---------------------------------------------------------------------------
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933    use agent_base::RunOutcome;
934
935    // ── summarize_outcome ──
936
937    #[test]
938    fn test_summarize_completed() {
939        let s = summarize_outcome(&RunOutcome::Completed);
940        assert_eq!(s, "task completed");
941    }
942
943    #[test]
944    fn test_summarize_failed() {
945        let outcome = RunOutcome::Failed {
946            error: "connection refused".to_string(),
947        };
948        let s = summarize_outcome(&outcome);
949        assert_eq!(s, "task failed: connection refused");
950    }
951
952    #[test]
953    fn test_summarize_max_turns() {
954        let outcome = RunOutcome::MaxTurnsExceeded { turns: 42 };
955        let s = summarize_outcome(&outcome);
956        assert!(s.contains("max turns exceeded"));
957        assert!(s.contains("42"));
958    }
959
960    #[test]
961    fn test_summarize_cancelled() {
962        let s = summarize_outcome(&RunOutcome::Cancelled);
963        assert_eq!(s, "cancelled");
964    }
965
966    // ── build_child_result / extract_assistant_text ──
967
968    fn text_delta(text: &str, agent_id: Option<&str>) -> agent_base::RuntimeEvent {
969        agent_base::RuntimeEvent::TextDelta {
970            session_id: agent_base::SessionId::new(1),
971            text: text.to_string(),
972            agent_id: agent_id.map(|s| s.to_string()),
973            trace_id: None,
974        }
975    }
976
977    #[test]
978    fn test_build_child_result_completed_returns_final_text() {
979        let events = vec![text_delta("I couldn't ", None), text_delta("delete.", None)];
980        assert_eq!(
981            build_child_result(&RunOutcome::Completed, &events),
982            "I couldn't delete."
983        );
984    }
985
986    #[test]
987    fn test_build_child_result_completed_falls_back_when_no_text() {
988        assert_eq!(
989            build_child_result(&RunOutcome::Completed, &[]),
990            "task completed"
991        );
992    }
993
994    #[test]
995    fn test_extract_assistant_text_ignores_subagent_text() {
996        let events = vec![
997            text_delta("root answer", None),
998            text_delta("grandchild", Some("root/child/grandchild")),
999        ];
1000        assert_eq!(extract_assistant_text(&events), "root answer");
1001    }
1002
1003    #[test]
1004    fn test_build_child_result_failed_keeps_error() {
1005        let outcome = RunOutcome::Failed {
1006            error: "boom".to_string(),
1007        };
1008        assert_eq!(build_child_result(&outcome, &[]), "task failed: boom");
1009    }
1010
1011    // ── collect_denied_tools ──
1012
1013    fn tool_finished(tool_name: &str, denied: bool) -> agent_base::RuntimeEvent {
1014        agent_base::RuntimeEvent::ToolCallFinished {
1015            session_id: agent_base::SessionId::new(1),
1016            tool_name: tool_name.to_string(),
1017            summary: "summary".to_string(),
1018            agent_id: None,
1019            trace_id: None,
1020            denied,
1021        }
1022    }
1023
1024    #[test]
1025    fn test_collect_denied_tools_filters_denied_only() {
1026        let events = vec![
1027            tool_finished("read_file", false),
1028            tool_finished("delete_file", true),
1029            tool_finished("shell", true),
1030        ];
1031        assert_eq!(
1032            collect_denied_tools(&events),
1033            vec!["delete_file".to_string(), "shell".to_string()]
1034        );
1035    }
1036
1037    #[test]
1038    fn test_collect_denied_tools_empty_when_no_denials() {
1039        let events = vec![
1040            tool_finished("read_file", false),
1041            text_delta("all good", None),
1042        ];
1043        assert!(collect_denied_tools(&events).is_empty());
1044    }
1045
1046    #[test]
1047    fn test_collect_denied_tools_excludes_grandchild_denials() {
1048        // A grandchild's denial carries an agent_id and must not be attributed to
1049        // the child.
1050        let events = vec![
1051            agent_base::RuntimeEvent::ToolCallFinished {
1052                session_id: agent_base::SessionId::new(1),
1053                tool_name: "grandchild_tool".to_string(),
1054                summary: "summary".to_string(),
1055                agent_id: Some("root/child/grandchild".to_string()),
1056                trace_id: None,
1057                denied: true,
1058            },
1059            tool_finished("child_tool", true),
1060        ];
1061        assert_eq!(
1062            collect_denied_tools(&events),
1063            vec!["child_tool".to_string()]
1064        );
1065    }
1066
1067    // ── build_child_input ──
1068
1069    #[test]
1070    fn test_build_child_input_task_only() {
1071        let task = MailboxTask {
1072            task: "do work".into(),
1073            interrupt: true,
1074            pending_messages: vec![],
1075        };
1076        let out = build_child_input(&task);
1077        assert_eq!(out, "do work");
1078    }
1079
1080    #[test]
1081    fn test_build_child_input_with_pending_messages() {
1082        let task = MailboxTask {
1083            task: "do work".into(),
1084            interrupt: false,
1085            pending_messages: vec!["context 1".into(), "context 2".into()],
1086        };
1087        let out = build_child_input(&task);
1088        assert!(out.contains("[Message]: context 1"));
1089        assert!(out.contains("[Message]: context 2"));
1090        assert!(out.contains("[Task]: do work"));
1091        // Messages come before task
1092        let msg_pos = out.find("[Message]:").unwrap();
1093        let task_pos = out.find("[Task]:").unwrap();
1094        assert!(msg_pos < task_pos, "messages should precede task");
1095    }
1096
1097    #[test]
1098    fn test_build_child_input_single_message() {
1099        let task = MailboxTask {
1100            task: "final task".into(),
1101            interrupt: true,
1102            pending_messages: vec!["hint".into()],
1103        };
1104        let out = build_child_input(&task);
1105        assert_eq!(out, "[Message]: hint\n\n[Task]: final task");
1106    }
1107
1108    // ── fork_history: resolve_fork_history ──
1109
1110    /// Mock LLM client for fork_history tests (minimal — never called).
1111    #[derive(Clone)]
1112    struct NoopLlmClient;
1113
1114    #[async_trait::async_trait]
1115    impl agent_base::LlmClient for NoopLlmClient {
1116        async fn chat(
1117            &self,
1118            _messages: &[agent_base::ChatMessage],
1119            _tools: &[serde_json::Value],
1120            _reasoning: Option<&agent_base::ReasoningConfig>,
1121            _response_format: Option<&agent_base::ResponseFormat>,
1122        ) -> agent_base::AgentResult<serde_json::Value> {
1123            unimplemented!()
1124        }
1125
1126        async fn chat_stream(
1127            &self,
1128            _messages: &[agent_base::ChatMessage],
1129            _tools: &[serde_json::Value],
1130            _reasoning: Option<&agent_base::ReasoningConfig>,
1131            _response_format: Option<&agent_base::ResponseFormat>,
1132        ) -> agent_base::AgentResult<
1133            std::pin::Pin<
1134                Box<
1135                    dyn futures_core::Stream<
1136                            Item = agent_base::AgentResult<agent_base::StreamChunk>,
1137                        > + Send,
1138                >,
1139            >,
1140        > {
1141            unimplemented!()
1142        }
1143
1144        fn capabilities(&self) -> agent_base::LlmCapabilities {
1145            agent_base::LlmCapabilities {
1146                supports_streaming: true,
1147                supports_tools: false,
1148                supports_vision: false,
1149                supports_thinking: false,
1150                max_context_tokens: None,
1151                max_output_tokens: None,
1152            }
1153        }
1154    }
1155
1156    /// Build a MultiAgentRuntime with a parent runtime that has a populated session.
1157    async fn setup_fork_history_test(
1158        parent_messages: Vec<agent_base::ChatMessage>,
1159    ) -> (Arc<MultiAgentRuntime>, agent_base::SessionId) {
1160        use tokio_util::sync::CancellationToken;
1161
1162        let llm = agent_base::llm::adapt(Arc::new(NoopLlmClient));
1163        let parent_runtime = agent_base::AgentBuilder::new(llm)
1164            .build()
1165            .expect("build parent runtime");
1166        let parent_sid = parent_runtime.create_session().await;
1167
1168        // Push messages directly into the session's chat_messages vector so
1169        // we can use proper Assistant/Tool variants (not just System).
1170        parent_runtime
1171            .with_session_mut(&parent_sid, |session| {
1172                session.chat_messages_mut().extend(parent_messages.clone());
1173            })
1174            .await
1175            .unwrap();
1176
1177        let session_manager = Arc::new(parent_runtime.session_manager().clone());
1178
1179        let ma_runtime = Arc::new(MultiAgentRuntime::new(
1180            MultiAgentConfig::enabled(),
1181            agent_base::llm::adapt(Arc::new(NoopLlmClient)),
1182            vec![],
1183            CancellationToken::new(),
1184            None,
1185            agent_base::Language::En,
1186            None,
1187            None,
1188        ));
1189        ma_runtime.set_session_manager(session_manager);
1190
1191        (ma_runtime, parent_sid)
1192    }
1193
1194    #[tokio::test]
1195    async fn resolve_fork_history_none_returns_empty() {
1196        let messages = vec![agent_base::ChatMessage::User {
1197            content: "hello".into(),
1198            images: vec![],
1199            ephemeral: false,
1200        }];
1201        let (ma, parent_sid) = setup_fork_history_test(messages).await;
1202
1203        // None
1204        let result = ma.resolve_fork_history(None, &parent_sid).await;
1205        assert!(result.is_empty());
1206
1207        // Some("none")
1208        let result = ma
1209            .resolve_fork_history(Some("none".to_string()), &parent_sid)
1210            .await;
1211        assert!(result.is_empty());
1212    }
1213
1214    #[tokio::test]
1215    async fn resolve_fork_history_all_returns_all_non_system() {
1216        let messages = vec![
1217            agent_base::ChatMessage::User {
1218                content: "question 1".into(),
1219                images: vec![],
1220                ephemeral: false,
1221            },
1222            agent_base::ChatMessage::Assistant {
1223                content: Some("answer 1".into()),
1224                reasoning_content: None,
1225                tool_calls: None,
1226            },
1227            agent_base::ChatMessage::User {
1228                content: "question 2".into(),
1229                images: vec![],
1230                ephemeral: false,
1231            },
1232            agent_base::ChatMessage::Assistant {
1233                content: Some("answer 2".into()),
1234                reasoning_content: None,
1235                tool_calls: None,
1236            },
1237        ];
1238        let (ma, parent_sid) = setup_fork_history_test(messages).await;
1239
1240        let result = ma
1241            .resolve_fork_history(Some("all".to_string()), &parent_sid)
1242            .await;
1243
1244        // Should have 4 messages (2 user + 2 assistant) — system messages are filtered out
1245        assert_eq!(result.len(), 4);
1246        assert!(matches!(result[0], agent_base::ChatMessage::User { .. }));
1247        assert!(matches!(
1248            result[1],
1249            agent_base::ChatMessage::Assistant { .. }
1250        ));
1251        assert!(matches!(result[2], agent_base::ChatMessage::User { .. }));
1252        assert!(matches!(
1253            result[3],
1254            agent_base::ChatMessage::Assistant { .. }
1255        ));
1256    }
1257
1258    #[tokio::test]
1259    async fn resolve_fork_history_n_turns() {
1260        // 3 turns: 3 user messages, 3 assistant responses
1261        let messages = vec![
1262            agent_base::ChatMessage::User {
1263                content: "q1".into(),
1264                images: vec![],
1265                ephemeral: false,
1266            },
1267            agent_base::ChatMessage::Assistant {
1268                content: Some("a1".into()),
1269                reasoning_content: None,
1270                tool_calls: None,
1271            },
1272            agent_base::ChatMessage::User {
1273                content: "q2".into(),
1274                images: vec![],
1275                ephemeral: false,
1276            },
1277            agent_base::ChatMessage::Assistant {
1278                content: Some("a2".into()),
1279                reasoning_content: None,
1280                tool_calls: None,
1281            },
1282            agent_base::ChatMessage::User {
1283                content: "q3".into(),
1284                images: vec![],
1285                ephemeral: false,
1286            },
1287            agent_base::ChatMessage::Assistant {
1288                content: Some("a3".into()),
1289                reasoning_content: None,
1290                tool_calls: None,
1291            },
1292        ];
1293        let (ma, parent_sid) = setup_fork_history_test(messages).await;
1294
1295        // Last 1 turn
1296        let result = ma
1297            .resolve_fork_history(Some("1".to_string()), &parent_sid)
1298            .await;
1299        assert_eq!(result.len(), 2, "1 turn = user q3 + assistant a3");
1300        assert!(matches!(result[0], agent_base::ChatMessage::User { .. }));
1301        assert_eq!(extract_user_content(&result[0]), "q3");
1302
1303        // Last 2 turns
1304        let result = ma
1305            .resolve_fork_history(Some("2".to_string()), &parent_sid)
1306            .await;
1307        assert_eq!(result.len(), 4, "2 turns = q2,a2,q3,a3");
1308    }
1309
1310    #[tokio::test]
1311    async fn resolve_fork_history_invalid_number_treats_as_none() {
1312        let messages = vec![agent_base::ChatMessage::User {
1313            content: "hello".into(),
1314            images: vec![],
1315            ephemeral: false,
1316        }];
1317        let (ma, parent_sid) = setup_fork_history_test(messages).await;
1318
1319        // Invalid number → empty
1320        let result = ma
1321            .resolve_fork_history(Some("not-a-number".to_string()), &parent_sid)
1322            .await;
1323        assert!(result.is_empty());
1324
1325        // Zero → empty
1326        let result = ma
1327            .resolve_fork_history(Some("0".to_string()), &parent_sid)
1328            .await;
1329        assert!(result.is_empty());
1330    }
1331
1332    #[tokio::test]
1333    async fn resolve_fork_history_no_session_manager_returns_empty() {
1334        use tokio_util::sync::CancellationToken;
1335
1336        let ma_runtime = MultiAgentRuntime::new(
1337            MultiAgentConfig::enabled(),
1338            agent_base::llm::adapt(Arc::new(NoopLlmClient)),
1339            vec![],
1340            CancellationToken::new(),
1341            None,
1342            agent_base::Language::En,
1343            None,
1344            None,
1345        );
1346        // session_manager is NOT set
1347
1348        let sid = agent_base::SessionId::new(9999);
1349        let result = ma_runtime
1350            .resolve_fork_history(Some("all".to_string()), &sid)
1351            .await;
1352        assert!(result.is_empty());
1353    }
1354
1355    #[tokio::test]
1356    async fn resolve_fork_history_empty_session_returns_empty() {
1357        let (ma, parent_sid) = setup_fork_history_test(vec![]).await;
1358
1359        let result = ma
1360            .resolve_fork_history(Some("all".to_string()), &parent_sid)
1361            .await;
1362        assert!(result.is_empty());
1363    }
1364
1365    // ── fork_history: prefill_child_session ──
1366
1367    #[tokio::test]
1368    async fn prefill_child_session_user_and_assistant() {
1369        let llm = agent_base::llm::adapt(Arc::new(NoopLlmClient));
1370        let child_runtime = agent_base::AgentBuilder::new(llm)
1371            .build()
1372            .expect("build child runtime");
1373        let child_sid = child_runtime.create_session().await;
1374
1375        let parent_messages = vec![
1376            agent_base::ChatMessage::User {
1377                content: "user question".into(),
1378                images: vec![],
1379                ephemeral: false,
1380            },
1381            agent_base::ChatMessage::Assistant {
1382                content: Some("assistant reply".into()),
1383                reasoning_content: None,
1384                tool_calls: None,
1385            },
1386            agent_base::ChatMessage::Tool {
1387                tool_call_id: "call_123".into(),
1388                content: "tool output".into(),
1389            },
1390        ];
1391
1392        // Create a minimal MultiAgentRuntime just to call prefill_child_session
1393        use tokio_util::sync::CancellationToken;
1394        let ma_runtime = MultiAgentRuntime::new(
1395            MultiAgentConfig::enabled(),
1396            agent_base::llm::adapt(Arc::new(NoopLlmClient)),
1397            vec![],
1398            CancellationToken::new(),
1399            None,
1400            agent_base::Language::En,
1401            None,
1402            None,
1403        );
1404
1405        ma_runtime
1406            .prefill_child_session(&child_runtime, &child_sid, &parent_messages)
1407            .await
1408            .expect("prefill should succeed");
1409
1410        // Verify the child session contains the pre-filled messages
1411        let session = child_runtime
1412            .session(&child_sid)
1413            .await
1414            .expect("session exists");
1415        let msgs = session.chat_messages().to_vec();
1416
1417        // Should have: user msg + system msg (assistant) + system msg (tool)
1418        assert_eq!(msgs.len(), 3);
1419        assert!(matches!(msgs[0], agent_base::ChatMessage::User { .. }));
1420        assert!(matches!(msgs[1], agent_base::ChatMessage::System { .. }));
1421        assert!(matches!(msgs[2], agent_base::ChatMessage::System { .. }));
1422    }
1423
1424    #[tokio::test]
1425    async fn prefill_child_session_tool_call_only_skipped() {
1426        let llm = agent_base::llm::adapt(Arc::new(NoopLlmClient));
1427        let child_runtime = agent_base::AgentBuilder::new(llm)
1428            .build()
1429            .expect("build child runtime");
1430        let child_sid = child_runtime.create_session().await;
1431
1432        // Assistant message with only tool_calls (no text content) should be skipped
1433        let parent_messages = vec![
1434            agent_base::ChatMessage::User {
1435                content: "do something".into(),
1436                images: vec![],
1437                ephemeral: false,
1438            },
1439            agent_base::ChatMessage::Assistant {
1440                content: None, // no text — tool call only
1441                reasoning_content: None,
1442                tool_calls: Some(vec![]),
1443            },
1444        ];
1445
1446        use tokio_util::sync::CancellationToken;
1447        let ma_runtime = MultiAgentRuntime::new(
1448            MultiAgentConfig::enabled(),
1449            agent_base::llm::adapt(Arc::new(NoopLlmClient)),
1450            vec![],
1451            CancellationToken::new(),
1452            None,
1453            agent_base::Language::En,
1454            None,
1455            None,
1456        );
1457
1458        ma_runtime
1459            .prefill_child_session(&child_runtime, &child_sid, &parent_messages)
1460            .await
1461            .expect("prefill should succeed");
1462
1463        let session = child_runtime
1464            .session(&child_sid)
1465            .await
1466            .expect("session exists");
1467        let msgs = session.chat_messages().to_vec();
1468
1469        // Only the user message — tool-call-only assistant should be skipped
1470        assert_eq!(msgs.len(), 1);
1471        assert!(matches!(msgs[0], agent_base::ChatMessage::User { .. }));
1472    }
1473
1474    #[tokio::test]
1475    async fn prefill_child_session_empty_vec_noop() {
1476        let llm = agent_base::llm::adapt(Arc::new(NoopLlmClient));
1477        let child_runtime = agent_base::AgentBuilder::new(llm)
1478            .build()
1479            .expect("build child runtime");
1480        let child_sid = child_runtime.create_session().await;
1481
1482        use tokio_util::sync::CancellationToken;
1483        let ma_runtime = MultiAgentRuntime::new(
1484            MultiAgentConfig::enabled(),
1485            agent_base::llm::adapt(Arc::new(NoopLlmClient)),
1486            vec![],
1487            CancellationToken::new(),
1488            None,
1489            agent_base::Language::En,
1490            None,
1491            None,
1492        );
1493
1494        ma_runtime
1495            .prefill_child_session(&child_runtime, &child_sid, &[])
1496            .await
1497            .expect("prefill should succeed");
1498
1499        let session = child_runtime
1500            .session(&child_sid)
1501            .await
1502            .expect("session exists");
1503        let msgs = session.chat_messages().to_vec();
1504
1505        // System prompt is added but we don't assert exact count — just that no user/injected msgs
1506        assert!(msgs.is_empty() || matches!(msgs[0], agent_base::ChatMessage::System { .. }));
1507    }
1508
1509    fn extract_user_content(msg: &agent_base::ChatMessage) -> &str {
1510        match msg {
1511            agent_base::ChatMessage::User { content, .. } => content.as_str(),
1512            _ => "",
1513        }
1514    }
1515
1516    // ── lifecycle: spawn / send / wait / close ──
1517
1518    struct StreamingStub;
1519
1520    #[async_trait::async_trait]
1521    impl agent_base::StreamClient for StreamingStub {
1522        async fn stream(
1523            &self,
1524            _messages: &[agent_base::ChatMessage],
1525            _tools: &[serde_json::Value],
1526            _reasoning: Option<&agent_base::ReasoningConfig>,
1527            _response_format: Option<&agent_base::ResponseFormat>,
1528        ) -> agent_base::AgentResult<
1529            std::pin::Pin<
1530                Box<
1531                    dyn futures_core::Stream<
1532                            Item = agent_base::AgentResult<agent_base::StreamChunk>,
1533                        > + Send,
1534                >,
1535            >,
1536        > {
1537            Ok(Box::pin(futures_util::stream::iter(vec![
1538                Ok(agent_base::StreamChunk::Text("child ok".to_string())),
1539                Ok(agent_base::StreamChunk::Stop {
1540                    finish_reason: Some("stop".to_string()),
1541                }),
1542            ])))
1543        }
1544
1545        fn capabilities(&self) -> agent_base::LlmCapabilities {
1546            agent_base::LlmCapabilities::default()
1547        }
1548    }
1549
1550    fn make_ma_runtime() -> Arc<MultiAgentRuntime> {
1551        let client: Arc<dyn agent_base::StreamClient> = Arc::new(StreamingStub);
1552        Arc::new(MultiAgentRuntime::new(
1553            MultiAgentConfig::enabled(),
1554            client,
1555            vec![],
1556            tokio_util::sync::CancellationToken::new(),
1557            None,
1558            agent_base::Language::En,
1559            None,
1560            None,
1561        ))
1562    }
1563
1564    #[tokio::test(flavor = "multi_thread")]
1565    async fn test_spawn_send_task_wait_close_lifecycle() {
1566        let ma = make_ma_runtime();
1567
1568        let path = ma
1569            .spawn_child(
1570                "worker",
1571                "child system prompt".to_string(),
1572                0,
1573                0,
1574                false,
1575                vec![],
1576            )
1577            .await
1578            .expect("spawn child");
1579        assert_eq!(path, "root/worker");
1580
1581        let agents = ma.list_agents();
1582        assert_eq!(agents.len(), 1);
1583        assert_eq!(agents[0].agent_path, "root/worker");
1584
1585        // send_message posts a pending message (no execution trigger).
1586        assert!(
1587            ma.send_message("root/worker", "heads up".to_string())
1588                .unwrap()
1589        );
1590
1591        // send_task triggers execution; the child completes via the stub stream.
1592        assert!(
1593            ma.send_task("root/worker", "do the thing".to_string(), false)
1594                .unwrap()
1595        );
1596
1597        let result = ma.wait_for_result(Some("root/worker"), 2000).await;
1598        assert_eq!(result.status, "ok");
1599        assert_eq!(result.result.as_deref(), Some("child ok"));
1600
1601        let close = ma.close_agent("root/worker").unwrap();
1602        assert!(close.closed);
1603        assert_eq!(close.message, "agent closed");
1604
1605        // Closing a second time reports not found.
1606        let close2 = ma.close_agent("root/worker").unwrap();
1607        assert!(!close2.closed);
1608        assert_eq!(close2.message, "agent not found");
1609    }
1610
1611    #[tokio::test(flavor = "multi_thread")]
1612    async fn test_spawn_child_with_history_defaults_to_none() {
1613        let ma = make_ma_runtime();
1614        // No session manager set → fork_history resolves to empty; spawn still succeeds.
1615        let path = ma
1616            .spawn_child_with_history(
1617                "w2",
1618                "prompt".to_string(),
1619                0,
1620                0,
1621                false,
1622                None,
1623                &agent_base::SessionId::new(0),
1624            )
1625            .await
1626            .expect("spawn with history");
1627        assert_eq!(path, "root/w2");
1628    }
1629
1630    #[tokio::test(flavor = "multi_thread")]
1631    async fn test_error_paths() {
1632        let ma = make_ma_runtime();
1633
1634        // Valid path, unknown agent → "agent not found".
1635        assert_eq!(
1636            ma.send_task("root/ghost", "x".to_string(), false)
1637                .unwrap_err(),
1638            "agent not found"
1639        );
1640
1641        // Invalid paths (must start with "root", no empty segments).
1642        assert!(ma.send_message("worker", "x".to_string()).is_err());
1643        assert!(ma.send_message("", "x".to_string()).is_err());
1644
1645        // wait_for_result with invalid path.
1646        let r = ma.wait_for_result(Some("worker"), 10).await;
1647        assert_eq!(r.status, "error");
1648
1649        // wait_for_result with no results times out.
1650        let r2 = ma.wait_for_result(None, 50).await;
1651        assert_eq!(r2.status, "timeout");
1652
1653        // cancel_all is a no-op when no children are running.
1654        ma.cancel_all();
1655    }
1656
1657    // ── child permission mode ──
1658
1659    fn make_runtime_full(
1660        mode: ChildPermissionMode,
1661        policy: Option<Arc<dyn ToolPolicy>>,
1662    ) -> Arc<MultiAgentRuntime> {
1663        make_runtime_full_with_approval(mode, policy, None)
1664    }
1665
1666    fn make_runtime_full_with_approval(
1667        mode: ChildPermissionMode,
1668        policy: Option<Arc<dyn ToolPolicy>>,
1669        approval: Option<Arc<dyn ApprovalHandler>>,
1670    ) -> Arc<MultiAgentRuntime> {
1671        let config = MultiAgentConfig {
1672            child_permission_mode: mode,
1673            ..MultiAgentConfig::enabled()
1674        };
1675        Arc::new(MultiAgentRuntime::new(
1676            config,
1677            Arc::new(StreamingStub),
1678            vec![],
1679            tokio_util::sync::CancellationToken::new(),
1680            None,
1681            agent_base::Language::En,
1682            policy,
1683            approval,
1684        ))
1685    }
1686
1687    #[test]
1688    fn effective_permission_respects_mode() {
1689        let full = make_runtime_full(ChildPermissionMode::Full, None);
1690        assert!(full.effective_permission(false));
1691        assert!(full.effective_permission(true));
1692
1693        let none = make_runtime_full(ChildPermissionMode::None, None);
1694        assert!(!none.effective_permission(false));
1695        assert!(!none.effective_permission(true));
1696
1697        let per_spawn = make_runtime_full(ChildPermissionMode::PerSpawn, None);
1698        assert!(per_spawn.effective_permission(true));
1699        assert!(!per_spawn.effective_permission(false));
1700    }
1701
1702    #[tokio::test]
1703    async fn build_child_runtime_full_carries_no_policy() {
1704        let ma = make_ma_runtime();
1705        let child = ma
1706            .build_child_runtime("prompt".to_string(), true)
1707            .await
1708            .expect("build child");
1709        assert!(child.tool_policy().is_none());
1710    }
1711
1712    #[tokio::test]
1713    async fn build_child_runtime_none_falls_back_to_deny_all() {
1714        // Parent has no tool policy → child falls back to DenyAllToolPolicy.
1715        let ma = make_ma_runtime();
1716        let child = ma
1717            .build_child_runtime("prompt".to_string(), false)
1718            .await
1719            .expect("build child");
1720        assert!(child.tool_policy().is_some());
1721    }
1722
1723    #[tokio::test]
1724    async fn build_child_runtime_none_inherits_parent_policy() {
1725        // Parent has a tool policy → child inherits the same allocation.
1726        let parent_policy: Arc<dyn ToolPolicy> = Arc::new(DenyAllToolPolicy);
1727        let ma = make_runtime_full(ChildPermissionMode::None, Some(parent_policy.clone()));
1728        let child = ma
1729            .build_child_runtime("prompt".to_string(), false)
1730            .await
1731            .expect("build child");
1732        let child_policy = child.tool_policy().expect("child should carry a policy");
1733        assert!(Arc::ptr_eq(&parent_policy, child_policy));
1734    }
1735
1736    #[tokio::test]
1737    async fn build_child_runtime_none_delegates_to_parent_approval_handler() {
1738        // Codex-style: a restricted child routes the approval decision up to the
1739        // parent's handler (human-in-the-loop / auto) rather than hard-denying
1740        // locally. This is what makes `ask` mode coherent for sub-agents.
1741        let parent_handler: Arc<dyn ApprovalHandler> = Arc::new(AllowAllApprovalHandler);
1742        let ma = make_runtime_full_with_approval(
1743            ChildPermissionMode::None,
1744            None,
1745            Some(parent_handler.clone()),
1746        );
1747        let child = ma
1748            .build_child_runtime("prompt".to_string(), false)
1749            .await
1750            .expect("build child");
1751        let child_handler = child
1752            .approval_handler()
1753            .expect("child should carry an approval handler");
1754        assert!(Arc::ptr_eq(child_handler, &parent_handler));
1755    }
1756
1757    #[tokio::test]
1758    async fn build_child_runtime_none_denies_when_parent_has_no_handler() {
1759        // Parent carries no handler → the child must remain read-only (DenyAll),
1760        // preserving the "no policy, no handler → nothing can happen" invariant.
1761        let ma = make_runtime_full(ChildPermissionMode::None, None);
1762        let child = ma
1763            .build_child_runtime("prompt".to_string(), false)
1764            .await
1765            .expect("build child");
1766        let child_handler = child
1767            .approval_handler()
1768            .expect("child should carry a fallback DenyAll handler");
1769        let decision = child_handler
1770            .approve(
1771                agent_base::ApprovalRequest {
1772                    title: "x".into(),
1773                    message: "x".into(),
1774                    action_key: None,
1775                    risk_level: agent_base::RiskLevel::Sensitive,
1776                    raw: None,
1777                },
1778                tokio_util::sync::CancellationToken::new(),
1779            )
1780            .await
1781            .expect("DenyAll approves without error");
1782        assert_eq!(decision, agent_base::ApprovalDecision::Deny);
1783    }
1784
1785    #[tokio::test]
1786    async fn build_child_runtime_excludes_root_level_tools() {
1787        struct NoopDecomposeTool;
1788        #[async_trait::async_trait]
1789        impl Tool for NoopDecomposeTool {
1790            fn name(&self) -> &'static str {
1791                "decompose"
1792            }
1793
1794            fn description(&self) -> &'static str {
1795                "split a task into parallel slices"
1796            }
1797
1798            fn schema(&self) -> serde_json::Value {
1799                serde_json::json!({ "type": "object", "properties": {} })
1800            }
1801
1802            async fn call(
1803                &self,
1804                _args: &serde_json::Value,
1805                _ctx: &agent_base::ToolContext,
1806            ) -> agent_base::AgentResult<Vec<agent_base::Content>> {
1807                Ok(vec![agent_base::Content::text("serial")])
1808            }
1809        }
1810
1811        let config = MultiAgentConfig {
1812            child_excluded_tools: vec!["decompose".to_string()],
1813            ..MultiAgentConfig::default()
1814        };
1815        let ma = Arc::new(MultiAgentRuntime::new(
1816            config,
1817            Arc::new(StreamingStub),
1818            vec![Arc::new(NoopDecomposeTool), Arc::new(NoopReadFileTool)],
1819            tokio_util::sync::CancellationToken::new(),
1820            None,
1821            agent_base::Language::En,
1822            None,
1823            None,
1824        ));
1825
1826        let child = ma
1827            .build_child_runtime("prompt".to_string(), true)
1828            .await
1829            .expect("build child");
1830
1831        let registry = child.tools_mut();
1832        let registry = registry.read().await;
1833        assert!(
1834            registry.get("decompose").is_none(),
1835            "decompose must be excluded from child runtimes"
1836        );
1837        assert!(
1838            registry.get("read_file").is_some(),
1839            "non-excluded tools must still be inherited"
1840        );
1841    }
1842
1843    // ── end-to-end: denied_tools flows child → parent ──
1844
1845    struct NoopReadFileTool;
1846
1847    #[async_trait::async_trait]
1848    impl Tool for NoopReadFileTool {
1849        fn name(&self) -> &'static str {
1850            "read_file"
1851        }
1852
1853        fn description(&self) -> &'static str {
1854            "Read a file's contents"
1855        }
1856
1857        fn schema(&self) -> serde_json::Value {
1858            serde_json::json!({
1859                "type": "object",
1860                "properties": { "path": { "type": "string" } }
1861            })
1862        }
1863
1864        async fn call(
1865            &self,
1866            _args: &serde_json::Value,
1867            _ctx: &agent_base::ToolContext,
1868        ) -> agent_base::AgentResult<Vec<agent_base::Content>> {
1869            Ok(vec![agent_base::Content::text("contents")])
1870        }
1871    }
1872
1873    /// Scripted client: the first turn requests the `read_file` tool (which the
1874    /// child is denied); any later turn emits a plain text answer.
1875    struct DenialScriptedClient {
1876        turn: std::sync::atomic::AtomicUsize,
1877    }
1878
1879    #[async_trait::async_trait]
1880    impl agent_base::StreamClient for DenialScriptedClient {
1881        async fn stream(
1882            &self,
1883            _messages: &[agent_base::ChatMessage],
1884            _tools: &[serde_json::Value],
1885            _reasoning: Option<&agent_base::ReasoningConfig>,
1886            _response_format: Option<&agent_base::ResponseFormat>,
1887        ) -> agent_base::AgentResult<
1888            std::pin::Pin<
1889                Box<
1890                    dyn futures_core::Stream<
1891                            Item = agent_base::AgentResult<agent_base::StreamChunk>,
1892                        > + Send,
1893                >,
1894            >,
1895        > {
1896            let n = self.turn.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1897            let chunks: Vec<agent_base::AgentResult<agent_base::StreamChunk>> = if n == 0 {
1898                vec![
1899                    Ok(agent_base::StreamChunk::ToolCall(serde_json::json!({
1900                        "delta": {
1901                            "tool_calls": [{
1902                                "id": "call_1",
1903                                "function": {
1904                                    "name": "read_file",
1905                                    "arguments": "{\"path\":\"/etc/passwd\"}"
1906                                }
1907                            }]
1908                        }
1909                    }))),
1910                    Ok(agent_base::StreamChunk::Stop {
1911                        finish_reason: Some("tool_calls".to_string()),
1912                    }),
1913                ]
1914            } else {
1915                vec![
1916                    Ok(agent_base::StreamChunk::Text(
1917                        "I lack permission.".to_string(),
1918                    )),
1919                    Ok(agent_base::StreamChunk::Stop {
1920                        finish_reason: Some("stop".to_string()),
1921                    }),
1922                ]
1923            };
1924            Ok(Box::pin(futures_util::stream::iter(chunks)))
1925        }
1926
1927        fn capabilities(&self) -> agent_base::LlmCapabilities {
1928            agent_base::LlmCapabilities::default()
1929        }
1930    }
1931
1932    #[tokio::test(flavor = "multi_thread")]
1933    async fn test_child_denied_tool_reaches_parent_via_wait() {
1934        // `None` mode + no parent policy → child carries DenyAllToolPolicy, so
1935        // every tool it attempts is denied. Assert the denied tool name flows
1936        // end-to-end: collect_denied_tools → mailbox → wait_for_result.
1937        let config = MultiAgentConfig {
1938            child_permission_mode: ChildPermissionMode::None,
1939            ..MultiAgentConfig::enabled()
1940        };
1941        let ma = Arc::new(MultiAgentRuntime::new(
1942            config,
1943            Arc::new(DenialScriptedClient {
1944                turn: std::sync::atomic::AtomicUsize::new(0),
1945            }),
1946            vec![Arc::new(NoopReadFileTool) as Arc<dyn Tool>],
1947            tokio_util::sync::CancellationToken::new(),
1948            None,
1949            agent_base::Language::En,
1950            None,
1951            None,
1952        ));
1953
1954        let path = ma
1955            .spawn_child(
1956                "worker",
1957                "child system prompt".to_string(),
1958                0,
1959                1,
1960                false,
1961                vec![],
1962            )
1963            .await
1964            .expect("spawn child");
1965        assert_eq!(path, "root/worker");
1966
1967        ma.send_task("root/worker", "read the file".to_string(), false)
1968            .unwrap();
1969
1970        let result = ma.wait_for_result(Some("root/worker"), 3000).await;
1971        assert_eq!(result.status, "ok");
1972        assert_eq!(result.denied_tools, vec!["read_file".to_string()]);
1973    }
1974}