Skip to main content

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::ffi::OsString;
20use std::fmt::Write;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23use std::sync::atomic::{AtomicU64, Ordering};
24
25use crate::{PrimitiveToolName, Tool, ToolContext, ToolResult, ToolTier};
26use anyhow::{Context, Result};
27use serde::{Deserialize, Serialize};
28use serde_json::{Value, json};
29use tokio::sync::RwLock;
30
31/// Status of a TODO item.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum TodoStatus {
35    /// Task not yet started.
36    Pending,
37    /// Task currently being worked on.
38    InProgress,
39    /// Task finished successfully.
40    Completed,
41}
42
43impl TodoStatus {
44    /// Returns the icon for this status.
45    #[must_use]
46    pub const fn icon(&self) -> &'static str {
47        match self {
48            Self::Pending => "○",
49            Self::InProgress => "⚡",
50            Self::Completed => "✓",
51        }
52    }
53}
54
55/// A single TODO item.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct TodoItem {
58    /// Task description in imperative form (e.g., "Fix the bug").
59    pub content: String,
60    /// Current status of the task.
61    pub status: TodoStatus,
62    /// Present continuous form shown during execution (e.g., "Fixing the bug").
63    pub active_form: String,
64}
65
66impl TodoItem {
67    /// Creates a new pending TODO item.
68    #[must_use]
69    pub fn new(content: impl Into<String>, active_form: impl Into<String>) -> Self {
70        Self {
71            content: content.into(),
72            status: TodoStatus::Pending,
73            active_form: active_form.into(),
74        }
75    }
76
77    /// Creates a new TODO item with the given status.
78    #[must_use]
79    pub fn with_status(
80        content: impl Into<String>,
81        active_form: impl Into<String>,
82        status: TodoStatus,
83    ) -> Self {
84        Self {
85            content: content.into(),
86            status,
87            active_form: active_form.into(),
88        }
89    }
90
91    /// Returns the icon for this item's status.
92    #[must_use]
93    pub const fn icon(&self) -> &'static str {
94        self.status.icon()
95    }
96}
97
98/// Shared TODO state that can be persisted.
99#[derive(Debug, Default)]
100pub struct TodoState {
101    /// The list of TODO items.
102    pub items: Vec<TodoItem>,
103    /// Optional path for persistence.
104    storage_path: Option<PathBuf>,
105}
106
107impl TodoState {
108    /// Creates a new empty TODO state.
109    #[must_use]
110    pub const fn new() -> Self {
111        Self {
112            items: Vec::new(),
113            storage_path: None,
114        }
115    }
116
117    /// Creates a new TODO state with persistence.
118    #[must_use]
119    pub const fn with_storage(path: PathBuf) -> Self {
120        Self {
121            items: Vec::new(),
122            storage_path: Some(path),
123        }
124    }
125
126    /// Sets the storage path for persistence.
127    pub fn set_storage_path(&mut self, path: PathBuf) {
128        self.storage_path = Some(path);
129    }
130
131    /// Loads todos from storage if path is set.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if the file cannot be read or parsed.
136    pub async fn load(&mut self) -> Result<()> {
137        if let Some(ref path) = self.storage_path.as_ref().filter(|p| p.exists()) {
138            let content = tokio::fs::read_to_string(path)
139                .await
140                .context("Failed to read todos file")?;
141            self.items = serde_json::from_str(&content).context("Failed to parse todos file")?;
142        }
143        Ok(())
144    }
145
146    /// Saves todos to storage if path is set.
147    ///
148    /// The write is atomic: the JSON is written to a uniquely-named temp file
149    /// in the same directory and then renamed over the target (atomic on
150    /// POSIX). A crash or a cancelled future mid-write can therefore never
151    /// leave a truncated/invalid file in place, which [`load`](Self::load)
152    /// would otherwise fail to parse permanently.
153    ///
154    /// # Errors
155    ///
156    /// Returns an error if the file cannot be written or renamed into place.
157    pub async fn save(&self) -> Result<()> {
158        let Some(path) = self.storage_path.as_ref() else {
159            return Ok(());
160        };
161
162        // Ensure parent directory exists.
163        if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
164            tokio::fs::create_dir_all(parent)
165                .await
166                .context("Failed to create todos directory")?;
167        }
168
169        let content =
170            serde_json::to_string_pretty(&self.items).context("Failed to serialize todos")?;
171
172        let tmp_path = temp_sibling_path(path)?;
173        tokio::fs::write(&tmp_path, content)
174            .await
175            .context("Failed to write temp todos file")?;
176
177        if let Err(e) = tokio::fs::rename(&tmp_path, path).await {
178            // Best-effort cleanup so a failed rename doesn't leak the temp file.
179            let _ = tokio::fs::remove_file(&tmp_path).await;
180            return Err(e).context("Failed to atomically replace todos file");
181        }
182
183        Ok(())
184    }
185
186    /// Replaces the entire TODO list.
187    pub fn set_items(&mut self, items: Vec<TodoItem>) {
188        self.items = items;
189    }
190
191    /// Adds a new TODO item.
192    pub fn add(&mut self, item: TodoItem) {
193        self.items.push(item);
194    }
195
196    /// Returns the count of items by status.
197    #[must_use]
198    pub fn count_by_status(&self) -> (usize, usize, usize) {
199        let pending = self
200            .items
201            .iter()
202            .filter(|i| i.status == TodoStatus::Pending)
203            .count();
204        let in_progress = self
205            .items
206            .iter()
207            .filter(|i| i.status == TodoStatus::InProgress)
208            .count();
209        let completed = self
210            .items
211            .iter()
212            .filter(|i| i.status == TodoStatus::Completed)
213            .count();
214        (pending, in_progress, completed)
215    }
216
217    /// Returns the currently in-progress item, if any.
218    #[must_use]
219    pub fn current_task(&self) -> Option<&TodoItem> {
220        self.items
221            .iter()
222            .find(|i| i.status == TodoStatus::InProgress)
223    }
224
225    /// Formats the TODO list for display.
226    #[must_use]
227    pub fn format_display(&self) -> String {
228        if self.items.is_empty() {
229            return "No tasks".to_string();
230        }
231
232        let (_pending, in_progress, completed) = self.count_by_status();
233        let total = self.items.len();
234
235        let mut output = format!("TODO ({completed}/{total})");
236
237        if in_progress > 0
238            && let Some(current) = self.current_task()
239        {
240            let _ = write!(output, " - {}", current.active_form);
241        }
242
243        output.push('\n');
244
245        for item in &self.items {
246            let _ = writeln!(output, "  {} {}", item.icon(), item.content);
247        }
248
249        output
250    }
251
252    /// Returns true if there are no items.
253    #[must_use]
254    pub const fn is_empty(&self) -> bool {
255        self.items.is_empty()
256    }
257
258    /// Returns the number of items.
259    #[must_use]
260    pub const fn len(&self) -> usize {
261        self.items.len()
262    }
263}
264
265/// Builds a unique sibling path (same directory as `target`) for the
266/// write-then-rename in [`TodoState::save`]. Uniqueness combines the process
267/// id with a monotonic counter so concurrent saves never collide on the temp
268/// name.
269fn temp_sibling_path(target: &Path) -> Result<PathBuf> {
270    static COUNTER: AtomicU64 = AtomicU64::new(0);
271
272    let file_name = target
273        .file_name()
274        .context("storage path has no file name")?;
275    let nonce = COUNTER.fetch_add(1, Ordering::Relaxed);
276
277    let mut tmp_name = OsString::from(".");
278    tmp_name.push(file_name);
279    tmp_name.push(format!(".tmp.{}.{nonce}", std::process::id()));
280
281    Ok(
282        match target.parent().filter(|p| !p.as_os_str().is_empty()) {
283            Some(parent) => parent.join(tmp_name),
284            None => PathBuf::from(tmp_name),
285        },
286    )
287}
288
289/// Tool for writing/updating the TODO list.
290pub struct TodoWriteTool {
291    /// Shared TODO state.
292    state: Arc<RwLock<TodoState>>,
293}
294
295impl TodoWriteTool {
296    /// Creates a new `TodoWriteTool`.
297    #[must_use]
298    pub const fn new(state: Arc<RwLock<TodoState>>) -> Self {
299        Self { state }
300    }
301}
302
303/// Input for a single TODO item.
304#[derive(Debug, Deserialize)]
305struct TodoItemInput {
306    content: String,
307    status: TodoStatus,
308    #[serde(rename = "activeForm")]
309    active_form: String,
310}
311
312/// Input schema for `TodoWriteTool`.
313#[derive(Debug, Deserialize)]
314struct TodoWriteInput {
315    todos: Vec<TodoItemInput>,
316}
317
318impl<Ctx: Send + Sync + 'static> Tool<Ctx> for TodoWriteTool {
319    type Name = PrimitiveToolName;
320
321    fn name(&self) -> PrimitiveToolName {
322        PrimitiveToolName::TodoWrite
323    }
324
325    fn display_name(&self) -> &'static str {
326        "Update Tasks"
327    }
328
329    fn description(&self) -> &'static str {
330        "Update the TODO list to track tasks and show progress to the user. \
331         Use this tool frequently to plan complex tasks and mark progress. \
332         Each item needs 'content' (imperative form like 'Fix the bug'), \
333         'status' (pending/in_progress/completed), and 'activeForm' \
334         (present continuous like 'Fixing the bug'). \
335         Mark tasks completed immediately when done - don't batch completions."
336    }
337
338    fn input_schema(&self) -> Value {
339        json!({
340            "type": "object",
341            "required": ["todos"],
342            "properties": {
343                "todos": {
344                    "type": "array",
345                    "description": "The complete TODO list (replaces existing)",
346                    "items": {
347                        "type": "object",
348                        "required": ["content", "status", "activeForm"],
349                        "properties": {
350                            "content": {
351                                "type": "string",
352                                "description": "Task description in imperative form (e.g., 'Fix the bug')"
353                            },
354                            "status": {
355                                "type": "string",
356                                "enum": ["pending", "in_progress", "completed"],
357                                "description": "Current status of the task"
358                            },
359                            "activeForm": {
360                                "type": "string",
361                                "description": "Present continuous form shown during execution (e.g., 'Fixing the bug')"
362                            }
363                        }
364                    }
365                }
366            }
367        })
368    }
369
370    fn tier(&self) -> ToolTier {
371        ToolTier::Observe // No dangerous side effects
372    }
373
374    async fn execute(&self, _ctx: &ToolContext<Ctx>, input: Value) -> Result<ToolResult> {
375        let input: TodoWriteInput =
376            serde_json::from_value(input).context("Invalid input for todo_write")?;
377
378        let items: Vec<TodoItem> = input
379            .todos
380            .into_iter()
381            .map(|t| TodoItem {
382                content: t.content,
383                status: t.status,
384                active_form: t.active_form,
385            })
386            .collect();
387
388        // Update the shared state, then snapshot what needs persisting and
389        // drop the write guard *before* the (potentially slow) disk I/O. This
390        // keeps `TodoReadTool` unblocked during the save.
391        let mut state = self.state.write().await;
392        state.set_items(items);
393        let snapshot = TodoState {
394            items: state.items.clone(),
395            storage_path: state.storage_path.clone(),
396        };
397        let display = state.format_display();
398        drop(state);
399
400        if let Err(e) = snapshot.save().await {
401            log::warn!("Failed to persist todos: {e}");
402            return Ok(ToolResult::error(format!(
403                "TODO list updated in memory, but persisting to storage failed: {e}\n\n{display}"
404            )));
405        }
406
407        Ok(ToolResult::success(format!(
408            "TODO list updated.\n\n{display}"
409        )))
410    }
411}
412
413/// Tool for reading the current TODO list.
414pub struct TodoReadTool {
415    /// Shared TODO state.
416    state: Arc<RwLock<TodoState>>,
417}
418
419impl TodoReadTool {
420    /// Creates a new `TodoReadTool`.
421    #[must_use]
422    pub const fn new(state: Arc<RwLock<TodoState>>) -> Self {
423        Self { state }
424    }
425}
426
427impl<Ctx: Send + Sync + 'static> Tool<Ctx> for TodoReadTool {
428    type Name = PrimitiveToolName;
429
430    fn name(&self) -> PrimitiveToolName {
431        PrimitiveToolName::TodoRead
432    }
433
434    fn display_name(&self) -> &'static str {
435        "Read Tasks"
436    }
437
438    fn description(&self) -> &'static str {
439        "Read the current TODO list to see task status and progress."
440    }
441
442    fn input_schema(&self) -> Value {
443        json!({
444            "type": "object",
445            "properties": {}
446        })
447    }
448
449    fn tier(&self) -> ToolTier {
450        ToolTier::Observe
451    }
452
453    async fn execute(&self, _ctx: &ToolContext<Ctx>, _input: Value) -> Result<ToolResult> {
454        let display = {
455            let state = self.state.read().await;
456            state.format_display()
457        };
458
459        Ok(ToolResult::success(display))
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn test_todo_status_icons() {
469        assert_eq!(TodoStatus::Pending.icon(), "○");
470        assert_eq!(TodoStatus::InProgress.icon(), "⚡");
471        assert_eq!(TodoStatus::Completed.icon(), "✓");
472    }
473
474    #[test]
475    fn test_todo_item_new() {
476        let item = TodoItem::new("Fix the bug", "Fixing the bug");
477        assert_eq!(item.content, "Fix the bug");
478        assert_eq!(item.active_form, "Fixing the bug");
479        assert_eq!(item.status, TodoStatus::Pending);
480    }
481
482    #[test]
483    fn test_todo_state_count_by_status() {
484        let mut state = TodoState::new();
485        state.add(TodoItem::with_status(
486            "Task 1",
487            "Task 1",
488            TodoStatus::Pending,
489        ));
490        state.add(TodoItem::with_status(
491            "Task 2",
492            "Task 2",
493            TodoStatus::InProgress,
494        ));
495        state.add(TodoItem::with_status(
496            "Task 3",
497            "Task 3",
498            TodoStatus::Completed,
499        ));
500        state.add(TodoItem::with_status(
501            "Task 4",
502            "Task 4",
503            TodoStatus::Completed,
504        ));
505
506        let (pending, in_progress, completed) = state.count_by_status();
507        assert_eq!(pending, 1);
508        assert_eq!(in_progress, 1);
509        assert_eq!(completed, 2);
510    }
511
512    #[test]
513    fn test_todo_state_current_task() {
514        let mut state = TodoState::new();
515        state.add(TodoItem::with_status(
516            "Task 1",
517            "Task 1",
518            TodoStatus::Pending,
519        ));
520        assert!(state.current_task().is_none());
521
522        state.add(TodoItem::with_status(
523            "Task 2",
524            "Working on Task 2",
525            TodoStatus::InProgress,
526        ));
527        let current = state.current_task().unwrap();
528        assert_eq!(current.content, "Task 2");
529        assert_eq!(current.active_form, "Working on Task 2");
530    }
531
532    #[test]
533    fn test_todo_state_format_display() {
534        let mut state = TodoState::new();
535        assert_eq!(state.format_display(), "No tasks");
536
537        state.add(TodoItem::with_status(
538            "Fix bug",
539            "Fixing bug",
540            TodoStatus::InProgress,
541        ));
542        state.add(TodoItem::with_status(
543            "Write tests",
544            "Writing tests",
545            TodoStatus::Pending,
546        ));
547
548        let display = state.format_display();
549        assert!(display.contains("TODO (0/2)"));
550        assert!(display.contains("Fixing bug"));
551        assert!(display.contains("⚡ Fix bug"));
552        assert!(display.contains("○ Write tests"));
553    }
554
555    #[test]
556    fn test_todo_status_serde() {
557        let status = TodoStatus::InProgress;
558        let json = serde_json::to_string(&status).unwrap();
559        assert_eq!(json, "\"in_progress\"");
560
561        let parsed: TodoStatus = serde_json::from_str("\"completed\"").unwrap();
562        assert_eq!(parsed, TodoStatus::Completed);
563    }
564
565    #[tokio::test]
566    async fn save_then_load_round_trips_through_storage() -> Result<()> {
567        let dir = tempfile::tempdir().context("create temp dir")?;
568        let path = dir.path().join("todos.json");
569
570        let mut state = TodoState::with_storage(path.clone());
571        state.add(TodoItem::with_status(
572            "Fix bug",
573            "Fixing bug",
574            TodoStatus::InProgress,
575        ));
576        state.add(TodoItem::with_status(
577            "Write tests",
578            "Writing tests",
579            TodoStatus::Pending,
580        ));
581        state.save().await?;
582
583        assert!(path.exists(), "target file should exist after save");
584
585        // The atomic write must not leave its temp sibling behind.
586        let mut entries = tokio::fs::read_dir(dir.path()).await?;
587        while let Some(entry) = entries.next_entry().await? {
588            let name = entry.file_name();
589            let name = name.to_string_lossy();
590            assert!(!name.contains(".tmp."), "temp file leaked: {name}");
591        }
592
593        let mut loaded = TodoState::with_storage(path);
594        loaded.load().await?;
595        assert_eq!(loaded.len(), 2);
596        assert_eq!(loaded.items[0].content, "Fix bug");
597        assert_eq!(loaded.items[1].status, TodoStatus::Pending);
598
599        Ok(())
600    }
601
602    #[tokio::test]
603    async fn save_overwrites_existing_file_atomically() -> Result<()> {
604        let dir = tempfile::tempdir().context("create temp dir")?;
605        let path = dir.path().join("todos.json");
606
607        let mut first = TodoState::with_storage(path.clone());
608        first.add(TodoItem::new("Old task", "Doing old task"));
609        first.save().await?;
610
611        let mut second = TodoState::with_storage(path.clone());
612        second.add(TodoItem::new("New task", "Doing new task"));
613        second.save().await?;
614
615        let mut loaded = TodoState::with_storage(path);
616        loaded.load().await?;
617        assert_eq!(loaded.len(), 1);
618        assert_eq!(loaded.items[0].content, "New task");
619
620        Ok(())
621    }
622
623    #[tokio::test]
624    async fn save_is_noop_without_storage_path() -> Result<()> {
625        let mut state = TodoState::new();
626        state.add(TodoItem::new("Task", "Doing task"));
627        state.save().await?;
628        Ok(())
629    }
630}