use super::*;
#[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())
}
}
type TransitionChoiceQuery = (
Entity,
&'static AgentState,
&'static mut ContextWindow,
&'static StageInference,
&'static AgentBlueprint,
&'static StageCursor,
&'static AwaitingTransitionChoice,
Option<&'static InFlightWork>,
Option<&'static DispatchStall>,
);
pub fn dispatch_transition_choice(
mut agents: Query<TransitionChoiceQuery, With<AwaitingTransitionChoice>>,
stage: Res<InferenceStage>,
providers: Res<Providers>,
mut commands: Commands,
) {
crate::tick_scope::clear();
let now = chrono::Utc::now().timestamp();
for (entity, state, mut window, si, bp, cursor, choice, in_flight, stalled) 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 {
commands
.entity(entity)
.insert(note_stall(stalled, StallReason::ProviderMissing, now));
continue; };
let Some(permit) = stage.pools.try_acquire(&si.model) else {
commands
.entity(entity)
.insert(note_stall(stalled, StallReason::PoolFull, now));
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();
let lost_outcomes = stage.transition_outcomes.clone();
let lost_wake = stage.wake.clone();
crate::lane_supervisor::spawn_supervised(
&stage.runtime,
"transition-choice",
run_inference_job(
job,
stage.transition_outcomes.clone(),
stage.wake.clone(),
crate::inference_bridge::RetryPolicy::default(),
cancel.clone(),
),
move |message| {
let _ = lost_outcomes.send(crate::inference_bridge::InferenceOutcome {
entity,
result: Err(leviath_providers::ProviderError::Other(message)),
latency: std::time::Duration::ZERO,
});
lost_wake.notify_one();
},
);
track_in_flight(&mut commands, entity, in_flight, cancel);
commands
.entity(entity)
.remove::<AwaitingTransitionChoice>()
.remove::<DispatchStall>()
.insert(AwaitingTransitionResponse(choice.0.clone()));
}
}
type CollectTransitionChoiceQuery = (
&'static AgentBlueprint,
&'static mut StageCursor,
&'static mut AgentState,
&'static mut StageProgress,
&'static StageInferences,
&'static StageSetups,
&'static mut VisitCounts,
&'static mut ContextWindow,
&'static AwaitingTransitionResponse,
Option<&'static mut crate::persistence::RunOutcomeFlags>,
Option<&'static crate::persistence::RunMetadata>,
);
pub fn collect_transition_choice(
mut results: ResMut<TransitionResults>,
mut agents: Query<CollectTransitionChoiceQuery>,
sink: Option<Res<crate::host::WorldEventSink>>,
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,
metadata,
)) = 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];
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) => {
let name = bp.0.stages[idx].name.clone();
emit_stage_transition(&sink, metadata, &state.agent_id, from, &name, visit);
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>();
}
}
}
}