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/// What `collect_inference` selects.
38///
39/// `&'static` is bevy's `WorldQuery` convention, not a claim about
40/// lifetimes: the borrow is bound when the query is fetched.
41type InferenceQuery = (
42    &'static mut AgentState,
43    Option<&'static crate::persistence::RunMetadata>,
44    Option<&'static mut crate::persistence::TokenTotals>,
45    Option<&'static StageCursor>,
46    Option<&'static ContextWindow>,
47    Option<&'static mut StageLedger>,
48    Option<&'static mut StageIoBuffer>,
49    Option<&'static mut StageInference>,
50    Option<&'static mut crate::telemetry::StageActivity>,
51);
52
53/// Inference-collect system: drain completed inferences and apply them. A
54/// success is stored on the agent (bumping its iteration) and the agent advances
55/// to `ProcessResponse`; an error marks the agent `Error`. An outcome for an
56/// agent that is no longer `AwaitingInference` (cancelled or despawned between
57/// dispatch and now) is dropped.
58pub fn collect_inference(
59    mut results: ResMut<InferenceResults>,
60    mut agents: Query<InferenceQuery, With<AwaitingInference>>,
61    mut circuits: Option<ResMut<ProviderCircuits>>,
62    policy: Option<Res<CircuitPolicy>>,
63    persist: Option<Res<crate::pipeline::persist::PersistenceStage>>,
64    mut commands: Commands,
65) {
66    crate::tick_scope::clear();
67    let policy = policy.map(|p| *p).unwrap_or_default();
68    let now = chrono::Utc::now().timestamp();
69    while let Ok(outcome) = results.0.try_recv() {
70        let Ok((
71            mut state,
72            md,
73            mut totals,
74            cursor,
75            window,
76            mut ledger,
77            buffer,
78            mut inference,
79            activity,
80        )) = agents.get_mut(outcome.entity)
81        else {
82            continue; // stale: agent cancelled/despawned since dispatch
83        };
84        crate::tick_scope::enter(outcome.entity);
85        // The agent reached a terminal state while this inference was in flight
86        // (a cancel, or a panic that failed it). Drop the response: applying it
87        // would move the run on to `ProcessResponse` and it would keep going.
88        if is_terminal_status(&state.status) {
89            commands
90                .entity(outcome.entity)
91                .remove::<AwaitingInference>()
92                .remove::<InFlightWork>();
93            continue;
94        }
95        let idx = cursor.map_or(0, |c| c.index);
96        // Whoever we actually called. Read before the error arm below, which
97        // may swap the component over to the next provider.
98        let (called_provider, called_model) = inference
99            .as_deref()
100            .map(|i| (i.provider_name.clone(), i.model.clone()))
101            .unwrap_or_default();
102        // Record the call for the telemetry observer while the provider and
103        // timing are still at hand (the observer only sees components).
104        if let Some(mut activity) = activity {
105            let usage = outcome.result.as_ref().ok().map(|r| &r.tokens_used);
106            activity
107                .0
108                .push(crate::telemetry::ActivityRecord::Inference {
109                    provider: called_provider.clone(),
110                    model: called_model.clone(),
111                    latency_ms: u64::try_from(outcome.latency.as_millis()).unwrap_or(u64::MAX),
112                    prompt_tokens: usage.map_or(0, |u| u.prompt_tokens),
113                    completion_tokens: usage.map_or(0, |u| u.completion_tokens),
114                    cached_tokens: usage.map_or(0, |u| u.cached_tokens),
115                    success: outcome.result.is_ok(),
116                });
117        }
118        // Breaker bookkeeping, before the arms below consume the outcome. Any
119        // answer at all proves the provider is serving; a provider-fatal one
120        // counts against it and may take it out of service for everyone.
121        if let Some(circuits) = circuits.as_deref_mut() {
122            match outcome
123                .result
124                .as_ref()
125                .err()
126                .and_then(|e| e.unavailable_reason())
127            {
128                Some(reason) => {
129                    if circuits.record_failure(&called_provider, reason, now, &policy) {
130                        // Loud and once, on the transition only. This is the
131                        // alert issue #201 asked for: without it, ten dead
132                        // runs in a row look like ten unrelated failures.
133                        tracing::error!(
134                            provider = %called_provider,
135                            reason = reason.label(),
136                            failures = policy.failures_before_open,
137                            cooldown_secs = policy.cooldown_secs,
138                            "provider circuit opened; no run will be dispatched to it \
139                             until it recovers"
140                        );
141                    }
142                }
143                None if outcome.result.is_ok() => circuits.record_success(&called_provider),
144                // An ordinary error says nothing about the provider either
145                // way, so it neither counts against it nor clears its record.
146                None => {}
147            }
148        }
149        match outcome.result {
150            Ok(response) => {
151                state.iteration += 1;
152                crate::inference_usage::record_call(
153                    totals.as_deref_mut(),
154                    persist.as_deref(),
155                    md,
156                    &crate::inference_usage::CallUsage {
157                        kind: leviath_core::run_archive::InferenceKind::Stage,
158                        stage: &state.current_stage,
159                        iteration: state.iteration,
160                        provider: &called_provider,
161                        model: &called_model,
162                        usage: &response.tokens_used,
163                    },
164                );
165                // Accrue this iteration's tokens against the current stage record.
166                if let Some(rec) = ledger.as_deref_mut().and_then(|l| l.0.get_mut(idx)) {
167                    rec.prompt_tokens += response.tokens_used.prompt_tokens;
168                    rec.completion_tokens += response.tokens_used.completion_tokens;
169                    rec.cached_tokens += response.tokens_used.cached_tokens;
170                    rec.cache_write_tokens += response.tokens_used.cache_write_tokens;
171                    // The high-water mark rather than a sum: a region is
172                    // re-sent whole on every call, so summing would report a
173                    // number that is neither what it costs per call nor what it
174                    // holds. The largest it reached is the one that says
175                    // whether it is earning its place.
176                    //
177                    // Every region the window carries, not only the ones this
178                    // stage assembles: a stage layout hides the regions it does
179                    // not declare rather than dropping them, and they are
180                    // recorded here all the same.
181                    for region in window.iter().flat_map(|w| w.regions.iter()) {
182                        let seen = rec.region_tokens.entry(region.name.clone()).or_insert(0);
183                        *seen = (*seen).max(region.current_tokens);
184                    }
185                    warn_if_context_is_running_away(rec, response.tokens_used.prompt_tokens);
186                }
187                // Buffer the readable output + a token line for the stage's logs.
188                if let Some(mut buffer) = buffer {
189                    if !response.content.trim().is_empty() {
190                        buffer.output.push((idx, response.content.clone()));
191                    }
192                    buffer.logs.push((
193                        idx,
194                        format!(
195                            "[Tokens: {} in, {} out]",
196                            response.tokens_used.prompt_tokens,
197                            response.tokens_used.completion_tokens
198                        ),
199                    ));
200                }
201                let result = to_inference_result(&response);
202                commands
203                    .entity(outcome.entity)
204                    .insert(result)
205                    .remove::<AwaitingInference>()
206                    .remove::<InFlightWork>()
207                    .insert(ProcessResponse);
208            }
209            Err(err) => {
210                // A provider that is out of credits or holding a rejected key
211                // is not this request's problem: every later request to it
212                // fails the same way. Move the stage to the next candidate and
213                // try again rather than killing the run (issue #201).
214                let next = err.unavailable_reason().and_then(|_| {
215                    let si = inference.as_deref_mut()?;
216                    (!si.fallbacks.is_empty()).then(|| si.fallbacks.remove(0))
217                });
218                if let Some(next) = next {
219                    // Loud on purpose. Silently swapping providers is how a
220                    // factory ends up running on a model nobody chose.
221                    tracing::warn!(
222                        from_provider = %called_provider,
223                        from_model = %called_model,
224                        to_provider = %next.provider,
225                        to_model = %next.model,
226                        error = %err,
227                        "provider unusable; failing over to the next configured model"
228                    );
229                    if let Some(mut buffer) = buffer {
230                        buffer.logs.push((
231                            idx,
232                            format!(
233                                "[failover] {called_provider}/{called_model} is unusable \
234                                 ({err}); retrying on {}/{}",
235                                next.provider, next.model
236                            ),
237                        ));
238                    }
239                    let si = inference
240                        .as_deref_mut()
241                        .expect("the failover branch only runs with a StageInference");
242                    si.provider_name = next.provider;
243                    si.model = next.model;
244                    // Back to ready, not errored: the next tick dispatches it
245                    // against the new provider and takes that model's permit.
246                    // The iteration is deliberately not bumped - the agent has
247                    // still not had a turn.
248                    commands
249                        .entity(outcome.entity)
250                        .remove::<AwaitingInference>()
251                        .remove::<InFlightWork>()
252                        .insert(ReadyToInfer);
253                    continue;
254                }
255                // Running out of credits with no candidate left is an account
256                // state, not a defect in the run: the operator tops up and
257                // resumes. Failing here would make the run permanently
258                // unresumable, so it pauses instead, still pointed at the same
259                // inference, and a `lev resume` re-dispatches it (issue #413).
260                // Unattended is the exception: a scheduler or a benchmark is
261                // watching for a terminal status and would wait for ever for
262                // one that never comes, so for those a failure is the honest
263                // answer.
264                let attended = !md.is_some_and(|m| m.unattended);
265                if attended
266                    && err.unavailable_reason()
267                        == Some(leviath_providers::UnavailableReason::CreditsExhausted)
268                {
269                    let message = format!(
270                        "out of credits ({err}): top up the account, then \
271                         `lev resume` this run"
272                    );
273                    tracing::warn!(error = %err, "out of credits; pausing the run for a resume");
274                    if let Some(mut buffer) = buffer {
275                        buffer.logs.push((idx, format!("[paused] {message}")));
276                    }
277                    state.status = AgentStatus::Paused;
278                    commands
279                        .entity(outcome.entity)
280                        .remove::<AwaitingInference>()
281                        .remove::<InFlightWork>()
282                        .insert(crate::pipeline::PausedForSetup {
283                            blocker: leviath_core::run_meta::SetupBlocker::CreditsExhausted,
284                            remedy: message,
285                        })
286                        .insert(ReadyToInfer);
287                    continue;
288                }
289                if let Some(mut buffer) = buffer {
290                    buffer.logs.push((idx, format!("[error] {err}")));
291                }
292                // Record the error and route it to the stage's transition logic
293                // (which follows an `error`-conditioned edge if the stage has one,
294                // e.g. → error_recovery, or terminates the run otherwise).
295                state.status = AgentStatus::Error {
296                    message: err.to_string(),
297                };
298                commands
299                    .entity(outcome.entity)
300                    .remove::<AwaitingInference>()
301                    .remove::<InFlightWork>()
302                    .insert(StageOutcome::Errored(err.to_string()))
303                    .insert(ResolveTransition);
304            }
305        }
306    }
307}
308
309/// The response had tool calls; the agent is ready for the tool-dispatch system
310/// to run them (the calls live on its `InferenceResult`).
311#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
312pub struct ReadyForTools;
313
314/// The response had no tool calls; the agent is ready for the empty-response
315/// handler to decide finish vs. a "use your tools" nudge.
316#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
317pub struct ReadyForTransition;
318
319/// The agent's current stage is complete; the transition system will resolve the
320/// next stage (or completion).
321#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
322pub struct ResolveTransition;
323
324/// How much bigger than its first call a stage's prompt may get before the run
325/// says so.
326///
327/// The runtime notices a stalled run and a stuck one; it noticed nothing about
328/// the failure that actually costs money - a region filling up and being
329/// re-sent on every call. Measured, a profile stage capped at 10 iterations
330/// billed 1,135,289 tokens, roughly 113k per call, because an uncapped read had
331/// filled its region. Nothing warned, and the run looked healthy from the
332/// outside until the bill arrived.
333///
334/// Four rather than two: a stage that reads a file and then works with it has
335/// genuinely grown, and warning about that would be noise. Four is past the
336/// point where growth is explained by ordinary accumulation.
337const RUNAWAY_CONTEXT_FACTOR: usize = 4;
338
339/// Say so when a stage's per-call prompt has grown past
340/// [`RUNAWAY_CONTEXT_FACTOR`] times its first call.
341///
342/// Once per stage, on the crossing. Repeating it every call afterwards would
343/// bury the run's other output in exactly the situation where that output
344/// matters.
345pub(crate) fn warn_if_context_is_running_away(
346    rec: &mut leviath_core::run_meta::StageRecord,
347    prompt_tokens: usize,
348) {
349    let first = match rec.first_call_prompt_tokens {
350        Some(first) => first,
351        None => {
352            rec.first_call_prompt_tokens = Some(prompt_tokens);
353            return;
354        }
355    };
356    if rec.runaway_warned || first == 0 || prompt_tokens < first * RUNAWAY_CONTEXT_FACTOR {
357        return;
358    }
359    rec.runaway_warned = true;
360    tracing::warn!(
361        stage = %rec.name,
362        first_call_prompt_tokens = first,
363        this_call_prompt_tokens = prompt_tokens,
364        "this stage's context has grown past {RUNAWAY_CONTEXT_FACTOR}x its first call and is \
365         re-sent on every call; check whether a region is accumulating without a cap \
366         (`lev stages <run-id>` shows the per-region sizes)"
367    );
368}
369
370/// Per-stage progress counters, reset when an agent enters a stage.
371#[derive(Component, Debug, Clone, Default)]
372pub struct StageProgress {
373    /// Total tool calls the agent has made in this stage.
374    pub total_tool_calls: usize,
375    /// Consecutive text-only responses that were nudged toward tool use.
376    pub text_only_nudges: usize,
377    /// Inferences run in this stage (per-stage, unlike the run-cumulative
378    /// `AgentState.iteration`), for enforcing the stage's `max_iterations`.
379    pub iterations: usize,
380    /// Successful file-modifying tool calls (`write_file`/`edit_file`, plus any
381    /// tool named by an outgoing gate) made in this stage. Read by the
382    /// transition gate to enforce `require_modifications`.
383    pub modifying_tool_calls: usize,
384    /// Modifying tool calls the permission layer refused (`[denied] ...`). A
385    /// gate lets the transition through when this is non-zero: the agent is
386    /// trying to write and cannot, so re-running the stage only burns budget.
387    pub blocked_modification_calls: usize,
388    /// Content digests of the regions this stage's outgoing gates watch, as
389    /// they stood when the stage was entered.
390    ///
391    /// Only the watched regions: hashing every region on every entry would
392    /// cost the whole window for a feature most stages do not use. Empty for a
393    /// stage with no `require_region_updated` gate, which is the common case.
394    pub entry_region_digests: std::collections::HashMap<String, u64>,
395    /// How many times a transition gate has already sent this stage back for
396    /// another pass. Bounded by the gate's `max_attempts`.
397    pub gate_reentries: usize,
398    /// Unix seconds of the first tick this agent was ready to infer in the
399    /// stage - the clock a `stuck_after_minutes` threshold reads. Stamped
400    /// lazily by [`detect_stuck_stage`] so spawn, `enter_stage` and
401    /// [`force_transition`] all get a fresh clock from the `Default` reset
402    /// without threading a clock through their signatures.
403    pub stage_started_at: Option<i64>,
404    /// `write_file`/`edit_file` calls made in this stage, keyed by target path.
405    /// Feeds the `stuck_after_same_file_edits` threshold.
406    pub edits_by_path: std::collections::HashMap<String, usize>,
407    /// A `stuck` edge has already fired in this stage. One-shot per stage entry:
408    /// without it a stuck interrupt whose edge became unavailable would ping-pong
409    /// between [`detect_stuck_stage`] and [`resolve_transition`]'s resume arm.
410    pub stuck_fired: bool,
411}
412
413/// How a stage ended, when that governs the transition. Absent ⇒ the stage
414/// completed normally. Read by [`resolve_transition`] to follow an
415/// `error`/`max_iterations`/`stuck`-conditioned edge (e.g. → error_recovery)
416/// when the stage errored, hit its iteration cap, or stopped making progress.
417#[derive(Component, Debug, Clone, PartialEq, Eq)]
418pub enum StageOutcome {
419    /// The stage errored (carries the error message for the terminal case).
420    Errored(String),
421    /// The stage hit its `max_iterations` cap.
422    MaxIterations,
423    /// A `stuck` edge tripped mid-stage; carries the human-readable reason.
424    Stuck(String),
425}
426
427/// One [`StageRecord`](leviath_core::run_meta::StageRecord) per blueprint stage,
428/// seeded at spawn (names + `Pending`) and reconciled by [`dispatch_persistence`]
429/// (status + timestamps), with per-stage tokens accrued by [`collect_inference`].
430/// Serialized to `stages.json` so the dashboard / serve API can show every
431/// stage's real name and status - not just the active one (whose name is the only
432/// one carried in `meta.json`).
433#[derive(Component, Debug, Clone)]
434pub struct StageLedger(pub Vec<leviath_core::run_meta::StageRecord>);
435
436/// Buffered per-stage output/log lines awaiting the persistence lane. Emitters
437/// ([`collect_inference`], [`collect_tools`]) push; [`dispatch_persistence`]
438/// drains and clears, forwarding the lines to `stages/<idx>/output.log` (readable
439/// assistant output) and `stages/<idx>/logs.log` (tool + token + error events).
440#[derive(Component, Debug, Clone, Default)]
441pub struct StageIoBuffer {
442    /// Readable assistant output lines, each tagged with its stage index.
443    pub output: Vec<(usize, String)>,
444    /// Operational log lines (tool activity, token counts, errors), each tagged
445    /// with its stage index.
446    pub logs: Vec<(usize, String)>,
447}
448
449/// What `process_response` selects.
450///
451/// `&'static` is bevy's `WorldQuery` convention, not a claim about
452/// lifetimes: the borrow is bound when the query is fetched.
453type ProcessResponseQuery = (
454    Entity,
455    &'static crate::components::InferenceResult,
456    &'static mut StageProgress,
457    Option<&'static mut crate::persistence::TokenTotals>,
458);
459
460/// Process-response system: route each `ProcessResponse` agent by whether its
461/// last inference asked for tools. Tool calls present ⇒ `ReadyForTools` (and the
462/// stage's running tool-call count is bumped); none ⇒ `ReadyForTransition`. Pure
463/// routing - no I/O.
464pub fn process_response(
465    mut agents: Query<ProcessResponseQuery, With<ProcessResponse>>,
466    mut commands: Commands,
467) {
468    crate::tick_scope::clear();
469    for (entity, result, mut progress, totals) in agents.iter_mut() {
470        crate::tick_scope::enter(entity);
471        progress.iterations += 1; // per-stage inference count (for max_iterations)
472        let mut e = commands.entity(entity);
473        e.remove::<ProcessResponse>();
474        if result.tool_calls.is_empty() {
475            e.insert(ReadyForTransition);
476        } else {
477            progress.total_tool_calls += result.tool_calls.len();
478            // Per-path edit churn, for `stuck` edges armed on same-file edits.
479            // Counted from the *requested* calls: a model asking to edit the
480            // same wrong file five times is stuck whether or not each call ran.
481            for path in result.tool_calls.iter().filter_map(edited_path) {
482                *progress.edits_by_path.entry(path.to_string()).or_insert(0) += 1;
483            }
484            if let Some(mut totals) = totals {
485                totals.tool_calls += result.tool_calls.len();
486            }
487            e.insert(ReadyForTools);
488        }
489    }
490}
491
492/// The path a tool call targets, for per-stage edit-churn tracking. Only the two
493/// mutating file tools count: both carry the path in their `path` argument. A
494/// call without a string `path` (or any other tool) contributes nothing.
495pub(crate) fn edited_path(call: &crate::components::ToolCall) -> Option<&str> {
496    matches!(call.name.as_str(), "write_file" | "edit_file")
497        .then(|| call.arguments.get("path").and_then(|v| v.as_str()))
498        .flatten()
499}
500
501/// The global config's `[nudge]` defaults, captured per agent at spawn time so
502/// a hot-reloaded config applies from the next run rather than mutating live
503/// ones (same snapshot semantics as the batch-tool-hint global). Absent on
504/// worlds that spawn agents without going through the seeded spawn (tests,
505/// embedders); [`leviath_core::resolve_nudge`] then falls through to the
506/// built-in defaults.
507#[derive(Component, Debug, Clone, Default)]
508pub struct GlobalNudge(pub leviath_core::NudgeConfig);
509
510/// Whether this stage's deliverable *is* its text response.
511///
512/// A stage with interaction points presents what it writes for the user to
513/// approve, revise or edit - the text is the work product, not a model stalling
514/// before it starts. Nudging one is worse than wasteful: the nudge says "use
515/// your tools to complete the task", and a stage built to produce a document
516/// usually has no tool that could. A planning stage told to complete the task
517/// went looking for a way to write the file, found none, and asked the user to
518/// grant it a write tool or create the file by hand - instead of ending the
519/// stage and presenting the plan it had already finished writing.
520pub(crate) fn stage_output_is_reviewed(bp: &AgentBlueprint, cursor: &StageCursor) -> bool {
521    matches!(
522        bp.0.stages.get(cursor.index).map(|s| &s.mode),
523        Some(leviath_core::blueprint::StageMode::InteractivePoints { points }) if !points.is_empty()
524    )
525}
526
527/// What `handle_empty_response` selects.
528///
529/// `&'static` is bevy's `WorldQuery` convention, not a claim about
530/// lifetimes: the borrow is bound when the query is fetched.
531type EmptyResponseQuery = (
532    Entity,
533    &'static mut ContextWindow,
534    &'static crate::components::InferenceResult,
535    &'static mut StageProgress,
536    &'static AgentBlueprint,
537    &'static StageCursor,
538    Option<&'static GlobalNudge>,
539);
540
541/// Empty-response system: for each `ReadyForTransition` agent decide whether the
542/// stage is done. If the agent has already made tool calls, its nudge is
543/// disabled, or it has been nudged its budgeted number of times, the text
544/// response is accepted and the agent advances to `ResolveTransition`.
545/// Otherwise (text only, no work yet) the response + the stage's nudge are
546/// added to context and the agent loops back to `ReadyToInfer`. Ported from
547/// `AgentEngine::loop_handle_empty_tool_calls`.
548///
549/// The nudge is programmable per stage (`[stages.<name>.nudge]`), per agent
550/// (`[agent.nudge]`), and globally (config `[nudge]`), each field cascading
551/// independently through [`leviath_core::resolve_nudge`]. With nothing
552/// configured, a stage whose output is reviewed is never nudged - see
553/// `stage_output_is_reviewed` - but an explicit `enabled` at any level speaks
554/// for itself. The text supports `{stage}` and `{regions}` placeholders.
555pub fn handle_empty_response(
556    mut agents: Query<EmptyResponseQuery, With<ReadyForTransition>>,
557    mut commands: Commands,
558) {
559    crate::tick_scope::clear();
560    for (entity, mut window, infer, mut progress, bp, cursor, global) in agents.iter_mut() {
561        crate::tick_scope::enter(entity);
562        let stage = bp.0.stages.get(cursor.index);
563        let nudge = leviath_core::resolve_nudge(
564            global.map(|g| &g.0),
565            bp.0.nudge.as_ref(),
566            stage.and_then(|s| s.nudge.as_ref()),
567            stage_output_is_reviewed(bp, cursor),
568        );
569        if progress.total_tool_calls > 0 || !nudge.enabled || progress.text_only_nudges >= nudge.max
570        {
571            commands
572                .entity(entity)
573                .remove::<ReadyForTransition>()
574                .insert(ResolveTransition);
575        } else {
576            progress.text_only_nudges += 1;
577            let response_tokens = leviath_core::estimate_tokens(&infer.response);
578            let _ = window.add_typed_entry(
579                "conversation",
580                leviath_core::EntryKind::AssistantTurn { tool_calls: vec![] },
581                infer.response.clone(),
582                response_tokens,
583            );
584            let stage_name = stage.map(|s| s.name.as_str()).unwrap_or("");
585            let regions = stage
586                .and_then(|s| s.context_layout.as_ref())
587                .unwrap_or(&bp.0.context_layout)
588                .regions
589                .iter()
590                .filter(|r| r.required)
591                .map(|r| r.name.as_str())
592                .collect::<Vec<_>>()
593                .join(", ");
594            let text = leviath_core::text::interpolate(
595                &nudge.text,
596                &[("stage", stage_name), ("regions", &regions)],
597            );
598            inject_system_nudge(&mut window, &text);
599            commands
600                .entity(entity)
601                .remove::<ReadyForTransition>()
602                .insert(ReadyToInfer);
603        }
604    }
605}
606
607/// Append a `[System]` nudge to the conversation region: the one injection path
608/// shared by the empty-response nudge, the required-region nudges, and the
609/// transition-gate hold, so every nudge reaches the model with the same shape.
610/// (An unprefixed `Text` entry assembles as a user message, so the prefix is
611/// what distinguishes framework guidance from real user input.)
612pub(crate) fn inject_system_nudge(window: &mut ContextWindow, text: &str) {
613    let content = format!("[System] {text}");
614    let tokens = leviath_core::estimate_tokens(&content);
615    let _ = window.add_to_region("conversation", content, tokens);
616}