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: "todo_write".into(),
81            description: "Create and manage a structured task list for the current session. \
82                Use proactively for tasks with 3 or more distinct steps. \
83                Update status in real-time as you work. \
84                Mark exactly ONE task as in_progress at a time."
85                .into(),
86            parameters: json!({
87                "type": "object",
88                "required": ["todos"],
89                "properties": {
90                    "todos": {
91                        "type": "array",
92                        "description": "Complete replacement for the task list. \
93                            Pass an empty array to clear all tasks.",
94                        "items": {
95                            "type": "object",
96                            "required": ["content", "status"],
97                            "properties": {
98                                "content": {
99                                    "type": "string",
100                                    "description": "Task description in imperative form, e.g. \"Run tests\"."
101                                },
102                                "status": {
103                                    "type": "string",
104                                    "enum": ["pending", "in_progress", "completed", "cancelled"],
105                                    "description": "Current status of this task."
106                                },
107                                "active_form": {
108                                    "type": "string",
109                                    "description": "Optional present-continuous label shown while in_progress, \
110                                        e.g. \"Running tests\"."
111                                }
112                            }
113                        }
114                    }
115                }
116            }),
117        }
118    }
119
120    async fn execute(&self, arguments: Value) -> Result<String> {
121        // Parse the todos array.
122        let raw_todos = arguments.get("todos").ok_or_else(|| Error::BadToolArgs {
123            name: "todo_write".into(),
124            message: "missing required field `todos`".into(),
125        })?;
126
127        let items: Vec<TodoItem> =
128            serde_json::from_value(raw_todos.clone()).map_err(|e| Error::BadToolArgs {
129                name: "todo_write".into(),
130                message: format!("failed to parse `todos`: {e}"),
131            })?;
132
133        // Validate: at most one item may have status `in_progress`.
134        let in_progress_count = items
135            .iter()
136            .filter(|t| t.status == TodoStatus::InProgress)
137            .count();
138        if in_progress_count > 1 {
139            return Err(Error::BadToolArgs {
140                name: "todo_write".into(),
141                message: format!(
142                    "at most one task may have status `in_progress`; found {in_progress_count}"
143                ),
144            });
145        }
146
147        // Compute summary stats before we move `items`.
148        let count = items.len();
149        let in_progress_label = items
150            .iter()
151            .find(|t| t.status == TodoStatus::InProgress)
152            .map(|t| t.active_form.clone().unwrap_or_else(|| t.content.clone()));
153        let remaining = items
154            .iter()
155            .filter(|t| matches!(t.status, TodoStatus::Pending | TodoStatus::InProgress))
156            .count();
157
158        // Commit to shared state.
159        {
160            let mut list = self.todo_list.write().map_err(|_| Error::Tool {
161                name: "todo_write".into(),
162                message: "todo list lock poisoned".into(),
163            })?;
164            *list = items.clone();
165        }
166
167        // Emit event so TUI / HTTP consumers can react.
168        self.event_sink
169            .emit(AgentEvent::TodoUpdated {
170                todos: items.clone(),
171            })
172            .await;
173
174        // Return a JSON summary; include the full list so the TUI backend
175        // can reconstruct state by parsing this output.
176        let result = json!({
177            "updated": true,
178            "count": count,
179            "in_progress": in_progress_label,
180            "remaining": remaining,
181            "todos": items,
182        });
183        Ok(result.to_string())
184    }
185
186    fn side_effect_class(&self) -> ToolSideEffect {
187        // Mutates in-memory state only — safe to re-apply on resume.
188        ToolSideEffect::Mutating
189    }
190
191    fn is_readonly(&self) -> bool {
192        false
193    }
194}
195
196// ---------------------------------------------------------------------------
197// Tests
198// ---------------------------------------------------------------------------
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::event::NullSink;
204
205    fn make_tool() -> (TodoWriteTool, Arc<RwLock<Vec<TodoItem>>>) {
206        let list = Arc::new(RwLock::new(vec![]));
207        let tool = TodoWriteTool::new(list.clone(), Arc::new(NullSink));
208        (tool, list)
209    }
210
211    #[tokio::test]
212    async fn basic_write_and_read() {
213        let (tool, list) = make_tool();
214        let args = json!({
215            "todos": [
216                {"content": "Step 1", "status": "pending"},
217                {"content": "Step 2", "status": "in_progress"},
218            ]
219        });
220        let result = tool.execute(args).await.unwrap();
221        let parsed: Value = serde_json::from_str(&result).unwrap();
222        assert_eq!(parsed["count"], 2);
223        assert_eq!(parsed["remaining"], 2);
224        assert_eq!(parsed["in_progress"], "Step 2");
225        assert_eq!(parsed["updated"], true);
226
227        let stored = list.read().unwrap();
228        assert_eq!(stored.len(), 2);
229        assert_eq!(stored[0].content, "Step 1");
230    }
231
232    #[tokio::test]
233    async fn rejects_multiple_in_progress() {
234        let (tool, _) = make_tool();
235        let args = json!({
236            "todos": [
237                {"content": "A", "status": "in_progress"},
238                {"content": "B", "status": "in_progress"},
239            ]
240        });
241        let err = tool.execute(args).await.unwrap_err();
242        assert!(err.to_string().contains("at most one task"));
243    }
244
245    #[tokio::test]
246    async fn clears_list_with_empty_array() {
247        let (tool, list) = make_tool();
248        // Seed with one item.
249        let _ = tool
250            .execute(json!({"todos": [{"content": "X", "status": "pending"}]}))
251            .await
252            .unwrap();
253        assert_eq!(list.read().unwrap().len(), 1);
254
255        // Clear.
256        let result = tool.execute(json!({"todos": []})).await.unwrap();
257        let parsed: Value = serde_json::from_str(&result).unwrap();
258        assert_eq!(parsed["count"], 0);
259        assert!(list.read().unwrap().is_empty());
260    }
261
262    #[tokio::test]
263    async fn active_form_used_when_present() {
264        let (tool, _) = make_tool();
265        let args = json!({
266            "todos": [
267                {
268                    "content": "Run tests",
269                    "status": "in_progress",
270                    "active_form": "Running tests"
271                }
272            ]
273        });
274        let result = tool.execute(args).await.unwrap();
275        let parsed: Value = serde_json::from_str(&result).unwrap();
276        assert_eq!(parsed["in_progress"], "Running tests");
277    }
278
279    #[test]
280    fn side_effect_is_mutating() {
281        let (tool, _) = make_tool();
282        assert_eq!(tool.side_effect_class(), ToolSideEffect::Mutating);
283        assert!(!tool.is_readonly());
284    }
285}