1use std::collections::HashMap;
7
8use anyhow::bail;
9use leviath_runtime::control_socket::{ControlClient, ControlResponse};
10use leviath_runtime::host::SpawnArgs;
11
12use crate::commands::run::manifest::find_manifest;
13use crate::commands::run::task::{read_region_value, resolve_task};
14use crate::runstate::new_run_id;
15
16pub struct AgentSource {
18 pub manifest: std::path::PathBuf,
20 pub run_stem: String,
25 pub blueprint: leviath_core::Blueprint,
27}
28
29pub fn load_agent_source(path: &str) -> anyhow::Result<AgentSource> {
38 let found = find_manifest(path)?;
39 let manifest = std::fs::canonicalize(&found).unwrap_or(found);
53 let run_stem = manifest
54 .parent()
55 .and_then(|p| p.file_name())
56 .and_then(|n| n.to_str())
57 .unwrap_or("agent")
58 .to_string();
59 let content = std::fs::read_to_string(&manifest)
60 .map_err(|e| anyhow::anyhow!("read manifest '{}': {e}", manifest.display()))?;
61 let blueprint = leviath_core::manifest::parse_manifest(&content)
62 .map_err(|e| anyhow::anyhow!("parse manifest: {e}"))?;
63 Ok(AgentSource {
64 manifest,
65 run_stem,
66 blueprint,
67 })
68}
69
70fn resolve_regions(
76 blueprint: &leviath_core::Blueprint,
77 regions: HashMap<String, String>,
78) -> anyhow::Result<HashMap<String, String>> {
79 let declared = blueprint.caller_inputs();
80 let mut out = HashMap::new();
81 for (name, raw) in regions {
82 if !declared.contains(&name.as_str()) {
83 bail!(
84 "unknown region '--{name}'; this agent's caller-input regions are: {}",
85 if declared.is_empty() {
86 "(none)".to_string()
87 } else {
88 declared.join(", ")
89 }
90 );
91 }
92 out.insert(name, read_region_value(&raw)?);
93 }
94 Ok(out)
95}
96
97pub fn never_interactive() -> bool {
106 false
107}
108
109pub struct LaunchRequest<'a> {
116 pub path: &'a str,
118 pub task: Option<&'a str>,
120 pub stdin_is_terminal: &'a dyn Fn() -> bool,
122 pub model: Option<String>,
124 pub workdir: &'a str,
126 pub yolo: bool,
128 pub allow: Vec<String>,
130 pub max_depth: Option<usize>,
132 pub regions: HashMap<String, String>,
134 pub no_seed_commands: bool,
136 pub output_request: Option<leviath_core::output::OutputSpec>,
138}
139
140pub fn resolve_spawn_args(req: LaunchRequest<'_>) -> anyhow::Result<SpawnArgs> {
155 let LaunchRequest {
156 path,
157 task,
158 stdin_is_terminal,
159 model,
160 workdir,
161 yolo,
162 allow,
163 max_depth,
164 regions,
165 no_seed_commands,
166 output_request,
167 } = req;
168 let source = load_agent_source(path)?;
169 let resolved_regions = resolve_regions(&source.blueprint, regions)?;
170 let task = match source.blueprint.accepts_task() {
175 true => resolve_task(
176 task,
177 &source.blueprint.name,
178 &source.blueprint.description,
179 stdin_is_terminal,
180 )?,
181 false => match task.map(str::trim).unwrap_or("") {
182 "" => String::new(),
183 _ => anyhow::bail!(source.blueprint.task_refusal()),
184 },
185 };
186
187 Ok(SpawnArgs {
188 run_id: new_run_id(&source.run_stem),
189 blueprint_path: source.manifest.to_string_lossy().to_string(),
190 task,
191 regions: resolved_regions,
192 model,
193 workdir: workdir.to_string(),
194 metadata: Default::default(),
195 callback_url: None,
196 callback_secret: None,
197 yolo,
198 no_seed_commands,
199 allow,
200 max_depth,
201 parent_run_id: None,
203 output: output_request,
204 })
205}
206
207fn warn_ungranted_read_paths(spawn_args: &SpawnArgs) {
219 for line in read_path_warning_for_spawn(spawn_args) {
220 eprintln!("{line}");
221 }
222}
223
224fn read_path_warning_for_spawn(spawn_args: &SpawnArgs) -> Vec<String> {
228 let Ok(content) = std::fs::read_to_string(&spawn_args.blueprint_path) else {
229 return Vec::new();
230 };
231 let Ok(blueprint) = leviath_core::manifest::parse_manifest(&content) else {
232 return Vec::new();
233 };
234 let Ok(config) = crate::config::Config::load() else {
235 return Vec::new();
236 };
237 spawn_warning_lines(
238 &blueprint,
239 &config,
240 std::path::Path::new(&spawn_args.workdir),
241 )
242}
243
244fn spawn_warning_lines(
247 blueprint: &leviath_core::Blueprint,
248 config: &crate::config::Config,
249 workdir: &std::path::Path,
250) -> Vec<String> {
251 let Some(Ok(report)) = crate::read_path_report::build(blueprint, config, workdir) else {
252 return Vec::new();
253 };
254 let Some(warning) = report.warning_line() else {
255 return Vec::new();
256 };
257 let mut lines = vec![warning];
258 lines.push(" add to your config.toml:".to_string());
259 lines.extend(
260 report
261 .grant_stanza()
262 .into_iter()
263 .map(|l| format!(" {l}")),
264 );
265 lines
266}
267
268fn warn_held_checkpoints(spawn_args: &SpawnArgs) {
278 for line in held_checkpoint_warning_for_spawn(spawn_args) {
279 eprintln!("{line}");
280 }
281}
282
283fn held_checkpoint_warning_for_spawn(spawn_args: &SpawnArgs) -> Vec<String> {
292 let path = std::path::Path::new(&spawn_args.blueprint_path);
293 let Ok(content) = std::fs::read_to_string(path) else {
294 return Vec::new();
295 };
296 let Ok(blueprint) = leviath_core::manifest::parse_manifest(&content) else {
297 return Vec::new();
298 };
299 let mut lines: Vec<String> =
300 crate::bundled::stale_install_note(path, &blueprint, leviath_core::agents_dir().as_deref())
301 .into_iter()
302 .collect();
303 if spawn_args.yolo {
304 let timeout = crate::config::Config::load()
305 .map(|c| c.limits.interaction_timeout_secs)
306 .unwrap_or(leviath_runtime::interaction_hub::DEFAULT_INTERACTION_TIMEOUT_SECS);
307 lines.extend(crate::held_checkpoints::preflight_lines(
308 &blueprint, timeout,
309 ));
310 }
311 lines
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
321pub struct SpawnedRun {
322 pub run_id: String,
324 pub blueprint_path: String,
326 pub workdir: String,
328 pub yolo: bool,
330}
331
332pub fn spawn_report(spawned: &SpawnedRun, json: bool) -> String {
336 match json {
337 true => serde_json::to_string_pretty(spawned).expect("a spawn report serializes"),
339 false => format!("spawned {}", spawned.run_id),
340 }
341}
342
343pub fn batch_report(spawned: &[SpawnedRun], json: bool) -> String {
348 match json {
349 true => serde_json::to_string_pretty(spawned).expect("spawn reports serialize"),
350 false => spawned
351 .iter()
352 .map(|s| format!("spawned {}", s.run_id))
353 .collect::<Vec<_>>()
354 .join("\n"),
355 }
356}
357
358fn respawned_run_id(previous: &str) -> String {
366 let mut parts = previous.rsplitn(3, '-');
367 let _entropy = parts.next();
368 let _secs = parts.next();
369 let stem = parts.next().unwrap_or(previous);
370 crate::runstate::new_run_id(stem)
371}
372
373pub async fn send_spawn(
378 client: &ControlClient,
379 spawn_args: SpawnArgs,
380 json: bool,
381) -> anyhow::Result<()> {
382 warn_ungranted_read_paths(&spawn_args);
383 warn_held_checkpoints(&spawn_args);
384 let spawned = spawn_once(client, spawn_args).await?;
385 println!("{}", spawn_report(&spawned, json));
386 Ok(())
387}
388
389pub async fn send_spawn_batch(
402 client: &ControlClient,
403 spawn_args: SpawnArgs,
404 count: usize,
405 json: bool,
406) -> anyhow::Result<()> {
407 if count == 0 {
408 bail!("--count must be at least 1");
409 }
410 if count == 1 {
411 return send_spawn(client, spawn_args, json).await;
412 }
413 warn_ungranted_read_paths(&spawn_args);
415 warn_held_checkpoints(&spawn_args);
416 let mut spawned = Vec::with_capacity(count);
417 for _ in 0..count {
418 let mut args = spawn_args.clone();
419 args.run_id = respawned_run_id(&spawn_args.run_id);
420 match spawn_once(client, args).await {
421 Ok(run) => spawned.push(run),
422 Err(e) => bail!(
423 "batch stopped after {} of {count} runs started (those keep \
424 running; see `lev ps`): {e}",
425 spawned.len()
426 ),
427 }
428 }
429 println!("{}", batch_report(&spawned, json));
430 Ok(())
431}
432
433async fn spawn_once(client: &ControlClient, spawn_args: SpawnArgs) -> anyhow::Result<SpawnedRun> {
435 let blueprint_path = spawn_args.blueprint_path.clone();
436 let workdir = spawn_args.workdir.clone();
437 let yolo = spawn_args.yolo;
438 match client.spawn(spawn_args).await {
439 Ok(ControlResponse::Spawned { run_id }) => Ok(SpawnedRun {
440 run_id,
441 blueprint_path,
442 workdir,
443 yolo,
444 }),
445 Ok(ControlResponse::Error { message }) => bail!("spawn failed: {message}"),
446 Ok(other) => bail!("unexpected daemon response: {other:?}"),
447 Err(e) => bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`"),
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use leviath_runtime::control_socket::{ControlId, bind_control_listener, control_id};
455 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
456 use tokio::task::JoinHandle;
457
458 fn write_manifest(dir: &std::path::Path) -> std::path::PathBuf {
459 std::fs::write(
460 dir.join("agent.leviath"),
461 crate::test_support::inline_coder_manifest(),
462 )
463 .unwrap();
464 dir.join("agent.leviath")
465 }
466
467 #[test]
468 fn resolve_spawn_args_finds_manifest_and_builds_request() {
469 let dir = tempfile::tempdir().unwrap();
470 let agent_dir = dir.path().join("my-agent");
471 std::fs::create_dir_all(&agent_dir).unwrap();
472 let manifest = write_manifest(&agent_dir);
473
474 let args = resolve_spawn_args(LaunchRequest {
475 path: manifest.to_str().unwrap(),
476 task: Some("do it"),
477 stdin_is_terminal: &never_interactive,
478 model: Some("m".to_string()),
479 workdir: "/work",
480 yolo: false,
481 allow: Vec::new(),
482 max_depth: None,
483 regions: HashMap::new(),
484 no_seed_commands: false,
485 output_request: None,
486 })
487 .unwrap();
488 assert!(args.run_id.contains("my-agent"));
489 assert_eq!(args.task, "do it");
490 assert_eq!(args.model.as_deref(), Some("m"));
491 assert_eq!(
492 args.blueprint_path,
493 std::fs::canonicalize(&manifest).unwrap().to_string_lossy()
494 );
495 assert_eq!(args.workdir, "/work");
496 }
497
498 #[test]
503 fn resolve_spawn_args_sends_an_absolute_blueprint_path_for_a_relative_input() {
504 let _guard = crate::config::isolate_cwd_for_test();
509 let dir = tempfile::Builder::new()
515 .prefix("lev-relpath-")
516 .tempdir_in(".")
517 .unwrap();
518 let agent_dir = dir.path().join("my-agent");
519 std::fs::create_dir_all(&agent_dir).unwrap();
520 write_manifest(&agent_dir);
521
522 let relative = std::path::Path::new(".")
525 .join(dir.path().file_name().unwrap())
526 .join("my-agent");
527 assert!(relative.is_relative(), "expected a relative path");
531
532 let args = resolve_spawn_args(LaunchRequest {
533 path: relative.to_str().unwrap(),
534 task: Some("do it"),
535 stdin_is_terminal: &never_interactive,
536 model: None,
537 workdir: "/work",
538 yolo: false,
539 allow: Vec::new(),
540 max_depth: None,
541 regions: HashMap::new(),
542 no_seed_commands: false,
543 output_request: None,
544 })
545 .unwrap();
546 assert!(
547 std::path::Path::new(&args.blueprint_path).is_absolute(),
548 "got: {}",
549 args.blueprint_path
550 );
551 assert!(args.blueprint_path.ends_with("agent.leviath"));
552 }
553
554 #[test]
555 fn resolve_spawn_args_errors_on_missing_manifest() {
556 assert!(
557 resolve_spawn_args(LaunchRequest {
558 path: "/no/such/agent",
559 task: Some("t"),
560 stdin_is_terminal: &never_interactive,
561 model: None,
562 workdir: "/work",
563 yolo: false,
564 allow: Vec::new(),
565 max_depth: None,
566 regions: HashMap::new(),
567 no_seed_commands: false,
568 output_request: None,
569 })
570 .is_err()
571 );
572 }
573
574 #[test]
577 fn resolve_spawn_args_reads_the_task_from_a_file() {
578 let dir = tempfile::tempdir().unwrap();
579 let agent_dir = dir.path().join("my-agent");
580 std::fs::create_dir_all(&agent_dir).unwrap();
581 let manifest = write_manifest(&agent_dir);
582 let task_file = dir.path().join("task.md");
583 std::fs::write(&task_file, " summarize the README \n").unwrap();
584
585 let args = resolve_spawn_args(LaunchRequest {
586 path: manifest.to_str().unwrap(),
587 task: Some(task_file.to_str().unwrap()),
588 stdin_is_terminal: &never_interactive,
589 model: None,
590 workdir: "/work",
591 yolo: false,
592 allow: Vec::new(),
593 max_depth: None,
594 regions: HashMap::new(),
595 no_seed_commands: false,
596 output_request: None,
597 })
598 .unwrap();
599 assert_eq!(args.task, "summarize the README");
600 }
601
602 #[test]
605 fn resolve_spawn_args_without_a_task_errors_when_stdin_is_not_a_tty() {
606 let dir = tempfile::tempdir().unwrap();
607 let agent_dir = dir.path().join("my-agent");
608 std::fs::create_dir_all(&agent_dir).unwrap();
609 let manifest = write_manifest(&agent_dir);
610
611 let err = resolve_spawn_args(LaunchRequest {
612 path: manifest.to_str().unwrap(),
613 task: None,
614 stdin_is_terminal: &never_interactive,
615 model: None,
616 workdir: "/work",
617 yolo: false,
618 allow: Vec::new(),
619 max_depth: None,
620 regions: HashMap::new(),
621 no_seed_commands: false,
622 output_request: None,
623 })
624 .unwrap_err();
625 assert!(err.to_string().contains("No task provided"), "got: {err}");
626 }
627
628 fn write_taskless_manifest(dir: &std::path::Path) -> std::path::PathBuf {
630 std::fs::create_dir_all(dir).unwrap();
631 std::fs::write(
632 dir.join("agent.leviath"),
633 r#"
634[agent]
635name = "diffonly"
636
637[stages.main]
638mode = "autonomous"
639
640[stages.main.model]
641provider = "anthropic"
642model = "claude-sonnet-5"
643
644[context.regions]
645diff = { kind = "pinned", max_tokens = 4000, seed = "diff" }
646conversation = { kind = "sliding_window", max_items = 20, max_tokens = 10000 }
647"#,
648 )
649 .unwrap();
650 dir.join("agent.leviath")
651 }
652
653 #[test]
657 fn an_agent_that_takes_no_task_is_not_asked_for_one() {
658 let dir = tempfile::tempdir().unwrap();
659 let manifest = write_taskless_manifest(&dir.path().join("diffonly"));
660 let mut regions = HashMap::new();
661 regions.insert("diff".to_string(), "a patch".to_string());
662
663 let args = resolve_spawn_args(LaunchRequest {
664 path: manifest.to_str().unwrap(),
665 task: None,
666 stdin_is_terminal: &never_interactive,
669 model: None,
670 workdir: "/work",
671 yolo: false,
672 allow: Vec::new(),
673 max_depth: None,
674 regions,
675 no_seed_commands: false,
676 output_request: None,
677 })
678 .expect("no task is required of an agent that takes none");
679 assert_eq!(args.task, "");
680 assert_eq!(
681 args.regions.get("diff").map(String::as_str),
682 Some("a patch")
683 );
684 }
685
686 #[test]
689 fn an_agent_that_takes_no_task_refuses_one() {
690 let dir = tempfile::tempdir().unwrap();
691 let manifest = write_taskless_manifest(&dir.path().join("diffonly"));
692
693 let err = resolve_spawn_args(LaunchRequest {
694 path: manifest.to_str().unwrap(),
695 task: Some("review my code"),
696 stdin_is_terminal: &never_interactive,
697 model: None,
698 workdir: "/work",
699 yolo: false,
700 allow: Vec::new(),
701 max_depth: None,
702 regions: HashMap::new(),
703 no_seed_commands: false,
704 output_request: None,
705 })
706 .unwrap_err();
707 let msg = err.to_string();
708 assert!(
709 msg.contains("declares no region to put it in"),
710 "got: {msg}"
711 );
712 assert!(msg.contains("it takes: diff"), "got: {msg}");
713 }
714
715 #[test]
718 fn a_blank_task_is_not_a_task() {
719 let dir = tempfile::tempdir().unwrap();
720 let manifest = write_taskless_manifest(&dir.path().join("diffonly"));
721
722 let args = resolve_spawn_args(LaunchRequest {
723 path: manifest.to_str().unwrap(),
724 task: Some(" "),
725 stdin_is_terminal: &never_interactive,
726 model: None,
727 workdir: "/work",
728 yolo: false,
729 allow: Vec::new(),
730 max_depth: None,
731 regions: HashMap::new(),
732 no_seed_commands: false,
733 output_request: None,
734 })
735 .expect("blank is the same as absent");
736 assert_eq!(args.task, "");
737 }
738
739 #[test]
742 fn resolve_spawn_args_rejects_a_bad_region_before_it_looks_at_the_task() {
743 let dir = tempfile::tempdir().unwrap();
744 let manifest = write_region_manifest(&dir.path().join("reviewer"));
745 let regions = HashMap::from([("bogus".to_string(), "x".to_string())]);
746
747 let err = resolve_spawn_args(LaunchRequest {
748 path: manifest.to_str().unwrap(),
749 task: None,
750 stdin_is_terminal: &never_interactive,
751 model: None,
752 workdir: "/work",
753 yolo: false,
754 allow: Vec::new(),
755 max_depth: None,
756 regions,
757 no_seed_commands: false,
758 output_request: None,
759 })
760 .unwrap_err();
761 assert!(err.to_string().contains("unknown region"), "got: {err}");
762 }
763
764 fn write_region_manifest(dir: &std::path::Path) -> std::path::PathBuf {
767 std::fs::create_dir_all(dir).unwrap();
768 std::fs::write(
769 dir.join("agent.leviath"),
770 r#"
771[agent]
772name = "reviewer"
773
774[stages.main]
775mode = "autonomous"
776
777[stages.main.model]
778provider = "anthropic"
779model = "claude-sonnet-5"
780
781[context.regions]
782task = { kind = "pinned", max_tokens = 4000, seed = "task_input" }
783criteria = { kind = "pinned", max_tokens = 2000, seed = "input" }
784conversation = { kind = "sliding_window", max_items = 20, max_tokens = 10000 }
785"#,
786 )
787 .unwrap();
788 dir.join("agent.leviath")
789 }
790
791 #[test]
792 fn resolve_spawn_args_resolves_declared_region_and_reads_at_path() {
793 let dir = tempfile::tempdir().unwrap();
794 let manifest = write_region_manifest(&dir.path().join("reviewer"));
795 let policy = dir.path().join("policy.md");
796 std::fs::write(&policy, " focus on safety ").unwrap();
797
798 let regions = HashMap::from([(
799 "criteria".to_string(),
800 format!("@{}", policy.to_string_lossy()),
801 )]);
802 let args = resolve_spawn_args(LaunchRequest {
803 path: manifest.to_str().unwrap(),
804 task: Some("review it"),
805 stdin_is_terminal: &never_interactive,
806 model: None,
807 workdir: "/work",
808 yolo: false,
809 allow: Vec::new(),
810 max_depth: None,
811 regions,
812 no_seed_commands: false,
813 output_request: None,
814 })
815 .unwrap();
816 assert_eq!(
818 args.regions.get("criteria").map(String::as_str),
819 Some("focus on safety")
820 );
821 }
822
823 #[test]
824 fn resolve_spawn_args_unknown_region_reports_none_when_no_caller_inputs() {
825 let dir = tempfile::tempdir().unwrap();
827 let agent_dir = dir.path().join("noinput");
828 std::fs::create_dir_all(&agent_dir).unwrap();
829 std::fs::write(
830 agent_dir.join("agent.leviath"),
831 r#"
832[agent]
833name = "noinput"
834
835[stages.main]
836mode = "autonomous"
837
838[stages.main.model]
839provider = "anthropic"
840model = "claude-sonnet-5"
841
842[context.regions]
843data = { kind = "pinned", max_tokens = 2000 }
844conversation = { kind = "sliding_window", max_items = 20, max_tokens = 10000 }
845"#,
846 )
847 .unwrap();
848 let manifest = agent_dir.join("agent.leviath");
849 let regions = HashMap::from([("foo".to_string(), "x".to_string())]);
850 let err = resolve_spawn_args(LaunchRequest {
851 path: manifest.to_str().unwrap(),
852 task: Some("t"),
853 stdin_is_terminal: &never_interactive,
854 model: None,
855 workdir: "/work",
856 yolo: false,
857 allow: Vec::new(),
858 max_depth: None,
859 regions,
860 no_seed_commands: false,
861 output_request: None,
862 })
863 .unwrap_err();
864 assert!(err.to_string().contains("(none)"), "got: {err}");
865 }
866
867 #[test]
868 fn resolve_spawn_args_manifest_read_error_surfaces() {
869 let dir = tempfile::tempdir().unwrap();
872 let agent_dir = dir.path().join("dirmanifest");
873 std::fs::create_dir_all(agent_dir.join("agent.leviath")).unwrap();
874 let regions = HashMap::from([("x".to_string(), "y".to_string())]);
875 let err = resolve_spawn_args(LaunchRequest {
876 path: agent_dir.to_str().unwrap(),
877 task: Some("t"),
878 stdin_is_terminal: &never_interactive,
879 model: None,
880 workdir: "/work",
881 yolo: false,
882 allow: Vec::new(),
883 max_depth: None,
884 regions,
885 no_seed_commands: false,
886 output_request: None,
887 })
888 .unwrap_err();
889 assert!(err.to_string().contains("read manifest"), "got: {err}");
890 }
891
892 #[test]
893 fn resolve_spawn_args_manifest_parse_error_surfaces() {
894 let dir = tempfile::tempdir().unwrap();
895 let agent_dir = dir.path().join("badtoml");
896 std::fs::create_dir_all(&agent_dir).unwrap();
897 std::fs::write(
898 agent_dir.join("agent.leviath"),
899 "this is : not = valid toml [[[",
900 )
901 .unwrap();
902 let regions = HashMap::from([("x".to_string(), "y".to_string())]);
903 let err = resolve_spawn_args(LaunchRequest {
904 path: agent_dir.join("agent.leviath").to_str().unwrap(),
905 task: Some("t"),
906 stdin_is_terminal: &never_interactive,
907 model: None,
908 workdir: "/work",
909 yolo: false,
910 allow: Vec::new(),
911 max_depth: None,
912 regions,
913 no_seed_commands: false,
914 output_request: None,
915 })
916 .unwrap_err();
917 assert!(err.to_string().contains("parse manifest"), "got: {err}");
918 }
919
920 #[test]
921 fn resolve_spawn_args_region_value_bad_file_errors() {
922 let dir = tempfile::tempdir().unwrap();
925 let manifest = write_region_manifest(&dir.path().join("reviewer"));
926 let regions = HashMap::from([("criteria".to_string(), "@/no/such/file.md".to_string())]);
927 let err = resolve_spawn_args(LaunchRequest {
928 path: manifest.to_str().unwrap(),
929 task: Some("review it"),
930 stdin_is_terminal: &never_interactive,
931 model: None,
932 workdir: "/work",
933 yolo: false,
934 allow: Vec::new(),
935 max_depth: None,
936 regions,
937 no_seed_commands: false,
938 output_request: None,
939 })
940 .unwrap_err();
941 assert!(
942 err.to_string().contains("Failed to read region file"),
943 "got: {err}"
944 );
945 }
946
947 #[test]
948 fn resolve_spawn_args_rejects_unknown_region_flag() {
949 let dir = tempfile::tempdir().unwrap();
950 let manifest = write_region_manifest(&dir.path().join("reviewer"));
951 let regions = HashMap::from([("bogus".to_string(), "x".to_string())]);
952 let err = resolve_spawn_args(LaunchRequest {
953 path: manifest.to_str().unwrap(),
954 task: Some("review it"),
955 stdin_is_terminal: &never_interactive,
956 model: None,
957 workdir: "/work",
958 yolo: false,
959 allow: Vec::new(),
960 max_depth: None,
961 regions,
962 no_seed_commands: false,
963 output_request: None,
964 })
965 .unwrap_err();
966 assert!(
967 err.to_string().contains("unknown region '--bogus'"),
968 "got: {err}"
969 );
970 }
971
972 fn fake_daemon(
975 dir: &std::path::Path,
976 response_line: &'static str,
977 ) -> (ControlId, JoinHandle<()>) {
978 let id = control_id(dir);
979 let mut listener = bind_control_listener(&id).unwrap();
980 let handle = tokio::spawn(async move {
981 let stream = listener
982 .accept()
983 .await
984 .expect("accept succeeds")
985 .expect("our own connection is admitted");
986 let (read_half, mut write_half) = tokio::io::split(stream);
987 let mut lines = BufReader::new(read_half).lines();
988 let _request = lines.next_line().await.unwrap();
989 write_half
990 .write_all(response_line.as_bytes())
991 .await
992 .unwrap();
993 write_half.write_all(b"\n").await.unwrap();
994 });
995 (id, handle)
996 }
997
998 async fn send(response_line: &'static str) -> anyhow::Result<()> {
999 let dir = tempfile::tempdir().unwrap();
1000 let (id, server) = fake_daemon(dir.path(), response_line);
1001 let result = send_spawn(&ControlClient::new(id), SpawnArgs::default(), false).await;
1002 server.await.unwrap();
1003 result
1004 }
1005
1006 fn fake_daemon_serving(
1010 dir: &std::path::Path,
1011 responses: Vec<&'static str>,
1012 ) -> (ControlId, JoinHandle<()>) {
1013 let id = control_id(dir);
1014 let mut listener = bind_control_listener(&id).unwrap();
1015 let handle = tokio::spawn(async move {
1016 for response_line in responses {
1017 let stream = listener
1018 .accept()
1019 .await
1020 .expect("accept succeeds")
1021 .expect("our own connection is admitted");
1022 let (read_half, mut write_half) = tokio::io::split(stream);
1023 let mut lines = BufReader::new(read_half).lines();
1024 let _request = lines.next_line().await.unwrap();
1025 write_half
1026 .write_all(response_line.as_bytes())
1027 .await
1028 .unwrap();
1029 write_half.write_all(b"\n").await.unwrap();
1030 }
1031 });
1032 (id, handle)
1033 }
1034
1035 #[tokio::test]
1036 async fn a_batch_spawn_starts_count_runs_and_reports_them_all() {
1037 let dir = tempfile::tempdir().unwrap();
1038 let (id, server) = fake_daemon_serving(
1039 dir.path(),
1040 vec![
1041 r#"{"result":"spawned","run_id":"a-1-000000000001"}"#,
1042 r#"{"result":"spawned","run_id":"a-1-000000000002"}"#,
1043 r#"{"result":"spawned","run_id":"a-1-000000000003"}"#,
1044 ],
1045 );
1046 let args = SpawnArgs {
1047 run_id: "wide-researcher-1785900000-0123456789ab".to_string(),
1048 ..SpawnArgs::default()
1049 };
1050 send_spawn_batch(&ControlClient::new(id), args, 3, false)
1051 .await
1052 .expect("all three spawn");
1053 server.await.unwrap();
1054 }
1055
1056 #[tokio::test]
1057 async fn a_batch_stopped_mid_way_says_how_many_runs_already_started() {
1058 let dir = tempfile::tempdir().unwrap();
1059 let (id, server) = fake_daemon_serving(
1060 dir.path(),
1061 vec![
1062 r#"{"result":"spawned","run_id":"a-1-000000000001"}"#,
1063 r#"{"result":"error","message":"the world is full"}"#,
1064 ],
1065 );
1066 let err = send_spawn_batch(&ControlClient::new(id), SpawnArgs::default(), 3, false)
1067 .await
1068 .expect_err("the second spawn fails");
1069 let text = err.to_string();
1073 assert!(text.contains("after 1 of 3"), "got: {text}");
1074 assert!(text.contains("the world is full"), "got: {text}");
1075 server.await.unwrap();
1076 }
1077
1078 #[tokio::test]
1079 async fn a_batch_of_one_is_exactly_a_single_spawn() {
1080 let dir = tempfile::tempdir().unwrap();
1081 let (id, server) = fake_daemon(dir.path(), r#"{"result":"spawned","run_id":"solo-1-0"}"#);
1082 send_spawn_batch(&ControlClient::new(id), SpawnArgs::default(), 1, false)
1083 .await
1084 .expect("the single spawn succeeds");
1085 server.await.unwrap();
1086 }
1087
1088 #[tokio::test]
1089 async fn a_batch_of_zero_is_refused_before_any_daemon_contact() {
1090 let dir = tempfile::tempdir().unwrap();
1091 let id = control_id(dir.path());
1093 let err = send_spawn_batch(&ControlClient::new(id), SpawnArgs::default(), 0, false)
1094 .await
1095 .expect_err("zero runs is a refusal");
1096 assert!(err.to_string().contains("at least 1"), "got: {err}");
1097 }
1098
1099 #[test]
1102 fn a_respawned_id_keeps_the_dashed_agent_stem() {
1103 let id = respawned_run_id("wide-researcher-1785900000-0123456789ab");
1104 assert!(id.starts_with("wide-researcher-"), "got: {id}");
1105 assert_ne!(id, "wide-researcher-1785900000-0123456789ab");
1106 let tail: Vec<&str> = id.rsplitn(3, '-').collect();
1108 assert_eq!(tail[0].len(), 12, "got: {id}");
1109 assert!(tail[1].chars().all(|c| c.is_ascii_digit()), "got: {id}");
1110 }
1111
1112 #[test]
1115 fn a_respawned_id_falls_back_to_the_whole_previous_id_as_stem() {
1116 let id = respawned_run_id("x");
1117 assert!(id.starts_with("x-"), "got: {id}");
1118 }
1119
1120 #[test]
1121 fn a_batch_report_lists_one_sentence_per_run() {
1122 let runs = vec![
1123 SpawnedRun {
1124 run_id: "a-1-1".into(),
1125 blueprint_path: "/b".into(),
1126 workdir: "/w".into(),
1127 yolo: false,
1128 },
1129 SpawnedRun {
1130 run_id: "a-1-2".into(),
1131 blueprint_path: "/b".into(),
1132 workdir: "/w".into(),
1133 yolo: false,
1134 },
1135 ];
1136 assert_eq!(batch_report(&runs, false), "spawned a-1-1\nspawned a-1-2");
1137 let parsed: Vec<SpawnedRun> =
1138 serde_json::from_str(&batch_report(&runs, true)).expect("a JSON array");
1139 assert_eq!(parsed, runs);
1140 }
1141
1142 fn spawned() -> SpawnedRun {
1143 SpawnedRun {
1144 run_id: "run-abc".to_string(),
1145 blueprint_path: "/agents/coder/agent.leviath".to_string(),
1146 workdir: "/work".to_string(),
1147 yolo: true,
1148 }
1149 }
1150
1151 #[test]
1152 fn spawn_report_without_json_is_the_sentence() {
1153 assert_eq!(spawn_report(&spawned(), false), "spawned run-abc");
1154 }
1155
1156 #[test]
1157 fn spawn_report_with_json_round_trips_every_field() {
1158 let parsed: SpawnedRun =
1161 serde_json::from_str(&spawn_report(&spawned(), true)).expect("valid JSON");
1162 assert_eq!(parsed, spawned());
1163 }
1164
1165 fn read_paths_blueprint() -> leviath_core::Blueprint {
1170 leviath_core::manifest::parse_manifest(
1171 r#"
1172[agent]
1173name = "cto"
1174version = "0.1.0"
1175description = "test"
1176
1177[stages.main]
1178mode = "autonomous"
1179
1180[context.regions]
1181system = { kind = "pinned", max_tokens = 1000 }
1182
1183[read_paths]
1184allow = ["/data/runs"]
1185"#,
1186 )
1187 .expect("blueprint parses")
1188 }
1189
1190 #[test]
1193 fn an_ungranted_declaration_warns_with_the_stanza_to_add() {
1194 let lines = spawn_warning_lines(
1195 &read_paths_blueprint(),
1196 &crate::config::Config::default(),
1197 std::path::Path::new("/work"),
1198 );
1199 let joined = lines.join("\n");
1200 assert!(joined.contains("agent 'cto'"), "{joined}");
1201 assert!(joined.contains("[agent_read_paths.cto]"), "{joined}");
1202 assert!(joined.contains(r#"allow = ["/data/runs"]"#), "{joined}");
1203 }
1204
1205 #[test]
1206 fn a_granted_declaration_says_nothing() {
1207 let mut config = crate::config::Config::default();
1208 config.security.read_paths = vec!["/data/runs".to_string()];
1209 assert!(
1210 spawn_warning_lines(
1211 &read_paths_blueprint(),
1212 &config,
1213 std::path::Path::new("/work")
1214 )
1215 .is_empty()
1216 );
1217 }
1218
1219 #[test]
1222 fn nothing_to_warn_about_produces_no_lines() {
1223 let plain =
1224 leviath_core::manifest::parse_manifest(&crate::test_support::inline_coder_manifest())
1225 .expect("blueprint parses");
1226 assert!(
1227 spawn_warning_lines(
1228 &plain,
1229 &crate::config::Config::default(),
1230 std::path::Path::new("/work")
1231 )
1232 .is_empty()
1233 );
1234
1235 let mut broken = crate::config::Config::default();
1236 broken.security.read_paths = vec!["regex:relative/.*".to_string()];
1237 assert!(
1238 spawn_warning_lines(
1239 &read_paths_blueprint(),
1240 &broken,
1241 std::path::Path::new("/work")
1242 )
1243 .is_empty()
1244 );
1245 }
1246
1247 #[tokio::test]
1250 async fn the_warning_reads_the_manifest_and_the_active_config() {
1251 let dir = tempfile::tempdir().unwrap();
1252 let manifest = dir.path().join("agent.leviath");
1253 std::fs::write(
1254 &manifest,
1255 crate::test_support::inline_coder_manifest()
1256 + "\n[read_paths]\nallow = [\"/data/runs\"]\n",
1257 )
1258 .unwrap();
1259 let args = SpawnArgs {
1260 blueprint_path: manifest.to_string_lossy().into_owned(),
1261 workdir: dir.path().to_string_lossy().into_owned(),
1262 ..SpawnArgs::default()
1263 };
1264 let lines = crate::config::with_isolated_config_path_async(
1265 "spawn-warn-read-paths",
1266 |_fake| async move {
1267 let lines = read_path_warning_for_spawn(&args);
1268 warn_ungranted_read_paths(&args);
1269 lines
1270 },
1271 )
1272 .await;
1273 let joined = lines.join("\n");
1274 assert!(joined.contains("1 declared, 0 granted"), "{joined}");
1275 assert!(joined.contains("[agent_read_paths.coder]"), "{joined}");
1276 }
1277
1278 #[test]
1281 fn the_warning_gives_up_quietly_on_a_broken_manifest_or_config() {
1282 let dir = tempfile::tempdir().unwrap();
1283 let manifest = dir.path().join("agent.leviath");
1284 std::fs::write(&manifest, "not valid toml [[[").unwrap();
1285 assert!(
1286 read_path_warning_for_spawn(&SpawnArgs {
1287 blueprint_path: manifest.to_string_lossy().into_owned(),
1288 ..SpawnArgs::default()
1289 })
1290 .is_empty()
1291 );
1292
1293 std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
1294 crate::config::with_isolated_config_path("spawn-warn-broken-config", |fake_dir| {
1295 std::fs::write(fake_dir.join("config.toml"), "not = valid = toml").unwrap();
1296 assert!(
1297 read_path_warning_for_spawn(&SpawnArgs {
1298 blueprint_path: manifest.to_string_lossy().into_owned(),
1299 ..SpawnArgs::default()
1300 })
1301 .is_empty()
1302 );
1303 });
1304 }
1305
1306 fn manifest_with_a_held_checkpoint(dir: &std::path::Path) -> String {
1309 let manifest = dir.join("agent.leviath");
1310 std::fs::write(
1311 &manifest,
1312 r#"
1313[agent]
1314name = "held"
1315version = "0.1.0"
1316description = "holds a checkpoint"
1317entry_stage = "plan"
1318
1319[stages.plan]
1320mode = "interactive_points"
1321model = { models = [{ provider = "anthropic", model = "claude-sonnet-5" }] }
1322max_iterations = 5
1323available_tools = ["read_file"]
1324
1325[[stages.plan.interaction_points]]
1326name = "plan_approval"
1327prompt = "Review the plan"
1328style = "confirm"
1329unattended = "ask"
1330
1331[context.regions]
1332system = { kind = "pinned", max_tokens = 1000 }
1333conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
1334"#,
1335 )
1336 .unwrap();
1337 manifest.to_string_lossy().into_owned()
1338 }
1339
1340 #[test]
1343 fn a_yolo_spawn_announces_the_checkpoints_that_still_hold() {
1344 let dir = tempfile::tempdir().unwrap();
1345 let blueprint_path = manifest_with_a_held_checkpoint(dir.path());
1346 crate::config::with_isolated_config_path("spawn-warn-held", |_fake| {
1347 let args = SpawnArgs {
1348 blueprint_path: blueprint_path.clone(),
1349 yolo: true,
1350 ..SpawnArgs::default()
1351 };
1352 let joined = held_checkpoint_warning_for_spawn(&args).join("\n");
1353 assert!(joined.contains("plan: plan_approval"), "{joined}");
1354 warn_held_checkpoints(&args);
1355
1356 assert!(
1359 held_checkpoint_warning_for_spawn(&SpawnArgs {
1360 blueprint_path: blueprint_path.clone(),
1361 yolo: false,
1362 ..SpawnArgs::default()
1363 })
1364 .is_empty()
1365 );
1366 });
1367 }
1368
1369 #[test]
1373 fn the_held_checkpoint_warning_gives_up_quietly() {
1374 let dir = tempfile::tempdir().unwrap();
1375 let missing = dir.path().join("nope.leviath");
1376 assert!(
1377 held_checkpoint_warning_for_spawn(&SpawnArgs {
1378 blueprint_path: missing.to_string_lossy().into_owned(),
1379 yolo: true,
1380 ..SpawnArgs::default()
1381 })
1382 .is_empty()
1383 );
1384
1385 let unparseable = dir.path().join("agent.leviath");
1386 std::fs::write(&unparseable, "not valid toml [[[").unwrap();
1387 assert!(
1388 held_checkpoint_warning_for_spawn(&SpawnArgs {
1389 blueprint_path: unparseable.to_string_lossy().into_owned(),
1390 yolo: true,
1391 ..SpawnArgs::default()
1392 })
1393 .is_empty()
1394 );
1395
1396 let held = manifest_with_a_held_checkpoint(dir.path());
1400 crate::config::with_isolated_config_path("spawn-held-broken-config", |fake_dir| {
1401 std::fs::write(fake_dir.join("config.toml"), "not = valid = toml").unwrap();
1402 let joined = held_checkpoint_warning_for_spawn(&SpawnArgs {
1403 blueprint_path: held.clone(),
1404 yolo: true,
1405 ..SpawnArgs::default()
1406 })
1407 .join("\n");
1408 assert!(joined.contains("plan_approval"), "{joined}");
1409 assert!(joined.contains("after 1h"), "{joined}");
1410 });
1411 }
1412
1413 #[tokio::test]
1414 async fn send_spawn_reports_success() {
1415 assert!(
1416 send(r#"{"result":"spawned","run_id":"run-9"}"#)
1417 .await
1418 .is_ok()
1419 );
1420 }
1421
1422 #[tokio::test]
1423 async fn send_spawn_reports_daemon_error() {
1424 let err = send(r#"{"result":"error","message":"boom"}"#)
1425 .await
1426 .unwrap_err();
1427 assert!(err.to_string().contains("boom"));
1428 }
1429
1430 #[tokio::test]
1431 async fn send_spawn_reports_unexpected_response() {
1432 let err = send(r#"{"result":"ok","ok":true}"#).await.unwrap_err();
1433 assert!(err.to_string().contains("unexpected"));
1434 }
1435
1436 #[tokio::test]
1437 async fn send_spawn_errors_when_daemon_absent() {
1438 let dir = tempfile::tempdir().unwrap();
1439 let id = control_id(&dir.path().join("no-daemon"));
1441 let err = send_spawn(&ControlClient::new(id), SpawnArgs::default(), false)
1442 .await
1443 .unwrap_err();
1444 assert!(err.to_string().contains("not reachable"));
1445 }
1446}