1use clap::Args;
4use std::path::PathBuf;
5
6#[derive(Args)]
7pub struct ValidateArgs {
8 #[arg(default_value = ".")]
10 pub(crate) path: String,
11}
12
13#[derive(Debug)]
18enum ManifestCheckError {
19 Io(anyhow::Error),
20 Parse(String),
21 Validation(String),
22}
23
24fn check_manifest(path: &std::path::Path) -> Result<leviath_core::Blueprint, ManifestCheckError> {
25 let manifest_path = if path.is_file() {
27 path.to_path_buf()
28 } else {
29 let p = path.join("agent.leviath");
30 if !p.exists() {
31 return Err(ManifestCheckError::Io(anyhow::anyhow!(
32 "No agent.leviath found at {}",
33 path.display()
34 )));
35 }
36 p
37 };
38
39 let content = std::fs::read_to_string(&manifest_path).map_err(|e| {
40 ManifestCheckError::Io(anyhow::anyhow!(
41 "Failed to read {}: {}",
42 manifest_path.display(),
43 e
44 ))
45 })?;
46
47 let blueprint = leviath_core::manifest::parse_manifest(&content)
48 .map_err(|e| ManifestCheckError::Parse(e.to_string()))?;
49
50 blueprint
51 .validate()
52 .map_err(|e| ManifestCheckError::Validation(e.to_string()))?;
53
54 crate::daemon::spawn::resolve_region_scripts(&blueprint, &manifest_path.to_string_lossy())
59 .map_err(ManifestCheckError::Validation)?;
60
61 Ok(blueprint)
62}
63
64fn print_success(blueprint: &leviath_core::Blueprint) {
66 println!("✓ Blueprint '{}' is valid.", blueprint.name);
67 println!(
68 " {} stages, version {}",
69 blueprint.stages.len(),
70 blueprint.version
71 );
72
73 let is_graph = blueprint.stages.iter().any(|s| s.transitions.is_some());
75 if is_graph {
76 let entry = blueprint.resolve_entry_stage_name();
77 println!(" Graph mode: entry stage '{}'", entry);
78
79 for stage in &blueprint.stages {
81 let transitions_info = match &stage.transitions {
82 Some(t) if !t.is_empty() => {
83 let targets: Vec<&str> = t.keys().map(|k| k.as_str()).collect();
84 format!(" → {}", targets.join(", "))
85 }
86 Some(_) => " (terminal)".to_string(),
87 None => " (linear)".to_string(),
88 };
89 let revisits = stage
90 .max_revisits
91 .map(|n| format!(" (max_revisits: {})", n))
92 .unwrap_or_default();
93 println!(" - {}{}{}", stage.name, transitions_info, revisits);
94 }
95 } else {
96 println!(
97 " Linear mode: {}",
98 blueprint
99 .stages
100 .iter()
101 .map(|s| s.name.as_str())
102 .collect::<Vec<_>>()
103 .join(" → ")
104 );
105 }
106
107 for line in command_seed_report(blueprint) {
110 println!("{line}");
111 }
112
113 print_warnings(blueprint);
115}
116
117fn command_seed_report(blueprint: &leviath_core::Blueprint) -> Vec<String> {
123 let seeds: Vec<(&str, &str)> = blueprint
124 .context_layout
125 .regions
126 .iter()
127 .filter_map(|r| match &r.seed {
128 Some(leviath_core::layout::RegionSeed::Command { command }) => {
129 Some((r.name.as_str(), command.as_str()))
130 }
131 _ => None,
132 })
133 .collect();
134 if seeds.is_empty() {
135 return Vec::new();
136 }
137 let mut lines = vec![format!(
138 " ⚠ {} region(s) run a shell command at spawn, before the first \
139 inference and before any tool-approval prompt:",
140 seeds.len()
141 )];
142 lines.extend(
143 seeds
144 .iter()
145 .map(|(region, command)| format!(" {region}: {command}")),
146 );
147 lines.push(
148 " Disable with `--no-seed-commands`, or machine-wide via \
149 `[security] allow_seed_commands = false`."
150 .to_string(),
151 );
152 lines
153}
154
155enum ValidateOutcome {
161 Success,
162 ParseError(String),
163 ValidationError(String),
164}
165
166fn execute_reporting_outcome(args: &ValidateArgs) -> anyhow::Result<ValidateOutcome> {
167 let path = PathBuf::from(&args.path);
168
169 let blueprint = match check_manifest(&path) {
170 Ok(bp) => bp,
171 Err(ManifestCheckError::Io(e)) => return Err(e),
172 Err(ManifestCheckError::Parse(e)) => return Ok(ValidateOutcome::ParseError(e)),
173 Err(ManifestCheckError::Validation(e)) => return Ok(ValidateOutcome::ValidationError(e)),
174 };
175
176 print_success(&blueprint);
177 print_script_tool_report(&path);
178 Ok(ValidateOutcome::Success)
179}
180
181fn print_script_tool_report(path: &std::path::Path) {
186 let agent_dir = if path.is_file() {
188 path.parent().unwrap_or(path).to_path_buf()
189 } else {
190 path.to_path_buf()
191 };
192 let tools_dir = agent_dir.join("tools");
193 if !tools_dir.is_dir() {
194 return;
195 }
196 let (set, skipped) = leviath_scripting::ScriptToolSet::discover(&[tools_dir]);
197 if !set.is_empty() {
198 println!(" {} script tool(s) in tools/", set.len());
199 }
200 for meta in set.metas() {
203 if !crate::daemon::spawn::current_platform_satisfies(&meta.required_caps) {
204 println!(
205 " ⚠ Warning: script tool '{}' won't load here (unsatisfiable @requires: {})",
206 meta.name,
207 meta.required_caps.join(", ")
208 );
209 }
210 }
211 for s in &skipped {
212 println!(
213 " ⚠ Warning: script tool '{}' skipped: {}",
214 s.path.display(),
215 s.reason
216 );
217 }
218}
219
220pub async fn execute(args: ValidateArgs) -> anyhow::Result<()> {
221 match execute_reporting_outcome(&args)? {
222 ValidateOutcome::Success => Ok(()),
223 ValidateOutcome::ParseError(e) => anyhow::bail!("✗ Parse error: {}", e),
224 ValidateOutcome::ValidationError(e) => anyhow::bail!("✗ Validation failed: {}", e),
225 }
226}
227
228fn print_warnings(blueprint: &leviath_core::Blueprint) {
229 let stage_names: std::collections::HashSet<&str> =
230 blueprint.stages.iter().map(|s| s.name.as_str()).collect();
231
232 let is_graph = blueprint.stages.iter().any(|s| s.transitions.is_some());
233 if !is_graph {
234 return;
235 }
236
237 let entry = blueprint.resolve_entry_stage_name();
238
239 let mut reachable = std::collections::HashSet::new();
241 let mut queue = std::collections::VecDeque::new();
242 queue.push_back(entry.clone());
243 while let Some(name) = queue.pop_front() {
244 if !reachable.insert(name.clone()) {
245 continue;
246 }
247 let Some(stage) = blueprint.find_stage(&name) else {
248 continue;
249 };
250 let Some(ref transitions) = stage.transitions else {
251 continue;
252 };
253 for target in transitions.keys() {
254 if !reachable.contains(target.as_str()) && stage_names.contains(target.as_str()) {
255 queue.push_back(target.clone());
256 }
257 }
258 }
259
260 for stage in &blueprint.stages {
261 if !reachable.contains(stage.name.as_str()) {
262 println!(
263 " ⚠ Warning: stage '{}' is unreachable from entry stage '{}'",
264 stage.name, entry
265 );
266 }
267 }
268
269 for stage in &blueprint.stages {
271 let Some(ref transitions) = stage.transitions else {
272 continue;
273 };
274 for target in transitions.keys() {
275 if target == &stage.name {
276 continue;
277 }
278 let Some(target_stage) = blueprint.find_stage(target) else {
280 continue;
281 };
282 let Some(ref t2) = target_stage.transitions else {
283 continue;
284 };
285 if t2.contains_key(&stage.name) && target_stage.max_revisits.is_none() {
286 #[rustfmt::skip]
287 println!(" ⚠ Warning: stage '{}' is in a cycle but has no max_revisits set", target);
288 }
289 }
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296 use crate::test_support::write_test_agent;
297
298 fn make_blueprint_toml(stages_toml: &str) -> String {
300 format!(
301 r#"
302[agent]
303name = "test"
304version = "0.1.0"
305description = "test blueprint"
306
307{}
308
309[context.regions]
310system = {{ kind = "pinned", max_tokens = 1000 }}
311conversation = {{ kind = "sliding_window", max_items = 50, max_tokens = 10000 }}
312"#,
313 stages_toml
314 )
315 }
316
317 fn parse(toml: &str) -> leviath_core::Blueprint {
318 leviath_core::manifest::parse_manifest(toml).unwrap()
319 }
320
321 #[test]
322 fn check_manifest_verifies_custom_region_scripts() {
323 let dir = tempfile::tempdir().unwrap();
326 let manifest_path = dir.path().join("agent.leviath");
327 let toml = r#"
328[agent]
329name = "custom-validate"
330version = "0.1.0"
331description = "d"
332
333[stages.main]
334mode = "autonomous"
335model = { provider = "anthropic", model = "claude-sonnet-5" }
336description = "Main stage"
337
338[context.regions]
339system = { kind = "pinned", max_tokens = 1000 }
340conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
341brain = { kind = "custom", script = "hooks/brain.rhai", max_tokens = 1000 }
342"#;
343 std::fs::write(&manifest_path, toml).unwrap();
344
345 let err = format!("{:?}", check_manifest(&manifest_path).unwrap_err());
347 assert!(err.starts_with("Validation"), "{err}");
348 assert!(err.contains("region 'brain'"), "{err}");
349
350 std::fs::create_dir(dir.path().join("hooks")).unwrap();
352 std::fs::write(
353 dir.path().join("hooks/brain.rhai"),
354 "fn render(ctx) { \"ok\" }",
355 )
356 .unwrap();
357 let bp = check_manifest(&manifest_path).unwrap();
358 assert_eq!(bp.name, "custom-validate");
359 }
360
361 #[test]
362 fn command_seed_report_is_empty_without_command_seeds() {
363 let bp = parse(&make_blueprint_toml(
364 r#"
365[stages.main]
366mode = "autonomous"
367model = { provider = "anthropic", model = "claude-sonnet-5" }
368description = "Main stage"
369"#,
370 ));
371 assert!(command_seed_report(&bp).is_empty());
372 }
373
374 #[test]
375 fn command_seed_report_names_every_region_and_command() {
376 let toml = r#"
377[agent]
378name = "scanner"
379version = "0.1.0"
380
381[stages.main]
382mode = "autonomous"
383model = { provider = "anthropic", model = "claude-sonnet-5" }
384description = "Main stage"
385
386[context.regions]
387facts = { kind = "pinned", max_tokens = 1000, seed = { command = "git ls-files" } }
388tests = { kind = "pinned", max_tokens = 1000, seed = { command = "ls tests" } }
389plain = { kind = "pinned", max_tokens = 1000 }
390conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
391"#;
392 let report = command_seed_report(&parse(toml)).join("\n");
393 assert!(report.contains("2 region(s)"), "got: {report}");
394 assert!(report.contains("facts: git ls-files"), "got: {report}");
395 assert!(report.contains("tests: ls tests"), "got: {report}");
396 assert!(report.contains("--no-seed-commands"), "got: {report}");
398 assert!(report.contains("allow_seed_commands"), "got: {report}");
399 assert!(!report.contains("plain"), "got: {report}");
401 print_success(&parse(toml));
403 }
404
405 #[test]
406 fn print_warnings_linear_mode_no_panic() {
407 let toml = make_blueprint_toml(
408 r#"
409[stages.main]
410mode = "autonomous"
411model = { provider = "anthropic", model = "claude-sonnet-4-6" }
412description = "Main stage"
413max_iterations = 10
414"#,
415 );
416 let bp = parse(&toml);
417 print_warnings(&bp);
419 }
420
421 #[test]
422 fn print_warnings_graph_all_reachable() {
423 let toml = make_blueprint_toml(
424 r#"
425[stages.a]
426mode = "autonomous"
427model = { provider = "anthropic", model = "claude-sonnet-4-6" }
428description = "Stage A"
429max_iterations = 5
430entry = true
431[stages.a.transitions]
432b = "true"
433
434[stages.b]
435mode = "autonomous"
436model = { provider = "anthropic", model = "claude-sonnet-4-6" }
437description = "Stage B"
438max_iterations = 5
439"#,
440 );
441 let bp = parse(&toml);
442 print_warnings(&bp);
444 }
445
446 #[test]
447 fn validate_args_default_path() {
448 let args = ValidateArgs {
450 path: ".".to_string(),
451 };
452 assert_eq!(args.path, ".");
453 }
454
455 #[test]
458 fn print_warnings_unreachable_stage_no_panic() {
459 let toml = make_blueprint_toml(
460 r#"
461[stages.a]
462mode = "autonomous"
463model = { provider = "anthropic", model = "claude-sonnet-4-6" }
464description = "Stage A"
465max_iterations = 5
466entry = true
467[stages.a.transitions]
468b = "true"
469
470[stages.b]
471mode = "autonomous"
472model = { provider = "anthropic", model = "claude-sonnet-4-6" }
473description = "Stage B"
474max_iterations = 5
475
476[stages.orphan]
477mode = "autonomous"
478model = { provider = "anthropic", model = "claude-sonnet-4-6" }
479description = "Unreachable stage"
480max_iterations = 5
481"#,
482 );
483 let bp = parse(&toml);
484 print_warnings(&bp);
486 }
487
488 #[test]
491 fn print_warnings_cycle_without_max_revisits_no_panic() {
492 let toml = make_blueprint_toml(
493 r#"
494[stages.a]
495mode = "autonomous"
496model = { provider = "anthropic", model = "claude-sonnet-4-6" }
497description = "Stage A"
498max_iterations = 5
499entry = true
500[stages.a.transitions]
501b = "true"
502
503[stages.b]
504mode = "autonomous"
505model = { provider = "anthropic", model = "claude-sonnet-4-6" }
506description = "Stage B"
507max_iterations = 5
508[stages.b.transitions]
509a = "true"
510"#,
511 );
512 let bp = parse(&toml);
513 print_warnings(&bp);
515 }
516
517 #[test]
520 fn print_warnings_cycle_with_max_revisits_no_panic() {
521 let toml = make_blueprint_toml(
522 r#"
523[stages.a]
524mode = "autonomous"
525model = { provider = "anthropic", model = "claude-sonnet-4-6" }
526description = "Stage A"
527max_iterations = 5
528entry = true
529[stages.a.transitions]
530b = "true"
531
532[stages.b]
533mode = "autonomous"
534model = { provider = "anthropic", model = "claude-sonnet-4-6" }
535description = "Stage B"
536max_iterations = 5
537max_revisits = 3
538[stages.b.transitions]
539a = "true"
540"#,
541 );
542 let bp = parse(&toml);
543 print_warnings(&bp);
544 }
545
546 #[test]
549 fn print_warnings_terminal_stage_no_panic() {
550 let toml = make_blueprint_toml(
551 r#"
552[stages.a]
553mode = "autonomous"
554model = { provider = "anthropic", model = "claude-sonnet-4-6" }
555description = "Stage A"
556max_iterations = 5
557entry = true
558[stages.a.transitions]
559b = "true"
560
561[stages.b]
562mode = "autonomous"
563model = { provider = "anthropic", model = "claude-sonnet-4-6" }
564description = "Terminal stage"
565max_iterations = 5
566[stages.b.transitions]
567"#,
568 );
569 let bp = parse(&toml);
570 print_warnings(&bp);
571 }
572
573 #[test]
576 fn print_warnings_self_loop_with_max_revisits_no_panic() {
577 let toml = make_blueprint_toml(
578 r#"
579[stages.a]
580mode = "autonomous"
581model = { provider = "anthropic", model = "claude-sonnet-4-6" }
582description = "Stage A"
583max_iterations = 5
584entry = true
585max_revisits = 3
586[stages.a.transitions]
587a = "true"
588"#,
589 );
590 let bp = parse(&toml);
591 print_warnings(&bp);
594 }
595
596 fn make_model() -> leviath_core::blueprint::ModelConfig {
605 leviath_core::blueprint::ModelConfig::new(
606 "anthropic".to_string(),
607 "claude-sonnet-4-6".to_string(),
608 )
609 }
610
611 #[test]
612 fn print_warnings_entry_stage_missing_no_panic() {
613 use leviath_core::{Blueprint, ContextLayout, Stage};
614
615 let mut stage_a = Stage::new("a".to_string(), make_model());
620 stage_a.transitions = Some(std::collections::HashMap::new());
621
622 let layout = ContextLayout::new(Vec::new(), 1000);
623 let mut bp = Blueprint::new(
624 "test".to_string(),
625 "test".to_string(),
626 vec![stage_a],
627 layout,
628 );
629 bp.entry_stage = Some("ghost".to_string());
630
631 print_warnings(&bp);
634 }
635
636 #[test]
637 fn print_warnings_transition_target_missing_no_panic() {
638 use leviath_core::{Blueprint, ContextLayout, Stage, TransitionEdge};
639
640 let mut transitions = std::collections::HashMap::new();
645 transitions.insert(
646 "ghost".to_string(),
647 TransitionEdge {
648 target: "ghost".to_string(),
649 condition: Default::default(),
650 hint: None,
651 transform: Default::default(),
652 gate: None,
653 stuck: None,
654 },
655 );
656 let mut stage_a = Stage::new("a".to_string(), make_model());
657 stage_a.transitions = Some(transitions);
658
659 let layout = ContextLayout::new(Vec::new(), 1000);
660 let bp = Blueprint::new(
661 "test".to_string(),
662 "test".to_string(),
663 vec![stage_a],
664 layout,
665 );
666
667 print_warnings(&bp);
670 }
671
672 #[tokio::test]
678 async fn execute_parse_error_returns_error() {
679 let dir = tempfile::tempdir().unwrap();
680 write_manifest(dir.path(), "not valid toml [[[");
681 let args = ValidateArgs {
682 path: dir.path().to_str().unwrap().to_string(),
683 };
684 let err = execute(args).await.unwrap_err();
685 assert!(err.to_string().contains("Parse error"));
686 }
687
688 #[tokio::test]
689 async fn execute_validation_error_returns_error() {
690 let dir = tempfile::tempdir().unwrap();
691 let manifest = r#"
692[agent]
693name = "bad-entry-agent"
694version = "0.1.0"
695description = "Entry stage does not exist"
696entry_stage = "does-not-exist"
697
698[stages.main]
699mode = "autonomous"
700model = { provider = "anthropic", model = "claude-sonnet-4-6" }
701description = "Main"
702max_iterations = 5
703
704[context.regions]
705system = { kind = "pinned", max_tokens = 1000 }
706"#;
707 write_manifest(dir.path(), manifest);
708 let args = ValidateArgs {
709 path: dir.path().to_str().unwrap().to_string(),
710 };
711 let err = execute(args).await.unwrap_err();
712 assert!(err.to_string().contains("Validation failed"));
713 }
714
715 #[tokio::test]
718 async fn execute_no_manifest_errors() {
719 let dir = tempfile::tempdir().unwrap();
720 let args = ValidateArgs {
721 path: dir.path().to_str().unwrap().to_string(),
722 };
723 let result = execute(args).await;
724 assert!(result.is_err());
725 }
726
727 #[tokio::test]
730 async fn execute_valid_manifest_file_path() {
731 let dir = tempfile::tempdir().unwrap();
732 let manifest = r#"
733[agent]
734name = "test-agent"
735version = "0.1.0"
736description = "A test agent"
737
738[stages.main]
739mode = "autonomous"
740model = { provider = "anthropic", model = "claude-sonnet-4-6" }
741description = "Main"
742max_iterations = 5
743
744[context.regions]
745system = { kind = "pinned", max_tokens = 1000 }
746conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
747"#;
748 let manifest_path = dir.path().join("agent.leviath");
749 std::fs::write(&manifest_path, manifest).unwrap();
750
751 let args = ValidateArgs {
752 path: manifest_path.to_str().unwrap().to_string(),
753 };
754 let result = execute(args).await;
755 assert!(result.is_ok());
756 }
757
758 #[tokio::test]
761 async fn execute_valid_manifest_directory_path() {
762 let dir = tempfile::tempdir().unwrap();
763 let manifest = r#"
764[agent]
765name = "dir-agent"
766version = "0.2.0"
767description = "A directory agent"
768
769[stages.main]
770mode = "autonomous"
771model = { provider = "anthropic", model = "claude-sonnet-4-6" }
772description = "Main"
773max_iterations = 5
774
775[context.regions]
776system = { kind = "pinned", max_tokens = 1000 }
777conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
778"#;
779 write_test_agent(dir.path(), manifest);
780
781 let args = ValidateArgs {
782 path: dir.path().to_str().unwrap().to_string(),
783 };
784 let result = execute(args).await;
785 assert!(result.is_ok());
786 }
787
788 fn assert_is_parse_error(outcome: &ValidateOutcome) {
795 assert!(matches!(outcome, ValidateOutcome::ParseError(_)));
796 }
797
798 #[test]
799 #[should_panic(expected = "assertion failed")]
800 fn assert_is_parse_error_panics_on_non_parse_error() {
801 assert_is_parse_error(&ValidateOutcome::Success);
802 }
803
804 #[test]
805 fn execute_reporting_outcome_malformed_toml_is_parse_error() {
806 let dir = tempfile::tempdir().unwrap();
807 write_manifest(dir.path(), "not valid toml [[[");
808 let args = ValidateArgs {
809 path: dir.path().to_str().unwrap().to_string(),
810 };
811 let outcome = execute_reporting_outcome(&args).unwrap();
812 assert_is_parse_error(&outcome);
813 }
814
815 #[test]
816 fn execute_reporting_outcome_bad_entry_stage_is_validation_error() {
817 let dir = tempfile::tempdir().unwrap();
818 let manifest = r#"
819[agent]
820name = "bad-entry-agent"
821version = "0.1.0"
822description = "Entry stage does not exist"
823entry_stage = "does-not-exist"
824
825[stages.main]
826mode = "autonomous"
827model = { provider = "anthropic", model = "claude-sonnet-4-6" }
828description = "Main"
829max_iterations = 5
830
831[context.regions]
832system = { kind = "pinned", max_tokens = 1000 }
833"#;
834 write_manifest(dir.path(), manifest);
835 let args = ValidateArgs {
836 path: dir.path().to_str().unwrap().to_string(),
837 };
838 let outcome = execute_reporting_outcome(&args).unwrap();
839 assert_is_validation_error(&outcome);
840 }
841
842 fn assert_is_validation_error(outcome: &ValidateOutcome) {
843 assert!(matches!(outcome, ValidateOutcome::ValidationError(_)));
844 }
845
846 #[test]
847 #[should_panic(expected = "assertion failed")]
848 fn assert_is_validation_error_panics_on_non_validation_error() {
849 assert_is_validation_error(&ValidateOutcome::Success);
850 }
851
852 #[test]
853 fn execute_reporting_outcome_missing_manifest_is_io_error() {
854 let dir = tempfile::tempdir().unwrap();
855 let args = ValidateArgs {
856 path: dir.path().to_str().unwrap().to_string(),
857 };
858 assert!(execute_reporting_outcome(&args).is_err());
859 }
860
861 #[test]
862 fn execute_reporting_outcome_valid_manifest_is_success() {
863 let dir = tempfile::tempdir().unwrap();
864 let manifest = r#"
865[agent]
866name = "ok-agent"
867version = "0.1.0"
868description = "Valid"
869
870[stages.main]
871mode = "autonomous"
872model = { provider = "anthropic", model = "claude-sonnet-4-6" }
873description = "Main"
874max_iterations = 5
875
876[context.regions]
877system = { kind = "pinned", max_tokens = 1000 }
878conversation = { kind = "sliding_window", max_items = 50, max_tokens = 10000 }
879"#;
880 write_manifest(dir.path(), manifest);
881 let args = ValidateArgs {
882 path: dir.path().to_str().unwrap().to_string(),
883 };
884 let outcome = execute_reporting_outcome(&args).unwrap();
885 assert_is_success(&outcome);
886 }
887
888 fn assert_is_success(outcome: &ValidateOutcome) {
889 assert!(matches!(outcome, ValidateOutcome::Success));
890 }
891
892 #[test]
893 fn execute_reporting_outcome_reports_agent_script_tools() {
894 let dir = tempfile::tempdir().unwrap();
898 let manifest = r#"
899[agent]
900name = "with-tools"
901version = "0.1.0"
902description = "has script tools"
903
904[stages.main]
905mode = "autonomous"
906model = { provider = "anthropic", model = "claude-sonnet-4-6" }
907description = "Main"
908max_iterations = 5
909
910[context.regions]
911system = { kind = "pinned", max_tokens = 1000 }
912"#;
913 write_manifest(dir.path(), manifest);
914 let tools = dir.path().join("tools");
915 std::fs::create_dir(&tools).unwrap();
916 std::fs::write(tools.join("ok.rhai"), "// @tool ok\nparams.x").unwrap();
917 std::fs::write(tools.join("bad.rhai"), "no directive\nlet").unwrap();
918 std::fs::write(tools.join("gpu.rhai"), "// @tool gpu\n// @requires gpu\n1").unwrap();
920 let args = ValidateArgs {
921 path: dir.path().to_str().unwrap().to_string(),
922 };
923 let outcome = execute_reporting_outcome(&args).unwrap();
924 assert_is_success(&outcome);
925 }
926
927 #[test]
928 fn print_script_tool_report_no_tools_dir_is_silent() {
929 let dir = tempfile::tempdir().unwrap();
933 let manifest = write_manifest(dir.path(), "unused");
934 print_script_tool_report(&manifest);
935 }
936
937 #[test]
938 fn print_script_tool_report_only_broken_scripts_warns_without_count() {
939 let dir = tempfile::tempdir().unwrap();
942 let tools = dir.path().join("tools");
943 std::fs::create_dir(&tools).unwrap();
944 std::fs::write(tools.join("bad.rhai"), "no directive\nlet").unwrap();
945 print_script_tool_report(dir.path());
946 }
947
948 #[test]
949 #[should_panic(expected = "assertion failed")]
950 fn assert_is_success_panics_on_non_success() {
951 assert_is_success(&ValidateOutcome::ParseError("x".to_string()));
952 }
953
954 #[test]
957 fn print_warnings_chain_all_reachable() {
958 let toml = make_blueprint_toml(
959 r#"
960[stages.a]
961mode = "autonomous"
962model = { provider = "anthropic", model = "claude-sonnet-4-6" }
963description = "A"
964max_iterations = 5
965entry = true
966[stages.a.transitions]
967b = "true"
968
969[stages.b]
970mode = "autonomous"
971model = { provider = "anthropic", model = "claude-sonnet-4-6" }
972description = "B"
973max_iterations = 5
974[stages.b.transitions]
975c = "true"
976
977[stages.c]
978mode = "autonomous"
979model = { provider = "anthropic", model = "claude-sonnet-4-6" }
980description = "C"
981max_iterations = 5
982"#,
983 );
984 let bp = parse(&toml);
985 print_warnings(&bp);
986 }
987
988 #[test]
996 fn print_warnings_diamond_graph_revisits_shared_target_no_panic() {
997 let toml = make_blueprint_toml(
998 r#"
999[stages.entry]
1000mode = "autonomous"
1001model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1002description = "Entry"
1003max_iterations = 5
1004entry = true
1005[stages.entry.transitions]
1006b = "true"
1007c = "true"
1008
1009[stages.b]
1010mode = "autonomous"
1011model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1012description = "B"
1013max_iterations = 5
1014[stages.b.transitions]
1015d = "true"
1016
1017[stages.c]
1018mode = "autonomous"
1019model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1020description = "C"
1021max_iterations = 5
1022[stages.c.transitions]
1023d = "true"
1024
1025[stages.d]
1026mode = "autonomous"
1027model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1028description = "D"
1029max_iterations = 5
1030"#,
1031 );
1032 let bp = parse(&toml);
1033 print_warnings(&bp);
1036 }
1037
1038 fn write_manifest(dir: &std::path::Path, content: &str) -> std::path::PathBuf {
1041 let path = dir.join("agent.leviath");
1042 std::fs::write(&path, content).unwrap();
1043 path
1044 }
1045
1046 fn unwrap_io_err(err: ManifestCheckError) -> anyhow::Error {
1049 let ManifestCheckError::Io(e) = err else {
1050 panic!("expected ManifestCheckError::Io, got {err:?}");
1051 };
1052 e
1053 }
1054
1055 #[test]
1056 #[should_panic(expected = "expected ManifestCheckError::Io")]
1057 fn unwrap_io_err_panics_on_parse_variant() {
1058 let dir = tempfile::tempdir().unwrap();
1059 write_manifest(dir.path(), "not valid toml [[[");
1060 let err = check_manifest(dir.path()).unwrap_err();
1061 unwrap_io_err(err);
1063 }
1064
1065 #[test]
1066 fn check_manifest_missing_directory_manifest_is_io_error() {
1067 let dir = tempfile::tempdir().unwrap();
1068 let err = check_manifest(dir.path()).unwrap_err();
1069 let e = unwrap_io_err(err);
1070 assert!(e.to_string().contains("No agent.leviath found"));
1071 }
1072
1073 #[test]
1074 fn check_manifest_unreadable_file_path_is_io_error() {
1075 let dir = tempfile::tempdir().unwrap();
1076 let missing = dir.path().join("nonexistent-subdir");
1080 let err = check_manifest(&missing).unwrap_err();
1081 unwrap_io_err(err);
1082 }
1083
1084 #[test]
1089 fn check_manifest_unreadable_file_is_io_error() {
1090 let dir = tempfile::tempdir().unwrap();
1094 std::fs::create_dir_all(dir.path().join("agent.leviath")).unwrap();
1095
1096 let result = check_manifest(dir.path());
1097
1098 let err = result.unwrap_err();
1099 let e = unwrap_io_err(err);
1100 assert!(e.to_string().contains("Failed to read"));
1101 }
1102
1103 #[test]
1104 fn check_manifest_malformed_toml_is_parse_error() {
1105 let dir = tempfile::tempdir().unwrap();
1106 write_manifest(dir.path(), "not valid toml [[[");
1107 let err = check_manifest(dir.path()).unwrap_err();
1108 assert_is_manifest_parse_error(&err);
1109 }
1110
1111 fn assert_is_manifest_parse_error(err: &ManifestCheckError) {
1112 assert!(matches!(err, ManifestCheckError::Parse(_)));
1113 }
1114
1115 #[test]
1116 #[should_panic(expected = "assertion failed")]
1117 fn assert_is_manifest_parse_error_panics_on_non_parse_error() {
1118 assert_is_manifest_parse_error(&ManifestCheckError::Io(anyhow::anyhow!("x")));
1119 }
1120
1121 #[test]
1122 fn check_manifest_direct_file_path_is_accepted() {
1123 let dir = tempfile::tempdir().unwrap();
1124 let toml = make_blueprint_toml(
1125 r#"
1126[stages.main]
1127mode = "autonomous"
1128model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1129description = "Main stage"
1130max_iterations = 5
1131"#,
1132 );
1133 let manifest_path = write_manifest(dir.path(), &toml);
1134 let blueprint = check_manifest(&manifest_path).unwrap();
1136 assert_eq!(blueprint.name, "test");
1137 }
1138
1139 #[test]
1140 fn check_manifest_valid_linear_blueprint_succeeds() {
1141 let dir = tempfile::tempdir().unwrap();
1142 let toml = make_blueprint_toml(
1143 r#"
1144[stages.main]
1145mode = "autonomous"
1146model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1147description = "Main stage"
1148max_iterations = 5
1149"#,
1150 );
1151 write_manifest(dir.path(), &toml);
1152 let blueprint = check_manifest(dir.path()).unwrap();
1153 assert_eq!(blueprint.name, "test");
1154 assert_eq!(blueprint.stages.len(), 1);
1155 }
1156
1157 #[test]
1160 fn print_success_linear_mode_no_panic() {
1161 let toml = make_blueprint_toml(
1162 r#"
1163[stages.main]
1164mode = "autonomous"
1165model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1166description = "Main stage"
1167max_iterations = 5
1168
1169[stages.review]
1170mode = "autonomous"
1171model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1172description = "Review stage"
1173max_iterations = 5
1174"#,
1175 );
1176 let bp = parse(&toml);
1177 print_success(&bp);
1178 }
1179
1180 #[test]
1181 fn print_success_graph_mode_with_terminal_and_revisits_no_panic() {
1182 let toml = make_blueprint_toml(
1183 r#"
1184[stages.a]
1185mode = "autonomous"
1186model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1187description = "A"
1188max_iterations = 5
1189entry = true
1190max_revisits = 3
1191[stages.a.transitions]
1192b = "true"
1193
1194[stages.b]
1195mode = "autonomous"
1196model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1197description = "B"
1198max_iterations = 5
1199"#,
1200 );
1201 let bp = parse(&toml);
1202 print_success(&bp);
1206 }
1207
1208 #[test]
1209 fn print_success_graph_mode_terminal_stage_no_panic() {
1210 let toml = make_blueprint_toml(
1211 r#"
1212[stages.a]
1213mode = "autonomous"
1214model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1215description = "A"
1216max_iterations = 5
1217entry = true
1218[stages.a.transitions]
1219b = "true"
1220
1221[stages.b]
1222mode = "autonomous"
1223model = { provider = "anthropic", model = "claude-sonnet-4-6" }
1224description = "B"
1225max_iterations = 5
1226[stages.b.transitions]
1227"#,
1228 );
1229 let bp = parse(&toml);
1230 let b = bp.find_stage("b").unwrap();
1233 assert!(matches!(&b.transitions, Some(t) if t.is_empty()));
1234 print_success(&bp);
1235 }
1236}