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
219#[derive(Debug, Clone, Default, PartialEq, Eq)]
222pub(crate) struct StuckMetrics {
223 pub iterations: usize,
225 pub elapsed_secs: u64,
227 pub tool_calls: usize,
229 pub hottest_edit: Option<(String, usize)>,
231}
232
233pub(crate) fn detect_stuck(
239 cfg: &leviath_core::blueprint::StuckConfig,
240 m: &StuckMetrics,
241) -> Option<String> {
242 if let (Some(limit), Some((path, hits))) = (cfg.after_same_file_edits, m.hottest_edit.as_ref())
243 && *hits >= limit
244 {
245 return Some(format!(
246 "you have written or edited '{path}' {hits} times in this stage without \
247 resolving the task - the problem is very likely not in that file"
248 ));
249 }
250 if let Some(limit) = cfg.after_iterations
251 && m.iterations >= limit
252 {
253 return Some(format!(
254 "you have run {} inference turns in this stage without finishing it",
255 m.iterations
256 ));
257 }
258 if let Some(limit) = cfg.after_tool_calls
259 && m.tool_calls >= limit
260 {
261 return Some(format!(
262 "you have made {} tool calls in this stage without finishing it",
263 m.tool_calls
264 ));
265 }
266 if let Some(limit) = cfg.after_minutes
267 && m.elapsed_secs >= limit as u64 * 60
268 {
269 return Some(format!(
270 "you have spent {} minutes in this stage without finishing it",
271 m.elapsed_secs / 60
272 ));
273 }
274 None
275}
276
277pub(crate) fn hottest_edit(
280 edits: &std::collections::HashMap<String, usize>,
281) -> Option<(String, usize)> {
282 edits
283 .iter()
284 .max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
285 .map(|(path, n)| (path.clone(), *n))
286}
287
288pub(crate) fn note_stuck(window: &mut ContextWindow, stage: &str, reason: &str) {
293 let region = if window.get_region(STUCK_REPORT_REGION).is_some() {
294 STUCK_REPORT_REGION
295 } else {
296 "conversation"
297 };
298 let content = format!(
299 "[Stuck detected in stage '{stage}'] {reason}. Stop repeating what you have been \
300 doing. Re-read the original task, separate what you have actually verified from \
301 what you assumed, and take a different approach - including reverting changes \
302 that made things worse."
303 );
304 let tokens = leviath_core::estimate_tokens(&content);
305 let _ = window.add_to_region(region, content, tokens);
306}
307
308#[allow(clippy::type_complexity)]
320pub fn detect_stuck_stage(
321 mut agents: Query<
322 (
323 Entity,
324 &AgentState,
325 &AgentBlueprint,
326 &StageCursor,
327 &mut StageProgress,
328 &VisitCounts,
329 &mut ContextWindow,
330 Option<&mut StageIoBuffer>,
331 ),
332 With<ReadyToInfer>,
333 >,
334 mut commands: Commands,
335) {
336 use leviath_core::blueprint::TransitionCondition;
337 let now = chrono::Utc::now().timestamp();
338 crate::tick_scope::clear();
339 for (entity, state, bp, cursor, mut progress, visits, mut window, buffer) in agents.iter_mut() {
340 crate::tick_scope::enter(entity);
341 if state.status != AgentStatus::Active || progress.stuck_fired {
342 continue; }
344 let stage = &bp.0.stages[cursor.index];
345 let Some(cfg) =
346 find_conditioned_edge_ref(&bp.0, stage, &visits.0, TransitionCondition::Stuck)
347 .and_then(|(_, edge)| edge.stuck)
348 else {
349 continue; };
351 let started = *progress.stage_started_at.get_or_insert(now);
355 let metrics = StuckMetrics {
356 iterations: progress.iterations,
357 elapsed_secs: (now - started).max(0) as u64,
358 tool_calls: progress.total_tool_calls,
359 hottest_edit: hottest_edit(&progress.edits_by_path),
360 };
361 let Some(reason) = detect_stuck(&cfg, &metrics) else {
362 continue;
363 };
364 progress.stuck_fired = true;
365 note_stuck(&mut window, &stage.name, &reason);
366 if let Some(mut buffer) = buffer {
367 buffer
368 .logs
369 .push((cursor.index, format!("[stuck] {reason}")));
370 }
371 commands
372 .entity(entity)
373 .remove::<ReadyToInfer>()
374 .insert(ResolveTransition)
375 .insert(StageOutcome::Stuck(reason));
376 }
377}
378
379pub(crate) fn resolve_transition_sync(
384 blueprint: &leviath_core::Blueprint,
385 stage: &leviath_core::Stage,
386 stage_idx: usize,
387 visits: &std::collections::HashMap<String, usize>,
388) -> StageResolution {
389 use leviath_core::blueprint::TransitionCondition;
390 match &stage.transitions {
391 None => {
392 if stage_idx + 1 < blueprint.stages.len() {
393 StageResolution::Next(
396 stage_idx + 1,
397 leviath_core::blueprint::EdgeTransform::Direct,
398 None,
399 )
400 } else {
401 StageResolution::Terminal
402 }
403 }
404 Some(transitions) => {
405 if transitions.is_empty() {
406 return StageResolution::Terminal;
407 }
408 let available: Vec<&leviath_core::blueprint::TransitionEdge> = transitions
410 .values()
411 .filter(|e| match blueprint.find_stage(&e.target) {
412 Some(ts) => match ts.max_revisits {
413 Some(max) => visits.get(&e.target).copied().unwrap_or(0) <= max,
414 None => true,
415 },
416 None => false, })
418 .collect();
419 let choosable: Vec<&leviath_core::blueprint::TransitionEdge> = available
421 .into_iter()
422 .filter(|e| {
423 matches!(
424 e.condition,
425 TransitionCondition::Always | TransitionCondition::LlmChoice
426 )
427 })
428 .collect();
429 match choosable.len() {
430 0 => StageResolution::Terminal,
431 1 if !stage.allow_complete => {
432 let idx = blueprint
433 .stages
434 .iter()
435 .position(|s| s.name == choosable[0].target)
436 .unwrap_or(0);
437 StageResolution::Next(
438 idx,
439 choosable[0].transform.clone(),
440 choosable[0].gate.clone(),
441 )
442 }
443 _ => StageResolution::Choose(choosable.into_iter().cloned().collect()),
444 }
445 }
446 }
447}
448
449#[derive(Component, Debug, Clone, Copy)]
453pub struct WaitingForChildren;
454
455pub fn is_terminal_status(status: &AgentStatus) -> bool {
461 matches!(
462 status,
463 AgentStatus::Complete | AgentStatus::Error { .. } | AgentStatus::Cancelled
464 )
465}
466
467pub fn gate_requires_children(world: &mut World) {
474 crate::tick_scope::clear();
475 use crate::components::SubAgentChildren;
476
477 let mut candidates: Vec<(Entity, Vec<Entity>)> = Vec::new();
480 {
481 let mut q = world.query_filtered::<(
482 Entity,
483 &AgentBlueprint,
484 &StageCursor,
485 &SubAgentChildren,
486 &AgentState,
487 ), With<ResolveTransition>>();
488 for (e, bp, cursor, children, _) in q.iter(world) {
489 if bp.0.stages[cursor.index].requires_children {
490 candidates.push((e, children.children.clone()));
491 }
492 }
493 }
494 for (entity, children) in candidates {
495 crate::tick_scope::enter(entity);
496 let pending = children.iter().any(|&c| {
497 world
498 .get::<AgentState>(c)
499 .is_some_and(|s| !is_terminal_status(&s.status))
500 });
501 if pending {
502 world
503 .entity_mut(entity)
504 .remove::<ResolveTransition>()
505 .insert(WaitingForChildren);
506 world
507 .get_mut::<AgentState>(entity)
508 .expect("held agent has AgentState")
509 .status = AgentStatus::Waiting;
510 }
511 }
512
513 crate::tick_scope::clear();
515 let mut waiting: Vec<(Entity, Vec<Entity>)> = Vec::new();
516 {
517 let mut q = world.query_filtered::<
518 (Entity, Option<&SubAgentChildren>, &AgentState),
519 With<WaitingForChildren>,
520 >();
521 for (e, children, _) in q.iter(world) {
522 waiting.push((e, children.map(|c| c.children.clone()).unwrap_or_default()));
523 }
524 }
525 for (entity, children) in waiting {
526 crate::tick_scope::enter(entity);
527 let all_done = children.iter().all(|&c| {
528 world
529 .get::<AgentState>(c)
530 .is_none_or(|s| is_terminal_status(&s.status))
531 });
532 if all_done {
533 world
534 .entity_mut(entity)
535 .remove::<WaitingForChildren>()
536 .insert(ResolveTransition);
537 world
538 .get_mut::<AgentState>(entity)
539 .expect("waiting agent has AgentState")
540 .status = AgentStatus::Active;
541 }
542 }
543}
544
545pub(crate) const DEFAULT_REQUIRED_REENTRY_CAP: usize = 3;
549
550#[derive(Component, Debug, Clone, Copy)]
553pub struct RequiredReentries(pub usize);
554
555pub(crate) fn unmet_required_regions(
560 blueprint: &leviath_core::Blueprint,
561 stage: &leviath_core::Stage,
562 window: &ContextWindow,
563) -> Vec<(String, Option<String>)> {
564 let can_write = stage
565 .available_tools
566 .iter()
567 .any(|t| t == "context_write" || t == "context_append");
568 if !can_write {
569 return Vec::new();
570 }
571 let layout = stage
572 .context_layout
573 .as_ref()
574 .unwrap_or(&blueprint.context_layout);
575 layout
576 .regions
577 .iter()
578 .filter(|r| r.required)
579 .filter(|r| {
583 !matches!(
584 r.seed,
585 Some(leviath_core::layout::RegionSeed::CallerInput { .. })
586 )
587 })
588 .filter(|r| {
589 window
590 .get_region(&r.name)
591 .map(|reg| reg.content.is_empty())
592 .unwrap_or(true)
593 })
594 .map(|r| (r.name.clone(), r.required_message.clone()))
595 .collect()
596}
597
598pub(crate) fn inject_required_region_nudges(
601 window: &mut ContextWindow,
602 unmet: &[(String, Option<String>)],
603) {
604 for (name, msg) in unmet {
605 let text = msg.clone().unwrap_or_else(|| {
606 format!(
607 "Required context region '{name}' is still empty. You must populate it \
608 (e.g. via context_write with region=\"{name}\") before this stage can complete."
609 )
610 });
611 let content = format!("[System] {text}");
612 let tokens = leviath_core::estimate_tokens(&content);
613 let _ = window.add_to_region("conversation", content, tokens);
614 }
615}
616
617#[allow(clippy::type_complexity)]
624pub fn require_context_regions(
625 mut agents: Query<
626 (
627 Entity,
628 &AgentBlueprint,
629 &StageCursor,
630 &mut ContextWindow,
631 Option<&RequiredReentries>,
632 Option<&StageOutcome>,
633 ),
634 With<ResolveTransition>,
635 >,
636 mut commands: Commands,
637) {
638 crate::tick_scope::clear();
639 for (entity, bp, cursor, mut window, reentries, outcome) in agents.iter_mut() {
640 crate::tick_scope::enter(entity);
641 if outcome.is_some() {
642 continue; }
644 let stage = &bp.0.stages[cursor.index];
645 let unmet = unmet_required_regions(&bp.0, stage, &window);
646 if unmet.is_empty() {
647 continue;
648 }
649 let cap = stage.max_revisits.unwrap_or(DEFAULT_REQUIRED_REENTRY_CAP);
650 let round = reentries.map_or(0, |r| r.0);
651 if round >= cap {
652 let names: Vec<&str> = unmet.iter().map(|(n, _)| n.as_str()).collect();
653 tracing::warn!(
654 stage = %stage.name,
655 regions = ?names,
656 attempts = cap,
657 "required context regions still empty after re-run attempts; proceeding"
658 );
659 continue; }
661 inject_required_region_nudges(&mut window, &unmet);
662 commands
663 .entity(entity)
664 .remove::<ResolveTransition>()
665 .insert(ReadyToInfer)
666 .insert(RequiredReentries(round + 1));
667 }
668}
669
670#[derive(Debug, Clone, PartialEq, Eq)]
672pub(crate) enum GateDecision {
673 Pass,
675 Forced,
678 Block(String),
680}
681
682pub(crate) fn gate_blocks(
702 gate: Option<&leviath_core::blueprint::TransitionGate>,
703 stage: &leviath_core::Stage,
704 progress: &StageProgress,
705 window: &ContextWindow,
706) -> GateDecision {
707 let Some(gate) = gate else {
708 return GateDecision::Pass;
709 };
710 if !gate.require_modifications {
711 return GateDecision::Pass;
712 }
713 let can_modify = stage.available_tools.iter().any(|t| {
714 let canonical = leviath_tools::canonical_tool_name(t);
715 leviath_core::blueprint::MODIFYING_TOOLS.contains(&canonical)
716 || gate
717 .tools
718 .iter()
719 .any(|extra| leviath_tools::canonical_tool_name(extra) == canonical)
720 });
721 if !can_modify {
722 return GateDecision::Pass;
723 }
724 if progress.modifying_tool_calls > 0 {
725 return GateDecision::Pass;
726 }
727 if progress.blocked_modification_calls > 0 {
728 tracing::warn!(
729 stage = %stage.name,
730 blocked = progress.blocked_modification_calls,
731 "file modifications were denied by policy; letting the gated transition through"
732 );
733 return GateDecision::Pass;
734 }
735 if let Some(region) = &gate.region
736 && window
737 .get_region(region)
738 .is_some_and(|r| !r.content.is_empty())
739 {
740 return GateDecision::Pass;
741 }
742 let cap = gate
743 .max_attempts
744 .unwrap_or(leviath_core::blueprint::DEFAULT_GATE_ATTEMPTS);
745 if progress.gate_reentries >= cap {
746 tracing::warn!(
747 stage = %stage.name,
748 attempts = cap,
749 "stage still has no file modifications after re-run attempts; proceeding"
750 );
751 return GateDecision::Forced;
752 }
753 GateDecision::Block(gate.message.clone().unwrap_or_else(|| {
754 "No file modifications were recorded in this stage. Changes made through the shell \
755 (sed -i, tee, >, >>) are not tracked by the framework. Re-apply your changes with \
756 edit_file or write_file before moving on."
757 .to_string()
758 }))
759}
760
761pub(crate) fn hold_for_gate(
766 entity: Entity,
767 nudge: &str,
768 progress: &mut StageProgress,
769 window: &mut ContextWindow,
770 commands: &mut Commands,
771) {
772 let content = format!("[System] {nudge}");
773 let tokens = leviath_core::estimate_tokens(&content);
774 let _ = window.add_to_region("conversation", content, tokens);
775 progress.gate_reentries += 1;
776 commands
777 .entity(entity)
778 .remove::<ResolveTransition>()
779 .remove::<AwaitingTransitionResponse>()
780 .remove::<StageOutcome>()
781 .insert(ReadyToInfer);
782}
783
784#[allow(clippy::type_complexity)]
790pub fn resolve_transition(
791 mut agents: Query<
792 (
793 Entity,
794 &AgentBlueprint,
795 &mut StageCursor,
796 &mut AgentState,
797 &mut StageProgress,
798 &StageInferences,
799 &StageSetups,
800 &mut VisitCounts,
801 &mut ContextWindow,
802 Option<&StageOutcome>,
803 Option<&mut crate::persistence::RunOutcomeFlags>,
804 ),
805 With<ResolveTransition>,
806 >,
807 mut commands: Commands,
808) {
809 crate::tick_scope::clear();
810 use leviath_core::blueprint::TransitionCondition;
811 for (
812 entity,
813 bp,
814 mut cursor,
815 mut state,
816 mut progress,
817 stage_infs,
818 setups,
819 mut visits,
820 mut window,
821 outcome,
822 mut flags,
823 ) in agents.iter_mut()
824 {
825 crate::tick_scope::enter(entity);
826 let stage = &bp.0.stages[cursor.index];
827 let resolution = match outcome {
830 Some(StageOutcome::Errored(_)) => {
834 find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::Error)
835 .map(|(i, t)| StageResolution::Next(i, t, None))
836 .unwrap_or(StageResolution::TerminalError)
837 }
838 Some(StageOutcome::MaxIterations) => {
839 find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::MaxIterations)
840 .map(|(i, t)| StageResolution::Next(i, t, None))
841 .unwrap_or_else(|| {
842 resolve_transition_sync(&bp.0, stage, cursor.index, &visits.0)
843 })
844 }
845 Some(StageOutcome::Stuck(_)) => {
846 find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::Stuck)
853 .map(|(i, t)| StageResolution::Next(i, t, None))
854 .unwrap_or(StageResolution::Resume)
855 }
856 None => resolve_transition_sync(&bp.0, stage, cursor.index, &visits.0),
857 };
858 match resolution {
859 StageResolution::Terminal => {
860 state.status = AgentStatus::Complete;
861 commands
862 .entity(entity)
863 .remove::<ResolveTransition>()
864 .remove::<StageOutcome>();
865 }
866 StageResolution::TerminalError => {
867 commands
869 .entity(entity)
870 .remove::<ResolveTransition>()
871 .remove::<StageOutcome>();
872 }
873 StageResolution::Next(idx, transform, gate) => {
874 let gate = outcome.is_none().then_some(gate).flatten();
877 match gate_blocks(gate.as_ref(), stage, &progress, &window) {
878 GateDecision::Block(nudge) => {
879 hold_for_gate(entity, &nudge, &mut progress, &mut window, &mut commands);
880 continue;
881 }
882 GateDecision::Forced => {
883 if let Some(flags) = flags.as_mut() {
884 flags.0.gates_forced += 1;
885 }
886 }
887 GateDecision::Pass => {}
888 }
889 let to_compact = apply_edge_transform(&mut window, &transform);
892 let setup = &setups.0[idx];
893 match enter_stage(
894 idx,
895 &bp.0,
896 &mut cursor,
897 &mut state,
898 &mut progress,
899 &mut visits,
900 setup,
901 &mut window,
902 ) {
903 Ok(()) => {
904 state.status = AgentStatus::Active;
907 let name = bp.0.stages[idx].name.clone();
908 let mut ec = commands.entity(entity);
909 ec.remove::<ResolveTransition>().remove::<StageOutcome>();
910 attach_stage_components(ec, stage_infs.0[idx].clone(), setup, idx, name);
911 if !to_compact.is_empty() {
912 commands
913 .entity(entity)
914 .insert(PendingEdgeCompact(to_compact));
915 }
916 }
917 Err(message) => {
918 state.status = AgentStatus::Error { message };
919 commands
920 .entity(entity)
921 .remove::<ResolveTransition>()
922 .remove::<StageOutcome>();
923 }
924 }
925 }
926 StageResolution::Choose(edges) => {
927 commands
928 .entity(entity)
929 .remove::<ResolveTransition>()
930 .remove::<StageOutcome>()
931 .insert(AwaitingTransitionChoice(edges));
932 }
933 StageResolution::Resume => {
934 commands
938 .entity(entity)
939 .remove::<ResolveTransition>()
940 .remove::<StageOutcome>()
941 .insert(ReadyToInfer);
942 }
943 }
944 }
945}
946
947#[allow(clippy::too_many_arguments)]
956pub(crate) fn enter_stage(
957 idx: usize,
958 blueprint: &leviath_core::Blueprint,
959 cursor: &mut StageCursor,
960 state: &mut AgentState,
961 progress: &mut StageProgress,
962 visits: &mut VisitCounts,
963 setup: &StageSetup,
964 window: &mut ContextWindow,
965) -> Result<(), String> {
966 cursor.index = idx;
967 let name = blueprint.stages[idx].name.clone();
968 state.current_stage = name.clone();
969 state.accepts_messages = setup.accepts_messages;
970 *progress = StageProgress::default();
971 *visits.0.entry(name).or_insert(0) += 1;
972
973 apply_stage_context(setup, window)
974}
975
976pub(crate) fn apply_stage_context(
982 setup: &StageSetup,
983 window: &mut ContextWindow,
984) -> Result<(), String> {
985 if let Some(layout) = &setup.context_layout {
986 crate::context_setup::apply_layout(window, layout);
987 }
988
989 let target = window
992 .regions
993 .iter()
994 .find(|r| matches!(r.kind, leviath_core::RegionKind::Pinned))
995 .map(|r| r.name.clone())
996 .unwrap_or_else(|| "conversation".to_string());
997 if let Some(region) = window.regions.iter_mut().find(|r| r.name == target) {
998 region.remove_entries_by_prefix("[Stage instructions:");
999 }
1000 if let Some(sp) = &setup.system_prompt {
1001 let content = format!("[Stage instructions: {sp}]");
1002 let tokens = leviath_core::estimate_tokens(&content);
1003 window
1004 .add_to_region(&target, content, tokens)
1005 .map_err(|e| {
1006 format!(
1007 "stage system prompt (~{tokens} tokens) does not fit context region \
1008 '{target}': {e}. Increase that region's max_tokens (or shorten the prompt)."
1009 )
1010 })?;
1011 }
1012 Ok(())
1013}
1014
1015pub(crate) fn attach_stage_components(
1020 mut entity: bevy_ecs::system::EntityCommands,
1021 stage_inf: StageInference,
1022 setup: &StageSetup,
1023 stage_index: usize,
1024 stage_name: String,
1025) {
1026 entity
1027 .insert(stage_inf)
1028 .insert(setup.inference_config.clone())
1029 .insert(StageJustEntered {
1030 index: stage_index,
1031 name: stage_name,
1032 })
1033 .remove::<crate::interaction_points::InteractionPointCursor>()
1035 .remove::<crate::interaction_points::InteractionPointRounds>()
1036 .remove::<RequiredReentries>()
1037 .insert(ReadyToInfer);
1038 match &setup.routing {
1039 Some(routing) => {
1040 entity.insert(crate::components::ToolResultRoutingComponent {
1041 routing: routing.clone(),
1042 });
1043 }
1044 None => {
1045 entity.remove::<crate::components::ToolResultRoutingComponent>();
1046 }
1047 }
1048}
1049
1050pub fn force_transition(world: &mut World, entity: Entity, target_idx: usize) {
1057 let attach: Option<(StageInference, StageSetup, String)> = {
1061 let mut q = world.query::<(
1062 &AgentBlueprint,
1063 &mut StageCursor,
1064 &mut AgentState,
1065 &mut StageProgress,
1066 &StageInferences,
1067 &StageSetups,
1068 &mut VisitCounts,
1069 &mut ContextWindow,
1070 )>();
1071 let Ok((
1072 bp,
1073 mut cursor,
1074 mut state,
1075 mut progress,
1076 stage_infs,
1077 setups,
1078 mut visits,
1079 mut window,
1080 )) = q.get_mut(world, entity)
1081 else {
1082 return; };
1084 let setup = setups.0[target_idx].clone();
1085 let stage_inf = stage_infs.0[target_idx].clone();
1086 let name = bp.0.stages[target_idx].name.clone();
1087 let bp = bp.0.clone();
1088 match enter_stage(
1089 target_idx,
1090 &bp,
1091 &mut cursor,
1092 &mut state,
1093 &mut progress,
1094 &mut visits,
1095 &setup,
1096 &mut window,
1097 ) {
1098 Ok(()) => Some((stage_inf, setup, name)),
1099 Err(message) => {
1100 state.status = AgentStatus::Error { message };
1101 None
1102 }
1103 }
1104 };
1105
1106 let Some((stage_inf, setup, name)) = attach else {
1108 return;
1109 };
1110 let mut em = world.entity_mut(entity);
1111 em.insert(stage_inf)
1112 .insert(setup.inference_config.clone())
1113 .insert(StageJustEntered {
1114 index: target_idx,
1115 name,
1116 })
1117 .insert(ReadyToInfer);
1118 match &setup.routing {
1119 Some(routing) => {
1120 em.insert(crate::components::ToolResultRoutingComponent {
1121 routing: routing.clone(),
1122 });
1123 }
1124 None => {
1125 em.remove::<crate::components::ToolResultRoutingComponent>();
1126 }
1127 }
1128}
1129
1130pub struct ResolvedStage {
1135 pub provider_name: String,
1137 pub model: String,
1139 pub tools: Vec<Tool>,
1141}
1142
1143pub(crate) const DEFAULT_CONTEXT_WINDOW_TOKENS: usize = 8192;
1147
1148pub(crate) fn context_window_tokens(world: &World, provider_name: &str, model: &str) -> usize {
1153 match world
1154 .get_resource::<Providers>()
1155 .and_then(|p| p.0.get(provider_name))
1156 {
1157 Some(provider) => provider.max_context_tokens(model),
1158 None => {
1159 tracing::warn!(
1160 provider = provider_name,
1161 model,
1162 "provider not registered; using default context window for percentage budgets"
1163 );
1164 DEFAULT_CONTEXT_WINDOW_TOKENS
1165 }
1166 }
1167}
1168
1169pub(crate) fn stage_setup_from(
1173 stage: &leviath_core::Stage,
1174 global_batch_tool_hint: bool,
1175 agent_batch_tool_hint: Option<bool>,
1176) -> StageSetup {
1177 let temperature = stage
1178 .model
1179 .parameters
1180 .get("temperature")
1181 .and_then(|v| v.as_f64())
1182 .map(|t| t as f32);
1183 let extra_params: serde_json::Map<String, serde_json::Value> = stage
1187 .model
1188 .parameters
1189 .iter()
1190 .filter(|(k, _)| k.as_str() != "temperature" && k.as_str() != "max_output_tokens")
1191 .map(|(k, v)| (k.clone(), v.clone()))
1192 .collect();
1193 let max_output_tokens = stage
1194 .model
1195 .parameters
1196 .get("max_output_tokens")
1197 .and_then(|v| v.as_u64())
1198 .map(|t| t as usize);
1199 let base_prompt = stage
1200 .config
1201 .get("system_prompt")
1202 .and_then(|v| v.as_str())
1203 .map(String::from);
1204 let system_prompt = match &stage.mode {
1208 leviath_core::blueprint::StageMode::FanOut { config }
1209 if !config.split_prompt.trim().is_empty() =>
1210 {
1211 Some(match base_prompt {
1212 Some(base) => format!("{base}\n\n{}", config.split_prompt),
1213 None => config.split_prompt.clone(),
1214 })
1215 }
1216 _ => base_prompt,
1217 };
1218 let batch_tool_hint = leviath_core::taint::resolve_batch_tool_hint(
1220 global_batch_tool_hint,
1221 agent_batch_tool_hint,
1222 stage.batch_tool_hint,
1223 );
1224 StageSetup {
1225 inference_config: InferenceConfig {
1226 temperature,
1227 max_output_tokens,
1228 extra_params,
1229 batch_tool_hint,
1230 request_timeout_secs: stage.model.request_timeout_secs,
1231 },
1232 routing: stage.tool_result_routing.clone(),
1233 accepts_messages: stage.accepts_messages,
1234 context_layout: stage.context_layout.clone(),
1235 system_prompt,
1236 }
1237}
1238
1239pub fn spawn_agent(
1253 world: &mut World,
1254 agent_id: String,
1255 blueprint: leviath_core::Blueprint,
1256 task: &str,
1257 stages: Vec<ResolvedStage>,
1258 global_batch_tool_hint: bool,
1259) -> Result<Entity, String> {
1260 let seeds = std::collections::HashMap::from([("task".to_string(), task.to_string())]);
1261 spawn_agent_seeded(
1265 world,
1266 agent_id,
1267 blueprint,
1268 &seeds,
1269 stages,
1270 global_batch_tool_hint,
1271 std::collections::HashMap::new(),
1272 )
1273}
1274
1275pub fn spawn_agent_seeded(
1280 world: &mut World,
1281 agent_id: String,
1282 mut blueprint: leviath_core::Blueprint,
1283 seeds: &std::collections::HashMap<String, String>,
1284 stages: Vec<ResolvedStage>,
1285 global_batch_tool_hint: bool,
1286 region_scripts: std::collections::HashMap<
1287 String,
1288 std::sync::Arc<leviath_scripting::region_hook::RegionScript>,
1289 >,
1290) -> Result<Entity, String> {
1291 let stage_windows: Vec<usize> = stages
1297 .iter()
1298 .map(|rs| context_window_tokens(world, &rs.provider_name, &rs.model))
1299 .collect();
1300 blueprint.context_layout = blueprint.context_layout.resolved(stage_windows[0]);
1301 for (i, stage) in blueprint.stages.iter_mut().enumerate() {
1302 if let Some(layout) = &stage.context_layout {
1303 stage.context_layout = Some(layout.resolved(stage_windows[i]));
1304 }
1305 }
1306 blueprint
1309 .context_layout
1310 .validate()
1311 .map_err(|e| e.to_string())?;
1312 for stage in &blueprint.stages {
1313 if let Some(layout) = &stage.context_layout {
1314 layout.validate().map_err(|e| e.to_string())?;
1315 }
1316 }
1317
1318 let stage_infs: Vec<StageInference> = stages
1319 .into_iter()
1320 .map(|rs| StageInference {
1321 provider_name: rs.provider_name,
1322 model: rs.model,
1323 tools: rs.tools,
1324 tool_filter: None, })
1326 .collect();
1327 let agent_batch_tool_hint = blueprint.batch_tool_hint;
1328 let setups: Vec<StageSetup> = blueprint
1329 .stages
1330 .iter()
1331 .map(|s| stage_setup_from(s, global_batch_tool_hint, agent_batch_tool_hint))
1332 .collect();
1333
1334 let mut window = ContextWindow::new(blueprint.context_layout.total_budget_tokens);
1338 window.region_scripts = region_scripts;
1341 crate::context_setup::init_window_seeded(&mut window, &blueprint, seeds);
1342 apply_stage_context(&setups[0], &mut window)?;
1343
1344 let stage0_name = blueprint.stages[0].name.clone();
1345 let stage0_inf = stage_infs[0].clone();
1346 let setup0 = &setups[0];
1347 let stage0_cfg = setup0.inference_config.clone();
1348 let stage0_routing = setup0.routing.clone();
1349 let accepts_messages = setup0.accepts_messages;
1350
1351 let mut visits = VisitCounts::default();
1355 *visits.0.entry(stage0_name.clone()).or_insert(0) += 1;
1356
1357 let ledger = StageLedger(
1360 blueprint
1361 .stages
1362 .iter()
1363 .enumerate()
1364 .map(|(i, s)| leviath_core::run_meta::StageRecord::new(s.name.clone(), i))
1365 .collect(),
1366 );
1367
1368 let repetition = blueprint
1370 .repetition_detection
1371 .as_ref()
1372 .map(crate::repetition::RepetitionDetector::from_detection_config);
1373
1374 let entity = world
1375 .spawn((
1376 AgentBlueprint(blueprint),
1377 AgentState {
1378 agent_id,
1379 current_stage: stage0_name,
1380 iteration: 0,
1381 status: AgentStatus::Active,
1382 spawned_children_ids: vec![],
1383 pending_wait: None,
1384 accepts_messages,
1385 },
1386 MessageInbox::default(),
1387 StageCursor { index: 0 },
1388 StageProgress::default(),
1389 StageInferences(stage_infs),
1390 StageSetups(setups),
1391 visits,
1392 window,
1393 stage0_inf,
1394 stage0_cfg,
1395 ReadyToInfer,
1396 ))
1397 .id();
1398 world
1400 .entity_mut(entity)
1401 .insert((ledger, StageIoBuffer::default()));
1402 if let Some(detector) = repetition {
1403 world.entity_mut(entity).insert(detector);
1404 }
1405 if let Some(routing) = stage0_routing {
1406 world
1407 .entity_mut(entity)
1408 .insert(crate::components::ToolResultRoutingComponent { routing });
1409 }
1410 Ok(entity)
1411}
1412
1413#[derive(Component, Debug, Clone)]
1417pub struct AwaitingTransitionResponse(pub Vec<leviath_core::blueprint::TransitionEdge>);
1418
1419#[derive(Resource)]
1423pub struct TransitionResults(pub UnboundedReceiver<InferenceOutcome>);
1424
1425pub(crate) fn build_transition_prompt(
1428 stage: &leviath_core::Stage,
1429 edges: &[leviath_core::blueprint::TransitionEdge],
1430) -> String {
1431 let mut p = match &stage.transition_prompt {
1432 Some(custom) => {
1433 let mut p = custom.clone();
1434 p.push_str("\n\nAvailable transitions:\n");
1435 p
1436 }
1437 None => format!(
1438 "Stage '{}' is complete. Available next stages:\n",
1439 stage.name
1440 ),
1441 };
1442 for edge in edges {
1443 p.push_str(&format!("- {}", edge.target));
1444 if let Some(hint) = &edge.hint {
1445 p.push_str(&format!(": {hint}"));
1446 }
1447 p.push('\n');
1448 }
1449 if stage.transition_prompt.is_some() {
1450 if stage.allow_complete {
1451 p.push_str(
1452 "\nRespond with ONLY the stage name you want to transition to, or ONLY the \
1453 word DONE if no further stage is needed and the run should end here.",
1454 );
1455 } else {
1456 p.push_str(
1457 "\nRespond with ONLY the stage name you want to transition to, nothing else.",
1458 );
1459 }
1460 } else if stage.allow_complete {
1461 p.push_str(
1462 "\nWhich stage should run next? Respond with ONLY the stage name, or ONLY the \
1463 word DONE if no further stage is needed and the run should end here.",
1464 );
1465 } else {
1466 p.push_str("\nWhich stage should run next? Respond with ONLY the stage name.");
1467 }
1468 p
1469}
1470
1471pub(crate) fn match_transition_choice(
1483 choice: &str,
1484 edges: &[leviath_core::blueprint::TransitionEdge],
1485 allow_complete: bool,
1486) -> Option<String> {
1487 let lines: Vec<&str> = choice
1488 .lines()
1489 .map(str::trim)
1490 .filter(|l| !l.is_empty())
1491 .collect();
1492 let words_in = |line: &str| {
1498 line.split(|c: char| !c.is_alphanumeric() && c != '_')
1499 .filter(|w| !w.is_empty())
1500 .count()
1501 };
1502 let first = lines.first().copied();
1503 let last = lines
1504 .last()
1505 .copied()
1506 .filter(|l| lines.len() > 1 && words_in(l) <= 3);
1507 for line in first.into_iter().chain(last) {
1508 for word in line.split(|c: char| !c.is_alphanumeric() && c != '_') {
1509 if word.is_empty() {
1510 continue;
1511 }
1512 if allow_complete && word.eq_ignore_ascii_case("done") {
1513 return None;
1514 }
1515 if let Some(edge) = edges.iter().find(|e| word.eq_ignore_ascii_case(&e.target)) {
1516 return Some(edge.target.clone());
1517 }
1518 }
1519 }
1520 if allow_complete {
1523 None
1524 } else {
1525 edges.first().map(|edge| edge.target.clone())
1526 }
1527}
1528
1529#[allow(clippy::type_complexity)]
1536pub fn dispatch_transition_choice(
1537 mut agents: Query<
1538 (
1539 Entity,
1540 &AgentState,
1541 &mut ContextWindow,
1542 &StageInference,
1543 &AgentBlueprint,
1544 &StageCursor,
1545 &AwaitingTransitionChoice,
1546 Option<&InFlightWork>,
1547 ),
1548 With<AwaitingTransitionChoice>,
1549 >,
1550 stage: Res<InferenceStage>,
1551 providers: Res<Providers>,
1552 mut commands: Commands,
1553) {
1554 crate::tick_scope::clear();
1555 for (entity, state, mut window, si, bp, cursor, choice, in_flight) in agents.iter_mut() {
1556 crate::tick_scope::enter(entity);
1557 if state.status != AgentStatus::Active {
1558 continue; }
1560 let Some(provider) = providers.0.get(&si.provider_name) else {
1561 continue; };
1563 let Some(permit) = stage.pools.try_acquire(&si.model) else {
1564 continue; };
1566
1567 let current = &bp.0.stages[cursor.index];
1568 let prompt = build_transition_prompt(current, &choice.0);
1569 let tokens = leviath_core::estimate_tokens(&prompt);
1570 let _ = window.add_typed_entry(
1571 "conversation",
1572 leviath_core::EntryKind::UserMessage,
1573 prompt,
1574 tokens,
1575 );
1576
1577 let assembled = window.assemble();
1582 let remaining = window.max_tokens.saturating_sub(window.current_tokens);
1583 let request = InferenceRequest {
1584 system: assembled.system_blocks,
1585 messages: assembled.messages,
1586 model: si.model.clone(),
1587 max_tokens: remaining.min(256), temperature: 0.0, tools: Vec::new(),
1590 extra: serde_json::Value::Null,
1591 request_timeout_secs: None,
1592 };
1593
1594 let job = InferenceJob {
1595 entity,
1596 provider,
1597 request,
1598 permit,
1599 exact_token_counting: false,
1602 };
1603 let cancel = crate::cancel::CancelToken::new();
1604 stage.runtime.spawn(run_inference_job(
1605 job,
1606 stage.transition_outcomes.clone(),
1607 stage.wake.clone(),
1608 crate::inference_bridge::RetryPolicy::default(),
1609 cancel.clone(),
1610 ));
1611 track_in_flight(&mut commands, entity, in_flight, cancel);
1612 commands
1613 .entity(entity)
1614 .remove::<AwaitingTransitionChoice>()
1615 .insert(AwaitingTransitionResponse(choice.0.clone()));
1616 }
1617}
1618
1619#[allow(clippy::type_complexity)]
1624pub fn collect_transition_choice(
1625 mut results: ResMut<TransitionResults>,
1626 mut agents: Query<(
1627 &AgentBlueprint,
1628 &mut StageCursor,
1629 &mut AgentState,
1630 &mut StageProgress,
1631 &StageInferences,
1632 &StageSetups,
1633 &mut VisitCounts,
1634 &mut ContextWindow,
1635 &AwaitingTransitionResponse,
1636 Option<&mut crate::persistence::RunOutcomeFlags>,
1637 )>,
1638 mut commands: Commands,
1639) {
1640 crate::tick_scope::clear();
1641 while let Ok(outcome) = results.0.try_recv() {
1642 let Ok((
1643 bp,
1644 mut cursor,
1645 mut state,
1646 mut progress,
1647 stage_infs,
1648 setups,
1649 mut visits,
1650 mut window,
1651 resp,
1652 mut flags,
1653 )) = agents.get_mut(outcome.entity)
1654 else {
1655 continue; };
1657 crate::tick_scope::enter(outcome.entity);
1658 if is_terminal_status(&state.status) {
1662 commands
1663 .entity(outcome.entity)
1664 .remove::<AwaitingTransitionResponse>()
1665 .remove::<InFlightWork>();
1666 continue;
1667 }
1668 let response = match outcome.result {
1669 Ok(response) => response,
1670 Err(err) => {
1671 state.status = AgentStatus::Error {
1672 message: err.to_string(),
1673 };
1674 commands
1675 .entity(outcome.entity)
1676 .remove::<AwaitingTransitionResponse>();
1677 continue;
1678 }
1679 };
1680
1681 let choice = response.content.trim().to_string();
1682 let tokens = leviath_core::estimate_tokens(&choice);
1683 let _ = window.add_typed_entry(
1684 "conversation",
1685 leviath_core::EntryKind::AssistantTurn { tool_calls: vec![] },
1686 format!("Transitioning to: {choice}"),
1687 tokens,
1688 );
1689
1690 let allow_complete = bp.0.stages[cursor.index].allow_complete;
1691 match match_transition_choice(&choice, &resp.0, allow_complete) {
1692 Some(target) => {
1693 let idx =
1694 bp.0.stages
1695 .iter()
1696 .position(|s| s.name == target)
1697 .unwrap_or(0);
1698 let edge = resp.0.iter().find(|e| e.target == target);
1701 let transform = edge.map(|e| e.transform.clone()).unwrap_or_default();
1702 let stage = &bp.0.stages[cursor.index];
1705 match gate_blocks(
1706 edge.and_then(|e| e.gate.as_ref()),
1707 stage,
1708 &progress,
1709 &window,
1710 ) {
1711 GateDecision::Block(nudge) => {
1712 hold_for_gate(
1713 outcome.entity,
1714 &nudge,
1715 &mut progress,
1716 &mut window,
1717 &mut commands,
1718 );
1719 continue;
1720 }
1721 GateDecision::Forced => {
1722 if let Some(flags) = flags.as_mut() {
1723 flags.0.gates_forced += 1;
1724 }
1725 }
1726 GateDecision::Pass => {}
1727 }
1728 let to_compact = apply_edge_transform(&mut window, &transform);
1729 let setup = &setups.0[idx];
1730 match enter_stage(
1731 idx,
1732 &bp.0,
1733 &mut cursor,
1734 &mut state,
1735 &mut progress,
1736 &mut visits,
1737 setup,
1738 &mut window,
1739 ) {
1740 Ok(()) => {
1741 let name = bp.0.stages[idx].name.clone();
1742 let mut ec = commands.entity(outcome.entity);
1743 ec.remove::<AwaitingTransitionResponse>();
1744 attach_stage_components(ec, stage_infs.0[idx].clone(), setup, idx, name);
1745 if !to_compact.is_empty() {
1746 commands
1747 .entity(outcome.entity)
1748 .insert(PendingEdgeCompact(to_compact));
1749 }
1750 }
1751 Err(message) => {
1752 state.status = AgentStatus::Error { message };
1753 commands
1754 .entity(outcome.entity)
1755 .remove::<AwaitingTransitionResponse>();
1756 }
1757 }
1758 }
1759 None => {
1760 state.status = AgentStatus::Complete;
1761 commands
1762 .entity(outcome.entity)
1763 .remove::<AwaitingTransitionResponse>();
1764 }
1765 }
1766 }
1767}
1768
1769pub fn sync_tool_stages(
1774 service: Res<ToolServiceRes>,
1775 entered: Query<(Entity, &StageJustEntered)>,
1776 mut commands: Commands,
1777) {
1778 crate::tick_scope::clear();
1779 for (entity, stage) in entered.iter() {
1780 crate::tick_scope::enter(entity);
1781 service.0.sync_stage(entity, stage.index, &stage.name);
1782 commands.entity(entity).remove::<StageJustEntered>();
1783 }
1784}
1785
1786pub fn refresh_advertised_tools(
1794 service: Res<ToolServiceRes>,
1795 mut agents: Query<
1796 (
1797 Entity,
1798 &StageCursor,
1799 &mut StageInference,
1800 &mut StageInferences,
1801 ),
1802 With<ToolsNeedRefresh>,
1803 >,
1804 mut commands: Commands,
1805) {
1806 crate::tick_scope::clear();
1807 for (entity, cursor, mut si, mut sis) in agents.iter_mut() {
1808 crate::tick_scope::enter(entity);
1809 if let Some(tools) = service.0.refresh_tools(entity, cursor.index) {
1810 si.tools = tools.clone();
1811 if let Some(slot) = sis.0.get_mut(cursor.index) {
1814 slot.tools = tools;
1815 }
1816 }
1817 commands.entity(entity).remove::<ToolsNeedRefresh>();
1818 }
1819}
1820
1821pub fn poll_dynamic_tool_refresh(
1826 service: Res<ToolServiceRes>,
1827 agents: Query<Entity, With<DynamicTools>>,
1828 mut commands: Commands,
1829) {
1830 crate::tick_scope::clear();
1831 for entity in agents.iter() {
1832 crate::tick_scope::enter(entity);
1833 if service.0.wants_refresh(entity) {
1834 commands.entity(entity).insert(ToolsNeedRefresh);
1835 }
1836 }
1837}