Skip to main content

telos_agent/tools/builtin/
todo_write.rs

1//! `TodoWrite` tool — lightweight in-session progress tracking.
2//!
3//! Replaces the entire task list atomically (replace-all semantics). Items have
4//! no IDs — the model keeps the full desired list. Three statuses: `pending`,
5//! `in_progress`, `completed`. Ephemeral — not persisted across sessions.
6//!
7//! Differs from the Task system (persistent, per-task CRUD with IDs and
8//! dependency tracking). This is a *session progress tracker* — simple,
9//! instantaneous, and heavily guided by the prompt.
10
11use async_trait::async_trait;
12use serde_json::{Value, json};
13use std::sync::{Arc, Mutex};
14
15use crate::error::AgentError;
16use crate::tools::api::{Tool, ToolContext, ToolDefinition, ToolOutput};
17
18/// In-memory store for the current todo list.
19#[derive(Debug, Clone, Default)]
20pub struct TodoList {
21    pub items: Vec<TodoItem>,
22}
23
24#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
25pub struct TodoItem {
26    pub content: String,
27    pub status: String,
28    #[serde(default, skip_serializing_if = "String::is_empty")]
29    pub active_form: String,
30}
31
32pub type SharedTodoList = Arc<Mutex<TodoList>>;
33
34/// `TodoWrite` — replace the todo list with the given items.
35pub struct TodoWriteTool {
36    todos: SharedTodoList,
37}
38
39impl TodoWriteTool {
40    pub fn new(todos: SharedTodoList) -> Self {
41        Self { todos }
42    }
43}
44
45#[async_trait]
46impl Tool for TodoWriteTool {
47    fn definition(&self) -> ToolDefinition {
48        ToolDefinition {
49            name: "TodoWrite".into(),
50            description: "Creates and manages a structured task list for tracking progress. Use for multi-step tasks (3+ steps), complex work, or when user provides multiple tasks. Skip for single straightforward tasks or conversational requests. \
51Rules: one item in_progress at a time; update status in real-time; mark in_progress before starting; always include active_form; mark completed only when fully done (no failing tests, no partial work)."
52                    .into(),
53            input_schema: json!({
54                "type": "object",
55                "properties": {
56                    "todos": {
57                        "type": "array",
58                        "description": "The complete todo list (replaces all existing items)",
59                        "items": {
60                            "type": "object",
61                            "properties": {
62                                "content": {
63                                    "type": "string",
64                                    "description": "The task content (imperative form, e.g. 'Implement login handler')"
65                                },
66                                "status": {
67                                    "type": "string",
68                                    "enum": ["pending", "in_progress", "completed"],
69                                    "description": "Current status of the task"
70                                },
71                                "active_form": {
72                                    "type": "string",
73                                    "description": "Present continuous form (e.g. 'Implementing login handler')"
74                                }
75                            },
76                            "required": ["content", "status", "active_form"]
77                        }
78                    }
79                },
80                "required": ["todos"]
81            }),
82        }
83    }
84
85    fn prompt_text(&self) -> Option<&'static str> {
86        Some(TODO_WRITE_PROMPT)
87    }
88
89    fn is_concurrency_safe(&self, _: &Value) -> bool {
90        true
91    }
92
93    async fn invoke(
94        &self,
95        arguments: Value,
96        _context: ToolContext,
97    ) -> Result<ToolOutput, AgentError> {
98        let new_items: Vec<TodoItem> = {
99            let arr = arguments
100                .get("todos")
101                .and_then(|v| v.as_array())
102                .ok_or_else(|| AgentError::Validation("missing `todos` array".into()))?;
103
104            arr.iter()
105                .map(|item| TodoItem {
106                    content: item.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string(),
107                    status: item
108                        .get("status")
109                        .and_then(|v| v.as_str())
110                        .unwrap_or("pending")
111                        .to_string(),
112                    active_form: item
113                        .get("active_form")
114                        .and_then(|v| v.as_str())
115                        .unwrap_or("")
116                        .to_string(),
117                })
118                .collect()
119        };
120
121        let old_items = {
122            let mut todos = self.todos.lock().unwrap();
123            std::mem::replace(&mut todos.items, new_items).clone()
124        };
125
126        let new_items = self.todos.lock().unwrap().items.clone();
127
128        // Auto-clear: if all items are completed, reset to empty
129        if !new_items.is_empty() && new_items.iter().all(|i| i.status == "completed") {
130            self.todos.lock().unwrap().items.clear();
131        }
132
133        // Build verification nudge if applicable
134        let mut extra = Vec::new();
135        let completed_count = new_items.iter().filter(|i| i.status == "completed").count();
136        if completed_count >= 3 {
137            let has_verify_mention =
138                new_items.iter().any(|i| i.content.to_lowercase().contains("verif"));
139            if !has_verify_mention {
140                extra.push("Consider spawning a Verify subagent to validate the completed work.");
141            }
142        }
143
144        let mut result = json!({
145            "old_todos": old_items,
146            "new_todos": new_items,
147            "updated": true,
148            "message": format!("Todo list updated: {} items", new_items.len())
149        });
150
151        if let Some(obj) = result.as_object_mut()
152            && !extra.is_empty()
153        {
154            obj.insert("hints".into(), json!(extra));
155        }
156
157        Ok(ToolOutput::json(result))
158    }
159}
160
161const TODO_WRITE_PROMPT: &str = r#"TodoWrite manages your session task list.
162
163**Rules:**
1641. Exactly ONE item `in_progress` at a time.
1652. Update status immediately after finishing — mark new work `in_progress` before starting.
1663. Always include `active_form` (present continuous) for each item.
1674. Only mark `completed` when fully done (no failing tests, no partial work).
1685. Remove stale items promptly. Merge new user instructions into the full list."#;