Skip to main content

recursive/tools/
todo.rs

1//! `todo_write` tool — agent task-list management.
2//!
3//! Allows the agent to maintain a structured checklist for complex tasks.
4//! State is kept in a shared `Arc<RwLock<Vec<TodoItem>>>` that can be
5//! read back via [`AgentRuntime::current_todos`].
6
7use std::sync::{Arc, RwLock};
8
9use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11use serde_json::{json, Value};
12
13use crate::error::{Error, Result};
14use crate::event::{AgentEvent, EventSink};
15use crate::llm::ToolSpec;
16use crate::tools::{Tool, ToolSideEffect};
17
18// ---------------------------------------------------------------------------
19// Data types
20// ---------------------------------------------------------------------------
21
22/// Status of a single task in the agent's to-do list.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24#[serde(rename_all = "snake_case")]
25pub enum TodoStatus {
26    Pending,
27    InProgress,
28    Completed,
29    Cancelled,
30}
31
32/// A single task item in the agent's to-do list.
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct TodoItem {
35    /// Task description in imperative form, e.g. "Run tests".
36    pub content: String,
37    /// Current status of this task.
38    pub status: TodoStatus,
39    /// Optional present-continuous form shown while `in_progress`,
40    /// e.g. "Running tests". Falls back to `content` when absent.
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub active_form: Option<String>,
43}
44
45// ---------------------------------------------------------------------------
46// TodoWriteTool
47// ---------------------------------------------------------------------------
48
49/// Tool that lets the agent create and update a structured task checklist.
50///
51/// Holds a shared reference to the task list so the runtime can read it
52/// back via [`AgentRuntime::current_todos`]. Emits
53/// [`AgentEvent::TodoUpdated`] via the supplied `event_sink` on every
54/// successful write so downstream consumers (TUI, HTTP API, …) can react
55/// without polling.
56pub struct TodoWriteTool {
57    todo_list: Arc<RwLock<Vec<TodoItem>>>,
58    event_sink: Arc<dyn EventSink>,
59}
60
61impl TodoWriteTool {
62    /// Create a new `TodoWriteTool`.
63    ///
64    /// * `todo_list` — shared state; pass the same `Arc` to
65    ///   [`AgentRuntime`] so `current_todos()` returns up-to-date data.
66    /// * `event_sink` — receives [`AgentEvent::TodoUpdated`] on every
67    ///   successful write. Pass [`crate::event::NullSink`] to suppress.
68    pub fn new(todo_list: Arc<RwLock<Vec<TodoItem>>>, event_sink: Arc<dyn EventSink>) -> Self {
69        Self {
70            todo_list,
71            event_sink,
72        }
73    }
74}
75
76#[async_trait]
77impl Tool for TodoWriteTool {
78    fn spec(&self) -> ToolSpec {
79        ToolSpec {
80            name: "TodoWrite".into(),
81            description: "Create and manage a structured task list for the current session. \
82                Use it proactively for tasks with 3 or more distinct steps; update status \
83                in real time as you work.\n\n\
84                When to use:\n\
85                - Create the list BEFORE starting work (capture requirements as todos)\n\
86                - Mark exactly ONE task as in_progress at a time\n\
87                - Mark completed IMMEDIATELY after finishing (not batched)\n\
88                - ONLY mark completed when fully done (tests passing, no partial work)\n\
89                - Clear the list (call with an empty array) when all tasks are done\n\n\
90                When NOT to use:\n\
91                - Single, straightforward tasks\n\
92                - Purely conversational responses\n\
93                - Tasks completable in less than 3 trivial steps\n\n\
94                Do not use this tool for a single trivial task — just do the task directly."
95                .into(),
96            parameters: json!({
97                "type": "object",
98                "required": ["todos"],
99                "properties": {
100                    "todos": {
101                        "type": "array",
102                        "description": "Complete replacement for the task list. \
103                            Pass an empty array to clear all tasks.",
104                        "items": {
105                            "type": "object",
106                            "required": ["content", "status"],
107                            "properties": {
108                                "content": {
109                                    "type": "string",
110                                    "description": "Task description in imperative form, e.g. \"Run tests\"."
111                                },
112                                "status": {
113                                    "type": "string",
114                                    "enum": ["pending", "in_progress", "completed", "cancelled"],
115                                    "description": "Current status of this task."
116                                },
117                                "active_form": {
118                                    "type": "string",
119                                    "description": "Optional present-continuous label shown while in_progress, \
120                                        e.g. \"Running tests\"."
121                                }
122                            }
123                        }
124                    }
125                }
126            }),
127        }
128    }
129
130    fn is_deferred(&self) -> bool {
131        true
132    }
133
134    async fn execute(&self, arguments: Value) -> Result<String> {
135        // Parse the todos array.
136        let raw_todos = arguments.get("todos").ok_or_else(|| Error::BadToolArgs {
137            name: "TodoWrite".into(),
138            message: "missing required field `todos`".into(),
139        })?;
140
141        let items: Vec<TodoItem> =
142            serde_json::from_value(raw_todos.clone()).map_err(|e| Error::BadToolArgs {
143                name: "TodoWrite".into(),
144                message: format!("failed to parse `todos`: {e}"),
145            })?;
146
147        // Validate: at most one item may have status `in_progress`.
148        let in_progress_count = items
149            .iter()
150            .filter(|t| t.status == TodoStatus::InProgress)
151            .count();
152        if in_progress_count > 1 {
153            return Err(Error::BadToolArgs {
154                name: "TodoWrite".into(),
155                message: format!(
156                    "at most one task may have status `in_progress`; found {in_progress_count}"
157                ),
158            });
159        }
160
161        // Compute summary stats before we move `items`.
162        let count = items.len();
163        let in_progress_label = items
164            .iter()
165            .find(|t| t.status == TodoStatus::InProgress)
166            .map(|t| t.active_form.clone().unwrap_or_else(|| t.content.clone()));
167        let remaining = items
168            .iter()
169            .filter(|t| matches!(t.status, TodoStatus::Pending | TodoStatus::InProgress))
170            .count();
171
172        // Commit to shared state.
173        {
174            let mut list = self.todo_list.write().map_err(|_| Error::Tool {
175                name: "TodoWrite".into(),
176                call_id: None,
177                message: "todo list lock poisoned".into(),
178            })?;
179            *list = items.clone();
180        }
181
182        // Emit event so TUI / HTTP consumers can react.
183        self.event_sink
184            .emit(AgentEvent::TodoUpdated {
185                todos: items.clone(),
186            })
187            .await;
188
189        // Return a JSON summary; include the full list so the TUI backend
190        // can reconstruct state by parsing this output.
191        let result = json!({
192            "updated": true,
193            "count": count,
194            "in_progress": in_progress_label,
195            "remaining": remaining,
196            "todos": items,
197        });
198        Ok(result.to_string())
199    }
200
201    fn side_effect_class(&self) -> ToolSideEffect {
202        // Mutates in-memory state only — safe to re-apply on resume.
203        ToolSideEffect::Mutating
204    }
205
206    fn is_readonly(&self) -> bool {
207        false
208    }
209}
210
211// ---------------------------------------------------------------------------
212// Tests
213// ---------------------------------------------------------------------------
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::event::NullSink;
219
220    fn make_tool() -> (TodoWriteTool, Arc<RwLock<Vec<TodoItem>>>) {
221        let list = Arc::new(RwLock::new(vec![]));
222        let tool = TodoWriteTool::new(list.clone(), Arc::new(NullSink));
223        (tool, list)
224    }
225
226    #[tokio::test]
227    async fn basic_write_and_read() {
228        let (tool, list) = make_tool();
229        let args = json!({
230            "todos": [
231                {"content": "Step 1", "status": "pending"},
232                {"content": "Step 2", "status": "in_progress"},
233            ]
234        });
235        let result = tool.execute(args).await.unwrap();
236        let parsed: Value = serde_json::from_str(&result).unwrap();
237        assert_eq!(parsed["count"], 2);
238        assert_eq!(parsed["remaining"], 2);
239        assert_eq!(parsed["in_progress"], "Step 2");
240        assert_eq!(parsed["updated"], true);
241
242        let stored = list.read().unwrap();
243        assert_eq!(stored.len(), 2);
244        assert_eq!(stored[0].content, "Step 1");
245    }
246
247    #[tokio::test]
248    async fn rejects_multiple_in_progress() {
249        let (tool, _) = make_tool();
250        let args = json!({
251            "todos": [
252                {"content": "A", "status": "in_progress"},
253                {"content": "B", "status": "in_progress"},
254            ]
255        });
256        let err = tool.execute(args).await.unwrap_err();
257        assert!(err.to_string().contains("at most one task"));
258    }
259
260    #[tokio::test]
261    async fn clears_list_with_empty_array() {
262        let (tool, list) = make_tool();
263        // Seed with one item.
264        let _ = tool
265            .execute(json!({"todos": [{"content": "X", "status": "pending"}]}))
266            .await
267            .unwrap();
268        assert_eq!(list.read().unwrap().len(), 1);
269
270        // Clear.
271        let result = tool.execute(json!({"todos": []})).await.unwrap();
272        let parsed: Value = serde_json::from_str(&result).unwrap();
273        assert_eq!(parsed["count"], 0);
274        assert!(list.read().unwrap().is_empty());
275    }
276
277    #[tokio::test]
278    async fn active_form_used_when_present() {
279        let (tool, _) = make_tool();
280        let args = json!({
281            "todos": [
282                {
283                    "content": "Run tests",
284                    "status": "in_progress",
285                    "active_form": "Running tests"
286                }
287            ]
288        });
289        let result = tool.execute(args).await.unwrap();
290        let parsed: Value = serde_json::from_str(&result).unwrap();
291        assert_eq!(parsed["in_progress"], "Running tests");
292    }
293
294    #[test]
295    fn side_effect_is_mutating() {
296        let (tool, _) = make_tool();
297        assert_eq!(tool.side_effect_class(), ToolSideEffect::Mutating);
298        assert!(!tool.is_readonly());
299    }
300
301    #[test]
302    fn is_deferred_returns_true() {
303        // kills `replace TodoWriteTool::is_deferred -> bool with false`
304        let (tool, _) = make_tool();
305        assert!(tool.is_deferred(), "TodoWriteTool must be a deferred tool");
306    }
307
308    #[tokio::test]
309    async fn remaining_excludes_completed_and_cancelled() {
310        // kills mutations in the `remaining` filter predicate:
311        // e.g. adding Completed to the filter, or removing InProgress
312        let (tool, _) = make_tool();
313        let args = json!({
314            "todos": [
315                {"content": "A", "status": "pending"},
316                {"content": "B", "status": "in_progress"},
317                {"content": "C", "status": "completed"},
318                {"content": "D", "status": "cancelled"},
319            ]
320        });
321        let result = tool.execute(args).await.unwrap();
322        let parsed: Value = serde_json::from_str(&result).unwrap();
323        assert_eq!(parsed["count"], 4, "count must include all 4 todos");
324        assert_eq!(
325            parsed["remaining"], 2,
326            "remaining must count only pending+in_progress (not completed/cancelled)"
327        );
328    }
329
330    #[tokio::test]
331    async fn missing_todos_field_returns_error() {
332        // kills `missing required field 'todos'` guard removal mutation
333        let (tool, _) = make_tool();
334        let res = tool.execute(json!({})).await;
335        assert!(res.is_err(), "missing 'todos' field must return error");
336    }
337
338    #[tokio::test]
339    async fn in_progress_label_uses_active_form_when_present() {
340        // kills `active_form.clone().unwrap_or_else(|| t.content.clone())` removal mutations
341        let (tool, _) = make_tool();
342        let args = json!({
343            "todos": [
344                {"content": "Write tests", "active_form": "Writing tests...", "status": "in_progress"},
345            ]
346        });
347        let result = tool.execute(args).await.unwrap();
348        let parsed: Value = serde_json::from_str(&result).unwrap();
349        assert_eq!(
350            parsed["in_progress"], "Writing tests...",
351            "in_progress label must use active_form when set; got: {}",
352            parsed["in_progress"]
353        );
354    }
355
356    #[tokio::test]
357    async fn in_progress_label_falls_back_to_content() {
358        // kills `unwrap_or_else(|| t.content.clone())` fallback removal mutation
359        let (tool, _) = make_tool();
360        let args = json!({
361            "todos": [
362                {"content": "Run tests", "status": "in_progress"},
363                // no active_form → must fall back to content
364            ]
365        });
366        let result = tool.execute(args).await.unwrap();
367        let parsed: Value = serde_json::from_str(&result).unwrap();
368        assert_eq!(
369            parsed["in_progress"], "Run tests",
370            "in_progress must fall back to content when active_form absent"
371        );
372    }
373}