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