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, SubAgentReport};
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    // A parent may ask its child for a particular shape. Passed through as a
125    // label, never interpreted: the child's own `submit_output` description is
126    // where it turns into an instruction. No schema here - a model composing a
127    // JSON Schema inline is a worse idea than letting the child's blueprint
128    // declare one.
129    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: // Sub-agents receive their whole task via `full_task`; no region flags.
159        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        // The tool's schema promises "its current status and result if
190        // complete", so a finished child's answer comes back with the status
191        // rather than the parent being told only that it finished.
192        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    // The whole wait happens off the tool lane. The child's own tool batches
207    // queue on that lane, so a parent that kept lane capacity while waiting was
208    // holding the very thing the child needed to finish - a parent and child
209    // deadlocked on each other, which is what froze whole factories for hours
210    // (issue #191).
211    leviath_runtime::tool_bridge::off_lane(poll_until_finished(h, agent_id)).await
212}
213
214/// Poll `agent_id` until it reaches a terminal state, or until the caller does.
215async 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                // This is what the tool has always advertised - "block until a
221                // sub-agent completes, then return its final result" - and what
222                // it never did. A parent that waited got a status label and had
223                // to agree on a file path out of band to receive any work.
224                return format!(
225                    "Sub-agent '{agent_id}' finished with status: {}{}",
226                    label(&report.status),
227                    describe_result(&report)
228                );
229            }
230            // The caller itself was cancelled (or failed) while waiting. Give up
231            // rather than keep polling for a child that is being torn down with
232            // it - this loop has no other exit, so it would otherwise run for as
233            // long as the daemon lived.
234            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
242/// Whether the agent that called `wait_for_agent` has itself reached a terminal
243/// state. A dropped request (daemon shutting down) counts as terminal - there is
244/// nothing left to wait for either way.
245async 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    // Empty string means unset, same as absent: delivery defaults to the
259    // conversation region, which is what the tool's schema documents.
260    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
308/// Query a child's status via the host, `None` if it dropped the request or the
309/// run is unknown.
310async 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
321/// Just the status, for the callers that only need to know whether a run is
322/// still going.
323async fn status_of(h: &SubAgentHandle, agent_id: &str) -> Option<AgentStatus> {
324    report_of(h, agent_id).await.map(|r| r.status)
325}
326
327/// Render a finished child's answer for its parent to read.
328///
329/// A child that submitted nothing says so rather than reporting an empty
330/// result: "produced no final output" is actionable (the parent can ask, or
331/// route around it), and a bare status line looks like success.
332fn 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
360/// What the parent model is told a child's status is. `Display` rather than
361/// `label` so a failed child reports why it failed, which is the whole reason
362/// the parent asked.
363fn label(status: &AgentStatus) -> String {
364    status.to_string()
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    /// The escalation this closes: `write_file` is confined to the workdir, but
372    /// the spawner was not - so a model could author `x/agent.leviath` in its own
373    /// workspace and spawn it, and the child is built with seeds enforced, so
374    /// that manifest's command seeds ran on the host before its first inference.
375    #[tokio::test]
376    async fn spawn_refuses_a_blueprint_the_agent_could_have_written() {
377        let work = tempfile::tempdir().unwrap();
378        // Exactly what the model would produce: a manifest inside its workdir.
379        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    /// And a blueprint from outside the workspace is untouched - an installed
407    /// agent by name, or a path a human chose.
408    #[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        // The receiver is dropped so the op fails fast rather than waiting on a
419        // reply no host is here to send. What this asserts is that the path
420        // check let the blueprint through, not that a spawn succeeded.
421        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            // This crate's own directory, deliberately *not* the system temp
452            // dir: `temp_blueprint()` writes under temp, and on Linux that is
453            // `/tmp` - so a workdir of `/tmp` made every fixture blueprint look
454            // like one the agent had planted in its own workspace, and the
455            // containment guard refused them all. macOS puts tempdirs under
456            // `$TMPDIR` in `/var/folders`, so nothing local caught it.
457            workdir: env!("CARGO_MANIFEST_DIR").to_string(),
458            max_depth: 3,
459            no_seed_commands: false,
460            unattended: false,
461        }
462    }
463
464    /// A `SubAgentHandle` whose host answers each op from plain canned values -
465    /// no per-call-site closures, so this single service loop is the only region
466    /// (covered collectively across the suite). `spawn_result` answers `Spawn`
467    /// and the received args are recorded into the returned `Vec` for assertions;
468    /// `statuses` answers successive `Check`s for *children* in order (`None`
469    /// once exhausted); `ok` answers `Send`/`Kill`. The caller ("parent") is
470    /// reported `Active` - see [`fake_host_with_parent`] to script it.
471    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    /// [`fake_host`] with the child's submitted answer scripted too, so the
484    /// "return its final result" half of `check`/`wait` can be exercised.
485    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    /// [`fake_host`] with the calling agent's own status scripted too.
503    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    /// The one fake behind the three wrappers above.
517    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                    // `wait` polls the *caller* as well as the child (to bail out
540                    // if the caller was itself cancelled), so the scripted queue
541                    // answers only for children - the caller is reported Active
542                    // unless a test scripts it otherwise.
543                    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    /// A host that drops every op without replying - the handler then sees a
568    /// dropped oneshot.
569    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    /// A handle whose host is already gone (sends fail immediately).
580    fn dead_handle() -> SubAgentHandle {
581        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
582        handle_with(tx)
583    }
584
585    /// Write a minimal valid blueprint into a temp dir and return that dir (whose
586    /// path `find_manifest` resolves to `<dir>/agent.leviath`).
587    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 the handle and drain the host task - covers the loop's exit.
675        drop(h);
676        t.await.unwrap();
677        // The seed context was folded into the child's task.
678        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    /// A child of an unattended parent is unattended. Spawning it attended left
685    /// it stopped at its first approval prompt with nobody there to answer, and
686    /// parked the parent behind it for good (issue #184).
687    #[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        // Active on the first poll, Complete after.
714        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    /// `wait_for_agent` gives up when the *calling* agent is cancelled. The loop
731    /// has no other exit, so a cancelled caller would otherwise poll for a child
732    /// that is being torn down with it until the daemon exits.
733    #[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            // The child never finishes on its own.
738            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    /// `wait_for_agent` waits off the tool lane.
755    ///
756    /// The child's own tool batches queue on that lane. A parent that kept lane
757    /// capacity for the length of the wait was holding exactly what the child
758    /// needed in order to finish, so a factory of parents waiting on children
759    /// wedged itself and stayed wedged (issue #191).
760    #[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        // The child stays busy for several polls - long enough that the parent is
765        // demonstrably parked - and then finishes, so the wait is exercised to its
766        // end rather than abandoned mid-await.
767        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        // The waiter gives the lane back rather than sitting on it.
805        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        // Which is what lets anything else run - a child's tool batch, here.
814        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        // And the waiter takes a permit again and reports, once its child is done.
828        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        // Bound first: an expression that only a *failing* assertion evaluates
834        // is a region no passing run ever reaches.
835        let reported = waited.results[0].1.clone();
836        assert!(
837            reported.contains("finished with status: complete"),
838            "got: {reported}"
839        );
840    }
841
842    /// A caller the host no longer knows about (daemon shutting down, or the
843    /// run already reaped) also ends the wait - there is nothing left to wait
844    /// for either way.
845    #[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, // the host has no such caller
852        );
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        // A dead host: `status_of`'s send fails, so it returns `None` early.
926        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    /// A host that answers only `Send`, plus what a test needs to assert on it.
999    struct SendRecordingHost {
1000        /// The handle under test.
1001        handle: SubAgentHandle,
1002        /// Each `Send` op's `target_region`, in arrival order.
1003        regions: std::sync::Arc<std::sync::Mutex<Vec<Option<String>>>>,
1004        /// The service loop, joined at the end of the test.
1005        task: tokio::task::JoinHandle<()>,
1006    }
1007
1008    /// A host that answers only `Send`, recording each op's `target_region`.
1009    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                    // Any other op: drop it unanswered; callers see a dropped
1025                    // oneshot, which every handler already tolerates.
1026                    other => drop(other),
1027                }
1028            }
1029        });
1030        SendRecordingHost {
1031            handle: handle_with(tx),
1032            regions,
1033            task,
1034        }
1035    }
1036
1037    /// `target_region` was schema-advertised and documented but never read on
1038    /// this path; the host op now carries it. Absent and empty both mean the
1039    /// documented default (conversation), so they forward as `None`.
1040    #[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        // A non-Send op goes through the recording host's drop arm.
1063        handle(&h, &tc("check_agent", json!({ "agent_id": "c" }))).await;
1064        // Closing the handle ends the host loop; the task exits cleanly.
1065        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        // status_of returns None on a dropped reply → "no such sub-agent".
1111        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    /// `wait_for_agent`'s schema has always said "block until a sub-agent
1155    /// completes, then return its final result". It returned a status label and
1156    /// nothing else, so a parent had to agree on a file path out of band to
1157    /// receive any work at all.
1158    #[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    /// A child still working has nothing to report yet, so the status line
1181    /// stands alone rather than claiming an empty answer.
1182    #[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    /// A child whose answer hit the size limit says so, so the parent reads a
1191    /// partial answer as partial rather than as everything the child had.
1192    #[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    /// "produced no final output" is actionable - the parent can ask, or route
1208    /// around it. A bare status line reads as success.
1209    #[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    /// A parent may ask its child for a shape. It travels as a label, so a
1217    /// format nothing in this crate has heard of reaches the child intact.
1218    #[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        // A model composing a JSON Schema inline is a worse idea than letting
1243        // the child's blueprint declare one, so the tool does not offer it.
1244        assert!(spec.schema.is_none());
1245    }
1246
1247    /// A spawn that asks for nothing leaves the child's blueprint in charge.
1248    #[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}