learn-claude-code/s05_todo_write at main · shareAI-lab/learn-claude-code

先前的loop完全交给大模型,因此非常可能出现:

  • 需要改3个文件,开始改第一个文件
  • 失败了,重试一下;失败了,再重试一下,……

对话越长,注意力越容易被稀释,之前的安排很容易被忘记

Todo Write

我们当然要让模型学会Plan,但是仍然没有解决先前的问题

因此我们需要提醒模型当前进度与未来计划

实现的方式是引入一个新的工具todo_write以及一个reminder机制

  • todo_write:模型可以通过调用这个工具修改任务进度
    • 初始所有任务都是pending
    • 当前步骤in_progress
    • 完成任务completed
  • reminder:(CC没有的机制),如果模型连续多轮没有使用todo_write工具,自动注入一条提示

Code

  1#!/usr/bin/env python3
  2"""
  3s05: TodoWrite — add a planning tool on top of s04 hooks.
  4
  5  +---------+      +-------+      +------------------+
  6  |  User   | ---> |  LLM  | ---> | TOOL_HANDLERS    |
  7  | prompt  |      |       |      |  bash            |
  8  +---------+      +---+---+      |  read_file       |
  9                        ^         |  write_file      |
 10                        | result  |  edit_file       |
 11                        +---------+  glob            |
 12                                      todo_write ← NEW
 13                                   +------------------+
 14                                        |
 15                         in-memory current_todos
 16                                        |
 17                        if rounds_since_todo >= 3:
 18                          inject <reminder>
 19
 20Changes from s04:
 21  + todo_write tool + run_todo_write() implementation
 22  + Nag reminder (inject reminder after 3 rounds without todo update)
 23  + SYSTEM prompt includes "plan before execute" guidance
 24  + rounds_since_todo counter in agent_loop
 25  Loop unchanged: new tool auto-dispatches via TOOL_HANDLERS.
 26
 27Run: python s05_todo_write/code.py
 28Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env
 29"""
 30
 31import os, subprocess
 32from pathlib import Path
 33
 34try:
 35    import readline
 36    readline.parse_and_bind('set bind-tty-special-chars off')
 37except ImportError:
 38    pass
 39
 40from anthropic import Anthropic
 41from dotenv import load_dotenv
 42
 43load_dotenv(override=True)
 44if os.getenv("ANTHROPIC_BASE_URL"):
 45    os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
 46
 47WORKDIR = Path.cwd()
 48client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
 49MODEL = os.environ["MODEL_ID"]
 50CURRENT_TODOS: list[dict] = []
 51
 52# s05 change: SYSTEM prompt adds planning guidance
 53SYSTEM = (
 54    f"You are a coding agent at {WORKDIR}. "
 55    "Before starting any multi-step task, use todo_write to plan your steps. "
 56    "Update status as you go."
 57)
 58
 59
 60# ═══════════════════════════════════════════════════════════
 61#  FROM s02-s04 (unchanged): Tool Implementations
 62# ═══════════════════════════════════════════════════════════
 63
 64def safe_path(p: str) -> Path:
 65    path = (WORKDIR / p).resolve()
 66    if not path.is_relative_to(WORKDIR):
 67        raise ValueError(f"Path escapes workspace: {p}")
 68    return path
 69
 70def run_bash(command: str) -> str:
 71    try:
 72        r = subprocess.run(command, shell=True, cwd=WORKDIR,
 73                           capture_output=True, text=True, timeout=120)
 74        out = (r.stdout + r.stderr).strip()
 75        return out[:50000] if out else "(no output)"
 76    except subprocess.TimeoutExpired:
 77        return "Error: Timeout (120s)"
 78
 79def run_read(path: str, limit: int | None = None) -> str:
 80    try:
 81        lines = safe_path(path).read_text().splitlines()
 82        if limit and limit < len(lines):
 83            lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
 84        return "\n".join(lines)
 85    except Exception as e:
 86        return f"Error: {e}"
 87
 88def run_write(path: str, content: str) -> str:
 89    try:
 90        file_path = safe_path(path)
 91        file_path.parent.mkdir(parents=True, exist_ok=True)
 92        file_path.write_text(content)
 93        return f"Wrote {len(content)} bytes to {path}"
 94    except Exception as e:
 95        return f"Error: {e}"
 96
 97def run_edit(path: str, old_text: str, new_text: str) -> str:
 98    try:
 99        file_path = safe_path(path)
