1use std::collections::HashMap;
7
8use anyhow::bail;
9use leviath_core::layout::RegionSeed;
10use leviath_runtime::control_socket::{ControlClient, ControlResponse};
11use leviath_runtime::host::SpawnArgs;
12
13use crate::commands::run::manifest::find_manifest;
14use crate::commands::run::task::{read_region_value, resolve_task};
15use crate::runstate::new_run_id;
16
17pub struct AgentSource {
19 pub manifest: std::path::PathBuf,
21 pub run_stem: String,
26 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: Vec<String> = blueprint
80 .context_layout
81 .regions
82 .iter()
83 .filter_map(|r| match &r.seed {
84 Some(RegionSeed::CallerInput { name }) => Some(name.clone()),
85 _ => None,
86 })
87 .collect();
88 let mut out = HashMap::new();
89 for (name, raw) in regions {
90 if !declared.contains(&name) {
91 bail!(
92 "unknown region '--{name}'; this agent's caller-input regions are: {}",
93 if declared.is_empty() {
94 "(none)".to_string()
95 } else {
96 declared.join(", ")
97 }
98 );
99 }
100 out.insert(name, read_region_value(&raw)?);
101 }
102 Ok(out)
103}
104
105pub fn never_interactive() -> bool {
114 false
115}
116
117#[allow(clippy::too_many_arguments)]
130pub fn resolve_spawn_args(
131 path: &str,
132 task: Option<&str>,
133 stdin_is_terminal: &dyn Fn() -> bool,
134 model: Option<String>,
135 workdir: &str,
136 yolo: bool,
137 allow: Vec<String>,
138 max_depth: Option<usize>,
139 regions: HashMap<String, String>,
140 no_seed_commands: bool,
141) -> anyhow::Result<SpawnArgs> {
142 let source = load_agent_source(path)?;
143 let resolved_regions = resolve_regions(&source.blueprint, regions)?;
144 let task = resolve_task(
145 task,
146 &source.blueprint.name,
147 &source.blueprint.description,
148 stdin_is_terminal,
149 )?;
150
151 Ok(SpawnArgs {
152 run_id: new_run_id(&source.run_stem),
153 blueprint_path: source.manifest.to_string_lossy().to_string(),
154 task,
155 regions: resolved_regions,
156 model,
157 workdir: workdir.to_string(),
158 metadata: Default::default(),
159 callback_url: None,
160 callback_secret: None,
161 yolo,
162 no_seed_commands,
163 allow,
164 max_depth,
165 parent_run_id: None,
167 })
168}
169
170fn warn_ungranted_read_paths(spawn_args: &SpawnArgs) {
182 for line in read_path_warning_for_spawn(spawn_args) {
183 eprintln!("{line}");
184 }
185}
186
187fn read_path_warning_for_spawn(spawn_args: &SpawnArgs) -> Vec<String> {
191 let Ok(content) = std::fs::read_to_string(&spawn_args.blueprint_path) else {
192 return Vec::new();
193 };
194 let Ok(blueprint) = leviath_core::manifest::parse_manifest(&content) else {
195 return Vec::new();
196 };
197 let Ok(config) = crate::config::Config::load() else {
198 return Vec::new();
199 };
200 spawn_warning_lines(
201 &blueprint,
202 &config,
203 std::path::Path::new(&spawn_args.workdir),
204 )
205}
206
207fn spawn_warning_lines(
210 blueprint: &leviath_core::Blueprint,
211 config: &crate::config::Config,
212 workdir: &std::path::Path,
213) -> Vec<String> {
214 let Some(Ok(report)) = crate::read_path_report::build(blueprint, config, workdir) else {
215 return Vec::new();
216 };
217 let Some(warning) = report.warning_line() else {
218 return Vec::new();
219 };
220 let mut lines = vec![warning];
221 lines.push(" add to your config.toml:".to_string());
222 lines.extend(
223 report
224 .grant_stanza()
225 .into_iter()
226 .map(|l| format!(" {l}")),
227 );
228 lines
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
238pub struct SpawnedRun {
239 pub run_id: String,
241 pub blueprint_path: String,
243 pub workdir: String,
245 pub yolo: bool,
247}
248
249pub fn spawn_report(spawned: &SpawnedRun, json: bool) -> String {
253 match json {
254 true => serde_json::to_string_pretty(spawned).expect("a spawn report serializes"),
256 false => format!("spawned {}", spawned.run_id),
257 }
258}
259
260pub async fn send_spawn(
265 client: &ControlClient,
266 spawn_args: SpawnArgs,
267 json: bool,
268) -> anyhow::Result<()> {
269 warn_ungranted_read_paths(&spawn_args);
270 let blueprint_path = spawn_args.blueprint_path.clone();
271 let workdir = spawn_args.workdir.clone();
272 let yolo = spawn_args.yolo;
273 match client.spawn(spawn_args).await {
274 Ok(ControlResponse::Spawned { run_id }) => {
275 let spawned = SpawnedRun {
276 run_id,
277 blueprint_path,
278 workdir,
279 yolo,
280 };
281 println!("{}", spawn_report(&spawned, json));
282 Ok(())
283 }
284 Ok(ControlResponse::Error { message }) => bail!("spawn failed: {message}"),
285 Ok(other) => bail!("unexpected daemon response: {other:?}"),
286 Err(e) => bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`"),
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use leviath_runtime::control_socket::{ControlId, bind_control_listener, control_id};
294 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
295 use tokio::task::JoinHandle;
296
297 fn write_manifest(dir: &std::path::Path) -> std::path::PathBuf {
298 std::fs::write(
299 dir.join("agent.leviath"),
300 crate::test_support::inline_coder_manifest(),
301 )
302 .unwrap();
303 dir.join("agent.leviath")
304 }
305
306 #[test]
307 fn resolve_spawn_args_finds_manifest_and_builds_request() {
308 let dir = tempfile::tempdir().unwrap();
309 let agent_dir = dir.path().join("my-agent");
310 std::fs::create_dir_all(&agent_dir).unwrap();
311 let manifest = write_manifest(&agent_dir);
312
313 let args = resolve_spawn_args(
314 manifest.to_str().unwrap(),
315 Some("do it"),
316 &never_interactive,
317 Some("m".to_string()),
318 "/work",
319 false,
320 Vec::new(),
321 None,
322 HashMap::new(),
323 false,
324 )
325 .unwrap();
326 assert!(args.run_id.contains("my-agent"));
327 assert_eq!(args.task, "do it");
328 assert_eq!(args.model.as_deref(), Some("m"));
329 assert_eq!(
330 args.blueprint_path,
331 std::fs::canonicalize(&manifest).unwrap().to_string_lossy()
332 );
333 assert_eq!(args.workdir, "/work");
334 }
335
336 #[test]
341 fn resolve_spawn_args_sends_an_absolute_blueprint_path_for_a_relative_input() {
342 let _guard = crate::config::isolate_cwd_for_test();
347 let dir = tempfile::Builder::new()
353 .prefix("lev-relpath-")
354 .tempdir_in(".")
355 .unwrap();
356 let agent_dir = dir.path().join("my-agent");
357 std::fs::create_dir_all(&agent_dir).unwrap();
358 write_manifest(&agent_dir);
359
360 let relative = std::path::Path::new(".")
363 .join(dir.path().file_name().unwrap())
364 .join("my-agent");
365 assert!(relative.is_relative(), "expected a relative path");
369
370 let args = resolve_spawn_args(
371 relative.to_str().unwrap(),
372 Some("do it"),
373 &never_interactive,
374 None,
375 "/work",
376 false,
377 Vec::new(),
378 None,
379 HashMap::new(),
380 false,
381 )
382 .unwrap();
383 assert!(
384 std::path::Path::new(&args.blueprint_path).is_absolute(),
385 "got: {}",
386 args.blueprint_path
387 );
388 assert!(args.blueprint_path.ends_with("agent.leviath"));
389 }
390
391 #[test]
392 fn resolve_spawn_args_errors_on_missing_manifest() {
393 assert!(
394 resolve_spawn_args(
395 "/no/such/agent",
396 Some("t"),
397 &never_interactive,
398 None,
399 "/work",
400 false,
401 Vec::new(),
402 None,
403 HashMap::new(),
404 false,
405 )
406 .is_err()
407 );
408 }
409
410 #[test]
413 fn resolve_spawn_args_reads_the_task_from_a_file() {
414 let dir = tempfile::tempdir().unwrap();
415 let agent_dir = dir.path().join("my-agent");
416 std::fs::create_dir_all(&agent_dir).unwrap();
417 let manifest = write_manifest(&agent_dir);
418 let task_file = dir.path().join("task.md");
419 std::fs::write(&task_file, " summarize the README \n").unwrap();
420
421 let args = resolve_spawn_args(
422 manifest.to_str().unwrap(),
423 Some(task_file.to_str().unwrap()),
424 &never_interactive,
425 None,
426 "/work",
427 false,
428 Vec::new(),
429 None,
430 HashMap::new(),
431 false,
432 )
433 .unwrap();
434 assert_eq!(args.task, "summarize the README");
435 }
436
437 #[test]
440 fn resolve_spawn_args_without_a_task_errors_when_stdin_is_not_a_tty() {
441 let dir = tempfile::tempdir().unwrap();
442 let agent_dir = dir.path().join("my-agent");
443 std::fs::create_dir_all(&agent_dir).unwrap();
444 let manifest = write_manifest(&agent_dir);
445
446 let err = resolve_spawn_args(
447 manifest.to_str().unwrap(),
448 None,
449 &never_interactive,
450 None,
451 "/work",
452 false,
453 Vec::new(),
454 None,
455 HashMap::new(),
456 false,
457 )
458 .unwrap_err();
459 assert!(err.to_string().contains("No task provided"), "got: {err}");
460 }
461
462 #[test]
465 fn resolve_spawn_args_rejects_a_bad_region_before_it_looks_at_the_task() {
466 let dir = tempfile::tempdir().unwrap();
467 let manifest = write_region_manifest(&dir.path().join("reviewer"));
468 let regions = HashMap::from([("bogus".to_string(), "x".to_string())]);
469
470 let err = resolve_spawn_args(
471 manifest.to_str().unwrap(),
472 None,
473 &never_interactive,
474 None,
475 "/work",
476 false,
477 Vec::new(),
478 None,
479 regions,
480 false,
481 )
482 .unwrap_err();
483 assert!(err.to_string().contains("unknown region"), "got: {err}");
484 }
485
486 fn write_region_manifest(dir: &std::path::Path) -> std::path::PathBuf {
489 std::fs::create_dir_all(dir).unwrap();
490 std::fs::write(
491 dir.join("agent.leviath"),
492 r#"
493[agent]
494name = "reviewer"
495
496[stages.main]
497mode = "autonomous"
498
499[stages.main.model]
500provider = "anthropic"
501model = "claude-sonnet-5"
502
503[context.regions]
504task = { kind = "pinned", max_tokens = 4000, seed = "task_input" }
505criteria = { kind = "pinned", max_tokens = 2000, seed = "input" }
506conversation = { kind = "sliding_window", max_items = 20, max_tokens = 10000 }
507"#,
508 )
509 .unwrap();
510 dir.join("agent.leviath")
511 }
512
513 #[test]
514 fn resolve_spawn_args_resolves_declared_region_and_reads_at_path() {
515 let dir = tempfile::tempdir().unwrap();
516 let manifest = write_region_manifest(&dir.path().join("reviewer"));
517 let policy = dir.path().join("policy.md");
518 std::fs::write(&policy, " focus on safety ").unwrap();
519
520 let regions = HashMap::from([(
521 "criteria".to_string(),
522 format!("@{}", policy.to_string_lossy()),
523 )]);
524 let args = resolve_spawn_args(
525 manifest.to_str().unwrap(),
526 Some("review it"),
527 &never_interactive,
528 None,
529 "/work",
530 false,
531 Vec::new(),
532 None,
533 regions,
534 false,
535 )
536 .unwrap();
537 assert_eq!(
539 args.regions.get("criteria").map(String::as_str),
540 Some("focus on safety")
541 );
542 }
543
544 #[test]
545 fn resolve_spawn_args_unknown_region_reports_none_when_no_caller_inputs() {
546 let dir = tempfile::tempdir().unwrap();
548 let agent_dir = dir.path().join("noinput");
549 std::fs::create_dir_all(&agent_dir).unwrap();
550 std::fs::write(
551 agent_dir.join("agent.leviath"),
552 r#"
553[agent]
554name = "noinput"
555
556[stages.main]
557mode = "autonomous"
558
559[stages.main.model]
560provider = "anthropic"
561model = "claude-sonnet-5"
562
563[context.regions]
564data = { kind = "pinned", max_tokens = 2000 }
565conversation = { kind = "sliding_window", max_items = 20, max_tokens = 10000 }
566"#,
567 )
568 .unwrap();
569 let manifest = agent_dir.join("agent.leviath");
570 let regions = HashMap::from([("foo".to_string(), "x".to_string())]);
571 let err = resolve_spawn_args(
572 manifest.to_str().unwrap(),
573 Some("t"),
574 &never_interactive,
575 None,
576 "/work",
577 false,
578 Vec::new(),
579 None,
580 regions,
581 false,
582 )
583 .unwrap_err();
584 assert!(err.to_string().contains("(none)"), "got: {err}");
585 }
586
587 #[test]
588 fn resolve_spawn_args_manifest_read_error_surfaces() {
589 let dir = tempfile::tempdir().unwrap();
592 let agent_dir = dir.path().join("dirmanifest");
593 std::fs::create_dir_all(agent_dir.join("agent.leviath")).unwrap();
594 let regions = HashMap::from([("x".to_string(), "y".to_string())]);
595 let err = resolve_spawn_args(
596 agent_dir.to_str().unwrap(),
597 Some("t"),
598 &never_interactive,
599 None,
600 "/work",
601 false,
602 Vec::new(),
603 None,
604 regions,
605 false,
606 )
607 .unwrap_err();
608 assert!(err.to_string().contains("read manifest"), "got: {err}");
609 }
610
611 #[test]
612 fn resolve_spawn_args_manifest_parse_error_surfaces() {
613 let dir = tempfile::tempdir().unwrap();
614 let agent_dir = dir.path().join("badtoml");
615 std::fs::create_dir_all(&agent_dir).unwrap();
616 std::fs::write(
617 agent_dir.join("agent.leviath"),
618 "this is : not = valid toml [[[",
619 )
620 .unwrap();
621 let regions = HashMap::from([("x".to_string(), "y".to_string())]);
622 let err = resolve_spawn_args(
623 agent_dir.join("agent.leviath").to_str().unwrap(),
624 Some("t"),
625 &never_interactive,
626 None,
627 "/work",
628 false,
629 Vec::new(),
630 None,
631 regions,
632 false,
633 )
634 .unwrap_err();
635 assert!(err.to_string().contains("parse manifest"), "got: {err}");
636 }
637
638 #[test]
639 fn resolve_spawn_args_region_value_bad_file_errors() {
640 let dir = tempfile::tempdir().unwrap();
643 let manifest = write_region_manifest(&dir.path().join("reviewer"));
644 let regions = HashMap::from([("criteria".to_string(), "@/no/such/file.md".to_string())]);
645 let err = resolve_spawn_args(
646 manifest.to_str().unwrap(),
647 Some("review it"),
648 &never_interactive,
649 None,
650 "/work",
651 false,
652 Vec::new(),
653 None,
654 regions,
655 false,
656 )
657 .unwrap_err();
658 assert!(
659 err.to_string().contains("Failed to read region file"),
660 "got: {err}"
661 );
662 }
663
664 #[test]
665 fn resolve_spawn_args_rejects_unknown_region_flag() {
666 let dir = tempfile::tempdir().unwrap();
667 let manifest = write_region_manifest(&dir.path().join("reviewer"));
668 let regions = HashMap::from([("bogus".to_string(), "x".to_string())]);
669 let err = resolve_spawn_args(
670 manifest.to_str().unwrap(),
671 Some("review it"),
672 &never_interactive,
673 None,
674 "/work",
675 false,
676 Vec::new(),
677 None,
678 regions,
679 false,
680 )
681 .unwrap_err();
682 assert!(
683 err.to_string().contains("unknown region '--bogus'"),
684 "got: {err}"
685 );
686 }
687
688 fn fake_daemon(
691 dir: &std::path::Path,
692 response_line: &'static str,
693 ) -> (ControlId, JoinHandle<()>) {
694 let id = control_id(dir);
695 let mut listener = bind_control_listener(&id).unwrap();
696 let handle = tokio::spawn(async move {
697 let stream = listener
698 .accept()
699 .await
700 .expect("accept succeeds")
701 .expect("our own connection is admitted");
702 let (read_half, mut write_half) = tokio::io::split(stream);
703 let mut lines = BufReader::new(read_half).lines();
704 let _request = lines.next_line().await.unwrap();
705 write_half
706 .write_all(response_line.as_bytes())
707 .await
708 .unwrap();
709 write_half.write_all(b"\n").await.unwrap();
710 });
711 (id, handle)
712 }
713
714 async fn send(response_line: &'static str) -> anyhow::Result<()> {
715 let dir = tempfile::tempdir().unwrap();
716 let (id, server) = fake_daemon(dir.path(), response_line);
717 let result = send_spawn(&ControlClient::new(id), SpawnArgs::default(), false).await;
718 server.await.unwrap();
719 result
720 }
721
722 fn spawned() -> SpawnedRun {
723 SpawnedRun {
724 run_id: "run-abc".to_string(),
725 blueprint_path: "/agents/coder/agent.leviath".to_string(),
726 workdir: "/work".to_string(),
727 yolo: true,
728 }
729 }
730
731 #[test]
732 fn spawn_report_without_json_is_the_sentence() {
733 assert_eq!(spawn_report(&spawned(), false), "spawned run-abc");
734 }
735
736 #[test]
737 fn spawn_report_with_json_round_trips_every_field() {
738 let parsed: SpawnedRun =
741 serde_json::from_str(&spawn_report(&spawned(), true)).expect("valid JSON");
742 assert_eq!(parsed, spawned());
743 }
744
745 fn read_paths_blueprint() -> leviath_core::Blueprint {
750 leviath_core::manifest::parse_manifest(
751 r#"
752[agent]
753name = "cto"
754version = "0.1.0"
755description = "test"
756
757[stages.main]
758mode = "autonomous"
759
760[context.regions]
761system = { kind = "pinned", max_tokens = 1000 }
762
763[read_paths]
764allow = ["/data/runs"]
765"#,
766 )
767 .expect("blueprint parses")
768 }
769
770 #[test]
773 fn an_ungranted_declaration_warns_with_the_stanza_to_add() {
774 let lines = spawn_warning_lines(
775 &read_paths_blueprint(),
776 &crate::config::Config::default(),
777 std::path::Path::new("/work"),
778 );
779 let joined = lines.join("\n");
780 assert!(joined.contains("agent 'cto'"), "{joined}");
781 assert!(joined.contains("[agent_read_paths.cto]"), "{joined}");
782 assert!(joined.contains(r#"allow = ["/data/runs"]"#), "{joined}");
783 }
784
785 #[test]
786 fn a_granted_declaration_says_nothing() {
787 let mut config = crate::config::Config::default();
788 config.security.read_paths = vec!["/data/runs".to_string()];
789 assert!(
790 spawn_warning_lines(
791 &read_paths_blueprint(),
792 &config,
793 std::path::Path::new("/work")
794 )
795 .is_empty()
796 );
797 }
798
799 #[test]
802 fn nothing_to_warn_about_produces_no_lines() {
803 let plain =
804 leviath_core::manifest::parse_manifest(&crate::test_support::inline_coder_manifest())
805 .expect("blueprint parses");
806 assert!(
807 spawn_warning_lines(
808 &plain,
809 &crate::config::Config::default(),
810 std::path::Path::new("/work")
811 )
812 .is_empty()
813 );
814
815 let mut broken = crate::config::Config::default();
816 broken.security.read_paths = vec!["regex:relative/.*".to_string()];
817 assert!(
818 spawn_warning_lines(
819 &read_paths_blueprint(),
820 &broken,
821 std::path::Path::new("/work")
822 )
823 .is_empty()
824 );
825 }
826
827 #[tokio::test]
830 async fn the_warning_reads_the_manifest_and_the_active_config() {
831 let dir = tempfile::tempdir().unwrap();
832 let manifest = dir.path().join("agent.leviath");
833 std::fs::write(
834 &manifest,
835 crate::test_support::inline_coder_manifest()
836 + "\n[read_paths]\nallow = [\"/data/runs\"]\n",
837 )
838 .unwrap();
839 let args = SpawnArgs {
840 blueprint_path: manifest.to_string_lossy().into_owned(),
841 workdir: dir.path().to_string_lossy().into_owned(),
842 ..SpawnArgs::default()
843 };
844 let lines = crate::config::with_isolated_config_path_async(
845 "spawn-warn-read-paths",
846 |_fake| async move {
847 let lines = read_path_warning_for_spawn(&args);
848 warn_ungranted_read_paths(&args);
849 lines
850 },
851 )
852 .await;
853 let joined = lines.join("\n");
854 assert!(joined.contains("1 declared, 0 granted"), "{joined}");
855 assert!(joined.contains("[agent_read_paths.coder]"), "{joined}");
856 }
857
858 #[test]
861 fn the_warning_gives_up_quietly_on_a_broken_manifest_or_config() {
862 let dir = tempfile::tempdir().unwrap();
863 let manifest = dir.path().join("agent.leviath");
864 std::fs::write(&manifest, "not valid toml [[[").unwrap();
865 assert!(
866 read_path_warning_for_spawn(&SpawnArgs {
867 blueprint_path: manifest.to_string_lossy().into_owned(),
868 ..SpawnArgs::default()
869 })
870 .is_empty()
871 );
872
873 std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
874 crate::config::with_isolated_config_path("spawn-warn-broken-config", |fake_dir| {
875 std::fs::write(fake_dir.join("config.toml"), "not = valid = toml").unwrap();
876 assert!(
877 read_path_warning_for_spawn(&SpawnArgs {
878 blueprint_path: manifest.to_string_lossy().into_owned(),
879 ..SpawnArgs::default()
880 })
881 .is_empty()
882 );
883 });
884 }
885
886 #[tokio::test]
887 async fn send_spawn_reports_success() {
888 assert!(
889 send(r#"{"result":"spawned","run_id":"run-9"}"#)
890 .await
891 .is_ok()
892 );
893 }
894
895 #[tokio::test]
896 async fn send_spawn_reports_daemon_error() {
897 let err = send(r#"{"result":"error","message":"boom"}"#)
898 .await
899 .unwrap_err();
900 assert!(err.to_string().contains("boom"));
901 }
902
903 #[tokio::test]
904 async fn send_spawn_reports_unexpected_response() {
905 let err = send(r#"{"result":"ok","ok":true}"#).await.unwrap_err();
906 assert!(err.to_string().contains("unexpected"));
907 }
908
909 #[tokio::test]
910 async fn send_spawn_errors_when_daemon_absent() {
911 let dir = tempfile::tempdir().unwrap();
912 let id = control_id(&dir.path().join("no-daemon"));
914 let err = send_spawn(&ControlClient::new(id), SpawnArgs::default(), false)
915 .await
916 .unwrap_err();
917 assert!(err.to_string().contains("not reachable"));
918 }
919}