use super::*;
#[derive(Component, Debug, Clone)]
pub struct AgentBlueprint(pub leviath_core::Blueprint);
#[derive(Component, Debug, Clone, Copy)]
pub struct StageCursor {
pub index: usize,
}
#[derive(Component, Debug, Clone)]
pub struct StageInferences(pub Vec<StageInference>);
#[derive(Component, Debug, Clone, Default)]
pub struct VisitCounts(pub std::collections::HashMap<String, usize>);
#[derive(Clone)]
pub struct StageSetup {
pub inference_config: InferenceConfig,
pub routing: Option<leviath_core::ToolResultRouting>,
pub accepts_messages: bool,
pub context_layout: Option<leviath_core::ContextLayout>,
pub system_prompt: Option<String>,
pub output: Option<leviath_core::output::OutputSpec>,
}
#[derive(Component, Clone)]
pub struct StageSetups(pub Vec<StageSetup>);
#[derive(Component, Debug, Clone)]
pub struct AwaitingTransitionChoice(pub Vec<leviath_core::blueprint::TransitionEdge>);
pub(crate) enum StageResolution {
Terminal,
TerminalError,
DeadEnd,
Next(
usize,
leviath_core::blueprint::EdgeTransform,
Option<Box<leviath_core::blueprint::TransitionGate>>,
),
Choose(Vec<leviath_core::blueprint::TransitionEdge>),
Resume,
}
pub(crate) fn find_conditioned_edge_ref<'a>(
blueprint: &leviath_core::Blueprint,
stage: &'a leviath_core::Stage,
visits: &std::collections::HashMap<String, usize>,
condition: leviath_core::blueprint::TransitionCondition,
) -> Option<(usize, &'a leviath_core::blueprint::TransitionEdge)> {
let transitions = stage.transitions.as_ref()?;
transitions.values().find_map(|edge| {
if edge.condition != condition {
return None;
}
let idx = blueprint
.stages
.iter()
.position(|s| s.name == edge.target)?;
let within_budget = match blueprint.stages[idx].max_revisits {
Some(max) => visits.get(&edge.target).copied().unwrap_or(0) <= max,
None => true,
};
within_budget.then_some((idx, edge))
})
}
pub(crate) fn find_conditioned_edge(
blueprint: &leviath_core::Blueprint,
stage: &leviath_core::Stage,
visits: &std::collections::HashMap<String, usize>,
condition: leviath_core::blueprint::TransitionCondition,
) -> Option<(usize, leviath_core::blueprint::EdgeTransform)> {
find_conditioned_edge_ref(blueprint, stage, visits, condition)
.map(|(idx, edge)| (idx, edge.transform.clone()))
}
pub(crate) fn resolve_transition_sync(
blueprint: &leviath_core::Blueprint,
stage: &leviath_core::Stage,
stage_idx: usize,
visits: &std::collections::HashMap<String, usize>,
) -> StageResolution {
use leviath_core::blueprint::TransitionCondition;
match &stage.transitions {
None => {
if stage_idx + 1 < blueprint.stages.len() {
StageResolution::Next(
stage_idx + 1,
leviath_core::blueprint::EdgeTransform::Direct,
None,
)
} else {
StageResolution::Terminal
}
}
Some(transitions) => {
if transitions.is_empty() {
return StageResolution::Terminal;
}
let available: Vec<&leviath_core::blueprint::TransitionEdge> = transitions
.values()
.filter(|e| match blueprint.find_stage(&e.target) {
Some(ts) => match ts.max_revisits {
Some(max) => visits.get(&e.target).copied().unwrap_or(0) <= max,
None => true,
},
None => false, })
.collect();
let choosable: Vec<&leviath_core::blueprint::TransitionEdge> = available
.into_iter()
.filter(|e| {
matches!(
e.condition,
TransitionCondition::Always | TransitionCondition::LlmChoice
)
})
.collect();
match choosable.len() {
0 => {
let declared_normal = transitions.values().any(|e| {
matches!(
e.condition,
TransitionCondition::Always | TransitionCondition::LlmChoice
)
});
if declared_normal {
StageResolution::DeadEnd
} else {
StageResolution::Terminal
}
}
1 if !stage.allow_complete => {
let idx = blueprint
.stages
.iter()
.position(|s| s.name == choosable[0].target)
.unwrap_or(0);
StageResolution::Next(
idx,
choosable[0].transform.clone(),
choosable[0].gate.clone().map(Box::new),
)
}
_ => StageResolution::Choose(choosable.into_iter().cloned().collect()),
}
}
}
}
#[derive(Component, Debug, Clone, Copy)]
pub struct WaitingForChildren;
pub fn is_terminal_status(status: &AgentStatus) -> bool {
matches!(
status,
AgentStatus::Complete | AgentStatus::Error { .. } | AgentStatus::Cancelled
)
}
pub(crate) fn gate_blocks(
gate: Option<&leviath_core::blueprint::TransitionGate>,
stage: &leviath_core::Stage,
progress: &StageProgress,
window: &ContextWindow,
) -> GateDecision {
let Some(gate) = gate else {
return GateDecision::Pass;
};
if let Some(name) = &gate.require_region_updated
&& let (Some(before), Some(region)) = (
progress.entry_region_digests.get(name),
window.get_region(name),
)
&& *before == region_digest(region)
{
return spend_gate_attempt(
gate,
stage,
progress,
gate.message.clone().unwrap_or_else(|| {
format!(
"The `{name}` region is unchanged since this stage began. Whatever sent \
you back here was not answered by repeating the same content - revise it \
before moving on."
)
}),
);
}
if let Some(name) = &gate.require_no_open_items
&& let Some(region) = window.get_region(name)
{
let open = region.open_checklist_items();
if !open.is_empty() {
let cap = gate
.max_attempts
.unwrap_or(leviath_core::blueprint::DEFAULT_GATE_ATTEMPTS);
if progress.gate_reentries >= cap {
tracing::warn!(
stage = %stage.name,
open = open.len(),
attempts = cap,
"stage still has open checklist items after re-run attempts; proceeding"
);
return GateDecision::Forced;
}
let listed = open
.iter()
.map(|i| format!("{} {}", i.id, i.text))
.collect::<Vec<_>>()
.join("; ");
return GateDecision::Block(gate.message.clone().unwrap_or_else(|| {
format!(
"{} item(s) are still open in `{name}`: {listed}. Finish them, or use \
todo_done to drop the ones that no longer apply, before moving on.",
open.len()
)
}));
}
}
let missing: Vec<&str> = gate
.require_regions
.iter()
.filter(|name| {
match window.get_region(name) {
Some(region) => region.content.is_empty(),
None => {
tracing::warn!(
stage = %stage.name,
region = %name,
"gate requires a region this stage's window does not hold; \
letting the transition through"
);
false
}
}
})
.map(String::as_str)
.collect();
if !missing.is_empty() {
let listed = missing.join(", ");
return spend_gate_attempt(
gate,
stage,
progress,
gate.message.clone().unwrap_or_else(|| {
format!(
"This stage is not finished: the `{listed}` region is still empty. \
Write it with context_write before moving on - later stages read \
from it, and there is nothing there yet."
)
}),
);
}
if !gate.require_modifications {
return GateDecision::Pass;
}
let can_modify = stage.available_tools.iter().any(|t| {
let canonical = leviath_tools::canonical_tool_name(t);
leviath_core::blueprint::MODIFYING_TOOLS.contains(&canonical)
|| gate
.tools
.iter()
.any(|extra| leviath_tools::canonical_tool_name(extra) == canonical)
});
if !can_modify {
return GateDecision::Pass;
}
if progress.modifying_tool_calls > 0 {
return GateDecision::Pass;
}
if progress.blocked_modification_calls > 0 {
tracing::warn!(
stage = %stage.name,
blocked = progress.blocked_modification_calls,
"file modifications were denied by policy; letting the gated transition through"
);
return GateDecision::Pass;
}
if let Some(region) = &gate.region
&& window
.get_region(region)
.is_some_and(|r| !r.content.is_empty())
{
return GateDecision::Pass;
}
spend_gate_attempt(
gate,
stage,
progress,
gate.message.clone().unwrap_or_else(|| {
"No file modifications were recorded in this stage. Changes made through the shell \
(sed -i, tee, >, >>) are not tracked by the framework. Re-apply your changes with \
edit_file or write_file before moving on."
.to_string()
}),
)
}
fn spend_gate_attempt(
gate: &leviath_core::blueprint::TransitionGate,
stage: &leviath_core::Stage,
progress: &StageProgress,
nudge: String,
) -> GateDecision {
let cap = gate
.max_attempts
.unwrap_or(leviath_core::blueprint::DEFAULT_GATE_ATTEMPTS);
if progress.gate_reentries >= cap {
tracing::warn!(
stage = %stage.name,
attempts = cap,
"transition gate still unsatisfied after re-run attempts; proceeding"
);
return GateDecision::Forced;
}
GateDecision::Block(nudge)
}
pub(crate) fn hold_for_gate(
entity: Entity,
nudge: &str,
progress: &mut StageProgress,
window: &mut ContextWindow,
commands: &mut Commands,
) {
crate::pipeline::response::inject_system_nudge(window, nudge);
progress.gate_reentries += 1;
commands
.entity(entity)
.remove::<ResolveTransition>()
.remove::<AwaitingTransitionResponse>()
.remove::<StageOutcome>()
.insert(ReadyToInfer);
}
type ResolveTransitionQuery = (
Entity,
&'static AgentBlueprint,
&'static mut StageCursor,
&'static mut AgentState,
&'static mut StageProgress,
&'static StageInferences,
&'static StageSetups,
&'static mut VisitCounts,
&'static mut ContextWindow,
Option<&'static StageOutcome>,
Option<&'static mut crate::persistence::RunOutcomeFlags>,
Option<&'static crate::persistence::RunMetadata>,
Option<&'static crate::persistence::FinalOutput>,
);
pub fn resolve_transition(
mut agents: Query<ResolveTransitionQuery, With<ResolveTransition>>,
sink: Option<Res<crate::host::WorldEventSink>>,
mut commands: Commands,
) {
crate::tick_scope::clear();
use leviath_core::blueprint::TransitionCondition;
for (
entity,
bp,
mut cursor,
mut state,
mut progress,
stage_infs,
setups,
mut visits,
mut window,
outcome,
mut flags,
metadata,
submitted,
) in agents.iter_mut()
{
crate::tick_scope::enter(entity);
if state.status == AgentStatus::Paused {
continue;
}
let stage = &bp.0.stages[cursor.index];
let resolution = match outcome {
Some(StageOutcome::Errored(message)) => {
match find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::Error) {
Some((i, t)) => {
note_error(&mut window, &stage.name, message);
StageResolution::Next(i, t, None)
}
None => StageResolution::TerminalError,
}
}
Some(StageOutcome::MaxIterations) => {
note_max_iterations(&mut window, &stage.name, stage.max_iterations.unwrap_or(0));
find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::MaxIterations)
.map(|(i, t)| StageResolution::Next(i, t, None))
.unwrap_or_else(|| {
resolve_transition_sync(&bp.0, stage, cursor.index, &visits.0)
})
}
Some(StageOutcome::Stuck(_)) => {
find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::Stuck)
.map(|(i, t)| StageResolution::Next(i, t, None))
.unwrap_or(StageResolution::Resume)
}
None => resolve_transition_sync(&bp.0, stage, cursor.index, &visits.0),
};
let resolution = match resolution {
StageResolution::DeadEnd => {
let message = format!(
"stage '{}' dead-ended: every declared transition's target has spent \
its max_revisits budget before an output or terminal stage was reached",
stage.name
);
let escape =
find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::DeadEnd)
.or_else(|| {
find_conditioned_edge(
&bp.0,
stage,
&visits.0,
TransitionCondition::Error,
)
});
match escape {
Some((i, t)) => {
note_error(&mut window, &stage.name, &message);
StageResolution::Next(i, t, None)
}
None => {
state.status = AgentStatus::Error { message };
StageResolution::TerminalError
}
}
}
other => other,
};
match resolution {
StageResolution::Terminal => {
let owed_output = bp.0.stages.iter().any(|s| s.require_output);
state.status = match owed_output && submitted.is_none() {
true => AgentStatus::Error {
message: "the run finished without the final output it \
requires; the stage that owes one never called \
submit_output"
.to_string(),
},
false => AgentStatus::Complete,
};
commands
.entity(entity)
.remove::<ResolveTransition>()
.remove::<StageOutcome>();
}
StageResolution::TerminalError | StageResolution::DeadEnd => {
commands
.entity(entity)
.remove::<ResolveTransition>()
.remove::<StageOutcome>();
}
StageResolution::Next(idx, transform, gate) => {
let gate = outcome.is_none().then_some(gate).flatten();
match gate_blocks(gate.as_deref(), stage, &progress, &window) {
GateDecision::Block(nudge) => {
hold_for_gate(entity, &nudge, &mut progress, &mut window, &mut commands);
continue;
}
GateDecision::Forced => {
if let Some(flags) = flags.as_mut() {
flags.0.gates_forced += 1;
}
}
GateDecision::Pass => {}
}
let to_compact = apply_edge_transform(&mut window, &transform);
let setup = &setups.0[idx];
let from = state.current_stage.clone();
match enter_stage(
idx,
&bp.0,
setup,
StageEntry {
cursor: &mut cursor,
state: &mut state,
progress: &mut progress,
visits: &mut visits,
window: &mut window,
},
) {
Ok(visit) => {
state.status = AgentStatus::Active;
let name = bp.0.stages[idx].name.clone();
emit_stage_transition(&sink, metadata, &state.agent_id, from, &name, visit);
let mut ec = commands.entity(entity);
ec.remove::<ResolveTransition>().remove::<StageOutcome>();
attach_stage_components(ec, stage_infs.0[idx].clone(), setup, idx, name);
if !to_compact.is_empty() {
commands
.entity(entity)
.insert(PendingEdgeCompact(to_compact));
}
}
Err(message) => {
state.status = AgentStatus::Error { message };
commands
.entity(entity)
.remove::<ResolveTransition>()
.remove::<StageOutcome>();
}
}
}
StageResolution::Choose(edges) => {
commands
.entity(entity)
.remove::<ResolveTransition>()
.remove::<StageOutcome>()
.insert(AwaitingTransitionChoice(edges));
}
StageResolution::Resume => {
commands
.entity(entity)
.remove::<ResolveTransition>()
.remove::<StageOutcome>()
.insert(ReadyToInfer);
}
}
}
}
pub(crate) struct StageEntry<'a> {
pub cursor: &'a mut StageCursor,
pub state: &'a mut AgentState,
pub progress: &'a mut StageProgress,
pub visits: &'a mut VisitCounts,
pub window: &'a mut ContextWindow,
}
pub(crate) fn enter_stage(
idx: usize,
blueprint: &leviath_core::Blueprint,
setup: &StageSetup,
entry: StageEntry<'_>,
) -> Result<usize, String> {
let StageEntry {
cursor,
state,
progress,
visits,
window,
} = entry;
cursor.index = idx;
let name = blueprint.stages[idx].name.clone();
state.current_stage = name.clone();
state.accepts_messages = setup.accepts_messages;
*progress = StageProgress::default();
let visit = visits.0.entry(name).or_insert(0);
*visit += 1;
let visit = *visit;
let result = apply_stage_context(setup, window).map(|()| visit);
progress.entry_region_digests = watched_region_digests(&blueprint.stages[idx], window);
result
}
pub(crate) fn watched_region_digests(
stage: &leviath_core::Stage,
window: &ContextWindow,
) -> std::collections::HashMap<String, u64> {
let mut digests = std::collections::HashMap::new();
let Some(transitions) = &stage.transitions else {
return digests;
};
for edge in transitions.values() {
let Some(name) = edge
.gate
.as_ref()
.and_then(|g| g.require_region_updated.as_ref())
else {
continue;
};
if let Some(region) = window.get_region(name) {
digests.insert(name.clone(), region_digest(region));
}
}
digests
}
pub(crate) fn region_digest(region: &leviath_core::Region) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
for entry in ®ion.content {
entry.content.hash(&mut hasher);
}
hasher.finish()
}
pub(crate) fn emit_stage_transition(
sink: &Option<Res<crate::host::WorldEventSink>>,
metadata: Option<&crate::persistence::RunMetadata>,
agent_id: &str,
from: String,
to: &str,
iteration: usize,
) {
if let (Some(sink), Some(md)) = (sink.as_ref(), metadata) {
let _ = sink.0.send(crate::host::WorldEvent::StageTransition {
run_id: md.run_id.clone(),
agent_id: agent_id.to_string(),
from,
to: to.to_string(),
iteration,
});
}
}
fn stage_instructions_target(window: &mut ContextWindow) -> String {
let declared = leviath_core::layout::STAGE_INSTRUCTIONS_REGION;
if let Some(at) = window.regions.iter().position(|r| r.name == declared) {
if at + 1 < window.regions.len() {
let region = window.regions.remove(at);
window.regions.push(region);
}
return declared.to_string();
}
window
.regions
.iter()
.find(|r| matches!(r.kind, leviath_core::RegionKind::Pinned))
.map(|r| r.name.clone())
.unwrap_or_else(|| "conversation".to_string())
}
pub(crate) fn apply_stage_context(
setup: &StageSetup,
window: &mut ContextWindow,
) -> Result<(), String> {
if let Some(layout) = &setup.context_layout {
crate::context_setup::apply_layout(window, layout);
}
let target = stage_instructions_target(window);
if let Some(region) = window.regions.iter_mut().find(|r| r.name == target) {
if target == leviath_core::layout::STAGE_INSTRUCTIONS_REGION {
region.clear();
} else {
region.remove_entries_by_prefix("[Stage instructions:");
}
}
if let Some(sp) = &setup.system_prompt {
let content = format!("[Stage instructions: {sp}]");
let tokens = leviath_core::estimate_tokens(&content);
window
.add_to_region(&target, content, tokens)
.map_err(|e| {
format!(
"stage system prompt (~{tokens} tokens) does not fit context region \
'{target}': {e}. Increase that region's max_tokens (or shorten the prompt)."
)
})?;
}
Ok(())
}
pub(crate) fn attach_stage_components(
mut entity: bevy_ecs::system::EntityCommands,
stage_inf: StageInference,
setup: &StageSetup,
stage_index: usize,
stage_name: String,
) {
entity
.insert(stage_inf)
.insert(setup.inference_config.clone())
.insert(StageJustEntered {
index: stage_index,
name: stage_name,
})
.remove::<crate::interaction_points::InteractionPointCursor>()
.remove::<crate::interaction_points::InteractionPointRounds>()
.remove::<RequiredReentries>()
.remove::<OutputReentries>()
.insert(ReadyToInfer);
match &setup.routing {
Some(routing) => {
entity.insert(crate::components::ToolResultRoutingComponent {
routing: routing.clone(),
});
}
None => {
entity.remove::<crate::components::ToolResultRoutingComponent>();
}
}
}
pub fn force_transition(world: &mut World, agent: crate::world::AgentId, target_idx: usize) {
let Some(entity) = agent.resolve_in(world) else {
return;
};
let attach: Option<(StageInference, StageSetup, String)> = {
let mut q = world.query::<(
&AgentBlueprint,
&mut StageCursor,
&mut AgentState,
&mut StageProgress,
&StageInferences,
&StageSetups,
&mut VisitCounts,
&mut ContextWindow,
)>();
let Ok((
bp,
mut cursor,
mut state,
mut progress,
stage_infs,
setups,
mut visits,
mut window,
)) = q.get_mut(world, entity)
else {
return; };
let setup = setups.0[target_idx].clone();
let stage_inf = stage_infs.0[target_idx].clone();
let name = bp.0.stages[target_idx].name.clone();
let bp = bp.0.clone();
match enter_stage(
target_idx,
&bp,
&setup,
StageEntry {
cursor: &mut cursor,
state: &mut state,
progress: &mut progress,
visits: &mut visits,
window: &mut window,
},
) {
Ok(_) => Some((stage_inf, setup, name)),
Err(message) => {
state.status = AgentStatus::Error { message };
None
}
}
};
let Some((stage_inf, setup, name)) = attach else {
return;
};
let mut em = world.entity_mut(entity);
em.insert(stage_inf)
.insert(setup.inference_config.clone())
.insert(StageJustEntered {
index: target_idx,
name,
})
.insert(ReadyToInfer);
match &setup.routing {
Some(routing) => {
em.insert(crate::components::ToolResultRoutingComponent {
routing: routing.clone(),
});
}
None => {
em.remove::<crate::components::ToolResultRoutingComponent>();
}
}
}