1use std::collections::{HashMap, HashSet};
14use std::sync::{Arc, Mutex as StdMutex};
15
16use bevy_ecs::entity::Entity;
17use bevy_ecs::world::World;
18use leviath_core::blueprint::Blueprint;
19use leviath_providers::Tool;
20use leviath_runtime::host::{SpawnArgs, SubAgentOp};
21use leviath_runtime::interaction_hub::InteractionHub;
22use leviath_runtime::persistence::{RunMetadata, TokenTotals};
23use leviath_runtime::pipeline::{
24 CompactionSettings, ModelDefaults, PersistWatermark, Providers, resolve_stages,
25 spawn_agent_seeded,
26};
27use tokio::sync::Mutex;
28use tokio::sync::mpsc::UnboundedSender;
29
30use crate::config::Config;
31use crate::daemon::seed_command::SeedCommandPolicy;
32use crate::daemon::subagent::SubAgentHandle;
33use crate::daemon::tool_service::{AgentToolState, CliToolService};
34
35const DEFAULT_SUBAGENT_DEPTH: usize = 3;
37
38mod policy;
43pub(crate) use policy::*;
44mod scripts;
45pub(crate) use scripts::*;
46mod seeds;
47use seeds::*;
48mod tool_state;
49use tool_state::*;
50
51#[derive(Clone)]
61pub struct SpawnDeps<'a> {
62 pub tool_service: &'a CliToolService,
64 pub config: &'a Config,
66 pub shared_mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
68 pub mcp_tool_defs: &'a [Tool],
70 pub hub: &'a InteractionHub,
72 pub now_secs: i64,
74 pub subagent_tx: UnboundedSender<SubAgentOp>,
76}
77
78pub fn build_agent(
88 world: &mut World,
89 deps: SpawnDeps<'_>,
90 args: &SpawnArgs,
91) -> Result<Entity, String> {
92 build_agent_inner(world, deps, args, true)
93}
94
95pub fn build_agent_for_reload(
99 world: &mut World,
100 deps: SpawnDeps<'_>,
101 args: &SpawnArgs,
102) -> Result<Entity, String> {
103 build_agent_inner(world, deps, args, false)
104}
105
106fn check_spawn_request(args: &SpawnArgs) -> Result<(), String> {
112 if !leviath_core::is_safe_path_component(&args.run_id) {
123 return Err(format!(
124 "run id '{}' is not a usable directory name",
125 args.run_id
126 ));
127 }
128
129 if !std::fs::metadata(&args.workdir).is_ok_and(|m| m.is_dir()) {
134 return Err(format!(
135 "workspace '{}' does not exist or is not a directory",
136 args.workdir
137 ));
138 }
139
140 Ok(())
141}
142
143fn load_blueprint(
149 args: &SpawnArgs,
150 config: &crate::config::Config,
151) -> Result<(String, leviath_core::Blueprint), String> {
152 let content = std::fs::read_to_string(&args.blueprint_path)
153 .map_err(|e| format!("read manifest '{}': {e}", args.blueprint_path))?;
154 let mut blueprint = leviath_core::manifest::parse_manifest(&content)
155 .map_err(|e| format!("parse manifest: {e}"))?;
156 blueprint
157 .validate()
158 .map_err(|e| format!("invalid blueprint: {e}"))?;
159 log_blueprint_lint(&content, &blueprint, &args.blueprint_path);
165 if let Some(md) = args.max_depth {
167 blueprint.max_child_depth = Some(md);
168 }
169 if let Some(default_max) = config.limits.default_max_iterations {
174 for stage in &mut blueprint.stages {
175 match stage.max_iterations {
182 None | Some(0) => stage.max_iterations = Some(default_max),
183 Some(_) => {}
184 }
185 }
186 }
187
188 Ok((content, blueprint))
189}
190
191struct RunRecordParts {
197 agent_name: String,
198 model_label: Option<String>,
199 num_stages: usize,
200 read_path_counts: Option<leviath_core::run_meta::ReadPathGrantCounts>,
201 output_validators:
202 HashMap<String, std::sync::Arc<leviath_scripting::output_validator::OutputValidator>>,
203 outcome_flags: leviath_runtime::persistence::RunOutcomeFlags,
204 compaction: Option<leviath_core::CompactionConfig>,
205 tool_sensitivities: Option<HashMap<String, leviath_core::TaintLevel>>,
206 security: leviath_core::taint::SecurityConfig,
207 mcp_overrides: std::collections::HashMap<String, leviath_core::policy::McpToolOverride>,
208}
209
210fn attach_run_record(
213 world: &mut World,
214 entity: Entity,
215 args: &SpawnArgs,
216 deps: &SpawnDeps<'_>,
217 parts: RunRecordParts,
218) {
219 let metadata = RunMetadata {
220 run_id: args.run_id.clone(),
221 agent_name: parts.agent_name,
222 agent_path: args.blueprint_path.clone(),
223 task: args.task.clone(),
224 model: parts.model_label,
225 workdir: args.workdir.clone(),
226 num_stages: parts.num_stages,
227 started_at: deps.now_secs,
228 parent_run_id: args.parent_run_id.clone(),
229 metadata: args.metadata.clone(),
230 callback_url: args.callback_url.clone(),
231 callback_secret: args.callback_secret.clone(),
232 title: None,
233 unattended: args.yolo,
234 read_paths: parts.read_path_counts,
235 output_request: args.output.clone(),
236 };
237 {
238 let mut entity_mut = world.entity_mut(entity);
239 if !parts.output_validators.is_empty() {
240 entity_mut.insert(leviath_runtime::components::OutputValidators(
241 parts.output_validators,
242 ));
243 }
244 entity_mut.insert((
245 metadata,
246 TokenTotals::default(),
247 PersistWatermark::default(),
248 parts.outcome_flags,
251 ));
252 (deps.config.title.enabled && !args.task.is_empty() && args.parent_run_id.is_none())
258 .then_some(leviath_runtime::title::PendingTitle)
259 .into_iter()
260 .for_each(|marker| {
261 entity_mut.insert(marker);
262 });
263 args.yolo
268 .then_some(leviath_runtime::components::InteractionAutoApprove)
269 .into_iter()
270 .for_each(|marker| {
271 entity_mut.insert(marker);
272 });
273 parts.compaction.into_iter().for_each(|cc| {
276 entity_mut.insert(CompactionSettings(cc));
277 });
278 parts
282 .tool_sensitivities
283 .into_iter()
284 .for_each(|sensitivities| {
285 let mut gate = leviath_runtime::TaintGate::new(parts.security.clone());
286 gate.apply_mcp_overrides(&parts.mcp_overrides);
287 entity_mut.insert((
288 gate,
289 leviath_runtime::pipeline::ToolSensitivities(sensitivities),
290 ));
291 if args.yolo {
295 entity_mut.insert(leviath_runtime::components::GateAutoApprove);
296 }
297 entity_mut
300 .get_mut::<leviath_runtime::components::ContextWindow>()
301 .into_iter()
302 .for_each(|mut window| window.enable_taint_tracking());
303 });
304 }
305}
306
307struct TaintSetup {
314 security: leviath_core::taint::SecurityConfig,
315 mcp_overrides: std::collections::HashMap<String, leviath_core::policy::McpToolOverride>,
316 tool_sensitivities: Option<HashMap<String, leviath_core::TaintLevel>>,
317}
318
319fn resolve_taint_setup(
322 world: &World,
323 blueprint: &leviath_core::Blueprint,
324 config: &crate::config::Config,
325 all_tool_defs: &[leviath_providers::Tool],
326 read_paths_granted: bool,
327) -> TaintSetup {
328 let security = leviath_core::taint::resolve_security(
335 config.taint_tracking,
336 blueprint.security.as_ref(),
337 None,
338 );
339 let mcp_overrides = world
343 .get_resource::<leviath_runtime::pipeline::PolicyGate>()
344 .map(|p| p.0.mcp_overrides.clone())
345 .unwrap_or_default();
346 let tool_sensitivities: Option<HashMap<String, leviath_core::TaintLevel>> =
347 security.taint_tracking.then(|| {
348 let mut gate = leviath_runtime::TaintGate::new(security.clone());
349 gate.apply_mcp_overrides(&mcp_overrides);
350 let mut map: HashMap<String, leviath_core::TaintLevel> = all_tool_defs
351 .iter()
352 .map(|t| {
353 (
354 t.name.clone(),
355 gate.tool_classification(&t.name).sensitivity,
356 )
357 })
358 .collect();
359 bump_read_sensitivities(&mut map, read_paths_granted);
360 map
361 });
362
363 TaintSetup {
364 security,
365 mcp_overrides,
366 tool_sensitivities,
367 }
368}
369
370fn log_blueprint_lint(content: &str, blueprint: &Blueprint, manifest_path: &str) {
379 let agent_dir = std::path::Path::new(manifest_path)
380 .parent()
381 .map(std::path::Path::to_path_buf)
382 .unwrap_or_default();
383 let env = crate::lint::LintEnv::offline(&agent_dir);
384 for finding in crate::lint::lint_manifest(content, blueprint, &env) {
385 if finding.severity == crate::lint::LintSeverity::Note {
388 continue;
389 }
390 let line = format!(
395 "blueprint '{}': {} [{}]",
396 blueprint.name,
397 finding.one_line(),
398 finding.code
399 );
400 tracing::warn!("{line}");
401 }
402}
403
404fn build_agent_inner(
405 world: &mut World,
406 deps: SpawnDeps<'_>,
407 args: &SpawnArgs,
408 enforce_seeds: bool,
409) -> Result<Entity, String> {
410 check_spawn_request(args)?;
412
413 let (content, blueprint) = load_blueprint(args, deps.config)?;
415
416 let entry_stage = blueprint
423 .entry_stage
424 .clone()
425 .or_else(|| blueprint.stages.first().map(|s| s.name.clone()))
426 .unwrap_or_default();
427 let entry_index = blueprint
428 .stages
429 .iter()
430 .position(|s| s.name == entry_stage)
431 .unwrap_or(0);
432 let stage_sandbox_by_index: Vec<leviath_core::ToolSandboxConfig> = blueprint
433 .stages
434 .iter()
435 .map(|s| {
436 leviath_core::resolve_sandbox(
437 deps.config.sandbox.as_ref(),
438 blueprint.sandbox.as_ref(),
439 s.sandbox.as_ref(),
440 )
441 })
442 .collect();
443 let sandbox = crate::daemon::sandbox_manager::SandboxManager::build(
444 &args.run_id,
445 stage_sandbox_by_index,
446 &args.workdir,
447 entry_index,
448 )?
449 .map(Arc::new);
450
451 let (read_path_policy, read_path_warning) =
456 build_read_path_policy(&blueprint, deps.config, std::path::Path::new(&args.workdir))?;
457 if let Some(warning) = &read_path_warning {
458 tracing::warn!(agent_name = %blueprint.name, "{warning}");
459 }
460 let read_paths_granted = read_path_policy.is_active()
463 && (read_path_policy.allow_blueprint || !read_path_policy.grants.is_empty());
464 let seed_read_paths = read_path_policy.clone();
467 let read_path_counts =
470 read_path_grant_counts(&blueprint, deps.config, std::path::Path::new(&args.workdir));
471 let tool_ctx = leviath_tools::ToolContext::new(std::path::PathBuf::from(&args.workdir))
472 .with_read_paths(read_path_policy)
473 .with_shell_env(shell_env_policy(deps.config));
474 let mut builtins = leviath_tools::BuiltinTools::new(tool_ctx);
475 if let Some(mgr) = &sandbox {
476 builtins =
477 builtins.with_shell_executor(mgr.clone() as Arc<dyn leviath_tools::ShellExecutor>);
478 }
479 let builtins = Arc::new(builtins);
480 let builtin_names: HashSet<String> = builtins.names().into_iter().collect();
481 let mut all_tool_defs = builtins.tool_defs();
482 all_tool_defs.extend(leviath_tools::BuiltinTools::subagent_tool_defs());
483 all_tool_defs.extend(deps.mcp_tool_defs.iter().cloned());
484 let static_tool_defs = all_tool_defs.clone();
488
489 let dynamic_tools = blueprint.dynamic_tools;
499 let workdir_tools_dir =
500 dynamic_tools.then(|| std::path::PathBuf::from(&args.workdir).join("tools"));
501 let (script_tools, script_tool_names, script_defs) = discover_script_tools(
502 &args.blueprint_path,
503 &builtin_names,
504 deps.mcp_tool_defs,
505 workdir_tools_dir.clone(),
506 );
507 all_tool_defs.extend(script_defs);
508
509 let stages = {
511 let registry = &world
512 .get_resource::<Providers>()
513 .expect("Providers resource present in a PipelineWorld")
514 .0;
515 resolve_stages(
516 &blueprint,
517 args.model.as_deref(),
518 &model_defaults(deps.config),
519 registry,
520 &all_tool_defs,
521 args.yolo,
522 args.output.as_ref(),
523 )?
524 };
525
526 let agent_name = blueprint.name.clone();
528 let num_stages = blueprint.stages.len();
529 let compaction = blueprint.compaction_config.clone();
530 let max_child_depth = blueprint.max_child_depth.unwrap_or(DEFAULT_SUBAGENT_DEPTH);
531 let TaintSetup {
535 security,
536 mcp_overrides,
537 tool_sensitivities,
538 } = resolve_taint_setup(
539 world,
540 &blueprint,
541 deps.config,
542 &all_tool_defs,
543 read_paths_granted,
544 );
545
546 let stage_perms_by_index: Vec<HashMap<String, String>> = blueprint
549 .stages
550 .iter()
551 .map(|s| s.tool_permissions.clone())
552 .collect();
553 let agent_perms = blueprint.agent_tool_permissions();
558 let stage_available: Vec<Vec<String>> = blueprint
561 .stages
562 .iter()
563 .map(|s| s.available_tools.clone())
564 .collect();
565 let stage_required: Vec<Vec<String>> = blueprint
568 .stages
569 .iter()
570 .map(|s| s.required_tools.clone())
571 .collect();
572 let stage_required_by_index: Vec<HashSet<String>> = stage_required
576 .iter()
577 .map(|names| {
578 names
579 .iter()
580 .map(|n| leviath_tools::canonical_tool_name(n).to_string())
581 .collect()
582 })
583 .collect();
584 let model_label = stages
585 .first()
586 .map(|s| format!("{}/{}", s.provider_name, s.model));
587
588 let seeds = if enforce_seeds {
597 let policy = SeedCommandPolicy::new(
598 deps.config.security.allow_seed_commands && !args.no_seed_commands,
599 std::time::Duration::from_secs(deps.config.limits.script_shell_timeout_secs),
600 Arc::new(
604 deps.config
605 .safe_keys_for_agent(&agent_name, blueprint.safe_commands.as_ref())
606 .into_keys()
607 .collect(),
608 ),
609 sandbox.clone(),
610 shell_env_policy(deps.config),
611 );
612 resolve_seeds(&blueprint, args, &args.workdir, &policy, &seed_read_paths)?
613 } else {
614 HashMap::new()
615 };
616
617 let region_scripts = resolve_region_scripts(&blueprint, &args.blueprint_path)?;
622 let stage_hooks = resolve_stage_hook_scripts(&blueprint, &args.blueprint_path)?;
623 let output_validators = resolve_output_validators(&blueprint, &args.blueprint_path)?;
624
625 let outcome_flags = leviath_runtime::persistence::RunOutcomeFlags::for_blueprint(&blueprint);
630 let blueprint_safe = blueprint.safe_commands.clone();
633
634 let entity = spawn_agent_seeded(
636 world,
637 leviath_runtime::pipeline::SeededSpawn {
638 agent_id: args.run_id.clone(),
639 blueprint,
640 seeds,
641 stages,
642 global_hints: leviath_core::config::PromptHints {
643 batch_tool: deps.config.batch_tool_hint,
644 shell: deps.config.shell_hint,
645 },
646 global_nudge: deps.config.nudge.clone(),
647 region_scripts,
648 },
649 )?;
650
651 if !stage_hooks.is_empty() {
656 world
657 .entity_mut(entity)
658 .insert(leviath_runtime::components::StageHookScripts(stage_hooks));
659 }
660
661 attach_run_record(
664 world,
665 entity,
666 args,
667 &deps,
668 RunRecordParts {
669 agent_name: agent_name.clone(),
670 model_label,
671 num_stages,
672 read_path_counts,
673 output_validators,
674 outcome_flags,
675 compaction,
676 tool_sensitivities,
677 security: security.clone(),
678 mcp_overrides,
679 },
680 );
681
682 let mut launch_overrides: HashMap<String, crate::config::ToolPolicy> = HashMap::new();
686 if args.yolo {
687 launch_overrides.insert("*".to_string(), crate::config::ToolPolicy::Allow);
688 }
689 for tool in &args.allow {
690 launch_overrides.insert(tool.clone(), crate::config::ToolPolicy::Allow);
691 }
692 let subagent = SubAgentHandle {
693 sender: deps.subagent_tx,
694 parent_run_id: args.run_id.clone(),
695 workdir: args.workdir.clone(),
696 max_depth: max_child_depth,
697 no_seed_commands: args.no_seed_commands,
698 unattended: args.yolo,
699 };
700 let entry_stage_perms = stage_perms_by_index
704 .get(entry_index)
705 .cloned()
706 .unwrap_or_default();
707 let effective_script_perms = crate::daemon::script_host::effective_script_permissions(
710 &deps.config.tool_script_permissions,
711 &content,
712 );
713 let agent_scoped_perms = deps.config.permissions_for_agent(&agent_name);
718 let script_allow = crate::daemon::script_host::resolve_script_permissions(
719 &effective_script_perms,
720 &|builtin| {
721 crate::tools::resolve_policy(
722 builtin,
723 true,
724 &launch_overrides,
725 &entry_stage_perms,
726 &agent_perms,
727 &agent_scoped_perms,
728 deps.config.security.allow_blueprint_permissions,
729 )
730 },
731 );
732 let script_host: Arc<dyn leviath_scripting::ScriptHost> = Arc::new(
733 crate::daemon::script_host::DaemonScriptHost::new(
734 script_allow,
735 std::path::PathBuf::from(&args.workdir),
736 )
737 .with_shell(
741 sandbox.clone(),
742 std::time::Duration::from_secs(deps.config.limits.script_shell_timeout_secs),
743 shell_env_policy(deps.config),
744 )
745 .with_local_network(deps.config.security.allow_local_network)
749 .with_env_allowlist(deps.config.security.allow_env_vars.clone()),
752 );
753 let dynamic = dynamic_tools.then(|| {
756 world
757 .entity_mut(entity)
758 .insert(leviath_runtime::pipeline::DynamicTools);
759 Arc::new(crate::daemon::tool_service::DynamicToolCtx {
760 scan_dirs: script_scan_dirs(&args.blueprint_path, workdir_tools_dir),
761 reserved_names: reserved_tool_names(&builtin_names, deps.mcp_tool_defs),
762 static_defs: static_tool_defs,
763 stage_available,
764 stage_required,
765 unattended: args.yolo,
766 dirty: Arc::new(std::sync::atomic::AtomicBool::new(false)),
767 })
768 });
769 let state = build_tool_state(ToolStateParts {
770 builtins,
771 builtin_names,
772 mcp: deps.shared_mcp,
773 config: deps.config,
774 hub: deps.hub,
775 run_id: &args.run_id,
776 entry_stage: &entry_stage,
777 entry_index,
778 stage_perms_by_index,
779 stage_required_by_index,
780 agent_perms,
781 agent_name: &agent_name,
782 launch_overrides,
783 subagent: Some(subagent),
784 sandbox,
785 script_tools,
786 script_tool_names,
787 script_host,
788 dynamic,
789 unattended: args.yolo,
790 blueprint_safe: blueprint_safe.as_ref(),
791 });
792 deps.tool_service.register(entity, state);
793
794 Ok(entity)
795}
796
797#[cfg(test)]
798mod tests {
799 use super::*;
800 use leviath_core::blueprint::ModelConfig;
801 use leviath_runtime::ProviderRegistry;
802 use leviath_runtime::world::PipelineWorld;
803
804 fn sub_tx() -> UnboundedSender<SubAgentOp> {
806 tokio::sync::mpsc::unbounded_channel().0
807 }
808
809 #[test]
813 fn log_blueprint_lint_warns_about_findings_and_skips_notes() {
814 crate::test_support::with_tracing(|| {});
815 let home = tempfile::tempdir().unwrap();
816 temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
817 let manifest = r#"
820[agent]
821name = "noisy"
822version = "0.1.0"
823
824[stages.main]
825model = { models = [{ provider = "anthropic", model = "claude-sonnet-5" }] }
826available_tools = ["ask_user_text"]
827
828[read_paths]
829allow = ["~/.leviath/runs"]
830
831[context.regions]
832system = { kind = "pinned", max_tokens = 1000 }
833"#;
834 let bp = leviath_core::manifest::parse_manifest(manifest).unwrap();
835 let dir = tempfile::tempdir().unwrap();
836 let path = dir.path().join("agent.leviath");
837 std::fs::write(&path, manifest).unwrap();
838
839 let env = crate::lint::LintEnv::offline(dir.path());
842 let findings = crate::lint::lint_manifest(manifest, &bp, &env);
843 assert!(
844 findings
845 .iter()
846 .any(|f| f.severity == crate::lint::LintSeverity::Note),
847 "the fixture needs a note for the skip arm to run"
848 );
849 assert!(
850 findings
851 .iter()
852 .any(|f| f.severity == crate::lint::LintSeverity::Warning),
853 "the fixture needs a warning for the log arm to run"
854 );
855
856 log_blueprint_lint(manifest, &bp, &path.to_string_lossy());
857 });
858 }
859
860 #[test]
862 fn log_blueprint_lint_is_silent_for_a_clean_blueprint() {
863 crate::test_support::with_tracing(|| {});
864 let home = tempfile::tempdir().unwrap();
865 temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
866 let manifest = r#"
867[agent]
868name = "quiet"
869version = "0.1.0"
870
871[stages.main]
872mode = "autonomous"
873model = { models = [{ provider = "anthropic", model = "claude-sonnet-5" }] }
874max_iterations = 5
875
876[context.regions]
877system = { kind = "pinned", max_tokens = 1000 }
878"#;
879 let bp = leviath_core::manifest::parse_manifest(manifest).unwrap();
880 let dir = tempfile::tempdir().unwrap();
881 let path = dir.path().join("agent.leviath");
882 std::fs::write(&path, manifest).unwrap();
883 let env = crate::lint::LintEnv::offline(dir.path());
884 assert!(crate::lint::lint_manifest(manifest, &bp, &env).is_empty());
885 log_blueprint_lint(manifest, &bp, &path.to_string_lossy());
886 });
887 }
888
889 #[test]
890 fn fallback_order_parses_provider_slash_model_and_drops_junk() {
891 crate::test_support::with_tracing(|| {
894 let parsed = parse_fallback_order(&[
895 "openrouter/deepseek/deepseek-v4-flash".to_string(),
898 "anthropic/claude-sonnet-5".to_string(),
899 "anthropic".to_string(),
901 "/no-provider".to_string(),
902 "no-model/".to_string(),
903 String::new(),
904 ]);
905 assert_eq!(
906 parsed
907 .iter()
908 .map(|e| (e.provider.as_str(), e.model.as_str()))
909 .collect::<Vec<_>>(),
910 vec![
911 ("openrouter", "deepseek/deepseek-v4-flash"),
912 ("anthropic", "claude-sonnet-5"),
913 ]
914 );
915 });
916 }
917
918 #[test]
919 fn model_defaults_carries_the_fallback_chain_from_config() {
920 let mut config = Config {
921 default_provider: "openrouter".to_string(),
922 default_model: Some("deepseek".to_string()),
923 ..Default::default()
924 };
925 config.providers.fallback_order = vec!["anthropic/claude-sonnet-5".to_string()];
926 let defaults = model_defaults(&config);
927 assert_eq!(defaults.provider, "openrouter");
928 assert_eq!(defaults.model.as_deref(), Some("deepseek"));
929 assert_eq!(defaults.fallback_order.len(), 1);
930 assert_eq!(defaults.fallback_order[0].provider, "anthropic");
931 }
932
933 #[test]
934 fn discover_script_tools_registers_and_drops_collisions() {
935 crate::test_support::with_tracing(|| {});
936 let home = tempfile::tempdir().unwrap();
939 temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
940 let agent_dir = tempfile::tempdir().unwrap();
941 let tools = agent_dir.path().join("tools");
942 std::fs::create_dir(&tools).unwrap();
943 std::fs::write(tools.join("echo.rhai"), "// @tool echo\nparams.x").unwrap();
944 std::fs::write(tools.join("read_file.rhai"), "// @tool read_file\n1").unwrap();
946 std::fs::write(tools.join("mcp_tool.rhai"), "// @tool mcp_tool\n1").unwrap();
949 std::fs::write(tools.join("bad.rhai"), "no tool directive\nlet").unwrap();
951 std::fs::write(
955 tools.join("needs_gpu.rhai"),
956 "// @tool needs_gpu\n// @requires gpu\n1",
957 )
958 .unwrap();
959 std::fs::write(
961 tools.join("net_tool.rhai"),
962 "// @tool net_tool\n// @requires network\n1",
963 )
964 .unwrap();
965 let blueprint = agent_dir.path().join("agent.leviath");
966
967 let builtins: HashSet<String> = ["read_file".to_string()].into_iter().collect();
968 let mcp = vec![leviath_providers::Tool {
969 name: "mcp_tool".to_string(),
970 description: String::new(),
971 parameters: serde_json::json!({}),
972 }];
973 let (set, names, defs) =
974 discover_script_tools(blueprint.to_str().unwrap(), &builtins, &mcp, None);
975 assert!(set.contains("echo") && set.contains("read_file"));
978 assert!(names.contains("echo"));
979 assert!(!names.contains("read_file"));
980 assert!(!names.contains("mcp_tool"));
981 assert!(!names.contains("needs_gpu"), "unsatisfiable cap dropped");
982 assert!(names.contains("net_tool"), "satisfiable cap kept");
983 let mut def_names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
984 def_names.sort_unstable();
985 assert_eq!(def_names, vec!["echo", "net_tool"]);
986 });
987 }
988
989 #[test]
990 fn script_cap_maps_known_and_unknown_names() {
991 use leviath_tools::ToolCapability::*;
992 assert_eq!(script_cap("network"), Some(Network));
993 assert_eq!(script_cap("http"), Some(Network));
994 assert_eq!(script_cap("shell"), Some(ProcessSpawn));
995 assert_eq!(script_cap("process_spawn"), Some(ProcessSpawn));
996 assert_eq!(script_cap("filesystem"), Some(FileSystem));
997 assert_eq!(script_cap("fs"), Some(FileSystem));
998 assert_eq!(script_cap("gpu"), None);
999 }
1000
1001 #[test]
1002 fn platform_satisfies_caps_gates_on_support() {
1003 use leviath_tools::{PlatformCapabilities, ToolCapability};
1004 let mobile = PlatformCapabilities::mobile();
1006 assert!(platform_satisfies_caps(&mobile, &[]));
1007 assert!(platform_satisfies_caps(&mobile, &["network".to_string()]));
1009 assert!(!platform_satisfies_caps(&mobile, &["shell".to_string()]));
1010 let desktop = PlatformCapabilities::from_capabilities([
1012 ToolCapability::Network,
1013 ToolCapability::FileSystem,
1014 ToolCapability::ProcessSpawn,
1015 ]);
1016 assert!(!platform_satisfies_caps(&desktop, &["mystery".to_string()]));
1017 }
1018
1019 #[test]
1020 fn discover_script_tools_empty_when_no_tools_dir() {
1021 let home = tempfile::tempdir().unwrap();
1022 temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1023 let agent_dir = tempfile::tempdir().unwrap();
1024 let blueprint = agent_dir.path().join("agent.leviath");
1025 let (set, names, defs) =
1026 discover_script_tools(blueprint.to_str().unwrap(), &HashSet::new(), &[], None);
1027 assert!(set.is_empty() && names.is_empty() && defs.is_empty());
1028 });
1029 }
1030
1031 #[test]
1032 fn discover_script_tools_handles_pathless_blueprint() {
1033 let home = tempfile::tempdir().unwrap();
1036 temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1037 let (set, _n, _d) = discover_script_tools("", &HashSet::new(), &[], None);
1038 assert!(set.is_empty());
1039 });
1040 }
1041 use leviath_core::blueprint::ModelEntry;
1042
1043 fn model_cfg(models: Vec<(&str, &str)>) -> ModelConfig {
1044 ModelConfig {
1045 models: models
1046 .into_iter()
1047 .map(|(p, m)| ModelEntry {
1048 provider: p.to_string(),
1049 model: m.to_string(),
1050 })
1051 .collect(),
1052 allow_user_default: true,
1053 parameters: HashMap::new(),
1054 request_timeout_secs: None,
1055 }
1056 }
1057
1058 fn registry_with(providers: &[&str]) -> ProviderRegistry {
1059 let mut r = ProviderRegistry::new();
1060 for p in providers {
1061 r.register(p.to_string(), Arc::new(FakeProvider));
1062 }
1063 r
1064 }
1065
1066 struct FakeProvider;
1067 #[async_trait::async_trait]
1068 impl leviath_providers::Provider for FakeProvider {
1069 async fn infer(
1070 &self,
1071 _r: &leviath_providers::InferenceRequest,
1072 ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
1073 Err(leviath_providers::ProviderError::Other(
1074 "test provider".to_string(),
1075 ))
1076 }
1077 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
1078 1
1079 }
1080 fn max_context_tokens(&self, _m: &str) -> usize {
1081 1000
1082 }
1083 fn name(&self) -> &str {
1084 "fake"
1085 }
1086 fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
1087 leviath_providers::ModelCapabilities::default()
1088 }
1089 }
1090
1091 use leviath_providers::Provider;
1094 use leviath_runtime::components::AgentStatus;
1095 use leviath_runtime::inference_pool::InferencePoolConfig;
1096 use tokio::runtime::Handle;
1097
1098 fn coder_manifest() -> String {
1099 crate::test_support::inline_coder_manifest()
1102 }
1103
1104 fn test_world() -> (PipelineWorld, Arc<CliToolService>) {
1105 let cli = Arc::new(CliToolService::new());
1106 let world = PipelineWorld::new(
1107 registry_with(&["anthropic", "openai", "ollama"]),
1108 cli.clone(),
1109 InferencePoolConfig::new(),
1110 1,
1111 None,
1112 Handle::current(),
1113 );
1114 (world, cli)
1115 }
1116
1117 fn spawn_args(path: &str) -> SpawnArgs {
1118 SpawnArgs {
1119 run_id: "run-x".to_string(),
1120 blueprint_path: path.to_string(),
1121 task: String::new(),
1125 regions: HashMap::new(),
1126 model: None,
1127 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1128 metadata: HashMap::new(),
1129 callback_url: None,
1130 callback_secret: None,
1131 yolo: false,
1132 no_seed_commands: false,
1133 allow: Vec::new(),
1134 max_depth: None,
1135 parent_run_id: None,
1136 output: None,
1137 }
1138 }
1139
1140 fn custom_region_manifest() -> &'static str {
1145 "[agent]\nname = \"cr\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1146 [context.regions.brain]\nkind = \"custom\"\nscript = \"hooks/brain.rhai\"\nmax_tokens = 4000\n\n\
1147 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\n\
1148 [stages.main.context.regions.stage_view]\nkind = \"custom\"\nscript = \"hooks/stage.rhai\"\nmax_tokens = 2000\n"
1149 }
1150
1151 fn validator_blueprint(agent_script: Option<&str>, stage_script: Option<&str>) -> Blueprint {
1154 let mut bp = leviath_core::manifest::parse_manifest(
1155 "[agent]\nname = \"v\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1156 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1157 )
1158 .unwrap();
1159 let spec = |script: &str| leviath_core::output::OutputSpec {
1160 validator: Some(script.to_string()),
1161 ..leviath_core::output::OutputSpec::default()
1162 };
1163 bp.output = agent_script.map(spec);
1164 bp.stages[0].output = stage_script.map(spec);
1165 bp
1166 }
1167
1168 #[test]
1172 fn resolve_output_validators_compiles_each_distinct_script_once() {
1173 let dir = tempfile::tempdir().unwrap();
1174 let manifest = dir.path().join("agent.leviath");
1175 std::fs::create_dir(dir.path().join("validators")).unwrap();
1176 std::fs::write(
1177 dir.path().join("validators/shape.rhai"),
1178 "fn validate(content) { () }",
1179 )
1180 .unwrap();
1181
1182 let bp = validator_blueprint(Some("validators/shape.rhai"), Some("validators/shape.rhai"));
1185 let compiled =
1186 resolve_output_validators(&bp, &manifest.to_string_lossy()).expect("it compiles");
1187
1188 assert_eq!(compiled.len(), 1);
1189 assert!(compiled.contains_key("validators/shape.rhai"));
1190 }
1191
1192 #[test]
1195 fn resolve_output_validators_is_empty_without_any() {
1196 let dir = tempfile::tempdir().unwrap();
1197 let manifest = dir.path().join("agent.leviath");
1198
1199 let bp = validator_blueprint(None, None);
1201 assert!(
1202 resolve_output_validators(&bp, &manifest.to_string_lossy())
1203 .unwrap()
1204 .is_empty()
1205 );
1206
1207 let mut shaped = validator_blueprint(None, None);
1209 shaped.stages[0].output = Some(leviath_core::output::OutputSpec {
1210 format: Some("a2ui".to_string()),
1211 ..leviath_core::output::OutputSpec::default()
1212 });
1213 assert!(
1214 resolve_output_validators(&shaped, &manifest.to_string_lossy())
1215 .unwrap()
1216 .is_empty()
1217 );
1218 }
1219
1220 #[test]
1221 fn resolve_output_validators_reports_a_missing_script() {
1222 let dir = tempfile::tempdir().unwrap();
1223 let manifest = dir.path().join("agent.leviath");
1224 let bp = validator_blueprint(None, Some("validators/gone.rhai"));
1225
1226 let err = resolve_output_validators(&bp, &manifest.to_string_lossy())
1227 .expect_err("a script that is not there");
1228
1229 assert!(err.contains("cannot read output validator"), "{err}");
1230 assert!(err.contains("gone.rhai"), "{err}");
1231 }
1232
1233 #[test]
1234 fn resolve_output_validators_reports_one_that_does_not_compile() {
1235 let dir = tempfile::tempdir().unwrap();
1236 let manifest = dir.path().join("agent.leviath");
1237 std::fs::write(dir.path().join("broken.rhai"), "fn validate(a, b) { () }").unwrap();
1238 let bp = validator_blueprint(None, Some("broken.rhai"));
1239
1240 let err =
1241 resolve_output_validators(&bp, &manifest.to_string_lossy()).expect_err("wrong arity");
1242
1243 assert!(err.contains("failed to compile"), "{err}");
1244 }
1245
1246 fn hooked_manifest(hooks: &str) -> leviath_core::Blueprint {
1249 leviath_core::manifest::parse_manifest(&format!(
1250 "[agent]\nname = \"h\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1251 [stages.main]\nmodel = {{ provider = \"anthropic\", model = \"m\" }}\n{hooks}"
1252 ))
1253 .expect("the fixture manifest parses")
1254 }
1255
1256 #[test]
1257 fn stage_hooks_are_empty_when_no_stage_declares_one() {
1258 let dir = tempfile::tempdir().unwrap();
1259 let manifest = dir.path().join("agent.leviath");
1260 let bp = hooked_manifest("");
1261 let got = resolve_stage_hook_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1262 assert!(got.is_empty());
1263 }
1264
1265 #[test]
1266 fn a_declared_hook_is_compiled_and_keyed_by_its_path() {
1267 let dir = tempfile::tempdir().unwrap();
1268 let manifest = dir.path().join("agent.leviath");
1269 std::fs::write(dir.path().join("h.rhai"), "fn on_stage_enter(ctx) { () }").unwrap();
1270 let bp = hooked_manifest("[stages.main.hooks]\non_stage_enter = \"h.rhai\"\n");
1271
1272 let got = resolve_stage_hook_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1273 assert_eq!(got.len(), 1);
1274 assert!(got["h.rhai"].defines("on_stage_enter"));
1275 }
1276
1277 #[test]
1279 fn one_file_backing_two_hooks_is_compiled_once() {
1280 let dir = tempfile::tempdir().unwrap();
1281 let manifest = dir.path().join("agent.leviath");
1282 std::fs::write(
1283 dir.path().join("h.rhai"),
1284 "fn on_stage_enter(ctx) { () } fn on_stage_exit(ctx) { () }",
1285 )
1286 .unwrap();
1287 let bp = hooked_manifest(
1288 "[stages.main.hooks]\non_stage_enter = \"h.rhai\"\non_stage_exit = \"h.rhai\"\n",
1289 );
1290
1291 let got = resolve_stage_hook_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1292 assert_eq!(got.len(), 1, "one entry, not one per hook");
1293 assert!(got["h.rhai"].defines("on_stage_enter"));
1294 assert!(got["h.rhai"].defines("on_stage_exit"));
1295 }
1296
1297 #[test]
1300 fn a_missing_hook_script_fails_the_spawn() {
1301 let dir = tempfile::tempdir().unwrap();
1302 let manifest = dir.path().join("agent.leviath");
1303 let bp = hooked_manifest("[stages.main.hooks]\non_stage_enter = \"gone.rhai\"\n");
1304
1305 let err = resolve_stage_hook_scripts(&bp, &manifest.to_string_lossy())
1306 .expect_err("a missing script is a spawn error");
1307 assert!(err.contains("cannot read stage hook script"), "{err}");
1308 }
1309
1310 #[test]
1311 fn a_hook_script_that_does_not_compile_fails_the_spawn() {
1312 let dir = tempfile::tempdir().unwrap();
1313 let manifest = dir.path().join("agent.leviath");
1314 std::fs::write(dir.path().join("h.rhai"), "fn on_stage_enter(ctx) {").unwrap();
1315 let bp = hooked_manifest("[stages.main.hooks]\non_stage_enter = \"h.rhai\"\n");
1316
1317 let err = resolve_stage_hook_scripts(&bp, &manifest.to_string_lossy())
1318 .expect_err("a broken script is a spawn error");
1319 assert!(err.contains("failed to compile"), "{err}");
1320 }
1321
1322 #[test]
1326 fn a_file_that_lacks_the_hook_it_was_named_for_fails_the_spawn() {
1327 let dir = tempfile::tempdir().unwrap();
1328 let manifest = dir.path().join("agent.leviath");
1329 std::fs::write(dir.path().join("h.rhai"), "fn on_stage_exit(ctx) { () }").unwrap();
1330 let bp = hooked_manifest("[stages.main.hooks]\non_stage_enter = \"h.rhai\"\n");
1331
1332 let err = resolve_stage_hook_scripts(&bp, &manifest.to_string_lossy())
1333 .expect_err("a file missing its named hook is a spawn error");
1334 assert!(err.contains("defines no"), "{err}");
1335 }
1336
1337 #[test]
1338 fn resolve_region_scripts_empty_without_custom_regions() {
1339 let dir = tempfile::tempdir().unwrap();
1340 let manifest = dir.path().join("agent.leviath");
1341 let bp = leviath_core::manifest::parse_manifest(
1342 "[agent]\nname = \"plain\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1343 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1344 )
1345 .unwrap();
1346 let scripts = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1347 assert!(scripts.is_empty());
1348 }
1349
1350 #[test]
1351 fn resolve_region_scripts_collects_global_and_per_stage_layouts() {
1352 let dir = tempfile::tempdir().unwrap();
1353 let manifest = dir.path().join("agent.leviath");
1354 std::fs::create_dir(dir.path().join("hooks")).unwrap();
1355 std::fs::write(
1356 dir.path().join("hooks/brain.rhai"),
1357 "fn render(ctx) { \"b\" }",
1358 )
1359 .unwrap();
1360 std::fs::write(
1361 dir.path().join("hooks/stage.rhai"),
1362 "fn render(ctx) { \"s\" }",
1363 )
1364 .unwrap();
1365 let bp = leviath_core::manifest::parse_manifest(custom_region_manifest()).unwrap();
1366 let scripts = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1367 assert_eq!(scripts.len(), 2);
1368 assert!(scripts.contains_key("hooks/brain.rhai"));
1369 assert!(scripts.contains_key("hooks/stage.rhai"));
1370 }
1371
1372 #[test]
1373 fn resolve_region_scripts_reads_a_shared_path_once() {
1374 let dir = tempfile::tempdir().unwrap();
1376 let manifest = dir.path().join("agent.leviath");
1377 std::fs::create_dir(dir.path().join("hooks")).unwrap();
1378 std::fs::write(
1379 dir.path().join("hooks/shared.rhai"),
1380 "fn render(ctx) { \"x\" }",
1381 )
1382 .unwrap();
1383 let bp = leviath_core::manifest::parse_manifest(
1384 "[agent]\nname = \"cr\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1385 [context.regions.a]\nkind = \"custom\"\nscript = \"hooks/shared.rhai\"\nmax_tokens = 2000\n\n\
1386 [context.regions.b]\nkind = \"custom\"\nscript = \"hooks/shared.rhai\"\nmax_tokens = 2000\n\n\
1387 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1388 )
1389 .unwrap();
1390 let scripts = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1391 assert_eq!(scripts.len(), 1);
1392 }
1393
1394 #[test]
1395 fn resolve_region_scripts_missing_file_is_a_hard_error() {
1396 let dir = tempfile::tempdir().unwrap();
1397 let manifest = dir.path().join("agent.leviath");
1398 let bp = leviath_core::manifest::parse_manifest(custom_region_manifest()).unwrap();
1399 let err = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap_err();
1400 assert!(err.contains("region 'brain'"), "{err}");
1401 assert!(err.contains("hooks/brain.rhai"), "{err}");
1402 }
1403
1404 #[test]
1405 fn resolve_region_scripts_uncompilable_script_is_a_hard_error() {
1406 let dir = tempfile::tempdir().unwrap();
1407 let manifest = dir.path().join("agent.leviath");
1408 std::fs::create_dir(dir.path().join("hooks")).unwrap();
1409 std::fs::write(dir.path().join("hooks/brain.rhai"), "fn render(ctx) {").unwrap();
1410 std::fs::write(
1411 dir.path().join("hooks/stage.rhai"),
1412 "fn render(ctx) { \"s\" }",
1413 )
1414 .unwrap();
1415 let bp = leviath_core::manifest::parse_manifest(custom_region_manifest()).unwrap();
1416 let err = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap_err();
1417 assert!(err.contains("failed to compile"), "{err}");
1418 assert!(err.contains("region 'brain'"), "{err}");
1419 }
1420
1421 #[tokio::test]
1422 async fn build_agent_fails_fast_on_a_broken_custom_region_script() {
1423 let dir = tempfile::tempdir().unwrap();
1427 let manifest = dir.path().join("agent.leviath");
1428 std::fs::write(&manifest, custom_region_manifest()).unwrap();
1429
1430 let (mut world, cli) = test_world();
1431 let hub = InteractionHub::new();
1432 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1433 let args = spawn_args(&manifest.to_string_lossy());
1434 let err = build_agent(
1435 world.world_mut(),
1436 SpawnDeps {
1437 tool_service: cli.as_ref(),
1438 config: &Config::default(),
1439 shared_mcp: mcp,
1440 mcp_tool_defs: &[],
1441 hub: &hub,
1442 now_secs: 100,
1443 subagent_tx: sub_tx(),
1444 },
1445 &args,
1446 )
1447 .unwrap_err();
1448 assert!(err.contains("region 'brain'"), "got: {err}");
1449 assert!(err.contains("hooks/brain.rhai"), "got: {err}");
1450 }
1451
1452 #[tokio::test]
1460 async fn build_agent_rejects_a_run_id_that_is_not_a_directory_name() {
1461 for bad in ["../escape", "a/b", "..", ".", ""] {
1462 let (mut world, cli) = test_world();
1463 let hub = InteractionHub::new();
1464 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1465 let mut args = spawn_args("/nonexistent/agent.leviath");
1466 args.run_id = bad.to_string();
1467 let err = build_agent(
1468 world.world_mut(),
1469 SpawnDeps {
1470 tool_service: cli.as_ref(),
1471 config: &Config::default(),
1472 shared_mcp: mcp,
1473 mcp_tool_defs: &[],
1474 hub: &hub,
1475 now_secs: 100,
1476 subagent_tx: sub_tx(),
1477 },
1478 &args,
1479 )
1480 .unwrap_err();
1481 assert!(
1482 err.contains("run id"),
1483 "run id {bad:?} names a directory and must be refused, got: {err}"
1484 );
1485 }
1486 }
1487
1488 #[tokio::test]
1489 async fn build_agent_rejects_a_workdir_that_is_missing_or_not_a_directory() {
1490 let dir = tempfile::tempdir().unwrap();
1494 let manifest = dir.path().join("agent.leviath");
1495 std::fs::write(
1496 &manifest,
1497 "[agent]\nname = \"w\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1498 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1499 )
1500 .unwrap();
1501 let not_a_dir = dir.path().join("a-file");
1502 std::fs::write(¬_a_dir, "x").unwrap();
1503
1504 for workdir in [
1505 dir.path()
1506 .join("does-not-exist")
1507 .to_string_lossy()
1508 .to_string(),
1509 not_a_dir.to_string_lossy().to_string(),
1510 ] {
1511 let (mut world, cli) = test_world();
1512 let hub = InteractionHub::new();
1513 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1514 let mut args = spawn_args(&manifest.to_string_lossy());
1515 args.workdir = workdir.clone();
1516 let err = build_agent(
1517 world.world_mut(),
1518 SpawnDeps {
1519 tool_service: cli.as_ref(),
1520 config: &Config::default(),
1521 shared_mcp: mcp,
1522 mcp_tool_defs: &[],
1523 hub: &hub,
1524 now_secs: 100,
1525 subagent_tx: sub_tx(),
1526 },
1527 &args,
1528 )
1529 .unwrap_err();
1530 assert!(err.contains("workspace"), "got: {err}");
1531 assert!(err.contains(&workdir), "got: {err}");
1532 }
1533 }
1534
1535 #[tokio::test]
1536 async fn build_agent_attaches_taint_gate_when_security_enabled() {
1537 let dir = tempfile::tempdir().unwrap();
1538 let manifest = dir.path().join("agent.leviath");
1539 std::fs::write(
1540 &manifest,
1541 "[agent]\nname = \"sec\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1542 [security]\ntaint_tracking = true\n\n\
1543 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1544 )
1545 .unwrap();
1546 let (mut world, cli) = test_world();
1547 let hub = InteractionHub::new();
1548 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1549 let entity = build_agent(
1550 world.world_mut(),
1551 SpawnDeps {
1552 tool_service: cli.as_ref(),
1553 config: &Config::default(),
1554 shared_mcp: mcp,
1555 mcp_tool_defs: &[],
1556 hub: &hub,
1557 now_secs: 100,
1558 subagent_tx: sub_tx(),
1559 },
1560 &spawn_args(&manifest.to_string_lossy()),
1561 )
1562 .expect("spawn succeeds");
1563
1564 assert!(
1566 world
1567 .world()
1568 .get::<leviath_runtime::TaintGate>(entity)
1569 .is_some()
1570 );
1571 assert!(
1572 world
1573 .world()
1574 .get::<leviath_runtime::pipeline::ToolSensitivities>(entity)
1575 .is_some()
1576 );
1577 assert!(
1578 world
1579 .world()
1580 .get::<leviath_runtime::components::ContextWindow>(entity)
1581 .unwrap()
1582 .overall_taint()
1583 .is_some()
1584 );
1585 assert!(
1587 world
1588 .world()
1589 .get::<leviath_runtime::components::GateAutoApprove>(entity)
1590 .is_none()
1591 );
1592 }
1593
1594 #[tokio::test]
1595 async fn build_agent_marks_root_runs_for_titling_but_not_subagents() {
1596 let dir = tempfile::tempdir().unwrap();
1597 let manifest = dir.path().join("agent.leviath");
1598 std::fs::write(
1599 &manifest,
1600 "[agent]\nname = \"titler\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1603 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\n\
1604 [context.regions]\ntask = { kind = \"pinned\", max_tokens = 1000 }\n",
1605 )
1606 .unwrap();
1607 let (mut world, cli) = test_world();
1608 let hub = InteractionHub::new();
1609
1610 let root = build_agent(
1612 world.world_mut(),
1613 SpawnDeps {
1614 tool_service: cli.as_ref(),
1615 config: &Config::default(),
1616 shared_mcp: Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1617 mcp_tool_defs: &[],
1618 hub: &hub,
1619 now_secs: 100,
1620 subagent_tx: sub_tx(),
1621 },
1622 &SpawnArgs {
1623 task: "title me".to_string(),
1624 ..spawn_args(&manifest.to_string_lossy())
1625 },
1626 )
1627 .expect("spawn succeeds");
1628 assert!(
1629 world
1630 .world()
1631 .get::<leviath_runtime::title::PendingTitle>(root)
1632 .is_some()
1633 );
1634
1635 let mut child_args = spawn_args(&manifest.to_string_lossy());
1637 child_args.run_id = "run-child".to_string();
1638 child_args.parent_run_id = Some("run-x".to_string());
1639 let child = build_agent(
1640 world.world_mut(),
1641 SpawnDeps {
1642 tool_service: cli.as_ref(),
1643 config: &Config::default(),
1644 shared_mcp: Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1645 mcp_tool_defs: &[],
1646 hub: &hub,
1647 now_secs: 100,
1648 subagent_tx: sub_tx(),
1649 },
1650 &child_args,
1651 )
1652 .expect("spawn succeeds");
1653 assert!(
1654 world
1655 .world()
1656 .get::<leviath_runtime::title::PendingTitle>(child)
1657 .is_none()
1658 );
1659
1660 let config = Config {
1662 title: leviath_core::config::TitleConfig {
1663 enabled: false,
1664 provider: None,
1665 model: None,
1666 },
1667 ..Config::default()
1668 };
1669 let mut off_args = spawn_args(&manifest.to_string_lossy());
1670 off_args.run_id = "run-off".to_string();
1671 let off = build_agent(
1672 world.world_mut(),
1673 SpawnDeps {
1674 tool_service: cli.as_ref(),
1675 config: &config,
1676 shared_mcp: Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1677 mcp_tool_defs: &[],
1678 hub: &hub,
1679 now_secs: 100,
1680 subagent_tx: sub_tx(),
1681 },
1682 &off_args,
1683 )
1684 .expect("spawn succeeds");
1685 assert!(
1686 world
1687 .world()
1688 .get::<leviath_runtime::title::PendingTitle>(off)
1689 .is_none()
1690 );
1691 }
1692
1693 #[tokio::test]
1694 async fn build_agent_applies_policy_mcp_overrides_to_the_gate() {
1695 let dir = tempfile::tempdir().unwrap();
1696 let manifest = dir.path().join("agent.leviath");
1697 std::fs::write(
1698 &manifest,
1699 "[agent]\nname = \"sec-ov\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1700 [security]\ntaint_tracking = true\n\n\
1701 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1702 )
1703 .unwrap();
1704 let (mut world, cli) = test_world();
1705 world
1709 .world_mut()
1710 .insert_resource(leviath_runtime::pipeline::PolicyGate(
1711 leviath_core::PolicyConfig {
1712 allowlist: Vec::new(),
1713 mcp_overrides: HashMap::from([(
1714 "notes.share".to_string(),
1715 leviath_core::policy::McpToolOverride {
1716 sensitivity: None,
1717 direction: Some("outbound".to_string()),
1718 clearance: Some(leviath_core::TaintLevel::Private),
1719 },
1720 )]),
1721 },
1722 ));
1723 let hub = InteractionHub::new();
1724 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1725 let entity = build_agent(
1726 world.world_mut(),
1727 SpawnDeps {
1728 tool_service: cli.as_ref(),
1729 config: &Config::default(),
1730 shared_mcp: mcp,
1731 mcp_tool_defs: &[],
1732 hub: &hub,
1733 now_secs: 100,
1734 subagent_tx: sub_tx(),
1735 },
1736 &spawn_args(&manifest.to_string_lossy()),
1737 )
1738 .expect("spawn succeeds");
1739
1740 let gate = world
1741 .world()
1742 .get::<leviath_runtime::TaintGate>(entity)
1743 .expect("gate attached");
1744 let classification = gate.tool_classification("notes.share");
1745 assert_eq!(
1746 classification.direction,
1747 leviath_core::taint::ToolDirection::Outbound
1748 );
1749 assert_eq!(classification.clearance, leviath_core::TaintLevel::Private);
1750 }
1751
1752 #[tokio::test]
1753 async fn build_agent_errors_when_required_caller_region_missing() {
1754 let dir = tempfile::tempdir().unwrap();
1757 let manifest = dir.path().join("agent.leviath");
1758 std::fs::write(
1759 &manifest,
1760 "[agent]\nname = \"needs\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1761 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\n\
1762 [context.regions]\n\
1763 spec = { kind = \"pinned\", max_tokens = 2000, seed = \"input\", required = true }\n\
1764 conversation = { kind = \"sliding_window\", max_items = 20, max_tokens = 10000 }\n",
1765 )
1766 .unwrap();
1767 let (mut world, cli) = test_world();
1768 let hub = InteractionHub::new();
1769 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1770 let err = build_agent(
1772 world.world_mut(),
1773 SpawnDeps {
1774 tool_service: cli.as_ref(),
1775 config: &Config::default(),
1776 shared_mcp: mcp,
1777 mcp_tool_defs: &[],
1778 hub: &hub,
1779 now_secs: 100,
1780 subagent_tx: sub_tx(),
1781 },
1782 &spawn_args(&manifest.to_string_lossy()),
1783 )
1784 .unwrap_err();
1785 assert!(err.contains("spec"), "got: {err}");
1786 }
1787
1788 #[tokio::test]
1789 async fn build_agent_attaches_sandbox_when_configured() {
1790 let dir = tempfile::tempdir().unwrap();
1794 let manifest = dir.path().join("agent.leviath");
1795 std::fs::write(
1796 &manifest,
1797 "[agent]\nname = \"sb\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1798 [sandbox]\nkind = \"namespace\"\non_unavailable = \"warn\"\n\n\
1799 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1800 )
1801 .unwrap();
1802 let (mut world, cli) = test_world();
1803 let hub = InteractionHub::new();
1804 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1805 let entity = build_agent(
1806 world.world_mut(),
1807 SpawnDeps {
1808 tool_service: cli.as_ref(),
1809 config: &Config::default(),
1810 shared_mcp: mcp,
1811 mcp_tool_defs: &[],
1812 hub: &hub,
1813 now_secs: 100,
1814 subagent_tx: sub_tx(),
1815 },
1816 &spawn_args(&manifest.to_string_lossy()),
1817 )
1818 .expect("spawn succeeds");
1819 let state = cli.take(entity).expect("state registered");
1821 assert!(state.sandbox.is_some(), "sandbox manager attached");
1822 }
1823
1824 #[tokio::test]
1825 async fn build_agent_errors_when_sandbox_runtime_unavailable() {
1826 let dir = tempfile::tempdir().unwrap();
1831 let manifest = dir.path().join("agent.leviath");
1832 std::fs::write(
1833 &manifest,
1834 "[agent]\nname = \"sb\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1835 [sandbox]\nkind = \"container\"\nimage = \"x\"\nengine = \"leviath-no-such-engine\"\n\n\
1836 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1837 )
1838 .unwrap();
1839 let (mut world, cli) = test_world();
1840 let hub = InteractionHub::new();
1841 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1842 let err = build_agent(
1843 world.world_mut(),
1844 SpawnDeps {
1845 tool_service: cli.as_ref(),
1846 config: &Config::default(),
1847 shared_mcp: mcp,
1848 mcp_tool_defs: &[],
1849 hub: &hub,
1850 now_secs: 100,
1851 subagent_tx: sub_tx(),
1852 },
1853 &spawn_args(&manifest.to_string_lossy()),
1854 )
1855 .expect_err("a nonexistent engine can't start the container");
1856 assert!(err.contains("sandbox unavailable"), "got: {err}");
1857 }
1858
1859 #[tokio::test]
1860 async fn build_agent_yolo_attaches_gate_auto_approve_when_taint_on() {
1861 let dir = tempfile::tempdir().unwrap();
1862 let manifest = dir.path().join("agent.leviath");
1863 std::fs::write(
1864 &manifest,
1865 "[agent]\nname = \"sec\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1866 [security]\ntaint_tracking = true\n\n\
1867 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1868 )
1869 .unwrap();
1870 let (mut world, cli) = test_world();
1871 let hub = InteractionHub::new();
1872 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1873 let mut args = spawn_args(&manifest.to_string_lossy());
1874 args.yolo = true;
1875 let entity = build_agent(
1876 world.world_mut(),
1877 SpawnDeps {
1878 tool_service: cli.as_ref(),
1879 config: &Config::default(),
1880 shared_mcp: mcp,
1881 mcp_tool_defs: &[],
1882 hub: &hub,
1883 now_secs: 100,
1884 subagent_tx: sub_tx(),
1885 },
1886 &args,
1887 )
1888 .expect("spawn succeeds");
1889 assert!(
1892 world
1893 .world()
1894 .get::<leviath_runtime::components::GateAutoApprove>(entity)
1895 .is_some()
1896 );
1897 assert!(
1900 world
1901 .world()
1902 .get::<leviath_runtime::components::InteractionAutoApprove>(entity)
1903 .is_some()
1904 );
1905 assert!(cli.take(entity).expect("tool state registered").unattended);
1906 assert!(
1909 world
1910 .world()
1911 .get::<RunMetadata>(entity)
1912 .expect("run metadata attached")
1913 .unattended
1914 );
1915 }
1916
1917 #[tokio::test]
1920 async fn build_agent_yolo_leaves_the_run_active_and_unattended() {
1921 let dir = tempfile::tempdir().unwrap();
1922 let manifest = dir.path().join("agent.leviath");
1923 std::fs::write(
1924 &manifest,
1925 "[agent]\nname = \"a\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1926 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1927 )
1928 .unwrap();
1929 let (mut world, cli) = test_world();
1930 let mut args = spawn_args(&manifest.to_string_lossy());
1931 args.yolo = true;
1932 let entity = build_agent(
1933 world.world_mut(),
1934 SpawnDeps {
1935 tool_service: cli.as_ref(),
1936 config: &Config::default(),
1937 shared_mcp: Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1938 mcp_tool_defs: &[],
1939 hub: &InteractionHub::new(),
1940 now_secs: 100,
1941 subagent_tx: sub_tx(),
1942 },
1943 &args,
1944 )
1945 .expect("spawn succeeds");
1946
1947 assert_eq!(
1948 world.agent_status(world.own_agent(entity)),
1949 Some(AgentStatus::Active)
1950 );
1951 let meta = world
1952 .world()
1953 .get::<RunMetadata>(entity)
1954 .expect("run metadata attached");
1955 assert!(meta.unattended);
1956 }
1957
1958 #[tokio::test]
1967 async fn build_agent_refuses_a_validator_that_does_not_compile() {
1968 let dir = tempfile::tempdir().unwrap();
1969 let manifest = dir.path().join("agent.leviath");
1970 std::fs::write(dir.path().join("shape.rhai"), "fn validate(a, b) { () }").unwrap();
1971 std::fs::write(
1972 &manifest,
1973 "[agent]\nname = \"v\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1974 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\
1975 available_tools = [\"submit_output\"]\n\n\
1976 [stages.main.output]\nformat = \"a2ui\"\nvalidator = \"shape.rhai\"\n",
1977 )
1978 .unwrap();
1979
1980 let (mut world, cli) = test_world();
1981 let hub = InteractionHub::new();
1982 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1983 let args = spawn_args(&manifest.to_string_lossy());
1984 let err = build_agent(
1985 world.world_mut(),
1986 SpawnDeps {
1987 tool_service: cli.as_ref(),
1988 config: &Config::default(),
1989 shared_mcp: mcp,
1990 mcp_tool_defs: &[],
1991 hub: &hub,
1992 now_secs: 100,
1993 subagent_tx: sub_tx(),
1994 },
1995 &args,
1996 )
1997 .unwrap_err();
1998
1999 assert!(err.contains("failed to compile"), "got: {err}");
2000 assert!(err.contains("exactly one parameter"), "and says why: {err}");
2001 }
2002
2003 #[tokio::test]
2007 async fn build_agent_carries_output_validators_onto_the_entity() {
2008 let dir = tempfile::tempdir().unwrap();
2009 let manifest = dir.path().join("agent.leviath");
2010 std::fs::write(dir.path().join("shape.rhai"), "fn validate(content) { () }").unwrap();
2011 std::fs::write(
2012 &manifest,
2013 "[agent]\nname = \"v\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2014 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\
2015 available_tools = [\"submit_output\"]\n\n\
2016 [stages.main.output]\nformat = \"a2ui\"\nvalidator = \"shape.rhai\"\n",
2017 )
2018 .unwrap();
2019
2020 let (mut world, cli) = test_world();
2021 let hub = InteractionHub::new();
2022 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2023 let args = spawn_args(&manifest.to_string_lossy());
2024 let entity = build_agent(
2025 world.world_mut(),
2026 SpawnDeps {
2027 tool_service: cli.as_ref(),
2028 config: &Config::default(),
2029 shared_mcp: mcp,
2030 mcp_tool_defs: &[],
2031 hub: &hub,
2032 now_secs: 100,
2033 subagent_tx: sub_tx(),
2034 },
2035 &args,
2036 )
2037 .expect("spawns");
2038
2039 let validators = world
2040 .world()
2041 .get::<leviath_runtime::components::OutputValidators>(entity)
2042 .expect("the compiled validator reaches the entity");
2043 assert!(validators.0.contains_key("shape.rhai"));
2044 }
2045
2046 #[tokio::test]
2049 async fn build_agent_carries_no_validators_when_none_are_named() {
2050 let dir = tempfile::tempdir().unwrap();
2051 let manifest = dir.path().join("agent.leviath");
2052 std::fs::write(
2053 &manifest,
2054 "[agent]\nname = \"v\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2055 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2056 )
2057 .unwrap();
2058
2059 let (mut world, cli) = test_world();
2060 let hub = InteractionHub::new();
2061 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2062 let args = spawn_args(&manifest.to_string_lossy());
2063 let entity = build_agent(
2064 world.world_mut(),
2065 SpawnDeps {
2066 tool_service: cli.as_ref(),
2067 config: &Config::default(),
2068 shared_mcp: mcp,
2069 mcp_tool_defs: &[],
2070 hub: &hub,
2071 now_secs: 100,
2072 subagent_tx: sub_tx(),
2073 },
2074 &args,
2075 )
2076 .expect("spawns");
2077
2078 assert!(
2079 world
2080 .world()
2081 .get::<leviath_runtime::components::OutputValidators>(entity)
2082 .is_none()
2083 );
2084 }
2085
2086 #[tokio::test]
2087 async fn build_agent_carries_required_tools_into_the_tool_state() {
2088 let dir = tempfile::tempdir().unwrap();
2089 let manifest = dir.path().join("agent.leviath");
2090 std::fs::write(
2091 &manifest,
2092 "[agent]\nname = \"asks\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2093 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\
2094 available_tools = [\"read_file\", \"ask_user_text\"]\n\
2095 required_tools = [\"ask_user_text\"]\n",
2096 )
2097 .unwrap();
2098 let (mut world, cli) = test_world();
2099 let mut args = spawn_args(&manifest.to_string_lossy());
2100 args.yolo = true;
2101 let entity = build_agent(
2102 world.world_mut(),
2103 SpawnDeps {
2104 tool_service: cli.as_ref(),
2105 config: &Config::default(),
2106 shared_mcp: Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
2107 mcp_tool_defs: &[],
2108 hub: &InteractionHub::new(),
2109 now_secs: 100,
2110 subagent_tx: sub_tx(),
2111 },
2112 &args,
2113 )
2114 .expect("spawn succeeds");
2115
2116 let state = cli.take(entity).expect("tool state registered");
2117 assert!(
2118 state
2119 .stage_required
2120 .lock()
2121 .unwrap()
2122 .contains("ask_user_text")
2123 );
2124 assert_eq!(state.stage_required_by_index.len(), 1);
2125 }
2126
2127 #[tokio::test]
2128 async fn build_agent_without_yolo_keeps_prompts_interactive() {
2129 let dir = tempfile::tempdir().unwrap();
2130 let manifest = dir.path().join("agent.leviath");
2131 std::fs::write(
2132 &manifest,
2133 "[agent]\nname = \"plain\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2134 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2135 )
2136 .unwrap();
2137 let (mut world, cli) = test_world();
2138 let hub = InteractionHub::new();
2139 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2140 let entity = build_agent(
2141 world.world_mut(),
2142 SpawnDeps {
2143 tool_service: cli.as_ref(),
2144 config: &Config::default(),
2145 shared_mcp: mcp,
2146 mcp_tool_defs: &[],
2147 hub: &hub,
2148 now_secs: 100,
2149 subagent_tx: sub_tx(),
2150 },
2151 &spawn_args(&manifest.to_string_lossy()),
2152 )
2153 .expect("spawn succeeds");
2154 assert!(
2155 world
2156 .world()
2157 .get::<leviath_runtime::components::InteractionAutoApprove>(entity)
2158 .is_none()
2159 );
2160 assert!(!cli.take(entity).expect("tool state registered").unattended);
2161 }
2162
2163 #[tokio::test]
2164 async fn build_agent_no_security_block_leaves_taint_off_by_default() {
2165 let dir = tempfile::tempdir().unwrap();
2170 let manifest = dir.path().join("agent.leviath");
2171 std::fs::write(
2172 &manifest,
2173 "[agent]\nname = \"plain\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2174 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2175 )
2176 .unwrap();
2177 let (mut world, cli) = test_world();
2178 let hub = InteractionHub::new();
2179 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2180 let entity = build_agent(
2181 world.world_mut(),
2182 SpawnDeps {
2183 tool_service: cli.as_ref(),
2184 config: &Config::default(),
2185 shared_mcp: mcp,
2187 mcp_tool_defs: &[],
2188 hub: &hub,
2189 now_secs: 100,
2190 subagent_tx: sub_tx(),
2191 },
2192 &spawn_args(&manifest.to_string_lossy()),
2193 )
2194 .expect("spawn succeeds");
2195 assert!(
2196 world
2197 .world()
2198 .get::<leviath_runtime::TaintGate>(entity)
2199 .is_none(),
2200 "no [security] block + global off ⇒ no taint gate"
2201 );
2202 }
2203
2204 async fn spawned_no_output_tools(manifest_body: &str) -> bool {
2206 let dir = tempfile::tempdir().unwrap();
2207 let manifest = dir.path().join("agent.leviath");
2208 std::fs::write(&manifest, manifest_body).unwrap();
2209 let (mut world, cli) = test_world();
2210 let hub = InteractionHub::new();
2211 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2212 let entity = build_agent(
2213 world.world_mut(),
2214 SpawnDeps {
2215 tool_service: cli.as_ref(),
2216 config: &Config::default(),
2217 shared_mcp: mcp,
2218 mcp_tool_defs: &[],
2219 hub: &hub,
2220 now_secs: 100,
2221 subagent_tx: sub_tx(),
2222 },
2223 &spawn_args(&manifest.to_string_lossy()),
2224 )
2225 .expect("spawn succeeds");
2226 world
2227 .world()
2228 .get::<leviath_runtime::persistence::RunOutcomeFlags>(entity)
2229 .expect("build_agent attaches run outcome flags")
2230 .0
2231 .no_output_tools
2232 }
2233
2234 #[tokio::test]
2235 async fn build_agent_records_whether_the_blueprint_can_write_at_all() {
2236 assert!(!spawned_no_output_tools(&coder_manifest()).await);
2239 assert!(
2243 spawned_no_output_tools(
2244 "[agent]\nname = \"router\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2245 [stages.triage]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\
2246 available_tools = [\"read_file\", \"spawn_agent\"]\n",
2247 )
2248 .await
2249 );
2250 }
2251
2252 #[tokio::test]
2253 async fn build_agent_spawns_registers_and_wires_tools() {
2254 let dir = tempfile::tempdir().unwrap();
2255 let manifest = dir.path().join("agent.leviath");
2256 std::fs::write(&manifest, coder_manifest()).unwrap();
2257
2258 let (mut world, cli) = test_world();
2259 let hub = InteractionHub::new();
2260 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2261 let entity = build_agent(
2262 world.world_mut(),
2263 SpawnDeps {
2264 tool_service: cli.as_ref(),
2265 config: &Config::default(),
2266 shared_mcp: mcp,
2267 mcp_tool_defs: &[],
2268 hub: &hub,
2269 now_secs: 100,
2270 subagent_tx: sub_tx(),
2271 },
2272 &spawn_args(&manifest.to_string_lossy()),
2273 )
2274 .expect("spawn succeeds");
2275
2276 assert_eq!(
2277 world.agent_status(world.own_agent(entity)),
2278 Some(AgentStatus::Active)
2279 );
2280 let md = world
2282 .world()
2283 .get::<RunMetadata>(entity)
2284 .expect("run metadata");
2285 assert_eq!(md.run_id, "run-x");
2286 assert_eq!(md.agent_name, "coder");
2287 let out = leviath_runtime::pipeline::ToolService::exec_for(
2289 cli.as_ref(),
2290 entity,
2291 vec![leviath_providers::ToolCall {
2292 id: "c1".to_string(),
2293 name: "list_dir".to_string(),
2294 arguments: serde_json::json!({"path": "."}),
2295 thought_signature: None,
2296 }],
2297 leviath_runtime::pipeline::noop_progress(),
2298 )()
2299 .await;
2300 assert_eq!(out[0].0, "c1");
2301 assert!(!out[0].1.contains("no tool state"));
2302 }
2303
2304 #[tokio::test]
2305 async fn build_agent_tags_dynamic_tools_agent() {
2306 let dir = tempfile::tempdir().unwrap();
2310 let manifest = dir.path().join("agent.leviath");
2311 std::fs::write(
2312 &manifest,
2313 coder_manifest().replace("[agent]", "[agent]\ndynamic_tools = true"),
2314 )
2315 .unwrap();
2316
2317 let (mut world, cli) = test_world();
2318 let hub = InteractionHub::new();
2319 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2320 let entity = build_agent(
2321 world.world_mut(),
2322 SpawnDeps {
2323 tool_service: cli.as_ref(),
2324 config: &Config::default(),
2325 shared_mcp: mcp,
2326 mcp_tool_defs: &[],
2327 hub: &hub,
2328 now_secs: 100,
2329 subagent_tx: sub_tx(),
2330 },
2331 &spawn_args(&manifest.to_string_lossy()),
2332 )
2333 .expect("spawn succeeds");
2334
2335 assert!(
2336 world
2337 .world()
2338 .get::<leviath_runtime::pipeline::DynamicTools>(entity)
2339 .is_some(),
2340 "dynamic_tools agent must carry the DynamicTools marker"
2341 );
2342 assert!(
2344 leviath_runtime::pipeline::ToolService::refresh_tools(cli.as_ref(), entity, 0)
2345 .is_some()
2346 );
2347 }
2348
2349 #[tokio::test]
2350 async fn build_agent_applies_yolo_allow_and_max_depth() {
2351 let dir = tempfile::tempdir().unwrap();
2352 let manifest = dir.path().join("agent.leviath");
2353 std::fs::write(&manifest, coder_manifest()).unwrap();
2354
2355 let (mut world, cli) = test_world();
2356 let hub = InteractionHub::new();
2357 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2358 let config = Config {
2362 tool_permissions: HashMap::from([(
2363 "read_file".to_string(),
2364 crate::config::ToolPolicy::Deny,
2365 )]),
2366 ..Default::default()
2367 };
2368 let mut args = spawn_args(&manifest.to_string_lossy());
2369 args.yolo = true;
2370 args.allow = vec!["read_file".to_string()];
2371 args.max_depth = Some(7);
2372
2373 let entity = build_agent(
2374 world.world_mut(),
2375 SpawnDeps {
2376 tool_service: cli.as_ref(),
2377 config: &config,
2378 shared_mcp: mcp,
2379 mcp_tool_defs: &[],
2380 hub: &hub,
2381 now_secs: 100,
2382 subagent_tx: sub_tx(),
2383 },
2384 &args,
2385 )
2386 .expect("spawn succeeds");
2387 assert_eq!(
2388 world.agent_status(world.own_agent(entity)),
2389 Some(AgentStatus::Active)
2390 );
2391
2392 let out = leviath_runtime::pipeline::ToolService::exec_for(
2394 cli.as_ref(),
2395 entity,
2396 vec![leviath_providers::ToolCall {
2397 id: "c1".to_string(),
2398 name: "read_file".to_string(),
2399 arguments: serde_json::json!({"path": "/no/such/file"}),
2400 thought_signature: None,
2401 }],
2402 leviath_runtime::pipeline::noop_progress(),
2403 )()
2404 .await;
2405 let result = out[0].1.clone();
2406 assert!(
2407 result.contains("[denied]"),
2408 "a configured deny must survive --yolo, got: {result}"
2409 );
2410
2411 let out = leviath_runtime::pipeline::ToolService::exec_for(
2414 cli.as_ref(),
2415 entity,
2416 vec![leviath_providers::ToolCall {
2417 id: "c2".to_string(),
2418 name: "list_dir".to_string(),
2419 arguments: serde_json::json!({"path": "."}),
2420 thought_signature: None,
2421 }],
2422 leviath_runtime::pipeline::noop_progress(),
2423 )()
2424 .await;
2425 let result = out[0].1.clone();
2426 assert!(
2427 !result.contains("[denied]"),
2428 "--yolo must still waive approval where nothing denies, got: {result}"
2429 );
2430 }
2431
2432 #[tokio::test]
2433 async fn build_agent_honors_agent_level_tool_permissions() {
2434 let dir = tempfile::tempdir().unwrap();
2435 let manifest = dir.path().join("agent.leviath");
2436 std::fs::write(
2440 &manifest,
2441 "[agent]\nname = \"perm\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2442 [tool_permissions]\nread_file = \"deny\"\n\n\
2443 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2444 )
2445 .unwrap();
2446
2447 let (mut world, cli) = test_world();
2448 let hub = InteractionHub::new();
2449 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2450 let entity = build_agent(
2451 world.world_mut(),
2452 SpawnDeps {
2453 tool_service: cli.as_ref(),
2454 config: &Config::default(),
2455 shared_mcp: mcp,
2456 mcp_tool_defs: &[],
2457 hub: &hub,
2458 now_secs: 100,
2459 subagent_tx: sub_tx(),
2460 },
2461 &spawn_args(&manifest.to_string_lossy()),
2462 )
2463 .expect("spawn succeeds");
2464
2465 let out = leviath_runtime::pipeline::ToolService::exec_for(
2466 cli.as_ref(),
2467 entity,
2468 vec![leviath_providers::ToolCall {
2469 id: "c1".to_string(),
2470 name: "read_file".to_string(),
2471 arguments: serde_json::json!({"path": "/no/such/file"}),
2472 thought_signature: None,
2473 }],
2474 leviath_runtime::pipeline::noop_progress(),
2475 )()
2476 .await;
2477 assert!(
2478 out[0].1.contains("[denied]"),
2479 "agent-level deny should block read_file"
2480 );
2481 }
2482
2483 #[tokio::test]
2484 async fn build_agent_script_host_honors_agent_level_grants() {
2485 let dir = tempfile::tempdir().unwrap();
2486 let manifest = dir.path().join("agent.leviath");
2487 std::fs::write(
2488 &manifest,
2489 "[agent]\nname = \"scriptperm\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2490 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2491 )
2492 .unwrap();
2493
2494 let mut config = Config::default();
2500 config.agent_tool_permissions.insert(
2501 "scriptperm".to_string(),
2502 HashMap::from([("write_file".to_string(), crate::config::ToolPolicy::Allow)]),
2503 );
2504
2505 let (mut world, cli) = test_world();
2506 let hub = InteractionHub::new();
2507 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2508 let mut args = spawn_args(&manifest.to_string_lossy());
2509 args.workdir = dir.path().to_string_lossy().to_string();
2510 let entity = build_agent(
2511 world.world_mut(),
2512 SpawnDeps {
2513 tool_service: cli.as_ref(),
2514 config: &config,
2515 shared_mcp: mcp,
2516 mcp_tool_defs: &[],
2517 hub: &hub,
2518 now_secs: 100,
2519 subagent_tx: sub_tx(),
2520 },
2521 &args,
2522 )
2523 .expect("spawn succeeds");
2524
2525 let state = cli.take(entity).expect("tool state registered at spawn");
2526 state
2527 .script_host
2528 .write_file("granted.txt", "ok")
2529 .expect("agent-level write_file grant must reach the script host");
2530 assert_eq!(
2531 std::fs::read_to_string(dir.path().join("granted.txt")).unwrap(),
2532 "ok"
2533 );
2534 }
2535
2536 #[tokio::test]
2537 async fn build_agent_applies_default_max_iterations_only_when_stage_omits_it() {
2538 let dir = tempfile::tempdir().unwrap();
2539 let manifest = dir.path().join("agent.leviath");
2540 std::fs::write(
2542 &manifest,
2543 "[agent]\nname = \"iters\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2544 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\n\
2545 [stages.capped]\nmax_iterations = 3\n\
2546 model = { provider = \"anthropic\", model = \"m\" }\n",
2547 )
2548 .unwrap();
2549
2550 let (mut world, cli) = test_world();
2551 let hub = InteractionHub::new();
2552 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2553 let config = Config {
2555 limits: crate::config::LimitsConfig {
2556 default_max_iterations: Some(42),
2557 ..Default::default()
2558 },
2559 ..Default::default()
2560 };
2561 let entity = build_agent(
2562 world.world_mut(),
2563 SpawnDeps {
2564 tool_service: cli.as_ref(),
2565 config: &config,
2566 shared_mcp: mcp,
2567 mcp_tool_defs: &[],
2568 hub: &hub,
2569 now_secs: 100,
2570 subagent_tx: sub_tx(),
2571 },
2572 &spawn_args(&manifest.to_string_lossy()),
2573 )
2574 .expect("spawn succeeds");
2575
2576 let bp = world
2577 .world()
2578 .get::<leviath_runtime::pipeline::AgentBlueprint>(entity)
2579 .expect("blueprint");
2580 let by_name = |n: &str| {
2581 bp.0.stages
2582 .iter()
2583 .find(|s| s.name == n)
2584 .unwrap()
2585 .max_iterations
2586 };
2587 assert_eq!(by_name("main"), Some(42));
2589 assert_eq!(by_name("capped"), Some(3));
2591 }
2592
2593 #[tokio::test]
2594 async fn build_agent_leaves_max_iterations_unset_when_config_default_is_none() {
2595 let dir = tempfile::tempdir().unwrap();
2596 let manifest = dir.path().join("agent.leviath");
2597 std::fs::write(
2598 &manifest,
2599 "[agent]\nname = \"nolimit\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2600 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2601 )
2602 .unwrap();
2603
2604 let (mut world, cli) = test_world();
2605 let hub = InteractionHub::new();
2606 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2607 let config = Config {
2609 limits: crate::config::LimitsConfig {
2610 default_max_iterations: None,
2611 ..Default::default()
2612 },
2613 ..Default::default()
2614 };
2615 let entity = build_agent(
2616 world.world_mut(),
2617 SpawnDeps {
2618 tool_service: cli.as_ref(),
2619 config: &config,
2620 shared_mcp: mcp,
2621 mcp_tool_defs: &[],
2622 hub: &hub,
2623 now_secs: 100,
2624 subagent_tx: sub_tx(),
2625 },
2626 &spawn_args(&manifest.to_string_lossy()),
2627 )
2628 .expect("spawn succeeds");
2629
2630 let bp = world
2631 .world()
2632 .get::<leviath_runtime::pipeline::AgentBlueprint>(entity)
2633 .expect("blueprint");
2634 assert_eq!(bp.0.stages[0].max_iterations, None);
2635 }
2636
2637 #[tokio::test]
2638 async fn fake_provider_methods_are_exercised() {
2639 let p = FakeProvider;
2640 assert_eq!(p.name(), "fake");
2641 assert_eq!(p.count_tokens("t", "m").await, 1);
2642 assert_eq!(p.max_context_tokens("m"), 1000);
2643 let _ = p.capabilities("m");
2644 assert!(
2645 p.infer(&leviath_providers::InferenceRequest {
2646 system: vec![],
2647 messages: vec![],
2648 model: "m".to_string(),
2649 max_tokens: 1,
2650 temperature: 0.0,
2651 tools: vec![],
2652 extra: serde_json::Value::Null,
2653 request_timeout_secs: None,
2654 })
2655 .await
2656 .is_err()
2657 );
2658 }
2659
2660 use std::path::Path;
2663
2664 fn blueprint_declaring(read_paths: &[&str]) -> Blueprint {
2665 let stage = leviath_core::Stage::new("s".to_string(), model_cfg(vec![("anthropic", "m")]));
2666 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
2667 let mut bp = Blueprint::new("cto".to_string(), "d".to_string(), vec![stage], layout);
2668 if !read_paths.is_empty() {
2669 bp.read_paths = Some(leviath_core::ReadPathsConfig {
2670 allow: read_paths.iter().map(|s| s.to_string()).collect(),
2671 });
2672 }
2673 bp
2674 }
2675
2676 #[test]
2680 fn read_path_grant_counts_are_recorded_for_a_declaring_blueprint() {
2681 let bp = blueprint_declaring(&["/data/runs", "/data/docs"]);
2682 let mut config = Config::default();
2683 config.security.read_paths = vec!["/data/runs".to_string()];
2684 let counts = read_path_grant_counts(&bp, &config, Path::new("/w")).expect("declares paths");
2685 assert_eq!(counts.declared, 2);
2686 assert_eq!(counts.granted, 1);
2687
2688 assert!(
2689 read_path_grant_counts(&blueprint_declaring(&[]), &config, Path::new("/w")).is_none()
2690 );
2691
2692 let mut broken = Config::default();
2693 broken.security.read_paths = vec!["regex:relative/.*".to_string()];
2694 assert!(read_path_grant_counts(&bp, &broken, Path::new("/w")).is_none());
2695 }
2696
2697 #[test]
2698 fn read_path_policy_is_inactive_without_declarations() {
2699 let bp = blueprint_declaring(&[]);
2700 let (policy, warning) =
2701 build_read_path_policy(&bp, &Config::default(), Path::new("/w")).unwrap();
2702 assert!(!policy.is_active());
2703 assert!(warning.is_none());
2704
2705 let mut bp = blueprint_declaring(&[]);
2707 bp.read_paths = Some(leviath_core::ReadPathsConfig { allow: vec![] });
2708 let (policy, warning) =
2709 build_read_path_policy(&bp, &Config::default(), Path::new("/w")).unwrap();
2710 assert!(!policy.is_active());
2711 assert!(warning.is_none());
2712 }
2713
2714 #[test]
2717 fn read_path_policy_warns_when_nothing_grants() {
2718 let bp = blueprint_declaring(&["/data/runs", "glob:/data/docs/**"]);
2719 let (policy, warning) =
2720 build_read_path_policy(&bp, &Config::default(), Path::new("/w")).unwrap();
2721 assert!(policy.is_active());
2722 assert!(!policy.allow_blueprint);
2723 assert!(policy.grants.is_empty());
2724 let warning = warning.expect("ungranted declarations must warn");
2725 assert!(warning.contains("allow_blueprint_read_paths"), "{warning}");
2726 assert!(warning.contains("[agent_read_paths.cto]"), "{warning}");
2727 assert!(warning.contains("\"/data/runs\""), "{warning}");
2728 assert!(warning.contains("\"glob:/data/docs/**\""), "{warning}");
2729 }
2730
2731 #[test]
2732 fn read_path_policy_is_quiet_when_granted() {
2733 let bp = blueprint_declaring(&["/data/runs"]);
2734 let mut config = Config::default();
2735 config.agent_read_paths.insert(
2736 "cto".to_string(),
2737 crate::config::ReadPathGrants {
2738 allow: vec!["/data/runs".to_string()],
2739 },
2740 );
2741 let (policy, warning) = build_read_path_policy(&bp, &config, Path::new("/w")).unwrap();
2742 assert!(policy.is_active());
2743 assert!(!policy.grants.is_empty());
2744 assert!(warning.is_none());
2745 }
2746
2747 #[test]
2748 fn read_path_policy_is_quiet_under_the_override() {
2749 let bp = blueprint_declaring(&["/data/runs"]);
2750 let mut config = Config::default();
2751 config.security.allow_blueprint_read_paths = true;
2752 let (policy, warning) = build_read_path_policy(&bp, &config, Path::new("/w")).unwrap();
2753 assert!(policy.allow_blueprint);
2754 assert!(warning.is_none());
2755 }
2756
2757 #[test]
2760 fn read_path_policy_rejects_bad_entries_loudly() {
2761 let bp = blueprint_declaring(&["glob:["]);
2762 let err = build_read_path_policy(&bp, &Config::default(), Path::new("/w")).unwrap_err();
2763 assert!(err.contains("agent 'cto' [read_paths]"), "{err}");
2764
2765 let bp = blueprint_declaring(&["/data/runs"]);
2766 let mut config = Config::default();
2767 config.security.read_paths = vec!["regex:(".to_string()];
2768 let err = build_read_path_policy(&bp, &config, Path::new("/w")).unwrap_err();
2769 assert!(err.contains("config.toml"), "{err}");
2770 }
2771
2772 #[test]
2775 fn read_sensitivities_bump_only_the_read_tools_when_granted() {
2776 use leviath_core::TaintLevel;
2777 let base = || {
2778 HashMap::from([
2779 ("read_file".to_string(), TaintLevel::Internal),
2780 ("list_dir".to_string(), TaintLevel::Public),
2781 ("write_file".to_string(), TaintLevel::Internal),
2782 ])
2783 };
2784
2785 let mut map = base();
2786 bump_read_sensitivities(&mut map, true);
2787 assert_eq!(map.get("read_file"), Some(&TaintLevel::Private));
2788 assert_eq!(map.get("list_dir"), Some(&TaintLevel::Private));
2789 assert_eq!(map.get("write_file"), Some(&TaintLevel::Internal));
2790 assert!(!map.contains_key("read_files"));
2792
2793 let mut map = base();
2794 bump_read_sensitivities(&mut map, false);
2795 assert_eq!(map, base(), "no grant, no change");
2796 }
2797
2798 #[tokio::test]
2799 async fn build_agent_read_error() {
2800 let (mut world, cli) = test_world();
2801 let hub = InteractionHub::new();
2802 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2803 let err = build_agent(
2804 world.world_mut(),
2805 SpawnDeps {
2806 tool_service: cli.as_ref(),
2807 config: &Config::default(),
2808 shared_mcp: mcp,
2809 mcp_tool_defs: &[],
2810 hub: &hub,
2811 now_secs: 100,
2812 subagent_tx: sub_tx(),
2813 },
2814 &spawn_args("/no/such/manifest.leviath"),
2815 )
2816 .unwrap_err();
2817 assert!(err.contains("read manifest"));
2818 }
2819
2820 const OVERSIZED_MANIFEST: &str = r#"
2823[agent]
2824name = "tiny"
2825version = "0.1.0"
2826description = "d"
2827entry_stage = "main"
2828
2829[context.regions]
2830task = { kind = "pinned", max_tokens = 20 }
2831
2832[stages.main]
2833mode = "autonomous"
2834model = { models = [{ provider = "anthropic", model = "m" }] }
2835description = "d"
2836available_tools = []
2837system_prompt = "SYSTEM_PROMPT_PLACEHOLDER"
2838"#;
2839
2840 #[tokio::test]
2841 async fn build_agent_propagates_spawn_error() {
2842 let dir = tempfile::tempdir().unwrap();
2843 let manifest = dir.path().join("tiny.leviath");
2844 let content = OVERSIZED_MANIFEST.replace("SYSTEM_PROMPT_PLACEHOLDER", &"x ".repeat(5000));
2846 std::fs::write(&manifest, content).unwrap();
2847
2848 let (mut world, cli) = test_world();
2849 let hub = InteractionHub::new();
2850 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2851 let result = build_agent(
2852 world.world_mut(),
2853 SpawnDeps {
2854 tool_service: cli.as_ref(),
2855 config: &Config::default(),
2856 shared_mcp: mcp,
2857 mcp_tool_defs: &[],
2858 hub: &hub,
2859 now_secs: 100,
2860 subagent_tx: sub_tx(),
2861 },
2862 &spawn_args(&manifest.to_string_lossy()),
2863 );
2864 assert!(result.is_err(), "expected spawn error, got {result:?}");
2865 }
2866
2867 #[tokio::test]
2868 async fn build_agent_refuses_a_manifest_with_no_usable_provider() {
2869 let dir = tempfile::tempdir().unwrap();
2873 let manifest = dir.path().join("ghostly.leviath");
2874 std::fs::write(
2875 &manifest,
2876 r#"
2877[agent]
2878name = "ghostly"
2879version = "0.1.0"
2880description = "d"
2881entry_stage = "main"
2882
2883[context.regions]
2884task = { kind = "pinned", max_tokens = 4000 }
2885
2886[stages.main]
2887mode = "autonomous"
2888model = { models = [{ provider = "ghost", model = "m" }], allow_user_default = false }
2889description = "d"
2890available_tools = []
2891"#,
2892 )
2893 .unwrap();
2894 let (mut world, cli) = test_world();
2895 let hub = InteractionHub::new();
2896 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2897 let err = build_agent(
2898 world.world_mut(),
2899 SpawnDeps {
2900 tool_service: cli.as_ref(),
2901 config: &Config::default(),
2902 shared_mcp: mcp,
2903 mcp_tool_defs: &[],
2904 hub: &hub,
2905 now_secs: 100,
2906 subagent_tx: sub_tx(),
2907 },
2908 &spawn_args(&manifest.to_string_lossy()),
2909 )
2910 .unwrap_err();
2911 assert!(err.contains("main"), "names the stage: {err}");
2912 assert!(err.contains("ghost"), "names what it tried: {err}");
2913 }
2914
2915 #[tokio::test]
2916 async fn build_agent_invalid_blueprint() {
2917 let dir = tempfile::tempdir().unwrap();
2918 let manifest = dir.path().join("bad.leviath");
2919 std::fs::write(
2921 &manifest,
2922 r#"
2923[agent]
2924name = "bad"
2925version = "0.1.0"
2926description = "d"
2927entry_stage = "ghost"
2928
2929[context.regions]
2930task = { kind = "pinned", max_tokens = 4000 }
2931
2932[stages.main]
2933mode = "autonomous"
2934model = { models = [{ provider = "anthropic", model = "m" }] }
2935description = "d"
2936available_tools = []
2937"#,
2938 )
2939 .unwrap();
2940 let (mut world, cli) = test_world();
2941 let hub = InteractionHub::new();
2942 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2943 let err = build_agent(
2944 world.world_mut(),
2945 SpawnDeps {
2946 tool_service: cli.as_ref(),
2947 config: &Config::default(),
2948 shared_mcp: mcp,
2949 mcp_tool_defs: &[],
2950 hub: &hub,
2951 now_secs: 100,
2952 subagent_tx: sub_tx(),
2953 },
2954 &spawn_args(&manifest.to_string_lossy()),
2955 )
2956 .unwrap_err();
2957 assert!(err.contains("invalid blueprint"));
2958 }
2959
2960 #[tokio::test]
2961 async fn build_agent_without_entry_stage_and_with_compaction() {
2962 let dir = tempfile::tempdir().unwrap();
2963 let manifest = dir.path().join("mini.leviath");
2964 std::fs::write(
2966 &manifest,
2967 r#"
2968[agent]
2969name = "mini"
2970version = "0.1.0"
2971description = "d"
2972
2973[compaction]
2974provider = "anthropic"
2975model = "claude-x"
2976
2977[context.regions]
2978task = { kind = "pinned", max_tokens = 4000 }
2979
2980[stages.main]
2981mode = "autonomous"
2982model = { models = [{ provider = "anthropic", model = "m" }] }
2983description = "d"
2984available_tools = []
2985system_prompt = "be brief"
2986"#,
2987 )
2988 .unwrap();
2989 let (mut world, cli) = test_world();
2990 let hub = InteractionHub::new();
2991 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2992 let entity = build_agent(
2993 world.world_mut(),
2994 SpawnDeps {
2995 tool_service: cli.as_ref(),
2996 config: &Config::default(),
2997 shared_mcp: mcp,
2998 mcp_tool_defs: &[],
2999 hub: &hub,
3000 now_secs: 100,
3001 subagent_tx: sub_tx(),
3002 },
3003 &spawn_args(&manifest.to_string_lossy()),
3004 )
3005 .expect("spawn succeeds");
3006 assert_eq!(
3007 world.agent_status(world.own_agent(entity)),
3008 Some(AgentStatus::Active)
3009 );
3010 assert!(world.world().get::<CompactionSettings>(entity).is_some());
3012 }
3013
3014 fn write_read_paths_manifest(dir: &std::path::Path, allow: &str) -> std::path::PathBuf {
3017 let manifest = dir.join("reader.leviath");
3018 std::fs::write(
3019 &manifest,
3020 format!(
3021 r#"
3022[agent]
3023name = "reader"
3024version = "0.1.0"
3025description = "d"
3026
3027[read_paths]
3028allow = [{allow}]
3029
3030[context.regions]
3031task = {{ kind = "pinned", max_tokens = 4000 }}
3032
3033[stages.main]
3034mode = "autonomous"
3035model = {{ models = [{{ provider = "anthropic", model = "m" }}] }}
3036description = "d"
3037available_tools = []
3038system_prompt = "be brief"
3039"#
3040 ),
3041 )
3042 .unwrap();
3043 manifest
3044 }
3045
3046 #[tokio::test]
3049 async fn build_agent_attaches_declared_stage_hooks() {
3050 let dir = tempfile::tempdir().unwrap();
3051 std::fs::write(dir.path().join("h.rhai"), "fn on_stage_enter(ctx) { () }").unwrap();
3052 let manifest = dir.path().join("agent.leviath");
3053 std::fs::write(
3054 &manifest,
3055 "[agent]\nname = \"h\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
3056 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\
3057 [stages.main.hooks]\non_stage_enter = \"h.rhai\"\n",
3058 )
3059 .unwrap();
3060
3061 let (mut world, cli) = test_world();
3062 let hub = InteractionHub::new();
3063 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
3064 let entity = build_agent(
3065 world.world_mut(),
3066 SpawnDeps {
3067 tool_service: cli.as_ref(),
3068 config: &Config::default(),
3069 shared_mcp: mcp,
3070 mcp_tool_defs: &[],
3071 hub: &hub,
3072 now_secs: 100,
3073 subagent_tx: sub_tx(),
3074 },
3075 &spawn_args(&manifest.to_string_lossy()),
3076 )
3077 .expect("spawn succeeds");
3078
3079 let scripts = world
3080 .world_mut()
3081 .get::<leviath_runtime::components::StageHookScripts>(entity)
3082 .expect("the hook script is attached");
3083 assert!(scripts.0.contains_key("h.rhai"));
3084 }
3085
3086 #[tokio::test]
3089 async fn build_agent_refuses_a_broken_stage_hook() {
3090 let dir = tempfile::tempdir().unwrap();
3091 std::fs::write(dir.path().join("h.rhai"), "fn on_stage_enter(ctx) {").unwrap();
3092 let manifest = dir.path().join("agent.leviath");
3093 std::fs::write(
3094 &manifest,
3095 "[agent]\nname = \"h\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
3096 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\
3097 [stages.main.hooks]\non_stage_enter = \"h.rhai\"\n",
3098 )
3099 .unwrap();
3100
3101 let (mut world, cli) = test_world();
3102 let hub = InteractionHub::new();
3103 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
3104 let err = build_agent(
3105 world.world_mut(),
3106 SpawnDeps {
3107 tool_service: cli.as_ref(),
3108 config: &Config::default(),
3109 shared_mcp: mcp,
3110 mcp_tool_defs: &[],
3111 hub: &hub,
3112 now_secs: 100,
3113 subagent_tx: sub_tx(),
3114 },
3115 &spawn_args(&manifest.to_string_lossy()),
3116 )
3117 .expect_err("a broken hook script must fail the spawn");
3118 assert!(err.contains("failed to compile"), "{err}");
3119 }
3120
3121 #[tokio::test]
3124 async fn build_agent_wires_granted_read_paths() {
3125 let dir = tempfile::tempdir().unwrap();
3126 let manifest = write_read_paths_manifest(dir.path(), "\"/tmp\"");
3127 let (mut world, cli) = test_world();
3128 let hub = InteractionHub::new();
3129 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
3130 let mut config = Config::default();
3131 config.security.allow_blueprint_read_paths = true;
3132 config.taint_tracking = true;
3133 let entity = build_agent(
3134 world.world_mut(),
3135 SpawnDeps {
3136 tool_service: cli.as_ref(),
3137 config: &config,
3138 shared_mcp: mcp,
3139 mcp_tool_defs: &[],
3140 hub: &hub,
3141 now_secs: 100,
3142 subagent_tx: sub_tx(),
3143 },
3144 &spawn_args(&manifest.to_string_lossy()),
3145 )
3146 .expect("spawn succeeds");
3147 assert_eq!(
3148 world.agent_status(world.own_agent(entity)),
3149 Some(AgentStatus::Active)
3150 );
3151 }
3152
3153 #[tokio::test]
3156 async fn build_agent_wires_ungranted_read_paths() {
3157 let dir = tempfile::tempdir().unwrap();
3158 let manifest = write_read_paths_manifest(dir.path(), "\"/tmp\"");
3159 let (mut world, cli) = test_world();
3160 let hub = InteractionHub::new();
3161 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
3162 let entity = build_agent(
3163 world.world_mut(),
3164 SpawnDeps {
3165 tool_service: cli.as_ref(),
3166 config: &Config::default(),
3167 shared_mcp: mcp,
3168 mcp_tool_defs: &[],
3169 hub: &hub,
3170 now_secs: 100,
3171 subagent_tx: sub_tx(),
3172 },
3173 &spawn_args(&manifest.to_string_lossy()),
3174 )
3175 .expect("spawn succeeds even when nothing grants the declaration");
3176 assert_eq!(
3177 world.agent_status(world.own_agent(entity)),
3178 Some(AgentStatus::Active)
3179 );
3180 }
3181
3182 #[tokio::test]
3185 async fn build_agent_rejects_a_malformed_config_grant() {
3186 let dir = tempfile::tempdir().unwrap();
3187 let manifest = write_read_paths_manifest(dir.path(), "\"/tmp\"");
3188 let (mut world, cli) = test_world();
3189 let hub = InteractionHub::new();
3190 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
3191 let mut config = Config::default();
3192 config.security.read_paths = vec!["glob:[".to_string()];
3193 let err = build_agent(
3194 world.world_mut(),
3195 SpawnDeps {
3196 tool_service: cli.as_ref(),
3197 config: &config,
3198 shared_mcp: mcp,
3199 mcp_tool_defs: &[],
3200 hub: &hub,
3201 now_secs: 100,
3202 subagent_tx: sub_tx(),
3203 },
3204 &spawn_args(&manifest.to_string_lossy()),
3205 )
3206 .expect_err("a broken config grant must fail the spawn");
3207 assert!(err.contains("config.toml"), "{err}");
3208 }
3209
3210 #[tokio::test]
3211 async fn build_agent_parse_error() {
3212 let dir = tempfile::tempdir().unwrap();
3213 let manifest = dir.path().join("bad.leviath");
3214 std::fs::write(&manifest, "this is not valid toml : : :").unwrap();
3215 let (mut world, cli) = test_world();
3216 let hub = InteractionHub::new();
3217 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
3218 let err = build_agent(
3219 world.world_mut(),
3220 SpawnDeps {
3221 tool_service: cli.as_ref(),
3222 config: &Config::default(),
3223 shared_mcp: mcp,
3224 mcp_tool_defs: &[],
3225 hub: &hub,
3226 now_secs: 100,
3227 subagent_tx: sub_tx(),
3228 },
3229 &spawn_args(&manifest.to_string_lossy()),
3230 )
3231 .unwrap_err();
3232 assert!(err.contains("parse manifest"));
3233 }
3234
3235 fn bp(regions_toml: &str) -> Blueprint {
3238 let implicit_task = match regions_toml.contains("task") {
3243 true => "",
3244 false => "task = { kind = \"pinned\", max_tokens = 1000 }",
3245 };
3246 let toml = format!(
3247 r#"
3248[agent]
3249name = "seedy"
3250
3251[stages.main]
3252mode = "autonomous"
3253
3254[stages.main.model]
3255provider = "anthropic"
3256model = "claude-sonnet-5"
3257
3258[context.regions]
3259{regions_toml}
3260{implicit_task}
3261conversation = {{ kind = "sliding_window", max_items = 20, max_tokens = 10000 }}
3262"#
3263 );
3264 leviath_core::manifest::parse_manifest(&toml).unwrap()
3265 }
3266
3267 fn args_with(task: &str, regions: HashMap<String, String>, workdir: &str) -> SpawnArgs {
3268 SpawnArgs {
3269 run_id: "r".to_string(),
3270 blueprint_path: "/bp".to_string(),
3271 task: task.to_string(),
3272 regions,
3273 model: None,
3274 workdir: workdir.to_string(),
3275 metadata: HashMap::new(),
3276 callback_url: None,
3277 callback_secret: None,
3278 yolo: false,
3279 no_seed_commands: false,
3280 allow: Vec::new(),
3281 max_depth: None,
3282 parent_run_id: None,
3283 output: None,
3284 }
3285 }
3286
3287 fn seed_policy() -> SeedCommandPolicy {
3290 SeedCommandPolicy::disabled()
3291 }
3292
3293 fn seed_safe_keys() -> std::sync::Arc<std::collections::HashSet<String>> {
3297 std::sync::Arc::new(
3298 ["shell:scan-repo".to_string()]
3299 .into_iter()
3300 .collect::<std::collections::HashSet<_>>(),
3301 )
3302 }
3303
3304 fn no_read_paths() -> leviath_core::ReadPathPolicy {
3307 leviath_core::ReadPathPolicy {
3308 agent: "a".to_string(),
3309 blueprint: Default::default(),
3310 grants: Default::default(),
3311 allow_blueprint: false,
3312 }
3313 }
3314
3315 fn stub_policy(result: Result<String, String>) -> SeedCommandPolicy {
3318 SeedCommandPolicy {
3319 allowed: true,
3320 timeout: std::time::Duration::from_secs(1),
3321 safe_keys: seed_safe_keys(),
3322 runner: std::sync::Arc::new(move |_, _, _| result.clone()),
3323 }
3324 }
3325
3326 #[test]
3327 fn resolve_seeds_fills_task_and_caller_input() {
3328 let bp = bp(
3329 r#"task = { kind = "pinned", max_tokens = 4000, seed = "task_input" }
3330criteria = { kind = "pinned", max_tokens = 2000, seed = "input" }"#,
3331 );
3332 let args = args_with(
3333 "build it",
3334 HashMap::from([("criteria".to_string(), "be safe".to_string())]),
3335 "/tmp",
3336 );
3337 let seeds = resolve_seeds(&bp, &args, "/tmp", &seed_policy(), &no_read_paths()).unwrap();
3338 assert_eq!(seeds.get("task").map(String::as_str), Some("build it"));
3339 assert_eq!(seeds.get("criteria").map(String::as_str), Some("be safe"));
3340 }
3341
3342 #[test]
3343 fn resolve_seeds_required_caller_input_missing_is_error() {
3344 let bp =
3345 bp(r#"spec = { kind = "pinned", max_tokens = 2000, seed = "input", required = true }"#);
3346 let args = args_with("t", HashMap::new(), "/tmp");
3347 let err = resolve_seeds(&bp, &args, "/tmp", &seed_policy(), &no_read_paths()).unwrap_err();
3348 assert!(err.contains("spec"), "got: {err}");
3349 }
3350
3351 #[test]
3352 fn resolve_seeds_optional_caller_input_missing_is_omitted() {
3353 let bp = bp(r#"notes = { kind = "pinned", max_tokens = 2000, seed = "input" }"#);
3354 let args = args_with("t", HashMap::new(), "/tmp");
3355 let seeds = resolve_seeds(&bp, &args, "/tmp", &seed_policy(), &no_read_paths()).unwrap();
3356 assert!(!seeds.contains_key("notes"));
3357 }
3358
3359 #[test]
3360 fn resolve_seeds_literal_and_files() {
3361 let dir = tempfile::tempdir().unwrap();
3362 std::fs::write(dir.path().join("a.txt"), "alpha").unwrap();
3363 std::fs::write(dir.path().join("b.txt"), "beta").unwrap();
3364 let bp = bp(
3365 r#"lit = { kind = "pinned", max_tokens = 500, seed = { literal = "hello" } }
3366docs = { kind = "pinned", max_tokens = 2000, seed = { files = ["a.txt", "b.txt"] } }"#,
3367 );
3368 let args = args_with("t", HashMap::new(), &dir.path().to_string_lossy());
3369 let seeds = resolve_seeds(
3370 &bp,
3371 &args,
3372 &dir.path().to_string_lossy(),
3373 &seed_policy(),
3374 &no_read_paths(),
3375 )
3376 .unwrap();
3377 assert_eq!(seeds.get("lit").map(String::as_str), Some("hello"));
3378 let docs = seeds.get("docs").unwrap();
3379 assert!(docs.contains("alpha") && docs.contains("beta"));
3380 }
3381
3382 #[test]
3383 fn resolve_seeds_glob_concatenates_matches() {
3384 let dir = tempfile::tempdir().unwrap();
3385 std::fs::create_dir(dir.path().join("specs")).unwrap();
3386 std::fs::write(dir.path().join("specs/one.md"), "spec one").unwrap();
3387 std::fs::write(dir.path().join("specs/two.md"), "spec two").unwrap();
3388 let bp =
3389 bp(r#"specs = { kind = "pinned", max_tokens = 4000, seed = { glob = "specs/*.md" } }"#);
3390 let wd = dir.path().to_string_lossy().to_string();
3391 let args = args_with("t", HashMap::new(), &wd);
3392 let seeds = resolve_seeds(&bp, &args, &wd, &seed_policy(), &no_read_paths()).unwrap();
3393 let specs = seeds.get("specs").unwrap();
3394 assert!(specs.contains("spec one") && specs.contains("spec two"));
3395 }
3396
3397 #[test]
3398 fn resolve_seeds_rhai_runs_script() {
3399 let dir = tempfile::tempdir().unwrap();
3400 std::fs::write(
3402 dir.path().join("init.rhai"),
3403 r#""seeded: " + input["task"]"#,
3404 )
3405 .unwrap();
3406 let bp = bp(
3407 r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "init.rhai" } }"#,
3408 );
3409 let wd = dir.path().to_string_lossy().to_string();
3410 let args = args_with("hello", HashMap::new(), &wd);
3411 let seeds = resolve_seeds(&bp, &args, &wd, &seed_policy(), &no_read_paths()).unwrap();
3412 assert_eq!(
3413 seeds.get("scripted").map(String::as_str),
3414 Some("seeded: hello")
3415 );
3416 }
3417
3418 #[test]
3419 fn resolve_seeds_files_required_missing_errors_optional_skips() {
3420 let dir = tempfile::tempdir().unwrap();
3421 let wd = dir.path().to_string_lossy().to_string();
3422 let req = bp(
3424 r#"docs = { kind = "pinned", max_tokens = 2000, seed = { files = ["missing.txt"] }, required = true }"#,
3425 );
3426 let args = args_with("t", HashMap::new(), &wd);
3427 let err = resolve_seeds(&req, &args, &wd, &seed_policy(), &no_read_paths()).unwrap_err();
3428 assert!(err.contains("missing.txt"), "got: {err}");
3429 let opt = bp(
3431 r#"docs = { kind = "pinned", max_tokens = 2000, seed = { files = ["missing.txt"] } }"#,
3432 );
3433 let seeds = resolve_seeds(&opt, &args, &wd, &seed_policy(), &no_read_paths()).unwrap();
3434 assert!(!seeds.contains_key("docs"));
3435 }
3436
3437 #[test]
3438 fn resolve_seeds_glob_no_match_required_errors_optional_skips() {
3439 let dir = tempfile::tempdir().unwrap();
3440 let wd = dir.path().to_string_lossy().to_string();
3441 let args = args_with("t", HashMap::new(), &wd);
3442 let req = bp(
3444 r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "none/*.md" }, required = true }"#,
3445 );
3446 let err = resolve_seeds(&req, &args, &wd, &seed_policy(), &no_read_paths()).unwrap_err();
3447 assert!(err.contains("matched no files"), "got: {err}");
3448 let opt =
3450 bp(r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "none/*.md" } }"#);
3451 let seeds = resolve_seeds(&opt, &args, &wd, &seed_policy(), &no_read_paths()).unwrap();
3452 assert!(!seeds.contains_key("specs"));
3453 }
3454
3455 #[test]
3456 fn resolve_seeds_bad_glob_pattern_errors() {
3457 let dir = tempfile::tempdir().unwrap();
3459 let wd = dir.path().to_string_lossy().to_string();
3460 let bp = bp(r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "[" } }"#);
3461 let args = args_with("t", HashMap::new(), &wd);
3462 let err = resolve_seeds(&bp, &args, &wd, &seed_policy(), &no_read_paths()).unwrap_err();
3463 assert!(err.contains("bad glob"), "got: {err}");
3464 }
3465
3466 #[test]
3467 fn resolve_seeds_rhai_script_error() {
3468 let dir = tempfile::tempdir().unwrap();
3469 std::fs::write(dir.path().join("boom.rhai"), "undefined_func()").unwrap();
3471 let wd = dir.path().to_string_lossy().to_string();
3472 let bp = bp(
3473 r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "boom.rhai" } }"#,
3474 );
3475 let args = args_with("t", HashMap::new(), &wd);
3476 let err = resolve_seeds(&bp, &args, &wd, &seed_policy(), &no_read_paths()).unwrap_err();
3477 assert!(err.contains("rhai seed failed"), "got: {err}");
3478 }
3479
3480 fn command_bp(required: bool) -> leviath_core::Blueprint {
3484 let req = if required { ", required = true" } else { "" };
3485 bp(&format!(
3486 r#"facts = {{ kind = "pinned", max_tokens = 500, seed = {{ command = "scan-repo" }}{req} }}"#
3487 ))
3488 }
3489
3490 #[test]
3491 fn resolve_seeds_command_stores_output() {
3492 let bp = command_bp(false);
3493 let args = args_with("t", HashMap::new(), "/tmp");
3494 let seeds = resolve_seeds(
3495 &bp,
3496 &args,
3497 "/tmp",
3498 &stub_policy(Ok("src/lib.rs\nsrc/main.rs".to_string())),
3499 &no_read_paths(),
3500 )
3501 .unwrap();
3502 assert_eq!(
3503 seeds.get("facts").map(String::as_str),
3504 Some("src/lib.rs\nsrc/main.rs")
3505 );
3506 }
3507
3508 #[test]
3509 fn resolve_seeds_command_receives_the_workdir_and_command() {
3510 let bp = command_bp(false);
3512 let args = args_with("t", HashMap::new(), "/work");
3513 let policy = SeedCommandPolicy {
3514 allowed: true,
3515 timeout: std::time::Duration::from_secs(9),
3516 safe_keys: seed_safe_keys(),
3517 runner: std::sync::Arc::new(|command, workdir, timeout| {
3518 Ok(format!(
3519 "{command}@{}#{}",
3520 workdir.display(),
3521 timeout.as_secs()
3522 ))
3523 }),
3524 };
3525 let seeds = resolve_seeds(&bp, &args, "/work", &policy, &no_read_paths()).unwrap();
3526 assert_eq!(
3527 seeds.get("facts").map(String::as_str),
3528 Some("scan-repo@/work#9")
3529 );
3530 }
3531
3532 #[test]
3533 fn resolve_seeds_command_failure_is_skipped_when_optional() {
3534 let bp = command_bp(false);
3535 let args = args_with("t", HashMap::new(), "/tmp");
3536 let seeds = resolve_seeds(
3537 &bp,
3538 &args,
3539 "/tmp",
3540 &stub_policy(Err("timed out".to_string())),
3541 &no_read_paths(),
3542 )
3543 .unwrap();
3544 assert!(
3545 !seeds.contains_key("facts"),
3546 "an optional command seed must not sink the spawn"
3547 );
3548 }
3549
3550 #[test]
3551 fn resolve_seeds_command_failure_errors_when_required() {
3552 let bp = command_bp(true);
3553 let args = args_with("t", HashMap::new(), "/tmp");
3554 let err = resolve_seeds(
3555 &bp,
3556 &args,
3557 "/tmp",
3558 &stub_policy(Err("boom".to_string())),
3559 &no_read_paths(),
3560 )
3561 .unwrap_err();
3562 assert!(err.contains("scan-repo"), "got: {err}");
3563 assert!(err.contains("boom"), "got: {err}");
3564 }
3565
3566 #[test]
3567 fn resolve_seeds_command_empty_output_is_skipped_when_optional() {
3568 let bp = command_bp(false);
3569 let args = args_with("t", HashMap::new(), "/tmp");
3570 let seeds = resolve_seeds(
3571 &bp,
3572 &args,
3573 "/tmp",
3574 &stub_policy(Ok(" \n".to_string())),
3575 &no_read_paths(),
3576 )
3577 .unwrap();
3578 assert!(!seeds.contains_key("facts"));
3579 }
3580
3581 #[test]
3582 fn resolve_seeds_command_empty_output_errors_when_required() {
3583 let bp = command_bp(true);
3584 let args = args_with("t", HashMap::new(), "/tmp");
3585 let err = resolve_seeds(
3586 &bp,
3587 &args,
3588 "/tmp",
3589 &stub_policy(Ok(String::new())),
3590 &no_read_paths(),
3591 )
3592 .unwrap_err();
3593 assert!(err.contains("returned empty"), "got: {err}");
3594 }
3595
3596 #[test]
3597 fn resolve_seeds_command_skipped_when_disabled() {
3598 let bp = command_bp(false);
3602 let args = args_with("t", HashMap::new(), "/tmp");
3603 let mut policy = stub_policy(Ok("SHOULD NOT BE USED".to_string()));
3604 policy.allowed = false;
3605 let seeds = resolve_seeds(&bp, &args, "/tmp", &policy, &no_read_paths()).unwrap();
3606 assert!(!seeds.contains_key("facts"));
3607 }
3608
3609 #[test]
3610 fn resolve_seeds_required_command_errors_when_disabled() {
3611 let bp = command_bp(true);
3614 let args = args_with("t", HashMap::new(), "/tmp");
3615 let err = resolve_seeds(
3616 &bp,
3617 &args,
3618 "/tmp",
3619 &SeedCommandPolicy::disabled(),
3620 &no_read_paths(),
3621 )
3622 .unwrap_err();
3623 assert!(err.contains("allow_seed_commands"), "got: {err}");
3624 }
3625
3626 #[test]
3627 fn resolve_seeds_glob_matching_directory_required_errors() {
3628 let dir = tempfile::tempdir().unwrap();
3631 std::fs::create_dir(dir.path().join("subdir")).unwrap();
3632 let wd = dir.path().to_string_lossy().to_string();
3633 let bp = bp(
3634 r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "sub*" }, required = true }"#,
3635 );
3636 let args = args_with("t", HashMap::new(), &wd);
3637 let err = resolve_seeds(&bp, &args, &wd, &seed_policy(), &no_read_paths()).unwrap_err();
3638 assert!(err.contains("read seed file"), "got: {err}");
3639 }
3640
3641 #[test]
3642 fn resolve_seeds_rhai_read_error() {
3643 let dir = tempfile::tempdir().unwrap();
3644 let wd = dir.path().to_string_lossy().to_string();
3645 let bp = bp(
3646 r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "nope.rhai" } }"#,
3647 );
3648 let args = args_with("t", HashMap::new(), &wd);
3649 let err = resolve_seeds(&bp, &args, &wd, &seed_policy(), &no_read_paths()).unwrap_err();
3650 assert!(err.contains("read rhai seed"), "got: {err}");
3651 }
3652
3653 #[test]
3654 fn resolve_seeds_rhai_empty_required_errors_optional_skips() {
3655 let dir = tempfile::tempdir().unwrap();
3656 std::fs::write(dir.path().join("empty.rhai"), r#""""#).unwrap();
3658 let wd = dir.path().to_string_lossy().to_string();
3659 let args = args_with("t", HashMap::new(), &wd);
3660 let req = bp(
3661 r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "empty.rhai" }, required = true }"#,
3662 );
3663 let err = resolve_seeds(&req, &args, &wd, &seed_policy(), &no_read_paths()).unwrap_err();
3664 assert!(err.contains("returned empty"), "got: {err}");
3665 let opt = bp(
3667 r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "empty.rhai" } }"#,
3668 );
3669 let seeds = resolve_seeds(&opt, &args, &wd, &seed_policy(), &no_read_paths()).unwrap();
3670 assert!(!seeds.contains_key("scripted"));
3671 }
3672
3673 #[test]
3674 fn resolve_seeds_tolerates_unknown_caller_region() {
3675 let bp = bp(r#"task = { kind = "pinned", max_tokens = 4000, seed = "task_input" }"#);
3678 let args = args_with(
3679 "t",
3680 HashMap::from([("ghost".to_string(), "x".to_string())]),
3681 "/tmp",
3682 );
3683 let seeds = resolve_seeds(&bp, &args, "/tmp", &seed_policy(), &no_read_paths()).unwrap();
3684 assert_eq!(seeds.get("task").map(String::as_str), Some("t"));
3685 assert!(!seeds.contains_key("ghost"));
3686 }
3687
3688 fn workdir_with_a_neighbour() -> (tempfile::TempDir, String) {
3693 let root = tempfile::tempdir().expect("tempdir");
3694 let work = root.path().join("work");
3695 std::fs::create_dir_all(&work).expect("dirs");
3696 std::fs::write(root.path().join("config.toml"), "api_key = \"sk-SECRET\"").expect("write");
3697 let wd = work.to_string_lossy().to_string();
3698 (root, wd)
3699 }
3700
3701 #[test]
3704 fn a_seed_file_outside_the_workdir_is_refused() {
3705 let (_root, wd) = workdir_with_a_neighbour();
3706 let bp = bp(
3707 r#"notes = { kind = "pinned", max_tokens = 2000, seed = { files = ["../config.toml"] } }"#,
3708 );
3709 let args = args_with("t", HashMap::new(), &wd);
3710 let err = resolve_seeds(&bp, &args, &wd, &seed_policy(), &no_read_paths()).unwrap_err();
3711 assert!(err.contains("outside the working directory"), "{err}");
3712 assert!(err.contains("read_paths"), "{err}");
3713 }
3714
3715 #[test]
3718 fn a_seed_file_inside_the_workdir_still_seeds() {
3719 let (root, wd) = workdir_with_a_neighbour();
3720 std::fs::write(root.path().join("work").join("notes.md"), "hello").expect("write");
3721 let bp = bp(
3722 r#"notes = { kind = "pinned", max_tokens = 2000, seed = { files = ["notes.md"] } }"#,
3723 );
3724 let args = args_with("t", HashMap::new(), &wd);
3725 let seeds = resolve_seeds(&bp, &args, &wd, &seed_policy(), &no_read_paths()).unwrap();
3726 assert!(seeds.get("notes").is_some_and(|s| s.contains("hello")));
3727 }
3728
3729 #[test]
3732 fn a_glob_that_matches_outside_the_workdir_is_refused() {
3733 let (_root, wd) = workdir_with_a_neighbour();
3734 let bp =
3735 bp(r#"notes = { kind = "pinned", max_tokens = 2000, seed = { glob = "../*.toml" } }"#);
3736 let args = args_with("t", HashMap::new(), &wd);
3737 let err = resolve_seeds(&bp, &args, &wd, &seed_policy(), &no_read_paths()).unwrap_err();
3738 assert!(err.contains("outside the working directory"), "{err}");
3739 }
3740
3741 #[test]
3744 fn a_granted_read_path_lets_a_seed_file_out() {
3745 let (root, wd) = workdir_with_a_neighbour();
3746 let outside = root.path().to_string_lossy().to_string();
3747 let mut policy = no_read_paths();
3748 policy.blueprint = leviath_core::ReadPathSet::compile(
3749 std::slice::from_ref(&outside),
3750 std::path::Path::new(&wd),
3751 None,
3752 cfg!(windows),
3753 )
3754 .expect("declaration compiles");
3755 policy.allow_blueprint = true;
3756
3757 let bp = bp(
3758 r#"notes = { kind = "pinned", max_tokens = 2000, seed = { files = ["../config.toml"] } }"#,
3759 );
3760 let args = args_with("t", HashMap::new(), &wd);
3761 let seeds = resolve_seeds(&bp, &args, &wd, &seed_policy(), &policy).unwrap();
3762 assert!(seeds.get("notes").is_some_and(|s| s.contains("sk-SECRET")));
3763 }
3764
3765 #[test]
3768 fn a_hook_script_outside_the_blueprint_directory_is_refused() {
3769 let root = tempfile::tempdir().expect("tempdir");
3770 let bp_dir = root.path().join("agents").join("evil");
3771 std::fs::create_dir_all(&bp_dir).expect("dirs");
3772 std::fs::write(root.path().join("outside.txt"), "NOT RHAI").expect("write");
3773
3774 let mut stage = leviath_core::Stage::new(
3775 "main".to_string(),
3776 leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
3777 );
3778 stage.hooks.on_stage_enter = Some("../../outside.txt".to_string());
3779 let blueprint = leviath_core::Blueprint::new(
3780 "evil".to_string(),
3781 "d".to_string(),
3782 vec![stage],
3783 leviath_core::layout::ContextLayout::new(vec![], 1000),
3784 );
3785
3786 let bp_path = bp_dir.join("agent.leviath");
3787 let err = resolve_stage_hook_scripts(&blueprint, bp_path.to_str().expect("utf8"))
3788 .expect_err("an escaping script path is refused");
3789 assert!(err.contains("outside the blueprint's directory"), "{err}");
3790 assert!(!err.contains("failed to compile"), "{err}");
3793 }
3794
3795 #[test]
3798 fn a_declared_but_ungranted_read_path_does_not_let_a_seed_file_out() {
3799 let (root, wd) = workdir_with_a_neighbour();
3800 let outside = root.path().to_string_lossy().to_string();
3801 let mut policy = no_read_paths();
3802 policy.blueprint = leviath_core::ReadPathSet::compile(
3803 std::slice::from_ref(&outside),
3804 std::path::Path::new(&wd),
3805 None,
3806 cfg!(windows),
3807 )
3808 .expect("declaration compiles");
3809 let bp = bp(
3812 r#"notes = { kind = "pinned", max_tokens = 2000, seed = { files = ["../config.toml"] } }"#,
3813 );
3814 let args = args_with("t", HashMap::new(), &wd);
3815 let err = resolve_seeds(&bp, &args, &wd, &seed_policy(), &policy).unwrap_err();
3816 assert!(err.contains("outside the working directory"), "{err}");
3817 }
3818
3819 #[test]
3820 fn a_rhai_seed_script_outside_the_workdir_is_refused() {
3821 let (_root, wd) = workdir_with_a_neighbour();
3822 let bp = bp(
3823 r#"notes = { kind = "pinned", max_tokens = 2000, seed = { rhai = "../config.toml" } }"#,
3824 );
3825 let args = args_with("t", HashMap::new(), &wd);
3826 let err = resolve_seeds(&bp, &args, &wd, &seed_policy(), &no_read_paths()).unwrap_err();
3827 assert!(err.contains("outside the working directory"), "{err}");
3828 }
3829
3830 #[test]
3831 fn a_custom_region_script_outside_the_blueprint_directory_is_refused() {
3832 let root = tempfile::tempdir().expect("tempdir");
3833 let bp_dir = root.path().join("agents").join("evil");
3834 std::fs::create_dir_all(&bp_dir).expect("dirs");
3835 std::fs::write(root.path().join("outside.txt"), "NOT RHAI").expect("write");
3836
3837 let blueprint =
3838 bp(r#"notes = { kind = "custom", script = "../../outside.txt", max_tokens = 2000 }"#);
3839 let bp_path = bp_dir.join("agent.leviath");
3840 let err = resolve_region_scripts(&blueprint, bp_path.to_str().expect("utf8"))
3841 .expect_err("an escaping script path is refused");
3842 assert!(err.contains("outside the blueprint's directory"), "{err}");
3843 }
3844
3845 #[test]
3846 fn an_output_validator_outside_the_blueprint_directory_is_refused() {
3847 let root = tempfile::tempdir().expect("tempdir");
3848 let bp_dir = root.path().join("agents").join("evil");
3849 std::fs::create_dir_all(&bp_dir).expect("dirs");
3850 std::fs::write(root.path().join("outside.txt"), "NOT RHAI").expect("write");
3851
3852 let mut blueprint = leviath_core::Blueprint::new(
3853 "evil".to_string(),
3854 "d".to_string(),
3855 vec![],
3856 leviath_core::layout::ContextLayout::new(vec![], 1000),
3857 );
3858 blueprint.output = Some(leviath_core::output::OutputSpec {
3859 validator: Some("../../outside.txt".to_string()),
3860 ..Default::default()
3861 });
3862 let bp_path = bp_dir.join("agent.leviath");
3863 let err = resolve_output_validators(&blueprint, bp_path.to_str().expect("utf8"))
3864 .expect_err("an escaping validator path is refused");
3865 assert!(err.contains("outside the blueprint's directory"), "{err}");
3866 }
3867
3868 #[test]
3870 fn a_hook_script_beside_the_blueprint_still_loads() {
3871 let root = tempfile::tempdir().expect("tempdir");
3872 let bp_dir = root.path().join("agents").join("good");
3873 std::fs::create_dir_all(&bp_dir).expect("dirs");
3874 std::fs::write(bp_dir.join("h.rhai"), "fn on_stage_enter(ctx) { () }").expect("write");
3875
3876 let mut stage = leviath_core::Stage::new(
3877 "main".to_string(),
3878 leviath_core::blueprint::ModelConfig::new("p".to_string(), "m".to_string()),
3879 );
3880 stage.hooks.on_stage_enter = Some("h.rhai".to_string());
3881 let blueprint = leviath_core::Blueprint::new(
3882 "good".to_string(),
3883 "d".to_string(),
3884 vec![stage],
3885 leviath_core::layout::ContextLayout::new(vec![], 1000),
3886 );
3887
3888 let bp_path = bp_dir.join("agent.leviath");
3889 let scripts = resolve_stage_hook_scripts(&blueprint, bp_path.to_str().expect("utf8"))
3890 .expect("a script beside the blueprint loads");
3891 assert!(scripts.contains_key("h.rhai"));
3892 }
3893
3894 fn bp_taking_no_task(regions_toml: &str) -> Blueprint {
3899 let toml = format!(
3900 r#"
3901[agent]
3902name = "seedy"
3903
3904[stages.main]
3905mode = "autonomous"
3906
3907[stages.main.model]
3908provider = "anthropic"
3909model = "claude-sonnet-5"
3910
3911[context.regions]
3912{regions_toml}
3913conversation = {{ kind = "sliding_window", max_items = 20, max_tokens = 10000 }}
3914"#
3915 );
3916 leviath_core::manifest::parse_manifest(&toml).unwrap()
3917 }
3918
3919 #[test]
3920 fn a_task_the_blueprint_cannot_hold_is_refused() {
3921 let bp = bp_taking_no_task(r#"notes = { kind = "pinned", max_tokens = 100 }"#);
3925 let args = args_with("do the thing", HashMap::new(), "/w");
3926 let err = resolve_seeds(&bp, &args, "/w", &seed_policy(), &no_read_paths())
3927 .expect_err("a task with nowhere to go should be refused");
3928 assert!(err.contains("declares no region to put it in"), "{err}");
3929 assert!(err.contains("takes no caller input at all"), "{err}");
3932 }
3933
3934 #[test]
3935 fn the_refusal_names_the_input_the_agent_does_take() {
3936 let bp =
3937 bp_taking_no_task(r#"diff = { kind = "pinned", max_tokens = 100, seed = "diff" }"#);
3938 let args = args_with("do the thing", HashMap::new(), "/w");
3939 let err =
3940 resolve_seeds(&bp, &args, "/w", &seed_policy(), &no_read_paths()).expect_err("refused");
3941 assert!(err.contains("it takes: diff"), "{err}");
3942 }
3943
3944 #[test]
3945 fn an_agent_driven_by_named_regions_still_spawns_with_no_task() {
3946 let bp =
3949 bp_taking_no_task(r#"diff = { kind = "pinned", max_tokens = 100, seed = "diff" }"#);
3950 let mut regions = HashMap::new();
3951 regions.insert("diff".to_string(), "a patch".to_string());
3952 let args = args_with("", regions, "/w");
3953 let seeds = resolve_seeds(&bp, &args, "/w", &seed_policy(), &no_read_paths())
3954 .expect("no task was supplied, so there is nothing to refuse");
3955 assert_eq!(seeds.get("diff").map(String::as_str), Some("a patch"));
3956 }
3957
3958 #[test]
3959 fn a_whitespace_only_task_is_not_treated_as_a_task() {
3960 let bp = bp_taking_no_task(r#"notes = { kind = "pinned", max_tokens = 100 }"#);
3961 let args = args_with(" \n ", HashMap::new(), "/w");
3962 resolve_seeds(&bp, &args, "/w", &seed_policy(), &no_read_paths())
3963 .expect("blank is the same as absent");
3964 }
3965
3966 #[test]
3973 fn every_bundled_agent_that_documents_a_task_accepts_one() {
3974 for agent in crate::bundled::BUNDLED_AGENTS {
3975 let name = agent.name;
3976 let (_, content) = agent
3982 .files
3983 .iter()
3984 .find(|(rel, _)| *rel == "agent.leviath")
3985 .expect("every bundled agent ships an agent.leviath");
3986 let bp = leviath_core::manifest::parse_manifest(content)
3987 .expect("every bundled agent's manifest parses");
3988 assert!(
3994 !content.contains("--task") || bp.accepts_task(),
3995 "{name} tells the user to pass --task but declares no region to hold one"
3996 );
3997 }
3998 }
3999}