Skip to main content

robit_agent/tool/
query_task.rs

1//! `query_task` tool - inspect the status of async background tasks.
2//!
3//! Async tools (e.g. `generate_image`) return a pending placeholder and finish
4//! in the background. Their final result is reinjected into the conversation
5//! as a system notification when they complete, so the LLM normally doesn't
6//! need to poll. This tool exists for the cases where the LLM wants to check
7//! what is still running or re-read a task's final status (e.g. after several
8//! tasks were launched in parallel).
9//!
10//! The tool is stateless: it reads the per-Agent [`TaskRegistry`] supplied via
11//! [`ToolContext`], so a single shared instance works across every session.
12
13use async_trait::async_trait;
14use serde::Deserialize;
15use serde_json::{json, Value};
16
17use super::task_registry::{AsyncTaskRecord, AsyncTaskStatus, TaskRegistry};
18use super::{Tool, ToolContext, ToolResult};
19use crate::error::Result;
20
21#[derive(Debug, Deserialize)]
22struct QueryTaskArgs {
23    #[serde(default)]
24    task_id: Option<String>,
25}
26
27pub struct QueryTaskTool;
28
29impl QueryTaskTool {
30    pub fn new() -> Self {
31        Self
32    }
33}
34
35#[async_trait]
36impl Tool for QueryTaskTool {
37    fn name(&self) -> &str {
38        "query_task"
39    }
40
41    fn description(&self) -> &str {
42        "Query the status of async background tasks (e.g. image generation). \
43         Pass a `task_id` to inspect one task, or omit it to list all currently \
44         running (pending) tasks. Final results of completed tasks are already \
45         delivered to you as system-notification messages; use this tool only to \
46         check what is still in progress or to re-read a task's final status."
47    }
48
49    fn parameters_schema(&self) -> Value {
50        json!({
51            "type": "object",
52            "properties": {
53                "task_id": {
54                    "type": "string",
55                    "description": "Optional task id to inspect. Omit to list all pending tasks."
56                }
57            }
58        })
59    }
60
61    fn requires_confirmation(&self) -> bool {
62        false
63    }
64
65    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<ToolResult> {
66        let parsed: QueryTaskArgs = match serde_json::from_value(args) {
67            Ok(a) => a,
68            Err(e) => return Ok(ToolResult::error(format!("Argument parsing failed: {}", e))),
69        };
70
71        let value = match parsed.task_id {
72            Some(id) => match ctx.task_registry.get(&id) {
73                Some(record) => record_to_json(&record),
74                None => json!({
75                    "found": false,
76                    "task_id": id,
77                    "message": "No task with this id is tracked.",
78                }),
79            },
80            None => pending_list_json(ctx.task_registry.clone()),
81        };
82
83        let content = serde_json::to_string_pretty(&value)
84            .unwrap_or_else(|_| value.to_string());
85        Ok(ToolResult::success(content))
86    }
87}
88
89fn pending_list_json(registry: TaskRegistry) -> Value {
90    let pending = registry.list_pending();
91    json!({
92        "pending_count": pending.len(),
93        "pending": pending.iter().map(brief_json).collect::<Vec<_>>(),
94    })
95}
96
97fn brief_json(r: &AsyncTaskRecord) -> Value {
98    json!({
99        "task_id": r.task_id,
100        "tool": r.tool_name,
101        "status": r.status.as_str(),
102    })
103}
104
105fn record_to_json(r: &AsyncTaskRecord) -> Value {
106    json!({
107        "found": true,
108        "task_id": r.task_id,
109        "tool": r.tool_name,
110        "status": r.status.as_str(),
111        "result": r.result_summary,
112    })
113}
114
115// Keep AsyncTaskStatus referenced even if serde path changes.
116#[allow(dead_code)]
117fn _status_used(s: AsyncTaskStatus) -> &'static str {
118    s.as_str()
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::tool::task_registry::AsyncTaskRecord;
125    use std::time::Instant;
126
127    fn record(id: &str, status: AsyncTaskStatus, summary: Option<&str>) -> AsyncTaskRecord {
128        AsyncTaskRecord {
129            task_id: id.into(),
130            tool_name: "generate_image".into(),
131            tool_call_id: "tc".into(),
132            session_id: "sess".into(),
133            status,
134            started_at: Instant::now(),
135            result_summary: summary.map(String::from),
136        }
137    }
138
139    #[tokio::test]
140    async fn list_pending_empty_returns_count_zero() {
141        let tool = QueryTaskTool::new();
142        let registry = TaskRegistry::new();
143        let ctx = make_ctx(registry);
144        let res = tool
145            .execute(serde_json::json!({}), &ctx)
146            .await
147            .unwrap();
148        assert!(!res.is_error);
149        assert!(res.content.contains("\"pending_count\": 0"));
150    }
151
152    #[tokio::test]
153    async fn list_pending_shows_registered_tasks() {
154        let tool = QueryTaskTool::new();
155        let registry = TaskRegistry::new();
156        registry.register(record("t1", AsyncTaskStatus::Pending, None));
157        registry.register(record("t2", AsyncTaskStatus::Pending, None));
158        let ctx = make_ctx(registry);
159        let res = tool.execute(serde_json::json!({}), &ctx).await.unwrap();
160        assert!(res.content.contains("\"pending_count\": 2"));
161        assert!(res.content.contains("t1"));
162        assert!(res.content.contains("t2"));
163    }
164
165    #[tokio::test]
166    async fn query_specific_task_returns_summary() {
167        let tool = QueryTaskTool::new();
168        let registry = TaskRegistry::new();
169        registry.register(record(
170            "t9",
171            AsyncTaskStatus::Completed,
172            Some("saved 1 image to images/cat.png"),
173        ));
174        let ctx = make_ctx(registry);
175        let res = tool
176            .execute(serde_json::json!({ "task_id": "t9" }), &ctx)
177            .await
178            .unwrap();
179        assert!(res.content.contains("\"found\": true"));
180        assert!(res.content.contains("completed"));
181        assert!(res.content.contains("cat.png"));
182    }
183
184    #[tokio::test]
185    async fn query_unknown_task_reports_not_found() {
186        let tool = QueryTaskTool::new();
187        let ctx = make_ctx(TaskRegistry::new());
188        let res = tool
189            .execute(serde_json::json!({ "task_id": "nope" }), &ctx)
190            .await
191            .unwrap();
192        assert!(res.content.contains("\"found\": false"));
193    }
194
195    // Build a minimal ToolContext for testing. Only task_registry is read by
196    // this tool, so the other fields use placeholder values.
197    fn make_ctx(registry: TaskRegistry) -> ToolContext {
198        use std::path::PathBuf;
199        use std::sync::Arc;
200        use tokio::sync::mpsc;
201        use tokio_util::sync::CancellationToken;
202
203        // A frontend that accepts everything (this tool never calls it).
204        struct DummyFrontend;
205        #[async_trait]
206        impl crate::frontend::Frontend for DummyFrontend {
207            async fn on_event(&self, _: crate::event::AgentEvent) -> Result<()> {
208                Ok(())
209            }
210            async fn request_tool_confirmation(
211                &self,
212                _: &crate::tool::ToolCallInfo,
213            ) -> Result<bool> {
214                Ok(true)
215            }
216        }
217
218        let (done_tx, _done_rx) = mpsc::channel(1);
219        ToolContext {
220            working_dir: PathBuf::from("."),
221            session_id: "sess".to_string(),
222            tool_call_id: "tc".to_string(),
223            frontend: Arc::new(DummyFrontend),
224            extensions: std::collections::HashMap::new(),
225            supports_images: false,
226            async_runner: crate::tool::async_runner::AsyncTaskRunner::new(done_tx),
227            cancel_token: CancellationToken::new(),
228            task_registry: registry,
229        }
230    }
231}