Skip to main content

leviath_cli/daemon/
subagent.rs

1//! Sub-agent tool handlers: turn `spawn_agent` / `check_agent` /
2//! `wait_for_agent` / `send_to_agent` / `kill_agent` tool calls into
3//! [`SubAgentOp`]s serviced by the host (which owns the world + spawner). The
4//! tool lane runs off the world, so it blocks on the host applying each op via a
5//! oneshot - the same shape as an interaction.
6
7use 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::{never_interactive, resolve_spawn_args};
16
17/// Per-agent state needed to service the sub-agent tools: a sender into the
18/// host's [`SubAgentOp`] channel plus the spawning agent's identity and the
19/// context children inherit.
20#[derive(Clone)]
21pub struct SubAgentHandle {
22    /// Sender into the host's sub-agent op channel.
23    pub sender: UnboundedSender<SubAgentOp>,
24    /// The run id of the agent that owns this handle (the would-be parent).
25    pub parent_run_id: String,
26    /// Working directory children inherit.
27    pub workdir: String,
28    /// Maximum allowed sub-agent tree depth.
29    pub max_depth: usize,
30    /// The parent run's `--no-seed-commands` setting, inherited by children so a
31    /// per-run opt-out can't be side-stepped by spawning a sub-agent whose
32    /// blueprint declares command seeds.
33    pub no_seed_commands: bool,
34    /// The parent run's `--yolo` setting, inherited by children.
35    ///
36    /// A child spawned attended under an unattended parent stops at its first
37    /// approval prompt with nobody there to answer, and takes the parent down
38    /// with it whenever the parent is waiting on it. The operator asked for an
39    /// unattended run; the tree is the run.
40    pub unattended: bool,
41}
42
43// The sub-agent tool-name list lives in `leviath-tools` (next to the tool
44// defs), shared with the runtime's crash-replay synthesis; re-exported here for
45// the existing dispatch-routing callers.
46pub use leviath_tools::{SUBAGENT_TOOLS, is_subagent_tool};
47
48/// How often `wait_for_agent` / `spawn_agent(wait=true)` polls the child.
49const WAIT_POLL: Duration = Duration::from_millis(500);
50
51/// Dispatch one sub-agent tool call, returning the textual result for the model.
52pub 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
63/// Whether `blueprint`, read as a path, lands inside `workdir`.
64///
65/// Symlink-aware, so a link planted in the workspace cannot be used to point at
66/// something that only *looks* outside it. A bare agent name is not a path that
67/// exists here, so it is never caught by this.
68fn 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    // Only an existing path can be one the agent just wrote.
72    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
79/// A required string argument, or `""` when missing/not a string.
80fn 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    // Never a blueprint the agent could have written itself.
91    //
92    // `blueprint` comes from model output and `find_manifest` accepts any path.
93    // `write_file` is confined to the workdir - but the spawner was not, so a
94    // model steered by injected content could write `x/agent.leviath` inside its
95    // own workdir and then spawn it. The child is built with seeds enforced, so
96    // that manifest's `seed = { command = ... }` ran on the host before its
97    // first inference, and its `[[mcp_servers]]` spawned arbitrary programs:
98    // a confined file write escalated to unconfined command execution.
99    //
100    // Refusing paths *inside the workdir* closes that exactly, and leaves
101    // everything legitimate working - an installed agent by name, or a path a
102    // human or the parent blueprint chose. A model that can already write
103    // outside the workdir has arbitrary execution by other means, so nothing
104    // here is the weak link.
105    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    // Optional seed context is prepended to the task (it lands in the child's
114    // pinned task region, which is exactly what the parent wants seeded).
115    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
125    let spawn_args = match resolve_spawn_args(
126        blueprint,
127        Some(&full_task),
128        &never_interactive,
129        None,
130        &h.workdir,
131        h.unattended,
132        Vec::new(),
133        child_max_depth,
134        // Sub-agents receive their whole task via `full_task`; no region flags.
135        std::collections::HashMap::new(),
136        h.no_seed_commands,
137    ) {
138        Ok(a) => a,
139        Err(e) => return format!("[error] cannot spawn '{blueprint}': {e}"),
140    };
141
142    let (tx, rx) = oneshot::channel();
143    if h.sender
144        .send(SubAgentOp::Spawn {
145            args: Box::new(spawn_args),
146            parent_run_id: h.parent_run_id.clone(),
147            max_depth: h.max_depth,
148            reply: tx,
149        })
150        .is_err()
151    {
152        return "[error] the daemon is shutting down".to_string();
153    }
154    match rx.await {
155        Ok(Ok(child_id)) if wait_flag => wait(h, &child_id).await,
156        Ok(Ok(child_id)) => format!("Spawned sub-agent '{child_id}'."),
157        Ok(Err(e)) => format!("[error] {e}"),
158        Err(_) => "[error] the daemon dropped the spawn request".to_string(),
159    }
160}
161
162async fn check(h: &SubAgentHandle, agent_id: &str) -> String {
163    match status_of(h, agent_id).await {
164        Some(status) => format!("Sub-agent '{agent_id}' status: {}", label(&status)),
165        None => format!("[error] no such sub-agent '{agent_id}'"),
166    }
167}
168
169async fn wait(h: &SubAgentHandle, agent_id: &str) -> String {
170    if agent_id.is_empty() {
171        return "[error] wait_for_agent requires 'agent_id'".to_string();
172    }
173    // The whole wait happens off the tool lane. The child's own tool batches
174    // queue on that lane, so a parent that kept lane capacity while waiting was
175    // holding the very thing the child needed to finish - a parent and child
176    // deadlocked on each other, which is what froze whole factories for hours
177    // (issue #191).
178    leviath_runtime::tool_bridge::off_lane(poll_until_finished(h, agent_id)).await
179}
180
181/// Poll `agent_id` until it reaches a terminal state, or until the caller does.
182async fn poll_until_finished(h: &SubAgentHandle, agent_id: &str) -> String {
183    loop {
184        match status_of(h, agent_id).await {
185            None => return format!("[error] no such sub-agent '{agent_id}'"),
186            Some(status) if is_terminal(&status) => {
187                return format!(
188                    "Sub-agent '{agent_id}' finished with status: {}",
189                    label(&status)
190                );
191            }
192            // The caller itself was cancelled (or failed) while waiting. Give up
193            // rather than keep polling for a child that is being torn down with
194            // it - this loop has no other exit, so it would otherwise run for as
195            // long as the daemon lived.
196            Some(_) if caller_is_terminal(h).await => {
197                return format!("[error] cancelled while waiting for '{agent_id}'");
198            }
199            Some(_) => tokio::time::sleep(WAIT_POLL).await,
200        }
201    }
202}
203
204/// Whether the agent that called `wait_for_agent` has itself reached a terminal
205/// state. A dropped request (daemon shutting down) counts as terminal - there is
206/// nothing left to wait for either way.
207async fn caller_is_terminal(h: &SubAgentHandle) -> bool {
208    match status_of(h, &h.parent_run_id).await {
209        Some(status) => is_terminal(&status),
210        None => true,
211    }
212}
213
214async fn send(h: &SubAgentHandle, args: &serde_json::Value) -> String {
215    let agent_id = str_arg(args, "agent_id");
216    let message = str_arg(args, "message");
217    if agent_id.is_empty() || message.is_empty() {
218        return "[error] send_to_agent requires 'agent_id' and 'message'".to_string();
219    }
220    // Empty string means unset, same as absent: delivery defaults to the
221    // conversation region, which is what the tool's schema documents.
222    let target_region = Some(str_arg(args, "target_region"))
223        .filter(|s| !s.is_empty())
224        .map(str::to_string);
225    let (tx, rx) = oneshot::channel();
226    if h.sender
227        .send(SubAgentOp::Send {
228            run_id: agent_id.to_string(),
229            caller_run_id: h.parent_run_id.clone(),
230            content: message.to_string(),
231            target_region,
232            reply: tx,
233        })
234        .is_err()
235    {
236        return "[error] the daemon is shutting down".to_string();
237    }
238    match rx.await {
239        Ok(true) => format!("Delivered message to '{agent_id}'."),
240        Ok(false) => format!(
241            "[error] '{agent_id}' did not accept the message. An agent may only \
242             message itself or an agent it spawned."
243        ),
244        Err(_) => "[error] the daemon dropped the message".to_string(),
245    }
246}
247
248async fn kill(h: &SubAgentHandle, agent_id: &str) -> String {
249    if agent_id.is_empty() {
250        return "[error] kill_agent requires 'agent_id'".to_string();
251    }
252    let (tx, rx) = oneshot::channel();
253    if h.sender
254        .send(SubAgentOp::Kill {
255            run_id: agent_id.to_string(),
256            caller_run_id: h.parent_run_id.clone(),
257            reply: tx,
258        })
259        .is_err()
260    {
261        return "[error] the daemon is shutting down".to_string();
262    }
263    match rx.await {
264        Ok(true) => format!("Killed sub-agent '{agent_id}' and its descendants."),
265        Ok(false) => format!("[error] no such sub-agent '{agent_id}'"),
266        Err(_) => "[error] the daemon dropped the kill request".to_string(),
267    }
268}
269
270/// Query a child's status via the host, `None` if it dropped the request or the
271/// run is unknown.
272async fn status_of(h: &SubAgentHandle, agent_id: &str) -> Option<AgentStatus> {
273    let (tx, rx) = oneshot::channel();
274    h.sender
275        .send(SubAgentOp::Check {
276            run_id: agent_id.to_string(),
277            reply: tx,
278        })
279        .ok()?;
280    rx.await.ok().flatten()
281}
282
283fn is_terminal(status: &AgentStatus) -> bool {
284    matches!(
285        status,
286        AgentStatus::Complete | AgentStatus::Cancelled | AgentStatus::Error { .. }
287    )
288}
289
290/// What the parent model is told a child's status is. `Display` rather than
291/// `label` so a failed child reports why it failed, which is the whole reason
292/// the parent asked.
293fn label(status: &AgentStatus) -> String {
294    status.to_string()
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    /// The escalation this closes: `write_file` is confined to the workdir, but
302    /// the spawner was not - so a model could author `x/agent.leviath` in its own
303    /// workspace and spawn it, and the child is built with seeds enforced, so
304    /// that manifest's command seeds ran on the host before its first inference.
305    #[tokio::test]
306    async fn spawn_refuses_a_blueprint_the_agent_could_have_written() {
307        let work = tempfile::tempdir().unwrap();
308        // Exactly what the model would produce: a manifest inside its workdir.
309        let planted = work.path().join("x");
310        std::fs::create_dir(&planted).unwrap();
311        std::fs::write(planted.join("agent.leviath"), "[agent]\nname = \"x\"\n").unwrap();
312
313        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
314        let h = SubAgentHandle {
315            sender: tx,
316            parent_run_id: "parent".to_string(),
317            workdir: work.path().to_string_lossy().to_string(),
318            max_depth: 3,
319            no_seed_commands: false,
320            unattended: false,
321        };
322
323        for bad in [
324            planted.to_string_lossy().to_string(),
325            "x".to_string(),
326            "x/agent.leviath".to_string(),
327        ] {
328            let out = spawn(&h, &serde_json::json!({"blueprint": bad, "task": "go"})).await;
329            assert!(
330                out.contains("own working directory"),
331                "{bad} must be refused: {out}"
332            );
333        }
334    }
335
336    /// And a blueprint from outside the workspace is untouched - an installed
337    /// agent by name, or a path a human chose.
338    #[tokio::test]
339    async fn spawn_allows_a_blueprint_outside_the_workdir() {
340        let work = tempfile::tempdir().unwrap();
341        let elsewhere = tempfile::tempdir().unwrap();
342        std::fs::write(
343            elsewhere.path().join("agent.leviath"),
344            "[agent]\nname = \"x\"\n",
345        )
346        .unwrap();
347
348        // The receiver is dropped so the op fails fast rather than waiting on a
349        // reply no host is here to send. What this asserts is that the path
350        // check let the blueprint through, not that a spawn succeeded.
351        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
352        drop(rx);
353        let h = SubAgentHandle {
354            sender: tx,
355            parent_run_id: "parent".to_string(),
356            workdir: work.path().to_string_lossy().to_string(),
357            max_depth: 3,
358            no_seed_commands: false,
359            unattended: false,
360        };
361        let out = spawn(
362            &h,
363            &serde_json::json!({
364                "blueprint": elsewhere.path().to_string_lossy(),
365                "task": "go"
366            }),
367        )
368        .await;
369        assert!(
370            !out.contains("own working directory"),
371            "a blueprint outside the workspace must not be refused: {out}"
372        );
373    }
374    use leviath_runtime::host::SpawnArgs;
375    use serde_json::json;
376
377    fn handle_with(sender: UnboundedSender<SubAgentOp>) -> SubAgentHandle {
378        SubAgentHandle {
379            sender,
380            parent_run_id: "parent".to_string(),
381            // This crate's own directory, deliberately *not* the system temp
382            // dir: `temp_blueprint()` writes under temp, and on Linux that is
383            // `/tmp` - so a workdir of `/tmp` made every fixture blueprint look
384            // like one the agent had planted in its own workspace, and the
385            // containment guard refused them all. macOS puts tempdirs under
386            // `$TMPDIR` in `/var/folders`, so nothing local caught it.
387            workdir: env!("CARGO_MANIFEST_DIR").to_string(),
388            max_depth: 3,
389            no_seed_commands: false,
390            unattended: false,
391        }
392    }
393
394    /// A `SubAgentHandle` whose host answers each op from plain canned values -
395    /// no per-call-site closures, so this single service loop is the only region
396    /// (covered collectively across the suite). `spawn_result` answers `Spawn`
397    /// and the received args are recorded into the returned `Vec` for assertions;
398    /// `statuses` answers successive `Check`s for *children* in order (`None`
399    /// once exhausted); `ok` answers `Send`/`Kill`. The caller ("parent") is
400    /// reported `Active` - see [`fake_host_with_parent`] to script it.
401    #[allow(clippy::type_complexity)]
402    fn fake_host(
403        spawn_result: Result<String, String>,
404        statuses: Vec<Option<AgentStatus>>,
405        ok: bool,
406    ) -> (
407        SubAgentHandle,
408        std::sync::Arc<std::sync::Mutex<Vec<SpawnArgs>>>,
409        tokio::task::JoinHandle<()>,
410    ) {
411        fake_host_with_parent(spawn_result, statuses, ok, Some(AgentStatus::Active))
412    }
413
414    /// [`fake_host`] with the calling agent's own status scripted too.
415    #[allow(clippy::type_complexity)]
416    fn fake_host_with_parent(
417        spawn_result: Result<String, String>,
418        statuses: Vec<Option<AgentStatus>>,
419        ok: bool,
420        parent_status: Option<AgentStatus>,
421    ) -> (
422        SubAgentHandle,
423        std::sync::Arc<std::sync::Mutex<Vec<SpawnArgs>>>,
424        tokio::task::JoinHandle<()>,
425    ) {
426        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
427        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
428        let seen_task = seen.clone();
429        let task = tokio::spawn(async move {
430            let mut checks = statuses.into_iter();
431            while let Some(op) = rx.recv().await {
432                match op {
433                    SubAgentOp::Spawn { reply, args, .. } => {
434                        seen_task.lock().unwrap().push(*args);
435                        let _ = reply.send(spawn_result.clone());
436                    }
437                    // `wait` polls the *caller* as well as the child (to bail out
438                    // if the caller was itself cancelled), so the scripted queue
439                    // answers only for children - the caller is reported Active
440                    // unless a test scripts it otherwise.
441                    SubAgentOp::Check { reply, run_id } if run_id == "parent" => {
442                        let _ = reply.send(parent_status.clone());
443                    }
444                    SubAgentOp::Check { reply, .. } => {
445                        let _ = reply.send(checks.next().flatten());
446                    }
447                    SubAgentOp::Send { reply, .. } => {
448                        let _ = reply.send(ok);
449                    }
450                    SubAgentOp::Kill { reply, .. } => {
451                        let _ = reply.send(ok);
452                    }
453                }
454            }
455        });
456        (handle_with(tx), seen, task)
457    }
458
459    /// A host that drops every op without replying - the handler then sees a
460    /// dropped oneshot.
461    fn drop_host() -> (SubAgentHandle, tokio::task::JoinHandle<()>) {
462        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
463        let task = tokio::spawn(async move {
464            while let Some(op) = rx.recv().await {
465                drop(op);
466            }
467        });
468        (handle_with(tx), task)
469    }
470
471    /// A handle whose host is already gone (sends fail immediately).
472    fn dead_handle() -> SubAgentHandle {
473        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
474        handle_with(tx)
475    }
476
477    /// Write a minimal valid blueprint into a temp dir and return that dir (whose
478    /// path `find_manifest` resolves to `<dir>/agent.leviath`).
479    fn temp_blueprint() -> tempfile::TempDir {
480        let dir = tempfile::tempdir().unwrap();
481        std::fs::write(
482            dir.path().join("agent.leviath"),
483            r#"
484[agent]
485name = "child"
486version = "0.1.0"
487description = "child"
488
489[stages.main]
490model = { provider = "anthropic", model = "claude-sonnet-4-6" }
491"#,
492        )
493        .unwrap();
494        dir
495    }
496
497    fn tc(name: &str, args: serde_json::Value) -> ToolCall {
498        ToolCall {
499            id: "1".to_string(),
500            name: name.to_string(),
501            arguments: args,
502            thought_signature: None,
503        }
504    }
505
506    #[test]
507    fn is_subagent_tool_recognizes_the_five_names() {
508        for name in SUBAGENT_TOOLS {
509            assert!(is_subagent_tool(name));
510        }
511        assert!(!is_subagent_tool("read_file"));
512    }
513
514    #[test]
515    fn label_and_terminal_cover_all_statuses() {
516        assert_eq!(label(&AgentStatus::Idle), "idle");
517        assert_eq!(label(&AgentStatus::Active), "active");
518        assert_eq!(label(&AgentStatus::Paused), "paused");
519        assert_eq!(label(&AgentStatus::Waiting), "waiting");
520        assert_eq!(label(&AgentStatus::Complete), "complete");
521        assert_eq!(label(&AgentStatus::Cancelled), "cancelled");
522        assert_eq!(
523            label(&AgentStatus::Error {
524                message: "boom".to_string()
525            }),
526            "error: boom"
527        );
528        for s in [AgentStatus::Active, AgentStatus::Waiting, AgentStatus::Idle] {
529            assert!(!is_terminal(&s));
530        }
531        for s in [
532            AgentStatus::Complete,
533            AgentStatus::Cancelled,
534            AgentStatus::Error {
535                message: "x".to_string(),
536            },
537        ] {
538            assert!(is_terminal(&s));
539        }
540    }
541
542    #[tokio::test]
543    async fn spawn_resolves_blueprint_forwards_seed_and_reports_the_child_id() {
544        let bp = temp_blueprint();
545        let (h, seen, t) = fake_host(Ok("child-123".to_string()), vec![], false);
546        let out = handle(
547            &h,
548            &tc(
549                "spawn_agent",
550                json!({
551                    "blueprint": bp.path().to_str().unwrap(),
552                    "task": "do it",
553                    "seed_context": "prior findings",
554                    "max_child_depth": 2
555                }),
556            ),
557        )
558        .await;
559        assert!(out.contains("Spawned sub-agent 'child-123'"));
560        // Drop the handle and drain the host task - covers the loop's exit.
561        drop(h);
562        t.await.unwrap();
563        // The seed context was folded into the child's task.
564        let seen = seen.lock().unwrap();
565        assert_eq!(seen.len(), 1);
566        assert!(seen[0].task.contains("do it") && seen[0].task.contains("prior findings"));
567        assert_eq!(seen[0].max_depth, Some(2));
568    }
569
570    /// A child of an unattended parent is unattended. Spawning it attended left
571    /// it stopped at its first approval prompt with nobody there to answer, and
572    /// parked the parent behind it for good (issue #184).
573    #[tokio::test]
574    async fn spawn_hands_the_parents_unattended_setting_to_the_child() {
575        for unattended in [false, true] {
576            let bp = temp_blueprint();
577            let (mut h, seen, _t) = fake_host(Ok("child-1".to_string()), vec![], false);
578            h.unattended = unattended;
579            let out = handle(
580                &h,
581                &tc(
582                    "spawn_agent",
583                    json!({"blueprint": bp.path().to_str().unwrap(), "task": "go"}),
584                ),
585            )
586            .await;
587            assert!(out.contains("Spawned sub-agent"), "{out}");
588            let seen = seen.lock().unwrap();
589            assert_eq!(
590                seen[0].yolo, unattended,
591                "a child inherits the parent's unattended setting"
592            );
593        }
594    }
595
596    #[tokio::test]
597    async fn spawn_with_wait_blocks_until_the_child_finishes() {
598        let bp = temp_blueprint();
599        // Active on the first poll, Complete after.
600        let (h, _seen, _t) = fake_host(
601            Ok("child-1".to_string()),
602            vec![Some(AgentStatus::Active), Some(AgentStatus::Complete)],
603            false,
604        );
605        let out = handle(
606            &h,
607            &tc(
608                "spawn_agent",
609                json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t", "wait": true }),
610            ),
611        )
612        .await;
613        assert!(out.contains("finished with status: complete"));
614    }
615
616    /// `wait_for_agent` gives up when the *calling* agent is cancelled. The loop
617    /// has no other exit, so a cancelled caller would otherwise poll for a child
618    /// that is being torn down with it until the daemon exits.
619    #[tokio::test]
620    async fn wait_gives_up_when_the_calling_agent_is_cancelled() {
621        let (h, _seen, _t) = fake_host_with_parent(
622            Ok("child-1".to_string()),
623            // The child never finishes on its own.
624            vec![Some(AgentStatus::Active); 8],
625            false,
626            Some(AgentStatus::Cancelled),
627        );
628        let out = tokio::time::timeout(
629            std::time::Duration::from_secs(5),
630            handle(&h, &tc("wait_for_agent", json!({ "agent_id": "child-1" }))),
631        )
632        .await
633        .expect("the wait returns instead of polling forever");
634        assert!(
635            out.contains("cancelled while waiting"),
636            "reports why it stopped, got: {out}"
637        );
638    }
639
640    /// `wait_for_agent` waits off the tool lane.
641    ///
642    /// The child's own tool batches queue on that lane. A parent that kept lane
643    /// capacity for the length of the wait was holding exactly what the child
644    /// needed in order to finish, so a factory of parents waiting on children
645    /// wedged itself and stayed wedged (issue #191).
646    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
647    async fn wait_does_not_hold_the_tool_lane() {
648        use leviath_runtime::tool_bridge::{ToolJob, ToolLane, ToolLaneStats};
649
650        // The child stays busy for several polls - long enough that the parent is
651        // demonstrably parked - and then finishes, so the wait is exercised to its
652        // end rather than abandoned mid-await.
653        let mut statuses = vec![Some(AgentStatus::Active); 6];
654        statuses.push(Some(AgentStatus::Complete));
655        let (h, _seen, _t) = fake_host(Ok("child-1".to_string()), statuses, false);
656
657        let (job_tx, job_rx) = tokio::sync::mpsc::unbounded_channel();
658        let (result_tx, mut results) = tokio::sync::mpsc::unbounded_channel();
659        let stats = std::sync::Arc::new(ToolLaneStats::new(1));
660        let lane = ToolLane::new(
661            tokio::runtime::Handle::current(),
662            result_tx,
663            std::sync::Arc::new(tokio::sync::Notify::new()),
664            1,
665            stats.clone(),
666        );
667        let _serving = lane.serve(job_rx);
668        let submit = |entity: u32, exec: leviath_runtime::tool_bridge::BoxedToolExec| {
669            stats.enqueued();
670            job_tx
671                .send(ToolJob {
672                    entity: bevy_ecs::entity::Entity::from_raw_u32(entity)
673                        .expect("a small index is a valid id"),
674                    exec,
675                    cancel: leviath_runtime::cancel::CancelToken::new(),
676                })
677                .expect("the lane is serving");
678        };
679
680        submit(
681            1,
682            Box::new(move || {
683                Box::pin(async move {
684                    let out =
685                        handle(&h, &tc("wait_for_agent", json!({"agent_id": "child-1"}))).await;
686                    vec![("wait".to_string(), out)]
687                })
688            }),
689        );
690        // The waiter gives the lane back rather than sitting on it.
691        tokio::time::timeout(std::time::Duration::from_secs(30), async {
692            while stats.parked() == 0 {
693                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
694            }
695        })
696        .await
697        .expect("the wait stepped off the lane");
698
699        // Which is what lets anything else run - a child's tool batch, here.
700        submit(
701            2,
702            Box::new(|| Box::pin(async { vec![("child".to_string(), "ran".to_string())] })),
703        );
704        let outcome = tokio::time::timeout(std::time::Duration::from_secs(30), results.recv())
705            .await
706            .expect("the batch behind the waiter ran")
707            .expect("an outcome arrived");
708        assert_eq!(
709            outcome.results,
710            vec![("child".to_string(), "ran".to_string())]
711        );
712
713        // And the waiter takes a permit again and reports, once its child is done.
714        let waited = tokio::time::timeout(std::time::Duration::from_secs(30), results.recv())
715            .await
716            .expect("the wait finished")
717            .expect("an outcome arrived");
718        assert_eq!(waited.results.len(), 1);
719        // Bound first: an expression that only a *failing* assertion evaluates
720        // is a region no passing run ever reaches.
721        let reported = waited.results[0].1.clone();
722        assert!(
723            reported.contains("finished with status: complete"),
724            "got: {reported}"
725        );
726    }
727
728    /// A caller the host no longer knows about (daemon shutting down, or the
729    /// run already reaped) also ends the wait - there is nothing left to wait
730    /// for either way.
731    #[tokio::test]
732    async fn wait_gives_up_when_the_caller_is_unknown_to_the_host() {
733        let (h, _seen, _t) = fake_host_with_parent(
734            Ok("child-1".to_string()),
735            vec![Some(AgentStatus::Active); 8],
736            false,
737            None, // the host has no such caller
738        );
739        let out = tokio::time::timeout(
740            std::time::Duration::from_secs(5),
741            handle(&h, &tc("wait_for_agent", json!({ "agent_id": "child-1" }))),
742        )
743        .await
744        .expect("the wait returns instead of polling forever");
745        assert!(out.contains("cancelled while waiting"), "got: {out}");
746    }
747
748    #[tokio::test]
749    async fn spawn_requires_blueprint_and_task_and_reports_resolve_errors() {
750        let (h, _seen, _t) = fake_host(Ok(String::new()), vec![], false);
751        assert!(
752            handle(&h, &tc("spawn_agent", json!({ "task": "t" })))
753                .await
754                .contains("requires 'blueprint' and 'task'")
755        );
756        assert!(
757            handle(
758                &h,
759                &tc(
760                    "spawn_agent",
761                    json!({ "blueprint": "/no/such/agent", "task": "t" })
762                )
763            )
764            .await
765            .contains("cannot spawn")
766        );
767    }
768
769    #[tokio::test]
770    async fn spawn_reports_spawner_error_and_dead_host() {
771        let bp = temp_blueprint();
772        let (h, _seen, _t) = fake_host(Err("bad blueprint".to_string()), vec![], false);
773        assert!(
774            handle(
775                &h,
776                &tc(
777                    "spawn_agent",
778                    json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t" })
779                )
780            )
781            .await
782            .contains("bad blueprint")
783        );
784        assert!(
785            handle(
786                &dead_handle(),
787                &tc(
788                    "spawn_agent",
789                    json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t" })
790                )
791            )
792            .await
793            .contains("shutting down")
794        );
795    }
796
797    #[tokio::test]
798    async fn check_reports_status_or_missing() {
799        let (h, _seen, _t) = fake_host(Ok(String::new()), vec![Some(AgentStatus::Active)], false);
800        assert!(
801            handle(&h, &tc("check_agent", json!({ "agent_id": "c" })))
802                .await
803                .contains("status: active")
804        );
805        let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
806        assert!(
807            handle(&h2, &tc("check_agent", json!({ "agent_id": "c" })))
808                .await
809                .contains("no such sub-agent")
810        );
811        // A dead host: `status_of`'s send fails, so it returns `None` early.
812        assert!(
813            handle(
814                &dead_handle(),
815                &tc("check_agent", json!({ "agent_id": "c" }))
816            )
817            .await
818            .contains("no such sub-agent")
819        );
820    }
821
822    #[tokio::test]
823    async fn wait_requires_id_and_returns_when_terminal_or_missing() {
824        assert!(
825            handle(&dead_handle(), &tc("wait_for_agent", json!({})))
826                .await
827                .contains("requires 'agent_id'")
828        );
829        let (h, _seen, _t) = fake_host(
830            Ok(String::new()),
831            vec![Some(AgentStatus::Error {
832                message: "boom".to_string(),
833            })],
834            false,
835        );
836        assert!(
837            handle(&h, &tc("wait_for_agent", json!({ "agent_id": "c" })))
838                .await
839                .contains("error: boom")
840        );
841        let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
842        assert!(
843            handle(&h2, &tc("wait_for_agent", json!({ "agent_id": "c" })))
844                .await
845                .contains("no such sub-agent")
846        );
847    }
848
849    #[tokio::test]
850    async fn send_delivers_or_reports_failure() {
851        let (h, _seen, _t) = fake_host(Ok(String::new()), vec![], true);
852        assert!(
853            handle(
854                &h,
855                &tc("send_to_agent", json!({ "agent_id": "c", "message": "hi" }))
856            )
857            .await
858            .contains("Delivered message")
859        );
860        assert!(
861            handle(&h, &tc("send_to_agent", json!({ "agent_id": "c" })))
862                .await
863                .contains("requires 'agent_id' and 'message'")
864        );
865        let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
866        assert!(
867            handle(
868                &h2,
869                &tc("send_to_agent", json!({ "agent_id": "c", "message": "hi" }))
870            )
871            .await
872            .contains("did not accept")
873        );
874        assert!(
875            handle(
876                &dead_handle(),
877                &tc("send_to_agent", json!({ "agent_id": "c", "message": "hi" }))
878            )
879            .await
880            .contains("shutting down")
881        );
882    }
883
884    /// A host that answers only `Send`, recording each op's `target_region`.
885    #[allow(clippy::type_complexity)]
886    fn send_recording_host() -> (
887        SubAgentHandle,
888        std::sync::Arc<std::sync::Mutex<Vec<Option<String>>>>,
889        tokio::task::JoinHandle<()>,
890    ) {
891        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
892        let regions = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
893        let regions_task = regions.clone();
894        let task = tokio::spawn(async move {
895            while let Some(op) = rx.recv().await {
896                match op {
897                    SubAgentOp::Send {
898                        reply,
899                        target_region,
900                        ..
901                    } => {
902                        regions_task.lock().unwrap().push(target_region);
903                        let _ = reply.send(true);
904                    }
905                    // Any other op: drop it unanswered; callers see a dropped
906                    // oneshot, which every handler already tolerates.
907                    other => drop(other),
908                }
909            }
910        });
911        (handle_with(tx), regions, task)
912    }
913
914    /// `target_region` was schema-advertised and documented but never read on
915    /// this path; the host op now carries it. Absent and empty both mean the
916    /// documented default (conversation), so they forward as `None`.
917    #[tokio::test]
918    async fn send_forwards_target_region() {
919        let (h, regions, task) = send_recording_host();
920        for args in [
921            json!({ "agent_id": "c", "message": "hi", "target_region": "notes" }),
922            json!({ "agent_id": "c", "message": "hi" }),
923            json!({ "agent_id": "c", "message": "hi", "target_region": "" }),
924        ] {
925            assert!(
926                handle(&h, &tc("send_to_agent", args))
927                    .await
928                    .contains("Delivered message")
929            );
930        }
931        assert_eq!(
932            *regions.lock().unwrap(),
933            vec![Some("notes".to_string()), None, None]
934        );
935        // A non-Send op goes through the recording host's drop arm.
936        handle(&h, &tc("check_agent", json!({ "agent_id": "c" }))).await;
937        // Closing the handle ends the host loop; the task exits cleanly.
938        drop(h);
939        task.await.unwrap();
940    }
941
942    #[tokio::test]
943    async fn kill_cancels_or_reports_missing() {
944        let (h, _seen, _t) = fake_host(Ok(String::new()), vec![], true);
945        assert!(
946            handle(&h, &tc("kill_agent", json!({ "agent_id": "c" })))
947                .await
948                .contains("Killed sub-agent")
949        );
950        assert!(
951            handle(&h, &tc("kill_agent", json!({})))
952                .await
953                .contains("requires 'agent_id'")
954        );
955        let (h2, _seen2, _t2) = fake_host(Ok(String::new()), vec![], false);
956        assert!(
957            handle(&h2, &tc("kill_agent", json!({ "agent_id": "c" })))
958                .await
959                .contains("no such sub-agent")
960        );
961        assert!(
962            handle(
963                &dead_handle(),
964                &tc("kill_agent", json!({ "agent_id": "c" }))
965            )
966            .await
967            .contains("shutting down")
968        );
969    }
970
971    #[tokio::test]
972    async fn handle_rejects_a_non_subagent_tool() {
973        assert!(
974            handle(&dead_handle(), &tc("read_file", json!({})))
975                .await
976                .contains("is not a sub-agent tool")
977        );
978    }
979
980    #[tokio::test]
981    async fn dropped_reply_paths_are_handled() {
982        let (h, t) = drop_host();
983        // status_of returns None on a dropped reply → "no such sub-agent".
984        assert!(
985            handle(&h, &tc("check_agent", json!({ "agent_id": "c" })))
986                .await
987                .contains("no such sub-agent")
988        );
989        assert!(
990            handle(
991                &h,
992                &tc("send_to_agent", json!({ "agent_id": "c", "message": "m" }))
993            )
994            .await
995            .contains("dropped the message")
996        );
997        assert!(
998            handle(&h, &tc("kill_agent", json!({ "agent_id": "c" })))
999                .await
1000                .contains("dropped the kill request")
1001        );
1002        let bp = temp_blueprint();
1003        assert!(
1004            handle(
1005                &h,
1006                &tc(
1007                    "spawn_agent",
1008                    json!({ "blueprint": bp.path().to_str().unwrap(), "task": "t" })
1009                )
1010            )
1011            .await
1012            .contains("dropped the spawn request")
1013        );
1014        drop(h);
1015        t.await.unwrap();
1016    }
1017}