agent_sdk/
todo.rs

1//! TODO task tracking for agents.
2//!
3//! This module provides tools for agents to track tasks and show progress.
4//! Task tracking helps agents organize complex work and gives users visibility
5//! into what the agent is working on.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use agent_sdk::todo::{TodoState, TodoWriteTool, TodoReadTool};
11//! use std::sync::Arc;
12//! use tokio::sync::RwLock;
13//!
14//! let state = Arc::new(RwLock::new(TodoState::new()));
15//! let write_tool = TodoWriteTool::new(Arc::clone(&state));
16//! let read_tool = TodoReadTool::new(state);
17//! ```
18
19use std::fmt::Write;
20use std::path::PathBuf;
21use std::sync::Arc;
22
23use crate::{Tool, ToolContext, ToolResult, ToolTier};
24use anyhow::{Context, Result};
25use async_trait::async_trait;
26use serde::{Deserialize, Serialize};
27use serde_json::{Value, json};
28use tokio::sync::RwLock;
29
30/// Status of a TODO item.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum TodoStatus {
34    /// Task not yet started.
35    Pending,
36    /// Task currently being worked on.
37    InProgress,
38    /// Task finished successfully.
39    Completed,
40}
41
42impl TodoStatus {
43    /// Returns the icon for this status.
44    #[must_use]
45    pub const fn icon(&self) -> &'static str {
46        match self {
47            Self::Pending => "○",
48            Self::InProgress => "⚡",
49            Self::Completed => "✓",
50        }
51    }
52}
53
54/// A single TODO item.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct TodoItem {
57    /// Task description in imperative form (e.g., "Fix the bug").
58    pub content: String,
59    /// Current status of the task.
60    pub status: TodoStatus,
61    /// Present continuous form shown during execution (e.g., "Fixing the bug").
62    pub active_form: String,
63}
64
65impl TodoItem {
66    /// Creates a new pending TODO item.
67    #[must_use]
68    pub fn new(content: impl Into<String>, active_form: impl Into<String>) -> Self {
69        Self {
70            content: content.into(),
71            status: TodoStatus::Pending,
72            active_form: active_form.into(),
73        }
74    }
75
76    /// Creates a new TODO item with the given status.
77    #[must_use]
78    pub fn with_status(
79        content: impl Into<String>,
80        active_form: impl Into<String>,
81        status: TodoStatus,
82    ) -> Self {
83        Self {
84            content: content.into(),
85            status,
86            active_form: active_form.into(),
87        }
88    }
89
90    /// Returns the icon for this item's status.
91    #[must_use]
92    pub const fn icon(&self) -> &'static str {
93        self.status.icon()
94    }
95}
96
97/// Shared TODO state that can be persisted.
98#[derive(Debug, Default)]
99pub struct TodoState {
100    /// The list of TODO items.
101    pub items: Vec<TodoItem>,
102    /// Optional path for persistence.
103    storage_path: Option<PathBuf>,
104}
105
106impl TodoState {
107    /// Creates a new empty TODO state.
108    #[must_use]
109    pub const fn new() -> Self {
110        Self {
111            items: Vec::new(),
112            storage_path: None,
113        }
114    }
115
116    /// Creates a new TODO state with persistence.
117    #[must_use]
118    pub const fn with_storage(path: PathBuf) -> Self {
119        Self {
120            items: Vec::new(),
121            storage_path: Some(path),
122        }
123    }
124
125    /// Sets the storage path for persistence.
126    pub fn set_storage_path(&mut self, path: PathBuf) {
127        self.storage_path = Some(path);
128    }
129
130    /// Loads todos from storage if path is set.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error if the file cannot be read or parsed.
135    pub fn load(&mut self) -> Result<()> {
136        if let Some(ref path) = self.storage_path.as_ref().filter(|p| p.exists()) {
137            let content = std::fs::read_to_string(path).context("Failed to read todos file")?;
138            self.items = serde_json::from_str(&content).context("Failed to parse todos file")?;
139        }
140        Ok(())
141    }
142
143    /// Saves todos to storage if path is set.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the file cannot be written.
148    pub fn save(&self) -> Result<()> {
149        if let Some(ref path) = self.storage_path {
150            // Ensure parent directory exists
151            if let Some(parent) = path.parent() {
152                std::fs::create_dir_all(parent).context("Failed to create todos directory")?;
153            }
154            let content =
155                serde_json::to_string_pretty(&self.items).context("Failed to serialize todos")?;
156            std::fs::write(path, content).context("Failed to write todos file")?;
157        }
158        Ok(())
159    }
160
161    /// Replaces the entire TODO list.
162    pub fn set_items(&mut self, items: Vec<TodoItem>) {
163        self.items = items;
164    }
165
166    /// Adds a new TODO item.
167    pub fn add(&mut self, item: TodoItem) {
168        self.items.push(item);
169    }
170
171    /// Returns the count of items by status.
172    #[must_use]
173    pub fn count_by_status(&self) -> (usize, usize, usize) {
174        let pending = self
175            .items
176            .iter()
177            .filter(|i| i.status == TodoStatus::Pending)
178            .count();
179        let in_progress = self
180            .items
181            .iter()
182            .filter(|i| i.status == TodoStatus::InProgress)
183            .count();
184        let completed = self
185            .items
186            .iter()
187            .filter(|i| i.status == TodoStatus::Completed)
188            .count();
189        (pending, in_progress, completed)
190    }
191
192    /// Returns the currently in-progress item, if any.
193    #[must_use]
194    pub fn current_task(&self) -> Option<&TodoItem> {
195        self.items
196            .iter()
197            .find(|i| i.status == TodoStatus::InProgress)
198    }
199
200    /// Formats the TODO list for display.
201    #[must_use]
202    pub fn format_display(&self) -> String {
203        if self.items.is_empty() {
204            return "No tasks".to_string();
205        }
206
207        let (_pending, in_progress, completed) = self.count_by_status();
208        let total = self.items.len();
209
210        let mut output = format!("TODO ({completed}/{total})");
211
212        if in_progress > 0
213            && let Some(current) = self.current_task()
214        {
215            let _ = write!(output, " - {}", current.active_form);
216        }
217
218        output.push('\n');
219
220        for item in &self.items {
221            let _ = writeln!(output, "  {} {}", item.icon(), item.content);
222        }
223
224        output
225    }
226
227    /// Returns true if there are no items.
228    #[must_use]
229    pub const fn is_empty(&self) -> bool {
230        self.items.is_empty()
231    }
232
233    /// Returns the number of items.
234    #[must_use]
235    pub const fn len(&self) -> usize {
236        self.items.len()
237    }
238}
239
240/// Tool for writing/updating the TODO list.
241pub struct TodoWriteTool {
242    /// Shared TODO state.
243    state: Arc<RwLock<TodoState>>,
244}
245
246impl TodoWriteTool {
247    /// Creates a new `TodoWriteTool`.
248    #[must_use]
249    pub const fn new(state: Arc<RwLock<TodoState>>) -> Self {
250        Self { state }
251    }
252}
253
254/// Input for a single TODO item.
255#[derive(Debug, Deserialize)]
256struct TodoItemInput {
257    content: String,
258    status: TodoStatus,
259    #[serde(rename = "activeForm")]
260    active_form: String,
261}
262
263/// Input schema for `TodoWriteTool`.
264#[derive(Debug, Deserialize)]
265struct TodoWriteInput {
266    todos: Vec<TodoItemInput>,
267}
268
269#[async_trait]
270impl<Ctx: Send + Sync + 'static> Tool<Ctx> for TodoWriteTool {
271    fn name(&self) -> &'static str {
272        "todo_write"
273    }
274
275    fn description(&self) -> &'static str {
276        "Update the TODO list to track tasks and show progress to the user. \
277         Use this tool frequently to plan complex tasks and mark progress. \
278         Each item needs 'content' (imperative form like 'Fix the bug'), \
279         'status' (pending/in_progress/completed), and 'activeForm' \
280         (present continuous like 'Fixing the bug'). \
281         Mark tasks completed immediately when done - don't batch completions."
282    }
283
284    fn input_schema(&self) -> Value {
285        json!({
286            "type": "object",
287            "required": ["todos"],
288            "properties": {
289                "todos": {
290                    "type": "array",
291                    "description": "The complete TODO list (replaces existing)",
292                    "items": {
293                        "type": "object",
294                        "required": ["content", "status", "activeForm"],
295                        "properties": {
296                            "content": {
297                                "type": "string",
298                                "description": "Task description in imperative form (e.g., 'Fix the bug')"
299                            },
300                            "status": {
301                                "type": "string",
302                                "enum": ["pending", "in_progress", "completed"],
303                                "description": "Current status of the task"
304                            },
305                            "activeForm": {
306                                "type": "string",
307                                "description": "Present continuous form shown during execution (e.g., 'Fixing the bug')"
308                            }
309                        }
310                    }
311                }
312            }
313        })
314    }
315
316    fn tier(&self) -> ToolTier {
317        ToolTier::Observe // No dangerous side effects
318    }
319
320    async fn execute(&self, _ctx: &ToolContext<Ctx>, input: Value) -> Result<ToolResult> {
321        let input: TodoWriteInput =
322            serde_json::from_value(input).context("Invalid input for todo_write")?;
323
324        let items: Vec<TodoItem> = input
325            .todos
326            .into_iter()
327            .map(|t| TodoItem {
328                content: t.content,
329                status: t.status,
330                active_form: t.active_form,
331            })
332            .collect();
333
334        let display = {
335            let mut state = self.state.write().await;
336            state.set_items(items);
337
338            // Save to storage if configured
339            if let Err(e) = state.save() {
340                tracing::warn!("Failed to save todos: {e}");
341            }
342
343            state.format_display()
344        };
345
346        Ok(ToolResult::success(format!(
347            "TODO list updated.\n\n{display}"
348        )))
349    }
350}
351
352/// Tool for reading the current TODO list.
353pub struct TodoReadTool {
354    /// Shared TODO state.
355    state: Arc<RwLock<TodoState>>,
356}
357
358impl TodoReadTool {
359    /// Creates a new `TodoReadTool`.
360    #[must_use]
361    pub const fn new(state: Arc<RwLock<TodoState>>) -> Self {
362        Self { state }
363    }
364}
365
366#[async_trait]
367impl<Ctx: Send + Sync + 'static> Tool<Ctx> for TodoReadTool {
368    fn name(&self) -> &'static str {
369        "todo_read"
370    }
371
372    fn description(&self) -> &'static str {
373        "Read the current TODO list to see task status and progress."
374    }
375
376    fn input_schema(&self) -> Value {
377        json!({
378            "type": "object",
379            "properties": {}
380        })
381    }
382
383    fn tier(&self) -> ToolTier {
384        ToolTier::Observe
385    }
386
387    async fn execute(&self, _ctx: &ToolContext<Ctx>, _input: Value) -> Result<ToolResult> {
388        let display = {
389            let state = self.state.read().await;
390            state.format_display()
391        };
392
393        Ok(ToolResult::success(display))
394    }
395}
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn test_todo_status_icons() {
403        assert_eq!(TodoStatus::Pending.icon(), "○");
404        assert_eq!(TodoStatus::InProgress.icon(), "⚡");
405        assert_eq!(TodoStatus::Completed.icon(), "✓");
406    }
407
408    #[test]
409    fn test_todo_item_new() {
410        let item = TodoItem::new("Fix the bug", "Fixing the bug");
411        assert_eq!(item.content, "Fix the bug");
412        assert_eq!(item.active_form, "Fixing the bug");
413        assert_eq!(item.status, TodoStatus::Pending);
414    }
415
416    #[test]
417    fn test_todo_state_count_by_status() {
418        let mut state = TodoState::new();
419        state.add(TodoItem::with_status(
420            "Task 1",
421            "Task 1",
422            TodoStatus::Pending,
423        ));
424        state.add(TodoItem::with_status(
425            "Task 2",
426            "Task 2",
427            TodoStatus::InProgress,
428        ));
429        state.add(TodoItem::with_status(
430            "Task 3",
431            "Task 3",
432            TodoStatus::Completed,
433        ));
434        state.add(TodoItem::with_status(
435            "Task 4",
436            "Task 4",
437            TodoStatus::Completed,
438        ));
439
440        let (pending, in_progress, completed) = state.count_by_status();
441        assert_eq!(pending, 1);
442        assert_eq!(in_progress, 1);
443        assert_eq!(completed, 2);
444    }
445
446    #[test]
447    fn test_todo_state_current_task() {
448        let mut state = TodoState::new();
449        state.add(TodoItem::with_status(
450            "Task 1",
451            "Task 1",
452            TodoStatus::Pending,
453        ));
454        assert!(state.current_task().is_none());
455
456        state.add(TodoItem::with_status(
457            "Task 2",
458            "Working on Task 2",
459            TodoStatus::InProgress,
460        ));
461        let current = state.current_task().unwrap();
462        assert_eq!(current.content, "Task 2");
463        assert_eq!(current.active_form, "Working on Task 2");
464    }
465
466    #[test]
467    fn test_todo_state_format_display() {
468        let mut state = TodoState::new();
469        assert_eq!(state.format_display(), "No tasks");
470
471        state.add(TodoItem::with_status(
472            "Fix bug",
473            "Fixing bug",
474            TodoStatus::InProgress,
475        ));
476        state.add(TodoItem::with_status(
477            "Write tests",
478            "Writing tests",
479            TodoStatus::Pending,
480        ));
481
482        let display = state.format_display();
483        assert!(display.contains("TODO (0/2)"));
484        assert!(display.contains("Fixing bug"));
485        assert!(display.contains("⚡ Fix bug"));
486        assert!(display.contains("○ Write tests"));
487    }
488
489    #[test]
490    fn test_todo_status_serde() {
491        let status = TodoStatus::InProgress;
492        let json = serde_json::to_string(&status).unwrap();
493        assert_eq!(json, "\"in_progress\"");
494
495        let parsed: TodoStatus = serde_json::from_str("\"completed\"").unwrap();
496        assert_eq!(parsed, TodoStatus::Completed);
497    }
498}