Skip to main content

leviath_runtime/pipeline/
transition_choice.rs

1//! The LLM-chosen transition: build the prompt that asks which stage runs
2//! next, dispatch it as an inference, and match the answer back to one of the
3//! edges that were offered.
4
5use super::*;
6
7/// A transition-choice inference is in flight (an LLM is picking the next stage);
8/// holds the choosable edges so the collect system can match the response back to
9/// one. (Ported from the async portion of `graph::prompt_llm_transition`.)
10#[derive(Component, Debug, Clone)]
11pub struct AwaitingTransitionResponse(pub Vec<leviath_core::blueprint::TransitionEdge>);
12
13/// The receiving end of the transition-choice outcomes channel, as a world
14/// resource for the collect system. (The sending end lives in
15/// [`InferenceStage::transition_outcomes`].)
16#[derive(Resource)]
17pub struct TransitionResults(pub UnboundedReceiver<InferenceOutcome>);
18
19/// Build the LLM prompt that asks which stage to run next. (Ported from the
20/// prompt-building portion of `graph::prompt_llm_transition`.)
21pub(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
65/// Match an LLM transition response to one of the choosable edges' target stages,
66/// or `None` if the stage may complete and the LLM chose to end here.
67///
68/// Models are asked to answer with only the target stage name (or `DONE`), but
69/// frequently wrap it in prose or re-explain the stage. We therefore look for a
70/// clean, standalone decision - scanning the first line, then the concluding
71/// line, for a **whole-word** match against a stage name or `DONE` - instead of
72/// substring-scanning the whole response, where a stage name mentioned in
73/// passing ("the implementation", "the approved plan") would hijack the routing.
74/// When nothing matches, a stage that may complete ends the run; otherwise the
75/// run advances along the first declared edge.
76pub(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    // Candidate decision lines, in priority order: the first line (the model was
87    // told to reply with only the name, so the answer leads), then - only if it
88    // is short and answer-like (≤ 3 words) - the concluding line, which catches
89    // models that reason first and answer last without matching a stage name
90    // buried in a prose summary ("the approved plan was implemented").
91    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    // No clear decision: a stage that may end prefers ending over looping back;
115    // otherwise the run advances along the first declared edge.
116    if allow_complete {
117        None
118    } else {
119        edges.first().map(|edge| edge.target.clone())
120    }
121}
122
123/// What `dispatch_transition_choice` selects.
124///
125/// `&'static` is bevy's `WorldQuery` convention, not a claim about
126/// lifetimes: the borrow is bound when the query is fetched.
127type 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
139/// Transition-choice dispatch: for each `AwaitingTransitionChoice` agent, inject
140/// the "which stage next?" prompt into its context, build a short deterministic
141/// request, acquire a per-model permit, spawn the inference onto the transition
142/// lane, and move it to `AwaitingTransitionResponse`. Provider-missing / pool-full
143/// leaves it choosing and retries next tick (same backpressure as
144/// [`dispatch_inference`]).
145pub 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; // paused / waiting / cancelled - don't start new work
158        }
159        // Same bookkeeping as the inference lane: an agent parked here is
160        // runnable with nothing outstanding, so a decline that never resolves
161        // wedges the run just as thoroughly (issue #190).
162        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; // provider not registered - retry later
167        };
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; // pool full - retry next tick
173        };
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        // Plain `assemble()` (default meta): this is the deterministic
186        // 256-token routing call, not stage inference - custom regions still
187        // render (they may hold the whole context), just with empty stage
188        // fields in their ctx.
189        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), // short routing response
196            temperature: 0.0,               // deterministic routing
197            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            // Routing responses are tiny (≤256 tokens) and always fit; skip the
208            // extra count call for them.
209            exact_token_counting: false,
210        };
211        let cancel = crate::cancel::CancelToken::new();
212        // Supervised for the same reason as the inference lane: the agent is
213        // about to wait on `AwaitingTransitionResponse`, so a job that dies
214        // without reporting would strand it mid-route.
215        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
245/// What `collect_transition_choice` selects.
246///
247/// `&'static` is bevy's `WorldQuery` convention, not a claim about
248/// lifetimes: the borrow is bound when the query is fetched.
249type 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
263/// Transition-choice collect: drain completed routing inferences, match each to a
264/// target stage (or completion), record the decision in context, and either enter
265/// the chosen stage (loop to `ReadyToInfer`) or mark the agent `Complete`. A
266/// provider error marks the agent `Error`.
267pub 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; // stale: agent cancelled/despawned since dispatch
290        };
291        crate::tick_scope::enter(outcome.entity);
292        // Cancelled/failed mid-choice: every arm below rewrites the status
293        // (including a bare `Complete` when nothing matches), which would report
294        // a cancelled run as having finished normally.
295        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                // The chosen edge (absent when the matched target has no explicit
333                // edge, e.g. a fallback - then Direct, ungated).
334                let edge = resp.0.iter().find(|e| e.target == target);
335                let transform = edge.map(|e| e.transform.clone()).unwrap_or_default();
336                // The edge's gate is checked BEFORE its transform runs, so a
337                // held stage keeps the context it still needs.
338                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                        // No `status = Active` here, unlike the same sequence in
379                        // `resolve_transition`. That reset exists to clear an
380                        // error status when recovering down an `error` edge, and
381                        // this path cannot be carrying one: `StageResolution`
382                        // only yields `Choose` from the branch that ran with no
383                        // stage outcome, so an errored stage routes to `Next`
384                        // and never reaches an LLM choice.
385                        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}