Skip to main content

leviath_core/blueprint/
transition.rs

1//! How a run leaves one stage for the next.
2//!
3//! An edge carries a condition (when it may be taken), a transform (what of the
4//! context comes along), and a gate (what must be true first). Nudges and stuck
5//! detection are here too, because both exist to answer the same question: this
6//! stage is not finishing, so what should happen.
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::ValidationError;
11use crate::layout::ContextLayout;
12
13/// Context transform for converting between agent types.
14///
15/// When spawning a sub-agent with a different blueprint, transforms define
16/// how to map regions from the parent agent's context to the child agent's
17/// context. This enables smooth handoffs between agents with different
18/// memory structures.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ContextTransform {
21    /// Source blueprint name
22    pub from_blueprint: String,
23
24    /// Target blueprint name
25    pub to_blueprint: String,
26
27    /// Region mapping rules
28    pub mappings: Vec<RegionMapping>,
29}
30
31impl ContextTransform {
32    /// Validate that this transform references valid regions.
33    pub(super) fn validate(
34        &self,
35        layout: &ContextLayout,
36    ) -> std::result::Result<(), ValidationError> {
37        for mapping in &self.mappings {
38            // We can only validate target regions against the current layout
39            // (source regions belong to a different blueprint)
40            if layout.get_region(&mapping.to_region).is_none() {
41                return Err(ValidationError::Region {
42                    region: mapping.to_region.clone(),
43                    message: "transform target region not found in layout".to_string(),
44                });
45            }
46        }
47        Ok(())
48    }
49}
50
51/// Mapping rule for a single region in a context transform.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct RegionMapping {
54    /// Source region name
55    pub from_region: String,
56
57    /// Target region name
58    pub to_region: String,
59
60    /// Optional transformation to apply to content
61    pub transform: Option<ContentTransform>,
62}
63
64/// A directed transition edge from one stage to another.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct TransitionEdge {
67    /// Target stage name (derived from the HashMap key during parsing)
68    pub target: String,
69
70    /// When this edge is available
71    #[serde(default)]
72    pub condition: TransitionCondition,
73
74    /// Human-readable hint for the LLM
75    pub hint: Option<String>,
76
77    /// How context transforms when crossing this edge
78    #[serde(default)]
79    pub transform: EdgeTransform,
80
81    /// Preconditions the agent must satisfy before this edge may be followed.
82    /// Absent ⇒ the edge is unconditional (beyond its `condition`).
83    #[serde(default)]
84    pub gate: Option<TransitionGate>,
85
86    /// Thresholds arming a [`TransitionCondition::Stuck`] edge. `Some` iff the
87    /// condition is `Stuck` - both the manifest parser and [`super::Blueprint::validate`]
88    /// reject the two half-configured shapes.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub stuck: Option<StuckConfig>,
91}
92
93/// Thresholds that arm a [`TransitionCondition::Stuck`] edge.
94///
95/// At least one threshold is always set: an edge with none could never fire, so
96/// both the manifest parser and [`super::Blueprint::validate`] reject that shape rather
97/// than build a dead edge. Every threshold is evaluated against the *current
98/// stage's* progress counters, which reset on each stage entry - so a blueprint
99/// can arm different stages with different thresholds independently.
100#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
101pub struct StuckConfig {
102    /// `stuck_after_iterations`: inferences run in this stage without finishing it.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub after_iterations: Option<usize>,
105
106    /// `stuck_after_minutes`: wall-clock minutes spent in this stage.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub after_minutes: Option<usize>,
109
110    /// `stuck_after_same_file_edits`: `write_file`/`edit_file` calls against a
111    /// single path in this stage - the "100 iterations in the wrong file" mode.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub after_same_file_edits: Option<usize>,
114
115    /// `stuck_after_tool_calls`: total tool calls made in this stage.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub after_tool_calls: Option<usize>,
118}
119
120impl StuckConfig {
121    /// Whether any threshold is set. `false` ⇒ the edge could never fire.
122    pub fn is_armed(&self) -> bool {
123        self.after_iterations.is_some()
124            || self.after_minutes.is_some()
125            || self.after_same_file_edits.is_some()
126            || self.after_tool_calls.is_some()
127    }
128}
129
130/// Preconditions an edge imposes on the stage it leaves, checked once the edge
131/// has been chosen but before its transform runs. A gate that isn't satisfied
132/// re-runs the stage with a `[System]` nudge instead of transitioning.
133///
134/// The motivating case: an agent that reads and reasons about the
135/// codebase entirely through `shell` and reaches the review stage without ever
136/// having called a file-writing tool, producing a run with no output at all.
137#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
138pub struct TransitionGate {
139    /// Require at least one successful file-modifying tool call in the stage
140    /// being left.
141    #[serde(default)]
142    pub require_modifications: bool,
143
144    /// Nudge injected when the gate blocks. A default explaining the framework's
145    /// change tracking is generated when absent.
146    #[serde(default)]
147    pub message: Option<String>,
148
149    /// Region whose non-emptiness also satisfies the gate. Per-stage tool-call
150    /// counters reset on stage entry and are not restored when a run resumes
151    /// after a daemon restart, but context regions are - so pointing the gate at
152    /// the region the write tools are routed into keeps a resumed run honest.
153    #[serde(default)]
154    pub region: Option<String>,
155
156    /// Tool names counted as modifying beyond the built-in `write_file` /
157    /// `edit_file` - for agents whose writes go through MCP or script tools.
158    #[serde(default)]
159    pub tools: Vec<String>,
160
161    /// How many times the stage is re-run before the gate gives up and lets the
162    /// transition through (with a warning). Defaults to
163    /// [`DEFAULT_GATE_ATTEMPTS`].
164    #[serde(default)]
165    pub max_attempts: Option<usize>,
166
167    /// Region that must have *changed* during this stage, not merely be present.
168    ///
169    /// Every other gate asks whether something exists. That cannot express a
170    /// revise loop: a stage sent back to redo its work satisfies a presence
171    /// check by re-emitting what it already wrote, so a reviewer's rejection
172    /// can be answered with the same plan and the loop spins until it runs out
173    /// of revisits. Measured, a plan that overrode a documented definition was
174    /// re-confirmed by a verify stage and the run ended confidently wrong.
175    ///
176    /// Compared against the region's content as it stood when the stage was
177    /// entered, so "changed" means changed by *this* pass.
178    #[serde(default)]
179    pub require_region_updated: Option<String>,
180
181    /// Regions that must all hold content before this edge may be taken.
182    ///
183    /// Conjunctive, and that is the point. [`Self::region`] reads as though it
184    /// says this and does not: it is one of several *alternative* ways to
185    /// satisfy [`Self::require_modifications`], so
186    /// `{ require_modifications = true, region = "plan" }` is met by writing
187    /// any file anywhere while `plan` stays empty. That is the right
188    /// shape for what `region` is for - a restart-durable stand-in for
189    /// per-stage counters, which do not survive a daemon restart - and the
190    /// wrong shape for "this stage does not leave without writing X". This key
191    /// is the second thing, ANDed with every other condition on the gate.
192    ///
193    /// A name the window does not hold passes with a warning rather than
194    /// blocking: `lev validate` refuses a gate naming a region no stage
195    /// declares, so reaching that at runtime means a layout moved underneath
196    /// the edge, and stranding a run over it would be worse than the missing
197    /// check. The warning is what keeps the skipped check findable.
198    #[serde(default)]
199    pub require_regions: Vec<String>,
200    /// Checklist region that must have no open items before this edge is taken.
201    ///
202    /// The other gates ask whether a region has content, which cannot tell a
203    /// list of three unfinished items from a list of three finished ones. This
204    /// is the gate a checklist exists for: it makes "did you actually finish"
205    /// a mechanical question rather than one the model answers about itself.
206    #[serde(default)]
207    pub require_no_open_items: Option<String>,
208
209    /// A region that must hold at least so many entries before this edge is
210    /// taken.
211    ///
212    /// The presence gates ask whether a region has *anything*; this asks how
213    /// much. It is what turns a stage whose model cannot call tools - an image
214    /// model that returns however many pictures it likes per reply - into one
215    /// that draws until the set is complete: the gate holds the stage and
216    /// re-runs it with the message, and the next reply adds to the region.
217    /// It is also the honest form of "build from four views": a rule the
218    /// runtime keeps rather than a wish in a prompt.
219    #[serde(default)]
220    pub require_region_entries: Option<RegionCount>,
221}
222
223/// A region and how many entries it must hold, for
224/// [`TransitionGate::require_region_entries`].
225#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
226pub struct RegionCount {
227    /// The region counted.
228    pub region: String,
229    /// The fewest entries that satisfy the gate.
230    pub at_least: usize,
231}
232
233/// Default re-run budget for an unsatisfied [`TransitionGate`].
234pub const DEFAULT_GATE_ATTEMPTS: usize = 3;
235
236/// Built-in tools that modify files on disk, for [`TransitionGate`]'s
237/// `require_modifications` accounting. Extended per-edge by
238/// [`TransitionGate::tools`].
239pub const MODIFYING_TOOLS: &[&str] = &["write_file", "edit_file"];
240
241/// The tool an agent calls to hand back the run's final output.
242///
243/// Named here rather than in `leviath-tools` because both the blueprint
244/// validator and the manifest parser need it, and neither may depend on the
245/// tools crate.
246pub const SUBMIT_OUTPUT_TOOL: &str = "submit_output";
247
248/// The tool that starts a fan-out: one worker per item, all at once.
249///
250/// The single entry point to the fan-out engine. A `mode = "fan_out"` stage is
251/// sugar that grants this tool and transitions to its `merge_stage` once the
252/// call returns; any other stage can grant it directly and fan out in the
253/// middle of its own work, as many times as it needs.
254///
255/// It replaced a design where a fan-out stage's raw text output *was* the work
256/// item list, parsed out of prose by the runtime. That was the one structured
257/// answer the framework asked for without a tool to carry it, and it is what
258/// killed a `deep-researcher` run that answered, accurately, "I have completed
259/// the research."
260///
261/// Named here for the same reason [`SUBMIT_OUTPUT_TOOL`] is: the manifest parser
262/// and the blueprint validator both need it and neither may depend on the tools
263/// crate.
264pub const FAN_OUT_TOOL: &str = "fan_out";
265
266/// Times a stage is re-run for a missing final output before the gate gives up
267/// and lets it through with the run's `output_forced` flag set. Matches
268/// [`DEFAULT_GATE_ATTEMPTS`], and is overridden by a stage's `max_revisits`.
269pub const DEFAULT_OUTPUT_REENTRY_CAP: usize = 3;
270
271/// Settings for the empty-response nudge: the `[System]` message injected when
272/// a stage's model replies with text before making any tool call.
273///
274/// Every field is optional. A field left unset cascades stage → agent → global
275/// config and finally to the built-in default, so a `[stages.<name>.nudge]`
276/// block only has to name what it wants to change. An empty block is inert.
277#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
278pub struct NudgeConfig {
279    /// Whether the nudge fires at all. When unset at every level, the default
280    /// is on - except for a stage with interaction points, whose text response
281    /// is its work product and which is left alone. Setting this explicitly at
282    /// any level overrides that implicit rule in both directions.
283    #[serde(default)]
284    pub enabled: Option<bool>,
285
286    /// How many text-only responses to nudge before accepting the text as
287    /// final. Defaults to [`DEFAULT_MAX_NUDGES`].
288    #[serde(default)]
289    pub max: Option<usize>,
290
291    /// The nudge text. Defaults to [`DEFAULT_NUDGE_TEXT`]. Supports `{stage}`
292    /// (the stage's name) and `{regions}` (comma-separated names of the
293    /// stage's required context regions) placeholders.
294    #[serde(default)]
295    pub text: Option<String>,
296}
297
298/// Default nudge injected when a model responds with text before making any
299/// tool call, used when no [`NudgeConfig`] level sets `text`.
300pub const DEFAULT_NUDGE_TEXT: &str = "You have tools available. Please use them to complete the task. Start by reading the relevant files in the working directory.";
301
302/// Default number of text-only responses to nudge before accepting the text as
303/// final, used when no [`NudgeConfig`] level sets `max`.
304pub const DEFAULT_MAX_NUDGES: usize = 3;
305
306/// A fully-resolved nudge policy for one stage: every [`NudgeConfig`] field
307/// cascaded and defaulted. Produced by [`resolve_nudge`].
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub struct ResolvedNudge {
310    /// Whether the nudge fires for this stage.
311    pub enabled: bool,
312    /// Text-only responses tolerated before the text is accepted as final.
313    pub max: usize,
314    /// The nudge text, before placeholder interpolation.
315    pub text: String,
316}
317
318/// Resolve the nudge policy for a stage, cascading each field independently
319/// stage → agent → global. Narrowest level wins with no clamping - like
320/// [`crate::taint::resolve_batch_tool_hint`], this is a UX knob, not a
321/// permission, so a manifest may raise `max` above the global setting.
322///
323/// `stage_is_reviewed` feeds only the *default* for `enabled`: a stage with
324/// interaction points presents its text for the user to approve, so nudging it
325/// to "use your tools" is off unless some level explicitly turns it on.
326pub fn resolve_nudge(
327    global: Option<&NudgeConfig>,
328    agent: Option<&NudgeConfig>,
329    stage: Option<&NudgeConfig>,
330    stage_is_reviewed: bool,
331) -> ResolvedNudge {
332    fn field<T: Clone>(
333        global: Option<&NudgeConfig>,
334        agent: Option<&NudgeConfig>,
335        stage: Option<&NudgeConfig>,
336        get: impl Fn(&NudgeConfig) -> Option<T>,
337    ) -> Option<T> {
338        stage
339            .and_then(&get)
340            .or_else(|| agent.and_then(&get))
341            .or_else(|| global.and_then(&get))
342    }
343    ResolvedNudge {
344        enabled: field(global, agent, stage, |c| c.enabled).unwrap_or(!stage_is_reviewed),
345        max: field(global, agent, stage, |c| c.max).unwrap_or(DEFAULT_MAX_NUDGES),
346        text: field(global, agent, stage, |c| c.text.clone())
347            .unwrap_or_else(|| DEFAULT_NUDGE_TEXT.to_string()),
348    }
349}
350
351/// Condition that determines when a transition edge is available.
352#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354pub enum TransitionCondition {
355    /// Always available (LLM chooses)
356    #[default]
357    Always,
358    /// Only on error
359    Error,
360    /// Only when max_iterations hit
361    MaxIterations,
362    /// LLM picks from available transitions (default for multi-transition stages)
363    LlmChoice,
364    /// Fires when the graph would otherwise strand here: the stage finished, and
365    /// every normal (`always`/`llm_choice`) edge's target has spent its
366    /// `max_revisits`.
367    ///
368    /// Exists because the alternatives were both bad. Declaring an ordinary edge
369    /// to the output stage silences the `dead-end-possible` lint by adding a
370    /// route the model can take at the end of *every* visit - measured, that
371    /// collapsed pipelines in 10 of 24 runs of one agent and 21 of 36 of
372    /// another. Declaring nothing leaves the run to die with everything it
373    /// established thrown away. This edge is reachable when stuck and invisible
374    /// the rest of the time, which is what "escape" actually means.
375    ///
376    /// Deliberately not `max_iterations`: that fires when a stage burns its
377    /// iteration budget, which is a different event and does not fire here at
378    /// all.
379    DeadEnd,
380    /// Fires *mid-stage* when the stage's runtime metrics cross this edge's
381    /// [`StuckConfig`] thresholds - the agent is burning iterations, wall clock,
382    /// or edits to one file without finishing. Unlike every other condition this
383    /// interrupts a stage the agent never said it had completed, so when the edge
384    /// is unavailable the runtime resumes the stage rather than transitioning.
385    Stuck,
386}
387
388/// How context transforms when crossing a transition edge.
389#[derive(Debug, Clone, Default, Serialize, Deserialize)]
390#[serde(rename_all = "snake_case")]
391pub enum EdgeTransform {
392    /// Copy everything as-is (default for single-transition linear stages)
393    #[default]
394    Direct,
395
396    /// Clear stage-specific regions, keep pinned/system
397    Clear,
398
399    /// LLM-compact stage content into summary
400    Compact {
401        /// What to ask the compaction model for, replacing the built-in
402        /// instruction. `None` uses the default summary prompt.
403        #[serde(default)]
404        prompt: Option<String>,
405    },
406
407    /// Per-region rules
408    Custom {
409        /// Regions copied through untouched.
410        carry: Vec<String>,
411        /// Regions replaced by an LLM summary of themselves.
412        compact: Vec<String>,
413        /// Regions emptied on the way across.
414        clear: Vec<String>,
415        /// The instruction used for everything in `compact`. `None` uses the
416        /// default summary prompt.
417        compact_prompt: Option<String>,
418    },
419}
420
421impl PartialEq for EdgeTransform {
422    #[inline(never)]
423    fn eq(&self, other: &Self) -> bool {
424        match (self, other) {
425            (Self::Direct, Self::Direct) | (Self::Clear, Self::Clear) => true,
426            (Self::Compact { prompt: a }, Self::Compact { prompt: b }) => a == b,
427            (
428                Self::Custom {
429                    carry: ca,
430                    compact: coa,
431                    clear: cla,
432                    compact_prompt: cpa,
433                },
434                Self::Custom {
435                    carry: cb,
436                    compact: cob,
437                    clear: clb,
438                    compact_prompt: cpb,
439                },
440            ) => ca == cb && coa == cob && cla == clb && cpa == cpb,
441            _ => false,
442        }
443    }
444}
445impl Eq for EdgeTransform {}
446
447/// Content transformation type.
448#[derive(Debug, Clone, Serialize, Deserialize)]
449pub enum ContentTransform {
450    /// Copy content as-is
451    Direct,
452
453    /// Summarize content to fit target region
454    Summarize,
455
456    /// Extract specific fields
457    Extract {
458        /// Which fields to keep, by name. Anything else is dropped.
459        fields: Vec<String>,
460    },
461}