Skip to main content

robit_agent/tool/
async_runner.rs

1//! Async tool execution: lets long-running tools return a pending placeholder
2//! and run their actual work in a background task.
3//!
4//! A tool that wants to run asynchronously (decided at runtime inside its
5//! `execute`) calls [`AsyncTaskRunner::submit`] with the real work as a future
6//! and returns a `ToolResult::pending(..)` placeholder. The runner spawns the
7//! work as an independent tokio task; when it finishes (or is cancelled) the
8//! result is sent back to the Agent via the `done` channel, which reinjects it
9//! into the conversation history and wakes the LLM.
10//!
11//! Cancellation is cooperative: each task owns a `CancellationToken`. When the
12//! Agent cancels a task (user `/cancel`, session expiry, or Agent shutdown),
13//! the spawned `select!` favors the cancellation branch and the work future is
14//! dropped (HTTP requests etc. are cancellation-safe).
15
16use std::future::Future;
17use std::pin::Pin;
18
19use futures_util::future::BoxFuture;
20use tokio::sync::mpsc;
21use tokio_util::sync::CancellationToken;
22
23use crate::event::SessionId;
24use crate::tool::ToolResult;
25
26/// A future produced by an async tool representing its background work.
27///
28/// Resolves to the final `ToolResult` that will be reinjected into the
29/// conversation when the task completes.
30pub type AsyncTaskWork = BoxFuture<'static, ToolResult>;
31
32/// Message delivered to the Agent when a background task finishes (success,
33/// failure, or cancellation).
34pub struct AsyncTaskDone {
35    pub task_id: String,
36    pub tool_call_id: String,
37    pub session_id: SessionId,
38    pub tool_name: String,
39    pub result: ToolResult,
40    /// `true` if the task was cancelled (the `cancel` token fired) rather than
41    /// completing on its own. Lets the Agent record `Cancelled` rather than
42    /// `Failed` status.
43    pub cancelled: bool,
44}
45
46/// Handle used by tools to submit background work.
47///
48/// Cheap to clone (just an `mpsc::Sender`); one is placed in every
49/// `ToolContext`. The matching `Receiver` lives on the `Agent`, which `select!`s
50/// on it alongside user input.
51#[derive(Clone)]
52pub struct AsyncTaskRunner {
53    done_tx: mpsc::Sender<AsyncTaskDone>,
54}
55
56impl AsyncTaskRunner {
57    /// Create a runner wired to the Agent's `done` channel.
58    pub fn new(done_tx: mpsc::Sender<AsyncTaskDone>) -> Self {
59        Self { done_tx }
60    }
61
62    /// Spawn `work` as a background task and return its `task_id` immediately.
63    ///
64    /// The caller (the tool's `execute`) should return a `ToolResult::pending`
65    /// placeholder using this `task_id`. When `work` resolves - or `cancel` is
66    /// triggered - the result is sent to the Agent for reinjection.
67    ///
68    /// If the Agent has exited, `done_tx.send` fails silently and the result is
69    /// dropped (side effects like saved files already happened).
70    pub fn submit(
71        &self,
72        tool_call_id: String,
73        session_id: SessionId,
74        tool_name: String,
75        work: AsyncTaskWork,
76        cancel: CancellationToken,
77    ) -> String {
78        let task_id = uuid::Uuid::new_v4().to_string();
79        let done_tx = self.done_tx.clone();
80        let tid = task_id.clone();
81        let cancelled_msg = format!("异步任务 {} 已取消", tid);
82
83        tokio::spawn(async move {
84            // Favor cancellation: if cancelled while still running, return a
85            // cancelled result instead of the work's outcome. Dropping `work`
86            // cancels any in-flight HTTP requests it holds.
87            let (result, cancelled) = tokio::select! {
88                biased;
89                _ = cancel.cancelled() => (ToolResult::error(cancelled_msg), true),
90                r = work => (r, false),
91            };
92
93            let done = AsyncTaskDone {
94                task_id: tid,
95                tool_call_id,
96                session_id,
97                tool_name,
98                result,
99                cancelled,
100            };
101            // Agent gone -> drop result. `send` is async but the channel is
102            // bounded; use a blocking-style await (spawned task can wait).
103            let _ = done_tx.send(done).await;
104        });
105
106        task_id
107    }
108}
109
110// Keep the raw `Pin<Box<dyn Future>>` alias referenced for documentation;
111// `BoxFuture` is the same type with a Send bound.
112#[allow(dead_code)]
113type _AnyFuture = Pin<Box<dyn Future<Output = ToolResult> + Send>>;
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use std::sync::Arc;
119    use std::sync::Mutex as StdMutex;
120    use tokio::sync::Mutex;
121
122    async fn recv_n(rx: &Mutex<mpsc::Receiver<AsyncTaskDone>>, n: usize) -> Vec<AsyncTaskDone> {
123        let mut out = Vec::new();
124        for _ in 0..n {
125            match rx.lock().await.recv().await {
126                Some(d) => out.push(d),
127                None => break,
128            }
129        }
130        out
131    }
132
133    #[tokio::test]
134    async fn submit_returns_completed_result() {
135        let (tx, rx) = mpsc::channel(8);
136        let runner = AsyncTaskRunner::new(tx);
137        let counter: Arc<StdMutex<u32>> = Arc::new(StdMutex::new(0));
138        let c = counter.clone();
139        let task_id = runner.submit(
140            "tc1".into(),
141            "sess".into(),
142            "test_tool".into(),
143            Box::pin(async move {
144                *c.lock().unwrap() += 1;
145                ToolResult::success("done")
146            }),
147            CancellationToken::new(),
148        );
149        assert!(!task_id.is_empty());
150
151        let results = recv_n(&Mutex::new(rx), 1).await;
152        assert_eq!(results.len(), 1);
153        assert_eq!(results[0].task_id, task_id);
154        assert!(!results[0].result.is_error);
155        assert_eq!(results[0].result.content, "done");
156        assert_eq!(*counter.lock().unwrap(), 1);
157    }
158
159    #[tokio::test]
160    async fn cancel_returns_cancelled_result() {
161        let (tx, rx) = mpsc::channel(8);
162        let runner = AsyncTaskRunner::new(tx);
163        let cancel = CancellationToken::new();
164        let started = Arc::new(tokio::sync::Notify::new());
165        let started2 = started.clone();
166        let task_id = runner.submit(
167            "tc2".into(),
168            "sess".into(),
169            "test_tool".into(),
170            Box::pin(async move {
171                started2.notify_one();
172                // Simulate long work that should be preempted by cancellation.
173                tokio::time::sleep(std::time::Duration::from_secs(60)).await;
174                ToolResult::success("should not reach")
175            }),
176            cancel.clone(),
177        );
178
179        started.notified().await;
180        cancel.cancel();
181
182        let results = recv_n(&Mutex::new(rx), 1).await;
183        assert_eq!(results.len(), 1);
184        assert_eq!(results[0].task_id, task_id);
185        assert!(results[0].result.is_error);
186        assert!(results[0].result.content.contains("已取消"));
187        assert!(results[0].cancelled);
188    }
189}