1use std::sync::atomic::{AtomicUsize, Ordering};
18use std::sync::Arc;
19
20use async_trait::async_trait;
21use serde_json::Value;
22use tokio_util::sync::CancellationToken;
23use uuid::Uuid;
24
25use crate::error::{CoreError, Result as CoreResult};
26use crate::event::EventSink;
27use crate::message::{AgentMessage, ContentBlock, Role};
28use crate::model::{Model, ModelProvider};
29use crate::policy::ToolPolicy;
30use crate::runner::{run_agent, RunConfig, RunOutcome};
31use crate::tool::{InvokeContext, Tool, ToolDefinition, ToolResult};
32
33pub const DEFAULT_MAX_DEPTH: usize = 5;
37
38pub const DEFAULT_MAX_DELEGATIONS: usize = 64;
45
46#[derive(Clone)]
53pub struct SubagentProfile {
54 pub name: String,
56 pub description: String,
58 pub instructions: String,
60 pub model: Option<Model>,
62 pub config: Option<RunConfig>,
64 pub tools: Vec<Arc<dyn Tool>>,
66 pub subagents: Vec<SubagentProfile>,
69}
70
71impl SubagentProfile {
72 #[must_use]
77 pub fn new(name: impl Into<String>, instructions: impl Into<String>) -> Self {
78 Self {
79 name: name.into(),
80 description: String::new(),
83 instructions: instructions.into(),
84 model: None,
85 config: None,
86 tools: Vec::new(),
87 subagents: Vec::new(),
88 }
89 }
90
91 #[must_use]
93 pub fn with_description(mut self, description: impl Into<String>) -> Self {
94 self.description = description.into();
95 self
96 }
97
98 #[must_use]
100 pub fn with_model(mut self, model: Model) -> Self {
101 self.model = Some(model);
102 self
103 }
104
105 #[must_use]
107 pub fn with_config(mut self, config: RunConfig) -> Self {
108 self.config = Some(config);
109 self
110 }
111
112 #[must_use]
114 pub fn with_tool(mut self, tool: Arc<dyn Tool>) -> Self {
115 self.tools.push(tool);
116 self
117 }
118
119 #[must_use]
121 pub fn with_subagent(mut self, subagent: SubagentProfile) -> Self {
122 self.subagents.push(subagent);
123 self
124 }
125}
126
127#[derive(Clone, Copy, Debug)]
129pub struct SubagentOptions {
130 pub max_depth: usize,
135 pub max_delegations: usize,
141}
142
143impl Default for SubagentOptions {
144 fn default() -> Self {
145 Self {
146 max_depth: DEFAULT_MAX_DEPTH,
147 max_delegations: DEFAULT_MAX_DELEGATIONS,
148 }
149 }
150}
151
152pub struct TaskTool {
164 provider: Arc<dyn ModelProvider>,
166 parent_model: Model,
168 parent_config: RunConfig,
170 subagents: Vec<SubagentProfile>,
172 max_depth: usize,
174 depth: usize,
176 cancel: CancellationToken,
178 event_sink: Option<Arc<dyn EventSink>>,
181 policy: Option<Arc<dyn ToolPolicy>>,
189 remaining_delegations: Arc<AtomicUsize>,
192}
193
194impl TaskTool {
195 #[allow(clippy::too_many_arguments)]
203 #[must_use]
204 pub fn new(
205 provider: Arc<dyn ModelProvider>,
206 parent_model: Model,
207 parent_config: RunConfig,
208 subagents: Vec<SubagentProfile>,
209 options: SubagentOptions,
210 cancel: CancellationToken,
211 event_sink: Option<Arc<dyn EventSink>>,
212 policy: Option<Arc<dyn ToolPolicy>>,
213 ) -> Self {
214 Self {
215 provider,
216 parent_model,
217 parent_config,
218 subagents,
219 max_depth: options.max_depth,
220 depth: 0,
221 cancel,
222 event_sink,
223 policy,
224 remaining_delegations: Arc::new(AtomicUsize::new(options.max_delegations)),
225 }
226 }
227
228 fn child(
230 &self,
231 subagents: Vec<SubagentProfile>,
232 parent_model: Model,
233 parent_config: RunConfig,
234 ) -> Self {
235 Self {
236 provider: Arc::clone(&self.provider),
237 parent_model,
238 parent_config,
239 subagents,
240 max_depth: self.max_depth,
241 depth: self.depth + 1,
242 cancel: self.cancel.clone(),
243 event_sink: self.event_sink.as_ref().map(Arc::clone),
244 policy: self.policy.as_ref().map(Arc::clone),
246 remaining_delegations: Arc::clone(&self.remaining_delegations),
248 }
249 }
250
251 fn resolve(&self, name: &str) -> Option<&SubagentProfile> {
253 self.subagents.iter().find(|s| s.name == name)
254 }
255
256 fn max_delegations_hint(&self) -> usize {
261 self.remaining_delegations.load(Ordering::Relaxed) + 1
264 }
265
266 async fn delegate(&self, profile: &SubagentProfile, prompt: String) -> CoreResult<RunOutcome> {
268 let child_model = profile
270 .model
271 .clone()
272 .unwrap_or_else(|| self.parent_model.clone());
273 let child_config = profile
274 .config
275 .clone()
276 .unwrap_or_else(|| self.parent_config.clone());
277
278 let mut child_tools: Vec<Arc<dyn Tool>> = Vec::new();
285 if !profile.subagents.is_empty() {
286 let child_task = self.child(
287 profile.subagents.clone(),
288 child_model.clone(),
291 child_config.clone(),
292 );
293 child_tools.push(Arc::new(child_task));
294 }
295 child_tools.extend(profile.tools.clone());
296
297 let child_session = Uuid::new_v4();
299 let mut child_messages = vec![
300 AgentMessage {
301 role: Role::System,
302 content: vec![ContentBlock::Text {
303 text: profile.instructions.clone(),
304 }],
305 },
306 AgentMessage {
307 role: Role::User,
308 content: vec![ContentBlock::Text { text: prompt }],
309 },
310 ];
311
312 let child_hooks = crate::event::RunHooks {
317 session_id: Some(child_session),
318 turn_sink: None,
319 event_sink: self.event_sink.as_deref(),
320 policy: self.policy.as_deref(),
321 };
322
323 run_agent(
327 self.provider.as_ref(),
328 &child_tools,
329 &mut child_messages,
330 &child_model,
331 &child_config,
332 &self.cancel,
333 &child_hooks,
334 )
335 .await
336 }
337}
338
339#[async_trait]
340impl Tool for TaskTool {
341 fn definition(&self) -> ToolDefinition {
342 let mut desc = String::from(
343 "Delegate a focused subtask to a named subagent. The subagent runs \
344 in a fresh context and its answer is returned to you. Call this \
345 only when a declared subagent is well-suited to the work. \
346 Available subagents:",
347 );
348 if self.subagents.is_empty() {
349 desc.push_str(" (none declared)");
350 } else {
351 for s in &self.subagents {
352 let guidance = if s.description.trim().is_empty() {
353 "(no description provided)"
354 } else {
355 s.description.trim()
356 };
357 desc.push_str(&format!("\n - \"{}\": {}", s.name, guidance));
358 }
359 }
360
361 let mut fields = serde_json::Map::new();
363 fields.insert("type".into(), Value::String("object".into()));
364 fields.insert(
365 "properties".into(),
366 serde_json::json!({
367 "agent": {
368 "type": "string",
369 "description": "The name of the declared subagent to delegate to."
370 },
371 "prompt": {
372 "type": "string",
373 "description": "The task to give the subagent (it sees this, not your conversation history)."
374 }
375 }),
376 );
377 fields.insert(
378 "required".into(),
379 Value::Array(vec![
380 Value::String("agent".into()),
381 Value::String("prompt".into()),
382 ]),
383 );
384
385 ToolDefinition {
386 name: "task".into(),
387 label: "Task".into(),
388 description: desc,
389 parameters: crate::tool::ParameterSchema {
390 fields: fields.into_iter().collect(),
391 },
392 }
393 }
394
395 async fn execute(&self, ctx: InvokeContext, input: Value) -> CoreResult<ToolResult> {
396 let obj = input.as_object().ok_or_else(|| {
398 CoreError::ToolInputValidation("task tool expects an object input".into())
399 })?;
400 let agent = obj.get("agent").and_then(Value::as_str).ok_or_else(|| {
401 CoreError::ToolInputValidation("task tool requires a string `agent`".into())
402 })?;
403 let prompt = obj.get("prompt").and_then(Value::as_str).ok_or_else(|| {
404 CoreError::ToolInputValidation("task tool requires a string `prompt`".into())
405 })?;
406
407 let profile = match self.resolve(agent) {
409 Some(p) => p,
410 None => {
411 let known: Vec<&str> = self.subagents.iter().map(|s| s.name.as_str()).collect();
412 return Err(CoreError::ToolInputValidation(format!(
413 "subagent not declared: \"{agent}\" (known: {})",
414 known.join(", ")
415 )));
416 }
417 };
418
419 let prev = self.remaining_delegations.fetch_sub(1, Ordering::Relaxed);
424 if prev == 0 {
425 self.remaining_delegations.fetch_add(1, Ordering::Relaxed);
427 return Err(CoreError::ToolInputValidation(format!(
428 "delegation budget exhausted (max {} total delegations across the tree)",
429 self.max_delegations_hint()
430 )));
431 }
432
433 if self.depth >= self.max_depth {
435 self.remaining_delegations.fetch_add(1, Ordering::Relaxed);
438 return Err(CoreError::ToolInputValidation(format!(
439 "delegation depth exceeded (depth {} >= max_depth {})",
440 self.depth, self.max_depth
441 )));
442 }
443
444 if ctx.cancel.is_cancelled() {
447 return Err(CoreError::Cancelled("task delegation cancelled".into()));
448 }
449
450 let outcome = self.delegate(profile, prompt.to_string()).await?;
454 Ok(ToolResult {
455 content: vec![serde_json::json!({
456 "type": "text",
457 "text": if outcome.final_text.trim().is_empty() {
458 "(subagent returned no text)".to_string()
459 } else {
460 outcome.final_text
461 },
462 })],
463 details: None,
464 })
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use crate::model::{ModelRequest, ModelResponse};
472
473 fn dummy_profile(name: &str) -> SubagentProfile {
474 SubagentProfile::new(name, "you are a helper")
475 }
476
477 fn top_level_tool(profiles: Vec<SubagentProfile>, max_depth: usize) -> TaskTool {
478 TaskTool::new(
479 test_provider(),
484 Model {
485 id: "test/model".into(),
486 },
487 RunConfig::default(),
488 profiles,
489 SubagentOptions {
490 max_depth,
491 max_delegations: DEFAULT_MAX_DELEGATIONS,
492 },
493 CancellationToken::new(),
494 None,
495 None,
496 )
497 }
498
499 fn test_provider() -> Arc<dyn ModelProvider> {
501 use async_trait::async_trait;
502 struct TestProvider;
503 #[async_trait]
504 impl ModelProvider for TestProvider {
505 async fn invoke(
506 &self,
507 _request: crate::model::ModelRequest,
508 ) -> CoreResult<crate::model::ModelResponse> {
509 Ok(crate::model::ModelResponse {
511 messages: vec![crate::message::AgentMessage {
512 role: crate::message::Role::Assistant,
513 content: vec![crate::message::ContentBlock::Text {
514 text: "child done".into(),
515 }],
516 }],
517 })
518 }
519 }
520 Arc::new(TestProvider)
521 }
522
523 #[test]
524 fn definition_lists_declared_subagents() {
525 let profiles = vec![
526 dummy_profile("reviewer").with_description("Review changes."),
527 dummy_profile("classifier").with_description("Classify issues."),
528 ];
529 let tool = top_level_tool(profiles, DEFAULT_MAX_DEPTH);
530 let def = tool.definition();
531 assert_eq!(def.name, "task");
532 assert_eq!(def.label, "Task");
533 assert!(def.description.contains("\"reviewer\""), "missing reviewer");
534 assert!(def.description.contains("Review changes."));
535 assert!(def.description.contains("\"classifier\""));
536 assert!(def.description.contains("Classify issues."));
537 }
538
539 #[test]
540 fn definition_handles_no_subagents() {
541 let tool = top_level_tool(vec![], DEFAULT_MAX_DEPTH);
542 let def = tool.definition();
543 assert!(def.description.contains("(none declared)"));
544 }
545
546 #[test]
547 fn definition_schema_requires_agent_and_prompt() {
548 let tool = top_level_tool(vec![dummy_profile("a")], DEFAULT_MAX_DEPTH);
549 let def = tool.definition();
550 let required = def
551 .parameters
552 .fields
553 .get("required")
554 .and_then(|v| v.as_array())
555 .expect("required array");
556 let names: Vec<&str> = required.iter().filter_map(Value::as_str).collect();
557 assert!(names.contains(&"agent"));
558 assert!(names.contains(&"prompt"));
559 }
560
561 #[tokio::test]
562 async fn unknown_agent_returns_error() {
563 let tool = top_level_tool(vec![dummy_profile("reviewer")], DEFAULT_MAX_DEPTH);
564 let ctx = InvokeContext {
565 tool_call_id: "c1".into(),
566 cancel: CancellationToken::new(),
567 };
568 let err = tool
569 .execute(ctx, serde_json::json!({ "agent": "ghost", "prompt": "hi" }))
570 .await
571 .expect_err("unknown agent should error");
572 let msg = err.to_string();
573 assert!(msg.contains("not declared"), "msg: {msg}");
574 assert!(msg.contains("ghost"));
575 assert!(msg.contains("reviewer"));
577 }
578
579 #[tokio::test]
580 async fn depth_exceeded_at_max_zero() {
581 let tool = top_level_tool(vec![dummy_profile("a")], 0);
583 let ctx = InvokeContext {
584 tool_call_id: "c2".into(),
585 cancel: CancellationToken::new(),
586 };
587 let err = tool
588 .execute(ctx, serde_json::json!({ "agent": "a", "prompt": "hi" }))
589 .await
590 .expect_err("depth should exceed");
591 let msg = err.to_string();
592 assert!(msg.contains("depth exceeded"), "msg: {msg}");
593 assert!(msg.contains("max_depth 0"));
594 }
595
596 #[tokio::test]
597 async fn budget_exhaustion_blocks_delegation() {
598 let tool = TaskTool::new(
601 test_provider(),
602 Model {
603 id: "test/model".into(),
604 },
605 RunConfig::default(),
606 vec![dummy_profile("worker")],
607 SubagentOptions {
608 max_depth: DEFAULT_MAX_DEPTH,
609 max_delegations: 1,
610 },
611 CancellationToken::new(),
612 None,
613 None,
614 );
615 let ctx1 = InvokeContext {
616 tool_call_id: "b1".into(),
617 cancel: CancellationToken::new(),
618 };
619 let r1 = tool
621 .execute(
622 ctx1,
623 serde_json::json!({ "agent": "worker", "prompt": "go" }),
624 )
625 .await
626 .expect("first delegation succeeds");
627 assert_eq!(r1.content.len(), 1);
628
629 let ctx2 = InvokeContext {
631 tool_call_id: "b2".into(),
632 cancel: CancellationToken::new(),
633 };
634 let err = tool
635 .execute(
636 ctx2,
637 serde_json::json!({ "agent": "worker", "prompt": "again" }),
638 )
639 .await
640 .expect_err("budget should be exhausted");
641 let msg = err.to_string();
642 assert!(msg.contains("budget exhausted"), "msg: {msg}");
643 }
644
645 #[tokio::test]
646 async fn delegate_runs_child_and_returns_text() {
647 let tool = top_level_tool(vec![dummy_profile("worker")], DEFAULT_MAX_DEPTH);
648 let ctx = InvokeContext {
649 tool_call_id: "c3".into(),
650 cancel: CancellationToken::new(),
651 };
652 let result = tool
653 .execute(
654 ctx,
655 serde_json::json!({ "agent": "worker", "prompt": "do it" }),
656 )
657 .await
658 .expect("delegation should succeed");
659 assert_eq!(result.content.len(), 1);
660 let text = result.content[0]
661 .get("text")
662 .and_then(Value::as_str)
663 .expect("text");
664 assert_eq!(text, "child done");
665 }
666
667 #[tokio::test]
668 async fn cancellation_aborts_before_child_spawn() {
669 let tool = top_level_tool(vec![dummy_profile("a")], DEFAULT_MAX_DEPTH);
670 let cancel = CancellationToken::new();
671 let ctx = InvokeContext {
672 tool_call_id: "c4".into(),
673 cancel: cancel.clone(),
674 };
675 cancel.cancel();
676 let err = tool
677 .execute(ctx, serde_json::json!({ "agent": "a", "prompt": "hi" }))
678 .await
679 .expect_err("should be cancelled");
680 assert!(matches!(err, CoreError::Cancelled(_)), "err: {err}");
681 }
682
683 struct ScriptedProvider {
687 responses: std::sync::Mutex<std::collections::VecDeque<ModelResponse>>,
688 }
689
690 impl ScriptedProvider {
691 fn new(responses: Vec<Vec<AgentMessage>>) -> Self {
692 let responses = responses
693 .into_iter()
694 .map(|msgs| ModelResponse { messages: msgs })
695 .collect();
696 Self {
697 responses: std::sync::Mutex::new(responses),
698 }
699 }
700 }
701
702 #[async_trait]
703 impl ModelProvider for ScriptedProvider {
704 async fn invoke(&self, _request: ModelRequest) -> CoreResult<ModelResponse> {
705 let next = self
706 .responses
707 .lock()
708 .unwrap()
709 .pop_front()
710 .unwrap_or(ModelResponse { messages: vec![] });
711 Ok(next)
712 }
713 }
714
715 fn assistant_text(t: &str) -> AgentMessage {
716 AgentMessage {
717 role: Role::Assistant,
718 content: vec![ContentBlock::Text { text: t.into() }],
719 }
720 }
721
722 fn parent_task_call(agent: &str, prompt: &str) -> AgentMessage {
724 AgentMessage {
725 role: Role::Assistant,
726 content: vec![ContentBlock::ToolUse {
727 id: "call_1".into(),
728 call: crate::tool::ToolCall {
729 name: "task".into(),
730 input: serde_json::json!({ "agent": agent, "prompt": prompt }),
731 },
732 }],
733 }
734 }
735
736 #[tokio::test]
737 async fn integration_parent_delegates_and_child_answers() {
738 let provider: Arc<dyn ModelProvider> = Arc::new(ScriptedProvider::new(vec![
741 vec![parent_task_call("worker", "do the work")],
743 vec![assistant_text("child done")],
745 vec![assistant_text("got: child done")],
748 ]));
749 let cancel = CancellationToken::new();
750 let task = Arc::new(TaskTool::new(
751 Arc::clone(&provider),
752 Model {
753 id: "test/m".into(),
754 },
755 RunConfig::default(),
756 vec![dummy_profile("worker")],
757 SubagentOptions::default(),
758 cancel.clone(),
759 None,
760 None,
761 ));
762 let tools: Vec<Arc<dyn Tool>> = vec![task];
763 let mut messages = vec![
764 AgentMessage {
765 role: Role::System,
766 content: vec![ContentBlock::Text {
767 text: "be brief".into(),
768 }],
769 },
770 AgentMessage {
771 role: Role::User,
772 content: vec![ContentBlock::Text {
773 text: "delegate the work".into(),
774 }],
775 },
776 ];
777 let outcome = run_agent(
778 provider.as_ref(),
779 &tools,
780 &mut messages,
781 &Model {
782 id: "test/m".into(),
783 },
784 &RunConfig::default(),
785 &cancel,
786 &crate::event::RunHooks::default(),
787 )
788 .await
789 .expect("parent run");
790 assert_eq!(outcome.turns, 2);
791 assert_eq!(outcome.final_text, "got: child done");
792 }
793
794 #[tokio::test]
795 async fn integration_nested_delegation_stops_at_max_depth() {
796 let grandchild = dummy_profile("grandchild");
799 let child = SubagentProfile::new("child", "you delegate").with_subagent(grandchild);
800
801 let provider: Arc<dyn ModelProvider> = Arc::new(ScriptedProvider::new(vec![
804 vec![parent_task_call("child", "sub-delegate")],
806 vec![AgentMessage {
808 role: Role::Assistant,
809 content: vec![ContentBlock::ToolUse {
810 id: "cchild".into(),
811 call: crate::tool::ToolCall {
812 name: "task".into(),
813 input: serde_json::json!({
814 "agent": "grandchild",
815 "prompt": "too deep"
816 }),
817 },
818 }],
819 }],
820 vec![assistant_text("grandchild was unreachable")],
823 vec![assistant_text("done")],
825 ]));
826 let cancel = CancellationToken::new();
827 let task = Arc::new(TaskTool::new(
828 Arc::clone(&provider),
829 Model {
830 id: "test/m".into(),
831 },
832 RunConfig::default(),
833 vec![child],
834 SubagentOptions {
836 max_depth: 1,
837 max_delegations: DEFAULT_MAX_DELEGATIONS,
838 },
839 cancel.clone(),
840 None,
841 None,
842 ));
843 let tools: Vec<Arc<dyn Tool>> = vec![task];
844 let mut messages = vec![AgentMessage {
845 role: Role::User,
846 content: vec![ContentBlock::Text { text: "go".into() }],
847 }];
848 let outcome = run_agent(
849 provider.as_ref(),
850 &tools,
851 &mut messages,
852 &Model {
853 id: "test/m".into(),
854 },
855 &RunConfig::default(),
856 &cancel,
857 &crate::event::RunHooks::default(),
858 )
859 .await
860 .expect("parent run");
861 assert_eq!(outcome.turns, 2);
865 }
866
867 struct FlagTool {
871 name: String,
872 ran: Arc<std::sync::atomic::AtomicBool>,
873 }
874
875 #[async_trait]
876 impl Tool for FlagTool {
877 fn definition(&self) -> ToolDefinition {
878 ToolDefinition {
879 name: self.name.clone(),
880 label: self.name.clone(),
881 description: "records execution".into(),
882 parameters: crate::tool::ParameterSchema {
883 fields: std::collections::BTreeMap::new(),
884 },
885 }
886 }
887
888 async fn execute(&self, _ctx: InvokeContext, _input: Value) -> CoreResult<ToolResult> {
889 self.ran.store(true, Ordering::SeqCst);
890 Ok(ToolResult {
891 content: vec![serde_json::json!({ "type": "text", "text": "ran" })],
892 details: None,
893 })
894 }
895 }
896
897 struct DenyByName(String);
899
900 #[async_trait]
901 impl ToolPolicy for DenyByName {
902 async fn check(
903 &self,
904 tool: &str,
905 _input: &Value,
906 _ctx: &InvokeContext,
907 ) -> crate::policy::PolicyVerdict {
908 if tool == self.0 {
909 crate::policy::PolicyVerdict::Deny("blocked by test policy".into())
910 } else {
911 crate::policy::PolicyVerdict::Allow
912 }
913 }
914 }
915
916 #[tokio::test]
917 async fn policy_applies_to_delegated_subagent() {
918 let ran = Arc::new(std::sync::atomic::AtomicBool::new(false));
923 let danger = Arc::new(FlagTool {
924 name: "danger".into(),
925 ran: Arc::clone(&ran),
926 });
927 let worker = SubagentProfile::new("worker", "you do work").with_tool(danger);
928
929 let provider: Arc<dyn ModelProvider> = Arc::new(ScriptedProvider::new(vec![
930 vec![parent_task_call("worker", "use the danger tool")],
932 vec![AgentMessage {
934 role: Role::Assistant,
935 content: vec![ContentBlock::ToolUse {
936 id: "cdanger".into(),
937 call: crate::tool::ToolCall {
938 name: "danger".into(),
939 input: serde_json::json!({}),
940 },
941 }],
942 }],
943 vec![assistant_text("could not run danger")],
945 vec![assistant_text("done")],
947 ]));
948
949 let cancel = CancellationToken::new();
950 let policy: Arc<dyn ToolPolicy> = Arc::new(DenyByName("danger".into()));
951 let task = Arc::new(TaskTool::new(
952 Arc::clone(&provider),
953 Model {
954 id: "test/m".into(),
955 },
956 RunConfig::default(),
957 vec![worker],
958 SubagentOptions::default(),
959 cancel.clone(),
960 None,
961 Some(Arc::clone(&policy)),
962 ));
963 let tools: Vec<Arc<dyn Tool>> = vec![task];
964 let mut messages = vec![AgentMessage {
965 role: Role::User,
966 content: vec![ContentBlock::Text {
967 text: "delegate".into(),
968 }],
969 }];
970 let hooks = crate::event::RunHooks {
972 policy: Some(policy.as_ref()),
973 ..crate::event::RunHooks::default()
974 };
975 let outcome = run_agent(
976 provider.as_ref(),
977 &tools,
978 &mut messages,
979 &Model {
980 id: "test/m".into(),
981 },
982 &RunConfig::default(),
983 &cancel,
984 &hooks,
985 )
986 .await
987 .expect("parent run completes");
988
989 assert_eq!(outcome.final_text, "done");
990 assert!(
991 !ran.load(Ordering::SeqCst),
992 "policy must block the subagent's tool — it was inherited, not bypassed"
993 );
994 }
995}