leviath_runtime/pipeline/
transition_choice.rs1use super::*;
6
7#[derive(Component, Debug, Clone)]
11pub struct AwaitingTransitionResponse(pub Vec<leviath_core::blueprint::TransitionEdge>);
12
13#[derive(Resource)]
17pub struct TransitionResults(pub UnboundedReceiver<InferenceOutcome>);
18
19pub(crate) fn build_transition_prompt(
22 stage: &leviath_core::Stage,
23 edges: &[leviath_core::blueprint::TransitionEdge],
24) -> String {
25 let mut p = match &stage.transition_prompt {
26 Some(custom) => {
27 let mut p = custom.clone();
28 p.push_str("\n\nAvailable transitions:\n");
29 p
30 }
31 None => format!(
32 "Stage '{}' is complete. Available next stages:\n",
33 stage.name
34 ),
35 };
36 for edge in edges {
37 p.push_str(&format!("- {}", edge.target));
38 if let Some(hint) = &edge.hint {
39 p.push_str(&format!(": {hint}"));
40 }
41 p.push('\n');
42 }
43 if stage.transition_prompt.is_some() {
44 if stage.allow_complete {
45 p.push_str(
46 "\nRespond with ONLY the stage name you want to transition to, or ONLY the \
47 word DONE if no further stage is needed and the run should end here.",
48 );
49 } else {
50 p.push_str(
51 "\nRespond with ONLY the stage name you want to transition to, nothing else.",
52 );
53 }
54 } else if stage.allow_complete {
55 p.push_str(
56 "\nWhich stage should run next? Respond with ONLY the stage name, or ONLY the \
57 word DONE if no further stage is needed and the run should end here.",
58 );
59 } else {
60 p.push_str("\nWhich stage should run next? Respond with ONLY the stage name.");
61 }
62 p
63}
64
65pub(crate) fn match_transition_choice(
77 choice: &str,
78 edges: &[leviath_core::blueprint::TransitionEdge],
79 allow_complete: bool,
80) -> Option<String> {
81 let lines: Vec<&str> = choice
82 .lines()
83 .map(str::trim)
84 .filter(|l| !l.is_empty())
85 .collect();
86 let words_in = |line: &str| {
92 line.split(|c: char| !c.is_alphanumeric() && c != '_')
93 .filter(|w| !w.is_empty())
94 .count()
95 };
96 let first = lines.first().copied();
97 let last = lines
98 .last()
99 .copied()
100 .filter(|l| lines.len() > 1 && words_in(l) <= 3);
101 for line in first.into_iter().chain(last) {
102 for word in line.split(|c: char| !c.is_alphanumeric() && c != '_') {
103 if word.is_empty() {
104 continue;
105 }
106 if allow_complete && word.eq_ignore_ascii_case("done") {
107 return None;
108 }
109 if let Some(edge) = edges.iter().find(|e| word.eq_ignore_ascii_case(&e.target)) {
110 return Some(edge.target.clone());
111 }
112 }
113 }
114 if allow_complete {
117 None
118 } else {
119 edges.first().map(|edge| edge.target.clone())
120 }
121}
122
123type TransitionChoiceQuery = (
128 Entity,
129 &'static AgentState,
130 &'static mut ContextWindow,
131 &'static StageInference,
132 &'static AgentBlueprint,
133 &'static StageCursor,
134 &'static AwaitingTransitionChoice,
135 Option<&'static InFlightWork>,
136 Option<&'static DispatchStall>,
137);
138
139pub fn dispatch_transition_choice(
146 mut agents: Query<TransitionChoiceQuery, With<AwaitingTransitionChoice>>,
147 stage: Res<InferenceStage>,
148 providers: Res<Providers>,
149 mut commands: Commands,
150) {
151 crate::tick_scope::clear();
152 let now = chrono::Utc::now().timestamp();
153 for (entity, state, mut window, si, bp, cursor, choice, in_flight, stalled) in agents.iter_mut()
154 {
155 crate::tick_scope::enter(entity);
156 if state.status != AgentStatus::Active {
157 continue; }
159 let Some(provider) = providers.0.get(&si.provider_name) else {
163 commands
164 .entity(entity)
165 .insert(note_stall(stalled, StallReason::ProviderMissing, now));
166 continue; };
168 let Some(permit) = stage.pools.try_acquire(&si.model) else {
169 commands
170 .entity(entity)
171 .insert(note_stall(stalled, StallReason::PoolFull, now));
172 continue; };
174
175 let current = &bp.0.stages[cursor.index];
176 let prompt = build_transition_prompt(current, &choice.0);
177 let tokens = leviath_core::estimate_tokens(&prompt);
178 let _ = window.add_typed_entry(
179 "conversation",
180 leviath_core::EntryKind::UserMessage,
181 prompt,
182 tokens,
183 );
184
185 let assembled = window.assemble();
190 let remaining = window.max_tokens.saturating_sub(window.current_tokens);
191 let request = InferenceRequest {
192 system: assembled.system_blocks,
193 messages: assembled.messages,
194 model: si.model.clone(),
195 max_tokens: remaining.min(256), temperature: 0.0, tools: Vec::new(),
198 extra: serde_json::Value::Null,
199 request_timeout_secs: None,
200 };
201
202 let job = InferenceJob {
203 entity,
204 provider,
205 request,
206 permit,
207 exact_token_counting: false,
210 };
211 let cancel = crate::cancel::CancelToken::new();
212 let lost_outcomes = stage.transition_outcomes.clone();
216 let lost_wake = stage.wake.clone();
217 crate::lane_supervisor::spawn_supervised(
218 &stage.runtime,
219 "transition-choice",
220 run_inference_job(
221 job,
222 stage.transition_outcomes.clone(),
223 stage.wake.clone(),
224 crate::inference_bridge::RetryPolicy::default(),
225 cancel.clone(),
226 ),
227 move |message| {
228 let _ = lost_outcomes.send(crate::inference_bridge::InferenceOutcome {
229 entity,
230 result: Err(leviath_providers::ProviderError::Other(message)),
231 latency: std::time::Duration::ZERO,
232 });
233 lost_wake.notify_one();
234 },
235 );
236 track_in_flight(&mut commands, entity, in_flight, cancel);
237 commands
238 .entity(entity)
239 .remove::<AwaitingTransitionChoice>()
240 .remove::<DispatchStall>()
241 .insert(AwaitingTransitionResponse(choice.0.clone()));
242 }
243}
244
245type CollectTransitionChoiceQuery = (
250 &'static AgentBlueprint,
251 &'static mut StageCursor,
252 &'static mut AgentState,
253 &'static mut StageProgress,
254 &'static StageInferences,
255 &'static StageSetups,
256 &'static mut VisitCounts,
257 &'static mut ContextWindow,
258 &'static AwaitingTransitionResponse,
259 Option<&'static mut crate::persistence::RunOutcomeFlags>,
260 Option<&'static crate::persistence::RunMetadata>,
261);
262
263pub fn collect_transition_choice(
268 mut results: ResMut<TransitionResults>,
269 mut agents: Query<CollectTransitionChoiceQuery>,
270 sink: Option<Res<crate::host::WorldEventSink>>,
271 mut commands: Commands,
272) {
273 crate::tick_scope::clear();
274 while let Ok(outcome) = results.0.try_recv() {
275 let Ok((
276 bp,
277 mut cursor,
278 mut state,
279 mut progress,
280 stage_infs,
281 setups,
282 mut visits,
283 mut window,
284 resp,
285 mut flags,
286 metadata,
287 )) = agents.get_mut(outcome.entity)
288 else {
289 continue; };
291 crate::tick_scope::enter(outcome.entity);
292 if is_terminal_status(&state.status) {
296 commands
297 .entity(outcome.entity)
298 .remove::<AwaitingTransitionResponse>()
299 .remove::<InFlightWork>();
300 continue;
301 }
302 let response = match outcome.result {
303 Ok(response) => response,
304 Err(err) => {
305 state.status = AgentStatus::Error {
306 message: err.to_string(),
307 };
308 commands
309 .entity(outcome.entity)
310 .remove::<AwaitingTransitionResponse>();
311 continue;
312 }
313 };
314
315 let choice = response.content.trim().to_string();
316 let tokens = leviath_core::estimate_tokens(&choice);
317 let _ = window.add_typed_entry(
318 "conversation",
319 leviath_core::EntryKind::AssistantTurn { tool_calls: vec![] },
320 format!("Transitioning to: {choice}"),
321 tokens,
322 );
323
324 let allow_complete = bp.0.stages[cursor.index].allow_complete;
325 match match_transition_choice(&choice, &resp.0, allow_complete) {
326 Some(target) => {
327 let idx =
328 bp.0.stages
329 .iter()
330 .position(|s| s.name == target)
331 .unwrap_or(0);
332 let edge = resp.0.iter().find(|e| e.target == target);
335 let transform = edge.map(|e| e.transform.clone()).unwrap_or_default();
336 let stage = &bp.0.stages[cursor.index];
339 match gate_blocks(
340 edge.and_then(|e| e.gate.as_ref()),
341 stage,
342 &progress,
343 &window,
344 ) {
345 GateDecision::Block(nudge) => {
346 hold_for_gate(
347 outcome.entity,
348 &nudge,
349 &mut progress,
350 &mut window,
351 &mut commands,
352 );
353 continue;
354 }
355 GateDecision::Forced => {
356 if let Some(flags) = flags.as_mut() {
357 flags.0.gates_forced += 1;
358 }
359 }
360 GateDecision::Pass => {}
361 }
362 let to_compact = apply_edge_transform(&mut window, &transform);
363 let setup = &setups.0[idx];
364 let from = state.current_stage.clone();
365 match enter_stage(
366 idx,
367 &bp.0,
368 setup,
369 StageEntry {
370 cursor: &mut cursor,
371 state: &mut state,
372 progress: &mut progress,
373 visits: &mut visits,
374 window: &mut window,
375 },
376 ) {
377 Ok(visit) => {
378 let name = bp.0.stages[idx].name.clone();
386 emit_stage_transition(&sink, metadata, &state.agent_id, from, &name, visit);
387 let mut ec = commands.entity(outcome.entity);
388 ec.remove::<AwaitingTransitionResponse>();
389 attach_stage_components(ec, stage_infs.0[idx].clone(), setup, idx, name);
390 if !to_compact.is_empty() {
391 commands
392 .entity(outcome.entity)
393 .insert(PendingEdgeCompact(to_compact));
394 }
395 }
396 Err(message) => {
397 state.status = AgentStatus::Error { message };
398 commands
399 .entity(outcome.entity)
400 .remove::<AwaitingTransitionResponse>();
401 }
402 }
403 }
404 None => {
405 state.status = AgentStatus::Complete;
406 commands
407 .entity(outcome.entity)
408 .remove::<AwaitingTransitionResponse>();
409 }
410 }
411 }
412}