1use 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#[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
34pub struct TodoItem {
35 pub content: String,
37 pub status: TodoStatus,
39 #[serde(skip_serializing_if = "Option::is_none")]
42 pub active_form: Option<String>,
43}
44
45pub struct TodoWriteTool {
57 todo_list: Arc<RwLock<Vec<TodoItem>>>,
58 event_sink: Arc<dyn EventSink>,
59}
60
61impl TodoWriteTool {
62 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 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 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 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 {
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 self.event_sink
169 .emit(AgentEvent::TodoUpdated {
170 todos: items.clone(),
171 })
172 .await;
173
174 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 ToolSideEffect::Mutating
189 }
190
191 fn is_readonly(&self) -> bool {
192 false
193 }
194}
195
196#[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 let _ = tool
250 .execute(json!({"todos": [{"content": "X", "status": "pending"}]}))
251 .await
252 .unwrap();
253 assert_eq!(list.read().unwrap().len(), 1);
254
255 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}