Skip to main content

telos_agent/tools/builtin/
tasks.rs

1use async_trait::async_trait;
2use serde_json::{Value, json};
3use std::sync::Arc;
4
5use crate::error::AgentError;
6use crate::knowledge::tasks::{Task, TaskManager, TaskStatus};
7use crate::tools::api::{Tool, ToolContext, ToolDefinition, ToolOutput};
8
9// ── TaskCreate ──────────────────────────────────────────
10pub struct TaskCreateTool {
11    manager: Arc<TaskManager>,
12}
13impl TaskCreateTool {
14    pub fn new(m: Arc<TaskManager>) -> Self {
15        Self { manager: m }
16    }
17}
18#[async_trait]
19impl Tool for TaskCreateTool {
20    fn definition(&self) -> ToolDefinition {
21        ToolDefinition {
22            name: "TaskCreate".into(),
23            description: "Create a new task for tracking work.".into(),
24            input_schema: json!({"type":"object","properties":{"subject":{"type":"string"},"description":{"type":"string"},"blocked_by":{"type":"array","items":{"type":"string"},"default":[]}},"required":["subject","description"]}),
25        }
26    }
27    fn is_concurrency_safe(&self, _: &Value) -> bool {
28        true
29    }
30    async fn invoke(&self, args: Value, _: ToolContext) -> Result<ToolOutput, AgentError> {
31        let subject = args.get("subject").and_then(|v| v.as_str()).unwrap_or("");
32        let desc = args.get("description").and_then(|v| v.as_str()).unwrap_or("");
33        let blocked_by: Vec<String> = args
34            .get("blocked_by")
35            .and_then(|v| v.as_array())
36            .map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect())
37            .unwrap_or_default();
38        let id = uuid_v4();
39        let task = Task {
40            id: id.clone(),
41            subject: subject.into(),
42            description: desc.into(),
43            status: TaskStatus::Pending,
44            blocked_by,
45            blocks: vec![],
46            output: None,
47            kind: None,
48            agent_id: None,
49            agent_type: None,
50            worktree_path: None,
51            error: None,
52        };
53        self.manager.create(task);
54        Ok(ToolOutput::json(json!({"task_id": id, "status": "created"})))
55    }
56}
57
58fn uuid_v4() -> String {
59    use std::time::{SystemTime, UNIX_EPOCH};
60    let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default();
61    format!("task_{:x}", now.as_nanos())
62}
63
64fn status_str(status: &TaskStatus) -> &'static str {
65    match status {
66        TaskStatus::Pending => "pending",
67        TaskStatus::InProgress => "in_progress",
68        TaskStatus::Completed => "completed",
69        TaskStatus::Failed => "failed",
70        TaskStatus::Cancelled => "cancelled",
71        TaskStatus::Deleted => "deleted",
72    }
73}
74
75// ── TaskGet ─────────────────────────────────────────────
76pub struct TaskGetTool {
77    manager: Arc<TaskManager>,
78}
79impl TaskGetTool {
80    pub fn new(m: Arc<TaskManager>) -> Self {
81        Self { manager: m }
82    }
83}
84#[async_trait]
85impl Tool for TaskGetTool {
86    fn definition(&self) -> ToolDefinition {
87        ToolDefinition {
88            name: "TaskGet".into(),
89            description: "Get details of a task by ID.".into(),
90            input_schema: json!({"type":"object","properties":{"task_id":{"type":"string"}},"required":["task_id"]}),
91        }
92    }
93    fn is_concurrency_safe(&self, _: &Value) -> bool {
94        true
95    }
96    async fn invoke(&self, args: Value, _: ToolContext) -> Result<ToolOutput, AgentError> {
97        let id = args.get("task_id").and_then(|v| v.as_str()).unwrap_or("");
98        match self.manager.get(id) {
99            Some(task) => Ok(ToolOutput::json(serde_json::to_value(task).unwrap_or_default())),
100            None => Ok(ToolOutput::json(json!({"error": "task not found"}))),
101        }
102    }
103}
104
105// ── TaskOutput ──────────────────────────────────────────
106pub struct TaskOutputTool {
107    manager: Arc<TaskManager>,
108}
109impl TaskOutputTool {
110    pub fn new(m: Arc<TaskManager>) -> Self {
111        Self { manager: m }
112    }
113}
114#[async_trait]
115impl Tool for TaskOutputTool {
116    fn definition(&self) -> ToolDefinition {
117        ToolDefinition {
118            name: "task_output".into(),
119            description: "Read the current status and output for a background task.".into(),
120            input_schema: json!({"type":"object","properties":{"task_id":{"type":"string"}},"required":["task_id"]}),
121        }
122    }
123    fn is_concurrency_safe(&self, _: &Value) -> bool {
124        true
125    }
126    async fn invoke(&self, args: Value, _: ToolContext) -> Result<ToolOutput, AgentError> {
127        let id = args.get("task_id").and_then(|v| v.as_str()).unwrap_or("");
128        let Some(task) = self.manager.get(id) else {
129            return Ok(ToolOutput::json(json!({"task_id": id, "error": "task not found"})));
130        };
131        Ok(ToolOutput::json(json!({
132            "task_id": task.id,
133            "subject": task.subject,
134            "description": task.description,
135            "status": status_str(&task.status),
136            "output": task.output,
137            "kind": task.kind,
138            "agent_id": task.agent_id,
139            "agent_type": task.agent_type,
140            "worktree_path": task.worktree_path,
141            "error": task.error,
142        })))
143    }
144}
145
146// ── TaskStop ────────────────────────────────────────────
147pub struct TaskStopTool {
148    manager: Arc<TaskManager>,
149}
150impl TaskStopTool {
151    pub fn new(m: Arc<TaskManager>) -> Self {
152        Self { manager: m }
153    }
154}
155#[async_trait]
156impl Tool for TaskStopTool {
157    fn definition(&self) -> ToolDefinition {
158        ToolDefinition {
159            name: "task_stop".into(),
160            description: "Request cancellation for a running background task.".into(),
161            input_schema: json!({"type":"object","properties":{"task_id":{"type":"string"}},"required":["task_id"]}),
162        }
163    }
164    async fn invoke(&self, args: Value, _: ToolContext) -> Result<ToolOutput, AgentError> {
165        let id = args.get("task_id").and_then(|v| v.as_str()).unwrap_or("");
166        let Some(task) = self.manager.get(id) else {
167            return Ok(ToolOutput::json(json!({"task_id": id, "error": "task not found"})));
168        };
169        match task.status {
170            TaskStatus::Completed
171            | TaskStatus::Failed
172            | TaskStatus::Cancelled
173            | TaskStatus::Deleted => Ok(ToolOutput::json(json!({
174                "task_id": id,
175                "status": status_str(&task.status),
176                "stopped": false
177            }))),
178            TaskStatus::Pending | TaskStatus::InProgress => {
179                let cancellation_requested = self.manager.request_cancel(id);
180                self.manager.cancel(id, "stopped by user".into());
181                Ok(ToolOutput::json(json!({
182                    "task_id": id,
183                    "status": "cancelled",
184                    "stopped": true,
185                    "cancellation_requested": cancellation_requested
186                })))
187            }
188        }
189    }
190}
191
192// ── TaskList ────────────────────────────────────────────
193pub struct TaskListTool {
194    manager: Arc<TaskManager>,
195}
196impl TaskListTool {
197    pub fn new(m: Arc<TaskManager>) -> Self {
198        Self { manager: m }
199    }
200}
201#[async_trait]
202impl Tool for TaskListTool {
203    fn definition(&self) -> ToolDefinition {
204        ToolDefinition {
205            name: "TaskList".into(),
206            description: "List all tasks with status.".into(),
207            input_schema: json!({"type":"object","properties":{}}),
208        }
209    }
210    fn is_concurrency_safe(&self, _: &Value) -> bool {
211        true
212    }
213    async fn invoke(&self, _: Value, _: ToolContext) -> Result<ToolOutput, AgentError> {
214        let tasks = self.manager.list();
215        Ok(ToolOutput::json(serde_json::to_value(tasks).unwrap_or_default()))
216    }
217}
218
219// ── TaskUpdate ──────────────────────────────────────────
220pub struct TaskUpdateTool {
221    manager: Arc<TaskManager>,
222}
223impl TaskUpdateTool {
224    pub fn new(m: Arc<TaskManager>) -> Self {
225        Self { manager: m }
226    }
227}
228#[async_trait]
229impl Tool for TaskUpdateTool {
230    fn definition(&self) -> ToolDefinition {
231        ToolDefinition {
232            name: "TaskUpdate".into(),
233            description: "Update task status: pending, in_progress, completed, deleted.".into(),
234            input_schema: json!({"type":"object","properties":{"task_id":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","completed","deleted"]}},"required":["task_id","status"]}),
235        }
236    }
237    fn is_concurrency_safe(&self, _: &Value) -> bool {
238        true
239    }
240    async fn invoke(&self, args: Value, _: ToolContext) -> Result<ToolOutput, AgentError> {
241        let id = args.get("task_id").and_then(|v| v.as_str()).unwrap_or("");
242        let status_str = args.get("status").and_then(|v| v.as_str()).unwrap_or("pending");
243        let status = match status_str {
244            "pending" => TaskStatus::Pending,
245            "in_progress" => TaskStatus::InProgress,
246            "completed" => TaskStatus::Completed,
247            "failed" => TaskStatus::Failed,
248            "cancelled" => TaskStatus::Cancelled,
249            "deleted" => TaskStatus::Deleted,
250            _ => return Err(AgentError::Validation("invalid status".into())),
251        };
252        self.manager.update(id, status);
253        Ok(ToolOutput::json(json!({"task_id": id, "status": status_str, "updated": true})))
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use std::sync::Arc;
260
261    use serde_json::json;
262
263    use super::{TaskCreateTool, TaskGetTool, TaskUpdateTool};
264    use crate::knowledge::tasks::TaskManager;
265    use crate::tools::api::{Tool, ToolContext};
266
267    #[tokio::test]
268    async fn task_tools_persist_windows_path_like_fields_under_nested_temp_dir() {
269        let dir = tempfile::tempdir().unwrap();
270        let task_dir = dir.path().join("AppData").join("Local").join("Telos").join("tasks");
271        let manager = Arc::new(TaskManager::new(task_dir.clone()));
272        let create = TaskCreateTool::new(manager.clone());
273        let get = TaskGetTool::new(manager.clone());
274        let ctx = ToolContext::dummy();
275
276        let created = create
277            .invoke(
278                json!({
279                    "subject": "Windows path task",
280                    "description": r"Check C:\Users\alice\repo before continuing",
281                    "blocked_by": [r"%LOCALAPPDATA%\Telos\state.json"]
282                }),
283                ctx.clone(),
284            )
285            .await
286            .unwrap()
287            .content;
288        let task_id = created["task_id"].as_str().unwrap();
289
290        assert!(task_dir.join(format!("{task_id}.json")).exists());
291
292        let loaded = get.invoke(json!({"task_id": task_id}), ctx).await.unwrap().content;
293        assert_eq!(loaded["description"], r"Check C:\Users\alice\repo before continuing");
294        assert_eq!(loaded["blocked_by"][0], r"%LOCALAPPDATA%\Telos\state.json");
295
296        let reopened = TaskManager::new(task_dir);
297        let persisted = reopened.get(task_id).unwrap();
298        assert_eq!(persisted.blocked_by, vec![r"%LOCALAPPDATA%\Telos\state.json".to_string()]);
299    }
300
301    #[tokio::test]
302    async fn task_update_rejects_unknown_status() {
303        let dir = tempfile::tempdir().unwrap();
304        let manager = Arc::new(TaskManager::new(dir.path().to_path_buf()));
305        let update = TaskUpdateTool::new(manager);
306
307        let err = update
308            .invoke(json!({"task_id": "task_missing", "status": "blocked"}), ToolContext::dummy())
309            .await
310            .unwrap_err();
311
312        assert!(err.to_string().contains("invalid status"));
313    }
314}