Skip to main content

leviath_runtime/
tool_bridge.rs

1//! The async worker side of the ECS tool stage - the sync-ECS ↔ async-I/O
2//! bridge for tool execution.
3//!
4//! When the pipeline decides an agent's response has tool calls to run, the
5//! tool-dispatch system builds a [`ToolJob`] (the agent plus a boxed async
6//! closure that executes that agent's batch of calls against its own tool
7//! registry / workdir / policy) and sends it to the tool lane. Each
8//! [`tool_worker`] pulls jobs and reports each [`ToolOutcome`] back on the
9//! results channel, waking the tick loop; the tool-collect system applies the
10//! results on a later tick.
11//!
12//! **Concurrency**: the lane is a *pool* - [`spawn_tool_pool`] runs N workers off
13//! one shared job channel (`Arc<Mutex<Receiver>>`). A worker holds the receiver
14//! lock only across `recv()`, then releases it and runs `exec().await`, so up to
15//! N agents' tool batches execute concurrently (the receive is serialized; the
16//! execution is not). This is the tool-lane counterpart of the inference pool's
17//! per-model concurrency cap. The dispatch/collect systems are unchanged.
18
19use std::future::Future;
20use std::pin::Pin;
21use std::sync::Arc;
22
23use bevy_ecs::entity::Entity;
24use tokio::runtime::Handle;
25use tokio::sync::Mutex;
26use tokio::sync::Notify;
27use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
28use tokio::task::JoinHandle;
29
30/// The future produced by a boxed tool-execution closure: resolves to
31/// `(tool_call_id, result)` pairs - the same shape the engine's tool executors
32/// already return.
33pub type ToolExecFuture = Pin<Box<dyn Future<Output = Vec<(String, String)>> + Send>>;
34
35/// A boxed, per-agent tool-execution closure. Built by the dispatch system so it
36/// captures that agent's own tool registry, workdir, and policy; run once by the
37/// tool worker.
38pub type BoxedToolExec = Box<dyn FnOnce() -> ToolExecFuture + Send>;
39
40/// A batch of tool calls to execute for one agent.
41pub struct ToolJob {
42    /// The agent the calls belong to.
43    pub entity: Entity,
44    /// Runs the agent's batch of tool calls.
45    pub exec: BoxedToolExec,
46    /// Fires when the agent is cancelled, so the worker drops the batch instead
47    /// of running it to completion. The agent holds the other half.
48    pub cancel: crate::cancel::CancelToken,
49}
50
51/// The result of a [`ToolJob`], applied on a later tick by the tool-collect
52/// system.
53pub struct ToolOutcome {
54    /// The agent the results belong to.
55    pub entity: Entity,
56    /// `(tool_call_id, result)` pairs.
57    pub results: Vec<(String, String)>,
58    /// Wall-clock time the whole batch took. Per-call timing would require
59    /// every executor to report it through `BoxedToolExec`'s return shape, so
60    /// each call in the batch shares this one figure.
61    pub elapsed: std::time::Duration,
62}
63
64/// A shared, multi-consumer job receiver: several [`tool_worker`]s pull from the
65/// same channel by taking the lock only long enough to `recv()`.
66pub type SharedJobRx = Arc<Mutex<UnboundedReceiver<ToolJob>>>;
67
68/// One tool worker: pulls [`ToolJob`]s from the shared channel and runs them,
69/// reporting each outcome and waking the tick loop. Holds the receiver lock only
70/// across `recv()` (so sibling workers can run their `exec().await` in parallel),
71/// then releases it before executing. Returns when the job channel is closed (all
72/// senders dropped - i.e. the world is shutting down).
73pub async fn tool_worker(
74    jobs: SharedJobRx,
75    results: UnboundedSender<ToolOutcome>,
76    wake: Arc<Notify>,
77) {
78    loop {
79        // Serialize only the receive; drop the guard before executing.
80        let next = {
81            let mut rx = jobs.lock().await;
82            rx.recv().await
83        };
84        let Some(ToolJob {
85            entity,
86            exec,
87            cancel,
88        }) = next
89        else {
90            return; // channel closed → shut down
91        };
92        // A cancelled agent's batch is dropped rather than run to completion.
93        // This is what returns the worker to the pool: several of the things a
94        // batch can await are unbounded (a tool-approval prompt, an `ask_user`,
95        // a `wait_for_agent` poll), so without this a cancelled agent kept one
96        // of the lane's fixed number of workers forever - and once they were all
97        // taken, no other agent's tools ran either.
98        let started = std::time::Instant::now();
99        let out = tokio::select! {
100            biased;
101            _ = cancel.cancelled() => continue,
102            out = exec() => out,
103        };
104        // Harmless no-op if the collect side has gone away.
105        let _ = results.send(ToolOutcome {
106            entity,
107            results: out,
108            elapsed: started.elapsed(),
109        });
110        wake.notify_one();
111    }
112}
113
114/// Spawn a pool of `workers` [`tool_worker`] tasks off one shared job channel and
115/// return their handles. `workers` is clamped to at least 1. This is the tool-lane
116/// concurrency cap - the number of agents whose tool batches may run at once.
117pub fn spawn_tool_pool(
118    runtime: &Handle,
119    jobs: UnboundedReceiver<ToolJob>,
120    results: UnboundedSender<ToolOutcome>,
121    wake: Arc<Notify>,
122    workers: usize,
123) -> Vec<JoinHandle<()>> {
124    let shared: SharedJobRx = Arc::new(Mutex::new(jobs));
125    (0..workers.max(1))
126        .map(|_| runtime.spawn(tool_worker(shared.clone(), results.clone(), wake.clone())))
127        .collect()
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use tokio::sync::mpsc;
134
135    fn job(entity: u32, pairs: Vec<(&'static str, &'static str)>) -> ToolJob {
136        job_with(entity, pairs, crate::cancel::CancelToken::new())
137    }
138
139    fn job_with(
140        entity: u32,
141        pairs: Vec<(&'static str, &'static str)>,
142        cancel: crate::cancel::CancelToken,
143    ) -> ToolJob {
144        ToolJob {
145            entity: Entity::from_raw_u32(entity).expect("index came from a live entity id"),
146            exec: Box::new(move || {
147                Box::pin(async move {
148                    pairs
149                        .into_iter()
150                        .map(|(a, b)| (a.to_string(), b.to_string()))
151                        .collect()
152                })
153            }),
154            cancel,
155        }
156    }
157
158    /// A job whose batch blocks until `release` fires, signalling `started` once
159    /// it is running. Used both for the cancel case (where it is dropped
160    /// mid-flight) and for the released case, so the batch body is exercised.
161    fn held_job(
162        entity: u32,
163        started: Arc<Notify>,
164        release: Arc<Notify>,
165        cancel: crate::cancel::CancelToken,
166    ) -> ToolJob {
167        ToolJob {
168            entity: Entity::from_raw_u32(entity).expect("index came from a live entity id"),
169            exec: Box::new(move || {
170                Box::pin(async move {
171                    // `notify_one` (not `notify_waiters`) on both signals: it
172                    // stores a permit when nobody is waiting yet, so neither the
173                    // test nor the batch can lose the other's wakeup by being
174                    // slow to arm - a real flake on a loaded runner.
175                    started.notify_one();
176                    release.notified().await;
177                    vec![("held".to_string(), "done".to_string())]
178                })
179            }),
180            cancel,
181        }
182    }
183
184    /// The released counterpart of [`held_job`]: with no cancel, the batch runs
185    /// to completion and reports its results.
186    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
187    async fn a_released_batch_completes_normally() {
188        let (jtx, jrx) = mpsc::unbounded_channel();
189        let (rtx, mut rrx) = mpsc::unbounded_channel();
190        let wake = Arc::new(Notify::new());
191        let started = Arc::new(Notify::new());
192        let release = Arc::new(Notify::new());
193
194        jtx.send(held_job(
195            7,
196            started.clone(),
197            release.clone(),
198            crate::cancel::CancelToken::new(),
199        ))
200        .unwrap();
201        drop(jtx);
202
203        let handles = spawn_tool_pool(&Handle::current(), jrx, rtx, wake, 1);
204        tokio::time::timeout(std::time::Duration::from_secs(5), started.notified())
205            .await
206            .expect("the batch started");
207        release.notify_one();
208
209        let out = tokio::time::timeout(std::time::Duration::from_secs(5), rrx.recv())
210            .await
211            .expect("the batch finished")
212            .expect("an outcome arrived");
213        assert_eq!(out.results, vec![("held".to_string(), "done".to_string())]);
214        for h in handles {
215            let _ = h.await;
216        }
217    }
218
219    fn shared(rx: UnboundedReceiver<ToolJob>) -> SharedJobRx {
220        Arc::new(Mutex::new(rx))
221    }
222
223    #[tokio::test]
224    async fn worker_processes_jobs_in_order_then_exits_on_close() {
225        let (jtx, jrx) = mpsc::unbounded_channel();
226        let (rtx, mut rrx) = mpsc::unbounded_channel();
227        let wake = Arc::new(Notify::new());
228
229        jtx.send(job(1, vec![("c1", "r1")])).unwrap();
230        jtx.send(job(2, vec![("c2", "r2")])).unwrap();
231        drop(jtx); // close the job channel so the worker loop ends
232
233        tool_worker(shared(jrx), rtx, wake).await;
234
235        let first = rrx.try_recv().unwrap();
236        assert_eq!(
237            first.entity,
238            Entity::from_raw_u32(1).expect("a small literal index is always a valid entity id")
239        );
240        assert_eq!(first.results, vec![("c1".to_string(), "r1".to_string())]);
241        let second = rrx.try_recv().unwrap();
242        assert_eq!(
243            second.entity,
244            Entity::from_raw_u32(2).expect("a small literal index is always a valid entity id")
245        );
246        assert!(rrx.try_recv().is_err()); // no more outcomes
247    }
248
249    /// A cancelled batch is dropped, not run to completion, and the worker goes
250    /// back to serving the lane.
251    ///
252    /// This is what unwedges the daemon: several things a batch can await are
253    /// unbounded (a tool-approval prompt, `ask_user`, a `wait_for_agent` poll),
254    /// and the lane has a fixed number of workers - so a cancelled agent used to
255    /// hold one forever, and once all of them were held no agent's tools ran.
256    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
257    async fn a_cancelled_batch_is_abandoned_and_frees_the_worker() {
258        let (jtx, jrx) = mpsc::unbounded_channel();
259        let (rtx, mut rrx) = mpsc::unbounded_channel();
260        let wake = Arc::new(Notify::new());
261        let cancel = crate::cancel::CancelToken::new();
262
263        // A batch that only finishes when released - the unbounded-prompt case.
264        let started = Arc::new(Notify::new());
265        let release = Arc::new(Notify::new());
266        jtx.send(held_job(
267            1,
268            started.clone(),
269            release.clone(),
270            cancel.clone(),
271        ))
272        .unwrap();
273        // Queued behind it: only reachable once the worker is released.
274        jtx.send(job(2, vec![("c2", "r2")])).unwrap();
275        drop(jtx);
276
277        let handles = spawn_tool_pool(&Handle::current(), jrx, rtx, wake, 1);
278        // Wait until the stuck batch is actually executing, then cancel it.
279        tokio::time::timeout(std::time::Duration::from_secs(5), started.notified())
280            .await
281            .expect("the batch started");
282        cancel.cancel();
283
284        // The single worker moves on to the next job rather than staying parked.
285        let next = tokio::time::timeout(std::time::Duration::from_secs(5), rrx.recv())
286            .await
287            .expect("the worker was freed by the cancel")
288            .expect("an outcome arrived");
289        assert_eq!(
290            next.entity,
291            Entity::from_raw_u32(2).expect("a small literal index is always a valid entity id"),
292            "the queued batch ran"
293        );
294        assert!(
295            rrx.try_recv().is_err(),
296            "and the cancelled batch reported no results"
297        );
298        for h in handles {
299            let _ = h.await;
300        }
301    }
302
303    #[tokio::test]
304    async fn worker_survives_dropped_results_receiver() {
305        let (jtx, jrx) = mpsc::unbounded_channel();
306        let (rtx, rrx) = mpsc::unbounded_channel();
307        drop(rrx); // nobody to receive outcomes
308        let wake = Arc::new(Notify::new());
309
310        jtx.send(job(9, vec![("c", "r")])).unwrap();
311        drop(jtx);
312        // Must drain the job and not panic despite the failed send.
313        tool_worker(shared(jrx), rtx, wake).await;
314    }
315
316    #[tokio::test(flavor = "multi_thread", worker_threads = 3)]
317    async fn pool_runs_batches_concurrently_and_all_exit_on_close() {
318        use std::sync::atomic::{AtomicUsize, Ordering};
319        use std::time::Duration;
320
321        let (jtx, jrx) = mpsc::unbounded_channel();
322        let (rtx, mut rrx) = mpsc::unbounded_channel();
323        let wake = Arc::new(Notify::new());
324
325        // Three jobs that each block on a barrier: they can only all finish if
326        // they run concurrently (a single-worker lane would deadlock the test's
327        // timeout). A shared counter + Notify serves as the rendezvous.
328        let arrived = Arc::new(AtomicUsize::new(0));
329        let go = Arc::new(Notify::new());
330        for i in 1..=3u32 {
331            let arrived = arrived.clone();
332            let go = go.clone();
333            jtx.send(ToolJob {
334                entity: Entity::from_raw_u32(i).expect("index came from a live entity id"),
335                exec: Box::new(move || {
336                    Box::pin(async move {
337                        if arrived.fetch_add(1, Ordering::SeqCst) + 1 == 3 {
338                            go.notify_waiters();
339                        }
340                        // Wait until all three have arrived (proving concurrency).
341                        while arrived.load(Ordering::SeqCst) < 3 {
342                            go.notified().await;
343                        }
344                        vec![("c".to_string(), "r".to_string())]
345                    })
346                }),
347                cancel: crate::cancel::CancelToken::new(),
348            })
349            .unwrap();
350        }
351        drop(jtx);
352
353        let handles = spawn_tool_pool(&Handle::current(), jrx, rtx, wake, 3);
354        // All three outcomes must arrive; bounded so a serialization bug fails fast.
355        for _ in 0..3 {
356            tokio::time::timeout(Duration::from_secs(5), rrx.recv())
357                .await
358                .expect("all batches complete concurrently")
359                .expect("outcome present");
360        }
361        for h in handles {
362            h.await.unwrap(); // every worker exits once the channel closes
363        }
364    }
365
366    #[tokio::test(flavor = "multi_thread")]
367    async fn spawn_tool_pool_clamps_zero_to_one_worker() {
368        let (jtx, jrx) = mpsc::unbounded_channel();
369        let (rtx, mut rrx) = mpsc::unbounded_channel();
370        let wake = Arc::new(Notify::new());
371        jtx.send(job(7, vec![("c", "r")])).unwrap();
372        drop(jtx);
373        let handles = spawn_tool_pool(&Handle::current(), jrx, rtx, wake, 0);
374        assert_eq!(handles.len(), 1); // clamped up to one worker
375        let out = rrx.recv().await.unwrap();
376        assert_eq!(
377            out.entity,
378            Entity::from_raw_u32(7).expect("a small literal index is always a valid entity id")
379        );
380        for h in handles {
381            h.await.unwrap();
382        }
383    }
384}