/ tools / todo_tool.py
todo_tool.py
  1  #!/usr/bin/env python3
  2  """
  3  Todo Tool Module - Planning & Task Management
  4  
  5  Provides an in-memory task list the agent uses to decompose complex tasks,
  6  track progress, and maintain focus across long conversations. The state
  7  lives on the AIAgent instance (one per session) and is re-injected into
  8  the conversation after context compression events.
  9  
 10  Design:
 11  - Single `todo` tool: provide `todos` param to write, omit to read
 12  - Every call returns the full current list
 13  - No system prompt mutation, no tool response modification
 14  - Behavioral guidance lives entirely in the tool schema description
 15  """
 16  
 17  import json
 18  from typing import Dict, Any, List, Optional
 19  
 20  
 21  # Valid status values for todo items
 22  VALID_STATUSES = {"pending", "in_progress", "completed", "cancelled"}
 23  
 24  
 25  class TodoStore:
 26      """
 27      In-memory todo list. One instance per AIAgent (one per session).
 28  
 29      Items are ordered -- list position is priority. Each item has:
 30        - id: unique string identifier (agent-chosen)
 31        - content: task description
 32        - status: pending | in_progress | completed | cancelled
 33      """
 34  
 35      def __init__(self):
 36          self._items: List[Dict[str, str]] = []
 37  
 38      def write(self, todos: List[Dict[str, Any]], merge: bool = False) -> List[Dict[str, str]]:
 39          """
 40          Write todos. Returns the full current list after writing.
 41  
 42          Args:
 43              todos: list of {id, content, status} dicts
 44              merge: if False, replace the entire list. If True, update
 45                     existing items by id and append new ones.
 46          """
 47          if not merge:
 48              # Replace mode: new list entirely
 49              self._items = [self._validate(t) for t in self._dedupe_by_id(todos)]
 50          else:
 51              # Merge mode: update existing items by id, append new ones
 52              existing = {item["id"]: item for item in self._items}
 53              for t in self._dedupe_by_id(todos):
 54                  item_id = str(t.get("id", "")).strip()
 55                  if not item_id:
 56                      continue  # Can't merge without an id
 57  
 58                  if item_id in existing:
 59                      # Update only the fields the LLM actually provided
 60                      if "content" in t and t["content"]:
 61                          existing[item_id]["content"] = str(t["content"]).strip()
 62                      if "status" in t and t["status"]:
 63                          status = str(t["status"]).strip().lower()
 64                          if status in VALID_STATUSES:
 65                              existing[item_id]["status"] = status
 66                  else:
 67                      # New item -- validate fully and append to end
 68                      validated = self._validate(t)
 69                      existing[validated["id"]] = validated
 70                      self._items.append(validated)
 71              # Rebuild _items preserving order for existing items
 72              seen = set()
 73              rebuilt = []
 74              for item in self._items:
 75                  current = existing.get(item["id"], item)
 76                  if current["id"] not in seen:
 77                      rebuilt.append(current)
 78                      seen.add(current["id"])
 79              self._items = rebuilt
 80          return self.read()
 81  
 82      def read(self) -> List[Dict[str, str]]:
 83          """Return a copy of the current list."""
 84          return [item.copy() for item in self._items]
 85  
 86      def has_items(self) -> bool:
 87          """Check if there are any items in the list."""
 88          return bool(self._items)
 89  
 90      def format_for_injection(self) -> Optional[str]:
 91          """
 92          Render the todo list for post-compression injection.
 93  
 94          Returns a human-readable string to append to the compressed
 95          message history, or None if the list is empty.
 96          """
 97          if not self._items:
 98              return None
 99  
