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