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, DenyAllApprovalHandler, Language, LlmClient,
12    RunOutcome, RuntimeEvent, SessionId, Tool, UserEvent,
13};
14use tokio::task::JoinSet;
15use tokio_util::sync::CancellationToken;
16
17use super::config::MultiAgentConfig;
18use super::mailbox::{ChildMailbox, MailboxHub, MailboxResult, MailboxStatus, MailboxTask};
19use super::path::AgentPath;
20use super::registry::{AgentRegistry, AgentStatus};
21
22// ---------------------------------------------------------------------------
23// MultiAgentRuntime
24// ---------------------------------------------------------------------------
25
26/// Coordinates sub-agent lifecycle, event bridging, and cancellation.
27///
28/// Created once during builder setup and shared via `Arc` to all 6 multi-agent
29/// tools. Each tool calls methods on the runtime to spawn, communicate with, or
30/// close sub-agents.
31pub struct MultiAgentRuntime {
32    /// Agent lifecycle registry (spawn/close/query).
33    registry: Mutex<AgentRegistry>,
34
35    /// Inter-agent message hub.
36    mailbox: Arc<MailboxHub>,
37
38    /// Shared LLM client (from parent agent).
39    client: Arc<dyn LlmClient>,
40
41    /// Business tools to register on child agents (NOT the 6 multi-agent tools).
42    business_tools: Vec<Arc<dyn Tool>>,
43
44    /// Channel to the bridge task that emits events on parent's event bus.
45    event_tx: Mutex<Option<tokio::sync::mpsc::UnboundedSender<RuntimeEvent>>>,
46
47    /// Root cancellation token (propagates to all children).
48    root_cancel: CancellationToken,
49
50    /// JoinSet tracking all child agent tasks.
51    join_set: Mutex<JoinSet<()>>,
52
53    /// Per-child cancellation tokens.
54    child_cancels: Mutex<HashMap<AgentPath, CancellationToken>>,
55
56    /// Error recovery strategy (inherited from parent).
57    error_recovery: Option<Arc<dyn agent_base::ToolErrorRecovery>>,
58
59    /// Language preference.
60    language: Language,
61}
62
63impl MultiAgentRuntime {
64    /// Create a new multi-agent runtime.
65    ///
66    /// This is called internally by the builder. Tools receive an `Arc<Self>`.
67    pub fn new(
68        config: MultiAgentConfig,
69        client: Arc<dyn LlmClient>,
70        business_tools: Vec<Arc<dyn Tool>>,
71        root_cancel: CancellationToken,
72        error_recovery: Option<Arc<dyn agent_base::ToolErrorRecovery>>,
73        language: Language,
74    ) -> Self {
75        Self {
76            registry: Mutex::new(AgentRegistry::new(config)),
77            mailbox: Arc::new(MailboxHub::new()),
78            client,
79            business_tools,
80            event_tx: Mutex::new(None),
81            root_cancel,
82            join_set: Mutex::new(JoinSet::new()),
83            child_cancels: Mutex::new(HashMap::new()),
84            error_recovery,
85            language,
86        }
87    }
88
89    /// Set the event sender for bridging child events to parent.
90    ///
91    /// Called by the builder after creating the bridge channel.
92    pub fn set_event_sender(&self, tx: tokio::sync::mpsc::UnboundedSender<RuntimeEvent>) {
93        *self.event_tx.lock().unwrap() = Some(tx);
94    }
95
96    /// Spawn a child agent at the given path with a specific system prompt.
97    ///
98    /// This is called by the `spawn_agent` tool. It:
99    /// 1. Checks spawn limits
100    /// 2. Registers the agent in the registry
101    /// 3. Creates a mailbox
102    /// 4. Builds a child AgentRuntime
103    /// 5. Spawns a tokio task for the child's event loop
104    /// 6. Returns the AgentPath
105    ///
106    /// # Errors
107    ///
108    /// Returns a string error message if spawning fails (limits exceeded, etc.).
109    pub async fn spawn_child(
110        &self,
111        name: &str,
112        system_prompt: String,
113        depth: i32,
114        tool_count: usize,
115    ) -> Result<String, String> {
116        let path = AgentPath::root().join(name);
117
118        // 1. Check limits and register
119        {
120            let mut registry = self.registry.lock().unwrap();
121            registry.can_spawn(depth).map_err(|e| e.to_string())?;
122            registry
123                .register(&path, depth, tool_count)
124                .map_err(|e| e.to_string())?;
125        }
126
127        // 2. Create mailbox
128        let child_mailbox = self
129            .mailbox
130            .register(&path)
131            .ok_or_else(|| "mailbox already exists".to_string())?;
132
133        // 3. Build child AgentRuntime (roll back registry+mailbox on failure)
134        let child_runtime = self.build_child_runtime(system_prompt).map_err(|e| {
135            self.registry.lock().unwrap().close(&path);
136            self.mailbox.unregister(&path);
137            format!("failed to build child runtime: {}", e)
138        })?;
139
140        // 4. Create session for child
141        let session_id = child_runtime.create_session().await;
142
143        // 5. Create child cancellation token
144        let child_cancel = self.root_cancel.child_token();
145        {
146            let mut cancels = self.child_cancels.lock().unwrap();
147            cancels.insert(path.clone(), child_cancel.clone());
148        }
149
150        // 6. Spawn child agent event loop
151        let agent_path = path.clone();
152        let mailbox_for_task = self.mailbox.clone();
153        let mailbox_for_close = self.mailbox.clone();
154        let event_tx = self.event_tx.lock().unwrap().clone();
155        let registry_agent_path = path.clone();
156
157        self.join_set.lock().unwrap().spawn(async move {
158            run_child_loop(
159                child_mailbox,
160                child_runtime,
161                session_id,
162                agent_path.clone(),
163                mailbox_for_task,
164                event_tx,
165                child_cancel,
166            )
167            .await;
168
169            // Post close notification when loop exits
170            mailbox_for_close.post_result(MailboxResult {
171                agent_path,
172                status: MailboxStatus::Closed,
173                result: None,
174            });
175        });
176
177        self.registry
178            .lock()
179            .unwrap()
180            .set_status(&registry_agent_path, AgentStatus::Idle);
181
182        Ok(path.to_string())
183    }
184
185    /// Send a message to a child agent (no execution trigger).
186    ///
187    /// Called by `send_message` tool.
188    pub fn send_message(&self, agent_path: &str, message: String) -> Result<bool, String> {
189        let path = self.parse_path(agent_path)?;
190        Ok(self.mailbox.send_message(&path, message))
191    }
192
193    /// Send a task to a child agent (triggers execution).
194    ///
195    /// Called by `followup_task` tool. Updates status to Running.
196    pub fn send_task(
197        &self,
198        agent_path: &str,
199        task: String,
200        interrupt: bool,
201    ) -> Result<bool, String> {
202        let path = self.parse_path(agent_path)?;
203        if !self.mailbox.contains(&path) {
204            return Err("agent not found".to_string());
205        }
206        let sent = self.mailbox.send_task(&path, task, interrupt);
207        if sent {
208            self.registry
209                .lock()
210                .unwrap()
211                .set_status(&path, AgentStatus::Running);
212        }
213        Ok(sent)
214    }
215
216    /// Wait for a result from any or a specific child agent.
217    ///
218    /// Called by `wait_agent` tool. Blocks until a result arrives or timeout.
219    pub async fn wait_for_result(&self, agent_path: Option<&str>, timeout_ms: u64) -> WaitResult {
220        let filter_path = match agent_path {
221            Some(s) => match AgentPath::parse(s) {
222                Some(p) => Some(p),
223                None => {
224                    return WaitResult {
225                        status: "error".to_string(),
226                        result: Some(format!("invalid agent path: {}", s)),
227                        agent_path: None,
228                        has_more: false,
229                    };
230                }
231            },
232            None => None,
233        };
234
235        let mut seq = self.mailbox.subscribe_seq();
236        let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
237
238        loop {
239            // Check for existing results
240            let result = match &filter_path {
241                Some(path) => self.mailbox.try_recv_result(path),
242                None => self.mailbox.try_recv_any(),
243            };
244
245            if let Some(r) = result {
246                let has_more = self.mailbox.total_pending_results() > 0;
247                let (status_str, result_text) = match r.status {
248                    MailboxStatus::Ok => ("ok".to_string(), r.result),
249                    MailboxStatus::Error => ("error".to_string(), r.result),
250                    MailboxStatus::Closed => ("closed".to_string(), r.result),
251                };
252                return WaitResult {
253                    status: status_str,
254                    result: result_text,
255                    agent_path: Some(r.agent_path.to_string()),
256                    has_more,
257                };
258            }
259
260            // Wait for sequence number change or timeout
261            let now = tokio::time::Instant::now();
262            if now >= deadline {
263                return WaitResult {
264                    status: "timeout".to_string(),
265                    result: None,
266                    agent_path: None,
267                    has_more: false,
268                };
269            }
270
271            let remaining = deadline - now;
272            tokio::select! {
273                _ = seq.changed() => {
274                    // Sequence changed — loop back to check results
275                    continue;
276                }
277                _ = tokio::time::sleep(remaining) => {
278                    return WaitResult {
279                        status: "timeout".to_string(),
280                        result: None,
281                        agent_path: None,
282                        has_more: false,
283                    };
284                }
285            }
286        }
287    }
288
289    /// Close a child agent.
290    ///
291    /// Called by `close_agent` tool. Cancels the child's task, removes from
292    /// registry, and posts a Closed result.
293    pub fn close_agent(&self, agent_path: &str) -> Result<CloseResult, String> {
294        let path = self.parse_path(agent_path)?;
295
296        // Get previous status
297        let previous_status = {
298            let registry = self.registry.lock().unwrap();
299            registry
300                .get(&path)
301                .map(|e| format!("{:?}", e.status).to_lowercase())
302                .unwrap_or_else(|| "unknown".to_string())
303        };
304
305        // Cancel child token
306        {
307            let mut cancels = self.child_cancels.lock().unwrap();
308            if let Some(token) = cancels.remove(&path) {
309                token.cancel();
310            }
311        }
312
313        // Close in registry
314        let existed = { self.registry.lock().unwrap().close(&path).is_some() };
315
316        // Unregister mailbox
317        self.mailbox.unregister(&path);
318
319        Ok(CloseResult {
320            closed: existed,
321            previous_status,
322            message: if existed {
323                "agent closed".to_string()
324            } else {
325                "agent not found".to_string()
326            },
327        })
328    }
329
330    /// List all active sub-agents.
331    ///
332    /// Called by `list_agents` tool.
333    pub fn list_agents(&self) -> Vec<AgentInfo> {
334        let registry = self.registry.lock().unwrap();
335        registry
336            .list()
337            .into_iter()
338            .map(|e| AgentInfo {
339                agent_path: e.path.to_string(),
340                status: format!("{:?}", e.status).to_lowercase(),
341                tool_count: e.tool_count,
342            })
343            .collect()
344    }
345
346    /// Get the mailbox hub (for tools that need it directly).
347    pub fn mailbox(&self) -> &Arc<MailboxHub> {
348        &self.mailbox
349    }
350
351    /// Get reference to the registry.
352    pub fn registry(&self) -> &Mutex<AgentRegistry> {
353        &self.registry
354    }
355
356    /// Cancel all child agents.
357    pub fn cancel_all(&self) {
358        let mut cancels = self.child_cancels.lock().unwrap();
359        for (_, token) in cancels.drain() {
360            token.cancel();
361        }
362    }
363}
364
365impl Drop for MultiAgentRuntime {
366    fn drop(&mut self) {
367        self.cancel_all();
368        // Drain any already-completed join handles to detect panics
369        let mut js = self.join_set.lock().unwrap();
370        while let Some(result) = js.try_join_next() {
371            if let Err(e) = result
372                && e.is_panic()
373            {
374                tracing::error!(
375                    error = %e,
376                    "child agent task panicked"
377                );
378            }
379        }
380    }
381}
382
383impl MultiAgentRuntime {
384    fn parse_path(&self, s: &str) -> Result<AgentPath, String> {
385        AgentPath::parse(s).ok_or_else(|| format!("invalid agent path: '{}'", s))
386    }
387
388    fn build_child_runtime(&self, system_prompt: String) -> AgentResult<AgentRuntime> {
389        let mut builder = AgentBuilder::new(self.client.clone())
390            .system_prompt(system_prompt)
391            .approval_handler(Arc::new(DenyAllApprovalHandler))
392            .language(self.language.clone());
393
394        // Register business tools (NOT multi-agent tools)
395        for tool in &self.business_tools {
396            builder = builder.register_tool_arc(tool.clone());
397        }
398
399        if let Some(ref recovery) = self.error_recovery {
400            builder = builder.error_recovery(recovery.clone());
401        }
402
403        builder.build()
404    }
405}
406
407// ---------------------------------------------------------------------------
408// Result types
409// ---------------------------------------------------------------------------
410
411/// Result from `wait_for_result()`.
412#[derive(Clone, Debug)]
413pub struct WaitResult {
414    pub status: String,
415    pub result: Option<String>,
416    pub agent_path: Option<String>,
417    pub has_more: bool,
418}
419
420/// Result from `close_agent()`.
421#[derive(Clone, Debug)]
422pub struct CloseResult {
423    pub closed: bool,
424    pub previous_status: String,
425    pub message: String,
426}
427
428/// Agent info for `list_agents()`.
429#[derive(Clone, Debug, serde::Serialize)]
430pub struct AgentInfo {
431    pub agent_path: String,
432    pub status: String,
433    pub tool_count: usize,
434}
435
436// ---------------------------------------------------------------------------
437// Child agent event loop
438// ---------------------------------------------------------------------------
439
440/// Run the child agent's main event loop.
441///
442/// This function runs inside a tokio task spawned by [`MultiAgentRuntime::spawn_child`].
443/// It:
444/// 1. Subscribes to child agent events and bridges them to parent
445/// 2. Listens for tasks from the mailbox
446/// 3. Executes each task via `run_turn`
447/// 4. Posts results back via the mailbox
448async fn run_child_loop(
449    child_mailbox: ChildMailbox,
450    child_runtime: AgentRuntime,
451    session_id: SessionId,
452    agent_path: AgentPath,
453    mailbox: Arc<MailboxHub>,
454    event_tx: Option<tokio::sync::mpsc::UnboundedSender<RuntimeEvent>>,
455    child_cancel: CancellationToken,
456) {
457    let mut task_rx = child_mailbox.task_rx;
458
459    // Spawn event bridging: forward child events to parent as SubAgentEvent
460    if let Some(tx) = event_tx {
461        let mut child_events = child_runtime.subscribe_runtime_events();
462        let bridge_path = agent_path.to_string();
463        let bridge_cancel = child_cancel.clone();
464
465        tokio::spawn(async move {
466            loop {
467                tokio::select! {
468                    _ = bridge_cancel.cancelled() => break,
469                    event = child_events.recv() => {
470                        match event {
471                            Ok(event) => {
472                                if matches!(event, RuntimeEvent::RunFinished { .. } | RuntimeEvent::RunCancelled { .. }) {
473                                    continue;
474                                }
475                                let _ = tx.send(RuntimeEvent::UserEvent {
476                                    session_id: SessionId::new(0),
477                                    event: UserEvent::SubAgentEvent {
478                                        subagent: bridge_path.clone(),
479                                        event: Box::new(event),
480                                    },
481                                    agent_id: None,
482                                    trace_id: None,
483                                });
484                            }
485                            Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
486                                tracing::warn!(
487                                    subagent = %bridge_path,
488                                    lagged = n,
489                                    "child event bridge lagged"
490                                );
491                            }
492                            Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
493                        }
494                    }
495                }
496            }
497        });
498    }
499
500    // Main task loop
501    loop {
502        tokio::select! {
503            _ = child_cancel.cancelled() => {
504                break;
505            }
506            task = task_rx.recv() => {
507                match task {
508                    Some(task) => {
509                        let input = build_child_input(&task);
510                        let result = child_runtime.run_turn_collect(
511                            session_id.clone(),
512                            &input,
513                        ).await;
514
515                        match result {
516                            Ok((_events, outcome)) => {
517                                let summary = summarize_outcome(&outcome);
518                                mailbox.post_result(MailboxResult {
519                                    agent_path: agent_path.clone(),
520                                    status: MailboxStatus::Ok,
521                                    result: Some(summary),
522                                });
523                            }
524                            Err(e) => {
525                                mailbox.post_result(MailboxResult {
526                                    agent_path: agent_path.clone(),
527                                    status: MailboxStatus::Error,
528                                    result: Some(e.to_string()),
529                                });
530                            }
531                        }
532                    }
533                    None => break, // task channel closed
534                }
535            }
536        }
537    }
538}
539
540/// Build the input text for a child agent from a mailbox task.
541fn build_child_input(task: &MailboxTask) -> String {
542    if task.pending_messages.is_empty() {
543        task.task.clone()
544    } else {
545        let mut parts: Vec<String> = Vec::new();
546        for msg in &task.pending_messages {
547            parts.push(format!("[Message]: {}", msg));
548        }
549        parts.push(format!("[Task]: {}", task.task));
550        parts.join("\n\n")
551    }
552}
553
554/// Extract a human-readable summary from a run outcome.
555fn summarize_outcome(outcome: &RunOutcome) -> String {
556    match outcome {
557        RunOutcome::Completed => "task completed".to_string(),
558        RunOutcome::Failed { error } => format!("task failed: {}", error),
559        RunOutcome::MaxTurnsExceeded { turns } => {
560            format!("max turns exceeded ({} turns)", turns)
561        }
562        RunOutcome::Cancelled => "cancelled".to_string(),
563    }
564}
565
566// ---------------------------------------------------------------------------
567// Tests
568// ---------------------------------------------------------------------------
569
570#[cfg(test)]
571mod tests {
572    use super::*;
573    use agent_base::RunOutcome;
574
575    // ── summarize_outcome ──
576
577    #[test]
578    fn test_summarize_completed() {
579        let s = summarize_outcome(&RunOutcome::Completed);
580        assert_eq!(s, "task completed");
581    }
582
583    #[test]
584    fn test_summarize_failed() {
585        let outcome = RunOutcome::Failed {
586            error: "connection refused".to_string(),
587        };
588        let s = summarize_outcome(&outcome);
589        assert_eq!(s, "task failed: connection refused");
590    }
591
592    #[test]
593    fn test_summarize_max_turns() {
594        let outcome = RunOutcome::MaxTurnsExceeded { turns: 42 };
595        let s = summarize_outcome(&outcome);
596        assert!(s.contains("max turns exceeded"));
597        assert!(s.contains("42"));
598    }
599
600    #[test]
601    fn test_summarize_cancelled() {
602        let s = summarize_outcome(&RunOutcome::Cancelled);
603        assert_eq!(s, "cancelled");
604    }
605
606    // ── build_child_input ──
607
608    #[test]
609    fn test_build_child_input_task_only() {
610        let task = MailboxTask {
611            task: "do work".into(),
612            interrupt: true,
613            pending_messages: vec![],
614        };
615        let out = build_child_input(&task);
616        assert_eq!(out, "do work");
617    }
618
619    #[test]
620    fn test_build_child_input_with_pending_messages() {
621        let task = MailboxTask {
622            task: "do work".into(),
623            interrupt: false,
624            pending_messages: vec!["context 1".into(), "context 2".into()],
625        };
626        let out = build_child_input(&task);
627        assert!(out.contains("[Message]: context 1"));
628        assert!(out.contains("[Message]: context 2"));
629        assert!(out.contains("[Task]: do work"));
630        // Messages come before task
631        let msg_pos = out.find("[Message]:").unwrap();
632        let task_pos = out.find("[Task]:").unwrap();
633        assert!(msg_pos < task_pos, "messages should precede task");
634    }
635
636    #[test]
637    fn test_build_child_input_single_message() {
638        let task = MailboxTask {
639            task: "final task".into(),
640            interrupt: true,
641            pending_messages: vec!["hint".into()],
642        };
643        let out = build_child_input(&task);
644        assert_eq!(out, "[Message]: hint\n\n[Task]: final task");
645    }
646}