1use std::collections::{HashMap, HashSet};
14use std::sync::{Arc, Mutex as StdMutex};
15
16use bevy_ecs::entity::Entity;
17use bevy_ecs::world::World;
18use leviath_core::blueprint::Blueprint;
19use leviath_providers::Tool;
20use leviath_runtime::host::{SpawnArgs, SubAgentOp};
21use leviath_runtime::interaction_hub::InteractionHub;
22use leviath_runtime::persistence::{RunMetadata, TokenTotals};
23use leviath_runtime::pipeline::{
24 CompactionSettings, ModelDefaults, PersistWatermark, Providers, resolve_stages,
25 spawn_agent_seeded,
26};
27use tokio::sync::Mutex;
28use tokio::sync::mpsc::UnboundedSender;
29
30use crate::config::Config;
31use crate::daemon::seed_command::SeedCommandPolicy;
32use crate::daemon::subagent::SubAgentHandle;
33use crate::daemon::tool_service::{AgentToolState, CliToolService};
34
35const DEFAULT_SUBAGENT_DEPTH: usize = 3;
37
38pub(crate) fn model_defaults(config: &Config) -> ModelDefaults {
41 ModelDefaults {
42 provider: config.default_provider.clone(),
43 model: config.default_model.clone(),
44 fallback_order: parse_fallback_order(&config.providers.fallback_order),
45 }
46}
47
48fn parse_fallback_order(entries: &[String]) -> Vec<leviath_core::blueprint::ModelEntry> {
56 entries
57 .iter()
58 .filter_map(|raw| match raw.split_once('/') {
59 Some((provider, model)) if !provider.is_empty() && !model.is_empty() => Some(
60 leviath_core::blueprint::ModelEntry::new(provider.to_string(), model.to_string()),
61 ),
62 _ => {
63 tracing::warn!(
64 entry = %raw,
65 "ignoring [providers] fallback_order entry: expected \"provider/model\""
66 );
67 None
68 }
69 })
70 .collect()
71}
72
73fn script_scan_dirs(
80 blueprint_path: &str,
81 extra: Option<std::path::PathBuf>,
82) -> Vec<std::path::PathBuf> {
83 std::path::Path::new(blueprint_path)
84 .parent()
85 .map(|d| d.join("tools"))
86 .into_iter()
87 .chain(extra)
88 .chain(leviath_core::tools_dir())
89 .collect()
90}
91
92pub(crate) fn resolve_region_scripts(
104 blueprint: &Blueprint,
105 blueprint_path: &str,
106) -> Result<HashMap<String, Arc<leviath_scripting::region_hook::RegionScript>>, String> {
107 let base = std::path::Path::new(blueprint_path)
108 .parent()
109 .map(std::path::Path::to_path_buf)
110 .unwrap_or_default();
111 let mut scripts = HashMap::new();
112
113 let layouts = std::iter::once(&blueprint.context_layout).chain(
114 blueprint
115 .stages
116 .iter()
117 .filter_map(|s| s.context_layout.as_ref()),
118 );
119 for layout in layouts {
120 for region in &layout.regions {
121 let leviath_core::RegionKind::Custom { script, .. } = ®ion.kind else {
122 continue;
123 };
124 if scripts.contains_key(script) {
125 continue;
126 }
127 let path = base.join(script);
128 let source = std::fs::read_to_string(&path).map_err(|e| {
129 format!(
130 "region '{}': cannot read custom region script '{}': {e}",
131 region.name,
132 path.display()
133 )
134 })?;
135 let compiled =
136 leviath_scripting::region_hook::compile(script, &source).map_err(|e| {
137 format!(
138 "region '{}': custom region script failed to compile: {e}",
139 region.name
140 )
141 })?;
142 scripts.insert(script.clone(), Arc::new(compiled));
143 }
144 }
145 Ok(scripts)
146}
147
148fn reserved_tool_names(builtin_names: &HashSet<String>, mcp_tool_defs: &[Tool]) -> HashSet<String> {
151 let mut reserved: HashSet<String> = builtin_names.clone();
152 reserved.extend(leviath_tools::BuiltinTools::subagent_tool_names());
153 reserved.extend(mcp_tool_defs.iter().map(|t| t.name.clone()));
154 reserved
155}
156
157fn script_cap(name: &str) -> Option<leviath_tools::ToolCapability> {
162 match name {
163 "network" | "net" | "http" => Some(leviath_tools::ToolCapability::Network),
164 "shell" | "process" | "process_spawn" => Some(leviath_tools::ToolCapability::ProcessSpawn),
165 "filesystem" | "file" | "fs" => Some(leviath_tools::ToolCapability::FileSystem),
166 _ => None,
167 }
168}
169
170fn platform_satisfies_caps(
173 platform: &leviath_tools::PlatformCapabilities,
174 required_caps: &[String],
175) -> bool {
176 required_caps
177 .iter()
178 .all(|c| script_cap(c).is_some_and(|cap| platform.supports(cap)))
179}
180
181pub(crate) fn current_platform_satisfies(required_caps: &[String]) -> bool {
187 platform_satisfies_caps(
188 &leviath_tools::PlatformCapabilities::current(),
189 required_caps,
190 )
191}
192
193pub(crate) fn discover_script_tools_in(
201 dirs: &[std::path::PathBuf],
202 reserved: &HashSet<String>,
203) -> (leviath_scripting::ScriptToolSet, HashSet<String>, Vec<Tool>) {
204 let (set, skipped) = leviath_scripting::ScriptToolSet::discover(dirs);
205 for s in &skipped {
206 let path = s.path.display().to_string();
210 tracing::warn!(tool = %path, reason = %s.reason, "skipping invalid script tool");
211 }
212 let platform = leviath_tools::PlatformCapabilities::current();
213 let mut names = HashSet::new();
214 let mut defs = Vec::new();
215 for meta in set.metas() {
216 if reserved.contains(&meta.name) {
217 tracing::warn!(tool = %meta.name, "script tool name collides with an existing tool - ignoring");
218 continue;
219 }
220 if !platform_satisfies_caps(&platform, &meta.required_caps) {
221 let caps = meta.required_caps.join(", ");
222 tracing::warn!(tool = %meta.name, requires = %caps, "script tool requires a capability this platform lacks - ignoring");
223 continue;
224 }
225 names.insert(meta.name.clone());
226 defs.push(Tool {
227 name: meta.name.clone(),
228 description: meta.description.clone(),
229 parameters: meta.parameters_schema(),
230 });
231 }
232 (set, names, defs)
233}
234
235fn discover_script_tools(
239 blueprint_path: &str,
240 builtin_names: &HashSet<String>,
241 mcp_tool_defs: &[Tool],
242 extra_dir: Option<std::path::PathBuf>,
243) -> (leviath_scripting::ScriptToolSet, HashSet<String>, Vec<Tool>) {
244 let dirs = script_scan_dirs(blueprint_path, extra_dir);
245 let reserved = reserved_tool_names(builtin_names, mcp_tool_defs);
246 discover_script_tools_in(&dirs, &reserved)
247}
248
249#[allow(clippy::too_many_arguments)]
255fn build_tool_state(
256 builtins: Arc<leviath_tools::BuiltinTools>,
257 builtin_names: HashSet<String>,
258 mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
259 config: &Config,
260 hub: &InteractionHub,
261 run_id: &str,
262 entry_stage: &str,
263 entry_index: usize,
264 stage_perms_by_index: Vec<HashMap<String, String>>,
265 stage_required_by_index: Vec<HashSet<String>>,
266 agent_perms: HashMap<String, String>,
267 agent_name: &str,
268 launch_overrides: HashMap<String, crate::config::ToolPolicy>,
269 subagent: Option<SubAgentHandle>,
270 sandbox: Option<Arc<crate::daemon::sandbox_manager::SandboxManager>>,
271 script_tools: leviath_scripting::ScriptToolSet,
272 script_tool_names: HashSet<String>,
273 script_host: Arc<dyn leviath_scripting::ScriptHost>,
274 dynamic: Option<Arc<crate::daemon::tool_service::DynamicToolCtx>>,
275 unattended: bool,
276) -> Arc<AgentToolState> {
277 let entry_perms = stage_perms_by_index
278 .get(entry_index)
279 .cloned()
280 .unwrap_or_default();
281 let entry_required = stage_required_by_index
282 .get(entry_index)
283 .cloned()
284 .unwrap_or_default();
285 Arc::new(AgentToolState {
286 builtins,
287 mcp,
288 builtin_names,
289 launch_overrides: Arc::new(launch_overrides),
290 session_allows: Arc::new(Mutex::new(HashSet::new())),
291 stage_perms: Arc::new(StdMutex::new(entry_perms)),
292 stage_perms_by_index: Arc::new(stage_perms_by_index),
293 stage_required: Arc::new(StdMutex::new(entry_required)),
294 stage_required_by_index: Arc::new(stage_required_by_index),
295 agent_perms: Arc::new(agent_perms),
296 global_perms: Arc::new(config.permissions_for_agent(agent_name)),
301 interaction: hub.backend_for(run_id),
302 unattended,
303 stage_name: Arc::new(StdMutex::new(entry_stage.to_string())),
304 subagent,
305 sandbox,
306 script_tools: Arc::new(StdMutex::new(script_tools)),
307 script_tool_names: Arc::new(StdMutex::new(script_tool_names)),
308 script_host,
309 dynamic,
310 })
311}
312
313fn resolve_seeds(
330 blueprint: &Blueprint,
331 args: &SpawnArgs,
332 workdir: &str,
333 commands: &SeedCommandPolicy,
334) -> Result<HashMap<String, String>, String> {
335 use leviath_core::layout::RegionSeed;
336
337 let mut caller: HashMap<String, String> = HashMap::new();
339 caller.insert("task".to_string(), args.task.clone());
340 for (k, v) in &args.regions {
341 caller.insert(k.clone(), v.clone());
342 }
343
344 let base = std::path::Path::new(workdir);
349 let mut seeds: HashMap<String, String> = HashMap::new();
350
351 for region in &blueprint.context_layout.regions {
352 let Some(seed) = ®ion.seed else { continue };
353 match seed {
354 RegionSeed::CallerInput { name } => {
355 let value = caller.get(name).map(|s| s.as_str()).unwrap_or("");
356 if value.trim().is_empty() {
357 if region.required {
358 return Err(region.required_message.clone().unwrap_or_else(|| {
359 format!(
360 "required region '{}' was not provided; supply it via \
361 --{name} <text|@file> (CLI), a ---region:{name}--- block \
362 (ACP), or the API `regions` field",
363 region.name
364 )
365 }));
366 }
367 continue;
369 }
370 seeds.insert(region.name.clone(), value.to_string());
371 }
372 RegionSeed::Literal { text } => {
373 seeds.insert(region.name.clone(), text.clone());
374 }
375 RegionSeed::Files { paths } => {
376 let content = read_and_concat(
377 ®ion.name,
378 paths.iter().map(|p| base.join(p)),
379 region.required,
380 )?;
381 if let Some(content) = content {
382 seeds.insert(region.name.clone(), content);
383 }
384 }
385 RegionSeed::Glob { pattern } => {
386 let full = base.join(pattern);
387 let full = full.to_string_lossy();
388 let matches = glob::glob(&full)
389 .map_err(|e| format!("region '{}': bad glob '{pattern}': {e}", region.name))?;
390 let paths: Vec<std::path::PathBuf> = matches.filter_map(|m| m.ok()).collect();
391 let content = read_and_concat(®ion.name, paths.into_iter(), region.required)?;
392 match content {
393 Some(content) => {
394 seeds.insert(region.name.clone(), content);
395 }
396 None if region.required => {
397 return Err(format!(
398 "required region '{}': glob '{pattern}' matched no files",
399 region.name
400 ));
401 }
402 None => {}
403 }
404 }
405 RegionSeed::Rhai { script } => {
406 let path = base.join(script);
407 let src = std::fs::read_to_string(&path).map_err(|e| {
408 format!(
409 "region '{}': read rhai seed '{}': {e}",
410 region.name,
411 path.display()
412 )
413 })?;
414 let mut input = rhai::Map::new();
415 input.insert("task".into(), rhai::Dynamic::from(args.task.clone()));
416 input.insert("workdir".into(), rhai::Dynamic::from(workdir.to_string()));
417 let out = leviath_scripting::ScriptEngine::new()
418 .transform(&src, input)
419 .map_err(|e| format!("region '{}': rhai seed failed: {e}", region.name))?;
420 if !out.trim().is_empty() {
421 seeds.insert(region.name.clone(), out);
422 } else if region.required {
423 return Err(format!(
424 "required region '{}': rhai seed '{script}' returned empty",
425 region.name
426 ));
427 }
428 }
429 RegionSeed::Command { command } => {
435 if !commands.allowed {
436 if region.required {
437 return Err(format!(
438 "required region '{}': command seeds are disabled \
439 (`[security] allow_seed_commands = false` or --no-seed-commands)",
440 region.name
441 ));
442 }
443 tracing::warn!(
444 region = %region.name,
445 "command seed skipped: command seeds are disabled"
446 );
447 continue;
448 }
449 match commands.run(command, base) {
450 Ok(out) if !out.trim().is_empty() => {
451 seeds.insert(region.name.clone(), out);
452 }
453 Ok(_) => {
454 if region.required {
455 return Err(format!(
456 "required region '{}': command seed '{command}' returned empty",
457 region.name
458 ));
459 }
460 tracing::warn!(
461 region = %region.name,
462 command = %command,
463 "command seed returned no output; region left empty"
464 );
465 }
466 Err(e) => {
467 if region.required {
468 return Err(format!(
469 "required region '{}': command seed '{command}' failed: {e}",
470 region.name
471 ));
472 }
473 tracing::warn!(
474 region = %region.name,
475 command = %command,
476 error = %e,
477 "command seed failed; region left empty"
478 );
479 }
480 }
481 }
482 }
483 }
484
485 Ok(seeds)
486}
487
488fn read_and_concat(
492 region: &str,
493 paths: impl Iterator<Item = std::path::PathBuf>,
494 required: bool,
495) -> Result<Option<String>, String> {
496 let mut parts: Vec<String> = Vec::new();
497 for path in paths {
498 match std::fs::read_to_string(&path) {
499 Ok(text) => parts.push(format!("--- {} ---\n{}", path.display(), text)),
500 Err(e) => {
501 if required {
502 return Err(format!(
503 "region '{region}': read seed file '{}': {e}",
504 path.display()
505 ));
506 }
507 }
508 }
509 }
510 Ok((!parts.is_empty()).then(|| parts.join("\n\n")))
511}
512
513fn build_read_path_policy(
524 blueprint: &leviath_core::Blueprint,
525 config: &crate::config::Config,
526 workdir: &std::path::Path,
527) -> Result<(leviath_core::ReadPathPolicy, Option<String>), String> {
528 let Some(rp) = blueprint
529 .read_paths
530 .as_ref()
531 .filter(|rp| !rp.allow.is_empty())
532 else {
533 return Ok((leviath_core::ReadPathPolicy::inactive(), None));
534 };
535 let home = leviath_core::home_dir();
536 let declared =
537 leviath_core::ReadPathSet::compile(&rp.allow, workdir, home.as_deref(), cfg!(windows))
538 .map_err(|e| format!("agent '{}' [read_paths]: {e}", blueprint.name))?;
539 let grant_entries = config.read_path_grants_for_agent(&blueprint.name);
540 let grants =
541 leviath_core::ReadPathSet::compile(&grant_entries, workdir, home.as_deref(), cfg!(windows))
542 .map_err(|e| format!("read_paths grant in your config.toml: {e}"))?;
543 let allow_blueprint = config.security.allow_blueprint_read_paths;
544 let warning = (!allow_blueprint && grants.is_empty()).then(|| {
545 let entries = rp
546 .allow
547 .iter()
548 .map(|e| format!("\"{e}\""))
549 .collect::<Vec<_>>()
550 .join(", ");
551 format!(
552 "agent '{name}' declares [read_paths] but nothing grants them; reads outside \
553 the workdir will be refused. To grant them, add to your config.toml either:\n\
554 [security]\nallow_blueprint_read_paths = true\n\
555 or the specific paths:\n[agent_read_paths.{name}]\nallow = [{entries}]",
556 name = blueprint.name,
557 )
558 });
559 Ok((
560 leviath_core::ReadPathPolicy {
561 agent: blueprint.name.clone(),
562 blueprint: declared,
563 grants,
564 allow_blueprint,
565 },
566 warning,
567 ))
568}
569
570fn read_path_grant_counts(
575 blueprint: &leviath_core::Blueprint,
576 config: &crate::config::Config,
577 workdir: &std::path::Path,
578) -> Option<leviath_core::run_meta::ReadPathGrantCounts> {
579 let report = crate::read_path_report::build(blueprint, config, workdir)?.ok()?;
580 Some(leviath_core::run_meta::ReadPathGrantCounts {
581 declared: report.declared(),
582 granted: report.granted(),
583 })
584}
585
586fn bump_read_sensitivities(
591 map: &mut HashMap<String, leviath_core::TaintLevel>,
592 read_paths_granted: bool,
593) {
594 if !read_paths_granted {
595 return;
596 }
597 for tool in ["read_file", "read_files", "list_dir"] {
598 if let Some(level) = map.get_mut(tool) {
599 *level = (*level).max(leviath_core::TaintLevel::Private);
600 }
601 }
602}
603
604#[allow(clippy::too_many_arguments)]
614pub fn build_agent(
615 world: &mut World,
616 tool_service: &CliToolService,
617 config: &Config,
618 shared_mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
619 mcp_tool_defs: &[Tool],
620 hub: &InteractionHub,
621 args: &SpawnArgs,
622 now_secs: i64,
623 subagent_tx: UnboundedSender<SubAgentOp>,
624) -> Result<Entity, String> {
625 build_agent_inner(
626 world,
627 tool_service,
628 config,
629 shared_mcp,
630 mcp_tool_defs,
631 hub,
632 args,
633 now_secs,
634 subagent_tx,
635 true,
636 )
637}
638
639#[allow(clippy::too_many_arguments)]
643pub fn build_agent_for_reload(
644 world: &mut World,
645 tool_service: &CliToolService,
646 config: &Config,
647 shared_mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
648 mcp_tool_defs: &[Tool],
649 hub: &InteractionHub,
650 args: &SpawnArgs,
651 now_secs: i64,
652 subagent_tx: UnboundedSender<SubAgentOp>,
653) -> Result<Entity, String> {
654 build_agent_inner(
655 world,
656 tool_service,
657 config,
658 shared_mcp,
659 mcp_tool_defs,
660 hub,
661 args,
662 now_secs,
663 subagent_tx,
664 false,
665 )
666}
667
668fn log_blueprint_lint(content: &str, blueprint: &Blueprint, manifest_path: &str) {
677 let agent_dir = std::path::Path::new(manifest_path)
678 .parent()
679 .map(std::path::Path::to_path_buf)
680 .unwrap_or_default();
681 let env = crate::lint::LintEnv::offline(&agent_dir);
682 for finding in crate::lint::lint_manifest(content, blueprint, &env) {
683 if finding.severity == crate::lint::LintSeverity::Note {
686 continue;
687 }
688 let line = format!(
693 "blueprint '{}': {} [{}]",
694 blueprint.name,
695 finding.one_line(),
696 finding.code
697 );
698 tracing::warn!("{line}");
699 }
700}
701
702#[allow(clippy::too_many_arguments)]
703fn build_agent_inner(
704 world: &mut World,
705 tool_service: &CliToolService,
706 config: &Config,
707 shared_mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
708 mcp_tool_defs: &[Tool],
709 hub: &InteractionHub,
710 args: &SpawnArgs,
711 now_secs: i64,
712 subagent_tx: UnboundedSender<SubAgentOp>,
713 enforce_seeds: bool,
714) -> Result<Entity, String> {
715 if !std::fs::metadata(&args.workdir).is_ok_and(|m| m.is_dir()) {
720 return Err(format!(
721 "workspace '{}' does not exist or is not a directory",
722 args.workdir
723 ));
724 }
725
726 let content = std::fs::read_to_string(&args.blueprint_path)
728 .map_err(|e| format!("read manifest '{}': {e}", args.blueprint_path))?;
729 let mut blueprint = leviath_core::manifest::parse_manifest(&content)
730 .map_err(|e| format!("parse manifest: {e}"))?;
731 blueprint
732 .validate()
733 .map_err(|e| format!("invalid blueprint: {e}"))?;
734 log_blueprint_lint(&content, &blueprint, &args.blueprint_path);
740 if let Some(md) = args.max_depth {
742 blueprint.max_child_depth = Some(md);
743 }
744 if let Some(default_max) = config.limits.default_max_iterations {
749 for stage in &mut blueprint.stages {
750 match stage.max_iterations {
757 None | Some(0) => stage.max_iterations = Some(default_max),
758 Some(_) => {}
759 }
760 }
761 }
762
763 let entry_stage = blueprint
770 .entry_stage
771 .clone()
772 .or_else(|| blueprint.stages.first().map(|s| s.name.clone()))
773 .unwrap_or_default();
774 let entry_index = blueprint
775 .stages
776 .iter()
777 .position(|s| s.name == entry_stage)
778 .unwrap_or(0);
779 let stage_sandbox_by_index: Vec<leviath_core::ToolSandboxConfig> = blueprint
780 .stages
781 .iter()
782 .map(|s| {
783 leviath_core::resolve_sandbox(
784 config.sandbox.as_ref(),
785 blueprint.sandbox.as_ref(),
786 s.sandbox.as_ref(),
787 )
788 })
789 .collect();
790 let sandbox = crate::daemon::sandbox_manager::SandboxManager::build(
791 &args.run_id,
792 stage_sandbox_by_index,
793 &args.workdir,
794 entry_index,
795 )?
796 .map(Arc::new);
797
798 let (read_path_policy, read_path_warning) =
803 build_read_path_policy(&blueprint, config, std::path::Path::new(&args.workdir))?;
804 if let Some(warning) = &read_path_warning {
805 tracing::warn!(agent_name = %blueprint.name, "{warning}");
806 }
807 let read_paths_granted = read_path_policy.is_active()
810 && (read_path_policy.allow_blueprint || !read_path_policy.grants.is_empty());
811 let read_path_counts =
814 read_path_grant_counts(&blueprint, config, std::path::Path::new(&args.workdir));
815 let tool_ctx = leviath_tools::ToolContext::new(std::path::PathBuf::from(&args.workdir))
816 .with_read_paths(read_path_policy);
817 let mut builtins = leviath_tools::BuiltinTools::new(tool_ctx);
818 if let Some(mgr) = &sandbox {
819 builtins =
820 builtins.with_shell_executor(mgr.clone() as Arc<dyn leviath_tools::ShellExecutor>);
821 }
822 let builtins = Arc::new(builtins);
823 let builtin_names: HashSet<String> = builtins.names().into_iter().collect();
824 let mut all_tool_defs = builtins.tool_defs();
825 all_tool_defs.extend(leviath_tools::BuiltinTools::subagent_tool_defs());
826 all_tool_defs.extend(mcp_tool_defs.iter().cloned());
827 let static_tool_defs = all_tool_defs.clone();
831
832 let dynamic_tools = blueprint.dynamic_tools;
842 let workdir_tools_dir =
843 dynamic_tools.then(|| std::path::PathBuf::from(&args.workdir).join("tools"));
844 let (script_tools, script_tool_names, script_defs) = discover_script_tools(
845 &args.blueprint_path,
846 &builtin_names,
847 mcp_tool_defs,
848 workdir_tools_dir.clone(),
849 );
850 all_tool_defs.extend(script_defs);
851
852 let stages = {
854 let registry = &world
855 .get_resource::<Providers>()
856 .expect("Providers resource present in a PipelineWorld")
857 .0;
858 resolve_stages(
859 &blueprint,
860 args.model.as_deref(),
861 &model_defaults(config),
862 registry,
863 &all_tool_defs,
864 args.yolo,
865 )?
866 };
867
868 let agent_name = blueprint.name.clone();
870 let num_stages = blueprint.stages.len();
871 let compaction = blueprint.compaction_config.clone();
872 let max_child_depth = blueprint.max_child_depth.unwrap_or(DEFAULT_SUBAGENT_DEPTH);
873 let security = leviath_core::taint::resolve_security(
882 config.taint_tracking,
883 blueprint.security.as_ref(),
884 None,
885 );
886 let mcp_overrides = world
890 .get_resource::<leviath_runtime::pipeline::PolicyGate>()
891 .map(|p| p.0.mcp_overrides.clone())
892 .unwrap_or_default();
893 let tool_sensitivities: Option<HashMap<String, leviath_core::TaintLevel>> =
894 security.taint_tracking.then(|| {
895 let mut gate = leviath_runtime::TaintGate::new(security.clone());
896 gate.apply_mcp_overrides(&mcp_overrides);
897 let mut map: HashMap<String, leviath_core::TaintLevel> = all_tool_defs
898 .iter()
899 .map(|t| {
900 (
901 t.name.clone(),
902 gate.tool_classification(&t.name).sensitivity,
903 )
904 })
905 .collect();
906 bump_read_sensitivities(&mut map, read_paths_granted);
907 map
908 });
909 let stage_perms_by_index: Vec<HashMap<String, String>> = blueprint
912 .stages
913 .iter()
914 .map(|s| s.tool_permissions.clone())
915 .collect();
916 let agent_perms = blueprint.agent_tool_permissions();
921 let stage_available: Vec<Vec<String>> = blueprint
924 .stages
925 .iter()
926 .map(|s| s.available_tools.clone())
927 .collect();
928 let stage_required: Vec<Vec<String>> = blueprint
931 .stages
932 .iter()
933 .map(|s| s.required_tools.clone())
934 .collect();
935 let stage_required_by_index: Vec<HashSet<String>> = stage_required
939 .iter()
940 .map(|names| {
941 names
942 .iter()
943 .map(|n| leviath_tools::canonical_tool_name(n).to_string())
944 .collect()
945 })
946 .collect();
947 let model_label = stages
948 .first()
949 .map(|s| format!("{}/{}", s.provider_name, s.model));
950
951 let seeds = if enforce_seeds {
960 let policy = SeedCommandPolicy::new(
961 config.security.allow_seed_commands && !args.no_seed_commands,
962 std::time::Duration::from_secs(config.limits.script_shell_timeout_secs),
963 sandbox.clone(),
964 );
965 resolve_seeds(&blueprint, args, &args.workdir, &policy)?
966 } else {
967 HashMap::new()
968 };
969
970 let region_scripts = resolve_region_scripts(&blueprint, &args.blueprint_path)?;
975
976 let outcome_flags = leviath_runtime::persistence::RunOutcomeFlags::for_blueprint(&blueprint);
981
982 let entity = spawn_agent_seeded(
984 world,
985 args.run_id.clone(),
986 blueprint,
987 &seeds,
988 stages,
989 leviath_core::config::PromptHints {
990 batch_tool: config.batch_tool_hint,
991 shell: config.shell_hint,
992 },
993 config.nudge.clone(),
994 region_scripts,
995 )?;
996
997 let metadata = RunMetadata {
1000 run_id: args.run_id.clone(),
1001 agent_name: agent_name.clone(),
1002 agent_path: args.blueprint_path.clone(),
1003 task: args.task.clone(),
1004 model: model_label,
1005 workdir: args.workdir.clone(),
1006 num_stages,
1007 started_at: now_secs,
1008 parent_run_id: args.parent_run_id.clone(),
1009 metadata: args.metadata.clone(),
1010 callback_url: args.callback_url.clone(),
1011 callback_secret: args.callback_secret.clone(),
1012 title: None,
1013 unattended: args.yolo,
1014 read_paths: read_path_counts,
1015 };
1016 {
1017 let mut entity_mut = world.entity_mut(entity);
1018 entity_mut.insert((
1019 metadata,
1020 TokenTotals::default(),
1021 PersistWatermark::default(),
1022 outcome_flags,
1025 ));
1026 (config.title.enabled && !args.task.is_empty() && args.parent_run_id.is_none())
1032 .then_some(leviath_runtime::title::PendingTitle)
1033 .into_iter()
1034 .for_each(|marker| {
1035 entity_mut.insert(marker);
1036 });
1037 args.yolo
1042 .then_some(leviath_runtime::components::InteractionAutoApprove)
1043 .into_iter()
1044 .for_each(|marker| {
1045 entity_mut.insert(marker);
1046 });
1047 compaction.into_iter().for_each(|cc| {
1050 entity_mut.insert(CompactionSettings(cc));
1051 });
1052 tool_sensitivities.into_iter().for_each(|sensitivities| {
1056 let mut gate = leviath_runtime::TaintGate::new(security.clone());
1057 gate.apply_mcp_overrides(&mcp_overrides);
1058 entity_mut.insert((
1059 gate,
1060 leviath_runtime::pipeline::ToolSensitivities(sensitivities),
1061 ));
1062 if args.yolo {
1066 entity_mut.insert(leviath_runtime::components::GateAutoApprove);
1067 }
1068 entity_mut
1071 .get_mut::<leviath_runtime::components::ContextWindow>()
1072 .into_iter()
1073 .for_each(|mut window| window.enable_taint_tracking());
1074 });
1075 }
1076
1077 let mut launch_overrides: HashMap<String, crate::config::ToolPolicy> = HashMap::new();
1081 if args.yolo {
1082 launch_overrides.insert("*".to_string(), crate::config::ToolPolicy::Allow);
1083 }
1084 for tool in &args.allow {
1085 launch_overrides.insert(tool.clone(), crate::config::ToolPolicy::Allow);
1086 }
1087 let subagent = SubAgentHandle {
1088 sender: subagent_tx,
1089 parent_run_id: args.run_id.clone(),
1090 workdir: args.workdir.clone(),
1091 max_depth: max_child_depth,
1092 no_seed_commands: args.no_seed_commands,
1093 unattended: args.yolo,
1094 };
1095 let entry_stage_perms = stage_perms_by_index
1099 .get(entry_index)
1100 .cloned()
1101 .unwrap_or_default();
1102 let effective_script_perms = crate::daemon::script_host::effective_script_permissions(
1105 &config.tool_script_permissions,
1106 &content,
1107 );
1108 let agent_scoped_perms = config.permissions_for_agent(&agent_name);
1113 let script_allow = crate::daemon::script_host::resolve_script_permissions(
1114 &effective_script_perms,
1115 &|builtin| {
1116 crate::tools::resolve_policy(
1117 builtin,
1118 true,
1119 &launch_overrides,
1120 &entry_stage_perms,
1121 &agent_perms,
1122 &agent_scoped_perms,
1123 )
1124 },
1125 );
1126 let script_host: Arc<dyn leviath_scripting::ScriptHost> = Arc::new(
1127 crate::daemon::script_host::DaemonScriptHost::new(
1128 script_allow,
1129 std::path::PathBuf::from(&args.workdir),
1130 )
1131 .with_shell(
1135 sandbox.clone(),
1136 std::time::Duration::from_secs(config.limits.script_shell_timeout_secs),
1137 )
1138 .with_local_network(config.security.allow_local_network)
1142 .with_env_allowlist(config.security.allow_env_vars.clone()),
1145 );
1146 let dynamic = dynamic_tools.then(|| {
1149 world
1150 .entity_mut(entity)
1151 .insert(leviath_runtime::pipeline::DynamicTools);
1152 Arc::new(crate::daemon::tool_service::DynamicToolCtx {
1153 scan_dirs: script_scan_dirs(&args.blueprint_path, workdir_tools_dir),
1154 reserved_names: reserved_tool_names(&builtin_names, mcp_tool_defs),
1155 static_defs: static_tool_defs,
1156 stage_available,
1157 stage_required,
1158 unattended: args.yolo,
1159 dirty: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1160 })
1161 });
1162 let state = build_tool_state(
1163 builtins,
1164 builtin_names,
1165 shared_mcp,
1166 config,
1167 hub,
1168 &args.run_id,
1169 &entry_stage,
1170 entry_index,
1171 stage_perms_by_index,
1172 stage_required_by_index,
1173 agent_perms,
1174 &agent_name,
1175 launch_overrides,
1176 Some(subagent),
1177 sandbox,
1178 script_tools,
1179 script_tool_names,
1180 script_host,
1181 dynamic,
1182 args.yolo,
1183 );
1184 tool_service.register(entity, state);
1185
1186 Ok(entity)
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191 use super::*;
1192 use leviath_core::blueprint::ModelConfig;
1193 use leviath_runtime::ProviderRegistry;
1194 use leviath_runtime::world::PipelineWorld;
1195
1196 fn sub_tx() -> UnboundedSender<SubAgentOp> {
1198 tokio::sync::mpsc::unbounded_channel().0
1199 }
1200
1201 #[test]
1205 fn log_blueprint_lint_warns_about_findings_and_skips_notes() {
1206 crate::test_support::with_tracing(|| {});
1207 let home = tempfile::tempdir().unwrap();
1208 temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1209 let manifest = r#"
1212[agent]
1213name = "noisy"
1214version = "0.1.0"
1215
1216[stages.main]
1217model = { models = [{ provider = "anthropic", model = "claude-sonnet-5" }] }
1218available_tools = ["ask_user_text"]
1219
1220[read_paths]
1221allow = ["~/.leviath/runs"]
1222
1223[context.regions]
1224system = { kind = "pinned", max_tokens = 1000 }
1225"#;
1226 let bp = leviath_core::manifest::parse_manifest(manifest).unwrap();
1227 let dir = tempfile::tempdir().unwrap();
1228 let path = dir.path().join("agent.leviath");
1229 std::fs::write(&path, manifest).unwrap();
1230
1231 let env = crate::lint::LintEnv::offline(dir.path());
1234 let findings = crate::lint::lint_manifest(manifest, &bp, &env);
1235 assert!(
1236 findings
1237 .iter()
1238 .any(|f| f.severity == crate::lint::LintSeverity::Note),
1239 "the fixture needs a note for the skip arm to run"
1240 );
1241 assert!(
1242 findings
1243 .iter()
1244 .any(|f| f.severity == crate::lint::LintSeverity::Warning),
1245 "the fixture needs a warning for the log arm to run"
1246 );
1247
1248 log_blueprint_lint(manifest, &bp, &path.to_string_lossy());
1249 });
1250 }
1251
1252 #[test]
1254 fn log_blueprint_lint_is_silent_for_a_clean_blueprint() {
1255 crate::test_support::with_tracing(|| {});
1256 let home = tempfile::tempdir().unwrap();
1257 temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1258 let manifest = r#"
1259[agent]
1260name = "quiet"
1261version = "0.1.0"
1262
1263[stages.main]
1264mode = "autonomous"
1265model = { models = [{ provider = "anthropic", model = "claude-sonnet-5" }] }
1266max_iterations = 5
1267
1268[context.regions]
1269system = { kind = "pinned", max_tokens = 1000 }
1270"#;
1271 let bp = leviath_core::manifest::parse_manifest(manifest).unwrap();
1272 let dir = tempfile::tempdir().unwrap();
1273 let path = dir.path().join("agent.leviath");
1274 std::fs::write(&path, manifest).unwrap();
1275 let env = crate::lint::LintEnv::offline(dir.path());
1276 assert!(crate::lint::lint_manifest(manifest, &bp, &env).is_empty());
1277 log_blueprint_lint(manifest, &bp, &path.to_string_lossy());
1278 });
1279 }
1280
1281 #[test]
1282 fn fallback_order_parses_provider_slash_model_and_drops_junk() {
1283 crate::test_support::with_tracing(|| {
1286 let parsed = parse_fallback_order(&[
1287 "openrouter/deepseek/deepseek-v4-flash".to_string(),
1290 "anthropic/claude-sonnet-5".to_string(),
1291 "anthropic".to_string(),
1293 "/no-provider".to_string(),
1294 "no-model/".to_string(),
1295 String::new(),
1296 ]);
1297 assert_eq!(
1298 parsed
1299 .iter()
1300 .map(|e| (e.provider.as_str(), e.model.as_str()))
1301 .collect::<Vec<_>>(),
1302 vec![
1303 ("openrouter", "deepseek/deepseek-v4-flash"),
1304 ("anthropic", "claude-sonnet-5"),
1305 ]
1306 );
1307 });
1308 }
1309
1310 #[test]
1311 fn model_defaults_carries_the_fallback_chain_from_config() {
1312 let mut config = Config {
1313 default_provider: "openrouter".to_string(),
1314 default_model: Some("deepseek".to_string()),
1315 ..Default::default()
1316 };
1317 config.providers.fallback_order = vec!["anthropic/claude-sonnet-5".to_string()];
1318 let defaults = model_defaults(&config);
1319 assert_eq!(defaults.provider, "openrouter");
1320 assert_eq!(defaults.model.as_deref(), Some("deepseek"));
1321 assert_eq!(defaults.fallback_order.len(), 1);
1322 assert_eq!(defaults.fallback_order[0].provider, "anthropic");
1323 }
1324
1325 #[test]
1326 fn discover_script_tools_registers_and_drops_collisions() {
1327 crate::test_support::with_tracing(|| {});
1328 let home = tempfile::tempdir().unwrap();
1331 temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1332 let agent_dir = tempfile::tempdir().unwrap();
1333 let tools = agent_dir.path().join("tools");
1334 std::fs::create_dir(&tools).unwrap();
1335 std::fs::write(tools.join("echo.rhai"), "// @tool echo\nparams.x").unwrap();
1336 std::fs::write(tools.join("read_file.rhai"), "// @tool read_file\n1").unwrap();
1338 std::fs::write(tools.join("mcp_tool.rhai"), "// @tool mcp_tool\n1").unwrap();
1341 std::fs::write(tools.join("bad.rhai"), "no tool directive\nlet").unwrap();
1343 std::fs::write(
1347 tools.join("needs_gpu.rhai"),
1348 "// @tool needs_gpu\n// @requires gpu\n1",
1349 )
1350 .unwrap();
1351 std::fs::write(
1353 tools.join("net_tool.rhai"),
1354 "// @tool net_tool\n// @requires network\n1",
1355 )
1356 .unwrap();
1357 let blueprint = agent_dir.path().join("agent.leviath");
1358
1359 let builtins: HashSet<String> = ["read_file".to_string()].into_iter().collect();
1360 let mcp = vec![leviath_providers::Tool {
1361 name: "mcp_tool".to_string(),
1362 description: String::new(),
1363 parameters: serde_json::json!({}),
1364 }];
1365 let (set, names, defs) =
1366 discover_script_tools(blueprint.to_str().unwrap(), &builtins, &mcp, None);
1367 assert!(set.contains("echo") && set.contains("read_file"));
1370 assert!(names.contains("echo"));
1371 assert!(!names.contains("read_file"));
1372 assert!(!names.contains("mcp_tool"));
1373 assert!(!names.contains("needs_gpu"), "unsatisfiable cap dropped");
1374 assert!(names.contains("net_tool"), "satisfiable cap kept");
1375 let mut def_names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
1376 def_names.sort_unstable();
1377 assert_eq!(def_names, vec!["echo", "net_tool"]);
1378 });
1379 }
1380
1381 #[test]
1382 fn script_cap_maps_known_and_unknown_names() {
1383 use leviath_tools::ToolCapability::*;
1384 assert_eq!(script_cap("network"), Some(Network));
1385 assert_eq!(script_cap("http"), Some(Network));
1386 assert_eq!(script_cap("shell"), Some(ProcessSpawn));
1387 assert_eq!(script_cap("process_spawn"), Some(ProcessSpawn));
1388 assert_eq!(script_cap("filesystem"), Some(FileSystem));
1389 assert_eq!(script_cap("fs"), Some(FileSystem));
1390 assert_eq!(script_cap("gpu"), None);
1391 }
1392
1393 #[test]
1394 fn platform_satisfies_caps_gates_on_support() {
1395 use leviath_tools::{PlatformCapabilities, ToolCapability};
1396 let mobile = PlatformCapabilities::mobile();
1398 assert!(platform_satisfies_caps(&mobile, &[]));
1399 assert!(platform_satisfies_caps(&mobile, &["network".to_string()]));
1401 assert!(!platform_satisfies_caps(&mobile, &["shell".to_string()]));
1402 let desktop = PlatformCapabilities::from_capabilities([
1404 ToolCapability::Network,
1405 ToolCapability::FileSystem,
1406 ToolCapability::ProcessSpawn,
1407 ]);
1408 assert!(!platform_satisfies_caps(&desktop, &["mystery".to_string()]));
1409 }
1410
1411 #[test]
1412 fn discover_script_tools_empty_when_no_tools_dir() {
1413 let home = tempfile::tempdir().unwrap();
1414 temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1415 let agent_dir = tempfile::tempdir().unwrap();
1416 let blueprint = agent_dir.path().join("agent.leviath");
1417 let (set, names, defs) =
1418 discover_script_tools(blueprint.to_str().unwrap(), &HashSet::new(), &[], None);
1419 assert!(set.is_empty() && names.is_empty() && defs.is_empty());
1420 });
1421 }
1422
1423 #[test]
1424 fn discover_script_tools_handles_pathless_blueprint() {
1425 let home = tempfile::tempdir().unwrap();
1428 temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1429 let (set, _n, _d) = discover_script_tools("", &HashSet::new(), &[], None);
1430 assert!(set.is_empty());
1431 });
1432 }
1433 use leviath_core::blueprint::ModelEntry;
1434
1435 fn model_cfg(models: Vec<(&str, &str)>) -> ModelConfig {
1436 ModelConfig {
1437 models: models
1438 .into_iter()
1439 .map(|(p, m)| ModelEntry {
1440 provider: p.to_string(),
1441 model: m.to_string(),
1442 })
1443 .collect(),
1444 allow_user_default: true,
1445 parameters: HashMap::new(),
1446 request_timeout_secs: None,
1447 }
1448 }
1449
1450 fn registry_with(providers: &[&str]) -> ProviderRegistry {
1451 let mut r = ProviderRegistry::new();
1452 for p in providers {
1453 r.register(p.to_string(), Arc::new(FakeProvider));
1454 }
1455 r
1456 }
1457
1458 struct FakeProvider;
1459 #[async_trait::async_trait]
1460 impl leviath_providers::Provider for FakeProvider {
1461 async fn infer(
1462 &self,
1463 _r: leviath_providers::InferenceRequest,
1464 ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
1465 Err(leviath_providers::ProviderError::Other(
1466 "test provider".to_string(),
1467 ))
1468 }
1469 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
1470 1
1471 }
1472 fn max_context_tokens(&self, _m: &str) -> usize {
1473 1000
1474 }
1475 fn name(&self) -> &str {
1476 "fake"
1477 }
1478 fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
1479 leviath_providers::ModelCapabilities::default()
1480 }
1481 }
1482
1483 use leviath_providers::Provider;
1486 use leviath_runtime::components::AgentStatus;
1487 use leviath_runtime::inference_pool::InferencePoolConfig;
1488 use tokio::runtime::Handle;
1489
1490 fn coder_manifest() -> String {
1491 crate::test_support::inline_coder_manifest()
1494 }
1495
1496 fn test_world() -> (PipelineWorld, Arc<CliToolService>) {
1497 let cli = Arc::new(CliToolService::new());
1498 let world = PipelineWorld::new(
1499 registry_with(&["anthropic", "openai", "ollama"]),
1500 cli.clone(),
1501 InferencePoolConfig::new(),
1502 1,
1503 None,
1504 Handle::current(),
1505 );
1506 (world, cli)
1507 }
1508
1509 fn spawn_args(path: &str) -> SpawnArgs {
1510 SpawnArgs {
1511 run_id: "run-x".to_string(),
1512 blueprint_path: path.to_string(),
1513 task: "do the thing".to_string(),
1514 regions: HashMap::new(),
1515 model: None,
1516 workdir: std::env::temp_dir().to_string_lossy().to_string(),
1517 metadata: HashMap::new(),
1518 callback_url: None,
1519 callback_secret: None,
1520 yolo: false,
1521 no_seed_commands: false,
1522 allow: Vec::new(),
1523 max_depth: None,
1524 parent_run_id: None,
1525 }
1526 }
1527
1528 fn custom_region_manifest() -> &'static str {
1533 "[agent]\nname = \"cr\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1534 [context.regions.brain]\nkind = \"custom\"\nscript = \"hooks/brain.rhai\"\nmax_tokens = 4000\n\n\
1535 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\n\
1536 [stages.main.context.regions.stage_view]\nkind = \"custom\"\nscript = \"hooks/stage.rhai\"\nmax_tokens = 2000\n"
1537 }
1538
1539 #[test]
1540 fn resolve_region_scripts_empty_without_custom_regions() {
1541 let dir = tempfile::tempdir().unwrap();
1542 let manifest = dir.path().join("agent.leviath");
1543 let bp = leviath_core::manifest::parse_manifest(
1544 "[agent]\nname = \"plain\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1545 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1546 )
1547 .unwrap();
1548 let scripts = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1549 assert!(scripts.is_empty());
1550 }
1551
1552 #[test]
1553 fn resolve_region_scripts_collects_global_and_per_stage_layouts() {
1554 let dir = tempfile::tempdir().unwrap();
1555 let manifest = dir.path().join("agent.leviath");
1556 std::fs::create_dir(dir.path().join("hooks")).unwrap();
1557 std::fs::write(
1558 dir.path().join("hooks/brain.rhai"),
1559 "fn render(ctx) { \"b\" }",
1560 )
1561 .unwrap();
1562 std::fs::write(
1563 dir.path().join("hooks/stage.rhai"),
1564 "fn render(ctx) { \"s\" }",
1565 )
1566 .unwrap();
1567 let bp = leviath_core::manifest::parse_manifest(custom_region_manifest()).unwrap();
1568 let scripts = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1569 assert_eq!(scripts.len(), 2);
1570 assert!(scripts.contains_key("hooks/brain.rhai"));
1571 assert!(scripts.contains_key("hooks/stage.rhai"));
1572 }
1573
1574 #[test]
1575 fn resolve_region_scripts_reads_a_shared_path_once() {
1576 let dir = tempfile::tempdir().unwrap();
1578 let manifest = dir.path().join("agent.leviath");
1579 std::fs::create_dir(dir.path().join("hooks")).unwrap();
1580 std::fs::write(
1581 dir.path().join("hooks/shared.rhai"),
1582 "fn render(ctx) { \"x\" }",
1583 )
1584 .unwrap();
1585 let bp = leviath_core::manifest::parse_manifest(
1586 "[agent]\nname = \"cr\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1587 [context.regions.a]\nkind = \"custom\"\nscript = \"hooks/shared.rhai\"\nmax_tokens = 2000\n\n\
1588 [context.regions.b]\nkind = \"custom\"\nscript = \"hooks/shared.rhai\"\nmax_tokens = 2000\n\n\
1589 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1590 )
1591 .unwrap();
1592 let scripts = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1593 assert_eq!(scripts.len(), 1);
1594 }
1595
1596 #[test]
1597 fn resolve_region_scripts_missing_file_is_a_hard_error() {
1598 let dir = tempfile::tempdir().unwrap();
1599 let manifest = dir.path().join("agent.leviath");
1600 let bp = leviath_core::manifest::parse_manifest(custom_region_manifest()).unwrap();
1601 let err = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap_err();
1602 assert!(err.contains("region 'brain'"), "{err}");
1603 assert!(err.contains("hooks/brain.rhai"), "{err}");
1604 }
1605
1606 #[test]
1607 fn resolve_region_scripts_uncompilable_script_is_a_hard_error() {
1608 let dir = tempfile::tempdir().unwrap();
1609 let manifest = dir.path().join("agent.leviath");
1610 std::fs::create_dir(dir.path().join("hooks")).unwrap();
1611 std::fs::write(dir.path().join("hooks/brain.rhai"), "fn render(ctx) {").unwrap();
1612 std::fs::write(
1613 dir.path().join("hooks/stage.rhai"),
1614 "fn render(ctx) { \"s\" }",
1615 )
1616 .unwrap();
1617 let bp = leviath_core::manifest::parse_manifest(custom_region_manifest()).unwrap();
1618 let err = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap_err();
1619 assert!(err.contains("failed to compile"), "{err}");
1620 assert!(err.contains("region 'brain'"), "{err}");
1621 }
1622
1623 #[tokio::test]
1624 async fn build_agent_fails_fast_on_a_broken_custom_region_script() {
1625 let dir = tempfile::tempdir().unwrap();
1629 let manifest = dir.path().join("agent.leviath");
1630 std::fs::write(&manifest, custom_region_manifest()).unwrap();
1631
1632 let (mut world, cli) = test_world();
1633 let hub = InteractionHub::new();
1634 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1635 let args = spawn_args(&manifest.to_string_lossy());
1636 let err = build_agent(
1637 world.world_mut(),
1638 cli.as_ref(),
1639 &Config::default(),
1640 mcp,
1641 &[],
1642 &hub,
1643 &args,
1644 100,
1645 sub_tx(),
1646 )
1647 .unwrap_err();
1648 assert!(err.contains("region 'brain'"), "got: {err}");
1649 assert!(err.contains("hooks/brain.rhai"), "got: {err}");
1650 }
1651
1652 #[tokio::test]
1653 async fn build_agent_rejects_a_workdir_that_is_missing_or_not_a_directory() {
1654 let dir = tempfile::tempdir().unwrap();
1658 let manifest = dir.path().join("agent.leviath");
1659 std::fs::write(
1660 &manifest,
1661 "[agent]\nname = \"w\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1662 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1663 )
1664 .unwrap();
1665 let not_a_dir = dir.path().join("a-file");
1666 std::fs::write(¬_a_dir, "x").unwrap();
1667
1668 for workdir in [
1669 dir.path()
1670 .join("does-not-exist")
1671 .to_string_lossy()
1672 .to_string(),
1673 not_a_dir.to_string_lossy().to_string(),
1674 ] {
1675 let (mut world, cli) = test_world();
1676 let hub = InteractionHub::new();
1677 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1678 let mut args = spawn_args(&manifest.to_string_lossy());
1679 args.workdir = workdir.clone();
1680 let err = build_agent(
1681 world.world_mut(),
1682 cli.as_ref(),
1683 &Config::default(),
1684 mcp,
1685 &[],
1686 &hub,
1687 &args,
1688 100,
1689 sub_tx(),
1690 )
1691 .unwrap_err();
1692 assert!(err.contains("workspace"), "got: {err}");
1693 assert!(err.contains(&workdir), "got: {err}");
1694 }
1695 }
1696
1697 #[tokio::test]
1698 async fn build_agent_attaches_taint_gate_when_security_enabled() {
1699 let dir = tempfile::tempdir().unwrap();
1700 let manifest = dir.path().join("agent.leviath");
1701 std::fs::write(
1702 &manifest,
1703 "[agent]\nname = \"sec\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1704 [security]\ntaint_tracking = true\n\n\
1705 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1706 )
1707 .unwrap();
1708 let (mut world, cli) = test_world();
1709 let hub = InteractionHub::new();
1710 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1711 let entity = build_agent(
1712 world.world_mut(),
1713 cli.as_ref(),
1714 &Config::default(),
1715 mcp,
1716 &[],
1717 &hub,
1718 &spawn_args(&manifest.to_string_lossy()),
1719 100,
1720 sub_tx(),
1721 )
1722 .expect("spawn succeeds");
1723
1724 assert!(
1726 world
1727 .world()
1728 .get::<leviath_runtime::TaintGate>(entity)
1729 .is_some()
1730 );
1731 assert!(
1732 world
1733 .world()
1734 .get::<leviath_runtime::pipeline::ToolSensitivities>(entity)
1735 .is_some()
1736 );
1737 assert!(
1738 world
1739 .world()
1740 .get::<leviath_runtime::components::ContextWindow>(entity)
1741 .unwrap()
1742 .overall_taint()
1743 .is_some()
1744 );
1745 assert!(
1747 world
1748 .world()
1749 .get::<leviath_runtime::components::GateAutoApprove>(entity)
1750 .is_none()
1751 );
1752 }
1753
1754 #[tokio::test]
1755 async fn build_agent_marks_root_runs_for_titling_but_not_subagents() {
1756 let dir = tempfile::tempdir().unwrap();
1757 let manifest = dir.path().join("agent.leviath");
1758 std::fs::write(
1759 &manifest,
1760 "[agent]\nname = \"titler\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1761 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1762 )
1763 .unwrap();
1764 let (mut world, cli) = test_world();
1765 let hub = InteractionHub::new();
1766
1767 let root = build_agent(
1769 world.world_mut(),
1770 cli.as_ref(),
1771 &Config::default(),
1772 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1773 &[],
1774 &hub,
1775 &spawn_args(&manifest.to_string_lossy()),
1776 100,
1777 sub_tx(),
1778 )
1779 .expect("spawn succeeds");
1780 assert!(
1781 world
1782 .world()
1783 .get::<leviath_runtime::title::PendingTitle>(root)
1784 .is_some()
1785 );
1786
1787 let mut child_args = spawn_args(&manifest.to_string_lossy());
1789 child_args.run_id = "run-child".to_string();
1790 child_args.parent_run_id = Some("run-x".to_string());
1791 let child = build_agent(
1792 world.world_mut(),
1793 cli.as_ref(),
1794 &Config::default(),
1795 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1796 &[],
1797 &hub,
1798 &child_args,
1799 100,
1800 sub_tx(),
1801 )
1802 .expect("spawn succeeds");
1803 assert!(
1804 world
1805 .world()
1806 .get::<leviath_runtime::title::PendingTitle>(child)
1807 .is_none()
1808 );
1809
1810 let config = Config {
1812 title: leviath_core::config::TitleConfig {
1813 enabled: false,
1814 provider: None,
1815 model: None,
1816 },
1817 ..Config::default()
1818 };
1819 let mut off_args = spawn_args(&manifest.to_string_lossy());
1820 off_args.run_id = "run-off".to_string();
1821 let off = build_agent(
1822 world.world_mut(),
1823 cli.as_ref(),
1824 &config,
1825 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1826 &[],
1827 &hub,
1828 &off_args,
1829 100,
1830 sub_tx(),
1831 )
1832 .expect("spawn succeeds");
1833 assert!(
1834 world
1835 .world()
1836 .get::<leviath_runtime::title::PendingTitle>(off)
1837 .is_none()
1838 );
1839 }
1840
1841 #[tokio::test]
1842 async fn build_agent_applies_policy_mcp_overrides_to_the_gate() {
1843 let dir = tempfile::tempdir().unwrap();
1844 let manifest = dir.path().join("agent.leviath");
1845 std::fs::write(
1846 &manifest,
1847 "[agent]\nname = \"sec-ov\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1848 [security]\ntaint_tracking = true\n\n\
1849 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1850 )
1851 .unwrap();
1852 let (mut world, cli) = test_world();
1853 world
1857 .world_mut()
1858 .insert_resource(leviath_runtime::pipeline::PolicyGate(
1859 leviath_core::PolicyConfig {
1860 allowlist: Vec::new(),
1861 mcp_overrides: HashMap::from([(
1862 "notes.share".to_string(),
1863 leviath_core::policy::McpToolOverride {
1864 sensitivity: None,
1865 direction: Some("outbound".to_string()),
1866 clearance: Some(leviath_core::TaintLevel::Private),
1867 },
1868 )]),
1869 },
1870 ));
1871 let hub = InteractionHub::new();
1872 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1873 let entity = build_agent(
1874 world.world_mut(),
1875 cli.as_ref(),
1876 &Config::default(),
1877 mcp,
1878 &[],
1879 &hub,
1880 &spawn_args(&manifest.to_string_lossy()),
1881 100,
1882 sub_tx(),
1883 )
1884 .expect("spawn succeeds");
1885
1886 let gate = world
1887 .world()
1888 .get::<leviath_runtime::TaintGate>(entity)
1889 .expect("gate attached");
1890 let classification = gate.tool_classification("notes.share");
1891 assert_eq!(
1892 classification.direction,
1893 leviath_core::taint::ToolDirection::Outbound
1894 );
1895 assert_eq!(classification.clearance, leviath_core::TaintLevel::Private);
1896 }
1897
1898 #[tokio::test]
1899 async fn build_agent_errors_when_required_caller_region_missing() {
1900 let dir = tempfile::tempdir().unwrap();
1903 let manifest = dir.path().join("agent.leviath");
1904 std::fs::write(
1905 &manifest,
1906 "[agent]\nname = \"needs\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1907 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\n\
1908 [context.regions]\n\
1909 spec = { kind = \"pinned\", max_tokens = 2000, seed = \"input\", required = true }\n\
1910 conversation = { kind = \"sliding_window\", max_items = 20, max_tokens = 10000 }\n",
1911 )
1912 .unwrap();
1913 let (mut world, cli) = test_world();
1914 let hub = InteractionHub::new();
1915 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1916 let err = build_agent(
1918 world.world_mut(),
1919 cli.as_ref(),
1920 &Config::default(),
1921 mcp,
1922 &[],
1923 &hub,
1924 &spawn_args(&manifest.to_string_lossy()),
1925 100,
1926 sub_tx(),
1927 )
1928 .unwrap_err();
1929 assert!(err.contains("spec"), "got: {err}");
1930 }
1931
1932 #[tokio::test]
1933 async fn build_agent_attaches_sandbox_when_configured() {
1934 let dir = tempfile::tempdir().unwrap();
1938 let manifest = dir.path().join("agent.leviath");
1939 std::fs::write(
1940 &manifest,
1941 "[agent]\nname = \"sb\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1942 [sandbox]\nkind = \"namespace\"\non_unavailable = \"warn\"\n\n\
1943 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1944 )
1945 .unwrap();
1946 let (mut world, cli) = test_world();
1947 let hub = InteractionHub::new();
1948 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1949 let entity = build_agent(
1950 world.world_mut(),
1951 cli.as_ref(),
1952 &Config::default(),
1953 mcp,
1954 &[],
1955 &hub,
1956 &spawn_args(&manifest.to_string_lossy()),
1957 100,
1958 sub_tx(),
1959 )
1960 .expect("spawn succeeds");
1961 let state = cli.take(entity).expect("state registered");
1963 assert!(state.sandbox.is_some(), "sandbox manager attached");
1964 }
1965
1966 #[tokio::test]
1967 async fn build_agent_errors_when_sandbox_runtime_unavailable() {
1968 let dir = tempfile::tempdir().unwrap();
1973 let manifest = dir.path().join("agent.leviath");
1974 std::fs::write(
1975 &manifest,
1976 "[agent]\nname = \"sb\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1977 [sandbox]\nkind = \"container\"\nimage = \"x\"\nengine = \"leviath-no-such-engine\"\n\n\
1978 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1979 )
1980 .unwrap();
1981 let (mut world, cli) = test_world();
1982 let hub = InteractionHub::new();
1983 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1984 let err = build_agent(
1985 world.world_mut(),
1986 cli.as_ref(),
1987 &Config::default(),
1988 mcp,
1989 &[],
1990 &hub,
1991 &spawn_args(&manifest.to_string_lossy()),
1992 100,
1993 sub_tx(),
1994 )
1995 .expect_err("a nonexistent engine can't start the container");
1996 assert!(err.contains("sandbox unavailable"), "got: {err}");
1997 }
1998
1999 #[tokio::test]
2000 async fn build_agent_yolo_attaches_gate_auto_approve_when_taint_on() {
2001 let dir = tempfile::tempdir().unwrap();
2002 let manifest = dir.path().join("agent.leviath");
2003 std::fs::write(
2004 &manifest,
2005 "[agent]\nname = \"sec\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2006 [security]\ntaint_tracking = true\n\n\
2007 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2008 )
2009 .unwrap();
2010 let (mut world, cli) = test_world();
2011 let hub = InteractionHub::new();
2012 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2013 let mut args = spawn_args(&manifest.to_string_lossy());
2014 args.yolo = true;
2015 let entity = build_agent(
2016 world.world_mut(),
2017 cli.as_ref(),
2018 &Config::default(),
2019 mcp,
2020 &[],
2021 &hub,
2022 &args,
2023 100,
2024 sub_tx(),
2025 )
2026 .expect("spawn succeeds");
2027 assert!(
2030 world
2031 .world()
2032 .get::<leviath_runtime::components::GateAutoApprove>(entity)
2033 .is_some()
2034 );
2035 assert!(
2038 world
2039 .world()
2040 .get::<leviath_runtime::components::InteractionAutoApprove>(entity)
2041 .is_some()
2042 );
2043 assert!(cli.take(entity).expect("tool state registered").unattended);
2044 assert!(
2047 world
2048 .world()
2049 .get::<RunMetadata>(entity)
2050 .expect("run metadata attached")
2051 .unattended
2052 );
2053 }
2054
2055 #[tokio::test]
2058 async fn build_agent_yolo_leaves_the_run_active_and_unattended() {
2059 let dir = tempfile::tempdir().unwrap();
2060 let manifest = dir.path().join("agent.leviath");
2061 std::fs::write(
2062 &manifest,
2063 "[agent]\nname = \"a\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2064 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2065 )
2066 .unwrap();
2067 let (mut world, cli) = test_world();
2068 let mut args = spawn_args(&manifest.to_string_lossy());
2069 args.yolo = true;
2070 let entity = build_agent(
2071 world.world_mut(),
2072 cli.as_ref(),
2073 &Config::default(),
2074 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
2075 &[],
2076 &InteractionHub::new(),
2077 &args,
2078 100,
2079 sub_tx(),
2080 )
2081 .expect("spawn succeeds");
2082
2083 assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
2084 let meta = world
2085 .world()
2086 .get::<RunMetadata>(entity)
2087 .expect("run metadata attached");
2088 assert!(meta.unattended);
2089 }
2090
2091 #[tokio::test]
2096 async fn build_agent_carries_required_tools_into_the_tool_state() {
2097 let dir = tempfile::tempdir().unwrap();
2098 let manifest = dir.path().join("agent.leviath");
2099 std::fs::write(
2100 &manifest,
2101 "[agent]\nname = \"asks\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2102 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\
2103 available_tools = [\"read_file\", \"ask_user_text\"]\n\
2104 required_tools = [\"ask_user_text\"]\n",
2105 )
2106 .unwrap();
2107 let (mut world, cli) = test_world();
2108 let mut args = spawn_args(&manifest.to_string_lossy());
2109 args.yolo = true;
2110 let entity = build_agent(
2111 world.world_mut(),
2112 cli.as_ref(),
2113 &Config::default(),
2114 Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
2115 &[],
2116 &InteractionHub::new(),
2117 &args,
2118 100,
2119 sub_tx(),
2120 )
2121 .expect("spawn succeeds");
2122
2123 let state = cli.take(entity).expect("tool state registered");
2124 assert!(
2125 state
2126 .stage_required
2127 .lock()
2128 .unwrap()
2129 .contains("ask_user_text")
2130 );
2131 assert_eq!(state.stage_required_by_index.len(), 1);
2132 }
2133
2134 #[tokio::test]
2135 async fn build_agent_without_yolo_keeps_prompts_interactive() {
2136 let dir = tempfile::tempdir().unwrap();
2137 let manifest = dir.path().join("agent.leviath");
2138 std::fs::write(
2139 &manifest,
2140 "[agent]\nname = \"plain\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2141 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2142 )
2143 .unwrap();
2144 let (mut world, cli) = test_world();
2145 let hub = InteractionHub::new();
2146 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2147 let entity = build_agent(
2148 world.world_mut(),
2149 cli.as_ref(),
2150 &Config::default(),
2151 mcp,
2152 &[],
2153 &hub,
2154 &spawn_args(&manifest.to_string_lossy()),
2155 100,
2156 sub_tx(),
2157 )
2158 .expect("spawn succeeds");
2159 assert!(
2160 world
2161 .world()
2162 .get::<leviath_runtime::components::InteractionAutoApprove>(entity)
2163 .is_none()
2164 );
2165 assert!(!cli.take(entity).expect("tool state registered").unattended);
2166 }
2167
2168 #[tokio::test]
2169 async fn build_agent_no_security_block_leaves_taint_off_by_default() {
2170 let dir = tempfile::tempdir().unwrap();
2175 let manifest = dir.path().join("agent.leviath");
2176 std::fs::write(
2177 &manifest,
2178 "[agent]\nname = \"plain\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2179 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2180 )
2181 .unwrap();
2182 let (mut world, cli) = test_world();
2183 let hub = InteractionHub::new();
2184 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2185 let entity = build_agent(
2186 world.world_mut(),
2187 cli.as_ref(),
2188 &Config::default(), mcp,
2190 &[],
2191 &hub,
2192 &spawn_args(&manifest.to_string_lossy()),
2193 100,
2194 sub_tx(),
2195 )
2196 .expect("spawn succeeds");
2197 assert!(
2198 world
2199 .world()
2200 .get::<leviath_runtime::TaintGate>(entity)
2201 .is_none(),
2202 "no [security] block + global off ⇒ no taint gate"
2203 );
2204 }
2205
2206 async fn spawned_no_output_tools(manifest_body: &str) -> bool {
2208 let dir = tempfile::tempdir().unwrap();
2209 let manifest = dir.path().join("agent.leviath");
2210 std::fs::write(&manifest, manifest_body).unwrap();
2211 let (mut world, cli) = test_world();
2212 let hub = InteractionHub::new();
2213 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2214 let entity = build_agent(
2215 world.world_mut(),
2216 cli.as_ref(),
2217 &Config::default(),
2218 mcp,
2219 &[],
2220 &hub,
2221 &spawn_args(&manifest.to_string_lossy()),
2222 100,
2223 sub_tx(),
2224 )
2225 .expect("spawn succeeds");
2226 world
2227 .world()
2228 .get::<leviath_runtime::persistence::RunOutcomeFlags>(entity)
2229 .expect("build_agent attaches run outcome flags")
2230 .0
2231 .no_output_tools
2232 }
2233
2234 #[tokio::test]
2235 async fn build_agent_records_whether_the_blueprint_can_write_at_all() {
2236 assert!(!spawned_no_output_tools(&coder_manifest()).await);
2239 assert!(
2243 spawned_no_output_tools(
2244 "[agent]\nname = \"router\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2245 [stages.triage]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\
2246 available_tools = [\"read_file\", \"spawn_agent\"]\n",
2247 )
2248 .await
2249 );
2250 }
2251
2252 #[tokio::test]
2253 async fn build_agent_spawns_registers_and_wires_tools() {
2254 let dir = tempfile::tempdir().unwrap();
2255 let manifest = dir.path().join("agent.leviath");
2256 std::fs::write(&manifest, coder_manifest()).unwrap();
2257
2258 let (mut world, cli) = test_world();
2259 let hub = InteractionHub::new();
2260 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2261 let entity = build_agent(
2262 world.world_mut(),
2263 cli.as_ref(),
2264 &Config::default(),
2265 mcp,
2266 &[],
2267 &hub,
2268 &spawn_args(&manifest.to_string_lossy()),
2269 100,
2270 sub_tx(),
2271 )
2272 .expect("spawn succeeds");
2273
2274 assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
2275 let md = world
2277 .world()
2278 .get::<RunMetadata>(entity)
2279 .expect("run metadata");
2280 assert_eq!(md.run_id, "run-x");
2281 assert_eq!(md.agent_name, "coder");
2282 let out = leviath_runtime::pipeline::ToolService::exec_for(
2284 cli.as_ref(),
2285 entity,
2286 vec![leviath_providers::ToolCall {
2287 id: "c1".to_string(),
2288 name: "list_dir".to_string(),
2289 arguments: serde_json::json!({"path": "."}),
2290 thought_signature: None,
2291 }],
2292 leviath_runtime::pipeline::noop_progress(),
2293 )()
2294 .await;
2295 assert_eq!(out[0].0, "c1");
2296 assert!(!out[0].1.contains("no tool state"));
2297 }
2298
2299 #[tokio::test]
2300 async fn build_agent_tags_dynamic_tools_agent() {
2301 let dir = tempfile::tempdir().unwrap();
2305 let manifest = dir.path().join("agent.leviath");
2306 std::fs::write(
2307 &manifest,
2308 coder_manifest().replace("[agent]", "[agent]\ndynamic_tools = true"),
2309 )
2310 .unwrap();
2311
2312 let (mut world, cli) = test_world();
2313 let hub = InteractionHub::new();
2314 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2315 let entity = build_agent(
2316 world.world_mut(),
2317 cli.as_ref(),
2318 &Config::default(),
2319 mcp,
2320 &[],
2321 &hub,
2322 &spawn_args(&manifest.to_string_lossy()),
2323 100,
2324 sub_tx(),
2325 )
2326 .expect("spawn succeeds");
2327
2328 assert!(
2329 world
2330 .world()
2331 .get::<leviath_runtime::pipeline::DynamicTools>(entity)
2332 .is_some(),
2333 "dynamic_tools agent must carry the DynamicTools marker"
2334 );
2335 assert!(
2337 leviath_runtime::pipeline::ToolService::refresh_tools(cli.as_ref(), entity, 0)
2338 .is_some()
2339 );
2340 }
2341
2342 #[tokio::test]
2343 async fn build_agent_applies_yolo_allow_and_max_depth() {
2344 let dir = tempfile::tempdir().unwrap();
2345 let manifest = dir.path().join("agent.leviath");
2346 std::fs::write(&manifest, coder_manifest()).unwrap();
2347
2348 let (mut world, cli) = test_world();
2349 let hub = InteractionHub::new();
2350 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2351 let config = Config {
2355 tool_permissions: HashMap::from([(
2356 "read_file".to_string(),
2357 crate::config::ToolPolicy::Deny,
2358 )]),
2359 ..Default::default()
2360 };
2361 let mut args = spawn_args(&manifest.to_string_lossy());
2362 args.yolo = true;
2363 args.allow = vec!["read_file".to_string()];
2364 args.max_depth = Some(7);
2365
2366 let entity = build_agent(
2367 world.world_mut(),
2368 cli.as_ref(),
2369 &config,
2370 mcp,
2371 &[],
2372 &hub,
2373 &args,
2374 100,
2375 sub_tx(),
2376 )
2377 .expect("spawn succeeds");
2378 assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
2379
2380 let out = leviath_runtime::pipeline::ToolService::exec_for(
2382 cli.as_ref(),
2383 entity,
2384 vec![leviath_providers::ToolCall {
2385 id: "c1".to_string(),
2386 name: "read_file".to_string(),
2387 arguments: serde_json::json!({"path": "/no/such/file"}),
2388 thought_signature: None,
2389 }],
2390 leviath_runtime::pipeline::noop_progress(),
2391 )()
2392 .await;
2393 let result = out[0].1.clone();
2394 assert!(
2395 result.contains("[denied]"),
2396 "a configured deny must survive --yolo, got: {result}"
2397 );
2398
2399 let out = leviath_runtime::pipeline::ToolService::exec_for(
2402 cli.as_ref(),
2403 entity,
2404 vec![leviath_providers::ToolCall {
2405 id: "c2".to_string(),
2406 name: "list_dir".to_string(),
2407 arguments: serde_json::json!({"path": "."}),
2408 thought_signature: None,
2409 }],
2410 leviath_runtime::pipeline::noop_progress(),
2411 )()
2412 .await;
2413 let result = out[0].1.clone();
2414 assert!(
2415 !result.contains("[denied]"),
2416 "--yolo must still waive approval where nothing denies, got: {result}"
2417 );
2418 }
2419
2420 #[tokio::test]
2421 async fn build_agent_honors_agent_level_tool_permissions() {
2422 let dir = tempfile::tempdir().unwrap();
2423 let manifest = dir.path().join("agent.leviath");
2424 std::fs::write(
2428 &manifest,
2429 "[agent]\nname = \"perm\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2430 [tool_permissions]\nread_file = \"deny\"\n\n\
2431 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2432 )
2433 .unwrap();
2434
2435 let (mut world, cli) = test_world();
2436 let hub = InteractionHub::new();
2437 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2438 let entity = build_agent(
2439 world.world_mut(),
2440 cli.as_ref(),
2441 &Config::default(),
2442 mcp,
2443 &[],
2444 &hub,
2445 &spawn_args(&manifest.to_string_lossy()),
2446 100,
2447 sub_tx(),
2448 )
2449 .expect("spawn succeeds");
2450
2451 let out = leviath_runtime::pipeline::ToolService::exec_for(
2452 cli.as_ref(),
2453 entity,
2454 vec![leviath_providers::ToolCall {
2455 id: "c1".to_string(),
2456 name: "read_file".to_string(),
2457 arguments: serde_json::json!({"path": "/no/such/file"}),
2458 thought_signature: None,
2459 }],
2460 leviath_runtime::pipeline::noop_progress(),
2461 )()
2462 .await;
2463 assert!(
2464 out[0].1.contains("[denied]"),
2465 "agent-level deny should block read_file"
2466 );
2467 }
2468
2469 #[tokio::test]
2470 async fn build_agent_script_host_honors_agent_level_grants() {
2471 let dir = tempfile::tempdir().unwrap();
2472 let manifest = dir.path().join("agent.leviath");
2473 std::fs::write(
2474 &manifest,
2475 "[agent]\nname = \"scriptperm\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2476 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2477 )
2478 .unwrap();
2479
2480 let mut config = Config::default();
2486 config.agent_tool_permissions.insert(
2487 "scriptperm".to_string(),
2488 HashMap::from([("write_file".to_string(), crate::config::ToolPolicy::Allow)]),
2489 );
2490
2491 let (mut world, cli) = test_world();
2492 let hub = InteractionHub::new();
2493 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2494 let mut args = spawn_args(&manifest.to_string_lossy());
2495 args.workdir = dir.path().to_string_lossy().to_string();
2496 let entity = build_agent(
2497 world.world_mut(),
2498 cli.as_ref(),
2499 &config,
2500 mcp,
2501 &[],
2502 &hub,
2503 &args,
2504 100,
2505 sub_tx(),
2506 )
2507 .expect("spawn succeeds");
2508
2509 let state = cli.take(entity).expect("tool state registered at spawn");
2510 state
2511 .script_host
2512 .write_file("granted.txt", "ok")
2513 .expect("agent-level write_file grant must reach the script host");
2514 assert_eq!(
2515 std::fs::read_to_string(dir.path().join("granted.txt")).unwrap(),
2516 "ok"
2517 );
2518 }
2519
2520 #[tokio::test]
2521 async fn build_agent_applies_default_max_iterations_only_when_stage_omits_it() {
2522 let dir = tempfile::tempdir().unwrap();
2523 let manifest = dir.path().join("agent.leviath");
2524 std::fs::write(
2526 &manifest,
2527 "[agent]\nname = \"iters\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2528 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\n\
2529 [stages.capped]\nmax_iterations = 3\n\
2530 model = { provider = \"anthropic\", model = \"m\" }\n",
2531 )
2532 .unwrap();
2533
2534 let (mut world, cli) = test_world();
2535 let hub = InteractionHub::new();
2536 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2537 let config = Config {
2539 limits: crate::config::LimitsConfig {
2540 default_max_iterations: Some(42),
2541 ..Default::default()
2542 },
2543 ..Default::default()
2544 };
2545 let entity = build_agent(
2546 world.world_mut(),
2547 cli.as_ref(),
2548 &config,
2549 mcp,
2550 &[],
2551 &hub,
2552 &spawn_args(&manifest.to_string_lossy()),
2553 100,
2554 sub_tx(),
2555 )
2556 .expect("spawn succeeds");
2557
2558 let bp = world
2559 .world()
2560 .get::<leviath_runtime::pipeline::AgentBlueprint>(entity)
2561 .expect("blueprint");
2562 let by_name = |n: &str| {
2563 bp.0.stages
2564 .iter()
2565 .find(|s| s.name == n)
2566 .unwrap()
2567 .max_iterations
2568 };
2569 assert_eq!(by_name("main"), Some(42));
2571 assert_eq!(by_name("capped"), Some(3));
2573 }
2574
2575 #[tokio::test]
2576 async fn build_agent_leaves_max_iterations_unset_when_config_default_is_none() {
2577 let dir = tempfile::tempdir().unwrap();
2578 let manifest = dir.path().join("agent.leviath");
2579 std::fs::write(
2580 &manifest,
2581 "[agent]\nname = \"nolimit\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2582 [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2583 )
2584 .unwrap();
2585
2586 let (mut world, cli) = test_world();
2587 let hub = InteractionHub::new();
2588 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2589 let config = Config {
2591 limits: crate::config::LimitsConfig {
2592 default_max_iterations: None,
2593 ..Default::default()
2594 },
2595 ..Default::default()
2596 };
2597 let entity = build_agent(
2598 world.world_mut(),
2599 cli.as_ref(),
2600 &config,
2601 mcp,
2602 &[],
2603 &hub,
2604 &spawn_args(&manifest.to_string_lossy()),
2605 100,
2606 sub_tx(),
2607 )
2608 .expect("spawn succeeds");
2609
2610 let bp = world
2611 .world()
2612 .get::<leviath_runtime::pipeline::AgentBlueprint>(entity)
2613 .expect("blueprint");
2614 assert_eq!(bp.0.stages[0].max_iterations, None);
2615 }
2616
2617 #[tokio::test]
2618 async fn fake_provider_methods_are_exercised() {
2619 let p = FakeProvider;
2620 assert_eq!(p.name(), "fake");
2621 assert_eq!(p.count_tokens("t", "m").await, 1);
2622 assert_eq!(p.max_context_tokens("m"), 1000);
2623 let _ = p.capabilities("m");
2624 assert!(
2625 p.infer(leviath_providers::InferenceRequest {
2626 system: vec![],
2627 messages: vec![],
2628 model: "m".to_string(),
2629 max_tokens: 1,
2630 temperature: 0.0,
2631 tools: vec![],
2632 extra: serde_json::Value::Null,
2633 request_timeout_secs: None,
2634 })
2635 .await
2636 .is_err()
2637 );
2638 }
2639
2640 use std::path::Path;
2643
2644 fn blueprint_declaring(read_paths: &[&str]) -> Blueprint {
2645 let stage = leviath_core::Stage::new("s".to_string(), model_cfg(vec![("anthropic", "m")]));
2646 let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
2647 let mut bp = Blueprint::new("cto".to_string(), "d".to_string(), vec![stage], layout);
2648 if !read_paths.is_empty() {
2649 bp.read_paths = Some(leviath_core::ReadPathsConfig {
2650 allow: read_paths.iter().map(|s| s.to_string()).collect(),
2651 });
2652 }
2653 bp
2654 }
2655
2656 #[test]
2660 fn read_path_grant_counts_are_recorded_for_a_declaring_blueprint() {
2661 let bp = blueprint_declaring(&["/data/runs", "/data/docs"]);
2662 let mut config = Config::default();
2663 config.security.read_paths = vec!["/data/runs".to_string()];
2664 let counts = read_path_grant_counts(&bp, &config, Path::new("/w")).expect("declares paths");
2665 assert_eq!(counts.declared, 2);
2666 assert_eq!(counts.granted, 1);
2667
2668 assert!(
2669 read_path_grant_counts(&blueprint_declaring(&[]), &config, Path::new("/w")).is_none()
2670 );
2671
2672 let mut broken = Config::default();
2673 broken.security.read_paths = vec!["regex:relative/.*".to_string()];
2674 assert!(read_path_grant_counts(&bp, &broken, Path::new("/w")).is_none());
2675 }
2676
2677 #[test]
2678 fn read_path_policy_is_inactive_without_declarations() {
2679 let bp = blueprint_declaring(&[]);
2680 let (policy, warning) =
2681 build_read_path_policy(&bp, &Config::default(), Path::new("/w")).unwrap();
2682 assert!(!policy.is_active());
2683 assert!(warning.is_none());
2684
2685 let mut bp = blueprint_declaring(&[]);
2687 bp.read_paths = Some(leviath_core::ReadPathsConfig { allow: vec![] });
2688 let (policy, warning) =
2689 build_read_path_policy(&bp, &Config::default(), Path::new("/w")).unwrap();
2690 assert!(!policy.is_active());
2691 assert!(warning.is_none());
2692 }
2693
2694 #[test]
2697 fn read_path_policy_warns_when_nothing_grants() {
2698 let bp = blueprint_declaring(&["/data/runs", "glob:/data/docs/**"]);
2699 let (policy, warning) =
2700 build_read_path_policy(&bp, &Config::default(), Path::new("/w")).unwrap();
2701 assert!(policy.is_active());
2702 assert!(!policy.allow_blueprint);
2703 assert!(policy.grants.is_empty());
2704 let warning = warning.expect("ungranted declarations must warn");
2705 assert!(warning.contains("allow_blueprint_read_paths"), "{warning}");
2706 assert!(warning.contains("[agent_read_paths.cto]"), "{warning}");
2707 assert!(warning.contains("\"/data/runs\""), "{warning}");
2708 assert!(warning.contains("\"glob:/data/docs/**\""), "{warning}");
2709 }
2710
2711 #[test]
2712 fn read_path_policy_is_quiet_when_granted() {
2713 let bp = blueprint_declaring(&["/data/runs"]);
2714 let mut config = Config::default();
2715 config.agent_read_paths.insert(
2716 "cto".to_string(),
2717 crate::config::ReadPathGrants {
2718 allow: vec!["/data/runs".to_string()],
2719 },
2720 );
2721 let (policy, warning) = build_read_path_policy(&bp, &config, Path::new("/w")).unwrap();
2722 assert!(policy.is_active());
2723 assert!(!policy.grants.is_empty());
2724 assert!(warning.is_none());
2725 }
2726
2727 #[test]
2728 fn read_path_policy_is_quiet_under_the_override() {
2729 let bp = blueprint_declaring(&["/data/runs"]);
2730 let mut config = Config::default();
2731 config.security.allow_blueprint_read_paths = true;
2732 let (policy, warning) = build_read_path_policy(&bp, &config, Path::new("/w")).unwrap();
2733 assert!(policy.allow_blueprint);
2734 assert!(warning.is_none());
2735 }
2736
2737 #[test]
2740 fn read_path_policy_rejects_bad_entries_loudly() {
2741 let bp = blueprint_declaring(&["glob:["]);
2742 let err = build_read_path_policy(&bp, &Config::default(), Path::new("/w")).unwrap_err();
2743 assert!(err.contains("agent 'cto' [read_paths]"), "{err}");
2744
2745 let bp = blueprint_declaring(&["/data/runs"]);
2746 let mut config = Config::default();
2747 config.security.read_paths = vec!["regex:(".to_string()];
2748 let err = build_read_path_policy(&bp, &config, Path::new("/w")).unwrap_err();
2749 assert!(err.contains("config.toml"), "{err}");
2750 }
2751
2752 #[test]
2755 fn read_sensitivities_bump_only_the_read_tools_when_granted() {
2756 use leviath_core::TaintLevel;
2757 let base = || {
2758 HashMap::from([
2759 ("read_file".to_string(), TaintLevel::Internal),
2760 ("list_dir".to_string(), TaintLevel::Public),
2761 ("write_file".to_string(), TaintLevel::Internal),
2762 ])
2763 };
2764
2765 let mut map = base();
2766 bump_read_sensitivities(&mut map, true);
2767 assert_eq!(map.get("read_file"), Some(&TaintLevel::Private));
2768 assert_eq!(map.get("list_dir"), Some(&TaintLevel::Private));
2769 assert_eq!(map.get("write_file"), Some(&TaintLevel::Internal));
2770 assert!(!map.contains_key("read_files"));
2772
2773 let mut map = base();
2774 bump_read_sensitivities(&mut map, false);
2775 assert_eq!(map, base(), "no grant, no change");
2776 }
2777
2778 #[tokio::test]
2779 async fn build_agent_read_error() {
2780 let (mut world, cli) = test_world();
2781 let hub = InteractionHub::new();
2782 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2783 let err = build_agent(
2784 world.world_mut(),
2785 cli.as_ref(),
2786 &Config::default(),
2787 mcp,
2788 &[],
2789 &hub,
2790 &spawn_args("/no/such/manifest.leviath"),
2791 100,
2792 sub_tx(),
2793 )
2794 .unwrap_err();
2795 assert!(err.contains("read manifest"));
2796 }
2797
2798 const OVERSIZED_MANIFEST: &str = r#"
2801[agent]
2802name = "tiny"
2803version = "0.1.0"
2804description = "d"
2805entry_stage = "main"
2806
2807[context.regions]
2808task = { kind = "pinned", max_tokens = 20 }
2809
2810[stages.main]
2811mode = "autonomous"
2812model = { models = [{ provider = "anthropic", model = "m" }] }
2813description = "d"
2814available_tools = []
2815system_prompt = "SYSTEM_PROMPT_PLACEHOLDER"
2816"#;
2817
2818 #[tokio::test]
2819 async fn build_agent_propagates_spawn_error() {
2820 let dir = tempfile::tempdir().unwrap();
2821 let manifest = dir.path().join("tiny.leviath");
2822 let content = OVERSIZED_MANIFEST.replace("SYSTEM_PROMPT_PLACEHOLDER", &"x ".repeat(5000));
2824 std::fs::write(&manifest, content).unwrap();
2825
2826 let (mut world, cli) = test_world();
2827 let hub = InteractionHub::new();
2828 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2829 let result = build_agent(
2830 world.world_mut(),
2831 cli.as_ref(),
2832 &Config::default(),
2833 mcp,
2834 &[],
2835 &hub,
2836 &spawn_args(&manifest.to_string_lossy()),
2837 100,
2838 sub_tx(),
2839 );
2840 assert!(result.is_err(), "expected spawn error, got {result:?}");
2841 }
2842
2843 #[tokio::test]
2844 async fn build_agent_refuses_a_manifest_with_no_usable_provider() {
2845 let dir = tempfile::tempdir().unwrap();
2849 let manifest = dir.path().join("ghostly.leviath");
2850 std::fs::write(
2851 &manifest,
2852 r#"
2853[agent]
2854name = "ghostly"
2855version = "0.1.0"
2856description = "d"
2857entry_stage = "main"
2858
2859[context.regions]
2860task = { kind = "pinned", max_tokens = 4000 }
2861
2862[stages.main]
2863mode = "autonomous"
2864model = { models = [{ provider = "ghost", model = "m" }], allow_user_default = false }
2865description = "d"
2866available_tools = []
2867"#,
2868 )
2869 .unwrap();
2870 let (mut world, cli) = test_world();
2871 let hub = InteractionHub::new();
2872 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2873 let err = build_agent(
2874 world.world_mut(),
2875 cli.as_ref(),
2876 &Config::default(),
2877 mcp,
2878 &[],
2879 &hub,
2880 &spawn_args(&manifest.to_string_lossy()),
2881 100,
2882 sub_tx(),
2883 )
2884 .unwrap_err();
2885 assert!(err.contains("main"), "names the stage: {err}");
2886 assert!(err.contains("ghost"), "names what it tried: {err}");
2887 }
2888
2889 #[tokio::test]
2890 async fn build_agent_invalid_blueprint() {
2891 let dir = tempfile::tempdir().unwrap();
2892 let manifest = dir.path().join("bad.leviath");
2893 std::fs::write(
2895 &manifest,
2896 r#"
2897[agent]
2898name = "bad"
2899version = "0.1.0"
2900description = "d"
2901entry_stage = "ghost"
2902
2903[context.regions]
2904task = { kind = "pinned", max_tokens = 4000 }
2905
2906[stages.main]
2907mode = "autonomous"
2908model = { models = [{ provider = "anthropic", model = "m" }] }
2909description = "d"
2910available_tools = []
2911"#,
2912 )
2913 .unwrap();
2914 let (mut world, cli) = test_world();
2915 let hub = InteractionHub::new();
2916 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2917 let err = build_agent(
2918 world.world_mut(),
2919 cli.as_ref(),
2920 &Config::default(),
2921 mcp,
2922 &[],
2923 &hub,
2924 &spawn_args(&manifest.to_string_lossy()),
2925 100,
2926 sub_tx(),
2927 )
2928 .unwrap_err();
2929 assert!(err.contains("invalid blueprint"));
2930 }
2931
2932 #[tokio::test]
2933 async fn build_agent_without_entry_stage_and_with_compaction() {
2934 let dir = tempfile::tempdir().unwrap();
2935 let manifest = dir.path().join("mini.leviath");
2936 std::fs::write(
2938 &manifest,
2939 r#"
2940[agent]
2941name = "mini"
2942version = "0.1.0"
2943description = "d"
2944
2945[compaction]
2946provider = "anthropic"
2947model = "claude-x"
2948
2949[context.regions]
2950task = { kind = "pinned", max_tokens = 4000 }
2951
2952[stages.main]
2953mode = "autonomous"
2954model = { models = [{ provider = "anthropic", model = "m" }] }
2955description = "d"
2956available_tools = []
2957system_prompt = "be brief"
2958"#,
2959 )
2960 .unwrap();
2961 let (mut world, cli) = test_world();
2962 let hub = InteractionHub::new();
2963 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2964 let entity = build_agent(
2965 world.world_mut(),
2966 cli.as_ref(),
2967 &Config::default(),
2968 mcp,
2969 &[],
2970 &hub,
2971 &spawn_args(&manifest.to_string_lossy()),
2972 100,
2973 sub_tx(),
2974 )
2975 .expect("spawn succeeds");
2976 assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
2977 assert!(world.world().get::<CompactionSettings>(entity).is_some());
2979 }
2980
2981 fn write_read_paths_manifest(dir: &std::path::Path, allow: &str) -> std::path::PathBuf {
2984 let manifest = dir.join("reader.leviath");
2985 std::fs::write(
2986 &manifest,
2987 format!(
2988 r#"
2989[agent]
2990name = "reader"
2991version = "0.1.0"
2992description = "d"
2993
2994[read_paths]
2995allow = [{allow}]
2996
2997[context.regions]
2998task = {{ kind = "pinned", max_tokens = 4000 }}
2999
3000[stages.main]
3001mode = "autonomous"
3002model = {{ models = [{{ provider = "anthropic", model = "m" }}] }}
3003description = "d"
3004available_tools = []
3005system_prompt = "be brief"
3006"#
3007 ),
3008 )
3009 .unwrap();
3010 manifest
3011 }
3012
3013 #[tokio::test]
3016 async fn build_agent_wires_granted_read_paths() {
3017 let dir = tempfile::tempdir().unwrap();
3018 let manifest = write_read_paths_manifest(dir.path(), "\"/tmp\"");
3019 let (mut world, cli) = test_world();
3020 let hub = InteractionHub::new();
3021 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
3022 let mut config = Config::default();
3023 config.security.allow_blueprint_read_paths = true;
3024 config.taint_tracking = true;
3025 let entity = build_agent(
3026 world.world_mut(),
3027 cli.as_ref(),
3028 &config,
3029 mcp,
3030 &[],
3031 &hub,
3032 &spawn_args(&manifest.to_string_lossy()),
3033 100,
3034 sub_tx(),
3035 )
3036 .expect("spawn succeeds");
3037 assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
3038 }
3039
3040 #[tokio::test]
3043 async fn build_agent_wires_ungranted_read_paths() {
3044 let dir = tempfile::tempdir().unwrap();
3045 let manifest = write_read_paths_manifest(dir.path(), "\"/tmp\"");
3046 let (mut world, cli) = test_world();
3047 let hub = InteractionHub::new();
3048 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
3049 let entity = build_agent(
3050 world.world_mut(),
3051 cli.as_ref(),
3052 &Config::default(),
3053 mcp,
3054 &[],
3055 &hub,
3056 &spawn_args(&manifest.to_string_lossy()),
3057 100,
3058 sub_tx(),
3059 )
3060 .expect("spawn succeeds even when nothing grants the declaration");
3061 assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
3062 }
3063
3064 #[tokio::test]
3067 async fn build_agent_rejects_a_malformed_config_grant() {
3068 let dir = tempfile::tempdir().unwrap();
3069 let manifest = write_read_paths_manifest(dir.path(), "\"/tmp\"");
3070 let (mut world, cli) = test_world();
3071 let hub = InteractionHub::new();
3072 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
3073 let mut config = Config::default();
3074 config.security.read_paths = vec!["glob:[".to_string()];
3075 let err = build_agent(
3076 world.world_mut(),
3077 cli.as_ref(),
3078 &config,
3079 mcp,
3080 &[],
3081 &hub,
3082 &spawn_args(&manifest.to_string_lossy()),
3083 100,
3084 sub_tx(),
3085 )
3086 .expect_err("a broken config grant must fail the spawn");
3087 assert!(err.contains("config.toml"), "{err}");
3088 }
3089
3090 #[tokio::test]
3091 async fn build_agent_parse_error() {
3092 let dir = tempfile::tempdir().unwrap();
3093 let manifest = dir.path().join("bad.leviath");
3094 std::fs::write(&manifest, "this is not valid toml : : :").unwrap();
3095 let (mut world, cli) = test_world();
3096 let hub = InteractionHub::new();
3097 let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
3098 let err = build_agent(
3099 world.world_mut(),
3100 cli.as_ref(),
3101 &Config::default(),
3102 mcp,
3103 &[],
3104 &hub,
3105 &spawn_args(&manifest.to_string_lossy()),
3106 100,
3107 sub_tx(),
3108 )
3109 .unwrap_err();
3110 assert!(err.contains("parse manifest"));
3111 }
3112
3113 fn bp(regions_toml: &str) -> Blueprint {
3116 let toml = format!(
3117 r#"
3118[agent]
3119name = "seedy"
3120
3121[stages.main]
3122mode = "autonomous"
3123
3124[stages.main.model]
3125provider = "anthropic"
3126model = "claude-sonnet-5"
3127
3128[context.regions]
3129{regions_toml}
3130conversation = {{ kind = "sliding_window", max_items = 20, max_tokens = 10000 }}
3131"#
3132 );
3133 leviath_core::manifest::parse_manifest(&toml).unwrap()
3134 }
3135
3136 fn args_with(task: &str, regions: HashMap<String, String>, workdir: &str) -> SpawnArgs {
3137 SpawnArgs {
3138 run_id: "r".to_string(),
3139 blueprint_path: "/bp".to_string(),
3140 task: task.to_string(),
3141 regions,
3142 model: None,
3143 workdir: workdir.to_string(),
3144 metadata: HashMap::new(),
3145 callback_url: None,
3146 callback_secret: None,
3147 yolo: false,
3148 no_seed_commands: false,
3149 allow: Vec::new(),
3150 max_depth: None,
3151 parent_run_id: None,
3152 }
3153 }
3154
3155 fn seed_policy() -> SeedCommandPolicy {
3158 SeedCommandPolicy::disabled()
3159 }
3160
3161 fn stub_policy(result: Result<String, String>) -> SeedCommandPolicy {
3164 SeedCommandPolicy {
3165 allowed: true,
3166 timeout: std::time::Duration::from_secs(1),
3167 runner: std::sync::Arc::new(move |_, _, _| result.clone()),
3168 }
3169 }
3170
3171 #[test]
3172 fn resolve_seeds_fills_task_and_caller_input() {
3173 let bp = bp(
3174 r#"task = { kind = "pinned", max_tokens = 4000, seed = "task_input" }
3175criteria = { kind = "pinned", max_tokens = 2000, seed = "input" }"#,
3176 );
3177 let args = args_with(
3178 "build it",
3179 HashMap::from([("criteria".to_string(), "be safe".to_string())]),
3180 "/tmp",
3181 );
3182 let seeds = resolve_seeds(&bp, &args, "/tmp", &seed_policy()).unwrap();
3183 assert_eq!(seeds.get("task").map(String::as_str), Some("build it"));
3184 assert_eq!(seeds.get("criteria").map(String::as_str), Some("be safe"));
3185 }
3186
3187 #[test]
3188 fn resolve_seeds_required_caller_input_missing_is_error() {
3189 let bp =
3190 bp(r#"spec = { kind = "pinned", max_tokens = 2000, seed = "input", required = true }"#);
3191 let args = args_with("t", HashMap::new(), "/tmp");
3192 let err = resolve_seeds(&bp, &args, "/tmp", &seed_policy()).unwrap_err();
3193 assert!(err.contains("spec"), "got: {err}");
3194 }
3195
3196 #[test]
3197 fn resolve_seeds_optional_caller_input_missing_is_omitted() {
3198 let bp = bp(r#"notes = { kind = "pinned", max_tokens = 2000, seed = "input" }"#);
3199 let args = args_with("t", HashMap::new(), "/tmp");
3200 let seeds = resolve_seeds(&bp, &args, "/tmp", &seed_policy()).unwrap();
3201 assert!(!seeds.contains_key("notes"));
3202 }
3203
3204 #[test]
3205 fn resolve_seeds_literal_and_files() {
3206 let dir = tempfile::tempdir().unwrap();
3207 std::fs::write(dir.path().join("a.txt"), "alpha").unwrap();
3208 std::fs::write(dir.path().join("b.txt"), "beta").unwrap();
3209 let bp = bp(
3210 r#"lit = { kind = "pinned", max_tokens = 500, seed = { literal = "hello" } }
3211docs = { kind = "pinned", max_tokens = 2000, seed = { files = ["a.txt", "b.txt"] } }"#,
3212 );
3213 let args = args_with("t", HashMap::new(), &dir.path().to_string_lossy());
3214 let seeds =
3215 resolve_seeds(&bp, &args, &dir.path().to_string_lossy(), &seed_policy()).unwrap();
3216 assert_eq!(seeds.get("lit").map(String::as_str), Some("hello"));
3217 let docs = seeds.get("docs").unwrap();
3218 assert!(docs.contains("alpha") && docs.contains("beta"));
3219 }
3220
3221 #[test]
3222 fn resolve_seeds_glob_concatenates_matches() {
3223 let dir = tempfile::tempdir().unwrap();
3224 std::fs::create_dir(dir.path().join("specs")).unwrap();
3225 std::fs::write(dir.path().join("specs/one.md"), "spec one").unwrap();
3226 std::fs::write(dir.path().join("specs/two.md"), "spec two").unwrap();
3227 let bp =
3228 bp(r#"specs = { kind = "pinned", max_tokens = 4000, seed = { glob = "specs/*.md" } }"#);
3229 let wd = dir.path().to_string_lossy().to_string();
3230 let args = args_with("t", HashMap::new(), &wd);
3231 let seeds = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap();
3232 let specs = seeds.get("specs").unwrap();
3233 assert!(specs.contains("spec one") && specs.contains("spec two"));
3234 }
3235
3236 #[test]
3237 fn resolve_seeds_rhai_runs_script() {
3238 let dir = tempfile::tempdir().unwrap();
3239 std::fs::write(
3241 dir.path().join("init.rhai"),
3242 r#""seeded: " + input["task"]"#,
3243 )
3244 .unwrap();
3245 let bp = bp(
3246 r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "init.rhai" } }"#,
3247 );
3248 let wd = dir.path().to_string_lossy().to_string();
3249 let args = args_with("hello", HashMap::new(), &wd);
3250 let seeds = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap();
3251 assert_eq!(
3252 seeds.get("scripted").map(String::as_str),
3253 Some("seeded: hello")
3254 );
3255 }
3256
3257 #[test]
3258 fn resolve_seeds_files_required_missing_errors_optional_skips() {
3259 let dir = tempfile::tempdir().unwrap();
3260 let wd = dir.path().to_string_lossy().to_string();
3261 let req = bp(
3263 r#"docs = { kind = "pinned", max_tokens = 2000, seed = { files = ["missing.txt"] }, required = true }"#,
3264 );
3265 let args = args_with("t", HashMap::new(), &wd);
3266 let err = resolve_seeds(&req, &args, &wd, &seed_policy()).unwrap_err();
3267 assert!(err.contains("missing.txt"), "got: {err}");
3268 let opt = bp(
3270 r#"docs = { kind = "pinned", max_tokens = 2000, seed = { files = ["missing.txt"] } }"#,
3271 );
3272 let seeds = resolve_seeds(&opt, &args, &wd, &seed_policy()).unwrap();
3273 assert!(!seeds.contains_key("docs"));
3274 }
3275
3276 #[test]
3277 fn resolve_seeds_glob_no_match_required_errors_optional_skips() {
3278 let dir = tempfile::tempdir().unwrap();
3279 let wd = dir.path().to_string_lossy().to_string();
3280 let args = args_with("t", HashMap::new(), &wd);
3281 let req = bp(
3283 r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "none/*.md" }, required = true }"#,
3284 );
3285 let err = resolve_seeds(&req, &args, &wd, &seed_policy()).unwrap_err();
3286 assert!(err.contains("matched no files"), "got: {err}");
3287 let opt =
3289 bp(r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "none/*.md" } }"#);
3290 let seeds = resolve_seeds(&opt, &args, &wd, &seed_policy()).unwrap();
3291 assert!(!seeds.contains_key("specs"));
3292 }
3293
3294 #[test]
3295 fn resolve_seeds_bad_glob_pattern_errors() {
3296 let dir = tempfile::tempdir().unwrap();
3298 let wd = dir.path().to_string_lossy().to_string();
3299 let bp = bp(r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "[" } }"#);
3300 let args = args_with("t", HashMap::new(), &wd);
3301 let err = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap_err();
3302 assert!(err.contains("bad glob"), "got: {err}");
3303 }
3304
3305 #[test]
3306 fn resolve_seeds_rhai_script_error() {
3307 let dir = tempfile::tempdir().unwrap();
3308 std::fs::write(dir.path().join("boom.rhai"), "undefined_func()").unwrap();
3310 let wd = dir.path().to_string_lossy().to_string();
3311 let bp = bp(
3312 r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "boom.rhai" } }"#,
3313 );
3314 let args = args_with("t", HashMap::new(), &wd);
3315 let err = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap_err();
3316 assert!(err.contains("rhai seed failed"), "got: {err}");
3317 }
3318
3319 fn command_bp(required: bool) -> leviath_core::Blueprint {
3323 let req = if required { ", required = true" } else { "" };
3324 bp(&format!(
3325 r#"facts = {{ kind = "pinned", max_tokens = 500, seed = {{ command = "scan-repo" }}{req} }}"#
3326 ))
3327 }
3328
3329 #[test]
3330 fn resolve_seeds_command_stores_output() {
3331 let bp = command_bp(false);
3332 let args = args_with("t", HashMap::new(), "/tmp");
3333 let seeds = resolve_seeds(
3334 &bp,
3335 &args,
3336 "/tmp",
3337 &stub_policy(Ok("src/lib.rs\nsrc/main.rs".to_string())),
3338 )
3339 .unwrap();
3340 assert_eq!(
3341 seeds.get("facts").map(String::as_str),
3342 Some("src/lib.rs\nsrc/main.rs")
3343 );
3344 }
3345
3346 #[test]
3347 fn resolve_seeds_command_receives_the_workdir_and_command() {
3348 let bp = command_bp(false);
3350 let args = args_with("t", HashMap::new(), "/work");
3351 let policy = SeedCommandPolicy {
3352 allowed: true,
3353 timeout: std::time::Duration::from_secs(9),
3354 runner: std::sync::Arc::new(|command, workdir, timeout| {
3355 Ok(format!(
3356 "{command}@{}#{}",
3357 workdir.display(),
3358 timeout.as_secs()
3359 ))
3360 }),
3361 };
3362 let seeds = resolve_seeds(&bp, &args, "/work", &policy).unwrap();
3363 assert_eq!(
3364 seeds.get("facts").map(String::as_str),
3365 Some("scan-repo@/work#9")
3366 );
3367 }
3368
3369 #[test]
3370 fn resolve_seeds_command_failure_is_skipped_when_optional() {
3371 let bp = command_bp(false);
3372 let args = args_with("t", HashMap::new(), "/tmp");
3373 let seeds = resolve_seeds(
3374 &bp,
3375 &args,
3376 "/tmp",
3377 &stub_policy(Err("timed out".to_string())),
3378 )
3379 .unwrap();
3380 assert!(
3381 !seeds.contains_key("facts"),
3382 "an optional command seed must not sink the spawn"
3383 );
3384 }
3385
3386 #[test]
3387 fn resolve_seeds_command_failure_errors_when_required() {
3388 let bp = command_bp(true);
3389 let args = args_with("t", HashMap::new(), "/tmp");
3390 let err =
3391 resolve_seeds(&bp, &args, "/tmp", &stub_policy(Err("boom".to_string()))).unwrap_err();
3392 assert!(err.contains("scan-repo"), "got: {err}");
3393 assert!(err.contains("boom"), "got: {err}");
3394 }
3395
3396 #[test]
3397 fn resolve_seeds_command_empty_output_is_skipped_when_optional() {
3398 let bp = command_bp(false);
3399 let args = args_with("t", HashMap::new(), "/tmp");
3400 let seeds =
3401 resolve_seeds(&bp, &args, "/tmp", &stub_policy(Ok(" \n".to_string()))).unwrap();
3402 assert!(!seeds.contains_key("facts"));
3403 }
3404
3405 #[test]
3406 fn resolve_seeds_command_empty_output_errors_when_required() {
3407 let bp = command_bp(true);
3408 let args = args_with("t", HashMap::new(), "/tmp");
3409 let err = resolve_seeds(&bp, &args, "/tmp", &stub_policy(Ok(String::new()))).unwrap_err();
3410 assert!(err.contains("returned empty"), "got: {err}");
3411 }
3412
3413 #[test]
3414 fn resolve_seeds_command_skipped_when_disabled() {
3415 let bp = command_bp(false);
3419 let args = args_with("t", HashMap::new(), "/tmp");
3420 let mut policy = stub_policy(Ok("SHOULD NOT BE USED".to_string()));
3421 policy.allowed = false;
3422 let seeds = resolve_seeds(&bp, &args, "/tmp", &policy).unwrap();
3423 assert!(!seeds.contains_key("facts"));
3424 }
3425
3426 #[test]
3427 fn resolve_seeds_required_command_errors_when_disabled() {
3428 let bp = command_bp(true);
3431 let args = args_with("t", HashMap::new(), "/tmp");
3432 let err = resolve_seeds(&bp, &args, "/tmp", &SeedCommandPolicy::disabled()).unwrap_err();
3433 assert!(err.contains("allow_seed_commands"), "got: {err}");
3434 }
3435
3436 #[test]
3437 fn resolve_seeds_glob_matching_directory_required_errors() {
3438 let dir = tempfile::tempdir().unwrap();
3441 std::fs::create_dir(dir.path().join("subdir")).unwrap();
3442 let wd = dir.path().to_string_lossy().to_string();
3443 let bp = bp(
3444 r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "sub*" }, required = true }"#,
3445 );
3446 let args = args_with("t", HashMap::new(), &wd);
3447 let err = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap_err();
3448 assert!(err.contains("read seed file"), "got: {err}");
3449 }
3450
3451 #[test]
3452 fn resolve_seeds_rhai_read_error() {
3453 let dir = tempfile::tempdir().unwrap();
3454 let wd = dir.path().to_string_lossy().to_string();
3455 let bp = bp(
3456 r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "nope.rhai" } }"#,
3457 );
3458 let args = args_with("t", HashMap::new(), &wd);
3459 let err = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap_err();
3460 assert!(err.contains("read rhai seed"), "got: {err}");
3461 }
3462
3463 #[test]
3464 fn resolve_seeds_rhai_empty_required_errors_optional_skips() {
3465 let dir = tempfile::tempdir().unwrap();
3466 std::fs::write(dir.path().join("empty.rhai"), r#""""#).unwrap();
3468 let wd = dir.path().to_string_lossy().to_string();
3469 let args = args_with("t", HashMap::new(), &wd);
3470 let req = bp(
3471 r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "empty.rhai" }, required = true }"#,
3472 );
3473 let err = resolve_seeds(&req, &args, &wd, &seed_policy()).unwrap_err();
3474 assert!(err.contains("returned empty"), "got: {err}");
3475 let opt = bp(
3477 r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "empty.rhai" } }"#,
3478 );
3479 let seeds = resolve_seeds(&opt, &args, &wd, &seed_policy()).unwrap();
3480 assert!(!seeds.contains_key("scripted"));
3481 }
3482
3483 #[test]
3484 fn resolve_seeds_tolerates_unknown_caller_region() {
3485 let bp = bp(r#"task = { kind = "pinned", max_tokens = 4000, seed = "task_input" }"#);
3488 let args = args_with(
3489 "t",
3490 HashMap::from([("ghost".to_string(), "x".to_string())]),
3491 "/tmp",
3492 );
3493 let seeds = resolve_seeds(&bp, &args, "/tmp", &seed_policy()).unwrap();
3494 assert_eq!(seeds.get("task").map(String::as_str), Some("t"));
3495 assert!(!seeds.contains_key("ghost"));
3496 }
3497}