Skip to main content

leviath_runtime/pipeline/
requirements.rs

1//! Stage-exit requirement gates: the systems that hold an agent at a stage
2//! boundary until something it owes has arrived - its sub-agents, its required
3//! context regions, or its final output.
4//!
5//! Distinct from `gate.rs`, which is the taint gate on tool output.
6
7use super::*;
8
9/// `requires_children` gate (exclusive, mirrors the fan-out wait): a stage marked
10/// `requires_children` may not transition while any of the agent's spawned
11/// sub-agents ([`SubAgentChildren`](crate::components::SubAgentChildren)) are
12/// still running - the parent is held `Waiting` (`WaitingForChildren`) and
13/// resumes (re-inserting `ResolveTransition`, back to `Active`) once every child
14/// is terminal.
15pub fn gate_requires_children(world: &mut World) {
16    crate::tick_scope::clear();
17    use crate::components::SubAgentChildren;
18
19    // Hold: transitioning agents whose stage requires children that aren't done.
20    // `&AgentState` in the query guarantees the later `.expect()` never fires.
21    let mut candidates: Vec<(Entity, Vec<Entity>)> = Vec::new();
22    {
23        let mut q = world.query_filtered::<(
24            Entity,
25            &AgentBlueprint,
26            &StageCursor,
27            &SubAgentChildren,
28            &AgentState,
29        ), With<ResolveTransition>>();
30        for (e, bp, cursor, children, _) in q.iter(world) {
31            if bp.0.stages[cursor.index].requires_children {
32                candidates.push((e, children.children.clone()));
33            }
34        }
35    }
36    for (entity, children) in candidates {
37        crate::tick_scope::enter(entity);
38        let pending = children.iter().any(|&c| {
39            world
40                .get::<AgentState>(c)
41                .is_some_and(|s| !is_terminal_status(&s.status))
42        });
43        if pending {
44            world
45                .entity_mut(entity)
46                .remove::<ResolveTransition>()
47                .insert(WaitingForChildren);
48            world
49                .get_mut::<AgentState>(entity)
50                .expect("held agent has AgentState")
51                .status = AgentStatus::Waiting;
52        }
53    }
54
55    // Resume: held agents whose children have all finished.
56    crate::tick_scope::clear();
57    let mut waiting: Vec<(Entity, Vec<Entity>)> = Vec::new();
58    {
59        let mut q = world.query_filtered::<
60            (Entity, Option<&SubAgentChildren>, &AgentState),
61            With<WaitingForChildren>,
62        >();
63        for (e, children, _) in q.iter(world) {
64            waiting.push((e, children.map(|c| c.children.clone()).unwrap_or_default()));
65        }
66    }
67    for (entity, children) in waiting {
68        crate::tick_scope::enter(entity);
69        let all_done = children.iter().all(|&c| {
70            world
71                .get::<AgentState>(c)
72                .is_none_or(|s| is_terminal_status(&s.status))
73        });
74        if all_done {
75            world
76                .entity_mut(entity)
77                .remove::<WaitingForChildren>()
78                .insert(ResolveTransition);
79            world
80                .get_mut::<AgentState>(entity)
81                .expect("waiting agent has AgentState")
82                .status = AgentStatus::Active;
83        }
84    }
85}
86
87/// Default re-entry cap for required-region gating: how many times a stage is
88/// re-run to populate an empty `required` region before proceeding anyway (with a
89/// warning). Overridable per stage via `max_revisits`.
90pub(crate) const DEFAULT_REQUIRED_REENTRY_CAP: usize = 3;
91
92/// Counts how many times the current stage has been re-run to satisfy required
93/// context regions. Absent ⇒ 0; reset when a new stage is entered.
94#[derive(Component, Debug, Clone, Copy)]
95pub struct RequiredReentries(pub usize);
96
97/// Required regions (from the stage's effective layout) still empty at stage end,
98/// as `(name, optional custom message)`. Empty when the stage has no
99/// context-writing tool (gating a stage that can't populate the region would loop
100/// pointlessly). Ported from the imperative `unmet_required_regions`.
101pub(crate) fn unmet_required_regions(
102    blueprint: &leviath_core::Blueprint,
103    stage: &leviath_core::Stage,
104    window: &ContextWindow,
105) -> Vec<(String, Option<String>)> {
106    let can_write = stage
107        .available_tools
108        .iter()
109        .any(|t| t == "context_write" || t == "context_append");
110    if !can_write {
111        return Vec::new();
112    }
113    let layout = stage
114        .context_layout
115        .as_ref()
116        .unwrap_or(&blueprint.context_layout);
117    layout
118        .regions
119        .iter()
120        .filter(|r| r.required)
121        // Caller-input regions are validated (and seeded) at spawn, not written
122        // by the agent - skip them here so this gate never nags the agent to
123        // populate a slot the caller owns.
124        .filter(|r| {
125            !matches!(
126                r.seed,
127                Some(leviath_core::layout::RegionSeed::CallerInput { .. })
128            )
129        })
130        .filter(|r| {
131            window
132                .get_region(&r.name)
133                .map(|reg| reg.content.is_empty())
134                .unwrap_or(true)
135        })
136        .map(|r| (r.name.clone(), r.required_message.clone()))
137        .collect()
138}
139
140/// Inject a `[System]` nudge into the conversation region for each unmet required
141/// region, so the stage re-run tells the agent exactly what to populate. A custom
142/// `required_message` may name the region via a `{region}` placeholder; the
143/// generated default is built through the same substitution.
144pub(crate) fn inject_required_region_nudges(
145    window: &mut ContextWindow,
146    unmet: &[(String, Option<String>)],
147) {
148    const DEFAULT_REQUIRED_MESSAGE: &str = "Required context region '{region}' is still empty. \
149         You must populate it (e.g. via context_write with region=\"{region}\") before this \
150         stage can complete.";
151    for (name, msg) in unmet {
152        let text = leviath_core::text::interpolate(
153            msg.as_deref().unwrap_or(DEFAULT_REQUIRED_MESSAGE),
154            &[("region", name)],
155        );
156        crate::pipeline::response::inject_system_nudge(window, &text);
157    }
158}
159
160/// What `require_context_regions` selects.
161///
162/// `&'static` is bevy's `WorldQuery` convention, not a claim about
163/// lifetimes: the borrow is bound when the query is fetched.
164type ContextRegionQuery = (
165    Entity,
166    &'static AgentBlueprint,
167    &'static StageCursor,
168    &'static mut ContextWindow,
169    Option<&'static RequiredReentries>,
170    Option<&'static StageOutcome>,
171    Option<&'static mut crate::persistence::RunOutcomeFlags>,
172);
173
174/// Required-region gate: before a normally-completed stage transitions, if it can
175/// write context and a `required` region is still empty, inject a nudge and re-run
176/// the stage (loop back to `ReadyToInfer`) instead of transitioning - bounded by
177/// the stage's `max_revisits` (or a default cap), after which
178/// it proceeds with a warning. Skipped when the stage ended on an error / max-iter
179/// outcome (those transitions take precedence). Ported from the imperative gate.
180pub fn require_context_regions(
181    mut agents: Query<ContextRegionQuery, With<ResolveTransition>>,
182    mut commands: Commands,
183) {
184    crate::tick_scope::clear();
185    for (entity, bp, cursor, mut window, reentries, outcome, flags) in agents.iter_mut() {
186        crate::tick_scope::enter(entity);
187        if outcome.is_some() {
188            continue; // error / max-iterations transition takes precedence
189        }
190        let stage = &bp.0.stages[cursor.index];
191        let unmet = unmet_required_regions(&bp.0, stage, &window);
192        if unmet.is_empty() {
193            continue;
194        }
195        let cap = stage.max_revisits.unwrap_or(DEFAULT_REQUIRED_REENTRY_CAP);
196        let round = reentries.map_or(0, |r| r.0);
197        if round >= cap {
198            let names: Vec<&str> = unmet.iter().map(|(n, _)| n.as_str()).collect();
199            tracing::warn!(
200                stage = %stage.name,
201                regions = ?names,
202                attempts = cap,
203                "required context regions still empty after re-run attempts; proceeding"
204            );
205            // Recorded as well as logged. A log line is not readable after the
206            // fact, so "the agent wrote its plan" and "we asked twice and moved
207            // on" both finished `complete` and nothing downstream could tell
208            // them apart - which is how this went unnoticed across four
209            // benchmark rounds (#371).
210            if let Some(mut flags) = flags {
211                for name in &names {
212                    if !flags
213                        .0
214                        .required_regions_abandoned
215                        .iter()
216                        .any(|seen| seen == name)
217                    {
218                        flags.0.required_regions_abandoned.push((*name).to_string());
219                    }
220                }
221            }
222            continue; // proceed with the transition despite the unmet regions
223        }
224        inject_required_region_nudges(&mut window, &unmet);
225        commands
226            .entity(entity)
227            .remove::<ResolveTransition>()
228            .insert(ReadyToInfer)
229            .insert(RequiredReentries(round + 1));
230    }
231}
232
233/// Counts how many times the current stage has been re-run for a final output
234/// it was required to produce. Absent ⇒ 0; reset when a new stage is entered.
235#[derive(Component, Debug, Clone, Copy)]
236pub struct OutputReentries(pub usize);
237
238/// The nudge a stage gets when it finishes without the output it owes.
239///
240/// Names the tool rather than describing it, because the description the model
241/// already has carries the shape; what it missed was that the call is not
242/// optional.
243const MISSING_OUTPUT_NUDGE: &str = "This stage is not finished: you have not called `submit_output`. Whatever you wrote to \
244     files or to context is not what the caller receives - only the final output is. Call \
245     `submit_output` now with your answer.";
246
247/// What `require_final_output` selects.
248///
249/// `&'static` is bevy's `WorldQuery` convention, not a claim about
250/// lifetimes: the borrow is bound when the query is fetched.
251type FinalOutputQuery = (
252    Entity,
253    &'static AgentBlueprint,
254    &'static StageCursor,
255    &'static AgentState,
256    &'static mut ContextWindow,
257    Option<&'static OutputReentries>,
258    Option<&'static StageOutcome>,
259    Option<&'static crate::persistence::FinalOutput>,
260    Option<&'static mut crate::persistence::RunOutcomeFlags>,
261);
262
263/// Required-output gate: hold a stage that owes a final output and has not
264/// submitted one, nudge it, and re-run - bounded, then give up loudly.
265///
266/// The same shape as [`require_context_regions`] and the edge gate's
267/// `require_modifications`, and deliberately so: a missing output never strands
268/// a run. When the re-entry budget is spent the transition proceeds and the run
269/// records `output_forced`, so a caller reading `meta.json` can tell "no answer
270/// because the agent never gave one" from "no answer because nobody asked".
271///
272/// Skipped when the stage ended on an error or max-iterations outcome, which
273/// take precedence: an agent that already failed should follow its error edge
274/// rather than be told to summarise.
275///
276/// Whether *this stage* submitted is the question, not whether the run holds an
277/// output from anywhere. A blueprint whose worker stage submits and whose later
278/// summary stage also must would otherwise let the summary coast on the
279/// worker's answer.
280pub fn require_final_output(
281    mut agents: Query<FinalOutputQuery, With<ResolveTransition>>,
282    mut commands: Commands,
283) {
284    crate::tick_scope::clear();
285    for (entity, bp, cursor, state, mut window, reentries, outcome, submitted, mut flags) in
286        agents.iter_mut()
287    {
288        crate::tick_scope::enter(entity);
289        let stage = &bp.0.stages[cursor.index];
290        if !stage.require_output {
291            continue;
292        }
293        // An output carried in from an earlier stage does not discharge this
294        // stage's obligation.
295        if submitted.is_some_and(|o| o.0.stage == state.current_stage) {
296            continue;
297        }
298        // An error or max-iterations transition takes precedence over the nudge:
299        // the stage is already ending and holding it here would fight that. The
300        // *flag* still has to be honest though. A model that cannot satisfy its
301        // validator burns every iteration retrying and leaves on that path, so
302        // this is the ordinary way a required output goes missing, not an edge
303        // case. Left unrecorded the run reports `output_forced: 0`, which reads
304        // as "nothing was required" rather than "the requirement went unmet".
305        if outcome.is_some() {
306            tracing::warn!(
307                stage = %stage.name,
308                "stage ended without its required final output"
309            );
310            if let Some(flags) = flags.as_mut() {
311                flags.0.output_forced += 1;
312            }
313            continue;
314        }
315        // Its own budget, not the stage's `max_revisits`. Those are different
316        // questions - "how many times may the graph re-enter this stage" and
317        // "how many times do we nudge a model that owes an answer" - and
318        // borrowing the first for the second made a routing setting silently
319        // multiply an inference bill. Each retry re-sends the whole stage
320        // context, and an output stage runs last, when that context is at its
321        // largest: an agent with `max_revisits = 10` billed ten full prompts to
322        // fail to say one word.
323        let cap = leviath_core::blueprint::DEFAULT_OUTPUT_REENTRY_CAP;
324        let round = reentries.map_or(0, |r| r.0);
325        if round >= cap {
326            tracing::warn!(
327                stage = %stage.name,
328                attempts = cap,
329                "stage never produced its required final output; proceeding without one"
330            );
331            if let Some(flags) = flags.as_mut() {
332                flags.0.output_forced += 1;
333            }
334            continue; // proceed rather than strand the run
335        }
336        crate::pipeline::response::inject_system_nudge(&mut window, MISSING_OUTPUT_NUDGE);
337        commands
338            .entity(entity)
339            .remove::<ResolveTransition>()
340            .insert(ReadyToInfer)
341            .insert(OutputReentries(round + 1));
342    }
343}
344
345/// What a chosen edge's gate says about the transition.
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub(crate) enum GateDecision {
348    /// The gate is satisfied (or absent) - follow the edge.
349    Pass,
350    /// The gate is unsatisfied but out of re-run budget - follow the edge and
351    /// record it in the run's flags so the run explains itself afterwards.
352    Forced,
353    /// Hold the agent in this stage and show it this nudge.
354    Block(String),
355}