Skip to main content

leviath_runtime/pipeline/
tools.rs

1//! Tool dispatch: batching, policy triage, and handing calls to the tool lane.
2
3use super::*;
4
5/// The agent's tool batch has been handed to the tool lane; it is waiting for
6/// the results (which the tool-collect system will apply).
7#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
8pub struct AwaitingTools;
9
10/// Marker: this agent's advertised tools should be re-resolved before its next
11/// turn - mid-run dynamic tool discovery. Consumed by
12/// [`refresh_advertised_tools`], which asks the [`ToolService`] for the stage's
13/// fresh tool defs and writes them into the live [`StageInference`].
14#[derive(Component, Debug, Clone, Copy)]
15pub struct ToolsNeedRefresh;
16
17/// Marker: this agent opted into `dynamic_tools`. Only such agents
18/// are polled by [`poll_dynamic_tool_refresh`] for a pending tool re-scan, so the
19/// default (static) agent pays nothing.
20#[derive(Component, Debug, Clone, Copy)]
21pub struct DynamicTools;
22
23/// Provides a per-agent tool-execution closure. The concrete implementation
24/// (in the CLI) holds each agent's tool registry, workdir, and permission
25/// policy; the pipeline stays agnostic to *how* tools run. `exec_for` returns a
26/// boxed closure the tool worker runs off the tick.
27pub trait ToolService: Send + Sync {
28    /// Build the closure that runs `calls` for `entity`, resolving `(id, result)`
29    /// pairs.
30    fn exec_for(&self, entity: Entity, calls: Vec<leviath_providers::ToolCall>) -> BoxedToolExec;
31
32    /// Notify the service that `entity` entered the stage at `stage_index` named
33    /// `stage_name`, so it can re-sync that agent's per-stage tool permissions.
34    /// Default no-op for services without per-stage policy.
35    fn sync_stage(&self, _entity: Entity, _stage_index: usize, _stage_name: &str) {}
36
37    /// Re-resolve `entity`'s advertised tool defs for the stage at `stage_index` -
38    /// e.g. after new tools were discovered on disk. `None` means "no change"
39    /// (the default, for services without dynamic tools); `Some(tools)` replaces
40    /// the stage's advertised set.
41    fn refresh_tools(
42        &self,
43        _entity: Entity,
44        _stage_index: usize,
45    ) -> Option<Vec<leviath_providers::Tool>> {
46        None
47    }
48
49    /// Whether `entity` (a `dynamic_tools` agent) has pending tool changes that
50    /// warrant a re-scan + re-advertise. Polled by [`poll_dynamic_tool_refresh`];
51    /// implementors return (and clear) a per-agent dirty flag. Default `false`.
52    fn wants_refresh(&self, _entity: Entity) -> bool {
53        false
54    }
55}
56
57/// The tool service, as a world resource.
58#[derive(Resource, Clone)]
59pub struct ToolServiceRes(pub Arc<dyn ToolService>);
60
61/// The job sender feeding the tool lane, as a world resource.
62#[derive(Resource, Clone)]
63pub struct ToolStage(pub UnboundedSender<ToolJob>);
64
65/// Context-tool results computed inline by [`dispatch_tools`] (the `context_*`
66/// tools mutate the ECS window, so they can't run on the async lane), held until
67/// [`collect_tools`] merges them with the lane results. Absent when a batch had
68/// no context tools.
69#[derive(Component, Debug, Clone, Default)]
70pub struct ContextToolResults(pub Vec<(String, String)>);
71
72/// Merge context + lane tool results into one `(id, result)` list in the
73/// original tool-call order (Anthropic requires a `tool_result` per `tool_use`,
74/// in order).
75/// Collapse a possibly-multiline string to a single trimmed line capped at
76/// `max` characters (with an ellipsis when truncated), for one-line log entries.
77pub(crate) fn one_line(s: &str, max: usize) -> String {
78    let flat = s.split_whitespace().collect::<Vec<_>>().join(" ");
79    if flat.chars().count() > max {
80        format!("{}…", flat.chars().take(max).collect::<String>())
81    } else {
82        flat
83    }
84}
85
86pub(crate) fn merge_in_call_order(
87    tool_calls: &[crate::components::ToolCall],
88    parts: &[(String, String)],
89) -> Vec<(String, String)> {
90    tool_calls
91        .iter()
92        .map(|tc| {
93            let result = parts
94                .iter()
95                .find(|(id, _)| id == &tc.tool_id)
96                .map(|(_, r)| r.clone())
97                .unwrap_or_default();
98            (tc.tool_id.clone(), result)
99        })
100        .collect()
101}
102
103/// Whether a tool result describes a call whose side effect never happened.
104///
105/// `[error]` (it ran and failed), `[denied]` (policy refused it) and
106/// `[unavailable]` (the stage never offered it) all mean the same thing to
107/// anything reasoning about what the agent *did*: file tracking must not record
108/// a write that was not written, and the modification counters behind a
109/// transition gate must not count it as work. Three separate prefix lists is
110/// exactly how a fourth prefix gets missed - `[unavailable]` was, when dispatch
111/// began refusing unoffered tools and both call sites still listed only two.
112pub(crate) fn call_had_no_effect(result: &str) -> bool {
113    result.starts_with("[error]")
114        || result.starts_with("[denied]")
115        || result.starts_with("[unavailable]")
116}
117
118/// The effective tool names this stage advertised, canonicalised.
119///
120/// The same narrowing the request builder applies: `tools`, then `tool_filter`
121/// when it is set and non-empty. Deriving both from one function is what keeps
122/// "what the model was offered" and "what the model may call" the same set.
123pub(crate) fn offered_tool_names(stage: &StageInference) -> Vec<&str> {
124    stage
125        .tools
126        .iter()
127        .filter(|t| match stage.tool_filter.as_deref() {
128            Some(filter) if !filter.is_empty() => filter.iter().any(|f| f == &t.name),
129            _ => true,
130        })
131        .map(|t| leviath_tools::canonical_tool_name(&t.name))
132        .collect()
133}
134
135/// `Some(message)` when `name` is not among the stage's advertised tools.
136///
137/// The message is written for the model, not the user: it says plainly that the
138/// tool does not exist *here* and lists what does, so the next turn is a usable
139/// call rather than a retry of the same one. A stage advertising nothing says so
140/// instead of printing an empty list.
141pub(crate) fn unoffered_tool_refusal(stage: &StageInference, name: &str) -> Option<String> {
142    let canonical = leviath_tools::canonical_tool_name(name);
143    let offered = offered_tool_names(stage);
144    if offered.contains(&canonical) {
145        return None;
146    }
147    Some(match offered.is_empty() {
148        true => format!(
149            "[unavailable] '{name}' is not available in this stage, which has no \
150             tools at all. Answer directly instead of calling a tool."
151        ),
152        false => format!(
153            "[unavailable] '{name}' is not available in this stage. You may call: {}.",
154            offered.join(", ")
155        ),
156    })
157}
158
159/// Tool-dispatch system: for each `ReadyForTools` agent, apply its `context_*`
160/// tool calls inline (they mutate the ECS window) and hand the rest to the
161/// sequential tool lane, moving it to `AwaitingTools`. If a batch is *all*
162/// context tools there is nothing for the lane, so the results are applied
163/// immediately and the agent loops straight back to `ReadyToInfer`. The lane
164/// serializes execution, so there is no permit gate - every ready agent is
165/// enqueued in turn.
166#[allow(clippy::type_complexity, clippy::too_many_arguments)]
167pub fn dispatch_tools(
168    mut agents: Query<
169        (
170            Entity,
171            &AgentState,
172            &StageInference,
173            &crate::components::InferenceResult,
174            &mut ContextWindow,
175            Option<&crate::components::ToolResultRoutingComponent>,
176            Option<&ToolSensitivities>,
177            Option<&mut crate::taint::TaintGate>,
178            Option<&crate::gate_prompt::GateResolved>,
179            Option<&crate::components::GateAutoApprove>,
180            Option<&InFlightWork>,
181        ),
182        With<ReadyForTools>,
183    >,
184    service: Res<ToolServiceRes>,
185    stage: Res<ToolStage>,
186    policy: Option<Res<PolicyGate>>,
187    script_rules: Option<Res<GateScriptRules>>,
188    hub: Option<Res<InteractionHub>>,
189    gate_stage: Option<Res<crate::gate_prompt::GatePromptStage>>,
190    mut commands: Commands,
191) {
192    crate::tick_scope::clear();
193    let default_policy = leviath_core::PolicyConfig::default();
194    let policy_ref = policy.as_ref().map(|p| &p.0).unwrap_or(&default_policy);
195    let script_checker = script_rules.as_ref().map(|r| r.0.as_ref());
196    // Interactive gate prompting is available only when both the hub and the
197    // gate-prompt lane are wired (the daemon); otherwise blocks are returned as
198    // `[blocked]` immediately, preserving the headless/non-interactive behavior.
199    let interactive = hub.as_ref().zip(gate_stage.as_ref());
200    for (
201        entity,
202        state,
203        stage_inf,
204        result,
205        mut window,
206        routing,
207        sensitivities,
208        mut gate,
209        resolved,
210        auto_gate,
211        in_flight,
212    ) in agents.iter_mut()
213    {
214        crate::tick_scope::enter(entity);
215        // `--yolo`: waive taint-gate enforcement so a headless run never blocks
216        // on a gate prompt no one can answer (taint tracking still records).
217        let auto_approve_gates = auto_gate.is_some();
218        if state.status != AgentStatus::Active {
219            continue; // paused / waiting / cancelled - don't start new work
220        }
221
222        // Apply context_* tools inline (they need world access); collect the rest
223        // for the async lane. A taint-gated agent's outbound call that would leak
224        // over-cleared data (and isn't allowlisted) is blocked - either returned
225        // as `[blocked]`, or (interactive) held for a user gate prompt.
226        let mut context_results = Vec::new();
227        let mut lane_calls = Vec::new();
228        // (tool_id, name, taint, clearance) for blocked calls awaiting a prompt.
229        let mut pending_prompts: Vec<(
230            String,
231            String,
232            leviath_core::TaintLevel,
233            leviath_core::TaintLevel,
234        )> = Vec::new();
235        for c in &result.tool_calls {
236            // Layer 1, enforced rather than merely advertised.
237            //
238            // A stage's `available_tools` was applied only when building the
239            // schema list sent to the model. Nothing checked it again here, so a
240            // model that *named* a tool it had never been offered got that call
241            // dispatched anyway - reaching the permission gate, and for a
242            // default-`Ask` tool surfacing to the user as an approval prompt for
243            // something the stage was never granted.
244            //
245            // That is not hypothetical. A `plan` stage granting only
246            // `read_file`/`list_dir`/`ask_user_*`/`edit_document` emitted
247            // `write_file` with a complete source file in it, and the user was
248            // asked to approve writing code from the planning stage. Declining
249            // it was the only thing that stopped it.
250            //
251            // Checked against `StageInference`, which *is* the set advertised
252            // for this stage - resolved at spawn, swapped on every transition,
253            // and rewritten by the dynamic-tools refresh - so enforcement cannot
254            // drift from advertising the way a second copy of the rule would.
255            if let Some(refusal) = unoffered_tool_refusal(stage_inf, &c.name) {
256                context_results.push((c.tool_id.clone(), refusal));
257                continue;
258            }
259            if crate::context_tools::is_context_tool(&c.name) {
260                let text =
261                    crate::context_tools::handle_context_tool(&c.name, &c.arguments, &mut window);
262                context_results.push((c.tool_id.clone(), text));
263                continue;
264            }
265            // A call the user already resolved in a prior prompt round.
266            if let Some(resolved) = resolved {
267                if let Some(msg) = resolved.denied.get(&c.tool_id) {
268                    context_results.push((c.tool_id.clone(), msg.clone()));
269                    continue;
270                }
271                if resolved.approved.contains(&c.tool_id) {
272                    lane_calls.push(leviath_providers::ToolCall {
273                        id: c.tool_id.clone(),
274                        name: c.name.clone(),
275                        arguments: c.arguments.clone(),
276                        thought_signature: c.thought_signature.clone(),
277                    });
278                    continue;
279                }
280            }
281            if let Some(gate) = gate.as_deref_mut() {
282                let decision = gate.check_with_policy(
283                    &state.agent_id,
284                    &c.name,
285                    &window,
286                    None,
287                    policy_ref,
288                    script_checker,
289                );
290                if !decision.is_allowed() {
291                    if auto_approve_gates {
292                        // `--yolo`: waive enforcement but record the override in
293                        // the audit trail (rather than skipping the gate), so the
294                        // over-cleared call is still accounted for. Fall through
295                        // to dispatch the call.
296                        let (taint, clearance) = decision
297                            .blocked_levels()
298                            .expect("a non-Allowed GateDecision is always Blocked");
299                        gate.record_allow(
300                            &state.agent_id,
301                            &c.name,
302                            taint,
303                            clearance,
304                            leviath_core::taint::GateDecisionSource::YoloAutoApprove,
305                        );
306                    } else {
307                        match (interactive, decision.blocked_levels()) {
308                            (Some(_), Some((taint, clearance))) => {
309                                pending_prompts.push((
310                                    c.tool_id.clone(),
311                                    c.name.clone(),
312                                    taint,
313                                    clearance,
314                                ));
315                            }
316                            _ => {
317                                context_results
318                                    .push((c.tool_id.clone(), taint_block_message(&decision)));
319                            }
320                        }
321                        continue;
322                    }
323                }
324            }
325            lane_calls.push(leviath_providers::ToolCall {
326                id: c.tool_id.clone(),
327                name: c.name.clone(),
328                arguments: c.arguments.clone(),
329                thought_signature: c.thought_signature.clone(),
330            });
331        }
332
333        // Hold the batch and ask the user about each blocked call.
334        if let (false, Some((hub, gate_stage))) = (pending_prompts.is_empty(), interactive) {
335            let n = pending_prompts.len();
336            for (tool_id, name, taint, clearance) in pending_prompts {
337                gate_stage
338                    .runtime
339                    .spawn(crate::gate_prompt::run_gate_prompt(
340                        entity,
341                        (*hub).clone(),
342                        state.agent_id.clone(),
343                        tool_id,
344                        name,
345                        taint,
346                        clearance,
347                        gate_stage.outcomes.clone(),
348                        gate_stage.wake.clone(),
349                    ));
350            }
351            commands
352                .entity(entity)
353                .remove::<ReadyForTools>()
354                .insert(crate::gate_prompt::AwaitingGatePrompt(n))
355                .insert(crate::gate_prompt::GateResolved::default());
356            continue; // re-run after the prompts resolve
357        }
358
359        // Dispatching the batch consumes any resolution state from a prior round.
360        commands
361            .entity(entity)
362            .remove::<crate::gate_prompt::GateResolved>();
363
364        if lane_calls.is_empty() {
365            // Nothing async to run - apply the context results now and loop back.
366            let merged = merge_in_call_order(&result.tool_calls, &context_results);
367            apply_tool_results(
368                &mut window,
369                &result.response,
370                &result.tool_calls,
371                &merged,
372                routing.map(|c| &c.routing),
373                sensitivities.map(|s| &s.0),
374            );
375            commands
376                .entity(entity)
377                .remove::<ReadyForTools>()
378                .insert(ReadyToInfer);
379            continue;
380        }
381
382        let exec = service.0.exec_for(entity, lane_calls);
383        let cancel = crate::cancel::CancelToken::new();
384        // The lane worker is alive for the world's lifetime; a failed send would
385        // only happen during shutdown, where dropping the job is fine.
386        let _ = stage.0.send(ToolJob {
387            entity,
388            exec,
389            cancel: cancel.clone(),
390        });
391        track_in_flight(&mut commands, entity, in_flight, cancel);
392        commands
393            .entity(entity)
394            .remove::<ReadyForTools>()
395            .insert(AwaitingTools)
396            .insert(ContextToolResults(context_results));
397    }
398}