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