1use super::{CarriedState, Tool, ToolCtx, ToolOutput};
13use anyhow::Result;
14use async_trait::async_trait;
15use serde::{Deserialize, Serialize};
16use serde_json::{json, Value};
17use std::sync::Mutex;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum Status {
22 Pending,
23 InProgress,
24 Completed,
25}
26
27impl Status {
28 fn marker(self) -> &'static str {
29 match self {
30 Status::Pending => "[ ]",
31 Status::InProgress => "[~]",
32 Status::Completed => "[x]",
33 }
34 }
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct TodoItem {
39 pub content: String,
40 pub status: Status,
41}
42
43#[derive(Default)]
45pub struct TodoTool {
46 items: Mutex<Vec<TodoItem>>,
47}
48
49impl TodoTool {
50 pub fn new() -> Self {
51 Self::default()
52 }
53
54 pub fn items(&self) -> Vec<TodoItem> {
56 self.items.lock().unwrap().clone()
57 }
58
59 fn render(items: &[TodoItem]) -> String {
60 if items.is_empty() {
61 return "(the list is empty)".to_string();
62 }
63 let done = items
64 .iter()
65 .filter(|i| i.status == Status::Completed)
66 .count();
67 let mut out = format!("{done}/{} done\n", items.len());
68 for item in items {
69 out.push_str(&format!("{} {}\n", item.status.marker(), item.content));
70 }
71 out
72 }
73}
74
75#[async_trait]
76impl Tool for TodoTool {
77 fn name(&self) -> &str {
78 "todo"
79 }
80
81 fn description(&self) -> &str {
82 "Record and update your task list for multi-step work. If a task will take more \
83 than three tool calls, call this FIRST, before any other tool, and keep the list \
84 updated as you work. Pass the COMPLETE list every time — it replaces what was \
85 there, so include finished items with status `completed`. Exactly one item should \
86 be `in_progress` at a time, and an item should be marked `completed` as soon as \
87 it is done rather than in a batch at the end. Skip this tool only for work of \
88 one or two steps."
89 }
90
91 fn input_schema(&self) -> Value {
92 json!({
93 "type": "object",
94 "properties": {
95 "items": {
96 "type": "array",
97 "description": "The complete task list, in order.",
98 "items": {
99 "type": "object",
100 "properties": {
101 "content": {
102 "type": "string",
103 "description": "One concrete step, phrased as an action."
104 },
105 "status": {
106 "type": "string",
107 "enum": ["pending", "in_progress", "completed"]
108 }
109 },
110 "required": ["content", "status"]
111 }
112 }
113 },
114 "required": ["items"]
115 })
116 }
117
118 fn read_only(&self) -> bool {
119 true
121 }
122
123 fn carried_state(&self) -> Option<CarriedState> {
137 let items = self.items.lock().unwrap();
138 if items.is_empty() {
142 return None;
143 }
144 Some(CarriedState {
145 label: "todo".into(),
146 body: Self::render(&items),
147 })
148 }
149
150 async fn call(&self, input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
151 let Some(raw) = input.get("items").and_then(Value::as_array) else {
152 return Ok(ToolOutput::err(
153 "`items` must be an array of {content, status}",
154 ));
155 };
156
157 let mut items = Vec::with_capacity(raw.len());
158 for (i, entry) in raw.iter().enumerate() {
159 let Some(content) = entry.get("content").and_then(Value::as_str) else {
160 return Ok(ToolOutput::err(format!("item {i} has no `content` string")));
161 };
162 let status = match entry.get("status").and_then(Value::as_str) {
163 Some("pending") => Status::Pending,
164 Some("in_progress") => Status::InProgress,
165 Some("completed") => Status::Completed,
166 other => {
167 return Ok(ToolOutput::err(format!(
168 "item {i} has status {other:?}; expected pending, in_progress, or completed"
169 )))
170 }
171 };
172 items.push(TodoItem {
173 content: content.to_string(),
174 status,
175 });
176 }
177
178 let in_progress = items
181 .iter()
182 .filter(|i| i.status == Status::InProgress)
183 .count();
184 let mut note = String::new();
185 if in_progress > 1 {
186 note = format!(
187 "\n(note: {in_progress} items are in_progress — finish one before starting another)"
188 );
189 }
190
191 let rendered = Self::render(&items);
192 *self.items.lock().unwrap() = items;
193 Ok(ToolOutput::ok(format!("{rendered}{note}")))
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[tokio::test]
202 async fn writing_the_list_echoes_it_back_with_progress() {
203 let tool = TodoTool::new();
204 let out = tool
205 .call(
206 json!({"items": [
207 {"content": "read the config", "status": "completed"},
208 {"content": "fix the port", "status": "in_progress"},
209 {"content": "run the tests", "status": "pending"}
210 ]}),
211 &ToolCtx::default(),
212 )
213 .await
214 .unwrap();
215
216 assert!(!out.is_error);
217 assert!(out.content.starts_with("1/3 done"));
218 assert!(out.content.contains("[x] read the config"));
219 assert!(out.content.contains("[~] fix the port"));
220 assert!(out.content.contains("[ ] run the tests"));
221 assert_eq!(tool.items().len(), 3);
222 }
223
224 #[tokio::test]
225 async fn the_list_is_replaced_not_appended() {
226 let tool = TodoTool::new();
227 let ctx = ToolCtx::default();
228 tool.call(
229 json!({"items": [{"content": "a", "status": "pending"}]}),
230 &ctx,
231 )
232 .await
233 .unwrap();
234 tool.call(
235 json!({"items": [{"content": "b", "status": "pending"}]}),
236 &ctx,
237 )
238 .await
239 .unwrap();
240
241 let items = tool.items();
242 assert_eq!(items.len(), 1, "a write replaces the whole list");
243 assert_eq!(items[0].content, "b");
244 }
245
246 #[tokio::test]
247 async fn a_bad_status_is_reported_rather_than_silently_dropped() {
248 let tool = TodoTool::new();
249 let out = tool
250 .call(
251 json!({"items": [{"content": "a", "status": "done"}]}),
252 &ToolCtx::default(),
253 )
254 .await
255 .unwrap();
256 assert!(out.is_error);
257 assert!(out.content.contains("expected pending"));
258 assert!(tool.items().is_empty(), "a rejected write changes nothing");
259 }
260
261 #[tokio::test]
262 async fn multiple_in_progress_items_get_a_nudge() {
263 let tool = TodoTool::new();
264 let out = tool
265 .call(
266 json!({"items": [
267 {"content": "a", "status": "in_progress"},
268 {"content": "b", "status": "in_progress"}
269 ]}),
270 &ToolCtx::default(),
271 )
272 .await
273 .unwrap();
274 assert!(!out.is_error, "the write still lands");
275 assert!(out.content.contains("finish one before starting another"));
276 }
277}