1use std::time::Duration;
8
9use leviath_providers::ToolCall;
10use leviath_runtime::components::AgentStatus;
11use leviath_runtime::host::{SubAgentOp, SubAgentReport};
12use tokio::sync::mpsc::UnboundedSender;
13use tokio::sync::oneshot;
14
15use crate::daemon::client::{never_interactive, resolve_spawn_args};
16
17#[derive(Clone)]
21pub struct SubAgentHandle {
22 pub sender: UnboundedSender<SubAgentOp>,
24 pub parent_run_id: String,
26 pub workdir: String,
28 pub max_depth: usize,
30 pub no_seed_commands: bool,
34 pub unattended: bool,
41}
42
43pub use leviath_tools::{SUBAGENT_TOOLS, is_subagent_tool};
47
48const WAIT_POLL: Duration = Duration::from_millis(500);
50
51pub async fn handle(h: &SubAgentHandle, tc: &ToolCall) -> String {
53 match tc.name.as_str() {
54 "spawn_agent" => spawn(h, &tc.arguments).await,
55 "check_agent" => check(h, str_arg(&tc.arguments, "agent_id")).await,
56 "wait_for_agent" => wait(h, str_arg(&tc.arguments, "agent_id")).await,
57 "send_to_agent" => send(h, &tc.arguments).await,
58 "kill_agent" => kill(h, str_arg(&tc.arguments, "agent_id")).await,
59 other => format!("[error] '{other}' is not a sub-agent tool"),
60 }
61}
62
63fn resolves_within_workdir(blueprint: &str, workdir: &str) -> bool {
69 let candidate = std::path::Path::new(blueprint);
70 let workdir = std::path::Path::new(workdir);
71 if !candidate.exists() {
73 let joined = workdir.join(blueprint);
74 return joined.exists() && leviath_core::resolves_within(&joined, workdir);
75 }
76 leviath_core::resolves_within(candidate, workdir)
77}
78
79fn str_arg<'a>(args: &'a serde_json::Value, key: &str) -> &'a str {
81 args.get(key).and_then(|v| v.as_str()).unwrap_or("")
82}
83
84async fn spawn(h: &SubAgentHandle, args: &serde_json::Value) -> String {
85 let blueprint = str_arg(args, "blueprint");
86 let task = str_arg(args, "task");
87 if blueprint.is_empty() || task.is_empty() {
88 return "[error] spawn_agent requires 'blueprint' and 'task'".to_string();
89 }
90 if resolves_within_workdir(blueprint, &h.workdir) {
106 return format!(
107 "[error] '{blueprint}' is inside this agent's own working directory. \
108 Spawn an installed agent by name, or a blueprint from outside the \
109 workspace - an agent may not author the blueprint it runs."
110 );
111 }
112
113 let full_task = match args.get("seed_context").and_then(|v| v.as_str()) {
116 Some(seed) if !seed.is_empty() => format!("{task}\n\nContext:\n{seed}"),
117 _ => task.to_string(),
118 };
119 let child_max_depth = args
120 .get("max_child_depth")
121 .and_then(|v| v.as_u64())
122 .map(|n| n as usize);
123 let wait_flag = args.get("wait").and_then(|v| v.as_bool()).unwrap_or(false);
124 let child_output = {
130 let field = |key: &str| {
131 args.get(key)
132 .and_then(|v| v.as_str())
133 .filter(|s| !s.is_empty())
134 .map(str::to_string)
135 };
136 let (format, instructions) = (field("output_format"), field("output_instructions"));
137 match format.is_none() && instructions.is_none() {
138 true => None,
139 false => Some(leviath_core::output::OutputSpec {
140 format,
141 instructions,
142 example: None,
143 schema: None,
144 validator: None,
145 }),
146 }
147 };
148
149 let spawn_args = match resolve_spawn_args(crate::daemon::client::LaunchRequest {
150 path: blueprint,
151 task: Some(&full_task),
152 stdin_is_terminal: &never_interactive,
153 model: None,
154 workdir: &h.workdir,
155 yolo: h.unattended,
156 allow: Vec::new(),
157 max_depth: child_max_depth,
158 regions: std::collections::HashMap::new(),
160 no_seed_commands: h.no_seed_commands,
161 output_request: child_output,
162 }) {
163 Ok(a) => a,
164 Err(e) => return format!("[error] cannot spawn '{blueprint}': {e}"),
165 };
166
167 let (tx, rx) = oneshot::channel();
168 if h.sender
169 .send(SubAgentOp::Spawn {
170 args: Box::new(spawn_args),
171 parent_run_id: h.parent_run_id.clone(),
172 max_depth: h.max_depth,
173 reply: tx,
174 })
175 .is_err()
176 {
177 return "[error] the daemon is shutting down".to_string();
178 }
179 match rx.await {
180 Ok(Ok(child_id)) if wait_flag => wait(h, &child_id).await,
181 Ok(Ok(child_id)) => format!("Spawned sub-agent '{child_id}'."),
182 Ok(Err(e)) => format!("[error] {e}"),
183 Err(_) => "[error] the daemon dropped the spawn request".to_string(),
184 }
185}
186
187async fn check(h: &SubAgentHandle, agent_id: &str) -> String {
188 match report_of(h, agent_id).await {
189 Some(report) if is_terminal(&report.status) => format!(
193 "Sub-agent '{agent_id}' status: {}{}",
194 label(&report.status),
195 describe_result(&report)
196 ),
197 Some(report) => format!("Sub-agent '{agent_id}' status: {}", label(&report.status)),
198 None => format!("[error] no such sub-agent '{agent_id}'"),
199 }
200}
201
202async fn wait(h: &SubAgentHandle, agent_id: &str) -> String {
203 if agent_id.is_empty() {
204 return "[error] wait_for_agent requires 'agent_id'".to_string();
205 }
206 leviath_runtime::tool_bridge::off_lane(poll_until_finished(h, agent_id)).await
212}
213
214async fn poll_until_finished(h: &SubAgentHandle, agent_id: &str) -> String {
216 loop {
217 match report_of(h, agent_id).await {
218 None => return format!("[error] no such sub-agent '{agent_id}'"),
219 Some(report) if is_terminal(&report.status) => {
220 return format!(
225 "Sub-agent '{agent_id}' finished with status: {}{}",
226 label(&report.status),
227 describe_result(&report)
228 );
229 }
230 Some(_) if caller_is_terminal(h).await => {
235 return format!("[error] cancelled while waiting for '{agent_id}'");
236 }
237 Some(_) => tokio::time::sleep(WAIT_POLL).await,
238 }
239 }
240}
241
242async fn caller_is_terminal(h: &SubAgentHandle) -> bool {
246 match status_of(h, &h.parent_run_id).await {
247 Some(status) => is_terminal(&status),
248 None => true,
249 }
250}
251
252async fn send(h: &SubAgentHandle, args: &serde_json::Value) -> String {
253 let agent_id = str_arg(args, "agent_id");
254 let message = str_arg(args, "message");
255 if agent_id.is_empty() || message.is_empty() {
256 return "[error] send_to_agent requires 'agent_id' and 'message'".to_string();
257 }
258 let target_region = Some(str_arg(args, "target_region"))
261 .filter(|s| !s.is_empty())
262 .map(str::to_string);
263 let (tx, rx) = oneshot::channel();
264 if h.sender
265 .send(SubAgentOp::Send {
266 run_id: agent_id.to_string(),
267 caller_run_id: h.parent_run_id.clone(),
268 content: message.to_string(),
269 target_region,
270 reply: tx,
271 })
272 .is_err()
273 {
274 return "[error] the daemon is shutting down".to_string();
275 }
276 match rx.await {
277 Ok(true) => format!("Delivered message to '{agent_id}'."),
278 Ok(false) => format!(
279 "[error] '{agent_id}' did not accept the message. An agent may only \
280 message itself or an agent it spawned."
281 ),
282 Err(_) => "[error] the daemon dropped the message".to_string(),
283 }
284}
285
286async fn kill(h: &SubAgentHandle, agent_id: &str) -> String {
287 if agent_id.is_empty() {
288 return "[error] kill_agent requires 'agent_id'".to_string();
289 }
290 let (tx, rx) = oneshot::channel();
291 if h.sender
292 .send(SubAgentOp::Kill {
293 run_id: agent_id.to_string(),
294 caller_run_id: h.parent_run_id.clone(),
295 reply: tx,
296 })
297 .is_err()
298 {
299 return "[error] the daemon is shutting down".to_string();
300 }
301 match rx.await {
302 Ok(true) => format!("Killed sub-agent '{agent_id}' and its descendants."),
303 Ok(false) => format!("[error] no such sub-agent '{agent_id}'"),
304 Err(_) => "[error] the daemon dropped the kill request".to_string(),
305 }
306}
307
308async fn report_of(h: &SubAgentHandle, agent_id: &str) -> Option<SubAgentReport> {
311 let (tx, rx) = oneshot::channel();
312 h.sender
313 .send(SubAgentOp::Check {
314 run_id: agent_id.to_string(),
315 reply: tx,
316 })
317 .ok()?;
318 rx.await.ok().flatten()
319}
320
321async fn status_of(h: &SubAgentHandle, agent_id: &str) -> Option<AgentStatus> {
324 report_of(h, agent_id).await.map(|r| r.status)
325}
326
327fn describe_result(report: &SubAgentReport) -> String {
333 match &report.final_output {
334 Some(output) => {
335 let shape = output
336 .format
337 .as_deref()
338 .map(|f| format!(" ({f})"))
339 .unwrap_or_default();
340 let truncated = match output.truncated {
341 true => "\n[the agent's output was truncated at the size limit]",
342 false => "",
343 };
344 format!(
345 "\n\n--- final output{shape} ---\n{}{truncated}",
346 output.content
347 )
348 }
349 None => "\n\n[this agent produced no final output]".to_string(),
350 }
351}
352
353fn is_terminal(status: &AgentStatus) -> bool {
354 matches!(
355 status,
356 AgentStatus::Complete | AgentStatus::Cancelled | AgentStatus::Error { .. }
357 )
358}
359
360fn label(status: &AgentStatus) -> String {
364 status.to_string()
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370
371 #[tokio::test]
376 async fn spawn_refuses_a_blueprint_the_agent_could_have_written() {
377 let work = tempfile::tempdir().unwrap();
378 let planted = work.path().join("x");
380 std::fs::create_dir(&planted).unwrap();
381 std::fs::write(planted.join("agent.leviath"), "[agent]\nname = \"x\"\n").unwrap();
382
383 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
384 let h = SubAgentHandle {
385 sender: tx,
386 parent_run_id: "parent".to_string(),
387 workdir: work.path().to_string_lossy().to_string(),
388 max_depth: 3,
389 no_seed_commands: false,
390 unattended: false,
391 };
392
393 for bad in [
394 planted.to_string_lossy().to_string(),
395 "x".to_string(),
396 "x/agent.leviath".to_string(),
397 ] {
398 let out = spawn(&h, &serde_json::json!({"blueprint": bad, "task": "go"})).await;
399 assert!(
400 out.contains("own working directory"),
401 "{bad} must be refused: {out}"
402 );
403 }
404 }
405
406 #[tokio::test]
409 async fn spawn_allows_a_blueprint_outside_the_workdir() {
410 let work = tempfile::tempdir().unwrap();
411 let elsewhere = tempfile::tempdir().unwrap();
412 std::fs::write(
413 elsewhere.path().join("agent.leviath"),
414 "[agent]\nname = \"x\"\n",
415 )
416 .unwrap();
417
418 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
422 drop(rx);
423 let h = SubAgentHandle {
424 sender: tx,
425 parent_run_id: "parent".to_string(),
426 workdir: work.path().to_string_lossy().to_string(),
427 max_depth: 3,
428 no_seed_commands: false,
429 unattended: false,
430 };
431 let out = spawn(
432 &h,
433 &serde_json::json!({
434 "blueprint": elsewhere.path().to_string_lossy(),
435 "task": "go"
436 }),
437 )
438 .await;
439 assert!(
440 !out.contains("own working directory"),
441 "a blueprint outside the workspace must not be refused: {out}"
442 );
443 }
444 use leviath_runtime::host::SpawnArgs;
445 use serde_json::json;
446
447 fn handle_with(sender: UnboundedSender<SubAgentOp>) -> SubAgentHandle {
448 SubAgentHandle {
449 sender,
450 parent_run_id: "parent".to_string(),
451 workdir: env!("CARGO_MANIFEST_DIR").to_string(),
458 max_depth: 3,
459 no_seed_commands: false,
460 unattended: false,
461 }
462 }
463
464 fn fake_host(
472 spawn_result: Result<String, String>,
473 statuses: Vec<Option<AgentStatus>>,
474 ok: bool,
475 ) -> (
476 SubAgentHandle,
477 std::sync::Arc<std::sync::Mutex<Vec<SpawnArgs>>>,
478 tokio::task::JoinHandle<()>,
479 ) {
480 fake_host_with_parent(spawn_result, statuses, ok, Some(AgentStatus::Active))
481 }
482
483 fn fake_host_with_output(
486 statuses: Vec<Option<AgentStatus>>,
487 output: Option<leviath_core::output::FinalOutput>,
488 ) -> (
489 SubAgentHandle,
490 std::sync::Arc<std::sync::Mutex<Vec<SpawnArgs>>>,
491 tokio::task::JoinHandle<()>,
492 ) {
493 fake_host_full(
494 Ok("child-1".to_string()),
495 statuses,
496 false,
497 Some(AgentStatus::Active),
498 output,
499 )
500 }
501
502 fn fake_host_with_parent(
504 spawn_result: Result<String, String>,
505 statuses: Vec<Option<AgentStatus>>,
506 ok: bool,
507 parent_status: Option<AgentStatus>,
508 ) -> (
509 SubAgentHandle,
510 std::sync::Arc<std::sync::Mutex<Vec<SpawnArgs>>>,
511 tokio::task::JoinHandle<()>,
512 ) {
513 fake_host_full(spawn_result, statuses, ok, parent_status, None)
514 }
515
516 fn fake_host_full(
518 spawn_result: Result<String, String>,
519 statuses: Vec<Option<AgentStatus>>,
520 ok: bool,
521 parent_status: Option<AgentStatus>,
522 child_output: Option<leviath_core::output::FinalOutput>,
523 ) -> (
524 SubAgentHandle,
525 std::sync::Arc<std::sync::Mutex<Vec<SpawnArgs>>>,
526 tokio::task::JoinHandle<()>,
527 ) {
528 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
529 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
530 let seen_task = seen.clone();
531 let task = tokio::spawn(async move {
532 let mut checks = statuses.into_iter();
533 while let Some(op) = rx.recv().await {
534 match op {
535 SubAgentOp::Spawn { reply, args, .. } => {
536 seen_task.lock().unwrap().push(*args);
537 let _ = reply.send(spawn_result.clone());
538 }
539 SubAgentOp::Check { reply, run_id } if run_id == "parent" => {
544 let _ = reply.send(parent_status.clone().map(|status| SubAgentReport {
545 status,
546 final_output: None,
547 }));
548 }
549 SubAgentOp::Check { reply, .. } => {
550 let _ = reply.send(checks.next().flatten().map(|status| SubAgentReport {
551 status,
552 final_output: child_output.clone(),
553 }));
554 }
555 SubAgentOp::Send { reply, .. } => {
556 let _ = reply.send(ok);
557 }
558 SubAgentOp::Kill { reply, .. } => {
559 let _ = reply.send(ok);
560 }
561 }
562 }
563 });
564 (handle_with(tx), seen, task)
565 }
566
567 fn drop_host() -> (SubAgentHandle, tokio::task::JoinHandle<()>) {
570 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
571 let task = tokio::spawn(async move {
572 while let Some(op) = rx.recv().await {
573 drop(op);
574 }
575 });
576 (handle_with(tx), task)
577 }
578
579 fn dead_handle() -> SubAgentHandle {
581 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
582 handle_with(tx)
583 }
584
585 fn temp_blueprint() -> tempfile::TempDir {
588 let dir = tempfile::tempdir().unwrap();
589 std::fs::write(
590 dir.path().join("agent.leviath"),
591 r#"
592[agent]
593name = "child"
594version = "0.1.0"
595description = "child"
596
597[stages.main]
598model = { provider = "anthropic", model = "claude-sonnet-4-6" }
599
600# Every caller here spawns the child with a task, and a child with nowhere to
601# put one is refused - which is the point: a sub-agent that silently discards
602# its parent's instructions is the failure this fixture would otherwise model.
603[context.regions]
604task = { kind = "pinned", max_tokens = 1000 }
605"#,
606 )
607 .unwrap();
608 dir
609 }
610
611 fn tc(name: &str, args: serde_json::Value) -> ToolCall {
612 ToolCall {
613 id: "1".to_string(),
614 name: name.to_string(),
615 arguments: args,
616 thought_signature: None,
617 }
618 }
619
620 #[test]
621 fn is_subagent_tool_recognizes_the_five_names() {
622 for name in SUBAGENT_TOOLS {
623 assert!(is_subagent_tool(name));
624 }
625 assert!(!is_subagent_tool("read_file"));
626 }
627
628 #[test]
629 fn label_and_terminal_cover_all_statuses() {
630 assert_eq!(label(&AgentStatus::Idle), "idle");
631 assert_eq!(label(&AgentStatus::Active), "active");
632 assert_eq!(label(&AgentStatus::Paused), "paused");
633 assert_eq!(label(&AgentStatus::Waiting), "waiting");
634 assert_eq!(label(&AgentStatus::Complete), "complete");
635 assert_eq!(label(&AgentStatus::Cancelled), "cancelled");
636 assert_eq!(
637 label(&AgentStatus::Error {
638 message: "boom".to_string()
639 }),
640 "error: boom"
641 );
642 for s in [AgentStatus::Active, AgentStatus::Waiting, AgentStatus::Idle] {
643 assert!(!is_terminal(&s));
644 }
645 for s in [
646 AgentStatus::Complete,
647 AgentStatus::Cancelled,
648 AgentStatus::Error {
649 message: "x".to_string(),
650 },
651 ] {
652 assert!(is_terminal(&s));
653 }
654 }
655
656 #[tokio::test]
657 async fn spawn_resolves_blueprint_forwards_seed_and_reports_the_child_id() {
658 let bp = temp_blueprint();
659 let (h, seen, t) = fake_host(Ok("child-123".to_string()), vec![], false);
660 let out = handle(
661 &h,
662 &tc(
663 "spawn_agent",
664 json!({
665 "blueprint": bp.path().to_str().unwrap(),
666 "task": "do it",
667 "seed_context": "prior findings",
668 "max_child_depth": 2
669 }),
670 ),
671 )
672 .await;
673 assert!(out.contains("Spawned sub-agent 'child-123'"));
674 drop(h);
676 t.await.unwrap();
677 let seen = seen.lock().unwrap();
679 assert_eq!(seen.len(), 1);
680 assert!(seen[0].task.contains("do it") && seen[0].task.contains("prior findings"));
681 assert_eq!(seen[0].max_depth, Some(2));
682 }
683
684 #[tokio::test]
688 async fn spawn_hands_the_parents_unattended_setting_to_the_child() {
689 for unattended in [false, true] {
690 let bp = temp_blueprint();
691 let (mut h, seen, _t) = fake_host(Ok("child-1".to_string()), vec![], false);
692 h.unattended = unattended;
693 let out = handle(
694 &h,
695 &tc(
696 "spawn_agent",
697 json!({"blueprint": bp.path().to_str().unwrap(), "task": "go"}),
698 ),
699 )
700 .await;
701 assert!(out.contains("Spawned sub-agent"), "{out}");
702 let seen = seen.lock().unwrap();
703 assert_eq!(
704 seen[0].yolo, unattended,
705 "a child inherits the parent's unattended setting"
706 );
707 }
708 }
709
710 #[tokio::test]
711 async fn spawn_with_wait_blocks_until_the_child_finishes() {
712 let bp = temp_blueprint();
713 let (h, _seen, _t) = fake_host(
715 Ok("child-1".to_string()),
716 vec![Some(AgentStatus::Active), Some(AgentStatus::Complete)],
717 false,
718 );
719 let out = handle(
720 &h,
721 &tc(
722 "spawn_agent",
723 json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t", "wait": true }),
724 ),
725 )
726 .await;
727 assert!(out.contains("finished with status: complete"));
728 }
729
730 #[tokio::test]
734 async fn wait_gives_up_when_the_calling_agent_is_cancelled() {
735 let (h, _seen, _t) = fake_host_with_parent(
736 Ok("child-1".to_string()),
737 vec![Some(AgentStatus::Active); 8],
739 false,
740 Some(AgentStatus::Cancelled),
741 );
742 let out = tokio::time::timeout(
743 std::time::Duration::from_secs(5),
744 handle(&h, &tc("wait_for_agent", json!({ "agent_id": "child-1" }))),
745 )
746 .await
747 .expect("the wait returns instead of polling forever");
748 assert!(
749 out.contains("cancelled while waiting"),
750 "reports why it stopped, got: {out}"
751 );
752 }
753
754 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
761 async fn wait_does_not_hold_the_tool_lane() {
762 use leviath_runtime::tool_bridge::{ToolJob, ToolLane, ToolLaneStats};
763
764 let mut statuses = vec![Some(AgentStatus::Active); 6];
768 statuses.push(Some(AgentStatus::Complete));
769 let (h, _seen, _t) = fake_host(Ok("child-1".to_string()), statuses, false);
770
771 let (job_tx, job_rx) = tokio::sync::mpsc::unbounded_channel();
772 let (result_tx, mut results) = tokio::sync::mpsc::unbounded_channel();
773 let stats = std::sync::Arc::new(ToolLaneStats::new(1));
774 let lane = ToolLane::new(
775 tokio::runtime::Handle::current(),
776 result_tx,
777 std::sync::Arc::new(tokio::sync::Notify::new()),
778 1,
779 stats.clone(),
780 );
781 let _serving = lane.serve(job_rx);
782 let submit = |entity: u32, exec: leviath_runtime::tool_bridge::BoxedToolExec| {
783 stats.enqueued();
784 job_tx
785 .send(ToolJob {
786 entity: bevy_ecs::entity::Entity::from_raw_u32(entity)
787 .expect("a small index is a valid id"),
788 exec,
789 cancel: leviath_runtime::cancel::CancelToken::new(),
790 })
791 .expect("the lane is serving");
792 };
793
794 submit(
795 1,
796 Box::new(move || {
797 Box::pin(async move {
798 let out =
799 handle(&h, &tc("wait_for_agent", json!({"agent_id": "child-1"}))).await;
800 vec![("wait".to_string(), out)]
801 })
802 }),
803 );
804 tokio::time::timeout(std::time::Duration::from_secs(30), async {
806 while stats.parked() == 0 {
807 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
808 }
809 })
810 .await
811 .expect("the wait stepped off the lane");
812
813 submit(
815 2,
816 Box::new(|| Box::pin(async { vec![("child".to_string(), "ran".to_string())] })),
817 );
818 let outcome = tokio::time::timeout(std::time::Duration::from_secs(30), results.recv())
819 .await
820 .expect("the batch behind the waiter ran")
821 .expect("an outcome arrived");
822 assert_eq!(
823 outcome.results,
824 vec![("child".to_string(), "ran".to_string())]
825 );
826
827 let waited = tokio::time::timeout(std::time::Duration::from_secs(30), results.recv())
829 .await
830 .expect("the wait finished")
831 .expect("an outcome arrived");
832 assert_eq!(waited.results.len(), 1);
833 let reported = waited.results[0].1.clone();
836 assert!(
837 reported.contains("finished with status: complete"),
838 "got: {reported}"
839 );
840 }
841
842 #[tokio::test]
846 async fn wait_gives_up_when_the_caller_is_unknown_to_the_host() {
847 let (h, _seen, _t) = fake_host_with_parent(
848 Ok("child-1".to_string()),
849 vec![Some(AgentStatus::Active); 8],
850 false,
851 None, );
853 let out = tokio::time::timeout(
854 std::time::Duration::from_secs(5),
855 handle(&h, &tc("wait_for_agent", json!({ "agent_id": "child-1" }))),
856 )
857 .await
858 .expect("the wait returns instead of polling forever");
859 assert!(out.contains("cancelled while waiting"), "got: {out}");
860 }
861
862 #[tokio::test]
863 async fn spawn_requires_blueprint_and_task_and_reports_resolve_errors() {
864 let (h, _seen, _t) = fake_host(Ok(String::new()), vec![], false);
865 assert!(
866 handle(&h, &tc("spawn_agent", json!({ "task": "t" })))
867 .await
868 .contains("requires 'blueprint' and 'task'")
869 );
870 assert!(
871 handle(
872 &h,
873 &tc(
874 "spawn_agent",
875 json!({ "blueprint": "/no/such/agent", "task": "t" })
876 )
877 )
878 .await
879 .contains("cannot spawn")
880 );
881 }
882
883 #[tokio::test]
884 async fn spawn_reports_spawner_error_and_dead_host() {
885 let bp = temp_blueprint();
886 let (h, _seen, _t) = fake_host(Err("bad blueprint".to_string()), vec![], false);
887 assert!(
888 handle(
889 &h,
890 &tc(
891 "spawn_agent",
892 json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t" })
893 )
894 )
895 .await
896 .contains("bad blueprint")
897 );
898 assert!(
899 handle(
900 &dead_handle(),
901 &tc(
902 "spawn_agent",
903 json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t" })
904 )
905 )
906 .await
907 .contains("shutting down")
908 );
909 }
910
911 #[tokio::test]
912 async fn check_reports_status_or_missing() {
913 let (h, _seen, _t) = fake_host(Ok(String::new()), vec![Some(AgentStatus::Active)], false);
914 assert!(
915 handle(&h, &tc("check_agent", json!({ "agent_id": "c" })))
916 .await
917 .contains("status: active")
918 );
919 let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
920 assert!(
921 handle(&h2, &tc("check_agent", json!({ "agent_id": "c" })))
922 .await
923 .contains("no such sub-agent")
924 );
925 assert!(
927 handle(
928 &dead_handle(),
929 &tc("check_agent", json!({ "agent_id": "c" }))
930 )
931 .await
932 .contains("no such sub-agent")
933 );
934 }
935
936 #[tokio::test]
937 async fn wait_requires_id_and_returns_when_terminal_or_missing() {
938 assert!(
939 handle(&dead_handle(), &tc("wait_for_agent", json!({})))
940 .await
941 .contains("requires 'agent_id'")
942 );
943 let (h, _seen, _t) = fake_host(
944 Ok(String::new()),
945 vec![Some(AgentStatus::Error {
946 message: "boom".to_string(),
947 })],
948 false,
949 );
950 assert!(
951 handle(&h, &tc("wait_for_agent", json!({ "agent_id": "c" })))
952 .await
953 .contains("error: boom")
954 );
955 let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
956 assert!(
957 handle(&h2, &tc("wait_for_agent", json!({ "agent_id": "c" })))
958 .await
959 .contains("no such sub-agent")
960 );
961 }
962
963 #[tokio::test]
964 async fn send_delivers_or_reports_failure() {
965 let (h, _seen, _t) = fake_host(Ok(String::new()), vec![], true);
966 assert!(
967 handle(
968 &h,
969 &tc("send_to_agent", json!({ "agent_id": "c", "message": "hi" }))
970 )
971 .await
972 .contains("Delivered message")
973 );
974 assert!(
975 handle(&h, &tc("send_to_agent", json!({ "agent_id": "c" })))
976 .await
977 .contains("requires 'agent_id' and 'message'")
978 );
979 let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
980 assert!(
981 handle(
982 &h2,
983 &tc("send_to_agent", json!({ "agent_id": "c", "message": "hi" }))
984 )
985 .await
986 .contains("did not accept")
987 );
988 assert!(
989 handle(
990 &dead_handle(),
991 &tc("send_to_agent", json!({ "agent_id": "c", "message": "hi" }))
992 )
993 .await
994 .contains("shutting down")
995 );
996 }
997
998 struct SendRecordingHost {
1000 handle: SubAgentHandle,
1002 regions: std::sync::Arc<std::sync::Mutex<Vec<Option<String>>>>,
1004 task: tokio::task::JoinHandle<()>,
1006 }
1007
1008 fn send_recording_host() -> SendRecordingHost {
1010 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1011 let regions = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1012 let regions_task = regions.clone();
1013 let task = tokio::spawn(async move {
1014 while let Some(op) = rx.recv().await {
1015 match op {
1016 SubAgentOp::Send {
1017 reply,
1018 target_region,
1019 ..
1020 } => {
1021 regions_task.lock().unwrap().push(target_region);
1022 let _ = reply.send(true);
1023 }
1024 other => drop(other),
1027 }
1028 }
1029 });
1030 SendRecordingHost {
1031 handle: handle_with(tx),
1032 regions,
1033 task,
1034 }
1035 }
1036
1037 #[tokio::test]
1041 async fn send_forwards_target_region() {
1042 let SendRecordingHost {
1043 handle: h,
1044 regions,
1045 task,
1046 } = send_recording_host();
1047 for args in [
1048 json!({ "agent_id": "c", "message": "hi", "target_region": "notes" }),
1049 json!({ "agent_id": "c", "message": "hi" }),
1050 json!({ "agent_id": "c", "message": "hi", "target_region": "" }),
1051 ] {
1052 assert!(
1053 handle(&h, &tc("send_to_agent", args))
1054 .await
1055 .contains("Delivered message")
1056 );
1057 }
1058 assert_eq!(
1059 *regions.lock().unwrap(),
1060 vec![Some("notes".to_string()), None, None]
1061 );
1062 handle(&h, &tc("check_agent", json!({ "agent_id": "c" }))).await;
1064 drop(h);
1066 task.await.unwrap();
1067 }
1068
1069 #[tokio::test]
1070 async fn kill_cancels_or_reports_missing() {
1071 let (h, _seen, _t) = fake_host(Ok(String::new()), vec![], true);
1072 assert!(
1073 handle(&h, &tc("kill_agent", json!({ "agent_id": "c" })))
1074 .await
1075 .contains("Killed sub-agent")
1076 );
1077 assert!(
1078 handle(&h, &tc("kill_agent", json!({})))
1079 .await
1080 .contains("requires 'agent_id'")
1081 );
1082 let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
1083 assert!(
1084 handle(&h2, &tc("kill_agent", json!({ "agent_id": "c" })))
1085 .await
1086 .contains("no such sub-agent")
1087 );
1088 assert!(
1089 handle(
1090 &dead_handle(),
1091 &tc("kill_agent", json!({ "agent_id": "c" }))
1092 )
1093 .await
1094 .contains("shutting down")
1095 );
1096 }
1097
1098 #[tokio::test]
1099 async fn handle_rejects_a_non_subagent_tool() {
1100 assert!(
1101 handle(&dead_handle(), &tc("read_file", json!({})))
1102 .await
1103 .contains("is not a sub-agent tool")
1104 );
1105 }
1106
1107 #[tokio::test]
1108 async fn dropped_reply_paths_are_handled() {
1109 let (h, t) = drop_host();
1110 assert!(
1112 handle(&h, &tc("check_agent", json!({ "agent_id": "c" })))
1113 .await
1114 .contains("no such sub-agent")
1115 );
1116 assert!(
1117 handle(
1118 &h,
1119 &tc("send_to_agent", json!({ "agent_id": "c", "message": "m" }))
1120 )
1121 .await
1122 .contains("dropped the message")
1123 );
1124 assert!(
1125 handle(&h, &tc("kill_agent", json!({ "agent_id": "c" })))
1126 .await
1127 .contains("dropped the kill request")
1128 );
1129 let bp = temp_blueprint();
1130 assert!(
1131 handle(
1132 &h,
1133 &tc(
1134 "spawn_agent",
1135 json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t" })
1136 )
1137 )
1138 .await
1139 .contains("dropped the spawn request")
1140 );
1141 drop(h);
1142 t.await.unwrap();
1143 }
1144
1145 fn answer(text: &str) -> leviath_core::output::FinalOutput {
1146 leviath_core::output::FinalOutput::new(
1147 text,
1148 Some("markdown".to_string()),
1149 "fix_worker".to_string(),
1150 0,
1151 )
1152 }
1153
1154 #[tokio::test]
1159 async fn wait_returns_the_childs_final_output() {
1160 let (h, _seen, _t) = fake_host_with_output(
1161 vec![Some(AgentStatus::Complete)],
1162 Some(answer("changed src/lib.rs and its test")),
1163 );
1164 let out = handle(&h, &tc("wait_for_agent", json!({"agent_id": "child-1"}))).await;
1165 assert!(out.contains("complete"), "{out}");
1166 assert!(out.contains("changed src/lib.rs and its test"), "{out}");
1167 assert!(out.contains("markdown"), "names the shape: {out}");
1168 }
1169
1170 #[tokio::test]
1171 async fn check_returns_the_childs_final_output_once_it_is_done() {
1172 let (h, _seen, _t) = fake_host_with_output(
1173 vec![Some(AgentStatus::Complete)],
1174 Some(answer("all three tests pass")),
1175 );
1176 let out = handle(&h, &tc("check_agent", json!({"agent_id": "child-1"}))).await;
1177 assert!(out.contains("all three tests pass"), "{out}");
1178 }
1179
1180 #[tokio::test]
1183 async fn check_on_a_running_child_reports_status_only() {
1184 let (h, _seen, _t) = fake_host_with_output(vec![Some(AgentStatus::Active)], None);
1185 let out = handle(&h, &tc("check_agent", json!({"agent_id": "child-1"}))).await;
1186 assert!(out.contains("active"), "{out}");
1187 assert!(!out.contains("final output"), "{out}");
1188 }
1189
1190 #[tokio::test]
1193 async fn a_truncated_child_answer_is_marked_as_cut() {
1194 let mut cut = answer("the first part of a very long report");
1195 cut.truncated = true;
1196 let (h, _seen, _t) = fake_host_with_output(vec![Some(AgentStatus::Complete)], Some(cut));
1197
1198 let out = handle(&h, &tc("wait_for_agent", json!({"agent_id": "child-1"}))).await;
1199
1200 assert!(
1201 out.contains("the first part of a very long report"),
1202 "{out}"
1203 );
1204 assert!(out.contains("truncated at the size limit"), "{out}");
1205 }
1206
1207 #[tokio::test]
1210 async fn a_finished_child_that_submitted_nothing_says_so() {
1211 let (h, _seen, _t) = fake_host_with_output(vec![Some(AgentStatus::Complete)], None);
1212 let out = handle(&h, &tc("wait_for_agent", json!({"agent_id": "child-1"}))).await;
1213 assert!(out.contains("no final output"), "{out}");
1214 }
1215
1216 #[tokio::test]
1219 async fn spawn_passes_a_requested_output_shape_to_the_child() {
1220 let (h, seen, _t) = fake_host(Ok("child-1".to_string()), vec![], false);
1221 let dir = temp_blueprint();
1222 let _ = handle(
1223 &h,
1224 &tc(
1225 "spawn_agent",
1226 json!({
1227 "blueprint": dir.path().to_str().unwrap(),
1228 "task": "do it",
1229 "output_format": "a2ui",
1230 "output_instructions": "One card per finding.",
1231 }),
1232 ),
1233 )
1234 .await;
1235 let args = seen.lock().unwrap();
1236 let spec = args[0]
1237 .output
1238 .as_ref()
1239 .expect("the request reached the child");
1240 assert_eq!(spec.format.as_deref(), Some("a2ui"));
1241 assert_eq!(spec.instructions.as_deref(), Some("One card per finding."));
1242 assert!(spec.schema.is_none());
1245 }
1246
1247 #[tokio::test]
1249 async fn spawn_without_output_args_requests_no_shape() {
1250 let (h, seen, _t) = fake_host(Ok("child-1".to_string()), vec![], false);
1251 let dir = temp_blueprint();
1252 let _ = handle(
1253 &h,
1254 &tc(
1255 "spawn_agent",
1256 json!({"blueprint": dir.path().to_str().unwrap(), "task": "do it"}),
1257 ),
1258 )
1259 .await;
1260 assert!(seen.lock().unwrap()[0].output.is_none());
1261 }
1262}