Skip to main content

leviath_core/blueprint/
mod.rs

1//! Agent blueprints and stage definitions.
2//!
3//! A blueprint is the complete definition of an agent type, including its
4//! execution stages, model selection, tool access, and context layout.
5//! Blueprints are typically defined in `leviath.toml` files and can be
6//! shared, installed, and versioned.
7
8use crate::error::ValidationError;
9use crate::layout::{ContextLayout, RegionSeed};
10use crate::lifecycle::CompactionConfig;
11use serde::{Deserialize, Serialize};
12use std::collections::{BTreeMap, HashMap};
13
14/// Regions every stage can see, whatever its own `[context.regions]` says.
15///
16/// The runtime adds the first three when a blueprint declares none, and carries
17/// all four visible through a stage's layout swap: the first two hold the typed
18/// tool_use/tool_result turns, an answer submitted early has to survive to the
19/// end, and the last holds the instructions of the stage being entered. Mirrors
20/// `context_setup::apply_layout`, which is where the rule is enforced.
21pub const ALWAYS_VISIBLE_REGIONS: [&str; 4] = [
22    "conversation",
23    "tool_results",
24    "final_output",
25    crate::layout::STAGE_INSTRUCTIONS_REGION,
26];
27
28/// An agent blueprint - the complete definition of an agent type.
29///
30/// Includes stages, model selection, tools, AND context layout. A blueprint
31/// defines everything needed to instantiate and run an agent with specific
32/// capabilities and memory structure.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Blueprint {
35    /// Unique name for this agent type
36    pub name: String,
37
38    /// Human-readable description
39    pub description: String,
40
41    /// Execution stages (e.g., analyze → implement → review)
42    pub stages: Vec<Stage>,
43
44    /// Context window layout defining memory regions
45    pub context_layout: ContextLayout,
46
47    /// Context transforms for inter-agent communication
48    pub transforms: Vec<ContextTransform>,
49
50    /// Version of this blueprint
51    pub version: String,
52
53    /// Configuration for LLM-based compaction
54    pub compaction_config: Option<CompactionConfig>,
55
56    /// Maximum depth of the sub-agent tree (default: 3)
57    pub max_child_depth: Option<usize>,
58
59    /// Which stage to start from (default: first defined)
60    pub entry_stage: Option<String>,
61
62    /// Additional metadata
63    pub metadata: HashMap<String, serde_json::Value>,
64
65    /// Security configuration for taint tracking.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub security: Option<crate::taint::SecurityConfig>,
68
69    /// Agent-level override for the batch-tool-calls system-prompt hint. `None`
70    /// inherits the global config toggle; a per-stage `batch_tool_hint` overrides
71    /// this. See [`crate::taint::resolve_batch_tool_hint`] for the cascade.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub batch_tool_hint: Option<bool>,
74
75    /// Agent-level override for the platform shell hint. `None` inherits the
76    /// global config toggle; a per-stage `shell_hint` overrides this. See
77    /// [`crate::taint::resolve_shell_hint`] for the cascade.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub shell_hint: Option<bool>,
80
81    /// Agent-level default for the empty-response nudge. `None` inherits the
82    /// global config's `[nudge]` section; a per-stage `[stages.<name>.nudge]`
83    /// overrides this. See [`resolve_nudge`] for the cascade.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub nudge: Option<NudgeConfig>,
86
87    /// Repetition detection configuration.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub repetition_detection: Option<RepetitionDetectionConfig>,
90
91    /// File tracking configuration.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub file_tracking: Option<FileTrackingConfig>,
94
95    /// Agent-level sandbox configuration for tool execution. Per-stage
96    /// `[stages.<name>.sandbox]` overrides this; both cascade through
97    /// [`crate::resolve_sandbox`].
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub sandbox: Option<crate::sandbox::ToolSandboxConfig>,
100
101    /// Opt-in escape hatch: when `true`, the agent may add tools to
102    /// its own `tools/` directory mid-run and have them re-discovered and
103    /// re-advertised for its next turn. **Off by default** - tools are otherwise
104    /// discovered once at spawn and an agent cannot grow its own toolchain.
105    #[serde(default)]
106    pub dynamic_tools: bool,
107
108    /// Read paths this agent *declares* beyond its workdir - directories a
109    /// planner-style agent needs to see, like run archives or design docs.
110    /// Declaring is not granting: entries only take effect when the user's
111    /// config also grants them (`[security] read_paths`,
112    /// `[agent_read_paths.<name>]`, or `allow_blueprint_read_paths = true`),
113    /// so an installed manifest cannot widen its own sandbox. Read-only in
114    /// every case; `write_file` and `edit_file` stay confined to the workdir.
115    /// Semantics live in [`crate::read_paths`].
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub read_paths: Option<ReadPathsConfig>,
118
119    /// The `[safe_commands]` section: tools and shell command prefixes this
120    /// agent would like to run without an approval prompt.
121    ///
122    /// Declaring is not granting, exactly as for [`Self::read_paths`]: entries
123    /// take effect only when the user opts in, per agent via
124    /// `[agent_safe_commands.<name>] allow_blueprint = true` or globally via
125    /// `[security] allow_blueprint_safe_commands`. Otherwise any agent package
126    /// could pre-approve its own shell with one TOML line.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub safe_commands: Option<SafeCommandsConfig>,
129
130    /// Agent-level default shape for the run's final output. A per-stage
131    /// `[stages.<name>.output]` narrows it, and whoever starts the run can
132    /// override it again. See [`crate::output::resolve_output_spec`].
133    ///
134    /// `None` means this agent declares no shape, which is not the same as
135    /// producing no output: a stage may still ask for one.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub output: Option<crate::output::OutputSpec>,
138
139    /// Rows this agent adds to the mime registry, `[mime_types]` in the
140    /// manifest: the types its tools produce and take, layered over the
141    /// operator's rows for this agent's runs only. Validated at parse; an
142    /// empty table is the common case and is not written back.
143    #[serde(default, skip_serializing_if = "toml::Table::is_empty")]
144    pub mime_types: toml::Table,
145
146    /// Things that must be in place before this agent can run, declared as
147    /// `[[dependencies]]` in the manifest: an MCP server, an environment
148    /// variable, a program on `PATH`, or a condition a Rhai script checks.
149    /// Declared, never granted. The operator is shown what is missing and how
150    /// to fix it, and an unmet required dependency fails the spawn before the
151    /// first billed inference. See [`Dependency`].
152    #[serde(default, skip_serializing_if = "Vec::is_empty")]
153    pub dependencies: Vec<Dependency>,
154}
155
156/// The `[safe_commands]` section of a manifest.
157///
158/// Entry syntax is not checked here. What counts as a usable shell prefix is
159/// defined by the key parser in the CLI (a program, optionally with the
160/// subcommand that narrows it), which this crate does not depend on. A bad
161/// entry is a lint finding and is skipped with a warning at spawn, rather than
162/// a parse error - the same place the check can be written once instead of
163/// twice.
164#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
165pub struct SafeCommandsConfig {
166    /// Tools that need no prompt whatever their arguments.
167    #[serde(default)]
168    pub tools: Vec<String>,
169    /// Shell command prefixes that need no prompt: `"cargo test"`, not
170    /// `"cargo test --lib"` and never `"cargo"`.
171    #[serde(default)]
172    pub shell: Vec<String>,
173}
174
175/// The `[read_paths]` section of a manifest: raw declared entries, compiled
176/// against the run's workdir and home at spawn.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ReadPathsConfig {
179    /// Declared entries. Each may be:
180    /// - an exact path, granting its subtree: `"~/.leviath/runs"` or
181    ///   `"../shared-docs"` (relative to the run's workdir)
182    /// - a glob: `"glob:~/.leviath/runs/**"`
183    /// - a regex, auto-anchored: `"regex:/data/design-docs/.*"`
184    ///
185    /// Patterns are written with `/` separators on every OS and match the
186    /// symlink-resolved real path.
187    #[serde(default)]
188    pub allow: Vec<String>,
189}
190
191/// One `[[dependencies]]` entry: something that must be in place before an
192/// agent can run. Declared in the manifest, never granted - every surface that
193/// reports it (`lev validate`, `lev deps`, the spawn gate, the API) shows what
194/// is missing and the `remedy` for fixing it.
195///
196/// The `kind` field selects what must be present and carries its own fields
197/// (see [`DependencyKind`]); the optional [`install`](Self::install) block says
198/// how `lev deps install` can put it in place, and is never run automatically.
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
200pub struct Dependency {
201    /// A short identifier, unique within the blueprint.
202    pub name: String,
203
204    /// What must be present, and the fields describing it.
205    #[serde(flatten)]
206    pub kind: DependencyKind,
207
208    /// Whether an unmet dependency blocks the run. `true` (the default) fails
209    /// the spawn; `false` downgrades a miss to a warning the run proceeds past.
210    #[serde(default = "default_dependency_required")]
211    pub required: bool,
212
213    /// A human sentence telling the user how to satisfy the dependency, shown
214    /// wherever a miss is reported.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub remedy: Option<String>,
217
218    /// A one-line note on why the agent needs it.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub description: Option<String>,
221
222    /// How `lev deps install` can put this dependency in place. Optional and
223    /// never run automatically: installing runs commands or writes config on
224    /// the user's machine and always asks first. See [`DependencyInstall`].
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub install: Option<DependencyInstall>,
227}
228
229/// The default for [`Dependency::required`]: a declared dependency blocks the
230/// run unless the manifest says otherwise.
231fn default_dependency_required() -> bool {
232    true
233}
234
235/// What a [`Dependency`] requires, selected by the manifest's `kind` field.
236#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
237#[serde(tag = "kind", rename_all = "snake_case")]
238pub enum DependencyKind {
239    /// An MCP server that must be configured in the user's config, plus any
240    /// environment variables or secrets it needs. The check confirms the named
241    /// server exists and every `env` var is set and non-empty.
242    McpServer {
243        /// The server name that must appear in the user's `[[mcp_servers]]`.
244        server: String,
245        /// Environment variables / secrets the server needs. Values are
246        /// prompted for at install, never stored in the blueprint.
247        #[serde(default, skip_serializing_if = "Vec::is_empty")]
248        env: Vec<String>,
249    },
250    /// An environment variable that must be set and non-empty.
251    Env {
252        /// The variable name.
253        var: String,
254    },
255    /// A program that must resolve on `PATH`.
256    Binary {
257        /// The program name, e.g. `blender`.
258        command: String,
259    },
260    /// A condition a Rhai script decides. The `check` script returns
261    /// `#{ ok: bool, remedy: string }`; the optional installer lives in
262    /// [`DependencyInstall::script`].
263    Script {
264        /// Path to the Rhai check script, relative to the blueprint directory.
265        check: String,
266    },
267}
268
269impl DependencyKind {
270    /// The manifest `kind` string for this variant (`"mcp_server"`, `"env"`,
271    /// `"binary"`, `"script"`), matching the serialized tag.
272    pub fn tag(&self) -> &'static str {
273        match self {
274            DependencyKind::McpServer { .. } => "mcp_server",
275            DependencyKind::Env { .. } => "env",
276            DependencyKind::Binary { .. } => "binary",
277            DependencyKind::Script { .. } => "script",
278        }
279    }
280}
281
282/// How a [`Dependency`] can be installed by `lev deps install`.
283///
284/// Every field is optional; a dependency may declare any combination. Nothing
285/// here runs without an explicit `lev deps install` and a confirmation, because
286/// each option changes the user's machine: running a command, executing a
287/// script, or writing an MCP server into their config.
288#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
289pub struct DependencyInstall {
290    /// A shell command that installs the dependency on any platform, e.g.
291    /// `"pip install trimesh"`. Run only after the user confirms.
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub command: Option<String>,
294
295    /// Per-OS shell commands, keyed by `"macos"`, `"linux"` or `"windows"`,
296    /// preferred over [`command`](Self::command) on a matching host.
297    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
298    pub commands: BTreeMap<String, String>,
299
300    /// A Rhai install script (relative to the blueprint), run with the script
301    /// I/O surface and gated exactly like a script tool. For a `script`
302    /// dependency this is its installer.
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub script: Option<String>,
305
306    /// For an `mcp_server` dependency: the non-user-specific server settings the
307    /// installer writes into the user's config. Secrets are never placed here -
308    /// they are named in the dependency's `env` and prompted for securely.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub server: Option<McpServerTemplate>,
311}
312
313/// The non-secret settings for an MCP server that a blueprint can ship so
314/// `lev deps install` can write it into the user's config. Mirrors the
315/// installable half of the CLI's MCP server config; the user-specific secrets
316/// (header and env values) are prompted for and stored separately.
317#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
318pub struct McpServerTemplate {
319    /// `"stdio"` or `"http"`. Inferred from `command`/`url` when omitted.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub transport: Option<String>,
322    /// The program to launch for a stdio server.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub command: Option<String>,
325    /// The endpoint for an http server.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub url: Option<String>,
328    /// Arguments passed to `command`.
329    #[serde(default, skip_serializing_if = "Vec::is_empty")]
330    pub args: Vec<String>,
331    /// Non-secret headers, for an http server. A value may reference a secret
332    /// with `${VAR}`, where `VAR` is named in the dependency's `env`.
333    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
334    pub headers: BTreeMap<String, String>,
335    /// Environment for a stdio server's child process. A value may reference a
336    /// secret with `${VAR}` (expanded from the environment at connect time, so
337    /// the secret stays out of the config file), where `VAR` is named in the
338    /// dependency's `env`.
339    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
340    pub env: BTreeMap<String, String>,
341}
342
343impl Blueprint {
344    /// Create a new blueprint with the specified configuration.
345    pub fn new(
346        name: String,
347        description: String,
348        stages: Vec<Stage>,
349        context_layout: ContextLayout,
350    ) -> Self {
351        Self {
352            name,
353            description,
354            stages,
355            context_layout,
356            transforms: Vec::new(),
357            version: "0.1.0".to_string(),
358            compaction_config: None,
359            max_child_depth: None,
360            entry_stage: None,
361            metadata: HashMap::new(),
362            security: None,
363            batch_tool_hint: None,
364            shell_hint: None,
365            nudge: None,
366            repetition_detection: None,
367            file_tracking: None,
368            sandbox: None,
369            dynamic_tools: false,
370            read_paths: None,
371            safe_commands: None,
372            output: None,
373            mime_types: toml::Table::new(),
374            dependencies: Vec::new(),
375        }
376    }
377
378    /// Whether any region is seeded from the caller's `task`.
379    ///
380    /// The blueprint's answer to "do you take a task?", which is a different
381    /// question from whether one was supplied. An agent driven by named regions
382    /// (`reviewer` takes `--diff` and `--criteria`) answers no, and handing it a
383    /// task would put that text nowhere at all - so both the CLI, before it asks
384    /// for one, and the daemon, before it spawns, ask this first.
385    pub fn accepts_task(&self) -> bool {
386        self.context_layout
387            .regions
388            .iter()
389            .any(|r| matches!(&r.seed, Some(RegionSeed::CallerInput { name }) if name == "task"))
390    }
391
392    /// Whether a run cannot start without a task: the region seeded from it is
393    /// `required`. An optional task region is what lets a blueprint driven by
394    /// its other inputs (`--diff`, an attachment) run with no task at all, and
395    /// still take one from a fan-out that spawns it as its own worker.
396    pub fn requires_task(&self) -> bool {
397        self.context_layout.regions.iter().any(|r| {
398            r.required
399                && matches!(&r.seed, Some(RegionSeed::CallerInput { name }) if name == "task")
400        })
401    }
402
403    /// The caller input keys this blueprint does read, in declaration order.
404    ///
405    /// The mime type patterns `stage` takes as parts: its own
406    /// `[input] accepts` when it declares one, else the union of `accepts`
407    /// across the regions it sees. Text is always taken and never listed, so
408    /// an empty answer means "text only, unless a region takes anything".
409    /// A visible region with no `accepts` takes anything, and is reported as
410    /// `*/*`.
411    pub fn stage_inputs(&self, stage: &Stage) -> Vec<String> {
412        if !stage.input_accepts.is_empty() {
413            return stage.input_accepts.clone();
414        }
415        let layout = stage
416            .context_layout
417            .as_ref()
418            .unwrap_or(&self.context_layout);
419        let mut out: Vec<String> = Vec::new();
420        for region in &layout.regions {
421            if stage.context_hide.contains(&region.name) {
422                continue;
423            }
424            let patterns: Vec<String> = match region.accepts.is_empty() {
425                true => vec!["*/*".to_string()],
426                false => region.accepts.clone(),
427            };
428            for p in patterns {
429                if !p.starts_with("text/") && !out.contains(&p) {
430                    out.push(p);
431                }
432            }
433        }
434        out
435    }
436
437    /// Used to turn "that agent takes no task" into a message naming what it
438    /// takes instead, which is the difference between a dead end and a fix.
439    pub fn caller_inputs(&self) -> Vec<&str> {
440        self.context_layout
441            .regions
442            .iter()
443            .filter_map(|r| match &r.seed {
444                Some(RegionSeed::CallerInput { name }) => Some(name.as_str()),
445                _ => None,
446            })
447            .collect()
448    }
449
450    /// Why a task cannot be given to this blueprint, phrased for the user.
451    ///
452    /// One message rather than two, because the CLI refuses before it asks for a
453    /// task and the daemon refuses before it spawns, and a user who hit one and
454    /// then the other should not be told two different things.
455    pub fn task_refusal(&self) -> String {
456        let inputs = self.caller_inputs();
457        let takes = match inputs.is_empty() {
458            true => "it takes no caller input at all".to_string(),
459            false => format!("it takes: {}", inputs.join(", ")),
460        };
461        format!(
462            "agent '{}' was given a task but declares no region to put it in, so the task \
463             would be ignored - {takes}. Add a region seeded from the task, for example:\n\
464             [context.regions]\ntask = {{ kind = \"pinned\", max_tokens = 2000, \
465             required = true, seed = \"task\" }}",
466            self.name,
467        )
468    }
469
470    /// Agent-level tool permissions, keyed by tool name.
471    ///
472    /// The manifest parser records a top-level `[tool_permissions]` block as
473    /// `tool_perm:<tool>` → policy-string entries in [`Self::metadata`]. This
474    /// projects them back into a tool-keyed map for the runtime's agent-level
475    /// permission layer. Non-`tool_perm:` keys and non-string values are ignored.
476    pub fn agent_tool_permissions(&self) -> HashMap<String, String> {
477        self.metadata
478            .iter()
479            .filter_map(|(k, v)| {
480                Some((
481                    k.strip_prefix("tool_perm:")?.to_string(),
482                    v.as_str()?.to_string(),
483                ))
484            })
485            .collect()
486    }
487
488    /// Add context transforms to this blueprint.
489    pub fn with_transforms(mut self, transforms: Vec<ContextTransform>) -> Self {
490        self.transforms = transforms;
491        self
492    }
493
494    /// Set the version of this blueprint.
495    pub fn with_version(mut self, version: String) -> Self {
496        self.version = version;
497        self
498    }
499
500    /// Validate that the blueprint is well-formed.
501    pub fn validate(&self) -> std::result::Result<(), ValidationError> {
502        // Validate context layout
503        self.context_layout.validate()?;
504
505        // Check that all stages have valid configurations
506        for stage in &self.stages {
507            stage.validate()?;
508        }
509
510        // Validate transforms reference real regions
511        for transform in &self.transforms {
512            transform.validate(&self.context_layout)?;
513        }
514
515        // Graph validation
516        self.validate_graph()?;
517
518        self.validate_region_references()?;
519
520        self.validate_dependencies()?;
521
522        Ok(())
523    }
524
525    /// Check every `[[dependencies]]` entry is well-formed. This validates the
526    /// declaration only - names are unique and non-empty, each kind's fields are
527    /// present, and an `install` block is shaped for its kind. Whether the
528    /// dependency is actually satisfied (the server exists, the var is set, the
529    /// binary is on `PATH`) is checked at spawn and by `lev deps check`, which
530    /// see the machine this crate does not touch.
531    fn validate_dependencies(&self) -> std::result::Result<(), ValidationError> {
532        let mut seen = std::collections::HashSet::new();
533        for dep in &self.dependencies {
534            let name = dep.name.trim();
535            if name.is_empty() {
536                return Err(ValidationError::Dependency {
537                    name: dep.name.clone(),
538                    message: "a dependency needs a non-empty name".to_string(),
539                });
540            }
541            if !seen.insert(name) {
542                return Err(ValidationError::Dependency {
543                    name: name.to_string(),
544                    message: "two dependencies share this name".to_string(),
545                });
546            }
547            let require = |field: &str, value: &str| -> std::result::Result<(), ValidationError> {
548                if value.trim().is_empty() {
549                    return Err(ValidationError::Dependency {
550                        name: name.to_string(),
551                        message: format!(
552                            "a '{}' dependency needs a non-empty '{field}'",
553                            dep.kind.tag()
554                        ),
555                    });
556                }
557                Ok(())
558            };
559            match &dep.kind {
560                DependencyKind::McpServer { server, .. } => require("server", server)?,
561                DependencyKind::Env { var } => require("var", var)?,
562                DependencyKind::Binary { command } => require("command", command)?,
563                DependencyKind::Script { check } => require("check", check)?,
564            }
565            if let Some(install) = &dep.install {
566                if install.server.is_some() && !matches!(dep.kind, DependencyKind::McpServer { .. })
567                {
568                    return Err(ValidationError::Dependency {
569                        name: name.to_string(),
570                        message: "install.server is only valid for a 'mcp_server' dependency"
571                            .to_string(),
572                    });
573                }
574                if let Some(transport) =
575                    install.server.as_ref().and_then(|s| s.transport.as_deref())
576                    && !matches!(transport, "stdio" | "http")
577                {
578                    return Err(ValidationError::Dependency {
579                        name: name.to_string(),
580                        message: format!(
581                            "install.server.transport must be \"stdio\" or \"http\", got \"{transport}\""
582                        ),
583                    });
584                }
585                for os in install.commands.keys() {
586                    if !matches!(os.as_str(), "macos" | "linux" | "windows") {
587                        return Err(ValidationError::Dependency {
588                            name: name.to_string(),
589                            message: format!(
590                                "install.commands key '{os}' must be \"macos\", \"linux\" or \"windows\""
591                            ),
592                        });
593                    }
594                }
595            }
596        }
597        Ok(())
598    }
599
600    /// Every region a stage can name, anywhere in this blueprint.
601    ///
602    /// The union of the global layout, every stage's own layout, and the three
603    /// the runtime adds if nobody declared them. It is a union rather than the
604    /// per-stage set on purpose: a stage that omits a region from its
605    /// `[context.regions]` hides it, it does not destroy it, so naming a region
606    /// another stage declared is legitimate. Only a name that exists nowhere is
607    /// a typo.
608    fn known_region_names(&self) -> std::collections::HashSet<&str> {
609        let mut names: std::collections::HashSet<&str> = self
610            .context_layout
611            .regions
612            .iter()
613            .map(|r| r.name.as_str())
614            .collect();
615        for stage in &self.stages {
616            if let Some(layout) = &stage.context_layout {
617                names.extend(layout.regions.iter().map(|r| r.name.as_str()));
618            }
619        }
620        // Added by `setup_context_window` when a blueprint does not declare
621        // them, so they are always addressable.
622        names.extend(ALWAYS_VISIBLE_REGIONS);
623        names
624    }
625
626    /// The regions `stage` can actually see while it runs.
627    ///
628    /// Its own `[context.regions]` when it declares one, the blueprint's
629    /// otherwise, plus the regions the runtime carries visible whatever a stage
630    /// says. Narrower than `known_region_names`, which asks only whether a name
631    /// exists somewhere - the difference being
632    /// that a region another stage declares exists, and is still not readable
633    /// from here.
634    ///
635    /// Public so the runtime can size each region's percentage budget against
636    /// the smallest window among the stages that actually see it - a region a
637    /// narrow-window stage never reads must not be shrunk to fit that stage.
638    pub fn regions_visible_to<'a>(
639        &'a self,
640        stage: &'a Stage,
641    ) -> std::collections::HashSet<&'a str> {
642        let layout = stage
643            .context_layout
644            .as_ref()
645            .unwrap_or(&self.context_layout);
646        let mut names: std::collections::HashSet<&str> =
647            layout.regions.iter().map(|r| r.name.as_str()).collect();
648        names.extend(ALWAYS_VISIBLE_REGIONS);
649        // `validate_region_references` has already refused a hide list that
650        // names an always-visible region, so nothing here can remove one.
651        for hidden in &stage.context_hide {
652            names.remove(hidden.as_str());
653        }
654        names
655    }
656
657    /// Refuse a region name that exists nowhere in the blueprint.
658    ///
659    /// Routing and gates are addressed by name, and a name that matches
660    /// nothing, accepted in silence, sends the routed tool result to the
661    /// default region and leaves the gate holding nothing back - both looking
662    /// exactly like a working config. A gate that silently never fires is the
663    /// expensive case: it reads as the model behaving well.
664    fn validate_region_references(&self) -> std::result::Result<(), ValidationError> {
665        let known = self.known_region_names();
666        let checklists: std::collections::HashSet<&str> = self
667            .context_layout
668            .regions
669            .iter()
670            .chain(
671                self.stages
672                    .iter()
673                    .filter_map(|s| s.context_layout.as_ref())
674                    .flat_map(|l| l.regions.iter()),
675            )
676            .filter(|r| matches!(r.kind, crate::RegionKind::Checklist))
677            .map(|r| r.name.as_str())
678            .collect();
679
680        for stage in &self.stages {
681            let bad = |message: String| ValidationError::Stage {
682                stage: stage.name.clone(),
683                message,
684            };
685
686            // A hidden region has to be one the stage would otherwise carry:
687            // a name that matches nothing is a typo, and a typo here is the
688            // silent kind (the large region stays in every prompt and the
689            // bill says so a month later). The always-visible four cannot be
690            // hidden at all - the model's own turns live there.
691            for hidden in &stage.context_hide {
692                if ALWAYS_VISIBLE_REGIONS.contains(&hidden.as_str()) {
693                    return Err(bad(format!(
694                        "context.hide names '{hidden}', which every stage carries and cannot hide"
695                    )));
696                }
697                if !known.contains(hidden.as_str()) {
698                    return Err(bad(format!(
699                        "context.hide names region '{hidden}', which no layout in this \
700                         blueprint declares"
701                    )));
702                }
703            }
704
705            // `reset` empties a region on entry. `conversation` and the other
706            // always-visible regions can be reset (that is the point - a stage
707            // starting on a clean conversation), but a name no layout declares
708            // is the same silent typo `hide` guards against.
709            for name in &stage.context_reset {
710                if !known.contains(name.as_str()) {
711                    return Err(bad(format!(
712                        "context.reset names region '{name}', which no layout in this \
713                         blueprint declares"
714                    )));
715                }
716            }
717
718            if let Some(routing) = &stage.tool_result_routing {
719                // Routing is checked against what *this* stage can see, not
720                // against every name in the blueprint. A stage that omits a
721                // region from its own `[context.regions]` hides it, so a result
722                // routed there is written somewhere the stage cannot read - and
723                // the pointer left in `conversation` tells the model to go read
724                // it. There is no reading of a blueprint where that is
725                // intended.
726                let visible = self.regions_visible_to(stage);
727                let dead_drop = |key: &str, region: &str| ValidationError::Stage {
728                    stage: stage.name.clone(),
729                    message: format!(
730                        "tool_routing.{key} sends results to region '{region}', \
731                             which this stage's context does not include, so it \
732                             could not read them back. Add '{region}' to \
733                             [stages.{}.context.regions], or route somewhere the \
734                             stage can see.",
735                        stage.name
736                    ),
737                };
738                if !visible.contains(routing.default_region.as_str()) {
739                    return Err(dead_drop("default_region", &routing.default_region));
740                }
741                for (tool, region) in &routing.tool_overrides {
742                    if !visible.contains(region.as_str()) {
743                        return Err(dead_drop(&format!("overrides.{tool}"), region));
744                    }
745                }
746            }
747
748            // `output_routing` sends the model's produced parts to a region a
749            // *later* stage usually reads, so unlike `tool_routing` above it is
750            // checked against every region the blueprint declares, not only the
751            // ones this stage can see. A target no layout declares is still a
752            // dead drop - the part would land nowhere - so it is refused.
753            for (pattern, region) in &stage.output_routing {
754                if !known.contains(region.as_str()) {
755                    return Err(ValidationError::Stage {
756                        stage: stage.name.clone(),
757                        message: format!(
758                            "output_routing.\"{pattern}\" sends produced parts to region \
759                             '{region}', which no layout in this blueprint declares. Add it to a \
760                             [context.regions] table, or route to a region that exists."
761                        ),
762                    });
763                }
764            }
765
766            for edge in stage.transitions.iter().flat_map(|t| t.values()) {
767                let Some(gate) = &edge.gate else { continue };
768                for (key, region) in [
769                    ("region", gate.region.as_ref()),
770                    (
771                        "require_region_updated",
772                        gate.require_region_updated.as_ref(),
773                    ),
774                    ("require_no_open_items", gate.require_no_open_items.as_ref()),
775                    (
776                        "require_region_entries",
777                        gate.require_region_entries.as_ref().map(|c| &c.region),
778                    ),
779                ] {
780                    let Some(region) = region else { continue };
781                    if !known.contains(region.as_str()) {
782                        return Err(bad(format!(
783                            "transition to '{}': gate.{key} names region \
784                             '{region}', which no stage declares",
785                            edge.target
786                        )));
787                    }
788                }
789                // A checklist gate counts open items, which only a checklist
790                // region has. Pointed at any other kind it can only ever read
791                // zero, so it would pass on the first attempt every time.
792                if let Some(region) = &gate.require_no_open_items
793                    && !checklists.contains(region.as_str())
794                {
795                    return Err(bad(format!(
796                        "transition to '{}': gate.require_no_open_items names \
797                         region '{region}', which is not a checklist region \
798                         (set kind = \"checklist\" on it)",
799                        edge.target
800                    )));
801                }
802            }
803        }
804        Ok(())
805    }
806
807    /// Validate stage graph constraints.
808    fn validate_graph(&self) -> std::result::Result<(), ValidationError> {
809        let stage_names: std::collections::HashSet<&str> =
810            self.stages.iter().map(|s| s.name.as_str()).collect();
811
812        // Entry stage must exist if set
813        if let Some(entry) = &self.entry_stage
814            && !stage_names.contains(entry.as_str())
815        {
816            return Err(ValidationError::Graph(format!(
817                "entry_stage '{}' does not match any defined stage",
818                entry
819            )));
820        }
821
822        // Fan-out stages reference a worker source + optional merge stage. These
823        // are checked even for otherwise-linear blueprints (before the early
824        // return below), since `worker_stage`/`merge_stage` name local stages.
825        // `worker_agent`/`worker_query` are environment-dependent (resolved
826        // against installed agents at run time), so they are not checked here.
827        for stage in &self.stages {
828            if let StageMode::FanOut { config } = &stage.mode {
829                let sources = [
830                    config.worker_agent.is_some(),
831                    config.worker_stage.is_some(),
832                    config.worker_query.is_some(),
833                ]
834                .iter()
835                .filter(|&&set| set)
836                .count();
837                if sources != 1 {
838                    return Err(ValidationError::Stage {
839                        stage: stage.name.clone(),
840                        message: "fan_out stage must set exactly one of worker_agent, \
841                                  worker_stage, or worker_query"
842                            .to_string(),
843                    });
844                }
845                if let Some(ws) = &config.worker_stage {
846                    match self.stages.iter().find(|s| &s.name == ws) {
847                        None => {
848                            return Err(ValidationError::Stage {
849                                stage: stage.name.clone(),
850                                message: format!("fan_out worker_stage '{}' does not exist", ws),
851                            });
852                        }
853                        Some(target) if !target.allow_as_worker => {
854                            return Err(ValidationError::Stage {
855                                stage: stage.name.clone(),
856                                message: format!(
857                                    "fan_out worker_stage '{}' must set allow_as_worker = true",
858                                    ws
859                                ),
860                            });
861                        }
862                        Some(_) => {}
863                    }
864                }
865                if let Some(ms) = &config.merge_stage
866                    && !stage_names.contains(ms.as_str())
867                {
868                    return Err(ValidationError::Stage {
869                        stage: stage.name.clone(),
870                        message: format!("fan_out merge_stage '{}' does not exist", ms),
871                    });
872                }
873            }
874        }
875
876        let has_any_transitions = self.stages.iter().any(|s| s.transitions.is_some());
877        if !has_any_transitions {
878            // Pure linear mode - no graph validation needed
879            return Ok(());
880        }
881
882        // All transition targets must exist
883        for stage in &self.stages {
884            if let Some(ref transitions) = stage.transitions {
885                for (target_name, edge) in transitions {
886                    if !stage_names.contains(target_name.as_str()) {
887                        return Err(ValidationError::Transition {
888                            from: stage.name.clone(),
889                            to: target_name.clone(),
890                            message: "target stage does not exist".to_string(),
891                        });
892                    }
893                    // A `stuck` edge with no threshold could never fire. Caught
894                    // here as well as in the manifest parser, so blueprints built
895                    // programmatically (API / `lev validate`) are held to it too.
896                    if edge.condition == TransitionCondition::Stuck
897                        && !edge.stuck.is_some_and(|c| c.is_armed())
898                    {
899                        return Err(ValidationError::Transition {
900                            from: stage.name.clone(),
901                            to: target_name.clone(),
902                            message: "condition = \"stuck\" requires at least one \
903                                      stuck_after_* threshold (the edge could never fire)"
904                                .to_string(),
905                        });
906                    }
907                }
908
909                // A `require_modifications` gate on a stage that advertises no
910                // file-modifying tool can never be satisfied - it would just
911                // burn the stage's re-run budget every time.
912                for (target_name, edge) in transitions {
913                    let Some(gate) = &edge.gate else { continue };
914                    if !gate.require_modifications {
915                        continue;
916                    }
917                    let can_modify = stage.grants_all_builtins()
918                        || stage.available_tools.iter().any(|t| {
919                            MODIFYING_TOOLS.contains(&t.as_str())
920                                || gate.tools.iter().any(|extra| extra == t)
921                        });
922                    if !can_modify {
923                        return Err(ValidationError::Transition {
924                            from: stage.name.clone(),
925                            to: target_name.clone(),
926                            message: "gate requires modifications, but the stage has no \
927                                      file-modifying tool in available_tools"
928                                .to_string(),
929                        });
930                    }
931                }
932
933                // Self-loop safety: stages that transition to themselves need max_revisits
934                if transitions.contains_key(&stage.name) && stage.max_revisits.is_none() {
935                    return Err(ValidationError::Stage {
936                        stage: stage.name.clone(),
937                        message: "self-loop transition requires max_revisits".to_string(),
938                    });
939                }
940            }
941        }
942
943        // At least one terminal path must exist (a stage with no outgoing transitions,
944        // or with only conditional transitions that may not fire)
945        let entry = self.resolve_entry_stage_name();
946        let has_terminal = self.has_terminal_path(&entry, &mut std::collections::HashSet::new());
947        if !has_terminal {
948            return Err(ValidationError::Graph(
949                "no terminal path exists from entry stage - agent would never complete".to_string(),
950            ));
951        }
952
953        Ok(())
954    }
955
956    /// Resolve the entry stage name.
957    pub fn resolve_entry_stage_name(&self) -> String {
958        self.entry_stage.clone().unwrap_or_else(|| {
959            self.stages
960                .first()
961                .map(|s| s.name.clone())
962                .unwrap_or_default()
963        })
964    }
965
966    /// Check if there is a terminal path reachable from `stage_name`.
967    fn has_terminal_path(
968        &self,
969        stage_name: &str,
970        visited: &mut std::collections::HashSet<String>,
971    ) -> bool {
972        if visited.contains(stage_name) {
973            return false;
974        }
975        visited.insert(stage_name.to_string());
976
977        let stage = self.stages.iter().find(|s| s.name == stage_name);
978        let stage = match stage {
979            Some(s) => s,
980            // Unreachable via this function's only call site (`validate_graph`,
981            // below): it rejects any transition target that doesn't match a
982            // real stage name *before* ever calling `has_terminal_path`, and
983            // `has_terminal_path` is private, so no other caller can pass in
984            // an unvalidated stage name.
985            None => return false,
986        };
987
988        // A fan-out stage with a merge stage hands off to it after workers
989        // complete, so its terminal path runs through the merge stage.
990        if let StageMode::FanOut {
991            config:
992                FanOutConfig {
993                    merge_stage: Some(ms),
994                    ..
995                },
996        } = &stage.mode
997        {
998            return self.has_terminal_path(ms, visited);
999        }
1000
1001        match &stage.transitions {
1002            None => {
1003                // Linear mode: check if there's a next stage by index
1004                let idx = self
1005                    .stages
1006                    .iter()
1007                    .position(|s| s.name == stage_name)
1008                    .unwrap_or(0);
1009                if idx + 1 >= self.stages.len() {
1010                    return true; // terminal
1011                }
1012                self.has_terminal_path(&self.stages[idx + 1].name, visited)
1013            }
1014            Some(transitions) => {
1015                if transitions.is_empty() {
1016                    return true; // terminal stage
1017                }
1018                // Check if any transition leads to a terminal
1019                for target in transitions.keys() {
1020                    if self.has_terminal_path(target, visited) {
1021                        return true;
1022                    }
1023                }
1024                // No target reaches a terminal stage. Exhausting a stage's
1025                // edges is not a terminal path: running out of edges mid-graph
1026                // is a run *error* (StageResolution::DeadEnd in the runtime),
1027                // not a completion, so certifying it here would validate
1028                // blueprints that can never finish successfully.
1029                false
1030            }
1031        }
1032    }
1033
1034    /// Find a stage by name.
1035    pub fn find_stage(&self, name: &str) -> Option<&Stage> {
1036        self.stages.iter().find(|s| s.name == name)
1037    }
1038}
1039
1040// Sections of the former single-file blueprint, one per concept. Glob
1041// re-exported so every existing `blueprint::Stage` path keeps working and the
1042// split stays a pure move.
1043mod model;
1044pub use model::*;
1045mod stage;
1046pub use stage::*;
1047mod transition;
1048pub use transition::*;
1049mod tool_groups;
1050pub use tool_groups::*;
1051
1052#[cfg(test)]
1053mod tests {
1054    use super::*;
1055    use crate::layout::ContextLayout;
1056    use crate::layout::RegionDefinition;
1057    use crate::region::RegionKind;
1058
1059    /// Build a blueprint from a manifest, so these read as the TOML an author
1060    /// would actually write rather than as hand-assembled structs.
1061    fn bp_with_regions(regions_toml: &str) -> Blueprint {
1062        crate::manifest::parse_manifest(&format!(
1063            r#"
1064[agent]
1065name = "asked"
1066
1067[stages.main]
1068mode = "autonomous"
1069model = {{ provider = "anthropic", model = "m" }}
1070
1071[context.regions]
1072{regions_toml}
1073"#
1074        ))
1075        .expect("fixture parses")
1076    }
1077
1078    #[test]
1079    fn a_blueprint_accepts_a_task_when_some_region_seeds_from_it() {
1080        // Both spellings: the explicit seed and the region named `task`, which
1081        // gets the same seed implicitly.
1082        assert!(
1083            bp_with_regions(r#"brief = { kind = "pinned", max_tokens = 10, seed = "task" }"#)
1084                .accepts_task()
1085        );
1086        assert!(bp_with_regions(r#"task = { kind = "pinned", max_tokens = 10 }"#).accepts_task());
1087    }
1088
1089    /// `requires_task` is the `required` flag on the task region, and nothing
1090    /// else: an optional task region takes one without insisting.
1091    #[test]
1092    fn a_blueprint_requires_a_task_only_when_its_task_region_is_required() {
1093        assert!(
1094            bp_with_regions(r#"task = { kind = "pinned", max_tokens = 10, required = true }"#)
1095                .requires_task()
1096        );
1097        let optional = bp_with_regions(
1098            r#"task = { kind = "pinned", max_tokens = 10 }
1099diff = { kind = "pinned", max_tokens = 10, seed = "diff", required = true }"#,
1100        );
1101        assert!(optional.accepts_task());
1102        assert!(!optional.requires_task());
1103    }
1104
1105    #[test]
1106    fn a_blueprint_taking_other_caller_input_does_not_accept_a_task() {
1107        let bp = bp_with_regions(r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }"#);
1108        assert!(!bp.accepts_task());
1109        assert_eq!(bp.caller_inputs(), ["diff"]);
1110        assert!(!bp.requires_task());
1111    }
1112
1113    #[test]
1114    fn the_refusal_names_what_the_agent_takes_instead() {
1115        let bp = bp_with_regions(
1116            r#"diff = { kind = "pinned", max_tokens = 10, seed = "diff" }
1117criteria = { kind = "pinned", max_tokens = 10, seed = "criteria" }"#,
1118        );
1119        let msg = bp.task_refusal();
1120        assert!(msg.contains("agent 'asked'"), "{msg}");
1121        assert!(msg.contains("it takes: diff, criteria"), "{msg}");
1122    }
1123
1124    #[test]
1125    fn the_refusal_says_so_when_the_agent_takes_nothing() {
1126        let bp = bp_with_regions(r#"notes = { kind = "pinned", max_tokens = 10 }"#);
1127        assert!(bp.caller_inputs().is_empty());
1128        // Bound rather than called inside the assert message: a message
1129        // expression only runs when the assert fails, so it would be an
1130        // uncovered region on every green run.
1131        let msg = bp.task_refusal();
1132        assert!(msg.contains("it takes no caller input at all"), "{msg}");
1133    }
1134
1135    #[test]
1136    fn resolve_nudge_defaults_when_nothing_is_configured() {
1137        // No config anywhere: on for a normal stage, off for a reviewed one,
1138        // with the built-in cap and text.
1139        let normal = resolve_nudge(None, None, None, false);
1140        assert!(normal.enabled);
1141        assert_eq!(normal.max, DEFAULT_MAX_NUDGES);
1142        assert_eq!(normal.text, DEFAULT_NUDGE_TEXT);
1143        let reviewed = resolve_nudge(None, None, None, true);
1144        assert!(!reviewed.enabled);
1145        // The other fields don't depend on review status.
1146        assert_eq!(reviewed.max, DEFAULT_MAX_NUDGES);
1147        assert_eq!(reviewed.text, DEFAULT_NUDGE_TEXT);
1148    }
1149
1150    #[test]
1151    fn resolve_nudge_cascades_each_field_independently() {
1152        let global = NudgeConfig {
1153            enabled: Some(true),
1154            max: Some(10),
1155            text: Some("global".to_string()),
1156        };
1157        let agent = NudgeConfig {
1158            max: Some(2),
1159            ..Default::default()
1160        };
1161        let stage = NudgeConfig {
1162            text: Some("stage".to_string()),
1163            ..Default::default()
1164        };
1165        let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage), false);
1166        // enabled from global, max from agent, text from stage.
1167        assert!(resolved.enabled);
1168        assert_eq!(resolved.max, 2);
1169        assert_eq!(resolved.text, "stage");
1170        // The stage level wins over both when it sets a field.
1171        let stage_all = NudgeConfig {
1172            enabled: Some(false),
1173            max: Some(0),
1174            text: Some("s".to_string()),
1175        };
1176        let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage_all), false);
1177        assert_eq!(
1178            resolved,
1179            ResolvedNudge {
1180                enabled: false,
1181                max: 0,
1182                text: "s".to_string()
1183            }
1184        );
1185    }
1186
1187    #[test]
1188    fn resolve_nudge_explicit_enabled_overrides_review_suppression() {
1189        // A reviewed stage is only *implicitly* exempt: any level that sets
1190        // `enabled` speaks for itself, in either direction.
1191        let on = NudgeConfig {
1192            enabled: Some(true),
1193            ..Default::default()
1194        };
1195        assert!(resolve_nudge(None, None, Some(&on), true).enabled);
1196        assert!(resolve_nudge(None, Some(&on), None, true).enabled);
1197        assert!(resolve_nudge(Some(&on), None, None, true).enabled);
1198        let off = NudgeConfig {
1199            enabled: Some(false),
1200            ..Default::default()
1201        };
1202        assert!(!resolve_nudge(None, None, Some(&off), false).enabled);
1203    }
1204
1205    #[test]
1206    fn test_blueprint_creation() {
1207        let regions = vec![RegionDefinition::new(
1208            "test".to_string(),
1209            RegionKind::Pinned,
1210            5000,
1211        )];
1212        let layout = ContextLayout::new(regions, 10000);
1213
1214        let stages = vec![Stage::new(
1215            "analyze".to_string(),
1216            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1217        )];
1218
1219        let blueprint = Blueprint::new(
1220            "test-agent".to_string(),
1221            "A test agent".to_string(),
1222            stages,
1223            layout,
1224        );
1225
1226        assert_eq!(blueprint.name, "test-agent");
1227        assert_eq!(blueprint.stages.len(), 1);
1228    }
1229
1230    #[test]
1231    fn test_blueprint_with_transforms_version() {
1232        let stages = vec![Stage::new("plan".to_string(), make_model())];
1233        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout())
1234            .with_transforms(vec![ContextTransform {
1235                from_blueprint: "a".to_string(),
1236                to_blueprint: "b".to_string(),
1237                mappings: vec![],
1238            }])
1239            .with_version("2.0.0".to_string());
1240
1241        assert_eq!(bp.transforms.len(), 1);
1242        assert_eq!(bp.version, "2.0.0");
1243    }
1244
1245    #[test]
1246    fn agent_tool_permissions_projects_only_string_tool_perm_entries() {
1247        let stages = vec![Stage::new("plan".to_string(), make_model())];
1248        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1249        // A well-formed tool_perm string entry - included.
1250        bp.metadata.insert(
1251            "tool_perm:bash".to_string(),
1252            serde_json::Value::String("deny".to_string()),
1253        );
1254        // A non-`tool_perm:` key - skipped (strip_prefix returns None).
1255        bp.metadata
1256            .insert("title".to_string(), serde_json::Value::String("x".into()));
1257        // A tool_perm key whose value isn't a string - skipped (as_str is None).
1258        bp.metadata
1259            .insert("tool_perm:weird".to_string(), serde_json::Value::Bool(true));
1260
1261        let perms = bp.agent_tool_permissions();
1262        assert_eq!(perms.get("bash").map(String::as_str), Some("deny"));
1263        assert!(!perms.contains_key("title"));
1264        assert!(!perms.contains_key("weird"));
1265        assert_eq!(perms.len(), 1);
1266    }
1267
1268    #[test]
1269    fn test_blueprint_validate_runs_transform_validation() {
1270        // A transform whose mapping targets a real region - validate() must
1271        // reach ContextTransform::validate() and succeed.
1272        let stages = vec![Stage::new("plan".to_string(), make_model())];
1273        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1274        bp.transforms.push(ContextTransform {
1275            from_blueprint: "a".to_string(),
1276            to_blueprint: "b".to_string(),
1277            mappings: vec![RegionMapping {
1278                from_region: "test".to_string(),
1279                to_region: "test".to_string(),
1280                transform: None,
1281            }],
1282        });
1283        assert!(bp.validate().is_ok());
1284    }
1285
1286    #[test]
1287    fn test_blueprint_validate_fails_on_transform_targeting_unknown_region() {
1288        let stages = vec![Stage::new("plan".to_string(), make_model())];
1289        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1290        bp.transforms.push(ContextTransform {
1291            from_blueprint: "a".to_string(),
1292            to_blueprint: "b".to_string(),
1293            mappings: vec![RegionMapping {
1294                from_region: "test".to_string(),
1295                to_region: "nonexistent".to_string(),
1296                transform: None,
1297            }],
1298        });
1299        let err = bp.validate().unwrap_err();
1300        assert_eq!(
1301            err,
1302            ValidationError::Region {
1303                region: "nonexistent".to_string(),
1304                message: "transform target region not found in layout".to_string(),
1305            }
1306        );
1307    }
1308
1309    #[test]
1310    fn test_mixed_linear_and_graph_mode_terminal_path() {
1311        // "plan" has explicit transitions (triggers graph-mode validation),
1312        // but "impl" and "review" have none - they must fall back to
1313        // linear (next-by-index) terminal-path resolution.
1314        let mut plan = Stage::new("plan".to_string(), make_model());
1315        let impl_stage = Stage::new("impl".to_string(), make_model());
1316        let review = Stage::new("review".to_string(), make_model());
1317
1318        let mut transitions = HashMap::new();
1319        transitions.insert(
1320            "impl".to_string(),
1321            TransitionEdge {
1322                target: "impl".to_string(),
1323                condition: TransitionCondition::Always,
1324                hint: None,
1325                transform: EdgeTransform::Direct,
1326                gate: None,
1327                stuck: None,
1328            },
1329        );
1330        plan.transitions = Some(transitions);
1331
1332        let bp = Blueprint::new(
1333            "t".into(),
1334            "".into(),
1335            vec![plan, impl_stage, review],
1336            make_layout(),
1337        );
1338        assert!(bp.validate().is_ok());
1339    }
1340
1341    #[test]
1342    fn test_stage_validation() {
1343        let stage = Stage::new(
1344            "test".to_string(),
1345            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1346        );
1347        assert!(stage.validate().is_ok());
1348
1349        let empty_stage = Stage::new(
1350            "".to_string(),
1351            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1352        );
1353        assert!(empty_stage.validate().is_err());
1354    }
1355
1356    #[test]
1357    fn test_stage_validate_with_valid_context_layout_is_ok() {
1358        let mut stage = Stage::new("test".to_string(), make_model());
1359        stage.context_layout = Some(make_layout());
1360        assert!(stage.validate().is_ok());
1361    }
1362
1363    #[test]
1364    fn test_stage_validate_with_invalid_context_layout_is_err() {
1365        // Duplicate region names make the layout itself invalid.
1366        let regions = vec![
1367            RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
1368            RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
1369        ];
1370        let mut stage = Stage::new("test".to_string(), make_model());
1371        stage.context_layout = Some(ContextLayout::new(regions, 200));
1372        assert!(stage.validate().is_err());
1373    }
1374
1375    #[test]
1376    fn test_stage_with_tools_context_layout_description() {
1377        let stage = Stage::new("test".to_string(), make_model())
1378            .with_tools(vec!["read_file".to_string(), "bash".to_string()])
1379            .with_context_layout(make_layout())
1380            .with_description("does things".to_string());
1381
1382        assert_eq!(stage.available_tools, vec!["read_file", "bash"]);
1383        assert!(stage.context_layout.is_some());
1384        assert_eq!(stage.description.as_deref(), Some("does things"));
1385    }
1386
1387    #[test]
1388    fn test_stage_with_mode() {
1389        let stage = Stage::new("test".to_string(), make_model())
1390            .with_mode(StageMode::InteractivePoints { points: vec![] });
1391        assert_eq!(stage.mode, StageMode::InteractivePoints { points: vec![] });
1392    }
1393
1394    #[test]
1395    fn test_stage_allow_complete_defaults_false() {
1396        let stage = Stage::new("review".to_string(), make_model());
1397        assert!(!stage.allow_complete);
1398    }
1399
1400    #[test]
1401    fn test_stage_allow_complete_serde_default_when_missing() {
1402        // A serialized stage from before allow_complete existed must still
1403        // deserialize, defaulting to false.
1404        let json = r#"{
1405            "name": "review",
1406            "description": null,
1407            "model": {"provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {}},
1408            "available_tools": [],
1409            "max_iterations": null,
1410            "context_layout": null,
1411            "config": {},
1412            "transitions": null,
1413            "max_revisits": null,
1414            "transition_prompt": null
1415        }"#;
1416        let stage: Stage = serde_json::from_str(json).unwrap();
1417        assert!(!stage.allow_complete);
1418        assert!(stage.accepts_messages);
1419    }
1420
1421    #[test]
1422    fn test_stage_allow_complete_roundtrip() {
1423        let mut stage = Stage::new("review".to_string(), make_model());
1424        stage.allow_complete = true;
1425        let json = serde_json::to_string(&stage).unwrap();
1426        let back: Stage = serde_json::from_str(&json).unwrap();
1427        assert!(back.allow_complete);
1428    }
1429
1430    #[test]
1431    fn test_interaction_point_directives_default_empty() {
1432        let point = InteractionPoint {
1433            name: "plan_approval".to_string(),
1434            prompt: "Approve?".to_string(),
1435            required: true,
1436            unattended: UnattendedPolicy::AutoApprove,
1437            style: InteractionStyle::MultipleChoice,
1438            options: vec!["Approve".to_string(), "Revise".to_string()],
1439            directives: HashMap::new(),
1440            abort_options: Vec::new(),
1441            edit_options: Vec::new(),
1442            document_region: None,
1443        };
1444        assert!(point.directives.is_empty());
1445        assert!(point.abort_options.is_empty());
1446        assert!(point.edit_options.is_empty());
1447    }
1448
1449    #[test]
1450    fn test_interaction_point_directives_roundtrip() {
1451        let mut directives = HashMap::new();
1452        directives.insert(
1453            "Revise".to_string(),
1454            "Ask what to change, then re-plan.".to_string(),
1455        );
1456        let point = InteractionPoint {
1457            name: "plan_approval".to_string(),
1458            prompt: "Approve?".to_string(),
1459            required: true,
1460            unattended: UnattendedPolicy::Ask,
1461            style: InteractionStyle::MultipleChoice,
1462            options: vec!["Approve".to_string(), "Revise".to_string()],
1463            directives,
1464            abort_options: vec!["Abort".to_string()],
1465            edit_options: vec!["Add detail".to_string()],
1466            document_region: Some("plan".to_string()),
1467        };
1468        let json = serde_json::to_string(&point).unwrap();
1469        let back: InteractionPoint = serde_json::from_str(&json).unwrap();
1470        assert_eq!(
1471            back.directives.get("Revise").map(|s| s.as_str()),
1472            Some("Ask what to change, then re-plan.")
1473        );
1474        assert_eq!(back.abort_options, vec!["Abort".to_string()]);
1475        assert_eq!(back.edit_options, vec!["Add detail".to_string()]);
1476        // A point that holds for a person under `--yolo` has to survive the
1477        // round trip: this is what a restored run re-arms from.
1478        assert_eq!(back.unattended, UnattendedPolicy::Ask);
1479    }
1480
1481    #[test]
1482    fn test_interaction_point_directives_serde_default_when_missing() {
1483        let json = r#"{
1484            "name": "plan_approval",
1485            "prompt": "Approve?",
1486            "required": true,
1487            "style": "multiple_choice",
1488            "options": ["Approve", "Revise"]
1489        }"#;
1490        let point: InteractionPoint = serde_json::from_str(json).unwrap();
1491        assert!(point.directives.is_empty());
1492        assert!(point.abort_options.is_empty());
1493    }
1494
1495    #[test]
1496    fn test_interaction_point_followups_alias_still_deserializes() {
1497        // Backward compat: old serialized blueprints used "followups".
1498        let json = r#"{
1499            "name": "plan_approval",
1500            "prompt": "Approve?",
1501            "required": true,
1502            "style": "multiple_choice",
1503            "options": ["Approve", "Revise"],
1504            "followups": { "Revise": "What to change?" }
1505        }"#;
1506        let point: InteractionPoint = serde_json::from_str(json).unwrap();
1507        assert_eq!(
1508            point.directives.get("Revise").map(|s| s.as_str()),
1509            Some("What to change?")
1510        );
1511    }
1512
1513    #[test]
1514    fn test_model_config_new_creates_single_entry() {
1515        let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1516        assert_eq!(mc.models.len(), 1);
1517        assert_eq!(mc.models[0].provider, "anthropic");
1518        assert_eq!(mc.models[0].model, "claude-sonnet-4-6");
1519        assert!(mc.allow_user_default);
1520    }
1521
1522    #[test]
1523    fn test_model_config_with_multiple_models() {
1524        let mc = ModelConfig {
1525            models: vec![
1526                ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1527                ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1528                ModelEntry::new("ollama".to_string(), "llama3".to_string()),
1529            ],
1530            allow_user_default: true,
1531            parameters: HashMap::new(),
1532            request_timeout_secs: None,
1533        };
1534        assert_eq!(mc.models.len(), 3);
1535        assert_eq!(mc.models[0].provider, "anthropic");
1536        assert_eq!(mc.models[1].provider, "openai");
1537        assert_eq!(mc.models[2].provider, "ollama");
1538    }
1539
1540    #[test]
1541    fn test_model_config_serde_roundtrip() {
1542        let mc = ModelConfig {
1543            models: vec![
1544                ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1545                ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1546            ],
1547            allow_user_default: false,
1548            parameters: HashMap::new(),
1549            request_timeout_secs: None,
1550        };
1551        let json = serde_json::to_string(&mc).unwrap();
1552        let back: ModelConfig = serde_json::from_str(&json).unwrap();
1553        assert_eq!(back.models.len(), 2);
1554        assert_eq!(back.models[0].provider, "anthropic");
1555        assert_eq!(back.models[1].provider, "openai");
1556        assert!(!back.allow_user_default);
1557    }
1558
1559    #[test]
1560    fn test_model_config_serde_defaults_when_fields_missing() {
1561        // Minimal JSON - models defaults to empty, allow_user_default defaults to true
1562        let json = r#"{"parameters": {}}"#;
1563        let mc: ModelConfig = serde_json::from_str(json).unwrap();
1564        assert!(mc.models.is_empty());
1565        assert!(mc.allow_user_default);
1566    }
1567
1568    #[test]
1569    fn test_model_config_convenience_accessors() {
1570        let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1571        assert_eq!(mc.provider(), "anthropic");
1572        assert_eq!(mc.model(), "claude-sonnet-4-6");
1573    }
1574
1575    #[test]
1576    fn test_model_config_convenience_accessors_empty_models() {
1577        let mc = ModelConfig {
1578            models: vec![],
1579            allow_user_default: true,
1580            parameters: HashMap::new(),
1581            request_timeout_secs: None,
1582        };
1583        assert_eq!(mc.provider(), "anthropic");
1584        assert_eq!(mc.model(), "claude-sonnet-4-6");
1585    }
1586
1587    fn make_model() -> ModelConfig {
1588        ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string())
1589    }
1590
1591    fn make_layout() -> ContextLayout {
1592        let regions = vec![RegionDefinition::new(
1593            "test".to_string(),
1594            RegionKind::Pinned,
1595            5000,
1596        )];
1597        ContextLayout::new(regions, 10000)
1598    }
1599
1600    #[test]
1601    fn test_graph_validation_entry_stage_exists() {
1602        let stages = vec![Stage::new("plan".to_string(), make_model())];
1603        let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1604        bp.entry_stage = Some("nonexistent".to_string());
1605        assert!(bp.validate().is_err());
1606    }
1607
1608    #[test]
1609    fn test_graph_validation_entry_stage_valid() {
1610        let stages = vec![Stage::new("plan".to_string(), make_model())];
1611        let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1612        bp.entry_stage = Some("plan".to_string());
1613        assert!(bp.validate().is_ok());
1614    }
1615
1616    #[test]
1617    fn test_graph_validation_transition_target_missing() {
1618        let mut stage = Stage::new("plan".to_string(), make_model());
1619        let mut transitions = HashMap::new();
1620        transitions.insert(
1621            "nonexistent".to_string(),
1622            TransitionEdge {
1623                target: "nonexistent".to_string(),
1624                condition: TransitionCondition::Always,
1625                hint: None,
1626                transform: EdgeTransform::Direct,
1627                gate: None,
1628                stuck: None,
1629            },
1630        );
1631        stage.transitions = Some(transitions);
1632        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1633        assert!(bp.validate().is_err());
1634    }
1635
1636    /// A `require_modifications` gate on a stage that can't modify anything
1637    /// could never be satisfied - it would just burn the stage's re-run budget
1638    /// on every pass. Reject it at load time instead.
1639    #[test]
1640    fn test_graph_validation_modification_gate_needs_a_writing_stage() {
1641        let gated = |tools: &[&str], extra: &[&str]| {
1642            let mut stage = Stage::new("impl".to_string(), make_model());
1643            stage.available_tools = tools.iter().map(|t| t.to_string()).collect();
1644            let mut transitions = HashMap::new();
1645            transitions.insert(
1646                "review".to_string(),
1647                TransitionEdge {
1648                    target: "review".to_string(),
1649                    condition: TransitionCondition::Always,
1650                    hint: None,
1651                    transform: EdgeTransform::Direct,
1652                    stuck: None,
1653                    gate: Some(TransitionGate {
1654                        require_modifications: true,
1655                        tools: extra.iter().map(|t| t.to_string()).collect(),
1656                        ..Default::default()
1657                    }),
1658                },
1659            );
1660            stage.transitions = Some(transitions);
1661            Blueprint::new(
1662                "t".into(),
1663                "".into(),
1664                vec![stage, Stage::new("review".to_string(), make_model())],
1665                make_layout(),
1666            )
1667        };
1668        let err = gated(&["read_file"], &[]).validate().unwrap_err();
1669        assert!(err.to_string().contains("no file-modifying tool"));
1670        // A built-in write tool satisfies it...
1671        assert!(gated(&["read_file", "edit_file"], &[]).validate().is_ok());
1672        // ...so does a group that carries one, with neither name written...
1673        assert!(gated(&["@builtin"], &[]).validate().is_ok());
1674        assert!(gated(&["@all"], &[]).validate().is_ok());
1675        // ...but not a group that carries none.
1676        assert!(gated(&["@scripts"], &[]).validate().is_err());
1677        // ...as does one the gate itself declares (MCP / script toolchains).
1678        assert!(
1679            gated(&["read_file", "patch_file"], &["patch_file"])
1680                .validate()
1681                .is_ok()
1682        );
1683        // A gate that doesn't require modifications is never checked.
1684        let mut off = gated(&["read_file"], &[]);
1685        off.stages[0]
1686            .transitions
1687            .as_mut()
1688            .unwrap()
1689            .get_mut("review")
1690            .unwrap()
1691            .gate = Some(TransitionGate::default());
1692        assert!(off.validate().is_ok());
1693        // Neither is an edge with no gate at all.
1694        off.stages[0]
1695            .transitions
1696            .as_mut()
1697            .unwrap()
1698            .get_mut("review")
1699            .unwrap()
1700            .gate = None;
1701        assert!(off.validate().is_ok());
1702    }
1703
1704    #[test]
1705    fn test_graph_validation_self_loop_requires_max_revisits() {
1706        let mut stage = Stage::new("impl".to_string(), make_model());
1707        let mut transitions = HashMap::new();
1708        transitions.insert(
1709            "impl".to_string(),
1710            TransitionEdge {
1711                target: "impl".to_string(),
1712                condition: TransitionCondition::Always,
1713                hint: None,
1714                transform: EdgeTransform::Direct,
1715                gate: None,
1716                stuck: None,
1717            },
1718        );
1719        stage.transitions = Some(transitions);
1720        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1721        assert!(bp.validate().is_err());
1722    }
1723
1724    #[test]
1725    fn test_graph_validation_self_loop_with_max_revisits_ok() {
1726        let mut stage = Stage::new("impl".to_string(), make_model());
1727        stage.max_revisits = Some(3);
1728        let mut transitions = HashMap::new();
1729        transitions.insert(
1730            "impl".to_string(),
1731            TransitionEdge {
1732                target: "impl".to_string(),
1733                condition: TransitionCondition::Always,
1734                hint: None,
1735                transform: EdgeTransform::Direct,
1736                gate: None,
1737                stuck: None,
1738            },
1739        );
1740        stage.transitions = Some(transitions);
1741        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1742        // Must fail: a self-loop exhausting its max_revisits leaves zero
1743        // edges, which is a run error (StageResolution::DeadEnd) and not a
1744        // terminal path, so a blueprint whose only ending is exhaustion can
1745        // never finish successfully.
1746        let err = bp
1747            .validate()
1748            .expect_err("an exhaustion-only graph is invalid");
1749        assert!(err.to_string().contains("no terminal path"), "{err}");
1750    }
1751
1752    #[test]
1753    fn test_graph_validation_terminal_path_exists() {
1754        let mut plan = Stage::new("plan".to_string(), make_model());
1755        let mut review = Stage::new("review".to_string(), make_model());
1756        review.transitions = Some(HashMap::new()); // terminal: no outgoing
1757
1758        let mut transitions = HashMap::new();
1759        transitions.insert(
1760            "review".to_string(),
1761            TransitionEdge {
1762                target: "review".to_string(),
1763                condition: TransitionCondition::Always,
1764                hint: None,
1765                transform: EdgeTransform::Direct,
1766                gate: None,
1767                stuck: None,
1768            },
1769        );
1770        plan.transitions = Some(transitions);
1771
1772        let bp = Blueprint::new("t".into(), "".into(), vec![plan, review], make_layout());
1773        assert!(bp.validate().is_ok());
1774    }
1775
1776    #[test]
1777    fn test_graph_no_terminal_path() {
1778        // Two stages that only transition to each other with no terminal
1779        let mut a = Stage::new("a".to_string(), make_model());
1780        let mut b = Stage::new("b".to_string(), make_model());
1781
1782        let mut a_transitions = HashMap::new();
1783        a_transitions.insert(
1784            "b".to_string(),
1785            TransitionEdge {
1786                target: "b".to_string(),
1787                condition: TransitionCondition::Always,
1788                hint: None,
1789                transform: EdgeTransform::Direct,
1790                gate: None,
1791                stuck: None,
1792            },
1793        );
1794        a.transitions = Some(a_transitions);
1795
1796        let mut b_transitions = HashMap::new();
1797        b_transitions.insert(
1798            "a".to_string(),
1799            TransitionEdge {
1800                target: "a".to_string(),
1801                condition: TransitionCondition::Always,
1802                hint: None,
1803                transform: EdgeTransform::Direct,
1804                gate: None,
1805                stuck: None,
1806            },
1807        );
1808        b.transitions = Some(b_transitions);
1809
1810        let bp = Blueprint::new("t".into(), "".into(), vec![a, b], make_layout());
1811        assert!(bp.validate().is_err());
1812    }
1813
1814    #[test]
1815    fn test_linear_stages_still_validate() {
1816        // No transitions set at all - pure linear mode
1817        let stages = vec![
1818            Stage::new("plan".to_string(), make_model()),
1819            Stage::new("impl".to_string(), make_model()),
1820            Stage::new("review".to_string(), make_model()),
1821        ];
1822        let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1823        assert!(bp.validate().is_ok());
1824    }
1825
1826    #[test]
1827    fn test_resolve_entry_stage_name() {
1828        let stages = vec![
1829            Stage::new("plan".to_string(), make_model()),
1830            Stage::new("impl".to_string(), make_model()),
1831        ];
1832        let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1833        assert_eq!(bp.resolve_entry_stage_name(), "plan");
1834
1835        bp.entry_stage = Some("impl".to_string());
1836        assert_eq!(bp.resolve_entry_stage_name(), "impl");
1837    }
1838
1839    #[test]
1840    fn test_find_stage() {
1841        let stages = vec![
1842            Stage::new("plan".to_string(), make_model()),
1843            Stage::new("impl".to_string(), make_model()),
1844        ];
1845        let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1846        assert!(bp.find_stage("plan").is_some());
1847        assert!(bp.find_stage("impl").is_some());
1848        assert!(bp.find_stage("nonexistent").is_none());
1849    }
1850
1851    #[test]
1852    fn test_transition_condition_default() {
1853        let cond = TransitionCondition::default();
1854        assert_eq!(cond, TransitionCondition::Always);
1855    }
1856
1857    #[test]
1858    fn test_edge_transform_default() {
1859        let t = EdgeTransform::default();
1860        assert_eq!(t, EdgeTransform::Direct);
1861    }
1862
1863    #[test]
1864    fn test_stage_mode_equality() {
1865        assert_eq!(StageMode::Autonomous, StageMode::Autonomous);
1866        assert_eq!(StageMode::Interactive, StageMode::Interactive);
1867        assert_ne!(StageMode::Autonomous, StageMode::Interactive);
1868    }
1869
1870    #[test]
1871    fn test_interaction_style_equality() {
1872        assert_eq!(InteractionStyle::FreeText, InteractionStyle::FreeText);
1873        assert_ne!(InteractionStyle::FreeText, InteractionStyle::MultipleChoice);
1874    }
1875
1876    // ─── stuck detection ────────────────────────────────────────────────────
1877
1878    #[test]
1879    fn stuck_config_is_armed_only_when_a_threshold_is_set() {
1880        assert!(!StuckConfig::default().is_armed());
1881        for cfg in [
1882            StuckConfig {
1883                after_iterations: Some(1),
1884                ..Default::default()
1885            },
1886            StuckConfig {
1887                after_minutes: Some(1),
1888                ..Default::default()
1889            },
1890            StuckConfig {
1891                after_same_file_edits: Some(1),
1892                ..Default::default()
1893            },
1894            StuckConfig {
1895                after_tool_calls: Some(1),
1896                ..Default::default()
1897            },
1898        ] {
1899            assert!(cfg.is_armed(), "{cfg:?} should be armed");
1900        }
1901    }
1902
1903    #[test]
1904    fn transition_condition_stuck_round_trips_as_snake_case() {
1905        let json = serde_json::to_string(&TransitionCondition::Stuck).unwrap();
1906        assert_eq!(json, "\"stuck\"");
1907        let back: TransitionCondition = serde_json::from_str(&json).unwrap();
1908        assert_eq!(back, TransitionCondition::Stuck);
1909        assert_ne!(TransitionCondition::Stuck, TransitionCondition::Always);
1910    }
1911
1912    #[test]
1913    fn transition_edge_stuck_round_trips_and_is_omitted_when_absent() {
1914        let plain = TransitionEdge {
1915            target: "b".to_string(),
1916            condition: TransitionCondition::Always,
1917            hint: None,
1918            transform: EdgeTransform::Direct,
1919            gate: None,
1920            stuck: None,
1921        };
1922        let json = serde_json::to_string(&plain).unwrap();
1923        assert!(
1924            !json.contains("stuck"),
1925            "absent config must be skipped: {json}"
1926        );
1927
1928        let armed = TransitionEdge {
1929            condition: TransitionCondition::Stuck,
1930            stuck: Some(StuckConfig {
1931                after_iterations: Some(20),
1932                after_minutes: Some(10),
1933                after_same_file_edits: Some(3),
1934                after_tool_calls: Some(60),
1935            }),
1936            ..plain
1937        };
1938        let back: TransitionEdge = serde_json::from_str(&serde_json::to_string(&armed).unwrap())
1939            .expect("armed edge round-trips");
1940        assert_eq!(back.condition, TransitionCondition::Stuck);
1941        assert_eq!(back.stuck, armed.stuck);
1942    }
1943
1944    /// A blueprint built programmatically (API / `lev validate`) bypasses the
1945    /// manifest parser, so `validate` has to catch the dead-edge shape too.
1946    #[test]
1947    fn validate_rejects_a_stuck_edge_with_no_threshold() {
1948        let build = |stuck| {
1949            let mut a = Stage::new("a".to_string(), make_model());
1950            let b = Stage::new("b".to_string(), make_model());
1951            let mut transitions = std::collections::HashMap::new();
1952            transitions.insert(
1953                "b".to_string(),
1954                TransitionEdge {
1955                    target: "b".to_string(),
1956                    condition: TransitionCondition::Stuck,
1957                    hint: None,
1958                    transform: EdgeTransform::Direct,
1959                    gate: None,
1960                    stuck,
1961                },
1962            );
1963            a.transitions = Some(transitions);
1964            Blueprint::new("t".into(), "".into(), vec![a, b], make_layout())
1965        };
1966
1967        for dead in [None, Some(StuckConfig::default())] {
1968            let err = build(dead)
1969                .validate()
1970                .expect_err("dead stuck edge rejected");
1971            assert!(
1972                format!("{err:?}").contains("stuck_after_"),
1973                "unexpected error: {err:?}"
1974            );
1975        }
1976
1977        // The same graph with a real threshold is fine.
1978        assert!(
1979            build(Some(StuckConfig {
1980                after_iterations: Some(5),
1981                ..Default::default()
1982            }))
1983            .validate()
1984            .is_ok()
1985        );
1986    }
1987
1988    /// `required_tools` keeps a blocking human tool through an unattended run.
1989    /// Naming one the stage can't call keeps nothing, so it is rejected rather
1990    /// than quietly ignored - the author meant something by writing it.
1991    #[test]
1992    fn validate_rejects_a_required_tool_the_stage_cannot_call() {
1993        let mut stage = Stage::new("plan".to_string(), make_model());
1994        stage.available_tools = vec!["read_file".to_string()];
1995        stage.required_tools = vec!["ask_user_text".to_string()];
1996        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1997
1998        let err = bp.validate().expect_err("a tool it cannot call");
1999        let text = format!("{err:?}");
2000        assert!(text.contains("ask_user_text"), "names the tool: {text}");
2001        assert!(text.contains("available_tools"), "says why: {text}");
2002    }
2003
2004    #[test]
2005    fn validate_accepts_a_required_tool_the_stage_offers() {
2006        let mut stage = Stage::new("plan".to_string(), make_model());
2007        stage.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
2008        stage.required_tools = vec!["ask_user_text".to_string()];
2009        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2010
2011        bp.validate().expect("the tool is on offer");
2012    }
2013
2014    /// With a group in the list the membership question belongs to the
2015    /// install, so validation takes the author's word and the lint checks.
2016    #[test]
2017    fn validate_accepts_a_required_tool_a_group_could_cover() {
2018        let mut stage = Stage::new("plan".to_string(), make_model());
2019        stage.available_tools = vec!["@builtin".to_string()];
2020        stage.required_tools = vec!["ask_user_text".to_string()];
2021        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2022
2023        bp.validate().expect("the group may cover it");
2024    }
2025
2026    #[test]
2027    fn validate_rejects_a_group_shaped_entry_that_names_no_group() {
2028        let mut stage = Stage::new("plan".to_string(), make_model());
2029        stage.available_tools = vec!["read_file".to_string(), "@builtins".to_string()];
2030        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2031
2032        let err = bp.validate().expect_err("not a group");
2033        let text = format!("{err:?}");
2034        assert!(text.contains("@builtins"), "names the entry: {text}");
2035        assert!(text.contains("@builtin,"), "lists the groups: {text}");
2036    }
2037
2038    #[test]
2039    fn stage_reports_its_groups_and_named_tools_separately() {
2040        let mut stage = Stage::new("plan".to_string(), make_model());
2041        stage.available_tools = vec![
2042            "read_file".to_string(),
2043            "@scripts".to_string(),
2044            "github__create_issue".to_string(),
2045        ];
2046        assert_eq!(stage.tool_groups(), vec![ToolGroup::Scripts]);
2047        assert!(stage.grants_group(ToolGroup::Scripts));
2048        assert!(!stage.grants_group(ToolGroup::Mcp));
2049        assert!(!stage.grants_all_builtins());
2050        let named: Vec<&String> = stage.named_tools().collect();
2051        assert_eq!(named, vec!["read_file", "github__create_issue"]);
2052
2053        stage.available_tools = vec!["@all".to_string()];
2054        assert!(stage.grants_all_builtins());
2055        assert!(stage.grants_group(ToolGroup::Mcp));
2056        assert_eq!(stage.named_tools().count(), 0);
2057    }
2058
2059    /// A stage required to produce an output, without the tool that produces
2060    /// one, would spend its whole re-entry budget being nudged toward a tool it
2061    /// was never offered and then give up. Caught at load instead.
2062    #[test]
2063    fn validate_rejects_require_output_without_the_submit_tool() {
2064        let mut stage = Stage::new("summary".to_string(), make_model());
2065        stage.available_tools = vec!["read_file".to_string()];
2066        stage.require_output = true;
2067        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2068
2069        let err = bp.validate().expect_err("no way to submit");
2070        let text = format!("{err:?}");
2071        assert!(text.contains(SUBMIT_OUTPUT_TOOL), "names the tool: {text}");
2072        assert!(text.contains("require_output"), "says why: {text}");
2073    }
2074
2075    #[test]
2076    fn validate_accepts_require_output_when_the_stage_can_submit() {
2077        let mut stage = Stage::new("summary".to_string(), make_model());
2078        stage.available_tools = vec![SUBMIT_OUTPUT_TOOL.to_string()];
2079        stage.require_output = true;
2080        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2081
2082        bp.validate().expect("the stage can submit");
2083    }
2084
2085    /// Declaring a shape is not the same as demanding one, so a stage carrying
2086    /// only an `output` block needs no tool grant.
2087    #[test]
2088    fn validate_accepts_a_declared_shape_without_require_output() {
2089        let mut stage = Stage::new("summary".to_string(), make_model());
2090        stage.available_tools = vec!["read_file".to_string()];
2091        stage.output = Some(crate::output::OutputSpec {
2092            format: Some("a2ui".to_string()),
2093            ..Default::default()
2094        });
2095        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2096
2097        bp.validate().expect("declaring a shape demands nothing");
2098    }
2099
2100    #[test]
2101    fn output_mode_compares_equal_only_to_itself() {
2102        assert_eq!(StageMode::Output, StageMode::Output);
2103        assert_ne!(StageMode::Output, StageMode::Autonomous);
2104        assert_ne!(StageMode::Autonomous, StageMode::Output);
2105    }
2106
2107    #[test]
2108    fn test_transition_condition_equality() {
2109        assert_eq!(
2110            TransitionCondition::LlmChoice,
2111            TransitionCondition::LlmChoice
2112        );
2113        assert_ne!(TransitionCondition::Always, TransitionCondition::Error);
2114    }
2115
2116    #[test]
2117    fn test_edge_transform_compact_and_custom_equality() {
2118        let a = EdgeTransform::Compact {
2119            prompt: Some("p".to_string()),
2120        };
2121        let b = EdgeTransform::Compact {
2122            prompt: Some("p".to_string()),
2123        };
2124        assert_eq!(a, b);
2125
2126        let c1 = EdgeTransform::Custom {
2127            carry: vec!["a".to_string()],
2128            compact: vec!["b".to_string()],
2129            clear: vec!["c".to_string()],
2130            compact_prompt: Some("p".to_string()),
2131        };
2132        let c2 = c1.clone();
2133        assert_eq!(c1, c2);
2134
2135        assert_ne!(EdgeTransform::Direct, EdgeTransform::Clear);
2136    }
2137
2138    #[test]
2139    fn test_stage_accepts_messages_default_true() {
2140        let stage = Stage::new(
2141            "test".to_string(),
2142            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
2143        );
2144        assert!(stage.accepts_messages);
2145    }
2146
2147    #[test]
2148    fn test_stage_accepts_messages_serde_roundtrip() {
2149        // Serialize a stage with accepts_messages = false, then deserialize
2150        let mut stage = Stage::new(
2151            "report".to_string(),
2152            ModelConfig::new("anthropic".to_string(), "claude-opus-4-6".to_string()),
2153        );
2154        stage.accepts_messages = false;
2155
2156        let json = serde_json::to_string(&stage).expect("should serialize");
2157        let deserialized: Stage = serde_json::from_str(&json).expect("should deserialize");
2158        assert!(!deserialized.accepts_messages);
2159    }
2160
2161    #[test]
2162    fn test_stage_accepts_messages_json_default() {
2163        // When accepts_messages is missing from JSON, it should default to true
2164        let json = r#"{
2165            "name": "analyze",
2166            "model": { "provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {} },
2167            "available_tools": [],
2168            "mode": "Autonomous",
2169            "config": {},
2170            "tool_permissions": {},
2171            "requires_children": false
2172        }"#;
2173        let stage: Stage = serde_json::from_str(json).expect("should parse");
2174        assert!(stage.accepts_messages);
2175    }
2176
2177    #[test]
2178    fn test_has_terminal_path_unknown_stage_returns_false() {
2179        // `has_terminal_path` is private; this test is in the same module.
2180        // Calling it with a stage name that doesn't exist in the Blueprint
2181        // exercises the `None => return false` arm (blueprint.rs line 203).
2182        let stages = vec![Stage::new("start".to_string(), make_model())];
2183        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2184        let mut visited = std::collections::HashSet::new();
2185        assert!(!bp.has_terminal_path("nonexistent_stage", &mut visited));
2186    }
2187
2188    #[test]
2189    fn test_blueprint_validate_fails_when_layout_has_duplicate_region() {
2190        let regions = vec![
2191            RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
2192            RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
2193        ];
2194        let layout = ContextLayout::new(regions, 200);
2195        let stages = vec![Stage::new("start".to_string(), make_model())];
2196        let bp = Blueprint::new("t".into(), "d".into(), stages, layout);
2197        assert_eq!(
2198            bp.validate().unwrap_err(),
2199            ValidationError::Region {
2200                region: "dup".to_string(),
2201                message: "duplicate region name".to_string(),
2202            }
2203        );
2204    }
2205
2206    #[test]
2207    fn test_blueprint_validate_fails_when_stage_has_empty_name() {
2208        let stages = vec![Stage::new("".to_string(), make_model())];
2209        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2210        assert_eq!(
2211            bp.validate().unwrap_err(),
2212            ValidationError::Stage {
2213                stage: "(empty)".to_string(),
2214                message: "stage name cannot be empty".to_string(),
2215            }
2216        );
2217    }
2218
2219    #[test]
2220    fn test_file_tracking_config_defaults() {
2221        let json = r#"{"region": "files"}"#;
2222        let config: FileTrackingConfig = serde_json::from_str(json).unwrap();
2223        assert_eq!(config.region, "files");
2224        assert!(config.track_reads);
2225        assert!(config.track_writes);
2226        assert!(config.max_file_tokens.is_none());
2227    }
2228
2229    #[test]
2230    fn test_file_tracking_config_serde_roundtrip() {
2231        let config = FileTrackingConfig {
2232            region: "files".to_string(),
2233            track_reads: true,
2234            track_writes: false,
2235            max_file_tokens: Some(5000),
2236        };
2237        let json = serde_json::to_string(&config).unwrap();
2238        let back: FileTrackingConfig = serde_json::from_str(&json).unwrap();
2239        assert_eq!(back.region, "files");
2240        assert!(back.track_reads);
2241        assert!(!back.track_writes);
2242        assert_eq!(back.max_file_tokens, Some(5000));
2243    }
2244
2245    #[test]
2246    fn test_blueprint_file_tracking_default_none() {
2247        let stages = vec![Stage::new("plan".to_string(), make_model())];
2248        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2249        assert!(bp.file_tracking.is_none());
2250    }
2251
2252    #[test]
2253    fn test_blueprint_file_tracking_serde_roundtrip() {
2254        let stages = vec![Stage::new("plan".to_string(), make_model())];
2255        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2256        bp.file_tracking = Some(FileTrackingConfig {
2257            region: "files".to_string(),
2258            track_reads: true,
2259            track_writes: true,
2260            max_file_tokens: Some(3000),
2261        });
2262        let json = serde_json::to_string(&bp).unwrap();
2263        let back: Blueprint = serde_json::from_str(&json).unwrap();
2264        let ft = back.file_tracking.unwrap();
2265        assert_eq!(ft.region, "files");
2266        assert_eq!(ft.max_file_tokens, Some(3000));
2267    }
2268
2269    #[test]
2270    fn test_tool_result_routing_default() {
2271        let routing = ToolResultRouting::default();
2272        assert_eq!(routing.default_region, "tool_results");
2273        assert!(routing.persist);
2274        assert!(routing.tool_overrides.is_empty());
2275        assert!(routing.max_result_tokens.is_none());
2276    }
2277
2278    #[test]
2279    fn test_stage_new_has_no_tool_result_routing() {
2280        let stage = Stage::new("plan".to_string(), make_model());
2281        assert!(stage.tool_result_routing.is_none());
2282    }
2283
2284    #[test]
2285    fn test_tool_result_routing_serde_roundtrip() {
2286        let mut routing = ToolResultRouting {
2287            default_region: "custom_region".to_string(),
2288            persist: false,
2289            max_result_tokens: Some(4096),
2290            ..Default::default()
2291        };
2292        routing
2293            .tool_overrides
2294            .insert("read_file".to_string(), "file_reads".to_string());
2295
2296        let json = serde_json::to_string(&routing).unwrap();
2297        let back: ToolResultRouting = serde_json::from_str(&json).unwrap();
2298
2299        assert_eq!(back.default_region, "custom_region");
2300        assert!(!back.persist);
2301        assert_eq!(back.max_result_tokens, Some(4096));
2302        assert_eq!(
2303            back.tool_overrides.get("read_file").map(String::as_str),
2304            Some("file_reads")
2305        );
2306    }
2307
2308    #[test]
2309    fn test_stage_with_tool_result_routing_serde_roundtrip() {
2310        let stages = vec![{
2311            let mut s = Stage::new("plan".to_string(), make_model());
2312            s.tool_result_routing = Some(ToolResultRouting {
2313                default_region: "results".to_string(),
2314                tool_overrides: HashMap::new(),
2315                persist: true,
2316                max_result_tokens: Some(2048),
2317                tool_max_result_tokens: HashMap::new(),
2318            });
2319            s
2320        }];
2321        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2322        let json = serde_json::to_string(&bp).unwrap();
2323        let back: Blueprint = serde_json::from_str(&json).unwrap();
2324
2325        let routing = back.stages[0]
2326            .tool_result_routing
2327            .as_ref()
2328            .expect("tool_result_routing should be Some");
2329        assert_eq!(routing.default_region, "results");
2330        assert!(routing.persist);
2331        assert_eq!(routing.max_result_tokens, Some(2048));
2332        assert!(routing.tool_overrides.is_empty());
2333    }
2334
2335    // ─── fan_out (StageMode::FanOut) ─────────────────────────────────────────
2336
2337    fn fanout_config() -> FanOutConfig {
2338        FanOutConfig {
2339            worker_agent: None,
2340            worker_stage: Some("fix_worker".to_string()),
2341            worker_query: None,
2342            merge_stage: Some("merge".to_string()),
2343            max_workers: 3,
2344            on_worker_failure: WorkerFailurePolicy::Continue,
2345            split_prompt: "split".to_string(),
2346            results_region: None,
2347            max_items: None,
2348            max_attempts: None,
2349        }
2350    }
2351
2352    /// Blueprint: fan_out stage (worker_stage=fix_worker) → merge → terminal.
2353    /// The merge stage carries an (empty) transitions table so the blueprint is
2354    /// in graph mode - this makes `validate_graph` run `has_terminal_path`,
2355    /// which walks the fan-out stage's merge hand-off.
2356    fn fanout_blueprint(worker_allowed: bool, config: FanOutConfig) -> Blueprint {
2357        let mut fan = Stage::new("parallel".to_string(), make_model());
2358        fan.mode = StageMode::FanOut { config };
2359        let mut worker = Stage::new("fix_worker".to_string(), make_model());
2360        worker.allow_as_worker = worker_allowed;
2361        let mut merge = Stage::new("merge".to_string(), make_model());
2362        merge.transitions = Some(HashMap::new()); // terminal, graph mode
2363        Blueprint::new(
2364            "t".into(),
2365            "d".into(),
2366            vec![fan, worker, merge],
2367            make_layout(),
2368        )
2369    }
2370
2371    #[test]
2372    fn fanout_stagemode_partial_eq_and_default_policy() {
2373        let a = StageMode::FanOut {
2374            config: fanout_config(),
2375        };
2376        let b = StageMode::FanOut {
2377            config: fanout_config(),
2378        };
2379        assert_eq!(a, b);
2380        let mut other = fanout_config();
2381        other.max_workers = 99;
2382        assert_ne!(a, StageMode::FanOut { config: other });
2383        assert_ne!(a, StageMode::Autonomous);
2384        assert_eq!(
2385            WorkerFailurePolicy::default(),
2386            WorkerFailurePolicy::Continue
2387        );
2388    }
2389
2390    #[test]
2391    fn fanout_config_serde_roundtrip_and_max_workers_default() {
2392        let toml = r#"
2393worker_agent = "fixer"
2394split_prompt = "go"
2395on_worker_failure = "fail_all"
2396"#;
2397        let cfg: FanOutConfig = toml::from_str(toml).unwrap();
2398        assert_eq!(cfg.worker_agent.as_deref(), Some("fixer"));
2399        assert_eq!(cfg.max_workers, DEFAULT_MAX_WORKERS);
2400        assert_eq!(cfg.worker_cap(), Some(DEFAULT_MAX_WORKERS));
2401        assert_eq!(
2402            FanOutConfig {
2403                max_workers: 0,
2404                ..fanout_config()
2405            }
2406            .worker_cap(),
2407            None
2408        );
2409        assert_eq!(cfg.on_worker_failure, WorkerFailurePolicy::FailAll);
2410        // JSON round-trip preserves everything.
2411        let json = serde_json::to_string(&fanout_config()).unwrap();
2412        let back: FanOutConfig = serde_json::from_str(&json).unwrap();
2413        assert_eq!(back, fanout_config());
2414    }
2415
2416    #[test]
2417    fn fanout_validate_ok_with_allowed_worker_stage() {
2418        assert!(fanout_blueprint(true, fanout_config()).validate().is_ok());
2419    }
2420
2421    #[test]
2422    fn fanout_validate_rejects_worker_stage_not_opted_in() {
2423        let err = fanout_blueprint(false, fanout_config())
2424            .validate()
2425            .unwrap_err();
2426        assert!(err.to_string().contains("allow_as_worker"));
2427    }
2428
2429    #[test]
2430    fn fanout_validate_rejects_missing_worker_stage() {
2431        let mut cfg = fanout_config();
2432        cfg.worker_stage = Some("nope".to_string());
2433        let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2434        assert!(err.to_string().contains("does not exist"));
2435    }
2436
2437    #[test]
2438    fn fanout_validate_rejects_missing_merge_stage() {
2439        let mut cfg = fanout_config();
2440        cfg.merge_stage = Some("nomerge".to_string());
2441        let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2442        assert!(err.to_string().contains("merge_stage"));
2443    }
2444
2445    #[test]
2446    fn fanout_validate_rejects_wrong_worker_source_count() {
2447        // zero sources
2448        let mut cfg = fanout_config();
2449        cfg.worker_stage = None;
2450        assert!(fanout_blueprint(true, cfg).validate().is_err());
2451        // two sources
2452        let mut cfg2 = fanout_config();
2453        cfg2.worker_agent = Some("x".to_string()); // plus worker_stage
2454        assert!(fanout_blueprint(true, cfg2).validate().is_err());
2455    }
2456
2457    #[test]
2458    fn fanout_terminal_path_runs_through_merge_stage() {
2459        // worker_agent form (no local worker_stage), merge → terminal.
2460        let mut cfg = fanout_config();
2461        cfg.worker_stage = None;
2462        cfg.worker_agent = Some("external".to_string());
2463        assert!(fanout_blueprint(false, cfg).validate().is_ok());
2464    }
2465
2466    #[test]
2467    fn fanout_validate_ok_without_merge_stage() {
2468        // No merge stage: valid, and the fan-out stage falls through to the
2469        // linear next stage for its terminal path.
2470        let mut cfg = fanout_config();
2471        cfg.merge_stage = None;
2472        assert!(fanout_blueprint(true, cfg).validate().is_ok());
2473    }
2474}