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>,
}
#[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,
Next(
usize,
leviath_core::blueprint::EdgeTransform,
Option<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 const WORKSPACE_CHECK_INTERVAL: usize = 5;
#[allow(clippy::type_complexity)]
pub fn check_workspace_health(
mut agents: Query<
(
Entity,
&RunMetadata,
&StageProgress,
&mut AgentState,
Option<&mut crate::persistence::RunOutcomeFlags>,
),
With<ReadyToInfer>,
>,
mut commands: Commands,
) {
crate::tick_scope::clear();
for (entity, md, progress, mut state, flags) in agents.iter_mut() {
crate::tick_scope::enter(entity);
if state.status != AgentStatus::Active {
continue;
}
if progress.iterations % WORKSPACE_CHECK_INTERVAL != 0 {
continue;
}
if std::fs::metadata(&md.workdir).is_ok_and(|m| m.is_dir()) {
continue;
}
tracing::error!(
run_id = %md.run_id,
workdir = %md.workdir,
"working directory is gone; failing the run"
);
state.status = AgentStatus::Error {
message: format!("workspace '{}' is no longer accessible", md.workdir),
};
if let Some(mut flags) = flags {
flags.0.workspace_lost = true;
}
commands.entity(entity).remove::<ReadyToInfer>();
}
}
#[allow(clippy::type_complexity)]
pub fn enforce_max_iterations(
mut agents: Query<
(
Entity,
&AgentState,
&AgentBlueprint,
&StageCursor,
&StageProgress,
Option<&mut crate::persistence::RunOutcomeFlags>,
),
With<ReadyToInfer>,
>,
mut commands: Commands,
) {
crate::tick_scope::clear();
for (entity, state, bp, cursor, progress, flags) in agents.iter_mut() {
crate::tick_scope::enter(entity);
if state.status != AgentStatus::Active {
continue;
}
let max = bp.0.stages[cursor.index].max_iterations.unwrap_or(0);
if max > 0 && progress.iterations >= max {
if let Some(mut flags) = flags {
flags.0.max_iterations_hit += 1;
}
commands
.entity(entity)
.remove::<ReadyToInfer>()
.insert(ResolveTransition)
.insert(StageOutcome::MaxIterations);
}
}
}
pub(crate) const STUCK_REPORT_REGION: &str = "stuck_report";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct StuckMetrics {
pub iterations: usize,
pub elapsed_secs: u64,
pub tool_calls: usize,
pub hottest_edit: Option<(String, usize)>,
}
pub(crate) fn detect_stuck(
cfg: &leviath_core::blueprint::StuckConfig,
m: &StuckMetrics,
) -> Option<String> {
if let (Some(limit), Some((path, hits))) = (cfg.after_same_file_edits, m.hottest_edit.as_ref())
&& *hits >= limit
{
return Some(format!(
"you have written or edited '{path}' {hits} times in this stage without \
resolving the task - the problem is very likely not in that file"
));
}
if let Some(limit) = cfg.after_iterations
&& m.iterations >= limit
{
return Some(format!(
"you have run {} inference turns in this stage without finishing it",
m.iterations
));
}
if let Some(limit) = cfg.after_tool_calls
&& m.tool_calls >= limit
{
return Some(format!(
"you have made {} tool calls in this stage without finishing it",
m.tool_calls
));
}
if let Some(limit) = cfg.after_minutes
&& m.elapsed_secs >= limit as u64 * 60
{
return Some(format!(
"you have spent {} minutes in this stage without finishing it",
m.elapsed_secs / 60
));
}
None
}
pub(crate) fn hottest_edit(
edits: &std::collections::HashMap<String, usize>,
) -> Option<(String, usize)> {
edits
.iter()
.max_by(|a, b| a.1.cmp(b.1).then_with(|| b.0.cmp(a.0)))
.map(|(path, n)| (path.clone(), *n))
}
pub(crate) fn note_stuck(window: &mut ContextWindow, stage: &str, reason: &str) {
let region = if window.get_region(STUCK_REPORT_REGION).is_some() {
STUCK_REPORT_REGION
} else {
"conversation"
};
let content = format!(
"[Stuck detected in stage '{stage}'] {reason}. Stop repeating what you have been \
doing. Re-read the original task, separate what you have actually verified from \
what you assumed, and take a different approach - including reverting changes \
that made things worse."
);
let tokens = leviath_core::estimate_tokens(&content);
let _ = window.add_to_region(region, content, tokens);
}
#[allow(clippy::type_complexity)]
pub fn detect_stuck_stage(
mut agents: Query<
(
Entity,
&AgentState,
&AgentBlueprint,
&StageCursor,
&mut StageProgress,
&VisitCounts,
&mut ContextWindow,
Option<&mut StageIoBuffer>,
),
With<ReadyToInfer>,
>,
mut commands: Commands,
) {
use leviath_core::blueprint::TransitionCondition;
let now = chrono::Utc::now().timestamp();
crate::tick_scope::clear();
for (entity, state, bp, cursor, mut progress, visits, mut window, buffer) in agents.iter_mut() {
crate::tick_scope::enter(entity);
if state.status != AgentStatus::Active || progress.stuck_fired {
continue; }
let stage = &bp.0.stages[cursor.index];
let Some(cfg) =
find_conditioned_edge_ref(&bp.0, stage, &visits.0, TransitionCondition::Stuck)
.and_then(|(_, edge)| edge.stuck)
else {
continue; };
let started = *progress.stage_started_at.get_or_insert(now);
let metrics = StuckMetrics {
iterations: progress.iterations,
elapsed_secs: (now - started).max(0) as u64,
tool_calls: progress.total_tool_calls,
hottest_edit: hottest_edit(&progress.edits_by_path),
};
let Some(reason) = detect_stuck(&cfg, &metrics) else {
continue;
};
progress.stuck_fired = true;
note_stuck(&mut window, &stage.name, &reason);
if let Some(mut buffer) = buffer {
buffer
.logs
.push((cursor.index, format!("[stuck] {reason}")));
}
commands
.entity(entity)
.remove::<ReadyToInfer>()
.insert(ResolveTransition)
.insert(StageOutcome::Stuck(reason));
}
}
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 => 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(),
)
}
_ => 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 fn gate_requires_children(world: &mut World) {
crate::tick_scope::clear();
use crate::components::SubAgentChildren;
let mut candidates: Vec<(Entity, Vec<Entity>)> = Vec::new();
{
let mut q = world.query_filtered::<(
Entity,
&AgentBlueprint,
&StageCursor,
&SubAgentChildren,
&AgentState,
), With<ResolveTransition>>();
for (e, bp, cursor, children, _) in q.iter(world) {
if bp.0.stages[cursor.index].requires_children {
candidates.push((e, children.children.clone()));
}
}
}
for (entity, children) in candidates {
crate::tick_scope::enter(entity);
let pending = children.iter().any(|&c| {
world
.get::<AgentState>(c)
.is_some_and(|s| !is_terminal_status(&s.status))
});
if pending {
world
.entity_mut(entity)
.remove::<ResolveTransition>()
.insert(WaitingForChildren);
world
.get_mut::<AgentState>(entity)
.expect("held agent has AgentState")
.status = AgentStatus::Waiting;
}
}
crate::tick_scope::clear();
let mut waiting: Vec<(Entity, Vec<Entity>)> = Vec::new();
{
let mut q = world.query_filtered::<
(Entity, Option<&SubAgentChildren>, &AgentState),
With<WaitingForChildren>,
>();
for (e, children, _) in q.iter(world) {
waiting.push((e, children.map(|c| c.children.clone()).unwrap_or_default()));
}
}
for (entity, children) in waiting {
crate::tick_scope::enter(entity);
let all_done = children.iter().all(|&c| {
world
.get::<AgentState>(c)
.is_none_or(|s| is_terminal_status(&s.status))
});
if all_done {
world
.entity_mut(entity)
.remove::<WaitingForChildren>()
.insert(ResolveTransition);
world
.get_mut::<AgentState>(entity)
.expect("waiting agent has AgentState")
.status = AgentStatus::Active;
}
}
}
pub(crate) const DEFAULT_REQUIRED_REENTRY_CAP: usize = 3;
#[derive(Component, Debug, Clone, Copy)]
pub struct RequiredReentries(pub usize);
pub(crate) fn unmet_required_regions(
blueprint: &leviath_core::Blueprint,
stage: &leviath_core::Stage,
window: &ContextWindow,
) -> Vec<(String, Option<String>)> {
let can_write = stage
.available_tools
.iter()
.any(|t| t == "context_write" || t == "context_append");
if !can_write {
return Vec::new();
}
let layout = stage
.context_layout
.as_ref()
.unwrap_or(&blueprint.context_layout);
layout
.regions
.iter()
.filter(|r| r.required)
.filter(|r| {
!matches!(
r.seed,
Some(leviath_core::layout::RegionSeed::CallerInput { .. })
)
})
.filter(|r| {
window
.get_region(&r.name)
.map(|reg| reg.content.is_empty())
.unwrap_or(true)
})
.map(|r| (r.name.clone(), r.required_message.clone()))
.collect()
}
pub(crate) fn inject_required_region_nudges(
window: &mut ContextWindow,
unmet: &[(String, Option<String>)],
) {
for (name, msg) in unmet {
let text = msg.clone().unwrap_or_else(|| {
format!(
"Required context region '{name}' is still empty. You must populate it \
(e.g. via context_write with region=\"{name}\") before this stage can complete."
)
});
let content = format!("[System] {text}");
let tokens = leviath_core::estimate_tokens(&content);
let _ = window.add_to_region("conversation", content, tokens);
}
}
#[allow(clippy::type_complexity)]
pub fn require_context_regions(
mut agents: Query<
(
Entity,
&AgentBlueprint,
&StageCursor,
&mut ContextWindow,
Option<&RequiredReentries>,
Option<&StageOutcome>,
),
With<ResolveTransition>,
>,
mut commands: Commands,
) {
crate::tick_scope::clear();
for (entity, bp, cursor, mut window, reentries, outcome) in agents.iter_mut() {
crate::tick_scope::enter(entity);
if outcome.is_some() {
continue; }
let stage = &bp.0.stages[cursor.index];
let unmet = unmet_required_regions(&bp.0, stage, &window);
if unmet.is_empty() {
continue;
}
let cap = stage.max_revisits.unwrap_or(DEFAULT_REQUIRED_REENTRY_CAP);
let round = reentries.map_or(0, |r| r.0);
if round >= cap {
let names: Vec<&str> = unmet.iter().map(|(n, _)| n.as_str()).collect();
tracing::warn!(
stage = %stage.name,
regions = ?names,
attempts = cap,
"required context regions still empty after re-run attempts; proceeding"
);
continue; }
inject_required_region_nudges(&mut window, &unmet);
commands
.entity(entity)
.remove::<ResolveTransition>()
.insert(ReadyToInfer)
.insert(RequiredReentries(round + 1));
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum GateDecision {
Pass,
Forced,
Block(String),
}
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 !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;
}
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,
"stage still has no file modifications after re-run attempts; proceeding"
);
return GateDecision::Forced;
}
GateDecision::Block(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()
}))
}
pub(crate) fn hold_for_gate(
entity: Entity,
nudge: &str,
progress: &mut StageProgress,
window: &mut ContextWindow,
commands: &mut Commands,
) {
let content = format!("[System] {nudge}");
let tokens = leviath_core::estimate_tokens(&content);
let _ = window.add_to_region("conversation", content, tokens);
progress.gate_reentries += 1;
commands
.entity(entity)
.remove::<ResolveTransition>()
.remove::<AwaitingTransitionResponse>()
.remove::<StageOutcome>()
.insert(ReadyToInfer);
}
#[allow(clippy::type_complexity)]
pub fn resolve_transition(
mut agents: Query<
(
Entity,
&AgentBlueprint,
&mut StageCursor,
&mut AgentState,
&mut StageProgress,
&StageInferences,
&StageSetups,
&mut VisitCounts,
&mut ContextWindow,
Option<&StageOutcome>,
Option<&mut crate::persistence::RunOutcomeFlags>,
),
With<ResolveTransition>,
>,
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,
) in agents.iter_mut()
{
crate::tick_scope::enter(entity);
let stage = &bp.0.stages[cursor.index];
let resolution = match outcome {
Some(StageOutcome::Errored(_)) => {
find_conditioned_edge(&bp.0, stage, &visits.0, TransitionCondition::Error)
.map(|(i, t)| StageResolution::Next(i, t, None))
.unwrap_or(StageResolution::TerminalError)
}
Some(StageOutcome::MaxIterations) => {
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),
};
match resolution {
StageResolution::Terminal => {
state.status = AgentStatus::Complete;
commands
.entity(entity)
.remove::<ResolveTransition>()
.remove::<StageOutcome>();
}
StageResolution::TerminalError => {
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_ref(), 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];
match enter_stage(
idx,
&bp.0,
&mut cursor,
&mut state,
&mut progress,
&mut visits,
setup,
&mut window,
) {
Ok(()) => {
state.status = AgentStatus::Active;
let name = bp.0.stages[idx].name.clone();
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);
}
}
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn enter_stage(
idx: usize,
blueprint: &leviath_core::Blueprint,
cursor: &mut StageCursor,
state: &mut AgentState,
progress: &mut StageProgress,
visits: &mut VisitCounts,
setup: &StageSetup,
window: &mut ContextWindow,
) -> Result<(), String> {
cursor.index = idx;
let name = blueprint.stages[idx].name.clone();
state.current_stage = name.clone();
state.accepts_messages = setup.accepts_messages;
*progress = StageProgress::default();
*visits.0.entry(name).or_insert(0) += 1;
apply_stage_context(setup, window)
}
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 = window
.regions
.iter()
.find(|r| matches!(r.kind, leviath_core::RegionKind::Pinned))
.map(|r| r.name.clone())
.unwrap_or_else(|| "conversation".to_string());
if let Some(region) = window.regions.iter_mut().find(|r| r.name == target) {
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>()
.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, entity: Entity, target_idx: usize) {
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,
&mut cursor,
&mut state,
&mut progress,
&mut visits,
&setup,
&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>();
}
}
}
pub struct ResolvedStage {
pub provider_name: String,
pub model: String,
pub tools: Vec<Tool>,
}
pub(crate) const DEFAULT_CONTEXT_WINDOW_TOKENS: usize = 8192;
pub(crate) fn context_window_tokens(world: &World, provider_name: &str, model: &str) -> usize {
match world
.get_resource::<Providers>()
.and_then(|p| p.0.get(provider_name))
{
Some(provider) => provider.max_context_tokens(model),
None => {
tracing::warn!(
provider = provider_name,
model,
"provider not registered; using default context window for percentage budgets"
);
DEFAULT_CONTEXT_WINDOW_TOKENS
}
}
}
pub(crate) fn stage_setup_from(
stage: &leviath_core::Stage,
global_batch_tool_hint: bool,
agent_batch_tool_hint: Option<bool>,
) -> StageSetup {
let temperature = stage
.model
.parameters
.get("temperature")
.and_then(|v| v.as_f64())
.map(|t| t as f32);
let extra_params: serde_json::Map<String, serde_json::Value> = stage
.model
.parameters
.iter()
.filter(|(k, _)| k.as_str() != "temperature" && k.as_str() != "max_output_tokens")
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let max_output_tokens = stage
.model
.parameters
.get("max_output_tokens")
.and_then(|v| v.as_u64())
.map(|t| t as usize);
let base_prompt = stage
.config
.get("system_prompt")
.and_then(|v| v.as_str())
.map(String::from);
let system_prompt = match &stage.mode {
leviath_core::blueprint::StageMode::FanOut { config }
if !config.split_prompt.trim().is_empty() =>
{
Some(match base_prompt {
Some(base) => format!("{base}\n\n{}", config.split_prompt),
None => config.split_prompt.clone(),
})
}
_ => base_prompt,
};
let batch_tool_hint = leviath_core::taint::resolve_batch_tool_hint(
global_batch_tool_hint,
agent_batch_tool_hint,
stage.batch_tool_hint,
);
StageSetup {
inference_config: InferenceConfig {
temperature,
max_output_tokens,
extra_params,
batch_tool_hint,
request_timeout_secs: stage.model.request_timeout_secs,
},
routing: stage.tool_result_routing.clone(),
accepts_messages: stage.accepts_messages,
context_layout: stage.context_layout.clone(),
system_prompt,
}
}
pub fn spawn_agent(
world: &mut World,
agent_id: String,
blueprint: leviath_core::Blueprint,
task: &str,
stages: Vec<ResolvedStage>,
global_batch_tool_hint: bool,
) -> Result<Entity, String> {
let seeds = std::collections::HashMap::from([("task".to_string(), task.to_string())]);
spawn_agent_seeded(
world,
agent_id,
blueprint,
&seeds,
stages,
global_batch_tool_hint,
std::collections::HashMap::new(),
)
}
pub fn spawn_agent_seeded(
world: &mut World,
agent_id: String,
mut blueprint: leviath_core::Blueprint,
seeds: &std::collections::HashMap<String, String>,
stages: Vec<ResolvedStage>,
global_batch_tool_hint: bool,
region_scripts: std::collections::HashMap<
String,
std::sync::Arc<leviath_scripting::region_hook::RegionScript>,
>,
) -> Result<Entity, String> {
let stage_windows: Vec<usize> = stages
.iter()
.map(|rs| context_window_tokens(world, &rs.provider_name, &rs.model))
.collect();
blueprint.context_layout = blueprint.context_layout.resolved(stage_windows[0]);
for (i, stage) in blueprint.stages.iter_mut().enumerate() {
if let Some(layout) = &stage.context_layout {
stage.context_layout = Some(layout.resolved(stage_windows[i]));
}
}
blueprint
.context_layout
.validate()
.map_err(|e| e.to_string())?;
for stage in &blueprint.stages {
if let Some(layout) = &stage.context_layout {
layout.validate().map_err(|e| e.to_string())?;
}
}
let stage_infs: Vec<StageInference> = stages
.into_iter()
.map(|rs| StageInference {
provider_name: rs.provider_name,
model: rs.model,
tools: rs.tools,
tool_filter: None, })
.collect();
let agent_batch_tool_hint = blueprint.batch_tool_hint;
let setups: Vec<StageSetup> = blueprint
.stages
.iter()
.map(|s| stage_setup_from(s, global_batch_tool_hint, agent_batch_tool_hint))
.collect();
let mut window = ContextWindow::new(blueprint.context_layout.total_budget_tokens);
window.region_scripts = region_scripts;
crate::context_setup::init_window_seeded(&mut window, &blueprint, seeds);
apply_stage_context(&setups[0], &mut window)?;
let stage0_name = blueprint.stages[0].name.clone();
let stage0_inf = stage_infs[0].clone();
let setup0 = &setups[0];
let stage0_cfg = setup0.inference_config.clone();
let stage0_routing = setup0.routing.clone();
let accepts_messages = setup0.accepts_messages;
let mut visits = VisitCounts::default();
*visits.0.entry(stage0_name.clone()).or_insert(0) += 1;
let ledger = StageLedger(
blueprint
.stages
.iter()
.enumerate()
.map(|(i, s)| leviath_core::run_meta::StageRecord::new(s.name.clone(), i))
.collect(),
);
let repetition = blueprint
.repetition_detection
.as_ref()
.map(crate::repetition::RepetitionDetector::from_detection_config);
let entity = world
.spawn((
AgentBlueprint(blueprint),
AgentState {
agent_id,
current_stage: stage0_name,
iteration: 0,
status: AgentStatus::Active,
spawned_children_ids: vec![],
pending_wait: None,
accepts_messages,
},
MessageInbox::default(),
StageCursor { index: 0 },
StageProgress::default(),
StageInferences(stage_infs),
StageSetups(setups),
visits,
window,
stage0_inf,
stage0_cfg,
ReadyToInfer,
))
.id();
world
.entity_mut(entity)
.insert((ledger, StageIoBuffer::default()));
if let Some(detector) = repetition {
world.entity_mut(entity).insert(detector);
}
if let Some(routing) = stage0_routing {
world
.entity_mut(entity)
.insert(crate::components::ToolResultRoutingComponent { routing });
}
Ok(entity)
}
#[derive(Component, Debug, Clone)]
pub struct AwaitingTransitionResponse(pub Vec<leviath_core::blueprint::TransitionEdge>);
#[derive(Resource)]
pub struct TransitionResults(pub UnboundedReceiver<InferenceOutcome>);
pub(crate) fn build_transition_prompt(
stage: &leviath_core::Stage,
edges: &[leviath_core::blueprint::TransitionEdge],
) -> String {
let mut p = match &stage.transition_prompt {
Some(custom) => {
let mut p = custom.clone();
p.push_str("\n\nAvailable transitions:\n");
p
}
None => format!(
"Stage '{}' is complete. Available next stages:\n",
stage.name
),
};
for edge in edges {
p.push_str(&format!("- {}", edge.target));
if let Some(hint) = &edge.hint {
p.push_str(&format!(": {hint}"));
}
p.push('\n');
}
if stage.transition_prompt.is_some() {
if stage.allow_complete {
p.push_str(
"\nRespond with ONLY the stage name you want to transition to, or ONLY the \
word DONE if no further stage is needed and the run should end here.",
);
} else {
p.push_str(
"\nRespond with ONLY the stage name you want to transition to, nothing else.",
);
}
} else if stage.allow_complete {
p.push_str(
"\nWhich stage should run next? Respond with ONLY the stage name, or ONLY the \
word DONE if no further stage is needed and the run should end here.",
);
} else {
p.push_str("\nWhich stage should run next? Respond with ONLY the stage name.");
}
p
}
pub(crate) fn match_transition_choice(
choice: &str,
edges: &[leviath_core::blueprint::TransitionEdge],
allow_complete: bool,
) -> Option<String> {
let lines: Vec<&str> = choice
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
let words_in = |line: &str| {
line.split(|c: char| !c.is_alphanumeric() && c != '_')
.filter(|w| !w.is_empty())
.count()
};
let first = lines.first().copied();
let last = lines
.last()
.copied()
.filter(|l| lines.len() > 1 && words_in(l) <= 3);
for line in first.into_iter().chain(last) {
for word in line.split(|c: char| !c.is_alphanumeric() && c != '_') {
if word.is_empty() {
continue;
}
if allow_complete && word.eq_ignore_ascii_case("done") {
return None;
}
if let Some(edge) = edges.iter().find(|e| word.eq_ignore_ascii_case(&e.target)) {
return Some(edge.target.clone());
}
}
}
if allow_complete {
None
} else {
edges.first().map(|edge| edge.target.clone())
}
}
#[allow(clippy::type_complexity)]
pub fn dispatch_transition_choice(
mut agents: Query<
(
Entity,
&AgentState,
&mut ContextWindow,
&StageInference,
&AgentBlueprint,
&StageCursor,
&AwaitingTransitionChoice,
Option<&InFlightWork>,
),
With<AwaitingTransitionChoice>,
>,
stage: Res<InferenceStage>,
providers: Res<Providers>,
mut commands: Commands,
) {
crate::tick_scope::clear();
for (entity, state, mut window, si, bp, cursor, choice, in_flight) in agents.iter_mut() {
crate::tick_scope::enter(entity);
if state.status != AgentStatus::Active {
continue; }
let Some(provider) = providers.0.get(&si.provider_name) else {
continue; };
let Some(permit) = stage.pools.try_acquire(&si.model) else {
continue; };
let current = &bp.0.stages[cursor.index];
let prompt = build_transition_prompt(current, &choice.0);
let tokens = leviath_core::estimate_tokens(&prompt);
let _ = window.add_typed_entry(
"conversation",
leviath_core::EntryKind::UserMessage,
prompt,
tokens,
);
let assembled = window.assemble();
let remaining = window.max_tokens.saturating_sub(window.current_tokens);
let request = InferenceRequest {
system: assembled.system_blocks,
messages: assembled.messages,
model: si.model.clone(),
max_tokens: remaining.min(256), temperature: 0.0, tools: Vec::new(),
extra: serde_json::Value::Null,
request_timeout_secs: None,
};
let job = InferenceJob {
entity,
provider,
request,
permit,
exact_token_counting: false,
};
let cancel = crate::cancel::CancelToken::new();
stage.runtime.spawn(run_inference_job(
job,
stage.transition_outcomes.clone(),
stage.wake.clone(),
crate::inference_bridge::RetryPolicy::default(),
cancel.clone(),
));
track_in_flight(&mut commands, entity, in_flight, cancel);
commands
.entity(entity)
.remove::<AwaitingTransitionChoice>()
.insert(AwaitingTransitionResponse(choice.0.clone()));
}
}
#[allow(clippy::type_complexity)]
pub fn collect_transition_choice(
mut results: ResMut<TransitionResults>,
mut agents: Query<(
&AgentBlueprint,
&mut StageCursor,
&mut AgentState,
&mut StageProgress,
&StageInferences,
&StageSetups,
&mut VisitCounts,
&mut ContextWindow,
&AwaitingTransitionResponse,
Option<&mut crate::persistence::RunOutcomeFlags>,
)>,
mut commands: Commands,
) {
crate::tick_scope::clear();
while let Ok(outcome) = results.0.try_recv() {
let Ok((
bp,
mut cursor,
mut state,
mut progress,
stage_infs,
setups,
mut visits,
mut window,
resp,
mut flags,
)) = agents.get_mut(outcome.entity)
else {
continue; };
crate::tick_scope::enter(outcome.entity);
if is_terminal_status(&state.status) {
commands
.entity(outcome.entity)
.remove::<AwaitingTransitionResponse>()
.remove::<InFlightWork>();
continue;
}
let response = match outcome.result {
Ok(response) => response,
Err(err) => {
state.status = AgentStatus::Error {
message: err.to_string(),
};
commands
.entity(outcome.entity)
.remove::<AwaitingTransitionResponse>();
continue;
}
};
let choice = response.content.trim().to_string();
let tokens = leviath_core::estimate_tokens(&choice);
let _ = window.add_typed_entry(
"conversation",
leviath_core::EntryKind::AssistantTurn { tool_calls: vec![] },
format!("Transitioning to: {choice}"),
tokens,
);
let allow_complete = bp.0.stages[cursor.index].allow_complete;
match match_transition_choice(&choice, &resp.0, allow_complete) {
Some(target) => {
let idx =
bp.0.stages
.iter()
.position(|s| s.name == target)
.unwrap_or(0);
let edge = resp.0.iter().find(|e| e.target == target);
let transform = edge.map(|e| e.transform.clone()).unwrap_or_default();
let stage = &bp.0.stages[cursor.index];
match gate_blocks(
edge.and_then(|e| e.gate.as_ref()),
stage,
&progress,
&window,
) {
GateDecision::Block(nudge) => {
hold_for_gate(
outcome.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];
match enter_stage(
idx,
&bp.0,
&mut cursor,
&mut state,
&mut progress,
&mut visits,
setup,
&mut window,
) {
Ok(()) => {
let name = bp.0.stages[idx].name.clone();
let mut ec = commands.entity(outcome.entity);
ec.remove::<AwaitingTransitionResponse>();
attach_stage_components(ec, stage_infs.0[idx].clone(), setup, idx, name);
if !to_compact.is_empty() {
commands
.entity(outcome.entity)
.insert(PendingEdgeCompact(to_compact));
}
}
Err(message) => {
state.status = AgentStatus::Error { message };
commands
.entity(outcome.entity)
.remove::<AwaitingTransitionResponse>();
}
}
}
None => {
state.status = AgentStatus::Complete;
commands
.entity(outcome.entity)
.remove::<AwaitingTransitionResponse>();
}
}
}
}
pub fn sync_tool_stages(
service: Res<ToolServiceRes>,
entered: Query<(Entity, &StageJustEntered)>,
mut commands: Commands,
) {
crate::tick_scope::clear();
for (entity, stage) in entered.iter() {
crate::tick_scope::enter(entity);
service.0.sync_stage(entity, stage.index, &stage.name);
commands.entity(entity).remove::<StageJustEntered>();
}
}
pub fn refresh_advertised_tools(
service: Res<ToolServiceRes>,
mut agents: Query<
(
Entity,
&StageCursor,
&mut StageInference,
&mut StageInferences,
),
With<ToolsNeedRefresh>,
>,
mut commands: Commands,
) {
crate::tick_scope::clear();
for (entity, cursor, mut si, mut sis) in agents.iter_mut() {
crate::tick_scope::enter(entity);
if let Some(tools) = service.0.refresh_tools(entity, cursor.index) {
si.tools = tools.clone();
if let Some(slot) = sis.0.get_mut(cursor.index) {
slot.tools = tools;
}
}
commands.entity(entity).remove::<ToolsNeedRefresh>();
}
}
pub fn poll_dynamic_tool_refresh(
service: Res<ToolServiceRes>,
agents: Query<Entity, With<DynamicTools>>,
mut commands: Commands,
) {
crate::tick_scope::clear();
for entity in agents.iter() {
crate::tick_scope::enter(entity);
if service.0.wants_refresh(entity) {
commands.entity(entity).insert(ToolsNeedRefresh);
}
}
}