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}