code.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. #!/usr/bin/env python3
  2. """
  3. s05: TodoWrite — 在 s04 Hooks 基础上增加规划工具。
  4. +---------+ +-------+ +------------------+
  5. | User | ---> | LLM | ---> | TOOL_HANDLERS |
  6. | prompt | | | | bash |
  7. +---------+ +---+---+ | read_file |
  8. ^ | write_file |
  9. | result | edit_file |
  10. +---------+ glob |
  11. todo_write ← 新增
  12. +------------------+
  13. |
  14. 内存中的 current_todos
  15. |
  16. if rounds_since_todo >= 3:
  17. 注入 <reminder>
  18. 相对 s04 的变化:
  19. + todo_write 工具 + run_todo_write() 实现
  20. + 催办提醒(3 轮未更新 todo 后注入提醒)
  21. + SYSTEM 提示词包含“先规划再执行”的指导
  22. + agent_loop 中增加 rounds_since_todo 计数器
  23. 循环不变:新工具通过 TOOL_HANDLERS 自动分发。
  24. 运行: python s05_todo_write/code.py
  25. 需要: pip install anthropic python-dotenv + .env 中配置 ANTHROPIC_API_KEY
  26. """
  27. import ast, json, os, subprocess
  28. from pathlib import Path
  29. try:
  30. import readline
  31. readline.parse_and_bind('set bind-tty-special-chars off')
  32. except ImportError:
  33. pass
  34. from anthropic import Anthropic
  35. from dotenv import load_dotenv
  36. load_dotenv(override=True)
  37. if os.getenv("ANTHROPIC_BASE_URL"):
  38. os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
  39. WORKDIR = Path.cwd()
  40. client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
  41. MODEL = os.environ["MODEL_ID"]
  42. CURRENT_TODOS: list[dict] = []
  43. # s05 变化: SYSTEM 提示词增加规划指导
  44. SYSTEM = (
  45. f"你是位于 {WORKDIR}. "
  46. "开始任何多步骤任务前,使用 todo_write 规划步骤。"
  47. "执行过程中持续更新状态。"
  48. )
  49. # ═══════════════════════════════════════════════════════════
  50. # 来自 s02-s04 (未改动): 工具实现
  51. # ═══════════════════════════════════════════════════════════
  52. def safe_path(p: str) -> Path:
  53. path = (WORKDIR / p).resolve()
  54. if not path.is_relative_to(WORKDIR):
  55. raise ValueError(f"路径逃逸出工作区:{p}")
  56. return path
  57. def run_bash(command: str) -> str:
  58. try:
  59. r = subprocess.run(command, shell=True, cwd=WORKDIR,
  60. capture_output=True, text=True, timeout=120)
  61. out = (r.stdout + r.stderr).strip()
  62. return out[:50000] if out else "(无输出)"
  63. except subprocess.TimeoutExpired:
  64. return "错误:执行超时(120 秒)"
  65. def run_read(path: str, limit: int | None = None) -> str:
  66. try:
  67. lines = safe_path(path).read_text().splitlines()
  68. if limit and limit < len(lines):
  69. lines = lines[:limit] + [f"... ({len(lines) - limit} 行更多内容)"]
  70. return "\n".join(lines)
  71. except Exception as e:
  72. return f"错误:{e}"
  73. def run_write(path: str, content: str) -> str:
  74. try:
  75. file_path = safe_path(path)
  76. file_path.parent.mkdir(parents=True, exist_ok=True)
  77. file_path.write_text(content)
  78. return f"已写入 {len(content)} 字节到 {path}"
  79. except Exception as e:
  80. return f"错误:{e}"
  81. def run_edit(path: str, old_text: str, new_text: str) -> str:
  82. try:
  83. file_path = safe_path(path)
  84. text = file_path.read_text()
  85. if old_text not in text:
  86. return f"错误:在文件中未找到目标文本:{path}"
  87. file_path.write_text(text.replace(old_text, new_text, 1))
  88. return f"已编辑 {path}"
  89. except Exception as e:
  90. return f"错误:{e}"
  91. def run_glob(pattern: str) -> str:
  92. import glob as g
  93. try:
  94. results = []
  95. for match in g.glob(pattern, root_dir=WORKDIR):
  96. if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
  97. results.append(match)
  98. return "\n".join(results) if results else "(无匹配)"
  99. except Exception as e:
  100. return f"错误:{e}"
  101. # ═══════════════════════════════════════════════════════════
  102. # 新增于 s05: todo_write 工具 — 只做规划,不执行
  103. # ═══════════════════════════════════════════════════════════
  104. def _normalize_todos(todos):
  105. if isinstance(todos, str):
  106. try:
  107. 个待办 = json.loads(todos)
  108. except json.JSONDecodeError:
  109. try:
  110. 个待办 = ast.literal_eval(todos)
  111. except (SyntaxError, ValueError):
  112. return None, "错误:todos 必须是列表或 JSON 数组字符串"
  113. if not isinstance(todos, list):
  114. return None, "错误:todos 必须是列表"
  115. for i, t in enumerate(todos):
  116. if not isinstance(t, dict):
  117. return None, f"错误:todos[{i}] 必须是对象"
  118. if "content" not in t or "status" not in t:
  119. return None, f"错误:todos[{i}] 缺少 'content' 或 'status'"
  120. if t["status"] not in ("pending", "in_progress", "completed"):
  121. return None, f"错误:todos[{i}] 包含无效状态 '{t['status']}'"
  122. return 个待办, None
  123. def run_todo_write(todos: list) -> str:
  124. global CURRENT_TODOS
  125. 个待办, error = _normalize_todos(todos)
  126. if error:
  127. return error
  128. CURRENT_TODOS = 个待办
  129. lines = ["\n\033[33m## 当前任务\033[0m"]
  130. for t in CURRENT_TODOS:
  131. icon = {"pending": " ", "in_progress": "\033[36m▸\033[0m", "completed": "\033[32m✓\033[0m"}[t["status"]]
  132. lines.append(f" [{icon}] {t['content']}")
  133. print("\n".join(lines))
  134. return f"已更新 {len(CURRENT_TODOS)} 个任务"
  135. TOOLS = [
  136. {"name": "bash", "description": "运行一条 shell 命令。",
  137. "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
  138. {"name": "read_file", "description": "读取文件内容。",
  139. "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
  140. {"name": "write_file", "description": "向文件写入内容。",
  141. "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
  142. {"name": "edit_file", "description": "在文件中替换一次完全匹配的文本。",
  143. "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
  144. {"name": "glob", "description": "查找匹配 glob 模式的文件。",
  145. "input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
  146. # s05: 新工具
  147. {"name": "todo_write", "description": "为当前编码会话创建并维护任务清单。",
  148. "input_schema": {"type": "object", "properties": {"todos": {"type": "array", "items": {"type": "object", "properties": {"content": {"type": "string"}, "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}}, "required": ["content", "status"]}}}, "required": ["todos"]}},
  149. ]
  150. TOOL_HANDLERS = {
  151. "bash": run_bash, "read_file": run_read, "write_file": run_write,
  152. "edit_file": run_edit, "glob": run_glob, "todo_write": run_todo_write,
  153. }
  154. # ═══════════════════════════════════════════════════════════
  155. # 来自 s04 (未改动): Hook 系统
  156. # ═══════════════════════════════════════════════════════════
  157. HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}
  158. def register_hook(event: str, callback):
  159. HOOKS[event].append(callback)
  160. def trigger_hooks(event: str, *args):
  161. for callback in HOOKS[event]:
  162. result = callback(*args)
  163. if result is not None:
  164. return result
  165. return None
  166. # s04 保留 Hooks
  167. DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
  168. def permission_hook(block):
  169. """PreToolUse: deny list check."""
  170. if block.name == "bash":
  171. for p in DENY_LIST:
  172. if p in block.input.get("command", ""):
  173. print(f"\n\033[31m⛔ 已拦截:'{p}'\033[0m")
  174. return "权限被拒绝"
  175. return None
  176. def log_hook(block):
  177. """PreToolUse:记录工具调用。"""
  178. print(f"\033[90m[HOOK] {block.name}\033[0m")
  179. return None
  180. def context_inject_hook(query: str):
  181. """UserPromptSubmit: log working directory."""
  182. print(f"\033[90m[HOOK] UserPromptSubmit: 工作目录:{WORKDIR}\033[0m")
  183. return None
  184. def summary_hook(messages: list):
  185. """Stop:打印工具调用次数。"""
  186. tool_count = sum(1 for m in messages
  187. for b in (m.get("content") if isinstance(m.get("content"), list) else [])
  188. if isinstance(b, dict) and b.get("type") == "tool_result")
  189. print(f"\033[90m[HOOK] Stop:会话使用了 {tool_count} 次工具调用\033[0m")
  190. return None
  191. register_hook("UserPromptSubmit", context_inject_hook)
  192. register_hook("PreToolUse", permission_hook)
  193. register_hook("PreToolUse", log_hook)
  194. register_hook("Stop", summary_hook)
  195. # ═══════════════════════════════════════════════════════════
  196. # agent_loop — 与以下相同: s04 + 催办提醒 计数器
  197. # ═══════════════════════════════════════════════════════════
  198. def agent_loop(messages: list):
  199. rounds_since_todo = 0
  200. while True:
  201. # s05: 催办提醒 — 如果模型连续 3 轮没有更新待办,则注入提醒
  202. if rounds_since_todo >= 3 and messages:
  203. messages.append({"role": "user",
  204. "content": "<reminder>请更新你的待办事项。</reminder>"})
  205. rounds_since_todo = 0
  206. response = client.messages.create(
  207. model=MODEL, system=SYSTEM, messages=messages,
  208. tools=TOOLS, max_tokens=8000,
  209. )
  210. messages.append({"role": "assistant", "content": response.content})
  211. if response.stop_reason != "tool_use":
  212. force = trigger_hooks("Stop", messages)
  213. if force:
  214. messages.append({"role": "user", "content": force})
  215. continue
  216. return
  217. rounds_since_todo += 1
  218. results = []
  219. for block in response.content:
  220. if block.type != "tool_use":
  221. continue
  222. blocked = trigger_hooks("PreToolUse", block)
  223. if blocked:
  224. results.append({"type": "tool_result", "tool_use_id": block.id,
  225. "content": str(blocked)})
  226. continue
  227. handler = TOOL_HANDLERS.get(block.name)
  228. output = handler(**block.input) if handler else f"未知工具:{block.name}"
  229. trigger_hooks("PostToolUse", block, output)
  230. # s05: 调用 todo_write 时重置催办计数器
  231. if block.name == "todo_write":
  232. rounds_since_todo = 0
  233. results.append({"type": "tool_result", "tool_use_id": block.id,
  234. "content": output})
  235. messages.append({"role": "user", "content": results})
  236. if __name__ == "__main__":
  237. print("s05: TodoWrite — 先规划再执行,忘记会提醒")
  238. print("输入问题后按回车。输入 q 退出。\n")
  239. history = []
  240. while True:
  241. try:
  242. query = input("\033[36ms05 >> \033[0m")
  243. except (EOFError, KeyboardInterrupt):
  244. break
  245. if query.strip().lower() in ("q", "exit", ""):
  246. break
  247. trigger_hooks("UserPromptSubmit", query)
  248. history.append({"role": "user", "content": query})
  249. agent_loop(history)
  250. for block in history[-1]["content"]:
  251. if getattr(block, "type", None) == "text":
  252. print(block.text)
  253. print()