1use super::*;
4
5#[derive(Component, Debug, Clone)]
9pub struct AgentBlueprint(pub leviath_core::Blueprint);
10
11#[derive(Component, Debug, Clone, Copy)]
13pub struct StageCursor {
14 pub index: usize,
16}
17
18#[derive(Component, Debug, Clone)]
23pub struct StageInferences(pub Vec<StageInference>);
24
25#[derive(Component, Debug, Clone, Default)]
27pub struct VisitCounts(pub std::collections::HashMap<String, usize>);
28
29#[derive(Clone)]
36pub struct StageSetup {
37 pub inference_config: InferenceConfig,
39 pub routing: Option<leviath_core::ToolResultRouting>,
41 pub accepts_messages: bool,
43 pub context_layout: Option<leviath_core::ContextLayout>,
45 pub system_prompt: Option<String>,
47}
48
49#[derive(Component, Clone)]
51pub struct StageSetups(pub Vec<StageSetup>);
52
53#[derive(Component, Debug, Clone)]
57pub struct AwaitingTransitionChoice(pub Vec<leviath_core::blueprint::TransitionEdge>);
58
59pub(crate) enum StageResolution {
61 Terminal,
63 TerminalError,
66 Next(
69 usize,
70 leviath_core::blueprint::EdgeTransform,
71 Option<leviath_core::blueprint::TransitionGate>,
72 ),
73 Choose(Vec<leviath_core::blueprint::TransitionEdge>),
75 Resume,
80}
81
82pub(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
107pub(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
119pub const WORKSPACE_CHECK_INTERVAL: usize = 5;
123
124#[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#[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 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
214pub(crate) const STUCK_REPORT_REGION: &str = "stuck_report";
218
219pub(crate) const ERROR_REPORT_REGION: &str = "error_report";
224
225#[derive(Debug, Clone, Default, PartialEq, Eq)]
228pub(crate) struct StuckMetrics {
229 pub iterations: usize,
231 pub elapsed_secs: u64,
233 pub tool_calls: usize,
235 pub hottest_edit: Option<(String, usize)>,
237}
238
239pub(crate) fn detect_stuck(
245 cfg: &leviath_core::blueprint::StuckConfig,
246 m: &StuckMetrics,
247) -> Option<String> {
248 if let (Some(limit), Some((path, hits))) = (cfg.after_same_file_edits, m.hottest_edit.as_ref())
249 && *hits >= limit
250 {
251 return Some(format!(
252 "you have written or edited '{path}' {hits} times in this stage without \
253 resolving the task - the problem is very likely not in that file"
254 ));
255 }
256 if let Some(limit) = cfg.after_iterations
257 && m.iterations >= limit
258 {
259 return Some(format!(
260 "you have run {} inference turns in this stage without finishing it",
261 m.iterations
262 ));
263 }
264 if let Some(limit) = cfg.after_tool_calls
265 && m.tool_calls >= limit
266 {
267 return Some(format!(
268 "you have made {} tool calls in this stage without finishing it",
269 m.tool_calls
270 ));
271 }
272 if let Some(limit) = cfg.after_minutes
273 && m.elapsed_secs >= limit as u64 * 60
274 {
275 return Some(format!(
276 "you have spent {} minutes in this stage without finishing it",
277 m.elapsed_secs / 60
278 ));
279 }
280 None
281}
282
283pub(crate) fn hottest_edit(
286 edits: &std::collections::HashMap<String, usize>,
287) -> Option<(String, usize)> {
288 edits
289 .iter()
290 .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
291 .map(|(path, n)| (path.clone(), *n))
292}
293
294pub(crate) fn note_stuck(window: &mut ContextWindow, stage: &str, reason: &str) {
299 let region = if window.get_region(STUCK_REPORT_REGION).is_some() {
300 STUCK_REPORT_REGION
301 } else {
302 "conversation"
303 };
304 let content = format!(
305 "[Stuck detected in stage '{stage}'] {reason}. Stop repeating what you have been \
306 doing. Re-read the original task, separate what you have actually verified from \
307 what you assumed, and take a different approach - including reverting changes \
308 that made things worse."
309 );
310 let tokens = leviath_core::estimate_tokens(&content);
311 let _ = window.add_to_region(region, content, tokens);
312}
313
314fn note_abnormal_ending(window: &mut ContextWindow, content: String) {
318 let region = if window.get_region(ERROR_REPORT_REGION).is_some() {
319 ERROR_REPORT_REGION
320 } else {
321 "conversation"
322 };
323 let tokens = leviath_core::estimate_tokens(&content);
324 let _ = window.add_to_region(region, content, tokens);
325}
326
327pub(crate) fn note_error(window: &mut ContextWindow, stage: &str, message: &str) {
331 note_abnormal_ending(
332 window,
333 format!(
334 "[Inference error in stage '{stage}'] {message}. Diagnose this failure from \
335 the error text above before retrying or working around it."
336 ),
337 );
338}
339
340pub(crate) fn note_max_iterations(window: &mut ContextWindow, stage: &str, cap: usize) {
344 note_abnormal_ending(
345 window,
346 format!(
347 "[Stage '{stage}' hit its iteration cap ({cap})] The stage was cut off before \
348 it declared completion - treat its output as possibly incomplete and verify \
349 it before building on it."
350 ),
351 );
352}
353
354#[allow(clippy::type_complexity)]
366pub fn detect_stuck_stage(
367 mut agents: Query<
368 (
369 Entity,
370 &AgentState,
371 &AgentBlueprint,
372 &StageCursor,
373 &mut StageProgress,
374 &VisitCounts,
375 &mut ContextWindow,
376 Option<&mut StageIoBuffer>,
377 ),
378 With<ReadyToInfer>,
379 >,
380 mut commands: Commands,
381) {
382 use leviath_core::blueprint::TransitionCondition;
383 let now = chrono::Utc::now().timestamp();
384 crate::tick_scope::clear();
385 for (entity, state, bp, cursor, mut progress, visits, mut window, buffer) in agents.iter_mut() {
386 crate::tick_scope::enter(entity);
387 if state.status != AgentStatus::Active || progress.stuck_fired {
388 continue; }
390 let stage = &bp.0.stages[cursor.index];
391 let Some(cfg) =
392 find_conditioned_edge_ref(&bp.0, stage, &visits.0, TransitionCondition::Stuck)
393 .and_then(|(_, edge)| edge.stuck)
394 else {
395 continue; };
397 let started = *progress.stage_started_at.get_or_insert(now);
401 let metrics = StuckMetrics {
402 iterations: progress.iterations,
403 elapsed_secs: (now - started).max(0) as u64,
404 tool_calls: progress.total_tool_calls,
405 hottest_edit: hottest_edit(&progress.edits_by_path),
406 };
407 let Some(reason) = detect_stuck(&cfg, &metrics) else {
408 continue;
409 };
410 progress.stuck_fired = true;
411 note_stuck(&mut window, &stage.name, &reason);
412 if let Some(mut buffer) = buffer {
413 buffer
414 .logs
415 .push((cursor.index, format!("[stuck] {reason}")));
416 }
417 commands
418 .entity(entity)
419 .remove::<ReadyToInfer>()
420 .insert(ResolveTransition)
421 .insert(StageOutcome::Stuck(reason));
422 }
423}
424
425pub(crate) fn resolve_transition_sync(
430 blueprint: &leviath_core::Blueprint,
431 stage: &leviath_core::Stage,
432 stage_idx: usize,
433 visits: &std::collections::HashMap<String, usize>,
434) -> StageResolution {
435 use leviath_core::blueprint::TransitionCondition;
436 match &stage.transitions {
437 None => {
438 if stage_idx + 1 < blueprint.stages.len() {
439 StageResolution::Next(
442 stage_idx + 1,
443 leviath_core::blueprint::EdgeTransform::Direct,
444 None,
445 )
446 } else {
447 StageResolution::Terminal
448 }
449 }
450 Some(transitions) => {
451 if transitions.is_empty() {
452 return StageResolution::Terminal;
453 }
454 let available: Vec<&leviath_core::blueprint::TransitionEdge> = transitions
456 .values()
457 .filter(|e| match blueprint.find_stage(&e.target) {
458 Some(ts) => match ts.max_revisits {
459 Some(max) => visits.get(&e.target).copied().unwrap_or(0) <= max,
460 None => true,
461 },
462 None => false, })
464 .collect();
465 let choosable: Vec<&leviath_core::blueprint::TransitionEdge> = available
467 .into_iter()
468 .filter(|e| {
469 matches!(
470 e.condition,
471 TransitionCondition::Always | TransitionCondition::LlmChoice
472 )
473 })
474 .collect();
475 match choosable.len() {
476 0 => StageResolution::Terminal,
477 1 if !stage.allow_complete => {
478 let idx = blueprint
479 .stages
480 .iter()
481 .position(|s| s.name == choosable[0].target)
482 .unwrap_or(0);
483 StageResolution::Next(
484 idx,
485 choosable[0].transform.clone(),
486 choosable[0].gate.clone(),
487 )
488 }
489 _ => StageResolution::Choose(choosable.into_iter().cloned().collect()),
490 }
491 }
492 }
493}
494
495#[derive(Component, Debug, Clone, Copy)]
499pub struct WaitingForChildren;
500
501pub fn is_terminal_status(status: &AgentStatus) -> bool {
507 matches!(
508 status,
509 AgentStatus::Complete | AgentStatus::Error { .. } | AgentStatus::Cancelled
510 )
511}
512
513pub fn gate_requires_children(world: &mut World) {
520 crate::tick_scope::clear();
521 use crate::components::SubAgentChildren;
522
523 let mut candidates: Vec<(Entity, Vec<Entity>)> = Vec::new();
526 {
527 let mut q = world.query_filtered::<(
528 Entity,
529 &AgentBlueprint,
530 &StageCursor,
531 &SubAgentChildren,
532 &AgentState,
533 ), With<ResolveTransition>>();
534 for (e, bp, cursor, children, _) in q.iter(world) {
535 if bp.0.stages[cursor.index].requires_children {
536 candidates.push((e, children.children.clone()));
537 }
538 }
539 }
540 for (entity, children) in candidates {
541 crate::tick_scope::enter(entity);
542 let pending = children.iter().any(|&c| {
543 world
544 .get::<AgentState>(c)
545 .is_some_and(|s| !is_terminal_status(&s.status))
546 });
547 if pending {
548 world
549 .entity_mut(entity)
550 .remove::<ResolveTransition>()
551 .insert(WaitingForChildren);
552 world
553 .get_mut::<AgentState>(entity)
554 .expect("held agent has AgentState")
555 .status = AgentStatus::Waiting;
556 }
557 }
558
559 crate::tick_scope::clear();
561 let mut waiting: Vec<(Entity, Vec<Entity>)> = Vec::new();
562 {
563 let mut q = world.query_filtered::<
564 (Entity, Option<&SubAgentChildren>, &AgentState),
565 With<WaitingForChildren>,
566 >();
567 for (e, children, _) in q.iter(world) {
568 waiting.push((e, children.map(|c| c.children.clone()).unwrap_or_default()));
569 }
570 }
571 for (entity, children) in waiting {
572 crate::tick_scope::enter(entity);
573 let all_done = children.iter().all(|&c| {
574 world
575 .get::<AgentState>(c)
576 .is_none_or(|s| is_terminal_status(&s.status))
577 });
578 if all_done {
579 world
580 .entity_mut(entity)
581 .remove::<WaitingForChildren>()
582 .insert(ResolveTransition);
583 world
584 .get_mut::<AgentState>(entity)
585 .expect("waiting agent has AgentState")
586 .status = AgentStatus::Active;
587 }
588 }
589}
590
591pub(crate) const DEFAULT_REQUIRED_REENTRY_CAP: usize = 3;
595
596#[derive(Component, Debug, Clone, Copy)]
599pub struct RequiredReentries(pub usize);
600
601pub(crate) fn unmet_required_regions(
606 blueprint: &leviath_core::Blueprint,
607 stage: &leviath_core::Stage,
608 window: &ContextWindow,
609) -> Vec<(String, Option<String>)> {
610 let can_write = stage
611 .available_tools
612 .iter()
613 .any(|t| t == "context_write" || t == "context_append");
614 if !can_write {
615 return Vec::new();
616 }
617 let layout = stage
618 .context_layout
619 .as_ref()
620 .unwrap_or(&blueprint.context_layout);
621 layout
622 .regions
623 .iter()
624 .filter(|r| r.required)
625 .filter(|r| {
629 !matches!(
630 r.seed,
631 Some(leviath_core::layout::RegionSeed::CallerInput { .. })
632 )
633 })
634 .filter(|r| {
635 window
636 .get_region(&r.name)
637 .map(|reg| reg.content.is_empty())
638 .unwrap_or(true)
639 })
640 .map(|r| (r.name.clone(), r.required_message.clone()))
641 .collect()
642}
643
644pub(crate) fn inject_required_region_nudges(
649 window: &mut ContextWindow,
650 unmet: &[(String, Option<String>)],
651) {
652 const DEFAULT_REQUIRED_MESSAGE: &str = "Required context region '{region}' is still empty. \
653 You must populate it (e.g. via context_write with region=\"{region}\") before this \
654 stage can complete.";
655 for (name, msg) in unmet {
656 let text = leviath_core::text::interpolate(
657 msg.as_deref().unwrap_or(DEFAULT_REQUIRED_MESSAGE),
658 &[("region", name)],
659 );
660 crate::pipeline::response::inject_system_nudge(window, &text);
661 }
662}
663
664#[allow(clippy::type_complexity)]
671pub fn require_context_regions(
672 mut agents: Query<
673 (
674 Entity,
675 &AgentBlueprint,
676 &StageCursor,
677 &mut ContextWindow,
678 Option<&RequiredReentries>,
679 Option<&StageOutcome>,
680 ),
681 With<ResolveTransition>,
682 >,
683 mut commands: Commands,
684) {
685 crate::tick_scope::clear();
686 for (entity, bp, cursor, mut window, reentries, outcome) in agents.iter_mut() {
687 crate::tick_scope::enter(entity);
688 if outcome.is_some() {
689 continue; }
691 let stage = &bp.0.stages[cursor.index];
692 let unmet = unmet_required_regions(&bp.0, stage, &window);
693 if unmet.is_empty() {
694 continue;
695 }
696 let cap = stage.max_revisits.unwrap_or(DEFAULT_REQUIRED_REENTRY_CAP);
697 let round = reentries.map_or(0, |r| r.0);
698 if round >= cap {
699 let names: Vec<&str> = unmet.iter().map(|(n, _)| n.as_str()).collect();
700 tracing::warn!(
701 stage = %stage.name,
702 regions = ?names,
703 attempts = cap,
704 "required context regions still empty after re-run attempts; proceeding"
705 );
706 continue; }
708 inject_required_region_nudges(&mut window, &unmet);
709 commands
710 .entity(entity)
711 .remove::<ResolveTransition>()
712 .insert(ReadyToInfer)
713 .insert(RequiredReentries(round + 1));
714 }
715}
716
717#[derive(Debug, Clone, PartialEq, Eq)]
719pub(crate) enum GateDecision {
720 Pass,
722 Forced,
725 Block(String),
727}
728
729pub(crate) fn gate_blocks(
749 gate: Option<&leviath_core::blueprint::TransitionGate>,
750 stage: &leviath_core::Stage,
751 progress: &StageProgress,
752 window: &ContextWindow,
753) -> GateDecision {
754 let Some(gate) = gate else {
755 return GateDecision::Pass;
756 };
757 if !gate.require_modifications {
758 return GateDecision::Pass;
759 }
760 let can_modify = stage.available_tools.iter().any(|t| {
761 let canonical = leviath_tools::canonical_tool_name(t);
762 leviath_core::blueprint::MODIFYING_TOOLS.contains(&canonical)
763 || gate
764 .tools
765 .iter()
766 .any(|extra| leviath_tools::canonical_tool_name(extra) == canonical)
767 });
768 if !can_modify {
769 return GateDecision::Pass;
770 }
771 if progress.modifying_tool_calls > 0 {
772 return GateDecision::Pass;
773 }
774 if progress.blocked_modification_calls > 0 {
775 tracing::warn!(
776 stage = %stage.name,
777 blocked = progress.blocked_modification_calls,
778 "file modifications were denied by policy; letting the gated transition through"
779 );
780 return GateDecision::Pass;
781 }
782 if let Some(region) = &gate.region
783 && window
784 .get_region(region)
785 .is_some_and(|r| !r.content.is_empty())
786 {
787 return GateDecision::Pass;
788 }
789 let cap = gate
790 .max_attempts
791 .unwrap_or(leviath_core::blueprint::DEFAULT_GATE_ATTEMPTS);
792 if progress.gate_reentries >= cap {
793 tracing::warn!(
794 stage = %stage.name,
795 attempts = cap,
796 "stage still has no file modifications after re-run attempts; proceeding"
797 );
798 return GateDecision::Forced;
799 }
800 GateDecision::Block(gate.message.clone().unwrap_or_else(|| {
801 "No file modifications were recorded in this stage. Changes made through the shell \
802 (sed -i, tee, >, >>) are not tracked by the framework. Re-apply your changes with \
803 edit_file or write_file before moving on."
804 .to_string()
805 }))
806}
807
808pub(crate) fn hold_for_gate(
813 entity: Entity,
814 nudge: &str,
815 progress: &mut StageProgress,
816 window: &mut ContextWindow,
817 commands: &mut Commands,
818) {
819 crate::pipeline::response::inject_system_nudge(window, nudge);
820 progress.gate_reentries += 1;
821 commands
822 .entity(entity)
823 .remove::<ResolveTransition>()
824 .remove::<AwaitingTransitionResponse>()
825 .remove::<StageOutcome>()
826 .insert(ReadyToInfer);
827}
828
829#[allow(clippy::type_complexity)]
835pub fn resolve_transition(
836 mut agents: Query<
837 (
838 Entity,
839 &AgentBlueprint,
840 &mut StageCursor,
841 &mut AgentState,
842 &mut StageProgress,
843 &StageInferences,
844 &StageSetups,
845 &mut VisitCounts,
846 &mut ContextWindow,
847 Option<&StageOutcome>,
848 Option<&mut crate::persistence::RunOutcomeFlags>,
849 Option<&crate::persistence::RunMetadata>,
850 ),
851 With<ResolveTransition>,
852 >,
853 sink: Option<Res<crate::host::WorldEventSink>>,
854 mut commands: Commands,
855) {
856 crate::tick_scope::clear();
857 use leviath_core::blueprint::TransitionCondition;
858 for (
859 entity,
860 bp,
861 mut cursor,
862 mut state,
863 mut progress,
864 stage_infs,
865 setups,
866 mut visits,
867 mut window,
868 outcome,
869 mut flags,
870 metadata,
871 ) in agents.iter_mut()
872 {
873 crate::tick_scope::enter(entity);
874 if state.status == AgentStatus::Paused {
878 continue;
879 }
880 let stage = &bp.0.stages[cursor.index];
881 let resolution = match outcome {
884 Some(StageOutcome::Errored(message)) => {
888 match find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::Error) {
889 Some((i, t)) => {
890 note_error(&mut window, &stage.name, message);
894 StageResolution::Next(i, t, None)
895 }
896 None => StageResolution::TerminalError,
897 }
898 }
899 Some(StageOutcome::MaxIterations) => {
900 note_max_iterations(&mut window, &stage.name, stage.max_iterations.unwrap_or(0));
904 find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::MaxIterations)
905 .map(|(i, t)| StageResolution::Next(i, t, None))
906 .unwrap_or_else(|| {
907 resolve_transition_sync(&bp.0, stage, cursor.index, &visits.0)
908 })
909 }
910 Some(StageOutcome::Stuck(_)) => {
911 find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::Stuck)
918 .map(|(i, t)| StageResolution::Next(i, t, None))
919 .unwrap_or(StageResolution::Resume)
920 }
921 None => resolve_transition_sync(&bp.0, stage, cursor.index, &visits.0),
922 };
923 match resolution {
924 StageResolution::Terminal => {
925 state.status = AgentStatus::Complete;
926 commands
927 .entity(entity)
928 .remove::<ResolveTransition>()
929 .remove::<StageOutcome>();
930 }
931 StageResolution::TerminalError => {
932 commands
934 .entity(entity)
935 .remove::<ResolveTransition>()
936 .remove::<StageOutcome>();
937 }
938 StageResolution::Next(idx, transform, gate) => {
939 let gate = outcome.is_none().then_some(gate).flatten();
942 match gate_blocks(gate.as_ref(), stage, &progress, &window) {
943 GateDecision::Block(nudge) => {
944 hold_for_gate(entity, &nudge, &mut progress, &mut window, &mut commands);
945 continue;
946 }
947 GateDecision::Forced => {
948 if let Some(flags) = flags.as_mut() {
949 flags.0.gates_forced += 1;
950 }
951 }
952 GateDecision::Pass => {}
953 }
954 let to_compact = apply_edge_transform(&mut window, &transform);
957 let setup = &setups.0[idx];
958 let from = state.current_stage.clone();
959 match enter_stage(
960 idx,
961 &bp.0,
962 &mut cursor,
963 &mut state,
964 &mut progress,
965 &mut visits,
966 setup,
967 &mut window,
968 ) {
969 Ok(visit) => {
970 state.status = AgentStatus::Active;
973 let name = bp.0.stages[idx].name.clone();
974 emit_stage_transition(&sink, metadata, &state.agent_id, from, &name, visit);
975 let mut ec = commands.entity(entity);
976 ec.remove::<ResolveTransition>().remove::<StageOutcome>();
977 attach_stage_components(ec, stage_infs.0[idx].clone(), setup, idx, name);
978 if !to_compact.is_empty() {
979 commands
980 .entity(entity)
981 .insert(PendingEdgeCompact(to_compact));
982 }
983 }
984 Err(message) => {
985 state.status = AgentStatus::Error { message };
986 commands
987 .entity(entity)
988 .remove::<ResolveTransition>()
989 .remove::<StageOutcome>();
990 }
991 }
992 }
993 StageResolution::Choose(edges) => {
994 commands
995 .entity(entity)
996 .remove::<ResolveTransition>()
997 .remove::<StageOutcome>()
998 .insert(AwaitingTransitionChoice(edges));
999 }
1000 StageResolution::Resume => {
1001 commands
1005 .entity(entity)
1006 .remove::<ResolveTransition>()
1007 .remove::<StageOutcome>()
1008 .insert(ReadyToInfer);
1009 }
1010 }
1011 }
1012}
1013
1014#[allow(clippy::too_many_arguments)]
1026pub(crate) fn enter_stage(
1027 idx: usize,
1028 blueprint: &leviath_core::Blueprint,
1029 cursor: &mut StageCursor,
1030 state: &mut AgentState,
1031 progress: &mut StageProgress,
1032 visits: &mut VisitCounts,
1033 setup: &StageSetup,
1034 window: &mut ContextWindow,
1035) -> Result<usize, String> {
1036 cursor.index = idx;
1037 let name = blueprint.stages[idx].name.clone();
1038 state.current_stage = name.clone();
1039 state.accepts_messages = setup.accepts_messages;
1040 *progress = StageProgress::default();
1041 let visit = visits.0.entry(name).or_insert(0);
1042 *visit += 1;
1043 let visit = *visit;
1044
1045 apply_stage_context(setup, window).map(|()| visit)
1046}
1047
1048fn emit_stage_transition(
1053 sink: &Option<Res<crate::host::WorldEventSink>>,
1054 metadata: Option<&crate::persistence::RunMetadata>,
1055 agent_id: &str,
1056 from: String,
1057 to: &str,
1058 iteration: usize,
1059) {
1060 if let (Some(sink), Some(md)) = (sink.as_ref(), metadata) {
1061 let _ = sink.0.send(crate::host::WorldEvent::StageTransition {
1062 run_id: md.run_id.clone(),
1063 agent_id: agent_id.to_string(),
1064 from,
1065 to: to.to_string(),
1066 iteration,
1067 });
1068 }
1069}
1070
1071pub(crate) fn apply_stage_context(
1077 setup: &StageSetup,
1078 window: &mut ContextWindow,
1079) -> Result<(), String> {
1080 if let Some(layout) = &setup.context_layout {
1081 crate::context_setup::apply_layout(window, layout);
1082 }
1083
1084 let target = window
1087 .regions
1088 .iter()
1089 .find(|r| matches!(r.kind, leviath_core::RegionKind::Pinned))
1090 .map(|r| r.name.clone())
1091 .unwrap_or_else(|| "conversation".to_string());
1092 if let Some(region) = window.regions.iter_mut().find(|r| r.name == target) {
1093 region.remove_entries_by_prefix("[Stage instructions:");
1094 }
1095 if let Some(sp) = &setup.system_prompt {
1096 let content = format!("[Stage instructions: {sp}]");
1097 let tokens = leviath_core::estimate_tokens(&content);
1098 window
1099 .add_to_region(&target, content, tokens)
1100 .map_err(|e| {
1101 format!(
1102 "stage system prompt (~{tokens} tokens) does not fit context region \
1103 '{target}': {e}. Increase that region's max_tokens (or shorten the prompt)."
1104 )
1105 })?;
1106 }
1107 Ok(())
1108}
1109
1110pub(crate) fn attach_stage_components(
1115 mut entity: bevy_ecs::system::EntityCommands,
1116 stage_inf: StageInference,
1117 setup: &StageSetup,
1118 stage_index: usize,
1119 stage_name: String,
1120) {
1121 entity
1122 .insert(stage_inf)
1123 .insert(setup.inference_config.clone())
1124 .insert(StageJustEntered {
1125 index: stage_index,
1126 name: stage_name,
1127 })
1128 .remove::<crate::interaction_points::InteractionPointCursor>()
1130 .remove::<crate::interaction_points::InteractionPointRounds>()
1131 .remove::<RequiredReentries>()
1132 .insert(ReadyToInfer);
1133 match &setup.routing {
1134 Some(routing) => {
1135 entity.insert(crate::components::ToolResultRoutingComponent {
1136 routing: routing.clone(),
1137 });
1138 }
1139 None => {
1140 entity.remove::<crate::components::ToolResultRoutingComponent>();
1141 }
1142 }
1143}
1144
1145pub fn force_transition(world: &mut World, entity: Entity, target_idx: usize) {
1152 let attach: Option<(StageInference, StageSetup, String)> = {
1156 let mut q = world.query::<(
1157 &AgentBlueprint,
1158 &mut StageCursor,
1159 &mut AgentState,
1160 &mut StageProgress,
1161 &StageInferences,
1162 &StageSetups,
1163 &mut VisitCounts,
1164 &mut ContextWindow,
1165 )>();
1166 let Ok((
1167 bp,
1168 mut cursor,
1169 mut state,
1170 mut progress,
1171 stage_infs,
1172 setups,
1173 mut visits,
1174 mut window,
1175 )) = q.get_mut(world, entity)
1176 else {
1177 return; };
1179 let setup = setups.0[target_idx].clone();
1180 let stage_inf = stage_infs.0[target_idx].clone();
1181 let name = bp.0.stages[target_idx].name.clone();
1182 let bp = bp.0.clone();
1183 match enter_stage(
1184 target_idx,
1185 &bp,
1186 &mut cursor,
1187 &mut state,
1188 &mut progress,
1189 &mut visits,
1190 &setup,
1191 &mut window,
1192 ) {
1193 Ok(_) => Some((stage_inf, setup, name)),
1194 Err(message) => {
1195 state.status = AgentStatus::Error { message };
1196 None
1197 }
1198 }
1199 };
1200
1201 let Some((stage_inf, setup, name)) = attach else {
1203 return;
1204 };
1205 let mut em = world.entity_mut(entity);
1206 em.insert(stage_inf)
1207 .insert(setup.inference_config.clone())
1208 .insert(StageJustEntered {
1209 index: target_idx,
1210 name,
1211 })
1212 .insert(ReadyToInfer);
1213 match &setup.routing {
1214 Some(routing) => {
1215 em.insert(crate::components::ToolResultRoutingComponent {
1216 routing: routing.clone(),
1217 });
1218 }
1219 None => {
1220 em.remove::<crate::components::ToolResultRoutingComponent>();
1221 }
1222 }
1223}
1224
1225#[derive(Debug)]
1230pub struct ResolvedStage {
1231 pub provider_name: String,
1233 pub model: String,
1235 pub tools: Vec<Tool>,
1237 pub fallbacks: Vec<leviath_core::blueprint::ModelEntry>,
1240}
1241
1242pub(crate) const DEFAULT_CONTEXT_WINDOW_TOKENS: usize = 8192;
1246
1247pub(crate) fn context_window_tokens(world: &World, provider_name: &str, model: &str) -> usize {
1252 match world
1253 .get_resource::<Providers>()
1254 .and_then(|p| p.0.get(provider_name))
1255 {
1256 Some(provider) => provider.max_context_tokens(model),
1257 None => {
1258 tracing::warn!(
1259 provider = provider_name,
1260 model,
1261 "provider not registered; using default context window for percentage budgets"
1262 );
1263 DEFAULT_CONTEXT_WINDOW_TOKENS
1264 }
1265 }
1266}
1267
1268pub(crate) fn stage_setup_from(
1276 stage: &leviath_core::Stage,
1277 global_hints: leviath_core::config::PromptHints,
1278 agent_hints: leviath_core::config::PromptHintOverrides,
1279) -> StageSetup {
1280 let temperature = stage
1281 .model
1282 .parameters
1283 .get("temperature")
1284 .and_then(|v| v.as_f64())
1285 .map(|t| t as f32);
1286 let extra_params: serde_json::Map<String, serde_json::Value> = stage
1290 .model
1291 .parameters
1292 .iter()
1293 .filter(|(k, _)| k.as_str() != "temperature" && k.as_str() != "max_output_tokens")
1294 .map(|(k, v)| (k.clone(), v.clone()))
1295 .collect();
1296 let max_output_tokens = stage
1297 .model
1298 .parameters
1299 .get("max_output_tokens")
1300 .and_then(|v| v.as_u64())
1301 .map(|t| t as usize);
1302 let base_prompt = stage
1303 .config
1304 .get("system_prompt")
1305 .and_then(|v| v.as_str())
1306 .map(String::from);
1307 let system_prompt = match &stage.mode {
1311 leviath_core::blueprint::StageMode::FanOut { config }
1312 if !config.split_prompt.trim().is_empty() =>
1313 {
1314 Some(match base_prompt {
1315 Some(base) => format!("{base}\n\n{}", config.split_prompt),
1316 None => config.split_prompt.clone(),
1317 })
1318 }
1319 _ => base_prompt,
1320 };
1321 let batch_tool_hint = leviath_core::taint::resolve_batch_tool_hint(
1323 global_hints.batch_tool,
1324 agent_hints.batch_tool,
1325 stage.batch_tool_hint,
1326 );
1327 let shell_hint = leviath_core::taint::resolve_shell_hint(
1328 global_hints.shell,
1329 agent_hints.shell,
1330 stage.shell_hint,
1331 );
1332 StageSetup {
1333 inference_config: InferenceConfig {
1334 temperature,
1335 max_output_tokens,
1336 extra_params,
1337 batch_tool_hint,
1338 shell_hint,
1339 request_timeout_secs: stage.model.request_timeout_secs,
1340 },
1341 routing: stage.tool_result_routing.clone(),
1342 accepts_messages: stage.accepts_messages,
1343 context_layout: stage.context_layout.clone(),
1344 system_prompt,
1345 }
1346}
1347
1348pub fn spawn_agent(
1362 world: &mut World,
1363 agent_id: String,
1364 blueprint: leviath_core::Blueprint,
1365 task: &str,
1366 stages: Vec<ResolvedStage>,
1367 global_hints: leviath_core::config::PromptHints,
1368) -> Result<Entity, String> {
1369 let seeds = std::collections::HashMap::from([("task".to_string(), task.to_string())]);
1370 spawn_agent_seeded(
1377 world,
1378 agent_id,
1379 blueprint,
1380 &seeds,
1381 stages,
1382 global_hints,
1383 leviath_core::NudgeConfig::default(),
1384 std::collections::HashMap::new(),
1385 )
1386}
1387
1388#[allow(clippy::too_many_arguments)]
1398pub fn spawn_agent_seeded(
1399 world: &mut World,
1400 agent_id: String,
1401 mut blueprint: leviath_core::Blueprint,
1402 seeds: &std::collections::HashMap<String, String>,
1403 stages: Vec<ResolvedStage>,
1404 global_hints: leviath_core::config::PromptHints,
1405 global_nudge: leviath_core::NudgeConfig,
1406 region_scripts: std::collections::HashMap<
1407 String,
1408 std::sync::Arc<leviath_scripting::region_hook::RegionScript>,
1409 >,
1410) -> Result<Entity, String> {
1411 let stage_windows: Vec<usize> = stages
1417 .iter()
1418 .map(|rs| context_window_tokens(world, &rs.provider_name, &rs.model))
1419 .collect();
1420 blueprint.context_layout = blueprint.context_layout.resolved(stage_windows[0]);
1421 for (i, stage) in blueprint.stages.iter_mut().enumerate() {
1422 if let Some(layout) = &stage.context_layout {
1423 stage.context_layout = Some(layout.resolved(stage_windows[i]));
1424 }
1425 }
1426 blueprint
1429 .context_layout
1430 .validate()
1431 .map_err(|e| e.to_string())?;
1432 for stage in &blueprint.stages {
1433 if let Some(layout) = &stage.context_layout {
1434 layout.validate().map_err(|e| e.to_string())?;
1435 }
1436 }
1437
1438 let stage_infs: Vec<StageInference> = stages
1439 .into_iter()
1440 .map(|rs| StageInference {
1441 provider_name: rs.provider_name,
1442 model: rs.model,
1443 tools: rs.tools,
1444 tool_filter: None, fallbacks: rs.fallbacks,
1446 })
1447 .collect();
1448 let agent_hints = leviath_core::config::PromptHintOverrides {
1449 batch_tool: blueprint.batch_tool_hint,
1450 shell: blueprint.shell_hint,
1451 };
1452 let setups: Vec<StageSetup> = blueprint
1453 .stages
1454 .iter()
1455 .map(|s| stage_setup_from(s, global_hints, agent_hints))
1456 .collect();
1457
1458 let mut window = ContextWindow::new(blueprint.context_layout.total_budget_tokens);
1462 window.region_scripts = region_scripts;
1465 crate::context_setup::init_window_seeded(&mut window, &blueprint, seeds);
1466 apply_stage_context(&setups[0], &mut window)?;
1467
1468 let stage0_name = blueprint.stages[0].name.clone();
1469 let stage0_inf = stage_infs[0].clone();
1470 let setup0 = &setups[0];
1471 let stage0_cfg = setup0.inference_config.clone();
1472 let stage0_routing = setup0.routing.clone();
1473 let accepts_messages = setup0.accepts_messages;
1474
1475 let mut visits = VisitCounts::default();
1479 *visits.0.entry(stage0_name.clone()).or_insert(0) += 1;
1480
1481 let ledger = StageLedger(
1484 blueprint
1485 .stages
1486 .iter()
1487 .enumerate()
1488 .map(|(i, s)| leviath_core::run_meta::StageRecord::new(s.name.clone(), i))
1489 .collect(),
1490 );
1491
1492 let repetition = blueprint
1494 .repetition_detection
1495 .as_ref()
1496 .map(crate::repetition::RepetitionDetector::from_detection_config);
1497
1498 let entity = world
1499 .spawn((
1500 AgentBlueprint(blueprint),
1501 AgentState {
1502 agent_id,
1503 current_stage: stage0_name,
1504 iteration: 0,
1505 status: AgentStatus::Active,
1506 spawned_children_ids: vec![],
1507 pending_wait: None,
1508 accepts_messages,
1509 },
1510 MessageInbox::default(),
1511 StageCursor { index: 0 },
1512 StageProgress::default(),
1513 StageInferences(stage_infs),
1514 StageSetups(setups),
1515 visits,
1516 window,
1517 stage0_inf,
1518 stage0_cfg,
1519 ReadyToInfer,
1520 ))
1521 .id();
1522 world.entity_mut(entity).insert((
1524 ledger,
1525 StageIoBuffer::default(),
1526 crate::pipeline::response::GlobalNudge(global_nudge),
1527 ));
1528 if let Some(detector) = repetition {
1529 world.entity_mut(entity).insert(detector);
1530 }
1531 if let Some(routing) = stage0_routing {
1532 world
1533 .entity_mut(entity)
1534 .insert(crate::components::ToolResultRoutingComponent { routing });
1535 }
1536 Ok(entity)
1537}
1538
1539#[derive(Component, Debug, Clone)]
1543pub struct AwaitingTransitionResponse(pub Vec<leviath_core::blueprint::TransitionEdge>);
1544
1545#[derive(Resource)]
1549pub struct TransitionResults(pub UnboundedReceiver<InferenceOutcome>);
1550
1551pub(crate) fn build_transition_prompt(
1554 stage: &leviath_core::Stage,
1555 edges: &[leviath_core::blueprint::TransitionEdge],
1556) -> String {
1557 let mut p = match &stage.transition_prompt {
1558 Some(custom) => {
1559 let mut p = custom.clone();
1560 p.push_str("\n\nAvailable transitions:\n");
1561 p
1562 }
1563 None => format!(
1564 "Stage '{}' is complete. Available next stages:\n",
1565 stage.name
1566 ),
1567 };
1568 for edge in edges {
1569 p.push_str(&format!("- {}", edge.target));
1570 if let Some(hint) = &edge.hint {
1571 p.push_str(&format!(": {hint}"));
1572 }
1573 p.push('\n');
1574 }
1575 if stage.transition_prompt.is_some() {
1576 if stage.allow_complete {
1577 p.push_str(
1578 "\nRespond with ONLY the stage name you want to transition to, or ONLY the \
1579 word DONE if no further stage is needed and the run should end here.",
1580 );
1581 } else {
1582 p.push_str(
1583 "\nRespond with ONLY the stage name you want to transition to, nothing else.",
1584 );
1585 }
1586 } else if stage.allow_complete {
1587 p.push_str(
1588 "\nWhich stage should run next? Respond with ONLY the stage name, or ONLY the \
1589 word DONE if no further stage is needed and the run should end here.",
1590 );
1591 } else {
1592 p.push_str("\nWhich stage should run next? Respond with ONLY the stage name.");
1593 }
1594 p
1595}
1596
1597pub(crate) fn match_transition_choice(
1609 choice: &str,
1610 edges: &[leviath_core::blueprint::TransitionEdge],
1611 allow_complete: bool,
1612) -> Option<String> {
1613 let lines: Vec<&str> = choice
1614 .lines()
1615 .map(str::trim)
1616 .filter(|l| !l.is_empty())
1617 .collect();
1618 let words_in = |line: &str| {
1624 line.split(|c: char| !c.is_alphanumeric() && c != '_')
1625 .filter(|w| !w.is_empty())
1626 .count()
1627 };
1628 let first = lines.first().copied();
1629 let last = lines
1630 .last()
1631 .copied()
1632 .filter(|l| lines.len() > 1 && words_in(l) <= 3);
1633 for line in first.into_iter().chain(last) {
1634 for word in line.split(|c: char| !c.is_alphanumeric() && c != '_') {
1635 if word.is_empty() {
1636 continue;
1637 }
1638 if allow_complete && word.eq_ignore_ascii_case("done") {
1639 return None;
1640 }
1641 if let Some(edge) = edges.iter().find(|e| word.eq_ignore_ascii_case(&e.target)) {
1642 return Some(edge.target.clone());
1643 }
1644 }
1645 }
1646 if allow_complete {
1649 None
1650 } else {
1651 edges.first().map(|edge| edge.target.clone())
1652 }
1653}
1654
1655#[allow(clippy::type_complexity)]
1662pub fn dispatch_transition_choice(
1663 mut agents: Query<
1664 (
1665 Entity,
1666 &AgentState,
1667 &mut ContextWindow,
1668 &StageInference,
1669 &AgentBlueprint,
1670 &StageCursor,
1671 &AwaitingTransitionChoice,
1672 Option<&InFlightWork>,
1673 Option<&DispatchStall>,
1674 ),
1675 With<AwaitingTransitionChoice>,
1676 >,
1677 stage: Res<InferenceStage>,
1678 providers: Res<Providers>,
1679 mut commands: Commands,
1680) {
1681 crate::tick_scope::clear();
1682 let now = chrono::Utc::now().timestamp();
1683 for (entity, state, mut window, si, bp, cursor, choice, in_flight, stalled) in agents.iter_mut()
1684 {
1685 crate::tick_scope::enter(entity);
1686 if state.status != AgentStatus::Active {
1687 continue; }
1689 let Some(provider) = providers.0.get(&si.provider_name) else {
1693 commands
1694 .entity(entity)
1695 .insert(note_stall(stalled, StallReason::ProviderMissing, now));
1696 continue; };
1698 let Some(permit) = stage.pools.try_acquire(&si.model) else {
1699 commands
1700 .entity(entity)
1701 .insert(note_stall(stalled, StallReason::PoolFull, now));
1702 continue; };
1704
1705 let current = &bp.0.stages[cursor.index];
1706 let prompt = build_transition_prompt(current, &choice.0);
1707 let tokens = leviath_core::estimate_tokens(&prompt);
1708 let _ = window.add_typed_entry(
1709 "conversation",
1710 leviath_core::EntryKind::UserMessage,
1711 prompt,
1712 tokens,
1713 );
1714
1715 let assembled = window.assemble();
1720 let remaining = window.max_tokens.saturating_sub(window.current_tokens);
1721 let request = InferenceRequest {
1722 system: assembled.system_blocks,
1723 messages: assembled.messages,
1724 model: si.model.clone(),
1725 max_tokens: remaining.min(256), temperature: 0.0, tools: Vec::new(),
1728 extra: serde_json::Value::Null,
1729 request_timeout_secs: None,
1730 };
1731
1732 let job = InferenceJob {
1733 entity,
1734 provider,
1735 request,
1736 permit,
1737 exact_token_counting: false,
1740 };
1741 let cancel = crate::cancel::CancelToken::new();
1742 let lost_outcomes = stage.transition_outcomes.clone();
1746 let lost_wake = stage.wake.clone();
1747 crate::lane_supervisor::spawn_supervised(
1748 &stage.runtime,
1749 "transition-choice",
1750 run_inference_job(
1751 job,
1752 stage.transition_outcomes.clone(),
1753 stage.wake.clone(),
1754 crate::inference_bridge::RetryPolicy::default(),
1755 cancel.clone(),
1756 ),
1757 move |message| {
1758 let _ = lost_outcomes.send(crate::inference_bridge::InferenceOutcome {
1759 entity,
1760 result: Err(leviath_providers::ProviderError::Other(message)),
1761 latency: std::time::Duration::ZERO,
1762 });
1763 lost_wake.notify_one();
1764 },
1765 );
1766 track_in_flight(&mut commands, entity, in_flight, cancel);
1767 commands
1768 .entity(entity)
1769 .remove::<AwaitingTransitionChoice>()
1770 .remove::<DispatchStall>()
1771 .insert(AwaitingTransitionResponse(choice.0.clone()));
1772 }
1773}
1774
1775#[allow(clippy::type_complexity)]
1780pub fn collect_transition_choice(
1781 mut results: ResMut<TransitionResults>,
1782 mut agents: Query<(
1783 &AgentBlueprint,
1784 &mut StageCursor,
1785 &mut AgentState,
1786 &mut StageProgress,
1787 &StageInferences,
1788 &StageSetups,
1789 &mut VisitCounts,
1790 &mut ContextWindow,
1791 &AwaitingTransitionResponse,
1792 Option<&mut crate::persistence::RunOutcomeFlags>,
1793 Option<&crate::persistence::RunMetadata>,
1794 )>,
1795 sink: Option<Res<crate::host::WorldEventSink>>,
1796 mut commands: Commands,
1797) {
1798 crate::tick_scope::clear();
1799 while let Ok(outcome) = results.0.try_recv() {
1800 let Ok((
1801 bp,
1802 mut cursor,
1803 mut state,
1804 mut progress,
1805 stage_infs,
1806 setups,
1807 mut visits,
1808 mut window,
1809 resp,
1810 mut flags,
1811 metadata,
1812 )) = agents.get_mut(outcome.entity)
1813 else {
1814 continue; };
1816 crate::tick_scope::enter(outcome.entity);
1817 if is_terminal_status(&state.status) {
1821 commands
1822 .entity(outcome.entity)
1823 .remove::<AwaitingTransitionResponse>()
1824 .remove::<InFlightWork>();
1825 continue;
1826 }
1827 let response = match outcome.result {
1828 Ok(response) => response,
1829 Err(err) => {
1830 state.status = AgentStatus::Error {
1831 message: err.to_string(),
1832 };
1833 commands
1834 .entity(outcome.entity)
1835 .remove::<AwaitingTransitionResponse>();
1836 continue;
1837 }
1838 };
1839
1840 let choice = response.content.trim().to_string();
1841 let tokens = leviath_core::estimate_tokens(&choice);
1842 let _ = window.add_typed_entry(
1843 "conversation",
1844 leviath_core::EntryKind::AssistantTurn { tool_calls: vec![] },
1845 format!("Transitioning to: {choice}"),
1846 tokens,
1847 );
1848
1849 let allow_complete = bp.0.stages[cursor.index].allow_complete;
1850 match match_transition_choice(&choice, &resp.0, allow_complete) {
1851 Some(target) => {
1852 let idx =
1853 bp.0.stages
1854 .iter()
1855 .position(|s| s.name == target)
1856 .unwrap_or(0);
1857 let edge = resp.0.iter().find(|e| e.target == target);
1860 let transform = edge.map(|e| e.transform.clone()).unwrap_or_default();
1861 let stage = &bp.0.stages[cursor.index];
1864 match gate_blocks(
1865 edge.and_then(|e| e.gate.as_ref()),
1866 stage,
1867 &progress,
1868 &window,
1869 ) {
1870 GateDecision::Block(nudge) => {
1871 hold_for_gate(
1872 outcome.entity,
1873 &nudge,
1874 &mut progress,
1875 &mut window,
1876 &mut commands,
1877 );
1878 continue;
1879 }
1880 GateDecision::Forced => {
1881 if let Some(flags) = flags.as_mut() {
1882 flags.0.gates_forced += 1;
1883 }
1884 }
1885 GateDecision::Pass => {}
1886 }
1887 let to_compact = apply_edge_transform(&mut window, &transform);
1888 let setup = &setups.0[idx];
1889 let from = state.current_stage.clone();
1890 match enter_stage(
1891 idx,
1892 &bp.0,
1893 &mut cursor,
1894 &mut state,
1895 &mut progress,
1896 &mut visits,
1897 setup,
1898 &mut window,
1899 ) {
1900 Ok(visit) => {
1901 let name = bp.0.stages[idx].name.clone();
1902 emit_stage_transition(&sink, metadata, &state.agent_id, from, &name, visit);
1903 let mut ec = commands.entity(outcome.entity);
1904 ec.remove::<AwaitingTransitionResponse>();
1905 attach_stage_components(ec, stage_infs.0[idx].clone(), setup, idx, name);
1906 if !to_compact.is_empty() {
1907 commands
1908 .entity(outcome.entity)
1909 .insert(PendingEdgeCompact(to_compact));
1910 }
1911 }
1912 Err(message) => {
1913 state.status = AgentStatus::Error { message };
1914 commands
1915 .entity(outcome.entity)
1916 .remove::<AwaitingTransitionResponse>();
1917 }
1918 }
1919 }
1920 None => {
1921 state.status = AgentStatus::Complete;
1922 commands
1923 .entity(outcome.entity)
1924 .remove::<AwaitingTransitionResponse>();
1925 }
1926 }
1927 }
1928}
1929
1930pub fn sync_tool_stages(
1935 service: Res<ToolServiceRes>,
1936 entered: Query<(Entity, &StageJustEntered)>,
1937 mut commands: Commands,
1938) {
1939 crate::tick_scope::clear();
1940 for (entity, stage) in entered.iter() {
1941 crate::tick_scope::enter(entity);
1942 service.0.sync_stage(entity, stage.index, &stage.name);
1943 commands.entity(entity).remove::<StageJustEntered>();
1944 }
1945}
1946
1947pub fn refresh_advertised_tools(
1955 service: Res<ToolServiceRes>,
1956 mut agents: Query<
1957 (
1958 Entity,
1959 &StageCursor,
1960 &mut StageInference,
1961 &mut StageInferences,
1962 ),
1963 With<ToolsNeedRefresh>,
1964 >,
1965 mut commands: Commands,
1966) {
1967 crate::tick_scope::clear();
1968 for (entity, cursor, mut si, mut sis) in agents.iter_mut() {
1969 crate::tick_scope::enter(entity);
1970 if let Some(tools) = service.0.refresh_tools(entity, cursor.index) {
1971 si.tools = tools.clone();
1972 if let Some(slot) = sis.0.get_mut(cursor.index) {
1975 slot.tools = tools;
1976 }
1977 }
1978 commands.entity(entity).remove::<ToolsNeedRefresh>();
1979 }
1980}
1981
1982pub fn poll_dynamic_tool_refresh(
1987 service: Res<ToolServiceRes>,
1988 agents: Query<Entity, With<DynamicTools>>,
1989 mut commands: Commands,
1990) {
1991 crate::tick_scope::clear();
1992 for entity in agents.iter() {
1993 crate::tick_scope::enter(entity);
1994 if service.0.wants_refresh(entity) {
1995 commands.entity(entity).insert(ToolsNeedRefresh);
1996 }
1997 }
1998}