100        text = file_path.read_text()
101        if old_text not in text:
102            return f"Error: text not found in {path}"
103        file_path.write_text(text.replace(old_text, new_text, 1))
104        return f"Edited {path}"
105    except Exception as e:
106        return f"Error: {e}"
107
108def run_glob(pattern: str) -> str:
109    import glob as g
110    try:
111        results = []
112        for match in g.glob(pattern, root_dir=WORKDIR):
113            if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
114                results.append(match)
115        return "\n".join(results) if results else "(no matches)"
116    except Exception as e:
117        return f"Error: {e}"
118
119
120# ═══════════════════════════════════════════════════════════
121#  NEW in s05: todo_write tool — plan only, no execution
122# ═══════════════════════════════════════════════════════════
123
124# 传入的todos是模型新修改or创建的任务列表
125def run_todo_write(todos: list) -> str:
126    
127    # CURRENT_TODOS 是一个列表
128    # 每个元素都是一个字典,包含 "content" 和 "status" 两个字段
129    global CURRENT_TODOS
130    
131    # validate required fields
132    for i, t in enumerate(todos):
133        # 缺少 content 或 status 字段
134        if "content" not in t or "status" not in t: 
135            return f"Error: todos[{i}] missing 'content' or 'status'"
136        
137        # status 字段值不合法
138        if t["status"] not in ("pending", "in_progress", "completed"):
139            return f"Error: todos[{i}] has invalid status '{t['status']}'"
140        
141    CURRENT_TODOS = todos
142    lines = ["\n\033[33m## Current Tasks\033[0m"]
143    for t in CURRENT_TODOS:
144        icon = {"pending": " ", "in_progress": "\033[36m▸\033[0m", "completed": "\033[32m✓\033[0m"}[t["status"]]
145        lines.append(f"  [{icon}] {t['content']}")
146    print("\n".join(lines))
147    return f"Updated {len(CURRENT_TODOS)} tasks"
148
149TOOLS = [
150    {"name": "bash", "description": "Run a shell command.",
151     "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
152    {"name": "read_file", "description": "Read file contents.",
153     "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
154    {"name": "write_file", "description": "Write content to a file.",
155     "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
156    {"name": "edit_file", "description": "Replace exact text in a file once.",
157     "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
158    {"name": "glob", "description": "Find files matching a glob pattern.",
159     "input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
160    # s05: new tool
161    {"name": "todo_write", "description": "Create and manage a task list for your current coding session.",
162     "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"]}},
163]
164
165TOOL_HANDLERS = {
166    "bash": run_bash, "read_file": run_read, "write_file": run_write,
167    "edit_file": run_edit, "glob": run_glob, "todo_write": run_todo_write,
168}
169
170
171# ═══════════════════════════════════════════════════════════
172#  FROM s04 (unchanged): Hook System
173# ═══════════════════════════════════════════════════════════
174
175HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}
176
177def register_hook(event: str, callback):
178    HOOKS[event].append(callback)
179
180def trigger_hooks(event: str, *args):
181    for callback in HOOKS[event]:
182        result = callback(*args)
183        if result is not None:
184            return result
185    return None
186
187# s04 hooks preserved
188DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
189
190def permission_hook(block):
191    """PreToolUse: deny list check."""
192    if block.name == "bash":
193        for p in DENY_LIST:
194            if p in block.input.get("command", ""):
195                print(f"\n\033[31m⛔ Blocked: '{p}'\033[0m")
196                return "Permission denied"
197    return None
198
199def log_hook(block):
200    """PreToolUse: log tool calls."""
201    print(f"\033[90m[HOOK] {block.name}\033[0m" + f"{block.input if block.input else ''}")
202    return None
203
204def context_inject_hook(query: str):
205    """UserPromptSubmit: log working directory."""
206    print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")
207    return None
208
209def summary_hook(messages: list):
210    """Stop: print a clean summary of assistant texts and tool calls."""
211    print("\n\033[33m══════ Session Summary ══════\033[0m")
212    for msg in messages:
213        if msg["role"] != "assistant":
214            continue
215        for block in msg["content"]:
216            if getattr(block, "type", None) == "text":
217                print(f"\n\033[36m[Assistant]\033[0m {block.text}")
218            elif getattr(block, "type", None) == "tool_use":
219                print(f"\033[90m[Tool: {block.name}]\033[0m {block.input}")
220    print("\033[33m══════════════════════════════\033[0m\n")
221    return None
222
223register_hook("UserPromptSubmit", context_inject_hook)
224register_hook("PreToolUse", permission_hook)
225register_hook("PreToolUse", log_hook)
226register_hook("Stop", summary_hook)
227
228
229# ═══════════════════════════════════════════════════════════
230#  agent_loop — same as s04 + nag reminder counter
231# ═══════════════════════════════════════════════════════════
232
233rounds_since_todo = 0
234
235def agent_loop(messages: list):
236    global rounds_since_todo
237    while True:
238        # s05: nag reminder — inject if model hasn't updated todos for 3 rounds
239        # 提示词注入 提醒模型更新todos
240        if rounds_since_todo >= 3 and messages:
241            messages.append({"role": "user",
242                             "content": "<reminder>Update your todos.</reminder>"})
243            rounds_since_todo = 0
244
245        response = client.messages.create(
246            model=MODEL, system=SYSTEM, messages=messages,
247            tools=TOOLS, max_tokens=8000,
248        )
249        messages.append({"role": "assistant", "content": response.content})
250
251        if response.stop_reason != "tool_use":
252            force = trigger_hooks("Stop", messages)
253            if force:
254                messages.append({"role": "user", "content": force})
255                continue
256            return
257
258        rounds_since_todo += 1 # todo+1
259        results = []
260        for block in response.content:
261            if block.type != "tool_use":
262                continue
263
264            blocked = trigger_hooks("PreToolUse", block)
265            if blocked:
266                results.append({"type": "tool_result", "tool_use_id": block.id,
267                                "content": str(blocked)})
268                continue
269
270            handler = TOOL_HANDLERS.get(block.name)
271            output = handler(**block.input) if handler else f"Unknown: {block.name}"
272
273            trigger_hooks("PostToolUse", block, output)
274
275            # s05: reset nag counter when todo_write is called
276            if block.name == "todo_write":
277                rounds_since_todo = 0 # 清空
278
279            results.append({"type": "tool_result", "tool_use_id": block.id,
280                            "content": output})
281
282        messages.append({"role": "user", "content": results})
283
284
285if __name__ == "__main__":
286    print("s05: TodoWrite — plan before execute, nag if you forget")
287    print("Type a question, press Enter. Type q to quit.\n")
288
289    history = []
290    while True:
291        try:
292            query = input("\033[36ms05 >> \033[0m")
293        except (EOFError, KeyboardInterrupt):
294            break
295        if query.strip().lower() in ("q", "exit", ""):
296            break
297        trigger_hooks("UserPromptSubmit", query)
298        history.append({"role": "user", "content": query})
299        agent_loop(history)
300        for block in history[-1]["content"]:
301            if getattr(block, "type", None) == "text":
302                print(block.text)
303        print()

Claude Code

CC其实有两套任务系统

  • V1:TodoWrite,数据在内存
  • V2:TaskSystem(s12教学),持久化落盘

切换由 isTodoV2Enabled() 控制

  • 交互式会话中 V2 默认启用
  • 非交互式会话(SDK)中 V1 默认启用

CC 源码中没有固定的"3 轮"逻辑,更接近的是 TodoWriteTool.ts:72-107 中当 3 个以上 todo 全部完成但没有 verification 项时,追加 verification nudge

省流:提醒模型做check