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