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<&mut StageInference>,
53            Option<&mut crate::telemetry::StageActivity>,
54        ),
55        With<AwaitingInference>,
56    >,
57    mut circuits: Option<ResMut<ProviderCircuits>>,
58    policy: Option<Res<CircuitPolicy>>,
59    mut commands: Commands,
60) {
61    crate::tick_scope::clear();
62    let policy = policy.map(|p| *p).unwrap_or_default();
63    let now = chrono::Utc::now().timestamp();
64    while let Ok(outcome) = results.0.try_recv() {
65        let Ok((mut state, totals, cursor, mut ledger, buffer, mut inference, activity)) =
66            agents.get_mut(outcome.entity)
67        else {
68            continue; // stale: agent cancelled/despawned since dispatch
69        };
70        crate::tick_scope::enter(outcome.entity);
71        // The agent reached a terminal state while this inference was in flight
72        // (a cancel, or a panic that failed it). Drop the response: applying it
73        // would move the run on to `ProcessResponse` and it would keep going.
74        if is_terminal_status(&state.status) {
75            commands
76                .entity(outcome.entity)
77                .remove::<AwaitingInference>()
78                .remove::<InFlightWork>();
79            continue;
80        }
81        let idx = cursor.map_or(0, |c| c.index);
82        // Whoever we actually called. Read before the error arm below, which
83        // may swap the component over to the next provider.
84        let (called_provider, called_model) = inference
85            .as_deref()
86            .map(|i| (i.provider_name.clone(), i.model.clone()))
87            .unwrap_or_default();
88        // Record the call for the telemetry observer while the provider and
89        // timing are still at hand (the observer only sees components).
90        if let Some(mut activity) = activity {
91            let usage = outcome.result.as_ref().ok().map(|r| &r.tokens_used);
92            activity
93                .0
94                .push(crate::telemetry::ActivityRecord::Inference {
95                    provider: called_provider.clone(),
96                    model: called_model.clone(),
97                    latency_ms: u64::try_from(outcome.latency.as_millis()).unwrap_or(u64::MAX),
98                    prompt_tokens: usage.map_or(0, |u| u.prompt_tokens),
99                    completion_tokens: usage.map_or(0, |u| u.completion_tokens),
100                    cached_tokens: usage.map_or(0, |u| u.cached_tokens),
101                    success: outcome.result.is_ok(),
102                });
103        }
104        // Breaker bookkeeping, before the arms below consume the outcome. Any
105        // answer at all proves the provider is serving; a provider-fatal one
106        // counts against it and may take it out of service for everyone.
107        if let Some(circuits) = circuits.as_deref_mut() {
108            match outcome
109                .result
110                .as_ref()
111                .err()
112                .and_then(|e| e.unavailable_reason())
113            {
114                Some(reason) => {
115                    if circuits.record_failure(&called_provider, reason, now, &policy) {
116                        // Loud and once, on the transition only. This is the
117                        // alert issue #201 asked for: without it, ten dead
118                        // runs in a row look like ten unrelated failures.
119                        tracing::error!(
120                            provider = %called_provider,
121                            reason = reason.label(),
122                            failures = policy.failures_before_open,
123                            cooldown_secs = policy.cooldown_secs,
124                            "provider circuit opened; no run will be dispatched to it \
125                             until it recovers"
126                        );
127                    }
128                }
129                None if outcome.result.is_ok() => circuits.record_success(&called_provider),
130                // An ordinary error says nothing about the provider either
131                // way, so it neither counts against it nor clears its record.
132                None => {}
133            }
134        }
135        match outcome.result {
136            Ok(response) => {
137                state.iteration += 1;
138                if let Some(mut totals) = totals {
139                    totals.add_usage(&response.tokens_used);
140                }
141                // Accrue this iteration's tokens against the current stage record.
142                if let Some(rec) = ledger.as_deref_mut().and_then(|l| l.0.get_mut(idx)) {
143                    rec.prompt_tokens += response.tokens_used.prompt_tokens;
144                    rec.completion_tokens += response.tokens_used.completion_tokens;
145                    rec.cached_tokens += response.tokens_used.cached_tokens;
146                }
147                // Buffer the readable output + a token line for the stage's logs.
148                if let Some(mut buffer) = buffer {
149                    if !response.content.trim().is_empty() {
150                        buffer.output.push((idx, response.content.clone()));
151                    }
152                    buffer.logs.push((
153                        idx,
154                        format!(
155                            "[Tokens: {} in, {} out]",
156                            response.tokens_used.prompt_tokens,
157                            response.tokens_used.completion_tokens
158                        ),
159                    ));
160                }
161                let result = to_inference_result(&response);
162                commands
163                    .entity(outcome.entity)
164                    .insert(result)
165                    .remove::<AwaitingInference>()
166                    .remove::<InFlightWork>()
167                    .insert(ProcessResponse);
168            }
169            Err(err) => {
170                // A provider that is out of credits or holding a rejected key
171                // is not this request's problem: every later request to it
172                // fails the same way. Move the stage to the next candidate and
173                // try again rather than killing the run (issue #201).
174                let next = err.unavailable_reason().and_then(|_| {
175                    let si = inference.as_deref_mut()?;
176                    (!si.fallbacks.is_empty()).then(|| si.fallbacks.remove(0))
177                });
178                if let Some(next) = next {
179                    // Loud on purpose. Silently swapping providers is how a
180                    // factory ends up running on a model nobody chose.
181                    tracing::warn!(
182                        from_provider = %called_provider,
183                        from_model = %called_model,
184                        to_provider = %next.provider,
185                        to_model = %next.model,
186                        error = %err,
187                        "provider unusable; failing over to the next configured model"
188                    );
189                    if let Some(mut buffer) = buffer {
190                        buffer.logs.push((
191                            idx,
192                            format!(
193                                "[failover] {called_provider}/{called_model} is unusable \
194                                 ({err}); retrying on {}/{}",
195                                next.provider, next.model
196                            ),
197                        ));
198                    }
199                    let si = inference
200                        .as_deref_mut()
201                        .expect("the failover branch only runs with a StageInference");
202                    si.provider_name = next.provider;
203                    si.model = next.model;
204                    // Back to ready, not errored: the next tick dispatches it
205                    // against the new provider and takes that model's permit.
206                    // The iteration is deliberately not bumped - the agent has
207                    // still not had a turn.
208                    commands
209                        .entity(outcome.entity)
210                        .remove::<AwaitingInference>()
211                        .remove::<InFlightWork>()
212                        .insert(ReadyToInfer);
213                    continue;
214                }
215                if let Some(mut buffer) = buffer {
216                    buffer.logs.push((idx, format!("[error] {err}")));
217                }
218                // Record the error and route it to the stage's transition logic
219                // (which follows an `error`-conditioned edge if the stage has one,
220                // e.g. → error_recovery, or terminates the run otherwise).
221                state.status = AgentStatus::Error {
222                    message: err.to_string(),
223                };
224                commands
225                    .entity(outcome.entity)
226                    .remove::<AwaitingInference>()
227                    .remove::<InFlightWork>()
228                    .insert(StageOutcome::Errored(err.to_string()))
229                    .insert(ResolveTransition);
230            }
231        }
232    }
233}
234
235/// The response had tool calls; the agent is ready for the tool-dispatch system
236/// to run them (the calls live on its `InferenceResult`).
237#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
238pub struct ReadyForTools;
239
240/// The response had no tool calls; the agent is ready for the empty-response
241/// handler to decide finish vs. a "use your tools" nudge.
242#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
243pub struct ReadyForTransition;
244
245/// The agent's current stage is complete; the transition system will resolve the
246/// next stage (or completion).
247#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
248pub struct ResolveTransition;
249
250/// Per-stage progress counters, reset when an agent enters a stage.
251#[derive(Component, Debug, Clone, Default)]
252pub struct StageProgress {
253    /// Total tool calls the agent has made in this stage.
254    pub total_tool_calls: usize,
255    /// Consecutive text-only responses that were nudged toward tool use.
256    pub text_only_nudges: usize,
257    /// Inferences run in this stage (per-stage, unlike the run-cumulative
258    /// `AgentState.iteration`), for enforcing the stage's `max_iterations`.
259    pub iterations: usize,
260    /// Successful file-modifying tool calls (`write_file`/`edit_file`, plus any
261    /// tool named by an outgoing gate) made in this stage. Read by the
262    /// transition gate to enforce `require_modifications`.
263    pub modifying_tool_calls: usize,
264    /// Modifying tool calls the permission layer refused (`[denied] ...`). A
265    /// gate lets the transition through when this is non-zero: the agent is
266    /// trying to write and cannot, so re-running the stage only burns budget.
267    pub blocked_modification_calls: usize,
268    /// How many times a transition gate has already sent this stage back for
269    /// another pass. Bounded by the gate's `max_attempts`.
270    pub gate_reentries: usize,
271    /// Unix seconds of the first tick this agent was ready to infer in the
272    /// stage - the clock a `stuck_after_minutes` threshold reads. Stamped
273    /// lazily by [`detect_stuck_stage`] so spawn, `enter_stage` and
274    /// [`force_transition`] all get a fresh clock from the `Default` reset
275    /// without threading a clock through their signatures.
276    pub stage_started_at: Option<i64>,
277    /// `write_file`/`edit_file` calls made in this stage, keyed by target path.
278    /// Feeds the `stuck_after_same_file_edits` threshold.
279    pub edits_by_path: std::collections::HashMap<String, usize>,
280    /// A `stuck` edge has already fired in this stage. One-shot per stage entry:
281    /// without it a stuck interrupt whose edge became unavailable would ping-pong
282    /// between [`detect_stuck_stage`] and [`resolve_transition`]'s resume arm.
283    pub stuck_fired: bool,
284}
285
286/// How a stage ended, when that governs the transition. Absent ⇒ the stage
287/// completed normally. Read by [`resolve_transition`] to follow an
288/// `error`/`max_iterations`/`stuck`-conditioned edge (e.g. → error_recovery)
289/// when the stage errored, hit its iteration cap, or stopped making progress.
290#[derive(Component, Debug, Clone, PartialEq, Eq)]
291pub enum StageOutcome {
292    /// The stage errored (carries the error message for the terminal case).
293    Errored(String),
294    /// The stage hit its `max_iterations` cap.
295    MaxIterations,
296    /// A `stuck` edge tripped mid-stage; carries the human-readable reason.
297    Stuck(String),
298}
299
300/// One [`StageRecord`](leviath_core::run_meta::StageRecord) per blueprint stage,
301/// seeded at spawn (names + `Pending`) and reconciled by [`dispatch_persistence`]
302/// (status + timestamps), with per-stage tokens accrued by [`collect_inference`].
303/// Serialized to `stages.json` so the dashboard / serve API can show every
304/// stage's real name and status - not just the active one (whose name is the only
305/// one carried in `meta.json`).
306#[derive(Component, Debug, Clone)]
307pub struct StageLedger(pub Vec<leviath_core::run_meta::StageRecord>);
308
309/// Buffered per-stage output/log lines awaiting the persistence lane. Emitters
310/// ([`collect_inference`], [`collect_tools`]) push; [`dispatch_persistence`]
311/// drains and clears, forwarding the lines to `stages/<idx>/output.log` (readable
312/// assistant output) and `stages/<idx>/logs.log` (tool + token + error events).
313#[derive(Component, Debug, Clone, Default)]
314pub struct StageIoBuffer {
315    /// Readable assistant output lines, each tagged with its stage index.
316    pub output: Vec<(usize, String)>,
317    /// Operational log lines (tool activity, token counts, errors), each tagged
318    /// with its stage index.
319    pub logs: Vec<(usize, String)>,
320}
321
322/// Process-response system: route each `ProcessResponse` agent by whether its
323/// last inference asked for tools. Tool calls present ⇒ `ReadyForTools` (and the
324/// stage's running tool-call count is bumped); none ⇒ `ReadyForTransition`. Pure
325/// routing - no I/O.
326#[allow(clippy::type_complexity)]
327pub fn process_response(
328    mut agents: Query<
329        (
330            Entity,
331            &crate::components::InferenceResult,
332            &mut StageProgress,
333            Option<&mut crate::persistence::TokenTotals>,
334        ),
335        With<ProcessResponse>,
336    >,
337    mut commands: Commands,
338) {
339    crate::tick_scope::clear();
340    for (entity, result, mut progress, totals) in agents.iter_mut() {
341        crate::tick_scope::enter(entity);
342        progress.iterations += 1; // per-stage inference count (for max_iterations)
343        let mut e = commands.entity(entity);
344        e.remove::<ProcessResponse>();
345        if result.tool_calls.is_empty() {
346            e.insert(ReadyForTransition);
347        } else {
348            progress.total_tool_calls += result.tool_calls.len();
349            // Per-path edit churn, for `stuck` edges armed on same-file edits.
350            // Counted from the *requested* calls: a model asking to edit the
351            // same wrong file five times is stuck whether or not each call ran.
352            for path in result.tool_calls.iter().filter_map(edited_path) {
353                *progress.edits_by_path.entry(path.to_string()).or_insert(0) += 1;
354            }
355            if let Some(mut totals) = totals {
356                totals.tool_calls += result.tool_calls.len();
357            }
358            e.insert(ReadyForTools);
359        }
360    }
361}
362
363/// The path a tool call targets, for per-stage edit-churn tracking. Only the two
364/// mutating file tools count: both carry the path in their `path` argument. A
365/// call without a string `path` (or any other tool) contributes nothing.
366pub(crate) fn edited_path(call: &crate::components::ToolCall) -> Option<&str> {
367    matches!(call.name.as_str(), "write_file" | "edit_file")
368        .then(|| call.arguments.get("path").and_then(|v| v.as_str()))
369        .flatten()
370}
371
372/// The global config's `[nudge]` defaults, captured per agent at spawn time so
373/// a hot-reloaded config applies from the next run rather than mutating live
374/// ones (same snapshot semantics as the batch-tool-hint global). Absent on
375/// worlds that spawn agents without going through the seeded spawn (tests,
376/// embedders); [`leviath_core::resolve_nudge`] then falls through to the
377/// built-in defaults.
378#[derive(Component, Debug, Clone, Default)]
379pub struct GlobalNudge(pub leviath_core::NudgeConfig);
380
381/// Whether this stage's deliverable *is* its text response.
382///
383/// A stage with interaction points presents what it writes for the user to
384/// approve, revise or edit - the text is the work product, not a model stalling
385/// before it starts. Nudging one is worse than wasteful: the nudge says "use
386/// your tools to complete the task", and a stage built to produce a document
387/// usually has no tool that could. A planning stage told to complete the task
388/// went looking for a way to write the file, found none, and asked the user to
389/// grant it a write tool or create the file by hand - instead of ending the
390/// stage and presenting the plan it had already finished writing.
391pub(crate) fn stage_output_is_reviewed(bp: &AgentBlueprint, cursor: &StageCursor) -> bool {
392    matches!(
393        bp.0.stages.get(cursor.index).map(|s| &s.mode),
394        Some(leviath_core::blueprint::StageMode::InteractivePoints { points }) if !points.is_empty()
395    )
396}
397
398/// Empty-response system: for each `ReadyForTransition` agent decide whether the
399/// stage is done. If the agent has already made tool calls, its nudge is
400/// disabled, or it has been nudged its budgeted number of times, the text
401/// response is accepted and the agent advances to `ResolveTransition`.
402/// Otherwise (text only, no work yet) the response + the stage's nudge are
403/// added to context and the agent loops back to `ReadyToInfer`. Ported from
404/// `AgentEngine::loop_handle_empty_tool_calls`.
405///
406/// The nudge is programmable per stage (`[stages.<name>.nudge]`), per agent
407/// (`[agent.nudge]`), and globally (config `[nudge]`), each field cascading
408/// independently through [`leviath_core::resolve_nudge`]. With nothing
409/// configured, a stage whose output is reviewed is never nudged - see
410/// `stage_output_is_reviewed` - but an explicit `enabled` at any level speaks
411/// for itself. The text supports `{stage}` and `{regions}` placeholders.
412#[allow(clippy::type_complexity)]
413pub fn handle_empty_response(
414    mut agents: Query<
415        (
416            Entity,
417            &mut ContextWindow,
418            &crate::components::InferenceResult,
419            &mut StageProgress,
420            &AgentBlueprint,
421            &StageCursor,
422            Option<&GlobalNudge>,
423        ),
424        With<ReadyForTransition>,
425    >,
426    mut commands: Commands,
427) {
428    crate::tick_scope::clear();
429    for (entity, mut window, infer, mut progress, bp, cursor, global) in agents.iter_mut() {
430        crate::tick_scope::enter(entity);
431        let stage = bp.0.stages.get(cursor.index);
432        let nudge = leviath_core::resolve_nudge(
433            global.map(|g| &g.0),
434            bp.0.nudge.as_ref(),
435            stage.and_then(|s| s.nudge.as_ref()),
436            stage_output_is_reviewed(bp, cursor),
437        );
438        if progress.total_tool_calls > 0 || !nudge.enabled || progress.text_only_nudges >= nudge.max
439        {
440            commands
441                .entity(entity)
442                .remove::<ReadyForTransition>()
443                .insert(ResolveTransition);
444        } else {
445            progress.text_only_nudges += 1;
446            let response_tokens = leviath_core::estimate_tokens(&infer.response);
447            let _ = window.add_typed_entry(
448                "conversation",
449                leviath_core::EntryKind::AssistantTurn { tool_calls: vec![] },
450                infer.response.clone(),
451                response_tokens,
452            );
453            let stage_name = stage.map(|s| s.name.as_str()).unwrap_or("");
454            let regions = stage
455                .and_then(|s| s.context_layout.as_ref())
456                .unwrap_or(&bp.0.context_layout)
457                .regions
458                .iter()
459                .filter(|r| r.required)
460                .map(|r| r.name.as_str())
461                .collect::<Vec<_>>()
462                .join(", ");
463            let text = leviath_core::text::interpolate(
464                &nudge.text,
465                &[("stage", stage_name), ("regions", &regions)],
466            );
467            inject_system_nudge(&mut window, &text);
468            commands
469                .entity(entity)
470                .remove::<ReadyForTransition>()
471                .insert(ReadyToInfer);
472        }
473    }
474}
475
476/// Append a `[System]` nudge to the conversation region: the one injection path
477/// shared by the empty-response nudge, the required-region nudges, and the
478/// transition-gate hold, so every nudge reaches the model with the same shape.
479/// (An unprefixed `Text` entry assembles as a user message, so the prefix is
480/// what distinguishes framework guidance from real user input.)
481pub(crate) fn inject_system_nudge(window: &mut ContextWindow, text: &str) {
482    let content = format!("[System] {text}");
483    let tokens = leviath_core::estimate_tokens(&content);
484    let _ = window.add_to_region("conversation", content, tokens);
485}