leviath_runtime/pipeline/inference.rs
1//! Inference dispatch: building each ready agent's request and handing it to the async lane.
2
3use super::*;
4
5/// The batch-tool-calls hint, prepended to a stage's system blocks when
6/// `InferenceConfig::batch_tool_hint` is set. Identical across every agent,
7/// stage, and run, so it is a stable cache prefix (`CacheHint::Always`). It tells
8/// the model it may emit several `tool_use` blocks per response and should batch
9/// *independent* operations - while explicitly forbidding batching of dependent
10/// ones.
11pub(crate) const BATCH_TOOL_HINT: &str = "You can call multiple tools in a single response. \
12When operations are independent (reading, editing, or writing different files, or \
13writing a file then running a command that doesn't need its output), batch them in \
14one response to cut round trips. Do NOT batch when a call depends on a previous \
15call's result, or when you must see a command's output before deciding the next step.";
16
17/// What the `shell` tool actually runs on Windows, and the PowerShell commands
18/// that stand in for the POSIX ones a model reaches for by reflex. Prepended to
19/// a shell-granting stage's system blocks when [`shell_guidance_for`] returns
20/// it; see [`InferenceConfig::shell_hint`](crate::components::InferenceConfig).
21pub(crate) const WINDOWS_SHELL_HINT: &str = "The shell tool runs on Windows through `cmd.exe /C`, \
22not a POSIX shell. GNU coreutils are not available: use `type` or PowerShell's `Get-Content` \
23instead of `cat`, `findstr` or `Select-String` instead of `grep`, `dir` or `Get-ChildItem` \
24instead of `ls`, and `Measure-Object -Line` instead of `wc -l`. Run a PowerShell command as \
25`powershell -Command \"...\"`. Paths use backslashes and drive letters, and `%VAR%` (cmd) or \
26`$env:VAR` (PowerShell) expands environment variables.";
27
28/// The shell guidance for `os`, or `None` when the platform's shell needs no
29/// explanation (a POSIX shell is what the model already assumes).
30///
31/// Pure over the OS string rather than `#[cfg]`-switched, following
32/// `leviath_sys::browser::open_command_for`, so every branch is reachable under
33/// test on a single platform. Callers pass [`std::env::consts::OS`].
34pub(crate) fn shell_guidance_for(os: &str) -> Option<&'static str> {
35 match os {
36 "windows" => Some(WINDOWS_SHELL_HINT),
37 _ => None,
38 }
39}
40
41/// The framework-authored system blocks a stage carries ahead of its own
42/// context, in the order they are prepended.
43///
44/// Both hints read the same on every agent, stage, and run of a given host, so
45/// they lead the `Always`-tier prefix (which `assemble` already sorts first) and
46/// leave prefix caching intact. `os` is the host OS string
47/// ([`std::env::consts::OS`] in production) and `tools` the stage's advertised
48/// tools: telling a stage that cannot run commands which shell it would have
49/// gotten is pure overhead, so the shell hint is gated on the tool being there.
50///
51/// Note this is a `build_request` concern, so the request paths that assemble
52/// their own [`InferenceRequest`] - `lev test`, title generation, compaction -
53/// carry no hints. That was already true of the batch hint.
54pub(crate) fn hint_blocks(
55 config: Option<&InferenceConfig>,
56 tools: &[Tool],
57 os: &str,
58) -> Vec<leviath_providers::SystemBlock> {
59 let always = |text: &str| leviath_providers::SystemBlock {
60 text: text.to_string(),
61 cache_hint: leviath_core::CacheHint::Always,
62 };
63 let mut blocks = Vec::new();
64 if config.map(|c| c.batch_tool_hint).unwrap_or(false) {
65 blocks.push(always(BATCH_TOOL_HINT));
66 }
67 if config.map(|c| c.shell_hint).unwrap_or(false)
68 && tools.iter().any(|t| t.name == "shell")
69 && let Some(text) = shell_guidance_for(os)
70 {
71 blocks.push(always(text));
72 }
73 blocks
74}
75
76/// Build the [`InferenceRequest`] for an agent from its context window + stage
77/// data. Pure; no `.await` - a custom region's render hook is a bounded,
78/// synchronous Rhai eval. (Ported from `AgentEngine::build_inference_request`,
79/// with provider resolution lifted into the caller so this stays query-friendly.)
80///
81/// `stage_name` / `stage_iterations` feed custom-region `render(ctx)` hooks;
82/// they change nothing when the window has no custom regions.
83pub(crate) fn build_request(
84 window: &ContextWindow,
85 config: Option<&InferenceConfig>,
86 stage: &StageInference,
87 provider: &Arc<dyn Provider>,
88 stage_name: &str,
89 stage_iterations: usize,
90 previous_system_hash: Option<u64>,
91) -> (InferenceRequest, u64) {
92 let assembled = window.assemble_with_meta(&crate::custom_region::AssembleMeta {
93 stage_name: stage_name.to_string(),
94 stage_iterations,
95 model: stage.model.clone(),
96 previous_system_hash,
97 });
98 let system_hash = assembled.system_hash;
99 let remaining = window.max_tokens.saturating_sub(window.current_tokens);
100 let caps = provider.capabilities(&stage.model);
101 let output_cap = config
102 .and_then(|c| c.max_output_tokens)
103 .unwrap_or(caps.max_output_tokens);
104 let max_tokens = remaining.min(output_cap);
105
106 let filtered_tools = match stage.tool_filter.as_deref() {
107 Some(filter) if !filter.is_empty() => stage
108 .tools
109 .iter()
110 .filter(|t| filter.iter().any(|f| f == &t.name))
111 .cloned()
112 .collect(),
113 _ => stage.tools.clone(),
114 };
115
116 let temperature = if caps.supports_temperature {
117 config.and_then(|c| c.temperature).unwrap_or(0.7)
118 } else {
119 0.0
120 };
121
122 // Pass through any extra model parameters (top_p, stop, seed, …) so the
123 // provider can apply them; `Null` when there are none.
124 let extra = match config.map(|c| &c.extra_params) {
125 Some(params) if !params.is_empty() => serde_json::Value::Object(params.clone()),
126 _ => serde_json::Value::Null,
127 };
128
129 let mut system = hint_blocks(config, &filtered_tools, std::env::consts::OS);
130 system.extend(assembled.system_blocks);
131
132 let request = InferenceRequest {
133 system,
134 messages: assembled.messages,
135 model: stage.model.clone(),
136 max_tokens,
137 temperature,
138 tools: filtered_tools,
139 extra,
140 request_timeout_secs: config.and_then(|c| c.request_timeout_secs),
141 };
142 (request, system_hash)
143}
144
145/// Build the [`RetryPolicy`] for a job from the operator's `[limits]` retry
146/// schedule, applying a stage's per-stage inference wall-clock cap when
147/// configured.
148///
149/// `tuning` carries the two configurable numbers (`[limits]
150/// inference_retry_attempts` and `inference_retry_base_ms`); everything else -
151/// the capacity schedule and the total-backoff ceiling - comes from the default
152/// policy. When the stage set `request_timeout_secs` (from
153/// `[stages.<name>.model]`) that overrides `job_timeout`; otherwise the default
154/// job timeout stands. Pure so both branches are unit-testable without driving
155/// the ECS dispatch.
156pub(crate) fn retry_policy_for(
157 config: Option<&InferenceConfig>,
158 tuning: InferenceRetryTuning,
159) -> crate::inference_bridge::RetryPolicy {
160 let mut policy = crate::inference_bridge::RetryPolicy {
161 max_attempts: tuning.max_attempts,
162 base_delay: std::time::Duration::from_millis(tuning.base_delay_ms),
163 ..crate::inference_bridge::RetryPolicy::default()
164 };
165 if let Some(secs) = config.and_then(|c| c.request_timeout_secs) {
166 policy.job_timeout = std::time::Duration::from_secs(secs);
167 }
168 policy
169}
170
171/// The cancellation handles for an agent's currently in-flight async work (its
172/// inference request, its tool batch). Attached when the work is dispatched,
173/// removed when it lands - so the presence of this component means "there is
174/// something running for this agent that a cancel needs to stop".
175///
176/// Without it, cancelling only stopped *new* work from being dispatched: a
177/// request already handed to the async lanes ran to completion, holding its
178/// inference-pool permit or tool-lane capacity the whole time.
179#[derive(Component, Default, Debug)]
180pub struct InFlightWork(pub Vec<crate::cancel::CancelToken>);
181
182/// Stop the in-flight work of every agent that has reached a terminal state, and
183/// drop the handles. Runs before the dispatch systems each tick, so a cancel
184/// takes effect on the very next tick rather than whenever the provider or tool
185/// happens to answer.
186pub fn abort_terminal_work(
187 agents: Query<(Entity, &AgentState, &InFlightWork)>,
188 mut commands: Commands,
189) {
190 crate::tick_scope::clear();
191 for (entity, state, in_flight) in agents.iter() {
192 if !is_terminal_status(&state.status) {
193 continue;
194 }
195 crate::tick_scope::enter(entity);
196 for token in &in_flight.0 {
197 token.cancel();
198 }
199 commands.entity(entity).remove::<InFlightWork>();
200 }
201}
202
203/// Record `token` as in-flight work for `entity`, keeping any already attached
204/// (an agent can have both a tool batch and an inference outstanding across a
205/// tick boundary).
206pub(crate) fn track_in_flight(
207 commands: &mut Commands,
208 entity: Entity,
209 existing: Option<&InFlightWork>,
210 token: crate::cancel::CancelToken,
211) {
212 let mut tokens = existing.map(|w| w.0.clone()).unwrap_or_default();
213 tokens.push(token);
214 commands.entity(entity).insert(InFlightWork(tokens));
215}
216
217/// What `dispatch_inference` selects.
218///
219/// `&'static` is bevy's `WorldQuery` convention, not a claim about
220/// lifetimes: the borrow is bound when the query is fetched.
221type InferenceQuery = (
222 Entity,
223 &'static AgentState,
224 &'static ContextWindow,
225 Option<&'static InferenceConfig>,
226 &'static StageInference,
227 Option<&'static InFlightWork>,
228 Option<&'static StageProgress>,
229 Option<&'static DispatchStall>,
230 Option<&'static SystemPrefixHash>,
231);
232
233/// The system prefix the last request sent, as a digest.
234///
235/// Kept per agent because that is the granularity Anthropic's prefix cache
236/// works at: one run's blocks, in one order. Absent before the first request,
237/// which is exactly when there is nothing to invalidate.
238#[derive(bevy_ecs::component::Component, Debug, Clone, Copy)]
239pub struct SystemPrefixHash(pub u64);
240
241/// Inference-dispatch system: for every `ReadyToInfer` agent, resolve its
242/// provider and, **if a per-model permit is free**, build the request, spawn the
243/// inference job, and move it to `AwaitingInference`. If its provider is missing
244/// or no slot is free, it stays `ReadyToInfer` and is retried on a later tick -
245/// no blocking, no wasted task.
246pub fn dispatch_inference(
247 agents: Query<InferenceQuery, With<ReadyToInfer>>,
248 stage: Res<InferenceStage>,
249 providers: Res<Providers>,
250 circuits: Option<Res<ProviderCircuits>>,
251 policy: Option<Res<CircuitPolicy>>,
252 retry: Option<Res<InferenceRetryTuning>>,
253 par_commands: ParallelCommands,
254) {
255 // Fan out across ready agents: request assembly (`build_request`) is the
256 // per-agent CPU cost and is independent, so it runs in parallel on the
257 // compute pool. Permit acquisition (an atomic semaphore) and the tokio spawn
258 // are thread-safe; the marker swap is batched via `ParallelCommands`.
259 //
260 // This is the one system whose per-agent body runs off the driver thread, so
261 // the thread-local `tick_scope` can't carry an entity back to the catcher.
262 // Each agent's share runs under `run_agent_parallel`, which catches there -
263 // where the entity is known - and marks that agent for `tick` to fail
264 // (issue #109). Clearing the thread-local keeps a panic in the fan-out
265 // machinery *itself* unattributed rather than blamed on whichever agent a
266 // previous system left recorded.
267 crate::tick_scope::clear();
268 let now = chrono::Utc::now().timestamp();
269 let circuit_policy = policy.map(|p| *p).unwrap_or_default();
270 // The daemon inserts this from `[limits]`; a world that never set it (every
271 // embedded host, and most tests) gets the built-in schedule.
272 let retry_tuning = retry.map(|r| *r).unwrap_or_default();
273 let circuits = circuits.as_deref();
274 agents.par_iter().for_each(
275 |(entity, state, window, config, si, in_flight, progress, stalled, prefix)| {
276 crate::tick_scope::run_agent_parallel(entity, &par_commands, &mut || {
277 if state.status != AgentStatus::Active {
278 return; // paused / waiting / cancelled - don't start new work
279 }
280 // Every decline below records why and since when, so the
281 // watchdog can tell a run that is waiting from one that is
282 // waiting for something that will never happen (issue #190).
283 let stall = |reason| {
284 let noted = note_stall(stalled, reason, now);
285 par_commands.command_scope(|mut commands| {
286 commands.entity(entity).insert(noted);
287 });
288 };
289 // The rotation system already moved this agent onto the best
290 // provider still standing. Reaching a tripped one here means
291 // every candidate is out of service, so park rather than send
292 // a request that is going to fail the same way as the last
293 // three (issue #201). The stall watchdog ends the wait.
294 if circuits.is_some_and(|c| c.is_open(&si.provider_name, now, &circuit_policy)) {
295 tracing::debug!(
296 provider = %si.provider_name,
297 "inference waiting: the provider's circuit is open"
298 );
299 stall(StallReason::ProviderCircuitOpen);
300 return;
301 }
302 let Some(provider) = providers.0.get(&si.provider_name) else {
303 // Leave ready and retry later - but say so. A silently
304 // starved agent reads as a wedged run with no error.
305 tracing::warn!(
306 provider = %si.provider_name,
307 "inference waiting: provider not registered"
308 );
309 stall(StallReason::ProviderMissing);
310 return;
311 };
312 let Some(permit) = stage.pools.try_acquire(&si.model) else {
313 // Every in-flight call on this model holds a permit; if
314 // this repeats for minutes, one of them is stuck (see the
315 // default request timeout in leviath-providers).
316 tracing::debug!(
317 model = %si.model,
318 "inference waiting: per-model pool is full"
319 );
320 stall(StallReason::PoolFull);
321 return;
322 };
323 let (request, system_hash) = build_request(
324 window,
325 config,
326 si,
327 &provider,
328 &state.current_stage,
329 progress.map(|p| p.iterations).unwrap_or(0),
330 prefix.map(|p| p.0),
331 );
332 // Remembered for the next request, which is the only way the
333 // breakpoint decision can be made on evidence.
334 par_commands.command_scope(|mut commands| {
335 commands
336 .entity(entity)
337 .insert(SystemPrefixHash(system_hash));
338 });
339 let job = InferenceJob {
340 entity,
341 provider,
342 request,
343 permit,
344 exact_token_counting: stage.exact_token_counting,
345 };
346 let cancel = crate::cancel::CancelToken::new();
347 // Supervised: this agent is about to become `AwaitingInference`,
348 // which the driver reads as "busy". A job that died without
349 // reporting would leave it waiting on a completion that can no
350 // longer come, so the supervisor reports one in its place.
351 let lost_outcomes = stage.outcomes.clone();
352 let lost_wake = stage.wake.clone();
353 crate::lane_supervisor::spawn_supervised(
354 &stage.runtime,
355 "inference",
356 run_inference_job(
357 job,
358 stage.outcomes.clone(),
359 stage.wake.clone(),
360 retry_policy_for(config, retry_tuning),
361 cancel.clone(),
362 ),
363 move |message| {
364 let _ = lost_outcomes.send(InferenceOutcome {
365 entity,
366 result: Err(leviath_providers::ProviderError::Other(message)),
367 // The job never got to measure itself.
368 latency: std::time::Duration::ZERO,
369 });
370 lost_wake.notify_one();
371 },
372 );
373 par_commands.command_scope(|mut commands| {
374 track_in_flight(&mut commands, entity, in_flight, cancel);
375 commands
376 .entity(entity)
377 .remove::<ReadyToInfer>()
378 // Dispatched: whatever it was waiting for, it isn't
379 // waiting any more.
380 .remove::<DispatchStall>()
381 .insert(AwaitingInference);
382 });
383 });
384 },
385 );
386}