Skip to main content

leviath_cli/daemon/
spawn.rs

1//! The daemon spawner: turns a [`SpawnArgs`] request into a live agent in the
2//! shared world - the CLI-side policy the runtime host calls for a `Spawn`
3//! control op.
4//!
5//! It loads the blueprint, resolves each stage's provider/model (against the
6//! world's registered providers) and effective tool set, spawns the agent via
7//! [`leviath_runtime::pipeline::spawn_agent`], attaches its run metadata /
8//! token totals / compaction settings, and registers its per-agent tool state
9//! with the [`CliToolService`]. The heavy MCP connections are shared (built once
10//! at daemon startup), so this whole path is synchronous - which lets it run
11//! straight from the host's control loop.
12
13use std::collections::{HashMap, HashSet};
14use std::sync::{Arc, Mutex as StdMutex};
15
16use bevy_ecs::entity::Entity;
17use bevy_ecs::world::World;
18use leviath_core::blueprint::{Blueprint, ModelConfig};
19use leviath_providers::Tool;
20use leviath_runtime::ProviderRegistry;
21use leviath_runtime::host::{SpawnArgs, SubAgentOp};
22use leviath_runtime::interaction_hub::InteractionHub;
23use leviath_runtime::persistence::{RunMetadata, TokenTotals};
24use leviath_runtime::pipeline::{
25    CompactionSettings, PersistWatermark, Providers, ResolvedStage, spawn_agent_seeded,
26};
27use tokio::sync::Mutex;
28use tokio::sync::mpsc::UnboundedSender;
29
30use crate::config::Config;
31use crate::daemon::seed_command::SeedCommandPolicy;
32use crate::daemon::subagent::SubAgentHandle;
33use crate::daemon::tool_service::{AgentToolState, CliToolService};
34
35/// Default max sub-agent tree depth when a blueprint doesn't set one.
36const DEFAULT_SUBAGENT_DEPTH: usize = 3;
37
38/// Resolve a stage's [`ModelConfig`] to a concrete `(provider, model)` against
39/// the registered providers. Honors a `--model` override (`provider/model` or a
40/// bare `model`), otherwise picks the first listed model whose provider is
41/// registered, then falls back to the user default (when `allow_user_default`),
42/// and finally to the config's first listed entry. (Ported from the executor's
43/// inline resolution.)
44pub fn resolve_stage_model(
45    model_cfg: &ModelConfig,
46    model_override: Option<&str>,
47    config: &Config,
48    registry: &ProviderRegistry,
49) -> (String, String) {
50    let (override_provider, override_model) = match model_override {
51        Some(ov) if ov.contains('/') => {
52            let (p, m) = ov.split_once('/').unwrap();
53            (Some(p.to_string()), Some(m.to_string()))
54        }
55        Some(ov) => (None, Some(ov.to_string())),
56        None => (None, None),
57    };
58
59    // Full provider/model override wins outright.
60    if let Some(provider) = override_provider {
61        return (provider, override_model.unwrap_or_default());
62    }
63
64    // First listed model whose provider is registered.
65    for entry in &model_cfg.models {
66        if registry.has(&entry.provider) {
67            let model = override_model
68                .clone()
69                .unwrap_or_else(|| entry.model.clone());
70            return (entry.provider.clone(), model);
71        }
72    }
73
74    // Fall back to the user's default model, or finally the first listed entry.
75    user_default_model(model_cfg, override_model.as_deref(), config, registry).unwrap_or_else(
76        || {
77            (
78                model_cfg.provider().to_string(),
79                model_cfg.model().to_string(),
80            )
81        },
82    )
83}
84
85/// The user-default fallback for [`resolve_stage_model`]: `None` when the stage
86/// forbids it or no usable default exists.
87fn user_default_model(
88    model_cfg: &ModelConfig,
89    override_model: Option<&str>,
90    config: &Config,
91    registry: &ProviderRegistry,
92) -> Option<(String, String)> {
93    if !model_cfg.allow_user_default {
94        return None;
95    }
96    if let Some(model) = override_model {
97        return Some((config.default_provider.clone(), model.to_string()));
98    }
99    if let Some(default_model) = &config.default_model
100        && registry.has(&config.default_provider)
101    {
102        return Some((config.default_provider.clone(), default_model.clone()));
103    }
104    None
105}
106
107/// Resolve every stage's provider/model + effective tool set from the blueprint.
108fn resolve_stages(
109    blueprint: &Blueprint,
110    model_override: Option<&str>,
111    config: &Config,
112    registry: &ProviderRegistry,
113    all_tool_defs: &[Tool],
114) -> Vec<ResolvedStage> {
115    blueprint
116        .stages
117        .iter()
118        .map(|stage| {
119            let (provider_name, model) =
120                resolve_stage_model(&stage.model, model_override, config, registry);
121            // Empty `available_tools` exposes no tools; otherwise filter the full
122            // set by name (alias-resolved). A name matching nothing (a typo, or an
123            // MCP tool whose server isn't installed) is simply omitted.
124            let tools = filter_tools_by_available(all_tool_defs, &stage.available_tools);
125            ResolvedStage {
126                provider_name,
127                model,
128                tools,
129            }
130        })
131        .collect()
132}
133
134/// The directories scanned for an agent's Rhai script tools, in precedence order
135/// (earlier wins on a name collision): the agent's own `<agent_dir>/tools/`, then
136/// `extra` (the run workdir's `tools/`, only for `dynamic_tools` agents so a
137/// mid-run write is picked up), then the global `~/.leviath/tools/`. `Option`'s
138/// iterator flattens the "no parent" / "no home" cases without a dangling
139/// `if let` else region.
140fn script_scan_dirs(
141    blueprint_path: &str,
142    extra: Option<std::path::PathBuf>,
143) -> Vec<std::path::PathBuf> {
144    std::path::Path::new(blueprint_path)
145        .parent()
146        .map(|d| d.join("tools"))
147        .into_iter()
148        .chain(extra)
149        .chain(leviath_core::tools_dir())
150        .collect()
151}
152
153/// Read and compile every custom region's Rhai script declared by `blueprint`
154/// (global layout plus each stage's per-stage layout), keyed by the script
155/// path as written. Paths resolve relative to the blueprint's directory (the
156/// script-tool convention - the script travels with the agent), with absolute
157/// paths passing through `Path::join` unchanged. Each distinct path is read
158/// and compiled once; regions sharing a script share the compiled AST.
159///
160/// A missing or uncompilable script is a **hard spawn error** (fail fast,
161/// before any tokens are spent): a hook that silently never ran would change
162/// every inference with no signal. Runtime hook *eval* failures, by contrast,
163/// warn and fall back per hook.
164pub(crate) fn resolve_region_scripts(
165    blueprint: &Blueprint,
166    blueprint_path: &str,
167) -> Result<HashMap<String, Arc<leviath_scripting::region_hook::RegionScript>>, String> {
168    let base = std::path::Path::new(blueprint_path)
169        .parent()
170        .map(std::path::Path::to_path_buf)
171        .unwrap_or_default();
172    let mut scripts = HashMap::new();
173
174    let layouts = std::iter::once(&blueprint.context_layout).chain(
175        blueprint
176            .stages
177            .iter()
178            .filter_map(|s| s.context_layout.as_ref()),
179    );
180    for layout in layouts {
181        for region in &layout.regions {
182            let leviath_core::RegionKind::Custom { script, .. } = &region.kind else {
183                continue;
184            };
185            if scripts.contains_key(script) {
186                continue;
187            }
188            let path = base.join(script);
189            let source = std::fs::read_to_string(&path).map_err(|e| {
190                format!(
191                    "region '{}': cannot read custom region script '{}': {e}",
192                    region.name,
193                    path.display()
194                )
195            })?;
196            let compiled =
197                leviath_scripting::region_hook::compile(script, &source).map_err(|e| {
198                    format!(
199                        "region '{}': custom region script failed to compile: {e}",
200                        region.name
201                    )
202                })?;
203            scripts.insert(script.clone(), Arc::new(compiled));
204        }
205    }
206    Ok(scripts)
207}
208
209/// Names already claimed by a built-in, sub-agent, or MCP tool - a discovered
210/// script tool colliding with one of these is dropped (never shadows a core tool).
211fn reserved_tool_names(builtin_names: &HashSet<String>, mcp_tool_defs: &[Tool]) -> HashSet<String> {
212    let mut reserved: HashSet<String> = builtin_names.clone();
213    reserved.extend(leviath_tools::BuiltinTools::subagent_tool_names());
214    reserved.extend(mcp_tool_defs.iter().map(|t| t.name.clone()));
215    reserved
216}
217
218/// Map a script's self-declared `@requires` capability name to the platform
219/// [`ToolCapability`] it corresponds to. An unrecognized name returns `None`,
220/// which the discovery pass treats as unsatisfiable (the tool is dropped) so a
221/// typo can't silently slip a tool through the platform gate.
222fn script_cap(name: &str) -> Option<leviath_tools::ToolCapability> {
223    match name {
224        "network" | "net" | "http" => Some(leviath_tools::ToolCapability::Network),
225        "shell" | "process" | "process_spawn" => Some(leviath_tools::ToolCapability::ProcessSpawn),
226        "filesystem" | "file" | "fs" => Some(leviath_tools::ToolCapability::FileSystem),
227        _ => None,
228    }
229}
230
231/// Whether `platform` can satisfy every capability a script `@requires`. An
232/// unknown capability name is never satisfiable.
233fn platform_satisfies_caps(
234    platform: &leviath_tools::PlatformCapabilities,
235    required_caps: &[String],
236) -> bool {
237    required_caps
238        .iter()
239        .all(|c| script_cap(c).is_some_and(|cap| platform.supports(cap)))
240}
241
242/// Whether the *current* platform can satisfy a script's `@requires` - the same
243/// gate `discover_script_tools_in` applies at spawn. Exposed so the read-only CLI
244/// surfaces (`lev tools`, `lev validate`, `lev mcp list`) report a tool's real
245/// availability (and flag an unknown/typo'd capability) instead of listing a tool
246/// the daemon would silently drop.
247pub(crate) fn current_platform_satisfies(required_caps: &[String]) -> bool {
248    platform_satisfies_caps(
249        &leviath_tools::PlatformCapabilities::current(),
250        required_caps,
251    )
252}
253
254/// Discover and compile the script tools in `dirs`, returning the compiled set,
255/// the routable names (collisions against `reserved` excluded), and the
256/// advertised `Tool` defs.
257///
258/// A tool whose `@requires` capabilities the current platform can't satisfy is
259/// dropped here (self-declared platform gating) - mirroring how
260/// built-ins filter against [`PlatformCapabilities`].
261pub(crate) fn discover_script_tools_in(
262    dirs: &[std::path::PathBuf],
263    reserved: &HashSet<String>,
264) -> (leviath_scripting::ScriptToolSet, HashSet<String>, Vec<Tool>) {
265    let (set, skipped) = leviath_scripting::ScriptToolSet::discover(dirs);
266    for s in &skipped {
267        // Pre-format the path to a plain string so the `tracing` field carries no
268        // inline method call (an inline `%s.path.display()` leaves a macro
269        // sub-region llvm-cov can't attribute even with the event enabled).
270        let path = s.path.display().to_string();
271        tracing::warn!(tool = %path, reason = %s.reason, "skipping invalid script tool");
272    }
273    let platform = leviath_tools::PlatformCapabilities::current();
274    let mut names = HashSet::new();
275    let mut defs = Vec::new();
276    for meta in set.metas() {
277        if reserved.contains(&meta.name) {
278            tracing::warn!(tool = %meta.name, "script tool name collides with an existing tool - ignoring");
279            continue;
280        }
281        if !platform_satisfies_caps(&platform, &meta.required_caps) {
282            let caps = meta.required_caps.join(", ");
283            tracing::warn!(tool = %meta.name, requires = %caps, "script tool requires a capability this platform lacks - ignoring");
284            continue;
285        }
286        names.insert(meta.name.clone());
287        defs.push(Tool {
288            name: meta.name.clone(),
289            description: meta.description.clone(),
290            parameters: meta.parameters_schema(),
291        });
292    }
293    (set, names, defs)
294}
295
296/// Filter `all` tool defs down to those a stage's `available_tools` names
297/// (alias-resolved). Shared by spawn-time stage resolution and the mid-run
298/// tool-service refresh so both apply Layer-1 identically.
299pub fn filter_tools_by_available(all: &[Tool], available: &[String]) -> Vec<Tool> {
300    if available.is_empty() {
301        return Vec::new();
302    }
303    all.iter()
304        .filter(|t| {
305            available
306                .iter()
307                .any(|n| leviath_tools::canonical_tool_name(n) == t.name)
308        })
309        .cloned()
310        .collect()
311}
312
313/// Discover the agent's Rhai script tools and build their `Tool`
314/// defs (the spawn-time entry point). `extra_dir` adds the run workdir's `tools/`
315/// for `dynamic_tools` agents.
316fn discover_script_tools(
317    blueprint_path: &str,
318    builtin_names: &HashSet<String>,
319    mcp_tool_defs: &[Tool],
320    extra_dir: Option<std::path::PathBuf>,
321) -> (leviath_scripting::ScriptToolSet, HashSet<String>, Vec<Tool>) {
322    let dirs = script_scan_dirs(blueprint_path, extra_dir);
323    let reserved = reserved_tool_names(builtin_names, mcp_tool_defs);
324    discover_script_tools_in(&dirs, &reserved)
325}
326
327/// Build one agent's [`AgentToolState`] from the shared executors + config.
328///
329/// `stage_perms_by_index` holds every stage's `[tool_permissions]` (in stage
330/// order); the entry stage's map seeds `stage_perms`, and the pipeline's
331/// `sync_stage` swaps in the right one as the agent changes stage.
332#[allow(clippy::too_many_arguments)]
333fn build_tool_state(
334    builtins: Arc<leviath_tools::BuiltinTools>,
335    builtin_names: HashSet<String>,
336    mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
337    config: &Config,
338    hub: &InteractionHub,
339    run_id: &str,
340    entry_stage: &str,
341    entry_index: usize,
342    stage_perms_by_index: Vec<HashMap<String, String>>,
343    agent_perms: HashMap<String, String>,
344    agent_name: &str,
345    launch_overrides: HashMap<String, crate::config::ToolPolicy>,
346    subagent: Option<SubAgentHandle>,
347    sandbox: Option<Arc<crate::daemon::sandbox_manager::SandboxManager>>,
348    script_tools: leviath_scripting::ScriptToolSet,
349    script_tool_names: HashSet<String>,
350    script_host: Arc<dyn leviath_scripting::ScriptHost>,
351    dynamic: Option<Arc<crate::daemon::tool_service::DynamicToolCtx>>,
352    unattended: bool,
353) -> Arc<AgentToolState> {
354    let entry_perms = stage_perms_by_index
355        .get(entry_index)
356        .cloned()
357        .unwrap_or_default();
358    Arc::new(AgentToolState {
359        builtins,
360        mcp,
361        builtin_names,
362        launch_overrides: Arc::new(launch_overrides),
363        session_allows: Arc::new(Mutex::new(HashSet::new())),
364        stage_perms: Arc::new(StdMutex::new(entry_perms)),
365        stage_perms_by_index: Arc::new(stage_perms_by_index),
366        agent_perms: Arc::new(agent_perms),
367        // The ceiling a blueprint may tighten but not loosen: the user's global
368        // `[tool_permissions]` plus any `[agent_tool_permissions.<name>]` grant
369        // they made for this specific agent. Resolved once here so every later
370        // `resolve_policy` reads one flat map.
371        global_perms: Arc::new(config.permissions_for_agent(agent_name)),
372        interaction: hub.backend_for(run_id),
373        unattended,
374        stage_name: Arc::new(StdMutex::new(entry_stage.to_string())),
375        subagent,
376        sandbox,
377        script_tools: Arc::new(StdMutex::new(script_tools)),
378        script_tool_names: Arc::new(StdMutex::new(script_tool_names)),
379        script_host,
380        dynamic,
381    })
382}
383
384/// Resolve every region's initial content from its blueprint-declared
385/// [`RegionSeed`] plus the caller-provided values on `args`, into a
386/// name→content map ready for [`spawn_agent_seeded`].
387///
388/// The caller map is `{ "task": args.task } ∪ args.regions` (a `regions["task"]`
389/// wins). Then:
390/// - `CallerInput { name }` pulls from the caller map; if the region is
391///   `required` and the value is missing/blank this returns `Err` - the
392///   required-at-spawn gate, before any inference.
393/// - `Files` / `Glob` read workdir files; `Literal` is verbatim; `Rhai` runs a
394///   workdir script whose `String` return seeds the region.
395/// - `Command` runs a shell command in the workdir under `commands` -
396///   sandboxed, time- and size-capped, and skippable. Every failure is
397///   non-fatal unless the region is `required`.
398/// - Any caller key (other than `task`) that isn't a declared `CallerInput`
399///   region is rejected (typo protection, mirrors the CLI-side check).
400fn resolve_seeds(
401    blueprint: &Blueprint,
402    args: &SpawnArgs,
403    workdir: &str,
404    commands: &SeedCommandPolicy,
405) -> Result<HashMap<String, String>, String> {
406    use leviath_core::layout::RegionSeed;
407
408    // The effective caller-supplied values: task text plus any named regions.
409    let mut caller: HashMap<String, String> = HashMap::new();
410    caller.insert("task".to_string(), args.task.clone());
411    for (k, v) in &args.regions {
412        caller.insert(k.clone(), v.clone());
413    }
414
415    // Unknown caller keys are tolerated here (silently unused): the CLI already
416    // rejects typos client-side in `resolve_spawn_args`, and an ACP host sending
417    // a stray `---region:...---` marker shouldn't fail the whole turn over it.
418
419    let base = std::path::Path::new(workdir);
420    let mut seeds: HashMap<String, String> = HashMap::new();
421
422    for region in &blueprint.context_layout.regions {
423        let Some(seed) = &region.seed else { continue };
424        match seed {
425            RegionSeed::CallerInput { name } => {
426                let value = caller.get(name).map(|s| s.as_str()).unwrap_or("");
427                if value.trim().is_empty() {
428                    if region.required {
429                        return Err(region.required_message.clone().unwrap_or_else(|| {
430                            format!(
431                                "required region '{}' was not provided; supply it via \
432                                 --{name} <text|@file> (CLI), a ---region:{name}--- block \
433                                 (ACP), or the API `regions` field",
434                                region.name
435                            )
436                        }));
437                    }
438                    // Optional and unprovided - leave the region empty.
439                    continue;
440                }
441                seeds.insert(region.name.clone(), value.to_string());
442            }
443            RegionSeed::Literal { text } => {
444                seeds.insert(region.name.clone(), text.clone());
445            }
446            RegionSeed::Files { paths } => {
447                let content = read_and_concat(
448                    &region.name,
449                    paths.iter().map(|p| base.join(p)),
450                    region.required,
451                )?;
452                if let Some(content) = content {
453                    seeds.insert(region.name.clone(), content);
454                }
455            }
456            RegionSeed::Glob { pattern } => {
457                let full = base.join(pattern);
458                let full = full.to_string_lossy();
459                let matches = glob::glob(&full)
460                    .map_err(|e| format!("region '{}': bad glob '{pattern}': {e}", region.name))?;
461                let paths: Vec<std::path::PathBuf> = matches.filter_map(|m| m.ok()).collect();
462                let content = read_and_concat(&region.name, paths.into_iter(), region.required)?;
463                match content {
464                    Some(content) => {
465                        seeds.insert(region.name.clone(), content);
466                    }
467                    None if region.required => {
468                        return Err(format!(
469                            "required region '{}': glob '{pattern}' matched no files",
470                            region.name
471                        ));
472                    }
473                    None => {}
474                }
475            }
476            RegionSeed::Rhai { script } => {
477                let path = base.join(script);
478                let src = std::fs::read_to_string(&path).map_err(|e| {
479                    format!(
480                        "region '{}': read rhai seed '{}': {e}",
481                        region.name,
482                        path.display()
483                    )
484                })?;
485                let mut input = rhai::Map::new();
486                input.insert("task".into(), rhai::Dynamic::from(args.task.clone()));
487                input.insert("workdir".into(), rhai::Dynamic::from(workdir.to_string()));
488                let out = leviath_scripting::ScriptEngine::new()
489                    .transform(&src, input)
490                    .map_err(|e| format!("region '{}': rhai seed failed: {e}", region.name))?;
491                if !out.trim().is_empty() {
492                    seeds.insert(region.name.clone(), out);
493                } else if region.required {
494                    return Err(format!(
495                        "required region '{}': rhai seed '{script}' returned empty",
496                        region.name
497                    ));
498                }
499            }
500            // A command seed *executes* at spawn, before any inference and so
501            // before any tool-approval prompt. It is therefore skipped outright
502            // when disabled, and every failure mode is non-fatal unless the
503            // region is `required` (mirroring the Files/Glob arms above): a
504            // discovery nicety must never be able to sink a run.
505            RegionSeed::Command { command } => {
506                if !commands.allowed {
507                    if region.required {
508                        return Err(format!(
509                            "required region '{}': command seeds are disabled \
510                             (`[security] allow_seed_commands = false` or --no-seed-commands)",
511                            region.name
512                        ));
513                    }
514                    tracing::warn!(
515                        region = %region.name,
516                        "command seed skipped: command seeds are disabled"
517                    );
518                    continue;
519                }
520                match commands.run(command, base) {
521                    Ok(out) if !out.trim().is_empty() => {
522                        seeds.insert(region.name.clone(), out);
523                    }
524                    Ok(_) => {
525                        if region.required {
526                            return Err(format!(
527                                "required region '{}': command seed '{command}' returned empty",
528                                region.name
529                            ));
530                        }
531                        tracing::warn!(
532                            region = %region.name,
533                            command = %command,
534                            "command seed returned no output; region left empty"
535                        );
536                    }
537                    Err(e) => {
538                        if region.required {
539                            return Err(format!(
540                                "required region '{}': command seed '{command}' failed: {e}",
541                                region.name
542                            ));
543                        }
544                        tracing::warn!(
545                            region = %region.name,
546                            command = %command,
547                            error = %e,
548                            "command seed failed; region left empty"
549                        );
550                    }
551                }
552            }
553        }
554    }
555
556    Ok(seeds)
557}
558
559/// Read each file and concatenate with `--- <path> ---` headers. Returns
560/// `Ok(None)` when the list is empty; a missing/unreadable file is an error only
561/// when `required`, else it is skipped.
562fn read_and_concat(
563    region: &str,
564    paths: impl Iterator<Item = std::path::PathBuf>,
565    required: bool,
566) -> Result<Option<String>, String> {
567    let mut parts: Vec<String> = Vec::new();
568    for path in paths {
569        match std::fs::read_to_string(&path) {
570            Ok(text) => parts.push(format!("--- {} ---\n{}", path.display(), text)),
571            Err(e) => {
572                if required {
573                    return Err(format!(
574                        "region '{region}': read seed file '{}': {e}",
575                        path.display()
576                    ));
577                }
578            }
579        }
580    }
581    Ok((!parts.is_empty()).then(|| parts.join("\n\n")))
582}
583
584/// Load the blueprint at `args.blueprint_path`, spawn the agent into `world`,
585/// register its tool state, and return the new entity. Operates on the raw ECS
586/// [`World`] so it is callable both from the host's spawner (via
587/// `PipelineWorld::world_mut`) and from a fan-out world-system.
588///
589/// Enforces the required-at-spawn region gate - a fresh spawn whose required
590/// caller-input regions weren't provided fails here. Use
591/// [`build_agent_for_reload`] on the recovery path, where the window is restored
592/// from a snapshot afterward and the gate must not re-fire.
593#[allow(clippy::too_many_arguments)]
594pub fn build_agent(
595    world: &mut World,
596    tool_service: &CliToolService,
597    config: &Config,
598    shared_mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
599    mcp_tool_defs: &[Tool],
600    hub: &InteractionHub,
601    args: &SpawnArgs,
602    now_secs: i64,
603    subagent_tx: UnboundedSender<SubAgentOp>,
604) -> Result<Entity, String> {
605    build_agent_inner(
606        world,
607        tool_service,
608        config,
609        shared_mcp,
610        mcp_tool_defs,
611        hub,
612        args,
613        now_secs,
614        subagent_tx,
615        true,
616    )
617}
618
619/// Like [`build_agent`], but skips the required-at-spawn region gate - used by
620/// restart recovery, which reloads a run that already passed the gate when first
621/// spawned and whose context window is restored from a snapshot after this call.
622#[allow(clippy::too_many_arguments)]
623pub fn build_agent_for_reload(
624    world: &mut World,
625    tool_service: &CliToolService,
626    config: &Config,
627    shared_mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
628    mcp_tool_defs: &[Tool],
629    hub: &InteractionHub,
630    args: &SpawnArgs,
631    now_secs: i64,
632    subagent_tx: UnboundedSender<SubAgentOp>,
633) -> Result<Entity, String> {
634    build_agent_inner(
635        world,
636        tool_service,
637        config,
638        shared_mcp,
639        mcp_tool_defs,
640        hub,
641        args,
642        now_secs,
643        subagent_tx,
644        false,
645    )
646}
647
648#[allow(clippy::too_many_arguments)]
649fn build_agent_inner(
650    world: &mut World,
651    tool_service: &CliToolService,
652    config: &Config,
653    shared_mcp: Arc<Mutex<leviath_mcp::ToolExecutor>>,
654    mcp_tool_defs: &[Tool],
655    hub: &InteractionHub,
656    args: &SpawnArgs,
657    now_secs: i64,
658    subagent_tx: UnboundedSender<SubAgentOp>,
659    enforce_seeds: bool,
660) -> Result<Entity, String> {
661    // 0. The working directory must exist before anything is built over it.
662    // `ToolContext::new` silently keeps a path it can't canonicalize, so without
663    // this a bogus workdir spawns a healthy-looking agent whose every tool call
664    // fails with a message naming the shell rather than the directory (#107).
665    if !std::fs::metadata(&args.workdir).is_ok_and(|m| m.is_dir()) {
666        return Err(format!(
667            "workspace '{}' does not exist or is not a directory",
668            args.workdir
669        ));
670    }
671
672    // 1. Load the blueprint (the client resolves the manifest path).
673    let content = std::fs::read_to_string(&args.blueprint_path)
674        .map_err(|e| format!("read manifest '{}': {e}", args.blueprint_path))?;
675    let mut blueprint = leviath_core::manifest::parse_manifest(&content)
676        .map_err(|e| format!("parse manifest: {e}"))?;
677    blueprint
678        .validate()
679        .map_err(|e| format!("invalid blueprint: {e}"))?;
680    // A request-level `--max-depth` overrides the blueprint's sub-agent depth cap.
681    if let Some(md) = args.max_depth {
682        blueprint.max_child_depth = Some(md);
683    }
684    // Apply the config's `default_max_iterations` to any stage that doesn't set
685    // its own, so an agent can't loop forever with no completion signal
686    // (`enforce_max_iterations` treats `None`/0 as unbounded). A stage's explicit
687    // `max_iterations` always wins.
688    if let Some(default_max) = config.limits.default_max_iterations {
689        for stage in &mut blueprint.stages {
690            // `0` means *unbounded* to the pipeline, and `get_or_insert` only
691            // fills `None` - so a manifest writing `max_iterations = 0` looked
692            // like "unset" while actually opting out of the user's ceiling
693            // entirely, and looped without limit against their API keys. A
694            // manifest may still declare its own finite number; it may not
695            // declare "no limit" over a user who asked for one.
696            match stage.max_iterations {
697                None | Some(0) => stage.max_iterations = Some(default_max),
698                Some(_) => {}
699            }
700        }
701    }
702
703    // 2a. Entry stage + per-stage sandbox resolution. Each stage's effective
704    // sandbox cascades stage → agent → global (`resolve_sandbox`); building the
705    // manager creates any containers up front and fails here (returning the
706    // error to the spawner) when a required runtime is unavailable and the config
707    // says to error. `None` means no stage is sandboxed → no executor attached
708    // (zero overhead, exact prior host behavior).
709    let entry_stage = blueprint
710        .entry_stage
711        .clone()
712        .or_else(|| blueprint.stages.first().map(|s| s.name.clone()))
713        .unwrap_or_default();
714    let entry_index = blueprint
715        .stages
716        .iter()
717        .position(|s| s.name == entry_stage)
718        .unwrap_or(0);
719    let stage_sandbox_by_index: Vec<leviath_core::ToolSandboxConfig> = blueprint
720        .stages
721        .iter()
722        .map(|s| {
723            leviath_core::resolve_sandbox(
724                config.sandbox.as_ref(),
725                blueprint.sandbox.as_ref(),
726                s.sandbox.as_ref(),
727            )
728        })
729        .collect();
730    let sandbox = crate::daemon::sandbox_manager::SandboxManager::build(
731        &args.run_id,
732        stage_sandbox_by_index,
733        &args.workdir,
734        entry_index,
735    )?
736    .map(Arc::new);
737
738    // 2b. Per-agent built-in tools (over the agent's workdir), routing shell
739    // execution through the sandbox when one is configured.
740    let mut builtins = leviath_tools::BuiltinTools::new(leviath_tools::ToolContext::new(
741        std::path::PathBuf::from(&args.workdir),
742    ));
743    if let Some(mgr) = &sandbox {
744        builtins =
745            builtins.with_shell_executor(mgr.clone() as Arc<dyn leviath_tools::ShellExecutor>);
746    }
747    let builtins = Arc::new(builtins);
748    let builtin_names: HashSet<String> = builtins.names().into_iter().collect();
749    let mut all_tool_defs = builtins.tool_defs();
750    all_tool_defs.extend(leviath_tools::BuiltinTools::subagent_tool_defs());
751    all_tool_defs.extend(mcp_tool_defs.iter().cloned());
752    // The non-script defs (built-in + sub-agent + MCP), captured before script
753    // defs are appended - a `dynamic_tools` agent re-filters against these plus a
754    // fresh script scan on each mid-run refresh.
755    let static_tool_defs = all_tool_defs.clone();
756
757    // 2c. Rhai script tools (issue #97): discover and compile the agent's
758    // `tools/` dir plus the global `~/.leviath/tools/` (per-agent wins on a name
759    // collision). Their defs are added to `all_tool_defs` *before* stage
760    // resolution so a stage's `available_tools` (Layer 1) and taint
761    // classification see them. A script tool whose name collides with a built-in,
762    // sub-agent, or MCP tool is ignored (the existing tool wins), so it never
763    // shadows a core tool.
764    // A `dynamic_tools` agent also scans its run workdir's `tools/`, so a tool it
765    // writes mid-run (into a workdir it can reach) is discoverable on re-scan.
766    let dynamic_tools = blueprint.dynamic_tools;
767    let workdir_tools_dir =
768        dynamic_tools.then(|| std::path::PathBuf::from(&args.workdir).join("tools"));
769    let (script_tools, script_tool_names, script_defs) = discover_script_tools(
770        &args.blueprint_path,
771        &builtin_names,
772        mcp_tool_defs,
773        workdir_tools_dir.clone(),
774    );
775    all_tool_defs.extend(script_defs);
776
777    // 3. Resolve stages against the world's providers.
778    let stages = {
779        let registry = &world
780            .get_resource::<Providers>()
781            .expect("Providers resource present in a PipelineWorld")
782            .0;
783        resolve_stages(
784            &blueprint,
785            args.model.as_deref(),
786            config,
787            registry,
788            &all_tool_defs,
789        )
790    };
791
792    // 4. Snapshot the blueprint bits we need after it's moved into the world.
793    let agent_name = blueprint.name.clone();
794    let num_stages = blueprint.stages.len();
795    let compaction = blueprint.compaction_config.clone();
796    let max_child_depth = blueprint.max_child_depth.unwrap_or(DEFAULT_SUBAGENT_DEPTH);
797    // Taint gate: opt-in via the blueprint's `[security]` block, else the global
798    // config's `taint_tracking`, else off. Cascading through
799    // `resolve_security` (rather than `unwrap_or_default`, which forced taint on
800    // for every agent because `SecurityConfig::default()` is taint-on) means a
801    // blueprint with no `[security]` block correctly inherits the global setting -
802    // off by default. When on, the agent's outbound tool calls are gated
803    // against its context taint + the policy allowlist; when off no gate is
804    // attached (zero enforcement overhead).
805    let security = leviath_core::taint::resolve_security(
806        config.taint_tracking,
807        blueprint.security.as_ref(),
808        None,
809    );
810    // The `[mcp_overrides]` from policy.toml (loaded into the world at daemon
811    // setup), applied to every gate this agent gets so a user's reclassified
812    // MCP tool is enforced, not just printed by `lev policy list`.
813    let mcp_overrides = world
814        .get_resource::<leviath_runtime::pipeline::PolicyGate>()
815        .map(|p| p.0.mcp_overrides.clone())
816        .unwrap_or_default();
817    let tool_sensitivities: Option<HashMap<String, leviath_core::TaintLevel>> =
818        security.taint_tracking.then(|| {
819            let mut gate = leviath_runtime::TaintGate::new(security.clone());
820            gate.apply_mcp_overrides(&mcp_overrides);
821            all_tool_defs
822                .iter()
823                .map(|t| {
824                    (
825                        t.name.clone(),
826                        gate.tool_classification(&t.name).sensitivity,
827                    )
828                })
829                .collect()
830        });
831    // Per-stage tool permissions (in stage order) + the entry stage's index, for
832    // the tool state's stage-scoped policy layer.
833    let stage_perms_by_index: Vec<HashMap<String, String>> = blueprint
834        .stages
835        .iter()
836        .map(|s| s.tool_permissions.clone())
837        .collect();
838    // Agent-level tool permissions (the manifest's top-level `[tool_permissions]`,
839    // recorded in blueprint metadata). Populates the tool state's agent-level
840    // policy layer (between stage and global in `resolve_policy`) - without this
841    // the manifest's top-level block would be silently ignored.
842    let agent_perms = blueprint.agent_tool_permissions();
843    // Each stage's `available_tools` (Layer-1 allowlist), captured before the
844    // blueprint moves - a `dynamic_tools` agent re-filters against these on refresh.
845    let stage_available: Vec<Vec<String>> = blueprint
846        .stages
847        .iter()
848        .map(|s| s.available_tools.clone())
849        .collect();
850    let model_label = stages
851        .first()
852        .map(|s| format!("{}/{}", s.provider_name, s.model));
853
854    // 5. Resolve region seeds (caller input + blueprint-declared sources) into
855    // concrete content. On a fresh spawn (`enforce_seeds`), required caller-input
856    // regions that weren't provided fail here - before any inference, so no
857    // tokens are spent. On reload the window is restored from a snapshot after
858    // this, so seeding is skipped entirely.
859    // Command seeds (issue #108) run here, so they inherit the entry stage's
860    // sandbox (built in step 2a above) and are refused by either the machine-wide
861    // `[security] allow_seed_commands` switch or this run's `--no-seed-commands`.
862    let seeds = if enforce_seeds {
863        let policy = SeedCommandPolicy::new(
864            config.security.allow_seed_commands && !args.no_seed_commands,
865            std::time::Duration::from_secs(config.limits.script_shell_timeout_secs),
866            sandbox.clone(),
867        );
868        resolve_seeds(&blueprint, args, &args.workdir, &policy)?
869    } else {
870        HashMap::new()
871    };
872
873    // 5b. Read + compile-check custom regions' Rhai scripts (issue #152) -
874    // once per distinct path, blueprint-dir-relative. Runs on fresh spawns
875    // AND reloads (the hooks must work after a restart), and a broken script
876    // is a hard error either way.
877    let region_scripts = resolve_region_scripts(&blueprint, &args.blueprint_path)?;
878
879    // 6. Spawn the agent.
880    let entity = spawn_agent_seeded(
881        world,
882        args.run_id.clone(),
883        blueprint,
884        &seeds,
885        stages,
886        config.batch_tool_hint,
887        region_scripts,
888    )?;
889
890    // 7. Attach run metadata / token totals / persistence watermark (+ optional
891    // compaction settings).
892    let metadata = RunMetadata {
893        run_id: args.run_id.clone(),
894        agent_name: agent_name.clone(),
895        agent_path: args.blueprint_path.clone(),
896        task: args.task.clone(),
897        model: model_label,
898        workdir: args.workdir.clone(),
899        num_stages,
900        started_at: now_secs,
901        parent_run_id: args.parent_run_id.clone(),
902        metadata: args.metadata.clone(),
903        callback_url: args.callback_url.clone(),
904        callback_secret: args.callback_secret.clone(),
905        title: None,
906    };
907    {
908        let mut entity_mut = world.entity_mut(entity);
909        entity_mut.insert((
910            metadata,
911            TokenTotals::default(),
912            PersistWatermark::default(),
913            // Fresh counters; a reloaded run gets its accumulated flags put back
914            // by `recovery::reload_persisted_agents`.
915            leviath_runtime::persistence::RunOutcomeFlags::default(),
916        ));
917        // Mark eligible runs for one-shot title generation (the `title` module
918        // fills `RunMetadata.title`, which the dashboard displays and
919        // searches). Root runs only: sub-agents inherit their parent's context
920        // in the run list, and titling every fan-out worker would multiply
921        // cheap-but-nonzero LLM calls for no UX gain.
922        (config.title.enabled && !args.task.is_empty() && args.parent_run_id.is_none())
923            .then_some(leviath_runtime::title::PendingTitle)
924            .into_iter()
925            .for_each(|marker| {
926                entity_mut.insert(marker);
927            });
928        // `--yolo` means run unattended, so a blueprint's stage-boundary
929        // checkpoints are approved rather than parked on a hub nobody is
930        // watching. (`.then_some(..).into_iter()` keeps the non-yolo path
931        // branch-free, matching the taint-gate marker below.)
932        args.yolo
933            .then_some(leviath_runtime::components::InteractionAutoApprove)
934            .into_iter()
935            .for_each(|marker| {
936                entity_mut.insert(marker);
937            });
938        // `Option`'s iterator inserts compaction settings when present without a
939        // dangling `if let` block-end region.
940        compaction.into_iter().for_each(|cc| {
941            entity_mut.insert(CompactionSettings(cc));
942        });
943        // Attach the taint gate + per-tool sensitivities and turn on the window's
944        // taint tracking when the blueprint opts in (`Option`'s iterator keeps the
945        // enforcement path region-free when taint is off).
946        tool_sensitivities.into_iter().for_each(|sensitivities| {
947            let mut gate = leviath_runtime::TaintGate::new(security.clone());
948            gate.apply_mcp_overrides(&mcp_overrides);
949            entity_mut.insert((
950                gate,
951                leviath_runtime::pipeline::ToolSensitivities(sensitivities),
952            ));
953            // `--yolo` means run unattended: waive taint-gate prompts (the
954            // tool-policy wildcard below doesn't cover them), so a headless run
955            // never blocks on a gate no one can answer.
956            if args.yolo {
957                entity_mut.insert(leviath_runtime::components::GateAutoApprove);
958            }
959            // `Option`'s iterator enables tracking without a dead "no window" arm
960            // (a freshly spawned agent always carries a ContextWindow).
961            entity_mut
962                .get_mut::<leviath_runtime::components::ContextWindow>()
963                .into_iter()
964                .for_each(|mut window| window.enable_taint_tracking());
965        });
966    }
967
968    // 8. Register the per-agent tool state.
969    // Launch overrides: `--yolo` allows every tool (`*` wildcard); `--allow X`
970    // allows tool `X` outright.
971    let mut launch_overrides: HashMap<String, crate::config::ToolPolicy> = HashMap::new();
972    if args.yolo {
973        launch_overrides.insert("*".to_string(), crate::config::ToolPolicy::Allow);
974    }
975    for tool in &args.allow {
976        launch_overrides.insert(tool.clone(), crate::config::ToolPolicy::Allow);
977    }
978    let subagent = SubAgentHandle {
979        sender: subagent_tx,
980        parent_run_id: args.run_id.clone(),
981        workdir: args.workdir.clone(),
982        max_depth: max_child_depth,
983        no_seed_commands: args.no_seed_commands,
984    };
985    // Rhai script-tool host (Layer 3): resolve `[tool_script_permissions]` once,
986    // with `read_file`/`shell` `inherit` deferring to the agent's own resolved
987    // policy for that built-in (evaluated against the entry stage).
988    let entry_stage_perms = stage_perms_by_index
989        .get(entry_index)
990        .cloned()
991        .unwrap_or_default();
992    // The agent may carry its own `[tool_script_permissions]` (it can ship its own
993    // tool scripts), overlaid per field on the global config.
994    let effective_script_perms = crate::daemon::script_host::effective_script_permissions(
995        &config.tool_script_permissions,
996        &content,
997    );
998    // Same ceiling `build_tool_state` resolves for the built-in tools: the
999    // global `[tool_permissions]` with this agent's `[agent_tool_permissions]`
1000    // grants overlaid. Passing the raw global map here would silently ignore a
1001    // per-agent grant when a script tool's `inherit` defers to the built-in.
1002    let agent_scoped_perms = config.permissions_for_agent(&agent_name);
1003    let script_allow = crate::daemon::script_host::resolve_script_permissions(
1004        &effective_script_perms,
1005        &|builtin| {
1006            crate::tools::resolve_policy(
1007                builtin,
1008                true,
1009                &launch_overrides,
1010                &entry_stage_perms,
1011                &agent_perms,
1012                &agent_scoped_perms,
1013            )
1014        },
1015    );
1016    let script_host: Arc<dyn leviath_scripting::ScriptHost> = Arc::new(
1017        crate::daemon::script_host::DaemonScriptHost::new(
1018            script_allow,
1019            std::path::PathBuf::from(&args.workdir),
1020        )
1021        // Route a script `shell()` through the agent's per-stage sandbox (so a
1022        // script can't escape the isolation the stage declared) and cap it at the
1023        // configured wall-clock timeout.
1024        .with_shell(
1025            sandbox.clone(),
1026            std::time::Duration::from_secs(config.limits.script_shell_timeout_secs),
1027        )
1028        // `[security] allow_local_network`. Off by default, so a `web_fetch` URL
1029        // the model picked out of attacker-influenced context cannot reach cloud
1030        // metadata, the user's own `lev serve`, or their LAN.
1031        .with_local_network(config.security.allow_local_network)
1032        // `[security] allow_env_vars`. Empty by default, so a script tool cannot
1033        // read the user's provider keys and post them somewhere.
1034        .with_env_allowlist(config.security.allow_env_vars.clone()),
1035    );
1036    // Build the dynamic-tools re-resolution context (issue #97 escape hatch) and
1037    // tag the entity `DynamicTools` so the runtime polls it for mid-run re-scans.
1038    let dynamic = dynamic_tools.then(|| {
1039        world
1040            .entity_mut(entity)
1041            .insert(leviath_runtime::pipeline::DynamicTools);
1042        Arc::new(crate::daemon::tool_service::DynamicToolCtx {
1043            scan_dirs: script_scan_dirs(&args.blueprint_path, workdir_tools_dir),
1044            reserved_names: reserved_tool_names(&builtin_names, mcp_tool_defs),
1045            static_defs: static_tool_defs,
1046            stage_available,
1047            dirty: Arc::new(std::sync::atomic::AtomicBool::new(false)),
1048        })
1049    });
1050    let state = build_tool_state(
1051        builtins,
1052        builtin_names,
1053        shared_mcp,
1054        config,
1055        hub,
1056        &args.run_id,
1057        &entry_stage,
1058        entry_index,
1059        stage_perms_by_index,
1060        agent_perms,
1061        &agent_name,
1062        launch_overrides,
1063        Some(subagent),
1064        sandbox,
1065        script_tools,
1066        script_tool_names,
1067        script_host,
1068        dynamic,
1069        args.yolo,
1070    );
1071    tool_service.register(entity, state);
1072
1073    Ok(entity)
1074}
1075
1076#[cfg(test)]
1077mod tests {
1078    use super::*;
1079    use leviath_runtime::world::PipelineWorld;
1080
1081    /// A throwaway sub-agent op sender for tests that don't exercise the bridge.
1082    fn sub_tx() -> UnboundedSender<SubAgentOp> {
1083        tokio::sync::mpsc::unbounded_channel().0
1084    }
1085
1086    #[test]
1087    fn discover_script_tools_registers_and_drops_collisions() {
1088        crate::test_support::with_tracing(|| {});
1089        // Point LEVIATH_HOME at an empty temp dir so the global tools/ scan is
1090        // hermetic (no real ~/.leviath/tools leaking in).
1091        let home = tempfile::tempdir().unwrap();
1092        temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1093            let agent_dir = tempfile::tempdir().unwrap();
1094            let tools = agent_dir.path().join("tools");
1095            std::fs::create_dir(&tools).unwrap();
1096            std::fs::write(tools.join("echo.rhai"), "// @tool echo\nparams.x").unwrap();
1097            // A tool named after a built-in must be dropped (never shadow it).
1098            std::fs::write(tools.join("read_file.rhai"), "// @tool read_file\n1").unwrap();
1099            // A tool colliding with an MCP tool is also dropped (exercises the
1100            // mcp_tool_defs reservation).
1101            std::fs::write(tools.join("mcp_tool.rhai"), "// @tool mcp_tool\n1").unwrap();
1102            // A malformed script is skipped + warned about (the skipped loop).
1103            std::fs::write(tools.join("bad.rhai"), "no tool directive\nlet").unwrap();
1104            // A tool requiring a capability this platform can't provide is dropped
1105            // (unknown cap name → never satisfiable). Desktop has every real cap,
1106            // so a bogus name is the portable way to exercise the drop branch.
1107            std::fs::write(
1108                tools.join("needs_gpu.rhai"),
1109                "// @tool needs_gpu\n// @requires gpu\n1",
1110            )
1111            .unwrap();
1112            // A tool requiring a capability the desktop platform *does* provide is kept.
1113            std::fs::write(
1114                tools.join("net_tool.rhai"),
1115                "// @tool net_tool\n// @requires network\n1",
1116            )
1117            .unwrap();
1118            let blueprint = agent_dir.path().join("agent.leviath");
1119
1120            let builtins: HashSet<String> = ["read_file".to_string()].into_iter().collect();
1121            let mcp = vec![leviath_providers::Tool {
1122                name: "mcp_tool".to_string(),
1123                description: String::new(),
1124                parameters: serde_json::json!({}),
1125            }];
1126            let (set, names, defs) =
1127                discover_script_tools(blueprint.to_str().unwrap(), &builtins, &mcp, None);
1128            // Compiled the valid ones; only the non-colliding, platform-satisfiable
1129            // ones are routable.
1130            assert!(set.contains("echo") && set.contains("read_file"));
1131            assert!(names.contains("echo"));
1132            assert!(!names.contains("read_file"));
1133            assert!(!names.contains("mcp_tool"));
1134            assert!(!names.contains("needs_gpu"), "unsatisfiable cap dropped");
1135            assert!(names.contains("net_tool"), "satisfiable cap kept");
1136            let mut def_names: Vec<&str> = defs.iter().map(|d| d.name.as_str()).collect();
1137            def_names.sort_unstable();
1138            assert_eq!(def_names, vec!["echo", "net_tool"]);
1139        });
1140    }
1141
1142    #[test]
1143    fn script_cap_maps_known_and_unknown_names() {
1144        use leviath_tools::ToolCapability::*;
1145        assert_eq!(script_cap("network"), Some(Network));
1146        assert_eq!(script_cap("http"), Some(Network));
1147        assert_eq!(script_cap("shell"), Some(ProcessSpawn));
1148        assert_eq!(script_cap("process_spawn"), Some(ProcessSpawn));
1149        assert_eq!(script_cap("filesystem"), Some(FileSystem));
1150        assert_eq!(script_cap("fs"), Some(FileSystem));
1151        assert_eq!(script_cap("gpu"), None);
1152    }
1153
1154    #[test]
1155    fn platform_satisfies_caps_gates_on_support() {
1156        use leviath_tools::{PlatformCapabilities, ToolCapability};
1157        // Empty requirement is always satisfied.
1158        let mobile = PlatformCapabilities::mobile();
1159        assert!(platform_satisfies_caps(&mobile, &[]));
1160        // Mobile has filesystem/network but not process spawning.
1161        assert!(platform_satisfies_caps(&mobile, &["network".to_string()]));
1162        assert!(!platform_satisfies_caps(&mobile, &["shell".to_string()]));
1163        // An unknown cap name is never satisfiable, even on a full desktop.
1164        let desktop = PlatformCapabilities::from_capabilities([
1165            ToolCapability::Network,
1166            ToolCapability::FileSystem,
1167            ToolCapability::ProcessSpawn,
1168        ]);
1169        assert!(!platform_satisfies_caps(&desktop, &["mystery".to_string()]));
1170    }
1171
1172    #[test]
1173    fn discover_script_tools_empty_when_no_tools_dir() {
1174        let home = tempfile::tempdir().unwrap();
1175        temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1176            let agent_dir = tempfile::tempdir().unwrap();
1177            let blueprint = agent_dir.path().join("agent.leviath");
1178            let (set, names, defs) =
1179                discover_script_tools(blueprint.to_str().unwrap(), &HashSet::new(), &[], None);
1180            assert!(set.is_empty() && names.is_empty() && defs.is_empty());
1181        });
1182    }
1183
1184    #[test]
1185    fn discover_script_tools_handles_pathless_blueprint() {
1186        // A blueprint path with no parent exercises the "no agent dir" arm; the
1187        // global tools/ scan still runs (empty here).
1188        let home = tempfile::tempdir().unwrap();
1189        temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
1190            let (set, _n, _d) = discover_script_tools("", &HashSet::new(), &[], None);
1191            assert!(set.is_empty());
1192        });
1193    }
1194    use leviath_core::blueprint::ModelEntry;
1195
1196    fn model_cfg(models: Vec<(&str, &str)>) -> ModelConfig {
1197        ModelConfig {
1198            models: models
1199                .into_iter()
1200                .map(|(p, m)| ModelEntry {
1201                    provider: p.to_string(),
1202                    model: m.to_string(),
1203                })
1204                .collect(),
1205            allow_user_default: true,
1206            parameters: HashMap::new(),
1207            request_timeout_secs: None,
1208        }
1209    }
1210
1211    fn registry_with(providers: &[&str]) -> ProviderRegistry {
1212        let mut r = ProviderRegistry::new();
1213        for p in providers {
1214            r.register(p.to_string(), Arc::new(FakeProvider));
1215        }
1216        r
1217    }
1218
1219    struct FakeProvider;
1220    #[async_trait::async_trait]
1221    impl leviath_providers::Provider for FakeProvider {
1222        async fn infer(
1223            &self,
1224            _r: leviath_providers::InferenceRequest,
1225        ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
1226            Err(leviath_providers::ProviderError::Other(
1227                "test provider".to_string(),
1228            ))
1229        }
1230        async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
1231            1
1232        }
1233        fn max_context_tokens(&self, _m: &str) -> usize {
1234            1000
1235        }
1236        fn name(&self) -> &str {
1237            "fake"
1238        }
1239        fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
1240            leviath_providers::ModelCapabilities::default()
1241        }
1242    }
1243
1244    #[test]
1245    fn resolve_full_override_wins() {
1246        let (p, m) = resolve_stage_model(
1247            &model_cfg(vec![("anthropic", "x")]),
1248            Some("openai/gpt-5"),
1249            &Config::default(),
1250            &registry_with(&[]),
1251        );
1252        assert_eq!((p.as_str(), m.as_str()), ("openai", "gpt-5"));
1253    }
1254
1255    #[test]
1256    fn resolve_first_available_model() {
1257        // anthropic not registered, openai is → picks openai.
1258        let (p, m) = resolve_stage_model(
1259            &model_cfg(vec![("anthropic", "a"), ("openai", "o")]),
1260            None,
1261            &Config::default(),
1262            &registry_with(&["openai"]),
1263        );
1264        assert_eq!((p.as_str(), m.as_str()), ("openai", "o"));
1265    }
1266
1267    #[test]
1268    fn resolve_model_only_override_keeps_available_provider() {
1269        let (p, m) = resolve_stage_model(
1270            &model_cfg(vec![("openai", "o")]),
1271            Some("gpt-override"),
1272            &Config::default(),
1273            &registry_with(&["openai"]),
1274        );
1275        assert_eq!((p.as_str(), m.as_str()), ("openai", "gpt-override"));
1276    }
1277
1278    #[test]
1279    fn resolve_user_default_when_nothing_listed_available() {
1280        // Listed provider "ghost" is unavailable; anthropic (the default) is.
1281        let config = Config {
1282            default_provider: "anthropic".to_string(),
1283            default_model: Some("claude-default".to_string()),
1284            ..Default::default()
1285        };
1286        let (p, m) = resolve_stage_model(
1287            &model_cfg(vec![("ghost", "g")]),
1288            None,
1289            &config,
1290            &registry_with(&["anthropic"]),
1291        );
1292        assert_eq!((p.as_str(), m.as_str()), ("anthropic", "claude-default"));
1293    }
1294
1295    #[test]
1296    fn resolve_user_default_with_model_override() {
1297        let config = Config {
1298            default_provider: "anthropic".to_string(),
1299            ..Default::default()
1300        };
1301        let (p, m) = resolve_stage_model(
1302            &model_cfg(vec![("ghost", "g")]),
1303            Some("just-a-model"),
1304            &config,
1305            &registry_with(&[]),
1306        );
1307        assert_eq!((p.as_str(), m.as_str()), ("anthropic", "just-a-model"));
1308    }
1309
1310    #[test]
1311    fn resolve_user_default_provider_unavailable_falls_through() {
1312        // allow_user_default, a default_model set, but the default provider isn't
1313        // registered ⇒ neither user-default branch fires ⇒ last resort.
1314        let config = Config {
1315            default_provider: "ghost-default".to_string(),
1316            default_model: Some("dm".to_string()),
1317            ..Default::default()
1318        };
1319        let (p, m) = resolve_stage_model(
1320            &model_cfg(vec![("ghost", "g")]),
1321            None,
1322            &config,
1323            &registry_with(&[]),
1324        );
1325        assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
1326    }
1327
1328    #[test]
1329    fn resolve_last_resort_first_listed() {
1330        // No override, nothing available, no usable default → first listed entry.
1331        let config = Config::default(); // default_model None
1332        let (p, m) = resolve_stage_model(
1333            &model_cfg(vec![("ghost", "g")]),
1334            None,
1335            &config,
1336            &registry_with(&[]),
1337        );
1338        assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
1339    }
1340
1341    #[test]
1342    fn resolve_no_user_default_uses_last_resort() {
1343        let mut cfg = model_cfg(vec![("ghost", "g")]);
1344        cfg.allow_user_default = false; // forbid the default fallback
1345        let config = Config {
1346            default_model: Some("would-be-default".to_string()),
1347            ..Default::default()
1348        };
1349        let (p, m) = resolve_stage_model(&cfg, None, &config, &registry_with(&["anthropic"]));
1350        assert_eq!((p.as_str(), m.as_str()), ("ghost", "g"));
1351    }
1352
1353    // ── build_agent (full spawn from a manifest) ──
1354
1355    use leviath_providers::Provider;
1356    use leviath_runtime::components::AgentStatus;
1357    use leviath_runtime::inference_pool::InferencePoolConfig;
1358    use tokio::runtime::Handle;
1359
1360    fn coder_manifest() -> String {
1361        // Self-contained fixture - not the shipped blueprint, so these spawn-logic
1362        // tests stay isolated from agents/coder edits.
1363        crate::test_support::inline_coder_manifest()
1364    }
1365
1366    fn test_world() -> (PipelineWorld, Arc<CliToolService>) {
1367        let cli = Arc::new(CliToolService::new());
1368        let world = PipelineWorld::new(
1369            registry_with(&["anthropic", "openai", "ollama"]),
1370            cli.clone(),
1371            InferencePoolConfig::new(),
1372            1,
1373            std::env::temp_dir(),
1374            Handle::current(),
1375        );
1376        (world, cli)
1377    }
1378
1379    fn spawn_args(path: &str) -> SpawnArgs {
1380        SpawnArgs {
1381            run_id: "run-x".to_string(),
1382            blueprint_path: path.to_string(),
1383            task: "do the thing".to_string(),
1384            regions: HashMap::new(),
1385            model: None,
1386            workdir: std::env::temp_dir().to_string_lossy().to_string(),
1387            metadata: HashMap::new(),
1388            callback_url: None,
1389            callback_secret: None,
1390            yolo: false,
1391            no_seed_commands: false,
1392            allow: Vec::new(),
1393            max_depth: None,
1394            parent_run_id: None,
1395        }
1396    }
1397
1398    // ─── resolve_region_scripts ──────────────────────────────────────────
1399
1400    /// Manifest with a global custom region and a per-stage one, both
1401    /// pointing into `hooks/` next to the manifest.
1402    fn custom_region_manifest() -> &'static str {
1403        "[agent]\nname = \"cr\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1404         [context.regions.brain]\nkind = \"custom\"\nscript = \"hooks/brain.rhai\"\nmax_tokens = 4000\n\n\
1405         [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\n\
1406         [stages.main.context.regions.stage_view]\nkind = \"custom\"\nscript = \"hooks/stage.rhai\"\nmax_tokens = 2000\n"
1407    }
1408
1409    #[test]
1410    fn resolve_region_scripts_empty_without_custom_regions() {
1411        let dir = tempfile::tempdir().unwrap();
1412        let manifest = dir.path().join("agent.leviath");
1413        let bp = leviath_core::manifest::parse_manifest(
1414            "[agent]\nname = \"plain\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1415             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1416        )
1417        .unwrap();
1418        let scripts = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1419        assert!(scripts.is_empty());
1420    }
1421
1422    #[test]
1423    fn resolve_region_scripts_collects_global_and_per_stage_layouts() {
1424        let dir = tempfile::tempdir().unwrap();
1425        let manifest = dir.path().join("agent.leviath");
1426        std::fs::create_dir(dir.path().join("hooks")).unwrap();
1427        std::fs::write(
1428            dir.path().join("hooks/brain.rhai"),
1429            "fn render(ctx) { \"b\" }",
1430        )
1431        .unwrap();
1432        std::fs::write(
1433            dir.path().join("hooks/stage.rhai"),
1434            "fn render(ctx) { \"s\" }",
1435        )
1436        .unwrap();
1437        let bp = leviath_core::manifest::parse_manifest(custom_region_manifest()).unwrap();
1438        let scripts = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1439        assert_eq!(scripts.len(), 2);
1440        assert!(scripts.contains_key("hooks/brain.rhai"));
1441        assert!(scripts.contains_key("hooks/stage.rhai"));
1442    }
1443
1444    #[test]
1445    fn resolve_region_scripts_reads_a_shared_path_once() {
1446        // Two regions declaring the same script share one compiled Arc.
1447        let dir = tempfile::tempdir().unwrap();
1448        let manifest = dir.path().join("agent.leviath");
1449        std::fs::create_dir(dir.path().join("hooks")).unwrap();
1450        std::fs::write(
1451            dir.path().join("hooks/shared.rhai"),
1452            "fn render(ctx) { \"x\" }",
1453        )
1454        .unwrap();
1455        let bp = leviath_core::manifest::parse_manifest(
1456            "[agent]\nname = \"cr\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1457             [context.regions.a]\nkind = \"custom\"\nscript = \"hooks/shared.rhai\"\nmax_tokens = 2000\n\n\
1458             [context.regions.b]\nkind = \"custom\"\nscript = \"hooks/shared.rhai\"\nmax_tokens = 2000\n\n\
1459             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1460        )
1461        .unwrap();
1462        let scripts = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap();
1463        assert_eq!(scripts.len(), 1);
1464    }
1465
1466    #[test]
1467    fn resolve_region_scripts_missing_file_is_a_hard_error() {
1468        let dir = tempfile::tempdir().unwrap();
1469        let manifest = dir.path().join("agent.leviath");
1470        let bp = leviath_core::manifest::parse_manifest(custom_region_manifest()).unwrap();
1471        let err = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap_err();
1472        assert!(err.contains("region 'brain'"), "{err}");
1473        assert!(err.contains("hooks/brain.rhai"), "{err}");
1474    }
1475
1476    #[test]
1477    fn resolve_region_scripts_uncompilable_script_is_a_hard_error() {
1478        let dir = tempfile::tempdir().unwrap();
1479        let manifest = dir.path().join("agent.leviath");
1480        std::fs::create_dir(dir.path().join("hooks")).unwrap();
1481        std::fs::write(dir.path().join("hooks/brain.rhai"), "fn render(ctx) {").unwrap();
1482        std::fs::write(
1483            dir.path().join("hooks/stage.rhai"),
1484            "fn render(ctx) { \"s\" }",
1485        )
1486        .unwrap();
1487        let bp = leviath_core::manifest::parse_manifest(custom_region_manifest()).unwrap();
1488        let err = resolve_region_scripts(&bp, &manifest.to_string_lossy()).unwrap_err();
1489        assert!(err.contains("failed to compile"), "{err}");
1490        assert!(err.contains("region 'brain'"), "{err}");
1491    }
1492
1493    #[tokio::test]
1494    async fn build_agent_fails_fast_on_a_broken_custom_region_script() {
1495        // The resolve error propagates out of build_agent before any tokens
1496        // are spent - a hook that silently never ran would change every
1497        // inference with no signal.
1498        let dir = tempfile::tempdir().unwrap();
1499        let manifest = dir.path().join("agent.leviath");
1500        std::fs::write(&manifest, custom_region_manifest()).unwrap();
1501
1502        let (mut world, cli) = test_world();
1503        let hub = InteractionHub::new();
1504        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1505        let args = spawn_args(&manifest.to_string_lossy());
1506        let err = build_agent(
1507            world.world_mut(),
1508            cli.as_ref(),
1509            &Config::default(),
1510            mcp,
1511            &[],
1512            &hub,
1513            &args,
1514            100,
1515            sub_tx(),
1516        )
1517        .unwrap_err();
1518        assert!(err.contains("region 'brain'"), "got: {err}");
1519        assert!(err.contains("hooks/brain.rhai"), "got: {err}");
1520    }
1521
1522    #[tokio::test]
1523    async fn build_agent_rejects_a_workdir_that_is_missing_or_not_a_directory() {
1524        // `ToolContext::new` silently keeps a path it can't canonicalize, so
1525        // without this check a bogus workdir spawns a healthy-looking agent
1526        // whose every tool call then fails with ENOENT (issue #107).
1527        let dir = tempfile::tempdir().unwrap();
1528        let manifest = dir.path().join("agent.leviath");
1529        std::fs::write(
1530            &manifest,
1531            "[agent]\nname = \"w\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1532             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1533        )
1534        .unwrap();
1535        let not_a_dir = dir.path().join("a-file");
1536        std::fs::write(&not_a_dir, "x").unwrap();
1537
1538        for workdir in [
1539            dir.path()
1540                .join("does-not-exist")
1541                .to_string_lossy()
1542                .to_string(),
1543            not_a_dir.to_string_lossy().to_string(),
1544        ] {
1545            let (mut world, cli) = test_world();
1546            let hub = InteractionHub::new();
1547            let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1548            let mut args = spawn_args(&manifest.to_string_lossy());
1549            args.workdir = workdir.clone();
1550            let err = build_agent(
1551                world.world_mut(),
1552                cli.as_ref(),
1553                &Config::default(),
1554                mcp,
1555                &[],
1556                &hub,
1557                &args,
1558                100,
1559                sub_tx(),
1560            )
1561            .unwrap_err();
1562            assert!(err.contains("workspace"), "got: {err}");
1563            assert!(err.contains(&workdir), "got: {err}");
1564        }
1565    }
1566
1567    #[tokio::test]
1568    async fn build_agent_attaches_taint_gate_when_security_enabled() {
1569        let dir = tempfile::tempdir().unwrap();
1570        let manifest = dir.path().join("agent.leviath");
1571        std::fs::write(
1572            &manifest,
1573            "[agent]\nname = \"sec\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1574             [security]\ntaint_tracking = true\n\n\
1575             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1576        )
1577        .unwrap();
1578        let (mut world, cli) = test_world();
1579        let hub = InteractionHub::new();
1580        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1581        let entity = build_agent(
1582            world.world_mut(),
1583            cli.as_ref(),
1584            &Config::default(),
1585            mcp,
1586            &[],
1587            &hub,
1588            &spawn_args(&manifest.to_string_lossy()),
1589            100,
1590            sub_tx(),
1591        )
1592        .expect("spawn succeeds");
1593
1594        // Taint opt-in ⇒ gate + sensitivities attached and window tracking on.
1595        assert!(
1596            world
1597                .world()
1598                .get::<leviath_runtime::TaintGate>(entity)
1599                .is_some()
1600        );
1601        assert!(
1602            world
1603                .world()
1604                .get::<leviath_runtime::pipeline::ToolSensitivities>(entity)
1605                .is_some()
1606        );
1607        assert!(
1608            world
1609                .world()
1610                .get::<leviath_runtime::components::ContextWindow>(entity)
1611                .unwrap()
1612                .overall_taint()
1613                .is_some()
1614        );
1615        // Without `--yolo`, the gate stays interactive: no auto-approve marker.
1616        assert!(
1617            world
1618                .world()
1619                .get::<leviath_runtime::components::GateAutoApprove>(entity)
1620                .is_none()
1621        );
1622    }
1623
1624    #[tokio::test]
1625    async fn build_agent_marks_root_runs_for_titling_but_not_subagents() {
1626        let dir = tempfile::tempdir().unwrap();
1627        let manifest = dir.path().join("agent.leviath");
1628        std::fs::write(
1629            &manifest,
1630            "[agent]\nname = \"titler\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1631             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1632        )
1633        .unwrap();
1634        let (mut world, cli) = test_world();
1635        let hub = InteractionHub::new();
1636
1637        // Root run with the default-enabled [title] config: marked.
1638        let root = build_agent(
1639            world.world_mut(),
1640            cli.as_ref(),
1641            &Config::default(),
1642            Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1643            &[],
1644            &hub,
1645            &spawn_args(&manifest.to_string_lossy()),
1646            100,
1647            sub_tx(),
1648        )
1649        .expect("spawn succeeds");
1650        assert!(
1651            world
1652                .world()
1653                .get::<leviath_runtime::title::PendingTitle>(root)
1654                .is_some()
1655        );
1656
1657        // A sub-agent run is never marked: titles serve the top-level run list.
1658        let mut child_args = spawn_args(&manifest.to_string_lossy());
1659        child_args.run_id = "run-child".to_string();
1660        child_args.parent_run_id = Some("run-x".to_string());
1661        let child = build_agent(
1662            world.world_mut(),
1663            cli.as_ref(),
1664            &Config::default(),
1665            Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1666            &[],
1667            &hub,
1668            &child_args,
1669            100,
1670            sub_tx(),
1671        )
1672        .expect("spawn succeeds");
1673        assert!(
1674            world
1675                .world()
1676                .get::<leviath_runtime::title::PendingTitle>(child)
1677                .is_none()
1678        );
1679
1680        // Disabled config: not marked.
1681        let config = Config {
1682            title: leviath_core::config::TitleConfig {
1683                enabled: false,
1684                provider: None,
1685                model: None,
1686            },
1687            ..Config::default()
1688        };
1689        let mut off_args = spawn_args(&manifest.to_string_lossy());
1690        off_args.run_id = "run-off".to_string();
1691        let off = build_agent(
1692            world.world_mut(),
1693            cli.as_ref(),
1694            &config,
1695            Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new())),
1696            &[],
1697            &hub,
1698            &off_args,
1699            100,
1700            sub_tx(),
1701        )
1702        .expect("spawn succeeds");
1703        assert!(
1704            world
1705                .world()
1706                .get::<leviath_runtime::title::PendingTitle>(off)
1707                .is_none()
1708        );
1709    }
1710
1711    #[tokio::test]
1712    async fn build_agent_applies_policy_mcp_overrides_to_the_gate() {
1713        let dir = tempfile::tempdir().unwrap();
1714        let manifest = dir.path().join("agent.leviath");
1715        std::fs::write(
1716            &manifest,
1717            "[agent]\nname = \"sec-ov\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1718             [security]\ntaint_tracking = true\n\n\
1719             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1720        )
1721        .unwrap();
1722        let (mut world, cli) = test_world();
1723        // The daemon loads policy.toml into this resource at setup; an
1724        // [mcp_overrides] entry there must reach the gate attached at spawn,
1725        // not just `lev policy list` output.
1726        world
1727            .world_mut()
1728            .insert_resource(leviath_runtime::pipeline::PolicyGate(
1729                leviath_core::PolicyConfig {
1730                    allowlist: Vec::new(),
1731                    mcp_overrides: HashMap::from([(
1732                        "notes.share".to_string(),
1733                        leviath_core::policy::McpToolOverride {
1734                            sensitivity: None,
1735                            direction: Some("outbound".to_string()),
1736                            clearance: Some(leviath_core::TaintLevel::Private),
1737                        },
1738                    )]),
1739                },
1740            ));
1741        let hub = InteractionHub::new();
1742        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1743        let entity = build_agent(
1744            world.world_mut(),
1745            cli.as_ref(),
1746            &Config::default(),
1747            mcp,
1748            &[],
1749            &hub,
1750            &spawn_args(&manifest.to_string_lossy()),
1751            100,
1752            sub_tx(),
1753        )
1754        .expect("spawn succeeds");
1755
1756        let gate = world
1757            .world()
1758            .get::<leviath_runtime::TaintGate>(entity)
1759            .expect("gate attached");
1760        let classification = gate.tool_classification("notes.share");
1761        assert_eq!(
1762            classification.direction,
1763            leviath_core::taint::ToolDirection::Outbound
1764        );
1765        assert_eq!(classification.clearance, leviath_core::TaintLevel::Private);
1766    }
1767
1768    #[tokio::test]
1769    async fn build_agent_errors_when_required_caller_region_missing() {
1770        // A required caller-input region that the request doesn't provide makes
1771        // build_agent fail (via resolve_seeds) before spawning - no inference.
1772        let dir = tempfile::tempdir().unwrap();
1773        let manifest = dir.path().join("agent.leviath");
1774        std::fs::write(
1775            &manifest,
1776            "[agent]\nname = \"needs\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1777             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\n\
1778             [context.regions]\n\
1779             spec = { kind = \"pinned\", max_tokens = 2000, seed = \"input\", required = true }\n\
1780             conversation = { kind = \"sliding_window\", max_items = 20, max_tokens = 10000 }\n",
1781        )
1782        .unwrap();
1783        let (mut world, cli) = test_world();
1784        let hub = InteractionHub::new();
1785        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1786        // spawn_args() provides only the task, not the required `spec` region.
1787        let err = build_agent(
1788            world.world_mut(),
1789            cli.as_ref(),
1790            &Config::default(),
1791            mcp,
1792            &[],
1793            &hub,
1794            &spawn_args(&manifest.to_string_lossy()),
1795            100,
1796            sub_tx(),
1797        )
1798        .unwrap_err();
1799        assert!(err.contains("spec"), "got: {err}");
1800    }
1801
1802    #[tokio::test]
1803    async fn build_agent_attaches_sandbox_when_configured() {
1804        // A `namespace` sandbox with `on_unavailable = "warn"` builds on every
1805        // platform without running any external command, so this deterministically
1806        // exercises the spawn-side sandbox wiring (manager built + attached).
1807        let dir = tempfile::tempdir().unwrap();
1808        let manifest = dir.path().join("agent.leviath");
1809        std::fs::write(
1810            &manifest,
1811            "[agent]\nname = \"sb\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1812             [sandbox]\nkind = \"namespace\"\non_unavailable = \"warn\"\n\n\
1813             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1814        )
1815        .unwrap();
1816        let (mut world, cli) = test_world();
1817        let hub = InteractionHub::new();
1818        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1819        let entity = build_agent(
1820            world.world_mut(),
1821            cli.as_ref(),
1822            &Config::default(),
1823            mcp,
1824            &[],
1825            &hub,
1826            &spawn_args(&manifest.to_string_lossy()),
1827            100,
1828            sub_tx(),
1829        )
1830        .expect("spawn succeeds");
1831        // The agent's tool state carries a sandbox manager.
1832        let state = cli.take(entity).expect("state registered");
1833        assert!(state.sandbox.is_some(), "sandbox manager attached");
1834    }
1835
1836    #[tokio::test]
1837    async fn build_agent_errors_when_sandbox_runtime_unavailable() {
1838        // A container sandbox naming a nonexistent engine fails to start on every
1839        // platform (no runtime needed), so build_agent surfaces the error - this
1840        // covers the `?` on `SandboxManager::build` uniformly across OSes,
1841        // independent of which container runtimes happen to be installed.
1842        let dir = tempfile::tempdir().unwrap();
1843        let manifest = dir.path().join("agent.leviath");
1844        std::fs::write(
1845            &manifest,
1846            "[agent]\nname = \"sb\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1847             [sandbox]\nkind = \"container\"\nimage = \"x\"\nengine = \"leviath-no-such-engine\"\n\n\
1848             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1849        )
1850        .unwrap();
1851        let (mut world, cli) = test_world();
1852        let hub = InteractionHub::new();
1853        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1854        let err = build_agent(
1855            world.world_mut(),
1856            cli.as_ref(),
1857            &Config::default(),
1858            mcp,
1859            &[],
1860            &hub,
1861            &spawn_args(&manifest.to_string_lossy()),
1862            100,
1863            sub_tx(),
1864        )
1865        .expect_err("a nonexistent engine can't start the container");
1866        assert!(err.contains("sandbox unavailable"), "got: {err}");
1867    }
1868
1869    #[tokio::test]
1870    async fn build_agent_yolo_attaches_gate_auto_approve_when_taint_on() {
1871        let dir = tempfile::tempdir().unwrap();
1872        let manifest = dir.path().join("agent.leviath");
1873        std::fs::write(
1874            &manifest,
1875            "[agent]\nname = \"sec\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1876             [security]\ntaint_tracking = true\n\n\
1877             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1878        )
1879        .unwrap();
1880        let (mut world, cli) = test_world();
1881        let hub = InteractionHub::new();
1882        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1883        let mut args = spawn_args(&manifest.to_string_lossy());
1884        args.yolo = true;
1885        let entity = build_agent(
1886            world.world_mut(),
1887            cli.as_ref(),
1888            &Config::default(),
1889            mcp,
1890            &[],
1891            &hub,
1892            &args,
1893            100,
1894            sub_tx(),
1895        )
1896        .expect("spawn succeeds");
1897        // Taint on + `--yolo` ⇒ gate is auto-approved (marker attached) so a
1898        // headless run never blocks on a gate prompt.
1899        assert!(
1900            world
1901                .world()
1902                .get::<leviath_runtime::components::GateAutoApprove>(entity)
1903                .is_some()
1904        );
1905        // ...and likewise for the blueprint's own stage-boundary checkpoints and
1906        // the agent's `ask_user_*` tools (#107): unattended means unattended.
1907        assert!(
1908            world
1909                .world()
1910                .get::<leviath_runtime::components::InteractionAutoApprove>(entity)
1911                .is_some()
1912        );
1913        assert!(cli.take(entity).expect("tool state registered").unattended);
1914    }
1915
1916    #[tokio::test]
1917    async fn build_agent_without_yolo_keeps_prompts_interactive() {
1918        let dir = tempfile::tempdir().unwrap();
1919        let manifest = dir.path().join("agent.leviath");
1920        std::fs::write(
1921            &manifest,
1922            "[agent]\nname = \"plain\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1923             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1924        )
1925        .unwrap();
1926        let (mut world, cli) = test_world();
1927        let hub = InteractionHub::new();
1928        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1929        let entity = build_agent(
1930            world.world_mut(),
1931            cli.as_ref(),
1932            &Config::default(),
1933            mcp,
1934            &[],
1935            &hub,
1936            &spawn_args(&manifest.to_string_lossy()),
1937            100,
1938            sub_tx(),
1939        )
1940        .expect("spawn succeeds");
1941        assert!(
1942            world
1943                .world()
1944                .get::<leviath_runtime::components::InteractionAutoApprove>(entity)
1945                .is_none()
1946        );
1947        assert!(!cli.take(entity).expect("tool state registered").unattended);
1948    }
1949
1950    #[tokio::test]
1951    async fn build_agent_no_security_block_leaves_taint_off_by_default() {
1952        // Bug regression: a blueprint with no `[security]` block and a default
1953        // (taint-off) global config must NOT attach the taint gate - an
1954        // `unwrap_or_default()` on the resolved security forces it on for
1955        // every agent.
1956        let dir = tempfile::tempdir().unwrap();
1957        let manifest = dir.path().join("agent.leviath");
1958        std::fs::write(
1959            &manifest,
1960            "[agent]\nname = \"plain\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
1961             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
1962        )
1963        .unwrap();
1964        let (mut world, cli) = test_world();
1965        let hub = InteractionHub::new();
1966        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1967        let entity = build_agent(
1968            world.world_mut(),
1969            cli.as_ref(),
1970            &Config::default(), // taint_tracking defaults to false
1971            mcp,
1972            &[],
1973            &hub,
1974            &spawn_args(&manifest.to_string_lossy()),
1975            100,
1976            sub_tx(),
1977        )
1978        .expect("spawn succeeds");
1979        assert!(
1980            world
1981                .world()
1982                .get::<leviath_runtime::TaintGate>(entity)
1983                .is_none(),
1984            "no [security] block + global off ⇒ no taint gate"
1985        );
1986    }
1987
1988    #[tokio::test]
1989    async fn build_agent_spawns_registers_and_wires_tools() {
1990        let dir = tempfile::tempdir().unwrap();
1991        let manifest = dir.path().join("agent.leviath");
1992        std::fs::write(&manifest, coder_manifest()).unwrap();
1993
1994        let (mut world, cli) = test_world();
1995        let hub = InteractionHub::new();
1996        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
1997        let entity = build_agent(
1998            world.world_mut(),
1999            cli.as_ref(),
2000            &Config::default(),
2001            mcp,
2002            &[],
2003            &hub,
2004            &spawn_args(&manifest.to_string_lossy()),
2005            100,
2006            sub_tx(),
2007        )
2008        .expect("spawn succeeds");
2009
2010        assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
2011        // The run metadata was attached.
2012        let md = world
2013            .world()
2014            .get::<RunMetadata>(entity)
2015            .expect("run metadata");
2016        assert_eq!(md.run_id, "run-x");
2017        assert_eq!(md.agent_name, "coder");
2018        // Tool state was registered: a tool batch dispatches (not "no tool state").
2019        let out = leviath_runtime::pipeline::ToolService::exec_for(
2020            cli.as_ref(),
2021            entity,
2022            vec![leviath_providers::ToolCall {
2023                id: "c1".to_string(),
2024                name: "list_dir".to_string(),
2025                arguments: serde_json::json!({"path": "."}),
2026                thought_signature: None,
2027            }],
2028        )()
2029        .await;
2030        assert_eq!(out[0].0, "c1");
2031        assert!(!out[0].1.contains("no tool state"));
2032    }
2033
2034    #[tokio::test]
2035    async fn build_agent_tags_dynamic_tools_agent() {
2036        // A blueprint opting into dynamic_tools gets the DynamicTools marker so the
2037        // runtime polls it for mid-run re-scans; the agent's tool state carries the
2038        // re-resolution context (exercised via refresh_tools).
2039        let dir = tempfile::tempdir().unwrap();
2040        let manifest = dir.path().join("agent.leviath");
2041        std::fs::write(
2042            &manifest,
2043            coder_manifest().replace("[agent]", "[agent]\ndynamic_tools = true"),
2044        )
2045        .unwrap();
2046
2047        let (mut world, cli) = test_world();
2048        let hub = InteractionHub::new();
2049        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2050        let entity = build_agent(
2051            world.world_mut(),
2052            cli.as_ref(),
2053            &Config::default(),
2054            mcp,
2055            &[],
2056            &hub,
2057            &spawn_args(&manifest.to_string_lossy()),
2058            100,
2059            sub_tx(),
2060        )
2061        .expect("spawn succeeds");
2062
2063        assert!(
2064            world
2065                .world()
2066                .get::<leviath_runtime::pipeline::DynamicTools>(entity)
2067                .is_some(),
2068            "dynamic_tools agent must carry the DynamicTools marker"
2069        );
2070        // The dynamic context is wired: refresh_tools returns Some for stage 0.
2071        assert!(
2072            leviath_runtime::pipeline::ToolService::refresh_tools(cli.as_ref(), entity, 0)
2073                .is_some()
2074        );
2075    }
2076
2077    #[tokio::test]
2078    async fn build_agent_applies_yolo_allow_and_max_depth() {
2079        let dir = tempfile::tempdir().unwrap();
2080        let manifest = dir.path().join("agent.leviath");
2081        std::fs::write(&manifest, coder_manifest()).unwrap();
2082
2083        let (mut world, cli) = test_world();
2084        let hub = InteractionHub::new();
2085        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2086        // The user's config denies read_file. Neither `--yolo` nor an explicit
2087        // `--allow read_file` lifts that: a deny rule is a decision, and skipping
2088        // *prompts* is all `--yolo` is for.
2089        let config = Config {
2090            tool_permissions: HashMap::from([(
2091                "read_file".to_string(),
2092                crate::config::ToolPolicy::Deny,
2093            )]),
2094            ..Default::default()
2095        };
2096        let mut args = spawn_args(&manifest.to_string_lossy());
2097        args.yolo = true;
2098        args.allow = vec!["read_file".to_string()];
2099        args.max_depth = Some(7);
2100
2101        let entity = build_agent(
2102            world.world_mut(),
2103            cli.as_ref(),
2104            &config,
2105            mcp,
2106            &[],
2107            &hub,
2108            &args,
2109            100,
2110            sub_tx(),
2111        )
2112        .expect("spawn succeeds");
2113        assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
2114
2115        // The config deny stands: read_file is refused, not executed.
2116        let out = leviath_runtime::pipeline::ToolService::exec_for(
2117            cli.as_ref(),
2118            entity,
2119            vec![leviath_providers::ToolCall {
2120                id: "c1".to_string(),
2121                name: "read_file".to_string(),
2122                arguments: serde_json::json!({"path": "/no/such/file"}),
2123                thought_signature: None,
2124            }],
2125        )()
2126        .await;
2127        let result = out[0].1.clone();
2128        assert!(
2129            result.contains("[denied]"),
2130            "a configured deny must survive --yolo, got: {result}"
2131        );
2132
2133        // `--yolo` still does its job for a tool the config did not deny:
2134        // `list_dir` runs unattended with no approval prompt.
2135        let out = leviath_runtime::pipeline::ToolService::exec_for(
2136            cli.as_ref(),
2137            entity,
2138            vec![leviath_providers::ToolCall {
2139                id: "c2".to_string(),
2140                name: "list_dir".to_string(),
2141                arguments: serde_json::json!({"path": "."}),
2142                thought_signature: None,
2143            }],
2144        )()
2145        .await;
2146        let result = out[0].1.clone();
2147        assert!(
2148            !result.contains("[denied]"),
2149            "--yolo must still waive approval where nothing denies, got: {result}"
2150        );
2151    }
2152
2153    #[tokio::test]
2154    async fn build_agent_honors_agent_level_tool_permissions() {
2155        let dir = tempfile::tempdir().unwrap();
2156        let manifest = dir.path().join("agent.leviath");
2157        // A top-level `[tool_permissions]` block denying a builtin - no stage
2158        // perms, no launch overrides, no global config deny. Only the agent-level
2159        // layer can produce the deny, so this proves it is wired through.
2160        std::fs::write(
2161            &manifest,
2162            "[agent]\nname = \"perm\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2163             [tool_permissions]\nread_file = \"deny\"\n\n\
2164             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2165        )
2166        .unwrap();
2167
2168        let (mut world, cli) = test_world();
2169        let hub = InteractionHub::new();
2170        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2171        let entity = build_agent(
2172            world.world_mut(),
2173            cli.as_ref(),
2174            &Config::default(),
2175            mcp,
2176            &[],
2177            &hub,
2178            &spawn_args(&manifest.to_string_lossy()),
2179            100,
2180            sub_tx(),
2181        )
2182        .expect("spawn succeeds");
2183
2184        let out = leviath_runtime::pipeline::ToolService::exec_for(
2185            cli.as_ref(),
2186            entity,
2187            vec![leviath_providers::ToolCall {
2188                id: "c1".to_string(),
2189                name: "read_file".to_string(),
2190                arguments: serde_json::json!({"path": "/no/such/file"}),
2191                thought_signature: None,
2192            }],
2193        )()
2194        .await;
2195        assert!(
2196            out[0].1.contains("[denied]"),
2197            "agent-level deny should block read_file"
2198        );
2199    }
2200
2201    #[tokio::test]
2202    async fn build_agent_script_host_honors_agent_level_grants() {
2203        let dir = tempfile::tempdir().unwrap();
2204        let manifest = dir.path().join("agent.leviath");
2205        std::fs::write(
2206            &manifest,
2207            "[agent]\nname = \"scriptperm\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2208             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2209        )
2210        .unwrap();
2211
2212        // `write_file` defaults to Ask, and a script-permission `Inherit`
2213        // permits the host function only on a hard Allow. The grant below
2214        // lives solely in the user's per-agent block, so the script host can
2215        // only see it through the agent-scoped ceiling - the raw global
2216        // `[tool_permissions]` map is empty here.
2217        let mut config = Config::default();
2218        config.agent_tool_permissions.insert(
2219            "scriptperm".to_string(),
2220            HashMap::from([("write_file".to_string(), crate::config::ToolPolicy::Allow)]),
2221        );
2222
2223        let (mut world, cli) = test_world();
2224        let hub = InteractionHub::new();
2225        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2226        let mut args = spawn_args(&manifest.to_string_lossy());
2227        args.workdir = dir.path().to_string_lossy().to_string();
2228        let entity = build_agent(
2229            world.world_mut(),
2230            cli.as_ref(),
2231            &config,
2232            mcp,
2233            &[],
2234            &hub,
2235            &args,
2236            100,
2237            sub_tx(),
2238        )
2239        .expect("spawn succeeds");
2240
2241        let state = cli.take(entity).expect("tool state registered at spawn");
2242        state
2243            .script_host
2244            .write_file("granted.txt", "ok")
2245            .expect("agent-level write_file grant must reach the script host");
2246        assert_eq!(
2247            std::fs::read_to_string(dir.path().join("granted.txt")).unwrap(),
2248            "ok"
2249        );
2250    }
2251
2252    #[tokio::test]
2253    async fn build_agent_applies_default_max_iterations_only_when_stage_omits_it() {
2254        let dir = tempfile::tempdir().unwrap();
2255        let manifest = dir.path().join("agent.leviath");
2256        // Two stages: one omits max_iterations, one sets it explicitly to 3.
2257        std::fs::write(
2258            &manifest,
2259            "[agent]\nname = \"iters\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2260             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n\n\
2261             [stages.capped]\nmax_iterations = 3\n\
2262             model = { provider = \"anthropic\", model = \"m\" }\n",
2263        )
2264        .unwrap();
2265
2266        let (mut world, cli) = test_world();
2267        let hub = InteractionHub::new();
2268        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2269        // A non-default cap so the assertion can't accidentally match the built-in.
2270        let config = Config {
2271            limits: crate::config::LimitsConfig {
2272                default_max_iterations: Some(42),
2273                ..Default::default()
2274            },
2275            ..Default::default()
2276        };
2277        let entity = build_agent(
2278            world.world_mut(),
2279            cli.as_ref(),
2280            &config,
2281            mcp,
2282            &[],
2283            &hub,
2284            &spawn_args(&manifest.to_string_lossy()),
2285            100,
2286            sub_tx(),
2287        )
2288        .expect("spawn succeeds");
2289
2290        let bp = world
2291            .world()
2292            .get::<leviath_runtime::pipeline::AgentBlueprint>(entity)
2293            .expect("blueprint");
2294        let by_name = |n: &str| {
2295            bp.0.stages
2296                .iter()
2297                .find(|s| s.name == n)
2298                .unwrap()
2299                .max_iterations
2300        };
2301        // The stage that omitted it inherits the config default …
2302        assert_eq!(by_name("main"), Some(42));
2303        // … while an explicit per-stage cap is left untouched.
2304        assert_eq!(by_name("capped"), Some(3));
2305    }
2306
2307    #[tokio::test]
2308    async fn build_agent_leaves_max_iterations_unset_when_config_default_is_none() {
2309        let dir = tempfile::tempdir().unwrap();
2310        let manifest = dir.path().join("agent.leviath");
2311        std::fs::write(
2312            &manifest,
2313            "[agent]\nname = \"nolimit\"\nversion = \"0.1.0\"\ndescription = \"d\"\n\n\
2314             [stages.main]\nmodel = { provider = \"anthropic\", model = \"m\" }\n",
2315        )
2316        .unwrap();
2317
2318        let (mut world, cli) = test_world();
2319        let hub = InteractionHub::new();
2320        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2321        // `None` disables the config default entirely - the stage stays uncapped.
2322        let config = Config {
2323            limits: crate::config::LimitsConfig {
2324                default_max_iterations: None,
2325                ..Default::default()
2326            },
2327            ..Default::default()
2328        };
2329        let entity = build_agent(
2330            world.world_mut(),
2331            cli.as_ref(),
2332            &config,
2333            mcp,
2334            &[],
2335            &hub,
2336            &spawn_args(&manifest.to_string_lossy()),
2337            100,
2338            sub_tx(),
2339        )
2340        .expect("spawn succeeds");
2341
2342        let bp = world
2343            .world()
2344            .get::<leviath_runtime::pipeline::AgentBlueprint>(entity)
2345            .expect("blueprint");
2346        assert_eq!(bp.0.stages[0].max_iterations, None);
2347    }
2348
2349    #[tokio::test]
2350    async fn fake_provider_methods_are_exercised() {
2351        let p = FakeProvider;
2352        assert_eq!(p.name(), "fake");
2353        assert_eq!(p.count_tokens("t", "m").await, 1);
2354        assert_eq!(p.max_context_tokens("m"), 1000);
2355        let _ = p.capabilities("m");
2356        assert!(
2357            p.infer(leviath_providers::InferenceRequest {
2358                system: vec![],
2359                messages: vec![],
2360                model: "m".to_string(),
2361                max_tokens: 1,
2362                temperature: 0.0,
2363                tools: vec![],
2364                extra: serde_json::Value::Null,
2365                request_timeout_secs: None,
2366            })
2367            .await
2368            .is_err()
2369        );
2370    }
2371
2372    #[test]
2373    fn resolve_stages_empty_available_tools_gets_none() {
2374        let mut stage =
2375            leviath_core::Stage::new("s".to_string(), model_cfg(vec![("anthropic", "m")]));
2376        stage.available_tools = vec![]; // empty ⇒ no tools
2377        let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
2378        let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
2379        let tools = vec![Tool {
2380            name: "read_file".to_string(),
2381            description: String::new(),
2382            parameters: serde_json::Value::Null,
2383        }];
2384        let resolved = resolve_stages(
2385            &bp,
2386            None,
2387            &Config::default(),
2388            &registry_with(&["anthropic"]),
2389            &tools,
2390        );
2391        assert!(resolved[0].tools.is_empty());
2392    }
2393
2394    #[test]
2395    fn resolve_stages_matches_by_alias_and_skips_unknown_names() {
2396        // A stage names `bash` (an alias) and a not-installed MCP tool. The
2397        // filter must select the canonical `shell` definition for the alias and
2398        // silently omit the unknown name (no error, no panic).
2399        let mut stage =
2400            leviath_core::Stage::new("s".to_string(), model_cfg(vec![("anthropic", "m")]));
2401        stage.available_tools = vec!["bash".to_string(), "acme__uninstalled".to_string()];
2402        let layout = leviath_core::layout::ContextLayout::new(vec![], 1000);
2403        let bp = Blueprint::new("t".to_string(), "d".to_string(), vec![stage], layout);
2404        let tools = vec![
2405            Tool {
2406                name: "shell".to_string(),
2407                description: String::new(),
2408                parameters: serde_json::Value::Null,
2409            },
2410            Tool {
2411                name: "read_file".to_string(),
2412                description: String::new(),
2413                parameters: serde_json::Value::Null,
2414            },
2415        ];
2416        let resolved = resolve_stages(
2417            &bp,
2418            None,
2419            &Config::default(),
2420            &registry_with(&["anthropic"]),
2421            &tools,
2422        );
2423        let selected: Vec<&str> = resolved[0].tools.iter().map(|t| t.name.as_str()).collect();
2424        // `bash` resolved to `shell`; the unknown MCP name and unlisted
2425        // `read_file` were both excluded.
2426        assert_eq!(selected, vec!["shell"]);
2427    }
2428
2429    #[tokio::test]
2430    async fn build_agent_read_error() {
2431        let (mut world, cli) = test_world();
2432        let hub = InteractionHub::new();
2433        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2434        let err = build_agent(
2435            world.world_mut(),
2436            cli.as_ref(),
2437            &Config::default(),
2438            mcp,
2439            &[],
2440            &hub,
2441            &spawn_args("/no/such/manifest.leviath"),
2442            100,
2443            sub_tx(),
2444        )
2445        .unwrap_err();
2446        assert!(err.contains("read manifest"));
2447    }
2448
2449    /// A minimal single-stage manifest with a tiny task region and a `system_prompt`
2450    /// large enough to overflow it, so stage-0 setup fails in `spawn_agent`.
2451    const OVERSIZED_MANIFEST: &str = r#"
2452[agent]
2453name = "tiny"
2454version = "0.1.0"
2455description = "d"
2456entry_stage = "main"
2457
2458[context.regions]
2459task = { kind = "pinned", max_tokens = 20 }
2460
2461[stages.main]
2462mode = "autonomous"
2463model = { models = [{ provider = "anthropic", model = "m" }] }
2464description = "d"
2465available_tools = []
2466system_prompt = "SYSTEM_PROMPT_PLACEHOLDER"
2467"#;
2468
2469    #[tokio::test]
2470    async fn build_agent_propagates_spawn_error() {
2471        let dir = tempfile::tempdir().unwrap();
2472        let manifest = dir.path().join("tiny.leviath");
2473        // A huge prompt that cannot fit the 20-token "task" region.
2474        let content = OVERSIZED_MANIFEST.replace("SYSTEM_PROMPT_PLACEHOLDER", &"x ".repeat(5000));
2475        std::fs::write(&manifest, content).unwrap();
2476
2477        let (mut world, cli) = test_world();
2478        let hub = InteractionHub::new();
2479        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2480        let result = build_agent(
2481            world.world_mut(),
2482            cli.as_ref(),
2483            &Config::default(),
2484            mcp,
2485            &[],
2486            &hub,
2487            &spawn_args(&manifest.to_string_lossy()),
2488            100,
2489            sub_tx(),
2490        );
2491        assert!(result.is_err(), "expected spawn error, got {result:?}");
2492    }
2493
2494    #[tokio::test]
2495    async fn build_agent_invalid_blueprint() {
2496        let dir = tempfile::tempdir().unwrap();
2497        let manifest = dir.path().join("bad.leviath");
2498        // entry_stage names a stage that doesn't exist ⇒ validate() fails.
2499        std::fs::write(
2500            &manifest,
2501            r#"
2502[agent]
2503name = "bad"
2504version = "0.1.0"
2505description = "d"
2506entry_stage = "ghost"
2507
2508[context.regions]
2509task = { kind = "pinned", max_tokens = 4000 }
2510
2511[stages.main]
2512mode = "autonomous"
2513model = { models = [{ provider = "anthropic", model = "m" }] }
2514description = "d"
2515available_tools = []
2516"#,
2517        )
2518        .unwrap();
2519        let (mut world, cli) = test_world();
2520        let hub = InteractionHub::new();
2521        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2522        let err = build_agent(
2523            world.world_mut(),
2524            cli.as_ref(),
2525            &Config::default(),
2526            mcp,
2527            &[],
2528            &hub,
2529            &spawn_args(&manifest.to_string_lossy()),
2530            100,
2531            sub_tx(),
2532        )
2533        .unwrap_err();
2534        assert!(err.contains("invalid blueprint"));
2535    }
2536
2537    #[tokio::test]
2538    async fn build_agent_without_entry_stage_and_with_compaction() {
2539        let dir = tempfile::tempdir().unwrap();
2540        let manifest = dir.path().join("mini.leviath");
2541        // No entry_stage (falls back to the first stage) + a compaction section.
2542        std::fs::write(
2543            &manifest,
2544            r#"
2545[agent]
2546name = "mini"
2547version = "0.1.0"
2548description = "d"
2549
2550[compaction]
2551provider = "anthropic"
2552model = "claude-x"
2553
2554[context.regions]
2555task = { kind = "pinned", max_tokens = 4000 }
2556
2557[stages.main]
2558mode = "autonomous"
2559model = { models = [{ provider = "anthropic", model = "m" }] }
2560description = "d"
2561available_tools = []
2562system_prompt = "be brief"
2563"#,
2564        )
2565        .unwrap();
2566        let (mut world, cli) = test_world();
2567        let hub = InteractionHub::new();
2568        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2569        let entity = build_agent(
2570            world.world_mut(),
2571            cli.as_ref(),
2572            &Config::default(),
2573            mcp,
2574            &[],
2575            &hub,
2576            &spawn_args(&manifest.to_string_lossy()),
2577            100,
2578            sub_tx(),
2579        )
2580        .expect("spawn succeeds");
2581        assert_eq!(world.agent_status(entity), Some(AgentStatus::Active));
2582        // Compaction settings were attached.
2583        assert!(world.world().get::<CompactionSettings>(entity).is_some());
2584    }
2585
2586    #[tokio::test]
2587    async fn build_agent_parse_error() {
2588        let dir = tempfile::tempdir().unwrap();
2589        let manifest = dir.path().join("bad.leviath");
2590        std::fs::write(&manifest, "this is not valid toml : : :").unwrap();
2591        let (mut world, cli) = test_world();
2592        let hub = InteractionHub::new();
2593        let mcp = Arc::new(Mutex::new(leviath_mcp::ToolExecutor::new()));
2594        let err = build_agent(
2595            world.world_mut(),
2596            cli.as_ref(),
2597            &Config::default(),
2598            mcp,
2599            &[],
2600            &hub,
2601            &spawn_args(&manifest.to_string_lossy()),
2602            100,
2603            sub_tx(),
2604        )
2605        .unwrap_err();
2606        assert!(err.contains("parse manifest"));
2607    }
2608
2609    // ─── resolve_seeds ────────────────────────────────────────────────────────
2610
2611    fn bp(regions_toml: &str) -> Blueprint {
2612        let toml = format!(
2613            r#"
2614[agent]
2615name = "seedy"
2616
2617[stages.main]
2618mode = "autonomous"
2619
2620[stages.main.model]
2621provider = "anthropic"
2622model = "claude-sonnet-5"
2623
2624[context.regions]
2625{regions_toml}
2626conversation = {{ kind = "sliding_window", max_items = 20, max_tokens = 10000 }}
2627"#
2628        );
2629        leviath_core::manifest::parse_manifest(&toml).unwrap()
2630    }
2631
2632    fn args_with(task: &str, regions: HashMap<String, String>, workdir: &str) -> SpawnArgs {
2633        SpawnArgs {
2634            run_id: "r".to_string(),
2635            blueprint_path: "/bp".to_string(),
2636            task: task.to_string(),
2637            regions,
2638            model: None,
2639            workdir: workdir.to_string(),
2640            metadata: HashMap::new(),
2641            callback_url: None,
2642            callback_secret: None,
2643            yolo: false,
2644            no_seed_commands: false,
2645            allow: Vec::new(),
2646            max_depth: None,
2647            parent_run_id: None,
2648        }
2649    }
2650
2651    /// The default policy for the non-command seed tests: command seeds off, so
2652    /// nothing is ever executed by a test that isn't about command seeds.
2653    fn seed_policy() -> SeedCommandPolicy {
2654        SeedCommandPolicy::disabled()
2655    }
2656
2657    /// A policy whose runner is a stub returning `result`, for the command-seed
2658    /// arms (no real process, deterministic on every platform).
2659    fn stub_policy(result: Result<String, String>) -> SeedCommandPolicy {
2660        SeedCommandPolicy {
2661            allowed: true,
2662            timeout: std::time::Duration::from_secs(1),
2663            runner: std::sync::Arc::new(move |_, _, _| result.clone()),
2664        }
2665    }
2666
2667    #[test]
2668    fn resolve_seeds_fills_task_and_caller_input() {
2669        let bp = bp(
2670            r#"task = { kind = "pinned", max_tokens = 4000, seed = "task_input" }
2671criteria = { kind = "pinned", max_tokens = 2000, seed = "input" }"#,
2672        );
2673        let args = args_with(
2674            "build it",
2675            HashMap::from([("criteria".to_string(), "be safe".to_string())]),
2676            "/tmp",
2677        );
2678        let seeds = resolve_seeds(&bp, &args, "/tmp", &seed_policy()).unwrap();
2679        assert_eq!(seeds.get("task").map(String::as_str), Some("build it"));
2680        assert_eq!(seeds.get("criteria").map(String::as_str), Some("be safe"));
2681    }
2682
2683    #[test]
2684    fn resolve_seeds_required_caller_input_missing_is_error() {
2685        let bp =
2686            bp(r#"spec = { kind = "pinned", max_tokens = 2000, seed = "input", required = true }"#);
2687        let args = args_with("t", HashMap::new(), "/tmp");
2688        let err = resolve_seeds(&bp, &args, "/tmp", &seed_policy()).unwrap_err();
2689        assert!(err.contains("spec"), "got: {err}");
2690    }
2691
2692    #[test]
2693    fn resolve_seeds_optional_caller_input_missing_is_omitted() {
2694        let bp = bp(r#"notes = { kind = "pinned", max_tokens = 2000, seed = "input" }"#);
2695        let args = args_with("t", HashMap::new(), "/tmp");
2696        let seeds = resolve_seeds(&bp, &args, "/tmp", &seed_policy()).unwrap();
2697        assert!(!seeds.contains_key("notes"));
2698    }
2699
2700    #[test]
2701    fn resolve_seeds_literal_and_files() {
2702        let dir = tempfile::tempdir().unwrap();
2703        std::fs::write(dir.path().join("a.txt"), "alpha").unwrap();
2704        std::fs::write(dir.path().join("b.txt"), "beta").unwrap();
2705        let bp = bp(
2706            r#"lit = { kind = "pinned", max_tokens = 500, seed = { literal = "hello" } }
2707docs = { kind = "pinned", max_tokens = 2000, seed = { files = ["a.txt", "b.txt"] } }"#,
2708        );
2709        let args = args_with("t", HashMap::new(), &dir.path().to_string_lossy());
2710        let seeds =
2711            resolve_seeds(&bp, &args, &dir.path().to_string_lossy(), &seed_policy()).unwrap();
2712        assert_eq!(seeds.get("lit").map(String::as_str), Some("hello"));
2713        let docs = seeds.get("docs").unwrap();
2714        assert!(docs.contains("alpha") && docs.contains("beta"));
2715    }
2716
2717    #[test]
2718    fn resolve_seeds_glob_concatenates_matches() {
2719        let dir = tempfile::tempdir().unwrap();
2720        std::fs::create_dir(dir.path().join("specs")).unwrap();
2721        std::fs::write(dir.path().join("specs/one.md"), "spec one").unwrap();
2722        std::fs::write(dir.path().join("specs/two.md"), "spec two").unwrap();
2723        let bp =
2724            bp(r#"specs = { kind = "pinned", max_tokens = 4000, seed = { glob = "specs/*.md" } }"#);
2725        let wd = dir.path().to_string_lossy().to_string();
2726        let args = args_with("t", HashMap::new(), &wd);
2727        let seeds = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap();
2728        let specs = seeds.get("specs").unwrap();
2729        assert!(specs.contains("spec one") && specs.contains("spec two"));
2730    }
2731
2732    #[test]
2733    fn resolve_seeds_rhai_runs_script() {
2734        let dir = tempfile::tempdir().unwrap();
2735        // A script that returns the task text uppercased-ish via concatenation.
2736        std::fs::write(
2737            dir.path().join("init.rhai"),
2738            r#""seeded: " + input["task"]"#,
2739        )
2740        .unwrap();
2741        let bp = bp(
2742            r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "init.rhai" } }"#,
2743        );
2744        let wd = dir.path().to_string_lossy().to_string();
2745        let args = args_with("hello", HashMap::new(), &wd);
2746        let seeds = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap();
2747        assert_eq!(
2748            seeds.get("scripted").map(String::as_str),
2749            Some("seeded: hello")
2750        );
2751    }
2752
2753    #[test]
2754    fn resolve_seeds_files_required_missing_errors_optional_skips() {
2755        let dir = tempfile::tempdir().unwrap();
2756        let wd = dir.path().to_string_lossy().to_string();
2757        // Required + a missing file → error.
2758        let req = bp(
2759            r#"docs = { kind = "pinned", max_tokens = 2000, seed = { files = ["missing.txt"] }, required = true }"#,
2760        );
2761        let args = args_with("t", HashMap::new(), &wd);
2762        let err = resolve_seeds(&req, &args, &wd, &seed_policy()).unwrap_err();
2763        assert!(err.contains("missing.txt"), "got: {err}");
2764        // Optional + a missing file → the region is simply omitted.
2765        let opt = bp(
2766            r#"docs = { kind = "pinned", max_tokens = 2000, seed = { files = ["missing.txt"] } }"#,
2767        );
2768        let seeds = resolve_seeds(&opt, &args, &wd, &seed_policy()).unwrap();
2769        assert!(!seeds.contains_key("docs"));
2770    }
2771
2772    #[test]
2773    fn resolve_seeds_glob_no_match_required_errors_optional_skips() {
2774        let dir = tempfile::tempdir().unwrap();
2775        let wd = dir.path().to_string_lossy().to_string();
2776        let args = args_with("t", HashMap::new(), &wd);
2777        // Required glob with no matches → error.
2778        let req = bp(
2779            r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "none/*.md" }, required = true }"#,
2780        );
2781        let err = resolve_seeds(&req, &args, &wd, &seed_policy()).unwrap_err();
2782        assert!(err.contains("matched no files"), "got: {err}");
2783        // Optional glob with no matches → region omitted.
2784        let opt =
2785            bp(r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "none/*.md" } }"#);
2786        let seeds = resolve_seeds(&opt, &args, &wd, &seed_policy()).unwrap();
2787        assert!(!seeds.contains_key("specs"));
2788    }
2789
2790    #[test]
2791    fn resolve_seeds_bad_glob_pattern_errors() {
2792        // An unclosed `[` is an invalid glob pattern → `glob::glob` returns Err.
2793        let dir = tempfile::tempdir().unwrap();
2794        let wd = dir.path().to_string_lossy().to_string();
2795        let bp = bp(r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "[" } }"#);
2796        let args = args_with("t", HashMap::new(), &wd);
2797        let err = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap_err();
2798        assert!(err.contains("bad glob"), "got: {err}");
2799    }
2800
2801    #[test]
2802    fn resolve_seeds_rhai_script_error() {
2803        let dir = tempfile::tempdir().unwrap();
2804        // A script that calls an undefined function → runtime error.
2805        std::fs::write(dir.path().join("boom.rhai"), "undefined_func()").unwrap();
2806        let wd = dir.path().to_string_lossy().to_string();
2807        let bp = bp(
2808            r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "boom.rhai" } }"#,
2809        );
2810        let args = args_with("t", HashMap::new(), &wd);
2811        let err = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap_err();
2812        assert!(err.contains("rhai seed failed"), "got: {err}");
2813    }
2814
2815    // ─── command seeds (issue #108) ──────────────────────────────────────────
2816
2817    /// A blueprint with one command-seeded region, optionally `required`.
2818    fn command_bp(required: bool) -> leviath_core::Blueprint {
2819        let req = if required { ", required = true" } else { "" };
2820        bp(&format!(
2821            r#"facts = {{ kind = "pinned", max_tokens = 500, seed = {{ command = "scan-repo" }}{req} }}"#
2822        ))
2823    }
2824
2825    #[test]
2826    fn resolve_seeds_command_stores_output() {
2827        let bp = command_bp(false);
2828        let args = args_with("t", HashMap::new(), "/tmp");
2829        let seeds = resolve_seeds(
2830            &bp,
2831            &args,
2832            "/tmp",
2833            &stub_policy(Ok("src/lib.rs\nsrc/main.rs".to_string())),
2834        )
2835        .unwrap();
2836        assert_eq!(
2837            seeds.get("facts").map(String::as_str),
2838            Some("src/lib.rs\nsrc/main.rs")
2839        );
2840    }
2841
2842    #[test]
2843    fn resolve_seeds_command_receives_the_workdir_and_command() {
2844        // The declared command and the run's workdir reach the runner verbatim.
2845        let bp = command_bp(false);
2846        let args = args_with("t", HashMap::new(), "/work");
2847        let policy = SeedCommandPolicy {
2848            allowed: true,
2849            timeout: std::time::Duration::from_secs(9),
2850            runner: std::sync::Arc::new(|command, workdir, timeout| {
2851                Ok(format!(
2852                    "{command}@{}#{}",
2853                    workdir.display(),
2854                    timeout.as_secs()
2855                ))
2856            }),
2857        };
2858        let seeds = resolve_seeds(&bp, &args, "/work", &policy).unwrap();
2859        assert_eq!(
2860            seeds.get("facts").map(String::as_str),
2861            Some("scan-repo@/work#9")
2862        );
2863    }
2864
2865    #[test]
2866    fn resolve_seeds_command_failure_is_skipped_when_optional() {
2867        let bp = command_bp(false);
2868        let args = args_with("t", HashMap::new(), "/tmp");
2869        let seeds = resolve_seeds(
2870            &bp,
2871            &args,
2872            "/tmp",
2873            &stub_policy(Err("timed out".to_string())),
2874        )
2875        .unwrap();
2876        assert!(
2877            !seeds.contains_key("facts"),
2878            "an optional command seed must not sink the spawn"
2879        );
2880    }
2881
2882    #[test]
2883    fn resolve_seeds_command_failure_errors_when_required() {
2884        let bp = command_bp(true);
2885        let args = args_with("t", HashMap::new(), "/tmp");
2886        let err =
2887            resolve_seeds(&bp, &args, "/tmp", &stub_policy(Err("boom".to_string()))).unwrap_err();
2888        assert!(err.contains("scan-repo"), "got: {err}");
2889        assert!(err.contains("boom"), "got: {err}");
2890    }
2891
2892    #[test]
2893    fn resolve_seeds_command_empty_output_is_skipped_when_optional() {
2894        let bp = command_bp(false);
2895        let args = args_with("t", HashMap::new(), "/tmp");
2896        let seeds =
2897            resolve_seeds(&bp, &args, "/tmp", &stub_policy(Ok("   \n".to_string()))).unwrap();
2898        assert!(!seeds.contains_key("facts"));
2899    }
2900
2901    #[test]
2902    fn resolve_seeds_command_empty_output_errors_when_required() {
2903        let bp = command_bp(true);
2904        let args = args_with("t", HashMap::new(), "/tmp");
2905        let err = resolve_seeds(&bp, &args, "/tmp", &stub_policy(Ok(String::new()))).unwrap_err();
2906        assert!(err.contains("returned empty"), "got: {err}");
2907    }
2908
2909    #[test]
2910    fn resolve_seeds_command_skipped_when_disabled() {
2911        // `[security] allow_seed_commands = false` / `--no-seed-commands`: the
2912        // runner is never consulted. The stub would have produced content, so an
2913        // empty region proves the seed was skipped rather than merely failing.
2914        let bp = command_bp(false);
2915        let args = args_with("t", HashMap::new(), "/tmp");
2916        let mut policy = stub_policy(Ok("SHOULD NOT BE USED".to_string()));
2917        policy.allowed = false;
2918        let seeds = resolve_seeds(&bp, &args, "/tmp", &policy).unwrap();
2919        assert!(!seeds.contains_key("facts"));
2920    }
2921
2922    #[test]
2923    fn resolve_seeds_required_command_errors_when_disabled() {
2924        // A required region can't be silently left empty - the run stops with a
2925        // message naming the switch that turned command seeds off.
2926        let bp = command_bp(true);
2927        let args = args_with("t", HashMap::new(), "/tmp");
2928        let err = resolve_seeds(&bp, &args, "/tmp", &SeedCommandPolicy::disabled()).unwrap_err();
2929        assert!(err.contains("allow_seed_commands"), "got: {err}");
2930    }
2931
2932    #[test]
2933    fn resolve_seeds_glob_matching_directory_required_errors() {
2934        // A required glob that matches a directory entry → reading it as a file
2935        // fails, so read_and_concat returns Err and resolve_seeds propagates it.
2936        let dir = tempfile::tempdir().unwrap();
2937        std::fs::create_dir(dir.path().join("subdir")).unwrap();
2938        let wd = dir.path().to_string_lossy().to_string();
2939        let bp = bp(
2940            r#"specs = { kind = "pinned", max_tokens = 2000, seed = { glob = "sub*" }, required = true }"#,
2941        );
2942        let args = args_with("t", HashMap::new(), &wd);
2943        let err = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap_err();
2944        assert!(err.contains("read seed file"), "got: {err}");
2945    }
2946
2947    #[test]
2948    fn resolve_seeds_rhai_read_error() {
2949        let dir = tempfile::tempdir().unwrap();
2950        let wd = dir.path().to_string_lossy().to_string();
2951        let bp = bp(
2952            r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "nope.rhai" } }"#,
2953        );
2954        let args = args_with("t", HashMap::new(), &wd);
2955        let err = resolve_seeds(&bp, &args, &wd, &seed_policy()).unwrap_err();
2956        assert!(err.contains("read rhai seed"), "got: {err}");
2957    }
2958
2959    #[test]
2960    fn resolve_seeds_rhai_empty_required_errors_optional_skips() {
2961        let dir = tempfile::tempdir().unwrap();
2962        // A script returning an empty string.
2963        std::fs::write(dir.path().join("empty.rhai"), r#""""#).unwrap();
2964        let wd = dir.path().to_string_lossy().to_string();
2965        let args = args_with("t", HashMap::new(), &wd);
2966        let req = bp(
2967            r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "empty.rhai" }, required = true }"#,
2968        );
2969        let err = resolve_seeds(&req, &args, &wd, &seed_policy()).unwrap_err();
2970        assert!(err.contains("returned empty"), "got: {err}");
2971        // Optional + empty → region omitted (no error).
2972        let opt = bp(
2973            r#"scripted = { kind = "pinned", max_tokens = 500, seed = { rhai = "empty.rhai" } }"#,
2974        );
2975        let seeds = resolve_seeds(&opt, &args, &wd, &seed_policy()).unwrap();
2976        assert!(!seeds.contains_key("scripted"));
2977    }
2978
2979    #[test]
2980    fn resolve_seeds_tolerates_unknown_caller_region() {
2981        // Unknown caller keys are silently unused (CLI validates client-side;
2982        // ACP stray markers must not fail the spawn).
2983        let bp = bp(r#"task = { kind = "pinned", max_tokens = 4000, seed = "task_input" }"#);
2984        let args = args_with(
2985            "t",
2986            HashMap::from([("ghost".to_string(), "x".to_string())]),
2987            "/tmp",
2988        );
2989        let seeds = resolve_seeds(&bp, &args, "/tmp", &seed_policy()).unwrap();
2990        assert_eq!(seeds.get("task").map(String::as_str), Some("t"));
2991        assert!(!seeds.contains_key("ghost"));
2992    }
2993}