todo_tool.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. """TodoTool - 轻量级待办板
  2. MVP 目标:
  3. - 仅支持 add / list / update
  4. - 状态枚举:pending | in_progress | completed(强约束:同时最多 1 个 in_progress)
  5. - 存储:.helloagents/todos/todos.json,原子写入 + 简单备份
  6. - 输出:按状态分组的要点列表,便于 LLM 消化
  7. """
  8. from __future__ import annotations
  9. import json
  10. from dataclasses import dataclass, asdict
  11. from datetime import datetime
  12. from pathlib import Path
  13. from typing import Any, Dict, List, Optional
  14. from ..base import Tool, ToolParameter
  15. STATUSES = ("pending", "in_progress", "completed")
  16. @dataclass
  17. class TodoItem:
  18. id: int
  19. title: str
  20. desc: str = ""
  21. status: str = "pending"
  22. created_at: str = ""
  23. updated_at: str = ""
  24. @classmethod
  25. def from_dict(cls, data: Dict[str, Any]) -> "TodoItem":
  26. return cls(
  27. id=int(data["id"]),
  28. title=data.get("title", ""),
  29. desc=data.get("desc", ""),
  30. status=data.get("status", "pending"),
  31. created_at=data.get("created_at", ""),
  32. updated_at=data.get("updated_at", ""),
  33. )
  34. class TodoTool(Tool):
  35. def __init__(self, workspace: str):
  36. super().__init__(
  37. name="todo",
  38. description="待办工具:add/list/update;状态 pending|in_progress|completed(同时仅允许1个in_progress)",
  39. )
  40. self.workspace = Path(workspace)
  41. self.workspace.mkdir(parents=True, exist_ok=True)
  42. self.data_file = self.workspace / "todos.json"
  43. self.backup_file = self.workspace / "todos.json.bak"
  44. if not self.data_file.exists():
  45. self._save({"items": []})
  46. def get_parameters(self) -> List[ToolParameter]:
  47. return [
  48. ToolParameter(name="action", type="string", description="add | list | update", required=True),
  49. ToolParameter(name="title", type="string", description="待办标题(add必填,update可选)", required=False),
  50. ToolParameter(name="desc", type="string", description="待办描述(可选)", required=False),
  51. ToolParameter(name="status", type="string", description="pending|in_progress|completed(update可选)", required=False),
  52. ToolParameter(name="id", type="integer", description="要更新的待办ID(update必填)", required=False),
  53. ]
  54. # ---------------- core ops ----------------
  55. def run(self, parameters: Dict[str, Any]) -> str:
  56. if not self.validate_parameters(parameters):
  57. return "参数缺失,需包含 action(add/list/update)。"
  58. action = str(parameters.get("action", "")).strip().lower().rstrip("]")
  59. if action == "add":
  60. return self._add(title=parameters.get("title", ""), desc=parameters.get("desc", ""), status=parameters.get("status", "pending"))
  61. if action == "list":
  62. return self._list(status_filter=parameters.get("status"))
  63. if action == "update":
  64. return self._update(
  65. todo_id=parameters.get("id"),
  66. title=parameters.get("title"),
  67. desc=parameters.get("desc"),
  68. status=parameters.get("status"),
  69. )
  70. return "不支持的 action,应为 add/list/update。"
  71. # ---------------- storage ----------------
  72. def _load(self) -> Dict[str, Any]:
  73. with open(self.data_file, "r", encoding="utf-8") as f:
  74. return json.load(f)
  75. def _save(self, data: Dict[str, Any]) -> None:
  76. tmp = self.data_file.with_suffix(".tmp")
  77. if self.data_file.exists():
  78. try:
  79. self.backup_file.write_text(self.data_file.read_text(encoding="utf-8"), encoding="utf-8")
  80. except Exception:
  81. pass
  82. tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
  83. tmp.replace(self.data_file)
  84. # ---------------- helpers ----------------
  85. def _next_id(self, items: List[TodoItem]) -> int:
  86. return (max([i.id for i in items], default=0) + 1) if items else 1
  87. def _now(self) -> str:
  88. return datetime.now().isoformat(timespec="seconds")
  89. def _enforce_single_in_progress(self, items: List[TodoItem], incoming_status: str, incoming_id: Optional[int]) -> Optional[str]:
  90. if incoming_status != "in_progress":
  91. return None
  92. for it in items:
  93. if it.status == "in_progress" and (incoming_id is None or it.id != incoming_id):
  94. return f"已有进行中的任务 #{it.id}《{it.title}》。先完成/更新它后再切换。"
  95. return None
  96. # ---------------- actions ----------------
  97. def _add(self, title: str, desc: str, status: str) -> str:
  98. title = (title or "").strip()
  99. if not title:
  100. return "❌ add 失败:title 不能为空。"
  101. status = status if status in STATUSES else "pending"
  102. data = self._load()
  103. items = [TodoItem.from_dict(i) for i in data.get("items", [])]
  104. conflict = self._enforce_single_in_progress(items, status, None)
  105. if conflict:
  106. return f"❌ add 失败:{conflict}"
  107. now = self._now()
  108. new_item = TodoItem(id=self._next_id(items), title=title, desc=desc or "", status=status, created_at=now, updated_at=now)
  109. items.append(new_item)
  110. self._save({"items": [asdict(i) for i in items]})
  111. return f"✅ 已添加 #{new_item.id} [{new_item.status}] {new_item.title}"
  112. def _update(self, todo_id: Any, title: Optional[str], desc: Optional[str], status: Optional[str]) -> str:
  113. try:
  114. tid = int(todo_id)
  115. except Exception:
  116. return "❌ update 失败:缺少有效的 id。"
  117. if status and status not in STATUSES:
  118. return "❌ update 失败:status 必须是 pending|in_progress|completed。"
  119. data = self._load()
  120. items = [TodoItem.from_dict(i) for i in data.get("items", [])]
  121. target = next((i for i in items if i.id == tid), None)
  122. if not target:
  123. return f"❌ update 失败:未找到 id={tid} 的任务。"
  124. conflict = self._enforce_single_in_progress(items, status or target.status, tid)
  125. if conflict:
  126. return f"❌ update 失败:{conflict}"
  127. changed = []
  128. if title is not None:
  129. target.title = title.strip()
  130. changed.append("title")
  131. if desc is not None:
  132. target.desc = desc
  133. changed.append("desc")
  134. if status is not None:
  135. target.status = status
  136. changed.append("status")
  137. target.updated_at = self._now()
  138. self._save({"items": [asdict(i) for i in items]})
  139. if not changed:
  140. return f"⚠️ 未修改任何字段 #{tid}"
  141. return f"✅ 已更新 #{tid} ({', '.join(changed)}) -> [{target.status}] {target.title}"
  142. def _list(self, status_filter: Optional[str]) -> str:
  143. data = self._load()
  144. items = [TodoItem.from_dict(i) for i in data.get("items", [])]
  145. if status_filter and status_filter in STATUSES:
  146. items = [i for i in items if i.status == status_filter]
  147. groups = {"in_progress": [], "pending": [], "completed": []}
  148. for it in items:
  149. groups.setdefault(it.status, []).append(it)
  150. # ANSI colors similar to v2_todo_agent
  151. COLOR_PENDING = "\x1b[38;2;176;176;176m"
  152. COLOR_PROGRESS = "\x1b[38;2;120;200;255m"
  153. COLOR_DONE = "\x1b[38;2;34;139;34m"
  154. RESET = "\x1b[0m"
  155. def fmt(group_name: str, arr: List[TodoItem]) -> str:
  156. if not arr:
  157. return ""
  158. lines = [f"[{group_name.upper()}]"]
  159. for it in sorted(arr, key=lambda x: x.id):
  160. mark = "☒" if it.status == "completed" else "☐"
  161. color = COLOR_PENDING
  162. if it.status == "in_progress":
  163. color = COLOR_PROGRESS
  164. elif it.status == "completed":
  165. color = COLOR_DONE
  166. line_main = f"- {mark} #{it.id} {it.title} (updated {it.updated_at})"
  167. line_desc = f" {it.desc}" if it.desc else None
  168. if it.status == "completed":
  169. line_main = f"{color}\x1b[9m{line_main}{RESET}"
  170. if line_desc:
  171. line_desc = f"{color}\x1b[9m{line_desc}{RESET}"
  172. else:
  173. line_main = f"{color}{line_main}{RESET}"
  174. if line_desc:
  175. line_desc = f"{color}{line_desc}{RESET}"
  176. lines.append(line_main)
  177. if line_desc:
  178. lines.append(line_desc)
  179. return "\n".join(lines)
  180. parts = [fmt("in_progress", groups["in_progress"]), fmt("pending", groups["pending"]), fmt("completed", groups["completed"])]
  181. out = "\n\n".join([p for p in parts if p])
  182. return out or "暂无待办。"