1use std::collections::{HashMap, HashSet};
14use std::sync::{Arc, Mutex as StdMutex};
15
16use bevy_ecs::entity::Entity;
17use bevy_ecs::world::World;
18use leviath_core::blueprint::{Blueprint, ModelConfig};
19use leviath_providers::Tool;
20use leviath_runtime::ProviderRegistry;
21use leviath_runtime::host::{SpawnArgs, SubAgentOp};
22use leviath_runtime::interaction_hub::InteractionHub;
23use leviath_runtime::persistence::{RunMetadata, TokenTotals};
24use leviath_runtime::pipeline::{
25 CompactionSettings, PersistWatermark, Providers, ResolvedStage, spawn_agent_seeded,
26};
27use tokio::sync::Mutex;
28use tokio::sync::mpsc::UnboundedSender;
29
30use crate::config::Config;
31use crate::daemon::seed_command::SeedCommandPolicy;
32use crate::daemon::subagent::SubAgentHandle;
33use crate::daemon::tool_service::{AgentToolState, CliToolService};
34
35const DEFAULT_SUBAGENT_DEPTH: usize = 3;
37
38pub fn resolve_stage_model(
45 model_cfg: &ModelConfig,
46 model_override: Option<&str>,
47 config: &Config,
48 registry: &ProviderRegistry,
49) -> (String, String) {
50 let (override_provider, override_model) = match model_override {
51 Some(ov) if ov.contains('/') => {
52 let (p, m) = ov.split_once('/').unwrap();
53 (Some(p.to_string()), Some(m.to_string()))
54 }
55 Some(ov) => (None, Some(ov.to_string())),
56 None => (None, None),
57 };
58
59 if let Some(provider) = override_provider {
61 return (provider, override_model.unwrap_or_default());
62 }
63
64 for entry in &model_cfg.models {
66 if registry.has(&entry.provider) {
67 let model = override_model
68 .clone()
69 .unwrap_or_else(|| entry.model.clone());
70 return (entry.provider.clone(), model);
71 }
72 }
73
74 user_default_model(model_cfg, override_model.as_deref(), config, registry).unwrap_or_else(
76 || {
77 (
78 model_cfg.provider().to_string(),
79 model_cfg.model().to_string(),
80 )
81 },
82 )
83}
84
85fn user_default_model(
88 model_cfg: &ModelConfig,
89 override_model: Option<&str>,
90 config: &Config,
91 registry: &ProviderRegistry,
92) -> Option<(String, String)> {
93 if !model_cfg.allow_user_default {
94 return None;
95 }
96 if let Some(model) = override_model {
97 return Some((config.default_provider.clone(), model.to_string()));
98 }
99 if let Some(default_model) = &config.default_model
100 && registry.has(&config.default_provider)
101 {
102 return Some((config.default_provider.clone(), default_model.clone()));
103 }
104 None
105}
106
107fn resolve_stages(
109 blueprint: &Blueprint,
110 model_override: Option<&str>,
111 config: &Config,
112 registry: &ProviderRegistry,
113 all_tool_defs: &[Tool],
114) -> Vec<ResolvedStage> {
115 blueprint
116 .stages
117 .iter()
118 .map(|stage| {
119 let (provider_name, model) =
120 resolve_stage_model(&stage.model, model_override, config, registry);
121 let tools = filter_tools_by_available(all_tool_defs, &stage.available_tools);
125 ResolvedStage {
126 provider_name,
127 model,
128 tools,
129 }
130 })
131 .collect()
132}
133
134fn script_scan_dirs(
141 blueprint_path: &str,
142 extra: Option<std::path::PathBuf>,
143) -> Vec<std::path::PathBuf> {
144 std::path::Path::new(blueprint_path)
145 .parent()
146 .map(|d| d.join("tools"))
147 .into_iter()
148 .chain(extra)
149 .chain(leviath_core::tools_dir())
150 .collect()
151}
152
153pub(crate) fn resolve_region_scripts(
165 blueprint: &Blueprint,
166 blueprint_path: &str,
167) -> Result<HashMap<String, Arc<leviath_scripting::region_hook::RegionScript>>, String> {
168 let base = std::path::Path::new(blueprint_path)
169 .parent()
170 .map(std::path::Path::to_path_buf)
171 .unwrap_or_default();
172 let mut scripts = HashMap::new();
173
174 let layouts = std::iter::once(&blueprint.context_layout).chain(
175 blueprint
176 .stages
177 .iter()
178 .filter_map(|s| s.context_layout.as_ref()),
179 );
180 for layout in layouts {
181 for region in &layout.regions {
182 let leviath_core::RegionKind::Custom { script, .. } = ®ion.kind else {
183 continue;
184 };
185 if scripts.contains_key(script) {
186 continue;
187 }
188 let path = base.join(script);
189 let source = std::fs::read_to_string(&path).map_err(|e| {
190 format!(
191 "region '{}': cannot read custom region script '{}': {e}",
192 region.name,
193 path.display()
194 )
195 })?;
196 let compiled =
197 leviath_scripting::region_hook::compile(script, &source).map_err(|e| {
198 format!(
199 "region '{}': custom region script failed to compile: {e}",
200 region.name
201 )
202 })?;
203 scripts.insert(script.clone(), Arc::new(compiled));
204 }
205 }
206 Ok(scripts)
207}
208
209fn reserved_tool_names(builtin_names: &HashSet<String>, mcp_tool_defs: &[Tool]) -> HashSet<String> {
212 let mut reserved: HashSet<String> = builtin_names.clone();
213 reserved.extend(leviath_tools::BuiltinTools::subagent_tool_names());
214 reserved.extend(mcp_tool_defs.iter().map(|t| t.name.clone()));
215 reserved
216}
217
218fn script_cap(name: &str) -> Option<leviath_tools::ToolCapability> {
223 match name {
224 "network" | "net" | "http" => Some(leviath_tools::ToolCapability::Network),
225 "shell" | "process" | "process_spawn" => Some(leviath_tools::ToolCapability::ProcessSpawn),
226 "filesystem" | "file" | "fs" => Some(leviath_tools::ToolCapability::FileSystem),
227 _ => None,
228 }
229}
230
231fn platform_satisfies_caps(
234 platform: &leviath_tools::PlatformCapabilities,
235 required_caps: &[String],
236) -> bool {
237 required_caps
238 .iter()
239 .all(|c| script_cap(c).is_some_and(|cap| platform.supports(cap)))
240}
241
242pub(crate) fn current_platform_satisfies(required_caps: &[String]) -> bool {
248 platform_satisfies_caps(
249 &leviath_tools::PlatformCapabilities::current(),
250 required_caps,
251 )
252}
253
254pub(crate) fn discover_script_tools_in(
262 dirs: &[std::path::PathBuf],
263 reserved: &HashSet<String>,
264) -> (leviath_scripting::ScriptToolSet, HashSet<String>, Vec<Tool>) {
265 let (set, skipped) = leviath_scripting::ScriptToolSet::discover(dirs);
266 for s in &skipped {
267 let path = s.path.display().to_string();
271 tracing::warn!(tool = %path, reason = %s.reason, "skipping invalid script tool");
272 }
273 let platform = leviath_tools::PlatformCapabilities::current();
274 let mut names = HashSet::new();
275 let mut defs = Vec::new();
276 for meta in set.metas() {
277 if reserved.contains(&meta.name) {
278 tracing::warn!(tool = %meta.name, "script tool name collides with an existing tool - ignoring");
279 continue;
280 }
281 if !platform_satisfies_caps(&platform, &meta.required_caps) {
282 let caps = meta.required_caps.join(", ");
283 tracing::warn!(tool = %meta.name, requires = %caps, "script tool requires a capability this platform lacks - ignoring");
284 continue;
285 }
286 names.insert(meta.name.clone());
287 defs.push(Tool {
288 name: meta.name.clone(),
289 description: meta.description.clone(),
290 parameters: meta.parameters_schema(),
291 });
292 }
293 (set, names, defs)
294}
295
296pub fn filter_tools_by_available(all: &[Tool], available: &[String]) -> Vec<Tool> {
300 if available.is_empty() {
301 return Vec::new();
302 }
303 all.iter()
304 .filter(|t| {
305 available
306 .iter()
307 .any(|n| leviath_tools::canonical_tool_name(n) == t.name)
308 })
309 .cloned()
310 .collect()
311}
312
313fn discover_script_tools(
317 blueprint_path: &str,
318 builtin_names: &HashSet<String>,
319 mcp_tool_defs: &[Tool],
320 extra_dir: Option<std::path::PathBuf>,
321) -> (leviath_scripting::ScriptToolSet, HashSet<String>, Vec<Tool>) {
322 let dirs = script_scan_dirs(blueprint_path, extra_dir);
323 let reserved = reserved_tool_names(builtin_names, mcp_tool_defs);
324 discover_script_tools_in(&dirs, &reserved)
325}
326
327#[allow(clippy::too_many_arguments)]
333fn build_tool_state(
334 builtins: Arc<leviath_tools::BuiltinTools>,
335 builtin_names: HashSet<String>,
336 mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
337 config: &Config,
338 hub: &InteractionHub,
339 run_id: &str,
340 entry_stage: &str,
341 entry_index: usize,
342 stage_perms_by_index: Vec<HashMap<String, String>>,
343 agent_perms: HashMap<String, String>,
344 agent_name: &str,
345 launch_overrides: HashMap<String, crate::config::ToolPolicy>,
346 subagent: Option<SubAgentHandle>,
347 sandbox: Option<Arc<crate::daemon::sandbox_manager::SandboxManager>>,
348 script_tools: leviath_scripting::ScriptToolSet,
349 script_tool_names: HashSet<String>,
350 script_host: Arc<dyn leviath_scripting::ScriptHost>,
351 dynamic: Option<Arc<crate::daemon::tool_service::DynamicToolCtx>>,
352 unattended: bool,
353) -> Arc<AgentToolState> {
354 let entry_perms = stage_perms_by_index
355 .get(entry_index)
356 .cloned()
357 .unwrap_or_default();
358 Arc::new(AgentToolState {
359 builtins,
360 mcp,
361 builtin_names,
362 launch_overrides: Arc::new(launch_overrides),
363 session_allows: Arc::new(Mutex::new(HashSet::new())),
364 stage_perms: Arc::new(StdMutex::new(entry_perms)),
365 stage_perms_by_index: Arc::new(stage_perms_by_index),
366 agent_perms: Arc::new(agent_perms),
367 global_perms: Arc::new(config.permissions_for_agent(agent_name)),
372 interaction: hub.backend_for(run_id),
373 unattended,
374 stage_name: Arc::new(StdMutex::new(entry_stage.to_string())),
375 subagent,
376 sandbox,
377 script_tools: Arc::new(StdMutex::new(script_tools)),
378 script_tool_names: Arc::new(StdMutex::new(script_tool_names)),
379 script_host,
380 dynamic,
381 })
382}
383
384fn resolve_seeds(
401 blueprint: &Blueprint,
402 args: &SpawnArgs,
403 workdir: &str,
404 commands: &SeedCommandPolicy,
405) -> Result<HashMap<String, String>, String> {
406 use leviath_core::layout::RegionSeed;
407
408 let mut caller: HashMap<String, String> = HashMap::new();
410 caller.insert("task".to_string(), args.task.clone());
411 for (k, v) in &args.regions {
412 caller.insert(k.clone(), v.clone());
413 }
414
415 let base = std::path::Path::new(workdir);
420 let mut seeds: HashMap<String, String> = HashMap::new();
421
422 for region in &blueprint.context_layout.regions {
423 let Some(seed) = ®ion.seed else { continue };
424 match seed {
425 RegionSeed::CallerInput { name } => {
426 let value = caller.get(name).map(|s| s.as_str()).unwrap_or("");
427 if value.trim().is_empty() {
428 if region.required {
429 return Err(region.required_message.clone().unwrap_or_else(|| {
430 format!(
431 "required region '{}' was not provided; supply it via \
432 --{name} <text|@file> (CLI), a ---region:{name}--- block \
433 (ACP), or the API `regions` field",
434 region.name
435 )
436 }));
437 }
438 continue;
440 }
441 seeds.insert(region.name.clone(), value.to_string());
442 }
443 RegionSeed::Literal { text } => {
444 seeds.insert(region.name.clone(), text.clone());
445 }
446 RegionSeed::Files { paths } => {
447 let content = read_and_concat(
448 ®ion.name,
449 paths.iter().map(|p| base.join(p)),
450 region.required,
451 )?;
452 if let Some(content) = content {
453 seeds.insert(region.name.clone(), content);
454 }
455 }
456 RegionSeed::Glob { pattern } => {
457 let full = base.join(pattern);
458 let full = full.to_string_lossy();
459 let matches = glob::glob(&full)
460 .map_err(|e| format!("region '{}': bad glob '{pattern}': {e}", region.name))?;
461 let paths: Vec<std::path::PathBuf> = matches.filter_map(|m| m.ok()).collect();
462 let content = read_and_concat(®ion.name, paths.into_iter(), region.required)?;
463 match content {
464 Some(content) => {
465 seeds.insert(region.name.clone(), content);
466 }
467 None if region.required => {
468 return Err(format!(
469 "required region '{}': glob '{pattern}' matched no files",
470 region.name
471 ));
472 }
473 None => {}
474 }
475 }
476 RegionSeed::Rhai { script } => {
477 let path = base.join(script);
478 let src = std::fs::read_to_string(&path).map_err(|e| {
479 format!(
480 "region '{}': read rhai seed '{}': {e}",
481 region.name,
482 path.display()
483 )
484 })?;
485 let mut input = rhai::Map::new();
486 input.insert("task".into(), rhai::Dynamic::from(args.task.clone()));
487 input.insert("workdir".into(), rhai::Dynamic::from(workdir.to_string()));
488 let out = leviath_scripting::ScriptEngine::new()
489 .transform(&src, input)
490 .map_err(|e| format!("region '{}': rhai seed failed: {e}", region.name))?;
491 if !out.trim().is_empty() {
492 seeds.insert(region.name.clone(), out);
493 } else if region.required {
494 return Err(format!(
495 "required region '{}': rhai seed '{script}' returned empty",
496 region.name
497 ));
498 }
499 }
500 RegionSeed::Command { command } => {
506 if !commands.allowed {
507 if region.required {
508 return Err(format!(
509 "required region '{}': command seeds are disabled \
510 (`[security] allow_seed_commands = false` or --no-seed-commands)",
511 region.name
512 ));
513 }
514 tracing::warn!(
515 region = %region.name,
516 "command seed skipped: command seeds are disabled"
517 );
518 continue;
519 }
520 match commands.run(command, base) {
521 Ok(out) if !out.trim().is_empty() => {
522 seeds.insert(region.name.clone(), out);
523 }
524 Ok(_) => {
525 if region.required {
526 return Err(format!(
527 "required region '{}': command seed '{command}' returned empty",
528 region.name
529 ));
530 }
531 tracing::warn!(
532 region = %region.name,
533 command = %command,
534 "command seed returned no output; region left empty"
535 );
536 }
537 Err(e) => {
538 if region.required {
539 return Err(format!(
540 "required region '{}': command seed '{command}' failed: {e}",
541 region.name
542 ));
543 }
544 tracing::warn!(
545 region = %region.name,
546 command = %command,
547 error = %e,
548 "command seed failed; region left empty"
549 );
550 }
551 }
552 }
553 }
554 }
555
556 Ok(seeds)
557}
558
559fn read_and_concat(
563 region: &str,
564 paths: impl Iterator<Item = std::path::PathBuf>,
565 required: bool,
566) -> Result<Option<String>, String> {
567 let mut parts: Vec<String> = Vec::new();
568 for path in paths {
569 match std::fs::read_to_string(&path) {
570 Ok(text) => parts.push(format!("--- {} ---\n{}", path.display(), text)),
571 Err(e) => {
572 if required {
573 return Err(format!(
574 "region '{region}': read seed file '{}': {e}",
575 path.display()
576 ));
577 }
578 }
579 }
580 }
581 Ok((!parts.is_empty()).then(|| parts.join("\n\n")))
582}
583
584#[allow(clippy::too_many_arguments)]
594pub fn build_agent(
595 world: &mut World,
596 tool_service: &CliToolService,
597 config: &Config,
598 shared_mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
599 mcp_tool_defs: &[Tool],
600 hub: &InteractionHub,
601 args: &SpawnArgs,
602 now_secs: i64,
603 subagent_tx: UnboundedSender<SubAgentOp>,
604) -> Result<Entity, String> {
605 build_agent_inner(
606 world,
607 tool_service,
608 config,
609 shared_mcp,
610 mcp_tool_defs,
611 hub,
612 args,
613 now_secs,
614 subagent_tx,
615 true,
616 )
617}
618
619#[allow(clippy::too_many_arguments)]
623pub fn build_agent_for_reload(
624 world: &mut World,
625 tool_service: &CliToolService,
626 config: &Config,
627 shared_mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
628 mcp_tool_defs: &[Tool],
629 hub: &InteractionHub,
630 args: &SpawnArgs,
631 now_secs: i64,
632 subagent_tx: UnboundedSender<SubAgentOp>,
633) -> Result<Entity, String> {
634 build_agent_inner(
635 world,
636 tool_service,
637 config,
638 shared_mcp,
639 mcp_tool_defs,
640 hub,
641 args,
642 now_secs,
643 subagent_tx,
644 false,
645 )
646}
647
648#[allow(clippy::too_many_arguments)]
649fn build_agent_inner(
650 world: &mut World,
651 tool_service: &CliToolService,
652 config: &Config,
653 shared_mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
654 mcp_tool_defs: &[Tool],
655 hub: &InteractionHub,
656 args: &SpawnArgs,
657 now_secs: i64,
658 subagent_tx: UnboundedSender<SubAgentOp>,
659 enforce_seeds: bool,
660) -> Result<Entity, String> {
661 if !std::fs::metadata(&args.workdir).is_ok_and(|m| m.is_dir()) {
666 return Err(format!(
667 "workspace '{}' does not exist or is not a directory",
668 args.workdir
669 ));
670 }
671
672 let content = std::fs::read_to_string(&args.blueprint_path)
674 .map_err(|e| format!("read manifest '{}': {e}", args.blueprint_path))?;
675 let mut blueprint = leviath_core::manifest::parse_manifest(&content)
676 .map_err(|e| format!("parse manifest: {e}"))?;
677 blueprint
678 .validate()
679 .map_err(|e| format!("invalid blueprint: {e}"))?;
680 if let Some(md) = args.max_depth {
682 blueprint.max_child_depth = Some(md);
683 }
684 if let Some(default_max) = config.limits.default_max_iterations {
689 for stage in &mut blueprint.stages {
690 match stage.max_iterations {
697 None | Some(0) => stage.max_iterations = Some(default_max),
698 Some(_) => {}
699 }
700 }
701 }
702
703 let entry_stage = blueprint
710 .entry_stage
711 .clone()
712 .or_else(|| blueprint.stages.first().map(|s| s.name.clone()))
713 .unwrap_or_default();
714 let entry_index = blueprint
715 .stages
716 .iter()
717 .position(|s| s.name == entry_stage)
718 .unwrap_or(0);
719 let stage_sandbox_by_index: Vec<leviath_core::ToolSandboxConfig> = blueprint
720 .stages
721 .iter()
722 .map(|s| {
723 leviath_core::resolve_sandbox(
724 config.sandbox.as_ref(),
725 blueprint.sandbox.as_ref(),
726 s.sandbox.as_ref(),
727 )
728 })
729 .collect();
730 let sandbox = crate::daemon::sandbox_manager::SandboxManager::build(
731 &args.run_id,
732 stage_sandbox_by_index,
733 &args.workdir,
734 entry_index,
735 )?
736 .map(Arc::new);
737
738 let mut builtins = leviath_tools::BuiltinTools::new(leviath_tools::ToolContext::new(
741 std::path::PathBuf::from(&args.workdir),
742 ));
743 if let Some(mgr) = &sandbox {
744 builtins =
745 builtins.with_shell_executor(mgr.clone() as Arc<dyn leviath_tools::ShellExecutor>);
746 }
747 let builtins = Arc::new(builtins);
748 let builtin_names: HashSet<String> = builtins.names().into_iter().collect();
749 let mut all_tool_defs = builtins.tool_defs();
750 all_tool_defs.extend(leviath_tools::BuiltinTools::subagent_tool_defs());
751 all_tool_defs.extend(mcp_tool_defs.iter().cloned());
752 let static_tool_defs = all_tool_defs.clone();
756
757 let dynamic_tools = blueprint.dynamic_tools;
767 let workdir_tools_dir =
768 dynamic_tools.then(|| std::path::PathBuf::from(&args.workdir).join("tools"));
769 let (script_tools, script_tool_names, script_defs) = discover_script_tools(
770 &args.blueprint_path,
771 &builtin_names,
772 mcp_tool_defs,
773 workdir_tools_dir.clone(),
774 );
775 all_tool_defs.extend(script_defs);
776
777 let stages = {
779 let registry = &world
780 .get_resource::<Providers>()
781 .expect("Providers resource present in a PipelineWorld")
782 .0;
783 resolve_stages(
784 &blueprint,
785 args.model.as_deref(),
786 config,
787 registry,
788 &all_tool_defs,
789 )
790 };
791
792 let agent_name = blueprint.name.clone();
794 let num_stages = blueprint.stages.len();
795 let compaction = blueprint.compaction_config.clone();
796 let max_child_depth = blueprint.max_child_depth.unwrap_or(DEFAULT_SUBAGENT_DEPTH);
797 let security = leviath_core::taint::resolve_security(
806 config.taint_tracking,
807 blueprint.security.as_ref(),
808 None,
809 );
810 let mcp_overrides = world
814 .get_resource::<leviath_runtime::pipeline::PolicyGate>()
815 .map(|p| p.0.mcp_overrides.clone())
816 .unwrap_or_default();
817 let tool_sensitivities: Option<HashMap<String, leviath_core::TaintLevel>> =
818 security.taint_tracking.then(|| {
819 let mut gate = leviath_runtime::TaintGate::new(security.clone());
820 gate.apply_mcp_overrides(&mcp_overrides);
821 all_tool_defs
822 .iter()
823 .map(|t| {
824 (
825 t.name.clone(),
826 gate.tool_classification(&t.name).sensitivity,
827 )
828 })
829 .collect()
830 });
831 let stage_perms_by_index: Vec<HashMap<String, String>> = blueprint
834 .stages
835 .iter()
836 .map(|s| s.tool_permissions.clone())
837 .collect();
838 let agent_perms = blueprint.agent_tool_permissions();
843 let stage_available: Vec<Vec<String>> = blueprint
846 .stages
847 .iter()
848 .map(|s| s.available_tools.clone())
849 .collect();
850 let model_label = stages
851 .first()
852 .map(|s| format!("{}/{}", s.provider_name, s.model));
853
854 let seeds = if enforce_seeds {
863 let policy = SeedCommandPolicy::new(
864 config.security.allow_seed_commands && !args.no_seed_commands,
865 std::time::Duration::from_secs(config.limits.script_shell_timeout_secs),
866 sandbox.clone(),
867 );
868 resolve_seeds(&blueprint, args, &args.workdir, &policy)?
869 } else {
870 HashMap::new()
871 };
872
873 let region_scripts = resolve_region_scripts(&blueprint, &args.blueprint_path)?;
878
879 let entity = spawn_agent_seeded(
881 world,
882 args.run_id.clone(),
883 blueprint,
884 &seeds,
885 stages,
886 config.batch_tool_hint,
887 region_scripts,
888 )?;
889
890 let metadata = RunMetadata {
893 run_id: args.run_id.clone(),
894 agent_name: agent_name.clone(),
895 agent_path: args.blueprint_path.clone(),
896 task: args.task.clone(),
897 model: model_label,
898 workdir: args.workdir.clone(),
899 num_stages,
900 started_at: now_secs,
901 parent_run_id: args.parent_run_id.clone(),
902 metadata: args.metadata.clone(),
903 callback_url: args.callback_url.clone(),
904 callback_secret: args.callback_secret.clone(),
905 title: None,
906 };
907 {
908 let mut entity_mut = world.entity_mut(entity);
909 entity_mut.insert((
910 metadata,
911 TokenTotals::default(),
912 PersistWatermark::default(),
913 leviath_runtime::persistence::RunOutcomeFlags::default(),
916 ));
917 (config.title.enabled && !args.task.is_empty() && args.parent_run_id.is_none())
923 .then_some(leviath_runtime::title::PendingTitle)
924 .into_iter()
925 .for_each(|marker| {
926 entity_mut.insert(marker);
927 });
928 args.yolo
933 .then_some(leviath_runtime::components::InteractionAutoApprove)
934 .into_iter()
935 .for_each(|marker| {
936 entity_mut.insert(marker);
937 });
938 compaction.into_iter().for_each(|cc| {
941 entity_mut.insert(CompactionSettings(cc));
942 });
943 tool_sensitivities.into_iter().for_each(|sensitivities| {
947 let mut gate = leviath_runtime::TaintGate::new(security.clone());
948 gate.apply_mcp_overrides(&mcp_overrides);
949 entity_mut.insert((
950 gate,
951 leviath_runtime::pipeline::ToolSensitivities(sensitivities),
952 ));
953 if args.yolo {
957 entity_mut.insert(leviath_runtime::components::GateAutoApprove);
958 }
959 entity_mut
962 .get_mut::<leviath_runtime::components::ContextWindow>()
963 .into_iter()
964 .for_each(|mut window| window.enable_taint_tracking());
965 });
966 }
967
968 let mut launch_overrides: HashMap<String, crate::config::ToolPolicy> = HashMap::new();
972 if args.yolo {
973 launch_overrides.insert("*".to_string(), crate::config::ToolPolicy::Allow);
974 }
975 for tool in &args.allow {
976 launch_overrides.insert(tool.clone(), crate::config::ToolPolicy::Allow);
977 }
978 let subagent = SubAgentHandle {
979 sender: subagent_tx,
980 parent_run_id: args.run_id.clone(),
981 workdir: args.workdir.clone(),
982 max_depth: max_child_depth,
983 no_seed_commands: args.no_seed_commands,
984 };
985 let entry_stage_perms = stage_perms_by_index
989 .get(entry_index)
990 .cloned()
991 .unwrap_or_default();
992 let effective_script_perms = crate::daemon::script_host::effective_script_permissions(
995 &config.tool_script_permissions,
996 &content,
997 );
998 let agent_scoped_perms = config.permissions_for_agent(&agent_name);
1003 let script_allow = crate::daemon::script_host::resolve_script_permissions(
1004 &effective_script_perms,
1005 &|builtin| {
1006 crate::tools::resolve_policy(
1007 builtin,
1008 true,
1009 &launch_overrides,
1010 &entry_stage_perms,
1011 &agent_perms,
1012 &agent_scoped_perms,
1013 )
1014 },
1015 );
1016 let script_host: Arc<dyn leviath_scripting::ScriptHost> = Arc::new(
1017 crate::daemon::script_host::DaemonScriptHost::new(
1018 script_allow,
1019 std::path::PathBuf::from(&args.workdir),
1020 )
1021 .with_shell(
1025 sandbox.clone(),
1026 std::time::Duration::from_secs(config.limits.script_shell_timeout_secs),
1027 )
1028 .with_local_network(config.security.allow_local_network)
1032 .with_env_allowlist(config.security.allow_env_vars.clone()),
1035 );
1036 let dynamic = dynamic_tools.then(|| {
1039 world
1040 .entity_mut(entity)
1041 .insert(leviath_runtime::pipeline::DynamicTools);
1042 Arc::new(crate::daemon::tool_service::DynamicToolCtx {
1043 scan_dirs: script_scan_dirs(&args.blueprint_path, workdir_tools_dir),
1044 reserved_names: reserved_tool_names(&builtin_names, mcp_tool_defs),
1045 static_defs: static_tool_defs,
1046 stage_available,
1047 dirty: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1048 })
1049 });
1050 let state = build_tool_state(
1051 builtins,
1052 builtin_names,
1053 shared_mcp,
1054 config,
1055 hub,
1056 &args.run_id,
1057 &entry_stage,
1058 entry_index,
1059 stage_perms_by_index,
1060 agent_perms,
1061 &agent_name,
1062 launch_overrides,
1063 Some(subagent),
1064 sandbox,
1065 script_tools,
1066 script_tool_names,
1067 script_host,
1068 dynamic,
1069 args.yolo,
1070 );
1071 tool_service.register(entity, state);
1072
1073 Ok(entity)
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078 use super::*;
1079 use leviath_runtime::world::PipelineWorld;
1080
1081 fn sub_tx() -> UnboundedSender<SubAgentOp> {
1083 tokio::sync::mpsc::unbounded_channel().0
1084 }
1085
1086 #[test]
1087 fn discover_script_tools_registers_and_drops_collisions() {
1088 crate::test_support::with_tracing(|| {});
1089 let home = tempfile::tempdir().unwrap();
1092 temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1093 let agent_dir = tempfile::tempdir().unwrap();
1094 let tools = agent_dir.path().join("tools");
1095 std::fs::create_dir(&tools).unwrap();
1096 std::fs::write(tools.join("echo.rhai"), "// @tool echo\nparams.x").unwrap();
1097 std::fs::write(tools.join("read_file.rhai"), "// @tool read_file\n1").unwrap();
1099 std::fs::write(tools.join("mcp_tool.rhai"), "// @tool mcp_tool\n1").unwrap();
1102 std::fs::write(tools.join("bad.rhai"), "no tool directive\nlet").unwrap();
1104 std::fs::write(
1108 tools.join("needs_gpu.rhai"),
1109 "// @tool needs_gpu\n// @requires gpu\n1",
1110 )
1111 .unwrap();
1112 std::fs::write(
1114 tools.join("net_tool.rhai"),
1115 "// @tool net_tool\n// @requires network\n1",
1116 )
1117 .unwrap();
1118 let blueprint = agent_dir.path().join("agent.leviath");
1119
1120 let builtins: HashSet<String> = ["read_file".to_string()].into_iter().collect();
1121 let mcp = vec![leviath_providers::Tool {
1122 name: "mcp_tool".to_string(),
1123 description: String::new(),
1124 parameters: serde_json::json!({}),
1125 }];
1126 let (set, names, defs) =
1127 discover_script_tools(blueprint.to_str().unwrap(), &builtins, &mcp, None);
1128 assert!(set.contains("echo") && set.contains("read_file"));
1131 assert!(names.contains("echo"));
1132 assert!(!names.contains("read_file"));
1133 assert!(!names.contains("mcp_tool"));
1134 assert!(!names.contains("needs_gpu"), "unsatisfiable cap dropped");
1135 assert!(names.contains("net_tool"), "satisfiable cap kept");
1136 let mut def_names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
1137 def_names.sort_unstable();
1138 assert_eq!(def_names, vec!["echo", "net_tool"]);
1139 });
1140 }
1141
1142 #[test]
1143 fn script_cap_maps_known_and_unknown_names() {
1144 use leviath_tools::ToolCapability::*;
1145 assert_eq!(script_cap("network"), Some(Network));
1146 assert_eq!(script_cap("http"), Some(Network));
1147 assert_eq!(script_cap("shell"), Some(ProcessSpawn));
1148 assert_eq!(script_cap("process_spawn"), Some(ProcessSpawn));
1149 assert_eq!(script_cap("filesystem"), Some(FileSystem));
1150 assert_eq!(script_cap("fs"), Some(FileSystem));
1151 assert_eq!(script_cap("gpu"), None);
1152 }
1153
1154 #[test]
1155 fn platform_satisfies_caps_gates_on_support() {
1156 use leviath_tools::{PlatformCapabilities, ToolCapability};
1157 let mobile = PlatformCapabilities::mobile();
1159 assert!(platform_satisfies_caps(&mobile, &[]));
1160 assert!(platform_satisfies_caps(&mobile, &["network".to_string()]));
1162 assert!(!platform_satisfies_caps(&mobile, &["shell".to_string()]));
1163 let desktop = PlatformCapabilities::from_capabilities([
1165 ToolCapability::Network,
1166 ToolCapability::FileSystem,
1167 ToolCapability::ProcessSpawn,
1168 ]);
1169 assert!(!platform_satisfies_caps(&desktop, &["mystery".to_string()]));
1170 }
1171
1172 #[test]
1173 fn discover_script_tools_empty_when_no_tools_dir() {
1174 let home = tempfile::tempdir().unwrap();
1175 temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1176 let agent_dir = tempfile::tempdir().unwrap();
1177 let blueprint = agent_dir.path().join("agent.leviath");
1178 let (set, names, defs) =
1179 discover_script_tools(blueprint.to_str().unwrap(), &HashSet::new(), &[], None);
1180 assert!(set.is_empty() && names.is_empty() && defs.is_empty());
1181 });
1182 }
1183
1184 #[test]
1185 fn discover_script_tools_handles_pathless_blueprint() {
1186 let home = tempfile::tempdir().unwrap();
1189 temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1190 let (set, _n, _d) = discover_script_tools("", &HashSet::new(), &[], None);
1191 assert!(set.is_empty());
1192 });
1193 }
1194 use leviath_core::blueprint::ModelEntry;
1195
1196 fn model_cfg(models: Vec<(&str, &str)>) -> ModelConfig {
1197 ModelConfig {
1198 models: models
1199 .into_iter()
1200 .map(|(p, m)| ModelEntry {
1201 provider: p.to_string(),
1202 model: m.to_string(),
1203 })
1204 .collect(),
1205 allow_user_default: true,
1206 parameters: HashMap::new(),
1207 request_timeout_secs: None,
1208 }
1209 }
1210
1211 fn registry_with(providers: &[&str]) -> ProviderRegistry {
1212 let mut r = ProviderRegistry::new();
1213 for p in providers {
1214 r.register(p.to_string(), Arc::new(FakeProvider));
1215 }
1216 r
1217 }
1218
1219 struct FakeProvider;
1220 #[async_trait::async_trait]
1221 impl leviath_providers::Provider for FakeProvider {
1222 async fn infer(
1223 &self,
1224 _r: leviath_providers::InferenceRequest,
1225 ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
1226 Err(leviath_providers::ProviderError::Other(
1227 "test provider".to_string(),
1228 ))
1229 }
1230 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
1231 1
1232 }
1233 fn max_context_tokens(&self, _m: &str) -> usize {
1234 1000
1235 }
1236 fn name(&self) -> &str {
1237 "fake"
1238 }
1239 fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
1240 leviath_providers::ModelCapabilities::default()
1241 }
1242 }
1243
1244 #[test]
1245 fn resolve_full_override_wins() {
1246 let (p, m) = resolve_stage_model(
1247 &model_cfg(vec![("anthropic", "x")]),
1248 Some("openai/gpt-5"),
1249 &Config::default(),
1250 ®istry_with(&[]),
1251 );
1252 assert_eq!((p.as_str(), m.as_str()), ("openai", "gpt-5"));
1253 }
1254
1255 #[test]
1256 fn resolve_first_available_model() {
1257 let (p, m) = resolve_stage_model(
1259 &model_cfg(vec![("anthropic", "a"), ("openai", "o")]),
1260 None,
1261 &Config::default(),
1262 ®istry_with(&["openai"]),
1263 );
1264 assert_eq!((p.as_str(), m.as_str()), ("openai", "o"));
1265 }
1266
1267 #[test]
1268 fn resolve_model_only_override_keeps_available_provider() {
1269 let (p, m) = resolve_stage_model(
1270 &model_cfg(vec![("openai", "o")]),
1271 Some("gpt-override"),
1272 &Config::default(),
1273 ®istry_with(&["openai"]),
1274 );
1275 assert_eq!((p.as_str(), m.as_str()), ("openai", "gpt-override"));
1276 }
1277
1278 #[test]
1279 fn resolve_user_default_when_nothing_listed_available() {
1280 let config = Config {
1282 default_provider: "anthropic".to_string(),
1283 default_model: Some("claude-default".to_string()),
1284 ..Default::default()
1285 };
1286 let (p, m) = resolve_stage_model(
1287 &model_cfg(vec![("ghost", "g")]),
1288 None,
1289 &config,
1290 ®istry_with(&["anthropic"]),
1291 );
1292 assert_eq!((p.as_str(), m.as_str()), ("anthropic", "claude-default"));
1293 }
1294
1295 #[test]
1296 fn resolve_user_default_with_model_override() {
1297 let config = Config {
1298 default_provider: "anthropic".to_string(),
1299 ..Default::default()
1300 };
1301 let (p, m) = resolve_stage_model(
1302 &model_cfg(vec![("ghost", "g")]),
1303 Some("just-a-model"),
1304 &config,
1305 ®istry_with(&[]),
1306 );
1307 assert_eq!((p.as_str(), m.as_str()), ("anthropic", "just-a-model"));
1308 }
1309
1310 #[test]
1311 fn resolve_user_default_provider_unavailable_falls_through() {
1312 let config = Config {
1315 default_provider: "ghost-default".to_string(),
1316 default_model: Some("dm".to_string()),
1317 ..Default::default()
1318 };
1319 let (p, m) = resolve_stage_model(
1320 &model_cfg(vec![("ghost", "g")]),
1321 None,
1322 &config,
1323 ®istry_with(&[]),
1324 );
1325 assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
1326 }
1327
1328 #[test]
1329 fn resolve_last_resort_first_listed() {
1330 let config = Config::default(); let (p, m) = resolve_stage_model(
1333 &model_cfg(vec![("ghost", "g")]),
1334 None,
1335 &config,
1336 ®istry_with(&[]),
1337 );
1338 assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
1339 }
1340
1341 #[test]
1342 fn resolve_no_user_default_uses_last_resort() {
1343 let mut cfg = model_cfg(vec![("ghost", "g")]);
1344 cfg.allow_user_default = false; let config = Config {
1346 default_model: Some("would-be-default".to_string()),
1347 ..Default::default()
1348 };
1349 let (p, m) = resolve_stage_model(&cfg, None, &config, ®istry_with(&["anthropic"]));
1350 assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
1351 }
1352
1353 use leviath_providers::Provider;
1356 use leviath_runtime::components::AgentStatus;
1357 use leviath_runtime::inference_pool::InferencePoolConfig;
1358 use tokio::runtime::Handle;
1359
1360 fn coder_manifest() -> String {
1361 crate::test_support::inline_coder_manifest()
1364 }
1365
1366 fn test_world() -> (PipelineWorld, Arc<CliToolService>) {
1367 let cli = Arc::new(CliToolService::new());
1368 let world = PipelineWorld::new(
1369 registry_with(&["anthropic", "openai", "ollama"]),
1370 cli.clone(),
1371 InferencePoolConfig::new(),
1372 1,
1373 std::env::temp_dir(),
1374 Handle::current(),
1375 );
1376 (world, cli)
1377 }
1378
1379 fn spawn_args(path: &str) -> SpawnArgs {
1380 SpawnArgs {
1381 run_id: "run-x".to_string(),
1382 blueprint_path: path.to_string(),
1383 task: "do the thing".to_string(),
1384 regions: HashMap::new(),
1385 model: None,
1386 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1387 metadata: HashMap::new(),
1388 callback_url: None,
1389 callback_secret: None,
1390 yolo: false,
1391 no_seed_commands: false,
1392 allow: Vec::new(),
1393 max_depth: None,
1394 parent_run_id: None,
1395 }
1396 }
1397
1398 fn custom_region_manifest() -> &'static str {
1403 "[agent]\nname = \"cr\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1404 [context.regions.brain]\nkind = \"custom\"\nscript = \"hooks/brain.rhai\"\nmax_tokens = 4000\n\n\
1405 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\n\
1406 [stages.main.context.regions.stage_view]\nkind = \"custom\"\nscript = \"hooks/stage.rhai\"\nmax_tokens = 2000\n"
1407 }
1408
1409 #[test]
1410 fn resolve_region_scripts_empty_without_custom_regions() {
1411 let dir = tempfile::tempdir().unwrap();
1412 let manifest = dir.path().join("agent.leviath");
1413 let bp = leviath_core::manifest::parse_manifest(
1414 "[agent]\nname = \"plain\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1415 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1416 )
1417 .unwrap();
1418 let scripts = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1419 assert!(scripts.is_empty());
1420 }
1421
1422 #[test]
1423 fn resolve_region_scripts_collects_global_and_per_stage_layouts() {
1424 let dir = tempfile::tempdir().unwrap();
1425 let manifest = dir.path().join("agent.leviath");
1426 std::fs::create_dir(dir.path().join("hooks")).unwrap();
1427 std::fs::write(
1428 dir.path().join("hooks/brain.rhai"),
1429 "fn render(ctx) { \"b\" }",
1430 )
1431 .unwrap();
1432 std::fs::write(
1433 dir.path().join("hooks/stage.rhai"),
1434 "fn render(ctx) { \"s\" }",
1435 )
1436 .unwrap();
1437 let bp = leviath_core::manifest::parse_manifest(custom_region_manifest()).unwrap();
1438 let scripts = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1439 assert_eq!(scripts.len(), 2);
1440 assert!(scripts.contains_key("hooks/brain.rhai"));
1441 assert!(scripts.contains_key("hooks/stage.rhai"));
1442 }
1443
1444 #[test]
1445 fn resolve_region_scripts_reads_a_shared_path_once() {
1446 let dir = tempfile::tempdir().unwrap();
1448 let manifest = dir.path().join("agent.leviath");
1449 std::fs::create_dir(dir.path().join("hooks")).unwrap();
1450 std::fs::write(
1451 dir.path().join("hooks/shared.rhai"),
1452 "fn render(ctx) { \"x\" }",
1453 )
1454 .unwrap();
1455 let bp = leviath_core::manifest::parse_manifest(
1456 "[agent]\nname = \"cr\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1457 [context.regions.a]\nkind = \"custom\"\nscript = \"hooks/shared.rhai\"\nmax_tokens = 2000\n\n\
1458 [context.regions.b]\nkind = \"custom\"\nscript = \"hooks/shared.rhai\"\nmax_tokens = 2000\n\n\
1459 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1460 )
1461 .unwrap();
1462 let scripts = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1463 assert_eq!(scripts.len(), 1);
1464 }
1465
1466 #[test]
1467 fn resolve_region_scripts_missing_file_is_a_hard_error() {
1468 let dir = tempfile::tempdir().unwrap();
1469 let manifest = dir.path().join("agent.leviath");
1470 let bp = leviath_core::manifest::parse_manifest(custom_region_manifest()).unwrap();
1471 let err = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap_err();
1472 assert!(err.contains("region 'brain'"), "{err}");
1473 assert!(err.contains("hooks/brain.rhai"), "{err}");
1474 }
1475
1476 #[test]
1477 fn resolve_region_scripts_uncompilable_script_is_a_hard_error() {
1478 let dir = tempfile::tempdir().unwrap();
1479 let manifest = dir.path().join("agent.leviath");
1480 std::fs::create_dir(dir.path().join("hooks")).unwrap();
1481 std::fs::write(dir.path().join("hooks/brain.rhai"), "fn render(ctx) {").unwrap();
1482 std::fs::write(
1483 dir.path().join("hooks/stage.rhai"),
1484 "fn render(ctx) { \"s\" }",
1485 )
1486 .unwrap();
1487 let bp = leviath_core::manifest::parse_manifest(custom_region_manifest()).unwrap();
1488 let err = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap_err();
1489 assert!(err.contains("failed to compile"), "{err}");
1490 assert!(err.contains("region 'brain'"), "{err}");
1491 }
1492
1493 #[tokio::test]
1494 async fn build_agent_fails_fast_on_a_broken_custom_region_script() {
1495 let dir = tempfile::tempdir().unwrap();
1499 let manifest = dir.path().join("agent.leviath");
1500 std::fs::write(&manifest, custom_region_manifest()).unwrap();
1501
1502 let (mut world, cli) = test_world();
1503 let hub = InteractionHub::new();
1504 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1505 let args = spawn_args(&manifest.to_string_lossy());
1506 let err = build_agent(
1507 world.world_mut(),
1508 cli.as_ref(),
1509 &Config::default(),
1510 mcp,
1511 &[],
1512 &hub,
1513 &args,
1514 100,
1515 sub_tx(),
1516 )
1517 .unwrap_err();
1518 assert!(err.contains("region 'brain'"), "got: {err}");
1519 assert!(err.contains("hooks/brain.rhai"), "got: {err}");
1520 }
1521
1522 #[tokio::test]
1523 async fn build_agent_rejects_a_workdir_that_is_missing_or_not_a_directory() {
1524 let dir = tempfile::tempdir().unwrap();
1528 let manifest = dir.path().join("agent.leviath");
1529 std::fs::write(
1530 &manifest,
1531 "[agent]\nname = \"w\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1532 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1533 )
1534 .unwrap();
1535 let not_a_dir = dir.path().join("a-file");
1536 std::fs::write(¬_a_dir, "x").unwrap();
1537
1538 for workdir in [
1539 dir.path()
1540 .join("does-not-exist")
1541 .to_string_lossy()
1542 .to_string(),
1543 not_a_dir.to_string_lossy().to_string(),
1544 ] {
1545 let (mut world, cli) = test_world();
1546 let hub = InteractionHub::new();
1547 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1548 let mut args = spawn_args(&manifest.to_string_lossy());
1549 args.workdir = workdir.clone();
1550 let err = build_agent(
1551 world.world_mut(),
1552 cli.as_ref(),
1553 &Config::default(),
1554 mcp,
1555 &[],
1556 &hub,
1557 &args,
1558 100,
1559 sub_tx(),
1560 )
1561 .unwrap_err();
1562 assert!(err.contains("workspace"), "got: {err}");
1563 assert!(err.contains(&workdir), "got: {err}");
1564 }
1565 }
1566
1567 #[tokio::test]
1568 async fn build_agent_attaches_taint_gate_when_security_enabled() {
1569 let dir = tempfile::tempdir().unwrap();
1570 let manifest = dir.path().join("agent.leviath");
1571 std::fs::write(
1572 &manifest,
1573 "[agent]\nname = \"sec\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1574 [security]\ntaint_tracking = true\n\n\
1575 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1576 )
1577 .unwrap();
1578 let (mut world, cli) = test_world();
1579 let hub = InteractionHub::new();
1580 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1581 let entity = build_agent(
1582 world.world_mut(),
1583 cli.as_ref(),
1584 &Config::default(),
1585 mcp,
1586 &[],
1587 &hub,
1588 &spawn_args(&manifest.to_string_lossy()),
1589 100,
1590 sub_tx(),
1591 )
1592 .expect("spawn succeeds");
1593
1594 assert!(
1596 world
1597 .world()
1598 .get::<leviath_runtime::TaintGate>(entity)
1599 .is_some()
1600 );
1601 assert!(
1602 world
1603 .world()
1604 .get::<leviath_runtime::pipeline::ToolSensitivities>(entity)
1605 .is_some()
1606 );
1607 assert!(
1608 world
1609 .world()
1610 .get::<leviath_runtime::components::ContextWindow>(entity)
1611 .unwrap()
1612 .overall_taint()
1613 .is_some()
1614 );
1615 assert!(
1617 world
1618 .world()
1619 .get::<leviath_runtime::components::GateAutoApprove>(entity)
1620 .is_none()
1621 );
1622 }
1623
1624 #[tokio::test]
1625 async fn build_agent_marks_root_runs_for_titling_but_not_subagents() {
1626 let dir = tempfile::tempdir().unwrap();
1627 let manifest = dir.path().join("agent.leviath");
1628 std::fs::write(
1629 &manifest,
1630 "[agent]\nname = \"titler\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1631 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1632 )
1633 .unwrap();
1634 let (mut world, cli) = test_world();
1635 let hub = InteractionHub::new();
1636
1637 let root = build_agent(
1639 world.world_mut(),
1640 cli.as_ref(),
1641 &Config::default(),
1642 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1643 &[],
1644 &hub,
1645 &spawn_args(&manifest.to_string_lossy()),
1646 100,
1647 sub_tx(),
1648 )
1649 .expect("spawn succeeds");
1650 assert!(
1651 world
1652 .world()
1653 .get::<leviath_runtime::title::PendingTitle>(root)
1654 .is_some()
1655 );
1656
1657 let mut child_args = spawn_args(&manifest.to_string_lossy());
1659 child_args.run_id = "run-child".to_string();
1660 child_args.parent_run_id = Some("run-x".to_string());
1661 let child = build_agent(
1662 world.world_mut(),
1663 cli.as_ref(),
1664 &Config::default(),
1665 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1666 &[],
1667 &hub,
1668 &child_args,
1669 100,
1670 sub_tx(),
1671 )
1672 .expect("spawn succeeds");
1673 assert!(
1674 world
1675 .world()
1676 .get::<leviath_runtime::title::PendingTitle>(child)
1677 .is_none()
1678 );
1679
1680 let config = Config {
1682 title: leviath_core::config::TitleConfig {
1683 enabled: false,
1684 provider: None,
1685 model: None,
1686 },
1687 ..Config::default()
1688 };
1689 let mut off_args = spawn_args(&manifest.to_string_lossy());
1690 off_args.run_id = "run-off".to_string();
1691 let off = build_agent(
1692 world.world_mut(),
1693 cli.as_ref(),
1694 &config,
1695 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1696 &[],
1697 &hub,
1698 &off_args,
1699 100,
1700 sub_tx(),
1701 )
1702 .expect("spawn succeeds");
1703 assert!(
1704 world
1705 .world()
1706 .get::<leviath_runtime::title::PendingTitle>(off)
1707 .is_none()
1708 );
1709 }
1710
1711 #[tokio::test]
1712 async fn build_agent_applies_policy_mcp_overrides_to_the_gate() {
1713 let dir = tempfile::tempdir().unwrap();
1714 let manifest = dir.path().join("agent.leviath");
1715 std::fs::write(
1716 &manifest,
1717 "[agent]\nname = \"sec-ov\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1718 [security]\ntaint_tracking = true\n\n\
1719 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1720 )
1721 .unwrap();
1722 let (mut world, cli) = test_world();
1723 world
1727 .world_mut()
1728 .insert_resource(leviath_runtime::pipeline::PolicyGate(
1729 leviath_core::PolicyConfig {
1730 allowlist: Vec::new(),
1731 mcp_overrides: HashMap::from([(
1732 "notes.share".to_string(),
1733 leviath_core::policy::McpToolOverride {
1734 sensitivity: None,
1735 direction: Some("outbound".to_string()),
1736 clearance: Some(leviath_core::TaintLevel::Private),
1737 },
1738 )]),
1739 },
1740 ));
1741 let hub = InteractionHub::new();
1742 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1743 let entity = build_agent(
1744 world.world_mut(),
1745 cli.as_ref(),
1746 &Config::default(),
1747 mcp,
1748 &[],
1749 &hub,
1750 &spawn_args(&manifest.to_string_lossy()),
1751 100,
1752 sub_tx(),
1753 )
1754 .expect("spawn succeeds");
1755
1756 let gate = world
1757 .world()
1758 .get::<leviath_runtime::TaintGate>(entity)
1759 .expect("gate attached");
1760 let classification = gate.tool_classification("notes.share");
1761 assert_eq!(
1762 classification.direction,
1763 leviath_core::taint::ToolDirection::Outbound
1764 );
1765 assert_eq!(classification.clearance, leviath_core::TaintLevel::Private);
1766 }
1767
1768 #[tokio::test]
1769 async fn build_agent_errors_when_required_caller_region_missing() {
1770 let dir = tempfile::tempdir().unwrap();
1773 let manifest = dir.path().join("agent.leviath");
1774 std::fs::write(
1775 &manifest,
1776 "[agent]\nname = \"needs\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1777 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\n\
1778 [context.regions]\n\
1779 spec = { kind = \"pinned\", max_tokens = 2000, seed = \"input\", required = true }\n\
1780 conversation = { kind = \"sliding_window\", max_items = 20, max_tokens = 10000 }\n",
1781 )
1782 .unwrap();
1783 let (mut world, cli) = test_world();
1784 let hub = InteractionHub::new();
1785 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1786 let err = build_agent(
1788 world.world_mut(),
1789 cli.as_ref(),
1790 &Config::default(),
1791 mcp,
1792 &[],
1793 &hub,
1794 &spawn_args(&manifest.to_string_lossy()),
1795 100,
1796 sub_tx(),
1797 )
1798 .unwrap_err();
1799 assert!(err.contains("spec"), "got: {err}");
1800 }
1801
1802 #[tokio::test]
1803 async fn build_agent_attaches_sandbox_when_configured() {
1804 let dir = tempfile::tempdir().unwrap();
1808 let manifest = dir.path().join("agent.leviath");
1809 std::fs::write(
1810 &manifest,
1811 "[agent]\nname = \"sb\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1812 [sandbox]\nkind = \"namespace\"\non_unavailable = \"warn\"\n\n\
1813 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1814 )
1815 .unwrap();
1816 let (mut world, cli) = test_world();
1817 let hub = InteractionHub::new();
1818 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1819 let entity = build_agent(
1820 world.world_mut(),
1821 cli.as_ref(),
1822 &Config::default(),
1823 mcp,
1824 &[],
1825 &hub,
1826 &spawn_args(&manifest.to_string_lossy()),
1827 100,
1828 sub_tx(),
1829 )
1830 .expect("spawn succeeds");
1831 let state = cli.take(entity).expect("state registered");
1833 assert!(state.sandbox.is_some(), "sandbox manager attached");
1834 }
1835
1836 #[tokio::test]
1837 async fn build_agent_errors_when_sandbox_runtime_unavailable() {
1838 let dir = tempfile::tempdir().unwrap();
1843 let manifest = dir.path().join("agent.leviath");
1844 std::fs::write(
1845 &manifest,
1846 "[agent]\nname = \"sb\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1847 [sandbox]\nkind = \"container\"\nimage = \"x\"\nengine = \"leviath-no-such-engine\"\n\n\
1848 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1849 )
1850 .unwrap();
1851 let (mut world, cli) = test_world();
1852 let hub = InteractionHub::new();
1853 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1854 let err = build_agent(
1855 world.world_mut(),
1856 cli.as_ref(),
1857 &Config::default(),
1858 mcp,
1859 &[],
1860 &hub,
1861 &spawn_args(&manifest.to_string_lossy()),
1862 100,
1863 sub_tx(),
1864 )
1865 .expect_err("a nonexistent engine can't start the container");
1866 assert!(err.contains("sandbox unavailable"), "got: {err}");
1867 }
1868
1869 #[tokio::test]
1870 async fn build_agent_yolo_attaches_gate_auto_approve_when_taint_on() {
1871 let dir = tempfile::tempdir().unwrap();
1872 let manifest = dir.path().join("agent.leviath");
1873 std::fs::write(
1874 &manifest,
1875 "[agent]\nname = \"sec\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1876 [security]\ntaint_tracking = true\n\n\
1877 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1878 )
1879 .unwrap();
1880 let (mut world, cli) = test_world();
1881 let hub = InteractionHub::new();
1882 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1883 let mut args = spawn_args(&manifest.to_string_lossy());
1884 args.yolo = true;
1885 let entity = build_agent(
1886 world.world_mut(),
1887 cli.as_ref(),
1888 &Config::default(),
1889 mcp,
1890 &[],
1891 &hub,
1892 &args,
1893 100,
1894 sub_tx(),
1895 )
1896 .expect("spawn succeeds");
1897 assert!(
1900 world
1901 .world()
1902 .get::<leviath_runtime::components::GateAutoApprove>(entity)
1903 .is_some()
1904 );
1905 assert!(
1908 world
1909 .world()
1910 .get::<leviath_runtime::components::InteractionAutoApprove>(entity)
1911 .is_some()
1912 );
1913 assert!(cli.take(entity).expect("tool state registered").unattended);
1914 }
1915
1916 #[tokio::test]
1917 async fn build_agent_without_yolo_keeps_prompts_interactive() {
1918 let dir = tempfile::tempdir().unwrap();
1919 let manifest = dir.path().join("agent.leviath");
1920 std::fs::write(
1921 &manifest,
1922 "[agent]\nname = \"plain\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1923 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1924 )
1925 .unwrap();
1926 let (mut world, cli) = test_world();
1927 let hub = InteractionHub::new();
1928 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1929 let entity = build_agent(
1930 world.world_mut(),
1931 cli.as_ref(),
1932 &Config::default(),
1933 mcp,
1934 &[],
1935 &hub,
1936 &spawn_args(&manifest.to_string_lossy()),
1937 100,
1938 sub_tx(),
1939 )
1940 .expect("spawn succeeds");
1941 assert!(
1942 world
1943 .world()
1944 .get::<leviath_runtime::components::InteractionAutoApprove>(entity)
1945 .is_none()
1946 );
1947 assert!(!cli.take(entity).expect("tool state registered").unattended);
1948 }
1949
1950 #[tokio::test]
1951 async fn build_agent_no_security_block_leaves_taint_off_by_default() {
1952 let dir = tempfile::tempdir().unwrap();
1957 let manifest = dir.path().join("agent.leviath");
1958 std::fs::write(
1959 &manifest,
1960 "[agent]\nname = \"plain\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1961 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1962 )
1963 .unwrap();
1964 let (mut world, cli) = test_world();
1965 let hub = InteractionHub::new();
1966 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1967 let entity = build_agent(
1968 world.world_mut(),
1969 cli.as_ref(),
1970 &Config::default(), mcp,
1972 &[],
1973 &hub,
1974 &spawn_args(&manifest.to_string_lossy()),
1975 100,
1976 sub_tx(),
1977 )
1978 .expect("spawn succeeds");
1979 assert!(
1980 world
1981 .world()
1982 .get::<leviath_runtime::TaintGate>(entity)
1983 .is_none(),
1984 "no [security] block + global off ⇒ no taint gate"
1985 );
1986 }
1987
1988 #[tokio::test]
1989 async fn build_agent_spawns_registers_and_wires_tools() {
1990 let dir = tempfile::tempdir().unwrap();
1991 let manifest = dir.path().join("agent.leviath");
1992 std::fs::write(&manifest, coder_manifest()).unwrap();
1993
1994 let (mut world, cli) = test_world();
1995 let hub = InteractionHub::new();
1996 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1997 let entity = build_agent(
1998 world.world_mut(),
1999 cli.as_ref(),
2000 &Config::default(),
2001 mcp,
2002 &[],
2003 &hub,
2004 &spawn_args(&manifest.to_string_lossy()),
2005 100,
2006 sub_tx(),
2007 )
2008 .expect("spawn succeeds");
2009
2010 assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
2011 let md = world
2013 .world()
2014 .get::<RunMetadata>(entity)
2015 .expect("run metadata");
2016 assert_eq!(md.run_id, "run-x");
2017 assert_eq!(md.agent_name, "coder");
2018 let out = leviath_runtime::pipeline::ToolService::exec_for(
2020 cli.as_ref(),
2021 entity,
2022 vec![leviath_providers::ToolCall {
2023 id: "c1".to_string(),
2024 name: "list_dir".to_string(),
2025 arguments: serde_json::json!({"path": "."}),
2026 thought_signature: None,
2027 }],
2028 )()
2029 .await;
2030 assert_eq!(out[0].0, "c1");
2031 assert!(!out[0].1.contains("no tool state"));
2032 }
2033
2034 #[tokio::test]
2035 async fn build_agent_tags_dynamic_tools_agent() {
2036 let dir = tempfile::tempdir().unwrap();
2040 let manifest = dir.path().join("agent.leviath");
2041 std::fs::write(
2042 &manifest,
2043 coder_manifest().replace("[agent]", "[agent]\ndynamic_tools = true"),
2044 )
2045 .unwrap();
2046
2047 let (mut world, cli) = test_world();
2048 let hub = InteractionHub::new();
2049 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2050 let entity = build_agent(
2051 world.world_mut(),
2052 cli.as_ref(),
2053 &Config::default(),
2054 mcp,
2055 &[],
2056 &hub,
2057 &spawn_args(&manifest.to_string_lossy()),
2058 100,
2059 sub_tx(),
2060 )
2061 .expect("spawn succeeds");
2062
2063 assert!(
2064 world
2065 .world()
2066 .get::<leviath_runtime::pipeline::DynamicTools>(entity)
2067 .is_some(),
2068 "dynamic_tools agent must carry the DynamicTools marker"
2069 );
2070 assert!(
2072 leviath_runtime::pipeline::ToolService::refresh_tools(cli.as_ref(), entity, 0)
2073 .is_some()
2074 );
2075 }
2076
2077 #[tokio::test]
2078 async fn build_agent_applies_yolo_allow_and_max_depth() {
2079 let dir = tempfile::tempdir().unwrap();
2080 let manifest = dir.path().join("agent.leviath");
2081 std::fs::write(&manifest, coder_manifest()).unwrap();
2082
2083 let (mut world, cli) = test_world();
2084 let hub = InteractionHub::new();
2085 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2086 let config = Config {
2090 tool_permissions: HashMap::from([(
2091 "read_file".to_string(),
2092 crate::config::ToolPolicy::Deny,
2093 )]),
2094 ..Default::default()
2095 };
2096 let mut args = spawn_args(&manifest.to_string_lossy());
2097 args.yolo = true;
2098 args.allow = vec!["read_file".to_string()];
2099 args.max_depth = Some(7);
2100
2101 let entity = build_agent(
2102 world.world_mut(),
2103 cli.as_ref(),
2104 &config,
2105 mcp,
2106 &[],
2107 &hub,
2108 &args,
2109 100,
2110 sub_tx(),
2111 )
2112 .expect("spawn succeeds");
2113 assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
2114
2115 let out = leviath_runtime::pipeline::ToolService::exec_for(
2117 cli.as_ref(),
2118 entity,
2119 vec![leviath_providers::ToolCall {
2120 id: "c1".to_string(),
2121 name: "read_file".to_string(),
2122 arguments: serde_json::json!({"path": "/no/such/file"}),
2123 thought_signature: None,
2124 }],
2125 )()
2126 .await;
2127 let result = out[0].1.clone();
2128 assert!(
2129 result.contains("[denied]"),
2130 "a configured deny must survive --yolo, got: {result}"
2131 );
2132
2133 let out = leviath_runtime::pipeline::ToolService::exec_for(
2136 cli.as_ref(),
2137 entity,
2138 vec![leviath_providers::ToolCall {
2139 id: "c2".to_string(),
2140 name: "list_dir".to_string(),
2141 arguments: serde_json::json!({"path": "."}),
2142 thought_signature: None,
2143 }],
2144 )()
2145 .await;
2146 let result = out[0].1.clone();
2147 assert!(
2148 !result.contains("[denied]"),
2149 "--yolo must still waive approval where nothing denies, got: {result}"
2150 );
2151 }
2152
2153 #[tokio::test]
2154 async fn build_agent_honors_agent_level_tool_permissions() {
2155 let dir = tempfile::tempdir().unwrap();
2156 let manifest = dir.path().join("agent.leviath");
2157 std::fs::write(
2161 &manifest,
2162 "[agent]\nname = \"perm\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2163 [tool_permissions]\nread_file = \"deny\"\n\n\
2164 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2165 )
2166 .unwrap();
2167
2168 let (mut world, cli) = test_world();
2169 let hub = InteractionHub::new();
2170 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2171 let entity = build_agent(
2172 world.world_mut(),
2173 cli.as_ref(),
2174 &Config::default(),
2175 mcp,
2176 &[],
2177 &hub,
2178 &spawn_args(&manifest.to_string_lossy()),
2179 100,
2180 sub_tx(),
2181 )
2182 .expect("spawn succeeds");
2183
2184 let out = leviath_runtime::pipeline::ToolService::exec_for(
2185 cli.as_ref(),
2186 entity,
2187 vec![leviath_providers::ToolCall {
2188 id: "c1".to_string(),
2189 name: "read_file".to_string(),
2190 arguments: serde_json::json!({"path": "/no/such/file"}),
2191 thought_signature: None,
2192 }],
2193 )()
2194 .await;
2195 assert!(
2196 out[0].1.contains("[denied]"),
2197 "agent-level deny should block read_file"
2198 );
2199 }
2200
2201 #[tokio::test]
2202 async fn build_agent_script_host_honors_agent_level_grants() {
2203 let dir = tempfile::tempdir().unwrap();
2204 let manifest = dir.path().join("agent.leviath");
2205 std::fs::write(
2206 &manifest,
2207 "[agent]\nname = \"scriptperm\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2208 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2209 )
2210 .unwrap();
2211
2212 let mut config = Config::default();
2218 config.agent_tool_permissions.insert(
2219 "scriptperm".to_string(),
2220 HashMap::from([("write_file".to_string(), crate::config::ToolPolicy::Allow)]),
2221 );
2222
2223 let (mut world, cli) = test_world();
2224 let hub = InteractionHub::new();
2225 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2226 let mut args = spawn_args(&manifest.to_string_lossy());
2227 args.workdir = dir.path().to_string_lossy().to_string();
2228 let entity = build_agent(
2229 world.world_mut(),
2230 cli.as_ref(),
2231 &config,
2232 mcp,
2233 &[],
2234 &hub,
2235 &args,
2236 100,
2237 sub_tx(),
2238 )
2239 .expect("spawn succeeds");
2240
2241 let state = cli.take(entity).expect("tool state registered at spawn");
2242 state
2243 .script_host
2244 .write_file("granted.txt", "ok")
2245 .expect("agent-level write_file grant must reach the script host");
2246 assert_eq!(
2247 std::fs::read_to_string(dir.path().join("granted.txt")).unwrap(),
2248 "ok"
2249 );
2250 }
2251
2252 #[tokio::test]
2253 async fn build_agent_applies_default_max_iterations_only_when_stage_omits_it() {
2254 let dir = tempfile::tempdir().unwrap();
2255 let manifest = dir.path().join("agent.leviath");
2256 std::fs::write(
2258 &manifest,
2259 "[agent]\nname = \"iters\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2260 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\n\
2261 [stages.capped]\nmax_iterations = 3\n\
2262 model = { provider = \"anthropic\", model = \"m\" }\n",
2263 )
2264 .unwrap();
2265
2266 let (mut world, cli) = test_world();
2267 let hub = InteractionHub::new();
2268 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2269 let config = Config {
2271 limits: crate::config::LimitsConfig {
2272 default_max_iterations: Some(42),
2273 ..Default::default()
2274 },
2275 ..Default::default()
2276 };
2277 let entity = build_agent(
2278 world.world_mut(),
2279 cli.as_ref(),
2280 &config,
2281 mcp,
2282 &[],
2283 &hub,
2284 &spawn_args(&manifest.to_string_lossy()),
2285 100,
2286 sub_tx(),
2287 )
2288 .expect("spawn succeeds");
2289
2290 let bp = world
2291 .world()
2292 .get::<leviath_runtime::pipeline::AgentBlueprint>(entity)
2293 .expect("blueprint");
2294 let by_name = |n: &str| {
2295 bp.0.stages
2296 .iter()
2297 .find(|s| s.name == n)
2298 .unwrap()
2299 .max_iterations
2300 };
2301 assert_eq!(by_name("main"), Some(42));
2303 assert_eq!(by_name("capped"), Some(3));
2305 }
2306
2307 #[tokio::test]
2308 async fn build_agent_leaves_max_iterations_unset_when_config_default_is_none() {
2309 let dir = tempfile::tempdir().unwrap();
2310 let manifest = dir.path().join("agent.leviath");
2311 std::fs::write(
2312 &manifest,
2313 "[agent]\nname = \"nolimit\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2314 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2315 )
2316 .unwrap();
2317
2318 let (mut world, cli) = test_world();
2319 let hub = InteractionHub::new();
2320 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2321 let config = Config {
2323 limits: crate::config::LimitsConfig {
2324 default_max_iterations: None,
2325 ..Default::default()
2326 },
2327 ..Default::default()
2328 };
2329 let entity = build_agent(
2330 world.world_mut(),
2331 cli.as_ref(),
2332 &config,
2333 mcp,
2334 &[],
2335 &hub,
2336 &spawn_args(&manifest.to_string_lossy()),
2337 100,
2338 sub_tx(),
2339 )
2340 .expect("spawn succeeds");
2341
2342 let bp = world
2343 .world()
2344 .get::<leviath_runtime::pipeline::AgentBlueprint>(entity)
2345 .expect("blueprint");
2346 assert_eq!(bp.0.stages[0].max_iterations, None);
2347 }
2348
2349 #[tokio::test]
2350 async fn fake_provider_methods_are_exercised() {
2351 let p = FakeProvider;
2352 assert_eq!(p.name(), "fake");
2353 assert_eq!(p.count_tokens("t", "m").await, 1);
2354 assert_eq!(p.max_context_tokens("m"), 1000);
2355 let _ = p.capabilities("m");
2356 assert!(
2357 p.infer(leviath_providers::InferenceRequest {
2358 system: vec![],
2359 messages: vec![],
2360 model: "m".to_string(),
2361 max_tokens: 1,
2362 temperature: 0.0,
2363 tools: vec![],
2364 extra: serde_json::Value::Null,
2365 request_timeout_secs: None,
2366 })
2367 .await
2368 .is_err()
2369 );
2370 }
2371
2372 #[test]
2373 fn resolve_stages_empty_available_tools_gets_none() {
2374 let mut stage =
2375 leviath_core::Stage::new("s".to_string(), model_cfg(vec![("anthropic", "m")]));
2376 stage.available_tools = vec![]; let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
2378 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
2379 let tools = vec![Tool {
2380 name: "read_file".to_string(),
2381 description: String::new(),
2382 parameters: serde_json::Value::Null,
2383 }];
2384 let resolved = resolve_stages(
2385 &bp,
2386 None,
2387 &Config::default(),
2388 ®istry_with(&["anthropic"]),
2389 &tools,
2390 );
2391 assert!(resolved[0].tools.is_empty());
2392 }
2393
2394 #[test]
2395 fn resolve_stages_matches_by_alias_and_skips_unknown_names() {
2396 let mut stage =
2400 leviath_core::Stage::new("s".to_string(), model_cfg(vec![("anthropic", "m")]));
2401 stage.available_tools = vec!["bash".to_string(), "acme__uninstalled".to_string()];
2402 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
2403 let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
2404 let tools = vec![
2405 Tool {
2406 name: "shell".to_string(),
2407 description: String::new(),
2408 parameters: serde_json::Value::Null,
2409 },
2410 Tool {
2411 name: "read_file".to_string(),
2412 description: String::new(),
2413 parameters: serde_json::Value::Null,
2414 },
2415 ];
2416 let resolved = resolve_stages(
2417 &bp,
2418 None,
2419 &Config::default(),
2420 ®istry_with(&["anthropic"]),
2421 &tools,
2422 );
2423 let selected: Vec<&str> = resolved[0].tools.iter().map(|t| t.name.as_str()).collect();
2424 assert_eq!(selected, vec!["shell"]);
2427 }
2428
2429 #[tokio::test]
2430 async fn build_agent_read_error() {
2431 let (mut world, cli) = test_world();
2432 let hub = InteractionHub::new();
2433 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2434 let err = build_agent(
2435 world.world_mut(),
2436 cli.as_ref(),
2437 &Config::default(),
2438 mcp,
2439 &[],
2440 &hub,
2441 &spawn_args("/no/such/manifest.leviath"),
2442 100,
2443 sub_tx(),
2444 )
2445 .unwrap_err();
2446 assert!(err.contains("read manifest"));
2447 }
2448
2449 const OVERSIZED_MANIFEST: &str = r#"
2452[agent]
2453name = "tiny"
2454version = "0.1.0"
2455description = "d"
2456entry_stage = "main"
2457
2458[context.regions]
2459task = { kind = "pinned", max_tokens = 20 }
2460
2461[stages.main]
2462mode = "autonomous"
2463model = { models = [{ provider = "anthropic", model = "m" }] }
2464description = "d"
2465available_tools = []
2466system_prompt = "SYSTEM_PROMPT_PLACEHOLDER"
2467"#;
2468
2469 #[tokio::test]
2470 async fn build_agent_propagates_spawn_error() {
2471 let dir = tempfile::tempdir().unwrap();
2472 let manifest = dir.path().join("tiny.leviath");
2473 let content = OVERSIZED_MANIFEST.replace("SYSTEM_PROMPT_PLACEHOLDER", &"x ".repeat(5000));
2475 std::fs::write(&manifest, content).unwrap();
2476
2477 let (mut world, cli) = test_world();
2478 let hub = InteractionHub::new();
2479 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2480 let result = build_agent(
2481 world.world_mut(),
2482 cli.as_ref(),
2483 &Config::default(),
2484 mcp,
2485 &[],
2486 &hub,
2487 &spawn_args(&manifest.to_string_lossy()),
2488 100,
2489 sub_tx(),
2490 );
2491 assert!(result.is_err(), "expected spawn error, got {result:?}");
2492 }
2493
2494 #[tokio::test]
2495 async fn build_agent_invalid_blueprint() {
2496 let dir = tempfile::tempdir().unwrap();
2497 let manifest = dir.path().join("bad.leviath");
2498 std::fs::write(
2500 &manifest,
2501 r#"
2502[agent]
2503name = "bad"
2504version = "0.1.0"
2505description = "d"
2506entry_stage = "ghost"
2507
2508[context.regions]
2509task = { kind = "pinned", max_tokens = 4000 }
2510
2511[stages.main]
2512mode = "autonomous"
2513model = { models = [{ provider = "anthropic", model = "m" }] }
2514description = "d"
2515available_tools = []
2516"#,
2517 )
2518 .unwrap();
2519 let (mut world, cli) = test_world();
2520 let hub = InteractionHub::new();
2521 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2522 let err = build_agent(
2523 world.world_mut(),
2524 cli.as_ref(),
2525 &Config::default(),
2526 mcp,
2527 &[],
2528 &hub,
2529 &spawn_args(&manifest.to_string_lossy()),
2530 100,
2531 sub_tx(),
2532 )
2533 .unwrap_err();
2534 assert!(err.contains("invalid blueprint"));
2535 }
2536
2537 #[tokio::test]
2538 async fn build_agent_without_entry_stage_and_with_compaction() {
2539 let dir = tempfile::tempdir().unwrap();
2540 let manifest = dir.path().join("mini.leviath");
2541 std::fs::write(
2543 &manifest,
2544 r#"
2545[agent]
2546name = "mini"
2547version = "0.1.0"
2548description = "d"
2549
2550[compaction]
2551provider = "anthropic"
2552model = "claude-x"
2553
2554[context.regions]
2555task = { kind = "pinned", max_tokens = 4000 }
2556
2557[stages.main]
2558mode = "autonomous"
2559model = { models = [{ provider = "anthropic", model = "m" }] }
2560description = "d"
2561available_tools = []
2562system_prompt = "be brief"
2563"#,
2564 )
2565 .unwrap();
2566 let (mut world, cli) = test_world();
2567 let hub = InteractionHub::new();
2568 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2569 let entity = build_agent(
2570 world.world_mut(),
2571 cli.as_ref(),
2572 &Config::default(),
2573 mcp,
2574 &[],
2575 &hub,
2576 &spawn_args(&manifest.to_string_lossy()),
2577 100,
2578 sub_tx(),
2579 )
2580 .expect("spawn succeeds");
2581 assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
2582 assert!(world.world().get::<CompactionSettings>(entity).is_some());
2584 }
2585
2586 #[tokio::test]
2587 async fn build_agent_parse_error() {
2588 let dir = tempfile::tempdir().unwrap();
2589 let manifest = dir.path().join("bad.leviath");
2590 std::fs::write(&manifest, "this is not valid toml : : :").unwrap();
2591 let (mut world, cli) = test_world();
2592 let hub = InteractionHub::new();
2593 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2594 let err = build_agent(
2595 world.world_mut(),
2596 cli.as_ref(),
2597 &Config::default(),
2598 mcp,
2599 &[],
2600 &hub,
2601 &spawn_args(&manifest.to_string_lossy()),
2602 100,
2603 sub_tx(),
2604 )
2605 .unwrap_err();
2606 assert!(err.contains("parse manifest"));
2607 }
2608
2609 fn bp(regions_toml: &str) -> Blueprint {
2612 let toml = format!(
2613 r#"
2614[agent]
2615name = "seedy"
2616
2617[stages.main]
2618mode = "autonomous"
2619
2620[stages.main.model]
2621provider = "anthropic"
2622model = "claude-sonnet-5"
2623
2624[context.regions]
2625{regions_toml}
2626conversation = {{ kind = "sliding_window", max_items = 20, max_tokens = 10000 }}
2627"#
2628 );
2629 leviath_core::manifest::parse_manifest(&toml).unwrap()
2630 }
2631
2632 fn args_with(task: &str, regions: HashMap<String, String>, workdir: &str) -> SpawnArgs {
2633 SpawnArgs {
2634 run_id: "r".to_string(),
2635 blueprint_path: "/bp".to_string(),
2636 task: task.to_string(),
2637 regions,
2638 model: None,
2639 workdir: workdir.to_string(),
2640 metadata: HashMap::new(),
2641 callback_url: None,
2642 callback_secret: None,
2643 yolo: false,
2644 no_seed_commands: false,
2645 allow: Vec::new(),
2646 max_depth: None,
2647 parent_run_id: None,
2648 }
2649 }
2650
2651 fn seed_policy() -> SeedCommandPolicy {
2654 SeedCommandPolicy::disabled()
2655 }
2656
2657 fn stub_policy(result: Result<String, String>) -> SeedCommandPolicy {
2660 SeedCommandPolicy {
2661 allowed: true,
2662 timeout: std::time::Duration::from_secs(1),
2663 runner: std::sync::Arc::new(move |_, _, _| result.clone()),
2664 }
2665 }
2666
2667 #[test]
2668 fn resolve_seeds_fills_task_and_caller_input() {
2669 let bp = bp(
2670 r#"task = { kind = "pinned", max_tokens = 4000, seed = "task_input" }
2671criteria = { kind = "pinned", max_tokens = 2000, seed = "input" }"#,
2672 );
2673 let args = args_with(
2674 "build it",
2675 HashMap::from([("criteria".to_string(), "be safe".to_string())]),
2676 "/tmp",
2677 );
2678 let seeds = resolve_seeds(&bp, &args, "/tmp", &seed_policy()).unwrap();
2679 assert_eq!(seeds.get("task").map(String::as_str), Some("build it"));
2680 assert_eq!(seeds.get("criteria").map(String::as_str), Some("be safe"));
2681 }
2682
2683 #[test]
2684 fn resolve_seeds_required_caller_input_missing_is_error() {
2685 let bp =
2686 bp(r#"spec = { kind = "pinned", max_tokens = 2000, seed = "input", required = true }"#);
2687 let args = args_with("t", HashMap::new(), "/tmp");
2688 let err = resolve_seeds(&bp, &args, "/tmp", &seed_policy()).unwrap_err();
2689 assert!(err.contains("spec"), "got: {err}");
2690 }
2691
2692 #[test]
2693 fn resolve_seeds_optional_caller_input_missing_is_omitted() {
2694 let bp = bp(r#"notes = { kind = "pinned", max_tokens = 2000, seed = "input" }"#);
2695 let args = args_with("t", HashMap::new(), "/tmp");
2696 let seeds = resolve_seeds(&bp, &args, "/tmp", &seed_policy()).unwrap();
2697 assert!(!seeds.contains_key("notes"));
2698 }
2699
2700 #[test]
2701 fn resolve_seeds_literal_and_files() {
2702 let dir = tempfile::tempdir().unwrap();
2703 std::fs::write(dir.path().join("a.txt"), "alpha").unwrap();
2704 std::fs::write(dir.path().join("b.txt"), "beta").unwrap();
2705 let bp = bp(
2706 r#"lit = { kind = "pinned", max_tokens = 500, seed = { literal = "hello" } }
2707docs = { kind = "pinned", max_tokens = 2000, seed = { files = ["a.txt", "b.txt"] } }"#,
2708 );
2709 let args = args_with("t", HashMap::new(), &dir.path().to_string_lossy());
2710 let seeds =
2711 resolve_seeds(&bp, &args, &dir.path().to_string_lossy(), &seed_policy()).unwrap();
2712 assert_eq!(seeds.get("lit").map(String::as_str), Some("hello"));
2713 let docs = seeds.get("docs").unwrap();
2714 assert!(docs.contains("alpha") && docs.contains("beta"));
2715 }
2716
2717 #[test]
2718 fn resolve_seeds_glob_concatenates_matches() {
2719 let dir = tempfile::tempdir().unwrap();
2720 std::fs::create_dir(dir.path().join("specs")).unwrap();
2721 std::fs::write(dir.path().join("specs/one.md"), "spec one").unwrap();
2722 std::fs::write(dir.path().join("specs/two.md"), "spec two").unwrap();
2723 let bp =
2724 bp(r#"specs = { kind = "pinned", max_tokens = 4000, seed = { glob = "specs/*.md" } }"#);
2725 let wd = dir.path().to_string_lossy().to_string();
2726 let args = args_with("t", HashMap::new(), &wd);
2727 let seeds = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap();
2728 let specs = seeds.get("specs").unwrap();
2729 assert!(specs.contains("spec one") && specs.contains("spec two"));
2730 }
2731
2732 #[test]
2733 fn resolve_seeds_rhai_runs_script() {
2734 let dir = tempfile::tempdir().unwrap();
2735 std::fs::write(
2737 dir.path().join("init.rhai"),
2738 r#""seeded: " + input["task"]"#,
2739 )
2740 .unwrap();
2741 let bp = bp(
2742 r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "init.rhai" } }"#,
2743 );
2744 let wd = dir.path().to_string_lossy().to_string();
2745 let args = args_with("hello", HashMap::new(), &wd);
2746 let seeds = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap();
2747 assert_eq!(
2748 seeds.get("scripted").map(String::as_str),
2749 Some("seeded: hello")
2750 );
2751 }
2752
2753 #[test]
2754 fn resolve_seeds_files_required_missing_errors_optional_skips() {
2755 let dir = tempfile::tempdir().unwrap();
2756 let wd = dir.path().to_string_lossy().to_string();
2757 let req = bp(
2759 r#"docs = { kind = "pinned", max_tokens = 2000, seed = { files = ["missing.txt"] }, required = true }"#,
2760 );
2761 let args = args_with("t", HashMap::new(), &wd);
2762 let err = resolve_seeds(&req, &args, &wd, &seed_policy()).unwrap_err();
2763 assert!(err.contains("missing.txt"), "got: {err}");
2764 let opt = bp(
2766 r#"docs = { kind = "pinned", max_tokens = 2000, seed = { files = ["missing.txt"] } }"#,
2767 );
2768 let seeds = resolve_seeds(&opt, &args, &wd, &seed_policy()).unwrap();
2769 assert!(!seeds.contains_key("docs"));
2770 }
2771
2772 #[test]
2773 fn resolve_seeds_glob_no_match_required_errors_optional_skips() {
2774 let dir = tempfile::tempdir().unwrap();
2775 let wd = dir.path().to_string_lossy().to_string();
2776 let args = args_with("t", HashMap::new(), &wd);
2777 let req = bp(
2779 r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "none/*.md" }, required = true }"#,
2780 );
2781 let err = resolve_seeds(&req, &args, &wd, &seed_policy()).unwrap_err();
2782 assert!(err.contains("matched no files"), "got: {err}");
2783 let opt =
2785 bp(r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "none/*.md" } }"#);
2786 let seeds = resolve_seeds(&opt, &args, &wd, &seed_policy()).unwrap();
2787 assert!(!seeds.contains_key("specs"));
2788 }
2789
2790 #[test]
2791 fn resolve_seeds_bad_glob_pattern_errors() {
2792 let dir = tempfile::tempdir().unwrap();
2794 let wd = dir.path().to_string_lossy().to_string();
2795 let bp = bp(r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "[" } }"#);
2796 let args = args_with("t", HashMap::new(), &wd);
2797 let err = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap_err();
2798 assert!(err.contains("bad glob"), "got: {err}");
2799 }
2800
2801 #[test]
2802 fn resolve_seeds_rhai_script_error() {
2803 let dir = tempfile::tempdir().unwrap();
2804 std::fs::write(dir.path().join("boom.rhai"), "undefined_func()").unwrap();
2806 let wd = dir.path().to_string_lossy().to_string();
2807 let bp = bp(
2808 r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "boom.rhai" } }"#,
2809 );
2810 let args = args_with("t", HashMap::new(), &wd);
2811 let err = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap_err();
2812 assert!(err.contains("rhai seed failed"), "got: {err}");
2813 }
2814
2815 fn command_bp(required: bool) -> leviath_core::Blueprint {
2819 let req = if required { ", required = true" } else { "" };
2820 bp(&format!(
2821 r#"facts = {{ kind = "pinned", max_tokens = 500, seed = {{ command = "scan-repo" }}{req} }}"#
2822 ))
2823 }
2824
2825 #[test]
2826 fn resolve_seeds_command_stores_output() {
2827 let bp = command_bp(false);
2828 let args = args_with("t", HashMap::new(), "/tmp");
2829 let seeds = resolve_seeds(
2830 &bp,
2831 &args,
2832 "/tmp",
2833 &stub_policy(Ok("src/lib.rs\nsrc/main.rs".to_string())),
2834 )
2835 .unwrap();
2836 assert_eq!(
2837 seeds.get("facts").map(String::as_str),
2838 Some("src/lib.rs\nsrc/main.rs")
2839 );
2840 }
2841
2842 #[test]
2843 fn resolve_seeds_command_receives_the_workdir_and_command() {
2844 let bp = command_bp(false);
2846 let args = args_with("t", HashMap::new(), "/work");
2847 let policy = SeedCommandPolicy {
2848 allowed: true,
2849 timeout: std::time::Duration::from_secs(9),
2850 runner: std::sync::Arc::new(|command, workdir, timeout| {
2851 Ok(format!(
2852 "{command}@{}#{}",
2853 workdir.display(),
2854 timeout.as_secs()
2855 ))
2856 }),
2857 };
2858 let seeds = resolve_seeds(&bp, &args, "/work", &policy).unwrap();
2859 assert_eq!(
2860 seeds.get("facts").map(String::as_str),
2861 Some("scan-repo@/work#9")
2862 );
2863 }
2864
2865 #[test]
2866 fn resolve_seeds_command_failure_is_skipped_when_optional() {
2867 let bp = command_bp(false);
2868 let args = args_with("t", HashMap::new(), "/tmp");
2869 let seeds = resolve_seeds(
2870 &bp,
2871 &args,
2872 "/tmp",
2873 &stub_policy(Err("timed out".to_string())),
2874 )
2875 .unwrap();
2876 assert!(
2877 !seeds.contains_key("facts"),
2878 "an optional command seed must not sink the spawn"
2879 );
2880 }
2881
2882 #[test]
2883 fn resolve_seeds_command_failure_errors_when_required() {
2884 let bp = command_bp(true);
2885 let args = args_with("t", HashMap::new(), "/tmp");
2886 let err =
2887 resolve_seeds(&bp, &args, "/tmp", &stub_policy(Err("boom".to_string()))).unwrap_err();
2888 assert!(err.contains("scan-repo"), "got: {err}");
2889 assert!(err.contains("boom"), "got: {err}");
2890 }
2891
2892 #[test]
2893 fn resolve_seeds_command_empty_output_is_skipped_when_optional() {
2894 let bp = command_bp(false);
2895 let args = args_with("t", HashMap::new(), "/tmp");
2896 let seeds =
2897 resolve_seeds(&bp, &args, "/tmp", &stub_policy(Ok(" \n".to_string()))).unwrap();
2898 assert!(!seeds.contains_key("facts"));
2899 }
2900
2901 #[test]
2902 fn resolve_seeds_command_empty_output_errors_when_required() {
2903 let bp = command_bp(true);
2904 let args = args_with("t", HashMap::new(), "/tmp");
2905 let err = resolve_seeds(&bp, &args, "/tmp", &stub_policy(Ok(String::new()))).unwrap_err();
2906 assert!(err.contains("returned empty"), "got: {err}");
2907 }
2908
2909 #[test]
2910 fn resolve_seeds_command_skipped_when_disabled() {
2911 let bp = command_bp(false);
2915 let args = args_with("t", HashMap::new(), "/tmp");
2916 let mut policy = stub_policy(Ok("SHOULD NOT BE USED".to_string()));
2917 policy.allowed = false;
2918 let seeds = resolve_seeds(&bp, &args, "/tmp", &policy).unwrap();
2919 assert!(!seeds.contains_key("facts"));
2920 }
2921
2922 #[test]
2923 fn resolve_seeds_required_command_errors_when_disabled() {
2924 let bp = command_bp(true);
2927 let args = args_with("t", HashMap::new(), "/tmp");
2928 let err = resolve_seeds(&bp, &args, "/tmp", &SeedCommandPolicy::disabled()).unwrap_err();
2929 assert!(err.contains("allow_seed_commands"), "got: {err}");
2930 }
2931
2932 #[test]
2933 fn resolve_seeds_glob_matching_directory_required_errors() {
2934 let dir = tempfile::tempdir().unwrap();
2937 std::fs::create_dir(dir.path().join("subdir")).unwrap();
2938 let wd = dir.path().to_string_lossy().to_string();
2939 let bp = bp(
2940 r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "sub*" }, required = true }"#,
2941 );
2942 let args = args_with("t", HashMap::new(), &wd);
2943 let err = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap_err();
2944 assert!(err.contains("read seed file"), "got: {err}");
2945 }
2946
2947 #[test]
2948 fn resolve_seeds_rhai_read_error() {
2949 let dir = tempfile::tempdir().unwrap();
2950 let wd = dir.path().to_string_lossy().to_string();
2951 let bp = bp(
2952 r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "nope.rhai" } }"#,
2953 );
2954 let args = args_with("t", HashMap::new(), &wd);
2955 let err = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap_err();
2956 assert!(err.contains("read rhai seed"), "got: {err}");
2957 }
2958
2959 #[test]
2960 fn resolve_seeds_rhai_empty_required_errors_optional_skips() {
2961 let dir = tempfile::tempdir().unwrap();
2962 std::fs::write(dir.path().join("empty.rhai"), r#""""#).unwrap();
2964 let wd = dir.path().to_string_lossy().to_string();
2965 let args = args_with("t", HashMap::new(), &wd);
2966 let req = bp(
2967 r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "empty.rhai" }, required = true }"#,
2968 );
2969 let err = resolve_seeds(&req, &args, &wd, &seed_policy()).unwrap_err();
2970 assert!(err.contains("returned empty"), "got: {err}");
2971 let opt = bp(
2973 r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "empty.rhai" } }"#,
2974 );
2975 let seeds = resolve_seeds(&opt, &args, &wd, &seed_policy()).unwrap();
2976 assert!(!seeds.contains_key("scripted"));
2977 }
2978
2979 #[test]
2980 fn resolve_seeds_tolerates_unknown_caller_region() {
2981 let bp = bp(r#"task = { kind = "pinned", max_tokens = 4000, seed = "task_input" }"#);
2984 let args = args_with(
2985 "t",
2986 HashMap::from([("ghost".to_string(), "x".to_string())]),
2987 "/tmp",
2988 );
2989 let seeds = resolve_seeds(&bp, &args, "/tmp", &seed_policy()).unwrap();
2990 assert_eq!(seeds.get("task").map(String::as_str), Some("t"));
2991 assert!(!seeds.contains_key("ghost"));
2992 }
2993}