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}
48
49/// Pre-resolved [`StageSetup`] for every stage of the agent's blueprint.
50#[derive(Component, Clone)]
51pub struct StageSetups(pub Vec<StageSetup>);
52
53/// The stage completed with multiple candidate edges (or a single edge the stage
54/// may decline); an LLM must choose. Holds the choosable edges for the async
55/// transition-choice system.
56#[derive(Component, Debug, Clone)]
57pub struct AwaitingTransitionChoice(pub Vec<leviath_core::blueprint::TransitionEdge>);
58
59/// The outcome of synchronously resolving a completed stage's transition.
60pub(crate) enum StageResolution {
61    /// No valid outgoing transition - the agent is done.
62    Terminal,
63    /// The stage errored and has no `error` edge - terminate the run as errored,
64    /// preserving the error status the collect system already set.
65    TerminalError,
66    /// Advance to this stage index, applying the edge's context transform once
67    /// the edge's gate (if any) is satisfied.
68    Next(
69        usize,
70        leviath_core::blueprint::EdgeTransform,
71        Option<leviath_core::blueprint::TransitionGate>,
72    ),
73    /// Multiple candidate edges - an LLM must choose among them.
74    Choose(Vec<leviath_core::blueprint::TransitionEdge>),
75    /// Not a transition after all - put the agent back to work in its current
76    /// stage. Only a stuck interrupt produces this: it fires mid-stage, so when
77    /// its escape edge is no longer available the stage must simply continue
78    /// (falling through would end a stage the agent never said it had finished).
79    Resume,
80}
81
82/// Find the first available edge with the given `condition` (e.g. `Error` or
83/// `MaxIterations`) whose target exists and hasn't exhausted its revisit budget.
84pub(crate) fn find_conditioned_edge_ref<'a>(
85    blueprint: &leviath_core::Blueprint,
86    stage: &'a leviath_core::Stage,
87    visits: &std::collections::HashMap<String, usize>,
88    condition: leviath_core::blueprint::TransitionCondition,
89) -> Option<(usize, &'a leviath_core::blueprint::TransitionEdge)> {
90    let transitions = stage.transitions.as_ref()?;
91    transitions.values().find_map(|edge| {
92        if edge.condition != condition {
93            return None;
94        }
95        let idx = blueprint
96            .stages
97            .iter()
98            .position(|s| s.name == edge.target)?;
99        let within_budget = match blueprint.stages[idx].max_revisits {
100            Some(max) => visits.get(&edge.target).copied().unwrap_or(0) <= max,
101            None => true,
102        };
103        within_budget.then_some((idx, edge))
104    })
105}
106
107/// As [`find_conditioned_edge_ref`], projected to the target index and a cloned
108/// edge transform - what the transition systems need.
109pub(crate) fn find_conditioned_edge(
110    blueprint: &leviath_core::Blueprint,
111    stage: &leviath_core::Stage,
112    visits: &std::collections::HashMap<String, usize>,
113    condition: leviath_core::blueprint::TransitionCondition,
114) -> Option<(usize, leviath_core::blueprint::EdgeTransform)> {
115    find_conditioned_edge_ref(blueprint, stage, visits, condition)
116        .map(|(idx, edge)| (idx, edge.transform.clone()))
117}
118
119/// How often (in per-stage iterations) [`check_workspace_health`] stats the
120/// agent's working directory. One `metadata` call every few iterations is far
121/// cheaper than the tool failures it replaces.
122pub const WORKSPACE_CHECK_INTERVAL: usize = 5;
123
124/// Workspace health guard: fail a run whose working directory has disappeared.
125///
126/// The motivating failure: an external harness deleted the workspace out from
127/// under running agents, which then spent every remaining iteration collecting
128/// `No such file or directory` from their tools - 16-17 of them in the observed
129/// runs - with no way back. Nothing can recreate a deleted checkout from inside
130/// the agent, so this stops immediately with a message that names the real
131/// problem, instead of routing to error recovery to flail more cheaply.
132#[allow(clippy::type_complexity)]
133pub fn check_workspace_health(
134    mut agents: Query<
135        (
136            Entity,
137            &RunMetadata,
138            &StageProgress,
139            &mut AgentState,
140            Option<&mut crate::persistence::RunOutcomeFlags>,
141        ),
142        With<ReadyToInfer>,
143    >,
144    mut commands: Commands,
145) {
146    crate::tick_scope::clear();
147    for (entity, md, progress, mut state, flags) in agents.iter_mut() {
148        crate::tick_scope::enter(entity);
149        if state.status != AgentStatus::Active {
150            continue;
151        }
152        if progress.iterations % WORKSPACE_CHECK_INTERVAL != 0 {
153            continue;
154        }
155        if std::fs::metadata(&md.workdir).is_ok_and(|m| m.is_dir()) {
156            continue;
157        }
158        tracing::error!(
159            run_id = %md.run_id,
160            workdir = %md.workdir,
161            "working directory is gone; failing the run"
162        );
163        state.status = AgentStatus::Error {
164            message: format!("workspace '{}' is no longer accessible", md.workdir),
165        };
166        if let Some(mut flags) = flags {
167            flags.0.workspace_lost = true;
168        }
169        commands.entity(entity).remove::<ReadyToInfer>();
170    }
171}
172
173/// Max-iterations guard: for each `ReadyToInfer` agent whose per-stage inference
174/// count has reached the stage's `max_iterations`, end the stage (routing to a
175/// `max_iterations` edge if one exists, else a normal transition) instead of
176/// running another inference. Ported from the imperative `run_autonomous` cap.
177#[allow(clippy::type_complexity)]
178pub fn enforce_max_iterations(
179    mut agents: Query<
180        (
181            Entity,
182            &AgentState,
183            &AgentBlueprint,
184            &StageCursor,
185            &StageProgress,
186            Option<&mut crate::persistence::RunOutcomeFlags>,
187        ),
188        With<ReadyToInfer>,
189    >,
190    mut commands: Commands,
191) {
192    crate::tick_scope::clear();
193    for (entity, state, bp, cursor, progress, flags) in agents.iter_mut() {
194        crate::tick_scope::enter(entity);
195        if state.status != AgentStatus::Active {
196            continue;
197        }
198        let max = bp.0.stages[cursor.index].max_iterations.unwrap_or(0);
199        if max > 0 && progress.iterations >= max {
200            // Record it on the run: a stage that ran out of iterations is one of
201            // the ways a run ends up with nothing to show (issue #107).
202            if let Some(mut flags) = flags {
203                flags.0.max_iterations_hit += 1;
204            }
205            commands
206                .entity(entity)
207                .remove::<ReadyToInfer>()
208                .insert(ResolveTransition)
209                .insert(StageOutcome::MaxIterations);
210        }
211    }
212}
213
214/// The context region a stuck diagnosis is written to when the blueprint declares
215/// one. Pinned by convention, so the note survives the edge transform into the
216/// stage that has to act on it.
217pub(crate) const STUCK_REPORT_REGION: &str = "stuck_report";
218
219/// The context region an abnormal-ending note (inference error, iteration cap)
220/// is written to when the blueprint declares one. Pinned by convention, like
221/// [`STUCK_REPORT_REGION`], so the note survives the edge transform into the
222/// stage that has to act on it.
223pub(crate) const ERROR_REPORT_REGION: &str = "error_report";
224
225/// The per-stage numbers a [`StuckConfig`](leviath_core::blueprint::StuckConfig)
226/// is evaluated against.
227#[derive(Debug, Clone, Default, PartialEq, Eq)]
228pub(crate) struct StuckMetrics {
229    /// Inferences run in this stage.
230    pub iterations: usize,
231    /// Wall-clock seconds since the stage clock was stamped.
232    pub elapsed_secs: u64,
233    /// Total tool calls made in this stage.
234    pub tool_calls: usize,
235    /// The most-churned path this stage and how many write/edit calls it took.
236    pub hottest_edit: Option<(String, usize)>,
237}
238
239/// Evaluate a stage's metrics against a stuck edge's thresholds, returning a
240/// human-readable reason for the first one that trips.
241///
242/// Ordered most-diagnostic first: file churn names the actual mistake, while
243/// iterations, tool calls and wall clock are only symptoms of it.
244pub(crate) fn detect_stuck(
245    cfg: &leviath_core::blueprint::StuckConfig,
246    m: &StuckMetrics,
247) -> Option<String> {
248    if let (Some(limit), Some((path, hits))) = (cfg.after_same_file_edits, m.hottest_edit.as_ref())
249        && *hits >= limit
250    {
251        return Some(format!(
252            "you have written or edited '{path}' {hits} times in this stage without \
253             resolving the task - the problem is very likely not in that file"
254        ));
255    }
256    if let Some(limit) = cfg.after_iterations
257        && m.iterations >= limit
258    {
259        return Some(format!(
260            "you have run {} inference turns in this stage without finishing it",
261            m.iterations
262        ));
263    }
264    if let Some(limit) = cfg.after_tool_calls
265        && m.tool_calls >= limit
266    {
267        return Some(format!(
268            "you have made {} tool calls in this stage without finishing it",
269            m.tool_calls
270        ));
271    }
272    if let Some(limit) = cfg.after_minutes
273        && m.elapsed_secs >= limit as u64 * 60
274    {
275        return Some(format!(
276            "you have spent {} minutes in this stage without finishing it",
277            m.elapsed_secs / 60
278        ));
279    }
280    None
281}
282
283/// The single most-edited path in a stage. Ties break on path name so the
284/// diagnosis is deterministic regardless of `HashMap` iteration order.
285pub(crate) fn hottest_edit(
286    edits: &std::collections::HashMap<String, usize>,
287) -> Option<(String, usize)> {
288    edits
289        .iter()
290        .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
291        .map(|(path, n)| (path.clone(), *n))
292}
293
294/// Write the "why you're stuck" note where the next stage will read it: the
295/// blueprint's `stuck_report` region when it declares one, else `conversation`
296/// (which every blueprint is required to declare). Best-effort, like the
297/// repetition nudge - an overflowing region silently drops the note.
298pub(crate) fn note_stuck(window: &mut ContextWindow, stage: &str, reason: &str) {
299    let region = if window.get_region(STUCK_REPORT_REGION).is_some() {
300        STUCK_REPORT_REGION
301    } else {
302        "conversation"
303    };
304    let content = format!(
305        "[Stuck detected in stage '{stage}'] {reason}. Stop repeating what you have been \
306         doing. Re-read the original task, separate what you have actually verified from \
307         what you assumed, and take a different approach - including reverting changes \
308         that made things worse."
309    );
310    let tokens = leviath_core::estimate_tokens(&content);
311    let _ = window.add_to_region(region, content, tokens);
312}
313
314/// Write an abnormal-ending note where the next stage will read it: the
315/// blueprint's `error_report` region when it declares one, else `conversation`.
316/// Best-effort, like [`note_stuck`] - an overflowing region silently drops it.
317fn note_abnormal_ending(window: &mut ContextWindow, content: String) {
318    let region = if window.get_region(ERROR_REPORT_REGION).is_some() {
319        ERROR_REPORT_REGION
320    } else {
321        "conversation"
322    };
323    let tokens = leviath_core::estimate_tokens(&content);
324    let _ = window.add_to_region(region, content, tokens);
325}
326
327/// Write the inference error that ended a stage into context, so the recovery
328/// stage an `error` edge routes to starts out knowing what failed instead of
329/// being told to diagnose an error it cannot see.
330pub(crate) fn note_error(window: &mut ContextWindow, stage: &str, message: &str) {
331    note_abnormal_ending(
332        window,
333        format!(
334            "[Inference error in stage '{stage}'] {message}. Diagnose this failure from \
335             the error text above before retrying or working around it."
336        ),
337    );
338}
339
340/// Write an iteration-cap note into context when a stage runs out of
341/// iterations, so whatever stage runs next - a `max_iterations` edge target or
342/// the normal successor - knows the work was cut off rather than finished.
343pub(crate) fn note_max_iterations(window: &mut ContextWindow, stage: &str, cap: usize) {
344    note_abnormal_ending(
345        window,
346        format!(
347            "[Stage '{stage}' hit its iteration cap ({cap})] The stage was cut off before \
348             it declared completion - treat its output as possibly incomplete and verify \
349             it before building on it."
350        ),
351    );
352}
353
354/// Stuck-detection guard: for each `ReadyToInfer` agent whose current stage
355/// declares a `stuck`-conditioned edge, evaluate that edge's thresholds against
356/// the stage's progress. When one trips, write the diagnosis into context and
357/// route the agent down the stuck edge (`ResolveTransition` +
358/// [`StageOutcome::Stuck`]) instead of running another inference.
359///
360/// Fires at most once per stage entry (`StageProgress::stuck_fired`, cleared by
361/// `enter_stage`'s progress reset), and never once the stuck edge's target has
362/// spent its `max_revisits` - an exhausted escape hatch must leave the agent
363/// working the stage normally (its `max_iterations` is still the hard cap) rather
364/// than kick it out down an unrelated edge.
365#[allow(clippy::type_complexity)]
366pub fn detect_stuck_stage(
367    mut agents: Query<
368        (
369            Entity,
370            &AgentState,
371            &AgentBlueprint,
372            &StageCursor,
373            &mut StageProgress,
374            &VisitCounts,
375            &mut ContextWindow,
376            Option<&mut StageIoBuffer>,
377        ),
378        With<ReadyToInfer>,
379    >,
380    mut commands: Commands,
381) {
382    use leviath_core::blueprint::TransitionCondition;
383    let now = chrono::Utc::now().timestamp();
384    crate::tick_scope::clear();
385    for (entity, state, bp, cursor, mut progress, visits, mut window, buffer) in agents.iter_mut() {
386        crate::tick_scope::enter(entity);
387        if state.status != AgentStatus::Active || progress.stuck_fired {
388            continue; // paused/waiting, or this stage already used its escape
389        }
390        let stage = &bp.0.stages[cursor.index];
391        let Some(cfg) =
392            find_conditioned_edge_ref(&bp.0, stage, &visits.0, TransitionCondition::Stuck)
393                .and_then(|(_, edge)| edge.stuck)
394        else {
395            continue; // no stuck edge here, or its escape hatch is spent
396        };
397        // Lazy stamp: one place covers spawn, `enter_stage`, `force_transition`
398        // and snapshot restore, and it measures time the agent was actually
399        // runnable rather than time spent queued behind other work.
400        let started = *progress.stage_started_at.get_or_insert(now);
401        let metrics = StuckMetrics {
402            iterations: progress.iterations,
403            elapsed_secs: (now - started).max(0) as u64,
404            tool_calls: progress.total_tool_calls,
405            hottest_edit: hottest_edit(&progress.edits_by_path),
406        };
407        let Some(reason) = detect_stuck(&cfg, &metrics) else {
408            continue;
409        };
410        progress.stuck_fired = true;
411        note_stuck(&mut window, &stage.name, &reason);
412        if let Some(mut buffer) = buffer {
413            buffer
414                .logs
415                .push((cursor.index, format!("[stuck] {reason}")));
416        }
417        commands
418            .entity(entity)
419            .remove::<ReadyToInfer>()
420            .insert(ResolveTransition)
421            .insert(StageOutcome::Stuck(reason));
422    }
423}
424
425/// Resolve the next stage for a normally-completed stage without any LLM call.
426/// (Ported from the synchronous portion of `graph::resolve_transition`; the
427/// `Error`/`MaxIterations` auto-transitions don't apply to a normal completion,
428/// and the LLM-choice case is returned as [`StageResolution::Choose`].)
429pub(crate) fn resolve_transition_sync(
430    blueprint: &leviath_core::Blueprint,
431    stage: &leviath_core::Stage,
432    stage_idx: usize,
433    visits: &std::collections::HashMap<String, usize>,
434) -> StageResolution {
435    use leviath_core::blueprint::TransitionCondition;
436    match &stage.transitions {
437        None => {
438            if stage_idx + 1 < blueprint.stages.len() {
439                // A linear fall-through carries context as-is (Direct), and has
440                // no edge to hang a gate on.
441                StageResolution::Next(
442                    stage_idx + 1,
443                    leviath_core::blueprint::EdgeTransform::Direct,
444                    None,
445                )
446            } else {
447                StageResolution::Terminal
448            }
449        }
450        Some(transitions) => {
451            if transitions.is_empty() {
452                return StageResolution::Terminal;
453            }
454            // Filter edges whose target hasn't exhausted its revisit budget.
455            let available: Vec<&leviath_core::blueprint::TransitionEdge> = transitions
456                .values()
457                .filter(|e| match blueprint.find_stage(&e.target) {
458                    Some(ts) => match ts.max_revisits {
459                        Some(max) => visits.get(&e.target).copied().unwrap_or(0) <= max,
460                        None => true,
461                    },
462                    None => false, // unknown target
463                })
464                .collect();
465            // Only Always/LlmChoice edges are auto/LLM-followable on completion.
466            let choosable: Vec<&leviath_core::blueprint::TransitionEdge> = available
467                .into_iter()
468                .filter(|e| {
469                    matches!(
470                        e.condition,
471                        TransitionCondition::Always | TransitionCondition::LlmChoice
472                    )
473                })
474                .collect();
475            match choosable.len() {
476                0 => StageResolution::Terminal,
477                1 if !stage.allow_complete => {
478                    let idx = blueprint
479                        .stages
480                        .iter()
481                        .position(|s| s.name == choosable[0].target)
482                        .unwrap_or(0);
483                    StageResolution::Next(
484                        idx,
485                        choosable[0].transform.clone(),
486                        choosable[0].gate.clone(),
487                    )
488                }
489                _ => StageResolution::Choose(choosable.into_iter().cloned().collect()),
490            }
491        }
492    }
493}
494
495/// Marks a parent agent held at a `requires_children` stage boundary until all
496/// its spawned sub-agents are terminal. Distinct from `FanOutWaiting` (which is
497/// the fan-out split/merge wait).
498#[derive(Component, Debug, Clone, Copy)]
499pub struct WaitingForChildren;
500
501/// Whether an agent status is terminal (the run/child has finished).
502///
503/// Every collect system consults this before applying an outcome: a run that
504/// reached a terminal state while its work was in flight must stay there, not be
505/// walked back to `Active`/`Complete` by the result landing afterwards.
506pub fn is_terminal_status(status: &AgentStatus) -> bool {
507    matches!(
508        status,
509        AgentStatus::Complete | AgentStatus::Error { .. } | AgentStatus::Cancelled
510    )
511}
512
513/// `requires_children` gate (exclusive, mirrors the fan-out wait): a stage marked
514/// `requires_children` may not transition while any of the agent's spawned
515/// sub-agents ([`SubAgentChildren`](crate::components::SubAgentChildren)) are
516/// still running - the parent is held `Waiting` (`WaitingForChildren`) and
517/// resumes (re-inserting `ResolveTransition`, back to `Active`) once every child
518/// is terminal.
519pub fn gate_requires_children(world: &mut World) {
520    crate::tick_scope::clear();
521    use crate::components::SubAgentChildren;
522
523    // Hold: transitioning agents whose stage requires children that aren't done.
524    // `&AgentState` in the query guarantees the later `.expect()` never fires.
525    let mut candidates: Vec<(Entity, Vec<Entity>)> = Vec::new();
526    {
527        let mut q = world.query_filtered::<(
528            Entity,
529            &AgentBlueprint,
530            &StageCursor,
531            &SubAgentChildren,
532            &AgentState,
533        ), With<ResolveTransition>>();
534        for (e, bp, cursor, children, _) in q.iter(world) {
535            if bp.0.stages[cursor.index].requires_children {
536                candidates.push((e, children.children.clone()));
537            }
538        }
539    }
540    for (entity, children) in candidates {
541        crate::tick_scope::enter(entity);
542        let pending = children.iter().any(|&c| {
543            world
544                .get::<AgentState>(c)
545                .is_some_and(|s| !is_terminal_status(&s.status))
546        });
547        if pending {
548            world
549                .entity_mut(entity)
550                .remove::<ResolveTransition>()
551                .insert(WaitingForChildren);
552            world
553                .get_mut::<AgentState>(entity)
554                .expect("held agent has AgentState")
555                .status = AgentStatus::Waiting;
556        }
557    }
558
559    // Resume: held agents whose children have all finished.
560    crate::tick_scope::clear();
561    let mut waiting: Vec<(Entity, Vec<Entity>)> = Vec::new();
562    {
563        let mut q = world.query_filtered::<
564            (Entity, Option<&SubAgentChildren>, &AgentState),
565            With<WaitingForChildren>,
566        >();
567        for (e, children, _) in q.iter(world) {
568            waiting.push((e, children.map(|c| c.children.clone()).unwrap_or_default()));
569        }
570    }
571    for (entity, children) in waiting {
572        crate::tick_scope::enter(entity);
573        let all_done = children.iter().all(|&c| {
574            world
575                .get::<AgentState>(c)
576                .is_none_or(|s| is_terminal_status(&s.status))
577        });
578        if all_done {
579            world
580                .entity_mut(entity)
581                .remove::<WaitingForChildren>()
582                .insert(ResolveTransition);
583            world
584                .get_mut::<AgentState>(entity)
585                .expect("waiting agent has AgentState")
586                .status = AgentStatus::Active;
587        }
588    }
589}
590
591/// Default re-entry cap for required-region gating: how many times a stage is
592/// re-run to populate an empty `required` region before proceeding anyway (with a
593/// warning). Overridable per stage via `max_revisits`.
594pub(crate) const DEFAULT_REQUIRED_REENTRY_CAP: usize = 3;
595
596/// Counts how many times the current stage has been re-run to satisfy required
597/// context regions. Absent ⇒ 0; reset when a new stage is entered.
598#[derive(Component, Debug, Clone, Copy)]
599pub struct RequiredReentries(pub usize);
600
601/// Required regions (from the stage's effective layout) still empty at stage end,
602/// as `(name, optional custom message)`. Empty when the stage has no
603/// context-writing tool (gating a stage that can't populate the region would loop
604/// pointlessly). Ported from the imperative `unmet_required_regions`.
605pub(crate) fn unmet_required_regions(
606    blueprint: &leviath_core::Blueprint,
607    stage: &leviath_core::Stage,
608    window: &ContextWindow,
609) -> Vec<(String, Option<String>)> {
610    let can_write = stage
611        .available_tools
612        .iter()
613        .any(|t| t == "context_write" || t == "context_append");
614    if !can_write {
615        return Vec::new();
616    }
617    let layout = stage
618        .context_layout
619        .as_ref()
620        .unwrap_or(&blueprint.context_layout);
621    layout
622        .regions
623        .iter()
624        .filter(|r| r.required)
625        // Caller-input regions are validated (and seeded) at spawn, not written
626        // by the agent - skip them here so this gate never nags the agent to
627        // populate a slot the caller owns.
628        .filter(|r| {
629            !matches!(
630                r.seed,
631                Some(leviath_core::layout::RegionSeed::CallerInput { .. })
632            )
633        })
634        .filter(|r| {
635            window
636                .get_region(&r.name)
637                .map(|reg| reg.content.is_empty())
638                .unwrap_or(true)
639        })
640        .map(|r| (r.name.clone(), r.required_message.clone()))
641        .collect()
642}
643
644/// Inject a `[System]` nudge into the conversation region for each unmet required
645/// region, so the stage re-run tells the agent exactly what to populate. A custom
646/// `required_message` may name the region via a `{region}` placeholder; the
647/// generated default is built through the same substitution.
648pub(crate) fn inject_required_region_nudges(
649    window: &mut ContextWindow,
650    unmet: &[(String, Option<String>)],
651) {
652    const DEFAULT_REQUIRED_MESSAGE: &str = "Required context region '{region}' is still empty. \
653         You must populate it (e.g. via context_write with region=\"{region}\") before this \
654         stage can complete.";
655    for (name, msg) in unmet {
656        let text = leviath_core::text::interpolate(
657            msg.as_deref().unwrap_or(DEFAULT_REQUIRED_MESSAGE),
658            &[("region", name)],
659        );
660        crate::pipeline::response::inject_system_nudge(window, &text);
661    }
662}
663
664/// Required-region gate: before a normally-completed stage transitions, if it can
665/// write context and a `required` region is still empty, inject a nudge and re-run
666/// the stage (loop back to `ReadyToInfer`) instead of transitioning - bounded by
667/// the stage's `max_revisits` (or a default cap), after which
668/// it proceeds with a warning. Skipped when the stage ended on an error / max-iter
669/// outcome (those transitions take precedence). Ported from the imperative gate.
670#[allow(clippy::type_complexity)]
671pub fn require_context_regions(
672    mut agents: Query<
673        (
674            Entity,
675            &AgentBlueprint,
676            &StageCursor,
677            &mut ContextWindow,
678            Option<&RequiredReentries>,
679            Option<&StageOutcome>,
680        ),
681        With<ResolveTransition>,
682    >,
683    mut commands: Commands,
684) {
685    crate::tick_scope::clear();
686    for (entity, bp, cursor, mut window, reentries, outcome) in agents.iter_mut() {
687        crate::tick_scope::enter(entity);
688        if outcome.is_some() {
689            continue; // error / max-iterations transition takes precedence
690        }
691        let stage = &bp.0.stages[cursor.index];
692        let unmet = unmet_required_regions(&bp.0, stage, &window);
693        if unmet.is_empty() {
694            continue;
695        }
696        let cap = stage.max_revisits.unwrap_or(DEFAULT_REQUIRED_REENTRY_CAP);
697        let round = reentries.map_or(0, |r| r.0);
698        if round >= cap {
699            let names: Vec<&str> = unmet.iter().map(|(n, _)| n.as_str()).collect();
700            tracing::warn!(
701                stage = %stage.name,
702                regions = ?names,
703                attempts = cap,
704                "required context regions still empty after re-run attempts; proceeding"
705            );
706            continue; // proceed with the transition despite the unmet regions
707        }
708        inject_required_region_nudges(&mut window, &unmet);
709        commands
710            .entity(entity)
711            .remove::<ResolveTransition>()
712            .insert(ReadyToInfer)
713            .insert(RequiredReentries(round + 1));
714    }
715}
716
717/// What a chosen edge's gate says about the transition.
718#[derive(Debug, Clone, PartialEq, Eq)]
719pub(crate) enum GateDecision {
720    /// The gate is satisfied (or absent) - follow the edge.
721    Pass,
722    /// The gate is unsatisfied but out of re-run budget - follow the edge and
723    /// record it in the run's flags so the run explains itself afterwards.
724    Forced,
725    /// Hold the agent in this stage and show it this nudge.
726    Block(String),
727}
728
729/// Decide whether a chosen edge's [gate](leviath_core::blueprint::TransitionGate)
730/// blocks the transition.
731///
732/// The failure this guards against: an agent can read and reason about a
733/// codebase entirely through `shell` and arrive at the review stage having
734/// changed nothing, producing a run
735/// with no output. A `require_modifications` gate keeps it in the stage until it
736/// has actually written something.
737///
738/// The gate passes when any of these hold:
739/// - the stage advertises no file-modifying tool (it could never pass, so gating
740///   it would only burn iterations);
741/// - a modifying tool call succeeded in this stage;
742/// - one was refused by the permission layer (the agent is trying and cannot);
743/// - the gate names a region and that region is non-empty (the durable signal:
744///   per-stage counters don't survive a daemon restart, but regions do).
745///
746/// When the gate's re-run budget is spent it gives up loudly, as
747/// [`GateDecision::Forced`].
748pub(crate) fn gate_blocks(
749    gate: Option<&leviath_core::blueprint::TransitionGate>,
750    stage: &leviath_core::Stage,
751    progress: &StageProgress,
752    window: &ContextWindow,
753) -> GateDecision {
754    let Some(gate) = gate else {
755        return GateDecision::Pass;
756    };
757    if !gate.require_modifications {
758        return GateDecision::Pass;
759    }
760    let can_modify = stage.available_tools.iter().any(|t| {
761        let canonical = leviath_tools::canonical_tool_name(t);
762        leviath_core::blueprint::MODIFYING_TOOLS.contains(&canonical)
763            || gate
764                .tools
765                .iter()
766                .any(|extra| leviath_tools::canonical_tool_name(extra) == canonical)
767    });
768    if !can_modify {
769        return GateDecision::Pass;
770    }
771    if progress.modifying_tool_calls > 0 {
772        return GateDecision::Pass;
773    }
774    if progress.blocked_modification_calls > 0 {
775        tracing::warn!(
776            stage = %stage.name,
777            blocked = progress.blocked_modification_calls,
778            "file modifications were denied by policy; letting the gated transition through"
779        );
780        return GateDecision::Pass;
781    }
782    if let Some(region) = &gate.region
783        && window
784            .get_region(region)
785            .is_some_and(|r| !r.content.is_empty())
786    {
787        return GateDecision::Pass;
788    }
789    let cap = gate
790        .max_attempts
791        .unwrap_or(leviath_core::blueprint::DEFAULT_GATE_ATTEMPTS);
792    if progress.gate_reentries >= cap {
793        tracing::warn!(
794            stage = %stage.name,
795            attempts = cap,
796            "stage still has no file modifications after re-run attempts; proceeding"
797        );
798        return GateDecision::Forced;
799    }
800    GateDecision::Block(gate.message.clone().unwrap_or_else(|| {
801        "No file modifications were recorded in this stage. Changes made through the shell \
802         (sed -i, tee, >, >>) are not tracked by the framework. Re-apply your changes with \
803         edit_file or write_file before moving on."
804            .to_string()
805    }))
806}
807
808/// Hold an agent in its current stage after a gate refused the transition: inject
809/// the nudge, count the re-entry, and put it back in front of the model. The
810/// stage is *not* re-entered - `StageProgress` is deliberately preserved so the
811/// stage's `max_iterations` still bounds the loop.
812pub(crate) fn hold_for_gate(
813    entity: Entity,
814    nudge: &str,
815    progress: &mut StageProgress,
816    window: &mut ContextWindow,
817    commands: &mut Commands,
818) {
819    crate::pipeline::response::inject_system_nudge(window, nudge);
820    progress.gate_reentries += 1;
821    commands
822        .entity(entity)
823        .remove::<ResolveTransition>()
824        .remove::<AwaitingTransitionResponse>()
825        .remove::<StageOutcome>()
826        .insert(ReadyToInfer);
827}
828
829/// Transition-resolution system: for each `ResolveTransition` agent, resolve the
830/// next stage. Terminal ⇒ mark the agent `Complete`. A single/linear target ⇒
831/// enter the new stage (swap its `StageInference`, reset stage progress, bump the
832/// visit count) and loop to `ReadyToInfer`. Multiple candidate edges ⇒ hand off
833/// to the async transition-choice system via `AwaitingTransitionChoice`.
834#[allow(clippy::type_complexity)]
835pub fn resolve_transition(
836    mut agents: Query<
837        (
838            Entity,
839            &AgentBlueprint,
840            &mut StageCursor,
841            &mut AgentState,
842            &mut StageProgress,
843            &StageInferences,
844            &StageSetups,
845            &mut VisitCounts,
846            &mut ContextWindow,
847            Option<&StageOutcome>,
848            Option<&mut crate::persistence::RunOutcomeFlags>,
849            Option<&crate::persistence::RunMetadata>,
850        ),
851        With<ResolveTransition>,
852    >,
853    sink: Option<Res<crate::host::WorldEventSink>>,
854    mut commands: Commands,
855) {
856    crate::tick_scope::clear();
857    use leviath_core::blueprint::TransitionCondition;
858    for (
859        entity,
860        bp,
861        mut cursor,
862        mut state,
863        mut progress,
864        stage_infs,
865        setups,
866        mut visits,
867        mut window,
868        outcome,
869        mut flags,
870        metadata,
871    ) in agents.iter_mut()
872    {
873        crate::tick_scope::enter(entity);
874        // A pause that lands while a transition is pending must hold: entering
875        // the next stage flips the agent back to Active. The marker stays put,
876        // so the transition resolves on the first tick after resume.
877        if state.status == AgentStatus::Paused {
878            continue;
879        }
880        let stage = &bp.0.stages[cursor.index];
881        // How the stage ended governs the transition: an error/max-iterations
882        // outcome follows its conditioned edge (e.g. → error_recovery) if present.
883        let resolution = match outcome {
884            // An error/max-iterations edge is never gated: the stage already
885            // failed, and holding it back to demand file changes would strand a
886            // run that can't make any.
887            Some(StageOutcome::Errored(message)) => {
888                match find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::Error) {
889                    Some((i, t)) => {
890                        // Put the error where the recovery stage will read it;
891                        // without an error edge the run terminates and the
892                        // status already carries the message.
893                        note_error(&mut window, &stage.name, message);
894                        StageResolution::Next(i, t, None)
895                    }
896                    None => StageResolution::TerminalError,
897                }
898            }
899            Some(StageOutcome::MaxIterations) => {
900                // Whatever runs next - a max_iterations edge target, the normal
901                // successor, or the transition-choice model - should know the
902                // stage was cut off, not finished.
903                note_max_iterations(&mut window, &stage.name, stage.max_iterations.unwrap_or(0));
904                find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::MaxIterations)
905                    .map(|(i, t)| StageResolution::Next(i, t, None))
906                    .unwrap_or_else(|| {
907                        resolve_transition_sync(&bp.0, stage, cursor.index, &visits.0)
908                    })
909            }
910            Some(StageOutcome::Stuck(_)) => {
911                // A stuck interrupt is mid-stage, not a stage end. If the escape
912                // hatch went away between detection and here (its target spent
913                // its last revisit), resume - falling through to
914                // `resolve_transition_sync` would end a stage the agent never
915                // said it had finished, e.g. shunting `implement` into `review`
916                // with the work half-done.
917                find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::Stuck)
918                    .map(|(i, t)| StageResolution::Next(i, t, None))
919                    .unwrap_or(StageResolution::Resume)
920            }
921            None => resolve_transition_sync(&bp.0, stage, cursor.index, &visits.0),
922        };
923        match resolution {
924            StageResolution::Terminal => {
925                state.status = AgentStatus::Complete;
926                commands
927                    .entity(entity)
928                    .remove::<ResolveTransition>()
929                    .remove::<StageOutcome>();
930            }
931            StageResolution::TerminalError => {
932                // Status was set to Error by the collect system; just stop.
933                commands
934                    .entity(entity)
935                    .remove::<ResolveTransition>()
936                    .remove::<StageOutcome>();
937            }
938            StageResolution::Next(idx, transform, gate) => {
939                // Check the edge's gate BEFORE the transform runs: the transform
940                // compacts/clears regions, and a held stage must keep its context.
941                let gate = outcome.is_none().then_some(gate).flatten();
942                match gate_blocks(gate.as_ref(), stage, &progress, &window) {
943                    GateDecision::Block(nudge) => {
944                        hold_for_gate(entity, &nudge, &mut progress, &mut window, &mut commands);
945                        continue;
946                    }
947                    GateDecision::Forced => {
948                        if let Some(flags) = flags.as_mut() {
949                            flags.0.gates_forced += 1;
950                        }
951                    }
952                    GateDecision::Pass => {}
953                }
954                // Reshape the outgoing context per the edge transform before the
955                // new stage's layout/prompt setup.
956                let to_compact = apply_edge_transform(&mut window, &transform);
957                let setup = &setups.0[idx];
958                let from = state.current_stage.clone();
959                match enter_stage(
960                    idx,
961                    &bp.0,
962                    &mut cursor,
963                    &mut state,
964                    &mut progress,
965                    &mut visits,
966                    setup,
967                    &mut window,
968                ) {
969                    Ok(visit) => {
970                        // Entering a stage is active work; clears a prior error
971                        // status when recovering down an `error` edge.
972                        state.status = AgentStatus::Active;
973                        let name = bp.0.stages[idx].name.clone();
974                        emit_stage_transition(&sink, metadata, &state.agent_id, from, &name, visit);
975                        let mut ec = commands.entity(entity);
976                        ec.remove::<ResolveTransition>().remove::<StageOutcome>();
977                        attach_stage_components(ec, stage_infs.0[idx].clone(), setup, idx, name);
978                        if !to_compact.is_empty() {
979                            commands
980                                .entity(entity)
981                                .insert(PendingEdgeCompact(to_compact));
982                        }
983                    }
984                    Err(message) => {
985                        state.status = AgentStatus::Error { message };
986                        commands
987                            .entity(entity)
988                            .remove::<ResolveTransition>()
989                            .remove::<StageOutcome>();
990                    }
991                }
992            }
993            StageResolution::Choose(edges) => {
994                commands
995                    .entity(entity)
996                    .remove::<ResolveTransition>()
997                    .remove::<StageOutcome>()
998                    .insert(AwaitingTransitionChoice(edges));
999            }
1000            StageResolution::Resume => {
1001                // `StageProgress::stuck_fired` is already set, so this cannot
1002                // ping-pong with `detect_stuck_stage`; the stage now simply runs
1003                // out to its ordinary `max_iterations`.
1004                commands
1005                    .entity(entity)
1006                    .remove::<ResolveTransition>()
1007                    .remove::<StageOutcome>()
1008                    .insert(ReadyToInfer);
1009            }
1010        }
1011    }
1012}
1013
1014/// Enter the stage at `idx`: update the cursor + current-stage name, reset
1015/// per-stage progress, bump the visit count, set `accepts_messages`, and apply the
1016/// stage's context setup - swap to its layout (if any) and (re)inject its system
1017/// prompt as pinned `[Stage instructions: …]` context, replacing the previous
1018/// stage's. (Ported from the imperative loop's per-stage setup.)
1019///
1020/// Returns `Err` only when the system prompt doesn't fit its region - the same
1021/// hard failure the imperative loop raises; the caller marks the agent `Error`.
1022/// `Ok` carries the stage's updated visit count (this entry included), which the
1023/// transition systems stamp into the [`StageTransition`](crate::host::WorldEvent)
1024/// event.
1025#[allow(clippy::too_many_arguments)]
1026pub(crate) fn enter_stage(
1027    idx: usize,
1028    blueprint: &leviath_core::Blueprint,
1029    cursor: &mut StageCursor,
1030    state: &mut AgentState,
1031    progress: &mut StageProgress,
1032    visits: &mut VisitCounts,
1033    setup: &StageSetup,
1034    window: &mut ContextWindow,
1035) -> Result<usize, String> {
1036    cursor.index = idx;
1037    let name = blueprint.stages[idx].name.clone();
1038    state.current_stage = name.clone();
1039    state.accepts_messages = setup.accepts_messages;
1040    *progress = StageProgress::default();
1041    let visit = visits.0.entry(name).or_insert(0);
1042    *visit += 1;
1043    let visit = *visit;
1044
1045    apply_stage_context(setup, window).map(|()| visit)
1046}
1047
1048/// Push a [`StageTransition`](crate::host::WorldEvent::StageTransition) event
1049/// into the world's event stream. A no-op in worlds that don't stream (no
1050/// [`WorldEventSink`](crate::host::WorldEventSink) resource) and for bare
1051/// agents without run metadata.
1052fn emit_stage_transition(
1053    sink: &Option<Res<crate::host::WorldEventSink>>,
1054    metadata: Option<&crate::persistence::RunMetadata>,
1055    agent_id: &str,
1056    from: String,
1057    to: &str,
1058    iteration: usize,
1059) {
1060    if let (Some(sink), Some(md)) = (sink.as_ref(), metadata) {
1061        let _ = sink.0.send(crate::host::WorldEvent::StageTransition {
1062            run_id: md.run_id.clone(),
1063            agent_id: agent_id.to_string(),
1064            from,
1065            to: to.to_string(),
1066            iteration,
1067        });
1068    }
1069}
1070
1071/// Apply a stage's context setup to a window: swap to the stage's layout (if any)
1072/// and (re)inject its system prompt as pinned `[Stage instructions: …]` context,
1073/// clearing any previous stage's first. Returns `Err` only when the prompt
1074/// doesn't fit its region. Shared by [`enter_stage`] (transitions) and
1075/// [`build_agent`] (the first stage, at spawn).
1076pub(crate) fn apply_stage_context(
1077    setup: &StageSetup,
1078    window: &mut ContextWindow,
1079) -> Result<(), String> {
1080    if let Some(layout) = &setup.context_layout {
1081        crate::context_setup::apply_layout(window, layout);
1082    }
1083
1084    // Inject stage instructions into the first pinned region (cacheable), or the
1085    // conversation region if there is none - clearing any prior stage's first.
1086    let target = window
1087        .regions
1088        .iter()
1089        .find(|r| matches!(r.kind, leviath_core::RegionKind::Pinned))
1090        .map(|r| r.name.clone())
1091        .unwrap_or_else(|| "conversation".to_string());
1092    if let Some(region) = window.regions.iter_mut().find(|r| r.name == target) {
1093        region.remove_entries_by_prefix("[Stage instructions:");
1094    }
1095    if let Some(sp) = &setup.system_prompt {
1096        let content = format!("[Stage instructions: {sp}]");
1097        let tokens = leviath_core::estimate_tokens(&content);
1098        window
1099            .add_to_region(&target, content, tokens)
1100            .map_err(|e| {
1101                format!(
1102                    "stage system prompt (~{tokens} tokens) does not fit context region \
1103                 '{target}': {e}. Increase that region's max_tokens (or shorten the prompt)."
1104                )
1105            })?;
1106    }
1107    Ok(())
1108}
1109
1110/// Finish a successful stage entry: attach the new stage's inference config,
1111/// tool-result routing (present ⇒ insert, absent ⇒ clear the stale one), and its
1112/// pre-resolved [`StageInference`], then mark the agent `ReadyToInfer`. Shared by
1113/// both the synchronous and LLM-choice transition paths.
1114pub(crate) fn attach_stage_components(
1115    mut entity: bevy_ecs::system::EntityCommands,
1116    stage_inf: StageInference,
1117    setup: &StageSetup,
1118    stage_index: usize,
1119    stage_name: String,
1120) {
1121    entity
1122        .insert(stage_inf)
1123        .insert(setup.inference_config.clone())
1124        .insert(StageJustEntered {
1125            index: stage_index,
1126            name: stage_name,
1127        })
1128        // A fresh stage re-arms its interaction points + required-region gate.
1129        .remove::<crate::interaction_points::InteractionPointCursor>()
1130        .remove::<crate::interaction_points::InteractionPointRounds>()
1131        .remove::<RequiredReentries>()
1132        .insert(ReadyToInfer);
1133    match &setup.routing {
1134        Some(routing) => {
1135            entity.insert(crate::components::ToolResultRoutingComponent {
1136                routing: routing.clone(),
1137            });
1138        }
1139        None => {
1140            entity.remove::<crate::components::ToolResultRoutingComponent>();
1141        }
1142    }
1143}
1144
1145/// Force an agent into the stage at `target_idx` via direct world access - the
1146/// same effect as [`resolve_transition`]'s linear-`Next` arm, but callable from
1147/// an exclusive system (e.g. the fan-out collector jumping to its `merge_stage`)
1148/// or the daemon (spawning a fan-out worker directly at its worker stage) where no
1149/// [`Commands`] queue is available. On a system-prompt overflow the agent is
1150/// marked `Error`, mirroring the transition systems.
1151pub fn force_transition(world: &mut World, entity: Entity, target_idx: usize) {
1152    // Phase 1 (scoped borrow): mutate the agent's own state via `enter_stage`,
1153    // returning the components Phase 2 must insert - or `None` if the agent is
1154    // gone or its system prompt overflowed (already marked `Error` in-place).
1155    let attach: Option<(StageInference, StageSetup, String)> = {
1156        let mut q = world.query::<(
1157            &AgentBlueprint,
1158            &mut StageCursor,
1159            &mut AgentState,
1160            &mut StageProgress,
1161            &StageInferences,
1162            &StageSetups,
1163            &mut VisitCounts,
1164            &mut ContextWindow,
1165        )>();
1166        let Ok((
1167            bp,
1168            mut cursor,
1169            mut state,
1170            mut progress,
1171            stage_infs,
1172            setups,
1173            mut visits,
1174            mut window,
1175        )) = q.get_mut(world, entity)
1176        else {
1177            return; // agent despawned
1178        };
1179        let setup = setups.0[target_idx].clone();
1180        let stage_inf = stage_infs.0[target_idx].clone();
1181        let name = bp.0.stages[target_idx].name.clone();
1182        let bp = bp.0.clone();
1183        match enter_stage(
1184            target_idx,
1185            &bp,
1186            &mut cursor,
1187            &mut state,
1188            &mut progress,
1189            &mut visits,
1190            &setup,
1191            &mut window,
1192        ) {
1193            Ok(_) => Some((stage_inf, setup, name)),
1194            Err(message) => {
1195                state.status = AgentStatus::Error { message };
1196                None
1197            }
1198        }
1199    };
1200
1201    // Phase 2 (borrow released): attach the new stage's components directly.
1202    let Some((stage_inf, setup, name)) = attach else {
1203        return;
1204    };
1205    let mut em = world.entity_mut(entity);
1206    em.insert(stage_inf)
1207        .insert(setup.inference_config.clone())
1208        .insert(StageJustEntered {
1209            index: target_idx,
1210            name,
1211        })
1212        .insert(ReadyToInfer);
1213    match &setup.routing {
1214        Some(routing) => {
1215            em.insert(crate::components::ToolResultRoutingComponent {
1216                routing: routing.clone(),
1217            });
1218        }
1219        None => {
1220            em.remove::<crate::components::ToolResultRoutingComponent>();
1221        }
1222    }
1223}
1224
1225/// A blueprint stage resolved to a concrete provider, model, and effective tool
1226/// set - the per-stage input to [`spawn_agent`]. The caller (CLI / daemon) owns
1227/// the model-selection policy (overrides, availability, user defaults) and tool
1228/// filtering; the runtime just turns the result into agent data.
1229#[derive(Debug)]
1230pub struct ResolvedStage {
1231    /// The provider to call for this stage.
1232    pub provider_name: String,
1233    /// The resolved model name.
1234    pub model: String,
1235    /// The effective tool set for this stage (already filtered).
1236    pub tools: Vec<Tool>,
1237    /// Where to go if `provider_name` turns out to be unusable, best first.
1238    /// See [`crate::pipeline::resolve_stage_candidates`].
1239    pub fallbacks: Vec<leviath_core::blueprint::ModelEntry>,
1240}
1241
1242/// Fallback context window used when a stage's provider isn't registered (so
1243/// percentage budgets can't be resolved against a real model). Matches
1244/// [`leviath_providers::ModelCapabilities`]'s default `max_context_tokens`.
1245pub(crate) const DEFAULT_CONTEXT_WINDOW_TOKENS: usize = 8192;
1246
1247/// Look up a model's context window (for resolving percentage region budgets)
1248/// via the registered [`Providers`]. Falls back to
1249/// [`DEFAULT_CONTEXT_WINDOW_TOKENS`] with a warning when the provider isn't
1250/// registered - non-fatal, and `min_tokens` floors still protect regions.
1251pub(crate) fn context_window_tokens(world: &World, provider_name: &str, model: &str) -> usize {
1252    match world
1253        .get_resource::<Providers>()
1254        .and_then(|p| p.0.get(provider_name))
1255    {
1256        Some(provider) => provider.max_context_tokens(model),
1257        None => {
1258            tracing::warn!(
1259                provider = provider_name,
1260                model,
1261                "provider not registered; using default context window for percentage budgets"
1262            );
1263            DEFAULT_CONTEXT_WINDOW_TOKENS
1264        }
1265    }
1266}
1267
1268/// Build a stage's [`StageSetup`] from its blueprint definition: inference config
1269/// (from the model parameters), tool-result routing, accepts-messages, layout,
1270/// and system prompt.
1271///
1272/// `global_hints` is the caller's config-level toggle for each system-prompt
1273/// hint; `agent_hints` the blueprint's agent-level override of the same. Each
1274/// one cascades stage → agent → global here.
1275pub(crate) fn stage_setup_from(
1276    stage: &leviath_core::Stage,
1277    global_hints: leviath_core::config::PromptHints,
1278    agent_hints: leviath_core::config::PromptHintOverrides,
1279) -> StageSetup {
1280    let temperature = stage
1281        .model
1282        .parameters
1283        .get("temperature")
1284        .and_then(|v| v.as_f64())
1285        .map(|t| t as f32);
1286    // Every other model parameter (top_p, stop, seed, frequency_penalty, …) is
1287    // passed through to the provider verbatim; only temperature/max_output_tokens
1288    // are consumed specially above.
1289    let extra_params: serde_json::Map<String, serde_json::Value> = stage
1290        .model
1291        .parameters
1292        .iter()
1293        .filter(|(k, _)| k.as_str() != "temperature" && k.as_str() != "max_output_tokens")
1294        .map(|(k, v)| (k.clone(), v.clone()))
1295        .collect();
1296    let max_output_tokens = stage
1297        .model
1298        .parameters
1299        .get("max_output_tokens")
1300        .and_then(|v| v.as_u64())
1301        .map(|t| t as usize);
1302    let base_prompt = stage
1303        .config
1304        .get("system_prompt")
1305        .and_then(|v| v.as_str())
1306        .map(String::from);
1307    // A fan-out stage's single inference IS the "split": fold its `split_prompt`
1308    // (which asks for the JSON array of work items) onto any base instructions so
1309    // the stage's normal inference produces the work items the split system parses.
1310    let system_prompt = match &stage.mode {
1311        leviath_core::blueprint::StageMode::FanOut { config }
1312            if !config.split_prompt.trim().is_empty() =>
1313        {
1314            Some(match base_prompt {
1315                Some(base) => format!("{base}\n\n{}", config.split_prompt),
1316                None => config.split_prompt.clone(),
1317            })
1318        }
1319        _ => base_prompt,
1320    };
1321    // Cascade each hint toggle: stage > agent > global (both default on).
1322    let batch_tool_hint = leviath_core::taint::resolve_batch_tool_hint(
1323        global_hints.batch_tool,
1324        agent_hints.batch_tool,
1325        stage.batch_tool_hint,
1326    );
1327    let shell_hint = leviath_core::taint::resolve_shell_hint(
1328        global_hints.shell,
1329        agent_hints.shell,
1330        stage.shell_hint,
1331    );
1332    StageSetup {
1333        inference_config: InferenceConfig {
1334            temperature,
1335            max_output_tokens,
1336            extra_params,
1337            batch_tool_hint,
1338            shell_hint,
1339            request_timeout_secs: stage.model.request_timeout_secs,
1340        },
1341        routing: stage.tool_result_routing.clone(),
1342        accepts_messages: stage.accepts_messages,
1343        context_layout: stage.context_layout.clone(),
1344        system_prompt,
1345    }
1346}
1347
1348/// Spawn a fully-formed agent into `world` from its blueprint, task, and
1349/// per-stage resolution, and return its entity. Builds every stage's
1350/// `StageInference`/`StageSetup` up front (so transitions are pure component
1351/// swaps), seeds the context window, applies the **first** stage's setup (its
1352/// layout and system prompt), pre-counts the first stage's visit, and marks the
1353/// agent `ReadyToInfer`. Returns `Err` if the first stage's system prompt doesn't fit
1354/// its region (the same hard failure the imperative loop raises at stage 0).
1355///
1356/// `stages` must be aligned with `blueprint.stages` (one [`ResolvedStage`] each).
1357///
1358/// `global_hints` is the caller's global config toggle for each system-prompt
1359/// hint; each is resolved per stage against the blueprint's agent-level and
1360/// per-stage override of the same name.
1361pub fn spawn_agent(
1362    world: &mut World,
1363    agent_id: String,
1364    blueprint: leviath_core::Blueprint,
1365    task: &str,
1366    stages: Vec<ResolvedStage>,
1367    global_hints: leviath_core::config::PromptHints,
1368) -> Result<Entity, String> {
1369    let seeds = std::collections::HashMap::from([("task".to_string(), task.to_string())]);
1370    // No compiled custom-region scripts on this path: script-backed regions
1371    // require the seeded spawn (the CLI resolves and compiles them). A custom
1372    // region spawned through here renders its fallback shape. Global nudge
1373    // defaults are likewise a seeded-spawn concern (the CLI reads them from
1374    // config.toml); agents spawned through here cascade straight from the
1375    // blueprint to the built-in defaults.
1376    spawn_agent_seeded(
1377        world,
1378        agent_id,
1379        blueprint,
1380        &seeds,
1381        stages,
1382        global_hints,
1383        leviath_core::NudgeConfig::default(),
1384        std::collections::HashMap::new(),
1385    )
1386}
1387
1388/// Like [`spawn_agent`], but seeds the context window from a name→content map
1389/// (caller-input regions filled by the CLI/ACP/API, plus blueprint-resolved
1390/// seeds) rather than a single task string. `spawn_agent` is the thin wrapper
1391/// that seeds only the `task` key.
1392///
1393/// `global_nudge` is the caller's config-level `[nudge]` defaults, captured on
1394/// the agent as a [`crate::pipeline::response::GlobalNudge`] component; each
1395/// field is resolved per stage against the blueprint's agent-level and
1396/// per-stage nudge settings when an empty response is handled.
1397#[allow(clippy::too_many_arguments)]
1398pub fn spawn_agent_seeded(
1399    world: &mut World,
1400    agent_id: String,
1401    mut blueprint: leviath_core::Blueprint,
1402    seeds: &std::collections::HashMap<String, String>,
1403    stages: Vec<ResolvedStage>,
1404    global_hints: leviath_core::config::PromptHints,
1405    global_nudge: leviath_core::NudgeConfig,
1406    region_scripts: std::collections::HashMap<
1407        String,
1408        std::sync::Arc<leviath_scripting::region_hook::RegionScript>,
1409    >,
1410) -> Result<Entity, String> {
1411    // Resolve any percentage region budgets against each stage's model context
1412    // window (the only place the model - and hence the window - is known). The
1413    // global layout resolves against the entry stage (stage 0); each per-stage
1414    // layout resolves against that stage's own model. Absolute layouts resolve to
1415    // themselves, so this is a no-op for legacy blueprints.
1416    let stage_windows: Vec<usize> = stages
1417        .iter()
1418        .map(|rs| context_window_tokens(world, &rs.provider_name, &rs.model))
1419        .collect();
1420    blueprint.context_layout = blueprint.context_layout.resolved(stage_windows[0]);
1421    for (i, stage) in blueprint.stages.iter_mut().enumerate() {
1422        if let Some(layout) = &stage.context_layout {
1423            stage.context_layout = Some(layout.resolved(stage_windows[i]));
1424        }
1425    }
1426    // Validate the resolved (fully-absolute) layouts, now that percentages are
1427    // concrete numbers judged against the real model window.
1428    blueprint
1429        .context_layout
1430        .validate()
1431        .map_err(|e| e.to_string())?;
1432    for stage in &blueprint.stages {
1433        if let Some(layout) = &stage.context_layout {
1434            layout.validate().map_err(|e| e.to_string())?;
1435        }
1436    }
1437
1438    let stage_infs: Vec<StageInference> = stages
1439        .into_iter()
1440        .map(|rs| StageInference {
1441            provider_name: rs.provider_name,
1442            model: rs.model,
1443            tools: rs.tools,
1444            tool_filter: None, // tools already resolved to the effective set
1445            fallbacks: rs.fallbacks,
1446        })
1447        .collect();
1448    let agent_hints = leviath_core::config::PromptHintOverrides {
1449        batch_tool: blueprint.batch_tool_hint,
1450        shell: blueprint.shell_hint,
1451    };
1452    let setups: Vec<StageSetup> = blueprint
1453        .stages
1454        .iter()
1455        .map(|s| stage_setup_from(s, global_hints, agent_hints))
1456        .collect();
1457
1458    // Seed the window from the blueprint layout + task, then apply stage 0's
1459    // context setup (layout swap + system-prompt injection) just as entering any
1460    // later stage would.
1461    let mut window = ContextWindow::new(blueprint.context_layout.total_budget_tokens);
1462    // Attach compiled custom-region scripts BEFORE seeding, so seed writes
1463    // pass through each region's on_write hook like any other entry.
1464    window.region_scripts = region_scripts;
1465    crate::context_setup::init_window_seeded(&mut window, &blueprint, seeds);
1466    apply_stage_context(&setups[0], &mut window)?;
1467
1468    let stage0_name = blueprint.stages[0].name.clone();
1469    let stage0_inf = stage_infs[0].clone();
1470    let setup0 = &setups[0];
1471    let stage0_cfg = setup0.inference_config.clone();
1472    let stage0_routing = setup0.routing.clone();
1473    let accepts_messages = setup0.accepts_messages;
1474
1475    // Pre-count stage 0's visit: the imperative loop bumps a stage's visit after
1476    // it runs and before resolving its transition, so stage 0 must read as
1477    // visited once by the time its first transition resolves.
1478    let mut visits = VisitCounts::default();
1479    *visits.0.entry(stage0_name.clone()).or_insert(0) += 1;
1480
1481    // Seed the per-stage ledger (names + Pending) so the dashboard shows every
1482    // stage's real name from the first persist, not just the active one.
1483    let ledger = StageLedger(
1484        blueprint
1485            .stages
1486            .iter()
1487            .enumerate()
1488            .map(|(i, s)| leviath_core::run_meta::StageRecord::new(s.name.clone(), i))
1489            .collect(),
1490    );
1491
1492    // Repetition detection is opt-in per blueprint.
1493    let repetition = blueprint
1494        .repetition_detection
1495        .as_ref()
1496        .map(crate::repetition::RepetitionDetector::from_detection_config);
1497
1498    let entity = world
1499        .spawn((
1500            AgentBlueprint(blueprint),
1501            AgentState {
1502                agent_id,
1503                current_stage: stage0_name,
1504                iteration: 0,
1505                status: AgentStatus::Active,
1506                spawned_children_ids: vec![],
1507                pending_wait: None,
1508                accepts_messages,
1509            },
1510            MessageInbox::default(),
1511            StageCursor { index: 0 },
1512            StageProgress::default(),
1513            StageInferences(stage_infs),
1514            StageSetups(setups),
1515            visits,
1516            window,
1517            stage0_inf,
1518            stage0_cfg,
1519            ReadyToInfer,
1520        ))
1521        .id();
1522    // Inserted after spawn: the bundle above is already at bevy's 15-tuple limit.
1523    world.entity_mut(entity).insert((
1524        ledger,
1525        StageIoBuffer::default(),
1526        crate::pipeline::response::GlobalNudge(global_nudge),
1527    ));
1528    if let Some(detector) = repetition {
1529        world.entity_mut(entity).insert(detector);
1530    }
1531    if let Some(routing) = stage0_routing {
1532        world
1533            .entity_mut(entity)
1534            .insert(crate::components::ToolResultRoutingComponent { routing });
1535    }
1536    Ok(entity)
1537}
1538
1539/// A transition-choice inference is in flight (an LLM is picking the next stage);
1540/// holds the choosable edges so the collect system can match the response back to
1541/// one. (Ported from the async portion of `graph::prompt_llm_transition`.)
1542#[derive(Component, Debug, Clone)]
1543pub struct AwaitingTransitionResponse(pub Vec<leviath_core::blueprint::TransitionEdge>);
1544
1545/// The receiving end of the transition-choice outcomes channel, as a world
1546/// resource for the collect system. (The sending end lives in
1547/// [`InferenceStage::transition_outcomes`].)
1548#[derive(Resource)]
1549pub struct TransitionResults(pub UnboundedReceiver<InferenceOutcome>);
1550
1551/// Build the LLM prompt that asks which stage to run next. (Ported from the
1552/// prompt-building portion of `graph::prompt_llm_transition`.)
1553pub(crate) fn build_transition_prompt(
1554    stage: &leviath_core::Stage,
1555    edges: &[leviath_core::blueprint::TransitionEdge],
1556) -> String {
1557    let mut p = match &stage.transition_prompt {
1558        Some(custom) => {
1559            let mut p = custom.clone();
1560            p.push_str("\n\nAvailable transitions:\n");
1561            p
1562        }
1563        None => format!(
1564            "Stage '{}' is complete. Available next stages:\n",
1565            stage.name
1566        ),
1567    };
1568    for edge in edges {
1569        p.push_str(&format!("- {}", edge.target));
1570        if let Some(hint) = &edge.hint {
1571            p.push_str(&format!(": {hint}"));
1572        }
1573        p.push('\n');
1574    }
1575    if stage.transition_prompt.is_some() {
1576        if stage.allow_complete {
1577            p.push_str(
1578                "\nRespond with ONLY the stage name you want to transition to, or ONLY the \
1579                 word DONE if no further stage is needed and the run should end here.",
1580            );
1581        } else {
1582            p.push_str(
1583                "\nRespond with ONLY the stage name you want to transition to, nothing else.",
1584            );
1585        }
1586    } else if stage.allow_complete {
1587        p.push_str(
1588            "\nWhich stage should run next? Respond with ONLY the stage name, or ONLY the \
1589             word DONE if no further stage is needed and the run should end here.",
1590        );
1591    } else {
1592        p.push_str("\nWhich stage should run next? Respond with ONLY the stage name.");
1593    }
1594    p
1595}
1596
1597/// Match an LLM transition response to one of the choosable edges' target stages,
1598/// or `None` if the stage may complete and the LLM chose to end here.
1599///
1600/// Models are asked to answer with only the target stage name (or `DONE`), but
1601/// frequently wrap it in prose or re-explain the stage. We therefore look for a
1602/// clean, standalone decision - scanning the first line, then the concluding
1603/// line, for a **whole-word** match against a stage name or `DONE` - instead of
1604/// substring-scanning the whole response, where a stage name mentioned in
1605/// passing ("the implementation", "the approved plan") would hijack the routing.
1606/// When nothing matches, a stage that may complete ends the run; otherwise the
1607/// run advances along the first declared edge.
1608pub(crate) fn match_transition_choice(
1609    choice: &str,
1610    edges: &[leviath_core::blueprint::TransitionEdge],
1611    allow_complete: bool,
1612) -> Option<String> {
1613    let lines: Vec<&str> = choice
1614        .lines()
1615        .map(str::trim)
1616        .filter(|l| !l.is_empty())
1617        .collect();
1618    // Candidate decision lines, in priority order: the first line (the model was
1619    // told to reply with only the name, so the answer leads), then - only if it
1620    // is short and answer-like (≤ 3 words) - the concluding line, which catches
1621    // models that reason first and answer last without matching a stage name
1622    // buried in a prose summary ("the approved plan was implemented").
1623    let words_in = |line: &str| {
1624        line.split(|c: char| !c.is_alphanumeric() && c != '_')
1625            .filter(|w| !w.is_empty())
1626            .count()
1627    };
1628    let first = lines.first().copied();
1629    let last = lines
1630        .last()
1631        .copied()
1632        .filter(|l| lines.len() > 1 && words_in(l) <= 3);
1633    for line in first.into_iter().chain(last) {
1634        for word in line.split(|c: char| !c.is_alphanumeric() && c != '_') {
1635            if word.is_empty() {
1636                continue;
1637            }
1638            if allow_complete && word.eq_ignore_ascii_case("done") {
1639                return None;
1640            }
1641            if let Some(edge) = edges.iter().find(|e| word.eq_ignore_ascii_case(&e.target)) {
1642                return Some(edge.target.clone());
1643            }
1644        }
1645    }
1646    // No clear decision: a stage that may end prefers ending over looping back;
1647    // otherwise the run advances along the first declared edge.
1648    if allow_complete {
1649        None
1650    } else {
1651        edges.first().map(|edge| edge.target.clone())
1652    }
1653}
1654
1655/// Transition-choice dispatch: for each `AwaitingTransitionChoice` agent, inject
1656/// the "which stage next?" prompt into its context, build a short deterministic
1657/// request, acquire a per-model permit, spawn the inference onto the transition
1658/// lane, and move it to `AwaitingTransitionResponse`. Provider-missing / pool-full
1659/// leaves it choosing and retries next tick (same backpressure as
1660/// [`dispatch_inference`]).
1661#[allow(clippy::type_complexity)]
1662pub fn dispatch_transition_choice(
1663    mut agents: Query<
1664        (
1665            Entity,
1666            &AgentState,
1667            &mut ContextWindow,
1668            &StageInference,
1669            &AgentBlueprint,
1670            &StageCursor,
1671            &AwaitingTransitionChoice,
1672            Option<&InFlightWork>,
1673            Option<&DispatchStall>,
1674        ),
1675        With<AwaitingTransitionChoice>,
1676    >,
1677    stage: Res<InferenceStage>,
1678    providers: Res<Providers>,
1679    mut commands: Commands,
1680) {
1681    crate::tick_scope::clear();
1682    let now = chrono::Utc::now().timestamp();
1683    for (entity, state, mut window, si, bp, cursor, choice, in_flight, stalled) in agents.iter_mut()
1684    {
1685        crate::tick_scope::enter(entity);
1686        if state.status != AgentStatus::Active {
1687            continue; // paused / waiting / cancelled - don't start new work
1688        }
1689        // Same bookkeeping as the inference lane: an agent parked here is
1690        // runnable with nothing outstanding, so a decline that never resolves
1691        // wedges the run just as thoroughly (issue #190).
1692        let Some(provider) = providers.0.get(&si.provider_name) else {
1693            commands
1694                .entity(entity)
1695                .insert(note_stall(stalled, StallReason::ProviderMissing, now));
1696            continue; // provider not registered - retry later
1697        };
1698        let Some(permit) = stage.pools.try_acquire(&si.model) else {
1699            commands
1700                .entity(entity)
1701                .insert(note_stall(stalled, StallReason::PoolFull, now));
1702            continue; // pool full - retry next tick
1703        };
1704
1705        let current = &bp.0.stages[cursor.index];
1706        let prompt = build_transition_prompt(current, &choice.0);
1707        let tokens = leviath_core::estimate_tokens(&prompt);
1708        let _ = window.add_typed_entry(
1709            "conversation",
1710            leviath_core::EntryKind::UserMessage,
1711            prompt,
1712            tokens,
1713        );
1714
1715        // Plain `assemble()` (default meta): this is the deterministic
1716        // 256-token routing call, not stage inference - custom regions still
1717        // render (they may hold the whole context), just with empty stage
1718        // fields in their ctx.
1719        let assembled = window.assemble();
1720        let remaining = window.max_tokens.saturating_sub(window.current_tokens);
1721        let request = InferenceRequest {
1722            system: assembled.system_blocks,
1723            messages: assembled.messages,
1724            model: si.model.clone(),
1725            max_tokens: remaining.min(256), // short routing response
1726            temperature: 0.0,               // deterministic routing
1727            tools: Vec::new(),
1728            extra: serde_json::Value::Null,
1729            request_timeout_secs: None,
1730        };
1731
1732        let job = InferenceJob {
1733            entity,
1734            provider,
1735            request,
1736            permit,
1737            // Routing responses are tiny (≤256 tokens) and always fit; skip the
1738            // extra count call for them.
1739            exact_token_counting: false,
1740        };
1741        let cancel = crate::cancel::CancelToken::new();
1742        // Supervised for the same reason as the inference lane: the agent is
1743        // about to wait on `AwaitingTransitionResponse`, so a job that dies
1744        // without reporting would strand it mid-route.
1745        let lost_outcomes = stage.transition_outcomes.clone();
1746        let lost_wake = stage.wake.clone();
1747        crate::lane_supervisor::spawn_supervised(
1748            &stage.runtime,
1749            "transition-choice",
1750            run_inference_job(
1751                job,
1752                stage.transition_outcomes.clone(),
1753                stage.wake.clone(),
1754                crate::inference_bridge::RetryPolicy::default(),
1755                cancel.clone(),
1756            ),
1757            move |message| {
1758                let _ = lost_outcomes.send(crate::inference_bridge::InferenceOutcome {
1759                    entity,
1760                    result: Err(leviath_providers::ProviderError::Other(message)),
1761                    latency: std::time::Duration::ZERO,
1762                });
1763                lost_wake.notify_one();
1764            },
1765        );
1766        track_in_flight(&mut commands, entity, in_flight, cancel);
1767        commands
1768            .entity(entity)
1769            .remove::<AwaitingTransitionChoice>()
1770            .remove::<DispatchStall>()
1771            .insert(AwaitingTransitionResponse(choice.0.clone()));
1772    }
1773}
1774
1775/// Transition-choice collect: drain completed routing inferences, match each to a
1776/// target stage (or completion), record the decision in context, and either enter
1777/// the chosen stage (loop to `ReadyToInfer`) or mark the agent `Complete`. A
1778/// provider error marks the agent `Error`.
1779#[allow(clippy::type_complexity)]
1780pub fn collect_transition_choice(
1781    mut results: ResMut<TransitionResults>,
1782    mut agents: Query<(
1783        &AgentBlueprint,
1784        &mut StageCursor,
1785        &mut AgentState,
1786        &mut StageProgress,
1787        &StageInferences,
1788        &StageSetups,
1789        &mut VisitCounts,
1790        &mut ContextWindow,
1791        &AwaitingTransitionResponse,
1792        Option<&mut crate::persistence::RunOutcomeFlags>,
1793        Option<&crate::persistence::RunMetadata>,
1794    )>,
1795    sink: Option<Res<crate::host::WorldEventSink>>,
1796    mut commands: Commands,
1797) {
1798    crate::tick_scope::clear();
1799    while let Ok(outcome) = results.0.try_recv() {
1800        let Ok((
1801            bp,
1802            mut cursor,
1803            mut state,
1804            mut progress,
1805            stage_infs,
1806            setups,
1807            mut visits,
1808            mut window,
1809            resp,
1810            mut flags,
1811            metadata,
1812        )) = agents.get_mut(outcome.entity)
1813        else {
1814            continue; // stale: agent cancelled/despawned since dispatch
1815        };
1816        crate::tick_scope::enter(outcome.entity);
1817        // Cancelled/failed mid-choice: every arm below rewrites the status
1818        // (including a bare `Complete` when nothing matches), which would report
1819        // a cancelled run as having finished normally.
1820        if is_terminal_status(&state.status) {
1821            commands
1822                .entity(outcome.entity)
1823                .remove::<AwaitingTransitionResponse>()
1824                .remove::<InFlightWork>();
1825            continue;
1826        }
1827        let response = match outcome.result {
1828            Ok(response) => response,
1829            Err(err) => {
1830                state.status = AgentStatus::Error {
1831                    message: err.to_string(),
1832                };
1833                commands
1834                    .entity(outcome.entity)
1835                    .remove::<AwaitingTransitionResponse>();
1836                continue;
1837            }
1838        };
1839
1840        let choice = response.content.trim().to_string();
1841        let tokens = leviath_core::estimate_tokens(&choice);
1842        let _ = window.add_typed_entry(
1843            "conversation",
1844            leviath_core::EntryKind::AssistantTurn { tool_calls: vec![] },
1845            format!("Transitioning to: {choice}"),
1846            tokens,
1847        );
1848
1849        let allow_complete = bp.0.stages[cursor.index].allow_complete;
1850        match match_transition_choice(&choice, &resp.0, allow_complete) {
1851            Some(target) => {
1852                let idx =
1853                    bp.0.stages
1854                        .iter()
1855                        .position(|s| s.name == target)
1856                        .unwrap_or(0);
1857                // The chosen edge (absent when the matched target has no explicit
1858                // edge, e.g. a fallback - then Direct, ungated).
1859                let edge = resp.0.iter().find(|e| e.target == target);
1860                let transform = edge.map(|e| e.transform.clone()).unwrap_or_default();
1861                // The edge's gate is checked BEFORE its transform runs, so a
1862                // held stage keeps the context it still needs.
1863                let stage = &bp.0.stages[cursor.index];
1864                match gate_blocks(
1865                    edge.and_then(|e| e.gate.as_ref()),
1866                    stage,
1867                    &progress,
1868                    &window,
1869                ) {
1870                    GateDecision::Block(nudge) => {
1871                        hold_for_gate(
1872                            outcome.entity,
1873                            &nudge,
1874                            &mut progress,
1875                            &mut window,
1876                            &mut commands,
1877                        );
1878                        continue;
1879                    }
1880                    GateDecision::Forced => {
1881                        if let Some(flags) = flags.as_mut() {
1882                            flags.0.gates_forced += 1;
1883                        }
1884                    }
1885                    GateDecision::Pass => {}
1886                }
1887                let to_compact = apply_edge_transform(&mut window, &transform);
1888                let setup = &setups.0[idx];
1889                let from = state.current_stage.clone();
1890                match enter_stage(
1891                    idx,
1892                    &bp.0,
1893                    &mut cursor,
1894                    &mut state,
1895                    &mut progress,
1896                    &mut visits,
1897                    setup,
1898                    &mut window,
1899                ) {
1900                    Ok(visit) => {
1901                        let name = bp.0.stages[idx].name.clone();
1902                        emit_stage_transition(&sink, metadata, &state.agent_id, from, &name, visit);
1903                        let mut ec = commands.entity(outcome.entity);
1904                        ec.remove::<AwaitingTransitionResponse>();
1905                        attach_stage_components(ec, stage_infs.0[idx].clone(), setup, idx, name);
1906                        if !to_compact.is_empty() {
1907                            commands
1908                                .entity(outcome.entity)
1909                                .insert(PendingEdgeCompact(to_compact));
1910                        }
1911                    }
1912                    Err(message) => {
1913                        state.status = AgentStatus::Error { message };
1914                        commands
1915                            .entity(outcome.entity)
1916                            .remove::<AwaitingTransitionResponse>();
1917                    }
1918                }
1919            }
1920            None => {
1921                state.status = AgentStatus::Complete;
1922                commands
1923                    .entity(outcome.entity)
1924                    .remove::<AwaitingTransitionResponse>();
1925            }
1926        }
1927    }
1928}
1929
1930/// Notify the [`ToolService`] of every agent that just entered a stage (tagged
1931/// with [`StageJustEntered`] by the transition systems), so it can re-sync that
1932/// agent's per-stage tool permissions, then clear the tag. Runs after the
1933/// transition systems each tick.
1934pub fn sync_tool_stages(
1935    service: Res<ToolServiceRes>,
1936    entered: Query<(Entity, &StageJustEntered)>,
1937    mut commands: Commands,
1938) {
1939    crate::tick_scope::clear();
1940    for (entity, stage) in entered.iter() {
1941        crate::tick_scope::enter(entity);
1942        service.0.sync_stage(entity, stage.index, &stage.name);
1943        commands.entity(entity).remove::<StageJustEntered>();
1944    }
1945}
1946
1947/// Re-advertise an agent's tools mid-run: when tagged [`ToolsNeedRefresh`], ask
1948/// the tool service for this stage's freshly-resolved tool defs and, if it
1949/// returns a set, write it into the live [`StageInference`] (what the next
1950/// inference request advertises, read fresh by `build_request`) and the matching
1951/// [`StageInferences`] catalog entry (so a later revisit of this stage keeps the
1952/// updated set). Always consumes the marker. This is the mechanism behind
1953/// mid-run dynamic tool discovery and lazily-listed MCP tools.
1954pub fn refresh_advertised_tools(
1955    service: Res<ToolServiceRes>,
1956    mut agents: Query<
1957        (
1958            Entity,
1959            &StageCursor,
1960            &mut StageInference,
1961            &mut StageInferences,
1962        ),
1963        With<ToolsNeedRefresh>,
1964    >,
1965    mut commands: Commands,
1966) {
1967    crate::tick_scope::clear();
1968    for (entity, cursor, mut si, mut sis) in agents.iter_mut() {
1969        crate::tick_scope::enter(entity);
1970        if let Some(tools) = service.0.refresh_tools(entity, cursor.index) {
1971            si.tools = tools.clone();
1972            // Keep the catalog entry in sync so re-entering this stage advertises
1973            // the same refreshed set.
1974            if let Some(slot) = sis.0.get_mut(cursor.index) {
1975                slot.tools = tools;
1976            }
1977        }
1978        commands.entity(entity).remove::<ToolsNeedRefresh>();
1979    }
1980}
1981
1982/// Poll each `dynamic_tools` agent for a pending tool re-scan and, when the tool
1983/// service reports one, tag it [`ToolsNeedRefresh`] so [`refresh_advertised_tools`]
1984/// re-advertises before its next turn. Only agents carrying [`DynamicTools`] are
1985/// queried, so static agents (the default) cost nothing.
1986pub fn poll_dynamic_tool_refresh(
1987    service: Res<ToolServiceRes>,
1988    agents: Query<Entity, With<DynamicTools>>,
1989    mut commands: Commands,
1990) {
1991    crate::tick_scope::clear();
1992    for entity in agents.iter() {
1993        crate::tick_scope::enter(entity);
1994        if service.0.wants_refresh(entity) {
1995            commands.entity(entity).insert(ToolsNeedRefresh);
1996        }
1997    }
1998}