Skip to main content

robit_agent/tool/
task_registry.rs

1//! Task registry: tracks the lifecycle of async tool tasks for status queries.
2//!
3//! The `Agent` owns a [`TaskRegistry`] (wrapped in `Arc`, cheap to clone) and
4//! injects a clone into every [`ToolContext`](super::ToolContext). The
5//! [`query_task`](super::query_task) tool reads it to report task status to the
6//! LLM. Because the registry is shared via `ToolContext` (not held by the tool
7//! instance), a single stateless `query_task` works across every Agent/session
8//! even though `ToolRegistry` itself is shared.
9//!
10//! Locking: a `std::sync::Mutex` guards the `HashMap`. All operations are pure
11//! HashMap reads/writes that never `.await`, so a blocking std mutex is safe
12//! and cheaper than a `tokio::sync::Mutex`. Callers must not hold the guard
13//! across `.await`.
14
15use std::collections::HashMap;
16use std::sync::{Arc, Mutex};
17use std::time::Instant;
18
19use crate::event::SessionId;
20
21/// Lifecycle state of an async task.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum AsyncTaskStatus {
24    /// Submitted, running in the background.
25    Pending,
26    /// Finished successfully (final result reinjected into history).
27    Completed,
28    /// Finished with an error result.
29    Failed,
30    /// Cancelled before completion.
31    Cancelled,
32}
33
34impl AsyncTaskStatus {
35    pub fn as_str(&self) -> &'static str {
36        match self {
37            AsyncTaskStatus::Pending => "pending",
38            AsyncTaskStatus::Completed => "completed",
39            AsyncTaskStatus::Failed => "failed",
40            AsyncTaskStatus::Cancelled => "cancelled",
41        }
42    }
43}
44
45/// A snapshot of one async task's metadata, used for status queries.
46#[derive(Debug, Clone)]
47pub struct AsyncTaskRecord {
48    pub task_id: String,
49    pub tool_name: String,
50    pub tool_call_id: String,
51    pub session_id: SessionId,
52    pub status: AsyncTaskStatus,
53    pub started_at: Instant,
54    /// Summary of the final result (truncated content), set on completion.
55    /// `None` while `Pending`.
56    pub result_summary: Option<String>,
57}
58
59/// Shared, thread-safe map of task_id -> record.
60///
61/// Cloning creates a new handle to the same underlying map.
62#[derive(Clone, Default)]
63pub struct TaskRegistry {
64    inner: Arc<Mutex<HashMap<String, AsyncTaskRecord>>>,
65}
66
67impl TaskRegistry {
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    /// Register a newly submitted task as `Pending`.
73    pub fn register(&self, record: AsyncTaskRecord) {
74        let mut g = self.inner.lock().expect("task registry lock poisoned");
75        g.insert(record.task_id.clone(), record);
76    }
77
78    /// Update a task's status (and optional result summary) on completion.
79    pub fn update(
80        &self,
81        task_id: &str,
82        status: AsyncTaskStatus,
83        result_summary: Option<String>,
84    ) {
85        let mut g = self.inner.lock().expect("task registry lock poisoned");
86        if let Some(r) = g.get_mut(task_id) {
87            r.status = status;
88            if result_summary.is_some() {
89                r.result_summary = result_summary;
90            }
91        }
92    }
93
94    /// Remove a task record (e.g. after it has been queried and aged out).
95    pub fn remove(&self, task_id: &str) {
96        let mut g = self.inner.lock().expect("task registry lock poisoned");
97        g.remove(task_id);
98    }
99
100    /// Look up a single task.
101    pub fn get(&self, task_id: &str) -> Option<AsyncTaskRecord> {
102        self.inner
103            .lock()
104            .expect("task registry lock poisoned")
105            .get(task_id)
106            .cloned()
107    }
108
109    /// All currently `Pending` tasks.
110    pub fn list_pending(&self) -> Vec<AsyncTaskRecord> {
111        let g = self.inner.lock().expect("task registry lock poisoned");
112        g.values()
113            .filter(|r| r.status == AsyncTaskStatus::Pending)
114            .cloned()
115            .collect()
116    }
117
118    /// All known tasks (any status).
119    pub fn list_all(&self) -> Vec<AsyncTaskRecord> {
120        let g = self.inner.lock().expect("task registry lock poisoned");
121        g.values().cloned().collect()
122    }
123
124    /// Number of tracked tasks.
125    pub fn len(&self) -> usize {
126        self.inner.lock().expect("task registry lock poisoned").len()
127    }
128
129    pub fn is_empty(&self) -> bool {
130        self.len() == 0
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    fn record(id: &str, status: AsyncTaskStatus) -> AsyncTaskRecord {
139        AsyncTaskRecord {
140            task_id: id.into(),
141            tool_name: "test_tool".into(),
142            tool_call_id: "tc".into(),
143            session_id: "sess".into(),
144            status,
145            started_at: Instant::now(),
146            result_summary: None,
147        }
148    }
149
150    #[test]
151    fn register_and_get() {
152        let reg = TaskRegistry::new();
153        reg.register(record("t1", AsyncTaskStatus::Pending));
154        assert_eq!(reg.len(), 1);
155        let r = reg.get("t1").unwrap();
156        assert_eq!(r.status, AsyncTaskStatus::Pending);
157    }
158
159    #[test]
160    fn update_changes_status_and_summary() {
161        let reg = TaskRegistry::new();
162        reg.register(record("t1", AsyncTaskStatus::Pending));
163        reg.update("t1", AsyncTaskStatus::Completed, Some("ok".into()));
164        let r = reg.get("t1").unwrap();
165        assert_eq!(r.status, AsyncTaskStatus::Completed);
166        assert_eq!(r.result_summary.as_deref(), Some("ok"));
167    }
168
169    #[test]
170    fn list_pending_filters_by_status() {
171        let reg = TaskRegistry::new();
172        reg.register(record("t1", AsyncTaskStatus::Pending));
173        reg.register(record("t2", AsyncTaskStatus::Completed));
174        reg.register(record("t3", AsyncTaskStatus::Pending));
175        let pending = reg.list_pending();
176        assert_eq!(pending.len(), 2);
177        assert!(pending.iter().all(|r| r.status == AsyncTaskStatus::Pending));
178    }
179
180    #[test]
181    fn remove_drops_record() {
182        let reg = TaskRegistry::new();
183        reg.register(record("t1", AsyncTaskStatus::Pending));
184        reg.remove("t1");
185        assert!(reg.get("t1").is_none());
186        assert!(reg.is_empty());
187    }
188
189    #[test]
190    fn clone_shares_state() {
191        let reg = TaskRegistry::new();
192        let clone = reg.clone();
193        reg.register(record("t1", AsyncTaskStatus::Pending));
194        // Clone sees the same record.
195        assert!(clone.get("t1").is_some());
196    }
197}