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}