telos_agent/tools/builtin/
tasks.rs1use 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
9pub 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
75pub 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
105pub 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
146pub 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
192pub 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
219pub 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}