Skip to main content

mecha_core/tool/
todo.rs

1//! A task list the agent maintains for itself.
2//!
3//! Planning as a *tool* rather than a mode. The alternative — a "plan phase"
4//! that produces a plan and then hands off — goes stale the moment the first
5//! step surprises the model. A list it rewrites as it goes stays honest, and
6//! because the current state is echoed back in every tool result, the model
7//! re-reads its own plan on the next turn without anyone re-prompting it.
8//!
9//! It also gives the *user* something to look at during a long run, which is
10//! most of why it's worth having.
11
12use 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/// Holds the list for the lifetime of the agent.
44#[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    /// Current list, for a UI that wants to render progress live.
55    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        // Touches nothing outside the agent's own head.
120        true
121    }
122
123    /// The list survives a compaction verbatim.
124    ///
125    /// The model re-reads its plan every turn through the echo in the last
126    /// `todo` result — which is a *message*, and therefore exactly the kind of
127    /// thing a compaction summarises away. That made this tool's whole
128    /// mechanism quietly conditional on the transcript never getting long,
129    /// which is the one situation the list matters most in: the measured
130    /// failure of summarisation is that it keeps what is true and drops how
131    /// far you got, and this list is nothing but how far you got.
132    ///
133    /// Rendered rather than summarised, because the tool holds the exact
134    /// current answer and a summariser would only be a lossy path to a worse
135    /// copy of it.
136    fn carried_state(&self) -> Option<CarriedState> {
137        let items = self.items.lock().unwrap();
138        // An empty list is genuinely nothing to carry, and an empty section in
139        // the prompt reads as "the plan is finished" rather than "there was
140        // never a plan".
141        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        // Nudge rather than reject: two items in flight is a mild smell, not an
179        // error, and refusing the write would lose the update entirely.
180        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}