Skip to main content

leviath_core/
blueprint.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;
10use crate::lifecycle::CompactionConfig;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14/// An agent blueprint - the complete definition of an agent type.
15///
16/// Includes stages, model selection, tools, AND context layout. A blueprint
17/// defines everything needed to instantiate and run an agent with specific
18/// capabilities and memory structure.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Blueprint {
21    /// Unique name for this agent type
22    pub name: String,
23
24    /// Human-readable description
25    pub description: String,
26
27    /// Execution stages (e.g., analyze → implement → review)
28    pub stages: Vec<Stage>,
29
30    /// Context window layout defining memory regions
31    pub context_layout: ContextLayout,
32
33    /// Context transforms for inter-agent communication
34    pub transforms: Vec<ContextTransform>,
35
36    /// Version of this blueprint
37    pub version: String,
38
39    /// Configuration for LLM-based compaction
40    pub compaction_config: Option<CompactionConfig>,
41
42    /// Maximum depth of the sub-agent tree (default: 3)
43    pub max_child_depth: Option<usize>,
44
45    /// Which stage to start from (default: first defined)
46    pub entry_stage: Option<String>,
47
48    /// Additional metadata
49    pub metadata: HashMap<String, serde_json::Value>,
50
51    /// Security configuration for taint tracking.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub security: Option<crate::taint::SecurityConfig>,
54
55    /// Agent-level override for the batch-tool-calls system-prompt hint. `None`
56    /// inherits the global config toggle; a per-stage `batch_tool_hint` overrides
57    /// this. See [`crate::taint::resolve_batch_tool_hint`] for the cascade.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub batch_tool_hint: Option<bool>,
60
61    /// Agent-level override for the platform shell hint. `None` inherits the
62    /// global config toggle; a per-stage `shell_hint` overrides this. See
63    /// [`crate::taint::resolve_shell_hint`] for the cascade.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub shell_hint: Option<bool>,
66
67    /// Agent-level default for the empty-response nudge. `None` inherits the
68    /// global config's `[nudge]` section; a per-stage `[stages.<name>.nudge]`
69    /// overrides this. See [`resolve_nudge`] for the cascade.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub nudge: Option<NudgeConfig>,
72
73    /// Repetition detection configuration.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub repetition_detection: Option<RepetitionDetectionConfig>,
76
77    /// File tracking configuration.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub file_tracking: Option<FileTrackingConfig>,
80
81    /// Agent-level sandbox configuration for tool execution. Per-stage
82    /// `[stages.<name>.sandbox]` overrides this; both cascade through
83    /// [`crate::resolve_sandbox`].
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub sandbox: Option<crate::sandbox::ToolSandboxConfig>,
86
87    /// Opt-in escape hatch: when `true`, the agent may add tools to
88    /// its own `tools/` directory mid-run and have them re-discovered and
89    /// re-advertised for its next turn. **Off by default** - tools are otherwise
90    /// discovered once at spawn and an agent cannot grow its own toolchain.
91    #[serde(default)]
92    pub dynamic_tools: bool,
93
94    /// Read paths this agent *declares* beyond its workdir - directories a
95    /// planner-style agent needs to see, like run archives or design docs.
96    /// Declaring is not granting: entries only take effect when the user's
97    /// config also grants them (`[security] read_paths`,
98    /// `[agent_read_paths.<name>]`, or `allow_blueprint_read_paths = true`),
99    /// so an installed manifest cannot widen its own sandbox. Read-only in
100    /// every case; `write_file` and `edit_file` stay confined to the workdir.
101    /// Semantics live in [`crate::read_paths`].
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub read_paths: Option<ReadPathsConfig>,
104}
105
106/// The `[read_paths]` section of a manifest: raw declared entries, compiled
107/// against the run's workdir and home at spawn.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct ReadPathsConfig {
110    /// Declared entries. Each may be:
111    /// - an exact path, granting its subtree: `"~/.leviath/runs"` or
112    ///   `"../shared-docs"` (relative to the run's workdir)
113    /// - a glob: `"glob:~/.leviath/runs/**"`
114    /// - a regex, auto-anchored: `"regex:/data/design-docs/.*"`
115    ///
116    /// Patterns are written with `/` separators on every OS and match the
117    /// symlink-resolved real path.
118    #[serde(default)]
119    pub allow: Vec<String>,
120}
121
122impl Blueprint {
123    /// Create a new blueprint with the specified configuration.
124    pub fn new(
125        name: String,
126        description: String,
127        stages: Vec<Stage>,
128        context_layout: ContextLayout,
129    ) -> Self {
130        Self {
131            name,
132            description,
133            stages,
134            context_layout,
135            transforms: Vec::new(),
136            version: "0.1.0".to_string(),
137            compaction_config: None,
138            max_child_depth: None,
139            entry_stage: None,
140            metadata: HashMap::new(),
141            security: None,
142            batch_tool_hint: None,
143            shell_hint: None,
144            nudge: None,
145            repetition_detection: None,
146            file_tracking: None,
147            sandbox: None,
148            dynamic_tools: false,
149            read_paths: None,
150        }
151    }
152
153    /// Agent-level tool permissions, keyed by tool name.
154    ///
155    /// The manifest parser records a top-level `[tool_permissions]` block as
156    /// `tool_perm:<tool>` → policy-string entries in [`Self::metadata`]. This
157    /// projects them back into a tool-keyed map for the runtime's agent-level
158    /// permission layer. Non-`tool_perm:` keys and non-string values are ignored.
159    pub fn agent_tool_permissions(&self) -> HashMap<String, String> {
160        self.metadata
161            .iter()
162            .filter_map(|(k, v)| {
163                Some((
164                    k.strip_prefix("tool_perm:")?.to_string(),
165                    v.as_str()?.to_string(),
166                ))
167            })
168            .collect()
169    }
170
171    /// Add context transforms to this blueprint.
172    pub fn with_transforms(mut self, transforms: Vec<ContextTransform>) -> Self {
173        self.transforms = transforms;
174        self
175    }
176
177    /// Set the version of this blueprint.
178    pub fn with_version(mut self, version: String) -> Self {
179        self.version = version;
180        self
181    }
182
183    /// Validate that the blueprint is well-formed.
184    pub fn validate(&self) -> std::result::Result<(), ValidationError> {
185        // Validate context layout
186        self.context_layout.validate()?;
187
188        // Check that all stages have valid configurations
189        for stage in &self.stages {
190            stage.validate()?;
191        }
192
193        // Validate transforms reference real regions
194        for transform in &self.transforms {
195            transform.validate(&self.context_layout)?;
196        }
197
198        // Graph validation
199        self.validate_graph()?;
200
201        Ok(())
202    }
203
204    /// Validate stage graph constraints.
205    fn validate_graph(&self) -> std::result::Result<(), ValidationError> {
206        let stage_names: std::collections::HashSet<&str> =
207            self.stages.iter().map(|s| s.name.as_str()).collect();
208
209        // Entry stage must exist if set
210        if let Some(entry) = &self.entry_stage
211            && !stage_names.contains(entry.as_str())
212        {
213            return Err(ValidationError::Graph(format!(
214                "entry_stage '{}' does not match any defined stage",
215                entry
216            )));
217        }
218
219        // Fan-out stages reference a worker source + optional merge stage. These
220        // are checked even for otherwise-linear blueprints (before the early
221        // return below), since `worker_stage`/`merge_stage` name local stages.
222        // `worker_agent`/`worker_query` are environment-dependent (resolved
223        // against installed agents at run time), so they are not checked here.
224        for stage in &self.stages {
225            if let StageMode::FanOut { config } = &stage.mode {
226                let sources = [
227                    config.worker_agent.is_some(),
228                    config.worker_stage.is_some(),
229                    config.worker_query.is_some(),
230                ]
231                .iter()
232                .filter(|&&set| set)
233                .count();
234                if sources != 1 {
235                    return Err(ValidationError::Stage {
236                        stage: stage.name.clone(),
237                        message: "fan_out stage must set exactly one of worker_agent, \
238                                  worker_stage, or worker_query"
239                            .to_string(),
240                    });
241                }
242                if let Some(ws) = &config.worker_stage {
243                    match self.stages.iter().find(|s| &s.name == ws) {
244                        None => {
245                            return Err(ValidationError::Stage {
246                                stage: stage.name.clone(),
247                                message: format!("fan_out worker_stage '{}' does not exist", ws),
248                            });
249                        }
250                        Some(target) if !target.allow_as_worker => {
251                            return Err(ValidationError::Stage {
252                                stage: stage.name.clone(),
253                                message: format!(
254                                    "fan_out worker_stage '{}' must set allow_as_worker = true",
255                                    ws
256                                ),
257                            });
258                        }
259                        Some(_) => {}
260                    }
261                }
262                if let Some(ms) = &config.merge_stage
263                    && !stage_names.contains(ms.as_str())
264                {
265                    return Err(ValidationError::Stage {
266                        stage: stage.name.clone(),
267                        message: format!("fan_out merge_stage '{}' does not exist", ms),
268                    });
269                }
270            }
271        }
272
273        let has_any_transitions = self.stages.iter().any(|s| s.transitions.is_some());
274        if !has_any_transitions {
275            // Pure linear mode - no graph validation needed
276            return Ok(());
277        }
278
279        // All transition targets must exist
280        for stage in &self.stages {
281            if let Some(ref transitions) = stage.transitions {
282                for (target_name, edge) in transitions {
283                    if !stage_names.contains(target_name.as_str()) {
284                        return Err(ValidationError::Transition {
285                            from: stage.name.clone(),
286                            to: target_name.clone(),
287                            message: "target stage does not exist".to_string(),
288                        });
289                    }
290                    // A `stuck` edge with no threshold could never fire. Caught
291                    // here as well as in the manifest parser, so blueprints built
292                    // programmatically (API / `lev validate`) are held to it too.
293                    if edge.condition == TransitionCondition::Stuck
294                        && !edge.stuck.is_some_and(|c| c.is_armed())
295                    {
296                        return Err(ValidationError::Transition {
297                            from: stage.name.clone(),
298                            to: target_name.clone(),
299                            message: "condition = \"stuck\" requires at least one \
300                                      stuck_after_* threshold (the edge could never fire)"
301                                .to_string(),
302                        });
303                    }
304                }
305
306                // A `require_modifications` gate on a stage that advertises no
307                // file-modifying tool can never be satisfied - it would just
308                // burn the stage's re-run budget every time.
309                for (target_name, edge) in transitions {
310                    let Some(gate) = &edge.gate else { continue };
311                    if !gate.require_modifications {
312                        continue;
313                    }
314                    let can_modify = stage.available_tools.iter().any(|t| {
315                        MODIFYING_TOOLS.contains(&t.as_str())
316                            || gate.tools.iter().any(|extra| extra == t)
317                    });
318                    if !can_modify {
319                        return Err(ValidationError::Transition {
320                            from: stage.name.clone(),
321                            to: target_name.clone(),
322                            message: "gate requires modifications, but the stage has no \
323                                      file-modifying tool in available_tools"
324                                .to_string(),
325                        });
326                    }
327                }
328
329                // Self-loop safety: stages that transition to themselves need max_revisits
330                if transitions.contains_key(&stage.name) && stage.max_revisits.is_none() {
331                    return Err(ValidationError::Stage {
332                        stage: stage.name.clone(),
333                        message: "self-loop transition requires max_revisits".to_string(),
334                    });
335                }
336            }
337        }
338
339        // At least one terminal path must exist (a stage with no outgoing transitions,
340        // or with only conditional transitions that may not fire)
341        let entry = self.resolve_entry_stage_name();
342        let has_terminal = self.has_terminal_path(&entry, &mut std::collections::HashSet::new());
343        if !has_terminal {
344            return Err(ValidationError::Graph(
345                "no terminal path exists from entry stage - agent would never complete".to_string(),
346            ));
347        }
348
349        Ok(())
350    }
351
352    /// Resolve the entry stage name.
353    pub fn resolve_entry_stage_name(&self) -> String {
354        self.entry_stage.clone().unwrap_or_else(|| {
355            self.stages
356                .first()
357                .map(|s| s.name.clone())
358                .unwrap_or_default()
359        })
360    }
361
362    /// Check if there is a terminal path reachable from `stage_name`.
363    fn has_terminal_path(
364        &self,
365        stage_name: &str,
366        visited: &mut std::collections::HashSet<String>,
367    ) -> bool {
368        if visited.contains(stage_name) {
369            return false;
370        }
371        visited.insert(stage_name.to_string());
372
373        let stage = self.stages.iter().find(|s| s.name == stage_name);
374        let stage = match stage {
375            Some(s) => s,
376            // Unreachable via this function's only call site (`validate_graph`,
377            // below): it rejects any transition target that doesn't match a
378            // real stage name *before* ever calling `has_terminal_path`, and
379            // `has_terminal_path` is private, so no other caller can pass in
380            // an unvalidated stage name.
381            None => return false,
382        };
383
384        // A fan-out stage with a merge stage hands off to it after workers
385        // complete, so its terminal path runs through the merge stage.
386        if let StageMode::FanOut {
387            config:
388                FanOutConfig {
389                    merge_stage: Some(ms),
390                    ..
391                },
392        } = &stage.mode
393        {
394            return self.has_terminal_path(ms, visited);
395        }
396
397        match &stage.transitions {
398            None => {
399                // Linear mode: check if there's a next stage by index
400                let idx = self
401                    .stages
402                    .iter()
403                    .position(|s| s.name == stage_name)
404                    .unwrap_or(0);
405                if idx + 1 >= self.stages.len() {
406                    return true; // terminal
407                }
408                self.has_terminal_path(&self.stages[idx + 1].name, visited)
409            }
410            Some(transitions) => {
411                if transitions.is_empty() {
412                    return true; // terminal stage
413                }
414                // Check if any transition leads to a terminal
415                for target in transitions.keys() {
416                    if self.has_terminal_path(target, visited) {
417                        return true;
418                    }
419                }
420                // If all targets are exhaustible (already visited + have max_revisits),
421                // the stage will eventually have zero available edges → terminal
422                transitions.keys().all(|target| {
423                    self.stages
424                        .iter()
425                        .find(|s| s.name == *target)
426                        .map(|s| s.max_revisits.is_some())
427                        .unwrap_or(false)
428                })
429            }
430        }
431    }
432
433    /// Find a stage by name.
434    pub fn find_stage(&self, name: &str) -> Option<&Stage> {
435        self.stages.iter().find(|s| s.name == name)
436    }
437}
438
439/// Configuration for automatic file tracking in context regions.
440///
441/// When configured, read_file/write_file results are automatically synced to a
442/// HashMap region, and tool results reference the system prompt instead of
443/// duplicating content.
444#[derive(Debug, Clone, Serialize, Deserialize)]
445pub struct FileTrackingConfig {
446    /// Name of the HashMap region to sync files to
447    pub region: String,
448    /// Auto-update on read_file
449    #[serde(default = "default_true_val")]
450    pub track_reads: bool,
451    /// Auto-update on write_file. (`edit_file` is not tracked: its arguments are
452    /// `old_str`/`new_str`, so the post-edit file body isn't available without a
453    /// re-read.)
454    #[serde(default = "default_true_val")]
455    pub track_writes: bool,
456    /// Truncate files larger than this token count in context
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub max_file_tokens: Option<usize>,
459}
460
461fn default_true_val() -> bool {
462    true
463}
464
465/// Configuration for repetition detection in the inference loop.
466///
467/// Controls thresholds for detecting degenerate read loops where agents
468/// call the same tool repeatedly without productive action.
469#[derive(Debug, Clone, Serialize, Deserialize)]
470pub struct RepetitionDetectionConfig {
471    /// Maximum times the same tool+args combo may repeat before a nudge.
472    pub max_repeat_calls: Option<usize>,
473    /// Maximum consecutive read-only calls with no productive calls in between.
474    pub max_readonly_streak: Option<usize>,
475    /// Whether detection is enabled. Default: true.
476    pub enabled: Option<bool>,
477}
478
479/// Configuration for routing tool results to specific context window regions.
480#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct ToolResultRouting {
482    /// Default region for tool results (default: "tool_results")
483    pub default_region: String,
484    /// Per-tool overrides: tool_name → region_name
485    pub tool_overrides: HashMap<String, String>,
486    /// Whether to keep tool results (true) or discard after use (false)
487    pub persist: bool,
488    /// Max tokens per tool result (truncate if larger)
489    pub max_result_tokens: Option<usize>,
490}
491
492impl Default for ToolResultRouting {
493    fn default() -> Self {
494        Self {
495            default_region: "tool_results".to_string(),
496            tool_overrides: HashMap::new(),
497            persist: true,
498            max_result_tokens: None,
499        }
500    }
501}
502
503/// Interaction mode for a stage.
504#[derive(Debug, Clone, Default, Serialize, Deserialize)]
505pub enum StageMode {
506    /// Runs without user input, fully autonomous
507    #[default]
508    Autonomous,
509
510    /// Requires user input before starting
511    Interactive,
512
513    /// Can receive input at defined points during execution
514    InteractivePoints {
515        /// Points where user input can be requested
516        points: Vec<InteractionPoint>,
517    },
518
519    /// Splits work into JSON items and runs them across parallel in-process
520    /// sub-agent workers, then optionally merges before transitioning.
521    FanOut {
522        /// Fan-out configuration (worker source, concurrency, failure policy).
523        config: FanOutConfig,
524    },
525}
526
527impl PartialEq for StageMode {
528    #[inline(never)]
529    fn eq(&self, other: &Self) -> bool {
530        match (self, other) {
531            (Self::Autonomous, Self::Autonomous) | (Self::Interactive, Self::Interactive) => true,
532            (Self::InteractivePoints { points: a }, Self::InteractivePoints { points: b }) => {
533                a == b
534            }
535            (Self::FanOut { config: a }, Self::FanOut { config: b }) => a == b,
536            _ => false,
537        }
538    }
539}
540impl Eq for StageMode {}
541
542/// How a fan-out stage handles worker failures.
543#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
544#[serde(rename_all = "snake_case")]
545pub enum WorkerFailurePolicy {
546    /// Run the merge/next stage with the successful workers; failures are
547    /// reported into the consolidated results.
548    #[default]
549    Continue,
550    /// Any worker failure routes the fan-out stage down its `error` edge.
551    FailAll,
552}
553
554/// Configuration for a [`StageMode::FanOut`] stage.
555///
556/// Exactly one of `worker_agent` / `worker_stage` / `worker_query` selects the
557/// worker's agent type (validated when the blueprint's graph is checked).
558#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
559pub struct FanOutConfig {
560    /// A separate registered/installed blueprint run as the worker agent type.
561    #[serde(default, skip_serializing_if = "Option::is_none")]
562    pub worker_agent: Option<String>,
563    /// A stage in *this* blueprint (self-as-agent-type); must be marked
564    /// `allow_as_worker = true`.
565    #[serde(default, skip_serializing_if = "Option::is_none")]
566    pub worker_stage: Option<String>,
567    /// Discovery hint matched against installed agent types.
568    #[serde(default, skip_serializing_if = "Option::is_none")]
569    pub worker_query: Option<String>,
570    /// Optional stage that reconciles worker results before transitioning.
571    #[serde(default, skip_serializing_if = "Option::is_none")]
572    pub merge_stage: Option<String>,
573    /// Maximum number of workers running concurrently.
574    #[serde(default = "default_max_workers")]
575    pub max_workers: usize,
576    /// How to handle worker failures.
577    #[serde(default)]
578    pub on_worker_failure: WorkerFailurePolicy,
579    /// Prompt that produces the JSON array of work items (one per worker).
580    #[serde(default)]
581    pub split_prompt: String,
582}
583
584/// Default `max_workers` when unspecified.
585fn default_max_workers() -> usize {
586    4
587}
588
589/// Style of interaction at an interaction point.
590#[derive(Debug, Clone, Default, Serialize, Deserialize)]
591#[serde(rename_all = "snake_case")]
592pub enum InteractionStyle {
593    /// Free-form text answer (default).
594    #[default]
595    FreeText,
596    /// User picks one option from a list.
597    MultipleChoice,
598    /// Simple yes/no confirmation.
599    Confirm,
600}
601
602impl PartialEq for InteractionStyle {
603    #[inline(never)]
604    fn eq(&self, other: &Self) -> bool {
605        std::mem::discriminant(self) == std::mem::discriminant(other)
606    }
607}
608impl Eq for InteractionStyle {}
609
610/// What an interaction point does when the run is unattended (`--yolo`).
611#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
612#[serde(rename_all = "snake_case")]
613pub enum UnattendedPolicy {
614    /// Resolve the point as approved without opening a prompt. The default:
615    /// `--yolo` means nobody is watching, and a checkpoint nobody can answer
616    /// would park the run for as long as the daemon lives.
617    #[default]
618    AutoApprove,
619
620    /// Open the prompt anyway and wait for a person, even under `--yolo`. For a
621    /// checkpoint whose whole purpose is a human decision - a plan the user
622    /// signs off before any code is written. Pair it with `[limits]
623    /// interaction_timeout_secs` so an unanswered gate still releases.
624    Ask,
625}
626
627/// A point where a stage can request user input.
628#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
629pub struct InteractionPoint {
630    /// Unique name for this interaction point
631    pub name: String,
632
633    /// Prompt to show the user
634    pub prompt: String,
635
636    /// Whether input is required (vs optional). A presentation hint carried on
637    /// the prompt - not to be confused with [`InteractionPoint::unattended`],
638    /// which decides whether the prompt is raised at all in a `--yolo` run.
639    pub required: bool,
640
641    /// What this point does when nobody is watching. Defaults to
642    /// [`UnattendedPolicy::AutoApprove`]; set `unattended = "ask"` to hold the
643    /// run for a person even under `--yolo`.
644    #[serde(default)]
645    pub unattended: UnattendedPolicy,
646
647    /// Style of interaction (free text, multiple choice, confirm)
648    #[serde(default)]
649    pub style: InteractionStyle,
650
651    /// Options for MultipleChoice style
652    #[serde(default)]
653    pub options: Vec<String>,
654
655    /// Directives keyed by option label.
656    ///
657    /// When the user picks an option present in this map (e.g. "Revise - I'll
658    /// describe changes"), the mapped directive text is injected into the
659    /// agent's conversation context and the stage re-runs inference IN-STAGE
660    /// (bounded by a revision cap) instead of falling through to a stage
661    /// transition. The directive tells the agent what to do next - e.g. call
662    /// `ask_user_text` to learn what to change, or `edit_document` to let the
663    /// user edit the plan directly - so the routing decision is deterministic
664    /// (code) while the actual input capture is an agent tool call.
665    #[serde(default, alias = "followups")]
666    pub directives: HashMap<String, String>,
667
668    /// Options that, when selected, immediately abort the run: the engine
669    /// marks the run cancelled and stops with no further inference and no
670    /// transition resolution. Matched against the selected option label with
671    /// the same dash/whitespace normalization used for directive lookup.
672    #[serde(default)]
673    pub abort_options: Vec<String>,
674
675    /// Options that, when selected, open the stage's most recent output (e.g.
676    /// the plan) in an editable field so the user can modify it directly. The
677    /// engine issues the edit interaction itself and injects the edited text
678    /// back into context - deterministic, with no dependence on the model
679    /// choosing to call an edit tool. Matched with the same normalization.
680    #[serde(default)]
681    pub edit_options: Vec<String>,
682
683    /// Optional pinned region to hold this point's authoritative document (e.g.
684    /// `"plan"`). When set, each time the point is presented the current
685    /// document - the produced text, or the user's direct edit - *replaces* that
686    /// region's content, so later revisions and downstream stages build on the
687    /// current version rather than regenerating from the task. `None` ⇒ the
688    /// document lives only in the rolling conversation / output.
689    #[serde(default)]
690    pub document_region: Option<String>,
691}
692
693/// A single execution stage in an agent's workflow.
694///
695/// Stages allow an agent to use different models or configurations for
696/// different phases of work. For example, a coding agent might have:
697/// - Analyze stage: fast model for understanding requirements
698/// - Implement stage: powerful model for code generation
699/// - Review stage: critique model for checking quality
700///
701/// Each stage can have its own context layout (memory structure), allowing
702/// different stages to have different region configurations optimized for
703/// their specific needs.
704#[derive(Debug, Clone, Serialize, Deserialize)]
705pub struct Stage {
706    /// Name of this stage
707    pub name: String,
708
709    /// Description of what this stage does
710    pub description: Option<String>,
711
712    /// Model to use for this stage
713    pub model: ModelConfig,
714
715    /// Which tools are available in this stage
716    pub available_tools: Vec<String>,
717
718    /// Human-in-the-loop tools (`ask_user_*`, `present_for_review`,
719    /// `edit_document`) that survive an unattended run.
720    ///
721    /// An unattended run - `lev run --yolo`, or a child of one - drops every
722    /// blocking human tool from the stage's advertised set, because a call to
723    /// one can only park the agent until the daemon dies (issue #204). Naming a
724    /// tool here says this stage genuinely needs a person and the run should
725    /// wait for one anyway. Pair it with `[limits] interaction_timeout_secs` so
726    /// an unanswered prompt still releases eventually.
727    ///
728    /// Entries must also appear in `available_tools` - listing a tool the stage
729    /// can't call in the first place is a validation error, not a silent no-op.
730    /// Matched verbatim against `available_tools` (this crate has no alias
731    /// table), so write the name the same way in both.
732    #[serde(default)]
733    pub required_tools: Vec<String>,
734
735    /// Maximum iterations for this stage
736    pub max_iterations: Option<usize>,
737
738    /// Interaction mode (autonomous or interactive)
739    #[serde(default)]
740    pub mode: StageMode,
741
742    /// Optional stage-specific context layout
743    /// If None, uses the blueprint's global layout
744    pub context_layout: Option<ContextLayout>,
745
746    /// Custom configuration for this stage
747    pub config: HashMap<String, serde_json::Value>,
748
749    /// Per-tool permission overrides for this stage.
750    /// Keys: tool name. Values: "allow" | "ask" | "deny".
751    /// Narrower than agent-level, wider than launch flags.
752    #[serde(default)]
753    pub tool_permissions: HashMap<String, String>,
754
755    /// If true, don't advance to the next stage until all children spawned
756    /// during this stage have completed.
757    #[serde(default)]
758    pub requires_children: bool,
759
760    /// Directed transitions from this stage (None = linear/next-in-list)
761    pub transitions: Option<HashMap<String, TransitionEdge>>,
762
763    /// Max times this stage can be re-entered (revisits, not counting first visit)
764    pub max_revisits: Option<usize>,
765
766    /// Custom prompt for transition decisions (overrides default)
767    pub transition_prompt: Option<String>,
768
769    /// Whether this stage accepts mid-run user messages.
770    /// When true, messages sent to the agent are injected into context
771    /// between inference calls. Default: true.
772    #[serde(default = "default_true")]
773    pub accepts_messages: bool,
774
775    /// Whether the LLM may end the run at this stage instead of naming a
776    /// transition target - e.g. a review stage that approves the work
777    /// needs no further stage. When true, `prompt_llm_transition`'s query
778    /// offers an explicit "DONE" response that resolves to a terminal
779    /// (no-transition) outcome instead of forcing the single/first
780    /// available edge.
781    #[serde(default)]
782    pub allow_complete: bool,
783
784    /// Whether this stage may be used as a fan-out `worker_stage` - i.e. run as
785    /// an in-process sub-agent worker entered at this stage. Off by default so a
786    /// blueprint author must explicitly opt a stage in to being fanned into
787    /// (you can only fan out into a stage designed for it).
788    #[serde(default)]
789    pub allow_as_worker: bool,
790
791    /// Whether this stage means to offer human-in-the-loop tools (`ask_user_*`,
792    /// `present_for_review`, `edit_document`) while running autonomously.
793    ///
794    /// Grants nothing and changes no runtime behavior: it only records the
795    /// author's intent, so `lev validate` stops flagging a deliberate choice.
796    /// An autonomous stage that calls one of those tools with nobody attached
797    /// parks in `WaitingInput` until someone kills the run, which is almost
798    /// always a mistake and occasionally exactly what was wanted (an agent
799    /// driven from the dashboard, say). Off by default so the flag has to be
800    /// written down.
801    #[serde(default)]
802    pub allow_blocking_tools: bool,
803
804    /// Per-stage taint/security override. `None` inherits the agent-level
805    /// `Blueprint.security` (which in turn inherits the global config toggle).
806    /// Set `taint_tracking = false` here to opt a single stage out, or `true`
807    /// to opt it in independently of the agent/global setting.
808    #[serde(default)]
809    pub security: Option<crate::taint::SecurityConfig>,
810
811    /// Per-stage override for the batch-tool-calls system-prompt hint. `None`
812    /// inherits the agent-level `Blueprint.batch_tool_hint` (which in turn
813    /// inherits the global config toggle). Set `false` to opt a sequential stage
814    /// out, or `true` to opt it in independently of the agent/global setting.
815    #[serde(default)]
816    pub batch_tool_hint: Option<bool>,
817
818    /// Per-stage override for the platform shell hint. `None` inherits the
819    /// agent-level `Blueprint.shell_hint` (which in turn inherits the global
820    /// config toggle). A stage that grants no shell tool never emits the hint
821    /// regardless, so this is for opting a shell-granting stage out.
822    #[serde(default)]
823    pub shell_hint: Option<bool>,
824
825    /// Per-stage empty-response nudge settings. Each field independently
826    /// inherits the agent-level `Blueprint.nudge` (which in turn inherits the
827    /// global config's `[nudge]` section). A stage whose deliverable is text -
828    /// a planner, a briefing writer - sets `enabled = false` here so it is
829    /// never told to "use your tools". See [`resolve_nudge`].
830    #[serde(default)]
831    pub nudge: Option<NudgeConfig>,
832
833    /// Per-stage sandbox override. `None` inherits the agent-level
834    /// `Blueprint.sandbox` (which in turn inherits the global default = host).
835    /// Set a tighter sandbox here to isolate a single stage - e.g. run analysis
836    /// on the host but implementation in a networkless container.
837    #[serde(default)]
838    pub sandbox: Option<crate::sandbox::ToolSandboxConfig>,
839
840    /// Optional routing configuration for tool results.
841    /// When set, tool results are routed to the configured region(s) instead
842    /// of the default "conversation" region.
843    #[serde(default)]
844    pub tool_result_routing: Option<ToolResultRouting>,
845}
846
847/// Default value for bool fields that should default to true.
848fn default_true() -> bool {
849    true
850}
851
852impl Stage {
853    /// Create a new stage with the specified configuration.
854    pub fn new(name: String, model: ModelConfig) -> Self {
855        Self {
856            name,
857            description: None,
858            model,
859            available_tools: Vec::new(),
860            required_tools: Vec::new(),
861            max_iterations: None,
862            mode: StageMode::Autonomous,
863            context_layout: None,
864            config: HashMap::new(),
865            tool_permissions: HashMap::new(),
866            requires_children: false,
867            transitions: None,
868            max_revisits: None,
869            transition_prompt: None,
870            accepts_messages: true,
871            allow_complete: false,
872            allow_as_worker: false,
873            allow_blocking_tools: false,
874            security: None,
875            batch_tool_hint: None,
876            shell_hint: None,
877            nudge: None,
878            sandbox: None,
879            tool_result_routing: None,
880        }
881    }
882
883    /// Add tools to this stage.
884    pub fn with_tools(mut self, tools: Vec<String>) -> Self {
885        self.available_tools = tools;
886        self
887    }
888
889    /// Set the interaction mode for this stage.
890    pub fn with_mode(mut self, mode: StageMode) -> Self {
891        self.mode = mode;
892        self
893    }
894
895    /// Set a stage-specific context layout.
896    pub fn with_context_layout(mut self, layout: ContextLayout) -> Self {
897        self.context_layout = Some(layout);
898        self
899    }
900
901    /// Set the description for this stage.
902    pub fn with_description(mut self, description: String) -> Self {
903        self.description = Some(description);
904        self
905    }
906
907    /// Validate that this stage is well-formed.
908    fn validate(&self) -> std::result::Result<(), ValidationError> {
909        if self.name.is_empty() {
910            return Err(ValidationError::Stage {
911                stage: "(empty)".to_string(),
912                message: "stage name cannot be empty".to_string(),
913            });
914        }
915
916        // A `required_tools` entry the stage can't call is dead text: it looks
917        // like it keeps a tool through an unattended run, and keeps nothing.
918        // Rejected rather than ignored so the typo surfaces at `lev validate`
919        // instead of at 3am in a `--yolo` run.
920        for tool in &self.required_tools {
921            if !self.available_tools.contains(tool) {
922                return Err(ValidationError::Stage {
923                    stage: self.name.clone(),
924                    message: format!(
925                        "required_tools entry '{}' is not in available_tools - a tool the \
926                         stage cannot call can't be kept through an unattended run",
927                        tool
928                    ),
929                });
930            }
931        }
932
933        // Validate stage-specific context layout if present
934        if let Some(layout) = &self.context_layout {
935            layout.validate()?;
936        }
937
938        Ok(())
939    }
940}
941
942/// A single model entry within a [`ModelConfig`] models list.
943#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
944pub struct ModelEntry {
945    /// Provider name (e.g., "anthropic", "openai")
946    pub provider: String,
947
948    /// Model identifier (e.g., "claude-sonnet-4-6")
949    pub model: String,
950}
951
952impl ModelEntry {
953    pub fn new(provider: String, model: String) -> Self {
954        Self { provider, model }
955    }
956}
957
958/// Model configuration for a stage.
959///
960/// Models are specified as an ordered priority list in `models`. The first
961/// entry whose provider is registered at runtime is used. When
962/// `allow_user_default` is true (the default), the user's configured default
963/// model is tried as a last resort. When false, the stage fails if none of
964/// the listed models are available.
965#[derive(Debug, Clone, Serialize, Deserialize)]
966pub struct ModelConfig {
967    /// Ordered list of models to try (first available wins).
968    #[serde(default)]
969    pub models: Vec<ModelEntry>,
970
971    /// When true (default), fall back to the user's configured default model
972    /// if none of the listed models are available.
973    #[serde(default = "default_allow_user_default")]
974    pub allow_user_default: bool,
975
976    /// Optional parameters that apply to whichever model gets selected.
977    #[serde(default)]
978    pub parameters: HashMap<String, serde_json::Value>,
979
980    /// Optional per-stage cap on the wall-clock time (in seconds) one inference
981    /// for this stage may run - the whole call including retries. When set, it
982    /// overrides the default job timeout; when `None`, the default applies.
983    ///
984    /// This lets a stage with slow first-token latency (e.g. a large-prompt
985    /// analyze call) get a long cap while a quick iterative stage fails fast on
986    /// a stalled connection instead of hanging for the full default.
987    #[serde(default)]
988    pub request_timeout_secs: Option<u64>,
989}
990
991fn default_allow_user_default() -> bool {
992    true
993}
994
995impl ModelConfig {
996    /// Create a new model configuration with a single model entry.
997    pub fn new(provider: String, model: String) -> Self {
998        Self {
999            models: vec![ModelEntry::new(provider, model)],
1000            allow_user_default: true,
1001            parameters: HashMap::new(),
1002            request_timeout_secs: None,
1003        }
1004    }
1005
1006    /// Convenience: provider of the first model entry (for backward compat).
1007    pub fn provider(&self) -> &str {
1008        self.models
1009            .first()
1010            .map(|e| e.provider.as_str())
1011            .unwrap_or("anthropic")
1012    }
1013
1014    /// Convenience: model name of the first model entry (for backward compat).
1015    pub fn model(&self) -> &str {
1016        self.models
1017            .first()
1018            .map(|e| e.model.as_str())
1019            .unwrap_or("claude-sonnet-4-6")
1020    }
1021}
1022
1023/// Context transform for converting between agent types.
1024///
1025/// When spawning a sub-agent with a different blueprint, transforms define
1026/// how to map regions from the parent agent's context to the child agent's
1027/// context. This enables smooth handoffs between agents with different
1028/// memory structures.
1029#[derive(Debug, Clone, Serialize, Deserialize)]
1030pub struct ContextTransform {
1031    /// Source blueprint name
1032    pub from_blueprint: String,
1033
1034    /// Target blueprint name
1035    pub to_blueprint: String,
1036
1037    /// Region mapping rules
1038    pub mappings: Vec<RegionMapping>,
1039}
1040
1041impl ContextTransform {
1042    /// Validate that this transform references valid regions.
1043    fn validate(&self, layout: &ContextLayout) -> std::result::Result<(), ValidationError> {
1044        for mapping in &self.mappings {
1045            // We can only validate target regions against the current layout
1046            // (source regions belong to a different blueprint)
1047            if layout.get_region(&mapping.to_region).is_none() {
1048                return Err(ValidationError::Region {
1049                    region: mapping.to_region.clone(),
1050                    message: "transform target region not found in layout".to_string(),
1051                });
1052            }
1053        }
1054        Ok(())
1055    }
1056}
1057
1058/// Mapping rule for a single region in a context transform.
1059#[derive(Debug, Clone, Serialize, Deserialize)]
1060pub struct RegionMapping {
1061    /// Source region name
1062    pub from_region: String,
1063
1064    /// Target region name
1065    pub to_region: String,
1066
1067    /// Optional transformation to apply to content
1068    pub transform: Option<ContentTransform>,
1069}
1070
1071/// A directed transition edge from one stage to another.
1072#[derive(Debug, Clone, Serialize, Deserialize)]
1073pub struct TransitionEdge {
1074    /// Target stage name (derived from the HashMap key during parsing)
1075    pub target: String,
1076
1077    /// When this edge is available
1078    #[serde(default)]
1079    pub condition: TransitionCondition,
1080
1081    /// Human-readable hint for the LLM
1082    pub hint: Option<String>,
1083
1084    /// How context transforms when crossing this edge
1085    #[serde(default)]
1086    pub transform: EdgeTransform,
1087
1088    /// Preconditions the agent must satisfy before this edge may be followed.
1089    /// Absent ⇒ the edge is unconditional (beyond its `condition`).
1090    #[serde(default)]
1091    pub gate: Option<TransitionGate>,
1092
1093    /// Thresholds arming a [`TransitionCondition::Stuck`] edge. `Some` iff the
1094    /// condition is `Stuck` - both the manifest parser and [`Blueprint::validate`]
1095    /// reject the two half-configured shapes.
1096    #[serde(default, skip_serializing_if = "Option::is_none")]
1097    pub stuck: Option<StuckConfig>,
1098}
1099
1100/// Thresholds that arm a [`TransitionCondition::Stuck`] edge.
1101///
1102/// At least one threshold is always set: an edge with none could never fire, so
1103/// both the manifest parser and [`Blueprint::validate`] reject that shape rather
1104/// than build a dead edge. Every threshold is evaluated against the *current
1105/// stage's* progress counters, which reset on each stage entry - so a blueprint
1106/// can arm different stages with different thresholds independently.
1107#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1108pub struct StuckConfig {
1109    /// `stuck_after_iterations`: inferences run in this stage without finishing it.
1110    #[serde(default, skip_serializing_if = "Option::is_none")]
1111    pub after_iterations: Option<usize>,
1112
1113    /// `stuck_after_minutes`: wall-clock minutes spent in this stage.
1114    #[serde(default, skip_serializing_if = "Option::is_none")]
1115    pub after_minutes: Option<usize>,
1116
1117    /// `stuck_after_same_file_edits`: `write_file`/`edit_file` calls against a
1118    /// single path in this stage - the "100 iterations in the wrong file" mode.
1119    #[serde(default, skip_serializing_if = "Option::is_none")]
1120    pub after_same_file_edits: Option<usize>,
1121
1122    /// `stuck_after_tool_calls`: total tool calls made in this stage.
1123    #[serde(default, skip_serializing_if = "Option::is_none")]
1124    pub after_tool_calls: Option<usize>,
1125}
1126
1127impl StuckConfig {
1128    /// Whether any threshold is set. `false` ⇒ the edge could never fire.
1129    pub fn is_armed(&self) -> bool {
1130        self.after_iterations.is_some()
1131            || self.after_minutes.is_some()
1132            || self.after_same_file_edits.is_some()
1133            || self.after_tool_calls.is_some()
1134    }
1135}
1136
1137/// Preconditions an edge imposes on the stage it leaves, checked once the edge
1138/// has been chosen but before its transform runs. A gate that isn't satisfied
1139/// re-runs the stage with a `[System]` nudge instead of transitioning.
1140///
1141/// The motivating case: an agent that reads and reasons about the
1142/// codebase entirely through `shell` and reaches the review stage without ever
1143/// having called a file-writing tool, producing a run with no output at all.
1144#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1145pub struct TransitionGate {
1146    /// Require at least one successful file-modifying tool call in the stage
1147    /// being left.
1148    #[serde(default)]
1149    pub require_modifications: bool,
1150
1151    /// Nudge injected when the gate blocks. A default explaining the framework's
1152    /// change tracking is generated when absent.
1153    #[serde(default)]
1154    pub message: Option<String>,
1155
1156    /// Region whose non-emptiness also satisfies the gate. Per-stage tool-call
1157    /// counters reset on stage entry and are not restored when a run resumes
1158    /// after a daemon restart, but context regions are - so pointing the gate at
1159    /// the region the write tools are routed into keeps a resumed run honest.
1160    #[serde(default)]
1161    pub region: Option<String>,
1162
1163    /// Tool names counted as modifying beyond the built-in `write_file` /
1164    /// `edit_file` - for agents whose writes go through MCP or script tools.
1165    #[serde(default)]
1166    pub tools: Vec<String>,
1167
1168    /// How many times the stage is re-run before the gate gives up and lets the
1169    /// transition through (with a warning). Defaults to
1170    /// [`DEFAULT_GATE_ATTEMPTS`].
1171    #[serde(default)]
1172    pub max_attempts: Option<usize>,
1173}
1174
1175/// Default re-run budget for an unsatisfied [`TransitionGate`].
1176pub const DEFAULT_GATE_ATTEMPTS: usize = 3;
1177
1178/// Built-in tools that modify files on disk, for [`TransitionGate`]'s
1179/// `require_modifications` accounting. Extended per-edge by
1180/// [`TransitionGate::tools`].
1181pub const MODIFYING_TOOLS: &[&str] = &["write_file", "edit_file"];
1182
1183/// Settings for the empty-response nudge: the `[System]` message injected when
1184/// a stage's model replies with text before making any tool call.
1185///
1186/// Every field is optional. A field left unset cascades stage → agent → global
1187/// config and finally to the built-in default, so a `[stages.<name>.nudge]`
1188/// block only has to name what it wants to change. An empty block is inert.
1189#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1190pub struct NudgeConfig {
1191    /// Whether the nudge fires at all. When unset at every level, the default
1192    /// is on - except for a stage with interaction points, whose text response
1193    /// is its work product and which is left alone. Setting this explicitly at
1194    /// any level overrides that implicit rule in both directions.
1195    #[serde(default)]
1196    pub enabled: Option<bool>,
1197
1198    /// How many text-only responses to nudge before accepting the text as
1199    /// final. Defaults to [`DEFAULT_MAX_NUDGES`].
1200    #[serde(default)]
1201    pub max: Option<usize>,
1202
1203    /// The nudge text. Defaults to [`DEFAULT_NUDGE_TEXT`]. Supports `{stage}`
1204    /// (the stage's name) and `{regions}` (comma-separated names of the
1205    /// stage's required context regions) placeholders.
1206    #[serde(default)]
1207    pub text: Option<String>,
1208}
1209
1210/// Default nudge injected when a model responds with text before making any
1211/// tool call, used when no [`NudgeConfig`] level sets `text`.
1212pub const DEFAULT_NUDGE_TEXT: &str = "You have tools available. Please use them to complete the task. Start by reading the relevant files in the working directory.";
1213
1214/// Default number of text-only responses to nudge before accepting the text as
1215/// final, used when no [`NudgeConfig`] level sets `max`.
1216pub const DEFAULT_MAX_NUDGES: usize = 3;
1217
1218/// A fully-resolved nudge policy for one stage: every [`NudgeConfig`] field
1219/// cascaded and defaulted. Produced by [`resolve_nudge`].
1220#[derive(Debug, Clone, PartialEq, Eq)]
1221pub struct ResolvedNudge {
1222    /// Whether the nudge fires for this stage.
1223    pub enabled: bool,
1224    /// Text-only responses tolerated before the text is accepted as final.
1225    pub max: usize,
1226    /// The nudge text, before placeholder interpolation.
1227    pub text: String,
1228}
1229
1230/// Resolve the nudge policy for a stage, cascading each field independently
1231/// stage → agent → global. Narrowest level wins with no clamping - like
1232/// [`crate::taint::resolve_batch_tool_hint`], this is a UX knob, not a
1233/// permission, so a manifest may raise `max` above the global setting.
1234///
1235/// `stage_is_reviewed` feeds only the *default* for `enabled`: a stage with
1236/// interaction points presents its text for the user to approve, so nudging it
1237/// to "use your tools" is off unless some level explicitly turns it on.
1238pub fn resolve_nudge(
1239    global: Option<&NudgeConfig>,
1240    agent: Option<&NudgeConfig>,
1241    stage: Option<&NudgeConfig>,
1242    stage_is_reviewed: bool,
1243) -> ResolvedNudge {
1244    fn field<T: Clone>(
1245        global: Option<&NudgeConfig>,
1246        agent: Option<&NudgeConfig>,
1247        stage: Option<&NudgeConfig>,
1248        get: impl Fn(&NudgeConfig) -> Option<T>,
1249    ) -> Option<T> {
1250        stage
1251            .and_then(&get)
1252            .or_else(|| agent.and_then(&get))
1253            .or_else(|| global.and_then(&get))
1254    }
1255    ResolvedNudge {
1256        enabled: field(global, agent, stage, |c| c.enabled).unwrap_or(!stage_is_reviewed),
1257        max: field(global, agent, stage, |c| c.max).unwrap_or(DEFAULT_MAX_NUDGES),
1258        text: field(global, agent, stage, |c| c.text.clone())
1259            .unwrap_or_else(|| DEFAULT_NUDGE_TEXT.to_string()),
1260    }
1261}
1262
1263/// Condition that determines when a transition edge is available.
1264#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1265#[serde(rename_all = "snake_case")]
1266pub enum TransitionCondition {
1267    /// Always available (LLM chooses)
1268    #[default]
1269    Always,
1270    /// Only on error
1271    Error,
1272    /// Only when max_iterations hit
1273    MaxIterations,
1274    /// LLM picks from available transitions (default for multi-transition stages)
1275    LlmChoice,
1276    /// Fires *mid-stage* when the stage's runtime metrics cross this edge's
1277    /// [`StuckConfig`] thresholds - the agent is burning iterations, wall clock,
1278    /// or edits to one file without finishing. Unlike every other condition this
1279    /// interrupts a stage the agent never said it had completed, so when the edge
1280    /// is unavailable the runtime resumes the stage rather than transitioning.
1281    Stuck,
1282}
1283
1284/// How context transforms when crossing a transition edge.
1285#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1286#[serde(rename_all = "snake_case")]
1287pub enum EdgeTransform {
1288    /// Copy everything as-is (default for single-transition linear stages)
1289    #[default]
1290    Direct,
1291
1292    /// Clear stage-specific regions, keep pinned/system
1293    Clear,
1294
1295    /// LLM-compact stage content into summary
1296    Compact {
1297        #[serde(default)]
1298        prompt: Option<String>,
1299    },
1300
1301    /// Per-region rules
1302    Custom {
1303        carry: Vec<String>,
1304        compact: Vec<String>,
1305        clear: Vec<String>,
1306        compact_prompt: Option<String>,
1307    },
1308}
1309
1310impl PartialEq for EdgeTransform {
1311    #[inline(never)]
1312    fn eq(&self, other: &Self) -> bool {
1313        match (self, other) {
1314            (Self::Direct, Self::Direct) | (Self::Clear, Self::Clear) => true,
1315            (Self::Compact { prompt: a }, Self::Compact { prompt: b }) => a == b,
1316            (
1317                Self::Custom {
1318                    carry: ca,
1319                    compact: coa,
1320                    clear: cla,
1321                    compact_prompt: cpa,
1322                },
1323                Self::Custom {
1324                    carry: cb,
1325                    compact: cob,
1326                    clear: clb,
1327                    compact_prompt: cpb,
1328                },
1329            ) => ca == cb && coa == cob && cla == clb && cpa == cpb,
1330            _ => false,
1331        }
1332    }
1333}
1334impl Eq for EdgeTransform {}
1335
1336/// Content transformation type.
1337#[derive(Debug, Clone, Serialize, Deserialize)]
1338pub enum ContentTransform {
1339    /// Copy content as-is
1340    Direct,
1341
1342    /// Summarize content to fit target region
1343    Summarize,
1344
1345    /// Extract specific fields
1346    Extract { fields: Vec<String> },
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351    use super::*;
1352    use crate::layout::ContextLayout;
1353    use crate::layout::RegionDefinition;
1354    use crate::region::RegionKind;
1355
1356    #[test]
1357    fn resolve_nudge_defaults_when_nothing_is_configured() {
1358        // No config anywhere: on for a normal stage, off for a reviewed one,
1359        // with the built-in cap and text.
1360        let normal = resolve_nudge(None, None, None, false);
1361        assert!(normal.enabled);
1362        assert_eq!(normal.max, DEFAULT_MAX_NUDGES);
1363        assert_eq!(normal.text, DEFAULT_NUDGE_TEXT);
1364        let reviewed = resolve_nudge(None, None, None, true);
1365        assert!(!reviewed.enabled);
1366        // The other fields don't depend on review status.
1367        assert_eq!(reviewed.max, DEFAULT_MAX_NUDGES);
1368        assert_eq!(reviewed.text, DEFAULT_NUDGE_TEXT);
1369    }
1370
1371    #[test]
1372    fn resolve_nudge_cascades_each_field_independently() {
1373        let global = NudgeConfig {
1374            enabled: Some(true),
1375            max: Some(10),
1376            text: Some("global".to_string()),
1377        };
1378        let agent = NudgeConfig {
1379            max: Some(2),
1380            ..Default::default()
1381        };
1382        let stage = NudgeConfig {
1383            text: Some("stage".to_string()),
1384            ..Default::default()
1385        };
1386        let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage), false);
1387        // enabled from global, max from agent, text from stage.
1388        assert!(resolved.enabled);
1389        assert_eq!(resolved.max, 2);
1390        assert_eq!(resolved.text, "stage");
1391        // The stage level wins over both when it sets a field.
1392        let stage_all = NudgeConfig {
1393            enabled: Some(false),
1394            max: Some(0),
1395            text: Some("s".to_string()),
1396        };
1397        let resolved = resolve_nudge(Some(&global), Some(&agent), Some(&stage_all), false);
1398        assert_eq!(
1399            resolved,
1400            ResolvedNudge {
1401                enabled: false,
1402                max: 0,
1403                text: "s".to_string()
1404            }
1405        );
1406    }
1407
1408    #[test]
1409    fn resolve_nudge_explicit_enabled_overrides_review_suppression() {
1410        // A reviewed stage is only *implicitly* exempt: any level that sets
1411        // `enabled` speaks for itself, in either direction.
1412        let on = NudgeConfig {
1413            enabled: Some(true),
1414            ..Default::default()
1415        };
1416        assert!(resolve_nudge(None, None, Some(&on), true).enabled);
1417        assert!(resolve_nudge(None, Some(&on), None, true).enabled);
1418        assert!(resolve_nudge(Some(&on), None, None, true).enabled);
1419        let off = NudgeConfig {
1420            enabled: Some(false),
1421            ..Default::default()
1422        };
1423        assert!(!resolve_nudge(None, None, Some(&off), false).enabled);
1424    }
1425
1426    #[test]
1427    fn test_blueprint_creation() {
1428        let regions = vec![RegionDefinition::new(
1429            "test".to_string(),
1430            RegionKind::Pinned,
1431            5000,
1432        )];
1433        let layout = ContextLayout::new(regions, 10000);
1434
1435        let stages = vec![Stage::new(
1436            "analyze".to_string(),
1437            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1438        )];
1439
1440        let blueprint = Blueprint::new(
1441            "test-agent".to_string(),
1442            "A test agent".to_string(),
1443            stages,
1444            layout,
1445        );
1446
1447        assert_eq!(blueprint.name, "test-agent");
1448        assert_eq!(blueprint.stages.len(), 1);
1449    }
1450
1451    #[test]
1452    fn test_blueprint_with_transforms_version() {
1453        let stages = vec![Stage::new("plan".to_string(), make_model())];
1454        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout())
1455            .with_transforms(vec![ContextTransform {
1456                from_blueprint: "a".to_string(),
1457                to_blueprint: "b".to_string(),
1458                mappings: vec![],
1459            }])
1460            .with_version("2.0.0".to_string());
1461
1462        assert_eq!(bp.transforms.len(), 1);
1463        assert_eq!(bp.version, "2.0.0");
1464    }
1465
1466    #[test]
1467    fn agent_tool_permissions_projects_only_string_tool_perm_entries() {
1468        let stages = vec![Stage::new("plan".to_string(), make_model())];
1469        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1470        // A well-formed tool_perm string entry - included.
1471        bp.metadata.insert(
1472            "tool_perm:bash".to_string(),
1473            serde_json::Value::String("deny".to_string()),
1474        );
1475        // A non-`tool_perm:` key - skipped (strip_prefix returns None).
1476        bp.metadata
1477            .insert("title".to_string(), serde_json::Value::String("x".into()));
1478        // A tool_perm key whose value isn't a string - skipped (as_str is None).
1479        bp.metadata
1480            .insert("tool_perm:weird".to_string(), serde_json::Value::Bool(true));
1481
1482        let perms = bp.agent_tool_permissions();
1483        assert_eq!(perms.get("bash").map(String::as_str), Some("deny"));
1484        assert!(!perms.contains_key("title"));
1485        assert!(!perms.contains_key("weird"));
1486        assert_eq!(perms.len(), 1);
1487    }
1488
1489    #[test]
1490    fn test_blueprint_validate_runs_transform_validation() {
1491        // A transform whose mapping targets a real region - validate() must
1492        // reach ContextTransform::validate() and succeed.
1493        let stages = vec![Stage::new("plan".to_string(), make_model())];
1494        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1495        bp.transforms.push(ContextTransform {
1496            from_blueprint: "a".to_string(),
1497            to_blueprint: "b".to_string(),
1498            mappings: vec![RegionMapping {
1499                from_region: "test".to_string(),
1500                to_region: "test".to_string(),
1501                transform: None,
1502            }],
1503        });
1504        assert!(bp.validate().is_ok());
1505    }
1506
1507    #[test]
1508    fn test_blueprint_validate_fails_on_transform_targeting_unknown_region() {
1509        let stages = vec![Stage::new("plan".to_string(), make_model())];
1510        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
1511        bp.transforms.push(ContextTransform {
1512            from_blueprint: "a".to_string(),
1513            to_blueprint: "b".to_string(),
1514            mappings: vec![RegionMapping {
1515                from_region: "test".to_string(),
1516                to_region: "nonexistent".to_string(),
1517                transform: None,
1518            }],
1519        });
1520        let err = bp.validate().unwrap_err();
1521        assert_eq!(
1522            err,
1523            ValidationError::Region {
1524                region: "nonexistent".to_string(),
1525                message: "transform target region not found in layout".to_string(),
1526            }
1527        );
1528    }
1529
1530    #[test]
1531    fn test_mixed_linear_and_graph_mode_terminal_path() {
1532        // "plan" has explicit transitions (triggers graph-mode validation),
1533        // but "impl" and "review" have none - they must fall back to
1534        // linear (next-by-index) terminal-path resolution.
1535        let mut plan = Stage::new("plan".to_string(), make_model());
1536        let impl_stage = Stage::new("impl".to_string(), make_model());
1537        let review = Stage::new("review".to_string(), make_model());
1538
1539        let mut transitions = HashMap::new();
1540        transitions.insert(
1541            "impl".to_string(),
1542            TransitionEdge {
1543                target: "impl".to_string(),
1544                condition: TransitionCondition::Always,
1545                hint: None,
1546                transform: EdgeTransform::Direct,
1547                gate: None,
1548                stuck: None,
1549            },
1550        );
1551        plan.transitions = Some(transitions);
1552
1553        let bp = Blueprint::new(
1554            "t".into(),
1555            "".into(),
1556            vec![plan, impl_stage, review],
1557            make_layout(),
1558        );
1559        assert!(bp.validate().is_ok());
1560    }
1561
1562    #[test]
1563    fn test_stage_validation() {
1564        let stage = Stage::new(
1565            "test".to_string(),
1566            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1567        );
1568        assert!(stage.validate().is_ok());
1569
1570        let empty_stage = Stage::new(
1571            "".to_string(),
1572            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1573        );
1574        assert!(empty_stage.validate().is_err());
1575    }
1576
1577    #[test]
1578    fn test_stage_validate_with_valid_context_layout_is_ok() {
1579        let mut stage = Stage::new("test".to_string(), make_model());
1580        stage.context_layout = Some(make_layout());
1581        assert!(stage.validate().is_ok());
1582    }
1583
1584    #[test]
1585    fn test_stage_validate_with_invalid_context_layout_is_err() {
1586        // Duplicate region names make the layout itself invalid.
1587        let regions = vec![
1588            RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
1589            RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
1590        ];
1591        let mut stage = Stage::new("test".to_string(), make_model());
1592        stage.context_layout = Some(ContextLayout::new(regions, 200));
1593        assert!(stage.validate().is_err());
1594    }
1595
1596    #[test]
1597    fn test_stage_with_tools_context_layout_description() {
1598        let stage = Stage::new("test".to_string(), make_model())
1599            .with_tools(vec!["read_file".to_string(), "bash".to_string()])
1600            .with_context_layout(make_layout())
1601            .with_description("does things".to_string());
1602
1603        assert_eq!(stage.available_tools, vec!["read_file", "bash"]);
1604        assert!(stage.context_layout.is_some());
1605        assert_eq!(stage.description.as_deref(), Some("does things"));
1606    }
1607
1608    #[test]
1609    fn test_stage_with_mode() {
1610        let stage = Stage::new("test".to_string(), make_model())
1611            .with_mode(StageMode::InteractivePoints { points: vec![] });
1612        assert_eq!(stage.mode, StageMode::InteractivePoints { points: vec![] });
1613    }
1614
1615    #[test]
1616    fn test_stage_allow_complete_defaults_false() {
1617        let stage = Stage::new("review".to_string(), make_model());
1618        assert!(!stage.allow_complete);
1619    }
1620
1621    #[test]
1622    fn test_stage_allow_complete_serde_default_when_missing() {
1623        // A serialized stage from before allow_complete existed must still
1624        // deserialize, defaulting to false.
1625        let json = r#"{
1626            "name": "review",
1627            "description": null,
1628            "model": {"provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {}},
1629            "available_tools": [],
1630            "max_iterations": null,
1631            "context_layout": null,
1632            "config": {},
1633            "transitions": null,
1634            "max_revisits": null,
1635            "transition_prompt": null
1636        }"#;
1637        let stage: Stage = serde_json::from_str(json).unwrap();
1638        assert!(!stage.allow_complete);
1639        assert!(stage.accepts_messages);
1640    }
1641
1642    #[test]
1643    fn test_stage_allow_complete_roundtrip() {
1644        let mut stage = Stage::new("review".to_string(), make_model());
1645        stage.allow_complete = true;
1646        let json = serde_json::to_string(&stage).unwrap();
1647        let back: Stage = serde_json::from_str(&json).unwrap();
1648        assert!(back.allow_complete);
1649    }
1650
1651    #[test]
1652    fn test_interaction_point_directives_default_empty() {
1653        let point = InteractionPoint {
1654            name: "plan_approval".to_string(),
1655            prompt: "Approve?".to_string(),
1656            required: true,
1657            unattended: UnattendedPolicy::AutoApprove,
1658            style: InteractionStyle::MultipleChoice,
1659            options: vec!["Approve".to_string(), "Revise".to_string()],
1660            directives: HashMap::new(),
1661            abort_options: Vec::new(),
1662            edit_options: Vec::new(),
1663            document_region: None,
1664        };
1665        assert!(point.directives.is_empty());
1666        assert!(point.abort_options.is_empty());
1667        assert!(point.edit_options.is_empty());
1668    }
1669
1670    #[test]
1671    fn test_interaction_point_directives_roundtrip() {
1672        let mut directives = HashMap::new();
1673        directives.insert(
1674            "Revise".to_string(),
1675            "Ask what to change, then re-plan.".to_string(),
1676        );
1677        let point = InteractionPoint {
1678            name: "plan_approval".to_string(),
1679            prompt: "Approve?".to_string(),
1680            required: true,
1681            unattended: UnattendedPolicy::Ask,
1682            style: InteractionStyle::MultipleChoice,
1683            options: vec!["Approve".to_string(), "Revise".to_string()],
1684            directives,
1685            abort_options: vec!["Abort".to_string()],
1686            edit_options: vec!["Add detail".to_string()],
1687            document_region: Some("plan".to_string()),
1688        };
1689        let json = serde_json::to_string(&point).unwrap();
1690        let back: InteractionPoint = serde_json::from_str(&json).unwrap();
1691        assert_eq!(
1692            back.directives.get("Revise").map(|s| s.as_str()),
1693            Some("Ask what to change, then re-plan.")
1694        );
1695        assert_eq!(back.abort_options, vec!["Abort".to_string()]);
1696        assert_eq!(back.edit_options, vec!["Add detail".to_string()]);
1697        // A point that holds for a person under `--yolo` has to survive the
1698        // round trip: this is what a restored run re-arms from.
1699        assert_eq!(back.unattended, UnattendedPolicy::Ask);
1700    }
1701
1702    #[test]
1703    fn test_interaction_point_directives_serde_default_when_missing() {
1704        let json = r#"{
1705            "name": "plan_approval",
1706            "prompt": "Approve?",
1707            "required": true,
1708            "style": "multiple_choice",
1709            "options": ["Approve", "Revise"]
1710        }"#;
1711        let point: InteractionPoint = serde_json::from_str(json).unwrap();
1712        assert!(point.directives.is_empty());
1713        assert!(point.abort_options.is_empty());
1714    }
1715
1716    #[test]
1717    fn test_interaction_point_followups_alias_still_deserializes() {
1718        // Backward compat: old serialized blueprints used "followups".
1719        let json = r#"{
1720            "name": "plan_approval",
1721            "prompt": "Approve?",
1722            "required": true,
1723            "style": "multiple_choice",
1724            "options": ["Approve", "Revise"],
1725            "followups": { "Revise": "What to change?" }
1726        }"#;
1727        let point: InteractionPoint = serde_json::from_str(json).unwrap();
1728        assert_eq!(
1729            point.directives.get("Revise").map(|s| s.as_str()),
1730            Some("What to change?")
1731        );
1732    }
1733
1734    #[test]
1735    fn test_model_config_new_creates_single_entry() {
1736        let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1737        assert_eq!(mc.models.len(), 1);
1738        assert_eq!(mc.models[0].provider, "anthropic");
1739        assert_eq!(mc.models[0].model, "claude-sonnet-4-6");
1740        assert!(mc.allow_user_default);
1741    }
1742
1743    #[test]
1744    fn test_model_config_with_multiple_models() {
1745        let mc = ModelConfig {
1746            models: vec![
1747                ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1748                ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1749                ModelEntry::new("ollama".to_string(), "llama3".to_string()),
1750            ],
1751            allow_user_default: true,
1752            parameters: HashMap::new(),
1753            request_timeout_secs: None,
1754        };
1755        assert_eq!(mc.models.len(), 3);
1756        assert_eq!(mc.models[0].provider, "anthropic");
1757        assert_eq!(mc.models[1].provider, "openai");
1758        assert_eq!(mc.models[2].provider, "ollama");
1759    }
1760
1761    #[test]
1762    fn test_model_config_serde_roundtrip() {
1763        let mc = ModelConfig {
1764            models: vec![
1765                ModelEntry::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
1766                ModelEntry::new("openai".to_string(), "gpt-4o".to_string()),
1767            ],
1768            allow_user_default: false,
1769            parameters: HashMap::new(),
1770            request_timeout_secs: None,
1771        };
1772        let json = serde_json::to_string(&mc).unwrap();
1773        let back: ModelConfig = serde_json::from_str(&json).unwrap();
1774        assert_eq!(back.models.len(), 2);
1775        assert_eq!(back.models[0].provider, "anthropic");
1776        assert_eq!(back.models[1].provider, "openai");
1777        assert!(!back.allow_user_default);
1778    }
1779
1780    #[test]
1781    fn test_model_config_serde_defaults_when_fields_missing() {
1782        // Minimal JSON - models defaults to empty, allow_user_default defaults to true
1783        let json = r#"{"parameters": {}}"#;
1784        let mc: ModelConfig = serde_json::from_str(json).unwrap();
1785        assert!(mc.models.is_empty());
1786        assert!(mc.allow_user_default);
1787    }
1788
1789    #[test]
1790    fn test_model_config_convenience_accessors() {
1791        let mc = ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string());
1792        assert_eq!(mc.provider(), "anthropic");
1793        assert_eq!(mc.model(), "claude-sonnet-4-6");
1794    }
1795
1796    #[test]
1797    fn test_model_config_convenience_accessors_empty_models() {
1798        let mc = ModelConfig {
1799            models: vec![],
1800            allow_user_default: true,
1801            parameters: HashMap::new(),
1802            request_timeout_secs: None,
1803        };
1804        assert_eq!(mc.provider(), "anthropic");
1805        assert_eq!(mc.model(), "claude-sonnet-4-6");
1806    }
1807
1808    fn make_model() -> ModelConfig {
1809        ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string())
1810    }
1811
1812    fn make_layout() -> ContextLayout {
1813        let regions = vec![RegionDefinition::new(
1814            "test".to_string(),
1815            RegionKind::Pinned,
1816            5000,
1817        )];
1818        ContextLayout::new(regions, 10000)
1819    }
1820
1821    #[test]
1822    fn test_graph_validation_entry_stage_exists() {
1823        let stages = vec![Stage::new("plan".to_string(), make_model())];
1824        let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1825        bp.entry_stage = Some("nonexistent".to_string());
1826        assert!(bp.validate().is_err());
1827    }
1828
1829    #[test]
1830    fn test_graph_validation_entry_stage_valid() {
1831        let stages = vec![Stage::new("plan".to_string(), make_model())];
1832        let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
1833        bp.entry_stage = Some("plan".to_string());
1834        assert!(bp.validate().is_ok());
1835    }
1836
1837    #[test]
1838    fn test_graph_validation_transition_target_missing() {
1839        let mut stage = Stage::new("plan".to_string(), make_model());
1840        let mut transitions = HashMap::new();
1841        transitions.insert(
1842            "nonexistent".to_string(),
1843            TransitionEdge {
1844                target: "nonexistent".to_string(),
1845                condition: TransitionCondition::Always,
1846                hint: None,
1847                transform: EdgeTransform::Direct,
1848                gate: None,
1849                stuck: None,
1850            },
1851        );
1852        stage.transitions = Some(transitions);
1853        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1854        assert!(bp.validate().is_err());
1855    }
1856
1857    /// A `require_modifications` gate on a stage that can't modify anything
1858    /// could never be satisfied - it would just burn the stage's re-run budget
1859    /// on every pass. Reject it at load time instead.
1860    #[test]
1861    fn test_graph_validation_modification_gate_needs_a_writing_stage() {
1862        let gated = |tools: &[&str], extra: &[&str]| {
1863            let mut stage = Stage::new("impl".to_string(), make_model());
1864            stage.available_tools = tools.iter().map(|t| t.to_string()).collect();
1865            let mut transitions = HashMap::new();
1866            transitions.insert(
1867                "review".to_string(),
1868                TransitionEdge {
1869                    target: "review".to_string(),
1870                    condition: TransitionCondition::Always,
1871                    hint: None,
1872                    transform: EdgeTransform::Direct,
1873                    stuck: None,
1874                    gate: Some(TransitionGate {
1875                        require_modifications: true,
1876                        tools: extra.iter().map(|t| t.to_string()).collect(),
1877                        ..Default::default()
1878                    }),
1879                },
1880            );
1881            stage.transitions = Some(transitions);
1882            Blueprint::new(
1883                "t".into(),
1884                "".into(),
1885                vec![stage, Stage::new("review".to_string(), make_model())],
1886                make_layout(),
1887            )
1888        };
1889        let err = gated(&["read_file"], &[]).validate().unwrap_err();
1890        assert!(err.to_string().contains("no file-modifying tool"));
1891        // A built-in write tool satisfies it...
1892        assert!(gated(&["read_file", "edit_file"], &[]).validate().is_ok());
1893        // ...as does one the gate itself declares (MCP / script toolchains).
1894        assert!(
1895            gated(&["read_file", "patch_file"], &["patch_file"])
1896                .validate()
1897                .is_ok()
1898        );
1899        // A gate that doesn't require modifications is never checked.
1900        let mut off = gated(&["read_file"], &[]);
1901        off.stages[0]
1902            .transitions
1903            .as_mut()
1904            .unwrap()
1905            .get_mut("review")
1906            .unwrap()
1907            .gate = Some(TransitionGate::default());
1908        assert!(off.validate().is_ok());
1909        // Neither is an edge with no gate at all.
1910        off.stages[0]
1911            .transitions
1912            .as_mut()
1913            .unwrap()
1914            .get_mut("review")
1915            .unwrap()
1916            .gate = None;
1917        assert!(off.validate().is_ok());
1918    }
1919
1920    #[test]
1921    fn test_graph_validation_self_loop_requires_max_revisits() {
1922        let mut stage = Stage::new("impl".to_string(), make_model());
1923        let mut transitions = HashMap::new();
1924        transitions.insert(
1925            "impl".to_string(),
1926            TransitionEdge {
1927                target: "impl".to_string(),
1928                condition: TransitionCondition::Always,
1929                hint: None,
1930                transform: EdgeTransform::Direct,
1931                gate: None,
1932                stuck: None,
1933            },
1934        );
1935        stage.transitions = Some(transitions);
1936        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1937        assert!(bp.validate().is_err());
1938    }
1939
1940    #[test]
1941    fn test_graph_validation_self_loop_with_max_revisits_ok() {
1942        let mut stage = Stage::new("impl".to_string(), make_model());
1943        stage.max_revisits = Some(3);
1944        let mut transitions = HashMap::new();
1945        transitions.insert(
1946            "impl".to_string(),
1947            TransitionEdge {
1948                target: "impl".to_string(),
1949                condition: TransitionCondition::Always,
1950                hint: None,
1951                transform: EdgeTransform::Direct,
1952                gate: None,
1953                stuck: None,
1954            },
1955        );
1956        stage.transitions = Some(transitions);
1957        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
1958        // Should pass: self-loop has max_revisits, and the self-loop target
1959        // will eventually exhaust, leaving zero edges → terminal
1960        assert!(bp.validate().is_ok());
1961    }
1962
1963    #[test]
1964    fn test_graph_validation_terminal_path_exists() {
1965        let mut plan = Stage::new("plan".to_string(), make_model());
1966        let mut review = Stage::new("review".to_string(), make_model());
1967        review.transitions = Some(HashMap::new()); // terminal: no outgoing
1968
1969        let mut transitions = HashMap::new();
1970        transitions.insert(
1971            "review".to_string(),
1972            TransitionEdge {
1973                target: "review".to_string(),
1974                condition: TransitionCondition::Always,
1975                hint: None,
1976                transform: EdgeTransform::Direct,
1977                gate: None,
1978                stuck: None,
1979            },
1980        );
1981        plan.transitions = Some(transitions);
1982
1983        let bp = Blueprint::new("t".into(), "".into(), vec![plan, review], make_layout());
1984        assert!(bp.validate().is_ok());
1985    }
1986
1987    #[test]
1988    fn test_graph_no_terminal_path() {
1989        // Two stages that only transition to each other with no terminal
1990        let mut a = Stage::new("a".to_string(), make_model());
1991        let mut b = Stage::new("b".to_string(), make_model());
1992
1993        let mut a_transitions = HashMap::new();
1994        a_transitions.insert(
1995            "b".to_string(),
1996            TransitionEdge {
1997                target: "b".to_string(),
1998                condition: TransitionCondition::Always,
1999                hint: None,
2000                transform: EdgeTransform::Direct,
2001                gate: None,
2002                stuck: None,
2003            },
2004        );
2005        a.transitions = Some(a_transitions);
2006
2007        let mut b_transitions = HashMap::new();
2008        b_transitions.insert(
2009            "a".to_string(),
2010            TransitionEdge {
2011                target: "a".to_string(),
2012                condition: TransitionCondition::Always,
2013                hint: None,
2014                transform: EdgeTransform::Direct,
2015                gate: None,
2016                stuck: None,
2017            },
2018        );
2019        b.transitions = Some(b_transitions);
2020
2021        let bp = Blueprint::new("t".into(), "".into(), vec![a, b], make_layout());
2022        assert!(bp.validate().is_err());
2023    }
2024
2025    #[test]
2026    fn test_linear_stages_still_validate() {
2027        // No transitions set at all - pure linear mode
2028        let stages = vec![
2029            Stage::new("plan".to_string(), make_model()),
2030            Stage::new("impl".to_string(), make_model()),
2031            Stage::new("review".to_string(), make_model()),
2032        ];
2033        let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
2034        assert!(bp.validate().is_ok());
2035    }
2036
2037    #[test]
2038    fn test_resolve_entry_stage_name() {
2039        let stages = vec![
2040            Stage::new("plan".to_string(), make_model()),
2041            Stage::new("impl".to_string(), make_model()),
2042        ];
2043        let mut bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
2044        assert_eq!(bp.resolve_entry_stage_name(), "plan");
2045
2046        bp.entry_stage = Some("impl".to_string());
2047        assert_eq!(bp.resolve_entry_stage_name(), "impl");
2048    }
2049
2050    #[test]
2051    fn test_find_stage() {
2052        let stages = vec![
2053            Stage::new("plan".to_string(), make_model()),
2054            Stage::new("impl".to_string(), make_model()),
2055        ];
2056        let bp = Blueprint::new("t".into(), "".into(), stages, make_layout());
2057        assert!(bp.find_stage("plan").is_some());
2058        assert!(bp.find_stage("impl").is_some());
2059        assert!(bp.find_stage("nonexistent").is_none());
2060    }
2061
2062    #[test]
2063    fn test_transition_condition_default() {
2064        let cond = TransitionCondition::default();
2065        assert_eq!(cond, TransitionCondition::Always);
2066    }
2067
2068    #[test]
2069    fn test_edge_transform_default() {
2070        let t = EdgeTransform::default();
2071        assert_eq!(t, EdgeTransform::Direct);
2072    }
2073
2074    #[test]
2075    fn test_stage_mode_equality() {
2076        assert_eq!(StageMode::Autonomous, StageMode::Autonomous);
2077        assert_eq!(StageMode::Interactive, StageMode::Interactive);
2078        assert_ne!(StageMode::Autonomous, StageMode::Interactive);
2079    }
2080
2081    #[test]
2082    fn test_interaction_style_equality() {
2083        assert_eq!(InteractionStyle::FreeText, InteractionStyle::FreeText);
2084        assert_ne!(InteractionStyle::FreeText, InteractionStyle::MultipleChoice);
2085    }
2086
2087    // ─── stuck detection (#106) ─────────────────────────────────────────────
2088
2089    #[test]
2090    fn stuck_config_is_armed_only_when_a_threshold_is_set() {
2091        assert!(!StuckConfig::default().is_armed());
2092        for cfg in [
2093            StuckConfig {
2094                after_iterations: Some(1),
2095                ..Default::default()
2096            },
2097            StuckConfig {
2098                after_minutes: Some(1),
2099                ..Default::default()
2100            },
2101            StuckConfig {
2102                after_same_file_edits: Some(1),
2103                ..Default::default()
2104            },
2105            StuckConfig {
2106                after_tool_calls: Some(1),
2107                ..Default::default()
2108            },
2109        ] {
2110            assert!(cfg.is_armed(), "{cfg:?} should be armed");
2111        }
2112    }
2113
2114    #[test]
2115    fn transition_condition_stuck_round_trips_as_snake_case() {
2116        let json = serde_json::to_string(&TransitionCondition::Stuck).unwrap();
2117        assert_eq!(json, "\"stuck\"");
2118        let back: TransitionCondition = serde_json::from_str(&json).unwrap();
2119        assert_eq!(back, TransitionCondition::Stuck);
2120        assert_ne!(TransitionCondition::Stuck, TransitionCondition::Always);
2121    }
2122
2123    #[test]
2124    fn transition_edge_stuck_round_trips_and_is_omitted_when_absent() {
2125        let plain = TransitionEdge {
2126            target: "b".to_string(),
2127            condition: TransitionCondition::Always,
2128            hint: None,
2129            transform: EdgeTransform::Direct,
2130            gate: None,
2131            stuck: None,
2132        };
2133        let json = serde_json::to_string(&plain).unwrap();
2134        assert!(
2135            !json.contains("stuck"),
2136            "absent config must be skipped: {json}"
2137        );
2138
2139        let armed = TransitionEdge {
2140            condition: TransitionCondition::Stuck,
2141            stuck: Some(StuckConfig {
2142                after_iterations: Some(20),
2143                after_minutes: Some(10),
2144                after_same_file_edits: Some(3),
2145                after_tool_calls: Some(60),
2146            }),
2147            ..plain
2148        };
2149        let back: TransitionEdge = serde_json::from_str(&serde_json::to_string(&armed).unwrap())
2150            .expect("armed edge round-trips");
2151        assert_eq!(back.condition, TransitionCondition::Stuck);
2152        assert_eq!(back.stuck, armed.stuck);
2153    }
2154
2155    /// A blueprint built programmatically (API / `lev validate`) bypasses the
2156    /// manifest parser, so `validate` has to catch the dead-edge shape too.
2157    #[test]
2158    fn validate_rejects_a_stuck_edge_with_no_threshold() {
2159        let build = |stuck| {
2160            let mut a = Stage::new("a".to_string(), make_model());
2161            let b = Stage::new("b".to_string(), make_model());
2162            let mut transitions = std::collections::HashMap::new();
2163            transitions.insert(
2164                "b".to_string(),
2165                TransitionEdge {
2166                    target: "b".to_string(),
2167                    condition: TransitionCondition::Stuck,
2168                    hint: None,
2169                    transform: EdgeTransform::Direct,
2170                    gate: None,
2171                    stuck,
2172                },
2173            );
2174            a.transitions = Some(transitions);
2175            Blueprint::new("t".into(), "".into(), vec![a, b], make_layout())
2176        };
2177
2178        for dead in [None, Some(StuckConfig::default())] {
2179            let err = build(dead)
2180                .validate()
2181                .expect_err("dead stuck edge rejected");
2182            assert!(
2183                format!("{err:?}").contains("stuck_after_"),
2184                "unexpected error: {err:?}"
2185            );
2186        }
2187
2188        // The same graph with a real threshold is fine.
2189        assert!(
2190            build(Some(StuckConfig {
2191                after_iterations: Some(5),
2192                ..Default::default()
2193            }))
2194            .validate()
2195            .is_ok()
2196        );
2197    }
2198
2199    /// `required_tools` keeps a blocking human tool through an unattended run.
2200    /// Naming one the stage can't call keeps nothing, so it is rejected rather
2201    /// than quietly ignored - the author meant something by writing it.
2202    #[test]
2203    fn validate_rejects_a_required_tool_the_stage_cannot_call() {
2204        let mut stage = Stage::new("plan".to_string(), make_model());
2205        stage.available_tools = vec!["read_file".to_string()];
2206        stage.required_tools = vec!["ask_user_text".to_string()];
2207        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2208
2209        let err = bp.validate().expect_err("a tool it cannot call");
2210        let text = format!("{err:?}");
2211        assert!(text.contains("ask_user_text"), "names the tool: {text}");
2212        assert!(text.contains("available_tools"), "says why: {text}");
2213    }
2214
2215    #[test]
2216    fn validate_accepts_a_required_tool_the_stage_offers() {
2217        let mut stage = Stage::new("plan".to_string(), make_model());
2218        stage.available_tools = vec!["read_file".to_string(), "ask_user_text".to_string()];
2219        stage.required_tools = vec!["ask_user_text".to_string()];
2220        let bp = Blueprint::new("t".into(), "".into(), vec![stage], make_layout());
2221
2222        bp.validate().expect("the tool is on offer");
2223    }
2224
2225    #[test]
2226    fn test_transition_condition_equality() {
2227        assert_eq!(
2228            TransitionCondition::LlmChoice,
2229            TransitionCondition::LlmChoice
2230        );
2231        assert_ne!(TransitionCondition::Always, TransitionCondition::Error);
2232    }
2233
2234    #[test]
2235    fn test_edge_transform_compact_and_custom_equality() {
2236        let a = EdgeTransform::Compact {
2237            prompt: Some("p".to_string()),
2238        };
2239        let b = EdgeTransform::Compact {
2240            prompt: Some("p".to_string()),
2241        };
2242        assert_eq!(a, b);
2243
2244        let c1 = EdgeTransform::Custom {
2245            carry: vec!["a".to_string()],
2246            compact: vec!["b".to_string()],
2247            clear: vec!["c".to_string()],
2248            compact_prompt: Some("p".to_string()),
2249        };
2250        let c2 = c1.clone();
2251        assert_eq!(c1, c2);
2252
2253        assert_ne!(EdgeTransform::Direct, EdgeTransform::Clear);
2254    }
2255
2256    #[test]
2257    fn test_stage_accepts_messages_default_true() {
2258        let stage = Stage::new(
2259            "test".to_string(),
2260            ModelConfig::new("anthropic".to_string(), "claude-sonnet-4-6".to_string()),
2261        );
2262        assert!(stage.accepts_messages);
2263    }
2264
2265    #[test]
2266    fn test_stage_accepts_messages_serde_roundtrip() {
2267        // Serialize a stage with accepts_messages = false, then deserialize
2268        let mut stage = Stage::new(
2269            "report".to_string(),
2270            ModelConfig::new("anthropic".to_string(), "claude-opus-4-6".to_string()),
2271        );
2272        stage.accepts_messages = false;
2273
2274        let json = serde_json::to_string(&stage).expect("should serialize");
2275        let deserialized: Stage = serde_json::from_str(&json).expect("should deserialize");
2276        assert!(!deserialized.accepts_messages);
2277    }
2278
2279    #[test]
2280    fn test_stage_accepts_messages_json_default() {
2281        // When accepts_messages is missing from JSON, it should default to true
2282        let json = r#"{
2283            "name": "analyze",
2284            "model": { "provider": "anthropic", "model": "claude-sonnet-4-6", "parameters": {} },
2285            "available_tools": [],
2286            "mode": "Autonomous",
2287            "config": {},
2288            "tool_permissions": {},
2289            "requires_children": false
2290        }"#;
2291        let stage: Stage = serde_json::from_str(json).expect("should parse");
2292        assert!(stage.accepts_messages);
2293    }
2294
2295    #[test]
2296    fn test_has_terminal_path_unknown_stage_returns_false() {
2297        // `has_terminal_path` is private; this test is in the same module.
2298        // Calling it with a stage name that doesn't exist in the Blueprint
2299        // exercises the `None => return false` arm (blueprint.rs line 203).
2300        let stages = vec![Stage::new("start".to_string(), make_model())];
2301        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2302        let mut visited = std::collections::HashSet::new();
2303        assert!(!bp.has_terminal_path("nonexistent_stage", &mut visited));
2304    }
2305
2306    #[test]
2307    fn test_blueprint_validate_fails_when_layout_has_duplicate_region() {
2308        let regions = vec![
2309            RegionDefinition::new("dup".to_string(), RegionKind::Pinned, 100),
2310            RegionDefinition::new("dup".to_string(), RegionKind::Temporary, 100),
2311        ];
2312        let layout = ContextLayout::new(regions, 200);
2313        let stages = vec![Stage::new("start".to_string(), make_model())];
2314        let bp = Blueprint::new("t".into(), "d".into(), stages, layout);
2315        assert_eq!(
2316            bp.validate().unwrap_err(),
2317            ValidationError::Region {
2318                region: "dup".to_string(),
2319                message: "duplicate region name".to_string(),
2320            }
2321        );
2322    }
2323
2324    #[test]
2325    fn test_blueprint_validate_fails_when_stage_has_empty_name() {
2326        let stages = vec![Stage::new("".to_string(), make_model())];
2327        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2328        assert_eq!(
2329            bp.validate().unwrap_err(),
2330            ValidationError::Stage {
2331                stage: "(empty)".to_string(),
2332                message: "stage name cannot be empty".to_string(),
2333            }
2334        );
2335    }
2336
2337    #[test]
2338    fn test_file_tracking_config_defaults() {
2339        let json = r#"{"region": "files"}"#;
2340        let config: FileTrackingConfig = serde_json::from_str(json).unwrap();
2341        assert_eq!(config.region, "files");
2342        assert!(config.track_reads);
2343        assert!(config.track_writes);
2344        assert!(config.max_file_tokens.is_none());
2345    }
2346
2347    #[test]
2348    fn test_file_tracking_config_serde_roundtrip() {
2349        let config = FileTrackingConfig {
2350            region: "files".to_string(),
2351            track_reads: true,
2352            track_writes: false,
2353            max_file_tokens: Some(5000),
2354        };
2355        let json = serde_json::to_string(&config).unwrap();
2356        let back: FileTrackingConfig = serde_json::from_str(&json).unwrap();
2357        assert_eq!(back.region, "files");
2358        assert!(back.track_reads);
2359        assert!(!back.track_writes);
2360        assert_eq!(back.max_file_tokens, Some(5000));
2361    }
2362
2363    #[test]
2364    fn test_blueprint_file_tracking_default_none() {
2365        let stages = vec![Stage::new("plan".to_string(), make_model())];
2366        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2367        assert!(bp.file_tracking.is_none());
2368    }
2369
2370    #[test]
2371    fn test_blueprint_file_tracking_serde_roundtrip() {
2372        let stages = vec![Stage::new("plan".to_string(), make_model())];
2373        let mut bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2374        bp.file_tracking = Some(FileTrackingConfig {
2375            region: "files".to_string(),
2376            track_reads: true,
2377            track_writes: true,
2378            max_file_tokens: Some(3000),
2379        });
2380        let json = serde_json::to_string(&bp).unwrap();
2381        let back: Blueprint = serde_json::from_str(&json).unwrap();
2382        let ft = back.file_tracking.unwrap();
2383        assert_eq!(ft.region, "files");
2384        assert_eq!(ft.max_file_tokens, Some(3000));
2385    }
2386
2387    #[test]
2388    fn test_tool_result_routing_default() {
2389        let routing = ToolResultRouting::default();
2390        assert_eq!(routing.default_region, "tool_results");
2391        assert!(routing.persist);
2392        assert!(routing.tool_overrides.is_empty());
2393        assert!(routing.max_result_tokens.is_none());
2394    }
2395
2396    #[test]
2397    fn test_stage_new_has_no_tool_result_routing() {
2398        let stage = Stage::new("plan".to_string(), make_model());
2399        assert!(stage.tool_result_routing.is_none());
2400    }
2401
2402    #[test]
2403    fn test_tool_result_routing_serde_roundtrip() {
2404        let mut routing = ToolResultRouting {
2405            default_region: "custom_region".to_string(),
2406            persist: false,
2407            max_result_tokens: Some(4096),
2408            ..Default::default()
2409        };
2410        routing
2411            .tool_overrides
2412            .insert("read_file".to_string(), "file_reads".to_string());
2413
2414        let json = serde_json::to_string(&routing).unwrap();
2415        let back: ToolResultRouting = serde_json::from_str(&json).unwrap();
2416
2417        assert_eq!(back.default_region, "custom_region");
2418        assert!(!back.persist);
2419        assert_eq!(back.max_result_tokens, Some(4096));
2420        assert_eq!(
2421            back.tool_overrides.get("read_file").map(String::as_str),
2422            Some("file_reads")
2423        );
2424    }
2425
2426    #[test]
2427    fn test_stage_with_tool_result_routing_serde_roundtrip() {
2428        let stages = vec![{
2429            let mut s = Stage::new("plan".to_string(), make_model());
2430            s.tool_result_routing = Some(ToolResultRouting {
2431                default_region: "results".to_string(),
2432                tool_overrides: HashMap::new(),
2433                persist: true,
2434                max_result_tokens: Some(2048),
2435            });
2436            s
2437        }];
2438        let bp = Blueprint::new("t".into(), "d".into(), stages, make_layout());
2439        let json = serde_json::to_string(&bp).unwrap();
2440        let back: Blueprint = serde_json::from_str(&json).unwrap();
2441
2442        let routing = back.stages[0]
2443            .tool_result_routing
2444            .as_ref()
2445            .expect("tool_result_routing should be Some");
2446        assert_eq!(routing.default_region, "results");
2447        assert!(routing.persist);
2448        assert_eq!(routing.max_result_tokens, Some(2048));
2449        assert!(routing.tool_overrides.is_empty());
2450    }
2451
2452    // ─── fan_out (StageMode::FanOut) ─────────────────────────────────────────
2453
2454    fn fanout_config() -> FanOutConfig {
2455        FanOutConfig {
2456            worker_agent: None,
2457            worker_stage: Some("fix_worker".to_string()),
2458            worker_query: None,
2459            merge_stage: Some("merge".to_string()),
2460            max_workers: 3,
2461            on_worker_failure: WorkerFailurePolicy::Continue,
2462            split_prompt: "split".to_string(),
2463        }
2464    }
2465
2466    /// Blueprint: fan_out stage (worker_stage=fix_worker) → merge → terminal.
2467    /// The merge stage carries an (empty) transitions table so the blueprint is
2468    /// in graph mode - this makes `validate_graph` run `has_terminal_path`,
2469    /// which walks the fan-out stage's merge hand-off.
2470    fn fanout_blueprint(worker_allowed: bool, config: FanOutConfig) -> Blueprint {
2471        let mut fan = Stage::new("parallel".to_string(), make_model());
2472        fan.mode = StageMode::FanOut { config };
2473        let mut worker = Stage::new("fix_worker".to_string(), make_model());
2474        worker.allow_as_worker = worker_allowed;
2475        let mut merge = Stage::new("merge".to_string(), make_model());
2476        merge.transitions = Some(HashMap::new()); // terminal, graph mode
2477        Blueprint::new(
2478            "t".into(),
2479            "d".into(),
2480            vec![fan, worker, merge],
2481            make_layout(),
2482        )
2483    }
2484
2485    #[test]
2486    fn fanout_stagemode_partial_eq_and_default_policy() {
2487        let a = StageMode::FanOut {
2488            config: fanout_config(),
2489        };
2490        let b = StageMode::FanOut {
2491            config: fanout_config(),
2492        };
2493        assert_eq!(a, b);
2494        let mut other = fanout_config();
2495        other.max_workers = 99;
2496        assert_ne!(a, StageMode::FanOut { config: other });
2497        assert_ne!(a, StageMode::Autonomous);
2498        assert_eq!(
2499            WorkerFailurePolicy::default(),
2500            WorkerFailurePolicy::Continue
2501        );
2502    }
2503
2504    #[test]
2505    fn fanout_config_serde_roundtrip_and_max_workers_default() {
2506        let toml = r#"
2507worker_agent = "fixer"
2508split_prompt = "go"
2509on_worker_failure = "fail_all"
2510"#;
2511        let cfg: FanOutConfig = toml::from_str(toml).unwrap();
2512        assert_eq!(cfg.worker_agent.as_deref(), Some("fixer"));
2513        assert_eq!(cfg.max_workers, 4); // default
2514        assert_eq!(cfg.on_worker_failure, WorkerFailurePolicy::FailAll);
2515        // JSON round-trip preserves everything.
2516        let json = serde_json::to_string(&fanout_config()).unwrap();
2517        let back: FanOutConfig = serde_json::from_str(&json).unwrap();
2518        assert_eq!(back, fanout_config());
2519    }
2520
2521    #[test]
2522    fn fanout_validate_ok_with_allowed_worker_stage() {
2523        assert!(fanout_blueprint(true, fanout_config()).validate().is_ok());
2524    }
2525
2526    #[test]
2527    fn fanout_validate_rejects_worker_stage_not_opted_in() {
2528        let err = fanout_blueprint(false, fanout_config())
2529            .validate()
2530            .unwrap_err();
2531        assert!(err.to_string().contains("allow_as_worker"));
2532    }
2533
2534    #[test]
2535    fn fanout_validate_rejects_missing_worker_stage() {
2536        let mut cfg = fanout_config();
2537        cfg.worker_stage = Some("nope".to_string());
2538        let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2539        assert!(err.to_string().contains("does not exist"));
2540    }
2541
2542    #[test]
2543    fn fanout_validate_rejects_missing_merge_stage() {
2544        let mut cfg = fanout_config();
2545        cfg.merge_stage = Some("nomerge".to_string());
2546        let err = fanout_blueprint(true, cfg).validate().unwrap_err();
2547        assert!(err.to_string().contains("merge_stage"));
2548    }
2549
2550    #[test]
2551    fn fanout_validate_rejects_wrong_worker_source_count() {
2552        // zero sources
2553        let mut cfg = fanout_config();
2554        cfg.worker_stage = None;
2555        assert!(fanout_blueprint(true, cfg).validate().is_err());
2556        // two sources
2557        let mut cfg2 = fanout_config();
2558        cfg2.worker_agent = Some("x".to_string()); // plus worker_stage
2559        assert!(fanout_blueprint(true, cfg2).validate().is_err());
2560    }
2561
2562    #[test]
2563    fn fanout_terminal_path_runs_through_merge_stage() {
2564        // worker_agent form (no local worker_stage), merge → terminal.
2565        let mut cfg = fanout_config();
2566        cfg.worker_stage = None;
2567        cfg.worker_agent = Some("external".to_string());
2568        assert!(fanout_blueprint(false, cfg).validate().is_ok());
2569    }
2570
2571    #[test]
2572    fn fanout_validate_ok_without_merge_stage() {
2573        // No merge stage: valid, and the fan-out stage falls through to the
2574        // linear next stage for its terminal path.
2575        let mut cfg = fanout_config();
2576        cfg.merge_stage = None;
2577        assert!(fanout_blueprint(true, cfg).validate().is_ok());
2578    }
2579}