Skip to main content

leviath_runtime/pipeline/
response.rs

1//! Response collection and stage-progress accounting.
2
3use super::*;
4
5/// The response has been applied and is ready to be examined for tool calls (or
6/// completion) by the process-response system.
7#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
8pub struct ProcessResponse;
9
10/// The receiving end of the inference-outcomes channel, as a world resource for
11/// the collect system. (The sending end lives in [`InferenceStage`].)
12#[derive(Resource)]
13pub struct InferenceResults(pub UnboundedReceiver<InferenceOutcome>);
14
15/// Convert a provider response into the stored `InferenceResult` component.
16/// (Ported from `AgentEngine::apply_inference_response`.)
17pub(crate) fn to_inference_result(
18    response: &leviath_providers::InferenceResponse,
19) -> crate::components::InferenceResult {
20    crate::components::InferenceResult {
21        response: response.content.clone(),
22        tool_calls: response
23            .tool_calls
24            .iter()
25            .map(|tc| crate::components::ToolCall {
26                tool_id: tc.id.clone(),
27                name: tc.name.clone(),
28                arguments: tc.arguments.clone(),
29                thought_signature: tc.thought_signature.clone(),
30            })
31            .collect(),
32        tokens_used: response.tokens_used.total_tokens,
33        timestamp: chrono::Utc::now().timestamp(),
34    }
35}
36
37/// Inference-collect system: drain completed inferences and apply them. A
38/// success is stored on the agent (bumping its iteration) and the agent advances
39/// to `ProcessResponse`; an error marks the agent `Error`. An outcome for an
40/// agent that is no longer `AwaitingInference` (cancelled or despawned between
41/// dispatch and now) is dropped.
42#[allow(clippy::type_complexity)]
43pub fn collect_inference(
44    mut results: ResMut<InferenceResults>,
45    mut agents: Query<
46        (
47            &mut AgentState,
48            Option<&mut crate::persistence::TokenTotals>,
49            Option<&StageCursor>,
50            Option<&mut StageLedger>,
51            Option<&mut StageIoBuffer>,
52            Option<&StageInference>,
53            Option<&mut crate::telemetry::StageActivity>,
54        ),
55        With<AwaitingInference>,
56    >,
57    mut commands: Commands,
58) {
59    crate::tick_scope::clear();
60    while let Ok(outcome) = results.0.try_recv() {
61        let Ok((mut state, totals, cursor, mut ledger, buffer, inference, activity)) =
62            agents.get_mut(outcome.entity)
63        else {
64            continue; // stale: agent cancelled/despawned since dispatch
65        };
66        crate::tick_scope::enter(outcome.entity);
67        // The agent reached a terminal state while this inference was in flight
68        // (a cancel, or a panic that failed it). Drop the response: applying it
69        // would move the run on to `ProcessResponse` and it would keep going.
70        if is_terminal_status(&state.status) {
71            commands
72                .entity(outcome.entity)
73                .remove::<AwaitingInference>()
74                .remove::<InFlightWork>();
75            continue;
76        }
77        let idx = cursor.map_or(0, |c| c.index);
78        // Record the call for the telemetry observer while the provider and
79        // timing are still at hand (the observer only sees components).
80        if let Some(mut activity) = activity {
81            let usage = outcome.result.as_ref().ok().map(|r| &r.tokens_used);
82            activity
83                .0
84                .push(crate::telemetry::ActivityRecord::Inference {
85                    provider: inference
86                        .map(|i| i.provider_name.clone())
87                        .unwrap_or_default(),
88                    model: inference.map(|i| i.model.clone()).unwrap_or_default(),
89                    latency_ms: u64::try_from(outcome.latency.as_millis()).unwrap_or(u64::MAX),
90                    prompt_tokens: usage.map_or(0, |u| u.prompt_tokens),
91                    completion_tokens: usage.map_or(0, |u| u.completion_tokens),
92                    cached_tokens: usage.map_or(0, |u| u.cached_tokens),
93                    success: outcome.result.is_ok(),
94                });
95        }
96        match outcome.result {
97            Ok(response) => {
98                state.iteration += 1;
99                if let Some(mut totals) = totals {
100                    totals.add_usage(&response.tokens_used);
101                }
102                // Accrue this iteration's tokens against the current stage record.
103                if let Some(rec) = ledger.as_deref_mut().and_then(|l| l.0.get_mut(idx)) {
104                    rec.prompt_tokens += response.tokens_used.prompt_tokens;
105                    rec.completion_tokens += response.tokens_used.completion_tokens;
106                    rec.cached_tokens += response.tokens_used.cached_tokens;
107                }
108                // Buffer the readable output + a token line for the stage's logs.
109                if let Some(mut buffer) = buffer {
110                    if !response.content.trim().is_empty() {
111                        buffer.output.push((idx, response.content.clone()));
112                    }
113                    buffer.logs.push((
114                        idx,
115                        format!(
116                            "[Tokens: {} in, {} out]",
117                            response.tokens_used.prompt_tokens,
118                            response.tokens_used.completion_tokens
119                        ),
120                    ));
121                }
122                let result = to_inference_result(&response);
123                commands
124                    .entity(outcome.entity)
125                    .insert(result)
126                    .remove::<AwaitingInference>()
127                    .remove::<InFlightWork>()
128                    .insert(ProcessResponse);
129            }
130            Err(err) => {
131                if let Some(mut buffer) = buffer {
132                    buffer.logs.push((idx, format!("[error] {err}")));
133                }
134                // Record the error and route it to the stage's transition logic
135                // (which follows an `error`-conditioned edge if the stage has one,
136                // e.g. → error_recovery, or terminates the run otherwise).
137                state.status = AgentStatus::Error {
138                    message: err.to_string(),
139                };
140                commands
141                    .entity(outcome.entity)
142                    .remove::<AwaitingInference>()
143                    .remove::<InFlightWork>()
144                    .insert(StageOutcome::Errored(err.to_string()))
145                    .insert(ResolveTransition);
146            }
147        }
148    }
149}
150
151/// The response had tool calls; the agent is ready for the tool-dispatch system
152/// to run them (the calls live on its `InferenceResult`).
153#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
154pub struct ReadyForTools;
155
156/// The response had no tool calls; the agent is ready for the empty-response
157/// handler to decide finish vs. a "use your tools" nudge.
158#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
159pub struct ReadyForTransition;
160
161/// The agent's current stage is complete; the transition system will resolve the
162/// next stage (or completion).
163#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
164pub struct ResolveTransition;
165
166/// Per-stage progress counters, reset when an agent enters a stage.
167#[derive(Component, Debug, Clone, Default)]
168pub struct StageProgress {
169    /// Total tool calls the agent has made in this stage.
170    pub total_tool_calls: usize,
171    /// Consecutive text-only responses that were nudged toward tool use.
172    pub text_only_nudges: usize,
173    /// Inferences run in this stage (per-stage, unlike the run-cumulative
174    /// `AgentState.iteration`), for enforcing the stage's `max_iterations`.
175    pub iterations: usize,
176    /// Successful file-modifying tool calls (`write_file`/`edit_file`, plus any
177    /// tool named by an outgoing gate) made in this stage. Read by the
178    /// transition gate to enforce `require_modifications`.
179    pub modifying_tool_calls: usize,
180    /// Modifying tool calls the permission layer refused (`[denied] ...`). A
181    /// gate lets the transition through when this is non-zero: the agent is
182    /// trying to write and cannot, so re-running the stage only burns budget.
183    pub blocked_modification_calls: usize,
184    /// How many times a transition gate has already sent this stage back for
185    /// another pass. Bounded by the gate's `max_attempts`.
186    pub gate_reentries: usize,
187    /// Unix seconds of the first tick this agent was ready to infer in the
188    /// stage - the clock a `stuck_after_minutes` threshold reads. Stamped
189    /// lazily by [`detect_stuck_stage`] so spawn, `enter_stage` and
190    /// [`force_transition`] all get a fresh clock from the `Default` reset
191    /// without threading a clock through their signatures.
192    pub stage_started_at: Option<i64>,
193    /// `write_file`/`edit_file` calls made in this stage, keyed by target path.
194    /// Feeds the `stuck_after_same_file_edits` threshold.
195    pub edits_by_path: std::collections::HashMap<String, usize>,
196    /// A `stuck` edge has already fired in this stage. One-shot per stage entry:
197    /// without it a stuck interrupt whose edge became unavailable would ping-pong
198    /// between [`detect_stuck_stage`] and [`resolve_transition`]'s resume arm.
199    pub stuck_fired: bool,
200}
201
202/// How a stage ended, when that governs the transition. Absent ⇒ the stage
203/// completed normally. Read by [`resolve_transition`] to follow an
204/// `error`/`max_iterations`/`stuck`-conditioned edge (e.g. → error_recovery)
205/// when the stage errored, hit its iteration cap, or stopped making progress.
206#[derive(Component, Debug, Clone, PartialEq, Eq)]
207pub enum StageOutcome {
208    /// The stage errored (carries the error message for the terminal case).
209    Errored(String),
210    /// The stage hit its `max_iterations` cap.
211    MaxIterations,
212    /// A `stuck` edge tripped mid-stage; carries the human-readable reason.
213    Stuck(String),
214}
215
216/// One [`StageRecord`](leviath_core::run_meta::StageRecord) per blueprint stage,
217/// seeded at spawn (names + `Pending`) and reconciled by [`dispatch_persistence`]
218/// (status + timestamps), with per-stage tokens accrued by [`collect_inference`].
219/// Serialized to `stages.json` so the dashboard / serve API can show every
220/// stage's real name and status - not just the active one (whose name is the only
221/// one carried in `meta.json`).
222#[derive(Component, Debug, Clone)]
223pub struct StageLedger(pub Vec<leviath_core::run_meta::StageRecord>);
224
225/// Buffered per-stage output/log lines awaiting the persistence lane. Emitters
226/// ([`collect_inference`], [`collect_tools`]) push; [`dispatch_persistence`]
227/// drains and clears, forwarding the lines to `stages/<idx>/output.log` (readable
228/// assistant output) and `stages/<idx>/logs.log` (tool + token + error events).
229#[derive(Component, Debug, Clone, Default)]
230pub struct StageIoBuffer {
231    /// Readable assistant output lines, each tagged with its stage index.
232    pub output: Vec<(usize, String)>,
233    /// Operational log lines (tool activity, token counts, errors), each tagged
234    /// with its stage index.
235    pub logs: Vec<(usize, String)>,
236}
237
238/// Process-response system: route each `ProcessResponse` agent by whether its
239/// last inference asked for tools. Tool calls present ⇒ `ReadyForTools` (and the
240/// stage's running tool-call count is bumped); none ⇒ `ReadyForTransition`. Pure
241/// routing - no I/O.
242#[allow(clippy::type_complexity)]
243pub fn process_response(
244    mut agents: Query<
245        (
246            Entity,
247            &crate::components::InferenceResult,
248            &mut StageProgress,
249            Option<&mut crate::persistence::TokenTotals>,
250        ),
251        With<ProcessResponse>,
252    >,
253    mut commands: Commands,
254) {
255    crate::tick_scope::clear();
256    for (entity, result, mut progress, totals) in agents.iter_mut() {
257        crate::tick_scope::enter(entity);
258        progress.iterations += 1; // per-stage inference count (for max_iterations)
259        let mut e = commands.entity(entity);
260        e.remove::<ProcessResponse>();
261        if result.tool_calls.is_empty() {
262            e.insert(ReadyForTransition);
263        } else {
264            progress.total_tool_calls += result.tool_calls.len();
265            // Per-path edit churn, for `stuck` edges armed on same-file edits.
266            // Counted from the *requested* calls: a model asking to edit the
267            // same wrong file five times is stuck whether or not each call ran.
268            for path in result.tool_calls.iter().filter_map(edited_path) {
269                *progress.edits_by_path.entry(path.to_string()).or_insert(0) += 1;
270            }
271            if let Some(mut totals) = totals {
272                totals.tool_calls += result.tool_calls.len();
273            }
274            e.insert(ReadyForTools);
275        }
276    }
277}
278
279/// The path a tool call targets, for per-stage edit-churn tracking. Only the two
280/// mutating file tools count: both carry the path in their `path` argument. A
281/// call without a string `path` (or any other tool) contributes nothing.
282pub(crate) fn edited_path(call: &crate::components::ToolCall) -> Option<&str> {
283    matches!(call.name.as_str(), "write_file" | "edit_file")
284        .then(|| call.arguments.get("path").and_then(|v| v.as_str()))
285        .flatten()
286}
287
288/// The "use your tools" nudge injected when a model responds with text before
289/// making any tool call.
290pub(crate) const NUDGE_TEXT: &str = "You have tools available. Please use them to complete the task. Start by reading the relevant files in the working directory.";
291/// How many text-only responses to nudge before accepting the text as final.
292pub(crate) const MAX_TEXT_ONLY_NUDGES: usize = 3;
293
294/// Whether this stage's deliverable *is* its text response.
295///
296/// A stage with interaction points presents what it writes for the user to
297/// approve, revise or edit - the text is the work product, not a model stalling
298/// before it starts. Nudging one is worse than wasteful: the nudge says "use
299/// your tools to complete the task", and a stage built to produce a document
300/// usually has no tool that could. A planning stage told to complete the task
301/// went looking for a way to write the file, found none, and asked the user to
302/// grant it a write tool or create the file by hand - instead of ending the
303/// stage and presenting the plan it had already finished writing.
304pub(crate) fn stage_output_is_reviewed(bp: &AgentBlueprint, cursor: &StageCursor) -> bool {
305    matches!(
306        bp.0.stages.get(cursor.index).map(|s| &s.mode),
307        Some(leviath_core::blueprint::StageMode::InteractivePoints { points }) if !points.is_empty()
308    )
309}
310
311/// Empty-response system: for each `ReadyForTransition` agent decide whether the
312/// stage is done. If the agent has already made tool calls, or we've nudged the
313/// max number of times, the text response is accepted and the agent advances to
314/// `ResolveTransition`. Otherwise (text only, no work yet) the response + a
315/// "use your tools" nudge are added to context and the agent loops back to
316/// `ReadyToInfer`. Ported from `AgentEngine::loop_handle_empty_tool_calls`.
317///
318/// A stage whose output is reviewed is never nudged - see
319/// `stage_output_is_reviewed`.
320#[allow(clippy::type_complexity)]
321pub fn handle_empty_response(
322    mut agents: Query<
323        (
324            Entity,
325            &mut ContextWindow,
326            &crate::components::InferenceResult,
327            &mut StageProgress,
328            &AgentBlueprint,
329            &StageCursor,
330        ),
331        With<ReadyForTransition>,
332    >,
333    mut commands: Commands,
334) {
335    crate::tick_scope::clear();
336    for (entity, mut window, infer, mut progress, bp, cursor) in agents.iter_mut() {
337        crate::tick_scope::enter(entity);
338        if progress.total_tool_calls > 0
339            || progress.text_only_nudges >= MAX_TEXT_ONLY_NUDGES
340            || stage_output_is_reviewed(bp, cursor)
341        {
342            commands
343                .entity(entity)
344                .remove::<ReadyForTransition>()
345                .insert(ResolveTransition);
346        } else {
347            progress.text_only_nudges += 1;
348            let response_tokens = leviath_core::estimate_tokens(&infer.response);
349            let _ = window.add_typed_entry(
350                "conversation",
351                leviath_core::EntryKind::AssistantTurn { tool_calls: vec![] },
352                infer.response.clone(),
353                response_tokens,
354            );
355            let nudge_tokens = leviath_core::estimate_tokens(NUDGE_TEXT);
356            let _ = window.add_typed_entry(
357                "conversation",
358                leviath_core::EntryKind::UserMessage,
359                NUDGE_TEXT.to_string(),
360                nudge_tokens,
361            );
362            commands
363                .entity(entity)
364                .remove::<ReadyForTransition>()
365                .insert(ReadyToInfer);
366        }
367    }
368}