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
231pub async fn send_spawn(client: &ControlClient, spawn_args: SpawnArgs) -> anyhow::Result<()> {
234 warn_ungranted_read_paths(&spawn_args);
235 match client.spawn(spawn_args).await {
236 Ok(ControlResponse::Spawned { run_id }) => {
237 println!("spawned {run_id}");
238 Ok(())
239 }
240 Ok(ControlResponse::Error { message }) => bail!("spawn failed: {message}"),
241 Ok(other) => bail!("unexpected daemon response: {other:?}"),
242 Err(e) => bail!("the leviath daemon is not reachable ({e}); start it with `lev daemon`"),
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use leviath_runtime::control_socket::{ControlId, bind_control_listener, control_id};
250 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
251 use tokio::task::JoinHandle;
252
253 fn write_manifest(dir: &std::path::Path) -> std::path::PathBuf {
254 std::fs::write(
255 dir.join("agent.leviath"),
256 crate::test_support::inline_coder_manifest(),
257 )
258 .unwrap();
259 dir.join("agent.leviath")
260 }
261
262 #[test]
263 fn resolve_spawn_args_finds_manifest_and_builds_request() {
264 let dir = tempfile::tempdir().unwrap();
265 let agent_dir = dir.path().join("my-agent");
266 std::fs::create_dir_all(&agent_dir).unwrap();
267 let manifest = write_manifest(&agent_dir);
268
269 let args = resolve_spawn_args(
270 manifest.to_str().unwrap(),
271 Some("do it"),
272 &never_interactive,
273 Some("m".to_string()),
274 "/work",
275 false,
276 Vec::new(),
277 None,
278 HashMap::new(),
279 false,
280 )
281 .unwrap();
282 assert!(args.run_id.contains("my-agent"));
283 assert_eq!(args.task, "do it");
284 assert_eq!(args.model.as_deref(), Some("m"));
285 assert_eq!(
286 args.blueprint_path,
287 std::fs::canonicalize(&manifest).unwrap().to_string_lossy()
288 );
289 assert_eq!(args.workdir, "/work");
290 }
291
292 #[test]
297 fn resolve_spawn_args_sends_an_absolute_blueprint_path_for_a_relative_input() {
298 let _guard = crate::config::isolate_cwd_for_test();
303 let dir = tempfile::Builder::new()
309 .prefix("lev-relpath-")
310 .tempdir_in(".")
311 .unwrap();
312 let agent_dir = dir.path().join("my-agent");
313 std::fs::create_dir_all(&agent_dir).unwrap();
314 write_manifest(&agent_dir);
315
316 let relative = std::path::Path::new(".")
319 .join(dir.path().file_name().unwrap())
320 .join("my-agent");
321 assert!(relative.is_relative(), "expected a relative path");
325
326 let args = resolve_spawn_args(
327 relative.to_str().unwrap(),
328 Some("do it"),
329 &never_interactive,
330 None,
331 "/work",
332 false,
333 Vec::new(),
334 None,
335 HashMap::new(),
336 false,
337 )
338 .unwrap();
339 assert!(
340 std::path::Path::new(&args.blueprint_path).is_absolute(),
341 "got: {}",
342 args.blueprint_path
343 );
344 assert!(args.blueprint_path.ends_with("agent.leviath"));
345 }
346
347 #[test]
348 fn resolve_spawn_args_errors_on_missing_manifest() {
349 assert!(
350 resolve_spawn_args(
351 "/no/such/agent",
352 Some("t"),
353 &never_interactive,
354 None,
355 "/work",
356 false,
357 Vec::new(),
358 None,
359 HashMap::new(),
360 false,
361 )
362 .is_err()
363 );
364 }
365
366 #[test]
369 fn resolve_spawn_args_reads_the_task_from_a_file() {
370 let dir = tempfile::tempdir().unwrap();
371 let agent_dir = dir.path().join("my-agent");
372 std::fs::create_dir_all(&agent_dir).unwrap();
373 let manifest = write_manifest(&agent_dir);
374 let task_file = dir.path().join("task.md");
375 std::fs::write(&task_file, " summarize the README \n").unwrap();
376
377 let args = resolve_spawn_args(
378 manifest.to_str().unwrap(),
379 Some(task_file.to_str().unwrap()),
380 &never_interactive,
381 None,
382 "/work",
383 false,
384 Vec::new(),
385 None,
386 HashMap::new(),
387 false,
388 )
389 .unwrap();
390 assert_eq!(args.task, "summarize the README");
391 }
392
393 #[test]
396 fn resolve_spawn_args_without_a_task_errors_when_stdin_is_not_a_tty() {
397 let dir = tempfile::tempdir().unwrap();
398 let agent_dir = dir.path().join("my-agent");
399 std::fs::create_dir_all(&agent_dir).unwrap();
400 let manifest = write_manifest(&agent_dir);
401
402 let err = resolve_spawn_args(
403 manifest.to_str().unwrap(),
404 None,
405 &never_interactive,
406 None,
407 "/work",
408 false,
409 Vec::new(),
410 None,
411 HashMap::new(),
412 false,
413 )
414 .unwrap_err();
415 assert!(err.to_string().contains("No task provided"), "got: {err}");
416 }
417
418 #[test]
421 fn resolve_spawn_args_rejects_a_bad_region_before_it_looks_at_the_task() {
422 let dir = tempfile::tempdir().unwrap();
423 let manifest = write_region_manifest(&dir.path().join("reviewer"));
424 let regions = HashMap::from([("bogus".to_string(), "x".to_string())]);
425
426 let err = resolve_spawn_args(
427 manifest.to_str().unwrap(),
428 None,
429 &never_interactive,
430 None,
431 "/work",
432 false,
433 Vec::new(),
434 None,
435 regions,
436 false,
437 )
438 .unwrap_err();
439 assert!(err.to_string().contains("unknown region"), "got: {err}");
440 }
441
442 fn write_region_manifest(dir: &std::path::Path) -> std::path::PathBuf {
445 std::fs::create_dir_all(dir).unwrap();
446 std::fs::write(
447 dir.join("agent.leviath"),
448 r#"
449[agent]
450name = "reviewer"
451
452[stages.main]
453mode = "autonomous"
454
455[stages.main.model]
456provider = "anthropic"
457model = "claude-sonnet-5"
458
459[context.regions]
460task = { kind = "pinned", max_tokens = 4000, seed = "task_input" }
461criteria = { kind = "pinned", max_tokens = 2000, seed = "input" }
462conversation = { kind = "sliding_window", max_items = 20, max_tokens = 10000 }
463"#,
464 )
465 .unwrap();
466 dir.join("agent.leviath")
467 }
468
469 #[test]
470 fn resolve_spawn_args_resolves_declared_region_and_reads_at_path() {
471 let dir = tempfile::tempdir().unwrap();
472 let manifest = write_region_manifest(&dir.path().join("reviewer"));
473 let policy = dir.path().join("policy.md");
474 std::fs::write(&policy, " focus on safety ").unwrap();
475
476 let regions = HashMap::from([(
477 "criteria".to_string(),
478 format!("@{}", policy.to_string_lossy()),
479 )]);
480 let args = resolve_spawn_args(
481 manifest.to_str().unwrap(),
482 Some("review it"),
483 &never_interactive,
484 None,
485 "/work",
486 false,
487 Vec::new(),
488 None,
489 regions,
490 false,
491 )
492 .unwrap();
493 assert_eq!(
495 args.regions.get("criteria").map(String::as_str),
496 Some("focus on safety")
497 );
498 }
499
500 #[test]
501 fn resolve_spawn_args_unknown_region_reports_none_when_no_caller_inputs() {
502 let dir = tempfile::tempdir().unwrap();
504 let agent_dir = dir.path().join("noinput");
505 std::fs::create_dir_all(&agent_dir).unwrap();
506 std::fs::write(
507 agent_dir.join("agent.leviath"),
508 r#"
509[agent]
510name = "noinput"
511
512[stages.main]
513mode = "autonomous"
514
515[stages.main.model]
516provider = "anthropic"
517model = "claude-sonnet-5"
518
519[context.regions]
520data = { kind = "pinned", max_tokens = 2000 }
521conversation = { kind = "sliding_window", max_items = 20, max_tokens = 10000 }
522"#,
523 )
524 .unwrap();
525 let manifest = agent_dir.join("agent.leviath");
526 let regions = HashMap::from([("foo".to_string(), "x".to_string())]);
527 let err = resolve_spawn_args(
528 manifest.to_str().unwrap(),
529 Some("t"),
530 &never_interactive,
531 None,
532 "/work",
533 false,
534 Vec::new(),
535 None,
536 regions,
537 false,
538 )
539 .unwrap_err();
540 assert!(err.to_string().contains("(none)"), "got: {err}");
541 }
542
543 #[test]
544 fn resolve_spawn_args_manifest_read_error_surfaces() {
545 let dir = tempfile::tempdir().unwrap();
548 let agent_dir = dir.path().join("dirmanifest");
549 std::fs::create_dir_all(agent_dir.join("agent.leviath")).unwrap();
550 let regions = HashMap::from([("x".to_string(), "y".to_string())]);
551 let err = resolve_spawn_args(
552 agent_dir.to_str().unwrap(),
553 Some("t"),
554 &never_interactive,
555 None,
556 "/work",
557 false,
558 Vec::new(),
559 None,
560 regions,
561 false,
562 )
563 .unwrap_err();
564 assert!(err.to_string().contains("read manifest"), "got: {err}");
565 }
566
567 #[test]
568 fn resolve_spawn_args_manifest_parse_error_surfaces() {
569 let dir = tempfile::tempdir().unwrap();
570 let agent_dir = dir.path().join("badtoml");
571 std::fs::create_dir_all(&agent_dir).unwrap();
572 std::fs::write(
573 agent_dir.join("agent.leviath"),
574 "this is : not = valid toml [[[",
575 )
576 .unwrap();
577 let regions = HashMap::from([("x".to_string(), "y".to_string())]);
578 let err = resolve_spawn_args(
579 agent_dir.join("agent.leviath").to_str().unwrap(),
580 Some("t"),
581 &never_interactive,
582 None,
583 "/work",
584 false,
585 Vec::new(),
586 None,
587 regions,
588 false,
589 )
590 .unwrap_err();
591 assert!(err.to_string().contains("parse manifest"), "got: {err}");
592 }
593
594 #[test]
595 fn resolve_spawn_args_region_value_bad_file_errors() {
596 let dir = tempfile::tempdir().unwrap();
599 let manifest = write_region_manifest(&dir.path().join("reviewer"));
600 let regions = HashMap::from([("criteria".to_string(), "@/no/such/file.md".to_string())]);
601 let err = resolve_spawn_args(
602 manifest.to_str().unwrap(),
603 Some("review it"),
604 &never_interactive,
605 None,
606 "/work",
607 false,
608 Vec::new(),
609 None,
610 regions,
611 false,
612 )
613 .unwrap_err();
614 assert!(
615 err.to_string().contains("Failed to read region file"),
616 "got: {err}"
617 );
618 }
619
620 #[test]
621 fn resolve_spawn_args_rejects_unknown_region_flag() {
622 let dir = tempfile::tempdir().unwrap();
623 let manifest = write_region_manifest(&dir.path().join("reviewer"));
624 let regions = HashMap::from([("bogus".to_string(), "x".to_string())]);
625 let err = resolve_spawn_args(
626 manifest.to_str().unwrap(),
627 Some("review it"),
628 &never_interactive,
629 None,
630 "/work",
631 false,
632 Vec::new(),
633 None,
634 regions,
635 false,
636 )
637 .unwrap_err();
638 assert!(
639 err.to_string().contains("unknown region '--bogus'"),
640 "got: {err}"
641 );
642 }
643
644 fn fake_daemon(
647 dir: &std::path::Path,
648 response_line: &'static str,
649 ) -> (ControlId, JoinHandle<()>) {
650 let id = control_id(dir);
651 let mut listener = bind_control_listener(&id).unwrap();
652 let handle = tokio::spawn(async move {
653 let stream = listener
654 .accept()
655 .await
656 .expect("accept succeeds")
657 .expect("our own connection is admitted");
658 let (read_half, mut write_half) = tokio::io::split(stream);
659 let mut lines = BufReader::new(read_half).lines();
660 let _request = lines.next_line().await.unwrap();
661 write_half
662 .write_all(response_line.as_bytes())
663 .await
664 .unwrap();
665 write_half.write_all(b"\n").await.unwrap();
666 });
667 (id, handle)
668 }
669
670 async fn send(response_line: &'static str) -> anyhow::Result<()> {
671 let dir = tempfile::tempdir().unwrap();
672 let (id, server) = fake_daemon(dir.path(), response_line);
673 let result = send_spawn(&ControlClient::new(id), SpawnArgs::default()).await;
674 server.await.unwrap();
675 result
676 }
677
678 fn read_paths_blueprint() -> leviath_core::Blueprint {
683 leviath_core::manifest::parse_manifest(
684 r#"
685[agent]
686name = "cto"
687version = "0.1.0"
688description = "test"
689
690[stages.main]
691mode = "autonomous"
692
693[context.regions]
694system = { kind = "pinned", max_tokens = 1000 }
695
696[read_paths]
697allow = ["/data/runs"]
698"#,
699 )
700 .expect("blueprint parses")
701 }
702
703 #[test]
706 fn an_ungranted_declaration_warns_with_the_stanza_to_add() {
707 let lines = spawn_warning_lines(
708 &read_paths_blueprint(),
709 &crate::config::Config::default(),
710 std::path::Path::new("/work"),
711 );
712 let joined = lines.join("\n");
713 assert!(joined.contains("agent 'cto'"), "{joined}");
714 assert!(joined.contains("[agent_read_paths.cto]"), "{joined}");
715 assert!(joined.contains(r#"allow = ["/data/runs"]"#), "{joined}");
716 }
717
718 #[test]
719 fn a_granted_declaration_says_nothing() {
720 let mut config = crate::config::Config::default();
721 config.security.read_paths = vec!["/data/runs".to_string()];
722 assert!(
723 spawn_warning_lines(
724 &read_paths_blueprint(),
725 &config,
726 std::path::Path::new("/work")
727 )
728 .is_empty()
729 );
730 }
731
732 #[test]
735 fn nothing_to_warn_about_produces_no_lines() {
736 let plain =
737 leviath_core::manifest::parse_manifest(&crate::test_support::inline_coder_manifest())
738 .expect("blueprint parses");
739 assert!(
740 spawn_warning_lines(
741 &plain,
742 &crate::config::Config::default(),
743 std::path::Path::new("/work")
744 )
745 .is_empty()
746 );
747
748 let mut broken = crate::config::Config::default();
749 broken.security.read_paths = vec!["regex:relative/.*".to_string()];
750 assert!(
751 spawn_warning_lines(
752 &read_paths_blueprint(),
753 &broken,
754 std::path::Path::new("/work")
755 )
756 .is_empty()
757 );
758 }
759
760 #[tokio::test]
763 async fn the_warning_reads_the_manifest_and_the_active_config() {
764 let dir = tempfile::tempdir().unwrap();
765 let manifest = dir.path().join("agent.leviath");
766 std::fs::write(
767 &manifest,
768 crate::test_support::inline_coder_manifest()
769 + "\n[read_paths]\nallow = [\"/data/runs\"]\n",
770 )
771 .unwrap();
772 let args = SpawnArgs {
773 blueprint_path: manifest.to_string_lossy().into_owned(),
774 workdir: dir.path().to_string_lossy().into_owned(),
775 ..SpawnArgs::default()
776 };
777 let lines = crate::config::with_isolated_config_path_async(
778 "spawn-warn-read-paths",
779 |_fake| async move {
780 let lines = read_path_warning_for_spawn(&args);
781 warn_ungranted_read_paths(&args);
782 lines
783 },
784 )
785 .await;
786 let joined = lines.join("\n");
787 assert!(joined.contains("1 declared, 0 granted"), "{joined}");
788 assert!(joined.contains("[agent_read_paths.coder]"), "{joined}");
789 }
790
791 #[test]
794 fn the_warning_gives_up_quietly_on_a_broken_manifest_or_config() {
795 let dir = tempfile::tempdir().unwrap();
796 let manifest = dir.path().join("agent.leviath");
797 std::fs::write(&manifest, "not valid toml [[[").unwrap();
798 assert!(
799 read_path_warning_for_spawn(&SpawnArgs {
800 blueprint_path: manifest.to_string_lossy().into_owned(),
801 ..SpawnArgs::default()
802 })
803 .is_empty()
804 );
805
806 std::fs::write(&manifest, crate::test_support::inline_coder_manifest()).unwrap();
807 crate::config::with_isolated_config_path("spawn-warn-broken-config", |fake_dir| {
808 std::fs::write(fake_dir.join("config.toml"), "not = valid = toml").unwrap();
809 assert!(
810 read_path_warning_for_spawn(&SpawnArgs {
811 blueprint_path: manifest.to_string_lossy().into_owned(),
812 ..SpawnArgs::default()
813 })
814 .is_empty()
815 );
816 });
817 }
818
819 #[tokio::test]
820 async fn send_spawn_reports_success() {
821 assert!(
822 send(r#"{"result":"spawned","run_id":"run-9"}"#)
823 .await
824 .is_ok()
825 );
826 }
827
828 #[tokio::test]
829 async fn send_spawn_reports_daemon_error() {
830 let err = send(r#"{"result":"error","message":"boom"}"#)
831 .await
832 .unwrap_err();
833 assert!(err.to_string().contains("boom"));
834 }
835
836 #[tokio::test]
837 async fn send_spawn_reports_unexpected_response() {
838 let err = send(r#"{"result":"ok","ok":true}"#).await.unwrap_err();
839 assert!(err.to_string().contains("unexpected"));
840 }
841
842 #[tokio::test]
843 async fn send_spawn_errors_when_daemon_absent() {
844 let dir = tempfile::tempdir().unwrap();
845 let id = control_id(&dir.path().join("no-daemon"));
847 let err = send_spawn(&ControlClient::new(id), SpawnArgs::default())
848 .await
849 .unwrap_err();
850 assert!(err.to_string().contains("not reachable"));
851 }
852}