Skip to main content

leviath_runtime/pipeline/
transition.rs

1//! Stage transitions: cursors, gates, stuck detection, spawning, and transition choices.
2
3use super::*;
4
5// ─── Stage transition ────────────────────────────────────────────────────────
6
7/// The agent's blueprint (its stage graph), as a component.
8#[derive(Component, Debug, Clone)]
9pub struct AgentBlueprint(pub leviath_core::Blueprint);
10
11/// The index of the agent's current stage within its blueprint.
12#[derive(Component, Debug, Clone, Copy)]
13pub struct StageCursor {
14    /// Current stage index.
15    pub index: usize,
16}
17
18/// Pre-resolved [`StageInference`] for every stage of the agent's blueprint,
19/// built once when the agent is spawned (the CLI resolves each stage's provider,
20/// model, and tool definitions). The transition system swaps the agent's
21/// `StageInference` to the entry for its new stage by index.
22#[derive(Component, Debug, Clone)]
23pub struct StageInferences(pub Vec<StageInference>);
24
25/// How many times the agent has entered each stage (for `max_revisits`).
26#[derive(Component, Debug, Clone, Default)]
27pub struct VisitCounts(pub std::collections::HashMap<String, usize>);
28
29/// Pre-resolved per-stage setup, applied by `enter_stage` when an agent enters
30/// a stage: inference parameters, tool-result routing, whether the stage accepts
31/// live user input, an optional stage-specific context layout, and an optional
32/// system prompt. Built once per stage when the agent is spawned (mirrors
33/// [`StageInferences`]) so stage entry stays synchronous and query-friendly.
34/// (Ported from the imperative loop's per-stage setup in the CLI executor.)
35#[derive(Clone)]
36pub struct StageSetup {
37    /// Per-stage inference config (temperature / max output tokens).
38    pub inference_config: InferenceConfig,
39    /// Optional per-stage tool-result routing.
40    pub routing: Option<leviath_core::ToolResultRouting>,
41    /// Whether the stage delivers live user messages to the agent.
42    pub accepts_messages: bool,
43    /// Optional stage-specific context layout to swap to on entry.
44    pub context_layout: Option<leviath_core::ContextLayout>,
45    /// Optional stage instructions injected as pinned context on entry.
46    pub system_prompt: Option<String>,
47    /// The output shape resolved for this stage (agent, stage, and the
48    /// launching caller's request combined), and whether the stage must produce
49    /// one. `None` means no level asked for a shape.
50    ///
51    /// Held here as well as on [`StageInference`] because the two use it for
52    /// different things: this copy is folded into the stage's system prompt, and
53    /// that copy is what validates a submission at dispatch.
54    pub output: Option<leviath_core::output::OutputSpec>,
55}
56
57/// Pre-resolved [`StageSetup`] for every stage of the agent's blueprint.
58#[derive(Component, Clone)]
59pub struct StageSetups(pub Vec<StageSetup>);
60
61/// The stage completed with multiple candidate edges (or a single edge the stage
62/// may decline); an LLM must choose. Holds the choosable edges for the async
63/// transition-choice system.
64#[derive(Component, Debug, Clone)]
65pub struct AwaitingTransitionChoice(pub Vec<leviath_core::blueprint::TransitionEdge>);
66
67/// The outcome of synchronously resolving a completed stage's transition.
68pub(crate) enum StageResolution {
69    /// No valid outgoing transition - the agent is done.
70    Terminal,
71    /// The stage errored and has no `error` edge - terminate the run as errored,
72    /// preserving the error status the collect system already set.
73    TerminalError,
74    /// The stage DECLARES normal outgoing transitions, but every one of them is
75    /// revisit-exhausted (or targets an unknown stage): the graph dead-ended in
76    /// the middle. Distinct from [`Self::Terminal`] because reporting this as
77    /// `Complete` is how a run silently ended at stage 2 of 5 with no output -
78    /// the resolver routes it down the stage's `error` edge, or fails the run.
79    DeadEnd,
80    /// Advance to this stage index, applying the edge's context transform once
81    /// the edge's gate (if any) is satisfied.
82    /// Boxed rather than inline: `TransitionGate` grows every time a gate
83    /// condition is added, and this variant is otherwise a `usize` and a small
84    /// enum - carrying it by value made every `StageResolution` the size of the
85    /// largest gate, including the five variants that hold nothing.
86    Next(
87        usize,
88        leviath_core::blueprint::EdgeTransform,
89        Option<Box<leviath_core::blueprint::TransitionGate>>,
90    ),
91    /// Multiple candidate edges - an LLM must choose among them.
92    Choose(Vec<leviath_core::blueprint::TransitionEdge>),
93    /// Not a transition after all - put the agent back to work in its current
94    /// stage. Only a stuck interrupt produces this: it fires mid-stage, so when
95    /// its escape edge is no longer available the stage must simply continue
96    /// (falling through would end a stage the agent never said it had finished).
97    Resume,
98}
99
100/// Find the first available edge with the given `condition` (e.g. `Error` or
101/// `MaxIterations`) whose target exists and hasn't exhausted its revisit budget.
102pub(crate) fn find_conditioned_edge_ref<'a>(
103    blueprint: &leviath_core::Blueprint,
104    stage: &'a leviath_core::Stage,
105    visits: &std::collections::HashMap<String, usize>,
106    condition: leviath_core::blueprint::TransitionCondition,
107) -> Option<(usize, &'a leviath_core::blueprint::TransitionEdge)> {
108    let transitions = stage.transitions.as_ref()?;
109    transitions.values().find_map(|edge| {
110        if edge.condition != condition {
111            return None;
112        }
113        let idx = blueprint
114            .stages
115            .iter()
116            .position(|s| s.name == edge.target)?;
117        let within_budget = match blueprint.stages[idx].max_revisits {
118            Some(max) => visits.get(&edge.target).copied().unwrap_or(0) <= max,
119            None => true,
120        };
121        within_budget.then_some((idx, edge))
122    })
123}
124
125/// As [`find_conditioned_edge_ref`], projected to the target index and a cloned
126/// edge transform - what the transition systems need.
127pub(crate) fn find_conditioned_edge(
128    blueprint: &leviath_core::Blueprint,
129    stage: &leviath_core::Stage,
130    visits: &std::collections::HashMap<String, usize>,
131    condition: leviath_core::blueprint::TransitionCondition,
132) -> Option<(usize, leviath_core::blueprint::EdgeTransform)> {
133    find_conditioned_edge_ref(blueprint, stage, visits, condition)
134        .map(|(idx, edge)| (idx, edge.transform.clone()))
135}
136
137/// Resolve the next stage for a normally-completed stage without any LLM call.
138/// (Ported from the synchronous portion of `graph::resolve_transition`; the
139/// `Error`/`MaxIterations` auto-transitions don't apply to a normal completion,
140/// and the LLM-choice case is returned as [`StageResolution::Choose`].)
141pub(crate) fn resolve_transition_sync(
142    blueprint: &leviath_core::Blueprint,
143    stage: &leviath_core::Stage,
144    stage_idx: usize,
145    visits: &std::collections::HashMap<String, usize>,
146) -> StageResolution {
147    use leviath_core::blueprint::TransitionCondition;
148    match &stage.transitions {
149        None => {
150            if stage_idx + 1 < blueprint.stages.len() {
151                // A linear fall-through carries context as-is (Direct), and has
152                // no edge to hang a gate on.
153                StageResolution::Next(
154                    stage_idx + 1,
155                    leviath_core::blueprint::EdgeTransform::Direct,
156                    None,
157                )
158            } else {
159                StageResolution::Terminal
160            }
161        }
162        Some(transitions) => {
163            if transitions.is_empty() {
164                return StageResolution::Terminal;
165            }
166            // Filter edges whose target hasn't exhausted its revisit budget.
167            let available: Vec<&leviath_core::blueprint::TransitionEdge> = transitions
168                .values()
169                .filter(|e| match blueprint.find_stage(&e.target) {
170                    Some(ts) => match ts.max_revisits {
171                        Some(max) => visits.get(&e.target).copied().unwrap_or(0) <= max,
172                        None => true,
173                    },
174                    None => false, // unknown target
175                })
176                .collect();
177            // Only Always/LlmChoice edges are auto/LLM-followable on completion.
178            let choosable: Vec<&leviath_core::blueprint::TransitionEdge> = available
179                .into_iter()
180                .filter(|e| {
181                    matches!(
182                        e.condition,
183                        TransitionCondition::Always | TransitionCondition::LlmChoice
184                    )
185                })
186                .collect();
187            match choosable.len() {
188                0 => {
189                    // No followable edge left. If the stage never declared a
190                    // normal (Always/LlmChoice) edge, this is a legitimate
191                    // terminal whose conditioned edges are alternates. If it
192                    // DID - and they were all filtered out above - the graph
193                    // dead-ended mid-run, which must not read as success.
194                    let declared_normal = transitions.values().any(|e| {
195                        matches!(
196                            e.condition,
197                            TransitionCondition::Always | TransitionCondition::LlmChoice
198                        )
199                    });
200                    if declared_normal {
201                        StageResolution::DeadEnd
202                    } else {
203                        StageResolution::Terminal
204                    }
205                }
206                1 if !stage.allow_complete => {
207                    let idx = blueprint
208                        .stages
209                        .iter()
210                        .position(|s| s.name == choosable[0].target)
211                        .unwrap_or(0);
212                    StageResolution::Next(
213                        idx,
214                        choosable[0].transform.clone(),
215                        choosable[0].gate.clone().map(Box::new),
216                    )
217                }
218                _ => StageResolution::Choose(choosable.into_iter().cloned().collect()),
219            }
220        }
221    }
222}
223
224/// Marks a parent agent held at a `requires_children` stage boundary until all
225/// its spawned sub-agents are terminal. Distinct from `FanOutWaiting` (which is
226/// the fan-out split/merge wait).
227#[derive(Component, Debug, Clone, Copy)]
228pub struct WaitingForChildren;
229
230/// Whether an agent status is terminal (the run/child has finished).
231///
232/// Every collect system consults this before applying an outcome: a run that
233/// reached a terminal state while its work was in flight must stay there, not be
234/// walked back to `Active`/`Complete` by the result landing afterwards.
235pub fn is_terminal_status(status: &AgentStatus) -> bool {
236    matches!(
237        status,
238        AgentStatus::Complete | AgentStatus::Error { .. } | AgentStatus::Cancelled
239    )
240}
241
242/// Decide whether a chosen edge's [gate](leviath_core::blueprint::TransitionGate)
243/// blocks the transition.
244///
245/// The failure this guards against: an agent can read and reason about a
246/// codebase entirely through `shell` and arrive at the review stage having
247/// changed nothing, producing a run
248/// with no output. A `require_modifications` gate keeps it in the stage until it
249/// has actually written something.
250///
251/// The gate passes when any of these hold:
252/// - the stage advertises no file-modifying tool (it could never pass, so gating
253///   it would only burn iterations);
254/// - a modifying tool call succeeded in this stage;
255/// - one was refused by the permission layer (the agent is trying and cannot);
256/// - the gate names a region and that region is non-empty (the durable signal:
257///   per-stage counters don't survive a daemon restart, but regions do).
258///
259/// When the gate's re-run budget is spent it gives up loudly, as
260/// [`GateDecision::Forced`].
261pub(crate) fn gate_blocks(
262    gate: Option<&leviath_core::blueprint::TransitionGate>,
263    stage: &leviath_core::Stage,
264    progress: &StageProgress,
265    window: &ContextWindow,
266) -> GateDecision {
267    let Some(gate) = gate else {
268        return GateDecision::Pass;
269    };
270    // Checked before `require_modifications` and independently of it: an edge
271    // may ask for a changed region without asking for a file write, and a
272    // revise loop usually does exactly that.
273    //
274    // A missing baseline means the gate names a region the window does not
275    // hold. A gate cannot demand an update to something that does not exist,
276    // and blocking on it would strand the run, so it passes.
277    if let Some(name) = &gate.require_region_updated
278        && let (Some(before), Some(region)) = (
279            progress.entry_region_digests.get(name),
280            window.get_region(name),
281        )
282        && *before == region_digest(region)
283    {
284        return spend_gate_attempt(
285            gate,
286            stage,
287            progress,
288            gate.message.clone().unwrap_or_else(|| {
289                format!(
290                    "The `{name}` region is unchanged since this stage began. Whatever sent \
291                     you back here was not answered by repeating the same content - revise it \
292                     before moving on."
293                )
294            }),
295        );
296    }
297    // Checked before `require_modifications` and independently of it: a stage
298    // whose work is a set of items usually has no file write to require.
299    //
300    // A gate naming a region the window does not hold passes rather than
301    // blocking - no amount of work could satisfy it, and stranding the run over
302    // a typo in a region name would be worse than the missing check.
303    if let Some(name) = &gate.require_no_open_items
304        && let Some(region) = window.get_region(name)
305    {
306        let open = region.open_checklist_items();
307        if !open.is_empty() {
308            let cap = gate
309                .max_attempts
310                .unwrap_or(leviath_core::blueprint::DEFAULT_GATE_ATTEMPTS);
311            if progress.gate_reentries >= cap {
312                tracing::warn!(
313                    stage = %stage.name,
314                    open = open.len(),
315                    attempts = cap,
316                    "stage still has open checklist items after re-run attempts; proceeding"
317                );
318                return GateDecision::Forced;
319            }
320            let listed = open
321                .iter()
322                .map(|i| format!("{} {}", i.id, i.text))
323                .collect::<Vec<_>>()
324                .join("; ");
325            return GateDecision::Block(gate.message.clone().unwrap_or_else(|| {
326                format!(
327                    "{} item(s) are still open in `{name}`: {listed}. Finish them, or use \
328                     todo_done to drop the ones that no longer apply, before moving on.",
329                    open.len()
330                )
331            }));
332        }
333    }
334    // Conjunctive, and checked before `require_modifications` so it holds
335    // whatever else the gate asks for. `gate.region` below is one of several
336    // *alternative* ways to satisfy `require_modifications`, which is why it
337    // cannot express "do not leave without writing this" (#371).
338    let missing: Vec<&str> = gate
339        .require_regions
340        .iter()
341        .filter(|name| {
342            match window.get_region(name) {
343                Some(region) => region.content.is_empty(),
344                // Not held by the window at all. `lev validate` refuses a gate
345                // naming a region no stage declares, so this means a layout
346                // moved underneath the edge; blocking would strand the run over
347                // something no amount of work could satisfy.
348                None => {
349                    tracing::warn!(
350                        stage = %stage.name,
351                        region = %name,
352                        "gate requires a region this stage's window does not hold; \
353                         letting the transition through"
354                    );
355                    false
356                }
357            }
358        })
359        .map(String::as_str)
360        .collect();
361    if !missing.is_empty() {
362        let listed = missing.join(", ");
363        return spend_gate_attempt(
364            gate,
365            stage,
366            progress,
367            gate.message.clone().unwrap_or_else(|| {
368                format!(
369                    "This stage is not finished: the `{listed}` region is still empty. \
370                     Write it with context_write before moving on - later stages read \
371                     from it, and there is nothing there yet."
372                )
373            }),
374        );
375    }
376    if !gate.require_modifications {
377        return GateDecision::Pass;
378    }
379    let can_modify = stage.available_tools.iter().any(|t| {
380        let canonical = leviath_tools::canonical_tool_name(t);
381        leviath_core::blueprint::MODIFYING_TOOLS.contains(&canonical)
382            || gate
383                .tools
384                .iter()
385                .any(|extra| leviath_tools::canonical_tool_name(extra) == canonical)
386    });
387    if !can_modify {
388        return GateDecision::Pass;
389    }
390    if progress.modifying_tool_calls > 0 {
391        return GateDecision::Pass;
392    }
393    if progress.blocked_modification_calls > 0 {
394        tracing::warn!(
395            stage = %stage.name,
396            blocked = progress.blocked_modification_calls,
397            "file modifications were denied by policy; letting the gated transition through"
398        );
399        return GateDecision::Pass;
400    }
401    if let Some(region) = &gate.region
402        && window
403            .get_region(region)
404            .is_some_and(|r| !r.content.is_empty())
405    {
406        return GateDecision::Pass;
407    }
408    spend_gate_attempt(
409        gate,
410        stage,
411        progress,
412        gate.message.clone().unwrap_or_else(|| {
413            "No file modifications were recorded in this stage. Changes made through the shell \
414             (sed -i, tee, >, >>) are not tracked by the framework. Re-apply your changes with \
415             edit_file or write_file before moving on."
416                .to_string()
417        }),
418    )
419}
420
421/// Block with `nudge`, or give up and let the edge through once the gate's
422/// re-run budget is spent.
423///
424/// Shared by every gate condition so one blueprint key (`max_attempts`) bounds
425/// all of them: a gate that could block forever would strand the run, which is
426/// worse than letting a questionable transition through with a warning.
427fn spend_gate_attempt(
428    gate: &leviath_core::blueprint::TransitionGate,
429    stage: &leviath_core::Stage,
430    progress: &StageProgress,
431    nudge: String,
432) -> GateDecision {
433    let cap = gate
434        .max_attempts
435        .unwrap_or(leviath_core::blueprint::DEFAULT_GATE_ATTEMPTS);
436    if progress.gate_reentries >= cap {
437        tracing::warn!(
438            stage = %stage.name,
439            attempts = cap,
440            "transition gate still unsatisfied after re-run attempts; proceeding"
441        );
442        return GateDecision::Forced;
443    }
444    GateDecision::Block(nudge)
445}
446
447/// Hold an agent in its current stage after a gate refused the transition: inject
448/// the nudge, count the re-entry, and put it back in front of the model. The
449/// stage is *not* re-entered - `StageProgress` is deliberately preserved so the
450/// stage's `max_iterations` still bounds the loop.
451pub(crate) fn hold_for_gate(
452    entity: Entity,
453    nudge: &str,
454    progress: &mut StageProgress,
455    window: &mut ContextWindow,
456    commands: &mut Commands,
457) {
458    crate::pipeline::response::inject_system_nudge(window, nudge);
459    progress.gate_reentries += 1;
460    commands
461        .entity(entity)
462        .remove::<ResolveTransition>()
463        .remove::<AwaitingTransitionResponse>()
464        .remove::<StageOutcome>()
465        .insert(ReadyToInfer);
466}
467
468/// What `resolve_transition` selects.
469///
470/// `&'static` is bevy's `WorldQuery` convention, not a claim about
471/// lifetimes: the borrow is bound when the query is fetched.
472type ResolveTransitionQuery = (
473    Entity,
474    &'static AgentBlueprint,
475    &'static mut StageCursor,
476    &'static mut AgentState,
477    &'static mut StageProgress,
478    &'static StageInferences,
479    &'static StageSetups,
480    &'static mut VisitCounts,
481    &'static mut ContextWindow,
482    Option<&'static StageOutcome>,
483    Option<&'static mut crate::persistence::RunOutcomeFlags>,
484    Option<&'static crate::persistence::RunMetadata>,
485    Option<&'static crate::persistence::FinalOutput>,
486);
487
488/// Transition-resolution system: for each `ResolveTransition` agent, resolve the
489/// next stage. Terminal ⇒ mark the agent `Complete`. A single/linear target ⇒
490/// enter the new stage (swap its `StageInference`, reset stage progress, bump the
491/// visit count) and loop to `ReadyToInfer`. Multiple candidate edges ⇒ hand off
492/// to the async transition-choice system via `AwaitingTransitionChoice`.
493pub fn resolve_transition(
494    mut agents: Query<ResolveTransitionQuery, With<ResolveTransition>>,
495    sink: Option<Res<crate::host::WorldEventSink>>,
496    mut commands: Commands,
497) {
498    crate::tick_scope::clear();
499    use leviath_core::blueprint::TransitionCondition;
500    for (
501        entity,
502        bp,
503        mut cursor,
504        mut state,
505        mut progress,
506        stage_infs,
507        setups,
508        mut visits,
509        mut window,
510        outcome,
511        mut flags,
512        metadata,
513        submitted,
514    ) in agents.iter_mut()
515    {
516        crate::tick_scope::enter(entity);
517        // A pause that lands while a transition is pending must hold: entering
518        // the next stage flips the agent back to Active. The marker stays put,
519        // so the transition resolves on the first tick after resume.
520        if state.status == AgentStatus::Paused {
521            continue;
522        }
523        let stage = &bp.0.stages[cursor.index];
524        // How the stage ended governs the transition: an error/max-iterations
525        // outcome follows its conditioned edge (e.g. → error_recovery) if present.
526        let resolution = match outcome {
527            // An error/max-iterations edge is never gated: the stage already
528            // failed, and holding it back to demand file changes would strand a
529            // run that can't make any.
530            Some(StageOutcome::Errored(message)) => {
531                match find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::Error) {
532                    Some((i, t)) => {
533                        // Put the error where the recovery stage will read it;
534                        // without an error edge the run terminates and the
535                        // status already carries the message.
536                        note_error(&mut window, &stage.name, message);
537                        StageResolution::Next(i, t, None)
538                    }
539                    None => StageResolution::TerminalError,
540                }
541            }
542            Some(StageOutcome::MaxIterations) => {
543                // Whatever runs next - a max_iterations edge target, the normal
544                // successor, or the transition-choice model - should know the
545                // stage was cut off, not finished.
546                note_max_iterations(&mut window, &stage.name, stage.max_iterations.unwrap_or(0));
547                find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::MaxIterations)
548                    .map(|(i, t)| StageResolution::Next(i, t, None))
549                    .unwrap_or_else(|| {
550                        resolve_transition_sync(&bp.0, stage, cursor.index, &visits.0)
551                    })
552            }
553            Some(StageOutcome::Stuck(_)) => {
554                // A stuck interrupt is mid-stage, not a stage end. If the escape
555                // hatch went away between detection and here (its target spent
556                // its last revisit), resume - falling through to
557                // `resolve_transition_sync` would end a stage the agent never
558                // said it had finished, e.g. shunting `implement` into `review`
559                // with the work half-done.
560                find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::Stuck)
561                    .map(|(i, t)| StageResolution::Next(i, t, None))
562                    .unwrap_or(StageResolution::Resume)
563            }
564            None => resolve_transition_sync(&bp.0, stage, cursor.index, &visits.0),
565        };
566        // A dead end resolves like a stage error: down the `error` edge when one
567        // has budget left (this is what finally makes `error_recovery` reachable
568        // for exhaustion, not just for provider failures), and otherwise the run
569        // FAILS. It used to resolve as `Terminal`, so a wide-researcher whose
570        // deep_dive ran `compare` out of revisits reported `complete` from the
571        // middle of its graph with the output stage still pending and nothing
572        // produced - success indistinguishable from the run that worked.
573        let resolution = match resolution {
574            StageResolution::DeadEnd => {
575                let message = format!(
576                    "stage '{}' dead-ended: every declared transition's target has spent \
577                     its max_revisits budget before an output or terminal stage was reached",
578                    stage.name
579                );
580                // A `dead_end` edge first, then the `error` edge. Both are
581                // escapes from this exact situation, but one was declared *for*
582                // it: an author who wrote both means the specific one to win,
583                // and an `error` edge is also carrying provider failures.
584                let escape =
585                    find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::DeadEnd)
586                        .or_else(|| {
587                            find_conditioned_edge(
588                                &bp.0,
589                                stage,
590                                &visits.0,
591                                TransitionCondition::Error,
592                            )
593                        });
594                match escape {
595                    Some((i, t)) => {
596                        note_error(&mut window, &stage.name, &message);
597                        StageResolution::Next(i, t, None)
598                    }
599                    None => {
600                        state.status = AgentStatus::Error { message };
601                        StageResolution::TerminalError
602                    }
603                }
604            }
605            other => other,
606        };
607        match resolution {
608            StageResolution::Terminal => {
609                // A run that owed a final output and never produced one is not
610                // a success. `require_final_output` forces past the obligation
611                // rather than stranding the run - correct, since a later stage
612                // may still answer - but nothing downgraded the *terminal*
613                // status, so a run ended `complete` with no `final_output` on
614                // disk. `lev result` already exits non-zero there, so the two
615                // disagreed in exactly the case a caller most needs to know
616                // about, and anything polling `status` read it as success.
617                let owed_output = bp.0.stages.iter().any(|s| s.require_output);
618                state.status = match owed_output && submitted.is_none() {
619                    true => AgentStatus::Error {
620                        message: "the run finished without the final output it \
621                                  requires; the stage that owes one never called \
622                                  submit_output"
623                            .to_string(),
624                    },
625                    false => AgentStatus::Complete,
626                };
627                commands
628                    .entity(entity)
629                    .remove::<ResolveTransition>()
630                    .remove::<StageOutcome>();
631            }
632            // `DeadEnd` is in the pattern only for exhaustiveness: the
633            // conversion above always turns it into `Next` or `TerminalError`.
634            StageResolution::TerminalError | StageResolution::DeadEnd => {
635                // Status was set to Error by the collect system (or by the
636                // dead-end conversion above); just stop.
637                commands
638                    .entity(entity)
639                    .remove::<ResolveTransition>()
640                    .remove::<StageOutcome>();
641            }
642            StageResolution::Next(idx, transform, gate) => {
643                // Check the edge's gate BEFORE the transform runs: the transform
644                // compacts/clears regions, and a held stage must keep its context.
645                let gate = outcome.is_none().then_some(gate).flatten();
646                match gate_blocks(gate.as_deref(), stage, &progress, &window) {
647                    GateDecision::Block(nudge) => {
648                        hold_for_gate(entity, &nudge, &mut progress, &mut window, &mut commands);
649                        continue;
650                    }
651                    GateDecision::Forced => {
652                        if let Some(flags) = flags.as_mut() {
653                            flags.0.gates_forced += 1;
654                        }
655                    }
656                    GateDecision::Pass => {}
657                }
658                // Reshape the outgoing context per the edge transform before the
659                // new stage's layout/prompt setup.
660                let to_compact = apply_edge_transform(&mut window, &transform);
661                let setup = &setups.0[idx];
662                let from = state.current_stage.clone();
663                match enter_stage(
664                    idx,
665                    &bp.0,
666                    setup,
667                    StageEntry {
668                        cursor: &mut cursor,
669                        state: &mut state,
670                        progress: &mut progress,
671                        visits: &mut visits,
672                        window: &mut window,
673                    },
674                ) {
675                    Ok(visit) => {
676                        // Entering a stage is active work; clears a prior error
677                        // status when recovering down an `error` edge.
678                        state.status = AgentStatus::Active;
679                        let name = bp.0.stages[idx].name.clone();
680                        emit_stage_transition(&sink, metadata, &state.agent_id, from, &name, visit);
681                        let mut ec = commands.entity(entity);
682                        ec.remove::<ResolveTransition>().remove::<StageOutcome>();
683                        attach_stage_components(ec, stage_infs.0[idx].clone(), setup, idx, name);
684                        if !to_compact.is_empty() {
685                            commands
686                                .entity(entity)
687                                .insert(PendingEdgeCompact(to_compact));
688                        }
689                    }
690                    Err(message) => {
691                        state.status = AgentStatus::Error { message };
692                        commands
693                            .entity(entity)
694                            .remove::<ResolveTransition>()
695                            .remove::<StageOutcome>();
696                    }
697                }
698            }
699            StageResolution::Choose(edges) => {
700                commands
701                    .entity(entity)
702                    .remove::<ResolveTransition>()
703                    .remove::<StageOutcome>()
704                    .insert(AwaitingTransitionChoice(edges));
705            }
706            StageResolution::Resume => {
707                // `StageProgress::stuck_fired` is already set, so this cannot
708                // ping-pong with `detect_stuck_stage`; the stage now simply runs
709                // out to its ordinary `max_iterations`.
710                commands
711                    .entity(entity)
712                    .remove::<ResolveTransition>()
713                    .remove::<StageOutcome>()
714                    .insert(ReadyToInfer);
715            }
716        }
717    }
718}
719
720/// Enter the stage at `idx`: update the cursor + current-stage name, reset
721/// per-stage progress, bump the visit count, set `accepts_messages`, and apply the
722/// stage's context setup - swap to its layout (if any) and (re)inject its system
723/// prompt as pinned `[Stage instructions: …]` context, replacing the previous
724/// stage's. (Ported from the imperative loop's per-stage setup.)
725///
726/// Returns `Err` only when the system prompt doesn't fit its region - the same
727/// hard failure the imperative loop raises; the caller marks the agent `Error`.
728/// `Ok` carries the stage's updated visit count (this entry included), which the
729/// transition systems stamp into the [`StageTransition`](crate::host::WorldEvent)
730/// event.
731/// The per-agent components entering a stage rewrites.
732///
733/// Borrowed together because entering a stage is one atomic edit across all
734/// five: the cursor moves, per-stage progress resets, the visit count bumps,
735/// `accepts_messages` is set from the new stage's mode, and the window is
736/// re-laid-out. Doing them through five separate queries over the same entity
737/// would cost five passes to say one thing.
738pub(crate) struct StageEntry<'a> {
739    /// Where in the blueprint the agent is.
740    pub cursor: &'a mut StageCursor,
741    /// The agent's live state.
742    pub state: &'a mut AgentState,
743    /// Per-stage counters, reset on entry.
744    pub progress: &'a mut StageProgress,
745    /// How many times each stage has been entered.
746    pub visits: &'a mut VisitCounts,
747    /// The context window, re-laid-out for the new stage.
748    pub window: &'a mut ContextWindow,
749}
750
751pub(crate) fn enter_stage(
752    idx: usize,
753    blueprint: &leviath_core::Blueprint,
754    setup: &StageSetup,
755    entry: StageEntry<'_>,
756) -> Result<usize, String> {
757    let StageEntry {
758        cursor,
759        state,
760        progress,
761        visits,
762        window,
763    } = entry;
764    cursor.index = idx;
765    let name = blueprint.stages[idx].name.clone();
766    state.current_stage = name.clone();
767    state.accepts_messages = setup.accepts_messages;
768    *progress = StageProgress::default();
769    let visit = visits.0.entry(name).or_insert(0);
770    *visit += 1;
771    let visit = *visit;
772
773    let result = apply_stage_context(setup, window).map(|()| visit);
774    // After the layout swap, so the digest is of the region this stage will
775    // actually work on rather than the one the previous stage left behind.
776    progress.entry_region_digests = watched_region_digests(&blueprint.stages[idx], window);
777    result
778}
779
780/// Content digests of the regions this stage's outgoing gates watch.
781///
782/// Keyed by region name and taken at stage entry, so [`gate_blocks`] can ask
783/// whether *this pass* changed anything rather than whether the region merely
784/// has content. A region a gate names but the window does not hold is absent
785/// here, and an absent digest reads as "no baseline", which the gate treats as
786/// changed - a gate cannot demand an update to something that does not exist.
787pub(crate) fn watched_region_digests(
788    stage: &leviath_core::Stage,
789    window: &ContextWindow,
790) -> std::collections::HashMap<String, u64> {
791    let mut digests = std::collections::HashMap::new();
792    let Some(transitions) = &stage.transitions else {
793        return digests;
794    };
795    for edge in transitions.values() {
796        let Some(name) = edge
797            .gate
798            .as_ref()
799            .and_then(|g| g.require_region_updated.as_ref())
800        else {
801            continue;
802        };
803        if let Some(region) = window.get_region(name) {
804            digests.insert(name.clone(), region_digest(region));
805        }
806    }
807    digests
808}
809
810/// A hash of everything a region currently holds.
811///
812/// Content only: token counts and timestamps would make an unchanged region
813/// look changed, which is the failure this gate exists to prevent.
814pub(crate) fn region_digest(region: &leviath_core::Region) -> u64 {
815    use std::hash::{Hash, Hasher};
816
817    let mut hasher = std::collections::hash_map::DefaultHasher::new();
818    for entry in &region.content {
819        entry.content.hash(&mut hasher);
820    }
821    hasher.finish()
822}
823
824/// Push a [`StageTransition`](crate::host::WorldEvent::StageTransition) event
825/// into the world's event stream. A no-op in worlds that don't stream (no
826/// [`WorldEventSink`](crate::host::WorldEventSink) resource) and for bare
827/// agents without run metadata.
828pub(crate) fn emit_stage_transition(
829    sink: &Option<Res<crate::host::WorldEventSink>>,
830    metadata: Option<&crate::persistence::RunMetadata>,
831    agent_id: &str,
832    from: String,
833    to: &str,
834    iteration: usize,
835) {
836    if let (Some(sink), Some(md)) = (sink.as_ref(), metadata) {
837        let _ = sink.0.send(crate::host::WorldEvent::StageTransition {
838            run_id: md.run_id.clone(),
839            agent_id: agent_id.to_string(),
840            from,
841            to: to.to_string(),
842            iteration,
843        });
844    }
845}
846
847/// Which region a stage's instructions are written into.
848///
849/// A declared [`STAGE_INSTRUCTIONS_REGION`] when there is one, and it is moved
850/// to the end of the region list so it renders after every other pinned block.
851/// That ordering is the point: pinned regions carry `CacheHint::Always` and are
852/// assembled in list order, so instructions sitting anywhere but last put a
853/// per-stage string *in front of* the shared prefix - and changing stage then
854/// rewrites the head of the prefix and invalidates everything behind it. Last
855/// means the bytes in front stay identical across a transition.
856///
857/// Otherwise the historical target: the first pinned region, or `conversation`
858/// when a layout declares no pinned region at all.
859///
860/// [`STAGE_INSTRUCTIONS_REGION`]: leviath_core::layout::STAGE_INSTRUCTIONS_REGION
861fn stage_instructions_target(window: &mut ContextWindow) -> String {
862    let declared = leviath_core::layout::STAGE_INSTRUCTIONS_REGION;
863    if let Some(at) = window.regions.iter().position(|r| r.name == declared) {
864        if at + 1 < window.regions.len() {
865            let region = window.regions.remove(at);
866            window.regions.push(region);
867        }
868        return declared.to_string();
869    }
870    window
871        .regions
872        .iter()
873        .find(|r| matches!(r.kind, leviath_core::RegionKind::Pinned))
874        .map(|r| r.name.clone())
875        .unwrap_or_else(|| "conversation".to_string())
876}
877
878/// Apply a stage's context setup to a window: swap to the stage's layout (if any)
879/// and (re)inject its system prompt as pinned `[Stage instructions: …]` context,
880/// clearing any previous stage's first. Returns `Err` only when the prompt
881/// doesn't fit its region. Shared by [`enter_stage`] (transitions) and
882/// [`build_agent`] (the first stage, at spawn).
883pub(crate) fn apply_stage_context(
884    setup: &StageSetup,
885    window: &mut ContextWindow,
886) -> Result<(), String> {
887    if let Some(layout) = &setup.context_layout {
888        crate::context_setup::apply_layout(window, layout);
889    }
890
891    let target = stage_instructions_target(window);
892    if let Some(region) = window.regions.iter_mut().find(|r| r.name == target) {
893        if target == leviath_core::layout::STAGE_INSTRUCTIONS_REGION {
894            // The whole region is ours, so the previous stage's prompt goes by
895            // emptying it. The fallback below cannot do that - it shares a
896            // region with the author's own content - and has to identify its
897            // own entries by their prefix, which silently removes any author
898            // content that happens to start with the same words.
899            region.clear();
900        } else {
901            region.remove_entries_by_prefix("[Stage instructions:");
902        }
903    }
904    if let Some(sp) = &setup.system_prompt {
905        let content = format!("[Stage instructions: {sp}]");
906        let tokens = leviath_core::estimate_tokens(&content);
907        window
908            .add_to_region(&target, content, tokens)
909            .map_err(|e| {
910                format!(
911                    "stage system prompt (~{tokens} tokens) does not fit context region \
912                 '{target}': {e}. Increase that region's max_tokens (or shorten the prompt)."
913                )
914            })?;
915    }
916    Ok(())
917}
918
919/// Finish a successful stage entry: attach the new stage's inference config,
920/// tool-result routing (present ⇒ insert, absent ⇒ clear the stale one), and its
921/// pre-resolved [`StageInference`], then mark the agent `ReadyToInfer`. Shared by
922/// both the synchronous and LLM-choice transition paths.
923pub(crate) fn attach_stage_components(
924    mut entity: bevy_ecs::system::EntityCommands,
925    stage_inf: StageInference,
926    setup: &StageSetup,
927    stage_index: usize,
928    stage_name: String,
929) {
930    entity
931        .insert(stage_inf)
932        .insert(setup.inference_config.clone())
933        .insert(StageJustEntered {
934            index: stage_index,
935            name: stage_name,
936        })
937        // A fresh stage re-arms its interaction points and its required-region
938        // and required-output gates: each stage owes its own, and gets its own
939        // budget of attempts to produce it.
940        .remove::<crate::interaction_points::InteractionPointCursor>()
941        .remove::<crate::interaction_points::InteractionPointRounds>()
942        .remove::<RequiredReentries>()
943        .remove::<OutputReentries>()
944        .insert(ReadyToInfer);
945    match &setup.routing {
946        Some(routing) => {
947            entity.insert(crate::components::ToolResultRoutingComponent {
948                routing: routing.clone(),
949            });
950        }
951        None => {
952            entity.remove::<crate::components::ToolResultRoutingComponent>();
953        }
954    }
955}
956
957/// Force an agent into the stage at `target_idx` via direct world access - the
958/// same effect as [`resolve_transition`]'s linear-`Next` arm, but callable from
959/// an exclusive system (e.g. the fan-out collector jumping to its `merge_stage`)
960/// or the daemon (spawning a fan-out worker directly at its worker stage) where no
961/// [`Commands`] queue is available. On a system-prompt overflow the agent is
962/// marked `Error`, mirroring the transition systems.
963pub fn force_transition(world: &mut World, agent: crate::world::AgentId, target_idx: usize) {
964    // Moving the wrong agent to a stage is how a run silently ends up somewhere
965    // its blueprint never sent it.
966    let Some(entity) = agent.resolve_in(world) else {
967        return;
968    };
969    // Phase 1 (scoped borrow): mutate the agent's own state via `enter_stage`,
970    // returning the components Phase 2 must insert - or `None` if the agent is
971    // gone or its system prompt overflowed (already marked `Error` in-place).
972    let attach: Option<(StageInference, StageSetup, String)> = {
973        let mut q = world.query::<(
974            &AgentBlueprint,
975            &mut StageCursor,
976            &mut AgentState,
977            &mut StageProgress,
978            &StageInferences,
979            &StageSetups,
980            &mut VisitCounts,
981            &mut ContextWindow,
982        )>();
983        let Ok((
984            bp,
985            mut cursor,
986            mut state,
987            mut progress,
988            stage_infs,
989            setups,
990            mut visits,
991            mut window,
992        )) = q.get_mut(world, entity)
993        else {
994            return; // agent despawned
995        };
996        let setup = setups.0[target_idx].clone();
997        let stage_inf = stage_infs.0[target_idx].clone();
998        let name = bp.0.stages[target_idx].name.clone();
999        let bp = bp.0.clone();
1000        match enter_stage(
1001            target_idx,
1002            &bp,
1003            &setup,
1004            StageEntry {
1005                cursor: &mut cursor,
1006                state: &mut state,
1007                progress: &mut progress,
1008                visits: &mut visits,
1009                window: &mut window,
1010            },
1011        ) {
1012            Ok(_) => Some((stage_inf, setup, name)),
1013            Err(message) => {
1014                state.status = AgentStatus::Error { message };
1015                None
1016            }
1017        }
1018    };
1019
1020    // Phase 2 (borrow released): attach the new stage's components directly.
1021    let Some((stage_inf, setup, name)) = attach else {
1022        return;
1023    };
1024    let mut em = world.entity_mut(entity);
1025    em.insert(stage_inf)
1026        .insert(setup.inference_config.clone())
1027        .insert(StageJustEntered {
1028            index: target_idx,
1029            name,
1030        })
1031        .insert(ReadyToInfer);
1032    match &setup.routing {
1033        Some(routing) => {
1034            em.insert(crate::components::ToolResultRoutingComponent {
1035                routing: routing.clone(),
1036            });
1037        }
1038        None => {
1039            em.remove::<crate::components::ToolResultRoutingComponent>();
1040        }
1041    }
1042}