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/// Build the [`InferenceRequest`] for an agent from its context window + stage
18/// data. Pure; no `.await` - a custom region's render hook is a bounded,
19/// synchronous Rhai eval. (Ported from `AgentEngine::build_inference_request`,
20/// with provider resolution lifted into the caller so this stays query-friendly.)
21///
22/// `stage_name` / `stage_iterations` feed custom-region `render(ctx)` hooks;
23/// they change nothing when the window has no custom regions.
24pub(crate) fn build_request(
25 window: &ContextWindow,
26 config: Option<&InferenceConfig>,
27 stage: &StageInference,
28 provider: &Arc<dyn Provider>,
29 stage_name: &str,
30 stage_iterations: usize,
31) -> InferenceRequest {
32 let assembled = window.assemble_with_meta(&crate::custom_region::AssembleMeta {
33 stage_name: stage_name.to_string(),
34 stage_iterations,
35 model: stage.model.clone(),
36 });
37 let remaining = window.max_tokens.saturating_sub(window.current_tokens);
38 let caps = provider.capabilities(&stage.model);
39 let output_cap = config
40 .and_then(|c| c.max_output_tokens)
41 .unwrap_or(caps.max_output_tokens);
42 let max_tokens = remaining.min(output_cap);
43
44 let filtered_tools = match stage.tool_filter.as_deref() {
45 Some(filter) if !filter.is_empty() => stage
46 .tools
47 .iter()
48 .filter(|t| filter.iter().any(|f| f == &t.name))
49 .cloned()
50 .collect(),
51 _ => stage.tools.clone(),
52 };
53
54 let temperature = if caps.supports_temperature {
55 config.and_then(|c| c.temperature).unwrap_or(0.7)
56 } else {
57 0.0
58 };
59
60 // Pass through any extra model parameters (top_p, stop, seed, …) so the
61 // provider can apply them; `Null` when there are none.
62 let extra = match config.map(|c| &c.extra_params) {
63 Some(params) if !params.is_empty() => serde_json::Value::Object(params.clone()),
64 _ => serde_json::Value::Null,
65 };
66
67 // Prepend the batch-tool-calls hint as a stable, always-cacheable system
68 // block when this stage opts in. Prepending keeps it at the front of the
69 // `Always`-tier prefix (which `assemble` already sorts first), maximizing
70 // prefix-cache hits since the text never varies across agents/stages/runs.
71 let mut system = assembled.system_blocks;
72 if config.map(|c| c.batch_tool_hint).unwrap_or(false) {
73 system.insert(
74 0,
75 leviath_providers::SystemBlock {
76 text: BATCH_TOOL_HINT.to_string(),
77 cache_hint: leviath_core::CacheHint::Always,
78 },
79 );
80 }
81
82 InferenceRequest {
83 system,
84 messages: assembled.messages,
85 model: stage.model.clone(),
86 max_tokens,
87 temperature,
88 tools: filtered_tools,
89 extra,
90 request_timeout_secs: config.and_then(|c| c.request_timeout_secs),
91 }
92}
93
94/// Build the [`RetryPolicy`] for a job, applying a stage's per-stage inference
95/// wall-clock cap when configured. Starts from the default policy and, when the
96/// stage set `request_timeout_secs` (from `[stages.<name>.model]`), overrides its
97/// `job_timeout`; otherwise the default job timeout stands. Pure so the override
98/// branch is unit-testable without driving the ECS dispatch.
99pub(crate) fn retry_policy_for(
100 config: Option<&InferenceConfig>,
101) -> crate::inference_bridge::RetryPolicy {
102 let mut policy = crate::inference_bridge::RetryPolicy::default();
103 if let Some(secs) = config.and_then(|c| c.request_timeout_secs) {
104 policy.job_timeout = std::time::Duration::from_secs(secs);
105 }
106 policy
107}
108
109/// The cancellation handles for an agent's currently in-flight async work (its
110/// inference request, its tool batch). Attached when the work is dispatched,
111/// removed when it lands - so the presence of this component means "there is
112/// something running for this agent that a cancel needs to stop".
113///
114/// Without it, cancelling only stopped *new* work from being dispatched: a
115/// request already handed to the async lanes ran to completion, holding its
116/// inference-pool permit or tool-lane worker the whole time.
117#[derive(Component, Default, Debug)]
118pub struct InFlightWork(pub Vec<crate::cancel::CancelToken>);
119
120/// Stop the in-flight work of every agent that has reached a terminal state, and
121/// drop the handles. Runs before the dispatch systems each tick, so a cancel
122/// takes effect on the very next tick rather than whenever the provider or tool
123/// happens to answer.
124pub fn abort_terminal_work(
125 agents: Query<(Entity, &AgentState, &InFlightWork)>,
126 mut commands: Commands,
127) {
128 crate::tick_scope::clear();
129 for (entity, state, in_flight) in agents.iter() {
130 if !is_terminal_status(&state.status) {
131 continue;
132 }
133 crate::tick_scope::enter(entity);
134 for token in &in_flight.0 {
135 token.cancel();
136 }
137 commands.entity(entity).remove::<InFlightWork>();
138 }
139}
140
141/// Record `token` as in-flight work for `entity`, keeping any already attached
142/// (an agent can have both a tool batch and an inference outstanding across a
143/// tick boundary).
144pub(crate) fn track_in_flight(
145 commands: &mut Commands,
146 entity: Entity,
147 existing: Option<&InFlightWork>,
148 token: crate::cancel::CancelToken,
149) {
150 let mut tokens = existing.map(|w| w.0.clone()).unwrap_or_default();
151 tokens.push(token);
152 commands.entity(entity).insert(InFlightWork(tokens));
153}
154
155/// Inference-dispatch system: for every `ReadyToInfer` agent, resolve its
156/// provider and, **if a per-model permit is free**, build the request, spawn the
157/// inference job, and move it to `AwaitingInference`. If its provider is missing
158/// or no slot is free, it stays `ReadyToInfer` and is retried on a later tick -
159/// no blocking, no wasted task.
160#[allow(clippy::type_complexity)]
161pub fn dispatch_inference(
162 agents: Query<
163 (
164 Entity,
165 &AgentState,
166 &ContextWindow,
167 Option<&InferenceConfig>,
168 &StageInference,
169 Option<&InFlightWork>,
170 Option<&StageProgress>,
171 ),
172 With<ReadyToInfer>,
173 >,
174 stage: Res<InferenceStage>,
175 providers: Res<Providers>,
176 par_commands: ParallelCommands,
177) {
178 // Fan out across ready agents: request assembly (`build_request`) is the
179 // per-agent CPU cost and is independent, so it runs in parallel on the
180 // compute pool. Permit acquisition (an atomic semaphore) and the tokio spawn
181 // are thread-safe; the marker swap is batched via `ParallelCommands`.
182 //
183 // This is the one system whose per-agent body runs off the driver thread, so
184 // the thread-local `tick_scope` can't carry an entity back to the catcher.
185 // Each agent's share runs under `run_agent_parallel`, which catches there -
186 // where the entity is known - and marks that agent for `tick` to fail
187 // (issue #109). Clearing the thread-local keeps a panic in the fan-out
188 // machinery *itself* unattributed rather than blamed on whichever agent a
189 // previous system left recorded.
190 crate::tick_scope::clear();
191 agents
192 .par_iter()
193 .for_each(|(entity, state, window, config, si, in_flight, progress)| {
194 crate::tick_scope::run_agent_parallel(entity, &par_commands, &mut || {
195 if state.status != AgentStatus::Active {
196 return; // paused / waiting / cancelled - don't start new work
197 }
198 let Some(provider) = providers.0.get(&si.provider_name) else {
199 // Leave ready and retry later - but say so. A silently
200 // starved agent reads as a wedged run with no error.
201 tracing::warn!(
202 provider = %si.provider_name,
203 "inference waiting: provider not registered"
204 );
205 return;
206 };
207 let Some(permit) = stage.pools.try_acquire(&si.model) else {
208 // Every in-flight call on this model holds a permit; if
209 // this repeats for minutes, one of them is stuck (see the
210 // default request timeout in leviath-providers).
211 tracing::debug!(
212 model = %si.model,
213 "inference waiting: per-model pool is full"
214 );
215 return;
216 };
217 let request = build_request(
218 window,
219 config,
220 si,
221 &provider,
222 &state.current_stage,
223 progress.map(|p| p.iterations).unwrap_or(0),
224 );
225 let job = InferenceJob {
226 entity,
227 provider,
228 request,
229 permit,
230 exact_token_counting: stage.exact_token_counting,
231 };
232 let cancel = crate::cancel::CancelToken::new();
233 stage.runtime.spawn(run_inference_job(
234 job,
235 stage.outcomes.clone(),
236 stage.wake.clone(),
237 retry_policy_for(config),
238 cancel.clone(),
239 ));
240 par_commands.command_scope(|mut commands| {
241 track_in_flight(&mut commands, entity, in_flight, cancel);
242 commands
243 .entity(entity)
244 .remove::<ReadyToInfer>()
245 .insert(AwaitingInference);
246 });
247 });
248 });
249}