Skip to main content

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}