1use 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
23pub struct MultiAgentRuntime {
33 registry: Mutex<AgentRegistry>,
35
36 mailbox: Arc<MailboxHub>,
38
39 client: Arc<dyn StreamClient>,
41
42 business_tools: Vec<Arc<dyn Tool>>,
44
45 child_excluded_tools: Vec<String>,
48
49 child_reasoning_effort: Option<ReasoningEffort>,
52
53 child_read_only: bool,
56
57 event_tx: Mutex<Option<tokio::sync::mpsc::UnboundedSender<RuntimeEvent>>>,
59
60 root_cancel: CancellationToken,
62
63 join_set: Mutex<JoinSet<()>>,
65
66 child_cancels: Mutex<HashMap<AgentPath, CancellationToken>>,
68
69 error_recovery: Option<Arc<dyn agent_base::ToolErrorRecovery>>,
71
72 language: Language,
74
75 child_permission_mode: ChildPermissionMode,
77
78 tool_policy: Option<Arc<dyn ToolPolicy>>,
80
81 approval_handler: Option<Arc<dyn ApprovalHandler>>,
88
89 session_manager: Mutex<Option<Arc<agent_base::engine::SessionManager>>>,
91}
92
93impl MultiAgentRuntime {
94 #[allow(clippy::too_many_arguments)] 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 pub fn set_event_sender(&self, tx: tokio::sync::mpsc::UnboundedSender<RuntimeEvent>) {
137 *self.event_tx.lock().unwrap() = Some(tx);
138 }
139
140 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 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 {
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 let child_mailbox = self
185 .mailbox
186 .register(&path)
187 .ok_or_else(|| "mailbox already exists".to_string())?;
188
189 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 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 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 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 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(®istry_agent_path, AgentStatus::Idle);
248
249 Ok(path.to_string())
250 }
251
252 #[allow(clippy::too_many_arguments)] 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 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 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 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 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 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 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 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 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 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 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 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 pub fn close_agent(&self, agent_path: &str) -> Result<CloseResult, String> {
465 let path = self.parse_path(agent_path)?;
466
467 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 {
478 let mut cancels = self.child_cancels.lock().unwrap();
479 if let Some(token) = cancels.remove(&path) {
480 token.cancel();
481 }
482 }
483
484 let existed = { self.registry.lock().unwrap().close(&path).is_some() };
486
487 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 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 pub fn mailbox(&self) -> &Arc<MailboxHub> {
519 &self.mailbox
520 }
521
522 pub fn registry(&self) -> &Mutex<AgentRegistry> {
524 &self.registry
525 }
526
527 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 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 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 (system_prompt, None, Arc::new(AllowAllApprovalHandler))
586 } else {
587 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 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 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 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 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 }
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 _ => {} }
701 }
702
703 Ok(())
704 }
705}
706
707#[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 pub denied_tools: Vec<String>,
720}
721
722#[derive(Clone, Debug)]
724pub struct CloseResult {
725 pub closed: bool,
726 pub previous_status: String,
727 pub message: String,
728}
729
730#[derive(Clone, Debug, serde::Serialize)]
732pub struct AgentInfo {
733 pub agent_path: String,
734 pub status: String,
735 pub tool_count: usize,
736}
737
738async 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 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 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, }
835 }
836 }
837 }
838}
839
840fn 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
854fn 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
866fn 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
886fn 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
907fn 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#[cfg(test)]
931mod tests {
932 use super::*;
933 use agent_base::RunOutcome;
934
935 #[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 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 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 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 #[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 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 #[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 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 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 let result = ma.resolve_fork_history(None, &parent_sid).await;
1205 assert!(result.is_empty());
1206
1207 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 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 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 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 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 let result = ma
1321 .resolve_fork_history(Some("not-a-number".to_string()), &parent_sid)
1322 .await;
1323 assert!(result.is_empty());
1324
1325 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 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 #[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 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 let session = child_runtime
1412 .session(&child_sid)
1413 .await
1414 .expect("session exists");
1415 let msgs = session.chat_messages().to_vec();
1416
1417 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 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, 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 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 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 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 assert!(
1587 ma.send_message("root/worker", "heads up".to_string())
1588 .unwrap()
1589 );
1590
1591 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 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 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 assert_eq!(
1636 ma.send_task("root/ghost", "x".to_string(), false)
1637 .unwrap_err(),
1638 "agent not found"
1639 );
1640
1641 assert!(ma.send_message("worker", "x".to_string()).is_err());
1643 assert!(ma.send_message("", "x".to_string()).is_err());
1644
1645 let r = ma.wait_for_result(Some("worker"), 10).await;
1647 assert_eq!(r.status, "error");
1648
1649 let r2 = ma.wait_for_result(None, 50).await;
1651 assert_eq!(r2.status, "timeout");
1652
1653 ma.cancel_all();
1655 }
1656
1657 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 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 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 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 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 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 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 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}