1use std::time::Duration;
8
9use leviath_providers::ToolCall;
10use leviath_runtime::components::AgentStatus;
11use leviath_runtime::host::SubAgentOp;
12use tokio::sync::mpsc::UnboundedSender;
13use tokio::sync::oneshot;
14
15use crate::daemon::client::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}
35
36pub const SUBAGENT_TOOLS: &[&str] = &[
38 "spawn_agent",
39 "check_agent",
40 "wait_for_agent",
41 "send_to_agent",
42 "kill_agent",
43];
44
45pub fn is_subagent_tool(name: &str) -> bool {
47 SUBAGENT_TOOLS.contains(&name)
48}
49
50const WAIT_POLL: Duration = Duration::from_millis(500);
52
53pub async fn handle(h: &SubAgentHandle, tc: &ToolCall) -> String {
55 match tc.name.as_str() {
56 "spawn_agent" => spawn(h, &tc.arguments).await,
57 "check_agent" => check(h, str_arg(&tc.arguments, "agent_id")).await,
58 "wait_for_agent" => wait(h, str_arg(&tc.arguments, "agent_id")).await,
59 "send_to_agent" => send(h, &tc.arguments).await,
60 "kill_agent" => kill(h, str_arg(&tc.arguments, "agent_id")).await,
61 other => format!("[error] '{other}' is not a sub-agent tool"),
62 }
63}
64
65fn resolves_within_workdir(blueprint: &str, workdir: &str) -> bool {
71 let candidate = std::path::Path::new(blueprint);
72 let workdir = std::path::Path::new(workdir);
73 if !candidate.exists() {
75 let joined = workdir.join(blueprint);
76 return joined.exists() && leviath_core::resolves_within(&joined, workdir);
77 }
78 leviath_core::resolves_within(candidate, workdir)
79}
80
81fn str_arg<'a>(args: &'a serde_json::Value, key: &str) -> &'a str {
83 args.get(key).and_then(|v| v.as_str()).unwrap_or("")
84}
85
86async fn spawn(h: &SubAgentHandle, args: &serde_json::Value) -> String {
87 let blueprint = str_arg(args, "blueprint");
88 let task = str_arg(args, "task");
89 if blueprint.is_empty() || task.is_empty() {
90 return "[error] spawn_agent requires 'blueprint' and 'task'".to_string();
91 }
92 if resolves_within_workdir(blueprint, &h.workdir) {
108 return format!(
109 "[error] '{blueprint}' is inside this agent's own working directory. \
110 Spawn an installed agent by name, or a blueprint from outside the \
111 workspace - an agent may not author the blueprint it runs."
112 );
113 }
114
115 let full_task = match args.get("seed_context").and_then(|v| v.as_str()) {
118 Some(seed) if !seed.is_empty() => format!("{task}\n\nContext:\n{seed}"),
119 _ => task.to_string(),
120 };
121 let child_max_depth = args
122 .get("max_child_depth")
123 .and_then(|v| v.as_u64())
124 .map(|n| n as usize);
125 let wait_flag = args.get("wait").and_then(|v| v.as_bool()).unwrap_or(false);
126
127 let spawn_args = match resolve_spawn_args(
128 blueprint,
129 &full_task,
130 None,
131 &h.workdir,
132 false,
133 Vec::new(),
134 child_max_depth,
135 std::collections::HashMap::new(),
137 h.no_seed_commands,
138 ) {
139 Ok(a) => a,
140 Err(e) => return format!("[error] cannot spawn '{blueprint}': {e}"),
141 };
142
143 let (tx, rx) = oneshot::channel();
144 if h.sender
145 .send(SubAgentOp::Spawn {
146 args: Box::new(spawn_args),
147 parent_run_id: h.parent_run_id.clone(),
148 max_depth: h.max_depth,
149 reply: tx,
150 })
151 .is_err()
152 {
153 return "[error] the daemon is shutting down".to_string();
154 }
155 match rx.await {
156 Ok(Ok(child_id)) if wait_flag => wait(h, &child_id).await,
157 Ok(Ok(child_id)) => format!("Spawned sub-agent '{child_id}'."),
158 Ok(Err(e)) => format!("[error] {e}"),
159 Err(_) => "[error] the daemon dropped the spawn request".to_string(),
160 }
161}
162
163async fn check(h: &SubAgentHandle, agent_id: &str) -> String {
164 match status_of(h, agent_id).await {
165 Some(status) => format!("Sub-agent '{agent_id}' status: {}", label(&status)),
166 None => format!("[error] no such sub-agent '{agent_id}'"),
167 }
168}
169
170async fn wait(h: &SubAgentHandle, agent_id: &str) -> String {
171 if agent_id.is_empty() {
172 return "[error] wait_for_agent requires 'agent_id'".to_string();
173 }
174 loop {
175 match status_of(h, agent_id).await {
176 None => return format!("[error] no such sub-agent '{agent_id}'"),
177 Some(status) if is_terminal(&status) => {
178 return format!(
179 "Sub-agent '{agent_id}' finished with status: {}",
180 label(&status)
181 );
182 }
183 Some(_) if caller_is_terminal(h).await => {
188 return format!("[error] cancelled while waiting for '{agent_id}'");
189 }
190 Some(_) => tokio::time::sleep(WAIT_POLL).await,
191 }
192 }
193}
194
195async fn caller_is_terminal(h: &SubAgentHandle) -> bool {
199 match status_of(h, &h.parent_run_id).await {
200 Some(status) => is_terminal(&status),
201 None => true,
202 }
203}
204
205async fn send(h: &SubAgentHandle, args: &serde_json::Value) -> String {
206 let agent_id = str_arg(args, "agent_id");
207 let message = str_arg(args, "message");
208 if agent_id.is_empty() || message.is_empty() {
209 return "[error] send_to_agent requires 'agent_id' and 'message'".to_string();
210 }
211 let (tx, rx) = oneshot::channel();
212 if h.sender
213 .send(SubAgentOp::Send {
214 run_id: agent_id.to_string(),
215 caller_run_id: h.parent_run_id.clone(),
216 content: message.to_string(),
217 reply: tx,
218 })
219 .is_err()
220 {
221 return "[error] the daemon is shutting down".to_string();
222 }
223 match rx.await {
224 Ok(true) => format!("Delivered message to '{agent_id}'."),
225 Ok(false) => format!(
226 "[error] '{agent_id}' did not accept the message. An agent may only \
227 message itself or an agent it spawned."
228 ),
229 Err(_) => "[error] the daemon dropped the message".to_string(),
230 }
231}
232
233async fn kill(h: &SubAgentHandle, agent_id: &str) -> String {
234 if agent_id.is_empty() {
235 return "[error] kill_agent requires 'agent_id'".to_string();
236 }
237 let (tx, rx) = oneshot::channel();
238 if h.sender
239 .send(SubAgentOp::Kill {
240 run_id: agent_id.to_string(),
241 caller_run_id: h.parent_run_id.clone(),
242 reply: tx,
243 })
244 .is_err()
245 {
246 return "[error] the daemon is shutting down".to_string();
247 }
248 match rx.await {
249 Ok(true) => format!("Killed sub-agent '{agent_id}' and its descendants."),
250 Ok(false) => format!("[error] no such sub-agent '{agent_id}'"),
251 Err(_) => "[error] the daemon dropped the kill request".to_string(),
252 }
253}
254
255async fn status_of(h: &SubAgentHandle, agent_id: &str) -> Option<AgentStatus> {
258 let (tx, rx) = oneshot::channel();
259 h.sender
260 .send(SubAgentOp::Check {
261 run_id: agent_id.to_string(),
262 reply: tx,
263 })
264 .ok()?;
265 rx.await.ok().flatten()
266}
267
268fn is_terminal(status: &AgentStatus) -> bool {
269 matches!(
270 status,
271 AgentStatus::Complete | AgentStatus::Cancelled | AgentStatus::Error { .. }
272 )
273}
274
275fn label(status: &AgentStatus) -> String {
276 match status {
277 AgentStatus::Idle => "idle".to_string(),
278 AgentStatus::Active => "active".to_string(),
279 AgentStatus::Waiting => "waiting".to_string(),
280 AgentStatus::Complete => "complete".to_string(),
281 AgentStatus::Cancelled => "cancelled".to_string(),
282 AgentStatus::Error { message } => format!("error: {message}"),
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[tokio::test]
295 async fn spawn_refuses_a_blueprint_the_agent_could_have_written() {
296 let work = tempfile::tempdir().unwrap();
297 let planted = work.path().join("x");
299 std::fs::create_dir(&planted).unwrap();
300 std::fs::write(planted.join("agent.leviath"), "[agent]\nname = \"x\"\n").unwrap();
301
302 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
303 let h = SubAgentHandle {
304 sender: tx,
305 parent_run_id: "parent".to_string(),
306 workdir: work.path().to_string_lossy().to_string(),
307 max_depth: 3,
308 no_seed_commands: false,
309 };
310
311 for bad in [
312 planted.to_string_lossy().to_string(),
313 "x".to_string(),
314 "x/agent.leviath".to_string(),
315 ] {
316 let out = spawn(&h, &serde_json::json!({"blueprint": bad, "task": "go"})).await;
317 assert!(
318 out.contains("own working directory"),
319 "{bad} must be refused: {out}"
320 );
321 }
322 }
323
324 #[tokio::test]
327 async fn spawn_allows_a_blueprint_outside_the_workdir() {
328 let work = tempfile::tempdir().unwrap();
329 let elsewhere = tempfile::tempdir().unwrap();
330 std::fs::write(
331 elsewhere.path().join("agent.leviath"),
332 "[agent]\nname = \"x\"\n",
333 )
334 .unwrap();
335
336 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
340 drop(rx);
341 let h = SubAgentHandle {
342 sender: tx,
343 parent_run_id: "parent".to_string(),
344 workdir: work.path().to_string_lossy().to_string(),
345 max_depth: 3,
346 no_seed_commands: false,
347 };
348 let out = spawn(
349 &h,
350 &serde_json::json!({
351 "blueprint": elsewhere.path().to_string_lossy(),
352 "task": "go"
353 }),
354 )
355 .await;
356 assert!(
357 !out.contains("own working directory"),
358 "a blueprint outside the workspace must not be refused: {out}"
359 );
360 }
361 use leviath_runtime::host::SpawnArgs;
362 use serde_json::json;
363
364 fn handle_with(sender: UnboundedSender<SubAgentOp>) -> SubAgentHandle {
365 SubAgentHandle {
366 sender,
367 parent_run_id: "parent".to_string(),
368 workdir: env!("CARGO_MANIFEST_DIR").to_string(),
375 max_depth: 3,
376 no_seed_commands: false,
377 }
378 }
379
380 #[allow(clippy::type_complexity)]
388 fn fake_host(
389 spawn_result: Result<String, String>,
390 statuses: Vec<Option<AgentStatus>>,
391 ok: bool,
392 ) -> (
393 SubAgentHandle,
394 std::sync::Arc<std::sync::Mutex<Vec<SpawnArgs>>>,
395 tokio::task::JoinHandle<()>,
396 ) {
397 fake_host_with_parent(spawn_result, statuses, ok, Some(AgentStatus::Active))
398 }
399
400 #[allow(clippy::type_complexity)]
402 fn fake_host_with_parent(
403 spawn_result: Result<String, String>,
404 statuses: Vec<Option<AgentStatus>>,
405 ok: bool,
406 parent_status: Option<AgentStatus>,
407 ) -> (
408 SubAgentHandle,
409 std::sync::Arc<std::sync::Mutex<Vec<SpawnArgs>>>,
410 tokio::task::JoinHandle<()>,
411 ) {
412 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
413 let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
414 let seen_task = seen.clone();
415 let task = tokio::spawn(async move {
416 let mut checks = statuses.into_iter();
417 while let Some(op) = rx.recv().await {
418 match op {
419 SubAgentOp::Spawn { reply, args, .. } => {
420 seen_task.lock().unwrap().push(*args);
421 let _ = reply.send(spawn_result.clone());
422 }
423 SubAgentOp::Check { reply, run_id } if run_id == "parent" => {
428 let _ = reply.send(parent_status.clone());
429 }
430 SubAgentOp::Check { reply, .. } => {
431 let _ = reply.send(checks.next().flatten());
432 }
433 SubAgentOp::Send { reply, .. } => {
434 let _ = reply.send(ok);
435 }
436 SubAgentOp::Kill { reply, .. } => {
437 let _ = reply.send(ok);
438 }
439 }
440 }
441 });
442 (handle_with(tx), seen, task)
443 }
444
445 fn drop_host() -> (SubAgentHandle, tokio::task::JoinHandle<()>) {
448 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
449 let task = tokio::spawn(async move {
450 while let Some(op) = rx.recv().await {
451 drop(op);
452 }
453 });
454 (handle_with(tx), task)
455 }
456
457 fn dead_handle() -> SubAgentHandle {
459 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
460 handle_with(tx)
461 }
462
463 fn temp_blueprint() -> tempfile::TempDir {
466 let dir = tempfile::tempdir().unwrap();
467 std::fs::write(
468 dir.path().join("agent.leviath"),
469 r#"
470[agent]
471name = "child"
472version = "0.1.0"
473description = "child"
474
475[stages.main]
476model = { provider = "anthropic", model = "claude-sonnet-4-6" }
477"#,
478 )
479 .unwrap();
480 dir
481 }
482
483 fn tc(name: &str, args: serde_json::Value) -> ToolCall {
484 ToolCall {
485 id: "1".to_string(),
486 name: name.to_string(),
487 arguments: args,
488 thought_signature: None,
489 }
490 }
491
492 #[test]
493 fn is_subagent_tool_recognizes_the_five_names() {
494 for name in SUBAGENT_TOOLS {
495 assert!(is_subagent_tool(name));
496 }
497 assert!(!is_subagent_tool("read_file"));
498 }
499
500 #[test]
501 fn label_and_terminal_cover_all_statuses() {
502 assert_eq!(label(&AgentStatus::Idle), "idle");
503 assert_eq!(label(&AgentStatus::Active), "active");
504 assert_eq!(label(&AgentStatus::Waiting), "waiting");
505 assert_eq!(label(&AgentStatus::Complete), "complete");
506 assert_eq!(label(&AgentStatus::Cancelled), "cancelled");
507 assert_eq!(
508 label(&AgentStatus::Error {
509 message: "boom".to_string()
510 }),
511 "error: boom"
512 );
513 for s in [AgentStatus::Active, AgentStatus::Waiting, AgentStatus::Idle] {
514 assert!(!is_terminal(&s));
515 }
516 for s in [
517 AgentStatus::Complete,
518 AgentStatus::Cancelled,
519 AgentStatus::Error {
520 message: "x".to_string(),
521 },
522 ] {
523 assert!(is_terminal(&s));
524 }
525 }
526
527 #[tokio::test]
528 async fn spawn_resolves_blueprint_forwards_seed_and_reports_the_child_id() {
529 let bp = temp_blueprint();
530 let (h, seen, t) = fake_host(Ok("child-123".to_string()), vec![], false);
531 let out = handle(
532 &h,
533 &tc(
534 "spawn_agent",
535 json!({
536 "blueprint": bp.path().to_str().unwrap(),
537 "task": "do it",
538 "seed_context": "prior findings",
539 "max_child_depth": 2
540 }),
541 ),
542 )
543 .await;
544 assert!(out.contains("Spawned sub-agent 'child-123'"));
545 drop(h);
547 t.await.unwrap();
548 let seen = seen.lock().unwrap();
550 assert_eq!(seen.len(), 1);
551 assert!(seen[0].task.contains("do it") && seen[0].task.contains("prior findings"));
552 assert_eq!(seen[0].max_depth, Some(2));
553 }
554
555 #[tokio::test]
556 async fn spawn_with_wait_blocks_until_the_child_finishes() {
557 let bp = temp_blueprint();
558 let (h, _seen, _t) = fake_host(
560 Ok("child-1".to_string()),
561 vec![Some(AgentStatus::Active), Some(AgentStatus::Complete)],
562 false,
563 );
564 let out = handle(
565 &h,
566 &tc(
567 "spawn_agent",
568 json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t", "wait": true }),
569 ),
570 )
571 .await;
572 assert!(out.contains("finished with status: complete"));
573 }
574
575 #[tokio::test]
580 async fn wait_gives_up_when_the_calling_agent_is_cancelled() {
581 let (h, _seen, _t) = fake_host_with_parent(
582 Ok("child-1".to_string()),
583 vec![Some(AgentStatus::Active); 8],
585 false,
586 Some(AgentStatus::Cancelled),
587 );
588 let out = tokio::time::timeout(
589 std::time::Duration::from_secs(5),
590 handle(&h, &tc("wait_for_agent", json!({ "agent_id": "child-1" }))),
591 )
592 .await
593 .expect("the wait returns instead of polling forever");
594 assert!(
595 out.contains("cancelled while waiting"),
596 "reports why it stopped, got: {out}"
597 );
598 }
599
600 #[tokio::test]
604 async fn wait_gives_up_when_the_caller_is_unknown_to_the_host() {
605 let (h, _seen, _t) = fake_host_with_parent(
606 Ok("child-1".to_string()),
607 vec![Some(AgentStatus::Active); 8],
608 false,
609 None, );
611 let out = tokio::time::timeout(
612 std::time::Duration::from_secs(5),
613 handle(&h, &tc("wait_for_agent", json!({ "agent_id": "child-1" }))),
614 )
615 .await
616 .expect("the wait returns instead of polling forever");
617 assert!(out.contains("cancelled while waiting"), "got: {out}");
618 }
619
620 #[tokio::test]
621 async fn spawn_requires_blueprint_and_task_and_reports_resolve_errors() {
622 let (h, _seen, _t) = fake_host(Ok(String::new()), vec![], false);
623 assert!(
624 handle(&h, &tc("spawn_agent", json!({ "task": "t" })))
625 .await
626 .contains("requires 'blueprint' and 'task'")
627 );
628 assert!(
629 handle(
630 &h,
631 &tc(
632 "spawn_agent",
633 json!({ "blueprint": "/no/such/agent", "task": "t" })
634 )
635 )
636 .await
637 .contains("cannot spawn")
638 );
639 }
640
641 #[tokio::test]
642 async fn spawn_reports_spawner_error_and_dead_host() {
643 let bp = temp_blueprint();
644 let (h, _seen, _t) = fake_host(Err("bad blueprint".to_string()), vec![], false);
645 assert!(
646 handle(
647 &h,
648 &tc(
649 "spawn_agent",
650 json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t" })
651 )
652 )
653 .await
654 .contains("bad blueprint")
655 );
656 assert!(
657 handle(
658 &dead_handle(),
659 &tc(
660 "spawn_agent",
661 json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t" })
662 )
663 )
664 .await
665 .contains("shutting down")
666 );
667 }
668
669 #[tokio::test]
670 async fn check_reports_status_or_missing() {
671 let (h, _seen, _t) = fake_host(Ok(String::new()), vec![Some(AgentStatus::Active)], false);
672 assert!(
673 handle(&h, &tc("check_agent", json!({ "agent_id": "c" })))
674 .await
675 .contains("status: active")
676 );
677 let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
678 assert!(
679 handle(&h2, &tc("check_agent", json!({ "agent_id": "c" })))
680 .await
681 .contains("no such sub-agent")
682 );
683 assert!(
685 handle(
686 &dead_handle(),
687 &tc("check_agent", json!({ "agent_id": "c" }))
688 )
689 .await
690 .contains("no such sub-agent")
691 );
692 }
693
694 #[tokio::test]
695 async fn wait_requires_id_and_returns_when_terminal_or_missing() {
696 assert!(
697 handle(&dead_handle(), &tc("wait_for_agent", json!({})))
698 .await
699 .contains("requires 'agent_id'")
700 );
701 let (h, _seen, _t) = fake_host(
702 Ok(String::new()),
703 vec![Some(AgentStatus::Error {
704 message: "boom".to_string(),
705 })],
706 false,
707 );
708 assert!(
709 handle(&h, &tc("wait_for_agent", json!({ "agent_id": "c" })))
710 .await
711 .contains("error: boom")
712 );
713 let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
714 assert!(
715 handle(&h2, &tc("wait_for_agent", json!({ "agent_id": "c" })))
716 .await
717 .contains("no such sub-agent")
718 );
719 }
720
721 #[tokio::test]
722 async fn send_delivers_or_reports_failure() {
723 let (h, _seen, _t) = fake_host(Ok(String::new()), vec![], true);
724 assert!(
725 handle(
726 &h,
727 &tc("send_to_agent", json!({ "agent_id": "c", "message": "hi" }))
728 )
729 .await
730 .contains("Delivered message")
731 );
732 assert!(
733 handle(&h, &tc("send_to_agent", json!({ "agent_id": "c" })))
734 .await
735 .contains("requires 'agent_id' and 'message'")
736 );
737 let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
738 assert!(
739 handle(
740 &h2,
741 &tc("send_to_agent", json!({ "agent_id": "c", "message": "hi" }))
742 )
743 .await
744 .contains("did not accept")
745 );
746 assert!(
747 handle(
748 &dead_handle(),
749 &tc("send_to_agent", json!({ "agent_id": "c", "message": "hi" }))
750 )
751 .await
752 .contains("shutting down")
753 );
754 }
755
756 #[tokio::test]
757 async fn kill_cancels_or_reports_missing() {
758 let (h, _seen, _t) = fake_host(Ok(String::new()), vec![], true);
759 assert!(
760 handle(&h, &tc("kill_agent", json!({ "agent_id": "c" })))
761 .await
762 .contains("Killed sub-agent")
763 );
764 assert!(
765 handle(&h, &tc("kill_agent", json!({})))
766 .await
767 .contains("requires 'agent_id'")
768 );
769 let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
770 assert!(
771 handle(&h2, &tc("kill_agent", json!({ "agent_id": "c" })))
772 .await
773 .contains("no such sub-agent")
774 );
775 assert!(
776 handle(
777 &dead_handle(),
778 &tc("kill_agent", json!({ "agent_id": "c" }))
779 )
780 .await
781 .contains("shutting down")
782 );
783 }
784
785 #[tokio::test]
786 async fn handle_rejects_a_non_subagent_tool() {
787 assert!(
788 handle(&dead_handle(), &tc("read_file", json!({})))
789 .await
790 .contains("is not a sub-agent tool")
791 );
792 }
793
794 #[tokio::test]
795 async fn dropped_reply_paths_are_handled() {
796 let (h, t) = drop_host();
797 assert!(
799 handle(&h, &tc("check_agent", json!({ "agent_id": "c" })))
800 .await
801 .contains("no such sub-agent")
802 );
803 assert!(
804 handle(
805 &h,
806 &tc("send_to_agent", json!({ "agent_id": "c", "message": "m" }))
807 )
808 .await
809 .contains("dropped the message")
810 );
811 assert!(
812 handle(&h, &tc("kill_agent", json!({ "agent_id": "c" })))
813 .await
814 .contains("dropped the kill request")
815 );
816 let bp = temp_blueprint();
817 assert!(
818 handle(
819 &h,
820 &tc(
821 "spawn_agent",
822 json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t" })
823 )
824 )
825 .await
826 .contains("dropped the spawn request")
827 );
828 drop(h);
829 t.await.unwrap();
830 }
831}