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/// Reports one tool call's result the moment it resolves, from inside the
24/// executor - `(tool_call_id, result)`. Dispatch builds one per batch to journal
25/// each completion as a `ToolCallDone` record, so a crash mid-batch loses only
26/// the calls that genuinely never finished (issue #96). Implementors that don't
27/// journal get a no-op.
28pub type ToolProgress = Arc<dyn Fn(&str, &str) + Send + Sync>;
29
30/// A [`ToolProgress`] that reports nowhere - for worlds without a persistence
31/// lane and for `ToolService` impls under test.
32pub fn noop_progress() -> ToolProgress {
33    Arc::new(|_, _| {})
34}
35
36/// Provides a per-agent tool-execution closure. The concrete implementation
37/// (in the CLI) holds each agent's tool registry, workdir, and permission
38/// policy; the pipeline stays agnostic to *how* tools run. `exec_for` returns a
39/// boxed closure the tool worker runs off the tick.
40pub trait ToolService: Send + Sync {
41    /// Build the closure that runs `calls` for `entity`, resolving `(id, result)`
42    /// pairs. The executor calls `progress` with each call's result as it
43    /// resolves (per-call, not at batch end).
44    fn exec_for(
45        &self,
46        entity: Entity,
47        calls: Vec<leviath_providers::ToolCall>,
48        progress: ToolProgress,
49    ) -> BoxedToolExec;
50
51    /// Notify the service that `entity` entered the stage at `stage_index` named
52    /// `stage_name`, so it can re-sync that agent's per-stage tool permissions.
53    /// Default no-op for services without per-stage policy.
54    fn sync_stage(&self, _entity: Entity, _stage_index: usize, _stage_name: &str) {}
55
56    /// Re-resolve `entity`'s advertised tool defs for the stage at `stage_index` -
57    /// e.g. after new tools were discovered on disk. `None` means "no change"
58    /// (the default, for services without dynamic tools); `Some(tools)` replaces
59    /// the stage's advertised set.
60    fn refresh_tools(
61        &self,
62        _entity: Entity,
63        _stage_index: usize,
64    ) -> Option<Vec<leviath_providers::Tool>> {
65        None
66    }
67
68    /// Whether `entity` (a `dynamic_tools` agent) has pending tool changes that
69    /// warrant a re-scan + re-advertise. Polled by [`poll_dynamic_tool_refresh`];
70    /// implementors return (and clear) a per-agent dirty flag. Default `false`.
71    fn wants_refresh(&self, _entity: Entity) -> bool {
72        false
73    }
74}
75
76/// The tool service, as a world resource.
77#[derive(Resource, Clone)]
78pub struct ToolServiceRes(pub Arc<dyn ToolService>);
79
80/// The job sender feeding the tool lane, as a world resource, paired with the
81/// lane's occupancy counters so dispatch can record what it queued.
82#[derive(Resource, Clone)]
83pub struct ToolStage {
84    /// Where batches are handed to the lane.
85    pub jobs: UnboundedSender<ToolJob>,
86    /// Shared with the lane's workers; see [`crate::tool_bridge::ToolLaneStats`].
87    pub stats: Arc<crate::tool_bridge::ToolLaneStats>,
88}
89
90impl ToolStage {
91    /// A stage wired to a real lane's counters.
92    pub fn new(
93        jobs: UnboundedSender<ToolJob>,
94        stats: Arc<crate::tool_bridge::ToolLaneStats>,
95    ) -> Self {
96        Self { jobs, stats }
97    }
98
99    /// A stage with counters of its own, for callers that drive `dispatch_tools`
100    /// without a lane behind it (tests read the channel directly).
101    pub fn detached(jobs: UnboundedSender<ToolJob>) -> Self {
102        Self::new(jobs, Arc::new(crate::tool_bridge::ToolLaneStats::new(1)))
103    }
104}
105
106/// Context-tool results computed inline by [`dispatch_tools`] (the `context_*`
107/// tools mutate the ECS window, so they can't run on the async lane), held until
108/// [`collect_tools`] merges them with the lane results. Absent when a batch had
109/// no context tools.
110#[derive(Component, Debug, Clone, Default)]
111pub struct ContextToolResults(pub Vec<(String, String)>);
112
113/// Merge context + lane tool results into one `(id, result)` list in the
114/// original tool-call order (Anthropic requires a `tool_result` per `tool_use`,
115/// in order).
116/// Collapse a possibly-multiline string to a single trimmed line capped at
117/// `max` characters (with an ellipsis when truncated), for one-line log entries.
118pub(crate) fn one_line(s: &str, max: usize) -> String {
119    let flat = s.split_whitespace().collect::<Vec<_>>().join(" ");
120    if flat.chars().count() > max {
121        format!("{}…", flat.chars().take(max).collect::<String>())
122    } else {
123        flat
124    }
125}
126
127pub(crate) fn merge_in_call_order(
128    tool_calls: &[crate::components::ToolCall],
129    parts: &[(String, String)],
130) -> Vec<(String, String)> {
131    tool_calls
132        .iter()
133        .map(|tc| {
134            let result = parts
135                .iter()
136                .find(|(id, _)| id == &tc.tool_id)
137                .map(|(_, r)| r.clone())
138                .unwrap_or_default();
139            (tc.tool_id.clone(), result)
140        })
141        .collect()
142}
143
144/// Whether a tool result describes a call whose side effect never happened.
145///
146/// `[error]` (it ran and failed), `[denied]` (policy refused it),
147/// `[unavailable]` (the stage never offered it) and `[blocked]` (the taint
148/// gate stopped it) all mean the same thing to anything reasoning about what
149/// the agent *did*: file tracking must not record a write that was not
150/// written, and the modification counters behind a transition gate must not
151/// count it as work. Separate prefix lists are exactly how a new prefix gets
152/// missed - `[unavailable]` was, when dispatch began refusing unoffered tools
153/// and both call sites still listed only two, and `[blocked]` was again, so a
154/// taint-blocked write counted as a modification until issue #155's pass.
155pub(crate) fn call_had_no_effect(result: &str) -> bool {
156    result.starts_with("[error]")
157        || result.starts_with("[denied]")
158        || result.starts_with("[unavailable]")
159        || result.starts_with("[blocked]")
160}
161
162/// The effective tool names this stage advertised, canonicalised.
163///
164/// The same narrowing the request builder applies: `tools`, then `tool_filter`
165/// when it is set and non-empty. Deriving both from one function is what keeps
166/// "what the model was offered" and "what the model may call" the same set.
167pub(crate) fn offered_tool_names(stage: &StageInference) -> Vec<&str> {
168    stage
169        .tools
170        .iter()
171        .filter(|t| match stage.tool_filter.as_deref() {
172            Some(filter) if !filter.is_empty() => filter.iter().any(|f| f == &t.name),
173            _ => true,
174        })
175        .map(|t| leviath_tools::canonical_tool_name(&t.name))
176        .collect()
177}
178
179/// `Some(message)` when `name` is not among the stage's advertised tools.
180///
181/// The message is written for the model, not the user: it says plainly that the
182/// tool does not exist *here* and lists what does, so the next turn is a usable
183/// call rather than a retry of the same one. A stage advertising nothing says so
184/// instead of printing an empty list.
185pub(crate) fn unoffered_tool_refusal(stage: &StageInference, name: &str) -> Option<String> {
186    let canonical = leviath_tools::canonical_tool_name(name);
187    let offered = offered_tool_names(stage);
188    if offered.contains(&canonical) {
189        return None;
190    }
191    Some(match offered.is_empty() {
192        true => format!(
193            "[unavailable] '{name}' is not available in this stage, which has no \
194             tools at all. Answer directly instead of calling a tool."
195        ),
196        false => format!(
197            "[unavailable] '{name}' is not available in this stage. You may call: {}.",
198            offered.join(", ")
199        ),
200    })
201}
202
203/// How long a dispatched batch may wait for its journal record's ack before
204/// running anyway. The wait is what keeps the `ToolBatch` record on disk ahead
205/// of the batch's side effects; the bound is the liveness valve - a dead or
206/// backed-up persistence worker degrades to an unjournaled dispatch instead of
207/// wedging every tool batch behind it.
208pub(crate) const BATCH_JOURNAL_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1);
209
210/// Wrap a tool-execution closure so it first waits (bounded) for the batch's
211/// journal-record ack. Both outcomes - acked, or timeout/dropped sender -
212/// proceed to run the batch.
213pub(crate) fn barrier_then(
214    exec: BoxedToolExec,
215    ack: tokio::sync::oneshot::Receiver<()>,
216    timeout: std::time::Duration,
217) -> BoxedToolExec {
218    Box::new(move || {
219        Box::pin(async move {
220            let _ = tokio::time::timeout(timeout, ack).await;
221            exec().await
222        })
223    })
224}
225
226/// `Some(refusal)` when `name`'s arguments do not satisfy the schema the
227/// stage advertised for it.
228///
229/// The def is found by canonicalising both the called name and each advertised
230/// name, the same resolution `offered_tool_names` applies, so a tool offered
231/// as `bash` validates a call to `shell` and vice versa. A name with no def
232/// here validates as fine - after the unoffered-tool check that cannot happen,
233/// and the schema's absence is not the model's mistake to be refused over.
234///
235/// A schema that does not compile (a typo'd Rhai `@param` type, an MCP
236/// fragment this crate cannot interpret) is logged and skipped rather than
237/// refused: validation must never turn a working tool into an unusable one.
238///
239/// Schemas are compiled per call, deliberately. They are small, calls arrive
240/// at model latency, and a compiled-validator cache would need invalidating on
241/// every dynamic-tools re-advertisement and scoping per agent - real
242/// complexity for unmeasurable savings.
243pub(crate) fn invalid_args_refusal(
244    stage: &StageInference,
245    name: &str,
246    args: &serde_json::Value,
247) -> Option<String> {
248    let canonical = leviath_tools::canonical_tool_name(name);
249    let tool = stage
250        .tools
251        .iter()
252        .find(|t| leviath_tools::canonical_tool_name(&t.name) == canonical)?;
253    match leviath_tools::validate_tool_args(name, &tool.parameters, args) {
254        leviath_tools::ArgValidation::Valid => None,
255        leviath_tools::ArgValidation::SchemaUnusable(e) => {
256            tracing::warn!(
257                tool = %name,
258                error = %e,
259                "tool schema did not compile; skipping argument validation"
260            );
261            None
262        }
263        leviath_tools::ArgValidation::Invalid(msg) => Some(msg),
264    }
265}
266
267/// What `dispatch_tools` selects.
268///
269/// `&'static` is bevy's `WorldQuery` convention, not a claim about
270/// lifetimes: the borrow is bound when the query is fetched.
271type DispatchToolsQuery = (
272    Entity,
273    &'static AgentState,
274    &'static StageInference,
275    &'static crate::components::InferenceResult,
276    &'static mut ContextWindow,
277    Option<&'static crate::components::ToolResultRoutingComponent>,
278    Option<&'static ToolSensitivities>,
279    Option<&'static mut crate::taint::TaintGate>,
280    Option<&'static crate::gate_prompt::GateResolved>,
281    Option<&'static crate::components::GateAutoApprove>,
282    Option<&'static InFlightWork>,
283    Option<&'static StageCursor>,
284    Option<&'static RunMetadata>,
285    Option<&'static crate::components::OutputValidators>,
286    // For the submit_output guard: a submission that is exactly the name of a
287    // stage in this blueprint is a routing token, not an answer.
288    Option<&'static crate::pipeline::transition::AgentBlueprint>,
289);
290
291/// The resources the daemon installs, which a bare world does not have.
292///
293/// Every field is optional because `lev run` drives these same systems with no
294/// daemon behind them: no gate lane to prompt through, no persistence lane, no
295/// event sink. Bundled as one `SystemParam` so a system's signature stays about
296/// what it *queries* rather than listing the six things that might be wired.
297#[derive(bevy_ecs::system::SystemParam)]
298pub struct DaemonServices<'w> {
299    /// The taint-gate policy, when one is configured.
300    pub policy: Option<Res<'w, PolicyGate>>,
301    /// Rhai rules the gate consults before blocking.
302    pub script_rules: Option<Res<'w, GateScriptRules>>,
303    /// The hub a blocked call can prompt through.
304    pub hub: Option<Res<'w, InteractionHub>>,
305    /// The lane a gate prompt's answer comes back on.
306    pub gate_stage: Option<Res<'w, crate::gate_prompt::GatePromptStage>>,
307    /// The lane run state is written on.
308    pub persist: Option<Res<'w, PersistenceStage>>,
309    /// Where world events are broadcast.
310    pub sink: Option<Res<'w, crate::host::WorldEventSink>>,
311}
312
313/// Tool-dispatch system: for each `ReadyForTools` agent, apply its `context_*`
314/// tool calls inline (they mutate the ECS window) and hand the rest to the
315/// sequential tool lane, moving it to `AwaitingTools`. If a batch is *all*
316/// context tools there is nothing for the lane, so the results are applied
317/// immediately and the agent loops straight back to `ReadyToInfer`. The lane
318/// serializes execution, so there is no permit gate - every ready agent is
319/// enqueued in turn.
320///
321/// A persisted agent's batch is journaled at dispatch: a `ToolBatch` record
322/// (inline results pre-filled, lane calls pending) goes to the persistence lane
323/// with an ack the exec waits on, and a per-call [`ToolProgress`] journals each
324/// completion as a `ToolCallDone`. On a crash mid-batch, recovery replays the
325/// recorded results instead of re-running their side effects (issue #96).
326pub fn dispatch_tools(
327    mut agents: Query<DispatchToolsQuery, With<ReadyForTools>>,
328    service: Res<ToolServiceRes>,
329    stage: Res<ToolStage>,
330    daemon: DaemonServices,
331    mut commands: Commands,
332) {
333    let DaemonServices {
334        policy,
335        script_rules,
336        hub,
337        gate_stage,
338        persist,
339        sink,
340    } = daemon;
341    crate::tick_scope::clear();
342    let default_policy = leviath_core::PolicyConfig::default();
343    let policy_ref = policy.as_ref().map(|p| &p.0).unwrap_or(&default_policy);
344    let script_checker = script_rules.as_ref().map(|r| r.0.as_ref());
345    // Interactive gate prompting is available only when both the hub and the
346    // gate-prompt lane are wired (the daemon); otherwise blocks are returned as
347    // `[blocked]` immediately, preserving the headless/non-interactive behavior.
348    let interactive = hub.as_ref().zip(gate_stage.as_ref());
349    for (
350        entity,
351        state,
352        stage_inf,
353        result,
354        mut window,
355        routing,
356        sensitivities,
357        mut gate,
358        resolved,
359        auto_gate,
360        in_flight,
361        cursor,
362        metadata,
363        validators,
364        blueprint,
365    ) in agents.iter_mut()
366    {
367        crate::tick_scope::enter(entity);
368        // `--yolo`: waive taint-gate enforcement so a headless run never blocks
369        // on a gate prompt no one can answer (taint tracking still records).
370        let auto_approve_gates = auto_gate.is_some();
371        if state.status != AgentStatus::Active {
372            continue; // paused / waiting / cancelled - don't start new work
373        }
374
375        // Apply context_* tools inline (they need world access); collect the rest
376        // for the async lane. A taint-gated agent's outbound call that would leak
377        // over-cleared data (and isn't allowlisted) is blocked - either returned
378        // as `[blocked]`, or (interactive) held for a user gate prompt.
379        let mut context_results = Vec::new();
380        let mut lane_calls = Vec::new();
381        // A final output submitted in this batch, committed to the entity after
382        // the loop (the loop holds borrows `commands` would conflict with).
383        let mut submitted: Option<leviath_core::output::FinalOutput> = None;
384        // (tool_id, name, taint, clearance) for blocked calls awaiting a prompt.
385        let mut pending_prompts: Vec<(
386            String,
387            String,
388            leviath_core::TaintLevel,
389            leviath_core::TaintLevel,
390        )> = Vec::new();
391        for c in &result.tool_calls {
392            // Layer 1, enforced rather than merely advertised.
393            //
394            // A stage's `available_tools` was applied only when building the
395            // schema list sent to the model. Nothing checked it again here, so a
396            // model that *named* a tool it had never been offered got that call
397            // dispatched anyway - reaching the permission gate, and for a
398            // default-`Ask` tool surfacing to the user as an approval prompt for
399            // something the stage was never granted.
400            //
401            // That is not hypothetical. A `plan` stage granting only
402            // `read_file`/`list_dir`/`ask_user_*`/`edit_document` emitted
403            // `write_file` with a complete source file in it, and the user was
404            // asked to approve writing code from the planning stage. Declining
405            // it was the only thing that stopped it.
406            //
407            // Checked against `StageInference`, which *is* the set advertised
408            // for this stage - resolved at spawn, swapped on every transition,
409            // and rewritten by the dynamic-tools refresh - so enforcement cannot
410            // drift from advertising the way a second copy of the rule would.
411            if let Some(refusal) = unoffered_tool_refusal(stage_inf, &c.name) {
412                context_results.push((c.tool_id.clone(), refusal));
413                continue;
414            }
415            // Layer 2: the call must satisfy the schema the model was shown.
416            // A mismatched call is refused back to the model with the
417            // validator's message, so the next turn can self-correct, rather
418            // than executed on garbage or surfaced to the user as a permission
419            // prompt for arguments that were never valid. Deterministic, so a
420            // gate-prompt re-run of the same batch refuses identically.
421            if let Some(refusal) = invalid_args_refusal(stage_inf, &c.name, &c.arguments) {
422                context_results.push((c.tool_id.clone(), refusal));
423                continue;
424            }
425            if crate::context_tools::is_context_tool(&c.name) {
426                let text =
427                    crate::context_tools::handle_context_tool(&c.name, &c.arguments, &mut window);
428                context_results.push((c.tool_id.clone(), text));
429                continue;
430            }
431            // Applied inline for the same reason the context tools are: it
432            // writes the live window and an ECS component, neither of which the
433            // async lane can reach. Recorded here and committed after the loop,
434            // because `commands` cannot be borrowed inside it.
435            if crate::output_tool::is_output_tool(&c.name) {
436                let stage_names: Vec<String> = blueprint
437                    .map(|bp| bp.0.stages.iter().map(|s| s.name.clone()).collect())
438                    .unwrap_or_default();
439                let (text, output) = crate::output_tool::handle_output_tool(
440                    &c.arguments,
441                    &crate::output_tool::OutputContext {
442                        spec: stage_inf.output.as_ref(),
443                        validators,
444                        stage: &state.current_stage,
445                        stage_names: &stage_names,
446                        workdir: metadata.map(|m| std::path::Path::new(&m.workdir)),
447                    },
448                    chrono::Utc::now().timestamp(),
449                    &mut window,
450                );
451                // A refused submission leaves any earlier one alone: a bad
452                // correction must not erase a good answer.
453                if let Some(output) = output {
454                    submitted = Some(output);
455                }
456                context_results.push((c.tool_id.clone(), text));
457                continue;
458            }
459            // A call the user already resolved in a prior prompt round.
460            if let Some(resolved) = resolved {
461                if let Some(msg) = resolved.denied.get(&c.tool_id) {
462                    context_results.push((c.tool_id.clone(), msg.clone()));
463                    continue;
464                }
465                if resolved.approved.contains(&c.tool_id) {
466                    lane_calls.push(leviath_providers::ToolCall {
467                        id: c.tool_id.clone(),
468                        name: c.name.clone(),
469                        arguments: c.arguments.clone(),
470                        thought_signature: c.thought_signature.clone(),
471                    });
472                    continue;
473                }
474            }
475            if let Some(gate) = gate.as_deref_mut() {
476                let decision = gate.check_with_policy(
477                    &state.agent_id,
478                    &c.name,
479                    &window,
480                    None,
481                    policy_ref,
482                    script_checker,
483                );
484                if !decision.is_allowed() {
485                    if auto_approve_gates {
486                        // `--yolo`: waive enforcement but record the override in
487                        // the audit trail (rather than skipping the gate), so the
488                        // over-cleared call is still accounted for. Fall through
489                        // to dispatch the call.
490                        let (taint, clearance) = decision
491                            .blocked_levels()
492                            .expect("a non-Allowed GateDecision is always Blocked");
493                        gate.record_allow(
494                            &state.agent_id,
495                            &c.name,
496                            taint,
497                            clearance,
498                            leviath_core::taint::GateDecisionSource::YoloAutoApprove,
499                        );
500                    } else {
501                        match (interactive, decision.blocked_levels()) {
502                            (Some(_), Some((taint, clearance))) => {
503                                pending_prompts.push((
504                                    c.tool_id.clone(),
505                                    c.name.clone(),
506                                    taint,
507                                    clearance,
508                                ));
509                            }
510                            _ => {
511                                context_results
512                                    .push((c.tool_id.clone(), taint_block_message(&decision)));
513                            }
514                        }
515                        continue;
516                    }
517                }
518            }
519            lane_calls.push(leviath_providers::ToolCall {
520                id: c.tool_id.clone(),
521                name: c.name.clone(),
522                arguments: c.arguments.clone(),
523                thought_signature: c.thought_signature.clone(),
524            });
525        }
526
527        // Commit a submitted output before any of the paths below can take an
528        // early exit, so an answer is recorded whether the rest of the batch
529        // dispatches, holds for a gate prompt, or turns out to be empty.
530        // Re-applying it on a gate-prompt re-run is harmless: the same
531        // submission produces the same component.
532        if let Some(output) = submitted {
533            commands
534                .entity(entity)
535                .insert(crate::persistence::FinalOutput(output));
536        }
537
538        // Hold the batch and ask the user about each blocked call.
539        if let (false, Some((hub, gate_stage))) = (pending_prompts.is_empty(), interactive) {
540            let n = pending_prompts.len();
541            for (tool_id, name, taint, clearance) in pending_prompts {
542                gate_stage
543                    .runtime
544                    .spawn(crate::gate_prompt::run_gate_prompt(
545                        crate::gate_prompt::GatedCall {
546                            entity,
547                            agent_id: state.agent_id.clone(),
548                            tool_id,
549                            tool_name: name,
550                            taint,
551                            clearance,
552                        },
553                        crate::interaction_hub::PromptLane {
554                            hub: (*hub).clone(),
555                            outcomes: gate_stage.outcomes.clone(),
556                            wake: gate_stage.wake.clone(),
557                        },
558                    ));
559            }
560            commands
561                .entity(entity)
562                .remove::<ReadyForTools>()
563                .insert(crate::gate_prompt::AwaitingGatePrompt(n))
564                .insert(crate::gate_prompt::GateResolved::default());
565            continue; // re-run after the prompts resolve
566        }
567
568        // Dispatching the batch consumes any resolution state from a prior round.
569        commands
570            .entity(entity)
571            .remove::<crate::gate_prompt::GateResolved>();
572
573        if lane_calls.is_empty() {
574            // Nothing async to run - apply the context results now and loop back.
575            let merged = merge_in_call_order(&result.tool_calls, &context_results);
576            apply_tool_results(
577                &mut window,
578                &result.response,
579                &result.tool_calls,
580                &merged,
581                routing.map(|c| &c.routing),
582                sensitivities.map(|s| &s.0),
583            );
584            commands
585                .entity(entity)
586                .remove::<ReadyForTools>()
587                .insert(ReadyToInfer);
588            continue;
589        }
590
591        // Journal the batch before it can run: a `ToolBatch` record with the
592        // dispatcher's inline results pre-filled and every lane call pending,
593        // plus a per-call progress hook that records each completion. Worlds
594        // without a persistence lane or run metadata (tests, unpersisted
595        // agents) dispatch unjournaled with a no-op progress.
596        let (progress, ack) = match (persist.as_ref(), metadata) {
597            (Some(persist), Some(md)) => {
598                let record = leviath_core::run_archive::RunRecord::ToolBatch {
599                    calls: result
600                        .tool_calls
601                        .iter()
602                        .map(|c| leviath_core::run_archive::ToolCallRecord {
603                            id: c.tool_id.clone(),
604                            name: c.name.clone(),
605                            arguments: c.arguments.to_string(),
606                            result: context_results
607                                .iter()
608                                .find(|(id, _)| id == &c.tool_id)
609                                .map(|(_, r)| r.clone()),
610                            thought_signature: c.thought_signature.clone(),
611                        })
612                        .collect(),
613                    at: chrono::Utc::now().timestamp(),
614                    stage_index: cursor.map_or(0, |c| c.index),
615                    iteration: state.iteration,
616                    response: result.response.clone(),
617                };
618                let (ack_tx, ack_rx) = tokio::sync::oneshot::channel();
619                let _ = persist.0.send(PersistMsg::Append {
620                    run_id: md.run_id.clone(),
621                    record: Box::new(record),
622                    ack: Some(ack_tx),
623                });
624                let sender = persist.0.clone();
625                let run_id = md.run_id.clone();
626                let iteration = state.iteration;
627                let progress: ToolProgress = Arc::new(move |call_id: &str, result: &str| {
628                    let _ = sender.send(PersistMsg::Append {
629                        run_id: run_id.clone(),
630                        record: Box::new(leviath_core::run_archive::RunRecord::ToolCallDone {
631                            iteration,
632                            call_id: call_id.to_string(),
633                            result: result.to_string(),
634                            at: chrono::Utc::now().timestamp(),
635                        }),
636                        ack: None,
637                    });
638                });
639                (progress, Some(ack_rx))
640            }
641            _ => (noop_progress(), None),
642        };
643        // Announce each lane-bound call before it starts executing. Inline
644        // results (context tools, refusals, blocks) never reach the lane and
645        // are deliberately not announced.
646        if let (Some(sink), Some(md)) = (sink.as_ref(), metadata) {
647            for call in &lane_calls {
648                let _ = sink.0.send(crate::host::WorldEvent::ToolCallStarted {
649                    run_id: md.run_id.clone(),
650                    agent_id: state.agent_id.clone(),
651                    call_id: call.id.clone(),
652                    tool: call.name.clone(),
653                });
654            }
655        }
656        let exec = service.0.exec_for(entity, lane_calls, progress);
657        let exec = match ack {
658            Some(ack) => barrier_then(exec, ack, BATCH_JOURNAL_ACK_TIMEOUT),
659            None => exec,
660        };
661        let cancel = crate::cancel::CancelToken::new();
662        // The lane is alive for the world's lifetime; a failed send would
663        // only happen during shutdown, where dropping the job is fine.
664        stage.stats.enqueued();
665        let _ = stage.jobs.send(ToolJob {
666            entity,
667            exec,
668            cancel: cancel.clone(),
669        });
670        track_in_flight(&mut commands, entity, in_flight, cancel);
671        commands
672            .entity(entity)
673            .remove::<ReadyForTools>()
674            .insert(AwaitingTools)
675            .insert(ContextToolResults(context_results));
676    }
677}