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