100          # Status markers for compact display
101          markers = {
102              "completed": "[x]",
103              "in_progress": "[>]",
104              "pending": "[ ]",
105              "cancelled": "[~]",
106          }
107  
108          # Only inject pending/in_progress items — completed/cancelled ones
109          # cause the model to re-do finished work after compression.
110          active_items = [
111              item for item in self._items
112              if item["status"] in ("pending", "in_progress")
113          ]
114          if not active_items:
115              return None
116  
117          lines = ["[Your active task list was preserved across context compression]"]
118          for item in active_items:
119              marker = markers.get(item["status"], "[?]")
120              lines.append(f"- {marker} {item['id']}. {item['content']} ({item['status']})")
121  
122          return "\n".join(lines)
123  
124      @staticmethod
125      def _validate(item: Dict[str, Any]) -> Dict[str, str]:
126          """
127          Validate and normalize a todo item.
128  
129          Ensures required fields exist and status is valid.
130          Returns a clean dict with only {id, content, status}.
131          """
132          item_id = str(item.get("id", "")).strip()
133          if not item_id:
134              item_id = "?"
135  
136          content = str(item.get("content", "")).strip()
137          if not content:
138              content = "(no description)"
139  
140          status = str(item.get("status", "pending")).strip().lower()
141          if status not in VALID_STATUSES:
142              status = "pending"
143  
144          return {"id": item_id, "content": content, "status": status}
145  
146      @staticmethod
147      def _dedupe_by_id(todos: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
148          """Collapse duplicate ids, keeping the last occurrence in its position."""
149          last_index: Dict[str, int] = {}
150          for i, item in enumerate(todos):
151              item_id = str(item.get("id", "")).strip() or "?"
152              last_index[item_id] = i
153          return [todos[i] for i in sorted(last_index.values())]
154  
155  
156  def todo_tool(
157      todos: Optional[List[Dict[str, Any]]] = None,
158      merge: bool = False,
159      store: Optional[TodoStore] = None,
160  ) -> str:
161      """
162      Single entry point for the todo tool. Reads or writes depending on params.
163  
164      Args:
165          todos: if provided, write these items. If None, read current list.
166          merge: if True, update by id. If False (default), replace entire list.
167          store: the TodoStore instance from the AIAgent.
168  
169      Returns:
170          JSON string with the full current list and summary metadata.
171      """
172      if store is None:
173          return tool_error("TodoStore not initialized")
174  
175      if todos is not None:
176          items = store.write(todos, merge)
177      else:
178          items = store.read()
179  
180      # Build summary counts
181      pending = sum(1 for i in items if i["status"] == "pending")
182      in_progress = sum(1 for i in items if i["status"] == "in_progress")
183      completed = sum(1 for i in items if i["status"] == "completed")
184      cancelled = sum(1 for i in items if i["status"] == "cancelled")
185  
186      return json.dumps({
187          "todos": items,
188          "summary": {
189              "total": len(items),
190              "pending": pending,
191              "in_progress": in_progress,
192              "completed": completed,
193              "cancelled": cancelled,
194          },
195      }, ensure_ascii=False)
196  
197  
198  def check_todo_requirements() -> bool:
199      """Todo tool has no external requirements -- always available."""
200      return True
201  
202  
203  # =============================================================================
204  # OpenAI Function-Calling Schema
205  # =============================================================================
206  # Behavioral guidance is baked into the description so it's part of the
207  # static tool schema (cached, never changes mid-conversation).
208  
209  TODO_SCHEMA = {
210      "name": "todo",
211      "description": (
212          "Manage your task list for the current session. Use for complex tasks "
213          "with 3+ steps or when the user provides multiple tasks. "
214          "Call with no parameters to read the current list.\n\n"
215          "Writing:\n"
216          "- Provide 'todos' array to create/update items\n"
217          "- merge=false (default): replace the entire list with a fresh plan\n"
218          "- merge=true: update existing items by id, add any new ones\n\n"
219          "Each item: {id: string, content: string, "
220          "status: pending|in_progress|completed|cancelled}\n"
221          "List order is priority. Only ONE item in_progress at a time.\n"
222          "Mark items completed immediately when done. If something fails, "
223          "cancel it and add a revised item.\n\n"
224          "Always returns the full current list."
225      ),
226      "parameters": {
227          "type": "object",
228          "properties": {
229              "todos": {
230                  "type": "array",
231                  "description": "Task items to write. Omit to read current list.",
232                  "items": {
233                      "type": "object",
234                      "properties": {
235                          "id": {
236                              "type": "string",
237                              "description": "Unique item identifier"
238                          },
239                          "content": {
240                              "type": "string",
241                              "description": "Task description"
242                          },
243                          "status": {
244                              "type": "string",
245                              "enum": ["pending", "in_progress", "completed", "cancelled"],
246                              "description": "Current status"
247                          }
248                      },
249                      "required": ["id", "content", "status"]
250                  }
251              },
252              "merge": {
253                  "type": "boolean",
254                  "description": (
255                      "true: update existing items by id, add new ones. "
256                      "false (default): replace the entire list."
257                  ),
258                  "default": False
259              }
260          },
261          "required": []
262      }
263  }
264  
265  
266  # --- Registry ---
267  from tools.registry import registry, tool_error
268  
269  registry.register(
270      name="todo",
271      toolset="todo",
272      schema=TODO_SCHEMA,
273      handler=lambda args, **kw: todo_tool(
274          todos=args.get("todos"), merge=args.get("merge", False), store=kw.get("store")),
275      check_fn=check_todo_requirements,
276      emoji="📋",
277  )