From d6ccd6576b15a9b253d5a5e6670b549718f40703 Mon Sep 17 00:00:00 2001 From: Haoran Date: Fri, 26 Jun 2026 22:06:08 +0800 Subject: [PATCH 1/5] docs: reduce AI tone across s01-s20, add s21 workflow runtime and s22 goal loop Across s01-s20, rewrite the trilingual chapter prose (zh/en/ja) to read like an engineer talking rather than a generated summary, normalize the product name to "Claude Code", and fix the hand-drawn architecture diagrams. s08 is rewritten most heavily: its prose now walks the reader through each compaction layer by what it leaves unsolved. Two new chapters postdate the source leak and are reconstructed from the cc_workflow and cc_goal_loop reverse-engineering rather than file:line citations: - s21 workflow runtime: the Workflow tool launches a deterministic, resumable background runtime that fans out subagents. - s22 goal loop: the /goal command as a host-owned turn-completion gate, where a separate evaluator judges trusted transcript evidence after each turn and blocks the stop until the goal is met. Each ships a runnable code.py, trilingual READMEs, and house-style diagrams; web docs and course assets regenerated via npm run extract. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 11 + s01_agent_loop/README.en.md | 207 ------- s01_agent_loop/README.ja.md | 24 +- s01_agent_loop/README.md | 122 ++--- s01_agent_loop/README.zh.md | 207 +++++++ s01_agent_loop/images/agent-loop.en.svg | 86 --- s01_agent_loop/images/agent-loop.ja.svg | 86 --- s01_agent_loop/images/agent-loop.svg | 117 ++-- s02_tool_use/README.en.md | 222 -------- s02_tool_use/README.ja.md | 42 +- s02_tool_use/README.md | 150 +++-- s02_tool_use/README.zh.md | 218 ++++++++ .../images/concurrency-comparison.en.svg | 108 ---- .../images/concurrency-comparison.ja.svg | 108 ---- .../images/concurrency-comparison.svg | 108 ---- s02_tool_use/images/tool-dispatch.en.svg | 108 ---- s02_tool_use/images/tool-dispatch.ja.svg | 108 ---- s02_tool_use/images/tool-dispatch.svg | 186 +++---- s03_permission/README.en.md | 232 -------- s03_permission/README.ja.md | 36 +- s03_permission/README.md | 178 +++--- s03_permission/README.zh.md | 232 ++++++++ .../images/permission-overview.en.svg | 104 ---- .../images/permission-overview.ja.svg | 104 ---- s03_permission/images/permission-overview.svg | 214 ++++---- .../images/permission-pipeline.en.svg | 61 --- .../images/permission-pipeline.ja.svg | 61 --- s03_permission/images/permission-pipeline.svg | 126 +++-- s04_hooks/README.en.md | 283 ---------- s04_hooks/README.ja.md | 26 +- s04_hooks/README.md | 196 +++---- s04_hooks/README.zh.md | 283 ++++++++++ s04_hooks/images/hooks-overview.en.svg | 100 ---- s04_hooks/images/hooks-overview.ja.svg | 100 ---- s04_hooks/images/hooks-overview.svg | 194 +++---- s05_todo_write/README.en.md | 158 ------ s05_todo_write/README.ja.md | 24 +- s05_todo_write/README.md | 92 ++-- s05_todo_write/README.zh.md | 160 ++++++ s05_todo_write/images/todo-overview.en.svg | 93 ---- s05_todo_write/images/todo-overview.ja.svg | 93 ---- s05_todo_write/images/todo-overview.svg | 182 ++++--- s06_subagent/README.en.md | 189 ------- s06_subagent/README.ja.md | 18 +- s06_subagent/README.md | 152 +++--- s06_subagent/README.zh.md | 193 +++++++ s06_subagent/images/subagent-overview.en.svg | 125 ----- s06_subagent/images/subagent-overview.ja.svg | 125 ----- s06_subagent/images/subagent-overview.svg | 257 +++++---- s07_skill_loading/README.en.md | 182 ------- s07_skill_loading/README.ja.md | 20 +- s07_skill_loading/README.md | 124 ++--- s07_skill_loading/README.zh.md | 182 +++++++ .../images/skill-overview.en.svg | 110 ---- .../images/skill-overview.ja.svg | 110 ---- s07_skill_loading/images/skill-overview.svg | 252 +++++---- s08_context_compact/README.en.md | 310 ----------- s08_context_compact/README.ja.md | 191 ++++--- s08_context_compact/README.md | 305 +++++------ s08_context_compact/README.zh.md | 305 +++++++++++ s08_context_compact/code.py | 4 +- .../images/auto-compact.en.svg | 72 --- .../images/auto-compact.ja.svg | 72 --- s08_context_compact/images/auto-compact.svg | 176 +++--- .../images/compact-overview.en.svg | 138 ----- .../images/compact-overview.ja.svg | 138 ----- .../images/compact-overview.svg | 249 +++++---- .../images/compaction-layers.en.svg | 98 ---- .../images/compaction-layers.ja.svg | 98 ---- .../images/compaction-layers.svg | 205 ++++--- .../images/layer1-budget.en.svg | 50 -- .../images/layer1-budget.ja.svg | 50 -- s08_context_compact/images/layer1-budget.svg | 110 ++-- .../images/micro-compact.en.svg | 58 -- .../images/micro-compact.ja.svg | 58 -- s08_context_compact/images/micro-compact.svg | 115 ++-- s09_memory/README.en.md | 279 ---------- s09_memory/README.ja.md | 24 +- s09_memory/README.md | 223 ++++---- s09_memory/README.zh.md | 280 ++++++++++ s09_memory/images/memory-overview.en.svg | 104 ---- s09_memory/images/memory-overview.ja.svg | 104 ---- s09_memory/images/memory-overview.svg | 213 ++++---- s09_memory/images/memory-subsystems.en.svg | 78 --- s09_memory/images/memory-subsystems.ja.svg | 78 --- s09_memory/images/memory-subsystems.svg | 202 ++++--- s10_system_prompt/README.en.md | 254 --------- s10_system_prompt/README.ja.md | 24 +- s10_system_prompt/README.md | 174 +++--- s10_system_prompt/README.zh.md | 252 +++++++++ .../images/system-prompt-overview.en.svg | 107 ---- .../images/system-prompt-overview.ja.svg | 107 ---- .../images/system-prompt-overview.svg | 243 +++++---- s11_error_recovery/README.en.md | 277 ---------- s11_error_recovery/README.ja.md | 32 +- s11_error_recovery/README.md | 178 +++--- s11_error_recovery/README.zh.md | 277 ++++++++++ s11_error_recovery/code.py | 2 +- .../images/error-recovery-overview.en.svg | 98 ---- .../images/error-recovery-overview.ja.svg | 98 ---- .../images/error-recovery-overview.svg | 229 ++++---- s12_task_system/README.en.md | 282 ---------- s12_task_system/README.ja.md | 26 +- s12_task_system/README.md | 202 +++---- s12_task_system/README.zh.md | 282 ++++++++++ s12_task_system/code.py | 2 +- s12_task_system/images/task-dag.en.svg | 59 -- s12_task_system/images/task-dag.ja.svg | 59 -- s12_task_system/images/task-dag.svg | 122 +++-- .../images/task-system-overview.en.svg | 94 ---- .../images/task-system-overview.ja.svg | 94 ---- .../images/task-system-overview.svg | 216 +++++--- s13_background_tasks/README.en.md | 261 --------- s13_background_tasks/README.ja.md | 24 +- s13_background_tasks/README.md | 128 ++--- s13_background_tasks/README.zh.md | 261 +++++++++ .../images/background-tasks-overview.en.svg | 105 ---- .../images/background-tasks-overview.ja.svg | 105 ---- .../images/background-tasks-overview.svg | 203 ++++--- s14_cron_scheduler/README.en.md | 305 ----------- s14_cron_scheduler/README.ja.md | 20 +- s14_cron_scheduler/README.md | 224 ++++---- s14_cron_scheduler/README.zh.md | 303 +++++++++++ .../images/cron-scheduler-overview.en.svg | 125 ----- .../images/cron-scheduler-overview.ja.svg | 125 ----- .../images/cron-scheduler-overview.svg | 265 +++++---- s15_agent_teams/README.en.md | 254 --------- s15_agent_teams/README.ja.md | 34 +- s15_agent_teams/README.md | 212 ++++---- s15_agent_teams/README.zh.md | 254 +++++++++ s15_agent_teams/code.py | 10 +- .../images/agent-teams-overview.en.svg | 120 ---- .../images/agent-teams-overview.ja.svg | 120 ---- .../images/agent-teams-overview.svg | 235 ++++---- s15_agent_teams/images/team-topology.en.svg | 65 --- s15_agent_teams/images/team-topology.ja.svg | 65 --- s15_agent_teams/images/team-topology.svg | 129 +++-- s16_team_protocols/README.en.md | 241 -------- s16_team_protocols/README.ja.md | 20 +- s16_team_protocols/README.md | 176 +++--- s16_team_protocols/README.zh.md | 243 +++++++++ s16_team_protocols/code.py | 4 +- .../images/team-protocols-overview.en.svg | 143 ----- .../images/team-protocols-overview.ja.svg | 141 ----- .../images/team-protocols-overview.svg | 256 ++++----- s17_autonomous_agents/README.en.md | 271 --------- s17_autonomous_agents/README.ja.md | 36 +- s17_autonomous_agents/README.md | 226 ++++---- s17_autonomous_agents/README.zh.md | 271 +++++++++ .../images/autonomous-agents-overview.en.svg | 109 ---- .../images/autonomous-agents-overview.ja.svg | 109 ---- .../images/autonomous-agents-overview.svg | 183 ++++--- s18_worktree_isolation/README.en.md | 208 ------- s18_worktree_isolation/README.ja.md | 26 +- s18_worktree_isolation/README.md | 148 ++--- s18_worktree_isolation/README.zh.md | 208 +++++++ .../images/worktree-overview.en.svg | 103 ---- .../images/worktree-overview.ja.svg | 103 ---- .../images/worktree-overview.svg | 196 +++---- s19_mcp_plugin/README.en.md | 282 ---------- s19_mcp_plugin/README.ja.md | 46 +- s19_mcp_plugin/README.md | 214 ++++---- s19_mcp_plugin/README.zh.md | 282 ++++++++++ s19_mcp_plugin/code.py | 2 +- s19_mcp_plugin/images/mcp-architecture.en.svg | 112 ---- s19_mcp_plugin/images/mcp-architecture.ja.svg | 112 ---- s19_mcp_plugin/images/mcp-architecture.svg | 244 +++++---- s20_comprehensive/README.en.md | 250 --------- s20_comprehensive/README.ja.md | 12 +- s20_comprehensive/README.md | 236 ++++---- s20_comprehensive/README.zh.md | 254 +++++++++ s20_comprehensive/code.py | 2 +- .../images/system-architecture.en.svg | 85 --- .../images/system-architecture.ja.svg | 85 --- .../images/system-architecture.svg | 288 ++++++---- s21_workflow_runtime/README.ja.md | 227 ++++++++ s21_workflow_runtime/README.md | 227 ++++++++ s21_workflow_runtime/README.zh.md | 227 ++++++++ s21_workflow_runtime/code.py | 514 ++++++++++++++++++ .../images/workflow-runtime-overview.svg | 120 ++++ s22_goal_loop/README.ja.md | 170 ++++++ s22_goal_loop/README.md | 170 ++++++ s22_goal_loop/README.zh.md | 170 ++++++ s22_goal_loop/code.py | 279 ++++++++++ s22_goal_loop/images/goal-loop-overview.svg | 109 ++++ .../s01_agent_loop/agent-loop.en.svg | 86 --- .../s01_agent_loop/agent-loop.ja.svg | 86 --- .../s01_agent_loop/agent-loop.svg | 117 ++-- .../concurrency-comparison.en.svg | 108 ---- .../concurrency-comparison.ja.svg | 108 ---- .../s02_tool_use/concurrency-comparison.svg | 108 ---- .../s02_tool_use/tool-dispatch.en.svg | 108 ---- .../s02_tool_use/tool-dispatch.ja.svg | 108 ---- .../s02_tool_use/tool-dispatch.svg | 186 +++---- .../s03_permission/permission-overview.en.svg | 104 ---- .../s03_permission/permission-overview.ja.svg | 104 ---- .../s03_permission/permission-overview.svg | 214 ++++---- .../s03_permission/permission-pipeline.en.svg | 61 --- .../s03_permission/permission-pipeline.ja.svg | 61 --- .../s03_permission/permission-pipeline.svg | 126 +++-- .../s04_hooks/hooks-overview.en.svg | 100 ---- .../s04_hooks/hooks-overview.ja.svg | 100 ---- .../s04_hooks/hooks-overview.svg | 194 +++---- .../s05_todo_write/todo-overview.en.svg | 93 ---- .../s05_todo_write/todo-overview.ja.svg | 93 ---- .../s05_todo_write/todo-overview.svg | 182 ++++--- .../s06_subagent/subagent-overview.en.svg | 125 ----- .../s06_subagent/subagent-overview.ja.svg | 125 ----- .../s06_subagent/subagent-overview.svg | 257 +++++---- .../s07_skill_loading/skill-overview.en.svg | 110 ---- .../s07_skill_loading/skill-overview.ja.svg | 110 ---- .../s07_skill_loading/skill-overview.svg | 252 +++++---- .../s08_context_compact/auto-compact.en.svg | 72 --- .../s08_context_compact/auto-compact.ja.svg | 72 --- .../s08_context_compact/auto-compact.svg | 176 +++--- .../compact-overview.en.svg | 138 ----- .../compact-overview.ja.svg | 138 ----- .../s08_context_compact/compact-overview.svg | 249 +++++---- .../compaction-layers.en.svg | 98 ---- .../compaction-layers.ja.svg | 98 ---- .../s08_context_compact/compaction-layers.svg | 205 ++++--- .../s08_context_compact/layer1-budget.en.svg | 50 -- .../s08_context_compact/layer1-budget.ja.svg | 50 -- .../s08_context_compact/layer1-budget.svg | 110 ++-- .../s08_context_compact/micro-compact.en.svg | 58 -- .../s08_context_compact/micro-compact.ja.svg | 58 -- .../s08_context_compact/micro-compact.svg | 115 ++-- .../s09_memory/memory-overview.en.svg | 104 ---- .../s09_memory/memory-overview.ja.svg | 104 ---- .../s09_memory/memory-overview.svg | 213 ++++---- .../s09_memory/memory-subsystems.en.svg | 78 --- .../s09_memory/memory-subsystems.ja.svg | 78 --- .../s09_memory/memory-subsystems.svg | 202 ++++--- .../system-prompt-overview.en.svg | 107 ---- .../system-prompt-overview.ja.svg | 107 ---- .../system-prompt-overview.svg | 243 +++++---- .../error-recovery-overview.en.svg | 98 ---- .../error-recovery-overview.ja.svg | 98 ---- .../error-recovery-overview.svg | 229 ++++---- .../s12_task_system/task-dag.en.svg | 59 -- .../s12_task_system/task-dag.ja.svg | 59 -- .../s12_task_system/task-dag.svg | 122 +++-- .../task-system-overview.en.svg | 94 ---- .../task-system-overview.ja.svg | 94 ---- .../s12_task_system/task-system-overview.svg | 216 +++++--- .../background-tasks-overview.en.svg | 105 ---- .../background-tasks-overview.ja.svg | 105 ---- .../background-tasks-overview.svg | 203 ++++--- .../cron-scheduler-overview.en.svg | 125 ----- .../cron-scheduler-overview.ja.svg | 125 ----- .../cron-scheduler-overview.svg | 265 +++++---- .../agent-teams-overview.en.svg | 120 ---- .../agent-teams-overview.ja.svg | 120 ---- .../s15_agent_teams/agent-teams-overview.svg | 235 ++++---- .../s15_agent_teams/team-topology.en.svg | 65 --- .../s15_agent_teams/team-topology.ja.svg | 65 --- .../s15_agent_teams/team-topology.svg | 129 +++-- .../team-protocols-overview.en.svg | 143 ----- .../team-protocols-overview.ja.svg | 141 ----- .../team-protocols-overview.svg | 256 ++++----- .../autonomous-agents-overview.en.svg | 109 ---- .../autonomous-agents-overview.ja.svg | 109 ---- .../autonomous-agents-overview.svg | 183 ++++--- .../worktree-overview.en.svg | 103 ---- .../worktree-overview.ja.svg | 103 ---- .../worktree-overview.svg | 196 +++---- .../s19_mcp_plugin/mcp-architecture.en.svg | 112 ---- .../s19_mcp_plugin/mcp-architecture.ja.svg | 112 ---- .../s19_mcp_plugin/mcp-architecture.svg | 244 +++++---- .../system-architecture.en.svg | 85 --- .../system-architecture.ja.svg | 85 --- .../s20_comprehensive/system-architecture.svg | 288 ++++++---- .../workflow-runtime-overview.svg | 120 ++++ .../s22_goal_loop/goal-loop-overview.svg | 109 ++++ web/scripts/extract-content.ts | 4 +- web/src/data/generated/docs.json | 166 +++--- web/src/data/generated/versions.json | 494 ++++++++++++----- 277 files changed, 16341 insertions(+), 23759 deletions(-) delete mode 100644 s01_agent_loop/README.en.md create mode 100644 s01_agent_loop/README.zh.md delete mode 100644 s01_agent_loop/images/agent-loop.en.svg delete mode 100644 s01_agent_loop/images/agent-loop.ja.svg delete mode 100644 s02_tool_use/README.en.md create mode 100644 s02_tool_use/README.zh.md delete mode 100644 s02_tool_use/images/concurrency-comparison.en.svg delete mode 100644 s02_tool_use/images/concurrency-comparison.ja.svg delete mode 100644 s02_tool_use/images/concurrency-comparison.svg delete mode 100644 s02_tool_use/images/tool-dispatch.en.svg delete mode 100644 s02_tool_use/images/tool-dispatch.ja.svg delete mode 100644 s03_permission/README.en.md create mode 100644 s03_permission/README.zh.md delete mode 100644 s03_permission/images/permission-overview.en.svg delete mode 100644 s03_permission/images/permission-overview.ja.svg delete mode 100644 s03_permission/images/permission-pipeline.en.svg delete mode 100644 s03_permission/images/permission-pipeline.ja.svg delete mode 100644 s04_hooks/README.en.md create mode 100644 s04_hooks/README.zh.md delete mode 100644 s04_hooks/images/hooks-overview.en.svg delete mode 100644 s04_hooks/images/hooks-overview.ja.svg delete mode 100644 s05_todo_write/README.en.md create mode 100644 s05_todo_write/README.zh.md delete mode 100644 s05_todo_write/images/todo-overview.en.svg delete mode 100644 s05_todo_write/images/todo-overview.ja.svg delete mode 100644 s06_subagent/README.en.md create mode 100644 s06_subagent/README.zh.md delete mode 100644 s06_subagent/images/subagent-overview.en.svg delete mode 100644 s06_subagent/images/subagent-overview.ja.svg delete mode 100644 s07_skill_loading/README.en.md create mode 100644 s07_skill_loading/README.zh.md delete mode 100644 s07_skill_loading/images/skill-overview.en.svg delete mode 100644 s07_skill_loading/images/skill-overview.ja.svg delete mode 100644 s08_context_compact/README.en.md create mode 100644 s08_context_compact/README.zh.md delete mode 100644 s08_context_compact/images/auto-compact.en.svg delete mode 100644 s08_context_compact/images/auto-compact.ja.svg delete mode 100644 s08_context_compact/images/compact-overview.en.svg delete mode 100644 s08_context_compact/images/compact-overview.ja.svg delete mode 100644 s08_context_compact/images/compaction-layers.en.svg delete mode 100644 s08_context_compact/images/compaction-layers.ja.svg delete mode 100644 s08_context_compact/images/layer1-budget.en.svg delete mode 100644 s08_context_compact/images/layer1-budget.ja.svg delete mode 100644 s08_context_compact/images/micro-compact.en.svg delete mode 100644 s08_context_compact/images/micro-compact.ja.svg delete mode 100644 s09_memory/README.en.md create mode 100644 s09_memory/README.zh.md delete mode 100644 s09_memory/images/memory-overview.en.svg delete mode 100644 s09_memory/images/memory-overview.ja.svg delete mode 100644 s09_memory/images/memory-subsystems.en.svg delete mode 100644 s09_memory/images/memory-subsystems.ja.svg delete mode 100644 s10_system_prompt/README.en.md create mode 100644 s10_system_prompt/README.zh.md delete mode 100644 s10_system_prompt/images/system-prompt-overview.en.svg delete mode 100644 s10_system_prompt/images/system-prompt-overview.ja.svg delete mode 100644 s11_error_recovery/README.en.md create mode 100644 s11_error_recovery/README.zh.md delete mode 100644 s11_error_recovery/images/error-recovery-overview.en.svg delete mode 100644 s11_error_recovery/images/error-recovery-overview.ja.svg delete mode 100644 s12_task_system/README.en.md create mode 100644 s12_task_system/README.zh.md delete mode 100644 s12_task_system/images/task-dag.en.svg delete mode 100644 s12_task_system/images/task-dag.ja.svg delete mode 100644 s12_task_system/images/task-system-overview.en.svg delete mode 100644 s12_task_system/images/task-system-overview.ja.svg delete mode 100644 s13_background_tasks/README.en.md create mode 100644 s13_background_tasks/README.zh.md delete mode 100644 s13_background_tasks/images/background-tasks-overview.en.svg delete mode 100644 s13_background_tasks/images/background-tasks-overview.ja.svg delete mode 100644 s14_cron_scheduler/README.en.md create mode 100644 s14_cron_scheduler/README.zh.md delete mode 100644 s14_cron_scheduler/images/cron-scheduler-overview.en.svg delete mode 100644 s14_cron_scheduler/images/cron-scheduler-overview.ja.svg delete mode 100644 s15_agent_teams/README.en.md create mode 100644 s15_agent_teams/README.zh.md delete mode 100644 s15_agent_teams/images/agent-teams-overview.en.svg delete mode 100644 s15_agent_teams/images/agent-teams-overview.ja.svg delete mode 100644 s15_agent_teams/images/team-topology.en.svg delete mode 100644 s15_agent_teams/images/team-topology.ja.svg delete mode 100644 s16_team_protocols/README.en.md create mode 100644 s16_team_protocols/README.zh.md delete mode 100644 s16_team_protocols/images/team-protocols-overview.en.svg delete mode 100644 s16_team_protocols/images/team-protocols-overview.ja.svg delete mode 100644 s17_autonomous_agents/README.en.md create mode 100644 s17_autonomous_agents/README.zh.md delete mode 100644 s17_autonomous_agents/images/autonomous-agents-overview.en.svg delete mode 100644 s17_autonomous_agents/images/autonomous-agents-overview.ja.svg delete mode 100644 s18_worktree_isolation/README.en.md create mode 100644 s18_worktree_isolation/README.zh.md delete mode 100644 s18_worktree_isolation/images/worktree-overview.en.svg delete mode 100644 s18_worktree_isolation/images/worktree-overview.ja.svg delete mode 100644 s19_mcp_plugin/README.en.md create mode 100644 s19_mcp_plugin/README.zh.md delete mode 100644 s19_mcp_plugin/images/mcp-architecture.en.svg delete mode 100644 s19_mcp_plugin/images/mcp-architecture.ja.svg delete mode 100644 s20_comprehensive/README.en.md create mode 100644 s20_comprehensive/README.zh.md delete mode 100644 s20_comprehensive/images/system-architecture.en.svg delete mode 100644 s20_comprehensive/images/system-architecture.ja.svg create mode 100644 s21_workflow_runtime/README.ja.md create mode 100644 s21_workflow_runtime/README.md create mode 100644 s21_workflow_runtime/README.zh.md create mode 100644 s21_workflow_runtime/code.py create mode 100644 s21_workflow_runtime/images/workflow-runtime-overview.svg create mode 100644 s22_goal_loop/README.ja.md create mode 100644 s22_goal_loop/README.md create mode 100644 s22_goal_loop/README.zh.md create mode 100644 s22_goal_loop/code.py create mode 100644 s22_goal_loop/images/goal-loop-overview.svg delete mode 100644 web/public/course-assets/s01_agent_loop/agent-loop.en.svg delete mode 100644 web/public/course-assets/s01_agent_loop/agent-loop.ja.svg delete mode 100644 web/public/course-assets/s02_tool_use/concurrency-comparison.en.svg delete mode 100644 web/public/course-assets/s02_tool_use/concurrency-comparison.ja.svg delete mode 100644 web/public/course-assets/s02_tool_use/concurrency-comparison.svg delete mode 100644 web/public/course-assets/s02_tool_use/tool-dispatch.en.svg delete mode 100644 web/public/course-assets/s02_tool_use/tool-dispatch.ja.svg delete mode 100644 web/public/course-assets/s03_permission/permission-overview.en.svg delete mode 100644 web/public/course-assets/s03_permission/permission-overview.ja.svg delete mode 100644 web/public/course-assets/s03_permission/permission-pipeline.en.svg delete mode 100644 web/public/course-assets/s03_permission/permission-pipeline.ja.svg delete mode 100644 web/public/course-assets/s04_hooks/hooks-overview.en.svg delete mode 100644 web/public/course-assets/s04_hooks/hooks-overview.ja.svg delete mode 100644 web/public/course-assets/s05_todo_write/todo-overview.en.svg delete mode 100644 web/public/course-assets/s05_todo_write/todo-overview.ja.svg delete mode 100644 web/public/course-assets/s06_subagent/subagent-overview.en.svg delete mode 100644 web/public/course-assets/s06_subagent/subagent-overview.ja.svg delete mode 100644 web/public/course-assets/s07_skill_loading/skill-overview.en.svg delete mode 100644 web/public/course-assets/s07_skill_loading/skill-overview.ja.svg delete mode 100644 web/public/course-assets/s08_context_compact/auto-compact.en.svg delete mode 100644 web/public/course-assets/s08_context_compact/auto-compact.ja.svg delete mode 100644 web/public/course-assets/s08_context_compact/compact-overview.en.svg delete mode 100644 web/public/course-assets/s08_context_compact/compact-overview.ja.svg delete mode 100644 web/public/course-assets/s08_context_compact/compaction-layers.en.svg delete mode 100644 web/public/course-assets/s08_context_compact/compaction-layers.ja.svg delete mode 100644 web/public/course-assets/s08_context_compact/layer1-budget.en.svg delete mode 100644 web/public/course-assets/s08_context_compact/layer1-budget.ja.svg delete mode 100644 web/public/course-assets/s08_context_compact/micro-compact.en.svg delete mode 100644 web/public/course-assets/s08_context_compact/micro-compact.ja.svg delete mode 100644 web/public/course-assets/s09_memory/memory-overview.en.svg delete mode 100644 web/public/course-assets/s09_memory/memory-overview.ja.svg delete mode 100644 web/public/course-assets/s09_memory/memory-subsystems.en.svg delete mode 100644 web/public/course-assets/s09_memory/memory-subsystems.ja.svg delete mode 100644 web/public/course-assets/s10_system_prompt/system-prompt-overview.en.svg delete mode 100644 web/public/course-assets/s10_system_prompt/system-prompt-overview.ja.svg delete mode 100644 web/public/course-assets/s11_error_recovery/error-recovery-overview.en.svg delete mode 100644 web/public/course-assets/s11_error_recovery/error-recovery-overview.ja.svg delete mode 100644 web/public/course-assets/s12_task_system/task-dag.en.svg delete mode 100644 web/public/course-assets/s12_task_system/task-dag.ja.svg delete mode 100644 web/public/course-assets/s12_task_system/task-system-overview.en.svg delete mode 100644 web/public/course-assets/s12_task_system/task-system-overview.ja.svg delete mode 100644 web/public/course-assets/s13_background_tasks/background-tasks-overview.en.svg delete mode 100644 web/public/course-assets/s13_background_tasks/background-tasks-overview.ja.svg delete mode 100644 web/public/course-assets/s14_cron_scheduler/cron-scheduler-overview.en.svg delete mode 100644 web/public/course-assets/s14_cron_scheduler/cron-scheduler-overview.ja.svg delete mode 100644 web/public/course-assets/s15_agent_teams/agent-teams-overview.en.svg delete mode 100644 web/public/course-assets/s15_agent_teams/agent-teams-overview.ja.svg delete mode 100644 web/public/course-assets/s15_agent_teams/team-topology.en.svg delete mode 100644 web/public/course-assets/s15_agent_teams/team-topology.ja.svg delete mode 100644 web/public/course-assets/s16_team_protocols/team-protocols-overview.en.svg delete mode 100644 web/public/course-assets/s16_team_protocols/team-protocols-overview.ja.svg delete mode 100644 web/public/course-assets/s17_autonomous_agents/autonomous-agents-overview.en.svg delete mode 100644 web/public/course-assets/s17_autonomous_agents/autonomous-agents-overview.ja.svg delete mode 100644 web/public/course-assets/s18_worktree_isolation/worktree-overview.en.svg delete mode 100644 web/public/course-assets/s18_worktree_isolation/worktree-overview.ja.svg delete mode 100644 web/public/course-assets/s19_mcp_plugin/mcp-architecture.en.svg delete mode 100644 web/public/course-assets/s19_mcp_plugin/mcp-architecture.ja.svg delete mode 100644 web/public/course-assets/s20_comprehensive/system-architecture.en.svg delete mode 100644 web/public/course-assets/s20_comprehensive/system-architecture.ja.svg create mode 100644 web/public/course-assets/s21_workflow_runtime/workflow-runtime-overview.svg create mode 100644 web/public/course-assets/s22_goal_loop/goal-loop-overview.svg diff --git a/.gitignore b/.gitignore index ce87dce25..4f8618a6b 100644 --- a/.gitignore +++ b/.gitignore @@ -232,3 +232,14 @@ test_providers.py # Internal analysis artifacts (not learning material) analysis/ analysis_progress.md + +# Local scratch repo copies (not part of the course; contain .venv/node_modules) +/learn-claude-code/ +/learn-pi-agent/ + +# Local Claude Code config & skills (keep local, not published) +.claude/ + +# Local SVG style notes (not published) +/docs/svg-style-guide.md +.runtime/ diff --git a/s01_agent_loop/README.en.md b/s01_agent_loop/README.en.md deleted file mode 100644 index 92f761798..000000000 --- a/s01_agent_loop/README.en.md +++ /dev/null @@ -1,207 +0,0 @@ -# s01: The Agent Loop — One Loop Is All You Need - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20 -> *"One loop & Bash is all you need"* — One tool + one loop = one Agent. -> -> **Harness Layer**: The Loop — the first bridge between the model and the real world. - ---- - -## The Problem - -You ask the model: "List the files in my directory and run XXX.py." - -The model can output a bash command, but once it's done outputting, it stops — it won't execute the command on its own, and it won't keep reasoning based on the result. - -You could run it manually, paste the output back into the chat, and let it continue. Next command comes out, you run it again, paste it back. - -Every round-trip, you're the middle layer. Automating that is what this chapter is about. - ---- - -## The Solution - -![Agent Loop](images/agent-loop.en.svg) - -A `while True` loop: keep going when the model calls a tool, stop when it doesn't. The entire process hinges on two signals: - -| Signal | Meaning | Loop Action | -|--------|---------|-------------| -| `stop_reason == "tool_use"` | Model raises hand: "I need a tool" | Execute → feed result back → continue | -| `stop_reason != "tool_use"` | Model says: "I'm done" | Exit loop | - ---- - -## How It Works - -Let's translate this process into code. Step by step: - -**Step 1**: Start with the user's question as the first message. - -```python -messages = [{"role": "user", "content": query}] -``` - -**Step 2**: Send the messages and tool definitions to the LLM. - -```python -response = client.messages.create( - model=MODEL, system=SYSTEM, messages=messages, - tools=TOOLS, max_tokens=8000, -) -``` - -**Step 3**: Append the model's response and check whether it called a tool. No tool call → done. - -```python -messages.append({"role": "assistant", "content": response.content}) -if response.stop_reason != "tool_use": - return -``` - -**Step 4**: Execute the tool the model requested and collect the results. - -```python -results = [] -for block in response.content: - if block.type == "tool_use": - output = run_bash(block.input["command"]) - results.append({ - "type": "tool_result", - "tool_use_id": block.id, - "content": output, - }) -``` - -**Step 5**: Append the tool results as a new message and go back to Step 2. - -```python -messages.append({"role": "user", "content": results}) -``` - -Assembled into a complete function: - -```python -def agent_loop(messages): - while True: - response = client.messages.create( - model=MODEL, system=SYSTEM, messages=messages, - tools=TOOLS, max_tokens=8000, - ) - messages.append({"role": "assistant", "content": response.content}) - - if response.stop_reason != "tool_use": - return - - results = [] - for block in response.content: - if block.type == "tool_use": - output = run_bash(block.input["command"]) - results.append({ - "type": "tool_result", - "tool_use_id": block.id, - "content": output, - }) - messages.append({"role": "user", "content": results}) -``` - -Under 30 lines — that's the minimal runnable agent harness kernel. It's not intelligence itself, but the smallest runtime framework that lets the model keep acting. The model decides (whether to call a tool, which one), the harness executes (if called, run it, feed the result back). The next 18 chapters all add mechanisms on top of this loop. The loop itself never changes. - ---- - -## Try It - -> **Teaching demo notice**: The code executes shell commands generated by the model. Run it in a temporary test directory to avoid affecting your project files. s03 covers the real permission system. - -**Setup** (first run): - -```sh -pip install -r requirements.txt -cp .env.example .env -# Edit .env, fill in ANTHROPIC_API_KEY and MODEL_ID -``` - -**Run**: - -```sh -python s01_agent_loop/code.py -``` - -Try these prompts: - -1. `Create a file called hello.py that prints "Hello, World!"` -2. `List all Python files in this directory` -3. `What is the current git branch?` - -What to watch for: When does the model call a tool (loop continues), and when does it not (loop ends)? - ---- - -## What's Next - -Right now the model only has bash — reading files requires `cat`, writing files requires `echo ... >`, finding files requires `find`. Ugly and error-prone. - -→ s02 Tool Use: What happens when we give it 5 proper tools? Will the model call multiple tools at once? Will parallel tool executions step on each other? - -
-Dive into CC Source Code - -> The following is based on a review of CC source code `src/query.ts` (1729 lines). The core differences are twofold: CC doesn't rely on the `stop_reason` field to decide whether to continue the loop — instead it checks whether the content contains `tool_use` blocks (because `stop_reason` is unreliable in streaming responses); CC has more exit paths and recovery strategies for production-grade protection. - -**The 30-line `while True` from the teaching version IS the core of CC's 1729 lines.** Everything below is a protection mechanism layered on top of that core. - -
-1. Loop Structure Differences - -The teaching version checks `response.stop_reason`. CC doesn't use it as the sole signal for loop continuation — in streaming responses, `stop_reason` may not have updated yet even though `tool_use` blocks are already present. CC uses a `needsFollowUp` flag: during streaming message reception (`query.ts:830-834`), it's set to `true` whenever a `tool_use` block is detected. `QueryEngine.ts` captures the real `stop_reason` from `message_delta` for other logic, but the query loop itself relies on `needsFollowUp`. - -```typescript -// query.ts:554-558 -// stop_reason === 'tool_use' is unreliable. -// Set during streaming whenever a tool_use block arrives. -let needsFollowUp = false -``` - -
- -
-2. State Object — 10 Fields (Teaching Version Only Uses messages) - -| # | Field | Purpose | Chapter | -|---|-------|---------|---------| -| 1 | `messages` | Message array for the current iteration | s01 | -| 2 | `toolUseContext` | Tool, signal, and permission context | s02 | -| 3 | `autoCompactTracking` | Compaction state tracking | s08 | -| 4 | `maxOutputTokensRecoveryCount` | Token recovery attempt count (max 3) | s11 | -| 5 | `hasAttemptedReactiveCompact` | Whether reactive compaction was attempted this round | s08 | -| 6 | `maxOutputTokensOverride` | 8K→64K upgrade override | s11 | -| 7 | `pendingToolUseSummary` | Background Haiku-generated tool use summary | s08 | -| 8 | `stopHookActive` | Whether the stop hook produced a blocking error | s04 | -| 9 | `turnCount` | Turn count (for maxTurns check) | s01 | -| 10 | `transition` | Last continue reason | s11 | - -> Note: `taskBudgetRemaining` (`query.ts:291`) is a loop-local variable, not on State. The source comment explicitly says "Loop-local (not on State)". - -
- -
-3. Multiple Exit and Continue Paths - -The teaching version has only 1 exit path (model doesn't call a tool → done). The production version has multiple exit and continue paths, covering blocking limit, prompt too long, model error, abort, hook stop, max turns, token budget continuation, reactive compact retry, and more. Each scenario has a corresponding recovery or exit strategy. - -
- -
-4. Streaming Tool Execution and QueryEngine - -CC's `StreamingToolExecutor` (`query.ts:561`) allows tools to begin parallel execution while the model is still generating (concurrency-safe tools run in parallel, others run exclusively). `QueryEngine.ts` adds additional protections for cost overruns, structured output validation failures, and more. The teaching version doesn't implement these — the goal is conceptual clarity, not peak performance. - -
- -**In one sentence**: The core of query.ts's 1729 lines is a 30-line `while True`. All the complex fields and exit paths are protection mechanisms. Understand the core loop first, and everything that follows unfolds naturally. - -
- - diff --git a/s01_agent_loop/README.ja.md b/s01_agent_loop/README.ja.md index 636af477c..b074e50ea 100644 --- a/s01_agent_loop/README.ja.md +++ b/s01_agent_loop/README.ja.md @@ -1,6 +1,6 @@ # s01: Agent Loop — ループ一つで十分 -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) `s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20 > *"One loop & Bash is all you need"* — ツール一つ + ループ一つ = 一つの Agent。 @@ -13,7 +13,7 @@ モデルにこう頼んだとする:「ディレクトリ内のファイル一覧を取得して、XXX.py を実行して」。 -モデルは bash コマンドを出力できるが、出力が終わると止まってしまう — 自分で実行することも、結果を見て推論を続けることもない。 +モデルは bash コマンドを出力できるが、出力が終わると止まってしまう。自分で実行することも、結果を見て推論を続けることもない。 手動で実行し、出力をチャットに貼り付ければ、モデルは続きを生成できる。次のコマンドが出たら、また実行して貼り付ける。 @@ -23,14 +23,14 @@ ## ソリューション -![Agent Loop](images/agent-loop.ja.svg) +![Agent Loop](images/agent-loop.svg) 一つの `while True` ループ — モデルがツールを呼べば続き、呼ばなければ停止。全体でたった 2 つのシグナル: | シグナル | 意味 | ループの動作 | |----------|------|-------------| -| `stop_reason == "tool_use"` | モデルが「ツールが必要」と挙手 | 実行 → 結果を戻す → 続行 | -| `stop_reason != "tool_use"` | モデルが「完了」と宣言 | ループ終了 | +| `stop_reason == "tool_use"` | モデルがツール呼び出しを要求 | 実行 → 結果を戻す → 続行 | +| `stop_reason != "tool_use"` | モデルがツール呼び出しなしで生成終了 | ループ終了 | --- @@ -107,7 +107,7 @@ def agent_loop(messages): messages.append({"role": "user", "content": results}) ``` -30 行未満 — これが最小実行可能な agent harness のカーネルだ。これは知能そのものではなく、モデルが継続的に行動できるための最小ランタイムフレームワーク。モデルが決定し(ツールを呼ぶか、どれを呼ぶか)、harness が実行する(呼ばれたら実行し、結果を戻す)。次の 18 章はすべてこのループの上に仕組みを積み重ねていく。ループ自体は永遠に変わらない。 +30 行未満、これが最小実行可能な agent harness のカーネルだ。これは知能そのものではなく、モデルが継続的に行動できるための最小ランタイムフレームワーク。モデルが決定し(ツールを呼ぶか、どれを呼ぶか)、harness が実行する(呼ばれたら実行し、結果を戻す)。次の 18 章はすべてこのループの上に仕組みを積み重ねていく。ループ自体は永遠に変わらない。 --- @@ -146,16 +146,16 @@ python s01_agent_loop/code.py → s02 Tool Use:5 つの本格的なツールを与えたらどうなる? モデルは複数のツールを同時に呼び出すか? 並列実行で競合は起きないか?
-CC ソースコードを深掘り +Claude Code ソースコードを深掘り -> 以下は CC ソースコード `src/query.ts`(1729 行)の検証に基づく。核心的な違いは二つ:CC はループ継続の判断に `stop_reason` フィールドを頼らず、コンテンツに `tool_use` ブロックが含まれるかをチェックする(ストリーミングレスポンスでは `stop_reason` が信頼できないため)。CC には本番環境向けのより多くの終了パスとリカバリ戦略がある。 +> 以下は Claude Code ソースコード `src/query.ts`(1729 行)の検証に基づく。核心的な違いは二つ:Claude Code はループ継続の判断に `stop_reason` フィールドを頼らず、コンテンツに `tool_use` ブロックが含まれるかをチェックする(ストリーミングレスポンスでは `stop_reason` が信頼できないため)。Claude Code には本番環境向けのより多くの終了パスとリカバリ戦略がある。 -**教育版の 30 行 `while True` が CC の 1729 行の核心。** 以下の各項目は、すべてその核心の上に積み重ねられた保護機構である。 +以下の各項目は、すべて教育版のループの上に積み重ねられた保護機構である。
一、ループ構造の違い -教育版は `response.stop_reason` をチェックする。CC はこれをループ継続の唯一の根拠として使わない — ストリーミングレスポンスでは、`stop_reason` がまだ更新されていなくても、コンテンツに既に `tool_use` ブロックが含まれている可能性がある。CC は `needsFollowUp` フラグを使用する:ストリーミングメッセージの受信時(`query.ts:830-834`)に、`tool_use` ブロックが検出されると `true` に設定される。`QueryEngine.ts` は `message_delta` から実際の `stop_reason` を取得して他の処理に利用するが、query loop 自体は `needsFollowUp` に依存する。 +教育版は `response.stop_reason` をチェックする。Claude Code はこれをループ継続の唯一の根拠として使わない — ストリーミングレスポンスでは、`stop_reason` がまだ更新されていなくても、コンテンツに既に `tool_use` ブロックが含まれている可能性がある。Claude Code は `needsFollowUp` フラグを使用する:ストリーミングメッセージの受信時(`query.ts:830-834`)に、`tool_use` ブロックが検出されると `true` に設定される。`QueryEngine.ts` は `message_delta` から実際の `stop_reason` を取得して他の処理に利用するが、query loop 自体は `needsFollowUp` に依存する。 ```typescript // query.ts:554-558 @@ -196,11 +196,11 @@ let needsFollowUp = false
四、ストリーミングツール実行と QueryEngine -CC の `StreamingToolExecutor`(`query.ts:561`)は、モデルがまだ生成中にツールの実行を開始できる(concurrency-safe なツールは並列、それ以外は排他実行)。`QueryEngine.ts` はさらに、コスト超過や構造化出力の検証失敗などの保護を追加する。教育版はこれらを実装しない — 目標は概念の明確さであり、極限のパフォーマンスではない。 +Claude Code の `StreamingToolExecutor`(`query.ts:561`)は、モデルがまだ生成中にツールの実行を開始できる(concurrency-safe なツールは並列、それ以外は排他実行)。`QueryEngine.ts` はさらに、コスト超過や構造化出力の検証失敗などの保護を追加する。教育版はこれらを実装しない — 目標は概念の明確さであり、極限のパフォーマンスではない。
-**一言で**: query.ts の 1729 行の核心は 30 行の `while True`。複雑なフィールドや終了パスはすべて保護機構だ。まず核心のループを理解すれば、その後のすべては自然に理解できる。 +複雑なフィールドや終了パスはすべて保護機構であり、核心のループを理解していれば読み解きやすい。
diff --git a/s01_agent_loop/README.md b/s01_agent_loop/README.md index 3b109715a..83e819180 100644 --- a/s01_agent_loop/README.md +++ b/s01_agent_loop/README.md @@ -1,50 +1,50 @@ -# s01: Agent Loop — 一个循环就够了 +# s01: The Agent Loop — One Loop Is All You Need -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) `s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20 -> *"One loop & Bash is all you need"* — 一个工具 + 一个循环 = 一个 Agent。 +> *"One loop & Bash is all you need"* — One tool + one loop = one Agent. > -> **Harness 层**: 循环 — 模型与真实世界的第一道连接。 +> **Harness Layer**: The Loop — the first bridge between the model and the real world. --- -## 问题 +## The Problem -你提出了一个问题给大模型:“帮我读取下我的目录下有哪些文件,并且执行XXX.py”。 +You ask the model: "List the files in my directory and run XXX.py." -模型能输出一条 bash 命令,但输出完了就停了,它不会自己跑,也不会看到结果后继续推理。 +The model can output a bash command, but once it's done outputting, it stops. It won't execute the command on its own, and it won't keep reasoning based on the result. -你可以手动跑一遍,把输出粘贴回对话框,让它接着干。下一个命令出来,你再跑一遍、再贴回去。 +You could run it manually, paste the output back into the chat, and let it continue. Next command comes out, you run it again, paste it back. -每一个来回,你都在做中间层。而把它自动化,就是这一章要做的事。 +Every round-trip, you're the middle layer. Automating that is what this chapter is about. --- -## 解决方案 +## The Solution ![Agent Loop](images/agent-loop.svg) -一个 `while True` 循环,模型调用工具就继续,不调用就停。整个过程只有两个信号: +A `while True` loop: keep going when the model calls a tool, stop when it doesn't. The entire process hinges on two signals: -| 信号 | 含义 | 循环动作 | -|------|------|---------| -| `stop_reason == "tool_use"` | 模型举手说"我要用工具" | 执行 → 结果喂回去 → 继续 | -| `stop_reason != "tool_use"` | 模型说"我做完了" | 退出循环 | +| Signal | Meaning | Loop Action | +|--------|---------|-------------| +| `stop_reason == "tool_use"` | Model requests a tool call | Execute → feed result back → continue | +| `stop_reason != "tool_use"` | Model returns without a tool call | Exit loop | --- -## 工作原理 +## How It Works -将这个过程翻译成代码。分步来看: +Let's translate this process into code. Step by step: -**第 1 步**:把用户的问题作为第一条消息。 +**Step 1**: Start with the user's question as the first message. ```python messages = [{"role": "user", "content": query}] ``` -**第 2 步**:将消息和工具定义一起发给 LLM。 +**Step 2**: Send the messages and tool definitions to the LLM. ```python response = client.messages.create( @@ -53,7 +53,7 @@ response = client.messages.create( ) ``` -**第 3 步**:追加模型回答,检查它是否调了工具。没调 → 结束。 +**Step 3**: Append the model's response and check whether it called a tool. No tool call → done. ```python messages.append({"role": "assistant", "content": response.content}) @@ -61,7 +61,7 @@ if response.stop_reason != "tool_use": return ``` -**第 4 步**:执行模型要求的工具,收集结果。 +**Step 4**: Execute the tool the model requested and collect the results. ```python results = [] @@ -75,13 +75,13 @@ for block in response.content: }) ``` -**第 5 步**:把工具结果作为新消息追加,回到第 2 步。 +**Step 5**: Append the tool results as a new message and go back to Step 2. ```python messages.append({"role": "user", "content": results}) ``` -组装为一个完整函数: +Assembled into a complete function: ```python def agent_loop(messages): @@ -107,55 +107,55 @@ def agent_loop(messages): messages.append({"role": "user", "content": results}) ``` -不到 30 行,这就是最小可运行的 agent harness 内核。它不是智能本身,而是让模型能持续行动的最小运行框架,模型负责决策(要不要调工具、调哪个),harness 负责执行(调了就跑、结果喂回去)。后面 18 个章节都在这个循环上叠加机制,循环本身始终不变。 +Under 30 lines — that's the minimal runnable agent harness kernel. It's not intelligence itself, but the smallest runtime framework that lets the model keep acting. The model decides (whether to call a tool, which one), the harness executes (if called, run it, feed the result back). The next 18 chapters all add mechanisms on top of this loop. The loop itself never changes. --- -## 试一下 +## Try It -> **教学 demo 提示**:代码会执行模型生成的 shell 命令。建议在一个临时测试目录中运行,避免影响你的项目文件。s03 会讲真正的权限系统。 +> **Teaching demo notice**: The code executes shell commands generated by the model. Run it in a temporary test directory to avoid affecting your project files. s03 covers the real permission system. -**准备**(首次运行): +**Setup** (first run): ```sh pip install -r requirements.txt cp .env.example .env -# 编辑 .env,填入 ANTHROPIC_API_KEY 和 MODEL_ID +# Edit .env, fill in ANTHROPIC_API_KEY and MODEL_ID ``` -**运行**: +**Run**: ```sh python s01_agent_loop/code.py ``` -试试这些 prompt: +Try these prompts: 1. `Create a file called hello.py that prints "Hello, World!"` 2. `List all Python files in this directory` 3. `What is the current git branch?` -观察重点:模型什么时候调用工具(循环继续),什么时候不调用(循环结束)? +What to watch for: When does the model call a tool (loop continues), and when does it not (loop ends)? --- -## 接下来 +## What's Next -现在模型手里只有 bash 一个工具,读文件要 `cat`,写文件要 `echo ... >`,找个文件要 `find`,又丑又容易出错。 +Right now the model only has bash: reading files requires `cat`, writing files requires `echo ... >`, finding files requires `find`. Ugly and error-prone. -s02 Tool Use → 给它 5 个真正的工具,会发生什么?模型会不会一次调用多个工具?几个工具同时跑会不会互相踩? +→ s02 Tool Use: What happens when we give it 5 proper tools? Will the model call multiple tools at once? Will parallel tool executions step on each other?
-深入 CC 源码 +Dive into Claude Code Source Code -> 以下内容基于 CC 源码 `src/query.ts`(1729 行)的核查。核心差异就两个:CC 不看 `stop_reason` 字段而是检查内容里有没有 tool_use 块(因为流式响应中 stop_reason 不可靠);CC 有更多的退出路径和恢复策略做生产级保护。 +> The following is based on a review of Claude Code source code `src/query.ts` (1729 lines). The core differences are twofold: Claude Code doesn't rely on the `stop_reason` field to decide whether to continue the loop — instead it checks whether the content contains `tool_use` blocks (because `stop_reason` is unreliable in streaming responses); Claude Code has more exit paths and recovery strategies for production-grade protection. -**教学版的 30 行 `while True` 就是 CC 1729 行的核心。** 下面每一项都是在这个核心上叠加的保护机制。 +Everything below is a protection mechanism layered on top of the teaching version's loop.
-一、循环结构差异 +1. Loop Structure Differences -教学版检查 `response.stop_reason`。CC 不把它作为循环继续的唯一依据——流式响应中 `stop_reason` 可能还没更新但内容里已经有 `tool_use` 块了。CC 用 `needsFollowUp` 标志:接收到流式消息时(`query.ts:830-834`),只要检测到 `tool_use` 块就设为 `true`;`QueryEngine.ts` 会从 `message_delta` 捕获真实 `stop_reason` 用于其他逻辑,但 query loop 本身靠 `needsFollowUp` 决定是否继续。 +The teaching version checks `response.stop_reason`. Claude Code doesn't use it as the sole signal for loop continuation — in streaming responses, `stop_reason` may not have updated yet even though `tool_use` blocks are already present. Claude Code uses a `needsFollowUp` flag: during streaming message reception (`query.ts:830-834`), it's set to `true` whenever a `tool_use` block is detected. `QueryEngine.ts` captures the real `stop_reason` from `message_delta` for other logic, but the query loop itself relies on `needsFollowUp`. ```typescript // query.ts:554-558 @@ -167,41 +167,41 @@ let needsFollowUp = false
-二、State 对象 10 字段(教学版只用 messages) - -| # | 字段 | 用途 | 对应章节 | -|---|------|------|---------| -| 1 | `messages` | 当前迭代的消息数组 | s01 | -| 2 | `toolUseContext` | 工具、信号、权限上下文 | s02 | -| 3 | `autoCompactTracking` | 压缩状态追踪 | s08 | -| 4 | `maxOutputTokensRecoveryCount` | token 恢复尝试次数(上限 3) | s11 | -| 5 | `hasAttemptedReactiveCompact` | 本轮是否已尝试响应式压缩 | s08 | -| 6 | `maxOutputTokensOverride` | 8K→64K 的升级覆盖 | s11 | -| 7 | `pendingToolUseSummary` | 后台 Haiku 生成的 tool use 摘要 | s08 | -| 8 | `stopHookActive` | 停止钩子是否产生阻塞错误 | s04 | -| 9 | `turnCount` | 轮次计数(maxTurns 检查) | s01 | -| 10 | `transition` | 上一次继续原因 | s11 | - -> 注:`taskBudgetRemaining`(`query.ts:291`)是 loop-local 局部变量,不在 State 上。源码注释明确写了 "Loop-local (not on State)"。 +2. State Object — 10 Fields (Teaching Version Only Uses messages) + +| # | Field | Purpose | Chapter | +|---|-------|---------|---------| +| 1 | `messages` | Message array for the current iteration | s01 | +| 2 | `toolUseContext` | Tool, signal, and permission context | s02 | +| 3 | `autoCompactTracking` | Compaction state tracking | s08 | +| 4 | `maxOutputTokensRecoveryCount` | Token recovery attempt count (max 3) | s11 | +| 5 | `hasAttemptedReactiveCompact` | Whether reactive compaction was attempted this round | s08 | +| 6 | `maxOutputTokensOverride` | 8K→64K upgrade override | s11 | +| 7 | `pendingToolUseSummary` | Background Haiku-generated tool use summary | s08 | +| 8 | `stopHookActive` | Whether the stop hook produced a blocking error | s04 | +| 9 | `turnCount` | Turn count (for maxTurns check) | s01 | +| 10 | `transition` | Last continue reason | s11 | + +> Note: `taskBudgetRemaining` (`query.ts:291`) is a loop-local variable, not on State. The source comment explicitly says "Loop-local (not on State)".
-三、多条退出和继续路径 +3. Multiple Exit and Continue Paths -教学版只有 1 条退出路径(模型不调工具就结束)。生产版有多条退出和继续路径,覆盖 blocking limit、prompt too long、model error、abort、hook stop、max turns、token budget continuation、reactive compact retry 等场景。每种场景都有对应的恢复或退出策略。 +The teaching version has only 1 exit path (model doesn't call a tool → done). The production version has multiple exit and continue paths, covering blocking limit, prompt too long, model error, abort, hook stop, max turns, token budget continuation, reactive compact retry, and more. Each scenario has a corresponding recovery or exit strategy.
-四、流式工具执行和 QueryEngine +4. Streaming Tool Execution and QueryEngine -CC 的 `StreamingToolExecutor`(`query.ts:561`)让工具在模型还在生成时就开始并行执行(根据工具是否 concurrency-safe 决定并发或独占)。`QueryEngine.ts` 额外加了费用超限、结构化输出验证失败等保护。教学版不实现这些——目标是概念清晰,不是性能极致。 +Claude Code's `StreamingToolExecutor` (`query.ts:561`) allows tools to begin parallel execution while the model is still generating (concurrency-safe tools run in parallel, others run exclusively). `QueryEngine.ts` adds additional protections for cost overruns, structured output validation failures, and more. The teaching version doesn't implement these — the goal is conceptual clarity, not peak performance.
-**一句话**:1729 行的 query.ts 核心就是 30 行 `while True`。所有复杂字段和退出路径都是保护机制。先理解核心循环,后面的一切自然展开。 +All the complex fields and exit paths are protection mechanisms. Understanding the core loop makes the rest easier to follow.
- + diff --git a/s01_agent_loop/README.zh.md b/s01_agent_loop/README.zh.md new file mode 100644 index 000000000..12776730c --- /dev/null +++ b/s01_agent_loop/README.zh.md @@ -0,0 +1,207 @@ +# s01: Agent Loop — 一个循环就够了 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +`s01` → [s02](../s02_tool_use/) → s03 → s04 → ... → s20 +> *"One loop & Bash is all you need"* — 一个工具 + 一个循环 = 一个 Agent。 +> +> **Harness 层**: 循环 — 模型与真实世界的第一道连接。 + +--- + +## 问题 + +你提出了一个问题给大模型:“帮我读取下我的目录下有哪些文件,并且执行XXX.py”。 + +模型能输出一条 bash 命令,但输出完了就停了,它不会自己跑,也不会看到结果后继续推理。 + +你可以手动跑一遍,把输出粘贴回对话框,让它接着干。下一个命令出来,你再跑一遍、再贴回去。 + +每一个来回,你都在做中间层。而把它自动化,就是这一章要做的事。 + +--- + +## 解决方案 + +![Agent Loop](images/agent-loop.svg) + +一个 `while True` 循环,模型调用工具就继续,不调用就停。整个过程只有两个信号: + +| 信号 | 含义 | 循环动作 | +|------|------|---------| +| `stop_reason == "tool_use"` | 模型请求调用工具 | 执行 → 结果喂回去 → 继续 | +| `stop_reason != "tool_use"` | 模型未请求工具,生成结束 | 退出循环 | + +--- + +## 工作原理 + +将这个过程翻译成代码。分步来看: + +**第 1 步**:把用户的问题作为第一条消息。 + +```python +messages = [{"role": "user", "content": query}] +``` + +**第 2 步**:将消息和工具定义一起发给 LLM。 + +```python +response = client.messages.create( + model=MODEL, system=SYSTEM, messages=messages, + tools=TOOLS, max_tokens=8000, +) +``` + +**第 3 步**:追加模型回答,检查它是否调了工具。没调 → 结束。 + +```python +messages.append({"role": "assistant", "content": response.content}) +if response.stop_reason != "tool_use": + return +``` + +**第 4 步**:执行模型要求的工具,收集结果。 + +```python +results = [] +for block in response.content: + if block.type == "tool_use": + output = run_bash(block.input["command"]) + results.append({ + "type": "tool_result", + "tool_use_id": block.id, + "content": output, + }) +``` + +**第 5 步**:把工具结果作为新消息追加,回到第 2 步。 + +```python +messages.append({"role": "user", "content": results}) +``` + +组装为一个完整函数: + +```python +def agent_loop(messages): + while True: + response = client.messages.create( + model=MODEL, system=SYSTEM, messages=messages, + tools=TOOLS, max_tokens=8000, + ) + messages.append({"role": "assistant", "content": response.content}) + + if response.stop_reason != "tool_use": + return + + results = [] + for block in response.content: + if block.type == "tool_use": + output = run_bash(block.input["command"]) + results.append({ + "type": "tool_result", + "tool_use_id": block.id, + "content": output, + }) + messages.append({"role": "user", "content": results}) +``` + +不到 30 行,这就是最小可运行的 agent harness 内核。它不是智能本身,而是让模型能持续行动的最小运行框架,模型负责决策(要不要调工具、调哪个),harness 负责执行(调了就跑、结果喂回去)。后面 18 个章节都在这个循环上叠加机制,循环本身始终不变。 + +--- + +## 试一下 + +> **教学 demo 提示**:代码会执行模型生成的 shell 命令。建议在一个临时测试目录中运行,避免影响你的项目文件。s03 会讲真正的权限系统。 + +**准备**(首次运行): + +```sh +pip install -r requirements.txt +cp .env.example .env +# 编辑 .env,填入 ANTHROPIC_API_KEY 和 MODEL_ID +``` + +**运行**: + +```sh +python s01_agent_loop/code.py +``` + +试试这些 prompt: + +1. `Create a file called hello.py that prints "Hello, World!"` +2. `List all Python files in this directory` +3. `What is the current git branch?` + +观察重点:模型什么时候调用工具(循环继续),什么时候不调用(循环结束)? + +--- + +## 接下来 + +现在模型手里只有 bash 一个工具,读文件要 `cat`,写文件要 `echo ... >`,找个文件要 `find`,不够直观,也容易拼错。 + +s02 Tool Use → 给它 5 个真正的工具,会发生什么?模型会不会一次调用多个工具?几个工具同时跑会不会互相踩? + +
+深入 Claude Code 源码 + +> 以下内容基于 Claude Code 源码 `src/query.ts`(1729 行)的核查。核心差异就两个:Claude Code 不看 `stop_reason` 字段而是检查内容里有没有 tool_use 块(因为流式响应中 stop_reason 不可靠);Claude Code 有更多的退出路径和恢复策略做生产级保护。 + +下面每一项都是在教学版循环基础上叠加的保护机制。 + +
+一、循环结构差异 + +教学版检查 `response.stop_reason`。Claude Code 不把它作为循环继续的唯一依据——流式响应中 `stop_reason` 可能还没更新但内容里已经有 `tool_use` 块了。Claude Code 用 `needsFollowUp` 标志:接收到流式消息时(`query.ts:830-834`),只要检测到 `tool_use` 块就设为 `true`;`QueryEngine.ts` 会从 `message_delta` 捕获真实 `stop_reason` 用于其他逻辑,但 query loop 本身靠 `needsFollowUp` 决定是否继续。 + +```typescript +// query.ts:554-558 +// stop_reason === 'tool_use' is unreliable. +// Set during streaming whenever a tool_use block arrives. +let needsFollowUp = false +``` + +
+ +
+二、State 对象 10 字段(教学版只用 messages) + +| # | 字段 | 用途 | 对应章节 | +|---|------|------|---------| +| 1 | `messages` | 当前迭代的消息数组 | s01 | +| 2 | `toolUseContext` | 工具、信号、权限上下文 | s02 | +| 3 | `autoCompactTracking` | 压缩状态追踪 | s08 | +| 4 | `maxOutputTokensRecoveryCount` | token 恢复尝试次数(上限 3) | s11 | +| 5 | `hasAttemptedReactiveCompact` | 本轮是否已尝试响应式压缩 | s08 | +| 6 | `maxOutputTokensOverride` | 8K→64K 的升级覆盖 | s11 | +| 7 | `pendingToolUseSummary` | 后台 Haiku 生成的 tool use 摘要 | s08 | +| 8 | `stopHookActive` | 停止钩子是否产生阻塞错误 | s04 | +| 9 | `turnCount` | 轮次计数(maxTurns 检查) | s01 | +| 10 | `transition` | 上一次继续原因 | s11 | + +> 注:`taskBudgetRemaining`(`query.ts:291`)是 loop-local 局部变量,不在 State 上。源码注释明确写了 "Loop-local (not on State)"。 + +
+ +
+三、多条退出和继续路径 + +教学版只有 1 条退出路径(模型不调工具就结束)。生产版有多条退出和继续路径,覆盖 blocking limit、prompt too long、model error、abort、hook stop、max turns、token budget continuation、reactive compact retry 等场景。每种场景都有对应的恢复或退出策略。 + +
+ +
+四、流式工具执行和 QueryEngine + +Claude Code 的 `StreamingToolExecutor`(`query.ts:561`)让工具在模型还在生成时就开始并行执行(根据工具是否 concurrency-safe 决定并发或独占)。`QueryEngine.ts` 额外加了费用超限、结构化输出验证失败等保护。教学版不实现这些——目标是概念清晰,不是性能极致。 + +
+ +所有复杂字段和退出路径都是保护机制,理解核心循环后再看这些会更容易。 + +
+ + diff --git a/s01_agent_loop/images/agent-loop.en.svg b/s01_agent_loop/images/agent-loop.en.svg deleted file mode 100644 index 541ab3f96..000000000 --- a/s01_agent_loop/images/agent-loop.en.svg +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - Agent Loop — A while Loop Drives the Entire Agent - - - - User Query - "Create hello.py for me" - - - - - - - messages[] - Accumulated message list - - - - - - - LLM - - Model reads message history - Decision: Need a tool? - Returns stop_reason signal - - - - - - - stop_reason - == "tool_use"? - - - - No - - - - Return Result - Loop Ends - - - - Yes - - - - Execute Tool Call - run_bash(command) - - - - Append tool_result to messages - - - - Core: a - while True - loop. Model calls tool → Execute → Feed back → Ask again. No tool call → Stop. - All subsequent chapters layer mechanisms on top of this loop. - diff --git a/s01_agent_loop/images/agent-loop.ja.svg b/s01_agent_loop/images/agent-loop.ja.svg deleted file mode 100644 index ee726e697..000000000 --- a/s01_agent_loop/images/agent-loop.ja.svg +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - Agent Loop — 一つの while ループで Agent 全体を駆動 - - - - ユーザーの質問 - "hello.py を作って" - - - - - - - messages[] - 累積メッセージリスト - - - - - - - 大規模言語モデル (LLM) - - モデルがメッセージ履歴を読む - 判断:ツールが必要か? - stop_reason シグナルを返す - - - - - - - stop_reason - == "tool_use"? - - - - No - - - - 結果を返す - ループ終了 - - - - Yes - - - - ツール呼び出しを実行 - run_bash(command) - - - - tool_result を messages に追加 - - - - 核心:一つの - while True - ループ。ツール呼出 → 実行 → 結果を戻す → 再度問う。ツールなし → 停止。 - 以降の全章がこのループの上に仕組みを積み重ねる。 - diff --git a/s01_agent_loop/images/agent-loop.svg b/s01_agent_loop/images/agent-loop.svg index 87c6b5008..0c4af8c26 100644 --- a/s01_agent_loop/images/agent-loop.svg +++ b/s01_agent_loop/images/agent-loop.svg @@ -1,86 +1,69 @@ - + - - + + - - + + - - - - - - - - - - - - - - Agent Loop — 一个 while 循环驱动整个 Agent + - - - 用户提问 - "帮我创建 hello.py" + + Agent Loop + a while True loop drives the entire agent - - + + + User Query - - - messages[] - 累积式消息列表 + - - + + + messages[] + accumulated message history - - - 大模型 (LLM) - - 模型阅读消息历史 - 判断:需要工具吗? - 返回 stop_reason 信号 + - - + + + LLM Call + + messages.create( model, messages, tools ) - - - stop_reason - == "tool_use"? + - - - + + + stop_reason == "tool_use" ? - - - 返回结果 - 循环结束 + + + No + + Return - - - + + + Yes - - - 执行工具调用 - run_bash(command) + + + Execute Tool + + + $ run_bash(command) + - - - 追加 tool_result 到 messages + + + + + - - - 核心:一个 - while True - 循环。模型调工具 → 执行 → 喂回 → 再问。不调工具就停。 - 后续所有章节都在这个循环上叠加机制。 + + append tool_result to messages diff --git a/s02_tool_use/README.en.md b/s02_tool_use/README.en.md deleted file mode 100644 index b939810a6..000000000 --- a/s02_tool_use/README.en.md +++ /dev/null @@ -1,222 +0,0 @@ -# s02: Tool Use — Add a Tool, Add Just One Line - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20 -> *"Add a tool, add just one handler"* — The loop stays the same. Register the new tool in the dispatch map and you're done. -> -> **Harness Layer**: Tool Dispatch — Expanding the model's reach. - ---- - -## Only One Tool: Bash - -The s01 Agent has only one tool: bash. To read a file, `cat`; to write, `echo "..." > file.py`; to edit, `sed`. - -The model thinks "read this file" but has to spell out `cat path/to/file`. An extra layer of translation that wastes tokens and invites errors. - ---- - -## Overview: Tool Dispatch - -![Tool Dispatch](images/tool-dispatch.en.svg) - -The s01 loop is fully preserved (LLM call, stop_reason check, message append — not a single word changed). The only change is in that one line of tool execution: `run_bash()` is replaced with `TOOL_HANDLERS[block.name]()` dispatch lookup. - -Adding a tool to the Agent requires just two things: - -1. **Define the tool**: Add one entry to the `TOOLS` array -2. **Register the handler**: Add one mapping in the `TOOL_HANDLERS` dict - ---- - -## From 1 Tool to 5 Tools - -s01 had only bash: - -```python -TOOLS = [{"name": "bash", ...}] - -def run_bash(command): ... -``` - -s02 expands to 5 tools, each independently defined: - -```python -TOOLS = [ - {"name": "bash", "description": "Run a shell command.", ...}, - {"name": "read_file", "description": "Read file contents.", ...}, - {"name": "write_file", "description": "Write content to file.", ...}, - {"name": "edit_file", "description": "Replace text in file once.", ...}, - {"name": "glob", "description": "Find files by pattern.", ...}, -] -``` - -Each tool has its own implementation function: - -```python -def run_read(path, limit=None): - lines = safe_path(path).read_text().splitlines() - if limit: - lines = lines[:limit] - return "\n".join(lines) - -def run_write(path, content): - safe_path(path).write_text(content) - return f"Wrote {len(content)} bytes to {path}" - -def run_edit(path, old_text, new_text): - text = safe_path(path).read_text() - if old_text not in text: - return "Error: text not found" - safe_path(path).write_text(text.replace(old_text, new_text, 1)) - return f"Edited {path}" - -def run_glob(pattern): - import glob as g - return "\n".join(g.glob(pattern, root_dir=WORKDIR)) -``` - ---- - -## Tool Dispatch - -```python -TOOL_HANDLERS = { - "bash": run_bash, - "read_file": run_read, - "write_file": run_write, - "edit_file": run_edit, - "glob": run_glob, -} - -# Only one line changed in the loop — from hardcoded run_bash to dispatch lookup: -for block in response.content: - if block.type == "tool_use": - handler = TOOL_HANDLERS[block.name] # lookup - output = handler(**block.input) # call - results.append(...) -``` - -Adding a tool = one entry in `TOOLS` array + one line in `TOOL_HANDLERS` dict. The loop stays the same. - ---- - -## Multiple Tool Calls - -The model often returns multiple tool_use calls at once — "read a.py and b.py, then list all .py files". - -The teaching version executes them one by one in the original `response.content` order. CC's approach is more complex: it slices the original order into consecutive batches, where concurrency-safe tools within a batch run in parallel, and batches are strictly sequential (see appendix). - ---- - -## Quick Reference - -| Concept | One-Liner | -|---------|-----------| -| TOOL_HANDLERS | Tool name → handler function dict. Add a tool = add one mapping line | -| Tool Definition | JSON schema telling the model "what I can do" | -| Multiple tool calls | Model may return multiple tool_use at once; teaching version executes them in original order | -| Loop Unchanged | s01's `while True` loop — not a single line changed | - ---- - -## Changes from s01 - -| Component | Before (s01) | After (s02) | -|-----------|-------------|-------------| -| Tool count | 1 (bash) | 5 (+read, write, edit, glob) | -| Tool execution | Hardcoded `run_bash()` | TOOL_HANDLERS dispatch lookup | -| Path safety | None | safe_path validation (file tools only) | -| Loop | `while True` + `stop_reason` | Identical to s01 | - ---- - -## Try It - -```sh -cd learn-claude-code -python s02_tool_use/code.py -``` - -Try these prompts: - -1. `Read the file README.md and tell me what this project is about` -2. `Create a file called test.py that prints "hello", then read it back` -3. `Find all Python files in this directory` -4. `Read both README.md and requirements.txt, then create a summary file` - -What to watch for: When does the model call just one tool, and when does it call multiple at once? Are multiple tool calls executed in the correct order? - ---- - -## What's Next - -The Agent now has 5 specialized tools. File tools are protected by `safe_path`, but bash is unrestricted — `rm -rf /` still runs. - -→ s03 Permission: Add a gate before tool execution — is this operation safe? Does it need user approval? - -
-Dive into CC Source Code - -> The following is based on a review of CC source code `Tool.ts`, `tools.ts`, `toolOrchestration.ts`, `toolExecution.ts`, and `StreamingToolExecutor.ts`. - -### 1. Tool Definition Approach - -**Teaching version**: `TOOLS` array + `TOOL_HANDLERS` dict. Definition and implementation are separate. -**CC**: Each tool is an independent object created by `buildTool()`, containing schema, validation, permissions, and execution. `getAllBaseTools()` aggregates all tools. - -The teaching version's separation is clearer for teaching — readers immediately see "add a tool = two definitions". - -### 2. Concurrency Safety: isConcurrencySafe() - -![Tool Concurrency](images/concurrency-comparison.en.svg) - -The teaching version executes tools one by one in original order, without concurrency. CC uses `isConcurrencySafe(input)` to determine concurrency — note this isn't simply "read-only vs write", but judges by specific input: - -| | isReadOnly | isConcurrencySafe | -|---|---|---| -| FileRead | true | true | -| Glob | true | true | -| Bash `ls` | true | **true** ← key difference | -| Bash `rm` | false | false | -| TaskCreate | false | **true** ← modifies state but can be concurrent (introduced in s12) | - -CC's Bash tool's `isConcurrencySafe` equals `isReadOnly` — read-only commands can be concurrent, write commands cannot. TaskCreate modifies task files, but each writes a different file, so it can be concurrent. - -### 3. Partition Algorithm - -CC's `partitionToolCalls()` (`toolOrchestration.ts:91-115`) doesn't split into two groups — it batches tool calls **by consecutive blocks**: - -``` -[read A, read B, glob *.py, bash "rm x", read C] - → batch1(concurrent): [read A, read B, glob *.py] - → batch2(serial): [bash "rm x"] - → batch3(concurrent): [read C] -``` - -Consecutive concurrency-safe calls are grouped into the same batch for truly concurrent execution (`toolOrchestration.ts:152-176`, with a concurrency limit). When a non-concurrency-safe call is encountered, a new batch starts for serial execution. Batches are strictly sequential. - -### 4. Validation Pipeline - -Each tool call in CC goes through a strict 5-step validation (`toolExecution.ts`): - -1. **Zod schema validation** (`614-680`, teaching version uses JSON Schema): parameter type/structure check -2. **Tool-level validateInput()** (`682-733`): parameter value validation (e.g., is the path within the working directory) -3. **PreToolUse hooks** (`800-862`, covered in s04): hooks can return messages, modify input, or block execution -4. **Permission check** (`921-931`, core topic of s03): canUseTool + checkPermissions → allow/deny/ask -5. **Execute tool.call()** (`1207-1222`) - -The teaching version omits Zod (uses JSON Schema), omits validateInput (uses safety functions), but preserves the permission check and hook concepts. - -### 5. Streaming Tool Execution - -CC's `StreamingToolExecutor` (`StreamingToolExecutor.ts`) starts tools while the model is still generating — no waiting for the model to finish. `read_file` might complete while the model is still outputting "Let me analyze". The teaching version doesn't implement this, consistent with s01's goal — conceptual clarity, not peak performance. - -### 6. Tool Result Persistence - -Each tool has a `maxResultSizeChars` field. Results exceeding this threshold are persisted to disk, and the model sees a preview + file path. FileRead is special — set to `Infinity`, preventing file read output from being persisted again. Specifically, if FileRead's result exceeds the threshold and gets persisted, the model's next read of that persisted file would trigger another persistence → infinite loop (read file → persist → re-read → re-persist → ...). - -
- - diff --git a/s02_tool_use/README.ja.md b/s02_tool_use/README.ja.md index 23ff30aaa..af2bc34ad 100644 --- a/s02_tool_use/README.ja.md +++ b/s02_tool_use/README.ja.md @@ -1,11 +1,11 @@ -# s02: Tool Use — ツール一つ追加、一行追加だけ +# s02: Tool Use — 新しいツールごとに、たった1行 -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20 -> *"ツールを一つ追加、ハンドラを一つ追加"* — ループはそのまま。新しいツールをディスパッチマップに登録するだけ。 +> *"新しいツールごとに、ハンドラを1つ追加"* — ループはそのまま。新しいツールをディスパッチマップに登録するだけ。 > -> **Harness レイヤー**: ツールディスパッチ — モデルが触れる範囲を拡張。 +> **Harness レイヤー**: ツールディスパッチ — ツール名でハンドラ関数を検索して呼び出す。 --- @@ -13,15 +13,15 @@ s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20 s01 の Agent には bash 一つのツールしかない。ファイルを読むには `cat`、書くには `echo "..." > file.py`、編集するには `sed`。 -モデルは「このファイルを読みたい」と考えながら、`cat path/to/file` と組み立てなければならない。翻訳の層が一つ増え、トークンを無駄にし、エラーも起きやすい。 +モデルへの指示は「このファイルを読む」だが、実際には `cat path/to/file` と組み立てなければならない。翻訳の層が一つ増え、トークンを無駄にし、エラーも起きやすい。 --- ## 概要:ツールディスパッチ -![Tool Dispatch](images/tool-dispatch.ja.svg) +![Tool Dispatch](images/tool-dispatch.svg) -s01 のループは完全に保持される(LLM 呼び出し、stop_reason 判定、メッセージ追加 — 一文字も変更なし)。唯一の変更点はツール実行の 1 行:`run_bash()` が `TOOL_HANDLERS[block.name]()` の検索ディスパッチに置き換わる。 +s01 のループは完全に保持される(LLM 呼び出し、stop_reason 判定、メッセージ追加)。唯一の変更点はツール実行の 1 行:`run_bash()` が `TOOL_HANDLERS[block.name]()` の検索ディスパッチに置き換わる。 Agent にツールを追加するには、たった二つ: @@ -98,15 +98,13 @@ for block in response.content: results.append(...) ``` -ツールの追加 = `TOOLS` 配列に一条 + `TOOL_HANDLERS` 辞書に一行。ループは変わらない。 - --- ## 複数のツール呼び出し モデルはよく一度に複数の tool_use を返す — 「a.py と b.py を読んで、全 .py ファイルを列挙して」。 -教育版は `response.content` の元の順序で一つずつ実行する。CC のやり方はより複雑:元の順序を保ったまま連続バッチに分割し、バッチ内の並列安全なツールを並行実行し、バッチ間は厳密に順次(付録を参照)。 +教育版は `response.content` の元の順序で一つずつ実行する。Claude Code のやり方はより複雑:元の順序を保ったまま連続バッチに分割し、バッチ内の並列安全なツールを並行実行し、バッチ間は厳密に順次(付録を参照)。 --- @@ -114,10 +112,10 @@ for block in response.content: | 概念 | 一言で | |------|--------| -| TOOL_HANDLERS | ツール名 → ハンドラ関数の辞書。ツール追加 = マッピング一行追加 | +| TOOL_HANDLERS | ツール名 → ハンドラ関数の辞書。ループが `block.name` で検索して呼び出す | | ツール定義 | モデルに「何ができるか」を伝える JSON schema | | 複数ツール呼び出し | モデルは一度に複数の tool_use を返す可能性がある。教育版は元の順序で一つずつ実行 | -| ループ不変 | s01 の `while True` ループ — 一行も変更なし | +| ループ | s01 と同一、変更なし | --- @@ -154,25 +152,23 @@ python s02_tool_use/code.py Agent は 5 つの専用ツールを持つようになった。file tools は `safe_path` で保護されるが、bash は制限なし — `rm -rf /` はまだ実行できる。 -→ s03 Permission:ツール実行前にゲートを追加 — この操作は安全か? ユーザーの承認が必要か? +→ s03 Permission:ツール実行前にゲートを追加。この操作は安全か? ユーザーの承認が必要か?
-CC ソースコードを深掘り +Claude Code ソースコードを深掘り -> 以下は CC ソースコード `Tool.ts`、`tools.ts`、`toolOrchestration.ts`、`toolExecution.ts`、`StreamingToolExecutor.ts` の検証に基づく。 +> 以下は Claude Code ソースコード `Tool.ts`、`tools.ts`、`toolOrchestration.ts`、`toolExecution.ts`、`StreamingToolExecutor.ts` の検証に基づく。 ### 一、ツール定義方式 **教育版**:`TOOLS` 配列 + `TOOL_HANDLERS` 辞書。定義と実装が分離。 -**CC**:各ツールは `buildTool()` で作成された独立オブジェクトで、schema、バリデーション、権限、実行を含む。`getAllBaseTools()` が全ツールを集約。 +**Claude Code**:各ツールは `buildTool()` で作成された独立オブジェクトで、schema、バリデーション、権限、実行を含む。`getAllBaseTools()` が全ツールを集約。 教育版の分離方式は教学に適している — 読者は「ツール追加 = 二つの定義」と一目で分かる。 ### 二、並列安全性:isConcurrencySafe() -![Tool Concurrency](images/concurrency-comparison.ja.svg) - -教育版は元の順序で一つずつ実行し、並列処理は行わない。CC は `isConcurrencySafe(input)` で並列可否を判断する — これは単なる「読み取り専用 vs 書き込み」ではなく、具体的な入力で判断する: +教育版は元の順序で一つずつ実行し、並列処理は行わない。Claude Code は `isConcurrencySafe(input)` で並列可否を判断する — これは単なる「読み取り専用 vs 書き込み」ではなく、具体的な入力で判断する: | | isReadOnly | isConcurrencySafe | |---|---|---| @@ -182,11 +178,11 @@ Agent は 5 つの専用ツールを持つようになった。file tools は `s | Bash `rm` | false | false | | TaskCreate | false | **true** ← 状態変更するが並列可能(s12 で紹介) | -CC の Bash ツールの `isConcurrencySafe` は `isReadOnly` と同じ — 読み取り専用コマンドは並列可能、書き込みコマンドは不可。TaskCreate はタスクファイルを変更するが、毎回異なるファイルに書き込むため並列可能。 +Claude Code の Bash ツールの `isConcurrencySafe` は `isReadOnly` と同じ — 読み取り専用コマンドは並列可能、書き込みコマンドは不可。TaskCreate はタスクファイルを変更するが、毎回異なるファイルに書き込むため並列可能。 ### 三、パーティションアルゴリズム -CC の `partitionToolCalls()`(`toolOrchestration.ts:91-115`)は二つのグループに分けるのではなく、ツール呼び出しを**連続ブロックごとにバッチ化**する: +Claude Code の `partitionToolCalls()`(`toolOrchestration.ts:91-115`)は二つのグループに分けるのではなく、ツール呼び出しを**連続ブロックごとにバッチ化**する: ``` [read A, read B, glob *.py, bash "rm x", read C] @@ -199,7 +195,7 @@ CC の `partitionToolCalls()`(`toolOrchestration.ts:91-115`)は二つのグ ### 四、バリデーションパイプライン -CC の各ツール呼び出しは厳格な 5 段階のバリデーションを経る(`toolExecution.ts`): +Claude Code の各ツール呼び出しは厳格な 5 段階のバリデーションを経る(`toolExecution.ts`): 1. **Zod schema バリデーション**(`614-680`、教育版は JSON Schema で代替):パラメータの型/構造チェック 2. **ツールレベル validateInput()**(`682-733`):パラメータ値の検証(例:パスが作業ディレクトリ内か) @@ -211,7 +207,7 @@ CC の各ツール呼び出しは厳格な 5 段階のバリデーションを ### 五、ストリーミングツール実行 -CC の `StreamingToolExecutor`(`StreamingToolExecutor.ts`)はモデルがまだ生成中にツールを起動する — モデルの完了を待たない。`read_file` はモデルが「分析します」と出力中に完了するかもしれない。教育版はこれを実装しない。s01 と同じ目標 — 概念の明確さ、極限のパフォーマンスではない。 +Claude Code の `StreamingToolExecutor`(`StreamingToolExecutor.ts`)はモデルがまだ生成中にツールを起動する — モデルの完了を待たない。`read_file` はモデルが「分析します」と出力中に完了するかもしれない。教育版はこれを実装しない。s01 と同じ目標 — 概念の明確さ、極限のパフォーマンスではない。 ### 六、ツール結果の永続化 diff --git a/s02_tool_use/README.md b/s02_tool_use/README.md index 179df58cd..22cc58aa7 100644 --- a/s02_tool_use/README.md +++ b/s02_tool_use/README.md @@ -1,38 +1,38 @@ -# s02: Tool Use — 多加一个工具,只加一行 +# s02: Tool Use — Each New Tool, Just One Line -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20 -> *"加一个工具, 只加一个 handler"* — 循环不用动, 新工具注册进 dispatch map 就行。 +> *"Each new tool, just one handler"* — The loop stays the same. Register the new tool in the dispatch map and you're done. > -> **Harness 层**: 工具分发 — 扩展模型能触达的边界。 +> **Harness Layer**: Tool Dispatch — Route each tool_use to its handler function by name. --- -## 只有 bash 一个工具 +## Only One Tool: Bash -s01 的 Agent 只有一个 bash 工具。读文件要 `cat`,写文件要 `echo "..." > file.py`,改文件要 `sed`。 +The s01 Agent has only one tool: bash. To read a file, `cat`; to write, `echo "..." > file.py`; to edit, `sed`. -模型想的是"读这个文件",却要拼出 `cat path/to/file`。多了一层翻译,浪费 token,还容易拼错。 +The model needs to "read this file" but has to spell out `cat path/to/file`. An extra layer of translation that wastes tokens and invites errors. --- -## 全局视角:工具分发 +## Overview: Tool Dispatch ![Tool Dispatch](images/tool-dispatch.svg) -s01 的循环完全保留(LLM 调用、stop_reason 判断、消息追加)。唯一的变动在工具执行那 1 行:`run_bash()` 替换为 `TOOL_HANDLERS[block.name]()` 查表分发。 +The s01 loop is fully preserved (LLM call, stop_reason check, message append). The only change is in that one line of tool execution: `run_bash()` is replaced with `TOOL_HANDLERS[block.name]()` dispatch lookup. -给 Agent 加一个工具只需要做两件事: +Adding a tool to the Agent requires just two things: -1. **定义工具**:在 `TOOLS` 数组里加一条描述 -2. **注册处理函数**:在 `TOOL_HANDLERS` 字典里加一个映射 +1. **Define the tool**: Add one entry to the `TOOLS` array +2. **Register the handler**: Add one mapping in the `TOOL_HANDLERS` dict --- -## 从 1 个工具到 5 个工具 +## From 1 Tool to 5 Tools -s01 只有一个 bash: +s01 had only bash: ```python TOOLS = [{"name": "bash", ...}] @@ -40,7 +40,7 @@ TOOLS = [{"name": "bash", ...}] def run_bash(command): ... ``` -s02 加到 5 个,每个工具都是独立定义: +s02 expands to 5 tools, each independently defined: ```python TOOLS = [ @@ -52,7 +52,7 @@ TOOLS = [ ] ``` -每个工具有自己的实现函数: +Each tool has its own implementation function: ```python def run_read(path, limit=None): @@ -79,7 +79,7 @@ def run_glob(pattern): --- -## 工具分发 +## Tool Dispatch ```python TOOL_HANDLERS = { @@ -90,133 +90,129 @@ TOOL_HANDLERS = { "glob": run_glob, } -# 循环里只改了一行——从硬编码 run_bash 变成查表: +# Only one line changed in the loop — from hardcoded run_bash to dispatch lookup: for block in response.content: if block.type == "tool_use": - handler = TOOL_HANDLERS[block.name] # 查表 - output = handler(**block.input) # 调用 + handler = TOOL_HANDLERS[block.name] # lookup + output = handler(**block.input) # call results.append(...) ``` -加一个工具 = 在 `TOOLS` 数组加一条 + 在 `TOOL_HANDLERS` 字典加一行。循环不变。 - --- -## 多个工具调用 +## Multiple Tool Calls -模型经常一次返回多个 tool_use:"读一下 a.py 和 b.py,然后列出所有 .py 文件"。 +The model often returns multiple tool_use calls at once — "read a.py and b.py, then list all .py files". -教学版按 `response.content` 原始顺序逐个执行。CC 的做法更复杂:按原始顺序切成连续 batch,batch 内并发安全的工具并行执行,batch 间严格顺序(见附录)。 +The teaching version executes them one by one in the original `response.content` order. Claude Code's approach is more complex: it slices the original order into consecutive batches, where concurrency-safe tools within a batch run in parallel, and batches are strictly sequential (see appendix). --- -## 速查 +## Quick Reference -| 概念 | 一句话 | -|------|--------| -| TOOL_HANDLERS | 工具名 → 处理函数的字典。加工具 = 加一行映射 | -| 工具定义 | 告诉模型"我能做什么"的 JSON schema | -| 多工具调用 | 模型可一次返回多个 tool_use,教学版按原始顺序逐个执行 | -| 循环不变 | s01 的 `while True` 循环一行都没改 | +| Concept | One-Liner | +|---------|-----------| +| TOOL_HANDLERS | Tool name → handler function dict. The loop dispatches via `block.name` lookup | +| Tool Definition | JSON schema telling the model "what I can do" | +| Multiple tool calls | Model may return multiple tool_use at once; teaching version executes them in original order | +| Loop | Identical to s01, no changes | --- -## 相对 s01 的变更 +## Changes from s01 -| 组件 | 之前 (s01) | 之后 (s02) | -|------|-----------|-----------| -| 工具数量 | 1 (bash) | 5 (+read, write, edit, glob) | -| 工具执行 | 硬编码 `run_bash()` | TOOL_HANDLERS 查表分发 | -| 路径安全 | 无 | safe_path 校验(仅 file tools) | -| 循环 | `while True` + `stop_reason` | 与 s01 完全一致 | +| Component | Before (s01) | After (s02) | +|-----------|-------------|-------------| +| Tool count | 1 (bash) | 5 (+read, write, edit, glob) | +| Tool execution | Hardcoded `run_bash()` | TOOL_HANDLERS dispatch lookup | +| Path safety | None | safe_path validation (file tools only) | +| Loop | `while True` + `stop_reason` | Identical to s01 | --- -## 试一下 +## Try It ```sh cd learn-claude-code python s02_tool_use/code.py ``` -试试这些 prompt: +Try these prompts: 1. `Read the file README.md and tell me what this project is about` 2. `Create a file called test.py that prints "hello", then read it back` 3. `Find all Python files in this directory` 4. `Read both README.md and requirements.txt, then create a summary file` -观察重点:模型什么时候只调一个工具,什么时候一次调多个?多个工具调用的顺序和结果是否正确? +What to watch for: When does the model call just one tool, and when does it call multiple at once? Are multiple tool calls executed in the correct order? --- -## 接下来 +## What's Next -现在 Agent 有 5 个专用工具。file tools 受 `safe_path` 保护,但 bash 不受限制,`rm -rf /` 还是能跑。 +The Agent now has 5 specialized tools. File tools are protected by `safe_path`, but bash is unrestricted — `rm -rf /` still runs. -s03 Permission → 在工具执行之前加一道门:这个操作安全吗?需要用户批准吗? +→ s03 Permission: Add a gate before tool execution. Is this operation safe? Does it need user approval?
-深入 CC 源码 - -> 以下基于 CC 源码 `Tool.ts`、`tools.ts`、`toolOrchestration.ts`、`toolExecution.ts`、`StreamingToolExecutor.ts` 的核查。 +Dive into Claude Code Source Code -### 一、工具定义方式 +> The following is based on a review of Claude Code source code `Tool.ts`, `tools.ts`, `toolOrchestration.ts`, `toolExecution.ts`, and `StreamingToolExecutor.ts`. -**教学版**:`TOOLS` 数组 + `TOOL_HANDLERS` 字典。定义和实现分开。 -**CC**:每个工具是 `buildTool()` 创建的独立对象,包含 schema、验证、权限、执行。`getAllBaseTools()` 汇总所有工具。 +### 1. Tool Definition Approach -教学版的分离方式对教学更清晰——读者一眼看到"加一个工具 = 两条定义"。 +**Teaching version**: `TOOLS` array + `TOOL_HANDLERS` dict. Definition and implementation are separate. +**Claude Code**: Each tool is an independent object created by `buildTool()`, containing schema, validation, permissions, and execution. `getAllBaseTools()` aggregates all tools. -### 二、并发安全判断:isConcurrencySafe() +The teaching version's separation is clearer for teaching — readers immediately see "add a tool = two definitions". -![Tool Concurrency](images/concurrency-comparison.svg) +### 2. Concurrency Safety: isConcurrencySafe() -教学版按原始顺序逐个执行,不做并发。CC 用 `isConcurrencySafe(input)` 判断能否并发——注意这不是简单的"只读 vs 写",而是按具体输入判断: +The teaching version executes tools one by one in original order, without concurrency. Claude Code uses `isConcurrencySafe(input)` to determine concurrency — note this isn't simply "read-only vs write", but judges by specific input: | | isReadOnly | isConcurrencySafe | |---|---|---| | FileRead | true | true | | Glob | true | true | -| Bash `ls` | true | **true** ← 关键差异 | +| Bash `ls` | true | **true** ← key difference | | Bash `rm` | false | false | -| TaskCreate | false | **true** ← 改状态但可并发(TaskCreate 在 s12 介绍) | +| TaskCreate | false | **true** ← modifies state but can be concurrent (introduced in s12) | -CC 的 Bash tool 的 `isConcurrencySafe` 等于 `isReadOnly`——只读命令可并发,写命令不可。TaskCreate 虽然改了任务文件,但每次都写不同的文件,所以可以并发。 +Claude Code's Bash tool's `isConcurrencySafe` equals `isReadOnly` — read-only commands can be concurrent, write commands cannot. TaskCreate modifies task files, but each writes a different file, so it can be concurrent. -### 三、分区算法 +### 3. Partition Algorithm -CC 的 `partitionToolCalls()`(`toolOrchestration.ts:91-115`)不是分两组,而是把工具调用**按连续块分批**: +Claude Code's `partitionToolCalls()` (`toolOrchestration.ts:91-115`) doesn't split into two groups — it batches tool calls **by consecutive blocks**: ``` [read A, read B, glob *.py, bash "rm x", read C] - → batch1(并发): [read A, read B, glob *.py] - → batch2(串行): [bash "rm x"] - → batch3(并发): [read C] + → batch1(concurrent): [read A, read B, glob *.py] + → batch2(serial): [bash "rm x"] + → batch3(concurrent): [read C] ``` -并发安全的连续块编入同一个 batch,batch 内真正并发执行(`toolOrchestration.ts:152-176`,有并发上限)。遇到非并发安全的就开新 batch 串行执行。batch 之间严格顺序。 +Consecutive concurrency-safe calls are grouped into the same batch for truly concurrent execution (`toolOrchestration.ts:152-176`, with a concurrency limit). When a non-concurrency-safe call is encountered, a new batch starts for serial execution. Batches are strictly sequential. -### 四、验证管线 +### 4. Validation Pipeline -CC 的每个工具调用经过严格的 5 步验证(`toolExecution.ts`): +Each tool call in Claude Code goes through a strict 5-step validation (`toolExecution.ts`): -1. **Zod schema 验证**(`614-680`,教学版用 JSON Schema 替代):参数类型/结构检查 -2. **工具级 validateInput()**(`682-733`):参数值验证(如路径是否在工作区内) -3. **PreToolUse hooks**(`800-862`,s04 详细介绍):钩子可以返回消息、修改输入、阻止执行 -4. **权限检查**(`921-931`,s03 的核心内容):canUseTool + checkPermissions → allow/deny/ask -5. **执行 tool.call()**(`1207-1222`) +1. **Zod schema validation** (`614-680`, teaching version uses JSON Schema): parameter type/structure check +2. **Tool-level validateInput()** (`682-733`): parameter value validation (e.g., is the path within the working directory) +3. **PreToolUse hooks** (`800-862`, covered in s04): hooks can return messages, modify input, or block execution +4. **Permission check** (`921-931`, core topic of s03): canUseTool + checkPermissions → allow/deny/ask +5. **Execute tool.call()** (`1207-1222`) -教学版省略了 Zod(用 JSON Schema)、省略了 validateInput(用安全函数)、保留了权限检查和钩子概念。 +The teaching version omits Zod (uses JSON Schema), omits validateInput (uses safety functions), but preserves the permission check and hook concepts. -### 五、流式工具执行 +### 5. Streaming Tool Execution -CC 的 `StreamingToolExecutor`(`StreamingToolExecutor.ts`)让工具在模型还在生成时就启动——不等模型说完。`read_file` 可能在模型还在输出"我来分析"的时候就跑完了。教学版不实现这个,目标和 s01 一致——概念清晰,不追求性能极致。 +Claude Code's `StreamingToolExecutor` (`StreamingToolExecutor.ts`) starts tools while the model is still generating — no waiting for the model to finish. `read_file` might complete while the model is still outputting "Let me analyze". The teaching version doesn't implement this, consistent with s01's goal — conceptual clarity, not peak performance. -### 六、工具结果持久化 +### 6. Tool Result Persistence -每个工具有一个 `maxResultSizeChars` 字段。结果超过这个值就落盘,模型看到的是预览 + 文件路径。FileRead 特殊——设为 `Infinity`,防止读文件的输出又被当成文件落盘。具体来说,如果 FileRead 的结果超过阈值被落盘,模型下次读那个落盘文件时又会触发落盘 → 无限循环(读文件 → 落盘 → 再读 → 再落盘 → ...)。 +Each tool has a `maxResultSizeChars` field. Results exceeding this threshold are persisted to disk, and the model sees a preview + file path. FileRead is special — set to `Infinity`, preventing file read output from being persisted again. Specifically, if FileRead's result exceeds the threshold and gets persisted, the model's next read of that persisted file would trigger another persistence → infinite loop (read file → persist → re-read → re-persist → ...).
- + diff --git a/s02_tool_use/README.zh.md b/s02_tool_use/README.zh.md new file mode 100644 index 000000000..35878734d --- /dev/null +++ b/s02_tool_use/README.zh.md @@ -0,0 +1,218 @@ +# s02: Tool Use — 每加一个工具,只加一行 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → `s02` → [s03](../s03_permission/) → s04 → ... → s20 +> *"每加一个工具,只加一个 handler"* — 循环不用动,新工具注册进 dispatch map 就行。 +> +> **Harness 层**: 工具分发 — 根据工具名查表调用对应处理函数。 + +--- + +## 只有 bash 一个工具 + +s01 的 Agent 只有一个 bash 工具。读文件要 `cat`,写文件要 `echo "..." > file.py`,改文件要 `sed`。 + +模型收到的指令是"读这个文件",却要拼出 `cat path/to/file`。多了一层翻译,浪费 token,还容易拼错。 + +--- + +## 全局视角:工具分发 + +![Tool Dispatch](images/tool-dispatch.svg) + +s01 的循环完全保留(LLM 调用、stop_reason 判断、消息追加)。唯一的变动在工具执行那 1 行:`run_bash()` 替换为 `TOOL_HANDLERS[block.name]()` 查表分发。 + +给 Agent 加一个工具只需要做两件事: + +1. **定义工具**:在 `TOOLS` 数组里加一条描述 +2. **注册处理函数**:在 `TOOL_HANDLERS` 字典里加一个映射 + +--- + +## 从 1 个工具到 5 个工具 + +s01 只有一个 bash: + +```python +TOOLS = [{"name": "bash", ...}] + +def run_bash(command): ... +``` + +s02 加到 5 个,每个工具都是独立定义: + +```python +TOOLS = [ + {"name": "bash", "description": "Run a shell command.", ...}, + {"name": "read_file", "description": "Read file contents.", ...}, + {"name": "write_file", "description": "Write content to file.", ...}, + {"name": "edit_file", "description": "Replace text in file once.", ...}, + {"name": "glob", "description": "Find files by pattern.", ...}, +] +``` + +每个工具有自己的实现函数: + +```python +def run_read(path, limit=None): + lines = safe_path(path).read_text().splitlines() + if limit: + lines = lines[:limit] + return "\n".join(lines) + +def run_write(path, content): + safe_path(path).write_text(content) + return f"Wrote {len(content)} bytes to {path}" + +def run_edit(path, old_text, new_text): + text = safe_path(path).read_text() + if old_text not in text: + return "Error: text not found" + safe_path(path).write_text(text.replace(old_text, new_text, 1)) + return f"Edited {path}" + +def run_glob(pattern): + import glob as g + return "\n".join(g.glob(pattern, root_dir=WORKDIR)) +``` + +--- + +## 工具分发 + +```python +TOOL_HANDLERS = { + "bash": run_bash, + "read_file": run_read, + "write_file": run_write, + "edit_file": run_edit, + "glob": run_glob, +} + +# 循环里只改了一行——从硬编码 run_bash 变成查表: +for block in response.content: + if block.type == "tool_use": + handler = TOOL_HANDLERS[block.name] # 查表 + output = handler(**block.input) # 调用 + results.append(...) +``` + +--- + +## 多个工具调用 + +模型经常一次返回多个 tool_use:"读一下 a.py 和 b.py,然后列出所有 .py 文件"。 + +教学版按 `response.content` 原始顺序逐个执行。Claude Code 的做法更复杂:按原始顺序切成连续 batch,batch 内并发安全的工具并行执行,batch 间严格顺序(见附录)。 + +--- + +## 速查 + +| 概念 | 一句话 | +|------|--------| +| TOOL_HANDLERS | 工具名 → 处理函数的字典,循环通过 block.name 查表调用 | +| 工具定义 | 告诉模型"我能做什么"的 JSON schema | +| 多工具调用 | 模型可一次返回多个 tool_use,教学版按原始顺序逐个执行 | +| 循环 | 与 s01 完全一致,无改动 | + +--- + +## 相对 s01 的变更 + +| 组件 | 之前 (s01) | 之后 (s02) | +|------|-----------|-----------| +| 工具数量 | 1 (bash) | 5 (+read, write, edit, glob) | +| 工具执行 | 硬编码 `run_bash()` | TOOL_HANDLERS 查表分发 | +| 路径安全 | 无 | safe_path 校验(仅 file tools) | +| 循环 | `while True` + `stop_reason` | 与 s01 完全一致 | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s02_tool_use/code.py +``` + +试试这些 prompt: + +1. `Read the file README.md and tell me what this project is about` +2. `Create a file called test.py that prints "hello", then read it back` +3. `Find all Python files in this directory` +4. `Read both README.md and requirements.txt, then create a summary file` + +观察重点:模型什么时候只调一个工具,什么时候一次调多个?多个工具调用的顺序和结果是否正确? + +--- + +## 接下来 + +现在 Agent 有 5 个专用工具。file tools 受 `safe_path` 保护,但 bash 不受限制,`rm -rf /` 还是能跑。 + +s03 Permission → 在工具执行之前加一道门:这个操作安全吗?需要用户批准吗? + +
+深入 Claude Code 源码 + +> 以下基于 Claude Code 源码 `Tool.ts`、`tools.ts`、`toolOrchestration.ts`、`toolExecution.ts`、`StreamingToolExecutor.ts` 的核查。 + +### 一、工具定义方式 + +**教学版**:`TOOLS` 数组 + `TOOL_HANDLERS` 字典。定义和实现分开。 +**Claude Code**:每个工具是 `buildTool()` 创建的独立对象,包含 schema、验证、权限、执行。`getAllBaseTools()` 汇总所有工具。 + +教学版的分离方式对教学更清晰——读者一眼看到"加一个工具 = 两条定义"。 + +### 二、并发安全判断:isConcurrencySafe() + +教学版按原始顺序逐个执行,不做并发。Claude Code 用 `isConcurrencySafe(input)` 判断能否并发——注意这不是简单的"只读 vs 写",而是按具体输入判断: + +| | isReadOnly | isConcurrencySafe | +|---|---|---| +| FileRead | true | true | +| Glob | true | true | +| Bash `ls` | true | **true** ← 关键差异 | +| Bash `rm` | false | false | +| TaskCreate | false | **true** ← 改状态但可并发(TaskCreate 在 s12 介绍) | + +Claude Code 的 Bash tool 的 `isConcurrencySafe` 等于 `isReadOnly`——只读命令可并发,写命令不可。TaskCreate 虽然改了任务文件,但每次都写不同的文件,所以可以并发。 + +### 三、分区算法 + +Claude Code 的 `partitionToolCalls()`(`toolOrchestration.ts:91-115`)不是分两组,而是把工具调用**按连续块分批**: + +``` +[read A, read B, glob *.py, bash "rm x", read C] + → batch1(并发): [read A, read B, glob *.py] + → batch2(串行): [bash "rm x"] + → batch3(并发): [read C] +``` + +并发安全的连续块编入同一个 batch,batch 内真正并发执行(`toolOrchestration.ts:152-176`,有并发上限)。遇到非并发安全的就开新 batch 串行执行。batch 之间严格顺序。 + +### 四、验证管线 + +Claude Code 的每个工具调用经过严格的 5 步验证(`toolExecution.ts`): + +1. **Zod schema 验证**(`614-680`,教学版用 JSON Schema 替代):参数类型/结构检查 +2. **工具级 validateInput()**(`682-733`):参数值验证(如路径是否在工作区内) +3. **PreToolUse hooks**(`800-862`,s04 详细介绍):钩子可以返回消息、修改输入、阻止执行 +4. **权限检查**(`921-931`,s03 的核心内容):canUseTool + checkPermissions → allow/deny/ask +5. **执行 tool.call()**(`1207-1222`) + +教学版省略了 Zod(用 JSON Schema)、省略了 validateInput(用安全函数)、保留了权限检查和钩子概念。 + +### 五、流式工具执行 + +Claude Code 的 `StreamingToolExecutor`(`StreamingToolExecutor.ts`)让工具在模型还在生成时就启动——不等模型说完。`read_file` 可能在模型还在输出"我来分析"的时候就跑完了。教学版不实现这个,目标和 s01 一致——概念清晰,不追求性能极致。 + +### 六、工具结果持久化 + +每个工具有一个 `maxResultSizeChars` 字段。结果超过这个值就落盘,模型看到的是预览 + 文件路径。FileRead 特殊——设为 `Infinity`,防止读文件的输出又被当成文件落盘。具体来说,如果 FileRead 的结果超过阈值被落盘,模型下次读那个落盘文件时又会触发落盘 → 无限循环(读文件 → 落盘 → 再读 → 再落盘 → ...)。 + +
+ + diff --git a/s02_tool_use/images/concurrency-comparison.en.svg b/s02_tool_use/images/concurrency-comparison.en.svg deleted file mode 100644 index 04dab3235..000000000 --- a/s02_tool_use/images/concurrency-comparison.en.svg +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - Tool Concurrency — Teaching Version vs Claude Code - - - - Model returns 5 tool calls at once - - - read A.py - - - glob *.py - - - bash "ls -la" - - - write B.py - - - read C.py - - - - Teaching: Original Order, One by One - - - for block in response.content: - TOOL_HANDLERS[name](**input) - - Result: 5 serial calls, no batches - - - 1. read A.py - - - 2. glob *.py - - - 3. bash "ls -la" - - - 4. write B.py - - - 5. read C.py - - Teaching focus: tool dispatch first; concurrency omitted - - - - Claude Code: isConcurrencySafe(input) - - - Each tool call judged individually: - tool.isConcurrencySafe(parsedInput) → bool - - Result: 3 batches (by consecutive blocks) - - - Batch 1 - Concurrent - read A · glob · bash "ls" - - - - - Batch 2 - Serial - write B - - - - - Batch 3 - Concurrent - read C - - bash "ls" is safe and consecutive, so it stays in Batch 1 - - ✓ Input-dependent safety, not tool-name hardcoding - ✓ Original order preserved; only safe consecutive calls run together - - - - Key Difference - • Teaching: executes response.content in original order, one tool call at a time; no concurrency or batching - • CC: checks isConcurrencySafe(input), then groups consecutive safe calls into one batch - • Key difference: teaching focuses on dispatch; CC optimizes safe concurrency while preserving order semantics - diff --git a/s02_tool_use/images/concurrency-comparison.ja.svg b/s02_tool_use/images/concurrency-comparison.ja.svg deleted file mode 100644 index f130d5b32..000000000 --- a/s02_tool_use/images/concurrency-comparison.ja.svg +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - ツール並列実行 — 教育版 vs Claude Code - - - - モデルが一度に 5 つのツール呼び出しを返す - - - read A.py - - - glob *.py - - - bash "ls -la" - - - write B.py - - - read C.py - - - - 教育版:元の順序で一つずつ実行 - - - for block in response.content: - TOOL_HANDLERS[name](**input) - - 結果:5 回の直列呼び出し、batch なし - - - 1. read A.py - - - 2. glob *.py - - - 3. bash "ls -la" - - - 4. write B.py - - - 5. read C.py - - 教育の焦点:まず tool_use 分配を理解し、並列は省略 - - - - Claude Code:isConcurrencySafe(input) - - - 各ツール呼び出しを個別に判定: - tool.isConcurrencySafe(parsedInput) → bool - - 結果:3 バッチ(連続ブロックごと) - - - Batch 1 - 並列 - read A · glob · bash "ls" - - - - - Batch 2 - 直列 - write B - - - - - Batch 3 - 並列 - read C - - bash "ls" は安全かつ連続しているため Batch 1 に入る - - ✓ 入力に基づく安全判定、ツール名ハードコードではない - ✓ 元の順序を保ち、連続する安全呼び出しだけ並列化 - - - - 核心的な違い - • 教育版:response.content の元の順序で一つずつ実行し、並列処理も batch 化もしない - • CC:isConcurrencySafe(input) で判定し、連続する安全呼び出しを同じ batch にまとめる - • 差分の要点:教育版は分配に集中し、CC は順序意味を保ったまま安全な並列を最適化する - diff --git a/s02_tool_use/images/concurrency-comparison.svg b/s02_tool_use/images/concurrency-comparison.svg deleted file mode 100644 index e6941e619..000000000 --- a/s02_tool_use/images/concurrency-comparison.svg +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - Tool Concurrency — 教学版 vs Claude Code - - - - 模型一次返回 5 个工具调用 - - - read A.py - - - glob *.py - - - bash "ls -la" - - - write B.py - - - read C.py - - - - 教学版:按原始顺序逐个执行 - - - for block in response.content: - TOOL_HANDLERS[name](**input) - - 结果:5 次串行调用,不做 batch - - - 1. read A.py - - - 2. glob *.py - - - 3. bash "ls -la" - - - 4. write B.py - - - 5. read C.py - - 教学重点:先理解 tool_use 分发,暂不引入并发执行 - - - - Claude Code:isConcurrencySafe(input) - - - 每个工具调用单独判断: - tool.isConcurrencySafe(parsedInput) → bool - - 结果:3 个 batch(按连续块分批) - - - Batch 1 - 并发 - read A · glob · bash "ls" - - - - - Batch 2 - 串行 - write B - - - - - Batch 3 - 并发 - read C - - bash "ls" 是并发安全调用,且和 read/glob 连续,所以留在 Batch 1 - - ✓ 按输入判断并发安全,不按工具名硬编码 - ✓ 保留原始顺序,只在连续安全块内部并发 - - - - 核心差异 - • 教学版:按 response.content 原始顺序逐个执行,不做并发,也不分 batch - • CC:按 isConcurrencySafe(input) 判断,并把连续的并发安全调用合成同一个 batch - • 差异重点:教学版聚焦工具分发;CC 在保持顺序语义的同时优化安全并发 - diff --git a/s02_tool_use/images/tool-dispatch.en.svg b/s02_tool_use/images/tool-dispatch.en.svg deleted file mode 100644 index 6fd2e6667..000000000 --- a/s02_tool_use/images/tool-dispatch.en.svg +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - Tool Use — Loop Unchanged, Just Add Dispatch Mapping - - - s01 Preserved - - - - User Query - messages[] - - - - - - - LLM - stop_reason check - - - - - - - tool_use? - - - - No - - Return Result - - - - Yes - - - s02 New - - - - TOOL_HANDLERS Dispatch Mapping - - - - - - - bash - → run_bash() - - - - read_file - → run_read() - - - - write_file - → run_write() - - - - edit_file - → run_edit() - - - - glob - → run_glob() - - - - Append tool_result to messages - - - - - s01 Preserved (loop, LLM, decision — completely unchanged) - - s02 New (5 tools + dispatch mapping) - Only 1 line changed in the loop: run_bash() → TOOL_HANDLERS[block.name]() - diff --git a/s02_tool_use/images/tool-dispatch.ja.svg b/s02_tool_use/images/tool-dispatch.ja.svg deleted file mode 100644 index 8971d06ef..000000000 --- a/s02_tool_use/images/tool-dispatch.ja.svg +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - Tool Use — ループ不変、ディスパッチマッピングを追加 - - - s01 保持 - - - - ユーザーの質問 - messages[] - - - - - - - LLM - stop_reason 判定 - - - - - - - tool_use? - - - - No - - 結果を返す - - - - Yes - - - s02 新規 - - - - TOOL_HANDLERS ディスパッチマッピング - - - - - - - bash - → run_bash() - - - - read_file - → run_read() - - - - write_file - → run_write() - - - - edit_file - → run_edit() - - - - glob - → run_glob() - - - - tool_result を messages に追加 - - - - - s01 保持(ループ、LLM、判定 — 完全に不変) - - s02 新規(5 つのツール + ディスパッチマッピング) - ループ内で変更されたのは 1 行だけ:run_bash() → TOOL_HANDLERS[block.name]() - diff --git a/s02_tool_use/images/tool-dispatch.svg b/s02_tool_use/images/tool-dispatch.svg index a6b16ce2a..748047b4c 100644 --- a/s02_tool_use/images/tool-dispatch.svg +++ b/s02_tool_use/images/tool-dispatch.svg @@ -1,108 +1,102 @@ - + - - - - - - - + + + + - - + + - - - - - - + + - - - - Tool Use — 循环不变,只加分发映射 + + Tool Dispatch + Loop unchanged — add one handler mapping per tool - - s01 保留 + + - - - 用户提问 - messages[] + + + User Input + messages[] - - + + - - 大模型 (LLM) - stop_reason 判断 - - - - - - - tool_use? - - - - - - 返回结果 - - - - - - - s02 新增 - - - - TOOL_HANDLERS 分发映射 - - - - - - - bash - → run_bash() - - - - read_file - → run_read() - - - - write_file - → run_write() - - - - edit_file - → run_edit() - - - - glob - → run_glob() - - - - tool_result 追加到 messages - - - - - s01 保留(循环、LLM、判断——完全不变) - - s02 新增(5 个工具 + 分发映射) - 循环里只改了 1 行:run_bash() → TOOL_HANDLERS[block.name]() - + + LLM + stop_reason check + + + + + + + tool_use? + + + + No + + Return Result + + + + Yes + + + + + + + TOOL_HANDLERS {name: handler} + + + + + bash + run_bash() + + + + read_file + run_read() + + + + write_file + run_write() + + + + + edit_file + run_edit() + + + + glob + run_glob() + + + + + + tool_result + → messages[] + + + + + + + Loop change (1 line): + - run_bash() → TOOL_HANDLERS[block.name](**block.input) + \ No newline at end of file diff --git a/s03_permission/README.en.md b/s03_permission/README.en.md deleted file mode 100644 index f2f78a454..000000000 --- a/s03_permission/README.en.md +++ /dev/null @@ -1,232 +0,0 @@ -# s03: Permission — Check Permissions Before Execution - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 -> *"Check permissions before executing"* — The permission pipeline decides which operations need approval. -> -> **Harness Layer**: Permission — a gate before tool execution. - ---- - -## The Problem - -s02's Agent has 5 tools. File tools are protected by `safe_path`, but bash is unrestricted. Ask it to "clean up the project," and it might run `rm -rf /`. - -Safety can't rely on trusting the model — it needs code: a check before every tool execution. - ---- - -## The Solution - -![Permission Overview](images/permission-overview.en.svg) - -s02's loop is fully preserved. The only change is inserting `check_permission()` before tool execution — each tool call passes through three gates in a fixed order: hard deny first, then soft ask, and if neither matches, allow. - -The three gates correspond to three decisions: - -| Gate | Purpose | On Match | -|------|---------|----------| -| 1. Deny List | Permanently forbidden operations (`rm -rf /`, `sudo`) | Denied immediately, not executed | -| 2. Rule Matching | Context-dependent operations (writing outside workspace, `rm` files) | Passed to Gate 3 | -| 3. User Approval | After Gate 2 matches, pauses for user confirmation | User decides allow or deny | - -None of the three gates match → execute directly. Most routine operations take this path. - ---- - -## How It Works - -![Permission Pipeline](images/permission-pipeline.en.svg) - -**Gate 1**: A hard deny list. Check first; if matched, return a block message. (Teaching demo: simple string matching is not a reliable security mechanism — command variants and shell expansion can bypass it. CC's approach is in the appendix.) - -```python -DENY_LIST = [ - "rm -rf /", "sudo", "shutdown", "reboot", - "mkfs", "dd if=", "> /dev/sda", -] - -def check_deny_list(command: str) -> str | None: - for pattern in DENY_LIST: - if pattern in command: - return f"Blocked: '{pattern}' is on the deny list" - return None -``` - -**Gate 2**: Rule matching — describes "when to ask the user." Each rule specifies a tool and a check condition. - -```python -PERMISSION_RULES = [ - { - "tools": ["write_file", "edit_file"], - "check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR), - "message": "Writing outside workspace", - }, - { - "tools": ["bash"], - "check": lambda args: any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]), - "message": "Potentially destructive command", - }, -] - -def check_rules(tool_name: str, args: dict) -> str | None: - for rule in PERMISSION_RULES: - if tool_name in rule["tools"] and rule["check"](args): - return rule["message"] - return None -``` - -**Gate 3**: After a rule matches, pause for user input. - -```python -def ask_user(tool_name: str, args: dict, reason: str) -> str: - print(f"\n⚠ {reason}") - print(f" Tool: {tool_name}({args})") - choice = input(" Allow? [y/N] ").strip().lower() - return "allow" if choice in ("y", "yes") else "deny" -``` - -**All three gates chained together**, inserted before tool execution: - -```python -def check_permission(block) -> bool: - # Gate 1: Hard deny - if block.name == "bash": - reason = check_deny_list(block.input.get("command", "")) - if reason: - print(f"\n⛔ {reason}") - return False - - # Gate 2 + 3: Rule matching → User approval - reason = check_rules(block.name, block.input) - if reason: - decision = ask_user(block.name, block.input, reason) - if decision == "deny": - return False - - return True - -# In agent_loop — s02's loop with just one line added: -for block in response.content: - if block.type == "tool_use": - if not check_permission(block): # ← NEW - results.append({... "content": "Permission denied."}) - continue - output = TOOL_HANDLERS[block.name](**block.input) # s02 original - results.append(...) -``` - ---- - -## Changes from s02 - -| Component | Before (s02) | After (s03) | -|-----------|-------------|-------------| -| Security model | None (trust the model) | Three-gate permission pipeline | -| New functions | — | check_deny_list, check_rules, ask_user, check_permission | -| Loop | Executes all tools directly | Inserts check_permission() before execution | - ---- - -## Try It - -```sh -cd learn-claude-code -python s03_permission/code.py -``` - -Try these prompts: - -1. `Create a file called test.txt in the current directory` (should pass through) -2. `Delete the file test.txt` (bash + rm triggers Gate 2) -3. `What files are in the current directory?` (read-only, all pass) -4. `Try to write a file to /etc/something` (writing outside workspace triggers Gate 2) - -What to watch for: Which operations pass through? Which need your confirmation? Which are denied outright? - ---- - -## What's Next - -Permission checks are in place — but every check is hardcoded as `check_permission()` inside the loop. What if you want to add logging before and after each tool execution? What if you want to auto-trigger a git commit after certain operations? Scattering this extension logic throughout the loop makes it bloat. - -→ s04 Hooks: Add hooks to the loop. Extension logic hangs on hooks; the loop stays clean. - -
-Dive into CC Source Code - -> The following is based on a review of CC source code `types/permissions.ts`, `utils/permissions/permissions.ts`, `toolExecution.ts`, `utils/permissions/yoloClassifier.ts`, `tools/AgentTool/forkSubagent.ts`. - -### 1. PermissionResult: Not 3, but 4 - -The teaching version's three gates (deny → ask → allow) don't fully correspond to CC. CC's `PermissionResult` has 4 behaviors (`types/permissions.ts:241-266`): - -| behavior | Meaning | Teaching Version Equivalent | -|----------|---------|---------------------------| -| `allow` | Allow directly | Gate 3 passes | -| `deny` | Deny directly | Gate 1 matches | -| `ask` | Show dialog to user | Gate 2 matches | -| `passthrough` | Tool doesn't express opinion, passes to generic pipeline | Not in teaching version | - -### 2. Production Verification Stages - -CC's tool calls don't go through three gates — they go through multiple stages distributed across `checkPermissionsAndCallTool()` (`toolExecution.ts:599-1745`), hooks, `hasPermissionsToUseToolInner()` (`utils/permissions/permissions.ts:1158-1310`), and classifier logic: - -1. **Zod schema validation** (`toolExecution.ts:614-680`) — parameter type checking -2. **validateInput()** (`toolExecution.ts:682-733`) — tool-level semantic validation -3. **backfillObservableInput()** (`toolExecution.ts:784`) — backfill legacy fields -4. **PreToolUse hooks** (`toolExecution.ts:800-862`) — hooks can return allow/deny/ask -5. **resolveHookPermissionDecision()** (`toolExecution.ts:921-931`) — coordinate hook + pipeline decisions -6. **hasPermissionsToUseToolInner()** (`permissions.ts:1158-1310`) — multi-layer rule check: - - Entire tool disabled by deny rule → `deny` - - Entire tool flagged by ask rule → `ask` - - `tool.checkPermissions()` tool's own judgment - - Tool itself returns deny → `deny` - - `requiresUserInteraction()` → `ask` - - Content-related ask rules → `ask` (not bypassable) - - Security check violation → `ask` (not bypassable) - - bypassPermissions mode → `allow` - - Entire tool allowed by allow rule → `allow` - - passthrough → converted to `ask` - -### 3. Deny List: Not One File, but 8 Sources - -CC doesn't have a single deny list. Permission rules come from 8 sources (`types/permissions.ts:54-62`): - -| Source | Configuration Location | -|--------|----------------------| -| `userSettings` | `~/.claude/settings.json` | -| `projectSettings` | `.claude/settings.json` | -| `localSettings` | `settings.local.json` | -| `flagSettings` | Feature flags | -| `policySettings` | Enterprise management policy | -| `cliArg` | `--allowedTools` / `--deniedTools` | -| `command` | Inline command | -| `session` | In-session temporary authorization | - -Each rule format: `{ toolName: "Bash", ruleBehavior: "deny", ruleContent: "npm publish:*" }`. Rules from multiple sources are merged, with higher-priority sources overriding lower ones (low to high: user < project < local < flag < policy, plus cliArg, command, session). - -### 4. What is isDestructive() - -In CC, `isDestructive` (`Tool.ts:405-406`) is **purely for UI display** — showing a `[destructive]` label in the tool list. It doesn't participate in permission decisions. All tools return `false` by default. Only ExitWorktree (on remove) and MCP tools (depending on `annotations.destructiveHint`) override it. - -### 5. YoloClassifier (Auto-Approval) - -In CC's auto mode, it doesn't pop a dialog every time. `classifyYoloAction` (`utils/permissions/yoloClassifier.ts:1012`) sends the tool call + conversation context to a classifier LLM to judge safety. It first tries acceptEdits mode simulation (`permissions.ts:620-656`, if acceptEdits allows → auto-approve), then checks the safe tool whitelist (`permissions.ts:658-686`), and finally calls the classifier. If the classifier rejects too many times in a row → falls back to manual approval. - -### 6. Permission Bubbling - -A sub-Agent's (forked via AgentTool) `permissionMode` is set to `'bubble'` (`forkSubagent.ts:50`). This means permission dialogs **bubble up to the parent Agent's terminal**, rather than being silently denied in the sub-Agent. The Bash classifier continues running during this process — displaying the permission dialog while judging in the background whether auto-approval is possible. - -### The Teaching Version's Simplification Is Intentional - -- Multi-stage pipeline → 3 gates: dramatically lower barrier to understanding -- 8 rule sources → 1 local DENY_LIST: manageable concept count -- isDestructive → omitted (teaching version has no UI layer, and it doesn't participate in permission decisions in CC either) -- YoloClassifier → omitted (depends on additional LLM calls and telemetry) -- Permission bubbling → omitted (s15 covers multi-Agent) - -
- - diff --git a/s03_permission/README.ja.md b/s03_permission/README.ja.md index 4008ed6eb..91c1a8375 100644 --- a/s03_permission/README.ja.md +++ b/s03_permission/README.ja.md @@ -1,11 +1,11 @@ # s03: Permission — 実行前に権限を判断する -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 > *"ツール実行前に権限を判断"* — 権限パイプラインは、どの操作に承認が必要かを決める。 > -> **Harness レイヤー**: 権限 — ツール実行前に一つのゲートを追加。 +> **Harness レイヤー**: 権限パイプライン(deny / ask / allow)。 --- @@ -13,15 +13,15 @@ s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 s02 の Agent は 5 つのツールを持つ。file tools は `safe_path` で保護されるが、bash は制限なし。「プロジェクトを掃除して」と頼むと、`rm -rf /` を実行しかねない。 -安全性はモデルを信頼することではなく、コードに頼る — ツール実行前に判断を挟む。 +安全性はモデルを信頼することではなく、危険な操作を実行前にコードで遮断することに頼る。 --- ## ソリューション -![Permission Overview](images/permission-overview.ja.svg) +![Permission Overview](images/permission-overview.svg) -s02 のループは完全に維持される。唯一の変更は、ツール実行前に `check_permission()` を挿入すること — 各ツール呼び出しは 3 つのゲートを固定順序で通過する:ハード拒否が最優先、次にソフト確認、どちらも一致しなければ許可。 +s02 のループは完全に維持される。唯一の変更は、ツール実行前に `check_permission()` を挿入すること。各ツール呼び出しは 3 つのゲートを固定順序で通過する:ハード拒否が最優先、次にソフト確認、どちらも一致しなければ許可。 3 つのゲートは 3 つの決定に対応する: @@ -37,9 +37,9 @@ s02 のループは完全に維持される。唯一の変更は、ツール実 ## 仕組み -![Permission Pipeline](images/permission-pipeline.ja.svg) +![Permission Pipeline](images/permission-pipeline.svg) -**ゲート 1**:ハード拒否リスト。最初に確認し、一致すればブロックメッセージを返す。(教育デモ:単純な文字列マッチングは信頼できるセキュリティ機構ではない — コマンドの変種やシェル展開で回避される可能性がある。CC のアプローチは付録を参照。) +**ゲート 1**:ハード拒否リスト。最初に確認し、一致すればブロックメッセージを返す。(教育デモ:単純な文字列マッチングは信頼できるセキュリティ機構ではない。コマンドの変種やシェル展開で回避される可能性がある。Claude Code のアプローチは付録を参照。) ```python DENY_LIST = [ @@ -54,7 +54,7 @@ def check_deny_list(command: str) -> str | None: return None ``` -**ゲート 2**:ルールマッチング — 「いつユーザーに聞くべきか」を記述する。各ルールはツールとチェック条件を指定する。 +**ゲート 2**:ルールマッチング。「いつユーザーに聞くべきか」を記述する。各ルールはツールとチェック条件を指定する。 ```python PERMISSION_RULES = [ @@ -149,18 +149,18 @@ python s03_permission/code.py ## 次へ -権限チェックは実装された — しかし、毎回ループ内に `check_permission()` をハードコードしている。ツール実行の前後にログを追加したい場合は? 特定の操作後に自動的に git commit をトリガーしたい場合は? このような拡張ロジックがループ内に散らばると、ループはすぐに膨張する。 +権限チェックは実装されたが、毎回ループ内に `check_permission()` をハードコードしている。ツール実行の前後にログを追加したい場合は? 特定の操作後に自動的に git commit をトリガーしたい場合は? このような拡張ロジックがループ内に散らばると、ループはすぐに膨張する。 → s04 Hooks:ループにフックを追加する。拡張ロジックはフックにぶら下げ、ループはクリーンに保つ。
-CC ソースコードを深掘り +Claude Code ソースコードを深掘り -> 以下は CC ソースコード `types/permissions.ts`、`utils/permissions/permissions.ts`、`toolExecution.ts`、`utils/permissions/yoloClassifier.ts`、`tools/AgentTool/forkSubagent.ts` の検証に基づく。 +> 以下は Claude Code ソースコード `types/permissions.ts`、`utils/permissions/permissions.ts`、`toolExecution.ts`、`utils/permissions/yoloClassifier.ts`、`tools/AgentTool/forkSubagent.ts` の検証に基づく。 ### 一、PermissionResult:3 種ではなく、4 種 -教育版の 3 つのゲート(deny → ask → allow)は CC と完全には対応しない。CC の `PermissionResult` には 4 つの behavior がある(`types/permissions.ts:241-266`): +教育版の 3 つのゲート(deny → ask → allow)は Claude Code と完全には対応しない。Claude Code の `PermissionResult` には 4 つの behavior がある(`types/permissions.ts:241-266`): | behavior | 意味 | 教育版の対応 | |----------|------|-------------| @@ -171,7 +171,7 @@ python s03_permission/code.py ### 二、本番環境の検証段階 -CC のツール呼び出しは 3 つのゲートを通るのではなく、`checkPermissionsAndCallTool()`(`toolExecution.ts:599-1745`)、hooks、`hasPermissionsToUseToolInner()`(`utils/permissions/permissions.ts:1158-1310`)、classifier ロジックに分散する複数の段階を経る: +Claude Code のツール呼び出しは 3 つのゲートを通るのではなく、`checkPermissionsAndCallTool()`(`toolExecution.ts:599-1745`)、hooks、`hasPermissionsToUseToolInner()`(`utils/permissions/permissions.ts:1158-1310`)、classifier ロジックに分散する複数の段階を経る: 1. **Zod schema 検証**(`toolExecution.ts:614-680`)— パラメータの型チェック 2. **validateInput()**(`toolExecution.ts:682-733`)— ツールレベルの意味的検証 @@ -192,7 +192,7 @@ CC のツール呼び出しは 3 つのゲートを通るのではなく、`chec ### 三、拒否リスト:1 つのファイルではなく、8 つのソース -CC には単一の deny list はない。権限ルールは 8 つのソースから来る(`types/permissions.ts:54-62`): +Claude Code には単一の deny list はない。権限ルールは 8 つのソースから来る(`types/permissions.ts:54-62`): | ソース | 設定場所 | |--------|---------| @@ -209,11 +209,11 @@ CC には単一の deny list はない。権限ルールは 8 つのソースか ### 四、isDestructive() とは -CC では `isDestructive`(`Tool.ts:405-406`)は**純粋に UI 表示用** — ツール一覧に `[destructive]` ラベルを表示するだけ。権限決定には参加しない。デフォルトではすべてのツールが `false` を返す。ExitWorktree(remove 時)と MCP ツール(`annotations.destructiveHint` に依存)のみがオーバーライドする。 +Claude Code では `isDestructive`(`Tool.ts:405-406`)は**純粋に UI 表示用** — ツール一覧に `[destructive]` ラベルを表示するだけ。権限決定には参加しない。デフォルトではすべてのツールが `false` を返す。ExitWorktree(remove 時)と MCP ツール(`annotations.destructiveHint` に依存)のみがオーバーライドする。 ### 五、YoloClassifier(自動承認) -CC の auto モードでは、毎回ダイアログを表示するわけではない。`classifyYoloAction`(`utils/permissions/yoloClassifier.ts:1012`)はツール呼び出し + 会話コンテキストを分類器 LLM に送って安全性を判断する。まず acceptEdits モードのシミュレーションを試み(`permissions.ts:620-656`、acceptEdits が許可すれば → 自動承認)、次にセーフツールホワイトリストを確認し(`permissions.ts:658-686`)、最後に分類器を呼び出す。分類器が連続して拒否しすぎた場合 → 手動承認にフォールバック。 +Claude Code の auto モードでは、毎回ダイアログを表示するわけではない。`classifyYoloAction`(`utils/permissions/yoloClassifier.ts:1012`)はツール呼び出し + 会話コンテキストを分類器 LLM に送って安全性を判断する。まず acceptEdits モードのシミュレーションを試み(`permissions.ts:620-656`、acceptEdits が許可すれば → 自動承認)、次にセーフツールホワイトリストを確認し(`permissions.ts:658-686`)、最後に分類器を呼び出す。分類器が連続して拒否しすぎた場合 → 手動承認にフォールバック。 ### 六、権限バブリング @@ -221,9 +221,9 @@ CC の auto モードでは、毎回ダイアログを表示するわけでは ### 教育版の単純化は意図的 -- 多段階パイプライン → 3 ゲート:理解のハードルが大幅に下がる +- 多段階パイプライン → 3 ゲート:概念数が減り、理解しやすい - 8 ルールソース → 1 つのローカル DENY_LIST:概念量を制御可能 -- isDestructive → 省略(教育版には UI レイヤーがなく、CC でも権限決定には参加しない) +- isDestructive → 省略(教育版には UI レイヤーがなく、Claude Code でも権限決定には参加しない) - YoloClassifier → 省略(追加の LLM 呼び出しとテレメトリに依存) - 権限バブリング → 省略(s15 でマルチ Agent を扱う) diff --git a/s03_permission/README.md b/s03_permission/README.md index 0c745664c..f962eb8d9 100644 --- a/s03_permission/README.md +++ b/s03_permission/README.md @@ -1,45 +1,45 @@ -# s03: Permission — 执行前做权限判断 +# s03: Permission — Check Permissions Before Execution -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 -> *"工具执行前先做权限判断"* — 权限管线决定哪些操作需要审批。 +> *"Check permissions before executing"* — The permission pipeline decides which operations need approval. > -> **Harness 层**: 权限 — 在工具执行前加一道门。 +> **Harness Layer**: Permission pipeline (deny / ask / allow). --- -## 问题 +## The Problem -s02 的 Agent 有 5 个工具。file tools 受 `safe_path` 保护,但 bash 不受限制。让它"清理一下项目",可能执行 `rm -rf /`。 +s02's Agent has 5 tools. File tools are protected by `safe_path`, but bash is unrestricted. Ask it to "clean up the project," and it might run `rm -rf /`. -安全不能靠信任模型,要靠代码——在工具执行之前做判断。 +Safety can't rely on trusting the model. It needs code that intercepts dangerous operations. --- -## 解决方案 +## The Solution ![Permission Overview](images/permission-overview.svg) -s02 的循环完全保留。唯一的变动在工具执行前插入 `check_permission()`——每个工具调用经过三道闸门,顺序固定:硬拒绝优先,软询问次之,都没命中就放行。 +s02's loop is fully preserved. The only change is inserting `check_permission()` before tool execution. Each tool call passes through three gates in a fixed order: hard deny first, then soft ask, and if neither matches, allow. -三道闸门对应三种决策: +The three gates correspond to three decisions: -| 闸门 | 作用 | 命中后 | -|------|------|--------| -| 1. 拒绝列表 | 永远禁止的操作(`rm -rf /`、`sudo`) | 直接拒绝,不执行 | -| 2. 规则匹配 | 取决于上下文的操作(写工作区外、`rm` 文件) | 交给闸门 3 | -| 3. 用户审批 | 闸门 2 命中后,暂停等用户确认 | 用户决定允许或拒绝 | +| Gate | Purpose | On Match | +|------|---------|----------| +| 1. Deny List | Permanently forbidden operations (`rm -rf /`, `sudo`) | Denied immediately, not executed | +| 2. Rule Matching | Context-dependent operations (writing outside workspace, `rm` files) | Passed to Gate 3 | +| 3. User Approval | After Gate 2 matches, pauses for user confirmation | User decides allow or deny | -三道都没命中 → 直接执行。大部分日常操作走这条路。 +None of the three gates match → execute directly. Most routine operations take this path. --- -## 工作原理 +## How It Works ![Permission Pipeline](images/permission-pipeline.svg) -**闸门 1**:一张硬拒绝表,先查,命中就返回阻止信息。(教学示意:简单字符串匹配不是可靠安全机制,命令变体和 shell 展开可能绕过。CC 的做法见附录。) +**Gate 1**: A hard deny list. Check first; if matched, return a block message. (Teaching demo: simple string matching is not a reliable security mechanism. Command variants and shell expansion can bypass it. Claude Code's approach is in the appendix.) ```python DENY_LIST = [ @@ -54,7 +54,7 @@ def check_deny_list(command: str) -> str | None: return None ``` -**闸门 2**:规则匹配——描述"什么时候需要问用户"。每条规则指定工具和检查条件。 +**Gate 2**: Rule matching, which describes "when to ask the user." Each rule specifies a tool and a check condition. ```python PERMISSION_RULES = [ @@ -77,7 +77,7 @@ def check_rules(tool_name: str, args: dict) -> str | None: return None ``` -**闸门 3**:规则命中后,暂停等用户输入。 +**Gate 3**: After a rule matches, pause for user input. ```python def ask_user(tool_name: str, args: dict, reason: str) -> str: @@ -87,18 +87,18 @@ def ask_user(tool_name: str, args: dict, reason: str) -> str: return "allow" if choice in ("y", "yes") else "deny" ``` -**三道闸门串在一起**,插在工具执行之前: +**All three gates chained together**, inserted before tool execution: ```python def check_permission(block) -> bool: - # 闸门 1: 硬拒绝 + # Gate 1: Hard deny if block.name == "bash": reason = check_deny_list(block.input.get("command", "")) if reason: print(f"\n⛔ {reason}") return False - # 闸门 2 + 3: 规则匹配 → 用户审批 + # Gate 2 + 3: Rule matching → User approval reason = check_rules(block.name, block.input) if reason: decision = ask_user(block.name, block.input, reason) @@ -107,125 +107,125 @@ def check_permission(block) -> bool: return True -# 在 agent_loop 中——s02 的循环只加了一行: +# In agent_loop — s02's loop with just one line added: for block in response.content: if block.type == "tool_use": - if not check_permission(block): # ← 新增 + if not check_permission(block): # ← NEW results.append({... "content": "Permission denied."}) continue - output = TOOL_HANDLERS[block.name](**block.input) # s02 原有 + output = TOOL_HANDLERS[block.name](**block.input) # s02 original results.append(...) ``` --- -## 相对 s02 的变更 +## Changes from s02 -| 组件 | 之前 (s02) | 之后 (s03) | -|------|-----------|-----------| -| 安全模型 | 无(信任模型) | 三道闸门权限管线 | -| 新函数 | — | check_deny_list, check_rules, ask_user, check_permission | -| 循环 | 直接执行所有工具 | 执行前插入 check_permission() | +| Component | Before (s02) | After (s03) | +|-----------|-------------|-------------| +| Security model | None (trust the model) | Three-gate permission pipeline | +| New functions | — | check_deny_list, check_rules, ask_user, check_permission | +| Loop | Executes all tools directly | Inserts check_permission() before execution | --- -## 试一下 +## Try It ```sh cd learn-claude-code python s03_permission/code.py ``` -试试这些 prompt: +Try these prompts: -1. `Create a file called test.txt in the current directory`(应该直接通过) -2. `Delete the file test.txt`(bash + rm 会触发闸门 2) -3. `What files are in the current directory?`(只读,全部通过) -4. `Try to write a file to /etc/something`(写工作区外,触发闸门 2) +1. `Create a file called test.txt in the current directory` (should pass through) +2. `Delete the file test.txt` (bash + rm triggers Gate 2) +3. `What files are in the current directory?` (read-only, all pass) +4. `Try to write a file to /etc/something` (writing outside workspace triggers Gate 2) -观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝? +What to watch for: Which operations pass through? Which need your confirmation? Which are denied outright? --- -## 接下来 +## What's Next -权限检查做了——但每次都在循环里硬编码 `check_permission()`。如果我想在每次工具执行前后加日志?如果想在某些操作后自动触发 git commit?这些扩展逻辑散落在 loop 里,循环很快就会膨胀。 +Permission checks are in place, but every check is hardcoded as `check_permission()` inside the loop. What if you want to add logging before and after each tool execution? What if you want to auto-trigger a git commit after certain operations? Scattering this extension logic throughout the loop makes it bloat. -s04 Hooks → 给循环加钩子,扩展逻辑挂在钩子上,循环保持干净。 +→ s04 Hooks: Add hooks to the loop. Extension logic hangs on hooks; the loop stays clean.
-深入 CC 源码 +Dive into Claude Code Source Code -> 以下基于 CC 源码 `types/permissions.ts`、`utils/permissions/permissions.ts`、`toolExecution.ts`、`utils/permissions/yoloClassifier.ts`、`tools/AgentTool/forkSubagent.ts` 的核查。 +> The following is based on a review of Claude Code source code `types/permissions.ts`, `utils/permissions/permissions.ts`, `toolExecution.ts`, `utils/permissions/yoloClassifier.ts`, `tools/AgentTool/forkSubagent.ts`. -### 一、PermissionResult:不是 3 种,是 4 种 +### 1. PermissionResult: Not 3, but 4 -教学版的三道闸门(deny → ask → allow)和 CC 不完全对应。CC 的 `PermissionResult` 有 4 个 behavior(`types/permissions.ts:241-266`): +The teaching version's three gates (deny → ask → allow) don't fully correspond to Claude Code. Claude Code's `PermissionResult` has 4 behaviors (`types/permissions.ts:241-266`): -| behavior | 含义 | 教学版对应 | -|----------|------|-----------| -| `allow` | 直接允许 | 闸门 3 通过 | -| `deny` | 直接拒绝 | 闸门 1 命中 | -| `ask` | 弹出对话框问用户 | 闸门 2 命中 | -| `passthrough` | 工具不表态,交给通用管线决定 | 教学版无 | +| behavior | Meaning | Teaching Version Equivalent | +|----------|---------|---------------------------| +| `allow` | Allow directly | Gate 3 passes | +| `deny` | Deny directly | Gate 1 matches | +| `ask` | Show dialog to user | Gate 2 matches | +| `passthrough` | Tool doesn't express opinion, passes to generic pipeline | Not in teaching version | -### 二、生产版的验证阶段 +### 2. Production Verification Stages -CC 的工具调用不是经过三道闸门,而是经过多个阶段,分布在 `checkPermissionsAndCallTool()`(`toolExecution.ts:599-1745`)、hooks、`hasPermissionsToUseToolInner()`(`utils/permissions/permissions.ts:1158-1310`)和 classifier 逻辑里: +Claude Code's tool calls don't go through three gates — they go through multiple stages distributed across `checkPermissionsAndCallTool()` (`toolExecution.ts:599-1745`), hooks, `hasPermissionsToUseToolInner()` (`utils/permissions/permissions.ts:1158-1310`), and classifier logic: -1. **Zod schema 验证**(`toolExecution.ts:614-680`)— 参数类型检查 -2. **validateInput()**(`toolExecution.ts:682-733`)— 工具级语义验证 -3. **backfillObservableInput()**(`toolExecution.ts:784`)— 补全遗留字段 -4. **PreToolUse hooks**(`toolExecution.ts:800-862`)— 钩子可以返回 allow/deny/ask -5. **resolveHookPermissionDecision()**(`toolExecution.ts:921-931`)— 协调钩子+管线决策 -6. **hasPermissionsToUseToolInner()**(`permissions.ts:1158-1310`)— 多层规则检查: - - 整个工具被 deny rule 禁用 → `deny` - - 整个工具被 ask rule 标记 → `ask` - - `tool.checkPermissions()` 工具自己的判断 - - 工具自己返回 deny → `deny` +1. **Zod schema validation** (`toolExecution.ts:614-680`) — parameter type checking +2. **validateInput()** (`toolExecution.ts:682-733`) — tool-level semantic validation +3. **backfillObservableInput()** (`toolExecution.ts:784`) — backfill legacy fields +4. **PreToolUse hooks** (`toolExecution.ts:800-862`) — hooks can return allow/deny/ask +5. **resolveHookPermissionDecision()** (`toolExecution.ts:921-931`) — coordinate hook + pipeline decisions +6. **hasPermissionsToUseToolInner()** (`permissions.ts:1158-1310`) — multi-layer rule check: + - Entire tool disabled by deny rule → `deny` + - Entire tool flagged by ask rule → `ask` + - `tool.checkPermissions()` tool's own judgment + - Tool itself returns deny → `deny` - `requiresUserInteraction()` → `ask` - - 内容相关的 ask 规则 → `ask`(不可绕过) - - 安全检查违规 → `ask`(不可绕过) - - bypassPermissions 模式 → `allow` - - 整个工具被 allow rule 放行 → `allow` - - passthrough → 转为 `ask` + - Content-related ask rules → `ask` (not bypassable) + - Security check violation → `ask` (not bypassable) + - bypassPermissions mode → `allow` + - Entire tool allowed by allow rule → `allow` + - passthrough → converted to `ask` -### 三、拒绝列表:不是一个文件,是 8 个来源 +### 3. Deny List: Not One File, but 8 Sources -CC 没有单一的 deny list。权限规则来自 8 个来源(`types/permissions.ts:54-62`): +Claude Code doesn't have a single deny list. Permission rules come from 8 sources (`types/permissions.ts:54-62`): -| 来源 | 配置位置 | -|------|---------| +| Source | Configuration Location | +|--------|----------------------| | `userSettings` | `~/.claude/settings.json` | | `projectSettings` | `.claude/settings.json` | | `localSettings` | `settings.local.json` | | `flagSettings` | Feature flags | -| `policySettings` | 企业管理策略 | +| `policySettings` | Enterprise management policy | | `cliArg` | `--allowedTools` / `--deniedTools` | -| `command` | 内联命令 | -| `session` | 会话内临时授权 | +| `command` | Inline command | +| `session` | In-session temporary authorization | -每条规则格式:`{ toolName: "Bash", ruleBehavior: "deny", ruleContent: "npm publish:*" }`。多个来源的规则合并,高优先级来源覆盖低优先级(从低到高:user < project < local < flag < policy,加上 cliArg、command、session)。 +Each rule format: `{ toolName: "Bash", ruleBehavior: "deny", ruleContent: "npm publish:*" }`. Rules from multiple sources are merged, with higher-priority sources overriding lower ones (low to high: user < project < local < flag < policy, plus cliArg, command, session). -### 四、isDestructive() 是什么 +### 4. What is isDestructive() -CC 中 `isDestructive`(`Tool.ts:405-406`)**纯粹是 UI 展示用的**——在工具列表里显示 `[destructive]` 标签。它不参与权限决策。默认所有工具都返回 `false`。只有 ExitWorktree(remove 时)和 MCP 工具(依赖 `annotations.destructiveHint`)覆写了它。 +In Claude Code, `isDestructive` (`Tool.ts:405-406`) is **purely for UI display** — showing a `[destructive]` label in the tool list. It doesn't participate in permission decisions. All tools return `false` by default. Only ExitWorktree (on remove) and MCP tools (depending on `annotations.destructiveHint`) override it. -### 五、YoloClassifier(自动审批) +### 5. YoloClassifier (Auto-Approval) -CC 的 auto 模式下,不会每次都弹对话框。`classifyYoloAction`(`utils/permissions/yoloClassifier.ts:1012`)把工具调用 + 对话上下文发给一个分类器 LLM 判断是否安全。先尝试 acceptEdits 模式模拟(`permissions.ts:620-656`,如果 acceptEdits 允许 → 直接批准),再查安全工具白名单(`permissions.ts:658-686`),最后才调分类器。分类器连续拒绝太多次 → 回退到人工审批。 +In Claude Code's auto mode, it doesn't pop a dialog every time. `classifyYoloAction` (`utils/permissions/yoloClassifier.ts:1012`) sends the tool call + conversation context to a classifier LLM to judge safety. It first tries acceptEdits mode simulation (`permissions.ts:620-656`, if acceptEdits allows → auto-approve), then checks the safe tool whitelist (`permissions.ts:658-686`), and finally calls the classifier. If the classifier rejects too many times in a row → falls back to manual approval. -### 六、权限冒泡 +### 6. Permission Bubbling -子 Agent(通过 AgentTool fork 出来的)的 `permissionMode` 设为 `'bubble'`(`forkSubagent.ts:50`)。意思是权限弹窗**冒泡到父 Agent 的终端**,而不是在子 Agent 里静默拒绝。Bash 分类器在这个过程中继续跑——给权限对话框显示的同时在后台判断是否可以自动批准。 +A sub-Agent's (forked via AgentTool) `permissionMode` is set to `'bubble'` (`forkSubagent.ts:50`). This means permission dialogs **bubble up to the parent Agent's terminal**, rather than being silently denied in the sub-Agent. The Bash classifier continues running during this process — displaying the permission dialog while judging in the background whether auto-approval is possible. -### 教学版的简化是刻意的 +### The Teaching Version's Simplification Is Intentional -- 多阶段管线 → 3 道闸门:理解门槛大幅降低 -- 8 个规则来源 → 1 个本地 DENY_LIST:概念量可控 -- isDestructive → 忽略(教学版没有 UI 层,CC 里它也不参与权限决策) -- YoloClassifier → 省略(依赖于额外的 LLM 调用和遥测系统) -- 权限冒泡 → 省略(s15 才涉及多 Agent) +- Multi-stage pipeline → 3 gates: fewer concepts to learn +- 8 rule sources → 1 local DENY_LIST: manageable concept count +- isDestructive → omitted (teaching version has no UI layer, and it doesn't participate in permission decisions in Claude Code either) +- YoloClassifier → omitted (depends on additional LLM calls and telemetry) +- Permission bubbling → omitted (s15 covers multi-Agent)
diff --git a/s03_permission/README.zh.md b/s03_permission/README.zh.md new file mode 100644 index 000000000..ab20010d8 --- /dev/null +++ b/s03_permission/README.zh.md @@ -0,0 +1,232 @@ +# s03: Permission — 执行前做权限判断 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 +> *"工具执行前先做权限判断"* — 权限管线决定哪些操作需要审批。 +> +> **Harness 层**: 权限管线(deny / ask / allow)。 + +--- + +## 问题 + +s02 的 Agent 有 5 个工具。file tools 受 `safe_path` 保护,但 bash 不受限制。让它"清理一下项目",可能执行 `rm -rf /`。 + +安全不能靠信任模型,要靠代码在执行前拦截危险操作。 + +--- + +## 解决方案 + +![Permission Overview](images/permission-overview.svg) + +s02 的循环完全保留。唯一的变动是在工具执行前插入 `check_permission()`。每个工具调用经过三道闸门,顺序固定:硬拒绝优先,软询问次之,都没命中就放行。 + +三道闸门对应三种决策: + +| 闸门 | 作用 | 命中后 | +|------|------|--------| +| 1. 拒绝列表 | 永远禁止的操作(`rm -rf /`、`sudo`) | 直接拒绝,不执行 | +| 2. 规则匹配 | 取决于上下文的操作(写工作区外、`rm` 文件) | 交给闸门 3 | +| 3. 用户审批 | 闸门 2 命中后,暂停等用户确认 | 用户决定允许或拒绝 | + +三道都没命中 → 直接执行。大部分日常操作走这条路。 + +--- + +## 工作原理 + +![Permission Pipeline](images/permission-pipeline.svg) + +**闸门 1**:一张硬拒绝表,先查,命中就返回阻止信息。(教学示意:简单字符串匹配不是可靠安全机制,命令变体和 shell 展开可能绕过。Claude Code 的做法见附录。) + +```python +DENY_LIST = [ + "rm -rf /", "sudo", "shutdown", "reboot", + "mkfs", "dd if=", "> /dev/sda", +] + +def check_deny_list(command: str) -> str | None: + for pattern in DENY_LIST: + if pattern in command: + return f"Blocked: '{pattern}' is on the deny list" + return None +``` + +**闸门 2**:规则匹配,描述"什么时候需要问用户"。每条规则指定工具和检查条件。 + +```python +PERMISSION_RULES = [ + { + "tools": ["write_file", "edit_file"], + "check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR), + "message": "Writing outside workspace", + }, + { + "tools": ["bash"], + "check": lambda args: any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]), + "message": "Potentially destructive command", + }, +] + +def check_rules(tool_name: str, args: dict) -> str | None: + for rule in PERMISSION_RULES: + if tool_name in rule["tools"] and rule["check"](args): + return rule["message"] + return None +``` + +**闸门 3**:规则命中后,暂停等用户输入。 + +```python +def ask_user(tool_name: str, args: dict, reason: str) -> str: + print(f"\n⚠ {reason}") + print(f" Tool: {tool_name}({args})") + choice = input(" Allow? [y/N] ").strip().lower() + return "allow" if choice in ("y", "yes") else "deny" +``` + +**三道闸门串在一起**,插在工具执行之前: + +```python +def check_permission(block) -> bool: + # 闸门 1: 硬拒绝 + if block.name == "bash": + reason = check_deny_list(block.input.get("command", "")) + if reason: + print(f"\n⛔ {reason}") + return False + + # 闸门 2 + 3: 规则匹配 → 用户审批 + reason = check_rules(block.name, block.input) + if reason: + decision = ask_user(block.name, block.input, reason) + if decision == "deny": + return False + + return True + +# 在 agent_loop 中——s02 的循环只加了一行: +for block in response.content: + if block.type == "tool_use": + if not check_permission(block): # ← 新增 + results.append({... "content": "Permission denied."}) + continue + output = TOOL_HANDLERS[block.name](**block.input) # s02 原有 + results.append(...) +``` + +--- + +## 相对 s02 的变更 + +| 组件 | 之前 (s02) | 之后 (s03) | +|------|-----------|-----------| +| 安全模型 | 无(信任模型) | 三道闸门权限管线 | +| 新函数 | — | check_deny_list, check_rules, ask_user, check_permission | +| 循环 | 直接执行所有工具 | 执行前插入 check_permission() | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s03_permission/code.py +``` + +试试这些 prompt: + +1. `Create a file called test.txt in the current directory`(应该直接通过) +2. `Delete the file test.txt`(bash + rm 会触发闸门 2) +3. `What files are in the current directory?`(只读,全部通过) +4. `Try to write a file to /etc/something`(写工作区外,触发闸门 2) + +观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝? + +--- + +## 接下来 + +权限检查做了,但每次都在循环里硬编码 `check_permission()`。如果我想在每次工具执行前后加日志?如果想在某些操作后自动触发 git commit?这些扩展逻辑散落在 loop 里,循环很快就会膨胀。 + +s04 Hooks → 给循环加钩子,扩展逻辑挂在钩子上,循环保持干净。 + +
+深入 Claude Code 源码 + +> 以下基于 Claude Code 源码 `types/permissions.ts`、`utils/permissions/permissions.ts`、`toolExecution.ts`、`utils/permissions/yoloClassifier.ts`、`tools/AgentTool/forkSubagent.ts` 的核查。 + +### 一、PermissionResult:不是 3 种,是 4 种 + +教学版的三道闸门(deny → ask → allow)和 Claude Code 不完全对应。Claude Code 的 `PermissionResult` 有 4 个 behavior(`types/permissions.ts:241-266`): + +| behavior | 含义 | 教学版对应 | +|----------|------|-----------| +| `allow` | 直接允许 | 闸门 3 通过 | +| `deny` | 直接拒绝 | 闸门 1 命中 | +| `ask` | 弹出对话框问用户 | 闸门 2 命中 | +| `passthrough` | 工具不表态,交给通用管线决定 | 教学版无 | + +### 二、生产版的验证阶段 + +Claude Code 的工具调用不是经过三道闸门,而是经过多个阶段,分布在 `checkPermissionsAndCallTool()`(`toolExecution.ts:599-1745`)、hooks、`hasPermissionsToUseToolInner()`(`utils/permissions/permissions.ts:1158-1310`)和 classifier 逻辑里: + +1. **Zod schema 验证**(`toolExecution.ts:614-680`)— 参数类型检查 +2. **validateInput()**(`toolExecution.ts:682-733`)— 工具级语义验证 +3. **backfillObservableInput()**(`toolExecution.ts:784`)— 补全遗留字段 +4. **PreToolUse hooks**(`toolExecution.ts:800-862`)— 钩子可以返回 allow/deny/ask +5. **resolveHookPermissionDecision()**(`toolExecution.ts:921-931`)— 协调钩子+管线决策 +6. **hasPermissionsToUseToolInner()**(`permissions.ts:1158-1310`)— 多层规则检查: + - 整个工具被 deny rule 禁用 → `deny` + - 整个工具被 ask rule 标记 → `ask` + - `tool.checkPermissions()` 工具自己的判断 + - 工具自己返回 deny → `deny` + - `requiresUserInteraction()` → `ask` + - 内容相关的 ask 规则 → `ask`(不可绕过) + - 安全检查违规 → `ask`(不可绕过) + - bypassPermissions 模式 → `allow` + - 整个工具被 allow rule 放行 → `allow` + - passthrough → 转为 `ask` + +### 三、拒绝列表:不是一个文件,是 8 个来源 + +Claude Code 没有单一的 deny list。权限规则来自 8 个来源(`types/permissions.ts:54-62`): + +| 来源 | 配置位置 | +|------|---------| +| `userSettings` | `~/.claude/settings.json` | +| `projectSettings` | `.claude/settings.json` | +| `localSettings` | `settings.local.json` | +| `flagSettings` | Feature flags | +| `policySettings` | 企业管理策略 | +| `cliArg` | `--allowedTools` / `--deniedTools` | +| `command` | 内联命令 | +| `session` | 会话内临时授权 | + +每条规则格式:`{ toolName: "Bash", ruleBehavior: "deny", ruleContent: "npm publish:*" }`。多个来源的规则合并,高优先级来源覆盖低优先级(从低到高:user < project < local < flag < policy,加上 cliArg、command、session)。 + +### 四、isDestructive() 是什么 + +Claude Code 中 `isDestructive`(`Tool.ts:405-406`)**纯粹是 UI 展示用的**——在工具列表里显示 `[destructive]` 标签。它不参与权限决策。默认所有工具都返回 `false`。只有 ExitWorktree(remove 时)和 MCP 工具(依赖 `annotations.destructiveHint`)覆写了它。 + +### 五、YoloClassifier(自动审批) + +Claude Code 的 auto 模式下,不会每次都弹对话框。`classifyYoloAction`(`utils/permissions/yoloClassifier.ts:1012`)把工具调用 + 对话上下文发给一个分类器 LLM 判断是否安全。先尝试 acceptEdits 模式模拟(`permissions.ts:620-656`,如果 acceptEdits 允许 → 直接批准),再查安全工具白名单(`permissions.ts:658-686`),最后才调分类器。分类器连续拒绝太多次 → 回退到人工审批。 + +### 六、权限冒泡 + +子 Agent(通过 AgentTool fork 出来的)的 `permissionMode` 设为 `'bubble'`(`forkSubagent.ts:50`)。意思是权限弹窗**冒泡到父 Agent 的终端**,而不是在子 Agent 里静默拒绝。Bash 分类器在这个过程中继续跑——给权限对话框显示的同时在后台判断是否可以自动批准。 + +### 教学版的简化是刻意的 + +- 多阶段管线 → 3 道闸门:概念更少,更容易上手 +- 8 个规则来源 → 1 个本地 DENY_LIST:概念量可控 +- isDestructive → 忽略(教学版没有 UI 层,Claude Code 里它也不参与权限决策) +- YoloClassifier → 省略(依赖于额外的 LLM 调用和遥测系统) +- 权限冒泡 → 省略(s15 才涉及多 Agent) + +
+ + diff --git a/s03_permission/images/permission-overview.en.svg b/s03_permission/images/permission-overview.en.svg deleted file mode 100644 index 8255bb26d..000000000 --- a/s03_permission/images/permission-overview.en.svg +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - Permission — Loop unchanged, a gate before tool execution - - - s02 preserved - - - - messages[] - - - - - - - LLM - stop_reason? - - - - No - - - Return result - - - - Yes - - - s03 new - - - - check_permission() - - - - Gate 1: Deny List - - - - Gate 2: Rule Matching - - - - Gate 3: User Approval - - - - Deny - - - - Pass - - - s02 - - - - TOOL_ - HANDLERS - bash/read/write/... - - - - - - - tool_result - - - - - - - - s02 preserved (loop, LLM, dispatch — unchanged) - - s03 new (three-gate permission pipeline) - diff --git a/s03_permission/images/permission-overview.ja.svg b/s03_permission/images/permission-overview.ja.svg deleted file mode 100644 index f4fd613e2..000000000 --- a/s03_permission/images/permission-overview.ja.svg +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - Permission — ループは変更なし、ツール実行前にゲートを追加 - - - s02 維持 - - - - messages[] - - - - - - - LLM - stop_reason? - - - - No - - - 結果を返す - - - - Yes - - - s03 新規 - - - - check_permission() - - - - ゲート 1: 拒否リスト - - - - ゲート 2: ルール照合 - - - - ゲート 3: ユーザー承認 - - - - 拒否 - - - - 通過 - - - s02 - - - - TOOL_ - HANDLERS - bash/read/write/... - - - - - - - tool_result - - - - - - - - s02 維持(ループ、LLM、ディスパッチ — 変更なし) - - s03 新規(3 ゲート権限パイプライン) - diff --git a/s03_permission/images/permission-overview.svg b/s03_permission/images/permission-overview.svg index 61567d8f9..aae3369db 100644 --- a/s03_permission/images/permission-overview.svg +++ b/s03_permission/images/permission-overview.svg @@ -1,104 +1,120 @@ - + - - + + - - + + - - - - - - - - - - - - - - Permission — 循环不变,工具执行前加一道门 - - - s02 保留 - - - - messages[] - - - - - - - LLM - stop_reason? - - - - - - - 返回结果 - - - - - - - s03 新增 - - - - check_permission() - - - - 闸门 1: 拒绝列表 - - - - 闸门 2: 规则匹配 - - - - 闸门 3: 用户审批 - - - - 拒绝 - - - - 通过 - - - s02 - - - - TOOL_ - HANDLERS - bash/read/write/... - - - - - - - tool_result - - - - - - - - s02 保留(循环、LLM、分发——完全不变) - - s03 新增(三道闸门权限管线) - + + + + Permission — Gate Before Tool Execution + check_permission() gates every tool call before dispatch + + + + + + messages[] + + + + + + + LLM + stop_reason = ? + + + + + + + tool_use + + + + No + + Return Result + + + + Yes + + + + check_permission() + + + + Gate 1 + Deny List + + + + Gate 2 + Rule Matching + + + + Gate 3 + User Approval + + + + + + + + + + + + Denied + + + + Passed + + + + TOOL_HANDLERS + bash / read / write + + + + + + + + tool_result + + + + + + + + if not check_permission(block): + results.append("Permission denied.") + continue + output = TOOL_HANDLERS[name](**input) + \ No newline at end of file diff --git a/s03_permission/images/permission-pipeline.en.svg b/s03_permission/images/permission-pipeline.en.svg deleted file mode 100644 index 1eb105187..000000000 --- a/s03_permission/images/permission-pipeline.en.svg +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - Permission Pipeline — Three Gates - - - - Tool call enters - - - - - - Gate 1: Deny List - rm -rf /, sudo, shutdown - - - - - - Gate 2: Rule Matching - Write outside ws? Destructive? - no match → allow - - - match - - - - Gate 3 - User approval - allow / deny - - - - Three Decisions - - - Deny - Gate 1 hit, or user denied - - - Ask - Gate 2 matched, enter Gate 3 - - - Allow - No rule hit, or user approved - - Priority: hard deny → rule matching → if matched ask user; if unmatched allow by default - diff --git a/s03_permission/images/permission-pipeline.ja.svg b/s03_permission/images/permission-pipeline.ja.svg deleted file mode 100644 index 090aaf1cb..000000000 --- a/s03_permission/images/permission-pipeline.ja.svg +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - Permission Pipeline — 3 つのゲート - - - - ツール呼び出し - - - - - - ゲート 1: 拒否リスト - rm -rf /, sudo, shutdown - - - - - - ゲート 2: ルール照合 - ws 外への書き込み?破壊的? - 不一致 → allow - - - 一致 - - - - ゲート 3 - ユーザー承認 - allow / deny - - - - 3 つの決定 - - - 拒否 (deny) - ゲート 1 一致、またはユーザー拒否 - - - 確認 (ask) - ゲート 2 一致、ゲート 3 へ - - - 許可 (allow) - ルール不一致、またはユーザー許可 - - 優先順位:ハード拒否 → ルール照合 → 一致ならユーザー承認、不一致ならデフォルト許可 - diff --git a/s03_permission/images/permission-pipeline.svg b/s03_permission/images/permission-pipeline.svg index c3b0b95e0..f4f436a41 100644 --- a/s03_permission/images/permission-pipeline.svg +++ b/s03_permission/images/permission-pipeline.svg @@ -1,61 +1,99 @@ - + - - - - - + + + + + - - - - Permission Pipeline — 三道闸门 + + + + Permission Pipeline + Three-gate check before tool execution + + + + + + Tool Call + + + + + + Gate 1: Deny List + rm -rf / sudo shutdown + + + pass + + + + Gate 2: Rule Match + Write outside ws? Destructive? - - - 工具调用进入 + + match - + + + Gate 3 + User Approval - - - 闸门 1: 拒绝列表 - rm -rf /, sudo, shutdown + - + + + hit + + Deny - - - 闸门 2: 规则匹配 - 写工作区外?读敏感路径? - 未命中 → allow + + + no match + + Allow - - 命中 + + + deny + + Deny - - - 闸门 3 - 用户审批 - 允许 / 拒绝 + + + allow + + Allow - - - 三种决策 + + + Three Decisions - - 阻止 (deny) - 闸门 1 命中,或用户拒绝 + + + Deny + Gate 1 hit, or user denied + + Blocked: on deny list - - 询问 (ask) - 闸门 2 命中,进入闸门 3 + + + Ask + Gate 2 matched, enter Gate 3 + + Allow? [y/N] _ - - 允许 (allow) - 规则未命中,或用户允许 + + + Allow + No rule hit, or user approved + + Executing tool... - 规则优先:闸门 1 硬拒绝 → 闸门 2 规则匹配 → 命中则用户审批,未命中默认允许 + + Priority: hard deny → rule match → if matched, ask user; if unmatched, allow diff --git a/s04_hooks/README.en.md b/s04_hooks/README.en.md deleted file mode 100644 index 1a910309b..000000000 --- a/s04_hooks/README.en.md +++ /dev/null @@ -1,283 +0,0 @@ -# s04: Hooks — Hang on the Loop, Don't Write into It - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20 - -> *"Hang on the loop, don't write into it"* — Hooks inject extension logic before and after tool execution. -> -> **Harness Layer**: Hooks — Extension points that don't invade the loop. - ---- - -## The Problem - -The s03 Agent has permission checks. But every new check, "log every bash call", "auto git add after writes", requires modifying the `agent_loop` function. - -The loop quickly becomes this: - -```python -def agent_loop(messages): - while True: - # ... LLM call ... - for block in response.content: - if block.type != "tool_use": - continue - log_to_file(block) # added a line - check_permission(block) # added a line - notify_slack(block) # added another line - output = execute(block) - auto_git_add(block) # yet another line - # ... the loop is unrecognizable -``` - -What you want to extend is the Agent's behavior, but what you're modifying is the loop itself. The loop should be a stable core; extensions should hang on the outside. - ---- - -## The Solution - -![Hooks Overview](images/hooks-overview.en.svg) - -The s03 loop and permission logic are fully preserved. The only change is moving `check_permission()` from inside the loop body onto a hook. The loop no longer directly calls any check function. Instead it calls `trigger_hooks("PreToolUse", block)`, and the registry decides what to run. - -Four events, covering a complete agent cycle: - -| Event | Trigger Timing | Typical Use | -|-------|---------------|-------------| -| UserPromptSubmit | After user input, before entering LLM | Input validation, context injection | -| PreToolUse | Before tool execution | Permission checks, logging | -| PostToolUse | After tool execution | Side effects (auto git add etc.), output checking | -| Stop | When the loop is about to exit | Cleanup (CC also supports force continuation) | - -Extensions are added via `register_hook()`. The loop only calls `trigger_hooks()`. - ---- - -## How It Works - -**Hook registry**: a dict mapping event names to callback lists. - -```python -HOOKS = { - "UserPromptSubmit": [], - "PreToolUse": [], - "PostToolUse": [], - "Stop": [], -} - -def register_hook(event: str, callback): - HOOKS[event].append(callback) - -def trigger_hooks(event: str, *args): - for callback in HOOKS[event]: - result = callback(*args) - if result is not None: # return value ≠ None → hook says "stop" - return result - return None -``` - -In the teaching version, PreToolUse returning non-None means block execution; Stop returning non-None means force continuation. UserPromptSubmit and PostToolUse return values are unused. - -**UserPromptSubmit**, triggers after user input, before entering the LLM. CC can intercept or modify input; the teaching version only logs: - -```python -def context_inject_hook(query: str) -> str | None: - """Inject current working directory info into every prompt.""" - print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m") - return None # return None = no modification, let prompt through - -register_hook("UserPromptSubmit", context_inject_hook) -``` - -In the main loop, triggered right after user input: - -```python -query = input("s04 >> ") -trigger_hooks("UserPromptSubmit", query) # ← before entering LLM -history.append({"role": "user", "content": query}) -agent_loop(history) -``` - -**PreToolUse / PostToolUse**, hooks before and after tool execution. s03's permission check logic is now wrapped as a PreToolUse hook, plus a logging hook and a large-output reminder: - -```python -# PreToolUse: permission check (s03 logic, moved from loop to hook) -def permission_hook(block): - if block.name == "bash": - for pattern in DENY_LIST: - if pattern in block.input.get("command", ""): - return "Permission denied by deny list" - if block.name in ("write_file", "edit_file"): - path = block.input.get("path", "") - if not (WORKDIR / path).resolve().is_relative_to(WORKDIR): - choice = input(" Allow? [y/N] ").strip().lower() - if choice not in ("y", "yes"): - return "Permission denied by user" - return None - -# PreToolUse: logging -def log_hook(block): - print(f"[HOOK] {block.name}(...)") - -# PostToolUse: large output reminder -def large_output_hook(block, output): - if len(str(output)) > 100000: - print(f"[HOOK] ⚠ Large output from {block.name}") - -register_hook("PreToolUse", permission_hook) -register_hook("PreToolUse", log_hook) -register_hook("PostToolUse", large_output_hook) -``` - -**Stop**, triggers when the loop is about to exit (`stop_reason != "tool_use"`). The teaching version prints a cleanup summary: - -```python -def summary_hook(messages: list) -> str | None: - """Print a summary when the loop is about to stop.""" - tool_count = sum(1 for m in messages - for b in (m.get("content") if isinstance(m.get("content"), list) else []) - if isinstance(b, dict) and b.get("type") == "tool_result") - print(f"\033[90m[HOOK] Stop: session used {tool_count} tool calls\033[0m") - return None # return None = allow stop, return string = force continuation - -register_hook("Stop", summary_hook) -``` - -In agent_loop, triggered before exit: - -```python -if response.stop_reason != "tool_use": - force = trigger_hooks("Stop", messages) # ← before exiting - if force: - # hook returned a message → inject it and continue - messages.append({"role": "user", "content": force}) - continue - return -``` - -**Only one change in the loop**: s03 directly called `check_permission(block)`, s04 replaces it with `trigger_hooks("PreToolUse", block)`: - -```python -for block in response.content: - if block.type != "tool_use": - continue - - # s03: if not check_permission(block): ... - # s04: hooks replace hardcoding - blocked = trigger_hooks("PreToolUse", block) - if blocked: - results.append({"type": "tool_result", "tool_use_id": block.id, - "content": str(blocked)}) - continue - - handler = TOOL_HANDLERS.get(block.name) - output = handler(**block.input) if handler else f"Unknown: {block.name}" - - trigger_hooks("PostToolUse", block, output) - - results.append({"type": "tool_result", "tool_use_id": block.id, - "content": output}) -``` - -Four hooks cover the critical nodes of the agent cycle: input → before execution → after execution → exit. The loop only calls trigger_hooks(); all logic lives in hook callbacks. - ---- - -## Changes from s03 - -| Component | Before (s03) | After (s04) | -|-----------|-------------|-------------| -| Extension method | check_permission() hardcoded in the loop | HOOKS registry + trigger_hooks() | -| New functions | — | register_hook, trigger_hooks | -| Hook callbacks | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook | -| Loop | Directly calls check_permission() | Calls trigger_hooks("PreToolUse", ...) | -| Exit control | None | trigger_hooks("Stop", ...) can prevent exit | -| Input interception | None | trigger_hooks("UserPromptSubmit", ...) can inject context | - ---- - -## Try It - -```sh -cd learn-claude-code -python s04_hooks/code.py -``` - -Try these prompts: - -1. `Read the file README.md` (should pass directly, observe hook logs) -2. `Create a file called test.txt` (after creation, observe if PostToolUse fires) -3. `Delete all temporary files in /tmp` (bash + rm triggers permission hook) - -What to watch for: Before each tool execution, does the `[HOOK]` log appear? When permission is denied, was it intercepted by a hook or hardcoded in the loop? - ---- - -## What's Next - -The Agent can now safely execute operations. But does it ever stop to think "what should I do first, and what next?" Given a complex task, does it jump straight in, or plan first? - -→ s05 TodoWrite: Give the Agent a planning tool. Make a list first, then execute. - -
-Dive into CC Source Code - -> The following is based on a complete analysis of CC source code `toolHooks.ts` (650 lines), `hooks.ts`, `stopHooks.ts`, and `coreTypes.ts`. - -### 1. Hook Events: Not Just 4, but 27 - -The teaching version covers only PreToolUse and PostToolUse. CC actually has 27 hook events (`coreTypes.ts:25-53`): - -| Category | Events | -|----------|--------| -| Tool-related | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` | -| Session-related | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` | -| User interaction | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` | -| Sub-agents | `SubagentStart`, `SubagentStop` | -| Compaction-related | `PreCompact`, `PostCompact` | -| Team-related | `TeammateIdle`, `TaskCreated`, `TaskCompleted` | -| Other | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` | - -The teaching version covers only 4 core events (UserPromptSubmit, PreToolUse, PostToolUse, Stop) because they cover every critical node of a complete agent cycle. The other 23 follow the same pattern. - -### 2. HookResult Common Fields - -CC's `HookResult` (`types/hooks.ts:260-275`) has 14 fields. Common ones: - -| Field | Type | Purpose | -|-------|------|---------| -| `message` | Message | Optional UI message | -| `blockingError` | HookBlockingError | Blocking error → injected into conversation for model self-correction | -| `outcome` | success/blocking/non_blocking_error/cancelled | Execution result | -| `preventContinuation` | boolean | Prevent subsequent execution | -| `stopReason` | string | Stop reason description | -| `permissionBehavior` | allow/deny/ask/passthrough | Hook returns permission decision | -| `updatedInput` | Record | Modify tool input | -| `additionalContext` | string | Additional context | -| `updatedMCPToolOutput` | unknown | MCP tool output modification | - -### 3. Key Invariant: Hook 'allow' Cannot Bypass deny/ask Rules - -This is the most important security design in CC's permission system (`toolHooks.ts:325-331`): **when a hook returns allow, it still checks settings.json deny/ask rules.** Even if the user's hook script says "allow", if the tool is disabled in settings.json, the operation is still blocked. - -The teaching version doesn't have this layer; hooks returning non-None directly interrupt. This is sufficient for teaching, but would create a security vulnerability in production. - -### 4. stopHookActive Mechanism - -CC's Stop hooks have an infinite-loop prevention mechanism (`query.ts:212,1300`): the `stopHookActive` state field. When stop hooks produce a blockingError, the loop re-enters with `stopHookActive: true`. Subsequent iterations see this flag and don't trigger stop hooks again. This prevents a never-stopping bug: model self-corrects → stop hook errors again → model self-corrects again → stop hook errors again... - -### 5. hook_stopped_continuation - -When PostToolUse hooks return `preventContinuation: true`, a `hook_stopped_continuation` attachment is produced (`toolHooks.ts:117-130`). query.ts (L1388-1393) detects it and sets `shouldPreventContinuation = true`, causing the loop to exit. This is the mechanism for "hooks gracefully shut down the Agent" — not a crash, but a completion. - -### Teaching Version Simplifications Are Intentional - -- 27 events → 4 (UserPromptSubmit/PreToolUse/PostToolUse/Stop): covers agent cycle critical nodes -- 14 fields → simple return values (None = continue, non-None = interrupt/continue): minimal cognitive load -- Hook allow vs deny/ask invariant → omitted: teaching version has no settings.json layer -- stopHookActive → omitted: teaching version Stop hook only does simple continuation, no infinite-loop prevention needed - -
- - diff --git a/s04_hooks/README.ja.md b/s04_hooks/README.ja.md index 71c659fa4..e796ee8e0 100644 --- a/s04_hooks/README.ja.md +++ b/s04_hooks/README.ja.md @@ -1,12 +1,12 @@ # s04: Hooks — ループに掛ける、ループには書き込まない -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20 > *"ループに掛ける、ループには書き込まない"* — フックがツール実行の前後に拡張ロジックを注入する。 > -> **Harness レイヤー**: フック — ループを侵襲しない拡張ポイント。 +> **Harness レイヤー**: フック、ループを侵襲しない拡張ポイント。 --- @@ -37,7 +37,7 @@ def agent_loop(messages): ## ソリューション -![Hooks Overview](images/hooks-overview.ja.svg) +![Hooks Overview](images/hooks-overview.svg) s03 のループと権限ロジックは完全に保持される。唯一の変更点は `check_permission()` をループ本体内からフックに移動したこと。ループはもうチェック関数を直接呼び出さず、代わりに `trigger_hooks("PreToolUse", block)` を呼び、登録済みのフックが何を実行するかを決める。 @@ -48,7 +48,7 @@ s03 のループと権限ロジックは完全に保持される。唯一の変 | UserPromptSubmit | ユーザー入力後、LLM に入る前 | 入力バリデーション、コンテキスト注入 | | PreToolUse | ツール実行前 | 権限チェック、ログ記録 | | PostToolUse | ツール実行後 | 副作用(自動 git add など)、出力チェック | -| Stop | ループが終了する直前 | クリーンアップ(CC は強制続行もサポート) | +| Stop | ループが終了する直前 | クリーンアップ(Claude Code は強制続行もサポート) | 拡張は `register_hook()` で追加する。ループは `trigger_hooks()` を呼ぶだけ。 @@ -79,7 +79,7 @@ def trigger_hooks(event: str, *args): 教学版では、PreToolUse の非 None 戻り値は実行阻止を意味し、Stop の非 None 戻り値は強制続行を意味する。UserPromptSubmit と PostToolUse の戻り値は未使用。 -**UserPromptSubmit**、ユーザー入力後、LLM に入る前に発火。CC では入力の横取りや変更が可能、教学版はログ出力のみ: +**UserPromptSubmit**、ユーザー入力後、LLM に入る前に発火。Claude Code では入力の横取りや変更が可能、教学版はログ出力のみ: ```python def context_inject_hook(query: str) -> str | None: @@ -180,7 +180,7 @@ for block in response.content: "content": output}) ``` -4 つのフックが agent cycle の重要ノードをカバー:入力→実行前→実行後→終了。ループは trigger_hooks() を呼ぶだけで、具体的なロジックは全てフックコールバックにある。 +以上が agent_loop 内の全フック呼び出し箇所。 --- @@ -216,18 +216,18 @@ python s04_hooks/code.py ## 次へ -Agent は安全に操作を実行できるようになった。しかし「まず何をして、次に何をすべきか」を立ち止まって考えたことはあるか? 複雑なタスクを与えたとき、すぐに取り掛かるのか、まず計画を立てるのか? +Agent は安全に操作を実行できるようになったが、実行順序の概念がまだない。複雑なタスクを与えても、ツールを受け取った順に実行するだけで、事前に計画は立てない。 → s05 TodoWrite:Agent に計画ツールを与える。まずリストを作り、それから実行。
-CC ソースコードを深掘り +Claude Code ソースコードを深掘り -> 以下は CC ソースコード `toolHooks.ts`(650 行)、`hooks.ts`、`stopHooks.ts`、`coreTypes.ts` の完全分析に基づく。 +> 以下は Claude Code ソースコード `toolHooks.ts`(650 行)、`hooks.ts`、`stopHooks.ts`、`coreTypes.ts` の完全分析に基づく。 ### 一、Hook イベント:4 つではなく 27 個 -教育版は PreToolUse と PostToolUse のみを取り上げる。CC には実際に 27 のフックイベントがある(`coreTypes.ts:25-53`): +教育版は PreToolUse と PostToolUse のみを取り上げる。Claude Code には実際に 27 のフックイベントがある(`coreTypes.ts:25-53`): | カテゴリ | イベント | |----------|---------| @@ -243,7 +243,7 @@ Agent は安全に操作を実行できるようになった。しかし「ま ### 二、HookResult よく使うフィールド抜粋 -CC の `HookResult`(`types/hooks.ts:260-275`)には 14 のフィールドがある。よく使うもの: +Claude Code の `HookResult`(`types/hooks.ts:260-275`)には 14 のフィールドがある。よく使うもの: | フィールド | 型 | 用途 | |-----------|-----|------| @@ -259,13 +259,13 @@ CC の `HookResult`(`types/hooks.ts:260-275`)には 14 のフィールドが ### 三、重要な不変条件:Hook 'allow' は deny/ask ルールをバイパスできない -これは CC 権限システムで最も重要なセキュリティ設計(`toolHooks.ts:325-331`):**フックが allow を返しても、settings.json の deny/ask ルールをチェックする。** ユーザーのフックスクリプトが「許可」と言っても、settings.json でそのツールが無効になっていれば、操作は阻止される。 +これは Claude Code 権限システムで最も重要なセキュリティ設計(`toolHooks.ts:325-331`):**フックが allow を返しても、settings.json の deny/ask ルールをチェックする。** ユーザーのフックスクリプトが「許可」と言っても、settings.json でそのツールが無効になっていれば、操作は阻止される。 教育版にはこの階層がない。フックが非 None を返せば直接中断。教育目的では十分だが、本番環境ではセキュリティホールになる。 ### 四、stopHookActive 機構 -CC の Stop フックには無限ループ防止機構がある(`query.ts:212,1300`):`stopHookActive` 状態フィールド。Stop フックが blockingError を発生させると、ループは `stopHookActive: true` で次のラウンドに再入する。後続のイテレーションではこのフラグを見て Stop フックを再トリガーしない。これで「永久に止まらない」バグを防ぐ:モデルが自己修正 → Stop フックが再度エラー → モデルが再修正 → Stop フックが再度エラー... を防止。 +Claude Code の Stop フックには無限ループ防止機構がある(`query.ts:212,1300`):`stopHookActive` 状態フィールド。Stop フックが blockingError を発生させると、ループは `stopHookActive: true` で次のラウンドに再入する。後続のイテレーションではこのフラグを見て Stop フックを再トリガーしない。これで「永久に止まらない」バグを防ぐ:モデルが自己修正 → Stop フックが再度エラー → モデルが再修正 → Stop フックが再度エラー... を防止。 ### 五、hook_stopped_continuation diff --git a/s04_hooks/README.md b/s04_hooks/README.md index fc0e2e4f3..55d9066c0 100644 --- a/s04_hooks/README.md +++ b/s04_hooks/README.md @@ -1,20 +1,20 @@ -# s04: Hooks — 挂在循环上,不写进循环里 +# s04: Hooks — Hang on the Loop, Don't Write into It -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20 -> *"挂在循环上, 不写进循环里"* — hook 在工具执行前后注入扩展逻辑。 +> *"Hang on the loop, don't write into it"* — Hooks inject extension logic before and after tool execution. > -> **Harness 层**: hook — 扩展点不侵入循环。 +> **Harness Layer**: Hooks, extension points that don't invade the loop. --- -## 问题 +## The Problem -s03 的 Agent 有权限检查了。但每次加一个新检查,比如"记录每次 bash 调用"、"操作后自动 git add",都要修改 `agent_loop` 函数。 +The s03 Agent has permission checks. But every new check, "log every bash call", "auto git add after writes", requires modifying the `agent_loop` function. -循环很快就变成了这样: +The loop quickly becomes this: ```python def agent_loop(messages): @@ -23,40 +23,40 @@ def agent_loop(messages): for block in response.content: if block.type != "tool_use": continue - log_to_file(block) # 加一行 - check_permission(block) # 加一行 - notify_slack(block) # 又加一行 + log_to_file(block) # added a line + check_permission(block) # added a line + notify_slack(block) # added another line output = execute(block) - auto_git_add(block) # 再加一行 - # ... 很快循环就认不出来了 + auto_git_add(block) # yet another line + # ... the loop is unrecognizable ``` -你想扩展的是 Agent 的行为,但你改的却是循环本身。循环应该是一个稳定的核心,扩展应该挂在外面。 +What you want to extend is the Agent's behavior, but what you're modifying is the loop itself. The loop should be a stable core; extensions should hang on the outside. --- -## 解决方案 +## The Solution ![Hooks Overview](images/hooks-overview.svg) -s03 的循环和权限逻辑完全保留。唯一的变动是把 `check_permission()` 从循环体内移到了 hook 上,循环不再直接调用任何检查函数,改为 `trigger_hooks("PreToolUse", block)`,由注册表决定跑什么。 +The s03 loop and permission logic are fully preserved. The only change is moving `check_permission()` from inside the loop body onto a hook. The loop no longer directly calls any check function. Instead it calls `trigger_hooks("PreToolUse", block)`, and the registry decides what to run. -四个事件,覆盖一个完整的 agent cycle: +Four events, covering a complete agent cycle: -| 事件 | 触发时机 | 典型用途 | -|------|---------|---------| -| UserPromptSubmit | 用户输入提交后、进入 LLM 前 | 输入验证、注入上下文 | -| PreToolUse | 工具执行前 | 权限检查、日志记录 | -| PostToolUse | 工具执行后 | 副作用(自动 git add 等)、输出检查 | -| Stop | 循环即将退出时 | 收尾清理(CC 还支持强制续跑) | +| Event | Trigger Timing | Typical Use | +|-------|---------------|-------------| +| UserPromptSubmit | After user input, before entering LLM | Input validation, context injection | +| PreToolUse | Before tool execution | Permission checks, logging | +| PostToolUse | After tool execution | Side effects (auto git add etc.), output checking | +| Stop | When the loop is about to exit | Cleanup (Claude Code also supports force continuation) | -扩展通过 `register_hook()` 添加,循环只调用 `trigger_hooks()`。 +Extensions are added via `register_hook()`. The loop only calls `trigger_hooks()`. --- -## 工作原理 +## How It Works -**hook 注册表**:一个字典,事件名映射到回调列表。 +**Hook registry**: a dict mapping event names to callback lists. ```python HOOKS = { @@ -72,14 +72,14 @@ def register_hook(event: str, callback): def trigger_hooks(event: str, *args): for callback in HOOKS[event]: result = callback(*args) - if result is not None: # 返回值 ≠ None → hook 说"停" + if result is not None: # return value ≠ None → hook says "stop" return result return None ``` -教学版中,PreToolUse 的非 None 返回值会阻止本次工具执行,Stop 的非 None 返回值会强制续跑。UserPromptSubmit 和 PostToolUse 的返回值未被使用。 +In the teaching version, PreToolUse returning non-None means block execution; Stop returning non-None means force continuation. UserPromptSubmit and PostToolUse return values are unused. -**UserPromptSubmit**,用户输入提交后、进入 LLM 前触发。CC 中可以拦截或修改输入,教学版只做日志演示: +**UserPromptSubmit**, triggers after user input, before entering the LLM. Claude Code can intercept or modify input; the teaching version only logs: ```python def context_inject_hook(query: str) -> str | None: @@ -90,19 +90,19 @@ def context_inject_hook(query: str) -> str | None: register_hook("UserPromptSubmit", context_inject_hook) ``` -在主循环中,用户输入后立即触发: +In the main loop, triggered right after user input: ```python query = input("s04 >> ") -trigger_hooks("UserPromptSubmit", query) # ← 进入 LLM 之前 +trigger_hooks("UserPromptSubmit", query) # ← before entering LLM history.append({"role": "user", "content": query}) agent_loop(history) ``` -**PreToolUse / PostToolUse**,工具执行前后的 hook。s03 的权限检查逻辑现在包装成 PreToolUse hook,再加一个日志 hook 和一个大输出提醒: +**PreToolUse / PostToolUse**, hooks before and after tool execution. s03's permission check logic is now wrapped as a PreToolUse hook, plus a logging hook and a large-output reminder: ```python -# PreToolUse: 权限检查(s03 的逻辑,从循环移到 hook) +# PreToolUse: permission check (s03 logic, moved from loop to hook) def permission_hook(block): if block.name == "bash": for pattern in DENY_LIST: @@ -116,11 +116,11 @@ def permission_hook(block): return "Permission denied by user" return None -# PreToolUse: 日志 +# PreToolUse: logging def log_hook(block): print(f"[HOOK] {block.name}(...)") -# PostToolUse: 大文件提醒 +# PostToolUse: large output reminder def large_output_hook(block, output): if len(str(output)) > 100000: print(f"[HOOK] ⚠ Large output from {block.name}") @@ -130,7 +130,7 @@ register_hook("PreToolUse", log_hook) register_hook("PostToolUse", large_output_hook) ``` -**Stop**,循环即将退出时触发(`stop_reason != "tool_use"`)。教学版用于打印收尾统计: +**Stop**, triggers when the loop is about to exit (`stop_reason != "tool_use"`). The teaching version prints a cleanup summary: ```python def summary_hook(messages: list) -> str | None: @@ -144,11 +144,11 @@ def summary_hook(messages: list) -> str | None: register_hook("Stop", summary_hook) ``` -在 agent_loop 中,退出前触发: +In agent_loop, triggered before exit: ```python if response.stop_reason != "tool_use": - force = trigger_hooks("Stop", messages) # ← 退出之前 + force = trigger_hooks("Stop", messages) # ← before exiting if force: # hook returned a message → inject it and continue messages.append({"role": "user", "content": force}) @@ -156,7 +156,7 @@ if response.stop_reason != "tool_use": return ``` -**循环里只改了一处**:s03 直接调用 `check_permission(block)`,s04 改为 `trigger_hooks("PreToolUse", block)`: +**Only one change in the loop**: s03 directly called `check_permission(block)`, s04 replaces it with `trigger_hooks("PreToolUse", block)`: ```python for block in response.content: @@ -164,7 +164,7 @@ for block in response.content: continue # s03: if not check_permission(block): ... - # s04: hook 替代硬编码 + # s04: hooks replace hardcoding blocked = trigger_hooks("PreToolUse", block) if blocked: results.append({"type": "tool_result", "tool_use_id": block.id, @@ -180,104 +180,104 @@ for block in response.content: "content": output}) ``` -四个 hook 覆盖了 agent cycle 的关键节点:输入→执行前→执行后→退出。循环只负责调用 trigger_hooks(),具体逻辑全在 hook 回调里。 +That covers all hook call sites in agent_loop. --- -## 相对 s03 的变更 +## Changes from s03 -| 组件 | 之前 (s03) | 之后 (s04) | -|------|-----------|-----------| -| 扩展方式 | check_permission() 硬编码在循环里 | HOOKS 注册表 + trigger_hooks() | -| 新函数 | — | register_hook, trigger_hooks | -| hook 回调 | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook | -| 循环 | 直接调用 check_permission() | 调用 trigger_hooks("PreToolUse", ...) | -| 退出控制 | 无 | trigger_hooks("Stop", ...) 可阻止退出 | -| 输入拦截 | 无 | trigger_hooks("UserPromptSubmit", ...) 可注入上下文 | +| Component | Before (s03) | After (s04) | +|-----------|-------------|-------------| +| Extension method | check_permission() hardcoded in the loop | HOOKS registry + trigger_hooks() | +| New functions | — | register_hook, trigger_hooks | +| Hook callbacks | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook | +| Loop | Directly calls check_permission() | Calls trigger_hooks("PreToolUse", ...) | +| Exit control | None | trigger_hooks("Stop", ...) can prevent exit | +| Input interception | None | trigger_hooks("UserPromptSubmit", ...) can inject context | --- -## 试一下 +## Try It ```sh cd learn-claude-code python s04_hooks/code.py ``` -试试这些 prompt: +Try these prompts: -1. `Read the file README.md`(应该直接通过,观察 hook 日志) -2. `Create a file called test.txt`(通过后观察 PostToolUse 是否触发) -3. `Delete all temporary files in /tmp`(bash + rm 触发权限 hook) +1. `Read the file README.md` (should pass directly, observe hook logs) +2. `Create a file called test.txt` (after creation, observe if PostToolUse fires) +3. `Delete all temporary files in /tmp` (bash + rm triggers permission hook) -观察重点:每次工具执行前,是否出现了 `[HOOK]` 日志?权限被拒时,是 hook 拦截的还是循环里硬编码的? +What to watch for: Before each tool execution, does the `[HOOK]` log appear? When permission is denied, was it intercepted by a hook or hardcoded in the loop? --- -## 接下来 +## What's Next -Agent 现在能安全执行操作了。但它有没有停下来想过"我应该先做什么,再做什么"?给它一个复杂任务,它是一上来就动手,还是先列个计划? +The Agent can now safely execute operations, but it has no concept of execution order. Given a complex task, it executes tools as they come, without planning ahead. -s05 TodoWrite → 给 Agent 一个计划工具。先列清单,再做。 +→ s05 TodoWrite: Give the Agent a planning tool. Make a list first, then execute.
-深入 CC 源码 +Dive into Claude Code Source Code -> 以下基于 CC 源码 `toolHooks.ts`(650 行)、`hooks.ts`、`stopHooks.ts`、`coreTypes.ts` 的完整分析。 +> The following is based on a complete analysis of Claude Code source code `toolHooks.ts` (650 lines), `hooks.ts`, `stopHooks.ts`, and `coreTypes.ts`. -### 一、Hook 事件:不止这 4 个,而是 27 个 +### 1. Hook Events: Not Just 4, but 27 -教学版只讲了 PreToolUse 和 PostToolUse。CC 实际有 27 个 hook 事件(`coreTypes.ts:25-53`): +The teaching version covers only PreToolUse and PostToolUse. Claude Code actually has 27 hook events (`coreTypes.ts:25-53`): -| 类别 | 事件 | -|------|------| -| 工具相关 | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` | -| 会话相关 | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` | -| 用户交互 | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` | -| 子 Agent | `SubagentStart`, `SubagentStop` | -| 压缩相关 | `PreCompact`, `PostCompact` | -| 团队相关 | `TeammateIdle`, `TaskCreated`, `TaskCompleted` | -| 其他 | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` | +| Category | Events | +|----------|--------| +| Tool-related | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` | +| Session-related | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` | +| User interaction | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` | +| Sub-agents | `SubagentStart`, `SubagentStop` | +| Compaction-related | `PreCompact`, `PostCompact` | +| Team-related | `TeammateIdle`, `TaskCreated`, `TaskCompleted` | +| Other | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` | -教学版只讲 4 个核心事件(UserPromptSubmit、PreToolUse、PostToolUse、Stop),因为它们覆盖了一个完整 agent cycle 的关键节点。其他 23 个都是同样的模式。 +The teaching version covers only 4 core events (UserPromptSubmit, PreToolUse, PostToolUse, Stop) because they cover every critical node of a complete agent cycle. The other 23 follow the same pattern. -### 二、HookResult 常用字段摘录 +### 2. HookResult Common Fields -CC 的 `HookResult`(`types/hooks.ts:260-275`)有 14 个字段,以下是常用字段: +Claude Code's `HookResult` (`types/hooks.ts:260-275`) has 14 fields. Common ones: -| 字段 | 类型 | 用途 | -|------|------|------| -| `message` | Message | 可选 UI 消息 | -| `blockingError` | HookBlockingError | 阻塞错误 → 注入对话让模型自纠 | -| `outcome` | success/blocking/non_blocking_error/cancelled | 执行结果 | -| `preventContinuation` | boolean | 阻止后续执行 | -| `stopReason` | string | 停止原因描述 | -| `permissionBehavior` | allow/deny/ask/passthrough | hook 返回权限决策 | -| `updatedInput` | Record | 修改工具输入 | -| `additionalContext` | string | 附加上下文 | -| `updatedMCPToolOutput` | unknown | MCP 工具输出修改 | +| Field | Type | Purpose | +|-------|------|---------| +| `message` | Message | Optional UI message | +| `blockingError` | HookBlockingError | Blocking error → injected into conversation for model self-correction | +| `outcome` | success/blocking/non_blocking_error/cancelled | Execution result | +| `preventContinuation` | boolean | Prevent subsequent execution | +| `stopReason` | string | Stop reason description | +| `permissionBehavior` | allow/deny/ask/passthrough | Hook returns permission decision | +| `updatedInput` | Record | Modify tool input | +| `additionalContext` | string | Additional context | +| `updatedMCPToolOutput` | unknown | MCP tool output modification | -### 三、关键不变式:Hook 'allow' 不能绕过 deny/ask 规则 +### 3. Key Invariant: Hook 'allow' Cannot Bypass deny/ask Rules -这是 CC 权限系统最重要的安全设计(`toolHooks.ts:325-331`):**hook 返回 allow 时,仍然要检查 settings.json 的 deny/ask 规则**。即使用户的 hook 脚本说"允许",如果在 settings.json 中禁用了这个工具,操作仍然会被阻止。 +This is the most important security design in Claude Code's permission system (`toolHooks.ts:325-331`): **when a hook returns allow, it still checks settings.json deny/ask rules.** Even if the user's hook script says "allow", if the tool is disabled in settings.json, the operation is still blocked. -教学版没有这个层次,只把 PreToolUse 的非 None 返回值解释为阻止本次工具执行。这在教学场景中够了,但在生产环境中会形成安全漏洞。 +The teaching version doesn't have this layer; hooks returning non-None directly interrupt. This is sufficient for teaching, but would create a security vulnerability in production. -### 四、stopHookActive 机制 +### 4. stopHookActive Mechanism -CC 的 Stop hooks 有一个防无限循环机制(`query.ts:212,1300`):`stopHookActive` 状态字段。当 stop hooks 产生 blockingError 时,循环带 `stopHookActive: true` 重入下一轮。后续迭代中 stop hooks 看到这个标志就不会再次触发。这防止了一个永不停机的 bug:模型自纠后 stop hook 再次报错 → 模型再自纠 → stop hook 再报错... +Claude Code's Stop hooks have an infinite-loop prevention mechanism (`query.ts:212,1300`): the `stopHookActive` state field. When stop hooks produce a blockingError, the loop re-enters with `stopHookActive: true`. Subsequent iterations see this flag and don't trigger stop hooks again. This prevents a never-stopping bug: model self-corrects → stop hook errors again → model self-corrects again → stop hook errors again... -### 五、hook_stopped_continuation +### 5. hook_stopped_continuation -PostToolUse hooks 返回 `preventContinuation: true` 时,会产生一个 `hook_stopped_continuation` 附件(`toolHooks.ts:117-130`)。query.ts(L1388-1393)检测到后设置 `shouldPreventContinuation = true`,循环退出。这是 "hook 优雅地让 Agent 停机" 的机制,不是崩溃,是完成。 +When PostToolUse hooks return `preventContinuation: true`, a `hook_stopped_continuation` attachment is produced (`toolHooks.ts:117-130`). query.ts (L1388-1393) detects it and sets `shouldPreventContinuation = true`, causing the loop to exit. This is the mechanism for "hooks gracefully shut down the Agent" — not a crash, but a completion. -### 教学版的简化是刻意的 +### Teaching Version Simplifications Are Intentional -- 27 个事件 → 4 个(UserPromptSubmit/PreToolUse/PostToolUse/Stop):覆盖 agent cycle 关键节点 -- 14 个字段 → 简单的返回值(None = 继续,非 None = 阻止/续跑):心智负担降到最低 -- Hook allow vs deny/ask 不变式 → 省略:教学版没有 settings.json 层 -- stopHookActive → 省略:教学版 Stop hook 只做简单续跑,不涉及防无限循环机制 +- 27 events → 4 (UserPromptSubmit/PreToolUse/PostToolUse/Stop): covers agent cycle critical nodes +- 14 fields → simple return values (None = continue, non-None = interrupt/continue): minimal cognitive load +- Hook allow vs deny/ask invariant → omitted: teaching version has no settings.json layer +- stopHookActive → omitted: teaching version Stop hook only does simple continuation, no infinite-loop prevention needed
- + diff --git a/s04_hooks/README.zh.md b/s04_hooks/README.zh.md new file mode 100644 index 000000000..1cc85156d --- /dev/null +++ b/s04_hooks/README.zh.md @@ -0,0 +1,283 @@ +# s04: Hooks — 挂在循环上,不写进循环里 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → s02 → s03 → `s04` → [s05](../s05_todo_write/) → s06 → ... → s20 + +> *"挂在循环上, 不写进循环里"* — hook 在工具执行前后注入扩展逻辑。 +> +> **Harness 层**: hook,扩展点不侵入循环。 + +--- + +## 问题 + +s03 的 Agent 有权限检查了。但每次加一个新检查,比如"记录每次 bash 调用"、"操作后自动 git add",都要修改 `agent_loop` 函数。 + +循环很快就变成了这样: + +```python +def agent_loop(messages): + while True: + # ... LLM call ... + for block in response.content: + if block.type != "tool_use": + continue + log_to_file(block) # 加一行 + check_permission(block) # 加一行 + notify_slack(block) # 又加一行 + output = execute(block) + auto_git_add(block) # 再加一行 + # ... 很快循环就认不出来了 +``` + +你想扩展的是 Agent 的行为,但你改的却是循环本身。循环应该是一个稳定的核心,扩展应该挂在外面。 + +--- + +## 解决方案 + +![Hooks Overview](images/hooks-overview.svg) + +s03 的循环和权限逻辑完全保留。唯一的变动是把 `check_permission()` 从循环体内移到了 hook 上,循环不再直接调用任何检查函数,改为 `trigger_hooks("PreToolUse", block)`,由注册表决定跑什么。 + +四个事件,覆盖一个完整的 agent cycle: + +| 事件 | 触发时机 | 典型用途 | +|------|---------|---------| +| UserPromptSubmit | 用户输入提交后、进入 LLM 前 | 输入验证、注入上下文 | +| PreToolUse | 工具执行前 | 权限检查、日志记录 | +| PostToolUse | 工具执行后 | 副作用(自动 git add 等)、输出检查 | +| Stop | 循环即将退出时 | 收尾清理(Claude Code 还支持强制续跑) | + +扩展通过 `register_hook()` 添加,循环只调用 `trigger_hooks()`。 + +--- + +## 工作原理 + +**hook 注册表**:一个字典,事件名映射到回调列表。 + +```python +HOOKS = { + "UserPromptSubmit": [], + "PreToolUse": [], + "PostToolUse": [], + "Stop": [], +} + +def register_hook(event: str, callback): + HOOKS[event].append(callback) + +def trigger_hooks(event: str, *args): + for callback in HOOKS[event]: + result = callback(*args) + if result is not None: # 返回值 ≠ None → hook 说"停" + return result + return None +``` + +教学版中,PreToolUse 的非 None 返回值会阻止本次工具执行,Stop 的非 None 返回值会强制续跑。UserPromptSubmit 和 PostToolUse 的返回值未被使用。 + +**UserPromptSubmit**,用户输入提交后、进入 LLM 前触发。Claude Code 中可以拦截或修改输入,教学版只做日志演示: + +```python +def context_inject_hook(query: str) -> str | None: + """Inject current working directory info into every prompt.""" + print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m") + return None # return None = no modification, let prompt through + +register_hook("UserPromptSubmit", context_inject_hook) +``` + +在主循环中,用户输入后立即触发: + +```python +query = input("s04 >> ") +trigger_hooks("UserPromptSubmit", query) # ← 进入 LLM 之前 +history.append({"role": "user", "content": query}) +agent_loop(history) +``` + +**PreToolUse / PostToolUse**,工具执行前后的 hook。s03 的权限检查逻辑现在包装成 PreToolUse hook,再加一个日志 hook 和一个大输出提醒: + +```python +# PreToolUse: 权限检查(s03 的逻辑,从循环移到 hook) +def permission_hook(block): + if block.name == "bash": + for pattern in DENY_LIST: + if pattern in block.input.get("command", ""): + return "Permission denied by deny list" + if block.name in ("write_file", "edit_file"): + path = block.input.get("path", "") + if not (WORKDIR / path).resolve().is_relative_to(WORKDIR): + choice = input(" Allow? [y/N] ").strip().lower() + if choice not in ("y", "yes"): + return "Permission denied by user" + return None + +# PreToolUse: 日志 +def log_hook(block): + print(f"[HOOK] {block.name}(...)") + +# PostToolUse: 大文件提醒 +def large_output_hook(block, output): + if len(str(output)) > 100000: + print(f"[HOOK] ⚠ Large output from {block.name}") + +register_hook("PreToolUse", permission_hook) +register_hook("PreToolUse", log_hook) +register_hook("PostToolUse", large_output_hook) +``` + +**Stop**,循环即将退出时触发(`stop_reason != "tool_use"`)。教学版用于打印收尾统计: + +```python +def summary_hook(messages: list) -> str | None: + """Print a summary when the loop is about to stop.""" + tool_count = sum(1 for m in messages + for b in (m.get("content") if isinstance(m.get("content"), list) else []) + if isinstance(b, dict) and b.get("type") == "tool_result") + print(f"\033[90m[HOOK] Stop: session used {tool_count} tool calls\033[0m") + return None # return None = allow stop, return string = force continuation + +register_hook("Stop", summary_hook) +``` + +在 agent_loop 中,退出前触发: + +```python +if response.stop_reason != "tool_use": + force = trigger_hooks("Stop", messages) # ← 退出之前 + if force: + # hook returned a message → inject it and continue + messages.append({"role": "user", "content": force}) + continue + return +``` + +**循环里只改了一处**:s03 直接调用 `check_permission(block)`,s04 改为 `trigger_hooks("PreToolUse", block)`: + +```python +for block in response.content: + if block.type != "tool_use": + continue + + # s03: if not check_permission(block): ... + # s04: hook 替代硬编码 + blocked = trigger_hooks("PreToolUse", block) + if blocked: + results.append({"type": "tool_result", "tool_use_id": block.id, + "content": str(blocked)}) + continue + + handler = TOOL_HANDLERS.get(block.name) + output = handler(**block.input) if handler else f"Unknown: {block.name}" + + trigger_hooks("PostToolUse", block, output) + + results.append({"type": "tool_result", "tool_use_id": block.id, + "content": output}) +``` + +以上就是 agent_loop 中全部 hook 调用点。 + +--- + +## 相对 s03 的变更 + +| 组件 | 之前 (s03) | 之后 (s04) | +|------|-----------|-----------| +| 扩展方式 | check_permission() 硬编码在循环里 | HOOKS 注册表 + trigger_hooks() | +| 新函数 | — | register_hook, trigger_hooks | +| hook 回调 | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook | +| 循环 | 直接调用 check_permission() | 调用 trigger_hooks("PreToolUse", ...) | +| 退出控制 | 无 | trigger_hooks("Stop", ...) 可阻止退出 | +| 输入拦截 | 无 | trigger_hooks("UserPromptSubmit", ...) 可注入上下文 | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s04_hooks/code.py +``` + +试试这些 prompt: + +1. `Read the file README.md`(应该直接通过,观察 hook 日志) +2. `Create a file called test.txt`(通过后观察 PostToolUse 是否触发) +3. `Delete all temporary files in /tmp`(bash + rm 触发权限 hook) + +观察重点:每次工具执行前,是否出现了 `[HOOK]` 日志?权限被拒时,是 hook 拦截的还是循环里硬编码的? + +--- + +## 接下来 + +Agent 能安全执行操作了,但还缺少执行顺序的概念。给它一个复杂任务,它会按收到的顺序逐个执行,不会先列计划。 + +s05 TodoWrite → 给 Agent 一个计划工具。先列清单,再做。 + +
+深入 Claude Code 源码 + +> 以下基于 Claude Code 源码 `toolHooks.ts`(650 行)、`hooks.ts`、`stopHooks.ts`、`coreTypes.ts` 的完整分析。 + +### 一、Hook 事件:不止这 4 个,而是 27 个 + +教学版只讲了 PreToolUse 和 PostToolUse。Claude Code 实际有 27 个 hook 事件(`coreTypes.ts:25-53`): + +| 类别 | 事件 | +|------|------| +| 工具相关 | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` | +| 会话相关 | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` | +| 用户交互 | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` | +| 子 Agent | `SubagentStart`, `SubagentStop` | +| 压缩相关 | `PreCompact`, `PostCompact` | +| 团队相关 | `TeammateIdle`, `TaskCreated`, `TaskCompleted` | +| 其他 | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` | + +教学版只讲 4 个核心事件(UserPromptSubmit、PreToolUse、PostToolUse、Stop),因为它们覆盖了一个完整 agent cycle 的关键节点。其他 23 个都是同样的模式。 + +### 二、HookResult 常用字段摘录 + +Claude Code 的 `HookResult`(`types/hooks.ts:260-275`)有 14 个字段,以下是常用字段: + +| 字段 | 类型 | 用途 | +|------|------|------| +| `message` | Message | 可选 UI 消息 | +| `blockingError` | HookBlockingError | 阻塞错误 → 注入对话让模型自纠 | +| `outcome` | success/blocking/non_blocking_error/cancelled | 执行结果 | +| `preventContinuation` | boolean | 阻止后续执行 | +| `stopReason` | string | 停止原因描述 | +| `permissionBehavior` | allow/deny/ask/passthrough | hook 返回权限决策 | +| `updatedInput` | Record | 修改工具输入 | +| `additionalContext` | string | 附加上下文 | +| `updatedMCPToolOutput` | unknown | MCP 工具输出修改 | + +### 三、关键不变式:Hook 'allow' 不能绕过 deny/ask 规则 + +这是 Claude Code 权限系统最重要的安全设计(`toolHooks.ts:325-331`):**hook 返回 allow 时,仍然要检查 settings.json 的 deny/ask 规则**。即使用户的 hook 脚本说"允许",如果在 settings.json 中禁用了这个工具,操作仍然会被阻止。 + +教学版没有这个层次,只把 PreToolUse 的非 None 返回值解释为阻止本次工具执行。这在教学场景中够了,但在生产环境中会形成安全漏洞。 + +### 四、stopHookActive 机制 + +Claude Code 的 Stop hooks 有一个防无限循环机制(`query.ts:212,1300`):`stopHookActive` 状态字段。当 stop hooks 产生 blockingError 时,循环带 `stopHookActive: true` 重入下一轮。后续迭代中 stop hooks 看到这个标志就不会再次触发。这防止了一个永不停机的 bug:模型自纠后 stop hook 再次报错 → 模型再自纠 → stop hook 再报错... + +### 五、hook_stopped_continuation + +PostToolUse hooks 返回 `preventContinuation: true` 时,会产生一个 `hook_stopped_continuation` 附件(`toolHooks.ts:117-130`)。query.ts(L1388-1393)检测到后设置 `shouldPreventContinuation = true`,循环退出。这是 "hook 优雅地让 Agent 停机" 的机制,不是崩溃,是完成。 + +### 教学版的简化是刻意的 + +- 27 个事件 → 4 个(UserPromptSubmit/PreToolUse/PostToolUse/Stop):覆盖 agent cycle 关键节点 +- 14 个字段 → 简单的返回值(None = 继续,非 None = 阻止/续跑):心智负担降到最低 +- Hook allow vs deny/ask 不变式 → 省略:教学版没有 settings.json 层 +- stopHookActive → 省略:教学版 Stop hook 只做简单续跑,不涉及防无限循环机制 + +
+ + diff --git a/s04_hooks/images/hooks-overview.en.svg b/s04_hooks/images/hooks-overview.en.svg deleted file mode 100644 index 87afdc0c5..000000000 --- a/s04_hooks/images/hooks-overview.en.svg +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - Hooks — Extension Logic Hangs Outside, Loop Unchanged - - - - - - messages[] - (s01 preserved) - - - - - - - LLM - stop_reason=tool_use? - - - - No - - Return Result - - - - Yes - - - - trigger_hooks() - PreToolUse - - permission_hook · log_hook - Teaching: non-None → block - - - - - Write tool_result - - - - Pass - - - - TOOL_ - HANDLERS - bash/read/... - - - - After exec - - - - trigger_hooks() - PostToolUse - - large_output_hook - - - - Results appended to messages[], loop continues - - - - s03: - if not check_permission(block): ... - ← every new check requires modifying the loop - s04: - blocked = trigger_hooks("PreToolUse", block) - ← add check = register_hook(), loop unchanged - diff --git a/s04_hooks/images/hooks-overview.ja.svg b/s04_hooks/images/hooks-overview.ja.svg deleted file mode 100644 index d1addf60b..000000000 --- a/s04_hooks/images/hooks-overview.ja.svg +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - Hooks — 拡張ロジックは外側に、ループは一文字も変更しない - - - - - - messages[] - (s01 保持) - - - - - - - LLM - stop_reason=tool_use? - - - - No - - 結果を返す - - - - Yes - - - - trigger_hooks() - PreToolUse - - permission_hook · log_hook - 教育版: 非 None → ブロック - - - - - tool_result に返す - - - - 通過 - - - - TOOL_ - HANDLERS - bash/read/... - - - - 実行後 - - - - trigger_hooks() - PostToolUse - - large_output_hook - - - - 結果を messages[] に追加、ループ継続 - - - - s03: - if not check_permission(block): ... - ← チェックを追加するたびにループを修正 - s04: - blocked = trigger_hooks("PreToolUse", block) - ← チェック追加 = register_hook()、ループ不変 - diff --git a/s04_hooks/images/hooks-overview.svg b/s04_hooks/images/hooks-overview.svg index 410593afd..47b2e5eca 100644 --- a/s04_hooks/images/hooks-overview.svg +++ b/s04_hooks/images/hooks-overview.svg @@ -1,100 +1,106 @@ - + - - - - - - - + - - + + - - - - - - - - - - - Hooks — 扩展逻辑挂在外面,循环本身一字不改 - - - - - - messages[] - (s01 保留) - - - - - - - LLM - stop_reason=tool_use? - - - - - - 返回结果 - - - - - - - - trigger_hooks() - PreToolUse - - permission_hook · log_hook - 教学版:非 None → 阻止 - - - - - 写入 tool_result - - - - 通过 - - - - TOOL_ - HANDLERS - bash/read/... - - - - 执行后 - - - - trigger_hooks() - PostToolUse - - large_output_hook - - - - 结果追加到 messages[],循环继续 - - - - s03: - if not check_permission(block): ... - ← 每加一个检查就要改循环 - s04: - blocked = trigger_hooks("PreToolUse", block) - ← 加检查 = register_hook(),循环不改 - + + + + + Hooks — Extension Logic Outside the Loop + PreToolUse / PostToolUse inject checks without modifying agent_loop + + + + + + + messages[] + conversation history + + + + + + + LLM + stop_reason? + + + + end_turn + + Return Result + + + + tool_use + + + + PreToolUse + trigger_hooks() + + + permission · log + non-None = block exec + + + + + blocked → result + + + + pass + + + + TOOL_HANDLERS + bash / read / write + + + + + after exec + + + + PostToolUse + trigger_hooks() + + large_output_hook + + + + + + + result appended to messages[], loop continues + + + + + + + + + Before vs After + + + Before: + if not check_permission(block): ... + # each check = modify loop + + + After: + blocked = trigger_hooks("PreToolUse", block) + # add check = register_hook() + + \ No newline at end of file diff --git a/s05_todo_write/README.en.md b/s05_todo_write/README.en.md deleted file mode 100644 index 88c526cc7..000000000 --- a/s05_todo_write/README.en.md +++ /dev/null @@ -1,158 +0,0 @@ -# s05: TodoWrite — An Agent Without a Plan Drifts Off Course - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20 - -> *"An agent without a plan goes wherever the wind blows"* — List the steps first, then execute. Complex tasks are less likely to miss steps. -> -> **Harness Layer**: Planning — Let the Agent think before it acts. - ---- - -## The Problem - -Give the Agent a complex task: "Rename all Python files to snake_case, run tests, and fix failures." - -The Agent starts working, renames 3 files, runs a test, finds 2 failures, starts fixing. While fixing, it forgets the original goal was "rename to snake_case", the test failures have consumed all its attention. - -The longer the conversation, the worse it gets: tool results keep filling the context, diluting the system prompt's influence. A 10-step refactoring: after steps 1-3, the Agent starts improvising because steps 4-10 have been pushed out of its attention. - ---- - -## The Solution - -![Todo Overview](images/todo-overview.en.svg) - -The minimal hook structure from the previous chapter is preserved, focusing on the new `todo_write` tool and reminder mechanism. `todo_write` does no actual work, can't read files or run commands, it simply lets the Agent organize its thoughts before diving in. - -The dispatch mechanism is unchanged; the new tool is still routed through `TOOL_HANDLERS[block.name]`. However, to demonstrate the todo reminder, a counter was added to the loop: after 3 consecutive rounds without calling `todo_write`, a reminder is injected. - ---- - -## How It Works - -**The todo_write tool** accepts a list with statuses, keeps it in the current process memory, and displays progress in the terminal: - -```python -CURRENT_TODOS: list[dict] = [] - -def run_todo_write(todos: list) -> str: - global CURRENT_TODOS - CURRENT_TODOS = todos - - lines = ["\n## Current Tasks"] - for t in CURRENT_TODOS: - icon = {"pending": " ", "in_progress": "▸", "completed": "✓"}[t["status"]] - lines.append(f" [{icon}] {t['content']}") - print("\n".join(lines)) - return f"Updated {len(CURRENT_TODOS)} tasks" -``` - -The tool definition joins the other 5 in the dispatch map: - -```python -TOOLS = [ - {"name": "bash", ...}, - {"name": "read_file", ...}, - {"name": "write_file", ...}, - {"name": "edit_file", ...}, - {"name": "glob", ...}, - # s05: new entry - {"name": "todo_write", "description": "Create and manage a task list ...", - "input_schema": { - "type": "object", - "properties": { - "todos": { - "type": "array", - "items": { - "type": "object", - "properties": { - "content": {"type": "string"}, - "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}, - }, - }, - }, - }, - }, - }, -] - -TOOL_HANDLERS["todo_write"] = run_todo_write -``` - -**Nag reminder**, when the model hasn't called `todo_write` for 3 consecutive rounds, a reminder is automatically injected (teaching mechanism; CC source has no fixed round-count logic): - -```python -if rounds_since_todo >= 3 and messages: - messages.append({ - "role": "user", - "content": "Update your todos.", - }) - rounds_since_todo = 0 -``` - -Typical flow when the Agent receives a task: first call `todo_write` to list all steps (all `pending`) → pick one step, set it to `in_progress` → complete it, set to `completed` → look at the next `pending` → continue. After 3 rounds without `todo_write`, the loop appends a reminder before the next LLM call. - -**Key insight**: todo_write doesn't give the Agent any additional **execution capability**. What it adds is **planning capability**. - ---- - -## Changes from s04 - -| Component | Before (s04) | After (s05) | -|-----------|-------------|-------------| -| Tool count | 5 (bash, read, write, edit, glob) | 6 (+todo_write) | -| Planning | None | Stateful TODO list + nag reminder | -| SYSTEM prompt | Generic prompt | Added "plan before executing" guidance | -| Loop | Unchanged | Dispatch unchanged, added rounds_since_todo counter and reminder injection | - ---- - -## Try It - -```sh -cd learn-claude-code -python s05_todo_write/code.py -``` - -Try these prompts: - -1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard` (should list 3 steps first, then execute) -2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py` -3. `Review Python files under s05_todo_write/example and fix any style issues` - -What to watch for: Was the first tool call `todo_write`? How many TODO steps were listed? Did statuses move from `pending` to `in_progress` / `completed` during execution? - ---- - -## What's Next - -The Agent can plan now. But if a task is too large, say "refactor the entire auth module", a TODO list alone isn't enough. That task is itself a collection of dozens of subtasks that would drown in a single conversation's context. - -→ s06 Subagent: Break large tasks into subtasks, each handled by an independent Agent with its own clean context, no cross-contamination. - -
-Dive into CC Source Code - -CC has two task systems coexisting (`tasks.ts:133-139`): - -- **TodoWrite (V1)**: A simple list tool, data maintained in memory AppState (`TodoWriteTool.ts:65-103`). The teaching version also keeps it in process memory and clears it on exit. -- **Task System (V2 = s12)**: File-persisted, dependency graph, concurrency locks, ownership. - -The switch is controlled by `isTodoV2Enabled()`. In the current source: V2 is enabled by default in interactive sessions, V1 in non-interactive (SDK) sessions; setting `CLAUDE_CODE_ENABLE_TASKS` forces V2 regardless. Note the source comment "Force-enable tasks in non-interactive mode" describes the env var path's purpose, not the default branch's return semantics. - -The teaching version omits the `activeForm` field from the real source (`utils/todo/types.ts:8-15`). CC uses it for the UI spinner to show "what's being done"; the teaching version only has terminal output and doesn't need this field. - -The teaching version's nag reminder (3 rounds without update triggers injection) is an educational mechanism. The CC source has no fixed "3 rounds" logic; the closest is `TodoWriteTool.ts:72-107` which appends a verification nudge when 3+ todos are all completed without a verification item. - -Core increments of the Task System over TodoWrite: -- File persistence (Claude config directory `tasks/{taskListId}/{taskId}.json`) instead of in-memory list -- `blockedBy` dependency graph instead of flat list -- `proper-lockfile` concurrency safety instead of no locking -- Four separate tools (Create/Get/Update/List) instead of one -- TaskCreated / TaskCompleted hooks (`TaskCreateTool.ts:80-129`, `TaskUpdateTool.ts:231-260`) for external system integration - -
- - diff --git a/s05_todo_write/README.ja.md b/s05_todo_write/README.ja.md index 3830c02d2..f8d2e6094 100644 --- a/s05_todo_write/README.ja.md +++ b/s05_todo_write/README.ja.md @@ -1,12 +1,12 @@ # s05: TodoWrite — 計画なき Agent は途中で道を外れる -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20 > *"計画なき agent は風の向くままに"* — まず手順を列挙してから実行。長いタスクで見落としが減る。 > -> **Harness レイヤー**: 計画 — Agent が行動する前に考えさせる。 +> **Harness レイヤー**: 計画 — todo_write でステータス付きタスクリストを管理する。 --- @@ -22,9 +22,9 @@ Agent は作業を開始する。3 つのファイルをリネーム、テスト ## ソリューション -![Todo Overview](images/todo-overview.ja.svg) +![Todo Overview](images/todo-overview.svg) -前章の最小フック構造を保持し、本章では新規の `todo_write` ツールとリマインダー機構に注目する。`todo_write` は実際の作業を何もしない。ファイルを読めない、コマンドを実行できない。Agent が手を動かす前に思考を整理できるようにするだけ。 +前章の最小フック構造を保持し、本章では新規の `todo_write` ツールとリマインダー機構に注目する。`todo_write` は実際の作業を何もしない。ファイルを読めない、コマンドを実行できない。ステータス付きのタスクリストを管理するだけ。 ディスパッチ機構は変わらず、新ツールも `TOOL_HANDLERS[block.name]` を経由する。ただし、todo リマインダーのデモのため、ループにカウンターを追加した:連続 3 ラウンド `todo_write` を呼び出さないとリマインダーが注入される。 @@ -81,7 +81,7 @@ TOOLS = [ TOOL_HANDLERS["todo_write"] = run_todo_write ``` -**Nag リマインダー**、モデルが連続 3 ラウンド `todo_write` を呼び出さないとき、リマインダーが自動的に注入される(教育用機構、CC ソースコードに固定ラウンド数のロジックはない): +**Nag リマインダー**、モデルが連続 3 ラウンド `todo_write` を呼び出さないとき、リマインダーが自動的に注入される: ```python if rounds_since_todo >= 3 and messages: @@ -94,7 +94,9 @@ if rounds_since_todo >= 3 and messages: Agent がタスクを受け取った後の典型的な流れ:まず `todo_write` を呼び出して全手順を列挙(全て `pending`)→ 一つの手順に取り掛かり、`in_progress` に変更 → 完了したら `completed` に変更 → 次の `pending` を見る → 続行。3 ラウンド `todo_write` がない場合、次の LLM 呼び出し前にリマインダーが追加される。 -**重要な洞察**:todo_write は Agent に**実行能力**を何も追加しない。追加するのは**計画能力**だ。 +todo_write は Agent に実行能力を何も追加しない。ステータス付きのリストが増えるだけだ。 + +`todo_write` を呼ぶかどうか、どの手順を列挙するかは、タスクの複雑さに応じてモデル自身が決める。harness はモデルの代わりに計画しない——SYSTEM プロンプトは誘導するだけ、nag reminder も messages にテキストを一つ注入するだけで、モデルは次のラウンドで更新するかどうかを自分で決められる。 --- @@ -104,7 +106,7 @@ Agent がタスクを受け取った後の典型的な流れ:まず `todo_writ |--------------|-------------|-------------| | ツール数 | 5 (bash, read, write, edit, glob) | 6 (+todo_write) | | 計画能力 | なし | ステータス付き TODO リスト + Nag リマインダー | -| SYSTEM プロンプト | 汎用プロンプト | 「先に計画してから実行」のガイダンスを追加 | +| SYSTEM プロンプト | 汎用プロンプト | todo_write 使用ガイダンスを追加 | | ループ | 不変 | ディスパッチは不変、rounds_since_todo カウンターとリマインダー注入を追加 | --- @@ -133,18 +135,18 @@ Agent は計画できるようになった。しかしタスクが大きすぎ → s06 Subagent:大きなタスクをサブタスクに分割し、それぞれを独立した Agent に任せる。それぞれが独自のクリーンなコンテキストを持ち、相互汚染がない。
-CC ソースコードを深掘り +Claude Code ソースコードを深掘り -CC には二つのタスクシステムが共存している(`tasks.ts:133-139`): +Claude Code には二つのタスクシステムが共存している(`tasks.ts:133-139`): - **TodoWrite(V1)**:シンプルなリストツール、データはメモリ AppState で管理(`TodoWriteTool.ts:65-103`)。教育版もプロセスメモリに保持し、終了時に消える - **Task System(V2 = s12)**:ファイル永続化、依存グラフ、並行ロック、ownership 切り替えは `isTodoV2Enabled()` で制御される。現在のソースコードの実装:対話型セッションでは V2 がデフォルトで有効、非対話型セッション(SDK)では V1 がデフォルトで有効。`CLAUDE_CODE_ENABLE_TASKS` 環境変数を設定するとセッション種別に関わらず V2 が強制有効になる。ソースコメント「Force-enable tasks in non-interactive mode」は環境変数パスの用途を説明しており、デフォルト分岐の戻り値のセマンティクスとは異なるため注意。 -教育版は実際のソースコードにある `activeForm` フィールドを省略している(`utils/todo/types.ts:8-15`)。CC は UI スピナーに「何をしているか」を表示するために使用するが、教育版は端末出力のみでこのフィールドは不要。 +教育版は実際のソースコードにある `activeForm` フィールドを省略している(`utils/todo/types.ts:8-15`)。Claude Code は UI スピナーに「何をしているか」を表示するために使用するが、教育版は端末出力のみでこのフィールドは不要。 -教育版の Nag リマインダー(3 ラウンド未更新で注入)は教育用機構。CC ソースコードに固定「3 ラウンド」のロジックはなく、最も近いのは `TodoWriteTool.ts:72-107` で 3 つ以上の todo が全て完了しているのに verification 項目がない場合に verification nudge を追加する処理。 +教育版の Nag リマインダー(3 ラウンド未更新で注入)は教育用機構。Claude Code ソースコードに固定「3 ラウンド」のロジックはなく、最も近いのは `TodoWriteTool.ts:72-107` で 3 つ以上の todo が全て完了しているのに verification 項目がない場合に verification nudge を追加する処理。 Task System の TodoWrite に対する核心的な増分: - メモリリストではなくファイル永続化(Claude 設定ディレクトリ下 `tasks/{taskListId}/{taskId}.json`) diff --git a/s05_todo_write/README.md b/s05_todo_write/README.md index 3a4de4e10..01bfd7b65 100644 --- a/s05_todo_write/README.md +++ b/s05_todo_write/README.md @@ -1,38 +1,38 @@ -# s05: TodoWrite — 没有计划的 Agent,做着做着就偏了 +# s05: TodoWrite — An Agent Without a Plan Drifts Off Course -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20 -> *"没有计划的 agent 走哪算哪"* — 先列步骤再动手,长任务更不容易漏项。 +> *"An agent without a plan goes wherever the wind blows"* — List the steps first, then execute. Complex tasks are less likely to miss steps. > -> **Harness 层**: 规划 — 让 Agent 在动手之前先想清楚。 +> **Harness Layer**: Planning — maintain a stateful task list via todo_write. --- -## 问题 +## The Problem -给 Agent 一个复杂任务:"把所有 Python 文件改成 snake_case 命名,然后跑测试,修好失败。" +Give the Agent a complex task: "Rename all Python files to snake_case, run tests, and fix failures." -Agent 开始干活,改了 3 个文件,跑了个测试,发现 2 个失败,开始修。修着修着,它忘了最初是"改成 snake_case",测试失败把注意力全吸走了。 +The Agent starts working, renames 3 files, runs a test, finds 2 failures, starts fixing. While fixing, it forgets the original goal was "rename to snake_case", the test failures have consumed all its attention. -对话越长越严重:工具结果不断填满上下文,系统提示的影响力被稀释。一个 10 步重构,做完 1-3 步就开始即兴发挥,因为 4-10 步已经被挤出注意力了。 +The longer the conversation, the worse it gets: tool results keep filling the context, diluting the system prompt's influence. A 10-step refactoring: after steps 1-3, the Agent starts improvising because steps 4-10 have been pushed out of its attention. --- -## 解决方案 +## The Solution ![Todo Overview](images/todo-overview.svg) -保留上一章的最小 hook 结构,重点看新增的 `todo_write` 工具和 reminder 机制。`todo_write` 本身不做任何实际工作,不能读文件、不能跑命令,只是让 Agent 在动手之前先理清思路。 +The minimal hook structure from the previous chapter is preserved, focusing on the new `todo_write` tool and reminder mechanism. `todo_write` does no actual work, can't read files or run commands, it simply maintains a stateful task list. -dispatch 机制不变,新工具仍然走 `TOOL_HANDLERS[block.name]` 分发。但为了演示 todo reminder,循环里加了一个计数器:连续 3 轮没调 `todo_write` 就注入一条提醒。 +The dispatch mechanism is unchanged; the new tool is still routed through `TOOL_HANDLERS[block.name]`. However, to demonstrate the todo reminder, a counter was added to the loop: after 3 consecutive rounds without calling `todo_write`, a reminder is injected. --- -## 工作原理 +## How It Works -**todo_write 工具**,接收一个带状态的列表,保存在当前进程内存中,同时在终端显示进度: +**The todo_write tool** accepts a list with statuses, keeps it in the current process memory, and displays progress in the terminal: ```python CURRENT_TODOS: list[dict] = [] @@ -49,7 +49,7 @@ def run_todo_write(todos: list) -> str: return f"Updated {len(CURRENT_TODOS)} tasks" ``` -工具定义和其他 5 个工具一起加入 dispatch map: +The tool definition joins the other 5 in the dispatch map: ```python TOOLS = [ @@ -58,7 +58,7 @@ TOOLS = [ {"name": "write_file", ...}, {"name": "edit_file", ...}, {"name": "glob", ...}, - # s05: 新增一条 + # s05: new entry {"name": "todo_write", "description": "Create and manage a task list ...", "input_schema": { "type": "object", @@ -81,7 +81,7 @@ TOOLS = [ TOOL_HANDLERS["todo_write"] = run_todo_write ``` -**Nag reminder**,模型连续 3 轮没调 `todo_write` 时,自动注入一条提醒(教学版机制,CC 源码中没有这个固定轮数逻辑): +**Nag reminder**, when the model hasn't called `todo_write` for 3 consecutive rounds, a reminder is automatically injected: ```python if rounds_since_todo >= 3 and messages: @@ -92,66 +92,68 @@ if rounds_since_todo >= 3 and messages: rounds_since_todo = 0 ``` -Agent 收到任务后的典型流程:先调 `todo_write` 列出所有步骤(全 `pending`)→ 做一个步骤,改成 `in_progress` → 做完改成 `completed` → 看下一个 `pending` → 继续。连续 3 轮没有调用 `todo_write` 时,循环会在下一次 LLM 调用前追加一条 reminder。 +Typical flow when the Agent receives a task: first call `todo_write` to list all steps (all `pending`) → pick one step, set it to `in_progress` → complete it, set to `completed` → look at the next `pending` → continue. After 3 rounds without `todo_write`, the loop appends a reminder before the next LLM call. -**关键洞察**:todo_write 不给 Agent 增加任何**执行能力**。它增加的是**规划能力**。 +todo_write doesn't give the Agent any additional execution capability. It just adds a stateful list. + +Whether to call `todo_write` — and which steps to list — is entirely the model's own decision, based on task complexity. The harness never plans for the model: the SYSTEM prompt only nudges, and the nag reminder merely injects a text message into `messages[]`. The model is still free to update its todos on the next round. --- -## 相对 s04 的变更 +## Changes from s04 -| 组件 | 之前 (s04) | 之后 (s05) | -|------|-----------|-----------| -| 工具数量 | 5 (bash, read, write, edit, glob) | 6 (+todo_write) | -| 规划能力 | 无 | 带状态的 TODO 列表 + nag reminder | -| SYSTEM 提示 | 通用提示 | 加入 "先计划再执行" 引导 | -| 循环 | 不变 | dispatch 不变,新增 rounds_since_todo 计数器和 reminder 注入 | +| Component | Before (s04) | After (s05) | +|-----------|-------------|-------------| +| Tool count | 5 (bash, read, write, edit, glob) | 6 (+todo_write) | +| Planning | None | Stateful TODO list + nag reminder | +| SYSTEM prompt | Generic prompt | Added todo_write usage guidance | +| Loop | Unchanged | Dispatch unchanged, added rounds_since_todo counter and reminder injection | --- -## 试一下 +## Try It ```sh cd learn-claude-code python s05_todo_write/code.py ``` -试试这些 prompt: +Try these prompts: -1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard`(先列 3 步再执行) +1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard` (should list 3 steps first, then execute) 2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py` 3. `Review Python files under s05_todo_write/example and fix any style issues` -观察重点:第一次工具调用是不是 `todo_write`?TODO 列了几步?执行过程中状态有没有从 `pending` 变成 `in_progress` / `completed`? +What to watch for: Was the first tool call `todo_write`? How many TODO steps were listed? Did statuses move from `pending` to `in_progress` / `completed` during execution? --- -## 接下来 +## What's Next -Agent 能计划了。但如果一个任务太大,比如"重构整个认证模块",光靠 TODO 列表不够。这个任务本身就是几十个小任务的集合,放在同一个对话里会被上下文淹没。 +The Agent can plan now. But if a task is too large, say "refactor the entire auth module", a TODO list alone isn't enough. That task is itself a collection of dozens of subtasks that would drown in a single conversation's context. -s06 Subagent → 把大任务拆成子任务,每个子任务派一个独立的 Agent。它们有自己的干净上下文,不会互相污染。 +→ s06 Subagent: Break large tasks into subtasks, each handled by an independent Agent with its own clean context, no cross-contamination.
-深入 CC 源码 +Dive into Claude Code Source Code -CC 中有两套任务系统并存(`tasks.ts:133-139`): +Claude Code has two task systems coexisting (`tasks.ts:133-139`): -- **TodoWrite(V1)**:一个简单的列表工具,数据在内存 AppState 中维护(`TodoWriteTool.ts:65-103`)。教学版也保存在进程内存里,退出后清空 -- **Task System(V2 = s12)**:文件持久化、依赖图、并发锁、ownership +- **TodoWrite (V1)**: A simple list tool, data maintained in memory AppState (`TodoWriteTool.ts:65-103`). The teaching version also keeps it in process memory and clears it on exit. +- **Task System (V2 = s12)**: File-persisted, dependency graph, concurrency locks, ownership. -切换由 `isTodoV2Enabled()` 控制。当前源码的实现逻辑:交互式会话中 V2 默认启用,非交互式会话(SDK)中 V1 默认启用;设置 `CLAUDE_CODE_ENABLE_TASKS` 环境变量可强制启用 V2。注意源码注释 "Force-enable tasks in non-interactive mode" 描述的是 env var 路径的用途,和默认分支的返回值语义不同,阅读时需区分。 +The switch is controlled by `isTodoV2Enabled()`. In the current source: V2 is enabled by default in interactive sessions, V1 in non-interactive (SDK) sessions; setting `CLAUDE_CODE_ENABLE_TASKS` forces V2 regardless. Note the source comment "Force-enable tasks in non-interactive mode" describes the env var path's purpose, not the default branch's return semantics. -教学版省略了真实源码中的 `activeForm` 字段(`utils/todo/types.ts:8-15`)。CC 用它给 UI spinner 展示"正在做什么",教学版只有终端输出,不需要这个字段。 +The teaching version omits the `activeForm` field from the real source (`utils/todo/types.ts:8-15`). Claude Code uses it for the UI spinner to show "what's being done"; the teaching version only has terminal output and doesn't need this field. -教学版的 nag reminder(3 轮未更新就注入提醒)是教学机制。CC 源码中没有固定的"3 轮"逻辑,更接近的是 `TodoWriteTool.ts:72-107` 中当 3 个以上 todo 全部完成但没有 verification 项时,追加 verification nudge。 +The teaching version's nag reminder (3 rounds without update triggers injection) is an educational mechanism. The Claude Code source has no fixed "3 rounds" logic; the closest is `TodoWriteTool.ts:72-107` which appends a verification nudge when 3+ todos are all completed without a verification item. -Task System 相比 TodoWrite 的核心增量: -- 文件持久化(Claude 配置目录下 `tasks/{taskListId}/{taskId}.json`)而非内存列表 -- `blockedBy` 依赖图而非平铺列表 -- `proper-lockfile` 并发安全而非无锁 -- 四个独立工具(Create/Get/Update/List)而非一个 -- TaskCreated / TaskCompleted hooks(`TaskCreateTool.ts:80-129`、`TaskUpdateTool.ts:231-260`)供外部系统集成 +Core increments of the Task System over TodoWrite: +- File persistence (Claude config directory `tasks/{taskListId}/{taskId}.json`) instead of in-memory list +- `blockedBy` dependency graph instead of flat list +- `proper-lockfile` concurrency safety instead of no locking +- Four separate tools (Create/Get/Update/List) instead of one +- TaskCreated / TaskCompleted hooks (`TaskCreateTool.ts:80-129`, `TaskUpdateTool.ts:231-260`) for external system integration
diff --git a/s05_todo_write/README.zh.md b/s05_todo_write/README.zh.md new file mode 100644 index 000000000..740c731d3 --- /dev/null +++ b/s05_todo_write/README.zh.md @@ -0,0 +1,160 @@ +# s05: TodoWrite — 没有计划的 Agent,做着做着就偏了 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → s02 → s03 → s04 → `s05` → [s06](../s06_subagent/) → s07 → ... → s20 + +> *"没有计划的 agent 走哪算哪"* — 先列步骤再动手,长任务更不容易漏项。 +> +> **Harness 层**: 规划 — 用 todo_write 工具维护任务列表。 + +--- + +## 问题 + +给 Agent 一个复杂任务:"把所有 Python 文件改成 snake_case 命名,然后跑测试,修好失败。" + +Agent 开始干活,改了 3 个文件,跑了个测试,发现 2 个失败,开始修。修着修着,它忘了最初是"改成 snake_case",测试失败把注意力全吸走了。 + +对话越长越严重:工具结果不断填满上下文,系统提示的影响力被稀释。一个 10 步重构,做完 1-3 步就开始即兴发挥,因为 4-10 步已经被挤出注意力了。 + +--- + +## 解决方案 + +![Todo Overview](images/todo-overview.svg) + +保留上一章的最小 hook 结构,重点看新增的 `todo_write` 工具和 reminder 机制。`todo_write` 本身不做任何实际工作,不能读文件、不能跑命令,只是维护一个带状态的任务列表。 + +dispatch 机制不变,新工具仍然走 `TOOL_HANDLERS[block.name]` 分发。但为了演示 todo reminder,循环里加了一个计数器:连续 3 轮没调 `todo_write` 就注入一条提醒。 + +--- + +## 工作原理 + +**todo_write 工具**,接收一个带状态的列表,保存在当前进程内存中,同时在终端显示进度: + +```python +CURRENT_TODOS: list[dict] = [] + +def run_todo_write(todos: list) -> str: + global CURRENT_TODOS + CURRENT_TODOS = todos + + lines = ["\n## Current Tasks"] + for t in CURRENT_TODOS: + icon = {"pending": " ", "in_progress": "▸", "completed": "✓"}[t["status"]] + lines.append(f" [{icon}] {t['content']}") + print("\n".join(lines)) + return f"Updated {len(CURRENT_TODOS)} tasks" +``` + +工具定义和其他 5 个工具一起加入 dispatch map: + +```python +TOOLS = [ + {"name": "bash", ...}, + {"name": "read_file", ...}, + {"name": "write_file", ...}, + {"name": "edit_file", ...}, + {"name": "glob", ...}, + # s05: 新增一条 + {"name": "todo_write", "description": "Create and manage a task list ...", + "input_schema": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": {"type": "string"}, + "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}, + }, + }, + }, + }, + }, + }, +] + +TOOL_HANDLERS["todo_write"] = run_todo_write +``` + +**Nag reminder**,模型连续 3 轮没调 `todo_write` 时,自动注入一条提醒: + +```python +if rounds_since_todo >= 3 and messages: + messages.append({ + "role": "user", + "content": "Update your todos.", + }) + rounds_since_todo = 0 +``` + +Agent 收到任务后的典型流程:先调 `todo_write` 列出所有步骤(全 `pending`)→ 做一个步骤,改成 `in_progress` → 做完改成 `completed` → 看下一个 `pending` → 继续。连续 3 轮没有调用 `todo_write` 时,循环会在下一次 LLM 调用前追加一条 reminder。 + +todo_write 不给 Agent 增加任何执行能力,只是多了一个带状态的列表。 + +调不调 `todo_write`、列哪几步,完全由模型按任务复杂度自主决定。harness 不替模型规划——SYSTEM 提示只是引导,nag reminder 也只是往 messages 注入一条提示文本,模型下一轮仍可自行决定要不要更新。 + +--- + +## 相对 s04 的变更 + +| 组件 | 之前 (s04) | 之后 (s05) | +|------|-----------|-----------| +| 工具数量 | 5 (bash, read, write, edit, glob) | 6 (+todo_write) | +| 规划能力 | 无 | 带状态的 TODO 列表 + nag reminder | +| SYSTEM 提示 | 通用提示 | 加入 todo_write 使用引导 | +| 循环 | 不变 | dispatch 不变,新增 rounds_since_todo 计数器和 reminder 注入 | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s05_todo_write/code.py +``` + +试试这些 prompt: + +1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard`(先列 3 步再执行) +2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py` +3. `Review Python files under s05_todo_write/example and fix any style issues` + +观察重点:第一次工具调用是不是 `todo_write`?TODO 列了几步?执行过程中状态有没有从 `pending` 变成 `in_progress` / `completed`? + +--- + +## 接下来 + +Agent 能计划了。但如果一个任务太大,比如"重构整个认证模块",光靠 TODO 列表不够。这个任务本身就是几十个小任务的集合,放在同一个对话里会被上下文淹没。 + +s06 Subagent → 把大任务拆成子任务,每个子任务派一个独立的 Agent。它们有自己的干净上下文,不会互相污染。 + +
+深入 Claude Code 源码 + +Claude Code 中有两套任务系统并存(`tasks.ts:133-139`): + +- **TodoWrite(V1)**:一个简单的列表工具,数据在内存 AppState 中维护(`TodoWriteTool.ts:65-103`)。教学版也保存在进程内存里,退出后清空 +- **Task System(V2 = s12)**:文件持久化、依赖图、并发锁、ownership + +切换由 `isTodoV2Enabled()` 控制。当前源码的实现逻辑:交互式会话中 V2 默认启用,非交互式会话(SDK)中 V1 默认启用;设置 `CLAUDE_CODE_ENABLE_TASKS` 环境变量可强制启用 V2。注意源码注释 "Force-enable tasks in non-interactive mode" 描述的是 env var 路径的用途,和默认分支的返回值语义不同,阅读时需区分。 + +教学版省略了真实源码中的 `activeForm` 字段(`utils/todo/types.ts:8-15`)。Claude Code 用它给 UI spinner 展示"正在做什么",教学版只有终端输出,不需要这个字段。 + +教学版的 nag reminder(3 轮未更新就注入提醒)是教学机制。Claude Code 源码中没有固定的"3 轮"逻辑,更接近的是 `TodoWriteTool.ts:72-107` 中当 3 个以上 todo 全部完成但没有 verification 项时,追加 verification nudge。 + +Task System 相比 TodoWrite 的核心增量: +- 文件持久化(Claude 配置目录下 `tasks/{taskListId}/{taskId}.json`)而非内存列表 +- `blockedBy` 依赖图而非平铺列表 +- `proper-lockfile` 并发安全而非无锁 +- 四个独立工具(Create/Get/Update/List)而非一个 +- TaskCreated / TaskCompleted hooks(`TaskCreateTool.ts:80-129`、`TaskUpdateTool.ts:231-260`)供外部系统集成 + +
+ + diff --git a/s05_todo_write/images/todo-overview.en.svg b/s05_todo_write/images/todo-overview.en.svg deleted file mode 100644 index b4655e1a9..000000000 --- a/s05_todo_write/images/todo-overview.en.svg +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - TodoWrite — Loop Unchanged, One More Tool Auto-Dispatched - - - s04 Preserved - - - - messages[] - - - - - - - LLM - stop_reason=tool_use? - - - - No - - Return Result - - - - Yes - - - - trigger_hooks - PreToolUse - - - - - - - - TOOL_HANDLERS - - - - bash · read · write - - - edit · glob - - - - todo_write - s05 New - - → in-memory TODO list - - - - Results appended to messages[], loop continues - - - - Nag Reminder - Model hasn't called todo_write for 3 rounds → auto-inject <reminder>Update your todos.</reminder> - - - - - s04 Preserved (loop, hooks, 5 base tools) - - s05 New (todo_write + nag reminder) - diff --git a/s05_todo_write/images/todo-overview.ja.svg b/s05_todo_write/images/todo-overview.ja.svg deleted file mode 100644 index ce0f6977b..000000000 --- a/s05_todo_write/images/todo-overview.ja.svg +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - TodoWrite — ループ不変、ツール一つ追加で自動ディスパッチ - - - s04 保持 - - - - messages[] - - - - - - - LLM - stop_reason=tool_use? - - - - No - - 結果を返す - - - - Yes - - - - trigger_hooks - PreToolUse - - - - - - - - TOOL_HANDLERS - - - - bash · read · write - - - edit · glob - - - - todo_write - s05 新規 - - → メモリ内 TODO リスト - - - - 結果を messages[] に追加、ループ継続 - - - - Nag リマインダー(催促機構) - モデルが連続 3 ラウンド todo_write 未呼び出し → 自動注入 <reminder>Update your todos.</reminder> - - - - - s04 保持(ループ、フック、5 つの基本ツール) - - s05 新規(todo_write + Nag リマインダー) - diff --git a/s05_todo_write/images/todo-overview.svg b/s05_todo_write/images/todo-overview.svg index 25e12fecd..41057f69f 100644 --- a/s05_todo_write/images/todo-overview.svg +++ b/s05_todo_write/images/todo-overview.svg @@ -1,93 +1,99 @@ - + - - - - - - - + + + + - - - - - - - - - - - TodoWrite — 循环不变,多一个工具自动分发 - - - s04 保留 - - - - messages[] - - - - - - - LLM - stop_reason=tool_use? - - - - - - 返回结果 - - - - - - - - trigger_hooks - PreToolUse - - - - - - - - TOOL_HANDLERS - - - - bash · read · write - - - edit · glob - - - - todo_write - s05 新增 - - → 进程内 TODO 列表 - - - - 结果追加到 messages[],循环继续 - - - - Nag Reminder(催更机制) - 模型连续 3 轮没调 todo_write → 自动注入 <reminder>Update your todos.</reminder> - - - - - s04 保留(循环、钩子、5 个基础工具) - - s05 新增(todo_write + nag reminder) - + + + + + TodoWrite — One More Tool, Same Loop + Stateful task list added to the existing agent loop + + + + + + messages[] + conversation history + + + + + + + LLM + stop_reason? + + + + + + + tool_use + + + + no + + + + Return Result + + + + yes + + + + trigger_hooks + PreToolUse + + + + + + + TOOL_HANDLERS + + + + bash read write edit glob + + + + todo_write + + + in-process stateful TODO list + + + + + + + PostToolUse + + + + + + + + + Result appended to messages[], loop continues + + + + + + + Nag Reminder + if rounds_since_todo >= 3: inject <reminder>Update your todos.</reminder> + + \ No newline at end of file diff --git a/s06_subagent/README.en.md b/s06_subagent/README.en.md deleted file mode 100644 index 93313e351..000000000 --- a/s06_subagent/README.en.md +++ /dev/null @@ -1,189 +0,0 @@ -# s06: Subagent — Break Large Tasks into Small Ones with Clean Context - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20 - -> *"Break large tasks small, each with clean context"* — Subagent uses an independent messages[], no pollution in the main conversation. -> -> **Harness Layer**: Sub-Agent — Context isolation, attention doesn't drift. - ---- - -## The Problem - -The Agent is fixing a bug. It reads 30 files to trace the call chain, chatting for 60 rounds along the way. The messages list grows to 120 entries, most of which are intermediate steps from "tracing the call chain" — unrelated to the final goal of "fixing the bug." - -These intermediate steps occupy context space, making the Agent increasingly "forgetful" — it can no longer remember what the original problem was. - -Think of it differently: when you fix a bug, you'd "open a new terminal" to trace the call chain. When done, close the terminal, write the result into your notes, and return to the original terminal to keep fixing. The Agent needs this ability too — **open an independent sub-process, give it an independent message list, let it focus on one thing.** - ---- - -## The Solution - -![Subagent Overview](images/subagent-overview.en.svg) - -The minimal hook structure and `todo_write` tool from the previous chapter are preserved; this chapter focuses on the new `task` tool. When called, it spawns a sub-Agent with a fresh `messages[]`, running its own loop, and returning only a summary text to the main Agent. Conversation context is discarded, but file system side effects (writes, edits, commands) remain in the working directory. - -The sub-Agent's tools are restricted: it has bash/read/write/edit/glob, but no task, preventing recursive spawning. The sub-Agent's tool calls still go through permission hooks; context isolation does not bypass security. - ---- - -## How It Works - -**spawn_subagent**, gives the sub-Agent a fresh messages list, runs its own loop, returns only the conclusion: - -```python -def spawn_subagent(description: str) -> str: - # Sub-Agent tools: base tools, but no task (no recursion) - sub_tools = [...] - messages = [{"role": "user", "content": description}] # fresh messages[] - - for _ in range(30): # safety limit - response = client.messages.create( - model=MODEL, system=SUB_SYSTEM, - messages=messages, tools=sub_tools, max_tokens=8000, - ) - messages.append({"role": "assistant", "content": response.content}) - if response.stop_reason != "tool_use": - break - results = [] - for block in response.content: - if block.type == "tool_use": - blocked = trigger_hooks("PreToolUse", block) - if blocked: - results.append({... "content": str(blocked)}) - continue - handler = SUB_HANDLERS.get(block.name) - output = handler(**block.input) if handler else f"Unknown" - trigger_hooks("PostToolUse", block, output) - results.append({... "content": output}) - messages.append({"role": "user", "content": results}) - - # Return only the final text conclusion, all intermediate steps discarded - return extract_text(messages[-1]["content"]) -``` - -The main Agent calls it just like any other tool: - -```python -TOOLS = [ - {"name": "bash", ...}, - {"name": "read_file", ...}, - {"name": "write_file", ...}, - {"name": "edit_file", ...}, - {"name": "glob", ...}, - {"name": "todo_write", ...}, - # s06: new task tool - {"name": "task", - "description": "Launch a subagent to handle a complex subtask. Returns only the final conclusion.", - "input_schema": {"type": "object", "properties": {"description": {"type": "string"}}, "required": ["description"]}}, -] - -TOOL_HANDLERS["task"] = spawn_subagent -``` - -Three key design decisions: - -| Decision | Choice | Reason | -|----------|--------|--------| -| Context isolation | Fresh `messages[]` | Sub-Agent's intermediate steps don't pollute main Agent's context | -| Return only conclusion | `extract_text(last_message)` | Not returning the entire messages list | -| No recursion | Sub-Agent has no task tool | Prevents sub-Agent from spawning further sub-Agents | -| Security not bypassed | Sub-Agent tool calls go through PreToolUse hook | Context isolation does not mean permission isolation | - -The dispatch mechanism is unchanged; the task tool is routed through `TOOL_HANDLERS[block.name]`. The sub-Agent has its own `SUB_SYSTEM` prompt, explicitly instructing "complete the task, do not delegate further." - ---- - -## Changes from s05 - -| Component | Before (s05) | After (s06) | -|-----------|-------------|-------------| -| Tool count | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) | -| New function | — | spawn_subagent (independent messages[] + 30-round safety limit) | -| Context isolation | Everything in the main conversation | Sub-Agent uses fresh messages[] | -| Loop | Unchanged | Dispatch unchanged, sub-Agent has independent SUB_SYSTEM and hook-protected loop | - ---- - -## Try It - -```sh -cd learn-claude-code -python s06_subagent/code.py -``` - -Try these prompts: - -1. `Use a subtask to find what testing framework this project uses` (sub-Agent reads files, main Agent receives only the conclusion) -2. `Delegate: read all .py files in agents/ and summarize what each one does` -3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent` - -What to watch for: Do `[Subagent spawned]` / `[Subagent done]` appear? Do sub-Agent tool calls print as `[sub] ...`? Does the parent Agent continue with only the summary returned by the sub-Agent? - ---- - -## What's Next - -The Agent can now break tasks apart. But different tasks require different knowledge: editing frontend components needs React conventions, writing SQL needs table schemas. Stuffing all this knowledge into the system prompt would blow up the context. - -→ s07 Skill Loading: Inject skills on demand instead of piling documents into the system prompt. Load only when needed, as natural as reading a file. - -
-Dive into CC Source Code - -> The following is based on a complete analysis of CC source code `AgentTool.tsx`, `runAgent.ts`, `forkSubagent.ts`, and `forkedAgent.ts`. - -### 1. Not One Pattern, but Three - -The teaching version covers only "fresh messages[]". CC actually has three execution modes: - -| Mode | Trigger | Context | -|------|---------|---------| -| **Normal Subagent** | `subagent_type` specified (normal path) | Truly fresh messages[], only the prompt | -| **Fork Subagent** | No `subagent_type`, fork gate enabled | Constructs cache-friendly prefix via `buildForkedMessages()`, shares prompt cache | -| **General-Purpose** | No `subagent_type`, fork gate disabled | Same as Normal | - -### 2. Fork Mode: Sharing Prompt Cache - -This is a core concept the teaching version omits. Fork mode (`forkSubagent.ts:60-71`) doesn't create a fresh context. Instead, it constructs a cache-friendly message prefix via `buildForkedMessages()` (`forkSubagent.ts:107-168`), preserving the parent assistant message and generating placeholder tool results. The goal isn't isolation, but making the Anthropic API's prompt cache hit: parent and child Agent's system prompt, tools, and message prefix are byte-identical, so the API doesn't need to recompute. - -Five key components for cache hit (`forkedAgent.ts:57-68`): system prompt, tools, model, message prefix, thinking config, must be byte-identical. - -### 3. Context Isolation's Precise Granularity - -`createSubagentContext()` (`forkedAgent.ts:345-462`) creates the sub-Agent's `ToolUseContext`: - -| Field | Behavior | -|-------|----------| -| `abortController` | New child controller; parent abort propagates down | -| `setAppState` | Default no-op; but sync agents share via `shareSetAppState` (`runAgent.ts:697-714`) | -| `readFileState` | **Cloned from parent** (avoids re-reading same files) | -| `queryTracking` | New chainId, `depth = parentDepth + 1` | - -The sub-Agent isn't fully isolated: file read state is shared. The degree of UI and notification isolation varies by execution path (sync/async/fork/teammate differ). - -### 4. Recursive Fork Protection - -The teaching version uses "sub-Agent has no task tool" for recursion protection. The real implementation is more nuanced: `isInForkChild()` (`forkSubagent.ts:78-89`) checks for `FORK_BOILERPLATE_TAG` in history. But `constants/tools.ts:36-46` defaults `Agent` to all agents' disabled set (with `USER_TYPE === 'ant'` exception); `forkSubagent.ts:73-89` has fork-child-specific recursion protection; `agentToolUtils.ts:100-110` has special allowances in teammate scenarios. Not simply "no further sub-Agents." - -### 5. Permission Bubbling - -Fork Agent's `permissionMode: 'bubble'` (`forkSubagent.ts:67`) means the sub-Agent's permission prompts bubble up to the parent terminal: the user approves sub-Agent operations in the main terminal. - -### 6. Async vs Sync - -The teaching version only shows synchronous sub-Agents (parent waits for child to finish). CC also supports async paths (`AgentTool.tsx:686-764`): when `run_in_background: true`, the sub-Agent launches asynchronously, returning `{ status: 'async_launched' }` immediately to the parent, and notifies the parent when complete. Actual triggers go beyond `run_in_background`, including auto-background, assistant force async, and coordinator/proactive paths. - -### Teaching Version Simplifications Are Intentional - -- Three modes → one (fresh messages): conceptually clear -- Prompt cache sharing → omitted: teaching version doesn't involve API-layer optimization -- Recursive fork protection → simplified to "sub-Agent has no task tool" -- Async → omitted (left for s13): s06 focuses on the synchronous model first - -
- - diff --git a/s06_subagent/README.ja.md b/s06_subagent/README.ja.md index fc285208f..c15ea528c 100644 --- a/s06_subagent/README.ja.md +++ b/s06_subagent/README.ja.md @@ -1,6 +1,6 @@ # s06: Subagent — 大きなタスクを分割、それぞれがクリーンなコンテキストを取得 -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20 @@ -12,17 +12,17 @@ s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) ## 課題 -Agent がバグを修正している。呼び出しチェーンを追跡するために 30 のファイルを読み、途中で 60 ラウンドやり取りした。messages リストは 120 件に膨らみ、その大部分は「呼び出しチェーンの追跡」という中間過程 — 「バグ修正」という最終目標とは無関係。 +Agent がバグを修正している。呼び出しチェーンを追跡するために 30 のファイルを読み、途中で 60 ラウンドやり取りした。messages リストは 120 件に膨らみ、その大部分は「呼び出しチェーンの追跡」という中間過程であり、「バグ修正」という最終目標とは無関係。 -この中間過程がコンテキストの席を占め、Agent はますます「健忘」になる — 最初の問題が何だったか覚えていられない。 +この中間過程がコンテキストの席を占め、初期の重要な情報が有効ウィンドウから押し出され、元の問題に対するモデルの応答品質が低下する。 -別の見方をすると:バグを修正するとき、あなたは「新しいターミナルを開いて」呼び出しチェーンを追跡するだろう。追跡が終わったらターミナルを閉じ、結果をメモに書き、元のターミナルに戻ってバグ修正を続ける。Agent にもこの能力が必要 — **独立したサブプロセスを開き、独立したメッセージリストを与え、一つのことに集中させる。** +別の見方をすると:バグを修正するとき、あなたは「新しいターミナルを開いて」呼び出しチェーンを追跡するだろう。追跡が終わったらターミナルを閉じ、結果をメモに書き、元のターミナルに戻ってバグ修正を続ける。Agent にも同じ仕組みが必要になる。独立したサブプロセスを spawn し、専用のメッセージリストで単一タスクに限定する。 --- ## ソリューション -![Subagent Overview](images/subagent-overview.ja.svg) +![Subagent Overview](images/subagent-overview.svg) 前章の最小フック構造と `todo_write` ツールを保持し、本章は新規の `task` ツールに注目する。呼び出されると、サブエージェントを spawn する。新しい `messages[]` を持ち、自分自身のループを実行し、終了後に要約テキストのみをメイン Agent に返す。会話コンテキストは破棄されるが、ファイルシステムの副作用(書き込み、編集、コマンド実行)は作業ディレクトリに残る。 @@ -132,13 +132,13 @@ Agent はタスクを分割できるようになった。しかし各タスク → s07 Skill Loading:スキルをオンデマンドで注入する。system prompt にドキュメントを積み上げるのではなく、必要なときだけ読み込む。ファイルを読むのと同じくらい自然に。
-CC ソースコードを深掘り +Claude Code ソースコードを深掘り -> 以下は CC ソースコード `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` の完全分析に基づく。 +> 以下は Claude Code ソースコード `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` の完全分析に基づく。 ### 一、一つのパターンではなく三つ -教育版は「新規 messages[]」のみを取り上げる。CC には実際に三つの実行モードがある: +教育版は「新規 messages[]」のみを取り上げる。Claude Code には実際に三つの実行モードがある: | モード | トリガー | コンテキスト | |--------|---------|-------------| @@ -175,7 +175,7 @@ Fork Agent の `permissionMode: 'bubble'`(`forkSubagent.ts:67`)は、サブ ### 六、Async vs Sync -教育版は同期サブエージェントのみ(親が子の完了を待つ)を示す。CC は非同期パスもサポート(`AgentTool.tsx:686-764`):`run_in_background: true` の場合、サブエージェントは非同期で起動し、`{ status: 'async_launched' }` を直ちに親に返し、完了時に通知機構で親に知らせる。実際のトリガーは `run_in_background` だけでなく、auto-background、assistant force async、coordinator/proactive パスもある。 +教育版は同期サブエージェントのみ(親が子の完了を待つ)を示す。Claude Code は非同期パスもサポート(`AgentTool.tsx:686-764`):`run_in_background: true` の場合、サブエージェントは非同期で起動し、`{ status: 'async_launched' }` を直ちに親に返し、完了時に通知機構で親に知らせる。実際のトリガーは `run_in_background` だけでなく、auto-background、assistant force async、coordinator/proactive パスもある。 ### 教育版の簡略化は意図的 diff --git a/s06_subagent/README.md b/s06_subagent/README.md index 6744566b9..96c0d395d 100644 --- a/s06_subagent/README.md +++ b/s06_subagent/README.md @@ -1,48 +1,44 @@ -# s06: Subagent — 大任务拆小,每个拿到的都是干净上下文 +# s06: Subagent — Break Large Tasks into Small Ones with Clean Context -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20 -> *"大任务拆小, 每个小任务干净的上下文"* — Subagent 用独立 messages[], 不污染主对话。 +> *"Break large tasks small, each with clean context"* — Subagent uses an independent messages[], no pollution in the main conversation. > -> **Harness 层**: 子 Agent — 上下文隔离, 注意力不漂移。 +> **Harness Layer**: Sub-Agent — Context isolation, attention doesn't drift. --- -## 问题 +## The Problem -Agent 在修一个 bug。它读了 30 个文件来追踪调用链,中间聊了 60 轮。messages 列表涨到 120 条,其中大部分是"追踪调用链"的中间过程,和"修 bug"这个最终目标无关。 +The Agent is fixing a bug. It reads 30 files to trace the call chain, chatting for 60 rounds along the way. The messages list grows to 120 entries, most of which are intermediate steps from "tracing the call chain," unrelated to the final goal of "fixing the bug." -这些中间过程占着上下文位置,让 Agent 越来越"健忘",它记不住最初的问题是什么了。 +These intermediate steps occupy context space, pushing early information out of the effective window and degrading the model's response quality for the original problem. -换个角度:你修 bug 的时候,会"开一个新终端"来追踪调用链。追踪完了,终端关掉,结果写进笔记,回到原来的终端继续修 bug。Agent 也需要这个能力:开一个独立的子进程,给它一个独立的消息列表,让它专心做一件事。 +Think of it differently: when you fix a bug, you'd "open a new terminal" to trace the call chain. When done, close the terminal, write the result into your notes, and return to the original terminal to keep fixing. The Agent needs the same capability: spawn an independent sub-process with its own message list, scoped to a single task. --- -## 解决方案 +## The Solution ![Subagent Overview](images/subagent-overview.svg) -保留上一章的最小 hook 结构和 `todo_write` 工具,本章重点转向新增的 `task` 工具。调用它时,spawn 一个子 Agent,拥有全新的 `messages[]`,跑自己的循环,结束后只把摘要文本回传给主 Agent。对话上下文被丢弃,但文件系统的副作用(写文件、改文件、跑命令)保留在工作目录中。 +The minimal hook structure and `todo_write` tool from the previous chapter are preserved; this chapter focuses on the new `task` tool. When called, it spawns a sub-Agent with a fresh `messages[]`, running its own loop, and returning only a summary text to the main Agent. Conversation context is discarded, but file system side effects (writes, edits, commands) remain in the working directory. -子 Agent 的工具受限:有 bash/read/write/edit/glob,但没有 task,不能递归 spawn 新的子 Agent。子 Agent 的工具调用仍经过权限 hook,安全策略不因上下文隔离而跳过。 +The sub-Agent's tools are restricted: it has bash/read/write/edit/glob, but no task, preventing recursive spawning. The sub-Agent's tool calls still go through permission hooks; context isolation does not bypass security. --- -## 工作原理 +## How It Works -**spawn_subagent**,给子 Agent 一个全新的 messages 列表,跑自己的循环,只回传结论: +**spawn_subagent**, gives the sub-Agent a fresh messages list, runs its own loop, returns only the conclusion: ```python def spawn_subagent(description: str) -> str: - # 子 Agent 的工具:基础工具,但没有 task(禁止递归) - sub_tools = [ - {"name": "bash", ...}, {"name": "read_file", ...}, - {"name": "write_file", ...}, {"name": "edit_file", ...}, - {"name": "glob", ...}, - ] - messages = [{"role": "user", "content": description}] # 全新 messages[] + # Sub-Agent tools: base tools, but no task (no recursion) + sub_tools = [...] + messages = [{"role": "user", "content": description}] # fresh messages[] for _ in range(30): # safety limit response = client.messages.create( @@ -65,11 +61,11 @@ def spawn_subagent(description: str) -> str: results.append({... "content": output}) messages.append({"role": "user", "content": results}) - # 只返回最后的文本结论,中间过程全部丢弃 + # Return only the final text conclusion, all intermediate steps discarded return extract_text(messages[-1]["content"]) ``` -主 Agent 调用时,跟调其他工具一样: +The main Agent calls it just like any other tool: ```python TOOLS = [ @@ -79,7 +75,7 @@ TOOLS = [ {"name": "edit_file", ...}, {"name": "glob", ...}, {"name": "todo_write", ...}, - # s06: 新增 task 工具 + # s06: new task tool {"name": "task", "description": "Launch a subagent to handle a complex subtask. Returns only the final conclusion.", "input_schema": {"type": "object", "properties": {"description": {"type": "string"}}, "required": ["description"]}}, @@ -88,106 +84,106 @@ TOOLS = [ TOOL_HANDLERS["task"] = spawn_subagent ``` -三个关键设计决策: +Three key design decisions: -| 决策 | 选择 | 原因 | -|------|------|------| -| 上下文隔离 | 全新 `messages[]` | 子 Agent 的中间过程不污染主 Agent 的上下文 | -| 只回传结论 | `extract_text(last_message)` | 不是回传整个 messages 列表 | -| 禁止递归 | 子 Agent 无 task 工具 | 防止子 Agent 再 spawn 新的子 Agent | -| 安全策略不跳过 | 子 Agent 工具调用也走 PreToolUse hook | 上下文隔离不代表权限隔离 | +| Decision | Choice | Reason | +|----------|--------|--------| +| Context isolation | Fresh `messages[]` | Sub-Agent's intermediate steps don't pollute main Agent's context | +| Return only conclusion | `extract_text(last_message)` | Not returning the entire messages list | +| No recursion | Sub-Agent has no task tool | Prevents sub-Agent from spawning further sub-Agents | +| Security not bypassed | Sub-Agent tool calls go through PreToolUse hook | Context isolation does not mean permission isolation | -dispatch 机制不变,task 工具通过 `TOOL_HANDLERS[block.name]` 分发。子 Agent 有独立的 `SUB_SYSTEM` 提示,明确要求"直接完成任务,不要再委派"。 +The dispatch mechanism is unchanged; the task tool is routed through `TOOL_HANDLERS[block.name]`. The sub-Agent has its own `SUB_SYSTEM` prompt, explicitly instructing "complete the task, do not delegate further." --- -## 相对 s05 的变更 +## Changes from s05 -| 组件 | 之前 (s05) | 之后 (s06) | -|------|-----------|-----------| -| 工具数量 | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) | -| 新函数 | — | spawn_subagent(独立 messages[] + 30 轮安全限制) | -| 上下文隔离 | 全部在主对话中 | 子 Agent 用全新的 messages[] | -| 循环 | 不变 | dispatch 不变,子 Agent 有独立 SUB_SYSTEM 和 hook 保护的循环 | +| Component | Before (s05) | After (s06) | +|-----------|-------------|-------------| +| Tool count | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) | +| New function | — | spawn_subagent (independent messages[] + 30-round safety limit) | +| Context isolation | Everything in the main conversation | Sub-Agent uses fresh messages[] | +| Loop | Unchanged | Dispatch unchanged, sub-Agent has independent SUB_SYSTEM and hook-protected loop | --- -## 试一下 +## Try It ```sh cd learn-claude-code python s06_subagent/code.py ``` -试试这些 prompt: +Try these prompts: -1. `Use a subtask to find what testing framework this project uses`(子 Agent 去读文件,主 Agent 只收结论) +1. `Use a subtask to find what testing framework this project uses` (sub-Agent reads files, main Agent receives only the conclusion) 2. `Delegate: read all .py files in agents/ and summarize what each one does` 3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent` -观察重点:是否出现 `[Subagent spawned]` / `[Subagent done]`?子 Agent 的工具调用是否以 `[sub] ...` 输出?主 Agent 最后是否只继续处理子 Agent 返回的摘要? +What to watch for: Do `[Subagent spawned]` / `[Subagent done]` appear? Do sub-Agent tool calls print as `[sub] ...`? Does the parent Agent continue with only the summary returned by the sub-Agent? --- -## 接下来 +## What's Next -Agent 现在能拆任务了。但每个任务需要的知识不一样:改前端组件需要知道 React 规范,写 SQL 需要知道表结构。这些知识全塞进 system prompt,上下文直接爆了。 +The Agent can now break tasks apart. But different tasks require different knowledge: editing frontend components needs React conventions, writing SQL needs table schemas. Stuffing all this knowledge into the system prompt would blow up the context. -s07 Skill Loading → 技能按需注入,不在 system prompt 里堆文档。用到的时候才加载,和读文件一样自然。 +→ s07 Skill Loading: Inject skills on demand instead of piling documents into the system prompt. Load only when needed, as natural as reading a file.
-深入 CC 源码 +Dive into Claude Code Source Code -> 以下基于 CC 源码 `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` 的完整分析。 +> The following is based on a complete analysis of Claude Code source code `AgentTool.tsx`, `runAgent.ts`, `forkSubagent.ts`, and `forkedAgent.ts`. -### 一、不是一种模式,是三种 +### 1. Not One Pattern, but Three -教学版只讲了"全新的 messages[]"。CC 实际有三种执行模式: +The teaching version covers only "fresh messages[]". Claude Code actually has three execution modes: -| 模式 | 触发条件 | 上下文 | -|------|---------|--------| -| **Normal Subagent** | 指定了 `subagent_type`(normal path) | 全新 messages[],只有 prompt | -| **Fork Subagent** | 没指定 `subagent_type`,fork gate 开启 | 通过 `buildForkedMessages()` 构造 cache-friendly 前缀,共享 prompt cache | -| **General-Purpose** | 没指定 `subagent_type`,fork gate 关闭 | 同 Normal | +| Mode | Trigger | Context | +|------|---------|---------| +| **Normal Subagent** | `subagent_type` specified (normal path) | Truly fresh messages[], only the prompt | +| **Fork Subagent** | No `subagent_type`, fork gate enabled | Constructs cache-friendly prefix via `buildForkedMessages()`, shares prompt cache | +| **General-Purpose** | No `subagent_type`, fork gate disabled | Same as Normal | -### 二、Fork 模式:为了共享 Prompt Cache +### 2. Fork Mode: Sharing Prompt Cache -这是教学版没有的核心概念。Fork 模式(`forkSubagent.ts:60-71`)不创建全新上下文,而是通过 `buildForkedMessages()`(`forkSubagent.ts:107-168`)构造 cache-friendly 消息前缀,保留父 assistant message 并生成 placeholder tool results。目的不是隔离,而是让 Anthropic API 的 prompt cache 命中:父子 Agent 的 system prompt、tools、messages 前缀完全一致,API 端不需要重算。 +This is a core concept the teaching version omits. Fork mode (`forkSubagent.ts:60-71`) doesn't create a fresh context. Instead, it constructs a cache-friendly message prefix via `buildForkedMessages()` (`forkSubagent.ts:107-168`), preserving the parent assistant message and generating placeholder tool results. The goal isn't isolation, but making the Anthropic API's prompt cache hit: parent and child Agent's system prompt, tools, and message prefix are byte-identical, so the API doesn't need to recompute. -缓存命中的五个关键组件(`forkedAgent.ts:57-68`):system prompt、tools、model、messages 前缀、thinking config,必须字节级一致。 +Five key components for cache hit (`forkedAgent.ts:57-68`): system prompt, tools, model, message prefix, thinking config, must be byte-identical. -### 三、Context Isolation 的精确粒度 +### 3. Context Isolation's Precise Granularity -`createSubagentContext()`(`forkedAgent.ts:345-462`)创建子 Agent 的 `ToolUseContext`: +`createSubagentContext()` (`forkedAgent.ts:345-462`) creates the sub-Agent's `ToolUseContext`: -| 字段 | 行为 | -|------|------| -| `abortController` | 新的 child controller,父 abort 向下传播 | -| `setAppState` | 默认 no-op;但 sync agent 通过 `shareSetAppState` 共享(`runAgent.ts:697-714`) | -| `readFileState` | **从父克隆**(避免重复读相同文件) | -| `queryTracking` | 新 chainId,`depth = parentDepth + 1` | +| Field | Behavior | +|-------|----------| +| `abortController` | New child controller; parent abort propagates down | +| `setAppState` | Default no-op; but sync agents share via `shareSetAppState` (`runAgent.ts:697-714`) | +| `readFileState` | **Cloned from parent** (avoids re-reading same files) | +| `queryTracking` | New chainId, `depth = parentDepth + 1` | -子 Agent 不是完全隔离的:文件读取状态是共享的。UI 和通知的隔离程度取决于执行路径(sync/async/fork/teammate 各不同)。 +The sub-Agent isn't fully isolated: file read state is shared. The degree of UI and notification isolation varies by execution path (sync/async/fork/teammate differ). -### 四、递归 Fork 防护 +### 4. Recursive Fork Protection -教学版用"子 Agent 不给 task 工具"表达递归保护。真实实现更精细:`isInForkChild()`(`forkSubagent.ts:78-89`)检查对话历史中是否有 `FORK_BOILERPLATE_TAG`,有就拒绝。但 `constants/tools.ts:36-46` 中 `Agent` 工具默认在所有 agent 的禁用集合里,`USER_TYPE === 'ant'` 时例外;`forkSubagent.ts:73-89` 针对 fork child 有专门的递归保护;`agentToolUtils.ts:100-110` 在 teammate 场景下有特殊放行。不是简单的"禁止新的子 Agent"。 +The teaching version uses "sub-Agent has no task tool" for recursion protection. The real implementation is more nuanced: `isInForkChild()` (`forkSubagent.ts:78-89`) checks for `FORK_BOILERPLATE_TAG` in history. But `constants/tools.ts:36-46` defaults `Agent` to all agents' disabled set (with `USER_TYPE === 'ant'` exception); `forkSubagent.ts:73-89` has fork-child-specific recursion protection; `agentToolUtils.ts:100-110` has special allowances in teammate scenarios. Not simply "no further sub-Agents." -### 五、Permission Bubbling +### 5. Permission Bubbling -Fork Agent 的 `permissionMode: 'bubble'`(`forkSubagent.ts:67`)意味着子 Agent 的权限弹窗冒泡到父终端,用户在主终端里审批子 Agent 的操作。 +Fork Agent's `permissionMode: 'bubble'` (`forkSubagent.ts:67`) means the sub-Agent's permission prompts bubble up to the parent terminal: the user approves sub-Agent operations in the main terminal. -### 六、Async vs Sync +### 6. Async vs Sync -教学版只展示了同步子 Agent(父等着子跑完)。CC 还支持异步路径(`AgentTool.tsx:686-764`):`run_in_background: true` 时异步启动,返回 `{ status: 'async_launched' }` 立即给父 Agent,子 Agent 完成后通过通知机制告知父 Agent。实际触发条件不止 `run_in_background`,还有 auto-background、assistant force async、coordinator/proactive 等路径。 +The teaching version only shows synchronous sub-Agents (parent waits for child to finish). Claude Code also supports async paths (`AgentTool.tsx:686-764`): when `run_in_background: true`, the sub-Agent launches asynchronously, returning `{ status: 'async_launched' }` immediately to the parent, and notifies the parent when complete. Actual triggers go beyond `run_in_background`, including auto-background, assistant force async, and coordinator/proactive paths. -### 教学版的简化是刻意的 +### Teaching Version Simplifications Are Intentional -- 三种模式 → 一种(fresh messages):概念清晰 -- Prompt cache 共享 → 省略:教学版不涉及 API 层优化 -- 递归 fork 防护 → 简化为"子 Agent 无 task 工具" -- Async → 省略(留给 s13):s06 先理解同步模型 +- Three modes → one (fresh messages): conceptually clear +- Prompt cache sharing → omitted: teaching version doesn't involve API-layer optimization +- Recursive fork protection → simplified to "sub-Agent has no task tool" +- Async → omitted (left for s13): s06 focuses on the synchronous model first
- + diff --git a/s06_subagent/README.zh.md b/s06_subagent/README.zh.md new file mode 100644 index 000000000..a04423bdc --- /dev/null +++ b/s06_subagent/README.zh.md @@ -0,0 +1,193 @@ +# s06: Subagent — 大任务拆小,每个拿到的都是干净上下文 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20 + +> *"大任务拆小, 每个小任务干净的上下文"* — Subagent 用独立 messages[], 不污染主对话。 +> +> **Harness 层**: 子 Agent — 上下文隔离, 注意力不漂移。 + +--- + +## 问题 + +Agent 在修一个 bug。它读了 30 个文件来追踪调用链,中间聊了 60 轮。messages 列表涨到 120 条,其中大部分是"追踪调用链"的中间过程,和"修 bug"这个最终目标无关。 + +这些中间过程占着上下文位置,早期的关键信息被挤出有效窗口,模型对最初的问题描述的响应质量下降。 + +换个角度:你修 bug 的时候,会"开一个新终端"来追踪调用链。追踪完了,终端关掉,结果写进笔记,回到原来的终端继续修 bug。Agent 也需要这个能力:开一个独立的子进程,给它一个独立的消息列表,让它专心做一件事。 + +--- + +## 解决方案 + +![Subagent Overview](images/subagent-overview.svg) + +保留上一章的最小 hook 结构和 `todo_write` 工具,本章重点转向新增的 `task` 工具。调用它时,spawn 一个子 Agent,拥有全新的 `messages[]`,跑自己的循环,结束后只把摘要文本回传给主 Agent。对话上下文被丢弃,但文件系统的副作用(写文件、改文件、跑命令)保留在工作目录中。 + +子 Agent 的工具受限:有 bash/read/write/edit/glob,但没有 task,不能递归 spawn 新的子 Agent。子 Agent 的工具调用仍经过权限 hook,安全策略不因上下文隔离而跳过。 + +--- + +## 工作原理 + +**spawn_subagent**,给子 Agent 一个全新的 messages 列表,跑自己的循环,只回传结论: + +```python +def spawn_subagent(description: str) -> str: + # 子 Agent 的工具:基础工具,但没有 task(禁止递归) + sub_tools = [ + {"name": "bash", ...}, {"name": "read_file", ...}, + {"name": "write_file", ...}, {"name": "edit_file", ...}, + {"name": "glob", ...}, + ] + messages = [{"role": "user", "content": description}] # 全新 messages[] + + for _ in range(30): # safety limit + response = client.messages.create( + model=MODEL, system=SUB_SYSTEM, + messages=messages, tools=sub_tools, max_tokens=8000, + ) + messages.append({"role": "assistant", "content": response.content}) + if response.stop_reason != "tool_use": + break + results = [] + for block in response.content: + if block.type == "tool_use": + blocked = trigger_hooks("PreToolUse", block) + if blocked: + results.append({... "content": str(blocked)}) + continue + handler = SUB_HANDLERS.get(block.name) + output = handler(**block.input) if handler else f"Unknown" + trigger_hooks("PostToolUse", block, output) + results.append({... "content": output}) + messages.append({"role": "user", "content": results}) + + # 只返回最后的文本结论,中间过程全部丢弃 + return extract_text(messages[-1]["content"]) +``` + +主 Agent 调用时,跟调其他工具一样: + +```python +TOOLS = [ + {"name": "bash", ...}, + {"name": "read_file", ...}, + {"name": "write_file", ...}, + {"name": "edit_file", ...}, + {"name": "glob", ...}, + {"name": "todo_write", ...}, + # s06: 新增 task 工具 + {"name": "task", + "description": "Launch a subagent to handle a complex subtask. Returns only the final conclusion.", + "input_schema": {"type": "object", "properties": {"description": {"type": "string"}}, "required": ["description"]}}, +] + +TOOL_HANDLERS["task"] = spawn_subagent +``` + +三个关键设计决策: + +| 决策 | 选择 | 原因 | +|------|------|------| +| 上下文隔离 | 全新 `messages[]` | 子 Agent 的中间过程不污染主 Agent 的上下文 | +| 只回传结论 | `extract_text(last_message)` | 不是回传整个 messages 列表 | +| 禁止递归 | 子 Agent 无 task 工具 | 防止子 Agent 再 spawn 新的子 Agent | +| 安全策略不跳过 | 子 Agent 工具调用也走 PreToolUse hook | 上下文隔离不代表权限隔离 | + +dispatch 机制不变,task 工具通过 `TOOL_HANDLERS[block.name]` 分发。子 Agent 有独立的 `SUB_SYSTEM` 提示,明确要求"直接完成任务,不要再委派"。 + +--- + +## 相对 s05 的变更 + +| 组件 | 之前 (s05) | 之后 (s06) | +|------|-----------|-----------| +| 工具数量 | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) | +| 新函数 | — | spawn_subagent(独立 messages[] + 30 轮安全限制) | +| 上下文隔离 | 全部在主对话中 | 子 Agent 用全新的 messages[] | +| 循环 | 不变 | dispatch 不变,子 Agent 有独立 SUB_SYSTEM 和 hook 保护的循环 | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s06_subagent/code.py +``` + +试试这些 prompt: + +1. `Use a subtask to find what testing framework this project uses`(子 Agent 去读文件,主 Agent 只收结论) +2. `Delegate: read all .py files in agents/ and summarize what each one does` +3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent` + +观察重点:是否出现 `[Subagent spawned]` / `[Subagent done]`?子 Agent 的工具调用是否以 `[sub] ...` 输出?主 Agent 最后是否只继续处理子 Agent 返回的摘要? + +--- + +## 接下来 + +Agent 现在能拆任务了。但每个任务需要的知识不一样:改前端组件需要知道 React 规范,写 SQL 需要知道表结构。这些知识全塞进 system prompt,上下文直接爆了。 + +s07 Skill Loading → 技能按需注入,不在 system prompt 里堆文档。用到的时候才加载,和读文件一样自然。 + +
+深入 Claude Code 源码 + +> 以下基于 Claude Code 源码 `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` 的完整分析。 + +### 一、不是一种模式,是三种 + +教学版只讲了"全新的 messages[]"。Claude Code 实际有三种执行模式: + +| 模式 | 触发条件 | 上下文 | +|------|---------|--------| +| **Normal Subagent** | 指定了 `subagent_type`(normal path) | 全新 messages[],只有 prompt | +| **Fork Subagent** | 没指定 `subagent_type`,fork gate 开启 | 通过 `buildForkedMessages()` 构造 cache-friendly 前缀,共享 prompt cache | +| **General-Purpose** | 没指定 `subagent_type`,fork gate 关闭 | 同 Normal | + +### 二、Fork 模式:为了共享 Prompt Cache + +这是教学版没有的核心概念。Fork 模式(`forkSubagent.ts:60-71`)不创建全新上下文,而是通过 `buildForkedMessages()`(`forkSubagent.ts:107-168`)构造 cache-friendly 消息前缀,保留父 assistant message 并生成 placeholder tool results。目的不是隔离,而是让 Anthropic API 的 prompt cache 命中:父子 Agent 的 system prompt、tools、messages 前缀完全一致,API 端不需要重算。 + +缓存命中的五个关键组件(`forkedAgent.ts:57-68`):system prompt、tools、model、messages 前缀、thinking config,必须字节级一致。 + +### 三、Context Isolation 的精确粒度 + +`createSubagentContext()`(`forkedAgent.ts:345-462`)创建子 Agent 的 `ToolUseContext`: + +| 字段 | 行为 | +|------|------| +| `abortController` | 新的 child controller,父 abort 向下传播 | +| `setAppState` | 默认 no-op;但 sync agent 通过 `shareSetAppState` 共享(`runAgent.ts:697-714`) | +| `readFileState` | **从父克隆**(避免重复读相同文件) | +| `queryTracking` | 新 chainId,`depth = parentDepth + 1` | + +子 Agent 不是完全隔离的:文件读取状态是共享的。UI 和通知的隔离程度取决于执行路径(sync/async/fork/teammate 各不同)。 + +### 四、递归 Fork 防护 + +教学版用"子 Agent 不给 task 工具"表达递归保护。真实实现更精细:`isInForkChild()`(`forkSubagent.ts:78-89`)检查对话历史中是否有 `FORK_BOILERPLATE_TAG`,有就拒绝。但 `constants/tools.ts:36-46` 中 `Agent` 工具默认在所有 agent 的禁用集合里,`USER_TYPE === 'ant'` 时例外;`forkSubagent.ts:73-89` 针对 fork child 有专门的递归保护;`agentToolUtils.ts:100-110` 在 teammate 场景下有特殊放行。不是简单的"禁止新的子 Agent"。 + +### 五、Permission Bubbling + +Fork Agent 的 `permissionMode: 'bubble'`(`forkSubagent.ts:67`)意味着子 Agent 的权限弹窗冒泡到父终端,用户在主终端里审批子 Agent 的操作。 + +### 六、Async vs Sync + +教学版只展示了同步子 Agent(父等着子跑完)。Claude Code 还支持异步路径(`AgentTool.tsx:686-764`):`run_in_background: true` 时异步启动,返回 `{ status: 'async_launched' }` 立即给父 Agent,子 Agent 完成后通过通知机制告知父 Agent。实际触发条件不止 `run_in_background`,还有 auto-background、assistant force async、coordinator/proactive 等路径。 + +### 教学版的简化是刻意的 + +- 三种模式 → 一种(fresh messages):概念清晰 +- Prompt cache 共享 → 省略:教学版不涉及 API 层优化 +- 递归 fork 防护 → 简化为"子 Agent 无 task 工具" +- Async → 省略(留给 s13):s06 先理解同步模型 + +
+ + diff --git a/s06_subagent/images/subagent-overview.en.svg b/s06_subagent/images/subagent-overview.en.svg deleted file mode 100644 index d6eb4d6f5..000000000 --- a/s06_subagent/images/subagent-overview.en.svg +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - Subagent — Independent messages[], All Intermediate Steps Discarded - - - - Parent Agent - - - - messages[] - - - - - - - LLM - - - - tool_use - - - - TOOL_HANDLERS - - - - Base Tools - bash / read / write / ... - - - - task → spawn - - - - tool_result - - - append messages[] - Normal tool results also append to messages[] - - - - Subagent (Fresh Context) - - - - messages = [task] - fresh — no parent history - - - - - - - LLM - - - - Own while loop (max 30 rounds) - bash · read · write · edit · glob - No task — recursive spawn forbidden - - - - Intermediate 30+ tool calls + results - All discarded ✗ - - - - ✓ Extract only final text → return to Parent - - - - - ① task desc - - - - - ② summary - - - - - - s05 Preserved: loop, hooks, todo_write, 6 base tools - - - s06 New: task tool + spawn_subagent() — independent messages[], returns only summary - - - - ① Parent → Sub: - task description (a short string) - ② Sub → Parent: - extract_text() (final conclusion only) - diff --git a/s06_subagent/images/subagent-overview.ja.svg b/s06_subagent/images/subagent-overview.ja.svg deleted file mode 100644 index 87a457049..000000000 --- a/s06_subagent/images/subagent-overview.ja.svg +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - Subagent — 独立した messages[]、中間過程はすべて破棄 - - - - 親 Agent - - - - messages[] - - - - - - - LLM - - - - tool_use - - - - TOOL_HANDLERS - - - - 基本ツール - bash / read / write / ... - - - - task → spawn - - - - tool_result - - - messages[] に追加 - 通常ツール結果も messages[] に戻る - - - - サブエージェント(新規コンテキスト) - - - - messages = [task] - 新規 — 親の会話を継承しない - - - - - - - LLM - - - - 独自の while ループ(最大 30 ラウンド) - bash · read · write · edit · glob - task なし — 再帰 spawn 禁止 - - - - 中間 30+ ラウンドのツール呼び出し + 結果 - すべて破棄 ✗ - - - - ✓ 最後のテキストのみ抽出 → 親に返却 - - - - - ① task 説明 - - - - - ② summary - - - - - - s05 保持:ループ、フック、todo_write、6 つの基本ツール - - - s06 新規:task ツール + spawn_subagent() — 独立 messages[]、要約のみ返却 - - - - ① 親 → サブ: - task description(短い文字列) - ② サブ → 親: - extract_text()(最終結論のみ) - diff --git a/s06_subagent/images/subagent-overview.svg b/s06_subagent/images/subagent-overview.svg index c18d660cc..ae5c8eab9 100644 --- a/s06_subagent/images/subagent-overview.svg +++ b/s06_subagent/images/subagent-overview.svg @@ -1,125 +1,142 @@ - + - - - - - - - - - - + + + + - - - - - - - - - - - Subagent — 独立 messages[],中间过程全部丢弃 - - - - Parent Agent - - - - messages[] - - - - - - - LLM - - - - tool_use - - - - TOOL_HANDLERS - - - - 基础工具 - bash / read / write / ... - - - - task → spawn - - - - tool_result - - - append messages[] - 普通工具结果也回填 messages[] - - - - Subagent (全新上下文) - - - - messages = [task] - fresh — 不继承父对话 - - - - - - - LLM - - - - 自己的 while 循环(最多 30 轮) - bash · read · write · edit · glob - 无 task — 禁止递归 spawn - - - - 中间 30+ 轮工具调用 + 结果 - 全部丢弃 ✗ - - - - ✓ 只提取最后一段文本 → 返回给 Parent - - - - - ① task 描述 - - - - - ② summary - - - - - - s05 保留:循环、hook、todo_write、6 个基础工具 - - - s06 新增:task 工具 + spawn_subagent() — 独立 messages[],只回传摘要 - - - - ① Parent → Sub: - task description(一小段文字) - ② Sub → Parent: - extract_text()(只有最终结论) - + + + + + Subagent — Independent messages[], Intermediate Steps Discarded + + + + + Parent Agent + + + + messages[] + + + + + + + LLM + + + + tool_use + + + + TOOL_HANDLERS + + + + Base Tools + bash/read/write/... + + + + task + spawn_subagent() + + + + tool_result + + + + + + + append to messages[] + + + + Subagent (Fresh Context) + + + + messages = [task] + fresh — no parent history + + + + + + + LLM + + + + Own while loop (max 30 rounds) + bash / read / write / edit / glob + No task tool — no recursive spawn + + + + 30+ intermediate tool calls + ALL DISCARDED + + + + extract_text() — only final conclusion returned + + + + + + + + 1 task desc + + + + + + + + 2 summary + + + + + + + 1 Parent -> Subagent + task description (short text prompt) + 2 Subagent -> Parent + extract_text() (final conclusion only) + + + + Key Design Decisions + + + Context isolation + Fresh messages[] per subagent + + + Return conclusion + extract_text(last_msg) + + + No recursion + No task tool in subagent + + + Security enforced + PreToolUse hooks still apply + + + + + \ No newline at end of file diff --git a/s07_skill_loading/README.en.md b/s07_skill_loading/README.en.md deleted file mode 100644 index 35399ddba..000000000 --- a/s07_skill_loading/README.en.md +++ /dev/null @@ -1,182 +0,0 @@ -# s07: Skill Loading — Load Only When Needed - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20 -> *"Load when needed, don't stuff the prompt"* — Inject via tool_result, not system prompt. -> -> **Harness Layer**: Knowledge — load on demand, don't fill the context. - ---- - -## The Problem - -Your project has a React component spec, a SQL style guide, and an API design doc. You want the Agent to follow these specs automatically. The most straightforward idea — stuff them all into the system prompt: - -```python -SYSTEM = ( - f"You are a coding agent. " - + open("docs/react-style.md").read() # 2000 lines - + open("docs/sql-style.md").read() # 1500 lines - + open("docs/api-design.md").read() # 3000 lines -) -``` - -6500 lines of system prompt. The Agent carries these docs on every LLM call — whether it's changing a CSS color or fixing a SQL query. 99% of the content is irrelevant to the current task, burning tokens for nothing. - ---- - -## The Solution - -![Skill Overview](images/skill-overview.en.svg) - -The minimal hook structure, `todo_write`, and sub-Agent from the previous chapter are preserved. This chapter focuses on the new `load_skill` tool. At startup, inject the skill catalog into the SYSTEM prompt; at runtime, register one more tool to load full content, spending tokens only when used. - -Two-level design: - -| Level | Location | Timing | Cost | -|-------|----------|--------|------| -| 1. Catalog | system prompt | Injected at startup (harness scans skills/) | ~100 tokens/skill, carried every turn | -| 2. Content | tool_result | When Agent calls load_skill; SKILL.md can guide later read_file/bash access to extra resources | ~2000 tokens/skill, on demand | - -The dispatch mechanism is unchanged, `load_skill` auto-dispatches via `TOOL_HANDLERS[block.name]`. - ---- - -## How It Works - -**skills/ directory**, one subdirectory per skill, each containing a `SKILL.md` file: - -``` -skills/ - agent-builder/SKILL.md - code-review/SKILL.md - mcp-builder/SKILL.md - pdf/SKILL.md -``` - -**Level 1: Inject catalog at startup**: the harness calls `_scan_skills()` at startup to scan the skills/ directory, parsing each SKILL.md's YAML frontmatter (`name`, `description`) into a `SKILL_REGISTRY` dictionary. `list_skills()` generates the catalog from the registry, injected into the SYSTEM prompt. The Agent sees "which skills I have available" every turn, with no extra API calls: - -```python -SKILL_REGISTRY: dict[str, dict] = {} - -def _scan_skills(): - if not SKILLS_DIR.exists(): - return - for d in sorted(SKILLS_DIR.iterdir()): - if not d.is_dir(): - continue - manifest = d / "SKILL.md" - if manifest.exists(): - raw = manifest.read_text() - meta, body = _parse_frontmatter(raw) - name = meta.get("name", d.name) - desc = meta.get("description", raw.split("\n")[0].lstrip("#").strip()) - SKILL_REGISTRY[name] = {"name": name, "description": desc, "content": raw} - -_scan_skills() # runs once at startup - -def list_skills() -> str: - return "\n".join(f"- **{s['name']}**: {s['description']}" for s in SKILL_REGISTRY.values()) - -def build_system() -> str: - catalog = list_skills() - return ( - f"You are a coding agent at {WORKDIR}. " - f"Skills available:\n{catalog}\n" - "Use load_skill to get full details when needed." - ) - -SYSTEM = build_system() -``` - -**Level 2: load_skill**: the Agent decides "I need the SQL style guide" and calls `load_skill("sql-style")`. Lookup goes through the registry, not file paths, eliminating path traversal risk. The SKILL.md content is injected via `tool_result`, and can include later access to referenced `references/`, `scripts/`, or `assets/` through the existing file and bash tools. - -```python -def load_skill(name: str) -> str: - skill = SKILL_REGISTRY.get(name) - if not skill: - return f"Skill not found: {name}" - return skill["content"] -``` - -The key distinction: skill content is not part of the system prompt. It enters the current messages as a tool result. Subsequent calls carry it along with the history until context compaction, truncation, or session end. This naturally connects to s08's compact: on-demand loading solves "don't carry what you shouldn't", compact solves "how to drop what you should." - ---- - -## Changes from s06 - -| Component | Before (s06) | After (s07) | -|-----------|-------------|-------------| -| Tool count | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) | -| Knowledge loading | None | Two-level: startup catalog in SYSTEM + runtime load_skill; SKILL.md may guide later resource access | -| SYSTEM prompt | Static string | Startup scan of skills/ injects catalog | -| Skill registry | None | SKILL_REGISTRY (populated at startup, prevents path traversal) | -| Loop | Unchanged | Unchanged (skill tool auto-dispatches) | - ---- - -## Try It - -```sh -cd learn-claude-code -python s07_skill_loading/code.py -``` - -Try these prompts: - -1. `What skills are available?` -2. `Load the code-review skill and follow its instructions` -3. `I need to do a code review -- load the relevant skill first` - -What to watch for: Does the Agent know available skills from the SYSTEM catalog? Does `[HOOK] load_skill` appear when full instructions are needed? Does the answer use the loaded skill's instructions? - ---- - -## What's Next - -On-demand loading solved "don't carry what you shouldn't." But another problem looms: after the Agent works for 30 minutes, the messages list fills up with intermediate process. Old tool_results, stale file contents, occupying context but adding no value. - -→ s08 Context Compact: A four-layer compaction strategy. Cheap layers run first, expensive layers run last. - -
-Dive into CC Source Code - -> The following is based on analysis of CC source code `loadSkillsDir.ts`, `SkillTool.ts`, `bundledSkills.ts`, `commands.ts`. - -### 1. Skill Sources: Not Just One skills/ Directory - -The teaching version assumes all skills live in a `skills/` directory. CC loads from multiple sources spread across multiple files: `loadSkillsDir.ts` handles user/project/`--add-dir` directories and legacy commands (`.claude/commands/`); `bundledSkills.ts` handles built-in skills; `SkillTool.ts` handles MCP remote skills; `commands.ts` handles command aggregation. Types include managed/policy skills, user skills (`~/.claude/skills/`), project skills (`.claude/skills/`), `--add-dir` skills, legacy commands, dynamic skills, conditional skills (with `paths` frontmatter, activated by file path), bundled skills, plugin skills, MCP skills. - -### 2. SKILL.md Frontmatter — Common Fields - -CC's SKILL.md YAML frontmatter is parsed by `parseSkillFrontmatterFields()` in `loadSkillsDir.ts`. Common fields include: - -| Field | Purpose | -|-------|---------| -| `name` / `description` | Display name and description | -| `when_to_use` | Guides the model on when to invoke | -| `allowed-tools` | Auto-allow list of tools available to the skill | -| `context` | `inline` (default) or `fork` (run as sub-Agent) | -| `model` | Model override (haiku/sonnet/opus/inherit) | -| `hooks` | Skill-level hook configuration | -| `paths` | Glob patterns for conditional activation | -| `user-invocable` | Users can invoke via `/name` | - -The complete field list changes across versions; above are the core fields relevant to the teaching version. - -### 3. Precise Implementation of Two-Level Loading - -1. **Catalog (at startup)**: `getSkillDirCommands()` scans directory → registers as `Command` objects containing only metadata. `getSkillListingAttachments()` formats the skill list as attachments, budgeted at ~1% of the context window (cap 8000 characters). -2. **Load (on invocation)**: Model calls `Skill` tool (input fields are `skill` + optional `args`; teaching version uses `name`) → `getPromptForCommand()` expands full SKILL.md content → `SkillTool` returns a tool_result with display text `"Launching skill: {name}"`, while the actual skill content is injected via `newMessages`. The teaching version merges both into "injected via tool_result" as a simplification; the loaded SKILL.md can still guide later access to referenced resources through existing file/bash tools. - -### The Teaching Version's Simplification Is Intentional - -- Multiple files and sources → 1 `skills/` directory: sufficient to demonstrate the core concept of two-level loading -- Multiple frontmatter fields → only parse name/description: reduces parsing complexity -- Forked skills (`context: 'fork'`) → omitted: the teaching version only expands inline skill loading -- `Skill` tool input `skill`+`args` → teaching version uses `name`: avoids extra argument parsing complexity - -
- - diff --git a/s07_skill_loading/README.ja.md b/s07_skill_loading/README.ja.md index 7e12baa86..91134a6bb 100644 --- a/s07_skill_loading/README.ja.md +++ b/s07_skill_loading/README.ja.md @@ -1,6 +1,6 @@ # s07: Skill Loading — 必要なときにだけ読み込む -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20 > *"Load when needed, don't stuff the prompt"* — tool_result で注入、system prompt には詰め込まない。 @@ -11,7 +11,7 @@ s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_c ## 課題 -プロジェクトには React コンポーネント仕様、SQL スタイルガイド、API 設計ドキュメントがある。Agent にこれらの仕様を自動的に守らせたい。最も直接的な方法 — すべて system prompt に詰め込む: +プロジェクトには React コンポーネント仕様、SQL スタイルガイド、API 設計ドキュメントがある。Agent にこれらの仕様を自動的に守らせたい。最も直接的な方法は、すべて system prompt に詰め込むこと: ```python SYSTEM = ( @@ -22,13 +22,13 @@ SYSTEM = ( ) ``` -6500 行の system prompt。Agent は LLM を呼び出すたびにこれらのドキュメントを運ぶ — CSS の色を変えるときも SQL クエリを修正するときも。99% の内容が現在のタスクと無関係で、トークンを無駄に消費する。 +6500 行の system prompt。Agent は LLM を呼び出すたびにこれらのドキュメントを運ぶ。CSS の色を変えるときも SQL クエリを修正するときも同様だ。99% の内容が現在のタスクと無関係で、トークンを無駄に消費する。 --- ## ソリューション -![Skill Overview](images/skill-overview.ja.svg) +![Skill Overview](images/skill-overview.svg) 前章の最小フック構造、`todo_write`、サブ Agent を維持し、本章は新規の `load_skill` ツールに注目する。起動時にスキルカタログを SYSTEM prompt に注入し、実行時に完全な内容を読み込むツールを登録する。使ったときだけトークンを消費。 @@ -100,7 +100,7 @@ def load_skill(name: str) -> str: return skill["content"] ``` -重要な違い:スキル内容は system prompt の一部ではなく、ツール結果として現在の messages に入る。後続の呼び出しでは履歴とともに携帯され、コンテキスト圧縮、切り捨て、またはセッション終了まで保持される。これは s08 の compact と自然に接続する:オンデマンド読み込みで「運ぶべきでないものは運ばない」を解決し、compact が「捨てるべきものをどう捨てるか」を解決する。 +重要な違い:スキル内容は system prompt の一部ではなく、ツール結果として現在の messages に入る。後続の呼び出しでは履歴とともに携帯され、コンテキスト圧縮、切り捨て、またはセッション終了まで保持される。これは s08 の compact と自然に接続する:オンデマンド読み込みにより、無関係なドキュメントが system prompt に入らなくなる。compact が「捨てるべきものをどう捨てるか」を解決する。 --- @@ -135,22 +135,22 @@ python s07_skill_loading/code.py ## 次へ -オンデマンド読み込みで「運ぶべきでないものは運ばない」問題は解決した。しかし別の問題が待っている:Agent が 30 分連続で作業すると、messages リストが中間プロセスで埋め尽くされる。古い tool_result、期限切れのファイル内容、コンテキストを占領しているが価値を生まない。 +load_skill で起動時のトークン浪費は解消した。しかし別の問題が待っている:Agent が 30 分連続で作業すると、messages リストが中間プロセスで埋め尽くされる。古い tool_result、期限切れのファイル内容、コンテキストを占領しているが価値を生まない。 → s08 Context Compact:4 層圧縮戦略。安価な層を先に実行、高価な層を後に実行。
-CC ソースコードを深掘り +Claude Code ソースコードを深掘り -> 以下は CC ソースコード `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` の分析に基づく。 +> 以下は Claude Code ソースコード `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` の分析に基づく。 ### 一、スキルソース:skills/ ディレクトリだけではない -教育版はすべてのスキルが `skills/` ディレクトリにあると想定している。CC は実際に複数のファイルに分散したソースから読み込む:`loadSkillsDir.ts` は user/project/`--add-dir` ディレクトリと legacy commands(`.claude/commands/`)を担当、`bundledSkills.ts` は組み込みスキル、`SkillTool.ts` は MCP リモートスキル、`commands.ts` はコマンド集約を担当。タイプには managed/policy skills、user skills(`~/.claude/skills/`)、project skills(`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills(`paths` frontmatter を持ち、ファイルパスでアクティベート)、bundled skills、plugin skills、MCP skills が含まれる。 +教育版はすべてのスキルが `skills/` ディレクトリにあると想定している。Claude Code は実際に複数のファイルに分散したソースから読み込む:`loadSkillsDir.ts` は user/project/`--add-dir` ディレクトリと legacy commands(`.claude/commands/`)を担当、`bundledSkills.ts` は組み込みスキル、`SkillTool.ts` は MCP リモートスキル、`commands.ts` はコマンド集約を担当。タイプには managed/policy skills、user skills(`~/.claude/skills/`)、project skills(`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills(`paths` frontmatter を持ち、ファイルパスでアクティベート)、bundled skills、plugin skills、MCP skills が含まれる。 ### 二、SKILL.md Frontmatter の一般的なフィールド -CC の SKILL.md YAML frontmatter は `parseSkillFrontmatterFields()`(`loadSkillsDir.ts`)で解析される。一般的なフィールド: +Claude Code の SKILL.md YAML frontmatter は `parseSkillFrontmatterFields()`(`loadSkillsDir.ts`)で解析される。一般的なフィールド: | フィールド | 用途 | |-----------|------| diff --git a/s07_skill_loading/README.md b/s07_skill_loading/README.md index 8b53c7644..31d6f6cb5 100644 --- a/s07_skill_loading/README.md +++ b/s07_skill_loading/README.md @@ -1,51 +1,51 @@ -# s07: Skill Loading — 用到的时候才加载 +# s07: Skill Loading — Load Only When Needed -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20 -> *"用到时再加载, 别全塞 prompt 里"* — 通过 tool_result 注入, 不塞 system prompt。 +> *"Load when needed, don't stuff the prompt"* — Inject via tool_result, not system prompt. > -> **Harness 层**: 知识 — 按需加载, 不堆满上下文。 +> **Harness Layer**: Knowledge — load on demand, don't fill the context. --- -## 问题 +## The Problem -你的项目有一套 React 组件规范、一份 SQL 风格指南、一份 API 设计文档。你希望 Agent 自动遵守这些规范。最直接的想法,全塞进 system prompt: +Your project has a React component spec, a SQL style guide, and an API design doc. You want the Agent to follow these specs automatically. The most straightforward idea: stuff them all into the system prompt: ```python SYSTEM = ( f"You are a coding agent. " - + open("docs/react-style.md").read() # 2000 行 - + open("docs/sql-style.md").read() # 1500 行 - + open("docs/api-design.md").read() # 3000 行 + + open("docs/react-style.md").read() # 2000 lines + + open("docs/sql-style.md").read() # 1500 lines + + open("docs/api-design.md").read() # 3000 lines ) ``` -6500 行 system prompt。Agent 每次调用 LLM 都带着这些文档——不管是在改 CSS 颜色还是修 SQL 查询。99% 的内容和当前任务无关,白白消耗 token。 +6500 lines of system prompt. The Agent carries these docs on every LLM call, whether it's changing a CSS color or fixing a SQL query. 99% of the content is irrelevant to the current task, burning tokens for nothing. --- -## 解决方案 +## The Solution ![Skill Overview](images/skill-overview.svg) -保留上一章的最小 hook 结构、`todo_write` 和子 Agent,本章重点转向新增的 `load_skill` 工具。启动时把技能目录注入 SYSTEM prompt,运行时多注册一个工具加载完整内容,用到才花 token。 +The minimal hook structure, `todo_write`, and sub-Agent from the previous chapter are preserved. This chapter focuses on the new `load_skill` tool. At startup, inject the skill catalog into the SYSTEM prompt; at runtime, register one more tool to load full content, spending tokens only when used. -两层设计: +Two-level design: -| 层 | 位置 | 时机 | 代价 | -|---|------|------|------| -| 1. 目录 | system prompt | 启动时注入(harness 扫描 skills/) | ~100 tokens/skill,每轮都带 | -| 2. 内容 | tool_result | Agent 调用 load_skill 时;SKILL.md 可指引后续的 read_file/bash 调用,用于按需访问额外资源 | ~2000 tokens/skill,按需 | +| Level | Location | Timing | Cost | +|-------|----------|--------|------| +| 1. Catalog | system prompt | Injected at startup (harness scans skills/) | ~100 tokens/skill, carried every turn | +| 2. Content | tool_result | When Agent calls load_skill; SKILL.md can guide later read_file/bash access to extra resources | ~2000 tokens/skill, on demand | -dispatch 机制不变,load_skill 通过 `TOOL_HANDLERS[block.name]` 分发。 +The dispatch mechanism is unchanged, `load_skill` auto-dispatches via `TOOL_HANDLERS[block.name]`. --- -## 工作原理 +## How It Works -**skills/ 目录**,每个技能一个子目录,包含 `SKILL.md` 文件: +**skills/ directory**, one subdirectory per skill, each containing a `SKILL.md` file: ``` skills/ @@ -55,7 +55,7 @@ skills/ pdf/SKILL.md ``` -**第一级:启动时注入目录**:harness 启动时调用 `_scan_skills()` 扫描 skills/ 目录,解析每个 SKILL.md 的 YAML frontmatter(`name`、`description`),存入 `SKILL_REGISTRY` 字典。`list_skills()` 从注册表生成目录,注入 SYSTEM prompt。Agent 每轮都能看到"我有哪些技能可用",不花额外 API 调用: +**Level 1: Inject catalog at startup**: the harness calls `_scan_skills()` at startup to scan the skills/ directory, parsing each SKILL.md's YAML frontmatter (`name`, `description`) into a `SKILL_REGISTRY` dictionary. `list_skills()` generates the catalog from the registry, injected into the SYSTEM prompt. The Agent sees "which skills I have available" every turn, with no extra API calls: ```python SKILL_REGISTRY: dict[str, dict] = {} @@ -90,7 +90,7 @@ def build_system() -> str: SYSTEM = build_system() ``` -**第二级:load_skill**:Agent 决定"我需要 SQL 风格指南",调用 `load_skill("sql-style")`。通过注册表查找,不走文件路径,没有路径遍历风险。SKILL.md 内容通过 `tool_result` 注入,并可通过现有的 file 和 bash 工具进一步访问引用的 `references/`、`scripts/` 或 `assets/`。 +**Level 2: load_skill**: the Agent decides "I need the SQL style guide" and calls `load_skill("sql-style")`. Lookup goes through the registry, not file paths, eliminating path traversal risk. The SKILL.md content is injected via `tool_result`, and can include later access to referenced `references/`, `scripts/`, or `assets/` through the existing file and bash tools. ```python def load_skill(name: str) -> str: @@ -100,82 +100,82 @@ def load_skill(name: str) -> str: return skill["content"] ``` -关键区别:技能内容不是 system prompt 的一部分,它作为一次工具结果进入当前 messages。后续调用会随历史一起携带,直到上下文压缩、截断或会话结束。这和 s08 的 compact 自然衔接:按需加载解决了"不该提前带的不要带",compact 解决"该丢的怎么丢"。 +The key distinction: skill content is not part of the system prompt. It enters the current messages as a tool result. Subsequent calls carry it along with the history until context compaction, truncation, or session end. This naturally connects to s08's compact: on-demand loading keeps irrelevant docs out of the system prompt, compact solves "how to drop what you should." --- -## 相对 s06 的变更 +## Changes from s06 -| 组件 | 之前 (s06) | 之后 (s07) | -|------|-----------|-----------| -| 工具数量 | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) | -| 知识加载 | 无 | 两级:启动时目录注入 SYSTEM + 运行时 load_skill;SKILL.md 可指引后续资源访问 | -| SYSTEM 提示 | 静态字符串 | 启动时扫描 skills/ 注入目录 | -| 技能注册表 | 无 | SKILL_REGISTRY(启动时填充,防路径遍历) | -| 循环 | 不变 | 不变(skill 工具自动分发) | +| Component | Before (s06) | After (s07) | +|-----------|-------------|-------------| +| Tool count | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) | +| Knowledge loading | None | Two-level: startup catalog in SYSTEM + runtime load_skill; SKILL.md may guide later resource access | +| SYSTEM prompt | Static string | Startup scan of skills/ injects catalog | +| Skill registry | None | SKILL_REGISTRY (populated at startup, prevents path traversal) | +| Loop | Unchanged | Unchanged (skill tool auto-dispatches) | --- -## 试一下 +## Try It ```sh cd learn-claude-code python s07_skill_loading/code.py ``` -试试这些 prompt: +Try these prompts: 1. `What skills are available?` 2. `Load the code-review skill and follow its instructions` 3. `I need to do a code review -- load the relevant skill first` -观察重点:Agent 是否直接从 SYSTEM 里的目录知道有哪些技能?需要完整规范时是否出现 `[HOOK] load_skill`?加载后回答是否使用了对应 skill 的说明? +What to watch for: Does the Agent know available skills from the SYSTEM catalog? Does `[HOOK] load_skill` appear when full instructions are needed? Does the answer use the loaded skill's instructions? --- -## 接下来 +## What's Next -按需加载解决了"不该带的不要带"。但另一个问题来了:Agent 连续工作 30 分钟后,messages 列表塞满了中间过程。旧的 tool_result、过时的文件内容,占着上下文但不产生价值。 +load_skill eliminated the startup token waste. But another problem looms: after the Agent works for 30 minutes, the messages list fills up with intermediate process. Old tool_results, stale file contents, occupying context but adding no value. -s08 Context Compact → 四层压缩策略。便宜的先跑,贵的后跑。 +→ s08 Context Compact: A four-layer compaction strategy. Cheap layers run first, expensive layers run last.
-深入 CC 源码 +Dive into Claude Code Source Code -> 以下基于 CC 源码 `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` 的分析。 +> The following is based on analysis of Claude Code source code `loadSkillsDir.ts`, `SkillTool.ts`, `bundledSkills.ts`, `commands.ts`. -### 一、技能来源:不是只有一个 skills/ 目录 +### 1. Skill Sources: Not Just One skills/ Directory -教学版假设所有技能在 `skills/` 目录下。CC 实际从多个来源加载,分布在多个文件中:`loadSkillsDir.ts` 负责从 user/project/`--add-dir` 目录和 legacy commands(`.claude/commands/`)加载;`bundledSkills.ts` 负责内置技能;`SkillTool.ts` 处理 MCP 远程技能;`commands.ts` 负责命令聚合。类型包括 managed/policy skills、user skills(`~/.claude/skills/`)、project skills(`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills(带 `paths` frontmatter,按文件路径激活)、bundled skills、plugin skills、MCP skills。 +The teaching version assumes all skills live in a `skills/` directory. Claude Code loads from multiple sources spread across multiple files: `loadSkillsDir.ts` handles user/project/`--add-dir` directories and legacy commands (`.claude/commands/`); `bundledSkills.ts` handles built-in skills; `SkillTool.ts` handles MCP remote skills; `commands.ts` handles command aggregation. Types include managed/policy skills, user skills (`~/.claude/skills/`), project skills (`.claude/skills/`), `--add-dir` skills, legacy commands, dynamic skills, conditional skills (with `paths` frontmatter, activated by file path), bundled skills, plugin skills, MCP skills. -### 二、SKILL.md Frontmatter 常见字段 +### 2. SKILL.md Frontmatter — Common Fields -CC 的 SKILL.md YAML frontmatter 由 `parseSkillFrontmatterFields()` 解析(`loadSkillsDir.ts`),常见字段包括: +Claude Code's SKILL.md YAML frontmatter is parsed by `parseSkillFrontmatterFields()` in `loadSkillsDir.ts`. Common fields include: -| 字段 | 用途 | -|------|------| -| `name` / `description` | 显示名称和描述 | -| `when_to_use` | 指导模型何时调用 | -| `allowed-tools` | 技能可用工具的自动允许列表 | -| `context` | `inline`(默认)或 `fork`(作为子 Agent 运行) | -| `model` | 模型覆盖(haiku/sonnet/opus/inherit) | -| `hooks` | 技能级别的 hook 配置 | -| `paths` | 条件激活的 glob 模式 | -| `user-invocable` | 用户可以通过 `/name` 调用 | +| Field | Purpose | +|-------|---------| +| `name` / `description` | Display name and description | +| `when_to_use` | Guides the model on when to invoke | +| `allowed-tools` | Auto-allow list of tools available to the skill | +| `context` | `inline` (default) or `fork` (run as sub-Agent) | +| `model` | Model override (haiku/sonnet/opus/inherit) | +| `hooks` | Skill-level hook configuration | +| `paths` | Glob patterns for conditional activation | +| `user-invocable` | Users can invoke via `/name` | -完整字段列表随版本迭代会变化,以上仅列出教学版涉及的核心字段。 +The complete field list changes across versions; above are the core fields relevant to the teaching version. -### 三、两级加载的精确实现 +### 3. Precise Implementation of Two-Level Loading -1. **Catalog(启动时)**:`getSkillDirCommands()` 扫描目录 → 注册为 `Command` 对象,只包含元数据。`getSkillListingAttachments()` 把技能列表格式化为附件,预算为上下文窗口的 ~1%(上限 8000 字符)。 -2. **Load(调用时)**:模型调 `Skill` 工具(输入字段是 `skill` + 可选 `args`,教学版用 `name`)→ `getPromptForCommand()` 展开完整 SKILL.md 内容 → `SkillTool` 返回的 tool_result 展示文本只是 `"Launching skill: {name}"`,真正的技能内容通过 `newMessages` 注入对话。教学版把两者合并为"通过 tool_result 注入"是一种简化;加载后的 SKILL.md 仍可作为指引,帮助模型后续通过现有 file/bash 工具访问相关资源。 +1. **Catalog (at startup)**: `getSkillDirCommands()` scans directory → registers as `Command` objects containing only metadata. `getSkillListingAttachments()` formats the skill list as attachments, budgeted at ~1% of the context window (cap 8000 characters). +2. **Load (on invocation)**: Model calls `Skill` tool (input fields are `skill` + optional `args`; teaching version uses `name`) → `getPromptForCommand()` expands full SKILL.md content → `SkillTool` returns a tool_result with display text `"Launching skill: {name}"`, while the actual skill content is injected via `newMessages`. The teaching version merges both into "injected via tool_result" as a simplification; the loaded SKILL.md can still guide later access to referenced resources through existing file/bash tools. -### 教学版的简化是刻意的 +### The Teaching Version's Simplification Is Intentional -- 多文件多来源 → 1 个 `skills/` 目录:足以展示两级加载的核心概念 -- 多个 frontmatter 字段 → 只解析 name/description:减少解析复杂度 -- forked skills(`context: 'fork'`)→ 省略:教学版只展开 inline 技能加载 -- `Skill` 工具输入 `skill`+`args` → 教学版用 `name`:避免参数解析的额外复杂度 +- Multiple files and sources → 1 `skills/` directory: sufficient to demonstrate the core concept of two-level loading +- Multiple frontmatter fields → only parse name/description: reduces parsing complexity +- Forked skills (`context: 'fork'`) → omitted: the teaching version only expands inline skill loading +- `Skill` tool input `skill`+`args` → teaching version uses `name`: avoids extra argument parsing complexity
diff --git a/s07_skill_loading/README.zh.md b/s07_skill_loading/README.zh.md new file mode 100644 index 000000000..183b2afeb --- /dev/null +++ b/s07_skill_loading/README.zh.md @@ -0,0 +1,182 @@ +# s07: Skill Loading — 用到的时候才加载 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20 +> *"用到时再加载, 别全塞 prompt 里"* — 通过 tool_result 注入, 不塞 system prompt。 +> +> **Harness 层**: 知识 — 按需加载, 不堆满上下文。 + +--- + +## 问题 + +你的项目有一套 React 组件规范、一份 SQL 风格指南、一份 API 设计文档。你希望 Agent 自动遵守这些规范。最直接的想法,全塞进 system prompt: + +```python +SYSTEM = ( + f"You are a coding agent. " + + open("docs/react-style.md").read() # 2000 行 + + open("docs/sql-style.md").read() # 1500 行 + + open("docs/api-design.md").read() # 3000 行 +) +``` + +6500 行 system prompt。Agent 每次调用 LLM 都带着这些文档——不管是在改 CSS 颜色还是修 SQL 查询。99% 的内容和当前任务无关,白白消耗 token。 + +--- + +## 解决方案 + +![Skill Overview](images/skill-overview.svg) + +保留上一章的最小 hook 结构、`todo_write` 和子 Agent,本章重点转向新增的 `load_skill` 工具。启动时把技能目录注入 SYSTEM prompt,运行时多注册一个工具加载完整内容,用到才花 token。 + +两层设计: + +| 层 | 位置 | 时机 | 代价 | +|---|------|------|------| +| 1. 目录 | system prompt | 启动时注入(harness 扫描 skills/) | ~100 tokens/skill,每轮都带 | +| 2. 内容 | tool_result | Agent 调用 load_skill 时;SKILL.md 可指引后续的 read_file/bash 调用,用于按需访问额外资源 | ~2000 tokens/skill,按需 | + +dispatch 机制不变,load_skill 通过 `TOOL_HANDLERS[block.name]` 分发。 + +--- + +## 工作原理 + +**skills/ 目录**,每个技能一个子目录,包含 `SKILL.md` 文件: + +``` +skills/ + agent-builder/SKILL.md + code-review/SKILL.md + mcp-builder/SKILL.md + pdf/SKILL.md +``` + +**第一级:启动时注入目录**:harness 启动时调用 `_scan_skills()` 扫描 skills/ 目录,解析每个 SKILL.md 的 YAML frontmatter(`name`、`description`),存入 `SKILL_REGISTRY` 字典。`list_skills()` 从注册表生成目录,注入 SYSTEM prompt。Agent 每轮都能看到"我有哪些技能可用",不花额外 API 调用: + +```python +SKILL_REGISTRY: dict[str, dict] = {} + +def _scan_skills(): + if not SKILLS_DIR.exists(): + return + for d in sorted(SKILLS_DIR.iterdir()): + if not d.is_dir(): + continue + manifest = d / "SKILL.md" + if manifest.exists(): + raw = manifest.read_text() + meta, body = _parse_frontmatter(raw) + name = meta.get("name", d.name) + desc = meta.get("description", raw.split("\n")[0].lstrip("#").strip()) + SKILL_REGISTRY[name] = {"name": name, "description": desc, "content": raw} + +_scan_skills() # runs once at startup + +def list_skills() -> str: + return "\n".join(f"- **{s['name']}**: {s['description']}" for s in SKILL_REGISTRY.values()) + +def build_system() -> str: + catalog = list_skills() + return ( + f"You are a coding agent at {WORKDIR}. " + f"Skills available:\n{catalog}\n" + "Use load_skill to get full details when needed." + ) + +SYSTEM = build_system() +``` + +**第二级:load_skill**:Agent 决定"我需要 SQL 风格指南",调用 `load_skill("sql-style")`。通过注册表查找,不走文件路径,没有路径遍历风险。SKILL.md 内容通过 `tool_result` 注入,并可通过现有的 file 和 bash 工具进一步访问引用的 `references/`、`scripts/` 或 `assets/`。 + +```python +def load_skill(name: str) -> str: + skill = SKILL_REGISTRY.get(name) + if not skill: + return f"Skill not found: {name}" + return skill["content"] +``` + +关键区别:技能内容不是 system prompt 的一部分,它作为一次工具结果进入当前 messages。后续调用会随历史一起携带,直到上下文压缩、截断或会话结束。这和 s08 的 compact 自然衔接:技能内容以 tool_result 形式进入 messages,不塞 system prompt,compact 解决"该丢的怎么丢"。 + +--- + +## 相对 s06 的变更 + +| 组件 | 之前 (s06) | 之后 (s07) | +|------|-----------|-----------| +| 工具数量 | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) | +| 知识加载 | 无 | 两级:启动时目录注入 SYSTEM + 运行时 load_skill;SKILL.md 可指引后续资源访问 | +| SYSTEM 提示 | 静态字符串 | 启动时扫描 skills/ 注入目录 | +| 技能注册表 | 无 | SKILL_REGISTRY(启动时填充,防路径遍历) | +| 循环 | 不变 | 不变(skill 工具自动分发) | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s07_skill_loading/code.py +``` + +试试这些 prompt: + +1. `What skills are available?` +2. `Load the code-review skill and follow its instructions` +3. `I need to do a code review -- load the relevant skill first` + +观察重点:Agent 是否直接从 SYSTEM 里的目录知道有哪些技能?需要完整规范时是否出现 `[HOOK] load_skill`?加载后回答是否使用了对应 skill 的说明? + +--- + +## 接下来 + +load_skill 解决了启动时的 token 浪费。但另一个问题来了:Agent 连续工作 30 分钟后,messages 列表塞满了中间过程。旧的 tool_result、过时的文件内容,占着上下文但不产生价值。 + +s08 Context Compact → 四层压缩策略。便宜的先跑,贵的后跑。 + +
+深入 Claude Code 源码 + +> 以下基于 Claude Code 源码 `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` 的分析。 + +### 一、技能来源:不是只有一个 skills/ 目录 + +教学版假设所有技能在 `skills/` 目录下。Claude Code 实际从多个来源加载,分布在多个文件中:`loadSkillsDir.ts` 负责从 user/project/`--add-dir` 目录和 legacy commands(`.claude/commands/`)加载;`bundledSkills.ts` 负责内置技能;`SkillTool.ts` 处理 MCP 远程技能;`commands.ts` 负责命令聚合。类型包括 managed/policy skills、user skills(`~/.claude/skills/`)、project skills(`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills(带 `paths` frontmatter,按文件路径激活)、bundled skills、plugin skills、MCP skills。 + +### 二、SKILL.md Frontmatter 常见字段 + +Claude Code 的 SKILL.md YAML frontmatter 由 `parseSkillFrontmatterFields()` 解析(`loadSkillsDir.ts`),常见字段包括: + +| 字段 | 用途 | +|------|------| +| `name` / `description` | 显示名称和描述 | +| `when_to_use` | 指导模型何时调用 | +| `allowed-tools` | 技能可用工具的自动允许列表 | +| `context` | `inline`(默认)或 `fork`(作为子 Agent 运行) | +| `model` | 模型覆盖(haiku/sonnet/opus/inherit) | +| `hooks` | 技能级别的 hook 配置 | +| `paths` | 条件激活的 glob 模式 | +| `user-invocable` | 用户可以通过 `/name` 调用 | + +完整字段列表随版本迭代会变化,以上仅列出教学版涉及的核心字段。 + +### 三、两级加载的精确实现 + +1. **Catalog(启动时)**:`getSkillDirCommands()` 扫描目录 → 注册为 `Command` 对象,只包含元数据。`getSkillListingAttachments()` 把技能列表格式化为附件,预算为上下文窗口的 ~1%(上限 8000 字符)。 +2. **Load(调用时)**:模型调 `Skill` 工具(输入字段是 `skill` + 可选 `args`,教学版用 `name`)→ `getPromptForCommand()` 展开完整 SKILL.md 内容 → `SkillTool` 返回的 tool_result 展示文本只是 `"Launching skill: {name}"`,真正的技能内容通过 `newMessages` 注入对话。教学版把两者合并为"通过 tool_result 注入"是一种简化;加载后的 SKILL.md 仍可作为指引,帮助模型后续通过现有 file/bash 工具访问相关资源。 + +### 教学版的简化是刻意的 + +- 多文件多来源 → 1 个 `skills/` 目录:足以展示两级加载的核心概念 +- 多个 frontmatter 字段 → 只解析 name/description:减少解析复杂度 +- forked skills(`context: 'fork'`)→ 省略:教学版只展开 inline 技能加载 +- `Skill` 工具输入 `skill`+`args` → 教学版用 `name`:避免参数解析的额外复杂度 + +
+ + diff --git a/s07_skill_loading/images/skill-overview.en.svg b/s07_skill_loading/images/skill-overview.en.svg deleted file mode 100644 index ff31907ed..000000000 --- a/s07_skill_loading/images/skill-overview.en.svg +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - Skill Loading — catalog at startup, content on demand - - - History preserved - - - - messages[] - - - - - - - LLM - stop_reason=tool_use? - - - - No - - Return result - - - - Yes - - - - trigger_hooks - PreToolUse - - - - - - - TOOL_HANDLERS - - - - bash · read · write - - edit · glob · todo - - task (subagent) - - - load_skill - - - - Results appended to messages[], loop continues - - - - s07 new - - - - ① build_system() - Scan skills/ first line at startup - → inject SYSTEM prompt - - - - ② load_skill(name) - Read full SKILL.md at runtime - → inject tool_result - - - - SYSTEM has skill catalog, carried every turn - - - - - - - - History preserved (loop, hooks, TODO, subagent — unchanged) - - s07 new (startup catalog in SYSTEM + load_skill tool) - diff --git a/s07_skill_loading/images/skill-overview.ja.svg b/s07_skill_loading/images/skill-overview.ja.svg deleted file mode 100644 index 596dcd5b6..000000000 --- a/s07_skill_loading/images/skill-overview.ja.svg +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - Skill Loading — 起動時にカタログ注入、実行時にオンデマンド読み込み - - - 過去章を保持 - - - - messages[] - - - - - - - LLM - stop_reason=tool_use? - - - - No - - 結果を返す - - - - Yes - - - - trigger_hooks - PreToolUse - - - - - - - TOOL_HANDLERS - - - - bash · read · write - - edit · glob · todo - - task (subagent) - - - load_skill - - - - 結果を messages[] に追加、ループ継続 - - - - s07 新規 - - - - ① build_system() - 起動時に skills/ の 1 行目をスキャン - → SYSTEM プロンプトに注入 - - - - ② load_skill(name) - 実行時に完全な SKILL.md を読み取り - → tool_result に注入 - - - - SYSTEM にスキルカタログ、毎ターン携帯 - - - - - - - - 過去章を保持(ループ、フック、TODO、サブ Agent — 変更なし) - - s07 新規(起動時カタログ注入 SYSTEM + load_skill ツール) - diff --git a/s07_skill_loading/images/skill-overview.svg b/s07_skill_loading/images/skill-overview.svg index 600747bac..735bb5646 100644 --- a/s07_skill_loading/images/skill-overview.svg +++ b/s07_skill_loading/images/skill-overview.svg @@ -1,110 +1,154 @@ - + - - - - - - - + - - + + - - - - - - - - - - - Skill Loading — 启动时注入目录,运行时按需加载内容 - - - 历史章节保留 - - - - messages[] - - - - - - - LLM - stop_reason=tool_use? - - - - - - 返回结果 - - - - - - - - trigger_hooks - PreToolUse - - - - - - - TOOL_HANDLERS - - - - bash · read · write - - edit · glob · todo - - task (subagent) - - - load_skill - - - - 结果追加到 messages[],循环继续 - - - - s07 新增 - - - - ① build_system() - 启动时扫描 skills/ 第一行 - → 注入 SYSTEM prompt - - - - ② load_skill(name) - 运行时读完整 SKILL.md - → 注入 tool_result - - - - SYSTEM 含技能目录,每轮都带 - - - - - - - - 历史章节保留(循环、钩子、TODO、subagent — 完全不变) - - s07 新增(启动时目录注入 SYSTEM + load_skill 工具) - + + + + + Skill Loading + Inject catalog at startup, load content on demand at runtime + + + + + + messages[] + + + + + + + LLM + + + + + + + tool + stop_reason? + + + + No + + + + Return Result + + + + Yes + + + + trigger_hooks + PreToolUse + + + + + + + TOOL_HANDLERS + + + + bash read write + + + + edit glob todo + + + + task (subagent) + + + + load_skill + + + + + + Result appended to messages[], loop continues + + + + + + Level 1 + build_system() + Scan skills/ at + startup + + + + Level 2 + load_skill() + Read full SKILL.md + at runtime + + + + + + + + SYSTEM += skill catalog + + + + + + + + + + + + + + + + + Level + Location + Timing + Cost + + + + + + + + + + + + 1. Catalog + system prompt + (name + description) + Injected at startup + ~100 tokens/skill + carried every turn + + + 2. Content + tool_result + (full SKILL.md) + Agent calls load_skill + ~2000 tokens/skill + on demand only + + \ No newline at end of file diff --git a/s08_context_compact/README.en.md b/s08_context_compact/README.en.md deleted file mode 100644 index 15199c37e..000000000 --- a/s08_context_compact/README.en.md +++ /dev/null @@ -1,310 +0,0 @@ -# s08: Context Compact — Context Will Fill Up, Have a Way to Make Room - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 -> *"Context will fill up — have a way to make room"* — Four-layer compression pipeline: cheap first, expensive last. -> -> **Harness Layer**: Compression — clean memory, unlimited sessions. - ---- - -## The Problem - -The agent is running along, then freezes. - -It has bash, read, write — all the capabilities it needs. But it read a 1000-line file (~4000 tokens), then read 30 more files, ran 20 commands. Every command's output, every file's contents, all pile up in the `messages` list. - -The context window is finite. Once full, the API outright rejects the call: `prompt_too_long`. - -Without compression, an agent simply cannot work on large projects. - ---- - -## The Solution - -![Compact Overview](images/compact-overview.en.svg) - -The hook structure, skill loading, and sub-Agent from s07 are preserved, with some tools omitted to focus on compaction. The core change: insert three pre-processors (0 API calls) before each LLM call, trigger an LLM summary (1 API call) when tokens still exceed the threshold, and emergency-trim if the API throws an error. - -Core design: cheap first, expensive last. - ---- - -## How It Works - -![Four-layer compression pipeline](images/compaction-layers.en.svg) - -### L1: snip_compact — Trim Irrelevant Old Conversation - -The agent ran 80 turns of conversation, accumulating 160 `messages`. The very first "help me create hello.py" is barely relevant to current work, yet it still occupies space. - -Message count exceeds 50 → keep the first 3 (initial context) and the last 47 (current work), trim the middle; the only extra boundary rule is that `assistant(tool_use)` must not be separated from the following `user(tool_result)`: - -```python -def snip_compact(messages, max_messages=50): - if len(messages) <= max_messages: - return messages - head_end, tail_start = 3, len(messages) - (max_messages - 3) - if head_end > 0 and _message_has_tool_use(messages[head_end - 1]): - while head_end < len(messages) and _is_tool_result_message(messages[head_end]): - head_end += 1 - if (tail_start > 0 and tail_start < len(messages) - and _is_tool_result_message(messages[tail_start]) - and _message_has_tool_use(messages[tail_start - 1])): - tail_start -= 1 - snipped = tail_start - head_end - placeholder = {"role": "user", "content": f"[snipped {snipped} messages from conversation middle]"} - return messages[:head_end] + [placeholder] + messages[tail_start:] -``` - -Messages are still trimmed directly; this just adds one boundary guard. `tool_result` content within remaining messages still keeps accumulating — message #34 may still hold 30KB of old file contents. → L2. - -### L2: micro_compact — Placeholder for Old Tool Results - -![Old results placeholder](images/micro-compact.en.svg) - -The agent read 10 files consecutively. The full contents of reads 1–7 are still sitting in context, no longer needed, but hogging large amounts of space. - -Keep only the 3 most recent `tool_result` entries intact; replace older ones with a one-line placeholder: - -```python -KEEP_RECENT_TOOL_RESULTS = 3 - -def micro_compact(messages): - tool_results = collect_tool_result_blocks(messages) - if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS: - return messages - for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]: - if len(block.get("content", "")) > 120: - block["content"] = "[Earlier tool result compacted. Re-run if needed.]" - return messages -``` - -Old results are cleared, but a single new result can be 500KB — one `cat` of a large file can max out the context. → L3. - -### L3: tool_result_budget — Persist Large Results to Disk - -![Large results to disk](images/layer1-budget.en.svg) - -The model read 5 large files in one go; all `tool_result` blocks in the last user message total 500KB. - -Sum the size of all `tool_result` blocks in the last user message. If over 200KB → sort by size, starting from the largest, persist to `.task_outputs/tool-results/`, keeping only a `` marker + a 2000-character preview in context. The model sees the marker and knows the full content is on disk, re-reading it when needed. - -```python -def tool_result_budget(messages, max_bytes=200_000): - last = messages[-1] - blocks = [(i, b) for i, b in enumerate(last["content"]) - if b.get("type") == "tool_result"] - total = sum(len(str(b.get("content", ""))) for _, b in blocks) - if total <= max_bytes: - return messages - ranked = sorted(blocks, key=lambda p: len(str(p[1].get("content", ""))), reverse=True) - for idx, block in ranked: - if total <= max_bytes: - break - block["content"] = persist_large_output(block["tool_use_id"], str(block["content"])) - total = recalculate_total(blocks) - return messages -``` - -The first three layers are all plain-text / structural operations — 0 API calls — but they cannot "understand" conversation content. Context may still be too large. → L4. - -### L4: compact_history — Full LLM Summary - -![Full LLM summary](images/auto-compact.en.svg) - -All three previous layers have run, but after 30 minutes of continuous work on a huge project, tokens still exceed the threshold. - -Three-step process: - -1. **Save transcript**: Write the full conversation to `.transcripts/` in JSONL format. The transcript preserves a recoverable record, but the model's active context only contains the summary. For the model's current reasoning, the details are no longer in context. The teaching code does not provide a transcript retrieval tool. -2. **LLM generates summary**: Send conversation history to the LLM, asking it to preserve key information: current goals, important findings, modified files, remaining work, user constraints, etc. -3. **Replace message list**: All old messages are replaced with a single summary. The teaching version only keeps the summary; the real Claude Code re-attaches some recent files, plans, agent/skill/tool context after compaction. - -```python -def compact_history(messages): - transcript_path = write_transcript(messages) # Save full conversation first - summary = summarize_history(messages) # LLM generates summary - return [{"role": "user", - "content": f"[Compacted]\n\n{summary}"}] -``` - -**Circuit breaker**: After 3 consecutive failures, stop retrying to prevent an infinite loop wasting API calls. - -### Reactive: reactive_compact - -Sometimes the API still returns `prompt_too_long` (413) — when context grows faster than compression triggers. - -This triggers **reactive_compact**: more aggressive than compact_history, it retreats from the tail, but still avoids leaving an orphaned `tool_result`. - -```python -def reactive_compact(messages): - transcript = write_transcript(messages) - tail_start = max(0, len(messages) - 5) - if (tail_start > 0 and tail_start < len(messages) - and _is_tool_result_message(messages[tail_start]) - and _message_has_tool_use(messages[tail_start - 1])): - tail_start -= 1 - summary = summarize_history(messages[:tail_start]) - return [{"role": "user", - "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]] -``` - -Reactive compact has a retry limit (default 1). If it still fails, an exception is raised instead of looping forever. Full error recovery is deferred to s11. - -### Putting It All Together - -```python -def agent_loop(messages): - reactive_retries = 0 - while True: - # Three pre-processors (0 API calls) - # Order: budget first, so large content is persisted before placeholders - messages[:] = tool_result_budget(messages) # L3: persist large results - messages[:] = snip_compact(messages) # L1: trim middle - messages[:] = micro_compact(messages) # L2: old result placeholders - - # Still too much? LLM summary (1 API call) - if estimate_token_count(messages) > THRESHOLD: - messages[:] = compact_history(messages) - - try: - response = client.messages.create(...) - except PromptTooLongError: - if reactive_retries < MAX_REACTIVE_RETRIES: - messages[:] = reactive_compact(messages) # Emergency - reactive_retries += 1 - continue - raise # retry limit exceeded, raise exception - # ... tool execution ... - - # compact tool: when the model actively calls it, triggers compact_history - if block.name == "compact": - messages[:] = compact_history(messages) - results.append({..., "content": "[Compacted. History summarized.]"}) - messages.append({"role": "user", "content": results}) - break # end current turn, start fresh with compacted context -``` - -**The order must not be swapped.** L3 (budget) runs before L2 (micro) because micro replaces old large tool_results with one-line placeholders — budget must persist the full content before that happens. This is why CC source puts `applyToolResultBudget` first. - ---- - -## Changes From s07 - -| Component | Before (s07) | After (s08) | -|-----------|-------------|-------------| -| Context management | None (context grows unbounded) | Four-layer compression pipeline + emergency | -| New functions | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact | -| Tools | bash, read_file, write_file, edit_file, glob, todo_write, task, load_skill (8) | 8 + compact (9) | -| Loop | LLM call → tool execution | Three pre-processors before each turn + threshold-triggered compact_history | -| Design principle | — | Cheap first, expensive last | - ---- - -## Try It - -```sh -cd learn-claude-code -python s08_context_compact/code.py -``` - -Try these prompts: - -1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md` (read multiple files consecutively, observe L2 compressing old results) -2. `Read every file in s08_context_compact/` (read a large amount of content at once, observe L3 persisting to disk) -3. Chat for 20+ turns, observe whether `[auto compact]` or `[reactive compact]` appears - -What to watch for: After each tool execution, are old `tool_result` entries compressed? When tokens exceed the threshold after extended conversation, is summarization triggered automatically? - ---- - -## What's Next - -Context compression lets an agent run for a long time without crashing. But after each compression, the preferences and constraints the user told it are also lost. Can we let the agent selectively remember important things? - -s09 Memory → three subsystems: choosing what to remember, extracting key information, consolidating and organizing. Across compressions, across sessions. - -
-Deep Dive Into CC Source Code - -> The following is based on analysis of CC source code `compact.ts`, `autoCompact.ts`, `microCompact.ts`, and `query.ts`. - -### Execution Order Comparison - -The teaching version labels layers L1/L2/L3/L4 for pedagogical clarity, but actual execution order does not match the numbering: - -| Dimension | Teaching Version | Claude Code | -|-----------|-----------------|-------------| -| Execution order | budget → snip → micro → auto | budget → snip → micro → collapse → auto (`query.ts:379-468`) | -| snip_compact | Keep head 3 + tail 47 | CC only enables on main thread; implementation not in open-source repo (`HISTORY_SNIP` feature gate), but interface is visible: `snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`, also exposes `SnipTool` for model-initiated snipping. Teaching version's 3/47 are simplified parameters | -| micro_compact | Text placeholder replacement | Two paths: time-based clears content directly, cached uses API `cache_edits` (legacy path removed) | -| micro_compact whitelist | By position (most recent 3) | time-based triggers by time threshold; cached triggers by count (`microCompact.ts`) | -| tool_result_budget | 200KB characters | 200,000 characters (`toolLimits.ts:49`) | -| compact_history threshold | Character count estimate | Precise tokens: `contextWindow - maxOutputTokens - 13_000` | -| Summary requirements | 5 categories of info | 9 sections + ``/`` dual tags | -| Compression prompt | Simple prompt | Double-ended hard guardrails forbidding tool calls | -| PTL retry | Yes (simplified) | `truncateHeadForPTLRetry()` retreats by message groups (`compact.ts:243-290`) | -| Post-compaction recovery | None (teaching version only keeps summary) | Auto re-read recent files, plans, agent/skill/tool context | -| Circuit breaker | 3 times | 3 times (`autoCompact.ts:70`) | -| Reactive retry | 1 time | CC has more granular tiered retries | - -### Execution Order Details - -The real order in CC source `query.ts`: - -1. `applyToolResultBudget` (L379): persist large results first, ensuring full content is saved -2. `snipCompact` (L403): trim middle messages -3. `microcompact` (L414): old result placeholders -4. `contextCollapse` (L441): independent context management system (not in teaching version) -5. `autoCompact` (L454): LLM full summary - -The teaching version's budget → snip → micro order matches this. The teaching version does not have the contextCollapse mechanism. - -### read_file Trade-off - -The teaching version's `micro_compact` replaces old `tool_result` blocks with placeholders uniformly, including `read_file`. This usually does not affect functional correctness: if the model needs the file contents later, it can read the file again. The cost is an extra tool call and potentially lower prompt cache hit rates. - -Claude Code does not solve this with the teaching version's simple rule. It also puts `Read` in the microcompactable tool set, but maintains a separate `readFileState`: repeated reads of unchanged files return `FILE_UNCHANGED_STUB`, and after compaction it restores recently read file contents within a budget (for example, up to 5 files, 5K tokens per file, 50K tokens total). That is a production-level cache and recovery mechanism. The teaching version does not expand into that machinery; it keeps the simpler trade-off of compacting old results and re-reading when needed. - -### Full Constant Reference - -| Constant | Value | Source File | -|----------|-------|-------------| -| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` | -| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` | -| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` | -| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` | -| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` | -| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` | -| Time micro_compact interval | 60 minutes | `timeBasedMCConfig.ts` | -| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` | - -### contextCollapse and sessionMemoryCompact - -CC source code has two additional mechanisms not covered in this teaching version: - -- **contextCollapse**: An independent context management system that, when enabled, suppresses proactive autocompact (`autoCompact.ts:215-222`), with collapse's commit/blocking flow taking over context management. Manual `/compact` and reactive fallback remain independent paths, unaffected by contextCollapse. -- **sessionMemoryCompact**: Before compact_history, CC first attempts a lightweight summary using existing session memory (covered in s09) without calling the LLM. This mechanism becomes clearer after learning s09. - -### What Does the Compression Prompt Look Like? - -CC's compression prompt has two hard requirements: - -1. **Absolutely no tool calls**: It begins with `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`, and appends another REMINDER at the end -2. **Analyze first, then summarize**: The model must first reason in an `` tag, then output the formal summary in a `` tag. The analysis is stripped during formatting - -### Teaching Version Simplifications Are Intentional - -- micro_compact uses text placeholders → we don't have API-level `cache_edits` access -- read_file is not special-cased → the teaching version accepts re-reading when needed instead of introducing readFileState and post-compaction recovery -- Tokens estimated via character count → precise tokenizers are out of scope -- Post-compaction recovery omitted → teaching version only keeps summary, does not auto re-attach files -- Two auxiliary mechanisms not covered → they fall in the 10% detail category - -The core design principle, cheap first, expensive last, is fully preserved. - -
- - diff --git a/s08_context_compact/README.ja.md b/s08_context_compact/README.ja.md index da174d0a1..73229c4de 100644 --- a/s08_context_compact/README.ja.md +++ b/s08_context_compact/README.ja.md @@ -1,51 +1,53 @@ # s08: Context Compact — コンテキストはいつか満杯になる、場所を空ける方法が必要 -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 > *"Context will fill up — have a way to make room"* — 4層圧縮戦略、安価なものを先に、高価なものを後に実行。 > -> **Harness レイヤー**: 圧縮 — クリーンな記憶、無限のセッション。 +> **Harness レイヤー**: 圧縮 — コンテキスト超過時に自動要約し、セッションを持続可能に保つ。 --- ## 課題 -Agent が動いている途中で、止まってしまう。 +前章で Agent に Skills を加えた。少し「領域経験」を持ち始め、PDF や MCP、コードレビューに出くわすと、対応する操作説明を先に読み込んでから動くようになった。 -bash、read、write は揃っており、能力は十分。しかし 1000 行のファイル(~4000 token)を読み、さらに 30 のファイルを読み、20 のコマンドを実行したとします。各コマンドの出力、各ファイルの内容がすべて `messages` リストに蓄積されます。 +だが Agent が仕事をこなせるほど、別の問題が目立ってくる。1000 行のファイルを 1 つ読めば ~4000 token、さらに 30 個のファイルを読み、20 個のコマンドを走らせる。コマンドの出力もファイルの内容も、すべて `messages` リストに戻され、少しずつ積み上がる。 -コンテキストウィンドウには上限があります。満杯になると、API は即座に拒否します:`prompt_too_long`。 +普通のチャットなら数十ターンは何でもない。コードエージェントは違う。一度の読み取りが数千行、一度のテストが大量のログだ。タスクが終わらないうちに、コンテキストウィンドウが先に満杯になりかねない。 -圧縮しなければ、Agent は大規模プロジェクトではまともに動けません。 +満杯になると、問題は「モデルの答えが少し悪い」ではない。API がリクエストを直接拒否する:`prompt_too_long`。圧縮しなければ、Agent は大きなプロジェクトでまともに動けない。 --- ## ソリューション -![Compact Overview](images/compact-overview.ja.svg) +![Compact Overview](images/compact-overview.svg) -s07 のフック構造、スキルロード、サブ Agent の骨格を維持し、圧縮に焦点を当てるため一部のツールは省略。コアの変更点:各 LLM 呼び出し前に 3 層のプリプロセッサ(0 API)を挿入し、token が閾値を超えた場合は LLM 要約(1 API)をトリガー、API エラー時には緊急トリムを実行。 +s07 の hook 構造、skill ロード、サブエージェントの骨格は残し、この章では一層だけ加える。LLM を呼ぶ前に、まず `messages` を整える。 -コア設計:安価なものを先に、高価なものを後に。 +いちばん素直な発想は、満杯になったらモデルに要約させることだ。だがここには 2 つの問題がある。1 つ目、要約は API 呼び出しを 1 回余分に使う。コンテキストが大きくなるたびに要約していては、コストがすぐ膨らむ。2 つ目、すべての内容が要約に値するわけではない。古いツール結果の多くはとっくに不要だし、ただ大きいだけの内容もある。たとえば `cat` が数百 KB のログを吐いた場合、それは「理解」される必要はなく、コンテキストから外して必要なときに読み直せばいい。 + +だから compact は 1 つの動作ではなく、1 本のパイプラインだ。**安いものを先に、高いものを後に**。まずモデルを呼ばないローカルな整理を数ステップ走らせる。切れるものは切り、プレースホルダにできるものは置き換え、ディスクに退避できるものは退避する。それでも足りないときに初めて、LLM に本当の要約をさせる。 --- ## 仕組み -![4層圧縮パイプライン](images/compaction-layers.ja.svg) +![4層圧縮パイプライン](images/compaction-layers.svg) ### L1: snip_compact — 無関係な古い会話を切り捨て -Agent が 80 ラウンドの会話を実行し、`messages` が 160 件まで溜まった。先頭の「hello.py を作って」は現在の作業とほぼ無関係だが、スペースを占有し続けている。 +Agent が 80 ターン走り、`messages` は 160 件たまった。先頭の「hello.py を作って」は今の作業とほぼ無関係になっているが、まだ場所を占めている。 -メッセージ数が 50 を超えた場合 → 先頭 3 件(初期コンテキスト)と末尾 47 件(現在の作業)を保持して中間を切り詰める。ただし切れ目だけは調整し、`assistant(tool_use)` と後続の `user(tool_result)` を分断しない: +メッセージ数が 50 を超えたら → 先頭 3 件(最初のタスクと制約)と末尾 47 件(今の作業)を残し、中間を切る。気をつける境界は 1 つだけ:`assistant(tool_use)` とその後ろの `user(tool_result)` を切り離してはいけない。さもないとモデルは、どの呼び出しに対応するか分からない孤立した結果を見ることになる。 ```python def snip_compact(messages, max_messages=50): - if len(messages) <= max_messages: - return messages - head_end, tail_start = 3, len(messages) - (max_messages - 3) + if len(messages) <= max_messages: return messages + keep_head, keep_tail = 3, max_messages - 3 + head_end, tail_start = keep_head, len(messages) - keep_tail if head_end > 0 and _message_has_tool_use(messages[head_end - 1]): while head_end < len(messages) and _is_tool_result_message(messages[head_end]): head_end += 1 @@ -53,90 +55,83 @@ def snip_compact(messages, max_messages=50): and _is_tool_result_message(messages[tail_start]) and _message_has_tool_use(messages[tail_start - 1])): tail_start -= 1 + if head_end >= tail_start: return messages snipped = tail_start - head_end - placeholder = {"role": "user", "content": f"[snipped {snipped} messages from conversation middle]"} - return messages[:head_end] + [placeholder] + messages[tail_start:] + return messages[:head_end] + [{"role": "user", "content": f"[snipped {snipped} messages]"}] + messages[tail_start:] ``` -切り捨て自体は単純なままで、境界だけを保護する。残ったメッセージ内の `tool_result` 内容はまだ蓄積され続けている。34 番目のメッセージに 30KB の古いファイル内容が残っているかもしれない。→ L2。 +切るのはメッセージそのもので、切れ目に保護を 1 つ入れるだけだ。だが残ったメッセージの中では、`tool_result` の内容がまだ積み上がっている。34 番目のメッセージには 30KB の古いファイル内容が眠っているかもしれない。メッセージ数は減っても、token は減っていない。→ L2。 ### L2: micro_compact — 古いツール結果をプレースホルダに置換 -![古い結果のプレースホルダ](images/micro-compact.ja.svg) +![古い結果のプレースホルダ化](images/micro-compact.svg) -Agent が連続して 10 個のファイルを読んだ。1〜7 回目の完全な内容はまだコンテキストに残っており、もう不要だが、大量のスペースを占有している。 +コンテキストを膨らませる最大の原因は、会話そのものよりツール結果であることが多い。Agent が 10 個のファイルを続けて読んだとして、1 番目から 7 番目までの完全な内容はとっくに不要なのに、そのままコンテキストに居座っている。 -直近 3 件の `tool_result` の完全な内容のみを保持し、それより古いものは 1 行のプレースホルダに置換: +直近 3 件の `tool_result` の完全な内容を残し、それより古いものは 1 行のプレースホルダに置き換える。発想は素朴だ。古い結果が本当に要るなら、モデルがもう一度読めばいい。ずっと場所を占めるべきではない。 ```python -KEEP_RECENT_TOOL_RESULTS = 3 +KEEP_RECENT = 3 def micro_compact(messages): - tool_results = collect_tool_result_blocks(messages) - if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS: - return messages - for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]: + tool_results = collect_tool_results(messages) + if len(tool_results) <= KEEP_RECENT: return messages + for _, _, block in tool_results[:-KEEP_RECENT]: if len(block.get("content", "")) > 120: block["content"] = "[Earlier tool result compacted. Re-run if needed.]" return messages ``` -古い結果はクリーンアップされたが、1 件の新しい結果だけで 500KB の可能性がある。大きなファイルを `cat` するだけでコンテキストがいっぱいになる。→ L3。 +古い結果は片付いたが、まだ防げないケースがある。1 件の新しい結果だけで 500KB になることだ。大きなファイルを `cat` した出力 1 つでコンテキストを使い切ってしまう。しかもそれは新しすぎて、micro_compact は手を付けない。→ L3。 ### L3: tool_result_budget — 大きな結果をディスクに退避 -![大きな結果のディスク退避](images/layer1-budget.ja.svg) +![大きな結果のディスク退避](images/layer1-budget.svg) -モデルが一度に 5 つの大きなファイルを読み、1 つの user メッセージ内の全 `tool_result` の合計が 500KB に達した。 +結果には「多い」ではなく「1 件が大きすぎる」ものもある。モデルが大きなファイルを一度に 5 つ読み、最後の user メッセージ内の `tool_result` が合計 200KB を超える。こうなると直近 3 件を残しても無駄だ。最新の 1 件だけでコンテキストを使い切れるからだ。 -最後の user メッセージ内のすべての `tool_result` の合計サイズを集計。200KB を超えた場合 → サイズ順にソートし、最大のものから順に `.task_outputs/tool-results/` に退避。コンテキストには `` マーカー + 先頭 2000 文字のプレビューのみを残す。モデルはマーカーを見て完全な内容がディスク上にあることを認識し、必要に応じて再読み込みできる。 +ツール結果に予算を設ける。最後の user メッセージ内のすべての `tool_result` の合計サイズを数え、200KB を超えたら大きいものから `.task_outputs/tool-results/` に退避し、コンテキストには `` マーカーと先頭 2000 文字のプレビューだけを残す。モデルはマーカーを見れば完全な内容がディスク上にあると分かり、必要なときに読み戻せる。 ```python def tool_result_budget(messages, max_bytes=200_000): - last = messages[-1] + last = messages[-1] if messages else None + if not last or last.get("role") != "user" or not isinstance(last.get("content"), list): return messages blocks = [(i, b) for i, b in enumerate(last["content"]) - if b.get("type") == "tool_result"] + if isinstance(b, dict) and b.get("type") == "tool_result"] total = sum(len(str(b.get("content", ""))) for _, b in blocks) - if total <= max_bytes: - return messages + if total <= max_bytes: return messages ranked = sorted(blocks, key=lambda p: len(str(p[1].get("content", ""))), reverse=True) - for idx, block in ranked: - if total <= max_bytes: - break - block["content"] = persist_large_output(block["tool_use_id"], str(block["content"])) - total = recalculate_total(blocks) + for _, block in ranked: + if total <= max_bytes: break + block["content"] = persist_large_output(block.get("tool_use_id", "unknown"), str(block.get("content", ""))) + total = sum(len(str(b.get("content", ""))) for _, b in blocks) return messages ``` -最初の 3 層はすべて純粋なテキスト/構造操作(0 API 呼び出し)だが、会話内容を「理解」することはできない。コンテキストがまだ大きすぎる可能性がある。→ L4。 +ここで大事なのは捨てることではない。内容を「アクティブなコンテキスト」から「復元可能な外部ストレージ」へ移すことだ。これで最初の 3 層がそろう:純粋なテキスト/構造の操作、API 呼び出し 0、それぞれが 1 種類の冗長さを見張る。だが共通の限界がある:会話が何の話か読めない。どの発見が重要か、どの制約を残すべきか分からない。それでもコンテキストが大きすぎるなら、モデルに出てもらうしかない。→ L4。 ### L4: compact_history — LLM 全量要約 -![LLM 全量要約](images/auto-compact.ja.svg) - -最初の 3 層がすべて実行されたが、超大規模プロジェクトで 30 分間連続作業すると、token がまだ閾値を超えている。 +![LLM 全量要約](images/auto-compact.svg) -3 ステップのフロー: +3 層を走らせ切っても、token はまだ閾値を超える。この一手こそ、多くの人が直感的に思い描く「コンテキスト圧縮」だ。履歴をモデルに渡し、より短い状態に要約させる。 -1. **transcript を保存**:完全な会話を `.transcripts/` に JSONL 形式で書き出す。transcript は回復可能な記録として保存されるが、モデルのアクティブなコンテキストには要約しか残らない。モデルの現在の推論にとって、詳細はすでにコンテキストにない。教学コードは transcript 検索ツールを提供しない。 -2. **LLM で要約を生成**:会話履歴を LLM に送り、現在の目標、重要な発見、変更済みファイル、残りの作業、ユーザーの制約などの重要な情報を保持するよう指示。 -3. **メッセージリストを置換**:すべての古いメッセージが 1 件の要約に置き換えられる。教学版は要約のみを保持する。実際の Claude Code は compact 後に直近のファイル、計画、agent/skill/tool などのコンテキストを再付加する。 +3 ステップ。まず完全な会話を `.transcripts/`(JSONL)に書き出す。アクティブなコンテキストには要約だけが残るが、ディスクには完全な記録が残る。次に LLM に要約を生成させ、現在の目標、重要な発見、変更済みのファイル、残りの作業、ユーザーの制約を保持するよう求める。最後にこの 1 件の要約で古いメッセージをすべて置き換える。 ```python def compact_history(messages): - transcript_path = write_transcript(messages) # 先に完全な会話を保存 - summary = summarize_history(messages) # LLM で要約を生成 - return [{"role": "user", - "content": f"[Compacted]\n\n{summary}"}] + transcript_path = write_transcript(messages) # 先に完全な会話を保存 + summary = summarize_history(messages) # LLM が要約を生成 + return [{"role": "user", "content": f"[Compacted]\n\n{summary}"}] ``` -**サーキットブレーカー**:連続 3 回失敗したらリトライを停止し、無限ループによる API 呼び出しの浪費を防止。 +この一手はロッシー(不可逆)だ:transcript には完全な履歴があるが、モデルは今やその細部を見られず、要約だけを頼りに続ける。だから先に L1/L2/L3 を走らせる:モデルに要約させずに済むなら済ませる。いったん要約に入れば、細部は取り返しなく失われるからだ。教学版にはサーキットブレーカーも加えてある:compact が 3 回連続で失敗したら止め、API 呼び出しを無限に浪費しない。 ### 緊急: reactive_compact -API がまだ `prompt_too_long`(413)を返すことがある。コンテキストの増加速度が圧縮のトリガー速度を上回る場合。 +通常は、モデルを呼ぶ前にコンテキストを整えておく。だがコンテキストの増加が速すぎたり、token の見積もりがずれたりすると、API はやはり `prompt_too_long` を返しうる。 -この時 **reactive_compact** がトリガーされる:compact_history よりもさらに積極的だが、末尾を残す際も孤立した `tool_result` を残さないようにする。 +このとき reactive_compact に入る。compact_history によく似ているが、より激しい:先に transcript を保存し、前半の大部分を要約し、末尾 5 件だけを末尾コンテキストとして残す(こちらも孤立した `tool_result` を残さないようにする)。 ```python def reactive_compact(messages): @@ -147,47 +142,43 @@ def reactive_compact(messages): and _message_has_tool_use(messages[tail_start - 1])): tail_start -= 1 summary = summarize_history(messages[:tail_start]) - return [{"role": "user", - "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]] + return [{"role": "user", "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]] ``` -reactive compact にはリトライ上限がある(デフォルト 1 回)。さらに失敗した場合は例外をスローし、無限ループしない。完全なエラー回復ロジックは s11 に委ねる。 +reactive は通常パスではなくフォールバックだ。デフォルトでは 1 回だけリトライし、もう一度失敗したら無限ループせず例外を投げる。完全なエラー回復ロジックは s11 に譲る。 ### 合わせて実行 +これらを Agent Loop に戻して組み込む。各ラウンドで LLM を呼ぶ前に 3 層のローカル整理を走らせ、足りなければ要約し、呼び出しが実際にエラーになったら緊急パスに回る。 + ```python def agent_loop(messages): reactive_retries = 0 while True: - # 3 つのプリプロセッサ(0 API 呼び出し) - # 順序:budget を先に実行し、大きな内容をプレースホルダ化する前に退避 + # 3 つの前処理(API 呼び出し 0)、順序:budget -> snip -> micro messages[:] = tool_result_budget(messages) # L3: 大きな結果を退避 - messages[:] = snip_compact(messages) # L1: 中間を切り捨て - messages[:] = micro_compact(messages) # L2: 古い結果をプレースホルダに + messages[:] = snip_compact(messages) # L1: 中間を切る + messages[:] = micro_compact(messages) # L2: 古い結果をプレースホルダ化 - # まだ足りない?LLM 要約(1 API 呼び出し) - if estimate_token_count(messages) > THRESHOLD: + if estimate_size(messages) > CONTEXT_LIMIT: # まだ足りない -> LLM 要約(API 1 回) messages[:] = compact_history(messages) try: - response = client.messages.create(...) - except PromptTooLongError: - if reactive_retries < MAX_REACTIVE_RETRIES: - messages[:] = reactive_compact(messages) # 緊急対応 + response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000) + except Exception as e: + if "prompt_too_long" in str(e).lower() and reactive_retries < MAX_REACTIVE_RETRIES: + messages[:] = reactive_compact(messages) # 緊急 reactive_retries += 1 continue - raise # リトライ上限超過、例外をスロー + raise # ... ツール実行 ... - - # compact ツール:モデルが能動的に呼び出した場合、compact_history をトリガー - if block.name == "compact": - messages[:] = compact_history(messages) - results.append({..., "content": "[Compacted. History summarized.]"}) - messages.append({"role": "user", "content": results}) - break # 現在のターンを終了し、圧縮後のコンテキストで新しく開始 ``` -**順序は変えられない。** L3(budget)が L2(micro)の前に実行される理由:micro は古い大きな tool_result を 1 行のプレースホルダに置換するため、budget はその前に完全な内容を退避させる必要がある。CC ソースが `applyToolResultBudget` を最初に配置する理由も同じ。 +**順序は変えられない。** L3(budget)は L2(micro)より前でなければならない:micro は古い大きな `tool_result` を 1 行のプレースホルダに置き換えるので、もし先に走れば budget が完全な内容を退避する機会を失う。先に budget で大きな内容を保存し、それからプレースホルダ化と切り捨てを行う。これが Claude Code のソースが `applyToolResultBudget` を最前に置く理由でもある。 + +### compact ツール — モデルからも要求できる + +自動圧縮のほかに、モデル自身が整理を要求することもできる。コンテキストが長すぎる、あるいはタスクの段階が切り替わったと感じたとき、モデルは能動的に `compact` ツールを呼べる。教学版ではこのツールが `compact_history` をトリガーし、現在の turn を終え、圧縮後のコンテキストで新たな 1 ラウンドを始める。手動の `/compact` によく似ているが、違いは今回モデル自身が整理どきだと気づいた点だ。 --- @@ -195,11 +186,13 @@ def agent_loop(messages): | コンポーネント | 変更前 (s07) | 変更後 (s08) | |------|-----------|-----------| -| コンテキスト管理 | なし(コンテキストが無限に膨張) | 4 層圧縮パイプライン + 緊急対応 | -| 新規関数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact | -| ツール | bash, read_file, write_file, edit_file, glob, todo_write, task, load_skill (8) | 8 + compact (9) | -| ループ | LLM 呼び出し → ツール実行 | 各ラウンド前に 3 層プリプロセッサを実行 + 閾値で compact_history をトリガー | -| 設計原則 | — | 安価なものを先に、高価なものを後に | +| コンテキスト管理 | なし(無制限に膨張) | 4 層圧縮パイプライン + 緊急 | +| 新しい関数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact | +| ツール | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) | +| ループ | LLM 呼び出し → ツール実行 | 各ラウンド前に 3 層の前処理 + 閾値で compact_history を起動 | +| 設計原則 | Agent を仕事できるように | Agent が長く走っても崩れないように | + +この一手は「能力」を加えるというより「体力」を加えるに近い:s07 は Agent を専門的な作業に強くし、s08 は長いタスクで自分の履歴に押しつぶされないようにする。 --- @@ -210,26 +203,28 @@ cd learn-claude-code python s08_context_compact/code.py ``` -以下のプロンプトを試してみてください: +次の prompt を試してみよう: -1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(連続して複数のファイルを読み、L2 の古い結果圧縮を観察) -2. `Read every file in s08_context_compact/`(一度に大量の内容を読み込み、L3 のディスク退避を観察) -3. 20+ ラウンドの対話を繰り返し、`[auto compact]` または `[reactive compact]` が表示されるか観察 +1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(複数ファイルを続けて読み、L2 が古い結果を圧縮する様子を観察) +2. `Read every file in s08_context_compact/`(一度に大量に読み、L3 のディスク退避を観察) +3. 会話を 20 ターン以上続け、`[auto compact]` や `[reactive compact]` が出るか観察 -観察のポイント:ツール実行のたびに、古い tool_result は圧縮されているか?連続対話で token が閾値を超えたとき、要約が自動的にトリガーされたか? +観察ポイント:各ツール実行後、古い `tool_result` は置き換えられるか?大きな出力は退避されるか?token が閾値を超えたとき、要約は生成されるか? --- ## 次へ -コンテキスト圧縮により、Agent は長時間クラッシュせずに動けるようになった。しかし、圧縮のたびにユーザーが以前に伝えた偏好や制約も一緒に失われてしまう。Agent が重要なことを選択的に記憶できるようにできないか? +圧縮のおかげで Agent は長く走っても崩れない。だが圧縮のたびに細部がいくらか失われる:ユーザーが前に述べた好み、プロジェクトの長期的な制約、複数のタスクにまたがって重要な情報。どれも要約に完全に残る保証はない。 + +compact が答えるのは「今のセッションがもうすぐ満杯だ、どう走り続けるか」だ。「どの情報を長く残す価値があるか」には答えない。 -s09 Memory → 3 つのサブシステム:何を記憶するかの選択、重要情報の抽出、整理と統合。圧縮を越え、セッションを越えて。 +s09 Memory → 3 つのサブシステム:何を覚えるか選ぶ、重要な情報を抽出する、整理して定着させる。圧縮をまたぎ、セッションをまたいで。
-CC ソースコードの詳細 +Claude Code ソースコードの詳細 -> 以下は CC ソースコード `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` の分析に基づく。 +> 以下は Claude Code ソースコード `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` の分析に基づく。 ### 実行順序の対応 @@ -238,7 +233,7 @@ s09 Memory → 3 つのサブシステム:何を記憶するかの選択、重 | 項目 | 教学版 | Claude Code | |------|--------|-------------| | 実行順序 | budget → snip → micro → auto | budget → snip → micro → collapse → auto(`query.ts:379-468`) | -| snip_compact | 先頭 3 + 末尾 47 を保持 | CC はメインスレッドのみ有効;実装はオープンソースリポジトリにない(`HISTORY_SNIP` feature gate)、インターフェースは確認可能:`snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`、`SnipTool` もモデルが能動的に呼び出し可能。教学版の 3/47 は簡略パラメータ | +| snip_compact | 先頭 3 + 末尾 47 を保持 | Claude Code はメインスレッドのみ有効;実装はオープンソースリポジトリにない(`HISTORY_SNIP` feature gate)、インターフェースは確認可能:`snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`、`SnipTool` もモデルが能動的に呼び出し可能。教学版の 3/47 は簡略パラメータ | | micro_compact | テキストプレースホルダで置換 | 2 つのパス:time-based は直接内容をクリア、cached は API の `cache_edits` を使用(legacy パスは削除済み) | | micro_compact ホワイトリスト | 位置による(直近 3 件) | time-based は時間閾値でトリガー、cached はカウントでトリガー(`microCompact.ts`) | | tool_result_budget | 200KB 文字 | 200,000 文字(`toolLimits.ts:49`) | @@ -248,11 +243,11 @@ s09 Memory → 3 つのサブシステム:何を記憶するかの選択、重 | PTL retry | あり(簡略版) | `truncateHeadForPTLRetry()` がメッセージグループ単位でロールバック(`compact.ts:243-290`) | | 圧縮後のリカバリ | なし(教学版は要約のみ保持) | 直近のファイル、計画、agent/skill/tool などの自動再付加 | | サーキットブレーカー | 3 回 | 3 回(`autoCompact.ts:70`) | -| reactive リトライ | 1 回 | CC にはより精緻な段階別リトライがある | +| reactive リトライ | 1 回 | Claude Code にはより精緻な段階別リトライがある | ### 実行順序の詳細 -CC ソース `query.ts` での実際の順序: +Claude Code ソース `query.ts` での実際の順序: 1. `applyToolResultBudget`(L379):まず大きな結果を処理し、完全な内容を退避 2. `snipCompact`(L403):中間メッセージを切り捨て @@ -283,14 +278,14 @@ Claude Code は、この問題を教学版のような単純なルールでは ### contextCollapse と sessionMemoryCompact -CC ソースコードには、この教学版では展開していない 2 つのメカニズムが存在する: +Claude Code ソースコードには、この教学版では展開していない 2 つのメカニズムが存在する: - **contextCollapse**:独立したコンテキスト管理システム。有効時には proactive autocompact を抑制し(`autoCompact.ts:215-222`)、collapse の commit/blocking フローがコンテキスト管理を引き継ぐ。ただし manual `/compact` と reactive fallback は独立パスのままで、contextCollapse の影響を受けない。 -- **sessionMemoryCompact**:compact_history の前に、CC は既存の session memory(s09 で解説)を使った軽量要約を先に試みる。LLM を呼び出さない。このメカニズムは s09 を学んだ後に振り返るとより理解しやすい。 +- **sessionMemoryCompact**:compact_history の前に、Claude Code は既存の session memory(s09 で解説)を使った軽量要約を先に試みる。LLM を呼び出さない。このメカニズムは s09 を学んだ後に振り返るとより理解しやすい。 ### 圧縮プロンプトの中身 -CC の圧縮プロンプトには 2 つの厳格な要件がある: +Claude Code の圧縮プロンプトには 2 つの厳格な要件がある: 1. **ツール呼び出しの絶対禁止**:冒頭が `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.` で、末尾にも再度 REMINDER がある 2. **先に分析してから要約**:モデルはまず `` タグで思考を整理し、その後 `` タグで正式な要約を出力する。analysis はフォーマット時に除去される @@ -303,8 +298,8 @@ CC の圧縮プロンプトには 2 つの厳格な要件がある: - 圧縮後のリカバリを省略 → 教学版は要約のみを保持し、ファイルの自動再付加を行わない - 2 つの補助メカニズムを展開しない → 10% の細部に属する -コア設計思想、安価なものを先に高価なものを後に、は完全に保持されている。 +コア設計思想は完全に保持されている。
- + diff --git a/s08_context_compact/README.md b/s08_context_compact/README.md index ab5dae545..433fe89c1 100644 --- a/s08_context_compact/README.md +++ b/s08_context_compact/README.md @@ -1,51 +1,53 @@ -# s08: Context Compact — 上下文总会满,要有办法腾地方 +# s08: Context Compact — Context Will Fill Up, Have a Way to Make Room -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 -> *"上下文总会满, 要有办法腾地方"* — 四层压缩策略, 便宜的先跑贵的后跑。 +> *"Context will fill up — have a way to make room"* — Four-layer compaction pipeline: cheap first, expensive last. > -> **Harness 层**: 压缩 — 干净的记忆, 无限的会话。 +> **Harness Layer**: Compaction — auto-summarize when context exceeds limits, keeping sessions sustainable. --- -## 问题 +## The Problem -Agent 跑着跑着,不动了。 +The last chapter gave the agent Skills, so it picked up a bit of "domain experience": hand it a PDF, an MCP server, or a code review, and it loads the right playbook before acting. -手里有 bash、有 read、有 write,能力是够的。但它读了一个 1000 行的文件(~4000 token),又读了 30 个文件,跑了 20 条命令。每条命令的输出、每个文件的内容,全都堆在 `messages` 列表里。 +But the more capable the agent gets, the worse a second problem becomes. It reads one 1000-line file (that's ~4000 tokens), then 30 more files, then runs 20 commands. Every command's output and every file's contents get appended back into the `messages` list, piling up turn after turn. -上下文窗口是有限的。满了之后,API 直接拒绝:`prompt_too_long`。 +A few dozen turns of ordinary chat is nothing. A coding agent is different: one read is thousands of lines, one test run is a wall of logs. The task isn't even done, and the context window may already be full. -不压缩,Agent 根本没法在大项目里干活。 +Once it's full, the problem isn't "the model answered a little worse." The API rejects the call outright: `prompt_too_long`. Without compaction, an agent simply can't work on a large project. --- -## 解决方案 +## The Solution ![Compact Overview](images/compact-overview.svg) -保留 s07 的 hook 结构、技能加载、子 Agent 等骨架,省略部分工具细节以聚焦压缩。核心变动:每轮 LLM 调用前插入三层预处理器(0 API),token 仍超阈值时触发 LLM 摘要(1 API),API 报错时应急裁剪。 +The hook structure, skill loading, and subagent from s07 all stay; this chapter adds just one layer: before every LLM call, tidy up `messages` first. -核心设计:便宜的先跑,贵的后跑。 +The obvious idea is to let the model summarize once things fill up. But that has two problems. First, a summary costs an extra API call, and summarizing every time the context grows makes the bill climb fast. Second, not everything is worth summarizing: plenty of old tool results aren't needed anymore, and some content is merely big: a `cat` that dumps a few hundred KB of logs doesn't need to be *understood*, it just needs to move out of the context and be re-read if it ever matters again. + +So compaction isn't one action, it's a pipeline. **Cheap first, expensive last**: run a few local passes that never call the model: trim what can be trimmed, swap placeholders in for old results, spill big outputs to disk. Only when none of that is enough do you let the LLM do a real summary. --- -## 工作原理 +## How It Works -![四层压缩管线](images/compaction-layers.svg) +![Four-Layer Compaction Pipeline](images/compaction-layers.svg) -### L1: snip_compact — 裁掉无关的旧对话 +### L1: snip_compact — Trim Irrelevant Old Conversation -Agent 跑了 80 轮对话,`messages` 攒了 160 条。最前面的"帮我创建 hello.py"和当前工作几乎无关了,但全占着位置。 +The agent has run 80 turns and `messages` holds 160 entries. That opening "create hello.py for me" has almost nothing to do with the current work, yet it still takes up space. -消息数超过 50 条 → 保留头部 3 条(初始上下文)和尾部 47 条(当前工作),中间裁掉;唯一额外边界条件是,不能把 `assistant(tool_use)` 和后面的 `user(tool_result)` 拆开: +Once the count passes 50 → keep the first 3 (the original task and constraints) and the last 47 (the current work), and cut the middle. The one boundary to respect: don't split an `assistant(tool_use)` from the `user(tool_result)` that follows it, or the model sees an orphaned result with no idea which call it belongs to. ```python def snip_compact(messages, max_messages=50): - if len(messages) <= max_messages: - return messages - head_end, tail_start = 3, len(messages) - (max_messages - 3) + if len(messages) <= max_messages: return messages + keep_head, keep_tail = 3, max_messages - 3 + head_end, tail_start = keep_head, len(messages) - keep_tail if head_end > 0 and _message_has_tool_use(messages[head_end - 1]): while head_end < len(messages) and _is_tool_result_message(messages[head_end]): head_end += 1 @@ -53,90 +55,83 @@ def snip_compact(messages, max_messages=50): and _is_tool_result_message(messages[tail_start]) and _message_has_tool_use(messages[tail_start - 1])): tail_start -= 1 + if head_end >= tail_start: return messages snipped = tail_start - head_end - placeholder = {"role": "user", "content": f"[snipped {snipped} messages from conversation middle]"} - return messages[:head_end] + [placeholder] + messages[tail_start:] + return messages[:head_end] + [{"role": "user", "content": f"[snipped {snipped} messages]"}] + messages[tail_start:] ``` -裁掉的是消息本身,只是在切口处多做一步保护;剩下的消息里 `tool_result` 内容仍在累积——第 34 条消息里可能躺着 30KB 的旧文件内容。→ L2。 +What gets cut is the messages themselves, with one guard at the seam. But in the messages that remain, `tool_result` content is still piling up, and message 34 might still be sitting on 30KB of an old file. Fewer messages, but not fewer tokens. → L2. -### L2: micro_compact — 旧工具结果占位 +### L2: micro_compact — Placeholder for Old Tool Results -![旧结果占位](images/micro-compact.svg) +![Old Result Placeholders](images/micro-compact.svg) -Agent 连续读了 10 个文件。第 1-7 次的完整内容还躺在上下文里,早就不需要了,但占着大量空间。 +What blows up the context is usually not the conversation itself but the tool results. The agent read 10 files in a row; the full contents of files 1 through 7 stopped being useful long ago, yet they sit there verbatim. -只保留最近 3 条 `tool_result` 的完整内容,更旧的替换为一行占位符: +Keep the full content of the 3 most recent `tool_result`s and replace anything older with a one-line placeholder. The idea is plain: if an old result is really needed, the model can just read it again; it shouldn't hog space the whole time. ```python -KEEP_RECENT_TOOL_RESULTS = 3 +KEEP_RECENT = 3 def micro_compact(messages): - tool_results = collect_tool_result_blocks(messages) - if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS: - return messages - for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]: + tool_results = collect_tool_results(messages) + if len(tool_results) <= KEEP_RECENT: return messages + for _, _, block in tool_results[:-KEEP_RECENT]: if len(block.get("content", "")) > 120: block["content"] = "[Earlier tool result compacted. Re-run if needed.]" return messages ``` -旧结果清掉了,但单条新结果可能就有 500KB——一个 `cat` 大文件的输出就能打满上下文。→ L3。 +The old results are cleared, but one case still slips through: a single new result can be 500KB on its own: one `cat` of a big file is enough to fill the context, and it's too fresh for micro_compact to touch. → L3. -### L3: tool_result_budget — 大结果落盘 +### L3: tool_result_budget — Persist Large Results to Disk -![大结果落盘](images/layer1-budget.svg) +![Persist Large Results](images/layer1-budget.svg) -模型一次读了 5 个大文件,单条 user 消息里所有 `tool_result` 加起来 500KB。 +Some results aren't a problem of *many*, but of *one too big*. The model read 5 large files at once, and the `tool_result`s in that last user message add up to over 200KB, and keeping the 3 most recent doesn't help here, because the newest one alone can fill the context. -统计最后一条 user 消息里所有 `tool_result` 的总大小。超过 200KB → 按大小排序,从最大的开始落盘到 `.task_outputs/tool-results/`,上下文里只留 `` 标记 + 前 2000 字符预览。模型看到标记后知道完整内容在磁盘上,需要时可以重新读。 +Give tool results a budget. Add up the size of every `tool_result` in the last user message; if it's over 200KB, persist them to `.task_outputs/tool-results/` starting with the largest, leaving only a `` marker plus the first 2000 characters as a preview. The model sees the marker and knows the full content is on disk, to be re-read when needed. ```python def tool_result_budget(messages, max_bytes=200_000): - last = messages[-1] + last = messages[-1] if messages else None + if not last or last.get("role") != "user" or not isinstance(last.get("content"), list): return messages blocks = [(i, b) for i, b in enumerate(last["content"]) - if b.get("type") == "tool_result"] + if isinstance(b, dict) and b.get("type") == "tool_result"] total = sum(len(str(b.get("content", ""))) for _, b in blocks) - if total <= max_bytes: - return messages + if total <= max_bytes: return messages ranked = sorted(blocks, key=lambda p: len(str(p[1].get("content", ""))), reverse=True) - for idx, block in ranked: - if total <= max_bytes: - break - block["content"] = persist_large_output(block["tool_use_id"], str(block["content"])) - total = recalculate_total(blocks) + for _, block in ranked: + if total <= max_bytes: break + block["content"] = persist_large_output(block.get("tool_use_id", "unknown"), str(block.get("content", ""))) + total = sum(len(str(b.get("content", ""))) for _, b in blocks) return messages ``` -前三层都是纯文本/结构操作,0 API 调用,但也无法"理解"对话内容。上下文可能仍然太大。→ L4。 - -### L4: compact_history — LLM 全量摘要 +What matters here isn't discarding; it's moving content from "active context" to "recoverable external storage". That completes the first three layers: pure text/structure operations, 0 API calls, each watching one kind of bloat. But they share one limit: they can't read what the conversation is about, can't tell which findings matter or which constraints must stay. If the context is still too big, the model has to step in. → L4. -![LLM 全量摘要](images/auto-compact.svg) +### L4: compact_history — Full LLM Summary -前三层全跑完了,但在超大项目中连续工作 30 分钟后,token 仍然超过阈值。 +![Full LLM Summary](images/auto-compact.svg) -三步流程: +All three layers have run and the token count still tops the threshold. This is what most people picture as "context compaction": hand the history to the model and have it summarize into a shorter state. -1. **保存 transcript**:完整对话写入 `.transcripts/`,JSONL 格式。transcript 保留了可恢复记录,但模型的活跃上下文里只剩摘要。对模型当下推理来说,细节已经不在上下文中了。教学代码没有提供 transcript 检索工具。 -2. **LLM 生成摘要**:把对话历史发给 LLM,要求保留当前目标、重要发现、已改文件、剩余工作、用户约束等关键信息。 -3. **替换消息列表**:所有旧消息被替换为一条摘要。教学版只保留摘要;真实 Claude Code 会在 compact 后重新附加部分最近文件、计划、agent/skill/tool 等上下文。 +Three steps: first write the full conversation to `.transcripts/` (JSONL), so the active context keeps only the summary while the complete record stays on disk; then have the LLM produce a summary that preserves the current goal, key findings, files already changed, remaining work, and user constraints; finally replace all the old messages with that one summary. ```python def compact_history(messages): - transcript_path = write_transcript(messages) # 先保存完整对话 - summary = summarize_history(messages) # LLM 生成摘要 - return [{"role": "user", - "content": f"[Compacted]\n\n{summary}"}] + transcript_path = write_transcript(messages) # save the full conversation first + summary = summarize_history(messages) # LLM generates the summary + return [{"role": "user", "content": f"[Compacted]\n\n{summary}"}] ``` -**熔断器**:连续失败 3 次后停止重试,防止死循环浪费 API 调用。 +This step is lossy: the transcript holds the full history, but the model can no longer see those details; it has only the summary to go on. That's why L1/L2/L3 run first: don't make the model summarize if you can avoid it, because once you do, detail is gone for good. The teaching version also adds a circuit breaker: stop after 3 consecutive compaction failures instead of burning API calls in a loop. -### 应急: reactive_compact +### Reactive: reactive_compact -有时候 API 还是返回 `prompt_too_long`(413),上下文增长速度快于压缩触发速度时。 +Normally we tidy the context before calling the model. But when context grows too fast, or the token estimate is off, the API can still come back with `prompt_too_long`. -这时触发 **reactive_compact**:比 compact_history 更激进,从尾部回退,但仍要避免留下孤立 `tool_result`。 +That's when reactive_compact kicks in: much like compact_history but more aggressive: save the transcript, summarize most of the front, and keep only the last 5 messages as tail context (again avoiding an orphaned `tool_result`). ```python def reactive_compact(messages): @@ -147,164 +142,164 @@ def reactive_compact(messages): and _message_has_tool_use(messages[tail_start - 1])): tail_start -= 1 summary = summarize_history(messages[:tail_start]) - return [{"role": "user", - "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]] + return [{"role": "user", "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]] ``` -reactive compact 有重试上限(默认 1 次)。再失败就抛出异常,不无限循环。完整的错误恢复逻辑留给 s11。 +Reactive is the fallback, not the normal path: it retries once by default, and on another failure it raises rather than looping forever. The full error-recovery logic is left to s11. + +### Putting It All Together -### 合起来跑 +Wire it all back into the Agent Loop: before each LLM call, run the three local passes, summarize if that's not enough, and fall back to the emergency path only if the call actually errors. ```python def agent_loop(messages): reactive_retries = 0 while True: - # 三个预处理器(0 API 调用) - # 顺序:budget 先跑,确保大内容落盘后再做占位和裁剪 - messages[:] = tool_result_budget(messages) # L3: 大结果落盘 - messages[:] = snip_compact(messages) # L1: 裁中间 - messages[:] = micro_compact(messages) # L2: 旧结果占位 - - # 还不够?LLM 摘要(1 API 调用) - if estimate_token_count(messages) > THRESHOLD: + # three preprocessors (0 API calls), order: budget -> snip -> micro + messages[:] = tool_result_budget(messages) # L3: persist large results + messages[:] = snip_compact(messages) # L1: trim the middle + messages[:] = micro_compact(messages) # L2: old result placeholders + + if estimate_size(messages) > CONTEXT_LIMIT: # still too big -> LLM summary (1 API call) messages[:] = compact_history(messages) try: - response = client.messages.create(...) - except PromptTooLongError: - if reactive_retries < MAX_REACTIVE_RETRIES: - messages[:] = reactive_compact(messages) # 应急 + response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000) + except Exception as e: + if "prompt_too_long" in str(e).lower() and reactive_retries < MAX_REACTIVE_RETRIES: + messages[:] = reactive_compact(messages) # emergency reactive_retries += 1 continue - raise # 超过重试上限,抛出异常 - # ... 工具执行 ... - - # compact 工具:模型主动调用时触发 compact_history - if block.name == "compact": - messages[:] = compact_history(messages) - results.append({..., "content": "[Compacted. History summarized.]"}) - messages.append({"role": "user", "content": results}) - break # 结束当前 turn,用压缩后的上下文开始新一轮 + raise + # ... tool execution ... ``` -**顺序不能换。** L3(budget)在 L2(micro)前面,因为 micro 会把旧的大 tool_result 替换成一行占位符,budget 必须在那之前把完整内容落盘。这也是为什么 CC 源码把 `applyToolResultBudget` 放在最前面。 +**The order can't change.** L3 (budget) has to run before L2 (micro): micro replaces old large `tool_result`s with a one-line placeholder, so if it ran first, budget would never get to persist the full content. Save the big content first, then do the placeholdering and trimming. That's also why Claude Code's source puts `applyToolResultBudget` first. + +### The compact Tool — Let the Model Ask, Too + +Beyond automatic compaction, the model can ask for a tidy-up itself: when it feels the context is too long, or the task has shifted phase, it can call the `compact` tool. In the teaching version that tool triggers `compact_history`, then ends the current turn and starts fresh with the compacted context. It feels much like a manual `/compact`, except this time the model itself realized it was time. --- -## 相对 s07 的变更 +## Changes From s07 -| 组件 | 之前 (s07) | 之后 (s08) | -|------|-----------|-----------| -| 上下文管理 | 无(上下文无限膨胀) | 四层压缩管线 + 应急 | -| 新函数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact | -| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) | -| 循环 | LLM 调用 → 工具执行 | 每轮前跑三层预处理器 + 阈值触发 compact_history | -| 设计原则 | — | 便宜的先跑,贵的后跑 | +| Component | Before (s07) | After (s08) | +|-----------|-------------|-------------| +| Context management | None (context grows without bound) | Four-layer compaction pipeline + emergency | +| New functions | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact | +| Tools | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) | +| Loop | LLM call → tool execution | Run three preprocessors each round + threshold-triggered compact_history | +| Design principle | Make the agent capable | Keep the agent from crashing on long runs | + +This step doesn't add a *capability* so much as *stamina*: s07 made the agent better at specialized work, s08 keeps it from being dragged down by its own history on a long task. --- -## 试一下 +## Try It ```sh cd learn-claude-code python s08_context_compact/code.py ``` -试试这些 prompt: +Try these prompts: -1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(连续读多个文件,观察 L2 压缩旧结果) -2. `Read every file in s08_context_compact/`(一次性读大量内容,观察 L3 落盘) -3. 反复对话 20+ 轮,观察是否出现 `[auto compact]` 或 `[reactive compact]` +1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md` (read several files in a row, watch L2 compact old results) +2. `Read every file in s08_context_compact/` (read a lot at once, watch L3 persist to disk) +3. Keep the conversation going 20+ turns, watch for `[auto compact]` or `[reactive compact]` -观察重点:每次工具执行后,旧 tool_result 是否被压缩?连续对话后 token 超阈值时,是否自动触发了摘要? +What to watch: after each tool runs, does the old `tool_result` get replaced? Do large outputs get persisted? When tokens pass the threshold, does a summary get generated? --- -## 接下来 +## What's Next + +Compaction lets the agent run a long time without crashing. But every compaction loses some detail: a preference the user stated earlier, a long-standing project constraint, a fact that matters across tasks. None of it is guaranteed to survive in the summary. -上下文压缩让 Agent 能跑很久不会崩。但每次压缩后,用户之前告诉它的偏好、约束也跟着丢了。能不能让 Agent 有选择地记住重要的事? +Compaction answers "the current session is nearly full, how do we keep going". It doesn't answer "which information is worth keeping for the long haul". -s09 Memory → 三个子系统:选择记什么、提取关键信息、整理巩固。跨压缩、跨会话。 +s09 Memory → three subsystems: choosing what to remember, extracting the key information, and consolidating it. Across compactions, across sessions.
-深入 CC 源码 +Deep Dive Into Claude Code Source Code -> 以下基于 CC 源码 `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` 的分析。 +> The following is based on analysis of Claude Code source code `compact.ts`, `autoCompact.ts`, `microCompact.ts`, and `query.ts`. -### 执行顺序对照 +### Execution Order Comparison -教学版为了讲解方便按 L1/L2/L3/L4 编号,但实际执行顺序和编号不完全对应: +The teaching version labels layers L1/L2/L3/L4 for pedagogical clarity, but actual execution order does not match the numbering: -| 维度 | 教学版 | Claude Code | -|------|--------|-------------| -| 执行顺序 | budget → snip → micro → auto | budget → snip → micro → collapse → auto(`query.ts:379-468`) | -| snip_compact | 保留头 3 + 尾 47 | CC 仅主线程启用;实现不在开源仓库中(`HISTORY_SNIP` feature gate),但接口可见:`snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`,还暴露了 `SnipTool` 工具让模型主动调用。教学版的 3/47 是简化参数 | -| micro_compact | 文本占位符替换 | 两条路径:time-based 直接清内容,cached 走 API `cache_edits`(legacy path 已移除) | -| micro_compact 白名单 | 按位置(最近 3 条) | time-based 按时间阈值触发;cached 按计数触发(`microCompact.ts`) | -| tool_result_budget | 200KB 字符 | 200,000 字符(`toolLimits.ts:49`) | -| compact_history 阈值 | 字符数估算 | 精确 token:`contextWindow - maxOutputTokens - 13_000` | -| 摘要要求 | 5 类信息 | 9 个部分 + ``/`` 双标签 | -| 压缩 prompt | 简单 prompt | 首尾双重防呆禁止调工具 | -| PTL retry | 有(简化) | `truncateHeadForPTLRetry()` 按消息组回退(`compact.ts:243-290`) | -| 后压缩恢复 | 无(教学版只保留摘要) | 自动重新读取最近文件、计划、agent/skill/tool 等 | -| 熔断器 | 3 次 | 3 次(`autoCompact.ts:70`) | -| reactive 重试 | 1 次 | CC 有更精细的分级重试 | +| Dimension | Teaching Version | Claude Code | +|-----------|-----------------|-------------| +| Execution order | budget → snip → micro → auto | budget → snip → micro → collapse → auto (`query.ts:379-468`) | +| snip_compact | Keep head 3 + tail 47 | Claude Code only enables on main thread; implementation not in open-source repo (`HISTORY_SNIP` feature gate), but interface is visible: `snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`, also exposes `SnipTool` for model-initiated snipping. Teaching version's 3/47 are simplified parameters | +| micro_compact | Text placeholder replacement | Two paths: time-based clears content directly, cached uses API `cache_edits` (legacy path removed) | +| micro_compact whitelist | By position (most recent 3) | time-based triggers by time threshold; cached triggers by count (`microCompact.ts`) | +| tool_result_budget | 200KB characters | 200,000 characters (`toolLimits.ts:49`) | +| compact_history threshold | Character count estimate | Precise tokens: `contextWindow - maxOutputTokens - 13_000` | +| Summary requirements | 5 categories of info | 9 sections + ``/`` dual tags | +| Compression prompt | Simple prompt | Double-ended hard guardrails forbidding tool calls | +| PTL retry | Yes (simplified) | `truncateHeadForPTLRetry()` retreats by message groups (`compact.ts:243-290`) | +| Post-compaction recovery | None (teaching version only keeps summary) | Auto re-read recent files, plans, agent/skill/tool context | +| Circuit breaker | 3 times | 3 times (`autoCompact.ts:70`) | +| Reactive retry | 1 time | Claude Code has more granular tiered retries | -### 执行顺序详解 +### Execution Order Details -CC 源码 `query.ts` 中的真实顺序: +The real order in Claude Code source `query.ts`: -1. `applyToolResultBudget`(L379):先处理大结果,确保完整内容落盘 -2. `snipCompact`(L403):裁中间消息 -3. `microcompact`(L414):旧结果占位 -4. `contextCollapse`(L441):独立的上下文管理系统(教学版无) -5. `autoCompact`(L454):LLM 全量摘要 +1. `applyToolResultBudget` (L379): persist large results first, ensuring full content is saved +2. `snipCompact` (L403): trim middle messages +3. `microcompact` (L414): old result placeholders +4. `contextCollapse` (L441): independent context management system (not in teaching version) +5. `autoCompact` (L454): LLM full summary -教学版的 budget → snip → micro 顺序与此一致。教学版没有 contextCollapse 机制。 +The teaching version's budget → snip → micro order matches this. The teaching version does not have the contextCollapse mechanism. -### read_file 的取舍 +### read_file Trade-off -教学版的 `micro_compact` 会把旧 `tool_result` 统一替换成占位符,包括 `read_file`。这通常不影响功能正确性:如果后续还需要文件内容,模型可以重新读一次。代价是可能多一次工具调用,也可能降低 prompt cache 命中率。 +The teaching version's `micro_compact` replaces old `tool_result` blocks with placeholders uniformly, including `read_file`. This usually does not affect functional correctness: if the model needs the file contents later, it can read the file again. The cost is an extra tool call and potentially lower prompt cache hit rates. -Claude Code 没有用教学版这种简单规则解决这个问题。它把 `Read` 也放进可 microcompact 的工具集合,但同时维护 `readFileState`:重复读取未变化文件时返回 `FILE_UNCHANGED_STUB`,compact 后再按预算恢复最近读过的文件内容(例如最多 5 个文件、每个 5K token、总预算 50K token)。这是生产级实现里的缓存和恢复机制,教学版不展开,保留“压缩旧结果,必要时重新读取”的简单 trade-off。 +Claude Code does not solve this with the teaching version's simple rule. It also puts `Read` in the microcompactable tool set, but maintains a separate `readFileState`: repeated reads of unchanged files return `FILE_UNCHANGED_STUB`, and after compaction it restores recently read file contents within a budget (for example, up to 5 files, 5K tokens per file, 50K tokens total). That is a production-level cache and recovery mechanism. The teaching version does not expand into that machinery; it keeps the simpler trade-off of compacting old results and re-reading when needed. -### 完整常量参考 +### Full Constant Reference -| 常量 | 值 | 源文件 | -|------|-----|--------| +| Constant | Value | Source File | +|----------|-------|-------------| | `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` | | `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` | | `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` | | `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` | | `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` | | `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` | -| 时间 micro_compact 间隔 | 60 分钟 | `timeBasedMCConfig.ts` | +| Time micro_compact interval | 60 minutes | `timeBasedMCConfig.ts` | | `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` | -### contextCollapse 和 sessionMemoryCompact +### contextCollapse and sessionMemoryCompact -CC 源码中还有两个机制本教学版没有展开: +Claude Code source code has two additional mechanisms not covered in this teaching version: -- **contextCollapse**:独立的上下文管理系统,启用时抑制 proactive autocompact(`autoCompact.ts:215-222`),由 collapse 的 commit/blocking 流程接管上下文管理。但 manual `/compact` 和 reactive fallback 仍是独立路径,不受 contextCollapse 影响。 -- **sessionMemoryCompact**:compact_history 之前,CC 会先尝试用已有的 session memory(s09 会讲到)做轻量摘要,不调 LLM。这个机制等学完 s09 之后回头看会更清楚。 +- **contextCollapse**: An independent context management system that, when enabled, suppresses proactive autocompact (`autoCompact.ts:215-222`), with collapse's commit/blocking flow taking over context management. Manual `/compact` and reactive fallback remain independent paths, unaffected by contextCollapse. +- **sessionMemoryCompact**: Before compact_history, Claude Code first attempts a lightweight summary using existing session memory (covered in s09) without calling the LLM. This mechanism becomes clearer after learning s09. -### 压缩 prompt 长什么样? +### What Does the Compression Prompt Look Like? -CC 的压缩 prompt 有两个硬性要求: +Claude Code's compression prompt has two hard requirements: -1. **绝对禁止调用工具**:开头就是 `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`,末尾还会再 REMINDER 一次 -2. **先分析再总结**:模型需要先在 `` 标签里理清思路,然后在 `` 标签里输出正式摘要。analysis 在格式化时被剥离 +1. **Absolutely no tool calls**: It begins with `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`, and appends another REMINDER at the end +2. **Analyze first, then summarize**: The model must first reason in an `` tag, then output the formal summary in a `` tag. The analysis is stripped during formatting -### 教学版的简化是刻意的 +### Teaching Version Simplifications Are Intentional -- micro_compact 用文本占位 → 我们没有 API 层的 `cache_edits` 权限 -- read_file 不特殊处理 → 教学版接受必要时重新读取,避免引入 readFileState 和后压缩恢复机制 -- token 用字符数估算 → 精确 tokenizer 不在教学范围内 -- 后压缩恢复省略 → 教学版只保留摘要,不自动重新附加文件 -- 两个辅助机制不展开 → 属于 10% 的细节 +- micro_compact uses text placeholders → we don't have API-level `cache_edits` access +- read_file is not special-cased → the teaching version accepts re-reading when needed instead of introducing readFileState and post-compaction recovery +- Tokens estimated via character count → precise tokenizers are out of scope +- Post-compaction recovery omitted → teaching version only keeps summary, does not auto re-attach files +- Two auxiliary mechanisms not covered → they fall in the 10% detail category -核心设计思想,便宜的先跑贵的后跑,完整保留。 +The core design principle is fully preserved.
- + diff --git a/s08_context_compact/README.zh.md b/s08_context_compact/README.zh.md new file mode 100644 index 000000000..f089114bd --- /dev/null +++ b/s08_context_compact/README.zh.md @@ -0,0 +1,305 @@ +# s08: Context Compact — 上下文总会满,要有办法腾地方 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 +> *"上下文总会满, 要有办法腾地方"* — 四层压缩策略, 便宜的先跑贵的后跑。 +> +> **Harness 层**: 压缩 — 上下文超限时自动摘要,保持会话可持续。 + +--- + +## 问题 + +上一章给 Agent 加了 Skills,它开始有了一点"领域经验":遇到 PDF、MCP、代码审查,会先加载对应的操作说明再动手。 + +但 Agent 越会做事,另一个问题越明显。它读一个 1000 行的文件就是 ~4000 token,又读了 30 个文件,跑了 20 条命令。每条命令的输出、每个文件的内容,都被塞回 `messages` 列表,一点点堆起来。 + +普通聊天几十轮不算什么。代码 Agent 不一样:一次读取就是几千行,一次测试就是一大段日志。任务还没做完,上下文窗口可能先满了。 + +满了之后,问题不是"模型答得差一点",而是 API 直接拒绝:`prompt_too_long`。不压缩,Agent 根本没法在大项目里干活。 + +--- + +## 解决方案 + +![Compact Overview](images/compact-overview.svg) + +保留 s07 的 hook 结构、技能加载、子 Agent 骨架,这一章只往里加一层:每次调用 LLM 之前,先整理 `messages`。 + +最直接的想法是满了就让模型总结一下。但这里有两个问题。一是总结要多花一次 API 调用,每次上下文变大就摘要,成本很快上去。二是并不是所有内容都值得总结:很多旧工具结果早就不需要了;有些内容只是大,比如一次 `cat` 出几百 KB 日志,它不需要被"理解",只需要从上下文里挪走,必要时再读回来。 + +所以 compact 不是一个动作,是一条管线。**便宜的先跑,贵的后跑**:先做几步不调模型的本地整理,能裁的裁、能占位的占位、能落盘的落盘;只有这些都不够,才让 LLM 做一次真正的摘要。 + +--- + +## 工作原理 + +![四层压缩管线](images/compaction-layers.svg) + +### L1: snip_compact — 裁掉无关的旧对话 + +Agent 跑了 80 轮,`messages` 攒了 160 条。最前面那句"帮我创建 hello.py"和当前工作几乎无关了,但还占着位置。 + +消息数超过 50 条 → 保留头部 3 条(最初的任务、约束)和尾部 47 条(当前工作),中间裁掉。唯一要小心的边界:不能把 `assistant(tool_use)` 和后面的 `user(tool_result)` 拆开,否则模型会看到一个不知道对应哪次调用的孤立结果。 + +```python +def snip_compact(messages, max_messages=50): + if len(messages) <= max_messages: return messages + keep_head, keep_tail = 3, max_messages - 3 + head_end, tail_start = keep_head, len(messages) - keep_tail + if head_end > 0 and _message_has_tool_use(messages[head_end - 1]): + while head_end < len(messages) and _is_tool_result_message(messages[head_end]): + head_end += 1 + if (tail_start > 0 and tail_start < len(messages) + and _is_tool_result_message(messages[tail_start]) + and _message_has_tool_use(messages[tail_start - 1])): + tail_start -= 1 + if head_end >= tail_start: return messages + snipped = tail_start - head_end + return messages[:head_end] + [{"role": "user", "content": f"[snipped {snipped} messages]"}] + messages[tail_start:] +``` + +裁掉的是消息本身,在切口处多做一步保护。但剩下的消息里,`tool_result` 内容仍在累积,第 34 条消息里可能还躺着 30KB 的旧文件内容。消息数少了,token 没少。→ L2。 + +### L2: micro_compact — 旧工具结果占位 + +![旧结果占位](images/micro-compact.svg) + +最容易把上下文撑大的,往往不是对话本身,而是工具结果。Agent 连续读了 10 个文件,第 1 到第 7 个的完整内容早就不需要了,却还原样躺在上下文里。 + +保留最近 3 条 `tool_result` 的完整内容,更早的换成一行占位符。想法很朴素:旧结果真要用,模型重新读一次就行,不该一直占着位置。 + +```python +KEEP_RECENT = 3 + +def micro_compact(messages): + tool_results = collect_tool_results(messages) + if len(tool_results) <= KEEP_RECENT: return messages + for _, _, block in tool_results[:-KEEP_RECENT]: + if len(block.get("content", "")) > 120: + block["content"] = "[Earlier tool result compacted. Re-run if needed.]" + return messages +``` + +旧结果清掉了,但还挡不住一种情况:单条新结果就有 500KB,一次 `cat` 大文件的输出就能用满上下文,而它还很新,micro_compact 不会动它。→ L3。 + +### L3: tool_result_budget — 大结果落盘 + +![大结果落盘](images/layer1-budget.svg) + +有些结果不是"多",是"单条太大"。模型一次读了 5 个大文件,最后一条 user 消息里的 `tool_result` 加起来超过 200KB,这时候只保留最近 3 条没用,因为最新的那条本身就能用满上下文。 + +给工具结果设一个预算。统计最后一条 user 消息里所有 `tool_result` 的总大小,超过 200KB 就从最大的开始落盘到 `.task_outputs/tool-results/`,上下文里只留一个 `` 标记 + 前 2000 字符预览。模型看到标记就知道完整内容在磁盘上,需要时再读回来。 + +```python +def tool_result_budget(messages, max_bytes=200_000): + last = messages[-1] if messages else None + if not last or last.get("role") != "user" or not isinstance(last.get("content"), list): return messages + blocks = [(i, b) for i, b in enumerate(last["content"]) + if isinstance(b, dict) and b.get("type") == "tool_result"] + total = sum(len(str(b.get("content", ""))) for _, b in blocks) + if total <= max_bytes: return messages + ranked = sorted(blocks, key=lambda p: len(str(p[1].get("content", ""))), reverse=True) + for _, block in ranked: + if total <= max_bytes: break + block["content"] = persist_large_output(block.get("tool_use_id", "unknown"), str(block.get("content", ""))) + total = sum(len(str(b.get("content", ""))) for _, b in blocks) + return messages +``` + +这一步重要的不是丢,是把内容从"活跃上下文"移到"可恢复的外部存储"。前三层到这就齐了:纯文本/结构操作,0 API 调用,各盯一种冗余。但它们都有同一个限制:读不懂对话在说什么,不知道哪些发现重要、哪些约束必须留。如果上下文还是太大,就只能让模型出手。→ L4。 + +### L4: compact_history — LLM 全量摘要 + +![LLM 全量摘要](images/auto-compact.svg) + +前三层全跑完,token 还是超阈值。这一步才是很多人直觉里的"上下文压缩":把历史交给模型,摘成一段更短的状态。 + +三步:先把完整对话写进 `.transcripts/`(JSONL),活跃上下文里只剩摘要,但磁盘上留着完整记录;再让 LLM 生成摘要,要求保留当前目标、重要发现、已改文件、剩余工作、用户约束;最后用这一条摘要替换掉所有旧消息。 + +```python +def compact_history(messages): + transcript_path = write_transcript(messages) # 先存完整对话 + summary = summarize_history(messages) # LLM 生成摘要 + return [{"role": "user", "content": f"[Compacted]\n\n{summary}"}] +``` + +这一步是有损的:transcript 里有完整历史,但模型当下看不到那些细节了,只能靠摘要继续。所以前面才要先跑 L1/L2/L3:能不让模型总结就不总结,因为一旦进摘要,细节就不可避免地丢。教学版还加了个熔断器:连续 compact 失败 3 次就停,不无限重试浪费 API。 + +### 应急: reactive_compact + +正常情况下,我们在调用模型之前就把上下文整理好。但上下文增长太快、或 token 估算不准时,API 还是可能返回 `prompt_too_long`。 + +这时走 reactive_compact:和 compact_history 很像,但更激进:先存 transcript,对前面大半段生成摘要,只保留最后 5 条作尾部上下文(同样避免留下孤立 `tool_result`)。 + +```python +def reactive_compact(messages): + transcript = write_transcript(messages) + tail_start = max(0, len(messages) - 5) + if (tail_start > 0 and tail_start < len(messages) + and _is_tool_result_message(messages[tail_start]) + and _message_has_tool_use(messages[tail_start - 1])): + tail_start -= 1 + summary = summarize_history(messages[:tail_start]) + return [{"role": "user", "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]] +``` + +reactive 是兜底路径,不是常规路径,默认只重试 1 次,再失败就抛异常,不无限循环。完整的错误恢复逻辑留给 s11。 + +### 合起来跑 + +把这些挂回 Agent Loop:每轮调用 LLM 之前,先跑三层本地整理,不够再摘要;调用真报错了,再走应急。 + +```python +def agent_loop(messages): + reactive_retries = 0 + while True: + # 三个预处理器(0 API),顺序:budget → snip → micro + messages[:] = tool_result_budget(messages) # L3: 大结果落盘 + messages[:] = snip_compact(messages) # L1: 裁中间 + messages[:] = micro_compact(messages) # L2: 旧结果占位 + + if estimate_size(messages) > CONTEXT_LIMIT: # 还不够,LLM 摘要(1 API) + messages[:] = compact_history(messages) + + try: + response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000) + except Exception as e: + if "prompt_too_long" in str(e).lower() and reactive_retries < MAX_REACTIVE_RETRIES: + messages[:] = reactive_compact(messages) # 应急 + reactive_retries += 1 + continue + raise + # ... 工具执行 ... +``` + +**顺序不能换。** L3(budget)必须在 L2(micro)前面:micro 会把旧的大 `tool_result` 换成一行占位符,要是它先跑,budget 就没机会把完整内容落盘了。先 budget 把大内容存好,再做占位和裁剪。这也是 Claude Code 源码把 `applyToolResultBudget` 放在最前面的原因。 + +### compact 工具:让模型也能主动请求 + +除了自动压缩,模型自己也能要求整理:当它觉得上下文太长、或任务阶段切换了,可以主动调 `compact` 工具。在教学版里,这个工具触发 `compact_history`,然后结束当前 turn,用压缩后的上下文重新开一轮。和手动 `/compact` 的感觉很像,区别是这次是模型自己意识到要整理。 + +--- + +## 相对 s07 的变更 + +| 组件 | 之前 (s07) | 之后 (s08) | +|------|-----------|-----------| +| 上下文管理 | 无(上下文无限膨胀) | 四层压缩管线 + 应急 | +| 新函数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact | +| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) | +| 循环 | LLM 调用 → 工具执行 | 每轮前跑三层预处理器 + 阈值触发 compact_history | +| 设计原则 | 让 Agent 会做事 | 让 Agent 做久一点也不崩 | + +这一步不算给 Agent 加"能力",更像加"体力":s07 让它更会做专业任务,s08 让它在长任务里不被自己的历史拖垮。 + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s08_context_compact/code.py +``` + +试试这些 prompt: + +1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(连续读多个文件,观察 L2 压缩旧结果) +2. `Read every file in s08_context_compact/`(一次性读大量内容,观察 L3 落盘) +3. 反复对话 20+ 轮,观察是否出现 `[auto compact]` 或 `[reactive compact]` + +观察重点:每次工具执行后,旧 `tool_result` 是否被替换?大输出有没有落盘?token 超阈值时是否生成了摘要? + +--- + +## 接下来 + +压缩让 Agent 能跑很久不崩。但每次压缩都会丢一些细节:用户之前说过的偏好、项目里的长期约束、某些跨任务都重要的信息,不一定能完整留在摘要里。 + +compact 解决的是"当前会话快满了,怎么继续跑下去"。它没解决"哪些信息值得长期留下来"。 + +s09 Memory → 三个子系统:选择记什么、提取关键信息、整理巩固。跨压缩、跨会话。 + +
+深入 Claude Code 源码 + +> 以下基于 Claude Code 源码 `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` 的分析。 + +### 执行顺序对照 + +教学版为了讲解方便按 L1/L2/L3/L4 编号,但实际执行顺序和编号不完全对应: + +| 维度 | 教学版 | Claude Code | +|------|--------|-------------| +| 执行顺序 | budget → snip → micro → auto | budget → snip → micro → collapse → auto(`query.ts:379-468`) | +| snip_compact | 保留头 3 + 尾 47 | Claude Code 仅主线程启用;实现不在开源仓库中(`HISTORY_SNIP` feature gate),但接口可见:`snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`,还暴露了 `SnipTool` 工具让模型主动调用。教学版的 3/47 是简化参数 | +| micro_compact | 文本占位符替换 | 两条路径:time-based 直接清内容,cached 走 API `cache_edits`(legacy path 已移除) | +| micro_compact 白名单 | 按位置(最近 3 条) | time-based 按时间阈值触发;cached 按计数触发(`microCompact.ts`) | +| tool_result_budget | 200KB 字符 | 200,000 字符(`toolLimits.ts:49`) | +| compact_history 阈值 | 字符数估算 | 精确 token:`contextWindow - maxOutputTokens - 13_000` | +| 摘要要求 | 5 类信息 | 9 个部分 + ``/`` 双标签 | +| 压缩 prompt | 简单 prompt | 首尾双重防呆禁止调工具 | +| PTL retry | 有(简化) | `truncateHeadForPTLRetry()` 按消息组回退(`compact.ts:243-290`) | +| 后压缩恢复 | 无(教学版只保留摘要) | 自动重新读取最近文件、计划、agent/skill/tool 等 | +| 熔断器 | 3 次 | 3 次(`autoCompact.ts:70`) | +| reactive 重试 | 1 次 | Claude Code 有更精细的分级重试 | + +### 执行顺序详解 + +Claude Code 源码 `query.ts` 中的真实顺序: + +1. `applyToolResultBudget`(L379):先处理大结果,确保完整内容落盘 +2. `snipCompact`(L403):裁中间消息 +3. `microcompact`(L414):旧结果占位 +4. `contextCollapse`(L441):独立的上下文管理系统(教学版无) +5. `autoCompact`(L454):LLM 全量摘要 + +教学版的 budget → snip → micro 顺序与此一致。教学版没有 contextCollapse 机制。 + +### read_file 的取舍 + +教学版的 `micro_compact` 会把旧 `tool_result` 统一替换成占位符,包括 `read_file`。这通常不影响功能正确性:如果后续还需要文件内容,模型可以重新读一次。代价是可能多一次工具调用,也可能降低 prompt cache 命中率。 + +Claude Code 没有用教学版这种简单规则解决这个问题。它把 `Read` 也放进可 microcompact 的工具集合,但同时维护 `readFileState`:重复读取未变化文件时返回 `FILE_UNCHANGED_STUB`,compact 后再按预算恢复最近读过的文件内容(例如最多 5 个文件、每个 5K token、总预算 50K token)。这是生产级实现里的缓存和恢复机制,教学版不展开,保留“压缩旧结果,必要时重新读取”的简单 trade-off。 + +### 完整常量参考 + +| 常量 | 值 | 源文件 | +|------|-----|--------| +| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` | +| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` | +| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` | +| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` | +| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` | +| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` | +| 时间 micro_compact 间隔 | 60 分钟 | `timeBasedMCConfig.ts` | +| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` | + +### contextCollapse 和 sessionMemoryCompact + +Claude Code 源码中还有两个机制本教学版没有展开: + +- **contextCollapse**:独立的上下文管理系统,启用时抑制 proactive autocompact(`autoCompact.ts:215-222`),由 collapse 的 commit/blocking 流程接管上下文管理。但 manual `/compact` 和 reactive fallback 仍是独立路径,不受 contextCollapse 影响。 +- **sessionMemoryCompact**:compact_history 之前,Claude Code 会先尝试用已有的 session memory(s09 会讲到)做轻量摘要,不调 LLM。这个机制等学完 s09 之后回头看会更清楚。 + +### 压缩 prompt 长什么样? + +Claude Code 的压缩 prompt 有两个硬性要求: + +1. **绝对禁止调用工具**:开头就是 `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`,末尾还会再 REMINDER 一次 +2. **先分析再总结**:模型需要先在 `` 标签里理清思路,然后在 `` 标签里输出正式摘要。analysis 在格式化时被剥离 + +### 教学版的简化是刻意的 + +- micro_compact 用文本占位 → 我们没有 API 层的 `cache_edits` 权限 +- read_file 不特殊处理 → 教学版接受必要时重新读取,避免引入 readFileState 和后压缩恢复机制 +- token 用字符数估算 → 精确 tokenizer 不在教学范围内 +- 后压缩恢复省略 → 教学版只保留摘要,不自动重新附加文件 +- 两个辅助机制不展开 → 属于 10% 的细节 + +核心设计思想完整保留。 + +
+ + diff --git a/s08_context_compact/code.py b/s08_context_compact/code.py index 7186df554..6a44e6e9b 100644 --- a/s08_context_compact/code.py +++ b/s08_context_compact/code.py @@ -24,7 +24,7 @@ └─────────────────────────────────────────────────────────────┘ Core principle: cheap first, expensive last. -Execution order matches CC source: budget → snip → micro → auto. +Execution order matches Claude Code source: budget → snip → micro → auto. Builds on s07 (skill loading). Usage: @@ -455,7 +455,7 @@ def agent_loop(messages: list): reactive_retries = 0 while True: # s08 change: three preprocessors (0 API calls, cheap first) - # Order matches CC source: budget → snip → micro + # Order matches Claude Code source: budget → snip → micro messages[:] = tool_result_budget(messages) # L3: persist large results first messages[:] = snip_compact(messages) # L1: trim middle messages[:] = micro_compact(messages) # L2: old result placeholders diff --git a/s08_context_compact/images/auto-compact.en.svg b/s08_context_compact/images/auto-compact.en.svg deleted file mode 100644 index 30f5d7860..000000000 --- a/s08_context_compact/images/auto-compact.en.svg +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - L4: autoCompact — LLM Full Summary - - - - Trigger Condition - All three preprocessing layers have run, estimated tokens > contextWindow - maxOutputTokens - 13_000. - Tries sessionMemoryCompact first (lightweight summary from existing memory), only calls LLM if insufficient. - - - - Step 1: Save transcript - Write conversation to .transcripts/ - One JSONL message per line - File: transcript_{time}.jsonl - Full transcript stays on disk - - - - - Step 2: LLM generates summary - Send conversation history to LLM - Summary must include 9 sections: - request · concepts · files · errors - resolutions · user messages · todos - current state · next steps - - - - - Step 3: Replace message list - All old messages → 1 summary - Model continues from summary - Includes recently_read file list - ⚠ This is an irreversible operation - - - - Before messages - user - assistant - user - assistant - user - ~180 messages, occupying 62K tokens - - - - - After messages - - [Compacted] Summary: goal → create hello.py ... - Recent files: hello.py, README.md ... - ~1 message, occupying 1K tokens - - - - Circuit breaker: - 3 consecutive autocompact failures → stop retrying. Prevents wasting API calls when context is unrecoverable. - diff --git a/s08_context_compact/images/auto-compact.ja.svg b/s08_context_compact/images/auto-compact.ja.svg deleted file mode 100644 index b83a3f500..000000000 --- a/s08_context_compact/images/auto-compact.ja.svg +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - L4: autoCompact — LLM 完全要約 - - - - トリガー条件 - 前 3 層の前処理を全て実行後、推定 token > contextWindow - maxOutputTokens - 13_000。 - まず sessionMemoryCompact を試行(既存のメモリで軽量要約)、不足時のみ LLM を呼び出し。 - - - - ステップ 1:transcript 保存 - 完全な対話を .transcripts/ に書き込み - JSONL 形式、1 行 1 メッセージ - transcript_{time}.jsonl - 内容はディスクに残る - - - - - ステップ 2:LLM 要約生成 - 対話履歴を LLM に送信 - 要約は 9 つのセクションを含む: - リクエスト・概念・ファイル・エラー・解決 - ユーザーメッセージ・TODO・現在・次ステップ - 1 回のみ生成 - - - - - ステップ 3:要約に置換 - 全旧メッセージ → 1 件の要約に - モデルは要約から作業を継続 - recently_read を添付 - ⚠ これは復元不可能な操作 - - - - 圧縮前 messages - user - assistant - user - assistant - user - ~180 件のメッセージ、62K トークンを占有 - - - - - 圧縮後 messages - - [Compacted] 要約:目標 → hello.py を作成 ... - 最近のファイル:hello.py, README.md ... - ~1 件のメッセージ、1K トークンを占有 - - - - サーキットブレーカー: - autocompact が連続 3 回失敗 → リトライ停止。コンテキストが復元不可能な場合の API 呼び出しの無駄な反復を防止。 - diff --git a/s08_context_compact/images/auto-compact.svg b/s08_context_compact/images/auto-compact.svg index c7691f956..ca0c21d17 100644 --- a/s08_context_compact/images/auto-compact.svg +++ b/s08_context_compact/images/auto-compact.svg @@ -1,72 +1,112 @@ - + - - - - - + + + + + - - - - L4: autoCompact — LLM 全量摘要 - - - - 触发条件 - 前三层预处理全跑完,估算 token > contextWindow - maxOutputTokens - 13_000。 - 先尝试 sessionMemoryCompact(用已有记忆做轻量摘要),不足才调 LLM。 - - - - 步骤 1:保存 transcript - 完整对话写入 .transcripts/ - JSONL 格式,一行一条消息 - 文件名:transcript_{timestamp}.jsonl - 信息没有丢失,只是移出活跃区 - - - - - 步骤 2:LLM 生成摘要 - 把对话历史发给 LLM - 摘要需包含 9 个部分: - 请求·概念·文件·错误·解决 - 用户消息·待办·当前·下一步 - 只生成一次 - - - - - 步骤 3:替换消息列表 - 所有旧消息 → 1 条摘要 - 模型从摘要继续工作 - 附带 recently_read 文件列表 - ⚠ 这是无法恢复的操作 - - - - 压缩前 messages - user - assistant - user - assistant - user - ~180 条消息,占 62K token - - - - - 压缩后 messages - - [Compacted] 摘要:目标 → 创建 hello.py ... - 最近文件:hello.py, README.md ... - ~1 条消息,占 1K token - - - - 熔断器: - 连续 autocompact 失败 3 次 → 停止重试。防止上下文不可恢复时反复浪费 API 调用。 - + + + + + L4: compact_history + Full LLM Summary — when L1-L3 are exhausted and tokens still exceed threshold + + + + Trigger Condition + L1-L3 pre-processing complete; estimated tokens > contextWindow - maxOutputTokens - 13,000 + + + + Step 1 + Save Transcript + + Full conversation written to + + + .transcripts/ + JSONL format, one msg per line + Preserves recoverable record + + + + + + + + Step 2 + LLM Generates Summary + + Send history to LLM + Summary must include: + goals, concepts, files, errors, + resolutions, user msgs, TODOs, + current state, next steps + + + + + + + + Step 3 + Replace Messages + + All old msgs replaced + with 1 summary message + Attaches recently_read + file list for re-access + + + + + + Before Compaction + + + + + user + + + assistant + + + user + + + assistant + + + user + + ... many more messages ... + ~180 messages, ~62K tokens + + + + + + + + After Compaction + + + + + [Compacted] Goal: create hello.py + Recent files: hello.py, README.md + + ~1 message, ~1K tokens + + + + Circuit Breaker + + 3 consecutive autocompact failures → stop retrying + Prevents wasting API calls when context is unrecoverable + \ No newline at end of file diff --git a/s08_context_compact/images/compact-overview.en.svg b/s08_context_compact/images/compact-overview.en.svg deleted file mode 100644 index 542b15663..000000000 --- a/s08_context_compact/images/compact-overview.en.svg +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Context Compact — Compression Before LLM Call, Three Trigger Modes - - - s07 Preserved - s08 New - - - - messages[] - (s07 preserved) - - - - - - - Compression Pipeline - - - - ① Every Turn · Unconditional · 0 API - - - L3 tool_result_budget - - - L1 snip_compact - - - L2 micro_compact - - - - - - - Over threshold? - - - No → Pass - Straight to LLM - - - Yes↓ - - - - ② Conditional · Token Over Threshold · 1 API - - - L4 compact_history - - - - - - - LLM - stop_reason=tool_use? - - - - No - - Return Result - - - - Yes - - - - TOOL_HANDLERS - bash · read · write - task · load_skill · ... - - - - API error - - retry to compression pipeline - - - - ③ Emergency Trigger - API returns prompt_too_long - → reactive_compact → retry - - - - Tool results appended to messages[] → next turn → compress again → LLM - - - - - - s07 Preserved: loop, hooks, skill loading, sub-agents - - - ① Every Turn Auto: L3→L1→L2 run unconditionally before each LLM call, 0 API - - - ② Conditional: after L3/L1/L2, tokens still over threshold → compact_history, 1 API - - - ③ Emergency: API returns prompt_too_long → reactive_compact → retry - - Three modes with increasing cost: 0 API → 1 API → 1 API + more aggressive trimming - diff --git a/s08_context_compact/images/compact-overview.ja.svg b/s08_context_compact/images/compact-overview.ja.svg deleted file mode 100644 index 350cd13e8..000000000 --- a/s08_context_compact/images/compact-overview.ja.svg +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Context Compact — LLM 呼び出し前に圧縮、3 つのトリガーモード - - - s07 保持 - s08 新規 - - - - messages[] - (s07 保持) - - - - - - - 圧縮パイプライン - - - - ① 毎ターン自動 · 無条件 · 0 API - - - L3 tool_result_budget - - - L1 snip_compact - - - L2 micro_compact - - - - - - - 閾値超過? - - - No → 通過 - 直接 LLM へ - - - Yes↓ - - - - ② 条件 · トークン閾値超過 · 1 API - - - L4 compact_history - - - - - - - LLM - stop_reason=tool_use? - - - - No - - 結果を返す - - - - Yes - - - - TOOL_HANDLERS - bash · read · write - task · load_skill · ... - - - - API 例外 - - 圧縮パイプラインへ再試行 - - - - ③ 緊急トリガー - API が prompt_too_long を返す - → reactive_compact → リトライ - - - - ツール結果を messages[] に追加 → 次ターン → 再圧縮 → LLM - - - - - - s07 保持:ループ、フック、スキルロード、サブエージェント - - - ① 毎ターン自動:L3→L1→L2 が各 LLM 呼び出し前に無条件実行、0 API - - - ② 条件トリガー:L3/L1/L2 後もトークン超過 → compact_history、1 API - - - ③ 緊急トリガー:API が prompt_too_long を返す → reactive_compact → リトライ - - 3 つのモードはコスト増加:0 API → 1 API → 1 API + より積極的なトリム - diff --git a/s08_context_compact/images/compact-overview.svg b/s08_context_compact/images/compact-overview.svg index 837e9bb00..d8aa12a11 100644 --- a/s08_context_compact/images/compact-overview.svg +++ b/s08_context_compact/images/compact-overview.svg @@ -1,138 +1,131 @@ - + - - + + - - + + - - - - - - - - - - - - - - - - - - - - Context Compact — 压缩插在 LLM 调用前,三种触发模式 - - - s07 保留 - s08 新增 - - - - messages[] - (s07 保留) - - - - - - - 压缩管线 - - - - ① 每轮自动 · 无条件 · 0 API - - - L3 tool_result_budget - - - L1 snip_compact - - - L2 micro_compact - - - - - - - 超阈值? - - - 否 → 通过 - 直接进 LLM - - - 是↓ - - - - ② 条件触发 · token 超阈值 · 1 API - - - L4 compact_history - - - - - - - LLM - stop_reason=tool_use? - - - - - - 返回结果 - - - - - - - - TOOL_HANDLERS - bash · read · write - task · load_skill · ... - - - - API 异常 - - 重试回到压缩管线 - - - - ③ 异常触发 - API 返回 prompt_too_long - → reactive_compact → 重试 - - - - 工具结果追加到 messages[] → 下一轮 → 再次压缩 → LLM - - - - - - s07 保留:循环、hook、技能加载、子 Agent + + - - ① 每轮自动:L3→L1→L2 在每次 LLM 调用前无条件执行,0 API + + Context Compact Overview + Compression before each LLM call — three trigger modes + - - ② 条件触发:L3/L1/L2 跑完 token 仍超阈值 → compact_history,1 API + - - ③ 异常触发:API 返回 prompt_too_long → reactive_compact → 重试 + + + messages[] + history + + + - 三种模式的代价递增:0 API → 1 API → 1 API + 更激进的裁剪 + + + Compression Pipeline + + + + MODE 1 Per-turn auto · 0 API calls + + + + L3 tool_result_budget + + + + + + + L1 snip_compact + + + + + + + L2 micro_compact + + + + + + + tokens > + threshold? + + + + NO + + + + YES + + + + MODE 2 Conditional · 1 API call + + + + L4 compact_history + + + + + + + + + + + + + + LLM + stop_reason? + + + + tool_use + + + + TOOL_HANDLERS + bash · read · write ... + + + + end_turn + + + + Return Result + + + + + + Tool results appended to messages[] → next turn → compress again → LLM + + + + + MODE 3 Reactive trigger + API returns prompt_too_long + reactive_compact → retry + + + + API error + + + + Retry back to compression pipeline diff --git a/s08_context_compact/images/compaction-layers.en.svg b/s08_context_compact/images/compaction-layers.en.svg deleted file mode 100644 index 5a27e96da..000000000 --- a/s08_context_compact/images/compaction-layers.en.svg +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - Context Compaction — Pre-processing Pipeline + Auto-compact + Emergency Fallback - - - - Design Principles - Cheap operations first, expensive later - Trim text before dropping messages - Drop messages before calling LLM - - - - Increasing Cost - Text ops → LLM summary → Emergency trim - 0 API · 0 API · 0 API · 1 API · 1 API - - - - Pre-processing Pipeline (execution order: L3 → L1 → L2, before every LLM call, 0 API) - - - - L3 - toolResultBudget - tool_result total > 200KB → spill largest item - keep full content - Trigger: every turn, before microCompact can replace full content - - - - - - - L1 - snipCompact - messages > 50 → trim middle - keep head/tail - Trigger: message count exceeds threshold - - - - - - - L2 - microCompact - old tool_result → placeholder (keep latest 3) - compact old - Trigger: every turn automatically; tutorial uses text placeholder - - - - Auto-compact Decision (triggered when pre-processing is insufficient, 1 API call) - - - - L4 - autoCompact - tokens over threshold → LLM summary - 1 API call - Threshold: contextWindow - maxOutputTokens - 13,000 · Try sessionMemoryCompact first, then LLM - Circuit breaker: stop retrying after 3 consecutive failures - - - - Emergency Fallback (triggered when API still returns prompt_too_long) - - - - Emrg - reactiveCompact - API returns 413 / prompt_too_long → byte-level trim - Keep last 5 + summary; more aggressive than autoCompact - - diff --git a/s08_context_compact/images/compaction-layers.ja.svg b/s08_context_compact/images/compaction-layers.ja.svg deleted file mode 100644 index 851905483..000000000 --- a/s08_context_compact/images/compaction-layers.ja.svg +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - コンテキスト圧縮 — 前処理パイプライン + 自動圧縮 + 緊急フォールバック - - - - 設計原則 - 安価な処理を先に、高価な処理を後に - テキスト修正 → メッセージ削除の順 - メッセージ削除 → LLM 呼び出しの順 - - - - コスト増加 - テキスト操作 → LLM 要約 → 緊急トリム - 0 API · 0 API · 0 API · 1 API · 1 API - - - - 前処理パイプライン(実行順:L3 → L1 → L2、各 LLM 呼び出し前に自動実行、0 API) - - - - L3 - toolResultBudget - tool_result 合計 > 200KB → 最大項目を退避 - 完全内容を保持 - トリガー:毎ターン、microCompact が完全内容を置換する前に実行 - - - - - - - L1 - snipCompact - メッセージ > 50 → 中間をトリム - 先頭/末尾保持 - トリガー:メッセージ数が閾値を超過 - - - - - - - L2 - microCompact - 古い tool_result → プレースホルダー(最新 3 件保持) - 旧結果を圧縮 - トリガー:毎ターン自動実行、チュートリアル版はテキストプレースホルダーで模擬 - - - - 自動圧縮判定(前処理で不足時にトリガー、1 API 呼び出し) - - - - L4 - autoCompact - トークンが閾値超過 → LLM 全量要約 - 1 API 呼び出し - 閾値: contextWindow - maxOutputTokens - 13,000 · sessionMemoryCompact を先に試行、不足時のみ LLM 呼び出し - サーキットブレーカー:連続 3 回失敗後にリトライ停止 - - - - 緊急フォールバック(API が引き続き prompt_too_long を返す場合にトリガー) - - - - 緊急 - reactiveCompact - API が 413 / prompt_too_long を返す → バイト単位でトリム - 最後の 5 件 + 要約を保持、autoCompact より積極的 - - diff --git a/s08_context_compact/images/compaction-layers.svg b/s08_context_compact/images/compaction-layers.svg index 818b44e51..02a6c551a 100644 --- a/s08_context_compact/images/compaction-layers.svg +++ b/s08_context_compact/images/compaction-layers.svg @@ -1,98 +1,139 @@ - + - - - - - - - - - - - - - - + + + + + - + + - - - - 上下文压缩 — 预处理管线 + 自动压缩 + 应急兜底 + + Context Compaction — Four-Layer Pipeline + Cheap first, expensive last — 3 pre-processors (0 API) + 1 LLM summary + emergency fallback - - - 设计原则 - 便宜的先跑,贵的后跑 - 能改文本 → 不删整条 - 能删整条 → 不调 LLM + + + Design Principles + Text ops before dropping messages + Drop messages before calling LLM + LLM summary before emergency trim - - - 代价递增 - 文本操作 → LLM 摘要 → 应急裁剪 - 0 API · 0 API · 0 API · 1 API · 1 API + + + Cost Escalation + Text ops (0 API) — lowest cost + + LLM summary (1 API) — moderate cost + + 0 API -> 0 API -> 0 API -> 1 API - - - 预处理管线(执行顺序:L3 → L1 → L2,每轮 LLM 调用前自动执行,0 API) + + + Pre-Processing Pipeline + (Execution order: L3 → L1 → L2 | runs before each LLM call | 0 API cost) - - L3 - toolResultBudget - tool_result 总和 > 200KB → 最大项落盘 - 保留完整内容 - 触发:每轮自动,必须在 microCompact 之前保留完整内容 + + + + L3 + toolResultBudget + tool_result total > 200KB → persist largest items to disk + Preserves full content in .task_outputs/ before placeholders + + + total > 200KB? + persist_large_output() + + + 0 API - - + + - - L1 - snipCompact - 消息 > 50 条 → 裁掉中间 - 保留头尾 - 触发:消息数超过阈值 + + + + L1 + snipCompact + messages > 50 → keep head 3 + tail 47, trim middle + Boundary guard: tool_use must stay paired with tool_result + + + len(msgs) > 50? + head[:3] + tail[47:] + + + 0 API - - + + - - L2 - microCompact - 旧 tool_result → 占位符(保留最近 3 条) - 压旧结果 - 触发:每轮自动,教学版用文本占位符模拟 - - - - 自动压缩决策(预处理不够时触发,1 API 调用) - - - - L4 - autoCompact - token 超阈值 → LLM 全量摘要 - 1 API 调用 - 阈值: contextWindow - maxOutputTokens - 13,000 · 先尝试 sessionMemoryCompact,不够才调 LLM - 熔断:连续失败 3 次后停止重试 - - - - 应急兜底(API 仍然返回 prompt_too_long 时触发) - - - - 应急 - reactiveCompact - API 返回 413 / prompt_too_long → 字节级裁剪 - 保留最后 5 条 + 摘要,比 autoCompact 更激进 - - + + + + L2 + microCompact + Old tool_result → one-line placeholder (keep most recent 3) + Clears stale content while preserving recent working state + + + keep_recent = 3 + older → placeholder + + + 0 API + + + + + + + still over threshold? + + Auto Compact (1 API call) + + + + + + L4 + compactHistory + tokens > threshold → LLM summary → replace messages + Threshold: contextWindow - maxOutputTokens - 13K | 3-fail breaker + + + write_transcript() + summarize_history() + + + 1 API + + + + + + + API returns prompt_too_long? + + Emergency Fallback + + + + + + EM + reactiveCompact + API returns 413 / prompt_too_long → aggressive byte-level trim + Keep last 5 msgs + summary | Retry: 1 | More aggressive + + + summary + tail[-5:] + max_retries = 1 + \ No newline at end of file diff --git a/s08_context_compact/images/layer1-budget.en.svg b/s08_context_compact/images/layer1-budget.en.svg deleted file mode 100644 index 1870c59b4..000000000 --- a/s08_context_compact/images/layer1-budget.en.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - L3: toolResultBudget — Large Result Persistence - - - - Pain Point - Model read 30 files in one turn; total tool_result adds up to 500KB, filling the entire context window - - - Before - - tool_result: (78KB) ... - tool_result: (142KB) ... - tool_result: (290KB) ... - Total 510KB → over budget - - - - - - After - - tool_result: <persisted-output> - Full output: .task_outputs/t1.txt - Preview: (first 2000 chars) ... - Total 18KB → normal - - - - How - 1. Sum the size of all tool_result in the latest turn - 2. Over 200KB → sort by size, persist the largest to .task_outputs/tool-results/ - 3. Keep only <persisted-output> marker + first 2000 chars preview in context - - - - Result: No data lost (full data on disk), context drops from 510KB to ~18KB, 0 API calls - diff --git a/s08_context_compact/images/layer1-budget.ja.svg b/s08_context_compact/images/layer1-budget.ja.svg deleted file mode 100644 index b76862cbc..000000000 --- a/s08_context_compact/images/layer1-budget.ja.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - L3: toolResultBudget — 大結果の永続化 - - - - ペインポイント - モデルが一度に 30 ファイルを読み込み、単一ターンの tool_result が合計 500KB に達し、コンテキストウィンドウを圧迫 - - - 圧縮前 - - tool_result: (78KB) ... - tool_result: (142KB) ... - tool_result: (290KB) ... - 合計 510KB → 予算超過 - - - - - - 圧縮後 - - tool_result: <persisted-output> - Full output: .task_outputs/t1.txt - Preview: (先頭 2000 文字) ... - 合計 18KB → 正常 - - - - 方法 - 1. 最終ターンの全 tool_result の合計サイズを集計 - 2. 200KB 超過 → サイズ順にソートし、最大のものから .task_outputs/tool-results/ に永続化 - 3. コンテキストには <persisted-output> マーカー + 先頭 2000 文字のプレビューのみ残す - - - - 結果:情報は失われていない(ディスクに完全なデータあり)、コンテキストは 510KB → ~18KB に削減、0 回 API 呼び出し - diff --git a/s08_context_compact/images/layer1-budget.svg b/s08_context_compact/images/layer1-budget.svg index 53f2d5c77..d27b6dad4 100644 --- a/s08_context_compact/images/layer1-budget.svg +++ b/s08_context_compact/images/layer1-budget.svg @@ -1,50 +1,68 @@ - + - - - - - + + + + + - - - - L3: toolResultBudget — 大结果落盘 - - - - 痛点 - 模型一次读了 30 个文件,单轮 tool_result 加起来 500KB,直接把上下文窗口打满 - - - 压缩前 - - tool_result: (78KB) ... - tool_result: (142KB) ... - tool_result: (290KB) ... - 合计 510KB → 超预算 - - - - - - 压缩后 - - tool_result: <persisted-output> - Full output: .task_outputs/t1.txt - Preview: (前 2000 字符) ... - 合计 18KB → 正常 - - - - 怎么做 - 1. 统计最后一轮所有 tool_result 的总大小 - 2. 超过 200KB → 按大小排序,从最大的开始落盘到 .task_outputs/tool-results/ - 3. 上下文里只留 <persisted-output> 标记 + 前 2000 字符预览 - - - - 结果:信息没丢(磁盘有完整数据),上下文从 510KB 降到 ~18KB,0 次 API 调用 - + + + + + L3: toolResultBudget + Persist Large Results to Disk + + + + Problem + Model reads 30 files in one turn — tool_results total 500 KB, filling the entire context window. + + + Before + + + + tool_result: (78 KB) ... + tool_result: (142 KB) ... + tool_result: (290 KB) ... + total: 510 KB + + + 510 KB — over budget + + + + + + After + + + + <persisted-output> + path: .task_outputs/t1.txt + preview: (first 2000 chars) + total: ~18 KB + + + 18 KB — within budget + + + + How it works + + 1. + Sum the size of all tool_result blocks in the last user message. + + 2. + If total > 200 KB, sort by size desc, persist largest to .task_outputs/tool-results/ + + 3. + Keep only <persisted-output> marker + first 2000 chars preview in context. + + + + Result: Data saved to disk. Context reduced from 510 KB to ~18 KB. 0 API calls. + \ No newline at end of file diff --git a/s08_context_compact/images/micro-compact.en.svg b/s08_context_compact/images/micro-compact.en.svg deleted file mode 100644 index 8f5c5dc87..000000000 --- a/s08_context_compact/images/micro-compact.en.svg +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - L2: microCompact — Old Result Placeholder Replacement - - - - Pain Point - After 10 reads, results 1-7 still sit in context. - They take space but are no longer useful. - - - Before (all 10 tool_result complete) - - - Read file A: (full content, 3200 chars)... - - Read file B: (full content, 1800 chars)... - - Read file C: (full content, 4500 chars)... - - Read file J: (full content, 2800 chars) - 7 old results waste ~25K chars - - - - - - After (keep only latest 3 complete) - - - [Earlier result compacted. Re-run if needed.] - - [Earlier result compacted. Re-run if needed.] - - [Earlier result compacted. Re-run if needed.] - - Read file J: (full content, 2800 chars) - Keep latest 3; first 7 become placeholders - - - - How (teaching version) - Iterate through tool_result, keep only latest 3 complete, replace older ones with placeholders. - Real CC - Clears old results via API cache_edits (without breaking prompt cache prefix), only for COMPACTABLE_TOOLS: - Read, Bash, Grep, Glob, WebSearch, WebFetch, Edit, Write. Teaching version uses text placeholders to simulate the same effect. - diff --git a/s08_context_compact/images/micro-compact.ja.svg b/s08_context_compact/images/micro-compact.ja.svg deleted file mode 100644 index a418c544f..000000000 --- a/s08_context_compact/images/micro-compact.ja.svg +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - L2: microCompact — 旧結果のプレースホルダー置換 - - - - ペインポイント - 10 ファイルを読んでも、1〜7 回目の結果が残る。 - 古い内容が場所を取り続ける。 - - - 圧縮前(10 件の tool_result がすべて完全) - - - Read file A: (完全な内容, 3200 文字)... - - Read file B: (完全な内容, 1800 文字)... - - Read file C: (完全な内容, 4500 文字)... - - Read file J: (完全な内容, 2800 文字) - 7 件の旧結果が ~25K 文字を占有 - - - - - - 圧縮後(最新 3 件のみ完全保持) - - - [Earlier result compacted. Re-run if needed.] - - [Earlier result compacted. Re-run if needed.] - - [Earlier result compacted. Re-run if needed.] - - Read file J: (完全な内容, 2800 文字) - 最新 3 件を保持、前 7 件は置換 - - - - 方法(教学版) - tool_result を走査し、最新 3 件のみ完全保持、古いものはプレースホルダーに置換。 - 実際の CC - API cache_edits で旧結果をクリア(prompt cache プレフィックスを破壊しない)、COMPACTABLE_TOOLS のみ対象: - Read, Bash, Grep, Glob, WebSearch, WebFetch, Edit, Write。教学版はテキストプレースホルダーで同様の効果を模擬。 - diff --git a/s08_context_compact/images/micro-compact.svg b/s08_context_compact/images/micro-compact.svg index e1728f7d6..2fef06240 100644 --- a/s08_context_compact/images/micro-compact.svg +++ b/s08_context_compact/images/micro-compact.svg @@ -1,57 +1,66 @@ - + - - - - - + + - - - - L2: microCompact — 旧结果占位替换 - - - - 痛点 - Agent 连续读了 10 个文件,第 1-7 次的完整文件内容还躺在上下文里,占着位置但早就没用了 - - - 压缩前(10 条 tool_result 全部完整) - - - Read file A: (完整内容, 3200 字符)... - - Read file B: (完整内容, 1800 字符)... - - Read file C: (完整内容, 4500 字符)... - - Read file J: (完整内容, 2800 字符) - 7 条旧结果白占 ~25K 字符 - - - - - - 压缩后(只保留最近 3 条完整) - - - [Earlier result compacted. Re-run if needed.] - - [Earlier result compacted. Re-run if needed.] - - [Earlier result compacted. Re-run if needed.] - - Read file J: (完整内容, 2800 字符) - 只保留最近 3 条,前 7 条变占位 - - - - 怎么做(教学版) - 遍历 tool_result,只保留最近 3 条完整,更旧的替换为占位符。 - 真实 CC - 通过 API cache_edits 清除旧结果(不破坏 prompt cache 前缀),仅对 COMPACTABLE_TOOLS 生效: - Read, Bash, Grep, Glob, WebSearch, WebFetch, Edit, Write。教学版用文本占位模拟同样效果。 - + + + + + L2: micro_compact + Replace old tool_result with one-line placeholder + + + + Problem + Agent read 10 files. Reads 1-7 still in context, no longer needed, wasting ~25K chars. + + + Before (all 10 results intact) + + + + + + + Read A: (full content, 3200 chars) + Read B: (full content, 1800 chars) + Read C: (full content, 4500 chars) + ... 4 more old results ... + Read J: (full content, 2800 chars) + + + 7 old results waste ~25K chars + + + compact + + + + After (keep recent 3 intact) + + + + + + + [Compacted. Re-run if needed.] + [Compacted. Re-run if needed.] + [Compacted. Re-run if needed.] + ... 4 more compacted ... + Read J: (full content, 2800 chars) + + + Only recent 3 kept, 7 replaced + + + + How it works + Iterate tool_result blocks; keep most recent 3 intact, replace older ones with placeholder. + Scope + Applies to COMPACTABLE_TOOLS: Read, Bash, Grep, Glob, WebSearch, WebFetch, Edit, Write. + Cost + 0 API calls. Pure text replacement. If model needs old content, re-run the tool. + \ No newline at end of file diff --git a/s09_memory/README.en.md b/s09_memory/README.en.md deleted file mode 100644 index 2c5609f90..000000000 --- a/s09_memory/README.en.md +++ /dev/null @@ -1,279 +0,0 @@ -# s09: Memory — Compression Loses Details, Keep a Layer That Doesn't - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s20 -> *"Compression loses details, keep a layer that doesn't"* — File store + index + on-demand loading, across compactions, across sessions. -> -> **Harness Layer**: Memory — knowledge that survives compaction and sessions. - ---- - -## The Problem - -s08's autoCompact preserves current goals, remaining work, and user constraints in the summary, but details get lost: "use tabs not spaces" might get simplified to "user has code style preferences". And when you start a new session, even the summary is gone. - -LLMs have no persistent state; all information lives in the context window. When context fills up, it gets compressed, and compression is lossy. What's needed is a storage layer that doesn't participate in compression and persists across sessions. - ---- - -## The Solution - -![Memory Overview](images/memory-overview.en.svg) - -The s08 compression pipeline is preserved, focusing on memory. Storage uses the filesystem: a `.memory/` directory where each memory is a `.md` file with YAML frontmatter (`name` / `description` / `type`). When files accumulate, an index is needed: `MEMORY.md` holds one link per line and gets injected into the SYSTEM. - -Key design: the index stays in SYSTEM prompt (cacheable by prompt cache), file content is injected on demand (matched by filename/description to the current conversation, without breaking the cache). Writing has two paths: the user explicitly says "remember", or extraction runs in the background after each turn. When files accumulate, periodic consolidation deduplicates. - -Four memory types, each answering a different question: - -| Type | Answers | Example | -|------|---------|---------| -| user | Who you are | "Use tabs not spaces" | -| feedback | How to work | "Don't mock the database" | -| project | What's happening | "Auth rewrite is compliance-driven" | -| reference | Where to find things | "Pipeline bugs are in Linear INGEST" | - ---- - -## How It Works - -![Memory Subsystems](images/memory-subsystems.en.svg) - -### Storage: Markdown Files + Index - -Each memory is a `.md` file with YAML frontmatter for metadata: - -```markdown ---- -name: user-preference-tabs -description: User prefers tabs for indentation -type: user ---- - -User prefers using tabs, not spaces, for indentation. -**Why:** Consistency with existing codebase conventions. -**How to apply:** Always use tabs when writing or editing files. -``` - -`MEMORY.md` is the index, one link per line: - -```markdown -- [user-preference-tabs](user-preference-tabs.md) — User prefers tabs for indentation -``` - -Writing a new memory automatically rebuilds the index: - -```python -def write_memory_file(name, mem_type, description, body): - slug = name.lower().replace(" ", "-") - filepath = MEMORY_DIR / f"{slug}.md" - filepath.write_text( - f"---\nname: {name}\ndescription: {description}\ntype: {mem_type}\n---\n\n{body}\n" - ) - _rebuild_index() -``` - -### Loading: Two Paths - -**Path 1: Index in SYSTEM.** `build_system()` reads `MEMORY.md` once at the start of each user request and injects the memory catalog into the SYSTEM prompt. Memory extraction and consolidation run only when the turn ends, so SYSTEM does not need to be rebuilt repeatedly within the same user request. - -**Path 2: Relevant memories on demand.** At the start of each user request, `load_memories()` sends the recent conversation and the memory catalog (name + description) to the LLM as a lightweight side-query, selects relevant filenames, then reads and injects their contents. Capped at 5 to control cost. - -```python -def select_relevant_memories(messages, max_items=5): - files = list_memory_files() - if not files: - return [] - - # Build catalog: "0: user-preference-tabs — User prefers tabs..." - catalog = "\n".join(f"{i}: {f['name']} — {f['description']}" for i, f in enumerate(files)) - - response = client.messages.create(model=MODEL, messages=[{"role": "user", - "content": f"Select relevant memory indices. Return JSON array.\n\n" - f"Recent conversation:\n{recent}\n\nMemory catalog:\n{catalog}"}], - max_tokens=200) - indices = json.loads(re.search(r'\[.*?\]', response.content[0].text).group()) - return [files[i]["filename"] for i in indices if 0 <= i < len(files)] -``` - -If the side-query fails (API error, JSON parse failure), it falls back to keyword matching on name + description. - -### Writing: Extraction After Each Turn - -Users don't always say "remember this". Preferences are usually scattered across normal dialogue: "tabs are better than spaces", "let's use single quotes from now on". - -`extract_memories()` runs when each turn ends, triggered when the model stops without a tool_use (indicating the conversation has reached a natural break): - -```python -# In agent_loop: -if response.stop_reason != "tool_use": - extract_memories(messages) # Extract new memories from recent dialogue - consolidate_memories() # Check if consolidation is needed - return -``` - -Before extraction, existing memories are checked to avoid duplicates. The extraction prompt asks the LLM to return a JSON array of `{name, type, description, body}`, writing files only when genuinely new information is found. - -```python -def extract_memories(messages): - dialogue = format_recent_messages(messages[-10:]) - existing = "\n".join(f"- {m['name']}: {m['description']}" for m in list_memory_files()) - - prompt = ( - "Extract user preferences, constraints, or project facts.\n" - "Return JSON array: [{name, type, description, body}].\n" - "If nothing new or already covered, return [].\n\n" - f"Existing memories:\n{existing}\n\nDialogue:\n{dialogue[:4000]}" - ) - # ... parse response, write files ... -``` - -### Consolidation: Low-Frequency Deduplication - -Memory files accumulate. `consolidate_memories()` triggers when the file count reaches a threshold (default 10), asking the LLM to deduplicate, merge contradictions, and prune stale memories: - -```python -CONSOLIDATE_THRESHOLD = 10 - -def consolidate_memories(): - files = list_memory_files() - if len(files) < CONSOLIDATE_THRESHOLD: - return # Too few, not worth consolidating - # Send all memories to LLM, get back deduplicated list - # Replace all files with consolidated results -``` - -CC calls this process **Dream**, with four gates in practice: time interval, scan throttle, session count, file lock. The teaching version simplifies to a file-count threshold. - -### What Memory Stores - -Memory stores information that remains useful across sessions: user preferences, recurring feedback, project background, common entry points, and investigation clues. It focuses on "what will be useful later" and brings that information back through an index plus on-demand loading. - -Session memory focuses on continuity inside one session: what context should survive after compaction. The two work together: Memory handles long-term knowledge; session memory handles the current session across compaction. - ---- - -## Changes From s08 - -| Component | Before (s08) | After (s09) | -|-----------|-------------|-------------| -| Memory capability | None (preferences degrade with compaction) | Storage + loading + extraction + consolidation | -| New functions | — | write_memory_file, select_relevant_memories, load_memories, extract_memories, consolidate_memories | -| Storage | — | .memory/MEMORY.md index + .memory/*.md files | -| Tools | bash, read, write, edit, glob, todo_write, task, load_skill, compact (9) | bash, read_file, write_file, edit_file, glob, task (6) | -| Loop | Only compression each turn | Memory injection + compression + post-turn extraction + periodic consolidation | - ---- - -## Try It - -```sh -cd learn-claude-code -python s09_memory/code.py -``` - -Try these prompts (enter across multiple turns, observe memory accumulation and loading): - -1. `I prefer using tabs for indentation, not spaces. Remember that.` -2. `Create a Python file called test.py` (observe whether the Agent uses tabs) -3. `What did I tell you about my preferences?` (observe whether the Agent remembers) -4. `I also prefer single quotes over double quotes for strings.` - -What to watch for: Does `[Memory: extracted N new memories]` appear after each turn? Are `.md` files generated in `.memory/`? Is `MEMORY.md` index updated? Does the Agent automatically load previous memories in new conversations? - ---- - -## What's Next - -Memory, compression, and tools are all in place. But the system prompt is still a hardcoded string. Adding a new tool means manually adding a description; switching projects means rewriting the whole prompt. Prompts should be assembled at runtime. - -s10 System Prompt → segments + runtime assembly. Different projects, different tools, different prompts. - -
-Deep Dive Into CC Source Code - -> The following is based on analysis of CC source code under `src/` in `memdir/`, `services/`, `utils/`, `query/`. Line numbers verified against source. - -### Source Code Paths - -| File | Lines | Responsibility | -|------|-------|---------------| -| `memdir/memdir.ts` | 507 | Core: MEMORY.md definition (`34-38`), memory behavior instructions distinguishing memory/plan/tasks (`199-266`), `loadMemoryPrompt()` three paths (`419-490`) | -| `memdir/findRelevantMemories.ts` | 141 | Sonnet side-query memory selection (`18-24` system prompt, `97-122` call logic) | -| `memdir/memoryTypes.ts` | 271 | Type definitions, frontmatter fields | -| `memdir/memoryScan.ts` | — | Scan .md files, exclude MEMORY.md, read frontmatter, max 200 files, sorted by mtime desc (`35-94`) | -| `services/extractMemories/extractMemories.ts` | 615 | Forked agent extraction, restricted permissions, `skipTranscript: true`, `maxTurns: 5` (`371-427`) | -| `services/autoDream/autoDream.ts` | 324 | Dream consolidation, four-layer gating (`63-66` defaults, `130-190` gating, `224-233` forked agent) | -| `services/SessionMemory/sessionMemory.ts` | 495 | Session-level memory management | -| `services/compact/sessionMemoryCompact.ts` | — | Session memory lightweight summary, thresholds 10K/5/40K (`56-61`) | -| `utils/attachments.ts` | — | Injection budget: 200 lines / 4096 bytes per file, 60KB per session (`269-288`); find relevant memory by query (`2196-2241`) | -| `query.ts` | — | Memory prefetch at start of each user turn (`301-304`), non-blocking collection (`1592-1614`) | -| `query/stopHooks.ts` | — | Stop hook fire-and-forget triggers extraction and Dream (`141-155`) | - -### Memory Selection: LLM, Not Embedding - -CC uses **Sonnet itself to select** (`findRelevantMemories.ts`), not embedding vector similarity: - -1. `memoryScan.ts` scans all `.md` files in `.memory/` (excluding MEMORY.md), max 200 files, sorted by mtime descending -2. Lists all memory files' `name` + `description` as a catalog -3. Sends to Sonnet side-query: "Select truly useful memories by name and description (max 5). Skip if unsure." -4. Sonnet returns `{ selected_memories: ["file1.md", ...] }` -5. Selected files' full contents are read (≤ 200 lines / 4096 bytes per file) and injected. Total session budget: 60KB - -At the start of each user turn, `query.ts:301-304` starts memory prefetch (async); after tool execution, `1592-1614` collects completed results non-blocking. - -### Extraction Timing: Stop Hook, Not After autoCompact - -Trigger location (`stopHooks.ts:141-155`): inside `handleStopHooks()`, fire-and-forget triggers extraction and Dream. The teaching version places extraction in the `stop_reason != "tool_use"` branch, matching the direction. - -CC's extraction runs via forked agent (`extractMemories.ts:371-427`): restricted permissions, `skipTranscript: true`, `maxTurns: 5`. Also has overlap protection: if the main Agent already wrote memory files, extraction is skipped. - -### Memory File Format - -CC uses Markdown + YAML frontmatter, consistent with the teaching version. Four types: `user`, `feedback`, `project`, `reference`. - -`memdir.ts:34-38` defines index constraints: `MEMORY.md` max 200 lines / 25KB. `memdir.ts:199-266` builds memory behavior instructions, explicitly distinguishing memory from plan and tasks. Storage location: `~/.claude/projects//memory/`. - -### Dream: Four-Layer Gating - -Not "triggered when idle" or "consolidate when count is enough", but four gates (`autoDream.ts`, defaults `63-66`, gating logic `130-190`): - -1. **Time gate**: ≥ 24 hours since last consolidation -2. **Scan throttle**: Avoid frequent filesystem scans -3. **Session gate**: ≥ 5 session transcripts modified since last consolidation -4. **Lock gate**: No other process currently consolidating (`.consolidate-lock` file) - -The merge itself runs via forked agent (`224-233`): locate → collect recent signals → merge and write files → prune and update index. Lock file mtime serves as lastConsolidatedAt. Crash recovery: lock auto-expires after 1 hour. - -### User Memory vs Session Memory - -| | User Memory | Session Memory | -|---|---|---| -| Persistence | Cross-session | Single session | -| Storage | Multiple .md files in `memory/` | `session-memory//memory.md` | -| Loaded into | system prompt | compact summary | -| Purpose | Cross-session knowledge accumulation | Cross-compact context continuity | - -sessionMemoryCompact (mentioned in s08) uses Session Memory: before autoCompact, it reads the session memory file and, if sufficient (≥ 10K tokens, ≥ 5 text messages, ≤ 40K tokens, `sessionMemoryCompact.ts:56-61`), uses it as a summary without calling the LLM. - -### Where the Real Implementation Is More Complex - -- **Feature flags**: Memory features have multiple feature gate layers -- **Team memory**: Shared team memories, `loadMemoryPrompt()` has a dedicated path (not covered in teaching version) -- **KAIROS**: Timing-aware memory extraction strategy, daily-log mode in `loadMemoryPrompt()` -- **Prompt cache**: Memory injection must account for prompt cache TTL, avoiding full system prompt rewrites each turn -- **File locks**: Concurrency control for multi-process scenarios -- **Memory prefetch**: Async prefetch, non-blocking main flow - -### Teaching Version Simplifications Are Intentional - -- LLM side-query → LLM side-query + keyword fallback: teaching version keeps LLM selection, adds fallback path -- Memory JSON → Markdown + frontmatter: teaching version matches CC -- Stop hook trigger → `stop_reason != "tool_use"` branch: same direction -- Four-layer gating → file-count threshold: teaching version lacks transcript system and multi-session concepts -- Forked agent + restricted permissions → direct call: teaching version has no subprocess isolation - -
- - diff --git a/s09_memory/README.ja.md b/s09_memory/README.ja.md index f9e5a2c4c..2da381433 100644 --- a/s09_memory/README.ja.md +++ b/s09_memory/README.ja.md @@ -1,6 +1,6 @@ # s09: Memory — 圧縮は詳細を失う、失わない層が必要 -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s20 > *"圧縮は詳細を失う、失わない層が必要"* — ファイルストア + インデックス + オンデマンド読み込み。圧縮を越え、セッションを越えて。 @@ -13,13 +13,13 @@ s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 s08 の autoCompact は現在の目標、残りの作業、ユーザーの制約をサマリに保持するが、詳細は失われる:「タブでインデント、スペース不可」が「ユーザーにコードスタイルの好みあり」と簡略化される。そして新しいセッションを開始すると、サマリすらない。 -LLM には永続状態がなく、すべての情報はコンテキストウィンドウ内にある。コンテキストが満杯になれば圧縮され、圧縮は非可逆。圧縮に参加せず、セッションを越えて保持されるストレージ層が必要。 +LLM には永続状態がなく、すべての情報はコンテキストウィンドウ内にある。コンテキストが満杯になれば圧縮され、圧縮は非可逆。コンテキストウィンドウの外にストレージ層が必要になる。 --- ## ソリューション -![Memory Overview](images/memory-overview.ja.svg) +![Memory Overview](images/memory-overview.svg) s08 の圧縮パイプラインを維持し、記憶に焦点を当てる。ストレージにはファイルシステムを採用:`.memory/` ディレクトリに各記憶を `.md` ファイルとして保存、YAML frontmatter(`name` / `description` / `type`)付き。ファイルが増えたらインデックスが必要:`MEMORY.md` に 1 行 1 リンクを記録し、SYSTEM に注入。 @@ -38,7 +38,7 @@ s08 の圧縮パイプラインを維持し、記憶に焦点を当てる。ス ## 仕組み -![Memory Subsystems](images/memory-subsystems.ja.svg) +![Memory Subsystems](images/memory-subsystems.svg) ### ストレージ:Markdown ファイル + インデックス @@ -144,11 +144,11 @@ def consolidate_memories(): # Replace all files with consolidated results ``` -CC はこのプロセスを **Dream** と呼び、実際には 4 層のゲートがある:時間間隔、スキャンスロットル、セッション数、ファイルロック。教学版はファイル数閾値に簡略化。 +Claude Code はこのプロセスを **Dream** と呼び、実際には 4 層のゲートがある:時間間隔、スキャンスロットル、セッション数、ファイルロック。教学版はファイル数閾値に簡略化。 ### Memory に保存するもの -Memory はセッションを越えて有用な情報を保存する:ユーザーの好み、繰り返し出るフィードバック、プロジェクト背景、よく使う入口、調査の手がかりなど。「あとでまた使うもの」を対象にし、インデックス + オンデマンド読み込みで現在の会話に戻す。 +Memory はユーザーの好み、繰り返し出るフィードバック、プロジェクト背景、よく使う入口、調査の手がかりを保存する。インデックス + オンデマンド読み込みで現在の会話に戻す。 session memory は 1 つのセッション内の連続性を扱う:compact 後も現在の会話に残すべき文脈を保持する。両者は役割が分かれている。Memory は長期知識を扱い、session memory は現在のセッションを compact 越しにつなぐ。 @@ -191,9 +191,9 @@ python s09_memory/code.py s10 System Prompt → セグメント + 実行時組み立て。異なるプロジェクト、異なるツール、異なるプロンプト。
-CC ソースコードの詳細 +Claude Code ソースコードの詳細 -> 以下は CC ソースコード `src/` 下の `memdir/`、`services/`、`utils/`、`query/` の分析に基づく。行番号はソースコードと照合済み。 +> 以下は Claude Code ソースコード `src/` 下の `memdir/`、`services/`、`utils/`、`query/` の分析に基づく。行番号はソースコードと照合済み。 ### ソースコードパス @@ -213,7 +213,7 @@ s10 System Prompt → セグメント + 実行時組み立て。異なるプロ ### 記憶選択:embedding ではなく LLM -CC は **Sonnet 自身で選択**(`findRelevantMemories.ts`)、embedding ベクトル類似度ではない: +Claude Code は **Sonnet 自身で選択**(`findRelevantMemories.ts`)、embedding ベクトル類似度ではない: 1. `memoryScan.ts` が `.memory/` 下のすべての `.md` ファイルをスキャン(MEMORY.md を除外)、最大 200 ファイル、mtime 降順 2. `name` + `description` をカタログとしてリスト化 @@ -227,11 +227,11 @@ CC は **Sonnet 自身で選択**(`findRelevantMemories.ts`)、embedding ベ トリガー位置(`stopHooks.ts:141-155`):`handleStopHooks()` 内で、fire-and-forget で抽出と Dream をトリガー。教学版は `stop_reason != "tool_use"` 分岐に抽出を配置、方向は一致。 -CC の抽出は forked agent で実行(`extractMemories.ts:371-427`):制限付き権限、`skipTranscript: true`、`maxTurns: 5`。重複保護もある:メイン Agent が既に記憶ファイルを書き込んだ場合、抽出をスキップ。 +Claude Code の抽出は forked agent で実行(`extractMemories.ts:371-427`):制限付き権限、`skipTranscript: true`、`maxTurns: 5`。重複保護もある:メイン Agent が既に記憶ファイルを書き込んだ場合、抽出をスキップ。 ### 記憶ファイル形式 -CC は Markdown + YAML frontmatter を使用、教学版と一致。4 種類:`user`、`feedback`、`project`、`reference`。 +Claude Code は Markdown + YAML frontmatter を使用、教学版と一致。4 種類:`user`、`feedback`、`project`、`reference`。 `memdir.ts:34-38` がインデックス制約を定義:`MEMORY.md` 最大 200 行 / 25KB。`memdir.ts:199-266` が記憶動作指示を構築、memory と plan と tasks を明確に区別。保存場所:`~/.claude/projects//memory/`。 @@ -269,7 +269,7 @@ sessionMemoryCompact(s08 で触れた仕組み)は Session Memory を活用 ### 教学版の簡略化は意図的 - LLM side-query → LLM side-query + キーワードフォールバック:教学版は LLM 選択を維持し、フォールバックパスを追加 -- 記憶 JSON → Markdown + frontmatter:教学版は CC と一致 +- 記憶 JSON → Markdown + frontmatter:教学版は Claude Code と一致 - stop hook トリガー → `stop_reason != "tool_use"` 分岐:方向は一致 - 4 層ゲート → ファイル数閾値:教学版には transcript システムやマルチセッションの概念がない - forked agent + 制限付き権限 → 直接呼び出し:教学版にはサブプロセス分離がない diff --git a/s09_memory/README.md b/s09_memory/README.md index 88e5f6a6d..3bf91f9f8 100644 --- a/s09_memory/README.md +++ b/s09_memory/README.md @@ -1,48 +1,48 @@ -# s09: Memory — 压缩会丢细节,要有一层不丢的 +# s09: Memory — Compression Loses Details, Keep a Layer That Doesn't -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s20 -> *"压缩会丢细节, 要有一层不丢的"* — 文件仓库 + 索引 + 按需加载,跨压缩、跨会话。 +> *"Compression loses details, keep a layer that doesn't"* — File store + index + on-demand loading, across compactions, across sessions. > -> **Harness 层**: 记忆 — 跨压缩、跨会话的知识积累。 +> **Harness Layer**: Memory — knowledge that survives compaction and sessions. --- -## 问题 +## The Problem -s08 的 autoCompact 会把当前目标、剩余工作、用户约束写进摘要,但细节会丢失:"用 tab 缩进不要用空格"可能被简化成"用户有代码风格偏好"。而且新开一个会话,连摘要也没了。 +s08's autoCompact preserves current goals, remaining work, and user constraints in the summary, but details get lost: "use tabs not spaces" might get simplified to "user has code style preferences". And when you start a new session, even the summary is gone. -LLM 没有持久状态,所有信息都在上下文窗口里。上下文满了要压缩,压缩就有损。需要一层不参与压缩、跨会话保留的存储。 +LLMs have no persistent state; all information lives in the context window. When context fills up, it gets compressed, and compression is lossy. A storage layer outside the context window solves this. --- -## 解决方案 +## The Solution ![Memory Overview](images/memory-overview.svg) -s08 的压缩管线保留,聚焦记忆。存储选文件系统:`.memory/` 目录下,每个记忆一个 `.md` 文件,带 YAML frontmatter(`name` / `description` / `type`)。文件多了需要索引:`MEMORY.md` 一行一个链接,注入 SYSTEM。 +The s08 compression pipeline is preserved, focusing on memory. Storage uses the filesystem: a `.memory/` directory where each memory is a `.md` file with YAML frontmatter (`name` / `description` / `type`). When files accumulate, an index is needed: `MEMORY.md` holds one link per line and gets injected into the SYSTEM. -关键设计:索引常驻 SYSTEM prompt(可被 prompt cache 缓存),文件内容按需注入到当前 user turn(按 filename/description 匹配当前对话,不破坏 cache)。写入由每轮结束后的提取器完成:用户显式说"记住"或表达稳定偏好时,提取器会保存为记忆。文件积累多了,定期整理去重。 +Key design: the index stays in SYSTEM prompt (cacheable by prompt cache), file content is injected on demand (matched by filename/description to the current conversation, without breaking the cache). Writing has two paths: the user explicitly says "remember", or extraction runs in the background after each turn. When files accumulate, periodic consolidation deduplicates. -四类记忆,各有用途: +Four memory types, each answering a different question: -| 类型 | 回答什么 | 示例 | -|------|---------|------| -| user | 你是谁 | "用 tab 不用空格" | -| feedback | 怎么做事 | "别 mock 数据库" | -| project | 正在发生什么 | "auth 重写是合规驱动" | -| reference | 东西在哪找 | "pipeline bug 在 Linear INGEST" | +| Type | Answers | Example | +|------|---------|---------| +| user | Who you are | "Use tabs not spaces" | +| feedback | How to work | "Don't mock the database" | +| project | What's happening | "Auth rewrite is compliance-driven" | +| reference | Where to find things | "Pipeline bugs are in Linear INGEST" | --- -## 工作原理 +## How It Works ![Memory Subsystems](images/memory-subsystems.svg) -### 存储:Markdown 文件 + 索引 +### Storage: Markdown Files + Index -每个记忆是一个 `.md` 文件,YAML frontmatter 记录元数据: +Each memory is a `.md` file with YAML frontmatter for metadata: ```markdown --- @@ -56,13 +56,13 @@ User prefers using tabs, not spaces, for indentation. **How to apply:** Always use tabs when writing or editing files. ``` -`MEMORY.md` 是索引,一行一个链接: +`MEMORY.md` is the index, one link per line: ```markdown - [user-preference-tabs](user-preference-tabs.md) — User prefers tabs for indentation ``` -写入新记忆时自动重建索引: +Writing a new memory automatically rebuilds the index: ```python def write_memory_file(name, mem_type, description, body): @@ -74,11 +74,11 @@ def write_memory_file(name, mem_type, description, body): _rebuild_index() ``` -### 加载:两条路径 +### Loading: Two Paths -**路径一:索引常驻 SYSTEM。** `build_system()` 在每次用户请求开始时读取 `MEMORY.md`,把记忆清单注入。记忆提取和整理只在本轮结束时触发,因此同一轮用户请求中不需要重复重建 SYSTEM。 +**Path 1: Index in SYSTEM.** `build_system()` reads `MEMORY.md` once at the start of each user request and injects the memory catalog into the SYSTEM prompt. Memory extraction and consolidation run only when the turn ends, so SYSTEM does not need to be rebuilt repeatedly within the same user request. -**路径二:相关记忆按需注入。** 每次用户请求开始时,`load_memories()` 把最近对话和记忆目录(name + description)一起发给 LLM 做一次轻量 side-query,选出相关的文件名,再读文件内容临时注入到当前 user turn。最多 5 条,控制开销。 +**Path 2: Relevant memories on demand.** At the start of each user request, `load_memories()` sends the recent conversation and the memory catalog (name + description) to the LLM as a lightweight side-query, selects relevant filenames, then reads and injects their contents. Capped at 5 to control cost. ```python def select_relevant_memories(messages, max_items=5): @@ -93,28 +93,27 @@ def select_relevant_memories(messages, max_items=5): "content": f"Select relevant memory indices. Return JSON array.\n\n" f"Recent conversation:\n{recent}\n\nMemory catalog:\n{catalog}"}], max_tokens=200) - text = extract_text(response.content).strip() - indices = json.loads(re.search(r'\[.*?\]', text).group()) + indices = json.loads(re.search(r'\[.*?\]', response.content[0].text).group()) return [files[i]["filename"] for i in indices if 0 <= i < len(files)] ``` -如果 side-query 失败(API 错误、JSON 解析失败),降级到关键词匹配 name + description。 +If the side-query fails (API error, JSON parse failure), it falls back to keyword matching on name + description. -### 写入:每轮结束后提取 +### Writing: Extraction After Each Turn -用户不会每次都说"记住这个"。偏好通常散落在正常对话中:"用 tab 比空格好"、"以后都用单引号"。 +Users don't always say "remember this". Preferences are usually scattered across normal dialogue: "tabs are better than spaces", "let's use single quotes from now on". -`extract_memories()` 在每轮结束时运行,条件是模型停止且没有 tool_use(说明对话告一段落): +`extract_memories()` runs when each turn ends, triggered when the model stops without a tool_use (indicating the conversation has reached a natural break): ```python # In agent_loop: if response.stop_reason != "tool_use": - extract_memories(pre_compress) # 从压缩前快照提取新记忆 - consolidate_memories() # 检查是否需要整理 + extract_memories(messages) # Extract new memories from recent dialogue + consolidate_memories() # Check if consolidation is needed return ``` -提取前先检查已有记忆,避免重复。提取 prompt 要求 LLM 返回 `{name, type, description, body}` 的 JSON 数组,只有确实有新信息时才写文件。 +Before extraction, existing memories are checked to avoid duplicates. The extraction prompt asks the LLM to return a JSON array of `{name, type, description, body}`, writing files only when genuinely new information is found. ```python def extract_memories(messages): @@ -130,9 +129,9 @@ def extract_memories(messages): # ... parse response, write files ... ``` -### 整理:低频合并去重 +### Consolidation: Low-Frequency Deduplication -记忆文件会积累。`consolidate_memories()` 在文件数达到阈值(默认 10)时触发,让 LLM 去重、合并矛盾、淘汰过时记忆: +Memory files accumulate. `consolidate_memories()` triggers when the file count reaches a threshold (default 10), asking the LLM to deduplicate, merge contradictions, and prune stale memories: ```python CONSOLIDATE_THRESHOLD = 10 @@ -140,140 +139,140 @@ CONSOLIDATE_THRESHOLD = 10 def consolidate_memories(): files = list_memory_files() if len(files) < CONSOLIDATE_THRESHOLD: - return # 太少,不值得整理 + return # Too few, not worth consolidating # Send all memories to LLM, get back deduplicated list # Replace all files with consolidated results ``` -CC 把这个过程叫 Dream,实际有四层门控:时间间隔、扫描节流、会话数、文件锁。教学版简化为文件数阈值。 +Claude Code calls this process **Dream**, with four gates in practice: time interval, scan throttle, session count, file lock. The teaching version simplifies to a file-count threshold. -### Memory 适合保存什么 +### What Memory Stores -Memory 保存跨会话仍然有用的信息:用户偏好、反复出现的反馈、项目背景、常用入口和排查线索。它关注“以后还会用到什么”,并通过索引 + 按需加载把这些信息带回当前对话。 +Memory stores user preferences, recurring feedback, project background, common entry points, and investigation clues. It brings them back through an index plus on-demand loading. -session memory 关注同一会话内的连续性:compact 之后,当前会话还需要保留哪些上下文。两者配合使用:Memory 管长期知识,session memory 管当前会话的压缩续接。 +Session memory focuses on continuity inside one session: what context should survive after compaction. The two work together: Memory handles long-term knowledge; session memory handles the current session across compaction. --- -## 相对 s08 的变更 +## Changes From s08 -| 组件 | 之前 (s08) | 之后 (s09) | -|------|-----------|-----------| -| 记忆能力 | 无(压缩后偏好随摘要退化) | 存储 + 加载 + 提取 + 整理 | -| 新函数 | — | write_memory_file, select_relevant_memories, load_memories, extract_memories, consolidate_memories | -| 存储 | — | .memory/MEMORY.md 索引 + .memory/*.md 文件 | -| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill, compact (9) | bash, read_file, write_file, edit_file, glob, task (6) | -| 循环 | 每轮只做压缩 | 每轮注入记忆 + 压缩 + 每轮结束后提取 + 定期整理 | +| Component | Before (s08) | After (s09) | +|-----------|-------------|-------------| +| Memory capability | None (preferences degrade with compaction) | Storage + loading + extraction + consolidation | +| New functions | — | write_memory_file, select_relevant_memories, load_memories, extract_memories, consolidate_memories | +| Storage | — | .memory/MEMORY.md index + .memory/*.md files | +| Tools | bash, read, write, edit, glob, todo_write, task, load_skill, compact (9) | bash, read_file, write_file, edit_file, glob, task (6) | +| Loop | Only compression each turn | Memory injection + compression + post-turn extraction + periodic consolidation | --- -## 试一下 +## Try It ```sh cd learn-claude-code python s09_memory/code.py ``` -试试这些 prompt(分多轮输入,观察记忆的累积和加载): +Try these prompts (enter across multiple turns, observe memory accumulation and loading): 1. `I prefer using tabs for indentation, not spaces. Remember that.` -2. `Create a Python file called test.py`(观察 Agent 是否用了 tab) -3. `What did I tell you about my preferences?`(观察 Agent 是否记得) +2. `Create a Python file called test.py` (observe whether the Agent uses tabs) +3. `What did I tell you about my preferences?` (observe whether the Agent remembers) 4. `I also prefer single quotes over double quotes for strings.` -观察重点:每轮结束后是否出现 `[Memory: extracted N new memories]`?`.memory/` 目录下是否生成了 `.md` 文件?`MEMORY.md` 索引是否更新?新一轮对话时 Agent 是否自动加载了之前的记忆? +What to watch for: Does `[Memory: extracted N new memories]` appear after each turn? Are `.md` files generated in `.memory/`? Is `MEMORY.md` index updated? Does the Agent automatically load previous memories in new conversations? --- -## 接下来 +## What's Next -记忆、压缩、工具都已就绪。但 system prompt 还是硬编码的一大段字符串。加了新工具要手动加描述,换了项目要重写整个 prompt。prompt 应该运行时组装。 +Memory, compression, and tools are all in place. But the system prompt is still a hardcoded string. Adding a new tool means manually adding a description; switching projects means rewriting the whole prompt. Prompts should be assembled at runtime. -s10 System Prompt → 分段 + 运行时组装。不同项目、不同工具,拼出不同的 prompt。 +s10 System Prompt → segments + runtime assembly. Different projects, different tools, different prompts.
-深入 CC 源码 +Deep Dive Into Claude Code Source Code -> 以下基于 CC 源码 `src/` 下 `memdir/`、`services/`、`utils/`、`query/` 的分析,行号已对照核实。 +> The following is based on analysis of Claude Code source code under `src/` in `memdir/`, `services/`, `utils/`, `query/`. Line numbers verified against source. -### 源码路径 +### Source Code Paths -| 文件 | 行数 | 职责 | -|------|------|------| -| `memdir/memdir.ts` | 507 | 核心:MEMORY.md 定义(`34-38`)、记忆行为指令区分 memory/plan/tasks(`199-266`)、`loadMemoryPrompt()` 三条路径(`419-490`) | -| `memdir/findRelevantMemories.ts` | 141 | Sonnet side-query 选记忆(`18-24` 系统提示、`97-122` 调用逻辑) | -| `memdir/memoryTypes.ts` | 271 | 类型定义,frontmatter 字段 | -| `memdir/memoryScan.ts` | — | 扫描 .md 文件,排除 MEMORY.md,读 frontmatter,最多 200 个,按 mtime 降序(`35-94`) | -| `services/extractMemories/extractMemories.ts` | 615 | forked agent 提取记忆,受限权限,`skipTranscript: true`,`maxTurns: 5`(`371-427`) | -| `services/autoDream/autoDream.ts` | 324 | Dream 整理,四层门控(`63-66` 默认值、`130-190` 门控、`224-233` forked agent) | -| `services/SessionMemory/sessionMemory.ts` | 495 | 会话级记忆管理 | -| `services/compact/sessionMemoryCompact.ts` | — | session memory 轻量摘要,阈值 10K/5/40K(`56-61`) | -| `utils/attachments.ts` | — | 注入预算:200 行 / 4096 字节每文件,60KB 每 session(`269-288`);按 query 找相关 memory(`2196-2241`) | -| `query.ts` | — | memory prefetch 每轮启动(`301-304`),非阻塞收集(`1592-1614`) | -| `query/stopHooks.ts` | — | stop hook fire-and-forget 触发提取和 Dream(`141-155`) | +| File | Lines | Responsibility | +|------|-------|---------------| +| `memdir/memdir.ts` | 507 | Core: MEMORY.md definition (`34-38`), memory behavior instructions distinguishing memory/plan/tasks (`199-266`), `loadMemoryPrompt()` three paths (`419-490`) | +| `memdir/findRelevantMemories.ts` | 141 | Sonnet side-query memory selection (`18-24` system prompt, `97-122` call logic) | +| `memdir/memoryTypes.ts` | 271 | Type definitions, frontmatter fields | +| `memdir/memoryScan.ts` | — | Scan .md files, exclude MEMORY.md, read frontmatter, max 200 files, sorted by mtime desc (`35-94`) | +| `services/extractMemories/extractMemories.ts` | 615 | Forked agent extraction, restricted permissions, `skipTranscript: true`, `maxTurns: 5` (`371-427`) | +| `services/autoDream/autoDream.ts` | 324 | Dream consolidation, four-layer gating (`63-66` defaults, `130-190` gating, `224-233` forked agent) | +| `services/SessionMemory/sessionMemory.ts` | 495 | Session-level memory management | +| `services/compact/sessionMemoryCompact.ts` | — | Session memory lightweight summary, thresholds 10K/5/40K (`56-61`) | +| `utils/attachments.ts` | — | Injection budget: 200 lines / 4096 bytes per file, 60KB per session (`269-288`); find relevant memory by query (`2196-2241`) | +| `query.ts` | — | Memory prefetch at start of each user turn (`301-304`), non-blocking collection (`1592-1614`) | +| `query/stopHooks.ts` | — | Stop hook fire-and-forget triggers extraction and Dream (`141-155`) | -### 记忆选择:LLM 选,不是 embedding +### Memory Selection: LLM, Not Embedding -CC 用 **Sonnet 本身来选**(`findRelevantMemories.ts`),不是 embedding 向量相似度: +Claude Code uses **Sonnet itself to select** (`findRelevantMemories.ts`), not embedding vector similarity: -1. `memoryScan.ts` 扫描 `.memory/` 下所有 `.md` 文件(排除 MEMORY.md),最多 200 个,按 mtime 降序 -2. 把 `name` + `description` 列成清单 -3. 发给 Sonnet side-query:"根据名称和描述选出真正有用的记忆(最多 5 个)。不确定就不要选。" -4. Sonnet 返回 `{ selected_memories: ["file1.md", ...] }` -5. 选中文件读取完整内容(每文件 ≤ 200 行 / 4096 字节),注入上下文。单 session 总预算 60KB +1. `memoryScan.ts` scans all `.md` files in `.memory/` (excluding MEMORY.md), max 200 files, sorted by mtime descending +2. Lists all memory files' `name` + `description` as a catalog +3. Sends to Sonnet side-query: "Select truly useful memories by name and description (max 5). Skip if unsure." +4. Sonnet returns `{ selected_memories: ["file1.md", ...] }` +5. Selected files' full contents are read (≤ 200 lines / 4096 bytes per file) and injected. Total session budget: 60KB -每轮用户 turn 开始时,`query.ts:301-304` 启动 memory prefetch(异步);工具执行后 `1592-1614` 非阻塞收集结果,不卡主流程。 +At the start of each user turn, `query.ts:301-304` starts memory prefetch (async); after tool execution, `1592-1614` collects completed results non-blocking. -### 提取时机:stop hook,不是 autoCompact 后 +### Extraction Timing: Stop Hook, Not After autoCompact -触发位置(`stopHooks.ts:141-155`):在 `handleStopHooks()` 中,fire-and-forget 触发提取和 Dream。教学版把提取放在 `stop_reason != "tool_use"` 分支里,方向一致。 +Trigger location (`stopHooks.ts:141-155`): inside `handleStopHooks()`, fire-and-forget triggers extraction and Dream. The teaching version places extraction in the `stop_reason != "tool_use"` branch, matching the direction. -CC 的提取通过 forked agent 执行(`extractMemories.ts:371-427`):受限权限、`skipTranscript: true`、`maxTurns: 5`。还有重叠保护:如果主 Agent 已经写入了记忆文件,跳过提取。 +Claude Code's extraction runs via forked agent (`extractMemories.ts:371-427`): restricted permissions, `skipTranscript: true`, `maxTurns: 5`. Also has overlap protection: if the main Agent already wrote memory files, extraction is skipped. -### 记忆文件格式 +### Memory File Format -CC 用 Markdown + YAML frontmatter,和教学版一致。四种类型:`user`、`feedback`、`project`、`reference`。 +Claude Code uses Markdown + YAML frontmatter, consistent with the teaching version. Four types: `user`, `feedback`, `project`, `reference`. -`memdir.ts:34-38` 定义索引约束:`MEMORY.md` 最多 200 行 / 25KB。`memdir.ts:199-266` 构建记忆行为指令,明确区分 memory、plan、tasks。存储位置:`~/.claude/projects//memory/`。 +`memdir.ts:34-38` defines index constraints: `MEMORY.md` max 200 lines / 25KB. `memdir.ts:199-266` builds memory behavior instructions, explicitly distinguishing memory from plan and tasks. Storage location: `~/.claude/projects//memory/`. -### Dream:四层门控 +### Dream: Four-Layer Gating -不是"空闲时触发"或"数量够了就合并",而是四层门控(`autoDream.ts`,默认值 `63-66`,门控逻辑 `130-190`): +Not "triggered when idle" or "consolidate when count is enough", but four gates (`autoDream.ts`, defaults `63-66`, gating logic `130-190`): -1. **时间门控**:距上次合并 ≥ 24 小时 -2. **扫描节流**:避免频繁扫描文件系统 -3. **会话门控**:自上次合并以来修改了 ≥ 5 个会话 transcript -4. **锁门控**:没有其他进程正在合并(`.consolidate-lock` 文件) +1. **Time gate**: ≥ 24 hours since last consolidation +2. **Scan throttle**: Avoid frequent filesystem scans +3. **Session gate**: ≥ 5 session transcripts modified since last consolidation +4. **Lock gate**: No other process currently consolidating (`.consolidate-lock` file) -合并本身通过 forked agent 执行(`224-233`):定位 → 收集近期信号 → 合并写文件 → 剪枝更新索引。锁文件 mtime 就是 lastConsolidatedAt。崩溃恢复:1 小时后锁自动过期。 +The merge itself runs via forked agent (`224-233`): locate → collect recent signals → merge and write files → prune and update index. Lock file mtime serves as lastConsolidatedAt. Crash recovery: lock auto-expires after 1 hour. ### User Memory vs Session Memory | | User Memory | Session Memory | |---|---|---| -| 持久性 | 跨会话 | 单会话 | -| 存储 | `memory/` 下多个 .md 文件 | `session-memory//memory.md` | -| 加载到 | system prompt | compact 摘要 | -| 用途 | 跨会话的知识积累 | 跨 compact 的上下文连续性 | +| Persistence | Cross-session | Single session | +| Storage | Multiple .md files in `memory/` | `session-memory//memory.md` | +| Loaded into | system prompt | compact summary | +| Purpose | Cross-session knowledge accumulation | Cross-compact context continuity | -sessionMemoryCompact(s08 中提到的机制)正是使用了 Session Memory:autoCompact 前先读 session memory 文件,如果内容足够(≥ 10K token、≥ 5 条文本消息、≤ 40K token,`sessionMemoryCompact.ts:56-61`),就用它做摘要,不调 LLM。 +sessionMemoryCompact (mentioned in s08) uses Session Memory: before autoCompact, it reads the session memory file and, if sufficient (≥ 10K tokens, ≥ 5 text messages, ≤ 40K tokens, `sessionMemoryCompact.ts:56-61`), uses it as a summary without calling the LLM. -### 真实实现比教学版复杂的地方 +### Where the Real Implementation Is More Complex -- **Feature flags**:记忆相关功能有多层 feature gate 控制 -- **Team memory**:团队共享记忆,`loadMemoryPrompt()` 有专门路径(教学版未涉及) -- **KAIROS**:时机感知的记忆提取策略,`loadMemoryPrompt()` 中 daily-log 模式 -- **Prompt cache**:记忆注入需要考虑 prompt cache 的 TTL,避免每次都重写 system prompt 的大段内容 -- **文件锁**:多进程并发时的锁机制 -- **Memory prefetch**:异步预取,不阻塞主流程 +- **Feature flags**: Memory features have multiple feature gate layers +- **Team memory**: Shared team memories, `loadMemoryPrompt()` has a dedicated path (not covered in teaching version) +- **KAIROS**: Timing-aware memory extraction strategy, daily-log mode in `loadMemoryPrompt()` +- **Prompt cache**: Memory injection must account for prompt cache TTL, avoiding full system prompt rewrites each turn +- **File locks**: Concurrency control for multi-process scenarios +- **Memory prefetch**: Async prefetch, non-blocking main flow -### 教学版的简化是刻意的 +### Teaching Version Simplifications Are Intentional -- LLM side-query → LLM side-query + 关键词降级:教学版保留了 LLM 选择,加了降级路径 -- 记忆 JSON → Markdown + frontmatter:教学版与 CC 一致 -- stop hook 触发 → `stop_reason != "tool_use"` 分支:方向一致 -- 四层门控 → 文件数阈值:教学版没有 transcript 系统和多会话概念 -- forked agent + 受限权限 → 直接调用:教学版没有子进程隔离 +- LLM side-query → LLM side-query + keyword fallback: teaching version keeps LLM selection, adds fallback path +- Memory JSON → Markdown + frontmatter: teaching version matches Claude Code +- Stop hook trigger → `stop_reason != "tool_use"` branch: same direction +- Four-layer gating → file-count threshold: teaching version lacks transcript system and multi-session concepts +- Forked agent + restricted permissions → direct call: teaching version has no subprocess isolation
diff --git a/s09_memory/README.zh.md b/s09_memory/README.zh.md new file mode 100644 index 000000000..dd77b1391 --- /dev/null +++ b/s09_memory/README.zh.md @@ -0,0 +1,280 @@ +# s09: Memory — 压缩会丢细节,重要的得记在上下文之外 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s07 → s08 → `s09` → [s10](../s10_system_prompt/) → s11 → ... → s20 +> *"压缩会丢细节, 重要的得记在上下文之外"* — 文件仓库 + 索引 + 按需加载,跨压缩、跨会话。 +> +> **Harness 层**: 记忆 — 跨压缩、跨会话的知识积累。 + +--- + +## 问题 + +s08 的 autoCompact 会把当前目标、剩余工作、用户约束写进摘要,但细节会丢失:"用 tab 缩进不要用空格"可能被简化成"用户有代码风格偏好"。而且新开一个会话,连摘要也没了。 + +LLM 没有持久状态,所有信息都在上下文窗口里。上下文满了要压缩,压缩就有损。需要一层独立于上下文窗口的存储。 + +--- + +## 解决方案 + +![Memory Overview](images/memory-overview.svg) + +s08 的压缩管线保留,聚焦记忆。存储选文件系统:`.memory/` 目录下,每个记忆一个 `.md` 文件,带 YAML frontmatter(`name` / `description` / `type`)。文件多了需要索引:`MEMORY.md` 一行一个链接,注入 SYSTEM。 + +关键设计:索引常驻 SYSTEM prompt(可被 prompt cache 缓存),文件内容按需注入到当前 user turn(按 filename/description 匹配当前对话,不破坏 cache)。写入由每轮结束后的提取器完成:用户显式说"记住"或表达稳定偏好时,提取器会保存为记忆。文件积累多了,定期整理去重。 + +四类记忆,各有用途: + +| 类型 | 回答什么 | 示例 | +|------|---------|------| +| user | 你是谁 | "用 tab 不用空格" | +| feedback | 怎么做事 | "别 mock 数据库" | +| project | 正在发生什么 | "auth 重写是合规驱动" | +| reference | 东西在哪找 | "pipeline bug 在 Linear INGEST" | + +--- + +## 工作原理 + +![Memory Subsystems](images/memory-subsystems.svg) + +### 存储:Markdown 文件 + 索引 + +每个记忆是一个 `.md` 文件,YAML frontmatter 记录元数据: + +```markdown +--- +name: user-preference-tabs +description: User prefers tabs for indentation +type: user +--- + +User prefers using tabs, not spaces, for indentation. +**Why:** Consistency with existing codebase conventions. +**How to apply:** Always use tabs when writing or editing files. +``` + +`MEMORY.md` 是索引,一行一个链接: + +```markdown +- [user-preference-tabs](user-preference-tabs.md) — User prefers tabs for indentation +``` + +写入新记忆时自动重建索引: + +```python +def write_memory_file(name, mem_type, description, body): + slug = name.lower().replace(" ", "-") + filepath = MEMORY_DIR / f"{slug}.md" + filepath.write_text( + f"---\nname: {name}\ndescription: {description}\ntype: {mem_type}\n---\n\n{body}\n" + ) + _rebuild_index() +``` + +### 加载:两条路径 + +**路径一:索引常驻 SYSTEM。** `build_system()` 在每次用户请求开始时读取 `MEMORY.md`,把记忆清单注入。记忆提取和整理只在本轮结束时触发,因此同一轮用户请求中不需要重复重建 SYSTEM。 + +**路径二:相关记忆按需注入。** 每次用户请求开始时,`load_memories()` 把最近对话和记忆目录(name + description)一起发给 LLM 做一次轻量 side-query,选出相关的文件名,再读文件内容临时注入到当前 user turn。最多 5 条,控制开销。 + +```python +def select_relevant_memories(messages, max_items=5): + files = list_memory_files() + if not files: + return [] + + # Build catalog: "0: user-preference-tabs — User prefers tabs..." + catalog = "\n".join(f"{i}: {f['name']} — {f['description']}" for i, f in enumerate(files)) + + response = client.messages.create(model=MODEL, messages=[{"role": "user", + "content": f"Select relevant memory indices. Return JSON array.\n\n" + f"Recent conversation:\n{recent}\n\nMemory catalog:\n{catalog}"}], + max_tokens=200) + text = extract_text(response.content).strip() + indices = json.loads(re.search(r'\[.*?\]', text).group()) + return [files[i]["filename"] for i in indices if 0 <= i < len(files)] +``` + +如果 side-query 失败(API 错误、JSON 解析失败),降级到关键词匹配 name + description。 + +### 写入:每轮结束后提取 + +用户不会每次都说"记住这个"。偏好通常散落在正常对话中:"用 tab 比空格好"、"以后都用单引号"。 + +`extract_memories()` 在每轮结束时运行,条件是模型停止且没有 tool_use(说明对话告一段落): + +```python +# In agent_loop: +if response.stop_reason != "tool_use": + extract_memories(pre_compress) # 从压缩前快照提取新记忆 + consolidate_memories() # 检查是否需要整理 + return +``` + +提取前先检查已有记忆,避免重复。提取 prompt 要求 LLM 返回 `{name, type, description, body}` 的 JSON 数组,只有确实有新信息时才写文件。 + +```python +def extract_memories(messages): + dialogue = format_recent_messages(messages[-10:]) + existing = "\n".join(f"- {m['name']}: {m['description']}" for m in list_memory_files()) + + prompt = ( + "Extract user preferences, constraints, or project facts.\n" + "Return JSON array: [{name, type, description, body}].\n" + "If nothing new or already covered, return [].\n\n" + f"Existing memories:\n{existing}\n\nDialogue:\n{dialogue[:4000]}" + ) + # ... parse response, write files ... +``` + +### 整理:低频合并去重 + +记忆文件会积累。`consolidate_memories()` 在文件数达到阈值(默认 10)时触发,让 LLM 去重、合并矛盾、淘汰过时记忆: + +```python +CONSOLIDATE_THRESHOLD = 10 + +def consolidate_memories(): + files = list_memory_files() + if len(files) < CONSOLIDATE_THRESHOLD: + return # 太少,不值得整理 + # Send all memories to LLM, get back deduplicated list + # Replace all files with consolidated results +``` + +Claude Code 把这个过程叫 Dream,实际有四层门控:时间间隔、扫描节流、会话数、文件锁。教学版简化为文件数阈值。 + +### Memory 适合保存什么 + +Memory 保存用户偏好、反复出现的反馈、项目背景、常用入口和排查线索,通过索引 + 按需加载把这些信息带回当前对话。 + +session memory 关注同一会话内的连续性:compact 之后,当前会话还需要保留哪些上下文。两者配合使用:Memory 管长期知识,session memory 管当前会话的压缩续接。 + +--- + +## 相对 s08 的变更 + +| 组件 | 之前 (s08) | 之后 (s09) | +|------|-----------|-----------| +| 记忆能力 | 无(压缩后偏好随摘要退化) | 存储 + 加载 + 提取 + 整理 | +| 新函数 | — | write_memory_file, select_relevant_memories, load_memories, extract_memories, consolidate_memories | +| 存储 | — | .memory/MEMORY.md 索引 + .memory/*.md 文件 | +| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill, compact (9) | bash, read_file, write_file, edit_file, glob, task (6) | +| 循环 | 每轮只做压缩 | 每轮注入记忆 + 压缩 + 每轮结束后提取 + 定期整理 | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s09_memory/code.py +``` + +试试这些 prompt(分多轮输入,观察记忆的累积和加载): + +1. `I prefer using tabs for indentation, not spaces. Remember that.` +2. `Create a Python file called test.py`(观察 Agent 是否用了 tab) +3. `What did I tell you about my preferences?`(观察 Agent 是否记得) +4. `I also prefer single quotes over double quotes for strings.` + +观察重点:每轮结束后是否出现 `[Memory: extracted N new memories]`?`.memory/` 目录下是否生成了 `.md` 文件?`MEMORY.md` 索引是否更新?新一轮对话时 Agent 是否自动加载了之前的记忆? + +--- + +## 接下来 + +记忆、压缩、工具都已就绪。但 system prompt 还是硬编码的一大段字符串。加了新工具要手动加描述,换了项目要重写整个 prompt。prompt 应该运行时组装。 + +s10 System Prompt → 分段 + 运行时组装。不同项目、不同工具,拼出不同的 prompt。 + +
+深入 Claude Code 源码 + +> 以下基于 Claude Code 源码 `src/` 下 `memdir/`、`services/`、`utils/`、`query/` 的分析,行号已对照核实。 + +### 源码路径 + +| 文件 | 行数 | 职责 | +|------|------|------| +| `memdir/memdir.ts` | 507 | 核心:MEMORY.md 定义(`34-38`)、记忆行为指令区分 memory/plan/tasks(`199-266`)、`loadMemoryPrompt()` 三条路径(`419-490`) | +| `memdir/findRelevantMemories.ts` | 141 | Sonnet side-query 选记忆(`18-24` 系统提示、`97-122` 调用逻辑) | +| `memdir/memoryTypes.ts` | 271 | 类型定义,frontmatter 字段 | +| `memdir/memoryScan.ts` | — | 扫描 .md 文件,排除 MEMORY.md,读 frontmatter,最多 200 个,按 mtime 降序(`35-94`) | +| `services/extractMemories/extractMemories.ts` | 615 | forked agent 提取记忆,受限权限,`skipTranscript: true`,`maxTurns: 5`(`371-427`) | +| `services/autoDream/autoDream.ts` | 324 | Dream 整理,四层门控(`63-66` 默认值、`130-190` 门控、`224-233` forked agent) | +| `services/SessionMemory/sessionMemory.ts` | 495 | 会话级记忆管理 | +| `services/compact/sessionMemoryCompact.ts` | — | session memory 轻量摘要,阈值 10K/5/40K(`56-61`) | +| `utils/attachments.ts` | — | 注入预算:200 行 / 4096 字节每文件,60KB 每 session(`269-288`);按 query 找相关 memory(`2196-2241`) | +| `query.ts` | — | memory prefetch 每轮启动(`301-304`),非阻塞收集(`1592-1614`) | +| `query/stopHooks.ts` | — | stop hook fire-and-forget 触发提取和 Dream(`141-155`) | + +### 记忆选择:LLM 选,不是 embedding + +Claude Code 用 **Sonnet 本身来选**(`findRelevantMemories.ts`),不是 embedding 向量相似度: + +1. `memoryScan.ts` 扫描 `.memory/` 下所有 `.md` 文件(排除 MEMORY.md),最多 200 个,按 mtime 降序 +2. 把 `name` + `description` 列成清单 +3. 发给 Sonnet side-query:"根据名称和描述选出真正有用的记忆(最多 5 个)。不确定就不要选。" +4. Sonnet 返回 `{ selected_memories: ["file1.md", ...] }` +5. 选中文件读取完整内容(每文件 ≤ 200 行 / 4096 字节),注入上下文。单 session 总预算 60KB + +每轮用户 turn 开始时,`query.ts:301-304` 启动 memory prefetch(异步);工具执行后 `1592-1614` 非阻塞收集结果,不卡主流程。 + +### 提取时机:stop hook,不是 autoCompact 后 + +触发位置(`stopHooks.ts:141-155`):在 `handleStopHooks()` 中,fire-and-forget 触发提取和 Dream。教学版把提取放在 `stop_reason != "tool_use"` 分支里,方向一致。 + +Claude Code 的提取通过 forked agent 执行(`extractMemories.ts:371-427`):受限权限、`skipTranscript: true`、`maxTurns: 5`。还有重叠保护:如果主 Agent 已经写入了记忆文件,跳过提取。 + +### 记忆文件格式 + +Claude Code 用 Markdown + YAML frontmatter,和教学版一致。四种类型:`user`、`feedback`、`project`、`reference`。 + +`memdir.ts:34-38` 定义索引约束:`MEMORY.md` 最多 200 行 / 25KB。`memdir.ts:199-266` 构建记忆行为指令,明确区分 memory、plan、tasks。存储位置:`~/.claude/projects//memory/`。 + +### Dream:四层门控 + +不是"空闲时触发"或"数量够了就合并",而是四层门控(`autoDream.ts`,默认值 `63-66`,门控逻辑 `130-190`): + +1. **时间门控**:距上次合并 ≥ 24 小时 +2. **扫描节流**:避免频繁扫描文件系统 +3. **会话门控**:自上次合并以来修改了 ≥ 5 个会话 transcript +4. **锁门控**:没有其他进程正在合并(`.consolidate-lock` 文件) + +合并本身通过 forked agent 执行(`224-233`):定位 → 收集近期信号 → 合并写文件 → 剪枝更新索引。锁文件 mtime 就是 lastConsolidatedAt。崩溃恢复:1 小时后锁自动过期。 + +### User Memory vs Session Memory + +| | User Memory | Session Memory | +|---|---|---| +| 持久性 | 跨会话 | 单会话 | +| 存储 | `memory/` 下多个 .md 文件 | `session-memory//memory.md` | +| 加载到 | system prompt | compact 摘要 | +| 用途 | 跨会话的知识积累 | 跨 compact 的上下文连续性 | + +sessionMemoryCompact(s08 中提到的机制)正是使用了 Session Memory:autoCompact 前先读 session memory 文件,如果内容足够(≥ 10K token、≥ 5 条文本消息、≤ 40K token,`sessionMemoryCompact.ts:56-61`),就用它做摘要,不调 LLM。 + +### 真实实现比教学版复杂的地方 + +- **Feature flags**:记忆相关功能有多层 feature gate 控制 +- **Team memory**:团队共享记忆,`loadMemoryPrompt()` 有专门路径(教学版未涉及) +- **KAIROS**:时机感知的记忆提取策略,`loadMemoryPrompt()` 中 daily-log 模式 +- **Prompt cache**:记忆注入需要考虑 prompt cache 的 TTL,避免每次都重写 system prompt 的大段内容 +- **文件锁**:多进程并发时的锁机制 +- **Memory prefetch**:异步预取,不阻塞主流程 + +### 教学版的简化是刻意的 + +- LLM side-query → LLM side-query + 关键词降级:教学版保留了 LLM 选择,加了降级路径 +- 记忆 JSON → Markdown + frontmatter:教学版与 Claude Code 一致 +- stop hook 触发 → `stop_reason != "tool_use"` 分支:方向一致 +- 四层门控 → 文件数阈值:教学版没有 transcript 系统和多会话概念 +- forked agent + 受限权限 → 直接调用:教学版没有子进程隔离 + +
+ + diff --git a/s09_memory/images/memory-overview.en.svg b/s09_memory/images/memory-overview.en.svg deleted file mode 100644 index 51cd510b7..000000000 --- a/s09_memory/images/memory-overview.en.svg +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - Memory — Memory loading, extraction, and consolidation on s08 compression pipeline - - - - s08 preserved - - s09 new - - - - messages[] - - - - - - - Compression - budget → snip → micro - → autoCompact - (s08) - - - - - - - Loading - LLM side-query select - inject file contents - ≤ 5 items - - - - - - - LLM - stop_reason - =tool_use? - - - - no, stop - - return result - - - - yes - - - - TOOL_HANDLERS - bash · read · write - edit · glob · task - - - - .memory/ — MEMORY.md index + *.md files (cross-session persistent) - - - - read - - - - Extraction (after each turn) - - - Consolidation: triggers at ≥ 10 files, dedup·merge·prune - - - - tool results → messages[] → compress → load memories → LLM → extract after each turn - - - - - s08 preserved: compression pipeline (budget → snip → micro → auto) + emergency trim + loop - - s09 new: Loading (index in SYSTEM + on-demand inject) + Extraction (after each turn) + Consolidation (threshold) - diff --git a/s09_memory/images/memory-overview.ja.svg b/s09_memory/images/memory-overview.ja.svg deleted file mode 100644 index 3007a22f8..000000000 --- a/s09_memory/images/memory-overview.ja.svg +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - Memory — s08 圧縮パイプラインに記憶の読み込み・抽出・整理を挿入 - - - - s08 維持 - - s09 追加 - - - - messages[] - - - - - - - 圧縮パイプライン - budget → snip → micro - → autoCompact - (s08) - - - - - - - Loading - LLM side-query 選択 - ファイル内容を注入 - ≤ 5 件 - - - - - - - LLM - stop_reason - =tool_use? - - - - なし、停止 - - 結果を返す - - - - あり - - - - TOOL_HANDLERS - bash · read · write - edit · glob · task - - - - .memory/ — MEMORY.md インデックス + *.md ファイル(セッション間永続化) - - - - 読み込み - - - - Extraction(毎ターン終了後) - - - Consolidation: ファイル ≥ 10 でトリガー、重複排除・統合・剪定 - - - - ツール結果 → messages[] → 圧縮 → 記憶読み込み → LLM → 毎ターン終了後に抽出 - - - - - s08 維持:圧縮パイプライン(budget → snip → micro → auto)+ 緊急トリム + ループ - - s09 追加:Loading(インデックス常駐 + オンデマンド注入)+ Extraction(毎ターン終了後)+ Consolidation(閾値トリガー) - diff --git a/s09_memory/images/memory-overview.svg b/s09_memory/images/memory-overview.svg index 8932df1be..4e5008043 100644 --- a/s09_memory/images/memory-overview.svg +++ b/s09_memory/images/memory-overview.svg @@ -1,104 +1,123 @@ - + - - - - - - - - - - + + + + - + + - - - Memory — 在 s08 压缩管线上,插入记忆加载、提取与整理 - - - - s08 保留 - - s09 新增 - - - - messages[] - - - - - - - 压缩管线 - budget → snip → micro - → autoCompact - (s08) - - - - - - - Loading - LLM side-query 选文件 - 注入文件内容 - ≤ 5 条 - - - - - - - LLM - stop_reason - =tool_use? - - - - 否,停止 - - 返回结果 - - - - - - - - TOOL_HANDLERS - bash · read · write - edit · glob · task - - - - .memory/ — MEMORY.md 索引 + *.md 文件(跨会话持久化) - - - - 读取 - - - - Extraction(每轮结束后) - - - Consolidation: 文件数 ≥ 10 时触发,去重·合并·剪枝 - - - - 工具结果追加到 messages[] → 压缩 → 加载记忆 → LLM → 每轮结束后提取 - - - - - s08 保留:压缩管线(budget → snip → micro → auto)+ 应急裁剪 + 循环 - - s09 新增:Loading(索引常驻 + 按需注入)+ Extraction(每轮结束后)+ Consolidation(阈值触发) - + Memory — Load, Extract & Consolidate + Knowledge that survives compaction and sessions + + + + + + messages[] + conversation + + + + + + + Compression + budget → snip → micro + → autoCompact + + + + + + + Loading + LLM side-query selects + relevant files (max 5) + + + + + + + LLM + tool_use? + + + + yes + + + + TOOL_HANDLERS + bash · read · write + edit · glob · task + + + + no + + + + Return Result + + + + + + .memory/ + MEMORY.md index + *.md files (cross-session) + + + + + read + + + + + Extraction + (after each turn) + + + + Consolidation: files >= 10 → deduplicate · merge · prune + + + + + + + + + + + + tool result → messages[] → compress → load → LLM → extract + + + + Memory Types: + + + user + who you are + + feedback + how to work + + project + what's happening + + + reference + where to find things + + Pipeline: Loading → Extraction → Consolidation + + \ No newline at end of file diff --git a/s09_memory/images/memory-subsystems.en.svg b/s09_memory/images/memory-subsystems.en.svg deleted file mode 100644 index 3dbc3db7f..000000000 --- a/s09_memory/images/memory-subsystems.en.svg +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - Memory System — Store · Load · Extract · Consolidate - - - - Storage - - .memory/*.md files - MEMORY.md index - - - - - - Load - - Index in SYSTEM (always) - LLM side-query select files - ≤ 5 items, fallback to keyword - - - - - - Extract - - After each turn - Extract prefs - Avoid duplicates - - - - Consolidate - - ≥ 10 files - Dedup · merge - CC: gated Dream - - - - .memory/ — MEMORY.md index + *.md files (YAML frontmatter: name / description / type) - - - - read/write - - - - write - - - - overwrite - - - - Four types: - user (who you are) · feedback (how to work) · project (what's happening) · reference (where to find things) - - - - CC Source Comparison - • Selection: LLM side-query (Sonnet selects), not embedding vector similarity - • Extraction timing: stop hook (after each turn ends), not after autoCompact - • Dream: time + sessions + file lock, not simple count - diff --git a/s09_memory/images/memory-subsystems.ja.svg b/s09_memory/images/memory-subsystems.ja.svg deleted file mode 100644 index 21bc37585..000000000 --- a/s09_memory/images/memory-subsystems.ja.svg +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - Memory System — ストレージ · 読み込み · 抽出 · 整理 - - - - ストレージ - - .memory/*.md ファイル - MEMORY.md インデックス - - - - - - 読み込み - - インデックスを SYSTEM に常駐 - LLM side-query でファイル選択 - ≤ 5 件、失敗時はキーワードに降格 - - - - - - 抽出 - - 毎ターン終了後 - 好み/制約を抽出 - 重複を回避 - - - - 整理 - - ≥ 10 ファイル - 重複排除・統合 - CC: Dream ゲート - - - - .memory/ — MEMORY.md インデックス + *.md ファイル(YAML frontmatter: name / description / type) - - - - 読み/書き - - - - 書き込み - - - - 上書き - - - - 4 種類の記憶: - user(あなたは誰か)· feedback(どう作業するか)· project(何が起きているか)· reference(どこで探すか) - - - - CC ソースコード対照 - • 記憶選択:LLM side-query(Sonnet が選択)、embedding ベクトル類似度ではない - • 抽出タイミング:stop hook(毎ターン終了後)、autoCompact 後ではない - • Dream:時間・セッション・ロックで判定 - diff --git a/s09_memory/images/memory-subsystems.svg b/s09_memory/images/memory-subsystems.svg index 069cb0f3d..c125bd84e 100644 --- a/s09_memory/images/memory-subsystems.svg +++ b/s09_memory/images/memory-subsystems.svg @@ -1,78 +1,132 @@ - + - - - - - + + + + + - - - - Memory System — 存储 · 加载 · 提取 · 整理 - - - - 存储 - - .memory/*.md 文件 - MEMORY.md 索引 - - - - - - 加载 - - 索引常驻 SYSTEM - LLM side-query 选文件 - ≤ 5 条,失败降级到关键词 - - - - - - 提取 - - 每轮结束后触发 - LLM 提取偏好/约束 - 检查已有,避免重复 - - - - 整理 - - 文件 ≥ 10 触发 - 去重·合并·剪枝 - CC: 三层门控 - - - - .memory/ — MEMORY.md 索引 + *.md 文件(YAML frontmatter: name / description / type) - - - - 写入/读取 - - - - 写入 - - - - 覆写 - - - - 四类记忆: - user(你是谁)· feedback(怎么做事)· project(正在发生什么)· reference(东西在哪找) - - - - CC 源码对照 - • 记忆选择:LLM side-query(Sonnet 选),不是 embedding 向量相似度 - • 提取时机:stop hook 中触发(每轮结束后),不是 autoCompact 后 - • Dream 整理:三层门控(时间 ≥ 24h + 会话 ≥ 5 + 文件锁),不是简单计数 - + + + + + Memory Subsystems + Store · Load · Extract · Consolidate + + + + + + Store + + .memory/*.md files + MEMORY.md index + YAML frontmatter metadata + + + + + + + + Load + + Index in SYSTEM prompt + LLM side-query picks files + max 5, fallback to keywords + + + + + + + + Extract + + Triggered after each turn + LLM extracts prefs/facts + Dedup check + + + + + + + + Consolidate + + files >= 10 triggers + Dedup · Merge · Prune + Four-layer gating + + + + + .memory/ + MEMORY.md index + *.md files (YAML frontmatter: name / description / type) + + + + + write/read + + + + + write + + + + + overwrite + + + + + Four Memory Types + + + + user + who you are + + feedback + how to work + + project + what's happening + + reference + where to find + + + + + + + + + + + + + + + Implementation Notes + + + + + $ memory-selection + LLM side-query (Sonnet selects), not embedding similarity + + + $ extraction-timing + Triggered in stop hook (after each turn), not after autoCompact + + + $ dream-consolidation + Four gates: time >=24h + sessions >=5 + scan throttle + file lock + \ No newline at end of file diff --git a/s10_system_prompt/README.en.md b/s10_system_prompt/README.en.md deleted file mode 100644 index fcaa99c9d..000000000 --- a/s10_system_prompt/README.en.md +++ /dev/null @@ -1,254 +0,0 @@ -# s10: System Prompt — Assembled at Runtime, Never Hardcoded - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20 -> *"prompt is assembled, not hardcoded"* — Sections + on-demand assembly + caching. -> -> **Harness Layer**: Prompt — assembled at runtime, never hardcoded. - ---- - -## The Problem - -From s01 to s09, the system prompt was always one hardcoded line: - -```python -SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks." -``` - -That worked for s01 — only bash, read, write. But by s09, the agent has memory, compression, skill loading. The prompt needs to describe more and more capabilities: - -```python -SYSTEM = ( - f"You are a coding agent at {WORKDIR}. " - "Use tools to solve tasks. Act, don't explain. " - "Before starting any multi-step task, use todo_write. " - "Skills are available via list_skills and load_skill. " - "Relevant memories are injected below when available. " - # ... add a capability, add a line -) -``` - -Three problems: - -1. **Switching projects requires rewriting the entire prompt** — no way to know what to change and what to keep -2. **One change can break others** — adding a tool description might conflict with earlier instructions -3. **Every request carries everything** — even when the current conversation doesn't need certain sections, they waste tokens - -The system prompt should be a configuration assembled at runtime based on current state: which tools are enabled, which context is visible, which memories are relevant, and which content must remain stable to hit prompt cache. - ---- - -## The Solution - -![System Prompt Overview](images/system-prompt-overview.en.svg) - -s10 focuses on prompt assembly. It builds on the s08-s09 capabilities but doesn't re-implement compression or memory. The core change: split the hardcoded `SYSTEM` into independent sections, assemble them at runtime based on real state, and cache the result. - -Four sections, two loading strategies: - -| Section | Strategy | Content | Condition | -|---------|----------|---------|-----------| -| identity | always | who you are, how to work | always present | -| tools | always | available tool list | `enabled_tools` | -| workspace | always | working directory | always present | -| memory | on-demand | relevant memory content | whether `.memory/MEMORY.md` exists | - -Key design: whether a section loads depends on real state (tools exist, files exist), not keywords in messages. - ---- - -## How It Works - -### PROMPT_SECTIONS: Topic-Keyed Fragments - -Split the monolithic string into a dictionary, each key is a topic: - -```python -PROMPT_SECTIONS = { - "identity": "You are a coding agent. Act, don't explain.", - "tools": "Available tools: bash, read_file, write_file.", - "workspace": f"Working directory: {WORKDIR}", - "memory": "Relevant memories are injected below when available.", -} -``` - -Each section is maintained independently. Changing `tools` doesn't affect `identity`; adding `memory` doesn't touch `workspace`. - -### assemble_system_prompt: On-Demand Assembly - -Not every section is needed every turn. No memory files? Loading the memory section just wastes tokens. Assembly is based on real state in context: - -```python -def assemble_system_prompt(context: dict) -> str: - sections = [] - - # Always loaded - sections.append(PROMPT_SECTIONS["identity"]) - sections.append(PROMPT_SECTIONS["tools"]) - sections.append(PROMPT_SECTIONS["workspace"]) - - # On-demand — based on real state, not keywords - memories = context.get("memories", "") - if memories: - sections.append(f"Relevant memories:\n{memories}") - - return "\n\n".join(sections) -``` - -"Always loaded" sections are needed every turn: identity, tools, workspace. "On-demand" sections are only useful under specific conditions. - -Why not load everything? Tokens have cost (system prompt is billed every turn), and fewer instructions means more focused output (irrelevant instructions are noise). - -### get_system_prompt: Cache to Avoid Re-Assembly - -When context hasn't changed (multiple LLM calls in the same turn with the same context), re-assembling is wasteful. Use deterministic serialization to detect changes and return cached result: - -```python -def get_system_prompt(context: dict) -> str: - global _last_context_key, _last_prompt - key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str) - if key == _last_context_key and _last_prompt: - return _last_prompt - _last_context_key = key - _last_prompt = assemble_system_prompt(context) - return _last_prompt -``` - -`json.dumps` instead of `hash()`: Python's built-in `hash()` has process randomization (unsuitable for stable cache keys) and throws `unhashable type` on nested dicts/lists. - -Note: this cache only avoids redundant string assembly within a process. It's not the same as CC's API prompt cache, which uses `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` to separate static and dynamic parts — the static parts hit global cache and don't invalidate when dynamic content changes. - -### context: Real State, Not Keyword Guessing - -Context reflects the actual runtime state: - -```python -def update_context(context: dict, messages: list) -> dict: - memories = "" - if MEMORY_INDEX.exists(): - content = MEMORY_INDEX.read_text().strip() - if content: - memories = content - return { - "enabled_tools": list(TOOL_HANDLERS.keys()), - "workspace": str(WORKDIR), - "memories": memories, - } -``` - -`enabled_tools` lists actually registered tools. `memories` checks whether `.memory/MEMORY.md` exists. Section loading is based on this real state, not searching for keywords in messages. - -### Putting It Together - -```python -def agent_loop(messages: list, context: dict): - system = get_system_prompt(context) - while True: - response = client.messages.create( - model=MODEL, system=system, messages=messages, - tools=TOOLS, max_tokens=8000) - # ... tool execution ... - context = update_context(context, messages) - system = get_system_prompt(context) -``` - -At the start of each loop iteration, get the system prompt. If context changed, re-assemble; if not, return cached version. - ---- - -## Changes From s09 - -| Component | Before (s09) | After (s10) | -|-----------|-------------|-------------| -| prompt | Hardcoded SYSTEM string | PROMPT_SECTIONS + assemble_system_prompt | -| caching | None | get_system_prompt (json.dumps detection + cache) | -| new functions | — | assemble_system_prompt, get_system_prompt, update_context | -| tools | bash, read_file, write_file (3) | bash, read_file, write_file (3) — unchanged | -| loop | Uses fixed SYSTEM | Uses get_system_prompt(context) | - ---- - -## Try It - -```sh -cd learn-claude-code -python s10_system_prompt/code.py -``` - -What to watch for: - -1. Output shows which sections were loaded (`[assembled] sections: ...` label) -2. Cache hits show `[cache hit]` during continued conversation -3. Creating `.memory/MEMORY.md` makes the memory section appear on the next turn - -Try these prompts: - -1. `Read the file README.md` (observe the three always-loaded sections) -2. `Create a file called .memory/MEMORY.md with content "- [test](test.md) — test memory"` (write a memory index) -3. `Read the file code.py` (observe whether the memory section appears) - ---- - -## What's Next - -System prompts can now be assembled at runtime. But the agent still crashes on errors. Network hiccups, API rate limits, truncated output, context overflow — these aren't bugs, they're normal. - -s11 Error Recovery → four recovery paths. Upgrade tokens, compress context, exponential backoff, switch models. - -
-Deep Dive Into CC Source Code - -> The following is based on analysis of CC source code `constants/prompts.ts` (914 lines), `constants/systemPromptSections.ts` (68 lines), `context.ts` (189 lines), `utils/api.ts` (718 lines), `utils/systemPrompt.ts` (123 lines), and `bootstrap/state.ts`. - -### How many sections does CC's system prompt have? - -The count varies based on feature flags, output style, KAIROS/Proactive mode, user type, token budget, etc. Roughly two categories: - -**Static sections** (always loaded): identity, system, doing_tasks, actions, using_tools, tone_style, output_efficiency, etc. - -**Dynamic sections** (loaded by state): session_guidance, memory, ant_model_override, env_info_simple, language, output_style, mcp_instructions, scratchpad, frc, summarize_tool_results, numeric_length_anchors, token_budget, brief, etc. - -`mcp_instructions` is the only volatile section (created via `DANGEROUS_uncachedSystemPromptSection()`), because MCP servers can connect and disconnect between turns. - -### Assembly Function - -```typescript -getSystemPrompt(tools, model, additionalWorkingDirs?, mcpClients?): Promise -``` - -Returns `string[]` (each element is a section), separated by `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` between static and dynamic parts. - -### cache scope - -When global cache boundary is enabled, static sections are merged into one global cache block, and dynamic sections don't use global cache (`cacheScope: null`). Only paths without boundary or skipping global cache fall back to org scope. - -The teaching version's cache only avoids redundant string assembly. CC's three-layer cache: - -1. **lodash memoize**: `getSystemContext` and `getUserContext` cached per session (`context.ts`) -2. **Section registry cache**: `STATE.systemPromptSectionCache` caches dynamic section results, cleared on `/clear` or `/compact` -3. **API-level cache**: `splitSysPromptPrefix()` (`api.ts`) splits prompt into blocks with different cache scopes via boundary - -### getUserContext vs getSystemContext - -| | getSystemContext | getUserContext | -|---|---|---| -| Content | gitStatus, cacheBreaker | CLAUDE.md content, currentDate | -| Injection | appended to system prompt array | prepended as `` user message | -| When skipped | custom system prompt | always runs | - -### How modes change the prompt - -- **CLAUDE_CODE_SIMPLE**: entire prompt is 2 lines -- **Proactive/KAIROS**: compact prompt replaces all standard sections -- **Coordinator**: coordinator-specific prompt fully replaces default -- **Agent mode**: agent-defined prompt replaces or appends to default - -### Total size - -Standard interactive mode system prompt core is ~20-30KB text. CLAUDE_CODE_SIMPLE is ~150 characters. User context (CLAUDE.md) and system context (git status) add on top. - -
- - diff --git a/s10_system_prompt/README.ja.md b/s10_system_prompt/README.ja.md index 50f8f95fe..535ad70d8 100644 --- a/s10_system_prompt/README.ja.md +++ b/s10_system_prompt/README.ja.md @@ -1,11 +1,11 @@ # s10: System Prompt — 実行時アセンブリ、ハードコードなし -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20 -> *"prompt は組み立てるもの、固定するものではない"* — セグメント + オンデマンド結合 + キャッシュ。 +> *"prompt は組み立てるもの、固定するものではない"*。セグメント + オンデマンド結合 + キャッシュ。 > -> **Harness レイヤー**: プロンプト — 実行時組み立て、ハードコードなし。 +> **Harness レイヤー**: プロンプトの実行時組み立て。 --- @@ -42,7 +42,7 @@ System prompt は、実行時の現在状態に基づいて組み立てられる ## ソリューション -![System Prompt Overview](images/system-prompt-overview.ja.svg) +![System Prompt Overview](images/system-prompt-overview.svg) s10 は prompt アセンブリ機構に焦点を当てる。s08-s09 の能力を背景とするが、圧縮や記憶システムは再実装しない。核心の変更:ハードコードされた `SYSTEM` を独立セクションに分割し、実行時に実際の状態に基づいてオンデマンドで組み立て、結果をキャッシュして再組み立てを回避。 @@ -55,8 +55,6 @@ s10 は prompt アセンブリ機構に焦点を当てる。s08-s09 の能力を | workspace | 常に | 作業ディレクトリ | 常に存在 | | memory | オンデマンド | 関連記憶内容 | `.memory/MEMORY.md` が存在するか | -重要な設計:セクションをロードするかどうかは実際の状態(ツールが存在するか、ファイルが存在するか)で決まり、メッセージ内のキーワードではない。 - --- ## 仕組み @@ -118,11 +116,11 @@ def get_system_prompt(context: dict) -> str: `hash()` ではなく `json.dumps` を使用:Python 組み込みの `hash()` にはプロセスランダム化があり(安定したキャッシュキーに不適切)、list/dict で `unhashable type` エラーになる。 -注意:このキャッシュは「プロセス内での文字列再組み立ての回避」のみ。CC の API prompt cache とは別物。CC の prompt cache は `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` で静的/動的部分を分離し、静的部分が global cache に命中する。動的内容が変化しても静的部分は無効化されない。 +注意:このキャッシュは「プロセス内での文字列再組み立ての回避」のみ。Claude Code の API prompt cache とは別物。Claude Code の prompt cache は `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` で静的/動的部分を分離し、静的部分が global cache に命中する。動的内容が変化しても静的部分は無効化されない。 ### context: 実際の状態、キーワード推測ではない -context は現在の実行時状態の実際の状態を反映: +context は現在の実行時状態を反映: ```python def update_context(context: dict, messages: list) -> dict: @@ -138,7 +136,7 @@ def update_context(context: dict, messages: list) -> dict: } ``` -`enabled_tools` は実際に登録されたツールを一覧。`memories` は `.memory/MEMORY.md` が存在するかを確認。セクションの読み込みはこの実際の状態に基づき、メッセージ内のキーワード検索ではない。 +`enabled_tools` は実際に登録されたツールを一覧。`memories` は `.memory/MEMORY.md` が存在するかを確認。セクションの読み込みはこれらのフィールドに基づく。 ### 組み合わせて実行 @@ -198,11 +196,11 @@ System prompt を実行時に組み立てられるようになった。しかし s11 Error Recovery → 4 つのリカバリパス。token のアップグレード、コンテキスト圧縮、指数バックオフ、モデル切り替え。
-CC ソースコードの詳細 +Claude Code ソースコードの詳細 -> 以下は CC ソースコード `constants/prompts.ts`(914 行)、`constants/systemPromptSections.ts`(68 行)、`context.ts`(189 行)、`utils/api.ts`(718 行)、`utils/systemPrompt.ts`(123 行)、`bootstrap/state.ts` の分析に基づく。 +> 以下は Claude Code ソースコード `constants/prompts.ts`(914 行)、`constants/systemPromptSections.ts`(68 行)、`context.ts`(189 行)、`utils/api.ts`(718 行)、`utils/systemPrompt.ts`(123 行)、`bootstrap/state.ts` の分析に基づく。 -### CC の system prompt にはいくつのセクションがあるか? +### Claude Code の system prompt にはいくつのセクションがあるか? 数は固定されておらず、feature flag、output style、KAIROS/Proactive モード、ユーザータイプ、token 予算などに影響される。大まかに 2 つのカテゴリ: @@ -224,7 +222,7 @@ getSystemPrompt(tools, model, additionalWorkingDirs?, mcpClients?): Promise *"prompt 是组装出来的, 不是写死的"* — 分段 + 按需拼接 + 缓存。 +> *"prompt is assembled, not hardcoded"* — Sections + on-demand assembly + caching. > -> **Harness 层**: 提示 — 运行时组装, 不硬编码。 +> **Harness Layer**: Runtime prompt assembly. --- -## 问题 +## The Problem -从 s01 到 s09,system prompt 都是一行硬编码: +From s01 to s09, the system prompt was always one hardcoded line: ```python SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks." ``` -s01 够用,只有 bash、read、write 三个工具。但到 s09,Agent 已经有记忆、有压缩、有技能加载。prompt 该提的能力越来越多: +That worked for s01 with only bash, read, write. But by s09, the agent has memory, compression, skill loading. The prompt needs to describe more and more capabilities: ```python SYSTEM = ( @@ -26,44 +26,42 @@ SYSTEM = ( "Before starting any multi-step task, use todo_write. " "Skills are available via list_skills and load_skill. " "Relevant memories are injected below when available. " - # ... 加一个能力就多一段 + # ... add a capability, add a line ) ``` -三个问题: +Three problems: -1. **换项目要重写整个 prompt**,不知道哪些该改、哪些该留 -2. **修改一处可能影响全局**,加一段工具描述可能跟前面的指令冲突 -3. **每次请求都带全部内容**,即使当前对话用不到某些段落也浪费 token +1. **Switching projects requires rewriting the entire prompt**: no way to know what to change and what to keep +2. **One change can break others**: adding a tool description might conflict with earlier instructions +3. **Every request carries everything**: even when the current conversation doesn't need certain sections, they waste tokens -System prompt 应该是运行时根据当前状态组装的配置:哪些工具启用、哪些上下文可见、哪些记忆相关、哪些内容必须保持稳定以命中 prompt cache。 +The system prompt should be a configuration assembled at runtime based on current state: which tools are enabled, which context is visible, which memories are relevant, and which content must remain stable to hit prompt cache. --- -## 解决方案 +## The Solution ![System Prompt Overview](images/system-prompt-overview.svg) -s10 聚焦 prompt 组装机制。以 s08-s09 的能力为背景,但不重复实现压缩和记忆系统。核心变动:把硬编码的 `SYSTEM` 拆成独立段落(section),运行时根据真实状态按需拼接,缓存结果避免重复组装。 +s10 focuses on prompt assembly. It builds on the s08-s09 capabilities but doesn't re-implement compression or memory. The core change: split the hardcoded `SYSTEM` into independent sections, assemble them at runtime based on real state, and cache the result. -四个 section,两种加载策略: +Four sections, two loading strategies: -| Section | 加载策略 | 内容 | 判断依据 | -|---------|---------|------|---------| -| identity | 始终 | 你是谁、怎么做事 | 始终存在 | -| tools | 始终 | 可用工具列表 | `enabled_tools` | -| workspace | 始终 | 工作目录 | 始终存在 | -| memory | 按需 | 相关记忆内容 | `.memory/MEMORY.md` 是否存在 | - -关键设计:section 是否加载取决于真实状态(工具是否存在、文件是否存在),不是消息里的关键词。 +| Section | Strategy | Content | Condition | +|---------|----------|---------|-----------| +| identity | always | who you are, how to work | always present | +| tools | always | available tool list | `enabled_tools` | +| workspace | always | working directory | always present | +| memory | on-demand | relevant memory content | whether `.memory/MEMORY.md` exists | --- -## 工作原理 +## How It Works -### PROMPT_SECTIONS: 分段定义 +### PROMPT_SECTIONS: Topic-Keyed Fragments -把一大段字符串拆成字典,每个 key 是一个主题: +Split the monolithic string into a dictionary, each key is a topic: ```python PROMPT_SECTIONS = { @@ -74,22 +72,22 @@ PROMPT_SECTIONS = { } ``` -每个 section 独立维护。修改 `tools` 不影响 `identity`,新增 `memory` 不动 `workspace`。 +Each section is maintained independently. Changing `tools` doesn't affect `identity`; adding `memory` doesn't touch `workspace`. -### assemble_system_prompt: 按需拼接 +### assemble_system_prompt: On-Demand Assembly -不是所有 section 每次都需要。当前没有记忆文件,加载 memory section 只是浪费 token。根据 context 的真实状态决定加载哪些: +Not every section is needed every turn. No memory files? Loading the memory section just wastes tokens. Assembly is based on real state in context: ```python def assemble_system_prompt(context: dict) -> str: sections = [] - # 始终加载 + # Always loaded sections.append(PROMPT_SECTIONS["identity"]) sections.append(PROMPT_SECTIONS["tools"]) sections.append(PROMPT_SECTIONS["workspace"]) - # 按需加载 — 基于真实状态,不是关键词 + # On-demand — based on real state, not keywords memories = context.get("memories", "") if memories: sections.append(f"Relevant memories:\n{memories}") @@ -97,13 +95,13 @@ def assemble_system_prompt(context: dict) -> str: return "\n\n".join(sections) ``` -"始终加载"的是每轮都需要的:身份、工具、工作目录。"按需加载"的只在特定条件下才有用。 +"Always loaded" sections are needed every turn: identity, tools, workspace. "On-demand" sections are only useful under specific conditions. -为什么不全加载?token 有成本(system prompt 每轮计费),信息越少 LLM 越专注(无关指令是噪音)。 +Why not load everything? Tokens have cost (system prompt is billed every turn), and fewer instructions means more focused output (irrelevant instructions are noise). -### get_system_prompt: 缓存避免重复拼接 +### get_system_prompt: Cache to Avoid Re-Assembly -上下文没变时(同一轮对话的多次 LLM 调用,context 相同),重新拼接是浪费。用确定性序列化检测变化,命中缓存直接返回: +When context hasn't changed (multiple LLM calls in the same turn with the same context), re-assembling is wasteful. Use deterministic serialization to detect changes and return cached result: ```python def get_system_prompt(context: dict) -> str: @@ -116,13 +114,13 @@ def get_system_prompt(context: dict) -> str: return _last_prompt ``` -用 `json.dumps` 而不是 `hash()`:Python 内置 `hash()` 有进程随机化,不适合做稳定 cache key,而且遇到 list/dict 会报 `unhashable type`。 +`json.dumps` instead of `hash()`: Python's built-in `hash()` has process randomization (unsuitable for stable cache keys) and throws `unhashable type` on nested dicts/lists. -注意:这里的缓存只是"避免重复拼接字符串",和 CC 的 API prompt cache 不是一回事。CC 的 prompt cache 通过 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分隔静态和动态部分,静态部分命中 global cache,不因动态内容变化而失效。 +Note: this cache only avoids redundant string assembly within a process. It's not the same as Claude Code's API prompt cache, which uses `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` to separate static and dynamic parts — the static parts hit global cache and don't invalidate when dynamic content changes. -### context: 真实状态,不是关键词猜测 +### context: Real State, Not Keyword Guessing -context 反映当前运行态的真实状态: +Context reflects the actual runtime state: ```python def update_context(context: dict, messages: list) -> dict: @@ -138,9 +136,9 @@ def update_context(context: dict, messages: list) -> dict: } ``` -`enabled_tools` 列出实际注册的工具。`memories` 检查 `.memory/MEMORY.md` 是否存在。section 加载基于这些真实状态,不在消息里搜关键词。 +`enabled_tools` lists actually registered tools. `memories` checks whether `.memory/MEMORY.md` exists. Section loading is based on these fields. -### 合起来跑 +### Putting It Together ```python def agent_loop(messages: list, context: dict): @@ -149,105 +147,105 @@ def agent_loop(messages: list, context: dict): response = client.messages.create( model=MODEL, system=system, messages=messages, tools=TOOLS, max_tokens=8000) - # ... 工具执行 ... + # ... tool execution ... context = update_context(context, messages) system = get_system_prompt(context) ``` -每轮循环开头拿一次 system prompt。context 变了就重新组装,没变就返回缓存。 +At the start of each loop iteration, get the system prompt. If context changed, re-assemble; if not, return cached version. --- -## 相对 s09 的变更 +## Changes From s09 -| 组件 | 之前 (s09) | 之后 (s10) | -|------|-----------|-----------| -| prompt | 硬编码 SYSTEM 字符串 | PROMPT_SECTIONS + assemble_system_prompt | -| 缓存 | 无 | get_system_prompt(json.dumps 检测 + 缓存) | -| 新函数 | — | assemble_system_prompt, get_system_prompt, update_context | -| 工具 | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 不变 | -| 循环 | 用固定 SYSTEM | 用 get_system_prompt(context) | +| Component | Before (s09) | After (s10) | +|-----------|-------------|-------------| +| prompt | Hardcoded SYSTEM string | PROMPT_SECTIONS + assemble_system_prompt | +| caching | None | get_system_prompt (json.dumps detection + cache) | +| new functions | — | assemble_system_prompt, get_system_prompt, update_context | +| tools | bash, read_file, write_file (3) | bash, read_file, write_file (3) — unchanged | +| loop | Uses fixed SYSTEM | Uses get_system_prompt(context) | --- -## 试一下 +## Try It ```sh cd learn-claude-code python s10_system_prompt/code.py ``` -观察重点: +What to watch for: -1. 输出中能看到哪些 section 被加载了(`[assembled] sections: ...` 标签) -2. 连续对话时,缓存命中显示 `[cache hit]` -3. 创建 `.memory/MEMORY.md` 文件后,下一轮 memory section 自动加载 +1. Output shows which sections were loaded (`[assembled] sections: ...` label) +2. Cache hits show `[cache hit]` during continued conversation +3. Creating `.memory/MEMORY.md` makes the memory section appear on the next turn -试试这些 prompt: +Try these prompts: -1. `Read the file README.md`(观察始终加载的三个 section) -2. `Create a file called .memory/MEMORY.md with content "- [test](test.md) — test memory"`(写入记忆索引) -3. `Read the file code.py`(观察 memory section 是否出现) +1. `Read the file README.md` (observe the three always-loaded sections) +2. `Create a file called .memory/MEMORY.md with content "- [test](test.md) — test memory"` (write a memory index) +3. `Read the file code.py` (observe whether the memory section appears) --- -## 接下来 +## What's Next -System prompt 可以运行时组装了,但 Agent 碰到错误还是会崩。网络抖动、API 限流、输出被截断、上下文超限,这些不是 bug,是常态。 +System prompts can now be assembled at runtime. But the agent still crashes on errors. Network hiccups, API rate limits, truncated output, context overflow. These are not bugs; they are normal. -s11 Error Recovery → 四条恢复路径。升级 token、压缩上下文、指数退避、切换模型。 +s11 Error Recovery → four recovery paths. Upgrade tokens, compress context, exponential backoff, switch models.
-深入 CC 源码 +Deep Dive Into Claude Code Source Code -> 以下基于 CC 源码 `constants/prompts.ts`(914 行)、`constants/systemPromptSections.ts`(68 行)、`context.ts`(189 行)、`utils/api.ts`(718 行)、`utils/systemPrompt.ts`(123 行)、`bootstrap/state.ts` 的分析。 +> The following is based on analysis of Claude Code source code `constants/prompts.ts` (914 lines), `constants/systemPromptSections.ts` (68 lines), `context.ts` (189 lines), `utils/api.ts` (718 lines), `utils/systemPrompt.ts` (123 lines), and `bootstrap/state.ts`. -### CC 的 system prompt 有多少 section? +### How many sections does Claude Code's system prompt have? -数量不固定,受 feature flag、output style、KAIROS/Proactive 模式、用户类型、token 预算等影响。大致分两类: +The count varies based on feature flags, output style, KAIROS/Proactive mode, user type, token budget, etc. Roughly two categories: -**静态 section**(始终加载):identity、system、doing_tasks、actions、using_tools、tone_style、output_efficiency 等。 +**Static sections** (always loaded): identity, system, doing_tasks, actions, using_tools, tone_style, output_efficiency, etc. -**动态 section**(按状态加载):session_guidance、memory、ant_model_override、env_info_simple、language、output_style、mcp_instructions、scratchpad、frc、summarize_tool_results、numeric_length_anchors、token_budget、brief 等。 +**Dynamic sections** (loaded by state): session_guidance, memory, ant_model_override, env_info_simple, language, output_style, mcp_instructions, scratchpad, frc, summarize_tool_results, numeric_length_anchors, token_budget, brief, etc. -`mcp_instructions` 是唯一的易失性 section(通过 `DANGEROUS_uncachedSystemPromptSection()` 创建),因为 MCP server 可以在轮次间连接和断开。 +`mcp_instructions` is the only volatile section (created via `DANGEROUS_uncachedSystemPromptSection()`), because MCP servers can connect and disconnect between turns. -### 组装函数 +### Assembly Function ```typescript getSystemPrompt(tools, model, additionalWorkingDirs?, mcpClients?): Promise ``` -返回 `string[]`(每个元素是一个 section),由 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分隔静态和动态部分。 +Returns `string[]` (each element is a section), separated by `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` between static and dynamic parts. ### cache scope -启用 global cache boundary 时,静态 section 合并成一个 global cache block,动态 section 不使用 global cache(`cacheScope: null`)。没有 boundary 或跳过 global cache 的路径才会走 org scope。 +When global cache boundary is enabled, static sections are merged into one global cache block, and dynamic sections don't use global cache (`cacheScope: null`). Only paths without boundary or skipping global cache fall back to org scope. -教学版的缓存只避免重复拼接字符串。CC 的三层缓存: +The teaching version's cache only avoids redundant string assembly. Claude Code's three-layer cache: -1. **lodash memoize**:`getSystemContext` 和 `getUserContext` 在会话中缓存(`context.ts`) -2. **section 注册缓存**:`STATE.systemPromptSectionCache` 缓存动态 section 结果,`/clear` 或 `/compact` 时清除 -3. **API 级缓存**:`splitSysPromptPrefix()`(`api.ts`)把 prompt 按 boundary 分成不同 cache scope 的块 +1. **lodash memoize**: `getSystemContext` and `getUserContext` cached per session (`context.ts`) +2. **Section registry cache**: `STATE.systemPromptSectionCache` caches dynamic section results, cleared on `/clear` or `/compact` +3. **API-level cache**: `splitSysPromptPrefix()` (`api.ts`) splits prompt into blocks with different cache scopes via boundary ### getUserContext vs getSystemContext | | getSystemContext | getUserContext | |---|---|---| -| 内容 | gitStatus、cacheBreaker | CLAUDE.md 内容、currentDate | -| 注入方式 | 追加到 system prompt 数组 | 前置为 `` 用户消息 | -| 何时跳过 | 自定义 system prompt 时 | 始终运行 | +| Content | gitStatus, cacheBreaker | CLAUDE.md content, currentDate | +| Injection | appended to system prompt array | prepended as `` user message | +| When skipped | custom system prompt | always runs | -### 模式如何改变 prompt +### How modes change the prompt -- **CLAUDE_CODE_SIMPLE**:整个 prompt 只有 2 行 -- **Proactive/KAIROS**:用紧凑版 prompt 替换所有标准 section -- **Coordinator**:用协调器专用 prompt 完全替换 -- **Agent 模式**:Agent 定义的 prompt 替换或追加到默认 prompt +- **CLAUDE_CODE_SIMPLE**: entire prompt is 2 lines +- **Proactive/KAIROS**: compact prompt replaces all standard sections +- **Coordinator**: coordinator-specific prompt fully replaces default +- **Agent mode**: agent-defined prompt replaces or appends to default -### 总大小 +### Total size -标准交互模式下 system prompt 核心约 20-30KB 文本。CLAUDE_CODE_SIMPLE 约 150 字符。用户上下文(CLAUDE.md)和系统上下文(git status)在此基础上累加。 +Standard interactive mode system prompt core is ~20-30KB text. CLAUDE_CODE_SIMPLE is ~150 characters. User context (CLAUDE.md) and system context (git status) add on top.
diff --git a/s10_system_prompt/README.zh.md b/s10_system_prompt/README.zh.md new file mode 100644 index 000000000..a05717995 --- /dev/null +++ b/s10_system_prompt/README.zh.md @@ -0,0 +1,252 @@ +# s10: System Prompt — 运行时组装,不硬编码 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s08 → s09 → `s10` → [s11](../s11_error_recovery/) → s12 → ... → s20 +> *"prompt 是组装出来的, 不是写死的"*。分段 + 按需拼接 + 缓存。 +> +> **Harness 层**: 提示的运行时组装。 + +--- + +## 问题 + +从 s01 到 s09,system prompt 都是一行硬编码: + +```python +SYSTEM = f"You are a coding agent at {WORKDIR}. Use tools to solve tasks." +``` + +s01 够用,只有 bash、read、write 三个工具。但到 s09,Agent 已经有记忆、有压缩、有技能加载。prompt 该提的能力越来越多: + +```python +SYSTEM = ( + f"You are a coding agent at {WORKDIR}. " + "Use tools to solve tasks. Act, don't explain. " + "Before starting any multi-step task, use todo_write. " + "Skills are available via list_skills and load_skill. " + "Relevant memories are injected below when available. " + # ... 加一个能力就多一段 +) +``` + +三个问题: + +1. **换项目要重写整个 prompt**,不知道哪些该改、哪些该留 +2. **修改一处可能影响全局**,加一段工具描述可能跟前面的指令冲突 +3. **每次请求都带全部内容**,即使当前对话用不到某些段落也浪费 token + +System prompt 应该是运行时根据当前状态组装的配置:哪些工具启用、哪些上下文可见、哪些记忆相关、哪些内容必须保持稳定以命中 prompt cache。 + +--- + +## 解决方案 + +![System Prompt Overview](images/system-prompt-overview.svg) + +s10 聚焦 prompt 组装机制。以 s08-s09 的能力为背景,但不重复实现压缩和记忆系统。核心变动:把硬编码的 `SYSTEM` 拆成独立段落(section),运行时根据真实状态按需拼接,缓存结果避免重复组装。 + +四个 section,两种加载策略: + +| Section | 加载策略 | 内容 | 判断依据 | +|---------|---------|------|---------| +| identity | 始终 | 你是谁、怎么做事 | 始终存在 | +| tools | 始终 | 可用工具列表 | `enabled_tools` | +| workspace | 始终 | 工作目录 | 始终存在 | +| memory | 按需 | 相关记忆内容 | `.memory/MEMORY.md` 是否存在 | + +--- + +## 工作原理 + +### PROMPT_SECTIONS: 分段定义 + +把一大段字符串拆成字典,每个 key 是一个主题: + +```python +PROMPT_SECTIONS = { + "identity": "You are a coding agent. Act, don't explain.", + "tools": "Available tools: bash, read_file, write_file.", + "workspace": f"Working directory: {WORKDIR}", + "memory": "Relevant memories are injected below when available.", +} +``` + +每个 section 独立维护。修改 `tools` 不影响 `identity`,新增 `memory` 不动 `workspace`。 + +### assemble_system_prompt: 按需拼接 + +不是所有 section 每次都需要。当前没有记忆文件,加载 memory section 只是浪费 token。根据 context 的真实状态决定加载哪些: + +```python +def assemble_system_prompt(context: dict) -> str: + sections = [] + + # 始终加载 + sections.append(PROMPT_SECTIONS["identity"]) + sections.append(PROMPT_SECTIONS["tools"]) + sections.append(PROMPT_SECTIONS["workspace"]) + + # 按需加载 — 基于真实状态,不是关键词 + memories = context.get("memories", "") + if memories: + sections.append(f"Relevant memories:\n{memories}") + + return "\n\n".join(sections) +``` + +"始终加载"的是每轮都需要的:身份、工具、工作目录。"按需加载"的只在特定条件下才有用。 + +为什么不全加载?token 有成本(system prompt 每轮计费),信息越少 LLM 越专注(无关指令是噪音)。 + +### get_system_prompt: 缓存避免重复拼接 + +上下文没变时(同一轮对话的多次 LLM 调用,context 相同),重新拼接是浪费。用确定性序列化检测变化,命中缓存直接返回: + +```python +def get_system_prompt(context: dict) -> str: + global _last_context_key, _last_prompt + key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str) + if key == _last_context_key and _last_prompt: + return _last_prompt + _last_context_key = key + _last_prompt = assemble_system_prompt(context) + return _last_prompt +``` + +用 `json.dumps` 而不是 `hash()`:Python 内置 `hash()` 有进程随机化,不适合做稳定 cache key,而且遇到 list/dict 会报 `unhashable type`。 + +注意:这里的缓存只是"避免重复拼接字符串",和 Claude Code 的 API prompt cache 不是一回事。Claude Code 的 prompt cache 通过 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分隔静态和动态部分,静态部分命中 global cache,不因动态内容变化而失效。 + +### context: 真实状态,不是关键词猜测 + +context 反映当前运行态: + +```python +def update_context(context: dict, messages: list) -> dict: + memories = "" + if MEMORY_INDEX.exists(): + content = MEMORY_INDEX.read_text().strip() + if content: + memories = content + return { + "enabled_tools": list(TOOL_HANDLERS.keys()), + "workspace": str(WORKDIR), + "memories": memories, + } +``` + +`enabled_tools` 列出实际注册的工具。`memories` 检查 `.memory/MEMORY.md` 是否存在。section 加载基于这些字段。 + +### 合起来跑 + +```python +def agent_loop(messages: list, context: dict): + system = get_system_prompt(context) + while True: + response = client.messages.create( + model=MODEL, system=system, messages=messages, + tools=TOOLS, max_tokens=8000) + # ... 工具执行 ... + context = update_context(context, messages) + system = get_system_prompt(context) +``` + +每轮循环开头拿一次 system prompt。context 变了就重新组装,没变就返回缓存。 + +--- + +## 相对 s09 的变更 + +| 组件 | 之前 (s09) | 之后 (s10) | +|------|-----------|-----------| +| prompt | 硬编码 SYSTEM 字符串 | PROMPT_SECTIONS + assemble_system_prompt | +| 缓存 | 无 | get_system_prompt(json.dumps 检测 + 缓存) | +| 新函数 | — | assemble_system_prompt, get_system_prompt, update_context | +| 工具 | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 不变 | +| 循环 | 用固定 SYSTEM | 用 get_system_prompt(context) | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s10_system_prompt/code.py +``` + +观察重点: + +1. 输出中能看到哪些 section 被加载了(`[assembled] sections: ...` 标签) +2. 连续对话时,缓存命中显示 `[cache hit]` +3. 创建 `.memory/MEMORY.md` 文件后,下一轮 memory section 自动加载 + +试试这些 prompt: + +1. `Read the file README.md`(观察始终加载的三个 section) +2. `Create a file called .memory/MEMORY.md with content "- [test](test.md) — test memory"`(写入记忆索引) +3. `Read the file code.py`(观察 memory section 是否出现) + +--- + +## 接下来 + +System prompt 可以运行时组装了,但 Agent 碰到错误还是会崩。网络抖动、API 限流、输出被截断、上下文超限,这些不是 bug,是常态。 + +s11 Error Recovery → 四条恢复路径。升级 token、压缩上下文、指数退避、切换模型。 + +
+深入 Claude Code 源码 + +> 以下基于 Claude Code 源码 `constants/prompts.ts`(914 行)、`constants/systemPromptSections.ts`(68 行)、`context.ts`(189 行)、`utils/api.ts`(718 行)、`utils/systemPrompt.ts`(123 行)、`bootstrap/state.ts` 的分析。 + +### Claude Code 的 system prompt 有多少 section? + +数量不固定,受 feature flag、output style、KAIROS/Proactive 模式、用户类型、token 预算等影响。大致分两类: + +**静态 section**(始终加载):identity、system、doing_tasks、actions、using_tools、tone_style、output_efficiency 等。 + +**动态 section**(按状态加载):session_guidance、memory、ant_model_override、env_info_simple、language、output_style、mcp_instructions、scratchpad、frc、summarize_tool_results、numeric_length_anchors、token_budget、brief 等。 + +`mcp_instructions` 是唯一的易失性 section(通过 `DANGEROUS_uncachedSystemPromptSection()` 创建),因为 MCP server 可以在轮次间连接和断开。 + +### 组装函数 + +```typescript +getSystemPrompt(tools, model, additionalWorkingDirs?, mcpClients?): Promise +``` + +返回 `string[]`(每个元素是一个 section),由 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分隔静态和动态部分。 + +### cache scope + +启用 global cache boundary 时,静态 section 合并成一个 global cache block,动态 section 不使用 global cache(`cacheScope: null`)。没有 boundary 或跳过 global cache 的路径才会走 org scope。 + +教学版的缓存只避免重复拼接字符串。Claude Code 的三层缓存: + +1. **lodash memoize**:`getSystemContext` 和 `getUserContext` 在会话中缓存(`context.ts`) +2. **section 注册缓存**:`STATE.systemPromptSectionCache` 缓存动态 section 结果,`/clear` 或 `/compact` 时清除 +3. **API 级缓存**:`splitSysPromptPrefix()`(`api.ts`)把 prompt 按 boundary 分成不同 cache scope 的块 + +### getUserContext vs getSystemContext + +| | getSystemContext | getUserContext | +|---|---|---| +| 内容 | gitStatus、cacheBreaker | CLAUDE.md 内容、currentDate | +| 注入方式 | 追加到 system prompt 数组 | 前置为 `` 用户消息 | +| 何时跳过 | 自定义 system prompt 时 | 始终运行 | + +### 模式如何改变 prompt + +- **CLAUDE_CODE_SIMPLE**:整个 prompt 只有 2 行 +- **Proactive/KAIROS**:用紧凑版 prompt 替换所有标准 section +- **Coordinator**:用协调器专用 prompt 完全替换 +- **Agent 模式**:Agent 定义的 prompt 替换或追加到默认 prompt + +### 总大小 + +标准交互模式下 system prompt 核心约 20-30KB 文本。CLAUDE_CODE_SIMPLE 约 150 字符。用户上下文(CLAUDE.md)和系统上下文(git status)在此基础上累加。 + +
+ + diff --git a/s10_system_prompt/images/system-prompt-overview.en.svg b/s10_system_prompt/images/system-prompt-overview.en.svg deleted file mode 100644 index dfe0b9270..000000000 --- a/s10_system_prompt/images/system-prompt-overview.en.svg +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - - - - - - - - - - - - - - System Prompt — PROMPT_SECTIONS + On-Demand Assembly + Cache - - - - s09 Preserved - - s10 New - - - - - - PROMPT_SECTIONS - ✓ identity (always) - ✓ tools (always) - ✓ workspace (always) - ○ memory - - - - - - - assemble_system_prompt - Input: context dict - Always: identity + tools + workspace - On-demand: memory - Output: "\n\n".join(selected) - - - - - - - get_system_prompt - json.dumps(context) - Hit → return cached - Miss → assemble + store - (s10 new) - - - - system=get_system_prompt(context) - - - - - - messages[] - - - - - - - Compression + Loading - snip → micro → budget → auto - → load memory (s09) - - - - - - - LLM - stop_reason=tool_use? - system assembled - - - - yes - - - - TOOL_HANDLERS - bash · read · write - (s09 preserved) - - - - Tool results → messages[] → compress → load memory → assemble prompt → LLM - - - - - s09 Preserved: loop, compression pipeline, memory loading, tool execution - - s10 New: PROMPT_SECTIONS (4 sections) + assemble_system_prompt + get_system_prompt (cache) - diff --git a/s10_system_prompt/images/system-prompt-overview.ja.svg b/s10_system_prompt/images/system-prompt-overview.ja.svg deleted file mode 100644 index 2bafa145f..000000000 --- a/s10_system_prompt/images/system-prompt-overview.ja.svg +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - - - - - - - - - - - - - - System Prompt — PROMPT_SECTIONS + オンデマンド組み立て + キャッシュ - - - - s09 保持 - - s10 新規 - - - - - - PROMPT_SECTIONS - ✓ identity (常時) - ✓ tools (常時) - ✓ workspace (常時) - ○ memory - - - - - - - assemble_system_prompt - 入力: context dict - 常時: identity + tools + workspace - オンデマンド: memory - 出力: "\n\n".join(selected) - - - - - - - get_system_prompt - json.dumps(context) - ヒット → キャッシュ返却 - ミス → assemble + 保存 - (s10 新規) - - - - system=get_system_prompt(context) - - - - - - messages[] - - - - - - - 圧縮 + ロード - snip → micro → budget → auto - → 記憶ロード (s09) - - - - - - - LLM - stop_reason=tool_use? - system assembled - - - - あり - - - - TOOL_HANDLERS - bash · read · write - (s09 保持) - - - - ツール結果 → messages[] → 圧縮 → 記憶ロード → プロンプト組み立て → LLM - - - - - s09 保持:ループ、圧縮パイプライン、記憶ロード、ツール実行 - - s10 新規:PROMPT_SECTIONS(4 セクション)+ assemble_system_prompt + get_system_prompt(キャッシュ) - diff --git a/s10_system_prompt/images/system-prompt-overview.svg b/s10_system_prompt/images/system-prompt-overview.svg index 40c7df771..9411bc53b 100644 --- a/s10_system_prompt/images/system-prompt-overview.svg +++ b/s10_system_prompt/images/system-prompt-overview.svg @@ -1,107 +1,150 @@ - + - - - - - - - + + + + - + + - - - System Prompt — PROMPT_SECTIONS + 按需拼接 + 缓存 - - - - s09 保留 - - s10 新增 - - - - - - PROMPT_SECTIONS - ✓ identity (始终) - ✓ tools (始终) - ✓ workspace (始终) - ○ memory - - - - - - - assemble_system_prompt - 输入: context dict - 始终: identity + tools + workspace - 按需: memory - 输出: "\n\n".join(selected) - - - - - - - get_system_prompt - json.dumps(context) - 命中 → 返回缓存 - 未命中 → assemble + 存 - (s10 新增) - - - - system=get_system_prompt(context) - - - - - - messages[] - - - - - - - 压缩 + Loading - snip → micro → budget → auto - → 加载记忆 (s09) - - - - - - - LLM - stop_reason=tool_use? - system assembled - - - - - - - - TOOL_HANDLERS - bash · read · write - (s09 保留) - - - - 工具结果 → messages[] → 压缩 → 加载记忆 → 组装 prompt → LLM - - - - - s09 保留:循环、压缩管线、记忆加载、工具执行 - - s10 新增:PROMPT_SECTIONS(4 段)+ assemble_system_prompt + get_system_prompt(缓存) - + System Prompt Assembly + PROMPT_SECTIONS + on-demand assembly + caching + + + + + PROMPT ASSEMBLY + + + + + PROMPT_SECTIONS + + + + + identity (always) + tools (always) + workspace (always) + memory (on-demand) + + + + + + + + assemble_system_prompt + + + + + input: + context dict + always: + id+tools+ws + on-demand: + memory + output: + join(parts) + + + + + + + + get_system_prompt + + + + + key = + json.dumps(ctx) + hit → return cached + miss → assemble+store + avoids re-assembly + + + + + + system = prompt + + + + + AGENT LOOP + + + + + messages[] + accumulating + + + + + + + Compress + Load + snip > micro > budget > auto + + + + + + + LLM + API call + + + + + + + tool? + + + + yes + + + + TOOL_HANDLERS + bash | read | write + + + + no + + + + return + + + + + + + + + result → messages → re-assemble + + + + Sections: + identity + (always) + tools + (always) + workspace + (always) + memory + (on-demand, if .memory/ exists) + \ No newline at end of file diff --git a/s11_error_recovery/README.en.md b/s11_error_recovery/README.en.md deleted file mode 100644 index 070397b0f..000000000 --- a/s11_error_recovery/README.en.md +++ /dev/null @@ -1,277 +0,0 @@ -# s11: Error Recovery — Errors aren't the end, they're the start of a retry - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s20 -> *"Errors aren't the end, they're the start of a retry"* — escalate tokens, compact context, switch models. -> -> **Harness layer**: Resilience — classify and recover when the main loop hits errors. - ---- - -## The Problem - -The Agent is running along and then errors out: - -``` -Error: 529 overloaded -``` - -The Agent crashes. It doesn't retry, doesn't switch models, doesn't reduce context — it just crashes. - -In production, API errors are the norm. The three most common failure modes: **truncated output** (the model runs out of tokens mid-sentence), **context overflow** (still too long even after compaction), and **transient failures** (429 rate limiting / 529 overload). An Agent that doesn't handle errors is like a car that stalls at the slightest touch. - ---- - -## Solution - -![Error Recovery Overview](images/error-recovery-overview.en.svg) - -The loop and prompt assembly from s10 are fully preserved. The only change: the LLM call is wrapped in try/except, with different recovery paths based on error type. After recovery, `continue` loops back to the top to call the LLM again. - -The three most common recovery patterns (the teaching version only handles 429/529; real systems also cover connection errors, timeouts, cloud vendor credential caches, etc. CC actually has 13+ reason codes; see the Deep Dive for the rest): - -| Pattern | Trigger | Recovery Action | -|----------|---------|-----------------| -| Output truncated | `max_tokens` | Escalate 8K→64K / continuation prompt | -| Context overflow | `prompt_too_long` | Reactive compact → retry | -| Transient failure | 429 / 529 | Exponential backoff + jitter, fallback model on consecutive 529 | - ---- - -## How It Works - -### Path 1: Output Truncated - -The model runs out of tokens mid-sentence — `max_tokens` is exhausted. The default 8000 tokens isn't enough for a complete response. - -On the first occurrence, escalate `max_tokens` from 8K to 64K (8x the space) and retry the same request — the truncated output is NOT appended to messages, keeping the original request intact. If 64K is still not enough, save the truncated output and inject a continuation prompt telling the model to pick up where it left off, up to 3 times: - -```python -if response.stop_reason == "max_tokens": - # First escalation: don't append truncated output, retry same request - if not state.has_escalated: - max_tokens = ESCALATED_MAX_TOKENS - state.has_escalated = True - continue # messages unchanged, same request with more tokens - # 64K still truncated: save output + continuation prompt - messages.append({"role": "assistant", "content": response.content}) - if state.recovery_count < MAX_RECOVERY_RETRIES: - messages.append({"role": "user", "content": - "Output token limit hit. Resume directly — " - "no apology, no recap. Pick up mid-thought."}) - state.recovery_count += 1 - continue - return # still truncated after 3 continuations -# Normal: append after max_tokens check -messages.append({"role": "assistant", "content": response.content}) -``` - -Escalation gets one chance; continuation gets up to 3. After that, exit — further continuations won't produce meaningful output. - -### Path 2: Context Overflow - -The LLM says "your context is too long" (`prompt_too_long`). All four compaction layers from s08 have already run, and it's still over the limit. - -Trigger reactive compact — more aggressive than auto compact. The teaching version keeps only the last 5 messages to simulate compaction; real CC generates a compact summary via LLM, then retries with the compacted message list. Retry after compacting. But if it's still over the limit after one compaction, the only option is to exit — compacting again won't make it any smaller: - -```python -except PromptTooLongError: - if not state.has_attempted_reactive_compact: - messages[:] = reactive_compact(messages) - state.has_attempted_reactive_compact = True - continue - return # Already compacted and still over limit — must exit -``` - -### Path 3: Transient Failures - -Network blips, 429 rate limiting, 529 overload — these aren't bugs, they're normal in distributed systems. - -Both 429 and 529 use exponential backoff + jitter: wait 0.5 seconds on the first attempt, 1 second on the second, 2 seconds on the third, up to 10 retries. Random jitter prevents concurrent requests from all retrying at the same instant. Three consecutive 529 overload errors → switch to the fallback model (if `FALLBACK_MODEL_ID` environment variable is configured): - -```python -def retry_delay(attempt, retry_after=None): - if retry_after: - return retry_after - base = min(500 * (2 ** attempt), 32000) / 1000 - return base + random.uniform(0, base * 0.25) - -def with_retry(fn, state, max_retries=10): - for attempt in range(max_retries): - try: - return fn() - except (RateLimitError, OverloadedError): - delay = retry_delay(attempt) - time.sleep(delay) - if is_overloaded: - state.consecutive_529 += 1 - if state.consecutive_529 >= 3 and FALLBACK_MODEL: - state.current_model = FALLBACK_MODEL - raise MaxRetriesExceeded() -``` - -Backoff formula: `min(500 × 2^attempt, 32000) + random(0~25%)`. If the server returns a `Retry-After` header, that value takes priority. - -### Putting It All Together - -```python -def agent_loop(messages, context): - system = get_system_prompt(context) - state = RecoveryState() - max_tokens = 8000 - - while True: - try: - response = with_retry( - lambda: client.messages.create( - model=state.current_model, system=system, - messages=messages, tools=TOOLS, - max_tokens=max_tokens), - state) - except Exception as e: - if is_prompt_too_long_error(e): - if not state.has_attempted_reactive_compact: - messages[:] = reactive_compact(messages) - state.has_attempted_reactive_compact = True - continue - return - log_error(e) - return - - # max_tokens check BEFORE appending to messages - if response.stop_reason == "max_tokens": - if not state.has_escalated: - max_tokens = 64000 - state.has_escalated = True - continue # retry same request, messages unchanged - # save truncated output + continuation prompt - messages.append({"role": "assistant", "content": response.content}) - messages.append({"role": "user", "content": CONTINUATION_PROMPT}) - continue - # Normal completion - messages.append({"role": "assistant", "content": response.content}) - - if response.stop_reason != "tool_use": - return - # ... tool execution ... -``` - -The outer try/except catches API exceptions (prompt_too_long, etc.), `with_retry` handles transient errors (429/529), and `stop_reason` checks handle truncation. Three recovery mechanisms, each handling its own error type. - ---- - -## Changes from s10 - -| Component | Before (s10) | After (s11) | -|-----------|-------------|-------------| -| Error handling | None (crashes on any error) | Three recovery patterns + exponential backoff | -| New constants | — | ESCALATED_MAX_TOKENS=64000, MAX_RETRIES=10, BASE_DELAY_MS=500, FALLBACK_MODEL | -| New functions | — | with_retry, retry_delay, reactive_compact, is_prompt_too_long_error, RecoveryState | -| Tools | bash, read_file, write_file (3) | bash, read_file, write_file (3) — unchanged | -| Loop | Bare LLM call | Wrapped in try/except + continue retry | - ---- - -## Try It - -```sh -cd learn-claude-code -python s11_error_recovery/code.py -``` - -Try these prompts: - -1. Ask the Agent to generate a very long piece of code, and observe whether it automatically continues after truncation (look for the `[max_tokens] escalating` log) -2. Read many files consecutively to bloat the context, and observe reactive compact -3. If you encounter 429/529, observe the exponential backoff log output - ---- - -## What's Next - -The Agent can now automatically recover from errors. But the tasks it handles are still one-shot — you give it a task, it finishes, it's done. - -What if the Agent could manage a **task list** — with dependencies, persisted to disk, resumable across sessions? A TODO list is not a task system. - -s12 Task System → Tasks form a dependency graph with state and persistence. This is the foundation for multi-Agent collaboration. - -
-Deep Dive into CC Source - -> The following is based on CC source code: `query.ts` (1729 lines), `services/api/withRetry.ts` (822 lines), `query/tokenBudget.ts` (93 lines), and `utils/tokenBudget.ts` (73 lines). - -### 1. A Dozen-Plus Reason/Transition Codes (Not Just 3) - -The teaching version covers 3 of the most common recovery patterns. CC actually has a dozen-plus reason/transition codes, evaluated after every LLM call: - -| Reason/Transition | Teaching Version | CC Behavior | -|---|---|---| -| `completed` | Normal completion | Return result | -| `next_turn` | Normal tool call | Continue to next tool execution round | -| `max_output_tokens_escalate` | Path 1 | 8K→64K escalation | -| `max_output_tokens_recovery` | Path 1 continuation | Continuation prompt (up to 3 times) | -| `reactive_compact_retry` | Path 2 | Reactive compact → retry | -| `prompt_too_long` | Path 2 | Same as above | -| `collapse_drain_retry` | Not covered | Context collapse — commit staged content first | -| `model_error` | Not covered | Retry | -| `image_error` | Not covered | `ImageSizeError` / `ImageResizeError` handled specifically | -| `aborted_streaming` | Not covered | Streaming abort recovery | -| `aborted_tools` | Not covered | Tool abort | -| `stop_hook_blocking` | Not covered | Inject blocking error → model self-corrects | -| `stop_hook_prevented` | Not covered | Hooks prevent execution | -| `hook_stopped` | Not covered | Hook stopped execution | -| `token_budget_continuation` | Not covered | Continue when token usage < 90% | -| `blocking_limit` | Not covered | Blocking limit reached | -| `max_turns` | Not covered | Maximum turns reached | - -The teaching version only expands on the first 5 (most common); each of the rest has its own dedicated handling logic. - -### 2. Precise Exponential Backoff Formula - -CC's backoff delay (`withRetry.ts:530-548`): - -``` -delay = min(500 × 2^(attempt-1), 32000) + random(0~25%) -``` - -| Attempt | Base Delay | + Jitter | -|---------|-----------|----------| -| 1 | 500ms | 0-125ms | -| 2 | 1000ms | 0-250ms | -| 4 | 4000ms | 0-1000ms | -| 7+ | 32000ms (cap) | 0-8000ms | - -If the server returns a `Retry-After` header, that value takes priority. - -### 3. Original CONTINUATION Prompt - -CC's continuation prompt (`query.ts:1225-1227`): - -``` -Output token limit hit. Resume directly — no apology, no recap of what -you were doing. Pick up mid-thought if that is where the cut happened. -Break remaining work into smaller pieces. -``` - -Token budget nudge prompt (`tokenBudget.ts:72`): - -``` -Stopped at {pct}% of token target. Keep working — do not summarize. -``` - -### 4. Streaming Error Handling - -In CC's streaming path, recoverable errors (413, max_tokens, media errors) are **withheld from display** during streaming (`query.ts:788-822`) — SDK consumers don't see them, only the recovery logic does. After streaming ends, the system determines whether recovery is needed. - -### 5. 529 → Fallback Model Switch - -After 3 consecutive 529 overload errors (`MAX_529_RETRIES = 3`), CC automatically switches to the fallback model (e.g., Opus → Sonnet). On switch, all pending messages and tool results are cleared, and the user sees "Switched to {model} due to high demand". - -### 6. Diminishing Returns Detection - -Token budget "continuations" aren't unlimited. When there are 3 consecutive continuations with a token increment < 500, the system determines "continuing won't produce meaningful output" and stops continuation (`tokenBudget.ts:60-62`). - -
- - diff --git a/s11_error_recovery/README.ja.md b/s11_error_recovery/README.ja.md index 27c5e597d..0a705cc51 100644 --- a/s11_error_recovery/README.ja.md +++ b/s11_error_recovery/README.ja.md @@ -1,6 +1,6 @@ # s11: Error Recovery — エラーは終わりではなく、リトライの始まり -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s20 > *"エラーは終わりではなく、リトライの始まり"* — トークン拡張、コンテキスト圧縮、モデル切り替え。 @@ -17,7 +17,7 @@ Agent が動いている途中でエラーが出た: Error: 529 overloaded ``` -Agent がクラッシュした。リトライもしない、モデルも切り替えない、コンテキストも減らさない——そのままクラッシュ。 +Agent がクラッシュした。リトライもしない、モデルも切り替えない、コンテキストも減らさない。そのままクラッシュ。 本番環境では API エラーが日常茶飯事。最も一般的な 3 つの障害パターン:**出力の切り詰め**(モデルが途中まで出力して token が尽きた)、**コンテキスト超過**(圧縮後も長すぎる)、**一時的障害**(429 レート制限 / 529 過負荷)。エラーを処理しない Agent は、一度触れただけで止まる車のようなものだ。 @@ -25,11 +25,11 @@ Agent がクラッシュした。リトライもしない、モデルも切り ## 解決策 -![Error Recovery Overview](images/error-recovery-overview.ja.svg) +![Error Recovery Overview](images/error-recovery-overview.svg) s10 のループ、prompt 組み立てはすべてそのまま。唯一の変更点:LLM 呼び出しを try/except で包み、エラータイプに応じて異なる復旧パスに振り分ける。復旧後は `continue` でループ先頭に戻り、再度 LLM を呼び出す。 -最も一般的な 3 つの復旧パターン(教学版は 429/529 のみ対応;実際のシステムは接続エラー、タイムアウト、クラウドベンダーの認証キャッシュ等もカバー。CC には実際 13 以上の reason code があるが、残りは Deep dive で解説): +最も一般的な 3 つの復旧パターン(教学版は 429/529 のみ対応;実際のシステムは接続エラー、タイムアウト、クラウドベンダーの認証キャッシュ等もカバー。Claude Code には実際 13 以上の reason code があるが、残りは Deep dive で解説): | パターン | トリガー | 復旧アクション | |----------|----------|---------------| @@ -45,7 +45,7 @@ s10 のループ、prompt 組み立てはすべてそのまま。唯一の変更 モデルが途中まで出力して、`max_tokens` に達した。デフォルトの 8000 token では完全な回答を出力しきれない。 -初回発生時、`max_tokens` を 8K から 64K に拡張(8 倍の空間)し、同じリクエストをリトライする——この時、切り詰められた出力は messages に追加せず、元のリクエストをそのまま維持する。64K でも足りない場合にのみ、切り詰められた出力を保存し、続きのプロンプトを注入してモデルに先ほどの続きを出力させる。最大 3 回まで: +初回発生時、`max_tokens` を 8K から 64K に拡張(8 倍の空間)し、同じリクエストをリトライする。この時、切り詰められた出力は messages に追加せず、元のリクエストをそのまま維持する。64K でも足りない場合にのみ、切り詰められた出力を保存し、続きのプロンプトを注入してモデルに先ほどの続きを出力させる。最大 3 回まで: ```python if response.stop_reason == "max_tokens": @@ -67,13 +67,13 @@ if response.stop_reason == "max_tokens": messages.append({"role": "assistant", "content": response.content}) ``` -拡張は 1 回だけ、続きの出力は最大 3 回。超過したら終了——これ以上続けても実質的な出力は得られない。 +拡張は 1 回だけ、続きの出力は最大 3 回。超過したら終了する。これ以上続けても実質的な出力は得られない。 ### パス 2: コンテキスト超過 LLM が「コンテキストが長すぎる」と返す(`prompt_too_long`)。s08 の 4 層圧縮をすべて実行したのに、まだ超えている。 -reactive compact をトリガー——auto compact よりも積極的。教学版は最後の 5 メッセージだけを残して圧縮をシミュレート;実際の CC は LLM で compact サマリを生成してからリトライする。圧縮後にリトライ。ただし、一度圧縮してもまだ超過している場合は終了するしかない——再度圧縮しても小さくはならない: +reactive compact をトリガーする。auto compact よりも積極的。教学版は最後の 5 メッセージだけを残して圧縮をシミュレート;実際の Claude Code は LLM で compact サマリを生成してからリトライする。圧縮後にリトライ。ただし、一度圧縮してもまだ超過している場合は終了するしかない。再度圧縮しても小さくはならない: ```python except PromptTooLongError: @@ -86,7 +86,7 @@ except PromptTooLongError: ### パス 3: 一時的障害 -ネットワークの揺らぎ、429 レート制限、529 過負荷——これらはバグではなく、分散システムの日常だ。 +ネットワークの揺らぎ、429 レート制限、529 過負荷。これらはバグではなく、分散システムの日常だ。 429 と 529 は統一して指数バックオフ + ジッターを使用:1 回目は 0.5 秒待機、2 回目は 1 秒、3 回目は 2 秒、最大 10 回。ランダムジッターを加えることで、並行リクエストが同時にリトライするのを防ぐ。3 回連続で 529 過負荷 → フォールバックモデルに切り替え(`FALLBACK_MODEL_ID` 環境変数が設定されている場合): @@ -197,15 +197,15 @@ Agent に**タスクリスト**を管理させられないだろうか——依 s12 Task System → タスクとは依存関係があり、状態があり、永続化されたグラフだ。これはマルチ Agent 協調の基盤となる。
-CC ソースコード深掘り +Claude Code ソースコード深掘り -> 以下は CC ソースコード `query.ts`(1729 行)、`services/api/withRetry.ts`(822 行)、`query/tokenBudget.ts`(93 行)、`utils/tokenBudget.ts`(73 行)の分析に基づく。 +> 以下は Claude Code ソースコード `query.ts`(1729 行)、`services/api/withRetry.ts`(822 行)、`query/tokenBudget.ts`(93 行)、`utils/tokenBudget.ts`(73 行)の分析に基づく。 ### 一、十数種の reason/transition(3 つだけではない) -教学版では最も一般的な 3 つの復旧パターンを解説した。CC には実際十数種の reason/transition があり、毎回の LLM 呼び出し後に判定される: +教学版では最も一般的な 3 つの復旧パターンを解説した。Claude Code には実際十数種の reason/transition があり、毎回の LLM 呼び出し後に判定される: -| reason/transition | 教学版の対応 | CC の動作 | +| reason/transition | 教学版の対応 | Claude Code の動作 | |---|---|---| | `completed` | 正常終了 | 結果を返す | | `next_turn` | 通常のツール呼び出し | 次のツール実行ラウンドへ | @@ -229,7 +229,7 @@ s12 Task System → タスクとは依存関係があり、状態があり、永 ### 二、指数バックオフの正確な公式 -CC のバックオフ遅延(`withRetry.ts:530-548`): +Claude Code のバックオフ遅延(`withRetry.ts:530-548`): ``` delay = min(500 × 2^(attempt-1), 32000) + random(0~25%) @@ -246,7 +246,7 @@ delay = min(500 × 2^(attempt-1), 32000) + random(0~25%) ### 三、CONTINUATION プロンプト原文 -CC の続き出力プロンプト(`query.ts:1225-1227`): +Claude Code の続き出力プロンプト(`query.ts:1225-1227`): ``` Output token limit hit. Resume directly — no apology, no recap of what @@ -262,11 +262,11 @@ Stopped at {pct}% of token target. Keep working — do not summarize. ### 四、ストリーミングエラー処理 -CC のストリーミングパスでは、復旧可能なエラー(413、max_tokens、media error)はストリーミング中**表示を保留される**(`query.ts:788-822`)——SDK コンシューマーには見えず、復旧ロジックだけが認識できる。ストリーミング終了後に復旧が必要かどうかを判断する。 +Claude Code のストリーミングパスでは、復旧可能なエラー(413、max_tokens、media error)はストリーミング中**表示を保留される**(`query.ts:788-822`)——SDK コンシューマーには見えず、復旧ロジックだけが認識できる。ストリーミング終了後に復旧が必要かどうかを判断する。 ### 五、529 → フォールバックモデル切り替え -3 回連続で 529 過負荷エラーが発生した後(`MAX_529_RETRIES = 3`)、CC は自動的にフォールバックモデルに切り替える(例:Opus → Sonnet)。切り替え時にすべての保留中のメッセージと tool 結果をクリアし、ユーザーに "Switched to {model} due to high demand" と表示する。 +3 回連続で 529 過負荷エラーが発生した後(`MAX_529_RETRIES = 3`)、Claude Code は自動的にフォールバックモデルに切り替える(例:Opus → Sonnet)。切り替え時にすべての保留中のメッセージと tool 結果をクリアし、ユーザーに "Switched to {model} due to high demand" と表示する。 ### 六、収穫逓減の検出 diff --git a/s11_error_recovery/README.md b/s11_error_recovery/README.md index 32ae63128..eb976358c 100644 --- a/s11_error_recovery/README.md +++ b/s11_error_recovery/README.md @@ -1,51 +1,51 @@ -# s11: Error Recovery — 错误不是结束,是重试的开始 +# s11: Error Recovery — Errors aren't the end, they're the start of a retry -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s20 -> *"错误不是终点, 是重试的起点"* — 升级 token、压缩上下文、切换模型。 +> *"Errors aren't the end, they're the start of a retry"* — escalate tokens, compact context, switch models. > -> **Harness 层**: 韧性 — 主循环遇到错误时分类并恢复。 +> **Harness layer**: Resilience — classify and recover when the main loop hits errors. --- -## 问题 +## The Problem -Agent 跑着跑着报错了: +The Agent is running along and then errors out: ``` Error: 529 overloaded ``` -Agent 崩溃了。它没有重试,没有换模型,没有减少上下文——直接崩溃。 +The Agent crashes. It doesn't retry, doesn't switch models, doesn't reduce context. It just crashes. -生产环境中 API 错误是常态。三种最常见的故障模式:**输出被截断**(模型话说一半 token 用完了)、**上下文超限**(压缩后还是太长)、**临时故障**(429 限流 / 529 过载)。一个不处理错误的 Agent 就像一个一碰就熄火的车。 +In production, API errors are the norm. The three most common failure modes: **truncated output** (the model runs out of tokens mid-sentence), **context overflow** (still too long even after compaction), and **transient failures** (429 rate limiting / 529 overload). An Agent that doesn't handle errors is like a car that stalls at the slightest touch. --- -## 解决方案 +## Solution ![Error Recovery Overview](images/error-recovery-overview.svg) -s10 的循环、prompt 组装全部保留。唯一的变动:LLM 调用包裹在 try/except 里,根据错误类型走不同的恢复路径。恢复后 `continue` 回到循环开头重新调用 LLM。 +The loop and prompt assembly from s10 are fully preserved. The only change: the LLM call is wrapped in try/except, with different recovery paths based on error type. After recovery, `continue` loops back to the top to call the LLM again. -三种最常见的恢复模式(教学版只处理 429/529;真实系统还覆盖连接错误、超时、云厂商认证缓存等。CC 实际有 13+ reason code,其余见 Deep dive): +The three most common recovery patterns (the teaching version only handles 429/529; real systems also cover connection errors, timeouts, cloud vendor credential caches, etc. Claude Code actually has 13+ reason codes; see the Deep Dive for the rest): -| 模式 | 触发 | 恢复动作 | -|------|------|---------| -| 输出截断 | `max_tokens` | 升级 8K→64K / 续写提示 | -| 上下文超限 | `prompt_too_long` | reactive compact → 重试 | -| 临时故障 | 429 / 529 | 指数退避 + 抖动,连续 529 可切换备用模型 | +| Pattern | Trigger | Recovery Action | +|----------|---------|-----------------| +| Output truncated | `max_tokens` | Escalate 8K→64K / continuation prompt | +| Context overflow | `prompt_too_long` | Reactive compact → retry | +| Transient failure | 429 / 529 | Exponential backoff + jitter, fallback model on consecutive 529 | --- -## 工作原理 +## How It Works -### 路径 1: 输出被截断 +### Path 1: Output Truncated -模型话说一半,`max_tokens` 用完了。默认 8000 token 不够它输出完整回答。 +The model runs out of tokens mid-sentence. `max_tokens` is exhausted. The default 8000 tokens isn't enough for a complete response. -第一次发生时,直接把 `max_tokens` 从 8K 升级到 64K(8 倍空间),重试同一请求——此时不追加截断输出到 messages,保持原始请求不变。如果 64K 还是不够,才保存截断输出并注入续写提示让模型接着刚才的话继续说,最多 3 次: +On the first occurrence, escalate `max_tokens` from 8K to 64K (8x the space) and retry the same request. The truncated output is NOT appended to messages, keeping the original request intact. If 64K is still not enough, save the truncated output and inject a continuation prompt telling the model to pick up where it left off, up to 3 times: ```python if response.stop_reason == "max_tokens": @@ -67,13 +67,13 @@ if response.stop_reason == "max_tokens": messages.append({"role": "assistant", "content": response.content}) ``` -升级只有一次机会,续写最多 3 次。超过就退出——继续续写也不会有实质产出。 +Escalation gets one chance; continuation gets up to 3. After that, exit. Further continuations won't produce meaningful output. -### 路径 2: 上下文超限 +### Path 2: Context Overflow -LLM 说"你的上下文太长了"(`prompt_too_long`)。s08 的四层压缩全跑过了,还是超。 +The LLM says "your context is too long" (`prompt_too_long`). All four compaction layers from s08 have already run, and it's still over the limit. -触发 reactive compact——比 auto compact 更激进。教学版只保留最后 5 条消息模拟压缩效果;真实实现会调用 LLM 生成 compact 摘要再重试。压缩后重试。但如果压缩过一次还是超限,只能退出——再压缩也不会变小: +Trigger reactive compact, which is more aggressive than auto compact. The teaching version keeps only the last 5 messages to simulate compaction; real Claude Code generates a compact summary via LLM, then retries with the compacted message list. Retry after compacting. But if it's still over the limit after one compaction, the only option is to exit. Compacting again won't make it any smaller: ```python except PromptTooLongError: @@ -81,14 +81,14 @@ except PromptTooLongError: messages[:] = reactive_compact(messages) state.has_attempted_reactive_compact = True continue - return # 压缩过了还是超限,只能退出 + return # Already compacted and still over limit — must exit ``` -### 路径 3: 临时故障 +### Path 3: Transient Failures -网络抖动、429 限流、529 过载——这些不是 bug,是分布式系统的常态。 +Network blips, 429 rate limiting, 529 overload. These aren't bugs; they're normal in distributed systems. -429 和 529 统一走指数退避 + 抖动:第一次等 0.5 秒,第二次等 1 秒,第三次等 2 秒,最多 10 次。加随机抖动让并发请求不在同一时刻重试。连续 3 次 529 过载 → 切换到备用模型(若配置了 `FALLBACK_MODEL_ID` 环境变量): +Both 429 and 529 use exponential backoff + jitter: wait 0.5 seconds on the first attempt, 1 second on the second, 2 seconds on the third, up to 10 retries. Random jitter prevents concurrent requests from all retrying at the same instant. Three consecutive 529 overload errors → switch to the fallback model (if `FALLBACK_MODEL_ID` environment variable is configured): ```python def retry_delay(attempt, retry_after=None): @@ -111,9 +111,9 @@ def with_retry(fn, state, max_retries=10): raise MaxRetriesExceeded() ``` -退避公式:`min(500 × 2^attempt, 32000) + random(0~25%)`。如果服务器返回 `Retry-After` header,优先用那个值。 +Backoff formula: `min(500 × 2^attempt, 32000) + random(0~25%)`. If the server returns a `Retry-After` header, that value takes priority. -### 合起来跑 +### Putting It All Together ```python def agent_loop(messages, context): @@ -157,96 +157,96 @@ def agent_loop(messages, context): # ... tool execution ... ``` -外层 try/except 捕获 API 异常(prompt_too_long 等),`with_retry` 处理瞬态错误(429/529),`stop_reason` 检查处理截断。三种恢复机制各管各的错误类型。 +The outer try/except catches API exceptions (prompt_too_long, etc.), `with_retry` handles transient errors (429/529), and `stop_reason` checks handle truncation. Three recovery mechanisms, each handling its own error type. --- -## 相对 s10 的变更 +## Changes from s10 -| 组件 | 之前 (s10) | 之后 (s11) | -|------|-----------|-----------| -| 错误处理 | 无(一碰就崩溃) | 三种恢复模式 + 指数退避 | -| 新常量 | — | ESCALATED_MAX_TOKENS=64000, MAX_RETRIES=10, BASE_DELAY_MS=500, FALLBACK_MODEL | -| 新函数 | — | with_retry, retry_delay, reactive_compact, is_prompt_too_long_error, RecoveryState | -| 工具 | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 不变 | -| 循环 | 裸调用 LLM | try/except 包裹 + continue 重试 | +| Component | Before (s10) | After (s11) | +|-----------|-------------|-------------| +| Error handling | None (crashes on any error) | Three recovery patterns + exponential backoff | +| New constants | — | ESCALATED_MAX_TOKENS=64000, MAX_RETRIES=10, BASE_DELAY_MS=500, FALLBACK_MODEL | +| New functions | — | with_retry, retry_delay, reactive_compact, is_prompt_too_long_error, RecoveryState | +| Tools | bash, read_file, write_file (3) | bash, read_file, write_file (3) — unchanged | +| Loop | Bare LLM call | Wrapped in try/except + continue retry | --- -## 试一下 +## Try It ```sh cd learn-claude-code python s11_error_recovery/code.py ``` -试试这些 prompt: +Try these prompts: -1. 让 Agent 生成一段很长的代码,观察截断后是否自动续写(看 `[max_tokens] escalating` 日志) -2. 连续读取大量文件撑大上下文,观察 reactive compact -3. 如果遇到 429/529,观察指数退避的日志输出 +1. Ask the Agent to generate a very long piece of code, and observe whether it automatically continues after truncation (look for the `[max_tokens] escalating` log) +2. Read many files consecutively to bloat the context, and observe reactive compact +3. If you encounter 429/529, observe the exponential backoff log output --- -## 接下来 +## What's Next -Agent 现在能在错误中自动恢复了。但它处理的任务仍然是"一次性"的——你给它一个任务,它做完,结束。 +The Agent can now automatically recover from errors. But the tasks it handles are still one-shot — you give it a task, it finishes, it's done. -能不能让 Agent 管理一个**任务列表**——有依赖关系、持久化到磁盘、跨会话能恢复?TODO 列表不是任务系统。 +What if the Agent could manage a **task list** — with dependencies, persisted to disk, resumable across sessions? A TODO list is not a task system. -s12 Task System → 任务是有依赖、有状态、持久化的图。这是多 Agent 协作的基础。 +s12 Task System → Tasks form a dependency graph with state and persistence. This is the foundation for multi-Agent collaboration.
-深入 CC 源码 +Deep Dive into Claude Code Source -> 以下基于 CC 源码 `query.ts`(1729 行)、`services/api/withRetry.ts`(822 行)、`query/tokenBudget.ts`(93 行)、`utils/tokenBudget.ts`(73 行)的分析。 +> The following is based on Claude Code source code: `query.ts` (1729 lines), `services/api/withRetry.ts` (822 lines), `query/tokenBudget.ts` (93 lines), and `utils/tokenBudget.ts` (73 lines). -### 一、十几种 reason/transition(不只是 3 条) +### 1. A Dozen-Plus Reason/Transition Codes (Not Just 3) -教学版讲了 3 种最常见的恢复模式。CC 实际有十几种 reason/transition,每轮 LLM 调用后都会判断: +The teaching version covers 3 of the most common recovery patterns. Claude Code actually has a dozen-plus reason/transition codes, evaluated after every LLM call: -| reason/transition | 教学版对应 | CC 行为 | +| Reason/Transition | Teaching Version | Claude Code Behavior | |---|---|---| -| `completed` | 正常完成 | 返回结果 | -| `next_turn` | 正常工具调用 | 继续下一轮工具执行 | -| `max_output_tokens_escalate` | 路径 1 | 8K→64K 升级 | -| `max_output_tokens_recovery` | 路径 1 续写 | 续写提示(最多 3 次) | -| `reactive_compact_retry` | 路径 2 | reactive compact → 重试 | -| `prompt_too_long` | 路径 2 | 同上 | -| `collapse_drain_retry` | 未展开 | context collapse 先提交暂存 | -| `model_error` | 未展开 | 重试 | -| `image_error` | 未展开 | `ImageSizeError` / `ImageResizeError` 专门处理 | -| `aborted_streaming` | 未展开 | 流式中止恢复 | -| `aborted_tools` | 未展开 | 工具中止 | -| `stop_hook_blocking` | 未展开 | 注入 blocking error → 模型自纠 | -| `stop_hook_prevented` | 未展开 | hooks 阻止 | -| `hook_stopped` | 未展开 | hook 停止执行 | -| `token_budget_continuation` | 未展开 | token 用量 < 90% 时继续 | -| `blocking_limit` | 未展开 | 阻塞限制 | -| `max_turns` | 未展开 | 达到最大轮次 | - -教学版只展开了前 5 种(最常见的),其余各有专门处理逻辑。 - -### 二、指数退避的精确公式 - -CC 的退避延迟(`withRetry.ts:530-548`): +| `completed` | Normal completion | Return result | +| `next_turn` | Normal tool call | Continue to next tool execution round | +| `max_output_tokens_escalate` | Path 1 | 8K→64K escalation | +| `max_output_tokens_recovery` | Path 1 continuation | Continuation prompt (up to 3 times) | +| `reactive_compact_retry` | Path 2 | Reactive compact → retry | +| `prompt_too_long` | Path 2 | Same as above | +| `collapse_drain_retry` | Not covered | Context collapse — commit staged content first | +| `model_error` | Not covered | Retry | +| `image_error` | Not covered | `ImageSizeError` / `ImageResizeError` handled specifically | +| `aborted_streaming` | Not covered | Streaming abort recovery | +| `aborted_tools` | Not covered | Tool abort | +| `stop_hook_blocking` | Not covered | Inject blocking error → model self-corrects | +| `stop_hook_prevented` | Not covered | Hooks prevent execution | +| `hook_stopped` | Not covered | Hook stopped execution | +| `token_budget_continuation` | Not covered | Continue when token usage < 90% | +| `blocking_limit` | Not covered | Blocking limit reached | +| `max_turns` | Not covered | Maximum turns reached | + +The teaching version only expands on the first 5 (most common); each of the rest has its own dedicated handling logic. + +### 2. Precise Exponential Backoff Formula + +Claude Code's backoff delay (`withRetry.ts:530-548`): ``` delay = min(500 × 2^(attempt-1), 32000) + random(0~25%) ``` -| 尝试 | 基础延迟 | + 抖动 | -|------|---------|--------| +| Attempt | Base Delay | + Jitter | +|---------|-----------|----------| | 1 | 500ms | 0-125ms | | 2 | 1000ms | 0-250ms | | 4 | 4000ms | 0-1000ms | -| 7+ | 32000ms(上限) | 0-8000ms | +| 7+ | 32000ms (cap) | 0-8000ms | -如果服务器返回 `Retry-After` header,优先用那个值。 +If the server returns a `Retry-After` header, that value takes priority. -### 三、CONTINUATION 提示原文 +### 3. Original CONTINUATION Prompt -CC 的续写提示(`query.ts:1225-1227`): +Claude Code's continuation prompt (`query.ts:1225-1227`): ``` Output token limit hit. Resume directly — no apology, no recap of what @@ -254,23 +254,23 @@ you were doing. Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces. ``` -Token budget 的 nudge 提示(`tokenBudget.ts:72`): +Token budget nudge prompt (`tokenBudget.ts:72`): ``` Stopped at {pct}% of token target. Keep working — do not summarize. ``` -### 四、流式错误处理 +### 4. Streaming Error Handling -CC 的流式路径中,可恢复的错误(413、max_tokens、media error)在 streaming 期间**被暂扣不展示**(`query.ts:788-822`)——SDK 消费者看不到,只有恢复逻辑能看到。等 streaming 结束后才判断是否需要恢复。 +In Claude Code's streaming path, recoverable errors (413, max_tokens, media errors) are **withheld from display** during streaming (`query.ts:788-822`) — SDK consumers don't see them, only the recovery logic does. After streaming ends, the system determines whether recovery is needed. -### 五、529 → Fallback Model 切换 +### 5. 529 → Fallback Model Switch -连续 3 次 529 过载错误后(`MAX_529_RETRIES = 3`),CC 自动切换到 fallback model(如 Opus → Sonnet)。切换时清除所有 pending 消息和 tool 结果,给用户展示 "Switched to {model} due to high demand"。 +After 3 consecutive 529 overload errors (`MAX_529_RETRIES = 3`), Claude Code automatically switches to the fallback model (e.g., Opus → Sonnet). On switch, all pending messages and tool results are cleared, and the user sees "Switched to {model} due to high demand". -### 六、Diminishing Returns 检测 +### 6. Diminishing Returns Detection -Token budget 的"继续"不是无限的。当连续 3 次 continuation 且 token 增量 < 500 时,系统判断"继续也没有实质性产出",停止 continuation(`tokenBudget.ts:60-62`)。 +Token budget "continuations" aren't unlimited. When there are 3 consecutive continuations with a token increment < 500, the system determines "continuing won't produce meaningful output" and stops continuation (`tokenBudget.ts:60-62`).
diff --git a/s11_error_recovery/README.zh.md b/s11_error_recovery/README.zh.md new file mode 100644 index 000000000..dbe3a180f --- /dev/null +++ b/s11_error_recovery/README.zh.md @@ -0,0 +1,277 @@ +# s11: Error Recovery — 错误不是结束,是重试的开始 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s09 → s10 → `s11` → [s12](../s12_task_system/) → s13 → ... → s20 +> *"错误不是终点, 是重试的起点"* — 升级 token、压缩上下文、切换模型。 +> +> **Harness 层**: 韧性 — 主循环遇到错误时分类并恢复。 + +--- + +## 问题 + +Agent 跑着跑着报错了: + +``` +Error: 529 overloaded +``` + +Agent 崩溃了。没有重试,没有换模型,没有减少上下文,直接崩溃。 + +生产环境中 API 错误是常态。三种最常见的故障模式:**输出被截断**(模型输出到一半 token 用完了)、**上下文超限**(压缩后还是太长)、**临时故障**(429 限流 / 529 过载)。一个不处理错误的 Agent 就像一个一碰就熄火的车。 + +--- + +## 解决方案 + +![Error Recovery Overview](images/error-recovery-overview.svg) + +s10 的循环、prompt 组装全部保留。唯一的变动:LLM 调用包裹在 try/except 里,根据错误类型走不同的恢复路径。恢复后 `continue` 回到循环开头重新调用 LLM。 + +三种最常见的恢复模式(教学版只处理 429/529;真实系统还覆盖连接错误、超时、云厂商认证缓存等。Claude Code 实际有 13+ reason code,其余见 Deep dive): + +| 模式 | 触发 | 恢复动作 | +|------|------|---------| +| 输出截断 | `max_tokens` | 升级 8K→64K / 续写提示 | +| 上下文超限 | `prompt_too_long` | reactive compact → 重试 | +| 临时故障 | 429 / 529 | 指数退避 + 抖动,连续 529 可切换备用模型 | + +--- + +## 工作原理 + +### 路径 1: 输出被截断 + +模型输出到一半,`max_tokens` 用完了。默认 8000 token 不够它输出完整回答。 + +第一次发生时,直接把 `max_tokens` 从 8K 升级到 64K(8 倍空间),重试同一请求。此时不追加截断输出到 messages,保持原始请求不变。如果 64K 还是不够,才保存截断输出并注入续写提示让模型从截断处继续输出,最多 3 次: + +```python +if response.stop_reason == "max_tokens": + # First escalation: don't append truncated output, retry same request + if not state.has_escalated: + max_tokens = ESCALATED_MAX_TOKENS + state.has_escalated = True + continue # messages unchanged, same request with more tokens + # 64K still truncated: save output + continuation prompt + messages.append({"role": "assistant", "content": response.content}) + if state.recovery_count < MAX_RECOVERY_RETRIES: + messages.append({"role": "user", "content": + "Output token limit hit. Resume directly — " + "no apology, no recap. Pick up mid-thought."}) + state.recovery_count += 1 + continue + return # still truncated after 3 continuations +# Normal: append after max_tokens check +messages.append({"role": "assistant", "content": response.content}) +``` + +升级只有一次机会,续写最多 3 次。超过就退出,继续续写也不会有实质产出。 + +### 路径 2: 上下文超限 + +LLM 说"你的上下文太长了"(`prompt_too_long`)。s08 的四层压缩全跑过了,还是超。 + +触发 reactive compact,比 auto compact 更激进。教学版只保留最后 5 条消息模拟压缩效果;真实实现会调用 LLM 生成 compact 摘要再重试。压缩后重试。但如果压缩过一次还是超限,只能退出,再压缩也不会变小: + +```python +except PromptTooLongError: + if not state.has_attempted_reactive_compact: + messages[:] = reactive_compact(messages) + state.has_attempted_reactive_compact = True + continue + return # 压缩过了还是超限,只能退出 +``` + +### 路径 3: 临时故障 + +网络抖动、429 限流、529 过载。这些不是 bug,是分布式系统的常态。 + +429 和 529 统一走指数退避 + 抖动:第一次等 0.5 秒,第二次等 1 秒,第三次等 2 秒,最多 10 次。加随机抖动让并发请求不在同一时刻重试。连续 3 次 529 过载 → 切换到备用模型(若配置了 `FALLBACK_MODEL_ID` 环境变量): + +```python +def retry_delay(attempt, retry_after=None): + if retry_after: + return retry_after + base = min(500 * (2 ** attempt), 32000) / 1000 + return base + random.uniform(0, base * 0.25) + +def with_retry(fn, state, max_retries=10): + for attempt in range(max_retries): + try: + return fn() + except (RateLimitError, OverloadedError): + delay = retry_delay(attempt) + time.sleep(delay) + if is_overloaded: + state.consecutive_529 += 1 + if state.consecutive_529 >= 3 and FALLBACK_MODEL: + state.current_model = FALLBACK_MODEL + raise MaxRetriesExceeded() +``` + +退避公式:`min(500 × 2^attempt, 32000) + random(0~25%)`。如果服务器返回 `Retry-After` header,优先用那个值。 + +### 合起来跑 + +```python +def agent_loop(messages, context): + system = get_system_prompt(context) + state = RecoveryState() + max_tokens = 8000 + + while True: + try: + response = with_retry( + lambda: client.messages.create( + model=state.current_model, system=system, + messages=messages, tools=TOOLS, + max_tokens=max_tokens), + state) + except Exception as e: + if is_prompt_too_long_error(e): + if not state.has_attempted_reactive_compact: + messages[:] = reactive_compact(messages) + state.has_attempted_reactive_compact = True + continue + return + log_error(e) + return + + # max_tokens check BEFORE appending to messages + if response.stop_reason == "max_tokens": + if not state.has_escalated: + max_tokens = 64000 + state.has_escalated = True + continue # retry same request, messages unchanged + # save truncated output + continuation prompt + messages.append({"role": "assistant", "content": response.content}) + messages.append({"role": "user", "content": CONTINUATION_PROMPT}) + continue + # Normal completion + messages.append({"role": "assistant", "content": response.content}) + + if response.stop_reason != "tool_use": + return + # ... tool execution ... +``` + +外层 try/except 捕获 API 异常(prompt_too_long 等),`with_retry` 处理瞬态错误(429/529),`stop_reason` 检查处理截断。三种恢复机制各管各的错误类型。 + +--- + +## 相对 s10 的变更 + +| 组件 | 之前 (s10) | 之后 (s11) | +|------|-----------|-----------| +| 错误处理 | 无(一碰就崩溃) | 三种恢复模式 + 指数退避 | +| 新常量 | — | ESCALATED_MAX_TOKENS=64000, MAX_RETRIES=10, BASE_DELAY_MS=500, FALLBACK_MODEL | +| 新函数 | — | with_retry, retry_delay, reactive_compact, is_prompt_too_long_error, RecoveryState | +| 工具 | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 不变 | +| 循环 | 裸调用 LLM | try/except 包裹 + continue 重试 | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s11_error_recovery/code.py +``` + +试试这些 prompt: + +1. 让 Agent 生成一段很长的代码,观察截断后是否自动续写(看 `[max_tokens] escalating` 日志) +2. 连续读取大量文件撑大上下文,观察 reactive compact +3. 如果遇到 429/529,观察指数退避的日志输出 + +--- + +## 接下来 + +Agent 现在能在错误中自动恢复了。但它处理的任务仍然是"一次性"的——你给它一个任务,它做完,结束。 + +能不能让 Agent 管理一个**任务列表**——有依赖关系、持久化到磁盘、跨会话能恢复?TODO 列表不是任务系统。 + +s12 Task System → 任务是有依赖、有状态、持久化的图。这是多 Agent 协作的基础。 + +
+深入 Claude Code 源码 + +> 以下基于 Claude Code 源码 `query.ts`(1729 行)、`services/api/withRetry.ts`(822 行)、`query/tokenBudget.ts`(93 行)、`utils/tokenBudget.ts`(73 行)的分析。 + +### 一、十几种 reason/transition(不只是 3 条) + +教学版讲了 3 种最常见的恢复模式。Claude Code 实际有十几种 reason/transition,每轮 LLM 调用后都会判断: + +| reason/transition | 教学版对应 | Claude Code 行为 | +|---|---|---| +| `completed` | 正常完成 | 返回结果 | +| `next_turn` | 正常工具调用 | 继续下一轮工具执行 | +| `max_output_tokens_escalate` | 路径 1 | 8K→64K 升级 | +| `max_output_tokens_recovery` | 路径 1 续写 | 续写提示(最多 3 次) | +| `reactive_compact_retry` | 路径 2 | reactive compact → 重试 | +| `prompt_too_long` | 路径 2 | 同上 | +| `collapse_drain_retry` | 未展开 | context collapse 先提交暂存 | +| `model_error` | 未展开 | 重试 | +| `image_error` | 未展开 | `ImageSizeError` / `ImageResizeError` 专门处理 | +| `aborted_streaming` | 未展开 | 流式中止恢复 | +| `aborted_tools` | 未展开 | 工具中止 | +| `stop_hook_blocking` | 未展开 | 注入 blocking error → 模型自纠 | +| `stop_hook_prevented` | 未展开 | hooks 阻止 | +| `hook_stopped` | 未展开 | hook 停止执行 | +| `token_budget_continuation` | 未展开 | token 用量 < 90% 时继续 | +| `blocking_limit` | 未展开 | 阻塞限制 | +| `max_turns` | 未展开 | 达到最大轮次 | + +教学版只展开了前 5 种(最常见的),其余各有专门处理逻辑。 + +### 二、指数退避的精确公式 + +Claude Code 的退避延迟(`withRetry.ts:530-548`): + +``` +delay = min(500 × 2^(attempt-1), 32000) + random(0~25%) +``` + +| 尝试 | 基础延迟 | + 抖动 | +|------|---------|--------| +| 1 | 500ms | 0-125ms | +| 2 | 1000ms | 0-250ms | +| 4 | 4000ms | 0-1000ms | +| 7+ | 32000ms(上限) | 0-8000ms | + +如果服务器返回 `Retry-After` header,优先用那个值。 + +### 三、CONTINUATION 提示原文 + +Claude Code 的续写提示(`query.ts:1225-1227`): + +``` +Output token limit hit. Resume directly — no apology, no recap of what +you were doing. Pick up mid-thought if that is where the cut happened. +Break remaining work into smaller pieces. +``` + +Token budget 的 nudge 提示(`tokenBudget.ts:72`): + +``` +Stopped at {pct}% of token target. Keep working — do not summarize. +``` + +### 四、流式错误处理 + +Claude Code 的流式路径中,可恢复的错误(413、max_tokens、media error)在 streaming 期间**被暂扣不展示**(`query.ts:788-822`)——SDK 消费者看不到,只有恢复逻辑能看到。等 streaming 结束后才判断是否需要恢复。 + +### 五、529 → Fallback Model 切换 + +连续 3 次 529 过载错误后(`MAX_529_RETRIES = 3`),Claude Code 自动切换到 fallback model(如 Opus → Sonnet)。切换时清除所有 pending 消息和 tool 结果,给用户展示 "Switched to {model} due to high demand"。 + +### 六、Diminishing Returns 检测 + +Token budget 的"继续"不是无限的。当连续 3 次 continuation 且 token 增量 < 500 时,系统判断"继续也没有实质性产出",停止 continuation(`tokenBudget.ts:60-62`)。 + +
+ + diff --git a/s11_error_recovery/code.py b/s11_error_recovery/code.py index 8a4862b8e..c517d749b 100644 --- a/s11_error_recovery/code.py +++ b/s11_error_recovery/code.py @@ -234,7 +234,7 @@ def is_prompt_too_long_error(e: Exception) -> bool: def reactive_compact(messages: list) -> list: """Emergency compact — teaching version keeps last N messages. - Real CC generates a compact summary via LLM, then retries with + Real Claude Code generates a compact summary via LLM, then retries with the compacted message list. Teaching version simplifies to tail retention since s08/s09 already cover LLM-based compact.""" print(" \033[31m[reactive compact] trimming to last 5 messages\033[0m") diff --git a/s11_error_recovery/images/error-recovery-overview.en.svg b/s11_error_recovery/images/error-recovery-overview.en.svg deleted file mode 100644 index 22790a3c5..000000000 --- a/s11_error_recovery/images/error-recovery-overview.en.svg +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - Error Recovery — try/except wrapping LLM calls, three recovery modes - - - - s10 retained - - s11 new - - - - messages - - - - - prompt assembly - (s10) - - - - - compress + load - (s08-s09) - - - - - - LLM - try/except - - - - - TOOL_HANDLERS - bash · read · write - - - - error - - - - Error Recovery (classify, recover, retry LLM) - - - - Path 1 - max_tokens - Output truncated → escalate 8K→64K (once) / continuation prompt (max 3) - Trigger: stop_reason == "max_tokens" · Cost: 0-1 API · Recover then continue - - - - Path 2 - prompt_too_long - Context overflow → reactive compact → retry (one chance) - Trigger: API returns 413 · Cost: 1 API · Still over after compact → exit - - - - Path 3 - 429/529 - Transient failure → exponential backoff + jitter (max 10) / 3×529 → switch model - Trigger: RateLimitError / OverloadedError · Formula: min(500×2^n, 32s) + jitter - - - - Three most common recovery modes. CC has 13+ reason codes (image_error, aborted_streaming, etc.), each with dedicated handling. - All paths after recovery → continue back to LLM · Normal flow: tool results → messages → loop - diff --git a/s11_error_recovery/images/error-recovery-overview.ja.svg b/s11_error_recovery/images/error-recovery-overview.ja.svg deleted file mode 100644 index 36c4fd606..000000000 --- a/s11_error_recovery/images/error-recovery-overview.ja.svg +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - Error Recovery — try/except で LLM 呼び出しをラップ、3 つの復旧モード - - - - s10 維持 - - s11 新規 - - - - messages - - - - - prompt assembly - (s10) - - - - - compress + load - (s08-s09) - - - - - - LLM - try/except - - - - - TOOL_HANDLERS - bash · read · write - - - - エラー - - - - エラー復旧(分類処理、復旧後 LLM に戻りリトライ) - - - - パス 1 - max_tokens - 出力が途切れた → 8K→64K に拡張(1 回)/ 続行プロンプト(最大 3 回) - トリガー: stop_reason == "max_tokens" · コスト: 0-1 API · 復旧後 continue - - - - パス 2 - prompt_too_long - コンテキスト超過 → reactive compact → リトライ(1 回のみ) - トリガー: API が 413 返却 · コスト: 1 API · 圧縮後も超過 → 終了 - - - - パス 3 - 429/529 - 一時障害 → 指数バックオフ + ジッター(最大 10 回)/ 3 回 529 → モデル切替 - トリガー: RateLimitError / OverloadedError · 式: min(500×2^n, 32s) + jitter - - - - 最も一般的な 3 つの復旧モード。CC は実際に 13+ の reason code を持ち(image_error, aborted_streaming 等)、それぞれ専用の処理がある。 - 全パス復旧後 → continue で LLM に戻る · 正常フロー: ツール結果 → messages → ループ - \ No newline at end of file diff --git a/s11_error_recovery/images/error-recovery-overview.svg b/s11_error_recovery/images/error-recovery-overview.svg index 63f4b2fe1..0757c5e84 100644 --- a/s11_error_recovery/images/error-recovery-overview.svg +++ b/s11_error_recovery/images/error-recovery-overview.svg @@ -1,98 +1,145 @@ - + - - - - - + + - - + + - - - - - - - - - - + + - - - Error Recovery — try/except 包裹 LLM 调用,三种恢复模式 - - - - s10 保留 - - s11 新增 - - - - messages - - - - - prompt assembly - (s10) - - - - - compress + load - (s08-s09) - - - - - - LLM - try/except - - - - - TOOL_HANDLERS - bash · read · write - - - - 报错 - - - - 错误恢复(分类处理,恢复后回到 LLM 重试) - - - - 路径 1 - max_tokens - 输出被截断 → 升级 8K→64K(一次)/ 续写提示(最多 3 次) - 触发: stop_reason == "max_tokens" · 代价: 0-1 API · 恢复后 continue - - - - 路径 2 - prompt_too_long - 上下文超限 → reactive compact → 重试(一次机会) - 触发: API 返回 413 · 代价: 1 API · 压缩过还是超 → 退出 - - - - 路径 3 - 429/529 - 临时故障 → 指数退避 + 抖动(最多 10 次)/ 3 次 529 → 切换模型 - 触发: RateLimitError / OverloadedError · 公式: min(500×2^n, 32s) + jitter - - - - 三种最常见的恢复模式。CC 实际有 13+ reason code(image_error、aborted_streaming 等),各有专门处理。 - 所有路径恢复后 → continue 回到 LLM · 正常流程: 工具结果 → messages → 循环 - + Error Recovery + try/except wraps LLM calls — three recovery patterns + + + + + + messages + + + + + + + prompt + assembly + + + + + + + compress + + load context + + + + + + + LLM + try/except + + + + + + + TOOL_HANDLERS + bash · read · write + + + + + tool result → append → next turn + + + + error + + + + Error Recovery — classify and recover, then continue back to LLM + + + + + + Path 1 + + max_tokens + + + Trigger: stop_reason == "max_tokens" + Action: Escalate 8K → 64K (once) / continuation prompt (up to 3×) + Truncated output NOT appended on first escalation — same request retried with more tokens + + + + max_tokens = 64000 + continue # retry + + + + + + + + + + + Path 2 + + prompt_too_long + + + Trigger: API returns 413 / context exceeds limit + Action: Reactive compact → retry (one chance only) + If still over limit after compaction → exit (compacting again won't help) + + + + messages[:] = + reactive_compact(messages) + continue # retry once + + + + + + + + + + + Path 3 + + 429/529 + + + Trigger: RateLimitError / OverloadedError + Action: Exponential backoff + jitter (up to 10 retries) + 3× consecutive 529 → fallback model. min(500×2^n, 32s) + jitter + + + + delay = min(500*2**n, 32000) + delay += random(0, 25%) + time.sleep(delay / 1000) + continue # retry + + + + + + + + These are the 3 most common recovery patterns. The system has 13+ reason codes + (image_error, aborted_streaming, etc.), each with dedicated handling. All recovery paths → continue back to LLM loop. + + \ No newline at end of file diff --git a/s12_task_system/README.en.md b/s12_task_system/README.en.md deleted file mode 100644 index 756aedb42..000000000 --- a/s12_task_system/README.en.md +++ /dev/null @@ -1,282 +0,0 @@ -# s12: Task System — Break Big Goals into Small Tasks - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s20 - -> *"Break big goals into small tasks, order them, persist"* — File-persisted task graph, the foundation for multi-agent collaboration. -> -> **Harness Layer**: Tasks — Persisted goals, recoverable progress. - ---- - -## The Problem - -The agent receives a project: set up a database, write APIs, add tests. It uses s05's TodoWrite to create a checklist, then starts writing the API first, gets halfway through and realizes there are no database tables, goes back to fix them; when adding tests, discovers the API interface signatures have changed again... - -You can't build the roof before laying the foundation. Tasks have ordering. Task dependencies should form a Directed Acyclic Graph (DAG); the teaching version only demonstrates `blockedBy` checking, without cycle detection. - -s05's TodoWrite is an execution checklist for the current task, kept in session memory. What you need here is a **task system**: each task is a JSON file, tasks have `blockedBy` dependencies, and they persist across sessions on disk. - ---- - -## The Solution - -![Task System Overview](images/task-system-overview.en.svg) - -Teaching code keeps a basic agent loop, omitting S11's full error recovery (RecoveryState, backoff, escalation, reactive compact, fallback model) to stay focused on the task system. Added: 5 new task tools + `.tasks/` directory for persistence + `blockedBy` dependency checking. The task system and error recovery are independent layers: in CC source, `utils/tasks.ts` only handles CRUD, while `query.ts`'s with_retry/RecoveryState handles error recovery, with no coupling between them. - -TodoWrite vs Task System: - -| | TodoWrite (s05) | Task System (s12) | -|---|---|---| -| Role | Execution checklist for the current task | Recoverable task system | -| Storage | In-process / session state | `.tasks/{id}.json` | -| Dependencies | None | `blockedBy` / `blocks` graph | -| Lifecycle | Current session / current task | Cross-session | -| Coordination | No task claiming | `owner` / claim | -| Status | pending / in_progress / completed | pending / in_progress / completed | -| Granularity | The agent's own steps | Tasks that can be claimed, tracked, and unblocked | - ---- - -## How It Works - -![Task DAG](images/task-dag.en.svg) - -### Task: Data Structure - -Each task is a JSON file, stored in the `.tasks/` directory: - -```python -@dataclass -class Task: - id: str - subject: str - description: str - status: str # pending | in_progress | completed - owner: str | None # Agent name (multi-agent scenarios) - blockedBy: list[str] # List of dependency task IDs -``` - -IDs are generated with `timestamp + random hex`, simple but sufficient. CC uses sequential IDs + a highwatermark file to prevent ID reuse, which is a more rigorous design. - -### create_task: Create Tasks - -```python -def create_task(subject: str, description: str = "", - blockedBy: list[str] | None = None) -> Task: - task = Task( - id=f"task_{int(time.time())}_{random_hex(4)}", - subject=subject, description=description, - status="pending", owner=None, - blockedBy=blockedBy or [], - ) - save_task(task) - return task -``` - -Automatically calls `save_task` on creation to write `.tasks/{id}.json`. `blockedBy` declares dependencies, for example "write API" has `blockedBy: ["task_schema"]`. - -### can_start: Dependency Check - -A task can only start after all its `blockedBy` dependencies are **completed**: - -```python -def can_start(task_id: str) -> bool: - task = load_task(task_id) - for dep_id in task.blockedBy: - if not _task_path(dep_id).exists(): - return False # missing dependency = blocked - dep = load_task(dep_id) - if dep.status != "completed": - return False - return True -``` - -`can_start` is a prerequisite check for `claim_task`: if any `blockedBy` dependency is not completed, the task cannot be claimed. Missing dependencies are treated as blocked, avoiding crashes from referencing wrong IDs. - -### claim_task: Claim a Task - -When the agent starts working on a task, it calls `claim_task`: sets `owner`, changes status from `pending` → `in_progress`. The `owner` field records who is working on the task, preventing duplicate claims in multi-agent scenarios: - -```python -def claim_task(task_id: str, owner: str = "agent") -> str: - task = load_task(task_id) - if task.status != "pending": - return f"Task {task_id} is {task.status}, cannot claim" - if not can_start(task_id): - deps = [d for d in task.blockedBy - if load_task(d).status != "completed"] - return f"Blocked by: {deps}" - task.owner = owner - task.status = "in_progress" - save_task(task) - return f"Claimed {task_id} ({task.subject})" -``` - -If the task is already claimed by someone else (`status != "pending"`), or dependencies aren't met (`can_start` returns False), the claim is rejected. - -### complete_task: Complete and Unblock - -When a task is done, set it to `completed`. Simultaneously scan all other tasks to find downstream tasks that were **just unblocked**: - -```python -def complete_task(task_id: str) -> str: - task = load_task(task_id) - task.status = "completed" - save_task(task) - # Find newly unblocked downstream tasks - unblocked = [t.subject for t in list_tasks() - if t.status == "pending" and t.blockedBy - and can_start(t.id)] - msg = f"Completed {task_id} ({task.subject})" - if unblocked: - msg += f"\nUnblocked: {', '.join(unblocked)}" - return msg -``` - -After completing "schema", `can_start` returns True for "endpoints" and "docs"; they can begin. - -### get_task: View Full Details - -`list_tasks` only shows a one-line summary. `get_task` returns the full task JSON, including description and dependency details. When recovering across sessions, the agent needs to read the full description to continue work: - -```python -def get_task(task_id: str) -> str: - task = load_task(task_id) - return json.dumps(asdict(task), indent=2) -``` - -### State Machine: Two Actions, Three States - -``` -pending ──claim──→ in_progress ──complete──→ completed -``` - -Here `claim` / `complete` are actions, while `pending` / `in_progress` / `completed` are states: - -- **claim_task**: `pending` → `in_progress`. Sets owner, begins work. -- **complete_task**: `in_progress` → `completed`. Marks the task done and unblocks downstream. - -CC has no `in_progress → pending` release path. If a teammate terminates or shuts down, CC unassigns its unfinished tasks (clears owner) and resets status to `pending`, allowing other agents to reclaim them. The teaching version omits this recovery path. - -### Putting It Together - -```python -# Create tasks with dependencies -schema = create_task("setup database schema") -endpoints = create_task("create API endpoints", blockedBy=[schema.id]) -tests = create_task("write tests", blockedBy=[endpoints.id]) -docs = create_task("write docs", blockedBy=[schema.id]) - -# Agent claims the first available task -claim_task(schema.id) # ✓ Claimed (no dependencies) -complete_task(schema.id) # ✓ Completed → unblocks endpoints, docs - -claim_task(endpoints.id) # ✓ Claimed (schema completed) -complete_task(endpoints.id) # ✓ Completed → unblocks tests - -claim_task(docs.id) # ✓ Claimed (schema completed) -complete_task(docs.id) # ✓ Completed - -claim_task(tests.id) # ✓ Claimed (endpoints completed) -complete_task(tests.id) # ✓ Completed -``` - -Each `create_task` writes a JSON file, each `claim_task` / `complete_task` updates the file. Across sessions, the `.tasks/` directory persists — the agent reads the files to recover progress. - ---- - -## Changes from s11 - -| Component | Before (s11) | After (s12) | -|-----------|-------------|-------------| -| Task management | None | Task dataclass + 5 tools | -| New types | — | Task (id, subject, description, status, owner, blockedBy) | -| Storage | No persistence | `.tasks/{id}.json` cross-session | -| Dependencies | None | `blockedBy` graph + `can_start` check | -| Tools | bash, read_file, write_file (3) | + create_task, list_tasks, get_task, claim_task, complete_task (8) | -| Lifecycle | — | pending → in_progress → completed (no release rollback) | - ---- - -## Try It - -```sh -cd learn-claude-code -python s12_task_system/code.py -``` - -Try these prompts: - -1. `Create tasks: setup database schema, create API endpoints (depends on schema), write tests (depends on endpoints), write docs (depends on schema)` -2. `List all tasks and their statuses` -3. `Claim the first unblocked task and complete it` -4. `List tasks again — which ones are now unblocked?` - -What to observe: Are JSON files generated in the `.tasks/` directory? After completing a task, are the blocked tasks unblocked? - ---- - -## What's Next - -The task graph is in place. But some tasks take a long time — like running full test suites or deploying to a server. The agent calls the LLM billed by token, it can't afford to wait on a slow operation. - -s13 Background Tasks → Slow operations go to the background. The agent continues processing other tasks, and gets notified when the background work is done. - -
-Deep Dive into CC Source - -> The following is a complete analysis based on CC source code `utils/tasks.ts` (862 lines), `tools/TaskCreateTool/TaskCreateTool.ts` (138 lines), `tools/TaskUpdateTool/TaskUpdateTool.ts` (406 lines), `tools/TaskGetTool/TaskGetTool.ts` (128 lines), `tools/TaskListTool/TaskListTool.ts` (116 lines), `hooks/useTaskListWatcher.ts` (221 lines). - -### 1. TaskRecord's Full Fields - -The tutorial only covers id, subject, status, owner, blockedBy. CC actually has 9 fields (`utils/tasks.ts:76-89`): - -| Field | Type | Purpose | -|------|------|---------| -| `id` | string | Incrementing integer ID | -| `subject` | string | Short title | -| `description` | string | Free-form description | -| `activeForm` | string? | Present tense form, shown in spinner when in_progress | -| `owner` | string? | Assigned agent ID | -| `status` | pending/in_progress/completed | Lifecycle | -| `blocks` | string[] | Task IDs blocked by this task (downstream) | -| `blockedBy` | string[] | Task IDs blocking this task (upstream) | -| `metadata` | Record? | Arbitrary extension key-value pairs | - -Storage location: `~/.claude/tasks/{taskListId}/{id}.json`. One file per task. - -### 2. Not a TodoWrite Upgrade — Two Independent Systems - -In CC, Task System and TodoWrite **coexist**, toggled by `isTodoV2Enabled()` (`utils/tasks.ts:133`) — interactive sessions default to Task (V2), non-interactive/SDK sessions default to TodoWrite. The `CLAUDE_CODE_ENABLE_TASKS` env var can force-enable Task. Task has what TodoWrite lacks: file-lock concurrency protection, dependency enforcement, ownership, fs.watch reactive monitoring, lifecycle hooks. - -### 3. Concurrent Claim Locking - -`claimTask()` (`utils/tasks.ts:541-612`) uses dual locking to prevent races: - -**Task file lock**: `proper-lockfile` locks `{taskId}.json` (up to 30 retries, exponential backoff 5-100ms). Inside the lock: -1. Re-read task (prevent TOCTOU) -2. Check already claimed by another → `already_claimed` -3. Check already completed → `already_resolved` -4. Check upstream not completed → `blocked` -5. Set owner - -**List-level lock** (agent busy check): `.lock` file, atomic scan of all tasks to check if the agent already has other open tasks. - -Note: The teaching version combines claiming and starting work into one step (claim = set owner + in_progress); real CC's `claimTask` primarily resolves owner competition — it only sets owner without changing status. Status updates are handled by `TaskUpdate`. - -### 4. High-Water Mark to Prevent ID Reuse - -The `.highwatermark` file records the highest task ID ever assigned. Even if a task is deleted, its ID won't be reused. - -### 5. Four Task Tools - -CC's task system has four tools (not the tutorial's single generic Task tool): `TaskCreate`, `TaskGet`, `TaskUpdate`, `TaskList`. All set `isConcurrencySafe: true` and `shouldDefer: true` (tool schemas aren't in the initial prompt; only visible after ToolSearch). - -The teaching version's `create_task(blockedBy=...)` declares dependencies at creation time, which is a reasonable simplification. Real CC's `TaskCreate` only accepts subject/description/activeForm/metadata — dependencies are maintained via `TaskUpdate`'s `addBlocks/addBlockedBy`. - -
- - diff --git a/s12_task_system/README.ja.md b/s12_task_system/README.ja.md index ebc8c7e06..18f66d4e3 100644 --- a/s12_task_system/README.ja.md +++ b/s12_task_system/README.ja.md @@ -1,6 +1,6 @@ # s12: Task System — 大きな目標を小さなタスクに分割 -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s20 @@ -22,9 +22,9 @@ s05 の TodoWrite は現在のタスクの実行チェックリストで、セ ## ソリューション -![Task System Overview](images/task-system-overview.ja.svg) +![Task System Overview](images/task-system-overview.svg) -教学版は基本 agent loop を維持し、タスクシステムに集中するため S11 の完全なエラーリカバリ(RecoveryState、バックオフ、エスカレーション、reactive compact、フォールバックモデル)を省略。追加:5 つの新規タスクツール + `.tasks/` ディレクトリによる永続化 + `blockedBy` 依存チェック。タスクシステムとエラーリカバリは独立したレイヤー:CC ソースコードでは `utils/tasks.ts` は CRUD のみ、`query.ts` の with_retry/RecoveryState がエラーリカバリを担当し、互いに非結合。 +教学版は基本 agent loop を維持し、タスクシステムに集中するため S11 の完全なエラーリカバリ(RecoveryState、バックオフ、エスカレーション、reactive compact、フォールバックモデル)を省略。追加:5 つの新規タスクツール + `.tasks/` ディレクトリによる永続化 + `blockedBy` 依存チェック。タスクシステムとエラーリカバリは独立したレイヤー:Claude Code ソースコードでは `utils/tasks.ts` は CRUD のみ、`query.ts` の with_retry/RecoveryState がエラーリカバリを担当し、互いに非結合。 TodoWrite vs Task System: @@ -42,7 +42,7 @@ TodoWrite vs Task System: ## 仕組み -![Task DAG](images/task-dag.ja.svg) +![Task DAG](images/task-dag.svg) ### Task: データ構造 @@ -59,7 +59,7 @@ class Task: blockedBy: list[str] # 依存タスク ID のリスト ``` -ID は `timestamp + random hex` で生成、シンプルだが十分。CC は順次 ID + highwatermark ファイルで ID 再利用を防止する、より厳密な設計。 +ID は `timestamp + random hex` で生成、シンプルだが十分。Claude Code は順次 ID + highwatermark ファイルで ID 再利用を防止する、より厳密な設計。 ### create_task: タスク作成 @@ -159,7 +159,7 @@ pending ──claim──→ in_progress ──complete──→ completed - **claim_task**: `pending` → `in_progress`。owner を設定し、作業を開始。 - **complete_task**: `in_progress` → `completed`。タスクを完了済みにし、下流をアンロック。 -CC には `in_progress → pending` の release パスがない。teammate が終了または shutdown した場合、CC は未完了タスクの owner をクリアし、status を `pending` にリセットし、他の agent が再認識できるようにする。教学版はこの復旧パスを省略。 +Claude Code には `in_progress → pending` の release パスがない。teammate が終了または shutdown した場合、Claude Code は未完了タスクの owner をクリアし、status を `pending` にリセットし、他の agent が再認識できるようにする。教学版はこの復旧パスを省略。 ### 組み合わせて実行 @@ -226,13 +226,13 @@ python s12_task_system/code.py s13 Background Tasks → 遅い操作はバックグラウンドへ。Agent は他のタスクの処理を続け、バックグラウンドの完了を通知で受け取る。
-CC ソースコード深掘り +Claude Code ソースコード深掘り -> 以下は CC ソースコード `utils/tasks.ts`(862 行)、`tools/TaskCreateTool/TaskCreateTool.ts`(138 行)、`tools/TaskUpdateTool/TaskUpdateTool.ts`(406 行)、`tools/TaskGetTool/TaskGetTool.ts`(128 行)、`tools/TaskListTool/TaskListTool.ts`(116 行)、`hooks/useTaskListWatcher.ts`(221 行)の完全分析に基づく。 +> 以下は Claude Code ソースコード `utils/tasks.ts`(862 行)、`tools/TaskCreateTool/TaskCreateTool.ts`(138 行)、`tools/TaskUpdateTool/TaskUpdateTool.ts`(406 行)、`tools/TaskGetTool/TaskGetTool.ts`(128 行)、`tools/TaskListTool/TaskListTool.ts`(116 行)、`hooks/useTaskListWatcher.ts`(221 行)の完全分析に基づく。 ### 一、TaskRecord の完全フィールド -チュートリアルでは id、subject、status、owner、blockedBy のみ解説。CC は実際に 9 フィールドを持つ(`utils/tasks.ts:76-89`): +チュートリアルでは id、subject、status、owner、blockedBy のみ解説。Claude Code は実際に 9 フィールドを持つ(`utils/tasks.ts:76-89`): | フィールド | 型 | 用途 | |------|------|------| @@ -250,7 +250,7 @@ s13 Background Tasks → 遅い操作はバックグラウンドへ。Agent は ### 二、TodoWrite のアップグレードではなく、2 つの独立システム -CC では Task System と TodoWrite **は共存**し、`isTodoV2Enabled()` で切り替え(`utils/tasks.ts:133`)— 対話セッションはデフォルトで Task (V2)、非対話/SDK セッションは TodoWrite。環境変数 `CLAUDE_CODE_ENABLE_TASKS` で Task を強制有効化可能。Task は TodoWrite にない機能を持つ:ファイルロック並行保護、依存関係強制、ownership、fs.watch リアクティブ監視、ライフサイクルフック。 +Claude Code では Task System と TodoWrite **は共存**し、`isTodoV2Enabled()` で切り替え(`utils/tasks.ts:133`)— 対話セッションはデフォルトで Task (V2)、非対話/SDK セッションは TodoWrite。環境変数 `CLAUDE_CODE_ENABLE_TASKS` で Task を強制有効化可能。Task は TodoWrite にない機能を持つ:ファイルロック並行保護、依存関係強制、ownership、fs.watch リアクティブ監視、ライフサイクルフック。 ### 三、並行認識のロック機構 @@ -265,7 +265,7 @@ CC では Task System と TodoWrite **は共存**し、`isTodoV2Enabled()` で **リストレベルロック**(agent busy チェック時):`.lock` ファイル、全タスクを原子的に走査し該当 agent が他の open task を持つか確認。 -注意:教学版は認識と作業開始を 1 ステップに統合(claim = owner 設定 + in_progress);実際の CC の `claimTask` は主に owner 競合を解決し、owner のみを設定して status は変更しない。status の更新は `TaskUpdate` が担当。 +注意:教学版は認識と作業開始を 1 ステップに統合(claim = owner 設定 + in_progress);実際の Claude Code の `claimTask` は主に owner 競合を解決し、owner のみを設定して status は変更しない。status の更新は `TaskUpdate` が担当。 ### 四、高水位標による ID 再利用防止 @@ -273,9 +273,9 @@ CC では Task System と TodoWrite **は共存**し、`isTodoV2Enabled()` で ### 五、4 つの Task ツール -CC のタスクシステムは 4 つのツールを持つ(チュートリアルの汎用 Task ツールとは異なる):`TaskCreate`、`TaskGet`、`TaskUpdate`、`TaskList`。すべて `isConcurrencySafe: true` と `shouldDefer: true` が設定(ツールスキーマは初期プロンプトに含まれず、ToolSearch 後にのみ可視)。 +Claude Code のタスクシステムは 4 つのツールを持つ(チュートリアルの汎用 Task ツールとは異なる):`TaskCreate`、`TaskGet`、`TaskUpdate`、`TaskList`。すべて `isConcurrencySafe: true` と `shouldDefer: true` が設定(ツールスキーマは初期プロンプトに含まれず、ToolSearch 後にのみ可視)。 -教学版の `create_task(blockedBy=...)` は作成時に直接依存を宣言する合理な簡略化。実際の CC の `TaskCreate` は subject/description/activeForm/metadata のみを受け付け、依存関係は `TaskUpdate` の `addBlocks/addBlockedBy` で管理される。 +教学版の `create_task(blockedBy=...)` は作成時に直接依存を宣言する合理な簡略化。実際の Claude Code の `TaskCreate` は subject/description/activeForm/metadata のみを受け付け、依存関係は `TaskUpdate` の `addBlocks/addBlockedBy` で管理される。
diff --git a/s12_task_system/README.md b/s12_task_system/README.md index 03a925281..988046802 100644 --- a/s12_task_system/README.md +++ b/s12_task_system/README.md @@ -1,52 +1,52 @@ -# s12: Task System — 目标太大,拆成小任务 +# s12: Task System — Break Big Goals into Small Tasks -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s20 -> *"大目标拆成小任务, 排好序, 持久化"* — 文件持久化的任务图, 多 agent 协作的基础。 +> *"Break big goals into small tasks, order them, persist"* — File-persisted task graph, the basis for multi-agent collaboration. > -> **Harness 层**: 任务 — 持久化的目标, 可恢复的进度。 +> **Harness Layer**: Tasks — Persisted goals, recoverable progress. --- -## 问题 +## The Problem -Agent 接到一个项目:搭数据库、写 API、加测试。它用 s05 的 TodoWrite 列了一张清单,然后开始写 API,写到一半发现没数据库表,回头补;加测试时发现 API 接口签名又变了... +The agent receives a project: set up a database, write APIs, add tests. It uses s05's TodoWrite to create a checklist, then starts writing the API first, gets halfway through and realizes there are no database tables, goes back to fix them; when adding tests, discovers the API interface signatures have changed again... -盖房子不能先盖屋顶再打地基。任务之间有先后。任务依赖应该形成有向无环图(DAG);教学版只演示 `blockedBy` 检查,没有实现环检测。 +You can't build the roof before laying the foundation. Tasks have ordering. Task dependencies should form a Directed Acyclic Graph (DAG); the teaching version only demonstrates `blockedBy` checking, without cycle detection. -s05 的 TodoWrite 是当前任务的执行清单,保存在会话内存中。这里需要的是**任务系统**:每个任务是一个 JSON 文件,任务之间有 `blockedBy` 依赖,跨会话持久化在磁盘上。 +s05's TodoWrite is an execution checklist for the current task, kept in session memory. What you need here is a **task system**: each task is a JSON file, tasks have `blockedBy` dependencies, and they persist across sessions on disk. --- -## 解决方案 +## The Solution ![Task System Overview](images/task-system-overview.svg) -教学代码保留基础 agent loop,为聚焦任务系统省略了 S11 的完整错误恢复(RecoveryState、退避、升级、reactive compact、fallback model)。新增 5 个任务工具 + `.tasks/` 目录持久化 + `blockedBy` 依赖检查。任务系统与错误恢复是独立层:CC 源码中 `utils/tasks.ts` 只管 CRUD,`query.ts` 的 with_retry/RecoveryState 管错误恢复,互不耦合。 +Teaching code keeps a basic agent loop, omitting S11's full error recovery (RecoveryState, backoff, escalation, reactive compact, fallback model) to stay focused on the task system. Added: 5 new task tools + `.tasks/` directory for persistence + `blockedBy` dependency checking. The task system and error recovery are independent layers: in Claude Code source, `utils/tasks.ts` only handles CRUD, while `query.ts`'s with_retry/RecoveryState handles error recovery, with no coupling between them. -TodoWrite vs Task System: +TodoWrite vs Task System: | | TodoWrite (s05) | Task System (s12) | |---|---|---| -| 定位 | 当前任务的执行清单 | 可恢复的任务系统 | -| 存储 | 进程内 / 会话状态 | `.tasks/{id}.json` | -| 依赖 | 无 | `blockedBy` / `blocks` 依赖图 | -| 生命周期 | 当前会话 / 当前任务 | 跨会话保留 | -| 分工 | 不负责任务认领 | `owner` / claim | -| 状态 | pending / in_progress / completed | pending / in_progress / completed | -| 粒度 | Agent 自己的步骤 | 可被认领、追踪、解锁的任务 | +| Role | Execution checklist for the current task | Recoverable task system | +| Storage | In-process / session state | `.tasks/{id}.json` | +| Dependencies | None | `blockedBy` / `blocks` graph | +| Lifecycle | Current session / current task | Cross-session | +| Coordination | No task claiming | `owner` / claim | +| Status | pending / in_progress / completed | pending / in_progress / completed | +| Granularity | The agent's own steps | Tasks that can be claimed, tracked, and unblocked | --- -## 工作原理 +## How It Works ![Task DAG](images/task-dag.svg) -### Task: 数据结构 +### Task: Data Structure -每个任务是一个 JSON 文件,存于 `.tasks/` 目录: +Each task is a JSON file, stored in the `.tasks/` directory: ```python @dataclass @@ -55,13 +55,13 @@ class Task: subject: str description: str status: str # pending | in_progress | completed - owner: str | None # Agent 名(多 Agent 场景) - blockedBy: list[str] # 依赖的任务 ID 列表 + owner: str | None # Agent name (multi-agent scenarios) + blockedBy: list[str] # List of dependency task IDs ``` -ID 用 `timestamp + random hex` 生成,简单但够用。CC 用顺序 ID + highwatermark 文件防止 ID 重用,是更严谨的设计。 +IDs are generated with `timestamp + random hex`, simple but sufficient. Claude Code uses sequential IDs + a highwatermark file to prevent ID reuse, which is a more rigorous design. -### create_task: 创建任务 +### create_task: Create Tasks ```python def create_task(subject: str, description: str = "", @@ -76,11 +76,11 @@ def create_task(subject: str, description: str = "", return task ``` -创建时自动 `save_task` 到 `.tasks/{id}.json`。`blockedBy` 声明依赖,比如 "写 API" 的 `blockedBy` 是 `["task_schema"]`。 +Automatically calls `save_task` on creation to write `.tasks/{id}.json`. `blockedBy` declares dependencies, for example "write API" has `blockedBy: ["task_schema"]`. -### can_start: 依赖检查 +### can_start: Dependency Check -一个任务只能在它的 `blockedBy` **全部 completed** 之后才能开始: +A task can only start after all its `blockedBy` dependencies are **completed**: ```python def can_start(task_id: str) -> bool: @@ -94,11 +94,11 @@ def can_start(task_id: str) -> bool: return True ``` -`can_start` 是 `claim_task` 的前置检查:`blockedBy` 里有任何一个不是 completed,就不能认领。不存在的依赖视为 blocked,避免引用错误 ID 时崩溃。 +`can_start` is a prerequisite check for `claim_task`: if any `blockedBy` dependency is not completed, the task cannot be claimed. Missing dependencies are treated as blocked, avoiding crashes from referencing wrong IDs. -### claim_task: 认领任务 +### claim_task: Claim a Task -Agent 开始做一个任务时,调用 `claim_task`:设置 `owner`,状态从 `pending` → `in_progress`。`owner` 字段记录谁在做这个任务,多 Agent 场景下防止重复认领: +When the agent starts working on a task, it calls `claim_task`: sets `owner`, changes status from `pending` → `in_progress`. The `owner` field records who is working on the task, preventing duplicate claims in multi-agent scenarios: ```python def claim_task(task_id: str, owner: str = "agent") -> str: @@ -115,18 +115,18 @@ def claim_task(task_id: str, owner: str = "agent") -> str: return f"Claimed {task_id} ({task.subject})" ``` -如果任务已被别人认领(`status != "pending"`),或者依赖没完成(`can_start` 返回 False),拒绝认领。 +If the task is already claimed by someone else (`status != "pending"`), or dependencies aren't met (`can_start` returns False), the claim is rejected. -### complete_task: 完成与解锁 +### complete_task: Complete and Unblock -任务做完后,设为 `completed`。同时扫描所有其他任务,找出**刚刚被解锁**的下游任务: +When a task is done, set it to `completed`. Simultaneously scan all other tasks to find downstream tasks that were **just unblocked**: ```python def complete_task(task_id: str) -> str: task = load_task(task_id) task.status = "completed" save_task(task) - # 找出被解锁的下游任务 + # Find newly unblocked downstream tasks unblocked = [t.subject for t in list_tasks() if t.status == "pending" and t.blockedBy and can_start(t.id)] @@ -136,11 +136,11 @@ def complete_task(task_id: str) -> str: return msg ``` -完成 "schema" 后,"endpoints" 和 "docs" 的 `can_start` 返回 True,它们可以开始。 +After completing "schema", `can_start` returns True for "endpoints" and "docs"; they can begin. -### get_task: 查看完整细节 +### get_task: View Full Details -`list_tasks` 只显示一行摘要。`get_task` 返回完整的任务 JSON,包括 description 和依赖细节。跨会话恢复时,Agent 需要读取完整描述才能继续工作: +`list_tasks` only shows a one-line summary. `get_task` returns the full task JSON, including description and dependency details. When recovering across sessions, the agent needs to read the full description to continue work: ```python def get_task(task_id: str) -> str: @@ -148,134 +148,134 @@ def get_task(task_id: str) -> str: return json.dumps(asdict(task), indent=2) ``` -### 状态机: 两个动作,三个状态 +### State Machine: Two Actions, Three States ``` pending ──claim──→ in_progress ──complete──→ completed ``` -这里的 `claim` / `complete` 是动作,`pending` / `in_progress` / `completed` 是状态: +Here `claim` / `complete` are actions, while `pending` / `in_progress` / `completed` are states: -- **claim_task**: `pending` → `in_progress`。设置 owner,开始工作。 -- **complete_task**: `in_progress` → `completed`。把任务标记为完成,并解锁下游。 +- **claim_task**: `pending` → `in_progress`. Sets owner, begins work. +- **complete_task**: `in_progress` → `completed`. Marks the task done and unblocks downstream. -CC 没有 `in_progress → pending` 的 release 路径。如果 teammate 终止或 shutdown,CC 会把它未完成的任务 unassign(清除 owner),并将 status 重置为 `pending`,方便其他 agent 重新认领。教学版省略了这一恢复路径。 +Claude Code has no `in_progress → pending` release path. If a teammate terminates or shuts down, Claude Code unassigns its unfinished tasks (clears owner) and resets status to `pending`, allowing other agents to reclaim them. The teaching version omits this recovery path. -### 合起来跑 +### Putting It Together ```python -# 创建有依赖的任务 +# Create tasks with dependencies schema = create_task("setup database schema") endpoints = create_task("create API endpoints", blockedBy=[schema.id]) tests = create_task("write tests", blockedBy=[endpoints.id]) docs = create_task("write docs", blockedBy=[schema.id]) -# Agent 认领第一个可做的任务 -claim_task(schema.id) # ✓ Claimed (无依赖) -complete_task(schema.id) # ✓ Completed → 解锁 endpoints, docs +# Agent claims the first available task +claim_task(schema.id) # ✓ Claimed (no dependencies) +complete_task(schema.id) # ✓ Completed → unblocks endpoints, docs -claim_task(endpoints.id) # ✓ Claimed (schema 已完成) -complete_task(endpoints.id) # ✓ Completed → 解锁 tests +claim_task(endpoints.id) # ✓ Claimed (schema completed) +complete_task(endpoints.id) # ✓ Completed → unblocks tests -claim_task(docs.id) # ✓ Claimed (schema 已完成) +claim_task(docs.id) # ✓ Claimed (schema completed) complete_task(docs.id) # ✓ Completed -claim_task(tests.id) # ✓ Claimed (endpoints 已完成) +claim_task(tests.id) # ✓ Claimed (endpoints completed) complete_task(tests.id) # ✓ Completed ``` -每个 `create_task` 写一个 JSON 文件,每个 `claim_task` / `complete_task` 更新文件。跨会话时,`.tasks/` 目录还在,Agent 读文件就能恢复进度。 +Each `create_task` writes a JSON file, each `claim_task` / `complete_task` updates the file. Across sessions, the `.tasks/` directory persists; the agent reads the files to recover progress. --- -## 相对 s11 的变更 +## Changes from s11 -| 组件 | 之前 (s11) | 之后 (s12) | -|------|-----------|-----------| -| 任务管理 | 无 | Task dataclass + 5 个工具 | -| 新类型 | — | Task(id, subject, description, status, owner, blockedBy) | -| 存储 | 无持久化 | `.tasks/{id}.json` 跨会话 | -| 依赖 | 无 | `blockedBy` 图 + `can_start` 检查 | -| 工具 | bash, read_file, write_file (3) | + create_task, list_tasks, get_task, claim_task, complete_task (8) | -| 生命周期 | — | pending → in_progress → completed(无 release 回退) | +| Component | Before (s11) | After (s12) | +|-----------|-------------|-------------| +| Task management | None | Task dataclass + 5 tools | +| New types | — | Task (id, subject, description, status, owner, blockedBy) | +| Storage | No persistence | `.tasks/{id}.json` cross-session | +| Dependencies | None | `blockedBy` graph + `can_start` check | +| Tools | bash, read_file, write_file (3) | + create_task, list_tasks, get_task, claim_task, complete_task (8) | +| Lifecycle | — | pending → in_progress → completed (no release rollback) | --- -## 试一下 +## Try It ```sh cd learn-claude-code python s12_task_system/code.py ``` -试试这些 prompt: +Try these prompts: 1. `Create tasks: setup database schema, create API endpoints (depends on schema), write tests (depends on endpoints), write docs (depends on schema)` 2. `List all tasks and their statuses` 3. `Claim the first unblocked task and complete it` 4. `List tasks again — which ones are now unblocked?` -观察重点:`.tasks/` 目录下是否生成了 JSON 文件?完成任务后,被阻塞的任务是否解锁? +What to observe: Are JSON files generated in the `.tasks/` directory? After completing a task, are the blocked tasks unblocked? --- -## 接下来 +## What's Next -任务图有了。但有些任务要跑很久——比如全量测试、部署到服务器。Agent 调 LLM 按量计费,不能干等一个慢操作。 +The task graph is in place. But some tasks take a long time — like running full test suites or deploying to a server. The agent calls the LLM billed by token, it can't afford to wait on a slow operation. -s13 Background Tasks → 慢操作放后台。Agent 继续处理其他任务,后台跑完了通知它。 +s13 Background Tasks → Slow operations go to the background. The agent continues processing other tasks, and gets notified when the background work is done.
-深入 CC 源码 +Deep Dive into Claude Code Source -> 以下基于 CC 源码 `utils/tasks.ts`(862 行)、`tools/TaskCreateTool/TaskCreateTool.ts`(138 行)、`tools/TaskUpdateTool/TaskUpdateTool.ts`(406 行)、`tools/TaskGetTool/TaskGetTool.ts`(128 行)、`tools/TaskListTool/TaskListTool.ts`(116 行)、`hooks/useTaskListWatcher.ts`(221 行)的分析。 +> The following is a complete analysis based on Claude Code source code `utils/tasks.ts` (862 lines), `tools/TaskCreateTool/TaskCreateTool.ts` (138 lines), `tools/TaskUpdateTool/TaskUpdateTool.ts` (406 lines), `tools/TaskGetTool/TaskGetTool.ts` (128 lines), `tools/TaskListTool/TaskListTool.ts` (116 lines), `hooks/useTaskListWatcher.ts` (221 lines). -### 一、TaskRecord 的完整字段 +### 1. TaskRecord's Full Fields -教学版只讲了 id、subject、status、owner、blockedBy。CC 实际有 9 个字段(`utils/tasks.ts:76-89`): +The tutorial only covers id, subject, status, owner, blockedBy. Claude Code actually has 9 fields (`utils/tasks.ts:76-89`): -| 字段 | 类型 | 用途 | -|------|------|------| -| `id` | string | 递增整数 ID | -| `subject` | string | 简短标题 | -| `description` | string | 自由格式描述 | -| `activeForm` | string? | 进行时态,in_progress 时在 spinner 显示 | -| `owner` | string? | 分配的 agent ID | -| `status` | pending/in_progress/completed | 生命周期 | -| `blocks` | string[] | 此任务阻塞的任务 ID(下游) | -| `blockedBy` | string[] | 阻塞此任务的任务 ID(上游) | -| `metadata` | Record? | 任意扩展键值对 | +| Field | Type | Purpose | +|------|------|---------| +| `id` | string | Incrementing integer ID | +| `subject` | string | Short title | +| `description` | string | Free-form description | +| `activeForm` | string? | Present tense form, shown in spinner when in_progress | +| `owner` | string? | Assigned agent ID | +| `status` | pending/in_progress/completed | Lifecycle | +| `blocks` | string[] | Task IDs blocked by this task (downstream) | +| `blockedBy` | string[] | Task IDs blocking this task (upstream) | +| `metadata` | Record? | Arbitrary extension key-value pairs | -存储位置:`~/.claude/tasks/{taskListId}/{id}.json`。每个任务一个文件。 +Storage location: `~/.claude/tasks/{taskListId}/{id}.json`. One file per task. -### 二、不是 TodoWrite 的升级,是两个独立系统 +### 2. Not a TodoWrite Upgrade — Two Independent Systems -CC 中 Task System 和 TodoWrite **同时存在**,通过 `isTodoV2Enabled()` 切换(`utils/tasks.ts:133`)——交互式会话默认启用 Task(V2),非交互式/SDK 默认用 TodoWrite。环境变量 `CLAUDE_CODE_ENABLE_TASKS` 可强制启用 Task。Task 有 TodoWrite 没有的:文件锁并发保护、依赖强制执行、ownership、fs.watch 响应式监听、生命周期 hooks。 +In Claude Code, Task System and TodoWrite **coexist**, toggled by `isTodoV2Enabled()` (`utils/tasks.ts:133`): interactive sessions default to Task (V2), non-interactive/SDK sessions default to TodoWrite. The `CLAUDE_CODE_ENABLE_TASKS` env var can force-enable Task. Task has what TodoWrite lacks: file-lock concurrency protection, dependency enforcement, ownership, fs.watch reactive monitoring, lifecycle hooks. -### 三、并发认领的锁机制 +### 3. Concurrent Claim Locking -`claimTask()`(`utils/tasks.ts:541-612`)用双重锁防竞争: +`claimTask()` (`utils/tasks.ts:541-612`) uses dual locking to prevent races: -**任务文件锁**:`proper-lockfile` 锁住 `{taskId}.json`(最多重试 30 次,指数退避 5-100ms)。锁内: -1. 重新读取任务(防 TOCTOU) -2. 检查已被他人认领 → `already_claimed` -3. 检查已完成 → `already_resolved` -4. 检查上游未完成 → `blocked` -5. 设置 owner +**Task file lock**: `proper-lockfile` locks `{taskId}.json` (up to 30 retries, exponential backoff 5-100ms). Inside the lock: +1. Re-read task (prevent TOCTOU) +2. Check already claimed by another → `already_claimed` +3. Check already completed → `already_resolved` +4. Check upstream not completed → `blocked` +5. Set owner -**列表级锁**(agent busy 检查时):`.lock` 文件,原子性扫描所有任务并检查该 agent 是否已有其他 open task。 +**List-level lock** (agent busy check): `.lock` file, atomic scan of all tasks to check if the agent already has other open tasks. -注意:教学版把 claim 和开始工作合成一步(claim = set owner + in_progress);真实 CC 的 `claimTask` 主要解决 owner 竞争,只设 owner 不改 status,状态更新由 `TaskUpdate` 完成。 +Note: The teaching version combines claiming and starting work into one step (claim = set owner + in_progress); real Claude Code's `claimTask` primarily resolves owner competition: it only sets owner without changing status. Status updates are handled by `TaskUpdate`. -### 四、高水位标防 ID 重用 +### 4. High-Water Mark to Prevent ID Reuse -`.highwatermark` 文件记录曾分配过的最高任务 ID。即使任务被删除,ID 也不会被重用。 +The `.highwatermark` file records the highest task ID ever assigned. Even if a task is deleted, its ID won't be reused. -### 五、四个 Task 工具 +### 5. Four Task Tools -CC 的任务系统有四个工具(不是教学版的一个通用 Task 工具):`TaskCreate`、`TaskGet`、`TaskUpdate`、`TaskList`。全部设置 `isConcurrencySafe: true` 和 `shouldDefer: true`(工具 schema 不在初始 prompt 中,需 ToolSearch 后才可见)。 +Claude Code's task system has four tools (not the tutorial's single generic Task tool): `TaskCreate`, `TaskGet`, `TaskUpdate`, `TaskList`. All set `isConcurrencySafe: true` and `shouldDefer: true` (tool schemas aren't in the initial prompt; only visible after ToolSearch). -教学版的 `create_task(blockedBy=...)` 在创建时直接声明依赖,是合理简化。真实 CC 的 `TaskCreate` 只接受 subject/description/activeForm/metadata,依赖关系由 `TaskUpdate` 的 `addBlocks/addBlockedBy` 维护。 +The teaching version's `create_task(blockedBy=...)` declares dependencies at creation time, which is a reasonable simplification. Real Claude Code's `TaskCreate` only accepts subject/description/activeForm/metadata; dependencies are maintained via `TaskUpdate`'s `addBlocks/addBlockedBy`.
diff --git a/s12_task_system/README.zh.md b/s12_task_system/README.zh.md new file mode 100644 index 000000000..1a6a74c2c --- /dev/null +++ b/s12_task_system/README.zh.md @@ -0,0 +1,282 @@ +# s12: Task System — 目标太大,拆成小任务 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s10 → s11 → `s12` → [s13](../s13_background_tasks/) → s14 → ... → s20 + +> *"大目标拆成小任务, 排好序, 持久化"* — 文件持久化的任务图, 多 agent 协作的基础。 +> +> **Harness 层**: 任务 — 持久化的目标, 可恢复的进度。 + +--- + +## 问题 + +Agent 接到一个项目:搭数据库、写 API、加测试。它用 s05 的 TodoWrite 列了一张清单,然后开始写 API,写到一半发现没数据库表,回头补;加测试时发现 API 接口签名又变了... + +盖房子不能先盖屋顶再打地基。任务之间有先后。任务依赖应该形成有向无环图(DAG);教学版只演示 `blockedBy` 检查,没有实现环检测。 + +s05 的 TodoWrite 是当前任务的执行清单,保存在会话内存中。这里需要的是**任务系统**:每个任务是一个 JSON 文件,任务之间有 `blockedBy` 依赖,跨会话持久化在磁盘上。 + +--- + +## 解决方案 + +![Task System Overview](images/task-system-overview.svg) + +教学代码保留基础 agent loop,为聚焦任务系统省略了 S11 的完整错误恢复(RecoveryState、退避、升级、reactive compact、fallback model)。新增 5 个任务工具 + `.tasks/` 目录持久化 + `blockedBy` 依赖检查。任务系统与错误恢复是独立层:Claude Code 源码中 `utils/tasks.ts` 只管 CRUD,`query.ts` 的 with_retry/RecoveryState 管错误恢复,互不耦合。 + +TodoWrite vs Task System: + +| | TodoWrite (s05) | Task System (s12) | +|---|---|---| +| 定位 | 当前任务的执行清单 | 可恢复的任务系统 | +| 存储 | 进程内 / 会话状态 | `.tasks/{id}.json` | +| 依赖 | 无 | `blockedBy` / `blocks` 依赖图 | +| 生命周期 | 当前会话 / 当前任务 | 跨会话保留 | +| 分工 | 不负责任务认领 | `owner` / claim | +| 状态 | pending / in_progress / completed | pending / in_progress / completed | +| 粒度 | Agent 自己的步骤 | 可被认领、追踪、解锁的任务 | + +--- + +## 工作原理 + +![Task DAG](images/task-dag.svg) + +### Task: 数据结构 + +每个任务是一个 JSON 文件,存于 `.tasks/` 目录: + +```python +@dataclass +class Task: + id: str + subject: str + description: str + status: str # pending | in_progress | completed + owner: str | None # Agent 名(多 Agent 场景) + blockedBy: list[str] # 依赖的任务 ID 列表 +``` + +ID 用 `timestamp + random hex` 生成,简单但够用。Claude Code 用顺序 ID + highwatermark 文件防止 ID 重用,是更严谨的设计。 + +### create_task: 创建任务 + +```python +def create_task(subject: str, description: str = "", + blockedBy: list[str] | None = None) -> Task: + task = Task( + id=f"task_{int(time.time())}_{random_hex(4)}", + subject=subject, description=description, + status="pending", owner=None, + blockedBy=blockedBy or [], + ) + save_task(task) + return task +``` + +创建时自动 `save_task` 到 `.tasks/{id}.json`。`blockedBy` 声明依赖,比如 "写 API" 的 `blockedBy` 是 `["task_schema"]`。 + +### can_start: 依赖检查 + +一个任务只能在它的 `blockedBy` **全部 completed** 之后才能开始: + +```python +def can_start(task_id: str) -> bool: + task = load_task(task_id) + for dep_id in task.blockedBy: + if not _task_path(dep_id).exists(): + return False # missing dependency = blocked + dep = load_task(dep_id) + if dep.status != "completed": + return False + return True +``` + +`can_start` 是 `claim_task` 的前置检查:`blockedBy` 里有任何一个不是 completed,就不能认领。不存在的依赖视为 blocked,避免引用错误 ID 时崩溃。 + +### claim_task: 认领任务 + +Agent 开始做一个任务时,调用 `claim_task`:设置 `owner`,状态从 `pending` → `in_progress`。`owner` 字段记录谁在做这个任务,多 Agent 场景下防止重复认领: + +```python +def claim_task(task_id: str, owner: str = "agent") -> str: + task = load_task(task_id) + if task.status != "pending": + return f"Task {task_id} is {task.status}, cannot claim" + if not can_start(task_id): + deps = [d for d in task.blockedBy + if load_task(d).status != "completed"] + return f"Blocked by: {deps}" + task.owner = owner + task.status = "in_progress" + save_task(task) + return f"Claimed {task_id} ({task.subject})" +``` + +如果任务已被别人认领(`status != "pending"`),或者依赖没完成(`can_start` 返回 False),拒绝认领。 + +### complete_task: 完成与解锁 + +任务做完后,设为 `completed`。同时扫描所有其他任务,找出**刚刚被解锁**的下游任务: + +```python +def complete_task(task_id: str) -> str: + task = load_task(task_id) + task.status = "completed" + save_task(task) + # 找出被解锁的下游任务 + unblocked = [t.subject for t in list_tasks() + if t.status == "pending" and t.blockedBy + and can_start(t.id)] + msg = f"Completed {task_id} ({task.subject})" + if unblocked: + msg += f"\nUnblocked: {', '.join(unblocked)}" + return msg +``` + +完成 "schema" 后,"endpoints" 和 "docs" 的 `can_start` 返回 True,它们可以开始。 + +### get_task: 查看完整细节 + +`list_tasks` 只显示一行摘要。`get_task` 返回完整的任务 JSON,包括 description 和依赖细节。跨会话恢复时,Agent 需要读取完整描述才能继续工作: + +```python +def get_task(task_id: str) -> str: + task = load_task(task_id) + return json.dumps(asdict(task), indent=2) +``` + +### 状态机: 两个动作,三个状态 + +``` +pending ──claim──→ in_progress ──complete──→ completed +``` + +这里的 `claim` / `complete` 是动作,`pending` / `in_progress` / `completed` 是状态: + +- **claim_task**: `pending` → `in_progress`。设置 owner,开始工作。 +- **complete_task**: `in_progress` → `completed`。把任务标记为完成,并解锁下游。 + +Claude Code 没有 `in_progress → pending` 的 release 路径。如果 teammate 终止或 shutdown,Claude Code 会把它未完成的任务 unassign(清除 owner),并将 status 重置为 `pending`,方便其他 agent 重新认领。教学版省略了这一恢复路径。 + +### 合起来跑 + +```python +# 创建有依赖的任务 +schema = create_task("setup database schema") +endpoints = create_task("create API endpoints", blockedBy=[schema.id]) +tests = create_task("write tests", blockedBy=[endpoints.id]) +docs = create_task("write docs", blockedBy=[schema.id]) + +# Agent 认领第一个可做的任务 +claim_task(schema.id) # ✓ Claimed (无依赖) +complete_task(schema.id) # ✓ Completed → 解锁 endpoints, docs + +claim_task(endpoints.id) # ✓ Claimed (schema 已完成) +complete_task(endpoints.id) # ✓ Completed → 解锁 tests + +claim_task(docs.id) # ✓ Claimed (schema 已完成) +complete_task(docs.id) # ✓ Completed + +claim_task(tests.id) # ✓ Claimed (endpoints 已完成) +complete_task(tests.id) # ✓ Completed +``` + +每个 `create_task` 写一个 JSON 文件,每个 `claim_task` / `complete_task` 更新文件。跨会话时,`.tasks/` 目录还在,Agent 读文件就能恢复进度。 + +--- + +## 相对 s11 的变更 + +| 组件 | 之前 (s11) | 之后 (s12) | +|------|-----------|-----------| +| 任务管理 | 无 | Task dataclass + 5 个工具 | +| 新类型 | — | Task(id, subject, description, status, owner, blockedBy) | +| 存储 | 无持久化 | `.tasks/{id}.json` 跨会话 | +| 依赖 | 无 | `blockedBy` 图 + `can_start` 检查 | +| 工具 | bash, read_file, write_file (3) | + create_task, list_tasks, get_task, claim_task, complete_task (8) | +| 生命周期 | — | pending → in_progress → completed(无 release 回退) | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s12_task_system/code.py +``` + +试试这些 prompt: + +1. `Create tasks: setup database schema, create API endpoints (depends on schema), write tests (depends on endpoints), write docs (depends on schema)` +2. `List all tasks and their statuses` +3. `Claim the first unblocked task and complete it` +4. `List tasks again — which ones are now unblocked?` + +观察重点:`.tasks/` 目录下是否生成了 JSON 文件?完成任务后,被阻塞的任务是否解锁? + +--- + +## 接下来 + +任务图有了。但有些任务要跑很久——比如全量测试、部署到服务器。Agent 调 LLM 按量计费,不能干等一个慢操作。 + +s13 Background Tasks → 慢操作放后台。Agent 继续处理其他任务,后台跑完了通知它。 + +
+深入 Claude Code 源码 + +> 以下基于 Claude Code 源码 `utils/tasks.ts`(862 行)、`tools/TaskCreateTool/TaskCreateTool.ts`(138 行)、`tools/TaskUpdateTool/TaskUpdateTool.ts`(406 行)、`tools/TaskGetTool/TaskGetTool.ts`(128 行)、`tools/TaskListTool/TaskListTool.ts`(116 行)、`hooks/useTaskListWatcher.ts`(221 行)的分析。 + +### 一、TaskRecord 的完整字段 + +教学版只讲了 id、subject、status、owner、blockedBy。Claude Code 实际有 9 个字段(`utils/tasks.ts:76-89`): + +| 字段 | 类型 | 用途 | +|------|------|------| +| `id` | string | 递增整数 ID | +| `subject` | string | 简短标题 | +| `description` | string | 自由格式描述 | +| `activeForm` | string? | 进行时态,in_progress 时在 spinner 显示 | +| `owner` | string? | 分配的 agent ID | +| `status` | pending/in_progress/completed | 生命周期 | +| `blocks` | string[] | 此任务阻塞的任务 ID(下游) | +| `blockedBy` | string[] | 阻塞此任务的任务 ID(上游) | +| `metadata` | Record? | 任意扩展键值对 | + +存储位置:`~/.claude/tasks/{taskListId}/{id}.json`。每个任务一个文件。 + +### 二、不是 TodoWrite 的升级,是两个独立系统 + +Claude Code 中 Task System 和 TodoWrite **同时存在**,通过 `isTodoV2Enabled()` 切换(`utils/tasks.ts:133`)——交互式会话默认启用 Task(V2),非交互式/SDK 默认用 TodoWrite。环境变量 `CLAUDE_CODE_ENABLE_TASKS` 可强制启用 Task。Task 有 TodoWrite 没有的:文件锁并发保护、依赖强制执行、ownership、fs.watch 响应式监听、生命周期 hooks。 + +### 三、并发认领的锁机制 + +`claimTask()`(`utils/tasks.ts:541-612`)用双重锁防竞争: + +**任务文件锁**:`proper-lockfile` 锁住 `{taskId}.json`(最多重试 30 次,指数退避 5-100ms)。锁内: +1. 重新读取任务(防 TOCTOU) +2. 检查已被他人认领 → `already_claimed` +3. 检查已完成 → `already_resolved` +4. 检查上游未完成 → `blocked` +5. 设置 owner + +**列表级锁**(agent busy 检查时):`.lock` 文件,原子性扫描所有任务并检查该 agent 是否已有其他 open task。 + +注意:教学版把 claim 和开始工作合成一步(claim = set owner + in_progress);真实 Claude Code 的 `claimTask` 主要解决 owner 竞争,只设 owner 不改 status,状态更新由 `TaskUpdate` 完成。 + +### 四、高水位标防 ID 重用 + +`.highwatermark` 文件记录曾分配过的最高任务 ID。即使任务被删除,ID 也不会被重用。 + +### 五、四个 Task 工具 + +Claude Code 的任务系统有四个工具(不是教学版的一个通用 Task 工具):`TaskCreate`、`TaskGet`、`TaskUpdate`、`TaskList`。全部设置 `isConcurrencySafe: true` 和 `shouldDefer: true`(工具 schema 不在初始 prompt 中,需 ToolSearch 后才可见)。 + +教学版的 `create_task(blockedBy=...)` 在创建时直接声明依赖,是合理简化。真实 Claude Code 的 `TaskCreate` 只接受 subject/description/activeForm/metadata,依赖关系由 `TaskUpdate` 的 `addBlocks/addBlockedBy` 维护。 + +
+ + diff --git a/s12_task_system/code.py b/s12_task_system/code.py index d9da2527c..608320dc1 100644 --- a/s12_task_system/code.py +++ b/s12_task_system/code.py @@ -16,7 +16,7 @@ Note: Teaching code keeps a basic agent loop to stay focused on the task system. S11's full error recovery (RecoveryState, backoff, escalation, -reactive compact, fallback model) is omitted — in real CC, tasks.ts and +reactive compact, fallback model) is omitted — in real Claude Code, tasks.ts and withRetry are independent layers that compose naturally. """ diff --git a/s12_task_system/images/task-dag.en.svg b/s12_task_system/images/task-dag.en.svg deleted file mode 100644 index 6a075113d..000000000 --- a/s12_task_system/images/task-dag.en.svg +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - Task DAG — Dependency Example: Database → API → Tests → Deploy - - - - ✓ schema - completed - - - - - - - - ● endpoints - in_progress · owner: agent-1 - - - ○ docs - pending · blockedBy: schema ✓ - - - - - - - - ○ tests - blockedBy: endpoints ● - - - - - - ○ deploy - blockedBy: tests, docs - - - - - completed - - in_progress - - pending - → blockedBy (arrows = dependency direction) - docs' blockedBy (schema) is completed → can_start returns True, can be claimed - diff --git a/s12_task_system/images/task-dag.ja.svg b/s12_task_system/images/task-dag.ja.svg deleted file mode 100644 index 37ee46c9c..000000000 --- a/s12_task_system/images/task-dag.ja.svg +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - Task DAG — 依存関係の例:データベース → API → テスト → デプロイ - - - - ✓ schema - completed - - - - - - - - ● endpoints - in_progress · owner: agent-1 - - - ○ docs - pending · blockedBy: schema ✓ - - - - - - - - ○ tests - blockedBy: endpoints ● - - - - - - ○ deploy - blockedBy: tests, docs - - - - - completed - - in_progress - - pending - → blockedBy(矢印 = 依存方向) - docs の blockedBy (schema) は完了済み → can_start が True を返し、claim 可能 - diff --git a/s12_task_system/images/task-dag.svg b/s12_task_system/images/task-dag.svg index c044bd661..2ba3f5ecf 100644 --- a/s12_task_system/images/task-dag.svg +++ b/s12_task_system/images/task-dag.svg @@ -1,59 +1,101 @@ - + - - + + + + + - + + - - - Task DAG — 依赖关系示例:搭数据库 → API → 测试 → 部署 + Task DAG + Dependency Example: schema → endpoints / docs → tests → deploy + - - ✓ schema - completed + + + schema + completed + + + + + + + - - - + + + + + - - ● endpoints - in_progress · owner: agent-1 + + + + + endpoints + in_progress | owner: agent-1 - - ○ docs - pending · blockedBy: schema ✓ + + + docs + pending | blockedBy: schema - - - + + + + + + + + + + + - - ○ tests - blockedBy: endpoints ● + + + + + tests + pending | blockedBy: endpoints - - + + + deploy + pending | blockedBy: tests, docs - - ○ deploy - blockedBy: tests, docs + + + + - - - completed - - in_progress - - pending - → blockedBy(箭头 = 依赖方向) - docs 的 blockedBy (schema) 已完成 → can_start 返回 True,可被 claim - + + + + + + completed + + + + in_progress + + + + pending + + + + blockedBy (dependency direction) + + \ No newline at end of file diff --git a/s12_task_system/images/task-system-overview.en.svg b/s12_task_system/images/task-system-overview.en.svg deleted file mode 100644 index b4a74b6c0..000000000 --- a/s12_task_system/images/task-system-overview.en.svg +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - - - Task System — 5 Task Tools + .tasks/ Persistence + blockedBy Dependencies - - - - s11 Preserved - - s12 New - - - - messages - - - - - prompt + compress - (s10-s11) - - - - - LLM (try/except) - (s11) - - - - - - TOOL_HANDLERS - bash · read · write - create_task · list_tasks - get_task · claim_task · complete_task - - - - - - - .tasks/ — Cross-session Persistence - task_xxx.json · task_yyy.json · task_zzz.json - {id, subject, description, status, owner, blockedBy} - Tutorial ID: timestamp + random | CC: sequential ID + highwatermark - - - - create / save / read - - - - Dependency Check + Lifecycle - can_start: all blockedBy completed? - claim_task → owner = agent, pending → in_progress - complete_task → completed + unblock downstream - - - - State Machine: - - pending - - claim - - in_progress - - complete_task - - completed - No release rollback; crash → unassign owner - - - - - s11 Preserved: loop, prompt assembly, compression (error recovery independent from task system) - - s12 New: Task dataclass + 5 tools + .tasks/ persistence + blockedBy dependency graph - diff --git a/s12_task_system/images/task-system-overview.ja.svg b/s12_task_system/images/task-system-overview.ja.svg deleted file mode 100644 index 906a0dbfb..000000000 --- a/s12_task_system/images/task-system-overview.ja.svg +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - - - Task System — 5 つのタスクツール + .tasks/ 永続化 + blockedBy 依存 - - - - s11 保持 - - s12 新規 - - - - messages - - - - - prompt + compress - (s10-s11) - - - - - LLM (try/except) - (s11) - - - - - - TOOL_HANDLERS - bash · read · write - create_task · list_tasks - get_task · claim_task · complete_task - - - - - - - .tasks/ — セッション横断永続化 - task_xxx.json · task_yyy.json · task_zzz.json - {id, subject, description, status, owner, blockedBy} - チュートリアル ID: timestamp + random | CC: 順次 ID + highwatermark - - - - create / save / read - - - - 依存チェック + ライフサイクル - can_start: blockedBy がすべて completed? - claim_task → owner = agent, pending → in_progress - complete_task → completed + 下流をアンロック - - - - 状態マシン: - - pending - - claim - - in_progress - - complete_task - - completed - release ロールバックなし、クラッシュ時は unassign で owner クリア - - - - - s11 保持:ループ、プロンプト組み立て、圧縮(エラーリカバリとタスクシステムは独立) - - s12 新規:Task dataclass + 5 ツール + .tasks/ 永続化 + blockedBy 依存グラフ - diff --git a/s12_task_system/images/task-system-overview.svg b/s12_task_system/images/task-system-overview.svg index 097b61f36..ae5ba1b85 100644 --- a/s12_task_system/images/task-system-overview.svg +++ b/s12_task_system/images/task-system-overview.svg @@ -1,94 +1,138 @@ - + - - - - - + + - - + + - + + - - - Task System — 5 个任务工具 + .tasks/ 持久化 + blockedBy 依赖 - - - - s11 保留 - - s12 新增 - - - - messages - - - - - prompt + compress - (s10-s11) - - - - - LLM (try/except) - (s11) - - - - - - TOOL_HANDLERS - bash · read · write - create_task · list_tasks - get_task · claim_task · complete_task - - - - - - - .tasks/ — 跨会话持久化 - task_xxx.json · task_yyy.json · task_zzz.json - {id, subject, description, status, owner, blockedBy} - 教学版 ID: timestamp + random | CC: 顺序 ID + highwatermark - - - - create / save / read - - - - 依赖检查 + 生命周期 - can_start: blockedBy 全部 completed? - claim_task → owner = agent, pending → in_progress - complete_task → completed + 解锁下游 - - - - 状态机: - - pending - - claim - - in_progress - - complete_task - - completed - CC 无 release 回退,崩溃时用 unassign 清 owner - - - - - s11 保留:循环、prompt 组装、压缩(错误恢复与任务系统独立) - - s12 新增:Task dataclass + 5 个工具 + .tasks/ 持久化 + blockedBy 依赖图 + Task System Overview + 5 Task Tools + .tasks/ Persistence + blockedBy Dependencies + + + + Agent Loop + + + + messages + + + + + + prompt + + compress + + + + + + LLM + (try/except) + + + + + + TOOL_HANDLERS + bash read write + 5 task tools + + + + + + + + + + + on tool_use + → dispatch a task tool + + + + result → tool_result + + + + .tasks/ — Cross-Session Persistence + task_xxx.json task_yyy.json task_zzz.json + {id, subject, description, status, owner, blockedBy} + ID: timestamp + random_hex (teaching version) + ID: sequential + highwatermark (production) + + + + 5 Task Tools + create_task(subject, desc, blockedBy) + list_tasks() → summary table + get_task(id) → full JSON + claim_task(id) → set owner + complete_task(id) → unblock deps + + + + R/W + + + + Dependency Check + Lifecycle + + + can_start(task_id): + all blockedBy completed? + missing dep → blocked + + + claim_task: + owner = agent_name + pending → in_progress + + + complete_task: + status → completed + scan + unblock downstream + + + + State Machine + + + pending + + + claim + + + in_progress + + + complete + + + completed + + No release rollback. On crash: unassign owner, reset to pending for re-claim. + + + + TodoWrite vs Task System + + TodoWrite + Task System + + Storage + In-session memory + .tasks/{id}.json cross-session + + Dependencies + None + blockedBy graph + can_start check diff --git a/s13_background_tasks/README.en.md b/s13_background_tasks/README.en.md deleted file mode 100644 index f02ee3afd..000000000 --- a/s13_background_tasks/README.en.md +++ /dev/null @@ -1,261 +0,0 @@ -# s13: Background Tasks — Slow Operations Go to the Background - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s20 - -> *"Slow operations go to the background, agent continues processing"* — Background threads run commands, inject notifications when done. -> -> **Harness Layer**: Background — Async execution, doesn't block the main loop. - ---- - -## The Problem - -Ever used a washing machine? Throw clothes in, press start, then go do other things — cook, reply to messages, read papers. 30 minutes later the machine beeps: done. You don't stand there waiting for 30 minutes. - -The agent's bash tool is the same. `pip install torch` takes 10 minutes, `npm run build` takes 3 minutes. While these commands run, the agent waits for bash to return, unable to use that time to process other tasks. - -Reading files is milliseconds, no wait. `git status` returns in under a second, no wait. But `npm install`? Minutes. The agent waits 10 minutes doing nothing, and LLM calls are billed by token — idle time is waste. - ---- - -## The Solution - -![Background Tasks Overview](images/background-tasks-overview.en.svg) - -Teaching code carries forward S12's simplified task system and prompt assembly; to stay focused on background tasks, it omits full error recovery, memory, and skill systems. The only change: slow operations go to background threads, the agent continues running the loop, and background results are injected as notifications. - -Sync vs Background: - -| | Sync (s12) | Background (s13) | -|---|---|---| -| Slow operations | Agent waits | Background thread executes | -| Agent idle | Yes | No, continues processing | -| Result | Immediate return | Notification injected next turn | -| Decision criteria | — | `run_in_background` param (model explicit request), heuristic fallback | - ---- - -## How It Works - -### should_run_background: Explicit Request First, Heuristic Fallback - -The model explicitly requests background execution via the bash tool's `run_in_background` parameter. If the model doesn't specify, the teaching version falls back to keyword heuristics: - -```python -def is_slow_operation(tool_name: str, tool_input: dict) -> bool: - """Fallback heuristic: commands likely to take > 30s.""" - if tool_name != "bash": - return False - cmd = tool_input.get("command", "").lower() - slow_keywords = ["install", "build", "test", "deploy", "compile", - "docker build", "pip install", "npm install", - "cargo build", "pytest", "make"] - return any(kw in cmd for kw in slow_keywords) - -def should_run_background(tool_name: str, tool_input: dict) -> bool: - """Model explicit request takes priority; fallback to heuristic.""" - if tool_input.get("run_in_background"): - return True - return is_slow_operation(tool_name, tool_input) -``` - -CC's bash tool schema has a `run_in_background: boolean` parameter (`BashTool.tsx:241`). The model decides which commands go to background, no keyword guessing. The teaching version keeps heuristics as fallback, but the primary path is explicit model request. - -### start_background_task: Background Execution and Lifecycle - -Wraps the tool call in a worker function, dispatches to a daemon thread. Each background task gets a unique ID, with state tracked in the `background_tasks` dict: - -```python -_bg_counter = 0 -background_tasks: dict[str, dict] = {} # bg_id → {tool_use_id, command, status} -background_results: dict[str, str] = {} # bg_id → output -background_lock = threading.Lock() - -def start_background_task(block) -> str: - """Run tool in a daemon thread. Returns background task ID.""" - global _bg_counter - _bg_counter += 1 - bg_id = f"bg_{_bg_counter:04d}" - - def worker(): - result = execute_tool(block) - with background_lock: - background_tasks[bg_id]["status"] = "completed" - background_results[bg_id] = result - - with background_lock: - background_tasks[bg_id] = { - "tool_use_id": block.id, - "command": block.input.get("command", ""), - "status": "running", - } - thread = threading.Thread(target=worker, daemon=True) - thread.start() - return bg_id -``` - -Returns `bg_id` instead of just `[Running in background...]`. `daemon=True` ensures threads exit when the agent process exits. The teaching version uses in-memory dicts for tracking; real CC has `LocalShellTaskState`, output redirected to files, with full lifecycle including stopping tasks and reading subsequent output. - -### collect_background_results: Notification Collection - -When background tasks complete, results are collected and formatted as `` messages: - -```python -def collect_background_results() -> list[str]: - """Collect completed results as task_notification messages.""" - with background_lock: - ready_ids = [bid for bid, task in background_tasks.items() - if task["status"] == "completed"] - notifications = [] - for bg_id in ready_ids: - with background_lock: - task = background_tasks.pop(bg_id) - output = background_results.pop(bg_id, "") - notifications.append( - f"\n" - f" {bg_id}\n" - f" completed\n" - f" {task['command']}\n" - f" {output[:200]}\n" - f"") - return notifications -``` - -Notifications don't reuse the original `tool_use_id`. The original tool call was already answered with a placeholder `tool_result`; background completion is an independent event, injected in `task_notification` format. This respects Messages API tool pairing: one `tool_use` gets exactly one `tool_result`. - -### Loop Integration - -In the agent loop, tool execution splits into two paths. Notifications and results merge into a single user message: - -```python -results = [] -for block in response.content: - if block.type != "tool_use": - continue - if should_run_background(block.name, block.input): - bg_id = start_background_task(block) - results.append({"type": "tool_result", - "tool_use_id": block.id, - "content": f"[Background task {bg_id} started] " - f"Result will be available when complete."}) - else: - output = execute_tool(block) - results.append({"type": "tool_result", - "tool_use_id": block.id, "content": output}) - -# Merge notifications and tool results into one user message -user_content = [] -bg_notifications = collect_background_results() -if bg_notifications: - for notif in bg_notifications: - user_content.append({"type": "text", "text": notif}) -user_content.extend(results) -messages.append({"role": "user", "content": user_content}) -``` - -Slow operations get a placeholder tool_result with `bg_id`, so the LLM knows this command is still running and can do other things first. When background completes, the notification is injected as an independent text block alongside the current turn's tool_results in one user message. - -The teaching version polls background results while the agent loop continues running. Real CC uses a notification queue (`messageQueueManager.ts`) to deliver background completion events to subsequent turns, without waiting for the tool loop. - -### Putting It Together - -``` -Turn 1: - LLM → bash "npm install" (run_in_background=true) - → start_background_task → bg_0001 - → tool_result: "[Background task bg_0001 started]..." - → LLM: "OK, I'll check later. Let me also read the config." - -Turn 2: - LLM → read_file "package.json" (fast, sync) - → tool_result: file content - → collect: bg_0001 done! inject - → LLM sees: config file + install notification in one message -``` - -The agent didn't wait — while npm install ran in the background, it read the config file. - ---- - -## Changes from s12 - -| Component | Before (s12) | After (s13) | -|-----------|-------------|-------------| -| Execution model | All synchronous | Slow ops to background thread + notification injection | -| bash schema | `command` | `command` + `run_in_background` | -| New functions | — | `should_run_background`, `is_slow_operation`, `start_background_task`, `collect_background_results` | -| New types | — | `background_tasks: dict`, `background_results: dict`, `background_lock: Lock` | -| Notification format | — | `` (doesn't reuse tool_use_id) | -| Loop behavior | Tools execute serially | Slow ops async, fast ops sync, notifications collected each turn | -| Tools | 8 (s12) | 8 (unchanged, execution strategy changed) | - ---- - -## Try It - -```sh -cd learn-claude-code -python s13_background_tasks/code.py -``` - -Try these prompts: - -1. `Run pip list in the background and find all Python files in this directory` -2. `Run npm install (use run_in_background) and while waiting, read package.json` -3. `Create a task to setup the project, then run pip list in the background` - -What to observe: Are slow operations dispatched to background? Is a `bg_id` returned? Are background notifications injected in `` format? - ---- - -## What's Next - -Background tasks solved "slow operations don't block." But what if you want to do something on a schedule? Like "run tests every morning at 9am" or "check server status every 5 minutes." - -s14 Cron Scheduler → Give the agent an alarm clock. - -
-Deep Dive into CC Source - -> The following is a complete analysis based on CC source code `query.ts` (lines 211, 1054-1060, 1411-1482), `services/toolUseSummary/toolUseSummaryGenerator.ts` (L15 prompt text), `LocalShellTask.tsx` (L24-25 constants, L59-98 watchdog logic), `messageQueueManager.ts` (notification queue), `utils/task/framework.ts` (L267 `enqueueTaskNotification`). - -### 1. pendingToolUseSummary: Haiku Background Generation - -CC starts a Haiku side-query after each batch of tool executions to generate a tool use summary. Initiated at `query.ts:1411-1482`, prompt text defined at `services/toolUseSummary/toolUseSummaryGenerator.ts:15` (variable `TOOL_USE_SUMMARY_SYSTEM_PROMPT`). The prompt is "Write a short summary label... think git-commit-subject, not sentence", past tense, ~30 characters. - -Haiku summary (~1s) completes during the main model's streaming output (5-30s). Before the next turn starts, the summary is yielded. SDK consumers use these summaries for mobile progress display. - -### 2. Thread Model: No Real Threads - -CC runs on Node.js/Bun's single-threaded event loop. "Background" just means "don't await". `ShellCommand.background(taskId)` redirects stdout/stderr to files, letting the process run independently. - -### 3. Seven Background Task Types - -CC defines 7 background task types (`Task.ts:7-13`): `local_bash`, `local_agent`, `remote_agent`, `in_process_teammate`, `local_workflow`, `monitor_mcp`, `dream`. Each has its own registration, lifecycle, and notification mechanism. - -### 4. Notification Injection: Command Queue - -When a background task completes, it's enqueued via `enqueueTaskNotification` (`utils/task/framework.ts:267`) or `enqueuePendingNotification` (`messageQueueManager.ts`) into a shared command queue. The notification format is structured XML: - -```xml - - completed - Background command "npm test" completed (exit code 0) - -``` - -Priority is `next` > `later` (`messageQueueManager.ts`). Background tasks default to `later` (don't block user input). Consumption point at `query.ts:1566-1593`. - -### 5. Stall Watchdog - -Background bash tasks have a watchdog (`LocalShellTask.tsx` L24-25 constants, L59-98 logic) that periodically checks if output has stalled. After 45 seconds with no growth, it detects interactive prompts (`(y/n)` etc.), preventing background tasks from getting stuck on unanswered interactive dialogs. - -### 6. Concurrency Limits - -Foreground tool calls: `CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY` (default 10 concurrent safe tools). Background bash tasks: no hard limit, they're independent subprocesses. - -
- - diff --git a/s13_background_tasks/README.ja.md b/s13_background_tasks/README.ja.md index 7ee1d14df..1eca2d05a 100644 --- a/s13_background_tasks/README.ja.md +++ b/s13_background_tasks/README.ja.md @@ -1,6 +1,6 @@ # s13: Background Tasks — 遅い操作はバックグラウンドへ -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s20 @@ -22,9 +22,9 @@ Agent の bash ツールも同じ。`pip install torch` は 10 分、`npm run bu ## ソリューション -![Background Tasks Overview](images/background-tasks-overview.ja.svg) +![Background Tasks Overview](images/background-tasks-overview.svg) -教学版は S12 の簡易タスクシステムとプロンプト組み立てを踏襲。バックグラウンドタスクに集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。唯一の変更:遅い操作をバックグラウンドスレッドに投げ、Agent はループを継続、バックグラウンド完了時に通知を注入。 +教学版は S12 の簡易タスクシステムとプロンプト組み立てを踏襲。バックグラウンドタスクに集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。唯一の変更:ツール呼び出しをバックグラウンドスレッドにディスパッチし、完了時に結果を通知として注入。 同期 vs バックグラウンド: @@ -61,7 +61,7 @@ def should_run_background(tool_name: str, tool_input: dict) -> bool: return is_slow_operation(tool_name, tool_input) ``` -CC の bash ツールスキーマには `run_in_background: boolean` パラメータがある(`BashTool.tsx:241`)。モデルがどのコマンドをバックグラウンドにするかを決定、キーワード推測ではない。教学版はヒューリスティックをフォールバックとして残すが、主パスはモデルの明示的リクエスト。 +Claude Code の bash ツールスキーマには `run_in_background: boolean` パラメータがある(`BashTool.tsx:241`)。モデルがどのコマンドをバックグラウンドにするかを決定、キーワード推測ではない。教学版はヒューリスティックをフォールバックとして残すが、主パスはモデルの明示的リクエスト。 ### start_background_task: バックグラウンド実行とライフサイクル @@ -96,7 +96,7 @@ def start_background_task(block) -> str: return bg_id ``` -`[Running in background...]` ではなく `bg_id` を返す。`daemon=True` で Agent プロセス終了時にスレッドも終了。教学版はメモリ内辞書で追跡。実際の CC は `LocalShellTaskState` を持ち、出力をファイルにリダイレクト、タスク停止や継続出力読み取りを含む完全なライフサイクルを備える。 +`[Running in background...]` ではなく `bg_id` を返す。`daemon=True` で Agent プロセス終了時にスレッドも終了。教学版はメモリ内辞書で追跡。実際の Claude Code は `LocalShellTaskState` を持ち、出力をファイルにリダイレクト、タスク停止や継続出力読み取りを含む完全なライフサイクルを備える。 ### collect_background_results: 通知収集 @@ -157,7 +157,7 @@ messages.append({"role": "user", "content": user_content}) 遅い操作は `bg_id` 付きプレースホルダー tool_result を返し、LLM はコマンドがまだ実行中だと知り、先に他のことをできる。バックグラウンド完了時、通知は独立した text block として現在のターンの tool_result と一緒に 1 つの user メッセージを構成する。 -教学版は agent loop が継続実行中にバックグラウンド結果をポーリングする。実際の CC は通知キュー(`messageQueueManager.ts`)でバックグラウンド完了イベントを後続ターンに配信、ツールループを待つ必要はない。 +教学版は agent loop が継続実行中にバックグラウンド結果をポーリングする。実際の Claude Code は通知キュー(`messageQueueManager.ts`)でバックグラウンド完了イベントを後続ターンに配信、ツールループを待つ必要はない。 ### 組み合わせて実行 @@ -212,28 +212,28 @@ python s13_background_tasks/code.py ## 次の章 -バックグラウンドタスクは「遅い操作がブロックしない」を解決した。しかし、定期的に何かをしたい場合は?例えば「毎朝 9 時にテストを実行」「5 分ごとにサーバーステータスを確認」。 +Agent は長時間コマンドで止まらなくなった。しかし、定期的に何かをしたい場合は?例えば「毎朝 9 時にテストを実行」「5 分ごとにサーバーステータスを確認」。 s14 Cron Scheduler → Agent にアラームクロックを付ける。
-CC ソースコード深掘り +Claude Code ソースコード深掘り -> 以下は CC ソースコード `query.ts`(211, 1054-1060, 1411-1482 行)、`services/toolUseSummary/toolUseSummaryGenerator.ts`(L15 プロンプトテキスト)、`LocalShellTask.tsx`(L24-25 定数, L59-98 ウォッチドッグロジック)、`messageQueueManager.ts`(通知キュー)、`utils/task/framework.ts`(L267 `enqueueTaskNotification`)の完全分析に基づく。 +> 以下は Claude Code ソースコード `query.ts`(211, 1054-1060, 1411-1482 行)、`services/toolUseSummary/toolUseSummaryGenerator.ts`(L15 プロンプトテキスト)、`LocalShellTask.tsx`(L24-25 定数, L59-98 ウォッチドッグロジック)、`messageQueueManager.ts`(通知キュー)、`utils/task/framework.ts`(L267 `enqueueTaskNotification`)の完全分析に基づく。 ### 一、pendingToolUseSummary:Haiku バックグラウンド生成 -CC は各ツール実行バッチの後、Haiku サイドクエリを開始してツール使用サマリを生成。開始コードは `query.ts:1411-1482`、プロンプトテキストは `services/toolUseSummary/toolUseSummaryGenerator.ts:15`(変数 `TOOL_USE_SUMMARY_SYSTEM_PROMPT`)。プロンプトは "Write a short summary label... think git-commit-subject, not sentence"、過去形、約 30 文字。 +Claude Code は各ツール実行バッチの後、Haiku サイドクエリを開始してツール使用サマリを生成。開始コードは `query.ts:1411-1482`、プロンプトテキストは `services/toolUseSummary/toolUseSummaryGenerator.ts:15`(変数 `TOOL_USE_SUMMARY_SYSTEM_PROMPT`)。プロンプトは "Write a short summary label... think git-commit-subject, not sentence"、過去形、約 30 文字。 Haiku サマリ(~1s)はメインモデルのストリーミング出力(5-30s)中に完了。次のターン開始前にサマリを yield。SDK コンシューマーはこれらのサマリをモバイル進捗表示に使用。 ### 二、スレッドモデル:本当のスレッドはない -CC は Node.js/Bun のシングルスレッドイベントループで動作。「バックグラウンド」は単に「await しない」こと。`ShellCommand.background(taskId)` は stdout/stderr をファイルにリダイレクトし、プロセスを独立実行。 +Claude Code は Node.js/Bun のシングルスレッドイベントループで動作。「バックグラウンド」は単に「await しない」こと。`ShellCommand.background(taskId)` は stdout/stderr をファイルにリダイレクトし、プロセスを独立実行。 ### 三、7 種のバックグラウンドタスク型 -CC は 7 種のバックグラウンドタスク型を定義(`Task.ts:7-13`):`local_bash`、`local_agent`、`remote_agent`、`in_process_teammate`、`local_workflow`、`monitor_mcp`、`dream`。それぞれ独自の登録、ライフサイクル、通知メカニズムを持つ。 +Claude Code は 7 種のバックグラウンドタスク型を定義(`Task.ts:7-13`):`local_bash`、`local_agent`、`remote_agent`、`in_process_teammate`、`local_workflow`、`monitor_mcp`、`dream`。それぞれ独自の登録、ライフサイクル、通知メカニズムを持つ。 ### 四、通知注入:コマンドキュー diff --git a/s13_background_tasks/README.md b/s13_background_tasks/README.md index fe284cd8e..9497f801d 100644 --- a/s13_background_tasks/README.md +++ b/s13_background_tasks/README.md @@ -1,47 +1,47 @@ -# s13: Background Tasks — 慢操作放后台 +# s13: Background Tasks — Slow Operations Go to the Background -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s20 -> *"慢操作丢后台, agent 继续处理"* — 后台线程跑命令, 完成后注入通知。 +> *"Slow operations go to the background, agent continues processing"* — Background threads run commands, inject notifications when done. > -> **Harness 层**: 后台 — 异步执行, 不阻塞主循环。 +> **Harness Layer**: Background — Async execution, doesn't block the main loop. --- -## 问题 +## The Problem -你用过洗衣机吗?把衣服扔进去,按下启动,然后去干别的——做饭、回消息、看论文。30 分钟后洗衣机"滴滴滴"提醒你:好了。你不会站在洗衣机前面干等 30 分钟。 +Ever used a washing machine? Throw clothes in, press start, then go do other things — cook, reply to messages, read papers. 30 minutes later the machine beeps: done. You don't stand there waiting for 30 minutes. -Agent 的 bash 工具也一样。`pip install torch` 要 10 分钟,`npm run build` 要 3 分钟。这些命令一跑,Agent 就在等 bash 工具返回,没法利用这段时间处理别的任务。 +The agent's bash tool is the same. `pip install torch` takes 10 minutes, `npm run build` takes 3 minutes. While these commands run, the agent waits for bash to return, unable to use that time to process other tasks. -读文件是毫秒级,不等。`git status` 一秒内返回,不等。但 `npm install`?分钟级。Agent 等 10 分钟什么都不做,而 LLM 按 token 计费,空转就是浪费。 +Reading files is milliseconds, no wait. `git status` returns in under a second, no wait. But `npm install`? Minutes. The agent waits 10 minutes doing nothing, and LLM calls are billed by token — idle time is waste. --- -## 解决方案 +## The Solution ![Background Tasks Overview](images/background-tasks-overview.svg) -教学代码沿用 S12 的简化任务系统和 prompt 组装;为了聚焦后台任务,省略完整错误恢复、记忆和技能系统。唯一的变动:慢操作扔到后台线程,Agent 继续跑循环,后台完成后把通知注入到对话里。 +Teaching code carries forward S12's simplified task system and prompt assembly; to stay focused on background tasks, it omits full error recovery, memory, and skill systems. The only change: tool calls dispatch to background threads, and results are injected as notifications when they finish. -同步 vs 后台: +Sync vs Background: -| | 同步 (s12) | 后台 (s13) | +| | Sync (s12) | Background (s13) | |---|---|---| -| 慢操作 | Agent 干等 | 后台线程执行 | -| Agent 空闲 | 是 | 否,继续处理 | -| 结果 | 立即返回 | 下轮注入通知 | -| 判断标准 | — | `run_in_background` 参数(模型显式请求),启发式兜底 | +| Slow operations | Agent waits | Background thread executes | +| Agent idle | Yes | No, continues processing | +| Result | Immediate return | Notification injected next turn | +| Decision criteria | — | `run_in_background` param (model explicit request), heuristic fallback | --- -## 工作原理 +## How It Works -### should_run_background: 显式请求优先,启发式兜底 +### should_run_background: Explicit Request First, Heuristic Fallback -模型通过 bash 工具的 `run_in_background` 参数显式请求后台执行。如果模型没指定,教学版用关键词启发式兜底: +The model explicitly requests background execution via the bash tool's `run_in_background` parameter. If the model doesn't specify, the teaching version falls back to keyword heuristics: ```python def is_slow_operation(tool_name: str, tool_input: dict) -> bool: @@ -61,11 +61,11 @@ def should_run_background(tool_name: str, tool_input: dict) -> bool: return is_slow_operation(tool_name, tool_input) ``` -CC 的 bash 工具 schema 里有 `run_in_background: boolean` 参数(`BashTool.tsx:241`)。模型自己决定哪些命令丢后台,不靠关键词猜。教学版保留启发式作为兜底,但主路径是模型显式请求。 +Claude Code's bash tool schema has a `run_in_background: boolean` parameter (`BashTool.tsx:241`). The model decides which commands go to background, no keyword guessing. The teaching version keeps heuristics as fallback, but the primary path is explicit model request. -### start_background_task: 后台执行与生命周期 +### start_background_task: Background Execution and Lifecycle -把工具调用包装成 worker 函数,扔到 daemon 线程里执行。每个后台任务有唯一 ID,状态存在 `background_tasks` 字典里: +Wraps the tool call in a worker function, dispatches to a daemon thread. Each background task gets a unique ID, with state tracked in the `background_tasks` dict: ```python _bg_counter = 0 @@ -96,11 +96,11 @@ def start_background_task(block) -> str: return bg_id ``` -返回 `bg_id` 而不是只返回 `[Running in background...]`。`daemon=True` 确保 Agent 进程退出时线程跟着退出。教学版用内存字典追踪状态;真实 CC 有 `LocalShellTaskState`,输出重定向到文件,支持停止任务、读取后续输出等完整生命周期。 +Returns `bg_id` instead of just `[Running in background...]`. `daemon=True` ensures threads exit when the agent process exits. The teaching version uses in-memory dicts for tracking; real Claude Code has `LocalShellTaskState`, output redirected to files, with full lifecycle including stopping tasks and reading subsequent output. -### collect_background_results: 通知收集 +### collect_background_results: Notification Collection -后台任务完成后,收集结果并格式化为 `` 通知: +When background tasks complete, results are collected and formatted as `` messages: ```python def collect_background_results() -> list[str]: @@ -123,11 +123,11 @@ def collect_background_results() -> list[str]: return notifications ``` -通知不复用原始 `tool_use_id`。原始 tool call 已经用占位 `tool_result` 回复了,后台完成是独立事件,用 `task_notification` 格式注入。这符合 Messages API 的工具配对语义:一个 `tool_use` 只对应一个 `tool_result`。 +Notifications don't reuse the original `tool_use_id`. The original tool call was already answered with a placeholder `tool_result`; background completion is an independent event, injected in `task_notification` format. This respects Messages API tool pairing: one `tool_use` gets exactly one `tool_result`. -### 循环中的集成 +### Loop Integration -agent_loop 里,工具执行分两条路,通知和结果合并为一条 user 消息: +In the agent loop, tool execution splits into two paths. Notifications and results merge into a single user message: ```python results = [] @@ -145,7 +145,7 @@ for block in response.content: results.append({"type": "tool_result", "tool_use_id": block.id, "content": output}) -# 通知和工具结果合入同一条 user 消息 +# Merge notifications and tool results into one user message user_content = [] bg_notifications = collect_background_results() if bg_notifications: @@ -155,11 +155,11 @@ user_content.extend(results) messages.append({"role": "user", "content": user_content}) ``` -慢操作先回一个带 `bg_id` 的占位 tool_result,LLM 知道这个命令还在跑,可以先做别的事。后台完成后,通知作为独立 text block 和当前轮的 tool_result 一起组成 user 消息。 +Slow operations get a placeholder tool_result with `bg_id`, so the LLM knows this command is still running and can do other things first. When background completes, the notification is injected as an independent text block alongside the current turn's tool_results in one user message. -教学版在 agent loop 继续运行时轮询后台结果。真实 CC 通过通知队列(`messageQueueManager.ts`)把后台完成事件送入后续 turn,不需要等工具循环。 +The teaching version polls background results while the agent loop continues running. Real Claude Code uses a notification queue (`messageQueueManager.ts`) to deliver background completion events to subsequent turns, without waiting for the tool loop. -### 合起来跑 +### Putting It Together ``` Turn 1: @@ -175,69 +175,69 @@ Turn 2: → LLM sees: config file + install notification in one message ``` -Agent 没干等,npm install 跑后台的时候,它去读了配置文件。 +The agent didn't wait. While npm install ran in the background, it read the config file. --- -## 相对 s12 的变更 +## Changes from s12 -| 组件 | 之前 (s12) | 之后 (s13) | -|------|-----------|-----------| -| 执行模型 | 全部同步 | 慢操作后台线程 + 通知注入 | +| Component | Before (s12) | After (s13) | +|-----------|-------------|-------------| +| Execution model | All synchronous | Slow ops to background thread + notification injection | | bash schema | `command` | `command` + `run_in_background` | -| 新函数 | — | `should_run_background`, `is_slow_operation`, `start_background_task`, `collect_background_results` | -| 新类型 | — | `background_tasks: dict`, `background_results: dict`, `background_lock: Lock` | -| 通知格式 | — | ``(不复用 tool_use_id) | -| 循环行为 | 工具串行执行 | 慢操作异步,快操作同步,通知每轮收集 | -| 工具 | 8 (s12) | 8(不变,执行策略变了) | +| New functions | — | `should_run_background`, `is_slow_operation`, `start_background_task`, `collect_background_results` | +| New types | — | `background_tasks: dict`, `background_results: dict`, `background_lock: Lock` | +| Notification format | — | `` (doesn't reuse tool_use_id) | +| Loop behavior | Tools execute serially | Slow ops async, fast ops sync, notifications collected each turn | +| Tools | 8 (s12) | 8 (unchanged, execution strategy changed) | --- -## 试一下 +## Try It ```sh cd learn-claude-code python s13_background_tasks/code.py ``` -试试这些 prompt: +Try these prompts: 1. `Run pip list in the background and find all Python files in this directory` 2. `Run npm install (use run_in_background) and while waiting, read package.json` 3. `Create a task to setup the project, then run pip list in the background` -观察重点:慢操作有没有被送到后台?`bg_id` 是否返回?后台通知有没有以 `` 格式注入? +What to observe: Are slow operations dispatched to background? Is a `bg_id` returned? Are background notifications injected in `` format? --- -## 接下来 +## What's Next -后台任务解决了"慢操作不阻塞"。但如果想定时做某件事呢?比如"每天早上 9 点跑测试"、"每 5 分钟检查一次服务器状态"。 +The agent no longer stalls on long-running commands. But what if you want to do something on a schedule? Like "run tests every morning at 9am" or "check server status every 5 minutes." -s14 Cron Scheduler → 给 Agent 装一个闹钟。 +s14 Cron Scheduler → Give the agent an alarm clock.
-深入 CC 源码 +Deep Dive into Claude Code Source -> 以下基于 CC 源码 `query.ts`(211, 1054-1060, 1411-1482 行)、`services/toolUseSummary/toolUseSummaryGenerator.ts`(L15 prompt 文本)、`LocalShellTask.tsx`(L24-25 常量, L59-98 看门狗逻辑)、`messageQueueManager.ts`(通知队列)、`utils/task/framework.ts`(L267 `enqueueTaskNotification`)的完整分析。 +> The following is a complete analysis based on Claude Code source code `query.ts` (lines 211, 1054-1060, 1411-1482), `services/toolUseSummary/toolUseSummaryGenerator.ts` (L15 prompt text), `LocalShellTask.tsx` (L24-25 constants, L59-98 watchdog logic), `messageQueueManager.ts` (notification queue), `utils/task/framework.ts` (L267 `enqueueTaskNotification`). -### 一、pendingToolUseSummary:Haiku 后台生成 +### 1. pendingToolUseSummary: Haiku Background Generation -CC 在每批工具执行完后,启动一个 Haiku side-query 生成工具使用摘要。发起代码在 `query.ts:1411-1482`,prompt 文本定义在 `services/toolUseSummary/toolUseSummaryGenerator.ts:15`(变量名 `TOOL_USE_SUMMARY_SYSTEM_PROMPT`)。提示是 "Write a short summary label... think git-commit-subject, not sentence",过去时态,约 30 字符。 +Claude Code starts a Haiku side-query after each batch of tool executions to generate a tool use summary. Initiated at `query.ts:1411-1482`, prompt text defined at `services/toolUseSummary/toolUseSummaryGenerator.ts:15` (variable `TOOL_USE_SUMMARY_SYSTEM_PROMPT`). The prompt is "Write a short summary label... think git-commit-subject, not sentence", past tense, ~30 characters. -Haiku 摘要(~1s)在主模型流式生成(5-30s)期间完成。下一轮开始前,把摘要 yield 出去。SDK 消费这些摘要做移动端进度展示。 +Haiku summary (~1s) completes during the main model's streaming output (5-30s). Before the next turn starts, the summary is yielded. SDK consumers use these summaries for mobile progress display. -### 二、线程模型:没有真正的线程 +### 2. Thread Model: No Real Threads -CC 运行在 Node.js/Bun 单线程事件循环中。"后台"只是 "不 await"。`ShellCommand.background(taskId)` 把 stdout/stderr 重定向到文件,让进程独立运行。 +Claude Code runs on Node.js/Bun's single-threaded event loop. "Background" just means "don't await". `ShellCommand.background(taskId)` redirects stdout/stderr to files, letting the process run independently. -### 三、七种后台任务类型 +### 3. Seven Background Task Types -CC 定义了 7 种后台任务(`Task.ts:7-13`):`local_bash`、`local_agent`、`remote_agent`、`in_process_teammate`、`local_workflow`、`monitor_mcp`、`dream`。每种有自己的注册、生命周期和通知机制。 +Claude Code defines 7 background task types (`Task.ts:7-13`): `local_bash`, `local_agent`, `remote_agent`, `in_process_teammate`, `local_workflow`, `monitor_mcp`, `dream`. Each has its own registration, lifecycle, and notification mechanism. -### 四、通知注入:命令队列 +### 4. Notification Injection: Command Queue -后台任务完成后通过 `enqueueTaskNotification`(`utils/task/framework.ts:267`)或 `enqueuePendingNotification`(`messageQueueManager.ts`)入队到共享命令队列。通知格式是结构化的 XML: +When a background task completes, it's enqueued via `enqueueTaskNotification` (`utils/task/framework.ts:267`) or `enqueuePendingNotification` (`messageQueueManager.ts`) into a shared command queue. The notification format is structured XML: ```xml @@ -246,15 +246,15 @@ CC 定义了 7 种后台任务(`Task.ts:7-13`):`local_bash`、`local_agent ``` -优先级分 `next` > `later`(`messageQueueManager.ts`)。后台任务默认 `later`(不阻塞用户输入)。消费点在 `query.ts:1566-1593`。 +Priority is `next` > `later` (`messageQueueManager.ts`). Background tasks default to `later` (don't block user input). Consumption point at `query.ts:1566-1593`. -### 五、停滞看门狗 +### 5. Stall Watchdog -后台 bash 任务有一个看门狗(`LocalShellTask.tsx` L24-25 常量, L59-98 逻辑),定期检查输出是否停滞,45 秒无增长后检测交互式提示(`(y/n)` 等),防止后台任务卡在无人响应的交互式对话框。 +Background bash tasks have a watchdog (`LocalShellTask.tsx` L24-25 constants, L59-98 logic) that periodically checks if output has stalled. After 45 seconds with no growth, it detects interactive prompts (`(y/n)` etc.), preventing background tasks from getting stuck on unanswered interactive dialogs. -### 六、并发限制 +### 6. Concurrency Limits -前台工具调用:`CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY`(默认 10 个并发安全工具)。后台 bash 任务:没有硬性限制,它们是独立的子进程。 +Foreground tool calls: `CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY` (default 10 concurrent safe tools). Background bash tasks: no hard limit, they're independent subprocesses.
diff --git a/s13_background_tasks/README.zh.md b/s13_background_tasks/README.zh.md new file mode 100644 index 000000000..e23957fc0 --- /dev/null +++ b/s13_background_tasks/README.zh.md @@ -0,0 +1,261 @@ +# s13: Background Tasks — 慢操作放后台 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 → ... → s20 + +> *"慢操作丢后台, agent 继续处理"* — 后台线程跑命令, 完成后注入通知。 +> +> **Harness 层**: 后台 — 异步执行, 不阻塞主循环。 + +--- + +## 问题 + +你用过洗衣机吗?把衣服扔进去,按下启动,然后去干别的——做饭、回消息、看论文。30 分钟后洗衣机"滴滴滴"提醒你:好了。你不会站在洗衣机前面干等 30 分钟。 + +Agent 的 bash 工具也一样。`pip install torch` 要 10 分钟,`npm run build` 要 3 分钟。这些命令一跑,Agent 就在等 bash 工具返回,没法利用这段时间处理别的任务。 + +读文件是毫秒级,不等。`git status` 一秒内返回,不等。但 `npm install`是分钟级。Agent 等 10 分钟什么都不做,而 LLM 按 token 计费,空转就是浪费。 + +--- + +## 解决方案 + +![Background Tasks Overview](images/background-tasks-overview.svg) + +教学代码沿用 S12 的简化任务系统和 prompt 组装;为了聚焦后台任务,省略完整错误恢复、记忆和技能系统。唯一的变动:把工具调用扔到后台线程,完成后把结果作为通知注入到对话里。 + +同步 vs 后台: + +| | 同步 (s12) | 后台 (s13) | +|---|---|---| +| 慢操作 | Agent 干等 | 后台线程执行 | +| Agent 空闲 | 是 | 否,继续处理 | +| 结果 | 立即返回 | 下轮注入通知 | +| 判断标准 | — | `run_in_background` 参数(模型显式请求),启发式兜底 | + +--- + +## 工作原理 + +### should_run_background: 显式请求优先,启发式兜底 + +模型通过 bash 工具的 `run_in_background` 参数显式请求后台执行。如果模型没指定,教学版用关键词启发式兜底: + +```python +def is_slow_operation(tool_name: str, tool_input: dict) -> bool: + """Fallback heuristic: commands likely to take > 30s.""" + if tool_name != "bash": + return False + cmd = tool_input.get("command", "").lower() + slow_keywords = ["install", "build", "test", "deploy", "compile", + "docker build", "pip install", "npm install", + "cargo build", "pytest", "make"] + return any(kw in cmd for kw in slow_keywords) + +def should_run_background(tool_name: str, tool_input: dict) -> bool: + """Model explicit request takes priority; fallback to heuristic.""" + if tool_input.get("run_in_background"): + return True + return is_slow_operation(tool_name, tool_input) +``` + +Claude Code 的 bash 工具 schema 里有 `run_in_background: boolean` 参数(`BashTool.tsx:241`)。模型自己决定哪些命令丢后台,不靠关键词猜。教学版保留启发式作为兜底,但主路径是模型显式请求。 + +### start_background_task: 后台执行与生命周期 + +把工具调用包装成 worker 函数,扔到 daemon 线程里执行。每个后台任务有唯一 ID,状态存在 `background_tasks` 字典里: + +```python +_bg_counter = 0 +background_tasks: dict[str, dict] = {} # bg_id → {tool_use_id, command, status} +background_results: dict[str, str] = {} # bg_id → output +background_lock = threading.Lock() + +def start_background_task(block) -> str: + """Run tool in a daemon thread. Returns background task ID.""" + global _bg_counter + _bg_counter += 1 + bg_id = f"bg_{_bg_counter:04d}" + + def worker(): + result = execute_tool(block) + with background_lock: + background_tasks[bg_id]["status"] = "completed" + background_results[bg_id] = result + + with background_lock: + background_tasks[bg_id] = { + "tool_use_id": block.id, + "command": block.input.get("command", ""), + "status": "running", + } + thread = threading.Thread(target=worker, daemon=True) + thread.start() + return bg_id +``` + +返回 `bg_id` 而不是只返回 `[Running in background...]`。`daemon=True` 确保 Agent 进程退出时线程跟着退出。教学版用内存字典追踪状态;真实 Claude Code 有 `LocalShellTaskState`,输出重定向到文件,支持停止任务、读取后续输出等完整生命周期。 + +### collect_background_results: 通知收集 + +后台任务完成后,收集结果并格式化为 `` 通知: + +```python +def collect_background_results() -> list[str]: + """Collect completed results as task_notification messages.""" + with background_lock: + ready_ids = [bid for bid, task in background_tasks.items() + if task["status"] == "completed"] + notifications = [] + for bg_id in ready_ids: + with background_lock: + task = background_tasks.pop(bg_id) + output = background_results.pop(bg_id, "") + notifications.append( + f"\n" + f" {bg_id}\n" + f" completed\n" + f" {task['command']}\n" + f" {output[:200]}\n" + f"") + return notifications +``` + +通知不复用原始 `tool_use_id`。原始 tool call 已经用占位 `tool_result` 回复了,后台完成是独立事件,用 `task_notification` 格式注入。这符合 Messages API 的工具配对语义:一个 `tool_use` 只对应一个 `tool_result`。 + +### 循环中的集成 + +agent_loop 里,工具执行分两条路,通知和结果合并为一条 user 消息: + +```python +results = [] +for block in response.content: + if block.type != "tool_use": + continue + if should_run_background(block.name, block.input): + bg_id = start_background_task(block) + results.append({"type": "tool_result", + "tool_use_id": block.id, + "content": f"[Background task {bg_id} started] " + f"Result will be available when complete."}) + else: + output = execute_tool(block) + results.append({"type": "tool_result", + "tool_use_id": block.id, "content": output}) + +# 通知和工具结果合入同一条 user 消息 +user_content = [] +bg_notifications = collect_background_results() +if bg_notifications: + for notif in bg_notifications: + user_content.append({"type": "text", "text": notif}) +user_content.extend(results) +messages.append({"role": "user", "content": user_content}) +``` + +慢操作先回一个带 `bg_id` 的占位 tool_result,LLM 知道这个命令还在跑,可以先做别的事。后台完成后,通知作为独立 text block 和当前轮的 tool_result 一起组成 user 消息。 + +教学版在 agent loop 继续运行时轮询后台结果。真实 Claude Code 通过通知队列(`messageQueueManager.ts`)把后台完成事件送入后续 turn,不需要等工具循环。 + +### 合起来跑 + +``` +Turn 1: + LLM → bash "npm install" (run_in_background=true) + → start_background_task → bg_0001 + → tool_result: "[Background task bg_0001 started]..." + → LLM: "OK, I'll check later. Let me also read the config." + +Turn 2: + LLM → read_file "package.json" (fast, sync) + → tool_result: file content + → collect: bg_0001 done! inject + → LLM sees: config file + install notification in one message +``` + +Agent 没干等,npm install 跑后台的时候,它去读了配置文件。 + +--- + +## 相对 s12 的变更 + +| 组件 | 之前 (s12) | 之后 (s13) | +|------|-----------|-----------| +| 执行模型 | 全部同步 | 慢操作后台线程 + 通知注入 | +| bash schema | `command` | `command` + `run_in_background` | +| 新函数 | — | `should_run_background`, `is_slow_operation`, `start_background_task`, `collect_background_results` | +| 新类型 | — | `background_tasks: dict`, `background_results: dict`, `background_lock: Lock` | +| 通知格式 | — | ``(不复用 tool_use_id) | +| 循环行为 | 工具串行执行 | 慢操作异步,快操作同步,通知每轮收集 | +| 工具 | 8 (s12) | 8(不变,执行策略变了) | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s13_background_tasks/code.py +``` + +试试这些 prompt: + +1. `Run pip list in the background and find all Python files in this directory` +2. `Run npm install (use run_in_background) and while waiting, read package.json` +3. `Create a task to setup the project, then run pip list in the background` + +观察重点:慢操作有没有被送到后台?`bg_id` 是否返回?后台通知有没有以 `` 格式注入? + +--- + +## 接下来 + +Agent 不再因为长命令卡住了。但如果想定时做某件事呢?比如"每天早上 9 点跑测试"、"每 5 分钟检查一次服务器状态"。 + +s14 Cron Scheduler → 给 Agent 装一个闹钟。 + +
+深入 Claude Code 源码 + +> 以下基于 Claude Code 源码 `query.ts`(211, 1054-1060, 1411-1482 行)、`services/toolUseSummary/toolUseSummaryGenerator.ts`(L15 prompt 文本)、`LocalShellTask.tsx`(L24-25 常量, L59-98 看门狗逻辑)、`messageQueueManager.ts`(通知队列)、`utils/task/framework.ts`(L267 `enqueueTaskNotification`)的完整分析。 + +### 一、pendingToolUseSummary:Haiku 后台生成 + +Claude Code 在每批工具执行完后,启动一个 Haiku side-query 生成工具使用摘要。发起代码在 `query.ts:1411-1482`,prompt 文本定义在 `services/toolUseSummary/toolUseSummaryGenerator.ts:15`(变量名 `TOOL_USE_SUMMARY_SYSTEM_PROMPT`)。提示是 "Write a short summary label... think git-commit-subject, not sentence",过去时态,约 30 字符。 + +Haiku 摘要(~1s)在主模型流式生成(5-30s)期间完成。下一轮开始前,把摘要 yield 出去。SDK 消费这些摘要做移动端进度展示。 + +### 二、线程模型:没有真正的线程 + +Claude Code 运行在 Node.js/Bun 单线程事件循环中。"后台"只是 "不 await"。`ShellCommand.background(taskId)` 把 stdout/stderr 重定向到文件,让进程独立运行。 + +### 三、七种后台任务类型 + +Claude Code 定义了 7 种后台任务(`Task.ts:7-13`):`local_bash`、`local_agent`、`remote_agent`、`in_process_teammate`、`local_workflow`、`monitor_mcp`、`dream`。每种有自己的注册、生命周期和通知机制。 + +### 四、通知注入:命令队列 + +后台任务完成后通过 `enqueueTaskNotification`(`utils/task/framework.ts:267`)或 `enqueuePendingNotification`(`messageQueueManager.ts`)入队到共享命令队列。通知格式是结构化的 XML: + +```xml + + completed + Background command "npm test" completed (exit code 0) + +``` + +优先级分 `next` > `later`(`messageQueueManager.ts`)。后台任务默认 `later`(不阻塞用户输入)。消费点在 `query.ts:1566-1593`。 + +### 五、停滞看门狗 + +后台 bash 任务有一个看门狗(`LocalShellTask.tsx` L24-25 常量, L59-98 逻辑),定期检查输出是否停滞,45 秒无增长后检测交互式提示(`(y/n)` 等),防止后台任务卡在无人响应的交互式对话框。 + +### 六、并发限制 + +前台工具调用:`CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY`(默认 10 个并发安全工具)。后台 bash 任务:没有硬性限制,它们是独立的子进程。 + +
+ + diff --git a/s13_background_tasks/images/background-tasks-overview.en.svg b/s13_background_tasks/images/background-tasks-overview.en.svg deleted file mode 100644 index 830ffb90e..000000000 --- a/s13_background_tasks/images/background-tasks-overview.en.svg +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - - Background Tasks — Slow ops to background, Agent keeps thinking - - - - s12 retained - - s13 new - - - - messages - - - - - prompt + cache - (s10-s12) - - - - - LLM call - (s11 retry) - - - - - - TOOL DISPATCH - fast? → sync execute (s12) - slow? → run_in_background ★ - - - - - - - Background thread execution - run_in_background(tool_use_id, fn, *args) - threading.Thread(target=worker, daemon=True) - result → background_results[id] (threading.Lock protected) - - - - slow op - - - - Notification injection - collect_background_results() check each turn - completed → tool_result inject into messages - pending → "[Running in background...]" placeholder - - - - - - - Heuristic: - - fast - read_file · git status · glob - - slow - npm install · pip install · pytest (timeout > 30s) - - - - s12 sync blocking - - think - - waiting for bash 3min... - - continue - Total ~3min, Agent idled for 3 minutes - - - s13 background execution - - think - - keep doing other work - - notification: result ready - Total ~3min, but Agent wasn't idle - \ No newline at end of file diff --git a/s13_background_tasks/images/background-tasks-overview.ja.svg b/s13_background_tasks/images/background-tasks-overview.ja.svg deleted file mode 100644 index 207eec47b..000000000 --- a/s13_background_tasks/images/background-tasks-overview.ja.svg +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - - Background Tasks — 遅い操作はバックグラウンドへ、Agent は考え続ける - - - - s12 維持 - - s13 新規 - - - - messages - - - - - prompt + cache - (s10-s12) - - - - - LLM call - (s11 retry) - - - - - - TOOL DISPATCH - fast? → 同期実行 (s12) - slow? → run_in_background ★ - - - - - - - バックグラウンドスレッド実行 - run_in_background(tool_use_id, fn, *args) - threading.Thread(target=worker, daemon=True) - 結果 → background_results[id] (threading.Lock で保護) - - - - slow op - - - - 通知注入 - collect_background_results() 毎ターン確認 - 完了 → tool_result を messages に注入 - 未完了 → "[Running in background...]" プレースホルダー - - - - - - - ヒューリスティック判定: - - fast - read_file · git status · glob - - slow - npm install · pip install · pytest (timeout > 30s) - - - - s12 同期ブロッキング - - 思考 - - bash 待ち 3分... - - 継続 - 合計 ~3分、Agent は3分間待機 - - - s13 バックグラウンド実行 - - 思考 - - 別の作業を継続 - - 通知: 結果完了 - 合計 ~3分、Agent は遊ばず - diff --git a/s13_background_tasks/images/background-tasks-overview.svg b/s13_background_tasks/images/background-tasks-overview.svg index ac6dff0ac..6a7078461 100644 --- a/s13_background_tasks/images/background-tasks-overview.svg +++ b/s13_background_tasks/images/background-tasks-overview.svg @@ -1,105 +1,140 @@ - + - - - - - + + - - + + - + + - - - Background Tasks — 慢操作丢后台,Agent 继续思考 + Background Tasks + Slow operations go to background threads; agent continues processing - - - s12 保留 - - s13 新增 + - - - messages + + + messages - + + - - prompt + cache - (s10-s12) + + + prompt + cache - + + - - LLM call - (s11 retry) + + + LLM call - + + - - - TOOL DISPATCH - fast? → 同步执行 (s12) - slow? → run_in_background ★ + + + TOOL DISPATCH + fast → sync execute + slow → run_in_background - - + + + + next turn - - - 后台线程执行 - run_in_background(tool_use_id, fn, *args) - threading.Thread(target=worker, daemon=True) - 结果 → background_results[id] (threading.Lock 保护) + - - - slow op + + + + slow op - - - 通知注入 - collect_background_results() 每轮检查 - 已完成 → tool_result 注入 messages - 未完成 → "[Running in background...]" 占位 + + + Background Thread Execution + + + start_background_task(block) + Thread(target=worker, daemon=True) + result → background_results[id] - - - - - 启发式判断: - - fast - read_file · git status · glob - - slow - npm install · pip install · pytest (timeout > 30s) - - - - s12 同步阻塞 - - 思考 - - 等 bash 3 分钟... - - 继续 - 总耗时 ~3min,Agent 空 etc. 等了 3 分钟 - - - s13 后台执行 - - 思考 - - 继续做别的事 - - 通知: 结果来了 - 总耗时 ~3min,但 Agent 没闲着 - + + done + + + + Notification Injection + + + collect_background_results() + done → inject tool_result + pending → "[Running...]" placeholder + + + + + + inject into messages + + + + Heuristic: + + + + fast + read_file · git status · glob + + + + slow + npm install · pip install · pytest (>30s) + + + + + + Sync Blocking + Agent waits for slow operation + + + + think + + + + waiting... 3 min idle + + + + resume + + Total ~3 min, agent idle the entire time + + + + Background Execution + Agent continues while task runs + + + + think + + + + do other work + + + + result arrives + + Total ~3 min, but agent stayed productive + \ No newline at end of file diff --git a/s14_cron_scheduler/README.en.md b/s14_cron_scheduler/README.en.md deleted file mode 100644 index 34ca4c914..000000000 --- a/s14_cron_scheduler/README.en.md +++ /dev/null @@ -1,305 +0,0 @@ -# s14: Cron Scheduler — Producing Work on a Schedule - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20 -> *"Produce work on a schedule, decouple scheduling from execution"* — Cron scheduling, durable or session-level. -> -> **Harness Layer**: Scheduling — Independent thread checks time, queue delivers triggers. - ---- - -## The Problem - -An alarm clock doesn't need you to watch it. You set 7:00, it rings at 7:00 — you could be sleeping, showering, cooking, it rings regardless. - -s13 lets the agent run slow operations in the background, but every operation is still triggered manually. You say something, the agent acts. "Run tests every morning at 9am", "Check CI status every 30 minutes" — these recurring tasks shouldn't need a human to push them each time. - ---- - -## The Solution - -![Cron Scheduler Overview](images/cron-scheduler-overview.en.svg) - -Teaching code carries forward S13's simplified task system, background execution, and prompt assembly; to stay focused on the scheduler, it omits full error recovery, memory, and skill systems. Added: an independent cron scheduler thread that polls every second, queues matching jobs into `cron_queue`, and a queue processor that delivers them when the agent is idle. - -Manual vs Scheduled: - -| | Manual (s13) | Scheduled (s14) | -|---|---|---| -| Triggered by | User input | Scheduler thread | -| Trigger timing | Anytime | Specified by cron expression | -| Human involvement | Yes | No (scheduler auto-enqueues, idle agent auto-delivers) | -| Persistence | — | Durable survives restart | - ---- - -## How It Works - -### Four-Layer Model - -Cron scheduling has four layers: - -1. **Scheduler**: daemon thread, polls every second, checks if it's time -2. **Queue**: `cron_queue`, scheduler writes fired jobs -3. **Queue Processor**: sees non-empty queue and idle agent, starts one agent_loop turn -4. **Consumer**: agent_loop consumes queue and injects into messages - -The teaching version implements a minimal queue processor: `agent_lock` tells whether the agent is idle, and queued cron work is delivered automatically. Real CC's `useQueueProcessor.ts` also handles UI blocking, queue priority, and different message modes. - -### CronJob: Data Structure - -Each cron task is a `CronJob` object: - -```python -@dataclass -class CronJob: - id: str - cron: str # "0 9 * * *" (5-field cron expression) - prompt: str # Message injected to the agent when fired - recurring: bool # True=recurring, False=one-shot - durable: bool # True=write to disk, survives sessions -``` - -Cron expression, 5 fields, used by Unix for 50 years: - -``` -min hour dom month dow - * * * * * Every minute - 0 9 * * * Every day at 9:00 -*/5 * * * * Every 5 minutes - 0 9 * * 1-5 Weekdays at 9:00 -``` - -Supports `*`, `*/N`, `N`, `N-M`, `N,M,...`. - -### cron_matches: 5-Field Matching - -Standard cron semantics: minute, hour, month must all match; day-of-month (DOM) and day-of-week (DOW) use OR when both are constrained: - -```python -def cron_matches(cron_expr: str, dt: datetime) -> bool: - fields = cron_expr.strip().split() - if len(fields) != 5: - return False - minute, hour, dom, month, dow = fields - dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0 - - m = _cron_field_matches(minute, dt.minute) - h = _cron_field_matches(hour, dt.hour) - dom_ok = _cron_field_matches(dom, dt.day) - month_ok = _cron_field_matches(month, dt.month) - dow_ok = _cron_field_matches(dow, dow_val) - - if not (m and h and month_ok): - return False - # DOM and DOW: both constrained → either matching is enough (OR) - dom_unconstrained = dom == "*" - dow_unconstrained = dow == "*" - if dom_unconstrained and dow_unconstrained: - return True - if dom_unconstrained: - return dow_ok - if dow_unconstrained: - return dom_ok - return dom_ok or dow_ok -``` - -### Independent Scheduler Thread: 1-Second Polling - -The scheduler runs in an independent daemon thread, not dependent on whether agent_loop is executing. Individual job errors don't kill the entire thread: - -```python -def cron_scheduler_loop(): - while True: - time.sleep(1) - now = datetime.now() - minute_marker = now.strftime("%Y-%m-%d %H:%M") - with cron_lock: - for job in list(scheduled_jobs.values()): - try: - if cron_matches(job.cron, now): - if _last_fired.get(job.id) != minute_marker: - cron_queue.append(job) - _last_fired[job.id] = minute_marker - if not job.recurring: - scheduled_jobs.pop(job.id, None) - if job.durable: - save_durable_jobs() - except Exception as e: - print(f"[cron error] {job.id}: {e}") -``` - -Key design: -- **Independent of agent_loop**: scheduler checks time in background even when agent_loop isn't running -- **Date-aware minute_marker**: uses `"YYYY-MM-DD HH:MM"` to prevent same-minute double-fire while not skipping on the next day -- **Per-job try/except**: one bad job doesn't crash the scheduler thread -- **One-shot jobs**: auto-removed from scheduled_jobs after firing - -### Queue Processor + agent_loop: Delivery - -The queue processor does not check time. It only starts a turn when queued work exists and the agent is idle: - -```python -def queue_processor_loop(): - while True: - time.sleep(0.2) - if not has_cron_queue(): - continue - if not agent_lock.acquire(blocking=False): - continue - try: - if has_cron_queue(): - run_agent_turn_locked() - finally: - agent_lock.release() -``` - -agent_loop also doesn't check time. It only takes fired tasks from `cron_queue` and injects them into messages: - -```python -fired = consume_cron_queue() -for job in fired: - messages.append({"role": "user", - "content": f"[Scheduled] {job.prompt}"}) -``` - -Producer (scheduler thread), deliverer (queue processor), and consumer (agent_loop) are decoupled via `cron_queue`, `cron_lock`, and `agent_lock`. - -### Validation: Prevent Bad Cron from Killing the Scheduler - -`schedule_job` validates the cron expression before registering, returning an error for invalid input: - -```python -def schedule_job(cron, prompt, recurring=True, durable=True): - err = validate_cron(cron) - if err: - return err - # ... register job -``` - -Loading durable jobs from disk also skips invalid expressions, preventing a single bad task from breaking startup. - -### Durable vs Session-only - -- **Durable**: Task definition written to `.scheduled_tasks.json`. Loaded on agent restart. -- **Session-only**: In-memory only. Gone when the agent closes. - -> **Important caveat**: The cron scheduler must run inside the agent process. Process exits, scheduler stops. Durable only means the task definition survives restarts — next time the agent starts, the scheduler discovers "it should fire" and fires. If you need "run even when the app is closed", use system crontab or systemd timer. - -### Putting It Together - -``` -1. On startup: - load_durable_jobs() → restore durable tasks from .scheduled_tasks.json - Thread(cron_scheduler_loop, daemon=True).start() → scheduler begins polling - Thread(queue_processor_loop, daemon=True).start() → processor waits to deliver - -2. Register a task: - schedule_cron(cron="*/2 * * * *", prompt="run date", durable=True) - → CronJob written to scheduled_jobs + .scheduled_tasks.json - -3. Every 2 minutes: - Scheduler checks → cron_matches returns True → cron_queue.append(job) - → queue processor sees idle agent → agent_loop consume_cron_queue - → injects "[Scheduled] run date" - → LLM receives message, runs date command - -4. Process shutdown: - Scheduler thread stops (daemon=True) - .scheduled_tasks.json stays on disk - Next startup → load_durable_jobs → tasks restored -``` - ---- - -## Changes from s13 - -| Component | Before (s13) | After (s14) | -|-----------|-------------|-------------| -| Trigger method | User manual trigger | Scheduler thread auto-enqueues | -| New types | — | CronJob dataclass (id, cron, prompt, recurring, durable) | -| New functions | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop | -| New storage | — | .scheduled_tasks.json (durable) + memory (session-only) | -| Threads | Background execution thread | + Scheduler thread (daemon, 1s polling) + queue processor thread | -| Queue | background_results | + cron_queue (scheduler writes, queue processor delivers, agent_loop consumes) | -| Tools | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) | - ---- - -## Try It - -```sh -cd learn-claude-code -python s14_cron_scheduler/code.py -``` - -Try these prompts: - -1. `Schedule a task to print the current date every 2 minutes` -2. `List all cron jobs` -3. `Create a one-shot reminder in 1 minute to check the build status` -4. `Cancel the recurring job and verify with list_crons` - -What to observe: Is the scheduler thread running independently? Do cron tasks fire at the correct time? Without a new prompt, do you see `[queue processor]` and automatic execution? Is the durable job written to `.scheduled_tasks.json`? - ---- - -## What's Next - -One agent can do a lot now: plan, compress, background, schedule. But some tasks are too big for one agent. - -"Refactor the entire backend" — overhaul auth, database layer, API routes, and tests. One agent's attention is limited. This needs a team. - -s15 Agent Teams → One agent isn't enough, form a team. Persistent teammates + async inboxes. - -
-Deep Dive into CC Source - -> The following is a complete analysis based on CC source code `CronCreateTool.ts`, `cronScheduler.ts`, `cron.ts`, `cronTasks.ts`, `cronTasksLock.ts`, `useScheduledTasks.ts` (139 lines). - -### 1. Three Cron Tools - -CC exposes three cron tools to the model: `CronCreate`, `CronDelete`, `CronList`. All controlled by compile-time gate `feature('AGENT_TRIGGERS')` and runtime GrowthBook flag `tengu_kairos_cron`. There's also a `CLAUDE_CODE_DISABLE_CRON` env var for local override. - -### 2. Storage: `.claude/scheduled_tasks.json` - -```json -{ "tasks": [{ "id": "abc12345", "cron": "0 9 * * *", "prompt": "...", "recurring": true, "durable": true, "createdAt": 1714567890000 }] } -``` - -Durable tasks write to disk; session-only tasks live in `STATE.sessionCronTasks` memory array (lost on process restart). A `.scheduled_tasks.lock` file prevents duplicate firing across multiple sessions of the same project. - -### 3. Scheduler: 1-Second Polling - -`cronScheduler.ts` checks every second (`CHECK_INTERVAL_MS = 1000`). Whoever holds the lock triggers file tasks; all sessions trigger session-only tasks. A `chokidar` file watcher monitors `scheduled_tasks.json` changes. - -### 4. Cron Expression: Standard 5 Fields - -Minute hour day month weekday. Supports `*`, `*/N`, `N`, `N-M`, `N-M/S`, `N,M,...`. Doesn't support `L`, `W`, `?`. All times interpreted in local timezone. Day-of-month and day-of-week use OR semantics when both are constrained. - -### 5. Jitter (Thundering Herd Prevention) - -- Recurring tasks: trigger delay up to 10% of period (max 15 min), deterministic hash based on task ID -- One-shot tasks: up to 90s early when firing time falls on `:00` or `:30` -- Jitter config adjustable via GrowthBook, refreshed every 60 seconds - -### 6. Auto-Expiration - -Recurring tasks auto-expire after 7 days (configurable, max 30 days). Fire one last time before expiry, then auto-delete. - -### 7. Job Limit - -`MAX_JOBS = 50` (`CronCreateTool.ts:25`). Returns error when exceeded: "Too many scheduled jobs (max 50). Cancel one first." - -### 8. Trigger Injection - -After firing, enqueued via `enqueuePendingNotification()` with `priority: 'later'` into the command queue. Tagged `workload: WORKLOAD_CRON` — API serves cron-initiated requests at lower QoS when capacity is tight. - -### 9. Queue Processor: Automatic Delivery - -Real CC auto-triggers processing through `useQueueProcessor.ts:48-60` when no query is active, UI isn't blocked, and queue is non-empty. `queueProcessor.ts:52-87` dispatches commands to `handlePromptSubmit()` by queue priority. The teaching version keeps the core behavior with `queue_processor_loop`: when queued work exists and the agent is idle, it starts one agent_loop turn automatically. - -
- - diff --git a/s14_cron_scheduler/README.ja.md b/s14_cron_scheduler/README.ja.md index 281f2f33d..2d2b69f05 100644 --- a/s14_cron_scheduler/README.ja.md +++ b/s14_cron_scheduler/README.ja.md @@ -1,6 +1,6 @@ # s14: Cron Scheduler — スケジュールに従って作業を生産 -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20 > *"スケジュールに従って作業を生産、スケジューリングと実行を分離"* — cron スケジューリング、永続またはセッションレベル。 @@ -19,7 +19,7 @@ s13 で Agent は遅い操作をバックグラウンドで実行できるよう ## ソリューション -![Cron Scheduler Overview](images/cron-scheduler-overview.ja.svg) +![Cron Scheduler Overview](images/cron-scheduler-overview.svg) 教学版は S13 の簡易タスクシステム、バックグラウンド実行、プロンプト組み立てを踏襲。スケジューラに集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。追加:独立した cron スケジューラスレッド、1 秒ごとにポーリング、時間が来たらタスクを `cron_queue` に投入し、queue processor が Agent のアイドル時に自動配信。 @@ -45,7 +45,7 @@ cron スケジューリングは 4 層に分かれる: 3. **Queue Processor**:キューが空でなく Agent がアイドルなら、一回の agent_loop を開始 4. **Consumer**:agent_loop がキューから消費、messages に注入 -教学版は最小の queue processor を実装する。`agent_lock` で Agent がアイドルかを判定し、キューに入った cron 作業を自動配信する。実際の CC の `useQueueProcessor.ts` はさらに UI ブロック、キュープライオリティ、メッセージモードを扱う。 +教学版は最小の queue processor を実装する。`agent_lock` で Agent がアイドルかを判定し、キューに入った cron 作業を自動配信する。実際の Claude Code の `useQueueProcessor.ts` はさらに UI ブロック、キュープライオリティ、メッセージモードを扱う。 ### CronJob: データ構造 @@ -247,20 +247,18 @@ python s14_cron_scheduler/code.py ## 次の章 -一つの Agent でできることは増えた。計画、圧縮、バックグラウンド、スケジューリング。しかし、一部のタスクは一つの Agent では大きすぎる。 +一つの Agent で計画、圧縮、バックグラウンド実行、スケジューリングができるようになった。しかし「バックエンド全体をリファクタリング」のように、認証モジュール、データベース層、API ルート、テストを全面的に刷新するタスクは、一つの Agent のコンテキストウィンドウに収まらない。 -「バックエンド全体をリファクタリング」、認証モジュール、データベース層、API ルート、テストを全面的に刷新。一つの Agent の注意力には限界がある。これにはチームが必要だ。 - -s15 Agent Teams → 一人の Agent では足りない、チームを組もう。永続的なチームメイト + 非同期受信箱。 +s15 Agent Teams → 複数 Agent の協調、永続的なチームメイト + 非同期受信箱。
-CC ソースコード深掘り +Claude Code ソースコード深掘り -> 以下は CC ソースコード `CronCreateTool.ts`、`cronScheduler.ts`、`cron.ts`、`cronTasks.ts`、`cronTasksLock.ts`、`useScheduledTasks.ts`(139 行)の完全分析に基づく。 +> 以下は Claude Code ソースコード `CronCreateTool.ts`、`cronScheduler.ts`、`cron.ts`、`cronTasks.ts`、`cronTasksLock.ts`、`useScheduledTasks.ts`(139 行)の完全分析に基づく。 ### 一、3 つの Cron ツール -CC はモデルに 3 つの cron ツールを公開:`CronCreate`、`CronDelete`、`CronList`。すべてコンパイル時ゲート `feature('AGENT_TRIGGERS')` とランタイム GrowthBook フラグ `tengu_kairos_cron` で制御。`CLAUDE_CODE_DISABLE_CRON` 環境変数でローカル上書きも可能。 +Claude Code はモデルに 3 つの cron ツールを公開:`CronCreate`、`CronDelete`、`CronList`。すべてコンパイル時ゲート `feature('AGENT_TRIGGERS')` とランタイム GrowthBook フラグ `tengu_kairos_cron` で制御。`CLAUDE_CODE_DISABLE_CRON` 環境変数でローカル上書きも可能。 ### 二、ストレージ:`.claude/scheduled_tasks.json` @@ -298,7 +296,7 @@ durable タスクはディスクに書き込み。session-only タスクは `STA ### 九、Queue Processor:自動配信 -実際の CC は `useQueueProcessor.ts:48-60` により、アクティブな query がなく、UI がブロックされておらず、キューが空でない場合に自動的に処理をトリガーする。`queueProcessor.ts:52-87` がキュープライオリティに従ってコマンドを `handlePromptSubmit()` にディスパッチ。教学版は `queue_processor_loop` で核心動作を保つ:キューに作業があり Agent がアイドルなら、自動的に一回の agent_loop を開始する。 +実際の Claude Code は `useQueueProcessor.ts:48-60` により、アクティブな query がなく、UI がブロックされておらず、キューが空でない場合に自動的に処理をトリガーする。`queueProcessor.ts:52-87` がキュープライオリティに従ってコマンドを `handlePromptSubmit()` にディスパッチ。教学版は `queue_processor_loop` で核心動作を保つ:キューに作業があり Agent がアイドルなら、自動的に一回の agent_loop を開始する。
diff --git a/s14_cron_scheduler/README.md b/s14_cron_scheduler/README.md index 492ba8a9c..e0e4144d5 100644 --- a/s14_cron_scheduler/README.md +++ b/s14_cron_scheduler/README.md @@ -1,81 +1,81 @@ -# s14: Cron Scheduler — 按时间表生产工作 +# s14: Cron Scheduler — Producing Work on a Schedule -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20 -> *"按时间表生产工作, 调度与执行解耦"* — cron 调度, 持久化或会话级。 +> *"Produce work on a schedule, decouple scheduling from execution"* — Cron scheduling, durable or session-level. > -> **Harness 层**: 调度 — 独立线程判断时间, 队列传递触发。 +> **Harness Layer**: Scheduling — Independent thread checks time, queue delivers triggers. --- -## 问题 +## The Problem -闹钟不需要你盯着它才会响。你设好 7:00,到点它自己响,你在睡觉、在洗澡、在做饭,它都照响不误。 +An alarm clock doesn't need you to watch it. You set 7:00, it rings at 7:00. You could be sleeping, showering, cooking, it rings regardless. -s13 让 Agent 能后台执行慢操作,但所有操作仍然是你手动触发的。你说一句,Agent 动一下。"每天早上 9 点跑测试"、"每 30 分钟检查 CI 状态",这些周期性任务不该需要人每次来推。 +s13 lets the agent run slow operations in the background, but every operation is still triggered manually. You say something, the agent acts. "Run tests every morning at 9am", "Check CI status every 30 minutes": these recurring tasks shouldn't need a human to push them each time. --- -## 解决方案 +## The Solution ![Cron Scheduler Overview](images/cron-scheduler-overview.svg) -教学代码沿用 S13 的简化任务系统、后台执行和 prompt 组装;为了聚焦调度器,省略完整错误恢复、记忆和技能系统。新增:独立的 cron 调度线程,每秒检查一次,时间到了把任务塞进 `cron_queue`;再由 queue processor 在 Agent 空闲时自动交付。 +Teaching code carries forward S13's simplified task system, background execution, and prompt assembly; to stay focused on the scheduler, it omits full error recovery, memory, and skill systems. Added: an independent cron scheduler thread that polls every second, queues matching jobs into `cron_queue`, and a queue processor that delivers them when the agent is idle. -手动 vs 定时: +Manual vs Scheduled: -| | 手动触发 (s13) | 定时触发 (s14) | +| | Manual (s13) | Scheduled (s14) | |---|---|---| -| 触发者 | 用户输入 | 调度线程 | -| 触发时机 | 随时 | cron 表达式指定 | -| 需要人参与 | 是 | 否(调度器自动入队,空闲时自动交付) | -| 持久性 | — | durable 跨重启 | +| Triggered by | User input | Scheduler thread | +| Trigger timing | Anytime | Specified by cron expression | +| Human involvement | Yes | No (scheduler auto-enqueues, idle agent auto-delivers) | +| Persistence | — | Durable survives restart | --- -## 工作原理 +## How It Works -### 四层模型 +### Four-Layer Model -Cron 调度分四层: +Cron scheduling has four layers: -1. **Scheduler**:daemon 线程,每秒轮询,判断时间到了没有 -2. **Queue**:`cron_queue`,调度线程写入已触发任务 -3. **Queue Processor**:发现队列非空且 Agent 空闲,启动一轮 agent_loop -4. **Consumer**:agent_loop 从队列消费,注入到 messages +1. **Scheduler**: daemon thread, polls every second, checks if it's time +2. **Queue**: `cron_queue`, scheduler writes fired jobs +3. **Queue Processor**: sees non-empty queue and idle agent, starts one agent_loop turn +4. **Consumer**: agent_loop consumes queue and injects into messages -教学版实现的是最小 queue processor:用 `agent_lock` 判断 Agent 是否空闲,空闲时自动交付定时任务。真实 CC 的 `useQueueProcessor.ts` 还会处理 UI 阻塞、队列优先级和不同消息模式。 +The teaching version implements a minimal queue processor: `agent_lock` tells whether the agent is idle, and queued cron work is delivered automatically. Real Claude Code's `useQueueProcessor.ts` also handles UI blocking, queue priority, and different message modes. -### CronJob: 数据结构 +### CronJob: Data Structure -每个 cron 任务是一个 `CronJob` 对象: +Each cron task is a `CronJob` object: ```python @dataclass class CronJob: id: str - cron: str # "0 9 * * *" (五段式 cron 表达式) - prompt: str # 触发时注入给 Agent 的消息 - recurring: bool # True=周期性,False=一次性 - durable: bool # True=写磁盘,跨会话保留 + cron: str # "0 9 * * *" (5-field cron expression) + prompt: str # Message injected to the agent when fired + recurring: bool # True=recurring, False=one-shot + durable: bool # True=write to disk, survives sessions ``` -Cron 表达式,五段式,Unix 用了 50 年: +Cron expression, 5 fields, used by Unix for 50 years: ``` -分钟 小时 日 月 星期 - * * * * * 每分钟 - 0 9 * * * 每天早上 9:00 - */5 * * * * 每 5 分钟 - 0 9 * * 1-5 工作日早上 9:00 +min hour dom month dow + * * * * * Every minute + 0 9 * * * Every day at 9:00 +*/5 * * * * Every 5 minutes + 0 9 * * 1-5 Weekdays at 9:00 ``` -支持 `*`、`*/N`、`N`、`N-M`、`N,M,...`。 +Supports `*`, `*/N`, `N`, `N-M`, `N,M,...`. -### cron_matches: 五段式匹配 +### cron_matches: 5-Field Matching -标准 cron 语义:分钟、小时、月必须全部匹配;日(DOM)和星期(DOW)同时被约束时任一匹配即可(OR): +Standard cron semantics: minute, hour, month must all match; day-of-month (DOM) and day-of-week (DOW) use OR when both are constrained: ```python def cron_matches(cron_expr: str, dt: datetime) -> bool: @@ -105,9 +105,9 @@ def cron_matches(cron_expr: str, dt: datetime) -> bool: return dom_ok or dow_ok ``` -### 独立调度线程: 每秒轮询 +### Independent Scheduler Thread: 1-Second Polling -调度器跑在独立的 daemon 线程里,不依赖 agent_loop 是否在执行。单个 job 异常不会杀掉整个线程: +The scheduler runs in an independent daemon thread, not dependent on whether agent_loop is executing. Individual job errors don't kill the entire thread: ```python def cron_scheduler_loop(): @@ -130,15 +130,15 @@ def cron_scheduler_loop(): print(f"[cron error] {job.id}: {e}") ``` -关键设计: -- **独立于 agent_loop**:即使 agent_loop 没在跑,调度器也在后台检查时间 -- **date-aware minute_marker**:用 `"YYYY-MM-DD HH:MM"` 防止同一分钟重复触发,同时不会在第二天跳过 -- **单 job try/except**:一个坏 job 不会拖垮整个调度线程 -- **一次性任务**:触发后自动从 scheduled_jobs 里删除 +Key design: +- **Independent of agent_loop**: scheduler checks time in background even when agent_loop isn't running +- **Date-aware minute_marker**: uses `"YYYY-MM-DD HH:MM"` to prevent same-minute double-fire while not skipping on the next day +- **Per-job try/except**: one bad job doesn't crash the scheduler thread +- **One-shot jobs**: auto-removed from scheduled_jobs after firing -### Queue Processor + agent_loop: 交付端 +### Queue Processor + agent_loop: Delivery -queue processor 不检查时间,只负责在队列有任务且 Agent 空闲时拉起一轮执行: +The queue processor does not check time. It only starts a turn when queued work exists and the agent is idle: ```python def queue_processor_loop(): @@ -155,7 +155,7 @@ def queue_processor_loop(): agent_lock.release() ``` -agent_loop 也不负责检查时间,它只从 `cron_queue` 里拿已触发的任务,注入到 messages 里: +agent_loop also doesn't check time. It only takes fired tasks from `cron_queue` and injects them into messages: ```python fired = consume_cron_queue() @@ -164,11 +164,11 @@ for job in fired: "content": f"[Scheduled] {job.prompt}"}) ``` -生产者(调度线程)、交付者(queue processor)和消费者(agent_loop)通过 `cron_queue`、`cron_lock`、`agent_lock` 解耦。 +Producer (scheduler thread), deliverer (queue processor), and consumer (agent_loop) are decoupled via `cron_queue`, `cron_lock`, and `agent_lock`. -### 校验:防止坏 cron 杀掉调度器 +### Validation: Prevent Bad Cron from Killing the Scheduler -`schedule_job` 在注册前校验 cron 表达式,非法的直接返回错误: +`schedule_job` validates the cron expression before registering, returning an error for invalid input: ```python def schedule_job(cron, prompt, recurring=True, durable=True): @@ -178,127 +178,125 @@ def schedule_job(cron, prompt, recurring=True, durable=True): # ... register job ``` -从磁盘加载 durable job 时也会跳过非法表达式,避免单个坏任务拖垮启动。 +Loading durable jobs from disk also skips invalid expressions, preventing a single bad task from breaking startup. ### Durable vs Session-only -- **Durable**:任务定义写进 `.scheduled_tasks.json`。Agent 重启后加载文件,恢复任务。 -- **Session-only**:只在内存里。Agent 关闭就没了。 +- **Durable**: Task definition written to `.scheduled_tasks.json`. Loaded on agent restart. +- **Session-only**: In-memory only. Gone when the agent closes. -> **重要前提**:cron 调度器必须在 Agent 进程内跑。进程关闭,调度也停。Durable 只意味着任务定义跨重启保留,下次 Agent 启动时调度器才会发现"该触发了"并触发。如果需要"即使应用关闭也能定时跑",请用系统 crontab 或 systemd timer。 +> **Important caveat**: The cron scheduler must run inside the agent process. Process exits, scheduler stops. Durable only means the task definition survives restarts. Next time the agent starts, the scheduler checks whether a job is overdue and fires it. If you need "run even when the app is closed", use system crontab or systemd timer. -### 合起来跑 +### Putting It Together ``` -1. 启动时: - load_durable_jobs() → 从 .scheduled_tasks.json 恢复持久化任务 - Thread(cron_scheduler_loop, daemon=True).start() → 调度线程开始轮询 - Thread(queue_processor_loop, daemon=True).start() → 队列处理器等待交付 +1. On startup: + load_durable_jobs() → restore durable tasks from .scheduled_tasks.json + Thread(cron_scheduler_loop, daemon=True).start() → scheduler begins polling + Thread(queue_processor_loop, daemon=True).start() → processor waits to deliver -2. 注册任务: +2. Register a task: schedule_cron(cron="*/2 * * * *", prompt="run date", durable=True) - → CronJob 写入 scheduled_jobs + .scheduled_tasks.json - -3. 每 2 分钟: - 调度线程检查 → cron_matches 返回 True → cron_queue.append(job) - → queue processor 发现 Agent 空闲 → agent_loop consume_cron_queue - → 注入 "[Scheduled] run date" - → LLM 收到消息,执行 date 命令 - -4. 关闭进程: - 调度线程跟着停(daemon=True) - .scheduled_tasks.json 还在磁盘上 - 下次启动 → load_durable_jobs → 任务恢复 + → CronJob written to scheduled_jobs + .scheduled_tasks.json + +3. Every 2 minutes: + Scheduler checks → cron_matches returns True → cron_queue.append(job) + → queue processor sees idle agent → agent_loop consume_cron_queue + → injects "[Scheduled] run date" + → LLM receives message, runs date command + +4. Process shutdown: + Scheduler thread stops (daemon=True) + .scheduled_tasks.json stays on disk + Next startup → load_durable_jobs → tasks restored ``` --- -## 相对 s13 的变更 +## Changes from s13 -| 组件 | 之前 (s13) | 之后 (s14) | -|------|-----------|-----------| -| 触发方式 | 用户手动触发 | 调度线程自动入队 | -| 新类型 | — | CronJob dataclass (id, cron, prompt, recurring, durable) | -| 新函数 | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop | -| 新存储 | — | .scheduled_tasks.json (durable) + 内存 (session-only) | -| 线程 | 后台执行线程 | + 调度线程 (daemon, 1s 轮询) + queue processor 线程 | -| 队列 | background_results | + cron_queue (调度线程写, queue processor 交付, agent_loop 消费) | -| 工具 | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) | +| Component | Before (s13) | After (s14) | +|-----------|-------------|-------------| +| Trigger method | User manual trigger | Scheduler thread auto-enqueues | +| New types | — | CronJob dataclass (id, cron, prompt, recurring, durable) | +| New functions | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop | +| New storage | — | .scheduled_tasks.json (durable) + memory (session-only) | +| Threads | Background execution thread | + Scheduler thread (daemon, 1s polling) + queue processor thread | +| Queue | background_results | + cron_queue (scheduler writes, queue processor delivers, agent_loop consumes) | +| Tools | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) | --- -## 试一下 +## Try It ```sh cd learn-claude-code python s14_cron_scheduler/code.py ``` -试试这些 prompt: +Try these prompts: 1. `Schedule a task to print the current date every 2 minutes` 2. `List all cron jobs` 3. `Create a one-shot reminder in 1 minute to check the build status` 4. `Cancel the recurring job and verify with list_crons` -观察重点:调度线程是否在独立运行?cron 任务是否在正确的时间点触发?不输入新 prompt 时,是否也出现 `[queue processor]` 并自动执行?durable job 是否写入了 `.scheduled_tasks.json`? +What to observe: Is the scheduler thread running independently? Do cron tasks fire at the correct time? Without a new prompt, do you see `[queue processor]` and automatic execution? Is the durable job written to `.scheduled_tasks.json`? --- -## 接下来 +## What's Next -一个 Agent 能做很多事了,能计划、能压缩、能后台、能定时。但有些任务太大了,不是一个 Agent 能搞定的。 +One agent can plan, compress, background, and schedule. But some tasks are too big: "refactor the entire backend" means overhauling auth, database layer, API routes, and tests. One agent's context window can't hold all of that. -"重构整个后端",把认证模块、数据库层、API 路由、测试全部翻新。一个 Agent 的注意力是有限的,这需要一个团队。 - -s15 Agent Teams → 一个 Agent 不够,组队吧。持久队友 + 异步收件箱。 +s15 Agent Teams → Multiple agents, persistent teammates + async inboxes.
-深入 CC 源码 +Deep Dive into Claude Code Source -> 以下基于 CC 源码 `CronCreateTool.ts`、`cronScheduler.ts`、`cron.ts`、`cronTasks.ts`、`cronTasksLock.ts`、`useScheduledTasks.ts`(139 行)的完整分析。 +> The following is a complete analysis based on Claude Code source code `CronCreateTool.ts`, `cronScheduler.ts`, `cron.ts`, `cronTasks.ts`, `cronTasksLock.ts`, `useScheduledTasks.ts` (139 lines). -### 一、三个 Cron 工具 +### 1. Three Cron Tools -CC 暴露了三个 cron 工具给模型:`CronCreate`、`CronDelete`、`CronList`。全部由编译时门控 `feature('AGENT_TRIGGERS')` 和运行时 GrowthBook 标志 `tengu_kairos_cron` 控制。还有一个 `CLAUDE_CODE_DISABLE_CRON` 环境变量做本地覆盖。 +Claude Code exposes three cron tools to the model: `CronCreate`, `CronDelete`, `CronList`. All controlled by compile-time gate `feature('AGENT_TRIGGERS')` and runtime GrowthBook flag `tengu_kairos_cron`. There's also a `CLAUDE_CODE_DISABLE_CRON` env var for local override. -### 二、存储:`.claude/scheduled_tasks.json` +### 2. Storage: `.claude/scheduled_tasks.json` ```json { "tasks": [{ "id": "abc12345", "cron": "0 9 * * *", "prompt": "...", "recurring": true, "durable": true, "createdAt": 1714567890000 }] } ``` -Durable 任务写磁盘;session-only 任务存于 `STATE.sessionCronTasks` 内存数组(进程重启丢失)。还有一个 `.scheduled_tasks.lock` 文件防止同项目的多个 session 重复触发。 +Durable tasks write to disk; session-only tasks live in `STATE.sessionCronTasks` memory array (lost on process restart). A `.scheduled_tasks.lock` file prevents duplicate firing across multiple sessions of the same project. -### 三、调度器:1 秒轮询 +### 3. Scheduler: 1-Second Polling -`cronScheduler.ts` 每秒检查一次(`CHECK_INTERVAL_MS = 1000`)。谁持有锁谁触发文件任务;所有 session 都触发仅 session 任务。还有一个 `chokidar` 文件观察者监视 `scheduled_tasks.json` 变更。 +`cronScheduler.ts` checks every second (`CHECK_INTERVAL_MS = 1000`). Whoever holds the lock triggers file tasks; all sessions trigger session-only tasks. A `chokidar` file watcher monitors `scheduled_tasks.json` changes. -### 四、Cron 表达式:标准 5 字段 +### 4. Cron Expression: Standard 5 Fields -分钟 小时 日 月 星期。支持 `*`、`*/N`、`N`、`N-M`、`N-M/S`、`N,M,...`。不支持 `L`、`W`、`?`。所有时间以本地时区解释。Day-of-month 和 day-of-week 同时约束时用 OR 语义。 +Minute hour day month weekday. Supports `*`, `*/N`, `N`, `N-M`, `N-M/S`, `N,M,...`. Doesn't support `L`, `W`, `?`. All times interpreted in local timezone. Day-of-month and day-of-week use OR semantics when both are constrained. -### 五、抖动(防惊群效应) +### 5. Jitter (Thundering Herd Prevention) -- 重复性任务:触发延迟最多可达期间的 10%(上限 15 分钟),基于任务 ID 的确定性哈希 -- 一次性任务:当触发时间落在 `:00` 或 `:30` 时,最多提前 90 秒触发 -- 抖动配置可通过 GrowthBook 实时调整,60 秒刷新一次 +- Recurring tasks: trigger delay up to 10% of period (max 15 min), deterministic hash based on task ID +- One-shot tasks: up to 90s early when firing time falls on `:00` or `:30` +- Jitter config adjustable via GrowthBook, refreshed every 60 seconds -### 六、自动过期 +### 6. Auto-Expiration -重复性任务 7 天后自动过期(可配置,上限 30 天)。过期前最后一次触发,触发后自动删除。 +Recurring tasks auto-expire after 7 days (configurable, max 30 days). Fire one last time before expiry, then auto-delete. -### 七、作业数上限 +### 7. Job Limit -`MAX_JOBS = 50`(`CronCreateTool.ts:25`)。超限时返回错误:"Too many scheduled jobs (max 50). Cancel one first." +`MAX_JOBS = 50` (`CronCreateTool.ts:25`). Returns error when exceeded: "Too many scheduled jobs (max 50). Cancel one first." -### 八、触发注入 +### 8. Trigger Injection -触发后通过 `enqueuePendingNotification()` 以 `priority: 'later'` 入队命令队列。标记 `workload: WORKLOAD_CRON`,API 在容量紧张时以更低的 QoS 为 cron 发起的请求服务。 +After firing, enqueued via `enqueuePendingNotification()` with `priority: 'later'` into the command queue. Tagged `workload: WORKLOAD_CRON` — API serves cron-initiated requests at lower QoS when capacity is tight. -### 九、Queue Processor:自动交付 +### 9. Queue Processor: Automatic Delivery -真实 CC 通过 `useQueueProcessor.ts:48-60` 在无 query、无阻塞 UI、队列非空时自动触发处理。`queueProcessor.ts:52-87` 按队列优先级把命令交给 `handlePromptSubmit()`。教学版用 `queue_processor_loop` 保留核心行为:队列有任务且 Agent 空闲时,自动启动一轮 agent_loop。 +Real Claude Code auto-triggers processing through `useQueueProcessor.ts:48-60` when no query is active, UI isn't blocked, and queue is non-empty. `queueProcessor.ts:52-87` dispatches commands to `handlePromptSubmit()` by queue priority. The teaching version keeps the core behavior with `queue_processor_loop`: when queued work exists and the agent is idle, it starts one agent_loop turn automatically.
diff --git a/s14_cron_scheduler/README.zh.md b/s14_cron_scheduler/README.zh.md new file mode 100644 index 000000000..1ee2041ea --- /dev/null +++ b/s14_cron_scheduler/README.zh.md @@ -0,0 +1,303 @@ +# s14: Cron Scheduler — 按时间表生产工作 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s12 → s13 → `s14` → [s15](../s15_agent_teams/) → s16 → ... → s20 +> *"按时间表生产工作, 调度与执行解耦"* — cron 调度, 持久化或会话级。 +> +> **Harness 层**: 调度 — 独立线程判断时间, 队列传递触发。 + +--- + +## 问题 + +闹钟不需要你盯着它才会响。你设好 7:00,到点它自己响,你在睡觉、在洗澡、在做饭,它都照响不误。 + +s13 让 Agent 能后台执行慢操作,但所有操作仍然是你手动触发的。你说一句,Agent 动一下。"每天早上 9 点跑测试"、"每 30 分钟检查 CI 状态",这些周期性任务不该需要人每次来推。 + +--- + +## 解决方案 + +![Cron Scheduler Overview](images/cron-scheduler-overview.svg) + +教学代码沿用 S13 的简化任务系统、后台执行和 prompt 组装;为了聚焦调度器,省略完整错误恢复、记忆和技能系统。新增:独立的 cron 调度线程,每秒检查一次,时间到了把任务塞进 `cron_queue`;再由 queue processor 在 Agent 空闲时自动交付。 + +手动 vs 定时: + +| | 手动触发 (s13) | 定时触发 (s14) | +|---|---|---| +| 触发者 | 用户输入 | 调度线程 | +| 触发时机 | 随时 | cron 表达式指定 | +| 需要人参与 | 是 | 否(调度器自动入队,空闲时自动交付) | +| 持久性 | — | durable 跨重启 | + +--- + +## 工作原理 + +### 四层模型 + +Cron 调度分四层: + +1. **Scheduler**:daemon 线程,每秒轮询,判断时间到了没有 +2. **Queue**:`cron_queue`,调度线程写入已触发任务 +3. **Queue Processor**:发现队列非空且 Agent 空闲,启动一轮 agent_loop +4. **Consumer**:agent_loop 从队列消费,注入到 messages + +教学版实现的是最小 queue processor:用 `agent_lock` 判断 Agent 是否空闲,空闲时自动交付定时任务。真实 Claude Code 的 `useQueueProcessor.ts` 还会处理 UI 阻塞、队列优先级和不同消息模式。 + +### CronJob: 数据结构 + +每个 cron 任务是一个 `CronJob` 对象: + +```python +@dataclass +class CronJob: + id: str + cron: str # "0 9 * * *" (五段式 cron 表达式) + prompt: str # 触发时注入给 Agent 的消息 + recurring: bool # True=周期性,False=一次性 + durable: bool # True=写磁盘,跨会话保留 +``` + +Cron 表达式,五段式,Unix 用了 50 年: + +``` +分钟 小时 日 月 星期 + * * * * * 每分钟 + 0 9 * * * 每天早上 9:00 + */5 * * * * 每 5 分钟 + 0 9 * * 1-5 工作日早上 9:00 +``` + +支持 `*`、`*/N`、`N`、`N-M`、`N,M,...`。 + +### cron_matches: 五段式匹配 + +标准 cron 语义:分钟、小时、月必须全部匹配;日(DOM)和星期(DOW)同时被约束时任一匹配即可(OR): + +```python +def cron_matches(cron_expr: str, dt: datetime) -> bool: + fields = cron_expr.strip().split() + if len(fields) != 5: + return False + minute, hour, dom, month, dow = fields + dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0 + + m = _cron_field_matches(minute, dt.minute) + h = _cron_field_matches(hour, dt.hour) + dom_ok = _cron_field_matches(dom, dt.day) + month_ok = _cron_field_matches(month, dt.month) + dow_ok = _cron_field_matches(dow, dow_val) + + if not (m and h and month_ok): + return False + # DOM and DOW: both constrained → either matching is enough (OR) + dom_unconstrained = dom == "*" + dow_unconstrained = dow == "*" + if dom_unconstrained and dow_unconstrained: + return True + if dom_unconstrained: + return dow_ok + if dow_unconstrained: + return dom_ok + return dom_ok or dow_ok +``` + +### 独立调度线程: 每秒轮询 + +调度器跑在独立的 daemon 线程里,不依赖 agent_loop 是否在执行。单个 job 异常不会杀掉整个线程: + +```python +def cron_scheduler_loop(): + while True: + time.sleep(1) + now = datetime.now() + minute_marker = now.strftime("%Y-%m-%d %H:%M") + with cron_lock: + for job in list(scheduled_jobs.values()): + try: + if cron_matches(job.cron, now): + if _last_fired.get(job.id) != minute_marker: + cron_queue.append(job) + _last_fired[job.id] = minute_marker + if not job.recurring: + scheduled_jobs.pop(job.id, None) + if job.durable: + save_durable_jobs() + except Exception as e: + print(f"[cron error] {job.id}: {e}") +``` + +关键设计: +- **独立于 agent_loop**:即使 agent_loop 没在跑,调度器也在后台检查时间 +- **date-aware minute_marker**:用 `"YYYY-MM-DD HH:MM"` 防止同一分钟重复触发,同时不会在第二天跳过 +- **单 job try/except**:一个坏 job 不会拖垮整个调度线程 +- **一次性任务**:触发后自动从 scheduled_jobs 里删除 + +### Queue Processor + agent_loop: 交付端 + +queue processor 不检查时间,只负责在队列有任务且 Agent 空闲时拉起一轮执行: + +```python +def queue_processor_loop(): + while True: + time.sleep(0.2) + if not has_cron_queue(): + continue + if not agent_lock.acquire(blocking=False): + continue + try: + if has_cron_queue(): + run_agent_turn_locked() + finally: + agent_lock.release() +``` + +agent_loop 也不负责检查时间,它只从 `cron_queue` 里拿已触发的任务,注入到 messages 里: + +```python +fired = consume_cron_queue() +for job in fired: + messages.append({"role": "user", + "content": f"[Scheduled] {job.prompt}"}) +``` + +生产者(调度线程)、交付者(queue processor)和消费者(agent_loop)通过 `cron_queue`、`cron_lock`、`agent_lock` 解耦。 + +### 校验:防止坏 cron 杀掉调度器 + +`schedule_job` 在注册前校验 cron 表达式,非法的直接返回错误: + +```python +def schedule_job(cron, prompt, recurring=True, durable=True): + err = validate_cron(cron) + if err: + return err + # ... register job +``` + +从磁盘加载 durable job 时也会跳过非法表达式,避免单个坏任务拖垮启动。 + +### Durable vs Session-only + +- **Durable**:任务定义写进 `.scheduled_tasks.json`。Agent 重启后加载文件,恢复任务。 +- **Session-only**:只在内存里。Agent 关闭就没了。 + +> **重要前提**:cron 调度器必须在 Agent 进程内跑。进程关闭,调度也停。Durable 只意味着任务定义跨重启保留,下次 Agent 启动时调度器才会发现"该触发了"并触发。如果需要"即使应用关闭也能定时跑",请用系统 crontab 或 systemd timer。 + +### 合起来跑 + +``` +1. 启动时: + load_durable_jobs() → 从 .scheduled_tasks.json 恢复持久化任务 + Thread(cron_scheduler_loop, daemon=True).start() → 调度线程开始轮询 + Thread(queue_processor_loop, daemon=True).start() → 队列处理器等待交付 + +2. 注册任务: + schedule_cron(cron="*/2 * * * *", prompt="run date", durable=True) + → CronJob 写入 scheduled_jobs + .scheduled_tasks.json + +3. 每 2 分钟: + 调度线程检查 → cron_matches 返回 True → cron_queue.append(job) + → queue processor 发现 Agent 空闲 → agent_loop consume_cron_queue + → 注入 "[Scheduled] run date" + → LLM 收到消息,执行 date 命令 + +4. 关闭进程: + 调度线程跟着停(daemon=True) + .scheduled_tasks.json 还在磁盘上 + 下次启动 → load_durable_jobs → 任务恢复 +``` + +--- + +## 相对 s13 的变更 + +| 组件 | 之前 (s13) | 之后 (s14) | +|------|-----------|-----------| +| 触发方式 | 用户手动触发 | 调度线程自动入队 | +| 新类型 | — | CronJob dataclass (id, cron, prompt, recurring, durable) | +| 新函数 | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop | +| 新存储 | — | .scheduled_tasks.json (durable) + 内存 (session-only) | +| 线程 | 后台执行线程 | + 调度线程 (daemon, 1s 轮询) + queue processor 线程 | +| 队列 | background_results | + cron_queue (调度线程写, queue processor 交付, agent_loop 消费) | +| 工具 | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s14_cron_scheduler/code.py +``` + +试试这些 prompt: + +1. `Schedule a task to print the current date every 2 minutes` +2. `List all cron jobs` +3. `Create a one-shot reminder in 1 minute to check the build status` +4. `Cancel the recurring job and verify with list_crons` + +观察重点:调度线程是否在独立运行?cron 任务是否在正确的时间点触发?不输入新 prompt 时,是否也出现 `[queue processor]` 并自动执行?durable job 是否写入了 `.scheduled_tasks.json`? + +--- + +## 接下来 + +一个 Agent 能计划、压缩、后台执行、定时调度。但有些任务太大了,比如"重构整个后端",认证模块、数据库层、API 路由、测试全部翻新,一个 Agent 的上下文窗口装不下。 + +s15 Agent Teams → 多 Agent 协作,持久队友 + 异步收件箱。 + +
+深入 Claude Code 源码 + +> 以下基于 Claude Code 源码 `CronCreateTool.ts`、`cronScheduler.ts`、`cron.ts`、`cronTasks.ts`、`cronTasksLock.ts`、`useScheduledTasks.ts`(139 行)的完整分析。 + +### 一、三个 Cron 工具 + +Claude Code 暴露了三个 cron 工具给模型:`CronCreate`、`CronDelete`、`CronList`。全部由编译时门控 `feature('AGENT_TRIGGERS')` 和运行时 GrowthBook 标志 `tengu_kairos_cron` 控制。还有一个 `CLAUDE_CODE_DISABLE_CRON` 环境变量做本地覆盖。 + +### 二、存储:`.claude/scheduled_tasks.json` + +```json +{ "tasks": [{ "id": "abc12345", "cron": "0 9 * * *", "prompt": "...", "recurring": true, "durable": true, "createdAt": 1714567890000 }] } +``` + +Durable 任务写磁盘;session-only 任务存于 `STATE.sessionCronTasks` 内存数组(进程重启丢失)。还有一个 `.scheduled_tasks.lock` 文件防止同项目的多个 session 重复触发。 + +### 三、调度器:1 秒轮询 + +`cronScheduler.ts` 每秒检查一次(`CHECK_INTERVAL_MS = 1000`)。谁持有锁谁触发文件任务;所有 session 都触发仅 session 任务。还有一个 `chokidar` 文件观察者监视 `scheduled_tasks.json` 变更。 + +### 四、Cron 表达式:标准 5 字段 + +分钟 小时 日 月 星期。支持 `*`、`*/N`、`N`、`N-M`、`N-M/S`、`N,M,...`。不支持 `L`、`W`、`?`。所有时间以本地时区解释。Day-of-month 和 day-of-week 同时约束时用 OR 语义。 + +### 五、抖动(防惊群效应) + +- 重复性任务:触发延迟最多可达期间的 10%(上限 15 分钟),基于任务 ID 的确定性哈希 +- 一次性任务:当触发时间落在 `:00` 或 `:30` 时,最多提前 90 秒触发 +- 抖动配置可通过 GrowthBook 实时调整,60 秒刷新一次 + +### 六、自动过期 + +重复性任务 7 天后自动过期(可配置,上限 30 天)。过期前最后一次触发,触发后自动删除。 + +### 七、作业数上限 + +`MAX_JOBS = 50`(`CronCreateTool.ts:25`)。超限时返回错误:"Too many scheduled jobs (max 50). Cancel one first." + +### 八、触发注入 + +触发后通过 `enqueuePendingNotification()` 以 `priority: 'later'` 入队命令队列。标记 `workload: WORKLOAD_CRON`,API 在容量紧张时以更低的 QoS 为 cron 发起的请求服务。 + +### 九、Queue Processor:自动交付 + +真实 Claude Code 通过 `useQueueProcessor.ts:48-60` 在无 query、无阻塞 UI、队列非空时自动触发处理。`queueProcessor.ts:52-87` 按队列优先级把命令交给 `handlePromptSubmit()`。教学版用 `queue_processor_loop` 保留核心行为:队列有任务且 Agent 空闲时,自动启动一轮 agent_loop。 + +
+ + diff --git a/s14_cron_scheduler/images/cron-scheduler-overview.en.svg b/s14_cron_scheduler/images/cron-scheduler-overview.en.svg deleted file mode 100644 index 77bfd3ad9..000000000 --- a/s14_cron_scheduler/images/cron-scheduler-overview.en.svg +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - - - - - - - - - - - Cron Scheduler — Independent scheduler thread + cron_queue injection point - - - - s10-s13 retained - - s14 new - - - - - - consume - cron_queue - ★ s14 injection - - - - - - messages - - - - - - prompt + cache - assemble_system_prompt - (s10) - - - - - - LLM (try/except) - with_retry - (s11) - - - - - - TOOL DISPATCH - fast → sync (bash, read, write) - slow → background thread (s13) - cron → schedule_cron, list, cancel (s14) - task → create, list, claim, complete (s12) - - - - loop back: tool_results → next turn - - - - cron_scheduler_loop (daemon thread) - time.sleep(1) → cron_matches(job.cron, now) - match → cron_queue.append(job) - minute_marker prevents double-fire per minute - one-shot jobs auto-delete after firing - - - - - - - cron_queue - cron_lock · scheduler writes · loop reads - - - - next agent_loop consumes - - - - CronJob + Persistence - CronJob dataclass: - id, cron, prompt, recurring, durable - Durable → .scheduled_tasks.json - restored via load_durable_jobs after restart - Session-only → memory only - lost when process exits - ⚠ Process exit = scheduler stops (not OS-level crontab) - - - - 5-field Cron Expression - - * - - * - - * - - * - - * - min - hour - day - month - dow - - */5 * * * * → every 5 minutes - 0 9 * * 1-5 → weekdays 9:00 - 0 9 * * * → daily 9:00 - Supports: *, */N, N, N-M, N,M,... - diff --git a/s14_cron_scheduler/images/cron-scheduler-overview.ja.svg b/s14_cron_scheduler/images/cron-scheduler-overview.ja.svg deleted file mode 100644 index bc63ff6da..000000000 --- a/s14_cron_scheduler/images/cron-scheduler-overview.ja.svg +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - - - - - - - - - - - Cron Scheduler — 独立スケジューラスレッド + cron_queue 注入ポイント - - - - s10-s13 維持 - - s14 新規 - - - - - - consume - cron_queue - ★ s14 注入点 - - - - - - messages - - - - - - prompt + cache - assemble_system_prompt - (s10) - - - - - - LLM (try/except) - with_retry - (s11) - - - - - - TOOL DISPATCH - fast → sync (bash, read, write) - slow → background thread (s13) - cron → schedule_cron, list, cancel (s14) - task → create, list, claim, complete (s12) - - - - loop back: tool_results → next turn - - - - cron_scheduler_loop (daemon スレッド) - time.sleep(1) → cron_matches(job.cron, now) - マッチ → cron_queue.append(job) - minute_marker で同一分の重複発火を防止 - 一度きりのタスクは発火後自動削除 - - - - - - - cron_queue - cron_lock · スケジューラ書込 · loop 読込 - - - - 次の agent_loop が消費 - - - - CronJob + 永続化 - CronJob dataclass: - id, cron, prompt, recurring, durable - Durable → .scheduled_tasks.json - 再起動後 load_durable_jobs で復元 - Session-only → メモリのみ - プロセス終了で消失 - ⚠ プロセス終了 = スケジューラ停止(OS レベルの crontab ではない) - - - - 5 フィールド Cron 式 - - * - - * - - * - - * - - * - - - - - 曜日 - - */5 * * * * → 5 分ごと - 0 9 * * 1-5 → 平日 9:00 - 0 9 * * * → 毎日 9:00 - 対応: *, */N, N, N-M, N,M,... - diff --git a/s14_cron_scheduler/images/cron-scheduler-overview.svg b/s14_cron_scheduler/images/cron-scheduler-overview.svg index 3a8c4db61..becb5fa6f 100644 --- a/s14_cron_scheduler/images/cron-scheduler-overview.svg +++ b/s14_cron_scheduler/images/cron-scheduler-overview.svg @@ -1,125 +1,156 @@ - + - - - - - + + - - + + + + + - + + - - - Cron Scheduler — 独立调度线程 + cron_queue 注入点 - - - - s10-s13 保留 - - s14 新增 - - - - - - consume - cron_queue - ★ s14 注入点 - - - - - - messages - - - - - - prompt + cache - assemble_system_prompt - (s10) - - - - - - LLM (try/except) - with_retry - (s11) - - - - - - TOOL DISPATCH - fast → sync (bash, read, write) - slow → background thread (s13) - cron → schedule_cron, list, cancel (s14) - task → create, list, claim, complete (s12) - - - - loop back: tool_results → next turn - - - - cron_scheduler_loop(独立 daemon 线程) - time.sleep(1) → cron_matches(job.cron, now) - 匹配 → cron_queue.append(job) - minute_marker 防同分钟重复触发 - 一次性任务触发后自动删除 - - - - - - - cron_queue - cron_lock 保护 · 调度线程写 · agent_loop 读 - - - - 下次 agent_loop 消费 - - - - CronJob + 持久化 - CronJob dataclass: - id, cron, prompt, recurring, durable - Durable → .scheduled_tasks.json - 重启后 load_durable_jobs 恢复 - Session-only → 内存 only - 进程关闭即丢 - ⚠ 进程关闭 = 调度停止(不是 OS 级 crontab) - - - - 五段式 Cron 表达式 - - * - - * - - * - - * - - * - 分钟 - 小时 - - - 星期 - - */5 * * * * → 每 5 分钟 - 0 9 * * 1-5 → 工作日 9:00 - 0 9 * * * → 每天 9:00 - 支持: *, */N, N, N-M, N,M,... + Cron Scheduler + Scheduler produces · Queue Processor triggers · agent_loop consumes — via cron_queue + + + + Agent Loop + + + + consume + cron_queue + + + + + messages + + + + + prompt + cache + assemble_prompt() + + + + + LLM call + with_retry() + + + + + TOOL DISPATCH + fast: bash, read, write + slow: background thread + cron: sched, list, cancel + + + + + loop back: tool_results → next turn + + + + + + + 4 + consume + + + + + + 3 + start turn (trigger · no data) + + + + data flow (jobs · messages) + + control (trigger · no data) + + + + cron_queue + cron_lock protected + + + + + 2 + non-empty? + + + Queue Processor + polls 200ms · agent_lock + + + + + + 1 + append + + + + Scheduler Thread (daemon) + Polls every 1 second, independent of agent_loop + + time.sleep(1) + if cron_matches(job.cron, now): + cron_queue.append(job) + minute_marker prevents duplicate fire + one-shot jobs auto-removed after fire + + + + CronJob + Persistence + + CronJob(id, cron, prompt, + recurring, durable) + @dataclass + Durable + → .scheduled_tasks.json (survives restart) + Session + → in-memory only (lost on exit) + Process exit = scheduler stops (not OS crontab) + + + + 5-Field Cron Expression + + + * + minute + + + * + hour + + + * + day + + + * + month + + + * + weekday + + + */5 * * * * Every 5 minutes + 0 9 * * * Every day at 9:00 + 0 9 * * 1-5 Weekdays at 9:00 + Supports: *, */N, N, N-M, N,M,... diff --git a/s15_agent_teams/README.en.md b/s15_agent_teams/README.en.md deleted file mode 100644 index 2f422d55e..000000000 --- a/s15_agent_teams/README.en.md +++ /dev/null @@ -1,254 +0,0 @@ -# s15: Agent Teams — One Agent Isn't Enough, Form a Team - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → ... → s13 → s14 → `s15` → [s16](../s16_team_protocols/) → s17 → s18 → s19 → s20 -> *"One agent isn't enough, form a team"* — File-based inboxes + teammate threads. -> -> **Harness Layer**: Teams — Multi-agent collaboration, message bus. - ---- - -## The Problem - -"Refactor the entire backend" touches auth, database layer, API routes, and tests. One agent working on API routes no longer has auth module details in context. The context window is limited, a single agent can't cover every module. - -s06's sub-agents are temps, called in for one job, then gone. Some tasks need teammates that can communicate and collaborate. - ---- - -## The Solution - -![Agent Teams Overview](images/agent-teams-overview.en.svg) - -Teaching code carries forward S14's capabilities (prompt assembly, task system, background execution, cron scheduling). To stay focused on the team mechanism, it omits full error recovery, memory, and skill systems. Added: **MessageBus** (file-based inboxes), **spawn_teammate_thread** (launch teammate threads), **inbox injection** (Lead receives teammate messages and injects into history). - -Sub-agent vs Teammate: - -| | s06 Sub-agent | s15 Teammate | -|---|---|---| -| Lifetime | One-shot, destroyed after use | Multi-turn (teaching: 10 rounds; real CC: idle loop) | -| Communication | Only returns conclusion | Async inbox, communicate anytime | -| Context | Fully isolated | Shared via messages | -| Count | One lead + occasional sub-agent | One Lead + multiple teammates | - ---- - -## How It Works - -![Team Topology](images/team-topology.en.svg) - -### MessageBus: File-Based Inboxes - -Each agent (including Lead and teammates) has a `.jsonl` inbox. Send = append a JSON line to the target's file. Read = read file + delete (consumption): - -```python -class MessageBus: - def send(self, from_agent: str, to_agent: str, - content: str, msg_type: str = "message"): - msg = {"from": from_agent, "to": to_agent, - "content": content, "type": msg_type, - "ts": time.time()} - inbox = MAILBOX_DIR / f"{to_agent}.jsonl" - with open(inbox, "a") as f: - f.write(json.dumps(msg) + "\n") - - def read_inbox(self, agent: str) -> list[dict]: - inbox = MAILBOX_DIR / f"{agent}.jsonl" - if not inbox.exists(): - return [] - msgs = [json.loads(line) for line in inbox.read_text().splitlines()] - inbox.unlink() # consume: read + delete - return msgs -``` - -Why files instead of in-memory queues? Teaching code uses files because they're intuitive and observable across threads. Real CC also uses file inboxes (`~/.claude/teams/{team}/inboxes/`) but adds `proper-lockfile` for concurrent write safety. The teaching version's `read_inbox` has a read + unlink race, concurrent reads could lose messages, acceptable for teaching purposes. - -### spawn_teammate_thread: Launching a Teammate - -Lead calls the `spawn_teammate` tool to start a teammate. The teammate runs in its own daemon thread with its own system prompt, messages, and simplified tool set: - -```python -def spawn_teammate_thread(name: str, role: str, prompt: str) -> str: - system = f"You are '{name}', a {role}. Use tools to complete tasks." - - def run(): - messages = [{"role": "user", "content": prompt}] - sub_tools = [bash, read_file, write_file, send_message] - for _ in range(10): # max 10 rounds - inbox = BUS.read_inbox(name) - if inbox: - messages.append({"role": "user", - "content": f"{json.dumps(inbox)}"}) - response = client.messages.create( - model=MODEL, system=system, messages=messages[-20:], - tools=sub_tools, max_tokens=8000) - # ... execute tools, process results - # Send final summary to Lead - BUS.send(name, "lead", summary, "result") - - threading.Thread(target=run, daemon=True).start() -``` - -Key design: -- **Simplified tool set**: bash, read, write, send_message. Teaching code omits tasks and cron to focus on communication. Real CC teammates also have TaskCreate, TaskUpdate, etc., the task system is shared across the team -- **Teaching: 10 rounds max**: prevents infinite loops. Real CC uses idle loop: after each round, send `idle_notification`, wait for inbox messages, resume on arrival, exit only on `shutdown_request` -- **Auto-report on completion**: `BUS.send(name, "lead", summary)` sends the final result to Lead's inbox - -### Lead's Inbox Injection - -Lead checks inbox after each main loop iteration. Teammate messages are injected into history so the LLM can see and react to them: - -```python -# After main loop iteration -inbox = BUS.read_inbox("lead") -if inbox: - inbox_text = "\n".join( - f"From {m['from']}: {m['content'][:200]}" for m in inbox) - history.append({"role": "user", - "content": f"[Inbox]\n{inbox_text}"}) -``` - -Teaching code injects in the user input loop. Real CC is more refined, Lead's `useInboxPoller` checks every 1 second, submitting messages as new turns without waiting for user input. - -### Permission Bubbling - -Teaching code omits permission bubbling. Real CC's flow (`permissionSync.ts`, `useSwarmPermissionPoller.ts`): - -1. Teammate encounters an operation needing approval → sends `permission_request` to Lead's inbox -2. Lead's `useInboxPoller` detects the request → routes to approval queue -3. User approves → Lead sends `permission_response` back to teammate -4. Teammate's `useSwarmPermissionPoller` (polls every 500ms) receives reply → continue or reject - -### Putting It Together - -``` -1. Lead: "Build the backend: one agent isn't enough, form a team" -2. Lead → spawn_teammate("alice", "backend dev", "Create database schema") -3. Lead → spawn_teammate("bob", "frontend dev", "Write API client") -4. Alice thread starts → her own LLM call → bash "python manage.py migrate" -5. Bob thread starts → his own LLM call → write_file("client.ts", ...) -6. Alice done → BUS.send("alice", "lead", "Schema done: users, orders tables") -7. Bob done → BUS.send("bob", "lead", "Client written with types") -8. Lead next iteration → inbox injected into history → LLM sees both results -``` - -Two teammates work in parallel. - ---- - -## Changes from s14 - -| Component | Before (s14) | After (s15) | -|-----------|-------------|-------------| -| Agent count | 1 | 1 Lead + N teammate threads | -| Communication | None | MessageBus + .mailboxes/*.jsonl | -| New classes | — | MessageBus, active_teammates dict | -| New functions | — | spawn_teammate_thread, run_send_message, run_check_inbox | -| Lead tools | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) | -| Teammate tools | — | bash, read_file, write_file, send_message (4) | -| Permissions | Local decisions | Teaching code omits (real CC has bubbling) | - ---- - -## Try It - -```sh -cd learn-claude-code -python s15_agent_teams/code.py -``` - -Try these prompts: - -1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.` -2. `Check your inbox for alice's result.` -3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.` - -What to observe: How does Lead spawn teammates? What do the `.mailboxes/` JSONL files look like? After teammates finish, is Lead's inbox injected into history? - ---- - -## What's Next - -Teammates can work and communicate. But if Lead wants Alice to shut down, killing the thread outright could leave half-written files. A graceful shutdown protocol is needed: Lead sends shutdown_request, teammate wraps up and exits. - -s16 Team Protocols → Shutdown handshake and message conventions. - -
-Deep Dive into CC Source - -> The following is a complete analysis based on CC source code `spawnMultiAgent.ts`, `useInboxPoller.ts` (969 lines), `useSwarmPermissionPoller.ts` (330 lines), `teammateMailbox.ts`, `teamHelpers.ts`. - -### 1. No Central Message Bus, It's the Filesystem - -Teaching code uses a `MessageBus` class to send and receive messages. Real CC is more direct, each agent writes directly to other agents' inbox files. - -Inbox path: `~/.claude/teams/{teamName}/inboxes/{agentName}.json` - -Writes use `proper-lockfile` for concurrent write safety (up to 10 retries). Each file is a JSON array; appending reads → appends → writes back. - -### 2. 15 Message Types - -CC team communication has 15 structured message types (`teammateMailbox.ts`): - -| Type | Direction | Purpose | -|------|-----------|---------| -| `plain text` | Both ways | Normal inter-teammate communication | -| `idle_notification` | Teammate→Lead | Teammate finished a turn, now idle | -| `permission_request` | Teammate→Lead | Teammate needs operation approval | -| `permission_response` | Lead→Teammate | Lead's approval result | -| `plan_approval_request` | Teammate→Lead | Teammate submits plan for review | -| `plan_approval_response` | Lead→Teammate | Lead's plan review | -| `shutdown_request` | Lead→Teammate | Request graceful shutdown | -| `shutdown_approved` | Teammate→Lead | Confirm shutdown | -| `shutdown_rejected` | Teammate→Lead | Reject shutdown (with reason) | -| `task_assignment` | Lead→Teammate | Assign a task | -| `team_permission_update` | Lead→Teammate | Broadcast permission changes | -| `mode_set_request` | Lead→Teammate | Change teammate's permission mode | -| `sandbox_permission_*` | Both ways | Network permission request/reply | -| `teammate_terminated` | System | Teammate removed notification | - -Text messages are wrapped in `` XML tags for delivery to the model. - -### 3. Permission Bubbling: Bidirectional Polling - -Teaching code omits permission bubbling. Real CC's flow (`permissionSync.ts`): - -1. **Teammate** encounters operation needing approval → sends `permission_request` to Lead's inbox -2. **Lead's** `useInboxPoller` (polls every 1s) detects request → routes to `ToolUseConfirmQueue` -3. Lead's UI shows approval dialog with teammate name and color -4. User approves → Lead sends `permission_response` back to teammate's inbox -5. **Teammate's** `useSwarmPermissionPoller` (polls every 500ms) receives reply → continue or reject - -### 4. Teammate Lifecycle - -CC teammates are created by `spawnTeammate()` (`spawnMultiAgent.ts`): - -1. **Spawn**: Create tmux pane (or in-process), assign color, write team config -2. **Work**: `useInboxPoller` checks inbox every 1s → submit as new turn when messages arrive -3. **Idle**: Stop hook fires → send `idle_notification` to Lead -4. **Shutdown**: Lead sends `shutdown_request` → teammate replies `shutdown_approved` → Lead cleans up - -### 5. Team Config - -Team registry at `~/.claude/teams/{teamName}/config.json` (`teamHelpers.ts`): - -```json -{ - "name": "my-team", - "leadAgentId": "lead@my-team", - "members": [{ - "agentId": "researcher@my-team", - "name": "researcher", - "agentType": "general-purpose", - "color": "blue", - "isActive": true - }] -} -``` - -Teammates cannot be nested (`AgentTool.tsx:273` explicitly forbids "teammates spawning other teammates"). - -
- - diff --git a/s15_agent_teams/README.ja.md b/s15_agent_teams/README.ja.md index c4f7c37ac..2515a38f1 100644 --- a/s15_agent_teams/README.ja.md +++ b/s15_agent_teams/README.ja.md @@ -1,6 +1,6 @@ # s15: Agent Teams — 一人では無理、チームを組もう -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s13 → s14 → `s15` → [s16](../s16_team_protocols/) → s17 → s18 → s19 → s20 > *"一人では無理、チームを組もう"* — ファイル受信箱 + チームメイトスレッド。 @@ -19,7 +19,7 @@ s06 のサブ Agent は臨時スタッフ、一つの仕事を終えたら去る ## ソリューション -![Agent Teams Overview](images/agent-teams-overview.ja.svg) +![Agent Teams Overview](images/agent-teams-overview.svg) 教学版は S14 の能力(プロンプト組み立て、タスクシステム、バックグラウンド実行、cron スケジューリング)を踏襲。チーム機構に集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。追加:**MessageBus**(ファイル受信箱)、**spawn_teammate_thread**(チームメイトスレッド起動)、**inbox 注入**(Lead がチームメイトメッセージを受信し history に注入)。 @@ -27,7 +27,7 @@ s06 のサブ Agent は臨時スタッフ、一つの仕事を終えたら去る | | s06 サブ Agent | s15 チームメイト | |---|---|---| -| ライフサイクル | 一回きり、終了後に破棄 | マルチターン(教学版は 10 ラウンド制限、真实 CC は idle loop) | +| ライフサイクル | 一回きり、終了後に破棄 | マルチターン(教学版は 10 ラウンド制限、真实 Claude Code は idle loop) | | 通信 | 結果のみ返却 | 非同期受信箱、いつでも通信可能 | | コンテキスト | 完全に隔離 | メッセージで情報共有 | | 数 | メイン Agent + たまにサブ Agent | 1 Lead + 複数チームメイト | @@ -36,7 +36,7 @@ s06 のサブ Agent は臨時スタッフ、一つの仕事を終えたら去る ## 仕組み -![Team Topology](images/team-topology.ja.svg) +![Team Topology](images/team-topology.svg) ### MessageBus: ファイル受信箱 @@ -62,7 +62,7 @@ class MessageBus: return msgs ``` -なぜファイルか、メモリキューではなく?教学版がファイルを選ぶ理由は、直感的でスレッドをまたいで観察可能だから。真实 CC もファイル受信箱(`~/.claude/teams/{team}/inboxes/`)を使うが、`proper-lockfile` で並行書き込みの安全性を確保。教学版の `read_inbox` には read + unlink の競合状態があり、マルチスレッド同時読みでメッセージを損失する可能性があるが、教学目的には許容範囲。 +なぜファイルか、メモリキューではなく?教学版がファイルを選ぶ理由は、直感的でスレッドをまたいで観察可能だから。真实 Claude Code もファイル受信箱(`~/.claude/teams/{team}/inboxes/`)を使うが、`proper-lockfile` で並行書き込みの安全性を確保。教学版の `read_inbox` には read + unlink の競合状態があり、マルチスレッド同時読みでメッセージを損失する可能性があるが、教学目的には許容範囲。 ### spawn_teammate_thread: チームメイト起動 @@ -91,8 +91,8 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str: ``` 重要な設計: -- **チームメイトの簡易ツールセット**:bash、read、write、send_message。教学版は通信機構に集中するためタスクと cron を省略。真实 CC のチームメイトには TaskCreate、TaskUpdate 等のツールもあり、タスクシステムはチーム全体で共有 -- **教学版は 10 ラウンド制限**:無限ループを防止。真实 CC は idle loop:1 ラウンド終了後に `idle_notification` を送信、inbox メッセージを待機、到着後に再開、`shutdown_request` でのみ終了 +- **チームメイトの簡易ツールセット**:bash、read、write、send_message。教学版は通信機構に集中するためタスクと cron を省略。真实 Claude Code のチームメイトには TaskCreate、TaskUpdate 等のツールもあり、タスクシステムはチーム全体で共有 +- **教学版は 10 ラウンド制限**:無限ループを防止。真实 Claude Code は idle loop:1 ラウンド終了後に `idle_notification` を送信、inbox メッセージを待機、到着後に再開、`shutdown_request` でのみ終了 - **完了時自動報告**:`BUS.send(name, "lead", summary)` で最終結果を Lead の受信箱に送信 ### Lead の inbox 注入 @@ -109,11 +109,11 @@ if inbox: "content": f"[Inbox]\n{inbox_text}"}) ``` -教学版はユーザー入力ループ内で注入。真实 CC はより精密、Lead の `useInboxPoller` が毎秒チェックし、ユーザー入力を待たずにメッセージを新しい turn として送信。 +教学版はユーザー入力ループ内で注入。真实 Claude Code はより精密、Lead の `useInboxPoller` が毎秒チェックし、ユーザー入力を待たずにメッセージを新しい turn として送信。 ### 権限バブリング -教学版は権限バブリングを省略。真实 CC のフロー(`permissionSync.ts`、`useSwarmPermissionPoller.ts`): +教学版は権限バブリングを省略。真实 Claude Code のフロー(`permissionSync.ts`、`useSwarmPermissionPoller.ts`): 1. チームメイトが承認が必要な操作に遭遇 → `permission_request` を Lead の受信箱に送信 2. Lead の `useInboxPoller` がリクエストを検出 → 承認キューにルーティング @@ -123,7 +123,7 @@ if inbox: ### 組み合わせて実行 ``` -1. Lead: "バックエンド構築:一人では無理、チームを組もう" +1. Lead: "バックエンド構築:3 モジュールに分割、チームメイトを起動" 2. Lead → spawn_teammate("alice", "backend dev", "データベーススキーマを作成") 3. Lead → spawn_teammate("bob", "frontend dev", "API クライアントを作成") 4. alice スレッド起動 → 独自の LLM 呼び出し → bash "python manage.py migrate" @@ -147,7 +147,7 @@ if inbox: | 新規関数 | — | spawn_teammate_thread, run_send_message, run_check_inbox | | Lead ツール | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) | | チームメイトツール | — | bash, read_file, write_file, send_message (4) | -| 権限 | ローカル判断 | 教学版は省略(真实 CC はバブリング機構あり) | +| 権限 | ローカル判断 | 教学版は省略(真实 Claude Code はバブリング機構あり) | --- @@ -175,13 +175,13 @@ python s15_agent_teams/code.py s16 Team Protocols → シャットダウンハンドシェイクとメッセージの取り決め。
-CC ソースコード深掘り +Claude Code ソースコード深掘り -> 以下は CC ソースコード `spawnMultiAgent.ts`、`useInboxPoller.ts`(969 行)、`useSwarmPermissionPoller.ts`(330 行)、`teammateMailbox.ts`、`teamHelpers.ts` の完全分析に基づく。 +> 以下は Claude Code ソースコード `spawnMultiAgent.ts`、`useInboxPoller.ts`(969 行)、`useSwarmPermissionPoller.ts`(330 行)、`teammateMailbox.ts`、`teamHelpers.ts` の完全分析に基づく。 ### 一、中央メッセージバスはない、ファイルシステム -教学版は `MessageBus` クラスでメッセージを送受信。真实 CC はもっと直接的、各 Agent が他の Agent の受信箱ファイルに直接書き込む。 +教学版は `MessageBus` クラスでメッセージを送受信。真实 Claude Code はもっと直接的、各 Agent が他の Agent の受信箱ファイルに直接書き込む。 受信箱パス:`~/.claude/teams/{teamName}/inboxes/{agentName}.json` @@ -189,7 +189,7 @@ s16 Team Protocols → シャットダウンハンドシェイクとメッセー ### 二、15 種のメッセージ型 -CC のチーム通信には 15 種の構造化メッセージ(`teammateMailbox.ts`)がある: +Claude Code のチーム通信には 15 種の構造化メッセージ(`teammateMailbox.ts`)がある: | 型 | 方向 | 用途 | |------|------|------| @@ -212,7 +212,7 @@ CC のチーム通信には 15 種の構造化メッセージ(`teammateMailbox ### 三、権限バブリング:双方向ポーリング -教学版は権限バブリングを省略。真实 CC のフロー(`permissionSync.ts`): +教学版は権限バブリングを省略。真实 Claude Code のフロー(`permissionSync.ts`): 1. **チームメイト**が承認が必要な操作に遭遇 → `permission_request` を Lead の受信箱に送信 2. **Lead** の `useInboxPoller`(1 秒ごとにポーリング)がリクエストを検出 → `ToolUseConfirmQueue` にルーティング @@ -222,7 +222,7 @@ CC のチーム通信には 15 種の構造化メッセージ(`teammateMailbox ### 四、チームメイトライフサイクル -CC のチームメイトは `spawnTeammate()`(`spawnMultiAgent.ts`)で作成: +Claude Code のチームメイトは `spawnTeammate()`(`spawnMultiAgent.ts`)で作成: 1. **Spawn**:tmux ペイン(またはプロセス内)を作成、色を割り当て、team config に書き込み 2. **Work**:`useInboxPoller` が毎秒受信箱をチェック → メッセージ到着時に新しい turn として送信 diff --git a/s15_agent_teams/README.md b/s15_agent_teams/README.md index 84c75a28a..2404696cb 100644 --- a/s15_agent_teams/README.md +++ b/s15_agent_teams/README.md @@ -1,46 +1,46 @@ -# s15: Agent Teams — 一个搞不定,组队来 +# s15: Agent Teams — One Agent Isn't Enough, Form a Team -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s13 → s14 → `s15` → [s16](../s16_team_protocols/) → s17 → s18 → s19 → s20 -> *"一个搞不定, 组队来"* — 文件收件箱 + 队友线程。 +> *"One agent isn't enough, form a team"* — File-based inboxes + teammate threads. > -> **Harness 层**: 团队 — 多 Agent 协作, 消息总线。 +> **Harness Layer**: Teams — Multi-agent collaboration, message bus. --- -## 问题 +## The Problem -"重构整个后端"涉及认证模块、数据库层、API 路由、测试。一个 Agent 在修 API 路由时,认证模块的细节已经不在上下文里了。上下文窗口就那么大,单个 Agent 的注意力覆盖不了所有模块。 +"Refactor the entire backend" touches auth, database layer, API routes, and tests. One agent working on API routes no longer has auth module details in context. The context window is limited, a single agent can't cover every module. -s06 的子 Agent 是临时工,叫来干一件事就走了。但有些任务需要能通信、能协作的队友。 +s06's sub-agents are temps, called in for one job, then gone. Some tasks need teammates that can communicate and collaborate. --- -## 解决方案 +## The Solution ![Agent Teams Overview](images/agent-teams-overview.svg) -教学代码沿用 S14 的能力(prompt 组装、任务系统、后台执行、cron 调度)。为了聚焦团队机制,省略了完整错误恢复、记忆和技能系统。新增三样:**MessageBus**(文件收件箱)、**spawn_teammate_thread**(启动队友线程)、**inbox 注入**(Lead 接收队友消息并注入 history)。 +Teaching code carries forward S14's capabilities (prompt assembly, task system, background execution, cron scheduling). To stay focused on the team mechanism, it omits full error recovery, memory, and skill systems. Added: **MessageBus** (file-based inboxes), **spawn_teammate_thread** (launch teammate threads), **inbox injection** (Lead receives teammate messages and injects into history). -子 Agent vs 队友: +Sub-agent vs Teammate: -| | s06 子 Agent | s15 队友 | +| | s06 Sub-agent | s15 Teammate | |---|---|---| -| 生命周期 | 一次性,用完销毁 | 多轮(教学版限 10 轮,真实 CC 用 idle loop) | -| 通信 | 只回传结论 | 异步收件箱,随时通信 | -| 上下文 | 完全隔离 | 通过消息共享信息 | -| 数量 | 一个主 Agent + 偶尔子 Agent | 一个 Lead + 多个队友 | +| Lifetime | One-shot, destroyed after use | Multi-turn (teaching: 10 rounds; real Claude Code: idle loop) | +| Communication | Only returns conclusion | Async inbox, communicate anytime | +| Context | Fully isolated | Shared via messages | +| Count | One lead + occasional sub-agent | One Lead + multiple teammates | --- -## 工作原理 +## How It Works ![Team Topology](images/team-topology.svg) -### MessageBus: 文件收件箱 +### MessageBus: File-Based Inboxes -每个 Agent(包括 Lead 和队友)有一个 `.jsonl` 邮箱。发消息 = 往对方的文件里 append 一行 JSON。读消息 = 读文件 + 删除(消费式): +Each agent (including Lead and teammates) has a `.jsonl` inbox. Send = append a JSON line to the target's file. Read = read file + delete (consumption): ```python class MessageBus: @@ -58,15 +58,15 @@ class MessageBus: if not inbox.exists(): return [] msgs = [json.loads(line) for line in inbox.read_text().splitlines()] - inbox.unlink() # 消费式:读完删除 + inbox.unlink() # consume: read + delete return msgs ``` -为什么用文件而不是内存队列?教学版选文件是因为直观、跨线程可观察。真实 CC 也用文件收件箱(`~/.claude/teams/{team}/inboxes/`),但加了 `proper-lockfile` 防并发写冲突。教学版的 `read_inbox` 有 read + unlink 竞态,多线程同时读可能丢消息,对教学场景可以接受。 +Why files instead of in-memory queues? Teaching code uses files because they're intuitive and observable across threads. Real Claude Code also uses file inboxes (`~/.claude/teams/{team}/inboxes/`) but adds `proper-lockfile` for concurrent write safety. The teaching version's `read_inbox` has a read + unlink race, concurrent reads could lose messages, acceptable for teaching purposes. -### spawn_teammate_thread: 启动队友 +### spawn_teammate_thread: Launching a Teammate -Lead 调用 `spawn_teammate` 工具启动一个队友。队友跑在自己的 daemon 线程里,有自己的 system prompt、自己的 messages、自己的简化工具集: +Lead calls the `spawn_teammate` tool to start a teammate. The teammate runs in its own daemon thread with its own system prompt, messages, and simplified tool set: ```python def spawn_teammate_thread(name: str, role: str, prompt: str) -> str: @@ -75,7 +75,7 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str: def run(): messages = [{"role": "user", "content": prompt}] sub_tools = [bash, read_file, write_file, send_message] - for _ in range(10): # 最多 10 轮 + for _ in range(10): # max 10 rounds inbox = BUS.read_inbox(name) if inbox: messages.append({"role": "user", @@ -83,24 +83,24 @@ def spawn_teammate_thread(name: str, role: str, prompt: str) -> str: response = client.messages.create( model=MODEL, system=system, messages=messages[-20:], tools=sub_tools, max_tokens=8000) - # ... 执行工具、处理结果 - # 完成后发 summary 给 Lead + # ... execute tools, process results + # Send final summary to Lead BUS.send(name, "lead", summary, "result") threading.Thread(target=run, daemon=True).start() ``` -关键设计: -- **队友有简化工具集**:bash、read、write、send_message。教学版省略了任务和 cron,聚焦通信机制。真实 CC 的队友也有 TaskCreate、TaskUpdate 等工具,任务系统是团队共享的 -- **教学版限 10 轮**:防止队友无限循环。真实 CC 用 idle loop:跑完一轮后发 `idle_notification`,等 inbox 消息,收到后继续,直到 `shutdown_request` 才退出 -- **完成后自动汇报**:`BUS.send(name, "lead", summary)` 把最终结果发到 Lead 的收件箱 +Key design: +- **Simplified tool set**: bash, read, write, send_message. Teaching code omits tasks and cron to focus on communication. Real Claude Code teammates also have TaskCreate, TaskUpdate, etc., the task system is shared across the team +- **Teaching: 10 rounds max**: prevents infinite loops. Real Claude Code uses idle loop: after each round, send `idle_notification`, wait for inbox messages, resume on arrival, exit only on `shutdown_request` +- **Auto-report on completion**: `BUS.send(name, "lead", summary)` sends the final result to Lead's inbox -### Lead 的 inbox 注入 +### Lead's Inbox Injection -Lead 在每轮主循环结束后检查收件箱。队友发来的消息注入到 history 里,让 LLM 能看到并做出反应: +Lead checks inbox after each main loop iteration. Teammate messages are injected into history so the LLM can see and react to them: ```python -# 主循环结束后 +# After main loop iteration inbox = BUS.read_inbox("lead") if inbox: inbox_text = "\n".join( @@ -109,129 +109,129 @@ if inbox: "content": f"[Inbox]\n{inbox_text}"}) ``` -教学版在用户输入循环外注入。CC 更精细,Lead 的 `useInboxPoller` 每 1 秒检查一次,有消息就提交为新的 turn,不需要等用户输入。 +Teaching code injects in the user input loop. Real Claude Code is more refined, Lead's `useInboxPoller` checks every 1 second, submitting messages as new turns without waiting for user input. -### 权限冒泡 +### Permission Bubbling -教学版省略了权限冒泡。真实 CC 的流程(`permissionSync.ts`、`useSwarmPermissionPoller.ts`): +Teaching code omits permission bubbling. Real Claude Code's flow (`permissionSync.ts`, `useSwarmPermissionPoller.ts`): -1. 队友遇到需要审批的操作 → 发 `permission_request` 到 Lead 收件箱 -2. Lead 的 `useInboxPoller` 检测到请求 → 路由到审批队列 -3. 用户审批后 → Lead 发 `permission_response` 回队友 -4. 队友的 `useSwarmPermissionPoller`(每 500ms 轮询)收到回复 → 继续或拒绝 +1. Teammate encounters an operation needing approval → sends `permission_request` to Lead's inbox +2. Lead's `useInboxPoller` detects the request → routes to approval queue +3. User approves → Lead sends `permission_response` back to teammate +4. Teammate's `useSwarmPermissionPoller` (polls every 500ms) receives reply → continue or reject -### 合起来跑 +### Putting It Together ``` -1. Lead: "搭建后端:一个人搞不定,组队吧" -2. Lead → spawn_teammate("alice", "backend dev", "创建数据库 schema") -3. Lead → spawn_teammate("bob", "frontend dev", "写 API 客户端") -4. alice 线程启动 → 自己的 LLM 调用 → bash "python manage.py migrate" -5. bob 线程启动 → 自己的 LLM 调用 → write_file("client.ts", ...) -6. alice 完成 → BUS.send("alice", "lead", "Schema done: users, orders tables") -7. bob 完成 → BUS.send("bob", "lead", "Client written with types") -8. Lead 下次循环 → inbox 注入 history → LLM 看到 alice 和 bob 的结果 +1. Lead: "Build the backend: split into three modules, spawn teammates" +2. Lead → spawn_teammate("alice", "backend dev", "Create database schema") +3. Lead → spawn_teammate("bob", "frontend dev", "Write API client") +4. Alice thread starts → her own LLM call → bash "python manage.py migrate" +5. Bob thread starts → his own LLM call → write_file("client.ts", ...) +6. Alice done → BUS.send("alice", "lead", "Schema done: users, orders tables") +7. Bob done → BUS.send("bob", "lead", "Client written with types") +8. Lead next iteration → inbox injected into history → LLM sees both results ``` -两个队友并行工作。 +Two teammates work in parallel. --- -## 相对 s14 的变更 +## Changes from s14 -| 组件 | 之前 (s14) | 之后 (s15) | -|------|-----------|-----------| -| Agent 数量 | 1 | 1 Lead + N 队友线程 | -| 通信 | 无 | MessageBus + .mailboxes/*.jsonl | -| 新类 | — | MessageBus, active_teammates dict | -| 新函数 | — | spawn_teammate_thread, run_send_message, run_check_inbox | -| Lead 工具 | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) | -| 队友工具 | — | bash, read_file, write_file, send_message (4) | -| 权限 | 本地决策 | 教学版省略(真实 CC 有冒泡机制) | +| Component | Before (s14) | After (s15) | +|-----------|-------------|-------------| +| Agent count | 1 | 1 Lead + N teammate threads | +| Communication | None | MessageBus + .mailboxes/*.jsonl | +| New classes | — | MessageBus, active_teammates dict | +| New functions | — | spawn_teammate_thread, run_send_message, run_check_inbox | +| Lead tools | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) | +| Teammate tools | — | bash, read_file, write_file, send_message (4) | +| Permissions | Local decisions | Teaching code omits (real Claude Code has bubbling) | --- -## 试一下 +## Try It ```sh cd learn-claude-code python s15_agent_teams/code.py ``` -试试这些 prompt: +Try these prompts: 1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.` 2. `Check your inbox for alice's result.` 3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.` -观察重点:Lead 如何启动队友?`.mailboxes/` 目录下的 JSONL 文件长什么样?队友完成后 Lead 的 inbox 有没有注入到 history? +What to observe: How does Lead spawn teammates? What do the `.mailboxes/` JSONL files look like? After teammates finish, is Lead's inbox injected into history? --- -## 接下来 +## What's Next -队友能干活、能通信。但如果 Lead 想让 Alice 关机,直接杀线程会留下写到一半的文件。需要一个体面的关机协议:Lead 发 shutdown_request,队友收尾后退出。 +Teammates can work and communicate. But if Lead wants Alice to shut down, killing the thread outright could leave half-written files. A graceful shutdown protocol is needed: Lead sends shutdown_request, teammate wraps up and exits. -s16 Team Protocols → 关机握手与消息约定。 +s16 Team Protocols → Shutdown handshake and message conventions.
-深入 CC 源码 +Deep Dive into Claude Code Source -> 以下基于 CC 源码 `spawnMultiAgent.ts`、`useInboxPoller.ts`(969 行)、`useSwarmPermissionPoller.ts`(330 行)、`teammateMailbox.ts`、`teamHelpers.ts` 的完整分析。 +> The following is a complete analysis based on Claude Code source code `spawnMultiAgent.ts`, `useInboxPoller.ts` (969 lines), `useSwarmPermissionPoller.ts` (330 lines), `teammateMailbox.ts`, `teamHelpers.ts`. -### 一、没有中央消息总线,是文件系统 +### 1. No Central Message Bus, It's the Filesystem -教学版用 `MessageBus` 类收发消息。CC 的做法更直接,每个 Agent 直接写其他 Agent 的收件箱文件。 +Teaching code uses a `MessageBus` class to send and receive messages. Real Claude Code is more direct, each agent writes directly to other agents' inbox files. -收件箱路径:`~/.claude/teams/{teamName}/inboxes/{agentName}.json` +Inbox path: `~/.claude/teams/{teamName}/inboxes/{agentName}.json` -写入时用 `proper-lockfile` 文件锁保证并发安全(最多重试 10 次)。每个文件是一个 JSON 数组,append 新消息时读→追加→写回。 +Writes use `proper-lockfile` for concurrent write safety (up to 10 retries). Each file is a JSON array; appending reads → appends → writes back. -### 二、15 种消息类型 +### 2. 15 Message Types -CC 的团队通信有 15 种结构化消息(`teammateMailbox.ts`): +Claude Code team communication has 15 structured message types (`teammateMailbox.ts`): -| 类型 | 方向 | 用途 | -|------|------|------| -| `plain text` | 双向 | 普通队友间通信 | -| `idle_notification` | 队友→Lead | 队友完成一轮工作,进入空闲 | -| `permission_request` | 队友→Lead | 队友需要操作审批 | -| `permission_response` | Lead→队友 | Lead 审批结果 | -| `plan_approval_request` | 队友→Lead | 队友提交计划待审 | -| `plan_approval_response` | Lead→队友 | Lead 审批计划 | -| `shutdown_request` | Lead→队友 | 请求体面关机 | -| `shutdown_approved` | 队友→Lead | 确认关机 | -| `shutdown_rejected` | 队友→Lead | 拒绝关机(附原因) | -| `task_assignment` | Lead→队友 | 分配任务 | -| `team_permission_update` | Lead→队友 | 广播权限变更 | -| `mode_set_request` | Lead→队友 | 修改队友的权限模式 | -| `sandbox_permission_*` | 双向 | 网络权限请求/回复 | -| `teammate_terminated` | 系统 | 队友被移除通知 | +| Type | Direction | Purpose | +|------|-----------|---------| +| `plain text` | Both ways | Normal inter-teammate communication | +| `idle_notification` | Teammate→Lead | Teammate finished a turn, now idle | +| `permission_request` | Teammate→Lead | Teammate needs operation approval | +| `permission_response` | Lead→Teammate | Lead's approval result | +| `plan_approval_request` | Teammate→Lead | Teammate submits plan for review | +| `plan_approval_response` | Lead→Teammate | Lead's plan review | +| `shutdown_request` | Lead→Teammate | Request graceful shutdown | +| `shutdown_approved` | Teammate→Lead | Confirm shutdown | +| `shutdown_rejected` | Teammate→Lead | Reject shutdown (with reason) | +| `task_assignment` | Lead→Teammate | Assign a task | +| `team_permission_update` | Lead→Teammate | Broadcast permission changes | +| `mode_set_request` | Lead→Teammate | Change teammate's permission mode | +| `sandbox_permission_*` | Both ways | Network permission request/reply | +| `teammate_terminated` | System | Teammate removed notification | -文本消息被包装在 `` XML 标签中交付给模型。 +Text messages are wrapped in `` XML tags for delivery to the model. -### 三、权限冒泡:双向轮询 +### 3. Permission Bubbling: Bidirectional Polling -教学版省略了权限冒泡。CC 的实际流程(`permissionSync.ts`): +Teaching code omits permission bubbling. Real Claude Code's flow (`permissionSync.ts`): -1. **队友**遇到需要审批的操作 → 发 `permission_request` 到 Lead 的收件箱 -2. **Lead** 的 `useInboxPoller`(每 1 秒轮询)检测到请求 → 路由到 `ToolUseConfirmQueue` -3. Lead 的 UI 显示审批对话框,带队友名字和颜色 -4. 用户审批后 → Lead 发 `permission_response` 回队友的收件箱 -5. **队友**的 `useSwarmPermissionPoller`(每 500ms 轮询)收到回复 → 继续或拒绝执行 +1. **Teammate** encounters operation needing approval → sends `permission_request` to Lead's inbox +2. **Lead's** `useInboxPoller` (polls every 1s) detects request → routes to `ToolUseConfirmQueue` +3. Lead's UI shows approval dialog with teammate name and color +4. User approves → Lead sends `permission_response` back to teammate's inbox +5. **Teammate's** `useSwarmPermissionPoller` (polls every 500ms) receives reply → continue or reject -### 四、队友生命周期 +### 4. Teammate Lifecycle -CC 的队友由 `spawnTeammate()`(`spawnMultiAgent.ts`)创建: +Claude Code teammates are created by `spawnTeammate()` (`spawnMultiAgent.ts`): -1. **Spawn**:创建 tmux 窗格(或进程内),分配颜色,写入 team config -2. **Work**:`useInboxPoller` 每 1 秒检查收件箱 → 有消息就提交为新的 turn -3. **Idle**:Stop hook 触发 → 发 `idle_notification` 给 Lead -4. **Shutdown**:Lead 发 `shutdown_request` → 队友回复 `shutdown_approved` → Lead 清理 +1. **Spawn**: Create tmux pane (or in-process), assign color, write team config +2. **Work**: `useInboxPoller` checks inbox every 1s → submit as new turn when messages arrive +3. **Idle**: Stop hook fires → send `idle_notification` to Lead +4. **Shutdown**: Lead sends `shutdown_request` → teammate replies `shutdown_approved` → Lead cleans up -### 五、Team Config +### 5. Team Config -团队注册表在 `~/.claude/teams/{teamName}/config.json`(`teamHelpers.ts`): +Team registry at `~/.claude/teams/{teamName}/config.json` (`teamHelpers.ts`): ```json { @@ -247,7 +247,7 @@ CC 的队友由 `spawnTeammate()`(`spawnMultiAgent.ts`)创建: } ``` -队友之间不能嵌套(`AgentTool.tsx:273` 明确禁止 "teammates spawning other teammates")。 +Teammates cannot be nested (`AgentTool.tsx:273` explicitly forbids "teammates spawning other teammates").
diff --git a/s15_agent_teams/README.zh.md b/s15_agent_teams/README.zh.md new file mode 100644 index 000000000..a5f35bb6c --- /dev/null +++ b/s15_agent_teams/README.zh.md @@ -0,0 +1,254 @@ +# s15: Agent Teams — 一个搞不定,组队来 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s13 → s14 → `s15` → [s16](../s16_team_protocols/) → s17 → s18 → s19 → s20 +> *"一个搞不定, 组队来"* — 文件收件箱 + 队友线程。 +> +> **Harness 层**: 团队 — 多 Agent 协作, 消息总线。 + +--- + +## 问题 + +"重构整个后端"涉及认证模块、数据库层、API 路由、测试。一个 Agent 在修 API 路由时,认证模块的细节已经不在上下文里了。上下文窗口有限,单个 Agent 覆盖不了所有模块。 + +s06 的子 Agent 是临时工,叫来干一件事就走了。但有些任务需要能通信、能协作的队友。 + +--- + +## 解决方案 + +![Agent Teams Overview](images/agent-teams-overview.svg) + +教学代码沿用 S14 的能力(prompt 组装、任务系统、后台执行、cron 调度)。为了聚焦团队机制,省略了完整错误恢复、记忆和技能系统。新增三样:**MessageBus**(文件收件箱)、**spawn_teammate_thread**(启动队友线程)、**inbox 注入**(Lead 接收队友消息并注入 history)。 + +子 Agent vs 队友: + +| | s06 子 Agent | s15 队友 | +|---|---|---| +| 生命周期 | 一次性,用完销毁 | 多轮(教学版限 10 轮,真实 Claude Code 用 idle loop) | +| 通信 | 只回传结论 | 异步收件箱,随时通信 | +| 上下文 | 完全隔离 | 通过消息共享信息 | +| 数量 | 一个主 Agent + 偶尔子 Agent | 一个 Lead + 多个队友 | + +--- + +## 工作原理 + +![Team Topology](images/team-topology.svg) + +### MessageBus: 文件收件箱 + +每个 Agent(包括 Lead 和队友)有一个 `.jsonl` 邮箱。发消息 = 往对方的文件里 append 一行 JSON。读消息 = 读文件 + 删除(消费式): + +```python +class MessageBus: + def send(self, from_agent: str, to_agent: str, + content: str, msg_type: str = "message"): + msg = {"from": from_agent, "to": to_agent, + "content": content, "type": msg_type, + "ts": time.time()} + inbox = MAILBOX_DIR / f"{to_agent}.jsonl" + with open(inbox, "a") as f: + f.write(json.dumps(msg) + "\n") + + def read_inbox(self, agent: str) -> list[dict]: + inbox = MAILBOX_DIR / f"{agent}.jsonl" + if not inbox.exists(): + return [] + msgs = [json.loads(line) for line in inbox.read_text().splitlines()] + inbox.unlink() # 消费式:读完删除 + return msgs +``` + +为什么用文件而不是内存队列?教学版选文件是因为直观、跨线程可观察。真实 Claude Code 也用文件收件箱(`~/.claude/teams/{team}/inboxes/`),但加了 `proper-lockfile` 防并发写冲突。教学版的 `read_inbox` 有 read + unlink 竞态,多线程同时读可能丢消息,对教学场景可以接受。 + +### spawn_teammate_thread: 启动队友 + +Lead 调用 `spawn_teammate` 工具启动一个队友。队友跑在自己的 daemon 线程里,有自己的 system prompt、自己的 messages、自己的简化工具集: + +```python +def spawn_teammate_thread(name: str, role: str, prompt: str) -> str: + system = f"You are '{name}', a {role}. Use tools to complete tasks." + + def run(): + messages = [{"role": "user", "content": prompt}] + sub_tools = [bash, read_file, write_file, send_message] + for _ in range(10): # 最多 10 轮 + inbox = BUS.read_inbox(name) + if inbox: + messages.append({"role": "user", + "content": f"{json.dumps(inbox)}"}) + response = client.messages.create( + model=MODEL, system=system, messages=messages[-20:], + tools=sub_tools, max_tokens=8000) + # ... 执行工具、处理结果 + # 完成后发 summary 给 Lead + BUS.send(name, "lead", summary, "result") + + threading.Thread(target=run, daemon=True).start() +``` + +关键设计: +- **队友有简化工具集**:bash、read、write、send_message。教学版省略了任务和 cron,聚焦通信机制。真实 Claude Code 的队友也有 TaskCreate、TaskUpdate 等工具,任务系统是团队共享的 +- **教学版限 10 轮**:防止队友无限循环。真实 Claude Code 用 idle loop:跑完一轮后发 `idle_notification`,等 inbox 消息,收到后继续,直到 `shutdown_request` 才退出 +- **完成后自动汇报**:`BUS.send(name, "lead", summary)` 把最终结果发到 Lead 的收件箱 + +### Lead 的 inbox 注入 + +Lead 在每轮主循环结束后检查收件箱。队友发来的消息注入到 history 里,让 LLM 能看到并做出反应: + +```python +# 主循环结束后 +inbox = BUS.read_inbox("lead") +if inbox: + inbox_text = "\n".join( + f"From {m['from']}: {m['content'][:200]}" for m in inbox) + history.append({"role": "user", + "content": f"[Inbox]\n{inbox_text}"}) +``` + +教学版在用户输入循环外注入。Claude Code 更精细,Lead 的 `useInboxPoller` 每 1 秒检查一次,有消息就提交为新的 turn,不需要等用户输入。 + +### 权限冒泡 + +教学版省略了权限冒泡。真实 Claude Code 的流程(`permissionSync.ts`、`useSwarmPermissionPoller.ts`): + +1. 队友遇到需要审批的操作 → 发 `permission_request` 到 Lead 收件箱 +2. Lead 的 `useInboxPoller` 检测到请求 → 路由到审批队列 +3. 用户审批后 → Lead 发 `permission_response` 回队友 +4. 队友的 `useSwarmPermissionPoller`(每 500ms 轮询)收到回复 → 继续或拒绝 + +### 合起来跑 + +``` +1. Lead: "搭建后端:拆成三个模块,分别启动队友" +2. Lead → spawn_teammate("alice", "backend dev", "创建数据库 schema") +3. Lead → spawn_teammate("bob", "frontend dev", "写 API 客户端") +4. alice 线程启动 → 自己的 LLM 调用 → bash "python manage.py migrate" +5. bob 线程启动 → 自己的 LLM 调用 → write_file("client.ts", ...) +6. alice 完成 → BUS.send("alice", "lead", "Schema done: users, orders tables") +7. bob 完成 → BUS.send("bob", "lead", "Client written with types") +8. Lead 下次循环 → inbox 注入 history → LLM 看到 alice 和 bob 的结果 +``` + +两个队友并行工作。 + +--- + +## 相对 s14 的变更 + +| 组件 | 之前 (s14) | 之后 (s15) | +|------|-----------|-----------| +| Agent 数量 | 1 | 1 Lead + N 队友线程 | +| 通信 | 无 | MessageBus + .mailboxes/*.jsonl | +| 新类 | — | MessageBus, active_teammates dict | +| 新函数 | — | spawn_teammate_thread, run_send_message, run_check_inbox | +| Lead 工具 | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) | +| 队友工具 | — | bash, read_file, write_file, send_message (4) | +| 权限 | 本地决策 | 教学版省略(真实 Claude Code 有冒泡机制) | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s15_agent_teams/code.py +``` + +试试这些 prompt: + +1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.` +2. `Check your inbox for alice's result.` +3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.` + +观察重点:Lead 如何启动队友?`.mailboxes/` 目录下的 JSONL 文件长什么样?队友完成后 Lead 的 inbox 有没有注入到 history? + +--- + +## 接下来 + +队友能干活、能通信。但如果 Lead 想让 Alice 关机,直接杀线程会留下写到一半的文件。需要一个体面的关机协议:Lead 发 shutdown_request,队友收尾后退出。 + +s16 Team Protocols → 关机握手与消息约定。 + +
+深入 Claude Code 源码 + +> 以下基于 Claude Code 源码 `spawnMultiAgent.ts`、`useInboxPoller.ts`(969 行)、`useSwarmPermissionPoller.ts`(330 行)、`teammateMailbox.ts`、`teamHelpers.ts` 的完整分析。 + +### 一、没有中央消息总线,是文件系统 + +教学版用 `MessageBus` 类收发消息。Claude Code 的做法更直接,每个 Agent 直接写其他 Agent 的收件箱文件。 + +收件箱路径:`~/.claude/teams/{teamName}/inboxes/{agentName}.json` + +写入时用 `proper-lockfile` 文件锁保证并发安全(最多重试 10 次)。每个文件是一个 JSON 数组,append 新消息时读→追加→写回。 + +### 二、15 种消息类型 + +Claude Code 的团队通信有 15 种结构化消息(`teammateMailbox.ts`): + +| 类型 | 方向 | 用途 | +|------|------|------| +| `plain text` | 双向 | 普通队友间通信 | +| `idle_notification` | 队友→Lead | 队友完成一轮工作,进入空闲 | +| `permission_request` | 队友→Lead | 队友需要操作审批 | +| `permission_response` | Lead→队友 | Lead 审批结果 | +| `plan_approval_request` | 队友→Lead | 队友提交计划待审 | +| `plan_approval_response` | Lead→队友 | Lead 审批计划 | +| `shutdown_request` | Lead→队友 | 请求体面关机 | +| `shutdown_approved` | 队友→Lead | 确认关机 | +| `shutdown_rejected` | 队友→Lead | 拒绝关机(附原因) | +| `task_assignment` | Lead→队友 | 分配任务 | +| `team_permission_update` | Lead→队友 | 广播权限变更 | +| `mode_set_request` | Lead→队友 | 修改队友的权限模式 | +| `sandbox_permission_*` | 双向 | 网络权限请求/回复 | +| `teammate_terminated` | 系统 | 队友被移除通知 | + +文本消息被包装在 `` XML 标签中交付给模型。 + +### 三、权限冒泡:双向轮询 + +教学版省略了权限冒泡。Claude Code 的实际流程(`permissionSync.ts`): + +1. **队友**遇到需要审批的操作 → 发 `permission_request` 到 Lead 的收件箱 +2. **Lead** 的 `useInboxPoller`(每 1 秒轮询)检测到请求 → 路由到 `ToolUseConfirmQueue` +3. Lead 的 UI 显示审批对话框,带队友名字和颜色 +4. 用户审批后 → Lead 发 `permission_response` 回队友的收件箱 +5. **队友**的 `useSwarmPermissionPoller`(每 500ms 轮询)收到回复 → 继续或拒绝执行 + +### 四、队友生命周期 + +Claude Code 的队友由 `spawnTeammate()`(`spawnMultiAgent.ts`)创建: + +1. **Spawn**:创建 tmux 窗格(或进程内),分配颜色,写入 team config +2. **Work**:`useInboxPoller` 每 1 秒检查收件箱 → 有消息就提交为新的 turn +3. **Idle**:Stop hook 触发 → 发 `idle_notification` 给 Lead +4. **Shutdown**:Lead 发 `shutdown_request` → 队友回复 `shutdown_approved` → Lead 清理 + +### 五、Team Config + +团队注册表在 `~/.claude/teams/{teamName}/config.json`(`teamHelpers.ts`): + +```json +{ + "name": "my-team", + "leadAgentId": "lead@my-team", + "members": [{ + "agentId": "researcher@my-team", + "name": "researcher", + "agentType": "general-purpose", + "color": "blue", + "isActive": true + }] +} +``` + +队友之间不能嵌套(`AgentTool.tsx:273` 明确禁止 "teammates spawning other teammates")。 + +
+ + diff --git a/s15_agent_teams/code.py b/s15_agent_teams/code.py index 5b0fd7d33..e0b611c27 100644 --- a/s15_agent_teams/code.py +++ b/s15_agent_teams/code.py @@ -11,7 +11,7 @@ - Teammate runs own simplified agent_loop (bash, read, write, send_message) - Lead tools: spawn_teammate, send_message, check_inbox (3 new) - Lead inbox: teammate messages injected into history (not just printed) - - Teaching version: teammates limited to 10 rounds (real CC uses idle loop) + - Teaching version: teammates limited to 10 rounds (real Claude Code uses idle loop) ASCII flow: Lead: cron_queue → messages → prompt → LLM → TOOLS ────→ loop @@ -593,7 +593,7 @@ def run_cancel_cron(job_id: str) -> str: # ── MessageBus (s15 new) ── # Teaching version uses simple file append + unlink. -# Real CC uses proper-lockfile for concurrent write safety. +# Real Claude Code uses proper-lockfile for concurrent write safety. MAILBOX_DIR = WORKDIR / ".mailboxes" MAILBOX_DIR.mkdir(exist_ok=True) @@ -602,7 +602,7 @@ def run_cancel_cron(job_id: str) -> str: class MessageBus: """File-based message bus. Each agent has a .jsonl inbox. Read is destructive: read_text + unlink (consumes messages). - Teaching version: no file locking; real CC uses proper-lockfile.""" + Teaching version: no file locking; real Claude Code uses proper-lockfile.""" def send(self, from_agent: str, to_agent: str, content: str, msg_type: str = "message"): @@ -643,7 +643,7 @@ def peek(self, agent: str) -> bool: def spawn_teammate_thread(name: str, role: str, prompt: str) -> str: """Spawn a teammate agent in a background thread. Teaching version: max 10 rounds per teammate. - Real CC: teammates use idle loop (wait for inbox, work, repeat) + Real Claude Code: teammates use idle loop (wait for inbox, work, repeat) until shutdown_request.""" if name in active_teammates: return f"Teammate '{name}' already exists" @@ -855,7 +855,7 @@ def update_context(context: dict, messages: list) -> dict: # ── Agent Loop ── # Teaching code keeps a basic agent loop. S11's full error recovery is omitted. -# Cron queue is consumed when agent_loop is called; real CC auto-wakes via +# Cron queue is consumed when agent_loop is called; real Claude Code auto-wakes via # queue processor (useQueueProcessor.ts) when items arrive. def agent_loop(messages: list, context: dict): diff --git a/s15_agent_teams/images/agent-teams-overview.en.svg b/s15_agent_teams/images/agent-teams-overview.en.svg deleted file mode 100644 index f87995ad3..000000000 --- a/s15_agent_teams/images/agent-teams-overview.en.svg +++ /dev/null @@ -1,120 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - Agent Teams — Lead Loop + Teammate Threads + MessageBus - - - - s10-s14 Preserved - - s15 New - - Teammate - - Real CC detail - - - - cron_queue - - - - - messages - - - - - prompt + cache - - - - - LLM call - - - - - TOOL DISPATCH - bash · read · write · task(4) · cron(3) - ★ spawn_teammate · send_message · check_inbox - - - - - - - - spawn - - - - MessageBus (.mailboxes/*.jsonl) - - - - - - receive - receive - receive - - - - - send - send - send - - - Teammate: alice (Backend) - inbox → LLM → bash/read/write/send - Max 10 rounds → summary → BUS.send - - - Teammate: bob (Frontend) - Independent agent_loop, shared client - Thread(daemon=True) - - - Teammate: charlie (QA) - Cannot spawn other teammates - spawn → work → summary - - - - - permission_request - - - Permission Bubbling (real CC; omitted in teaching code) - ① Teammate needs approval → MessageBus sends permission_request ② Lead receives → user approval → approve/deny - - - - - s10-s14: prompt assembly, error recovery, task graph, background threads, cron scheduling - - s15: MessageBus + spawn_teammate_thread + send_message + check_inbox (permission bubbling is a real CC detail) - diff --git a/s15_agent_teams/images/agent-teams-overview.ja.svg b/s15_agent_teams/images/agent-teams-overview.ja.svg deleted file mode 100644 index 47c96654e..000000000 --- a/s15_agent_teams/images/agent-teams-overview.ja.svg +++ /dev/null @@ -1,120 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - Agent Teams — Lead ループ + チームメイトスレッド + MessageBus - - - - s10-s14 保持 - - s15 新規 - - チームメイト - - 真实 CC 補足 - - - - cron_queue - - - - - messages - - - - - prompt + cache - - - - - LLM call - - - - - TOOL DISPATCH - bash · read · write · task(4) · cron(3) - ★ spawn_teammate · send_message · check_inbox - - - - - - - - spawn - - - - MessageBus (.mailboxes/*.jsonl) - - - - - - receive - receive - receive - - - - - send - send - send - - - チームメイト: alice (Backend) - inbox → LLM → bash/read/write/send - 最大 10 ラウンド → summary → BUS.send - - - チームメイト: bob (Frontend) - 独立 agent_loop、共有 client - Thread(daemon=True) - - - チームメイト: charlie (QA) - 他のチームメイトを spawn 不可 - spawn → work → summary - - - - - permission_request - - - 権限バブリング(真实 CC、教学版は省略) - ① 承認が必要 → MessageBus が permission_request 送信 ② Lead が受信 → ユーザー承認 → approve/deny - - - - - s10-s14:プロンプト組み立て、エラーリカバリ、タスクグラフ、バックグラウンドスレッド、cron - - s15:MessageBus + spawn_teammate_thread + send_message + check_inbox(権限バブリングは真实 CC 補足) - diff --git a/s15_agent_teams/images/agent-teams-overview.svg b/s15_agent_teams/images/agent-teams-overview.svg index e708a3341..d1493110b 100644 --- a/s15_agent_teams/images/agent-teams-overview.svg +++ b/s15_agent_teams/images/agent-teams-overview.svg @@ -1,132 +1,119 @@ - + - - - - - + + - - - - - - - - + + - + + - - - Agent Teams — Lead Loop + Teammate Threads + MessageBus - - - - s10-s14 保留 - - s15 新增 - - Teammate - - 真实 CC 补充 - - - - - - cron_queue - - - - - messages - - - - - prompt + cache - - - - - LLM call - - - - - TOOL DISPATCH - bash · read · write · task(4) · cron(3) - ★ spawn_teammate · send_message · check_inbox - - - - - - - - - spawn - - - - - MessageBus (.mailboxes/*.jsonl) - - - - - - - - - receive - receive - receive - - - - - - send - send - send - - - - Teammate: alice (Backend) - inbox → LLM → bash/read/write/send - 最多 10 轮 → summary → BUS.send - - - - Teammate: bob (Frontend) - 独立 agent_loop,共享 client - Thread(daemon=True) - - - - Teammate: charlie (QA) - 不能 spawn 其他 teammate - spawn → work → summary - - - - - - permission_request - - - 权限冒泡(真实 CC,教学版省略) - ① 队友需审批 → MessageBus 发送 permission_request ② Lead 收到 → 用户审批 → 回复 approve/deny - - - - - s10-s14: prompt 组装、错误恢复、任务图、后台线程、cron 调度 - - s15: MessageBus + spawn_teammate_thread + send_message + check_inbox(权限冒泡见真实 CC 补充) + Agent Teams — Lead Loop + Teammate Threads + MessageBus + Multi-agent collaboration via file-based inboxes + + + + Lead Agent Loop + + + cron_queue + + + + messages + + + + prompt + cache + + + + LLM call + + + + TOOL DISPATCH + bash read write task cron + spawn_teammate send_message check_inbox + + + + + loop back: tool_results -> next turn + + + + + + check_inbox + + + send_message + + + + + spawn_teammate + creates teammate thread + + + + MessageBus + .mailboxes/*.jsonl + + + + receive + + send + + + receive + + send + + + receive + + send + + + + alice (Backend) + + task: create DB schema + + + bob (Frontend) + + task: write API client + + + charlie (QA) + + task: run tests, report + + + Each teammate: own daemon Thread + own agent_loop (shared client) · max 10 rounds · cannot spawn others + + + + Permission Bubbling + (omitted in teaching code, present in production) + + 1. Teammate needs approval -> sends permission_request via Bus + 2. Lead receives -> user approves -> sends permission_response back + + + + + perm_request + + + + Lead tools: bash, read, write, task(5), cron(3), spawn_teammate, send_message, check_inbox (14 total) diff --git a/s15_agent_teams/images/team-topology.en.svg b/s15_agent_teams/images/team-topology.en.svg deleted file mode 100644 index 7540db7c8..000000000 --- a/s15_agent_teams/images/team-topology.en.svg +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - Team Topology — Lead ↔ MessageBus ↔ Teammates - - - - Lead Agent - Main loop + spawn + inbox handling - check_inbox receives teammate messages - - - - Message Bus (.mailboxes/*.jsonl) - - - - Alice (Backend) - own loop → inbox → work → reply - - - Bob (Frontend) - own loop → inbox → work → reply - - - Charlie (QA) - own loop → inbox → work → reply - - - - send - - inbox - - - - - - receive - receive - receive - - - - send - send - send - - diff --git a/s15_agent_teams/images/team-topology.ja.svg b/s15_agent_teams/images/team-topology.ja.svg deleted file mode 100644 index 77c8709e8..000000000 --- a/s15_agent_teams/images/team-topology.ja.svg +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - Team Topology — Lead ↔ MessageBus ↔ チームメイト - - - - Lead Agent - メインループ + spawn + inbox 処理 - check_inbox でチームメイトのメッセージ受信 - - - - Message Bus (.mailboxes/*.jsonl) - - - - Alice (Backend) - 独立 loop → inbox → 作業 → 返信 - - - Bob (Frontend) - 独立 loop → inbox → 作業 → 返信 - - - Charlie (QA) - 独立 loop → inbox → 作業 → 返信 - - - - send - - inbox - - - - - - receive - receive - receive - - - - send - send - send - - diff --git a/s15_agent_teams/images/team-topology.svg b/s15_agent_teams/images/team-topology.svg index 9272e1b6d..093c1204e 100644 --- a/s15_agent_teams/images/team-topology.svg +++ b/s15_agent_teams/images/team-topology.svg @@ -1,72 +1,85 @@ - + - - - - - + + - - - - - - - - + + - - - - Team Topology — Lead ↔ MessageBus ↔ Teammates + + + + + Team Topology + Lead ↔ MessageBus ↔ Teammates + + + + Lead Agent + main loop + spawn + inbox + check_inbox for teammate msgs + + + + + send + + + + inbox - - - Lead Agent - 主循环 + spawn + inbox 处理 - check_inbox 接收队友消息 + + + MessageBus + .mailboxes/*.jsonl - - - Message Bus (.mailboxes/*.jsonl) + + + + receive + + send - - - Alice (Backend) - 独立 loop → inbox → 干活 → 回复 + + + receive + + send - - Bob (Frontend) - 独立 loop → inbox → 干活 → 回复 + + + receive + + send - - Charlie (QA) - 独立 loop → inbox → 干活 → 回复 + + + + Alice (Backend) + loop → inbox → work → reply + tools: bash, read, write, send - - - - send - - - inbox + + + Bob (Frontend) + loop → inbox → work → reply + tools: bash, read, write, send - - - - - - receive - receive - receive - - - - - send - send - send + + + Charlie (QA) + loop → inbox → work → reply + tools: bash, read, write, send + + + + + + + + + diff --git a/s16_team_protocols/README.en.md b/s16_team_protocols/README.en.md deleted file mode 100644 index 45cfb3103..000000000 --- a/s16_team_protocols/README.en.md +++ /dev/null @@ -1,241 +0,0 @@ -# s16: Team Protocols — Teammates Need Agreements - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → ... → s14 → s15 → `s16` → [s17](../s17_autonomous_agents/) → s18 → s19 → s20 -> *"Teammates need agreements"* — request-response pattern drives all negotiation. -> -> **Harness Layer**: Protocols — Structured handshakes between agents. - ---- - -## The Problem - -s15's teammates can work, but coordination is loose: Lead sends a message, teammate replies, no structured protocol. Two scenarios expose the gap: - -**Shutdown**: Lead wants Alice to shut down. Killing the thread outright leaves half-written files on disk. A handshake is needed: Lead sends a request, Alice confirms after wrapping up. - -**Plan approval**: Bob wants to refactor the auth module, a high-risk operation. Lead should review Bob's plan first, approve before Bob proceeds. - -Both scenarios share the same structure: one side sends a request, the other replies, both linked by the same ID. A state machine tracks: pending → approved / rejected. - ---- - -## The Solution - -![Team Protocols Overview](images/team-protocols-overview.en.svg) - -Teaching code continues the agent capability arc from earlier chapters and adds structured protocols on top of S15's team communication. To stay focused on the protocol mechanism, it omits full error recovery, memory, and skill systems. Added: **ProtocolState** (request state tracking), **dispatch_message** (routes incoming messages by type to handlers), **match_response** (correlates response to request via request_id, with type validation). - -Two protocols, one mechanism: - -| Protocol | Direction | Purpose | -|----------|-----------|---------| -| shutdown_request / response | Lead → Teammate | Graceful shutdown handshake | -| plan_approval_request / response | Teammate → Lead | Plan approval protocol example | - -> Teaching version demonstrates the request-response message flow for plan approval, but does not implement execution gating (intercepting bash/write_file when not approved). Real CC has a permission gating mechanism for teammates. - ---- - -## How It Works - -### ProtocolState: Request State - -Each protocol request creates a state record tracking who sent it, to whom, current status, and payload: - -```python -@dataclass -class ProtocolState: - request_id: str # Unique ID, e.g. "req_004281" - type: str # "shutdown" | "plan_approval" - sender: str # Sender - target: str # Recipient - status: str # pending | approved | rejected - payload: str # Plan text or shutdown reason - created_at: float # Timestamp - -pending_requests: dict[str, ProtocolState] = {} -``` - -A record is created when sending a request, found via `request_id` when receiving a response, and its status updated. - -### Four-Step Protocol Flow - -Using shutdown as an example, the full chain: - -``` -1. Lead sends request - req_id = new_request_id() # "req_004281" - pending_requests[req_id] = ProtocolState(type="shutdown", status="pending", ...) - BUS.send("lead", "alice", "shutdown_request", metadata={"request_id": req_id}) - -2. Teammate receives → dispatch - inbox = BUS.read_inbox("alice") - msg_type = msg["type"] # "shutdown_request" - → routed to handle_shutdown_request() - -3. Teammate replies - BUS.send("alice", "lead", "shutdown_response", - metadata={"request_id": req_id, "approve": True}) - -4. Lead receives response → match - match_response("shutdown_response", req_id, approve=True) - pending_requests[req_id].status = "approved" -``` - -`request_id` is the correlation key across the entire chain: the request carries it out, the response carries it back. - -### dispatch_message: Route by Type - -A teammate's inbox receives both plain messages and protocol messages. `handle_inbox_message` dispatches by message type: - -```python -def handle_inbox_message(name, msg, messages): - msg_type = msg.get("type", "message") - req_id = msg.get("metadata", {}).get("request_id", "") - - if msg_type == "shutdown_request": - BUS.send(name, "lead", "Shutting down.", "shutdown_response", - {"request_id": req_id, "approve": True}) - return True # Stop the loop - - if msg_type == "plan_approval_response": - approve = msg["metadata"].get("approve", False) - messages.append({"role": "user", - "content": "[Plan approved]" if approve else "[Plan rejected]"}) - return False # Continue -``` - -Adding a new protocol type means adding a new `if` branch. - -### match_response: Type Validation - -`match_response` doesn't just find state by `request_id`, it also validates that the response type matches the request type: - -```python -def match_response(response_type, request_id, approve): - state = pending_requests.get(request_id) - if not state: - return - if state.type == "shutdown" and response_type != "shutdown_response": - return # type mismatch, skip - if state.type == "plan_approval" and response_type != "plan_approval_response": - return - if state.status != "pending": - return # already resolved, skip duplicate - state.status = "approved" if approve else "rejected" -``` - -A shutdown_response cannot accidentally approve a plan_approval request. - -### Unified Inbox Consumer: consume_lead_inbox - -Both the `check_inbox` tool and the main loop call the same `consume_lead_inbox()` function, routing protocol messages before returning remaining content. This prevents messages from being consumed without protocol state updates: - -```python -def consume_lead_inbox(route_protocol=True) -> list[dict]: - msgs = BUS.read_inbox("lead") - if route_protocol: - for msg in msgs: - meta = msg.get("metadata", {}) - req_id = meta.get("request_id", "") - msg_type = msg.get("type", "") - if req_id and msg_type.endswith("_response"): - match_response(msg_type, req_id, meta.get("approve", False)) - return msgs -``` - -The main loop also injects inbox messages into `history` so the LLM can see and react to them. - -### Teammate Idle Loop: Wait Instead of Exit - -s15's teammates exit after 10 rounds. s16's teammates enter idle waiting after the LLM returns a non-tool_use response: poll inbox, respond to shutdown_request and exit, or continue working on new messages. - -``` -LLM returns non-tool_use - → idle: poll inbox every second - → receives shutdown_request → reply shutdown_response → exit - → receives new message → inject into messages → continue LLM turn -``` - -Teaching version omits idle_notification to Lead. Real CC sends `idle_notification` when idle, so Lead knows the teammate is free for new tasks. - -### Putting It Together - -``` -1. Lead: "Have Alice create a file, then shut her down" -2. Lead → spawn_teammate("alice", "backend", "Create config.py") -3. alice thread starts → write_file("config.py", "...") → done → idle -4. Lead → request_shutdown("alice") - → BUS.send("shutdown_request", {request_id: "req_000142"}) -5. alice idle poll receives → handle_shutdown_request - → BUS.send("shutdown_response", {request_id: "req_000142", approve: True}) -6. Lead consume_lead_inbox → match_response("req_000142", approve=True) - → pending_requests["req_000142"].status = "approved" - → inbox message injected into history, LLM sees shutdown result -``` - -Shutdown handshake complete: request → confirm → shutdown. Every step tracked by `request_id`. - ---- - -## Changes from s15 - -| Component | Before (s15) | After (s16) | -|-----------|-------------|-------------| -| Coordination | Loose text messages | Structured request-response protocol | -| Request tracking | None | ProtocolState + pending_requests dict | -| Message routing | All treated as text | dispatch_message routes by type | -| Shutdown | Natural exit or kill thread | request_id handshake mechanism | -| Plan approval | None | Message flow example (no execution gating) | -| New message types | message, result | + shutdown_request/response, plan_approval_request/response | -| Teammate lifecycle | Max 10 rounds | Idle loop (waits for inbox messages) | -| Lead inbox | check_inbox and main loop read separately | Unified consume_lead_inbox | -| Lead tools | 14 (s15) | 14 (core tool set plus request_shutdown, request_plan, review_plan) | -| Teammate tools | 4 (s15) | + submit_plan (5) | - ---- - -## Try It - -```sh -cd learn-claude-code -python s16_team_protocols/code.py -``` - -Try these prompts: - -1. `Spawn alice as a backend dev. Ask her to create a file. Then request her shutdown.` -2. `Spawn bob with a refactoring task. Have him submit a plan first. Then review and approve it.` - -What to observe: Is the shutdown handshake complete (request → confirm → shutdown)? Does `pending_requests` state transition correctly? Is `request_id` consistent between request and response? Can the idle teammate receive shutdown_request? - ---- - -## What's Next - -In s15-s16, Lead must assign tasks to each teammate. "Alice does this, Bob does that." With 10 unclaimed tasks on the board, Lead has to manually assign each one. - -What if teammates could check the board and claim tasks themselves? Lead only needs to create tasks; teammates discover, claim, and complete them on their own. - -s17 Autonomous Agents → Self-organizing teammates, no leader assignment needed. - -
-Deep Dive into CC Source - -CC's team protocol implementation (`teammateMailbox.ts`, 1184 lines) shares the same core structure as the teaching version: request_id + approve/reject request-response pattern. Differences: - -**Shutdown protocol**: CC's shutdown is three-way communication (`teammateMailbox.ts:720-763`, `SendMessageTool.ts:268-430`). Lead sends `shutdown_request`, teammate replies `shutdown_approved` (or `shutdown_rejected` with reason), system sends `teammate_terminated` to notify all parties. After confirmation, system cleans up pane (tmux/iTerm2), unassigns tasks, removes member from team config (`useInboxPoller.ts:677-800`). Teaching version uses `shutdown_response` as a unified name; real source splits into `shutdown_approved` and `shutdown_rejected` as two separate message types. - -**Plan approval**: In the real source, plan approval request is generated by `ExitPlanModeV2Tool.ts:263-312` when a plan-mode-required teammate exits plan mode. `useInboxPoller.ts:599-661` currently auto-writes approval and passes the request to Lead as context (regular message). `SendMessageTool.ts:434-518` retains explicit approve/reject response capability — approval can simultaneously set `permissionMode` (e.g. "approved but run in plan mode"), response can include `feedback` string for teammate to revise and resubmit. Not a simple "Lead manually uses review_plan tool" flow. - -**Message format**: CC's protocol messages are structured JSON (with Zod schema validation), teaching version uses simple type + metadata dict. Field names are also inconsistent: permission uses `request_id` (`teammateMailbox.ts:453-462`), shutdown and plan approval use `requestId` (`teammateMailbox.ts:684-763`). - -**Execution gating**: CC's teammates have full permission gating. Unapproved high-risk operations are intercepted, not optional. Teaching version only demonstrates the message flow without execution interception. - -**Generality**: Teaching version's single FSM (pending → approved | rejected) maps to two protocols. This simplification is correct. CC's protocol messages all share the same request id correlation mechanism. - -
- - diff --git a/s16_team_protocols/README.ja.md b/s16_team_protocols/README.ja.md index 8df8c30de..3cf7f33d3 100644 --- a/s16_team_protocols/README.ja.md +++ b/s16_team_protocols/README.ja.md @@ -1,6 +1,6 @@ # s16: Team Protocols — チームメイト間には取り決めが必要 -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s14 → s15 → `s16` → [s17](../s17_autonomous_agents/) → s18 → s19 → s20 > *"チームメイト間には取り決めが必要"* — request-response パターンが全てのネゴシエーションを駆動。 @@ -23,7 +23,7 @@ s15 のチームメイトは仕事ができるが、連携は緩い:Lead が ## ソリューション -![Team Protocols Overview](images/team-protocols-overview.ja.svg) +![Team Protocols Overview](images/team-protocols-overview.svg) 教学版は前章までの Agent 能力の流れを受け継ぎ、S15 のチーム通信の上に構造化プロトコルを追加する。プロトコル機構に集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。追加:**ProtocolState**(リクエスト状態追跡)、**dispatch_message**(メッセージタイプ別ルーティング)、**match_response**(request_id でリクエストとレスポンスを関連付け、型検証付き)。 @@ -34,7 +34,7 @@ s15 のチームメイトは仕事ができるが、連携は緩い:Lead が | shutdown_request / response | Lead → チームメイト | 丁寧なシャットダウンハンドシェイク | | plan_approval_request / response | チームメイト → Lead | 計画承認プロトコルの例 | -> 教学版は計画承認の request-response メッセージフローをデモするが、実行ゲーティング(未承認時の bash/write_file 拦截)は未実装。真实 CC にはチームメイト向けの permission gating 機構がある。 +> 教学版は計画承認の request-response メッセージフローをデモするが、実行ゲーティング(未承認時の bash/write_file 拦截)は未実装。真实 Claude Code にはチームメイト向けの permission gating 機構がある。 --- @@ -159,7 +159,7 @@ LLM が非 tool_use を返す → 新メッセージ受信 → messages に注入 → LLM ターン継続 ``` -教学版は Lead への idle_notification を省略。真实 CC は idle 時に `idle_notification` を送信、Lead はチームメイトが空いていることを知り、新しいタスクを割り当て可能。 +教学版は Lead への idle_notification を省略。真实 Claude Code は idle 時に `idle_notification` を送信、Lead はチームメイトが空いていることを知り、新しいタスクを割り当て可能。 ### 組み合わせて実行 @@ -222,19 +222,19 @@ s15-s16 では、Lead が各チームメイトにタスクを割り当てる必 s17 Autonomous Agents → チームメイトの自己組織化、リーダーの割り当て不要。
-CC ソースコード深掘り +Claude Code ソースコード深掘り -CC のチームプロトコル実装(`teammateMailbox.ts`、1184 行)は教学版と同じコア構造:request_id + approve/reject の request-response パターン。違いは以下の通り: +Claude Code のチームプロトコル実装(`teammateMailbox.ts`、1184 行)は教学版と同じコア構造:request_id + approve/reject の request-response パターン。違いは以下の通り: -**シャットダウンプロトコル**:CC のシャットダウンは三方向通信(`teammateMailbox.ts:720-763`、`SendMessageTool.ts:268-430`)。Lead が `shutdown_request` を送信、チームメイトが `shutdown_approved`(または理由付き `shutdown_rejected`)で返信、システムが `teammate_terminated` で全関係者に通知。確認後、システムが自動的に pane(tmux/iTerm2)をクリーンアップ、タスクを unassign、team config からメンバーを削除(`useInboxPoller.ts:677-800`)。教学版は `shutdown_response` で統一命名、真实源码は `shutdown_approved` と `shutdown_rejected` の 2 つの独立したメッセージ型に分割。 +**シャットダウンプロトコル**:Claude Code のシャットダウンは三方向通信(`teammateMailbox.ts:720-763`、`SendMessageTool.ts:268-430`)。Lead が `shutdown_request` を送信、チームメイトが `shutdown_approved`(または理由付き `shutdown_rejected`)で返信、システムが `teammate_terminated` で全関係者に通知。確認後、システムが自動的に pane(tmux/iTerm2)をクリーンアップ、タスクを unassign、team config からメンバーを削除(`useInboxPoller.ts:677-800`)。教学版は `shutdown_response` で統一命名、真实源码は `shutdown_approved` と `shutdown_rejected` の 2 つの独立したメッセージ型に分割。 **計画承認**:真实源码では plan approval request は `ExitPlanModeV2Tool.ts:263-312` で plan-mode-required チームメイトが plan mode を終了する際に生成される。`useInboxPoller.ts:599-661` は現在自動的に approval を書き戻し、リクエストを Lead にコンテキスト(regular message)として渡す。`SendMessageTool.ts:434-518` は明示的な approve/reject response 能力を保持、承認時に同時に `permissionMode` を設定可能(例:"承認するが plan mode で実行")、レスポンスにはチームメイトが修正して再提出するための `feedback` 文字列を含めることができる。単純な「Lead が手動で review_plan ツールを使う」フローではない。 -**メッセージ形式**:CC のプロトコルメッセージは構造化 JSON(Zod schema 検証付き)、教学版はシンプルな type + metadata dict。フィールド名も統一されていない:permission は `request_id`(`teammateMailbox.ts:453-462`)、shutdown と plan approval は `requestId`(`teammateMailbox.ts:684-763`)。 +**メッセージ形式**:Claude Code のプロトコルメッセージは構造化 JSON(Zod schema 検証付き)、教学版はシンプルな type + metadata dict。フィールド名も統一されていない:permission は `request_id`(`teammateMailbox.ts:453-462`)、shutdown と plan approval は `requestId`(`teammateMailbox.ts:684-763`)。 -**実行ゲーティング**:CC のチームメイトには完全な permission gating がある。未承認の高リスク操作は拦截され、オプションではない。教学版はメッセージフローのみをデモ。 +**実行ゲーティング**:Claude Code のチームメイトには完全な permission gating がある。未承認の高リスク操作は拦截され、オプションではない。教学版はメッセージフローのみをデモ。 -**汎用性**:教学版の 1 つの FSM(pending → approved | rejected)が 2 つのプロトコルに対応する簡略化は正しい。CC の全プロトコルメッセージは同じ request id 関連機構を共有。 +**汎用性**:教学版の 1 つの FSM(pending → approved | rejected)が 2 つのプロトコルに対応する簡略化は正しい。Claude Code の全プロトコルメッセージは同じ request id 関連機構を共有。
diff --git a/s16_team_protocols/README.md b/s16_team_protocols/README.md index d96190fec..50e28cb39 100644 --- a/s16_team_protocols/README.md +++ b/s16_team_protocols/README.md @@ -1,96 +1,94 @@ -# s16: Team Protocols — 队友之间要有约定 +# s16: Team Protocols — Teammates Need Agreements -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s14 → s15 → `s16` → [s17](../s17_autonomous_agents/) → s18 → s19 → s20 -> *"队友之间要有约定"* — request-response 模式驱动协商。 +> *"Teammates need agreements"* — request-response pattern drives all negotiation. > -> **Harness 层**: 协议 — Agent 之间的结构化握手。 +> **Harness Layer**: Protocols — Structured handshakes between agents. --- -## 问题 +## The Problem -s15 的队友能干活了,但协调是松散的:Lead 发消息,队友回复,没有结构化的协议。两个场景暴露了问题: +s15's teammates can work, but coordination is loose: Lead sends a message, teammate replies, no structured protocol. Two scenarios expose the gap: -**关机**:Lead 想让 Alice 关机。直接杀线程,Alice 写了一半的文件留在磁盘上。需要握手:Lead 发请求,Alice 确认收尾后关机。 +**Shutdown**: Lead wants Alice to shut down. Killing the thread outright leaves half-written files on disk. A handshake is needed: Lead sends a request, Alice confirms after wrapping up. -**计划审批**:Bob 想重构认证模块,属于高风险操作。应该先让 Lead 看 Bob 的计划,审批通过后再动手。 +**Plan approval**: Bob wants to refactor the auth module, a high-risk operation. Lead should review Bob's plan first, approve before Bob proceeds. -这两个场景结构完全一样:一方发请求,另一方给回复,请求和回复通过同一个 ID 关联。有状态机追踪:pending → approved / rejected。 +Both scenarios share the same structure: one side sends a request, the other replies, both linked by the same ID. A state machine tracks: pending → approved / rejected. --- -## 解决方案 +## The Solution ![Team Protocols Overview](images/team-protocols-overview.svg) -教学代码承接前面章节的 Agent 能力脉络,在 S15 团队通信基础上加入结构化协议。为了聚焦协议机制,省略了完整错误恢复、记忆和技能系统。新增三样:**ProtocolState**(请求状态追踪)、**dispatch_message**(按消息类型路由到处理器)、**match_response**(通过 request_id 关联回复与请求,含类型校验)。 +Teaching code continues the agent capability arc from earlier chapters and adds structured protocols on top of S15's team communication. To stay focused on the protocol mechanism, it omits full error recovery, memory, and skill systems. Added: **ProtocolState** (request state tracking), **dispatch_message** (routes incoming messages by type to handlers), **match_response** (correlates response to request via request_id, with type validation). -两种协议,一套机制: +Two protocols, one mechanism: -| 协议 | 方向 | 用途 | -|------|------|------| -| shutdown_request / response | Lead → 队友 | 体面关机握手 | -| plan_approval_request / response | 队友 → Lead | 计划审批协议示例 | +| Protocol | Direction | Purpose | +|----------|-----------|---------| +| shutdown_request / response | Lead → Teammate | Graceful shutdown handshake | +| plan_approval_request / response | Teammate → Lead | Plan approval protocol example | -> 教学版演示了计划审批的请求-响应消息流程,没有实现执行门控(未 approved 时拦截 bash/write_file)。真实 CC 的队友有 permission gating 机制。 +> Teaching version demonstrates the request-response message flow for plan approval, but does not implement execution gating (intercepting bash/write_file when not approved). Real Claude Code has a permission gating mechanism for teammates. --- -## 工作原理 +## How It Works -### ProtocolState: 请求状态 +### ProtocolState: Request State -每个协议请求创建一条状态记录,记录谁发的、发给谁、当前状态、附带内容: +Each protocol request creates a state record tracking who sent it, to whom, current status, and payload: ```python @dataclass class ProtocolState: - request_id: str # 唯一 ID,如 "req_004281" + request_id: str # Unique ID, e.g. "req_004281" type: str # "shutdown" | "plan_approval" - sender: str # 发起方 - target: str # 接收方 + sender: str # Sender + target: str # Recipient status: str # pending | approved | rejected - payload: str # 计划文本或关机原因 - created_at: float # 时间戳 + payload: str # Plan text or shutdown reason + created_at: float # Timestamp pending_requests: dict[str, ProtocolState] = {} ``` -发请求时创建记录,收回复时通过 `request_id` 找到对应记录,更新状态。 +A record is created when sending a request, found via `request_id` when receiving a response, and its status updated. -### 四步协议流程 +### Four-Step Protocol Flow -以关机为例,完整链路: +Using shutdown as an example, the full chain: ``` -① Lead 发请求 +1. Lead sends request req_id = new_request_id() # "req_004281" pending_requests[req_id] = ProtocolState(type="shutdown", status="pending", ...) BUS.send("lead", "alice", "shutdown_request", metadata={"request_id": req_id}) -② 队友收到 → dispatch +2. Teammate receives → dispatch inbox = BUS.read_inbox("alice") msg_type = msg["type"] # "shutdown_request" - → 路由到 handle_shutdown_request() + → routed to handle_shutdown_request() -③ 队友回复 +3. Teammate replies BUS.send("alice", "lead", "shutdown_response", metadata={"request_id": req_id, "approve": True}) -④ Lead 收响应 → match +4. Lead receives response → match match_response("shutdown_response", req_id, approve=True) pending_requests[req_id].status = "approved" ``` -`request_id` 是贯穿全链路的关联键,请求带着它出去,回复带着它回来。 +`request_id` is the correlation key across the entire chain: the request carries it out, the response carries it back. -> 教学版用 `shutdown_response` 统一命名(approve 字段区分同意/拒绝)。真实源码拆成 `shutdown_approved` 和 `shutdown_rejected` 两种独立消息类型(`teammateMailbox.ts:720-763`)。 +### dispatch_message: Route by Type -### dispatch_message: 按类型路由 - -队友的 inbox 不只收普通消息,还收协议消息。`handle_inbox_message` 按消息类型分发: +A teammate's inbox receives both plain messages and protocol messages. `handle_inbox_message` dispatches by message type: ```python def handle_inbox_message(name, msg, messages): @@ -100,20 +98,20 @@ def handle_inbox_message(name, msg, messages): if msg_type == "shutdown_request": BUS.send(name, "lead", "Shutting down.", "shutdown_response", {"request_id": req_id, "approve": True}) - return True # 停止循环 + return True # Stop the loop if msg_type == "plan_approval_response": approve = msg["metadata"].get("approve", False) messages.append({"role": "user", "content": "[Plan approved]" if approve else "[Plan rejected]"}) - return False # 继续循环 + return False # Continue ``` -新增协议类型只需加新的 `if` 分支。 +Adding a new protocol type means adding a new `if` branch. -### match_response: 类型校验 +### match_response: Type Validation -`match_response` 不只按 `request_id` 找状态,还会校验响应类型是否匹配请求类型: +`match_response` doesn't just find state by `request_id`, it also validates that the response type matches the request type: ```python def match_response(response_type, request_id, approve): @@ -129,11 +127,11 @@ def match_response(response_type, request_id, approve): state.status = "approved" if approve else "rejected" ``` -一个 shutdown_response 不会意外 approve 一个 plan_approval 请求。 +A shutdown_response cannot accidentally approve a plan_approval request. -### 统一 inbox 消费:consume_lead_inbox +### Unified Inbox Consumer: consume_lead_inbox -`check_inbox` 工具和主循环末尾都调用同一个 `consume_lead_inbox()` 函数,先路由协议消息再返回剩余内容,避免消息被读走但协议状态没更新: +Both the `check_inbox` tool and the main loop call the same `consume_lead_inbox()` function, routing protocol messages before returning remaining content. This prevents messages from being consumed without protocol state updates: ```python def consume_lead_inbox(route_protocol=True) -> list[dict]: @@ -148,95 +146,95 @@ def consume_lead_inbox(route_protocol=True) -> list[dict]: return msgs ``` -主循环末尾还会把 inbox 消息注入到 `history`,让 LLM 能看到并做出反应。 +The main loop also injects inbox messages into `history` so the LLM can see and react to them. -### 队友 idle loop:等待而不是退出 +### Teammate Idle Loop: Wait Instead of Exit -s15 的队友跑完 10 轮就退出。s16 的队友在 LLM 返回非 tool_use 后进入 idle 等待:轮询 inbox,收到 shutdown_request 就响应退出,收到新消息就继续工作。 +s15's teammates exit after 10 rounds. s16's teammates enter idle waiting after the LLM returns a non-tool_use response: poll inbox, respond to shutdown_request and exit, or continue working on new messages. ``` -LLM 返回非 tool_use - → idle: 每秒轮询 inbox - → 收到 shutdown_request → 回复 shutdown_response → 退出 - → 收到新消息 → 注入 messages → 继续 LLM turn +LLM returns non-tool_use + → idle: poll inbox every second + → receives shutdown_request → reply shutdown_response → exit + → receives new message → inject into messages → continue LLM turn ``` -教学版省略了 idle_notification 给 Lead 的通知。真实 CC 在 idle 时发 `idle_notification`,Lead 收到后知道队友空闲,可以分配新任务。 +Teaching version omits idle_notification to Lead. Real Claude Code sends `idle_notification` when idle, so Lead knows the teammate is free for new tasks. -### 合起来跑 +### Putting It Together ``` -1. Lead: "让 Alice 创建一个文件,然后关机" -2. Lead → spawn_teammate("alice", "backend", "创建 config.py") -3. alice 线程启动 → write_file("config.py", "...") → 完成 → idle +1. Lead: "Have Alice create a file, then shut her down" +2. Lead → spawn_teammate("alice", "backend", "Create config.py") +3. alice thread starts → write_file("config.py", "...") → done → idle 4. Lead → request_shutdown("alice") → BUS.send("shutdown_request", {request_id: "req_000142"}) -5. alice idle 轮询收到 → handle_shutdown_request +5. alice idle poll receives → handle_shutdown_request → BUS.send("shutdown_response", {request_id: "req_000142", approve: True}) 6. Lead consume_lead_inbox → match_response("req_000142", approve=True) → pending_requests["req_000142"].status = "approved" - → inbox 消息注入 history,LLM 看到关机结果 + → inbox message injected into history, LLM sees shutdown result ``` -关机握手完整:请求 → 确认 → 关机。每一步有 `request_id` 追溯。 +Shutdown handshake complete: request → confirm → shutdown. Every step tracked by `request_id`. --- -## 相对 s15 的变更 - -| 组件 | 之前 (s15) | 之后 (s16) | -|------|-----------|-----------| -| 协调方式 | 松散文本消息 | 结构化请求-响应协议 | -| 请求追踪 | 无 | ProtocolState + pending_requests dict | -| 消息路由 | 全部当文本处理 | dispatch_message 按类型分发 | -| 关机 | 自然退出或杀线程 | request_id 握手机制 | -| 计划审批 | 无 | 消息流程示例(未实现执行门控) | -| 新消息类型 | message, result | + shutdown_request/response, plan_approval_request/response | -| 队友生命周期 | 最多 10 轮 | idle loop(等待 inbox 消息) | -| Lead inbox | check_inbox 和主循环分别读 | 统一 consume_lead_inbox | -| Lead 工具 | 14 (s15) | 14(核心工具集加入 request_shutdown, request_plan, review_plan) | -| 队友工具 | 4 (s15) | + submit_plan (5) | +## Changes from s15 + +| Component | Before (s15) | After (s16) | +|-----------|-------------|-------------| +| Coordination | Loose text messages | Structured request-response protocol | +| Request tracking | None | ProtocolState + pending_requests dict | +| Message routing | All treated as text | dispatch_message routes by type | +| Shutdown | Natural exit or kill thread | request_id handshake mechanism | +| Plan approval | None | Message flow example (no execution gating) | +| New message types | message, result | + shutdown_request/response, plan_approval_request/response | +| Teammate lifecycle | Max 10 rounds | Idle loop (waits for inbox messages) | +| Lead inbox | check_inbox and main loop read separately | Unified consume_lead_inbox | +| Lead tools | 14 (s15) | 14 (core tool set plus request_shutdown, request_plan, review_plan) | +| Teammate tools | 4 (s15) | + submit_plan (5) | --- -## 试一下 +## Try It ```sh cd learn-claude-code python s16_team_protocols/code.py ``` -试试这些 prompt: +Try these prompts: 1. `Spawn alice as a backend dev. Ask her to create a file. Then request her shutdown.` 2. `Spawn bob with a refactoring task. Have him submit a plan first. Then review and approve it.` -观察重点:关机握手是否完整(请求 → 确认 → 关机)?`pending_requests` 的状态是否正确转换?`request_id` 是否在请求和响应之间保持一致?队友 idle 后是否能收到 shutdown_request? +What to observe: Is the shutdown handshake complete (request → confirm → shutdown)? Does `pending_requests` state transition correctly? Is `request_id` consistent between request and response? Can the idle teammate receive shutdown_request? --- -## 接下来 +## What's Next -s15-s16 中,Lead 必须给每个队友分配任务。"Alice 做这个,Bob 做那个"。任务看板上有 10 个未认领的任务,Lead 得手动 assign。 +In s15-s16, Lead must assign tasks to each teammate. "Alice does this, Bob does that." With 10 unclaimed tasks on the board, Lead has to manually assign each one. -能不能让队友自己看板、自己认领?Lead 只需要创建任务,队友自己发现、自己认领、自己完成。 +What if teammates could check the board and claim tasks themselves? Lead only needs to create tasks; teammates discover, claim, and complete them on their own. -s17 Autonomous Agents → 队友自组织,不需要领导分配。 +s17 Autonomous Agents → Self-organizing teammates, no leader assignment needed.
-深入 CC 源码 +Deep Dive into Claude Code Source -CC 的团队协议实现(`teammateMailbox.ts`,1184 行)和教学版在核心结构上一致:request_id + approve/reject 的请求-响应模式。差异在于: +Claude Code's team protocol implementation (`teammateMailbox.ts`, 1184 lines) shares the same core structure as the teaching version: request_id + approve/reject request-response pattern. Differences: -**关机协议**:CC 的 shutdown 是三向通信(`teammateMailbox.ts:720-763`、`SendMessageTool.ts:268-430`)。Lead 发 `shutdown_request`,队友回复 `shutdown_approved`(或 `shutdown_rejected` 附原因),系统发送 `teammate_terminated` 通知所有相关方。关机确认后系统自动清理 pane(tmux/iTerm2)、unassign 任务、从 team config 移除成员(`useInboxPoller.ts:677-800`)。教学版用 `shutdown_response` 统一命名,真实源码拆成 approved/rejected 两种独立消息。 +**Shutdown protocol**: Claude Code's shutdown is three-way communication (`teammateMailbox.ts:720-763`, `SendMessageTool.ts:268-430`). Lead sends `shutdown_request`, teammate replies `shutdown_approved` (or `shutdown_rejected` with reason), system sends `teammate_terminated` to notify all parties. After confirmation, system cleans up pane (tmux/iTerm2), unassigns tasks, removes member from team config (`useInboxPoller.ts:677-800`). Teaching version uses `shutdown_response` as a unified name; real source splits into `shutdown_approved` and `shutdown_rejected` as two separate message types. -**计划审批**:真实源码里 plan approval request 由 `ExitPlanModeV2Tool.ts:263-312` 在 plan-mode-required 队友退出 plan mode 时产生。`useInboxPoller.ts:599-661` 当前会自动回写 approval,并把请求交给 Lead 作为上下文(regular message)。`SendMessageTool.ts:434-518` 仍保留显式 approve/reject response 能力,审批时可同时设置 `permissionMode`(如"批准但以 plan mode 运行"),响应中可包含 `feedback` 字符串供队友修正后重新提交。不是简单的"Lead 手动 review_plan 工具"流程。 +**Plan approval**: In the real source, plan approval request is generated by `ExitPlanModeV2Tool.ts:263-312` when a plan-mode-required teammate exits plan mode. `useInboxPoller.ts:599-661` currently auto-writes approval and passes the request to Lead as context (regular message). `SendMessageTool.ts:434-518` retains explicit approve/reject response capability — approval can simultaneously set `permissionMode` (e.g. "approved but run in plan mode"), response can include `feedback` string for teammate to revise and resubmit. Not a simple "Lead manually uses review_plan tool" flow. -**消息格式**:CC 的协议消息是结构化的 JSON(有 Zod schema 验证),教学版用简单的 type + metadata 字典。字段名也不统一:permission 用 `request_id`(`teammateMailbox.ts:453-462`),shutdown 和 plan approval 用 `requestId`(`teammateMailbox.ts:684-763`)。 +**Message format**: Claude Code's protocol messages are structured JSON (with Zod schema validation), teaching version uses simple type + metadata dict. Field names are also inconsistent: permission uses `request_id` (`teammateMailbox.ts:453-462`), shutdown and plan approval use `requestId` (`teammateMailbox.ts:684-763`). -**执行门控**:CC 的队友有完整的 permission gating。未获批准的高风险操作会被拦截,不是可选的。教学版只演示了消息流程,没有实现执行拦截。 +**Execution gating**: Claude Code's teammates have full permission gating. Unapproved high-risk operations are intercepted, not optional. Teaching version only demonstrates the message flow without execution interception. -**通用性**:教学版的一个 FSM(pending → approved | rejected)对应两种协议,这个简化完全正确。CC 的所有协议消息共用同一个 request id 关联机制。 +**Generality**: Teaching version's single FSM (pending → approved | rejected) maps to two protocols. This simplification is correct. Claude Code's protocol messages all share the same request id correlation mechanism.
diff --git a/s16_team_protocols/README.zh.md b/s16_team_protocols/README.zh.md new file mode 100644 index 000000000..97558c825 --- /dev/null +++ b/s16_team_protocols/README.zh.md @@ -0,0 +1,243 @@ +# s16: Team Protocols — 队友之间要有约定 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s14 → s15 → `s16` → [s17](../s17_autonomous_agents/) → s18 → s19 → s20 +> *"队友之间要有约定"* — request-response 模式驱动协商。 +> +> **Harness 层**: 协议 — Agent 之间的结构化握手。 + +--- + +## 问题 + +s15 的队友能干活了,但协调是松散的:Lead 发消息,队友回复,没有结构化的协议。两个场景暴露了问题: + +**关机**:Lead 想让 Alice 关机。直接杀线程,Alice 写了一半的文件留在磁盘上。需要握手:Lead 发请求,Alice 确认收尾后关机。 + +**计划审批**:Bob 想重构认证模块,属于高风险操作。应该先让 Lead 看 Bob 的计划,审批通过后再动手。 + +这两个场景结构完全一样:一方发请求,另一方给回复,请求和回复通过同一个 ID 关联。有状态机追踪:pending → approved / rejected。 + +--- + +## 解决方案 + +![Team Protocols Overview](images/team-protocols-overview.svg) + +教学代码承接前面章节的 Agent 能力脉络,在 S15 团队通信基础上加入结构化协议。为了聚焦协议机制,省略了完整错误恢复、记忆和技能系统。新增三样:**ProtocolState**(请求状态追踪)、**dispatch_message**(按消息类型路由到处理器)、**match_response**(通过 request_id 关联回复与请求,含类型校验)。 + +两种协议,一套机制: + +| 协议 | 方向 | 用途 | +|------|------|------| +| shutdown_request / response | Lead → 队友 | 体面关机握手 | +| plan_approval_request / response | 队友 → Lead | 计划审批协议示例 | + +> 教学版演示了计划审批的请求-响应消息流程,没有实现执行门控(未 approved 时拦截 bash/write_file)。真实 Claude Code 的队友有 permission gating 机制。 + +--- + +## 工作原理 + +### ProtocolState: 请求状态 + +每个协议请求创建一条状态记录,记录谁发的、发给谁、当前状态、附带内容: + +```python +@dataclass +class ProtocolState: + request_id: str # 唯一 ID,如 "req_004281" + type: str # "shutdown" | "plan_approval" + sender: str # 发起方 + target: str # 接收方 + status: str # pending | approved | rejected + payload: str # 计划文本或关机原因 + created_at: float # 时间戳 + +pending_requests: dict[str, ProtocolState] = {} +``` + +发请求时创建记录,收回复时通过 `request_id` 找到对应记录,更新状态。 + +### 四步协议流程 + +以关机为例,完整链路: + +``` +① Lead 发请求 + req_id = new_request_id() # "req_004281" + pending_requests[req_id] = ProtocolState(type="shutdown", status="pending", ...) + BUS.send("lead", "alice", "shutdown_request", metadata={"request_id": req_id}) + +② 队友收到 → dispatch + inbox = BUS.read_inbox("alice") + msg_type = msg["type"] # "shutdown_request" + → 路由到 handle_shutdown_request() + +③ 队友回复 + BUS.send("alice", "lead", "shutdown_response", + metadata={"request_id": req_id, "approve": True}) + +④ Lead 收响应 → match + match_response("shutdown_response", req_id, approve=True) + pending_requests[req_id].status = "approved" +``` + +`request_id` 是贯穿全链路的关联键,请求带着它出去,回复带着它回来。 + +> 教学版用 `shutdown_response` 统一命名(approve 字段区分同意/拒绝)。真实源码拆成 `shutdown_approved` 和 `shutdown_rejected` 两种独立消息类型(`teammateMailbox.ts:720-763`)。 + +### dispatch_message: 按类型路由 + +队友的 inbox 不只收普通消息,还收协议消息。`handle_inbox_message` 按消息类型分发: + +```python +def handle_inbox_message(name, msg, messages): + msg_type = msg.get("type", "message") + req_id = msg.get("metadata", {}).get("request_id", "") + + if msg_type == "shutdown_request": + BUS.send(name, "lead", "Shutting down.", "shutdown_response", + {"request_id": req_id, "approve": True}) + return True # 停止循环 + + if msg_type == "plan_approval_response": + approve = msg["metadata"].get("approve", False) + messages.append({"role": "user", + "content": "[Plan approved]" if approve else "[Plan rejected]"}) + return False # 继续循环 +``` + +新增协议类型只需加新的 `if` 分支。 + +### match_response: 类型校验 + +`match_response` 不只按 `request_id` 找状态,还会校验响应类型是否匹配请求类型: + +```python +def match_response(response_type, request_id, approve): + state = pending_requests.get(request_id) + if not state: + return + if state.type == "shutdown" and response_type != "shutdown_response": + return # type mismatch, skip + if state.type == "plan_approval" and response_type != "plan_approval_response": + return + if state.status != "pending": + return # already resolved, skip duplicate + state.status = "approved" if approve else "rejected" +``` + +一个 shutdown_response 不会意外 approve 一个 plan_approval 请求。 + +### 统一 inbox 消费:consume_lead_inbox + +`check_inbox` 工具和主循环末尾都调用同一个 `consume_lead_inbox()` 函数,先路由协议消息再返回剩余内容,避免消息被读走但协议状态没更新: + +```python +def consume_lead_inbox(route_protocol=True) -> list[dict]: + msgs = BUS.read_inbox("lead") + if route_protocol: + for msg in msgs: + meta = msg.get("metadata", {}) + req_id = meta.get("request_id", "") + msg_type = msg.get("type", "") + if req_id and msg_type.endswith("_response"): + match_response(msg_type, req_id, meta.get("approve", False)) + return msgs +``` + +主循环末尾还会把 inbox 消息注入到 `history`,让 LLM 能看到并做出反应。 + +### 队友 idle loop:等待而不是退出 + +s15 的队友跑完 10 轮就退出。s16 的队友在 LLM 返回非 tool_use 后进入 idle 等待:轮询 inbox,收到 shutdown_request 就响应退出,收到新消息就继续工作。 + +``` +LLM 返回非 tool_use + → idle: 每秒轮询 inbox + → 收到 shutdown_request → 回复 shutdown_response → 退出 + → 收到新消息 → 注入 messages → 继续 LLM turn +``` + +教学版省略了 idle_notification 给 Lead 的通知。真实 Claude Code 在 idle 时发 `idle_notification`,Lead 收到后知道队友空闲,可以分配新任务。 + +### 合起来跑 + +``` +1. Lead: "让 Alice 创建一个文件,然后关机" +2. Lead → spawn_teammate("alice", "backend", "创建 config.py") +3. alice 线程启动 → write_file("config.py", "...") → 完成 → idle +4. Lead → request_shutdown("alice") + → BUS.send("shutdown_request", {request_id: "req_000142"}) +5. alice idle 轮询收到 → handle_shutdown_request + → BUS.send("shutdown_response", {request_id: "req_000142", approve: True}) +6. Lead consume_lead_inbox → match_response("req_000142", approve=True) + → pending_requests["req_000142"].status = "approved" + → inbox 消息注入 history,LLM 看到关机结果 +``` + +关机握手完整:请求 → 确认 → 关机。每一步有 `request_id` 追溯。 + +--- + +## 相对 s15 的变更 + +| 组件 | 之前 (s15) | 之后 (s16) | +|------|-----------|-----------| +| 协调方式 | 松散文本消息 | 结构化请求-响应协议 | +| 请求追踪 | 无 | ProtocolState + pending_requests dict | +| 消息路由 | 全部当文本处理 | dispatch_message 按类型分发 | +| 关机 | 自然退出或杀线程 | request_id 握手机制 | +| 计划审批 | 无 | 消息流程示例(未实现执行门控) | +| 新消息类型 | message, result | + shutdown_request/response, plan_approval_request/response | +| 队友生命周期 | 最多 10 轮 | idle loop(等待 inbox 消息) | +| Lead inbox | check_inbox 和主循环分别读 | 统一 consume_lead_inbox | +| Lead 工具 | 14 (s15) | 14(核心工具集加入 request_shutdown, request_plan, review_plan) | +| 队友工具 | 4 (s15) | + submit_plan (5) | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s16_team_protocols/code.py +``` + +试试这些 prompt: + +1. `Spawn alice as a backend dev. Ask her to create a file. Then request her shutdown.` +2. `Spawn bob with a refactoring task. Have him submit a plan first. Then review and approve it.` + +观察重点:关机握手是否完整(请求 → 确认 → 关机)?`pending_requests` 的状态是否正确转换?`request_id` 是否在请求和响应之间保持一致?队友 idle 后是否能收到 shutdown_request? + +--- + +## 接下来 + +s15-s16 中,Lead 必须给每个队友分配任务。"Alice 做这个,Bob 做那个"。任务看板上有 10 个未认领的任务,Lead 得手动 assign。 + +能不能让队友自己看板、自己认领?Lead 只需要创建任务,队友自己发现、自己认领、自己完成。 + +s17 Autonomous Agents → 队友自组织,不需要领导分配。 + +
+深入 Claude Code 源码 + +Claude Code 的团队协议实现(`teammateMailbox.ts`,1184 行)和教学版在核心结构上一致:request_id + approve/reject 的请求-响应模式。差异在于: + +**关机协议**:Claude Code 的 shutdown 是三向通信(`teammateMailbox.ts:720-763`、`SendMessageTool.ts:268-430`)。Lead 发 `shutdown_request`,队友回复 `shutdown_approved`(或 `shutdown_rejected` 附原因),系统发送 `teammate_terminated` 通知所有相关方。关机确认后系统自动清理 pane(tmux/iTerm2)、unassign 任务、从 team config 移除成员(`useInboxPoller.ts:677-800`)。教学版用 `shutdown_response` 统一命名,真实源码拆成 approved/rejected 两种独立消息。 + +**计划审批**:真实源码里 plan approval request 由 `ExitPlanModeV2Tool.ts:263-312` 在 plan-mode-required 队友退出 plan mode 时产生。`useInboxPoller.ts:599-661` 当前会自动回写 approval,并把请求交给 Lead 作为上下文(regular message)。`SendMessageTool.ts:434-518` 仍保留显式 approve/reject response 能力,审批时可同时设置 `permissionMode`(如"批准但以 plan mode 运行"),响应中可包含 `feedback` 字符串供队友修正后重新提交。不是简单的"Lead 手动 review_plan 工具"流程。 + +**消息格式**:Claude Code 的协议消息是结构化的 JSON(有 Zod schema 验证),教学版用简单的 type + metadata 字典。字段名也不统一:permission 用 `request_id`(`teammateMailbox.ts:453-462`),shutdown 和 plan approval 用 `requestId`(`teammateMailbox.ts:684-763`)。 + +**执行门控**:Claude Code 的队友有完整的 permission gating。未获批准的高风险操作会被拦截,不是可选的。教学版只演示了消息流程,没有实现执行拦截。 + +**通用性**:教学版的一个 FSM(pending → approved | rejected)对应两种协议,这个简化完全正确。Claude Code 的所有协议消息共用同一个 request id 关联机制。 + +
+ + diff --git a/s16_team_protocols/code.py b/s16_team_protocols/code.py index c1ec6636d..ebac6521b 100644 --- a/s16_team_protocols/code.py +++ b/s16_team_protocols/code.py @@ -340,7 +340,7 @@ def collect_background_results() -> list[str]: class MessageBus: """File-based message bus. Each agent has a .jsonl inbox. Read is destructive: read_text + unlink (consumes messages). - Teaching version: no file locking; real CC uses proper-lockfile.""" + Teaching version: no file locking; real Claude Code uses proper-lockfile.""" def send(self, from_agent: str, to_agent: str, content: str, msg_type: str = "message", metadata: dict = None): @@ -541,7 +541,7 @@ def run(): messages.append({"role": "assistant", "content": response.content}) if response.stop_reason != "tool_use": # Idle: wait for inbox messages instead of exiting - # Real CC sends idle_notification to Lead here + # Real Claude Code sends idle_notification to Lead here while not shutdown_requested: time.sleep(1) inbox = BUS.read_inbox(name) diff --git a/s16_team_protocols/images/team-protocols-overview.en.svg b/s16_team_protocols/images/team-protocols-overview.en.svg deleted file mode 100644 index 7dd6b28c4..000000000 --- a/s16_team_protocols/images/team-protocols-overview.en.svg +++ /dev/null @@ -1,143 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - Team Protocols — Request-Response + request_id Correlation + State Machine - - - - s15 Preserved - - s16 New - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH (core tool set) - bash · read · write · task(4) · spawn · send · inbox - ★ request_shutdown · request_plan · review_plan - - - - - - - Request-Response Protocol Flow (request_id throughout) - - - - ① Lead sends request - BUS.send("shutdown_request" - metadata={request_id}) - - - - - - ② Teammate receives - dispatch_by_type(inbox) - → handler(type, metadata) - - - - - - ③ Teammate responds - BUS.send("shutdown_response" - same request_id + approve) - - - - - - ④ Lead receives - match_response(request_id) - → resolve/reject callback - - - - - State Machine (same for both protocols) - - - pending - - - approve - - - approved - - - reject - - - rejected - - - - pending_requests Storage - pending_requests: dict[str, ProtocolState] - request_id → {type, sender, status, created_at} - match_response: find request by request_id - - - - Two protocols, one mechanism: - - shutdown_request - and - - plan_approval_request - share the same pending→approved/rejected FSM - New protocol type = new msg_type, no new state machine. request_id links request and response. - - - - - s15: MessageBus + spawn_teammate + inbox - - s16: request_id protocol + dispatch + pending_requests + state machine - diff --git a/s16_team_protocols/images/team-protocols-overview.ja.svg b/s16_team_protocols/images/team-protocols-overview.ja.svg deleted file mode 100644 index 28b368b97..000000000 --- a/s16_team_protocols/images/team-protocols-overview.ja.svg +++ /dev/null @@ -1,141 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - Team Protocols — リクエスト・レスポンス + request_id 紐付け + 状態機械 - - - - s15 保持 - - s16 新規 - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH(コアツールセット) - bash · read · write · task(4) · spawn · send · inbox - ★ request_shutdown · request_plan · review_plan - - - - - - - リクエスト・レスポンスプロトコルフロー(request_id が全チェーンを貫通) - - - - ① Lead が要求送信 - BUS.send("shutdown_request" - metadata={request_id}) - - - - - - ② チームメイト受信 - dispatch_by_type(inbox) - → handler(type, metadata) - - - - - - ③ チームメイト応答 - BUS.send("shutdown_response" - 同じ request_id + approve) - - - - - - ④ Lead 応答受信 - match_response(request_id) - → resolve/reject callback - - - - 状態機械(2 つのプロトコルで共通) - - - pending - - - approve - - - approved - - - reject - - - rejected - - - pending_requests ストレージ - pending_requests: dict[str, ProtocolState] - request_id → {type, sender, status, created_at} - match_response: request_id で要求を検索 - - - - 2 つのプロトコル、1 つの仕組み: - - shutdown_request - - - plan_approval_request - が pending→approved/rejected 状態機械を共有 - 新しいプロトコルタイプ = 新しい msg_type、新しい状態機械は不要。request_id が要求と応答を紐付け。 - - - - - s15: MessageBus + spawn_teammate + inbox - - s16: request_id プロトコル + dispatch + pending_requests + 状態機械 - diff --git a/s16_team_protocols/images/team-protocols-overview.svg b/s16_team_protocols/images/team-protocols-overview.svg index 04a9a8025..787c60a5e 100644 --- a/s16_team_protocols/images/team-protocols-overview.svg +++ b/s16_team_protocols/images/team-protocols-overview.svg @@ -1,148 +1,162 @@ - + - - - - - + + - - - - - - - - - - - + + - + + - - - Team Protocols — 请求-响应协议 + request_id 关联 + 状态机 + Team Protocols Overview + Request-Response Protocol + request_id Correlation + State Machine - - - s15 保留 - - s16 新增 + + + Lead Agent Loop - - - turn + + + turn - + + - - messages + + + messages - + + - - prompt + + + prompt - + + - - LLM + + + LLM - + + - - TOOL DISPATCH(核心工具集) - bash · read · write · task(4) · spawn · send · inbox - ★ request_shutdown · request_plan · review_plan + + + TOOL DISPATCH + bash read write task spawn send inbox ... - - + + + loop back - - - 请求-响应协议流程(request_id 贯穿) + + + Request-Response Protocol Flow (linked by request_id) - - - ① Lead 发请求 - BUS.send("shutdown_request" - metadata={request_id}) + + + 1. Lead Sends Request + + BUS.send( + "shutdown_request") - + + - - - ② 队友收到 - dispatch_by_type(inbox) - → handler(type, metadata) + + + 2. Teammate Receives + + dispatch_message() + → handler(type) - + + - - - ③ 队友回复 - BUS.send("shutdown_response" - 同 request_id + approve) + + + 3. Teammate Replies + + BUS.send( + "shutdown_response") - + + - - ④ Lead 收响应 - match_response(request_id) - → resolve/reject callback - - - - - 状态机(同一套,两种协议) - - - - pending - - - - approve - - - - approved - - - - reject - - - - rejected - - - - pending_requests 存储 - pending_requests: dict[str, ProtocolState] - request_id → {type, sender, status, created_at} - match_response: 按 request_id 找回对应请求 - - - - 两种协议,同一套机制: - - shutdown_request - - - plan_approval_request - 共用 pending→approved/rejected 状态机 - 新增协议类型 = 新的 msg_type,不需要新状态机。request_id 关联请求和响应。 - - - - - s15: MessageBus + spawn_teammate + inbox - - s16: request_id 协议 + dispatch + pending_requests + 状态机 - + + 4. Lead Matches + + match_response( + request_id) + + + + + + + request_id links all + + + + State Machine + Same FSM for both protocol types + + + + pending + + + + approve + + + + approved + + + + reject + + + + rejected + + + + pending_requests Storage + + + pending_requests: + dict[str, ProtocolState] + request_id → {type, sender, + status, created_at} + + + + Two Protocols, One Mechanism + + + + shutdown_request / response + Lead → Teammate (graceful shutdown) + + + + plan_approval_request / response + Teammate → Lead (plan review) + + + + + + + shared FSM + \ No newline at end of file diff --git a/s17_autonomous_agents/README.en.md b/s17_autonomous_agents/README.en.md deleted file mode 100644 index 4269b4983..000000000 --- a/s17_autonomous_agents/README.en.md +++ /dev/null @@ -1,271 +0,0 @@ -# s17: Autonomous Agents — Check the Board, Claim the Task - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20 - -> *"Check the board, claim the task"* — poll when idle, work when found. -> -> **Harness Layer**: Autonomy — Self-organizing teammates, no leader assignment needed. - ---- - -## The Problem - -s16's teammates can communicate and handshake shutdown. But each teammate waits for Lead to assign tasks — with 10 unclaimed tasks on the board, Lead has to manually assign 10 times. This doesn't scale. Teammates should check the task board themselves, claim unowned tasks, and look for the next one when done. - ---- - -## The Solution - -![Autonomous Agents Overview](images/autonomous-agents-overview.en.svg) - -Carries forward S16's teaching-version MessageBus and protocol tools. This chapter adds: **idle_poll** (poll every 5 seconds when idle), **scan_unclaimed_tasks** (scan the board for claimable tasks), **auto-claim** (claim on sight, no Lead needed). - -Teammate lifecycle expands from two phases to three: - -| Phase | Behavior | Exit condition | -|-------|----------|----------------| -| WORK | inbox → LLM → tool loop | `stop_reason != tool_use` | -| IDLE | 5s poll inbox + task board | 60s timeout | -| SHUTDOWN | Send summary, exit | — | - ---- - -## How It Works - -### idle_poll: Idle Polling - -After completing a task, the teammate doesn't exit. It enters the IDLE phase — checking every 5 seconds for new work: - -```python -IDLE_POLL_INTERVAL = 5 # seconds -IDLE_TIMEOUT = 60 # seconds - -def idle_poll(agent_name, messages, name, role) -> str: - """Return 'work', 'shutdown', or 'timeout'.""" - for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL): - time.sleep(IDLE_POLL_INTERVAL) - - # ① Check inbox (priority) - inbox = BUS.read_inbox(agent_name) - if inbox: - # shutdown_request handled immediately - for msg in inbox: - if msg.get("type") == "shutdown_request": - # ... reply shutdown_response - return "shutdown" - # Regular messages: inject into context, return to WORK - messages.append(...) - return "work" - - # ② Scan task board - unclaimed = scan_unclaimed_tasks() - if unclaimed: - task = unclaimed[0] - result = claim_task(task["id"], agent_name) - if "Claimed" in result: - messages.append(...) - return "work" - return "timeout" -``` - -Inbox takes priority (may contain protocol messages like shutdown_request), task board second. A shutdown_request received during IDLE is dispatched immediately — no need to wait for the next WORK phase. - -### scan_unclaimed_tasks: Scan the Task Board - -Find tasks that are pending, unowned, with all dependencies completed (`can_start`): - -```python -def scan_unclaimed_tasks() -> list[dict]: - unclaimed = [] - for f in sorted(TASKS_DIR.glob("task_*.json")): - task = json.loads(f.read_text()) - if (task.get("status") == "pending" - and not task.get("owner") - and can_start(task["id"])): - unclaimed.append(task) - return unclaimed -``` - -Three conditions: must be pending, no owner, all blockedBy dependencies completed. `can_start` checks dependency task status — having dependencies doesn't mean the task can't start, only unresolved dependencies block it. Teaching version picks the first by filename; CC uses file locks to prevent multiple teammates from claiming the same task. - -### claim_task: Owner Check - -Auto-claim checks the claim result, not treating failure as success: - -```python -def claim_task(task_id: str, owner: str = "agent") -> str: - task = load_task(task_id) - if task.status != "pending": - return f"Task {task_id} is {task.status}, cannot claim" - if task.owner: - return f"Task {task_id} already owned by {task.owner}" - if not can_start(task_id): - return f"Blocked by: {deps}" - task.owner = owner - task.status = "in_progress" - save_task(task) - return f"Claimed {task.id} ({task.subject})" -``` - -Teaching version has no file locks, so concurrent claims may still race. But the `task.owner` check avoids the most obvious "last writer wins" problem. CC uses `proper-lockfile` to protect task files, with `claimTask` doing read-modify-write inside a file lock (`utils/tasks.ts:541-612`). - -### Teammate Lifecycle: WORK → IDLE → SHUTDOWN - -s16's teammates exit after finishing. s17 adds the IDLE phase — teammates cycle through WORK → IDLE in an outer loop: - -```python -# Outer loop: WORK → IDLE cycle -while True: - # WORK phase: inner loop (max 10 LLM rounds) - for _ in range(10): - # Check inbox, dispatch protocol, call LLM, execute tools - ... - if response.stop_reason != "tool_use": - break # WORK phase ends - - # IDLE phase - idle_result = idle_poll(name, messages, name, role) - if idle_result == "shutdown": - break - if idle_result == "timeout": - break # 60s timeout → SHUTDOWN - -# SHUTDOWN: send summary to Lead -BUS.send(name, "lead", summary, "result") -``` - -Key design: -- **Outer while True**: WORK and IDLE alternate until timeout or shutdown request -- **Inner for 10**: WORK phase caps at 10 LLM rounds (prevents infinite loops) -- **IDLE timeout 60s**: 12 polls × 5s = 60s. Timeout sends summary and exits -- **shutdown_request works in both phases**: WORK phase dispatches via `handle_inbox_message`; IDLE phase's `idle_poll` checks and replies directly - -### Identity Re-injection - -After autoCompact (s08), a teammate's messages list may be compressed into a summary. On each new WORK phase entry, check: - -```python -if len(messages) <= 3: - messages.insert(0, {"role": "user", - "content": f"You are '{name}', role: {role}. " - f"Continue your work."}) -``` - -Short messages suggest compression happened — re-inject identity. In real CC, context compaction preserves the system prompt; the teaching version's simplified implementation needs manual handling. - -### consume_lead_inbox: Unified Inbox Consumer - -Both the `check_inbox` tool and the main loop call the same `consume_lead_inbox()` function: route protocol responses to update state first, then inject all messages into Lead's conversation history. Teammates' summaries and results don't just print to terminal — Lead's LLM can see them and coordinate next steps. - -### Putting It Together - -``` -1. Lead: "Build the backend — too many tasks, let teammates self-claim" -2. Lead → create_task("Create database schema") -3. Lead → create_task("Write API routes") -4. Lead → create_task("Write unit tests") -5. Lead → spawn_teammate("alice", "backend", "You are a backend developer") -6. Lead → spawn_teammate("bob", "backend", "You are a backend developer") - -7. alice thread starts → WORK: no initial inbox → spins → IDLE -8. bob thread starts → WORK: no initial inbox → spins → IDLE - -9. alice IDLE poll 1 → scan_unclaimed → finds "Create database schema" -10. alice → claim_task → "Create database schema" → back to WORK -11. bob IDLE poll 1 → scan_unclaimed → finds "Write API routes" -12. bob → claim_task → "Write API routes" → back to WORK - -13. alice WORK: write_file("schema.sql", ...) → complete_task → WORK ends -14. alice IDLE → scan → "Write unit tests" → claim → WORK -15. alice WORK: write_file("test_api.py", ...) → complete_task → WORK ends -16. alice IDLE → 60s no new tasks → SHUTDOWN - -17. bob similar flow → done → SHUTDOWN -18. Lead consume_lead_inbox → sees alice and bob's summaries -``` - -Two teammates claim and work in parallel. Lead only creates tasks and spawns teammates — no manual assignment needed. - ---- - -## Changes from s16 - -| Component | Before (s16) | After (s17) | -|-----------|-------------|-------------| -| Task assignment | Lead manually assigns | Teammates auto-claim (can_start checks deps) | -| Teammate state | WORK or exit | WORK → IDLE (60s poll) → SHUTDOWN | -| claim_task | No owner check | Rejects tasks that already have an owner | -| IDLE phase shutdown | Doesn't handle shutdown_request | Dispatches shutdown immediately and exits | -| Lead inbox | Prints only, not in context | consume_lead_inbox injects into history | -| New functions | — | idle_poll, scan_unclaimed_tasks, consume_lead_inbox | -| Identity persistence | System prompt only | Auto re-inject after compression | -| Lead tools | 14 (s16) | 14 (unchanged) | -| Teammate tools | 5 | 8 (+ list_tasks, claim_task, complete_task) | -| Teammate exit | Exit after task done | Exit only after 60s idle timeout | - ---- - -## Try It - -```sh -cd learn-claude-code -python s17_autonomous_agents/code.py -``` - -Try this prompt: - -`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.` - -What to observe: Do teammates auto-claim unassigned tasks? Are tasks with blockedBy dependencies claimed only after their dependencies complete? Does idle timeout trigger shutdown? Does a shutdown_request in IDLE phase get an immediate response? How do task states change in `.tasks/`? - ---- - -## What's Next - -Teammates self-organize now. But Alice and Bob both work in the same directory — Alice edits `config.py`, Bob also edits `config.py`, overwriting each other. - -s18 Worktree Isolation → Each task gets its own working directory, no conflicts. - -
-Deep Dive into CC Source - -> Teaching note: This chapter's idle_poll + auto-claim mechanism is a teaching design, using a unified polling function to demonstrate "find work when idle." CC's actual implementation combines multiple mechanisms, but shares the same goal — reducing Lead's manual assignment burden. - -### 1. CC's Idle Mechanism: Combined Approach, Not Single Polling - -Teaching version uses a single `idle_poll()` to handle both inbox checking and task claiming during idle. CC's actual implementation combines four mechanisms: - -**idle_notification**: After completing a round of work, `sendIdleNotification()` (`inProcessRunner.ts:569-589`) sends an idle notification to Lead. Lead knows the teammate is available and can assign new tasks or request shutdown. - -**mailbox polling**: `waitForNextPromptOrShutdown()` (`inProcessRunner.ts:689-868`) is a **500ms polling loop** that continuously checks three sources: pending user messages, mailbox file messages, and task list. Shutdown requests are prioritized (`inProcessRunner.ts:768-804`), preventing starvation by regular messages. - -**task watcher**: `useTaskListWatcher` (`hooks/useTaskListWatcher.ts:34-189`) uses `fs.watch()` to monitor the `.claude/tasks/` directory with 1-second debounce, triggering checks when new tasks are created or dependencies unblock. The dependency check (`L197-207`) verifies "no incomplete tasks in blockedBy", not "blockedBy is empty". - -**active claiming**: The polling loop also calls `tryClaimNextTask()` (`inProcessRunner.ts:853-860`) — actively claiming tasks from the task list while waiting. So "teammates don't actively poll for tasks" is inaccurate; CC has both passive notification and active claiming. - -### 2. Task Claiming: File Locks + Atomic Operations - -`claimTask()` (`utils/tasks.ts:541-612`) uses `proper-lockfile` task-level locks, performing read-check-modify-write within the lock. Checks: owner already exists (`L575-576`), already completed (`L580-581`), unresolved blockers in blockedBy (`L585-594`). `claimTaskWithBusyCheck()` (`utils/tasks.ts:614-692`) uses task-list level locks, making busy check and claim atomic to avoid TOCTOU. - -`findAvailableTask()` (`inProcessRunner.ts:595-604`) checks "all blockedBy completed" using `task.blockedBy.every(id => !unresolvedTaskIds.has(id))`. `tryClaimNextTask()` (`inProcessRunner.ts:624-657`) updates status to `in_progress` after claiming, so the UI immediately reflects the change. - -### 3. Teaching Version vs CC Comparison - -| Dimension | Teaching (s17) | CC | -|-----------|----------------|-----| -| Idle mechanism | idle_poll unified polling (5s) | idle_notification + 500ms mailbox polling + task watcher | -| Task discovery | scan_unclaimed_tasks (polling) | useTaskListWatcher (file watching) + tryClaimNextTask (active polling) | -| Dependency check | can_start (all blockedBy completed) | findAvailableTask (same semantics) | -| Concurrency safety | Owner check (no file lock) | proper-lockfile task lock + task-list lock | -| Shutdown handling | IDLE dispatches directly, WORK via handle_inbox_message | 500ms polling loop prioritizes shutdown_request | -| Timeout exit | 60s with no new tasks | No fixed timeout, Lead manual shutdown | -| Identity persistence | Messages length detection | Context compaction preserves system prompt | -| Claim failure handling | Check return value, skip on failure | File locks guarantee atomicity | - -Teaching version's `idle_poll()` merges CC's four mechanisms into one polling function — a reasonable simplification since the core semantics (find work when idle, claim after deps resolve, prioritize shutdown) are consistent. - -
- - diff --git a/s17_autonomous_agents/README.ja.md b/s17_autonomous_agents/README.ja.md index 1de34c37d..2822d3410 100644 --- a/s17_autonomous_agents/README.ja.md +++ b/s17_autonomous_agents/README.ja.md @@ -1,6 +1,6 @@ # s17: Autonomous Agents — ボードを見て、自分で認領 -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20 @@ -12,13 +12,13 @@ s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → ## 課題 -s16 のチームメイトは通信でき、シャットダウンハンドシェイクもできる。しかし各チームメイトは Lead がタスクを割り当てるのを待つ——ボードに 10 個の未認領タスクがあれば、Lead は 10 回手動で assign しなければならない。これはスケールしない。チームメイトは自分でタスクボードを見て、未認領のタスクを見つけて認領し、終わったら次を探すべき。 +s16 のチームメイトは通信でき、シャットダウンハンドシェイクもできる。しかし各チームメイトは Lead がタスクを割り当てるのを待つ。ボードに 10 個の未認領タスクがあれば、Lead は 10 回手動で assign しなければならない。これはスケールしない。チームメイトは自分でタスクボードを見て、未認領のタスクを見つけて認領し、終わったら次を探すべき。 --- ## ソリューション -![Autonomous Agents Overview](images/autonomous-agents-overview.ja.svg) +![Autonomous Agents Overview](images/autonomous-agents-overview.svg) S16 の教学版 MessageBus とプロトコルツールを踏襲。本章の追加:**idle_poll**(空き時に 5 秒ごとにポーリング)、**scan_unclaimed_tasks**(ボード上の認領可能なタスクをスキャン)、**自動認領**(見つけたら即座に claim、Lead 不要)。 @@ -36,7 +36,7 @@ S16 の教学版 MessageBus とプロトコルツールを踏襲。本章の追 ### idle_poll: 空き時ポーリング -チームメイトはタスク完了後も終了せず、IDLE フェーズに入る——5 秒ごとに新しい仕事がないか確認: +チームメイトはタスク完了後も終了せず、IDLE フェーズに入り、5 秒ごとに新しい仕事がないか確認する: ```python IDLE_POLL_INTERVAL = 5 # seconds @@ -88,7 +88,7 @@ def scan_unclaimed_tasks() -> list[dict]: return unclaimed ``` -3 つの条件:pending であること、owner がないこと、全 blockedBy 依存が完了していること。`can_start` は依存タスクの状態を確認——依存があるからといってタスクを開始できないわけではなく、未解決の依存のみがブロックする。教学版はファイル名順で最初のものを選択、CC はファイルロックで複数チームメイトの同時認領を防止。 +3 つの条件:pending であること、owner がないこと、全 blockedBy 依存が完了していること。`can_start` は依存タスクの状態を確認する。依存があるからといってタスクを開始できないわけではなく、未解決の依存のみがブロックする。教学版はファイル名順で最初のものを選択、Claude Code はファイルロックで複数チームメイトの同時認領を防止。 ### claim_task: owner チェック @@ -109,11 +109,11 @@ def claim_task(task_id: str, owner: str = "agent") -> str: return f"Claimed {task.id} ({task.subject})" ``` -教学版にはファイルロックがないため、並行認領で競合する可能性がある。しかし `task.owner` チェックで最も明白な「後書き上書き」問題を回避。CC は `proper-lockfile` でタスクファイルを保護、`claimTask` はファイルロック内で read-modify-write を実行(`utils/tasks.ts:541-612`)。 +教学版にはファイルロックがないため、並行認領で競合する可能性がある。しかし `task.owner` チェックで最も明白な「後書き上書き」問題を回避。Claude Code は `proper-lockfile` でタスクファイルを保護、`claimTask` はファイルロック内で read-modify-write を実行(`utils/tasks.ts:541-612`)。 ### チームメイトライフサイクル: WORK → IDLE → SHUTDOWN -s16 のチームメイトはタスク完了後に終了。s17 は IDLE フェーズを追加——外側ループで WORK → IDLE を繰り返す: +s16 のチームメイトはタスク完了後に終了。s17 は IDLE フェーズを追加し、外側ループで WORK → IDLE を繰り返す: ```python # 外側ループ: WORK → IDLE サイクル @@ -153,7 +153,7 @@ if len(messages) <= 3: f"Continue your work."}) ``` -メッセージが短い場合、圧縮が発生したことを示す——身份情報を再注入。真实 CC では context compaction が system prompt を保持、教学版の簡略実装は手動処理が必要。 +メッセージが短い場合、圧縮が発生したことを示すため、アイデンティティ情報を再注入する。真实 Claude Code では context compaction が system prompt を保持、教学版の簡略実装は手動処理が必要。 ### consume_lead_inbox: 統一 inbox コンシューマ @@ -186,7 +186,7 @@ if len(messages) <= 3: 18. Lead consume_lead_inbox → alice と bob の summary を確認 ``` -2 人のチームメイトが並行して認領・作業。Lead はタスクを作成してチームメイトを起動するだけで、手動割り当て不要。 +2 人のチームメイトが並行して認領・作業。 --- @@ -224,18 +224,18 @@ python s17_autonomous_agents/code.py ## 次の章 -チームメイトが自己組織化した。しかし Alice も Bob も同じディレクトリで作業——Alice が `config.py` を編集し、Bob も `config.py` を編集して互いに上書きしてしまう。 +チームメイトが自己組織化した。しかし Alice も Bob も同じディレクトリで作業している。Alice が `config.py` を編集し、Bob も `config.py` を編集して互いに上書きしてしまう。 s18 Worktree Isolation → 各タスクに専用の作業ディレクトリ、競合なし。
-CC ソースコード深掘り +Claude Code ソースコード深掘り -> 教学注記:本章の idle_poll + auto-claim 機構は教学設計であり、統一ポーリング関数で「空き時に仕事を探す」をデモ。CC の実際の実装は複数機構の組み合わせだが、目標は同じ——Lead の手動割り当て負担を軽減。 +> 教学注記:本章の idle_poll + auto-claim 機構は教学設計であり、統一ポーリング関数で「空き時に仕事を探す」をデモ。Claude Code の実際の実装は複数機構の組み合わせだが、目標は同じで、Lead の手動割り当て負担を軽減すること。 -### 一、CC の空き機構:組み合わせ路径、単一ポーリングではない +### 一、Claude Code の空き機構:組み合わせ路径、単一ポーリングではない -教学版は 1 つの `idle_poll()` で空き時の inbox 確認とタスク認領を統一処理。CC の実際の実装は 4 つの機構の組み合わせ: +教学版は 1 つの `idle_poll()` で空き時の inbox 確認とタスク認領を統一処理。Claude Code の実際の実装は 4 つの機構の組み合わせ: **idle_notification**:チームメイトが 1 ラウンドの作業を完了後、`sendIdleNotification()`(`inProcessRunner.ts:569-589`)が Lead に空き通知を送信。Lead はチームメイトが利用可能であることを知り、新しいタスクを割り当てたりシャットダウンを要求可能。 @@ -243,7 +243,7 @@ s18 Worktree Isolation → 各タスクに専用の作業ディレクトリ、 **task watcher**:`useTaskListWatcher`(`hooks/useTaskListWatcher.ts:34-189`)が `fs.watch()` で `.claude/tasks/` ディレクトリの変化を監視、1 秒 debounce で新タスク作成や依存アンロック時にチェックをトリガー。依存判断(`L197-207`)は「blockedBy に未完了タスクがない」で、「blockedBy が空」ではない。 -**能動 claim**:ポーリングループ内でも `tryClaimNextTask()`(`inProcessRunner.ts:853-860`)を呼び出し——待機中に task list から能動的にタスクを認領。したがって「チームメイトは能動的にタスクをポーリングしない」は不正確、CC は受動通知と能動認領の両方を持つ。 +**能動 claim**:ポーリングループ内でも `tryClaimNextTask()`(`inProcessRunner.ts:853-860`)を呼び出し、待機中に task list から能動的にタスクを認領。したがって「チームメイトは能動的にタスクをポーリングしない」は不正確、Claude Code は受動通知と能動認領の両方を持つ。 ### 二、タスク認領:ファイルロック + 原子操作 @@ -251,9 +251,9 @@ s18 Worktree Isolation → 各タスクに専用の作業ディレクトリ、 `findAvailableTask()`(`inProcessRunner.ts:595-604`)の依存判断も「全 blockedBy 完了」で、`task.blockedBy.every(id => !unresolvedTaskIds.has(id))` で実装。`tryClaimNextTask()`(`inProcessRunner.ts:624-657`)は認領後 status を `in_progress` に更新、UI に即座に反映。 -### 三、教学版 vs CC 対比 +### 三、教学版 vs Claude Code 対比 -| 次元 | 教学版 (s17) | CC | +| 次元 | 教学版 (s17) | Claude Code | |------|-------------|-----| | 空き機構 | idle_poll 統一ポーリング(5s) | idle_notification + 500ms mailbox ポーリング + task watcher | | タスク発見 | scan_unclaimed_tasks(ポーリング) | useTaskListWatcher(ファイル監視)+ tryClaimNextTask(能動ポーリング) | @@ -264,7 +264,7 @@ s18 Worktree Isolation → 各タスクに専用の作業ディレクトリ、 | 身份保持 | messages 長さ検出 | context compaction が system prompt を保持 | | claim 失敗処理 | 戻り値を確認、失敗時はスキップ | ファイルロックで原子性を保証 | -教学版の `idle_poll()` は CC の 4 つの機構を 1 つのポーリング関数に統合——核心セマンティクス(空き時に仕事を探す、依存アンロック後に認領、shutdown 優先)が一致するため、合理的な簡略化。 +教学版の `idle_poll()` は Claude Code の 4 つの機構を 1 つのポーリング関数に統合した。核心セマンティクス(空き時に仕事を探す、依存アンロック後に認領、shutdown 優先)が一致するため、合理的な簡略化。
diff --git a/s17_autonomous_agents/README.md b/s17_autonomous_agents/README.md index 769da6ca0..030b6fbfa 100644 --- a/s17_autonomous_agents/README.md +++ b/s17_autonomous_agents/README.md @@ -1,42 +1,42 @@ -# s17: Autonomous Agents — 自己看板,自己认领 +# s17: Autonomous Agents — Check the Board, Claim the Task -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20 -> *"自己看板,自己认领"* — 空闲时轮询,有活就干。 +> *"Check the board, claim the task"* — poll when idle, work when found. > -> **Harness 层**: 自治 — 队友自组织,不依赖 Lead 分配。 +> **Harness Layer**: Autonomy — Self-organizing teammates, no leader assignment needed. --- -## 问题 +## The Problem -s16 的队友能通信、能握手关机。但每个队友等 Lead 分配任务——如果任务看板上有 10 个未认领任务,Lead 得手动 assign 10 次。这不能扩展。队友应该自己看任务看板,发现没人做的任务就认领,做完再找下一个。 +s16's teammates can communicate and handshake shutdown. But each teammate waits for Lead to assign tasks. With 10 unclaimed tasks on the board, Lead has to manually assign 10 times. This doesn't scale. Teammates should check the task board themselves, claim unowned tasks, and look for the next one when done. --- -## 解决方案 +## The Solution ![Autonomous Agents Overview](images/autonomous-agents-overview.svg) -沿用 S16 的教学版 MessageBus 和协议工具。本章新增:**idle_poll**(空闲时每 5 秒轮询一次)、**scan_unclaimed_tasks**(扫描看板上可认领的任务)、**自动认领**(找到任务就 claim,不用 Lead 操心)。 +Carries forward S16's teaching-version MessageBus and protocol tools. This chapter adds: **idle_poll** (poll every 5 seconds when idle), **scan_unclaimed_tasks** (scan the board for claimable tasks), **auto-claim** (claim on sight, no Lead needed). -队友生命周期从两阶段变成三阶段: +Teammate lifecycle expands from two phases to three: -| 阶段 | 行为 | 退出条件 | -|------|------|---------| -| WORK | inbox → LLM → 工具循环 | `stop_reason != tool_use` | -| IDLE | 每 5s 轮询 inbox + 任务板 | 60s 超时 | -| SHUTDOWN | 发 summary,退出 | — | +| Phase | Behavior | Exit condition | +|-------|----------|----------------| +| WORK | inbox → LLM → tool loop | `stop_reason != tool_use` | +| IDLE | 5s poll inbox + task board | 60s timeout | +| SHUTDOWN | Send summary, exit | — | --- -## 工作原理 +## How It Works -### idle_poll: 空闲轮询 +### idle_poll: Idle Polling -队友完成当前任务后不退出,进入 IDLE 阶段——每 5 秒检查一次有没有新工作: +After completing a task, the teammate doesn't exit. It enters the IDLE phase, checking every 5 seconds for new work: ```python IDLE_POLL_INTERVAL = 5 # seconds @@ -47,19 +47,19 @@ def idle_poll(agent_name, messages, name, role) -> str: for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL): time.sleep(IDLE_POLL_INTERVAL) - # ① 检查收件箱(优先) + # ① Check inbox (priority) inbox = BUS.read_inbox(agent_name) if inbox: - # shutdown_request 立即处理 + # shutdown_request handled immediately for msg in inbox: if msg.get("type") == "shutdown_request": - # ... 回复 shutdown_response + # ... reply shutdown_response return "shutdown" - # 普通消息注入上下文,回到 WORK + # Regular messages: inject into context, return to WORK messages.append(...) return "work" - # ② 扫描任务看板 + # ② Scan task board unclaimed = scan_unclaimed_tasks() if unclaimed: task = unclaimed[0] @@ -70,11 +70,11 @@ def idle_poll(agent_name, messages, name, role) -> str: return "timeout" ``` -inbox 优先(可能包含 shutdown_request 等协议消息),任务板其次。IDLE 阶段收到 shutdown_request 会直接回复并退出,不等到下一轮 WORK。 +Inbox takes priority (may contain protocol messages like shutdown_request), task board second. A shutdown_request received during IDLE is dispatched immediately; no need to wait for the next WORK phase. -### scan_unclaimed_tasks: 扫描任务看板 +### scan_unclaimed_tasks: Scan the Task Board -找 pending 状态、无 owner、所有依赖已完成(`can_start`)的任务: +Find tasks that are pending, unowned, with all dependencies completed (`can_start`): ```python def scan_unclaimed_tasks() -> list[dict]: @@ -88,11 +88,11 @@ def scan_unclaimed_tasks() -> list[dict]: return unclaimed ``` -三个条件:必须是 pending、没有 owner、所有 blockedBy 依赖已完成。`can_start` 检查依赖任务的状态——有依赖不代表不能做,只有被未完成的任务阻塞才不能做。教学版按文件名排序取第一个;CC 用文件锁防止多个队友同时认领同一个任务。 +Three conditions: must be pending, no owner, all blockedBy dependencies completed. `can_start` checks dependency task status: having dependencies doesn't mean the task can't start, only unresolved dependencies block it. Teaching version picks the first by filename; Claude Code uses file locks to prevent multiple teammates from claiming the same task. -### claim_task: owner 检查 +### claim_task: Owner Check -自动认领时检查 claim 结果,不把失败当成功: +Auto-claim checks the claim result, not treating failure as success: ```python def claim_task(task_id: str, owner: str = "agent") -> str: @@ -109,42 +109,42 @@ def claim_task(task_id: str, owner: str = "agent") -> str: return f"Claimed {task.id} ({task.subject})" ``` -教学版没有文件锁,并发认领可能出现竞争。但至少 `task.owner` 检查避免了最明显的"后写覆盖"问题。CC 用 `proper-lockfile` 保护任务文件,`claimTask` 在文件锁内完成读-改-写(`utils/tasks.ts:541-612`)。 +Teaching version has no file locks, so concurrent claims may still race. But the `task.owner` check avoids the most obvious "last writer wins" problem. Claude Code uses `proper-lockfile` to protect task files, with `claimTask` doing read-modify-write inside a file lock (`utils/tasks.ts:541-612`). -### 队友生命周期: WORK → IDLE → SHUTDOWN +### Teammate Lifecycle: WORK → IDLE → SHUTDOWN -s16 的队友做完任务就退出。s17 加了 IDLE 阶段,队友在外层循环中反复 WORK → IDLE: +s16's teammates exit after finishing. s17 adds the IDLE phase. Teammates cycle through WORK → IDLE in an outer loop: ```python # Outer loop: WORK → IDLE cycle while True: - # WORK phase: 内层循环(最多 10 轮 LLM 调用) + # WORK phase: inner loop (max 10 LLM rounds) for _ in range(10): - # 检查 inbox、处理协议消息、调 LLM、执行工具 + # Check inbox, dispatch protocol, call LLM, execute tools ... if response.stop_reason != "tool_use": - break # WORK 阶段结束 + break # WORK phase ends # IDLE phase idle_result = idle_poll(name, messages, name, role) if idle_result == "shutdown": break if idle_result == "timeout": - break # 60s 超时 → SHUTDOWN + break # 60s timeout → SHUTDOWN -# SHUTDOWN: 发 summary 给 Lead +# SHUTDOWN: send summary to Lead BUS.send(name, "lead", summary, "result") ``` -关键设计: -- **外层 while True**:WORK 和 IDLE 交替进行,直到超时或收到关机请求 -- **内层 for 10**:WORK 阶段最多 10 轮 LLM 调用(防止无限循环) -- **IDLE 超时 60 秒**:12 次轮询 × 5 秒 = 60 秒。超时后发送 summary 并退出 -- **shutdown_request 两阶段都能响应**:WORK 阶段通过 `handle_inbox_message` 分发;IDLE 阶段 `idle_poll` 直接检查并回复 +Key design: +- **Outer while True**: WORK and IDLE alternate until timeout or shutdown request +- **Inner for 10**: WORK phase caps at 10 LLM rounds (prevents infinite loops) +- **IDLE timeout 60s**: 12 polls × 5s = 60s. Timeout sends summary and exits +- **shutdown_request works in both phases**: WORK phase dispatches via `handle_inbox_message`; IDLE phase's `idle_poll` checks and replies directly -### 身份重注入 +### Identity Re-injection -autoCompact(s08)之后,队友的 messages 列表可能被压缩成一段摘要。每次进入新的 WORK 阶段时检查: +After autoCompact (s08), a teammate's messages list may be compressed into a summary. On each new WORK phase entry, check: ```python if len(messages) <= 3: @@ -153,118 +153,118 @@ if len(messages) <= 3: f"Continue your work."}) ``` -消息过短说明发生了压缩,此时重新注入身份信息。真实 CC 中 context compaction 会保留 system prompt,教学版的简化实现需要手动处理。 +Short messages suggest compression happened, so re-inject identity. In real Claude Code, context compaction preserves the system prompt; the teaching version's simplified implementation needs manual handling. -### consume_lead_inbox: 统一 inbox 消费 +### consume_lead_inbox: Unified Inbox Consumer -`check_inbox` 工具和主循环末尾都调用同一个 `consume_lead_inbox()` 函数:先路由协议 response 更新状态,再把所有消息注入 Lead 的对话历史。队友发来的 summary/result 不会只打印在终端,Lead 的 LLM 能看到并协调下一步。 +Both the `check_inbox` tool and the main loop call the same `consume_lead_inbox()` function: route protocol responses to update state first, then inject all messages into Lead's conversation history. Teammates' summaries and results don't just print to terminal. Lead's LLM can see them and coordinate next steps. -### 合起来跑 +### Putting It Together ``` -1. Lead: "搭建后端——任务太多,让队友自己认领" -2. Lead → create_task("创建数据库 schema") -3. Lead → create_task("写 API 路由") -4. Lead → create_task("写单元测试") -5. Lead → spawn_teammate("alice", "backend", "你是后端开发者") -6. Lead → spawn_teammate("bob", "backend", "你是后端开发者") - -7. alice 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE -8. bob 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE - -9. alice IDLE 第 1 次轮询 → scan_unclaimed → 发现"创建数据库 schema" -10. alice → claim_task → "创建数据库 schema" → 回到 WORK -11. bob IDLE 第 1 次轮询 → scan_unclaimed → 发现"写 API 路由" -12. bob → claim_task → "写 API 路由" → 回到 WORK - -13. alice WORK: write_file("schema.sql", ...) → complete_task → WORK 结束 -14. alice IDLE → scan → "写单元测试" → claim → WORK -15. alice WORK: write_file("test_api.py", ...) → complete_task → WORK 结束 -16. alice IDLE → 60s 无新任务 → SHUTDOWN - -17. bob 类似流程 → 做完 → SHUTDOWN -18. Lead consume_lead_inbox → 看到 alice 和 bob 的 summary +1. Lead: "Build the backend — too many tasks, let teammates self-claim" +2. Lead → create_task("Create database schema") +3. Lead → create_task("Write API routes") +4. Lead → create_task("Write unit tests") +5. Lead → spawn_teammate("alice", "backend", "You are a backend developer") +6. Lead → spawn_teammate("bob", "backend", "You are a backend developer") + +7. alice thread starts → WORK: no initial inbox → spins → IDLE +8. bob thread starts → WORK: no initial inbox → spins → IDLE + +9. alice IDLE poll 1 → scan_unclaimed → finds "Create database schema" +10. alice → claim_task → "Create database schema" → back to WORK +11. bob IDLE poll 1 → scan_unclaimed → finds "Write API routes" +12. bob → claim_task → "Write API routes" → back to WORK + +13. alice WORK: write_file("schema.sql", ...) → complete_task → WORK ends +14. alice IDLE → scan → "Write unit tests" → claim → WORK +15. alice WORK: write_file("test_api.py", ...) → complete_task → WORK ends +16. alice IDLE → 60s no new tasks → SHUTDOWN + +17. bob similar flow → done → SHUTDOWN +18. Lead consume_lead_inbox → sees alice and bob's summaries ``` -两个队友并行认领、并行工作。Lead 只需要创建任务和启动队友,不需要手动分配。 +Two teammates claim and work in parallel. --- -## 相对 s16 的变更 - -| 组件 | 之前 (s16) | 之后 (s17) | -|------|-----------|-----------| -| 任务分配 | Lead 手动 assign | 队友自动认领(can_start 检查依赖) | -| 队友状态 | WORK 或退出 | WORK → IDLE(轮询 60s) → SHUTDOWN | -| claim_task | 无 owner 检查 | 拒绝已有 owner 的任务 | -| IDLE 阶段关机 | 不处理 shutdown_request | 直接 dispatch shutdown 并退出 | -| Lead inbox | 只打印,不进上下文 | consume_lead_inbox 统一注入 history | -| 新函数 | — | idle_poll, scan_unclaimed_tasks, consume_lead_inbox | -| 身份保持 | 仅 system prompt | 压缩后自动重注入 | -| Lead 工具 | 14 (s16) | 14(不变) | -| 队友工具 | 5 | 8(+ list_tasks, claim_task, complete_task) | -| 队友退出条件 | 完成任务即退出 | 60s 无新任务才退出 | +## Changes from s16 + +| Component | Before (s16) | After (s17) | +|-----------|-------------|-------------| +| Task assignment | Lead manually assigns | Teammates auto-claim (can_start checks deps) | +| Teammate state | WORK or exit | WORK → IDLE (60s poll) → SHUTDOWN | +| claim_task | No owner check | Rejects tasks that already have an owner | +| IDLE phase shutdown | Doesn't handle shutdown_request | Dispatches shutdown immediately and exits | +| Lead inbox | Prints only, not in context | consume_lead_inbox injects into history | +| New functions | — | idle_poll, scan_unclaimed_tasks, consume_lead_inbox | +| Identity persistence | System prompt only | Auto re-inject after compression | +| Lead tools | 14 (s16) | 14 (unchanged) | +| Teammate tools | 5 | 8 (+ list_tasks, claim_task, complete_task) | +| Teammate exit | Exit after task done | Exit only after 60s idle timeout | --- -## 试一下 +## Try It ```sh cd learn-claude-code python s17_autonomous_agents/code.py ``` -试试这个 prompt: +Try this prompt: `Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.` -观察重点:队友是否自动认领了未分配的任务?有 blockedBy 依赖的任务是否在前置完成后被正确认领?空闲超时后是否自动关机?IDLE 阶段收到 shutdown_request 是否立即响应?`.tasks/` 目录下的任务状态如何变化? +What to observe: Do teammates auto-claim unassigned tasks? Are tasks with blockedBy dependencies claimed only after their dependencies complete? Does idle timeout trigger shutdown? Does a shutdown_request in IDLE phase get an immediate response? How do task states change in `.tasks/`? --- -## 接下来 +## What's Next -队友自组织了。但 Alice 和 Bob 都在同一个目录下工作——Alice 改 `config.py`,Bob 也改 `config.py`,互相覆盖。 +Teammates self-organize now. But Alice and Bob both work in the same directory: Alice edits `config.py`, Bob also edits `config.py`, overwriting each other. -s18 Worktree Isolation → 每个任务有自己的工作目录,互不干扰。 +s18 Worktree Isolation → Each task gets its own working directory, no conflicts.
-深入 CC 源码 +Deep Dive into Claude Code Source -> 教学说明:本章的 idle_poll + auto-claim 机制是教学设计,用统一的轮询函数演示"空闲后找活干"。CC 的实际实现是多个机制的组合,但目标一致——减少 Lead 的手动分配负担。 +> Teaching note: This chapter's idle_poll + auto-claim mechanism is a teaching design, using a unified polling function to demonstrate "find work when idle." Claude Code's actual implementation combines multiple mechanisms, but shares the same goal: reducing Lead's manual assignment burden. -### 一、CC 的空闲机制:组合路径,不是单一轮询 +### 1. Claude Code's Idle Mechanism: Combined Approach, Not Single Polling -教学版用一个 `idle_poll()` 统一处理空闲时的 inbox 检查和任务认领。CC 的实际实现是四个机制的组合: +Teaching version uses a single `idle_poll()` to handle both inbox checking and task claiming during idle. Claude Code's actual implementation combines four mechanisms: -**idle_notification**:队友完成一轮工作后,`sendIdleNotification()`(`inProcessRunner.ts:569-589`)向 Lead 发送空闲通知。Lead 知道队友可用了,可以分配新任务或请求关机。 +**idle_notification**: After completing a round of work, `sendIdleNotification()` (`inProcessRunner.ts:569-589`) sends an idle notification to Lead. Lead knows the teammate is available and can assign new tasks or request shutdown. -**mailbox 轮询**:`waitForNextPromptOrShutdown()`(`inProcessRunner.ts:689-868`)是一个 **500ms 轮询循环**,持续检查三类来源:pending user messages、mailbox 文件消息、task list。shutdown_request 被优先处理(`inProcessRunner.ts:768-804`),不会被普通消息饿死。 +**mailbox polling**: `waitForNextPromptOrShutdown()` (`inProcessRunner.ts:689-868`) is a **500ms polling loop** that continuously checks three sources: pending user messages, mailbox file messages, and task list. Shutdown requests are prioritized (`inProcessRunner.ts:768-804`), preventing starvation by regular messages. -**task watcher**:`useTaskListWatcher`(`hooks/useTaskListWatcher.ts:34-189`)用 `fs.watch()` 监听 `.claude/tasks/` 目录变化,1 秒 debounce,当新任务创建或依赖解锁时触发检查。依赖判断(`L197-207`)是"blockedBy 中没有未完成的任务",不是"blockedBy 为空"。 +**task watcher**: `useTaskListWatcher` (`hooks/useTaskListWatcher.ts:34-189`) uses `fs.watch()` to monitor the `.claude/tasks/` directory with 1-second debounce, triggering checks when new tasks are created or dependencies unblock. The dependency check (`L197-207`) verifies "no incomplete tasks in blockedBy", not "blockedBy is empty". -**主动 claim**:轮询循环内部也会调用 `tryClaimNextTask()`(`inProcessRunner.ts:853-860`)——在等待期间主动从 task list 领取任务。所以"队友不主动轮询任务"不准确,CC 同时有被动通知和主动认领。 +**active claiming**: The polling loop also calls `tryClaimNextTask()` (`inProcessRunner.ts:853-860`), actively claiming tasks from the task list while waiting. So "teammates don't actively poll for tasks" is inaccurate; Claude Code has both passive notification and active claiming. -### 二、任务认领:文件锁 + 原子操作 +### 2. Task Claiming: File Locks + Atomic Operations -`claimTask()`(`utils/tasks.ts:541-612`)用 `proper-lockfile` 的任务文件锁,在锁内完成读-检查-改-写。检查项:owner 是否已存在(`L575-576`)、是否已完成(`L580-581`)、blockedBy 中是否有未完成任务(`L585-594`)。`claimTaskWithBusyCheck()`(`utils/tasks.ts:614-692`)用 task-list 级别锁,把 busy check 和 claim 做成原子操作,避免 TOCTOU。 +`claimTask()` (`utils/tasks.ts:541-612`) uses `proper-lockfile` task-level locks, performing read-check-modify-write within the lock. Checks: owner already exists (`L575-576`), already completed (`L580-581`), unresolved blockers in blockedBy (`L585-594`). `claimTaskWithBusyCheck()` (`utils/tasks.ts:614-692`) uses task-list level locks, making busy check and claim atomic to avoid TOCTOU. -`findAvailableTask()`(`inProcessRunner.ts:595-604`)的依赖判断也是"所有 blockedBy 已完成",用 `task.blockedBy.every(id => !unresolvedTaskIds.has(id))` 实现。`tryClaimNextTask()`(`inProcessRunner.ts:624-657`)在认领后把状态更新为 `in_progress`,让 UI 立即反映变化。 +`findAvailableTask()` (`inProcessRunner.ts:595-604`) checks "all blockedBy completed" using `task.blockedBy.every(id => !unresolvedTaskIds.has(id))`. `tryClaimNextTask()` (`inProcessRunner.ts:624-657`) updates status to `in_progress` after claiming, so the UI immediately reflects the change. -### 三、教学版 vs CC 对比 +### 3. Teaching Version vs Claude Code Comparison -| 维度 | 教学版 (s17) | CC | -|------|-------------|-----| -| 空闲机制 | idle_poll 统一轮询(5s) | idle_notification + 500ms mailbox 轮询 + task watcher | -| 任务发现 | scan_unclaimed_tasks(轮询) | useTaskListWatcher(文件监听)+ tryClaimNextTask(主动轮询) | -| 依赖判断 | can_start(所有 blockedBy 已完成) | findAvailableTask(同样语义) | -| 并发安全 | owner 检查(无文件锁) | proper-lockfile 任务锁 + task-list 锁 | -| shutdown 处理 | IDLE 直接分发,WORK 通过 handle_inbox_message | 500ms 轮询中优先处理 shutdown_request | -| 超时退出 | 60s 无新任务 | 无固定超时,Lead 手动 shutdown | -| 身份保持 | messages 长度检测 | context compaction 保留 system prompt | -| claim 失败处理 | 检查返回值,失败不注入 | 文件锁保证原子性 | +| Dimension | Teaching (s17) | Claude Code | +|-----------|----------------|-----| +| Idle mechanism | idle_poll unified polling (5s) | idle_notification + 500ms mailbox polling + task watcher | +| Task discovery | scan_unclaimed_tasks (polling) | useTaskListWatcher (file watching) + tryClaimNextTask (active polling) | +| Dependency check | can_start (all blockedBy completed) | findAvailableTask (same semantics) | +| Concurrency safety | Owner check (no file lock) | proper-lockfile task lock + task-list lock | +| Shutdown handling | IDLE dispatches directly, WORK via handle_inbox_message | 500ms polling loop prioritizes shutdown_request | +| Timeout exit | 60s with no new tasks | No fixed timeout, Lead manual shutdown | +| Identity persistence | Messages length detection | Context compaction preserves system prompt | +| Claim failure handling | Check return value, skip on failure | File locks guarantee atomicity | -教学版的 `idle_poll()` 把 CC 的四个机制合并成一个轮询函数——简化合理,因为核心语义(空闲时找活干、依赖解锁后可认领、shutdown 优先)是一致的。 +Teaching version's `idle_poll()` merges Claude Code's four mechanisms into one polling function. This is a reasonable simplification since the core semantics (find work when idle, claim after deps resolve, prioritize shutdown) are consistent.
diff --git a/s17_autonomous_agents/README.zh.md b/s17_autonomous_agents/README.zh.md new file mode 100644 index 000000000..458f3dd9e --- /dev/null +++ b/s17_autonomous_agents/README.zh.md @@ -0,0 +1,271 @@ +# s17: Autonomous Agents — 不等 Lead 派活,空了自己上看板认领 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s15 → s16 → `s17` → [s18](../s18_worktree_isolation/) → s19 → s20 + +> *"不等 Lead 派活,空了自己上看板认领"* — 空闲时轮询,有任务就认领。 +> +> **Harness 层**: 自治 — 队友自组织,不依赖 Lead 分配。 + +--- + +## 问题 + +s16 的队友能通信、能握手关机。但每个队友等 Lead 分配任务。如果任务看板上有 10 个未认领任务,Lead 得手动 assign 10 次。这不能扩展。队友应该自己看任务看板,发现没人做的任务就认领,做完再找下一个。 + +--- + +## 解决方案 + +![Autonomous Agents Overview](images/autonomous-agents-overview.svg) + +沿用 S16 的教学版 MessageBus 和协议工具。本章新增:**idle_poll**(空闲时每 5 秒轮询一次)、**scan_unclaimed_tasks**(扫描看板上可认领的任务)、**自动认领**(找到任务就 claim,无需 Lead 分配)。 + +队友生命周期从两阶段变成三阶段: + +| 阶段 | 行为 | 退出条件 | +|------|------|---------| +| WORK | inbox → LLM → 工具循环 | `stop_reason != tool_use` | +| IDLE | 每 5s 轮询 inbox + 任务板 | 60s 超时 | +| SHUTDOWN | 发 summary,退出 | — | + +--- + +## 工作原理 + +### idle_poll: 空闲轮询 + +队友完成当前任务后不退出,进入 IDLE 阶段,每 5 秒检查一次有没有新工作: + +```python +IDLE_POLL_INTERVAL = 5 # seconds +IDLE_TIMEOUT = 60 # seconds + +def idle_poll(agent_name, messages, name, role) -> str: + """Return 'work', 'shutdown', or 'timeout'.""" + for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL): + time.sleep(IDLE_POLL_INTERVAL) + + # ① 检查收件箱(优先) + inbox = BUS.read_inbox(agent_name) + if inbox: + # shutdown_request 立即处理 + for msg in inbox: + if msg.get("type") == "shutdown_request": + # ... 回复 shutdown_response + return "shutdown" + # 普通消息注入上下文,回到 WORK + messages.append(...) + return "work" + + # ② 扫描任务看板 + unclaimed = scan_unclaimed_tasks() + if unclaimed: + task = unclaimed[0] + result = claim_task(task["id"], agent_name) + if "Claimed" in result: + messages.append(...) + return "work" + return "timeout" +``` + +inbox 优先(可能包含 shutdown_request 等协议消息),任务板其次。IDLE 阶段收到 shutdown_request 会直接回复并退出,不等到下一轮 WORK。 + +### scan_unclaimed_tasks: 扫描任务看板 + +找 pending 状态、无 owner、所有依赖已完成(`can_start`)的任务: + +```python +def scan_unclaimed_tasks() -> list[dict]: + unclaimed = [] + for f in sorted(TASKS_DIR.glob("task_*.json")): + task = json.loads(f.read_text()) + if (task.get("status") == "pending" + and not task.get("owner") + and can_start(task["id"])): + unclaimed.append(task) + return unclaimed +``` + +三个条件:必须是 pending、没有 owner、所有 blockedBy 依赖已完成。`can_start` 检查依赖任务的状态:有依赖不代表不能做,只有被未完成的任务阻塞才不能做。教学版按文件名排序取第一个;Claude Code 用文件锁防止多个队友同时认领同一个任务。 + +### claim_task: owner 检查 + +自动认领时检查 claim 结果,不把失败当成功: + +```python +def claim_task(task_id: str, owner: str = "agent") -> str: + task = load_task(task_id) + if task.status != "pending": + return f"Task {task_id} is {task.status}, cannot claim" + if task.owner: + return f"Task {task_id} already owned by {task.owner}" + if not can_start(task_id): + return f"Blocked by: {deps}" + task.owner = owner + task.status = "in_progress" + save_task(task) + return f"Claimed {task.id} ({task.subject})" +``` + +教学版没有文件锁,并发认领可能出现竞争。但至少 `task.owner` 检查避免了最明显的"后写覆盖"问题。Claude Code 用 `proper-lockfile` 保护任务文件,`claimTask` 在文件锁内完成读-改-写(`utils/tasks.ts:541-612`)。 + +### 队友生命周期: WORK → IDLE → SHUTDOWN + +s16 的队友做完任务就退出。s17 加了 IDLE 阶段,队友在外层循环中反复 WORK → IDLE: + +```python +# Outer loop: WORK → IDLE cycle +while True: + # WORK phase: 内层循环(最多 10 轮 LLM 调用) + for _ in range(10): + # 检查 inbox、处理协议消息、调 LLM、执行工具 + ... + if response.stop_reason != "tool_use": + break # WORK 阶段结束 + + # IDLE phase + idle_result = idle_poll(name, messages, name, role) + if idle_result == "shutdown": + break + if idle_result == "timeout": + break # 60s 超时 → SHUTDOWN + +# SHUTDOWN: 发 summary 给 Lead +BUS.send(name, "lead", summary, "result") +``` + +关键设计: +- **外层 while True**:WORK 和 IDLE 交替进行,直到超时或收到关机请求 +- **内层 for 10**:WORK 阶段最多 10 轮 LLM 调用(防止无限循环) +- **IDLE 超时 60 秒**:12 次轮询 × 5 秒 = 60 秒。超时后发送 summary 并退出 +- **shutdown_request 两阶段都能响应**:WORK 阶段通过 `handle_inbox_message` 分发;IDLE 阶段 `idle_poll` 直接检查并回复 + +### 身份重注入 + +autoCompact(s08)之后,队友的 messages 列表可能被压缩成一段摘要。每次进入新的 WORK 阶段时检查: + +```python +if len(messages) <= 3: + messages.insert(0, {"role": "user", + "content": f"You are '{name}', role: {role}. " + f"Continue your work."}) +``` + +消息过短说明发生了压缩,此时重新注入身份信息。真实 Claude Code 中 context compaction 会保留 system prompt,教学版的简化实现需要手动处理。 + +### consume_lead_inbox: 统一 inbox 消费 + +`check_inbox` 工具和主循环末尾都调用同一个 `consume_lead_inbox()` 函数:先路由协议 response 更新状态,再把所有消息注入 Lead 的对话历史。队友发来的 summary/result 不会只打印在终端,Lead 的 LLM 能看到并协调下一步。 + +### 合起来跑 + +``` +1. Lead: "搭建后端——任务太多,让队友自己认领" +2. Lead → create_task("创建数据库 schema") +3. Lead → create_task("写 API 路由") +4. Lead → create_task("写单元测试") +5. Lead → spawn_teammate("alice", "backend", "你是后端开发者") +6. Lead → spawn_teammate("bob", "backend", "你是后端开发者") + +7. alice 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE +8. bob 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE + +9. alice IDLE 第 1 次轮询 → scan_unclaimed → 发现"创建数据库 schema" +10. alice → claim_task → "创建数据库 schema" → 回到 WORK +11. bob IDLE 第 1 次轮询 → scan_unclaimed → 发现"写 API 路由" +12. bob → claim_task → "写 API 路由" → 回到 WORK + +13. alice WORK: write_file("schema.sql", ...) → complete_task → WORK 结束 +14. alice IDLE → scan → "写单元测试" → claim → WORK +15. alice WORK: write_file("test_api.py", ...) → complete_task → WORK 结束 +16. alice IDLE → 60s 无新任务 → SHUTDOWN + +17. bob 类似流程 → 做完 → SHUTDOWN +18. Lead consume_lead_inbox → 看到 alice 和 bob 的 summary +``` + +两个队友并行认领、并行工作。 + +--- + +## 相对 s16 的变更 + +| 组件 | 之前 (s16) | 之后 (s17) | +|------|-----------|-----------| +| 任务分配 | Lead 手动 assign | 队友自动认领(can_start 检查依赖) | +| 队友状态 | WORK 或退出 | WORK → IDLE(轮询 60s) → SHUTDOWN | +| claim_task | 无 owner 检查 | 拒绝已有 owner 的任务 | +| IDLE 阶段关机 | 不处理 shutdown_request | 直接 dispatch shutdown 并退出 | +| Lead inbox | 只打印,不进上下文 | consume_lead_inbox 统一注入 history | +| 新函数 | — | idle_poll, scan_unclaimed_tasks, consume_lead_inbox | +| 身份保持 | 仅 system prompt | 压缩后自动重注入 | +| Lead 工具 | 14 (s16) | 14(不变) | +| 队友工具 | 5 | 8(+ list_tasks, claim_task, complete_task) | +| 队友退出条件 | 完成任务即退出 | 60s 无新任务才退出 | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s17_autonomous_agents/code.py +``` + +试试这个 prompt: + +`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.` + +观察重点:队友是否自动认领了未分配的任务?有 blockedBy 依赖的任务是否在前置完成后被正确认领?空闲超时后是否自动关机?IDLE 阶段收到 shutdown_request 是否立即响应?`.tasks/` 目录下的任务状态如何变化? + +--- + +## 接下来 + +队友自组织了。但 Alice 和 Bob 都在同一个目录下工作:Alice 改 `config.py`,Bob 也改 `config.py`,互相覆盖。 + +s18 Worktree Isolation → 每个任务有自己的工作目录,互不干扰。 + +
+深入 Claude Code 源码 + +> 教学说明:本章的 idle_poll + auto-claim 机制是教学设计,用统一的轮询函数演示"空闲后找活干"。Claude Code 的实际实现是多个机制的组合,但目标一致,都是减少 Lead 的手动分配负担。 + +### 一、Claude Code 的空闲机制:组合路径,不是单一轮询 + +教学版用一个 `idle_poll()` 统一处理空闲时的 inbox 检查和任务认领。Claude Code 的实际实现是四个机制的组合: + +**idle_notification**:队友完成一轮工作后,`sendIdleNotification()`(`inProcessRunner.ts:569-589`)向 Lead 发送空闲通知。Lead 知道队友可用了,可以分配新任务或请求关机。 + +**mailbox 轮询**:`waitForNextPromptOrShutdown()`(`inProcessRunner.ts:689-868`)是一个 **500ms 轮询循环**,持续检查三类来源:pending user messages、mailbox 文件消息、task list。shutdown_request 被优先处理(`inProcessRunner.ts:768-804`),不会被普通消息饿死。 + +**task watcher**:`useTaskListWatcher`(`hooks/useTaskListWatcher.ts:34-189`)用 `fs.watch()` 监听 `.claude/tasks/` 目录变化,1 秒 debounce,当新任务创建或依赖解锁时触发检查。依赖判断(`L197-207`)是"blockedBy 中没有未完成的任务",不是"blockedBy 为空"。 + +**主动 claim**:轮询循环内部也会调用 `tryClaimNextTask()`(`inProcessRunner.ts:853-860`),在等待期间主动从 task list 领取任务。所以"队友不主动轮询任务"不准确,Claude Code 同时有被动通知和主动认领。 + +### 二、任务认领:文件锁 + 原子操作 + +`claimTask()`(`utils/tasks.ts:541-612`)用 `proper-lockfile` 的任务文件锁,在锁内完成读-检查-改-写。检查项:owner 是否已存在(`L575-576`)、是否已完成(`L580-581`)、blockedBy 中是否有未完成任务(`L585-594`)。`claimTaskWithBusyCheck()`(`utils/tasks.ts:614-692`)用 task-list 级别锁,把 busy check 和 claim 做成原子操作,避免 TOCTOU。 + +`findAvailableTask()`(`inProcessRunner.ts:595-604`)的依赖判断也是"所有 blockedBy 已完成",用 `task.blockedBy.every(id => !unresolvedTaskIds.has(id))` 实现。`tryClaimNextTask()`(`inProcessRunner.ts:624-657`)在认领后把状态更新为 `in_progress`,让 UI 立即反映变化。 + +### 三、教学版 vs Claude Code 对比 + +| 维度 | 教学版 (s17) | Claude Code | +|------|-------------|-----| +| 空闲机制 | idle_poll 统一轮询(5s) | idle_notification + 500ms mailbox 轮询 + task watcher | +| 任务发现 | scan_unclaimed_tasks(轮询) | useTaskListWatcher(文件监听)+ tryClaimNextTask(主动轮询) | +| 依赖判断 | can_start(所有 blockedBy 已完成) | findAvailableTask(同样语义) | +| 并发安全 | owner 检查(无文件锁) | proper-lockfile 任务锁 + task-list 锁 | +| shutdown 处理 | IDLE 直接分发,WORK 通过 handle_inbox_message | 500ms 轮询中优先处理 shutdown_request | +| 超时退出 | 60s 无新任务 | 无固定超时,Lead 手动 shutdown | +| 身份保持 | messages 长度检测 | context compaction 保留 system prompt | +| claim 失败处理 | 检查返回值,失败不注入 | 文件锁保证原子性 | + +教学版的 `idle_poll()` 把 Claude Code 的四个机制合并成一个轮询函数。简化合理,因为核心语义(空闲时找活干、依赖解锁后可认领、shutdown 优先)是一致的。 + +
+ + diff --git a/s17_autonomous_agents/images/autonomous-agents-overview.en.svg b/s17_autonomous_agents/images/autonomous-agents-overview.en.svg deleted file mode 100644 index 709676b63..000000000 --- a/s17_autonomous_agents/images/autonomous-agents-overview.en.svg +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - Autonomous Agents — Idle Loop + Auto-Claim + WORK/IDLE Lifecycle - - - - s16 Preserved - - s17 New - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH (all s16 preserved) - bash · read · write · task(4) · send · inbox - ★ request_shutdown · request_plan · review_plan - - - - - - - same inner LLM/tool loop inside WORK - - - - Teammate Lifecycle (s17 new: WORK → IDLE → SHUTDOWN) - - - - WORK Phase - inner loop: inbox → LLM → bash / read / write - stop_reason == tool_use → loop - stop_reason != tool_use → IDLE - Max 10 rounds / interruptible by shutdown_request - - - - task done - - - - work found - - - - IDLE Phase (poll every 5s) - ├ Check inbox → has message → back to WORK - ├ scan_unclaimed_tasks → claim → back to WORK - └ 60s timeout → SHUTDOWN ↓ - idle_poll() + claim_task() - - - - SHUTDOWN - - - - 60s timeout - - - - - s16: MessageBus + protocols + request_shutdown + plan approval - - s17: idle_poll + scan_unclaimed_tasks + auto_claim + identity re-injection - - - - Lead tools unchanged (14) · Teammate tools 5 → 8 (+3 task tools) · Teammates self-claim, Lead only creates tasks - diff --git a/s17_autonomous_agents/images/autonomous-agents-overview.ja.svg b/s17_autonomous_agents/images/autonomous-agents-overview.ja.svg deleted file mode 100644 index 65d9a764e..000000000 --- a/s17_autonomous_agents/images/autonomous-agents-overview.ja.svg +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - Autonomous Agents — アイドルポーリング + 自動認領 + WORK/IDLE ライフサイクル - - - - s16 保持 - - s17 新規 - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH(s16 全保持) - bash · read · write · task(4) · send · inbox - ★ request_shutdown · request_plan · review_plan - - - - - - - 同じ内側 LLM/tool loop を WORK に入れる - - - - チームメイトライフサイクル(s17 新規:WORK → IDLE → SHUTDOWN) - - - - WORK フェーズ - 内側ループ:inbox → LLM → bash / read / write - stop_reason == tool_use → ループ - stop_reason != tool_use → IDLE - 最大 10 ラウンド / shutdown_request で中断可能 - - - - タスク完了 - - - - 仕事を発見 - - - - IDLE フェーズ(5 秒ごとにポーリング) - ├ inbox チェック → メッセージあり → WORK に戻る - ├ scan_unclaimed_tasks → 認領 → WORK に戻る - └ 60 秒タイムアウト → SHUTDOWN ↓ - idle_poll() + claim_task() - - - - SHUTDOWN - - - - 60 秒タイムアウト - - - - - s16: MessageBus + protocols + request_shutdown + plan approval - - s17: idle_poll + scan_unclaimed_tasks + auto_claim + identity re-injection - - - - Lead ツール不変(14) · チームメイトツール 5 → 8(+3 task tools) · チームメイトが自己認領、Lead はタスク作成のみ - diff --git a/s17_autonomous_agents/images/autonomous-agents-overview.svg b/s17_autonomous_agents/images/autonomous-agents-overview.svg index df99675a4..cfcd0506b 100644 --- a/s17_autonomous_agents/images/autonomous-agents-overview.svg +++ b/s17_autonomous_agents/images/autonomous-agents-overview.svg @@ -1,109 +1,132 @@ - + - - - - - - - + - - + + - + + - - - Autonomous Agents — 空闲循环 + 自动认领 + WORK/IDLE 生命周期 - - - - s16 保留 - - s17 新增 + Autonomous Agents Overview + Idle Polling + Auto-Claim + WORK / IDLE / SHUTDOWN Lifecycle - - - turn + + + Lead Inner Loop + (LLM / tool cycle drives each turn) - + + + turn - - messages + + - + + + messages - - prompt + + - + + + prompt - - LLM + + - + + + LLM - - TOOL DISPATCH (s16 全保留) - bash · read · write · task(4) · send · inbox - ★ request_shutdown · request_plan · review_plan + + - - + + + Tool Dispatch + bash read write task send inbox ... - - - 同一个内层 LLM/tool loop 放进 WORK + + + + loop back (stop_reason == tool_use) - - - 队友生命周期(s17 新增:WORK → IDLE → SHUTDOWN) + + + Teammate Lifecycle + (WORK → IDLE → SHUTDOWN) - - WORK 阶段 - 内层循环:inbox → LLM → bash / read / write - stop_reason == tool_use → loop - stop_reason != tool_use → IDLE - 最多 10 轮 / 可被 shutdown_request 中断 - - - - 任务完成 + + WORK Phase - - - 发现新任务 + + + inbox → LLM → bash/read/write + stop_reason == tool_use → loop + stop_reason != tool_use → IDLE + max 10 rounds per phase - - IDLE 阶段(每 5s 轮询) - ├ 检查 inbox → 有消息 → 回 WORK - ├ scan_unclaimed_tasks → 认领 → 回 WORK - └ 60s 超时 → SHUTDOWN ↓ - idle_poll() + claim_task() + + IDLE Phase (poll 5s) + + + + check inbox → msg → WORK + scan_unclaimed → claim → WORK + 60s timeout → SHUTDOWN + idle_poll() + claim_task() + + + + task done + + + + + + new task found - - SHUTDOWN + + SHUTDOWN - - 60s 超时 - - - - - s16: MessageBus + protocols + request_shutdown + plan approval - - s17: idle_poll + scan_unclaimed_tasks + auto_claim + identity re-injection - - - - Lead 工具不变(14) · 队友工具 5 → 8(+3 task tools) · 队友自主认领,Lead 只创建任务 - + + 60s timeout + + + + Key Capabilities + + + + + + Component + Behavior + + + + + + idle_poll + Poll inbox + task board every 5s during IDLE; prioritize shutdown_request + + + scan_unclaimed_tasks + Find pending tasks with no owner and resolved dependencies (can_start) + + + claim_task + Auto-claim with owner check; rejects already-owned or blocked tasks + + \ No newline at end of file diff --git a/s18_worktree_isolation/README.en.md b/s18_worktree_isolation/README.en.md deleted file mode 100644 index 19834907f..000000000 --- a/s18_worktree_isolation/README.en.md +++ /dev/null @@ -1,208 +0,0 @@ -# s18: Worktree Isolation — Separate Directories, No Conflicts - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → ... → s16 → s17 → `s18` → [s19](../s19_mcp_plugin/) → s20 - -> *"Separate directories, no conflicts"* — Tasks own the goal, worktrees own the directory, bound by ID. -> -> **Harness Layer**: Isolation — Parallel execution in separate directories. - ---- - -## The Problem - -In s17, Alice and Bob both work in the same directory. Alice's task is "refactor auth module", Bob's task is "refactor UI login page". - -Alice calls `write_file("config.py", ...)`. Bob also calls `write_file("config.py", ...)`. Both edit the same file, overwriting each other. And there's no clean rollback — you can't tell whose changes are whose. - -s15-s17 solved "who does what" (task system) and "how to communicate" (message bus), but not "where to work". - ---- - -## The Solution - -![Worktree Overview](images/worktree-overview.en.svg) - -Git worktree lets you create multiple independent working directories in the same repo, each with its own branch. Alice works in `.worktrees/auth-refactor/`, Bob in `.worktrees/ui-login/` — no conflicts. - -Carries forward S17's teaching-version MessageBus, protocols, and autonomous claiming. This chapter adds: - -| Capability | Purpose | -|------------|---------| -| create_worktree | Create isolated directory + branch for a task | -| bind_task_to_worktree | Bind task and directory (no status change) | -| remove_worktree / keep_worktree | Cleanup or preserve after completion | -| validate_worktree_name | Reject path traversal and illegal characters | - ---- - -## How It Works - -### Creation: Task-Worktree Binding - -```python -def create_worktree(name: str, task_id: str = "") -> str: - validate_worktree_name(name) # Only [A-Za-z0-9._-]{1,64} - path = WORKTREES_DIR / name - ok, result = run_git(["worktree", "add", str(path), "-b", f"wt/{name}", "HEAD"]) - if not ok: - return f"Git error: {result}" - if task_id: - bind_task_to_worktree(task_id, name) - log_event("create", name, task_id) - return f"Worktree '{name}' created at {path}" - -def bind_task_to_worktree(task_id: str, worktree_name: str): - task = load_task(task_id) - task.worktree = worktree_name # Write worktree field only - save_task(task) # Status stays pending, waits for teammate claim -``` - -Binding rule: one task binds to one worktree. Binding does NOT change task status — the task stays `pending`, and advances to `in_progress` only when a teammate claims it. This way Lead can pre-create tasks and worktrees, and teammates naturally claim worktree-bound tasks during idle. - -### Teammate Tool Cwd Switching - -Teaching version maintains a `wt_ctx` dict per teammate, tracking the current worktree path. When a teammate claims a task with a worktree, `wt_ctx` is automatically set to the worktree path; the teammate's `bash`, `read_file`, `write_file` execute in the worktree directory: - -```python -# Inside teammate thread -wt_ctx = {"path": None} - -def _run_claim_task(task_id): - result = claim_task(task_id, owner=name) - if "Claimed" in result: - task = load_task(task_id) - if task.worktree: - wt_ctx["path"] = str(WORKTREES_DIR / task.worktree) - return result - -def _run_bash(command): - return run_bash(command, cwd=wt_ctx["path"]) # Execute in worktree -``` - -This is a teaching simplification. Real CC's EnterWorktree uses `process.chdir()` to switch the entire process directory, and AgentTool isolation uses `cwdOverride` to wrap sub-agent execution. - -### Cleanup: Keep or Remove - -After task completion, two choices: - -```python -def remove_worktree(name: str, discard_changes: bool = False) -> str: - # Safety check: refuse by default if changes exist - if not discard_changes: - files, commits = _count_worktree_changes(path) - if files > 0 or commits > 0: - return "Has uncommitted changes. Use discard_changes=true to force, or keep_worktree" - ok, _ = run_git(["worktree", "remove", str(path), "--force"]) - if not ok: - return "Remove failed" - run_git(["branch", "-D", f"wt/{name}"]) - log_event("remove", name) - -def keep_worktree(name: str) -> str: - log_event("keep", name) - return f"Worktree '{name}' kept for review (branch: wt/{name})" -``` - -Keep = preserve branch for manual review and merge. Remove = refuse by default if uncommitted changes; requires `discard_changes=true` to confirm. Does NOT auto-complete task — task completion is triggered explicitly by the teammate's `complete_task`. - -### Event Log: Auditable - -Each lifecycle operation writes to a log for auditing: - -```python -def log_event(event_type: str, worktree_name: str, task_id: str = ""): - event = {"type": event_type, "worktree": worktree_name, - "task_id": task_id, "ts": time.time()} - # append to .worktrees/events.jsonl -``` - -Event types: `create`, `remove`, `keep`. Teaching version logs events for manual auditing; full recovery would need an index or `git worktree list` scanning. - -### run_git: Returns Success/Failure - -```python -def run_git(args: list[str]) -> tuple[bool, str]: - r = subprocess.run(["git"] + args, cwd=WORKDIR, ...) - return r.returncode == 0, output -``` - -`create_worktree` and `remove_worktree` only write event logs after successful git commands, ensuring logs reflect actual state. - ---- - -## Changes from s17 - -| Component | Before (s17) | After (s18) | -|-----------|-------------|-------------| -| Working directory | All agents share WORKDIR | Each task can bind to a git worktree | -| Task data | id/subject/status/owner/blockedBy | + worktree field | -| Teammate tool cwd | Always WORKDIR | Auto-switches when claiming worktree-bound task | -| New functions | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name | -| Worktree safety | None | Name validation + refuse removal with changes | -| Event log | None | events.jsonl lifecycle auditing | -| Lead tools | 14 (s17) | + create_worktree, remove_worktree, keep_worktree (17) | -| Teammate tools | 8 (s17) | 8 (bash/read/write execute in worktree cwd) | - ---- - -## Try It - -```sh -cd learn-claude-code -python s18_worktree_isolation/code.py -``` - -Try this prompt: - -`Create two tasks, then create worktrees for each (bind with task_id). Spawn alice and bob. Watch them auto-claim and work in isolated directories.` - -What to observe: Do both worktrees show different branches in `git status`? After claiming a worktree-bound task, does the teammate's bash run in the worktree directory? Does `remove_worktree` refuse when there are changes? Is task status still `pending` after binding? - ---- - -## What's Next - -Agent teams can now self-organize in isolated workspaces. But Agent capabilities are limited to the tools we wrote — bash, read, write, task... - -What if users already have their own tools? Like an internal Jira API, or a custom deployment system? - -s19 MCP Plugin → Give Agent a plugin system. External tools connect via standard protocol; Agent doesn't need to know who wrote them. - -
-Deep Dive into CC Source - -CC's worktree system has two paths: **EnterWorktree** (current session switches in) and **AgentTool isolation** (sub-agent isolation). - -### EnterWorktree: Current Session Switch - -`EnterWorktreeTool.ts:92-97` after creating the worktree, immediately calls `process.chdir(worktreePath)`, `setCwd()`, `setOriginalCwd()`, `saveWorktreeState()`. The current session's working directory switches directly to the worktree — not a prompt hint, but a process-level directory change. - -`ExitWorktreeTool.ts:261-320` both keep and remove call `restoreSessionToOriginalCwd()` to restore the original directory. Remove checks for uncommitted changes (`ExitWorktreeTool.ts:190-220`), refusing without `discard_changes: true`. - -### AgentTool Isolation: Sub-Agent Isolation - -`AgentTool.tsx:590-641` when `isolation: "worktree"`, calls `createAgentWorktree()` to create a worktree, uses `cwdOverridePath` to wrap sub-agent execution. All sub-agent operations automatically run in the worktree directory. `AgentTool/prompt.ts:272` tells the model: this is a temporary worktree, auto-cleanup if no changes, return path and branch if changes exist. - -`worktree.ts:902-951` `createAgentWorktree()` does NOT modify global session cwd, only for sub-agent use. `worktree.ts:961-1020` `removeAgentWorktree()` deletes from the main repo root. - -### Name Validation - -`worktree.ts:76-84` validates slug: rejects `.`/`..`, allows `[a-zA-Z0-9._-]`. `worktree.ts:48` defines `VALID_WORKTREE_SLUG_SEGMENT`. Teaching version's `validate_worktree_name` uses the same rule. - -### Path and Branch Naming - -Real path is `.claude/worktrees/`, branch name `worktree-{slug}` (`worktree.ts:204-227`, slashes replaced with `+`). Teaching version uses `.worktrees/` and `wt/{name}` for simplicity. - -Creation uses `git worktree add -B` (`worktree.ts:326-328`), preferring `origin/` over current HEAD. - -### State Management - -CC has no task-worktree binding. Worktree state is managed through `PersistedWorktreeSession` (`worktree.ts:756-768`), with fields including `originalCwd`, `worktreePath`, `worktreeName`, `worktreeBranch`, `originalBranch`, `originalHeadCommit`, `sessionId`, etc. — no taskId field. `saveWorktreeState()` (`sessionStorage.ts:2883-2920`) writes to session transcript with `type: 'worktree-state'`. - -Teaching version uses the task's `worktree` field for binding, a teaching simplification. CC treats worktree and task as two independent systems, connected through the Agent's context understanding. - -
- - diff --git a/s18_worktree_isolation/README.ja.md b/s18_worktree_isolation/README.ja.md index 1edc5a7e3..1955cf57b 100644 --- a/s18_worktree_isolation/README.ja.md +++ b/s18_worktree_isolation/README.ja.md @@ -1,6 +1,6 @@ # s18: Worktree Isolation — それぞれのディレクトリ、互いに干渉しない -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s16 → s17 → `s18` → [s19](../s19_mcp_plugin/) → s20 @@ -14,7 +14,7 @@ s01 → ... → s16 → s17 → `s18` → [s19](../s19_mcp_plugin/) → s20 s17 では、Alice も Bob も同じディレクトリで作業。Alice のタスクは「認証モジュールのリファクタリング」、Bob のタスクは「UI ログインページのリファクタリング」。 -Alice が `write_file("config.py", ...)` を呼び出し、Bob も `write_file("config.py", ...)` を呼び出す。両者が同じファイルを編集し、互いに上書き。クリーンなロールバックもできない——どの変更が誰のものか区別できない。 +Alice が `write_file("config.py", ...)` を呼び出し、Bob も `write_file("config.py", ...)` を呼び出す。両者が同じファイルを編集し、互いに上書き。クリーンなロールバックもできず、どの変更が誰のものか区別できない。 s15-s17 は「誰が何をするか」(タスクシステム)と「どう通信するか」(メッセージバス)を解決したが、「どこで作業するか」は未解決。 @@ -22,9 +22,9 @@ s15-s17 は「誰が何をするか」(タスクシステム)と「どう通 ## ソリューション -![Worktree Overview](images/worktree-overview.ja.svg) +![Worktree Overview](images/worktree-overview.svg) -Git worktree を使うと、同じリポジトリ内に複数の独立した作業ディレクトリを作成でき、それぞれが独自のブランチを持つ。Alice は `.worktrees/auth-refactor/` で作業、Bob は `.worktrees/ui-login/` で作業——互いに干渉しない。 +Git worktree を使うと、同じリポジトリ内に複数の独立した作業ディレクトリを作成でき、それぞれが独自のブランチを持つ。Alice は `.worktrees/auth-refactor/` で作業、Bob は `.worktrees/ui-login/` で作業し、ファイル操作は完全に独立する。 S17 の教学版 MessageBus、プロトコル、自治認領機構を踏襲。本章の追加: @@ -59,7 +59,7 @@ def bind_task_to_worktree(task_id: str, worktree_name: str): save_task(task) # 状態は pending のまま、チームメイトの claim を待つ ``` -紐付けルール:1 つのタスクに 1 つの worktree を紐付け。紐付けはタスクの状態を変更しない——タスクは `pending` のままで、チームメイトが認領した時に `in_progress` に進む。これにより Lead は事前にタスクと worktree を作成でき、チームメイトは idle 時に自然に worktree 紐付け済みタスクを認領する。 +紐付けルール:1 つのタスクに 1 つの worktree を紐付け。紐付けはタスクの状態を変更しない。タスクは `pending` のままで、チームメイトが認領した時に `in_progress` に進む。これにより Lead は事前にタスクと worktree を作成でき、チームメイトは idle 時に自然に worktree 紐付け済みタスクを認領する。 ### チームメイトツールの cwd 切り替え @@ -81,7 +81,7 @@ def _run_bash(command): return run_bash(command, cwd=wt_ctx["path"]) # worktree で実行 ``` -これは教学簡略化。真实 CC の EnterWorktree は `process.chdir()` でプロセス全体のディレクトリを切り替え、AgentTool isolation は `cwdOverride` でサブエージェント実行をラップする。 +これは教学簡略化。真实 Claude Code の EnterWorktree は `process.chdir()` でプロセス全体のディレクトリを切り替え、AgentTool isolation は `cwdOverride` でサブエージェント実行をラップする。 ### クリーンアップ:Keep または Remove @@ -105,7 +105,7 @@ def keep_worktree(name: str) -> str: return f"Worktree '{name}' kept for review (branch: wt/{name})" ``` -Keep = ブランチを保持し、手動 review 後にマージ。Remove = 未コミット変更がある場合デフォルトで拒否、`discard_changes=true` で確認が必要。タスクの自動 complete はしない——タスク完了はチームメイトの `complete_task` で明示的にトリガー。 +Keep = ブランチを保持し、手動 review 後にマージ。Remove = 未コミット変更がある場合デフォルトで拒否、`discard_changes=true` で確認が必要。タスクの自動 complete はしない。タスク完了はチームメイトの `complete_task` で明示的にトリガー。 ### イベントログ:監査可能 @@ -164,20 +164,20 @@ python s18_worktree_isolation/code.py ## 次の章 -Agent チームが隔離されたワークスペースで自己組織化できるようになった。しかし Agent の能力はツールに制限される——bash、read、write、task... +Agent チームが隔離されたワークスペースで自己組織化できるようになった。しかし Agent の能力はツールに制限される:bash、read、write、task... もしユーザーが独自のツールを持っていたら?例えば社内 Jira API や独自デプロイシステム? s19 MCP Plugin → Agent にプラグインシステムを追加。外部ツールが標準プロトコルで接続、Agent は誰が書いたか知る必要がない。
-CC ソースコード深掘り +Claude Code ソースコード深掘り -CC の worktree システムには 2 つのパスがある:**EnterWorktree**(現在のセッションが切り替え)と **AgentTool isolation**(サブエージェント隔離)。 +Claude Code の worktree システムには 2 つのパスがある:**EnterWorktree**(現在のセッションが切り替え)と **AgentTool isolation**(サブエージェント隔離)。 ### EnterWorktree:現在のセッション切り替え -`EnterWorktreeTool.ts:92-97` worktree 作成後、直ちに `process.chdir(worktreePath)`、`setCwd()`、`setOriginalCwd()`、`saveWorktreeState()` を呼び出し。現在のセッションの作業ディレクトリが直接 worktree に切り替わる——プロンプトのヒントではなく、プロセスレベルのディレクトリ変更。 +`EnterWorktreeTool.ts:92-97` worktree 作成後、直ちに `process.chdir(worktreePath)`、`setCwd()`、`setOriginalCwd()`、`saveWorktreeState()` を呼び出し。現在のセッションの作業ディレクトリが直接 worktree に切り替わる。プロンプトのヒントではなく、プロセスレベルのディレクトリ変更。 `ExitWorktreeTool.ts:261-320` keep/remove どちらも `restoreSessionToOriginalCwd()` で元のディレクトリに復元。Remove は未コミット変更をチェック(`ExitWorktreeTool.ts:190-220`)、`discard_changes: true` なしでは拒否。 @@ -199,9 +199,9 @@ CC の worktree システムには 2 つのパスがある:**EnterWorktree** ### 状態管理 -CC にはタスク-worktree 紐付けがない。Worktree 状態は `PersistedWorktreeSession`(`worktree.ts:756-768`)で管理、フィールドは `originalCwd`、`worktreePath`、`worktreeName`、`worktreeBranch`、`originalBranch`、`originalHeadCommit`、`sessionId` 等を含む——taskId フィールドはない。`saveWorktreeState()`(`sessionStorage.ts:2883-2920`)は `type: 'worktree-state'` で session transcript に書き込み。 +Claude Code にはタスク-worktree 紐付けがない。Worktree 状態は `PersistedWorktreeSession`(`worktree.ts:756-768`)で管理、フィールドは `originalCwd`、`worktreePath`、`worktreeName`、`worktreeBranch`、`originalBranch`、`originalHeadCommit`、`sessionId` 等を含む——taskId フィールドはない。`saveWorktreeState()`(`sessionStorage.ts:2883-2920`)は `type: 'worktree-state'` で session transcript に書き込み。 -教学版はタスクの `worktree` フィールドで紐付けを行う教学簡略化。CC は worktree とタスクを 2 つの独立システムとして扱い、Agent のコンテキスト理解で関連付ける。 +教学版はタスクの `worktree` フィールドで紐付けを行う教学簡略化。Claude Code は worktree とタスクを 2 つの独立システムとして扱い、Agent のコンテキスト理解で関連付ける。
diff --git a/s18_worktree_isolation/README.md b/s18_worktree_isolation/README.md index fcf39ac10..aa61c0142 100644 --- a/s18_worktree_isolation/README.md +++ b/s18_worktree_isolation/README.md @@ -1,49 +1,49 @@ -# s18: Worktree Isolation — 各干各的,互不干扰 +# s18: Worktree Isolation — Separate Directories, No Conflicts -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s16 → s17 → `s18` → [s19](../s19_mcp_plugin/) → s20 -> *"各干各的目录, 互不干扰"* — 任务管目标, worktree 管目录, 按 ID 绑定。 +> *"Separate directories, no conflicts"* — Tasks own the goal, worktrees own the directory, bound by ID. > -> **Harness 层**: 隔离 — 并行执行的目录隔离。 +> **Harness Layer**: Isolation — Parallel execution in separate directories. --- -## 问题 +## The Problem -s17 中,Alice 和 Bob 都在同一个目录下工作。Alice 的任务是"重构认证模块",Bob 的任务是"重构 UI 登录页"。 +In s17, Alice and Bob both work in the same directory. Alice's task is "refactor auth module", Bob's task is "refactor UI login page". -Alice `write_file("config.py", ...)`。Bob 也 `write_file("config.py", ...)`。两个人改同一个文件,互相覆盖。而且无法干净地回滚——分不清哪些改动是谁的。 +Alice calls `write_file("config.py", ...)`. Bob also calls `write_file("config.py", ...)`. Both edit the same file, overwriting each other. And there's no clean rollback; you can't tell whose changes are whose. -s15-s17 解决了"谁干什么"(任务系统)和"怎么通信"(消息总线),但没解决"在哪干"。 +s15-s17 solved "who does what" (task system) and "how to communicate" (message bus), but not "where to work". --- -## 解决方案 +## The Solution ![Worktree Overview](images/worktree-overview.svg) -Git worktree 让你在同一仓库中创建多个独立的工作目录,每个有自己的分支。Alice 在 `.worktrees/auth-refactor/` 下工作,Bob 在 `.worktrees/ui-login/` 下工作——互不干扰。 +Git worktree lets you create multiple independent working directories in the same repo, each with its own branch. Alice works in `.worktrees/auth-refactor/`, Bob in `.worktrees/ui-login/`, with completely independent file systems. -沿用 S17 的教学版 MessageBus、协议和自治认领机制。本章新增: +Carries forward S17's teaching-version MessageBus, protocols, and autonomous claiming. This chapter adds: -| 能力 | 作用 | -|------|------| -| create_worktree | 为任务创建独立目录 + 独立分支 | -| bind_task_to_worktree | 把任务和工作目录绑定(不改状态) | -| remove_worktree / keep_worktree | 完成后清理或保留 | -| validate_worktree_name | 拒绝路径穿越和非法字符 | +| Capability | Purpose | +|------------|---------| +| create_worktree | Create isolated directory + branch for a task | +| bind_task_to_worktree | Bind task and directory (no status change) | +| remove_worktree / keep_worktree | Cleanup or preserve after completion | +| validate_worktree_name | Reject path traversal and illegal characters | --- -## 工作原理 +## How It Works -### 创建:任务-Worktree 绑定 +### Creation: Task-Worktree Binding ```python def create_worktree(name: str, task_id: str = "") -> str: - validate_worktree_name(name) # 只允许 [A-Za-z0-9._-]{1,64} + validate_worktree_name(name) # Only [A-Za-z0-9._-]{1,64} path = WORKTREES_DIR / name ok, result = run_git(["worktree", "add", str(path), "-b", f"wt/{name}", "HEAD"]) if not ok: @@ -55,18 +55,18 @@ def create_worktree(name: str, task_id: str = "") -> str: def bind_task_to_worktree(task_id: str, worktree_name: str): task = load_task(task_id) - task.worktree = worktree_name # 只写 worktree 字段 - save_task(task) # 状态保持 pending,等队友 claim + task.worktree = worktree_name # Write worktree field only + save_task(task) # Status stays pending, waits for teammate claim ``` -绑定规则:一个任务绑定一个 worktree。绑定不改任务状态——任务仍是 `pending`,队友自动认领时才推进到 `in_progress`。这样 Lead 可以提前创建任务和 worktree,队友 idle 时自然认领带 worktree 的任务。 +Binding rule: one task binds to one worktree. Binding does NOT change task status: the task stays `pending`, and advances to `in_progress` only when a teammate claims it. This way Lead can pre-create tasks and worktrees, and teammates naturally claim worktree-bound tasks during idle. -### 队友工具的 cwd 切换 +### Teammate Tool Cwd Switching -教学版给每个队友维护一个 `wt_ctx` 字典,记录当前 worktree 路径。队友认领带 worktree 的任务时,`wt_ctx` 自动设置为 worktree 路径;队友的 `bash`、`read_file`、`write_file` 在 worktree 目录下执行: +Teaching version maintains a `wt_ctx` dict per teammate, tracking the current worktree path. When a teammate claims a task with a worktree, `wt_ctx` is automatically set to the worktree path; the teammate's `bash`, `read_file`, `write_file` execute in the worktree directory: ```python -# 队友线程内部 +# Inside teammate thread wt_ctx = {"path": None} def _run_claim_task(task_id): @@ -78,25 +78,25 @@ def _run_claim_task(task_id): return result def _run_bash(command): - return run_bash(command, cwd=wt_ctx["path"]) # 在 worktree 下执行 + return run_bash(command, cwd=wt_ctx["path"]) # Execute in worktree ``` -这是教学简化。真实 CC 的 EnterWorktree 用 `process.chdir()` 切换整个进程目录,AgentTool isolation 用 `cwdOverride` 包住子 agent 执行。 +This is a teaching simplification. Real Claude Code's EnterWorktree uses `process.chdir()` to switch the entire process directory, and AgentTool isolation uses `cwdOverride` to wrap sub-agent execution. -### 收尾:Keep 还是 Remove +### Cleanup: Keep or Remove -任务完成后,两个选择: +After task completion, two choices: ```python def remove_worktree(name: str, discard_changes: bool = False) -> str: - # 安全检查:有改动时默认拒绝 + # Safety check: refuse by default if changes exist if not discard_changes: files, commits = _count_worktree_changes(path) if files > 0 or commits > 0: - return "有未提交改动,使用 discard_changes=true 强制删除,或 keep_worktree 保留" + return "Has uncommitted changes. Use discard_changes=true to force, or keep_worktree" ok, _ = run_git(["worktree", "remove", str(path), "--force"]) if not ok: - return "删除失败" + return "Remove failed" run_git(["branch", "-D", f"wt/{name}"]) log_event("remove", name) @@ -105,11 +105,11 @@ def keep_worktree(name: str) -> str: return f"Worktree '{name}' kept for review (branch: wt/{name})" ``` -Keep = 留着分支,等人工 review 后合并到主分支。Remove = 有改动时默认拒绝,需要 `discard_changes=true` 确认。不自动 complete task——任务完成由队友的 `complete_task` 显式触发。 +Keep = preserve branch for manual review and merge. Remove = refuse by default if uncommitted changes; requires `discard_changes=true` to confirm. Does NOT auto-complete task. Task completion is triggered explicitly by the teammate's `complete_task`. -### 事件流:可审计 +### Event Log: Auditable -每次生命周期操作写入日志,方便排查: +Each lifecycle operation writes to a log for auditing: ```python def log_event(event_type: str, worktree_name: str, task_id: str = ""): @@ -118,9 +118,9 @@ def log_event(event_type: str, worktree_name: str, task_id: str = ""): # append to .worktrees/events.jsonl ``` -事件类型:`create`(创建)、`remove`(删除)、`keep`(保留)。教学版只记录事件用于人工排查;完整恢复还需要 index 或 `git worktree list` 扫描。 +Event types: `create`, `remove`, `keep`. Teaching version logs events for manual auditing; full recovery would need an index or `git worktree list` scanning. -### run_git:返回成功/失败 +### run_git: Returns Success/Failure ```python def run_git(args: list[str]) -> tuple[bool, str]: @@ -128,81 +128,81 @@ def run_git(args: list[str]) -> tuple[bool, str]: return r.returncode == 0, output ``` -`create_worktree` 和 `remove_worktree` 只在 git 命令成功后才写事件日志,保证日志反映真实状态。 +`create_worktree` and `remove_worktree` only write event logs after successful git commands, ensuring logs reflect actual state. --- -## 相对 s17 的变更 +## Changes from s17 -| 组件 | 之前 (s17) | 之后 (s18) | -|------|-----------|-----------| -| 工作目录 | 所有 Agent 共享 WORKDIR | 每个任务可绑定独立 git worktree | -| Task 数据 | id/subject/status/owner/blockedBy | + worktree 字段 | -| 队友工具 cwd | 始终 WORKDIR | 认领带 worktree 的任务时自动切换 | -| 新函数 | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name | -| worktree 安全 | 无 | name 校验 + 有改动时拒绝删除 | -| 事件日志 | 无 | events.jsonl 生命周期审计 | -| Lead 工具 | 14 (s17) | + create_worktree, remove_worktree, keep_worktree (17) | -| 队友工具 | 8 (s17) | 8(bash/read/write 在 worktree cwd 执行) | +| Component | Before (s17) | After (s18) | +|-----------|-------------|-------------| +| Working directory | All agents share WORKDIR | Each task can bind to a git worktree | +| Task data | id/subject/status/owner/blockedBy | + worktree field | +| Teammate tool cwd | Always WORKDIR | Auto-switches when claiming worktree-bound task | +| New functions | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name | +| Worktree safety | None | Name validation + refuse removal with changes | +| Event log | None | events.jsonl lifecycle auditing | +| Lead tools | 14 (s17) | + create_worktree, remove_worktree, keep_worktree (17) | +| Teammate tools | 8 (s17) | 8 (bash/read/write execute in worktree cwd) | --- -## 试一下 +## Try It ```sh cd learn-claude-code python s18_worktree_isolation/code.py ``` -试试这个 prompt: +Try this prompt: `Create two tasks, then create worktrees for each (bind with task_id). Spawn alice and bob. Watch them auto-claim and work in isolated directories.` -观察重点:两个 worktree 的 `git status` 输出是否显示不同的分支?队友认领带 worktree 的任务后,bash 命令是否在 worktree 目录下执行?`remove_worktree` 对有改动的 worktree 是否拒绝?`.tasks/` 中的任务在绑定后状态是否仍为 `pending`? +What to observe: Do both worktrees show different branches in `git status`? After claiming a worktree-bound task, does the teammate's bash run in the worktree directory? Does `remove_worktree` refuse when there are changes? Is task status still `pending` after binding? --- -## 接下来 +## What's Next -Agent 团队能在隔离的工作空间中自组织了。但 Agent 的能力受限于我们给它写的工具——bash、read、write、task... +Agent teams can now self-organize in isolated workspaces. But Agent capabilities are limited to the tools we wrote: bash, read, write, task... -如果用户已经有了自己的工具怎么办?比如一个公司内部的 Jira API、一个自建的部署系统? +What if users already have their own tools? Like an internal Jira API, or a custom deployment system? -s19 MCP Plugin → 给 Agent 装一个插件系统。外部工具通过标准协议接入,Agent 不需要知道它们是谁写的。 +s19 MCP Plugin → Give Agent a plugin system. External tools connect via standard protocol; Agent doesn't need to know who wrote them.
-深入 CC 源码 +Deep Dive into Claude Code Source -CC 的 worktree 系统有两条路径:**EnterWorktree**(当前会话切入)和 **AgentTool isolation**(子 agent 隔离)。 +Claude Code's worktree system has two paths: **EnterWorktree** (current session switches in) and **AgentTool isolation** (sub-agent isolation). -### EnterWorktree:当前会话切换 +### EnterWorktree: Current Session Switch -`EnterWorktreeTool.ts:92-97` 创建 worktree 后立即 `process.chdir(worktreePath)`、`setCwd()`、`setOriginalCwd()`、`saveWorktreeState()`。当前会话的工作目录直接切换到 worktree——不是 prompt 提醒,而是进程级目录变更。 +`EnterWorktreeTool.ts:92-97` after creating the worktree, immediately calls `process.chdir(worktreePath)`, `setCwd()`, `setOriginalCwd()`, `saveWorktreeState()`. The current session's working directory switches directly to the worktree, not a prompt hint but a process-level directory change. -`ExitWorktreeTool.ts:261-320` 的 keep/remove 都会 `restoreSessionToOriginalCwd()` 恢复原目录。Remove 时检查未提交改动(`ExitWorktreeTool.ts:190-220`),没有 `discard_changes: true` 就拒绝删除。 +`ExitWorktreeTool.ts:261-320` both keep and remove call `restoreSessionToOriginalCwd()` to restore the original directory. Remove checks for uncommitted changes (`ExitWorktreeTool.ts:190-220`), refusing without `discard_changes: true`. -### AgentTool isolation:子 agent 隔离 +### AgentTool Isolation: Sub-Agent Isolation -`AgentTool.tsx:590-641` 在 `isolation: "worktree"` 时调用 `createAgentWorktree()` 创建 worktree,用 `cwdOverridePath` 包住子 agent 执行。子 agent 的所有操作自动在 worktree 目录下进行。`AgentTool/prompt.ts:272` 告诉模型:这是临时 worktree,无改动自动清理,有改动返回路径和分支。 +`AgentTool.tsx:590-641` when `isolation: "worktree"`, calls `createAgentWorktree()` to create a worktree, uses `cwdOverridePath` to wrap sub-agent execution. All sub-agent operations automatically run in the worktree directory. `AgentTool/prompt.ts:272` tells the model: this is a temporary worktree, auto-cleanup if no changes, return path and branch if changes exist. -`worktree.ts:902-951` 的 `createAgentWorktree()` 不修改全局 session cwd,只给子 agent 用。`worktree.ts:961-1020` 的 `removeAgentWorktree()` 从主 repo root 删除。 +`worktree.ts:902-951` `createAgentWorktree()` does NOT modify global session cwd, only for sub-agent use. `worktree.ts:961-1020` `removeAgentWorktree()` deletes from the main repo root. -### name 校验 +### Name Validation -`worktree.ts:76-84` 校验 slug:拒绝 `.`/`..`,允许 `[a-zA-Z0-9._-]`。`worktree.ts:48` 定义 `VALID_WORKTREE_SLUG_SEGMENT`。教学版的 `validate_worktree_name` 用同样的规则。 +`worktree.ts:76-84` validates slug: rejects `.`/`..`, allows `[a-zA-Z0-9._-]`. `worktree.ts:48` defines `VALID_WORKTREE_SLUG_SEGMENT`. Teaching version's `validate_worktree_name` uses the same rule. -### 路径和分支命名 +### Path and Branch Naming -真实路径是 `.claude/worktrees/`,分支名 `worktree-{slug}`(`worktree.ts:204-227`,斜杠用 `+` 替代)。教学版用 `.worktrees/` 和 `wt/{name}` 简化。 +Real path is `.claude/worktrees/`, branch name `worktree-{slug}` (`worktree.ts:204-227`, slashes replaced with `+`). Teaching version uses `.worktrees/` and `wt/{name}` for simplicity. -创建时用 `git worktree add -B`(`worktree.ts:326-328`),优先基于 `origin/` 而非当前 HEAD。 +Creation uses `git worktree add -B` (`worktree.ts:326-328`), preferring `origin/` over current HEAD. -### 状态管理 +### State Management -CC 没有 task-worktree 绑定。Worktree 状态通过 `PersistedWorktreeSession`(`worktree.ts:756-768`)管理,字段包括 `originalCwd`、`worktreePath`、`worktreeName`、`worktreeBranch`、`originalBranch`、`originalHeadCommit`、`sessionId` 等——没有 taskId。`saveWorktreeState()`(`sessionStorage.ts:2883-2920`)以 `type: 'worktree-state'` 写入 session transcript。 +Claude Code has no task-worktree binding. Worktree state is managed through `PersistedWorktreeSession` (`worktree.ts:756-768`), with fields including `originalCwd`, `worktreePath`, `worktreeName`, `worktreeBranch`, `originalBranch`, `originalHeadCommit`, `sessionId`, etc. — no taskId field. `saveWorktreeState()` (`sessionStorage.ts:2883-2920`) writes to session transcript with `type: 'worktree-state'`. -教学版用 task 的 `worktree` 字段做绑定,是教学简化。CC 把 worktree 和 task 作为两个独立系统,通过 Agent 理解上下文来关联。 +Teaching version uses the task's `worktree` field for binding, a teaching simplification. Claude Code treats worktree and task as two independent systems, connected through the Agent's context understanding.
- + diff --git a/s18_worktree_isolation/README.zh.md b/s18_worktree_isolation/README.zh.md new file mode 100644 index 000000000..dd9d11d0d --- /dev/null +++ b/s18_worktree_isolation/README.zh.md @@ -0,0 +1,208 @@ +# s18: Worktree Isolation — 各干各的,互不干扰 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s16 → s17 → `s18` → [s19](../s19_mcp_plugin/) → s20 + +> *"各干各的目录, 互不干扰"* — 任务管目标, worktree 管目录, 按 ID 绑定。 +> +> **Harness 层**: 隔离 — 并行执行的目录隔离。 + +--- + +## 问题 + +s17 中,Alice 和 Bob 都在同一个目录下工作。Alice 的任务是"重构认证模块",Bob 的任务是"重构 UI 登录页"。 + +Alice `write_file("config.py", ...)`。Bob 也 `write_file("config.py", ...)`。两个人改同一个文件,互相覆盖。而且无法干净地回滚,分不清哪些改动是谁的。 + +s15-s17 解决了"谁干什么"(任务系统)和"怎么通信"(消息总线),但没解决"在哪干"。 + +--- + +## 解决方案 + +![Worktree Overview](images/worktree-overview.svg) + +Git worktree 让你在同一仓库中创建多个独立的工作目录,每个有自己的分支。Alice 在 `.worktrees/auth-refactor/` 下工作,Bob 在 `.worktrees/ui-login/` 下工作,文件操作完全独立。 + +沿用 S17 的教学版 MessageBus、协议和自治认领机制。本章新增: + +| 能力 | 作用 | +|------|------| +| create_worktree | 为任务创建独立目录 + 独立分支 | +| bind_task_to_worktree | 把任务和工作目录绑定(不改状态) | +| remove_worktree / keep_worktree | 完成后清理或保留 | +| validate_worktree_name | 拒绝路径穿越和非法字符 | + +--- + +## 工作原理 + +### 创建:任务-Worktree 绑定 + +```python +def create_worktree(name: str, task_id: str = "") -> str: + validate_worktree_name(name) # 只允许 [A-Za-z0-9._-]{1,64} + path = WORKTREES_DIR / name + ok, result = run_git(["worktree", "add", str(path), "-b", f"wt/{name}", "HEAD"]) + if not ok: + return f"Git error: {result}" + if task_id: + bind_task_to_worktree(task_id, name) + log_event("create", name, task_id) + return f"Worktree '{name}' created at {path}" + +def bind_task_to_worktree(task_id: str, worktree_name: str): + task = load_task(task_id) + task.worktree = worktree_name # 只写 worktree 字段 + save_task(task) # 状态保持 pending,等队友 claim +``` + +绑定规则:一个任务绑定一个 worktree。绑定不改任务状态,任务仍是 `pending`,队友自动认领时才推进到 `in_progress`。这样 Lead 可以提前创建任务和 worktree,队友 idle 时自然认领带 worktree 的任务。 + +### 队友工具的 cwd 切换 + +教学版给每个队友维护一个 `wt_ctx` 字典,记录当前 worktree 路径。队友认领带 worktree 的任务时,`wt_ctx` 自动设置为 worktree 路径;队友的 `bash`、`read_file`、`write_file` 在 worktree 目录下执行: + +```python +# 队友线程内部 +wt_ctx = {"path": None} + +def _run_claim_task(task_id): + result = claim_task(task_id, owner=name) + if "Claimed" in result: + task = load_task(task_id) + if task.worktree: + wt_ctx["path"] = str(WORKTREES_DIR / task.worktree) + return result + +def _run_bash(command): + return run_bash(command, cwd=wt_ctx["path"]) # 在 worktree 下执行 +``` + +这是教学简化。真实 Claude Code 的 EnterWorktree 用 `process.chdir()` 切换整个进程目录,AgentTool isolation 用 `cwdOverride` 包住子 agent 执行。 + +### 收尾:Keep 还是 Remove + +任务完成后,两个选择: + +```python +def remove_worktree(name: str, discard_changes: bool = False) -> str: + # 安全检查:有改动时默认拒绝 + if not discard_changes: + files, commits = _count_worktree_changes(path) + if files > 0 or commits > 0: + return "有未提交改动,使用 discard_changes=true 强制删除,或 keep_worktree 保留" + ok, _ = run_git(["worktree", "remove", str(path), "--force"]) + if not ok: + return "删除失败" + run_git(["branch", "-D", f"wt/{name}"]) + log_event("remove", name) + +def keep_worktree(name: str) -> str: + log_event("keep", name) + return f"Worktree '{name}' kept for review (branch: wt/{name})" +``` + +Keep = 留着分支,等人工 review 后合并到主分支。Remove = 有改动时默认拒绝,需要 `discard_changes=true` 确认。不自动 complete task。任务完成由队友的 `complete_task` 显式触发。 + +### 事件流:可审计 + +每次生命周期操作写入日志,方便排查: + +```python +def log_event(event_type: str, worktree_name: str, task_id: str = ""): + event = {"type": event_type, "worktree": worktree_name, + "task_id": task_id, "ts": time.time()} + # append to .worktrees/events.jsonl +``` + +事件类型:`create`(创建)、`remove`(删除)、`keep`(保留)。教学版只记录事件用于人工排查;完整恢复还需要 index 或 `git worktree list` 扫描。 + +### run_git:返回成功/失败 + +```python +def run_git(args: list[str]) -> tuple[bool, str]: + r = subprocess.run(["git"] + args, cwd=WORKDIR, ...) + return r.returncode == 0, output +``` + +`create_worktree` 和 `remove_worktree` 只在 git 命令成功后才写事件日志,保证日志反映真实状态。 + +--- + +## 相对 s17 的变更 + +| 组件 | 之前 (s17) | 之后 (s18) | +|------|-----------|-----------| +| 工作目录 | 所有 Agent 共享 WORKDIR | 每个任务可绑定独立 git worktree | +| Task 数据 | id/subject/status/owner/blockedBy | + worktree 字段 | +| 队友工具 cwd | 始终 WORKDIR | 认领带 worktree 的任务时自动切换 | +| 新函数 | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name | +| worktree 安全 | 无 | name 校验 + 有改动时拒绝删除 | +| 事件日志 | 无 | events.jsonl 生命周期审计 | +| Lead 工具 | 14 (s17) | + create_worktree, remove_worktree, keep_worktree (17) | +| 队友工具 | 8 (s17) | 8(bash/read/write 在 worktree cwd 执行) | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s18_worktree_isolation/code.py +``` + +试试这个 prompt: + +`Create two tasks, then create worktrees for each (bind with task_id). Spawn alice and bob. Watch them auto-claim and work in isolated directories.` + +观察重点:两个 worktree 的 `git status` 输出是否显示不同的分支?队友认领带 worktree 的任务后,bash 命令是否在 worktree 目录下执行?`remove_worktree` 对有改动的 worktree 是否拒绝?`.tasks/` 中的任务在绑定后状态是否仍为 `pending`? + +--- + +## 接下来 + +Agent 团队能在隔离的工作空间中自组织了。但 Agent 的能力受限于我们给它写的工具:bash、read、write、task... + +如果用户已经有了自己的工具怎么办?比如一个公司内部的 Jira API、一个自建的部署系统? + +s19 MCP Plugin → 给 Agent 装一个插件系统。外部工具通过标准协议接入,Agent 不需要知道它们是谁写的。 + +
+深入 Claude Code 源码 + +Claude Code 的 worktree 系统有两条路径:**EnterWorktree**(当前会话切入)和 **AgentTool isolation**(子 agent 隔离)。 + +### EnterWorktree:当前会话切换 + +`EnterWorktreeTool.ts:92-97` 创建 worktree 后立即 `process.chdir(worktreePath)`、`setCwd()`、`setOriginalCwd()`、`saveWorktreeState()`。当前会话的工作目录直接切换到 worktree,不是 prompt 提醒,而是进程级目录变更。 + +`ExitWorktreeTool.ts:261-320` 的 keep/remove 都会 `restoreSessionToOriginalCwd()` 恢复原目录。Remove 时检查未提交改动(`ExitWorktreeTool.ts:190-220`),没有 `discard_changes: true` 就拒绝删除。 + +### AgentTool isolation:子 agent 隔离 + +`AgentTool.tsx:590-641` 在 `isolation: "worktree"` 时调用 `createAgentWorktree()` 创建 worktree,用 `cwdOverridePath` 包住子 agent 执行。子 agent 的所有操作自动在 worktree 目录下进行。`AgentTool/prompt.ts:272` 告诉模型:这是临时 worktree,无改动自动清理,有改动返回路径和分支。 + +`worktree.ts:902-951` 的 `createAgentWorktree()` 不修改全局 session cwd,只给子 agent 用。`worktree.ts:961-1020` 的 `removeAgentWorktree()` 从主 repo root 删除。 + +### name 校验 + +`worktree.ts:76-84` 校验 slug:拒绝 `.`/`..`,允许 `[a-zA-Z0-9._-]`。`worktree.ts:48` 定义 `VALID_WORKTREE_SLUG_SEGMENT`。教学版的 `validate_worktree_name` 用同样的规则。 + +### 路径和分支命名 + +真实路径是 `.claude/worktrees/`,分支名 `worktree-{slug}`(`worktree.ts:204-227`,斜杠用 `+` 替代)。教学版用 `.worktrees/` 和 `wt/{name}` 简化。 + +创建时用 `git worktree add -B`(`worktree.ts:326-328`),优先基于 `origin/` 而非当前 HEAD。 + +### 状态管理 + +Claude Code 没有 task-worktree 绑定。Worktree 状态通过 `PersistedWorktreeSession`(`worktree.ts:756-768`)管理,字段包括 `originalCwd`、`worktreePath`、`worktreeName`、`worktreeBranch`、`originalBranch`、`originalHeadCommit`、`sessionId` 等——没有 taskId。`saveWorktreeState()`(`sessionStorage.ts:2883-2920`)以 `type: 'worktree-state'` 写入 session transcript。 + +教学版用 task 的 `worktree` 字段做绑定,是教学简化。Claude Code 把 worktree 和 task 作为两个独立系统,通过 Agent 理解上下文来关联。 + +
+ + diff --git a/s18_worktree_isolation/images/worktree-overview.en.svg b/s18_worktree_isolation/images/worktree-overview.en.svg deleted file mode 100644 index 57c915f7e..000000000 --- a/s18_worktree_isolation/images/worktree-overview.en.svg +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - Worktree Isolation — Git Worktree + Task-Directory Binding + Event Log - - - - s17 Preserved - - s18 New - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH (s17 + s18) - bash · read · write · task(4) · send · inbox - request_shutdown · request_plan · review_plan - ★ create_worktree · remove_worktree · keep_worktree - - - - - - - Worktree Isolation (s18 new: each task gets its own directory + branch) - - - - Main repo (.tasks/ + .worktrees/ + .mailboxes/) - - - - create + bind - - - - create + bind - - - - Alice: .worktrees/auth/ - branch: wt/auth-refactor - Task: Refactor auth module - ✓ Isolated, no impact on Bob or main repo - - - - Bob: .worktrees/ui/ - branch: wt/ui-login - Task: Refactor UI login page - ✓ Isolated, no impact on Alice or main repo - - - - Event log: .worktrees/events.jsonl → create / remove / keep - - - Cleanup: keep (preserve branch for review) / remove (delete + mark done) - - - - - s17: idle_poll + auto_claim + protocols + WORK/IDLE lifecycle - - s18: create_worktree + bind_task + remove/keep + events.jsonl (Lead 14→17) - diff --git a/s18_worktree_isolation/images/worktree-overview.ja.svg b/s18_worktree_isolation/images/worktree-overview.ja.svg deleted file mode 100644 index 2a26071d4..000000000 --- a/s18_worktree_isolation/images/worktree-overview.ja.svg +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - Worktree Isolation — Git Worktree + タスク・ディレクトリ紐付け + イベントログ - - - - s17 保持 - - s18 新規 - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH(s17 + s18) - bash · read · write · task(4) · send · inbox - request_shutdown · request_plan · review_plan - ★ create_worktree · remove_worktree · keep_worktree - - - - - - - Worktree 隔離(s18 新規:各タスクに独立ディレクトリ + 独立ブランチ) - - - - メインリポジトリ(.tasks/ + .worktrees/ + .mailboxes/) - - - - create + bind - - - - create + bind - - - - Alice: .worktrees/auth/ - branch: wt/auth-refactor - Task: 認証モジュールのリファクタリング - ✓ 隔離、Bob とメインリポジトリに影響なし - - - - Bob: .worktrees/ui/ - branch: wt/ui-login - Task: UI ログインページのリファクタリング - ✓ 隔離、Alice とメインリポジトリに影響なし - - - - イベントログ: .worktrees/events.jsonl → create / remove / keep - - - 片付け: keep(ブランチ保持 review)/ remove(削除+完了マーク) - - - - - s17: idle_poll + auto_claim + protocols + WORK/IDLE ライフサイクル - - s18: create_worktree + bind_task + remove/keep + events.jsonl(Lead 14→17) - diff --git a/s18_worktree_isolation/images/worktree-overview.svg b/s18_worktree_isolation/images/worktree-overview.svg index 2b88a75c4..7fb352be1 100644 --- a/s18_worktree_isolation/images/worktree-overview.svg +++ b/s18_worktree_isolation/images/worktree-overview.svg @@ -1,103 +1,107 @@ - + - - - - - + + - - - - - + + - + + - - - Worktree Isolation — Git Worktree + 任务-目录绑定 + 事件日志 - - - - s17 保留 - - s18 新增 - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH (s17 + s18) - bash · read · write · task(4) · send · inbox - request_shutdown · request_plan · review_plan - ★ create_worktree · remove_worktree · keep_worktree - - - - - - - Worktree 隔离(s18 新增:每个任务独立目录 + 独立分支) - - - - 主仓库 (.tasks/ + .worktrees/ + .mailboxes/) - - - - create + bind - - - - create + bind - - - - Alice: .worktrees/auth/ - branch: wt/auth-refactor - Task: 重构认证模块 - ✓ 隔离,不影响 Bob 和主仓库 - - - - Bob: .worktrees/ui/ - branch: wt/ui-login - Task: 重构 UI 登录页 - ✓ 隔离,不影响 Alice 和主仓库 - - - - 事件日志: .worktrees/events.jsonl → create / remove / keep - - - 收尾: keep (保留分支 review) / remove (删除+标记完成) - - - - - s17: idle_poll + auto_claim + protocols + WORK/IDLE lifecycle - - s18: create_worktree + bind_task + remove/keep + events.jsonl (Lead 14→17) - + Worktree Isolation + Git Worktree + Task-Directory Binding + Event Log + + + + + turn + + + + + + + messages + + + + + + + prompt + + + + + + + LLM + + + + + + + TOOL DISPATCH + bash read write task(4) send inbox + request_shutdown request_plan review_plan + create_worktree remove_worktree keep_worktree + + + + loop back + + + + Worktree Isolation: Each Task Gets Its Own Directory + Branch + + + + Main Repo (.tasks/ .worktrees/ .mailboxes/) + shared state, task metadata, message bus + + + + + + + + create + bind + + + + create + bind + + + + Alice: .worktrees/auth/ + branch: wt/auth-refactor + Task: Refactor auth module + Isolated from Bob and main repo + + + + Bob: .worktrees/ui/ + branch: wt/ui-login + Task: Refactor UI login page + Isolated from Alice and main repo + + + + Event Log: .worktrees/events.jsonl (create / remove / keep) + + + + Cleanup: keep (preserve for review) / remove (delete + done) + + + + $ create_worktree("auth", task_id="t1") # Creates .worktrees/auth/ + branch wt/auth + $ keep_worktree("auth") # Preserve branch for review + + + \ No newline at end of file diff --git a/s19_mcp_plugin/README.en.md b/s19_mcp_plugin/README.en.md deleted file mode 100644 index 92e0a315f..000000000 --- a/s19_mcp_plugin/README.en.md +++ /dev/null @@ -1,282 +0,0 @@ -# s19: MCP Tools — External Tools, Standard Protocol - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → ... → s17 → s18 → `s19` → [s20](../s20_comprehensive/) - -> *"External tools, standard protocol"* — Discover, assemble, invoke. Agent doesn't need to know who wrote them. -> -> **Harness layer**: Plugins — External capabilities via a standard protocol. - ---- - -## The Problem - -From s01 through s18, every tool the agent uses was hand-written — bash, read, write, task, worktree. Input validation, execution logic, error handling — all written line by line. - -Now you have 3 external services to integrate: the company's Jira API (query issues, create tickets), an in-house deployment system (trigger deploys, view logs), and the team's Notion knowledge base (search docs, create pages). You don't want to rewrite tool code for every service. - -You need a standard protocol — as long as an external service implements it, the agent can call its tools directly, regardless of what language the service is written in. - ---- - -## The Solution - -![MCP Architecture](images/mcp-architecture.en.svg) - -MCP (Model Context Protocol) defines how agents discover and invoke external tools. Core concepts: - -| Concept | Purpose | -|------|------| -| MCPClient | The agent-side client — connects to servers, discovers tools, invokes tools | -| MCP Server | The external service — implements `tools/list` + `tools/call` | -| assemble_tool_pool | Assembles built-in tools and MCP tools into one tool pool | -| mcp\_\_server\_\_tool naming | Prevents tool name collisions across different servers | - -Carries forward s18's teaching-version worktree isolation, autonomous claiming, idle polling, and protocol system. This chapter adds: the `connect_mcp` tool — connect to external services, discover tools, add them to the tool pool. - -The tutorial uses mock handlers to simulate external servers. The real version would spawn subprocesses and communicate via stdin/stdout JSON-RPC. Mocks let you run the full flow without external dependencies; the tradeoff is you don't see real network communication or process management. - ---- - -## How It Works - -### MCPClient: Discovery + Invocation - -```python -class MCPClient: - def __init__(self, name: str): - self.name = name - self.tools: list[dict] = [] - self._handlers: dict[str, callable] = {} - - def register(self, tool_defs, handlers): - """Simulates tools/list discovery.""" - self.tools = tool_defs - self._handlers = handlers - - def call_tool(self, tool_name: str, args: dict) -> str: - """Simulates tools/call.""" - handler = self._handlers.get(tool_name) - if not handler: - return f"MCP error: unknown tool '{tool_name}'" - return handler(**args) -``` - -The tutorial uses Python functions to simulate server tool implementations. The real version communicates with subprocesses via stdio JSON-RPC. - -### connect_mcp: Connect + Discover - -```python -def connect_mcp(name: str) -> str: - if name in mcp_clients: - return f"MCP server '{name}' already connected" - factory = MOCK_SERVERS.get(name) - if not factory: - return f"Unknown server '{name}'. Available: ..." - mcp_client = factory() - mcp_clients[name] = mcp_client - return f"Connected to '{name}'. Discovered: ..." -``` - -After connecting, the server's tools are immediately available. - -### normalize_mcp_name: Name Normalization - -```python -_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]') - -def normalize_mcp_name(name: str) -> str: - return _DISALLOWED_CHARS.sub('_', name) -``` - -All non-`[a-zA-Z0-9_-]` characters are replaced with `_`. Prevents special characters in server or tool names from causing naming conflicts or injection issues. - -### assemble_tool_pool: Assemble Tool Pool - -```python -def assemble_tool_pool() -> tuple[list[dict], dict]: - tools = list(BUILTIN_TOOLS) - handlers = dict(BUILTIN_HANDLERS) - for server_name, mcp_client in mcp_clients.items(): - safe_server = normalize_mcp_name(server_name) - for tool_def in mcp_client.tools: - safe_tool = normalize_mcp_name(tool_def["name"]) - prefixed = f"mcp__{safe_server}__{safe_tool}" - tools.append(...) - handlers[prefixed] = ( - lambda *, c=mcp_client, t=tool_def["name"], **kw: - c.call_tool(t, kw)) - return tools, handlers -``` - -The prefix `mcp__{server}__{tool}` prevents tool name collisions across different servers. Names are normalized through `normalize_mcp_name`. - -MCP tool descriptions include `(readOnly)` or `(destructive)` annotations — the tutorial uses text annotations, while real CC uses structured tool annotations for the permission system. - -### No Cache: Tool Pool Changes, Prompt Changes Too - -s10-s18's agent_loop used prompt caching to avoid re-serialization. s19 removes the cache: - -```python -def agent_loop(messages, context): - tools, handlers = assemble_tool_pool() # Rebuild every time - system = assemble_system_prompt(context) # Regenerate every time - ... - if any(b.name == "connect_mcp" ...): - tools, handlers = assemble_tool_pool() # Rebuild after connection - system = assemble_system_prompt(context) -``` - -Reason: after `connect_mcp`, the tool pool changes — new tools like `mcp__docs__search` are added. The cached tool list is stale; continuing to use it means the model can't call the new tools. The tutorial simply removes caching, at the cost of slightly more serialization time. - -### MCP Tools: Lead Only - -In the tutorial, `connect_mcp` is a Lead tool, and `assemble_tool_pool` only serves the Lead's agent_loop. Teammates still use a fixed 8-tool subset (bash, read_file, write_file, send_message, submit_plan, list_tasks, claim_task, complete_task). - -This is a teaching simplification. In real CC, MCP tools are available to both the main agent and sub-agents — sub-agents inherit the parent's MCP configuration. - ---- - -## Changes from s18 - -| Component | Before (s18) | After (s19) | -|------|-----------|-----------| -| Tool source | All hand-written built-in | Hand-written + MCP external tools with dynamic discovery | -| Tool pool | Fixed BUILTIN_TOOLS | assemble_tool_pool dynamically assembles mcp\_\_ prefixed tools | -| Name safety | None | normalize_mcp_name normalization | -| New type | — | MCPClient class (simulates tools/list + tools/call) | -| Namespace | — | mcp\_\_server\_\_tool prevents collisions | -| Tool descriptions | No annotations | (readOnly)/(destructive) annotations | -| Prompt cache | Yes (since s10) | Removed — tool pool is dynamic, cache goes stale | -| Lead tools | 17 (s18) | 18 (+connect_mcp) | -| Teammate tools | 8 (s18) | 8 (unchanged, MCP tools are Lead-only) | -| Extension method | Write code to add tools | Standard protocol, implement servers in any language | - ---- - -## Try It Out - -```sh -cd learn-claude-code -python s19_mcp_plugin/code.py -``` - -Try these prompts: - -1. `Connect to the docs MCP server and search for something` -2. `Connect to the deploy server and trigger a deployment` -3. `Connect both servers — what tools are now available?` - -What to observe: After connecting to an MCP server, do tool names have `mcp__docs__` or `mcp__deploy__` prefixes? Are both servers' tools available simultaneously? Do MCP tool descriptions include (readOnly)/(destructive) annotations? - ---- - -## What's Next - -The Agent can now connect external tools through a standard protocol. But the first 19 chapters each add one mechanism in isolation; a real Agent does not run as 19 separate demos. - -Tools, permissions, hooks, todo, task graph, memory, compact, background work, cron, teams, worktrees, and MCP should all attach to the same loop, not live in separate examples. - -s20 Comprehensive Agent → Combine the first 19 chapters into one complete harness. Many mechanisms, one loop. - -
-Deep Dive into CC Source - -> The following is based on analysis of CC source: `services/mcp/client.ts`, `auth.ts`, `config.ts`, `channelNotification.ts`. - -### 1. Six Transport Types - -The tutorial only shows a stdio mock. CC supports 6 transport types (`types.ts:23-25`): - -| Transport | Communication method | -|-----------|---------| -| `stdio` | Subprocess stdin/stdout (cross-platform default) | -| `sse` | HTTP Server-Sent Events | -| `http` | Streamable HTTP (POST/SSE bidirectional) | -| `ws` | WebSocket | -| `sse-ide` | IDE-embedded SSE transport | -| `sdk` | In-process SDK transport | - -On connection, local (stdio) and remote (http/sse/ws) servers are batched concurrently: local batch of 3, remote batch of 20. - -### 2. Tool Pool Merging Algorithm - -`assembleToolPool()` (`tools.ts:345-364`): - -```typescript -// Dedup with priority: built-in tools win on name collision (sorted first) -return uniqBy( - [...builtInTools.sort(byName), ...filteredMcpTools.sort(byName)], - 'name', -) -``` - -Built-in and MCP tools are sorted separately, not together. The reason is CC's `claude_code_system_cache_policy` places a global cache breakpoint after the last built-in tool at a specific position — mixing the sort would break this design. - -### 3. Naming Convention: `mcp__server__tool` - -`buildMcpToolName()` (`mcpStringUtils.ts:50-52`): - -``` -mcp____ -``` - -All non-`[a-zA-Z0-9_-]` characters are replaced with `_` (`normalization.ts:17-23`). The tutorial's `normalize_mcp_name` uses the same rule. - -### 4. Permission Checks - -CC has a separate permission system for MCP tools. `checkPermissions()` applies different logic for MCP tools than for built-in tools — MCP tools can declare their own permission requirements (readOnly, destructive, etc.), and CC decides whether user confirmation is needed based on the declaration. The tutorial only uses text annotations `(readOnly)` / `(destructive)` in descriptions, without permission enforcement. - -### 5. Configuration Sources and Priority - -MCP server configuration comes from multiple sources. CC's priority from lowest to highest: - -``` -claude.ai connectors < plugin < user settings.json < approved project .mcp.json < local settings.local.json -``` - -`claude.ai` connectors are fetched separately, deduplicated by content signature, and merged at the lowest precedence (`config.ts:1267-1289`). When enterprise `managed-mcp.json` exists, all other configurations are excluded. - -The tutorial passes server names directly to the `MOCK_SERVERS` dict, without config merging. - -### 6. Channel Notifications: Servers Push Messages Back - -The tutorial only covers agent → MCP Server unidirectional calls. CC also supports reverse notifications (`channelNotification.ts`): - -1. Server declares `capabilities.experimental['claude/channel']` -2. Server sends messages to agent via MCP notification `notifications/claude/channel` -3. Messages are wrapped in `...` XML tags -4. Agent is woken up by SleepTool (within 1 second) - -Servers can also request permissions: `notifications/claude/channel/permission_request` → Agent replies `notifications/claude/channel/permission`. Users confirm/deny via a 5-letter short ID. - -### 7. OAuth Authentication Flow - -CC's MCP authentication (`auth.ts`) supports a full OAuth 2.0 + PKCE flow: -- OAuth metadata discovery via public client + PKCE (RFC 8414 / RFC 9728) -- Local callback server receives authorization code -- Tokens persisted via `getSecureStorage()` (macOS Keychain / Linux encrypted file / Windows Credential Manager) -- Auto-refresh 5 minutes before expiry -- Cross-application access (XAA): browser gets id_token → RFC 8693 + RFC 7523 exchange → no repeated browser popups - -### 8. Connection Lifecycle Error Handling - -CC has fine-grained error classification and retry for MCP connections (`client.ts:1266-1402`): -- Terminal errors (ECONNRESET, ETIMEDOUT, EPIPE, etc.): 3 consecutive failures → close + reconnect -- Tool call 401: Token expired → throw `McpAuthError` → trigger re-authentication -- Tool call timeout: `Promise.race` timeout (configurable, default ~28 hours) -- Stdio disconnect: Kill process in SIGINT → SIGTERM → SIGKILL order - -### The Tutorial's Simplifications - -- 6 transport types → 1 (mock stdio): Manageable concept count -- Channel reverse notifications → omitted: Tutorial agent is always the initiator -- OAuth flow → omitted: Tutorial assumes servers need no auth -- Multi-layer config priority → omitted: Tutorial passes server name directly -- Complex error classification → omitted: Tutorial uses try/except as fallback -- MCP tools Lead-only → omitted sub-agent inheritance: Simplifies code structure - -
- - diff --git a/s19_mcp_plugin/README.ja.md b/s19_mcp_plugin/README.ja.md index 13efac214..b43871061 100644 --- a/s19_mcp_plugin/README.ja.md +++ b/s19_mcp_plugin/README.ja.md @@ -1,6 +1,6 @@ # s19: MCP Tools — 外部ツール、標準プロトコル -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s17 → s18 → `s19` → [s20](../s20_comprehensive/) @@ -12,28 +12,28 @@ s01 → ... → s17 → s18 → `s19` → [s20](../s20_comprehensive/) ## 課題 -s01 から s18 まで、Agent の全ツールは手書き — bash、read、write、task、worktree。入力検証、実行ロジック、エラーハンドリング、全て一行ずつ書いた。 +s01 から s18 まで、Agent の全ツールは手書きだった:bash、read、write、task、worktree。入力検証、実行ロジック、エラーハンドリング、全て一行ずつ書いた。 今、統合したい外部サービスが 3 つある:社内の Jira API(issue 検索、ticket 作成)、独自のデプロイシステム(deploy トリガー、ログ閲覧)、チームの Notion ナレッジベース(ドキュメント検索、ページ作成)。各サービスのためにツールコードを書き直したくない。 -標準プロトコルが必要 — 外部サービスがこのプロトコルを実装していれば、サービスが何の言語で書かれていても、Agent は直接そのツールを呼び出せる。 +標準プロトコルが必要だ。外部サービスがこのプロトコルを実装していれば、サービスが何の言語で書かれていても、Agent は直接そのツールを呼び出せる。 --- ## ソリューション -![MCP Architecture](images/mcp-architecture.ja.svg) +![MCP Architecture](images/mcp-architecture.svg) MCP(Model Context Protocol)は、Agent が外部ツールを発見・呼び出しする方法を定義。核心概念: | 概念 | 目的 | |------|------| -| MCPClient | Agent 側のクライアント — server に接続、ツールを発見、ツールを呼び出し | -| MCP Server | 外部サービス側 — `tools/list` + `tools/call` を実装 | +| MCPClient | Agent 側のクライアント。server に接続、ツールを発見、ツールを呼び出し | +| MCP Server | 外部サービス。`tools/list` + `tools/call` を実装 | | assemble_tool_pool | 組み込みツールと MCP ツールを一つのツールプールに組み立てる | | mcp\_\_server\_\_tool 命名 | 異なる server 間のツール名衝突を防止 | -s18 の教学版 worktree 隔離、自動認領、空き時ポーリング、プロトコルシステムを踏襲。本章の追加:`connect_mcp` ツール — 外部サービスに接続、ツールを発見、ツールプールに追加。 +s18 の教学版 worktree 隔離、自動認領、空き時ポーリング、プロトコルシステムを踏襲。本章の追加:`connect_mcp` ツール。外部サービスに接続し、ツールを発見してツールプールに追加する。 教学版は mock handler で外部 server をシミュレート。実際の版はサブプロセスを起動し、stdin/stdout で JSON-RPC リクエストを送信。mock の利点は外部サービスなしで完全なフローを実行できること;代償は実際のネットワーク通信やプロセス管理が見えないこと。 @@ -110,9 +110,9 @@ def assemble_tool_pool() -> tuple[list[dict], dict]: return tools, handlers ``` -プレフィックス `mcp__{server}__{tool}` で異なる server 間のツール名衝突を防止。名前は `normalize_mcp_name` で正規化。 +プレフィックス `mcp__{server}__{tool}` で各 server のツールを名前空間で隔離。名前は `normalize_mcp_name` で正規化。 -MCP ツールの description に `(readOnly)` または `(destructive)` アノテーションを付与 — 教学版はテキストアノテーション、実際の CC は tool annotations 構造体で権限システムが判断。 +MCP ツールの description に `(readOnly)` または `(destructive)` アノテーションを付与する。教学版はテキストアノテーション、実際の Claude Code は tool annotations 構造体で権限システムが判断。 ### キャッシュなし:ツールプールが変われば、プロンプトも変わる @@ -128,13 +128,13 @@ def agent_loop(messages, context): system = assemble_system_prompt(context) ``` -理由:`connect_mcp` 後にツールプールが変化 — `mcp__docs__search` などの新ツールが追加される。キャッシュ内のツールリストは古く、使い続けるとモデルが新ツールを呼び出せない。教学版はキャッシュを単に削除、代償はシリアライズ時間の若干の増加。 +理由:`connect_mcp` 後にツールプールが変化し、`mcp__docs__search` などの新ツールが追加される。キャッシュ内のツールリストは古く、使い続けるとモデルが新ツールを呼び出せない。教学版はキャッシュを単に削除、代償はシリアライズ時間の若干の増加。 ### MCP ツールは Lead のみ利用可能 教学版では、`connect_mcp` は Lead ツール、`assemble_tool_pool` も Lead の agent_loop のみにサービスを提供。チームメイトは引き続き固定の 8 ツールサブセット(bash、read_file、write_file、send_message、submit_plan、list_tasks、claim_task、complete_task)を使用。 -これは教学簡略化。実際の CC では、MCP ツールはメイン agent とサブ agent の両方で利用可能 — サブ agent は親の MCP 設定を継承。 +これは教学簡略化。実際の Claude Code では、MCP ツールはメイン agent とサブ agent の両方で利用可能で、サブ agent は親の MCP 設定を継承する。 --- @@ -148,10 +148,10 @@ def agent_loop(messages, context): | 新規タイプ | — | MCPClient クラス(tools/list + tools/call をシミュレート) | | 名前空間 | — | mcp\_\_server\_\_tool 衝突防止 | | ツール説明 | アノテーションなし | (readOnly)/(destructive) アノテーション | -| プロンプトキャッシュ | あり(s10 から) | 削除 — ツールプールが動的、キャッシュが陳腐化 | +| プロンプトキャッシュ | あり(s10 から) | 削除。ツールプールが動的でキャッシュが陳腐化 | | Lead ツール | 17 (s18) | 18 (+connect_mcp) | | チームメイトツール | 8 (s18) | 8(変更なし、MCP ツールは Lead のみ) | -| 拡張方法 | ツール追加のコードを書く | 標準プロトコル、任意言語で server を実装 | +| 拡張方法 | ツール追加のコードを書く | MCP プロトコルで任意言語の server を接続 | --- @@ -174,20 +174,20 @@ python s19_mcp_plugin/code.py ## 次の章 -Agent は標準プロトコルで外部ツールに接続できるようになりました。しかし前 19 章は各章で 1 つの仕組みだけを追加しています。実際の Agent は 19 個の demo に分かれて動くわけではありません。 +Agent は MCP で外部ツールに接続できるようになった。しかし前 19 章は各章で 1 つの仕組みだけを追加しています。実際の Agent は 19 個の demo に分かれて動くわけではありません。 tools、permissions、hooks、todo、task graph、memory、compact、background work、cron、teams、worktree、MCP は、別々の例ではなく同じ loop に接続されるべきです。 s20 Comprehensive Agent → 前 19 章の仕組みを 1 つの完全な harness に統合。仕組みは多く、loop は 1 つ。
-CC ソースコード深掘り +Claude Code ソースコード深掘り -> 以下は CC ソースコード `services/mcp/client.ts`、`auth.ts`、`config.ts`、`channelNotification.ts` の分析に基づく。 +> 以下は Claude Code ソースコード `services/mcp/client.ts`、`auth.ts`、`config.ts`、`channelNotification.ts` の分析に基づく。 ### 一、6 種の Transport タイプ -教学版は stdio mock のみ。CC は 6 種のトランスポートをサポート(`types.ts:23-25`): +教学版は stdio mock のみ。Claude Code は 6 種のトランスポートをサポート(`types.ts:23-25`): | Transport | 通信方式 | |-----------|---------| @@ -212,7 +212,7 @@ return uniqBy( ) ``` -組み込みツールと MCP ツールは別々にソート、混ぜてソートしない。理由は CC の `claude_code_system_cache_policy` が最後の組み込みツールの後の特定位置にグローバルキャッシュブレークポイントを置く設計のため — ソートを混ぜるとこの設計が壊れる。 +組み込みツールと MCP ツールは別々にソート、混ぜてソートしない。理由は Claude Code の `claude_code_system_cache_policy` が最後の組み込みツールの後の特定位置にグローバルキャッシュブレークポイントを置く設計のためで、ソートを混ぜるとこの設計が壊れる。 ### 三、命名規則:`mcp__server__tool` @@ -226,11 +226,11 @@ mcp____ ### 四、権限チェック -CC は MCP ツールに対して独立した権限システムを持つ。`checkPermissions()` は MCP ツールに対して組み込みツールとは異なるロジックを適用 — MCP ツールは独自の権限要件(readOnly、destructive 等)を宣言でき、CC は宣言に基づいてユーザー確認が必要かを判断。教学版は description 内のテキストアノテーション `(readOnly)` / `(destructive)` のみで、権限インターセプトは行わない。 +Claude Code は MCP ツールに対して独立した権限システムを持つ。`checkPermissions()` は MCP ツールに対して組み込みツールとは異なるロジックを適用する:MCP ツールは独自の権限要件(readOnly、destructive 等)を宣言でき、Claude Code は宣言に基づいてユーザー確認が必要かを判断。教学版は description 内のテキストアノテーション `(readOnly)` / `(destructive)` のみで、権限インターセプトは行わない。 ### 五、設定ソースと優先度 -MCP サーバー設定は複数のソースから。CC の優先度は低い順に: +MCP サーバー設定は複数のソースから。Claude Code の優先度は低い順に: ``` claude.ai コネクタ < プラグイン < ユーザー settings.json < 承認済みプロジェクト .mcp.json < ローカル settings.local.json @@ -242,7 +242,7 @@ claude.ai コネクタ < プラグイン < ユーザー settings.json < 承認 ### 六、Channel 通知:サーバーからの逆方向メッセージ -教学版は Agent → MCP Server の一方向呼び出しのみ。CC は逆方向通知もサポート(`channelNotification.ts`): +教学版は Agent → MCP Server の一方向呼び出しのみ。Claude Code は逆方向通知もサポート(`channelNotification.ts`): 1. Server が `capabilities.experimental['claude/channel']` を宣言 2. Server が MCP 通知 `notifications/claude/channel` で Agent にメッセージを送信 @@ -253,7 +253,7 @@ Server は権限リクエストも可能:`notifications/claude/channel/permiss ### 七、OAuth 認証フロー -CC の MCP 認証(`auth.ts`)は完全な OAuth 2.0 + PKCE フローをサポート: +Claude Code の MCP 認証(`auth.ts`)は完全な OAuth 2.0 + PKCE フローをサポート: - 公開クライアント + PKCE で OAuth メタデータを発見(RFC 8414 / RFC 9728) - ローカルコールバックサーバーが認可コードを受信 - トークンは `getSecureStorage()` で永続化(macOS Keychain / Linux 暗号化ファイル / Windows 資格情報マネージャー) @@ -262,7 +262,7 @@ CC の MCP 認証(`auth.ts`)は完全な OAuth 2.0 + PKCE フローをサポ ### 八、接続ライフサイクルのエラーハンドリング -CC は MCP 接続にきめ細かいエラー分類とリトライを行う(`client.ts:1266-1402`): +Claude Code は MCP 接続にきめ細かいエラー分類とリトライを行う(`client.ts:1266-1402`): - 終局エラー(ECONNRESET、ETIMEDOUT、EPIPE 等):連続 3 回 → クローズ + 再接続 - ツール呼び出し 401:トークン期限切れ → `McpAuthError` スロー → 再認証トリガー - ツール呼び出しタイムアウト:`Promise.race` タイムアウト(設定可能、デフォルト約 28 時間) diff --git a/s19_mcp_plugin/README.md b/s19_mcp_plugin/README.md index ade6f5872..a57f449fc 100644 --- a/s19_mcp_plugin/README.md +++ b/s19_mcp_plugin/README.md @@ -1,47 +1,47 @@ -# s19: MCP Tools — 外接工具,标准协议 +# s19: MCP Tools — External Tools, Standard Protocol -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s17 → s18 → `s19` → [s20](../s20_comprehensive/) -> *"外接工具, 标准协议"* — 发现、组装、调用,Agent 不需要知道工具是谁写的。 +> *"External tools, standard protocol"* — Discover, assemble, invoke. Agent doesn't need to know who wrote them. > -> **Harness 层**: 插件 — 外部能力通过标准协议接入。 +> **Harness layer**: Plugins — External capabilities via a standard protocol. --- -## 问题 +## The Problem -s01 到 s18,Agent 的所有工具都是手写的——bash、read、write、task、worktree。每个工具的输入验证、执行逻辑、错误处理,都是你一行行写的。 +From s01 through s18, every tool the agent uses was hand-written: bash, read, write, task, worktree. Input validation, execution logic, error handling, all written line by line. -现在你有 3 个外部服务想接入:公司的 Jira API(查 issue、建 ticket)、自建的部署系统(触发 deploy、看日志)、团队的 Notion 知识库(搜文档、建页面)。你不想为每个服务重写一套工具代码。 +Now you have 3 external services to integrate: the company's Jira API (query issues, create tickets), an in-house deployment system (trigger deploys, view logs), and the team's Notion knowledge base (search docs, create pages). You don't want to rewrite tool code for every service. -你需要一个标准协议——外部服务只要实现它,Agent 就能直接调用,不管服务用什么语言写的。 +You need a standard protocol. As long as an external service implements it, the agent can call its tools directly, regardless of what language the service is written in. --- -## 解决方案 +## The Solution ![MCP Architecture](images/mcp-architecture.svg) -MCP(Model Context Protocol)定义了 Agent 如何发现和调用外部工具。核心概念: +MCP (Model Context Protocol) defines how agents discover and invoke external tools. Core concepts: -| 概念 | 作用 | +| Concept | Purpose | |------|------| -| MCPClient | Agent 端的客户端,连接 server、发现工具、调用工具 | -| MCP Server | 外部服务,实现 `tools/list` + `tools/call` | -| assemble_tool_pool | 把内置工具和 MCP 工具组装成一个工具池 | -| mcp\_\_server\_\_tool 命名 | 避免不同 server 的工具名冲突 | +| MCPClient | Agent-side client that connects to servers, discovers tools, invokes tools | +| MCP Server | External service implementing `tools/list` + `tools/call` | +| assemble_tool_pool | Assembles built-in tools and MCP tools into one tool pool | +| mcp\_\_server\_\_tool naming | Prevents tool name collisions across different servers | -沿用 s18 的教学版 worktree 隔离、自主认领、空闲轮询、协议系统。本章新增:`connect_mcp` 工具——连接外部服务,发现工具,加入工具池。 +Carries forward s18's teaching-version worktree isolation, autonomous claiming, idle polling, and protocol system. This chapter adds the `connect_mcp` tool, which connects to external services, discovers tools, and adds them to the tool pool. -教学版用 mock handler 模拟外部 server。真实版会启动子进程,通过 stdin/stdout 发送 JSON-RPC 请求。mock 的好处是不依赖外部服务就能跑完整流程;代价是你看不到真正的网络通信和进程管理。 +The tutorial uses mock handlers to simulate external servers. The real version would spawn subprocesses and communicate via stdin/stdout JSON-RPC. Mocks let you run the full flow without external dependencies; the tradeoff is you don't see real network communication or process management. --- -## 工作原理 +## How It Works -### MCPClient:发现 + 调用 +### MCPClient: Discovery + Invocation ```python class MCPClient: @@ -63,9 +63,9 @@ class MCPClient: return handler(**args) ``` -教学版用 Python 函数模拟 server 的工具实现。真实版通过 stdio JSON-RPC 与子进程通信。 +The tutorial uses Python functions to simulate server tool implementations. The real version communicates with subprocesses via stdio JSON-RPC. -### connect_mcp:连接 + 发现 +### connect_mcp: Connect + Discover ```python def connect_mcp(name: str) -> str: @@ -79,9 +79,9 @@ def connect_mcp(name: str) -> str: return f"Connected to '{name}'. Discovered: ..." ``` -连接后,server 提供的工具立即可用。 +After connecting, the server's tools are immediately available. -### normalize_mcp_name:名称规范化 +### normalize_mcp_name: Name Normalization ```python _DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]') @@ -90,9 +90,9 @@ def normalize_mcp_name(name: str) -> str: return _DISALLOWED_CHARS.sub('_', name) ``` -所有非 `[a-zA-Z0-9_-]` 的字符替换为 `_`。防止 server 名或工具名中包含特殊字符导致命名冲突或注入问题。 +All non-`[a-zA-Z0-9_-]` characters are replaced with `_`. Prevents special characters in server or tool names from causing naming conflicts or injection issues. -### assemble_tool_pool:组装工具池 +### assemble_tool_pool: Assemble Tool Pool ```python def assemble_tool_pool() -> tuple[list[dict], dict]: @@ -110,173 +110,173 @@ def assemble_tool_pool() -> tuple[list[dict], dict]: return tools, handlers ``` -前缀 `mcp__{server}__{tool}` 避免不同 server 的工具名冲突。名称经过 `normalize_mcp_name` 规范化。 +The prefix `mcp__{server}__{tool}` namespaces each server's tools. Names are normalized through `normalize_mcp_name`. -MCP 工具的 description 带 `(readOnly)` 或 `(destructive)` 标注——教学版用文本标注,真实 CC 用 tool annotations 结构体让权限系统判断。 +MCP tool descriptions include `(readOnly)` or `(destructive)` annotations. The tutorial uses text annotations; real Claude Code uses structured tool annotations for the permission system. -### 无缓存:工具池变了,prompt 也变 +### No Cache: Tool Pool Changes, Prompt Changes Too -s10-s18 的 agent_loop 用 prompt cache 避免重复序列化。s19 去掉了缓存: +s10-s18's agent_loop used prompt caching to avoid re-serialization. s19 removes the cache: ```python def agent_loop(messages, context): - tools, handlers = assemble_tool_pool() # 每次重新构建 - system = assemble_system_prompt(context) # 每次重新生成 + tools, handlers = assemble_tool_pool() # Rebuild every time + system = assemble_system_prompt(context) # Regenerate every time ... if any(b.name == "connect_mcp" ...): - tools, handlers = assemble_tool_pool() # 连接后重建 + tools, handlers = assemble_tool_pool() # Rebuild after connection system = assemble_system_prompt(context) ``` -原因:`connect_mcp` 之后工具池变化了——新增了 `mcp__docs__search` 等工具。缓存中的工具列表是旧的,继续用会导致模型调用不到新工具。教学版直接去掉缓存,代价是多花一点序列化时间。 +Reason: after `connect_mcp`, the tool pool changes. New tools like `mcp__docs__search` are added. The cached tool list is stale; continuing to use it means the model can't call the new tools. The tutorial simply removes caching, at the cost of slightly more serialization time. -### MCP 工具只有 Lead 可用 +### MCP Tools: Lead Only -教学版中,`connect_mcp` 是 Lead 工具,`assemble_tool_pool` 也只服务于 Lead 的 agent_loop。Teammate 仍使用固定的 8 个子集工具(bash、read_file、write_file、send_message、submit_plan、list_tasks、claim_task、complete_task)。 +In the tutorial, `connect_mcp` is a Lead tool, and `assemble_tool_pool` only serves the Lead's agent_loop. Teammates still use a fixed 8-tool subset (bash, read_file, write_file, send_message, submit_plan, list_tasks, claim_task, complete_task). -这是教学简化。真实 CC 中,MCP 工具对主 agent 和子 agent 都可用——子 agent 继承父级的 MCP 配置。 +This is a teaching simplification. In real Claude Code, MCP tools are available to both the main agent and sub-agents. Sub-agents inherit the parent's MCP configuration. --- -## 相对 s18 的变更 +## Changes from s18 -| 组件 | 之前 (s18) | 之后 (s19) | +| Component | Before (s18) | After (s19) | |------|-----------|-----------| -| 工具来源 | 全部手写 builtin | 手写 + MCP 外部工具动态发现 | -| 工具池 | 固定 BUILTIN_TOOLS | assemble_tool_pool 动态组装 mcp\_\_ 前缀工具 | -| 名称安全 | 无 | normalize_mcp_name 规范化 | -| 新类型 | — | MCPClient 类(模拟 tools/list + tools/call) | -| 命名空间 | — | mcp\_\_server\_\_tool 避免冲突 | -| 工具描述 | 无标注 | (readOnly)/(destructive) 标注 | -| prompt 缓存 | 有(s10 起) | 去掉——工具池动态变化后缓存失效 | -| Lead 工具 | 17 (s18) | 18 (+connect_mcp) | -| Teammate 工具 | 8 (s18) | 8(不变,MCP 工具仅 Lead 可用) | -| 扩展方式 | 写代码加工具 | 标准协议,任意语言实现 server | +| Tool source | All hand-written built-in | Hand-written + MCP external tools with dynamic discovery | +| Tool pool | Fixed BUILTIN_TOOLS | assemble_tool_pool dynamically assembles mcp\_\_ prefixed tools | +| Name safety | None | normalize_mcp_name normalization | +| New type | — | MCPClient class (simulates tools/list + tools/call) | +| Namespace | — | mcp\_\_server\_\_tool prevents collisions | +| Tool descriptions | No annotations | (readOnly)/(destructive) annotations | +| Prompt cache | Yes (since s10) | Removed because tool pool is dynamic; cache goes stale | +| Lead tools | 17 (s18) | 18 (+connect_mcp) | +| Teammate tools | 8 (s18) | 8 (unchanged, MCP tools are Lead-only) | +| Extension method | Write code to add tools | MCP protocol; implement servers in any language | --- -## 试一下 +## Try It Out ```sh cd learn-claude-code python s19_mcp_plugin/code.py ``` -试试这些 prompt: +Try these prompts: 1. `Connect to the docs MCP server and search for something` 2. `Connect to the deploy server and trigger a deployment` 3. `Connect both servers — what tools are now available?` -观察重点:连接 MCP server 后,工具名是否带 `mcp__docs__` 或 `mcp__deploy__` 前缀?两个 server 的工具是否同时可用?MCP 工具的 description 是否带 (readOnly)/(destructive) 标注? +What to observe: After connecting to an MCP server, do tool names have `mcp__docs__` or `mcp__deploy__` prefixes? Are both servers' tools available simultaneously? Do MCP tool descriptions include (readOnly)/(destructive) annotations? --- -## 接下来 +## What's Next -现在 Agent 可以通过标准协议接入外部工具了。但前面 19 章每章都只加一个机制,真实 Agent 不会这样拆开运行。 +The Agent can now connect external tools through MCP. But the first 19 chapters each add one mechanism in isolation; a real Agent does not run as 19 separate demos. -工具、权限、hooks、todo、任务图、记忆、压缩、后台、cron、团队、worktree、MCP 这些机制应该挂在同一个循环上,而不是散在 19 个 demo 里。 +Tools, permissions, hooks, todo, task graph, memory, compact, background work, cron, teams, worktrees, and MCP should all attach to the same loop, not live in separate examples. -s20 Comprehensive Agent → 把前 19 章的机制合回一个完整 harness。机制很多,循环一个。 +s20 Comprehensive Agent → Combine the first 19 chapters into one complete harness. Many mechanisms, one loop.
-深入 CC 源码 +Deep Dive into Claude Code Source -> 以下基于 CC 源码 `services/mcp/client.ts`、`auth.ts`、`config.ts`、`channelNotification.ts` 的分析。 +> The following is based on analysis of Claude Code source: `services/mcp/client.ts`, `auth.ts`, `config.ts`, `channelNotification.ts`. -### 一、6 种 Transport 类型 +### 1. Six Transport Types -教学版只展示了 stdio mock。CC 支持 6 种传输(`types.ts:23-25`): +The tutorial only shows a stdio mock. Claude Code supports 6 transport types (`types.ts:23-25`): -| Transport | 通信方式 | +| Transport | Communication method | |-----------|---------| -| `stdio` | 子进程 stdin/stdout(跨平台默认) | +| `stdio` | Subprocess stdin/stdout (cross-platform default) | | `sse` | HTTP Server-Sent Events | -| `http` | Streamable HTTP(POST/SSE 双向) | +| `http` | Streamable HTTP (POST/SSE bidirectional) | | `ws` | WebSocket | -| `sse-ide` | IDE 内嵌 SSE 传输 | -| `sdk` | 进程内 SDK 传输 | +| `sse-ide` | IDE-embedded SSE transport | +| `sdk` | In-process SDK transport | -连接时本地(stdio)和远程(http/sse/ws)服务器分批并发:本地批量 3 个,远程批量 20 个。 +On connection, local (stdio) and remote (http/sse/ws) servers are batched concurrently: local batch of 3, remote batch of 20. -### 二、工具池组装算法 +### 2. Tool Pool Merging Algorithm -`assembleToolPool()`(`tools.ts:345-364`): +`assembleToolPool()` (`tools.ts:345-364`): ```typescript -// 去重时优先保留内置工具(name 相同时内置在前) +// Dedup with priority: built-in tools win on name collision (sorted first) return uniqBy( [...builtInTools.sort(byName), ...filteredMcpTools.sort(byName)], 'name', ) ``` -内置工具和 MCP 工具分开排序,不是合起来排。原因是 CC 的 `claude_code_system_cache_policy` 在最后一个内置工具之后的某个位置放全局缓存断点——混排会破坏这个设计。 +Built-in and MCP tools are sorted separately, not together. The reason is Claude Code's `claude_code_system_cache_policy` places a global cache breakpoint after the last built-in tool at a specific position. Mixing the sort would break this design. -### 三、命名规则:`mcp__server__tool` +### 3. Naming Convention: `mcp__server__tool` -`buildMcpToolName()`(`mcpStringUtils.ts:50-52`): +`buildMcpToolName()` (`mcpStringUtils.ts:50-52`): ``` mcp____ ``` -所有非 `[a-zA-Z0-9_-]` 字符替换为 `_`(`normalization.ts:17-23`)。教学版的 `normalize_mcp_name` 用同样的规则。 +All non-`[a-zA-Z0-9_-]` characters are replaced with `_` (`normalization.ts:17-23`). The tutorial's `normalize_mcp_name` uses the same rule. -### 四、权限检查 +### 4. Permission Checks -CC 对 MCP 工具有独立的权限系统。`checkPermissions()` 对 MCP 工具的检查逻辑不同于内置工具——MCP 工具可以声明自己的权限需求(readOnly、destructive 等),CC 根据声明决定是否需要用户确认。教学版只在 description 中用文本标注 `(readOnly)` / `(destructive)`,不做权限拦截。 +Claude Code has a separate permission system for MCP tools. `checkPermissions()` applies different logic for MCP tools than for built-in tools: MCP tools can declare their own permission requirements (readOnly, destructive, etc.), and Claude Code decides whether user confirmation is needed based on the declaration. The tutorial only uses text annotations `(readOnly)` / `(destructive)` in descriptions, without permission enforcement. -### 五、配置来源与优先级 +### 5. Configuration Sources and Priority -MCP 服务器配置来自多个来源。CC 的配置优先级从低到高: +MCP server configuration comes from multiple sources. Claude Code's priority from lowest to highest: ``` -claude.ai 连接器 < plugin < user settings.json < approved project .mcp.json < local settings.local.json +claude.ai connectors < plugin < user settings.json < approved project .mcp.json < local settings.local.json ``` -`claude.ai` 连接器单独拉取、按内容签名去重,以最低优先级合并(`config.ts:1267-1289`)。企业 `managed-mcp.json` 存在时完全排除其他配置。 +`claude.ai` connectors are fetched separately, deduplicated by content signature, and merged at the lowest precedence (`config.ts:1267-1289`). When enterprise `managed-mcp.json` exists, all other configurations are excluded. -教学版直接传 server name 给 `MOCK_SERVERS` 字典,不做配置合并。 +The tutorial passes server names directly to the `MOCK_SERVERS` dict, without config merging. -### 六、Channel 通知:服务器反向推消息 +### 6. Channel Notifications: Servers Push Messages Back -教学版只讲了 Agent → MCP Server 的单向调用。CC 还支持反向通知(`channelNotification.ts`): +The tutorial only covers agent → MCP Server unidirectional calls. Claude Code also supports reverse notifications (`channelNotification.ts`): -1. Server 声明 `capabilities.experimental['claude/channel']` -2. Server 通过 MCP 通知 `notifications/claude/channel` 给 Agent 发消息 -3. 消息包装在 `...` XML 标签中 -4. Agent 被 SleepTool 唤醒(1 秒内) +1. Server declares `capabilities.experimental['claude/channel']` +2. Server sends messages to agent via MCP notification `notifications/claude/channel` +3. Messages are wrapped in `...` XML tags +4. Agent is woken up by SleepTool (within 1 second) -Server 还可以请求权限:`notifications/claude/channel/permission_request` → Agent 回复 `notifications/claude/channel/permission`。用户通过 5 字母短 ID 确认/拒绝。 +Servers can also request permissions: `notifications/claude/channel/permission_request` → Agent replies `notifications/claude/channel/permission`. Users confirm/deny via a 5-letter short ID. -### 七、OAuth 认证流程 +### 7. OAuth Authentication Flow -CC 的 MCP 认证(`auth.ts`)支持完整的 OAuth 2.0 + PKCE 流程: -- 通过公钥客户端 + PKCE 发现 OAuth 元数据(RFC 8414 / RFC 9728) -- 本地回调服务器接收授权码 -- 令牌通过 `getSecureStorage()` 持久化(macOS Keychain / Linux 加密文件 / Windows 凭据管理器) -- 过期前 5 分钟自动刷新 -- 支持跨应用访问(XAA):浏览器获取 id_token → RFC 8693 + RFC 7523 交换 → 无需反复弹浏览器 +Claude Code's MCP authentication (`auth.ts`) supports a full OAuth 2.0 + PKCE flow: +- OAuth metadata discovery via public client + PKCE (RFC 8414 / RFC 9728) +- Local callback server receives authorization code +- Tokens persisted via `getSecureStorage()` (macOS Keychain / Linux encrypted file / Windows Credential Manager) +- Auto-refresh 5 minutes before expiry +- Cross-application access (XAA): browser gets id_token → RFC 8693 + RFC 7523 exchange → no repeated browser popups -### 八、连接生命周期的错误处理 +### 8. Connection Lifecycle Error Handling -CC 对 MCP 连接有精细的错误分类和重试(`client.ts:1266-1402`): -- 终局性错误(ECONNRESET、ETIMEDOUT、EPIPE 等):连续 3 次 → 关闭 + 重连 -- 工具调用 401:令牌过期 → 抛出 `McpAuthError` → 触发重认证 -- 工具调用超时:`Promise.race` 超时(可配置,默认约 28 小时) -- Stdio 断连:按 SIGINT → SIGTERM → SIGKILL 顺序杀进程 +Claude Code has fine-grained error classification and retry for MCP connections (`client.ts:1266-1402`): +- Terminal errors (ECONNRESET, ETIMEDOUT, EPIPE, etc.): 3 consecutive failures → close + reconnect +- Tool call 401: Token expired → throw `McpAuthError` → trigger re-authentication +- Tool call timeout: `Promise.race` timeout (configurable, default ~28 hours) +- Stdio disconnect: Kill process in SIGINT → SIGTERM → SIGKILL order -### 教学版的简化 +### The Tutorial's Simplifications -- 6 种 transport → 1 种(mock stdio):概念量可控 -- Channel 反向通知 → 省略:教学版 Agent 是主动方 -- OAuth 流程 → 省略:教学版假设 server 不需要认证 -- 多层配置优先级 → 省略:教学版直接传 server name -- 复杂的错误分类 → 省略:教学版用 try/except 兜底 -- MCP 工具只给 Lead → 省略子 agent 继承:简化代码结构 +- 6 transport types → 1 (mock stdio): Manageable concept count +- Channel reverse notifications → omitted: Tutorial agent is always the initiator +- OAuth flow → omitted: Tutorial assumes servers need no auth +- Multi-layer config priority → omitted: Tutorial passes server name directly +- Complex error classification → omitted: Tutorial uses try/except as fallback +- MCP tools Lead-only → omitted sub-agent inheritance: Simplifies code structure
- + diff --git a/s19_mcp_plugin/README.zh.md b/s19_mcp_plugin/README.zh.md new file mode 100644 index 000000000..374d44022 --- /dev/null +++ b/s19_mcp_plugin/README.zh.md @@ -0,0 +1,282 @@ +# s19: MCP Tools — 外接工具,标准协议 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s17 → s18 → `s19` → [s20](../s20_comprehensive/) + +> *"外接工具, 标准协议"* — 发现、组装、调用,Agent 不需要知道工具是谁写的。 +> +> **Harness 层**: 插件 — 外部能力通过标准协议接入。 + +--- + +## 问题 + +s01 到 s18,Agent 的所有工具都是手写的:bash、read、write、task、worktree。每个工具的输入验证、执行逻辑、错误处理,都是你一行行写的。 + +现在你有 3 个外部服务想接入:公司的 Jira API(查 issue、建 ticket)、自建的部署系统(触发 deploy、看日志)、团队的 Notion 知识库(搜文档、建页面)。你不想为每个服务重写一套工具代码。 + +你需要一个标准协议——外部服务只要实现它,Agent 就能直接调用,不管服务用什么语言写的。 + +--- + +## 解决方案 + +![MCP Architecture](images/mcp-architecture.svg) + +MCP(Model Context Protocol)定义了 Agent 如何发现和调用外部工具。核心概念: + +| 概念 | 作用 | +|------|------| +| MCPClient | Agent 端的客户端,连接 server、发现工具、调用工具 | +| MCP Server | 外部服务,实现 `tools/list` + `tools/call` | +| assemble_tool_pool | 把内置工具和 MCP 工具组装成一个工具池 | +| mcp\_\_server\_\_tool 命名 | 不同 server 的工具名隔离 | + +沿用 s18 的教学版 worktree 隔离、自主认领、空闲轮询、协议系统。本章新增:`connect_mcp` 工具,用于连接外部服务、发现工具、加入工具池。 + +教学版用 mock handler 模拟外部 server。真实版会启动子进程,通过 stdin/stdout 发送 JSON-RPC 请求。mock 的好处是不依赖外部服务就能跑完整流程;代价是你看不到真正的网络通信和进程管理。 + +--- + +## 工作原理 + +### MCPClient:发现 + 调用 + +```python +class MCPClient: + def __init__(self, name: str): + self.name = name + self.tools: list[dict] = [] + self._handlers: dict[str, callable] = {} + + def register(self, tool_defs, handlers): + """Simulates tools/list discovery.""" + self.tools = tool_defs + self._handlers = handlers + + def call_tool(self, tool_name: str, args: dict) -> str: + """Simulates tools/call.""" + handler = self._handlers.get(tool_name) + if not handler: + return f"MCP error: unknown tool '{tool_name}'" + return handler(**args) +``` + +教学版用 Python 函数模拟 server 的工具实现。真实版通过 stdio JSON-RPC 与子进程通信。 + +### connect_mcp:连接 + 发现 + +```python +def connect_mcp(name: str) -> str: + if name in mcp_clients: + return f"MCP server '{name}' already connected" + factory = MOCK_SERVERS.get(name) + if not factory: + return f"Unknown server '{name}'. Available: ..." + mcp_client = factory() + mcp_clients[name] = mcp_client + return f"Connected to '{name}'. Discovered: ..." +``` + +连接后,server 提供的工具立即可用。 + +### normalize_mcp_name:名称规范化 + +```python +_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]') + +def normalize_mcp_name(name: str) -> str: + return _DISALLOWED_CHARS.sub('_', name) +``` + +所有非 `[a-zA-Z0-9_-]` 的字符替换为 `_`。防止 server 名或工具名中包含特殊字符导致命名冲突或注入问题。 + +### assemble_tool_pool:组装工具池 + +```python +def assemble_tool_pool() -> tuple[list[dict], dict]: + tools = list(BUILTIN_TOOLS) + handlers = dict(BUILTIN_HANDLERS) + for server_name, mcp_client in mcp_clients.items(): + safe_server = normalize_mcp_name(server_name) + for tool_def in mcp_client.tools: + safe_tool = normalize_mcp_name(tool_def["name"]) + prefixed = f"mcp__{safe_server}__{safe_tool}" + tools.append(...) + handlers[prefixed] = ( + lambda *, c=mcp_client, t=tool_def["name"], **kw: + c.call_tool(t, kw)) + return tools, handlers +``` + +前缀 `mcp__{server}__{tool}` 把每个 server 的工具隔离到独立命名空间。名称经过 `normalize_mcp_name` 规范化。 + +MCP 工具的 description 带 `(readOnly)` 或 `(destructive)` 标注。教学版用文本标注,真实 Claude Code 用 tool annotations 结构体让权限系统判断。 + +### 无缓存:工具池变了,prompt 也变 + +s10-s18 的 agent_loop 用 prompt cache 避免重复序列化。s19 去掉了缓存: + +```python +def agent_loop(messages, context): + tools, handlers = assemble_tool_pool() # 每次重新构建 + system = assemble_system_prompt(context) # 每次重新生成 + ... + if any(b.name == "connect_mcp" ...): + tools, handlers = assemble_tool_pool() # 连接后重建 + system = assemble_system_prompt(context) +``` + +原因:`connect_mcp` 之后工具池变化了,新增了 `mcp__docs__search` 等工具。缓存中的工具列表是旧的,继续用会导致模型调用不到新工具。教学版直接去掉缓存,代价是多花一点序列化时间。 + +### MCP 工具只有 Lead 可用 + +教学版中,`connect_mcp` 是 Lead 工具,`assemble_tool_pool` 也只服务于 Lead 的 agent_loop。Teammate 仍使用固定的 8 个子集工具(bash、read_file、write_file、send_message、submit_plan、list_tasks、claim_task、complete_task)。 + +这是教学简化。真实 Claude Code 中,MCP 工具对主 agent 和子 agent 都可用,子 agent 继承父级的 MCP 配置。 + +--- + +## 相对 s18 的变更 + +| 组件 | 之前 (s18) | 之后 (s19) | +|------|-----------|-----------| +| 工具来源 | 全部手写 builtin | 手写 + MCP 外部工具动态发现 | +| 工具池 | 固定 BUILTIN_TOOLS | assemble_tool_pool 动态组装 mcp\_\_ 前缀工具 | +| 名称安全 | 无 | normalize_mcp_name 规范化 | +| 新类型 | — | MCPClient 类(模拟 tools/list + tools/call) | +| 命名空间 | — | mcp\_\_server\_\_tool 避免冲突 | +| 工具描述 | 无标注 | (readOnly)/(destructive) 标注 | +| prompt 缓存 | 有(s10 起) | 去掉,工具池动态变化后缓存失效 | +| Lead 工具 | 17 (s18) | 18 (+connect_mcp) | +| Teammate 工具 | 8 (s18) | 8(不变,MCP 工具仅 Lead 可用) | +| 扩展方式 | 写代码加工具 | MCP 协议,任意语言实现 server | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s19_mcp_plugin/code.py +``` + +试试这些 prompt: + +1. `Connect to the docs MCP server and search for something` +2. `Connect to the deploy server and trigger a deployment` +3. `Connect both servers — what tools are now available?` + +观察重点:连接 MCP server 后,工具名是否带 `mcp__docs__` 或 `mcp__deploy__` 前缀?两个 server 的工具是否同时可用?MCP 工具的 description 是否带 (readOnly)/(destructive) 标注? + +--- + +## 接下来 + +现在 Agent 可以通过 MCP 接入外部工具了。但前面 19 章每章都只加一个机制,真实 Agent 不会这样拆开运行。 + +工具、权限、hooks、todo、任务图、记忆、压缩、后台、cron、团队、worktree、MCP 这些机制应该挂在同一个循环上,而不是散在 19 个 demo 里。 + +s20 Comprehensive Agent → 把前 19 章的机制合回一个完整 harness。机制很多,循环一个。 + +
+深入 Claude Code 源码 + +> 以下基于 Claude Code 源码 `services/mcp/client.ts`、`auth.ts`、`config.ts`、`channelNotification.ts` 的分析。 + +### 一、6 种 Transport 类型 + +教学版只展示了 stdio mock。Claude Code 支持 6 种传输(`types.ts:23-25`): + +| Transport | 通信方式 | +|-----------|---------| +| `stdio` | 子进程 stdin/stdout(跨平台默认) | +| `sse` | HTTP Server-Sent Events | +| `http` | Streamable HTTP(POST/SSE 双向) | +| `ws` | WebSocket | +| `sse-ide` | IDE 内嵌 SSE 传输 | +| `sdk` | 进程内 SDK 传输 | + +连接时本地(stdio)和远程(http/sse/ws)服务器分批并发:本地批量 3 个,远程批量 20 个。 + +### 二、工具池组装算法 + +`assembleToolPool()`(`tools.ts:345-364`): + +```typescript +// 去重时优先保留内置工具(name 相同时内置在前) +return uniqBy( + [...builtInTools.sort(byName), ...filteredMcpTools.sort(byName)], + 'name', +) +``` + +内置工具和 MCP 工具分开排序,不是合起来排。原因是 Claude Code 的 `claude_code_system_cache_policy` 在最后一个内置工具之后的某个位置放全局缓存断点,混排会破坏这个设计。 + +### 三、命名规则:`mcp__server__tool` + +`buildMcpToolName()`(`mcpStringUtils.ts:50-52`): + +``` +mcp____ +``` + +所有非 `[a-zA-Z0-9_-]` 字符替换为 `_`(`normalization.ts:17-23`)。教学版的 `normalize_mcp_name` 用同样的规则。 + +### 四、权限检查 + +Claude Code 对 MCP 工具有独立的权限系统。`checkPermissions()` 对 MCP 工具的检查逻辑不同于内置工具:MCP 工具可以声明自己的权限需求(readOnly、destructive 等),Claude Code 根据声明决定是否需要用户确认。教学版只在 description 中用文本标注 `(readOnly)` / `(destructive)`,不做权限拦截。 + +### 五、配置来源与优先级 + +MCP 服务器配置来自多个来源。Claude Code 的配置优先级从低到高: + +``` +claude.ai 连接器 < plugin < user settings.json < approved project .mcp.json < local settings.local.json +``` + +`claude.ai` 连接器单独拉取、按内容签名去重,以最低优先级合并(`config.ts:1267-1289`)。企业 `managed-mcp.json` 存在时完全排除其他配置。 + +教学版直接传 server name 给 `MOCK_SERVERS` 字典,不做配置合并。 + +### 六、Channel 通知:服务器反向推消息 + +教学版只讲了 Agent → MCP Server 的单向调用。Claude Code 还支持反向通知(`channelNotification.ts`): + +1. Server 声明 `capabilities.experimental['claude/channel']` +2. Server 通过 MCP 通知 `notifications/claude/channel` 给 Agent 发消息 +3. 消息包装在 `...` XML 标签中 +4. Agent 被 SleepTool 唤醒(1 秒内) + +Server 还可以请求权限:`notifications/claude/channel/permission_request` → Agent 回复 `notifications/claude/channel/permission`。用户通过 5 字母短 ID 确认/拒绝。 + +### 七、OAuth 认证流程 + +Claude Code 的 MCP 认证(`auth.ts`)支持完整的 OAuth 2.0 + PKCE 流程: +- 通过公钥客户端 + PKCE 发现 OAuth 元数据(RFC 8414 / RFC 9728) +- 本地回调服务器接收授权码 +- 令牌通过 `getSecureStorage()` 持久化(macOS Keychain / Linux 加密文件 / Windows 凭据管理器) +- 过期前 5 分钟自动刷新 +- 支持跨应用访问(XAA):浏览器获取 id_token → RFC 8693 + RFC 7523 交换 → 无需反复弹浏览器 + +### 八、连接生命周期的错误处理 + +Claude Code 对 MCP 连接有精细的错误分类和重试(`client.ts:1266-1402`): +- 终局性错误(ECONNRESET、ETIMEDOUT、EPIPE 等):连续 3 次 → 关闭 + 重连 +- 工具调用 401:令牌过期 → 抛出 `McpAuthError` → 触发重认证 +- 工具调用超时:`Promise.race` 超时(可配置,默认约 28 小时) +- Stdio 断连:按 SIGINT → SIGTERM → SIGKILL 顺序杀进程 + +### 教学版的简化 + +- 6 种 transport → 1 种(mock stdio):概念量可控 +- Channel 反向通知 → 省略:教学版 Agent 是主动方 +- OAuth 流程 → 省略:教学版假设 server 不需要认证 +- 多层配置优先级 → 省略:教学版直接传 server name +- 复杂的错误分类 → 省略:教学版用 try/except 兜底 +- MCP 工具只给 Lead → 省略子 agent 继承:简化代码结构 + +
+ + diff --git a/s19_mcp_plugin/code.py b/s19_mcp_plugin/code.py index fb3c6c04c..16e9ef276 100644 --- a/s19_mcp_plugin/code.py +++ b/s19_mcp_plugin/code.py @@ -714,7 +714,7 @@ def _mock_server_deploy(): client.register( tool_defs=[ {"name": "trigger", - "description": "Trigger a deployment. (destructive — requires approval in real CC)", + "description": "Trigger a deployment. (destructive — requires approval in real Claude Code)", "inputSchema": {"type": "object", "properties": {"service": {"type": "string"}}, "required": ["service"]}}, diff --git a/s19_mcp_plugin/images/mcp-architecture.en.svg b/s19_mcp_plugin/images/mcp-architecture.en.svg deleted file mode 100644 index 01d0c068d..000000000 --- a/s19_mcp_plugin/images/mcp-architecture.en.svg +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - MCP Plugin — Standard Protocol + External Tool Integration + Tool Pool Assembly - - - - s18 Preserved - - s19 New - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH (Lead 18 tools) - bash · read · write · task(4) · send · inbox - request_shutdown · request_plan · review_plan - create_worktree · remove_worktree · keep_worktree - ★ connect_mcp + dynamic mcp__server__tool tools - - - - - - - MCP Architecture (s19 new: standard protocol + external tools dynamic integration) - - - - Agent Side (MCPClient) - - - connect_mcp → discover → register tools - - - assemble_tool_pool assembles builtin + mcp - - - call_tool("mcp__docs__search", ...) - - - - tools/list - - - tools/call + response - - - - MCP Servers (External Services) - - - docs server: search · get_version - - - deploy server: trigger · status - - - Any language, just needs stdio JSON-RPC - - - - Tool naming: mcp__{server}__{tool} → e.g. mcp__docs__search · mcp__deploy__trigger · prevents name collisions across servers - - - - - s18: worktree + events + protocols (Lead 17) - - s19: MCP + dynamic tools (Lead 18) - - - - Next: s20 combines tools, permissions, teams, worktrees, MCP, and more into one while True loop. - diff --git a/s19_mcp_plugin/images/mcp-architecture.ja.svg b/s19_mcp_plugin/images/mcp-architecture.ja.svg deleted file mode 100644 index d2b5255c5..000000000 --- a/s19_mcp_plugin/images/mcp-architecture.ja.svg +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - MCP Plugin — 標準プロトコル + 外部ツール接続 + ツールプール組み立て - - - - s18 保持 - - s19 新規 - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH(Lead 18 tools) - bash · read · write · task(4) · send · inbox - request_shutdown · request_plan · review_plan - create_worktree · remove_worktree · keep_worktree - ★ connect_mcp + 動的 mcp__server__tool ツール - - - - - - - MCP アーキテクチャ(s19 新規:標準プロトコル + 外部ツール動的統合) - - - - Agent 側(MCPClient) - - - connect_mcp → discover → ツール登録 - - - assemble_tool_pool builtin + mcp 組み立て - - - call_tool("mcp__docs__search", ...) - - - - tools/list - - - tools/call + response - - - - MCP Servers(外部サービス) - - - docs server: search · get_version - - - deploy server: trigger · status - - - 任意言語実装、stdio JSON-RPC のみ必要 - - - - ツール命名: mcp__{server}__{tool} → 例: mcp__docs__search · mcp__deploy__trigger · サーバー間の名前衝突を防止 - - - - - s18: worktree + events + protocols(Lead 17) - - s19: MCP + dynamic tools(Lead 18) - - - - 次の s20:tools、permissions、teams、worktree、MCP などを 1 つの while True ループに統合。 - diff --git a/s19_mcp_plugin/images/mcp-architecture.svg b/s19_mcp_plugin/images/mcp-architecture.svg index 6b365d6b7..abe2d13d7 100644 --- a/s19_mcp_plugin/images/mcp-architecture.svg +++ b/s19_mcp_plugin/images/mcp-architecture.svg @@ -1,112 +1,146 @@ - + - - - - - + + - - - - - + + - + + - - - MCP Plugin — 标准协议 + 外部工具接入 + 工具池组装 - - - - s18 保留 - - s19 新增 - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH (Lead 18 tools) - bash · read · write · task(4) · send · inbox - request_shutdown · request_plan · review_plan - create_worktree · remove_worktree · keep_worktree - ★ connect_mcp + 动态 mcp__server__tool 工具 - - - - - - - MCP 架构(s19 新增:标准协议 + 外部工具动态接入) - - - - Agent Side (MCPClient) - - - connect_mcp → discover → 注册工具 - - - assemble_tool_pool 组装 builtin + mcp - - - call_tool("mcp__docs__search", ...) - - - - tools/list - - - tools/call + response - - - - MCP Servers (外部服务) - - - docs server: search · get_version - - - deploy server: trigger · status - - - 任意语言实现,只需 stdio JSON-RPC - - - - 工具命名: mcp__{server}__{tool} → 例: mcp__docs__search · mcp__deploy__trigger · 避免不同 server 的工具名冲突 - - - - - s18: worktree + events + protocols (Lead 17) - - s19: MCP + dynamic tools (Lead 18) - - - - 下一章 s20:把工具、权限、团队、worktree、MCP 等机制合回同一个 while True 循环。 - + MCP Plugin — Standard Protocol + External Tool Integration + Discover, assemble, invoke — agent doesn't need to know who wrote them + + + + Agent Lead Loop + + + + turn + + + + + + + messages + + + + + + + prompt + + + + + + + LLM + + + + + + + TOOL DISPATCH (18 tools) + bash, read, write, task(5), connect_mcp, mcp__* + + + + loop back + + + + + + + + MCP Architecture — Standard Protocol + Dynamic External Tools + + + + Agent Side (MCPClient) + + + + connect_mcp + Connect to server, discover tools, register + + + + assemble_tool_pool + Merge built-in + MCP tools into one pool + + + + call_tool + + call_tool("mcp__docs__search", ...) + + + + + tools/list + + + + tools/call + + + + response + + + + + + + + + MCP Servers (External Services) + + + + docs server + search (readOnly) · get_version (readOnly) + + + + deploy server + trigger (destructive) · status (readOnly) + + + + any server ... + Any language, just implement stdio JSON-RPC + + + + Naming: mcp__{server}__{tool} e.g. mcp__docs__search · mcp__deploy__trigger + + + + Key Behaviors + + + + No prompt cache + Tool pool is dynamic; cache stales + + + + MCP tools = Lead only + Teammates use fixed 8-tool subset + + + + normalize_mcp_name + Sanitize names: [a-zA-Z0-9_-] + \ No newline at end of file diff --git a/s20_comprehensive/README.en.md b/s20_comprehensive/README.en.md deleted file mode 100644 index 07bdcadb5..000000000 --- a/s20_comprehensive/README.en.md +++ /dev/null @@ -1,250 +0,0 @@ -# s20: Comprehensive Agent — All Mechanisms, One Loop - -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) - -s01 → ... → s18 → s19 → `s20` - -> *"Many mechanisms, one loop"* — tools, permissions, memory, tasks, teams, and plugins all hang off the same `while True`. -> -> **Harness layer**: Comprehensive — put the previous 19 mechanisms back into one runnable system. - ---- - -## Problem - -The first 19 chapters add one mechanism at a time. That is the right way to learn, but a real agent does not run with only one mechanism enabled. - -A long-running coding agent needs all of these at once: - -- tool dispatch and permission boundaries -- hook extension points -- todo planning and task graphs -- skills, memory, and runtime system prompt assembly -- compaction and error recovery -- background tasks and cron scheduling -- teams, protocols, autonomous claiming -- worktree isolation -- MCP external tool integration - -The hard part is not piling up features. The hard part is seeing where each mechanism belongs around the loop. S20 is the endpoint chapter: every component is placed back into one harness. - ---- - -## Solution - -![System Architecture](images/system-architecture.en.svg) - -S20 does not invent a new mechanism. It merges the teaching components from the earlier chapters into one complete harness: - -```text -user input - → UserPromptSubmit hooks - → cron/background notification injection - → context compact - → memory + skills + MCP state assemble the system prompt - → LLM - → has tool_use block? - no → Stop hooks → return - yes → PreToolUse hooks + permission - → TOOL_HANDLERS / MCP handlers / background dispatch - → PostToolUse hooks - → tool_result / task_notification back to messages - → next round -``` - -The loop is still the same structure: call the model, check whether the response contains a `tool_use` block, execute tools, append results back to `messages`. CC source does not directly trust `stop_reason == "tool_use"`; the actual presence of a tool_use block is the continuation signal. What changed is that the harness around the loop is now complete. - ---- - -## Where Each Component Sits - -| Position | Component | Role | -|----------|-----------|------| -| Around user input | `UserPromptSubmit` hooks | Log, inject, or audit user input | -| Before LLM | cron queue | Inject scheduled prompts into `messages` | -| Before LLM | background notifications | Inject completed background work as `` | -| Before LLM | compaction pipeline | Budget large outputs, trim history, compact old tool results, summarize when needed | -| Before LLM | memory / skills / MCP state | Assemble the system prompt so the model sees current capabilities and long-term context | -| LLM call | error recovery | Retry 429/529, escalate `max_tokens`, compact on prompt-too-long | -| Before tool execution | `PreToolUse` hooks + permission | Block dangerous commands, out-of-bounds writes, destructive MCP tools | -| Tool dispatch | `assemble_tool_pool` | Assemble built-in tools and dynamic MCP tools | -| During tool execution | background dispatch | Move slow bash work into a daemon thread and return a placeholder result | -| After tool execution | `PostToolUse` hooks | Large-output warnings, logs, post-processing | -| Back to loop | tool_result | One `tool_result` per `tool_use`, then the next model round | -| No tool_use this round / on stop | `Stop` hooks | Stats, cleanup, audit | - ---- - -## What code.py Contains - -### Tools and Dispatch - -The built-in tool pool contains 27 tools: - -```text -bash, read_file, write_file, edit_file, glob -todo_write, task, load_skill, compact -create_task, list_tasks, get_task, claim_task, complete_task -schedule_cron, list_crons, cancel_cron -spawn_teammate, send_message, check_inbox -request_shutdown, request_plan, review_plan -create_worktree, remove_worktree, keep_worktree -connect_mcp -``` - -`assemble_tool_pool()` assembles these every round: - -```text -BUILTIN_TOOLS + connected MCP tools -BUILTIN_HANDLERS + mcp__server__tool handlers -``` - -After `connect_mcp("docs")`, the next round exposes tools like `mcp__docs__search`. - -### Permissions and Hooks - -Permission is not hardcoded into the tool execution line. It is a `PreToolUse` hook: - -```python -blocked = trigger_hooks("PreToolUse", block) -if blocked: - results.append(tool_result(block.id, blocked)) - continue -``` - -That means permission, logging, and audit logic all attach to the same hook point. After execution, `PostToolUse` hooks run. - -### Planning and Tasks - -S20 keeps two planning layers: - -- `todo_write`: lightweight plan for the current session, kept in memory -- task graph: cross-session, dependency-aware, claimable task files under `.tasks/task_*.json` - -The first keeps a single agent from drifting. The second supports team coordination. - -### Subagents and Teams - -S20 has two kinds of delegation: - -- `task`: one-shot subagent. It uses an isolated `messages[]`, discards intermediate context, and returns only a final summary. -- `spawn_teammate`: persistent teammate thread. It communicates through `MessageBus`, polls the task board while idle, and can claim work autonomously. - -One-shot subagents solve context isolation. Persistent teammates solve long-running parallel collaboration. - -### Memory, Skills, and Prompt - -`assemble_system_prompt(context)` assembles each round from: - -- identity and tool guidance -- workspace -- skills catalog -- `.memory/MEMORY.md` -- connected MCP servers - -Skills only put their catalog into the system prompt. Full content is loaded on demand through `load_skill(name)`. - -### Compaction and Recovery - -Before the LLM call, S20 runs the compaction pipeline: - -```text -tool_result_budget → snip_compact → micro_compact → compact_history -``` - -The model call is wrapped with recovery: - -- 429: exponential backoff retry -- 529: exponential backoff, optionally switch to fallback model after repeated failures -- `max_tokens`: raise max tokens, then request continuation -- prompt too long: reactive compact and retry - -### Background and Cron - -Slow bash work does not block the main loop: - -```text -should_run_background → start_background_task → placeholder tool_result -background done → task_notification → next round injects messages -``` - -The cron scheduler runs as a daemon thread and checks once per second. The CLI watches `cron_queue`; when a job fires, it injects `[Scheduled] ...` and runs one agent turn automatically. - -### Worktree and MCP - -Worktree isolation owns directories: - -- `create_worktree(name, task_id)` creates an isolated branch and directory -- the task `worktree` field binds a task to that directory -- when a teammate claims a task with a worktree, its bash/read/write tools run in that directory - -MCP owns external capability: - -- `connect_mcp(name)` connects a mock server -- `assemble_tool_pool()` assembles MCP tools into the tool pool -- tool names use `mcp__server__tool` - ---- - -## Changes from s19 - -| Component | s19 | s20 | -|-----------|-----|-----| -| tool pool | built-in + MCP | built-in + MCP, with s01-s18 tools restored | -| permission | omitted in teaching body | runs inside `PreToolUse` hook | -| hooks | omitted | UserPromptSubmit / PreToolUse / PostToolUse / Stop | -| todo | omitted | `todo_write` + reminder | -| skill | omitted | catalog in system prompt + `load_skill` | -| compact | omitted | pre-LLM compaction + `compact` tool + reactive compact | -| error recovery | simple try/except | retry / max_tokens / prompt too long | -| background | omitted | slow-operation thread + task notification | -| cron | omitted | daemon scheduler + durable jobs | -| multi-agent | kept | kept; teammates use basic tools in isolated directories | -| worktree | kept | kept | -| MCP | new | kept as part of the final tool pool | - ---- - -## Try It - -```sh -cd learn-claude-code -python s20_comprehensive/code.py -``` - -Try: - -1. `Create a todo list for inspecting this repo, then list Python files` -2. `Connect to the docs MCP server and search for agent loop` -3. `Create two tasks, create worktrees for them, then spawn alice and bob. Ask them to submit plans before claiming tasks.` -4. `remind me of the meeting in 3 minutes.` -5. `Run npm install in the background and continue reading README.md` - -Watch for: - -- whether each tool call passes through hooks/permission -- whether MCP tools appear on the next round after `connect_mcp` -- whether slow operations return a background placeholder -- whether cron automatically reminds you when the time arrives -- whether teammates submit plans and pause before approval -- whether teammates can claim tasks after plan approval -- whether teammates switch to the bound worktree directory - ---- - -## The End Is the Beginning - -From s01 to s20, the code gets more capable, but the core remains unchanged: - -```python -while True: - response = LLM(messages, tools) - if not has_tool_use(response.content): - return - results = execute_tools(response.content) - messages.append(tool_results) -``` - -Claude Code's complexity is not "another agent brain." It is the complexity of a mature harness. The model decides and chooses actions; the harness organizes environment, tools, permissions, memory, teams, and external capabilities. - -This is the endpoint of the course: many mechanisms, one loop. diff --git a/s20_comprehensive/README.ja.md b/s20_comprehensive/README.ja.md index 157c6c2ec..54995582f 100644 --- a/s20_comprehensive/README.ja.md +++ b/s20_comprehensive/README.ja.md @@ -1,6 +1,6 @@ # s20: Comprehensive Agent — すべての仕組みを 1 つのループへ -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s18 → s19 → `s20` @@ -32,7 +32,7 @@ s01 → ... → s18 → s19 → `s20` ## 解決策 -![System Architecture](images/system-architecture.ja.svg) +![System Architecture](images/system-architecture.svg) S20 は新しい単独 mechanism を発明しない。前章までの teaching component を 1 つの完全な harness に統合する: @@ -52,7 +52,7 @@ user input → next round ``` -loop 自体は同じ構造のままだ。model を呼び、response に `tool_use` block があるかを見て、tool を実行し、結果を `messages` に戻す。CC source でも `stop_reason == "tool_use"` を直接信頼せず、実際に tool_use block が出たかを continuation signal として扱う。変わったのは、loop の周囲の harness が完成形になったことだけ。 +loop 自体は同じ構造のままだ。model を呼び、response に `tool_use` block があるかを見て、tool を実行し、結果を `messages` に戻す。Claude Code source でも `stop_reason == "tool_use"` を直接信頼せず、実際に tool_use block が出たかを continuation signal として扱う。変わったのは、loop の周囲の harness が完成形になったことだけ。 --- @@ -247,4 +247,8 @@ while True: Claude Code の複雑さは「別の agent brain」ではない。成熟した harness の複雑さだ。model は判断と action selection を担当する。harness は environment、tools、permissions、memory、teams、external capabilities を整理する。 -これが本コースの終点だ:仕組みは多い、ループは 1 つ。 +ここが s01–s20 の本流の収束点だ:すべての component が同じ loop に集結する。 + +そしてその loop は常に単ステップで model 駆動だ——毎 round、model が tool を 1 つ選ぶ。オーケストレーションの形がすでに固定なら(parallel fan-out、item ごとの pipeline、中断からの resume)、model に round ごと駆動させるより、決定的で再開可能な script として書く。 + +これから:[s21 Workflow Runtime](../s21_workflow_runtime/) — モデルは単ステップ、スクリプトはオーケストレーション。 diff --git a/s20_comprehensive/README.md b/s20_comprehensive/README.md index f9f49d715..5e0ce46a5 100644 --- a/s20_comprehensive/README.md +++ b/s20_comprehensive/README.md @@ -1,85 +1,85 @@ -# s20: Comprehensive Agent — 全部机制,归到一个循环 +# s20: Comprehensive Agent — All Mechanisms, One Loop -[中文](README.md) · [English](README.en.md) · [日本語](README.ja.md) +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → ... → s18 → s19 → `s20` -> *"机制很多,循环一个"* — 工具、权限、记忆、任务、团队、插件都挂在同一个 while True 上。 +> *"Many mechanisms, one loop"* — tools, permissions, memory, tasks, teams, and plugins all hang off the same `while True`. > -> **Harness 层**: 综合 — 把前 19 章的机制放回同一个可运行系统。 +> **Harness layer**: Comprehensive — put the previous 19 mechanisms back into one runnable system. --- -## 问题 +## Problem -前 19 章每章只加一个机制。这样适合学习,但真实 Agent 不会只带一个机制运行。 +The first 19 chapters add one mechanism at a time. That is the right way to learn, but a real agent does not run with only one mechanism enabled. -一个能长期工作的 coding agent 需要同时拥有: +A long-running coding agent needs all of these at once: -- 工具分发和权限边界 -- hooks 扩展点 -- todo 计划和任务图 -- 技能、记忆、系统 prompt 组装 -- 压缩和错误恢复 -- 后台任务和 cron 调度 -- 团队、协议、自治认领 -- worktree 隔离 -- MCP 外部工具接入 +- tool dispatch and permission boundaries +- hook extension points +- todo planning and task graphs +- skills, memory, and runtime system prompt assembly +- compaction and error recovery +- background tasks and cron scheduling +- teams, protocols, autonomous claiming +- worktree isolation +- MCP external tool integration -难点不是把功能堆起来,而是看清楚它们都挂在循环的哪个位置。S20 就是终点章:把所有组件归位。 +The hard part is not piling up features. The hard part is seeing where each mechanism belongs around the loop. S20 is the endpoint chapter: every component is placed back into one harness. --- -## 解决方案 +## Solution ![System Architecture](images/system-architecture.svg) -S20 不是再发明一个新机制,而是把前面的教学组件合成一个完整 harness: +S20 does not invent a new mechanism. It merges the teaching components from the earlier chapters into one complete harness: ```text -用户输入 +user input → UserPromptSubmit hooks - → cron/background 通知注入 + → cron/background notification injection → context compact - → memory + skills + MCP 状态组装 system prompt + → memory + skills + MCP state assemble the system prompt → LLM → has tool_use block? - 否 → Stop hooks → 返回 - 是 → PreToolUse hooks + permission + no → Stop hooks → return + yes → PreToolUse hooks + permission → TOOL_HANDLERS / MCP handlers / background dispatch → PostToolUse hooks - → tool_result / task_notification 回 messages - → 下一轮 + → tool_result / task_notification back to messages + → next round ``` -循环本身仍然是同一个结构:调用模型,检查响应里是否出现 `tool_use` block,执行工具,把结果追加回 `messages`。CC 源码里也不直接信任 `stop_reason == "tool_use"`,而是以实际出现的 tool_use block 作为是否继续工具轮的信号。变化的是循环周围的 harness 变完整了。 +The loop is still the same structure: call the model, check whether the response contains a `tool_use` block, execute tools, append results back to `messages`. Claude Code source does not directly trust `stop_reason == "tool_use"`; the actual presence of a tool_use block is the continuation signal. What changed is that the harness around the loop is now complete. --- -## 组件在循环中的位置 - -| 位置 | 组件 | 作用 | -|------|------|------| -| 用户输入前后 | `UserPromptSubmit` hooks | 记录、注入、审计用户输入 | -| LLM 前 | cron queue | 把定时触发的 prompt 注入 `messages` | -| LLM 前 | background notifications | 后台任务完成后以 `` 注入 | -| LLM 前 | compaction pipeline | 先压大输出,再裁历史,再压旧 tool_result,必要时摘要 | -| LLM 前 | memory / skills / MCP state | 组装 system prompt,让模型看到当前能力和长期上下文 | -| LLM 调用 | error recovery | 429/529 重试,`max_tokens` 升级,prompt too long 触发 reactive compact | -| 工具执行前 | `PreToolUse` hooks + permission | 拦截危险命令、写越界、破坏性 MCP 工具 | -| 工具分发 | `assemble_tool_pool` | 组装内置工具和 MCP 动态工具 | -| 工具执行时 | background dispatch | 慢 bash 操作放 daemon thread,主循环先返回占位结果 | -| 工具执行后 | `PostToolUse` hooks | 大输出告警、日志等后处理 | -| 返回循环 | tool_result | 每个 `tool_use` 对应一个 `tool_result`,再回到下一轮 | -| 本轮没有 tool_use / 停止时 | `Stop` hooks | 统计、清理、审计 | +## Where Each Component Sits + +| Position | Component | Role | +|----------|-----------|------| +| Around user input | `UserPromptSubmit` hooks | Log, inject, or audit user input | +| Before LLM | cron queue | Inject scheduled prompts into `messages` | +| Before LLM | background notifications | Inject completed background work as `` | +| Before LLM | compaction pipeline | Budget large outputs, trim history, compact old tool results, summarize when needed | +| Before LLM | memory / skills / MCP state | Assemble the system prompt so the model sees current capabilities and long-term context | +| LLM call | error recovery | Retry 429/529, escalate `max_tokens`, compact on prompt-too-long | +| Before tool execution | `PreToolUse` hooks + permission | Block dangerous commands, out-of-bounds writes, destructive MCP tools | +| Tool dispatch | `assemble_tool_pool` | Assemble built-in tools and dynamic MCP tools | +| During tool execution | background dispatch | Move slow bash work into a daemon thread and return a placeholder result | +| After tool execution | `PostToolUse` hooks | Large-output warnings, logs, post-processing | +| Back to loop | tool_result | One `tool_result` per `tool_use`, then the next model round | +| No tool_use this round / on stop | `Stop` hooks | Stats, cleanup, audit | --- -## code.py 包含什么 +## What code.py Contains -### 工具与分发 +### Tools and Dispatch -内置工具池包含 27 个工具: +The built-in tool pool contains 27 tools: ```text bash, read_file, write_file, edit_file, glob @@ -92,18 +92,18 @@ create_worktree, remove_worktree, keep_worktree connect_mcp ``` -`assemble_tool_pool()` 每轮组装: +`assemble_tool_pool()` assembles these every round: ```text BUILTIN_TOOLS + connected MCP tools BUILTIN_HANDLERS + mcp__server__tool handlers ``` -所以 `connect_mcp("docs")` 后,下一轮工具池里会出现 `mcp__docs__search`。 +After `connect_mcp("docs")`, the next round exposes tools like `mcp__docs__search`. -### 权限和 hooks +### Permissions and Hooks -权限不写死在工具执行行里,而是作为 `PreToolUse` hook: +Permission is not hardcoded into the tool execution line. It is a `PreToolUse` hook: ```python blocked = trigger_hooks("PreToolUse", block) @@ -112,107 +112,107 @@ if blocked: continue ``` -这样 permission、log、审计都可以挂在同一个 hook 点上。执行后再触发 `PostToolUse`。 +That means permission, logging, and audit logic all attach to the same hook point. After execution, `PostToolUse` hooks run. -### 计划与任务 +### Planning and Tasks -S20 同时保留两层计划: +S20 keeps two planning layers: -- `todo_write`:当前会话内的轻量计划,保存在内存中 -- task graph:跨会话、可依赖、可认领的任务文件,写入 `.tasks/task_*.json` +- `todo_write`: lightweight plan for the current session, kept in memory +- task graph: cross-session, dependency-aware, claimable task files under `.tasks/task_*.json` -前者帮助单个 Agent 不漂移;后者支撑团队协作。 +The first keeps a single agent from drifting. The second supports team coordination. -### 子 agent 与团队 +### Subagents and Teams -S20 有两种 delegation: +S20 has two kinds of delegation: -- `task`:一次性 subagent。独立 `messages[]`,中间过程丢弃,只返回最终摘要。 -- `spawn_teammate`:持久队友线程。通过 MessageBus 收发消息,能 idle 轮询任务板并自动认领。 +- `task`: one-shot subagent. It uses an isolated `messages[]`, discards intermediate context, and returns only a final summary. +- `spawn_teammate`: persistent teammate thread. It communicates through `MessageBus`, polls the task board while idle, and can claim work autonomously. -一次性 subagent 解决“上下文隔离”;持久队友解决“长期并行协作”。 +One-shot subagents solve context isolation. Persistent teammates solve long-running parallel collaboration. -### 记忆、技能和 prompt +### Memory, Skills, and Prompt -`assemble_system_prompt(context)` 每轮组装: +`assemble_system_prompt(context)` assembles each round from: -- 身份和工具说明 +- identity and tool guidance - workspace - skills catalog - `.memory/MEMORY.md` -- 已连接 MCP server +- connected MCP servers -技能只在 system prompt 里放目录。完整内容通过 `load_skill(name)` 按需加载。 +Skills only put their catalog into the system prompt. Full content is loaded on demand through `load_skill(name)`. -### 压缩和恢复 +### Compaction and Recovery -LLM 前先跑压缩管线: +Before the LLM call, S20 runs the compaction pipeline: ```text tool_result_budget → snip_compact → micro_compact → compact_history ``` -调用模型时再包一层恢复: +The model call is wrapped with recovery: -- 429:指数退避重试 -- 529:指数退避,连续失败可切 fallback model -- `max_tokens`:先提高 max_tokens,再要求 continuation -- prompt too long:reactive compact 后重试 +- 429: exponential backoff retry +- 529: exponential backoff, optionally switch to fallback model after repeated failures +- `max_tokens`: raise max tokens, then request continuation +- prompt too long: reactive compact and retry -### 后台和 cron +### Background and Cron -慢 bash 操作不会阻塞主循环: +Slow bash work does not block the main loop: ```text should_run_background → start_background_task → placeholder tool_result -后台完成 → task_notification → 下一轮注入 messages +background done → task_notification → next round injects messages ``` -cron 调度器独立 daemon thread 每秒检查一次。CLI 会监听 `cron_queue`,命中后主动把 `[Scheduled] ...` 注入并运行一轮 Agent。 +The cron scheduler runs as a daemon thread and checks once per second. The CLI watches `cron_queue`; when a job fires, it injects `[Scheduled] ...` and runs one agent turn automatically. -### worktree 与 MCP +### Worktree and MCP -worktree 负责隔离目录: +Worktree isolation owns directories: -- `create_worktree(name, task_id)` 创建独立分支和目录 -- task 的 `worktree` 字段绑定目录 -- 队友 claim 到带 worktree 的 task 后,bash/read/write 自动在对应目录下执行 +- `create_worktree(name, task_id)` creates an isolated branch and directory +- the task `worktree` field binds a task to that directory +- when a teammate claims a task with a worktree, its bash/read/write tools run in that directory -MCP 负责外部能力: +MCP owns external capability: -- `connect_mcp(name)` 连接 mock server -- `assemble_tool_pool()` 把 MCP 工具组装进工具池 -- 工具名统一为 `mcp__server__tool` +- `connect_mcp(name)` connects a mock server +- `assemble_tool_pool()` assembles MCP tools into the tool pool +- tool names use `mcp__server__tool` --- -## 相对 s19 的变化 - -| 组件 | s19 | s20 | -|------|-----|-----| -| 工具池 | 内置 + MCP | 内置 + MCP,补齐 s01-s18 的工具 | -| 权限 | 教学主体省略 | `PreToolUse` hook 中执行 | -| hooks | 省略 | UserPromptSubmit / PreToolUse / PostToolUse / Stop | -| todo | 省略 | `todo_write` + reminder | -| skill | 省略 | catalog in system prompt + `load_skill` | -| compact | 省略 | LLM 前压缩 + `compact` 工具 + reactive compact | -| error recovery | 简化 try/except | retry / max_tokens / prompt too long | -| background | 省略 | 慢操作后台线程 + task notification | -| cron | 省略 | daemon scheduler + durable jobs | -| multi-agent | 保留 | 保留;队友使用隔离目录下的基础工具 | -| worktree | 保留 | 保留 | -| MCP | 新增 | 保留,作为最终工具池的一部分 | +## Changes from s19 + +| Component | s19 | s20 | +|-----------|-----|-----| +| tool pool | built-in + MCP | built-in + MCP, with s01-s18 tools restored | +| permission | omitted in teaching body | runs inside `PreToolUse` hook | +| hooks | omitted | UserPromptSubmit / PreToolUse / PostToolUse / Stop | +| todo | omitted | `todo_write` + reminder | +| skill | omitted | catalog in system prompt + `load_skill` | +| compact | omitted | pre-LLM compaction + `compact` tool + reactive compact | +| error recovery | simple try/except | retry / max_tokens / prompt too long | +| background | omitted | slow-operation thread + task notification | +| cron | omitted | daemon scheduler + durable jobs | +| multi-agent | kept | kept; teammates use basic tools in isolated directories | +| worktree | kept | kept | +| MCP | new | kept as part of the final tool pool | --- -## 试一下 +## Try It ```sh cd learn-claude-code python s20_comprehensive/code.py ``` -可以试: +Try: 1. `Create a todo list for inspecting this repo, then list Python files` 2. `Connect to the docs MCP server and search for agent loop` @@ -220,21 +220,21 @@ python s20_comprehensive/code.py 4. `remind me of the meeting in 3 minutes.` 5. `Run npm install in the background and continue reading README.md` -观察重点: +Watch for: -- 工具调用前是否经过 hooks/permission -- `connect_mcp` 后下一轮是否出现 MCP 工具 -- 慢操作是否返回 background placeholder -- 到点是不是自动提醒开会 -- 队友是否提交 plan,并在 approval 前暂停 -- plan 批准后,队友是否能认领任务 -- worktree 绑定后,队友是否切到对应目录 +- whether each tool call passes through hooks/permission +- whether MCP tools appear on the next round after `connect_mcp` +- whether slow operations return a background placeholder +- whether cron automatically reminds you when the time arrives +- whether teammates submit plans and pause before approval +- whether teammates can claim tasks after plan approval +- whether teammates switch to the bound worktree directory --- -## 结束亦是开始 +## The End Is the Beginning -从 s01 到 s20,代码表面越来越复杂,但核心始终没变: +From s01 to s20, the code gets more capable, but the core remains unchanged: ```python while True: @@ -245,6 +245,10 @@ while True: messages.append(tool_results) ``` -Claude Code 的复杂性不是“另一个 agent 大脑”,而是一个成熟 harness 的复杂性。模型负责判断和行动选择;harness 负责把环境、工具、权限、记忆、团队和外部能力组织好。 +Claude Code's complexity is not "another agent brain." It is the complexity of a mature harness. The model decides and chooses actions; the harness organizes environment, tools, permissions, memory, teams, and external capabilities. -这就是全书的终点:机制很多,循环一个。 +This is where the s01–s20 main line converges: every component in the same loop. + +And that loop is always single-step and model-driven — each round, the model picks one tool. When the shape of an orchestration is already fixed (parallel fan-out, per-item pipeline, resume from a break), instead of having the model drive it round by round, write it as a deterministic, resumable script. + +What's next: [s21 Workflow Runtime](../s21_workflow_runtime/) — the model owns each step, the script owns the orchestration. diff --git a/s20_comprehensive/README.zh.md b/s20_comprehensive/README.zh.md new file mode 100644 index 000000000..8068dbd1d --- /dev/null +++ b/s20_comprehensive/README.zh.md @@ -0,0 +1,254 @@ +# s20: Comprehensive Agent — 全部机制,归到一个循环 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s18 → s19 → `s20` + +> *"机制很多,循环一个"* — 工具、权限、记忆、任务、团队、插件都挂在同一个 while True 上。 +> +> **Harness 层**: 综合 — 把前 19 章的机制放回同一个可运行系统。 + +--- + +## 问题 + +前 19 章每章只加一个机制。这样适合学习,但真实 Agent 不会只带一个机制运行。 + +一个能长期工作的 coding agent 需要同时拥有: + +- 工具分发和权限边界 +- hooks 扩展点 +- todo 计划和任务图 +- 技能、记忆、系统 prompt 组装 +- 压缩和错误恢复 +- 后台任务和 cron 调度 +- 团队、协议、自治认领 +- worktree 隔离 +- MCP 外部工具接入 + +难点不是把功能堆起来,而是看清楚它们都挂在循环的哪个位置。S20 就是终点章:把所有组件归位。 + +--- + +## 解决方案 + +![System Architecture](images/system-architecture.svg) + +S20 不是再发明一个新机制,而是把前面的教学组件合成一个完整 harness: + +```text +用户输入 + → UserPromptSubmit hooks + → cron/background 通知注入 + → context compact + → memory + skills + MCP 状态组装 system prompt + → LLM + → has tool_use block? + 否 → Stop hooks → 返回 + 是 → PreToolUse hooks + permission + → TOOL_HANDLERS / MCP handlers / background dispatch + → PostToolUse hooks + → tool_result / task_notification 回 messages + → 下一轮 +``` + +循环本身仍然是同一个结构:调用模型,检查响应里是否出现 `tool_use` block,执行工具,把结果追加回 `messages`。Claude Code 源码里也不直接信任 `stop_reason == "tool_use"`,而是以实际出现的 tool_use block 作为是否继续工具轮的信号。变化的是循环周围的 harness 变完整了。 + +--- + +## 组件在循环中的位置 + +| 位置 | 组件 | 作用 | +|------|------|------| +| 用户输入前后 | `UserPromptSubmit` hooks | 记录、注入、审计用户输入 | +| LLM 前 | cron queue | 把定时触发的 prompt 注入 `messages` | +| LLM 前 | background notifications | 后台任务完成后以 `` 注入 | +| LLM 前 | compaction pipeline | 先压大输出,再裁历史,再压旧 tool_result,必要时摘要 | +| LLM 前 | memory / skills / MCP state | 组装 system prompt,让模型看到当前能力和长期上下文 | +| LLM 调用 | error recovery | 429/529 重试,`max_tokens` 升级,prompt too long 触发 reactive compact | +| 工具执行前 | `PreToolUse` hooks + permission | 拦截危险命令、写越界、破坏性 MCP 工具 | +| 工具分发 | `assemble_tool_pool` | 组装内置工具和 MCP 动态工具 | +| 工具执行时 | background dispatch | 慢 bash 操作放 daemon thread,主循环先返回占位结果 | +| 工具执行后 | `PostToolUse` hooks | 大输出告警、日志等后处理 | +| 返回循环 | tool_result | 每个 `tool_use` 对应一个 `tool_result`,再回到下一轮 | +| 本轮没有 tool_use / 停止时 | `Stop` hooks | 统计、清理、审计 | + +--- + +## code.py 包含什么 + +### 工具与分发 + +内置工具池包含 27 个工具: + +```text +bash, read_file, write_file, edit_file, glob +todo_write, task, load_skill, compact +create_task, list_tasks, get_task, claim_task, complete_task +schedule_cron, list_crons, cancel_cron +spawn_teammate, send_message, check_inbox +request_shutdown, request_plan, review_plan +create_worktree, remove_worktree, keep_worktree +connect_mcp +``` + +`assemble_tool_pool()` 每轮组装: + +```text +BUILTIN_TOOLS + connected MCP tools +BUILTIN_HANDLERS + mcp__server__tool handlers +``` + +所以 `connect_mcp("docs")` 后,下一轮工具池里会出现 `mcp__docs__search`。 + +### 权限和 hooks + +权限不写死在工具执行行里,而是作为 `PreToolUse` hook: + +```python +blocked = trigger_hooks("PreToolUse", block) +if blocked: + results.append(tool_result(block.id, blocked)) + continue +``` + +这样 permission、log、审计都可以挂在同一个 hook 点上。执行后再触发 `PostToolUse`。 + +### 计划与任务 + +S20 同时保留两层计划: + +- `todo_write`:当前会话内的轻量计划,保存在内存中 +- task graph:跨会话、可依赖、可认领的任务文件,写入 `.tasks/task_*.json` + +前者帮助单个 Agent 不漂移;后者支撑团队协作。 + +### 子 agent 与团队 + +S20 有两种 delegation: + +- `task`:一次性 subagent。独立 `messages[]`,中间过程丢弃,只返回最终摘要。 +- `spawn_teammate`:持久队友线程。通过 MessageBus 收发消息,能 idle 轮询任务板并自动认领。 + +一次性 subagent 解决“上下文隔离”;持久队友解决“长期并行协作”。 + +### 记忆、技能和 prompt + +`assemble_system_prompt(context)` 每轮组装: + +- 身份和工具说明 +- workspace +- skills catalog +- `.memory/MEMORY.md` +- 已连接 MCP server + +技能只在 system prompt 里放目录。完整内容通过 `load_skill(name)` 按需加载。 + +### 压缩和恢复 + +LLM 前先跑压缩管线: + +```text +tool_result_budget → snip_compact → micro_compact → compact_history +``` + +调用模型时再包一层恢复: + +- 429:指数退避重试 +- 529:指数退避,连续失败可切 fallback model +- `max_tokens`:先提高 max_tokens,再要求 continuation +- prompt too long:reactive compact 后重试 + +### 后台和 cron + +慢 bash 操作不会阻塞主循环: + +```text +should_run_background → start_background_task → placeholder tool_result +后台完成 → task_notification → 下一轮注入 messages +``` + +cron 调度器独立 daemon thread 每秒检查一次。CLI 会监听 `cron_queue`,命中后主动把 `[Scheduled] ...` 注入并运行一轮 Agent。 + +### worktree 与 MCP + +worktree 负责隔离目录: + +- `create_worktree(name, task_id)` 创建独立分支和目录 +- task 的 `worktree` 字段绑定目录 +- 队友 claim 到带 worktree 的 task 后,bash/read/write 自动在对应目录下执行 + +MCP 负责外部能力: + +- `connect_mcp(name)` 连接 mock server +- `assemble_tool_pool()` 把 MCP 工具组装进工具池 +- 工具名统一为 `mcp__server__tool` + +--- + +## 相对 s19 的变化 + +| 组件 | s19 | s20 | +|------|-----|-----| +| 工具池 | 内置 + MCP | 内置 + MCP,补齐 s01-s18 的工具 | +| 权限 | 教学主体省略 | `PreToolUse` hook 中执行 | +| hooks | 省略 | UserPromptSubmit / PreToolUse / PostToolUse / Stop | +| todo | 省略 | `todo_write` + reminder | +| skill | 省略 | catalog in system prompt + `load_skill` | +| compact | 省略 | LLM 前压缩 + `compact` 工具 + reactive compact | +| error recovery | 简化 try/except | retry / max_tokens / prompt too long | +| background | 省略 | 慢操作后台线程 + task notification | +| cron | 省略 | daemon scheduler + durable jobs | +| multi-agent | 保留 | 保留;队友使用隔离目录下的基础工具 | +| worktree | 保留 | 保留 | +| MCP | 新增 | 保留,作为最终工具池的一部分 | + +--- + +## 试一下 + +```sh +cd learn-claude-code +python s20_comprehensive/code.py +``` + +可以试: + +1. `Create a todo list for inspecting this repo, then list Python files` +2. `Connect to the docs MCP server and search for agent loop` +3. `Create two tasks, create worktrees for them, then spawn alice and bob. Ask them to submit plans before claiming tasks.` +4. `remind me of the meeting in 3 minutes.` +5. `Run npm install in the background and continue reading README.md` + +观察重点: + +- 工具调用前是否经过 hooks/permission +- `connect_mcp` 后下一轮是否出现 MCP 工具 +- 慢操作是否返回 background placeholder +- 到点是不是自动提醒开会 +- 队友是否提交 plan,并在 approval 前暂停 +- plan 批准后,队友是否能认领任务 +- worktree 绑定后,队友是否切到对应目录 + +--- + +## 结束亦是开始 + +从 s01 到 s20,代码表面越来越复杂,但核心始终没变: + +```python +while True: + response = LLM(messages, tools) + if not has_tool_use(response.content): + return + results = execute_tools(response.content) + messages.append(tool_results) +``` + +Claude Code 的复杂性不是“另一个 agent 大脑”,而是一个成熟 harness 的复杂性。模型负责判断和行动选择;harness 负责把环境、工具、权限、记忆、团队和外部能力组织好。 + +这是 s01–s20 主线的收束:所有组件都集合在同一个循环里。 + +而这个循环始终是单步、模型驱动的——每一轮,模型挑一个工具。当编排的形状已经固定(并行扇出、逐项流水、断点续跑),与其让模型一轮轮驱动,不如把它写成一段确定性、可恢复的脚本。 + +接下来:[s21 Workflow Runtime](../s21_workflow_runtime/) — 模型决定单步,脚本决定编排。 diff --git a/s20_comprehensive/code.py b/s20_comprehensive/code.py index 543bab112..f802f3507 100644 --- a/s20_comprehensive/code.py +++ b/s20_comprehensive/code.py @@ -1589,7 +1589,7 @@ def _mock_server_deploy(): client.register( tool_defs=[ {"name": "trigger", - "description": "Trigger a deployment. (destructive — requires approval in real CC)", + "description": "Trigger a deployment. (destructive — requires approval in real Claude Code)", "inputSchema": {"type": "object", "properties": {"service": {"type": "string"}}, "required": ["service"]}}, diff --git a/s20_comprehensive/images/system-architecture.en.svg b/s20_comprehensive/images/system-architecture.en.svg deleted file mode 100644 index 01ac3dfbd..000000000 --- a/s20_comprehensive/images/system-architecture.en.svg +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - s20 Comprehensive Agent — Every Mechanism Around One Loop - - Core Agent Loop - - messages[] - - - Before LLM - cron/background injection - compact + memory + prompt - - - LLM - stop_reason=tool_use? - - - Before Tools - PreToolUse hooks - permission pipeline - - - handlers - builtin + MCP - - tool_result / task_notification → messages[] → next turn - - Context & Knowledge - s07 skills + load_skill - s09 memory selection - s10 prompt sections - s08 compact pipeline - - - Governance - s03 permission - s04 hooks - s11 retry / fallback - Stop hooks - - - Durable Work - s05 todo_write - s12 task graph - s13 background - s14 cron scheduler - - - Teams & Plugins - s06 subagent - s15-s17 team protocols - s18 worktree isolation - s19 MCP tools - - - TOOL POOL: 27 builtins + dynamic mcp__server__tool - file/shell: bash · read · write · edit · glob - single-agent: todo_write · task · load_skill · compact - durable work: task tools · cron tools - team: spawn_teammate · send_message · check_inbox - protocol: request_shutdown · request_plan · review_plan - isolation/plugin: worktree tools · connect_mcp - - diff --git a/s20_comprehensive/images/system-architecture.ja.svg b/s20_comprehensive/images/system-architecture.ja.svg deleted file mode 100644 index 0461be007..000000000 --- a/s20_comprehensive/images/system-architecture.ja.svg +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - s20 Comprehensive Agent — すべての仕組みを 1 つのループへ - - Core Agent Loop - - messages[] - - - LLM 前 - cron/background 注入 - compact + memory + prompt - - - LLM - stop_reason=tool_use? - - - Tool 前 - PreToolUse hooks - permission pipeline - - - handlers - builtin + MCP - - tool_result / task_notification → messages[] → 次のターン - - Context / Knowledge - s07 skills + load_skill - s09 memory selection - s10 prompt sections - s08 compact pipeline - - - Governance - s03 permission - s04 hooks - s11 retry / fallback - Stop hooks - - - Durable Work - s05 todo_write - s12 task graph - s13 background - s14 cron scheduler - - - Teams / Plugins - s06 subagent - s15-s17 team protocols - s18 worktree isolation - s19 MCP tools - - - TOOL POOL: 27 builtins + dynamic mcp__server__tool - file/shell: bash · read · write · edit · glob - single-agent: todo_write · task · load_skill · compact - durable work: task tools · cron tools - team: spawn_teammate · send_message · check_inbox - protocol: request_shutdown · request_plan · review_plan - isolation/plugin: worktree tools · connect_mcp - - diff --git a/s20_comprehensive/images/system-architecture.svg b/s20_comprehensive/images/system-architecture.svg index 72e52f887..45ee92adc 100644 --- a/s20_comprehensive/images/system-architecture.svg +++ b/s20_comprehensive/images/system-architecture.svg @@ -1,105 +1,195 @@ - + - - - - - - - - - - - + - - + + - - - - s20 Comprehensive Agent — 全部机制挂在同一个循环上 - - - - 核心 Agent Loop - - - messages[] - - - - - LLM 前处理 - cron / background 注入 - compact + memory + prompt - - - - - LLM - stop_reason=tool_use? - - - - - 工具前闸门 - PreToolUse hooks - permission pipeline - - - - - handlers - builtin + MCP - - - tool_result / task_notification → messages[] → 下一轮 - - - - 上下文与知识 - s07 skills catalog + load_skill - s09 memory selection - s10 prompt sections - s08 compact pipeline - - - - 治理与扩展点 - s03 permission - s04 hooks - s11 retry / fallback - Stop hooks - - - - 持久工作 - s05 todo_write - s12 task graph - s13 background - s14 cron scheduler - - - - 团队与插件 - s06 subagent - s15-s17 team protocols - s18 worktree isolation - s19 MCP tools - - - - - TOOL POOL: 27 builtins + dynamic mcp__server__tool - file/shell: bash · read · write · edit · glob - single-agent: todo_write · task · load_skill · compact - durable work: create/list/get/claim/complete_task · schedule/list/cancel_cron - team: spawn_teammate · send_message · check_inbox - protocol: request_shutdown · request_plan · review_plan - isolation/plugin: create/remove/keep_worktree · connect_mcp - - + + + + + Comprehensive Agent — All Mechanisms, One Loop + Every component converges in the same while-True loop + + + + + + Core Agent Loop + + + + messages[] + + + + + + + LLM Pre-processing + cron / background inject + compact + memory + prompt + + + + + + + LLM + stop_reason= + tool_use? + + + + + + + Pre-Tool Gate + PreToolUse hooks + permission pipeline + + + + + + + Tool Handlers + builtin + MCP + PostToolUse hooks + + + + + + + + tool_result / task_notification → messages[] → next round + + + + no tool_use + + + + Stop hooks + + + + + + Return Result + + + + + + + + Context & Knowledge + skills catalog + load_skill + memory selection + system prompt assembly + compact pipeline + + + + + + Governance & Extensions + permission pipeline + hooks (Pre/Post/Stop) + retry / fallback + error recovery + + + + + + Durable Work + todo_write (session plan) + task graph (cross-session) + background dispatch + cron scheduler + + + + + + Teams & Plugins + subagent (one-shot) + team protocols + worktree isolation + MCP external tools + + + + + + + + + + + + + TOOL POOL: 27 builtins + dynamic mcp__server__tool + + + + + + file/shell + bash + read_file + write_file + edit_file + glob + + + single-agent + todo_write + task + load_skill + compact + + + durable work + create_task + list_tasks + get_task + claim_task + complete_task + schedule_cron + list_crons + cancel_cron + + + team + spawn_teammate + send_message + check_inbox + + + protocol + request_shutdown + request_plan + review_plan + + + isolation + create_worktree + remove_worktree + keep_worktree + plugin + connect_mcp + + + + + + + \ No newline at end of file diff --git a/s21_workflow_runtime/README.ja.md b/s21_workflow_runtime/README.ja.md new file mode 100644 index 000000000..2822f28ca --- /dev/null +++ b/s21_workflow_runtime/README.ja.md @@ -0,0 +1,227 @@ +# s21: Workflow Runtime — モデルは単ステップ、スクリプトはオーケストレーション + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s19 → s20 → `s21` + +> *"1 回の tool_use で、バックグラウンドでオーケストレーション一式を走らせる"* — `Workflow` ツールが決定的で再開可能な script runtime を起動し、subagent の群れを fan out する。 +> +> **Harness 層**: オーケストレーション — single-agent loop の上に、決定的な multi-agent script runtime を一層加える。 + +--- + +## 問題 + +s01 から s20 まで、loop は model 駆動で単ステップだ:毎 round で model が tool を 1 つ選び、結果を `messages[]` に戻し、また 1 round。オープンエンドな task ではこれが最善——次に何をするかは、context を見て model にその場で決めさせる。 + +しかし、一部の仕事は **agent の群れを決定的にオーケストレーション**する必要がある。大きな変更を review する例:10 個の dimension を並行で問題探し → 各 finding にそれぞれ adversarial な verification を dispatch → 集約して dedup → severity で sort。このオーケストレーションの形は固定で、欲しいのは: + +- **並行**、1 つずつ待たない; +- **決定的**、同じ入力で同じ構造が出る; +- **再開可能**、途中で落ちても、もう終わった部分はやり直さない。 + +これを model が main loop で 1 ステップずつ駆動するのは、遅いし、非決定的だし、落ちたら最初からだ。このとき欲しいのは「もう 1 round 会話する」ことではなく、**オーケストレーションをコードとして書く**ことだ。 + +## 解決策 + +Claude Code は tool pool に `Workflow` ツールを置く。あなた(あるいは `ultracode` trigger 下の model)がそれに **script** を渡し、script は `agent() / parallel() / pipeline() / phase()` という primitive で、オーケストレーションを決定的なコードとして書く。 + +main loop は 1 回の `tool_use` だけを見て、**即座に** `async_launched` を受け取る——本当の実行は**バックグラウンド runtime** の中で進み、progress を報告し、journal を disk に書く。script 内の中間結果は変数に入り、会話には入らない。`resumeFromRunId` は変更していない `agent()` を journal cache にヒットさせ、中断点から再開できる。 + +![Workflow Runtime 概観](images/workflow-runtime-overview.svg) + +計画は会話の 1 round ではなく、コードだ: + +```python +SAMPLE_META = {"name": "review-changes", "description": "...", "phases": ["Review", "Verify"]} + +async def sample_workflow(ctx, args): + ctx.phase("Review") + results = await ctx.pipeline(DIMENSIONS, audit, verify) # 各 dimension が独立で audit -> verify を走る + confirmed = [f for r in results if r for f in r["confirmed"]] + ctx.log(f"confirmed {len(confirmed)} real finding(s)") + return {"confirmed": confirmed} +``` + +## 仕組み + +### Workflow ツール:バックグラウンド起動、main loop は 1 回の tool_use だけ + +`Workflow`(別名 `RunWorkflow`)は main agent の tool pool にある。trigger が来る——明示的な「workflow を実行/作成」、保存済みの `/コマンド`、あるいは `ultracode` の高 effort path——と、model は `Workflow(...)` の `tool_use` を出す。`WorkflowTool.call` が引数を parse し、meta を検証し、permission を通し、`local_workflow` task を登録し、そして **即座に** `async_launched` を返す。main loop は block せず先へ進む;workflow はバックグラウンドで走る。 + +```python +class WorkflowTool: + async def call(self, meta, script_fn, args=None, resume_from_run_id=None): + validate_meta(meta) + check_permission(meta) + run_id = resume_from_run_id or create_run_id(meta) + task = LocalWorkflowTask(create_task_id(run_id), run_id, meta) + task.event("async_launched", runId=run_id, taskId=task.task_id) # 即座に返す + ... # 残りはバックグラウンド +``` + +> 実際の Claude Code:tool は即座に `{status:'async_launched', taskId, taskType:'local_workflow', runId, summary, transcriptDir, scriptPath}` を返し、バックグラウンド task は後で完了する。 + +### script と meta:最初の文 + +script の**最初の文**は必ず `export const meta = { name, description, phases }` で、しかも純粋な literal でなければならない——変数も、関数呼び出しも、連結も不可。runtime は何かを実行する前にまずそれを parse する:`name`/`description` が task と UI を駆動し、`phases` が progress group に名前を付ける。不正な入力は直ちに `WorkflowInputError`。 + +```python +def validate_meta(meta): + if not meta.get("name") or not meta.get("description"): + raise WorkflowInputError("meta requires `name` and `description`") + if "phases" in meta and not isinstance(meta["phases"], list): + raise WorkflowInputError("meta.phases must be a list") + return meta +``` + +> 実際の Claude Code:`parseWorkflowScript` は meta が最初の文かつ純粋な literal であることを強制する;教学版は dict をそのまま受け取る。 + +### オーケストレーション primitive:agent / parallel / pipeline / phase / log / workflow + +script は 1 つの context の中で走り、その中で使える global は**これらのオーケストレーション primitive だけ**だ。script 自身は file を直接読み書きせず、shell も走らせない——本当の codebase の読み書きは、**subagent** が自分の tool permission で行う。primitive は `ExecutionState` の method だ: + +| primitive | 役割 | +|------|------| +| `agent(prompt, {schema, label, phase})` | subagent を 1 つ fan out | +| `parallel(thunks)` | **barrier**:全部を並行で走らせ、まとめて待つ | +| `pipeline(items, *stages)` | item ごとに stage 分け、**barrier なし** | +| `phase(title)` | progress group(upsert) | +| `log(message)` | progress 行 | +| `workflow(name, args)` | nested sub-workflow(1 層のみ) | + +`pipeline` がデフォルト——各 item が独立で全 stage を通り、item A が stage 3 にいる間に item B はまだ stage 1 かもしれない;「前 stage の全結果をまとめて」が本当に必要なときだけ `parallel` という barrier を使う。 + +```python +async def pipeline(self, items, *stages): + async def run_item(item, idx): + value = item + for stage in stages: # 各 item が独立で全 stage を走る + value = await stage(value, item, idx) + return value + return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)]) +``` + +> 実際の Claude Code:同名の primitive は VM が script context に注入する;さらに `args`、`budget`(`budget.total/spent/remaining`)、agent 数上限(1000)、並行 semaphore もある。 + +### 構造化出力:agent({schema}) + StructuredOutput + +`agent({schema})` は subagent に schema に一致した JSON object を返させる(1 回の `StructuredOutput` 呼び出し経由)。runtime は schema で検証し、不一致なら 1 回 retry する。こうして下流のコードが消費するのは、また parse し直す散文ではなく、**object** だ。 + +```python +result = self.runner.run(prompt, schema, label) +if schema is not None: + ok, err = SimpleJsonSchema(schema).validate(result) + if not ok: # 1 回 nudge して retry、ダメなら raise + result = self.runner.run(prompt + "\n\nReturn valid JSON.", schema, label) + ok, err = SimpleJsonSchema(schema).validate(result) + if not ok: + raise WorkflowInputError(f"agent({{schema}}) invalid output: {err}") +``` + +> 実際の Claude Code:`SimpleJsonSchema` + `StructuredOutput` tool + schema retry。 + +### バックグラウンド task と progress event + +`LocalWorkflowTask` は status/usage を持ち、SDK 風の event stream を外へ出す:`task_started` → 一連の `task_progress`(`workflow_phase` / `workflow_agent` / `workflow_log` の batch を載せる)→ 最後の `task_notification`(completed / failed / stopped、output file・token 数・tool call 数・経過時間付き)。main session はこれらを event として見る;loop に再び入るのは最後の notification だけだ。 + +```python +class LocalWorkflowTask: + def progress_event(self, ptype, **data): # workflow_phase / workflow_agent / workflow_log + self.progress.append({"type": ptype, **data}) + print(f" progress {ptype} ...") +``` + +> 実際の Claude Code:progress は task status に畳み込まれ、`task_progress.workflow_progress` として UI/SDK へ送られる。 + +### ストレージ:snapshot + journal + +run が終わると 5 つのものを書く。すべて `~/.claude/projects///` の下だ:snapshot `.json`、output `.output.json`、journal `.journal.jsonl`、script `scripts/.js`、subagent transcript `subagents/workflows//`。保存済みの workflow は `.claude/workflows/`(project)か `~/.claude/workflows/`(user)に置く。 + +journal が resume の鍵だ——各 `agent()` の結果を、1 行ずつ記録する: + +```python +class WorkflowJournal: + def record(self, key, value): + self._f.write(json.dumps({"key": key, "value": value}) + "\n") + self._f.flush() + self.cache[key] = value +``` + +### resume:runId から cache を再利用 + +`Workflow({scriptPath, resumeFromRunId, args})` は **script を再実行する**が、各 `agent()` は**決定的な semantic key** を計算する:journal にある key はその場で cache 結果を返し(再実行しない)、変更していないものは全部ヒット;変更したものとその後だけが本当に走る。 + +肝は、key が**並行順序に依存してはいけない**ことだ——`parallel`/`pipeline` 内の agent の完了順は不定なので、key は競合する counter ではなく、呼び出し内容(kind・label・prompt・schema)の安定 hash から計算する。 + +```python +def key(self, kind, label, prompt, schema): + basis = f"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}" + return f"{kind}-{_stable_hash(basis) % 10**10:010d}" + +# agent() 内: +cached = self.journal.cached(key) +if cached is not MISS: + self.task.progress_event("workflow_agent", label=label, status="cached") + return cached +``` + +> 実際の Claude Code:同じく「決定的 semantic key + journal cache」;同一 session 内で resume 前に完了した `agent()` は cache を返し、その後のものは実際に走る。 + +### 決定性:再現可能が前提 + +resume が成立するには、script が再現可能でなければならない。だから runtime は `Date.now()`、引数なしの `new Date()`、`Math.random()` を script context から取り除き、Node API も与えない。同じ script + 同じ args → 同じ key → 100% cache ヒット。教学版は安定 hash で key を計算して同じ効果に達する(実際の版はこれらの非決定的なソースを除いた sandbox VM の中で JS script 全体を走らせる)。 + +### まとめて走らせる + +サンプル workflow `review-changes`:`pipeline` が各 review dimension を独立に「audit → verify」へ通す——audit は schema 付きの `agent()` で問題を探し、verify は `parallel()` で各 finding にそれぞれ adversarial な verification subagent を割り当て、最後に `isReal` のものだけを残して severity で sort する。 + +```python +async def sample_workflow(ctx, args): + ctx.phase("Review") + + async def audit(_v, dimension, _i): + out = await ctx.agent(f"Review the changed files for {dimension} issues.", + schema=FINDINGS_SCHEMA, label=f"audit:{dimension}", phase="Review") + return {"dimension": dimension, "findings": out["findings"]} + + async def verify(audited, dimension, _i): + ctx.phase("Verify") + verdicts = await ctx.parallel([ # 各 finding を独立で adversarial に verify + (lambda f=f: ctx.agent(f"Adversarially verify ... {f['title']}", + schema=VERDICT_SCHEMA, label=f"verify:{dimension}:{f['title']}")) + for f in audited["findings"]]) + return {"dimension": dimension, + "confirmed": [f for f, v in zip(audited["findings"], verdicts) if v and v["isReal"]]} + + results = await ctx.pipeline(DIMENSIONS, audit, verify) + ... +``` + +## s20 からの変更 + +| | s20 総合体 | s21 Workflow Runtime | +|--|-----------|---------------------| +| loop | 単一、model 駆動 | main loop は不変;その上に決定的なオーケストレーション層 | +| 次のステップを誰が決めるか | model が round ごとに | script があらかじめ書き定める | +| 複数 agent | s06 subagent、一度きりの fan-out | スクリプト化・再現可能・再開可能な batch オーケストレーション | +| 新しい仕組み | — | script DSL、バックグラウンド task、progress event、journal/resume、構造化出力、決定的 VM | + +s21 は main loop を置き換えない——tool layer に `Workflow` を露出し、その裏で `local_workflow` runtime を起動する:**1 つの workflow が N 個の agent loop を決定的に駆動する**。s06 の subagent は model がその場で一度 fan out するもの;s21 はオーケストレーションを再生可能な script として書くものだ。 + +## 試す + +```bash +python s21_workflow_runtime/code.py # review-changes を起動、event stream を見る +python s21_workflow_runtime/code.py resume # 前回の runId で再開、各 agent() が journal cache にヒット +``` + +観察:1 回の launch → `async_launched` → バックグラウンドの `workflow_phase` / `workflow_agent` progress → `task_notification`;結果は task に残る。`resume` では `agents=0 tokens=0`(全部 cache ヒット)、結果は 1 文字も違わない。 + +## これから + +オーケストレーションは agent 能力の上のもう 1 層だ:**main loop は単ステップを、script はチーム全体を司る**。仕事を決定的で再開可能な script として書けば、model は「round ごとの駆動者」から「script に scheduling される実行ユニット」へと変わる——同じ `agent()` が、main loop では model にその場で呼ばれ、workflow では script に batch でオーケストレーションされる。 + +これから:[s22 Goal Loop](../s22_goal_loop/) — オーケストレーションは仕事を扇出して main loop を離れる;次章はその逆で、目標が制御を引き戻す:満たされるまで turn を終わらせない。 + + diff --git a/s21_workflow_runtime/README.md b/s21_workflow_runtime/README.md new file mode 100644 index 000000000..dd09c3341 --- /dev/null +++ b/s21_workflow_runtime/README.md @@ -0,0 +1,227 @@ +# s21: Workflow Runtime — The Model Owns Each Step, the Script Owns the Orchestration + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s19 → s20 → `s21` + +> *"One tool_use, a whole orchestration runs in the background"* — the `Workflow` tool launches a deterministic, resumable script runtime that fans out a fleet of subagents. +> +> **Harness layer**: Orchestration — above the single-agent loop, a deterministic multi-agent script runtime. + +--- + +## The problem + +From s01 to s20 the loop is model-driven and single-step: each round the model picks one tool, the result goes back into `messages[]`, and another round begins. For open-ended tasks this is the best you can do — let the model decide the next step on the spot, looking at the context. + +But some work needs to **orchestrate a fleet of agents deterministically**. Take reviewing a large change: find problems across ten dimensions in parallel → dispatch an adversarial verification for each finding → merge and dedup → sort by severity. The shape of this orchestration is fixed, and what you want is: + +- **parallel**, not waiting one at a time; +- **deterministic**, the same input producing the same structure; +- **resumable**, so that if it dies halfway through, the parts already done are not redone. + +Having the model drive all of this step by step inside the main loop is slow, non-deterministic, and starts over when interrupted. At that point what you want is not "one more chat round" but to **write the orchestration as a piece of code**. + +## The solution + +Claude Code puts a `Workflow` tool in the tool pool. You (or the model, under an `ultracode` trigger) hand it a **script**, and the script uses the primitives `agent() / parallel() / pipeline() / phase()` to write the orchestration as deterministic code. + +The main loop sees a single `tool_use` and **immediately** gets back `async_launched` — the real execution proceeds in a **background runtime** that reports progress and writes a journal to disk. Intermediate results live in script variables, not in the conversation. `resumeFromRunId` lets any unchanged `agent()` hit the journal cache and resume from where it stopped. + +![Workflow Runtime overview](images/workflow-runtime-overview.svg) + +The plan is code, not a chat turn: + +```python +SAMPLE_META = {"name": "review-changes", "description": "...", "phases": ["Review", "Verify"]} + +async def sample_workflow(ctx, args): + ctx.phase("Review") + results = await ctx.pipeline(DIMENSIONS, audit, verify) # each dimension runs audit -> verify independently + confirmed = [f for r in results if r for f in r["confirmed"]] + ctx.log(f"confirmed {len(confirmed)} real finding(s)") + return {"confirmed": confirmed} +``` + +## How it works + +### The Workflow tool: launches in the background, the main loop sees one tool_use + +`Workflow` (aliased `RunWorkflow`) sits in the main agent's tool pool. A trigger arrives — an explicit "run/build workflow", a saved `/command`, or the high-effort `ultracode` path — and the model emits a `Workflow(...)` `tool_use`. `WorkflowTool.call` parses the arguments, validates the meta, passes permission, registers a `local_workflow` task, and **returns immediately** with `async_launched`. The main loop does not block; it keeps going while the workflow runs in the background. + +```python +class WorkflowTool: + async def call(self, meta, script_fn, args=None, resume_from_run_id=None): + validate_meta(meta) + check_permission(meta) + run_id = resume_from_run_id or create_run_id(meta) + task = LocalWorkflowTask(create_task_id(run_id), run_id, meta) + task.event("async_launched", runId=run_id, taskId=task.task_id) # returns immediately + ... # the rest runs in the background +``` + +> Real Claude Code: the tool returns immediately with `{status:'async_launched', taskId, taskType:'local_workflow', runId, summary, transcriptDir, scriptPath}`, and the background task completes later. + +### Script and meta: the first statement + +The script's **first statement** must be `export const meta = { name, description, phases }`, and it must be a pure literal — no variables, function calls, or interpolation. The runtime parses it before executing anything: `name`/`description` drive the task and the UI, `phases` name the progress groups. Bad input raises `WorkflowInputError` outright. + +```python +def validate_meta(meta): + if not meta.get("name") or not meta.get("description"): + raise WorkflowInputError("meta requires `name` and `description`") + if "phases" in meta and not isinstance(meta["phases"], list): + raise WorkflowInputError("meta.phases must be a list") + return meta +``` + +> Real Claude Code: `parseWorkflowScript` enforces that meta is the first statement and a pure literal; the teaching version just takes a dict. + +### Orchestration primitives: agent / parallel / pipeline / phase / log / workflow + +The script runs in a context whose **only** useful globals are these orchestration primitives. The script itself does not read/write files or run a shell — the actual codebase reads and writes are done by **subagents** through their own tool permissions. The primitives are methods on `ExecutionState`: + +| Primitive | What it does | +|------|------| +| `agent(prompt, {schema, label, phase})` | fan out a subagent | +| `parallel(thunks)` | **barrier**: run all concurrently, wait for all together | +| `pipeline(items, *stages)` | per-item, staged, **no barrier** | +| `phase(title)` | progress group (upsert) | +| `log(message)` | a progress line | +| `workflow(name, args)` | nested sub-workflow (one level only) | + +`pipeline` is the default — each item passes through all stages independently, so item A can be at stage 3 while item B is still at stage 1; reach for the `parallel` barrier only when you genuinely need "all of the previous stage's results at once". + +```python +async def pipeline(self, items, *stages): + async def run_item(item, idx): + value = item + for stage in stages: # each item runs through all stages independently + value = await stage(value, item, idx) + return value + return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)]) +``` + +> Real Claude Code: the same primitives are injected into the script context by the VM; there are also `args`, `budget` (`budget.total/spent/remaining`), an agent-count cap (1000), and a concurrency semaphore. + +### Structured output: agent({schema}) + StructuredOutput + +`agent({schema})` forces the subagent to return a JSON object that matches the schema (via a single `StructuredOutput` call); the runtime validates it against the schema and retries once on a mismatch. This way downstream code consumes an **object**, not prose it has to parse again. + +```python +result = self.runner.run(prompt, schema, label) +if schema is not None: + ok, err = SimpleJsonSchema(schema).validate(result) + if not ok: # one nudge retry, then raise + result = self.runner.run(prompt + "\n\nReturn valid JSON.", schema, label) + ok, err = SimpleJsonSchema(schema).validate(result) + if not ok: + raise WorkflowInputError(f"agent({{schema}}) invalid output: {err}") +``` + +> Real Claude Code: `SimpleJsonSchema` + the `StructuredOutput` tool + schema retry. + +### Background task and progress events + +`LocalWorkflowTask` holds the status/usage and emits an SDK-style event stream: `task_started` → a series of `task_progress` (carrying batches of `workflow_phase` / `workflow_agent` / `workflow_log`) → a final `task_notification` (completed / failed / stopped, with the output file, token count, tool-call count, and elapsed time). The main session treats these as events; only the final notification re-enters the loop. + +```python +class LocalWorkflowTask: + def progress_event(self, ptype, **data): # workflow_phase / workflow_agent / workflow_log + self.progress.append({"type": ptype, **data}) + print(f" progress {ptype} ...") +``` + +> Real Claude Code: progress is folded into the task status and sent to the UI/SDK as `task_progress.workflow_progress`. + +### Storage: snapshot + journal + +When a run finishes it writes five things, all under `~/.claude/projects///`: a snapshot `.json`, the output `.output.json`, the journal `.journal.jsonl`, the script `scripts/.js`, and the subagent transcripts `subagents/workflows//`. Saved workflows live in `.claude/workflows/` (project) or `~/.claude/workflows/` (user). + +The journal is the key to resume — it records the result of every `agent()`, one line at a time: + +```python +class WorkflowJournal: + def record(self, key, value): + self._f.write(json.dumps({"key": key, "value": value}) + "\n") + self._f.flush() + self.cache[key] = value +``` + +### resume: reuse the cache from a runId + +`Workflow({scriptPath, resumeFromRunId, args})` **re-runs the script**, but each `agent()` computes a **deterministic semantic key**: a key already in the journal returns its cached result directly (no re-run), so everything unchanged is a hit; only the changed one and whatever follows it actually runs. + +The key here is that the key **must not depend on concurrency order** — inside `parallel`/`pipeline` the completion order of agents is undefined, so the key is computed from a stable hash of the call's content (kind, label, prompt, schema), not from a counter that would race. + +```python +def key(self, kind, label, prompt, schema): + basis = f"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}" + return f"{kind}-{_stable_hash(basis) % 10**10:010d}" + +# inside agent(): +cached = self.journal.cached(key) +if cached is not MISS: + self.task.progress_event("workflow_agent", label=label, status="cached") + return cached +``` + +> Real Claude Code: the same "deterministic semantic key + journal cache"; within the same session, an `agent()` that completed before resume returns the cache, and what comes after it actually runs. + +### Determinism: reproducibility is the prerequisite + +For resume to hold, the script has to be reproducible. So the runtime strips `Date.now()`, the argless `new Date()`, and `Math.random()` out of the script context, and gives it no Node API either. The same script + the same args → the same key → a 100% cache hit. The teaching version reaches the same effect by computing the key from a stable hash (the real version runs the whole JS script in a sandbox VM with those non-deterministic sources removed). + +### Putting it together + +The example workflow `review-changes`: `pipeline` runs each review dimension through "audit → verify" independently — audit uses an `agent()` with a schema to find problems, verify uses `parallel()` to dispatch one adversarial verification subagent per finding, and at the end only the `isReal` ones survive, sorted by severity. + +```python +async def sample_workflow(ctx, args): + ctx.phase("Review") + + async def audit(_v, dimension, _i): + out = await ctx.agent(f"Review the changed files for {dimension} issues.", + schema=FINDINGS_SCHEMA, label=f"audit:{dimension}", phase="Review") + return {"dimension": dimension, "findings": out["findings"]} + + async def verify(audited, dimension, _i): + ctx.phase("Verify") + verdicts = await ctx.parallel([ # verify each finding adversarially, independently + (lambda f=f: ctx.agent(f"Adversarially verify ... {f['title']}", + schema=VERDICT_SCHEMA, label=f"verify:{dimension}:{f['title']}")) + for f in audited["findings"]]) + return {"dimension": dimension, + "confirmed": [f for f, v in zip(audited["findings"], verdicts) if v and v["isReal"]]} + + results = await ctx.pipeline(DIMENSIONS, audit, verify) + ... +``` + +## Changes from s20 + +| | s20 Comprehensive | s21 Workflow Runtime | +|--|-----------|---------------------| +| Loop | single, model-driven | main loop unchanged; a deterministic orchestration layer on top | +| Who decides the next step | the model, round by round | the script, with the orchestration written ahead of time | +| Multiple agents | s06 subagents, a one-shot fan-out | scripted, reproducible, resumable batch orchestration | +| New mechanisms | — | script DSL, background task, progress events, journal/resume, structured output, deterministic VM | + +s21 does not replace the main loop — it exposes `Workflow` at the tool layer, and behind it starts a `local_workflow` runtime: **one workflow deterministically drives N agent loops**. The s06 subagent is the model fanning out once on the spot; s21 is the orchestration written as a replayable script. + +## Try it + +```bash +python s21_workflow_runtime/code.py # launch review-changes, watch the event stream +python s21_workflow_runtime/code.py resume # resume from the last runId, every agent() hits the journal cache +``` + +Watch: one launch → `async_launched` → background `workflow_phase` / `workflow_agent` progress → `task_notification`; the result stays on the task. On `resume`, `agents=0 tokens=0` (all cache hits), and the result is identical down to the character. + +## What's next + +Orchestration is one more layer on top of agent capability: **the main loop owns the step, the script owns the whole squad**. Write the work as a deterministic, resumable script and the model turns from a "round-by-round driver" into an "execution unit scheduled by a script" — the same `agent()` can be called on the spot by the model inside the main loop, or orchestrated in batches by a script inside a workflow. + +What's next: [s22 Goal Loop](../s22_goal_loop/) — orchestration fans work out, away from the main loop; the next chapter reverses it, with a goal pulling control back in: until it's met, the turn isn't allowed to end. + + diff --git a/s21_workflow_runtime/README.zh.md b/s21_workflow_runtime/README.zh.md new file mode 100644 index 000000000..96bde1ee0 --- /dev/null +++ b/s21_workflow_runtime/README.zh.md @@ -0,0 +1,227 @@ +# s21: Workflow Runtime — 模型决定单步,脚本决定编排 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s19 → s20 → `s21` + +> *"一次 tool_use,后台跑完一整套编排"* — `Workflow` 工具启动一个确定性、可恢复的脚本运行时,扇出一群子 agent。 +> +> **Harness 层**: 编排 — 在单 agent 循环之上,加一层确定性的多 agent 脚本运行时。 + +--- + +## 问题 + +s01 到 s20,循环是模型驱动、单步的:每一轮模型挑一个工具,结果塞回 `messages[]`,再来一轮。开放式任务这样最好——下一步做什么,让模型看着上下文临场决定。 + +但有些活儿需要**确定性地编排一群 agent**。比如审一个大改动:十个维度并行找问题 → 每条发现各自派一个对抗性验证 → 汇总去重 → 按严重度排序。这种编排的形状是固定的,你想要的是: + +- **并行**,不是一个个串着等; +- **确定**,同样的输入跑出同样的结构; +- **可恢复**,跑到一半断了,已经做完的部分别重来。 + +让模型在主循环里一步步驱动这套,又慢、又不确定、断了还得从头。这时候你想要的不是"再聊一轮",而是**把编排写成一段代码**。 + +## 解决方案 + +Claude Code 在工具池里放一个 `Workflow` 工具。你(或者模型在 `ultracode` 触发下)给它一段**脚本**,脚本用 `agent() / parallel() / pipeline() / phase()` 这几个原语,把编排写成确定性的代码。 + +主循环只看到一次 `tool_use`,并**立即**拿到 `async_launched`——真正的执行在一个**后台运行时**里推进,上报进度、落盘 journal。脚本里的中间结果存在变量里,不进对话。`resumeFromRunId` 能让没改过的 `agent()` 命中 journal 缓存,断点续跑。 + +![Workflow Runtime 总览](images/workflow-runtime-overview.svg) + +计划是代码,不是一个聊天轮次: + +```python +SAMPLE_META = {"name": "review-changes", "description": "...", "phases": ["Review", "Verify"]} + +async def sample_workflow(ctx, args): + ctx.phase("Review") + results = await ctx.pipeline(DIMENSIONS, audit, verify) # 每个维度独立走 审计 → 验证 + confirmed = [f for r in results if r for f in r["confirmed"]] + ctx.log(f"confirmed {len(confirmed)} real finding(s)") + return {"confirmed": confirmed} +``` + +## 工作原理 + +### Workflow 工具:后台启动,主循环只见一次 tool_use + +`Workflow`(别名 `RunWorkflow`)在主 agent 的工具池里。一个触发到来——显式的"跑/建 workflow"、一个保存好的 `/命令`、或 `ultracode` 高强度路径——模型就发出一个 `Workflow(...)` 的 `tool_use`。`WorkflowTool.call` 解析入参、校验 meta、过权限、注册一个 `local_workflow` 任务,然后**立即返回** `async_launched`。主循环不阻塞,继续往下;workflow 在后台跑。 + +```python +class WorkflowTool: + async def call(self, meta, script_fn, args=None, resume_from_run_id=None): + validate_meta(meta) + check_permission(meta) + run_id = resume_from_run_id or create_run_id(meta) + task = LocalWorkflowTask(create_task_id(run_id), run_id, meta) + task.event("async_launched", runId=run_id, taskId=task.task_id) # 立即返回 + ... # 其余在后台 +``` + +> 真实 Claude Code:工具立刻返回 `{status:'async_launched', taskId, taskType:'local_workflow', runId, summary, transcriptDir, scriptPath}`,后台任务稍后完成。 + +### 脚本与 meta:第一条语句 + +脚本的**第一条语句**必须是 `export const meta = { name, description, phases }`,而且是个纯字面量——不能有变量、函数调用、拼接。运行时在执行任何东西之前先解析它:`name`/`description` 驱动任务和 UI,`phases` 给进度分组命名。坏输入直接 `WorkflowInputError`。 + +```python +def validate_meta(meta): + if not meta.get("name") or not meta.get("description"): + raise WorkflowInputError("meta requires `name` and `description`") + if "phases" in meta and not isinstance(meta["phases"], list): + raise WorkflowInputError("meta.phases must be a list") + return meta +``` + +> 真实 Claude Code:`parseWorkflowScript` 强制 meta 必须是第一条语句且是纯字面量;教学版直接收一个 dict。 + +### 编排原语:agent / parallel / pipeline / phase / log / workflow + +脚本跑在一个上下文里,里面有用的全局量**只有**这几个编排原语。脚本本身不直接读写文件、不跑 shell——真正的代码库读写由**子 agent**通过它们自己的工具权限完成。原语是 `ExecutionState` 上的方法: + +| 原语 | 作用 | +|------|------| +| `agent(prompt, {schema, label, phase})` | 扇出一个子 agent | +| `parallel(thunks)` | **屏障**:并发跑完全部、一起等回来 | +| `pipeline(items, *stages)` | 逐项分阶段、**无屏障** | +| `phase(title)` | 进度分组(upsert) | +| `log(message)` | 进度行 | +| `workflow(name, args)` | 嵌套子工作流(仅一层) | + +`pipeline` 是默认选择——每个 item 独立穿过所有 stage,item A 在 stage 3 时 item B 可能还在 stage 1;只有真需要"拿到全部上一阶段结果"时才用 `parallel` 这个屏障。 + +```python +async def pipeline(self, items, *stages): + async def run_item(item, idx): + value = item + for stage in stages: # 每个 item 独立走完所有 stage + value = await stage(value, item, idx) + return value + return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)]) +``` + +> 真实 Claude Code:同名原语由 VM 注入脚本上下文;还有 `args`、`budget`(`budget.total/spent/remaining`)、agent 数上限(1000)、并发信号量。 + +### 结构化输出:agent({schema}) + StructuredOutput + +`agent({schema})` 强制子 agent 返回一个匹配 schema 的 JSON 对象(通过一次 `StructuredOutput` 调用),运行时按 schema 校验、不匹配就重试一次。这样下游代码消费的是**对象**,不是要再解析的散文。 + +```python +result = self.runner.run(prompt, schema, label) +if schema is not None: + ok, err = SimpleJsonSchema(schema).validate(result) + if not ok: # 一次 nudge 重试,再不行就报错 + result = self.runner.run(prompt + "\n\nReturn valid JSON.", schema, label) + ok, err = SimpleJsonSchema(schema).validate(result) + if not ok: + raise WorkflowInputError(f"agent({{schema}}) invalid output: {err}") +``` + +> 真实 Claude Code:`SimpleJsonSchema` + `StructuredOutput` 工具 + schema 重试。 + +### 背景任务与进度事件 + +`LocalWorkflowTask` 持有 status/usage,并向外发出一条 SDK 风格的事件流:`task_started` → 一串 `task_progress`(装着 `workflow_phase` / `workflow_agent` / `workflow_log` 批次)→ 最后一个 `task_notification`(completed / failed / stopped,带 output 文件、token 数、工具调用数、耗时)。主会话把这些当事件看;只有最终的 notification 会重新进入循环。 + +```python +class LocalWorkflowTask: + def progress_event(self, ptype, **data): # workflow_phase / workflow_agent / workflow_log + self.progress.append({"type": ptype, **data}) + print(f" progress {ptype} ...") +``` + +> 真实 Claude Code:进度折叠进任务状态,作为 `task_progress.workflow_progress` 发给 UI/SDK。 + +### 存储:快照 + journal + +跑完写五样东西,都落在 `~/.claude/projects///` 下:快照 `.json`、输出 `.output.json`、journal `.journal.jsonl`、脚本 `scripts/.js`、子 agent transcript `subagents/workflows//`。保存好的 workflow 放 `.claude/workflows/`(项目)或 `~/.claude/workflows/`(用户)。 + +journal 是 resume 的关键——它逐条记下每个 `agent()` 的结果: + +```python +class WorkflowJournal: + def record(self, key, value): + self._f.write(json.dumps({"key": key, "value": value}) + "\n") + self._f.flush() + self.cache[key] = value +``` + +### resume:从 runId 复用缓存 + +`Workflow({scriptPath, resumeFromRunId, args})` 会**重跑脚本**,但每个 `agent()` 会算一个**确定性的语义 key**:key 在 journal 里就直接返回缓存结果(不重跑),没改过的全部命中;改过的那个及它之后的才真跑。 + +关键在于 key **不能依赖并发顺序**——`parallel`/`pipeline` 里 agent 的完成次序是不定的,所以 key 由调用内容(kind、label、prompt、schema)的稳定哈希算出,而不是一个会竞争的计数器。 + +```python +def key(self, kind, label, prompt, schema): + basis = f"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}" + return f"{kind}-{_stable_hash(basis) % 10**10:010d}" + +# agent() 里: +cached = self.journal.cached(key) +if cached is not MISS: + self.task.progress_event("workflow_agent", label=label, status="cached") + return cached +``` + +> 真实 Claude Code:同样是"确定性语义 key + journal 缓存";同会话内 resume 完成过的 `agent()` 返回缓存、之后的实跑。 + +### 确定性:可复现是前提 + +resume 要成立,脚本就得可复现。所以运行时把 `Date.now()`、无参 `new Date()`、`Math.random()` 从脚本上下文里去掉,也不给 Node API。同一份脚本 + 同样的 args → 同样的 key → 100% 缓存命中。教学版用稳定哈希算 key 来达到同样的效果(真实版是把整段 JS 脚本跑在去掉这些非确定源的沙箱 VM 里)。 + +### 合起来跑 + +示例 workflow `review-changes`:`pipeline` 把每个审查维度独立地走"审计 → 验证"——审计用一个带 schema 的 `agent()` 找问题,验证用 `parallel()` 给每条发现各派一个对抗性验证子 agent,最后只留 `isReal` 的、按严重度排序。 + +```python +async def sample_workflow(ctx, args): + ctx.phase("Review") + + async def audit(_v, dimension, _i): + out = await ctx.agent(f"Review the changed files for {dimension} issues.", + schema=FINDINGS_SCHEMA, label=f"audit:{dimension}", phase="Review") + return {"dimension": dimension, "findings": out["findings"]} + + async def verify(audited, dimension, _i): + ctx.phase("Verify") + verdicts = await ctx.parallel([ # 每条发现独立对抗性验证 + (lambda f=f: ctx.agent(f"Adversarially verify ... {f['title']}", + schema=VERDICT_SCHEMA, label=f"verify:{dimension}:{f['title']}")) + for f in audited["findings"]]) + return {"dimension": dimension, + "confirmed": [f for f, v in zip(audited["findings"], verdicts) if v and v["isReal"]]} + + results = await ctx.pipeline(DIMENSIONS, audit, verify) + ... +``` + +## 相对 s20 的变更 + +| | s20 综合体 | s21 Workflow Runtime | +|--|-----------|---------------------| +| 循环 | 单个、模型驱动 | 主循环不变;之上加一层确定性编排 | +| 谁决定下一步 | 模型逐轮决定 | 脚本预先写定编排 | +| 多 agent | s06 子 agent,一次性扇出 | 脚本化、可复现、可恢复的批量编排 | +| 新增机制 | — | 脚本 DSL、后台 task、进度事件、journal/resume、结构化输出、确定性 VM | + +s21 不替换主循环——它在 tool layer 暴露 `Workflow`,背后启动一个 `local_workflow` 运行时:**一个 workflow 确定性地驱动 N 个 agent 循环**。s06 的子 agent 是模型临场扇出一次;s21 是把编排写成可重放的脚本。 + +## 试一下 + +```bash +python s21_workflow_runtime/code.py # 启动 review-changes,看事件流 +python s21_workflow_runtime/code.py resume # 用上次 runId 续跑,每个 agent() 命中 journal 缓存 +``` + +观察:一次 launch → `async_launched` → 后台 `workflow_phase` / `workflow_agent` 进度推进 → `task_notification`;结果留在 task 上。`resume` 时 `agents=0 tokens=0`(全部缓存命中),结果一字不差。 + +## 接下来 + +编排是 agent 能力之上的又一层:**主循环管单步,脚本管整支队伍**。把工作写成确定性、可恢复的脚本,模型就从"逐轮驱动者"变成了"被脚本调度的执行单元"——同一个 `agent()`,既能在主循环里被模型临场调用,也能在 workflow 里被脚本批量编排。 + +接下来:[s22 Goal Loop](../s22_goal_loop/) — 编排把工作扇出去、脱离主循环;下一章反过来,一个目标把控制权重入主循环,没达成就不让 turn 结束。 + + diff --git a/s21_workflow_runtime/code.py b/s21_workflow_runtime/code.py new file mode 100644 index 000000000..6192704ad --- /dev/null +++ b/s21_workflow_runtime/code.py @@ -0,0 +1,514 @@ +""" +s21_workflow_runtime — Dynamic Workflow runtime (teaching version) + +Clean-room behavioral reconstruction of Claude Code's `Workflow` tool / dynamic +workflow runtime. Grounded in @anthropic-ai/claude-code@2.1.177 observed +behavior (reverse-research/cc_workflow), NOT leaked source. + +Idea: + s01-s20 build a single, model-driven agent loop. s21 adds a deterministic + orchestration LAYER on top: the main loop exposes a `Workflow` tool that + launches a background runtime; a script written with agent()/parallel()/ + pipeline()/phase() drives many subagents deterministically, reports progress, + persists a journal, and can resume from a runId. + +Run: + python code.py # run the sample workflow, print the event stream + python code.py resume # resume the last run; unchanged agent() calls hit cache + +Teaching simplifications (vs real runtime.mjs): + - The "subagent" is a deterministic MockAgentRunner, not a real LLM. + - A workflow is a plain async Python function, not a sandboxed JS script + string. The real runtime runs the script in an isolated JS VM with + Date.now()/Math.random() removed so resume is reproducible. + - Storage is a local .runtime/ dir instead of ~/.claude/projects/.../workflows/. +""" + +import asyncio +import hashlib +import json +import sys +from pathlib import Path + +# ---- knobs that mirror the real runtime's guards ---- +AGENT_CAP = 1000 # hard cap on agent() calls per run +CONCURRENCY = 8 # parallelism cap (semaphore) +STORE = Path(__file__).parent / ".runtime" # snapshots + journals live here +MISS = object() # journal cache miss sentinel + + +def _stable_hash(s: str) -> int: + """Process-stable hash (Python's hash() is salted per process, which would + break resume keys across `run` and `resume`).""" + return int(hashlib.sha256(s.encode()).hexdigest(), 16) + + +def create_run_id(meta) -> str: + # Deterministic in the teaching version so the journal path is predictable + # and `resume` lands on the same file. The real runtime mints a random id. + return f"wf_{meta['name']}_{_stable_hash(meta['name']) % 10000:04d}" + + +def create_task_id(run_id) -> str: + return f"local_workflow_{run_id}" + + +# ============================================================ +# Errors +# ============================================================ +class WorkflowInputError(Exception): + """Bad script / meta / schema input (mirrors WorkflowInputError).""" + + +# ============================================================ +# meta validation +# ============================================================ +def validate_meta(meta): + """Real runtime requires `export const meta = {...}` as the FIRST statement, + a pure literal, with name + description (+ optional phases). We take a dict.""" + if not isinstance(meta, dict): + raise WorkflowInputError("meta must be an object literal") + if not meta.get("name") or not meta.get("description"): + raise WorkflowInputError("meta requires `name` and `description`") + if "phases" in meta and not isinstance(meta["phases"], list): + raise WorkflowInputError("meta.phases must be a list") + return meta + + +def check_permission(meta, settings=None): + """allow / deny / ask gate before launch (s03 permission system, applied to + Workflow). Teaching version allows by default; a deny rule blocks.""" + settings = settings or {} + if meta["name"] in settings.get("deny", []): + raise WorkflowInputError(f"workflow '{meta['name']}' denied by settings") + return "allow" + + +# ============================================================ +# Minimal JSON-schema for structured output (SimpleJsonSchema) +# ============================================================ +class SimpleJsonSchema: + """Tiny validator backing agent({schema}). Just enough for teaching: + object/array/string/boolean/number + required keys.""" + + def __init__(self, schema): + self.schema = schema + + def validate(self, value, schema=None): + schema = self.schema if schema is None else schema + t = schema.get("type") + if t == "object": + if not isinstance(value, dict): + return False, "expected object" + for key in schema.get("required", []): + if key not in value: + return False, f"missing required key '{key}'" + for key, sub in schema.get("properties", {}).items(): + if key in value: + ok, err = self.validate(value[key], sub) + if not ok: + return False, f"{key}: {err}" + return True, None + if t == "array": + if not isinstance(value, list): + return False, "expected array" + items = schema.get("items") + if items: + for i, el in enumerate(value): + ok, err = self.validate(el, items) + if not ok: + return False, f"[{i}]: {err}" + return True, None + if t == "string": + return (isinstance(value, str), None if isinstance(value, str) else "expected string") + if t == "boolean": + return (isinstance(value, bool), None if isinstance(value, bool) else "expected boolean") + if t in ("number", "integer"): + ok = isinstance(value, (int, float)) and not isinstance(value, bool) + return (ok, None if ok else "expected number") + return True, None + + +def _fill_schema(schema, seed): + """Deterministic generic filler used for schemas the mock doesn't special-case.""" + t = schema.get("type") + if t == "object": + keys = schema.get("required") or list(schema.get("properties", {})) + return {k: _fill_schema(schema["properties"][k], f"{seed}/{k}") for k in keys} + if t == "array": + return [_fill_schema(schema["items"], f"{seed}/0")] + if t == "boolean": + return _stable_hash(seed) % 4 != 0 + if t in ("number", "integer"): + return _stable_hash(seed) % 5 + return seed.rsplit("/", 1)[-1] + + +# ============================================================ +# Subagent runner (mock for teaching; real path = an LLM tool loop) +# ============================================================ +class MockAgentRunner: + """Stands in for a spawned subagent. Deterministic so resume is reproducible. + A real runner would run an isolated agent loop that calls repo tools and is + forced to emit StructuredOutput when a schema is present.""" + + def run(self, prompt, schema=None, label=None): + if schema is None: + return f"[mock] {(label or prompt)[:60]}" + props = schema.get("properties", {}) + if "findings" in props: # an audit agent + n = 1 + (_stable_hash(prompt) % 2) # 1-2 findings + sev = ["high", "medium", "low"] + return {"findings": [ + {"title": f"{label or 'audit'} #{i + 1}", + "severity": sev[_stable_hash(prompt + str(i)) % 3]} + for i in range(n) + ]} + if "isReal" in props: # a verifier agent + real = _stable_hash(prompt) % 4 != 0 # ~75% confirmed + return {"isReal": real, + "reason": "reproduced" if real else "could not reproduce"} + return _fill_schema(schema, prompt) + + @staticmethod + def tokens(prompt, result): + return len(prompt) // 4 + len(json.dumps(result, default=str)) // 4 + + +# ============================================================ +# Journal (resume cache): started/result per agent under a semantic key +# ============================================================ +class WorkflowJournal: + """Append-only .journal.jsonl. On resume, agent() calls whose + semantic key is already present are replayed from cache instead of re-run.""" + + def __init__(self, run_id, resume, store=STORE): + store.mkdir(parents=True, exist_ok=True) + self.path = store / f"{run_id}.journal.jsonl" + self.resume = resume + self.cache = {} + if resume and self.path.exists(): + for line in self.path.read_text().splitlines(): + rec = json.loads(line) + self.cache[rec["key"]] = rec["value"] + self._f = self.path.open("a") + else: + self._f = self.path.open("w") # fresh run truncates + + def key(self, kind, label, prompt, schema): + # Deterministic semantic key — independent of concurrency order, so a + # parallel/pipeline call gets the same key on resume. + basis = f"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}" + return f"{kind}-{_stable_hash(basis) % 10**10:010d}" + + def cached(self, key): + return self.cache.get(key, MISS) + + def record(self, key, value): + self._f.write(json.dumps({"key": key, "value": value}) + "\n") + self._f.flush() + self.cache[key] = value + + def close(self): + self._f.close() + + +# ============================================================ +# Token budget +# ============================================================ +class Budget: + """budget.total / spent() / remaining(). Once spent reaches total, agent() + calls raise (the real runtime enforces the same ceiling).""" + + def __init__(self, total=None): + self.total = total + self._spent = 0 + + def add(self, n): + self._spent += n + + def spent(self): + return self._spent + + def remaining(self): + return float("inf") if self.total is None else max(0, self.total - self._spent) + + +# ============================================================ +# Background task state + progress events (the outer event stream) +# ============================================================ +class LocalWorkflowTask: + """type local_workflow. Holds status/usage and emits the SDK-like event + stream: task_started, task_progress (workflow_phase/agent/log), task_notification.""" + + def __init__(self, task_id, run_id, meta): + self.task_id = task_id + self.run_id = run_id + self.meta = meta + self.status = "running" + self.usage = {"agents": 0, "tokens": 0} + self.progress = [] + + def event(self, name, **data): + line = " ".join(f"{k}={v}" for k, v in data.items()) + print(f" event {name:<18} {line}") + + def progress_event(self, ptype, **data): + self.progress.append({"type": ptype, **data}) + line = " ".join(f"{k}={v}" for k, v in data.items()) + print(f" progress {ptype:<16} {line}") + + +# ============================================================ +# ExecutionState: the DSL the workflow script sees as `ctx` +# ============================================================ +class ExecutionState: + """Injected into the workflow script. Provides the orchestration primitives. + Mirrors ExecutionState in runtime.mjs.""" + + def __init__(self, task, journal, runner, budget, args, depth=0): + self.task = task + self.journal = journal + self.runner = runner + self.budget = budget + self.args = args + self._depth = depth + self._phase = None + self._phases_seen = set() + self._agents = 0 + self._sem = asyncio.Semaphore(CONCURRENCY) + + def phase(self, title): + """Start a phase; subsequent agent()s group under it. Upsert: emitting the + same phase again (e.g. from each pipeline item) does not re-announce it.""" + self._phase = title + if title not in self._phases_seen: + self._phases_seen.add(title) + self.task.progress_event("workflow_phase", title=title) + + def log(self, message): + """Emit a workflow_log progress line.""" + self.task.progress_event("workflow_log", message=message) + + async def agent(self, prompt, schema=None, label=None, phase=None): + """Spawn one subagent. With a schema, force StructuredOutput + validate + (retry once). On resume, a cached key short-circuits the run.""" + label = label or (prompt[:24] + "…") + self._agents += 1 + if self._agents > AGENT_CAP: + raise WorkflowInputError(f"agent() cap reached ({AGENT_CAP})") + if self.budget.remaining() <= 0: + raise WorkflowInputError("token budget exceeded") + + key = self.journal.key("agent", label, prompt, schema) + cached = self.journal.cached(key) + if cached is not MISS: + self.task.progress_event("workflow_agent", label=label, + phase=phase or self._phase, status="cached") + return cached + + async with self._sem: + await asyncio.sleep(0) # yield: real subagents are async + result = self.runner.run(prompt, schema, label) + + if schema is not None: + ok, err = SimpleJsonSchema(schema).validate(result) + if not ok: # one nudge/retry, then fail + result = self.runner.run(prompt + "\n\nReturn valid JSON.", schema, label) + ok, err = SimpleJsonSchema(schema).validate(result) + if not ok: + raise WorkflowInputError(f"agent({{schema}}) invalid output: {err}") + + toks = self.runner.tokens(prompt, result) + self.budget.add(toks) + self.task.usage["agents"] += 1 + self.task.usage["tokens"] += toks + self.journal.record(key, result) + self.task.progress_event("workflow_agent", label=label, + phase=phase or self._phase, status="done") + return result + + async def parallel(self, thunks): + """BARRIER: run all thunks concurrently, await all. A thunk that throws + resolves to None (filter before use).""" + async def safe(t): + try: + return await t() + except Exception: + return None + return await asyncio.gather(*[safe(t) for t in thunks]) + + async def pipeline(self, items, *stages): + """Per-item staged flow, NO barrier between stages: item A can be in + stage 3 while item B is still in stage 1. Each stage gets + (prev_result, original_item, index). A throwing stage drops that item.""" + async def run_item(item, idx): + value = item + for stage in stages: + try: + value = await stage(value, item, idx) + except Exception: + return None + return value + return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)]) + + async def workflow(self, name, args=None): + """Run a saved workflow inline as a child (one level), sharing this run's + journal + budget + agent counter.""" + if self._depth >= 1: + raise WorkflowInputError("workflow() nesting is one level only") + if name not in WORKFLOWS: + raise WorkflowInputError(f"unknown workflow '{name}'") + meta, fn = WORKFLOWS[name] + child = ExecutionState(self.task, self.journal, self.runner, self.budget, + args or {}, depth=self._depth + 1) + return await fn(child, args or {}) + + +# ============================================================ +# WorkflowTool: the tool entry (WorkflowTool.call) +# ============================================================ +class WorkflowTool: + """The Workflow tool. .call() validates meta, runs the permission check, + creates runId/taskId, registers a LocalWorkflowTask, and runs the script in + the background — driving progress, persisting the journal, returning the + final result. Supports resumeFromRunId. Mirrors WorkflowTool.call in runtime.mjs.""" + + async def call(self, meta, script_fn, args=None, resume_from_run_id=None): + validate_meta(meta) + check_permission(meta) + args = args or {} + run_id = resume_from_run_id or create_run_id(meta) + task_id = create_task_id(run_id) + resuming = resume_from_run_id is not None + journal = WorkflowJournal(run_id, resume=resuming) + + task = LocalWorkflowTask(task_id, run_id, meta) + # The real tool returns this immediately and runs the rest in background. + launched = {"status": "async_launched", "taskId": task_id, + "taskType": "local_workflow", "runId": run_id, + "workflowName": meta["name"]} + task.event("async_launched", runId=run_id, taskId=task_id) + task.event("task_started", workflow=meta["name"], + phases=",".join(meta.get("phases", [])) or "-", + resume=resuming) + + ctx = ExecutionState(task, journal, MockAgentRunner(), Budget(args.get("budget")), args) + try: + result = await script_fn(ctx, args) + task.status = "completed" + except Exception as e: # failed / stopped close the loop too + task.status = "failed" + result = {"error": str(e)} + finally: + journal.close() + + _write_json(STORE / f"{run_id}.output.json", result) + _save_last_run(run_id) + task.event("task_notification", status=task.status, + agents=task.usage["agents"], tokens=task.usage["tokens"], + outputFile=f".runtime/{run_id}.output.json") + return {"launched": launched, "result": result, "task": task} + + +def _write_json(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, default=str)) + + +def _save_last_run(run_id): + (STORE / "last_run.txt").write_text(run_id) + + +def _read_last_run(): + p = STORE / "last_run.txt" + return p.read_text().strip() if p.exists() else None + + +# ============================================================ +# Sample workflow: review changed code across dimensions, verify each finding. +# Mirrors cc_workflow/runtime/workflows/review_workflow.js (pipeline + parallel). +# ============================================================ +FINDINGS_SCHEMA = { + "type": "object", "required": ["findings"], + "properties": {"findings": {"type": "array", "items": { + "type": "object", "required": ["title", "severity"], + "properties": {"title": {"type": "string"}, "severity": {"type": "string"}}}}}, +} +VERDICT_SCHEMA = { + "type": "object", "required": ["isReal", "reason"], + "properties": {"isReal": {"type": "boolean"}, "reason": {"type": "string"}}, +} + +SAMPLE_META = { + "name": "review-changes", + "description": "Review changed files across dimensions, verify each finding", + "phases": ["Review", "Verify"], +} + +DIMENSIONS = ["correctness", "security", "performance", "style"] + + +async def sample_workflow(ctx, args): + """pipeline over review dimensions (audit -> verify-each), then keep only the + findings a verifier confirms. The plan is code, not a chat turn.""" + ctx.phase("Review") + + async def audit(_value, dimension, _idx): + out = await ctx.agent( + f"Review the changed files for {dimension} issues.", + schema=FINDINGS_SCHEMA, label=f"audit:{dimension}", phase="Review") + return {"dimension": dimension, "findings": out["findings"]} + + async def verify(audited, dimension, _idx): + ctx.phase("Verify") + # Each finding is verified by its own adversarial subagent, concurrently. + verdicts = await ctx.parallel([ + (lambda f=f: ctx.agent( + f"Adversarially verify this {dimension} finding — is it real? {f['title']}", + schema=VERDICT_SCHEMA, label=f"verify:{dimension}:{f['title']}", phase="Verify")) + for f in audited["findings"]]) + confirmed = [f for f, v in zip(audited["findings"], verdicts) + if v and v.get("isReal")] + return {"dimension": dimension, "confirmed": confirmed} + + results = await ctx.pipeline(DIMENSIONS, audit, verify) + confirmed = [{"dimension": r["dimension"], **f} + for r in results if r for f in r["confirmed"]] + confirmed.sort(key=lambda f: {"high": 0, "medium": 1, "low": 2}.get(f["severity"], 3)) + ctx.log(f"confirmed {len(confirmed)} real finding(s)") + return {"confirmed": confirmed} + + +# saved workflow registry (.claude/workflows/ analogue) +WORKFLOWS = {SAMPLE_META["name"]: (SAMPLE_META, sample_workflow)} + + +# ============================================================ +# Demo +# ============================================================ +async def main(argv): + resume_id = None + if argv and argv[0] == "resume": + resume_id = _read_last_run() + if not resume_id: + print("nothing to resume — run `python code.py` first.") + return + print(f"resuming {resume_id} — unchanged agent() calls hit the journal cache\n") + else: + print("launching workflow `review-changes`\n") + + tool = WorkflowTool() + out = await tool.call(SAMPLE_META, sample_workflow, + args={"budget": None}, resume_from_run_id=resume_id) + + print("\nresult:") + for f in out["result"].get("confirmed", []): + print(f" [{f['severity']:<6}] {f['dimension']}: {f['title']}") + t = out["task"] + print(f"\nstatus={t.status} agents={t.usage['agents']} tokens={t.usage['tokens']}" + f" journal=.runtime/{t.run_id}.journal.jsonl") + + +if __name__ == "__main__": + asyncio.run(main(sys.argv[1:])) diff --git a/s21_workflow_runtime/images/workflow-runtime-overview.svg b/s21_workflow_runtime/images/workflow-runtime-overview.svg new file mode 100644 index 000000000..281cd2e39 --- /dev/null +++ b/s21_workflow_runtime/images/workflow-runtime-overview.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + Workflow Runtime — one tool_use launches a background orchestration + the main loop calls Workflow like any tool; a deterministic runtime fans out subagents in the background and can resume + + + + Main session loop + + + + append tool_result / notification -> messages[] (loop continues) + + + + + + messages[] + message history + + + + + LLM + tool_use? + + + + + Workflow({script, args}) + (or name | scriptPath) · resumeFromRunId + + + + + tool_result + async_launched + + + + later + + + + task_notification + completed · final report + + + + Background workflow runtime — local_workflow + + + + WorkflowTool.call + validate meta · permission + runId · taskId + + + + LocalWorkflowTask + status · usage + progress events + + + + Script VM — runs the script + phase · agent() + parallel · pipeline + + + + + + Subagents × N + isolated ctx + schema output + + + + Journal + started / result + per agent() + + + + agent() + spawns + + + + record + + + + resumeFromRunId -> cached agent() + + + + + launch (async) + + + + task_progress: workflow_phase · workflow_agent · workflow_log + + + The runtime result stays on the task (scriptPath · transcripts · journal · output) — only the launch + final notification re-enter messages[]. + diff --git a/s22_goal_loop/README.ja.md b/s22_goal_loop/README.ja.md new file mode 100644 index 000000000..5d89939d8 --- /dev/null +++ b/s22_goal_loop/README.ja.md @@ -0,0 +1,170 @@ +# s22: Goal Loop — 終了を決めるのは目標、モデルではない + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s20 → s21 → `s22` + +> *"turn を終えてよいかは、モデルではなく目標条件が判定する"* — `/goal` は turn の終わり際に 1 つの gate を挿す:毎 turn 後、独立した evaluator が信頼できる証拠が条件を満たすか判定し、満たさなければ制御を次の turn へ押し戻す。 +> +> **Harness 層**: 目標ループ — turn の境界に、host が所有する完了 gate を 1 つ加える。 + +--- + +## 問題 + +s01 から s21 まで、turn はどう終わるか? model が `tool_use` を出さなくなると loop は `return` する。一度きりの task ならこれでいい——終わったら終わり。 + +しかし一部の目標は **複数の turn にまたがって追い続ける** 必要がある:「テストを green にする」「deploy が成功するまで」。よくある失敗は 2 つ:model が半分やって「まあ十分」と止まる;あるいは `tests passed` と打つだけで切り上げようとする。欲しいのは——**この turn を終えてよいかは model の判断ではなく、明示的な条件が信頼できる証拠に対して判定する**ことだ。 + +これは timer(s14 cron)でも、background task(s13)でも、model の自制でもない。host が turn の境界に加える gate だ。 + +## 解決策 + +`/goal <条件>` は session スコープの停止条件を設定する。host はそれを active goal として保存し、毎 turn 後、独立した small/fast の evaluator model が transcript の中の**信頼できる証拠**が条件を満たすか判定する。満たさない → gate がこの停止を塞ぎ、continuation を次の turn に送り込む;満たす → goal を clear し、達成を記録する。 + +![Goal Loop 概観](images/goal-loop-overview.svg) + +s01 の loop と比べると、増えるのは判定 1 つだけ——model が止まりたいとき、まず目標に訊く: + +```python +# s01:model が止まると言えば止まる +if not has_tool_use(response): + return +# s22:止まりたいとき、まず目標 gate を通す +if not has_tool_use(response): + verdict = goal.evaluate_after_turn() + if verdict == "continuing": + continue # 未達成 -> 押し戻してもう 1 turn + return # 達成 / 予算超過 / 目標なし -> 本当に止まる +``` + +## 仕組み + +### /goal:turn 境界の 1 つの gate + +`/goal` は session スコープの prompt ベース Stop hook だ。main loop の形は変えず、各 turn の終わりに `evaluate_after_turn()` を 1 つ差し込むだけ。この gate は **host が所有する**——model の自制ではない。model は 1 turn 引き止められたことすら知らず、ただ次の入力を受け取るだけだ。 + +```python +def submit(self, text, origin=None): + ... # 入力を記録、1 回の (mock) assistant turn を走らせる + return self.goal.evaluate_after_turn() # <-- turn 境界の Stop gate +``` + +> 実際の Claude Code:`/goal` は session スコープの Stop hook で、workspace trust と hook 制限で門制される;binary に `active_goal`、`goal_status`、`goal_met`、`tengu_goal_achieved` などの marker がある。 + +### 目標を設定:証拠ウィンドウは命令の後から始まる + +`set_goal` は active goal を保存する:目標テキスト、予算 `max_turns`、カウンタ、そして `start_index`——**証拠ウィンドウの起点**だ。現在の transcript 長を取るので、`/goal` 命令の行はすでにウィンドウの外にある。これが第一の防御:命令テキストは自分自身を満たせない。 + +```python +def set_goal(self, objective, max_turns=20): + self.active = { + "objective": objective, "status": "active", + "start_index": len(self.transcript), # 証拠ウィンドウはここから;命令自体はすでに外 + "max_turns": max_turns, "checks": 0, "continuation_turns": 0, + } +``` + +> 実際の Claude Code:`GoalRuntime.setGoal()` が activeGoal、startIndex、カウンタ、予算を保存する;提出後に `resetEvidenceStart()` でウィンドウを命令の直後に揃える。 + +### evaluator の判定:信頼できる証拠だけを認める + +ここが核心だ。evaluator は会話全体を見ず、証拠ウィンドウ内の**信頼できる origin** のメッセージだけを見る。3 つのフィルタが「達成に見えて実は違う」テキストを締め出す: + +```python +TRUSTED_EVIDENCE_ORIGINS = {"task-notification", "monitor-line"} + +def evidence_text(self): + out = [] + for m in self.transcript[self.active["start_index"]:]: + if m.origin.get("kind") == "slash-command": # 1 slash-command 由来は不可 + continue + if m.role == "user" and m.content.strip().startswith("/goal"): # 2 /goal 命令行は不可 + continue + if m.origin.get("kind") not in TRUSTED_EVIDENCE_ORIGINS: # 3 信頼できる origin だけ + continue + out.append(f"{m.role}: {m.content}") + return "\n".join(out) +``` + +効果:user が打った `tests passed` は認められず、`task-notification` が運んできた同じ行は認められる。model は誤魔化せない——自分の一言で goal を「達成」にはできない。教学版の `goal_satisfied()` は決定的な keyword チェック;実際の版はウィンドウを small/fast model に渡す。 + +> 実際の Claude Code:evaluator は worker と分離した small/fast model(marker `evaluatorModel`、`default small fast model`)で、任意の尤もらしさではなく transcript 証拠を判定する。 + +### gate の 3 状態:completed / continuing / blocked + +`evaluate_after_turn` は turn ごとに 1 回走り、出口は 3 つ:満たせば goal を clear(completed);満たさず予算が残っていれば continuation を enqueue して次の turn を走らせる(continuing);予算を使い切れば止める(blocked)——判定できない goal が無限に回り続けないように。 + +```python +def evaluate_after_turn(self): + g = self.active + g["checks"] += 1 + if self.goal_satisfied(): + g["status"] = "completed"; self.active = None + return "completed" # 達成 -> goal を clear + if g["continuation_turns"] < g["max_turns"]: + g["continuation_turns"] += 1 + self.queue.enqueue( + value="Continue working ... do not treat this reminder as completion evidence.", + origin={"kind": "active-goal"}) + return "continuing" # 未達成 -> continuation を enqueue + g["status"] = "blocked"; self.active = None + return "blocked" # 予算超過 -> もう塞がない +``` + +その continuation は `do not treat this reminder as completion evidence` という一文を自ら抱える——だから reminder テキスト自体も証拠から除外される。3 つの誤判定防御が揃う:命令テキスト、reminder テキスト、ただのテキスト、どれも達成にはならない。 + +> 実際の Claude Code:`evaluateAfterTurn` は `goal_evaluated` を出し、結果に応じて complete / continuation を enqueue / block する;デフォルト予算は `20`。 + +### continuation と外部 async inbox の分流 + +continuation は同じ `CommandQueue` に入るが、外部の async イベント(task 完了通知、monitor 行)とは **同じ drain ではない**。`dequeue` は switch を取る:外部 inbox の drain はデフォルトで active-goal の continuation を飛ばす。 + +```python +def dequeue(self, include_goal_continuations=True): + ... + for idx, item in enumerate(self.items): + if include_goal_continuations or item["origin"].get("kind") != "active-goal": + return self.items.pop(idx) + return None +``` + +なぜ分けるか:real-model テストで bug が見つかった——model が continuation を外部通知のように一緒に drain し、background 証拠が届く前に goal を死んだと判定してしまった。分流後は、goal の前進は明示的な一歩になり、async イベントに引きずられない。 + +> 実際の Claude Code:`drainCommandQueue` はデフォルトで `includeGoalContinuations=false`、active-goal continuation と外部 async inbox drain を分ける。 + +### まとめて走らせる + +`code.py` は `/goal until tests passed and deploy green` を走らせる:goal 設定後はまだ信頼できる証拠がない → gate が turn ごとに押し戻す;user が `tests passed` と打っても認められない(origin が信頼できない);background task が `task-notification` を届けると証拠が揃う → completed。さらに `max_turns=2` の goal で blocked を示す。 + +```python +s.submit("/goal until tests passed and deploy green") # goal 設定、ウィンドウは命令の後 +s.submit("tests passed, trust me") # ただのテキスト -> 達成にならない +s.submit("tests passed; deploy green", + origin={"kind": "task-notification"}) # 信頼できる証拠 -> completed +``` + +## s21 からの変更 + +| | s21 Workflow Runtime | s22 Goal Loop | +|--|---------------------|---------------| +| トリガー | スクリプト制御のオーケストレーション(main loop を離れる) | 条件制御の継続(main loop に再入する) | +| どこに付くか | tool layer:`Workflow` ツール 1 つ | turn 境界:完了 gate 1 つ | +| 誰が止めるか決めるか | スクリプトが終われば終わり | 目標条件が信頼できる証拠に対して判定 | +| 新しい仕組み | script DSL、background task、journal/resume、構造化出力 | 目標 gate、証拠の信頼境界、continuation 分流、予算 | + +s21 はオーケストレーションを script として書き、仕事を扇出して main loop から離す;s22 はその逆——制御を main loop に**再入**させる力だ:goal が満たされるまで、turn を終わらせない。どちらも s01 の `while` は変えず、両側から圧をかけるだけだ。 + +## 試す + +```bash +python s22_goal_loop/code.py # /goal until tests pass + deploy green、gate の判定を見る +``` + +観察:goal 設定後、毎 turn の終わりに `goal_evaluated` が出る;ただのテキストは `satisfied=False`、`task-notification` 由来は `satisfied=True`;予算を使い切ると `goal_blocked`。同じ `tests passed` でも、origin が違えば判定は逆になる——これが `/goal` が一言では騙されない理由だ。 + +## これから + +`/goal` は「main loop に再入する」トリガーの 1 つ:条件制御だ。s21 の「main loop を離れる」とちょうど対になる——一方は仕事を扇出し、もう一方は制御を引き戻す。さらに外には時間制御(`/loop`、cron)と事件制御(`Monitor`)の再入があり、同じ task / 通知の基盤を共有する;だが gate の核心はすでにここにある:**止まるか否かは model の一言ではなく、目標が信頼できる証拠に対して判定する。** + + diff --git a/s22_goal_loop/README.md b/s22_goal_loop/README.md new file mode 100644 index 000000000..3be56e2c8 --- /dev/null +++ b/s22_goal_loop/README.md @@ -0,0 +1,170 @@ +# s22: Goal Loop — The Goal Decides When to Stop, Not the Model + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s20 → s21 → `s22` + +> *"Whether a turn may end is decided by the goal condition, not the model"* — `/goal` adds a gate at the turn boundary: after every turn a separate evaluator judges whether trusted evidence satisfies the condition, and pushes control back into the next turn if it doesn't. +> +> **Harness layer**: Goal loop — a host-owned completion gate at the turn boundary. + +--- + +## The problem + +From s01 to s21, how does a turn end? The model stops emitting `tool_use` and the loop `return`s. For one-shot tasks that's fine — done is done. + +But some goals need to be **held across many turns**: "get the tests green", "until the deploy succeeds". Two failure modes are common: the model does half the work, decides it's close enough, and stops; or it just types `tests passed` and tries to wrap up. What you want is — **whether this turn may end is not the model's call; it's judged by an explicit condition, against trusted evidence.** + +This isn't a timer (s14 cron), not a background task (s13), and not the model policing itself. It's a gate the host adds at the turn boundary. + +## The solution + +`/goal ` sets a session-scoped stopping condition. The host stores it as an active goal, and after every turn a separate small/fast evaluator model judges whether the **trusted evidence** in the transcript meets the condition. Not met → the gate blocks the stop and feeds a continuation into the next turn; met → the goal is cleared and recorded as achieved. + +![Goal Loop overview](images/goal-loop-overview.svg) + +Against the s01 loop it's just one extra check — when the model wants to stop, ask the goal first: + +```python +# s01: the model says stop -> stop +if not has_tool_use(response): + return +# s22: when it wants to stop, pass the goal gate first +if not has_tool_use(response): + verdict = goal.evaluate_after_turn() + if verdict == "continuing": + continue # not met -> push it back for another turn + return # met / over budget / no goal -> really stop +``` + +## How it works + +### /goal: a gate at the turn boundary + +`/goal` is a session-scoped, prompt-based Stop hook. It doesn't change the shape of the main loop; it just inserts one `evaluate_after_turn()` at the end of each turn. The gate is **host-owned** — not the model restraining itself. The model doesn't even know it was held back a turn; it simply receives the next input. + +```python +def submit(self, text, origin=None): + ... # record input, run one (mock) assistant turn + return self.goal.evaluate_after_turn() # <-- the Stop gate at the turn boundary +``` + +> Real Claude Code: `/goal` is a session-scoped Stop hook, gated by workspace trust and hook restrictions; the binary carries markers like `active_goal`, `goal_status`, `goal_met`, `tengu_goal_achieved`. + +### Setting a goal: the evidence window starts after the command + +`set_goal` stores an active goal: the objective text, the `max_turns` budget, counters, and `start_index` — the **start of the evidence window**. It takes the current transcript length, so the `/goal` command line is already outside the window. That's the first guard: the command text can't satisfy itself. + +```python +def set_goal(self, objective, max_turns=20): + self.active = { + "objective": objective, "status": "active", + "start_index": len(self.transcript), # evidence window starts here; the command is already outside it + "max_turns": max_turns, "checks": 0, "continuation_turns": 0, + } +``` + +> Real Claude Code: `GoalRuntime.setGoal()` stores the activeGoal, startIndex, counters, and budget; right after submission `resetEvidenceStart()` aligns the window to just after the command. + +### The evaluator: only trusted evidence counts + +This is the heart of it. The evaluator doesn't read the whole conversation — only the messages from **trusted origins** inside the evidence window. Three filters keep "looks done but isn't" text out: + +```python +TRUSTED_EVIDENCE_ORIGINS = {"task-notification", "monitor-line"} + +def evidence_text(self): + out = [] + for m in self.transcript[self.active["start_index"]:]: + if m.origin.get("kind") == "slash-command": # 1 slash-command origin doesn't count + continue + if m.role == "user" and m.content.strip().startswith("/goal"): # 2 the /goal command line doesn't count + continue + if m.origin.get("kind") not in TRUSTED_EVIDENCE_ORIGINS: # 3 only trusted origins count + continue + out.append(f"{m.role}: {m.content}") + return "\n".join(out) +``` + +The effect: one `tests passed` typed by the user does not count; the same line delivered by a `task-notification` does. The model can't bluff its way through — it can't turn the goal "met" with a sentence of its own. The teaching `goal_satisfied()` is a deterministic keyword check; the real version hands the window to a small/fast model. + +> Real Claude Code: the evaluator is a small/fast model separate from the worker (markers `evaluatorModel`, `default small fast model`), judging transcript evidence rather than arbitrary plausibility. + +### Three gate states: completed / continuing / blocked + +`evaluate_after_turn` runs once per turn, with three exits: met → clear the goal (completed); not met and budget left → enqueue a continuation and let the next turn run (continuing); budget spent → stop (blocked), so a goal that can't be judged doesn't loop forever. + +```python +def evaluate_after_turn(self): + g = self.active + g["checks"] += 1 + if self.goal_satisfied(): + g["status"] = "completed"; self.active = None + return "completed" # met -> clear the goal + if g["continuation_turns"] < g["max_turns"]: + g["continuation_turns"] += 1 + self.queue.enqueue( + value="Continue working ... do not treat this reminder as completion evidence.", + origin={"kind": "active-goal"}) + return "continuing" # not met -> enqueue a continuation + g["status"] = "blocked"; self.active = None + return "blocked" # over budget -> stop blocking +``` + +The continuation carries its own line `do not treat this reminder as completion evidence` — so even the reminder text is excluded from evidence. All three guards are in place: command text, reminder text, plain text — none of them count as done. + +> Real Claude Code: `evaluateAfterTurn` emits `goal_evaluated` and completes / enqueues a continuation / blocks; the default budget is `20`. + +### Continuation vs the external async inbox + +The continuation goes into the same `CommandQueue`, but it and external async events (task-completion notifications, monitor lines) are **not the same drain**. `dequeue` takes a switch: an external-inbox drain skips active-goal continuations by default. + +```python +def dequeue(self, include_goal_continuations=True): + ... + for idx, item in enumerate(self.items): + if include_goal_continuations or item["origin"].get("kind") != "active-goal": + return self.items.pop(idx) + return None +``` + +Why split them: a real-model test once hit a bug — the model drained the continuation as if it were an external notification, declaring the goal dead before background evidence arrived. After the split, advancing a goal is an explicit step, not something an async event drags along. + +> Real Claude Code: `drainCommandQueue` defaults to `includeGoalContinuations=false`, separating active-goal continuations from the external async-inbox drain. + +### Putting it together + +`code.py` runs a `/goal until tests passed and deploy green`: after the goal is set there's no trusted evidence yet → the gate pushes it back, turn after turn; the user typing `tests passed` doesn't count either (untrusted origin); until a background task lands a `task-notification`, evidence arrives → completed. A second `max_turns=2` goal demonstrates blocked. + +```python +s.submit("/goal until tests passed and deploy green") # set the goal; window starts after the command +s.submit("tests passed, trust me") # plain text -> does not count +s.submit("tests passed; deploy green", + origin={"kind": "task-notification"}) # trusted evidence -> completed +``` + +## Changes from s21 + +| | s21 Workflow Runtime | s22 Goal Loop | +|--|---------------------|---------------| +| Trigger | script-controlled orchestration (leaves the main loop) | condition-controlled continuation (re-enters the main loop) | +| Where it attaches | tool layer: a `Workflow` tool | turn boundary: a completion gate | +| Who decides to stop | the script ends when it ends | the goal condition, judged against trusted evidence | +| New mechanisms | script DSL, background task, journal/resume, structured output | goal gate, evidence trust boundary, continuation split, budget | + +s21 writes orchestration as a script and fans work out, away from the main loop; s22 is the reverse — a force that re-enters the main loop: until the goal is met, the turn isn't allowed to end. Neither changes the s01 `while`; they press on it from opposite sides. + +## Try it + +```bash +python s22_goal_loop/code.py # /goal until tests pass + deploy green; watch the gate decide +``` + +Watch: after the goal is set, every turn ends with a `goal_evaluated`; plain text is `satisfied=False`, a `task-notification` origin is `satisfied=True`; when the budget runs out, `goal_blocked`. The same line `tests passed`, from different origins, gets the opposite verdict — that's where `/goal` refuses to be fooled by a sentence. + +## What's next + +`/goal` is one way to re-enter the main loop: condition control. It pairs with s21's "leave the main loop" — one fans work out, the other pulls control back. Further out there's time control (`/loop`, cron) and event control (`Monitor`), sharing the same task/notification substrate; but the gate's core is already here: **stopping isn't the model's say-so, it's the goal judged against trusted evidence.** + + diff --git a/s22_goal_loop/README.zh.md b/s22_goal_loop/README.zh.md new file mode 100644 index 000000000..aa4905e92 --- /dev/null +++ b/s22_goal_loop/README.zh.md @@ -0,0 +1,170 @@ +# s22: Goal Loop — 终止权从模型移交给目标条件 + +[中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) + +s01 → ... → s20 → s21 → `s22` + +> *"一个 turn 能否结束,由目标条件而非模型判定"* — `/goal` 在主循环的回合收尾处加一道闸门:每个 turn 后,一个独立 evaluator 判断可信证据是否满足条件,不满足就把控制权推回下一轮。 +> +> **Harness 层**: 目标闭环 — 在 turn 收尾处,加一道 host 拥有的完成闸门。 + +--- + +## 问题 + +s01 到 s21,一个 turn 怎么结束?模型不再发 `tool_use`,循环就 `return`。一次性任务这样没问题——做完就停。 + +但有些目标要**跨多个 turn 盯到底**:"把测试跑绿""部署成功为止"。两种失败都很常见:模型做了一半觉得差不多了就停;或者干脆嘴上说一句 `tests passed` 想收工。你要的是——**这个 turn 能不能结束,不由模型自己说了算,而由一个明确的条件、对着可信证据来判。** + +这不是定时(s14 cron),不是后台任务(s13),也不是指望模型自律。是 host 在回合收尾处加一道闸门。 + +## 解决方案 + +`/goal <条件>` 设一个 session 级的停止条件。host 把它存进 active goal,每个 turn 结束后,一个独立的小/快模型 evaluator 判断 transcript 里的**可信证据**是否满足条件。不满足 → 闸门挡住这次停止,把一条 continuation 喂进下一轮;满足 → 清除目标,记下达成。 + +![Goal Loop 总览](images/goal-loop-overview.svg) + +和 s01 的循环比,只多一道判断——模型想停时,先问目标: + +```python +# s01:模型说停就停 +if not has_tool_use(response): + return +# s22:想停时,先过目标闸门 +if not has_tool_use(response): + verdict = goal.evaluate_after_turn() + if verdict == "continuing": + continue # 没达成 -> 推回去再来一轮 + return # 达成 / 超预算 / 无目标 -> 真停 +``` + +## 工作原理 + +### /goal:回合收尾处的一道闸门 + +`/goal` 是一个 session 级、基于 prompt 的 Stop hook。它不改主循环的形状,只在每个 turn 收尾时插一句 `evaluate_after_turn()`。这道闸门是 **host 拥有的**——不是模型自我约束,模型甚至不知道自己被拦了一道,它只是收到了下一轮的输入。 + +```python +def submit(self, text, origin=None): + ... # 记录输入、跑一轮 (mock) assistant turn + return self.goal.evaluate_after_turn() # <-- 回合收尾的 Stop gate +``` + +> 真实 Claude Code:`/goal` 是 session 级的 Stop hook,受 workspace trust 和 hook 限制门控;binary 里有 `active_goal`、`goal_status`、`goal_met`、`tengu_goal_achieved` 等 marker。 + +### 设目标:证据窗口从命令之后开始 + +`set_goal` 存一个 active goal:目标文本、预算 `max_turns`、计数器,还有 `start_index`——**证据窗口的起点**。它取当前 transcript 长度,所以 `/goal` 命令那一行已经落在窗口外。这是第一道防线:命令文本自己不能满足自己。 + +```python +def set_goal(self, objective, max_turns=20): + self.active = { + "objective": objective, "status": "active", + "start_index": len(self.transcript), # 证据窗口从这里开始;命令本身已在窗口外 + "max_turns": max_turns, "checks": 0, "continuation_turns": 0, + } +``` + +> 真实 Claude Code:`GoalRuntime.setGoal()` 存 activeGoal、startIndex、计数器与预算;提交后再 `resetEvidenceStart()` 把窗口对齐到命令之后。 + +### evaluator 判定:只认可信证据 + +这是整个机制的核心。evaluator 不看整段对话,只看证据窗口里**可信来源**的消息。三道过滤层层把"看着像达成、其实不算"的文本挡在外面: + +```python +TRUSTED_EVIDENCE_ORIGINS = {"task-notification", "monitor-line"} + +def evidence_text(self): + out = [] + for m in self.transcript[self.active["start_index"]:]: + if m.origin.get("kind") == "slash-command": # 1 slash 来源不算 + continue + if m.role == "user" and m.content.strip().startswith("/goal"): # 2 命令文本不算 + continue + if m.origin.get("kind") not in TRUSTED_EVIDENCE_ORIGINS: # 3 只认可信来源 + continue + out.append(f"{m.role}: {m.content}") + return "\n".join(out) +``` + +效果:一句 `tests passed`,user 打字说的不算,`task-notification` 带来的才算。模型糊弄不过去——它没法凭一句自述把目标判成达成。教学版 `goal_satisfied()` 是确定性的关键词匹配;真实版把证据窗口交给一个小/快模型来判。 + +> 真实 Claude Code:evaluator 是与 worker 分离的 small/fast model(marker `evaluatorModel`、`default small fast model`),判 transcript 证据而非任意可信度。 + +### 闸门三态:完成 / 继续 / 超预算 + +`evaluate_after_turn` 每轮跑一次,三种出口:满足就清除目标(completed);没满足且预算没用完,就往队列塞一条 continuation 并放行下一轮(continuing);预算耗尽就停(blocked),避免一个判不出来的目标无限刷下去。 + +```python +def evaluate_after_turn(self): + g = self.active + g["checks"] += 1 + if self.goal_satisfied(): + g["status"] = "completed"; self.active = None + return "completed" # 达成 -> 清除目标 + if g["continuation_turns"] < g["max_turns"]: + g["continuation_turns"] += 1 + self.queue.enqueue( + value="Continue working ... do not treat this reminder as completion evidence.", + origin={"kind": "active-goal"}) + return "continuing" # 没达成 -> 入队 continuation + g["status"] = "blocked"; self.active = None + return "blocked" # 超预算 -> 放行,不再拦 +``` + +那条 continuation 自带一句 `do not treat this reminder as completion evidence`——连提醒文本本身也被排除在证据之外。三道防误判到齐:命令文本、提醒文本、普通文本,都不算达成。 + +> 真实 Claude Code:`evaluateAfterTurn` 发 `goal_evaluated`,按结果 complete / 入队 continuation / block;默认预算 `20`。 + +### continuation 与外部 async inbox 分流 + +continuation 进的是同一个 `CommandQueue`,但它和外部异步事件(task 完成通知、monitor 行)**不是同一种 drain**。`dequeue` 带一个开关:外部 inbox 的 drain 默认跳过 active-goal 的 continuation。 + +```python +def dequeue(self, include_goal_continuations=True): + ... + for idx, item in enumerate(self.items): + if include_goal_continuations or item["origin"].get("kind") != "active-goal": + return self.items.pop(idx) + return None +``` + +为什么要分开:real-model 测试里发现过一个 bug——模型把 continuation 当成外部通知一起 drain,结果在后台证据还没到之前就把目标判死了。分流之后,goal 的推进是显式的一步,不会被异步事件裹挟。 + +> 真实 Claude Code:`drainCommandQueue` 默认 `includeGoalContinuations=false`,把 active-goal continuation 和外部 async inbox drain 分开。 + +### 合起来跑 + +`code.py` 演示一个 `/goal until tests passed and deploy green`:设目标后没有可信证据 → 闸门一轮轮把它推回;user 直接打 `tests passed` 也不算(来源不可信);直到一个后台任务发来 `task-notification`,证据到位 → completed。再加一个 `max_turns=2` 的小目标演示 blocked。 + +```python +s.submit("/goal until tests passed and deploy green") # 设目标,窗口在命令后 +s.submit("tests passed, trust me") # 普通文本 -> 不算达成 +s.submit("tests passed; deploy green", + origin={"kind": "task-notification"}) # 可信证据 -> completed +``` + +## 相对 s21 的变更 + +| | s21 Workflow Runtime | s22 Goal Loop | +|--|---------------------|---------------| +| 触发方式 | 脚本控制的编排(脱离主循环) | 条件控制的继续(重入主循环) | +| 加在哪 | tool layer:一个 `Workflow` 工具 | turn 收尾:一道完成闸门 | +| 谁决定停 | 脚本跑完即止 | 目标条件对着可信证据判 | +| 新增机制 | 脚本 DSL、后台 task、journal/resume、结构化输出 | 目标闸门、证据信任边界、continuation 分流、预算 | + +s21 是把编排写成脚本、扇出去脱离主循环;s22 反过来,是一股力量把控制权**重入**主循环——目标没达成,turn 就不算结束。两者都不改 s01 那个 `while`,只是从两头给它加压。 + +## 试一下 + +```bash +python s22_goal_loop/code.py # /goal until tests pass + deploy green,看闸门怎么判 +``` + +观察:设目标后,每个 turn 后都有一条 `goal_evaluated`;普通文本 `satisfied=False`,`task-notification` 来源 `satisfied=True`;预算耗尽时 `goal_blocked`。同一句 `tests passed`,来源不同,判定相反——这就是 `/goal` 不被一句空话糊弄的地方。 + +## 接下来 + +`/goal` 是"重入主循环"的一种触发:条件控制。它和 s21 的"脱离主循环"正好成对——一个把工作扇出去,一个把控制权拉回来。再往外,还有时间控制(`/loop`、cron)和事件控制(`Monitor`)的重入,它们共享同一套 task / 通知基底;但闸门的核心已经在这里:**停不停,不由模型一句话说了算,而由目标对着可信证据来判。** + + diff --git a/s22_goal_loop/code.py b/s22_goal_loop/code.py new file mode 100644 index 000000000..62f466c39 --- /dev/null +++ b/s22_goal_loop/code.py @@ -0,0 +1,279 @@ +""" +s22_goal_loop — /goal session goal loop (teaching version) + +Clean-room behavioral reconstruction of Claude Code's `/goal` command. Grounded +in @anthropic-ai/claude-code@2.1.177 observed behavior +(reverse-research/cc_goal_loop), NOT leaked source. + +Idea: + s01-s21 end a turn when the model emits no tool_use. `/goal` adds a + host-owned turn-completion GATE: the user sets a stopping CONDITION, and after + every turn a separate evaluator judges whether trusted transcript evidence + satisfies it. Not satisfied -> the gate blocks the stop and feeds a + continuation into the next turn. Satisfied -> the active goal is cleared. + + So the core contrast with s01 is one extra check before "return": + + # s01: the model says stop -> stop + if not has_tool_use(response): + return + # s22: when it wants to stop, pass the goal gate first + if not has_tool_use(response): + verdict = goal.evaluate_after_turn() + if verdict == "continuing": + continue # not met -> push it back + return # met / over budget / no goal -> really stop + +Run: + python code.py # /goal until tests pass + deploy green; watch the gate + +Teaching simplifications (vs real /goal and runtime.mjs): + - The evaluator is a deterministic keyword check, not a small/fast model. + - One mock task-notification produces the trusted evidence; the loop / monitor + / background-task plane (s13/s14) is out of scope — this chapter is just the + goal gate. + - The evidence trust boundary is the faithful part: only task-notification / + monitor-line origins count as evidence, so the `/goal` command text, the + continuation reminder, and plain assistant prose can NOT satisfy the goal. +""" + +import itertools +import sys + +# ---- ids + a one-line event stream so the gate is visible ---- +_ids = itertools.count(1) + + +def make_id(prefix): + return f"{prefix}-{next(_ids):03d}" + + +def event(lane, etype, detail=""): + print(f" · {lane:<6} {etype:<26} {detail}") + + +# A message's origin.kind is the TRUST LABEL that decides whether it can count +# as goal evidence. Trusted async origins land real tool/task evidence; user / +# slash-command / active-goal (the continuation reminder) / assistant do not. +TRUSTED_EVIDENCE_ORIGINS = {"task-notification", "monitor-line"} + + +class Message: + def __init__(self, role, content, origin): + self.role = role + self.content = content + self.origin = origin or {"kind": "user"} + + +# ============================================================ +# CommandQueue — continuation prompts live here (mirrors CommandQueue) +# ============================================================ +class CommandQueue: + PRIORITY = {"now": 0, "next": 1, "later": 2} + + def __init__(self): + self.items = [] + + def enqueue(self, value, priority="next", origin=None): + item = {"id": make_id("cmd"), "priority": priority, + "origin": origin or {}, "value": value} + self.items.append(item) + return item + + def dequeue(self, include_goal_continuations=True): + # Goal continuations and the external async inbox are NOT the same drain. + # With include_goal_continuations=False an inbox drain skips them, so a + # goal can't be advanced (or blocked) before real evidence arrives. + self.items.sort(key=lambda i: self.PRIORITY.get(i["priority"], 1)) + for idx, item in enumerate(self.items): + if include_goal_continuations or item["origin"].get("kind") != "active-goal": + return self.items.pop(idx) + return None + + def remove_by_origin(self, kind): + before = len(self.items) + self.items = [i for i in self.items if i["origin"].get("kind") != kind] + return before - len(self.items) + + def __len__(self): + return len(self.items) + + +# ============================================================ +# GoalRuntime — the turn-completion gate (mirrors GoalRuntime) +# ============================================================ +class GoalRuntime: + def __init__(self, transcript, queue): + self.transcript = transcript # shared session transcript + self.queue = queue + self.active = None + + def set_goal(self, objective, max_turns=20): + # start_index marks the evidence window. The /goal command line is + # already recorded, so it sits OUTSIDE the window and can't satisfy + # itself. + self.active = { + "id": make_id("goal"), "objective": objective, "status": "active", + "start_index": len(self.transcript), "max_turns": max_turns, + "checks": 0, "continuation_turns": 0, + } + event("goal", "goal_started", f"{self.active['id']} :: {objective}") + return self.active + + def clear(self, reason="cleared"): + if not self.active: + return + self.active["status"] = reason + self.queue.remove_by_origin("active-goal") + event("goal", "goal_cleared", reason) + self.active = None + + def evidence_text(self): + """The trust boundary. Three filters keep self-satisfying text out: + drop slash-command origins, drop /goal command lines, and keep ONLY + trusted external async origins (task-notification / monitor-line).""" + if not self.active: + return "" + out = [] + for m in self.transcript[self.active["start_index"]:]: + if m.origin.get("kind") == "slash-command": + continue + if m.role == "user" and m.content.strip().startswith("/goal"): + continue + if m.origin.get("kind") not in TRUSTED_EVIDENCE_ORIGINS: + continue + out.append(f"{m.role}: {m.content}") + return "\n".join(out) + + def goal_satisfied(self): + # Real Claude Code routes this to a small/fast evaluator model reading + # the evidence window. The teaching version is a deterministic keyword + # check so the lifecycle is reproducible. + objective = self.active["objective"].lower() + evidence = self.evidence_text().lower() + wants_tests = "test" in objective + wants_deploy = "deploy" in objective or "green" in objective + tests_ok = not wants_tests or "tests passed" in evidence or "test passed" in evidence + deploy_ok = not wants_deploy or "deploy green" in evidence or "deployment green" in evidence + if any(k in objective for k in ("until", "pass", "green")): + return tests_ok and deploy_ok + return objective in evidence + + def evaluate_after_turn(self): + """The gate, run after every turn. Returns completed / continuing / + blocked / none.""" + g = self.active + if not g or g["status"] != "active": + return "none" + g["checks"] += 1 + satisfied = self.goal_satisfied() + event("goal", "goal_evaluated", f"check #{g['checks']} satisfied={satisfied}") + if satisfied: + g["status"] = "completed" + self.queue.remove_by_origin("active-goal") + event("goal", "goal_completed", g["id"]) + self.active = None + return "completed" + if g["continuation_turns"] < g["max_turns"]: + g["continuation_turns"] += 1 + self.queue.enqueue( + value=(f"Continue working toward active goal {g['id']}. Use tool/task " + "evidence; do not treat this reminder as completion evidence."), + priority="next", origin={"kind": "active-goal", "goal_id": g["id"]}) + event("goal", "goal_continuation_enqueued", + f"turn {g['continuation_turns']}/{g['max_turns']}") + return "continuing" + g["status"] = "blocked" + self.queue.remove_by_origin("active-goal") + event("goal", "goal_blocked", f"exceeded {g['max_turns']} turns") + self.active = None + return "blocked" + + +# ============================================================ +# Session — the main loop host with a Stop gate (mirrors submit / drain) +# ============================================================ +class Session: + def __init__(self): + self.transcript = [] + self.queue = CommandQueue() + self.goal = GoalRuntime(self.transcript, self.queue) + + def _add(self, role, content, origin): + self.transcript.append(Message(role, content, origin)) + + def submit(self, text, origin=None): + """One turn: record the input, run a (mock) assistant turn, then let the + goal gate evaluate. `origin` carries the trust label.""" + origin = origin or {"kind": "user"} + self._add("user", text, origin) # input recorded with its origin + kind = origin["kind"] + + if kind == "user" and text.strip().startswith("/goal"): + arg = text.strip()[5:].strip() + self._add("assistant", f"(slash) /goal {arg}", {"kind": "slash-command"}) + if arg in ("", "clear", "stop", "off"): + self.goal.clear() + else: + self.goal.set_goal(arg) + elif kind in TRUSTED_EVIDENCE_ORIGINS: + # The input itself (recorded above with a trusted origin) is the + # evidence; the assistant just observes it. + event("turn", f"observe {kind}", text[:48]) + self._add("assistant", f"Observed {kind}: {text}", origin) + elif kind == "active-goal": + event("turn", "continue-goal", "(reminder is not evidence)") + self._add("assistant", "Continuing the goal; checking task/monitor evidence.", origin) + else: + event("turn", "assistant-turn", text[:48]) + self._add("assistant", f"assistant handled: {text}", {"kind": "assistant"}) + + return self.goal.evaluate_after_turn() # <-- the Stop gate + + def drain_goal_continuation(self): + """Pull one goal continuation back into the loop — explicit, separate + from any external async-inbox drain.""" + item = self.queue.dequeue(include_goal_continuations=True) + if item and item["origin"].get("kind") == "active-goal": + return self.submit(item["value"], origin=item["origin"]) + return None + + +# ============================================================ +# Demo +# ============================================================ +def banner(text): + print(f"\n— {text} —") + + +def main(argv): + s = Session() + + banner("1. set a goal (the gate is now armed; window starts after the command)") + print("user> /goal until tests passed and deploy green") + s.submit("/goal until tests passed and deploy green") + + banner("2. model works, no TRUSTED evidence yet -> the gate keeps it going") + s.drain_goal_continuation() + s.submit("Inspecting the failing tests and the deploy config.") + + banner("3. plain user text 'tests passed' is NOT trusted -> still not satisfied") + s.submit("tests passed, trust me") + s.drain_goal_continuation() + print(f" active goal still open: {s.goal.active is not None}") + + banner("4. a background task lands a task-notification (trusted) -> satisfied") + verdict = s.submit("tests passed; deploy green", origin={"kind": "task-notification"}) + print(f" final verdict: goal {verdict}") + + banner("5. budget: a goal that never gets evidence blocks after max_turns") + s2 = Session() + s2.goal.set_goal("until tests passed", max_turns=2) + verdict = "continuing" + while verdict == "continuing": + verdict = s2.submit("still working, no task evidence yet") + print(f" final verdict: goal {verdict}") + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/s22_goal_loop/images/goal-loop-overview.svg b/s22_goal_loop/images/goal-loop-overview.svg new file mode 100644 index 000000000..907c522b0 --- /dev/null +++ b/s22_goal_loop/images/goal-loop-overview.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + Goal Loop — the host-owned turn-completion gate + after every turn an evaluator judges trusted evidence and blocks the stop until the goal is met + + + + Main loop — the turn boundary + + + + continuation -> transcript[] (next turn) + + + + + transcript[] + messages + origins + + + + + turn (LLM) + may emit tool_use + + + + + no tool_use + (wants to stop) + + + + + goal gate + evaluate_after_turn() + after every turn + + + + completed + / blocked + + + + return + turn ends + + + + + judge + + + evaluator + separate small / fast model + + + + reads + + + + evidence window + = transcript[start_index:] · trust boundary + + + + counts as evidence + task-notification + monitor-line + trusted async origins + + + + filtered out + /goal command text + continuation reminder + plain user / assistant + model can't self-satisfy + + + + + continuing + + + + CommandQueue + continuation (active-goal) + + + + + + The model proposes stop; the goal gate decides against trusted evidence only, not the model's own say-so. + diff --git a/web/public/course-assets/s01_agent_loop/agent-loop.en.svg b/web/public/course-assets/s01_agent_loop/agent-loop.en.svg deleted file mode 100644 index 541ab3f96..000000000 --- a/web/public/course-assets/s01_agent_loop/agent-loop.en.svg +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - Agent Loop — A while Loop Drives the Entire Agent - - - - User Query - "Create hello.py for me" - - - - - - - messages[] - Accumulated message list - - - - - - - LLM - - Model reads message history - Decision: Need a tool? - Returns stop_reason signal - - - - - - - stop_reason - == "tool_use"? - - - - No - - - - Return Result - Loop Ends - - - - Yes - - - - Execute Tool Call - run_bash(command) - - - - Append tool_result to messages - - - - Core: a - while True - loop. Model calls tool → Execute → Feed back → Ask again. No tool call → Stop. - All subsequent chapters layer mechanisms on top of this loop. - diff --git a/web/public/course-assets/s01_agent_loop/agent-loop.ja.svg b/web/public/course-assets/s01_agent_loop/agent-loop.ja.svg deleted file mode 100644 index ee726e697..000000000 --- a/web/public/course-assets/s01_agent_loop/agent-loop.ja.svg +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - Agent Loop — 一つの while ループで Agent 全体を駆動 - - - - ユーザーの質問 - "hello.py を作って" - - - - - - - messages[] - 累積メッセージリスト - - - - - - - 大規模言語モデル (LLM) - - モデルがメッセージ履歴を読む - 判断:ツールが必要か? - stop_reason シグナルを返す - - - - - - - stop_reason - == "tool_use"? - - - - No - - - - 結果を返す - ループ終了 - - - - Yes - - - - ツール呼び出しを実行 - run_bash(command) - - - - tool_result を messages に追加 - - - - 核心:一つの - while True - ループ。ツール呼出 → 実行 → 結果を戻す → 再度問う。ツールなし → 停止。 - 以降の全章がこのループの上に仕組みを積み重ねる。 - diff --git a/web/public/course-assets/s01_agent_loop/agent-loop.svg b/web/public/course-assets/s01_agent_loop/agent-loop.svg index 87c6b5008..0c4af8c26 100644 --- a/web/public/course-assets/s01_agent_loop/agent-loop.svg +++ b/web/public/course-assets/s01_agent_loop/agent-loop.svg @@ -1,86 +1,69 @@ - + - - + + - - + + - - - - - - - - - - - - - - Agent Loop — 一个 while 循环驱动整个 Agent + - - - 用户提问 - "帮我创建 hello.py" + + Agent Loop + a while True loop drives the entire agent - - + + + User Query - - - messages[] - 累积式消息列表 + - - + + + messages[] + accumulated message history - - - 大模型 (LLM) - - 模型阅读消息历史 - 判断:需要工具吗? - 返回 stop_reason 信号 + - - + + + LLM Call + + messages.create( model, messages, tools ) - - - stop_reason - == "tool_use"? + - - - + + + stop_reason == "tool_use" ? - - - 返回结果 - 循环结束 + + + No + + Return - - - + + + Yes - - - 执行工具调用 - run_bash(command) + + + Execute Tool + + + $ run_bash(command) + - - - 追加 tool_result 到 messages + + + + + - - - 核心:一个 - while True - 循环。模型调工具 → 执行 → 喂回 → 再问。不调工具就停。 - 后续所有章节都在这个循环上叠加机制。 + + append tool_result to messages diff --git a/web/public/course-assets/s02_tool_use/concurrency-comparison.en.svg b/web/public/course-assets/s02_tool_use/concurrency-comparison.en.svg deleted file mode 100644 index 04dab3235..000000000 --- a/web/public/course-assets/s02_tool_use/concurrency-comparison.en.svg +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - Tool Concurrency — Teaching Version vs Claude Code - - - - Model returns 5 tool calls at once - - - read A.py - - - glob *.py - - - bash "ls -la" - - - write B.py - - - read C.py - - - - Teaching: Original Order, One by One - - - for block in response.content: - TOOL_HANDLERS[name](**input) - - Result: 5 serial calls, no batches - - - 1. read A.py - - - 2. glob *.py - - - 3. bash "ls -la" - - - 4. write B.py - - - 5. read C.py - - Teaching focus: tool dispatch first; concurrency omitted - - - - Claude Code: isConcurrencySafe(input) - - - Each tool call judged individually: - tool.isConcurrencySafe(parsedInput) → bool - - Result: 3 batches (by consecutive blocks) - - - Batch 1 - Concurrent - read A · glob · bash "ls" - - - - - Batch 2 - Serial - write B - - - - - Batch 3 - Concurrent - read C - - bash "ls" is safe and consecutive, so it stays in Batch 1 - - ✓ Input-dependent safety, not tool-name hardcoding - ✓ Original order preserved; only safe consecutive calls run together - - - - Key Difference - • Teaching: executes response.content in original order, one tool call at a time; no concurrency or batching - • CC: checks isConcurrencySafe(input), then groups consecutive safe calls into one batch - • Key difference: teaching focuses on dispatch; CC optimizes safe concurrency while preserving order semantics - diff --git a/web/public/course-assets/s02_tool_use/concurrency-comparison.ja.svg b/web/public/course-assets/s02_tool_use/concurrency-comparison.ja.svg deleted file mode 100644 index f130d5b32..000000000 --- a/web/public/course-assets/s02_tool_use/concurrency-comparison.ja.svg +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - ツール並列実行 — 教育版 vs Claude Code - - - - モデルが一度に 5 つのツール呼び出しを返す - - - read A.py - - - glob *.py - - - bash "ls -la" - - - write B.py - - - read C.py - - - - 教育版:元の順序で一つずつ実行 - - - for block in response.content: - TOOL_HANDLERS[name](**input) - - 結果:5 回の直列呼び出し、batch なし - - - 1. read A.py - - - 2. glob *.py - - - 3. bash "ls -la" - - - 4. write B.py - - - 5. read C.py - - 教育の焦点:まず tool_use 分配を理解し、並列は省略 - - - - Claude Code:isConcurrencySafe(input) - - - 各ツール呼び出しを個別に判定: - tool.isConcurrencySafe(parsedInput) → bool - - 結果:3 バッチ(連続ブロックごと) - - - Batch 1 - 並列 - read A · glob · bash "ls" - - - - - Batch 2 - 直列 - write B - - - - - Batch 3 - 並列 - read C - - bash "ls" は安全かつ連続しているため Batch 1 に入る - - ✓ 入力に基づく安全判定、ツール名ハードコードではない - ✓ 元の順序を保ち、連続する安全呼び出しだけ並列化 - - - - 核心的な違い - • 教育版:response.content の元の順序で一つずつ実行し、並列処理も batch 化もしない - • CC:isConcurrencySafe(input) で判定し、連続する安全呼び出しを同じ batch にまとめる - • 差分の要点:教育版は分配に集中し、CC は順序意味を保ったまま安全な並列を最適化する - diff --git a/web/public/course-assets/s02_tool_use/concurrency-comparison.svg b/web/public/course-assets/s02_tool_use/concurrency-comparison.svg deleted file mode 100644 index e6941e619..000000000 --- a/web/public/course-assets/s02_tool_use/concurrency-comparison.svg +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - Tool Concurrency — 教学版 vs Claude Code - - - - 模型一次返回 5 个工具调用 - - - read A.py - - - glob *.py - - - bash "ls -la" - - - write B.py - - - read C.py - - - - 教学版:按原始顺序逐个执行 - - - for block in response.content: - TOOL_HANDLERS[name](**input) - - 结果:5 次串行调用,不做 batch - - - 1. read A.py - - - 2. glob *.py - - - 3. bash "ls -la" - - - 4. write B.py - - - 5. read C.py - - 教学重点:先理解 tool_use 分发,暂不引入并发执行 - - - - Claude Code:isConcurrencySafe(input) - - - 每个工具调用单独判断: - tool.isConcurrencySafe(parsedInput) → bool - - 结果:3 个 batch(按连续块分批) - - - Batch 1 - 并发 - read A · glob · bash "ls" - - - - - Batch 2 - 串行 - write B - - - - - Batch 3 - 并发 - read C - - bash "ls" 是并发安全调用,且和 read/glob 连续,所以留在 Batch 1 - - ✓ 按输入判断并发安全,不按工具名硬编码 - ✓ 保留原始顺序,只在连续安全块内部并发 - - - - 核心差异 - • 教学版:按 response.content 原始顺序逐个执行,不做并发,也不分 batch - • CC:按 isConcurrencySafe(input) 判断,并把连续的并发安全调用合成同一个 batch - • 差异重点:教学版聚焦工具分发;CC 在保持顺序语义的同时优化安全并发 - diff --git a/web/public/course-assets/s02_tool_use/tool-dispatch.en.svg b/web/public/course-assets/s02_tool_use/tool-dispatch.en.svg deleted file mode 100644 index 6fd2e6667..000000000 --- a/web/public/course-assets/s02_tool_use/tool-dispatch.en.svg +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - Tool Use — Loop Unchanged, Just Add Dispatch Mapping - - - s01 Preserved - - - - User Query - messages[] - - - - - - - LLM - stop_reason check - - - - - - - tool_use? - - - - No - - Return Result - - - - Yes - - - s02 New - - - - TOOL_HANDLERS Dispatch Mapping - - - - - - - bash - → run_bash() - - - - read_file - → run_read() - - - - write_file - → run_write() - - - - edit_file - → run_edit() - - - - glob - → run_glob() - - - - Append tool_result to messages - - - - - s01 Preserved (loop, LLM, decision — completely unchanged) - - s02 New (5 tools + dispatch mapping) - Only 1 line changed in the loop: run_bash() → TOOL_HANDLERS[block.name]() - diff --git a/web/public/course-assets/s02_tool_use/tool-dispatch.ja.svg b/web/public/course-assets/s02_tool_use/tool-dispatch.ja.svg deleted file mode 100644 index 8971d06ef..000000000 --- a/web/public/course-assets/s02_tool_use/tool-dispatch.ja.svg +++ /dev/null @@ -1,108 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - Tool Use — ループ不変、ディスパッチマッピングを追加 - - - s01 保持 - - - - ユーザーの質問 - messages[] - - - - - - - LLM - stop_reason 判定 - - - - - - - tool_use? - - - - No - - 結果を返す - - - - Yes - - - s02 新規 - - - - TOOL_HANDLERS ディスパッチマッピング - - - - - - - bash - → run_bash() - - - - read_file - → run_read() - - - - write_file - → run_write() - - - - edit_file - → run_edit() - - - - glob - → run_glob() - - - - tool_result を messages に追加 - - - - - s01 保持(ループ、LLM、判定 — 完全に不変) - - s02 新規(5 つのツール + ディスパッチマッピング) - ループ内で変更されたのは 1 行だけ:run_bash() → TOOL_HANDLERS[block.name]() - diff --git a/web/public/course-assets/s02_tool_use/tool-dispatch.svg b/web/public/course-assets/s02_tool_use/tool-dispatch.svg index a6b16ce2a..748047b4c 100644 --- a/web/public/course-assets/s02_tool_use/tool-dispatch.svg +++ b/web/public/course-assets/s02_tool_use/tool-dispatch.svg @@ -1,108 +1,102 @@ - + - - - - - - - + + + + - - + + - - - - - - + + - - - - Tool Use — 循环不变,只加分发映射 + + Tool Dispatch + Loop unchanged — add one handler mapping per tool - - s01 保留 + + - - - 用户提问 - messages[] + + + User Input + messages[] - - + + - - 大模型 (LLM) - stop_reason 判断 - - - - - - - tool_use? - - - - - - 返回结果 - - - - - - - s02 新增 - - - - TOOL_HANDLERS 分发映射 - - - - - - - bash - → run_bash() - - - - read_file - → run_read() - - - - write_file - → run_write() - - - - edit_file - → run_edit() - - - - glob - → run_glob() - - - - tool_result 追加到 messages - - - - - s01 保留(循环、LLM、判断——完全不变) - - s02 新增(5 个工具 + 分发映射) - 循环里只改了 1 行:run_bash() → TOOL_HANDLERS[block.name]() - + + LLM + stop_reason check + + + + + + + tool_use? + + + + No + + Return Result + + + + Yes + + + + + + + TOOL_HANDLERS {name: handler} + + + + + bash + run_bash() + + + + read_file + run_read() + + + + write_file + run_write() + + + + + edit_file + run_edit() + + + + glob + run_glob() + + + + + + tool_result + → messages[] + + + + + + + Loop change (1 line): + - run_bash() → TOOL_HANDLERS[block.name](**block.input) + \ No newline at end of file diff --git a/web/public/course-assets/s03_permission/permission-overview.en.svg b/web/public/course-assets/s03_permission/permission-overview.en.svg deleted file mode 100644 index 8255bb26d..000000000 --- a/web/public/course-assets/s03_permission/permission-overview.en.svg +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - Permission — Loop unchanged, a gate before tool execution - - - s02 preserved - - - - messages[] - - - - - - - LLM - stop_reason? - - - - No - - - Return result - - - - Yes - - - s03 new - - - - check_permission() - - - - Gate 1: Deny List - - - - Gate 2: Rule Matching - - - - Gate 3: User Approval - - - - Deny - - - - Pass - - - s02 - - - - TOOL_ - HANDLERS - bash/read/write/... - - - - - - - tool_result - - - - - - - - s02 preserved (loop, LLM, dispatch — unchanged) - - s03 new (three-gate permission pipeline) - diff --git a/web/public/course-assets/s03_permission/permission-overview.ja.svg b/web/public/course-assets/s03_permission/permission-overview.ja.svg deleted file mode 100644 index f4fd613e2..000000000 --- a/web/public/course-assets/s03_permission/permission-overview.ja.svg +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - Permission — ループは変更なし、ツール実行前にゲートを追加 - - - s02 維持 - - - - messages[] - - - - - - - LLM - stop_reason? - - - - No - - - 結果を返す - - - - Yes - - - s03 新規 - - - - check_permission() - - - - ゲート 1: 拒否リスト - - - - ゲート 2: ルール照合 - - - - ゲート 3: ユーザー承認 - - - - 拒否 - - - - 通過 - - - s02 - - - - TOOL_ - HANDLERS - bash/read/write/... - - - - - - - tool_result - - - - - - - - s02 維持(ループ、LLM、ディスパッチ — 変更なし) - - s03 新規(3 ゲート権限パイプライン) - diff --git a/web/public/course-assets/s03_permission/permission-overview.svg b/web/public/course-assets/s03_permission/permission-overview.svg index 61567d8f9..aae3369db 100644 --- a/web/public/course-assets/s03_permission/permission-overview.svg +++ b/web/public/course-assets/s03_permission/permission-overview.svg @@ -1,104 +1,120 @@ - + - - + + - - + + - - - - - - - - - - - - - - Permission — 循环不变,工具执行前加一道门 - - - s02 保留 - - - - messages[] - - - - - - - LLM - stop_reason? - - - - - - - 返回结果 - - - - - - - s03 新增 - - - - check_permission() - - - - 闸门 1: 拒绝列表 - - - - 闸门 2: 规则匹配 - - - - 闸门 3: 用户审批 - - - - 拒绝 - - - - 通过 - - - s02 - - - - TOOL_ - HANDLERS - bash/read/write/... - - - - - - - tool_result - - - - - - - - s02 保留(循环、LLM、分发——完全不变) - - s03 新增(三道闸门权限管线) - + + + + Permission — Gate Before Tool Execution + check_permission() gates every tool call before dispatch + + + + + + messages[] + + + + + + + LLM + stop_reason = ? + + + + + + + tool_use + + + + No + + Return Result + + + + Yes + + + + check_permission() + + + + Gate 1 + Deny List + + + + Gate 2 + Rule Matching + + + + Gate 3 + User Approval + + + + + + + + + + + + Denied + + + + Passed + + + + TOOL_HANDLERS + bash / read / write + + + + + + + + tool_result + + + + + + + + if not check_permission(block): + results.append("Permission denied.") + continue + output = TOOL_HANDLERS[name](**input) + \ No newline at end of file diff --git a/web/public/course-assets/s03_permission/permission-pipeline.en.svg b/web/public/course-assets/s03_permission/permission-pipeline.en.svg deleted file mode 100644 index 1eb105187..000000000 --- a/web/public/course-assets/s03_permission/permission-pipeline.en.svg +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - Permission Pipeline — Three Gates - - - - Tool call enters - - - - - - Gate 1: Deny List - rm -rf /, sudo, shutdown - - - - - - Gate 2: Rule Matching - Write outside ws? Destructive? - no match → allow - - - match - - - - Gate 3 - User approval - allow / deny - - - - Three Decisions - - - Deny - Gate 1 hit, or user denied - - - Ask - Gate 2 matched, enter Gate 3 - - - Allow - No rule hit, or user approved - - Priority: hard deny → rule matching → if matched ask user; if unmatched allow by default - diff --git a/web/public/course-assets/s03_permission/permission-pipeline.ja.svg b/web/public/course-assets/s03_permission/permission-pipeline.ja.svg deleted file mode 100644 index 090aaf1cb..000000000 --- a/web/public/course-assets/s03_permission/permission-pipeline.ja.svg +++ /dev/null @@ -1,61 +0,0 @@ - - - - - - - - - - - - - - Permission Pipeline — 3 つのゲート - - - - ツール呼び出し - - - - - - ゲート 1: 拒否リスト - rm -rf /, sudo, shutdown - - - - - - ゲート 2: ルール照合 - ws 外への書き込み?破壊的? - 不一致 → allow - - - 一致 - - - - ゲート 3 - ユーザー承認 - allow / deny - - - - 3 つの決定 - - - 拒否 (deny) - ゲート 1 一致、またはユーザー拒否 - - - 確認 (ask) - ゲート 2 一致、ゲート 3 へ - - - 許可 (allow) - ルール不一致、またはユーザー許可 - - 優先順位:ハード拒否 → ルール照合 → 一致ならユーザー承認、不一致ならデフォルト許可 - diff --git a/web/public/course-assets/s03_permission/permission-pipeline.svg b/web/public/course-assets/s03_permission/permission-pipeline.svg index c3b0b95e0..f4f436a41 100644 --- a/web/public/course-assets/s03_permission/permission-pipeline.svg +++ b/web/public/course-assets/s03_permission/permission-pipeline.svg @@ -1,61 +1,99 @@ - + - - - - - + + + + + - - - - Permission Pipeline — 三道闸门 + + + + Permission Pipeline + Three-gate check before tool execution + + + + + + Tool Call + + + + + + Gate 1: Deny List + rm -rf / sudo shutdown + + + pass + + + + Gate 2: Rule Match + Write outside ws? Destructive? - - - 工具调用进入 + + match - + + + Gate 3 + User Approval - - - 闸门 1: 拒绝列表 - rm -rf /, sudo, shutdown + - + + + hit + + Deny - - - 闸门 2: 规则匹配 - 写工作区外?读敏感路径? - 未命中 → allow + + + no match + + Allow - - 命中 + + + deny + + Deny - - - 闸门 3 - 用户审批 - 允许 / 拒绝 + + + allow + + Allow - - - 三种决策 + + + Three Decisions - - 阻止 (deny) - 闸门 1 命中,或用户拒绝 + + + Deny + Gate 1 hit, or user denied + + Blocked: on deny list - - 询问 (ask) - 闸门 2 命中,进入闸门 3 + + + Ask + Gate 2 matched, enter Gate 3 + + Allow? [y/N] _ - - 允许 (allow) - 规则未命中,或用户允许 + + + Allow + No rule hit, or user approved + + Executing tool... - 规则优先:闸门 1 硬拒绝 → 闸门 2 规则匹配 → 命中则用户审批,未命中默认允许 + + Priority: hard deny → rule match → if matched, ask user; if unmatched, allow diff --git a/web/public/course-assets/s04_hooks/hooks-overview.en.svg b/web/public/course-assets/s04_hooks/hooks-overview.en.svg deleted file mode 100644 index 87afdc0c5..000000000 --- a/web/public/course-assets/s04_hooks/hooks-overview.en.svg +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - Hooks — Extension Logic Hangs Outside, Loop Unchanged - - - - - - messages[] - (s01 preserved) - - - - - - - LLM - stop_reason=tool_use? - - - - No - - Return Result - - - - Yes - - - - trigger_hooks() - PreToolUse - - permission_hook · log_hook - Teaching: non-None → block - - - - - Write tool_result - - - - Pass - - - - TOOL_ - HANDLERS - bash/read/... - - - - After exec - - - - trigger_hooks() - PostToolUse - - large_output_hook - - - - Results appended to messages[], loop continues - - - - s03: - if not check_permission(block): ... - ← every new check requires modifying the loop - s04: - blocked = trigger_hooks("PreToolUse", block) - ← add check = register_hook(), loop unchanged - diff --git a/web/public/course-assets/s04_hooks/hooks-overview.ja.svg b/web/public/course-assets/s04_hooks/hooks-overview.ja.svg deleted file mode 100644 index d1addf60b..000000000 --- a/web/public/course-assets/s04_hooks/hooks-overview.ja.svg +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - Hooks — 拡張ロジックは外側に、ループは一文字も変更しない - - - - - - messages[] - (s01 保持) - - - - - - - LLM - stop_reason=tool_use? - - - - No - - 結果を返す - - - - Yes - - - - trigger_hooks() - PreToolUse - - permission_hook · log_hook - 教育版: 非 None → ブロック - - - - - tool_result に返す - - - - 通過 - - - - TOOL_ - HANDLERS - bash/read/... - - - - 実行後 - - - - trigger_hooks() - PostToolUse - - large_output_hook - - - - 結果を messages[] に追加、ループ継続 - - - - s03: - if not check_permission(block): ... - ← チェックを追加するたびにループを修正 - s04: - blocked = trigger_hooks("PreToolUse", block) - ← チェック追加 = register_hook()、ループ不変 - diff --git a/web/public/course-assets/s04_hooks/hooks-overview.svg b/web/public/course-assets/s04_hooks/hooks-overview.svg index 410593afd..47b2e5eca 100644 --- a/web/public/course-assets/s04_hooks/hooks-overview.svg +++ b/web/public/course-assets/s04_hooks/hooks-overview.svg @@ -1,100 +1,106 @@ - + - - - - - - - + - - + + - - - - - - - - - - - Hooks — 扩展逻辑挂在外面,循环本身一字不改 - - - - - - messages[] - (s01 保留) - - - - - - - LLM - stop_reason=tool_use? - - - - - - 返回结果 - - - - - - - - trigger_hooks() - PreToolUse - - permission_hook · log_hook - 教学版:非 None → 阻止 - - - - - 写入 tool_result - - - - 通过 - - - - TOOL_ - HANDLERS - bash/read/... - - - - 执行后 - - - - trigger_hooks() - PostToolUse - - large_output_hook - - - - 结果追加到 messages[],循环继续 - - - - s03: - if not check_permission(block): ... - ← 每加一个检查就要改循环 - s04: - blocked = trigger_hooks("PreToolUse", block) - ← 加检查 = register_hook(),循环不改 - + + + + + Hooks — Extension Logic Outside the Loop + PreToolUse / PostToolUse inject checks without modifying agent_loop + + + + + + + messages[] + conversation history + + + + + + + LLM + stop_reason? + + + + end_turn + + Return Result + + + + tool_use + + + + PreToolUse + trigger_hooks() + + + permission · log + non-None = block exec + + + + + blocked → result + + + + pass + + + + TOOL_HANDLERS + bash / read / write + + + + + after exec + + + + PostToolUse + trigger_hooks() + + large_output_hook + + + + + + + result appended to messages[], loop continues + + + + + + + + + Before vs After + + + Before: + if not check_permission(block): ... + # each check = modify loop + + + After: + blocked = trigger_hooks("PreToolUse", block) + # add check = register_hook() + + \ No newline at end of file diff --git a/web/public/course-assets/s05_todo_write/todo-overview.en.svg b/web/public/course-assets/s05_todo_write/todo-overview.en.svg deleted file mode 100644 index b4655e1a9..000000000 --- a/web/public/course-assets/s05_todo_write/todo-overview.en.svg +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - TodoWrite — Loop Unchanged, One More Tool Auto-Dispatched - - - s04 Preserved - - - - messages[] - - - - - - - LLM - stop_reason=tool_use? - - - - No - - Return Result - - - - Yes - - - - trigger_hooks - PreToolUse - - - - - - - - TOOL_HANDLERS - - - - bash · read · write - - - edit · glob - - - - todo_write - s05 New - - → in-memory TODO list - - - - Results appended to messages[], loop continues - - - - Nag Reminder - Model hasn't called todo_write for 3 rounds → auto-inject <reminder>Update your todos.</reminder> - - - - - s04 Preserved (loop, hooks, 5 base tools) - - s05 New (todo_write + nag reminder) - diff --git a/web/public/course-assets/s05_todo_write/todo-overview.ja.svg b/web/public/course-assets/s05_todo_write/todo-overview.ja.svg deleted file mode 100644 index ce0f6977b..000000000 --- a/web/public/course-assets/s05_todo_write/todo-overview.ja.svg +++ /dev/null @@ -1,93 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - TodoWrite — ループ不変、ツール一つ追加で自動ディスパッチ - - - s04 保持 - - - - messages[] - - - - - - - LLM - stop_reason=tool_use? - - - - No - - 結果を返す - - - - Yes - - - - trigger_hooks - PreToolUse - - - - - - - - TOOL_HANDLERS - - - - bash · read · write - - - edit · glob - - - - todo_write - s05 新規 - - → メモリ内 TODO リスト - - - - 結果を messages[] に追加、ループ継続 - - - - Nag リマインダー(催促機構) - モデルが連続 3 ラウンド todo_write 未呼び出し → 自動注入 <reminder>Update your todos.</reminder> - - - - - s04 保持(ループ、フック、5 つの基本ツール) - - s05 新規(todo_write + Nag リマインダー) - diff --git a/web/public/course-assets/s05_todo_write/todo-overview.svg b/web/public/course-assets/s05_todo_write/todo-overview.svg index 25e12fecd..41057f69f 100644 --- a/web/public/course-assets/s05_todo_write/todo-overview.svg +++ b/web/public/course-assets/s05_todo_write/todo-overview.svg @@ -1,93 +1,99 @@ - + - - - - - - - + + + + - - - - - - - - - - - TodoWrite — 循环不变,多一个工具自动分发 - - - s04 保留 - - - - messages[] - - - - - - - LLM - stop_reason=tool_use? - - - - - - 返回结果 - - - - - - - - trigger_hooks - PreToolUse - - - - - - - - TOOL_HANDLERS - - - - bash · read · write - - - edit · glob - - - - todo_write - s05 新增 - - → 进程内 TODO 列表 - - - - 结果追加到 messages[],循环继续 - - - - Nag Reminder(催更机制) - 模型连续 3 轮没调 todo_write → 自动注入 <reminder>Update your todos.</reminder> - - - - - s04 保留(循环、钩子、5 个基础工具) - - s05 新增(todo_write + nag reminder) - + + + + + TodoWrite — One More Tool, Same Loop + Stateful task list added to the existing agent loop + + + + + + messages[] + conversation history + + + + + + + LLM + stop_reason? + + + + + + + tool_use + + + + no + + + + Return Result + + + + yes + + + + trigger_hooks + PreToolUse + + + + + + + TOOL_HANDLERS + + + + bash read write edit glob + + + + todo_write + + + in-process stateful TODO list + + + + + + + PostToolUse + + + + + + + + + Result appended to messages[], loop continues + + + + + + + Nag Reminder + if rounds_since_todo >= 3: inject <reminder>Update your todos.</reminder> + + \ No newline at end of file diff --git a/web/public/course-assets/s06_subagent/subagent-overview.en.svg b/web/public/course-assets/s06_subagent/subagent-overview.en.svg deleted file mode 100644 index d6eb4d6f5..000000000 --- a/web/public/course-assets/s06_subagent/subagent-overview.en.svg +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - Subagent — Independent messages[], All Intermediate Steps Discarded - - - - Parent Agent - - - - messages[] - - - - - - - LLM - - - - tool_use - - - - TOOL_HANDLERS - - - - Base Tools - bash / read / write / ... - - - - task → spawn - - - - tool_result - - - append messages[] - Normal tool results also append to messages[] - - - - Subagent (Fresh Context) - - - - messages = [task] - fresh — no parent history - - - - - - - LLM - - - - Own while loop (max 30 rounds) - bash · read · write · edit · glob - No task — recursive spawn forbidden - - - - Intermediate 30+ tool calls + results - All discarded ✗ - - - - ✓ Extract only final text → return to Parent - - - - - ① task desc - - - - - ② summary - - - - - - s05 Preserved: loop, hooks, todo_write, 6 base tools - - - s06 New: task tool + spawn_subagent() — independent messages[], returns only summary - - - - ① Parent → Sub: - task description (a short string) - ② Sub → Parent: - extract_text() (final conclusion only) - diff --git a/web/public/course-assets/s06_subagent/subagent-overview.ja.svg b/web/public/course-assets/s06_subagent/subagent-overview.ja.svg deleted file mode 100644 index 87a457049..000000000 --- a/web/public/course-assets/s06_subagent/subagent-overview.ja.svg +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - Subagent — 独立した messages[]、中間過程はすべて破棄 - - - - 親 Agent - - - - messages[] - - - - - - - LLM - - - - tool_use - - - - TOOL_HANDLERS - - - - 基本ツール - bash / read / write / ... - - - - task → spawn - - - - tool_result - - - messages[] に追加 - 通常ツール結果も messages[] に戻る - - - - サブエージェント(新規コンテキスト) - - - - messages = [task] - 新規 — 親の会話を継承しない - - - - - - - LLM - - - - 独自の while ループ(最大 30 ラウンド) - bash · read · write · edit · glob - task なし — 再帰 spawn 禁止 - - - - 中間 30+ ラウンドのツール呼び出し + 結果 - すべて破棄 ✗ - - - - ✓ 最後のテキストのみ抽出 → 親に返却 - - - - - ① task 説明 - - - - - ② summary - - - - - - s05 保持:ループ、フック、todo_write、6 つの基本ツール - - - s06 新規:task ツール + spawn_subagent() — 独立 messages[]、要約のみ返却 - - - - ① 親 → サブ: - task description(短い文字列) - ② サブ → 親: - extract_text()(最終結論のみ) - diff --git a/web/public/course-assets/s06_subagent/subagent-overview.svg b/web/public/course-assets/s06_subagent/subagent-overview.svg index c18d660cc..ae5c8eab9 100644 --- a/web/public/course-assets/s06_subagent/subagent-overview.svg +++ b/web/public/course-assets/s06_subagent/subagent-overview.svg @@ -1,125 +1,142 @@ - + - - - - - - - - - - + + + + - - - - - - - - - - - Subagent — 独立 messages[],中间过程全部丢弃 - - - - Parent Agent - - - - messages[] - - - - - - - LLM - - - - tool_use - - - - TOOL_HANDLERS - - - - 基础工具 - bash / read / write / ... - - - - task → spawn - - - - tool_result - - - append messages[] - 普通工具结果也回填 messages[] - - - - Subagent (全新上下文) - - - - messages = [task] - fresh — 不继承父对话 - - - - - - - LLM - - - - 自己的 while 循环(最多 30 轮) - bash · read · write · edit · glob - 无 task — 禁止递归 spawn - - - - 中间 30+ 轮工具调用 + 结果 - 全部丢弃 ✗ - - - - ✓ 只提取最后一段文本 → 返回给 Parent - - - - - ① task 描述 - - - - - ② summary - - - - - - s05 保留:循环、hook、todo_write、6 个基础工具 - - - s06 新增:task 工具 + spawn_subagent() — 独立 messages[],只回传摘要 - - - - ① Parent → Sub: - task description(一小段文字) - ② Sub → Parent: - extract_text()(只有最终结论) - + + + + + Subagent — Independent messages[], Intermediate Steps Discarded + + + + + Parent Agent + + + + messages[] + + + + + + + LLM + + + + tool_use + + + + TOOL_HANDLERS + + + + Base Tools + bash/read/write/... + + + + task + spawn_subagent() + + + + tool_result + + + + + + + append to messages[] + + + + Subagent (Fresh Context) + + + + messages = [task] + fresh — no parent history + + + + + + + LLM + + + + Own while loop (max 30 rounds) + bash / read / write / edit / glob + No task tool — no recursive spawn + + + + 30+ intermediate tool calls + ALL DISCARDED + + + + extract_text() — only final conclusion returned + + + + + + + + 1 task desc + + + + + + + + 2 summary + + + + + + + 1 Parent -> Subagent + task description (short text prompt) + 2 Subagent -> Parent + extract_text() (final conclusion only) + + + + Key Design Decisions + + + Context isolation + Fresh messages[] per subagent + + + Return conclusion + extract_text(last_msg) + + + No recursion + No task tool in subagent + + + Security enforced + PreToolUse hooks still apply + + + + + \ No newline at end of file diff --git a/web/public/course-assets/s07_skill_loading/skill-overview.en.svg b/web/public/course-assets/s07_skill_loading/skill-overview.en.svg deleted file mode 100644 index ff31907ed..000000000 --- a/web/public/course-assets/s07_skill_loading/skill-overview.en.svg +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - Skill Loading — catalog at startup, content on demand - - - History preserved - - - - messages[] - - - - - - - LLM - stop_reason=tool_use? - - - - No - - Return result - - - - Yes - - - - trigger_hooks - PreToolUse - - - - - - - TOOL_HANDLERS - - - - bash · read · write - - edit · glob · todo - - task (subagent) - - - load_skill - - - - Results appended to messages[], loop continues - - - - s07 new - - - - ① build_system() - Scan skills/ first line at startup - → inject SYSTEM prompt - - - - ② load_skill(name) - Read full SKILL.md at runtime - → inject tool_result - - - - SYSTEM has skill catalog, carried every turn - - - - - - - - History preserved (loop, hooks, TODO, subagent — unchanged) - - s07 new (startup catalog in SYSTEM + load_skill tool) - diff --git a/web/public/course-assets/s07_skill_loading/skill-overview.ja.svg b/web/public/course-assets/s07_skill_loading/skill-overview.ja.svg deleted file mode 100644 index 596dcd5b6..000000000 --- a/web/public/course-assets/s07_skill_loading/skill-overview.ja.svg +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - Skill Loading — 起動時にカタログ注入、実行時にオンデマンド読み込み - - - 過去章を保持 - - - - messages[] - - - - - - - LLM - stop_reason=tool_use? - - - - No - - 結果を返す - - - - Yes - - - - trigger_hooks - PreToolUse - - - - - - - TOOL_HANDLERS - - - - bash · read · write - - edit · glob · todo - - task (subagent) - - - load_skill - - - - 結果を messages[] に追加、ループ継続 - - - - s07 新規 - - - - ① build_system() - 起動時に skills/ の 1 行目をスキャン - → SYSTEM プロンプトに注入 - - - - ② load_skill(name) - 実行時に完全な SKILL.md を読み取り - → tool_result に注入 - - - - SYSTEM にスキルカタログ、毎ターン携帯 - - - - - - - - 過去章を保持(ループ、フック、TODO、サブ Agent — 変更なし) - - s07 新規(起動時カタログ注入 SYSTEM + load_skill ツール) - diff --git a/web/public/course-assets/s07_skill_loading/skill-overview.svg b/web/public/course-assets/s07_skill_loading/skill-overview.svg index 600747bac..735bb5646 100644 --- a/web/public/course-assets/s07_skill_loading/skill-overview.svg +++ b/web/public/course-assets/s07_skill_loading/skill-overview.svg @@ -1,110 +1,154 @@ - + - - - - - - - + - - + + - - - - - - - - - - - Skill Loading — 启动时注入目录,运行时按需加载内容 - - - 历史章节保留 - - - - messages[] - - - - - - - LLM - stop_reason=tool_use? - - - - - - 返回结果 - - - - - - - - trigger_hooks - PreToolUse - - - - - - - TOOL_HANDLERS - - - - bash · read · write - - edit · glob · todo - - task (subagent) - - - load_skill - - - - 结果追加到 messages[],循环继续 - - - - s07 新增 - - - - ① build_system() - 启动时扫描 skills/ 第一行 - → 注入 SYSTEM prompt - - - - ② load_skill(name) - 运行时读完整 SKILL.md - → 注入 tool_result - - - - SYSTEM 含技能目录,每轮都带 - - - - - - - - 历史章节保留(循环、钩子、TODO、subagent — 完全不变) - - s07 新增(启动时目录注入 SYSTEM + load_skill 工具) - + + + + + Skill Loading + Inject catalog at startup, load content on demand at runtime + + + + + + messages[] + + + + + + + LLM + + + + + + + tool + stop_reason? + + + + No + + + + Return Result + + + + Yes + + + + trigger_hooks + PreToolUse + + + + + + + TOOL_HANDLERS + + + + bash read write + + + + edit glob todo + + + + task (subagent) + + + + load_skill + + + + + + Result appended to messages[], loop continues + + + + + + Level 1 + build_system() + Scan skills/ at + startup + + + + Level 2 + load_skill() + Read full SKILL.md + at runtime + + + + + + + + SYSTEM += skill catalog + + + + + + + + + + + + + + + + + Level + Location + Timing + Cost + + + + + + + + + + + + 1. Catalog + system prompt + (name + description) + Injected at startup + ~100 tokens/skill + carried every turn + + + 2. Content + tool_result + (full SKILL.md) + Agent calls load_skill + ~2000 tokens/skill + on demand only + + \ No newline at end of file diff --git a/web/public/course-assets/s08_context_compact/auto-compact.en.svg b/web/public/course-assets/s08_context_compact/auto-compact.en.svg deleted file mode 100644 index 30f5d7860..000000000 --- a/web/public/course-assets/s08_context_compact/auto-compact.en.svg +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - L4: autoCompact — LLM Full Summary - - - - Trigger Condition - All three preprocessing layers have run, estimated tokens > contextWindow - maxOutputTokens - 13_000. - Tries sessionMemoryCompact first (lightweight summary from existing memory), only calls LLM if insufficient. - - - - Step 1: Save transcript - Write conversation to .transcripts/ - One JSONL message per line - File: transcript_{time}.jsonl - Full transcript stays on disk - - - - - Step 2: LLM generates summary - Send conversation history to LLM - Summary must include 9 sections: - request · concepts · files · errors - resolutions · user messages · todos - current state · next steps - - - - - Step 3: Replace message list - All old messages → 1 summary - Model continues from summary - Includes recently_read file list - ⚠ This is an irreversible operation - - - - Before messages - user - assistant - user - assistant - user - ~180 messages, occupying 62K tokens - - - - - After messages - - [Compacted] Summary: goal → create hello.py ... - Recent files: hello.py, README.md ... - ~1 message, occupying 1K tokens - - - - Circuit breaker: - 3 consecutive autocompact failures → stop retrying. Prevents wasting API calls when context is unrecoverable. - diff --git a/web/public/course-assets/s08_context_compact/auto-compact.ja.svg b/web/public/course-assets/s08_context_compact/auto-compact.ja.svg deleted file mode 100644 index b83a3f500..000000000 --- a/web/public/course-assets/s08_context_compact/auto-compact.ja.svg +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - L4: autoCompact — LLM 完全要約 - - - - トリガー条件 - 前 3 層の前処理を全て実行後、推定 token > contextWindow - maxOutputTokens - 13_000。 - まず sessionMemoryCompact を試行(既存のメモリで軽量要約)、不足時のみ LLM を呼び出し。 - - - - ステップ 1:transcript 保存 - 完全な対話を .transcripts/ に書き込み - JSONL 形式、1 行 1 メッセージ - transcript_{time}.jsonl - 内容はディスクに残る - - - - - ステップ 2:LLM 要約生成 - 対話履歴を LLM に送信 - 要約は 9 つのセクションを含む: - リクエスト・概念・ファイル・エラー・解決 - ユーザーメッセージ・TODO・現在・次ステップ - 1 回のみ生成 - - - - - ステップ 3:要約に置換 - 全旧メッセージ → 1 件の要約に - モデルは要約から作業を継続 - recently_read を添付 - ⚠ これは復元不可能な操作 - - - - 圧縮前 messages - user - assistant - user - assistant - user - ~180 件のメッセージ、62K トークンを占有 - - - - - 圧縮後 messages - - [Compacted] 要約:目標 → hello.py を作成 ... - 最近のファイル:hello.py, README.md ... - ~1 件のメッセージ、1K トークンを占有 - - - - サーキットブレーカー: - autocompact が連続 3 回失敗 → リトライ停止。コンテキストが復元不可能な場合の API 呼び出しの無駄な反復を防止。 - diff --git a/web/public/course-assets/s08_context_compact/auto-compact.svg b/web/public/course-assets/s08_context_compact/auto-compact.svg index c7691f956..ca0c21d17 100644 --- a/web/public/course-assets/s08_context_compact/auto-compact.svg +++ b/web/public/course-assets/s08_context_compact/auto-compact.svg @@ -1,72 +1,112 @@ - + - - - - - + + + + + - - - - L4: autoCompact — LLM 全量摘要 - - - - 触发条件 - 前三层预处理全跑完,估算 token > contextWindow - maxOutputTokens - 13_000。 - 先尝试 sessionMemoryCompact(用已有记忆做轻量摘要),不足才调 LLM。 - - - - 步骤 1:保存 transcript - 完整对话写入 .transcripts/ - JSONL 格式,一行一条消息 - 文件名:transcript_{timestamp}.jsonl - 信息没有丢失,只是移出活跃区 - - - - - 步骤 2:LLM 生成摘要 - 把对话历史发给 LLM - 摘要需包含 9 个部分: - 请求·概念·文件·错误·解决 - 用户消息·待办·当前·下一步 - 只生成一次 - - - - - 步骤 3:替换消息列表 - 所有旧消息 → 1 条摘要 - 模型从摘要继续工作 - 附带 recently_read 文件列表 - ⚠ 这是无法恢复的操作 - - - - 压缩前 messages - user - assistant - user - assistant - user - ~180 条消息,占 62K token - - - - - 压缩后 messages - - [Compacted] 摘要:目标 → 创建 hello.py ... - 最近文件:hello.py, README.md ... - ~1 条消息,占 1K token - - - - 熔断器: - 连续 autocompact 失败 3 次 → 停止重试。防止上下文不可恢复时反复浪费 API 调用。 - + + + + + L4: compact_history + Full LLM Summary — when L1-L3 are exhausted and tokens still exceed threshold + + + + Trigger Condition + L1-L3 pre-processing complete; estimated tokens > contextWindow - maxOutputTokens - 13,000 + + + + Step 1 + Save Transcript + + Full conversation written to + + + .transcripts/ + JSONL format, one msg per line + Preserves recoverable record + + + + + + + + Step 2 + LLM Generates Summary + + Send history to LLM + Summary must include: + goals, concepts, files, errors, + resolutions, user msgs, TODOs, + current state, next steps + + + + + + + + Step 3 + Replace Messages + + All old msgs replaced + with 1 summary message + Attaches recently_read + file list for re-access + + + + + + Before Compaction + + + + + user + + + assistant + + + user + + + assistant + + + user + + ... many more messages ... + ~180 messages, ~62K tokens + + + + + + + + After Compaction + + + + + [Compacted] Goal: create hello.py + Recent files: hello.py, README.md + + ~1 message, ~1K tokens + + + + Circuit Breaker + + 3 consecutive autocompact failures → stop retrying + Prevents wasting API calls when context is unrecoverable + \ No newline at end of file diff --git a/web/public/course-assets/s08_context_compact/compact-overview.en.svg b/web/public/course-assets/s08_context_compact/compact-overview.en.svg deleted file mode 100644 index 542b15663..000000000 --- a/web/public/course-assets/s08_context_compact/compact-overview.en.svg +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Context Compact — Compression Before LLM Call, Three Trigger Modes - - - s07 Preserved - s08 New - - - - messages[] - (s07 preserved) - - - - - - - Compression Pipeline - - - - ① Every Turn · Unconditional · 0 API - - - L3 tool_result_budget - - - L1 snip_compact - - - L2 micro_compact - - - - - - - Over threshold? - - - No → Pass - Straight to LLM - - - Yes↓ - - - - ② Conditional · Token Over Threshold · 1 API - - - L4 compact_history - - - - - - - LLM - stop_reason=tool_use? - - - - No - - Return Result - - - - Yes - - - - TOOL_HANDLERS - bash · read · write - task · load_skill · ... - - - - API error - - retry to compression pipeline - - - - ③ Emergency Trigger - API returns prompt_too_long - → reactive_compact → retry - - - - Tool results appended to messages[] → next turn → compress again → LLM - - - - - - s07 Preserved: loop, hooks, skill loading, sub-agents - - - ① Every Turn Auto: L3→L1→L2 run unconditionally before each LLM call, 0 API - - - ② Conditional: after L3/L1/L2, tokens still over threshold → compact_history, 1 API - - - ③ Emergency: API returns prompt_too_long → reactive_compact → retry - - Three modes with increasing cost: 0 API → 1 API → 1 API + more aggressive trimming - diff --git a/web/public/course-assets/s08_context_compact/compact-overview.ja.svg b/web/public/course-assets/s08_context_compact/compact-overview.ja.svg deleted file mode 100644 index 350cd13e8..000000000 --- a/web/public/course-assets/s08_context_compact/compact-overview.ja.svg +++ /dev/null @@ -1,138 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Context Compact — LLM 呼び出し前に圧縮、3 つのトリガーモード - - - s07 保持 - s08 新規 - - - - messages[] - (s07 保持) - - - - - - - 圧縮パイプライン - - - - ① 毎ターン自動 · 無条件 · 0 API - - - L3 tool_result_budget - - - L1 snip_compact - - - L2 micro_compact - - - - - - - 閾値超過? - - - No → 通過 - 直接 LLM へ - - - Yes↓ - - - - ② 条件 · トークン閾値超過 · 1 API - - - L4 compact_history - - - - - - - LLM - stop_reason=tool_use? - - - - No - - 結果を返す - - - - Yes - - - - TOOL_HANDLERS - bash · read · write - task · load_skill · ... - - - - API 例外 - - 圧縮パイプラインへ再試行 - - - - ③ 緊急トリガー - API が prompt_too_long を返す - → reactive_compact → リトライ - - - - ツール結果を messages[] に追加 → 次ターン → 再圧縮 → LLM - - - - - - s07 保持:ループ、フック、スキルロード、サブエージェント - - - ① 毎ターン自動:L3→L1→L2 が各 LLM 呼び出し前に無条件実行、0 API - - - ② 条件トリガー:L3/L1/L2 後もトークン超過 → compact_history、1 API - - - ③ 緊急トリガー:API が prompt_too_long を返す → reactive_compact → リトライ - - 3 つのモードはコスト増加:0 API → 1 API → 1 API + より積極的なトリム - diff --git a/web/public/course-assets/s08_context_compact/compact-overview.svg b/web/public/course-assets/s08_context_compact/compact-overview.svg index 837e9bb00..d8aa12a11 100644 --- a/web/public/course-assets/s08_context_compact/compact-overview.svg +++ b/web/public/course-assets/s08_context_compact/compact-overview.svg @@ -1,138 +1,131 @@ - + - - + + - - + + - - - - - - - - - - - - - - - - - - - - Context Compact — 压缩插在 LLM 调用前,三种触发模式 - - - s07 保留 - s08 新增 - - - - messages[] - (s07 保留) - - - - - - - 压缩管线 - - - - ① 每轮自动 · 无条件 · 0 API - - - L3 tool_result_budget - - - L1 snip_compact - - - L2 micro_compact - - - - - - - 超阈值? - - - 否 → 通过 - 直接进 LLM - - - 是↓ - - - - ② 条件触发 · token 超阈值 · 1 API - - - L4 compact_history - - - - - - - LLM - stop_reason=tool_use? - - - - - - 返回结果 - - - - - - - - TOOL_HANDLERS - bash · read · write - task · load_skill · ... - - - - API 异常 - - 重试回到压缩管线 - - - - ③ 异常触发 - API 返回 prompt_too_long - → reactive_compact → 重试 - - - - 工具结果追加到 messages[] → 下一轮 → 再次压缩 → LLM - - - - - - s07 保留:循环、hook、技能加载、子 Agent + + - - ① 每轮自动:L3→L1→L2 在每次 LLM 调用前无条件执行,0 API + + Context Compact Overview + Compression before each LLM call — three trigger modes + - - ② 条件触发:L3/L1/L2 跑完 token 仍超阈值 → compact_history,1 API + - - ③ 异常触发:API 返回 prompt_too_long → reactive_compact → 重试 + + + messages[] + history + + + - 三种模式的代价递增:0 API → 1 API → 1 API + 更激进的裁剪 + + + Compression Pipeline + + + + MODE 1 Per-turn auto · 0 API calls + + + + L3 tool_result_budget + + + + + + + L1 snip_compact + + + + + + + L2 micro_compact + + + + + + + tokens > + threshold? + + + + NO + + + + YES + + + + MODE 2 Conditional · 1 API call + + + + L4 compact_history + + + + + + + + + + + + + + LLM + stop_reason? + + + + tool_use + + + + TOOL_HANDLERS + bash · read · write ... + + + + end_turn + + + + Return Result + + + + + + Tool results appended to messages[] → next turn → compress again → LLM + + + + + MODE 3 Reactive trigger + API returns prompt_too_long + reactive_compact → retry + + + + API error + + + + Retry back to compression pipeline diff --git a/web/public/course-assets/s08_context_compact/compaction-layers.en.svg b/web/public/course-assets/s08_context_compact/compaction-layers.en.svg deleted file mode 100644 index 5a27e96da..000000000 --- a/web/public/course-assets/s08_context_compact/compaction-layers.en.svg +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - Context Compaction — Pre-processing Pipeline + Auto-compact + Emergency Fallback - - - - Design Principles - Cheap operations first, expensive later - Trim text before dropping messages - Drop messages before calling LLM - - - - Increasing Cost - Text ops → LLM summary → Emergency trim - 0 API · 0 API · 0 API · 1 API · 1 API - - - - Pre-processing Pipeline (execution order: L3 → L1 → L2, before every LLM call, 0 API) - - - - L3 - toolResultBudget - tool_result total > 200KB → spill largest item - keep full content - Trigger: every turn, before microCompact can replace full content - - - - - - - L1 - snipCompact - messages > 50 → trim middle - keep head/tail - Trigger: message count exceeds threshold - - - - - - - L2 - microCompact - old tool_result → placeholder (keep latest 3) - compact old - Trigger: every turn automatically; tutorial uses text placeholder - - - - Auto-compact Decision (triggered when pre-processing is insufficient, 1 API call) - - - - L4 - autoCompact - tokens over threshold → LLM summary - 1 API call - Threshold: contextWindow - maxOutputTokens - 13,000 · Try sessionMemoryCompact first, then LLM - Circuit breaker: stop retrying after 3 consecutive failures - - - - Emergency Fallback (triggered when API still returns prompt_too_long) - - - - Emrg - reactiveCompact - API returns 413 / prompt_too_long → byte-level trim - Keep last 5 + summary; more aggressive than autoCompact - - diff --git a/web/public/course-assets/s08_context_compact/compaction-layers.ja.svg b/web/public/course-assets/s08_context_compact/compaction-layers.ja.svg deleted file mode 100644 index 851905483..000000000 --- a/web/public/course-assets/s08_context_compact/compaction-layers.ja.svg +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - コンテキスト圧縮 — 前処理パイプライン + 自動圧縮 + 緊急フォールバック - - - - 設計原則 - 安価な処理を先に、高価な処理を後に - テキスト修正 → メッセージ削除の順 - メッセージ削除 → LLM 呼び出しの順 - - - - コスト増加 - テキスト操作 → LLM 要約 → 緊急トリム - 0 API · 0 API · 0 API · 1 API · 1 API - - - - 前処理パイプライン(実行順:L3 → L1 → L2、各 LLM 呼び出し前に自動実行、0 API) - - - - L3 - toolResultBudget - tool_result 合計 > 200KB → 最大項目を退避 - 完全内容を保持 - トリガー:毎ターン、microCompact が完全内容を置換する前に実行 - - - - - - - L1 - snipCompact - メッセージ > 50 → 中間をトリム - 先頭/末尾保持 - トリガー:メッセージ数が閾値を超過 - - - - - - - L2 - microCompact - 古い tool_result → プレースホルダー(最新 3 件保持) - 旧結果を圧縮 - トリガー:毎ターン自動実行、チュートリアル版はテキストプレースホルダーで模擬 - - - - 自動圧縮判定(前処理で不足時にトリガー、1 API 呼び出し) - - - - L4 - autoCompact - トークンが閾値超過 → LLM 全量要約 - 1 API 呼び出し - 閾値: contextWindow - maxOutputTokens - 13,000 · sessionMemoryCompact を先に試行、不足時のみ LLM 呼び出し - サーキットブレーカー:連続 3 回失敗後にリトライ停止 - - - - 緊急フォールバック(API が引き続き prompt_too_long を返す場合にトリガー) - - - - 緊急 - reactiveCompact - API が 413 / prompt_too_long を返す → バイト単位でトリム - 最後の 5 件 + 要約を保持、autoCompact より積極的 - - diff --git a/web/public/course-assets/s08_context_compact/compaction-layers.svg b/web/public/course-assets/s08_context_compact/compaction-layers.svg index 818b44e51..02a6c551a 100644 --- a/web/public/course-assets/s08_context_compact/compaction-layers.svg +++ b/web/public/course-assets/s08_context_compact/compaction-layers.svg @@ -1,98 +1,139 @@ - + - - - - - - - - - - - - - - + + + + + - + + - - - - 上下文压缩 — 预处理管线 + 自动压缩 + 应急兜底 + + Context Compaction — Four-Layer Pipeline + Cheap first, expensive last — 3 pre-processors (0 API) + 1 LLM summary + emergency fallback - - - 设计原则 - 便宜的先跑,贵的后跑 - 能改文本 → 不删整条 - 能删整条 → 不调 LLM + + + Design Principles + Text ops before dropping messages + Drop messages before calling LLM + LLM summary before emergency trim - - - 代价递增 - 文本操作 → LLM 摘要 → 应急裁剪 - 0 API · 0 API · 0 API · 1 API · 1 API + + + Cost Escalation + Text ops (0 API) — lowest cost + + LLM summary (1 API) — moderate cost + + 0 API -> 0 API -> 0 API -> 1 API - - - 预处理管线(执行顺序:L3 → L1 → L2,每轮 LLM 调用前自动执行,0 API) + + + Pre-Processing Pipeline + (Execution order: L3 → L1 → L2 | runs before each LLM call | 0 API cost) - - L3 - toolResultBudget - tool_result 总和 > 200KB → 最大项落盘 - 保留完整内容 - 触发:每轮自动,必须在 microCompact 之前保留完整内容 + + + + L3 + toolResultBudget + tool_result total > 200KB → persist largest items to disk + Preserves full content in .task_outputs/ before placeholders + + + total > 200KB? + persist_large_output() + + + 0 API - - + + - - L1 - snipCompact - 消息 > 50 条 → 裁掉中间 - 保留头尾 - 触发:消息数超过阈值 + + + + L1 + snipCompact + messages > 50 → keep head 3 + tail 47, trim middle + Boundary guard: tool_use must stay paired with tool_result + + + len(msgs) > 50? + head[:3] + tail[47:] + + + 0 API - - + + - - L2 - microCompact - 旧 tool_result → 占位符(保留最近 3 条) - 压旧结果 - 触发:每轮自动,教学版用文本占位符模拟 - - - - 自动压缩决策(预处理不够时触发,1 API 调用) - - - - L4 - autoCompact - token 超阈值 → LLM 全量摘要 - 1 API 调用 - 阈值: contextWindow - maxOutputTokens - 13,000 · 先尝试 sessionMemoryCompact,不够才调 LLM - 熔断:连续失败 3 次后停止重试 - - - - 应急兜底(API 仍然返回 prompt_too_long 时触发) - - - - 应急 - reactiveCompact - API 返回 413 / prompt_too_long → 字节级裁剪 - 保留最后 5 条 + 摘要,比 autoCompact 更激进 - - + + + + L2 + microCompact + Old tool_result → one-line placeholder (keep most recent 3) + Clears stale content while preserving recent working state + + + keep_recent = 3 + older → placeholder + + + 0 API + + + + + + + still over threshold? + + Auto Compact (1 API call) + + + + + + L4 + compactHistory + tokens > threshold → LLM summary → replace messages + Threshold: contextWindow - maxOutputTokens - 13K | 3-fail breaker + + + write_transcript() + summarize_history() + + + 1 API + + + + + + + API returns prompt_too_long? + + Emergency Fallback + + + + + + EM + reactiveCompact + API returns 413 / prompt_too_long → aggressive byte-level trim + Keep last 5 msgs + summary | Retry: 1 | More aggressive + + + summary + tail[-5:] + max_retries = 1 + \ No newline at end of file diff --git a/web/public/course-assets/s08_context_compact/layer1-budget.en.svg b/web/public/course-assets/s08_context_compact/layer1-budget.en.svg deleted file mode 100644 index 1870c59b4..000000000 --- a/web/public/course-assets/s08_context_compact/layer1-budget.en.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - L3: toolResultBudget — Large Result Persistence - - - - Pain Point - Model read 30 files in one turn; total tool_result adds up to 500KB, filling the entire context window - - - Before - - tool_result: (78KB) ... - tool_result: (142KB) ... - tool_result: (290KB) ... - Total 510KB → over budget - - - - - - After - - tool_result: <persisted-output> - Full output: .task_outputs/t1.txt - Preview: (first 2000 chars) ... - Total 18KB → normal - - - - How - 1. Sum the size of all tool_result in the latest turn - 2. Over 200KB → sort by size, persist the largest to .task_outputs/tool-results/ - 3. Keep only <persisted-output> marker + first 2000 chars preview in context - - - - Result: No data lost (full data on disk), context drops from 510KB to ~18KB, 0 API calls - diff --git a/web/public/course-assets/s08_context_compact/layer1-budget.ja.svg b/web/public/course-assets/s08_context_compact/layer1-budget.ja.svg deleted file mode 100644 index b76862cbc..000000000 --- a/web/public/course-assets/s08_context_compact/layer1-budget.ja.svg +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - - - - - - - L3: toolResultBudget — 大結果の永続化 - - - - ペインポイント - モデルが一度に 30 ファイルを読み込み、単一ターンの tool_result が合計 500KB に達し、コンテキストウィンドウを圧迫 - - - 圧縮前 - - tool_result: (78KB) ... - tool_result: (142KB) ... - tool_result: (290KB) ... - 合計 510KB → 予算超過 - - - - - - 圧縮後 - - tool_result: <persisted-output> - Full output: .task_outputs/t1.txt - Preview: (先頭 2000 文字) ... - 合計 18KB → 正常 - - - - 方法 - 1. 最終ターンの全 tool_result の合計サイズを集計 - 2. 200KB 超過 → サイズ順にソートし、最大のものから .task_outputs/tool-results/ に永続化 - 3. コンテキストには <persisted-output> マーカー + 先頭 2000 文字のプレビューのみ残す - - - - 結果:情報は失われていない(ディスクに完全なデータあり)、コンテキストは 510KB → ~18KB に削減、0 回 API 呼び出し - diff --git a/web/public/course-assets/s08_context_compact/layer1-budget.svg b/web/public/course-assets/s08_context_compact/layer1-budget.svg index 53f2d5c77..d27b6dad4 100644 --- a/web/public/course-assets/s08_context_compact/layer1-budget.svg +++ b/web/public/course-assets/s08_context_compact/layer1-budget.svg @@ -1,50 +1,68 @@ - + - - - - - + + + + + - - - - L3: toolResultBudget — 大结果落盘 - - - - 痛点 - 模型一次读了 30 个文件,单轮 tool_result 加起来 500KB,直接把上下文窗口打满 - - - 压缩前 - - tool_result: (78KB) ... - tool_result: (142KB) ... - tool_result: (290KB) ... - 合计 510KB → 超预算 - - - - - - 压缩后 - - tool_result: <persisted-output> - Full output: .task_outputs/t1.txt - Preview: (前 2000 字符) ... - 合计 18KB → 正常 - - - - 怎么做 - 1. 统计最后一轮所有 tool_result 的总大小 - 2. 超过 200KB → 按大小排序,从最大的开始落盘到 .task_outputs/tool-results/ - 3. 上下文里只留 <persisted-output> 标记 + 前 2000 字符预览 - - - - 结果:信息没丢(磁盘有完整数据),上下文从 510KB 降到 ~18KB,0 次 API 调用 - + + + + + L3: toolResultBudget + Persist Large Results to Disk + + + + Problem + Model reads 30 files in one turn — tool_results total 500 KB, filling the entire context window. + + + Before + + + + tool_result: (78 KB) ... + tool_result: (142 KB) ... + tool_result: (290 KB) ... + total: 510 KB + + + 510 KB — over budget + + + + + + After + + + + <persisted-output> + path: .task_outputs/t1.txt + preview: (first 2000 chars) + total: ~18 KB + + + 18 KB — within budget + + + + How it works + + 1. + Sum the size of all tool_result blocks in the last user message. + + 2. + If total > 200 KB, sort by size desc, persist largest to .task_outputs/tool-results/ + + 3. + Keep only <persisted-output> marker + first 2000 chars preview in context. + + + + Result: Data saved to disk. Context reduced from 510 KB to ~18 KB. 0 API calls. + \ No newline at end of file diff --git a/web/public/course-assets/s08_context_compact/micro-compact.en.svg b/web/public/course-assets/s08_context_compact/micro-compact.en.svg deleted file mode 100644 index 8f5c5dc87..000000000 --- a/web/public/course-assets/s08_context_compact/micro-compact.en.svg +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - L2: microCompact — Old Result Placeholder Replacement - - - - Pain Point - After 10 reads, results 1-7 still sit in context. - They take space but are no longer useful. - - - Before (all 10 tool_result complete) - - - Read file A: (full content, 3200 chars)... - - Read file B: (full content, 1800 chars)... - - Read file C: (full content, 4500 chars)... - - Read file J: (full content, 2800 chars) - 7 old results waste ~25K chars - - - - - - After (keep only latest 3 complete) - - - [Earlier result compacted. Re-run if needed.] - - [Earlier result compacted. Re-run if needed.] - - [Earlier result compacted. Re-run if needed.] - - Read file J: (full content, 2800 chars) - Keep latest 3; first 7 become placeholders - - - - How (teaching version) - Iterate through tool_result, keep only latest 3 complete, replace older ones with placeholders. - Real CC - Clears old results via API cache_edits (without breaking prompt cache prefix), only for COMPACTABLE_TOOLS: - Read, Bash, Grep, Glob, WebSearch, WebFetch, Edit, Write. Teaching version uses text placeholders to simulate the same effect. - diff --git a/web/public/course-assets/s08_context_compact/micro-compact.ja.svg b/web/public/course-assets/s08_context_compact/micro-compact.ja.svg deleted file mode 100644 index a418c544f..000000000 --- a/web/public/course-assets/s08_context_compact/micro-compact.ja.svg +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - - - - - - - - - L2: microCompact — 旧結果のプレースホルダー置換 - - - - ペインポイント - 10 ファイルを読んでも、1〜7 回目の結果が残る。 - 古い内容が場所を取り続ける。 - - - 圧縮前(10 件の tool_result がすべて完全) - - - Read file A: (完全な内容, 3200 文字)... - - Read file B: (完全な内容, 1800 文字)... - - Read file C: (完全な内容, 4500 文字)... - - Read file J: (完全な内容, 2800 文字) - 7 件の旧結果が ~25K 文字を占有 - - - - - - 圧縮後(最新 3 件のみ完全保持) - - - [Earlier result compacted. Re-run if needed.] - - [Earlier result compacted. Re-run if needed.] - - [Earlier result compacted. Re-run if needed.] - - Read file J: (完全な内容, 2800 文字) - 最新 3 件を保持、前 7 件は置換 - - - - 方法(教学版) - tool_result を走査し、最新 3 件のみ完全保持、古いものはプレースホルダーに置換。 - 実際の CC - API cache_edits で旧結果をクリア(prompt cache プレフィックスを破壊しない)、COMPACTABLE_TOOLS のみ対象: - Read, Bash, Grep, Glob, WebSearch, WebFetch, Edit, Write。教学版はテキストプレースホルダーで同様の効果を模擬。 - diff --git a/web/public/course-assets/s08_context_compact/micro-compact.svg b/web/public/course-assets/s08_context_compact/micro-compact.svg index e1728f7d6..2fef06240 100644 --- a/web/public/course-assets/s08_context_compact/micro-compact.svg +++ b/web/public/course-assets/s08_context_compact/micro-compact.svg @@ -1,57 +1,66 @@ - + - - - - - + + - - - - L2: microCompact — 旧结果占位替换 - - - - 痛点 - Agent 连续读了 10 个文件,第 1-7 次的完整文件内容还躺在上下文里,占着位置但早就没用了 - - - 压缩前(10 条 tool_result 全部完整) - - - Read file A: (完整内容, 3200 字符)... - - Read file B: (完整内容, 1800 字符)... - - Read file C: (完整内容, 4500 字符)... - - Read file J: (完整内容, 2800 字符) - 7 条旧结果白占 ~25K 字符 - - - - - - 压缩后(只保留最近 3 条完整) - - - [Earlier result compacted. Re-run if needed.] - - [Earlier result compacted. Re-run if needed.] - - [Earlier result compacted. Re-run if needed.] - - Read file J: (完整内容, 2800 字符) - 只保留最近 3 条,前 7 条变占位 - - - - 怎么做(教学版) - 遍历 tool_result,只保留最近 3 条完整,更旧的替换为占位符。 - 真实 CC - 通过 API cache_edits 清除旧结果(不破坏 prompt cache 前缀),仅对 COMPACTABLE_TOOLS 生效: - Read, Bash, Grep, Glob, WebSearch, WebFetch, Edit, Write。教学版用文本占位模拟同样效果。 - + + + + + L2: micro_compact + Replace old tool_result with one-line placeholder + + + + Problem + Agent read 10 files. Reads 1-7 still in context, no longer needed, wasting ~25K chars. + + + Before (all 10 results intact) + + + + + + + Read A: (full content, 3200 chars) + Read B: (full content, 1800 chars) + Read C: (full content, 4500 chars) + ... 4 more old results ... + Read J: (full content, 2800 chars) + + + 7 old results waste ~25K chars + + + compact + + + + After (keep recent 3 intact) + + + + + + + [Compacted. Re-run if needed.] + [Compacted. Re-run if needed.] + [Compacted. Re-run if needed.] + ... 4 more compacted ... + Read J: (full content, 2800 chars) + + + Only recent 3 kept, 7 replaced + + + + How it works + Iterate tool_result blocks; keep most recent 3 intact, replace older ones with placeholder. + Scope + Applies to COMPACTABLE_TOOLS: Read, Bash, Grep, Glob, WebSearch, WebFetch, Edit, Write. + Cost + 0 API calls. Pure text replacement. If model needs old content, re-run the tool. + \ No newline at end of file diff --git a/web/public/course-assets/s09_memory/memory-overview.en.svg b/web/public/course-assets/s09_memory/memory-overview.en.svg deleted file mode 100644 index 51cd510b7..000000000 --- a/web/public/course-assets/s09_memory/memory-overview.en.svg +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - Memory — Memory loading, extraction, and consolidation on s08 compression pipeline - - - - s08 preserved - - s09 new - - - - messages[] - - - - - - - Compression - budget → snip → micro - → autoCompact - (s08) - - - - - - - Loading - LLM side-query select - inject file contents - ≤ 5 items - - - - - - - LLM - stop_reason - =tool_use? - - - - no, stop - - return result - - - - yes - - - - TOOL_HANDLERS - bash · read · write - edit · glob · task - - - - .memory/ — MEMORY.md index + *.md files (cross-session persistent) - - - - read - - - - Extraction (after each turn) - - - Consolidation: triggers at ≥ 10 files, dedup·merge·prune - - - - tool results → messages[] → compress → load memories → LLM → extract after each turn - - - - - s08 preserved: compression pipeline (budget → snip → micro → auto) + emergency trim + loop - - s09 new: Loading (index in SYSTEM + on-demand inject) + Extraction (after each turn) + Consolidation (threshold) - diff --git a/web/public/course-assets/s09_memory/memory-overview.ja.svg b/web/public/course-assets/s09_memory/memory-overview.ja.svg deleted file mode 100644 index 3007a22f8..000000000 --- a/web/public/course-assets/s09_memory/memory-overview.ja.svg +++ /dev/null @@ -1,104 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - Memory — s08 圧縮パイプラインに記憶の読み込み・抽出・整理を挿入 - - - - s08 維持 - - s09 追加 - - - - messages[] - - - - - - - 圧縮パイプライン - budget → snip → micro - → autoCompact - (s08) - - - - - - - Loading - LLM side-query 選択 - ファイル内容を注入 - ≤ 5 件 - - - - - - - LLM - stop_reason - =tool_use? - - - - なし、停止 - - 結果を返す - - - - あり - - - - TOOL_HANDLERS - bash · read · write - edit · glob · task - - - - .memory/ — MEMORY.md インデックス + *.md ファイル(セッション間永続化) - - - - 読み込み - - - - Extraction(毎ターン終了後) - - - Consolidation: ファイル ≥ 10 でトリガー、重複排除・統合・剪定 - - - - ツール結果 → messages[] → 圧縮 → 記憶読み込み → LLM → 毎ターン終了後に抽出 - - - - - s08 維持:圧縮パイプライン(budget → snip → micro → auto)+ 緊急トリム + ループ - - s09 追加:Loading(インデックス常駐 + オンデマンド注入)+ Extraction(毎ターン終了後)+ Consolidation(閾値トリガー) - diff --git a/web/public/course-assets/s09_memory/memory-overview.svg b/web/public/course-assets/s09_memory/memory-overview.svg index 8932df1be..4e5008043 100644 --- a/web/public/course-assets/s09_memory/memory-overview.svg +++ b/web/public/course-assets/s09_memory/memory-overview.svg @@ -1,104 +1,123 @@ - + - - - - - - - - - - + + + + - + + - - - Memory — 在 s08 压缩管线上,插入记忆加载、提取与整理 - - - - s08 保留 - - s09 新增 - - - - messages[] - - - - - - - 压缩管线 - budget → snip → micro - → autoCompact - (s08) - - - - - - - Loading - LLM side-query 选文件 - 注入文件内容 - ≤ 5 条 - - - - - - - LLM - stop_reason - =tool_use? - - - - 否,停止 - - 返回结果 - - - - - - - - TOOL_HANDLERS - bash · read · write - edit · glob · task - - - - .memory/ — MEMORY.md 索引 + *.md 文件(跨会话持久化) - - - - 读取 - - - - Extraction(每轮结束后) - - - Consolidation: 文件数 ≥ 10 时触发,去重·合并·剪枝 - - - - 工具结果追加到 messages[] → 压缩 → 加载记忆 → LLM → 每轮结束后提取 - - - - - s08 保留:压缩管线(budget → snip → micro → auto)+ 应急裁剪 + 循环 - - s09 新增:Loading(索引常驻 + 按需注入)+ Extraction(每轮结束后)+ Consolidation(阈值触发) - + Memory — Load, Extract & Consolidate + Knowledge that survives compaction and sessions + + + + + + messages[] + conversation + + + + + + + Compression + budget → snip → micro + → autoCompact + + + + + + + Loading + LLM side-query selects + relevant files (max 5) + + + + + + + LLM + tool_use? + + + + yes + + + + TOOL_HANDLERS + bash · read · write + edit · glob · task + + + + no + + + + Return Result + + + + + + .memory/ + MEMORY.md index + *.md files (cross-session) + + + + + read + + + + + Extraction + (after each turn) + + + + Consolidation: files >= 10 → deduplicate · merge · prune + + + + + + + + + + + + tool result → messages[] → compress → load → LLM → extract + + + + Memory Types: + + + user + who you are + + feedback + how to work + + project + what's happening + + + reference + where to find things + + Pipeline: Loading → Extraction → Consolidation + + \ No newline at end of file diff --git a/web/public/course-assets/s09_memory/memory-subsystems.en.svg b/web/public/course-assets/s09_memory/memory-subsystems.en.svg deleted file mode 100644 index 914f1b0fe..000000000 --- a/web/public/course-assets/s09_memory/memory-subsystems.en.svg +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - Memory System — Store · Load · Extract · Consolidate - - - - Storage - - .memory/*.md files - MEMORY.md index - - - - - - Load - - Index in SYSTEM (always) - LLM side-query select files - ≤ 5 items, fallback to keyword - - - - - - Extract - - After each turn - Extract prefs - Avoid duplicates - - - - Consolidate - - ≥ 10 files - Dedup · merge - CC: gated Dream - - - - .memory/ — MEMORY.md index + *.md files (YAML frontmatter: name / description / type) - - - - read/write - - - - write - - - - overwrite - - - - Four types: - user (who you are) · feedback (how to work) · project (what's happening) · reference (where to find things) - - - - CC Source Comparison - • Selection: LLM side-query (Sonnet selects), not embedding vector similarity - • Extraction timing: stop hook (after each turn ends), not after autoCompact - • Dream: time + sessions + file lock, not simple count - diff --git a/web/public/course-assets/s09_memory/memory-subsystems.ja.svg b/web/public/course-assets/s09_memory/memory-subsystems.ja.svg deleted file mode 100644 index 6bbd6814a..000000000 --- a/web/public/course-assets/s09_memory/memory-subsystems.ja.svg +++ /dev/null @@ -1,78 +0,0 @@ - - - - - - - - - - - - - - Memory System — ストレージ · 読み込み · 抽出 · 整理 - - - - ストレージ - - .memory/*.md ファイル - MEMORY.md インデックス - - - - - - 読み込み - - インデックスを SYSTEM に常駐 - LLM side-query でファイル選択 - ≤ 5 件、失敗時はキーワードに降格 - - - - - - 抽出 - - 毎ターン終了後 - 好み/制約を抽出 - 重複を回避 - - - - 整理 - - ≥ 10 ファイル - 重複排除・統合 - CC: Dream ゲート - - - - .memory/ — MEMORY.md インデックス + *.md ファイル(YAML frontmatter: name / description / type) - - - - 読み/書き - - - - 書き込み - - - - 上書き - - - - 4 種類の記憶: - user(あなたは誰か)· feedback(どう作業するか)· project(何が起きているか)· reference(どこで探すか) - - - - CC ソースコード対照 - • 記憶選択:LLM side-query(Sonnet が選択)、embedding ベクトル類似度ではない - • 抽出タイミング:stop hook(毎ターン終了後)、autoCompact 後ではない - • Dream:時間・セッション・ロックで判定 - diff --git a/web/public/course-assets/s09_memory/memory-subsystems.svg b/web/public/course-assets/s09_memory/memory-subsystems.svg index f7673169a..c125bd84e 100644 --- a/web/public/course-assets/s09_memory/memory-subsystems.svg +++ b/web/public/course-assets/s09_memory/memory-subsystems.svg @@ -1,78 +1,132 @@ - + - - - - - + + + + + - - - - Memory System — 存储 · 加载 · 提取 · 整理 - - - - 存储 - - .memory/*.md 文件 - MEMORY.md 索引 - - - - - - 加载 - - 索引常驻 SYSTEM - LLM side-query 选文件 - ≤ 5 条,失败降级到关键词 - - - - - - 提取 - - 每轮结束后触发 - LLM 提取偏好/约束 - 检查已有,避免重复 - - - - 整理 - - 文件 ≥ 10 触发 - 去重·合并·剪枝 - CC: 三层门控 - - - - .memory/ — MEMORY.md 索引 + *.md 文件(YAML frontmatter: name / description / type) - - - - 写入/读取 - - - - 写入 - - - - 覆写 - - - - 四类记忆: - user(你是谁)· feedback(怎么做事)· project(正在发生什么)· reference(东西在哪找) - - - - CC 源码对照 - • 记忆选择:LLM side-query(Sonnet 选),不是 embedding 向量相似度 - • 提取时机:stop hook 中触发(每轮结束后),不是 autoCompact 后 - • Dream 整理:三层门控(时间 ≥ 24h + 会话 ≥ 5 + 文件锁),不是简单计数 - + + + + + Memory Subsystems + Store · Load · Extract · Consolidate + + + + + + Store + + .memory/*.md files + MEMORY.md index + YAML frontmatter metadata + + + + + + + + Load + + Index in SYSTEM prompt + LLM side-query picks files + max 5, fallback to keywords + + + + + + + + Extract + + Triggered after each turn + LLM extracts prefs/facts + Dedup check + + + + + + + + Consolidate + + files >= 10 triggers + Dedup · Merge · Prune + Four-layer gating + + + + + .memory/ + MEMORY.md index + *.md files (YAML frontmatter: name / description / type) + + + + + write/read + + + + + write + + + + + overwrite + + + + + Four Memory Types + + + + user + who you are + + feedback + how to work + + project + what's happening + + reference + where to find + + + + + + + + + + + + + + + Implementation Notes + + + + + $ memory-selection + LLM side-query (Sonnet selects), not embedding similarity + + + $ extraction-timing + Triggered in stop hook (after each turn), not after autoCompact + + + $ dream-consolidation + Four gates: time >=24h + sessions >=5 + scan throttle + file lock + \ No newline at end of file diff --git a/web/public/course-assets/s10_system_prompt/system-prompt-overview.en.svg b/web/public/course-assets/s10_system_prompt/system-prompt-overview.en.svg deleted file mode 100644 index dfe0b9270..000000000 --- a/web/public/course-assets/s10_system_prompt/system-prompt-overview.en.svg +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - - - - - - - - - - - - - - System Prompt — PROMPT_SECTIONS + On-Demand Assembly + Cache - - - - s09 Preserved - - s10 New - - - - - - PROMPT_SECTIONS - ✓ identity (always) - ✓ tools (always) - ✓ workspace (always) - ○ memory - - - - - - - assemble_system_prompt - Input: context dict - Always: identity + tools + workspace - On-demand: memory - Output: "\n\n".join(selected) - - - - - - - get_system_prompt - json.dumps(context) - Hit → return cached - Miss → assemble + store - (s10 new) - - - - system=get_system_prompt(context) - - - - - - messages[] - - - - - - - Compression + Loading - snip → micro → budget → auto - → load memory (s09) - - - - - - - LLM - stop_reason=tool_use? - system assembled - - - - yes - - - - TOOL_HANDLERS - bash · read · write - (s09 preserved) - - - - Tool results → messages[] → compress → load memory → assemble prompt → LLM - - - - - s09 Preserved: loop, compression pipeline, memory loading, tool execution - - s10 New: PROMPT_SECTIONS (4 sections) + assemble_system_prompt + get_system_prompt (cache) - diff --git a/web/public/course-assets/s10_system_prompt/system-prompt-overview.ja.svg b/web/public/course-assets/s10_system_prompt/system-prompt-overview.ja.svg deleted file mode 100644 index 2bafa145f..000000000 --- a/web/public/course-assets/s10_system_prompt/system-prompt-overview.ja.svg +++ /dev/null @@ -1,107 +0,0 @@ - - - - - - - - - - - - - - - - - - - System Prompt — PROMPT_SECTIONS + オンデマンド組み立て + キャッシュ - - - - s09 保持 - - s10 新規 - - - - - - PROMPT_SECTIONS - ✓ identity (常時) - ✓ tools (常時) - ✓ workspace (常時) - ○ memory - - - - - - - assemble_system_prompt - 入力: context dict - 常時: identity + tools + workspace - オンデマンド: memory - 出力: "\n\n".join(selected) - - - - - - - get_system_prompt - json.dumps(context) - ヒット → キャッシュ返却 - ミス → assemble + 保存 - (s10 新規) - - - - system=get_system_prompt(context) - - - - - - messages[] - - - - - - - 圧縮 + ロード - snip → micro → budget → auto - → 記憶ロード (s09) - - - - - - - LLM - stop_reason=tool_use? - system assembled - - - - あり - - - - TOOL_HANDLERS - bash · read · write - (s09 保持) - - - - ツール結果 → messages[] → 圧縮 → 記憶ロード → プロンプト組み立て → LLM - - - - - s09 保持:ループ、圧縮パイプライン、記憶ロード、ツール実行 - - s10 新規:PROMPT_SECTIONS(4 セクション)+ assemble_system_prompt + get_system_prompt(キャッシュ) - diff --git a/web/public/course-assets/s10_system_prompt/system-prompt-overview.svg b/web/public/course-assets/s10_system_prompt/system-prompt-overview.svg index 40c7df771..9411bc53b 100644 --- a/web/public/course-assets/s10_system_prompt/system-prompt-overview.svg +++ b/web/public/course-assets/s10_system_prompt/system-prompt-overview.svg @@ -1,107 +1,150 @@ - + - - - - - - - + + + + - + + - - - System Prompt — PROMPT_SECTIONS + 按需拼接 + 缓存 - - - - s09 保留 - - s10 新增 - - - - - - PROMPT_SECTIONS - ✓ identity (始终) - ✓ tools (始终) - ✓ workspace (始终) - ○ memory - - - - - - - assemble_system_prompt - 输入: context dict - 始终: identity + tools + workspace - 按需: memory - 输出: "\n\n".join(selected) - - - - - - - get_system_prompt - json.dumps(context) - 命中 → 返回缓存 - 未命中 → assemble + 存 - (s10 新增) - - - - system=get_system_prompt(context) - - - - - - messages[] - - - - - - - 压缩 + Loading - snip → micro → budget → auto - → 加载记忆 (s09) - - - - - - - LLM - stop_reason=tool_use? - system assembled - - - - - - - - TOOL_HANDLERS - bash · read · write - (s09 保留) - - - - 工具结果 → messages[] → 压缩 → 加载记忆 → 组装 prompt → LLM - - - - - s09 保留:循环、压缩管线、记忆加载、工具执行 - - s10 新增:PROMPT_SECTIONS(4 段)+ assemble_system_prompt + get_system_prompt(缓存) - + System Prompt Assembly + PROMPT_SECTIONS + on-demand assembly + caching + + + + + PROMPT ASSEMBLY + + + + + PROMPT_SECTIONS + + + + + identity (always) + tools (always) + workspace (always) + memory (on-demand) + + + + + + + + assemble_system_prompt + + + + + input: + context dict + always: + id+tools+ws + on-demand: + memory + output: + join(parts) + + + + + + + + get_system_prompt + + + + + key = + json.dumps(ctx) + hit → return cached + miss → assemble+store + avoids re-assembly + + + + + + system = prompt + + + + + AGENT LOOP + + + + + messages[] + accumulating + + + + + + + Compress + Load + snip > micro > budget > auto + + + + + + + LLM + API call + + + + + + + tool? + + + + yes + + + + TOOL_HANDLERS + bash | read | write + + + + no + + + + return + + + + + + + + + result → messages → re-assemble + + + + Sections: + identity + (always) + tools + (always) + workspace + (always) + memory + (on-demand, if .memory/ exists) + \ No newline at end of file diff --git a/web/public/course-assets/s11_error_recovery/error-recovery-overview.en.svg b/web/public/course-assets/s11_error_recovery/error-recovery-overview.en.svg deleted file mode 100644 index 22790a3c5..000000000 --- a/web/public/course-assets/s11_error_recovery/error-recovery-overview.en.svg +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - Error Recovery — try/except wrapping LLM calls, three recovery modes - - - - s10 retained - - s11 new - - - - messages - - - - - prompt assembly - (s10) - - - - - compress + load - (s08-s09) - - - - - - LLM - try/except - - - - - TOOL_HANDLERS - bash · read · write - - - - error - - - - Error Recovery (classify, recover, retry LLM) - - - - Path 1 - max_tokens - Output truncated → escalate 8K→64K (once) / continuation prompt (max 3) - Trigger: stop_reason == "max_tokens" · Cost: 0-1 API · Recover then continue - - - - Path 2 - prompt_too_long - Context overflow → reactive compact → retry (one chance) - Trigger: API returns 413 · Cost: 1 API · Still over after compact → exit - - - - Path 3 - 429/529 - Transient failure → exponential backoff + jitter (max 10) / 3×529 → switch model - Trigger: RateLimitError / OverloadedError · Formula: min(500×2^n, 32s) + jitter - - - - Three most common recovery modes. CC has 13+ reason codes (image_error, aborted_streaming, etc.), each with dedicated handling. - All paths after recovery → continue back to LLM · Normal flow: tool results → messages → loop - diff --git a/web/public/course-assets/s11_error_recovery/error-recovery-overview.ja.svg b/web/public/course-assets/s11_error_recovery/error-recovery-overview.ja.svg deleted file mode 100644 index 36c4fd606..000000000 --- a/web/public/course-assets/s11_error_recovery/error-recovery-overview.ja.svg +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - Error Recovery — try/except で LLM 呼び出しをラップ、3 つの復旧モード - - - - s10 維持 - - s11 新規 - - - - messages - - - - - prompt assembly - (s10) - - - - - compress + load - (s08-s09) - - - - - - LLM - try/except - - - - - TOOL_HANDLERS - bash · read · write - - - - エラー - - - - エラー復旧(分類処理、復旧後 LLM に戻りリトライ) - - - - パス 1 - max_tokens - 出力が途切れた → 8K→64K に拡張(1 回)/ 続行プロンプト(最大 3 回) - トリガー: stop_reason == "max_tokens" · コスト: 0-1 API · 復旧後 continue - - - - パス 2 - prompt_too_long - コンテキスト超過 → reactive compact → リトライ(1 回のみ) - トリガー: API が 413 返却 · コスト: 1 API · 圧縮後も超過 → 終了 - - - - パス 3 - 429/529 - 一時障害 → 指数バックオフ + ジッター(最大 10 回)/ 3 回 529 → モデル切替 - トリガー: RateLimitError / OverloadedError · 式: min(500×2^n, 32s) + jitter - - - - 最も一般的な 3 つの復旧モード。CC は実際に 13+ の reason code を持ち(image_error, aborted_streaming 等)、それぞれ専用の処理がある。 - 全パス復旧後 → continue で LLM に戻る · 正常フロー: ツール結果 → messages → ループ - \ No newline at end of file diff --git a/web/public/course-assets/s11_error_recovery/error-recovery-overview.svg b/web/public/course-assets/s11_error_recovery/error-recovery-overview.svg index 63f4b2fe1..0757c5e84 100644 --- a/web/public/course-assets/s11_error_recovery/error-recovery-overview.svg +++ b/web/public/course-assets/s11_error_recovery/error-recovery-overview.svg @@ -1,98 +1,145 @@ - + - - - - - + + - - + + - - - - - - - - - - + + - - - Error Recovery — try/except 包裹 LLM 调用,三种恢复模式 - - - - s10 保留 - - s11 新增 - - - - messages - - - - - prompt assembly - (s10) - - - - - compress + load - (s08-s09) - - - - - - LLM - try/except - - - - - TOOL_HANDLERS - bash · read · write - - - - 报错 - - - - 错误恢复(分类处理,恢复后回到 LLM 重试) - - - - 路径 1 - max_tokens - 输出被截断 → 升级 8K→64K(一次)/ 续写提示(最多 3 次) - 触发: stop_reason == "max_tokens" · 代价: 0-1 API · 恢复后 continue - - - - 路径 2 - prompt_too_long - 上下文超限 → reactive compact → 重试(一次机会) - 触发: API 返回 413 · 代价: 1 API · 压缩过还是超 → 退出 - - - - 路径 3 - 429/529 - 临时故障 → 指数退避 + 抖动(最多 10 次)/ 3 次 529 → 切换模型 - 触发: RateLimitError / OverloadedError · 公式: min(500×2^n, 32s) + jitter - - - - 三种最常见的恢复模式。CC 实际有 13+ reason code(image_error、aborted_streaming 等),各有专门处理。 - 所有路径恢复后 → continue 回到 LLM · 正常流程: 工具结果 → messages → 循环 - + Error Recovery + try/except wraps LLM calls — three recovery patterns + + + + + + messages + + + + + + + prompt + assembly + + + + + + + compress + + load context + + + + + + + LLM + try/except + + + + + + + TOOL_HANDLERS + bash · read · write + + + + + tool result → append → next turn + + + + error + + + + Error Recovery — classify and recover, then continue back to LLM + + + + + + Path 1 + + max_tokens + + + Trigger: stop_reason == "max_tokens" + Action: Escalate 8K → 64K (once) / continuation prompt (up to 3×) + Truncated output NOT appended on first escalation — same request retried with more tokens + + + + max_tokens = 64000 + continue # retry + + + + + + + + + + + Path 2 + + prompt_too_long + + + Trigger: API returns 413 / context exceeds limit + Action: Reactive compact → retry (one chance only) + If still over limit after compaction → exit (compacting again won't help) + + + + messages[:] = + reactive_compact(messages) + continue # retry once + + + + + + + + + + + Path 3 + + 429/529 + + + Trigger: RateLimitError / OverloadedError + Action: Exponential backoff + jitter (up to 10 retries) + 3× consecutive 529 → fallback model. min(500×2^n, 32s) + jitter + + + + delay = min(500*2**n, 32000) + delay += random(0, 25%) + time.sleep(delay / 1000) + continue # retry + + + + + + + + These are the 3 most common recovery patterns. The system has 13+ reason codes + (image_error, aborted_streaming, etc.), each with dedicated handling. All recovery paths → continue back to LLM loop. + + \ No newline at end of file diff --git a/web/public/course-assets/s12_task_system/task-dag.en.svg b/web/public/course-assets/s12_task_system/task-dag.en.svg deleted file mode 100644 index 6a075113d..000000000 --- a/web/public/course-assets/s12_task_system/task-dag.en.svg +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - Task DAG — Dependency Example: Database → API → Tests → Deploy - - - - ✓ schema - completed - - - - - - - - ● endpoints - in_progress · owner: agent-1 - - - ○ docs - pending · blockedBy: schema ✓ - - - - - - - - ○ tests - blockedBy: endpoints ● - - - - - - ○ deploy - blockedBy: tests, docs - - - - - completed - - in_progress - - pending - → blockedBy (arrows = dependency direction) - docs' blockedBy (schema) is completed → can_start returns True, can be claimed - diff --git a/web/public/course-assets/s12_task_system/task-dag.ja.svg b/web/public/course-assets/s12_task_system/task-dag.ja.svg deleted file mode 100644 index 37ee46c9c..000000000 --- a/web/public/course-assets/s12_task_system/task-dag.ja.svg +++ /dev/null @@ -1,59 +0,0 @@ - - - - - - - - - - - - - Task DAG — 依存関係の例:データベース → API → テスト → デプロイ - - - - ✓ schema - completed - - - - - - - - ● endpoints - in_progress · owner: agent-1 - - - ○ docs - pending · blockedBy: schema ✓ - - - - - - - - ○ tests - blockedBy: endpoints ● - - - - - - ○ deploy - blockedBy: tests, docs - - - - - completed - - in_progress - - pending - → blockedBy(矢印 = 依存方向) - docs の blockedBy (schema) は完了済み → can_start が True を返し、claim 可能 - diff --git a/web/public/course-assets/s12_task_system/task-dag.svg b/web/public/course-assets/s12_task_system/task-dag.svg index c044bd661..2ba3f5ecf 100644 --- a/web/public/course-assets/s12_task_system/task-dag.svg +++ b/web/public/course-assets/s12_task_system/task-dag.svg @@ -1,59 +1,101 @@ - + - - + + + + + - + + - - - Task DAG — 依赖关系示例:搭数据库 → API → 测试 → 部署 + Task DAG + Dependency Example: schema → endpoints / docs → tests → deploy + - - ✓ schema - completed + + + schema + completed + + + + + + + - - - + + + + + - - ● endpoints - in_progress · owner: agent-1 + + + + + endpoints + in_progress | owner: agent-1 - - ○ docs - pending · blockedBy: schema ✓ + + + docs + pending | blockedBy: schema - - - + + + + + + + + + + + - - ○ tests - blockedBy: endpoints ● + + + + + tests + pending | blockedBy: endpoints - - + + + deploy + pending | blockedBy: tests, docs - - ○ deploy - blockedBy: tests, docs + + + + - - - completed - - in_progress - - pending - → blockedBy(箭头 = 依赖方向) - docs 的 blockedBy (schema) 已完成 → can_start 返回 True,可被 claim - + + + + + + completed + + + + in_progress + + + + pending + + + + blockedBy (dependency direction) + + \ No newline at end of file diff --git a/web/public/course-assets/s12_task_system/task-system-overview.en.svg b/web/public/course-assets/s12_task_system/task-system-overview.en.svg deleted file mode 100644 index b4a74b6c0..000000000 --- a/web/public/course-assets/s12_task_system/task-system-overview.en.svg +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - - - Task System — 5 Task Tools + .tasks/ Persistence + blockedBy Dependencies - - - - s11 Preserved - - s12 New - - - - messages - - - - - prompt + compress - (s10-s11) - - - - - LLM (try/except) - (s11) - - - - - - TOOL_HANDLERS - bash · read · write - create_task · list_tasks - get_task · claim_task · complete_task - - - - - - - .tasks/ — Cross-session Persistence - task_xxx.json · task_yyy.json · task_zzz.json - {id, subject, description, status, owner, blockedBy} - Tutorial ID: timestamp + random | CC: sequential ID + highwatermark - - - - create / save / read - - - - Dependency Check + Lifecycle - can_start: all blockedBy completed? - claim_task → owner = agent, pending → in_progress - complete_task → completed + unblock downstream - - - - State Machine: - - pending - - claim - - in_progress - - complete_task - - completed - No release rollback; crash → unassign owner - - - - - s11 Preserved: loop, prompt assembly, compression (error recovery independent from task system) - - s12 New: Task dataclass + 5 tools + .tasks/ persistence + blockedBy dependency graph - diff --git a/web/public/course-assets/s12_task_system/task-system-overview.ja.svg b/web/public/course-assets/s12_task_system/task-system-overview.ja.svg deleted file mode 100644 index 906a0dbfb..000000000 --- a/web/public/course-assets/s12_task_system/task-system-overview.ja.svg +++ /dev/null @@ -1,94 +0,0 @@ - - - - - - - - - - - - - - - - - - - Task System — 5 つのタスクツール + .tasks/ 永続化 + blockedBy 依存 - - - - s11 保持 - - s12 新規 - - - - messages - - - - - prompt + compress - (s10-s11) - - - - - LLM (try/except) - (s11) - - - - - - TOOL_HANDLERS - bash · read · write - create_task · list_tasks - get_task · claim_task · complete_task - - - - - - - .tasks/ — セッション横断永続化 - task_xxx.json · task_yyy.json · task_zzz.json - {id, subject, description, status, owner, blockedBy} - チュートリアル ID: timestamp + random | CC: 順次 ID + highwatermark - - - - create / save / read - - - - 依存チェック + ライフサイクル - can_start: blockedBy がすべて completed? - claim_task → owner = agent, pending → in_progress - complete_task → completed + 下流をアンロック - - - - 状態マシン: - - pending - - claim - - in_progress - - complete_task - - completed - release ロールバックなし、クラッシュ時は unassign で owner クリア - - - - - s11 保持:ループ、プロンプト組み立て、圧縮(エラーリカバリとタスクシステムは独立) - - s12 新規:Task dataclass + 5 ツール + .tasks/ 永続化 + blockedBy 依存グラフ - diff --git a/web/public/course-assets/s12_task_system/task-system-overview.svg b/web/public/course-assets/s12_task_system/task-system-overview.svg index 097b61f36..ae5ba1b85 100644 --- a/web/public/course-assets/s12_task_system/task-system-overview.svg +++ b/web/public/course-assets/s12_task_system/task-system-overview.svg @@ -1,94 +1,138 @@ - + - - - - - + + - - + + - + + - - - Task System — 5 个任务工具 + .tasks/ 持久化 + blockedBy 依赖 - - - - s11 保留 - - s12 新增 - - - - messages - - - - - prompt + compress - (s10-s11) - - - - - LLM (try/except) - (s11) - - - - - - TOOL_HANDLERS - bash · read · write - create_task · list_tasks - get_task · claim_task · complete_task - - - - - - - .tasks/ — 跨会话持久化 - task_xxx.json · task_yyy.json · task_zzz.json - {id, subject, description, status, owner, blockedBy} - 教学版 ID: timestamp + random | CC: 顺序 ID + highwatermark - - - - create / save / read - - - - 依赖检查 + 生命周期 - can_start: blockedBy 全部 completed? - claim_task → owner = agent, pending → in_progress - complete_task → completed + 解锁下游 - - - - 状态机: - - pending - - claim - - in_progress - - complete_task - - completed - CC 无 release 回退,崩溃时用 unassign 清 owner - - - - - s11 保留:循环、prompt 组装、压缩(错误恢复与任务系统独立) - - s12 新增:Task dataclass + 5 个工具 + .tasks/ 持久化 + blockedBy 依赖图 + Task System Overview + 5 Task Tools + .tasks/ Persistence + blockedBy Dependencies + + + + Agent Loop + + + + messages + + + + + + prompt + + compress + + + + + + LLM + (try/except) + + + + + + TOOL_HANDLERS + bash read write + 5 task tools + + + + + + + + + + + on tool_use + → dispatch a task tool + + + + result → tool_result + + + + .tasks/ — Cross-Session Persistence + task_xxx.json task_yyy.json task_zzz.json + {id, subject, description, status, owner, blockedBy} + ID: timestamp + random_hex (teaching version) + ID: sequential + highwatermark (production) + + + + 5 Task Tools + create_task(subject, desc, blockedBy) + list_tasks() → summary table + get_task(id) → full JSON + claim_task(id) → set owner + complete_task(id) → unblock deps + + + + R/W + + + + Dependency Check + Lifecycle + + + can_start(task_id): + all blockedBy completed? + missing dep → blocked + + + claim_task: + owner = agent_name + pending → in_progress + + + complete_task: + status → completed + scan + unblock downstream + + + + State Machine + + + pending + + + claim + + + in_progress + + + complete + + + completed + + No release rollback. On crash: unassign owner, reset to pending for re-claim. + + + + TodoWrite vs Task System + + TodoWrite + Task System + + Storage + In-session memory + .tasks/{id}.json cross-session + + Dependencies + None + blockedBy graph + can_start check diff --git a/web/public/course-assets/s13_background_tasks/background-tasks-overview.en.svg b/web/public/course-assets/s13_background_tasks/background-tasks-overview.en.svg deleted file mode 100644 index 830ffb90e..000000000 --- a/web/public/course-assets/s13_background_tasks/background-tasks-overview.en.svg +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - - Background Tasks — Slow ops to background, Agent keeps thinking - - - - s12 retained - - s13 new - - - - messages - - - - - prompt + cache - (s10-s12) - - - - - LLM call - (s11 retry) - - - - - - TOOL DISPATCH - fast? → sync execute (s12) - slow? → run_in_background ★ - - - - - - - Background thread execution - run_in_background(tool_use_id, fn, *args) - threading.Thread(target=worker, daemon=True) - result → background_results[id] (threading.Lock protected) - - - - slow op - - - - Notification injection - collect_background_results() check each turn - completed → tool_result inject into messages - pending → "[Running in background...]" placeholder - - - - - - - Heuristic: - - fast - read_file · git status · glob - - slow - npm install · pip install · pytest (timeout > 30s) - - - - s12 sync blocking - - think - - waiting for bash 3min... - - continue - Total ~3min, Agent idled for 3 minutes - - - s13 background execution - - think - - keep doing other work - - notification: result ready - Total ~3min, but Agent wasn't idle - \ No newline at end of file diff --git a/web/public/course-assets/s13_background_tasks/background-tasks-overview.ja.svg b/web/public/course-assets/s13_background_tasks/background-tasks-overview.ja.svg deleted file mode 100644 index 207eec47b..000000000 --- a/web/public/course-assets/s13_background_tasks/background-tasks-overview.ja.svg +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - - Background Tasks — 遅い操作はバックグラウンドへ、Agent は考え続ける - - - - s12 維持 - - s13 新規 - - - - messages - - - - - prompt + cache - (s10-s12) - - - - - LLM call - (s11 retry) - - - - - - TOOL DISPATCH - fast? → 同期実行 (s12) - slow? → run_in_background ★ - - - - - - - バックグラウンドスレッド実行 - run_in_background(tool_use_id, fn, *args) - threading.Thread(target=worker, daemon=True) - 結果 → background_results[id] (threading.Lock で保護) - - - - slow op - - - - 通知注入 - collect_background_results() 毎ターン確認 - 完了 → tool_result を messages に注入 - 未完了 → "[Running in background...]" プレースホルダー - - - - - - - ヒューリスティック判定: - - fast - read_file · git status · glob - - slow - npm install · pip install · pytest (timeout > 30s) - - - - s12 同期ブロッキング - - 思考 - - bash 待ち 3分... - - 継続 - 合計 ~3分、Agent は3分間待機 - - - s13 バックグラウンド実行 - - 思考 - - 別の作業を継続 - - 通知: 結果完了 - 合計 ~3分、Agent は遊ばず - diff --git a/web/public/course-assets/s13_background_tasks/background-tasks-overview.svg b/web/public/course-assets/s13_background_tasks/background-tasks-overview.svg index ac6dff0ac..6a7078461 100644 --- a/web/public/course-assets/s13_background_tasks/background-tasks-overview.svg +++ b/web/public/course-assets/s13_background_tasks/background-tasks-overview.svg @@ -1,105 +1,140 @@ - + - - - - - + + - - + + - + + - - - Background Tasks — 慢操作丢后台,Agent 继续思考 + Background Tasks + Slow operations go to background threads; agent continues processing - - - s12 保留 - - s13 新增 + - - - messages + + + messages - + + - - prompt + cache - (s10-s12) + + + prompt + cache - + + - - LLM call - (s11 retry) + + + LLM call - + + - - - TOOL DISPATCH - fast? → 同步执行 (s12) - slow? → run_in_background ★ + + + TOOL DISPATCH + fast → sync execute + slow → run_in_background - - + + + + next turn - - - 后台线程执行 - run_in_background(tool_use_id, fn, *args) - threading.Thread(target=worker, daemon=True) - 结果 → background_results[id] (threading.Lock 保护) + - - - slow op + + + + slow op - - - 通知注入 - collect_background_results() 每轮检查 - 已完成 → tool_result 注入 messages - 未完成 → "[Running in background...]" 占位 + + + Background Thread Execution + + + start_background_task(block) + Thread(target=worker, daemon=True) + result → background_results[id] - - - - - 启发式判断: - - fast - read_file · git status · glob - - slow - npm install · pip install · pytest (timeout > 30s) - - - - s12 同步阻塞 - - 思考 - - 等 bash 3 分钟... - - 继续 - 总耗时 ~3min,Agent 空 etc. 等了 3 分钟 - - - s13 后台执行 - - 思考 - - 继续做别的事 - - 通知: 结果来了 - 总耗时 ~3min,但 Agent 没闲着 - + + done + + + + Notification Injection + + + collect_background_results() + done → inject tool_result + pending → "[Running...]" placeholder + + + + + + inject into messages + + + + Heuristic: + + + + fast + read_file · git status · glob + + + + slow + npm install · pip install · pytest (>30s) + + + + + + Sync Blocking + Agent waits for slow operation + + + + think + + + + waiting... 3 min idle + + + + resume + + Total ~3 min, agent idle the entire time + + + + Background Execution + Agent continues while task runs + + + + think + + + + do other work + + + + result arrives + + Total ~3 min, but agent stayed productive + \ No newline at end of file diff --git a/web/public/course-assets/s14_cron_scheduler/cron-scheduler-overview.en.svg b/web/public/course-assets/s14_cron_scheduler/cron-scheduler-overview.en.svg deleted file mode 100644 index 77bfd3ad9..000000000 --- a/web/public/course-assets/s14_cron_scheduler/cron-scheduler-overview.en.svg +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - - - - - - - - - - - Cron Scheduler — Independent scheduler thread + cron_queue injection point - - - - s10-s13 retained - - s14 new - - - - - - consume - cron_queue - ★ s14 injection - - - - - - messages - - - - - - prompt + cache - assemble_system_prompt - (s10) - - - - - - LLM (try/except) - with_retry - (s11) - - - - - - TOOL DISPATCH - fast → sync (bash, read, write) - slow → background thread (s13) - cron → schedule_cron, list, cancel (s14) - task → create, list, claim, complete (s12) - - - - loop back: tool_results → next turn - - - - cron_scheduler_loop (daemon thread) - time.sleep(1) → cron_matches(job.cron, now) - match → cron_queue.append(job) - minute_marker prevents double-fire per minute - one-shot jobs auto-delete after firing - - - - - - - cron_queue - cron_lock · scheduler writes · loop reads - - - - next agent_loop consumes - - - - CronJob + Persistence - CronJob dataclass: - id, cron, prompt, recurring, durable - Durable → .scheduled_tasks.json - restored via load_durable_jobs after restart - Session-only → memory only - lost when process exits - ⚠ Process exit = scheduler stops (not OS-level crontab) - - - - 5-field Cron Expression - - * - - * - - * - - * - - * - min - hour - day - month - dow - - */5 * * * * → every 5 minutes - 0 9 * * 1-5 → weekdays 9:00 - 0 9 * * * → daily 9:00 - Supports: *, */N, N, N-M, N,M,... - diff --git a/web/public/course-assets/s14_cron_scheduler/cron-scheduler-overview.ja.svg b/web/public/course-assets/s14_cron_scheduler/cron-scheduler-overview.ja.svg deleted file mode 100644 index bc63ff6da..000000000 --- a/web/public/course-assets/s14_cron_scheduler/cron-scheduler-overview.ja.svg +++ /dev/null @@ -1,125 +0,0 @@ - - - - - - - - - - - - - - - - - - - Cron Scheduler — 独立スケジューラスレッド + cron_queue 注入ポイント - - - - s10-s13 維持 - - s14 新規 - - - - - - consume - cron_queue - ★ s14 注入点 - - - - - - messages - - - - - - prompt + cache - assemble_system_prompt - (s10) - - - - - - LLM (try/except) - with_retry - (s11) - - - - - - TOOL DISPATCH - fast → sync (bash, read, write) - slow → background thread (s13) - cron → schedule_cron, list, cancel (s14) - task → create, list, claim, complete (s12) - - - - loop back: tool_results → next turn - - - - cron_scheduler_loop (daemon スレッド) - time.sleep(1) → cron_matches(job.cron, now) - マッチ → cron_queue.append(job) - minute_marker で同一分の重複発火を防止 - 一度きりのタスクは発火後自動削除 - - - - - - - cron_queue - cron_lock · スケジューラ書込 · loop 読込 - - - - 次の agent_loop が消費 - - - - CronJob + 永続化 - CronJob dataclass: - id, cron, prompt, recurring, durable - Durable → .scheduled_tasks.json - 再起動後 load_durable_jobs で復元 - Session-only → メモリのみ - プロセス終了で消失 - ⚠ プロセス終了 = スケジューラ停止(OS レベルの crontab ではない) - - - - 5 フィールド Cron 式 - - * - - * - - * - - * - - * - - - - - 曜日 - - */5 * * * * → 5 分ごと - 0 9 * * 1-5 → 平日 9:00 - 0 9 * * * → 毎日 9:00 - 対応: *, */N, N, N-M, N,M,... - diff --git a/web/public/course-assets/s14_cron_scheduler/cron-scheduler-overview.svg b/web/public/course-assets/s14_cron_scheduler/cron-scheduler-overview.svg index 3a8c4db61..becb5fa6f 100644 --- a/web/public/course-assets/s14_cron_scheduler/cron-scheduler-overview.svg +++ b/web/public/course-assets/s14_cron_scheduler/cron-scheduler-overview.svg @@ -1,125 +1,156 @@ - + - - - - - + + - - + + + + + - + + - - - Cron Scheduler — 独立调度线程 + cron_queue 注入点 - - - - s10-s13 保留 - - s14 新增 - - - - - - consume - cron_queue - ★ s14 注入点 - - - - - - messages - - - - - - prompt + cache - assemble_system_prompt - (s10) - - - - - - LLM (try/except) - with_retry - (s11) - - - - - - TOOL DISPATCH - fast → sync (bash, read, write) - slow → background thread (s13) - cron → schedule_cron, list, cancel (s14) - task → create, list, claim, complete (s12) - - - - loop back: tool_results → next turn - - - - cron_scheduler_loop(独立 daemon 线程) - time.sleep(1) → cron_matches(job.cron, now) - 匹配 → cron_queue.append(job) - minute_marker 防同分钟重复触发 - 一次性任务触发后自动删除 - - - - - - - cron_queue - cron_lock 保护 · 调度线程写 · agent_loop 读 - - - - 下次 agent_loop 消费 - - - - CronJob + 持久化 - CronJob dataclass: - id, cron, prompt, recurring, durable - Durable → .scheduled_tasks.json - 重启后 load_durable_jobs 恢复 - Session-only → 内存 only - 进程关闭即丢 - ⚠ 进程关闭 = 调度停止(不是 OS 级 crontab) - - - - 五段式 Cron 表达式 - - * - - * - - * - - * - - * - 分钟 - 小时 - - - 星期 - - */5 * * * * → 每 5 分钟 - 0 9 * * 1-5 → 工作日 9:00 - 0 9 * * * → 每天 9:00 - 支持: *, */N, N, N-M, N,M,... + Cron Scheduler + Scheduler produces · Queue Processor triggers · agent_loop consumes — via cron_queue + + + + Agent Loop + + + + consume + cron_queue + + + + + messages + + + + + prompt + cache + assemble_prompt() + + + + + LLM call + with_retry() + + + + + TOOL DISPATCH + fast: bash, read, write + slow: background thread + cron: sched, list, cancel + + + + + loop back: tool_results → next turn + + + + + + + 4 + consume + + + + + + 3 + start turn (trigger · no data) + + + + data flow (jobs · messages) + + control (trigger · no data) + + + + cron_queue + cron_lock protected + + + + + 2 + non-empty? + + + Queue Processor + polls 200ms · agent_lock + + + + + + 1 + append + + + + Scheduler Thread (daemon) + Polls every 1 second, independent of agent_loop + + time.sleep(1) + if cron_matches(job.cron, now): + cron_queue.append(job) + minute_marker prevents duplicate fire + one-shot jobs auto-removed after fire + + + + CronJob + Persistence + + CronJob(id, cron, prompt, + recurring, durable) + @dataclass + Durable + → .scheduled_tasks.json (survives restart) + Session + → in-memory only (lost on exit) + Process exit = scheduler stops (not OS crontab) + + + + 5-Field Cron Expression + + + * + minute + + + * + hour + + + * + day + + + * + month + + + * + weekday + + + */5 * * * * Every 5 minutes + 0 9 * * * Every day at 9:00 + 0 9 * * 1-5 Weekdays at 9:00 + Supports: *, */N, N, N-M, N,M,... diff --git a/web/public/course-assets/s15_agent_teams/agent-teams-overview.en.svg b/web/public/course-assets/s15_agent_teams/agent-teams-overview.en.svg deleted file mode 100644 index f87995ad3..000000000 --- a/web/public/course-assets/s15_agent_teams/agent-teams-overview.en.svg +++ /dev/null @@ -1,120 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - Agent Teams — Lead Loop + Teammate Threads + MessageBus - - - - s10-s14 Preserved - - s15 New - - Teammate - - Real CC detail - - - - cron_queue - - - - - messages - - - - - prompt + cache - - - - - LLM call - - - - - TOOL DISPATCH - bash · read · write · task(4) · cron(3) - ★ spawn_teammate · send_message · check_inbox - - - - - - - - spawn - - - - MessageBus (.mailboxes/*.jsonl) - - - - - - receive - receive - receive - - - - - send - send - send - - - Teammate: alice (Backend) - inbox → LLM → bash/read/write/send - Max 10 rounds → summary → BUS.send - - - Teammate: bob (Frontend) - Independent agent_loop, shared client - Thread(daemon=True) - - - Teammate: charlie (QA) - Cannot spawn other teammates - spawn → work → summary - - - - - permission_request - - - Permission Bubbling (real CC; omitted in teaching code) - ① Teammate needs approval → MessageBus sends permission_request ② Lead receives → user approval → approve/deny - - - - - s10-s14: prompt assembly, error recovery, task graph, background threads, cron scheduling - - s15: MessageBus + spawn_teammate_thread + send_message + check_inbox (permission bubbling is a real CC detail) - diff --git a/web/public/course-assets/s15_agent_teams/agent-teams-overview.ja.svg b/web/public/course-assets/s15_agent_teams/agent-teams-overview.ja.svg deleted file mode 100644 index 47c96654e..000000000 --- a/web/public/course-assets/s15_agent_teams/agent-teams-overview.ja.svg +++ /dev/null @@ -1,120 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - Agent Teams — Lead ループ + チームメイトスレッド + MessageBus - - - - s10-s14 保持 - - s15 新規 - - チームメイト - - 真实 CC 補足 - - - - cron_queue - - - - - messages - - - - - prompt + cache - - - - - LLM call - - - - - TOOL DISPATCH - bash · read · write · task(4) · cron(3) - ★ spawn_teammate · send_message · check_inbox - - - - - - - - spawn - - - - MessageBus (.mailboxes/*.jsonl) - - - - - - receive - receive - receive - - - - - send - send - send - - - チームメイト: alice (Backend) - inbox → LLM → bash/read/write/send - 最大 10 ラウンド → summary → BUS.send - - - チームメイト: bob (Frontend) - 独立 agent_loop、共有 client - Thread(daemon=True) - - - チームメイト: charlie (QA) - 他のチームメイトを spawn 不可 - spawn → work → summary - - - - - permission_request - - - 権限バブリング(真实 CC、教学版は省略) - ① 承認が必要 → MessageBus が permission_request 送信 ② Lead が受信 → ユーザー承認 → approve/deny - - - - - s10-s14:プロンプト組み立て、エラーリカバリ、タスクグラフ、バックグラウンドスレッド、cron - - s15:MessageBus + spawn_teammate_thread + send_message + check_inbox(権限バブリングは真实 CC 補足) - diff --git a/web/public/course-assets/s15_agent_teams/agent-teams-overview.svg b/web/public/course-assets/s15_agent_teams/agent-teams-overview.svg index e708a3341..d1493110b 100644 --- a/web/public/course-assets/s15_agent_teams/agent-teams-overview.svg +++ b/web/public/course-assets/s15_agent_teams/agent-teams-overview.svg @@ -1,132 +1,119 @@ - + - - - - - + + - - - - - - - - + + - + + - - - Agent Teams — Lead Loop + Teammate Threads + MessageBus - - - - s10-s14 保留 - - s15 新增 - - Teammate - - 真实 CC 补充 - - - - - - cron_queue - - - - - messages - - - - - prompt + cache - - - - - LLM call - - - - - TOOL DISPATCH - bash · read · write · task(4) · cron(3) - ★ spawn_teammate · send_message · check_inbox - - - - - - - - - spawn - - - - - MessageBus (.mailboxes/*.jsonl) - - - - - - - - - receive - receive - receive - - - - - - send - send - send - - - - Teammate: alice (Backend) - inbox → LLM → bash/read/write/send - 最多 10 轮 → summary → BUS.send - - - - Teammate: bob (Frontend) - 独立 agent_loop,共享 client - Thread(daemon=True) - - - - Teammate: charlie (QA) - 不能 spawn 其他 teammate - spawn → work → summary - - - - - - permission_request - - - 权限冒泡(真实 CC,教学版省略) - ① 队友需审批 → MessageBus 发送 permission_request ② Lead 收到 → 用户审批 → 回复 approve/deny - - - - - s10-s14: prompt 组装、错误恢复、任务图、后台线程、cron 调度 - - s15: MessageBus + spawn_teammate_thread + send_message + check_inbox(权限冒泡见真实 CC 补充) + Agent Teams — Lead Loop + Teammate Threads + MessageBus + Multi-agent collaboration via file-based inboxes + + + + Lead Agent Loop + + + cron_queue + + + + messages + + + + prompt + cache + + + + LLM call + + + + TOOL DISPATCH + bash read write task cron + spawn_teammate send_message check_inbox + + + + + loop back: tool_results -> next turn + + + + + + check_inbox + + + send_message + + + + + spawn_teammate + creates teammate thread + + + + MessageBus + .mailboxes/*.jsonl + + + + receive + + send + + + receive + + send + + + receive + + send + + + + alice (Backend) + + task: create DB schema + + + bob (Frontend) + + task: write API client + + + charlie (QA) + + task: run tests, report + + + Each teammate: own daemon Thread + own agent_loop (shared client) · max 10 rounds · cannot spawn others + + + + Permission Bubbling + (omitted in teaching code, present in production) + + 1. Teammate needs approval -> sends permission_request via Bus + 2. Lead receives -> user approves -> sends permission_response back + + + + + perm_request + + + + Lead tools: bash, read, write, task(5), cron(3), spawn_teammate, send_message, check_inbox (14 total) diff --git a/web/public/course-assets/s15_agent_teams/team-topology.en.svg b/web/public/course-assets/s15_agent_teams/team-topology.en.svg deleted file mode 100644 index 7540db7c8..000000000 --- a/web/public/course-assets/s15_agent_teams/team-topology.en.svg +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - Team Topology — Lead ↔ MessageBus ↔ Teammates - - - - Lead Agent - Main loop + spawn + inbox handling - check_inbox receives teammate messages - - - - Message Bus (.mailboxes/*.jsonl) - - - - Alice (Backend) - own loop → inbox → work → reply - - - Bob (Frontend) - own loop → inbox → work → reply - - - Charlie (QA) - own loop → inbox → work → reply - - - - send - - inbox - - - - - - receive - receive - receive - - - - send - send - send - - diff --git a/web/public/course-assets/s15_agent_teams/team-topology.ja.svg b/web/public/course-assets/s15_agent_teams/team-topology.ja.svg deleted file mode 100644 index 77c8709e8..000000000 --- a/web/public/course-assets/s15_agent_teams/team-topology.ja.svg +++ /dev/null @@ -1,65 +0,0 @@ - - - - - - - - - - - - - - - - - - - - Team Topology — Lead ↔ MessageBus ↔ チームメイト - - - - Lead Agent - メインループ + spawn + inbox 処理 - check_inbox でチームメイトのメッセージ受信 - - - - Message Bus (.mailboxes/*.jsonl) - - - - Alice (Backend) - 独立 loop → inbox → 作業 → 返信 - - - Bob (Frontend) - 独立 loop → inbox → 作業 → 返信 - - - Charlie (QA) - 独立 loop → inbox → 作業 → 返信 - - - - send - - inbox - - - - - - receive - receive - receive - - - - send - send - send - - diff --git a/web/public/course-assets/s15_agent_teams/team-topology.svg b/web/public/course-assets/s15_agent_teams/team-topology.svg index 9272e1b6d..093c1204e 100644 --- a/web/public/course-assets/s15_agent_teams/team-topology.svg +++ b/web/public/course-assets/s15_agent_teams/team-topology.svg @@ -1,72 +1,85 @@ - + - - - - - + + - - - - - - - - + + - - - - Team Topology — Lead ↔ MessageBus ↔ Teammates + + + + + Team Topology + Lead ↔ MessageBus ↔ Teammates + + + + Lead Agent + main loop + spawn + inbox + check_inbox for teammate msgs + + + + + send + + + + inbox - - - Lead Agent - 主循环 + spawn + inbox 处理 - check_inbox 接收队友消息 + + + MessageBus + .mailboxes/*.jsonl - - - Message Bus (.mailboxes/*.jsonl) + + + + receive + + send - - - Alice (Backend) - 独立 loop → inbox → 干活 → 回复 + + + receive + + send - - Bob (Frontend) - 独立 loop → inbox → 干活 → 回复 + + + receive + + send - - Charlie (QA) - 独立 loop → inbox → 干活 → 回复 + + + + Alice (Backend) + loop → inbox → work → reply + tools: bash, read, write, send - - - - send - - - inbox + + + Bob (Frontend) + loop → inbox → work → reply + tools: bash, read, write, send - - - - - - receive - receive - receive - - - - - send - send - send + + + Charlie (QA) + loop → inbox → work → reply + tools: bash, read, write, send + + + + + + + + + diff --git a/web/public/course-assets/s16_team_protocols/team-protocols-overview.en.svg b/web/public/course-assets/s16_team_protocols/team-protocols-overview.en.svg deleted file mode 100644 index 7dd6b28c4..000000000 --- a/web/public/course-assets/s16_team_protocols/team-protocols-overview.en.svg +++ /dev/null @@ -1,143 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - Team Protocols — Request-Response + request_id Correlation + State Machine - - - - s15 Preserved - - s16 New - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH (core tool set) - bash · read · write · task(4) · spawn · send · inbox - ★ request_shutdown · request_plan · review_plan - - - - - - - Request-Response Protocol Flow (request_id throughout) - - - - ① Lead sends request - BUS.send("shutdown_request" - metadata={request_id}) - - - - - - ② Teammate receives - dispatch_by_type(inbox) - → handler(type, metadata) - - - - - - ③ Teammate responds - BUS.send("shutdown_response" - same request_id + approve) - - - - - - ④ Lead receives - match_response(request_id) - → resolve/reject callback - - - - - State Machine (same for both protocols) - - - pending - - - approve - - - approved - - - reject - - - rejected - - - - pending_requests Storage - pending_requests: dict[str, ProtocolState] - request_id → {type, sender, status, created_at} - match_response: find request by request_id - - - - Two protocols, one mechanism: - - shutdown_request - and - - plan_approval_request - share the same pending→approved/rejected FSM - New protocol type = new msg_type, no new state machine. request_id links request and response. - - - - - s15: MessageBus + spawn_teammate + inbox - - s16: request_id protocol + dispatch + pending_requests + state machine - diff --git a/web/public/course-assets/s16_team_protocols/team-protocols-overview.ja.svg b/web/public/course-assets/s16_team_protocols/team-protocols-overview.ja.svg deleted file mode 100644 index 28b368b97..000000000 --- a/web/public/course-assets/s16_team_protocols/team-protocols-overview.ja.svg +++ /dev/null @@ -1,141 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - Team Protocols — リクエスト・レスポンス + request_id 紐付け + 状態機械 - - - - s15 保持 - - s16 新規 - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH(コアツールセット) - bash · read · write · task(4) · spawn · send · inbox - ★ request_shutdown · request_plan · review_plan - - - - - - - リクエスト・レスポンスプロトコルフロー(request_id が全チェーンを貫通) - - - - ① Lead が要求送信 - BUS.send("shutdown_request" - metadata={request_id}) - - - - - - ② チームメイト受信 - dispatch_by_type(inbox) - → handler(type, metadata) - - - - - - ③ チームメイト応答 - BUS.send("shutdown_response" - 同じ request_id + approve) - - - - - - ④ Lead 応答受信 - match_response(request_id) - → resolve/reject callback - - - - 状態機械(2 つのプロトコルで共通) - - - pending - - - approve - - - approved - - - reject - - - rejected - - - pending_requests ストレージ - pending_requests: dict[str, ProtocolState] - request_id → {type, sender, status, created_at} - match_response: request_id で要求を検索 - - - - 2 つのプロトコル、1 つの仕組み: - - shutdown_request - - - plan_approval_request - が pending→approved/rejected 状態機械を共有 - 新しいプロトコルタイプ = 新しい msg_type、新しい状態機械は不要。request_id が要求と応答を紐付け。 - - - - - s15: MessageBus + spawn_teammate + inbox - - s16: request_id プロトコル + dispatch + pending_requests + 状態機械 - diff --git a/web/public/course-assets/s16_team_protocols/team-protocols-overview.svg b/web/public/course-assets/s16_team_protocols/team-protocols-overview.svg index 04a9a8025..787c60a5e 100644 --- a/web/public/course-assets/s16_team_protocols/team-protocols-overview.svg +++ b/web/public/course-assets/s16_team_protocols/team-protocols-overview.svg @@ -1,148 +1,162 @@ - + - - - - - + + - - - - - - - - - - - + + - + + - - - Team Protocols — 请求-响应协议 + request_id 关联 + 状态机 + Team Protocols Overview + Request-Response Protocol + request_id Correlation + State Machine - - - s15 保留 - - s16 新增 + + + Lead Agent Loop - - - turn + + + turn - + + - - messages + + + messages - + + - - prompt + + + prompt - + + - - LLM + + + LLM - + + - - TOOL DISPATCH(核心工具集) - bash · read · write · task(4) · spawn · send · inbox - ★ request_shutdown · request_plan · review_plan + + + TOOL DISPATCH + bash read write task spawn send inbox ... - - + + + loop back - - - 请求-响应协议流程(request_id 贯穿) + + + Request-Response Protocol Flow (linked by request_id) - - - ① Lead 发请求 - BUS.send("shutdown_request" - metadata={request_id}) + + + 1. Lead Sends Request + + BUS.send( + "shutdown_request") - + + - - - ② 队友收到 - dispatch_by_type(inbox) - → handler(type, metadata) + + + 2. Teammate Receives + + dispatch_message() + → handler(type) - + + - - - ③ 队友回复 - BUS.send("shutdown_response" - 同 request_id + approve) + + + 3. Teammate Replies + + BUS.send( + "shutdown_response") - + + - - ④ Lead 收响应 - match_response(request_id) - → resolve/reject callback - - - - - 状态机(同一套,两种协议) - - - - pending - - - - approve - - - - approved - - - - reject - - - - rejected - - - - pending_requests 存储 - pending_requests: dict[str, ProtocolState] - request_id → {type, sender, status, created_at} - match_response: 按 request_id 找回对应请求 - - - - 两种协议,同一套机制: - - shutdown_request - - - plan_approval_request - 共用 pending→approved/rejected 状态机 - 新增协议类型 = 新的 msg_type,不需要新状态机。request_id 关联请求和响应。 - - - - - s15: MessageBus + spawn_teammate + inbox - - s16: request_id 协议 + dispatch + pending_requests + 状态机 - + + 4. Lead Matches + + match_response( + request_id) + + + + + + + request_id links all + + + + State Machine + Same FSM for both protocol types + + + + pending + + + + approve + + + + approved + + + + reject + + + + rejected + + + + pending_requests Storage + + + pending_requests: + dict[str, ProtocolState] + request_id → {type, sender, + status, created_at} + + + + Two Protocols, One Mechanism + + + + shutdown_request / response + Lead → Teammate (graceful shutdown) + + + + plan_approval_request / response + Teammate → Lead (plan review) + + + + + + + shared FSM + \ No newline at end of file diff --git a/web/public/course-assets/s17_autonomous_agents/autonomous-agents-overview.en.svg b/web/public/course-assets/s17_autonomous_agents/autonomous-agents-overview.en.svg deleted file mode 100644 index 709676b63..000000000 --- a/web/public/course-assets/s17_autonomous_agents/autonomous-agents-overview.en.svg +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - Autonomous Agents — Idle Loop + Auto-Claim + WORK/IDLE Lifecycle - - - - s16 Preserved - - s17 New - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH (all s16 preserved) - bash · read · write · task(4) · send · inbox - ★ request_shutdown · request_plan · review_plan - - - - - - - same inner LLM/tool loop inside WORK - - - - Teammate Lifecycle (s17 new: WORK → IDLE → SHUTDOWN) - - - - WORK Phase - inner loop: inbox → LLM → bash / read / write - stop_reason == tool_use → loop - stop_reason != tool_use → IDLE - Max 10 rounds / interruptible by shutdown_request - - - - task done - - - - work found - - - - IDLE Phase (poll every 5s) - ├ Check inbox → has message → back to WORK - ├ scan_unclaimed_tasks → claim → back to WORK - └ 60s timeout → SHUTDOWN ↓ - idle_poll() + claim_task() - - - - SHUTDOWN - - - - 60s timeout - - - - - s16: MessageBus + protocols + request_shutdown + plan approval - - s17: idle_poll + scan_unclaimed_tasks + auto_claim + identity re-injection - - - - Lead tools unchanged (14) · Teammate tools 5 → 8 (+3 task tools) · Teammates self-claim, Lead only creates tasks - diff --git a/web/public/course-assets/s17_autonomous_agents/autonomous-agents-overview.ja.svg b/web/public/course-assets/s17_autonomous_agents/autonomous-agents-overview.ja.svg deleted file mode 100644 index 65d9a764e..000000000 --- a/web/public/course-assets/s17_autonomous_agents/autonomous-agents-overview.ja.svg +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - Autonomous Agents — アイドルポーリング + 自動認領 + WORK/IDLE ライフサイクル - - - - s16 保持 - - s17 新規 - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH(s16 全保持) - bash · read · write · task(4) · send · inbox - ★ request_shutdown · request_plan · review_plan - - - - - - - 同じ内側 LLM/tool loop を WORK に入れる - - - - チームメイトライフサイクル(s17 新規:WORK → IDLE → SHUTDOWN) - - - - WORK フェーズ - 内側ループ:inbox → LLM → bash / read / write - stop_reason == tool_use → ループ - stop_reason != tool_use → IDLE - 最大 10 ラウンド / shutdown_request で中断可能 - - - - タスク完了 - - - - 仕事を発見 - - - - IDLE フェーズ(5 秒ごとにポーリング) - ├ inbox チェック → メッセージあり → WORK に戻る - ├ scan_unclaimed_tasks → 認領 → WORK に戻る - └ 60 秒タイムアウト → SHUTDOWN ↓ - idle_poll() + claim_task() - - - - SHUTDOWN - - - - 60 秒タイムアウト - - - - - s16: MessageBus + protocols + request_shutdown + plan approval - - s17: idle_poll + scan_unclaimed_tasks + auto_claim + identity re-injection - - - - Lead ツール不変(14) · チームメイトツール 5 → 8(+3 task tools) · チームメイトが自己認領、Lead はタスク作成のみ - diff --git a/web/public/course-assets/s17_autonomous_agents/autonomous-agents-overview.svg b/web/public/course-assets/s17_autonomous_agents/autonomous-agents-overview.svg index df99675a4..cfcd0506b 100644 --- a/web/public/course-assets/s17_autonomous_agents/autonomous-agents-overview.svg +++ b/web/public/course-assets/s17_autonomous_agents/autonomous-agents-overview.svg @@ -1,109 +1,132 @@ - + - - - - - - - + - - + + - + + - - - Autonomous Agents — 空闲循环 + 自动认领 + WORK/IDLE 生命周期 - - - - s16 保留 - - s17 新增 + Autonomous Agents Overview + Idle Polling + Auto-Claim + WORK / IDLE / SHUTDOWN Lifecycle - - - turn + + + Lead Inner Loop + (LLM / tool cycle drives each turn) - + + + turn - - messages + + - + + + messages - - prompt + + - + + + prompt - - LLM + + - + + + LLM - - TOOL DISPATCH (s16 全保留) - bash · read · write · task(4) · send · inbox - ★ request_shutdown · request_plan · review_plan + + - - + + + Tool Dispatch + bash read write task send inbox ... - - - 同一个内层 LLM/tool loop 放进 WORK + + + + loop back (stop_reason == tool_use) - - - 队友生命周期(s17 新增:WORK → IDLE → SHUTDOWN) + + + Teammate Lifecycle + (WORK → IDLE → SHUTDOWN) - - WORK 阶段 - 内层循环:inbox → LLM → bash / read / write - stop_reason == tool_use → loop - stop_reason != tool_use → IDLE - 最多 10 轮 / 可被 shutdown_request 中断 - - - - 任务完成 + + WORK Phase - - - 发现新任务 + + + inbox → LLM → bash/read/write + stop_reason == tool_use → loop + stop_reason != tool_use → IDLE + max 10 rounds per phase - - IDLE 阶段(每 5s 轮询) - ├ 检查 inbox → 有消息 → 回 WORK - ├ scan_unclaimed_tasks → 认领 → 回 WORK - └ 60s 超时 → SHUTDOWN ↓ - idle_poll() + claim_task() + + IDLE Phase (poll 5s) + + + + check inbox → msg → WORK + scan_unclaimed → claim → WORK + 60s timeout → SHUTDOWN + idle_poll() + claim_task() + + + + task done + + + + + + new task found - - SHUTDOWN + + SHUTDOWN - - 60s 超时 - - - - - s16: MessageBus + protocols + request_shutdown + plan approval - - s17: idle_poll + scan_unclaimed_tasks + auto_claim + identity re-injection - - - - Lead 工具不变(14) · 队友工具 5 → 8(+3 task tools) · 队友自主认领,Lead 只创建任务 - + + 60s timeout + + + + Key Capabilities + + + + + + Component + Behavior + + + + + + idle_poll + Poll inbox + task board every 5s during IDLE; prioritize shutdown_request + + + scan_unclaimed_tasks + Find pending tasks with no owner and resolved dependencies (can_start) + + + claim_task + Auto-claim with owner check; rejects already-owned or blocked tasks + + \ No newline at end of file diff --git a/web/public/course-assets/s18_worktree_isolation/worktree-overview.en.svg b/web/public/course-assets/s18_worktree_isolation/worktree-overview.en.svg deleted file mode 100644 index 57c915f7e..000000000 --- a/web/public/course-assets/s18_worktree_isolation/worktree-overview.en.svg +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - Worktree Isolation — Git Worktree + Task-Directory Binding + Event Log - - - - s17 Preserved - - s18 New - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH (s17 + s18) - bash · read · write · task(4) · send · inbox - request_shutdown · request_plan · review_plan - ★ create_worktree · remove_worktree · keep_worktree - - - - - - - Worktree Isolation (s18 new: each task gets its own directory + branch) - - - - Main repo (.tasks/ + .worktrees/ + .mailboxes/) - - - - create + bind - - - - create + bind - - - - Alice: .worktrees/auth/ - branch: wt/auth-refactor - Task: Refactor auth module - ✓ Isolated, no impact on Bob or main repo - - - - Bob: .worktrees/ui/ - branch: wt/ui-login - Task: Refactor UI login page - ✓ Isolated, no impact on Alice or main repo - - - - Event log: .worktrees/events.jsonl → create / remove / keep - - - Cleanup: keep (preserve branch for review) / remove (delete + mark done) - - - - - s17: idle_poll + auto_claim + protocols + WORK/IDLE lifecycle - - s18: create_worktree + bind_task + remove/keep + events.jsonl (Lead 14→17) - diff --git a/web/public/course-assets/s18_worktree_isolation/worktree-overview.ja.svg b/web/public/course-assets/s18_worktree_isolation/worktree-overview.ja.svg deleted file mode 100644 index 2a26071d4..000000000 --- a/web/public/course-assets/s18_worktree_isolation/worktree-overview.ja.svg +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - Worktree Isolation — Git Worktree + タスク・ディレクトリ紐付け + イベントログ - - - - s17 保持 - - s18 新規 - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH(s17 + s18) - bash · read · write · task(4) · send · inbox - request_shutdown · request_plan · review_plan - ★ create_worktree · remove_worktree · keep_worktree - - - - - - - Worktree 隔離(s18 新規:各タスクに独立ディレクトリ + 独立ブランチ) - - - - メインリポジトリ(.tasks/ + .worktrees/ + .mailboxes/) - - - - create + bind - - - - create + bind - - - - Alice: .worktrees/auth/ - branch: wt/auth-refactor - Task: 認証モジュールのリファクタリング - ✓ 隔離、Bob とメインリポジトリに影響なし - - - - Bob: .worktrees/ui/ - branch: wt/ui-login - Task: UI ログインページのリファクタリング - ✓ 隔離、Alice とメインリポジトリに影響なし - - - - イベントログ: .worktrees/events.jsonl → create / remove / keep - - - 片付け: keep(ブランチ保持 review)/ remove(削除+完了マーク) - - - - - s17: idle_poll + auto_claim + protocols + WORK/IDLE ライフサイクル - - s18: create_worktree + bind_task + remove/keep + events.jsonl(Lead 14→17) - diff --git a/web/public/course-assets/s18_worktree_isolation/worktree-overview.svg b/web/public/course-assets/s18_worktree_isolation/worktree-overview.svg index 2b88a75c4..7fb352be1 100644 --- a/web/public/course-assets/s18_worktree_isolation/worktree-overview.svg +++ b/web/public/course-assets/s18_worktree_isolation/worktree-overview.svg @@ -1,103 +1,107 @@ - + - - - - - + + - - - - - + + - + + - - - Worktree Isolation — Git Worktree + 任务-目录绑定 + 事件日志 - - - - s17 保留 - - s18 新增 - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH (s17 + s18) - bash · read · write · task(4) · send · inbox - request_shutdown · request_plan · review_plan - ★ create_worktree · remove_worktree · keep_worktree - - - - - - - Worktree 隔离(s18 新增:每个任务独立目录 + 独立分支) - - - - 主仓库 (.tasks/ + .worktrees/ + .mailboxes/) - - - - create + bind - - - - create + bind - - - - Alice: .worktrees/auth/ - branch: wt/auth-refactor - Task: 重构认证模块 - ✓ 隔离,不影响 Bob 和主仓库 - - - - Bob: .worktrees/ui/ - branch: wt/ui-login - Task: 重构 UI 登录页 - ✓ 隔离,不影响 Alice 和主仓库 - - - - 事件日志: .worktrees/events.jsonl → create / remove / keep - - - 收尾: keep (保留分支 review) / remove (删除+标记完成) - - - - - s17: idle_poll + auto_claim + protocols + WORK/IDLE lifecycle - - s18: create_worktree + bind_task + remove/keep + events.jsonl (Lead 14→17) - + Worktree Isolation + Git Worktree + Task-Directory Binding + Event Log + + + + + turn + + + + + + + messages + + + + + + + prompt + + + + + + + LLM + + + + + + + TOOL DISPATCH + bash read write task(4) send inbox + request_shutdown request_plan review_plan + create_worktree remove_worktree keep_worktree + + + + loop back + + + + Worktree Isolation: Each Task Gets Its Own Directory + Branch + + + + Main Repo (.tasks/ .worktrees/ .mailboxes/) + shared state, task metadata, message bus + + + + + + + + create + bind + + + + create + bind + + + + Alice: .worktrees/auth/ + branch: wt/auth-refactor + Task: Refactor auth module + Isolated from Bob and main repo + + + + Bob: .worktrees/ui/ + branch: wt/ui-login + Task: Refactor UI login page + Isolated from Alice and main repo + + + + Event Log: .worktrees/events.jsonl (create / remove / keep) + + + + Cleanup: keep (preserve for review) / remove (delete + done) + + + + $ create_worktree("auth", task_id="t1") # Creates .worktrees/auth/ + branch wt/auth + $ keep_worktree("auth") # Preserve branch for review + + + \ No newline at end of file diff --git a/web/public/course-assets/s19_mcp_plugin/mcp-architecture.en.svg b/web/public/course-assets/s19_mcp_plugin/mcp-architecture.en.svg deleted file mode 100644 index 01d0c068d..000000000 --- a/web/public/course-assets/s19_mcp_plugin/mcp-architecture.en.svg +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - MCP Plugin — Standard Protocol + External Tool Integration + Tool Pool Assembly - - - - s18 Preserved - - s19 New - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH (Lead 18 tools) - bash · read · write · task(4) · send · inbox - request_shutdown · request_plan · review_plan - create_worktree · remove_worktree · keep_worktree - ★ connect_mcp + dynamic mcp__server__tool tools - - - - - - - MCP Architecture (s19 new: standard protocol + external tools dynamic integration) - - - - Agent Side (MCPClient) - - - connect_mcp → discover → register tools - - - assemble_tool_pool assembles builtin + mcp - - - call_tool("mcp__docs__search", ...) - - - - tools/list - - - tools/call + response - - - - MCP Servers (External Services) - - - docs server: search · get_version - - - deploy server: trigger · status - - - Any language, just needs stdio JSON-RPC - - - - Tool naming: mcp__{server}__{tool} → e.g. mcp__docs__search · mcp__deploy__trigger · prevents name collisions across servers - - - - - s18: worktree + events + protocols (Lead 17) - - s19: MCP + dynamic tools (Lead 18) - - - - Next: s20 combines tools, permissions, teams, worktrees, MCP, and more into one while True loop. - diff --git a/web/public/course-assets/s19_mcp_plugin/mcp-architecture.ja.svg b/web/public/course-assets/s19_mcp_plugin/mcp-architecture.ja.svg deleted file mode 100644 index d2b5255c5..000000000 --- a/web/public/course-assets/s19_mcp_plugin/mcp-architecture.ja.svg +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - MCP Plugin — 標準プロトコル + 外部ツール接続 + ツールプール組み立て - - - - s18 保持 - - s19 新規 - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH(Lead 18 tools) - bash · read · write · task(4) · send · inbox - request_shutdown · request_plan · review_plan - create_worktree · remove_worktree · keep_worktree - ★ connect_mcp + 動的 mcp__server__tool ツール - - - - - - - MCP アーキテクチャ(s19 新規:標準プロトコル + 外部ツール動的統合) - - - - Agent 側(MCPClient) - - - connect_mcp → discover → ツール登録 - - - assemble_tool_pool builtin + mcp 組み立て - - - call_tool("mcp__docs__search", ...) - - - - tools/list - - - tools/call + response - - - - MCP Servers(外部サービス) - - - docs server: search · get_version - - - deploy server: trigger · status - - - 任意言語実装、stdio JSON-RPC のみ必要 - - - - ツール命名: mcp__{server}__{tool} → 例: mcp__docs__search · mcp__deploy__trigger · サーバー間の名前衝突を防止 - - - - - s18: worktree + events + protocols(Lead 17) - - s19: MCP + dynamic tools(Lead 18) - - - - 次の s20:tools、permissions、teams、worktree、MCP などを 1 つの while True ループに統合。 - diff --git a/web/public/course-assets/s19_mcp_plugin/mcp-architecture.svg b/web/public/course-assets/s19_mcp_plugin/mcp-architecture.svg index 6b365d6b7..abe2d13d7 100644 --- a/web/public/course-assets/s19_mcp_plugin/mcp-architecture.svg +++ b/web/public/course-assets/s19_mcp_plugin/mcp-architecture.svg @@ -1,112 +1,146 @@ - + - - - - - + + - - - - - + + - + + - - - MCP Plugin — 标准协议 + 外部工具接入 + 工具池组装 - - - - s18 保留 - - s19 新增 - - - - turn - - - - - messages - - - - - prompt - - - - - LLM - - - - - TOOL DISPATCH (Lead 18 tools) - bash · read · write · task(4) · send · inbox - request_shutdown · request_plan · review_plan - create_worktree · remove_worktree · keep_worktree - ★ connect_mcp + 动态 mcp__server__tool 工具 - - - - - - - MCP 架构(s19 新增:标准协议 + 外部工具动态接入) - - - - Agent Side (MCPClient) - - - connect_mcp → discover → 注册工具 - - - assemble_tool_pool 组装 builtin + mcp - - - call_tool("mcp__docs__search", ...) - - - - tools/list - - - tools/call + response - - - - MCP Servers (外部服务) - - - docs server: search · get_version - - - deploy server: trigger · status - - - 任意语言实现,只需 stdio JSON-RPC - - - - 工具命名: mcp__{server}__{tool} → 例: mcp__docs__search · mcp__deploy__trigger · 避免不同 server 的工具名冲突 - - - - - s18: worktree + events + protocols (Lead 17) - - s19: MCP + dynamic tools (Lead 18) - - - - 下一章 s20:把工具、权限、团队、worktree、MCP 等机制合回同一个 while True 循环。 - + MCP Plugin — Standard Protocol + External Tool Integration + Discover, assemble, invoke — agent doesn't need to know who wrote them + + + + Agent Lead Loop + + + + turn + + + + + + + messages + + + + + + + prompt + + + + + + + LLM + + + + + + + TOOL DISPATCH (18 tools) + bash, read, write, task(5), connect_mcp, mcp__* + + + + loop back + + + + + + + + MCP Architecture — Standard Protocol + Dynamic External Tools + + + + Agent Side (MCPClient) + + + + connect_mcp + Connect to server, discover tools, register + + + + assemble_tool_pool + Merge built-in + MCP tools into one pool + + + + call_tool + + call_tool("mcp__docs__search", ...) + + + + + tools/list + + + + tools/call + + + + response + + + + + + + + + MCP Servers (External Services) + + + + docs server + search (readOnly) · get_version (readOnly) + + + + deploy server + trigger (destructive) · status (readOnly) + + + + any server ... + Any language, just implement stdio JSON-RPC + + + + Naming: mcp__{server}__{tool} e.g. mcp__docs__search · mcp__deploy__trigger + + + + Key Behaviors + + + + No prompt cache + Tool pool is dynamic; cache stales + + + + MCP tools = Lead only + Teammates use fixed 8-tool subset + + + + normalize_mcp_name + Sanitize names: [a-zA-Z0-9_-] + \ No newline at end of file diff --git a/web/public/course-assets/s20_comprehensive/system-architecture.en.svg b/web/public/course-assets/s20_comprehensive/system-architecture.en.svg deleted file mode 100644 index 01ac3dfbd..000000000 --- a/web/public/course-assets/s20_comprehensive/system-architecture.en.svg +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - s20 Comprehensive Agent — Every Mechanism Around One Loop - - Core Agent Loop - - messages[] - - - Before LLM - cron/background injection - compact + memory + prompt - - - LLM - stop_reason=tool_use? - - - Before Tools - PreToolUse hooks - permission pipeline - - - handlers - builtin + MCP - - tool_result / task_notification → messages[] → next turn - - Context & Knowledge - s07 skills + load_skill - s09 memory selection - s10 prompt sections - s08 compact pipeline - - - Governance - s03 permission - s04 hooks - s11 retry / fallback - Stop hooks - - - Durable Work - s05 todo_write - s12 task graph - s13 background - s14 cron scheduler - - - Teams & Plugins - s06 subagent - s15-s17 team protocols - s18 worktree isolation - s19 MCP tools - - - TOOL POOL: 27 builtins + dynamic mcp__server__tool - file/shell: bash · read · write · edit · glob - single-agent: todo_write · task · load_skill · compact - durable work: task tools · cron tools - team: spawn_teammate · send_message · check_inbox - protocol: request_shutdown · request_plan · review_plan - isolation/plugin: worktree tools · connect_mcp - - diff --git a/web/public/course-assets/s20_comprehensive/system-architecture.ja.svg b/web/public/course-assets/s20_comprehensive/system-architecture.ja.svg deleted file mode 100644 index 0461be007..000000000 --- a/web/public/course-assets/s20_comprehensive/system-architecture.ja.svg +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - s20 Comprehensive Agent — すべての仕組みを 1 つのループへ - - Core Agent Loop - - messages[] - - - LLM 前 - cron/background 注入 - compact + memory + prompt - - - LLM - stop_reason=tool_use? - - - Tool 前 - PreToolUse hooks - permission pipeline - - - handlers - builtin + MCP - - tool_result / task_notification → messages[] → 次のターン - - Context / Knowledge - s07 skills + load_skill - s09 memory selection - s10 prompt sections - s08 compact pipeline - - - Governance - s03 permission - s04 hooks - s11 retry / fallback - Stop hooks - - - Durable Work - s05 todo_write - s12 task graph - s13 background - s14 cron scheduler - - - Teams / Plugins - s06 subagent - s15-s17 team protocols - s18 worktree isolation - s19 MCP tools - - - TOOL POOL: 27 builtins + dynamic mcp__server__tool - file/shell: bash · read · write · edit · glob - single-agent: todo_write · task · load_skill · compact - durable work: task tools · cron tools - team: spawn_teammate · send_message · check_inbox - protocol: request_shutdown · request_plan · review_plan - isolation/plugin: worktree tools · connect_mcp - - diff --git a/web/public/course-assets/s20_comprehensive/system-architecture.svg b/web/public/course-assets/s20_comprehensive/system-architecture.svg index 72e52f887..45ee92adc 100644 --- a/web/public/course-assets/s20_comprehensive/system-architecture.svg +++ b/web/public/course-assets/s20_comprehensive/system-architecture.svg @@ -1,105 +1,195 @@ - + - - - - - - - - - - - + - - + + - - - - s20 Comprehensive Agent — 全部机制挂在同一个循环上 - - - - 核心 Agent Loop - - - messages[] - - - - - LLM 前处理 - cron / background 注入 - compact + memory + prompt - - - - - LLM - stop_reason=tool_use? - - - - - 工具前闸门 - PreToolUse hooks - permission pipeline - - - - - handlers - builtin + MCP - - - tool_result / task_notification → messages[] → 下一轮 - - - - 上下文与知识 - s07 skills catalog + load_skill - s09 memory selection - s10 prompt sections - s08 compact pipeline - - - - 治理与扩展点 - s03 permission - s04 hooks - s11 retry / fallback - Stop hooks - - - - 持久工作 - s05 todo_write - s12 task graph - s13 background - s14 cron scheduler - - - - 团队与插件 - s06 subagent - s15-s17 team protocols - s18 worktree isolation - s19 MCP tools - - - - - TOOL POOL: 27 builtins + dynamic mcp__server__tool - file/shell: bash · read · write · edit · glob - single-agent: todo_write · task · load_skill · compact - durable work: create/list/get/claim/complete_task · schedule/list/cancel_cron - team: spawn_teammate · send_message · check_inbox - protocol: request_shutdown · request_plan · review_plan - isolation/plugin: create/remove/keep_worktree · connect_mcp - - + + + + + Comprehensive Agent — All Mechanisms, One Loop + Every component converges in the same while-True loop + + + + + + Core Agent Loop + + + + messages[] + + + + + + + LLM Pre-processing + cron / background inject + compact + memory + prompt + + + + + + + LLM + stop_reason= + tool_use? + + + + + + + Pre-Tool Gate + PreToolUse hooks + permission pipeline + + + + + + + Tool Handlers + builtin + MCP + PostToolUse hooks + + + + + + + + tool_result / task_notification → messages[] → next round + + + + no tool_use + + + + Stop hooks + + + + + + Return Result + + + + + + + + Context & Knowledge + skills catalog + load_skill + memory selection + system prompt assembly + compact pipeline + + + + + + Governance & Extensions + permission pipeline + hooks (Pre/Post/Stop) + retry / fallback + error recovery + + + + + + Durable Work + todo_write (session plan) + task graph (cross-session) + background dispatch + cron scheduler + + + + + + Teams & Plugins + subagent (one-shot) + team protocols + worktree isolation + MCP external tools + + + + + + + + + + + + + TOOL POOL: 27 builtins + dynamic mcp__server__tool + + + + + + file/shell + bash + read_file + write_file + edit_file + glob + + + single-agent + todo_write + task + load_skill + compact + + + durable work + create_task + list_tasks + get_task + claim_task + complete_task + schedule_cron + list_crons + cancel_cron + + + team + spawn_teammate + send_message + check_inbox + + + protocol + request_shutdown + request_plan + review_plan + + + isolation + create_worktree + remove_worktree + keep_worktree + plugin + connect_mcp + + + + + + + \ No newline at end of file diff --git a/web/public/course-assets/s21_workflow_runtime/workflow-runtime-overview.svg b/web/public/course-assets/s21_workflow_runtime/workflow-runtime-overview.svg new file mode 100644 index 000000000..281cd2e39 --- /dev/null +++ b/web/public/course-assets/s21_workflow_runtime/workflow-runtime-overview.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + Workflow Runtime — one tool_use launches a background orchestration + the main loop calls Workflow like any tool; a deterministic runtime fans out subagents in the background and can resume + + + + Main session loop + + + + append tool_result / notification -> messages[] (loop continues) + + + + + + messages[] + message history + + + + + LLM + tool_use? + + + + + Workflow({script, args}) + (or name | scriptPath) · resumeFromRunId + + + + + tool_result + async_launched + + + + later + + + + task_notification + completed · final report + + + + Background workflow runtime — local_workflow + + + + WorkflowTool.call + validate meta · permission + runId · taskId + + + + LocalWorkflowTask + status · usage + progress events + + + + Script VM — runs the script + phase · agent() + parallel · pipeline + + + + + + Subagents × N + isolated ctx + schema output + + + + Journal + started / result + per agent() + + + + agent() + spawns + + + + record + + + + resumeFromRunId -> cached agent() + + + + + launch (async) + + + + task_progress: workflow_phase · workflow_agent · workflow_log + + + The runtime result stays on the task (scriptPath · transcripts · journal · output) — only the launch + final notification re-enter messages[]. + diff --git a/web/public/course-assets/s22_goal_loop/goal-loop-overview.svg b/web/public/course-assets/s22_goal_loop/goal-loop-overview.svg new file mode 100644 index 000000000..907c522b0 --- /dev/null +++ b/web/public/course-assets/s22_goal_loop/goal-loop-overview.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + Goal Loop — the host-owned turn-completion gate + after every turn an evaluator judges trusted evidence and blocks the stop until the goal is met + + + + Main loop — the turn boundary + + + + continuation -> transcript[] (next turn) + + + + + transcript[] + messages + origins + + + + + turn (LLM) + may emit tool_use + + + + + no tool_use + (wants to stop) + + + + + goal gate + evaluate_after_turn() + after every turn + + + + completed + / blocked + + + + return + turn ends + + + + + judge + + + evaluator + separate small / fast model + + + + reads + + + + evidence window + = transcript[start_index:] · trust boundary + + + + counts as evidence + task-notification + monitor-line + trusted async origins + + + + filtered out + /goal command text + continuation reminder + plain user / assistant + model can't self-satisfy + + + + + continuing + + + + CommandQueue + continuation (active-goal) + + + + + + The model proposes stop; the goal gate decides against trusted evidence only, not the model's own say-so. + diff --git a/web/scripts/extract-content.ts b/web/scripts/extract-content.ts index 7750b040f..4fa79aa6e 100644 --- a/web/scripts/extract-content.ts +++ b/web/scripts/extract-content.ts @@ -166,7 +166,7 @@ function copyChapterAssets(chapter: ChapterSource): ChapterImage[] { } function localeReadmeName(locale: Locale): string { - if (locale === "zh") return "README.md"; + if (locale === "en") return "README.md"; return `README.${locale}.md`; } @@ -178,7 +178,7 @@ function rewriteChapterMarkdown( let next = content; next = next.replace( - /^\[中文\]\(README\.md\)\s*.\s*\[English\]\(README\.en\.md\)\s*.\s*\[日本語\]\(README\.ja\.md\)\n\n?/m, + /^\[中文\]\(README\.zh\.md\)\s*.\s*\[English\]\(README\.md\)\s*.\s*\[日本語\]\(README\.ja\.md\)\n\n?/m, "" ); diff --git a/web/src/data/generated/docs.json b/web/src/data/generated/docs.json index 4e50b7380..84eccf0e4 100644 --- a/web/src/data/generated/docs.json +++ b/web/src/data/generated/docs.json @@ -3,360 +3,396 @@ "version": "s01", "locale": "en", "title": "s01: The Agent Loop — One Loop Is All You Need", - "content": "# s01: The Agent Loop — One Loop Is All You Need\n\n`s01` → [s02](/en/s02) → s03 → s04 → ... → s20\n> *\"One loop & Bash is all you need\"* — One tool + one loop = one Agent.\n>\n> **Harness Layer**: The Loop — the first bridge between the model and the real world.\n\n---\n\n## The Problem\n\nYou ask the model: \"List the files in my directory and run XXX.py.\"\n\nThe model can output a bash command, but once it's done outputting, it stops — it won't execute the command on its own, and it won't keep reasoning based on the result.\n\nYou could run it manually, paste the output back into the chat, and let it continue. Next command comes out, you run it again, paste it back.\n\nEvery round-trip, you're the middle layer. Automating that is what this chapter is about.\n\n---\n\n## The Solution\n\n![Agent Loop](/course-assets/s01_agent_loop/agent-loop.en.svg)\n\nA `while True` loop: keep going when the model calls a tool, stop when it doesn't. The entire process hinges on two signals:\n\n| Signal | Meaning | Loop Action |\n|--------|---------|-------------|\n| `stop_reason == \"tool_use\"` | Model raises hand: \"I need a tool\" | Execute → feed result back → continue |\n| `stop_reason != \"tool_use\"` | Model says: \"I'm done\" | Exit loop |\n\n---\n\n## How It Works\n\nLet's translate this process into code. Step by step:\n\n**Step 1**: Start with the user's question as the first message.\n\n```python\nmessages = [{\"role\": \"user\", \"content\": query}]\n```\n\n**Step 2**: Send the messages and tool definitions to the LLM.\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n**Step 3**: Append the model's response and check whether it called a tool. No tool call → done.\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\nif response.stop_reason != \"tool_use\":\n return\n```\n\n**Step 4**: Execute the tool the model requested and collect the results.\n\n```python\nresults = []\nfor block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n**Step 5**: Append the tool results as a new message and go back to Step 2.\n\n```python\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\nAssembled into a complete function:\n\n```python\ndef agent_loop(messages):\n while True:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n messages.append({\"role\": \"user\", \"content\": results})\n```\n\nUnder 30 lines — that's the minimal runnable agent harness kernel. It's not intelligence itself, but the smallest runtime framework that lets the model keep acting. The model decides (whether to call a tool, which one), the harness executes (if called, run it, feed the result back). The next 18 chapters all add mechanisms on top of this loop. The loop itself never changes.\n\n---\n\n## Try It\n\n> **Teaching demo notice**: The code executes shell commands generated by the model. Run it in a temporary test directory to avoid affecting your project files. s03 covers the real permission system.\n\n**Setup** (first run):\n\n```sh\npip install -r requirements.txt\ncp .env.example .env\n# Edit .env, fill in ANTHROPIC_API_KEY and MODEL_ID\n```\n\n**Run**:\n\n```sh\npython s01_agent_loop/code.py\n```\n\nTry these prompts:\n\n1. `Create a file called hello.py that prints \"Hello, World!\"`\n2. `List all Python files in this directory`\n3. `What is the current git branch?`\n\nWhat to watch for: When does the model call a tool (loop continues), and when does it not (loop ends)?\n\n---\n\n## What's Next\n\nRight now the model only has bash — reading files requires `cat`, writing files requires `echo ... >`, finding files requires `find`. Ugly and error-prone.\n\n→ s02 Tool Use: What happens when we give it 5 proper tools? Will the model call multiple tools at once? Will parallel tool executions step on each other?\n\n
\nDive into CC Source Code\n\n> The following is based on a review of CC source code `src/query.ts` (1729 lines). The core differences are twofold: CC doesn't rely on the `stop_reason` field to decide whether to continue the loop — instead it checks whether the content contains `tool_use` blocks (because `stop_reason` is unreliable in streaming responses); CC has more exit paths and recovery strategies for production-grade protection.\n\n**The 30-line `while True` from the teaching version IS the core of CC's 1729 lines.** Everything below is a protection mechanism layered on top of that core.\n\n
\n1. Loop Structure Differences\n\nThe teaching version checks `response.stop_reason`. CC doesn't use it as the sole signal for loop continuation — in streaming responses, `stop_reason` may not have updated yet even though `tool_use` blocks are already present. CC uses a `needsFollowUp` flag: during streaming message reception (`query.ts:830-834`), it's set to `true` whenever a `tool_use` block is detected. `QueryEngine.ts` captures the real `stop_reason` from `message_delta` for other logic, but the query loop itself relies on `needsFollowUp`.\n\n```typescript\n// query.ts:554-558\n// stop_reason === 'tool_use' is unreliable.\n// Set during streaming whenever a tool_use block arrives.\nlet needsFollowUp = false\n```\n\n
\n\n
\n2. State Object — 10 Fields (Teaching Version Only Uses messages)\n\n| # | Field | Purpose | Chapter |\n|---|-------|---------|---------|\n| 1 | `messages` | Message array for the current iteration | s01 |\n| 2 | `toolUseContext` | Tool, signal, and permission context | s02 |\n| 3 | `autoCompactTracking` | Compaction state tracking | s08 |\n| 4 | `maxOutputTokensRecoveryCount` | Token recovery attempt count (max 3) | s11 |\n| 5 | `hasAttemptedReactiveCompact` | Whether reactive compaction was attempted this round | s08 |\n| 6 | `maxOutputTokensOverride` | 8K→64K upgrade override | s11 |\n| 7 | `pendingToolUseSummary` | Background Haiku-generated tool use summary | s08 |\n| 8 | `stopHookActive` | Whether the stop hook produced a blocking error | s04 |\n| 9 | `turnCount` | Turn count (for maxTurns check) | s01 |\n| 10 | `transition` | Last continue reason | s11 |\n\n> Note: `taskBudgetRemaining` (`query.ts:291`) is a loop-local variable, not on State. The source comment explicitly says \"Loop-local (not on State)\".\n\n
\n\n
\n3. Multiple Exit and Continue Paths\n\nThe teaching version has only 1 exit path (model doesn't call a tool → done). The production version has multiple exit and continue paths, covering blocking limit, prompt too long, model error, abort, hook stop, max turns, token budget continuation, reactive compact retry, and more. Each scenario has a corresponding recovery or exit strategy.\n\n
\n\n
\n4. Streaming Tool Execution and QueryEngine\n\nCC's `StreamingToolExecutor` (`query.ts:561`) allows tools to begin parallel execution while the model is still generating (concurrency-safe tools run in parallel, others run exclusively). `QueryEngine.ts` adds additional protections for cost overruns, structured output validation failures, and more. The teaching version doesn't implement these — the goal is conceptual clarity, not peak performance.\n\n
\n\n**In one sentence**: The core of query.ts's 1729 lines is a 30-line `while True`. All the complex fields and exit paths are protection mechanisms. Understand the core loop first, and everything that follows unfolds naturally.\n\n
\n\n\n" + "content": "# s01: The Agent Loop — One Loop Is All You Need\n\n`s01` → [s02](/en/s02) → s03 → s04 → ... → s20\n> *\"One loop & Bash is all you need\"* — One tool + one loop = one Agent.\n>\n> **Harness Layer**: The Loop — the first bridge between the model and the real world.\n\n---\n\n## The Problem\n\nYou ask the model: \"List the files in my directory and run XXX.py.\"\n\nThe model can output a bash command, but once it's done outputting, it stops. It won't execute the command on its own, and it won't keep reasoning based on the result.\n\nYou could run it manually, paste the output back into the chat, and let it continue. Next command comes out, you run it again, paste it back.\n\nEvery round-trip, you're the middle layer. Automating that is what this chapter is about.\n\n---\n\n## The Solution\n\n![Agent Loop](/course-assets/s01_agent_loop/agent-loop.svg)\n\nA `while True` loop: keep going when the model calls a tool, stop when it doesn't. The entire process hinges on two signals:\n\n| Signal | Meaning | Loop Action |\n|--------|---------|-------------|\n| `stop_reason == \"tool_use\"` | Model requests a tool call | Execute → feed result back → continue |\n| `stop_reason != \"tool_use\"` | Model returns without a tool call | Exit loop |\n\n---\n\n## How It Works\n\nLet's translate this process into code. Step by step:\n\n**Step 1**: Start with the user's question as the first message.\n\n```python\nmessages = [{\"role\": \"user\", \"content\": query}]\n```\n\n**Step 2**: Send the messages and tool definitions to the LLM.\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n**Step 3**: Append the model's response and check whether it called a tool. No tool call → done.\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\nif response.stop_reason != \"tool_use\":\n return\n```\n\n**Step 4**: Execute the tool the model requested and collect the results.\n\n```python\nresults = []\nfor block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n**Step 5**: Append the tool results as a new message and go back to Step 2.\n\n```python\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\nAssembled into a complete function:\n\n```python\ndef agent_loop(messages):\n while True:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n messages.append({\"role\": \"user\", \"content\": results})\n```\n\nUnder 30 lines — that's the minimal runnable agent harness kernel. It's not intelligence itself, but the smallest runtime framework that lets the model keep acting. The model decides (whether to call a tool, which one), the harness executes (if called, run it, feed the result back). The next 18 chapters all add mechanisms on top of this loop. The loop itself never changes.\n\n---\n\n## Try It\n\n> **Teaching demo notice**: The code executes shell commands generated by the model. Run it in a temporary test directory to avoid affecting your project files. s03 covers the real permission system.\n\n**Setup** (first run):\n\n```sh\npip install -r requirements.txt\ncp .env.example .env\n# Edit .env, fill in ANTHROPIC_API_KEY and MODEL_ID\n```\n\n**Run**:\n\n```sh\npython s01_agent_loop/code.py\n```\n\nTry these prompts:\n\n1. `Create a file called hello.py that prints \"Hello, World!\"`\n2. `List all Python files in this directory`\n3. `What is the current git branch?`\n\nWhat to watch for: When does the model call a tool (loop continues), and when does it not (loop ends)?\n\n---\n\n## What's Next\n\nRight now the model only has bash: reading files requires `cat`, writing files requires `echo ... >`, finding files requires `find`. Ugly and error-prone.\n\n→ s02 Tool Use: What happens when we give it 5 proper tools? Will the model call multiple tools at once? Will parallel tool executions step on each other?\n\n
\nDive into Claude Code Source Code\n\n> The following is based on a review of Claude Code source code `src/query.ts` (1729 lines). The core differences are twofold: Claude Code doesn't rely on the `stop_reason` field to decide whether to continue the loop — instead it checks whether the content contains `tool_use` blocks (because `stop_reason` is unreliable in streaming responses); Claude Code has more exit paths and recovery strategies for production-grade protection.\n\nEverything below is a protection mechanism layered on top of the teaching version's loop.\n\n
\n1. Loop Structure Differences\n\nThe teaching version checks `response.stop_reason`. Claude Code doesn't use it as the sole signal for loop continuation — in streaming responses, `stop_reason` may not have updated yet even though `tool_use` blocks are already present. Claude Code uses a `needsFollowUp` flag: during streaming message reception (`query.ts:830-834`), it's set to `true` whenever a `tool_use` block is detected. `QueryEngine.ts` captures the real `stop_reason` from `message_delta` for other logic, but the query loop itself relies on `needsFollowUp`.\n\n```typescript\n// query.ts:554-558\n// stop_reason === 'tool_use' is unreliable.\n// Set during streaming whenever a tool_use block arrives.\nlet needsFollowUp = false\n```\n\n
\n\n
\n2. State Object — 10 Fields (Teaching Version Only Uses messages)\n\n| # | Field | Purpose | Chapter |\n|---|-------|---------|---------|\n| 1 | `messages` | Message array for the current iteration | s01 |\n| 2 | `toolUseContext` | Tool, signal, and permission context | s02 |\n| 3 | `autoCompactTracking` | Compaction state tracking | s08 |\n| 4 | `maxOutputTokensRecoveryCount` | Token recovery attempt count (max 3) | s11 |\n| 5 | `hasAttemptedReactiveCompact` | Whether reactive compaction was attempted this round | s08 |\n| 6 | `maxOutputTokensOverride` | 8K→64K upgrade override | s11 |\n| 7 | `pendingToolUseSummary` | Background Haiku-generated tool use summary | s08 |\n| 8 | `stopHookActive` | Whether the stop hook produced a blocking error | s04 |\n| 9 | `turnCount` | Turn count (for maxTurns check) | s01 |\n| 10 | `transition` | Last continue reason | s11 |\n\n> Note: `taskBudgetRemaining` (`query.ts:291`) is a loop-local variable, not on State. The source comment explicitly says \"Loop-local (not on State)\".\n\n
\n\n
\n3. Multiple Exit and Continue Paths\n\nThe teaching version has only 1 exit path (model doesn't call a tool → done). The production version has multiple exit and continue paths, covering blocking limit, prompt too long, model error, abort, hook stop, max turns, token budget continuation, reactive compact retry, and more. Each scenario has a corresponding recovery or exit strategy.\n\n
\n\n
\n4. Streaming Tool Execution and QueryEngine\n\nClaude Code's `StreamingToolExecutor` (`query.ts:561`) allows tools to begin parallel execution while the model is still generating (concurrency-safe tools run in parallel, others run exclusively). `QueryEngine.ts` adds additional protections for cost overruns, structured output validation failures, and more. The teaching version doesn't implement these — the goal is conceptual clarity, not peak performance.\n\n
\n\nAll the complex fields and exit paths are protection mechanisms. Understanding the core loop makes the rest easier to follow.\n\n
\n\n\n" }, { "version": "s01", "locale": "zh", "title": "s01: Agent Loop — 一个循环就够了", - "content": "# s01: Agent Loop — 一个循环就够了\n\n`s01` → [s02](/zh/s02) → s03 → s04 → ... → s20\n> *\"One loop & Bash is all you need\"* — 一个工具 + 一个循环 = 一个 Agent。\n>\n> **Harness 层**: 循环 — 模型与真实世界的第一道连接。\n\n---\n\n## 问题\n\n你提出了一个问题给大模型:“帮我读取下我的目录下有哪些文件,并且执行XXX.py”。\n\n模型能输出一条 bash 命令,但输出完了就停了,它不会自己跑,也不会看到结果后继续推理。\n\n你可以手动跑一遍,把输出粘贴回对话框,让它接着干。下一个命令出来,你再跑一遍、再贴回去。\n\n每一个来回,你都在做中间层。而把它自动化,就是这一章要做的事。\n\n---\n\n## 解决方案\n\n![Agent Loop](/course-assets/s01_agent_loop/agent-loop.svg)\n\n一个 `while True` 循环,模型调用工具就继续,不调用就停。整个过程只有两个信号:\n\n| 信号 | 含义 | 循环动作 |\n|------|------|---------|\n| `stop_reason == \"tool_use\"` | 模型举手说\"我要用工具\" | 执行 → 结果喂回去 → 继续 |\n| `stop_reason != \"tool_use\"` | 模型说\"我做完了\" | 退出循环 |\n\n---\n\n## 工作原理\n\n将这个过程翻译成代码。分步来看:\n\n**第 1 步**:把用户的问题作为第一条消息。\n\n```python\nmessages = [{\"role\": \"user\", \"content\": query}]\n```\n\n**第 2 步**:将消息和工具定义一起发给 LLM。\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n**第 3 步**:追加模型回答,检查它是否调了工具。没调 → 结束。\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\nif response.stop_reason != \"tool_use\":\n return\n```\n\n**第 4 步**:执行模型要求的工具,收集结果。\n\n```python\nresults = []\nfor block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n**第 5 步**:把工具结果作为新消息追加,回到第 2 步。\n\n```python\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n组装为一个完整函数:\n\n```python\ndef agent_loop(messages):\n while True:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n messages.append({\"role\": \"user\", \"content\": results})\n```\n\n不到 30 行,这就是最小可运行的 agent harness 内核。它不是智能本身,而是让模型能持续行动的最小运行框架,模型负责决策(要不要调工具、调哪个),harness 负责执行(调了就跑、结果喂回去)。后面 18 个章节都在这个循环上叠加机制,循环本身始终不变。\n\n---\n\n## 试一下\n\n> **教学 demo 提示**:代码会执行模型生成的 shell 命令。建议在一个临时测试目录中运行,避免影响你的项目文件。s03 会讲真正的权限系统。\n\n**准备**(首次运行):\n\n```sh\npip install -r requirements.txt\ncp .env.example .env\n# 编辑 .env,填入 ANTHROPIC_API_KEY 和 MODEL_ID\n```\n\n**运行**:\n\n```sh\npython s01_agent_loop/code.py\n```\n\n试试这些 prompt:\n\n1. `Create a file called hello.py that prints \"Hello, World!\"`\n2. `List all Python files in this directory`\n3. `What is the current git branch?`\n\n观察重点:模型什么时候调用工具(循环继续),什么时候不调用(循环结束)?\n\n---\n\n## 接下来\n\n现在模型手里只有 bash 一个工具,读文件要 `cat`,写文件要 `echo ... >`,找个文件要 `find`,又丑又容易出错。\n\ns02 Tool Use → 给它 5 个真正的工具,会发生什么?模型会不会一次调用多个工具?几个工具同时跑会不会互相踩?\n\n
\n深入 CC 源码\n\n> 以下内容基于 CC 源码 `src/query.ts`(1729 行)的核查。核心差异就两个:CC 不看 `stop_reason` 字段而是检查内容里有没有 tool_use 块(因为流式响应中 stop_reason 不可靠);CC 有更多的退出路径和恢复策略做生产级保护。\n\n**教学版的 30 行 `while True` 就是 CC 1729 行的核心。** 下面每一项都是在这个核心上叠加的保护机制。\n\n
\n一、循环结构差异\n\n教学版检查 `response.stop_reason`。CC 不把它作为循环继续的唯一依据——流式响应中 `stop_reason` 可能还没更新但内容里已经有 `tool_use` 块了。CC 用 `needsFollowUp` 标志:接收到流式消息时(`query.ts:830-834`),只要检测到 `tool_use` 块就设为 `true`;`QueryEngine.ts` 会从 `message_delta` 捕获真实 `stop_reason` 用于其他逻辑,但 query loop 本身靠 `needsFollowUp` 决定是否继续。\n\n```typescript\n// query.ts:554-558\n// stop_reason === 'tool_use' is unreliable.\n// Set during streaming whenever a tool_use block arrives.\nlet needsFollowUp = false\n```\n\n
\n\n
\n二、State 对象 10 字段(教学版只用 messages)\n\n| # | 字段 | 用途 | 对应章节 |\n|---|------|------|---------|\n| 1 | `messages` | 当前迭代的消息数组 | s01 |\n| 2 | `toolUseContext` | 工具、信号、权限上下文 | s02 |\n| 3 | `autoCompactTracking` | 压缩状态追踪 | s08 |\n| 4 | `maxOutputTokensRecoveryCount` | token 恢复尝试次数(上限 3) | s11 |\n| 5 | `hasAttemptedReactiveCompact` | 本轮是否已尝试响应式压缩 | s08 |\n| 6 | `maxOutputTokensOverride` | 8K→64K 的升级覆盖 | s11 |\n| 7 | `pendingToolUseSummary` | 后台 Haiku 生成的 tool use 摘要 | s08 |\n| 8 | `stopHookActive` | 停止钩子是否产生阻塞错误 | s04 |\n| 9 | `turnCount` | 轮次计数(maxTurns 检查) | s01 |\n| 10 | `transition` | 上一次继续原因 | s11 |\n\n> 注:`taskBudgetRemaining`(`query.ts:291`)是 loop-local 局部变量,不在 State 上。源码注释明确写了 \"Loop-local (not on State)\"。\n\n
\n\n
\n三、多条退出和继续路径\n\n教学版只有 1 条退出路径(模型不调工具就结束)。生产版有多条退出和继续路径,覆盖 blocking limit、prompt too long、model error、abort、hook stop、max turns、token budget continuation、reactive compact retry 等场景。每种场景都有对应的恢复或退出策略。\n\n
\n\n
\n四、流式工具执行和 QueryEngine\n\nCC 的 `StreamingToolExecutor`(`query.ts:561`)让工具在模型还在生成时就开始并行执行(根据工具是否 concurrency-safe 决定并发或独占)。`QueryEngine.ts` 额外加了费用超限、结构化输出验证失败等保护。教学版不实现这些——目标是概念清晰,不是性能极致。\n\n
\n\n**一句话**:1729 行的 query.ts 核心就是 30 行 `while True`。所有复杂字段和退出路径都是保护机制。先理解核心循环,后面的一切自然展开。\n\n
\n\n\n" + "content": "# s01: Agent Loop — 一个循环就够了\n\n`s01` → [s02](/zh/s02) → s03 → s04 → ... → s20\n> *\"One loop & Bash is all you need\"* — 一个工具 + 一个循环 = 一个 Agent。\n>\n> **Harness 层**: 循环 — 模型与真实世界的第一道连接。\n\n---\n\n## 问题\n\n你提出了一个问题给大模型:“帮我读取下我的目录下有哪些文件,并且执行XXX.py”。\n\n模型能输出一条 bash 命令,但输出完了就停了,它不会自己跑,也不会看到结果后继续推理。\n\n你可以手动跑一遍,把输出粘贴回对话框,让它接着干。下一个命令出来,你再跑一遍、再贴回去。\n\n每一个来回,你都在做中间层。而把它自动化,就是这一章要做的事。\n\n---\n\n## 解决方案\n\n![Agent Loop](/course-assets/s01_agent_loop/agent-loop.svg)\n\n一个 `while True` 循环,模型调用工具就继续,不调用就停。整个过程只有两个信号:\n\n| 信号 | 含义 | 循环动作 |\n|------|------|---------|\n| `stop_reason == \"tool_use\"` | 模型请求调用工具 | 执行 → 结果喂回去 → 继续 |\n| `stop_reason != \"tool_use\"` | 模型未请求工具,生成结束 | 退出循环 |\n\n---\n\n## 工作原理\n\n将这个过程翻译成代码。分步来看:\n\n**第 1 步**:把用户的问题作为第一条消息。\n\n```python\nmessages = [{\"role\": \"user\", \"content\": query}]\n```\n\n**第 2 步**:将消息和工具定义一起发给 LLM。\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n**第 3 步**:追加模型回答,检查它是否调了工具。没调 → 结束。\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\nif response.stop_reason != \"tool_use\":\n return\n```\n\n**第 4 步**:执行模型要求的工具,收集结果。\n\n```python\nresults = []\nfor block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n**第 5 步**:把工具结果作为新消息追加,回到第 2 步。\n\n```python\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n组装为一个完整函数:\n\n```python\ndef agent_loop(messages):\n while True:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n messages.append({\"role\": \"user\", \"content\": results})\n```\n\n不到 30 行,这就是最小可运行的 agent harness 内核。它不是智能本身,而是让模型能持续行动的最小运行框架,模型负责决策(要不要调工具、调哪个),harness 负责执行(调了就跑、结果喂回去)。后面 18 个章节都在这个循环上叠加机制,循环本身始终不变。\n\n---\n\n## 试一下\n\n> **教学 demo 提示**:代码会执行模型生成的 shell 命令。建议在一个临时测试目录中运行,避免影响你的项目文件。s03 会讲真正的权限系统。\n\n**准备**(首次运行):\n\n```sh\npip install -r requirements.txt\ncp .env.example .env\n# 编辑 .env,填入 ANTHROPIC_API_KEY 和 MODEL_ID\n```\n\n**运行**:\n\n```sh\npython s01_agent_loop/code.py\n```\n\n试试这些 prompt:\n\n1. `Create a file called hello.py that prints \"Hello, World!\"`\n2. `List all Python files in this directory`\n3. `What is the current git branch?`\n\n观察重点:模型什么时候调用工具(循环继续),什么时候不调用(循环结束)?\n\n---\n\n## 接下来\n\n现在模型手里只有 bash 一个工具,读文件要 `cat`,写文件要 `echo ... >`,找个文件要 `find`,不够直观,也容易拼错。\n\ns02 Tool Use → 给它 5 个真正的工具,会发生什么?模型会不会一次调用多个工具?几个工具同时跑会不会互相踩?\n\n
\n深入 Claude Code 源码\n\n> 以下内容基于 Claude Code 源码 `src/query.ts`(1729 行)的核查。核心差异就两个:Claude Code 不看 `stop_reason` 字段而是检查内容里有没有 tool_use 块(因为流式响应中 stop_reason 不可靠);Claude Code 有更多的退出路径和恢复策略做生产级保护。\n\n下面每一项都是在教学版循环基础上叠加的保护机制。\n\n
\n一、循环结构差异\n\n教学版检查 `response.stop_reason`。Claude Code 不把它作为循环继续的唯一依据——流式响应中 `stop_reason` 可能还没更新但内容里已经有 `tool_use` 块了。Claude Code 用 `needsFollowUp` 标志:接收到流式消息时(`query.ts:830-834`),只要检测到 `tool_use` 块就设为 `true`;`QueryEngine.ts` 会从 `message_delta` 捕获真实 `stop_reason` 用于其他逻辑,但 query loop 本身靠 `needsFollowUp` 决定是否继续。\n\n```typescript\n// query.ts:554-558\n// stop_reason === 'tool_use' is unreliable.\n// Set during streaming whenever a tool_use block arrives.\nlet needsFollowUp = false\n```\n\n
\n\n
\n二、State 对象 10 字段(教学版只用 messages)\n\n| # | 字段 | 用途 | 对应章节 |\n|---|------|------|---------|\n| 1 | `messages` | 当前迭代的消息数组 | s01 |\n| 2 | `toolUseContext` | 工具、信号、权限上下文 | s02 |\n| 3 | `autoCompactTracking` | 压缩状态追踪 | s08 |\n| 4 | `maxOutputTokensRecoveryCount` | token 恢复尝试次数(上限 3) | s11 |\n| 5 | `hasAttemptedReactiveCompact` | 本轮是否已尝试响应式压缩 | s08 |\n| 6 | `maxOutputTokensOverride` | 8K→64K 的升级覆盖 | s11 |\n| 7 | `pendingToolUseSummary` | 后台 Haiku 生成的 tool use 摘要 | s08 |\n| 8 | `stopHookActive` | 停止钩子是否产生阻塞错误 | s04 |\n| 9 | `turnCount` | 轮次计数(maxTurns 检查) | s01 |\n| 10 | `transition` | 上一次继续原因 | s11 |\n\n> 注:`taskBudgetRemaining`(`query.ts:291`)是 loop-local 局部变量,不在 State 上。源码注释明确写了 \"Loop-local (not on State)\"。\n\n
\n\n
\n三、多条退出和继续路径\n\n教学版只有 1 条退出路径(模型不调工具就结束)。生产版有多条退出和继续路径,覆盖 blocking limit、prompt too long、model error、abort、hook stop、max turns、token budget continuation、reactive compact retry 等场景。每种场景都有对应的恢复或退出策略。\n\n
\n\n
\n四、流式工具执行和 QueryEngine\n\nClaude Code 的 `StreamingToolExecutor`(`query.ts:561`)让工具在模型还在生成时就开始并行执行(根据工具是否 concurrency-safe 决定并发或独占)。`QueryEngine.ts` 额外加了费用超限、结构化输出验证失败等保护。教学版不实现这些——目标是概念清晰,不是性能极致。\n\n
\n\n所有复杂字段和退出路径都是保护机制,理解核心循环后再看这些会更容易。\n\n
\n\n\n" }, { "version": "s01", "locale": "ja", "title": "s01: Agent Loop — ループ一つで十分", - "content": "# s01: Agent Loop — ループ一つで十分\n\n`s01` → [s02](/ja/s02) → s03 → s04 → ... → s20\n> *\"One loop & Bash is all you need\"* — ツール一つ + ループ一つ = 一つの Agent。\n>\n> **Harness レイヤー**: ループ — モデルと現実世界をつなぐ最初の架け橋。\n\n---\n\n## 課題\n\nモデルにこう頼んだとする:「ディレクトリ内のファイル一覧を取得して、XXX.py を実行して」。\n\nモデルは bash コマンドを出力できるが、出力が終わると止まってしまう — 自分で実行することも、結果を見て推論を続けることもない。\n\n手動で実行し、出力をチャットに貼り付ければ、モデルは続きを生成できる。次のコマンドが出たら、また実行して貼り付ける。\n\n毎回の往復で、あなたが中間層になっている。これを自動化するのが、この章の目的だ。\n\n---\n\n## ソリューション\n\n![Agent Loop](/course-assets/s01_agent_loop/agent-loop.ja.svg)\n\n一つの `while True` ループ — モデルがツールを呼べば続き、呼ばなければ停止。全体でたった 2 つのシグナル:\n\n| シグナル | 意味 | ループの動作 |\n|----------|------|-------------|\n| `stop_reason == \"tool_use\"` | モデルが「ツールが必要」と挙手 | 実行 → 結果を戻す → 続行 |\n| `stop_reason != \"tool_use\"` | モデルが「完了」と宣言 | ループ終了 |\n\n---\n\n## 仕組み\n\nこのプロセスをコードに変換してみよう。ステップごとに:\n\n**ステップ 1**:ユーザーの質問を最初のメッセージとして設定する。\n\n```python\nmessages = [{\"role\": \"user\", \"content\": query}]\n```\n\n**ステップ 2**:メッセージとツール定義を一緒に LLM に送信する。\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n**ステップ 3**:モデルの応答を追加し、ツールを呼び出したか確認する。呼び出しなし → 終了。\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\nif response.stop_reason != \"tool_use\":\n return\n```\n\n**ステップ 4**:モデルが要求したツールを実行し、結果を収集する。\n\n```python\nresults = []\nfor block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n**ステップ 5**:ツールの結果を新しいメッセージとして追加し、ステップ 2 に戻る。\n\n```python\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n完全な関数に組み立てる:\n\n```python\ndef agent_loop(messages):\n while True:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n messages.append({\"role\": \"user\", \"content\": results})\n```\n\n30 行未満 — これが最小実行可能な agent harness のカーネルだ。これは知能そのものではなく、モデルが継続的に行動できるための最小ランタイムフレームワーク。モデルが決定し(ツールを呼ぶか、どれを呼ぶか)、harness が実行する(呼ばれたら実行し、結果を戻す)。次の 18 章はすべてこのループの上に仕組みを積み重ねていく。ループ自体は永遠に変わらない。\n\n---\n\n## 試してみよう\n\n> **教育デモの注意**: このコードはモデルが生成したシェルコマンドを実行します。プロジェクトファイルへの影響を避けるため、一時テストディレクトリで実行してください。s03 で本格的な権限システムを説明します。\n\n**準備**(初回のみ):\n\n```sh\npip install -r requirements.txt\ncp .env.example .env\n# .env を編集し、ANTHROPIC_API_KEY と MODEL_ID を入力\n```\n\n**実行**:\n\n```sh\npython s01_agent_loop/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Create a file called hello.py that prints \"Hello, World!\"`\n2. `List all Python files in this directory`\n3. `What is the current git branch?`\n\n観察のポイント:モデルがツールを呼び出すとき(ループ継続)、呼び出さないとき(ループ終了)の違い。\n\n---\n\n## 次へ\n\n現在、モデルが持っているのは bash だけだ — ファイルを読むには `cat`、書くには `echo ... >`、探すには `find`。不便でエラーも起きやすい。\n\n→ s02 Tool Use:5 つの本格的なツールを与えたらどうなる? モデルは複数のツールを同時に呼び出すか? 並列実行で競合は起きないか?\n\n
\nCC ソースコードを深掘り\n\n> 以下は CC ソースコード `src/query.ts`(1729 行)の検証に基づく。核心的な違いは二つ:CC はループ継続の判断に `stop_reason` フィールドを頼らず、コンテンツに `tool_use` ブロックが含まれるかをチェックする(ストリーミングレスポンスでは `stop_reason` が信頼できないため)。CC には本番環境向けのより多くの終了パスとリカバリ戦略がある。\n\n**教育版の 30 行 `while True` が CC の 1729 行の核心。** 以下の各項目は、すべてその核心の上に積み重ねられた保護機構である。\n\n
\n一、ループ構造の違い\n\n教育版は `response.stop_reason` をチェックする。CC はこれをループ継続の唯一の根拠として使わない — ストリーミングレスポンスでは、`stop_reason` がまだ更新されていなくても、コンテンツに既に `tool_use` ブロックが含まれている可能性がある。CC は `needsFollowUp` フラグを使用する:ストリーミングメッセージの受信時(`query.ts:830-834`)に、`tool_use` ブロックが検出されると `true` に設定される。`QueryEngine.ts` は `message_delta` から実際の `stop_reason` を取得して他の処理に利用するが、query loop 自体は `needsFollowUp` に依存する。\n\n```typescript\n// query.ts:554-558\n// stop_reason === 'tool_use' is unreliable.\n// Set during streaming whenever a tool_use block arrives.\nlet needsFollowUp = false\n```\n\n
\n\n
\n二、State オブジェクト 10 フィールド(教育版は messages のみ使用)\n\n| # | フィールド | 用途 | 対応章 |\n|---|-----------|------|--------|\n| 1 | `messages` | 現在のイテレーションのメッセージ配列 | s01 |\n| 2 | `toolUseContext` | ツール、シグナル、権限コンテキスト | s02 |\n| 3 | `autoCompactTracking` | 圧縮状態の追跡 | s08 |\n| 4 | `maxOutputTokensRecoveryCount` | トークンリカバリ試行回数(上限 3) | s11 |\n| 5 | `hasAttemptedReactiveCompact` | 今回のラウンドでリアクティブ圧縮を試みたか | s08 |\n| 6 | `maxOutputTokensOverride` | 8K→64K へのアップグレード上書き | s11 |\n| 7 | `pendingToolUseSummary` | バックグラウンド Haiku 生成のツール使用要約 | s08 |\n| 8 | `stopHookActive` | 停止フックがブロッキングエラーを発生させたか | s04 |\n| 9 | `turnCount` | ターン数(maxTurns チェック用) | s01 |\n| 10 | `transition` | 前回の継続理由 | s11 |\n\n> 注:`taskBudgetRemaining`(`query.ts:291`)は loop-local のローカル変数であり、State には含まれない。ソースコメントには明確に \"Loop-local (not on State)\" と書かれている。\n\n
\n\n
\n三、複数の終了パスと継続パス\n\n教育版には 1 つの終了パスしかない(モデルがツールを呼ばなければ終了)。本番版には複数の終了・継続パスがあり、blocking limit、prompt too long、model error、abort、hook stop、max turns、token budget continuation、reactive compact retry など多くのシナリオをカバーしている。各シナリオには対応するリカバリまたは終了戦略がある。\n\n
\n\n
\n四、ストリーミングツール実行と QueryEngine\n\nCC の `StreamingToolExecutor`(`query.ts:561`)は、モデルがまだ生成中にツールの実行を開始できる(concurrency-safe なツールは並列、それ以外は排他実行)。`QueryEngine.ts` はさらに、コスト超過や構造化出力の検証失敗などの保護を追加する。教育版はこれらを実装しない — 目標は概念の明確さであり、極限のパフォーマンスではない。\n\n
\n\n**一言で**: query.ts の 1729 行の核心は 30 行の `while True`。複雑なフィールドや終了パスはすべて保護機構だ。まず核心のループを理解すれば、その後のすべては自然に理解できる。\n\n
\n\n\n" + "content": "# s01: Agent Loop — ループ一つで十分\n\n`s01` → [s02](/ja/s02) → s03 → s04 → ... → s20\n> *\"One loop & Bash is all you need\"* — ツール一つ + ループ一つ = 一つの Agent。\n>\n> **Harness レイヤー**: ループ — モデルと現実世界をつなぐ最初の架け橋。\n\n---\n\n## 課題\n\nモデルにこう頼んだとする:「ディレクトリ内のファイル一覧を取得して、XXX.py を実行して」。\n\nモデルは bash コマンドを出力できるが、出力が終わると止まってしまう。自分で実行することも、結果を見て推論を続けることもない。\n\n手動で実行し、出力をチャットに貼り付ければ、モデルは続きを生成できる。次のコマンドが出たら、また実行して貼り付ける。\n\n毎回の往復で、あなたが中間層になっている。これを自動化するのが、この章の目的だ。\n\n---\n\n## ソリューション\n\n![Agent Loop](/course-assets/s01_agent_loop/agent-loop.svg)\n\n一つの `while True` ループ — モデルがツールを呼べば続き、呼ばなければ停止。全体でたった 2 つのシグナル:\n\n| シグナル | 意味 | ループの動作 |\n|----------|------|-------------|\n| `stop_reason == \"tool_use\"` | モデルがツール呼び出しを要求 | 実行 → 結果を戻す → 続行 |\n| `stop_reason != \"tool_use\"` | モデルがツール呼び出しなしで生成終了 | ループ終了 |\n\n---\n\n## 仕組み\n\nこのプロセスをコードに変換してみよう。ステップごとに:\n\n**ステップ 1**:ユーザーの質問を最初のメッセージとして設定する。\n\n```python\nmessages = [{\"role\": \"user\", \"content\": query}]\n```\n\n**ステップ 2**:メッセージとツール定義を一緒に LLM に送信する。\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n**ステップ 3**:モデルの応答を追加し、ツールを呼び出したか確認する。呼び出しなし → 終了。\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\nif response.stop_reason != \"tool_use\":\n return\n```\n\n**ステップ 4**:モデルが要求したツールを実行し、結果を収集する。\n\n```python\nresults = []\nfor block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n**ステップ 5**:ツールの結果を新しいメッセージとして追加し、ステップ 2 に戻る。\n\n```python\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n完全な関数に組み立てる:\n\n```python\ndef agent_loop(messages):\n while True:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n messages.append({\"role\": \"user\", \"content\": results})\n```\n\n30 行未満、これが最小実行可能な agent harness のカーネルだ。これは知能そのものではなく、モデルが継続的に行動できるための最小ランタイムフレームワーク。モデルが決定し(ツールを呼ぶか、どれを呼ぶか)、harness が実行する(呼ばれたら実行し、結果を戻す)。次の 18 章はすべてこのループの上に仕組みを積み重ねていく。ループ自体は永遠に変わらない。\n\n---\n\n## 試してみよう\n\n> **教育デモの注意**: このコードはモデルが生成したシェルコマンドを実行します。プロジェクトファイルへの影響を避けるため、一時テストディレクトリで実行してください。s03 で本格的な権限システムを説明します。\n\n**準備**(初回のみ):\n\n```sh\npip install -r requirements.txt\ncp .env.example .env\n# .env を編集し、ANTHROPIC_API_KEY と MODEL_ID を入力\n```\n\n**実行**:\n\n```sh\npython s01_agent_loop/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Create a file called hello.py that prints \"Hello, World!\"`\n2. `List all Python files in this directory`\n3. `What is the current git branch?`\n\n観察のポイント:モデルがツールを呼び出すとき(ループ継続)、呼び出さないとき(ループ終了)の違い。\n\n---\n\n## 次へ\n\n現在、モデルが持っているのは bash だけだ — ファイルを読むには `cat`、書くには `echo ... >`、探すには `find`。不便でエラーも起きやすい。\n\n→ s02 Tool Use:5 つの本格的なツールを与えたらどうなる? モデルは複数のツールを同時に呼び出すか? 並列実行で競合は起きないか?\n\n
\nClaude Code ソースコードを深掘り\n\n> 以下は Claude Code ソースコード `src/query.ts`(1729 行)の検証に基づく。核心的な違いは二つ:Claude Code はループ継続の判断に `stop_reason` フィールドを頼らず、コンテンツに `tool_use` ブロックが含まれるかをチェックする(ストリーミングレスポンスでは `stop_reason` が信頼できないため)。Claude Code には本番環境向けのより多くの終了パスとリカバリ戦略がある。\n\n以下の各項目は、すべて教育版のループの上に積み重ねられた保護機構である。\n\n
\n一、ループ構造の違い\n\n教育版は `response.stop_reason` をチェックする。Claude Code はこれをループ継続の唯一の根拠として使わない — ストリーミングレスポンスでは、`stop_reason` がまだ更新されていなくても、コンテンツに既に `tool_use` ブロックが含まれている可能性がある。Claude Code は `needsFollowUp` フラグを使用する:ストリーミングメッセージの受信時(`query.ts:830-834`)に、`tool_use` ブロックが検出されると `true` に設定される。`QueryEngine.ts` は `message_delta` から実際の `stop_reason` を取得して他の処理に利用するが、query loop 自体は `needsFollowUp` に依存する。\n\n```typescript\n// query.ts:554-558\n// stop_reason === 'tool_use' is unreliable.\n// Set during streaming whenever a tool_use block arrives.\nlet needsFollowUp = false\n```\n\n
\n\n
\n二、State オブジェクト 10 フィールド(教育版は messages のみ使用)\n\n| # | フィールド | 用途 | 対応章 |\n|---|-----------|------|--------|\n| 1 | `messages` | 現在のイテレーションのメッセージ配列 | s01 |\n| 2 | `toolUseContext` | ツール、シグナル、権限コンテキスト | s02 |\n| 3 | `autoCompactTracking` | 圧縮状態の追跡 | s08 |\n| 4 | `maxOutputTokensRecoveryCount` | トークンリカバリ試行回数(上限 3) | s11 |\n| 5 | `hasAttemptedReactiveCompact` | 今回のラウンドでリアクティブ圧縮を試みたか | s08 |\n| 6 | `maxOutputTokensOverride` | 8K→64K へのアップグレード上書き | s11 |\n| 7 | `pendingToolUseSummary` | バックグラウンド Haiku 生成のツール使用要約 | s08 |\n| 8 | `stopHookActive` | 停止フックがブロッキングエラーを発生させたか | s04 |\n| 9 | `turnCount` | ターン数(maxTurns チェック用) | s01 |\n| 10 | `transition` | 前回の継続理由 | s11 |\n\n> 注:`taskBudgetRemaining`(`query.ts:291`)は loop-local のローカル変数であり、State には含まれない。ソースコメントには明確に \"Loop-local (not on State)\" と書かれている。\n\n
\n\n
\n三、複数の終了パスと継続パス\n\n教育版には 1 つの終了パスしかない(モデルがツールを呼ばなければ終了)。本番版には複数の終了・継続パスがあり、blocking limit、prompt too long、model error、abort、hook stop、max turns、token budget continuation、reactive compact retry など多くのシナリオをカバーしている。各シナリオには対応するリカバリまたは終了戦略がある。\n\n
\n\n
\n四、ストリーミングツール実行と QueryEngine\n\nClaude Code の `StreamingToolExecutor`(`query.ts:561`)は、モデルがまだ生成中にツールの実行を開始できる(concurrency-safe なツールは並列、それ以外は排他実行)。`QueryEngine.ts` はさらに、コスト超過や構造化出力の検証失敗などの保護を追加する。教育版はこれらを実装しない — 目標は概念の明確さであり、極限のパフォーマンスではない。\n\n
\n\n複雑なフィールドや終了パスはすべて保護機構であり、核心のループを理解していれば読み解きやすい。\n\n
\n\n\n" }, { "version": "s02", "locale": "en", - "title": "s02: Tool Use — Add a Tool, Add Just One Line", - "content": "# s02: Tool Use — Add a Tool, Add Just One Line\n\ns01 → `s02` → [s03](/en/s03) → s04 → ... → s20\n> *\"Add a tool, add just one handler\"* — The loop stays the same. Register the new tool in the dispatch map and you're done.\n>\n> **Harness Layer**: Tool Dispatch — Expanding the model's reach.\n\n---\n\n## Only One Tool: Bash\n\nThe s01 Agent has only one tool: bash. To read a file, `cat`; to write, `echo \"...\" > file.py`; to edit, `sed`.\n\nThe model thinks \"read this file\" but has to spell out `cat path/to/file`. An extra layer of translation that wastes tokens and invites errors.\n\n---\n\n## Overview: Tool Dispatch\n\n![Tool Dispatch](/course-assets/s02_tool_use/tool-dispatch.en.svg)\n\nThe s01 loop is fully preserved (LLM call, stop_reason check, message append — not a single word changed). The only change is in that one line of tool execution: `run_bash()` is replaced with `TOOL_HANDLERS[block.name]()` dispatch lookup.\n\nAdding a tool to the Agent requires just two things:\n\n1. **Define the tool**: Add one entry to the `TOOLS` array\n2. **Register the handler**: Add one mapping in the `TOOL_HANDLERS` dict\n\n---\n\n## From 1 Tool to 5 Tools\n\ns01 had only bash:\n\n```python\nTOOLS = [{\"name\": \"bash\", ...}]\n\ndef run_bash(command): ...\n```\n\ns02 expands to 5 tools, each independently defined:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\", ...},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\", ...},\n {\"name\": \"write_file\", \"description\": \"Write content to file.\", ...},\n {\"name\": \"edit_file\", \"description\": \"Replace text in file once.\", ...},\n {\"name\": \"glob\", \"description\": \"Find files by pattern.\", ...},\n]\n```\n\nEach tool has its own implementation function:\n\n```python\ndef run_read(path, limit=None):\n lines = safe_path(path).read_text().splitlines()\n if limit:\n lines = lines[:limit]\n return \"\\n\".join(lines)\n\ndef run_write(path, content):\n safe_path(path).write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n\ndef run_edit(path, old_text, new_text):\n text = safe_path(path).read_text()\n if old_text not in text:\n return \"Error: text not found\"\n safe_path(path).write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n\ndef run_glob(pattern):\n import glob as g\n return \"\\n\".join(g.glob(pattern, root_dir=WORKDIR))\n```\n\n---\n\n## Tool Dispatch\n\n```python\nTOOL_HANDLERS = {\n \"bash\": run_bash,\n \"read_file\": run_read,\n \"write_file\": run_write,\n \"edit_file\": run_edit,\n \"glob\": run_glob,\n}\n\n# Only one line changed in the loop — from hardcoded run_bash to dispatch lookup:\nfor block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS[block.name] # lookup\n output = handler(**block.input) # call\n results.append(...)\n```\n\nAdding a tool = one entry in `TOOLS` array + one line in `TOOL_HANDLERS` dict. The loop stays the same.\n\n---\n\n## Multiple Tool Calls\n\nThe model often returns multiple tool_use calls at once — \"read a.py and b.py, then list all .py files\".\n\nThe teaching version executes them one by one in the original `response.content` order. CC's approach is more complex: it slices the original order into consecutive batches, where concurrency-safe tools within a batch run in parallel, and batches are strictly sequential (see appendix).\n\n---\n\n## Quick Reference\n\n| Concept | One-Liner |\n|---------|-----------|\n| TOOL_HANDLERS | Tool name → handler function dict. Add a tool = add one mapping line |\n| Tool Definition | JSON schema telling the model \"what I can do\" |\n| Multiple tool calls | Model may return multiple tool_use at once; teaching version executes them in original order |\n| Loop Unchanged | s01's `while True` loop — not a single line changed |\n\n---\n\n## Changes from s01\n\n| Component | Before (s01) | After (s02) |\n|-----------|-------------|-------------|\n| Tool count | 1 (bash) | 5 (+read, write, edit, glob) |\n| Tool execution | Hardcoded `run_bash()` | TOOL_HANDLERS dispatch lookup |\n| Path safety | None | safe_path validation (file tools only) |\n| Loop | `while True` + `stop_reason` | Identical to s01 |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s02_tool_use/code.py\n```\n\nTry these prompts:\n\n1. `Read the file README.md and tell me what this project is about`\n2. `Create a file called test.py that prints \"hello\", then read it back`\n3. `Find all Python files in this directory`\n4. `Read both README.md and requirements.txt, then create a summary file`\n\nWhat to watch for: When does the model call just one tool, and when does it call multiple at once? Are multiple tool calls executed in the correct order?\n\n---\n\n## What's Next\n\nThe Agent now has 5 specialized tools. File tools are protected by `safe_path`, but bash is unrestricted — `rm -rf /` still runs.\n\n→ s03 Permission: Add a gate before tool execution — is this operation safe? Does it need user approval?\n\n
\nDive into CC Source Code\n\n> The following is based on a review of CC source code `Tool.ts`, `tools.ts`, `toolOrchestration.ts`, `toolExecution.ts`, and `StreamingToolExecutor.ts`.\n\n### 1. Tool Definition Approach\n\n**Teaching version**: `TOOLS` array + `TOOL_HANDLERS` dict. Definition and implementation are separate.\n**CC**: Each tool is an independent object created by `buildTool()`, containing schema, validation, permissions, and execution. `getAllBaseTools()` aggregates all tools.\n\nThe teaching version's separation is clearer for teaching — readers immediately see \"add a tool = two definitions\".\n\n### 2. Concurrency Safety: isConcurrencySafe()\n\n![Tool Concurrency](/course-assets/s02_tool_use/concurrency-comparison.en.svg)\n\nThe teaching version executes tools one by one in original order, without concurrency. CC uses `isConcurrencySafe(input)` to determine concurrency — note this isn't simply \"read-only vs write\", but judges by specific input:\n\n| | isReadOnly | isConcurrencySafe |\n|---|---|---|\n| FileRead | true | true |\n| Glob | true | true |\n| Bash `ls` | true | **true** ← key difference |\n| Bash `rm` | false | false |\n| TaskCreate | false | **true** ← modifies state but can be concurrent (introduced in s12) |\n\nCC's Bash tool's `isConcurrencySafe` equals `isReadOnly` — read-only commands can be concurrent, write commands cannot. TaskCreate modifies task files, but each writes a different file, so it can be concurrent.\n\n### 3. Partition Algorithm\n\nCC's `partitionToolCalls()` (`toolOrchestration.ts:91-115`) doesn't split into two groups — it batches tool calls **by consecutive blocks**:\n\n```\n[read A, read B, glob *.py, bash \"rm x\", read C]\n → batch1(concurrent): [read A, read B, glob *.py]\n → batch2(serial): [bash \"rm x\"]\n → batch3(concurrent): [read C]\n```\n\nConsecutive concurrency-safe calls are grouped into the same batch for truly concurrent execution (`toolOrchestration.ts:152-176`, with a concurrency limit). When a non-concurrency-safe call is encountered, a new batch starts for serial execution. Batches are strictly sequential.\n\n### 4. Validation Pipeline\n\nEach tool call in CC goes through a strict 5-step validation (`toolExecution.ts`):\n\n1. **Zod schema validation** (`614-680`, teaching version uses JSON Schema): parameter type/structure check\n2. **Tool-level validateInput()** (`682-733`): parameter value validation (e.g., is the path within the working directory)\n3. **PreToolUse hooks** (`800-862`, covered in s04): hooks can return messages, modify input, or block execution\n4. **Permission check** (`921-931`, core topic of s03): canUseTool + checkPermissions → allow/deny/ask\n5. **Execute tool.call()** (`1207-1222`)\n\nThe teaching version omits Zod (uses JSON Schema), omits validateInput (uses safety functions), but preserves the permission check and hook concepts.\n\n### 5. Streaming Tool Execution\n\nCC's `StreamingToolExecutor` (`StreamingToolExecutor.ts`) starts tools while the model is still generating — no waiting for the model to finish. `read_file` might complete while the model is still outputting \"Let me analyze\". The teaching version doesn't implement this, consistent with s01's goal — conceptual clarity, not peak performance.\n\n### 6. Tool Result Persistence\n\nEach tool has a `maxResultSizeChars` field. Results exceeding this threshold are persisted to disk, and the model sees a preview + file path. FileRead is special — set to `Infinity`, preventing file read output from being persisted again. Specifically, if FileRead's result exceeds the threshold and gets persisted, the model's next read of that persisted file would trigger another persistence → infinite loop (read file → persist → re-read → re-persist → ...).\n\n
\n\n\n" + "title": "s02: Tool Use — Each New Tool, Just One Line", + "content": "# s02: Tool Use — Each New Tool, Just One Line\n\ns01 → `s02` → [s03](/en/s03) → s04 → ... → s20\n> *\"Each new tool, just one handler\"* — The loop stays the same. Register the new tool in the dispatch map and you're done.\n>\n> **Harness Layer**: Tool Dispatch — Route each tool_use to its handler function by name.\n\n---\n\n## Only One Tool: Bash\n\nThe s01 Agent has only one tool: bash. To read a file, `cat`; to write, `echo \"...\" > file.py`; to edit, `sed`.\n\nThe model needs to \"read this file\" but has to spell out `cat path/to/file`. An extra layer of translation that wastes tokens and invites errors.\n\n---\n\n## Overview: Tool Dispatch\n\n![Tool Dispatch](/course-assets/s02_tool_use/tool-dispatch.svg)\n\nThe s01 loop is fully preserved (LLM call, stop_reason check, message append). The only change is in that one line of tool execution: `run_bash()` is replaced with `TOOL_HANDLERS[block.name]()` dispatch lookup.\n\nAdding a tool to the Agent requires just two things:\n\n1. **Define the tool**: Add one entry to the `TOOLS` array\n2. **Register the handler**: Add one mapping in the `TOOL_HANDLERS` dict\n\n---\n\n## From 1 Tool to 5 Tools\n\ns01 had only bash:\n\n```python\nTOOLS = [{\"name\": \"bash\", ...}]\n\ndef run_bash(command): ...\n```\n\ns02 expands to 5 tools, each independently defined:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\", ...},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\", ...},\n {\"name\": \"write_file\", \"description\": \"Write content to file.\", ...},\n {\"name\": \"edit_file\", \"description\": \"Replace text in file once.\", ...},\n {\"name\": \"glob\", \"description\": \"Find files by pattern.\", ...},\n]\n```\n\nEach tool has its own implementation function:\n\n```python\ndef run_read(path, limit=None):\n lines = safe_path(path).read_text().splitlines()\n if limit:\n lines = lines[:limit]\n return \"\\n\".join(lines)\n\ndef run_write(path, content):\n safe_path(path).write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n\ndef run_edit(path, old_text, new_text):\n text = safe_path(path).read_text()\n if old_text not in text:\n return \"Error: text not found\"\n safe_path(path).write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n\ndef run_glob(pattern):\n import glob as g\n return \"\\n\".join(g.glob(pattern, root_dir=WORKDIR))\n```\n\n---\n\n## Tool Dispatch\n\n```python\nTOOL_HANDLERS = {\n \"bash\": run_bash,\n \"read_file\": run_read,\n \"write_file\": run_write,\n \"edit_file\": run_edit,\n \"glob\": run_glob,\n}\n\n# Only one line changed in the loop — from hardcoded run_bash to dispatch lookup:\nfor block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS[block.name] # lookup\n output = handler(**block.input) # call\n results.append(...)\n```\n\n---\n\n## Multiple Tool Calls\n\nThe model often returns multiple tool_use calls at once — \"read a.py and b.py, then list all .py files\".\n\nThe teaching version executes them one by one in the original `response.content` order. Claude Code's approach is more complex: it slices the original order into consecutive batches, where concurrency-safe tools within a batch run in parallel, and batches are strictly sequential (see appendix).\n\n---\n\n## Quick Reference\n\n| Concept | One-Liner |\n|---------|-----------|\n| TOOL_HANDLERS | Tool name → handler function dict. The loop dispatches via `block.name` lookup |\n| Tool Definition | JSON schema telling the model \"what I can do\" |\n| Multiple tool calls | Model may return multiple tool_use at once; teaching version executes them in original order |\n| Loop | Identical to s01, no changes |\n\n---\n\n## Changes from s01\n\n| Component | Before (s01) | After (s02) |\n|-----------|-------------|-------------|\n| Tool count | 1 (bash) | 5 (+read, write, edit, glob) |\n| Tool execution | Hardcoded `run_bash()` | TOOL_HANDLERS dispatch lookup |\n| Path safety | None | safe_path validation (file tools only) |\n| Loop | `while True` + `stop_reason` | Identical to s01 |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s02_tool_use/code.py\n```\n\nTry these prompts:\n\n1. `Read the file README.md and tell me what this project is about`\n2. `Create a file called test.py that prints \"hello\", then read it back`\n3. `Find all Python files in this directory`\n4. `Read both README.md and requirements.txt, then create a summary file`\n\nWhat to watch for: When does the model call just one tool, and when does it call multiple at once? Are multiple tool calls executed in the correct order?\n\n---\n\n## What's Next\n\nThe Agent now has 5 specialized tools. File tools are protected by `safe_path`, but bash is unrestricted — `rm -rf /` still runs.\n\n→ s03 Permission: Add a gate before tool execution. Is this operation safe? Does it need user approval?\n\n
\nDive into Claude Code Source Code\n\n> The following is based on a review of Claude Code source code `Tool.ts`, `tools.ts`, `toolOrchestration.ts`, `toolExecution.ts`, and `StreamingToolExecutor.ts`.\n\n### 1. Tool Definition Approach\n\n**Teaching version**: `TOOLS` array + `TOOL_HANDLERS` dict. Definition and implementation are separate.\n**Claude Code**: Each tool is an independent object created by `buildTool()`, containing schema, validation, permissions, and execution. `getAllBaseTools()` aggregates all tools.\n\nThe teaching version's separation is clearer for teaching — readers immediately see \"add a tool = two definitions\".\n\n### 2. Concurrency Safety: isConcurrencySafe()\n\nThe teaching version executes tools one by one in original order, without concurrency. Claude Code uses `isConcurrencySafe(input)` to determine concurrency — note this isn't simply \"read-only vs write\", but judges by specific input:\n\n| | isReadOnly | isConcurrencySafe |\n|---|---|---|\n| FileRead | true | true |\n| Glob | true | true |\n| Bash `ls` | true | **true** ← key difference |\n| Bash `rm` | false | false |\n| TaskCreate | false | **true** ← modifies state but can be concurrent (introduced in s12) |\n\nClaude Code's Bash tool's `isConcurrencySafe` equals `isReadOnly` — read-only commands can be concurrent, write commands cannot. TaskCreate modifies task files, but each writes a different file, so it can be concurrent.\n\n### 3. Partition Algorithm\n\nClaude Code's `partitionToolCalls()` (`toolOrchestration.ts:91-115`) doesn't split into two groups — it batches tool calls **by consecutive blocks**:\n\n```\n[read A, read B, glob *.py, bash \"rm x\", read C]\n → batch1(concurrent): [read A, read B, glob *.py]\n → batch2(serial): [bash \"rm x\"]\n → batch3(concurrent): [read C]\n```\n\nConsecutive concurrency-safe calls are grouped into the same batch for truly concurrent execution (`toolOrchestration.ts:152-176`, with a concurrency limit). When a non-concurrency-safe call is encountered, a new batch starts for serial execution. Batches are strictly sequential.\n\n### 4. Validation Pipeline\n\nEach tool call in Claude Code goes through a strict 5-step validation (`toolExecution.ts`):\n\n1. **Zod schema validation** (`614-680`, teaching version uses JSON Schema): parameter type/structure check\n2. **Tool-level validateInput()** (`682-733`): parameter value validation (e.g., is the path within the working directory)\n3. **PreToolUse hooks** (`800-862`, covered in s04): hooks can return messages, modify input, or block execution\n4. **Permission check** (`921-931`, core topic of s03): canUseTool + checkPermissions → allow/deny/ask\n5. **Execute tool.call()** (`1207-1222`)\n\nThe teaching version omits Zod (uses JSON Schema), omits validateInput (uses safety functions), but preserves the permission check and hook concepts.\n\n### 5. Streaming Tool Execution\n\nClaude Code's `StreamingToolExecutor` (`StreamingToolExecutor.ts`) starts tools while the model is still generating — no waiting for the model to finish. `read_file` might complete while the model is still outputting \"Let me analyze\". The teaching version doesn't implement this, consistent with s01's goal — conceptual clarity, not peak performance.\n\n### 6. Tool Result Persistence\n\nEach tool has a `maxResultSizeChars` field. Results exceeding this threshold are persisted to disk, and the model sees a preview + file path. FileRead is special — set to `Infinity`, preventing file read output from being persisted again. Specifically, if FileRead's result exceeds the threshold and gets persisted, the model's next read of that persisted file would trigger another persistence → infinite loop (read file → persist → re-read → re-persist → ...).\n\n
\n\n\n" }, { "version": "s02", "locale": "zh", - "title": "s02: Tool Use — 多加一个工具,只加一行", - "content": "# s02: Tool Use — 多加一个工具,只加一行\n\ns01 → `s02` → [s03](/zh/s03) → s04 → ... → s20\n> *\"加一个工具, 只加一个 handler\"* — 循环不用动, 新工具注册进 dispatch map 就行。\n>\n> **Harness 层**: 工具分发 — 扩展模型能触达的边界。\n\n---\n\n## 只有 bash 一个工具\n\ns01 的 Agent 只有一个 bash 工具。读文件要 `cat`,写文件要 `echo \"...\" > file.py`,改文件要 `sed`。\n\n模型想的是\"读这个文件\",却要拼出 `cat path/to/file`。多了一层翻译,浪费 token,还容易拼错。\n\n---\n\n## 全局视角:工具分发\n\n![Tool Dispatch](/course-assets/s02_tool_use/tool-dispatch.svg)\n\ns01 的循环完全保留(LLM 调用、stop_reason 判断、消息追加)。唯一的变动在工具执行那 1 行:`run_bash()` 替换为 `TOOL_HANDLERS[block.name]()` 查表分发。\n\n给 Agent 加一个工具只需要做两件事:\n\n1. **定义工具**:在 `TOOLS` 数组里加一条描述\n2. **注册处理函数**:在 `TOOL_HANDLERS` 字典里加一个映射\n\n---\n\n## 从 1 个工具到 5 个工具\n\ns01 只有一个 bash:\n\n```python\nTOOLS = [{\"name\": \"bash\", ...}]\n\ndef run_bash(command): ...\n```\n\ns02 加到 5 个,每个工具都是独立定义:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\", ...},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\", ...},\n {\"name\": \"write_file\", \"description\": \"Write content to file.\", ...},\n {\"name\": \"edit_file\", \"description\": \"Replace text in file once.\", ...},\n {\"name\": \"glob\", \"description\": \"Find files by pattern.\", ...},\n]\n```\n\n每个工具有自己的实现函数:\n\n```python\ndef run_read(path, limit=None):\n lines = safe_path(path).read_text().splitlines()\n if limit:\n lines = lines[:limit]\n return \"\\n\".join(lines)\n\ndef run_write(path, content):\n safe_path(path).write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n\ndef run_edit(path, old_text, new_text):\n text = safe_path(path).read_text()\n if old_text not in text:\n return \"Error: text not found\"\n safe_path(path).write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n\ndef run_glob(pattern):\n import glob as g\n return \"\\n\".join(g.glob(pattern, root_dir=WORKDIR))\n```\n\n---\n\n## 工具分发\n\n```python\nTOOL_HANDLERS = {\n \"bash\": run_bash,\n \"read_file\": run_read,\n \"write_file\": run_write,\n \"edit_file\": run_edit,\n \"glob\": run_glob,\n}\n\n# 循环里只改了一行——从硬编码 run_bash 变成查表:\nfor block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS[block.name] # 查表\n output = handler(**block.input) # 调用\n results.append(...)\n```\n\n加一个工具 = 在 `TOOLS` 数组加一条 + 在 `TOOL_HANDLERS` 字典加一行。循环不变。\n\n---\n\n## 多个工具调用\n\n模型经常一次返回多个 tool_use:\"读一下 a.py 和 b.py,然后列出所有 .py 文件\"。\n\n教学版按 `response.content` 原始顺序逐个执行。CC 的做法更复杂:按原始顺序切成连续 batch,batch 内并发安全的工具并行执行,batch 间严格顺序(见附录)。\n\n---\n\n## 速查\n\n| 概念 | 一句话 |\n|------|--------|\n| TOOL_HANDLERS | 工具名 → 处理函数的字典。加工具 = 加一行映射 |\n| 工具定义 | 告诉模型\"我能做什么\"的 JSON schema |\n| 多工具调用 | 模型可一次返回多个 tool_use,教学版按原始顺序逐个执行 |\n| 循环不变 | s01 的 `while True` 循环一行都没改 |\n\n---\n\n## 相对 s01 的变更\n\n| 组件 | 之前 (s01) | 之后 (s02) |\n|------|-----------|-----------|\n| 工具数量 | 1 (bash) | 5 (+read, write, edit, glob) |\n| 工具执行 | 硬编码 `run_bash()` | TOOL_HANDLERS 查表分发 |\n| 路径安全 | 无 | safe_path 校验(仅 file tools) |\n| 循环 | `while True` + `stop_reason` | 与 s01 完全一致 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s02_tool_use/code.py\n```\n\n试试这些 prompt:\n\n1. `Read the file README.md and tell me what this project is about`\n2. `Create a file called test.py that prints \"hello\", then read it back`\n3. `Find all Python files in this directory`\n4. `Read both README.md and requirements.txt, then create a summary file`\n\n观察重点:模型什么时候只调一个工具,什么时候一次调多个?多个工具调用的顺序和结果是否正确?\n\n---\n\n## 接下来\n\n现在 Agent 有 5 个专用工具。file tools 受 `safe_path` 保护,但 bash 不受限制,`rm -rf /` 还是能跑。\n\ns03 Permission → 在工具执行之前加一道门:这个操作安全吗?需要用户批准吗?\n\n
\n深入 CC 源码\n\n> 以下基于 CC 源码 `Tool.ts`、`tools.ts`、`toolOrchestration.ts`、`toolExecution.ts`、`StreamingToolExecutor.ts` 的核查。\n\n### 一、工具定义方式\n\n**教学版**:`TOOLS` 数组 + `TOOL_HANDLERS` 字典。定义和实现分开。\n**CC**:每个工具是 `buildTool()` 创建的独立对象,包含 schema、验证、权限、执行。`getAllBaseTools()` 汇总所有工具。\n\n教学版的分离方式对教学更清晰——读者一眼看到\"加一个工具 = 两条定义\"。\n\n### 二、并发安全判断:isConcurrencySafe()\n\n![Tool Concurrency](/course-assets/s02_tool_use/concurrency-comparison.svg)\n\n教学版按原始顺序逐个执行,不做并发。CC 用 `isConcurrencySafe(input)` 判断能否并发——注意这不是简单的\"只读 vs 写\",而是按具体输入判断:\n\n| | isReadOnly | isConcurrencySafe |\n|---|---|---|\n| FileRead | true | true |\n| Glob | true | true |\n| Bash `ls` | true | **true** ← 关键差异 |\n| Bash `rm` | false | false |\n| TaskCreate | false | **true** ← 改状态但可并发(TaskCreate 在 s12 介绍) |\n\nCC 的 Bash tool 的 `isConcurrencySafe` 等于 `isReadOnly`——只读命令可并发,写命令不可。TaskCreate 虽然改了任务文件,但每次都写不同的文件,所以可以并发。\n\n### 三、分区算法\n\nCC 的 `partitionToolCalls()`(`toolOrchestration.ts:91-115`)不是分两组,而是把工具调用**按连续块分批**:\n\n```\n[read A, read B, glob *.py, bash \"rm x\", read C]\n → batch1(并发): [read A, read B, glob *.py]\n → batch2(串行): [bash \"rm x\"]\n → batch3(并发): [read C]\n```\n\n并发安全的连续块编入同一个 batch,batch 内真正并发执行(`toolOrchestration.ts:152-176`,有并发上限)。遇到非并发安全的就开新 batch 串行执行。batch 之间严格顺序。\n\n### 四、验证管线\n\nCC 的每个工具调用经过严格的 5 步验证(`toolExecution.ts`):\n\n1. **Zod schema 验证**(`614-680`,教学版用 JSON Schema 替代):参数类型/结构检查\n2. **工具级 validateInput()**(`682-733`):参数值验证(如路径是否在工作区内)\n3. **PreToolUse hooks**(`800-862`,s04 详细介绍):钩子可以返回消息、修改输入、阻止执行\n4. **权限检查**(`921-931`,s03 的核心内容):canUseTool + checkPermissions → allow/deny/ask\n5. **执行 tool.call()**(`1207-1222`)\n\n教学版省略了 Zod(用 JSON Schema)、省略了 validateInput(用安全函数)、保留了权限检查和钩子概念。\n\n### 五、流式工具执行\n\nCC 的 `StreamingToolExecutor`(`StreamingToolExecutor.ts`)让工具在模型还在生成时就启动——不等模型说完。`read_file` 可能在模型还在输出\"我来分析\"的时候就跑完了。教学版不实现这个,目标和 s01 一致——概念清晰,不追求性能极致。\n\n### 六、工具结果持久化\n\n每个工具有一个 `maxResultSizeChars` 字段。结果超过这个值就落盘,模型看到的是预览 + 文件路径。FileRead 特殊——设为 `Infinity`,防止读文件的输出又被当成文件落盘。具体来说,如果 FileRead 的结果超过阈值被落盘,模型下次读那个落盘文件时又会触发落盘 → 无限循环(读文件 → 落盘 → 再读 → 再落盘 → ...)。\n\n
\n\n\n" + "title": "s02: Tool Use — 每加一个工具,只加一行", + "content": "# s02: Tool Use — 每加一个工具,只加一行\n\ns01 → `s02` → [s03](/zh/s03) → s04 → ... → s20\n> *\"每加一个工具,只加一个 handler\"* — 循环不用动,新工具注册进 dispatch map 就行。\n>\n> **Harness 层**: 工具分发 — 根据工具名查表调用对应处理函数。\n\n---\n\n## 只有 bash 一个工具\n\ns01 的 Agent 只有一个 bash 工具。读文件要 `cat`,写文件要 `echo \"...\" > file.py`,改文件要 `sed`。\n\n模型收到的指令是\"读这个文件\",却要拼出 `cat path/to/file`。多了一层翻译,浪费 token,还容易拼错。\n\n---\n\n## 全局视角:工具分发\n\n![Tool Dispatch](/course-assets/s02_tool_use/tool-dispatch.svg)\n\ns01 的循环完全保留(LLM 调用、stop_reason 判断、消息追加)。唯一的变动在工具执行那 1 行:`run_bash()` 替换为 `TOOL_HANDLERS[block.name]()` 查表分发。\n\n给 Agent 加一个工具只需要做两件事:\n\n1. **定义工具**:在 `TOOLS` 数组里加一条描述\n2. **注册处理函数**:在 `TOOL_HANDLERS` 字典里加一个映射\n\n---\n\n## 从 1 个工具到 5 个工具\n\ns01 只有一个 bash:\n\n```python\nTOOLS = [{\"name\": \"bash\", ...}]\n\ndef run_bash(command): ...\n```\n\ns02 加到 5 个,每个工具都是独立定义:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\", ...},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\", ...},\n {\"name\": \"write_file\", \"description\": \"Write content to file.\", ...},\n {\"name\": \"edit_file\", \"description\": \"Replace text in file once.\", ...},\n {\"name\": \"glob\", \"description\": \"Find files by pattern.\", ...},\n]\n```\n\n每个工具有自己的实现函数:\n\n```python\ndef run_read(path, limit=None):\n lines = safe_path(path).read_text().splitlines()\n if limit:\n lines = lines[:limit]\n return \"\\n\".join(lines)\n\ndef run_write(path, content):\n safe_path(path).write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n\ndef run_edit(path, old_text, new_text):\n text = safe_path(path).read_text()\n if old_text not in text:\n return \"Error: text not found\"\n safe_path(path).write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n\ndef run_glob(pattern):\n import glob as g\n return \"\\n\".join(g.glob(pattern, root_dir=WORKDIR))\n```\n\n---\n\n## 工具分发\n\n```python\nTOOL_HANDLERS = {\n \"bash\": run_bash,\n \"read_file\": run_read,\n \"write_file\": run_write,\n \"edit_file\": run_edit,\n \"glob\": run_glob,\n}\n\n# 循环里只改了一行——从硬编码 run_bash 变成查表:\nfor block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS[block.name] # 查表\n output = handler(**block.input) # 调用\n results.append(...)\n```\n\n---\n\n## 多个工具调用\n\n模型经常一次返回多个 tool_use:\"读一下 a.py 和 b.py,然后列出所有 .py 文件\"。\n\n教学版按 `response.content` 原始顺序逐个执行。Claude Code 的做法更复杂:按原始顺序切成连续 batch,batch 内并发安全的工具并行执行,batch 间严格顺序(见附录)。\n\n---\n\n## 速查\n\n| 概念 | 一句话 |\n|------|--------|\n| TOOL_HANDLERS | 工具名 → 处理函数的字典,循环通过 block.name 查表调用 |\n| 工具定义 | 告诉模型\"我能做什么\"的 JSON schema |\n| 多工具调用 | 模型可一次返回多个 tool_use,教学版按原始顺序逐个执行 |\n| 循环 | 与 s01 完全一致,无改动 |\n\n---\n\n## 相对 s01 的变更\n\n| 组件 | 之前 (s01) | 之后 (s02) |\n|------|-----------|-----------|\n| 工具数量 | 1 (bash) | 5 (+read, write, edit, glob) |\n| 工具执行 | 硬编码 `run_bash()` | TOOL_HANDLERS 查表分发 |\n| 路径安全 | 无 | safe_path 校验(仅 file tools) |\n| 循环 | `while True` + `stop_reason` | 与 s01 完全一致 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s02_tool_use/code.py\n```\n\n试试这些 prompt:\n\n1. `Read the file README.md and tell me what this project is about`\n2. `Create a file called test.py that prints \"hello\", then read it back`\n3. `Find all Python files in this directory`\n4. `Read both README.md and requirements.txt, then create a summary file`\n\n观察重点:模型什么时候只调一个工具,什么时候一次调多个?多个工具调用的顺序和结果是否正确?\n\n---\n\n## 接下来\n\n现在 Agent 有 5 个专用工具。file tools 受 `safe_path` 保护,但 bash 不受限制,`rm -rf /` 还是能跑。\n\ns03 Permission → 在工具执行之前加一道门:这个操作安全吗?需要用户批准吗?\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `Tool.ts`、`tools.ts`、`toolOrchestration.ts`、`toolExecution.ts`、`StreamingToolExecutor.ts` 的核查。\n\n### 一、工具定义方式\n\n**教学版**:`TOOLS` 数组 + `TOOL_HANDLERS` 字典。定义和实现分开。\n**Claude Code**:每个工具是 `buildTool()` 创建的独立对象,包含 schema、验证、权限、执行。`getAllBaseTools()` 汇总所有工具。\n\n教学版的分离方式对教学更清晰——读者一眼看到\"加一个工具 = 两条定义\"。\n\n### 二、并发安全判断:isConcurrencySafe()\n\n教学版按原始顺序逐个执行,不做并发。Claude Code 用 `isConcurrencySafe(input)` 判断能否并发——注意这不是简单的\"只读 vs 写\",而是按具体输入判断:\n\n| | isReadOnly | isConcurrencySafe |\n|---|---|---|\n| FileRead | true | true |\n| Glob | true | true |\n| Bash `ls` | true | **true** ← 关键差异 |\n| Bash `rm` | false | false |\n| TaskCreate | false | **true** ← 改状态但可并发(TaskCreate 在 s12 介绍) |\n\nClaude Code 的 Bash tool 的 `isConcurrencySafe` 等于 `isReadOnly`——只读命令可并发,写命令不可。TaskCreate 虽然改了任务文件,但每次都写不同的文件,所以可以并发。\n\n### 三、分区算法\n\nClaude Code 的 `partitionToolCalls()`(`toolOrchestration.ts:91-115`)不是分两组,而是把工具调用**按连续块分批**:\n\n```\n[read A, read B, glob *.py, bash \"rm x\", read C]\n → batch1(并发): [read A, read B, glob *.py]\n → batch2(串行): [bash \"rm x\"]\n → batch3(并发): [read C]\n```\n\n并发安全的连续块编入同一个 batch,batch 内真正并发执行(`toolOrchestration.ts:152-176`,有并发上限)。遇到非并发安全的就开新 batch 串行执行。batch 之间严格顺序。\n\n### 四、验证管线\n\nClaude Code 的每个工具调用经过严格的 5 步验证(`toolExecution.ts`):\n\n1. **Zod schema 验证**(`614-680`,教学版用 JSON Schema 替代):参数类型/结构检查\n2. **工具级 validateInput()**(`682-733`):参数值验证(如路径是否在工作区内)\n3. **PreToolUse hooks**(`800-862`,s04 详细介绍):钩子可以返回消息、修改输入、阻止执行\n4. **权限检查**(`921-931`,s03 的核心内容):canUseTool + checkPermissions → allow/deny/ask\n5. **执行 tool.call()**(`1207-1222`)\n\n教学版省略了 Zod(用 JSON Schema)、省略了 validateInput(用安全函数)、保留了权限检查和钩子概念。\n\n### 五、流式工具执行\n\nClaude Code 的 `StreamingToolExecutor`(`StreamingToolExecutor.ts`)让工具在模型还在生成时就启动——不等模型说完。`read_file` 可能在模型还在输出\"我来分析\"的时候就跑完了。教学版不实现这个,目标和 s01 一致——概念清晰,不追求性能极致。\n\n### 六、工具结果持久化\n\n每个工具有一个 `maxResultSizeChars` 字段。结果超过这个值就落盘,模型看到的是预览 + 文件路径。FileRead 特殊——设为 `Infinity`,防止读文件的输出又被当成文件落盘。具体来说,如果 FileRead 的结果超过阈值被落盘,模型下次读那个落盘文件时又会触发落盘 → 无限循环(读文件 → 落盘 → 再读 → 再落盘 → ...)。\n\n
\n\n\n" }, { "version": "s02", "locale": "ja", - "title": "s02: Tool Use — ツール一つ追加、一行追加だけ", - "content": "# s02: Tool Use — ツール一つ追加、一行追加だけ\n\ns01 → `s02` → [s03](/ja/s03) → s04 → ... → s20\n> *\"ツールを一つ追加、ハンドラを一つ追加\"* — ループはそのまま。新しいツールをディスパッチマップに登録するだけ。\n>\n> **Harness レイヤー**: ツールディスパッチ — モデルが触れる範囲を拡張。\n\n---\n\n## ツールは bash 一つだけ\n\ns01 の Agent には bash 一つのツールしかない。ファイルを読むには `cat`、書くには `echo \"...\" > file.py`、編集するには `sed`。\n\nモデルは「このファイルを読みたい」と考えながら、`cat path/to/file` と組み立てなければならない。翻訳の層が一つ増え、トークンを無駄にし、エラーも起きやすい。\n\n---\n\n## 概要:ツールディスパッチ\n\n![Tool Dispatch](/course-assets/s02_tool_use/tool-dispatch.ja.svg)\n\ns01 のループは完全に保持される(LLM 呼び出し、stop_reason 判定、メッセージ追加 — 一文字も変更なし)。唯一の変更点はツール実行の 1 行:`run_bash()` が `TOOL_HANDLERS[block.name]()` の検索ディスパッチに置き換わる。\n\nAgent にツールを追加するには、たった二つ:\n\n1. **ツールを定義**:`TOOLS` 配列に一条を追加\n2. **ハンドラを登録**:`TOOL_HANDLERS` 辞書に一つのマッピングを追加\n\n---\n\n## 1 つのツールから 5 つのツールへ\n\ns01 には bash だけだった:\n\n```python\nTOOLS = [{\"name\": \"bash\", ...}]\n\ndef run_bash(command): ...\n```\n\ns02 では 5 つに増え、各ツールは独立して定義される:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\", ...},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\", ...},\n {\"name\": \"write_file\", \"description\": \"Write content to file.\", ...},\n {\"name\": \"edit_file\", \"description\": \"Replace text in file once.\", ...},\n {\"name\": \"glob\", \"description\": \"Find files by pattern.\", ...},\n]\n```\n\n各ツールには専用の実装関数がある:\n\n```python\ndef run_read(path, limit=None):\n lines = safe_path(path).read_text().splitlines()\n if limit:\n lines = lines[:limit]\n return \"\\n\".join(lines)\n\ndef run_write(path, content):\n safe_path(path).write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n\ndef run_edit(path, old_text, new_text):\n text = safe_path(path).read_text()\n if old_text not in text:\n return \"Error: text not found\"\n safe_path(path).write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n\ndef run_glob(pattern):\n import glob as g\n return \"\\n\".join(g.glob(pattern, root_dir=WORKDIR))\n```\n\n---\n\n## ツールディスパッチ\n\n```python\nTOOL_HANDLERS = {\n \"bash\": run_bash,\n \"read_file\": run_read,\n \"write_file\": run_write,\n \"edit_file\": run_edit,\n \"glob\": run_glob,\n}\n\n# ループ内で変更されたのは一行だけ — ハードコードの run_bash から検索ディスパッチへ:\nfor block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS[block.name] # 検索\n output = handler(**block.input) # 呼び出し\n results.append(...)\n```\n\nツールの追加 = `TOOLS` 配列に一条 + `TOOL_HANDLERS` 辞書に一行。ループは変わらない。\n\n---\n\n## 複数のツール呼び出し\n\nモデルはよく一度に複数の tool_use を返す — 「a.py と b.py を読んで、全 .py ファイルを列挙して」。\n\n教育版は `response.content` の元の順序で一つずつ実行する。CC のやり方はより複雑:元の順序を保ったまま連続バッチに分割し、バッチ内の並列安全なツールを並行実行し、バッチ間は厳密に順次(付録を参照)。\n\n---\n\n## 速查\n\n| 概念 | 一言で |\n|------|--------|\n| TOOL_HANDLERS | ツール名 → ハンドラ関数の辞書。ツール追加 = マッピング一行追加 |\n| ツール定義 | モデルに「何ができるか」を伝える JSON schema |\n| 複数ツール呼び出し | モデルは一度に複数の tool_use を返す可能性がある。教育版は元の順序で一つずつ実行 |\n| ループ不変 | s01 の `while True` ループ — 一行も変更なし |\n\n---\n\n## s01 からの変更\n\n| コンポーネント | 変更前 (s01) | 変更後 (s02) |\n|--------------|-------------|-------------|\n| ツール数 | 1 (bash) | 5 (+read, write, edit, glob) |\n| ツール実行 | ハードコード `run_bash()` | TOOL_HANDLERS 検索ディスパッチ |\n| パス安全性 | なし | safe_path 検証(file tools のみ) |\n| ループ | `while True` + `stop_reason` | s01 と完全に同一 |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s02_tool_use/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Read the file README.md and tell me what this project is about`\n2. `Create a file called test.py that prints \"hello\", then read it back`\n3. `Find all Python files in this directory`\n4. `Read both README.md and requirements.txt, then create a summary file`\n\n観察のポイント:モデルがツールを一つだけ呼び出すときと、複数同時に呼び出すときの違い。複数のツール呼び出しは正しい順序で実行されているか?\n\n---\n\n## 次へ\n\nAgent は 5 つの専用ツールを持つようになった。file tools は `safe_path` で保護されるが、bash は制限なし — `rm -rf /` はまだ実行できる。\n\n→ s03 Permission:ツール実行前にゲートを追加 — この操作は安全か? ユーザーの承認が必要か?\n\n
\nCC ソースコードを深掘り\n\n> 以下は CC ソースコード `Tool.ts`、`tools.ts`、`toolOrchestration.ts`、`toolExecution.ts`、`StreamingToolExecutor.ts` の検証に基づく。\n\n### 一、ツール定義方式\n\n**教育版**:`TOOLS` 配列 + `TOOL_HANDLERS` 辞書。定義と実装が分離。\n**CC**:各ツールは `buildTool()` で作成された独立オブジェクトで、schema、バリデーション、権限、実行を含む。`getAllBaseTools()` が全ツールを集約。\n\n教育版の分離方式は教学に適している — 読者は「ツール追加 = 二つの定義」と一目で分かる。\n\n### 二、並列安全性:isConcurrencySafe()\n\n![Tool Concurrency](/course-assets/s02_tool_use/concurrency-comparison.ja.svg)\n\n教育版は元の順序で一つずつ実行し、並列処理は行わない。CC は `isConcurrencySafe(input)` で並列可否を判断する — これは単なる「読み取り専用 vs 書き込み」ではなく、具体的な入力で判断する:\n\n| | isReadOnly | isConcurrencySafe |\n|---|---|---|\n| FileRead | true | true |\n| Glob | true | true |\n| Bash `ls` | true | **true** ← 重要な違い |\n| Bash `rm` | false | false |\n| TaskCreate | false | **true** ← 状態変更するが並列可能(s12 で紹介) |\n\nCC の Bash ツールの `isConcurrencySafe` は `isReadOnly` と同じ — 読み取り専用コマンドは並列可能、書き込みコマンドは不可。TaskCreate はタスクファイルを変更するが、毎回異なるファイルに書き込むため並列可能。\n\n### 三、パーティションアルゴリズム\n\nCC の `partitionToolCalls()`(`toolOrchestration.ts:91-115`)は二つのグループに分けるのではなく、ツール呼び出しを**連続ブロックごとにバッチ化**する:\n\n```\n[read A, read B, glob *.py, bash \"rm x\", read C]\n → batch1(並列): [read A, read B, glob *.py]\n → batch2(直列): [bash \"rm x\"]\n → batch3(並列): [read C]\n```\n\n連続する並列安全な呼び出しを同じバッチにまとめ、真の並列実行を行う(`toolOrchestration.ts:152-176`、並列数上限あり)。非並列安全な呼び出しに遭遇すると新しいバッチを開始して直列実行。バッチ間は厳密に順次。\n\n### 四、バリデーションパイプライン\n\nCC の各ツール呼び出しは厳格な 5 段階のバリデーションを経る(`toolExecution.ts`):\n\n1. **Zod schema バリデーション**(`614-680`、教育版は JSON Schema で代替):パラメータの型/構造チェック\n2. **ツールレベル validateInput()**(`682-733`):パラメータ値の検証(例:パスが作業ディレクトリ内か)\n3. **PreToolUse フック**(`800-862`、s04 で詳解):フックはメッセージの返却、入力の変更、実行のブロックが可能\n4. **権限チェック**(`921-931`、s03 の核心):canUseTool + checkPermissions → allow/deny/ask\n5. **tool.call() の実行**(`1207-1222`)\n\n教育版は Zod を省略(JSON Schema を使用)、validateInput を省略(安全関数を使用)、権限チェックとフック概念は保持。\n\n### 五、ストリーミングツール実行\n\nCC の `StreamingToolExecutor`(`StreamingToolExecutor.ts`)はモデルがまだ生成中にツールを起動する — モデルの完了を待たない。`read_file` はモデルが「分析します」と出力中に完了するかもしれない。教育版はこれを実装しない。s01 と同じ目標 — 概念の明確さ、極限のパフォーマンスではない。\n\n### 六、ツール結果の永続化\n\n各ツールには `maxResultSizeChars` フィールドがある。この閾値を超える結果はディスクに保存され、モデルにはプレビュー + ファイルパスが表示される。FileRead は特殊 — `Infinity` に設定され、ファイル読み出し結果の再永続化を防ぐ。具体的には、FileRead の結果が閾値を超えて永続化されると、モデルがその永続化ファイルを次に読むときにまた永続化がトリガーされ → 無限ループ(ファイル読む → 永続化 → 再読み → 再永続化 → ...)になる。\n\n
\n\n\n" + "title": "s02: Tool Use — 新しいツールごとに、たった1行", + "content": "# s02: Tool Use — 新しいツールごとに、たった1行\n\ns01 → `s02` → [s03](/ja/s03) → s04 → ... → s20\n> *\"新しいツールごとに、ハンドラを1つ追加\"* — ループはそのまま。新しいツールをディスパッチマップに登録するだけ。\n>\n> **Harness レイヤー**: ツールディスパッチ — ツール名でハンドラ関数を検索して呼び出す。\n\n---\n\n## ツールは bash 一つだけ\n\ns01 の Agent には bash 一つのツールしかない。ファイルを読むには `cat`、書くには `echo \"...\" > file.py`、編集するには `sed`。\n\nモデルへの指示は「このファイルを読む」だが、実際には `cat path/to/file` と組み立てなければならない。翻訳の層が一つ増え、トークンを無駄にし、エラーも起きやすい。\n\n---\n\n## 概要:ツールディスパッチ\n\n![Tool Dispatch](/course-assets/s02_tool_use/tool-dispatch.svg)\n\ns01 のループは完全に保持される(LLM 呼び出し、stop_reason 判定、メッセージ追加)。唯一の変更点はツール実行の 1 行:`run_bash()` が `TOOL_HANDLERS[block.name]()` の検索ディスパッチに置き換わる。\n\nAgent にツールを追加するには、たった二つ:\n\n1. **ツールを定義**:`TOOLS` 配列に一条を追加\n2. **ハンドラを登録**:`TOOL_HANDLERS` 辞書に一つのマッピングを追加\n\n---\n\n## 1 つのツールから 5 つのツールへ\n\ns01 には bash だけだった:\n\n```python\nTOOLS = [{\"name\": \"bash\", ...}]\n\ndef run_bash(command): ...\n```\n\ns02 では 5 つに増え、各ツールは独立して定義される:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\", ...},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\", ...},\n {\"name\": \"write_file\", \"description\": \"Write content to file.\", ...},\n {\"name\": \"edit_file\", \"description\": \"Replace text in file once.\", ...},\n {\"name\": \"glob\", \"description\": \"Find files by pattern.\", ...},\n]\n```\n\n各ツールには専用の実装関数がある:\n\n```python\ndef run_read(path, limit=None):\n lines = safe_path(path).read_text().splitlines()\n if limit:\n lines = lines[:limit]\n return \"\\n\".join(lines)\n\ndef run_write(path, content):\n safe_path(path).write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n\ndef run_edit(path, old_text, new_text):\n text = safe_path(path).read_text()\n if old_text not in text:\n return \"Error: text not found\"\n safe_path(path).write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n\ndef run_glob(pattern):\n import glob as g\n return \"\\n\".join(g.glob(pattern, root_dir=WORKDIR))\n```\n\n---\n\n## ツールディスパッチ\n\n```python\nTOOL_HANDLERS = {\n \"bash\": run_bash,\n \"read_file\": run_read,\n \"write_file\": run_write,\n \"edit_file\": run_edit,\n \"glob\": run_glob,\n}\n\n# ループ内で変更されたのは一行だけ — ハードコードの run_bash から検索ディスパッチへ:\nfor block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS[block.name] # 検索\n output = handler(**block.input) # 呼び出し\n results.append(...)\n```\n\n---\n\n## 複数のツール呼び出し\n\nモデルはよく一度に複数の tool_use を返す — 「a.py と b.py を読んで、全 .py ファイルを列挙して」。\n\n教育版は `response.content` の元の順序で一つずつ実行する。Claude Code のやり方はより複雑:元の順序を保ったまま連続バッチに分割し、バッチ内の並列安全なツールを並行実行し、バッチ間は厳密に順次(付録を参照)。\n\n---\n\n## 速查\n\n| 概念 | 一言で |\n|------|--------|\n| TOOL_HANDLERS | ツール名 → ハンドラ関数の辞書。ループが `block.name` で検索して呼び出す |\n| ツール定義 | モデルに「何ができるか」を伝える JSON schema |\n| 複数ツール呼び出し | モデルは一度に複数の tool_use を返す可能性がある。教育版は元の順序で一つずつ実行 |\n| ループ | s01 と同一、変更なし |\n\n---\n\n## s01 からの変更\n\n| コンポーネント | 変更前 (s01) | 変更後 (s02) |\n|--------------|-------------|-------------|\n| ツール数 | 1 (bash) | 5 (+read, write, edit, glob) |\n| ツール実行 | ハードコード `run_bash()` | TOOL_HANDLERS 検索ディスパッチ |\n| パス安全性 | なし | safe_path 検証(file tools のみ) |\n| ループ | `while True` + `stop_reason` | s01 と完全に同一 |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s02_tool_use/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Read the file README.md and tell me what this project is about`\n2. `Create a file called test.py that prints \"hello\", then read it back`\n3. `Find all Python files in this directory`\n4. `Read both README.md and requirements.txt, then create a summary file`\n\n観察のポイント:モデルがツールを一つだけ呼び出すときと、複数同時に呼び出すときの違い。複数のツール呼び出しは正しい順序で実行されているか?\n\n---\n\n## 次へ\n\nAgent は 5 つの専用ツールを持つようになった。file tools は `safe_path` で保護されるが、bash は制限なし — `rm -rf /` はまだ実行できる。\n\n→ s03 Permission:ツール実行前にゲートを追加。この操作は安全か? ユーザーの承認が必要か?\n\n
\nClaude Code ソースコードを深掘り\n\n> 以下は Claude Code ソースコード `Tool.ts`、`tools.ts`、`toolOrchestration.ts`、`toolExecution.ts`、`StreamingToolExecutor.ts` の検証に基づく。\n\n### 一、ツール定義方式\n\n**教育版**:`TOOLS` 配列 + `TOOL_HANDLERS` 辞書。定義と実装が分離。\n**Claude Code**:各ツールは `buildTool()` で作成された独立オブジェクトで、schema、バリデーション、権限、実行を含む。`getAllBaseTools()` が全ツールを集約。\n\n教育版の分離方式は教学に適している — 読者は「ツール追加 = 二つの定義」と一目で分かる。\n\n### 二、並列安全性:isConcurrencySafe()\n\n教育版は元の順序で一つずつ実行し、並列処理は行わない。Claude Code は `isConcurrencySafe(input)` で並列可否を判断する — これは単なる「読み取り専用 vs 書き込み」ではなく、具体的な入力で判断する:\n\n| | isReadOnly | isConcurrencySafe |\n|---|---|---|\n| FileRead | true | true |\n| Glob | true | true |\n| Bash `ls` | true | **true** ← 重要な違い |\n| Bash `rm` | false | false |\n| TaskCreate | false | **true** ← 状態変更するが並列可能(s12 で紹介) |\n\nClaude Code の Bash ツールの `isConcurrencySafe` は `isReadOnly` と同じ — 読み取り専用コマンドは並列可能、書き込みコマンドは不可。TaskCreate はタスクファイルを変更するが、毎回異なるファイルに書き込むため並列可能。\n\n### 三、パーティションアルゴリズム\n\nClaude Code の `partitionToolCalls()`(`toolOrchestration.ts:91-115`)は二つのグループに分けるのではなく、ツール呼び出しを**連続ブロックごとにバッチ化**する:\n\n```\n[read A, read B, glob *.py, bash \"rm x\", read C]\n → batch1(並列): [read A, read B, glob *.py]\n → batch2(直列): [bash \"rm x\"]\n → batch3(並列): [read C]\n```\n\n連続する並列安全な呼び出しを同じバッチにまとめ、真の並列実行を行う(`toolOrchestration.ts:152-176`、並列数上限あり)。非並列安全な呼び出しに遭遇すると新しいバッチを開始して直列実行。バッチ間は厳密に順次。\n\n### 四、バリデーションパイプライン\n\nClaude Code の各ツール呼び出しは厳格な 5 段階のバリデーションを経る(`toolExecution.ts`):\n\n1. **Zod schema バリデーション**(`614-680`、教育版は JSON Schema で代替):パラメータの型/構造チェック\n2. **ツールレベル validateInput()**(`682-733`):パラメータ値の検証(例:パスが作業ディレクトリ内か)\n3. **PreToolUse フック**(`800-862`、s04 で詳解):フックはメッセージの返却、入力の変更、実行のブロックが可能\n4. **権限チェック**(`921-931`、s03 の核心):canUseTool + checkPermissions → allow/deny/ask\n5. **tool.call() の実行**(`1207-1222`)\n\n教育版は Zod を省略(JSON Schema を使用)、validateInput を省略(安全関数を使用)、権限チェックとフック概念は保持。\n\n### 五、ストリーミングツール実行\n\nClaude Code の `StreamingToolExecutor`(`StreamingToolExecutor.ts`)はモデルがまだ生成中にツールを起動する — モデルの完了を待たない。`read_file` はモデルが「分析します」と出力中に完了するかもしれない。教育版はこれを実装しない。s01 と同じ目標 — 概念の明確さ、極限のパフォーマンスではない。\n\n### 六、ツール結果の永続化\n\n各ツールには `maxResultSizeChars` フィールドがある。この閾値を超える結果はディスクに保存され、モデルにはプレビュー + ファイルパスが表示される。FileRead は特殊 — `Infinity` に設定され、ファイル読み出し結果の再永続化を防ぐ。具体的には、FileRead の結果が閾値を超えて永続化されると、モデルがその永続化ファイルを次に読むときにまた永続化がトリガーされ → 無限ループ(ファイル読む → 永続化 → 再読み → 再永続化 → ...)になる。\n\n
\n\n\n" }, { "version": "s03", "locale": "en", "title": "s03: Permission — Check Permissions Before Execution", - "content": "# s03: Permission — Check Permissions Before Execution\n\ns01 → s02 → `s03` → [s04](/en/s04) → s05 → ... → s20\n> *\"Check permissions before executing\"* — The permission pipeline decides which operations need approval.\n>\n> **Harness Layer**: Permission — a gate before tool execution.\n\n---\n\n## The Problem\n\ns02's Agent has 5 tools. File tools are protected by `safe_path`, but bash is unrestricted. Ask it to \"clean up the project,\" and it might run `rm -rf /`.\n\nSafety can't rely on trusting the model — it needs code: a check before every tool execution.\n\n---\n\n## The Solution\n\n![Permission Overview](/course-assets/s03_permission/permission-overview.en.svg)\n\ns02's loop is fully preserved. The only change is inserting `check_permission()` before tool execution — each tool call passes through three gates in a fixed order: hard deny first, then soft ask, and if neither matches, allow.\n\nThe three gates correspond to three decisions:\n\n| Gate | Purpose | On Match |\n|------|---------|----------|\n| 1. Deny List | Permanently forbidden operations (`rm -rf /`, `sudo`) | Denied immediately, not executed |\n| 2. Rule Matching | Context-dependent operations (writing outside workspace, `rm` files) | Passed to Gate 3 |\n| 3. User Approval | After Gate 2 matches, pauses for user confirmation | User decides allow or deny |\n\nNone of the three gates match → execute directly. Most routine operations take this path.\n\n---\n\n## How It Works\n\n![Permission Pipeline](/course-assets/s03_permission/permission-pipeline.en.svg)\n\n**Gate 1**: A hard deny list. Check first; if matched, return a block message. (Teaching demo: simple string matching is not a reliable security mechanism — command variants and shell expansion can bypass it. CC's approach is in the appendix.)\n\n```python\nDENY_LIST = [\n \"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\",\n \"mkfs\", \"dd if=\", \"> /dev/sda\",\n]\n\ndef check_deny_list(command: str) -> str | None:\n for pattern in DENY_LIST:\n if pattern in command:\n return f\"Blocked: '{pattern}' is on the deny list\"\n return None\n```\n\n**Gate 2**: Rule matching — describes \"when to ask the user.\" Each rule specifies a tool and a check condition.\n\n```python\nPERMISSION_RULES = [\n {\n \"tools\": [\"write_file\", \"edit_file\"],\n \"check\": lambda args: not (WORKDIR / args.get(\"path\", \"\")).resolve().is_relative_to(WORKDIR),\n \"message\": \"Writing outside workspace\",\n },\n {\n \"tools\": [\"bash\"],\n \"check\": lambda args: any(kw in args.get(\"command\", \"\") for kw in [\"rm \", \"> /etc/\", \"chmod 777\"]),\n \"message\": \"Potentially destructive command\",\n },\n]\n\ndef check_rules(tool_name: str, args: dict) -> str | None:\n for rule in PERMISSION_RULES:\n if tool_name in rule[\"tools\"] and rule[\"check\"](args):\n return rule[\"message\"]\n return None\n```\n\n**Gate 3**: After a rule matches, pause for user input.\n\n```python\ndef ask_user(tool_name: str, args: dict, reason: str) -> str:\n print(f\"\\n⚠ {reason}\")\n print(f\" Tool: {tool_name}({args})\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n return \"allow\" if choice in (\"y\", \"yes\") else \"deny\"\n```\n\n**All three gates chained together**, inserted before tool execution:\n\n```python\ndef check_permission(block) -> bool:\n # Gate 1: Hard deny\n if block.name == \"bash\":\n reason = check_deny_list(block.input.get(\"command\", \"\"))\n if reason:\n print(f\"\\n⛔ {reason}\")\n return False\n\n # Gate 2 + 3: Rule matching → User approval\n reason = check_rules(block.name, block.input)\n if reason:\n decision = ask_user(block.name, block.input, reason)\n if decision == \"deny\":\n return False\n\n return True\n\n# In agent_loop — s02's loop with just one line added:\nfor block in response.content:\n if block.type == \"tool_use\":\n if not check_permission(block): # ← NEW\n results.append({... \"content\": \"Permission denied.\"})\n continue\n output = TOOL_HANDLERS[block.name](**block.input) # s02 original\n results.append(...)\n```\n\n---\n\n## Changes from s02\n\n| Component | Before (s02) | After (s03) |\n|-----------|-------------|-------------|\n| Security model | None (trust the model) | Three-gate permission pipeline |\n| New functions | — | check_deny_list, check_rules, ask_user, check_permission |\n| Loop | Executes all tools directly | Inserts check_permission() before execution |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s03_permission/code.py\n```\n\nTry these prompts:\n\n1. `Create a file called test.txt in the current directory` (should pass through)\n2. `Delete all temporary files in /tmp` (bash + rm triggers Gate 2)\n3. `What files are in the current directory?` (read-only, all pass)\n4. `Try to write a file to /etc/something` (writing outside workspace triggers Gate 2)\n\nWhat to watch for: Which operations pass through? Which need your confirmation? Which are denied outright?\n\n---\n\n## What's Next\n\nPermission checks are in place — but every check is hardcoded as `check_permission()` inside the loop. What if you want to add logging before and after each tool execution? What if you want to auto-trigger a git commit after certain operations? Scattering this extension logic throughout the loop makes it bloat.\n\n→ s04 Hooks: Add hooks to the loop. Extension logic hangs on hooks; the loop stays clean.\n\n
\nDive into CC Source Code\n\n> The following is based on a review of CC source code `types/permissions.ts`, `utils/permissions/permissions.ts`, `toolExecution.ts`, `utils/permissions/yoloClassifier.ts`, `tools/AgentTool/forkSubagent.ts`.\n\n### 1. PermissionResult: Not 3, but 4\n\nThe teaching version's three gates (deny → ask → allow) don't fully correspond to CC. CC's `PermissionResult` has 4 behaviors (`types/permissions.ts:241-266`):\n\n| behavior | Meaning | Teaching Version Equivalent |\n|----------|---------|---------------------------|\n| `allow` | Allow directly | Gate 3 passes |\n| `deny` | Deny directly | Gate 1 matches |\n| `ask` | Show dialog to user | Gate 2 matches |\n| `passthrough` | Tool doesn't express opinion, passes to generic pipeline | Not in teaching version |\n\n### 2. Production Verification Stages\n\nCC's tool calls don't go through three gates — they go through multiple stages distributed across `checkPermissionsAndCallTool()` (`toolExecution.ts:599-1745`), hooks, `hasPermissionsToUseToolInner()` (`utils/permissions/permissions.ts:1158-1310`), and classifier logic:\n\n1. **Zod schema validation** (`toolExecution.ts:614-680`) — parameter type checking\n2. **validateInput()** (`toolExecution.ts:682-733`) — tool-level semantic validation\n3. **backfillObservableInput()** (`toolExecution.ts:784`) — backfill legacy fields\n4. **PreToolUse hooks** (`toolExecution.ts:800-862`) — hooks can return allow/deny/ask\n5. **resolveHookPermissionDecision()** (`toolExecution.ts:921-931`) — coordinate hook + pipeline decisions\n6. **hasPermissionsToUseToolInner()** (`permissions.ts:1158-1310`) — multi-layer rule check:\n - Entire tool disabled by deny rule → `deny`\n - Entire tool flagged by ask rule → `ask`\n - `tool.checkPermissions()` tool's own judgment\n - Tool itself returns deny → `deny`\n - `requiresUserInteraction()` → `ask`\n - Content-related ask rules → `ask` (not bypassable)\n - Security check violation → `ask` (not bypassable)\n - bypassPermissions mode → `allow`\n - Entire tool allowed by allow rule → `allow`\n - passthrough → converted to `ask`\n\n### 3. Deny List: Not One File, but 8 Sources\n\nCC doesn't have a single deny list. Permission rules come from 8 sources (`types/permissions.ts:54-62`):\n\n| Source | Configuration Location |\n|--------|----------------------|\n| `userSettings` | `~/.claude/settings.json` |\n| `projectSettings` | `.claude/settings.json` |\n| `localSettings` | `settings.local.json` |\n| `flagSettings` | Feature flags |\n| `policySettings` | Enterprise management policy |\n| `cliArg` | `--allowedTools` / `--deniedTools` |\n| `command` | Inline command |\n| `session` | In-session temporary authorization |\n\nEach rule format: `{ toolName: \"Bash\", ruleBehavior: \"deny\", ruleContent: \"npm publish:*\" }`. Rules from multiple sources are merged, with higher-priority sources overriding lower ones (low to high: user < project < local < flag < policy, plus cliArg, command, session).\n\n### 4. What is isDestructive()\n\nIn CC, `isDestructive` (`Tool.ts:405-406`) is **purely for UI display** — showing a `[destructive]` label in the tool list. It doesn't participate in permission decisions. All tools return `false` by default. Only ExitWorktree (on remove) and MCP tools (depending on `annotations.destructiveHint`) override it.\n\n### 5. YoloClassifier (Auto-Approval)\n\nIn CC's auto mode, it doesn't pop a dialog every time. `classifyYoloAction` (`utils/permissions/yoloClassifier.ts:1012`) sends the tool call + conversation context to a classifier LLM to judge safety. It first tries acceptEdits mode simulation (`permissions.ts:620-656`, if acceptEdits allows → auto-approve), then checks the safe tool whitelist (`permissions.ts:658-686`), and finally calls the classifier. If the classifier rejects too many times in a row → falls back to manual approval.\n\n### 6. Permission Bubbling\n\nA sub-Agent's (forked via AgentTool) `permissionMode` is set to `'bubble'` (`forkSubagent.ts:50`). This means permission dialogs **bubble up to the parent Agent's terminal**, rather than being silently denied in the sub-Agent. The Bash classifier continues running during this process — displaying the permission dialog while judging in the background whether auto-approval is possible.\n\n### The Teaching Version's Simplification Is Intentional\n\n- Multi-stage pipeline → 3 gates: dramatically lower barrier to understanding\n- 8 rule sources → 1 local DENY_LIST: manageable concept count\n- isDestructive → omitted (teaching version has no UI layer, and it doesn't participate in permission decisions in CC either)\n- YoloClassifier → omitted (depends on additional LLM calls and telemetry)\n- Permission bubbling → omitted (s15 covers multi-Agent)\n\n
\n\n\n" + "content": "# s03: Permission — Check Permissions Before Execution\n\ns01 → s02 → `s03` → [s04](/en/s04) → s05 → ... → s20\n> *\"Check permissions before executing\"* — The permission pipeline decides which operations need approval.\n>\n> **Harness Layer**: Permission pipeline (deny / ask / allow).\n\n---\n\n## The Problem\n\ns02's Agent has 5 tools. File tools are protected by `safe_path`, but bash is unrestricted. Ask it to \"clean up the project,\" and it might run `rm -rf /`.\n\nSafety can't rely on trusting the model. It needs code that intercepts dangerous operations.\n\n---\n\n## The Solution\n\n![Permission Overview](/course-assets/s03_permission/permission-overview.svg)\n\ns02's loop is fully preserved. The only change is inserting `check_permission()` before tool execution. Each tool call passes through three gates in a fixed order: hard deny first, then soft ask, and if neither matches, allow.\n\nThe three gates correspond to three decisions:\n\n| Gate | Purpose | On Match |\n|------|---------|----------|\n| 1. Deny List | Permanently forbidden operations (`rm -rf /`, `sudo`) | Denied immediately, not executed |\n| 2. Rule Matching | Context-dependent operations (writing outside workspace, `rm` files) | Passed to Gate 3 |\n| 3. User Approval | After Gate 2 matches, pauses for user confirmation | User decides allow or deny |\n\nNone of the three gates match → execute directly. Most routine operations take this path.\n\n---\n\n## How It Works\n\n![Permission Pipeline](/course-assets/s03_permission/permission-pipeline.svg)\n\n**Gate 1**: A hard deny list. Check first; if matched, return a block message. (Teaching demo: simple string matching is not a reliable security mechanism. Command variants and shell expansion can bypass it. Claude Code's approach is in the appendix.)\n\n```python\nDENY_LIST = [\n \"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\",\n \"mkfs\", \"dd if=\", \"> /dev/sda\",\n]\n\ndef check_deny_list(command: str) -> str | None:\n for pattern in DENY_LIST:\n if pattern in command:\n return f\"Blocked: '{pattern}' is on the deny list\"\n return None\n```\n\n**Gate 2**: Rule matching, which describes \"when to ask the user.\" Each rule specifies a tool and a check condition.\n\n```python\nPERMISSION_RULES = [\n {\n \"tools\": [\"write_file\", \"edit_file\"],\n \"check\": lambda args: not (WORKDIR / args.get(\"path\", \"\")).resolve().is_relative_to(WORKDIR),\n \"message\": \"Writing outside workspace\",\n },\n {\n \"tools\": [\"bash\"],\n \"check\": lambda args: any(kw in args.get(\"command\", \"\") for kw in [\"rm \", \"> /etc/\", \"chmod 777\"]),\n \"message\": \"Potentially destructive command\",\n },\n]\n\ndef check_rules(tool_name: str, args: dict) -> str | None:\n for rule in PERMISSION_RULES:\n if tool_name in rule[\"tools\"] and rule[\"check\"](args):\n return rule[\"message\"]\n return None\n```\n\n**Gate 3**: After a rule matches, pause for user input.\n\n```python\ndef ask_user(tool_name: str, args: dict, reason: str) -> str:\n print(f\"\\n⚠ {reason}\")\n print(f\" Tool: {tool_name}({args})\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n return \"allow\" if choice in (\"y\", \"yes\") else \"deny\"\n```\n\n**All three gates chained together**, inserted before tool execution:\n\n```python\ndef check_permission(block) -> bool:\n # Gate 1: Hard deny\n if block.name == \"bash\":\n reason = check_deny_list(block.input.get(\"command\", \"\"))\n if reason:\n print(f\"\\n⛔ {reason}\")\n return False\n\n # Gate 2 + 3: Rule matching → User approval\n reason = check_rules(block.name, block.input)\n if reason:\n decision = ask_user(block.name, block.input, reason)\n if decision == \"deny\":\n return False\n\n return True\n\n# In agent_loop — s02's loop with just one line added:\nfor block in response.content:\n if block.type == \"tool_use\":\n if not check_permission(block): # ← NEW\n results.append({... \"content\": \"Permission denied.\"})\n continue\n output = TOOL_HANDLERS[block.name](**block.input) # s02 original\n results.append(...)\n```\n\n---\n\n## Changes from s02\n\n| Component | Before (s02) | After (s03) |\n|-----------|-------------|-------------|\n| Security model | None (trust the model) | Three-gate permission pipeline |\n| New functions | — | check_deny_list, check_rules, ask_user, check_permission |\n| Loop | Executes all tools directly | Inserts check_permission() before execution |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s03_permission/code.py\n```\n\nTry these prompts:\n\n1. `Create a file called test.txt in the current directory` (should pass through)\n2. `Delete the file test.txt` (bash + rm triggers Gate 2)\n3. `What files are in the current directory?` (read-only, all pass)\n4. `Try to write a file to /etc/something` (writing outside workspace triggers Gate 2)\n\nWhat to watch for: Which operations pass through? Which need your confirmation? Which are denied outright?\n\n---\n\n## What's Next\n\nPermission checks are in place, but every check is hardcoded as `check_permission()` inside the loop. What if you want to add logging before and after each tool execution? What if you want to auto-trigger a git commit after certain operations? Scattering this extension logic throughout the loop makes it bloat.\n\n→ s04 Hooks: Add hooks to the loop. Extension logic hangs on hooks; the loop stays clean.\n\n
\nDive into Claude Code Source Code\n\n> The following is based on a review of Claude Code source code `types/permissions.ts`, `utils/permissions/permissions.ts`, `toolExecution.ts`, `utils/permissions/yoloClassifier.ts`, `tools/AgentTool/forkSubagent.ts`.\n\n### 1. PermissionResult: Not 3, but 4\n\nThe teaching version's three gates (deny → ask → allow) don't fully correspond to Claude Code. Claude Code's `PermissionResult` has 4 behaviors (`types/permissions.ts:241-266`):\n\n| behavior | Meaning | Teaching Version Equivalent |\n|----------|---------|---------------------------|\n| `allow` | Allow directly | Gate 3 passes |\n| `deny` | Deny directly | Gate 1 matches |\n| `ask` | Show dialog to user | Gate 2 matches |\n| `passthrough` | Tool doesn't express opinion, passes to generic pipeline | Not in teaching version |\n\n### 2. Production Verification Stages\n\nClaude Code's tool calls don't go through three gates — they go through multiple stages distributed across `checkPermissionsAndCallTool()` (`toolExecution.ts:599-1745`), hooks, `hasPermissionsToUseToolInner()` (`utils/permissions/permissions.ts:1158-1310`), and classifier logic:\n\n1. **Zod schema validation** (`toolExecution.ts:614-680`) — parameter type checking\n2. **validateInput()** (`toolExecution.ts:682-733`) — tool-level semantic validation\n3. **backfillObservableInput()** (`toolExecution.ts:784`) — backfill legacy fields\n4. **PreToolUse hooks** (`toolExecution.ts:800-862`) — hooks can return allow/deny/ask\n5. **resolveHookPermissionDecision()** (`toolExecution.ts:921-931`) — coordinate hook + pipeline decisions\n6. **hasPermissionsToUseToolInner()** (`permissions.ts:1158-1310`) — multi-layer rule check:\n - Entire tool disabled by deny rule → `deny`\n - Entire tool flagged by ask rule → `ask`\n - `tool.checkPermissions()` tool's own judgment\n - Tool itself returns deny → `deny`\n - `requiresUserInteraction()` → `ask`\n - Content-related ask rules → `ask` (not bypassable)\n - Security check violation → `ask` (not bypassable)\n - bypassPermissions mode → `allow`\n - Entire tool allowed by allow rule → `allow`\n - passthrough → converted to `ask`\n\n### 3. Deny List: Not One File, but 8 Sources\n\nClaude Code doesn't have a single deny list. Permission rules come from 8 sources (`types/permissions.ts:54-62`):\n\n| Source | Configuration Location |\n|--------|----------------------|\n| `userSettings` | `~/.claude/settings.json` |\n| `projectSettings` | `.claude/settings.json` |\n| `localSettings` | `settings.local.json` |\n| `flagSettings` | Feature flags |\n| `policySettings` | Enterprise management policy |\n| `cliArg` | `--allowedTools` / `--deniedTools` |\n| `command` | Inline command |\n| `session` | In-session temporary authorization |\n\nEach rule format: `{ toolName: \"Bash\", ruleBehavior: \"deny\", ruleContent: \"npm publish:*\" }`. Rules from multiple sources are merged, with higher-priority sources overriding lower ones (low to high: user < project < local < flag < policy, plus cliArg, command, session).\n\n### 4. What is isDestructive()\n\nIn Claude Code, `isDestructive` (`Tool.ts:405-406`) is **purely for UI display** — showing a `[destructive]` label in the tool list. It doesn't participate in permission decisions. All tools return `false` by default. Only ExitWorktree (on remove) and MCP tools (depending on `annotations.destructiveHint`) override it.\n\n### 5. YoloClassifier (Auto-Approval)\n\nIn Claude Code's auto mode, it doesn't pop a dialog every time. `classifyYoloAction` (`utils/permissions/yoloClassifier.ts:1012`) sends the tool call + conversation context to a classifier LLM to judge safety. It first tries acceptEdits mode simulation (`permissions.ts:620-656`, if acceptEdits allows → auto-approve), then checks the safe tool whitelist (`permissions.ts:658-686`), and finally calls the classifier. If the classifier rejects too many times in a row → falls back to manual approval.\n\n### 6. Permission Bubbling\n\nA sub-Agent's (forked via AgentTool) `permissionMode` is set to `'bubble'` (`forkSubagent.ts:50`). This means permission dialogs **bubble up to the parent Agent's terminal**, rather than being silently denied in the sub-Agent. The Bash classifier continues running during this process — displaying the permission dialog while judging in the background whether auto-approval is possible.\n\n### The Teaching Version's Simplification Is Intentional\n\n- Multi-stage pipeline → 3 gates: fewer concepts to learn\n- 8 rule sources → 1 local DENY_LIST: manageable concept count\n- isDestructive → omitted (teaching version has no UI layer, and it doesn't participate in permission decisions in Claude Code either)\n- YoloClassifier → omitted (depends on additional LLM calls and telemetry)\n- Permission bubbling → omitted (s15 covers multi-Agent)\n\n
\n\n\n" }, { "version": "s03", "locale": "zh", "title": "s03: Permission — 执行前做权限判断", - "content": "# s03: Permission — 执行前做权限判断\n\ns01 → s02 → `s03` → [s04](/zh/s04) → s05 → ... → s20\n> *\"工具执行前先做权限判断\"* — 权限管线决定哪些操作需要审批。\n>\n> **Harness 层**: 权限 — 在工具执行前加一道门。\n\n---\n\n## 问题\n\ns02 的 Agent 有 5 个工具。file tools 受 `safe_path` 保护,但 bash 不受限制。让它\"清理一下项目\",可能执行 `rm -rf /`。\n\n安全不能靠信任模型,要靠代码——在工具执行之前做判断。\n\n---\n\n## 解决方案\n\n![Permission Overview](/course-assets/s03_permission/permission-overview.svg)\n\ns02 的循环完全保留。唯一的变动在工具执行前插入 `check_permission()`——每个工具调用经过三道闸门,顺序固定:硬拒绝优先,软询问次之,都没命中就放行。\n\n三道闸门对应三种决策:\n\n| 闸门 | 作用 | 命中后 |\n|------|------|--------|\n| 1. 拒绝列表 | 永远禁止的操作(`rm -rf /`、`sudo`) | 直接拒绝,不执行 |\n| 2. 规则匹配 | 取决于上下文的操作(写工作区外、`rm` 文件) | 交给闸门 3 |\n| 3. 用户审批 | 闸门 2 命中后,暂停等用户确认 | 用户决定允许或拒绝 |\n\n三道都没命中 → 直接执行。大部分日常操作走这条路。\n\n---\n\n## 工作原理\n\n![Permission Pipeline](/course-assets/s03_permission/permission-pipeline.svg)\n\n**闸门 1**:一张硬拒绝表,先查,命中就返回阻止信息。(教学示意:简单字符串匹配不是可靠安全机制,命令变体和 shell 展开可能绕过。CC 的做法见附录。)\n\n```python\nDENY_LIST = [\n \"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\",\n \"mkfs\", \"dd if=\", \"> /dev/sda\",\n]\n\ndef check_deny_list(command: str) -> str | None:\n for pattern in DENY_LIST:\n if pattern in command:\n return f\"Blocked: '{pattern}' is on the deny list\"\n return None\n```\n\n**闸门 2**:规则匹配——描述\"什么时候需要问用户\"。每条规则指定工具和检查条件。\n\n```python\nPERMISSION_RULES = [\n {\n \"tools\": [\"write_file\", \"edit_file\"],\n \"check\": lambda args: not (WORKDIR / args.get(\"path\", \"\")).resolve().is_relative_to(WORKDIR),\n \"message\": \"Writing outside workspace\",\n },\n {\n \"tools\": [\"bash\"],\n \"check\": lambda args: any(kw in args.get(\"command\", \"\") for kw in [\"rm \", \"> /etc/\", \"chmod 777\"]),\n \"message\": \"Potentially destructive command\",\n },\n]\n\ndef check_rules(tool_name: str, args: dict) -> str | None:\n for rule in PERMISSION_RULES:\n if tool_name in rule[\"tools\"] and rule[\"check\"](args):\n return rule[\"message\"]\n return None\n```\n\n**闸门 3**:规则命中后,暂停等用户输入。\n\n```python\ndef ask_user(tool_name: str, args: dict, reason: str) -> str:\n print(f\"\\n⚠ {reason}\")\n print(f\" Tool: {tool_name}({args})\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n return \"allow\" if choice in (\"y\", \"yes\") else \"deny\"\n```\n\n**三道闸门串在一起**,插在工具执行之前:\n\n```python\ndef check_permission(block) -> bool:\n # 闸门 1: 硬拒绝\n if block.name == \"bash\":\n reason = check_deny_list(block.input.get(\"command\", \"\"))\n if reason:\n print(f\"\\n⛔ {reason}\")\n return False\n\n # 闸门 2 + 3: 规则匹配 → 用户审批\n reason = check_rules(block.name, block.input)\n if reason:\n decision = ask_user(block.name, block.input, reason)\n if decision == \"deny\":\n return False\n\n return True\n\n# 在 agent_loop 中——s02 的循环只加了一行:\nfor block in response.content:\n if block.type == \"tool_use\":\n if not check_permission(block): # ← 新增\n results.append({... \"content\": \"Permission denied.\"})\n continue\n output = TOOL_HANDLERS[block.name](**block.input) # s02 原有\n results.append(...)\n```\n\n---\n\n## 相对 s02 的变更\n\n| 组件 | 之前 (s02) | 之后 (s03) |\n|------|-----------|-----------|\n| 安全模型 | 无(信任模型) | 三道闸门权限管线 |\n| 新函数 | — | check_deny_list, check_rules, ask_user, check_permission |\n| 循环 | 直接执行所有工具 | 执行前插入 check_permission() |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s03_permission/code.py\n```\n\n试试这些 prompt:\n\n1. `Create a file called test.txt in the current directory`(应该直接通过)\n2. `Delete all temporary files in /tmp`(bash + rm 会触发闸门 2)\n3. `What files are in the current directory?`(只读,全部通过)\n4. `Try to write a file to /etc/something`(写工作区外,触发闸门 2)\n\n观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝?\n\n---\n\n## 接下来\n\n权限检查做了——但每次都在循环里硬编码 `check_permission()`。如果我想在每次工具执行前后加日志?如果想在某些操作后自动触发 git commit?这些扩展逻辑散落在 loop 里,循环很快就会膨胀。\n\ns04 Hooks → 给循环加钩子,扩展逻辑挂在钩子上,循环保持干净。\n\n
\n深入 CC 源码\n\n> 以下基于 CC 源码 `types/permissions.ts`、`utils/permissions/permissions.ts`、`toolExecution.ts`、`utils/permissions/yoloClassifier.ts`、`tools/AgentTool/forkSubagent.ts` 的核查。\n\n### 一、PermissionResult:不是 3 种,是 4 种\n\n教学版的三道闸门(deny → ask → allow)和 CC 不完全对应。CC 的 `PermissionResult` 有 4 个 behavior(`types/permissions.ts:241-266`):\n\n| behavior | 含义 | 教学版对应 |\n|----------|------|-----------|\n| `allow` | 直接允许 | 闸门 3 通过 |\n| `deny` | 直接拒绝 | 闸门 1 命中 |\n| `ask` | 弹出对话框问用户 | 闸门 2 命中 |\n| `passthrough` | 工具不表态,交给通用管线决定 | 教学版无 |\n\n### 二、生产版的验证阶段\n\nCC 的工具调用不是经过三道闸门,而是经过多个阶段,分布在 `checkPermissionsAndCallTool()`(`toolExecution.ts:599-1745`)、hooks、`hasPermissionsToUseToolInner()`(`utils/permissions/permissions.ts:1158-1310`)和 classifier 逻辑里:\n\n1. **Zod schema 验证**(`toolExecution.ts:614-680`)— 参数类型检查\n2. **validateInput()**(`toolExecution.ts:682-733`)— 工具级语义验证\n3. **backfillObservableInput()**(`toolExecution.ts:784`)— 补全遗留字段\n4. **PreToolUse hooks**(`toolExecution.ts:800-862`)— 钩子可以返回 allow/deny/ask\n5. **resolveHookPermissionDecision()**(`toolExecution.ts:921-931`)— 协调钩子+管线决策\n6. **hasPermissionsToUseToolInner()**(`permissions.ts:1158-1310`)— 多层规则检查:\n - 整个工具被 deny rule 禁用 → `deny`\n - 整个工具被 ask rule 标记 → `ask`\n - `tool.checkPermissions()` 工具自己的判断\n - 工具自己返回 deny → `deny`\n - `requiresUserInteraction()` → `ask`\n - 内容相关的 ask 规则 → `ask`(不可绕过)\n - 安全检查违规 → `ask`(不可绕过)\n - bypassPermissions 模式 → `allow`\n - 整个工具被 allow rule 放行 → `allow`\n - passthrough → 转为 `ask`\n\n### 三、拒绝列表:不是一个文件,是 8 个来源\n\nCC 没有单一的 deny list。权限规则来自 8 个来源(`types/permissions.ts:54-62`):\n\n| 来源 | 配置位置 |\n|------|---------|\n| `userSettings` | `~/.claude/settings.json` |\n| `projectSettings` | `.claude/settings.json` |\n| `localSettings` | `settings.local.json` |\n| `flagSettings` | Feature flags |\n| `policySettings` | 企业管理策略 |\n| `cliArg` | `--allowedTools` / `--deniedTools` |\n| `command` | 内联命令 |\n| `session` | 会话内临时授权 |\n\n每条规则格式:`{ toolName: \"Bash\", ruleBehavior: \"deny\", ruleContent: \"npm publish:*\" }`。多个来源的规则合并,高优先级来源覆盖低优先级(从低到高:user < project < local < flag < policy,加上 cliArg、command、session)。\n\n### 四、isDestructive() 是什么\n\nCC 中 `isDestructive`(`Tool.ts:405-406`)**纯粹是 UI 展示用的**——在工具列表里显示 `[destructive]` 标签。它不参与权限决策。默认所有工具都返回 `false`。只有 ExitWorktree(remove 时)和 MCP 工具(依赖 `annotations.destructiveHint`)覆写了它。\n\n### 五、YoloClassifier(自动审批)\n\nCC 的 auto 模式下,不会每次都弹对话框。`classifyYoloAction`(`utils/permissions/yoloClassifier.ts:1012`)把工具调用 + 对话上下文发给一个分类器 LLM 判断是否安全。先尝试 acceptEdits 模式模拟(`permissions.ts:620-656`,如果 acceptEdits 允许 → 直接批准),再查安全工具白名单(`permissions.ts:658-686`),最后才调分类器。分类器连续拒绝太多次 → 回退到人工审批。\n\n### 六、权限冒泡\n\n子 Agent(通过 AgentTool fork 出来的)的 `permissionMode` 设为 `'bubble'`(`forkSubagent.ts:50`)。意思是权限弹窗**冒泡到父 Agent 的终端**,而不是在子 Agent 里静默拒绝。Bash 分类器在这个过程中继续跑——给权限对话框显示的同时在后台判断是否可以自动批准。\n\n### 教学版的简化是刻意的\n\n- 多阶段管线 → 3 道闸门:理解门槛大幅降低\n- 8 个规则来源 → 1 个本地 DENY_LIST:概念量可控\n- isDestructive → 忽略(教学版没有 UI 层,CC 里它也不参与权限决策)\n- YoloClassifier → 省略(依赖于额外的 LLM 调用和遥测系统)\n- 权限冒泡 → 省略(s15 才涉及多 Agent)\n\n
\n\n\n" + "content": "# s03: Permission — 执行前做权限判断\n\ns01 → s02 → `s03` → [s04](/zh/s04) → s05 → ... → s20\n> *\"工具执行前先做权限判断\"* — 权限管线决定哪些操作需要审批。\n>\n> **Harness 层**: 权限管线(deny / ask / allow)。\n\n---\n\n## 问题\n\ns02 的 Agent 有 5 个工具。file tools 受 `safe_path` 保护,但 bash 不受限制。让它\"清理一下项目\",可能执行 `rm -rf /`。\n\n安全不能靠信任模型,要靠代码在执行前拦截危险操作。\n\n---\n\n## 解决方案\n\n![Permission Overview](/course-assets/s03_permission/permission-overview.svg)\n\ns02 的循环完全保留。唯一的变动是在工具执行前插入 `check_permission()`。每个工具调用经过三道闸门,顺序固定:硬拒绝优先,软询问次之,都没命中就放行。\n\n三道闸门对应三种决策:\n\n| 闸门 | 作用 | 命中后 |\n|------|------|--------|\n| 1. 拒绝列表 | 永远禁止的操作(`rm -rf /`、`sudo`) | 直接拒绝,不执行 |\n| 2. 规则匹配 | 取决于上下文的操作(写工作区外、`rm` 文件) | 交给闸门 3 |\n| 3. 用户审批 | 闸门 2 命中后,暂停等用户确认 | 用户决定允许或拒绝 |\n\n三道都没命中 → 直接执行。大部分日常操作走这条路。\n\n---\n\n## 工作原理\n\n![Permission Pipeline](/course-assets/s03_permission/permission-pipeline.svg)\n\n**闸门 1**:一张硬拒绝表,先查,命中就返回阻止信息。(教学示意:简单字符串匹配不是可靠安全机制,命令变体和 shell 展开可能绕过。Claude Code 的做法见附录。)\n\n```python\nDENY_LIST = [\n \"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\",\n \"mkfs\", \"dd if=\", \"> /dev/sda\",\n]\n\ndef check_deny_list(command: str) -> str | None:\n for pattern in DENY_LIST:\n if pattern in command:\n return f\"Blocked: '{pattern}' is on the deny list\"\n return None\n```\n\n**闸门 2**:规则匹配,描述\"什么时候需要问用户\"。每条规则指定工具和检查条件。\n\n```python\nPERMISSION_RULES = [\n {\n \"tools\": [\"write_file\", \"edit_file\"],\n \"check\": lambda args: not (WORKDIR / args.get(\"path\", \"\")).resolve().is_relative_to(WORKDIR),\n \"message\": \"Writing outside workspace\",\n },\n {\n \"tools\": [\"bash\"],\n \"check\": lambda args: any(kw in args.get(\"command\", \"\") for kw in [\"rm \", \"> /etc/\", \"chmod 777\"]),\n \"message\": \"Potentially destructive command\",\n },\n]\n\ndef check_rules(tool_name: str, args: dict) -> str | None:\n for rule in PERMISSION_RULES:\n if tool_name in rule[\"tools\"] and rule[\"check\"](args):\n return rule[\"message\"]\n return None\n```\n\n**闸门 3**:规则命中后,暂停等用户输入。\n\n```python\ndef ask_user(tool_name: str, args: dict, reason: str) -> str:\n print(f\"\\n⚠ {reason}\")\n print(f\" Tool: {tool_name}({args})\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n return \"allow\" if choice in (\"y\", \"yes\") else \"deny\"\n```\n\n**三道闸门串在一起**,插在工具执行之前:\n\n```python\ndef check_permission(block) -> bool:\n # 闸门 1: 硬拒绝\n if block.name == \"bash\":\n reason = check_deny_list(block.input.get(\"command\", \"\"))\n if reason:\n print(f\"\\n⛔ {reason}\")\n return False\n\n # 闸门 2 + 3: 规则匹配 → 用户审批\n reason = check_rules(block.name, block.input)\n if reason:\n decision = ask_user(block.name, block.input, reason)\n if decision == \"deny\":\n return False\n\n return True\n\n# 在 agent_loop 中——s02 的循环只加了一行:\nfor block in response.content:\n if block.type == \"tool_use\":\n if not check_permission(block): # ← 新增\n results.append({... \"content\": \"Permission denied.\"})\n continue\n output = TOOL_HANDLERS[block.name](**block.input) # s02 原有\n results.append(...)\n```\n\n---\n\n## 相对 s02 的变更\n\n| 组件 | 之前 (s02) | 之后 (s03) |\n|------|-----------|-----------|\n| 安全模型 | 无(信任模型) | 三道闸门权限管线 |\n| 新函数 | — | check_deny_list, check_rules, ask_user, check_permission |\n| 循环 | 直接执行所有工具 | 执行前插入 check_permission() |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s03_permission/code.py\n```\n\n试试这些 prompt:\n\n1. `Create a file called test.txt in the current directory`(应该直接通过)\n2. `Delete the file test.txt`(bash + rm 会触发闸门 2)\n3. `What files are in the current directory?`(只读,全部通过)\n4. `Try to write a file to /etc/something`(写工作区外,触发闸门 2)\n\n观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝?\n\n---\n\n## 接下来\n\n权限检查做了,但每次都在循环里硬编码 `check_permission()`。如果我想在每次工具执行前后加日志?如果想在某些操作后自动触发 git commit?这些扩展逻辑散落在 loop 里,循环很快就会膨胀。\n\ns04 Hooks → 给循环加钩子,扩展逻辑挂在钩子上,循环保持干净。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `types/permissions.ts`、`utils/permissions/permissions.ts`、`toolExecution.ts`、`utils/permissions/yoloClassifier.ts`、`tools/AgentTool/forkSubagent.ts` 的核查。\n\n### 一、PermissionResult:不是 3 种,是 4 种\n\n教学版的三道闸门(deny → ask → allow)和 Claude Code 不完全对应。Claude Code 的 `PermissionResult` 有 4 个 behavior(`types/permissions.ts:241-266`):\n\n| behavior | 含义 | 教学版对应 |\n|----------|------|-----------|\n| `allow` | 直接允许 | 闸门 3 通过 |\n| `deny` | 直接拒绝 | 闸门 1 命中 |\n| `ask` | 弹出对话框问用户 | 闸门 2 命中 |\n| `passthrough` | 工具不表态,交给通用管线决定 | 教学版无 |\n\n### 二、生产版的验证阶段\n\nClaude Code 的工具调用不是经过三道闸门,而是经过多个阶段,分布在 `checkPermissionsAndCallTool()`(`toolExecution.ts:599-1745`)、hooks、`hasPermissionsToUseToolInner()`(`utils/permissions/permissions.ts:1158-1310`)和 classifier 逻辑里:\n\n1. **Zod schema 验证**(`toolExecution.ts:614-680`)— 参数类型检查\n2. **validateInput()**(`toolExecution.ts:682-733`)— 工具级语义验证\n3. **backfillObservableInput()**(`toolExecution.ts:784`)— 补全遗留字段\n4. **PreToolUse hooks**(`toolExecution.ts:800-862`)— 钩子可以返回 allow/deny/ask\n5. **resolveHookPermissionDecision()**(`toolExecution.ts:921-931`)— 协调钩子+管线决策\n6. **hasPermissionsToUseToolInner()**(`permissions.ts:1158-1310`)— 多层规则检查:\n - 整个工具被 deny rule 禁用 → `deny`\n - 整个工具被 ask rule 标记 → `ask`\n - `tool.checkPermissions()` 工具自己的判断\n - 工具自己返回 deny → `deny`\n - `requiresUserInteraction()` → `ask`\n - 内容相关的 ask 规则 → `ask`(不可绕过)\n - 安全检查违规 → `ask`(不可绕过)\n - bypassPermissions 模式 → `allow`\n - 整个工具被 allow rule 放行 → `allow`\n - passthrough → 转为 `ask`\n\n### 三、拒绝列表:不是一个文件,是 8 个来源\n\nClaude Code 没有单一的 deny list。权限规则来自 8 个来源(`types/permissions.ts:54-62`):\n\n| 来源 | 配置位置 |\n|------|---------|\n| `userSettings` | `~/.claude/settings.json` |\n| `projectSettings` | `.claude/settings.json` |\n| `localSettings` | `settings.local.json` |\n| `flagSettings` | Feature flags |\n| `policySettings` | 企业管理策略 |\n| `cliArg` | `--allowedTools` / `--deniedTools` |\n| `command` | 内联命令 |\n| `session` | 会话内临时授权 |\n\n每条规则格式:`{ toolName: \"Bash\", ruleBehavior: \"deny\", ruleContent: \"npm publish:*\" }`。多个来源的规则合并,高优先级来源覆盖低优先级(从低到高:user < project < local < flag < policy,加上 cliArg、command、session)。\n\n### 四、isDestructive() 是什么\n\nClaude Code 中 `isDestructive`(`Tool.ts:405-406`)**纯粹是 UI 展示用的**——在工具列表里显示 `[destructive]` 标签。它不参与权限决策。默认所有工具都返回 `false`。只有 ExitWorktree(remove 时)和 MCP 工具(依赖 `annotations.destructiveHint`)覆写了它。\n\n### 五、YoloClassifier(自动审批)\n\nClaude Code 的 auto 模式下,不会每次都弹对话框。`classifyYoloAction`(`utils/permissions/yoloClassifier.ts:1012`)把工具调用 + 对话上下文发给一个分类器 LLM 判断是否安全。先尝试 acceptEdits 模式模拟(`permissions.ts:620-656`,如果 acceptEdits 允许 → 直接批准),再查安全工具白名单(`permissions.ts:658-686`),最后才调分类器。分类器连续拒绝太多次 → 回退到人工审批。\n\n### 六、权限冒泡\n\n子 Agent(通过 AgentTool fork 出来的)的 `permissionMode` 设为 `'bubble'`(`forkSubagent.ts:50`)。意思是权限弹窗**冒泡到父 Agent 的终端**,而不是在子 Agent 里静默拒绝。Bash 分类器在这个过程中继续跑——给权限对话框显示的同时在后台判断是否可以自动批准。\n\n### 教学版的简化是刻意的\n\n- 多阶段管线 → 3 道闸门:概念更少,更容易上手\n- 8 个规则来源 → 1 个本地 DENY_LIST:概念量可控\n- isDestructive → 忽略(教学版没有 UI 层,Claude Code 里它也不参与权限决策)\n- YoloClassifier → 省略(依赖于额外的 LLM 调用和遥测系统)\n- 权限冒泡 → 省略(s15 才涉及多 Agent)\n\n
\n\n\n" }, { "version": "s03", "locale": "ja", "title": "s03: Permission — 実行前に権限を判断する", - "content": "# s03: Permission — 実行前に権限を判断する\n\ns01 → s02 → `s03` → [s04](/ja/s04) → s05 → ... → s20\n> *\"ツール実行前に権限を判断\"* — 権限パイプラインは、どの操作に承認が必要かを決める。\n>\n> **Harness レイヤー**: 権限 — ツール実行前に一つのゲートを追加。\n\n---\n\n## 課題\n\ns02 の Agent は 5 つのツールを持つ。file tools は `safe_path` で保護されるが、bash は制限なし。「プロジェクトを掃除して」と頼むと、`rm -rf /` を実行しかねない。\n\n安全性はモデルを信頼することではなく、コードに頼る — ツール実行前に判断を挟む。\n\n---\n\n## ソリューション\n\n![Permission Overview](/course-assets/s03_permission/permission-overview.ja.svg)\n\ns02 のループは完全に維持される。唯一の変更は、ツール実行前に `check_permission()` を挿入すること — 各ツール呼び出しは 3 つのゲートを固定順序で通過する:ハード拒否が最優先、次にソフト確認、どちらも一致しなければ許可。\n\n3 つのゲートは 3 つの決定に対応する:\n\n| ゲート | 役割 | 一致時 |\n|--------|------|--------|\n| 1. 拒否リスト | 常に禁止される操作(`rm -rf /`、`sudo`) | 即座に拒否、実行しない |\n| 2. ルールマッチング | コンテキスト依存の操作(作業ディレクトリ外への書き込み、`rm` ファイル) | ゲート 3 へ |\n| 3. ユーザー承認 | ゲート 2 が一致した場合、ユーザー確認を待機 | ユーザーが許可または拒否を決定 |\n\n3 つのゲートのどれにも一致しない → 直接実行。日常の操作の大部分はこの経路を通る。\n\n---\n\n## 仕組み\n\n![Permission Pipeline](/course-assets/s03_permission/permission-pipeline.ja.svg)\n\n**ゲート 1**:ハード拒否リスト。最初に確認し、一致すればブロックメッセージを返す。(教育デモ:単純な文字列マッチングは信頼できるセキュリティ機構ではない — コマンドの変種やシェル展開で回避される可能性がある。CC のアプローチは付録を参照。)\n\n```python\nDENY_LIST = [\n \"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\",\n \"mkfs\", \"dd if=\", \"> /dev/sda\",\n]\n\ndef check_deny_list(command: str) -> str | None:\n for pattern in DENY_LIST:\n if pattern in command:\n return f\"Blocked: '{pattern}' is on the deny list\"\n return None\n```\n\n**ゲート 2**:ルールマッチング — 「いつユーザーに聞くべきか」を記述する。各ルールはツールとチェック条件を指定する。\n\n```python\nPERMISSION_RULES = [\n {\n \"tools\": [\"write_file\", \"edit_file\"],\n \"check\": lambda args: not (WORKDIR / args.get(\"path\", \"\")).resolve().is_relative_to(WORKDIR),\n \"message\": \"Writing outside workspace\",\n },\n {\n \"tools\": [\"bash\"],\n \"check\": lambda args: any(kw in args.get(\"command\", \"\") for kw in [\"rm \", \"> /etc/\", \"chmod 777\"]),\n \"message\": \"Potentially destructive command\",\n },\n]\n\ndef check_rules(tool_name: str, args: dict) -> str | None:\n for rule in PERMISSION_RULES:\n if tool_name in rule[\"tools\"] and rule[\"check\"](args):\n return rule[\"message\"]\n return None\n```\n\n**ゲート 3**:ルールが一致した後、ユーザー入力を待機。\n\n```python\ndef ask_user(tool_name: str, args: dict, reason: str) -> str:\n print(f\"\\n⚠ {reason}\")\n print(f\" Tool: {tool_name}({args})\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n return \"allow\" if choice in (\"y\", \"yes\") else \"deny\"\n```\n\n**3 つのゲートを直列に接続**、ツール実行前に挿入する:\n\n```python\ndef check_permission(block) -> bool:\n # ゲート 1: ハード拒否\n if block.name == \"bash\":\n reason = check_deny_list(block.input.get(\"command\", \"\"))\n if reason:\n print(f\"\\n⛔ {reason}\")\n return False\n\n # ゲート 2 + 3: ルールマッチング → ユーザー承認\n reason = check_rules(block.name, block.input)\n if reason:\n decision = ask_user(block.name, block.input, reason)\n if decision == \"deny\":\n return False\n\n return True\n\n# agent_loop で — s02 のループに 1 行追加するだけ:\nfor block in response.content:\n if block.type == \"tool_use\":\n if not check_permission(block): # ← 新規\n results.append({... \"content\": \"Permission denied.\"})\n continue\n output = TOOL_HANDLERS[block.name](**block.input) # s02 既存\n results.append(...)\n```\n\n---\n\n## s02 からの変更点\n\n| コンポーネント | 変更前 (s02) | 変更後 (s03) |\n|---------------|-------------|-------------|\n| セキュリティモデル | なし(モデルを信頼) | 3 ゲート権限パイプライン |\n| 新規関数 | — | check_deny_list, check_rules, ask_user, check_permission |\n| ループ | すべてのツールを直接実行 | 実行前に check_permission() を挿入 |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s03_permission/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Create a file called test.txt in the current directory`(そのまま通過するはず)\n2. `Delete all temporary files in /tmp`(bash + rm でゲート 2 が発動)\n3. `What files are in the current directory?`(読み取り専用、すべて通過)\n4. `Try to write a file to /etc/something`(作業ディレクトリ外への書き込みでゲート 2 が発動)\n\n観察のポイント:どの操作がそのまま通過するか? どれに確認が必要か? どれが即座に拒否されるか?\n\n---\n\n## 次へ\n\n権限チェックは実装された — しかし、毎回ループ内に `check_permission()` をハードコードしている。ツール実行の前後にログを追加したい場合は? 特定の操作後に自動的に git commit をトリガーしたい場合は? このような拡張ロジックがループ内に散らばると、ループはすぐに膨張する。\n\n→ s04 Hooks:ループにフックを追加する。拡張ロジックはフックにぶら下げ、ループはクリーンに保つ。\n\n
\nCC ソースコードを深掘り\n\n> 以下は CC ソースコード `types/permissions.ts`、`utils/permissions/permissions.ts`、`toolExecution.ts`、`utils/permissions/yoloClassifier.ts`、`tools/AgentTool/forkSubagent.ts` の検証に基づく。\n\n### 一、PermissionResult:3 種ではなく、4 種\n\n教育版の 3 つのゲート(deny → ask → allow)は CC と完全には対応しない。CC の `PermissionResult` には 4 つの behavior がある(`types/permissions.ts:241-266`):\n\n| behavior | 意味 | 教育版の対応 |\n|----------|------|-------------|\n| `allow` | 直接許可 | ゲート 3 通過 |\n| `deny` | 直接拒否 | ゲート 1 一致 |\n| `ask` | ユーザーにダイアログを表示 | ゲート 2 一致 |\n| `passthrough` | ツールが意見を表明せず、汎用パイプラインに委ねる | 教育版にはなし |\n\n### 二、本番環境の検証段階\n\nCC のツール呼び出しは 3 つのゲートを通るのではなく、`checkPermissionsAndCallTool()`(`toolExecution.ts:599-1745`)、hooks、`hasPermissionsToUseToolInner()`(`utils/permissions/permissions.ts:1158-1310`)、classifier ロジックに分散する複数の段階を経る:\n\n1. **Zod schema 検証**(`toolExecution.ts:614-680`)— パラメータの型チェック\n2. **validateInput()**(`toolExecution.ts:682-733`)— ツールレベルの意味的検証\n3. **backfillObservableInput()**(`toolExecution.ts:784`)— レガシーフィールドの補完\n4. **PreToolUse hooks**(`toolExecution.ts:800-862`)— フックが allow/deny/ask を返す\n5. **resolveHookPermissionDecision()**(`toolExecution.ts:921-931`)— フック + パイプラインの決定を調整\n6. **hasPermissionsToUseToolInner()**(`permissions.ts:1158-1310`)— 多層ルールチェック:\n - ツール全体が deny rule で無効 → `deny`\n - ツール全体が ask rule でマーク → `ask`\n - `tool.checkPermissions()` ツール自身の判断\n - ツール自身が deny を返す → `deny`\n - `requiresUserInteraction()` → `ask`\n - コンテンツ関連の ask ルール → `ask`(バイパス不可)\n - セキュリティチェック違反 → `ask`(バイパス不可)\n - bypassPermissions モード → `allow`\n - ツール全体が allow rule で許可 → `allow`\n - passthrough → `ask` に変換\n\n### 三、拒否リスト:1 つのファイルではなく、8 つのソース\n\nCC には単一の deny list はない。権限ルールは 8 つのソースから来る(`types/permissions.ts:54-62`):\n\n| ソース | 設定場所 |\n|--------|---------|\n| `userSettings` | `~/.claude/settings.json` |\n| `projectSettings` | `.claude/settings.json` |\n| `localSettings` | `settings.local.json` |\n| `flagSettings` | フィーチャーフラグ |\n| `policySettings` | 企業管理ポリシー |\n| `cliArg` | `--allowedTools` / `--deniedTools` |\n| `command` | インラインコマンド |\n| `session` | セッション内一時承認 |\n\n各ルールの形式:`{ toolName: \"Bash\", ruleBehavior: \"deny\", ruleContent: \"npm publish:*\" }`。複数ソースのルールは統合され、高優先度ソースが低優先度を上書きする(低→高:user < project < local < flag < policy、さらに cliArg、command、session)。\n\n### 四、isDestructive() とは\n\nCC では `isDestructive`(`Tool.ts:405-406`)は**純粋に UI 表示用** — ツール一覧に `[destructive]` ラベルを表示するだけ。権限決定には参加しない。デフォルトではすべてのツールが `false` を返す。ExitWorktree(remove 時)と MCP ツール(`annotations.destructiveHint` に依存)のみがオーバーライドする。\n\n### 五、YoloClassifier(自動承認)\n\nCC の auto モードでは、毎回ダイアログを表示するわけではない。`classifyYoloAction`(`utils/permissions/yoloClassifier.ts:1012`)はツール呼び出し + 会話コンテキストを分類器 LLM に送って安全性を判断する。まず acceptEdits モードのシミュレーションを試み(`permissions.ts:620-656`、acceptEdits が許可すれば → 自動承認)、次にセーフツールホワイトリストを確認し(`permissions.ts:658-686`)、最後に分類器を呼び出す。分類器が連続して拒否しすぎた場合 → 手動承認にフォールバック。\n\n### 六、権限バブリング\n\nサブ Agent(AgentTool 経由でフォークされたもの)の `permissionMode` は `'bubble'` に設定される(`forkSubagent.ts:50`)。これは権限ダイアログが**親 Agent のターミナルにバブルアップ**することを意味する。サブ Agent で黙って拒否されるのではない。Bash 分類器はこの過程で引き続き実行され — 権限ダイアログを表示しつつ、バックグラウンドで自動承認可能か判断する。\n\n### 教育版の単純化は意図的\n\n- 多段階パイプライン → 3 ゲート:理解のハードルが大幅に下がる\n- 8 ルールソース → 1 つのローカル DENY_LIST:概念量を制御可能\n- isDestructive → 省略(教育版には UI レイヤーがなく、CC でも権限決定には参加しない)\n- YoloClassifier → 省略(追加の LLM 呼び出しとテレメトリに依存)\n- 権限バブリング → 省略(s15 でマルチ Agent を扱う)\n\n
\n\n\n" + "content": "# s03: Permission — 実行前に権限を判断する\n\ns01 → s02 → `s03` → [s04](/ja/s04) → s05 → ... → s20\n> *\"ツール実行前に権限を判断\"* — 権限パイプラインは、どの操作に承認が必要かを決める。\n>\n> **Harness レイヤー**: 権限パイプライン(deny / ask / allow)。\n\n---\n\n## 課題\n\ns02 の Agent は 5 つのツールを持つ。file tools は `safe_path` で保護されるが、bash は制限なし。「プロジェクトを掃除して」と頼むと、`rm -rf /` を実行しかねない。\n\n安全性はモデルを信頼することではなく、危険な操作を実行前にコードで遮断することに頼る。\n\n---\n\n## ソリューション\n\n![Permission Overview](/course-assets/s03_permission/permission-overview.svg)\n\ns02 のループは完全に維持される。唯一の変更は、ツール実行前に `check_permission()` を挿入すること。各ツール呼び出しは 3 つのゲートを固定順序で通過する:ハード拒否が最優先、次にソフト確認、どちらも一致しなければ許可。\n\n3 つのゲートは 3 つの決定に対応する:\n\n| ゲート | 役割 | 一致時 |\n|--------|------|--------|\n| 1. 拒否リスト | 常に禁止される操作(`rm -rf /`、`sudo`) | 即座に拒否、実行しない |\n| 2. ルールマッチング | コンテキスト依存の操作(作業ディレクトリ外への書き込み、`rm` ファイル) | ゲート 3 へ |\n| 3. ユーザー承認 | ゲート 2 が一致した場合、ユーザー確認を待機 | ユーザーが許可または拒否を決定 |\n\n3 つのゲートのどれにも一致しない → 直接実行。日常の操作の大部分はこの経路を通る。\n\n---\n\n## 仕組み\n\n![Permission Pipeline](/course-assets/s03_permission/permission-pipeline.svg)\n\n**ゲート 1**:ハード拒否リスト。最初に確認し、一致すればブロックメッセージを返す。(教育デモ:単純な文字列マッチングは信頼できるセキュリティ機構ではない。コマンドの変種やシェル展開で回避される可能性がある。Claude Code のアプローチは付録を参照。)\n\n```python\nDENY_LIST = [\n \"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\",\n \"mkfs\", \"dd if=\", \"> /dev/sda\",\n]\n\ndef check_deny_list(command: str) -> str | None:\n for pattern in DENY_LIST:\n if pattern in command:\n return f\"Blocked: '{pattern}' is on the deny list\"\n return None\n```\n\n**ゲート 2**:ルールマッチング。「いつユーザーに聞くべきか」を記述する。各ルールはツールとチェック条件を指定する。\n\n```python\nPERMISSION_RULES = [\n {\n \"tools\": [\"write_file\", \"edit_file\"],\n \"check\": lambda args: not (WORKDIR / args.get(\"path\", \"\")).resolve().is_relative_to(WORKDIR),\n \"message\": \"Writing outside workspace\",\n },\n {\n \"tools\": [\"bash\"],\n \"check\": lambda args: any(kw in args.get(\"command\", \"\") for kw in [\"rm \", \"> /etc/\", \"chmod 777\"]),\n \"message\": \"Potentially destructive command\",\n },\n]\n\ndef check_rules(tool_name: str, args: dict) -> str | None:\n for rule in PERMISSION_RULES:\n if tool_name in rule[\"tools\"] and rule[\"check\"](args):\n return rule[\"message\"]\n return None\n```\n\n**ゲート 3**:ルールが一致した後、ユーザー入力を待機。\n\n```python\ndef ask_user(tool_name: str, args: dict, reason: str) -> str:\n print(f\"\\n⚠ {reason}\")\n print(f\" Tool: {tool_name}({args})\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n return \"allow\" if choice in (\"y\", \"yes\") else \"deny\"\n```\n\n**3 つのゲートを直列に接続**、ツール実行前に挿入する:\n\n```python\ndef check_permission(block) -> bool:\n # ゲート 1: ハード拒否\n if block.name == \"bash\":\n reason = check_deny_list(block.input.get(\"command\", \"\"))\n if reason:\n print(f\"\\n⛔ {reason}\")\n return False\n\n # ゲート 2 + 3: ルールマッチング → ユーザー承認\n reason = check_rules(block.name, block.input)\n if reason:\n decision = ask_user(block.name, block.input, reason)\n if decision == \"deny\":\n return False\n\n return True\n\n# agent_loop で — s02 のループに 1 行追加するだけ:\nfor block in response.content:\n if block.type == \"tool_use\":\n if not check_permission(block): # ← 新規\n results.append({... \"content\": \"Permission denied.\"})\n continue\n output = TOOL_HANDLERS[block.name](**block.input) # s02 既存\n results.append(...)\n```\n\n---\n\n## s02 からの変更点\n\n| コンポーネント | 変更前 (s02) | 変更後 (s03) |\n|---------------|-------------|-------------|\n| セキュリティモデル | なし(モデルを信頼) | 3 ゲート権限パイプライン |\n| 新規関数 | — | check_deny_list, check_rules, ask_user, check_permission |\n| ループ | すべてのツールを直接実行 | 実行前に check_permission() を挿入 |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s03_permission/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Create a file called test.txt in the current directory`(そのまま通過するはず)\n2. `Delete the file test.txt`(bash + rm でゲート 2 が発動)\n3. `What files are in the current directory?`(読み取り専用、すべて通過)\n4. `Try to write a file to /etc/something`(作業ディレクトリ外への書き込みでゲート 2 が発動)\n\n観察のポイント:どの操作がそのまま通過するか? どれに確認が必要か? どれが即座に拒否されるか?\n\n---\n\n## 次へ\n\n権限チェックは実装されたが、毎回ループ内に `check_permission()` をハードコードしている。ツール実行の前後にログを追加したい場合は? 特定の操作後に自動的に git commit をトリガーしたい場合は? このような拡張ロジックがループ内に散らばると、ループはすぐに膨張する。\n\n→ s04 Hooks:ループにフックを追加する。拡張ロジックはフックにぶら下げ、ループはクリーンに保つ。\n\n
\nClaude Code ソースコードを深掘り\n\n> 以下は Claude Code ソースコード `types/permissions.ts`、`utils/permissions/permissions.ts`、`toolExecution.ts`、`utils/permissions/yoloClassifier.ts`、`tools/AgentTool/forkSubagent.ts` の検証に基づく。\n\n### 一、PermissionResult:3 種ではなく、4 種\n\n教育版の 3 つのゲート(deny → ask → allow)は Claude Code と完全には対応しない。Claude Code の `PermissionResult` には 4 つの behavior がある(`types/permissions.ts:241-266`):\n\n| behavior | 意味 | 教育版の対応 |\n|----------|------|-------------|\n| `allow` | 直接許可 | ゲート 3 通過 |\n| `deny` | 直接拒否 | ゲート 1 一致 |\n| `ask` | ユーザーにダイアログを表示 | ゲート 2 一致 |\n| `passthrough` | ツールが意見を表明せず、汎用パイプラインに委ねる | 教育版にはなし |\n\n### 二、本番環境の検証段階\n\nClaude Code のツール呼び出しは 3 つのゲートを通るのではなく、`checkPermissionsAndCallTool()`(`toolExecution.ts:599-1745`)、hooks、`hasPermissionsToUseToolInner()`(`utils/permissions/permissions.ts:1158-1310`)、classifier ロジックに分散する複数の段階を経る:\n\n1. **Zod schema 検証**(`toolExecution.ts:614-680`)— パラメータの型チェック\n2. **validateInput()**(`toolExecution.ts:682-733`)— ツールレベルの意味的検証\n3. **backfillObservableInput()**(`toolExecution.ts:784`)— レガシーフィールドの補完\n4. **PreToolUse hooks**(`toolExecution.ts:800-862`)— フックが allow/deny/ask を返す\n5. **resolveHookPermissionDecision()**(`toolExecution.ts:921-931`)— フック + パイプラインの決定を調整\n6. **hasPermissionsToUseToolInner()**(`permissions.ts:1158-1310`)— 多層ルールチェック:\n - ツール全体が deny rule で無効 → `deny`\n - ツール全体が ask rule でマーク → `ask`\n - `tool.checkPermissions()` ツール自身の判断\n - ツール自身が deny を返す → `deny`\n - `requiresUserInteraction()` → `ask`\n - コンテンツ関連の ask ルール → `ask`(バイパス不可)\n - セキュリティチェック違反 → `ask`(バイパス不可)\n - bypassPermissions モード → `allow`\n - ツール全体が allow rule で許可 → `allow`\n - passthrough → `ask` に変換\n\n### 三、拒否リスト:1 つのファイルではなく、8 つのソース\n\nClaude Code には単一の deny list はない。権限ルールは 8 つのソースから来る(`types/permissions.ts:54-62`):\n\n| ソース | 設定場所 |\n|--------|---------|\n| `userSettings` | `~/.claude/settings.json` |\n| `projectSettings` | `.claude/settings.json` |\n| `localSettings` | `settings.local.json` |\n| `flagSettings` | フィーチャーフラグ |\n| `policySettings` | 企業管理ポリシー |\n| `cliArg` | `--allowedTools` / `--deniedTools` |\n| `command` | インラインコマンド |\n| `session` | セッション内一時承認 |\n\n各ルールの形式:`{ toolName: \"Bash\", ruleBehavior: \"deny\", ruleContent: \"npm publish:*\" }`。複数ソースのルールは統合され、高優先度ソースが低優先度を上書きする(低→高:user < project < local < flag < policy、さらに cliArg、command、session)。\n\n### 四、isDestructive() とは\n\nClaude Code では `isDestructive`(`Tool.ts:405-406`)は**純粋に UI 表示用** — ツール一覧に `[destructive]` ラベルを表示するだけ。権限決定には参加しない。デフォルトではすべてのツールが `false` を返す。ExitWorktree(remove 時)と MCP ツール(`annotations.destructiveHint` に依存)のみがオーバーライドする。\n\n### 五、YoloClassifier(自動承認)\n\nClaude Code の auto モードでは、毎回ダイアログを表示するわけではない。`classifyYoloAction`(`utils/permissions/yoloClassifier.ts:1012`)はツール呼び出し + 会話コンテキストを分類器 LLM に送って安全性を判断する。まず acceptEdits モードのシミュレーションを試み(`permissions.ts:620-656`、acceptEdits が許可すれば → 自動承認)、次にセーフツールホワイトリストを確認し(`permissions.ts:658-686`)、最後に分類器を呼び出す。分類器が連続して拒否しすぎた場合 → 手動承認にフォールバック。\n\n### 六、権限バブリング\n\nサブ Agent(AgentTool 経由でフォークされたもの)の `permissionMode` は `'bubble'` に設定される(`forkSubagent.ts:50`)。これは権限ダイアログが**親 Agent のターミナルにバブルアップ**することを意味する。サブ Agent で黙って拒否されるのではない。Bash 分類器はこの過程で引き続き実行され — 権限ダイアログを表示しつつ、バックグラウンドで自動承認可能か判断する。\n\n### 教育版の単純化は意図的\n\n- 多段階パイプライン → 3 ゲート:概念数が減り、理解しやすい\n- 8 ルールソース → 1 つのローカル DENY_LIST:概念量を制御可能\n- isDestructive → 省略(教育版には UI レイヤーがなく、Claude Code でも権限決定には参加しない)\n- YoloClassifier → 省略(追加の LLM 呼び出しとテレメトリに依存)\n- 権限バブリング → 省略(s15 でマルチ Agent を扱う)\n\n
\n\n\n" }, { "version": "s04", "locale": "en", "title": "s04: Hooks — Hang on the Loop, Don't Write into It", - "content": "# s04: Hooks — Hang on the Loop, Don't Write into It\n\ns01 → s02 → s03 → `s04` → [s05](/en/s05) → s06 → ... → s20\n\n> *\"Hang on the loop, don't write into it\"* — Hooks inject extension logic before and after tool execution.\n>\n> **Harness Layer**: Hooks — Extension points that don't invade the loop.\n\n---\n\n## The Problem\n\nThe s03 Agent has permission checks. But every new check, \"log every bash call\", \"auto git add after writes\", requires modifying the `agent_loop` function.\n\nThe loop quickly becomes this:\n\n```python\ndef agent_loop(messages):\n while True:\n # ... LLM call ...\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n log_to_file(block) # added a line\n check_permission(block) # added a line\n notify_slack(block) # added another line\n output = execute(block)\n auto_git_add(block) # yet another line\n # ... the loop is unrecognizable\n```\n\nWhat you want to extend is the Agent's behavior, but what you're modifying is the loop itself. The loop should be a stable core; extensions should hang on the outside.\n\n---\n\n## The Solution\n\n![Hooks Overview](/course-assets/s04_hooks/hooks-overview.en.svg)\n\nThe s03 loop and permission logic are fully preserved. The only change is moving `check_permission()` from inside the loop body onto a hook. The loop no longer directly calls any check function. Instead it calls `trigger_hooks(\"PreToolUse\", block)`, and the registry decides what to run.\n\nFour events, covering a complete agent cycle:\n\n| Event | Trigger Timing | Typical Use |\n|-------|---------------|-------------|\n| UserPromptSubmit | After user input, before entering LLM | Input validation, context injection |\n| PreToolUse | Before tool execution | Permission checks, logging |\n| PostToolUse | After tool execution | Side effects (auto git add etc.), output checking |\n| Stop | When the loop is about to exit | Cleanup (CC also supports force continuation) |\n\nExtensions are added via `register_hook()`. The loop only calls `trigger_hooks()`.\n\n---\n\n## How It Works\n\n**Hook registry**: a dict mapping event names to callback lists.\n\n```python\nHOOKS = {\n \"UserPromptSubmit\": [],\n \"PreToolUse\": [],\n \"PostToolUse\": [],\n \"Stop\": [],\n}\n\ndef register_hook(event: str, callback):\n HOOKS[event].append(callback)\n\ndef trigger_hooks(event: str, *args):\n for callback in HOOKS[event]:\n result = callback(*args)\n if result is not None: # return value ≠ None → hook says \"stop\"\n return result\n return None\n```\n\nIn the teaching version, PreToolUse returning non-None means block execution; Stop returning non-None means force continuation. UserPromptSubmit and PostToolUse return values are unused.\n\n**UserPromptSubmit**, triggers after user input, before entering the LLM. CC can intercept or modify input; the teaching version only logs:\n\n```python\ndef context_inject_hook(query: str) -> str | None:\n \"\"\"Inject current working directory info into every prompt.\"\"\"\n print(f\"\\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\\033[0m\")\n return None # return None = no modification, let prompt through\n\nregister_hook(\"UserPromptSubmit\", context_inject_hook)\n```\n\nIn the main loop, triggered right after user input:\n\n```python\nquery = input(\"s04 >> \")\ntrigger_hooks(\"UserPromptSubmit\", query) # ← before entering LLM\nhistory.append({\"role\": \"user\", \"content\": query})\nagent_loop(history)\n```\n\n**PreToolUse / PostToolUse**, hooks before and after tool execution. s03's permission check logic is now wrapped as a PreToolUse hook, plus a logging hook and a large-output reminder:\n\n```python\n# PreToolUse: permission check (s03 logic, moved from loop to hook)\ndef permission_hook(block):\n if block.name == \"bash\":\n for pattern in DENY_LIST:\n if pattern in block.input.get(\"command\", \"\"):\n return \"Permission denied by deny list\"\n if block.name in (\"write_file\", \"edit_file\"):\n path = block.input.get(\"path\", \"\")\n if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n return None\n\n# PreToolUse: logging\ndef log_hook(block):\n print(f\"[HOOK] {block.name}(...)\")\n\n# PostToolUse: large output reminder\ndef large_output_hook(block, output):\n if len(str(output)) > 100000:\n print(f\"[HOOK] ⚠ Large output from {block.name}\")\n\nregister_hook(\"PreToolUse\", permission_hook)\nregister_hook(\"PreToolUse\", log_hook)\nregister_hook(\"PostToolUse\", large_output_hook)\n```\n\n**Stop**, triggers when the loop is about to exit (`stop_reason != \"tool_use\"`). The teaching version prints a cleanup summary:\n\n```python\ndef summary_hook(messages: list) -> str | None:\n \"\"\"Print a summary when the loop is about to stop.\"\"\"\n tool_count = sum(1 for m in messages\n for b in (m.get(\"content\") if isinstance(m.get(\"content\"), list) else [])\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\")\n print(f\"\\033[90m[HOOK] Stop: session used {tool_count} tool calls\\033[0m\")\n return None # return None = allow stop, return string = force continuation\n\nregister_hook(\"Stop\", summary_hook)\n```\n\nIn agent_loop, triggered before exit:\n\n```python\nif response.stop_reason != \"tool_use\":\n force = trigger_hooks(\"Stop\", messages) # ← before exiting\n if force:\n # hook returned a message → inject it and continue\n messages.append({\"role\": \"user\", \"content\": force})\n continue\n return\n```\n\n**Only one change in the loop**: s03 directly called `check_permission(block)`, s04 replaces it with `trigger_hooks(\"PreToolUse\", block)`:\n\n```python\nfor block in response.content:\n if block.type != \"tool_use\":\n continue\n\n # s03: if not check_permission(block): ...\n # s04: hooks replace hardcoding\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": str(blocked)})\n continue\n\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n\n trigger_hooks(\"PostToolUse\", block, output)\n\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": output})\n```\n\nFour hooks cover the critical nodes of the agent cycle: input → before execution → after execution → exit. The loop only calls trigger_hooks(); all logic lives in hook callbacks.\n\n---\n\n## Changes from s03\n\n| Component | Before (s03) | After (s04) |\n|-----------|-------------|-------------|\n| Extension method | check_permission() hardcoded in the loop | HOOKS registry + trigger_hooks() |\n| New functions | — | register_hook, trigger_hooks |\n| Hook callbacks | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |\n| Loop | Directly calls check_permission() | Calls trigger_hooks(\"PreToolUse\", ...) |\n| Exit control | None | trigger_hooks(\"Stop\", ...) can prevent exit |\n| Input interception | None | trigger_hooks(\"UserPromptSubmit\", ...) can inject context |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s04_hooks/code.py\n```\n\nTry these prompts:\n\n1. `Read the file README.md` (should pass directly, observe hook logs)\n2. `Create a file called test.txt` (after creation, observe if PostToolUse fires)\n3. `Delete all temporary files in /tmp` (bash + rm triggers permission hook)\n\nWhat to watch for: Before each tool execution, does the `[HOOK]` log appear? When permission is denied, was it intercepted by a hook or hardcoded in the loop?\n\n---\n\n## What's Next\n\nThe Agent can now safely execute operations. But does it ever stop to think \"what should I do first, and what next?\" Given a complex task, does it jump straight in, or plan first?\n\n→ s05 TodoWrite: Give the Agent a planning tool. Make a list first, then execute.\n\n
\nDive into CC Source Code\n\n> The following is based on a complete analysis of CC source code `toolHooks.ts` (650 lines), `hooks.ts`, `stopHooks.ts`, and `coreTypes.ts`.\n\n### 1. Hook Events: Not Just 4, but 27\n\nThe teaching version covers only PreToolUse and PostToolUse. CC actually has 27 hook events (`coreTypes.ts:25-53`):\n\n| Category | Events |\n|----------|--------|\n| Tool-related | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |\n| Session-related | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |\n| User interaction | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |\n| Sub-agents | `SubagentStart`, `SubagentStop` |\n| Compaction-related | `PreCompact`, `PostCompact` |\n| Team-related | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |\n| Other | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |\n\nThe teaching version covers only 4 core events (UserPromptSubmit, PreToolUse, PostToolUse, Stop) because they cover every critical node of a complete agent cycle. The other 23 follow the same pattern.\n\n### 2. HookResult Common Fields\n\nCC's `HookResult` (`types/hooks.ts:260-275`) has 14 fields. Common ones:\n\n| Field | Type | Purpose |\n|-------|------|---------|\n| `message` | Message | Optional UI message |\n| `blockingError` | HookBlockingError | Blocking error → injected into conversation for model self-correction |\n| `outcome` | success/blocking/non_blocking_error/cancelled | Execution result |\n| `preventContinuation` | boolean | Prevent subsequent execution |\n| `stopReason` | string | Stop reason description |\n| `permissionBehavior` | allow/deny/ask/passthrough | Hook returns permission decision |\n| `updatedInput` | Record | Modify tool input |\n| `additionalContext` | string | Additional context |\n| `updatedMCPToolOutput` | unknown | MCP tool output modification |\n\n### 3. Key Invariant: Hook 'allow' Cannot Bypass deny/ask Rules\n\nThis is the most important security design in CC's permission system (`toolHooks.ts:325-331`): **when a hook returns allow, it still checks settings.json deny/ask rules.** Even if the user's hook script says \"allow\", if the tool is disabled in settings.json, the operation is still blocked.\n\nThe teaching version doesn't have this layer; hooks returning non-None directly interrupt. This is sufficient for teaching, but would create a security vulnerability in production.\n\n### 4. stopHookActive Mechanism\n\nCC's Stop hooks have an infinite-loop prevention mechanism (`query.ts:212,1300`): the `stopHookActive` state field. When stop hooks produce a blockingError, the loop re-enters with `stopHookActive: true`. Subsequent iterations see this flag and don't trigger stop hooks again. This prevents a never-stopping bug: model self-corrects → stop hook errors again → model self-corrects again → stop hook errors again...\n\n### 5. hook_stopped_continuation\n\nWhen PostToolUse hooks return `preventContinuation: true`, a `hook_stopped_continuation` attachment is produced (`toolHooks.ts:117-130`). query.ts (L1388-1393) detects it and sets `shouldPreventContinuation = true`, causing the loop to exit. This is the mechanism for \"hooks gracefully shut down the Agent\" — not a crash, but a completion.\n\n### Teaching Version Simplifications Are Intentional\n\n- 27 events → 4 (UserPromptSubmit/PreToolUse/PostToolUse/Stop): covers agent cycle critical nodes\n- 14 fields → simple return values (None = continue, non-None = interrupt/continue): minimal cognitive load\n- Hook allow vs deny/ask invariant → omitted: teaching version has no settings.json layer\n- stopHookActive → omitted: teaching version Stop hook only does simple continuation, no infinite-loop prevention needed\n\n
\n\n\n" + "content": "# s04: Hooks — Hang on the Loop, Don't Write into It\n\ns01 → s02 → s03 → `s04` → [s05](/en/s05) → s06 → ... → s20\n\n> *\"Hang on the loop, don't write into it\"* — Hooks inject extension logic before and after tool execution.\n>\n> **Harness Layer**: Hooks, extension points that don't invade the loop.\n\n---\n\n## The Problem\n\nThe s03 Agent has permission checks. But every new check, \"log every bash call\", \"auto git add after writes\", requires modifying the `agent_loop` function.\n\nThe loop quickly becomes this:\n\n```python\ndef agent_loop(messages):\n while True:\n # ... LLM call ...\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n log_to_file(block) # added a line\n check_permission(block) # added a line\n notify_slack(block) # added another line\n output = execute(block)\n auto_git_add(block) # yet another line\n # ... the loop is unrecognizable\n```\n\nWhat you want to extend is the Agent's behavior, but what you're modifying is the loop itself. The loop should be a stable core; extensions should hang on the outside.\n\n---\n\n## The Solution\n\n![Hooks Overview](/course-assets/s04_hooks/hooks-overview.svg)\n\nThe s03 loop and permission logic are fully preserved. The only change is moving `check_permission()` from inside the loop body onto a hook. The loop no longer directly calls any check function. Instead it calls `trigger_hooks(\"PreToolUse\", block)`, and the registry decides what to run.\n\nFour events, covering a complete agent cycle:\n\n| Event | Trigger Timing | Typical Use |\n|-------|---------------|-------------|\n| UserPromptSubmit | After user input, before entering LLM | Input validation, context injection |\n| PreToolUse | Before tool execution | Permission checks, logging |\n| PostToolUse | After tool execution | Side effects (auto git add etc.), output checking |\n| Stop | When the loop is about to exit | Cleanup (Claude Code also supports force continuation) |\n\nExtensions are added via `register_hook()`. The loop only calls `trigger_hooks()`.\n\n---\n\n## How It Works\n\n**Hook registry**: a dict mapping event names to callback lists.\n\n```python\nHOOKS = {\n \"UserPromptSubmit\": [],\n \"PreToolUse\": [],\n \"PostToolUse\": [],\n \"Stop\": [],\n}\n\ndef register_hook(event: str, callback):\n HOOKS[event].append(callback)\n\ndef trigger_hooks(event: str, *args):\n for callback in HOOKS[event]:\n result = callback(*args)\n if result is not None: # return value ≠ None → hook says \"stop\"\n return result\n return None\n```\n\nIn the teaching version, PreToolUse returning non-None means block execution; Stop returning non-None means force continuation. UserPromptSubmit and PostToolUse return values are unused.\n\n**UserPromptSubmit**, triggers after user input, before entering the LLM. Claude Code can intercept or modify input; the teaching version only logs:\n\n```python\ndef context_inject_hook(query: str) -> str | None:\n \"\"\"Inject current working directory info into every prompt.\"\"\"\n print(f\"\\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\\033[0m\")\n return None # return None = no modification, let prompt through\n\nregister_hook(\"UserPromptSubmit\", context_inject_hook)\n```\n\nIn the main loop, triggered right after user input:\n\n```python\nquery = input(\"s04 >> \")\ntrigger_hooks(\"UserPromptSubmit\", query) # ← before entering LLM\nhistory.append({\"role\": \"user\", \"content\": query})\nagent_loop(history)\n```\n\n**PreToolUse / PostToolUse**, hooks before and after tool execution. s03's permission check logic is now wrapped as a PreToolUse hook, plus a logging hook and a large-output reminder:\n\n```python\n# PreToolUse: permission check (s03 logic, moved from loop to hook)\ndef permission_hook(block):\n if block.name == \"bash\":\n for pattern in DENY_LIST:\n if pattern in block.input.get(\"command\", \"\"):\n return \"Permission denied by deny list\"\n if block.name in (\"write_file\", \"edit_file\"):\n path = block.input.get(\"path\", \"\")\n if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n return None\n\n# PreToolUse: logging\ndef log_hook(block):\n print(f\"[HOOK] {block.name}(...)\")\n\n# PostToolUse: large output reminder\ndef large_output_hook(block, output):\n if len(str(output)) > 100000:\n print(f\"[HOOK] ⚠ Large output from {block.name}\")\n\nregister_hook(\"PreToolUse\", permission_hook)\nregister_hook(\"PreToolUse\", log_hook)\nregister_hook(\"PostToolUse\", large_output_hook)\n```\n\n**Stop**, triggers when the loop is about to exit (`stop_reason != \"tool_use\"`). The teaching version prints a cleanup summary:\n\n```python\ndef summary_hook(messages: list) -> str | None:\n \"\"\"Print a summary when the loop is about to stop.\"\"\"\n tool_count = sum(1 for m in messages\n for b in (m.get(\"content\") if isinstance(m.get(\"content\"), list) else [])\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\")\n print(f\"\\033[90m[HOOK] Stop: session used {tool_count} tool calls\\033[0m\")\n return None # return None = allow stop, return string = force continuation\n\nregister_hook(\"Stop\", summary_hook)\n```\n\nIn agent_loop, triggered before exit:\n\n```python\nif response.stop_reason != \"tool_use\":\n force = trigger_hooks(\"Stop\", messages) # ← before exiting\n if force:\n # hook returned a message → inject it and continue\n messages.append({\"role\": \"user\", \"content\": force})\n continue\n return\n```\n\n**Only one change in the loop**: s03 directly called `check_permission(block)`, s04 replaces it with `trigger_hooks(\"PreToolUse\", block)`:\n\n```python\nfor block in response.content:\n if block.type != \"tool_use\":\n continue\n\n # s03: if not check_permission(block): ...\n # s04: hooks replace hardcoding\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": str(blocked)})\n continue\n\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n\n trigger_hooks(\"PostToolUse\", block, output)\n\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": output})\n```\n\nThat covers all hook call sites in agent_loop.\n\n---\n\n## Changes from s03\n\n| Component | Before (s03) | After (s04) |\n|-----------|-------------|-------------|\n| Extension method | check_permission() hardcoded in the loop | HOOKS registry + trigger_hooks() |\n| New functions | — | register_hook, trigger_hooks |\n| Hook callbacks | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |\n| Loop | Directly calls check_permission() | Calls trigger_hooks(\"PreToolUse\", ...) |\n| Exit control | None | trigger_hooks(\"Stop\", ...) can prevent exit |\n| Input interception | None | trigger_hooks(\"UserPromptSubmit\", ...) can inject context |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s04_hooks/code.py\n```\n\nTry these prompts:\n\n1. `Read the file README.md` (should pass directly, observe hook logs)\n2. `Create a file called test.txt` (after creation, observe if PostToolUse fires)\n3. `Delete all temporary files in /tmp` (bash + rm triggers permission hook)\n\nWhat to watch for: Before each tool execution, does the `[HOOK]` log appear? When permission is denied, was it intercepted by a hook or hardcoded in the loop?\n\n---\n\n## What's Next\n\nThe Agent can now safely execute operations, but it has no concept of execution order. Given a complex task, it executes tools as they come, without planning ahead.\n\n→ s05 TodoWrite: Give the Agent a planning tool. Make a list first, then execute.\n\n
\nDive into Claude Code Source Code\n\n> The following is based on a complete analysis of Claude Code source code `toolHooks.ts` (650 lines), `hooks.ts`, `stopHooks.ts`, and `coreTypes.ts`.\n\n### 1. Hook Events: Not Just 4, but 27\n\nThe teaching version covers only PreToolUse and PostToolUse. Claude Code actually has 27 hook events (`coreTypes.ts:25-53`):\n\n| Category | Events |\n|----------|--------|\n| Tool-related | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |\n| Session-related | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |\n| User interaction | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |\n| Sub-agents | `SubagentStart`, `SubagentStop` |\n| Compaction-related | `PreCompact`, `PostCompact` |\n| Team-related | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |\n| Other | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |\n\nThe teaching version covers only 4 core events (UserPromptSubmit, PreToolUse, PostToolUse, Stop) because they cover every critical node of a complete agent cycle. The other 23 follow the same pattern.\n\n### 2. HookResult Common Fields\n\nClaude Code's `HookResult` (`types/hooks.ts:260-275`) has 14 fields. Common ones:\n\n| Field | Type | Purpose |\n|-------|------|---------|\n| `message` | Message | Optional UI message |\n| `blockingError` | HookBlockingError | Blocking error → injected into conversation for model self-correction |\n| `outcome` | success/blocking/non_blocking_error/cancelled | Execution result |\n| `preventContinuation` | boolean | Prevent subsequent execution |\n| `stopReason` | string | Stop reason description |\n| `permissionBehavior` | allow/deny/ask/passthrough | Hook returns permission decision |\n| `updatedInput` | Record | Modify tool input |\n| `additionalContext` | string | Additional context |\n| `updatedMCPToolOutput` | unknown | MCP tool output modification |\n\n### 3. Key Invariant: Hook 'allow' Cannot Bypass deny/ask Rules\n\nThis is the most important security design in Claude Code's permission system (`toolHooks.ts:325-331`): **when a hook returns allow, it still checks settings.json deny/ask rules.** Even if the user's hook script says \"allow\", if the tool is disabled in settings.json, the operation is still blocked.\n\nThe teaching version doesn't have this layer; hooks returning non-None directly interrupt. This is sufficient for teaching, but would create a security vulnerability in production.\n\n### 4. stopHookActive Mechanism\n\nClaude Code's Stop hooks have an infinite-loop prevention mechanism (`query.ts:212,1300`): the `stopHookActive` state field. When stop hooks produce a blockingError, the loop re-enters with `stopHookActive: true`. Subsequent iterations see this flag and don't trigger stop hooks again. This prevents a never-stopping bug: model self-corrects → stop hook errors again → model self-corrects again → stop hook errors again...\n\n### 5. hook_stopped_continuation\n\nWhen PostToolUse hooks return `preventContinuation: true`, a `hook_stopped_continuation` attachment is produced (`toolHooks.ts:117-130`). query.ts (L1388-1393) detects it and sets `shouldPreventContinuation = true`, causing the loop to exit. This is the mechanism for \"hooks gracefully shut down the Agent\" — not a crash, but a completion.\n\n### Teaching Version Simplifications Are Intentional\n\n- 27 events → 4 (UserPromptSubmit/PreToolUse/PostToolUse/Stop): covers agent cycle critical nodes\n- 14 fields → simple return values (None = continue, non-None = interrupt/continue): minimal cognitive load\n- Hook allow vs deny/ask invariant → omitted: teaching version has no settings.json layer\n- stopHookActive → omitted: teaching version Stop hook only does simple continuation, no infinite-loop prevention needed\n\n
\n\n\n" }, { "version": "s04", "locale": "zh", "title": "s04: Hooks — 挂在循环上,不写进循环里", - "content": "# s04: Hooks — 挂在循环上,不写进循环里\n\ns01 → s02 → s03 → `s04` → [s05](/zh/s05) → s06 → ... → s20\n\n> *\"挂在循环上, 不写进循环里\"* — hook 在工具执行前后注入扩展逻辑。\n>\n> **Harness 层**: hook — 扩展点不侵入循环。\n\n---\n\n## 问题\n\ns03 的 Agent 有权限检查了。但每次加一个新检查,比如\"记录每次 bash 调用\"、\"操作后自动 git add\",都要修改 `agent_loop` 函数。\n\n循环很快就变成了这样:\n\n```python\ndef agent_loop(messages):\n while True:\n # ... LLM call ...\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n log_to_file(block) # 加一行\n check_permission(block) # 加一行\n notify_slack(block) # 又加一行\n output = execute(block)\n auto_git_add(block) # 再加一行\n # ... 很快循环就认不出来了\n```\n\n你想扩展的是 Agent 的行为,但你改的却是循环本身。循环应该是一个稳定的核心,扩展应该挂在外面。\n\n---\n\n## 解决方案\n\n![Hooks Overview](/course-assets/s04_hooks/hooks-overview.svg)\n\ns03 的循环和权限逻辑完全保留。唯一的变动是把 `check_permission()` 从循环体内移到了 hook 上,循环不再直接调用任何检查函数,改为 `trigger_hooks(\"PreToolUse\", block)`,由注册表决定跑什么。\n\n四个事件,覆盖一个完整的 agent cycle:\n\n| 事件 | 触发时机 | 典型用途 |\n|------|---------|---------|\n| UserPromptSubmit | 用户输入提交后、进入 LLM 前 | 输入验证、注入上下文 |\n| PreToolUse | 工具执行前 | 权限检查、日志记录 |\n| PostToolUse | 工具执行后 | 副作用(自动 git add 等)、输出检查 |\n| Stop | 循环即将退出时 | 收尾清理(CC 还支持强制续跑) |\n\n扩展通过 `register_hook()` 添加,循环只调用 `trigger_hooks()`。\n\n---\n\n## 工作原理\n\n**hook 注册表**:一个字典,事件名映射到回调列表。\n\n```python\nHOOKS = {\n \"UserPromptSubmit\": [],\n \"PreToolUse\": [],\n \"PostToolUse\": [],\n \"Stop\": [],\n}\n\ndef register_hook(event: str, callback):\n HOOKS[event].append(callback)\n\ndef trigger_hooks(event: str, *args):\n for callback in HOOKS[event]:\n result = callback(*args)\n if result is not None: # 返回值 ≠ None → hook 说\"停\"\n return result\n return None\n```\n\n教学版中,PreToolUse 的非 None 返回值会阻止本次工具执行,Stop 的非 None 返回值会强制续跑。UserPromptSubmit 和 PostToolUse 的返回值未被使用。\n\n**UserPromptSubmit**,用户输入提交后、进入 LLM 前触发。CC 中可以拦截或修改输入,教学版只做日志演示:\n\n```python\ndef context_inject_hook(query: str) -> str | None:\n \"\"\"Inject current working directory info into every prompt.\"\"\"\n print(f\"\\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\\033[0m\")\n return None # return None = no modification, let prompt through\n\nregister_hook(\"UserPromptSubmit\", context_inject_hook)\n```\n\n在主循环中,用户输入后立即触发:\n\n```python\nquery = input(\"s04 >> \")\ntrigger_hooks(\"UserPromptSubmit\", query) # ← 进入 LLM 之前\nhistory.append({\"role\": \"user\", \"content\": query})\nagent_loop(history)\n```\n\n**PreToolUse / PostToolUse**,工具执行前后的 hook。s03 的权限检查逻辑现在包装成 PreToolUse hook,再加一个日志 hook 和一个大输出提醒:\n\n```python\n# PreToolUse: 权限检查(s03 的逻辑,从循环移到 hook)\ndef permission_hook(block):\n if block.name == \"bash\":\n for pattern in DENY_LIST:\n if pattern in block.input.get(\"command\", \"\"):\n return \"Permission denied by deny list\"\n if block.name in (\"write_file\", \"edit_file\"):\n path = block.input.get(\"path\", \"\")\n if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n return None\n\n# PreToolUse: 日志\ndef log_hook(block):\n print(f\"[HOOK] {block.name}(...)\")\n\n# PostToolUse: 大文件提醒\ndef large_output_hook(block, output):\n if len(str(output)) > 100000:\n print(f\"[HOOK] ⚠ Large output from {block.name}\")\n\nregister_hook(\"PreToolUse\", permission_hook)\nregister_hook(\"PreToolUse\", log_hook)\nregister_hook(\"PostToolUse\", large_output_hook)\n```\n\n**Stop**,循环即将退出时触发(`stop_reason != \"tool_use\"`)。教学版用于打印收尾统计:\n\n```python\ndef summary_hook(messages: list) -> str | None:\n \"\"\"Print a summary when the loop is about to stop.\"\"\"\n tool_count = sum(1 for m in messages\n for b in (m.get(\"content\") if isinstance(m.get(\"content\"), list) else [])\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\")\n print(f\"\\033[90m[HOOK] Stop: session used {tool_count} tool calls\\033[0m\")\n return None # return None = allow stop, return string = force continuation\n\nregister_hook(\"Stop\", summary_hook)\n```\n\n在 agent_loop 中,退出前触发:\n\n```python\nif response.stop_reason != \"tool_use\":\n force = trigger_hooks(\"Stop\", messages) # ← 退出之前\n if force:\n # hook returned a message → inject it and continue\n messages.append({\"role\": \"user\", \"content\": force})\n continue\n return\n```\n\n**循环里只改了一处**:s03 直接调用 `check_permission(block)`,s04 改为 `trigger_hooks(\"PreToolUse\", block)`:\n\n```python\nfor block in response.content:\n if block.type != \"tool_use\":\n continue\n\n # s03: if not check_permission(block): ...\n # s04: hook 替代硬编码\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": str(blocked)})\n continue\n\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n\n trigger_hooks(\"PostToolUse\", block, output)\n\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": output})\n```\n\n四个 hook 覆盖了 agent cycle 的关键节点:输入→执行前→执行后→退出。循环只负责调用 trigger_hooks(),具体逻辑全在 hook 回调里。\n\n---\n\n## 相对 s03 的变更\n\n| 组件 | 之前 (s03) | 之后 (s04) |\n|------|-----------|-----------|\n| 扩展方式 | check_permission() 硬编码在循环里 | HOOKS 注册表 + trigger_hooks() |\n| 新函数 | — | register_hook, trigger_hooks |\n| hook 回调 | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |\n| 循环 | 直接调用 check_permission() | 调用 trigger_hooks(\"PreToolUse\", ...) |\n| 退出控制 | 无 | trigger_hooks(\"Stop\", ...) 可阻止退出 |\n| 输入拦截 | 无 | trigger_hooks(\"UserPromptSubmit\", ...) 可注入上下文 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s04_hooks/code.py\n```\n\n试试这些 prompt:\n\n1. `Read the file README.md`(应该直接通过,观察 hook 日志)\n2. `Create a file called test.txt`(通过后观察 PostToolUse 是否触发)\n3. `Delete all temporary files in /tmp`(bash + rm 触发权限 hook)\n\n观察重点:每次工具执行前,是否出现了 `[HOOK]` 日志?权限被拒时,是 hook 拦截的还是循环里硬编码的?\n\n---\n\n## 接下来\n\nAgent 现在能安全执行操作了。但它有没有停下来想过\"我应该先做什么,再做什么\"?给它一个复杂任务,它是一上来就动手,还是先列个计划?\n\ns05 TodoWrite → 给 Agent 一个计划工具。先列清单,再做。\n\n
\n深入 CC 源码\n\n> 以下基于 CC 源码 `toolHooks.ts`(650 行)、`hooks.ts`、`stopHooks.ts`、`coreTypes.ts` 的完整分析。\n\n### 一、Hook 事件:不止这 4 个,而是 27 个\n\n教学版只讲了 PreToolUse 和 PostToolUse。CC 实际有 27 个 hook 事件(`coreTypes.ts:25-53`):\n\n| 类别 | 事件 |\n|------|------|\n| 工具相关 | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |\n| 会话相关 | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |\n| 用户交互 | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |\n| 子 Agent | `SubagentStart`, `SubagentStop` |\n| 压缩相关 | `PreCompact`, `PostCompact` |\n| 团队相关 | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |\n| 其他 | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |\n\n教学版只讲 4 个核心事件(UserPromptSubmit、PreToolUse、PostToolUse、Stop),因为它们覆盖了一个完整 agent cycle 的关键节点。其他 23 个都是同样的模式。\n\n### 二、HookResult 常用字段摘录\n\nCC 的 `HookResult`(`types/hooks.ts:260-275`)有 14 个字段,以下是常用字段:\n\n| 字段 | 类型 | 用途 |\n|------|------|------|\n| `message` | Message | 可选 UI 消息 |\n| `blockingError` | HookBlockingError | 阻塞错误 → 注入对话让模型自纠 |\n| `outcome` | success/blocking/non_blocking_error/cancelled | 执行结果 |\n| `preventContinuation` | boolean | 阻止后续执行 |\n| `stopReason` | string | 停止原因描述 |\n| `permissionBehavior` | allow/deny/ask/passthrough | hook 返回权限决策 |\n| `updatedInput` | Record | 修改工具输入 |\n| `additionalContext` | string | 附加上下文 |\n| `updatedMCPToolOutput` | unknown | MCP 工具输出修改 |\n\n### 三、关键不变式:Hook 'allow' 不能绕过 deny/ask 规则\n\n这是 CC 权限系统最重要的安全设计(`toolHooks.ts:325-331`):**hook 返回 allow 时,仍然要检查 settings.json 的 deny/ask 规则**。即使用户的 hook 脚本说\"允许\",如果在 settings.json 中禁用了这个工具,操作仍然会被阻止。\n\n教学版没有这个层次,只把 PreToolUse 的非 None 返回值解释为阻止本次工具执行。这在教学场景中够了,但在生产环境中会形成安全漏洞。\n\n### 四、stopHookActive 机制\n\nCC 的 Stop hooks 有一个防无限循环机制(`query.ts:212,1300`):`stopHookActive` 状态字段。当 stop hooks 产生 blockingError 时,循环带 `stopHookActive: true` 重入下一轮。后续迭代中 stop hooks 看到这个标志就不会再次触发。这防止了一个永不停机的 bug:模型自纠后 stop hook 再次报错 → 模型再自纠 → stop hook 再报错...\n\n### 五、hook_stopped_continuation\n\nPostToolUse hooks 返回 `preventContinuation: true` 时,会产生一个 `hook_stopped_continuation` 附件(`toolHooks.ts:117-130`)。query.ts(L1388-1393)检测到后设置 `shouldPreventContinuation = true`,循环退出。这是 \"hook 优雅地让 Agent 停机\" 的机制,不是崩溃,是完成。\n\n### 教学版的简化是刻意的\n\n- 27 个事件 → 4 个(UserPromptSubmit/PreToolUse/PostToolUse/Stop):覆盖 agent cycle 关键节点\n- 14 个字段 → 简单的返回值(None = 继续,非 None = 阻止/续跑):心智负担降到最低\n- Hook allow vs deny/ask 不变式 → 省略:教学版没有 settings.json 层\n- stopHookActive → 省略:教学版 Stop hook 只做简单续跑,不涉及防无限循环机制\n\n
\n\n\n" + "content": "# s04: Hooks — 挂在循环上,不写进循环里\n\ns01 → s02 → s03 → `s04` → [s05](/zh/s05) → s06 → ... → s20\n\n> *\"挂在循环上, 不写进循环里\"* — hook 在工具执行前后注入扩展逻辑。\n>\n> **Harness 层**: hook,扩展点不侵入循环。\n\n---\n\n## 问题\n\ns03 的 Agent 有权限检查了。但每次加一个新检查,比如\"记录每次 bash 调用\"、\"操作后自动 git add\",都要修改 `agent_loop` 函数。\n\n循环很快就变成了这样:\n\n```python\ndef agent_loop(messages):\n while True:\n # ... LLM call ...\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n log_to_file(block) # 加一行\n check_permission(block) # 加一行\n notify_slack(block) # 又加一行\n output = execute(block)\n auto_git_add(block) # 再加一行\n # ... 很快循环就认不出来了\n```\n\n你想扩展的是 Agent 的行为,但你改的却是循环本身。循环应该是一个稳定的核心,扩展应该挂在外面。\n\n---\n\n## 解决方案\n\n![Hooks Overview](/course-assets/s04_hooks/hooks-overview.svg)\n\ns03 的循环和权限逻辑完全保留。唯一的变动是把 `check_permission()` 从循环体内移到了 hook 上,循环不再直接调用任何检查函数,改为 `trigger_hooks(\"PreToolUse\", block)`,由注册表决定跑什么。\n\n四个事件,覆盖一个完整的 agent cycle:\n\n| 事件 | 触发时机 | 典型用途 |\n|------|---------|---------|\n| UserPromptSubmit | 用户输入提交后、进入 LLM 前 | 输入验证、注入上下文 |\n| PreToolUse | 工具执行前 | 权限检查、日志记录 |\n| PostToolUse | 工具执行后 | 副作用(自动 git add 等)、输出检查 |\n| Stop | 循环即将退出时 | 收尾清理(Claude Code 还支持强制续跑) |\n\n扩展通过 `register_hook()` 添加,循环只调用 `trigger_hooks()`。\n\n---\n\n## 工作原理\n\n**hook 注册表**:一个字典,事件名映射到回调列表。\n\n```python\nHOOKS = {\n \"UserPromptSubmit\": [],\n \"PreToolUse\": [],\n \"PostToolUse\": [],\n \"Stop\": [],\n}\n\ndef register_hook(event: str, callback):\n HOOKS[event].append(callback)\n\ndef trigger_hooks(event: str, *args):\n for callback in HOOKS[event]:\n result = callback(*args)\n if result is not None: # 返回值 ≠ None → hook 说\"停\"\n return result\n return None\n```\n\n教学版中,PreToolUse 的非 None 返回值会阻止本次工具执行,Stop 的非 None 返回值会强制续跑。UserPromptSubmit 和 PostToolUse 的返回值未被使用。\n\n**UserPromptSubmit**,用户输入提交后、进入 LLM 前触发。Claude Code 中可以拦截或修改输入,教学版只做日志演示:\n\n```python\ndef context_inject_hook(query: str) -> str | None:\n \"\"\"Inject current working directory info into every prompt.\"\"\"\n print(f\"\\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\\033[0m\")\n return None # return None = no modification, let prompt through\n\nregister_hook(\"UserPromptSubmit\", context_inject_hook)\n```\n\n在主循环中,用户输入后立即触发:\n\n```python\nquery = input(\"s04 >> \")\ntrigger_hooks(\"UserPromptSubmit\", query) # ← 进入 LLM 之前\nhistory.append({\"role\": \"user\", \"content\": query})\nagent_loop(history)\n```\n\n**PreToolUse / PostToolUse**,工具执行前后的 hook。s03 的权限检查逻辑现在包装成 PreToolUse hook,再加一个日志 hook 和一个大输出提醒:\n\n```python\n# PreToolUse: 权限检查(s03 的逻辑,从循环移到 hook)\ndef permission_hook(block):\n if block.name == \"bash\":\n for pattern in DENY_LIST:\n if pattern in block.input.get(\"command\", \"\"):\n return \"Permission denied by deny list\"\n if block.name in (\"write_file\", \"edit_file\"):\n path = block.input.get(\"path\", \"\")\n if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n return None\n\n# PreToolUse: 日志\ndef log_hook(block):\n print(f\"[HOOK] {block.name}(...)\")\n\n# PostToolUse: 大文件提醒\ndef large_output_hook(block, output):\n if len(str(output)) > 100000:\n print(f\"[HOOK] ⚠ Large output from {block.name}\")\n\nregister_hook(\"PreToolUse\", permission_hook)\nregister_hook(\"PreToolUse\", log_hook)\nregister_hook(\"PostToolUse\", large_output_hook)\n```\n\n**Stop**,循环即将退出时触发(`stop_reason != \"tool_use\"`)。教学版用于打印收尾统计:\n\n```python\ndef summary_hook(messages: list) -> str | None:\n \"\"\"Print a summary when the loop is about to stop.\"\"\"\n tool_count = sum(1 for m in messages\n for b in (m.get(\"content\") if isinstance(m.get(\"content\"), list) else [])\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\")\n print(f\"\\033[90m[HOOK] Stop: session used {tool_count} tool calls\\033[0m\")\n return None # return None = allow stop, return string = force continuation\n\nregister_hook(\"Stop\", summary_hook)\n```\n\n在 agent_loop 中,退出前触发:\n\n```python\nif response.stop_reason != \"tool_use\":\n force = trigger_hooks(\"Stop\", messages) # ← 退出之前\n if force:\n # hook returned a message → inject it and continue\n messages.append({\"role\": \"user\", \"content\": force})\n continue\n return\n```\n\n**循环里只改了一处**:s03 直接调用 `check_permission(block)`,s04 改为 `trigger_hooks(\"PreToolUse\", block)`:\n\n```python\nfor block in response.content:\n if block.type != \"tool_use\":\n continue\n\n # s03: if not check_permission(block): ...\n # s04: hook 替代硬编码\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": str(blocked)})\n continue\n\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n\n trigger_hooks(\"PostToolUse\", block, output)\n\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": output})\n```\n\n以上就是 agent_loop 中全部 hook 调用点。\n\n---\n\n## 相对 s03 的变更\n\n| 组件 | 之前 (s03) | 之后 (s04) |\n|------|-----------|-----------|\n| 扩展方式 | check_permission() 硬编码在循环里 | HOOKS 注册表 + trigger_hooks() |\n| 新函数 | — | register_hook, trigger_hooks |\n| hook 回调 | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |\n| 循环 | 直接调用 check_permission() | 调用 trigger_hooks(\"PreToolUse\", ...) |\n| 退出控制 | 无 | trigger_hooks(\"Stop\", ...) 可阻止退出 |\n| 输入拦截 | 无 | trigger_hooks(\"UserPromptSubmit\", ...) 可注入上下文 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s04_hooks/code.py\n```\n\n试试这些 prompt:\n\n1. `Read the file README.md`(应该直接通过,观察 hook 日志)\n2. `Create a file called test.txt`(通过后观察 PostToolUse 是否触发)\n3. `Delete all temporary files in /tmp`(bash + rm 触发权限 hook)\n\n观察重点:每次工具执行前,是否出现了 `[HOOK]` 日志?权限被拒时,是 hook 拦截的还是循环里硬编码的?\n\n---\n\n## 接下来\n\nAgent 能安全执行操作了,但还缺少执行顺序的概念。给它一个复杂任务,它会按收到的顺序逐个执行,不会先列计划。\n\ns05 TodoWrite → 给 Agent 一个计划工具。先列清单,再做。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `toolHooks.ts`(650 行)、`hooks.ts`、`stopHooks.ts`、`coreTypes.ts` 的完整分析。\n\n### 一、Hook 事件:不止这 4 个,而是 27 个\n\n教学版只讲了 PreToolUse 和 PostToolUse。Claude Code 实际有 27 个 hook 事件(`coreTypes.ts:25-53`):\n\n| 类别 | 事件 |\n|------|------|\n| 工具相关 | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |\n| 会话相关 | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |\n| 用户交互 | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |\n| 子 Agent | `SubagentStart`, `SubagentStop` |\n| 压缩相关 | `PreCompact`, `PostCompact` |\n| 团队相关 | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |\n| 其他 | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |\n\n教学版只讲 4 个核心事件(UserPromptSubmit、PreToolUse、PostToolUse、Stop),因为它们覆盖了一个完整 agent cycle 的关键节点。其他 23 个都是同样的模式。\n\n### 二、HookResult 常用字段摘录\n\nClaude Code 的 `HookResult`(`types/hooks.ts:260-275`)有 14 个字段,以下是常用字段:\n\n| 字段 | 类型 | 用途 |\n|------|------|------|\n| `message` | Message | 可选 UI 消息 |\n| `blockingError` | HookBlockingError | 阻塞错误 → 注入对话让模型自纠 |\n| `outcome` | success/blocking/non_blocking_error/cancelled | 执行结果 |\n| `preventContinuation` | boolean | 阻止后续执行 |\n| `stopReason` | string | 停止原因描述 |\n| `permissionBehavior` | allow/deny/ask/passthrough | hook 返回权限决策 |\n| `updatedInput` | Record | 修改工具输入 |\n| `additionalContext` | string | 附加上下文 |\n| `updatedMCPToolOutput` | unknown | MCP 工具输出修改 |\n\n### 三、关键不变式:Hook 'allow' 不能绕过 deny/ask 规则\n\n这是 Claude Code 权限系统最重要的安全设计(`toolHooks.ts:325-331`):**hook 返回 allow 时,仍然要检查 settings.json 的 deny/ask 规则**。即使用户的 hook 脚本说\"允许\",如果在 settings.json 中禁用了这个工具,操作仍然会被阻止。\n\n教学版没有这个层次,只把 PreToolUse 的非 None 返回值解释为阻止本次工具执行。这在教学场景中够了,但在生产环境中会形成安全漏洞。\n\n### 四、stopHookActive 机制\n\nClaude Code 的 Stop hooks 有一个防无限循环机制(`query.ts:212,1300`):`stopHookActive` 状态字段。当 stop hooks 产生 blockingError 时,循环带 `stopHookActive: true` 重入下一轮。后续迭代中 stop hooks 看到这个标志就不会再次触发。这防止了一个永不停机的 bug:模型自纠后 stop hook 再次报错 → 模型再自纠 → stop hook 再报错...\n\n### 五、hook_stopped_continuation\n\nPostToolUse hooks 返回 `preventContinuation: true` 时,会产生一个 `hook_stopped_continuation` 附件(`toolHooks.ts:117-130`)。query.ts(L1388-1393)检测到后设置 `shouldPreventContinuation = true`,循环退出。这是 \"hook 优雅地让 Agent 停机\" 的机制,不是崩溃,是完成。\n\n### 教学版的简化是刻意的\n\n- 27 个事件 → 4 个(UserPromptSubmit/PreToolUse/PostToolUse/Stop):覆盖 agent cycle 关键节点\n- 14 个字段 → 简单的返回值(None = 继续,非 None = 阻止/续跑):心智负担降到最低\n- Hook allow vs deny/ask 不变式 → 省略:教学版没有 settings.json 层\n- stopHookActive → 省略:教学版 Stop hook 只做简单续跑,不涉及防无限循环机制\n\n
\n\n\n" }, { "version": "s04", "locale": "ja", "title": "s04: Hooks — ループに掛ける、ループには書き込まない", - "content": "# s04: Hooks — ループに掛ける、ループには書き込まない\n\ns01 → s02 → s03 → `s04` → [s05](/ja/s05) → s06 → ... → s20\n\n> *\"ループに掛ける、ループには書き込まない\"* — フックがツール実行の前後に拡張ロジックを注入する。\n>\n> **Harness レイヤー**: フック — ループを侵襲しない拡張ポイント。\n\n---\n\n## 課題\n\ns03 の Agent には権限チェックがある。しかし新しいチェックを追加するたび、「bash 呼び出しを毎回ログに記録」「操作後に自動 git add」、`agent_loop` 関数を修正する必要がある。\n\nループはすぐにこうなる:\n\n```python\ndef agent_loop(messages):\n while True:\n # ... LLM call ...\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n log_to_file(block) # 一行追加\n check_permission(block) # 一行追加\n notify_slack(block) # さらに一行追加\n output = execute(block)\n auto_git_add(block) # さらに一行追加\n # ... もうループが見えない\n```\n\n拡張したいのは Agent の振る舞いなのに、変更しているのはループそのもの。ループは安定した核心であるべき。拡張は外側に掛ける。\n\n---\n\n## ソリューション\n\n![Hooks Overview](/course-assets/s04_hooks/hooks-overview.ja.svg)\n\ns03 のループと権限ロジックは完全に保持される。唯一の変更点は `check_permission()` をループ本体内からフックに移動したこと。ループはもうチェック関数を直接呼び出さず、代わりに `trigger_hooks(\"PreToolUse\", block)` を呼び、登録済みのフックが何を実行するかを決める。\n\n4 つのイベントで、完全な agent cycle をカバー:\n\n| イベント | 発火タイミング | 典型的な用途 |\n|----------|--------------|-------------|\n| UserPromptSubmit | ユーザー入力後、LLM に入る前 | 入力バリデーション、コンテキスト注入 |\n| PreToolUse | ツール実行前 | 権限チェック、ログ記録 |\n| PostToolUse | ツール実行後 | 副作用(自動 git add など)、出力チェック |\n| Stop | ループが終了する直前 | クリーンアップ(CC は強制続行もサポート) |\n\n拡張は `register_hook()` で追加する。ループは `trigger_hooks()` を呼ぶだけ。\n\n---\n\n## 仕組み\n\n**フック登録簿**:イベント名をコールバックリストにマッピングする辞書。\n\n```python\nHOOKS = {\n \"UserPromptSubmit\": [],\n \"PreToolUse\": [],\n \"PostToolUse\": [],\n \"Stop\": [],\n}\n\ndef register_hook(event: str, callback):\n HOOKS[event].append(callback)\n\ndef trigger_hooks(event: str, *args):\n for callback in HOOKS[event]:\n result = callback(*args)\n if result is not None: # 戻り値 ≠ None → フックが「止め」と指示\n return result\n return None\n```\n\n教学版では、PreToolUse の非 None 戻り値は実行阻止を意味し、Stop の非 None 戻り値は強制続行を意味する。UserPromptSubmit と PostToolUse の戻り値は未使用。\n\n**UserPromptSubmit**、ユーザー入力後、LLM に入る前に発火。CC では入力の横取りや変更が可能、教学版はログ出力のみ:\n\n```python\ndef context_inject_hook(query: str) -> str | None:\n \"\"\"Inject current working directory info into every prompt.\"\"\"\n print(f\"\\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\\033[0m\")\n return None # return None = 変更なし、プロンプトを通す\n\nregister_hook(\"UserPromptSubmit\", context_inject_hook)\n```\n\nメインループでは、ユーザー入力直後に発火:\n\n```python\nquery = input(\"s04 >> \")\ntrigger_hooks(\"UserPromptSubmit\", query) # ← LLM に入る前\nhistory.append({\"role\": \"user\", \"content\": query})\nagent_loop(history)\n```\n\n**PreToolUse / PostToolUse**、ツール実行の前後のフック。s03 の権限チェックロジックは PreToolUse フックに包まれ、さらにログフックと大出力リマインダーが追加される:\n\n```python\n# PreToolUse: 権限チェック(s03 のロジック、ループからフックに移動)\ndef permission_hook(block):\n if block.name == \"bash\":\n for pattern in DENY_LIST:\n if pattern in block.input.get(\"command\", \"\"):\n return \"Permission denied by deny list\"\n if block.name in (\"write_file\", \"edit_file\"):\n path = block.input.get(\"path\", \"\")\n if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n return None\n\n# PreToolUse: ログ\ndef log_hook(block):\n print(f\"[HOOK] {block.name}(...)\")\n\n# PostToolUse: 大ファイルリマインダー\ndef large_output_hook(block, output):\n if len(str(output)) > 100000:\n print(f\"[HOOK] ⚠ Large output from {block.name}\")\n\nregister_hook(\"PreToolUse\", permission_hook)\nregister_hook(\"PreToolUse\", log_hook)\nregister_hook(\"PostToolUse\", large_output_hook)\n```\n\n**Stop**、ループが終了する直前に発火(`stop_reason != \"tool_use\"`)。教学版ではクリーンアップ統計を印刷:\n\n```python\ndef summary_hook(messages: list) -> str | None:\n \"\"\"Print a summary when the loop is about to stop.\"\"\"\n tool_count = sum(1 for m in messages\n for b in (m.get(\"content\") if isinstance(m.get(\"content\"), list) else [])\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\")\n print(f\"\\033[90m[HOOK] Stop: session used {tool_count} tool calls\\033[0m\")\n return None # return None = 終了を許可、return 文字列 = 強制続行\n\nregister_hook(\"Stop\", summary_hook)\n```\n\nagent_loop 内では、終了前に発火:\n\n```python\nif response.stop_reason != \"tool_use\":\n force = trigger_hooks(\"Stop\", messages) # ← 終了する前に\n if force:\n # フックがメッセージを返した → 注入して続行\n messages.append({\"role\": \"user\", \"content\": force})\n continue\n return\n```\n\n**ループ内で変更されたのは一箇所だけ**:s03 は直接 `check_permission(block)` を呼び出していたが、s04 は `trigger_hooks(\"PreToolUse\", block)` に置き換えた:\n\n```python\nfor block in response.content:\n if block.type != \"tool_use\":\n continue\n\n # s03: if not check_permission(block): ...\n # s04: フックがハードコードを代替\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": str(blocked)})\n continue\n\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n\n trigger_hooks(\"PostToolUse\", block, output)\n\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": output})\n```\n\n4 つのフックが agent cycle の重要ノードをカバー:入力→実行前→実行後→終了。ループは trigger_hooks() を呼ぶだけで、具体的なロジックは全てフックコールバックにある。\n\n---\n\n## s03 からの変更\n\n| コンポーネント | 変更前 (s03) | 変更後 (s04) |\n|--------------|-------------|-------------|\n| 拡張方式 | check_permission() をループ内にハードコード | HOOKS 登録簿 + trigger_hooks() |\n| 新規関数 | — | register_hook, trigger_hooks |\n| フックコールバック | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |\n| ループ | check_permission() を直接呼び出し | trigger_hooks(\"PreToolUse\", ...) を呼び出し |\n| 終了制御 | なし | trigger_hooks(\"Stop\", ...) が終了を阻止可能 |\n| 入力横取り | なし | trigger_hooks(\"UserPromptSubmit\", ...) がコンテキスト注入可能 |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s04_hooks/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Read the file README.md`(そのまま通過するはず、フックログを観察)\n2. `Create a file called test.txt`(作成後、PostToolUse が発火するか観察)\n3. `Delete all temporary files in /tmp`(bash + rm で権限フックが発動)\n\n観察のポイント:各ツール実行前に `[HOOK]` ログが表示されるか? 権限が拒否されたとき、フックが拦截したのか、ループ内のハードコードが拦截したのか?\n\n---\n\n## 次へ\n\nAgent は安全に操作を実行できるようになった。しかし「まず何をして、次に何をすべきか」を立ち止まって考えたことはあるか? 複雑なタスクを与えたとき、すぐに取り掛かるのか、まず計画を立てるのか?\n\n→ s05 TodoWrite:Agent に計画ツールを与える。まずリストを作り、それから実行。\n\n
\nCC ソースコードを深掘り\n\n> 以下は CC ソースコード `toolHooks.ts`(650 行)、`hooks.ts`、`stopHooks.ts`、`coreTypes.ts` の完全分析に基づく。\n\n### 一、Hook イベント:4 つではなく 27 個\n\n教育版は PreToolUse と PostToolUse のみを取り上げる。CC には実際に 27 のフックイベントがある(`coreTypes.ts:25-53`):\n\n| カテゴリ | イベント |\n|----------|---------|\n| ツール関連 | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |\n| セッション関連 | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |\n| ユーザー対話 | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |\n| サブエージェント | `SubagentStart`, `SubagentStop` |\n| 圧縮関連 | `PreCompact`, `PostCompact` |\n| チーム関連 | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |\n| その他 | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |\n\n教育版は 4 つのコアイベント(UserPromptSubmit、PreToolUse、PostToolUse、Stop)のみを取り上げる。これらで agent cycle の重要ノードを全てカバーできる。残り 23 個は同じパターン。\n\n### 二、HookResult よく使うフィールド抜粋\n\nCC の `HookResult`(`types/hooks.ts:260-275`)には 14 のフィールドがある。よく使うもの:\n\n| フィールド | 型 | 用途 |\n|-----------|-----|------|\n| `message` | Message | オプションの UI メッセージ |\n| `blockingError` | HookBlockingError | ブロッキングエラー → 会話に注入してモデルが自己修正 |\n| `outcome` | success/blocking/non_blocking_error/cancelled | 実行結果 |\n| `preventContinuation` | boolean | 後続実行を阻止 |\n| `stopReason` | string | 停止理由の説明 |\n| `permissionBehavior` | allow/deny/ask/passthrough | フックが権限決定を返す |\n| `updatedInput` | Record | ツール入力の変更 |\n| `additionalContext` | string | 追加コンテキスト |\n| `updatedMCPToolOutput` | unknown | MCP ツール出力の変更 |\n\n### 三、重要な不変条件:Hook 'allow' は deny/ask ルールをバイパスできない\n\nこれは CC 権限システムで最も重要なセキュリティ設計(`toolHooks.ts:325-331`):**フックが allow を返しても、settings.json の deny/ask ルールをチェックする。** ユーザーのフックスクリプトが「許可」と言っても、settings.json でそのツールが無効になっていれば、操作は阻止される。\n\n教育版にはこの階層がない。フックが非 None を返せば直接中断。教育目的では十分だが、本番環境ではセキュリティホールになる。\n\n### 四、stopHookActive 機構\n\nCC の Stop フックには無限ループ防止機構がある(`query.ts:212,1300`):`stopHookActive` 状態フィールド。Stop フックが blockingError を発生させると、ループは `stopHookActive: true` で次のラウンドに再入する。後続のイテレーションではこのフラグを見て Stop フックを再トリガーしない。これで「永久に止まらない」バグを防ぐ:モデルが自己修正 → Stop フックが再度エラー → モデルが再修正 → Stop フックが再度エラー... を防止。\n\n### 五、hook_stopped_continuation\n\nPostToolUse フックが `preventContinuation: true` を返すと、`hook_stopped_continuation` アタッチメントが生成される(`toolHooks.ts:117-130`)。query.ts(L1388-1393)はそれを検出して `shouldPreventContinuation = true` を設定し、ループが終了する。これは「フックが Agent を優雅に停止させる」機構 — クラッシュではなく、完了。\n\n### 教育版の簡略化は意図的\n\n- 27 イベント → 4(UserPromptSubmit/PreToolUse/PostToolUse/Stop):agent cycle の重要ノードをカバー\n- 14 フィールド → 単純な戻り値(None = 続行、非 None = 中断/続行):認知負荷を最小限に\n- Hook allow vs deny/ask の不変条件 → 省略:教育版に settings.json 層はない\n- stopHookActive → 省略:教育版の Stop フックは単純な続行のみ、無限ループ防止は不要\n\n
\n\n\n" + "content": "# s04: Hooks — ループに掛ける、ループには書き込まない\n\ns01 → s02 → s03 → `s04` → [s05](/ja/s05) → s06 → ... → s20\n\n> *\"ループに掛ける、ループには書き込まない\"* — フックがツール実行の前後に拡張ロジックを注入する。\n>\n> **Harness レイヤー**: フック、ループを侵襲しない拡張ポイント。\n\n---\n\n## 課題\n\ns03 の Agent には権限チェックがある。しかし新しいチェックを追加するたび、「bash 呼び出しを毎回ログに記録」「操作後に自動 git add」、`agent_loop` 関数を修正する必要がある。\n\nループはすぐにこうなる:\n\n```python\ndef agent_loop(messages):\n while True:\n # ... LLM call ...\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n log_to_file(block) # 一行追加\n check_permission(block) # 一行追加\n notify_slack(block) # さらに一行追加\n output = execute(block)\n auto_git_add(block) # さらに一行追加\n # ... もうループが見えない\n```\n\n拡張したいのは Agent の振る舞いなのに、変更しているのはループそのもの。ループは安定した核心であるべき。拡張は外側に掛ける。\n\n---\n\n## ソリューション\n\n![Hooks Overview](/course-assets/s04_hooks/hooks-overview.svg)\n\ns03 のループと権限ロジックは完全に保持される。唯一の変更点は `check_permission()` をループ本体内からフックに移動したこと。ループはもうチェック関数を直接呼び出さず、代わりに `trigger_hooks(\"PreToolUse\", block)` を呼び、登録済みのフックが何を実行するかを決める。\n\n4 つのイベントで、完全な agent cycle をカバー:\n\n| イベント | 発火タイミング | 典型的な用途 |\n|----------|--------------|-------------|\n| UserPromptSubmit | ユーザー入力後、LLM に入る前 | 入力バリデーション、コンテキスト注入 |\n| PreToolUse | ツール実行前 | 権限チェック、ログ記録 |\n| PostToolUse | ツール実行後 | 副作用(自動 git add など)、出力チェック |\n| Stop | ループが終了する直前 | クリーンアップ(Claude Code は強制続行もサポート) |\n\n拡張は `register_hook()` で追加する。ループは `trigger_hooks()` を呼ぶだけ。\n\n---\n\n## 仕組み\n\n**フック登録簿**:イベント名をコールバックリストにマッピングする辞書。\n\n```python\nHOOKS = {\n \"UserPromptSubmit\": [],\n \"PreToolUse\": [],\n \"PostToolUse\": [],\n \"Stop\": [],\n}\n\ndef register_hook(event: str, callback):\n HOOKS[event].append(callback)\n\ndef trigger_hooks(event: str, *args):\n for callback in HOOKS[event]:\n result = callback(*args)\n if result is not None: # 戻り値 ≠ None → フックが「止め」と指示\n return result\n return None\n```\n\n教学版では、PreToolUse の非 None 戻り値は実行阻止を意味し、Stop の非 None 戻り値は強制続行を意味する。UserPromptSubmit と PostToolUse の戻り値は未使用。\n\n**UserPromptSubmit**、ユーザー入力後、LLM に入る前に発火。Claude Code では入力の横取りや変更が可能、教学版はログ出力のみ:\n\n```python\ndef context_inject_hook(query: str) -> str | None:\n \"\"\"Inject current working directory info into every prompt.\"\"\"\n print(f\"\\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\\033[0m\")\n return None # return None = 変更なし、プロンプトを通す\n\nregister_hook(\"UserPromptSubmit\", context_inject_hook)\n```\n\nメインループでは、ユーザー入力直後に発火:\n\n```python\nquery = input(\"s04 >> \")\ntrigger_hooks(\"UserPromptSubmit\", query) # ← LLM に入る前\nhistory.append({\"role\": \"user\", \"content\": query})\nagent_loop(history)\n```\n\n**PreToolUse / PostToolUse**、ツール実行の前後のフック。s03 の権限チェックロジックは PreToolUse フックに包まれ、さらにログフックと大出力リマインダーが追加される:\n\n```python\n# PreToolUse: 権限チェック(s03 のロジック、ループからフックに移動)\ndef permission_hook(block):\n if block.name == \"bash\":\n for pattern in DENY_LIST:\n if pattern in block.input.get(\"command\", \"\"):\n return \"Permission denied by deny list\"\n if block.name in (\"write_file\", \"edit_file\"):\n path = block.input.get(\"path\", \"\")\n if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n return None\n\n# PreToolUse: ログ\ndef log_hook(block):\n print(f\"[HOOK] {block.name}(...)\")\n\n# PostToolUse: 大ファイルリマインダー\ndef large_output_hook(block, output):\n if len(str(output)) > 100000:\n print(f\"[HOOK] ⚠ Large output from {block.name}\")\n\nregister_hook(\"PreToolUse\", permission_hook)\nregister_hook(\"PreToolUse\", log_hook)\nregister_hook(\"PostToolUse\", large_output_hook)\n```\n\n**Stop**、ループが終了する直前に発火(`stop_reason != \"tool_use\"`)。教学版ではクリーンアップ統計を印刷:\n\n```python\ndef summary_hook(messages: list) -> str | None:\n \"\"\"Print a summary when the loop is about to stop.\"\"\"\n tool_count = sum(1 for m in messages\n for b in (m.get(\"content\") if isinstance(m.get(\"content\"), list) else [])\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\")\n print(f\"\\033[90m[HOOK] Stop: session used {tool_count} tool calls\\033[0m\")\n return None # return None = 終了を許可、return 文字列 = 強制続行\n\nregister_hook(\"Stop\", summary_hook)\n```\n\nagent_loop 内では、終了前に発火:\n\n```python\nif response.stop_reason != \"tool_use\":\n force = trigger_hooks(\"Stop\", messages) # ← 終了する前に\n if force:\n # フックがメッセージを返した → 注入して続行\n messages.append({\"role\": \"user\", \"content\": force})\n continue\n return\n```\n\n**ループ内で変更されたのは一箇所だけ**:s03 は直接 `check_permission(block)` を呼び出していたが、s04 は `trigger_hooks(\"PreToolUse\", block)` に置き換えた:\n\n```python\nfor block in response.content:\n if block.type != \"tool_use\":\n continue\n\n # s03: if not check_permission(block): ...\n # s04: フックがハードコードを代替\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": str(blocked)})\n continue\n\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n\n trigger_hooks(\"PostToolUse\", block, output)\n\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": output})\n```\n\n以上が agent_loop 内の全フック呼び出し箇所。\n\n---\n\n## s03 からの変更\n\n| コンポーネント | 変更前 (s03) | 変更後 (s04) |\n|--------------|-------------|-------------|\n| 拡張方式 | check_permission() をループ内にハードコード | HOOKS 登録簿 + trigger_hooks() |\n| 新規関数 | — | register_hook, trigger_hooks |\n| フックコールバック | — | context_inject_hook, permission_hook, log_hook, large_output_hook, summary_hook |\n| ループ | check_permission() を直接呼び出し | trigger_hooks(\"PreToolUse\", ...) を呼び出し |\n| 終了制御 | なし | trigger_hooks(\"Stop\", ...) が終了を阻止可能 |\n| 入力横取り | なし | trigger_hooks(\"UserPromptSubmit\", ...) がコンテキスト注入可能 |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s04_hooks/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Read the file README.md`(そのまま通過するはず、フックログを観察)\n2. `Create a file called test.txt`(作成後、PostToolUse が発火するか観察)\n3. `Delete all temporary files in /tmp`(bash + rm で権限フックが発動)\n\n観察のポイント:各ツール実行前に `[HOOK]` ログが表示されるか? 権限が拒否されたとき、フックが拦截したのか、ループ内のハードコードが拦截したのか?\n\n---\n\n## 次へ\n\nAgent は安全に操作を実行できるようになったが、実行順序の概念がまだない。複雑なタスクを与えても、ツールを受け取った順に実行するだけで、事前に計画は立てない。\n\n→ s05 TodoWrite:Agent に計画ツールを与える。まずリストを作り、それから実行。\n\n
\nClaude Code ソースコードを深掘り\n\n> 以下は Claude Code ソースコード `toolHooks.ts`(650 行)、`hooks.ts`、`stopHooks.ts`、`coreTypes.ts` の完全分析に基づく。\n\n### 一、Hook イベント:4 つではなく 27 個\n\n教育版は PreToolUse と PostToolUse のみを取り上げる。Claude Code には実際に 27 のフックイベントがある(`coreTypes.ts:25-53`):\n\n| カテゴリ | イベント |\n|----------|---------|\n| ツール関連 | `PreToolUse`, `PostToolUse`, `PostToolUseFailure` |\n| セッション関連 | `SessionStart`, `SessionEnd`, `Stop`, `StopFailure`, `Setup` |\n| ユーザー対話 | `UserPromptSubmit`, `Notification`, `PermissionRequest`, `PermissionDenied` |\n| サブエージェント | `SubagentStart`, `SubagentStop` |\n| 圧縮関連 | `PreCompact`, `PostCompact` |\n| チーム関連 | `TeammateIdle`, `TaskCreated`, `TaskCompleted` |\n| その他 | `Elicitation`, `ElicitationResult`, `ConfigChange`, `WorktreeCreate`, `WorktreeRemove`, `InstructionsLoaded`, `CwdChanged`, `FileChanged` |\n\n教育版は 4 つのコアイベント(UserPromptSubmit、PreToolUse、PostToolUse、Stop)のみを取り上げる。これらで agent cycle の重要ノードを全てカバーできる。残り 23 個は同じパターン。\n\n### 二、HookResult よく使うフィールド抜粋\n\nClaude Code の `HookResult`(`types/hooks.ts:260-275`)には 14 のフィールドがある。よく使うもの:\n\n| フィールド | 型 | 用途 |\n|-----------|-----|------|\n| `message` | Message | オプションの UI メッセージ |\n| `blockingError` | HookBlockingError | ブロッキングエラー → 会話に注入してモデルが自己修正 |\n| `outcome` | success/blocking/non_blocking_error/cancelled | 実行結果 |\n| `preventContinuation` | boolean | 後続実行を阻止 |\n| `stopReason` | string | 停止理由の説明 |\n| `permissionBehavior` | allow/deny/ask/passthrough | フックが権限決定を返す |\n| `updatedInput` | Record | ツール入力の変更 |\n| `additionalContext` | string | 追加コンテキスト |\n| `updatedMCPToolOutput` | unknown | MCP ツール出力の変更 |\n\n### 三、重要な不変条件:Hook 'allow' は deny/ask ルールをバイパスできない\n\nこれは Claude Code 権限システムで最も重要なセキュリティ設計(`toolHooks.ts:325-331`):**フックが allow を返しても、settings.json の deny/ask ルールをチェックする。** ユーザーのフックスクリプトが「許可」と言っても、settings.json でそのツールが無効になっていれば、操作は阻止される。\n\n教育版にはこの階層がない。フックが非 None を返せば直接中断。教育目的では十分だが、本番環境ではセキュリティホールになる。\n\n### 四、stopHookActive 機構\n\nClaude Code の Stop フックには無限ループ防止機構がある(`query.ts:212,1300`):`stopHookActive` 状態フィールド。Stop フックが blockingError を発生させると、ループは `stopHookActive: true` で次のラウンドに再入する。後続のイテレーションではこのフラグを見て Stop フックを再トリガーしない。これで「永久に止まらない」バグを防ぐ:モデルが自己修正 → Stop フックが再度エラー → モデルが再修正 → Stop フックが再度エラー... を防止。\n\n### 五、hook_stopped_continuation\n\nPostToolUse フックが `preventContinuation: true` を返すと、`hook_stopped_continuation` アタッチメントが生成される(`toolHooks.ts:117-130`)。query.ts(L1388-1393)はそれを検出して `shouldPreventContinuation = true` を設定し、ループが終了する。これは「フックが Agent を優雅に停止させる」機構 — クラッシュではなく、完了。\n\n### 教育版の簡略化は意図的\n\n- 27 イベント → 4(UserPromptSubmit/PreToolUse/PostToolUse/Stop):agent cycle の重要ノードをカバー\n- 14 フィールド → 単純な戻り値(None = 続行、非 None = 中断/続行):認知負荷を最小限に\n- Hook allow vs deny/ask の不変条件 → 省略:教育版に settings.json 層はない\n- stopHookActive → 省略:教育版の Stop フックは単純な続行のみ、無限ループ防止は不要\n\n
\n\n\n" }, { "version": "s05", "locale": "en", "title": "s05: TodoWrite — An Agent Without a Plan Drifts Off Course", - "content": "# s05: TodoWrite — An Agent Without a Plan Drifts Off Course\n\ns01 → s02 → s03 → s04 → `s05` → [s06](/en/s06) → s07 → ... → s20\n\n> *\"An agent without a plan goes wherever the wind blows\"* — List the steps first, then execute. Complex tasks are less likely to miss steps.\n>\n> **Harness Layer**: Planning — Let the Agent think before it acts.\n\n---\n\n## The Problem\n\nGive the Agent a complex task: \"Rename all Python files to snake_case, run tests, and fix failures.\"\n\nThe Agent starts working, renames 3 files, runs a test, finds 2 failures, starts fixing. While fixing, it forgets the original goal was \"rename to snake_case\", the test failures have consumed all its attention.\n\nThe longer the conversation, the worse it gets: tool results keep filling the context, diluting the system prompt's influence. A 10-step refactoring: after steps 1-3, the Agent starts improvising because steps 4-10 have been pushed out of its attention.\n\n---\n\n## The Solution\n\n![Todo Overview](/course-assets/s05_todo_write/todo-overview.en.svg)\n\nThe minimal hook structure from the previous chapter is preserved, focusing on the new `todo_write` tool and reminder mechanism. `todo_write` does no actual work, can't read files or run commands, it simply lets the Agent organize its thoughts before diving in.\n\nThe dispatch mechanism is unchanged; the new tool is still routed through `TOOL_HANDLERS[block.name]`. However, to demonstrate the todo reminder, a counter was added to the loop: after 3 consecutive rounds without calling `todo_write`, a reminder is injected.\n\n---\n\n## How It Works\n\n**The todo_write tool** accepts a list with statuses, keeps it in the current process memory, and displays progress in the terminal:\n\n```python\nCURRENT_TODOS: list[dict] = []\n\ndef run_todo_write(todos: list) -> str:\n global CURRENT_TODOS\n CURRENT_TODOS = todos\n\n lines = [\"\\n## Current Tasks\"]\n for t in CURRENT_TODOS:\n icon = {\"pending\": \" \", \"in_progress\": \"▸\", \"completed\": \"✓\"}[t[\"status\"]]\n lines.append(f\" [{icon}] {t['content']}\")\n print(\"\\n\".join(lines))\n return f\"Updated {len(CURRENT_TODOS)} tasks\"\n```\n\nThe tool definition joins the other 5 in the dispatch map:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n # s05: new entry\n {\"name\": \"todo_write\", \"description\": \"Create and manage a task list ...\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"content\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"completed\"]},\n },\n },\n },\n },\n },\n },\n]\n\nTOOL_HANDLERS[\"todo_write\"] = run_todo_write\n```\n\n**Nag reminder**, when the model hasn't called `todo_write` for 3 consecutive rounds, a reminder is automatically injected (teaching mechanism; CC source has no fixed round-count logic):\n\n```python\nif rounds_since_todo >= 3 and messages:\n messages.append({\n \"role\": \"user\",\n \"content\": \"Update your todos.\",\n })\n rounds_since_todo = 0\n```\n\nTypical flow when the Agent receives a task: first call `todo_write` to list all steps (all `pending`) → pick one step, set it to `in_progress` → complete it, set to `completed` → look at the next `pending` → continue. After 3 rounds without `todo_write`, the loop appends a reminder before the next LLM call.\n\n**Key insight**: todo_write doesn't give the Agent any additional **execution capability**. What it adds is **planning capability**.\n\n---\n\n## Changes from s04\n\n| Component | Before (s04) | After (s05) |\n|-----------|-------------|-------------|\n| Tool count | 5 (bash, read, write, edit, glob) | 6 (+todo_write) |\n| Planning | None | Stateful TODO list + nag reminder |\n| SYSTEM prompt | Generic prompt | Added \"plan before executing\" guidance |\n| Loop | Unchanged | Dispatch unchanged, added rounds_since_todo counter and reminder injection |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s05_todo_write/code.py\n```\n\nTry these prompts:\n\n1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard` (should list 3 steps first, then execute)\n2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py`\n3. `Review Python files under s05_todo_write/example and fix any style issues`\n\nWhat to watch for: Was the first tool call `todo_write`? How many TODO steps were listed? Did statuses move from `pending` to `in_progress` / `completed` during execution?\n\n---\n\n## What's Next\n\nThe Agent can plan now. But if a task is too large, say \"refactor the entire auth module\", a TODO list alone isn't enough. That task is itself a collection of dozens of subtasks that would drown in a single conversation's context.\n\n→ s06 Subagent: Break large tasks into subtasks, each handled by an independent Agent with its own clean context, no cross-contamination.\n\n
\nDive into CC Source Code\n\nCC has two task systems coexisting (`tasks.ts:133-139`):\n\n- **TodoWrite (V1)**: A simple list tool, data maintained in memory AppState (`TodoWriteTool.ts:65-103`). The teaching version also keeps it in process memory and clears it on exit.\n- **Task System (V2 = s12)**: File-persisted, dependency graph, concurrency locks, ownership.\n\nThe switch is controlled by `isTodoV2Enabled()`. In the current source: V2 is enabled by default in interactive sessions, V1 in non-interactive (SDK) sessions; setting `CLAUDE_CODE_ENABLE_TASKS` forces V2 regardless. Note the source comment \"Force-enable tasks in non-interactive mode\" describes the env var path's purpose, not the default branch's return semantics.\n\nThe teaching version omits the `activeForm` field from the real source (`utils/todo/types.ts:8-15`). CC uses it for the UI spinner to show \"what's being done\"; the teaching version only has terminal output and doesn't need this field.\n\nThe teaching version's nag reminder (3 rounds without update triggers injection) is an educational mechanism. The CC source has no fixed \"3 rounds\" logic; the closest is `TodoWriteTool.ts:72-107` which appends a verification nudge when 3+ todos are all completed without a verification item.\n\nCore increments of the Task System over TodoWrite:\n- File persistence (Claude config directory `tasks/{taskListId}/{taskId}.json`) instead of in-memory list\n- `blockedBy` dependency graph instead of flat list\n- `proper-lockfile` concurrency safety instead of no locking\n- Four separate tools (Create/Get/Update/List) instead of one\n- TaskCreated / TaskCompleted hooks (`TaskCreateTool.ts:80-129`, `TaskUpdateTool.ts:231-260`) for external system integration\n\n
\n\n\n" + "content": "# s05: TodoWrite — An Agent Without a Plan Drifts Off Course\n\ns01 → s02 → s03 → s04 → `s05` → [s06](/en/s06) → s07 → ... → s20\n\n> *\"An agent without a plan goes wherever the wind blows\"* — List the steps first, then execute. Complex tasks are less likely to miss steps.\n>\n> **Harness Layer**: Planning — maintain a stateful task list via todo_write.\n\n---\n\n## The Problem\n\nGive the Agent a complex task: \"Rename all Python files to snake_case, run tests, and fix failures.\"\n\nThe Agent starts working, renames 3 files, runs a test, finds 2 failures, starts fixing. While fixing, it forgets the original goal was \"rename to snake_case\", the test failures have consumed all its attention.\n\nThe longer the conversation, the worse it gets: tool results keep filling the context, diluting the system prompt's influence. A 10-step refactoring: after steps 1-3, the Agent starts improvising because steps 4-10 have been pushed out of its attention.\n\n---\n\n## The Solution\n\n![Todo Overview](/course-assets/s05_todo_write/todo-overview.svg)\n\nThe minimal hook structure from the previous chapter is preserved, focusing on the new `todo_write` tool and reminder mechanism. `todo_write` does no actual work, can't read files or run commands, it simply maintains a stateful task list.\n\nThe dispatch mechanism is unchanged; the new tool is still routed through `TOOL_HANDLERS[block.name]`. However, to demonstrate the todo reminder, a counter was added to the loop: after 3 consecutive rounds without calling `todo_write`, a reminder is injected.\n\n---\n\n## How It Works\n\n**The todo_write tool** accepts a list with statuses, keeps it in the current process memory, and displays progress in the terminal:\n\n```python\nCURRENT_TODOS: list[dict] = []\n\ndef run_todo_write(todos: list) -> str:\n global CURRENT_TODOS\n CURRENT_TODOS = todos\n\n lines = [\"\\n## Current Tasks\"]\n for t in CURRENT_TODOS:\n icon = {\"pending\": \" \", \"in_progress\": \"▸\", \"completed\": \"✓\"}[t[\"status\"]]\n lines.append(f\" [{icon}] {t['content']}\")\n print(\"\\n\".join(lines))\n return f\"Updated {len(CURRENT_TODOS)} tasks\"\n```\n\nThe tool definition joins the other 5 in the dispatch map:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n # s05: new entry\n {\"name\": \"todo_write\", \"description\": \"Create and manage a task list ...\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"content\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"completed\"]},\n },\n },\n },\n },\n },\n },\n]\n\nTOOL_HANDLERS[\"todo_write\"] = run_todo_write\n```\n\n**Nag reminder**, when the model hasn't called `todo_write` for 3 consecutive rounds, a reminder is automatically injected:\n\n```python\nif rounds_since_todo >= 3 and messages:\n messages.append({\n \"role\": \"user\",\n \"content\": \"Update your todos.\",\n })\n rounds_since_todo = 0\n```\n\nTypical flow when the Agent receives a task: first call `todo_write` to list all steps (all `pending`) → pick one step, set it to `in_progress` → complete it, set to `completed` → look at the next `pending` → continue. After 3 rounds without `todo_write`, the loop appends a reminder before the next LLM call.\n\ntodo_write doesn't give the Agent any additional execution capability. It just adds a stateful list.\n\nWhether to call `todo_write` — and which steps to list — is entirely the model's own decision, based on task complexity. The harness never plans for the model: the SYSTEM prompt only nudges, and the nag reminder merely injects a text message into `messages[]`. The model is still free to update its todos on the next round.\n\n---\n\n## Changes from s04\n\n| Component | Before (s04) | After (s05) |\n|-----------|-------------|-------------|\n| Tool count | 5 (bash, read, write, edit, glob) | 6 (+todo_write) |\n| Planning | None | Stateful TODO list + nag reminder |\n| SYSTEM prompt | Generic prompt | Added todo_write usage guidance |\n| Loop | Unchanged | Dispatch unchanged, added rounds_since_todo counter and reminder injection |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s05_todo_write/code.py\n```\n\nTry these prompts:\n\n1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard` (should list 3 steps first, then execute)\n2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py`\n3. `Review Python files under s05_todo_write/example and fix any style issues`\n\nWhat to watch for: Was the first tool call `todo_write`? How many TODO steps were listed? Did statuses move from `pending` to `in_progress` / `completed` during execution?\n\n---\n\n## What's Next\n\nThe Agent can plan now. But if a task is too large, say \"refactor the entire auth module\", a TODO list alone isn't enough. That task is itself a collection of dozens of subtasks that would drown in a single conversation's context.\n\n→ s06 Subagent: Break large tasks into subtasks, each handled by an independent Agent with its own clean context, no cross-contamination.\n\n
\nDive into Claude Code Source Code\n\nClaude Code has two task systems coexisting (`tasks.ts:133-139`):\n\n- **TodoWrite (V1)**: A simple list tool, data maintained in memory AppState (`TodoWriteTool.ts:65-103`). The teaching version also keeps it in process memory and clears it on exit.\n- **Task System (V2 = s12)**: File-persisted, dependency graph, concurrency locks, ownership.\n\nThe switch is controlled by `isTodoV2Enabled()`. In the current source: V2 is enabled by default in interactive sessions, V1 in non-interactive (SDK) sessions; setting `CLAUDE_CODE_ENABLE_TASKS` forces V2 regardless. Note the source comment \"Force-enable tasks in non-interactive mode\" describes the env var path's purpose, not the default branch's return semantics.\n\nThe teaching version omits the `activeForm` field from the real source (`utils/todo/types.ts:8-15`). Claude Code uses it for the UI spinner to show \"what's being done\"; the teaching version only has terminal output and doesn't need this field.\n\nThe teaching version's nag reminder (3 rounds without update triggers injection) is an educational mechanism. The Claude Code source has no fixed \"3 rounds\" logic; the closest is `TodoWriteTool.ts:72-107` which appends a verification nudge when 3+ todos are all completed without a verification item.\n\nCore increments of the Task System over TodoWrite:\n- File persistence (Claude config directory `tasks/{taskListId}/{taskId}.json`) instead of in-memory list\n- `blockedBy` dependency graph instead of flat list\n- `proper-lockfile` concurrency safety instead of no locking\n- Four separate tools (Create/Get/Update/List) instead of one\n- TaskCreated / TaskCompleted hooks (`TaskCreateTool.ts:80-129`, `TaskUpdateTool.ts:231-260`) for external system integration\n\n
\n\n\n" }, { "version": "s05", "locale": "zh", "title": "s05: TodoWrite — 没有计划的 Agent,做着做着就偏了", - "content": "# s05: TodoWrite — 没有计划的 Agent,做着做着就偏了\n\ns01 → s02 → s03 → s04 → `s05` → [s06](/zh/s06) → s07 → ... → s20\n\n> *\"没有计划的 agent 走哪算哪\"* — 先列步骤再动手,长任务更不容易漏项。\n>\n> **Harness 层**: 规划 — 让 Agent 在动手之前先想清楚。\n\n---\n\n## 问题\n\n给 Agent 一个复杂任务:\"把所有 Python 文件改成 snake_case 命名,然后跑测试,修好失败。\"\n\nAgent 开始干活,改了 3 个文件,跑了个测试,发现 2 个失败,开始修。修着修着,它忘了最初是\"改成 snake_case\",测试失败把注意力全吸走了。\n\n对话越长越严重:工具结果不断填满上下文,系统提示的影响力被稀释。一个 10 步重构,做完 1-3 步就开始即兴发挥,因为 4-10 步已经被挤出注意力了。\n\n---\n\n## 解决方案\n\n![Todo Overview](/course-assets/s05_todo_write/todo-overview.svg)\n\n保留上一章的最小 hook 结构,重点看新增的 `todo_write` 工具和 reminder 机制。`todo_write` 本身不做任何实际工作,不能读文件、不能跑命令,只是让 Agent 在动手之前先理清思路。\n\ndispatch 机制不变,新工具仍然走 `TOOL_HANDLERS[block.name]` 分发。但为了演示 todo reminder,循环里加了一个计数器:连续 3 轮没调 `todo_write` 就注入一条提醒。\n\n---\n\n## 工作原理\n\n**todo_write 工具**,接收一个带状态的列表,保存在当前进程内存中,同时在终端显示进度:\n\n```python\nCURRENT_TODOS: list[dict] = []\n\ndef run_todo_write(todos: list) -> str:\n global CURRENT_TODOS\n CURRENT_TODOS = todos\n\n lines = [\"\\n## Current Tasks\"]\n for t in CURRENT_TODOS:\n icon = {\"pending\": \" \", \"in_progress\": \"▸\", \"completed\": \"✓\"}[t[\"status\"]]\n lines.append(f\" [{icon}] {t['content']}\")\n print(\"\\n\".join(lines))\n return f\"Updated {len(CURRENT_TODOS)} tasks\"\n```\n\n工具定义和其他 5 个工具一起加入 dispatch map:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n # s05: 新增一条\n {\"name\": \"todo_write\", \"description\": \"Create and manage a task list ...\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"content\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"completed\"]},\n },\n },\n },\n },\n },\n },\n]\n\nTOOL_HANDLERS[\"todo_write\"] = run_todo_write\n```\n\n**Nag reminder**,模型连续 3 轮没调 `todo_write` 时,自动注入一条提醒(教学版机制,CC 源码中没有这个固定轮数逻辑):\n\n```python\nif rounds_since_todo >= 3 and messages:\n messages.append({\n \"role\": \"user\",\n \"content\": \"Update your todos.\",\n })\n rounds_since_todo = 0\n```\n\nAgent 收到任务后的典型流程:先调 `todo_write` 列出所有步骤(全 `pending`)→ 做一个步骤,改成 `in_progress` → 做完改成 `completed` → 看下一个 `pending` → 继续。连续 3 轮没有调用 `todo_write` 时,循环会在下一次 LLM 调用前追加一条 reminder。\n\n**关键洞察**:todo_write 不给 Agent 增加任何**执行能力**。它增加的是**规划能力**。\n\n---\n\n## 相对 s04 的变更\n\n| 组件 | 之前 (s04) | 之后 (s05) |\n|------|-----------|-----------|\n| 工具数量 | 5 (bash, read, write, edit, glob) | 6 (+todo_write) |\n| 规划能力 | 无 | 带状态的 TODO 列表 + nag reminder |\n| SYSTEM 提示 | 通用提示 | 加入 \"先计划再执行\" 引导 |\n| 循环 | 不变 | dispatch 不变,新增 rounds_since_todo 计数器和 reminder 注入 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s05_todo_write/code.py\n```\n\n试试这些 prompt:\n\n1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard`(先列 3 步再执行)\n2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py`\n3. `Review Python files under s05_todo_write/example and fix any style issues`\n\n观察重点:第一次工具调用是不是 `todo_write`?TODO 列了几步?执行过程中状态有没有从 `pending` 变成 `in_progress` / `completed`?\n\n---\n\n## 接下来\n\nAgent 能计划了。但如果一个任务太大,比如\"重构整个认证模块\",光靠 TODO 列表不够。这个任务本身就是几十个小任务的集合,放在同一个对话里会被上下文淹没。\n\ns06 Subagent → 把大任务拆成子任务,每个子任务派一个独立的 Agent。它们有自己的干净上下文,不会互相污染。\n\n
\n深入 CC 源码\n\nCC 中有两套任务系统并存(`tasks.ts:133-139`):\n\n- **TodoWrite(V1)**:一个简单的列表工具,数据在内存 AppState 中维护(`TodoWriteTool.ts:65-103`)。教学版也保存在进程内存里,退出后清空\n- **Task System(V2 = s12)**:文件持久化、依赖图、并发锁、ownership\n\n切换由 `isTodoV2Enabled()` 控制。当前源码的实现逻辑:交互式会话中 V2 默认启用,非交互式会话(SDK)中 V1 默认启用;设置 `CLAUDE_CODE_ENABLE_TASKS` 环境变量可强制启用 V2。注意源码注释 \"Force-enable tasks in non-interactive mode\" 描述的是 env var 路径的用途,和默认分支的返回值语义不同,阅读时需区分。\n\n教学版省略了真实源码中的 `activeForm` 字段(`utils/todo/types.ts:8-15`)。CC 用它给 UI spinner 展示\"正在做什么\",教学版只有终端输出,不需要这个字段。\n\n教学版的 nag reminder(3 轮未更新就注入提醒)是教学机制。CC 源码中没有固定的\"3 轮\"逻辑,更接近的是 `TodoWriteTool.ts:72-107` 中当 3 个以上 todo 全部完成但没有 verification 项时,追加 verification nudge。\n\nTask System 相比 TodoWrite 的核心增量:\n- 文件持久化(Claude 配置目录下 `tasks/{taskListId}/{taskId}.json`)而非内存列表\n- `blockedBy` 依赖图而非平铺列表\n- `proper-lockfile` 并发安全而非无锁\n- 四个独立工具(Create/Get/Update/List)而非一个\n- TaskCreated / TaskCompleted hooks(`TaskCreateTool.ts:80-129`、`TaskUpdateTool.ts:231-260`)供外部系统集成\n\n
\n\n\n" + "content": "# s05: TodoWrite — 没有计划的 Agent,做着做着就偏了\n\ns01 → s02 → s03 → s04 → `s05` → [s06](/zh/s06) → s07 → ... → s20\n\n> *\"没有计划的 agent 走哪算哪\"* — 先列步骤再动手,长任务更不容易漏项。\n>\n> **Harness 层**: 规划 — 用 todo_write 工具维护任务列表。\n\n---\n\n## 问题\n\n给 Agent 一个复杂任务:\"把所有 Python 文件改成 snake_case 命名,然后跑测试,修好失败。\"\n\nAgent 开始干活,改了 3 个文件,跑了个测试,发现 2 个失败,开始修。修着修着,它忘了最初是\"改成 snake_case\",测试失败把注意力全吸走了。\n\n对话越长越严重:工具结果不断填满上下文,系统提示的影响力被稀释。一个 10 步重构,做完 1-3 步就开始即兴发挥,因为 4-10 步已经被挤出注意力了。\n\n---\n\n## 解决方案\n\n![Todo Overview](/course-assets/s05_todo_write/todo-overview.svg)\n\n保留上一章的最小 hook 结构,重点看新增的 `todo_write` 工具和 reminder 机制。`todo_write` 本身不做任何实际工作,不能读文件、不能跑命令,只是维护一个带状态的任务列表。\n\ndispatch 机制不变,新工具仍然走 `TOOL_HANDLERS[block.name]` 分发。但为了演示 todo reminder,循环里加了一个计数器:连续 3 轮没调 `todo_write` 就注入一条提醒。\n\n---\n\n## 工作原理\n\n**todo_write 工具**,接收一个带状态的列表,保存在当前进程内存中,同时在终端显示进度:\n\n```python\nCURRENT_TODOS: list[dict] = []\n\ndef run_todo_write(todos: list) -> str:\n global CURRENT_TODOS\n CURRENT_TODOS = todos\n\n lines = [\"\\n## Current Tasks\"]\n for t in CURRENT_TODOS:\n icon = {\"pending\": \" \", \"in_progress\": \"▸\", \"completed\": \"✓\"}[t[\"status\"]]\n lines.append(f\" [{icon}] {t['content']}\")\n print(\"\\n\".join(lines))\n return f\"Updated {len(CURRENT_TODOS)} tasks\"\n```\n\n工具定义和其他 5 个工具一起加入 dispatch map:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n # s05: 新增一条\n {\"name\": \"todo_write\", \"description\": \"Create and manage a task list ...\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"content\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"completed\"]},\n },\n },\n },\n },\n },\n },\n]\n\nTOOL_HANDLERS[\"todo_write\"] = run_todo_write\n```\n\n**Nag reminder**,模型连续 3 轮没调 `todo_write` 时,自动注入一条提醒:\n\n```python\nif rounds_since_todo >= 3 and messages:\n messages.append({\n \"role\": \"user\",\n \"content\": \"Update your todos.\",\n })\n rounds_since_todo = 0\n```\n\nAgent 收到任务后的典型流程:先调 `todo_write` 列出所有步骤(全 `pending`)→ 做一个步骤,改成 `in_progress` → 做完改成 `completed` → 看下一个 `pending` → 继续。连续 3 轮没有调用 `todo_write` 时,循环会在下一次 LLM 调用前追加一条 reminder。\n\ntodo_write 不给 Agent 增加任何执行能力,只是多了一个带状态的列表。\n\n调不调 `todo_write`、列哪几步,完全由模型按任务复杂度自主决定。harness 不替模型规划——SYSTEM 提示只是引导,nag reminder 也只是往 messages 注入一条提示文本,模型下一轮仍可自行决定要不要更新。\n\n---\n\n## 相对 s04 的变更\n\n| 组件 | 之前 (s04) | 之后 (s05) |\n|------|-----------|-----------|\n| 工具数量 | 5 (bash, read, write, edit, glob) | 6 (+todo_write) |\n| 规划能力 | 无 | 带状态的 TODO 列表 + nag reminder |\n| SYSTEM 提示 | 通用提示 | 加入 todo_write 使用引导 |\n| 循环 | 不变 | dispatch 不变,新增 rounds_since_todo 计数器和 reminder 注入 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s05_todo_write/code.py\n```\n\n试试这些 prompt:\n\n1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard`(先列 3 步再执行)\n2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py`\n3. `Review Python files under s05_todo_write/example and fix any style issues`\n\n观察重点:第一次工具调用是不是 `todo_write`?TODO 列了几步?执行过程中状态有没有从 `pending` 变成 `in_progress` / `completed`?\n\n---\n\n## 接下来\n\nAgent 能计划了。但如果一个任务太大,比如\"重构整个认证模块\",光靠 TODO 列表不够。这个任务本身就是几十个小任务的集合,放在同一个对话里会被上下文淹没。\n\ns06 Subagent → 把大任务拆成子任务,每个子任务派一个独立的 Agent。它们有自己的干净上下文,不会互相污染。\n\n
\n深入 Claude Code 源码\n\nClaude Code 中有两套任务系统并存(`tasks.ts:133-139`):\n\n- **TodoWrite(V1)**:一个简单的列表工具,数据在内存 AppState 中维护(`TodoWriteTool.ts:65-103`)。教学版也保存在进程内存里,退出后清空\n- **Task System(V2 = s12)**:文件持久化、依赖图、并发锁、ownership\n\n切换由 `isTodoV2Enabled()` 控制。当前源码的实现逻辑:交互式会话中 V2 默认启用,非交互式会话(SDK)中 V1 默认启用;设置 `CLAUDE_CODE_ENABLE_TASKS` 环境变量可强制启用 V2。注意源码注释 \"Force-enable tasks in non-interactive mode\" 描述的是 env var 路径的用途,和默认分支的返回值语义不同,阅读时需区分。\n\n教学版省略了真实源码中的 `activeForm` 字段(`utils/todo/types.ts:8-15`)。Claude Code 用它给 UI spinner 展示\"正在做什么\",教学版只有终端输出,不需要这个字段。\n\n教学版的 nag reminder(3 轮未更新就注入提醒)是教学机制。Claude Code 源码中没有固定的\"3 轮\"逻辑,更接近的是 `TodoWriteTool.ts:72-107` 中当 3 个以上 todo 全部完成但没有 verification 项时,追加 verification nudge。\n\nTask System 相比 TodoWrite 的核心增量:\n- 文件持久化(Claude 配置目录下 `tasks/{taskListId}/{taskId}.json`)而非内存列表\n- `blockedBy` 依赖图而非平铺列表\n- `proper-lockfile` 并发安全而非无锁\n- 四个独立工具(Create/Get/Update/List)而非一个\n- TaskCreated / TaskCompleted hooks(`TaskCreateTool.ts:80-129`、`TaskUpdateTool.ts:231-260`)供外部系统集成\n\n
\n\n\n" }, { "version": "s05", "locale": "ja", "title": "s05: TodoWrite — 計画なき Agent は途中で道を外れる", - "content": "# s05: TodoWrite — 計画なき Agent は途中で道を外れる\n\ns01 → s02 → s03 → s04 → `s05` → [s06](/ja/s06) → s07 → ... → s20\n\n> *\"計画なき agent は風の向くままに\"* — まず手順を列挙してから実行。長いタスクで見落としが減る。\n>\n> **Harness レイヤー**: 計画 — Agent が行動する前に考えさせる。\n\n---\n\n## 課題\n\nAgent に複雑なタスクを与える:「全 Python ファイルを snake_case にリネームし、テストを実行し、失敗を修正して。」\n\nAgent は作業を開始する。3 つのファイルをリネーム、テストを実行、2 つの失敗を発見、修正を開始。修正しているうちに、本来の目的が「snake_case にリネーム」だったことを忘れる。テストの失敗に注意を全て持っていかれる。\n\n会話が長くなるほど悪化する:ツールの結果がコンテキストを埋め続け、システムプロンプトの影響力が希釈される。10 ステップのリファクタリング:ステップ 1-3 を終えた時点で Agent は即興で動き始める。ステップ 4-10 は既に注意の外に追い出されているから。\n\n---\n\n## ソリューション\n\n![Todo Overview](/course-assets/s05_todo_write/todo-overview.ja.svg)\n\n前章の最小フック構造を保持し、本章では新規の `todo_write` ツールとリマインダー機構に注目する。`todo_write` は実際の作業を何もしない。ファイルを読めない、コマンドを実行できない。Agent が手を動かす前に思考を整理できるようにするだけ。\n\nディスパッチ機構は変わらず、新ツールも `TOOL_HANDLERS[block.name]` を経由する。ただし、todo リマインダーのデモのため、ループにカウンターを追加した:連続 3 ラウンド `todo_write` を呼び出さないとリマインダーが注入される。\n\n---\n\n## 仕組み\n\n**todo_write ツール**は、ステータス付きのリストを受け取り、現在のプロセスメモリに保持し、端末に進捗を表示する:\n\n```python\nCURRENT_TODOS: list[dict] = []\n\ndef run_todo_write(todos: list) -> str:\n global CURRENT_TODOS\n CURRENT_TODOS = todos\n\n lines = [\"\\n## Current Tasks\"]\n for t in CURRENT_TODOS:\n icon = {\"pending\": \" \", \"in_progress\": \"▸\", \"completed\": \"✓\"}[t[\"status\"]]\n lines.append(f\" [{icon}] {t['content']}\")\n print(\"\\n\".join(lines))\n return f\"Updated {len(CURRENT_TODOS)} tasks\"\n```\n\nツール定義は他の 5 つと一緒にディスパッチマップに追加される:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n # s05: 新規追加\n {\"name\": \"todo_write\", \"description\": \"Create and manage a task list ...\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"content\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"completed\"]},\n },\n },\n },\n },\n },\n },\n]\n\nTOOL_HANDLERS[\"todo_write\"] = run_todo_write\n```\n\n**Nag リマインダー**、モデルが連続 3 ラウンド `todo_write` を呼び出さないとき、リマインダーが自動的に注入される(教育用機構、CC ソースコードに固定ラウンド数のロジックはない):\n\n```python\nif rounds_since_todo >= 3 and messages:\n messages.append({\n \"role\": \"user\",\n \"content\": \"Update your todos.\",\n })\n rounds_since_todo = 0\n```\n\nAgent がタスクを受け取った後の典型的な流れ:まず `todo_write` を呼び出して全手順を列挙(全て `pending`)→ 一つの手順に取り掛かり、`in_progress` に変更 → 完了したら `completed` に変更 → 次の `pending` を見る → 続行。3 ラウンド `todo_write` がない場合、次の LLM 呼び出し前にリマインダーが追加される。\n\n**重要な洞察**:todo_write は Agent に**実行能力**を何も追加しない。追加するのは**計画能力**だ。\n\n---\n\n## s04 からの変更\n\n| コンポーネント | 変更前 (s04) | 変更後 (s05) |\n|--------------|-------------|-------------|\n| ツール数 | 5 (bash, read, write, edit, glob) | 6 (+todo_write) |\n| 計画能力 | なし | ステータス付き TODO リスト + Nag リマインダー |\n| SYSTEM プロンプト | 汎用プロンプト | 「先に計画してから実行」のガイダンスを追加 |\n| ループ | 不変 | ディスパッチは不変、rounds_since_todo カウンターとリマインダー注入を追加 |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s05_todo_write/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard`(まず 3 手順を列挙してから実行するはず)\n2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py`\n3. `Review Python files under s05_todo_write/example and fix any style issues`\n\n観察のポイント:最初のツール呼び出しは `todo_write` か? TODO は何手順列挙されたか? 実行中にステータスが `pending` から `in_progress` / `completed` に変わったか?\n\n---\n\n## 次へ\n\nAgent は計画できるようになった。しかしタスクが大きすぎる場合、例えば「認証モジュール全体をリファクタリング」、TODO リストだけでは不十分。そのタスク自体が数十のサブタスクの集合体で、同じ会話のコンテキストに押し込めると溢れてしまう。\n\n→ s06 Subagent:大きなタスクをサブタスクに分割し、それぞれを独立した Agent に任せる。それぞれが独自のクリーンなコンテキストを持ち、相互汚染がない。\n\n
\nCC ソースコードを深掘り\n\nCC には二つのタスクシステムが共存している(`tasks.ts:133-139`):\n\n- **TodoWrite(V1)**:シンプルなリストツール、データはメモリ AppState で管理(`TodoWriteTool.ts:65-103`)。教育版もプロセスメモリに保持し、終了時に消える\n- **Task System(V2 = s12)**:ファイル永続化、依存グラフ、並行ロック、ownership\n\n切り替えは `isTodoV2Enabled()` で制御される。現在のソースコードの実装:対話型セッションでは V2 がデフォルトで有効、非対話型セッション(SDK)では V1 がデフォルトで有効。`CLAUDE_CODE_ENABLE_TASKS` 環境変数を設定するとセッション種別に関わらず V2 が強制有効になる。ソースコメント「Force-enable tasks in non-interactive mode」は環境変数パスの用途を説明しており、デフォルト分岐の戻り値のセマンティクスとは異なるため注意。\n\n教育版は実際のソースコードにある `activeForm` フィールドを省略している(`utils/todo/types.ts:8-15`)。CC は UI スピナーに「何をしているか」を表示するために使用するが、教育版は端末出力のみでこのフィールドは不要。\n\n教育版の Nag リマインダー(3 ラウンド未更新で注入)は教育用機構。CC ソースコードに固定「3 ラウンド」のロジックはなく、最も近いのは `TodoWriteTool.ts:72-107` で 3 つ以上の todo が全て完了しているのに verification 項目がない場合に verification nudge を追加する処理。\n\nTask System の TodoWrite に対する核心的な増分:\n- メモリリストではなくファイル永続化(Claude 設定ディレクトリ下 `tasks/{taskListId}/{taskId}.json`)\n- 平坦なリストではなく `blockedBy` 依存グラフ\n- ロックなしではなく `proper-lockfile` による並行安全性\n- 一つのツールではなく四つの独立ツール(Create/Get/Update/List)\n- TaskCreated / TaskCompleted フック(`TaskCreateTool.ts:80-129`、`TaskUpdateTool.ts:231-260`)による外部システム統合\n\n
\n\n\n" + "content": "# s05: TodoWrite — 計画なき Agent は途中で道を外れる\n\ns01 → s02 → s03 → s04 → `s05` → [s06](/ja/s06) → s07 → ... → s20\n\n> *\"計画なき agent は風の向くままに\"* — まず手順を列挙してから実行。長いタスクで見落としが減る。\n>\n> **Harness レイヤー**: 計画 — todo_write でステータス付きタスクリストを管理する。\n\n---\n\n## 課題\n\nAgent に複雑なタスクを与える:「全 Python ファイルを snake_case にリネームし、テストを実行し、失敗を修正して。」\n\nAgent は作業を開始する。3 つのファイルをリネーム、テストを実行、2 つの失敗を発見、修正を開始。修正しているうちに、本来の目的が「snake_case にリネーム」だったことを忘れる。テストの失敗に注意を全て持っていかれる。\n\n会話が長くなるほど悪化する:ツールの結果がコンテキストを埋め続け、システムプロンプトの影響力が希釈される。10 ステップのリファクタリング:ステップ 1-3 を終えた時点で Agent は即興で動き始める。ステップ 4-10 は既に注意の外に追い出されているから。\n\n---\n\n## ソリューション\n\n![Todo Overview](/course-assets/s05_todo_write/todo-overview.svg)\n\n前章の最小フック構造を保持し、本章では新規の `todo_write` ツールとリマインダー機構に注目する。`todo_write` は実際の作業を何もしない。ファイルを読めない、コマンドを実行できない。ステータス付きのタスクリストを管理するだけ。\n\nディスパッチ機構は変わらず、新ツールも `TOOL_HANDLERS[block.name]` を経由する。ただし、todo リマインダーのデモのため、ループにカウンターを追加した:連続 3 ラウンド `todo_write` を呼び出さないとリマインダーが注入される。\n\n---\n\n## 仕組み\n\n**todo_write ツール**は、ステータス付きのリストを受け取り、現在のプロセスメモリに保持し、端末に進捗を表示する:\n\n```python\nCURRENT_TODOS: list[dict] = []\n\ndef run_todo_write(todos: list) -> str:\n global CURRENT_TODOS\n CURRENT_TODOS = todos\n\n lines = [\"\\n## Current Tasks\"]\n for t in CURRENT_TODOS:\n icon = {\"pending\": \" \", \"in_progress\": \"▸\", \"completed\": \"✓\"}[t[\"status\"]]\n lines.append(f\" [{icon}] {t['content']}\")\n print(\"\\n\".join(lines))\n return f\"Updated {len(CURRENT_TODOS)} tasks\"\n```\n\nツール定義は他の 5 つと一緒にディスパッチマップに追加される:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n # s05: 新規追加\n {\"name\": \"todo_write\", \"description\": \"Create and manage a task list ...\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"todos\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"object\",\n \"properties\": {\n \"content\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\", \"enum\": [\"pending\", \"in_progress\", \"completed\"]},\n },\n },\n },\n },\n },\n },\n]\n\nTOOL_HANDLERS[\"todo_write\"] = run_todo_write\n```\n\n**Nag リマインダー**、モデルが連続 3 ラウンド `todo_write` を呼び出さないとき、リマインダーが自動的に注入される:\n\n```python\nif rounds_since_todo >= 3 and messages:\n messages.append({\n \"role\": \"user\",\n \"content\": \"Update your todos.\",\n })\n rounds_since_todo = 0\n```\n\nAgent がタスクを受け取った後の典型的な流れ:まず `todo_write` を呼び出して全手順を列挙(全て `pending`)→ 一つの手順に取り掛かり、`in_progress` に変更 → 完了したら `completed` に変更 → 次の `pending` を見る → 続行。3 ラウンド `todo_write` がない場合、次の LLM 呼び出し前にリマインダーが追加される。\n\ntodo_write は Agent に実行能力を何も追加しない。ステータス付きのリストが増えるだけだ。\n\n`todo_write` を呼ぶかどうか、どの手順を列挙するかは、タスクの複雑さに応じてモデル自身が決める。harness はモデルの代わりに計画しない——SYSTEM プロンプトは誘導するだけ、nag reminder も messages にテキストを一つ注入するだけで、モデルは次のラウンドで更新するかどうかを自分で決められる。\n\n---\n\n## s04 からの変更\n\n| コンポーネント | 変更前 (s04) | 変更後 (s05) |\n|--------------|-------------|-------------|\n| ツール数 | 5 (bash, read, write, edit, glob) | 6 (+todo_write) |\n| 計画能力 | なし | ステータス付き TODO リスト + Nag リマインダー |\n| SYSTEM プロンプト | 汎用プロンプト | todo_write 使用ガイダンスを追加 |\n| ループ | 不変 | ディスパッチは不変、rounds_since_todo カウンターとリマインダー注入を追加 |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s05_todo_write/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Refactor s05_todo_write/example/hello.py: add type hints, docstrings, and a main guard`(まず 3 手順を列挙してから実行するはず)\n2. `Create a Python package under s05_todo_write/example/demo_pkg with __init__.py, utils.py, and tests/test_utils.py`\n3. `Review Python files under s05_todo_write/example and fix any style issues`\n\n観察のポイント:最初のツール呼び出しは `todo_write` か? TODO は何手順列挙されたか? 実行中にステータスが `pending` から `in_progress` / `completed` に変わったか?\n\n---\n\n## 次へ\n\nAgent は計画できるようになった。しかしタスクが大きすぎる場合、例えば「認証モジュール全体をリファクタリング」、TODO リストだけでは不十分。そのタスク自体が数十のサブタスクの集合体で、同じ会話のコンテキストに押し込めると溢れてしまう。\n\n→ s06 Subagent:大きなタスクをサブタスクに分割し、それぞれを独立した Agent に任せる。それぞれが独自のクリーンなコンテキストを持ち、相互汚染がない。\n\n
\nClaude Code ソースコードを深掘り\n\nClaude Code には二つのタスクシステムが共存している(`tasks.ts:133-139`):\n\n- **TodoWrite(V1)**:シンプルなリストツール、データはメモリ AppState で管理(`TodoWriteTool.ts:65-103`)。教育版もプロセスメモリに保持し、終了時に消える\n- **Task System(V2 = s12)**:ファイル永続化、依存グラフ、並行ロック、ownership\n\n切り替えは `isTodoV2Enabled()` で制御される。現在のソースコードの実装:対話型セッションでは V2 がデフォルトで有効、非対話型セッション(SDK)では V1 がデフォルトで有効。`CLAUDE_CODE_ENABLE_TASKS` 環境変数を設定するとセッション種別に関わらず V2 が強制有効になる。ソースコメント「Force-enable tasks in non-interactive mode」は環境変数パスの用途を説明しており、デフォルト分岐の戻り値のセマンティクスとは異なるため注意。\n\n教育版は実際のソースコードにある `activeForm` フィールドを省略している(`utils/todo/types.ts:8-15`)。Claude Code は UI スピナーに「何をしているか」を表示するために使用するが、教育版は端末出力のみでこのフィールドは不要。\n\n教育版の Nag リマインダー(3 ラウンド未更新で注入)は教育用機構。Claude Code ソースコードに固定「3 ラウンド」のロジックはなく、最も近いのは `TodoWriteTool.ts:72-107` で 3 つ以上の todo が全て完了しているのに verification 項目がない場合に verification nudge を追加する処理。\n\nTask System の TodoWrite に対する核心的な増分:\n- メモリリストではなくファイル永続化(Claude 設定ディレクトリ下 `tasks/{taskListId}/{taskId}.json`)\n- 平坦なリストではなく `blockedBy` 依存グラフ\n- ロックなしではなく `proper-lockfile` による並行安全性\n- 一つのツールではなく四つの独立ツール(Create/Get/Update/List)\n- TaskCreated / TaskCompleted フック(`TaskCreateTool.ts:80-129`、`TaskUpdateTool.ts:231-260`)による外部システム統合\n\n
\n\n\n" }, { "version": "s06", "locale": "en", "title": "s06: Subagent — Break Large Tasks into Small Ones with Clean Context", - "content": "# s06: Subagent — Break Large Tasks into Small Ones with Clean Context\n\ns01 → s02 → s03 → s04 → s05 → `s06` → [s07](/en/s07) → s08 → ... → s20\n\n> *\"Break large tasks small, each with clean context\"* — Subagent uses an independent messages[], no pollution in the main conversation.\n>\n> **Harness Layer**: Sub-Agent — Context isolation, attention doesn't drift.\n\n---\n\n## The Problem\n\nThe Agent is fixing a bug. It reads 30 files to trace the call chain, chatting for 60 rounds along the way. The messages list grows to 120 entries, most of which are intermediate steps from \"tracing the call chain\" — unrelated to the final goal of \"fixing the bug.\"\n\nThese intermediate steps occupy context space, making the Agent increasingly \"forgetful\" — it can no longer remember what the original problem was.\n\nThink of it differently: when you fix a bug, you'd \"open a new terminal\" to trace the call chain. When done, close the terminal, write the result into your notes, and return to the original terminal to keep fixing. The Agent needs this ability too — **open an independent sub-process, give it an independent message list, let it focus on one thing.**\n\n---\n\n## The Solution\n\n![Subagent Overview](/course-assets/s06_subagent/subagent-overview.en.svg)\n\nThe minimal hook structure and `todo_write` tool from the previous chapter are preserved; this chapter focuses on the new `task` tool. When called, it spawns a sub-Agent with a fresh `messages[]`, running its own loop, and returning only a summary text to the main Agent. Conversation context is discarded, but file system side effects (writes, edits, commands) remain in the working directory.\n\nThe sub-Agent's tools are restricted: it has bash/read/write/edit/glob, but no task, preventing recursive spawning. The sub-Agent's tool calls still go through permission hooks; context isolation does not bypass security.\n\n---\n\n## How It Works\n\n**spawn_subagent**, gives the sub-Agent a fresh messages list, runs its own loop, returns only the conclusion:\n\n```python\ndef spawn_subagent(description: str) -> str:\n # Sub-Agent tools: base tools, but no task (no recursion)\n sub_tools = [...]\n messages = [{\"role\": \"user\", \"content\": description}] # fresh messages[]\n\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=sub_tools, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({... \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown\"\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({... \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # Return only the final text conclusion, all intermediate steps discarded\n return extract_text(messages[-1][\"content\"])\n```\n\nThe main Agent calls it just like any other tool:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n {\"name\": \"todo_write\", ...},\n # s06: new task tool\n {\"name\": \"task\",\n \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n]\n\nTOOL_HANDLERS[\"task\"] = spawn_subagent\n```\n\nThree key design decisions:\n\n| Decision | Choice | Reason |\n|----------|--------|--------|\n| Context isolation | Fresh `messages[]` | Sub-Agent's intermediate steps don't pollute main Agent's context |\n| Return only conclusion | `extract_text(last_message)` | Not returning the entire messages list |\n| No recursion | Sub-Agent has no task tool | Prevents sub-Agent from spawning further sub-Agents |\n| Security not bypassed | Sub-Agent tool calls go through PreToolUse hook | Context isolation does not mean permission isolation |\n\nThe dispatch mechanism is unchanged; the task tool is routed through `TOOL_HANDLERS[block.name]`. The sub-Agent has its own `SUB_SYSTEM` prompt, explicitly instructing \"complete the task, do not delegate further.\"\n\n---\n\n## Changes from s05\n\n| Component | Before (s05) | After (s06) |\n|-----------|-------------|-------------|\n| Tool count | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) |\n| New function | — | spawn_subagent (independent messages[] + 30-round safety limit) |\n| Context isolation | Everything in the main conversation | Sub-Agent uses fresh messages[] |\n| Loop | Unchanged | Dispatch unchanged, sub-Agent has independent SUB_SYSTEM and hook-protected loop |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s06_subagent/code.py\n```\n\nTry these prompts:\n\n1. `Use a subtask to find what testing framework this project uses` (sub-Agent reads files, main Agent receives only the conclusion)\n2. `Delegate: read all .py files in agents/ and summarize what each one does`\n3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`\n\nWhat to watch for: Do `[Subagent spawned]` / `[Subagent done]` appear? Do sub-Agent tool calls print as `[sub] ...`? Does the parent Agent continue with only the summary returned by the sub-Agent?\n\n---\n\n## What's Next\n\nThe Agent can now break tasks apart. But different tasks require different knowledge: editing frontend components needs React conventions, writing SQL needs table schemas. Stuffing all this knowledge into the system prompt would blow up the context.\n\n→ s07 Skill Loading: Inject skills on demand instead of piling documents into the system prompt. Load only when needed, as natural as reading a file.\n\n
\nDive into CC Source Code\n\n> The following is based on a complete analysis of CC source code `AgentTool.tsx`, `runAgent.ts`, `forkSubagent.ts`, and `forkedAgent.ts`.\n\n### 1. Not One Pattern, but Three\n\nThe teaching version covers only \"fresh messages[]\". CC actually has three execution modes:\n\n| Mode | Trigger | Context |\n|------|---------|---------|\n| **Normal Subagent** | `subagent_type` specified (normal path) | Truly fresh messages[], only the prompt |\n| **Fork Subagent** | No `subagent_type`, fork gate enabled | Constructs cache-friendly prefix via `buildForkedMessages()`, shares prompt cache |\n| **General-Purpose** | No `subagent_type`, fork gate disabled | Same as Normal |\n\n### 2. Fork Mode: Sharing Prompt Cache\n\nThis is a core concept the teaching version omits. Fork mode (`forkSubagent.ts:60-71`) doesn't create a fresh context. Instead, it constructs a cache-friendly message prefix via `buildForkedMessages()` (`forkSubagent.ts:107-168`), preserving the parent assistant message and generating placeholder tool results. The goal isn't isolation, but making the Anthropic API's prompt cache hit: parent and child Agent's system prompt, tools, and message prefix are byte-identical, so the API doesn't need to recompute.\n\nFive key components for cache hit (`forkedAgent.ts:57-68`): system prompt, tools, model, message prefix, thinking config, must be byte-identical.\n\n### 3. Context Isolation's Precise Granularity\n\n`createSubagentContext()` (`forkedAgent.ts:345-462`) creates the sub-Agent's `ToolUseContext`:\n\n| Field | Behavior |\n|-------|----------|\n| `abortController` | New child controller; parent abort propagates down |\n| `setAppState` | Default no-op; but sync agents share via `shareSetAppState` (`runAgent.ts:697-714`) |\n| `readFileState` | **Cloned from parent** (avoids re-reading same files) |\n| `queryTracking` | New chainId, `depth = parentDepth + 1` |\n\nThe sub-Agent isn't fully isolated: file read state is shared. The degree of UI and notification isolation varies by execution path (sync/async/fork/teammate differ).\n\n### 4. Recursive Fork Protection\n\nThe teaching version uses \"sub-Agent has no task tool\" for recursion protection. The real implementation is more nuanced: `isInForkChild()` (`forkSubagent.ts:78-89`) checks for `FORK_BOILERPLATE_TAG` in history. But `constants/tools.ts:36-46` defaults `Agent` to all agents' disabled set (with `USER_TYPE === 'ant'` exception); `forkSubagent.ts:73-89` has fork-child-specific recursion protection; `agentToolUtils.ts:100-110` has special allowances in teammate scenarios. Not simply \"no further sub-Agents.\"\n\n### 5. Permission Bubbling\n\nFork Agent's `permissionMode: 'bubble'` (`forkSubagent.ts:67`) means the sub-Agent's permission prompts bubble up to the parent terminal: the user approves sub-Agent operations in the main terminal.\n\n### 6. Async vs Sync\n\nThe teaching version only shows synchronous sub-Agents (parent waits for child to finish). CC also supports async paths (`AgentTool.tsx:686-764`): when `run_in_background: true`, the sub-Agent launches asynchronously, returning `{ status: 'async_launched' }` immediately to the parent, and notifies the parent when complete. Actual triggers go beyond `run_in_background`, including auto-background, assistant force async, and coordinator/proactive paths.\n\n### Teaching Version Simplifications Are Intentional\n\n- Three modes → one (fresh messages): conceptually clear\n- Prompt cache sharing → omitted: teaching version doesn't involve API-layer optimization\n- Recursive fork protection → simplified to \"sub-Agent has no task tool\"\n- Async → omitted (left for s13): s06 focuses on the synchronous model first\n\n
\n\n\n" + "content": "# s06: Subagent — Break Large Tasks into Small Ones with Clean Context\n\ns01 → s02 → s03 → s04 → s05 → `s06` → [s07](/en/s07) → s08 → ... → s20\n\n> *\"Break large tasks small, each with clean context\"* — Subagent uses an independent messages[], no pollution in the main conversation.\n>\n> **Harness Layer**: Sub-Agent — Context isolation, attention doesn't drift.\n\n---\n\n## The Problem\n\nThe Agent is fixing a bug. It reads 30 files to trace the call chain, chatting for 60 rounds along the way. The messages list grows to 120 entries, most of which are intermediate steps from \"tracing the call chain,\" unrelated to the final goal of \"fixing the bug.\"\n\nThese intermediate steps occupy context space, pushing early information out of the effective window and degrading the model's response quality for the original problem.\n\nThink of it differently: when you fix a bug, you'd \"open a new terminal\" to trace the call chain. When done, close the terminal, write the result into your notes, and return to the original terminal to keep fixing. The Agent needs the same capability: spawn an independent sub-process with its own message list, scoped to a single task.\n\n---\n\n## The Solution\n\n![Subagent Overview](/course-assets/s06_subagent/subagent-overview.svg)\n\nThe minimal hook structure and `todo_write` tool from the previous chapter are preserved; this chapter focuses on the new `task` tool. When called, it spawns a sub-Agent with a fresh `messages[]`, running its own loop, and returning only a summary text to the main Agent. Conversation context is discarded, but file system side effects (writes, edits, commands) remain in the working directory.\n\nThe sub-Agent's tools are restricted: it has bash/read/write/edit/glob, but no task, preventing recursive spawning. The sub-Agent's tool calls still go through permission hooks; context isolation does not bypass security.\n\n---\n\n## How It Works\n\n**spawn_subagent**, gives the sub-Agent a fresh messages list, runs its own loop, returns only the conclusion:\n\n```python\ndef spawn_subagent(description: str) -> str:\n # Sub-Agent tools: base tools, but no task (no recursion)\n sub_tools = [...]\n messages = [{\"role\": \"user\", \"content\": description}] # fresh messages[]\n\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=sub_tools, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({... \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown\"\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({... \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # Return only the final text conclusion, all intermediate steps discarded\n return extract_text(messages[-1][\"content\"])\n```\n\nThe main Agent calls it just like any other tool:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n {\"name\": \"todo_write\", ...},\n # s06: new task tool\n {\"name\": \"task\",\n \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n]\n\nTOOL_HANDLERS[\"task\"] = spawn_subagent\n```\n\nThree key design decisions:\n\n| Decision | Choice | Reason |\n|----------|--------|--------|\n| Context isolation | Fresh `messages[]` | Sub-Agent's intermediate steps don't pollute main Agent's context |\n| Return only conclusion | `extract_text(last_message)` | Not returning the entire messages list |\n| No recursion | Sub-Agent has no task tool | Prevents sub-Agent from spawning further sub-Agents |\n| Security not bypassed | Sub-Agent tool calls go through PreToolUse hook | Context isolation does not mean permission isolation |\n\nThe dispatch mechanism is unchanged; the task tool is routed through `TOOL_HANDLERS[block.name]`. The sub-Agent has its own `SUB_SYSTEM` prompt, explicitly instructing \"complete the task, do not delegate further.\"\n\n---\n\n## Changes from s05\n\n| Component | Before (s05) | After (s06) |\n|-----------|-------------|-------------|\n| Tool count | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) |\n| New function | — | spawn_subagent (independent messages[] + 30-round safety limit) |\n| Context isolation | Everything in the main conversation | Sub-Agent uses fresh messages[] |\n| Loop | Unchanged | Dispatch unchanged, sub-Agent has independent SUB_SYSTEM and hook-protected loop |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s06_subagent/code.py\n```\n\nTry these prompts:\n\n1. `Use a subtask to find what testing framework this project uses` (sub-Agent reads files, main Agent receives only the conclusion)\n2. `Delegate: read all .py files in agents/ and summarize what each one does`\n3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`\n\nWhat to watch for: Do `[Subagent spawned]` / `[Subagent done]` appear? Do sub-Agent tool calls print as `[sub] ...`? Does the parent Agent continue with only the summary returned by the sub-Agent?\n\n---\n\n## What's Next\n\nThe Agent can now break tasks apart. But different tasks require different knowledge: editing frontend components needs React conventions, writing SQL needs table schemas. Stuffing all this knowledge into the system prompt would blow up the context.\n\n→ s07 Skill Loading: Inject skills on demand instead of piling documents into the system prompt. Load only when needed, as natural as reading a file.\n\n
\nDive into Claude Code Source Code\n\n> The following is based on a complete analysis of Claude Code source code `AgentTool.tsx`, `runAgent.ts`, `forkSubagent.ts`, and `forkedAgent.ts`.\n\n### 1. Not One Pattern, but Three\n\nThe teaching version covers only \"fresh messages[]\". Claude Code actually has three execution modes:\n\n| Mode | Trigger | Context |\n|------|---------|---------|\n| **Normal Subagent** | `subagent_type` specified (normal path) | Truly fresh messages[], only the prompt |\n| **Fork Subagent** | No `subagent_type`, fork gate enabled | Constructs cache-friendly prefix via `buildForkedMessages()`, shares prompt cache |\n| **General-Purpose** | No `subagent_type`, fork gate disabled | Same as Normal |\n\n### 2. Fork Mode: Sharing Prompt Cache\n\nThis is a core concept the teaching version omits. Fork mode (`forkSubagent.ts:60-71`) doesn't create a fresh context. Instead, it constructs a cache-friendly message prefix via `buildForkedMessages()` (`forkSubagent.ts:107-168`), preserving the parent assistant message and generating placeholder tool results. The goal isn't isolation, but making the Anthropic API's prompt cache hit: parent and child Agent's system prompt, tools, and message prefix are byte-identical, so the API doesn't need to recompute.\n\nFive key components for cache hit (`forkedAgent.ts:57-68`): system prompt, tools, model, message prefix, thinking config, must be byte-identical.\n\n### 3. Context Isolation's Precise Granularity\n\n`createSubagentContext()` (`forkedAgent.ts:345-462`) creates the sub-Agent's `ToolUseContext`:\n\n| Field | Behavior |\n|-------|----------|\n| `abortController` | New child controller; parent abort propagates down |\n| `setAppState` | Default no-op; but sync agents share via `shareSetAppState` (`runAgent.ts:697-714`) |\n| `readFileState` | **Cloned from parent** (avoids re-reading same files) |\n| `queryTracking` | New chainId, `depth = parentDepth + 1` |\n\nThe sub-Agent isn't fully isolated: file read state is shared. The degree of UI and notification isolation varies by execution path (sync/async/fork/teammate differ).\n\n### 4. Recursive Fork Protection\n\nThe teaching version uses \"sub-Agent has no task tool\" for recursion protection. The real implementation is more nuanced: `isInForkChild()` (`forkSubagent.ts:78-89`) checks for `FORK_BOILERPLATE_TAG` in history. But `constants/tools.ts:36-46` defaults `Agent` to all agents' disabled set (with `USER_TYPE === 'ant'` exception); `forkSubagent.ts:73-89` has fork-child-specific recursion protection; `agentToolUtils.ts:100-110` has special allowances in teammate scenarios. Not simply \"no further sub-Agents.\"\n\n### 5. Permission Bubbling\n\nFork Agent's `permissionMode: 'bubble'` (`forkSubagent.ts:67`) means the sub-Agent's permission prompts bubble up to the parent terminal: the user approves sub-Agent operations in the main terminal.\n\n### 6. Async vs Sync\n\nThe teaching version only shows synchronous sub-Agents (parent waits for child to finish). Claude Code also supports async paths (`AgentTool.tsx:686-764`): when `run_in_background: true`, the sub-Agent launches asynchronously, returning `{ status: 'async_launched' }` immediately to the parent, and notifies the parent when complete. Actual triggers go beyond `run_in_background`, including auto-background, assistant force async, and coordinator/proactive paths.\n\n### Teaching Version Simplifications Are Intentional\n\n- Three modes → one (fresh messages): conceptually clear\n- Prompt cache sharing → omitted: teaching version doesn't involve API-layer optimization\n- Recursive fork protection → simplified to \"sub-Agent has no task tool\"\n- Async → omitted (left for s13): s06 focuses on the synchronous model first\n\n
\n\n\n" }, { "version": "s06", "locale": "zh", "title": "s06: Subagent — 大任务拆小,每个拿到的都是干净上下文", - "content": "# s06: Subagent — 大任务拆小,每个拿到的都是干净上下文\n\ns01 → s02 → s03 → s04 → s05 → `s06` → [s07](/zh/s07) → s08 → ... → s20\n\n> *\"大任务拆小, 每个小任务干净的上下文\"* — Subagent 用独立 messages[], 不污染主对话。\n>\n> **Harness 层**: 子 Agent — 上下文隔离, 注意力不漂移。\n\n---\n\n## 问题\n\nAgent 在修一个 bug。它读了 30 个文件来追踪调用链,中间聊了 60 轮。messages 列表涨到 120 条,其中大部分是\"追踪调用链\"的中间过程,和\"修 bug\"这个最终目标无关。\n\n这些中间过程占着上下文位置,让 Agent 越来越\"健忘\",它记不住最初的问题是什么了。\n\n换个角度:你修 bug 的时候,会\"开一个新终端\"来追踪调用链。追踪完了,终端关掉,结果写进笔记,回到原来的终端继续修 bug。Agent 也需要这个能力:开一个独立的子进程,给它一个独立的消息列表,让它专心做一件事。\n\n---\n\n## 解决方案\n\n![Subagent Overview](/course-assets/s06_subagent/subagent-overview.svg)\n\n保留上一章的最小 hook 结构和 `todo_write` 工具,本章重点转向新增的 `task` 工具。调用它时,spawn 一个子 Agent,拥有全新的 `messages[]`,跑自己的循环,结束后只把摘要文本回传给主 Agent。对话上下文被丢弃,但文件系统的副作用(写文件、改文件、跑命令)保留在工作目录中。\n\n子 Agent 的工具受限:有 bash/read/write/edit/glob,但没有 task,不能递归 spawn 新的子 Agent。子 Agent 的工具调用仍经过权限 hook,安全策略不因上下文隔离而跳过。\n\n---\n\n## 工作原理\n\n**spawn_subagent**,给子 Agent 一个全新的 messages 列表,跑自己的循环,只回传结论:\n\n```python\ndef spawn_subagent(description: str) -> str:\n # 子 Agent 的工具:基础工具,但没有 task(禁止递归)\n sub_tools = [\n {\"name\": \"bash\", ...}, {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...}, {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n ]\n messages = [{\"role\": \"user\", \"content\": description}] # 全新 messages[]\n\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=sub_tools, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({... \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown\"\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({... \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # 只返回最后的文本结论,中间过程全部丢弃\n return extract_text(messages[-1][\"content\"])\n```\n\n主 Agent 调用时,跟调其他工具一样:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n {\"name\": \"todo_write\", ...},\n # s06: 新增 task 工具\n {\"name\": \"task\",\n \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n]\n\nTOOL_HANDLERS[\"task\"] = spawn_subagent\n```\n\n三个关键设计决策:\n\n| 决策 | 选择 | 原因 |\n|------|------|------|\n| 上下文隔离 | 全新 `messages[]` | 子 Agent 的中间过程不污染主 Agent 的上下文 |\n| 只回传结论 | `extract_text(last_message)` | 不是回传整个 messages 列表 |\n| 禁止递归 | 子 Agent 无 task 工具 | 防止子 Agent 再 spawn 新的子 Agent |\n| 安全策略不跳过 | 子 Agent 工具调用也走 PreToolUse hook | 上下文隔离不代表权限隔离 |\n\ndispatch 机制不变,task 工具通过 `TOOL_HANDLERS[block.name]` 分发。子 Agent 有独立的 `SUB_SYSTEM` 提示,明确要求\"直接完成任务,不要再委派\"。\n\n---\n\n## 相对 s05 的变更\n\n| 组件 | 之前 (s05) | 之后 (s06) |\n|------|-----------|-----------|\n| 工具数量 | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) |\n| 新函数 | — | spawn_subagent(独立 messages[] + 30 轮安全限制) |\n| 上下文隔离 | 全部在主对话中 | 子 Agent 用全新的 messages[] |\n| 循环 | 不变 | dispatch 不变,子 Agent 有独立 SUB_SYSTEM 和 hook 保护的循环 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s06_subagent/code.py\n```\n\n试试这些 prompt:\n\n1. `Use a subtask to find what testing framework this project uses`(子 Agent 去读文件,主 Agent 只收结论)\n2. `Delegate: read all .py files in agents/ and summarize what each one does`\n3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`\n\n观察重点:是否出现 `[Subagent spawned]` / `[Subagent done]`?子 Agent 的工具调用是否以 `[sub] ...` 输出?主 Agent 最后是否只继续处理子 Agent 返回的摘要?\n\n---\n\n## 接下来\n\nAgent 现在能拆任务了。但每个任务需要的知识不一样:改前端组件需要知道 React 规范,写 SQL 需要知道表结构。这些知识全塞进 system prompt,上下文直接爆了。\n\ns07 Skill Loading → 技能按需注入,不在 system prompt 里堆文档。用到的时候才加载,和读文件一样自然。\n\n
\n深入 CC 源码\n\n> 以下基于 CC 源码 `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` 的完整分析。\n\n### 一、不是一种模式,是三种\n\n教学版只讲了\"全新的 messages[]\"。CC 实际有三种执行模式:\n\n| 模式 | 触发条件 | 上下文 |\n|------|---------|--------|\n| **Normal Subagent** | 指定了 `subagent_type`(normal path) | 全新 messages[],只有 prompt |\n| **Fork Subagent** | 没指定 `subagent_type`,fork gate 开启 | 通过 `buildForkedMessages()` 构造 cache-friendly 前缀,共享 prompt cache |\n| **General-Purpose** | 没指定 `subagent_type`,fork gate 关闭 | 同 Normal |\n\n### 二、Fork 模式:为了共享 Prompt Cache\n\n这是教学版没有的核心概念。Fork 模式(`forkSubagent.ts:60-71`)不创建全新上下文,而是通过 `buildForkedMessages()`(`forkSubagent.ts:107-168`)构造 cache-friendly 消息前缀,保留父 assistant message 并生成 placeholder tool results。目的不是隔离,而是让 Anthropic API 的 prompt cache 命中:父子 Agent 的 system prompt、tools、messages 前缀完全一致,API 端不需要重算。\n\n缓存命中的五个关键组件(`forkedAgent.ts:57-68`):system prompt、tools、model、messages 前缀、thinking config,必须字节级一致。\n\n### 三、Context Isolation 的精确粒度\n\n`createSubagentContext()`(`forkedAgent.ts:345-462`)创建子 Agent 的 `ToolUseContext`:\n\n| 字段 | 行为 |\n|------|------|\n| `abortController` | 新的 child controller,父 abort 向下传播 |\n| `setAppState` | 默认 no-op;但 sync agent 通过 `shareSetAppState` 共享(`runAgent.ts:697-714`) |\n| `readFileState` | **从父克隆**(避免重复读相同文件) |\n| `queryTracking` | 新 chainId,`depth = parentDepth + 1` |\n\n子 Agent 不是完全隔离的:文件读取状态是共享的。UI 和通知的隔离程度取决于执行路径(sync/async/fork/teammate 各不同)。\n\n### 四、递归 Fork 防护\n\n教学版用\"子 Agent 不给 task 工具\"表达递归保护。真实实现更精细:`isInForkChild()`(`forkSubagent.ts:78-89`)检查对话历史中是否有 `FORK_BOILERPLATE_TAG`,有就拒绝。但 `constants/tools.ts:36-46` 中 `Agent` 工具默认在所有 agent 的禁用集合里,`USER_TYPE === 'ant'` 时例外;`forkSubagent.ts:73-89` 针对 fork child 有专门的递归保护;`agentToolUtils.ts:100-110` 在 teammate 场景下有特殊放行。不是简单的\"禁止新的子 Agent\"。\n\n### 五、Permission Bubbling\n\nFork Agent 的 `permissionMode: 'bubble'`(`forkSubagent.ts:67`)意味着子 Agent 的权限弹窗冒泡到父终端,用户在主终端里审批子 Agent 的操作。\n\n### 六、Async vs Sync\n\n教学版只展示了同步子 Agent(父等着子跑完)。CC 还支持异步路径(`AgentTool.tsx:686-764`):`run_in_background: true` 时异步启动,返回 `{ status: 'async_launched' }` 立即给父 Agent,子 Agent 完成后通过通知机制告知父 Agent。实际触发条件不止 `run_in_background`,还有 auto-background、assistant force async、coordinator/proactive 等路径。\n\n### 教学版的简化是刻意的\n\n- 三种模式 → 一种(fresh messages):概念清晰\n- Prompt cache 共享 → 省略:教学版不涉及 API 层优化\n- 递归 fork 防护 → 简化为\"子 Agent 无 task 工具\"\n- Async → 省略(留给 s13):s06 先理解同步模型\n\n
\n\n\n" + "content": "# s06: Subagent — 大任务拆小,每个拿到的都是干净上下文\n\ns01 → s02 → s03 → s04 → s05 → `s06` → [s07](/zh/s07) → s08 → ... → s20\n\n> *\"大任务拆小, 每个小任务干净的上下文\"* — Subagent 用独立 messages[], 不污染主对话。\n>\n> **Harness 层**: 子 Agent — 上下文隔离, 注意力不漂移。\n\n---\n\n## 问题\n\nAgent 在修一个 bug。它读了 30 个文件来追踪调用链,中间聊了 60 轮。messages 列表涨到 120 条,其中大部分是\"追踪调用链\"的中间过程,和\"修 bug\"这个最终目标无关。\n\n这些中间过程占着上下文位置,早期的关键信息被挤出有效窗口,模型对最初的问题描述的响应质量下降。\n\n换个角度:你修 bug 的时候,会\"开一个新终端\"来追踪调用链。追踪完了,终端关掉,结果写进笔记,回到原来的终端继续修 bug。Agent 也需要这个能力:开一个独立的子进程,给它一个独立的消息列表,让它专心做一件事。\n\n---\n\n## 解决方案\n\n![Subagent Overview](/course-assets/s06_subagent/subagent-overview.svg)\n\n保留上一章的最小 hook 结构和 `todo_write` 工具,本章重点转向新增的 `task` 工具。调用它时,spawn 一个子 Agent,拥有全新的 `messages[]`,跑自己的循环,结束后只把摘要文本回传给主 Agent。对话上下文被丢弃,但文件系统的副作用(写文件、改文件、跑命令)保留在工作目录中。\n\n子 Agent 的工具受限:有 bash/read/write/edit/glob,但没有 task,不能递归 spawn 新的子 Agent。子 Agent 的工具调用仍经过权限 hook,安全策略不因上下文隔离而跳过。\n\n---\n\n## 工作原理\n\n**spawn_subagent**,给子 Agent 一个全新的 messages 列表,跑自己的循环,只回传结论:\n\n```python\ndef spawn_subagent(description: str) -> str:\n # 子 Agent 的工具:基础工具,但没有 task(禁止递归)\n sub_tools = [\n {\"name\": \"bash\", ...}, {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...}, {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n ]\n messages = [{\"role\": \"user\", \"content\": description}] # 全新 messages[]\n\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=sub_tools, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({... \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown\"\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({... \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # 只返回最后的文本结论,中间过程全部丢弃\n return extract_text(messages[-1][\"content\"])\n```\n\n主 Agent 调用时,跟调其他工具一样:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n {\"name\": \"todo_write\", ...},\n # s06: 新增 task 工具\n {\"name\": \"task\",\n \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n]\n\nTOOL_HANDLERS[\"task\"] = spawn_subagent\n```\n\n三个关键设计决策:\n\n| 决策 | 选择 | 原因 |\n|------|------|------|\n| 上下文隔离 | 全新 `messages[]` | 子 Agent 的中间过程不污染主 Agent 的上下文 |\n| 只回传结论 | `extract_text(last_message)` | 不是回传整个 messages 列表 |\n| 禁止递归 | 子 Agent 无 task 工具 | 防止子 Agent 再 spawn 新的子 Agent |\n| 安全策略不跳过 | 子 Agent 工具调用也走 PreToolUse hook | 上下文隔离不代表权限隔离 |\n\ndispatch 机制不变,task 工具通过 `TOOL_HANDLERS[block.name]` 分发。子 Agent 有独立的 `SUB_SYSTEM` 提示,明确要求\"直接完成任务,不要再委派\"。\n\n---\n\n## 相对 s05 的变更\n\n| 组件 | 之前 (s05) | 之后 (s06) |\n|------|-----------|-----------|\n| 工具数量 | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) |\n| 新函数 | — | spawn_subagent(独立 messages[] + 30 轮安全限制) |\n| 上下文隔离 | 全部在主对话中 | 子 Agent 用全新的 messages[] |\n| 循环 | 不变 | dispatch 不变,子 Agent 有独立 SUB_SYSTEM 和 hook 保护的循环 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s06_subagent/code.py\n```\n\n试试这些 prompt:\n\n1. `Use a subtask to find what testing framework this project uses`(子 Agent 去读文件,主 Agent 只收结论)\n2. `Delegate: read all .py files in agents/ and summarize what each one does`\n3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`\n\n观察重点:是否出现 `[Subagent spawned]` / `[Subagent done]`?子 Agent 的工具调用是否以 `[sub] ...` 输出?主 Agent 最后是否只继续处理子 Agent 返回的摘要?\n\n---\n\n## 接下来\n\nAgent 现在能拆任务了。但每个任务需要的知识不一样:改前端组件需要知道 React 规范,写 SQL 需要知道表结构。这些知识全塞进 system prompt,上下文直接爆了。\n\ns07 Skill Loading → 技能按需注入,不在 system prompt 里堆文档。用到的时候才加载,和读文件一样自然。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` 的完整分析。\n\n### 一、不是一种模式,是三种\n\n教学版只讲了\"全新的 messages[]\"。Claude Code 实际有三种执行模式:\n\n| 模式 | 触发条件 | 上下文 |\n|------|---------|--------|\n| **Normal Subagent** | 指定了 `subagent_type`(normal path) | 全新 messages[],只有 prompt |\n| **Fork Subagent** | 没指定 `subagent_type`,fork gate 开启 | 通过 `buildForkedMessages()` 构造 cache-friendly 前缀,共享 prompt cache |\n| **General-Purpose** | 没指定 `subagent_type`,fork gate 关闭 | 同 Normal |\n\n### 二、Fork 模式:为了共享 Prompt Cache\n\n这是教学版没有的核心概念。Fork 模式(`forkSubagent.ts:60-71`)不创建全新上下文,而是通过 `buildForkedMessages()`(`forkSubagent.ts:107-168`)构造 cache-friendly 消息前缀,保留父 assistant message 并生成 placeholder tool results。目的不是隔离,而是让 Anthropic API 的 prompt cache 命中:父子 Agent 的 system prompt、tools、messages 前缀完全一致,API 端不需要重算。\n\n缓存命中的五个关键组件(`forkedAgent.ts:57-68`):system prompt、tools、model、messages 前缀、thinking config,必须字节级一致。\n\n### 三、Context Isolation 的精确粒度\n\n`createSubagentContext()`(`forkedAgent.ts:345-462`)创建子 Agent 的 `ToolUseContext`:\n\n| 字段 | 行为 |\n|------|------|\n| `abortController` | 新的 child controller,父 abort 向下传播 |\n| `setAppState` | 默认 no-op;但 sync agent 通过 `shareSetAppState` 共享(`runAgent.ts:697-714`) |\n| `readFileState` | **从父克隆**(避免重复读相同文件) |\n| `queryTracking` | 新 chainId,`depth = parentDepth + 1` |\n\n子 Agent 不是完全隔离的:文件读取状态是共享的。UI 和通知的隔离程度取决于执行路径(sync/async/fork/teammate 各不同)。\n\n### 四、递归 Fork 防护\n\n教学版用\"子 Agent 不给 task 工具\"表达递归保护。真实实现更精细:`isInForkChild()`(`forkSubagent.ts:78-89`)检查对话历史中是否有 `FORK_BOILERPLATE_TAG`,有就拒绝。但 `constants/tools.ts:36-46` 中 `Agent` 工具默认在所有 agent 的禁用集合里,`USER_TYPE === 'ant'` 时例外;`forkSubagent.ts:73-89` 针对 fork child 有专门的递归保护;`agentToolUtils.ts:100-110` 在 teammate 场景下有特殊放行。不是简单的\"禁止新的子 Agent\"。\n\n### 五、Permission Bubbling\n\nFork Agent 的 `permissionMode: 'bubble'`(`forkSubagent.ts:67`)意味着子 Agent 的权限弹窗冒泡到父终端,用户在主终端里审批子 Agent 的操作。\n\n### 六、Async vs Sync\n\n教学版只展示了同步子 Agent(父等着子跑完)。Claude Code 还支持异步路径(`AgentTool.tsx:686-764`):`run_in_background: true` 时异步启动,返回 `{ status: 'async_launched' }` 立即给父 Agent,子 Agent 完成后通过通知机制告知父 Agent。实际触发条件不止 `run_in_background`,还有 auto-background、assistant force async、coordinator/proactive 等路径。\n\n### 教学版的简化是刻意的\n\n- 三种模式 → 一种(fresh messages):概念清晰\n- Prompt cache 共享 → 省略:教学版不涉及 API 层优化\n- 递归 fork 防护 → 简化为\"子 Agent 无 task 工具\"\n- Async → 省略(留给 s13):s06 先理解同步模型\n\n
\n\n\n" }, { "version": "s06", "locale": "ja", "title": "s06: Subagent — 大きなタスクを分割、それぞれがクリーンなコンテキストを取得", - "content": "# s06: Subagent — 大きなタスクを分割、それぞれがクリーンなコンテキストを取得\n\ns01 → s02 → s03 → s04 → s05 → `s06` → [s07](/ja/s07) → s08 → ... → s20\n\n> *\"大きなタスクは小さく、小さなタスクごとにクリーンなコンテキスト\"* — Subagent は独立した messages[] を使い、メイン会話を汚染しない。\n>\n> **Harness レイヤー**: サブエージェント — コンテキストの隔離、注意の散漫を防ぐ。\n\n---\n\n## 課題\n\nAgent がバグを修正している。呼び出しチェーンを追跡するために 30 のファイルを読み、途中で 60 ラウンドやり取りした。messages リストは 120 件に膨らみ、その大部分は「呼び出しチェーンの追跡」という中間過程 — 「バグ修正」という最終目標とは無関係。\n\nこの中間過程がコンテキストの席を占め、Agent はますます「健忘」になる — 最初の問題が何だったか覚えていられない。\n\n別の見方をすると:バグを修正するとき、あなたは「新しいターミナルを開いて」呼び出しチェーンを追跡するだろう。追跡が終わったらターミナルを閉じ、結果をメモに書き、元のターミナルに戻ってバグ修正を続ける。Agent にもこの能力が必要 — **独立したサブプロセスを開き、独立したメッセージリストを与え、一つのことに集中させる。**\n\n---\n\n## ソリューション\n\n![Subagent Overview](/course-assets/s06_subagent/subagent-overview.ja.svg)\n\n前章の最小フック構造と `todo_write` ツールを保持し、本章は新規の `task` ツールに注目する。呼び出されると、サブエージェントを spawn する。新しい `messages[]` を持ち、自分自身のループを実行し、終了後に要約テキストのみをメイン Agent に返す。会話コンテキストは破棄されるが、ファイルシステムの副作用(書き込み、編集、コマンド実行)は作業ディレクトリに残る。\n\nサブエージェントのツールは制限される:bash/read/write/edit/glob を持つが、task はない。再帰 spawn を防止する。サブエージェントのツール呼び出しも権限フックを経由する。コンテキスト分離は権限のバイパスではない。\n\n---\n\n## 仕組み\n\n**spawn_subagent**、サブエージェントに新しいメッセージリストを与え、自分自身のループを実行し、結論のみを返す:\n\n```python\ndef spawn_subagent(description: str) -> str:\n # サブエージェントのツール:基本ツールのみ、task なし(再帰禁止)\n sub_tools = [...]\n messages = [{\"role\": \"user\", \"content\": description}] # 新規 messages[]\n\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=sub_tools, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({... \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown\"\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({... \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # 最後のテキスト結論のみを返す、中間過程はすべて破棄\n return extract_text(messages[-1][\"content\"])\n```\n\nメイン Agent の呼び出しは、他のツールと同じ:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n {\"name\": \"todo_write\", ...},\n # s06: 新規 task ツール\n {\"name\": \"task\",\n \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n]\n\nTOOL_HANDLERS[\"task\"] = spawn_subagent\n```\n\n三つの重要な設計決定:\n\n| 決定 | 選択 | 理由 |\n|------|------|------|\n| コンテキスト隔離 | 新規 `messages[]` | サブエージェントの中間過程がメイン Agent のコンテキストを汚染しない |\n| 結論のみ返却 | `extract_text(last_message)` | messages リスト全体を返すのではない |\n| 再帰禁止 | サブエージェントに task ツールなし | サブエージェントがさらにサブエージェントを spawn するのを防止 |\n| セキュリティのバイパスなし | サブエージェントのツール呼び出しも PreToolUse フックを経由 | コンテキスト分離は権限分離ではない |\n\nディスパッチ機構は変わらず、task ツールは `TOOL_HANDLERS[block.name]` を経由する。サブエージェントは独立した `SUB_SYSTEM` プロンプトを持ち、「タスクを完了し、さらに委託しない」と明示される。\n\n---\n\n## s05 からの変更\n\n| コンポーネント | 変更前 (s05) | 変更後 (s06) |\n|--------------|-------------|-------------|\n| ツール数 | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) |\n| 新規関数 | — | spawn_subagent(独立 messages[] + 30 ラウンド安全制限) |\n| コンテキスト隔離 | すべてメイン会話内 | サブエージェントが新規 messages[] を使用 |\n| ループ | 不変 | ディスパッチは不変、サブエージェントに独立した SUB_SYSTEM とフック保護されたループ |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s06_subagent/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Use a subtask to find what testing framework this project uses`(サブエージェントがファイルを読み、メイン Agent は結論のみ受け取る)\n2. `Delegate: read all .py files in agents/ and summarize what each one does`\n3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`\n\n観察のポイント:`[Subagent spawned]` / `[Subagent done]` が表示されるか? サブエージェントのツール呼び出しが `[sub] ...` として出力されるか? 親 Agent はサブエージェントが返した要約だけを受け取って続行するか?\n\n---\n\n## 次へ\n\nAgent はタスクを分割できるようになった。しかし各タスクに必要な知識は異なる。フロントエンドコンポーネントの変更には React 規約が必要で、SQL を書くにはテーブル構造を知る必要がある。これらの知識をすべて system prompt に詰め込むと、コンテキストが溢れてしまう。\n\n→ s07 Skill Loading:スキルをオンデマンドで注入する。system prompt にドキュメントを積み上げるのではなく、必要なときだけ読み込む。ファイルを読むのと同じくらい自然に。\n\n
\nCC ソースコードを深掘り\n\n> 以下は CC ソースコード `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` の完全分析に基づく。\n\n### 一、一つのパターンではなく三つ\n\n教育版は「新規 messages[]」のみを取り上げる。CC には実際に三つの実行モードがある:\n\n| モード | トリガー | コンテキスト |\n|--------|---------|-------------|\n| **Normal Subagent** | `subagent_type` 指定時(normal path) | 新規 messages[]、プロンプトのみ |\n| **Fork Subagent** | `subagent_type` 未指定、fork gate 有効時 | `buildForkedMessages()` でキャッシュフレンドリーなプレフィックスを構築、プロンプトキャッシュを共有 |\n| **General-Purpose** | `subagent_type` 未指定、fork gate 無効時 | Normal と同じ |\n\n### 二、Fork モード:プロンプトキャッシュの共有のため\n\nこれは教育版にはない核心概念。Fork モード(`forkSubagent.ts:60-71`)は新規コンテキストを作成せず、`buildForkedMessages()`(`forkSubagent.ts:107-168`)でキャッシュフレンドリーなメッセージプレフィックスを構築する。親の assistant message を保持し、placeholder tool results を生成する。目的は隔離ではなく、Anthropic API のプロンプトキャッシュをヒットさせること:親子 Agent の system prompt、tools、messages プレフィックスがバイトレベルで一致するため、API 側で再計算が不要になる。\n\nキャッシュヒットの五つの重要コンポーネント(`forkedAgent.ts:57-68`):system prompt、tools、model、messages プレフィックス、thinking config、バイトレベルで一致する必要がある。\n\n### 三、コンテキスト隔離の精密な粒度\n\n`createSubagentContext()`(`forkedAgent.ts:345-462`)はサブエージェントの `ToolUseContext` を作成:\n\n| フィールド | 挙動 |\n|-----------|------|\n| `abortController` | 新しい子コントローラ、親の abort は下に伝播 |\n| `setAppState` | デフォルトは no-op、ただし sync agent は `shareSetAppState` で共有(`runAgent.ts:697-714`) |\n| `readFileState` | **親からクローン**(同じファイルの再読み込みを回避) |\n| `queryTracking` | 新しい chainId、`depth = parentDepth + 1` |\n\nサブエージェントは完全に隔離されているわけではない。ファイル読み取り状態は共有される。UI と通知の隔離度は実行パスにより異なる(sync/async/fork/teammate でそれぞれ異なる)。\n\n### 四、再帰 Fork 防護\n\n教育版は「サブエージェントに task ツールなし」で再帰防止を表現する。実際の実装はより精密:`isInForkChild()`(`forkSubagent.ts:78-89`)が会話履歴内の `FORK_BOILERPLATE_TAG` をチェックする。しかし `constants/tools.ts:36-46` では `Agent` ツールが全エージェントの無効セットにデフォルト設定(`USER_TYPE === 'ant'` 時は例外)、`forkSubagent.ts:73-89` は fork child 向けの専用再帰保護があり、`agentToolUtils.ts:100-110` は teammate シナリオで特別な許可がある。単純な「サブエージェントの再 spawn 禁止」ではない。\n\n### 五、Permission Bubbling\n\nFork Agent の `permissionMode: 'bubble'`(`forkSubagent.ts:67`)は、サブエージェントの権限プロンプトが親ターミナルにバブルアップすることを意味する。ユーザーはメインターミナルでサブエージェントの操作を承認する。\n\n### 六、Async vs Sync\n\n教育版は同期サブエージェントのみ(親が子の完了を待つ)を示す。CC は非同期パスもサポート(`AgentTool.tsx:686-764`):`run_in_background: true` の場合、サブエージェントは非同期で起動し、`{ status: 'async_launched' }` を直ちに親に返し、完了時に通知機構で親に知らせる。実際のトリガーは `run_in_background` だけでなく、auto-background、assistant force async、coordinator/proactive パスもある。\n\n### 教育版の簡略化は意図的\n\n- 三つのモード → 一つ(新規 messages):概念的に明確\n- プロンプトキャッシュ共有 → 省略:教育版は API 層の最適化を扱わない\n- 再帰 fork 防護 → 「サブエージェントに task ツールなし」に簡略化\n- Async → 省略(s13 に委ねる):s06 はまず同期モデルを理解する\n\n
\n\n\n" + "content": "# s06: Subagent — 大きなタスクを分割、それぞれがクリーンなコンテキストを取得\n\ns01 → s02 → s03 → s04 → s05 → `s06` → [s07](/ja/s07) → s08 → ... → s20\n\n> *\"大きなタスクは小さく、小さなタスクごとにクリーンなコンテキスト\"* — Subagent は独立した messages[] を使い、メイン会話を汚染しない。\n>\n> **Harness レイヤー**: サブエージェント — コンテキストの隔離、注意の散漫を防ぐ。\n\n---\n\n## 課題\n\nAgent がバグを修正している。呼び出しチェーンを追跡するために 30 のファイルを読み、途中で 60 ラウンドやり取りした。messages リストは 120 件に膨らみ、その大部分は「呼び出しチェーンの追跡」という中間過程であり、「バグ修正」という最終目標とは無関係。\n\nこの中間過程がコンテキストの席を占め、初期の重要な情報が有効ウィンドウから押し出され、元の問題に対するモデルの応答品質が低下する。\n\n別の見方をすると:バグを修正するとき、あなたは「新しいターミナルを開いて」呼び出しチェーンを追跡するだろう。追跡が終わったらターミナルを閉じ、結果をメモに書き、元のターミナルに戻ってバグ修正を続ける。Agent にも同じ仕組みが必要になる。独立したサブプロセスを spawn し、専用のメッセージリストで単一タスクに限定する。\n\n---\n\n## ソリューション\n\n![Subagent Overview](/course-assets/s06_subagent/subagent-overview.svg)\n\n前章の最小フック構造と `todo_write` ツールを保持し、本章は新規の `task` ツールに注目する。呼び出されると、サブエージェントを spawn する。新しい `messages[]` を持ち、自分自身のループを実行し、終了後に要約テキストのみをメイン Agent に返す。会話コンテキストは破棄されるが、ファイルシステムの副作用(書き込み、編集、コマンド実行)は作業ディレクトリに残る。\n\nサブエージェントのツールは制限される:bash/read/write/edit/glob を持つが、task はない。再帰 spawn を防止する。サブエージェントのツール呼び出しも権限フックを経由する。コンテキスト分離は権限のバイパスではない。\n\n---\n\n## 仕組み\n\n**spawn_subagent**、サブエージェントに新しいメッセージリストを与え、自分自身のループを実行し、結論のみを返す:\n\n```python\ndef spawn_subagent(description: str) -> str:\n # サブエージェントのツール:基本ツールのみ、task なし(再帰禁止)\n sub_tools = [...]\n messages = [{\"role\": \"user\", \"content\": description}] # 新規 messages[]\n\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=sub_tools, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({... \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown\"\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({... \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # 最後のテキスト結論のみを返す、中間過程はすべて破棄\n return extract_text(messages[-1][\"content\"])\n```\n\nメイン Agent の呼び出しは、他のツールと同じ:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n {\"name\": \"todo_write\", ...},\n # s06: 新規 task ツール\n {\"name\": \"task\",\n \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n]\n\nTOOL_HANDLERS[\"task\"] = spawn_subagent\n```\n\n三つの重要な設計決定:\n\n| 決定 | 選択 | 理由 |\n|------|------|------|\n| コンテキスト隔離 | 新規 `messages[]` | サブエージェントの中間過程がメイン Agent のコンテキストを汚染しない |\n| 結論のみ返却 | `extract_text(last_message)` | messages リスト全体を返すのではない |\n| 再帰禁止 | サブエージェントに task ツールなし | サブエージェントがさらにサブエージェントを spawn するのを防止 |\n| セキュリティのバイパスなし | サブエージェントのツール呼び出しも PreToolUse フックを経由 | コンテキスト分離は権限分離ではない |\n\nディスパッチ機構は変わらず、task ツールは `TOOL_HANDLERS[block.name]` を経由する。サブエージェントは独立した `SUB_SYSTEM` プロンプトを持ち、「タスクを完了し、さらに委託しない」と明示される。\n\n---\n\n## s05 からの変更\n\n| コンポーネント | 変更前 (s05) | 変更後 (s06) |\n|--------------|-------------|-------------|\n| ツール数 | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) |\n| 新規関数 | — | spawn_subagent(独立 messages[] + 30 ラウンド安全制限) |\n| コンテキスト隔離 | すべてメイン会話内 | サブエージェントが新規 messages[] を使用 |\n| ループ | 不変 | ディスパッチは不変、サブエージェントに独立した SUB_SYSTEM とフック保護されたループ |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s06_subagent/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Use a subtask to find what testing framework this project uses`(サブエージェントがファイルを読み、メイン Agent は結論のみ受け取る)\n2. `Delegate: read all .py files in agents/ and summarize what each one does`\n3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`\n\n観察のポイント:`[Subagent spawned]` / `[Subagent done]` が表示されるか? サブエージェントのツール呼び出しが `[sub] ...` として出力されるか? 親 Agent はサブエージェントが返した要約だけを受け取って続行するか?\n\n---\n\n## 次へ\n\nAgent はタスクを分割できるようになった。しかし各タスクに必要な知識は異なる。フロントエンドコンポーネントの変更には React 規約が必要で、SQL を書くにはテーブル構造を知る必要がある。これらの知識をすべて system prompt に詰め込むと、コンテキストが溢れてしまう。\n\n→ s07 Skill Loading:スキルをオンデマンドで注入する。system prompt にドキュメントを積み上げるのではなく、必要なときだけ読み込む。ファイルを読むのと同じくらい自然に。\n\n
\nClaude Code ソースコードを深掘り\n\n> 以下は Claude Code ソースコード `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` の完全分析に基づく。\n\n### 一、一つのパターンではなく三つ\n\n教育版は「新規 messages[]」のみを取り上げる。Claude Code には実際に三つの実行モードがある:\n\n| モード | トリガー | コンテキスト |\n|--------|---------|-------------|\n| **Normal Subagent** | `subagent_type` 指定時(normal path) | 新規 messages[]、プロンプトのみ |\n| **Fork Subagent** | `subagent_type` 未指定、fork gate 有効時 | `buildForkedMessages()` でキャッシュフレンドリーなプレフィックスを構築、プロンプトキャッシュを共有 |\n| **General-Purpose** | `subagent_type` 未指定、fork gate 無効時 | Normal と同じ |\n\n### 二、Fork モード:プロンプトキャッシュの共有のため\n\nこれは教育版にはない核心概念。Fork モード(`forkSubagent.ts:60-71`)は新規コンテキストを作成せず、`buildForkedMessages()`(`forkSubagent.ts:107-168`)でキャッシュフレンドリーなメッセージプレフィックスを構築する。親の assistant message を保持し、placeholder tool results を生成する。目的は隔離ではなく、Anthropic API のプロンプトキャッシュをヒットさせること:親子 Agent の system prompt、tools、messages プレフィックスがバイトレベルで一致するため、API 側で再計算が不要になる。\n\nキャッシュヒットの五つの重要コンポーネント(`forkedAgent.ts:57-68`):system prompt、tools、model、messages プレフィックス、thinking config、バイトレベルで一致する必要がある。\n\n### 三、コンテキスト隔離の精密な粒度\n\n`createSubagentContext()`(`forkedAgent.ts:345-462`)はサブエージェントの `ToolUseContext` を作成:\n\n| フィールド | 挙動 |\n|-----------|------|\n| `abortController` | 新しい子コントローラ、親の abort は下に伝播 |\n| `setAppState` | デフォルトは no-op、ただし sync agent は `shareSetAppState` で共有(`runAgent.ts:697-714`) |\n| `readFileState` | **親からクローン**(同じファイルの再読み込みを回避) |\n| `queryTracking` | 新しい chainId、`depth = parentDepth + 1` |\n\nサブエージェントは完全に隔離されているわけではない。ファイル読み取り状態は共有される。UI と通知の隔離度は実行パスにより異なる(sync/async/fork/teammate でそれぞれ異なる)。\n\n### 四、再帰 Fork 防護\n\n教育版は「サブエージェントに task ツールなし」で再帰防止を表現する。実際の実装はより精密:`isInForkChild()`(`forkSubagent.ts:78-89`)が会話履歴内の `FORK_BOILERPLATE_TAG` をチェックする。しかし `constants/tools.ts:36-46` では `Agent` ツールが全エージェントの無効セットにデフォルト設定(`USER_TYPE === 'ant'` 時は例外)、`forkSubagent.ts:73-89` は fork child 向けの専用再帰保護があり、`agentToolUtils.ts:100-110` は teammate シナリオで特別な許可がある。単純な「サブエージェントの再 spawn 禁止」ではない。\n\n### 五、Permission Bubbling\n\nFork Agent の `permissionMode: 'bubble'`(`forkSubagent.ts:67`)は、サブエージェントの権限プロンプトが親ターミナルにバブルアップすることを意味する。ユーザーはメインターミナルでサブエージェントの操作を承認する。\n\n### 六、Async vs Sync\n\n教育版は同期サブエージェントのみ(親が子の完了を待つ)を示す。Claude Code は非同期パスもサポート(`AgentTool.tsx:686-764`):`run_in_background: true` の場合、サブエージェントは非同期で起動し、`{ status: 'async_launched' }` を直ちに親に返し、完了時に通知機構で親に知らせる。実際のトリガーは `run_in_background` だけでなく、auto-background、assistant force async、coordinator/proactive パスもある。\n\n### 教育版の簡略化は意図的\n\n- 三つのモード → 一つ(新規 messages):概念的に明確\n- プロンプトキャッシュ共有 → 省略:教育版は API 層の最適化を扱わない\n- 再帰 fork 防護 → 「サブエージェントに task ツールなし」に簡略化\n- Async → 省略(s13 に委ねる):s06 はまず同期モデルを理解する\n\n
\n\n\n" }, { "version": "s07", "locale": "en", "title": "s07: Skill Loading — Load Only When Needed", - "content": "# s07: Skill Loading — Load Only When Needed\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/en/s08) → s09 → ... → s20\n> *\"Load when needed, don't stuff the prompt\"* — Inject via tool_result, not system prompt.\n>\n> **Harness Layer**: Knowledge — load on demand, don't fill the context.\n\n---\n\n## The Problem\n\nYour project has a React component spec, a SQL style guide, and an API design doc. You want the Agent to follow these specs automatically. The most straightforward idea — stuff them all into the system prompt:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read() # 2000 lines\n + open(\"docs/sql-style.md\").read() # 1500 lines\n + open(\"docs/api-design.md\").read() # 3000 lines\n)\n```\n\n6500 lines of system prompt. The Agent carries these docs on every LLM call — whether it's changing a CSS color or fixing a SQL query. 99% of the content is irrelevant to the current task, burning tokens for nothing.\n\n---\n\n## The Solution\n\n![Skill Overview](/course-assets/s07_skill_loading/skill-overview.en.svg)\n\nThe minimal hook structure, `todo_write`, and sub-Agent from the previous chapter are preserved. This chapter focuses on the new `load_skill` tool. At startup, inject the skill catalog into the SYSTEM prompt; at runtime, register one more tool to load full content, spending tokens only when used.\n\nTwo-level design:\n\n| Level | Location | Timing | Cost |\n|-------|----------|--------|------|\n| 1. Catalog | system prompt | Injected at startup (harness scans skills/) | ~100 tokens/skill, carried every turn |\n| 2. Content | tool_result | When Agent calls load_skill; SKILL.md can guide later read_file/bash access to extra resources | ~2000 tokens/skill, on demand |\n\nThe dispatch mechanism is unchanged, `load_skill` auto-dispatches via `TOOL_HANDLERS[block.name]`.\n\n---\n\n## How It Works\n\n**skills/ directory**, one subdirectory per skill, each containing a `SKILL.md` file:\n\n```\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n**Level 1: Inject catalog at startup**: the harness calls `_scan_skills()` at startup to scan the skills/ directory, parsing each SKILL.md's YAML frontmatter (`name`, `description`) into a `SKILL_REGISTRY` dictionary. `list_skills()` generates the catalog from the registry, injected into the SYSTEM prompt. The Agent sees \"which skills I have available\" every turn, with no extra API calls:\n\n```python\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills() # runs once at startup\n\ndef list_skills() -> str:\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n```\n\n**Level 2: load_skill**: the Agent decides \"I need the SQL style guide\" and calls `load_skill(\"sql-style\")`. Lookup goes through the registry, not file paths, eliminating path traversal risk. The SKILL.md content is injected via `tool_result`, and can include later access to referenced `references/`, `scripts/`, or `assets/` through the existing file and bash tools.\n\n```python\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n```\n\nThe key distinction: skill content is not part of the system prompt. It enters the current messages as a tool result. Subsequent calls carry it along with the history until context compaction, truncation, or session end. This naturally connects to s08's compact: on-demand loading solves \"don't carry what you shouldn't\", compact solves \"how to drop what you should.\"\n\n---\n\n## Changes from s06\n\n| Component | Before (s06) | After (s07) |\n|-----------|-------------|-------------|\n| Tool count | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |\n| Knowledge loading | None | Two-level: startup catalog in SYSTEM + runtime load_skill; SKILL.md may guide later resource access |\n| SYSTEM prompt | Static string | Startup scan of skills/ injects catalog |\n| Skill registry | None | SKILL_REGISTRY (populated at startup, prevents path traversal) |\n| Loop | Unchanged | Unchanged (skill tool auto-dispatches) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\nTry these prompts:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n\nWhat to watch for: Does the Agent know available skills from the SYSTEM catalog? Does `[HOOK] load_skill` appear when full instructions are needed? Does the answer use the loaded skill's instructions?\n\n---\n\n## What's Next\n\nOn-demand loading solved \"don't carry what you shouldn't.\" But another problem looms: after the Agent works for 30 minutes, the messages list fills up with intermediate process. Old tool_results, stale file contents, occupying context but adding no value.\n\n→ s08 Context Compact: A four-layer compaction strategy. Cheap layers run first, expensive layers run last.\n\n
\nDive into CC Source Code\n\n> The following is based on analysis of CC source code `loadSkillsDir.ts`, `SkillTool.ts`, `bundledSkills.ts`, `commands.ts`.\n\n### 1. Skill Sources: Not Just One skills/ Directory\n\nThe teaching version assumes all skills live in a `skills/` directory. CC loads from multiple sources spread across multiple files: `loadSkillsDir.ts` handles user/project/`--add-dir` directories and legacy commands (`.claude/commands/`); `bundledSkills.ts` handles built-in skills; `SkillTool.ts` handles MCP remote skills; `commands.ts` handles command aggregation. Types include managed/policy skills, user skills (`~/.claude/skills/`), project skills (`.claude/skills/`), `--add-dir` skills, legacy commands, dynamic skills, conditional skills (with `paths` frontmatter, activated by file path), bundled skills, plugin skills, MCP skills.\n\n### 2. SKILL.md Frontmatter — Common Fields\n\nCC's SKILL.md YAML frontmatter is parsed by `parseSkillFrontmatterFields()` in `loadSkillsDir.ts`. Common fields include:\n\n| Field | Purpose |\n|-------|---------|\n| `name` / `description` | Display name and description |\n| `when_to_use` | Guides the model on when to invoke |\n| `allowed-tools` | Auto-allow list of tools available to the skill |\n| `context` | `inline` (default) or `fork` (run as sub-Agent) |\n| `model` | Model override (haiku/sonnet/opus/inherit) |\n| `hooks` | Skill-level hook configuration |\n| `paths` | Glob patterns for conditional activation |\n| `user-invocable` | Users can invoke via `/name` |\n\nThe complete field list changes across versions; above are the core fields relevant to the teaching version.\n\n### 3. Precise Implementation of Two-Level Loading\n\n1. **Catalog (at startup)**: `getSkillDirCommands()` scans directory → registers as `Command` objects containing only metadata. `getSkillListingAttachments()` formats the skill list as attachments, budgeted at ~1% of the context window (cap 8000 characters).\n2. **Load (on invocation)**: Model calls `Skill` tool (input fields are `skill` + optional `args`; teaching version uses `name`) → `getPromptForCommand()` expands full SKILL.md content → `SkillTool` returns a tool_result with display text `\"Launching skill: {name}\"`, while the actual skill content is injected via `newMessages`. The teaching version merges both into \"injected via tool_result\" as a simplification; the loaded SKILL.md can still guide later access to referenced resources through existing file/bash tools.\n\n### The Teaching Version's Simplification Is Intentional\n\n- Multiple files and sources → 1 `skills/` directory: sufficient to demonstrate the core concept of two-level loading\n- Multiple frontmatter fields → only parse name/description: reduces parsing complexity\n- Forked skills (`context: 'fork'`) → omitted: the teaching version only expands inline skill loading\n- `Skill` tool input `skill`+`args` → teaching version uses `name`: avoids extra argument parsing complexity\n\n
\n\n\n" + "content": "# s07: Skill Loading — Load Only When Needed\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/en/s08) → s09 → ... → s20\n> *\"Load when needed, don't stuff the prompt\"* — Inject via tool_result, not system prompt.\n>\n> **Harness Layer**: Knowledge — load on demand, don't fill the context.\n\n---\n\n## The Problem\n\nYour project has a React component spec, a SQL style guide, and an API design doc. You want the Agent to follow these specs automatically. The most straightforward idea: stuff them all into the system prompt:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read() # 2000 lines\n + open(\"docs/sql-style.md\").read() # 1500 lines\n + open(\"docs/api-design.md\").read() # 3000 lines\n)\n```\n\n6500 lines of system prompt. The Agent carries these docs on every LLM call, whether it's changing a CSS color or fixing a SQL query. 99% of the content is irrelevant to the current task, burning tokens for nothing.\n\n---\n\n## The Solution\n\n![Skill Overview](/course-assets/s07_skill_loading/skill-overview.svg)\n\nThe minimal hook structure, `todo_write`, and sub-Agent from the previous chapter are preserved. This chapter focuses on the new `load_skill` tool. At startup, inject the skill catalog into the SYSTEM prompt; at runtime, register one more tool to load full content, spending tokens only when used.\n\nTwo-level design:\n\n| Level | Location | Timing | Cost |\n|-------|----------|--------|------|\n| 1. Catalog | system prompt | Injected at startup (harness scans skills/) | ~100 tokens/skill, carried every turn |\n| 2. Content | tool_result | When Agent calls load_skill; SKILL.md can guide later read_file/bash access to extra resources | ~2000 tokens/skill, on demand |\n\nThe dispatch mechanism is unchanged, `load_skill` auto-dispatches via `TOOL_HANDLERS[block.name]`.\n\n---\n\n## How It Works\n\n**skills/ directory**, one subdirectory per skill, each containing a `SKILL.md` file:\n\n```\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n**Level 1: Inject catalog at startup**: the harness calls `_scan_skills()` at startup to scan the skills/ directory, parsing each SKILL.md's YAML frontmatter (`name`, `description`) into a `SKILL_REGISTRY` dictionary. `list_skills()` generates the catalog from the registry, injected into the SYSTEM prompt. The Agent sees \"which skills I have available\" every turn, with no extra API calls:\n\n```python\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills() # runs once at startup\n\ndef list_skills() -> str:\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n```\n\n**Level 2: load_skill**: the Agent decides \"I need the SQL style guide\" and calls `load_skill(\"sql-style\")`. Lookup goes through the registry, not file paths, eliminating path traversal risk. The SKILL.md content is injected via `tool_result`, and can include later access to referenced `references/`, `scripts/`, or `assets/` through the existing file and bash tools.\n\n```python\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n```\n\nThe key distinction: skill content is not part of the system prompt. It enters the current messages as a tool result. Subsequent calls carry it along with the history until context compaction, truncation, or session end. This naturally connects to s08's compact: on-demand loading keeps irrelevant docs out of the system prompt, compact solves \"how to drop what you should.\"\n\n---\n\n## Changes from s06\n\n| Component | Before (s06) | After (s07) |\n|-----------|-------------|-------------|\n| Tool count | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |\n| Knowledge loading | None | Two-level: startup catalog in SYSTEM + runtime load_skill; SKILL.md may guide later resource access |\n| SYSTEM prompt | Static string | Startup scan of skills/ injects catalog |\n| Skill registry | None | SKILL_REGISTRY (populated at startup, prevents path traversal) |\n| Loop | Unchanged | Unchanged (skill tool auto-dispatches) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\nTry these prompts:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n\nWhat to watch for: Does the Agent know available skills from the SYSTEM catalog? Does `[HOOK] load_skill` appear when full instructions are needed? Does the answer use the loaded skill's instructions?\n\n---\n\n## What's Next\n\nload_skill eliminated the startup token waste. But another problem looms: after the Agent works for 30 minutes, the messages list fills up with intermediate process. Old tool_results, stale file contents, occupying context but adding no value.\n\n→ s08 Context Compact: A four-layer compaction strategy. Cheap layers run first, expensive layers run last.\n\n
\nDive into Claude Code Source Code\n\n> The following is based on analysis of Claude Code source code `loadSkillsDir.ts`, `SkillTool.ts`, `bundledSkills.ts`, `commands.ts`.\n\n### 1. Skill Sources: Not Just One skills/ Directory\n\nThe teaching version assumes all skills live in a `skills/` directory. Claude Code loads from multiple sources spread across multiple files: `loadSkillsDir.ts` handles user/project/`--add-dir` directories and legacy commands (`.claude/commands/`); `bundledSkills.ts` handles built-in skills; `SkillTool.ts` handles MCP remote skills; `commands.ts` handles command aggregation. Types include managed/policy skills, user skills (`~/.claude/skills/`), project skills (`.claude/skills/`), `--add-dir` skills, legacy commands, dynamic skills, conditional skills (with `paths` frontmatter, activated by file path), bundled skills, plugin skills, MCP skills.\n\n### 2. SKILL.md Frontmatter — Common Fields\n\nClaude Code's SKILL.md YAML frontmatter is parsed by `parseSkillFrontmatterFields()` in `loadSkillsDir.ts`. Common fields include:\n\n| Field | Purpose |\n|-------|---------|\n| `name` / `description` | Display name and description |\n| `when_to_use` | Guides the model on when to invoke |\n| `allowed-tools` | Auto-allow list of tools available to the skill |\n| `context` | `inline` (default) or `fork` (run as sub-Agent) |\n| `model` | Model override (haiku/sonnet/opus/inherit) |\n| `hooks` | Skill-level hook configuration |\n| `paths` | Glob patterns for conditional activation |\n| `user-invocable` | Users can invoke via `/name` |\n\nThe complete field list changes across versions; above are the core fields relevant to the teaching version.\n\n### 3. Precise Implementation of Two-Level Loading\n\n1. **Catalog (at startup)**: `getSkillDirCommands()` scans directory → registers as `Command` objects containing only metadata. `getSkillListingAttachments()` formats the skill list as attachments, budgeted at ~1% of the context window (cap 8000 characters).\n2. **Load (on invocation)**: Model calls `Skill` tool (input fields are `skill` + optional `args`; teaching version uses `name`) → `getPromptForCommand()` expands full SKILL.md content → `SkillTool` returns a tool_result with display text `\"Launching skill: {name}\"`, while the actual skill content is injected via `newMessages`. The teaching version merges both into \"injected via tool_result\" as a simplification; the loaded SKILL.md can still guide later access to referenced resources through existing file/bash tools.\n\n### The Teaching Version's Simplification Is Intentional\n\n- Multiple files and sources → 1 `skills/` directory: sufficient to demonstrate the core concept of two-level loading\n- Multiple frontmatter fields → only parse name/description: reduces parsing complexity\n- Forked skills (`context: 'fork'`) → omitted: the teaching version only expands inline skill loading\n- `Skill` tool input `skill`+`args` → teaching version uses `name`: avoids extra argument parsing complexity\n\n
\n\n\n" }, { "version": "s07", "locale": "zh", "title": "s07: Skill Loading — 用到的时候才加载", - "content": "# s07: Skill Loading — 用到的时候才加载\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/zh/s08) → s09 → ... → s20\n> *\"用到时再加载, 别全塞 prompt 里\"* — 通过 tool_result 注入, 不塞 system prompt。\n>\n> **Harness 层**: 知识 — 按需加载, 不堆满上下文。\n\n---\n\n## 问题\n\n你的项目有一套 React 组件规范、一份 SQL 风格指南、一份 API 设计文档。你希望 Agent 自动遵守这些规范。最直接的想法,全塞进 system prompt:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read() # 2000 行\n + open(\"docs/sql-style.md\").read() # 1500 行\n + open(\"docs/api-design.md\").read() # 3000 行\n)\n```\n\n6500 行 system prompt。Agent 每次调用 LLM 都带着这些文档——不管是在改 CSS 颜色还是修 SQL 查询。99% 的内容和当前任务无关,白白消耗 token。\n\n---\n\n## 解决方案\n\n![Skill Overview](/course-assets/s07_skill_loading/skill-overview.svg)\n\n保留上一章的最小 hook 结构、`todo_write` 和子 Agent,本章重点转向新增的 `load_skill` 工具。启动时把技能目录注入 SYSTEM prompt,运行时多注册一个工具加载完整内容,用到才花 token。\n\n两层设计:\n\n| 层 | 位置 | 时机 | 代价 |\n|---|------|------|------|\n| 1. 目录 | system prompt | 启动时注入(harness 扫描 skills/) | ~100 tokens/skill,每轮都带 |\n| 2. 内容 | tool_result | Agent 调用 load_skill 时;SKILL.md 可指引后续的 read_file/bash 调用,用于按需访问额外资源 | ~2000 tokens/skill,按需 |\n\ndispatch 机制不变,load_skill 通过 `TOOL_HANDLERS[block.name]` 分发。\n\n---\n\n## 工作原理\n\n**skills/ 目录**,每个技能一个子目录,包含 `SKILL.md` 文件:\n\n```\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n**第一级:启动时注入目录**:harness 启动时调用 `_scan_skills()` 扫描 skills/ 目录,解析每个 SKILL.md 的 YAML frontmatter(`name`、`description`),存入 `SKILL_REGISTRY` 字典。`list_skills()` 从注册表生成目录,注入 SYSTEM prompt。Agent 每轮都能看到\"我有哪些技能可用\",不花额外 API 调用:\n\n```python\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills() # runs once at startup\n\ndef list_skills() -> str:\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n```\n\n**第二级:load_skill**:Agent 决定\"我需要 SQL 风格指南\",调用 `load_skill(\"sql-style\")`。通过注册表查找,不走文件路径,没有路径遍历风险。SKILL.md 内容通过 `tool_result` 注入,并可通过现有的 file 和 bash 工具进一步访问引用的 `references/`、`scripts/` 或 `assets/`。\n\n```python\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n```\n\n关键区别:技能内容不是 system prompt 的一部分,它作为一次工具结果进入当前 messages。后续调用会随历史一起携带,直到上下文压缩、截断或会话结束。这和 s08 的 compact 自然衔接:按需加载解决了\"不该提前带的不要带\",compact 解决\"该丢的怎么丢\"。\n\n---\n\n## 相对 s06 的变更\n\n| 组件 | 之前 (s06) | 之后 (s07) |\n|------|-----------|-----------|\n| 工具数量 | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |\n| 知识加载 | 无 | 两级:启动时目录注入 SYSTEM + 运行时 load_skill;SKILL.md 可指引后续资源访问 |\n| SYSTEM 提示 | 静态字符串 | 启动时扫描 skills/ 注入目录 |\n| 技能注册表 | 无 | SKILL_REGISTRY(启动时填充,防路径遍历) |\n| 循环 | 不变 | 不变(skill 工具自动分发) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\n试试这些 prompt:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n\n观察重点:Agent 是否直接从 SYSTEM 里的目录知道有哪些技能?需要完整规范时是否出现 `[HOOK] load_skill`?加载后回答是否使用了对应 skill 的说明?\n\n---\n\n## 接下来\n\n按需加载解决了\"不该带的不要带\"。但另一个问题来了:Agent 连续工作 30 分钟后,messages 列表塞满了中间过程。旧的 tool_result、过时的文件内容,占着上下文但不产生价值。\n\ns08 Context Compact → 四层压缩策略。便宜的先跑,贵的后跑。\n\n
\n深入 CC 源码\n\n> 以下基于 CC 源码 `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` 的分析。\n\n### 一、技能来源:不是只有一个 skills/ 目录\n\n教学版假设所有技能在 `skills/` 目录下。CC 实际从多个来源加载,分布在多个文件中:`loadSkillsDir.ts` 负责从 user/project/`--add-dir` 目录和 legacy commands(`.claude/commands/`)加载;`bundledSkills.ts` 负责内置技能;`SkillTool.ts` 处理 MCP 远程技能;`commands.ts` 负责命令聚合。类型包括 managed/policy skills、user skills(`~/.claude/skills/`)、project skills(`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills(带 `paths` frontmatter,按文件路径激活)、bundled skills、plugin skills、MCP skills。\n\n### 二、SKILL.md Frontmatter 常见字段\n\nCC 的 SKILL.md YAML frontmatter 由 `parseSkillFrontmatterFields()` 解析(`loadSkillsDir.ts`),常见字段包括:\n\n| 字段 | 用途 |\n|------|------|\n| `name` / `description` | 显示名称和描述 |\n| `when_to_use` | 指导模型何时调用 |\n| `allowed-tools` | 技能可用工具的自动允许列表 |\n| `context` | `inline`(默认)或 `fork`(作为子 Agent 运行) |\n| `model` | 模型覆盖(haiku/sonnet/opus/inherit) |\n| `hooks` | 技能级别的 hook 配置 |\n| `paths` | 条件激活的 glob 模式 |\n| `user-invocable` | 用户可以通过 `/name` 调用 |\n\n完整字段列表随版本迭代会变化,以上仅列出教学版涉及的核心字段。\n\n### 三、两级加载的精确实现\n\n1. **Catalog(启动时)**:`getSkillDirCommands()` 扫描目录 → 注册为 `Command` 对象,只包含元数据。`getSkillListingAttachments()` 把技能列表格式化为附件,预算为上下文窗口的 ~1%(上限 8000 字符)。\n2. **Load(调用时)**:模型调 `Skill` 工具(输入字段是 `skill` + 可选 `args`,教学版用 `name`)→ `getPromptForCommand()` 展开完整 SKILL.md 内容 → `SkillTool` 返回的 tool_result 展示文本只是 `\"Launching skill: {name}\"`,真正的技能内容通过 `newMessages` 注入对话。教学版把两者合并为\"通过 tool_result 注入\"是一种简化;加载后的 SKILL.md 仍可作为指引,帮助模型后续通过现有 file/bash 工具访问相关资源。\n\n### 教学版的简化是刻意的\n\n- 多文件多来源 → 1 个 `skills/` 目录:足以展示两级加载的核心概念\n- 多个 frontmatter 字段 → 只解析 name/description:减少解析复杂度\n- forked skills(`context: 'fork'`)→ 省略:教学版只展开 inline 技能加载\n- `Skill` 工具输入 `skill`+`args` → 教学版用 `name`:避免参数解析的额外复杂度\n\n
\n\n\n" + "content": "# s07: Skill Loading — 用到的时候才加载\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/zh/s08) → s09 → ... → s20\n> *\"用到时再加载, 别全塞 prompt 里\"* — 通过 tool_result 注入, 不塞 system prompt。\n>\n> **Harness 层**: 知识 — 按需加载, 不堆满上下文。\n\n---\n\n## 问题\n\n你的项目有一套 React 组件规范、一份 SQL 风格指南、一份 API 设计文档。你希望 Agent 自动遵守这些规范。最直接的想法,全塞进 system prompt:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read() # 2000 行\n + open(\"docs/sql-style.md\").read() # 1500 行\n + open(\"docs/api-design.md\").read() # 3000 行\n)\n```\n\n6500 行 system prompt。Agent 每次调用 LLM 都带着这些文档——不管是在改 CSS 颜色还是修 SQL 查询。99% 的内容和当前任务无关,白白消耗 token。\n\n---\n\n## 解决方案\n\n![Skill Overview](/course-assets/s07_skill_loading/skill-overview.svg)\n\n保留上一章的最小 hook 结构、`todo_write` 和子 Agent,本章重点转向新增的 `load_skill` 工具。启动时把技能目录注入 SYSTEM prompt,运行时多注册一个工具加载完整内容,用到才花 token。\n\n两层设计:\n\n| 层 | 位置 | 时机 | 代价 |\n|---|------|------|------|\n| 1. 目录 | system prompt | 启动时注入(harness 扫描 skills/) | ~100 tokens/skill,每轮都带 |\n| 2. 内容 | tool_result | Agent 调用 load_skill 时;SKILL.md 可指引后续的 read_file/bash 调用,用于按需访问额外资源 | ~2000 tokens/skill,按需 |\n\ndispatch 机制不变,load_skill 通过 `TOOL_HANDLERS[block.name]` 分发。\n\n---\n\n## 工作原理\n\n**skills/ 目录**,每个技能一个子目录,包含 `SKILL.md` 文件:\n\n```\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n**第一级:启动时注入目录**:harness 启动时调用 `_scan_skills()` 扫描 skills/ 目录,解析每个 SKILL.md 的 YAML frontmatter(`name`、`description`),存入 `SKILL_REGISTRY` 字典。`list_skills()` 从注册表生成目录,注入 SYSTEM prompt。Agent 每轮都能看到\"我有哪些技能可用\",不花额外 API 调用:\n\n```python\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills() # runs once at startup\n\ndef list_skills() -> str:\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n```\n\n**第二级:load_skill**:Agent 决定\"我需要 SQL 风格指南\",调用 `load_skill(\"sql-style\")`。通过注册表查找,不走文件路径,没有路径遍历风险。SKILL.md 内容通过 `tool_result` 注入,并可通过现有的 file 和 bash 工具进一步访问引用的 `references/`、`scripts/` 或 `assets/`。\n\n```python\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n```\n\n关键区别:技能内容不是 system prompt 的一部分,它作为一次工具结果进入当前 messages。后续调用会随历史一起携带,直到上下文压缩、截断或会话结束。这和 s08 的 compact 自然衔接:技能内容以 tool_result 形式进入 messages,不塞 system prompt,compact 解决\"该丢的怎么丢\"。\n\n---\n\n## 相对 s06 的变更\n\n| 组件 | 之前 (s06) | 之后 (s07) |\n|------|-----------|-----------|\n| 工具数量 | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |\n| 知识加载 | 无 | 两级:启动时目录注入 SYSTEM + 运行时 load_skill;SKILL.md 可指引后续资源访问 |\n| SYSTEM 提示 | 静态字符串 | 启动时扫描 skills/ 注入目录 |\n| 技能注册表 | 无 | SKILL_REGISTRY(启动时填充,防路径遍历) |\n| 循环 | 不变 | 不变(skill 工具自动分发) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\n试试这些 prompt:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n\n观察重点:Agent 是否直接从 SYSTEM 里的目录知道有哪些技能?需要完整规范时是否出现 `[HOOK] load_skill`?加载后回答是否使用了对应 skill 的说明?\n\n---\n\n## 接下来\n\nload_skill 解决了启动时的 token 浪费。但另一个问题来了:Agent 连续工作 30 分钟后,messages 列表塞满了中间过程。旧的 tool_result、过时的文件内容,占着上下文但不产生价值。\n\ns08 Context Compact → 四层压缩策略。便宜的先跑,贵的后跑。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` 的分析。\n\n### 一、技能来源:不是只有一个 skills/ 目录\n\n教学版假设所有技能在 `skills/` 目录下。Claude Code 实际从多个来源加载,分布在多个文件中:`loadSkillsDir.ts` 负责从 user/project/`--add-dir` 目录和 legacy commands(`.claude/commands/`)加载;`bundledSkills.ts` 负责内置技能;`SkillTool.ts` 处理 MCP 远程技能;`commands.ts` 负责命令聚合。类型包括 managed/policy skills、user skills(`~/.claude/skills/`)、project skills(`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills(带 `paths` frontmatter,按文件路径激活)、bundled skills、plugin skills、MCP skills。\n\n### 二、SKILL.md Frontmatter 常见字段\n\nClaude Code 的 SKILL.md YAML frontmatter 由 `parseSkillFrontmatterFields()` 解析(`loadSkillsDir.ts`),常见字段包括:\n\n| 字段 | 用途 |\n|------|------|\n| `name` / `description` | 显示名称和描述 |\n| `when_to_use` | 指导模型何时调用 |\n| `allowed-tools` | 技能可用工具的自动允许列表 |\n| `context` | `inline`(默认)或 `fork`(作为子 Agent 运行) |\n| `model` | 模型覆盖(haiku/sonnet/opus/inherit) |\n| `hooks` | 技能级别的 hook 配置 |\n| `paths` | 条件激活的 glob 模式 |\n| `user-invocable` | 用户可以通过 `/name` 调用 |\n\n完整字段列表随版本迭代会变化,以上仅列出教学版涉及的核心字段。\n\n### 三、两级加载的精确实现\n\n1. **Catalog(启动时)**:`getSkillDirCommands()` 扫描目录 → 注册为 `Command` 对象,只包含元数据。`getSkillListingAttachments()` 把技能列表格式化为附件,预算为上下文窗口的 ~1%(上限 8000 字符)。\n2. **Load(调用时)**:模型调 `Skill` 工具(输入字段是 `skill` + 可选 `args`,教学版用 `name`)→ `getPromptForCommand()` 展开完整 SKILL.md 内容 → `SkillTool` 返回的 tool_result 展示文本只是 `\"Launching skill: {name}\"`,真正的技能内容通过 `newMessages` 注入对话。教学版把两者合并为\"通过 tool_result 注入\"是一种简化;加载后的 SKILL.md 仍可作为指引,帮助模型后续通过现有 file/bash 工具访问相关资源。\n\n### 教学版的简化是刻意的\n\n- 多文件多来源 → 1 个 `skills/` 目录:足以展示两级加载的核心概念\n- 多个 frontmatter 字段 → 只解析 name/description:减少解析复杂度\n- forked skills(`context: 'fork'`)→ 省略:教学版只展开 inline 技能加载\n- `Skill` 工具输入 `skill`+`args` → 教学版用 `name`:避免参数解析的额外复杂度\n\n
\n\n\n" }, { "version": "s07", "locale": "ja", "title": "s07: Skill Loading — 必要なときにだけ読み込む", - "content": "# s07: Skill Loading — 必要なときにだけ読み込む\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/ja/s08) → s09 → ... → s20\n> *\"Load when needed, don't stuff the prompt\"* — tool_result で注入、system prompt には詰め込まない。\n>\n> **Harness レイヤー**: 知識 — 必要に応じて読み込み、コンテキストに詰め込まない。\n\n---\n\n## 課題\n\nプロジェクトには React コンポーネント仕様、SQL スタイルガイド、API 設計ドキュメントがある。Agent にこれらの仕様を自動的に守らせたい。最も直接的な方法 — すべて system prompt に詰め込む:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read() # 2000 行\n + open(\"docs/sql-style.md\").read() # 1500 行\n + open(\"docs/api-design.md\").read() # 3000 行\n)\n```\n\n6500 行の system prompt。Agent は LLM を呼び出すたびにこれらのドキュメントを運ぶ — CSS の色を変えるときも SQL クエリを修正するときも。99% の内容が現在のタスクと無関係で、トークンを無駄に消費する。\n\n---\n\n## ソリューション\n\n![Skill Overview](/course-assets/s07_skill_loading/skill-overview.ja.svg)\n\n前章の最小フック構造、`todo_write`、サブ Agent を維持し、本章は新規の `load_skill` ツールに注目する。起動時にスキルカタログを SYSTEM prompt に注入し、実行時に完全な内容を読み込むツールを登録する。使ったときだけトークンを消費。\n\n2 層設計:\n\n| 層 | 場所 | タイミング | コスト |\n|---|------|-----------|--------|\n| 1. カタログ | system prompt | 起動時に注入(harness が skills/ をスキャン) | ~100 トークン/スキル、毎ターン携帯 |\n| 2. 内容 | tool_result | Agent が load_skill を呼び出したとき。SKILL.md は、必要に応じて read_file/bash で追加リソースへアクセスするための手がかりになる | ~2000 トークン/スキル、オンデマンド |\n\nディスパッチ機構は変わらず、`load_skill` は `TOOL_HANDLERS[block.name]` を通じて自動的にディスパッチされる。\n\n---\n\n## 仕組み\n\n**skills/ ディレクトリ**、スキルごとに 1 つのサブディレクトリ、それぞれに `SKILL.md` ファイルを含む:\n\n```\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n**第 1 層:起動時にカタログを注入**:harness は起動時に `_scan_skills()` を呼び出して skills/ ディレクトリをスキャンし、各 SKILL.md の YAML frontmatter(`name`、`description`)を解析して `SKILL_REGISTRY` 辞書に格納する。`list_skills()` はレジストリからカタログを生成し、SYSTEM prompt に注入する。Agent は毎ターン「どのスキルが利用可能か」を確認できる。追加の API 呼び出しは不要:\n\n```python\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills() # runs once at startup\n\ndef list_skills() -> str:\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n```\n\n**第 2 層:load_skill**:Agent が「SQL スタイルガイドが必要」と判断し、`load_skill(\"sql-style\")` を呼び出す。レジストリを通じて検索し、ファイルパスを経由しないため、パストラバーサルのリスクがない。SKILL.md の内容は `tool_result` を通じて注入され、既存の file および bash ツールを通じて、参照される `references/`、`scripts/`、`assets/` へのその後のアクセスも含められる。\n\n```python\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n```\n\n重要な違い:スキル内容は system prompt の一部ではなく、ツール結果として現在の messages に入る。後続の呼び出しでは履歴とともに携帯され、コンテキスト圧縮、切り捨て、またはセッション終了まで保持される。これは s08 の compact と自然に接続する:オンデマンド読み込みで「運ぶべきでないものは運ばない」を解決し、compact が「捨てるべきものをどう捨てるか」を解決する。\n\n---\n\n## s06 からの変更点\n\n| コンポーネント | 変更前 (s06) | 変更後 (s07) |\n|---------------|-------------|-------------|\n| ツール数 | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |\n| 知識読み込み | なし | 2 層:起動時カタログ注入 SYSTEM + 実行時 load_skill。SKILL.md がその後のリソースアクセスを案内できる |\n| SYSTEM プロンプト | 静的文字列 | 起動時に skills/ をスキャンしてカタログ注入 |\n| スキルレジストリ | なし | SKILL_REGISTRY(起動時に充填、パストラバーサル防止) |\n| ループ | 変更なし | 変更なし(スキルツールは自動ディスパッチ) |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n\n観察のポイント:Agent は SYSTEM 内のカタログから利用可能なスキルを知っているか? 完全な手順が必要なときに `[HOOK] load_skill` が表示されるか? 読み込んだスキルの説明を使って回答しているか?\n\n---\n\n## 次へ\n\nオンデマンド読み込みで「運ぶべきでないものは運ばない」問題は解決した。しかし別の問題が待っている:Agent が 30 分連続で作業すると、messages リストが中間プロセスで埋め尽くされる。古い tool_result、期限切れのファイル内容、コンテキストを占領しているが価値を生まない。\n\n→ s08 Context Compact:4 層圧縮戦略。安価な層を先に実行、高価な層を後に実行。\n\n
\nCC ソースコードを深掘り\n\n> 以下は CC ソースコード `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` の分析に基づく。\n\n### 一、スキルソース:skills/ ディレクトリだけではない\n\n教育版はすべてのスキルが `skills/` ディレクトリにあると想定している。CC は実際に複数のファイルに分散したソースから読み込む:`loadSkillsDir.ts` は user/project/`--add-dir` ディレクトリと legacy commands(`.claude/commands/`)を担当、`bundledSkills.ts` は組み込みスキル、`SkillTool.ts` は MCP リモートスキル、`commands.ts` はコマンド集約を担当。タイプには managed/policy skills、user skills(`~/.claude/skills/`)、project skills(`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills(`paths` frontmatter を持ち、ファイルパスでアクティベート)、bundled skills、plugin skills、MCP skills が含まれる。\n\n### 二、SKILL.md Frontmatter の一般的なフィールド\n\nCC の SKILL.md YAML frontmatter は `parseSkillFrontmatterFields()`(`loadSkillsDir.ts`)で解析される。一般的なフィールド:\n\n| フィールド | 用途 |\n|-----------|------|\n| `name` / `description` | 表示名と説明 |\n| `when_to_use` | モデルにいつ呼び出すかを指導 |\n| `allowed-tools` | スキルが使用可能なツールの自動許可リスト |\n| `context` | `inline`(デフォルト)または `fork`(サブ Agent として実行) |\n| `model` | モデルオーバーライド(haiku/sonnet/opus/inherit) |\n| `hooks` | スキルレベルのフック設定 |\n| `paths` | 条件付きアクティベーションの glob パターン |\n| `user-invocable` | ユーザーが `/name` で呼び出し可能 |\n\n完全なフィールドリストはバージョンによって変動する。上記は教育版に関連するコアフィールドのみ。\n\n### 三、2 層読み込みの正確な実装\n\n1. **カタログ(起動時)**:`getSkillDirCommands()` がディレクトリをスキャン → メタデータのみを含む `Command` オブジェクトとして登録。`getSkillListingAttachments()` がスキルリストを添付ファイルとしてフォーマット、コンテキストウィンドウの ~1% を予算とする(上限 8000 文字)。\n2. **読み込み(呼び出し時)**:モデルが `Skill` ツールを呼び出す(入力フィールドは `skill` + オプションの `args`、教育版は `name` を使用)→ `getPromptForCommand()` が完全な SKILL.md 内容を展開 → `SkillTool` が返す tool_result の表示テキストは `\"Launching skill: {name}\"` のみ、実際のスキル内容は `newMessages` を通じて注入される。教育版では両者を「tool_result を通じて注入」として簡略化している。読み込まれた SKILL.md は、モデルが後続で既存の file/bash ツールから関連リソースへアクセスする際の手がかりにもなる。\n\n### 教育版の単純化は意図的\n\n- 複数ファイル・複数ソース → 1 つの `skills/` ディレクトリ:2 層読み込みの核心概念を示すのに十分\n- 複数の frontmatter フィールド → name/description のみ解析:解析の複雑さを削減\n- forked skills(`context: 'fork'`)→ 省略:教育版では inline skill loading のみ展開する\n- `Skill` ツールの入力 `skill`+`args` → 教育版は `name` を使用:追加の引数解析の複雑さを回避\n\n
\n\n\n" + "content": "# s07: Skill Loading — 必要なときにだけ読み込む\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/ja/s08) → s09 → ... → s20\n> *\"Load when needed, don't stuff the prompt\"* — tool_result で注入、system prompt には詰め込まない。\n>\n> **Harness レイヤー**: 知識 — 必要に応じて読み込み、コンテキストに詰め込まない。\n\n---\n\n## 課題\n\nプロジェクトには React コンポーネント仕様、SQL スタイルガイド、API 設計ドキュメントがある。Agent にこれらの仕様を自動的に守らせたい。最も直接的な方法は、すべて system prompt に詰め込むこと:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read() # 2000 行\n + open(\"docs/sql-style.md\").read() # 1500 行\n + open(\"docs/api-design.md\").read() # 3000 行\n)\n```\n\n6500 行の system prompt。Agent は LLM を呼び出すたびにこれらのドキュメントを運ぶ。CSS の色を変えるときも SQL クエリを修正するときも同様だ。99% の内容が現在のタスクと無関係で、トークンを無駄に消費する。\n\n---\n\n## ソリューション\n\n![Skill Overview](/course-assets/s07_skill_loading/skill-overview.svg)\n\n前章の最小フック構造、`todo_write`、サブ Agent を維持し、本章は新規の `load_skill` ツールに注目する。起動時にスキルカタログを SYSTEM prompt に注入し、実行時に完全な内容を読み込むツールを登録する。使ったときだけトークンを消費。\n\n2 層設計:\n\n| 層 | 場所 | タイミング | コスト |\n|---|------|-----------|--------|\n| 1. カタログ | system prompt | 起動時に注入(harness が skills/ をスキャン) | ~100 トークン/スキル、毎ターン携帯 |\n| 2. 内容 | tool_result | Agent が load_skill を呼び出したとき。SKILL.md は、必要に応じて read_file/bash で追加リソースへアクセスするための手がかりになる | ~2000 トークン/スキル、オンデマンド |\n\nディスパッチ機構は変わらず、`load_skill` は `TOOL_HANDLERS[block.name]` を通じて自動的にディスパッチされる。\n\n---\n\n## 仕組み\n\n**skills/ ディレクトリ**、スキルごとに 1 つのサブディレクトリ、それぞれに `SKILL.md` ファイルを含む:\n\n```\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n**第 1 層:起動時にカタログを注入**:harness は起動時に `_scan_skills()` を呼び出して skills/ ディレクトリをスキャンし、各 SKILL.md の YAML frontmatter(`name`、`description`)を解析して `SKILL_REGISTRY` 辞書に格納する。`list_skills()` はレジストリからカタログを生成し、SYSTEM prompt に注入する。Agent は毎ターン「どのスキルが利用可能か」を確認できる。追加の API 呼び出しは不要:\n\n```python\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills() # runs once at startup\n\ndef list_skills() -> str:\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n```\n\n**第 2 層:load_skill**:Agent が「SQL スタイルガイドが必要」と判断し、`load_skill(\"sql-style\")` を呼び出す。レジストリを通じて検索し、ファイルパスを経由しないため、パストラバーサルのリスクがない。SKILL.md の内容は `tool_result` を通じて注入され、既存の file および bash ツールを通じて、参照される `references/`、`scripts/`、`assets/` へのその後のアクセスも含められる。\n\n```python\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n```\n\n重要な違い:スキル内容は system prompt の一部ではなく、ツール結果として現在の messages に入る。後続の呼び出しでは履歴とともに携帯され、コンテキスト圧縮、切り捨て、またはセッション終了まで保持される。これは s08 の compact と自然に接続する:オンデマンド読み込みにより、無関係なドキュメントが system prompt に入らなくなる。compact が「捨てるべきものをどう捨てるか」を解決する。\n\n---\n\n## s06 からの変更点\n\n| コンポーネント | 変更前 (s06) | 変更後 (s07) |\n|---------------|-------------|-------------|\n| ツール数 | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |\n| 知識読み込み | なし | 2 層:起動時カタログ注入 SYSTEM + 実行時 load_skill。SKILL.md がその後のリソースアクセスを案内できる |\n| SYSTEM プロンプト | 静的文字列 | 起動時に skills/ をスキャンしてカタログ注入 |\n| スキルレジストリ | なし | SKILL_REGISTRY(起動時に充填、パストラバーサル防止) |\n| ループ | 変更なし | 変更なし(スキルツールは自動ディスパッチ) |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n\n観察のポイント:Agent は SYSTEM 内のカタログから利用可能なスキルを知っているか? 完全な手順が必要なときに `[HOOK] load_skill` が表示されるか? 読み込んだスキルの説明を使って回答しているか?\n\n---\n\n## 次へ\n\nload_skill で起動時のトークン浪費は解消した。しかし別の問題が待っている:Agent が 30 分連続で作業すると、messages リストが中間プロセスで埋め尽くされる。古い tool_result、期限切れのファイル内容、コンテキストを占領しているが価値を生まない。\n\n→ s08 Context Compact:4 層圧縮戦略。安価な層を先に実行、高価な層を後に実行。\n\n
\nClaude Code ソースコードを深掘り\n\n> 以下は Claude Code ソースコード `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` の分析に基づく。\n\n### 一、スキルソース:skills/ ディレクトリだけではない\n\n教育版はすべてのスキルが `skills/` ディレクトリにあると想定している。Claude Code は実際に複数のファイルに分散したソースから読み込む:`loadSkillsDir.ts` は user/project/`--add-dir` ディレクトリと legacy commands(`.claude/commands/`)を担当、`bundledSkills.ts` は組み込みスキル、`SkillTool.ts` は MCP リモートスキル、`commands.ts` はコマンド集約を担当。タイプには managed/policy skills、user skills(`~/.claude/skills/`)、project skills(`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills(`paths` frontmatter を持ち、ファイルパスでアクティベート)、bundled skills、plugin skills、MCP skills が含まれる。\n\n### 二、SKILL.md Frontmatter の一般的なフィールド\n\nClaude Code の SKILL.md YAML frontmatter は `parseSkillFrontmatterFields()`(`loadSkillsDir.ts`)で解析される。一般的なフィールド:\n\n| フィールド | 用途 |\n|-----------|------|\n| `name` / `description` | 表示名と説明 |\n| `when_to_use` | モデルにいつ呼び出すかを指導 |\n| `allowed-tools` | スキルが使用可能なツールの自動許可リスト |\n| `context` | `inline`(デフォルト)または `fork`(サブ Agent として実行) |\n| `model` | モデルオーバーライド(haiku/sonnet/opus/inherit) |\n| `hooks` | スキルレベルのフック設定 |\n| `paths` | 条件付きアクティベーションの glob パターン |\n| `user-invocable` | ユーザーが `/name` で呼び出し可能 |\n\n完全なフィールドリストはバージョンによって変動する。上記は教育版に関連するコアフィールドのみ。\n\n### 三、2 層読み込みの正確な実装\n\n1. **カタログ(起動時)**:`getSkillDirCommands()` がディレクトリをスキャン → メタデータのみを含む `Command` オブジェクトとして登録。`getSkillListingAttachments()` がスキルリストを添付ファイルとしてフォーマット、コンテキストウィンドウの ~1% を予算とする(上限 8000 文字)。\n2. **読み込み(呼び出し時)**:モデルが `Skill` ツールを呼び出す(入力フィールドは `skill` + オプションの `args`、教育版は `name` を使用)→ `getPromptForCommand()` が完全な SKILL.md 内容を展開 → `SkillTool` が返す tool_result の表示テキストは `\"Launching skill: {name}\"` のみ、実際のスキル内容は `newMessages` を通じて注入される。教育版では両者を「tool_result を通じて注入」として簡略化している。読み込まれた SKILL.md は、モデルが後続で既存の file/bash ツールから関連リソースへアクセスする際の手がかりにもなる。\n\n### 教育版の単純化は意図的\n\n- 複数ファイル・複数ソース → 1 つの `skills/` ディレクトリ:2 層読み込みの核心概念を示すのに十分\n- 複数の frontmatter フィールド → name/description のみ解析:解析の複雑さを削減\n- forked skills(`context: 'fork'`)→ 省略:教育版では inline skill loading のみ展開する\n- `Skill` ツールの入力 `skill`+`args` → 教育版は `name` を使用:追加の引数解析の複雑さを回避\n\n
\n\n\n" }, { "version": "s08", "locale": "en", "title": "s08: Context Compact — Context Will Fill Up, Have a Way to Make Room", - "content": "# s08: Context Compact — Context Will Fill Up, Have a Way to Make Room\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/en/s09) → s10 → ... → s20\n> *\"Context will fill up — have a way to make room\"* — Four-layer compression pipeline: cheap first, expensive last.\n>\n> **Harness Layer**: Compression — clean memory, unlimited sessions.\n\n---\n\n## The Problem\n\nThe agent is running along, then freezes.\n\nIt has bash, read, write — all the capabilities it needs. But it read a 1000-line file (~4000 tokens), then read 30 more files, ran 20 commands. Every command's output, every file's contents, all pile up in the `messages` list.\n\nThe context window is finite. Once full, the API outright rejects the call: `prompt_too_long`.\n\nWithout compression, an agent simply cannot work on large projects.\n\n---\n\n## The Solution\n\n![Compact Overview](/course-assets/s08_context_compact/compact-overview.en.svg)\n\nThe hook structure, skill loading, and sub-Agent from s07 are preserved, with some tools omitted to focus on compaction. The core change: insert three pre-processors (0 API calls) before each LLM call, trigger an LLM summary (1 API call) when tokens still exceed the threshold, and emergency-trim if the API throws an error.\n\nCore design: cheap first, expensive last.\n\n---\n\n## How It Works\n\n![Four-layer compression pipeline](/course-assets/s08_context_compact/compaction-layers.en.svg)\n\n### L1: snip_compact — Trim Irrelevant Old Conversation\n\nThe agent ran 80 turns of conversation, accumulating 160 `messages`. The very first \"help me create hello.py\" is barely relevant to current work, yet it still occupies space.\n\nMessage count exceeds 50 → keep the first 3 (initial context) and the last 47 (current work), trim the middle; the only extra boundary rule is that `assistant(tool_use)` must not be separated from the following `user(tool_result)`:\n\n```python\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages:\n return messages\n head_end, tail_start = 3, len(messages) - (max_messages - 3)\n if _message_has_tool_use(messages[head_end - 1]):\n while head_end < len(messages) and _is_tool_result_message(messages[head_end]):\n head_end += 1\n if _is_tool_result_message(messages[tail_start]) and _message_has_tool_use(messages[tail_start - 1]):\n tail_start -= 1\n snipped = tail_start - head_end\n placeholder = {\"role\": \"user\", \"content\": f\"[snipped {snipped} messages from conversation middle]\"}\n return messages[:head_end] + [placeholder] + messages[tail_start:]\n```\n\nMessages are still trimmed directly; this just adds one boundary guard. `tool_result` content within remaining messages still keeps accumulating — message #34 may still hold 30KB of old file contents. → L2.\n\n### L2: micro_compact — Placeholder for Old Tool Results\n\n![Old results placeholder](/course-assets/s08_context_compact/micro-compact.en.svg)\n\nThe agent read 10 files consecutively. The full contents of reads 1–7 are still sitting in context, no longer needed, but hogging large amounts of space.\n\nKeep only the 3 most recent `tool_result` entries intact; replace older ones with a one-line placeholder:\n\n```python\nKEEP_RECENT_TOOL_RESULTS = 3\n\ndef micro_compact(messages):\n tool_results = collect_tool_result_blocks(messages)\n if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS:\n return messages\n for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n```\n\nOld results are cleared, but a single new result can be 500KB — one `cat` of a large file can max out the context. → L3.\n\n### L3: tool_result_budget — Persist Large Results to Disk\n\n![Large results to disk](/course-assets/s08_context_compact/layer1-budget.en.svg)\n\nThe model read 5 large files in one go; all `tool_result` blocks in the last user message total 500KB.\n\nSum the size of all `tool_result` blocks in the last user message. If over 200KB → sort by size, starting from the largest, persist to `.task_outputs/tool-results/`, keeping only a `` marker + a 2000-character preview in context. The model sees the marker and knows the full content is on disk, re-reading it when needed.\n\n```python\ndef tool_result_budget(messages, max_bytes=200_000):\n last = messages[-1]\n blocks = [(i, b) for i, b in enumerate(last[\"content\"])\n if b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes:\n return messages\n ranked = sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True)\n for idx, block in ranked:\n if total <= max_bytes:\n break\n block[\"content\"] = persist_large_output(block[\"tool_use_id\"], str(block[\"content\"]))\n total = recalculate_total(blocks)\n return messages\n```\n\nThe first three layers are all plain-text / structural operations — 0 API calls — but they cannot \"understand\" conversation content. Context may still be too large. → L4.\n\n### L4: compact_history — Full LLM Summary\n\n![Full LLM summary](/course-assets/s08_context_compact/auto-compact.en.svg)\n\nAll three previous layers have run, but after 30 minutes of continuous work on a huge project, tokens still exceed the threshold.\n\nThree-step process:\n\n1. **Save transcript**: Write the full conversation to `.transcripts/` in JSONL format. The transcript preserves a recoverable record, but the model's active context only contains the summary. For the model's current reasoning, the details are no longer in context. The teaching code does not provide a transcript retrieval tool.\n2. **LLM generates summary**: Send conversation history to the LLM, asking it to preserve key information: current goals, important findings, modified files, remaining work, user constraints, etc.\n3. **Replace message list**: All old messages are replaced with a single summary. The teaching version only keeps the summary; the real Claude Code re-attaches some recent files, plans, agent/skill/tool context after compaction.\n\n```python\ndef compact_history(messages):\n transcript_path = write_transcript(messages) # Save full conversation first\n summary = summarize_history(messages) # LLM generates summary\n return [{\"role\": \"user\",\n \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n```\n\n**Circuit breaker**: After 3 consecutive failures, stop retrying to prevent an infinite loop wasting API calls.\n\n### Reactive: reactive_compact\n\nSometimes the API still returns `prompt_too_long` (413) — when context grows faster than compression triggers.\n\nThis triggers **reactive_compact**: more aggressive than compact_history, it retreats from the tail, but still avoids leaving an orphaned `tool_result`.\n\n```python\ndef reactive_compact(messages):\n transcript = write_transcript(messages)\n summary = summarize_history(messages)\n tail_start = max(0, len(messages) - 5)\n if _is_tool_result_message(messages[tail_start]) and _message_has_tool_use(messages[tail_start - 1]):\n tail_start -= 1\n return [{\"role\": \"user\",\n \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *messages[tail_start:]]\n```\n\nReactive compact has a retry limit (default 1). If it still fails, an exception is raised instead of looping forever. Full error recovery is deferred to s11.\n\n### Putting It All Together\n\n```python\ndef agent_loop(messages):\n reactive_retries = 0\n while True:\n # Three pre-processors (0 API calls)\n # Order: budget first, so large content is persisted before placeholders\n messages[:] = tool_result_budget(messages) # L3: persist large results\n messages[:] = snip_compact(messages) # L1: trim middle\n messages[:] = micro_compact(messages) # L2: old result placeholders\n\n # Still too much? LLM summary (1 API call)\n if estimate_token_count(messages) > THRESHOLD:\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(...)\n except PromptTooLongError:\n if reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = reactive_compact(messages) # Emergency\n reactive_retries += 1\n continue\n raise # retry limit exceeded, raise exception\n # ... tool execution ...\n\n # compact tool: when the model actively calls it, triggers compact_history\n if block.name == \"compact\":\n messages[:] = compact_history(messages)\n results.append({..., \"content\": \"[Compacted. History summarized.]\"})\n messages.append({\"role\": \"user\", \"content\": results})\n break # end current turn, start fresh with compacted context\n```\n\n**The order must not be swapped.** L3 (budget) runs before L2 (micro) because micro replaces old large tool_results with one-line placeholders — budget must persist the full content before that happens. This is why CC source puts `applyToolResultBudget` first.\n\n---\n\n## Changes From s07\n\n| Component | Before (s07) | After (s08) |\n|-----------|-------------|-------------|\n| Context management | None (context grows unbounded) | Four-layer compression pipeline + emergency |\n| New functions | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |\n| Tools | bash, read_file, write_file, edit_file, glob, todo_write, task, load_skill (8) | 8 + compact (9) |\n| Loop | LLM call → tool execution | Three pre-processors before each turn + threshold-triggered compact_history |\n| Design principle | — | Cheap first, expensive last |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\nTry these prompts:\n\n1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md` (read multiple files consecutively, observe L2 compressing old results)\n2. `Read every file in s08_context_compact/` (read a large amount of content at once, observe L3 persisting to disk)\n3. Chat for 20+ turns, observe whether `[auto compact]` or `[reactive compact]` appears\n\nWhat to watch for: After each tool execution, are old `tool_result` entries compressed? When tokens exceed the threshold after extended conversation, is summarization triggered automatically?\n\n---\n\n## What's Next\n\nContext compression lets an agent run for a long time without crashing. But after each compression, the preferences and constraints the user told it are also lost. Can we let the agent selectively remember important things?\n\ns09 Memory → three subsystems: choosing what to remember, extracting key information, consolidating and organizing. Across compressions, across sessions.\n\n
\nDeep Dive Into CC Source Code\n\n> The following is based on analysis of CC source code `compact.ts`, `autoCompact.ts`, `microCompact.ts`, and `query.ts`.\n\n### Execution Order Comparison\n\nThe teaching version labels layers L1/L2/L3/L4 for pedagogical clarity, but actual execution order does not match the numbering:\n\n| Dimension | Teaching Version | Claude Code |\n|-----------|-----------------|-------------|\n| Execution order | budget → snip → micro → auto | budget → snip → micro → collapse → auto (`query.ts:379-468`) |\n| snip_compact | Keep head 3 + tail 47 | CC only enables on main thread; implementation not in open-source repo (`HISTORY_SNIP` feature gate), but interface is visible: `snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`, also exposes `SnipTool` for model-initiated snipping. Teaching version's 3/47 are simplified parameters |\n| micro_compact | Text placeholder replacement | Two paths: time-based clears content directly, cached uses API `cache_edits` (legacy path removed) |\n| micro_compact whitelist | By position (most recent 3) | time-based triggers by time threshold; cached triggers by count (`microCompact.ts`) |\n| tool_result_budget | 200KB characters | 200,000 characters (`toolLimits.ts:49`) |\n| compact_history threshold | Character count estimate | Precise tokens: `contextWindow - maxOutputTokens - 13_000` |\n| Summary requirements | 5 categories of info | 9 sections + ``/`` dual tags |\n| Compression prompt | Simple prompt | Double-ended hard guardrails forbidding tool calls |\n| PTL retry | Yes (simplified) | `truncateHeadForPTLRetry()` retreats by message groups (`compact.ts:243-290`) |\n| Post-compaction recovery | None (teaching version only keeps summary) | Auto re-read recent files, plans, agent/skill/tool context |\n| Circuit breaker | 3 times | 3 times (`autoCompact.ts:70`) |\n| Reactive retry | 1 time | CC has more granular tiered retries |\n\n### Execution Order Details\n\nThe real order in CC source `query.ts`:\n\n1. `applyToolResultBudget` (L379): persist large results first, ensuring full content is saved\n2. `snipCompact` (L403): trim middle messages\n3. `microcompact` (L414): old result placeholders\n4. `contextCollapse` (L441): independent context management system (not in teaching version)\n5. `autoCompact` (L454): LLM full summary\n\nThe teaching version's budget → snip → micro order matches this. The teaching version does not have the contextCollapse mechanism.\n\n### Full Constant Reference\n\n| Constant | Value | Source File |\n|----------|-------|-------------|\n| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |\n| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |\n| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |\n| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |\n| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |\n| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |\n| Time micro_compact interval | 60 minutes | `timeBasedMCConfig.ts` |\n| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |\n\n### contextCollapse and sessionMemoryCompact\n\nCC source code has two additional mechanisms not covered in this teaching version:\n\n- **contextCollapse**: An independent context management system that, when enabled, suppresses proactive autocompact (`autoCompact.ts:215-222`), with collapse's commit/blocking flow taking over context management. Manual `/compact` and reactive fallback remain independent paths, unaffected by contextCollapse.\n- **sessionMemoryCompact**: Before compact_history, CC first attempts a lightweight summary using existing session memory (covered in s09) without calling the LLM. This mechanism becomes clearer after learning s09.\n\n### What Does the Compression Prompt Look Like?\n\nCC's compression prompt has two hard requirements:\n\n1. **Absolutely no tool calls**: It begins with `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`, and appends another REMINDER at the end\n2. **Analyze first, then summarize**: The model must first reason in an `` tag, then output the formal summary in a `` tag. The analysis is stripped during formatting\n\n### Teaching Version Simplifications Are Intentional\n\n- micro_compact uses text placeholders → we don't have API-level `cache_edits` access\n- Tokens estimated via character count → precise tokenizers are out of scope\n- Post-compaction recovery omitted → teaching version only keeps summary, does not auto re-attach files\n- Two auxiliary mechanisms not covered → they fall in the 10% detail category\n\nThe core design principle, cheap first, expensive last, is fully preserved.\n\n
\n\n\n" + "content": "# s08: Context Compact — Context Will Fill Up, Have a Way to Make Room\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/en/s09) → s10 → ... → s20\n> *\"Context will fill up — have a way to make room\"* — Four-layer compaction pipeline: cheap first, expensive last.\n>\n> **Harness Layer**: Compaction — auto-summarize when context exceeds limits, keeping sessions sustainable.\n\n---\n\n## The Problem\n\nThe last chapter gave the agent Skills, so it picked up a bit of \"domain experience\": hand it a PDF, an MCP server, or a code review, and it loads the right playbook before acting.\n\nBut the more capable the agent gets, the worse a second problem becomes. It reads one 1000-line file (that's ~4000 tokens), then 30 more files, then runs 20 commands. Every command's output and every file's contents get appended back into the `messages` list, piling up turn after turn.\n\nA few dozen turns of ordinary chat is nothing. A coding agent is different: one read is thousands of lines, one test run is a wall of logs. The task isn't even done, and the context window may already be full.\n\nOnce it's full, the problem isn't \"the model answered a little worse.\" The API rejects the call outright: `prompt_too_long`. Without compaction, an agent simply can't work on a large project.\n\n---\n\n## The Solution\n\n![Compact Overview](/course-assets/s08_context_compact/compact-overview.svg)\n\nThe hook structure, skill loading, and subagent from s07 all stay; this chapter adds just one layer: before every LLM call, tidy up `messages` first.\n\nThe obvious idea is to let the model summarize once things fill up. But that has two problems. First, a summary costs an extra API call, and summarizing every time the context grows makes the bill climb fast. Second, not everything is worth summarizing: plenty of old tool results aren't needed anymore, and some content is merely big: a `cat` that dumps a few hundred KB of logs doesn't need to be *understood*, it just needs to move out of the context and be re-read if it ever matters again.\n\nSo compaction isn't one action, it's a pipeline. **Cheap first, expensive last**: run a few local passes that never call the model: trim what can be trimmed, swap placeholders in for old results, spill big outputs to disk. Only when none of that is enough do you let the LLM do a real summary.\n\n---\n\n## How It Works\n\n![Four-Layer Compaction Pipeline](/course-assets/s08_context_compact/compaction-layers.svg)\n\n### L1: snip_compact — Trim Irrelevant Old Conversation\n\nThe agent has run 80 turns and `messages` holds 160 entries. That opening \"create hello.py for me\" has almost nothing to do with the current work, yet it still takes up space.\n\nOnce the count passes 50 → keep the first 3 (the original task and constraints) and the last 47 (the current work), and cut the middle. The one boundary to respect: don't split an `assistant(tool_use)` from the `user(tool_result)` that follows it, or the model sees an orphaned result with no idea which call it belongs to.\n\n```python\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages: return messages\n keep_head, keep_tail = 3, max_messages - 3\n head_end, tail_start = keep_head, len(messages) - keep_tail\n if head_end > 0 and _message_has_tool_use(messages[head_end - 1]):\n while head_end < len(messages) and _is_tool_result_message(messages[head_end]):\n head_end += 1\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n if head_end >= tail_start: return messages\n snipped = tail_start - head_end\n return messages[:head_end] + [{\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}] + messages[tail_start:]\n```\n\nWhat gets cut is the messages themselves, with one guard at the seam. But in the messages that remain, `tool_result` content is still piling up, and message 34 might still be sitting on 30KB of an old file. Fewer messages, but not fewer tokens. → L2.\n\n### L2: micro_compact — Placeholder for Old Tool Results\n\n![Old Result Placeholders](/course-assets/s08_context_compact/micro-compact.svg)\n\nWhat blows up the context is usually not the conversation itself but the tool results. The agent read 10 files in a row; the full contents of files 1 through 7 stopped being useful long ago, yet they sit there verbatim.\n\nKeep the full content of the 3 most recent `tool_result`s and replace anything older with a one-line placeholder. The idea is plain: if an old result is really needed, the model can just read it again; it shouldn't hog space the whole time.\n\n```python\nKEEP_RECENT = 3\n\ndef micro_compact(messages):\n tool_results = collect_tool_results(messages)\n if len(tool_results) <= KEEP_RECENT: return messages\n for _, _, block in tool_results[:-KEEP_RECENT]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n```\n\nThe old results are cleared, but one case still slips through: a single new result can be 500KB on its own: one `cat` of a big file is enough to fill the context, and it's too fresh for micro_compact to touch. → L3.\n\n### L3: tool_result_budget — Persist Large Results to Disk\n\n![Persist Large Results](/course-assets/s08_context_compact/layer1-budget.svg)\n\nSome results aren't a problem of *many*, but of *one too big*. The model read 5 large files at once, and the `tool_result`s in that last user message add up to over 200KB, and keeping the 3 most recent doesn't help here, because the newest one alone can fill the context.\n\nGive tool results a budget. Add up the size of every `tool_result` in the last user message; if it's over 200KB, persist them to `.task_outputs/tool-results/` starting with the largest, leaving only a `` marker plus the first 2000 characters as a preview. The model sees the marker and knows the full content is on disk, to be re-read when needed.\n\n```python\ndef tool_result_budget(messages, max_bytes=200_000):\n last = messages[-1] if messages else None\n if not last or last.get(\"role\") != \"user\" or not isinstance(last.get(\"content\"), list): return messages\n blocks = [(i, b) for i, b in enumerate(last[\"content\"])\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes: return messages\n ranked = sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True)\n for _, block in ranked:\n if total <= max_bytes: break\n block[\"content\"] = persist_large_output(block.get(\"tool_use_id\", \"unknown\"), str(block.get(\"content\", \"\")))\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return messages\n```\n\nWhat matters here isn't discarding; it's moving content from \"active context\" to \"recoverable external storage\". That completes the first three layers: pure text/structure operations, 0 API calls, each watching one kind of bloat. But they share one limit: they can't read what the conversation is about, can't tell which findings matter or which constraints must stay. If the context is still too big, the model has to step in. → L4.\n\n### L4: compact_history — Full LLM Summary\n\n![Full LLM Summary](/course-assets/s08_context_compact/auto-compact.svg)\n\nAll three layers have run and the token count still tops the threshold. This is what most people picture as \"context compaction\": hand the history to the model and have it summarize into a shorter state.\n\nThree steps: first write the full conversation to `.transcripts/` (JSONL), so the active context keeps only the summary while the complete record stays on disk; then have the LLM produce a summary that preserves the current goal, key findings, files already changed, remaining work, and user constraints; finally replace all the old messages with that one summary.\n\n```python\ndef compact_history(messages):\n transcript_path = write_transcript(messages) # save the full conversation first\n summary = summarize_history(messages) # LLM generates the summary\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n```\n\nThis step is lossy: the transcript holds the full history, but the model can no longer see those details; it has only the summary to go on. That's why L1/L2/L3 run first: don't make the model summarize if you can avoid it, because once you do, detail is gone for good. The teaching version also adds a circuit breaker: stop after 3 consecutive compaction failures instead of burning API calls in a loop.\n\n### Reactive: reactive_compact\n\nNormally we tidy the context before calling the model. But when context grows too fast, or the token estimate is off, the API can still come back with `prompt_too_long`.\n\nThat's when reactive_compact kicks in: much like compact_history but more aggressive: save the transcript, summarize most of the front, and keep only the last 5 messages as tail context (again avoiding an orphaned `tool_result`).\n\n```python\ndef reactive_compact(messages):\n transcript = write_transcript(messages)\n tail_start = max(0, len(messages) - 5)\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n summary = summarize_history(messages[:tail_start])\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *messages[tail_start:]]\n```\n\nReactive is the fallback, not the normal path: it retries once by default, and on another failure it raises rather than looping forever. The full error-recovery logic is left to s11.\n\n### Putting It All Together\n\nWire it all back into the Agent Loop: before each LLM call, run the three local passes, summarize if that's not enough, and fall back to the emergency path only if the call actually errors.\n\n```python\ndef agent_loop(messages):\n reactive_retries = 0\n while True:\n # three preprocessors (0 API calls), order: budget -> snip -> micro\n messages[:] = tool_result_budget(messages) # L3: persist large results\n messages[:] = snip_compact(messages) # L1: trim the middle\n messages[:] = micro_compact(messages) # L2: old result placeholders\n\n if estimate_size(messages) > CONTEXT_LIMIT: # still too big -> LLM summary (1 API call)\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000)\n except Exception as e:\n if \"prompt_too_long\" in str(e).lower() and reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = reactive_compact(messages) # emergency\n reactive_retries += 1\n continue\n raise\n # ... tool execution ...\n```\n\n**The order can't change.** L3 (budget) has to run before L2 (micro): micro replaces old large `tool_result`s with a one-line placeholder, so if it ran first, budget would never get to persist the full content. Save the big content first, then do the placeholdering and trimming. That's also why Claude Code's source puts `applyToolResultBudget` first.\n\n### The compact Tool — Let the Model Ask, Too\n\nBeyond automatic compaction, the model can ask for a tidy-up itself: when it feels the context is too long, or the task has shifted phase, it can call the `compact` tool. In the teaching version that tool triggers `compact_history`, then ends the current turn and starts fresh with the compacted context. It feels much like a manual `/compact`, except this time the model itself realized it was time.\n\n---\n\n## Changes From s07\n\n| Component | Before (s07) | After (s08) |\n|-----------|-------------|-------------|\n| Context management | None (context grows without bound) | Four-layer compaction pipeline + emergency |\n| New functions | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |\n| Tools | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) |\n| Loop | LLM call → tool execution | Run three preprocessors each round + threshold-triggered compact_history |\n| Design principle | Make the agent capable | Keep the agent from crashing on long runs |\n\nThis step doesn't add a *capability* so much as *stamina*: s07 made the agent better at specialized work, s08 keeps it from being dragged down by its own history on a long task.\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\nTry these prompts:\n\n1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md` (read several files in a row, watch L2 compact old results)\n2. `Read every file in s08_context_compact/` (read a lot at once, watch L3 persist to disk)\n3. Keep the conversation going 20+ turns, watch for `[auto compact]` or `[reactive compact]`\n\nWhat to watch: after each tool runs, does the old `tool_result` get replaced? Do large outputs get persisted? When tokens pass the threshold, does a summary get generated?\n\n---\n\n## What's Next\n\nCompaction lets the agent run a long time without crashing. But every compaction loses some detail: a preference the user stated earlier, a long-standing project constraint, a fact that matters across tasks. None of it is guaranteed to survive in the summary.\n\nCompaction answers \"the current session is nearly full, how do we keep going\". It doesn't answer \"which information is worth keeping for the long haul\".\n\ns09 Memory → three subsystems: choosing what to remember, extracting the key information, and consolidating it. Across compactions, across sessions.\n\n
\nDeep Dive Into Claude Code Source Code\n\n> The following is based on analysis of Claude Code source code `compact.ts`, `autoCompact.ts`, `microCompact.ts`, and `query.ts`.\n\n### Execution Order Comparison\n\nThe teaching version labels layers L1/L2/L3/L4 for pedagogical clarity, but actual execution order does not match the numbering:\n\n| Dimension | Teaching Version | Claude Code |\n|-----------|-----------------|-------------|\n| Execution order | budget → snip → micro → auto | budget → snip → micro → collapse → auto (`query.ts:379-468`) |\n| snip_compact | Keep head 3 + tail 47 | Claude Code only enables on main thread; implementation not in open-source repo (`HISTORY_SNIP` feature gate), but interface is visible: `snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`, also exposes `SnipTool` for model-initiated snipping. Teaching version's 3/47 are simplified parameters |\n| micro_compact | Text placeholder replacement | Two paths: time-based clears content directly, cached uses API `cache_edits` (legacy path removed) |\n| micro_compact whitelist | By position (most recent 3) | time-based triggers by time threshold; cached triggers by count (`microCompact.ts`) |\n| tool_result_budget | 200KB characters | 200,000 characters (`toolLimits.ts:49`) |\n| compact_history threshold | Character count estimate | Precise tokens: `contextWindow - maxOutputTokens - 13_000` |\n| Summary requirements | 5 categories of info | 9 sections + ``/`` dual tags |\n| Compression prompt | Simple prompt | Double-ended hard guardrails forbidding tool calls |\n| PTL retry | Yes (simplified) | `truncateHeadForPTLRetry()` retreats by message groups (`compact.ts:243-290`) |\n| Post-compaction recovery | None (teaching version only keeps summary) | Auto re-read recent files, plans, agent/skill/tool context |\n| Circuit breaker | 3 times | 3 times (`autoCompact.ts:70`) |\n| Reactive retry | 1 time | Claude Code has more granular tiered retries |\n\n### Execution Order Details\n\nThe real order in Claude Code source `query.ts`:\n\n1. `applyToolResultBudget` (L379): persist large results first, ensuring full content is saved\n2. `snipCompact` (L403): trim middle messages\n3. `microcompact` (L414): old result placeholders\n4. `contextCollapse` (L441): independent context management system (not in teaching version)\n5. `autoCompact` (L454): LLM full summary\n\nThe teaching version's budget → snip → micro order matches this. The teaching version does not have the contextCollapse mechanism.\n\n### read_file Trade-off\n\nThe teaching version's `micro_compact` replaces old `tool_result` blocks with placeholders uniformly, including `read_file`. This usually does not affect functional correctness: if the model needs the file contents later, it can read the file again. The cost is an extra tool call and potentially lower prompt cache hit rates.\n\nClaude Code does not solve this with the teaching version's simple rule. It also puts `Read` in the microcompactable tool set, but maintains a separate `readFileState`: repeated reads of unchanged files return `FILE_UNCHANGED_STUB`, and after compaction it restores recently read file contents within a budget (for example, up to 5 files, 5K tokens per file, 50K tokens total). That is a production-level cache and recovery mechanism. The teaching version does not expand into that machinery; it keeps the simpler trade-off of compacting old results and re-reading when needed.\n\n### Full Constant Reference\n\n| Constant | Value | Source File |\n|----------|-------|-------------|\n| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |\n| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |\n| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |\n| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |\n| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |\n| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |\n| Time micro_compact interval | 60 minutes | `timeBasedMCConfig.ts` |\n| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |\n\n### contextCollapse and sessionMemoryCompact\n\nClaude Code source code has two additional mechanisms not covered in this teaching version:\n\n- **contextCollapse**: An independent context management system that, when enabled, suppresses proactive autocompact (`autoCompact.ts:215-222`), with collapse's commit/blocking flow taking over context management. Manual `/compact` and reactive fallback remain independent paths, unaffected by contextCollapse.\n- **sessionMemoryCompact**: Before compact_history, Claude Code first attempts a lightweight summary using existing session memory (covered in s09) without calling the LLM. This mechanism becomes clearer after learning s09.\n\n### What Does the Compression Prompt Look Like?\n\nClaude Code's compression prompt has two hard requirements:\n\n1. **Absolutely no tool calls**: It begins with `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`, and appends another REMINDER at the end\n2. **Analyze first, then summarize**: The model must first reason in an `` tag, then output the formal summary in a `` tag. The analysis is stripped during formatting\n\n### Teaching Version Simplifications Are Intentional\n\n- micro_compact uses text placeholders → we don't have API-level `cache_edits` access\n- read_file is not special-cased → the teaching version accepts re-reading when needed instead of introducing readFileState and post-compaction recovery\n- Tokens estimated via character count → precise tokenizers are out of scope\n- Post-compaction recovery omitted → teaching version only keeps summary, does not auto re-attach files\n- Two auxiliary mechanisms not covered → they fall in the 10% detail category\n\nThe core design principle is fully preserved.\n\n
\n\n\n" }, { "version": "s08", "locale": "zh", "title": "s08: Context Compact — 上下文总会满,要有办法腾地方", - "content": "# s08: Context Compact — 上下文总会满,要有办法腾地方\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/zh/s09) → s10 → ... → s20\n> *\"上下文总会满, 要有办法腾地方\"* — 四层压缩策略, 便宜的先跑贵的后跑。\n>\n> **Harness 层**: 压缩 — 干净的记忆, 无限的会话。\n\n---\n\n## 问题\n\nAgent 跑着跑着,不动了。\n\n手里有 bash、有 read、有 write,能力是够的。但它读了一个 1000 行的文件(~4000 token),又读了 30 个文件,跑了 20 条命令。每条命令的输出、每个文件的内容,全都堆在 `messages` 列表里。\n\n上下文窗口是有限的。满了之后,API 直接拒绝:`prompt_too_long`。\n\n不压缩,Agent 根本没法在大项目里干活。\n\n---\n\n## 解决方案\n\n![Compact Overview](/course-assets/s08_context_compact/compact-overview.svg)\n\n保留 s07 的 hook 结构、技能加载、子 Agent 等骨架,省略部分工具细节以聚焦压缩。核心变动:每轮 LLM 调用前插入三层预处理器(0 API),token 仍超阈值时触发 LLM 摘要(1 API),API 报错时应急裁剪。\n\n核心设计:便宜的先跑,贵的后跑。\n\n---\n\n## 工作原理\n\n![四层压缩管线](/course-assets/s08_context_compact/compaction-layers.svg)\n\n### L1: snip_compact — 裁掉无关的旧对话\n\nAgent 跑了 80 轮对话,`messages` 攒了 160 条。最前面的\"帮我创建 hello.py\"和当前工作几乎无关了,但全占着位置。\n\n消息数超过 50 条 → 保留头部 3 条(初始上下文)和尾部 47 条(当前工作),中间裁掉;唯一额外边界条件是,不能把 `assistant(tool_use)` 和后面的 `user(tool_result)` 拆开:\n\n```python\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages:\n return messages\n head_end, tail_start = 3, len(messages) - (max_messages - 3)\n if _message_has_tool_use(messages[head_end - 1]):\n while head_end < len(messages) and _is_tool_result_message(messages[head_end]):\n head_end += 1\n if _is_tool_result_message(messages[tail_start]) and _message_has_tool_use(messages[tail_start - 1]):\n tail_start -= 1\n snipped = tail_start - head_end\n placeholder = {\"role\": \"user\", \"content\": f\"[snipped {snipped} messages from conversation middle]\"}\n return messages[:head_end] + [placeholder] + messages[tail_start:]\n```\n\n裁掉的是消息本身,只是在切口处多做一步保护;剩下的消息里 `tool_result` 内容仍在累积——第 34 条消息里可能躺着 30KB 的旧文件内容。→ L2。\n\n### L2: micro_compact — 旧工具结果占位\n\n![旧结果占位](/course-assets/s08_context_compact/micro-compact.svg)\n\nAgent 连续读了 10 个文件。第 1-7 次的完整内容还躺在上下文里,早就不需要了,但占着大量空间。\n\n只保留最近 3 条 `tool_result` 的完整内容,更旧的替换为一行占位符:\n\n```python\nKEEP_RECENT_TOOL_RESULTS = 3\n\ndef micro_compact(messages):\n tool_results = collect_tool_result_blocks(messages)\n if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS:\n return messages\n for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n```\n\n旧结果清掉了,但单条新结果可能就有 500KB——一个 `cat` 大文件的输出就能打满上下文。→ L3。\n\n### L3: tool_result_budget — 大结果落盘\n\n![大结果落盘](/course-assets/s08_context_compact/layer1-budget.svg)\n\n模型一次读了 5 个大文件,单条 user 消息里所有 `tool_result` 加起来 500KB。\n\n统计最后一条 user 消息里所有 `tool_result` 的总大小。超过 200KB → 按大小排序,从最大的开始落盘到 `.task_outputs/tool-results/`,上下文里只留 `` 标记 + 前 2000 字符预览。模型看到标记后知道完整内容在磁盘上,需要时可以重新读。\n\n```python\ndef tool_result_budget(messages, max_bytes=200_000):\n last = messages[-1]\n blocks = [(i, b) for i, b in enumerate(last[\"content\"])\n if b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes:\n return messages\n ranked = sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True)\n for idx, block in ranked:\n if total <= max_bytes:\n break\n block[\"content\"] = persist_large_output(block[\"tool_use_id\"], str(block[\"content\"]))\n total = recalculate_total(blocks)\n return messages\n```\n\n前三层都是纯文本/结构操作,0 API 调用,但也无法\"理解\"对话内容。上下文可能仍然太大。→ L4。\n\n### L4: compact_history — LLM 全量摘要\n\n![LLM 全量摘要](/course-assets/s08_context_compact/auto-compact.svg)\n\n前三层全跑完了,但在超大项目中连续工作 30 分钟后,token 仍然超过阈值。\n\n三步流程:\n\n1. **保存 transcript**:完整对话写入 `.transcripts/`,JSONL 格式。transcript 保留了可恢复记录,但模型的活跃上下文里只剩摘要。对模型当下推理来说,细节已经不在上下文中了。教学代码没有提供 transcript 检索工具。\n2. **LLM 生成摘要**:把对话历史发给 LLM,要求保留当前目标、重要发现、已改文件、剩余工作、用户约束等关键信息。\n3. **替换消息列表**:所有旧消息被替换为一条摘要。教学版只保留摘要;真实 Claude Code 会在 compact 后重新附加部分最近文件、计划、agent/skill/tool 等上下文。\n\n```python\ndef compact_history(messages):\n transcript_path = write_transcript(messages) # 先保存完整对话\n summary = summarize_history(messages) # LLM 生成摘要\n return [{\"role\": \"user\",\n \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n```\n\n**熔断器**:连续失败 3 次后停止重试,防止死循环浪费 API 调用。\n\n### 应急: reactive_compact\n\n有时候 API 还是返回 `prompt_too_long`(413),上下文增长速度快于压缩触发速度时。\n\n这时触发 **reactive_compact**:比 compact_history 更激进,从尾部回退,但仍要避免留下孤立 `tool_result`。\n\n```python\ndef reactive_compact(messages):\n transcript = write_transcript(messages)\n summary = summarize_history(messages)\n tail_start = max(0, len(messages) - 5)\n if _is_tool_result_message(messages[tail_start]) and _message_has_tool_use(messages[tail_start - 1]):\n tail_start -= 1\n return [{\"role\": \"user\",\n \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *messages[tail_start:]]\n```\n\nreactive compact 有重试上限(默认 1 次)。再失败就抛出异常,不无限循环。完整的错误恢复逻辑留给 s11。\n\n### 合起来跑\n\n```python\ndef agent_loop(messages):\n reactive_retries = 0\n while True:\n # 三个预处理器(0 API 调用)\n # 顺序:budget 先跑,确保大内容落盘后再做占位和裁剪\n messages[:] = tool_result_budget(messages) # L3: 大结果落盘\n messages[:] = snip_compact(messages) # L1: 裁中间\n messages[:] = micro_compact(messages) # L2: 旧结果占位\n\n # 还不够?LLM 摘要(1 API 调用)\n if estimate_token_count(messages) > THRESHOLD:\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(...)\n except PromptTooLongError:\n if reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = reactive_compact(messages) # 应急\n reactive_retries += 1\n continue\n raise # 超过重试上限,抛出异常\n # ... 工具执行 ...\n\n # compact 工具:模型主动调用时触发 compact_history\n if block.name == \"compact\":\n messages[:] = compact_history(messages)\n results.append({..., \"content\": \"[Compacted. History summarized.]\"})\n messages.append({\"role\": \"user\", \"content\": results})\n break # 结束当前 turn,用压缩后的上下文开始新一轮\n```\n\n**顺序不能换。** L3(budget)在 L2(micro)前面,因为 micro 会把旧的大 tool_result 替换成一行占位符,budget 必须在那之前把完整内容落盘。这也是为什么 CC 源码把 `applyToolResultBudget` 放在最前面。\n\n---\n\n## 相对 s07 的变更\n\n| 组件 | 之前 (s07) | 之后 (s08) |\n|------|-----------|-----------|\n| 上下文管理 | 无(上下文无限膨胀) | 四层压缩管线 + 应急 |\n| 新函数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |\n| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) |\n| 循环 | LLM 调用 → 工具执行 | 每轮前跑三层预处理器 + 阈值触发 compact_history |\n| 设计原则 | — | 便宜的先跑,贵的后跑 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\n试试这些 prompt:\n\n1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(连续读多个文件,观察 L2 压缩旧结果)\n2. `Read every file in s08_context_compact/`(一次性读大量内容,观察 L3 落盘)\n3. 反复对话 20+ 轮,观察是否出现 `[auto compact]` 或 `[reactive compact]`\n\n观察重点:每次工具执行后,旧 tool_result 是否被压缩?连续对话后 token 超阈值时,是否自动触发了摘要?\n\n---\n\n## 接下来\n\n上下文压缩让 Agent 能跑很久不会崩。但每次压缩后,用户之前告诉它的偏好、约束也跟着丢了。能不能让 Agent 有选择地记住重要的事?\n\ns09 Memory → 三个子系统:选择记什么、提取关键信息、整理巩固。跨压缩、跨会话。\n\n
\n深入 CC 源码\n\n> 以下基于 CC 源码 `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` 的分析。\n\n### 执行顺序对照\n\n教学版为了讲解方便按 L1/L2/L3/L4 编号,但实际执行顺序和编号不完全对应:\n\n| 维度 | 教学版 | Claude Code |\n|------|--------|-------------|\n| 执行顺序 | budget → snip → micro → auto | budget → snip → micro → collapse → auto(`query.ts:379-468`) |\n| snip_compact | 保留头 3 + 尾 47 | CC 仅主线程启用;实现不在开源仓库中(`HISTORY_SNIP` feature gate),但接口可见:`snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`,还暴露了 `SnipTool` 工具让模型主动调用。教学版的 3/47 是简化参数 |\n| micro_compact | 文本占位符替换 | 两条路径:time-based 直接清内容,cached 走 API `cache_edits`(legacy path 已移除) |\n| micro_compact 白名单 | 按位置(最近 3 条) | time-based 按时间阈值触发;cached 按计数触发(`microCompact.ts`) |\n| tool_result_budget | 200KB 字符 | 200,000 字符(`toolLimits.ts:49`) |\n| compact_history 阈值 | 字符数估算 | 精确 token:`contextWindow - maxOutputTokens - 13_000` |\n| 摘要要求 | 5 类信息 | 9 个部分 + ``/`` 双标签 |\n| 压缩 prompt | 简单 prompt | 首尾双重防呆禁止调工具 |\n| PTL retry | 有(简化) | `truncateHeadForPTLRetry()` 按消息组回退(`compact.ts:243-290`) |\n| 后压缩恢复 | 无(教学版只保留摘要) | 自动重新读取最近文件、计划、agent/skill/tool 等 |\n| 熔断器 | 3 次 | 3 次(`autoCompact.ts:70`) |\n| reactive 重试 | 1 次 | CC 有更精细的分级重试 |\n\n### 执行顺序详解\n\nCC 源码 `query.ts` 中的真实顺序:\n\n1. `applyToolResultBudget`(L379):先处理大结果,确保完整内容落盘\n2. `snipCompact`(L403):裁中间消息\n3. `microcompact`(L414):旧结果占位\n4. `contextCollapse`(L441):独立的上下文管理系统(教学版无)\n5. `autoCompact`(L454):LLM 全量摘要\n\n教学版的 budget → snip → micro 顺序与此一致。教学版没有 contextCollapse 机制。\n\n### 完整常量参考\n\n| 常量 | 值 | 源文件 |\n|------|-----|--------|\n| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |\n| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |\n| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |\n| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |\n| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |\n| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |\n| 时间 micro_compact 间隔 | 60 分钟 | `timeBasedMCConfig.ts` |\n| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |\n\n### contextCollapse 和 sessionMemoryCompact\n\nCC 源码中还有两个机制本教学版没有展开:\n\n- **contextCollapse**:独立的上下文管理系统,启用时抑制 proactive autocompact(`autoCompact.ts:215-222`),由 collapse 的 commit/blocking 流程接管上下文管理。但 manual `/compact` 和 reactive fallback 仍是独立路径,不受 contextCollapse 影响。\n- **sessionMemoryCompact**:compact_history 之前,CC 会先尝试用已有的 session memory(s09 会讲到)做轻量摘要,不调 LLM。这个机制等学完 s09 之后回头看会更清楚。\n\n### 压缩 prompt 长什么样?\n\nCC 的压缩 prompt 有两个硬性要求:\n\n1. **绝对禁止调用工具**:开头就是 `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`,末尾还会再 REMINDER 一次\n2. **先分析再总结**:模型需要先在 `` 标签里理清思路,然后在 `` 标签里输出正式摘要。analysis 在格式化时被剥离\n\n### 教学版的简化是刻意的\n\n- micro_compact 用文本占位 → 我们没有 API 层的 `cache_edits` 权限\n- token 用字符数估算 → 精确 tokenizer 不在教学范围内\n- 后压缩恢复省略 → 教学版只保留摘要,不自动重新附加文件\n- 两个辅助机制不展开 → 属于 10% 的细节\n\n核心设计思想,便宜的先跑贵的后跑,完整保留。\n\n
\n\n\n" + "content": "# s08: Context Compact — 上下文总会满,要有办法腾地方\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/zh/s09) → s10 → ... → s20\n> *\"上下文总会满, 要有办法腾地方\"* — 四层压缩策略, 便宜的先跑贵的后跑。\n>\n> **Harness 层**: 压缩 — 上下文超限时自动摘要,保持会话可持续。\n\n---\n\n## 问题\n\n上一章给 Agent 加了 Skills,它开始有了一点\"领域经验\":遇到 PDF、MCP、代码审查,会先加载对应的操作说明再动手。\n\n但 Agent 越会做事,另一个问题越明显。它读一个 1000 行的文件就是 ~4000 token,又读了 30 个文件,跑了 20 条命令。每条命令的输出、每个文件的内容,都被塞回 `messages` 列表,一点点堆起来。\n\n普通聊天几十轮不算什么。代码 Agent 不一样:一次读取就是几千行,一次测试就是一大段日志。任务还没做完,上下文窗口可能先满了。\n\n满了之后,问题不是\"模型答得差一点\",而是 API 直接拒绝:`prompt_too_long`。不压缩,Agent 根本没法在大项目里干活。\n\n---\n\n## 解决方案\n\n![Compact Overview](/course-assets/s08_context_compact/compact-overview.svg)\n\n保留 s07 的 hook 结构、技能加载、子 Agent 骨架,这一章只往里加一层:每次调用 LLM 之前,先整理 `messages`。\n\n最直接的想法是满了就让模型总结一下。但这里有两个问题。一是总结要多花一次 API 调用,每次上下文变大就摘要,成本很快上去。二是并不是所有内容都值得总结:很多旧工具结果早就不需要了;有些内容只是大,比如一次 `cat` 出几百 KB 日志,它不需要被\"理解\",只需要从上下文里挪走,必要时再读回来。\n\n所以 compact 不是一个动作,是一条管线。**便宜的先跑,贵的后跑**:先做几步不调模型的本地整理,能裁的裁、能占位的占位、能落盘的落盘;只有这些都不够,才让 LLM 做一次真正的摘要。\n\n---\n\n## 工作原理\n\n![四层压缩管线](/course-assets/s08_context_compact/compaction-layers.svg)\n\n### L1: snip_compact — 裁掉无关的旧对话\n\nAgent 跑了 80 轮,`messages` 攒了 160 条。最前面那句\"帮我创建 hello.py\"和当前工作几乎无关了,但还占着位置。\n\n消息数超过 50 条 → 保留头部 3 条(最初的任务、约束)和尾部 47 条(当前工作),中间裁掉。唯一要小心的边界:不能把 `assistant(tool_use)` 和后面的 `user(tool_result)` 拆开,否则模型会看到一个不知道对应哪次调用的孤立结果。\n\n```python\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages: return messages\n keep_head, keep_tail = 3, max_messages - 3\n head_end, tail_start = keep_head, len(messages) - keep_tail\n if head_end > 0 and _message_has_tool_use(messages[head_end - 1]):\n while head_end < len(messages) and _is_tool_result_message(messages[head_end]):\n head_end += 1\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n if head_end >= tail_start: return messages\n snipped = tail_start - head_end\n return messages[:head_end] + [{\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}] + messages[tail_start:]\n```\n\n裁掉的是消息本身,在切口处多做一步保护。但剩下的消息里,`tool_result` 内容仍在累积,第 34 条消息里可能还躺着 30KB 的旧文件内容。消息数少了,token 没少。→ L2。\n\n### L2: micro_compact — 旧工具结果占位\n\n![旧结果占位](/course-assets/s08_context_compact/micro-compact.svg)\n\n最容易把上下文撑大的,往往不是对话本身,而是工具结果。Agent 连续读了 10 个文件,第 1 到第 7 个的完整内容早就不需要了,却还原样躺在上下文里。\n\n保留最近 3 条 `tool_result` 的完整内容,更早的换成一行占位符。想法很朴素:旧结果真要用,模型重新读一次就行,不该一直占着位置。\n\n```python\nKEEP_RECENT = 3\n\ndef micro_compact(messages):\n tool_results = collect_tool_results(messages)\n if len(tool_results) <= KEEP_RECENT: return messages\n for _, _, block in tool_results[:-KEEP_RECENT]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n```\n\n旧结果清掉了,但还挡不住一种情况:单条新结果就有 500KB,一次 `cat` 大文件的输出就能用满上下文,而它还很新,micro_compact 不会动它。→ L3。\n\n### L3: tool_result_budget — 大结果落盘\n\n![大结果落盘](/course-assets/s08_context_compact/layer1-budget.svg)\n\n有些结果不是\"多\",是\"单条太大\"。模型一次读了 5 个大文件,最后一条 user 消息里的 `tool_result` 加起来超过 200KB,这时候只保留最近 3 条没用,因为最新的那条本身就能用满上下文。\n\n给工具结果设一个预算。统计最后一条 user 消息里所有 `tool_result` 的总大小,超过 200KB 就从最大的开始落盘到 `.task_outputs/tool-results/`,上下文里只留一个 `` 标记 + 前 2000 字符预览。模型看到标记就知道完整内容在磁盘上,需要时再读回来。\n\n```python\ndef tool_result_budget(messages, max_bytes=200_000):\n last = messages[-1] if messages else None\n if not last or last.get(\"role\") != \"user\" or not isinstance(last.get(\"content\"), list): return messages\n blocks = [(i, b) for i, b in enumerate(last[\"content\"])\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes: return messages\n ranked = sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True)\n for _, block in ranked:\n if total <= max_bytes: break\n block[\"content\"] = persist_large_output(block.get(\"tool_use_id\", \"unknown\"), str(block.get(\"content\", \"\")))\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return messages\n```\n\n这一步重要的不是丢,是把内容从\"活跃上下文\"移到\"可恢复的外部存储\"。前三层到这就齐了:纯文本/结构操作,0 API 调用,各盯一种冗余。但它们都有同一个限制:读不懂对话在说什么,不知道哪些发现重要、哪些约束必须留。如果上下文还是太大,就只能让模型出手。→ L4。\n\n### L4: compact_history — LLM 全量摘要\n\n![LLM 全量摘要](/course-assets/s08_context_compact/auto-compact.svg)\n\n前三层全跑完,token 还是超阈值。这一步才是很多人直觉里的\"上下文压缩\":把历史交给模型,摘成一段更短的状态。\n\n三步:先把完整对话写进 `.transcripts/`(JSONL),活跃上下文里只剩摘要,但磁盘上留着完整记录;再让 LLM 生成摘要,要求保留当前目标、重要发现、已改文件、剩余工作、用户约束;最后用这一条摘要替换掉所有旧消息。\n\n```python\ndef compact_history(messages):\n transcript_path = write_transcript(messages) # 先存完整对话\n summary = summarize_history(messages) # LLM 生成摘要\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n```\n\n这一步是有损的:transcript 里有完整历史,但模型当下看不到那些细节了,只能靠摘要继续。所以前面才要先跑 L1/L2/L3:能不让模型总结就不总结,因为一旦进摘要,细节就不可避免地丢。教学版还加了个熔断器:连续 compact 失败 3 次就停,不无限重试浪费 API。\n\n### 应急: reactive_compact\n\n正常情况下,我们在调用模型之前就把上下文整理好。但上下文增长太快、或 token 估算不准时,API 还是可能返回 `prompt_too_long`。\n\n这时走 reactive_compact:和 compact_history 很像,但更激进:先存 transcript,对前面大半段生成摘要,只保留最后 5 条作尾部上下文(同样避免留下孤立 `tool_result`)。\n\n```python\ndef reactive_compact(messages):\n transcript = write_transcript(messages)\n tail_start = max(0, len(messages) - 5)\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n summary = summarize_history(messages[:tail_start])\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *messages[tail_start:]]\n```\n\nreactive 是兜底路径,不是常规路径,默认只重试 1 次,再失败就抛异常,不无限循环。完整的错误恢复逻辑留给 s11。\n\n### 合起来跑\n\n把这些挂回 Agent Loop:每轮调用 LLM 之前,先跑三层本地整理,不够再摘要;调用真报错了,再走应急。\n\n```python\ndef agent_loop(messages):\n reactive_retries = 0\n while True:\n # 三个预处理器(0 API),顺序:budget → snip → micro\n messages[:] = tool_result_budget(messages) # L3: 大结果落盘\n messages[:] = snip_compact(messages) # L1: 裁中间\n messages[:] = micro_compact(messages) # L2: 旧结果占位\n\n if estimate_size(messages) > CONTEXT_LIMIT: # 还不够,LLM 摘要(1 API)\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000)\n except Exception as e:\n if \"prompt_too_long\" in str(e).lower() and reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = reactive_compact(messages) # 应急\n reactive_retries += 1\n continue\n raise\n # ... 工具执行 ...\n```\n\n**顺序不能换。** L3(budget)必须在 L2(micro)前面:micro 会把旧的大 `tool_result` 换成一行占位符,要是它先跑,budget 就没机会把完整内容落盘了。先 budget 把大内容存好,再做占位和裁剪。这也是 Claude Code 源码把 `applyToolResultBudget` 放在最前面的原因。\n\n### compact 工具:让模型也能主动请求\n\n除了自动压缩,模型自己也能要求整理:当它觉得上下文太长、或任务阶段切换了,可以主动调 `compact` 工具。在教学版里,这个工具触发 `compact_history`,然后结束当前 turn,用压缩后的上下文重新开一轮。和手动 `/compact` 的感觉很像,区别是这次是模型自己意识到要整理。\n\n---\n\n## 相对 s07 的变更\n\n| 组件 | 之前 (s07) | 之后 (s08) |\n|------|-----------|-----------|\n| 上下文管理 | 无(上下文无限膨胀) | 四层压缩管线 + 应急 |\n| 新函数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |\n| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) |\n| 循环 | LLM 调用 → 工具执行 | 每轮前跑三层预处理器 + 阈值触发 compact_history |\n| 设计原则 | 让 Agent 会做事 | 让 Agent 做久一点也不崩 |\n\n这一步不算给 Agent 加\"能力\",更像加\"体力\":s07 让它更会做专业任务,s08 让它在长任务里不被自己的历史拖垮。\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\n试试这些 prompt:\n\n1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(连续读多个文件,观察 L2 压缩旧结果)\n2. `Read every file in s08_context_compact/`(一次性读大量内容,观察 L3 落盘)\n3. 反复对话 20+ 轮,观察是否出现 `[auto compact]` 或 `[reactive compact]`\n\n观察重点:每次工具执行后,旧 `tool_result` 是否被替换?大输出有没有落盘?token 超阈值时是否生成了摘要?\n\n---\n\n## 接下来\n\n压缩让 Agent 能跑很久不崩。但每次压缩都会丢一些细节:用户之前说过的偏好、项目里的长期约束、某些跨任务都重要的信息,不一定能完整留在摘要里。\n\ncompact 解决的是\"当前会话快满了,怎么继续跑下去\"。它没解决\"哪些信息值得长期留下来\"。\n\ns09 Memory → 三个子系统:选择记什么、提取关键信息、整理巩固。跨压缩、跨会话。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` 的分析。\n\n### 执行顺序对照\n\n教学版为了讲解方便按 L1/L2/L3/L4 编号,但实际执行顺序和编号不完全对应:\n\n| 维度 | 教学版 | Claude Code |\n|------|--------|-------------|\n| 执行顺序 | budget → snip → micro → auto | budget → snip → micro → collapse → auto(`query.ts:379-468`) |\n| snip_compact | 保留头 3 + 尾 47 | Claude Code 仅主线程启用;实现不在开源仓库中(`HISTORY_SNIP` feature gate),但接口可见:`snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`,还暴露了 `SnipTool` 工具让模型主动调用。教学版的 3/47 是简化参数 |\n| micro_compact | 文本占位符替换 | 两条路径:time-based 直接清内容,cached 走 API `cache_edits`(legacy path 已移除) |\n| micro_compact 白名单 | 按位置(最近 3 条) | time-based 按时间阈值触发;cached 按计数触发(`microCompact.ts`) |\n| tool_result_budget | 200KB 字符 | 200,000 字符(`toolLimits.ts:49`) |\n| compact_history 阈值 | 字符数估算 | 精确 token:`contextWindow - maxOutputTokens - 13_000` |\n| 摘要要求 | 5 类信息 | 9 个部分 + ``/`` 双标签 |\n| 压缩 prompt | 简单 prompt | 首尾双重防呆禁止调工具 |\n| PTL retry | 有(简化) | `truncateHeadForPTLRetry()` 按消息组回退(`compact.ts:243-290`) |\n| 后压缩恢复 | 无(教学版只保留摘要) | 自动重新读取最近文件、计划、agent/skill/tool 等 |\n| 熔断器 | 3 次 | 3 次(`autoCompact.ts:70`) |\n| reactive 重试 | 1 次 | Claude Code 有更精细的分级重试 |\n\n### 执行顺序详解\n\nClaude Code 源码 `query.ts` 中的真实顺序:\n\n1. `applyToolResultBudget`(L379):先处理大结果,确保完整内容落盘\n2. `snipCompact`(L403):裁中间消息\n3. `microcompact`(L414):旧结果占位\n4. `contextCollapse`(L441):独立的上下文管理系统(教学版无)\n5. `autoCompact`(L454):LLM 全量摘要\n\n教学版的 budget → snip → micro 顺序与此一致。教学版没有 contextCollapse 机制。\n\n### read_file 的取舍\n\n教学版的 `micro_compact` 会把旧 `tool_result` 统一替换成占位符,包括 `read_file`。这通常不影响功能正确性:如果后续还需要文件内容,模型可以重新读一次。代价是可能多一次工具调用,也可能降低 prompt cache 命中率。\n\nClaude Code 没有用教学版这种简单规则解决这个问题。它把 `Read` 也放进可 microcompact 的工具集合,但同时维护 `readFileState`:重复读取未变化文件时返回 `FILE_UNCHANGED_STUB`,compact 后再按预算恢复最近读过的文件内容(例如最多 5 个文件、每个 5K token、总预算 50K token)。这是生产级实现里的缓存和恢复机制,教学版不展开,保留“压缩旧结果,必要时重新读取”的简单 trade-off。\n\n### 完整常量参考\n\n| 常量 | 值 | 源文件 |\n|------|-----|--------|\n| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |\n| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |\n| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |\n| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |\n| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |\n| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |\n| 时间 micro_compact 间隔 | 60 分钟 | `timeBasedMCConfig.ts` |\n| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |\n\n### contextCollapse 和 sessionMemoryCompact\n\nClaude Code 源码中还有两个机制本教学版没有展开:\n\n- **contextCollapse**:独立的上下文管理系统,启用时抑制 proactive autocompact(`autoCompact.ts:215-222`),由 collapse 的 commit/blocking 流程接管上下文管理。但 manual `/compact` 和 reactive fallback 仍是独立路径,不受 contextCollapse 影响。\n- **sessionMemoryCompact**:compact_history 之前,Claude Code 会先尝试用已有的 session memory(s09 会讲到)做轻量摘要,不调 LLM。这个机制等学完 s09 之后回头看会更清楚。\n\n### 压缩 prompt 长什么样?\n\nClaude Code 的压缩 prompt 有两个硬性要求:\n\n1. **绝对禁止调用工具**:开头就是 `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`,末尾还会再 REMINDER 一次\n2. **先分析再总结**:模型需要先在 `` 标签里理清思路,然后在 `` 标签里输出正式摘要。analysis 在格式化时被剥离\n\n### 教学版的简化是刻意的\n\n- micro_compact 用文本占位 → 我们没有 API 层的 `cache_edits` 权限\n- read_file 不特殊处理 → 教学版接受必要时重新读取,避免引入 readFileState 和后压缩恢复机制\n- token 用字符数估算 → 精确 tokenizer 不在教学范围内\n- 后压缩恢复省略 → 教学版只保留摘要,不自动重新附加文件\n- 两个辅助机制不展开 → 属于 10% 的细节\n\n核心设计思想完整保留。\n\n
\n\n\n" }, { "version": "s08", "locale": "ja", "title": "s08: Context Compact — コンテキストはいつか満杯になる、場所を空ける方法が必要", - "content": "# s08: Context Compact — コンテキストはいつか満杯になる、場所を空ける方法が必要\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/ja/s09) → s10 → ... → s20\n> *\"Context will fill up — have a way to make room\"* — 4層圧縮戦略、安価なものを先に、高価なものを後に実行。\n>\n> **Harness レイヤー**: 圧縮 — クリーンな記憶、無限のセッション。\n\n---\n\n## 課題\n\nAgent が動いている途中で、止まってしまう。\n\nbash、read、write は揃っており、能力は十分。しかし 1000 行のファイル(~4000 token)を読み、さらに 30 のファイルを読み、20 のコマンドを実行したとします。各コマンドの出力、各ファイルの内容がすべて `messages` リストに蓄積されます。\n\nコンテキストウィンドウには上限があります。満杯になると、API は即座に拒否します:`prompt_too_long`。\n\n圧縮しなければ、Agent は大規模プロジェクトではまともに動けません。\n\n---\n\n## ソリューション\n\n![Compact Overview](/course-assets/s08_context_compact/compact-overview.ja.svg)\n\ns07 のフック構造、スキルロード、サブ Agent の骨格を維持し、圧縮に焦点を当てるため一部のツールは省略。コアの変更点:各 LLM 呼び出し前に 3 層のプリプロセッサ(0 API)を挿入し、token が閾値を超えた場合は LLM 要約(1 API)をトリガー、API エラー時には緊急トリムを実行。\n\nコア設計:安価なものを先に、高価なものを後に。\n\n---\n\n## 仕組み\n\n![4層圧縮パイプライン](/course-assets/s08_context_compact/compaction-layers.ja.svg)\n\n### L1: snip_compact — 無関係な古い会話を切り捨て\n\nAgent が 80 ラウンドの会話を実行し、`messages` が 160 件まで溜まった。先頭の「hello.py を作って」は現在の作業とほぼ無関係だが、スペースを占有し続けている。\n\nメッセージ数が 50 を超えた場合 → 先頭 3 件(初期コンテキスト)と末尾 47 件(現在の作業)を保持して中間を切り詰める。ただし切れ目だけは調整し、`assistant(tool_use)` と後続の `user(tool_result)` を分断しない:\n\n```python\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages:\n return messages\n head_end, tail_start = 3, len(messages) - (max_messages - 3)\n if _message_has_tool_use(messages[head_end - 1]):\n while head_end < len(messages) and _is_tool_result_message(messages[head_end]):\n head_end += 1\n if _is_tool_result_message(messages[tail_start]) and _message_has_tool_use(messages[tail_start - 1]):\n tail_start -= 1\n snipped = tail_start - head_end\n placeholder = {\"role\": \"user\", \"content\": f\"[snipped {snipped} messages from conversation middle]\"}\n return messages[:head_end] + [placeholder] + messages[tail_start:]\n```\n\n切り捨て自体は単純なままで、境界だけを保護する。残ったメッセージ内の `tool_result` 内容はまだ蓄積され続けている。34 番目のメッセージに 30KB の古いファイル内容が残っているかもしれない。→ L2。\n\n### L2: micro_compact — 古いツール結果をプレースホルダに置換\n\n![古い結果のプレースホルダ](/course-assets/s08_context_compact/micro-compact.ja.svg)\n\nAgent が連続して 10 個のファイルを読んだ。1〜7 回目の完全な内容はまだコンテキストに残っており、もう不要だが、大量のスペースを占有している。\n\n直近 3 件の `tool_result` の完全な内容のみを保持し、それより古いものは 1 行のプレースホルダに置換:\n\n```python\nKEEP_RECENT_TOOL_RESULTS = 3\n\ndef micro_compact(messages):\n tool_results = collect_tool_result_blocks(messages)\n if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS:\n return messages\n for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n```\n\n古い結果はクリーンアップされたが、1 件の新しい結果だけで 500KB の可能性がある。大きなファイルを `cat` するだけでコンテキストがいっぱいになる。→ L3。\n\n### L3: tool_result_budget — 大きな結果をディスクに退避\n\n![大きな結果のディスク退避](/course-assets/s08_context_compact/layer1-budget.ja.svg)\n\nモデルが一度に 5 つの大きなファイルを読み、1 つの user メッセージ内の全 `tool_result` の合計が 500KB に達した。\n\n最後の user メッセージ内のすべての `tool_result` の合計サイズを集計。200KB を超えた場合 → サイズ順にソートし、最大のものから順に `.task_outputs/tool-results/` に退避。コンテキストには `` マーカー + 先頭 2000 文字のプレビューのみを残す。モデルはマーカーを見て完全な内容がディスク上にあることを認識し、必要に応じて再読み込みできる。\n\n```python\ndef tool_result_budget(messages, max_bytes=200_000):\n last = messages[-1]\n blocks = [(i, b) for i, b in enumerate(last[\"content\"])\n if b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes:\n return messages\n ranked = sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True)\n for idx, block in ranked:\n if total <= max_bytes:\n break\n block[\"content\"] = persist_large_output(block[\"tool_use_id\"], str(block[\"content\"]))\n total = recalculate_total(blocks)\n return messages\n```\n\n最初の 3 層はすべて純粋なテキスト/構造操作(0 API 呼び出し)だが、会話内容を「理解」することはできない。コンテキストがまだ大きすぎる可能性がある。→ L4。\n\n### L4: compact_history — LLM 全量要約\n\n![LLM 全量要約](/course-assets/s08_context_compact/auto-compact.ja.svg)\n\n最初の 3 層がすべて実行されたが、超大規模プロジェクトで 30 分間連続作業すると、token がまだ閾値を超えている。\n\n3 ステップのフロー:\n\n1. **transcript を保存**:完全な会話を `.transcripts/` に JSONL 形式で書き出す。transcript は回復可能な記録として保存されるが、モデルのアクティブなコンテキストには要約しか残らない。モデルの現在の推論にとって、詳細はすでにコンテキストにない。教学コードは transcript 検索ツールを提供しない。\n2. **LLM で要約を生成**:会話履歴を LLM に送り、現在の目標、重要な発見、変更済みファイル、残りの作業、ユーザーの制約などの重要な情報を保持するよう指示。\n3. **メッセージリストを置換**:すべての古いメッセージが 1 件の要約に置き換えられる。教学版は要約のみを保持する。実際の Claude Code は compact 後に直近のファイル、計画、agent/skill/tool などのコンテキストを再付加する。\n\n```python\ndef compact_history(messages):\n transcript_path = write_transcript(messages) # 先に完全な会話を保存\n summary = summarize_history(messages) # LLM で要約を生成\n return [{\"role\": \"user\",\n \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n```\n\n**サーキットブレーカー**:連続 3 回失敗したらリトライを停止し、無限ループによる API 呼び出しの浪費を防止。\n\n### 緊急: reactive_compact\n\nAPI がまだ `prompt_too_long`(413)を返すことがある。コンテキストの増加速度が圧縮のトリガー速度を上回る場合。\n\nこの時 **reactive_compact** がトリガーされる:compact_history よりもさらに積極的だが、末尾を残す際も孤立した `tool_result` を残さないようにする。\n\n```python\ndef reactive_compact(messages):\n transcript = write_transcript(messages)\n summary = summarize_history(messages)\n tail_start = max(0, len(messages) - 5)\n if _is_tool_result_message(messages[tail_start]) and _message_has_tool_use(messages[tail_start - 1]):\n tail_start -= 1\n return [{\"role\": \"user\",\n \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *messages[tail_start:]]\n```\n\nreactive compact にはリトライ上限がある(デフォルト 1 回)。さらに失敗した場合は例外をスローし、無限ループしない。完全なエラー回復ロジックは s11 に委ねる。\n\n### 合わせて実行\n\n```python\ndef agent_loop(messages):\n reactive_retries = 0\n while True:\n # 3 つのプリプロセッサ(0 API 呼び出し)\n # 順序:budget を先に実行し、大きな内容をプレースホルダ化する前に退避\n messages[:] = tool_result_budget(messages) # L3: 大きな結果を退避\n messages[:] = snip_compact(messages) # L1: 中間を切り捨て\n messages[:] = micro_compact(messages) # L2: 古い結果をプレースホルダに\n\n # まだ足りない?LLM 要約(1 API 呼び出し)\n if estimate_token_count(messages) > THRESHOLD:\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(...)\n except PromptTooLongError:\n if reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = reactive_compact(messages) # 緊急対応\n reactive_retries += 1\n continue\n raise # リトライ上限超過、例外をスロー\n # ... ツール実行 ...\n\n # compact ツール:モデルが能動的に呼び出した場合、compact_history をトリガー\n if block.name == \"compact\":\n messages[:] = compact_history(messages)\n results.append({..., \"content\": \"[Compacted. History summarized.]\"})\n messages.append({\"role\": \"user\", \"content\": results})\n break # 現在のターンを終了し、圧縮後のコンテキストで新しく開始\n```\n\n**順序は変えられない。** L3(budget)が L2(micro)の前に実行される理由:micro は古い大きな tool_result を 1 行のプレースホルダに置換するため、budget はその前に完全な内容を退避させる必要がある。CC ソースが `applyToolResultBudget` を最初に配置する理由も同じ。\n\n---\n\n## s07 からの変更点\n\n| コンポーネント | 変更前 (s07) | 変更後 (s08) |\n|------|-----------|-----------|\n| コンテキスト管理 | なし(コンテキストが無限に膨張) | 4 層圧縮パイプライン + 緊急対応 |\n| 新規関数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |\n| ツール | bash, read_file, write_file, edit_file, glob, todo_write, task, load_skill (8) | 8 + compact (9) |\n| ループ | LLM 呼び出し → ツール実行 | 各ラウンド前に 3 層プリプロセッサを実行 + 閾値で compact_history をトリガー |\n| 設計原則 | — | 安価なものを先に、高価なものを後に |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\n以下のプロンプトを試してみてください:\n\n1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(連続して複数のファイルを読み、L2 の古い結果圧縮を観察)\n2. `Read every file in s08_context_compact/`(一度に大量の内容を読み込み、L3 のディスク退避を観察)\n3. 20+ ラウンドの対話を繰り返し、`[auto compact]` または `[reactive compact]` が表示されるか観察\n\n観察のポイント:ツール実行のたびに、古い tool_result は圧縮されているか?連続対話で token が閾値を超えたとき、要約が自動的にトリガーされたか?\n\n---\n\n## 次へ\n\nコンテキスト圧縮により、Agent は長時間クラッシュせずに動けるようになった。しかし、圧縮のたびにユーザーが以前に伝えた偏好や制約も一緒に失われてしまう。Agent が重要なことを選択的に記憶できるようにできないか?\n\ns09 Memory → 3 つのサブシステム:何を記憶するかの選択、重要情報の抽出、整理と統合。圧縮を越え、セッションを越えて。\n\n
\nCC ソースコードの詳細\n\n> 以下は CC ソースコード `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` の分析に基づく。\n\n### 実行順序の対応\n\n教学版は説明の便宜上 L1/L2/L3/L4 と番号を振っているが、実際の実行順序は番号と完全には一致しない:\n\n| 項目 | 教学版 | Claude Code |\n|------|--------|-------------|\n| 実行順序 | budget → snip → micro → auto | budget → snip → micro → collapse → auto(`query.ts:379-468`) |\n| snip_compact | 先頭 3 + 末尾 47 を保持 | CC はメインスレッドのみ有効;実装はオープンソースリポジトリにない(`HISTORY_SNIP` feature gate)、インターフェースは確認可能:`snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`、`SnipTool` もモデルが能動的に呼び出し可能。教学版の 3/47 は簡略パラメータ |\n| micro_compact | テキストプレースホルダで置換 | 2 つのパス:time-based は直接内容をクリア、cached は API の `cache_edits` を使用(legacy パスは削除済み) |\n| micro_compact ホワイトリスト | 位置による(直近 3 件) | time-based は時間閾値でトリガー、cached はカウントでトリガー(`microCompact.ts`) |\n| tool_result_budget | 200KB 文字 | 200,000 文字(`toolLimits.ts:49`) |\n| compact_history 閾値 | 文字数で推定 | 精密な token 数:`contextWindow - maxOutputTokens - 13_000` |\n| 要約の要求 | 5 種類の情報 | 9 つのセクション + ``/`` デュアルタグ |\n| 圧縮プロンプト | シンプルなプロンプト | 先頭と末尾に二重の安全ガードでツール呼び出しを禁止 |\n| PTL retry | あり(簡略版) | `truncateHeadForPTLRetry()` がメッセージグループ単位でロールバック(`compact.ts:243-290`) |\n| 圧縮後のリカバリ | なし(教学版は要約のみ保持) | 直近のファイル、計画、agent/skill/tool などの自動再付加 |\n| サーキットブレーカー | 3 回 | 3 回(`autoCompact.ts:70`) |\n| reactive リトライ | 1 回 | CC にはより精緻な段階別リトライがある |\n\n### 実行順序の詳細\n\nCC ソース `query.ts` での実際の順序:\n\n1. `applyToolResultBudget`(L379):まず大きな結果を処理し、完全な内容を退避\n2. `snipCompact`(L403):中間メッセージを切り捨て\n3. `microcompact`(L414):古い結果のプレースホルダ化\n4. `contextCollapse`(L441):独立したコンテキスト管理システム(教学版にはなし)\n5. `autoCompact`(L454):LLM 全量要約\n\n教学版の budget → snip → micro の順序はこれと一致する。教学版には contextCollapse メカニズムがない。\n\n### 完全な定数リファレンス\n\n| 定数 | 値 | ソースファイル |\n|------|-----|--------|\n| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |\n| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |\n| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |\n| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |\n| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |\n| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |\n| 時間ベース micro_compact 間隔 | 60 分 | `timeBasedMCConfig.ts` |\n| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |\n\n### contextCollapse と sessionMemoryCompact\n\nCC ソースコードには、この教学版では展開していない 2 つのメカニズムが存在する:\n\n- **contextCollapse**:独立したコンテキスト管理システム。有効時には proactive autocompact を抑制し(`autoCompact.ts:215-222`)、collapse の commit/blocking フローがコンテキスト管理を引き継ぐ。ただし manual `/compact` と reactive fallback は独立パスのままで、contextCollapse の影響を受けない。\n- **sessionMemoryCompact**:compact_history の前に、CC は既存の session memory(s09 で解説)を使った軽量要約を先に試みる。LLM を呼び出さない。このメカニズムは s09 を学んだ後に振り返るとより理解しやすい。\n\n### 圧縮プロンプトの中身\n\nCC の圧縮プロンプトには 2 つの厳格な要件がある:\n\n1. **ツール呼び出しの絶対禁止**:冒頭が `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.` で、末尾にも再度 REMINDER がある\n2. **先に分析してから要約**:モデルはまず `` タグで思考を整理し、その後 `` タグで正式な要約を出力する。analysis はフォーマット時に除去される\n\n### 教学版の簡略化は意図的\n\n- micro_compact でテキストプレースホルダを使用 → API 層の `cache_edits` 権限がないため\n- token を文字数で推定 → 精密な tokenizer は教学の対象外\n- 圧縮後のリカバリを省略 → 教学版は要約のみを保持し、ファイルの自動再付加を行わない\n- 2 つの補助メカニズムを展開しない → 10% の細部に属する\n\nコア設計思想、安価なものを先に高価なものを後に、は完全に保持されている。\n\n
\n\n\n" + "content": "# s08: Context Compact — コンテキストはいつか満杯になる、場所を空ける方法が必要\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/ja/s09) → s10 → ... → s20\n> *\"Context will fill up — have a way to make room\"* — 4層圧縮戦略、安価なものを先に、高価なものを後に実行。\n>\n> **Harness レイヤー**: 圧縮 — コンテキスト超過時に自動要約し、セッションを持続可能に保つ。\n\n---\n\n## 課題\n\n前章で Agent に Skills を加えた。少し「領域経験」を持ち始め、PDF や MCP、コードレビューに出くわすと、対応する操作説明を先に読み込んでから動くようになった。\n\nだが Agent が仕事をこなせるほど、別の問題が目立ってくる。1000 行のファイルを 1 つ読めば ~4000 token、さらに 30 個のファイルを読み、20 個のコマンドを走らせる。コマンドの出力もファイルの内容も、すべて `messages` リストに戻され、少しずつ積み上がる。\n\n普通のチャットなら数十ターンは何でもない。コードエージェントは違う。一度の読み取りが数千行、一度のテストが大量のログだ。タスクが終わらないうちに、コンテキストウィンドウが先に満杯になりかねない。\n\n満杯になると、問題は「モデルの答えが少し悪い」ではない。API がリクエストを直接拒否する:`prompt_too_long`。圧縮しなければ、Agent は大きなプロジェクトでまともに動けない。\n\n---\n\n## ソリューション\n\n![Compact Overview](/course-assets/s08_context_compact/compact-overview.svg)\n\ns07 の hook 構造、skill ロード、サブエージェントの骨格は残し、この章では一層だけ加える。LLM を呼ぶ前に、まず `messages` を整える。\n\nいちばん素直な発想は、満杯になったらモデルに要約させることだ。だがここには 2 つの問題がある。1 つ目、要約は API 呼び出しを 1 回余分に使う。コンテキストが大きくなるたびに要約していては、コストがすぐ膨らむ。2 つ目、すべての内容が要約に値するわけではない。古いツール結果の多くはとっくに不要だし、ただ大きいだけの内容もある。たとえば `cat` が数百 KB のログを吐いた場合、それは「理解」される必要はなく、コンテキストから外して必要なときに読み直せばいい。\n\nだから compact は 1 つの動作ではなく、1 本のパイプラインだ。**安いものを先に、高いものを後に**。まずモデルを呼ばないローカルな整理を数ステップ走らせる。切れるものは切り、プレースホルダにできるものは置き換え、ディスクに退避できるものは退避する。それでも足りないときに初めて、LLM に本当の要約をさせる。\n\n---\n\n## 仕組み\n\n![4層圧縮パイプライン](/course-assets/s08_context_compact/compaction-layers.svg)\n\n### L1: snip_compact — 無関係な古い会話を切り捨て\n\nAgent が 80 ターン走り、`messages` は 160 件たまった。先頭の「hello.py を作って」は今の作業とほぼ無関係になっているが、まだ場所を占めている。\n\nメッセージ数が 50 を超えたら → 先頭 3 件(最初のタスクと制約)と末尾 47 件(今の作業)を残し、中間を切る。気をつける境界は 1 つだけ:`assistant(tool_use)` とその後ろの `user(tool_result)` を切り離してはいけない。さもないとモデルは、どの呼び出しに対応するか分からない孤立した結果を見ることになる。\n\n```python\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages: return messages\n keep_head, keep_tail = 3, max_messages - 3\n head_end, tail_start = keep_head, len(messages) - keep_tail\n if head_end > 0 and _message_has_tool_use(messages[head_end - 1]):\n while head_end < len(messages) and _is_tool_result_message(messages[head_end]):\n head_end += 1\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n if head_end >= tail_start: return messages\n snipped = tail_start - head_end\n return messages[:head_end] + [{\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}] + messages[tail_start:]\n```\n\n切るのはメッセージそのもので、切れ目に保護を 1 つ入れるだけだ。だが残ったメッセージの中では、`tool_result` の内容がまだ積み上がっている。34 番目のメッセージには 30KB の古いファイル内容が眠っているかもしれない。メッセージ数は減っても、token は減っていない。→ L2。\n\n### L2: micro_compact — 古いツール結果をプレースホルダに置換\n\n![古い結果のプレースホルダ化](/course-assets/s08_context_compact/micro-compact.svg)\n\nコンテキストを膨らませる最大の原因は、会話そのものよりツール結果であることが多い。Agent が 10 個のファイルを続けて読んだとして、1 番目から 7 番目までの完全な内容はとっくに不要なのに、そのままコンテキストに居座っている。\n\n直近 3 件の `tool_result` の完全な内容を残し、それより古いものは 1 行のプレースホルダに置き換える。発想は素朴だ。古い結果が本当に要るなら、モデルがもう一度読めばいい。ずっと場所を占めるべきではない。\n\n```python\nKEEP_RECENT = 3\n\ndef micro_compact(messages):\n tool_results = collect_tool_results(messages)\n if len(tool_results) <= KEEP_RECENT: return messages\n for _, _, block in tool_results[:-KEEP_RECENT]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n```\n\n古い結果は片付いたが、まだ防げないケースがある。1 件の新しい結果だけで 500KB になることだ。大きなファイルを `cat` した出力 1 つでコンテキストを使い切ってしまう。しかもそれは新しすぎて、micro_compact は手を付けない。→ L3。\n\n### L3: tool_result_budget — 大きな結果をディスクに退避\n\n![大きな結果のディスク退避](/course-assets/s08_context_compact/layer1-budget.svg)\n\n結果には「多い」ではなく「1 件が大きすぎる」ものもある。モデルが大きなファイルを一度に 5 つ読み、最後の user メッセージ内の `tool_result` が合計 200KB を超える。こうなると直近 3 件を残しても無駄だ。最新の 1 件だけでコンテキストを使い切れるからだ。\n\nツール結果に予算を設ける。最後の user メッセージ内のすべての `tool_result` の合計サイズを数え、200KB を超えたら大きいものから `.task_outputs/tool-results/` に退避し、コンテキストには `` マーカーと先頭 2000 文字のプレビューだけを残す。モデルはマーカーを見れば完全な内容がディスク上にあると分かり、必要なときに読み戻せる。\n\n```python\ndef tool_result_budget(messages, max_bytes=200_000):\n last = messages[-1] if messages else None\n if not last or last.get(\"role\") != \"user\" or not isinstance(last.get(\"content\"), list): return messages\n blocks = [(i, b) for i, b in enumerate(last[\"content\"])\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes: return messages\n ranked = sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True)\n for _, block in ranked:\n if total <= max_bytes: break\n block[\"content\"] = persist_large_output(block.get(\"tool_use_id\", \"unknown\"), str(block.get(\"content\", \"\")))\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return messages\n```\n\nここで大事なのは捨てることではない。内容を「アクティブなコンテキスト」から「復元可能な外部ストレージ」へ移すことだ。これで最初の 3 層がそろう:純粋なテキスト/構造の操作、API 呼び出し 0、それぞれが 1 種類の冗長さを見張る。だが共通の限界がある:会話が何の話か読めない。どの発見が重要か、どの制約を残すべきか分からない。それでもコンテキストが大きすぎるなら、モデルに出てもらうしかない。→ L4。\n\n### L4: compact_history — LLM 全量要約\n\n![LLM 全量要約](/course-assets/s08_context_compact/auto-compact.svg)\n\n3 層を走らせ切っても、token はまだ閾値を超える。この一手こそ、多くの人が直感的に思い描く「コンテキスト圧縮」だ。履歴をモデルに渡し、より短い状態に要約させる。\n\n3 ステップ。まず完全な会話を `.transcripts/`(JSONL)に書き出す。アクティブなコンテキストには要約だけが残るが、ディスクには完全な記録が残る。次に LLM に要約を生成させ、現在の目標、重要な発見、変更済みのファイル、残りの作業、ユーザーの制約を保持するよう求める。最後にこの 1 件の要約で古いメッセージをすべて置き換える。\n\n```python\ndef compact_history(messages):\n transcript_path = write_transcript(messages) # 先に完全な会話を保存\n summary = summarize_history(messages) # LLM が要約を生成\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n```\n\nこの一手はロッシー(不可逆)だ:transcript には完全な履歴があるが、モデルは今やその細部を見られず、要約だけを頼りに続ける。だから先に L1/L2/L3 を走らせる:モデルに要約させずに済むなら済ませる。いったん要約に入れば、細部は取り返しなく失われるからだ。教学版にはサーキットブレーカーも加えてある:compact が 3 回連続で失敗したら止め、API 呼び出しを無限に浪費しない。\n\n### 緊急: reactive_compact\n\n通常は、モデルを呼ぶ前にコンテキストを整えておく。だがコンテキストの増加が速すぎたり、token の見積もりがずれたりすると、API はやはり `prompt_too_long` を返しうる。\n\nこのとき reactive_compact に入る。compact_history によく似ているが、より激しい:先に transcript を保存し、前半の大部分を要約し、末尾 5 件だけを末尾コンテキストとして残す(こちらも孤立した `tool_result` を残さないようにする)。\n\n```python\ndef reactive_compact(messages):\n transcript = write_transcript(messages)\n tail_start = max(0, len(messages) - 5)\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n summary = summarize_history(messages[:tail_start])\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *messages[tail_start:]]\n```\n\nreactive は通常パスではなくフォールバックだ。デフォルトでは 1 回だけリトライし、もう一度失敗したら無限ループせず例外を投げる。完全なエラー回復ロジックは s11 に譲る。\n\n### 合わせて実行\n\nこれらを Agent Loop に戻して組み込む。各ラウンドで LLM を呼ぶ前に 3 層のローカル整理を走らせ、足りなければ要約し、呼び出しが実際にエラーになったら緊急パスに回る。\n\n```python\ndef agent_loop(messages):\n reactive_retries = 0\n while True:\n # 3 つの前処理(API 呼び出し 0)、順序:budget -> snip -> micro\n messages[:] = tool_result_budget(messages) # L3: 大きな結果を退避\n messages[:] = snip_compact(messages) # L1: 中間を切る\n messages[:] = micro_compact(messages) # L2: 古い結果をプレースホルダ化\n\n if estimate_size(messages) > CONTEXT_LIMIT: # まだ足りない -> LLM 要約(API 1 回)\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000)\n except Exception as e:\n if \"prompt_too_long\" in str(e).lower() and reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = reactive_compact(messages) # 緊急\n reactive_retries += 1\n continue\n raise\n # ... ツール実行 ...\n```\n\n**順序は変えられない。** L3(budget)は L2(micro)より前でなければならない:micro は古い大きな `tool_result` を 1 行のプレースホルダに置き換えるので、もし先に走れば budget が完全な内容を退避する機会を失う。先に budget で大きな内容を保存し、それからプレースホルダ化と切り捨てを行う。これが Claude Code のソースが `applyToolResultBudget` を最前に置く理由でもある。\n\n### compact ツール — モデルからも要求できる\n\n自動圧縮のほかに、モデル自身が整理を要求することもできる。コンテキストが長すぎる、あるいはタスクの段階が切り替わったと感じたとき、モデルは能動的に `compact` ツールを呼べる。教学版ではこのツールが `compact_history` をトリガーし、現在の turn を終え、圧縮後のコンテキストで新たな 1 ラウンドを始める。手動の `/compact` によく似ているが、違いは今回モデル自身が整理どきだと気づいた点だ。\n\n---\n\n## s07 からの変更点\n\n| コンポーネント | 変更前 (s07) | 変更後 (s08) |\n|------|-----------|-----------|\n| コンテキスト管理 | なし(無制限に膨張) | 4 層圧縮パイプライン + 緊急 |\n| 新しい関数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |\n| ツール | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) |\n| ループ | LLM 呼び出し → ツール実行 | 各ラウンド前に 3 層の前処理 + 閾値で compact_history を起動 |\n| 設計原則 | Agent を仕事できるように | Agent が長く走っても崩れないように |\n\nこの一手は「能力」を加えるというより「体力」を加えるに近い:s07 は Agent を専門的な作業に強くし、s08 は長いタスクで自分の履歴に押しつぶされないようにする。\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\n次の prompt を試してみよう:\n\n1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(複数ファイルを続けて読み、L2 が古い結果を圧縮する様子を観察)\n2. `Read every file in s08_context_compact/`(一度に大量に読み、L3 のディスク退避を観察)\n3. 会話を 20 ターン以上続け、`[auto compact]` や `[reactive compact]` が出るか観察\n\n観察ポイント:各ツール実行後、古い `tool_result` は置き換えられるか?大きな出力は退避されるか?token が閾値を超えたとき、要約は生成されるか?\n\n---\n\n## 次へ\n\n圧縮のおかげで Agent は長く走っても崩れない。だが圧縮のたびに細部がいくらか失われる:ユーザーが前に述べた好み、プロジェクトの長期的な制約、複数のタスクにまたがって重要な情報。どれも要約に完全に残る保証はない。\n\ncompact が答えるのは「今のセッションがもうすぐ満杯だ、どう走り続けるか」だ。「どの情報を長く残す価値があるか」には答えない。\n\ns09 Memory → 3 つのサブシステム:何を覚えるか選ぶ、重要な情報を抽出する、整理して定着させる。圧縮をまたぎ、セッションをまたいで。\n\n
\nClaude Code ソースコードの詳細\n\n> 以下は Claude Code ソースコード `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` の分析に基づく。\n\n### 実行順序の対応\n\n教学版は説明の便宜上 L1/L2/L3/L4 と番号を振っているが、実際の実行順序は番号と完全には一致しない:\n\n| 項目 | 教学版 | Claude Code |\n|------|--------|-------------|\n| 実行順序 | budget → snip → micro → auto | budget → snip → micro → collapse → auto(`query.ts:379-468`) |\n| snip_compact | 先頭 3 + 末尾 47 を保持 | Claude Code はメインスレッドのみ有効;実装はオープンソースリポジトリにない(`HISTORY_SNIP` feature gate)、インターフェースは確認可能:`snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`、`SnipTool` もモデルが能動的に呼び出し可能。教学版の 3/47 は簡略パラメータ |\n| micro_compact | テキストプレースホルダで置換 | 2 つのパス:time-based は直接内容をクリア、cached は API の `cache_edits` を使用(legacy パスは削除済み) |\n| micro_compact ホワイトリスト | 位置による(直近 3 件) | time-based は時間閾値でトリガー、cached はカウントでトリガー(`microCompact.ts`) |\n| tool_result_budget | 200KB 文字 | 200,000 文字(`toolLimits.ts:49`) |\n| compact_history 閾値 | 文字数で推定 | 精密な token 数:`contextWindow - maxOutputTokens - 13_000` |\n| 要約の要求 | 5 種類の情報 | 9 つのセクション + ``/`` デュアルタグ |\n| 圧縮プロンプト | シンプルなプロンプト | 先頭と末尾に二重の安全ガードでツール呼び出しを禁止 |\n| PTL retry | あり(簡略版) | `truncateHeadForPTLRetry()` がメッセージグループ単位でロールバック(`compact.ts:243-290`) |\n| 圧縮後のリカバリ | なし(教学版は要約のみ保持) | 直近のファイル、計画、agent/skill/tool などの自動再付加 |\n| サーキットブレーカー | 3 回 | 3 回(`autoCompact.ts:70`) |\n| reactive リトライ | 1 回 | Claude Code にはより精緻な段階別リトライがある |\n\n### 実行順序の詳細\n\nClaude Code ソース `query.ts` での実際の順序:\n\n1. `applyToolResultBudget`(L379):まず大きな結果を処理し、完全な内容を退避\n2. `snipCompact`(L403):中間メッセージを切り捨て\n3. `microcompact`(L414):古い結果のプレースホルダ化\n4. `contextCollapse`(L441):独立したコンテキスト管理システム(教学版にはなし)\n5. `autoCompact`(L454):LLM 全量要約\n\n教学版の budget → snip → micro の順序はこれと一致する。教学版には contextCollapse メカニズムがない。\n\n### read_file のトレードオフ\n\n教学版の `micro_compact` は、古い `tool_result` を一律にプレースホルダへ置き換える。`read_file` も例外ではない。これは通常、機能的な正しさには影響しない。後でファイル内容が必要になれば、モデルはもう一度そのファイルを読めばよい。代償は、追加のツール呼び出しが発生し得ることと、prompt cache のヒット率が下がり得ること。\n\nClaude Code は、この問題を教学版のような単純なルールでは処理していない。`Read` も microcompact 可能なツール集合に入れる一方で、別途 `readFileState` を維持している。変更されていないファイルの再読込では `FILE_UNCHANGED_STUB` を返し、compact 後には予算内で直近に読んだファイル内容を復元する(例:最大 5 ファイル、1 ファイル 5K token、合計 50K token)。これは本番実装向けのキャッシュと復元メカニズムである。教学版ではそこまで展開せず、「古い結果を圧縮し、必要なら再読込する」という単純な trade-off を残している。\n\n### 完全な定数リファレンス\n\n| 定数 | 値 | ソースファイル |\n|------|-----|--------|\n| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |\n| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |\n| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |\n| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |\n| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |\n| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |\n| 時間ベース micro_compact 間隔 | 60 分 | `timeBasedMCConfig.ts` |\n| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |\n\n### contextCollapse と sessionMemoryCompact\n\nClaude Code ソースコードには、この教学版では展開していない 2 つのメカニズムが存在する:\n\n- **contextCollapse**:独立したコンテキスト管理システム。有効時には proactive autocompact を抑制し(`autoCompact.ts:215-222`)、collapse の commit/blocking フローがコンテキスト管理を引き継ぐ。ただし manual `/compact` と reactive fallback は独立パスのままで、contextCollapse の影響を受けない。\n- **sessionMemoryCompact**:compact_history の前に、Claude Code は既存の session memory(s09 で解説)を使った軽量要約を先に試みる。LLM を呼び出さない。このメカニズムは s09 を学んだ後に振り返るとより理解しやすい。\n\n### 圧縮プロンプトの中身\n\nClaude Code の圧縮プロンプトには 2 つの厳格な要件がある:\n\n1. **ツール呼び出しの絶対禁止**:冒頭が `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.` で、末尾にも再度 REMINDER がある\n2. **先に分析してから要約**:モデルはまず `` タグで思考を整理し、その後 `` タグで正式な要約を出力する。analysis はフォーマット時に除去される\n\n### 教学版の簡略化は意図的\n\n- micro_compact でテキストプレースホルダを使用 → API 層の `cache_edits` 権限がないため\n- read_file は特別扱いしない → 教学版では必要時の再読込を受け入れ、readFileState と圧縮後復元の仕組みを導入しない\n- token を文字数で推定 → 精密な tokenizer は教学の対象外\n- 圧縮後のリカバリを省略 → 教学版は要約のみを保持し、ファイルの自動再付加を行わない\n- 2 つの補助メカニズムを展開しない → 10% の細部に属する\n\nコア設計思想は完全に保持されている。\n\n
\n\n\n" }, { "version": "s09", "locale": "en", "title": "s09: Memory — Compression Loses Details, Keep a Layer That Doesn't", - "content": "# s09: Memory — Compression Loses Details, Keep a Layer That Doesn't\n\ns01 → ... → s07 → s08 → `s09` → [s10](/en/s10) → s11 → ... → s20\n> *\"Compression loses details, keep a layer that doesn't\"* — File store + index + on-demand loading, across compactions, across sessions.\n>\n> **Harness Layer**: Memory — knowledge that survives compaction and sessions.\n\n---\n\n## The Problem\n\ns08's autoCompact preserves current goals, remaining work, and user constraints in the summary, but details get lost: \"use tabs not spaces\" might get simplified to \"user has code style preferences\". And when you start a new session, even the summary is gone.\n\nLLMs have no persistent state; all information lives in the context window. When context fills up, it gets compressed, and compression is lossy. What's needed is a storage layer that doesn't participate in compression and persists across sessions.\n\n---\n\n## The Solution\n\n![Memory Overview](/course-assets/s09_memory/memory-overview.en.svg)\n\nThe s08 compression pipeline is preserved, focusing on memory. Storage uses the filesystem: a `.memory/` directory where each memory is a `.md` file with YAML frontmatter (`name` / `description` / `type`). When files accumulate, an index is needed: `MEMORY.md` holds one link per line and gets injected into the SYSTEM.\n\nKey design: the index stays in SYSTEM prompt (cacheable by prompt cache), file content is injected on demand (matched by filename/description to the current conversation, without breaking the cache). Writing has two paths: the user explicitly says \"remember\", or extraction runs in the background after each turn. When files accumulate, periodic consolidation deduplicates.\n\nFour memory types, each answering a different question:\n\n| Type | Answers | Example |\n|------|---------|---------|\n| user | Who you are | \"Use tabs not spaces\" |\n| feedback | How to work | \"Don't mock the database\" |\n| project | What's happening | \"Auth rewrite is compliance-driven\" |\n| reference | Where to find things | \"Pipeline bugs are in Linear INGEST\" |\n\n---\n\n## How It Works\n\n![Memory Subsystems](/course-assets/s09_memory/memory-subsystems.en.svg)\n\n### Storage: Markdown Files + Index\n\nEach memory is a `.md` file with YAML frontmatter for metadata:\n\n```markdown\n---\nname: user-preference-tabs\ndescription: User prefers tabs for indentation\ntype: user\n---\n\nUser prefers using tabs, not spaces, for indentation.\n**Why:** Consistency with existing codebase conventions.\n**How to apply:** Always use tabs when writing or editing files.\n```\n\n`MEMORY.md` is the index, one link per line:\n\n```markdown\n- [user-preference-tabs](user-preference-tabs.md) — User prefers tabs for indentation\n```\n\nWriting a new memory automatically rebuilds the index:\n\n```python\ndef write_memory_file(name, mem_type, description, body):\n slug = name.lower().replace(\" \", \"-\")\n filepath = MEMORY_DIR / f\"{slug}.md\"\n filepath.write_text(\n f\"---\\nname: {name}\\ndescription: {description}\\ntype: {mem_type}\\n---\\n\\n{body}\\n\"\n )\n _rebuild_index()\n```\n\n### Loading: Two Paths\n\n**Path 1: Index in SYSTEM.** `build_system()` reads `MEMORY.md` every turn and injects the memory catalog into the SYSTEM prompt. The index in SYSTEM can be cached by prompt cache, avoiding resending it every turn.\n\n**Path 2: Relevant memories on demand.** Before each LLM call, `load_memories()` sends the recent conversation and the memory catalog (name + description) to the LLM as a lightweight side-query, selects relevant filenames, then reads and injects their contents. Capped at 5 to control cost.\n\n```python\ndef select_relevant_memories(messages, max_items=5):\n files = list_memory_files()\n if not files:\n return []\n\n # Build catalog: \"0: user-preference-tabs — User prefers tabs...\"\n catalog = \"\\n\".join(f\"{i}: {f['name']} — {f['description']}\" for i, f in enumerate(files))\n\n response = client.messages.create(model=MODEL, messages=[{\"role\": \"user\",\n \"content\": f\"Select relevant memory indices. Return JSON array.\\n\\n\"\n f\"Recent conversation:\\n{recent}\\n\\nMemory catalog:\\n{catalog}\"}],\n max_tokens=200)\n indices = json.loads(re.search(r'\\[.*?\\]', response.content[0].text).group())\n return [files[i][\"filename\"] for i in indices if 0 <= i < len(files)]\n```\n\nIf the side-query fails (API error, JSON parse failure), it falls back to keyword matching on name + description.\n\n### Writing: Extraction After Each Turn\n\nUsers don't always say \"remember this\". Preferences are usually scattered across normal dialogue: \"tabs are better than spaces\", \"let's use single quotes from now on\".\n\n`extract_memories()` runs when each turn ends, triggered when the model stops without a tool_use (indicating the conversation has reached a natural break):\n\n```python\n# In agent_loop:\nif response.stop_reason != \"tool_use\":\n extract_memories(messages) # Extract new memories from recent dialogue\n consolidate_memories() # Check if consolidation is needed\n return\n```\n\nBefore extraction, existing memories are checked to avoid duplicates. The extraction prompt asks the LLM to return a JSON array of `{name, type, description, body}`, writing files only when genuinely new information is found.\n\n```python\ndef extract_memories(messages):\n dialogue = format_recent_messages(messages[-10:])\n existing = \"\\n\".join(f\"- {m['name']}: {m['description']}\" for m in list_memory_files())\n\n prompt = (\n \"Extract user preferences, constraints, or project facts.\\n\"\n \"Return JSON array: [{name, type, description, body}].\\n\"\n \"If nothing new or already covered, return [].\\n\\n\"\n f\"Existing memories:\\n{existing}\\n\\nDialogue:\\n{dialogue[:4000]}\"\n )\n # ... parse response, write files ...\n```\n\n### Consolidation: Low-Frequency Deduplication\n\nMemory files accumulate. `consolidate_memories()` triggers when the file count reaches a threshold (default 10), asking the LLM to deduplicate, merge contradictions, and prune stale memories:\n\n```python\nCONSOLIDATE_THRESHOLD = 10\n\ndef consolidate_memories():\n files = list_memory_files()\n if len(files) < CONSOLIDATE_THRESHOLD:\n return # Too few, not worth consolidating\n # Send all memories to LLM, get back deduplicated list\n # Replace all files with consolidated results\n```\n\nCC calls this process **Dream**, with four gates in practice: time interval, scan throttle, session count, file lock. The teaching version simplifies to a file-count threshold.\n\n### What Memory Stores\n\nMemory stores information that remains useful across sessions: user preferences, recurring feedback, project background, common entry points, and investigation clues. It focuses on \"what will be useful later\" and brings that information back through an index plus on-demand loading.\n\nSession memory focuses on continuity inside one session: what context should survive after compaction. The two work together: Memory handles long-term knowledge; session memory handles the current session across compaction.\n\n---\n\n## Changes From s08\n\n| Component | Before (s08) | After (s09) |\n|-----------|-------------|-------------|\n| Memory capability | None (preferences degrade with compaction) | Storage + loading + extraction + consolidation |\n| New functions | — | write_memory_file, select_relevant_memories, load_memories, extract_memories, consolidate_memories |\n| Storage | — | .memory/MEMORY.md index + .memory/*.md files |\n| Tools | bash, read, write, edit, glob, todo_write, task, load_skill, compact (9) | bash, read_file, write_file, edit_file, glob, task (6) |\n| Loop | Only compression each turn | Memory injection + compression + post-turn extraction + periodic consolidation |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s09_memory/code.py\n```\n\nTry these prompts (enter across multiple turns, observe memory accumulation and loading):\n\n1. `I prefer using tabs for indentation, not spaces. Remember that.`\n2. `Create a Python file called test.py` (observe whether the Agent uses tabs)\n3. `What did I tell you about my preferences?` (observe whether the Agent remembers)\n4. `I also prefer single quotes over double quotes for strings.`\n\nWhat to watch for: Does `[Memory: extracted N new memories]` appear after each turn? Are `.md` files generated in `.memory/`? Is `MEMORY.md` index updated? Does the Agent automatically load previous memories in new conversations?\n\n---\n\n## What's Next\n\nMemory, compression, and tools are all in place. But the system prompt is still a hardcoded string. Adding a new tool means manually adding a description; switching projects means rewriting the whole prompt. Prompts should be assembled at runtime.\n\ns10 System Prompt → segments + runtime assembly. Different projects, different tools, different prompts.\n\n
\nDeep Dive Into CC Source Code\n\n> The following is based on analysis of CC source code under `src/` in `memdir/`, `services/`, `utils/`, `query/`. Line numbers verified against source.\n\n### Source Code Paths\n\n| File | Lines | Responsibility |\n|------|-------|---------------|\n| `memdir/memdir.ts` | 507 | Core: MEMORY.md definition (`34-38`), memory behavior instructions distinguishing memory/plan/tasks (`199-266`), `loadMemoryPrompt()` three paths (`419-490`) |\n| `memdir/findRelevantMemories.ts` | 141 | Sonnet side-query memory selection (`18-24` system prompt, `97-122` call logic) |\n| `memdir/memoryTypes.ts` | 271 | Type definitions, frontmatter fields |\n| `memdir/memoryScan.ts` | — | Scan .md files, exclude MEMORY.md, read frontmatter, max 200 files, sorted by mtime desc (`35-94`) |\n| `services/extractMemories/extractMemories.ts` | 615 | Forked agent extraction, restricted permissions, `skipTranscript: true`, `maxTurns: 5` (`371-427`) |\n| `services/autoDream/autoDream.ts` | 324 | Dream consolidation, four-layer gating (`63-66` defaults, `130-190` gating, `224-233` forked agent) |\n| `services/SessionMemory/sessionMemory.ts` | 495 | Session-level memory management |\n| `services/compact/sessionMemoryCompact.ts` | — | Session memory lightweight summary, thresholds 10K/5/40K (`56-61`) |\n| `utils/attachments.ts` | — | Injection budget: 200 lines / 4096 bytes per file, 60KB per session (`269-288`); find relevant memory by query (`2196-2241`) |\n| `query.ts` | — | Memory prefetch at start of each user turn (`301-304`), non-blocking collection (`1592-1614`) |\n| `query/stopHooks.ts` | — | Stop hook fire-and-forget triggers extraction and Dream (`141-155`) |\n\n### Memory Selection: LLM, Not Embedding\n\nCC uses **Sonnet itself to select** (`findRelevantMemories.ts`), not embedding vector similarity:\n\n1. `memoryScan.ts` scans all `.md` files in `.memory/` (excluding MEMORY.md), max 200 files, sorted by mtime descending\n2. Lists all memory files' `name` + `description` as a catalog\n3. Sends to Sonnet side-query: \"Select truly useful memories by name and description (max 5). Skip if unsure.\"\n4. Sonnet returns `{ selected_memories: [\"file1.md\", ...] }`\n5. Selected files' full contents are read (≤ 200 lines / 4096 bytes per file) and injected. Total session budget: 60KB\n\nAt the start of each user turn, `query.ts:301-304` starts memory prefetch (async); after tool execution, `1592-1614` collects completed results non-blocking.\n\n### Extraction Timing: Stop Hook, Not After autoCompact\n\nTrigger location (`stopHooks.ts:141-155`): inside `handleStopHooks()`, fire-and-forget triggers extraction and Dream. The teaching version places extraction in the `stop_reason != \"tool_use\"` branch, matching the direction.\n\nCC's extraction runs via forked agent (`extractMemories.ts:371-427`): restricted permissions, `skipTranscript: true`, `maxTurns: 5`. Also has overlap protection: if the main Agent already wrote memory files, extraction is skipped.\n\n### Memory File Format\n\nCC uses Markdown + YAML frontmatter, consistent with the teaching version. Four types: `user`, `feedback`, `project`, `reference`.\n\n`memdir.ts:34-38` defines index constraints: `MEMORY.md` max 200 lines / 25KB. `memdir.ts:199-266` builds memory behavior instructions, explicitly distinguishing memory from plan and tasks. Storage location: `~/.claude/projects//memory/`.\n\n### Dream: Four-Layer Gating\n\nNot \"triggered when idle\" or \"consolidate when count is enough\", but four gates (`autoDream.ts`, defaults `63-66`, gating logic `130-190`):\n\n1. **Time gate**: ≥ 24 hours since last consolidation\n2. **Scan throttle**: Avoid frequent filesystem scans\n3. **Session gate**: ≥ 5 session transcripts modified since last consolidation\n4. **Lock gate**: No other process currently consolidating (`.consolidate-lock` file)\n\nThe merge itself runs via forked agent (`224-233`): locate → collect recent signals → merge and write files → prune and update index. Lock file mtime serves as lastConsolidatedAt. Crash recovery: lock auto-expires after 1 hour.\n\n### User Memory vs Session Memory\n\n| | User Memory | Session Memory |\n|---|---|---|\n| Persistence | Cross-session | Single session |\n| Storage | Multiple .md files in `memory/` | `session-memory//memory.md` |\n| Loaded into | system prompt | compact summary |\n| Purpose | Cross-session knowledge accumulation | Cross-compact context continuity |\n\nsessionMemoryCompact (mentioned in s08) uses Session Memory: before autoCompact, it reads the session memory file and, if sufficient (≥ 10K tokens, ≥ 5 text messages, ≤ 40K tokens, `sessionMemoryCompact.ts:56-61`), uses it as a summary without calling the LLM.\n\n### Where the Real Implementation Is More Complex\n\n- **Feature flags**: Memory features have multiple feature gate layers\n- **Team memory**: Shared team memories, `loadMemoryPrompt()` has a dedicated path (not covered in teaching version)\n- **KAIROS**: Timing-aware memory extraction strategy, daily-log mode in `loadMemoryPrompt()`\n- **Prompt cache**: Memory injection must account for prompt cache TTL, avoiding full system prompt rewrites each turn\n- **File locks**: Concurrency control for multi-process scenarios\n- **Memory prefetch**: Async prefetch, non-blocking main flow\n\n### Teaching Version Simplifications Are Intentional\n\n- LLM side-query → LLM side-query + keyword fallback: teaching version keeps LLM selection, adds fallback path\n- Memory JSON → Markdown + frontmatter: teaching version matches CC\n- Stop hook trigger → `stop_reason != \"tool_use\"` branch: same direction\n- Four-layer gating → file-count threshold: teaching version lacks transcript system and multi-session concepts\n- Forked agent + restricted permissions → direct call: teaching version has no subprocess isolation\n\n
\n\n\n" + "content": "# s09: Memory — Compression Loses Details, Keep a Layer That Doesn't\n\ns01 → ... → s07 → s08 → `s09` → [s10](/en/s10) → s11 → ... → s20\n> *\"Compression loses details, keep a layer that doesn't\"* — File store + index + on-demand loading, across compactions, across sessions.\n>\n> **Harness Layer**: Memory — knowledge that survives compaction and sessions.\n\n---\n\n## The Problem\n\ns08's autoCompact preserves current goals, remaining work, and user constraints in the summary, but details get lost: \"use tabs not spaces\" might get simplified to \"user has code style preferences\". And when you start a new session, even the summary is gone.\n\nLLMs have no persistent state; all information lives in the context window. When context fills up, it gets compressed, and compression is lossy. A storage layer outside the context window solves this.\n\n---\n\n## The Solution\n\n![Memory Overview](/course-assets/s09_memory/memory-overview.svg)\n\nThe s08 compression pipeline is preserved, focusing on memory. Storage uses the filesystem: a `.memory/` directory where each memory is a `.md` file with YAML frontmatter (`name` / `description` / `type`). When files accumulate, an index is needed: `MEMORY.md` holds one link per line and gets injected into the SYSTEM.\n\nKey design: the index stays in SYSTEM prompt (cacheable by prompt cache), file content is injected on demand (matched by filename/description to the current conversation, without breaking the cache). Writing has two paths: the user explicitly says \"remember\", or extraction runs in the background after each turn. When files accumulate, periodic consolidation deduplicates.\n\nFour memory types, each answering a different question:\n\n| Type | Answers | Example |\n|------|---------|---------|\n| user | Who you are | \"Use tabs not spaces\" |\n| feedback | How to work | \"Don't mock the database\" |\n| project | What's happening | \"Auth rewrite is compliance-driven\" |\n| reference | Where to find things | \"Pipeline bugs are in Linear INGEST\" |\n\n---\n\n## How It Works\n\n![Memory Subsystems](/course-assets/s09_memory/memory-subsystems.svg)\n\n### Storage: Markdown Files + Index\n\nEach memory is a `.md` file with YAML frontmatter for metadata:\n\n```markdown\n---\nname: user-preference-tabs\ndescription: User prefers tabs for indentation\ntype: user\n---\n\nUser prefers using tabs, not spaces, for indentation.\n**Why:** Consistency with existing codebase conventions.\n**How to apply:** Always use tabs when writing or editing files.\n```\n\n`MEMORY.md` is the index, one link per line:\n\n```markdown\n- [user-preference-tabs](user-preference-tabs.md) — User prefers tabs for indentation\n```\n\nWriting a new memory automatically rebuilds the index:\n\n```python\ndef write_memory_file(name, mem_type, description, body):\n slug = name.lower().replace(\" \", \"-\")\n filepath = MEMORY_DIR / f\"{slug}.md\"\n filepath.write_text(\n f\"---\\nname: {name}\\ndescription: {description}\\ntype: {mem_type}\\n---\\n\\n{body}\\n\"\n )\n _rebuild_index()\n```\n\n### Loading: Two Paths\n\n**Path 1: Index in SYSTEM.** `build_system()` reads `MEMORY.md` once at the start of each user request and injects the memory catalog into the SYSTEM prompt. Memory extraction and consolidation run only when the turn ends, so SYSTEM does not need to be rebuilt repeatedly within the same user request.\n\n**Path 2: Relevant memories on demand.** At the start of each user request, `load_memories()` sends the recent conversation and the memory catalog (name + description) to the LLM as a lightweight side-query, selects relevant filenames, then reads and injects their contents. Capped at 5 to control cost.\n\n```python\ndef select_relevant_memories(messages, max_items=5):\n files = list_memory_files()\n if not files:\n return []\n\n # Build catalog: \"0: user-preference-tabs — User prefers tabs...\"\n catalog = \"\\n\".join(f\"{i}: {f['name']} — {f['description']}\" for i, f in enumerate(files))\n\n response = client.messages.create(model=MODEL, messages=[{\"role\": \"user\",\n \"content\": f\"Select relevant memory indices. Return JSON array.\\n\\n\"\n f\"Recent conversation:\\n{recent}\\n\\nMemory catalog:\\n{catalog}\"}],\n max_tokens=200)\n indices = json.loads(re.search(r'\\[.*?\\]', response.content[0].text).group())\n return [files[i][\"filename\"] for i in indices if 0 <= i < len(files)]\n```\n\nIf the side-query fails (API error, JSON parse failure), it falls back to keyword matching on name + description.\n\n### Writing: Extraction After Each Turn\n\nUsers don't always say \"remember this\". Preferences are usually scattered across normal dialogue: \"tabs are better than spaces\", \"let's use single quotes from now on\".\n\n`extract_memories()` runs when each turn ends, triggered when the model stops without a tool_use (indicating the conversation has reached a natural break):\n\n```python\n# In agent_loop:\nif response.stop_reason != \"tool_use\":\n extract_memories(messages) # Extract new memories from recent dialogue\n consolidate_memories() # Check if consolidation is needed\n return\n```\n\nBefore extraction, existing memories are checked to avoid duplicates. The extraction prompt asks the LLM to return a JSON array of `{name, type, description, body}`, writing files only when genuinely new information is found.\n\n```python\ndef extract_memories(messages):\n dialogue = format_recent_messages(messages[-10:])\n existing = \"\\n\".join(f\"- {m['name']}: {m['description']}\" for m in list_memory_files())\n\n prompt = (\n \"Extract user preferences, constraints, or project facts.\\n\"\n \"Return JSON array: [{name, type, description, body}].\\n\"\n \"If nothing new or already covered, return [].\\n\\n\"\n f\"Existing memories:\\n{existing}\\n\\nDialogue:\\n{dialogue[:4000]}\"\n )\n # ... parse response, write files ...\n```\n\n### Consolidation: Low-Frequency Deduplication\n\nMemory files accumulate. `consolidate_memories()` triggers when the file count reaches a threshold (default 10), asking the LLM to deduplicate, merge contradictions, and prune stale memories:\n\n```python\nCONSOLIDATE_THRESHOLD = 10\n\ndef consolidate_memories():\n files = list_memory_files()\n if len(files) < CONSOLIDATE_THRESHOLD:\n return # Too few, not worth consolidating\n # Send all memories to LLM, get back deduplicated list\n # Replace all files with consolidated results\n```\n\nClaude Code calls this process **Dream**, with four gates in practice: time interval, scan throttle, session count, file lock. The teaching version simplifies to a file-count threshold.\n\n### What Memory Stores\n\nMemory stores user preferences, recurring feedback, project background, common entry points, and investigation clues. It brings them back through an index plus on-demand loading.\n\nSession memory focuses on continuity inside one session: what context should survive after compaction. The two work together: Memory handles long-term knowledge; session memory handles the current session across compaction.\n\n---\n\n## Changes From s08\n\n| Component | Before (s08) | After (s09) |\n|-----------|-------------|-------------|\n| Memory capability | None (preferences degrade with compaction) | Storage + loading + extraction + consolidation |\n| New functions | — | write_memory_file, select_relevant_memories, load_memories, extract_memories, consolidate_memories |\n| Storage | — | .memory/MEMORY.md index + .memory/*.md files |\n| Tools | bash, read, write, edit, glob, todo_write, task, load_skill, compact (9) | bash, read_file, write_file, edit_file, glob, task (6) |\n| Loop | Only compression each turn | Memory injection + compression + post-turn extraction + periodic consolidation |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s09_memory/code.py\n```\n\nTry these prompts (enter across multiple turns, observe memory accumulation and loading):\n\n1. `I prefer using tabs for indentation, not spaces. Remember that.`\n2. `Create a Python file called test.py` (observe whether the Agent uses tabs)\n3. `What did I tell you about my preferences?` (observe whether the Agent remembers)\n4. `I also prefer single quotes over double quotes for strings.`\n\nWhat to watch for: Does `[Memory: extracted N new memories]` appear after each turn? Are `.md` files generated in `.memory/`? Is `MEMORY.md` index updated? Does the Agent automatically load previous memories in new conversations?\n\n---\n\n## What's Next\n\nMemory, compression, and tools are all in place. But the system prompt is still a hardcoded string. Adding a new tool means manually adding a description; switching projects means rewriting the whole prompt. Prompts should be assembled at runtime.\n\ns10 System Prompt → segments + runtime assembly. Different projects, different tools, different prompts.\n\n
\nDeep Dive Into Claude Code Source Code\n\n> The following is based on analysis of Claude Code source code under `src/` in `memdir/`, `services/`, `utils/`, `query/`. Line numbers verified against source.\n\n### Source Code Paths\n\n| File | Lines | Responsibility |\n|------|-------|---------------|\n| `memdir/memdir.ts` | 507 | Core: MEMORY.md definition (`34-38`), memory behavior instructions distinguishing memory/plan/tasks (`199-266`), `loadMemoryPrompt()` three paths (`419-490`) |\n| `memdir/findRelevantMemories.ts` | 141 | Sonnet side-query memory selection (`18-24` system prompt, `97-122` call logic) |\n| `memdir/memoryTypes.ts` | 271 | Type definitions, frontmatter fields |\n| `memdir/memoryScan.ts` | — | Scan .md files, exclude MEMORY.md, read frontmatter, max 200 files, sorted by mtime desc (`35-94`) |\n| `services/extractMemories/extractMemories.ts` | 615 | Forked agent extraction, restricted permissions, `skipTranscript: true`, `maxTurns: 5` (`371-427`) |\n| `services/autoDream/autoDream.ts` | 324 | Dream consolidation, four-layer gating (`63-66` defaults, `130-190` gating, `224-233` forked agent) |\n| `services/SessionMemory/sessionMemory.ts` | 495 | Session-level memory management |\n| `services/compact/sessionMemoryCompact.ts` | — | Session memory lightweight summary, thresholds 10K/5/40K (`56-61`) |\n| `utils/attachments.ts` | — | Injection budget: 200 lines / 4096 bytes per file, 60KB per session (`269-288`); find relevant memory by query (`2196-2241`) |\n| `query.ts` | — | Memory prefetch at start of each user turn (`301-304`), non-blocking collection (`1592-1614`) |\n| `query/stopHooks.ts` | — | Stop hook fire-and-forget triggers extraction and Dream (`141-155`) |\n\n### Memory Selection: LLM, Not Embedding\n\nClaude Code uses **Sonnet itself to select** (`findRelevantMemories.ts`), not embedding vector similarity:\n\n1. `memoryScan.ts` scans all `.md` files in `.memory/` (excluding MEMORY.md), max 200 files, sorted by mtime descending\n2. Lists all memory files' `name` + `description` as a catalog\n3. Sends to Sonnet side-query: \"Select truly useful memories by name and description (max 5). Skip if unsure.\"\n4. Sonnet returns `{ selected_memories: [\"file1.md\", ...] }`\n5. Selected files' full contents are read (≤ 200 lines / 4096 bytes per file) and injected. Total session budget: 60KB\n\nAt the start of each user turn, `query.ts:301-304` starts memory prefetch (async); after tool execution, `1592-1614` collects completed results non-blocking.\n\n### Extraction Timing: Stop Hook, Not After autoCompact\n\nTrigger location (`stopHooks.ts:141-155`): inside `handleStopHooks()`, fire-and-forget triggers extraction and Dream. The teaching version places extraction in the `stop_reason != \"tool_use\"` branch, matching the direction.\n\nClaude Code's extraction runs via forked agent (`extractMemories.ts:371-427`): restricted permissions, `skipTranscript: true`, `maxTurns: 5`. Also has overlap protection: if the main Agent already wrote memory files, extraction is skipped.\n\n### Memory File Format\n\nClaude Code uses Markdown + YAML frontmatter, consistent with the teaching version. Four types: `user`, `feedback`, `project`, `reference`.\n\n`memdir.ts:34-38` defines index constraints: `MEMORY.md` max 200 lines / 25KB. `memdir.ts:199-266` builds memory behavior instructions, explicitly distinguishing memory from plan and tasks. Storage location: `~/.claude/projects//memory/`.\n\n### Dream: Four-Layer Gating\n\nNot \"triggered when idle\" or \"consolidate when count is enough\", but four gates (`autoDream.ts`, defaults `63-66`, gating logic `130-190`):\n\n1. **Time gate**: ≥ 24 hours since last consolidation\n2. **Scan throttle**: Avoid frequent filesystem scans\n3. **Session gate**: ≥ 5 session transcripts modified since last consolidation\n4. **Lock gate**: No other process currently consolidating (`.consolidate-lock` file)\n\nThe merge itself runs via forked agent (`224-233`): locate → collect recent signals → merge and write files → prune and update index. Lock file mtime serves as lastConsolidatedAt. Crash recovery: lock auto-expires after 1 hour.\n\n### User Memory vs Session Memory\n\n| | User Memory | Session Memory |\n|---|---|---|\n| Persistence | Cross-session | Single session |\n| Storage | Multiple .md files in `memory/` | `session-memory//memory.md` |\n| Loaded into | system prompt | compact summary |\n| Purpose | Cross-session knowledge accumulation | Cross-compact context continuity |\n\nsessionMemoryCompact (mentioned in s08) uses Session Memory: before autoCompact, it reads the session memory file and, if sufficient (≥ 10K tokens, ≥ 5 text messages, ≤ 40K tokens, `sessionMemoryCompact.ts:56-61`), uses it as a summary without calling the LLM.\n\n### Where the Real Implementation Is More Complex\n\n- **Feature flags**: Memory features have multiple feature gate layers\n- **Team memory**: Shared team memories, `loadMemoryPrompt()` has a dedicated path (not covered in teaching version)\n- **KAIROS**: Timing-aware memory extraction strategy, daily-log mode in `loadMemoryPrompt()`\n- **Prompt cache**: Memory injection must account for prompt cache TTL, avoiding full system prompt rewrites each turn\n- **File locks**: Concurrency control for multi-process scenarios\n- **Memory prefetch**: Async prefetch, non-blocking main flow\n\n### Teaching Version Simplifications Are Intentional\n\n- LLM side-query → LLM side-query + keyword fallback: teaching version keeps LLM selection, adds fallback path\n- Memory JSON → Markdown + frontmatter: teaching version matches Claude Code\n- Stop hook trigger → `stop_reason != \"tool_use\"` branch: same direction\n- Four-layer gating → file-count threshold: teaching version lacks transcript system and multi-session concepts\n- Forked agent + restricted permissions → direct call: teaching version has no subprocess isolation\n\n
\n\n\n" }, { "version": "s09", "locale": "zh", - "title": "s09: Memory — 压缩会丢细节,要有一层不丢的", - "content": "# s09: Memory — 压缩会丢细节,要有一层不丢的\n\ns01 → ... → s07 → s08 → `s09` → [s10](/zh/s10) → s11 → ... → s20\n> *\"压缩会丢细节, 要有一层不丢的\"* — 文件仓库 + 索引 + 按需加载,跨压缩、跨会话。\n>\n> **Harness 层**: 记忆 — 跨压缩、跨会话的知识积累。\n\n---\n\n## 问题\n\ns08 的 autoCompact 会把当前目标、剩余工作、用户约束写进摘要,但细节会丢失:\"用 tab 缩进不要用空格\"可能被简化成\"用户有代码风格偏好\"。而且新开一个会话,连摘要也没了。\n\nLLM 没有持久状态,所有信息都在上下文窗口里。上下文满了要压缩,压缩就有损。需要一层不参与压缩、跨会话保留的存储。\n\n---\n\n## 解决方案\n\n![Memory Overview](/course-assets/s09_memory/memory-overview.svg)\n\ns08 的压缩管线保留,聚焦记忆。存储选文件系统:`.memory/` 目录下,每个记忆一个 `.md` 文件,带 YAML frontmatter(`name` / `description` / `type`)。文件多了需要索引:`MEMORY.md` 一行一个链接,注入 SYSTEM。\n\n关键设计:索引常驻 SYSTEM prompt(可被 prompt cache 缓存),文件内容按需注入到当前 user turn(按 filename/description 匹配当前对话,不破坏 cache)。写入由每轮结束后的提取器完成:用户显式说\"记住\"或表达稳定偏好时,提取器会保存为记忆。文件积累多了,定期整理去重。\n\n四类记忆,各有用途:\n\n| 类型 | 回答什么 | 示例 |\n|------|---------|------|\n| user | 你是谁 | \"用 tab 不用空格\" |\n| feedback | 怎么做事 | \"别 mock 数据库\" |\n| project | 正在发生什么 | \"auth 重写是合规驱动\" |\n| reference | 东西在哪找 | \"pipeline bug 在 Linear INGEST\" |\n\n---\n\n## 工作原理\n\n![Memory Subsystems](/course-assets/s09_memory/memory-subsystems.svg)\n\n### 存储:Markdown 文件 + 索引\n\n每个记忆是一个 `.md` 文件,YAML frontmatter 记录元数据:\n\n```markdown\n---\nname: user-preference-tabs\ndescription: User prefers tabs for indentation\ntype: user\n---\n\nUser prefers using tabs, not spaces, for indentation.\n**Why:** Consistency with existing codebase conventions.\n**How to apply:** Always use tabs when writing or editing files.\n```\n\n`MEMORY.md` 是索引,一行一个链接:\n\n```markdown\n- [user-preference-tabs](user-preference-tabs.md) — User prefers tabs for indentation\n```\n\n写入新记忆时自动重建索引:\n\n```python\ndef write_memory_file(name, mem_type, description, body):\n slug = name.lower().replace(\" \", \"-\")\n filepath = MEMORY_DIR / f\"{slug}.md\"\n filepath.write_text(\n f\"---\\nname: {name}\\ndescription: {description}\\ntype: {mem_type}\\n---\\n\\n{body}\\n\"\n )\n _rebuild_index()\n```\n\n### 加载:两条路径\n\n**路径一:索引常驻 SYSTEM。** `build_system()` 每轮重建 SYSTEM 时读取 `MEMORY.md`,把记忆清单注入。SYSTEM prompt 中的索引可以被 prompt cache 缓存,不需要每轮重新发送。\n\n**路径二:相关记忆按需注入。** 每轮调用前,`load_memories()` 把最近对话和记忆目录(name + description)一起发给 LLM 做一次轻量 side-query,选出相关的文件名,再读文件内容临时注入到当前 user turn。最多 5 条,控制开销。\n\n```python\ndef select_relevant_memories(messages, max_items=5):\n files = list_memory_files()\n if not files:\n return []\n\n # Build catalog: \"0: user-preference-tabs — User prefers tabs...\"\n catalog = \"\\n\".join(f\"{i}: {f['name']} — {f['description']}\" for i, f in enumerate(files))\n\n response = client.messages.create(model=MODEL, messages=[{\"role\": \"user\",\n \"content\": f\"Select relevant memory indices. Return JSON array.\\n\\n\"\n f\"Recent conversation:\\n{recent}\\n\\nMemory catalog:\\n{catalog}\"}],\n max_tokens=200)\n text = extract_text(response.content).strip()\n indices = json.loads(re.search(r'\\[.*?\\]', text).group())\n return [files[i][\"filename\"] for i in indices if 0 <= i < len(files)]\n```\n\n如果 side-query 失败(API 错误、JSON 解析失败),降级到关键词匹配 name + description。\n\n### 写入:每轮结束后提取\n\n用户不会每次都说\"记住这个\"。偏好通常散落在正常对话中:\"用 tab 比空格好\"、\"以后都用单引号\"。\n\n`extract_memories()` 在每轮结束时运行,条件是模型停止且没有 tool_use(说明对话告一段落):\n\n```python\n# In agent_loop:\nif response.stop_reason != \"tool_use\":\n extract_memories(pre_compress) # 从压缩前快照提取新记忆\n consolidate_memories() # 检查是否需要整理\n return\n```\n\n提取前先检查已有记忆,避免重复。提取 prompt 要求 LLM 返回 `{name, type, description, body}` 的 JSON 数组,只有确实有新信息时才写文件。\n\n```python\ndef extract_memories(messages):\n dialogue = format_recent_messages(messages[-10:])\n existing = \"\\n\".join(f\"- {m['name']}: {m['description']}\" for m in list_memory_files())\n\n prompt = (\n \"Extract user preferences, constraints, or project facts.\\n\"\n \"Return JSON array: [{name, type, description, body}].\\n\"\n \"If nothing new or already covered, return [].\\n\\n\"\n f\"Existing memories:\\n{existing}\\n\\nDialogue:\\n{dialogue[:4000]}\"\n )\n # ... parse response, write files ...\n```\n\n### 整理:低频合并去重\n\n记忆文件会积累。`consolidate_memories()` 在文件数达到阈值(默认 10)时触发,让 LLM 去重、合并矛盾、淘汰过时记忆:\n\n```python\nCONSOLIDATE_THRESHOLD = 10\n\ndef consolidate_memories():\n files = list_memory_files()\n if len(files) < CONSOLIDATE_THRESHOLD:\n return # 太少,不值得整理\n # Send all memories to LLM, get back deduplicated list\n # Replace all files with consolidated results\n```\n\nCC 把这个过程叫 Dream,实际有四层门控:时间间隔、扫描节流、会话数、文件锁。教学版简化为文件数阈值。\n\n### Memory 适合保存什么\n\nMemory 保存跨会话仍然有用的信息:用户偏好、反复出现的反馈、项目背景、常用入口和排查线索。它关注“以后还会用到什么”,并通过索引 + 按需加载把这些信息带回当前对话。\n\nsession memory 关注同一会话内的连续性:compact 之后,当前会话还需要保留哪些上下文。两者配合使用:Memory 管长期知识,session memory 管当前会话的压缩续接。\n\n---\n\n## 相对 s08 的变更\n\n| 组件 | 之前 (s08) | 之后 (s09) |\n|------|-----------|-----------|\n| 记忆能力 | 无(压缩后偏好随摘要退化) | 存储 + 加载 + 提取 + 整理 |\n| 新函数 | — | write_memory_file, select_relevant_memories, load_memories, extract_memories, consolidate_memories |\n| 存储 | — | .memory/MEMORY.md 索引 + .memory/*.md 文件 |\n| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill, compact (9) | bash, read_file, write_file, edit_file, glob, task (6) |\n| 循环 | 每轮只做压缩 | 每轮注入记忆 + 压缩 + 每轮结束后提取 + 定期整理 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s09_memory/code.py\n```\n\n试试这些 prompt(分多轮输入,观察记忆的累积和加载):\n\n1. `I prefer using tabs for indentation, not spaces. Remember that.`\n2. `Create a Python file called test.py`(观察 Agent 是否用了 tab)\n3. `What did I tell you about my preferences?`(观察 Agent 是否记得)\n4. `I also prefer single quotes over double quotes for strings.`\n\n观察重点:每轮结束后是否出现 `[Memory: extracted N new memories]`?`.memory/` 目录下是否生成了 `.md` 文件?`MEMORY.md` 索引是否更新?新一轮对话时 Agent 是否自动加载了之前的记忆?\n\n---\n\n## 接下来\n\n记忆、压缩、工具都已就绪。但 system prompt 还是硬编码的一大段字符串。加了新工具要手动加描述,换了项目要重写整个 prompt。prompt 应该运行时组装。\n\ns10 System Prompt → 分段 + 运行时组装。不同项目、不同工具,拼出不同的 prompt。\n\n
\n深入 CC 源码\n\n> 以下基于 CC 源码 `src/` 下 `memdir/`、`services/`、`utils/`、`query/` 的分析,行号已对照核实。\n\n### 源码路径\n\n| 文件 | 行数 | 职责 |\n|------|------|------|\n| `memdir/memdir.ts` | 507 | 核心:MEMORY.md 定义(`34-38`)、记忆行为指令区分 memory/plan/tasks(`199-266`)、`loadMemoryPrompt()` 三条路径(`419-490`) |\n| `memdir/findRelevantMemories.ts` | 141 | Sonnet side-query 选记忆(`18-24` 系统提示、`97-122` 调用逻辑) |\n| `memdir/memoryTypes.ts` | 271 | 类型定义,frontmatter 字段 |\n| `memdir/memoryScan.ts` | — | 扫描 .md 文件,排除 MEMORY.md,读 frontmatter,最多 200 个,按 mtime 降序(`35-94`) |\n| `services/extractMemories/extractMemories.ts` | 615 | forked agent 提取记忆,受限权限,`skipTranscript: true`,`maxTurns: 5`(`371-427`) |\n| `services/autoDream/autoDream.ts` | 324 | Dream 整理,四层门控(`63-66` 默认值、`130-190` 门控、`224-233` forked agent) |\n| `services/SessionMemory/sessionMemory.ts` | 495 | 会话级记忆管理 |\n| `services/compact/sessionMemoryCompact.ts` | — | session memory 轻量摘要,阈值 10K/5/40K(`56-61`) |\n| `utils/attachments.ts` | — | 注入预算:200 行 / 4096 字节每文件,60KB 每 session(`269-288`);按 query 找相关 memory(`2196-2241`) |\n| `query.ts` | — | memory prefetch 每轮启动(`301-304`),非阻塞收集(`1592-1614`) |\n| `query/stopHooks.ts` | — | stop hook fire-and-forget 触发提取和 Dream(`141-155`) |\n\n### 记忆选择:LLM 选,不是 embedding\n\nCC 用 **Sonnet 本身来选**(`findRelevantMemories.ts`),不是 embedding 向量相似度:\n\n1. `memoryScan.ts` 扫描 `.memory/` 下所有 `.md` 文件(排除 MEMORY.md),最多 200 个,按 mtime 降序\n2. 把 `name` + `description` 列成清单\n3. 发给 Sonnet side-query:\"根据名称和描述选出真正有用的记忆(最多 5 个)。不确定就不要选。\"\n4. Sonnet 返回 `{ selected_memories: [\"file1.md\", ...] }`\n5. 选中文件读取完整内容(每文件 ≤ 200 行 / 4096 字节),注入上下文。单 session 总预算 60KB\n\n每轮用户 turn 开始时,`query.ts:301-304` 启动 memory prefetch(异步);工具执行后 `1592-1614` 非阻塞收集结果,不卡主流程。\n\n### 提取时机:stop hook,不是 autoCompact 后\n\n触发位置(`stopHooks.ts:141-155`):在 `handleStopHooks()` 中,fire-and-forget 触发提取和 Dream。教学版把提取放在 `stop_reason != \"tool_use\"` 分支里,方向一致。\n\nCC 的提取通过 forked agent 执行(`extractMemories.ts:371-427`):受限权限、`skipTranscript: true`、`maxTurns: 5`。还有重叠保护:如果主 Agent 已经写入了记忆文件,跳过提取。\n\n### 记忆文件格式\n\nCC 用 Markdown + YAML frontmatter,和教学版一致。四种类型:`user`、`feedback`、`project`、`reference`。\n\n`memdir.ts:34-38` 定义索引约束:`MEMORY.md` 最多 200 行 / 25KB。`memdir.ts:199-266` 构建记忆行为指令,明确区分 memory、plan、tasks。存储位置:`~/.claude/projects//memory/`。\n\n### Dream:四层门控\n\n不是\"空闲时触发\"或\"数量够了就合并\",而是四层门控(`autoDream.ts`,默认值 `63-66`,门控逻辑 `130-190`):\n\n1. **时间门控**:距上次合并 ≥ 24 小时\n2. **扫描节流**:避免频繁扫描文件系统\n3. **会话门控**:自上次合并以来修改了 ≥ 5 个会话 transcript\n4. **锁门控**:没有其他进程正在合并(`.consolidate-lock` 文件)\n\n合并本身通过 forked agent 执行(`224-233`):定位 → 收集近期信号 → 合并写文件 → 剪枝更新索引。锁文件 mtime 就是 lastConsolidatedAt。崩溃恢复:1 小时后锁自动过期。\n\n### User Memory vs Session Memory\n\n| | User Memory | Session Memory |\n|---|---|---|\n| 持久性 | 跨会话 | 单会话 |\n| 存储 | `memory/` 下多个 .md 文件 | `session-memory//memory.md` |\n| 加载到 | system prompt | compact 摘要 |\n| 用途 | 跨会话的知识积累 | 跨 compact 的上下文连续性 |\n\nsessionMemoryCompact(s08 中提到的机制)正是使用了 Session Memory:autoCompact 前先读 session memory 文件,如果内容足够(≥ 10K token、≥ 5 条文本消息、≤ 40K token,`sessionMemoryCompact.ts:56-61`),就用它做摘要,不调 LLM。\n\n### 真实实现比教学版复杂的地方\n\n- **Feature flags**:记忆相关功能有多层 feature gate 控制\n- **Team memory**:团队共享记忆,`loadMemoryPrompt()` 有专门路径(教学版未涉及)\n- **KAIROS**:时机感知的记忆提取策略,`loadMemoryPrompt()` 中 daily-log 模式\n- **Prompt cache**:记忆注入需要考虑 prompt cache 的 TTL,避免每次都重写 system prompt 的大段内容\n- **文件锁**:多进程并发时的锁机制\n- **Memory prefetch**:异步预取,不阻塞主流程\n\n### 教学版的简化是刻意的\n\n- LLM side-query → LLM side-query + 关键词降级:教学版保留了 LLM 选择,加了降级路径\n- 记忆 JSON → Markdown + frontmatter:教学版与 CC 一致\n- stop hook 触发 → `stop_reason != \"tool_use\"` 分支:方向一致\n- 四层门控 → 文件数阈值:教学版没有 transcript 系统和多会话概念\n- forked agent + 受限权限 → 直接调用:教学版没有子进程隔离\n\n
\n\n\n" + "title": "s09: Memory — 压缩会丢细节,重要的得记在上下文之外", + "content": "# s09: Memory — 压缩会丢细节,重要的得记在上下文之外\n\ns01 → ... → s07 → s08 → `s09` → [s10](/zh/s10) → s11 → ... → s20\n> *\"压缩会丢细节, 重要的得记在上下文之外\"* — 文件仓库 + 索引 + 按需加载,跨压缩、跨会话。\n>\n> **Harness 层**: 记忆 — 跨压缩、跨会话的知识积累。\n\n---\n\n## 问题\n\ns08 的 autoCompact 会把当前目标、剩余工作、用户约束写进摘要,但细节会丢失:\"用 tab 缩进不要用空格\"可能被简化成\"用户有代码风格偏好\"。而且新开一个会话,连摘要也没了。\n\nLLM 没有持久状态,所有信息都在上下文窗口里。上下文满了要压缩,压缩就有损。需要一层独立于上下文窗口的存储。\n\n---\n\n## 解决方案\n\n![Memory Overview](/course-assets/s09_memory/memory-overview.svg)\n\ns08 的压缩管线保留,聚焦记忆。存储选文件系统:`.memory/` 目录下,每个记忆一个 `.md` 文件,带 YAML frontmatter(`name` / `description` / `type`)。文件多了需要索引:`MEMORY.md` 一行一个链接,注入 SYSTEM。\n\n关键设计:索引常驻 SYSTEM prompt(可被 prompt cache 缓存),文件内容按需注入到当前 user turn(按 filename/description 匹配当前对话,不破坏 cache)。写入由每轮结束后的提取器完成:用户显式说\"记住\"或表达稳定偏好时,提取器会保存为记忆。文件积累多了,定期整理去重。\n\n四类记忆,各有用途:\n\n| 类型 | 回答什么 | 示例 |\n|------|---------|------|\n| user | 你是谁 | \"用 tab 不用空格\" |\n| feedback | 怎么做事 | \"别 mock 数据库\" |\n| project | 正在发生什么 | \"auth 重写是合规驱动\" |\n| reference | 东西在哪找 | \"pipeline bug 在 Linear INGEST\" |\n\n---\n\n## 工作原理\n\n![Memory Subsystems](/course-assets/s09_memory/memory-subsystems.svg)\n\n### 存储:Markdown 文件 + 索引\n\n每个记忆是一个 `.md` 文件,YAML frontmatter 记录元数据:\n\n```markdown\n---\nname: user-preference-tabs\ndescription: User prefers tabs for indentation\ntype: user\n---\n\nUser prefers using tabs, not spaces, for indentation.\n**Why:** Consistency with existing codebase conventions.\n**How to apply:** Always use tabs when writing or editing files.\n```\n\n`MEMORY.md` 是索引,一行一个链接:\n\n```markdown\n- [user-preference-tabs](user-preference-tabs.md) — User prefers tabs for indentation\n```\n\n写入新记忆时自动重建索引:\n\n```python\ndef write_memory_file(name, mem_type, description, body):\n slug = name.lower().replace(\" \", \"-\")\n filepath = MEMORY_DIR / f\"{slug}.md\"\n filepath.write_text(\n f\"---\\nname: {name}\\ndescription: {description}\\ntype: {mem_type}\\n---\\n\\n{body}\\n\"\n )\n _rebuild_index()\n```\n\n### 加载:两条路径\n\n**路径一:索引常驻 SYSTEM。** `build_system()` 在每次用户请求开始时读取 `MEMORY.md`,把记忆清单注入。记忆提取和整理只在本轮结束时触发,因此同一轮用户请求中不需要重复重建 SYSTEM。\n\n**路径二:相关记忆按需注入。** 每次用户请求开始时,`load_memories()` 把最近对话和记忆目录(name + description)一起发给 LLM 做一次轻量 side-query,选出相关的文件名,再读文件内容临时注入到当前 user turn。最多 5 条,控制开销。\n\n```python\ndef select_relevant_memories(messages, max_items=5):\n files = list_memory_files()\n if not files:\n return []\n\n # Build catalog: \"0: user-preference-tabs — User prefers tabs...\"\n catalog = \"\\n\".join(f\"{i}: {f['name']} — {f['description']}\" for i, f in enumerate(files))\n\n response = client.messages.create(model=MODEL, messages=[{\"role\": \"user\",\n \"content\": f\"Select relevant memory indices. Return JSON array.\\n\\n\"\n f\"Recent conversation:\\n{recent}\\n\\nMemory catalog:\\n{catalog}\"}],\n max_tokens=200)\n text = extract_text(response.content).strip()\n indices = json.loads(re.search(r'\\[.*?\\]', text).group())\n return [files[i][\"filename\"] for i in indices if 0 <= i < len(files)]\n```\n\n如果 side-query 失败(API 错误、JSON 解析失败),降级到关键词匹配 name + description。\n\n### 写入:每轮结束后提取\n\n用户不会每次都说\"记住这个\"。偏好通常散落在正常对话中:\"用 tab 比空格好\"、\"以后都用单引号\"。\n\n`extract_memories()` 在每轮结束时运行,条件是模型停止且没有 tool_use(说明对话告一段落):\n\n```python\n# In agent_loop:\nif response.stop_reason != \"tool_use\":\n extract_memories(pre_compress) # 从压缩前快照提取新记忆\n consolidate_memories() # 检查是否需要整理\n return\n```\n\n提取前先检查已有记忆,避免重复。提取 prompt 要求 LLM 返回 `{name, type, description, body}` 的 JSON 数组,只有确实有新信息时才写文件。\n\n```python\ndef extract_memories(messages):\n dialogue = format_recent_messages(messages[-10:])\n existing = \"\\n\".join(f\"- {m['name']}: {m['description']}\" for m in list_memory_files())\n\n prompt = (\n \"Extract user preferences, constraints, or project facts.\\n\"\n \"Return JSON array: [{name, type, description, body}].\\n\"\n \"If nothing new or already covered, return [].\\n\\n\"\n f\"Existing memories:\\n{existing}\\n\\nDialogue:\\n{dialogue[:4000]}\"\n )\n # ... parse response, write files ...\n```\n\n### 整理:低频合并去重\n\n记忆文件会积累。`consolidate_memories()` 在文件数达到阈值(默认 10)时触发,让 LLM 去重、合并矛盾、淘汰过时记忆:\n\n```python\nCONSOLIDATE_THRESHOLD = 10\n\ndef consolidate_memories():\n files = list_memory_files()\n if len(files) < CONSOLIDATE_THRESHOLD:\n return # 太少,不值得整理\n # Send all memories to LLM, get back deduplicated list\n # Replace all files with consolidated results\n```\n\nClaude Code 把这个过程叫 Dream,实际有四层门控:时间间隔、扫描节流、会话数、文件锁。教学版简化为文件数阈值。\n\n### Memory 适合保存什么\n\nMemory 保存用户偏好、反复出现的反馈、项目背景、常用入口和排查线索,通过索引 + 按需加载把这些信息带回当前对话。\n\nsession memory 关注同一会话内的连续性:compact 之后,当前会话还需要保留哪些上下文。两者配合使用:Memory 管长期知识,session memory 管当前会话的压缩续接。\n\n---\n\n## 相对 s08 的变更\n\n| 组件 | 之前 (s08) | 之后 (s09) |\n|------|-----------|-----------|\n| 记忆能力 | 无(压缩后偏好随摘要退化) | 存储 + 加载 + 提取 + 整理 |\n| 新函数 | — | write_memory_file, select_relevant_memories, load_memories, extract_memories, consolidate_memories |\n| 存储 | — | .memory/MEMORY.md 索引 + .memory/*.md 文件 |\n| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill, compact (9) | bash, read_file, write_file, edit_file, glob, task (6) |\n| 循环 | 每轮只做压缩 | 每轮注入记忆 + 压缩 + 每轮结束后提取 + 定期整理 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s09_memory/code.py\n```\n\n试试这些 prompt(分多轮输入,观察记忆的累积和加载):\n\n1. `I prefer using tabs for indentation, not spaces. Remember that.`\n2. `Create a Python file called test.py`(观察 Agent 是否用了 tab)\n3. `What did I tell you about my preferences?`(观察 Agent 是否记得)\n4. `I also prefer single quotes over double quotes for strings.`\n\n观察重点:每轮结束后是否出现 `[Memory: extracted N new memories]`?`.memory/` 目录下是否生成了 `.md` 文件?`MEMORY.md` 索引是否更新?新一轮对话时 Agent 是否自动加载了之前的记忆?\n\n---\n\n## 接下来\n\n记忆、压缩、工具都已就绪。但 system prompt 还是硬编码的一大段字符串。加了新工具要手动加描述,换了项目要重写整个 prompt。prompt 应该运行时组装。\n\ns10 System Prompt → 分段 + 运行时组装。不同项目、不同工具,拼出不同的 prompt。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `src/` 下 `memdir/`、`services/`、`utils/`、`query/` 的分析,行号已对照核实。\n\n### 源码路径\n\n| 文件 | 行数 | 职责 |\n|------|------|------|\n| `memdir/memdir.ts` | 507 | 核心:MEMORY.md 定义(`34-38`)、记忆行为指令区分 memory/plan/tasks(`199-266`)、`loadMemoryPrompt()` 三条路径(`419-490`) |\n| `memdir/findRelevantMemories.ts` | 141 | Sonnet side-query 选记忆(`18-24` 系统提示、`97-122` 调用逻辑) |\n| `memdir/memoryTypes.ts` | 271 | 类型定义,frontmatter 字段 |\n| `memdir/memoryScan.ts` | — | 扫描 .md 文件,排除 MEMORY.md,读 frontmatter,最多 200 个,按 mtime 降序(`35-94`) |\n| `services/extractMemories/extractMemories.ts` | 615 | forked agent 提取记忆,受限权限,`skipTranscript: true`,`maxTurns: 5`(`371-427`) |\n| `services/autoDream/autoDream.ts` | 324 | Dream 整理,四层门控(`63-66` 默认值、`130-190` 门控、`224-233` forked agent) |\n| `services/SessionMemory/sessionMemory.ts` | 495 | 会话级记忆管理 |\n| `services/compact/sessionMemoryCompact.ts` | — | session memory 轻量摘要,阈值 10K/5/40K(`56-61`) |\n| `utils/attachments.ts` | — | 注入预算:200 行 / 4096 字节每文件,60KB 每 session(`269-288`);按 query 找相关 memory(`2196-2241`) |\n| `query.ts` | — | memory prefetch 每轮启动(`301-304`),非阻塞收集(`1592-1614`) |\n| `query/stopHooks.ts` | — | stop hook fire-and-forget 触发提取和 Dream(`141-155`) |\n\n### 记忆选择:LLM 选,不是 embedding\n\nClaude Code 用 **Sonnet 本身来选**(`findRelevantMemories.ts`),不是 embedding 向量相似度:\n\n1. `memoryScan.ts` 扫描 `.memory/` 下所有 `.md` 文件(排除 MEMORY.md),最多 200 个,按 mtime 降序\n2. 把 `name` + `description` 列成清单\n3. 发给 Sonnet side-query:\"根据名称和描述选出真正有用的记忆(最多 5 个)。不确定就不要选。\"\n4. Sonnet 返回 `{ selected_memories: [\"file1.md\", ...] }`\n5. 选中文件读取完整内容(每文件 ≤ 200 行 / 4096 字节),注入上下文。单 session 总预算 60KB\n\n每轮用户 turn 开始时,`query.ts:301-304` 启动 memory prefetch(异步);工具执行后 `1592-1614` 非阻塞收集结果,不卡主流程。\n\n### 提取时机:stop hook,不是 autoCompact 后\n\n触发位置(`stopHooks.ts:141-155`):在 `handleStopHooks()` 中,fire-and-forget 触发提取和 Dream。教学版把提取放在 `stop_reason != \"tool_use\"` 分支里,方向一致。\n\nClaude Code 的提取通过 forked agent 执行(`extractMemories.ts:371-427`):受限权限、`skipTranscript: true`、`maxTurns: 5`。还有重叠保护:如果主 Agent 已经写入了记忆文件,跳过提取。\n\n### 记忆文件格式\n\nClaude Code 用 Markdown + YAML frontmatter,和教学版一致。四种类型:`user`、`feedback`、`project`、`reference`。\n\n`memdir.ts:34-38` 定义索引约束:`MEMORY.md` 最多 200 行 / 25KB。`memdir.ts:199-266` 构建记忆行为指令,明确区分 memory、plan、tasks。存储位置:`~/.claude/projects//memory/`。\n\n### Dream:四层门控\n\n不是\"空闲时触发\"或\"数量够了就合并\",而是四层门控(`autoDream.ts`,默认值 `63-66`,门控逻辑 `130-190`):\n\n1. **时间门控**:距上次合并 ≥ 24 小时\n2. **扫描节流**:避免频繁扫描文件系统\n3. **会话门控**:自上次合并以来修改了 ≥ 5 个会话 transcript\n4. **锁门控**:没有其他进程正在合并(`.consolidate-lock` 文件)\n\n合并本身通过 forked agent 执行(`224-233`):定位 → 收集近期信号 → 合并写文件 → 剪枝更新索引。锁文件 mtime 就是 lastConsolidatedAt。崩溃恢复:1 小时后锁自动过期。\n\n### User Memory vs Session Memory\n\n| | User Memory | Session Memory |\n|---|---|---|\n| 持久性 | 跨会话 | 单会话 |\n| 存储 | `memory/` 下多个 .md 文件 | `session-memory//memory.md` |\n| 加载到 | system prompt | compact 摘要 |\n| 用途 | 跨会话的知识积累 | 跨 compact 的上下文连续性 |\n\nsessionMemoryCompact(s08 中提到的机制)正是使用了 Session Memory:autoCompact 前先读 session memory 文件,如果内容足够(≥ 10K token、≥ 5 条文本消息、≤ 40K token,`sessionMemoryCompact.ts:56-61`),就用它做摘要,不调 LLM。\n\n### 真实实现比教学版复杂的地方\n\n- **Feature flags**:记忆相关功能有多层 feature gate 控制\n- **Team memory**:团队共享记忆,`loadMemoryPrompt()` 有专门路径(教学版未涉及)\n- **KAIROS**:时机感知的记忆提取策略,`loadMemoryPrompt()` 中 daily-log 模式\n- **Prompt cache**:记忆注入需要考虑 prompt cache 的 TTL,避免每次都重写 system prompt 的大段内容\n- **文件锁**:多进程并发时的锁机制\n- **Memory prefetch**:异步预取,不阻塞主流程\n\n### 教学版的简化是刻意的\n\n- LLM side-query → LLM side-query + 关键词降级:教学版保留了 LLM 选择,加了降级路径\n- 记忆 JSON → Markdown + frontmatter:教学版与 Claude Code 一致\n- stop hook 触发 → `stop_reason != \"tool_use\"` 分支:方向一致\n- 四层门控 → 文件数阈值:教学版没有 transcript 系统和多会话概念\n- forked agent + 受限权限 → 直接调用:教学版没有子进程隔离\n\n
\n\n\n" }, { "version": "s09", "locale": "ja", "title": "s09: Memory — 圧縮は詳細を失う、失わない層が必要", - "content": "# s09: Memory — 圧縮は詳細を失う、失わない層が必要\n\ns01 → ... → s07 → s08 → `s09` → [s10](/ja/s10) → s11 → ... → s20\n> *\"圧縮は詳細を失う、失わない層が必要\"* — ファイルストア + インデックス + オンデマンド読み込み。圧縮を越え、セッションを越えて。\n>\n> **Harness レイヤー**: 記憶 — 圧縮とセッションを越える知識の蓄積。\n\n---\n\n## 課題\n\ns08 の autoCompact は現在の目標、残りの作業、ユーザーの制約をサマリに保持するが、詳細は失われる:「タブでインデント、スペース不可」が「ユーザーにコードスタイルの好みあり」と簡略化される。そして新しいセッションを開始すると、サマリすらない。\n\nLLM には永続状態がなく、すべての情報はコンテキストウィンドウ内にある。コンテキストが満杯になれば圧縮され、圧縮は非可逆。圧縮に参加せず、セッションを越えて保持されるストレージ層が必要。\n\n---\n\n## ソリューション\n\n![Memory Overview](/course-assets/s09_memory/memory-overview.ja.svg)\n\ns08 の圧縮パイプラインを維持し、記憶に焦点を当てる。ストレージにはファイルシステムを採用:`.memory/` ディレクトリに各記憶を `.md` ファイルとして保存、YAML frontmatter(`name` / `description` / `type`)付き。ファイルが増えたらインデックスが必要:`MEMORY.md` に 1 行 1 リンクを記録し、SYSTEM に注入。\n\n重要な設計:インデックスは SYSTEM prompt に常駐(prompt cache でキャッシュ可能)、ファイル内容はオンデマンド注入(filename/description で現在の会話にマッチ、cache を破壊しない)。書き込みは 2 つのパス:ユーザーが明示的に「覚えて」と言うか、毎ターン終了後にバックグラウンドで抽出。ファイルが蓄積されたら、定期的に整理して重複排除。\n\n4 種類の記憶、それぞれ異なる質問に答える:\n\n| タイプ | 何に答えるか | 例 |\n|--------|-------------|-----|\n| user | あなたは誰か | \"タブでスペース不可\" |\n| feedback | どう作業するか | \"DB をモックしない\" |\n| project | 何が起きているか | \"auth 書き直しはコンプライアンス主導\" |\n| reference | どこで探すか | \"パイプラインのバグは Linear INGEST\" |\n\n---\n\n## 仕組み\n\n![Memory Subsystems](/course-assets/s09_memory/memory-subsystems.ja.svg)\n\n### ストレージ:Markdown ファイル + インデックス\n\n各記憶は `.md` ファイル、YAML frontmatter でメタデータを記録:\n\n```markdown\n---\nname: user-preference-tabs\ndescription: User prefers tabs for indentation\ntype: user\n---\n\nUser prefers using tabs, not spaces, for indentation.\n**Why:** Consistency with existing codebase conventions.\n**How to apply:** Always use tabs when writing or editing files.\n```\n\n`MEMORY.md` はインデックス、1 行に 1 リンク:\n\n```markdown\n- [user-preference-tabs](user-preference-tabs.md) — User prefers tabs for indentation\n```\n\n新しい記憶を書き込むとインデックスを自動再構築:\n\n```python\ndef write_memory_file(name, mem_type, description, body):\n slug = name.lower().replace(\" \", \"-\")\n filepath = MEMORY_DIR / f\"{slug}.md\"\n filepath.write_text(\n f\"---\\nname: {name}\\ndescription: {description}\\ntype: {mem_type}\\n---\\n\\n{body}\\n\"\n )\n _rebuild_index()\n```\n\n### 読み込み:2 つのパス\n\n**パス 1:インデックスを SYSTEM に常駐。** `build_system()` は毎ターン SYSTEM を再構築する際に `MEMORY.md` を読み込み、記憶カタログを注入。SYSTEM prompt 内のインデックスは prompt cache でキャッシュ可能で、毎ターン再送不要。\n\n**パス 2:関連記憶をオンデマンド注入。** 各 LLM 呼び出し前、`load_memories()` は最近の会話と記憶カタログ(name + description)を LLM に軽量 side-query として送信し、関連するファイル名を選択、ファイル内容を読み込んで注入。上限 5 件でコストを制御。\n\n```python\ndef select_relevant_memories(messages, max_items=5):\n files = list_memory_files()\n if not files:\n return []\n\n # Build catalog: \"0: user-preference-tabs — User prefers tabs...\"\n catalog = \"\\n\".join(f\"{i}: {f['name']} — {f['description']}\" for i, f in enumerate(files))\n\n response = client.messages.create(model=MODEL, messages=[{\"role\": \"user\",\n \"content\": f\"Select relevant memory indices. Return JSON array.\\n\\n\"\n f\"Recent conversation:\\n{recent}\\n\\nMemory catalog:\\n{catalog}\"}],\n max_tokens=200)\n indices = json.loads(re.search(r'\\[.*?\\]', response.content[0].text).group())\n return [files[i][\"filename\"] for i in indices if 0 <= i < len(files)]\n```\n\nside-query が失敗した場合(API エラー、JSON パース失敗)、name + description のキーワードマッチにフォールバック。\n\n### 書き込み:毎ターン終了後の抽出\n\nユーザーが毎回「これを覚えて」と言うわけではない。好みは通常、通常の会話の中に散らばっている:「タブの方がスペースより良い」「これからはシングルクォートにしよう」。\n\n`extract_memories()` は各ターン終了時に実行、モデルが tool_use なしで停止した場合にトリガー(会話が自然な区切りに達したことを示す):\n\n```python\n# In agent_loop:\nif response.stop_reason != \"tool_use\":\n extract_memories(messages) # 最近の会話から新しい記憶を抽出\n consolidate_memories() # 整理が必要かチェック\n return\n```\n\n抽出前に既存の記憶を確認し、重複を回避。抽出プロンプトは LLM に `{name, type, description, body}` の JSON 配列を要求、本当に新しい情報がある場合のみファイルに書き込む。\n\n```python\ndef extract_memories(messages):\n dialogue = format_recent_messages(messages[-10:])\n existing = \"\\n\".join(f\"- {m['name']}: {m['description']}\" for m in list_memory_files())\n\n prompt = (\n \"Extract user preferences, constraints, or project facts.\\n\"\n \"Return JSON array: [{name, type, description, body}].\\n\"\n \"If nothing new or already covered, return [].\\n\\n\"\n f\"Existing memories:\\n{existing}\\n\\nDialogue:\\n{dialogue[:4000]}\"\n )\n # ... parse response, write files ...\n```\n\n### 整理:低頻度の重複排除\n\n記憶ファイルは蓄積される。`consolidate_memories()` はファイル数が閾値(デフォルト 10)に達した時にトリガー、LLM に重複排除、矛盾の統合、古い記憶の剪定を依頼:\n\n```python\nCONSOLIDATE_THRESHOLD = 10\n\ndef consolidate_memories():\n files = list_memory_files()\n if len(files) < CONSOLIDATE_THRESHOLD:\n return # 少なすぎる、整理する価値なし\n # Send all memories to LLM, get back deduplicated list\n # Replace all files with consolidated results\n```\n\nCC はこのプロセスを **Dream** と呼び、実際には 4 層のゲートがある:時間間隔、スキャンスロットル、セッション数、ファイルロック。教学版はファイル数閾値に簡略化。\n\n### Memory に保存するもの\n\nMemory はセッションを越えて有用な情報を保存する:ユーザーの好み、繰り返し出るフィードバック、プロジェクト背景、よく使う入口、調査の手がかりなど。「あとでまた使うもの」を対象にし、インデックス + オンデマンド読み込みで現在の会話に戻す。\n\nsession memory は 1 つのセッション内の連続性を扱う:compact 後も現在の会話に残すべき文脈を保持する。両者は役割が分かれている。Memory は長期知識を扱い、session memory は現在のセッションを compact 越しにつなぐ。\n\n---\n\n## s08 からの変更点\n\n| コンポーネント | 変更前 (s08) | 変更後 (s09) |\n|-----------|-------------|-------------|\n| 記憶能力 | なし(圧縮後、好みはサマリと共に劣化) | ストレージ + 読み込み + 抽出 + 整理 |\n| 新規関数 | — | write_memory_file, select_relevant_memories, load_memories, extract_memories, consolidate_memories |\n| ストレージ | — | .memory/MEMORY.md インデックス + .memory/*.md ファイル |\n| ツール | bash, read, write, edit, glob, todo_write, task, load_skill, compact (9) | bash, read_file, write_file, edit_file, glob, task (6) |\n| ループ | 毎ターン圧縮のみ | 記憶注入 + 圧縮 + ターン終了後の抽出 + 定期整理 |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s09_memory/code.py\n```\n\n以下のプロンプトを試してみてください(複数ターンに分けて入力し、記憶の蓄積と読み込みを観察):\n\n1. `I prefer using tabs for indentation, not spaces. Remember that.`\n2. `Create a Python file called test.py`(Agent がタブを使用したか観察)\n3. `What did I tell you about my preferences?`(Agent が覚えているか観察)\n4. `I also prefer single quotes over double quotes for strings.`\n\n観察のポイント:各ターン終了後に `[Memory: extracted N new memories]` が表示されるか?`.memory/` ディレクトリに `.md` ファイルが生成されたか?`MEMORY.md` インデックスが更新されたか?新しい会話で Agent が以前の記憶を自動的に読み込んだか?\n\n---\n\n## 次へ\n\n記憶、圧縮、ツールはすべて揃った。しかし system prompt はまだハードコードされた文字列。新しいツールを追加するには手動で説明を書き、プロジェクトを変えるにはプロンプト全体を書き直す。プロンプトは実行時に組み立てられるべき。\n\ns10 System Prompt → セグメント + 実行時組み立て。異なるプロジェクト、異なるツール、異なるプロンプト。\n\n
\nCC ソースコードの詳細\n\n> 以下は CC ソースコード `src/` 下の `memdir/`、`services/`、`utils/`、`query/` の分析に基づく。行番号はソースコードと照合済み。\n\n### ソースコードパス\n\n| ファイル | 行数 | 職責 |\n|------|------|------|\n| `memdir/memdir.ts` | 507 | 核心:MEMORY.md 定義(`34-38`)、記憶動作指示で memory/plan/tasks を区別(`199-266`)、`loadMemoryPrompt()` 3 パス(`419-490`) |\n| `memdir/findRelevantMemories.ts` | 141 | Sonnet side-query で記憶選択(`18-24` システムプロンプト、`97-122` 呼び出しロジック) |\n| `memdir/memoryTypes.ts` | 271 | 型定義、frontmatter フィールド |\n| `memdir/memoryScan.ts` | — | .md ファイルをスキャン、MEMORY.md を除外、frontmatter を読み取り、最大 200 ファイル、mtime 降順(`35-94`) |\n| `services/extractMemories/extractMemories.ts` | 615 | forked agent で記憶を抽出、制限付き権限、`skipTranscript: true`、`maxTurns: 5`(`371-427`) |\n| `services/autoDream/autoDream.ts` | 324 | Dream 整理、4 層ゲート(`63-66` デフォルト値、`130-190` ゲート、`224-233` forked agent) |\n| `services/SessionMemory/sessionMemory.ts` | 495 | セッションレベルの記憶管理 |\n| `services/compact/sessionMemoryCompact.ts` | — | session memory 軽量サマリ、閾値 10K/5/40K(`56-61`) |\n| `utils/attachments.ts` | — | 注入予算:200 行 / 4096 バイト/ファイル、60KB/セッション(`269-288`);query で関連記憶を検索(`2196-2241`) |\n| `query.ts` | — | memory prefetch を毎ターン開始時に起動(`301-304`)、非ブロッキング収集(`1592-1614`) |\n| `query/stopHooks.ts` | — | stop hook fire-and-forget で抽出と Dream をトリガー(`141-155`) |\n\n### 記憶選択:embedding ではなく LLM\n\nCC は **Sonnet 自身で選択**(`findRelevantMemories.ts`)、embedding ベクトル類似度ではない:\n\n1. `memoryScan.ts` が `.memory/` 下のすべての `.md` ファイルをスキャン(MEMORY.md を除外)、最大 200 ファイル、mtime 降順\n2. `name` + `description` をカタログとしてリスト化\n3. Sonnet side-query に送信:「名前と説明から本当に有用な記憶を選択(最大 5 件)。不明ならスキップ。」\n4. Sonnet が `{ selected_memories: [\"file1.md\", ...] }` を返却\n5. 選択されたファイルの完全な内容を読み込み(≤ 200 行 / 4096 バイト/ファイル)、注入。セッション総予算:60KB\n\n毎ターンのユーザー turn 開始時、`query.ts:301-304` が memory prefetch を起動(非同期);ツール実行後、`1592-1614` が非ブロッキングで結果を収集。\n\n### 抽出タイミング:stop hook、autoCompact 後ではない\n\nトリガー位置(`stopHooks.ts:141-155`):`handleStopHooks()` 内で、fire-and-forget で抽出と Dream をトリガー。教学版は `stop_reason != \"tool_use\"` 分岐に抽出を配置、方向は一致。\n\nCC の抽出は forked agent で実行(`extractMemories.ts:371-427`):制限付き権限、`skipTranscript: true`、`maxTurns: 5`。重複保護もある:メイン Agent が既に記憶ファイルを書き込んだ場合、抽出をスキップ。\n\n### 記憶ファイル形式\n\nCC は Markdown + YAML frontmatter を使用、教学版と一致。4 種類:`user`、`feedback`、`project`、`reference`。\n\n`memdir.ts:34-38` がインデックス制約を定義:`MEMORY.md` 最大 200 行 / 25KB。`memdir.ts:199-266` が記憶動作指示を構築、memory と plan と tasks を明確に区別。保存場所:`~/.claude/projects//memory/`。\n\n### Dream:4 層ゲート\n\n「アイドル時にトリガー」や「数が足りたら統合」ではなく、4 層のゲート(`autoDream.ts`、デフォルト値 `63-66`、ゲートロジック `130-190`):\n\n1. **時間ゲート**:前回の統合から ≥ 24 時間\n2. **スキャンスロットル**:頻繁なファイルシステムスキャンを回避\n3. **セッションゲート**:前回の統合以降 ≥ 5 セッションの transcript が変更された\n4. **ロックゲート**:他のプロセスが統合中でない(`.consolidate-lock` ファイル)\n\n統合自体は forked agent で実行(`224-233`):定位 → 直近のシグナル収集 → 統合してファイル書き込み → 剪定してインデックス更新。ロックファイルの mtime が lastConsolidatedAt。クラッシュリカバリ:1 時間後にロックが自動期限切れ。\n\n### User Memory vs Session Memory\n\n| | User Memory | Session Memory |\n|---|---|---|\n| 永続性 | セッション間 | 単一セッション |\n| ストレージ | `memory/` 下の複数 .md ファイル | `session-memory//memory.md` |\n| 注入先 | system prompt | compact サマリ |\n| 目的 | セッション間の知識蓄積 | compact を越えたコンテキストの連続性 |\n\nsessionMemoryCompact(s08 で触れた仕組み)は Session Memory を活用:autoCompact の前に session memory ファイルを読み込み、内容が十分であれば(≥ 10K token、≥ 5 テキストメッセージ、≤ 40K token、`sessionMemoryCompact.ts:56-61`)、LLM を呼び出さずにサマリとして使用。\n\n### 実際の実装が教学版より複雑な点\n\n- **Feature flags**:記憶関連機能には複数の feature gate 層がある\n- **Team memory**:チーム共有記憶、`loadMemoryPrompt()` に専用パスあり(教学版では未カバー)\n- **KAIROS**:タイミング認識型の記憶抽出戦略、`loadMemoryPrompt()` の daily-log モード\n- **Prompt cache**:記憶注入は prompt cache の TTL を考慮する必要があり、毎ターン system prompt の大部分を書き直すことを避ける\n- **ファイルロック**:マルチプロセス時の並行制御\n- **Memory prefetch**:非同期プレフェッチ、メインフローをブロックしない\n\n### 教学版の簡略化は意図的\n\n- LLM side-query → LLM side-query + キーワードフォールバック:教学版は LLM 選択を維持し、フォールバックパスを追加\n- 記憶 JSON → Markdown + frontmatter:教学版は CC と一致\n- stop hook トリガー → `stop_reason != \"tool_use\"` 分岐:方向は一致\n- 4 層ゲート → ファイル数閾値:教学版には transcript システムやマルチセッションの概念がない\n- forked agent + 制限付き権限 → 直接呼び出し:教学版にはサブプロセス分離がない\n\n
\n\n\n" + "content": "# s09: Memory — 圧縮は詳細を失う、失わない層が必要\n\ns01 → ... → s07 → s08 → `s09` → [s10](/ja/s10) → s11 → ... → s20\n> *\"圧縮は詳細を失う、失わない層が必要\"* — ファイルストア + インデックス + オンデマンド読み込み。圧縮を越え、セッションを越えて。\n>\n> **Harness レイヤー**: 記憶 — 圧縮とセッションを越える知識の蓄積。\n\n---\n\n## 課題\n\ns08 の autoCompact は現在の目標、残りの作業、ユーザーの制約をサマリに保持するが、詳細は失われる:「タブでインデント、スペース不可」が「ユーザーにコードスタイルの好みあり」と簡略化される。そして新しいセッションを開始すると、サマリすらない。\n\nLLM には永続状態がなく、すべての情報はコンテキストウィンドウ内にある。コンテキストが満杯になれば圧縮され、圧縮は非可逆。コンテキストウィンドウの外にストレージ層が必要になる。\n\n---\n\n## ソリューション\n\n![Memory Overview](/course-assets/s09_memory/memory-overview.svg)\n\ns08 の圧縮パイプラインを維持し、記憶に焦点を当てる。ストレージにはファイルシステムを採用:`.memory/` ディレクトリに各記憶を `.md` ファイルとして保存、YAML frontmatter(`name` / `description` / `type`)付き。ファイルが増えたらインデックスが必要:`MEMORY.md` に 1 行 1 リンクを記録し、SYSTEM に注入。\n\n重要な設計:インデックスは SYSTEM prompt に常駐(prompt cache でキャッシュ可能)、ファイル内容はオンデマンド注入(filename/description で現在の会話にマッチ、cache を破壊しない)。書き込みは 2 つのパス:ユーザーが明示的に「覚えて」と言うか、毎ターン終了後にバックグラウンドで抽出。ファイルが蓄積されたら、定期的に整理して重複排除。\n\n4 種類の記憶、それぞれ異なる質問に答える:\n\n| タイプ | 何に答えるか | 例 |\n|--------|-------------|-----|\n| user | あなたは誰か | \"タブでスペース不可\" |\n| feedback | どう作業するか | \"DB をモックしない\" |\n| project | 何が起きているか | \"auth 書き直しはコンプライアンス主導\" |\n| reference | どこで探すか | \"パイプラインのバグは Linear INGEST\" |\n\n---\n\n## 仕組み\n\n![Memory Subsystems](/course-assets/s09_memory/memory-subsystems.svg)\n\n### ストレージ:Markdown ファイル + インデックス\n\n各記憶は `.md` ファイル、YAML frontmatter でメタデータを記録:\n\n```markdown\n---\nname: user-preference-tabs\ndescription: User prefers tabs for indentation\ntype: user\n---\n\nUser prefers using tabs, not spaces, for indentation.\n**Why:** Consistency with existing codebase conventions.\n**How to apply:** Always use tabs when writing or editing files.\n```\n\n`MEMORY.md` はインデックス、1 行に 1 リンク:\n\n```markdown\n- [user-preference-tabs](user-preference-tabs.md) — User prefers tabs for indentation\n```\n\n新しい記憶を書き込むとインデックスを自動再構築:\n\n```python\ndef write_memory_file(name, mem_type, description, body):\n slug = name.lower().replace(\" \", \"-\")\n filepath = MEMORY_DIR / f\"{slug}.md\"\n filepath.write_text(\n f\"---\\nname: {name}\\ndescription: {description}\\ntype: {mem_type}\\n---\\n\\n{body}\\n\"\n )\n _rebuild_index()\n```\n\n### 読み込み:2 つのパス\n\n**パス 1:インデックスを SYSTEM に常駐。** `build_system()` は各ユーザーリクエストの開始時に 1 回だけ `MEMORY.md` を読み込み、記憶カタログを SYSTEM prompt に注入。記憶の抽出と整理はターン終了時にだけ実行されるため、同じユーザーリクエスト内で SYSTEM を繰り返し再構築する必要はない。\n\n**パス 2:関連記憶をオンデマンド注入。** 各ユーザーリクエストの開始時に、`load_memories()` は最近の会話と記憶カタログ(name + description)を LLM に軽量 side-query として送信し、関連するファイル名を選択、ファイル内容を読み込んで注入。上限 5 件でコストを制御。\n\n```python\ndef select_relevant_memories(messages, max_items=5):\n files = list_memory_files()\n if not files:\n return []\n\n # Build catalog: \"0: user-preference-tabs — User prefers tabs...\"\n catalog = \"\\n\".join(f\"{i}: {f['name']} — {f['description']}\" for i, f in enumerate(files))\n\n response = client.messages.create(model=MODEL, messages=[{\"role\": \"user\",\n \"content\": f\"Select relevant memory indices. Return JSON array.\\n\\n\"\n f\"Recent conversation:\\n{recent}\\n\\nMemory catalog:\\n{catalog}\"}],\n max_tokens=200)\n indices = json.loads(re.search(r'\\[.*?\\]', response.content[0].text).group())\n return [files[i][\"filename\"] for i in indices if 0 <= i < len(files)]\n```\n\nside-query が失敗した場合(API エラー、JSON パース失敗)、name + description のキーワードマッチにフォールバック。\n\n### 書き込み:毎ターン終了後の抽出\n\nユーザーが毎回「これを覚えて」と言うわけではない。好みは通常、通常の会話の中に散らばっている:「タブの方がスペースより良い」「これからはシングルクォートにしよう」。\n\n`extract_memories()` は各ターン終了時に実行、モデルが tool_use なしで停止した場合にトリガー(会話が自然な区切りに達したことを示す):\n\n```python\n# In agent_loop:\nif response.stop_reason != \"tool_use\":\n extract_memories(messages) # 最近の会話から新しい記憶を抽出\n consolidate_memories() # 整理が必要かチェック\n return\n```\n\n抽出前に既存の記憶を確認し、重複を回避。抽出プロンプトは LLM に `{name, type, description, body}` の JSON 配列を要求、本当に新しい情報がある場合のみファイルに書き込む。\n\n```python\ndef extract_memories(messages):\n dialogue = format_recent_messages(messages[-10:])\n existing = \"\\n\".join(f\"- {m['name']}: {m['description']}\" for m in list_memory_files())\n\n prompt = (\n \"Extract user preferences, constraints, or project facts.\\n\"\n \"Return JSON array: [{name, type, description, body}].\\n\"\n \"If nothing new or already covered, return [].\\n\\n\"\n f\"Existing memories:\\n{existing}\\n\\nDialogue:\\n{dialogue[:4000]}\"\n )\n # ... parse response, write files ...\n```\n\n### 整理:低頻度の重複排除\n\n記憶ファイルは蓄積される。`consolidate_memories()` はファイル数が閾値(デフォルト 10)に達した時にトリガー、LLM に重複排除、矛盾の統合、古い記憶の剪定を依頼:\n\n```python\nCONSOLIDATE_THRESHOLD = 10\n\ndef consolidate_memories():\n files = list_memory_files()\n if len(files) < CONSOLIDATE_THRESHOLD:\n return # 少なすぎる、整理する価値なし\n # Send all memories to LLM, get back deduplicated list\n # Replace all files with consolidated results\n```\n\nClaude Code はこのプロセスを **Dream** と呼び、実際には 4 層のゲートがある:時間間隔、スキャンスロットル、セッション数、ファイルロック。教学版はファイル数閾値に簡略化。\n\n### Memory に保存するもの\n\nMemory はユーザーの好み、繰り返し出るフィードバック、プロジェクト背景、よく使う入口、調査の手がかりを保存する。インデックス + オンデマンド読み込みで現在の会話に戻す。\n\nsession memory は 1 つのセッション内の連続性を扱う:compact 後も現在の会話に残すべき文脈を保持する。両者は役割が分かれている。Memory は長期知識を扱い、session memory は現在のセッションを compact 越しにつなぐ。\n\n---\n\n## s08 からの変更点\n\n| コンポーネント | 変更前 (s08) | 変更後 (s09) |\n|-----------|-------------|-------------|\n| 記憶能力 | なし(圧縮後、好みはサマリと共に劣化) | ストレージ + 読み込み + 抽出 + 整理 |\n| 新規関数 | — | write_memory_file, select_relevant_memories, load_memories, extract_memories, consolidate_memories |\n| ストレージ | — | .memory/MEMORY.md インデックス + .memory/*.md ファイル |\n| ツール | bash, read, write, edit, glob, todo_write, task, load_skill, compact (9) | bash, read_file, write_file, edit_file, glob, task (6) |\n| ループ | 毎ターン圧縮のみ | 記憶注入 + 圧縮 + ターン終了後の抽出 + 定期整理 |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s09_memory/code.py\n```\n\n以下のプロンプトを試してみてください(複数ターンに分けて入力し、記憶の蓄積と読み込みを観察):\n\n1. `I prefer using tabs for indentation, not spaces. Remember that.`\n2. `Create a Python file called test.py`(Agent がタブを使用したか観察)\n3. `What did I tell you about my preferences?`(Agent が覚えているか観察)\n4. `I also prefer single quotes over double quotes for strings.`\n\n観察のポイント:各ターン終了後に `[Memory: extracted N new memories]` が表示されるか?`.memory/` ディレクトリに `.md` ファイルが生成されたか?`MEMORY.md` インデックスが更新されたか?新しい会話で Agent が以前の記憶を自動的に読み込んだか?\n\n---\n\n## 次へ\n\n記憶、圧縮、ツールはすべて揃った。しかし system prompt はまだハードコードされた文字列。新しいツールを追加するには手動で説明を書き、プロジェクトを変えるにはプロンプト全体を書き直す。プロンプトは実行時に組み立てられるべき。\n\ns10 System Prompt → セグメント + 実行時組み立て。異なるプロジェクト、異なるツール、異なるプロンプト。\n\n
\nClaude Code ソースコードの詳細\n\n> 以下は Claude Code ソースコード `src/` 下の `memdir/`、`services/`、`utils/`、`query/` の分析に基づく。行番号はソースコードと照合済み。\n\n### ソースコードパス\n\n| ファイル | 行数 | 職責 |\n|------|------|------|\n| `memdir/memdir.ts` | 507 | 核心:MEMORY.md 定義(`34-38`)、記憶動作指示で memory/plan/tasks を区別(`199-266`)、`loadMemoryPrompt()` 3 パス(`419-490`) |\n| `memdir/findRelevantMemories.ts` | 141 | Sonnet side-query で記憶選択(`18-24` システムプロンプト、`97-122` 呼び出しロジック) |\n| `memdir/memoryTypes.ts` | 271 | 型定義、frontmatter フィールド |\n| `memdir/memoryScan.ts` | — | .md ファイルをスキャン、MEMORY.md を除外、frontmatter を読み取り、最大 200 ファイル、mtime 降順(`35-94`) |\n| `services/extractMemories/extractMemories.ts` | 615 | forked agent で記憶を抽出、制限付き権限、`skipTranscript: true`、`maxTurns: 5`(`371-427`) |\n| `services/autoDream/autoDream.ts` | 324 | Dream 整理、4 層ゲート(`63-66` デフォルト値、`130-190` ゲート、`224-233` forked agent) |\n| `services/SessionMemory/sessionMemory.ts` | 495 | セッションレベルの記憶管理 |\n| `services/compact/sessionMemoryCompact.ts` | — | session memory 軽量サマリ、閾値 10K/5/40K(`56-61`) |\n| `utils/attachments.ts` | — | 注入予算:200 行 / 4096 バイト/ファイル、60KB/セッション(`269-288`);query で関連記憶を検索(`2196-2241`) |\n| `query.ts` | — | memory prefetch を毎ターン開始時に起動(`301-304`)、非ブロッキング収集(`1592-1614`) |\n| `query/stopHooks.ts` | — | stop hook fire-and-forget で抽出と Dream をトリガー(`141-155`) |\n\n### 記憶選択:embedding ではなく LLM\n\nClaude Code は **Sonnet 自身で選択**(`findRelevantMemories.ts`)、embedding ベクトル類似度ではない:\n\n1. `memoryScan.ts` が `.memory/` 下のすべての `.md` ファイルをスキャン(MEMORY.md を除外)、最大 200 ファイル、mtime 降順\n2. `name` + `description` をカタログとしてリスト化\n3. Sonnet side-query に送信:「名前と説明から本当に有用な記憶を選択(最大 5 件)。不明ならスキップ。」\n4. Sonnet が `{ selected_memories: [\"file1.md\", ...] }` を返却\n5. 選択されたファイルの完全な内容を読み込み(≤ 200 行 / 4096 バイト/ファイル)、注入。セッション総予算:60KB\n\n毎ターンのユーザー turn 開始時、`query.ts:301-304` が memory prefetch を起動(非同期);ツール実行後、`1592-1614` が非ブロッキングで結果を収集。\n\n### 抽出タイミング:stop hook、autoCompact 後ではない\n\nトリガー位置(`stopHooks.ts:141-155`):`handleStopHooks()` 内で、fire-and-forget で抽出と Dream をトリガー。教学版は `stop_reason != \"tool_use\"` 分岐に抽出を配置、方向は一致。\n\nClaude Code の抽出は forked agent で実行(`extractMemories.ts:371-427`):制限付き権限、`skipTranscript: true`、`maxTurns: 5`。重複保護もある:メイン Agent が既に記憶ファイルを書き込んだ場合、抽出をスキップ。\n\n### 記憶ファイル形式\n\nClaude Code は Markdown + YAML frontmatter を使用、教学版と一致。4 種類:`user`、`feedback`、`project`、`reference`。\n\n`memdir.ts:34-38` がインデックス制約を定義:`MEMORY.md` 最大 200 行 / 25KB。`memdir.ts:199-266` が記憶動作指示を構築、memory と plan と tasks を明確に区別。保存場所:`~/.claude/projects//memory/`。\n\n### Dream:4 層ゲート\n\n「アイドル時にトリガー」や「数が足りたら統合」ではなく、4 層のゲート(`autoDream.ts`、デフォルト値 `63-66`、ゲートロジック `130-190`):\n\n1. **時間ゲート**:前回の統合から ≥ 24 時間\n2. **スキャンスロットル**:頻繁なファイルシステムスキャンを回避\n3. **セッションゲート**:前回の統合以降 ≥ 5 セッションの transcript が変更された\n4. **ロックゲート**:他のプロセスが統合中でない(`.consolidate-lock` ファイル)\n\n統合自体は forked agent で実行(`224-233`):定位 → 直近のシグナル収集 → 統合してファイル書き込み → 剪定してインデックス更新。ロックファイルの mtime が lastConsolidatedAt。クラッシュリカバリ:1 時間後にロックが自動期限切れ。\n\n### User Memory vs Session Memory\n\n| | User Memory | Session Memory |\n|---|---|---|\n| 永続性 | セッション間 | 単一セッション |\n| ストレージ | `memory/` 下の複数 .md ファイル | `session-memory//memory.md` |\n| 注入先 | system prompt | compact サマリ |\n| 目的 | セッション間の知識蓄積 | compact を越えたコンテキストの連続性 |\n\nsessionMemoryCompact(s08 で触れた仕組み)は Session Memory を活用:autoCompact の前に session memory ファイルを読み込み、内容が十分であれば(≥ 10K token、≥ 5 テキストメッセージ、≤ 40K token、`sessionMemoryCompact.ts:56-61`)、LLM を呼び出さずにサマリとして使用。\n\n### 実際の実装が教学版より複雑な点\n\n- **Feature flags**:記憶関連機能には複数の feature gate 層がある\n- **Team memory**:チーム共有記憶、`loadMemoryPrompt()` に専用パスあり(教学版では未カバー)\n- **KAIROS**:タイミング認識型の記憶抽出戦略、`loadMemoryPrompt()` の daily-log モード\n- **Prompt cache**:記憶注入は prompt cache の TTL を考慮する必要があり、毎ターン system prompt の大部分を書き直すことを避ける\n- **ファイルロック**:マルチプロセス時の並行制御\n- **Memory prefetch**:非同期プレフェッチ、メインフローをブロックしない\n\n### 教学版の簡略化は意図的\n\n- LLM side-query → LLM side-query + キーワードフォールバック:教学版は LLM 選択を維持し、フォールバックパスを追加\n- 記憶 JSON → Markdown + frontmatter:教学版は Claude Code と一致\n- stop hook トリガー → `stop_reason != \"tool_use\"` 分岐:方向は一致\n- 4 層ゲート → ファイル数閾値:教学版には transcript システムやマルチセッションの概念がない\n- forked agent + 制限付き権限 → 直接呼び出し:教学版にはサブプロセス分離がない\n\n
\n\n\n" }, { "version": "s10", "locale": "en", "title": "s10: System Prompt — Assembled at Runtime, Never Hardcoded", - "content": "# s10: System Prompt — Assembled at Runtime, Never Hardcoded\n\ns01 → ... → s08 → s09 → `s10` → [s11](/en/s11) → s12 → ... → s20\n> *\"prompt is assembled, not hardcoded\"* — Sections + on-demand assembly + caching.\n>\n> **Harness Layer**: Prompt — assembled at runtime, never hardcoded.\n\n---\n\n## The Problem\n\nFrom s01 to s09, the system prompt was always one hardcoded line:\n\n```python\nSYSTEM = f\"You are a coding agent at {WORKDIR}. Use tools to solve tasks.\"\n```\n\nThat worked for s01 — only bash, read, write. But by s09, the agent has memory, compression, skill loading. The prompt needs to describe more and more capabilities:\n\n```python\nSYSTEM = (\n f\"You are a coding agent at {WORKDIR}. \"\n \"Use tools to solve tasks. Act, don't explain. \"\n \"Before starting any multi-step task, use todo_write. \"\n \"Skills are available via list_skills and load_skill. \"\n \"Relevant memories are injected below when available. \"\n # ... add a capability, add a line\n)\n```\n\nThree problems:\n\n1. **Switching projects requires rewriting the entire prompt** — no way to know what to change and what to keep\n2. **One change can break others** — adding a tool description might conflict with earlier instructions\n3. **Every request carries everything** — even when the current conversation doesn't need certain sections, they waste tokens\n\nThe system prompt should be a configuration assembled at runtime based on current state: which tools are enabled, which context is visible, which memories are relevant, and which content must remain stable to hit prompt cache.\n\n---\n\n## The Solution\n\n![System Prompt Overview](/course-assets/s10_system_prompt/system-prompt-overview.en.svg)\n\ns10 focuses on prompt assembly. It builds on the s08-s09 capabilities but doesn't re-implement compression or memory. The core change: split the hardcoded `SYSTEM` into independent sections, assemble them at runtime based on real state, and cache the result.\n\nFour sections, two loading strategies:\n\n| Section | Strategy | Content | Condition |\n|---------|----------|---------|-----------|\n| identity | always | who you are, how to work | always present |\n| tools | always | available tool list | `enabled_tools` |\n| workspace | always | working directory | always present |\n| memory | on-demand | relevant memory content | whether `.memory/MEMORY.md` exists |\n\nKey design: whether a section loads depends on real state (tools exist, files exist), not keywords in messages.\n\n---\n\n## How It Works\n\n### PROMPT_SECTIONS: Topic-Keyed Fragments\n\nSplit the monolithic string into a dictionary, each key is a topic:\n\n```python\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n```\n\nEach section is maintained independently. Changing `tools` doesn't affect `identity`; adding `memory` doesn't touch `workspace`.\n\n### assemble_system_prompt: On-Demand Assembly\n\nNot every section is needed every turn. No memory files? Loading the memory section just wastes tokens. Assembly is based on real state in context:\n\n```python\ndef assemble_system_prompt(context: dict) -> str:\n sections = []\n\n # Always loaded\n sections.append(PROMPT_SECTIONS[\"identity\"])\n sections.append(PROMPT_SECTIONS[\"tools\"])\n sections.append(PROMPT_SECTIONS[\"workspace\"])\n\n # On-demand — based on real state, not keywords\n memories = context.get(\"memories\", \"\")\n if memories:\n sections.append(f\"Relevant memories:\\n{memories}\")\n\n return \"\\n\\n\".join(sections)\n```\n\n\"Always loaded\" sections are needed every turn: identity, tools, workspace. \"On-demand\" sections are only useful under specific conditions.\n\nWhy not load everything? Tokens have cost (system prompt is billed every turn), and fewer instructions means more focused output (irrelevant instructions are noise).\n\n### get_system_prompt: Cache to Avoid Re-Assembly\n\nWhen context hasn't changed (multiple LLM calls in the same turn with the same context), re-assembling is wasteful. Use deterministic serialization to detect changes and return cached result:\n\n```python\ndef get_system_prompt(context: dict) -> str:\n global _last_context_key, _last_prompt\n key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)\n if key == _last_context_key and _last_prompt:\n return _last_prompt\n _last_context_key = key\n _last_prompt = assemble_system_prompt(context)\n return _last_prompt\n```\n\n`json.dumps` instead of `hash()`: Python's built-in `hash()` has process randomization (unsuitable for stable cache keys) and throws `unhashable type` on nested dicts/lists.\n\nNote: this cache only avoids redundant string assembly within a process. It's not the same as CC's API prompt cache, which uses `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` to separate static and dynamic parts — the static parts hit global cache and don't invalidate when dynamic content changes.\n\n### context: Real State, Not Keyword Guessing\n\nContext reflects the actual runtime state:\n\n```python\ndef update_context(context: dict, messages: list) -> dict:\n memories = \"\"\n if MEMORY_INDEX.exists():\n content = MEMORY_INDEX.read_text().strip()\n if content:\n memories = content\n return {\n \"enabled_tools\": list(TOOL_HANDLERS.keys()),\n \"workspace\": str(WORKDIR),\n \"memories\": memories,\n }\n```\n\n`enabled_tools` lists actually registered tools. `memories` checks whether `.memory/MEMORY.md` exists. Section loading is based on this real state, not searching for keywords in messages.\n\n### Putting It Together\n\n```python\ndef agent_loop(messages: list, context: dict):\n system = get_system_prompt(context)\n while True:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages,\n tools=TOOLS, max_tokens=8000)\n # ... tool execution ...\n context = update_context(context, messages)\n system = get_system_prompt(context)\n```\n\nAt the start of each loop iteration, get the system prompt. If context changed, re-assemble; if not, return cached version.\n\n---\n\n## Changes From s09\n\n| Component | Before (s09) | After (s10) |\n|-----------|-------------|-------------|\n| prompt | Hardcoded SYSTEM string | PROMPT_SECTIONS + assemble_system_prompt |\n| caching | None | get_system_prompt (json.dumps detection + cache) |\n| new functions | — | assemble_system_prompt, get_system_prompt, update_context |\n| tools | bash, read_file, write_file (3) | bash, read_file, write_file (3) — unchanged |\n| loop | Uses fixed SYSTEM | Uses get_system_prompt(context) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s10_system_prompt/code.py\n```\n\nWhat to watch for:\n\n1. Output shows which sections were loaded (`[assembled] sections: ...` label)\n2. Cache hits show `[cache hit]` during continued conversation\n3. Creating `.memory/MEMORY.md` makes the memory section appear on the next turn\n\nTry these prompts:\n\n1. `Read the file README.md` (observe the three always-loaded sections)\n2. `Create a file called .memory/MEMORY.md with content \"- [test](test.md) — test memory\"` (write a memory index)\n3. `Read the file code.py` (observe whether the memory section appears)\n\n---\n\n## What's Next\n\nSystem prompts can now be assembled at runtime. But the agent still crashes on errors. Network hiccups, API rate limits, truncated output, context overflow — these aren't bugs, they're normal.\n\ns11 Error Recovery → four recovery paths. Upgrade tokens, compress context, exponential backoff, switch models.\n\n
\nDeep Dive Into CC Source Code\n\n> The following is based on analysis of CC source code `constants/prompts.ts` (914 lines), `constants/systemPromptSections.ts` (68 lines), `context.ts` (189 lines), `utils/api.ts` (718 lines), `utils/systemPrompt.ts` (123 lines), and `bootstrap/state.ts`.\n\n### How many sections does CC's system prompt have?\n\nThe count varies based on feature flags, output style, KAIROS/Proactive mode, user type, token budget, etc. Roughly two categories:\n\n**Static sections** (always loaded): identity, system, doing_tasks, actions, using_tools, tone_style, output_efficiency, etc.\n\n**Dynamic sections** (loaded by state): session_guidance, memory, ant_model_override, env_info_simple, language, output_style, mcp_instructions, scratchpad, frc, summarize_tool_results, numeric_length_anchors, token_budget, brief, etc.\n\n`mcp_instructions` is the only volatile section (created via `DANGEROUS_uncachedSystemPromptSection()`), because MCP servers can connect and disconnect between turns.\n\n### Assembly Function\n\n```typescript\ngetSystemPrompt(tools, model, additionalWorkingDirs?, mcpClients?): Promise\n```\n\nReturns `string[]` (each element is a section), separated by `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` between static and dynamic parts.\n\n### cache scope\n\nWhen global cache boundary is enabled, static sections are merged into one global cache block, and dynamic sections don't use global cache (`cacheScope: null`). Only paths without boundary or skipping global cache fall back to org scope.\n\nThe teaching version's cache only avoids redundant string assembly. CC's three-layer cache:\n\n1. **lodash memoize**: `getSystemContext` and `getUserContext` cached per session (`context.ts`)\n2. **Section registry cache**: `STATE.systemPromptSectionCache` caches dynamic section results, cleared on `/clear` or `/compact`\n3. **API-level cache**: `splitSysPromptPrefix()` (`api.ts`) splits prompt into blocks with different cache scopes via boundary\n\n### getUserContext vs getSystemContext\n\n| | getSystemContext | getUserContext |\n|---|---|---|\n| Content | gitStatus, cacheBreaker | CLAUDE.md content, currentDate |\n| Injection | appended to system prompt array | prepended as `` user message |\n| When skipped | custom system prompt | always runs |\n\n### How modes change the prompt\n\n- **CLAUDE_CODE_SIMPLE**: entire prompt is 2 lines\n- **Proactive/KAIROS**: compact prompt replaces all standard sections\n- **Coordinator**: coordinator-specific prompt fully replaces default\n- **Agent mode**: agent-defined prompt replaces or appends to default\n\n### Total size\n\nStandard interactive mode system prompt core is ~20-30KB text. CLAUDE_CODE_SIMPLE is ~150 characters. User context (CLAUDE.md) and system context (git status) add on top.\n\n
\n\n\n" + "content": "# s10: System Prompt — Assembled at Runtime, Never Hardcoded\n\ns01 → ... → s08 → s09 → `s10` → [s11](/en/s11) → s12 → ... → s20\n> *\"prompt is assembled, not hardcoded\"* — Sections + on-demand assembly + caching.\n>\n> **Harness Layer**: Runtime prompt assembly.\n\n---\n\n## The Problem\n\nFrom s01 to s09, the system prompt was always one hardcoded line:\n\n```python\nSYSTEM = f\"You are a coding agent at {WORKDIR}. Use tools to solve tasks.\"\n```\n\nThat worked for s01 with only bash, read, write. But by s09, the agent has memory, compression, skill loading. The prompt needs to describe more and more capabilities:\n\n```python\nSYSTEM = (\n f\"You are a coding agent at {WORKDIR}. \"\n \"Use tools to solve tasks. Act, don't explain. \"\n \"Before starting any multi-step task, use todo_write. \"\n \"Skills are available via list_skills and load_skill. \"\n \"Relevant memories are injected below when available. \"\n # ... add a capability, add a line\n)\n```\n\nThree problems:\n\n1. **Switching projects requires rewriting the entire prompt**: no way to know what to change and what to keep\n2. **One change can break others**: adding a tool description might conflict with earlier instructions\n3. **Every request carries everything**: even when the current conversation doesn't need certain sections, they waste tokens\n\nThe system prompt should be a configuration assembled at runtime based on current state: which tools are enabled, which context is visible, which memories are relevant, and which content must remain stable to hit prompt cache.\n\n---\n\n## The Solution\n\n![System Prompt Overview](/course-assets/s10_system_prompt/system-prompt-overview.svg)\n\ns10 focuses on prompt assembly. It builds on the s08-s09 capabilities but doesn't re-implement compression or memory. The core change: split the hardcoded `SYSTEM` into independent sections, assemble them at runtime based on real state, and cache the result.\n\nFour sections, two loading strategies:\n\n| Section | Strategy | Content | Condition |\n|---------|----------|---------|-----------|\n| identity | always | who you are, how to work | always present |\n| tools | always | available tool list | `enabled_tools` |\n| workspace | always | working directory | always present |\n| memory | on-demand | relevant memory content | whether `.memory/MEMORY.md` exists |\n\n---\n\n## How It Works\n\n### PROMPT_SECTIONS: Topic-Keyed Fragments\n\nSplit the monolithic string into a dictionary, each key is a topic:\n\n```python\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n```\n\nEach section is maintained independently. Changing `tools` doesn't affect `identity`; adding `memory` doesn't touch `workspace`.\n\n### assemble_system_prompt: On-Demand Assembly\n\nNot every section is needed every turn. No memory files? Loading the memory section just wastes tokens. Assembly is based on real state in context:\n\n```python\ndef assemble_system_prompt(context: dict) -> str:\n sections = []\n\n # Always loaded\n sections.append(PROMPT_SECTIONS[\"identity\"])\n sections.append(PROMPT_SECTIONS[\"tools\"])\n sections.append(PROMPT_SECTIONS[\"workspace\"])\n\n # On-demand — based on real state, not keywords\n memories = context.get(\"memories\", \"\")\n if memories:\n sections.append(f\"Relevant memories:\\n{memories}\")\n\n return \"\\n\\n\".join(sections)\n```\n\n\"Always loaded\" sections are needed every turn: identity, tools, workspace. \"On-demand\" sections are only useful under specific conditions.\n\nWhy not load everything? Tokens have cost (system prompt is billed every turn), and fewer instructions means more focused output (irrelevant instructions are noise).\n\n### get_system_prompt: Cache to Avoid Re-Assembly\n\nWhen context hasn't changed (multiple LLM calls in the same turn with the same context), re-assembling is wasteful. Use deterministic serialization to detect changes and return cached result:\n\n```python\ndef get_system_prompt(context: dict) -> str:\n global _last_context_key, _last_prompt\n key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)\n if key == _last_context_key and _last_prompt:\n return _last_prompt\n _last_context_key = key\n _last_prompt = assemble_system_prompt(context)\n return _last_prompt\n```\n\n`json.dumps` instead of `hash()`: Python's built-in `hash()` has process randomization (unsuitable for stable cache keys) and throws `unhashable type` on nested dicts/lists.\n\nNote: this cache only avoids redundant string assembly within a process. It's not the same as Claude Code's API prompt cache, which uses `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` to separate static and dynamic parts — the static parts hit global cache and don't invalidate when dynamic content changes.\n\n### context: Real State, Not Keyword Guessing\n\nContext reflects the actual runtime state:\n\n```python\ndef update_context(context: dict, messages: list) -> dict:\n memories = \"\"\n if MEMORY_INDEX.exists():\n content = MEMORY_INDEX.read_text().strip()\n if content:\n memories = content\n return {\n \"enabled_tools\": list(TOOL_HANDLERS.keys()),\n \"workspace\": str(WORKDIR),\n \"memories\": memories,\n }\n```\n\n`enabled_tools` lists actually registered tools. `memories` checks whether `.memory/MEMORY.md` exists. Section loading is based on these fields.\n\n### Putting It Together\n\n```python\ndef agent_loop(messages: list, context: dict):\n system = get_system_prompt(context)\n while True:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages,\n tools=TOOLS, max_tokens=8000)\n # ... tool execution ...\n context = update_context(context, messages)\n system = get_system_prompt(context)\n```\n\nAt the start of each loop iteration, get the system prompt. If context changed, re-assemble; if not, return cached version.\n\n---\n\n## Changes From s09\n\n| Component | Before (s09) | After (s10) |\n|-----------|-------------|-------------|\n| prompt | Hardcoded SYSTEM string | PROMPT_SECTIONS + assemble_system_prompt |\n| caching | None | get_system_prompt (json.dumps detection + cache) |\n| new functions | — | assemble_system_prompt, get_system_prompt, update_context |\n| tools | bash, read_file, write_file (3) | bash, read_file, write_file (3) — unchanged |\n| loop | Uses fixed SYSTEM | Uses get_system_prompt(context) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s10_system_prompt/code.py\n```\n\nWhat to watch for:\n\n1. Output shows which sections were loaded (`[assembled] sections: ...` label)\n2. Cache hits show `[cache hit]` during continued conversation\n3. Creating `.memory/MEMORY.md` makes the memory section appear on the next turn\n\nTry these prompts:\n\n1. `Read the file README.md` (observe the three always-loaded sections)\n2. `Create a file called .memory/MEMORY.md with content \"- [test](test.md) — test memory\"` (write a memory index)\n3. `Read the file code.py` (observe whether the memory section appears)\n\n---\n\n## What's Next\n\nSystem prompts can now be assembled at runtime. But the agent still crashes on errors. Network hiccups, API rate limits, truncated output, context overflow. These are not bugs; they are normal.\n\ns11 Error Recovery → four recovery paths. Upgrade tokens, compress context, exponential backoff, switch models.\n\n
\nDeep Dive Into Claude Code Source Code\n\n> The following is based on analysis of Claude Code source code `constants/prompts.ts` (914 lines), `constants/systemPromptSections.ts` (68 lines), `context.ts` (189 lines), `utils/api.ts` (718 lines), `utils/systemPrompt.ts` (123 lines), and `bootstrap/state.ts`.\n\n### How many sections does Claude Code's system prompt have?\n\nThe count varies based on feature flags, output style, KAIROS/Proactive mode, user type, token budget, etc. Roughly two categories:\n\n**Static sections** (always loaded): identity, system, doing_tasks, actions, using_tools, tone_style, output_efficiency, etc.\n\n**Dynamic sections** (loaded by state): session_guidance, memory, ant_model_override, env_info_simple, language, output_style, mcp_instructions, scratchpad, frc, summarize_tool_results, numeric_length_anchors, token_budget, brief, etc.\n\n`mcp_instructions` is the only volatile section (created via `DANGEROUS_uncachedSystemPromptSection()`), because MCP servers can connect and disconnect between turns.\n\n### Assembly Function\n\n```typescript\ngetSystemPrompt(tools, model, additionalWorkingDirs?, mcpClients?): Promise\n```\n\nReturns `string[]` (each element is a section), separated by `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` between static and dynamic parts.\n\n### cache scope\n\nWhen global cache boundary is enabled, static sections are merged into one global cache block, and dynamic sections don't use global cache (`cacheScope: null`). Only paths without boundary or skipping global cache fall back to org scope.\n\nThe teaching version's cache only avoids redundant string assembly. Claude Code's three-layer cache:\n\n1. **lodash memoize**: `getSystemContext` and `getUserContext` cached per session (`context.ts`)\n2. **Section registry cache**: `STATE.systemPromptSectionCache` caches dynamic section results, cleared on `/clear` or `/compact`\n3. **API-level cache**: `splitSysPromptPrefix()` (`api.ts`) splits prompt into blocks with different cache scopes via boundary\n\n### getUserContext vs getSystemContext\n\n| | getSystemContext | getUserContext |\n|---|---|---|\n| Content | gitStatus, cacheBreaker | CLAUDE.md content, currentDate |\n| Injection | appended to system prompt array | prepended as `` user message |\n| When skipped | custom system prompt | always runs |\n\n### How modes change the prompt\n\n- **CLAUDE_CODE_SIMPLE**: entire prompt is 2 lines\n- **Proactive/KAIROS**: compact prompt replaces all standard sections\n- **Coordinator**: coordinator-specific prompt fully replaces default\n- **Agent mode**: agent-defined prompt replaces or appends to default\n\n### Total size\n\nStandard interactive mode system prompt core is ~20-30KB text. CLAUDE_CODE_SIMPLE is ~150 characters. User context (CLAUDE.md) and system context (git status) add on top.\n\n
\n\n\n" }, { "version": "s10", "locale": "zh", "title": "s10: System Prompt — 运行时组装,不硬编码", - "content": "# s10: System Prompt — 运行时组装,不硬编码\n\ns01 → ... → s08 → s09 → `s10` → [s11](/zh/s11) → s12 → ... → s20\n> *\"prompt 是组装出来的, 不是写死的\"* — 分段 + 按需拼接 + 缓存。\n>\n> **Harness 层**: 提示 — 运行时组装, 不硬编码。\n\n---\n\n## 问题\n\n从 s01 到 s09,system prompt 都是一行硬编码:\n\n```python\nSYSTEM = f\"You are a coding agent at {WORKDIR}. Use tools to solve tasks.\"\n```\n\ns01 够用,只有 bash、read、write 三个工具。但到 s09,Agent 已经有记忆、有压缩、有技能加载。prompt 该提的能力越来越多:\n\n```python\nSYSTEM = (\n f\"You are a coding agent at {WORKDIR}. \"\n \"Use tools to solve tasks. Act, don't explain. \"\n \"Before starting any multi-step task, use todo_write. \"\n \"Skills are available via list_skills and load_skill. \"\n \"Relevant memories are injected below when available. \"\n # ... 加一个能力就多一段\n)\n```\n\n三个问题:\n\n1. **换项目要重写整个 prompt**,不知道哪些该改、哪些该留\n2. **修改一处可能影响全局**,加一段工具描述可能跟前面的指令冲突\n3. **每次请求都带全部内容**,即使当前对话用不到某些段落也浪费 token\n\nSystem prompt 应该是运行时根据当前状态组装的配置:哪些工具启用、哪些上下文可见、哪些记忆相关、哪些内容必须保持稳定以命中 prompt cache。\n\n---\n\n## 解决方案\n\n![System Prompt Overview](/course-assets/s10_system_prompt/system-prompt-overview.svg)\n\ns10 聚焦 prompt 组装机制。以 s08-s09 的能力为背景,但不重复实现压缩和记忆系统。核心变动:把硬编码的 `SYSTEM` 拆成独立段落(section),运行时根据真实状态按需拼接,缓存结果避免重复组装。\n\n四个 section,两种加载策略:\n\n| Section | 加载策略 | 内容 | 判断依据 |\n|---------|---------|------|---------|\n| identity | 始终 | 你是谁、怎么做事 | 始终存在 |\n| tools | 始终 | 可用工具列表 | `enabled_tools` |\n| workspace | 始终 | 工作目录 | 始终存在 |\n| memory | 按需 | 相关记忆内容 | `.memory/MEMORY.md` 是否存在 |\n\n关键设计:section 是否加载取决于真实状态(工具是否存在、文件是否存在),不是消息里的关键词。\n\n---\n\n## 工作原理\n\n### PROMPT_SECTIONS: 分段定义\n\n把一大段字符串拆成字典,每个 key 是一个主题:\n\n```python\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n```\n\n每个 section 独立维护。修改 `tools` 不影响 `identity`,新增 `memory` 不动 `workspace`。\n\n### assemble_system_prompt: 按需拼接\n\n不是所有 section 每次都需要。当前没有记忆文件,加载 memory section 只是浪费 token。根据 context 的真实状态决定加载哪些:\n\n```python\ndef assemble_system_prompt(context: dict) -> str:\n sections = []\n\n # 始终加载\n sections.append(PROMPT_SECTIONS[\"identity\"])\n sections.append(PROMPT_SECTIONS[\"tools\"])\n sections.append(PROMPT_SECTIONS[\"workspace\"])\n\n # 按需加载 — 基于真实状态,不是关键词\n memories = context.get(\"memories\", \"\")\n if memories:\n sections.append(f\"Relevant memories:\\n{memories}\")\n\n return \"\\n\\n\".join(sections)\n```\n\n\"始终加载\"的是每轮都需要的:身份、工具、工作目录。\"按需加载\"的只在特定条件下才有用。\n\n为什么不全加载?token 有成本(system prompt 每轮计费),信息越少 LLM 越专注(无关指令是噪音)。\n\n### get_system_prompt: 缓存避免重复拼接\n\n上下文没变时(同一轮对话的多次 LLM 调用,context 相同),重新拼接是浪费。用确定性序列化检测变化,命中缓存直接返回:\n\n```python\ndef get_system_prompt(context: dict) -> str:\n global _last_context_key, _last_prompt\n key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)\n if key == _last_context_key and _last_prompt:\n return _last_prompt\n _last_context_key = key\n _last_prompt = assemble_system_prompt(context)\n return _last_prompt\n```\n\n用 `json.dumps` 而不是 `hash()`:Python 内置 `hash()` 有进程随机化,不适合做稳定 cache key,而且遇到 list/dict 会报 `unhashable type`。\n\n注意:这里的缓存只是\"避免重复拼接字符串\",和 CC 的 API prompt cache 不是一回事。CC 的 prompt cache 通过 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分隔静态和动态部分,静态部分命中 global cache,不因动态内容变化而失效。\n\n### context: 真实状态,不是关键词猜测\n\ncontext 反映当前运行态的真实状态:\n\n```python\ndef update_context(context: dict, messages: list) -> dict:\n memories = \"\"\n if MEMORY_INDEX.exists():\n content = MEMORY_INDEX.read_text().strip()\n if content:\n memories = content\n return {\n \"enabled_tools\": list(TOOL_HANDLERS.keys()),\n \"workspace\": str(WORKDIR),\n \"memories\": memories,\n }\n```\n\n`enabled_tools` 列出实际注册的工具。`memories` 检查 `.memory/MEMORY.md` 是否存在。section 加载基于这些真实状态,不在消息里搜关键词。\n\n### 合起来跑\n\n```python\ndef agent_loop(messages: list, context: dict):\n system = get_system_prompt(context)\n while True:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages,\n tools=TOOLS, max_tokens=8000)\n # ... 工具执行 ...\n context = update_context(context, messages)\n system = get_system_prompt(context)\n```\n\n每轮循环开头拿一次 system prompt。context 变了就重新组装,没变就返回缓存。\n\n---\n\n## 相对 s09 的变更\n\n| 组件 | 之前 (s09) | 之后 (s10) |\n|------|-----------|-----------|\n| prompt | 硬编码 SYSTEM 字符串 | PROMPT_SECTIONS + assemble_system_prompt |\n| 缓存 | 无 | get_system_prompt(json.dumps 检测 + 缓存) |\n| 新函数 | — | assemble_system_prompt, get_system_prompt, update_context |\n| 工具 | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 不变 |\n| 循环 | 用固定 SYSTEM | 用 get_system_prompt(context) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s10_system_prompt/code.py\n```\n\n观察重点:\n\n1. 输出中能看到哪些 section 被加载了(`[assembled] sections: ...` 标签)\n2. 连续对话时,缓存命中显示 `[cache hit]`\n3. 创建 `.memory/MEMORY.md` 文件后,下一轮 memory section 自动加载\n\n试试这些 prompt:\n\n1. `Read the file README.md`(观察始终加载的三个 section)\n2. `Create a file called .memory/MEMORY.md with content \"- [test](test.md) — test memory\"`(写入记忆索引)\n3. `Read the file code.py`(观察 memory section 是否出现)\n\n---\n\n## 接下来\n\nSystem prompt 可以运行时组装了,但 Agent 碰到错误还是会崩。网络抖动、API 限流、输出被截断、上下文超限,这些不是 bug,是常态。\n\ns11 Error Recovery → 四条恢复路径。升级 token、压缩上下文、指数退避、切换模型。\n\n
\n深入 CC 源码\n\n> 以下基于 CC 源码 `constants/prompts.ts`(914 行)、`constants/systemPromptSections.ts`(68 行)、`context.ts`(189 行)、`utils/api.ts`(718 行)、`utils/systemPrompt.ts`(123 行)、`bootstrap/state.ts` 的分析。\n\n### CC 的 system prompt 有多少 section?\n\n数量不固定,受 feature flag、output style、KAIROS/Proactive 模式、用户类型、token 预算等影响。大致分两类:\n\n**静态 section**(始终加载):identity、system、doing_tasks、actions、using_tools、tone_style、output_efficiency 等。\n\n**动态 section**(按状态加载):session_guidance、memory、ant_model_override、env_info_simple、language、output_style、mcp_instructions、scratchpad、frc、summarize_tool_results、numeric_length_anchors、token_budget、brief 等。\n\n`mcp_instructions` 是唯一的易失性 section(通过 `DANGEROUS_uncachedSystemPromptSection()` 创建),因为 MCP server 可以在轮次间连接和断开。\n\n### 组装函数\n\n```typescript\ngetSystemPrompt(tools, model, additionalWorkingDirs?, mcpClients?): Promise\n```\n\n返回 `string[]`(每个元素是一个 section),由 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分隔静态和动态部分。\n\n### cache scope\n\n启用 global cache boundary 时,静态 section 合并成一个 global cache block,动态 section 不使用 global cache(`cacheScope: null`)。没有 boundary 或跳过 global cache 的路径才会走 org scope。\n\n教学版的缓存只避免重复拼接字符串。CC 的三层缓存:\n\n1. **lodash memoize**:`getSystemContext` 和 `getUserContext` 在会话中缓存(`context.ts`)\n2. **section 注册缓存**:`STATE.systemPromptSectionCache` 缓存动态 section 结果,`/clear` 或 `/compact` 时清除\n3. **API 级缓存**:`splitSysPromptPrefix()`(`api.ts`)把 prompt 按 boundary 分成不同 cache scope 的块\n\n### getUserContext vs getSystemContext\n\n| | getSystemContext | getUserContext |\n|---|---|---|\n| 内容 | gitStatus、cacheBreaker | CLAUDE.md 内容、currentDate |\n| 注入方式 | 追加到 system prompt 数组 | 前置为 `` 用户消息 |\n| 何时跳过 | 自定义 system prompt 时 | 始终运行 |\n\n### 模式如何改变 prompt\n\n- **CLAUDE_CODE_SIMPLE**:整个 prompt 只有 2 行\n- **Proactive/KAIROS**:用紧凑版 prompt 替换所有标准 section\n- **Coordinator**:用协调器专用 prompt 完全替换\n- **Agent 模式**:Agent 定义的 prompt 替换或追加到默认 prompt\n\n### 总大小\n\n标准交互模式下 system prompt 核心约 20-30KB 文本。CLAUDE_CODE_SIMPLE 约 150 字符。用户上下文(CLAUDE.md)和系统上下文(git status)在此基础上累加。\n\n
\n\n\n" + "content": "# s10: System Prompt — 运行时组装,不硬编码\n\ns01 → ... → s08 → s09 → `s10` → [s11](/zh/s11) → s12 → ... → s20\n> *\"prompt 是组装出来的, 不是写死的\"*。分段 + 按需拼接 + 缓存。\n>\n> **Harness 层**: 提示的运行时组装。\n\n---\n\n## 问题\n\n从 s01 到 s09,system prompt 都是一行硬编码:\n\n```python\nSYSTEM = f\"You are a coding agent at {WORKDIR}. Use tools to solve tasks.\"\n```\n\ns01 够用,只有 bash、read、write 三个工具。但到 s09,Agent 已经有记忆、有压缩、有技能加载。prompt 该提的能力越来越多:\n\n```python\nSYSTEM = (\n f\"You are a coding agent at {WORKDIR}. \"\n \"Use tools to solve tasks. Act, don't explain. \"\n \"Before starting any multi-step task, use todo_write. \"\n \"Skills are available via list_skills and load_skill. \"\n \"Relevant memories are injected below when available. \"\n # ... 加一个能力就多一段\n)\n```\n\n三个问题:\n\n1. **换项目要重写整个 prompt**,不知道哪些该改、哪些该留\n2. **修改一处可能影响全局**,加一段工具描述可能跟前面的指令冲突\n3. **每次请求都带全部内容**,即使当前对话用不到某些段落也浪费 token\n\nSystem prompt 应该是运行时根据当前状态组装的配置:哪些工具启用、哪些上下文可见、哪些记忆相关、哪些内容必须保持稳定以命中 prompt cache。\n\n---\n\n## 解决方案\n\n![System Prompt Overview](/course-assets/s10_system_prompt/system-prompt-overview.svg)\n\ns10 聚焦 prompt 组装机制。以 s08-s09 的能力为背景,但不重复实现压缩和记忆系统。核心变动:把硬编码的 `SYSTEM` 拆成独立段落(section),运行时根据真实状态按需拼接,缓存结果避免重复组装。\n\n四个 section,两种加载策略:\n\n| Section | 加载策略 | 内容 | 判断依据 |\n|---------|---------|------|---------|\n| identity | 始终 | 你是谁、怎么做事 | 始终存在 |\n| tools | 始终 | 可用工具列表 | `enabled_tools` |\n| workspace | 始终 | 工作目录 | 始终存在 |\n| memory | 按需 | 相关记忆内容 | `.memory/MEMORY.md` 是否存在 |\n\n---\n\n## 工作原理\n\n### PROMPT_SECTIONS: 分段定义\n\n把一大段字符串拆成字典,每个 key 是一个主题:\n\n```python\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n```\n\n每个 section 独立维护。修改 `tools` 不影响 `identity`,新增 `memory` 不动 `workspace`。\n\n### assemble_system_prompt: 按需拼接\n\n不是所有 section 每次都需要。当前没有记忆文件,加载 memory section 只是浪费 token。根据 context 的真实状态决定加载哪些:\n\n```python\ndef assemble_system_prompt(context: dict) -> str:\n sections = []\n\n # 始终加载\n sections.append(PROMPT_SECTIONS[\"identity\"])\n sections.append(PROMPT_SECTIONS[\"tools\"])\n sections.append(PROMPT_SECTIONS[\"workspace\"])\n\n # 按需加载 — 基于真实状态,不是关键词\n memories = context.get(\"memories\", \"\")\n if memories:\n sections.append(f\"Relevant memories:\\n{memories}\")\n\n return \"\\n\\n\".join(sections)\n```\n\n\"始终加载\"的是每轮都需要的:身份、工具、工作目录。\"按需加载\"的只在特定条件下才有用。\n\n为什么不全加载?token 有成本(system prompt 每轮计费),信息越少 LLM 越专注(无关指令是噪音)。\n\n### get_system_prompt: 缓存避免重复拼接\n\n上下文没变时(同一轮对话的多次 LLM 调用,context 相同),重新拼接是浪费。用确定性序列化检测变化,命中缓存直接返回:\n\n```python\ndef get_system_prompt(context: dict) -> str:\n global _last_context_key, _last_prompt\n key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)\n if key == _last_context_key and _last_prompt:\n return _last_prompt\n _last_context_key = key\n _last_prompt = assemble_system_prompt(context)\n return _last_prompt\n```\n\n用 `json.dumps` 而不是 `hash()`:Python 内置 `hash()` 有进程随机化,不适合做稳定 cache key,而且遇到 list/dict 会报 `unhashable type`。\n\n注意:这里的缓存只是\"避免重复拼接字符串\",和 Claude Code 的 API prompt cache 不是一回事。Claude Code 的 prompt cache 通过 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分隔静态和动态部分,静态部分命中 global cache,不因动态内容变化而失效。\n\n### context: 真实状态,不是关键词猜测\n\ncontext 反映当前运行态:\n\n```python\ndef update_context(context: dict, messages: list) -> dict:\n memories = \"\"\n if MEMORY_INDEX.exists():\n content = MEMORY_INDEX.read_text().strip()\n if content:\n memories = content\n return {\n \"enabled_tools\": list(TOOL_HANDLERS.keys()),\n \"workspace\": str(WORKDIR),\n \"memories\": memories,\n }\n```\n\n`enabled_tools` 列出实际注册的工具。`memories` 检查 `.memory/MEMORY.md` 是否存在。section 加载基于这些字段。\n\n### 合起来跑\n\n```python\ndef agent_loop(messages: list, context: dict):\n system = get_system_prompt(context)\n while True:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages,\n tools=TOOLS, max_tokens=8000)\n # ... 工具执行 ...\n context = update_context(context, messages)\n system = get_system_prompt(context)\n```\n\n每轮循环开头拿一次 system prompt。context 变了就重新组装,没变就返回缓存。\n\n---\n\n## 相对 s09 的变更\n\n| 组件 | 之前 (s09) | 之后 (s10) |\n|------|-----------|-----------|\n| prompt | 硬编码 SYSTEM 字符串 | PROMPT_SECTIONS + assemble_system_prompt |\n| 缓存 | 无 | get_system_prompt(json.dumps 检测 + 缓存) |\n| 新函数 | — | assemble_system_prompt, get_system_prompt, update_context |\n| 工具 | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 不变 |\n| 循环 | 用固定 SYSTEM | 用 get_system_prompt(context) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s10_system_prompt/code.py\n```\n\n观察重点:\n\n1. 输出中能看到哪些 section 被加载了(`[assembled] sections: ...` 标签)\n2. 连续对话时,缓存命中显示 `[cache hit]`\n3. 创建 `.memory/MEMORY.md` 文件后,下一轮 memory section 自动加载\n\n试试这些 prompt:\n\n1. `Read the file README.md`(观察始终加载的三个 section)\n2. `Create a file called .memory/MEMORY.md with content \"- [test](test.md) — test memory\"`(写入记忆索引)\n3. `Read the file code.py`(观察 memory section 是否出现)\n\n---\n\n## 接下来\n\nSystem prompt 可以运行时组装了,但 Agent 碰到错误还是会崩。网络抖动、API 限流、输出被截断、上下文超限,这些不是 bug,是常态。\n\ns11 Error Recovery → 四条恢复路径。升级 token、压缩上下文、指数退避、切换模型。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `constants/prompts.ts`(914 行)、`constants/systemPromptSections.ts`(68 行)、`context.ts`(189 行)、`utils/api.ts`(718 行)、`utils/systemPrompt.ts`(123 行)、`bootstrap/state.ts` 的分析。\n\n### Claude Code 的 system prompt 有多少 section?\n\n数量不固定,受 feature flag、output style、KAIROS/Proactive 模式、用户类型、token 预算等影响。大致分两类:\n\n**静态 section**(始终加载):identity、system、doing_tasks、actions、using_tools、tone_style、output_efficiency 等。\n\n**动态 section**(按状态加载):session_guidance、memory、ant_model_override、env_info_simple、language、output_style、mcp_instructions、scratchpad、frc、summarize_tool_results、numeric_length_anchors、token_budget、brief 等。\n\n`mcp_instructions` 是唯一的易失性 section(通过 `DANGEROUS_uncachedSystemPromptSection()` 创建),因为 MCP server 可以在轮次间连接和断开。\n\n### 组装函数\n\n```typescript\ngetSystemPrompt(tools, model, additionalWorkingDirs?, mcpClients?): Promise\n```\n\n返回 `string[]`(每个元素是一个 section),由 `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 分隔静态和动态部分。\n\n### cache scope\n\n启用 global cache boundary 时,静态 section 合并成一个 global cache block,动态 section 不使用 global cache(`cacheScope: null`)。没有 boundary 或跳过 global cache 的路径才会走 org scope。\n\n教学版的缓存只避免重复拼接字符串。Claude Code 的三层缓存:\n\n1. **lodash memoize**:`getSystemContext` 和 `getUserContext` 在会话中缓存(`context.ts`)\n2. **section 注册缓存**:`STATE.systemPromptSectionCache` 缓存动态 section 结果,`/clear` 或 `/compact` 时清除\n3. **API 级缓存**:`splitSysPromptPrefix()`(`api.ts`)把 prompt 按 boundary 分成不同 cache scope 的块\n\n### getUserContext vs getSystemContext\n\n| | getSystemContext | getUserContext |\n|---|---|---|\n| 内容 | gitStatus、cacheBreaker | CLAUDE.md 内容、currentDate |\n| 注入方式 | 追加到 system prompt 数组 | 前置为 `` 用户消息 |\n| 何时跳过 | 自定义 system prompt 时 | 始终运行 |\n\n### 模式如何改变 prompt\n\n- **CLAUDE_CODE_SIMPLE**:整个 prompt 只有 2 行\n- **Proactive/KAIROS**:用紧凑版 prompt 替换所有标准 section\n- **Coordinator**:用协调器专用 prompt 完全替换\n- **Agent 模式**:Agent 定义的 prompt 替换或追加到默认 prompt\n\n### 总大小\n\n标准交互模式下 system prompt 核心约 20-30KB 文本。CLAUDE_CODE_SIMPLE 约 150 字符。用户上下文(CLAUDE.md)和系统上下文(git status)在此基础上累加。\n\n
\n\n\n" }, { "version": "s10", "locale": "ja", "title": "s10: System Prompt — 実行時アセンブリ、ハードコードなし", - "content": "# s10: System Prompt — 実行時アセンブリ、ハードコードなし\n\ns01 → ... → s08 → s09 → `s10` → [s11](/ja/s11) → s12 → ... → s20\n> *\"prompt は組み立てるもの、固定するものではない\"* — セグメント + オンデマンド結合 + キャッシュ。\n>\n> **Harness レイヤー**: プロンプト — 実行時組み立て、ハードコードなし。\n\n---\n\n## 課題\n\ns01 から s09 まで、system prompt は常に 1 行のハードコード:\n\n```python\nSYSTEM = f\"You are a coding agent at {WORKDIR}. Use tools to solve tasks.\"\n```\n\ns01 では十分だった。bash、read、write の 3 ツールのみ。しかし s09 では、Agent に記憶、圧縮、スキル読み込みがある。prompt が説明すべき能力が増え続ける:\n\n```python\nSYSTEM = (\n f\"You are a coding agent at {WORKDIR}. \"\n \"Use tools to solve tasks. Act, don't explain. \"\n \"Before starting any multi-step task, use todo_write. \"\n \"Skills are available via list_skills and load_skill. \"\n \"Relevant memories are injected below when available. \"\n # ... 能力を追加するたびに 1 行増える\n)\n```\n\n3 つの問題:\n\n1. **プロジェクトを変えるには prompt 全体を書き直す**必要がある。何を変え、何を残すべきか不明\n2. **一箇所の変更が全体に影響する**。ツール説明を追加すると、前の指示と矛盾する可能性\n3. **毎回のリクエストが全内容を送信する**。現在の会話で不要なセクションも token を無駄に消費\n\nSystem prompt は、実行時の現在状態に基づいて組み立てられる設定であるべき:どのツールが有効か、どのコンテキストが可視か、どの記憶が関連するか、どの内容を prompt cache に命中させるために安定させるべきか。\n\n---\n\n## ソリューション\n\n![System Prompt Overview](/course-assets/s10_system_prompt/system-prompt-overview.ja.svg)\n\ns10 は prompt アセンブリ機構に焦点を当てる。s08-s09 の能力を背景とするが、圧縮や記憶システムは再実装しない。核心の変更:ハードコードされた `SYSTEM` を独立セクションに分割し、実行時に実際の状態に基づいてオンデマンドで組み立て、結果をキャッシュして再組み立てを回避。\n\n4 つのセクション、2 つの読み込み戦略:\n\n| セクション | 戦略 | 内容 | 判断基準 |\n|-----------|------|------|---------|\n| identity | 常に | あなたは誰か、どう作業するか | 常に存在 |\n| tools | 常に | 利用可能ツール一覧 | `enabled_tools` |\n| workspace | 常に | 作業ディレクトリ | 常に存在 |\n| memory | オンデマンド | 関連記憶内容 | `.memory/MEMORY.md` が存在するか |\n\n重要な設計:セクションをロードするかどうかは実際の状態(ツールが存在するか、ファイルが存在するか)で決まり、メッセージ内のキーワードではない。\n\n---\n\n## 仕組み\n\n### PROMPT_SECTIONS: トピック別フラグメント\n\n単一の文字列を辞書に分割、各キーがトピック:\n\n```python\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n```\n\n各セクションは独立して管理。`tools` を変更しても `identity` に影響しない。`memory` を追加しても `workspace` はそのまま。\n\n### assemble_system_prompt: オンデマンド組み立て\n\nすべてのセクションが毎ターン必要なわけではない。記憶ファイルがなければ、memory セクションをロードしても token の無駄。context の実際の状態に基づいて組み立てる:\n\n```python\ndef assemble_system_prompt(context: dict) -> str:\n sections = []\n\n # 常にロード\n sections.append(PROMPT_SECTIONS[\"identity\"])\n sections.append(PROMPT_SECTIONS[\"tools\"])\n sections.append(PROMPT_SECTIONS[\"workspace\"])\n\n # オンデマンド — 実際の状態に基づく、キーワードではない\n memories = context.get(\"memories\", \"\")\n if memories:\n sections.append(f\"Relevant memories:\\n{memories}\")\n\n return \"\\n\\n\".join(sections)\n```\n\n「常にロード」は毎ターン必要なもの:アイデンティティ、ツール、作業ディレクトリ。「オンデマンド」は特定条件下でのみ有用。\n\nなぜ全部ロードしないのか?token にはコストがあり(system prompt は毎ターン課金)、情報が少ないほど LLM は集中する(無関係な指示はノイズ)。\n\n### get_system_prompt: キャッシュで再組み立てを回避\n\nコンテキストが変わっていない時(同じターン内で複数の LLM 呼び出し、context が同じ)、再組み立ては無駄。確定的シリアライズで変化を検出し、キャッシュヒット時は即座に返却:\n\n```python\ndef get_system_prompt(context: dict) -> str:\n global _last_context_key, _last_prompt\n key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)\n if key == _last_context_key and _last_prompt:\n return _last_prompt\n _last_context_key = key\n _last_prompt = assemble_system_prompt(context)\n return _last_prompt\n```\n\n`hash()` ではなく `json.dumps` を使用:Python 組み込みの `hash()` にはプロセスランダム化があり(安定したキャッシュキーに不適切)、list/dict で `unhashable type` エラーになる。\n\n注意:このキャッシュは「プロセス内での文字列再組み立ての回避」のみ。CC の API prompt cache とは別物。CC の prompt cache は `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` で静的/動的部分を分離し、静的部分が global cache に命中する。動的内容が変化しても静的部分は無効化されない。\n\n### context: 実際の状態、キーワード推測ではない\n\ncontext は現在の実行時状態の実際の状態を反映:\n\n```python\ndef update_context(context: dict, messages: list) -> dict:\n memories = \"\"\n if MEMORY_INDEX.exists():\n content = MEMORY_INDEX.read_text().strip()\n if content:\n memories = content\n return {\n \"enabled_tools\": list(TOOL_HANDLERS.keys()),\n \"workspace\": str(WORKDIR),\n \"memories\": memories,\n }\n```\n\n`enabled_tools` は実際に登録されたツールを一覧。`memories` は `.memory/MEMORY.md` が存在するかを確認。セクションの読み込みはこの実際の状態に基づき、メッセージ内のキーワード検索ではない。\n\n### 組み合わせて実行\n\n```python\ndef agent_loop(messages: list, context: dict):\n system = get_system_prompt(context)\n while True:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages,\n tools=TOOLS, max_tokens=8000)\n # ... ツール実行 ...\n context = update_context(context, messages)\n system = get_system_prompt(context)\n```\n\n各ループ反復の開始時に system prompt を取得。context が変わっていれば再組み立て、変わっていなければキャッシュを返却。\n\n---\n\n## s09 からの変更点\n\n| コンポーネント | 変更前 (s09) | 変更後 (s10) |\n|-----------|-------------|-------------|\n| prompt | ハードコード SYSTEM 文字列 | PROMPT_SECTIONS + assemble_system_prompt |\n| キャッシュ | なし | get_system_prompt(json.dumps 検出 + キャッシュ) |\n| 新規関数 | — | assemble_system_prompt, get_system_prompt, update_context |\n| ツール | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 変更なし |\n| ループ | 固定 SYSTEM を使用 | get_system_prompt(context) を使用 |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s10_system_prompt/code.py\n```\n\n観察のポイント:\n\n1. 出力にロードされたセクションが表示される(`[assembled] sections: ...` ラベル)\n2. 継続会話でキャッシュヒット時は `[cache hit]` と表示\n3. `.memory/MEMORY.md` を作成すると、次のターンで memory セクションが自動ロード\n\n以下のプロンプトを試してみてください:\n\n1. `Read the file README.md`(常にロードされる 3 つのセクションを観察)\n2. `Create a file called .memory/MEMORY.md with content \"- [test](test.md) — test memory\"`(記憶インデックスを書き込み)\n3. `Read the file code.py`(memory セクションが表示されるか観察)\n\n---\n\n## 次へ\n\nSystem prompt を実行時に組み立てられるようになった。しかし Agent はエラーでまだクラッシュする。ネットワークの不安定性、API レート制限、出力の切り詰め、コンテキスト超過、これらはバグではなく日常。\n\ns11 Error Recovery → 4 つのリカバリパス。token のアップグレード、コンテキスト圧縮、指数バックオフ、モデル切り替え。\n\n
\nCC ソースコードの詳細\n\n> 以下は CC ソースコード `constants/prompts.ts`(914 行)、`constants/systemPromptSections.ts`(68 行)、`context.ts`(189 行)、`utils/api.ts`(718 行)、`utils/systemPrompt.ts`(123 行)、`bootstrap/state.ts` の分析に基づく。\n\n### CC の system prompt にはいくつのセクションがあるか?\n\n数は固定されておらず、feature flag、output style、KAIROS/Proactive モード、ユーザータイプ、token 予算などに影響される。大まかに 2 つのカテゴリ:\n\n**静的セクション**(常にロード):identity、system、doing_tasks、actions、using_tools、tone_style、output_efficiency など。\n\n**動的セクション**(状態に応じてロード):session_guidance、memory、ant_model_override、env_info_simple、language、output_style、mcp_instructions、scratchpad、frc、summarize_tool_results、numeric_length_anchors、token_budget、brief など。\n\n`mcp_instructions` は唯一の揮発性セクション(`DANGEROUS_uncachedSystemPromptSection()` で作成)。MCP server はターン間で接続・切断可能なため。\n\n### 組み立て関数\n\n```typescript\ngetSystemPrompt(tools, model, additionalWorkingDirs?, mcpClients?): Promise\n```\n\n`string[]`(各要素がセクション)を返却。`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` で静的/動的部分を分離。\n\n### cache scope\n\nglobal cache boundary が有効な場合、静的セクションは 1 つの global cache block にマージされ、動的セクションは global cache を使用しない(`cacheScope: null`)。boundary なしまたは global cache をスキップするパスでのみ org scope にフォールバック。\n\n教学版のキャッシュは文字列の再組み立てを回避するのみ。CC の 3 層キャッシュ:\n\n1. **lodash memoize**: `getSystemContext` と `getUserContext` がセッション中キャッシュ(`context.ts`)\n2. **セクション登録キャッシュ**: `STATE.systemPromptSectionCache` が動的セクションの結果をキャッシュ、`/clear` や `/compact` でクリア\n3. **API レベルキャッシュ**: `splitSysPromptPrefix()`(`api.ts`)が boundary を通じて異なる cache scope のブロックに分割\n\n### getUserContext vs getSystemContext\n\n| | getSystemContext | getUserContext |\n|---|---|---|\n| 内容 | gitStatus、cacheBreaker | CLAUDE.md 内容、currentDate |\n| 注入方式 | system prompt 配列に追加 | `` ユーザーメッセージとして先頭に配置 |\n| スキップ条件 | カスタム system prompt 時 | 常に実行 |\n\n### モードによる prompt の変化\n\n- **CLAUDE_CODE_SIMPLE**: prompt 全体が 2 行のみ\n- **Proactive/KAIROS**: コンパクト版 prompt が標準セクション全体を置換\n- **Coordinator**: コーディネータ専用 prompt がデフォルトを完全に置換\n- **Agent モード**: Agent 定義の prompt がデフォルトを置換または追加\n\n### 総サイズ\n\n標準インタラクティブモードの system prompt コアは約 20-30KB テキスト。CLAUDE_CODE_SIMPLE は約 150 文字。ユーザーコンテキスト(CLAUDE.md)とシステムコンテキスト(git status)がこれに加算。\n\n
\n\n\n" + "content": "# s10: System Prompt — 実行時アセンブリ、ハードコードなし\n\ns01 → ... → s08 → s09 → `s10` → [s11](/ja/s11) → s12 → ... → s20\n> *\"prompt は組み立てるもの、固定するものではない\"*。セグメント + オンデマンド結合 + キャッシュ。\n>\n> **Harness レイヤー**: プロンプトの実行時組み立て。\n\n---\n\n## 課題\n\ns01 から s09 まで、system prompt は常に 1 行のハードコード:\n\n```python\nSYSTEM = f\"You are a coding agent at {WORKDIR}. Use tools to solve tasks.\"\n```\n\ns01 では十分だった。bash、read、write の 3 ツールのみ。しかし s09 では、Agent に記憶、圧縮、スキル読み込みがある。prompt が説明すべき能力が増え続ける:\n\n```python\nSYSTEM = (\n f\"You are a coding agent at {WORKDIR}. \"\n \"Use tools to solve tasks. Act, don't explain. \"\n \"Before starting any multi-step task, use todo_write. \"\n \"Skills are available via list_skills and load_skill. \"\n \"Relevant memories are injected below when available. \"\n # ... 能力を追加するたびに 1 行増える\n)\n```\n\n3 つの問題:\n\n1. **プロジェクトを変えるには prompt 全体を書き直す**必要がある。何を変え、何を残すべきか不明\n2. **一箇所の変更が全体に影響する**。ツール説明を追加すると、前の指示と矛盾する可能性\n3. **毎回のリクエストが全内容を送信する**。現在の会話で不要なセクションも token を無駄に消費\n\nSystem prompt は、実行時の現在状態に基づいて組み立てられる設定であるべき:どのツールが有効か、どのコンテキストが可視か、どの記憶が関連するか、どの内容を prompt cache に命中させるために安定させるべきか。\n\n---\n\n## ソリューション\n\n![System Prompt Overview](/course-assets/s10_system_prompt/system-prompt-overview.svg)\n\ns10 は prompt アセンブリ機構に焦点を当てる。s08-s09 の能力を背景とするが、圧縮や記憶システムは再実装しない。核心の変更:ハードコードされた `SYSTEM` を独立セクションに分割し、実行時に実際の状態に基づいてオンデマンドで組み立て、結果をキャッシュして再組み立てを回避。\n\n4 つのセクション、2 つの読み込み戦略:\n\n| セクション | 戦略 | 内容 | 判断基準 |\n|-----------|------|------|---------|\n| identity | 常に | あなたは誰か、どう作業するか | 常に存在 |\n| tools | 常に | 利用可能ツール一覧 | `enabled_tools` |\n| workspace | 常に | 作業ディレクトリ | 常に存在 |\n| memory | オンデマンド | 関連記憶内容 | `.memory/MEMORY.md` が存在するか |\n\n---\n\n## 仕組み\n\n### PROMPT_SECTIONS: トピック別フラグメント\n\n単一の文字列を辞書に分割、各キーがトピック:\n\n```python\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n```\n\n各セクションは独立して管理。`tools` を変更しても `identity` に影響しない。`memory` を追加しても `workspace` はそのまま。\n\n### assemble_system_prompt: オンデマンド組み立て\n\nすべてのセクションが毎ターン必要なわけではない。記憶ファイルがなければ、memory セクションをロードしても token の無駄。context の実際の状態に基づいて組み立てる:\n\n```python\ndef assemble_system_prompt(context: dict) -> str:\n sections = []\n\n # 常にロード\n sections.append(PROMPT_SECTIONS[\"identity\"])\n sections.append(PROMPT_SECTIONS[\"tools\"])\n sections.append(PROMPT_SECTIONS[\"workspace\"])\n\n # オンデマンド — 実際の状態に基づく、キーワードではない\n memories = context.get(\"memories\", \"\")\n if memories:\n sections.append(f\"Relevant memories:\\n{memories}\")\n\n return \"\\n\\n\".join(sections)\n```\n\n「常にロード」は毎ターン必要なもの:アイデンティティ、ツール、作業ディレクトリ。「オンデマンド」は特定条件下でのみ有用。\n\nなぜ全部ロードしないのか?token にはコストがあり(system prompt は毎ターン課金)、情報が少ないほど LLM は集中する(無関係な指示はノイズ)。\n\n### get_system_prompt: キャッシュで再組み立てを回避\n\nコンテキストが変わっていない時(同じターン内で複数の LLM 呼び出し、context が同じ)、再組み立ては無駄。確定的シリアライズで変化を検出し、キャッシュヒット時は即座に返却:\n\n```python\ndef get_system_prompt(context: dict) -> str:\n global _last_context_key, _last_prompt\n key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)\n if key == _last_context_key and _last_prompt:\n return _last_prompt\n _last_context_key = key\n _last_prompt = assemble_system_prompt(context)\n return _last_prompt\n```\n\n`hash()` ではなく `json.dumps` を使用:Python 組み込みの `hash()` にはプロセスランダム化があり(安定したキャッシュキーに不適切)、list/dict で `unhashable type` エラーになる。\n\n注意:このキャッシュは「プロセス内での文字列再組み立ての回避」のみ。Claude Code の API prompt cache とは別物。Claude Code の prompt cache は `SYSTEM_PROMPT_DYNAMIC_BOUNDARY` で静的/動的部分を分離し、静的部分が global cache に命中する。動的内容が変化しても静的部分は無効化されない。\n\n### context: 実際の状態、キーワード推測ではない\n\ncontext は現在の実行時状態を反映:\n\n```python\ndef update_context(context: dict, messages: list) -> dict:\n memories = \"\"\n if MEMORY_INDEX.exists():\n content = MEMORY_INDEX.read_text().strip()\n if content:\n memories = content\n return {\n \"enabled_tools\": list(TOOL_HANDLERS.keys()),\n \"workspace\": str(WORKDIR),\n \"memories\": memories,\n }\n```\n\n`enabled_tools` は実際に登録されたツールを一覧。`memories` は `.memory/MEMORY.md` が存在するかを確認。セクションの読み込みはこれらのフィールドに基づく。\n\n### 組み合わせて実行\n\n```python\ndef agent_loop(messages: list, context: dict):\n system = get_system_prompt(context)\n while True:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages,\n tools=TOOLS, max_tokens=8000)\n # ... ツール実行 ...\n context = update_context(context, messages)\n system = get_system_prompt(context)\n```\n\n各ループ反復の開始時に system prompt を取得。context が変わっていれば再組み立て、変わっていなければキャッシュを返却。\n\n---\n\n## s09 からの変更点\n\n| コンポーネント | 変更前 (s09) | 変更後 (s10) |\n|-----------|-------------|-------------|\n| prompt | ハードコード SYSTEM 文字列 | PROMPT_SECTIONS + assemble_system_prompt |\n| キャッシュ | なし | get_system_prompt(json.dumps 検出 + キャッシュ) |\n| 新規関数 | — | assemble_system_prompt, get_system_prompt, update_context |\n| ツール | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 変更なし |\n| ループ | 固定 SYSTEM を使用 | get_system_prompt(context) を使用 |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s10_system_prompt/code.py\n```\n\n観察のポイント:\n\n1. 出力にロードされたセクションが表示される(`[assembled] sections: ...` ラベル)\n2. 継続会話でキャッシュヒット時は `[cache hit]` と表示\n3. `.memory/MEMORY.md` を作成すると、次のターンで memory セクションが自動ロード\n\n以下のプロンプトを試してみてください:\n\n1. `Read the file README.md`(常にロードされる 3 つのセクションを観察)\n2. `Create a file called .memory/MEMORY.md with content \"- [test](test.md) — test memory\"`(記憶インデックスを書き込み)\n3. `Read the file code.py`(memory セクションが表示されるか観察)\n\n---\n\n## 次へ\n\nSystem prompt を実行時に組み立てられるようになった。しかし Agent はエラーでまだクラッシュする。ネットワークの不安定性、API レート制限、出力の切り詰め、コンテキスト超過、これらはバグではなく日常。\n\ns11 Error Recovery → 4 つのリカバリパス。token のアップグレード、コンテキスト圧縮、指数バックオフ、モデル切り替え。\n\n
\nClaude Code ソースコードの詳細\n\n> 以下は Claude Code ソースコード `constants/prompts.ts`(914 行)、`constants/systemPromptSections.ts`(68 行)、`context.ts`(189 行)、`utils/api.ts`(718 行)、`utils/systemPrompt.ts`(123 行)、`bootstrap/state.ts` の分析に基づく。\n\n### Claude Code の system prompt にはいくつのセクションがあるか?\n\n数は固定されておらず、feature flag、output style、KAIROS/Proactive モード、ユーザータイプ、token 予算などに影響される。大まかに 2 つのカテゴリ:\n\n**静的セクション**(常にロード):identity、system、doing_tasks、actions、using_tools、tone_style、output_efficiency など。\n\n**動的セクション**(状態に応じてロード):session_guidance、memory、ant_model_override、env_info_simple、language、output_style、mcp_instructions、scratchpad、frc、summarize_tool_results、numeric_length_anchors、token_budget、brief など。\n\n`mcp_instructions` は唯一の揮発性セクション(`DANGEROUS_uncachedSystemPromptSection()` で作成)。MCP server はターン間で接続・切断可能なため。\n\n### 組み立て関数\n\n```typescript\ngetSystemPrompt(tools, model, additionalWorkingDirs?, mcpClients?): Promise\n```\n\n`string[]`(各要素がセクション)を返却。`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` で静的/動的部分を分離。\n\n### cache scope\n\nglobal cache boundary が有効な場合、静的セクションは 1 つの global cache block にマージされ、動的セクションは global cache を使用しない(`cacheScope: null`)。boundary なしまたは global cache をスキップするパスでのみ org scope にフォールバック。\n\n教学版のキャッシュは文字列の再組み立てを回避するのみ。Claude Code の 3 層キャッシュ:\n\n1. **lodash memoize**: `getSystemContext` と `getUserContext` がセッション中キャッシュ(`context.ts`)\n2. **セクション登録キャッシュ**: `STATE.systemPromptSectionCache` が動的セクションの結果をキャッシュ、`/clear` や `/compact` でクリア\n3. **API レベルキャッシュ**: `splitSysPromptPrefix()`(`api.ts`)が boundary を通じて異なる cache scope のブロックに分割\n\n### getUserContext vs getSystemContext\n\n| | getSystemContext | getUserContext |\n|---|---|---|\n| 内容 | gitStatus、cacheBreaker | CLAUDE.md 内容、currentDate |\n| 注入方式 | system prompt 配列に追加 | `` ユーザーメッセージとして先頭に配置 |\n| スキップ条件 | カスタム system prompt 時 | 常に実行 |\n\n### モードによる prompt の変化\n\n- **CLAUDE_CODE_SIMPLE**: prompt 全体が 2 行のみ\n- **Proactive/KAIROS**: コンパクト版 prompt が標準セクション全体を置換\n- **Coordinator**: コーディネータ専用 prompt がデフォルトを完全に置換\n- **Agent モード**: Agent 定義の prompt がデフォルトを置換または追加\n\n### 総サイズ\n\n標準インタラクティブモードの system prompt コアは約 20-30KB テキスト。CLAUDE_CODE_SIMPLE は約 150 文字。ユーザーコンテキスト(CLAUDE.md)とシステムコンテキスト(git status)がこれに加算。\n\n
\n\n\n" }, { "version": "s11", "locale": "en", "title": "s11: Error Recovery — Errors aren't the end, they're the start of a retry", - "content": "# s11: Error Recovery — Errors aren't the end, they're the start of a retry\n\ns01 → ... → s09 → s10 → `s11` → [s12](/en/s12) → s13 → ... → s20\n> *\"Errors aren't the end, they're the start of a retry\"* — escalate tokens, compact context, switch models.\n>\n> **Harness layer**: Resilience — classify and recover when the main loop hits errors.\n\n---\n\n## The Problem\n\nThe Agent is running along and then errors out:\n\n```\nError: 529 overloaded\n```\n\nThe Agent crashes. It doesn't retry, doesn't switch models, doesn't reduce context — it just crashes.\n\nIn production, API errors are the norm. The three most common failure modes: **truncated output** (the model runs out of tokens mid-sentence), **context overflow** (still too long even after compaction), and **transient failures** (429 rate limiting / 529 overload). An Agent that doesn't handle errors is like a car that stalls at the slightest touch.\n\n---\n\n## Solution\n\n![Error Recovery Overview](/course-assets/s11_error_recovery/error-recovery-overview.en.svg)\n\nThe loop and prompt assembly from s10 are fully preserved. The only change: the LLM call is wrapped in try/except, with different recovery paths based on error type. After recovery, `continue` loops back to the top to call the LLM again.\n\nThe three most common recovery patterns (the teaching version only handles 429/529; real systems also cover connection errors, timeouts, cloud vendor credential caches, etc. CC actually has 13+ reason codes; see the Deep Dive for the rest):\n\n| Pattern | Trigger | Recovery Action |\n|----------|---------|-----------------|\n| Output truncated | `max_tokens` | Escalate 8K→64K / continuation prompt |\n| Context overflow | `prompt_too_long` | Reactive compact → retry |\n| Transient failure | 429 / 529 | Exponential backoff + jitter, fallback model on consecutive 529 |\n\n---\n\n## How It Works\n\n### Path 1: Output Truncated\n\nThe model runs out of tokens mid-sentence — `max_tokens` is exhausted. The default 8000 tokens isn't enough for a complete response.\n\nOn the first occurrence, escalate `max_tokens` from 8K to 64K (8x the space) and retry the same request — the truncated output is NOT appended to messages, keeping the original request intact. If 64K is still not enough, save the truncated output and inject a continuation prompt telling the model to pick up where it left off, up to 3 times:\n\n```python\nif response.stop_reason == \"max_tokens\":\n # First escalation: don't append truncated output, retry same request\n if not state.has_escalated:\n max_tokens = ESCALATED_MAX_TOKENS\n state.has_escalated = True\n continue # messages unchanged, same request with more tokens\n # 64K still truncated: save output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if state.recovery_count < MAX_RECOVERY_RETRIES:\n messages.append({\"role\": \"user\", \"content\":\n \"Output token limit hit. Resume directly — \"\n \"no apology, no recap. Pick up mid-thought.\"})\n state.recovery_count += 1\n continue\n return # still truncated after 3 continuations\n# Normal: append after max_tokens check\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\n```\n\nEscalation gets one chance; continuation gets up to 3. After that, exit — further continuations won't produce meaningful output.\n\n### Path 2: Context Overflow\n\nThe LLM says \"your context is too long\" (`prompt_too_long`). All four compaction layers from s08 have already run, and it's still over the limit.\n\nTrigger reactive compact — more aggressive than auto compact. The teaching version keeps only the last 5 messages to simulate compaction; real CC generates a compact summary via LLM, then retries with the compacted message list. Retry after compacting. But if it's still over the limit after one compaction, the only option is to exit — compacting again won't make it any smaller:\n\n```python\nexcept PromptTooLongError:\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n return # Already compacted and still over limit — must exit\n```\n\n### Path 3: Transient Failures\n\nNetwork blips, 429 rate limiting, 529 overload — these aren't bugs, they're normal in distributed systems.\n\nBoth 429 and 529 use exponential backoff + jitter: wait 0.5 seconds on the first attempt, 1 second on the second, 2 seconds on the third, up to 10 retries. Random jitter prevents concurrent requests from all retrying at the same instant. Three consecutive 529 overload errors → switch to the fallback model (if `FALLBACK_MODEL_ID` environment variable is configured):\n\n```python\ndef retry_delay(attempt, retry_after=None):\n if retry_after:\n return retry_after\n base = min(500 * (2 ** attempt), 32000) / 1000\n return base + random.uniform(0, base * 0.25)\n\ndef with_retry(fn, state, max_retries=10):\n for attempt in range(max_retries):\n try:\n return fn()\n except (RateLimitError, OverloadedError):\n delay = retry_delay(attempt)\n time.sleep(delay)\n if is_overloaded:\n state.consecutive_529 += 1\n if state.consecutive_529 >= 3 and FALLBACK_MODEL:\n state.current_model = FALLBACK_MODEL\n raise MaxRetriesExceeded()\n```\n\nBackoff formula: `min(500 × 2^attempt, 32000) + random(0~25%)`. If the server returns a `Retry-After` header, that value takes priority.\n\n### Putting It All Together\n\n```python\ndef agent_loop(messages, context):\n system = get_system_prompt(context)\n state = RecoveryState()\n max_tokens = 8000\n\n while True:\n try:\n response = with_retry(\n lambda: client.messages.create(\n model=state.current_model, system=system,\n messages=messages, tools=TOOLS,\n max_tokens=max_tokens),\n state)\n except Exception as e:\n if is_prompt_too_long_error(e):\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n return\n log_error(e)\n return\n\n # max_tokens check BEFORE appending to messages\n if response.stop_reason == \"max_tokens\":\n if not state.has_escalated:\n max_tokens = 64000\n state.has_escalated = True\n continue # retry same request, messages unchanged\n # save truncated output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n messages.append({\"role\": \"user\", \"content\": CONTINUATION_PROMPT})\n continue\n # Normal completion\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n # ... tool execution ...\n```\n\nThe outer try/except catches API exceptions (prompt_too_long, etc.), `with_retry` handles transient errors (429/529), and `stop_reason` checks handle truncation. Three recovery mechanisms, each handling its own error type.\n\n---\n\n## Changes from s10\n\n| Component | Before (s10) | After (s11) |\n|-----------|-------------|-------------|\n| Error handling | None (crashes on any error) | Three recovery patterns + exponential backoff |\n| New constants | — | ESCALATED_MAX_TOKENS=64000, MAX_RETRIES=10, BASE_DELAY_MS=500, FALLBACK_MODEL |\n| New functions | — | with_retry, retry_delay, reactive_compact, is_prompt_too_long_error, RecoveryState |\n| Tools | bash, read_file, write_file (3) | bash, read_file, write_file (3) — unchanged |\n| Loop | Bare LLM call | Wrapped in try/except + continue retry |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s11_error_recovery/code.py\n```\n\nTry these prompts:\n\n1. Ask the Agent to generate a very long piece of code, and observe whether it automatically continues after truncation (look for the `[max_tokens] escalating` log)\n2. Read many files consecutively to bloat the context, and observe reactive compact\n3. If you encounter 429/529, observe the exponential backoff log output\n\n---\n\n## What's Next\n\nThe Agent can now automatically recover from errors. But the tasks it handles are still one-shot — you give it a task, it finishes, it's done.\n\nWhat if the Agent could manage a **task list** — with dependencies, persisted to disk, resumable across sessions? A TODO list is not a task system.\n\ns12 Task System → Tasks form a dependency graph with state and persistence. This is the foundation for multi-Agent collaboration.\n\n
\nDeep Dive into CC Source\n\n> The following is based on CC source code: `query.ts` (1729 lines), `services/api/withRetry.ts` (822 lines), `query/tokenBudget.ts` (93 lines), and `utils/tokenBudget.ts` (73 lines).\n\n### 1. A Dozen-Plus Reason/Transition Codes (Not Just 3)\n\nThe teaching version covers 3 of the most common recovery patterns. CC actually has a dozen-plus reason/transition codes, evaluated after every LLM call:\n\n| Reason/Transition | Teaching Version | CC Behavior |\n|---|---|---|\n| `completed` | Normal completion | Return result |\n| `next_turn` | Normal tool call | Continue to next tool execution round |\n| `max_output_tokens_escalate` | Path 1 | 8K→64K escalation |\n| `max_output_tokens_recovery` | Path 1 continuation | Continuation prompt (up to 3 times) |\n| `reactive_compact_retry` | Path 2 | Reactive compact → retry |\n| `prompt_too_long` | Path 2 | Same as above |\n| `collapse_drain_retry` | Not covered | Context collapse — commit staged content first |\n| `model_error` | Not covered | Retry |\n| `image_error` | Not covered | `ImageSizeError` / `ImageResizeError` handled specifically |\n| `aborted_streaming` | Not covered | Streaming abort recovery |\n| `aborted_tools` | Not covered | Tool abort |\n| `stop_hook_blocking` | Not covered | Inject blocking error → model self-corrects |\n| `stop_hook_prevented` | Not covered | Hooks prevent execution |\n| `hook_stopped` | Not covered | Hook stopped execution |\n| `token_budget_continuation` | Not covered | Continue when token usage < 90% |\n| `blocking_limit` | Not covered | Blocking limit reached |\n| `max_turns` | Not covered | Maximum turns reached |\n\nThe teaching version only expands on the first 5 (most common); each of the rest has its own dedicated handling logic.\n\n### 2. Precise Exponential Backoff Formula\n\nCC's backoff delay (`withRetry.ts:530-548`):\n\n```\ndelay = min(500 × 2^(attempt-1), 32000) + random(0~25%)\n```\n\n| Attempt | Base Delay | + Jitter |\n|---------|-----------|----------|\n| 1 | 500ms | 0-125ms |\n| 2 | 1000ms | 0-250ms |\n| 4 | 4000ms | 0-1000ms |\n| 7+ | 32000ms (cap) | 0-8000ms |\n\nIf the server returns a `Retry-After` header, that value takes priority.\n\n### 3. Original CONTINUATION Prompt\n\nCC's continuation prompt (`query.ts:1225-1227`):\n\n```\nOutput token limit hit. Resume directly — no apology, no recap of what\nyou were doing. Pick up mid-thought if that is where the cut happened.\nBreak remaining work into smaller pieces.\n```\n\nToken budget nudge prompt (`tokenBudget.ts:72`):\n\n```\nStopped at {pct}% of token target. Keep working — do not summarize.\n```\n\n### 4. Streaming Error Handling\n\nIn CC's streaming path, recoverable errors (413, max_tokens, media errors) are **withheld from display** during streaming (`query.ts:788-822`) — SDK consumers don't see them, only the recovery logic does. After streaming ends, the system determines whether recovery is needed.\n\n### 5. 529 → Fallback Model Switch\n\nAfter 3 consecutive 529 overload errors (`MAX_529_RETRIES = 3`), CC automatically switches to the fallback model (e.g., Opus → Sonnet). On switch, all pending messages and tool results are cleared, and the user sees \"Switched to {model} due to high demand\".\n\n### 6. Diminishing Returns Detection\n\nToken budget \"continuations\" aren't unlimited. When there are 3 consecutive continuations with a token increment < 500, the system determines \"continuing won't produce meaningful output\" and stops continuation (`tokenBudget.ts:60-62`).\n\n
\n\n\n" + "content": "# s11: Error Recovery — Errors aren't the end, they're the start of a retry\n\ns01 → ... → s09 → s10 → `s11` → [s12](/en/s12) → s13 → ... → s20\n> *\"Errors aren't the end, they're the start of a retry\"* — escalate tokens, compact context, switch models.\n>\n> **Harness layer**: Resilience — classify and recover when the main loop hits errors.\n\n---\n\n## The Problem\n\nThe Agent is running along and then errors out:\n\n```\nError: 529 overloaded\n```\n\nThe Agent crashes. It doesn't retry, doesn't switch models, doesn't reduce context. It just crashes.\n\nIn production, API errors are the norm. The three most common failure modes: **truncated output** (the model runs out of tokens mid-sentence), **context overflow** (still too long even after compaction), and **transient failures** (429 rate limiting / 529 overload). An Agent that doesn't handle errors is like a car that stalls at the slightest touch.\n\n---\n\n## Solution\n\n![Error Recovery Overview](/course-assets/s11_error_recovery/error-recovery-overview.svg)\n\nThe loop and prompt assembly from s10 are fully preserved. The only change: the LLM call is wrapped in try/except, with different recovery paths based on error type. After recovery, `continue` loops back to the top to call the LLM again.\n\nThe three most common recovery patterns (the teaching version only handles 429/529; real systems also cover connection errors, timeouts, cloud vendor credential caches, etc. Claude Code actually has 13+ reason codes; see the Deep Dive for the rest):\n\n| Pattern | Trigger | Recovery Action |\n|----------|---------|-----------------|\n| Output truncated | `max_tokens` | Escalate 8K→64K / continuation prompt |\n| Context overflow | `prompt_too_long` | Reactive compact → retry |\n| Transient failure | 429 / 529 | Exponential backoff + jitter, fallback model on consecutive 529 |\n\n---\n\n## How It Works\n\n### Path 1: Output Truncated\n\nThe model runs out of tokens mid-sentence. `max_tokens` is exhausted. The default 8000 tokens isn't enough for a complete response.\n\nOn the first occurrence, escalate `max_tokens` from 8K to 64K (8x the space) and retry the same request. The truncated output is NOT appended to messages, keeping the original request intact. If 64K is still not enough, save the truncated output and inject a continuation prompt telling the model to pick up where it left off, up to 3 times:\n\n```python\nif response.stop_reason == \"max_tokens\":\n # First escalation: don't append truncated output, retry same request\n if not state.has_escalated:\n max_tokens = ESCALATED_MAX_TOKENS\n state.has_escalated = True\n continue # messages unchanged, same request with more tokens\n # 64K still truncated: save output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if state.recovery_count < MAX_RECOVERY_RETRIES:\n messages.append({\"role\": \"user\", \"content\":\n \"Output token limit hit. Resume directly — \"\n \"no apology, no recap. Pick up mid-thought.\"})\n state.recovery_count += 1\n continue\n return # still truncated after 3 continuations\n# Normal: append after max_tokens check\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\n```\n\nEscalation gets one chance; continuation gets up to 3. After that, exit. Further continuations won't produce meaningful output.\n\n### Path 2: Context Overflow\n\nThe LLM says \"your context is too long\" (`prompt_too_long`). All four compaction layers from s08 have already run, and it's still over the limit.\n\nTrigger reactive compact, which is more aggressive than auto compact. The teaching version keeps only the last 5 messages to simulate compaction; real Claude Code generates a compact summary via LLM, then retries with the compacted message list. Retry after compacting. But if it's still over the limit after one compaction, the only option is to exit. Compacting again won't make it any smaller:\n\n```python\nexcept PromptTooLongError:\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n return # Already compacted and still over limit — must exit\n```\n\n### Path 3: Transient Failures\n\nNetwork blips, 429 rate limiting, 529 overload. These aren't bugs; they're normal in distributed systems.\n\nBoth 429 and 529 use exponential backoff + jitter: wait 0.5 seconds on the first attempt, 1 second on the second, 2 seconds on the third, up to 10 retries. Random jitter prevents concurrent requests from all retrying at the same instant. Three consecutive 529 overload errors → switch to the fallback model (if `FALLBACK_MODEL_ID` environment variable is configured):\n\n```python\ndef retry_delay(attempt, retry_after=None):\n if retry_after:\n return retry_after\n base = min(500 * (2 ** attempt), 32000) / 1000\n return base + random.uniform(0, base * 0.25)\n\ndef with_retry(fn, state, max_retries=10):\n for attempt in range(max_retries):\n try:\n return fn()\n except (RateLimitError, OverloadedError):\n delay = retry_delay(attempt)\n time.sleep(delay)\n if is_overloaded:\n state.consecutive_529 += 1\n if state.consecutive_529 >= 3 and FALLBACK_MODEL:\n state.current_model = FALLBACK_MODEL\n raise MaxRetriesExceeded()\n```\n\nBackoff formula: `min(500 × 2^attempt, 32000) + random(0~25%)`. If the server returns a `Retry-After` header, that value takes priority.\n\n### Putting It All Together\n\n```python\ndef agent_loop(messages, context):\n system = get_system_prompt(context)\n state = RecoveryState()\n max_tokens = 8000\n\n while True:\n try:\n response = with_retry(\n lambda: client.messages.create(\n model=state.current_model, system=system,\n messages=messages, tools=TOOLS,\n max_tokens=max_tokens),\n state)\n except Exception as e:\n if is_prompt_too_long_error(e):\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n return\n log_error(e)\n return\n\n # max_tokens check BEFORE appending to messages\n if response.stop_reason == \"max_tokens\":\n if not state.has_escalated:\n max_tokens = 64000\n state.has_escalated = True\n continue # retry same request, messages unchanged\n # save truncated output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n messages.append({\"role\": \"user\", \"content\": CONTINUATION_PROMPT})\n continue\n # Normal completion\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n # ... tool execution ...\n```\n\nThe outer try/except catches API exceptions (prompt_too_long, etc.), `with_retry` handles transient errors (429/529), and `stop_reason` checks handle truncation. Three recovery mechanisms, each handling its own error type.\n\n---\n\n## Changes from s10\n\n| Component | Before (s10) | After (s11) |\n|-----------|-------------|-------------|\n| Error handling | None (crashes on any error) | Three recovery patterns + exponential backoff |\n| New constants | — | ESCALATED_MAX_TOKENS=64000, MAX_RETRIES=10, BASE_DELAY_MS=500, FALLBACK_MODEL |\n| New functions | — | with_retry, retry_delay, reactive_compact, is_prompt_too_long_error, RecoveryState |\n| Tools | bash, read_file, write_file (3) | bash, read_file, write_file (3) — unchanged |\n| Loop | Bare LLM call | Wrapped in try/except + continue retry |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s11_error_recovery/code.py\n```\n\nTry these prompts:\n\n1. Ask the Agent to generate a very long piece of code, and observe whether it automatically continues after truncation (look for the `[max_tokens] escalating` log)\n2. Read many files consecutively to bloat the context, and observe reactive compact\n3. If you encounter 429/529, observe the exponential backoff log output\n\n---\n\n## What's Next\n\nThe Agent can now automatically recover from errors. But the tasks it handles are still one-shot — you give it a task, it finishes, it's done.\n\nWhat if the Agent could manage a **task list** — with dependencies, persisted to disk, resumable across sessions? A TODO list is not a task system.\n\ns12 Task System → Tasks form a dependency graph with state and persistence. This is the foundation for multi-Agent collaboration.\n\n
\nDeep Dive into Claude Code Source\n\n> The following is based on Claude Code source code: `query.ts` (1729 lines), `services/api/withRetry.ts` (822 lines), `query/tokenBudget.ts` (93 lines), and `utils/tokenBudget.ts` (73 lines).\n\n### 1. A Dozen-Plus Reason/Transition Codes (Not Just 3)\n\nThe teaching version covers 3 of the most common recovery patterns. Claude Code actually has a dozen-plus reason/transition codes, evaluated after every LLM call:\n\n| Reason/Transition | Teaching Version | Claude Code Behavior |\n|---|---|---|\n| `completed` | Normal completion | Return result |\n| `next_turn` | Normal tool call | Continue to next tool execution round |\n| `max_output_tokens_escalate` | Path 1 | 8K→64K escalation |\n| `max_output_tokens_recovery` | Path 1 continuation | Continuation prompt (up to 3 times) |\n| `reactive_compact_retry` | Path 2 | Reactive compact → retry |\n| `prompt_too_long` | Path 2 | Same as above |\n| `collapse_drain_retry` | Not covered | Context collapse — commit staged content first |\n| `model_error` | Not covered | Retry |\n| `image_error` | Not covered | `ImageSizeError` / `ImageResizeError` handled specifically |\n| `aborted_streaming` | Not covered | Streaming abort recovery |\n| `aborted_tools` | Not covered | Tool abort |\n| `stop_hook_blocking` | Not covered | Inject blocking error → model self-corrects |\n| `stop_hook_prevented` | Not covered | Hooks prevent execution |\n| `hook_stopped` | Not covered | Hook stopped execution |\n| `token_budget_continuation` | Not covered | Continue when token usage < 90% |\n| `blocking_limit` | Not covered | Blocking limit reached |\n| `max_turns` | Not covered | Maximum turns reached |\n\nThe teaching version only expands on the first 5 (most common); each of the rest has its own dedicated handling logic.\n\n### 2. Precise Exponential Backoff Formula\n\nClaude Code's backoff delay (`withRetry.ts:530-548`):\n\n```\ndelay = min(500 × 2^(attempt-1), 32000) + random(0~25%)\n```\n\n| Attempt | Base Delay | + Jitter |\n|---------|-----------|----------|\n| 1 | 500ms | 0-125ms |\n| 2 | 1000ms | 0-250ms |\n| 4 | 4000ms | 0-1000ms |\n| 7+ | 32000ms (cap) | 0-8000ms |\n\nIf the server returns a `Retry-After` header, that value takes priority.\n\n### 3. Original CONTINUATION Prompt\n\nClaude Code's continuation prompt (`query.ts:1225-1227`):\n\n```\nOutput token limit hit. Resume directly — no apology, no recap of what\nyou were doing. Pick up mid-thought if that is where the cut happened.\nBreak remaining work into smaller pieces.\n```\n\nToken budget nudge prompt (`tokenBudget.ts:72`):\n\n```\nStopped at {pct}% of token target. Keep working — do not summarize.\n```\n\n### 4. Streaming Error Handling\n\nIn Claude Code's streaming path, recoverable errors (413, max_tokens, media errors) are **withheld from display** during streaming (`query.ts:788-822`) — SDK consumers don't see them, only the recovery logic does. After streaming ends, the system determines whether recovery is needed.\n\n### 5. 529 → Fallback Model Switch\n\nAfter 3 consecutive 529 overload errors (`MAX_529_RETRIES = 3`), Claude Code automatically switches to the fallback model (e.g., Opus → Sonnet). On switch, all pending messages and tool results are cleared, and the user sees \"Switched to {model} due to high demand\".\n\n### 6. Diminishing Returns Detection\n\nToken budget \"continuations\" aren't unlimited. When there are 3 consecutive continuations with a token increment < 500, the system determines \"continuing won't produce meaningful output\" and stops continuation (`tokenBudget.ts:60-62`).\n\n
\n\n\n" }, { "version": "s11", "locale": "zh", "title": "s11: Error Recovery — 错误不是结束,是重试的开始", - "content": "# s11: Error Recovery — 错误不是结束,是重试的开始\n\ns01 → ... → s09 → s10 → `s11` → [s12](/zh/s12) → s13 → ... → s20\n> *\"错误不是终点, 是重试的起点\"* — 升级 token、压缩上下文、切换模型。\n>\n> **Harness 层**: 韧性 — 主循环遇到错误时分类并恢复。\n\n---\n\n## 问题\n\nAgent 跑着跑着报错了:\n\n```\nError: 529 overloaded\n```\n\nAgent 崩溃了。它没有重试,没有换模型,没有减少上下文——直接崩溃。\n\n生产环境中 API 错误是常态。三种最常见的故障模式:**输出被截断**(模型话说一半 token 用完了)、**上下文超限**(压缩后还是太长)、**临时故障**(429 限流 / 529 过载)。一个不处理错误的 Agent 就像一个一碰就熄火的车。\n\n---\n\n## 解决方案\n\n![Error Recovery Overview](/course-assets/s11_error_recovery/error-recovery-overview.svg)\n\ns10 的循环、prompt 组装全部保留。唯一的变动:LLM 调用包裹在 try/except 里,根据错误类型走不同的恢复路径。恢复后 `continue` 回到循环开头重新调用 LLM。\n\n三种最常见的恢复模式(教学版只处理 429/529;真实系统还覆盖连接错误、超时、云厂商认证缓存等。CC 实际有 13+ reason code,其余见 Deep dive):\n\n| 模式 | 触发 | 恢复动作 |\n|------|------|---------|\n| 输出截断 | `max_tokens` | 升级 8K→64K / 续写提示 |\n| 上下文超限 | `prompt_too_long` | reactive compact → 重试 |\n| 临时故障 | 429 / 529 | 指数退避 + 抖动,连续 529 可切换备用模型 |\n\n---\n\n## 工作原理\n\n### 路径 1: 输出被截断\n\n模型话说一半,`max_tokens` 用完了。默认 8000 token 不够它输出完整回答。\n\n第一次发生时,直接把 `max_tokens` 从 8K 升级到 64K(8 倍空间),重试同一请求——此时不追加截断输出到 messages,保持原始请求不变。如果 64K 还是不够,才保存截断输出并注入续写提示让模型接着刚才的话继续说,最多 3 次:\n\n```python\nif response.stop_reason == \"max_tokens\":\n # First escalation: don't append truncated output, retry same request\n if not state.has_escalated:\n max_tokens = ESCALATED_MAX_TOKENS\n state.has_escalated = True\n continue # messages unchanged, same request with more tokens\n # 64K still truncated: save output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if state.recovery_count < MAX_RECOVERY_RETRIES:\n messages.append({\"role\": \"user\", \"content\":\n \"Output token limit hit. Resume directly — \"\n \"no apology, no recap. Pick up mid-thought.\"})\n state.recovery_count += 1\n continue\n return # still truncated after 3 continuations\n# Normal: append after max_tokens check\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\n```\n\n升级只有一次机会,续写最多 3 次。超过就退出——继续续写也不会有实质产出。\n\n### 路径 2: 上下文超限\n\nLLM 说\"你的上下文太长了\"(`prompt_too_long`)。s08 的四层压缩全跑过了,还是超。\n\n触发 reactive compact——比 auto compact 更激进。教学版只保留最后 5 条消息模拟压缩效果;真实实现会调用 LLM 生成 compact 摘要再重试。压缩后重试。但如果压缩过一次还是超限,只能退出——再压缩也不会变小:\n\n```python\nexcept PromptTooLongError:\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n return # 压缩过了还是超限,只能退出\n```\n\n### 路径 3: 临时故障\n\n网络抖动、429 限流、529 过载——这些不是 bug,是分布式系统的常态。\n\n429 和 529 统一走指数退避 + 抖动:第一次等 0.5 秒,第二次等 1 秒,第三次等 2 秒,最多 10 次。加随机抖动让并发请求不在同一时刻重试。连续 3 次 529 过载 → 切换到备用模型(若配置了 `FALLBACK_MODEL_ID` 环境变量):\n\n```python\ndef retry_delay(attempt, retry_after=None):\n if retry_after:\n return retry_after\n base = min(500 * (2 ** attempt), 32000) / 1000\n return base + random.uniform(0, base * 0.25)\n\ndef with_retry(fn, state, max_retries=10):\n for attempt in range(max_retries):\n try:\n return fn()\n except (RateLimitError, OverloadedError):\n delay = retry_delay(attempt)\n time.sleep(delay)\n if is_overloaded:\n state.consecutive_529 += 1\n if state.consecutive_529 >= 3 and FALLBACK_MODEL:\n state.current_model = FALLBACK_MODEL\n raise MaxRetriesExceeded()\n```\n\n退避公式:`min(500 × 2^attempt, 32000) + random(0~25%)`。如果服务器返回 `Retry-After` header,优先用那个值。\n\n### 合起来跑\n\n```python\ndef agent_loop(messages, context):\n system = get_system_prompt(context)\n state = RecoveryState()\n max_tokens = 8000\n\n while True:\n try:\n response = with_retry(\n lambda: client.messages.create(\n model=state.current_model, system=system,\n messages=messages, tools=TOOLS,\n max_tokens=max_tokens),\n state)\n except Exception as e:\n if is_prompt_too_long_error(e):\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n return\n log_error(e)\n return\n\n # max_tokens check BEFORE appending to messages\n if response.stop_reason == \"max_tokens\":\n if not state.has_escalated:\n max_tokens = 64000\n state.has_escalated = True\n continue # retry same request, messages unchanged\n # save truncated output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n messages.append({\"role\": \"user\", \"content\": CONTINUATION_PROMPT})\n continue\n # Normal completion\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n # ... tool execution ...\n```\n\n外层 try/except 捕获 API 异常(prompt_too_long 等),`with_retry` 处理瞬态错误(429/529),`stop_reason` 检查处理截断。三种恢复机制各管各的错误类型。\n\n---\n\n## 相对 s10 的变更\n\n| 组件 | 之前 (s10) | 之后 (s11) |\n|------|-----------|-----------|\n| 错误处理 | 无(一碰就崩溃) | 三种恢复模式 + 指数退避 |\n| 新常量 | — | ESCALATED_MAX_TOKENS=64000, MAX_RETRIES=10, BASE_DELAY_MS=500, FALLBACK_MODEL |\n| 新函数 | — | with_retry, retry_delay, reactive_compact, is_prompt_too_long_error, RecoveryState |\n| 工具 | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 不变 |\n| 循环 | 裸调用 LLM | try/except 包裹 + continue 重试 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s11_error_recovery/code.py\n```\n\n试试这些 prompt:\n\n1. 让 Agent 生成一段很长的代码,观察截断后是否自动续写(看 `[max_tokens] escalating` 日志)\n2. 连续读取大量文件撑大上下文,观察 reactive compact\n3. 如果遇到 429/529,观察指数退避的日志输出\n\n---\n\n## 接下来\n\nAgent 现在能在错误中自动恢复了。但它处理的任务仍然是\"一次性\"的——你给它一个任务,它做完,结束。\n\n能不能让 Agent 管理一个**任务列表**——有依赖关系、持久化到磁盘、跨会话能恢复?TODO 列表不是任务系统。\n\ns12 Task System → 任务是有依赖、有状态、持久化的图。这是多 Agent 协作的基础。\n\n
\n深入 CC 源码\n\n> 以下基于 CC 源码 `query.ts`(1729 行)、`services/api/withRetry.ts`(822 行)、`query/tokenBudget.ts`(93 行)、`utils/tokenBudget.ts`(73 行)的分析。\n\n### 一、十几种 reason/transition(不只是 3 条)\n\n教学版讲了 3 种最常见的恢复模式。CC 实际有十几种 reason/transition,每轮 LLM 调用后都会判断:\n\n| reason/transition | 教学版对应 | CC 行为 |\n|---|---|---|\n| `completed` | 正常完成 | 返回结果 |\n| `next_turn` | 正常工具调用 | 继续下一轮工具执行 |\n| `max_output_tokens_escalate` | 路径 1 | 8K→64K 升级 |\n| `max_output_tokens_recovery` | 路径 1 续写 | 续写提示(最多 3 次) |\n| `reactive_compact_retry` | 路径 2 | reactive compact → 重试 |\n| `prompt_too_long` | 路径 2 | 同上 |\n| `collapse_drain_retry` | 未展开 | context collapse 先提交暂存 |\n| `model_error` | 未展开 | 重试 |\n| `image_error` | 未展开 | `ImageSizeError` / `ImageResizeError` 专门处理 |\n| `aborted_streaming` | 未展开 | 流式中止恢复 |\n| `aborted_tools` | 未展开 | 工具中止 |\n| `stop_hook_blocking` | 未展开 | 注入 blocking error → 模型自纠 |\n| `stop_hook_prevented` | 未展开 | hooks 阻止 |\n| `hook_stopped` | 未展开 | hook 停止执行 |\n| `token_budget_continuation` | 未展开 | token 用量 < 90% 时继续 |\n| `blocking_limit` | 未展开 | 阻塞限制 |\n| `max_turns` | 未展开 | 达到最大轮次 |\n\n教学版只展开了前 5 种(最常见的),其余各有专门处理逻辑。\n\n### 二、指数退避的精确公式\n\nCC 的退避延迟(`withRetry.ts:530-548`):\n\n```\ndelay = min(500 × 2^(attempt-1), 32000) + random(0~25%)\n```\n\n| 尝试 | 基础延迟 | + 抖动 |\n|------|---------|--------|\n| 1 | 500ms | 0-125ms |\n| 2 | 1000ms | 0-250ms |\n| 4 | 4000ms | 0-1000ms |\n| 7+ | 32000ms(上限) | 0-8000ms |\n\n如果服务器返回 `Retry-After` header,优先用那个值。\n\n### 三、CONTINUATION 提示原文\n\nCC 的续写提示(`query.ts:1225-1227`):\n\n```\nOutput token limit hit. Resume directly — no apology, no recap of what\nyou were doing. Pick up mid-thought if that is where the cut happened.\nBreak remaining work into smaller pieces.\n```\n\nToken budget 的 nudge 提示(`tokenBudget.ts:72`):\n\n```\nStopped at {pct}% of token target. Keep working — do not summarize.\n```\n\n### 四、流式错误处理\n\nCC 的流式路径中,可恢复的错误(413、max_tokens、media error)在 streaming 期间**被暂扣不展示**(`query.ts:788-822`)——SDK 消费者看不到,只有恢复逻辑能看到。等 streaming 结束后才判断是否需要恢复。\n\n### 五、529 → Fallback Model 切换\n\n连续 3 次 529 过载错误后(`MAX_529_RETRIES = 3`),CC 自动切换到 fallback model(如 Opus → Sonnet)。切换时清除所有 pending 消息和 tool 结果,给用户展示 \"Switched to {model} due to high demand\"。\n\n### 六、Diminishing Returns 检测\n\nToken budget 的\"继续\"不是无限的。当连续 3 次 continuation 且 token 增量 < 500 时,系统判断\"继续也没有实质性产出\",停止 continuation(`tokenBudget.ts:60-62`)。\n\n
\n\n\n" + "content": "# s11: Error Recovery — 错误不是结束,是重试的开始\n\ns01 → ... → s09 → s10 → `s11` → [s12](/zh/s12) → s13 → ... → s20\n> *\"错误不是终点, 是重试的起点\"* — 升级 token、压缩上下文、切换模型。\n>\n> **Harness 层**: 韧性 — 主循环遇到错误时分类并恢复。\n\n---\n\n## 问题\n\nAgent 跑着跑着报错了:\n\n```\nError: 529 overloaded\n```\n\nAgent 崩溃了。没有重试,没有换模型,没有减少上下文,直接崩溃。\n\n生产环境中 API 错误是常态。三种最常见的故障模式:**输出被截断**(模型输出到一半 token 用完了)、**上下文超限**(压缩后还是太长)、**临时故障**(429 限流 / 529 过载)。一个不处理错误的 Agent 就像一个一碰就熄火的车。\n\n---\n\n## 解决方案\n\n![Error Recovery Overview](/course-assets/s11_error_recovery/error-recovery-overview.svg)\n\ns10 的循环、prompt 组装全部保留。唯一的变动:LLM 调用包裹在 try/except 里,根据错误类型走不同的恢复路径。恢复后 `continue` 回到循环开头重新调用 LLM。\n\n三种最常见的恢复模式(教学版只处理 429/529;真实系统还覆盖连接错误、超时、云厂商认证缓存等。Claude Code 实际有 13+ reason code,其余见 Deep dive):\n\n| 模式 | 触发 | 恢复动作 |\n|------|------|---------|\n| 输出截断 | `max_tokens` | 升级 8K→64K / 续写提示 |\n| 上下文超限 | `prompt_too_long` | reactive compact → 重试 |\n| 临时故障 | 429 / 529 | 指数退避 + 抖动,连续 529 可切换备用模型 |\n\n---\n\n## 工作原理\n\n### 路径 1: 输出被截断\n\n模型输出到一半,`max_tokens` 用完了。默认 8000 token 不够它输出完整回答。\n\n第一次发生时,直接把 `max_tokens` 从 8K 升级到 64K(8 倍空间),重试同一请求。此时不追加截断输出到 messages,保持原始请求不变。如果 64K 还是不够,才保存截断输出并注入续写提示让模型从截断处继续输出,最多 3 次:\n\n```python\nif response.stop_reason == \"max_tokens\":\n # First escalation: don't append truncated output, retry same request\n if not state.has_escalated:\n max_tokens = ESCALATED_MAX_TOKENS\n state.has_escalated = True\n continue # messages unchanged, same request with more tokens\n # 64K still truncated: save output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if state.recovery_count < MAX_RECOVERY_RETRIES:\n messages.append({\"role\": \"user\", \"content\":\n \"Output token limit hit. Resume directly — \"\n \"no apology, no recap. Pick up mid-thought.\"})\n state.recovery_count += 1\n continue\n return # still truncated after 3 continuations\n# Normal: append after max_tokens check\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\n```\n\n升级只有一次机会,续写最多 3 次。超过就退出,继续续写也不会有实质产出。\n\n### 路径 2: 上下文超限\n\nLLM 说\"你的上下文太长了\"(`prompt_too_long`)。s08 的四层压缩全跑过了,还是超。\n\n触发 reactive compact,比 auto compact 更激进。教学版只保留最后 5 条消息模拟压缩效果;真实实现会调用 LLM 生成 compact 摘要再重试。压缩后重试。但如果压缩过一次还是超限,只能退出,再压缩也不会变小:\n\n```python\nexcept PromptTooLongError:\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n return # 压缩过了还是超限,只能退出\n```\n\n### 路径 3: 临时故障\n\n网络抖动、429 限流、529 过载。这些不是 bug,是分布式系统的常态。\n\n429 和 529 统一走指数退避 + 抖动:第一次等 0.5 秒,第二次等 1 秒,第三次等 2 秒,最多 10 次。加随机抖动让并发请求不在同一时刻重试。连续 3 次 529 过载 → 切换到备用模型(若配置了 `FALLBACK_MODEL_ID` 环境变量):\n\n```python\ndef retry_delay(attempt, retry_after=None):\n if retry_after:\n return retry_after\n base = min(500 * (2 ** attempt), 32000) / 1000\n return base + random.uniform(0, base * 0.25)\n\ndef with_retry(fn, state, max_retries=10):\n for attempt in range(max_retries):\n try:\n return fn()\n except (RateLimitError, OverloadedError):\n delay = retry_delay(attempt)\n time.sleep(delay)\n if is_overloaded:\n state.consecutive_529 += 1\n if state.consecutive_529 >= 3 and FALLBACK_MODEL:\n state.current_model = FALLBACK_MODEL\n raise MaxRetriesExceeded()\n```\n\n退避公式:`min(500 × 2^attempt, 32000) + random(0~25%)`。如果服务器返回 `Retry-After` header,优先用那个值。\n\n### 合起来跑\n\n```python\ndef agent_loop(messages, context):\n system = get_system_prompt(context)\n state = RecoveryState()\n max_tokens = 8000\n\n while True:\n try:\n response = with_retry(\n lambda: client.messages.create(\n model=state.current_model, system=system,\n messages=messages, tools=TOOLS,\n max_tokens=max_tokens),\n state)\n except Exception as e:\n if is_prompt_too_long_error(e):\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n return\n log_error(e)\n return\n\n # max_tokens check BEFORE appending to messages\n if response.stop_reason == \"max_tokens\":\n if not state.has_escalated:\n max_tokens = 64000\n state.has_escalated = True\n continue # retry same request, messages unchanged\n # save truncated output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n messages.append({\"role\": \"user\", \"content\": CONTINUATION_PROMPT})\n continue\n # Normal completion\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n # ... tool execution ...\n```\n\n外层 try/except 捕获 API 异常(prompt_too_long 等),`with_retry` 处理瞬态错误(429/529),`stop_reason` 检查处理截断。三种恢复机制各管各的错误类型。\n\n---\n\n## 相对 s10 的变更\n\n| 组件 | 之前 (s10) | 之后 (s11) |\n|------|-----------|-----------|\n| 错误处理 | 无(一碰就崩溃) | 三种恢复模式 + 指数退避 |\n| 新常量 | — | ESCALATED_MAX_TOKENS=64000, MAX_RETRIES=10, BASE_DELAY_MS=500, FALLBACK_MODEL |\n| 新函数 | — | with_retry, retry_delay, reactive_compact, is_prompt_too_long_error, RecoveryState |\n| 工具 | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 不变 |\n| 循环 | 裸调用 LLM | try/except 包裹 + continue 重试 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s11_error_recovery/code.py\n```\n\n试试这些 prompt:\n\n1. 让 Agent 生成一段很长的代码,观察截断后是否自动续写(看 `[max_tokens] escalating` 日志)\n2. 连续读取大量文件撑大上下文,观察 reactive compact\n3. 如果遇到 429/529,观察指数退避的日志输出\n\n---\n\n## 接下来\n\nAgent 现在能在错误中自动恢复了。但它处理的任务仍然是\"一次性\"的——你给它一个任务,它做完,结束。\n\n能不能让 Agent 管理一个**任务列表**——有依赖关系、持久化到磁盘、跨会话能恢复?TODO 列表不是任务系统。\n\ns12 Task System → 任务是有依赖、有状态、持久化的图。这是多 Agent 协作的基础。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `query.ts`(1729 行)、`services/api/withRetry.ts`(822 行)、`query/tokenBudget.ts`(93 行)、`utils/tokenBudget.ts`(73 行)的分析。\n\n### 一、十几种 reason/transition(不只是 3 条)\n\n教学版讲了 3 种最常见的恢复模式。Claude Code 实际有十几种 reason/transition,每轮 LLM 调用后都会判断:\n\n| reason/transition | 教学版对应 | Claude Code 行为 |\n|---|---|---|\n| `completed` | 正常完成 | 返回结果 |\n| `next_turn` | 正常工具调用 | 继续下一轮工具执行 |\n| `max_output_tokens_escalate` | 路径 1 | 8K→64K 升级 |\n| `max_output_tokens_recovery` | 路径 1 续写 | 续写提示(最多 3 次) |\n| `reactive_compact_retry` | 路径 2 | reactive compact → 重试 |\n| `prompt_too_long` | 路径 2 | 同上 |\n| `collapse_drain_retry` | 未展开 | context collapse 先提交暂存 |\n| `model_error` | 未展开 | 重试 |\n| `image_error` | 未展开 | `ImageSizeError` / `ImageResizeError` 专门处理 |\n| `aborted_streaming` | 未展开 | 流式中止恢复 |\n| `aborted_tools` | 未展开 | 工具中止 |\n| `stop_hook_blocking` | 未展开 | 注入 blocking error → 模型自纠 |\n| `stop_hook_prevented` | 未展开 | hooks 阻止 |\n| `hook_stopped` | 未展开 | hook 停止执行 |\n| `token_budget_continuation` | 未展开 | token 用量 < 90% 时继续 |\n| `blocking_limit` | 未展开 | 阻塞限制 |\n| `max_turns` | 未展开 | 达到最大轮次 |\n\n教学版只展开了前 5 种(最常见的),其余各有专门处理逻辑。\n\n### 二、指数退避的精确公式\n\nClaude Code 的退避延迟(`withRetry.ts:530-548`):\n\n```\ndelay = min(500 × 2^(attempt-1), 32000) + random(0~25%)\n```\n\n| 尝试 | 基础延迟 | + 抖动 |\n|------|---------|--------|\n| 1 | 500ms | 0-125ms |\n| 2 | 1000ms | 0-250ms |\n| 4 | 4000ms | 0-1000ms |\n| 7+ | 32000ms(上限) | 0-8000ms |\n\n如果服务器返回 `Retry-After` header,优先用那个值。\n\n### 三、CONTINUATION 提示原文\n\nClaude Code 的续写提示(`query.ts:1225-1227`):\n\n```\nOutput token limit hit. Resume directly — no apology, no recap of what\nyou were doing. Pick up mid-thought if that is where the cut happened.\nBreak remaining work into smaller pieces.\n```\n\nToken budget 的 nudge 提示(`tokenBudget.ts:72`):\n\n```\nStopped at {pct}% of token target. Keep working — do not summarize.\n```\n\n### 四、流式错误处理\n\nClaude Code 的流式路径中,可恢复的错误(413、max_tokens、media error)在 streaming 期间**被暂扣不展示**(`query.ts:788-822`)——SDK 消费者看不到,只有恢复逻辑能看到。等 streaming 结束后才判断是否需要恢复。\n\n### 五、529 → Fallback Model 切换\n\n连续 3 次 529 过载错误后(`MAX_529_RETRIES = 3`),Claude Code 自动切换到 fallback model(如 Opus → Sonnet)。切换时清除所有 pending 消息和 tool 结果,给用户展示 \"Switched to {model} due to high demand\"。\n\n### 六、Diminishing Returns 检测\n\nToken budget 的\"继续\"不是无限的。当连续 3 次 continuation 且 token 增量 < 500 时,系统判断\"继续也没有实质性产出\",停止 continuation(`tokenBudget.ts:60-62`)。\n\n
\n\n\n" }, { "version": "s11", "locale": "ja", "title": "s11: Error Recovery — エラーは終わりではなく、リトライの始まり", - "content": "# s11: Error Recovery — エラーは終わりではなく、リトライの始まり\n\ns01 → ... → s09 → s10 → `s11` → [s12](/ja/s12) → s13 → ... → s20\n> *\"エラーは終わりではなく、リトライの始まり\"* — トークン拡張、コンテキスト圧縮、モデル切り替え。\n>\n> **Harness 層**: 耐障害性 — メインループのエラーを分類し復旧。\n\n---\n\n## 課題\n\nAgent が動いている途中でエラーが出た:\n\n```\nError: 529 overloaded\n```\n\nAgent がクラッシュした。リトライもしない、モデルも切り替えない、コンテキストも減らさない——そのままクラッシュ。\n\n本番環境では API エラーが日常茶飯事。最も一般的な 3 つの障害パターン:**出力の切り詰め**(モデルが途中まで出力して token が尽きた)、**コンテキスト超過**(圧縮後も長すぎる)、**一時的障害**(429 レート制限 / 529 過負荷)。エラーを処理しない Agent は、一度触れただけで止まる車のようなものだ。\n\n---\n\n## 解決策\n\n![Error Recovery Overview](/course-assets/s11_error_recovery/error-recovery-overview.ja.svg)\n\ns10 のループ、prompt 組み立てはすべてそのまま。唯一の変更点:LLM 呼び出しを try/except で包み、エラータイプに応じて異なる復旧パスに振り分ける。復旧後は `continue` でループ先頭に戻り、再度 LLM を呼び出す。\n\n最も一般的な 3 つの復旧パターン(教学版は 429/529 のみ対応;実際のシステムは接続エラー、タイムアウト、クラウドベンダーの認証キャッシュ等もカバー。CC には実際 13 以上の reason code があるが、残りは Deep dive で解説):\n\n| パターン | トリガー | 復旧アクション |\n|----------|----------|---------------|\n| 出力切り詰め | `max_tokens` | 8K→64K に拡張 / 続きのプロンプト注入 |\n| コンテキスト超過 | `prompt_too_long` | reactive compact → リトライ |\n| 一時的障害 | 429 / 529 | 指数バックオフ + ジッター、連続 529 でフォールバックモデルに切り替え可能 |\n\n---\n\n## 仕組み\n\n### パス 1: 出力が切り詰められた\n\nモデルが途中まで出力して、`max_tokens` に達した。デフォルトの 8000 token では完全な回答を出力しきれない。\n\n初回発生時、`max_tokens` を 8K から 64K に拡張(8 倍の空間)し、同じリクエストをリトライする——この時、切り詰められた出力は messages に追加せず、元のリクエストをそのまま維持する。64K でも足りない場合にのみ、切り詰められた出力を保存し、続きのプロンプトを注入してモデルに先ほどの続きを出力させる。最大 3 回まで:\n\n```python\nif response.stop_reason == \"max_tokens\":\n # First escalation: don't append truncated output, retry same request\n if not state.has_escalated:\n max_tokens = ESCALATED_MAX_TOKENS\n state.has_escalated = True\n continue # messages unchanged, same request with more tokens\n # 64K still truncated: save output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if state.recovery_count < MAX_RECOVERY_RETRIES:\n messages.append({\"role\": \"user\", \"content\":\n \"Output token limit hit. Resume directly — \"\n \"no apology, no recap. Pick up mid-thought.\"})\n state.recovery_count += 1\n continue\n return # still truncated after 3 continuations\n# Normal: append after max_tokens check\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\n```\n\n拡張は 1 回だけ、続きの出力は最大 3 回。超過したら終了——これ以上続けても実質的な出力は得られない。\n\n### パス 2: コンテキスト超過\n\nLLM が「コンテキストが長すぎる」と返す(`prompt_too_long`)。s08 の 4 層圧縮をすべて実行したのに、まだ超えている。\n\nreactive compact をトリガー——auto compact よりも積極的。教学版は最後の 5 メッセージだけを残して圧縮をシミュレート;実際の CC は LLM で compact サマリを生成してからリトライする。圧縮後にリトライ。ただし、一度圧縮してもまだ超過している場合は終了するしかない——再度圧縮しても小さくはならない:\n\n```python\nexcept PromptTooLongError:\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n return # 圧縮済みでも超過、終了するしかない\n```\n\n### パス 3: 一時的障害\n\nネットワークの揺らぎ、429 レート制限、529 過負荷——これらはバグではなく、分散システムの日常だ。\n\n429 と 529 は統一して指数バックオフ + ジッターを使用:1 回目は 0.5 秒待機、2 回目は 1 秒、3 回目は 2 秒、最大 10 回。ランダムジッターを加えることで、並行リクエストが同時にリトライするのを防ぐ。3 回連続で 529 過負荷 → フォールバックモデルに切り替え(`FALLBACK_MODEL_ID` 環境変数が設定されている場合):\n\n```python\ndef retry_delay(attempt, retry_after=None):\n if retry_after:\n return retry_after\n base = min(500 * (2 ** attempt), 32000) / 1000\n return base + random.uniform(0, base * 0.25)\n\ndef with_retry(fn, state, max_retries=10):\n for attempt in range(max_retries):\n try:\n return fn()\n except (RateLimitError, OverloadedError):\n delay = retry_delay(attempt)\n time.sleep(delay)\n if is_overloaded:\n state.consecutive_529 += 1\n if state.consecutive_529 >= 3 and FALLBACK_MODEL:\n state.current_model = FALLBACK_MODEL\n raise MaxRetriesExceeded()\n```\n\nバックオフの公式:`min(500 × 2^attempt, 32000) + random(0~25%)`。サーバーが `Retry-After` ヘッダーを返した場合、その値を優先して使用する。\n\n### 統合して実行\n\n```python\ndef agent_loop(messages, context):\n system = get_system_prompt(context)\n state = RecoveryState()\n max_tokens = 8000\n\n while True:\n try:\n response = with_retry(\n lambda: client.messages.create(\n model=state.current_model, system=system,\n messages=messages, tools=TOOLS,\n max_tokens=max_tokens),\n state)\n except Exception as e:\n if is_prompt_too_long_error(e):\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n return\n log_error(e)\n return\n\n # max_tokens check BEFORE appending to messages\n if response.stop_reason == \"max_tokens\":\n if not state.has_escalated:\n max_tokens = 64000\n state.has_escalated = True\n continue # retry same request, messages unchanged\n # save truncated output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n messages.append({\"role\": \"user\", \"content\": CONTINUATION_PROMPT})\n continue\n # Normal completion\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n # ... tool execution ...\n```\n\n外側の try/except が API 例外(prompt_too_long 等)を捕捉し、`with_retry` が一時的エラー(429/529)を処理し、`stop_reason` のチェックが切り詰めを処理する。3 つの復旧メカニズムがそれぞれ異なるエラータイプを担当する。\n\n---\n\n## s10 からの変更点\n\n| コンポーネント | 変更前 (s10) | 変更後 (s11) |\n|---------------|-------------|-------------|\n| エラー処理 | なし(エラーで即クラッシュ) | 3 つの復旧パターン + 指数バックオフ |\n| 新規定数 | — | ESCALATED_MAX_TOKENS=64000, MAX_RETRIES=10, BASE_DELAY_MS=500, FALLBACK_MODEL |\n| 新規関数 | — | with_retry, retry_delay, reactive_compact, is_prompt_too_long_error, RecoveryState |\n| ツール | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 変更なし |\n| ループ | LLM を直接呼び出し | try/except で包み + continue でリトライ |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s11_error_recovery/code.py\n```\n\n以下の prompt を試してみよう:\n\n1. Agent に長いコードを生成させ、切り詰め後に自動で続きが出力されるか観察する(`[max_tokens] escalating` ログを確認)\n2. 連続して大量のファイルを読み込みコンテキストを肥大化させ、reactive compact の動作を観察する\n3. 429/529 が発生した場合、指数バックオフのログ出力を観察する\n\n---\n\n## 次のステップ\n\nAgent はエラーから自動的に復旧できるようになった。しかし、まだ処理するタスクは「使い捨て」だ——タスクを与えると実行し、終わる。\n\nAgent に**タスクリスト**を管理させられないだろうか——依存関係があり、ディスクに永続化され、セッションをまたいで復旧できる?TODO リストはタスクシステムではない。\n\ns12 Task System → タスクとは依存関係があり、状態があり、永続化されたグラフだ。これはマルチ Agent 協調の基盤となる。\n\n
\nCC ソースコード深掘り\n\n> 以下は CC ソースコード `query.ts`(1729 行)、`services/api/withRetry.ts`(822 行)、`query/tokenBudget.ts`(93 行)、`utils/tokenBudget.ts`(73 行)の分析に基づく。\n\n### 一、十数種の reason/transition(3 つだけではない)\n\n教学版では最も一般的な 3 つの復旧パターンを解説した。CC には実際十数種の reason/transition があり、毎回の LLM 呼び出し後に判定される:\n\n| reason/transition | 教学版の対応 | CC の動作 |\n|---|---|---|\n| `completed` | 正常終了 | 結果を返す |\n| `next_turn` | 通常のツール呼び出し | 次のツール実行ラウンドへ |\n| `max_output_tokens_escalate` | パス 1 | 8K→64K に拡張 |\n| `max_output_tokens_recovery` | パス 1 続き出力 | 続きのプロンプト注入(最大 3 回) |\n| `reactive_compact_retry` | パス 2 | reactive compact → リトライ |\n| `prompt_too_long` | パス 2 | 同上 |\n| `collapse_drain_retry` | 未展開 | context collapse 時にまず保留中の内容をコミット |\n| `model_error` | 未展開 | リトライ |\n| `image_error` | 未展開 | `ImageSizeError` / `ImageResizeError` の専用処理 |\n| `aborted_streaming` | 未展開 | ストリーミング中断の復旧 |\n| `aborted_tools` | 未展開 | ツール中断 |\n| `stop_hook_blocking` | 未展開 | blocking error を注入 → モデルが自己修正 |\n| `stop_hook_prevented` | 未展開 | hooks によるブロック |\n| `hook_stopped` | 未展開 | hook による実行停止 |\n| `token_budget_continuation` | 未展開 | token 使用量 < 90% の時に継続 |\n| `blocking_limit` | 未展開 | ブロック制限 |\n| `max_turns` | 未展開 | 最大ターン数に到達 |\n\n教学版では最初の 5 つ(最も一般的なもの)だけを展開した。残りはそれぞれ専用の処理ロジックを持つ。\n\n### 二、指数バックオフの正確な公式\n\nCC のバックオフ遅延(`withRetry.ts:530-548`):\n\n```\ndelay = min(500 × 2^(attempt-1), 32000) + random(0~25%)\n```\n\n| 試行 | 基本遅延 | + ジッター |\n|------|---------|-----------|\n| 1 | 500ms | 0-125ms |\n| 2 | 1000ms | 0-250ms |\n| 4 | 4000ms | 0-1000ms |\n| 7+ | 32000ms(上限) | 0-8000ms |\n\nサーバーが `Retry-After` ヘッダーを返した場合、その値を優先して使用する。\n\n### 三、CONTINUATION プロンプト原文\n\nCC の続き出力プロンプト(`query.ts:1225-1227`):\n\n```\nOutput token limit hit. Resume directly — no apology, no recap of what\nyou were doing. Pick up mid-thought if that is where the cut happened.\nBreak remaining work into smaller pieces.\n```\n\nToken budget のナッジプロンプト(`tokenBudget.ts:72`):\n\n```\nStopped at {pct}% of token target. Keep working — do not summarize.\n```\n\n### 四、ストリーミングエラー処理\n\nCC のストリーミングパスでは、復旧可能なエラー(413、max_tokens、media error)はストリーミング中**表示を保留される**(`query.ts:788-822`)——SDK コンシューマーには見えず、復旧ロジックだけが認識できる。ストリーミング終了後に復旧が必要かどうかを判断する。\n\n### 五、529 → フォールバックモデル切り替え\n\n3 回連続で 529 過負荷エラーが発生した後(`MAX_529_RETRIES = 3`)、CC は自動的にフォールバックモデルに切り替える(例:Opus → Sonnet)。切り替え時にすべての保留中のメッセージと tool 結果をクリアし、ユーザーに \"Switched to {model} due to high demand\" と表示する。\n\n### 六、収穫逓減の検出\n\nToken budget の「継続」は無限ではない。連続 3 回の continuation で token 増分が 500 未満の場合、システムは「続けても実質的な出力は得られない」と判断し、continuation を停止する(`tokenBudget.ts:60-62`)。\n\n
\n\n\n" + "content": "# s11: Error Recovery — エラーは終わりではなく、リトライの始まり\n\ns01 → ... → s09 → s10 → `s11` → [s12](/ja/s12) → s13 → ... → s20\n> *\"エラーは終わりではなく、リトライの始まり\"* — トークン拡張、コンテキスト圧縮、モデル切り替え。\n>\n> **Harness 層**: 耐障害性 — メインループのエラーを分類し復旧。\n\n---\n\n## 課題\n\nAgent が動いている途中でエラーが出た:\n\n```\nError: 529 overloaded\n```\n\nAgent がクラッシュした。リトライもしない、モデルも切り替えない、コンテキストも減らさない。そのままクラッシュ。\n\n本番環境では API エラーが日常茶飯事。最も一般的な 3 つの障害パターン:**出力の切り詰め**(モデルが途中まで出力して token が尽きた)、**コンテキスト超過**(圧縮後も長すぎる)、**一時的障害**(429 レート制限 / 529 過負荷)。エラーを処理しない Agent は、一度触れただけで止まる車のようなものだ。\n\n---\n\n## 解決策\n\n![Error Recovery Overview](/course-assets/s11_error_recovery/error-recovery-overview.svg)\n\ns10 のループ、prompt 組み立てはすべてそのまま。唯一の変更点:LLM 呼び出しを try/except で包み、エラータイプに応じて異なる復旧パスに振り分ける。復旧後は `continue` でループ先頭に戻り、再度 LLM を呼び出す。\n\n最も一般的な 3 つの復旧パターン(教学版は 429/529 のみ対応;実際のシステムは接続エラー、タイムアウト、クラウドベンダーの認証キャッシュ等もカバー。Claude Code には実際 13 以上の reason code があるが、残りは Deep dive で解説):\n\n| パターン | トリガー | 復旧アクション |\n|----------|----------|---------------|\n| 出力切り詰め | `max_tokens` | 8K→64K に拡張 / 続きのプロンプト注入 |\n| コンテキスト超過 | `prompt_too_long` | reactive compact → リトライ |\n| 一時的障害 | 429 / 529 | 指数バックオフ + ジッター、連続 529 でフォールバックモデルに切り替え可能 |\n\n---\n\n## 仕組み\n\n### パス 1: 出力が切り詰められた\n\nモデルが途中まで出力して、`max_tokens` に達した。デフォルトの 8000 token では完全な回答を出力しきれない。\n\n初回発生時、`max_tokens` を 8K から 64K に拡張(8 倍の空間)し、同じリクエストをリトライする。この時、切り詰められた出力は messages に追加せず、元のリクエストをそのまま維持する。64K でも足りない場合にのみ、切り詰められた出力を保存し、続きのプロンプトを注入してモデルに先ほどの続きを出力させる。最大 3 回まで:\n\n```python\nif response.stop_reason == \"max_tokens\":\n # First escalation: don't append truncated output, retry same request\n if not state.has_escalated:\n max_tokens = ESCALATED_MAX_TOKENS\n state.has_escalated = True\n continue # messages unchanged, same request with more tokens\n # 64K still truncated: save output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if state.recovery_count < MAX_RECOVERY_RETRIES:\n messages.append({\"role\": \"user\", \"content\":\n \"Output token limit hit. Resume directly — \"\n \"no apology, no recap. Pick up mid-thought.\"})\n state.recovery_count += 1\n continue\n return # still truncated after 3 continuations\n# Normal: append after max_tokens check\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\n```\n\n拡張は 1 回だけ、続きの出力は最大 3 回。超過したら終了する。これ以上続けても実質的な出力は得られない。\n\n### パス 2: コンテキスト超過\n\nLLM が「コンテキストが長すぎる」と返す(`prompt_too_long`)。s08 の 4 層圧縮をすべて実行したのに、まだ超えている。\n\nreactive compact をトリガーする。auto compact よりも積極的。教学版は最後の 5 メッセージだけを残して圧縮をシミュレート;実際の Claude Code は LLM で compact サマリを生成してからリトライする。圧縮後にリトライ。ただし、一度圧縮してもまだ超過している場合は終了するしかない。再度圧縮しても小さくはならない:\n\n```python\nexcept PromptTooLongError:\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n return # 圧縮済みでも超過、終了するしかない\n```\n\n### パス 3: 一時的障害\n\nネットワークの揺らぎ、429 レート制限、529 過負荷。これらはバグではなく、分散システムの日常だ。\n\n429 と 529 は統一して指数バックオフ + ジッターを使用:1 回目は 0.5 秒待機、2 回目は 1 秒、3 回目は 2 秒、最大 10 回。ランダムジッターを加えることで、並行リクエストが同時にリトライするのを防ぐ。3 回連続で 529 過負荷 → フォールバックモデルに切り替え(`FALLBACK_MODEL_ID` 環境変数が設定されている場合):\n\n```python\ndef retry_delay(attempt, retry_after=None):\n if retry_after:\n return retry_after\n base = min(500 * (2 ** attempt), 32000) / 1000\n return base + random.uniform(0, base * 0.25)\n\ndef with_retry(fn, state, max_retries=10):\n for attempt in range(max_retries):\n try:\n return fn()\n except (RateLimitError, OverloadedError):\n delay = retry_delay(attempt)\n time.sleep(delay)\n if is_overloaded:\n state.consecutive_529 += 1\n if state.consecutive_529 >= 3 and FALLBACK_MODEL:\n state.current_model = FALLBACK_MODEL\n raise MaxRetriesExceeded()\n```\n\nバックオフの公式:`min(500 × 2^attempt, 32000) + random(0~25%)`。サーバーが `Retry-After` ヘッダーを返した場合、その値を優先して使用する。\n\n### 統合して実行\n\n```python\ndef agent_loop(messages, context):\n system = get_system_prompt(context)\n state = RecoveryState()\n max_tokens = 8000\n\n while True:\n try:\n response = with_retry(\n lambda: client.messages.create(\n model=state.current_model, system=system,\n messages=messages, tools=TOOLS,\n max_tokens=max_tokens),\n state)\n except Exception as e:\n if is_prompt_too_long_error(e):\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n return\n log_error(e)\n return\n\n # max_tokens check BEFORE appending to messages\n if response.stop_reason == \"max_tokens\":\n if not state.has_escalated:\n max_tokens = 64000\n state.has_escalated = True\n continue # retry same request, messages unchanged\n # save truncated output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n messages.append({\"role\": \"user\", \"content\": CONTINUATION_PROMPT})\n continue\n # Normal completion\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n # ... tool execution ...\n```\n\n外側の try/except が API 例外(prompt_too_long 等)を捕捉し、`with_retry` が一時的エラー(429/529)を処理し、`stop_reason` のチェックが切り詰めを処理する。3 つの復旧メカニズムがそれぞれ異なるエラータイプを担当する。\n\n---\n\n## s10 からの変更点\n\n| コンポーネント | 変更前 (s10) | 変更後 (s11) |\n|---------------|-------------|-------------|\n| エラー処理 | なし(エラーで即クラッシュ) | 3 つの復旧パターン + 指数バックオフ |\n| 新規定数 | — | ESCALATED_MAX_TOKENS=64000, MAX_RETRIES=10, BASE_DELAY_MS=500, FALLBACK_MODEL |\n| 新規関数 | — | with_retry, retry_delay, reactive_compact, is_prompt_too_long_error, RecoveryState |\n| ツール | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 変更なし |\n| ループ | LLM を直接呼び出し | try/except で包み + continue でリトライ |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s11_error_recovery/code.py\n```\n\n以下の prompt を試してみよう:\n\n1. Agent に長いコードを生成させ、切り詰め後に自動で続きが出力されるか観察する(`[max_tokens] escalating` ログを確認)\n2. 連続して大量のファイルを読み込みコンテキストを肥大化させ、reactive compact の動作を観察する\n3. 429/529 が発生した場合、指数バックオフのログ出力を観察する\n\n---\n\n## 次のステップ\n\nAgent はエラーから自動的に復旧できるようになった。しかし、まだ処理するタスクは「使い捨て」だ——タスクを与えると実行し、終わる。\n\nAgent に**タスクリスト**を管理させられないだろうか——依存関係があり、ディスクに永続化され、セッションをまたいで復旧できる?TODO リストはタスクシステムではない。\n\ns12 Task System → タスクとは依存関係があり、状態があり、永続化されたグラフだ。これはマルチ Agent 協調の基盤となる。\n\n
\nClaude Code ソースコード深掘り\n\n> 以下は Claude Code ソースコード `query.ts`(1729 行)、`services/api/withRetry.ts`(822 行)、`query/tokenBudget.ts`(93 行)、`utils/tokenBudget.ts`(73 行)の分析に基づく。\n\n### 一、十数種の reason/transition(3 つだけではない)\n\n教学版では最も一般的な 3 つの復旧パターンを解説した。Claude Code には実際十数種の reason/transition があり、毎回の LLM 呼び出し後に判定される:\n\n| reason/transition | 教学版の対応 | Claude Code の動作 |\n|---|---|---|\n| `completed` | 正常終了 | 結果を返す |\n| `next_turn` | 通常のツール呼び出し | 次のツール実行ラウンドへ |\n| `max_output_tokens_escalate` | パス 1 | 8K→64K に拡張 |\n| `max_output_tokens_recovery` | パス 1 続き出力 | 続きのプロンプト注入(最大 3 回) |\n| `reactive_compact_retry` | パス 2 | reactive compact → リトライ |\n| `prompt_too_long` | パス 2 | 同上 |\n| `collapse_drain_retry` | 未展開 | context collapse 時にまず保留中の内容をコミット |\n| `model_error` | 未展開 | リトライ |\n| `image_error` | 未展開 | `ImageSizeError` / `ImageResizeError` の専用処理 |\n| `aborted_streaming` | 未展開 | ストリーミング中断の復旧 |\n| `aborted_tools` | 未展開 | ツール中断 |\n| `stop_hook_blocking` | 未展開 | blocking error を注入 → モデルが自己修正 |\n| `stop_hook_prevented` | 未展開 | hooks によるブロック |\n| `hook_stopped` | 未展開 | hook による実行停止 |\n| `token_budget_continuation` | 未展開 | token 使用量 < 90% の時に継続 |\n| `blocking_limit` | 未展開 | ブロック制限 |\n| `max_turns` | 未展開 | 最大ターン数に到達 |\n\n教学版では最初の 5 つ(最も一般的なもの)だけを展開した。残りはそれぞれ専用の処理ロジックを持つ。\n\n### 二、指数バックオフの正確な公式\n\nClaude Code のバックオフ遅延(`withRetry.ts:530-548`):\n\n```\ndelay = min(500 × 2^(attempt-1), 32000) + random(0~25%)\n```\n\n| 試行 | 基本遅延 | + ジッター |\n|------|---------|-----------|\n| 1 | 500ms | 0-125ms |\n| 2 | 1000ms | 0-250ms |\n| 4 | 4000ms | 0-1000ms |\n| 7+ | 32000ms(上限) | 0-8000ms |\n\nサーバーが `Retry-After` ヘッダーを返した場合、その値を優先して使用する。\n\n### 三、CONTINUATION プロンプト原文\n\nClaude Code の続き出力プロンプト(`query.ts:1225-1227`):\n\n```\nOutput token limit hit. Resume directly — no apology, no recap of what\nyou were doing. Pick up mid-thought if that is where the cut happened.\nBreak remaining work into smaller pieces.\n```\n\nToken budget のナッジプロンプト(`tokenBudget.ts:72`):\n\n```\nStopped at {pct}% of token target. Keep working — do not summarize.\n```\n\n### 四、ストリーミングエラー処理\n\nClaude Code のストリーミングパスでは、復旧可能なエラー(413、max_tokens、media error)はストリーミング中**表示を保留される**(`query.ts:788-822`)——SDK コンシューマーには見えず、復旧ロジックだけが認識できる。ストリーミング終了後に復旧が必要かどうかを判断する。\n\n### 五、529 → フォールバックモデル切り替え\n\n3 回連続で 529 過負荷エラーが発生した後(`MAX_529_RETRIES = 3`)、Claude Code は自動的にフォールバックモデルに切り替える(例:Opus → Sonnet)。切り替え時にすべての保留中のメッセージと tool 結果をクリアし、ユーザーに \"Switched to {model} due to high demand\" と表示する。\n\n### 六、収穫逓減の検出\n\nToken budget の「継続」は無限ではない。連続 3 回の continuation で token 増分が 500 未満の場合、システムは「続けても実質的な出力は得られない」と判断し、continuation を停止する(`tokenBudget.ts:60-62`)。\n\n
\n\n\n" }, { "version": "s12", "locale": "en", "title": "s12: Task System — Break Big Goals into Small Tasks", - "content": "# s12: Task System — Break Big Goals into Small Tasks\n\ns01 → ... → s10 → s11 → `s12` → [s13](/en/s13) → s14 → ... → s20\n\n> *\"Break big goals into small tasks, order them, persist\"* — File-persisted task graph, the foundation for multi-agent collaboration.\n>\n> **Harness Layer**: Tasks — Persisted goals, recoverable progress.\n\n---\n\n## The Problem\n\nThe agent receives a project: set up a database, write APIs, add tests. It uses s05's TodoWrite to create a checklist, then starts writing the API first, gets halfway through and realizes there are no database tables, goes back to fix them; when adding tests, discovers the API interface signatures have changed again...\n\nYou can't build the roof before laying the foundation. Tasks have ordering. Task dependencies should form a Directed Acyclic Graph (DAG); the teaching version only demonstrates `blockedBy` checking, without cycle detection.\n\ns05's TodoWrite is an execution checklist for the current task, kept in session memory. What you need here is a **task system**: each task is a JSON file, tasks have `blockedBy` dependencies, and they persist across sessions on disk.\n\n---\n\n## The Solution\n\n![Task System Overview](/course-assets/s12_task_system/task-system-overview.en.svg)\n\nTeaching code keeps a basic agent loop, omitting S11's full error recovery (RecoveryState, backoff, escalation, reactive compact, fallback model) to stay focused on the task system. Added: 5 new task tools + `.tasks/` directory for persistence + `blockedBy` dependency checking. The task system and error recovery are independent layers: in CC source, `utils/tasks.ts` only handles CRUD, while `query.ts`'s with_retry/RecoveryState handles error recovery, with no coupling between them.\n\nTodoWrite vs Task System:\n\n| | TodoWrite (s05) | Task System (s12) |\n|---|---|---|\n| Role | Execution checklist for the current task | Recoverable task system |\n| Storage | In-process / session state | `.tasks/{id}.json` |\n| Dependencies | None | `blockedBy` / `blocks` graph |\n| Lifecycle | Current session / current task | Cross-session |\n| Coordination | No task claiming | `owner` / claim |\n| Status | pending / in_progress / completed | pending / in_progress / completed |\n| Granularity | The agent's own steps | Tasks that can be claimed, tracked, and unblocked |\n\n---\n\n## How It Works\n\n![Task DAG](/course-assets/s12_task_system/task-dag.en.svg)\n\n### Task: Data Structure\n\nEach task is a JSON file, stored in the `.tasks/` directory:\n\n```python\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str # pending | in_progress | completed\n owner: str | None # Agent name (multi-agent scenarios)\n blockedBy: list[str] # List of dependency task IDs\n```\n\nIDs are generated with `timestamp + random hex`, simple but sufficient. CC uses sequential IDs + a highwatermark file to prevent ID reuse, which is a more rigorous design.\n\n### create_task: Create Tasks\n\n```python\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random_hex(4)}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n```\n\nAutomatically calls `save_task` on creation to write `.tasks/{id}.json`. `blockedBy` declares dependencies, for example \"write API\" has `blockedBy: [\"task_schema\"]`.\n\n### can_start: Dependency Check\n\nA task can only start after all its `blockedBy` dependencies are **completed**:\n\n```python\ndef can_start(task_id: str) -> bool:\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False # missing dependency = blocked\n dep = load_task(dep_id)\n if dep.status != \"completed\":\n return False\n return True\n```\n\n`can_start` is a prerequisite check for `claim_task`: if any `blockedBy` dependency is not completed, the task cannot be claimed. Missing dependencies are treated as blocked, avoiding crashes from referencing wrong IDs.\n\n### claim_task: Claim a Task\n\nWhen the agent starts working on a task, it calls `claim_task`: sets `owner`, changes status from `pending` → `in_progress`. The `owner` field records who is working on the task, preventing duplicate claims in multi-agent scenarios:\n\n```python\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if load_task(d).status != \"completed\"]\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n return f\"Claimed {task_id} ({task.subject})\"\n```\n\nIf the task is already claimed by someone else (`status != \"pending\"`), or dependencies aren't met (`can_start` returns False), the claim is rejected.\n\n### complete_task: Complete and Unblock\n\nWhen a task is done, set it to `completed`. Simultaneously scan all other tasks to find downstream tasks that were **just unblocked**:\n\n```python\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n task.status = \"completed\"\n save_task(task)\n # Find newly unblocked downstream tasks\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy\n and can_start(t.id)]\n msg = f\"Completed {task_id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n return msg\n```\n\nAfter completing \"schema\", `can_start` returns True for \"endpoints\" and \"docs\"; they can begin.\n\n### get_task: View Full Details\n\n`list_tasks` only shows a one-line summary. `get_task` returns the full task JSON, including description and dependency details. When recovering across sessions, the agent needs to read the full description to continue work:\n\n```python\ndef get_task(task_id: str) -> str:\n task = load_task(task_id)\n return json.dumps(asdict(task), indent=2)\n```\n\n### State Machine: Two Actions, Three States\n\n```\npending ──claim──→ in_progress ──complete──→ completed\n```\n\nHere `claim` / `complete` are actions, while `pending` / `in_progress` / `completed` are states:\n\n- **claim_task**: `pending` → `in_progress`. Sets owner, begins work.\n- **complete_task**: `in_progress` → `completed`. Marks the task done and unblocks downstream.\n\nCC has no `in_progress → pending` release path. If a teammate terminates or shuts down, CC unassigns its unfinished tasks (clears owner) and resets status to `pending`, allowing other agents to reclaim them. The teaching version omits this recovery path.\n\n### Putting It Together\n\n```python\n# Create tasks with dependencies\nschema = create_task(\"setup database schema\")\nendpoints = create_task(\"create API endpoints\", blockedBy=[schema.id])\ntests = create_task(\"write tests\", blockedBy=[endpoints.id])\ndocs = create_task(\"write docs\", blockedBy=[schema.id])\n\n# Agent claims the first available task\nclaim_task(schema.id) # ✓ Claimed (no dependencies)\ncomplete_task(schema.id) # ✓ Completed → unblocks endpoints, docs\n\nclaim_task(endpoints.id) # ✓ Claimed (schema completed)\ncomplete_task(endpoints.id) # ✓ Completed → unblocks tests\n\nclaim_task(docs.id) # ✓ Claimed (schema completed)\ncomplete_task(docs.id) # ✓ Completed\n\nclaim_task(tests.id) # ✓ Claimed (endpoints completed)\ncomplete_task(tests.id) # ✓ Completed\n```\n\nEach `create_task` writes a JSON file, each `claim_task` / `complete_task` updates the file. Across sessions, the `.tasks/` directory persists — the agent reads the files to recover progress.\n\n---\n\n## Changes from s11\n\n| Component | Before (s11) | After (s12) |\n|-----------|-------------|-------------|\n| Task management | None | Task dataclass + 5 tools |\n| New types | — | Task (id, subject, description, status, owner, blockedBy) |\n| Storage | No persistence | `.tasks/{id}.json` cross-session |\n| Dependencies | None | `blockedBy` graph + `can_start` check |\n| Tools | bash, read_file, write_file (3) | + create_task, list_tasks, get_task, claim_task, complete_task (8) |\n| Lifecycle | — | pending → in_progress → completed (no release rollback) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s12_task_system/code.py\n```\n\nTry these prompts:\n\n1. `Create tasks: setup database schema, create API endpoints (depends on schema), write tests (depends on endpoints), write docs (depends on schema)`\n2. `List all tasks and their statuses`\n3. `Claim the first unblocked task and complete it`\n4. `List tasks again — which ones are now unblocked?`\n\nWhat to observe: Are JSON files generated in the `.tasks/` directory? After completing a task, are the blocked tasks unblocked?\n\n---\n\n## What's Next\n\nThe task graph is in place. But some tasks take a long time — like running full test suites or deploying to a server. The agent calls the LLM billed by token, it can't afford to wait on a slow operation.\n\ns13 Background Tasks → Slow operations go to the background. The agent continues processing other tasks, and gets notified when the background work is done.\n\n
\nDeep Dive into CC Source\n\n> The following is a complete analysis based on CC source code `utils/tasks.ts` (862 lines), `tools/TaskCreateTool/TaskCreateTool.ts` (138 lines), `tools/TaskUpdateTool/TaskUpdateTool.ts` (406 lines), `tools/TaskGetTool/TaskGetTool.ts` (128 lines), `tools/TaskListTool/TaskListTool.ts` (116 lines), `hooks/useTaskListWatcher.ts` (221 lines).\n\n### 1. TaskRecord's Full Fields\n\nThe tutorial only covers id, subject, status, owner, blockedBy. CC actually has 9 fields (`utils/tasks.ts:76-89`):\n\n| Field | Type | Purpose |\n|------|------|---------|\n| `id` | string | Incrementing integer ID |\n| `subject` | string | Short title |\n| `description` | string | Free-form description |\n| `activeForm` | string? | Present tense form, shown in spinner when in_progress |\n| `owner` | string? | Assigned agent ID |\n| `status` | pending/in_progress/completed | Lifecycle |\n| `blocks` | string[] | Task IDs blocked by this task (downstream) |\n| `blockedBy` | string[] | Task IDs blocking this task (upstream) |\n| `metadata` | Record? | Arbitrary extension key-value pairs |\n\nStorage location: `~/.claude/tasks/{taskListId}/{id}.json`. One file per task.\n\n### 2. Not a TodoWrite Upgrade — Two Independent Systems\n\nIn CC, Task System and TodoWrite **coexist**, toggled by `isTodoV2Enabled()` (`utils/tasks.ts:133`) — interactive sessions default to Task (V2), non-interactive/SDK sessions default to TodoWrite. The `CLAUDE_CODE_ENABLE_TASKS` env var can force-enable Task. Task has what TodoWrite lacks: file-lock concurrency protection, dependency enforcement, ownership, fs.watch reactive monitoring, lifecycle hooks.\n\n### 3. Concurrent Claim Locking\n\n`claimTask()` (`utils/tasks.ts:541-612`) uses dual locking to prevent races:\n\n**Task file lock**: `proper-lockfile` locks `{taskId}.json` (up to 30 retries, exponential backoff 5-100ms). Inside the lock:\n1. Re-read task (prevent TOCTOU)\n2. Check already claimed by another → `already_claimed`\n3. Check already completed → `already_resolved`\n4. Check upstream not completed → `blocked`\n5. Set owner\n\n**List-level lock** (agent busy check): `.lock` file, atomic scan of all tasks to check if the agent already has other open tasks.\n\nNote: The teaching version combines claiming and starting work into one step (claim = set owner + in_progress); real CC's `claimTask` primarily resolves owner competition — it only sets owner without changing status. Status updates are handled by `TaskUpdate`.\n\n### 4. High-Water Mark to Prevent ID Reuse\n\nThe `.highwatermark` file records the highest task ID ever assigned. Even if a task is deleted, its ID won't be reused.\n\n### 5. Four Task Tools\n\nCC's task system has four tools (not the tutorial's single generic Task tool): `TaskCreate`, `TaskGet`, `TaskUpdate`, `TaskList`. All set `isConcurrencySafe: true` and `shouldDefer: true` (tool schemas aren't in the initial prompt; only visible after ToolSearch).\n\nThe teaching version's `create_task(blockedBy=...)` declares dependencies at creation time, which is a reasonable simplification. Real CC's `TaskCreate` only accepts subject/description/activeForm/metadata — dependencies are maintained via `TaskUpdate`'s `addBlocks/addBlockedBy`.\n\n
\n\n\n" + "content": "# s12: Task System — Break Big Goals into Small Tasks\n\ns01 → ... → s10 → s11 → `s12` → [s13](/en/s13) → s14 → ... → s20\n\n> *\"Break big goals into small tasks, order them, persist\"* — File-persisted task graph, the basis for multi-agent collaboration.\n>\n> **Harness Layer**: Tasks — Persisted goals, recoverable progress.\n\n---\n\n## The Problem\n\nThe agent receives a project: set up a database, write APIs, add tests. It uses s05's TodoWrite to create a checklist, then starts writing the API first, gets halfway through and realizes there are no database tables, goes back to fix them; when adding tests, discovers the API interface signatures have changed again...\n\nYou can't build the roof before laying the foundation. Tasks have ordering. Task dependencies should form a Directed Acyclic Graph (DAG); the teaching version only demonstrates `blockedBy` checking, without cycle detection.\n\ns05's TodoWrite is an execution checklist for the current task, kept in session memory. What you need here is a **task system**: each task is a JSON file, tasks have `blockedBy` dependencies, and they persist across sessions on disk.\n\n---\n\n## The Solution\n\n![Task System Overview](/course-assets/s12_task_system/task-system-overview.svg)\n\nTeaching code keeps a basic agent loop, omitting S11's full error recovery (RecoveryState, backoff, escalation, reactive compact, fallback model) to stay focused on the task system. Added: 5 new task tools + `.tasks/` directory for persistence + `blockedBy` dependency checking. The task system and error recovery are independent layers: in Claude Code source, `utils/tasks.ts` only handles CRUD, while `query.ts`'s with_retry/RecoveryState handles error recovery, with no coupling between them.\n\nTodoWrite vs Task System:\n\n| | TodoWrite (s05) | Task System (s12) |\n|---|---|---|\n| Role | Execution checklist for the current task | Recoverable task system |\n| Storage | In-process / session state | `.tasks/{id}.json` |\n| Dependencies | None | `blockedBy` / `blocks` graph |\n| Lifecycle | Current session / current task | Cross-session |\n| Coordination | No task claiming | `owner` / claim |\n| Status | pending / in_progress / completed | pending / in_progress / completed |\n| Granularity | The agent's own steps | Tasks that can be claimed, tracked, and unblocked |\n\n---\n\n## How It Works\n\n![Task DAG](/course-assets/s12_task_system/task-dag.svg)\n\n### Task: Data Structure\n\nEach task is a JSON file, stored in the `.tasks/` directory:\n\n```python\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str # pending | in_progress | completed\n owner: str | None # Agent name (multi-agent scenarios)\n blockedBy: list[str] # List of dependency task IDs\n```\n\nIDs are generated with `timestamp + random hex`, simple but sufficient. Claude Code uses sequential IDs + a highwatermark file to prevent ID reuse, which is a more rigorous design.\n\n### create_task: Create Tasks\n\n```python\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random_hex(4)}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n```\n\nAutomatically calls `save_task` on creation to write `.tasks/{id}.json`. `blockedBy` declares dependencies, for example \"write API\" has `blockedBy: [\"task_schema\"]`.\n\n### can_start: Dependency Check\n\nA task can only start after all its `blockedBy` dependencies are **completed**:\n\n```python\ndef can_start(task_id: str) -> bool:\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False # missing dependency = blocked\n dep = load_task(dep_id)\n if dep.status != \"completed\":\n return False\n return True\n```\n\n`can_start` is a prerequisite check for `claim_task`: if any `blockedBy` dependency is not completed, the task cannot be claimed. Missing dependencies are treated as blocked, avoiding crashes from referencing wrong IDs.\n\n### claim_task: Claim a Task\n\nWhen the agent starts working on a task, it calls `claim_task`: sets `owner`, changes status from `pending` → `in_progress`. The `owner` field records who is working on the task, preventing duplicate claims in multi-agent scenarios:\n\n```python\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if load_task(d).status != \"completed\"]\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n return f\"Claimed {task_id} ({task.subject})\"\n```\n\nIf the task is already claimed by someone else (`status != \"pending\"`), or dependencies aren't met (`can_start` returns False), the claim is rejected.\n\n### complete_task: Complete and Unblock\n\nWhen a task is done, set it to `completed`. Simultaneously scan all other tasks to find downstream tasks that were **just unblocked**:\n\n```python\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n task.status = \"completed\"\n save_task(task)\n # Find newly unblocked downstream tasks\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy\n and can_start(t.id)]\n msg = f\"Completed {task_id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n return msg\n```\n\nAfter completing \"schema\", `can_start` returns True for \"endpoints\" and \"docs\"; they can begin.\n\n### get_task: View Full Details\n\n`list_tasks` only shows a one-line summary. `get_task` returns the full task JSON, including description and dependency details. When recovering across sessions, the agent needs to read the full description to continue work:\n\n```python\ndef get_task(task_id: str) -> str:\n task = load_task(task_id)\n return json.dumps(asdict(task), indent=2)\n```\n\n### State Machine: Two Actions, Three States\n\n```\npending ──claim──→ in_progress ──complete──→ completed\n```\n\nHere `claim` / `complete` are actions, while `pending` / `in_progress` / `completed` are states:\n\n- **claim_task**: `pending` → `in_progress`. Sets owner, begins work.\n- **complete_task**: `in_progress` → `completed`. Marks the task done and unblocks downstream.\n\nClaude Code has no `in_progress → pending` release path. If a teammate terminates or shuts down, Claude Code unassigns its unfinished tasks (clears owner) and resets status to `pending`, allowing other agents to reclaim them. The teaching version omits this recovery path.\n\n### Putting It Together\n\n```python\n# Create tasks with dependencies\nschema = create_task(\"setup database schema\")\nendpoints = create_task(\"create API endpoints\", blockedBy=[schema.id])\ntests = create_task(\"write tests\", blockedBy=[endpoints.id])\ndocs = create_task(\"write docs\", blockedBy=[schema.id])\n\n# Agent claims the first available task\nclaim_task(schema.id) # ✓ Claimed (no dependencies)\ncomplete_task(schema.id) # ✓ Completed → unblocks endpoints, docs\n\nclaim_task(endpoints.id) # ✓ Claimed (schema completed)\ncomplete_task(endpoints.id) # ✓ Completed → unblocks tests\n\nclaim_task(docs.id) # ✓ Claimed (schema completed)\ncomplete_task(docs.id) # ✓ Completed\n\nclaim_task(tests.id) # ✓ Claimed (endpoints completed)\ncomplete_task(tests.id) # ✓ Completed\n```\n\nEach `create_task` writes a JSON file, each `claim_task` / `complete_task` updates the file. Across sessions, the `.tasks/` directory persists; the agent reads the files to recover progress.\n\n---\n\n## Changes from s11\n\n| Component | Before (s11) | After (s12) |\n|-----------|-------------|-------------|\n| Task management | None | Task dataclass + 5 tools |\n| New types | — | Task (id, subject, description, status, owner, blockedBy) |\n| Storage | No persistence | `.tasks/{id}.json` cross-session |\n| Dependencies | None | `blockedBy` graph + `can_start` check |\n| Tools | bash, read_file, write_file (3) | + create_task, list_tasks, get_task, claim_task, complete_task (8) |\n| Lifecycle | — | pending → in_progress → completed (no release rollback) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s12_task_system/code.py\n```\n\nTry these prompts:\n\n1. `Create tasks: setup database schema, create API endpoints (depends on schema), write tests (depends on endpoints), write docs (depends on schema)`\n2. `List all tasks and their statuses`\n3. `Claim the first unblocked task and complete it`\n4. `List tasks again — which ones are now unblocked?`\n\nWhat to observe: Are JSON files generated in the `.tasks/` directory? After completing a task, are the blocked tasks unblocked?\n\n---\n\n## What's Next\n\nThe task graph is in place. But some tasks take a long time — like running full test suites or deploying to a server. The agent calls the LLM billed by token, it can't afford to wait on a slow operation.\n\ns13 Background Tasks → Slow operations go to the background. The agent continues processing other tasks, and gets notified when the background work is done.\n\n
\nDeep Dive into Claude Code Source\n\n> The following is a complete analysis based on Claude Code source code `utils/tasks.ts` (862 lines), `tools/TaskCreateTool/TaskCreateTool.ts` (138 lines), `tools/TaskUpdateTool/TaskUpdateTool.ts` (406 lines), `tools/TaskGetTool/TaskGetTool.ts` (128 lines), `tools/TaskListTool/TaskListTool.ts` (116 lines), `hooks/useTaskListWatcher.ts` (221 lines).\n\n### 1. TaskRecord's Full Fields\n\nThe tutorial only covers id, subject, status, owner, blockedBy. Claude Code actually has 9 fields (`utils/tasks.ts:76-89`):\n\n| Field | Type | Purpose |\n|------|------|---------|\n| `id` | string | Incrementing integer ID |\n| `subject` | string | Short title |\n| `description` | string | Free-form description |\n| `activeForm` | string? | Present tense form, shown in spinner when in_progress |\n| `owner` | string? | Assigned agent ID |\n| `status` | pending/in_progress/completed | Lifecycle |\n| `blocks` | string[] | Task IDs blocked by this task (downstream) |\n| `blockedBy` | string[] | Task IDs blocking this task (upstream) |\n| `metadata` | Record? | Arbitrary extension key-value pairs |\n\nStorage location: `~/.claude/tasks/{taskListId}/{id}.json`. One file per task.\n\n### 2. Not a TodoWrite Upgrade — Two Independent Systems\n\nIn Claude Code, Task System and TodoWrite **coexist**, toggled by `isTodoV2Enabled()` (`utils/tasks.ts:133`): interactive sessions default to Task (V2), non-interactive/SDK sessions default to TodoWrite. The `CLAUDE_CODE_ENABLE_TASKS` env var can force-enable Task. Task has what TodoWrite lacks: file-lock concurrency protection, dependency enforcement, ownership, fs.watch reactive monitoring, lifecycle hooks.\n\n### 3. Concurrent Claim Locking\n\n`claimTask()` (`utils/tasks.ts:541-612`) uses dual locking to prevent races:\n\n**Task file lock**: `proper-lockfile` locks `{taskId}.json` (up to 30 retries, exponential backoff 5-100ms). Inside the lock:\n1. Re-read task (prevent TOCTOU)\n2. Check already claimed by another → `already_claimed`\n3. Check already completed → `already_resolved`\n4. Check upstream not completed → `blocked`\n5. Set owner\n\n**List-level lock** (agent busy check): `.lock` file, atomic scan of all tasks to check if the agent already has other open tasks.\n\nNote: The teaching version combines claiming and starting work into one step (claim = set owner + in_progress); real Claude Code's `claimTask` primarily resolves owner competition: it only sets owner without changing status. Status updates are handled by `TaskUpdate`.\n\n### 4. High-Water Mark to Prevent ID Reuse\n\nThe `.highwatermark` file records the highest task ID ever assigned. Even if a task is deleted, its ID won't be reused.\n\n### 5. Four Task Tools\n\nClaude Code's task system has four tools (not the tutorial's single generic Task tool): `TaskCreate`, `TaskGet`, `TaskUpdate`, `TaskList`. All set `isConcurrencySafe: true` and `shouldDefer: true` (tool schemas aren't in the initial prompt; only visible after ToolSearch).\n\nThe teaching version's `create_task(blockedBy=...)` declares dependencies at creation time, which is a reasonable simplification. Real Claude Code's `TaskCreate` only accepts subject/description/activeForm/metadata; dependencies are maintained via `TaskUpdate`'s `addBlocks/addBlockedBy`.\n\n
\n\n\n" }, { "version": "s12", "locale": "zh", "title": "s12: Task System — 目标太大,拆成小任务", - "content": "# s12: Task System — 目标太大,拆成小任务\n\ns01 → ... → s10 → s11 → `s12` → [s13](/zh/s13) → s14 → ... → s20\n\n> *\"大目标拆成小任务, 排好序, 持久化\"* — 文件持久化的任务图, 多 agent 协作的基础。\n>\n> **Harness 层**: 任务 — 持久化的目标, 可恢复的进度。\n\n---\n\n## 问题\n\nAgent 接到一个项目:搭数据库、写 API、加测试。它用 s05 的 TodoWrite 列了一张清单,然后开始写 API,写到一半发现没数据库表,回头补;加测试时发现 API 接口签名又变了...\n\n盖房子不能先盖屋顶再打地基。任务之间有先后。任务依赖应该形成有向无环图(DAG);教学版只演示 `blockedBy` 检查,没有实现环检测。\n\ns05 的 TodoWrite 是当前任务的执行清单,保存在会话内存中。这里需要的是**任务系统**:每个任务是一个 JSON 文件,任务之间有 `blockedBy` 依赖,跨会话持久化在磁盘上。\n\n---\n\n## 解决方案\n\n![Task System Overview](/course-assets/s12_task_system/task-system-overview.svg)\n\n教学代码保留基础 agent loop,为聚焦任务系统省略了 S11 的完整错误恢复(RecoveryState、退避、升级、reactive compact、fallback model)。新增 5 个任务工具 + `.tasks/` 目录持久化 + `blockedBy` 依赖检查。任务系统与错误恢复是独立层:CC 源码中 `utils/tasks.ts` 只管 CRUD,`query.ts` 的 with_retry/RecoveryState 管错误恢复,互不耦合。\n\nTodoWrite vs Task System:\n\n| | TodoWrite (s05) | Task System (s12) |\n|---|---|---|\n| 定位 | 当前任务的执行清单 | 可恢复的任务系统 |\n| 存储 | 进程内 / 会话状态 | `.tasks/{id}.json` |\n| 依赖 | 无 | `blockedBy` / `blocks` 依赖图 |\n| 生命周期 | 当前会话 / 当前任务 | 跨会话保留 |\n| 分工 | 不负责任务认领 | `owner` / claim |\n| 状态 | pending / in_progress / completed | pending / in_progress / completed |\n| 粒度 | Agent 自己的步骤 | 可被认领、追踪、解锁的任务 |\n\n---\n\n## 工作原理\n\n![Task DAG](/course-assets/s12_task_system/task-dag.svg)\n\n### Task: 数据结构\n\n每个任务是一个 JSON 文件,存于 `.tasks/` 目录:\n\n```python\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str # pending | in_progress | completed\n owner: str | None # Agent 名(多 Agent 场景)\n blockedBy: list[str] # 依赖的任务 ID 列表\n```\n\nID 用 `timestamp + random hex` 生成,简单但够用。CC 用顺序 ID + highwatermark 文件防止 ID 重用,是更严谨的设计。\n\n### create_task: 创建任务\n\n```python\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random_hex(4)}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n```\n\n创建时自动 `save_task` 到 `.tasks/{id}.json`。`blockedBy` 声明依赖,比如 \"写 API\" 的 `blockedBy` 是 `[\"task_schema\"]`。\n\n### can_start: 依赖检查\n\n一个任务只能在它的 `blockedBy` **全部 completed** 之后才能开始:\n\n```python\ndef can_start(task_id: str) -> bool:\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False # missing dependency = blocked\n dep = load_task(dep_id)\n if dep.status != \"completed\":\n return False\n return True\n```\n\n`can_start` 是 `claim_task` 的前置检查:`blockedBy` 里有任何一个不是 completed,就不能认领。不存在的依赖视为 blocked,避免引用错误 ID 时崩溃。\n\n### claim_task: 认领任务\n\nAgent 开始做一个任务时,调用 `claim_task`:设置 `owner`,状态从 `pending` → `in_progress`。`owner` 字段记录谁在做这个任务,多 Agent 场景下防止重复认领:\n\n```python\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if load_task(d).status != \"completed\"]\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n return f\"Claimed {task_id} ({task.subject})\"\n```\n\n如果任务已被别人认领(`status != \"pending\"`),或者依赖没完成(`can_start` 返回 False),拒绝认领。\n\n### complete_task: 完成与解锁\n\n任务做完后,设为 `completed`。同时扫描所有其他任务,找出**刚刚被解锁**的下游任务:\n\n```python\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n task.status = \"completed\"\n save_task(task)\n # 找出被解锁的下游任务\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy\n and can_start(t.id)]\n msg = f\"Completed {task_id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n return msg\n```\n\n完成 \"schema\" 后,\"endpoints\" 和 \"docs\" 的 `can_start` 返回 True,它们可以开始。\n\n### get_task: 查看完整细节\n\n`list_tasks` 只显示一行摘要。`get_task` 返回完整的任务 JSON,包括 description 和依赖细节。跨会话恢复时,Agent 需要读取完整描述才能继续工作:\n\n```python\ndef get_task(task_id: str) -> str:\n task = load_task(task_id)\n return json.dumps(asdict(task), indent=2)\n```\n\n### 状态机: 两个动作,三个状态\n\n```\npending ──claim──→ in_progress ──complete──→ completed\n```\n\n这里的 `claim` / `complete` 是动作,`pending` / `in_progress` / `completed` 是状态:\n\n- **claim_task**: `pending` → `in_progress`。设置 owner,开始工作。\n- **complete_task**: `in_progress` → `completed`。把任务标记为完成,并解锁下游。\n\nCC 没有 `in_progress → pending` 的 release 路径。如果 teammate 终止或 shutdown,CC 会把它未完成的任务 unassign(清除 owner),并将 status 重置为 `pending`,方便其他 agent 重新认领。教学版省略了这一恢复路径。\n\n### 合起来跑\n\n```python\n# 创建有依赖的任务\nschema = create_task(\"setup database schema\")\nendpoints = create_task(\"create API endpoints\", blockedBy=[schema.id])\ntests = create_task(\"write tests\", blockedBy=[endpoints.id])\ndocs = create_task(\"write docs\", blockedBy=[schema.id])\n\n# Agent 认领第一个可做的任务\nclaim_task(schema.id) # ✓ Claimed (无依赖)\ncomplete_task(schema.id) # ✓ Completed → 解锁 endpoints, docs\n\nclaim_task(endpoints.id) # ✓ Claimed (schema 已完成)\ncomplete_task(endpoints.id) # ✓ Completed → 解锁 tests\n\nclaim_task(docs.id) # ✓ Claimed (schema 已完成)\ncomplete_task(docs.id) # ✓ Completed\n\nclaim_task(tests.id) # ✓ Claimed (endpoints 已完成)\ncomplete_task(tests.id) # ✓ Completed\n```\n\n每个 `create_task` 写一个 JSON 文件,每个 `claim_task` / `complete_task` 更新文件。跨会话时,`.tasks/` 目录还在,Agent 读文件就能恢复进度。\n\n---\n\n## 相对 s11 的变更\n\n| 组件 | 之前 (s11) | 之后 (s12) |\n|------|-----------|-----------|\n| 任务管理 | 无 | Task dataclass + 5 个工具 |\n| 新类型 | — | Task(id, subject, description, status, owner, blockedBy) |\n| 存储 | 无持久化 | `.tasks/{id}.json` 跨会话 |\n| 依赖 | 无 | `blockedBy` 图 + `can_start` 检查 |\n| 工具 | bash, read_file, write_file (3) | + create_task, list_tasks, get_task, claim_task, complete_task (8) |\n| 生命周期 | — | pending → in_progress → completed(无 release 回退) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s12_task_system/code.py\n```\n\n试试这些 prompt:\n\n1. `Create tasks: setup database schema, create API endpoints (depends on schema), write tests (depends on endpoints), write docs (depends on schema)`\n2. `List all tasks and their statuses`\n3. `Claim the first unblocked task and complete it`\n4. `List tasks again — which ones are now unblocked?`\n\n观察重点:`.tasks/` 目录下是否生成了 JSON 文件?完成任务后,被阻塞的任务是否解锁?\n\n---\n\n## 接下来\n\n任务图有了。但有些任务要跑很久——比如全量测试、部署到服务器。Agent 调 LLM 按量计费,不能干等一个慢操作。\n\ns13 Background Tasks → 慢操作放后台。Agent 继续处理其他任务,后台跑完了通知它。\n\n
\n深入 CC 源码\n\n> 以下基于 CC 源码 `utils/tasks.ts`(862 行)、`tools/TaskCreateTool/TaskCreateTool.ts`(138 行)、`tools/TaskUpdateTool/TaskUpdateTool.ts`(406 行)、`tools/TaskGetTool/TaskGetTool.ts`(128 行)、`tools/TaskListTool/TaskListTool.ts`(116 行)、`hooks/useTaskListWatcher.ts`(221 行)的分析。\n\n### 一、TaskRecord 的完整字段\n\n教学版只讲了 id、subject、status、owner、blockedBy。CC 实际有 9 个字段(`utils/tasks.ts:76-89`):\n\n| 字段 | 类型 | 用途 |\n|------|------|------|\n| `id` | string | 递增整数 ID |\n| `subject` | string | 简短标题 |\n| `description` | string | 自由格式描述 |\n| `activeForm` | string? | 进行时态,in_progress 时在 spinner 显示 |\n| `owner` | string? | 分配的 agent ID |\n| `status` | pending/in_progress/completed | 生命周期 |\n| `blocks` | string[] | 此任务阻塞的任务 ID(下游) |\n| `blockedBy` | string[] | 阻塞此任务的任务 ID(上游) |\n| `metadata` | Record? | 任意扩展键值对 |\n\n存储位置:`~/.claude/tasks/{taskListId}/{id}.json`。每个任务一个文件。\n\n### 二、不是 TodoWrite 的升级,是两个独立系统\n\nCC 中 Task System 和 TodoWrite **同时存在**,通过 `isTodoV2Enabled()` 切换(`utils/tasks.ts:133`)——交互式会话默认启用 Task(V2),非交互式/SDK 默认用 TodoWrite。环境变量 `CLAUDE_CODE_ENABLE_TASKS` 可强制启用 Task。Task 有 TodoWrite 没有的:文件锁并发保护、依赖强制执行、ownership、fs.watch 响应式监听、生命周期 hooks。\n\n### 三、并发认领的锁机制\n\n`claimTask()`(`utils/tasks.ts:541-612`)用双重锁防竞争:\n\n**任务文件锁**:`proper-lockfile` 锁住 `{taskId}.json`(最多重试 30 次,指数退避 5-100ms)。锁内:\n1. 重新读取任务(防 TOCTOU)\n2. 检查已被他人认领 → `already_claimed`\n3. 检查已完成 → `already_resolved`\n4. 检查上游未完成 → `blocked`\n5. 设置 owner\n\n**列表级锁**(agent busy 检查时):`.lock` 文件,原子性扫描所有任务并检查该 agent 是否已有其他 open task。\n\n注意:教学版把 claim 和开始工作合成一步(claim = set owner + in_progress);真实 CC 的 `claimTask` 主要解决 owner 竞争,只设 owner 不改 status,状态更新由 `TaskUpdate` 完成。\n\n### 四、高水位标防 ID 重用\n\n`.highwatermark` 文件记录曾分配过的最高任务 ID。即使任务被删除,ID 也不会被重用。\n\n### 五、四个 Task 工具\n\nCC 的任务系统有四个工具(不是教学版的一个通用 Task 工具):`TaskCreate`、`TaskGet`、`TaskUpdate`、`TaskList`。全部设置 `isConcurrencySafe: true` 和 `shouldDefer: true`(工具 schema 不在初始 prompt 中,需 ToolSearch 后才可见)。\n\n教学版的 `create_task(blockedBy=...)` 在创建时直接声明依赖,是合理简化。真实 CC 的 `TaskCreate` 只接受 subject/description/activeForm/metadata,依赖关系由 `TaskUpdate` 的 `addBlocks/addBlockedBy` 维护。\n\n
\n\n\n" + "content": "# s12: Task System — 目标太大,拆成小任务\n\ns01 → ... → s10 → s11 → `s12` → [s13](/zh/s13) → s14 → ... → s20\n\n> *\"大目标拆成小任务, 排好序, 持久化\"* — 文件持久化的任务图, 多 agent 协作的基础。\n>\n> **Harness 层**: 任务 — 持久化的目标, 可恢复的进度。\n\n---\n\n## 问题\n\nAgent 接到一个项目:搭数据库、写 API、加测试。它用 s05 的 TodoWrite 列了一张清单,然后开始写 API,写到一半发现没数据库表,回头补;加测试时发现 API 接口签名又变了...\n\n盖房子不能先盖屋顶再打地基。任务之间有先后。任务依赖应该形成有向无环图(DAG);教学版只演示 `blockedBy` 检查,没有实现环检测。\n\ns05 的 TodoWrite 是当前任务的执行清单,保存在会话内存中。这里需要的是**任务系统**:每个任务是一个 JSON 文件,任务之间有 `blockedBy` 依赖,跨会话持久化在磁盘上。\n\n---\n\n## 解决方案\n\n![Task System Overview](/course-assets/s12_task_system/task-system-overview.svg)\n\n教学代码保留基础 agent loop,为聚焦任务系统省略了 S11 的完整错误恢复(RecoveryState、退避、升级、reactive compact、fallback model)。新增 5 个任务工具 + `.tasks/` 目录持久化 + `blockedBy` 依赖检查。任务系统与错误恢复是独立层:Claude Code 源码中 `utils/tasks.ts` 只管 CRUD,`query.ts` 的 with_retry/RecoveryState 管错误恢复,互不耦合。\n\nTodoWrite vs Task System:\n\n| | TodoWrite (s05) | Task System (s12) |\n|---|---|---|\n| 定位 | 当前任务的执行清单 | 可恢复的任务系统 |\n| 存储 | 进程内 / 会话状态 | `.tasks/{id}.json` |\n| 依赖 | 无 | `blockedBy` / `blocks` 依赖图 |\n| 生命周期 | 当前会话 / 当前任务 | 跨会话保留 |\n| 分工 | 不负责任务认领 | `owner` / claim |\n| 状态 | pending / in_progress / completed | pending / in_progress / completed |\n| 粒度 | Agent 自己的步骤 | 可被认领、追踪、解锁的任务 |\n\n---\n\n## 工作原理\n\n![Task DAG](/course-assets/s12_task_system/task-dag.svg)\n\n### Task: 数据结构\n\n每个任务是一个 JSON 文件,存于 `.tasks/` 目录:\n\n```python\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str # pending | in_progress | completed\n owner: str | None # Agent 名(多 Agent 场景)\n blockedBy: list[str] # 依赖的任务 ID 列表\n```\n\nID 用 `timestamp + random hex` 生成,简单但够用。Claude Code 用顺序 ID + highwatermark 文件防止 ID 重用,是更严谨的设计。\n\n### create_task: 创建任务\n\n```python\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random_hex(4)}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n```\n\n创建时自动 `save_task` 到 `.tasks/{id}.json`。`blockedBy` 声明依赖,比如 \"写 API\" 的 `blockedBy` 是 `[\"task_schema\"]`。\n\n### can_start: 依赖检查\n\n一个任务只能在它的 `blockedBy` **全部 completed** 之后才能开始:\n\n```python\ndef can_start(task_id: str) -> bool:\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False # missing dependency = blocked\n dep = load_task(dep_id)\n if dep.status != \"completed\":\n return False\n return True\n```\n\n`can_start` 是 `claim_task` 的前置检查:`blockedBy` 里有任何一个不是 completed,就不能认领。不存在的依赖视为 blocked,避免引用错误 ID 时崩溃。\n\n### claim_task: 认领任务\n\nAgent 开始做一个任务时,调用 `claim_task`:设置 `owner`,状态从 `pending` → `in_progress`。`owner` 字段记录谁在做这个任务,多 Agent 场景下防止重复认领:\n\n```python\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if load_task(d).status != \"completed\"]\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n return f\"Claimed {task_id} ({task.subject})\"\n```\n\n如果任务已被别人认领(`status != \"pending\"`),或者依赖没完成(`can_start` 返回 False),拒绝认领。\n\n### complete_task: 完成与解锁\n\n任务做完后,设为 `completed`。同时扫描所有其他任务,找出**刚刚被解锁**的下游任务:\n\n```python\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n task.status = \"completed\"\n save_task(task)\n # 找出被解锁的下游任务\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy\n and can_start(t.id)]\n msg = f\"Completed {task_id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n return msg\n```\n\n完成 \"schema\" 后,\"endpoints\" 和 \"docs\" 的 `can_start` 返回 True,它们可以开始。\n\n### get_task: 查看完整细节\n\n`list_tasks` 只显示一行摘要。`get_task` 返回完整的任务 JSON,包括 description 和依赖细节。跨会话恢复时,Agent 需要读取完整描述才能继续工作:\n\n```python\ndef get_task(task_id: str) -> str:\n task = load_task(task_id)\n return json.dumps(asdict(task), indent=2)\n```\n\n### 状态机: 两个动作,三个状态\n\n```\npending ──claim──→ in_progress ──complete──→ completed\n```\n\n这里的 `claim` / `complete` 是动作,`pending` / `in_progress` / `completed` 是状态:\n\n- **claim_task**: `pending` → `in_progress`。设置 owner,开始工作。\n- **complete_task**: `in_progress` → `completed`。把任务标记为完成,并解锁下游。\n\nClaude Code 没有 `in_progress → pending` 的 release 路径。如果 teammate 终止或 shutdown,Claude Code 会把它未完成的任务 unassign(清除 owner),并将 status 重置为 `pending`,方便其他 agent 重新认领。教学版省略了这一恢复路径。\n\n### 合起来跑\n\n```python\n# 创建有依赖的任务\nschema = create_task(\"setup database schema\")\nendpoints = create_task(\"create API endpoints\", blockedBy=[schema.id])\ntests = create_task(\"write tests\", blockedBy=[endpoints.id])\ndocs = create_task(\"write docs\", blockedBy=[schema.id])\n\n# Agent 认领第一个可做的任务\nclaim_task(schema.id) # ✓ Claimed (无依赖)\ncomplete_task(schema.id) # ✓ Completed → 解锁 endpoints, docs\n\nclaim_task(endpoints.id) # ✓ Claimed (schema 已完成)\ncomplete_task(endpoints.id) # ✓ Completed → 解锁 tests\n\nclaim_task(docs.id) # ✓ Claimed (schema 已完成)\ncomplete_task(docs.id) # ✓ Completed\n\nclaim_task(tests.id) # ✓ Claimed (endpoints 已完成)\ncomplete_task(tests.id) # ✓ Completed\n```\n\n每个 `create_task` 写一个 JSON 文件,每个 `claim_task` / `complete_task` 更新文件。跨会话时,`.tasks/` 目录还在,Agent 读文件就能恢复进度。\n\n---\n\n## 相对 s11 的变更\n\n| 组件 | 之前 (s11) | 之后 (s12) |\n|------|-----------|-----------|\n| 任务管理 | 无 | Task dataclass + 5 个工具 |\n| 新类型 | — | Task(id, subject, description, status, owner, blockedBy) |\n| 存储 | 无持久化 | `.tasks/{id}.json` 跨会话 |\n| 依赖 | 无 | `blockedBy` 图 + `can_start` 检查 |\n| 工具 | bash, read_file, write_file (3) | + create_task, list_tasks, get_task, claim_task, complete_task (8) |\n| 生命周期 | — | pending → in_progress → completed(无 release 回退) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s12_task_system/code.py\n```\n\n试试这些 prompt:\n\n1. `Create tasks: setup database schema, create API endpoints (depends on schema), write tests (depends on endpoints), write docs (depends on schema)`\n2. `List all tasks and their statuses`\n3. `Claim the first unblocked task and complete it`\n4. `List tasks again — which ones are now unblocked?`\n\n观察重点:`.tasks/` 目录下是否生成了 JSON 文件?完成任务后,被阻塞的任务是否解锁?\n\n---\n\n## 接下来\n\n任务图有了。但有些任务要跑很久——比如全量测试、部署到服务器。Agent 调 LLM 按量计费,不能干等一个慢操作。\n\ns13 Background Tasks → 慢操作放后台。Agent 继续处理其他任务,后台跑完了通知它。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `utils/tasks.ts`(862 行)、`tools/TaskCreateTool/TaskCreateTool.ts`(138 行)、`tools/TaskUpdateTool/TaskUpdateTool.ts`(406 行)、`tools/TaskGetTool/TaskGetTool.ts`(128 行)、`tools/TaskListTool/TaskListTool.ts`(116 行)、`hooks/useTaskListWatcher.ts`(221 行)的分析。\n\n### 一、TaskRecord 的完整字段\n\n教学版只讲了 id、subject、status、owner、blockedBy。Claude Code 实际有 9 个字段(`utils/tasks.ts:76-89`):\n\n| 字段 | 类型 | 用途 |\n|------|------|------|\n| `id` | string | 递增整数 ID |\n| `subject` | string | 简短标题 |\n| `description` | string | 自由格式描述 |\n| `activeForm` | string? | 进行时态,in_progress 时在 spinner 显示 |\n| `owner` | string? | 分配的 agent ID |\n| `status` | pending/in_progress/completed | 生命周期 |\n| `blocks` | string[] | 此任务阻塞的任务 ID(下游) |\n| `blockedBy` | string[] | 阻塞此任务的任务 ID(上游) |\n| `metadata` | Record? | 任意扩展键值对 |\n\n存储位置:`~/.claude/tasks/{taskListId}/{id}.json`。每个任务一个文件。\n\n### 二、不是 TodoWrite 的升级,是两个独立系统\n\nClaude Code 中 Task System 和 TodoWrite **同时存在**,通过 `isTodoV2Enabled()` 切换(`utils/tasks.ts:133`)——交互式会话默认启用 Task(V2),非交互式/SDK 默认用 TodoWrite。环境变量 `CLAUDE_CODE_ENABLE_TASKS` 可强制启用 Task。Task 有 TodoWrite 没有的:文件锁并发保护、依赖强制执行、ownership、fs.watch 响应式监听、生命周期 hooks。\n\n### 三、并发认领的锁机制\n\n`claimTask()`(`utils/tasks.ts:541-612`)用双重锁防竞争:\n\n**任务文件锁**:`proper-lockfile` 锁住 `{taskId}.json`(最多重试 30 次,指数退避 5-100ms)。锁内:\n1. 重新读取任务(防 TOCTOU)\n2. 检查已被他人认领 → `already_claimed`\n3. 检查已完成 → `already_resolved`\n4. 检查上游未完成 → `blocked`\n5. 设置 owner\n\n**列表级锁**(agent busy 检查时):`.lock` 文件,原子性扫描所有任务并检查该 agent 是否已有其他 open task。\n\n注意:教学版把 claim 和开始工作合成一步(claim = set owner + in_progress);真实 Claude Code 的 `claimTask` 主要解决 owner 竞争,只设 owner 不改 status,状态更新由 `TaskUpdate` 完成。\n\n### 四、高水位标防 ID 重用\n\n`.highwatermark` 文件记录曾分配过的最高任务 ID。即使任务被删除,ID 也不会被重用。\n\n### 五、四个 Task 工具\n\nClaude Code 的任务系统有四个工具(不是教学版的一个通用 Task 工具):`TaskCreate`、`TaskGet`、`TaskUpdate`、`TaskList`。全部设置 `isConcurrencySafe: true` 和 `shouldDefer: true`(工具 schema 不在初始 prompt 中,需 ToolSearch 后才可见)。\n\n教学版的 `create_task(blockedBy=...)` 在创建时直接声明依赖,是合理简化。真实 Claude Code 的 `TaskCreate` 只接受 subject/description/activeForm/metadata,依赖关系由 `TaskUpdate` 的 `addBlocks/addBlockedBy` 维护。\n\n
\n\n\n" }, { "version": "s12", "locale": "ja", "title": "s12: Task System — 大きな目標を小さなタスクに分割", - "content": "# s12: Task System — 大きな目標を小さなタスクに分割\n\ns01 → ... → s10 → s11 → `s12` → [s13](/ja/s13) → s14 → ... → s20\n\n> *\"大きな目標を小さなタスクに分け、順序付け、永続化\"* — ファイル永続化タスクグラフ、マルチ Agent 協調の基盤。\n>\n> **Harness 層**: タスク — 永続化された目標、復旧可能な進捗。\n\n---\n\n## 課題\n\nAgent がプロジェクトを受けた:データベース構築、API 実装、テスト追加。s05 の TodoWrite でリストを作り、まず API を書き始め、途中でデータベーステーブルがないことに気づいて戻る。テスト追加時に API インターフェースのシグネチャがまた変わっている...\n\n屋根を先に建てて基礎を後から打つことはできない。タスクには順序がある。タスクの依存関係は有向非巡回グラフ(DAG)を形成すべき;教学版は `blockedBy` チェックのみをデモし、循環検出は実装していない。\n\ns05 の TodoWrite は現在のタスクの実行チェックリストで、セッションメモリに保持される。ここで必要なのは**タスクシステム**:各タスクは JSON ファイル、タスク間に `blockedBy` 依存関係、ディスク上でセッションをまたいで永続化。\n\n---\n\n## ソリューション\n\n![Task System Overview](/course-assets/s12_task_system/task-system-overview.ja.svg)\n\n教学版は基本 agent loop を維持し、タスクシステムに集中するため S11 の完全なエラーリカバリ(RecoveryState、バックオフ、エスカレーション、reactive compact、フォールバックモデル)を省略。追加:5 つの新規タスクツール + `.tasks/` ディレクトリによる永続化 + `blockedBy` 依存チェック。タスクシステムとエラーリカバリは独立したレイヤー:CC ソースコードでは `utils/tasks.ts` は CRUD のみ、`query.ts` の with_retry/RecoveryState がエラーリカバリを担当し、互いに非結合。\n\nTodoWrite vs Task System:\n\n| | TodoWrite (s05) | Task System (s12) |\n|---|---|---|\n| 位置づけ | 現在のタスクの実行チェックリスト | 復旧可能なタスクシステム |\n| ストレージ | プロセス内 / セッション状態 | `.tasks/{id}.json` |\n| 依存関係 | なし | `blockedBy` / `blocks` グラフ |\n| ライフサイクル | 現在のセッション / 現在のタスク | セッション横断 |\n| 分担 | タスク認識を扱わない | `owner` / claim |\n| ステータス | pending / in_progress / completed | pending / in_progress / completed |\n| 粒度 | Agent 自身の手順 | 認識・追跡・アンロックできるタスク |\n\n---\n\n## 仕組み\n\n![Task DAG](/course-assets/s12_task_system/task-dag.ja.svg)\n\n### Task: データ構造\n\n各タスクは JSON ファイル、`.tasks/` ディレクトリに保存:\n\n```python\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str # pending | in_progress | completed\n owner: str | None # Agent 名(マルチ Agent シナリオ)\n blockedBy: list[str] # 依存タスク ID のリスト\n```\n\nID は `timestamp + random hex` で生成、シンプルだが十分。CC は順次 ID + highwatermark ファイルで ID 再利用を防止する、より厳密な設計。\n\n### create_task: タスク作成\n\n```python\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random_hex(4)}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n```\n\n作成時に自動的に `save_task` で `.tasks/{id}.json` に書き込み。`blockedBy` で依存を宣言、例えば \"API を書く\" の `blockedBy` は `[\"task_schema\"]`。\n\n### can_start: 依存チェック\n\nタスクは `blockedBy` が**すべて completed** になってからでないと開始できない:\n\n```python\ndef can_start(task_id: str) -> bool:\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False # missing dependency = blocked\n dep = load_task(dep_id)\n if dep.status != \"completed\":\n return False\n return True\n```\n\n`can_start` は `claim_task` の事前チェック:`blockedBy` に一つでも completed でないものがあれば、認識不可。存在しない依存は blocked として扱い、誤った ID 参照時のクラッシュを防ぐ。\n\n### claim_task: タスク認識\n\nAgent がタスクに取り掛かる時、`claim_task` を呼び出し:`owner` を設定、ステータスを `pending` → `in_progress` に変更。`owner` フィールドは誰が作業中かを記録し、マルチ Agent シナリオで重複認識を防止:\n\n```python\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if load_task(d).status != \"completed\"]\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n return f\"Claimed {task_id} ({task.subject})\"\n```\n\nタスクが既に他者に認識されている(`status != \"pending\"`)、または依存が未完了(`can_start` が False)の場合、認識を拒否。\n\n### complete_task: 完了とアンロック\n\nタスク完了後、`completed` に設定。同時に他の全タスクを走査し、**直前にアンロックされた**下流タスクを特定:\n\n```python\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n task.status = \"completed\"\n save_task(task)\n # アンロックされた下流タスクを検索\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy\n and can_start(t.id)]\n msg = f\"Completed {task_id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n return msg\n```\n\n\"schema\" 完了後、\"endpoints\" と \"docs\" の `can_start` が True を返し、開始可能になる。\n\n### get_task: 完全な詳細を確認\n\n`list_tasks` は 1 行サマリのみ表示。`get_task` は description と依存関係の詳細を含む完全なタスク JSON を返す。セッションをまたいで復旧する際、Agent は完全な説明を読んで作業を継続する必要がある:\n\n```python\ndef get_task(task_id: str) -> str:\n task = load_task(task_id)\n return json.dumps(asdict(task), indent=2)\n```\n\n### 状態マシン: 2 つのアクション、3 つの状態\n\n```\npending ──claim──→ in_progress ──complete──→ completed\n```\n\nここで `claim` / `complete` はアクション、`pending` / `in_progress` / `completed` は状態:\n\n- **claim_task**: `pending` → `in_progress`。owner を設定し、作業を開始。\n- **complete_task**: `in_progress` → `completed`。タスクを完了済みにし、下流をアンロック。\n\nCC には `in_progress → pending` の release パスがない。teammate が終了または shutdown した場合、CC は未完了タスクの owner をクリアし、status を `pending` にリセットし、他の agent が再認識できるようにする。教学版はこの復旧パスを省略。\n\n### 組み合わせて実行\n\n```python\n# 依存関係のあるタスクを作成\nschema = create_task(\"setup database schema\")\nendpoints = create_task(\"create API endpoints\", blockedBy=[schema.id])\ntests = create_task(\"write tests\", blockedBy=[endpoints.id])\ndocs = create_task(\"write docs\", blockedBy=[schema.id])\n\n# Agent が最初に実行可能なタスクを認識\nclaim_task(schema.id) # ✓ Claimed(依存なし)\ncomplete_task(schema.id) # ✓ Completed → endpoints, docs をアンロック\n\nclaim_task(endpoints.id) # ✓ Claimed(schema 完了済み)\ncomplete_task(endpoints.id) # ✓ Completed → tests をアンロック\n\nclaim_task(docs.id) # ✓ Claimed(schema 完了済み)\ncomplete_task(docs.id) # ✓ Completed\n\nclaim_task(tests.id) # ✓ Claimed(endpoints 完了済み)\ncomplete_task(tests.id) # ✓ Completed\n```\n\n各 `create_task` が JSON ファイルを書き込み、各 `claim_task` / `complete_task` がファイルを更新。セッションをまたいでも `.tasks/` ディレクトリが残り、Agent はファイルを読んで進捗を復旧。\n\n---\n\n## s11 からの変更\n\n| コンポーネント | 変更前 (s11) | 変更後 (s12) |\n|--------------|------------|------------|\n| タスク管理 | なし | Task dataclass + 5 ツール |\n| 新規型 | — | Task(id, subject, description, status, owner, blockedBy) |\n| ストレージ | 永続化なし | `.tasks/{id}.json` セッション横断 |\n| 依存関係 | なし | `blockedBy` グラフ + `can_start` チェック |\n| ツール | bash, read_file, write_file (3) | + create_task, list_tasks, get_task, claim_task, complete_task (8) |\n| ライフサイクル | — | pending → in_progress → completed(release ロールバックなし) |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s12_task_system/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `Create tasks: setup database schema, create API endpoints (depends on schema), write tests (depends on endpoints), write docs (depends on schema)`\n2. `List all tasks and their statuses`\n3. `Claim the first unblocked task and complete it`\n4. `List tasks again — which ones are now unblocked?`\n\n観察ポイント:`.tasks/` ディレクトリに JSON ファイルが生成されているか?タスク完了後、ブロックされていたタスクがアンロックされているか?\n\n---\n\n## 次の章\n\nタスクグラフができた。しかし、一部のタスクは長時間かかる — 全テスト実行やサーバーデプロイなど。Agent は LLM をトークン課金で呼び出しており、遅い操作を待つ余裕はない。\n\ns13 Background Tasks → 遅い操作はバックグラウンドへ。Agent は他のタスクの処理を続け、バックグラウンドの完了を通知で受け取る。\n\n
\nCC ソースコード深掘り\n\n> 以下は CC ソースコード `utils/tasks.ts`(862 行)、`tools/TaskCreateTool/TaskCreateTool.ts`(138 行)、`tools/TaskUpdateTool/TaskUpdateTool.ts`(406 行)、`tools/TaskGetTool/TaskGetTool.ts`(128 行)、`tools/TaskListTool/TaskListTool.ts`(116 行)、`hooks/useTaskListWatcher.ts`(221 行)の完全分析に基づく。\n\n### 一、TaskRecord の完全フィールド\n\nチュートリアルでは id、subject、status、owner、blockedBy のみ解説。CC は実際に 9 フィールドを持つ(`utils/tasks.ts:76-89`):\n\n| フィールド | 型 | 用途 |\n|------|------|------|\n| `id` | string | 昇順整数 ID |\n| `subject` | string | 短いタイトル |\n| `description` | string | 自由形式の説明 |\n| `activeForm` | string? | 現在進行形、in_progress 時にスピナーに表示 |\n| `owner` | string? | 割り当てられた agent ID |\n| `status` | pending/in_progress/completed | ライフサイクル |\n| `blocks` | string[] | このタスクがブロックするタスク ID(下流) |\n| `blockedBy` | string[] | このタスクをブロックするタスク ID(上流) |\n| `metadata` | Record? | 任意の拡張キーバリューペア |\n\n保存場所:`~/.claude/tasks/{taskListId}/{id}.json`。タスクごとに 1 ファイル。\n\n### 二、TodoWrite のアップグレードではなく、2 つの独立システム\n\nCC では Task System と TodoWrite **は共存**し、`isTodoV2Enabled()` で切り替え(`utils/tasks.ts:133`)— 対話セッションはデフォルトで Task (V2)、非対話/SDK セッションは TodoWrite。環境変数 `CLAUDE_CODE_ENABLE_TASKS` で Task を強制有効化可能。Task は TodoWrite にない機能を持つ:ファイルロック並行保護、依存関係強制、ownership、fs.watch リアクティブ監視、ライフサイクルフック。\n\n### 三、並行認識のロック機構\n\n`claimTask()`(`utils/tasks.ts:541-612`)は二重ロックで競合を防止:\n\n**タスクファイルロック**:`proper-lockfile` で `{taskId}.json` をロック(最大 30 リトライ、指数バックオフ 5-100ms)。ロック内:\n1. タスクを再読込(TOCTOU 防止)\n2. 既に他者が認識済み → `already_claimed`\n3. 既に完了済み → `already_resolved`\n4. 上流が未完了 → `blocked`\n5. owner を設定\n\n**リストレベルロック**(agent busy チェック時):`.lock` ファイル、全タスクを原子的に走査し該当 agent が他の open task を持つか確認。\n\n注意:教学版は認識と作業開始を 1 ステップに統合(claim = owner 設定 + in_progress);実際の CC の `claimTask` は主に owner 競合を解決し、owner のみを設定して status は変更しない。status の更新は `TaskUpdate` が担当。\n\n### 四、高水位標による ID 再利用防止\n\n`.highwatermark` ファイルが過去に割り当てられた最大タスク ID を記録。タスクが削除されても ID は再利用されない。\n\n### 五、4 つの Task ツール\n\nCC のタスクシステムは 4 つのツールを持つ(チュートリアルの汎用 Task ツールとは異なる):`TaskCreate`、`TaskGet`、`TaskUpdate`、`TaskList`。すべて `isConcurrencySafe: true` と `shouldDefer: true` が設定(ツールスキーマは初期プロンプトに含まれず、ToolSearch 後にのみ可視)。\n\n教学版の `create_task(blockedBy=...)` は作成時に直接依存を宣言する合理な簡略化。実際の CC の `TaskCreate` は subject/description/activeForm/metadata のみを受け付け、依存関係は `TaskUpdate` の `addBlocks/addBlockedBy` で管理される。\n\n
\n\n\n" + "content": "# s12: Task System — 大きな目標を小さなタスクに分割\n\ns01 → ... → s10 → s11 → `s12` → [s13](/ja/s13) → s14 → ... → s20\n\n> *\"大きな目標を小さなタスクに分け、順序付け、永続化\"* — ファイル永続化タスクグラフ、マルチ Agent 協調の基盤。\n>\n> **Harness 層**: タスク — 永続化された目標、復旧可能な進捗。\n\n---\n\n## 課題\n\nAgent がプロジェクトを受けた:データベース構築、API 実装、テスト追加。s05 の TodoWrite でリストを作り、まず API を書き始め、途中でデータベーステーブルがないことに気づいて戻る。テスト追加時に API インターフェースのシグネチャがまた変わっている...\n\n屋根を先に建てて基礎を後から打つことはできない。タスクには順序がある。タスクの依存関係は有向非巡回グラフ(DAG)を形成すべき;教学版は `blockedBy` チェックのみをデモし、循環検出は実装していない。\n\ns05 の TodoWrite は現在のタスクの実行チェックリストで、セッションメモリに保持される。ここで必要なのは**タスクシステム**:各タスクは JSON ファイル、タスク間に `blockedBy` 依存関係、ディスク上でセッションをまたいで永続化。\n\n---\n\n## ソリューション\n\n![Task System Overview](/course-assets/s12_task_system/task-system-overview.svg)\n\n教学版は基本 agent loop を維持し、タスクシステムに集中するため S11 の完全なエラーリカバリ(RecoveryState、バックオフ、エスカレーション、reactive compact、フォールバックモデル)を省略。追加:5 つの新規タスクツール + `.tasks/` ディレクトリによる永続化 + `blockedBy` 依存チェック。タスクシステムとエラーリカバリは独立したレイヤー:Claude Code ソースコードでは `utils/tasks.ts` は CRUD のみ、`query.ts` の with_retry/RecoveryState がエラーリカバリを担当し、互いに非結合。\n\nTodoWrite vs Task System:\n\n| | TodoWrite (s05) | Task System (s12) |\n|---|---|---|\n| 位置づけ | 現在のタスクの実行チェックリスト | 復旧可能なタスクシステム |\n| ストレージ | プロセス内 / セッション状態 | `.tasks/{id}.json` |\n| 依存関係 | なし | `blockedBy` / `blocks` グラフ |\n| ライフサイクル | 現在のセッション / 現在のタスク | セッション横断 |\n| 分担 | タスク認識を扱わない | `owner` / claim |\n| ステータス | pending / in_progress / completed | pending / in_progress / completed |\n| 粒度 | Agent 自身の手順 | 認識・追跡・アンロックできるタスク |\n\n---\n\n## 仕組み\n\n![Task DAG](/course-assets/s12_task_system/task-dag.svg)\n\n### Task: データ構造\n\n各タスクは JSON ファイル、`.tasks/` ディレクトリに保存:\n\n```python\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str # pending | in_progress | completed\n owner: str | None # Agent 名(マルチ Agent シナリオ)\n blockedBy: list[str] # 依存タスク ID のリスト\n```\n\nID は `timestamp + random hex` で生成、シンプルだが十分。Claude Code は順次 ID + highwatermark ファイルで ID 再利用を防止する、より厳密な設計。\n\n### create_task: タスク作成\n\n```python\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random_hex(4)}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n```\n\n作成時に自動的に `save_task` で `.tasks/{id}.json` に書き込み。`blockedBy` で依存を宣言、例えば \"API を書く\" の `blockedBy` は `[\"task_schema\"]`。\n\n### can_start: 依存チェック\n\nタスクは `blockedBy` が**すべて completed** になってからでないと開始できない:\n\n```python\ndef can_start(task_id: str) -> bool:\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False # missing dependency = blocked\n dep = load_task(dep_id)\n if dep.status != \"completed\":\n return False\n return True\n```\n\n`can_start` は `claim_task` の事前チェック:`blockedBy` に一つでも completed でないものがあれば、認識不可。存在しない依存は blocked として扱い、誤った ID 参照時のクラッシュを防ぐ。\n\n### claim_task: タスク認識\n\nAgent がタスクに取り掛かる時、`claim_task` を呼び出し:`owner` を設定、ステータスを `pending` → `in_progress` に変更。`owner` フィールドは誰が作業中かを記録し、マルチ Agent シナリオで重複認識を防止:\n\n```python\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if load_task(d).status != \"completed\"]\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n return f\"Claimed {task_id} ({task.subject})\"\n```\n\nタスクが既に他者に認識されている(`status != \"pending\"`)、または依存が未完了(`can_start` が False)の場合、認識を拒否。\n\n### complete_task: 完了とアンロック\n\nタスク完了後、`completed` に設定。同時に他の全タスクを走査し、**直前にアンロックされた**下流タスクを特定:\n\n```python\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n task.status = \"completed\"\n save_task(task)\n # アンロックされた下流タスクを検索\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy\n and can_start(t.id)]\n msg = f\"Completed {task_id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n return msg\n```\n\n\"schema\" 完了後、\"endpoints\" と \"docs\" の `can_start` が True を返し、開始可能になる。\n\n### get_task: 完全な詳細を確認\n\n`list_tasks` は 1 行サマリのみ表示。`get_task` は description と依存関係の詳細を含む完全なタスク JSON を返す。セッションをまたいで復旧する際、Agent は完全な説明を読んで作業を継続する必要がある:\n\n```python\ndef get_task(task_id: str) -> str:\n task = load_task(task_id)\n return json.dumps(asdict(task), indent=2)\n```\n\n### 状態マシン: 2 つのアクション、3 つの状態\n\n```\npending ──claim──→ in_progress ──complete──→ completed\n```\n\nここで `claim` / `complete` はアクション、`pending` / `in_progress` / `completed` は状態:\n\n- **claim_task**: `pending` → `in_progress`。owner を設定し、作業を開始。\n- **complete_task**: `in_progress` → `completed`。タスクを完了済みにし、下流をアンロック。\n\nClaude Code には `in_progress → pending` の release パスがない。teammate が終了または shutdown した場合、Claude Code は未完了タスクの owner をクリアし、status を `pending` にリセットし、他の agent が再認識できるようにする。教学版はこの復旧パスを省略。\n\n### 組み合わせて実行\n\n```python\n# 依存関係のあるタスクを作成\nschema = create_task(\"setup database schema\")\nendpoints = create_task(\"create API endpoints\", blockedBy=[schema.id])\ntests = create_task(\"write tests\", blockedBy=[endpoints.id])\ndocs = create_task(\"write docs\", blockedBy=[schema.id])\n\n# Agent が最初に実行可能なタスクを認識\nclaim_task(schema.id) # ✓ Claimed(依存なし)\ncomplete_task(schema.id) # ✓ Completed → endpoints, docs をアンロック\n\nclaim_task(endpoints.id) # ✓ Claimed(schema 完了済み)\ncomplete_task(endpoints.id) # ✓ Completed → tests をアンロック\n\nclaim_task(docs.id) # ✓ Claimed(schema 完了済み)\ncomplete_task(docs.id) # ✓ Completed\n\nclaim_task(tests.id) # ✓ Claimed(endpoints 完了済み)\ncomplete_task(tests.id) # ✓ Completed\n```\n\n各 `create_task` が JSON ファイルを書き込み、各 `claim_task` / `complete_task` がファイルを更新。セッションをまたいでも `.tasks/` ディレクトリが残り、Agent はファイルを読んで進捗を復旧。\n\n---\n\n## s11 からの変更\n\n| コンポーネント | 変更前 (s11) | 変更後 (s12) |\n|--------------|------------|------------|\n| タスク管理 | なし | Task dataclass + 5 ツール |\n| 新規型 | — | Task(id, subject, description, status, owner, blockedBy) |\n| ストレージ | 永続化なし | `.tasks/{id}.json` セッション横断 |\n| 依存関係 | なし | `blockedBy` グラフ + `can_start` チェック |\n| ツール | bash, read_file, write_file (3) | + create_task, list_tasks, get_task, claim_task, complete_task (8) |\n| ライフサイクル | — | pending → in_progress → completed(release ロールバックなし) |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s12_task_system/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `Create tasks: setup database schema, create API endpoints (depends on schema), write tests (depends on endpoints), write docs (depends on schema)`\n2. `List all tasks and their statuses`\n3. `Claim the first unblocked task and complete it`\n4. `List tasks again — which ones are now unblocked?`\n\n観察ポイント:`.tasks/` ディレクトリに JSON ファイルが生成されているか?タスク完了後、ブロックされていたタスクがアンロックされているか?\n\n---\n\n## 次の章\n\nタスクグラフができた。しかし、一部のタスクは長時間かかる — 全テスト実行やサーバーデプロイなど。Agent は LLM をトークン課金で呼び出しており、遅い操作を待つ余裕はない。\n\ns13 Background Tasks → 遅い操作はバックグラウンドへ。Agent は他のタスクの処理を続け、バックグラウンドの完了を通知で受け取る。\n\n
\nClaude Code ソースコード深掘り\n\n> 以下は Claude Code ソースコード `utils/tasks.ts`(862 行)、`tools/TaskCreateTool/TaskCreateTool.ts`(138 行)、`tools/TaskUpdateTool/TaskUpdateTool.ts`(406 行)、`tools/TaskGetTool/TaskGetTool.ts`(128 行)、`tools/TaskListTool/TaskListTool.ts`(116 行)、`hooks/useTaskListWatcher.ts`(221 行)の完全分析に基づく。\n\n### 一、TaskRecord の完全フィールド\n\nチュートリアルでは id、subject、status、owner、blockedBy のみ解説。Claude Code は実際に 9 フィールドを持つ(`utils/tasks.ts:76-89`):\n\n| フィールド | 型 | 用途 |\n|------|------|------|\n| `id` | string | 昇順整数 ID |\n| `subject` | string | 短いタイトル |\n| `description` | string | 自由形式の説明 |\n| `activeForm` | string? | 現在進行形、in_progress 時にスピナーに表示 |\n| `owner` | string? | 割り当てられた agent ID |\n| `status` | pending/in_progress/completed | ライフサイクル |\n| `blocks` | string[] | このタスクがブロックするタスク ID(下流) |\n| `blockedBy` | string[] | このタスクをブロックするタスク ID(上流) |\n| `metadata` | Record? | 任意の拡張キーバリューペア |\n\n保存場所:`~/.claude/tasks/{taskListId}/{id}.json`。タスクごとに 1 ファイル。\n\n### 二、TodoWrite のアップグレードではなく、2 つの独立システム\n\nClaude Code では Task System と TodoWrite **は共存**し、`isTodoV2Enabled()` で切り替え(`utils/tasks.ts:133`)— 対話セッションはデフォルトで Task (V2)、非対話/SDK セッションは TodoWrite。環境変数 `CLAUDE_CODE_ENABLE_TASKS` で Task を強制有効化可能。Task は TodoWrite にない機能を持つ:ファイルロック並行保護、依存関係強制、ownership、fs.watch リアクティブ監視、ライフサイクルフック。\n\n### 三、並行認識のロック機構\n\n`claimTask()`(`utils/tasks.ts:541-612`)は二重ロックで競合を防止:\n\n**タスクファイルロック**:`proper-lockfile` で `{taskId}.json` をロック(最大 30 リトライ、指数バックオフ 5-100ms)。ロック内:\n1. タスクを再読込(TOCTOU 防止)\n2. 既に他者が認識済み → `already_claimed`\n3. 既に完了済み → `already_resolved`\n4. 上流が未完了 → `blocked`\n5. owner を設定\n\n**リストレベルロック**(agent busy チェック時):`.lock` ファイル、全タスクを原子的に走査し該当 agent が他の open task を持つか確認。\n\n注意:教学版は認識と作業開始を 1 ステップに統合(claim = owner 設定 + in_progress);実際の Claude Code の `claimTask` は主に owner 競合を解決し、owner のみを設定して status は変更しない。status の更新は `TaskUpdate` が担当。\n\n### 四、高水位標による ID 再利用防止\n\n`.highwatermark` ファイルが過去に割り当てられた最大タスク ID を記録。タスクが削除されても ID は再利用されない。\n\n### 五、4 つの Task ツール\n\nClaude Code のタスクシステムは 4 つのツールを持つ(チュートリアルの汎用 Task ツールとは異なる):`TaskCreate`、`TaskGet`、`TaskUpdate`、`TaskList`。すべて `isConcurrencySafe: true` と `shouldDefer: true` が設定(ツールスキーマは初期プロンプトに含まれず、ToolSearch 後にのみ可視)。\n\n教学版の `create_task(blockedBy=...)` は作成時に直接依存を宣言する合理な簡略化。実際の Claude Code の `TaskCreate` は subject/description/activeForm/metadata のみを受け付け、依存関係は `TaskUpdate` の `addBlocks/addBlockedBy` で管理される。\n\n
\n\n\n" }, { "version": "s13", "locale": "en", "title": "s13: Background Tasks — Slow Operations Go to the Background", - "content": "# s13: Background Tasks — Slow Operations Go to the Background\n\ns01 → ... → s11 → s12 → `s13` → [s14](/en/s14) → s15 → ... → s20\n\n> *\"Slow operations go to the background, agent continues processing\"* — Background threads run commands, inject notifications when done.\n>\n> **Harness Layer**: Background — Async execution, doesn't block the main loop.\n\n---\n\n## The Problem\n\nEver used a washing machine? Throw clothes in, press start, then go do other things — cook, reply to messages, read papers. 30 minutes later the machine beeps: done. You don't stand there waiting for 30 minutes.\n\nThe agent's bash tool is the same. `pip install torch` takes 10 minutes, `npm run build` takes 3 minutes. While these commands run, the agent waits for bash to return, unable to use that time to process other tasks.\n\nReading files is milliseconds, no wait. `git status` returns in under a second, no wait. But `npm install`? Minutes. The agent waits 10 minutes doing nothing, and LLM calls are billed by token — idle time is waste.\n\n---\n\n## The Solution\n\n![Background Tasks Overview](/course-assets/s13_background_tasks/background-tasks-overview.en.svg)\n\nTeaching code carries forward S12's simplified task system and prompt assembly; to stay focused on background tasks, it omits full error recovery, memory, and skill systems. The only change: slow operations go to background threads, the agent continues running the loop, and background results are injected as notifications.\n\nSync vs Background:\n\n| | Sync (s12) | Background (s13) |\n|---|---|---|\n| Slow operations | Agent waits | Background thread executes |\n| Agent idle | Yes | No, continues processing |\n| Result | Immediate return | Notification injected next turn |\n| Decision criteria | — | `run_in_background` param (model explicit request), heuristic fallback |\n\n---\n\n## How It Works\n\n### should_run_background: Explicit Request First, Heuristic Fallback\n\nThe model explicitly requests background execution via the bash tool's `run_in_background` parameter. If the model doesn't specify, the teaching version falls back to keyword heuristics:\n\n```python\ndef is_slow_operation(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Fallback heuristic: commands likely to take > 30s.\"\"\"\n if tool_name != \"bash\":\n return False\n cmd = tool_input.get(\"command\", \"\").lower()\n slow_keywords = [\"install\", \"build\", \"test\", \"deploy\", \"compile\",\n \"docker build\", \"pip install\", \"npm install\",\n \"cargo build\", \"pytest\", \"make\"]\n return any(kw in cmd for kw in slow_keywords)\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Model explicit request takes priority; fallback to heuristic.\"\"\"\n if tool_input.get(\"run_in_background\"):\n return True\n return is_slow_operation(tool_name, tool_input)\n```\n\nCC's bash tool schema has a `run_in_background: boolean` parameter (`BashTool.tsx:241`). The model decides which commands go to background, no keyword guessing. The teaching version keeps heuristics as fallback, but the primary path is explicit model request.\n\n### start_background_task: Background Execution and Lifecycle\n\nWraps the tool call in a worker function, dispatches to a daemon thread. Each background task gets a unique ID, with state tracked in the `background_tasks` dict:\n\n```python\n_bg_counter = 0\nbackground_tasks: dict[str, dict] = {} # bg_id → {tool_use_id, command, status}\nbackground_results: dict[str, str] = {} # bg_id → output\nbackground_lock = threading.Lock()\n\ndef start_background_task(block) -> str:\n \"\"\"Run tool in a daemon thread. Returns background task ID.\"\"\"\n global _bg_counter\n _bg_counter += 1\n bg_id = f\"bg_{_bg_counter:04d}\"\n\n def worker():\n result = execute_tool(block)\n with background_lock:\n background_tasks[bg_id][\"status\"] = \"completed\"\n background_results[bg_id] = result\n\n with background_lock:\n background_tasks[bg_id] = {\n \"tool_use_id\": block.id,\n \"command\": block.input.get(\"command\", \"\"),\n \"status\": \"running\",\n }\n thread = threading.Thread(target=worker, daemon=True)\n thread.start()\n return bg_id\n```\n\nReturns `bg_id` instead of just `[Running in background...]`. `daemon=True` ensures threads exit when the agent process exits. The teaching version uses in-memory dicts for tracking; real CC has `LocalShellTaskState`, output redirected to files, with full lifecycle including stopping tasks and reading subsequent output.\n\n### collect_background_results: Notification Collection\n\nWhen background tasks complete, results are collected and formatted as `` messages:\n\n```python\ndef collect_background_results() -> list[str]:\n \"\"\"Collect completed results as task_notification messages.\"\"\"\n with background_lock:\n ready_ids = [bid for bid, task in background_tasks.items()\n if task[\"status\"] == \"completed\"]\n notifications = []\n for bg_id in ready_ids:\n with background_lock:\n task = background_tasks.pop(bg_id)\n output = background_results.pop(bg_id, \"\")\n notifications.append(\n f\"\\n\"\n f\" {bg_id}\\n\"\n f\" completed\\n\"\n f\" {task['command']}\\n\"\n f\" {output[:200]}\\n\"\n f\"\")\n return notifications\n```\n\nNotifications don't reuse the original `tool_use_id`. The original tool call was already answered with a placeholder `tool_result`; background completion is an independent event, injected in `task_notification` format. This respects Messages API tool pairing: one `tool_use` gets exactly one `tool_result`.\n\n### Loop Integration\n\nIn the agent loop, tool execution splits into two paths. Notifications and results merge into a single user message:\n\n```python\nresults = []\nfor block in response.content:\n if block.type != \"tool_use\":\n continue\n if should_run_background(block.name, block.input):\n bg_id = start_background_task(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": f\"[Background task {bg_id} started] \"\n f\"Result will be available when complete.\"})\n else:\n output = execute_tool(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n\n# Merge notifications and tool results into one user message\nuser_content = []\nbg_notifications = collect_background_results()\nif bg_notifications:\n for notif in bg_notifications:\n user_content.append({\"type\": \"text\", \"text\": notif})\nuser_content.extend(results)\nmessages.append({\"role\": \"user\", \"content\": user_content})\n```\n\nSlow operations get a placeholder tool_result with `bg_id`, so the LLM knows this command is still running and can do other things first. When background completes, the notification is injected as an independent text block alongside the current turn's tool_results in one user message.\n\nThe teaching version polls background results while the agent loop continues running. Real CC uses a notification queue (`messageQueueManager.ts`) to deliver background completion events to subsequent turns, without waiting for the tool loop.\n\n### Putting It Together\n\n```\nTurn 1:\n LLM → bash \"npm install\" (run_in_background=true)\n → start_background_task → bg_0001\n → tool_result: \"[Background task bg_0001 started]...\"\n → LLM: \"OK, I'll check later. Let me also read the config.\"\n\nTurn 2:\n LLM → read_file \"package.json\" (fast, sync)\n → tool_result: file content\n → collect: bg_0001 done! inject \n → LLM sees: config file + install notification in one message\n```\n\nThe agent didn't wait — while npm install ran in the background, it read the config file.\n\n---\n\n## Changes from s12\n\n| Component | Before (s12) | After (s13) |\n|-----------|-------------|-------------|\n| Execution model | All synchronous | Slow ops to background thread + notification injection |\n| bash schema | `command` | `command` + `run_in_background` |\n| New functions | — | `should_run_background`, `is_slow_operation`, `start_background_task`, `collect_background_results` |\n| New types | — | `background_tasks: dict`, `background_results: dict`, `background_lock: Lock` |\n| Notification format | — | `` (doesn't reuse tool_use_id) |\n| Loop behavior | Tools execute serially | Slow ops async, fast ops sync, notifications collected each turn |\n| Tools | 8 (s12) | 8 (unchanged, execution strategy changed) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s13_background_tasks/code.py\n```\n\nTry these prompts:\n\n1. `Run pip list in the background and find all Python files in this directory`\n2. `Run npm install (use run_in_background) and while waiting, read package.json`\n3. `Create a task to setup the project, then run pip list in the background`\n\nWhat to observe: Are slow operations dispatched to background? Is a `bg_id` returned? Are background notifications injected in `` format?\n\n---\n\n## What's Next\n\nBackground tasks solved \"slow operations don't block.\" But what if you want to do something on a schedule? Like \"run tests every morning at 9am\" or \"check server status every 5 minutes.\"\n\ns14 Cron Scheduler → Give the agent an alarm clock.\n\n
\nDeep Dive into CC Source\n\n> The following is a complete analysis based on CC source code `query.ts` (lines 211, 1054-1060, 1411-1482), `services/toolUseSummary/toolUseSummaryGenerator.ts` (L15 prompt text), `LocalShellTask.tsx` (L24-25 constants, L59-98 watchdog logic), `messageQueueManager.ts` (notification queue), `utils/task/framework.ts` (L267 `enqueueTaskNotification`).\n\n### 1. pendingToolUseSummary: Haiku Background Generation\n\nCC starts a Haiku side-query after each batch of tool executions to generate a tool use summary. Initiated at `query.ts:1411-1482`, prompt text defined at `services/toolUseSummary/toolUseSummaryGenerator.ts:15` (variable `TOOL_USE_SUMMARY_SYSTEM_PROMPT`). The prompt is \"Write a short summary label... think git-commit-subject, not sentence\", past tense, ~30 characters.\n\nHaiku summary (~1s) completes during the main model's streaming output (5-30s). Before the next turn starts, the summary is yielded. SDK consumers use these summaries for mobile progress display.\n\n### 2. Thread Model: No Real Threads\n\nCC runs on Node.js/Bun's single-threaded event loop. \"Background\" just means \"don't await\". `ShellCommand.background(taskId)` redirects stdout/stderr to files, letting the process run independently.\n\n### 3. Seven Background Task Types\n\nCC defines 7 background task types (`Task.ts:7-13`): `local_bash`, `local_agent`, `remote_agent`, `in_process_teammate`, `local_workflow`, `monitor_mcp`, `dream`. Each has its own registration, lifecycle, and notification mechanism.\n\n### 4. Notification Injection: Command Queue\n\nWhen a background task completes, it's enqueued via `enqueueTaskNotification` (`utils/task/framework.ts:267`) or `enqueuePendingNotification` (`messageQueueManager.ts`) into a shared command queue. The notification format is structured XML:\n\n```xml\n\n completed\n Background command \"npm test\" completed (exit code 0)\n\n```\n\nPriority is `next` > `later` (`messageQueueManager.ts`). Background tasks default to `later` (don't block user input). Consumption point at `query.ts:1566-1593`.\n\n### 5. Stall Watchdog\n\nBackground bash tasks have a watchdog (`LocalShellTask.tsx` L24-25 constants, L59-98 logic) that periodically checks if output has stalled. After 45 seconds with no growth, it detects interactive prompts (`(y/n)` etc.), preventing background tasks from getting stuck on unanswered interactive dialogs.\n\n### 6. Concurrency Limits\n\nForeground tool calls: `CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY` (default 10 concurrent safe tools). Background bash tasks: no hard limit, they're independent subprocesses.\n\n
\n\n\n" + "content": "# s13: Background Tasks — Slow Operations Go to the Background\n\ns01 → ... → s11 → s12 → `s13` → [s14](/en/s14) → s15 → ... → s20\n\n> *\"Slow operations go to the background, agent continues processing\"* — Background threads run commands, inject notifications when done.\n>\n> **Harness Layer**: Background — Async execution, doesn't block the main loop.\n\n---\n\n## The Problem\n\nEver used a washing machine? Throw clothes in, press start, then go do other things — cook, reply to messages, read papers. 30 minutes later the machine beeps: done. You don't stand there waiting for 30 minutes.\n\nThe agent's bash tool is the same. `pip install torch` takes 10 minutes, `npm run build` takes 3 minutes. While these commands run, the agent waits for bash to return, unable to use that time to process other tasks.\n\nReading files is milliseconds, no wait. `git status` returns in under a second, no wait. But `npm install`? Minutes. The agent waits 10 minutes doing nothing, and LLM calls are billed by token — idle time is waste.\n\n---\n\n## The Solution\n\n![Background Tasks Overview](/course-assets/s13_background_tasks/background-tasks-overview.svg)\n\nTeaching code carries forward S12's simplified task system and prompt assembly; to stay focused on background tasks, it omits full error recovery, memory, and skill systems. The only change: tool calls dispatch to background threads, and results are injected as notifications when they finish.\n\nSync vs Background:\n\n| | Sync (s12) | Background (s13) |\n|---|---|---|\n| Slow operations | Agent waits | Background thread executes |\n| Agent idle | Yes | No, continues processing |\n| Result | Immediate return | Notification injected next turn |\n| Decision criteria | — | `run_in_background` param (model explicit request), heuristic fallback |\n\n---\n\n## How It Works\n\n### should_run_background: Explicit Request First, Heuristic Fallback\n\nThe model explicitly requests background execution via the bash tool's `run_in_background` parameter. If the model doesn't specify, the teaching version falls back to keyword heuristics:\n\n```python\ndef is_slow_operation(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Fallback heuristic: commands likely to take > 30s.\"\"\"\n if tool_name != \"bash\":\n return False\n cmd = tool_input.get(\"command\", \"\").lower()\n slow_keywords = [\"install\", \"build\", \"test\", \"deploy\", \"compile\",\n \"docker build\", \"pip install\", \"npm install\",\n \"cargo build\", \"pytest\", \"make\"]\n return any(kw in cmd for kw in slow_keywords)\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Model explicit request takes priority; fallback to heuristic.\"\"\"\n if tool_input.get(\"run_in_background\"):\n return True\n return is_slow_operation(tool_name, tool_input)\n```\n\nClaude Code's bash tool schema has a `run_in_background: boolean` parameter (`BashTool.tsx:241`). The model decides which commands go to background, no keyword guessing. The teaching version keeps heuristics as fallback, but the primary path is explicit model request.\n\n### start_background_task: Background Execution and Lifecycle\n\nWraps the tool call in a worker function, dispatches to a daemon thread. Each background task gets a unique ID, with state tracked in the `background_tasks` dict:\n\n```python\n_bg_counter = 0\nbackground_tasks: dict[str, dict] = {} # bg_id → {tool_use_id, command, status}\nbackground_results: dict[str, str] = {} # bg_id → output\nbackground_lock = threading.Lock()\n\ndef start_background_task(block) -> str:\n \"\"\"Run tool in a daemon thread. Returns background task ID.\"\"\"\n global _bg_counter\n _bg_counter += 1\n bg_id = f\"bg_{_bg_counter:04d}\"\n\n def worker():\n result = execute_tool(block)\n with background_lock:\n background_tasks[bg_id][\"status\"] = \"completed\"\n background_results[bg_id] = result\n\n with background_lock:\n background_tasks[bg_id] = {\n \"tool_use_id\": block.id,\n \"command\": block.input.get(\"command\", \"\"),\n \"status\": \"running\",\n }\n thread = threading.Thread(target=worker, daemon=True)\n thread.start()\n return bg_id\n```\n\nReturns `bg_id` instead of just `[Running in background...]`. `daemon=True` ensures threads exit when the agent process exits. The teaching version uses in-memory dicts for tracking; real Claude Code has `LocalShellTaskState`, output redirected to files, with full lifecycle including stopping tasks and reading subsequent output.\n\n### collect_background_results: Notification Collection\n\nWhen background tasks complete, results are collected and formatted as `` messages:\n\n```python\ndef collect_background_results() -> list[str]:\n \"\"\"Collect completed results as task_notification messages.\"\"\"\n with background_lock:\n ready_ids = [bid for bid, task in background_tasks.items()\n if task[\"status\"] == \"completed\"]\n notifications = []\n for bg_id in ready_ids:\n with background_lock:\n task = background_tasks.pop(bg_id)\n output = background_results.pop(bg_id, \"\")\n notifications.append(\n f\"\\n\"\n f\" {bg_id}\\n\"\n f\" completed\\n\"\n f\" {task['command']}\\n\"\n f\" {output[:200]}\\n\"\n f\"\")\n return notifications\n```\n\nNotifications don't reuse the original `tool_use_id`. The original tool call was already answered with a placeholder `tool_result`; background completion is an independent event, injected in `task_notification` format. This respects Messages API tool pairing: one `tool_use` gets exactly one `tool_result`.\n\n### Loop Integration\n\nIn the agent loop, tool execution splits into two paths. Notifications and results merge into a single user message:\n\n```python\nresults = []\nfor block in response.content:\n if block.type != \"tool_use\":\n continue\n if should_run_background(block.name, block.input):\n bg_id = start_background_task(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": f\"[Background task {bg_id} started] \"\n f\"Result will be available when complete.\"})\n else:\n output = execute_tool(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n\n# Merge notifications and tool results into one user message\nuser_content = []\nbg_notifications = collect_background_results()\nif bg_notifications:\n for notif in bg_notifications:\n user_content.append({\"type\": \"text\", \"text\": notif})\nuser_content.extend(results)\nmessages.append({\"role\": \"user\", \"content\": user_content})\n```\n\nSlow operations get a placeholder tool_result with `bg_id`, so the LLM knows this command is still running and can do other things first. When background completes, the notification is injected as an independent text block alongside the current turn's tool_results in one user message.\n\nThe teaching version polls background results while the agent loop continues running. Real Claude Code uses a notification queue (`messageQueueManager.ts`) to deliver background completion events to subsequent turns, without waiting for the tool loop.\n\n### Putting It Together\n\n```\nTurn 1:\n LLM → bash \"npm install\" (run_in_background=true)\n → start_background_task → bg_0001\n → tool_result: \"[Background task bg_0001 started]...\"\n → LLM: \"OK, I'll check later. Let me also read the config.\"\n\nTurn 2:\n LLM → read_file \"package.json\" (fast, sync)\n → tool_result: file content\n → collect: bg_0001 done! inject \n → LLM sees: config file + install notification in one message\n```\n\nThe agent didn't wait. While npm install ran in the background, it read the config file.\n\n---\n\n## Changes from s12\n\n| Component | Before (s12) | After (s13) |\n|-----------|-------------|-------------|\n| Execution model | All synchronous | Slow ops to background thread + notification injection |\n| bash schema | `command` | `command` + `run_in_background` |\n| New functions | — | `should_run_background`, `is_slow_operation`, `start_background_task`, `collect_background_results` |\n| New types | — | `background_tasks: dict`, `background_results: dict`, `background_lock: Lock` |\n| Notification format | — | `` (doesn't reuse tool_use_id) |\n| Loop behavior | Tools execute serially | Slow ops async, fast ops sync, notifications collected each turn |\n| Tools | 8 (s12) | 8 (unchanged, execution strategy changed) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s13_background_tasks/code.py\n```\n\nTry these prompts:\n\n1. `Run pip list in the background and find all Python files in this directory`\n2. `Run npm install (use run_in_background) and while waiting, read package.json`\n3. `Create a task to setup the project, then run pip list in the background`\n\nWhat to observe: Are slow operations dispatched to background? Is a `bg_id` returned? Are background notifications injected in `` format?\n\n---\n\n## What's Next\n\nThe agent no longer stalls on long-running commands. But what if you want to do something on a schedule? Like \"run tests every morning at 9am\" or \"check server status every 5 minutes.\"\n\ns14 Cron Scheduler → Give the agent an alarm clock.\n\n
\nDeep Dive into Claude Code Source\n\n> The following is a complete analysis based on Claude Code source code `query.ts` (lines 211, 1054-1060, 1411-1482), `services/toolUseSummary/toolUseSummaryGenerator.ts` (L15 prompt text), `LocalShellTask.tsx` (L24-25 constants, L59-98 watchdog logic), `messageQueueManager.ts` (notification queue), `utils/task/framework.ts` (L267 `enqueueTaskNotification`).\n\n### 1. pendingToolUseSummary: Haiku Background Generation\n\nClaude Code starts a Haiku side-query after each batch of tool executions to generate a tool use summary. Initiated at `query.ts:1411-1482`, prompt text defined at `services/toolUseSummary/toolUseSummaryGenerator.ts:15` (variable `TOOL_USE_SUMMARY_SYSTEM_PROMPT`). The prompt is \"Write a short summary label... think git-commit-subject, not sentence\", past tense, ~30 characters.\n\nHaiku summary (~1s) completes during the main model's streaming output (5-30s). Before the next turn starts, the summary is yielded. SDK consumers use these summaries for mobile progress display.\n\n### 2. Thread Model: No Real Threads\n\nClaude Code runs on Node.js/Bun's single-threaded event loop. \"Background\" just means \"don't await\". `ShellCommand.background(taskId)` redirects stdout/stderr to files, letting the process run independently.\n\n### 3. Seven Background Task Types\n\nClaude Code defines 7 background task types (`Task.ts:7-13`): `local_bash`, `local_agent`, `remote_agent`, `in_process_teammate`, `local_workflow`, `monitor_mcp`, `dream`. Each has its own registration, lifecycle, and notification mechanism.\n\n### 4. Notification Injection: Command Queue\n\nWhen a background task completes, it's enqueued via `enqueueTaskNotification` (`utils/task/framework.ts:267`) or `enqueuePendingNotification` (`messageQueueManager.ts`) into a shared command queue. The notification format is structured XML:\n\n```xml\n\n completed\n Background command \"npm test\" completed (exit code 0)\n\n```\n\nPriority is `next` > `later` (`messageQueueManager.ts`). Background tasks default to `later` (don't block user input). Consumption point at `query.ts:1566-1593`.\n\n### 5. Stall Watchdog\n\nBackground bash tasks have a watchdog (`LocalShellTask.tsx` L24-25 constants, L59-98 logic) that periodically checks if output has stalled. After 45 seconds with no growth, it detects interactive prompts (`(y/n)` etc.), preventing background tasks from getting stuck on unanswered interactive dialogs.\n\n### 6. Concurrency Limits\n\nForeground tool calls: `CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY` (default 10 concurrent safe tools). Background bash tasks: no hard limit, they're independent subprocesses.\n\n
\n\n\n" }, { "version": "s13", "locale": "zh", "title": "s13: Background Tasks — 慢操作放后台", - "content": "# s13: Background Tasks — 慢操作放后台\n\ns01 → ... → s11 → s12 → `s13` → [s14](/zh/s14) → s15 → ... → s20\n\n> *\"慢操作丢后台, agent 继续处理\"* — 后台线程跑命令, 完成后注入通知。\n>\n> **Harness 层**: 后台 — 异步执行, 不阻塞主循环。\n\n---\n\n## 问题\n\n你用过洗衣机吗?把衣服扔进去,按下启动,然后去干别的——做饭、回消息、看论文。30 分钟后洗衣机\"滴滴滴\"提醒你:好了。你不会站在洗衣机前面干等 30 分钟。\n\nAgent 的 bash 工具也一样。`pip install torch` 要 10 分钟,`npm run build` 要 3 分钟。这些命令一跑,Agent 就在等 bash 工具返回,没法利用这段时间处理别的任务。\n\n读文件是毫秒级,不等。`git status` 一秒内返回,不等。但 `npm install`?分钟级。Agent 等 10 分钟什么都不做,而 LLM 按 token 计费,空转就是浪费。\n\n---\n\n## 解决方案\n\n![Background Tasks Overview](/course-assets/s13_background_tasks/background-tasks-overview.svg)\n\n教学代码沿用 S12 的简化任务系统和 prompt 组装;为了聚焦后台任务,省略完整错误恢复、记忆和技能系统。唯一的变动:慢操作扔到后台线程,Agent 继续跑循环,后台完成后把通知注入到对话里。\n\n同步 vs 后台:\n\n| | 同步 (s12) | 后台 (s13) |\n|---|---|---|\n| 慢操作 | Agent 干等 | 后台线程执行 |\n| Agent 空闲 | 是 | 否,继续处理 |\n| 结果 | 立即返回 | 下轮注入通知 |\n| 判断标准 | — | `run_in_background` 参数(模型显式请求),启发式兜底 |\n\n---\n\n## 工作原理\n\n### should_run_background: 显式请求优先,启发式兜底\n\n模型通过 bash 工具的 `run_in_background` 参数显式请求后台执行。如果模型没指定,教学版用关键词启发式兜底:\n\n```python\ndef is_slow_operation(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Fallback heuristic: commands likely to take > 30s.\"\"\"\n if tool_name != \"bash\":\n return False\n cmd = tool_input.get(\"command\", \"\").lower()\n slow_keywords = [\"install\", \"build\", \"test\", \"deploy\", \"compile\",\n \"docker build\", \"pip install\", \"npm install\",\n \"cargo build\", \"pytest\", \"make\"]\n return any(kw in cmd for kw in slow_keywords)\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Model explicit request takes priority; fallback to heuristic.\"\"\"\n if tool_input.get(\"run_in_background\"):\n return True\n return is_slow_operation(tool_name, tool_input)\n```\n\nCC 的 bash 工具 schema 里有 `run_in_background: boolean` 参数(`BashTool.tsx:241`)。模型自己决定哪些命令丢后台,不靠关键词猜。教学版保留启发式作为兜底,但主路径是模型显式请求。\n\n### start_background_task: 后台执行与生命周期\n\n把工具调用包装成 worker 函数,扔到 daemon 线程里执行。每个后台任务有唯一 ID,状态存在 `background_tasks` 字典里:\n\n```python\n_bg_counter = 0\nbackground_tasks: dict[str, dict] = {} # bg_id → {tool_use_id, command, status}\nbackground_results: dict[str, str] = {} # bg_id → output\nbackground_lock = threading.Lock()\n\ndef start_background_task(block) -> str:\n \"\"\"Run tool in a daemon thread. Returns background task ID.\"\"\"\n global _bg_counter\n _bg_counter += 1\n bg_id = f\"bg_{_bg_counter:04d}\"\n\n def worker():\n result = execute_tool(block)\n with background_lock:\n background_tasks[bg_id][\"status\"] = \"completed\"\n background_results[bg_id] = result\n\n with background_lock:\n background_tasks[bg_id] = {\n \"tool_use_id\": block.id,\n \"command\": block.input.get(\"command\", \"\"),\n \"status\": \"running\",\n }\n thread = threading.Thread(target=worker, daemon=True)\n thread.start()\n return bg_id\n```\n\n返回 `bg_id` 而不是只返回 `[Running in background...]`。`daemon=True` 确保 Agent 进程退出时线程跟着退出。教学版用内存字典追踪状态;真实 CC 有 `LocalShellTaskState`,输出重定向到文件,支持停止任务、读取后续输出等完整生命周期。\n\n### collect_background_results: 通知收集\n\n后台任务完成后,收集结果并格式化为 `` 通知:\n\n```python\ndef collect_background_results() -> list[str]:\n \"\"\"Collect completed results as task_notification messages.\"\"\"\n with background_lock:\n ready_ids = [bid for bid, task in background_tasks.items()\n if task[\"status\"] == \"completed\"]\n notifications = []\n for bg_id in ready_ids:\n with background_lock:\n task = background_tasks.pop(bg_id)\n output = background_results.pop(bg_id, \"\")\n notifications.append(\n f\"\\n\"\n f\" {bg_id}\\n\"\n f\" completed\\n\"\n f\" {task['command']}\\n\"\n f\" {output[:200]}\\n\"\n f\"\")\n return notifications\n```\n\n通知不复用原始 `tool_use_id`。原始 tool call 已经用占位 `tool_result` 回复了,后台完成是独立事件,用 `task_notification` 格式注入。这符合 Messages API 的工具配对语义:一个 `tool_use` 只对应一个 `tool_result`。\n\n### 循环中的集成\n\nagent_loop 里,工具执行分两条路,通知和结果合并为一条 user 消息:\n\n```python\nresults = []\nfor block in response.content:\n if block.type != \"tool_use\":\n continue\n if should_run_background(block.name, block.input):\n bg_id = start_background_task(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": f\"[Background task {bg_id} started] \"\n f\"Result will be available when complete.\"})\n else:\n output = execute_tool(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n\n# 通知和工具结果合入同一条 user 消息\nuser_content = []\nbg_notifications = collect_background_results()\nif bg_notifications:\n for notif in bg_notifications:\n user_content.append({\"type\": \"text\", \"text\": notif})\nuser_content.extend(results)\nmessages.append({\"role\": \"user\", \"content\": user_content})\n```\n\n慢操作先回一个带 `bg_id` 的占位 tool_result,LLM 知道这个命令还在跑,可以先做别的事。后台完成后,通知作为独立 text block 和当前轮的 tool_result 一起组成 user 消息。\n\n教学版在 agent loop 继续运行时轮询后台结果。真实 CC 通过通知队列(`messageQueueManager.ts`)把后台完成事件送入后续 turn,不需要等工具循环。\n\n### 合起来跑\n\n```\nTurn 1:\n LLM → bash \"npm install\" (run_in_background=true)\n → start_background_task → bg_0001\n → tool_result: \"[Background task bg_0001 started]...\"\n → LLM: \"OK, I'll check later. Let me also read the config.\"\n\nTurn 2:\n LLM → read_file \"package.json\" (fast, sync)\n → tool_result: file content\n → collect: bg_0001 done! inject \n → LLM sees: config file + install notification in one message\n```\n\nAgent 没干等,npm install 跑后台的时候,它去读了配置文件。\n\n---\n\n## 相对 s12 的变更\n\n| 组件 | 之前 (s12) | 之后 (s13) |\n|------|-----------|-----------|\n| 执行模型 | 全部同步 | 慢操作后台线程 + 通知注入 |\n| bash schema | `command` | `command` + `run_in_background` |\n| 新函数 | — | `should_run_background`, `is_slow_operation`, `start_background_task`, `collect_background_results` |\n| 新类型 | — | `background_tasks: dict`, `background_results: dict`, `background_lock: Lock` |\n| 通知格式 | — | ``(不复用 tool_use_id) |\n| 循环行为 | 工具串行执行 | 慢操作异步,快操作同步,通知每轮收集 |\n| 工具 | 8 (s12) | 8(不变,执行策略变了) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s13_background_tasks/code.py\n```\n\n试试这些 prompt:\n\n1. `Run pip list in the background and find all Python files in this directory`\n2. `Run npm install (use run_in_background) and while waiting, read package.json`\n3. `Create a task to setup the project, then run pip list in the background`\n\n观察重点:慢操作有没有被送到后台?`bg_id` 是否返回?后台通知有没有以 `` 格式注入?\n\n---\n\n## 接下来\n\n后台任务解决了\"慢操作不阻塞\"。但如果想定时做某件事呢?比如\"每天早上 9 点跑测试\"、\"每 5 分钟检查一次服务器状态\"。\n\ns14 Cron Scheduler → 给 Agent 装一个闹钟。\n\n
\n深入 CC 源码\n\n> 以下基于 CC 源码 `query.ts`(211, 1054-1060, 1411-1482 行)、`services/toolUseSummary/toolUseSummaryGenerator.ts`(L15 prompt 文本)、`LocalShellTask.tsx`(L24-25 常量, L59-98 看门狗逻辑)、`messageQueueManager.ts`(通知队列)、`utils/task/framework.ts`(L267 `enqueueTaskNotification`)的完整分析。\n\n### 一、pendingToolUseSummary:Haiku 后台生成\n\nCC 在每批工具执行完后,启动一个 Haiku side-query 生成工具使用摘要。发起代码在 `query.ts:1411-1482`,prompt 文本定义在 `services/toolUseSummary/toolUseSummaryGenerator.ts:15`(变量名 `TOOL_USE_SUMMARY_SYSTEM_PROMPT`)。提示是 \"Write a short summary label... think git-commit-subject, not sentence\",过去时态,约 30 字符。\n\nHaiku 摘要(~1s)在主模型流式生成(5-30s)期间完成。下一轮开始前,把摘要 yield 出去。SDK 消费这些摘要做移动端进度展示。\n\n### 二、线程模型:没有真正的线程\n\nCC 运行在 Node.js/Bun 单线程事件循环中。\"后台\"只是 \"不 await\"。`ShellCommand.background(taskId)` 把 stdout/stderr 重定向到文件,让进程独立运行。\n\n### 三、七种后台任务类型\n\nCC 定义了 7 种后台任务(`Task.ts:7-13`):`local_bash`、`local_agent`、`remote_agent`、`in_process_teammate`、`local_workflow`、`monitor_mcp`、`dream`。每种有自己的注册、生命周期和通知机制。\n\n### 四、通知注入:命令队列\n\n后台任务完成后通过 `enqueueTaskNotification`(`utils/task/framework.ts:267`)或 `enqueuePendingNotification`(`messageQueueManager.ts`)入队到共享命令队列。通知格式是结构化的 XML:\n\n```xml\n\n completed\n Background command \"npm test\" completed (exit code 0)\n\n```\n\n优先级分 `next` > `later`(`messageQueueManager.ts`)。后台任务默认 `later`(不阻塞用户输入)。消费点在 `query.ts:1566-1593`。\n\n### 五、停滞看门狗\n\n后台 bash 任务有一个看门狗(`LocalShellTask.tsx` L24-25 常量, L59-98 逻辑),定期检查输出是否停滞,45 秒无增长后检测交互式提示(`(y/n)` 等),防止后台任务卡在无人响应的交互式对话框。\n\n### 六、并发限制\n\n前台工具调用:`CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY`(默认 10 个并发安全工具)。后台 bash 任务:没有硬性限制,它们是独立的子进程。\n\n
\n\n\n" + "content": "# s13: Background Tasks — 慢操作放后台\n\ns01 → ... → s11 → s12 → `s13` → [s14](/zh/s14) → s15 → ... → s20\n\n> *\"慢操作丢后台, agent 继续处理\"* — 后台线程跑命令, 完成后注入通知。\n>\n> **Harness 层**: 后台 — 异步执行, 不阻塞主循环。\n\n---\n\n## 问题\n\n你用过洗衣机吗?把衣服扔进去,按下启动,然后去干别的——做饭、回消息、看论文。30 分钟后洗衣机\"滴滴滴\"提醒你:好了。你不会站在洗衣机前面干等 30 分钟。\n\nAgent 的 bash 工具也一样。`pip install torch` 要 10 分钟,`npm run build` 要 3 分钟。这些命令一跑,Agent 就在等 bash 工具返回,没法利用这段时间处理别的任务。\n\n读文件是毫秒级,不等。`git status` 一秒内返回,不等。但 `npm install`是分钟级。Agent 等 10 分钟什么都不做,而 LLM 按 token 计费,空转就是浪费。\n\n---\n\n## 解决方案\n\n![Background Tasks Overview](/course-assets/s13_background_tasks/background-tasks-overview.svg)\n\n教学代码沿用 S12 的简化任务系统和 prompt 组装;为了聚焦后台任务,省略完整错误恢复、记忆和技能系统。唯一的变动:把工具调用扔到后台线程,完成后把结果作为通知注入到对话里。\n\n同步 vs 后台:\n\n| | 同步 (s12) | 后台 (s13) |\n|---|---|---|\n| 慢操作 | Agent 干等 | 后台线程执行 |\n| Agent 空闲 | 是 | 否,继续处理 |\n| 结果 | 立即返回 | 下轮注入通知 |\n| 判断标准 | — | `run_in_background` 参数(模型显式请求),启发式兜底 |\n\n---\n\n## 工作原理\n\n### should_run_background: 显式请求优先,启发式兜底\n\n模型通过 bash 工具的 `run_in_background` 参数显式请求后台执行。如果模型没指定,教学版用关键词启发式兜底:\n\n```python\ndef is_slow_operation(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Fallback heuristic: commands likely to take > 30s.\"\"\"\n if tool_name != \"bash\":\n return False\n cmd = tool_input.get(\"command\", \"\").lower()\n slow_keywords = [\"install\", \"build\", \"test\", \"deploy\", \"compile\",\n \"docker build\", \"pip install\", \"npm install\",\n \"cargo build\", \"pytest\", \"make\"]\n return any(kw in cmd for kw in slow_keywords)\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Model explicit request takes priority; fallback to heuristic.\"\"\"\n if tool_input.get(\"run_in_background\"):\n return True\n return is_slow_operation(tool_name, tool_input)\n```\n\nClaude Code 的 bash 工具 schema 里有 `run_in_background: boolean` 参数(`BashTool.tsx:241`)。模型自己决定哪些命令丢后台,不靠关键词猜。教学版保留启发式作为兜底,但主路径是模型显式请求。\n\n### start_background_task: 后台执行与生命周期\n\n把工具调用包装成 worker 函数,扔到 daemon 线程里执行。每个后台任务有唯一 ID,状态存在 `background_tasks` 字典里:\n\n```python\n_bg_counter = 0\nbackground_tasks: dict[str, dict] = {} # bg_id → {tool_use_id, command, status}\nbackground_results: dict[str, str] = {} # bg_id → output\nbackground_lock = threading.Lock()\n\ndef start_background_task(block) -> str:\n \"\"\"Run tool in a daemon thread. Returns background task ID.\"\"\"\n global _bg_counter\n _bg_counter += 1\n bg_id = f\"bg_{_bg_counter:04d}\"\n\n def worker():\n result = execute_tool(block)\n with background_lock:\n background_tasks[bg_id][\"status\"] = \"completed\"\n background_results[bg_id] = result\n\n with background_lock:\n background_tasks[bg_id] = {\n \"tool_use_id\": block.id,\n \"command\": block.input.get(\"command\", \"\"),\n \"status\": \"running\",\n }\n thread = threading.Thread(target=worker, daemon=True)\n thread.start()\n return bg_id\n```\n\n返回 `bg_id` 而不是只返回 `[Running in background...]`。`daemon=True` 确保 Agent 进程退出时线程跟着退出。教学版用内存字典追踪状态;真实 Claude Code 有 `LocalShellTaskState`,输出重定向到文件,支持停止任务、读取后续输出等完整生命周期。\n\n### collect_background_results: 通知收集\n\n后台任务完成后,收集结果并格式化为 `` 通知:\n\n```python\ndef collect_background_results() -> list[str]:\n \"\"\"Collect completed results as task_notification messages.\"\"\"\n with background_lock:\n ready_ids = [bid for bid, task in background_tasks.items()\n if task[\"status\"] == \"completed\"]\n notifications = []\n for bg_id in ready_ids:\n with background_lock:\n task = background_tasks.pop(bg_id)\n output = background_results.pop(bg_id, \"\")\n notifications.append(\n f\"\\n\"\n f\" {bg_id}\\n\"\n f\" completed\\n\"\n f\" {task['command']}\\n\"\n f\" {output[:200]}\\n\"\n f\"\")\n return notifications\n```\n\n通知不复用原始 `tool_use_id`。原始 tool call 已经用占位 `tool_result` 回复了,后台完成是独立事件,用 `task_notification` 格式注入。这符合 Messages API 的工具配对语义:一个 `tool_use` 只对应一个 `tool_result`。\n\n### 循环中的集成\n\nagent_loop 里,工具执行分两条路,通知和结果合并为一条 user 消息:\n\n```python\nresults = []\nfor block in response.content:\n if block.type != \"tool_use\":\n continue\n if should_run_background(block.name, block.input):\n bg_id = start_background_task(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": f\"[Background task {bg_id} started] \"\n f\"Result will be available when complete.\"})\n else:\n output = execute_tool(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n\n# 通知和工具结果合入同一条 user 消息\nuser_content = []\nbg_notifications = collect_background_results()\nif bg_notifications:\n for notif in bg_notifications:\n user_content.append({\"type\": \"text\", \"text\": notif})\nuser_content.extend(results)\nmessages.append({\"role\": \"user\", \"content\": user_content})\n```\n\n慢操作先回一个带 `bg_id` 的占位 tool_result,LLM 知道这个命令还在跑,可以先做别的事。后台完成后,通知作为独立 text block 和当前轮的 tool_result 一起组成 user 消息。\n\n教学版在 agent loop 继续运行时轮询后台结果。真实 Claude Code 通过通知队列(`messageQueueManager.ts`)把后台完成事件送入后续 turn,不需要等工具循环。\n\n### 合起来跑\n\n```\nTurn 1:\n LLM → bash \"npm install\" (run_in_background=true)\n → start_background_task → bg_0001\n → tool_result: \"[Background task bg_0001 started]...\"\n → LLM: \"OK, I'll check later. Let me also read the config.\"\n\nTurn 2:\n LLM → read_file \"package.json\" (fast, sync)\n → tool_result: file content\n → collect: bg_0001 done! inject \n → LLM sees: config file + install notification in one message\n```\n\nAgent 没干等,npm install 跑后台的时候,它去读了配置文件。\n\n---\n\n## 相对 s12 的变更\n\n| 组件 | 之前 (s12) | 之后 (s13) |\n|------|-----------|-----------|\n| 执行模型 | 全部同步 | 慢操作后台线程 + 通知注入 |\n| bash schema | `command` | `command` + `run_in_background` |\n| 新函数 | — | `should_run_background`, `is_slow_operation`, `start_background_task`, `collect_background_results` |\n| 新类型 | — | `background_tasks: dict`, `background_results: dict`, `background_lock: Lock` |\n| 通知格式 | — | ``(不复用 tool_use_id) |\n| 循环行为 | 工具串行执行 | 慢操作异步,快操作同步,通知每轮收集 |\n| 工具 | 8 (s12) | 8(不变,执行策略变了) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s13_background_tasks/code.py\n```\n\n试试这些 prompt:\n\n1. `Run pip list in the background and find all Python files in this directory`\n2. `Run npm install (use run_in_background) and while waiting, read package.json`\n3. `Create a task to setup the project, then run pip list in the background`\n\n观察重点:慢操作有没有被送到后台?`bg_id` 是否返回?后台通知有没有以 `` 格式注入?\n\n---\n\n## 接下来\n\nAgent 不再因为长命令卡住了。但如果想定时做某件事呢?比如\"每天早上 9 点跑测试\"、\"每 5 分钟检查一次服务器状态\"。\n\ns14 Cron Scheduler → 给 Agent 装一个闹钟。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `query.ts`(211, 1054-1060, 1411-1482 行)、`services/toolUseSummary/toolUseSummaryGenerator.ts`(L15 prompt 文本)、`LocalShellTask.tsx`(L24-25 常量, L59-98 看门狗逻辑)、`messageQueueManager.ts`(通知队列)、`utils/task/framework.ts`(L267 `enqueueTaskNotification`)的完整分析。\n\n### 一、pendingToolUseSummary:Haiku 后台生成\n\nClaude Code 在每批工具执行完后,启动一个 Haiku side-query 生成工具使用摘要。发起代码在 `query.ts:1411-1482`,prompt 文本定义在 `services/toolUseSummary/toolUseSummaryGenerator.ts:15`(变量名 `TOOL_USE_SUMMARY_SYSTEM_PROMPT`)。提示是 \"Write a short summary label... think git-commit-subject, not sentence\",过去时态,约 30 字符。\n\nHaiku 摘要(~1s)在主模型流式生成(5-30s)期间完成。下一轮开始前,把摘要 yield 出去。SDK 消费这些摘要做移动端进度展示。\n\n### 二、线程模型:没有真正的线程\n\nClaude Code 运行在 Node.js/Bun 单线程事件循环中。\"后台\"只是 \"不 await\"。`ShellCommand.background(taskId)` 把 stdout/stderr 重定向到文件,让进程独立运行。\n\n### 三、七种后台任务类型\n\nClaude Code 定义了 7 种后台任务(`Task.ts:7-13`):`local_bash`、`local_agent`、`remote_agent`、`in_process_teammate`、`local_workflow`、`monitor_mcp`、`dream`。每种有自己的注册、生命周期和通知机制。\n\n### 四、通知注入:命令队列\n\n后台任务完成后通过 `enqueueTaskNotification`(`utils/task/framework.ts:267`)或 `enqueuePendingNotification`(`messageQueueManager.ts`)入队到共享命令队列。通知格式是结构化的 XML:\n\n```xml\n\n completed\n Background command \"npm test\" completed (exit code 0)\n\n```\n\n优先级分 `next` > `later`(`messageQueueManager.ts`)。后台任务默认 `later`(不阻塞用户输入)。消费点在 `query.ts:1566-1593`。\n\n### 五、停滞看门狗\n\n后台 bash 任务有一个看门狗(`LocalShellTask.tsx` L24-25 常量, L59-98 逻辑),定期检查输出是否停滞,45 秒无增长后检测交互式提示(`(y/n)` 等),防止后台任务卡在无人响应的交互式对话框。\n\n### 六、并发限制\n\n前台工具调用:`CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY`(默认 10 个并发安全工具)。后台 bash 任务:没有硬性限制,它们是独立的子进程。\n\n
\n\n\n" }, { "version": "s13", "locale": "ja", "title": "s13: Background Tasks — 遅い操作はバックグラウンドへ", - "content": "# s13: Background Tasks — 遅い操作はバックグラウンドへ\n\ns01 → ... → s11 → s12 → `s13` → [s14](/ja/s14) → s15 → ... → s20\n\n> *\"遅い操作はバックグラウンドへ、agent は処理を継続\"* — バックグラウンドスレッドでコマンドを実行、完了時に通知を注入。\n>\n> **Harness 層**: バックグラウンド — 非同期実行、メインループをブロックしない。\n\n---\n\n## 課題\n\n洗濯機を使ったことがあるか?衣類を入れ、スタートを押し、他のことをする——料理、メッセージ返信、論文読み。30 分後に洗濯機が「ピッピッ」と知らせる:完了。30 分間立って待つ人はいない。\n\nAgent の bash ツールも同じ。`pip install torch` は 10 分、`npm run build` は 3 分かかる。これらのコマンドが実行中、Agent は bash の戻りを待ち、その時間を他のタスクの処理に使えない。\n\nファイル読み込みはミリ秒、待たない。`git status` は 1 秒以内に戻る、待たない。しかし `npm install` は?分単位。Agent は 10 分間何もせず待ち、LLM 呼び出しはトークン課金、アイドル時間は無駄。\n\n---\n\n## ソリューション\n\n![Background Tasks Overview](/course-assets/s13_background_tasks/background-tasks-overview.ja.svg)\n\n教学版は S12 の簡易タスクシステムとプロンプト組み立てを踏襲。バックグラウンドタスクに集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。唯一の変更:遅い操作をバックグラウンドスレッドに投げ、Agent はループを継続、バックグラウンド完了時に通知を注入。\n\n同期 vs バックグラウンド:\n\n| | 同期 (s12) | バックグラウンド (s13) |\n|---|---|---|\n| 遅い操作 | Agent が待機 | バックグラウンドスレッドで実行 |\n| Agent アイドル | はい | いいえ、処理を継続 |\n| 結果 | 即時返却 | 次ターンで通知を注入 |\n| 判断基準 | — | `run_in_background` パラメータ(モデル明示的リクエスト)、ヒューリスティックフォールバック |\n\n---\n\n## 仕組み\n\n### should_run_background: 明示的リクエスト優先、ヒューリスティックフォールバック\n\nモデルは bash ツールの `run_in_background` パラメータで明示的にバックグラウンド実行をリクエストする。モデルが指定しない場合、教学版はキーワードヒューリスティックにフォールバック:\n\n```python\ndef is_slow_operation(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Fallback heuristic: commands likely to take > 30s.\"\"\"\n if tool_name != \"bash\":\n return False\n cmd = tool_input.get(\"command\", \"\").lower()\n slow_keywords = [\"install\", \"build\", \"test\", \"deploy\", \"compile\",\n \"docker build\", \"pip install\", \"npm install\",\n \"cargo build\", \"pytest\", \"make\"]\n return any(kw in cmd for kw in slow_keywords)\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Model explicit request takes priority; fallback to heuristic.\"\"\"\n if tool_input.get(\"run_in_background\"):\n return True\n return is_slow_operation(tool_name, tool_input)\n```\n\nCC の bash ツールスキーマには `run_in_background: boolean` パラメータがある(`BashTool.tsx:241`)。モデルがどのコマンドをバックグラウンドにするかを決定、キーワード推測ではない。教学版はヒューリスティックをフォールバックとして残すが、主パスはモデルの明示的リクエスト。\n\n### start_background_task: バックグラウンド実行とライフサイクル\n\nツール呼び出しをワーカー関数にラップし、daemon スレッドにディスパッチ。各バックグラウンドタスクは一意 ID を持ち、`background_tasks` 辞書で状態を追跡:\n\n```python\n_bg_counter = 0\nbackground_tasks: dict[str, dict] = {} # bg_id → {tool_use_id, command, status}\nbackground_results: dict[str, str] = {} # bg_id → output\nbackground_lock = threading.Lock()\n\ndef start_background_task(block) -> str:\n \"\"\"Run tool in a daemon thread. Returns background task ID.\"\"\"\n global _bg_counter\n _bg_counter += 1\n bg_id = f\"bg_{_bg_counter:04d}\"\n\n def worker():\n result = execute_tool(block)\n with background_lock:\n background_tasks[bg_id][\"status\"] = \"completed\"\n background_results[bg_id] = result\n\n with background_lock:\n background_tasks[bg_id] = {\n \"tool_use_id\": block.id,\n \"command\": block.input.get(\"command\", \"\"),\n \"status\": \"running\",\n }\n thread = threading.Thread(target=worker, daemon=True)\n thread.start()\n return bg_id\n```\n\n`[Running in background...]` ではなく `bg_id` を返す。`daemon=True` で Agent プロセス終了時にスレッドも終了。教学版はメモリ内辞書で追跡。実際の CC は `LocalShellTaskState` を持ち、出力をファイルにリダイレクト、タスク停止や継続出力読み取りを含む完全なライフサイクルを備える。\n\n### collect_background_results: 通知収集\n\nバックグラウンドタスク完了時、結果を収集して `` メッセージとしてフォーマット:\n\n```python\ndef collect_background_results() -> list[str]:\n \"\"\"Collect completed results as task_notification messages.\"\"\"\n with background_lock:\n ready_ids = [bid for bid, task in background_tasks.items()\n if task[\"status\"] == \"completed\"]\n notifications = []\n for bg_id in ready_ids:\n with background_lock:\n task = background_tasks.pop(bg_id)\n output = background_results.pop(bg_id, \"\")\n notifications.append(\n f\"\\n\"\n f\" {bg_id}\\n\"\n f\" completed\\n\"\n f\" {task['command']}\\n\"\n f\" {output[:200]}\\n\"\n f\"\")\n return notifications\n```\n\n通知は元の `tool_use_id` を再利用しない。元のツール呼び出しはプレースホルダー `tool_result` で応答済み。バックグラウンド完了は独立したイベントで、`task_notification` 形式で注入する。これは Messages API のツールペアリングに従う:1 つの `tool_use` に対して正確に 1 つの `tool_result`。\n\n### ループ統合\n\nagent_loop でツール実行は 2 つのパスに分かれる。通知と結果は 1 つの user メッセージに統合:\n\n```python\nresults = []\nfor block in response.content:\n if block.type != \"tool_use\":\n continue\n if should_run_background(block.name, block.input):\n bg_id = start_background_task(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": f\"[Background task {bg_id} started] \"\n f\"Result will be available when complete.\"})\n else:\n output = execute_tool(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n\n# 通知とツール結果を 1 つの user メッセージに統合\nuser_content = []\nbg_notifications = collect_background_results()\nif bg_notifications:\n for notif in bg_notifications:\n user_content.append({\"type\": \"text\", \"text\": notif})\nuser_content.extend(results)\nmessages.append({\"role\": \"user\", \"content\": user_content})\n```\n\n遅い操作は `bg_id` 付きプレースホルダー tool_result を返し、LLM はコマンドがまだ実行中だと知り、先に他のことをできる。バックグラウンド完了時、通知は独立した text block として現在のターンの tool_result と一緒に 1 つの user メッセージを構成する。\n\n教学版は agent loop が継続実行中にバックグラウンド結果をポーリングする。実際の CC は通知キュー(`messageQueueManager.ts`)でバックグラウンド完了イベントを後続ターンに配信、ツールループを待つ必要はない。\n\n### 組み合わせて実行\n\n```\nTurn 1:\n LLM → bash \"npm install\" (run_in_background=true)\n → start_background_task → bg_0001\n → tool_result: \"[Background task bg_0001 started]...\"\n → LLM: \"OK, I'll check later. Let me also read the config.\"\n\nTurn 2:\n LLM → read_file \"package.json\" (fast, sync)\n → tool_result: file content\n → collect: bg_0001 done! inject \n → LLM sees: config file + install notification in one message\n```\n\nAgent は待たなかった。npm install がバックグラウンドで実行中に、設定ファイルを読んだ。\n\n---\n\n## s12 からの変更\n\n| コンポーネント | 変更前 (s12) | 変更後 (s13) |\n|--------------|------------|------------|\n| 実行モデル | すべて同期 | 遅い操作はバックグラウンドスレッド + 通知注入 |\n| bash スキーマ | `command` | `command` + `run_in_background` |\n| 新規関数 | — | `should_run_background`, `is_slow_operation`, `start_background_task`, `collect_background_results` |\n| 新規型 | — | `background_tasks: dict`, `background_results: dict`, `background_lock: Lock` |\n| 通知形式 | — | ``(tool_use_id を再利用しない) |\n| ループ動作 | ツール直列実行 | 遅い操作は非同期、速い操作は同期、通知は毎ターン収集 |\n| ツール | 8 (s12) | 8(変更なし、実行戦略が変更) |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s13_background_tasks/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `Run pip list in the background and find all Python files in this directory`\n2. `Run npm install (use run_in_background) and while waiting, read package.json`\n3. `Create a task to setup the project, then run pip list in the background`\n\n観察ポイント:遅い操作はバックグラウンドにディスパッチされているか?`bg_id` は返されているか?バックグラウンド通知は `` 形式で注入されているか?\n\n---\n\n## 次の章\n\nバックグラウンドタスクは「遅い操作がブロックしない」を解決した。しかし、定期的に何かをしたい場合は?例えば「毎朝 9 時にテストを実行」「5 分ごとにサーバーステータスを確認」。\n\ns14 Cron Scheduler → Agent にアラームクロックを付ける。\n\n
\nCC ソースコード深掘り\n\n> 以下は CC ソースコード `query.ts`(211, 1054-1060, 1411-1482 行)、`services/toolUseSummary/toolUseSummaryGenerator.ts`(L15 プロンプトテキスト)、`LocalShellTask.tsx`(L24-25 定数, L59-98 ウォッチドッグロジック)、`messageQueueManager.ts`(通知キュー)、`utils/task/framework.ts`(L267 `enqueueTaskNotification`)の完全分析に基づく。\n\n### 一、pendingToolUseSummary:Haiku バックグラウンド生成\n\nCC は各ツール実行バッチの後、Haiku サイドクエリを開始してツール使用サマリを生成。開始コードは `query.ts:1411-1482`、プロンプトテキストは `services/toolUseSummary/toolUseSummaryGenerator.ts:15`(変数 `TOOL_USE_SUMMARY_SYSTEM_PROMPT`)。プロンプトは \"Write a short summary label... think git-commit-subject, not sentence\"、過去形、約 30 文字。\n\nHaiku サマリ(~1s)はメインモデルのストリーミング出力(5-30s)中に完了。次のターン開始前にサマリを yield。SDK コンシューマーはこれらのサマリをモバイル進捗表示に使用。\n\n### 二、スレッドモデル:本当のスレッドはない\n\nCC は Node.js/Bun のシングルスレッドイベントループで動作。「バックグラウンド」は単に「await しない」こと。`ShellCommand.background(taskId)` は stdout/stderr をファイルにリダイレクトし、プロセスを独立実行。\n\n### 三、7 種のバックグラウンドタスク型\n\nCC は 7 種のバックグラウンドタスク型を定義(`Task.ts:7-13`):`local_bash`、`local_agent`、`remote_agent`、`in_process_teammate`、`local_workflow`、`monitor_mcp`、`dream`。それぞれ独自の登録、ライフサイクル、通知メカニズムを持つ。\n\n### 四、通知注入:コマンドキュー\n\nバックグラウンドタスク完了時、`enqueueTaskNotification`(`utils/task/framework.ts:267`)または `enqueuePendingNotification`(`messageQueueManager.ts`)で共有コマンドキューにエンキュー。通知形式は構造化 XML:\n\n```xml\n\n completed\n Background command \"npm test\" completed (exit code 0)\n\n```\n\n優先度は `next` > `later`(`messageQueueManager.ts`)。バックグラウンドタスクはデフォルト `later`(ユーザー入力をブロックしない)。消費点は `query.ts:1566-1593`。\n\n### 五、停滞ウォッチドッグ\n\nバックグラウンド bash タスクにはウォッチドッグがある(`LocalShellTask.tsx` L24-25 定数, L59-98 ロジック)。出力の停滞を定期チェックし、45 秒間増加がない場合にインタラクティブプロンプト(`(y/n)` 等)を検出、バックグラウンドタスクが無応答のインタラクティブダイアログでスタックするのを防ぐ。\n\n### 六、同時実行制限\n\nフォアグラウンドツール呼び出し:`CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY`(デフォルト 10 同時実行安全ツール)。バックグラウンド bash タスク:ハードリミットなし、独立したサブプロセス。\n\n
\n\n\n" + "content": "# s13: Background Tasks — 遅い操作はバックグラウンドへ\n\ns01 → ... → s11 → s12 → `s13` → [s14](/ja/s14) → s15 → ... → s20\n\n> *\"遅い操作はバックグラウンドへ、agent は処理を継続\"* — バックグラウンドスレッドでコマンドを実行、完了時に通知を注入。\n>\n> **Harness 層**: バックグラウンド — 非同期実行、メインループをブロックしない。\n\n---\n\n## 課題\n\n洗濯機を使ったことがあるか?衣類を入れ、スタートを押し、他のことをする——料理、メッセージ返信、論文読み。30 分後に洗濯機が「ピッピッ」と知らせる:完了。30 分間立って待つ人はいない。\n\nAgent の bash ツールも同じ。`pip install torch` は 10 分、`npm run build` は 3 分かかる。これらのコマンドが実行中、Agent は bash の戻りを待ち、その時間を他のタスクの処理に使えない。\n\nファイル読み込みはミリ秒、待たない。`git status` は 1 秒以内に戻る、待たない。しかし `npm install` は?分単位。Agent は 10 分間何もせず待ち、LLM 呼び出しはトークン課金、アイドル時間は無駄。\n\n---\n\n## ソリューション\n\n![Background Tasks Overview](/course-assets/s13_background_tasks/background-tasks-overview.svg)\n\n教学版は S12 の簡易タスクシステムとプロンプト組み立てを踏襲。バックグラウンドタスクに集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。唯一の変更:ツール呼び出しをバックグラウンドスレッドにディスパッチし、完了時に結果を通知として注入。\n\n同期 vs バックグラウンド:\n\n| | 同期 (s12) | バックグラウンド (s13) |\n|---|---|---|\n| 遅い操作 | Agent が待機 | バックグラウンドスレッドで実行 |\n| Agent アイドル | はい | いいえ、処理を継続 |\n| 結果 | 即時返却 | 次ターンで通知を注入 |\n| 判断基準 | — | `run_in_background` パラメータ(モデル明示的リクエスト)、ヒューリスティックフォールバック |\n\n---\n\n## 仕組み\n\n### should_run_background: 明示的リクエスト優先、ヒューリスティックフォールバック\n\nモデルは bash ツールの `run_in_background` パラメータで明示的にバックグラウンド実行をリクエストする。モデルが指定しない場合、教学版はキーワードヒューリスティックにフォールバック:\n\n```python\ndef is_slow_operation(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Fallback heuristic: commands likely to take > 30s.\"\"\"\n if tool_name != \"bash\":\n return False\n cmd = tool_input.get(\"command\", \"\").lower()\n slow_keywords = [\"install\", \"build\", \"test\", \"deploy\", \"compile\",\n \"docker build\", \"pip install\", \"npm install\",\n \"cargo build\", \"pytest\", \"make\"]\n return any(kw in cmd for kw in slow_keywords)\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Model explicit request takes priority; fallback to heuristic.\"\"\"\n if tool_input.get(\"run_in_background\"):\n return True\n return is_slow_operation(tool_name, tool_input)\n```\n\nClaude Code の bash ツールスキーマには `run_in_background: boolean` パラメータがある(`BashTool.tsx:241`)。モデルがどのコマンドをバックグラウンドにするかを決定、キーワード推測ではない。教学版はヒューリスティックをフォールバックとして残すが、主パスはモデルの明示的リクエスト。\n\n### start_background_task: バックグラウンド実行とライフサイクル\n\nツール呼び出しをワーカー関数にラップし、daemon スレッドにディスパッチ。各バックグラウンドタスクは一意 ID を持ち、`background_tasks` 辞書で状態を追跡:\n\n```python\n_bg_counter = 0\nbackground_tasks: dict[str, dict] = {} # bg_id → {tool_use_id, command, status}\nbackground_results: dict[str, str] = {} # bg_id → output\nbackground_lock = threading.Lock()\n\ndef start_background_task(block) -> str:\n \"\"\"Run tool in a daemon thread. Returns background task ID.\"\"\"\n global _bg_counter\n _bg_counter += 1\n bg_id = f\"bg_{_bg_counter:04d}\"\n\n def worker():\n result = execute_tool(block)\n with background_lock:\n background_tasks[bg_id][\"status\"] = \"completed\"\n background_results[bg_id] = result\n\n with background_lock:\n background_tasks[bg_id] = {\n \"tool_use_id\": block.id,\n \"command\": block.input.get(\"command\", \"\"),\n \"status\": \"running\",\n }\n thread = threading.Thread(target=worker, daemon=True)\n thread.start()\n return bg_id\n```\n\n`[Running in background...]` ではなく `bg_id` を返す。`daemon=True` で Agent プロセス終了時にスレッドも終了。教学版はメモリ内辞書で追跡。実際の Claude Code は `LocalShellTaskState` を持ち、出力をファイルにリダイレクト、タスク停止や継続出力読み取りを含む完全なライフサイクルを備える。\n\n### collect_background_results: 通知収集\n\nバックグラウンドタスク完了時、結果を収集して `` メッセージとしてフォーマット:\n\n```python\ndef collect_background_results() -> list[str]:\n \"\"\"Collect completed results as task_notification messages.\"\"\"\n with background_lock:\n ready_ids = [bid for bid, task in background_tasks.items()\n if task[\"status\"] == \"completed\"]\n notifications = []\n for bg_id in ready_ids:\n with background_lock:\n task = background_tasks.pop(bg_id)\n output = background_results.pop(bg_id, \"\")\n notifications.append(\n f\"\\n\"\n f\" {bg_id}\\n\"\n f\" completed\\n\"\n f\" {task['command']}\\n\"\n f\" {output[:200]}\\n\"\n f\"\")\n return notifications\n```\n\n通知は元の `tool_use_id` を再利用しない。元のツール呼び出しはプレースホルダー `tool_result` で応答済み。バックグラウンド完了は独立したイベントで、`task_notification` 形式で注入する。これは Messages API のツールペアリングに従う:1 つの `tool_use` に対して正確に 1 つの `tool_result`。\n\n### ループ統合\n\nagent_loop でツール実行は 2 つのパスに分かれる。通知と結果は 1 つの user メッセージに統合:\n\n```python\nresults = []\nfor block in response.content:\n if block.type != \"tool_use\":\n continue\n if should_run_background(block.name, block.input):\n bg_id = start_background_task(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": f\"[Background task {bg_id} started] \"\n f\"Result will be available when complete.\"})\n else:\n output = execute_tool(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n\n# 通知とツール結果を 1 つの user メッセージに統合\nuser_content = []\nbg_notifications = collect_background_results()\nif bg_notifications:\n for notif in bg_notifications:\n user_content.append({\"type\": \"text\", \"text\": notif})\nuser_content.extend(results)\nmessages.append({\"role\": \"user\", \"content\": user_content})\n```\n\n遅い操作は `bg_id` 付きプレースホルダー tool_result を返し、LLM はコマンドがまだ実行中だと知り、先に他のことをできる。バックグラウンド完了時、通知は独立した text block として現在のターンの tool_result と一緒に 1 つの user メッセージを構成する。\n\n教学版は agent loop が継続実行中にバックグラウンド結果をポーリングする。実際の Claude Code は通知キュー(`messageQueueManager.ts`)でバックグラウンド完了イベントを後続ターンに配信、ツールループを待つ必要はない。\n\n### 組み合わせて実行\n\n```\nTurn 1:\n LLM → bash \"npm install\" (run_in_background=true)\n → start_background_task → bg_0001\n → tool_result: \"[Background task bg_0001 started]...\"\n → LLM: \"OK, I'll check later. Let me also read the config.\"\n\nTurn 2:\n LLM → read_file \"package.json\" (fast, sync)\n → tool_result: file content\n → collect: bg_0001 done! inject \n → LLM sees: config file + install notification in one message\n```\n\nAgent は待たなかった。npm install がバックグラウンドで実行中に、設定ファイルを読んだ。\n\n---\n\n## s12 からの変更\n\n| コンポーネント | 変更前 (s12) | 変更後 (s13) |\n|--------------|------------|------------|\n| 実行モデル | すべて同期 | 遅い操作はバックグラウンドスレッド + 通知注入 |\n| bash スキーマ | `command` | `command` + `run_in_background` |\n| 新規関数 | — | `should_run_background`, `is_slow_operation`, `start_background_task`, `collect_background_results` |\n| 新規型 | — | `background_tasks: dict`, `background_results: dict`, `background_lock: Lock` |\n| 通知形式 | — | ``(tool_use_id を再利用しない) |\n| ループ動作 | ツール直列実行 | 遅い操作は非同期、速い操作は同期、通知は毎ターン収集 |\n| ツール | 8 (s12) | 8(変更なし、実行戦略が変更) |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s13_background_tasks/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `Run pip list in the background and find all Python files in this directory`\n2. `Run npm install (use run_in_background) and while waiting, read package.json`\n3. `Create a task to setup the project, then run pip list in the background`\n\n観察ポイント:遅い操作はバックグラウンドにディスパッチされているか?`bg_id` は返されているか?バックグラウンド通知は `` 形式で注入されているか?\n\n---\n\n## 次の章\n\nAgent は長時間コマンドで止まらなくなった。しかし、定期的に何かをしたい場合は?例えば「毎朝 9 時にテストを実行」「5 分ごとにサーバーステータスを確認」。\n\ns14 Cron Scheduler → Agent にアラームクロックを付ける。\n\n
\nClaude Code ソースコード深掘り\n\n> 以下は Claude Code ソースコード `query.ts`(211, 1054-1060, 1411-1482 行)、`services/toolUseSummary/toolUseSummaryGenerator.ts`(L15 プロンプトテキスト)、`LocalShellTask.tsx`(L24-25 定数, L59-98 ウォッチドッグロジック)、`messageQueueManager.ts`(通知キュー)、`utils/task/framework.ts`(L267 `enqueueTaskNotification`)の完全分析に基づく。\n\n### 一、pendingToolUseSummary:Haiku バックグラウンド生成\n\nClaude Code は各ツール実行バッチの後、Haiku サイドクエリを開始してツール使用サマリを生成。開始コードは `query.ts:1411-1482`、プロンプトテキストは `services/toolUseSummary/toolUseSummaryGenerator.ts:15`(変数 `TOOL_USE_SUMMARY_SYSTEM_PROMPT`)。プロンプトは \"Write a short summary label... think git-commit-subject, not sentence\"、過去形、約 30 文字。\n\nHaiku サマリ(~1s)はメインモデルのストリーミング出力(5-30s)中に完了。次のターン開始前にサマリを yield。SDK コンシューマーはこれらのサマリをモバイル進捗表示に使用。\n\n### 二、スレッドモデル:本当のスレッドはない\n\nClaude Code は Node.js/Bun のシングルスレッドイベントループで動作。「バックグラウンド」は単に「await しない」こと。`ShellCommand.background(taskId)` は stdout/stderr をファイルにリダイレクトし、プロセスを独立実行。\n\n### 三、7 種のバックグラウンドタスク型\n\nClaude Code は 7 種のバックグラウンドタスク型を定義(`Task.ts:7-13`):`local_bash`、`local_agent`、`remote_agent`、`in_process_teammate`、`local_workflow`、`monitor_mcp`、`dream`。それぞれ独自の登録、ライフサイクル、通知メカニズムを持つ。\n\n### 四、通知注入:コマンドキュー\n\nバックグラウンドタスク完了時、`enqueueTaskNotification`(`utils/task/framework.ts:267`)または `enqueuePendingNotification`(`messageQueueManager.ts`)で共有コマンドキューにエンキュー。通知形式は構造化 XML:\n\n```xml\n\n completed\n Background command \"npm test\" completed (exit code 0)\n\n```\n\n優先度は `next` > `later`(`messageQueueManager.ts`)。バックグラウンドタスクはデフォルト `later`(ユーザー入力をブロックしない)。消費点は `query.ts:1566-1593`。\n\n### 五、停滞ウォッチドッグ\n\nバックグラウンド bash タスクにはウォッチドッグがある(`LocalShellTask.tsx` L24-25 定数, L59-98 ロジック)。出力の停滞を定期チェックし、45 秒間増加がない場合にインタラクティブプロンプト(`(y/n)` 等)を検出、バックグラウンドタスクが無応答のインタラクティブダイアログでスタックするのを防ぐ。\n\n### 六、同時実行制限\n\nフォアグラウンドツール呼び出し:`CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY`(デフォルト 10 同時実行安全ツール)。バックグラウンド bash タスク:ハードリミットなし、独立したサブプロセス。\n\n
\n\n\n" }, { "version": "s14", "locale": "en", "title": "s14: Cron Scheduler — Producing Work on a Schedule", - "content": "# s14: Cron Scheduler — Producing Work on a Schedule\n\ns01 → ... → s12 → s13 → `s14` → [s15](/en/s15) → s16 → ... → s20\n> *\"Produce work on a schedule, decouple scheduling from execution\"* — Cron scheduling, durable or session-level.\n>\n> **Harness Layer**: Scheduling — Independent thread checks time, queue delivers triggers.\n\n---\n\n## The Problem\n\nAn alarm clock doesn't need you to watch it. You set 7:00, it rings at 7:00 — you could be sleeping, showering, cooking, it rings regardless.\n\ns13 lets the agent run slow operations in the background, but every operation is still triggered manually. You say something, the agent acts. \"Run tests every morning at 9am\", \"Check CI status every 30 minutes\" — these recurring tasks shouldn't need a human to push them each time.\n\n---\n\n## The Solution\n\n![Cron Scheduler Overview](/course-assets/s14_cron_scheduler/cron-scheduler-overview.en.svg)\n\nTeaching code carries forward S13's simplified task system, background execution, and prompt assembly; to stay focused on the scheduler, it omits full error recovery, memory, and skill systems. Added: an independent cron scheduler thread that polls every second, queues matching jobs into `cron_queue`, and a queue processor that delivers them when the agent is idle.\n\nManual vs Scheduled:\n\n| | Manual (s13) | Scheduled (s14) |\n|---|---|---|\n| Triggered by | User input | Scheduler thread |\n| Trigger timing | Anytime | Specified by cron expression |\n| Human involvement | Yes | No (scheduler auto-enqueues, idle agent auto-delivers) |\n| Persistence | — | Durable survives restart |\n\n---\n\n## How It Works\n\n### Four-Layer Model\n\nCron scheduling has four layers:\n\n1. **Scheduler**: daemon thread, polls every second, checks if it's time\n2. **Queue**: `cron_queue`, scheduler writes fired jobs\n3. **Queue Processor**: sees non-empty queue and idle agent, starts one agent_loop turn\n4. **Consumer**: agent_loop consumes queue and injects into messages\n\nThe teaching version implements a minimal queue processor: `agent_lock` tells whether the agent is idle, and queued cron work is delivered automatically. Real CC's `useQueueProcessor.ts` also handles UI blocking, queue priority, and different message modes.\n\n### CronJob: Data Structure\n\nEach cron task is a `CronJob` object:\n\n```python\n@dataclass\nclass CronJob:\n id: str\n cron: str # \"0 9 * * *\" (5-field cron expression)\n prompt: str # Message injected to the agent when fired\n recurring: bool # True=recurring, False=one-shot\n durable: bool # True=write to disk, survives sessions\n```\n\nCron expression, 5 fields, used by Unix for 50 years:\n\n```\nmin hour dom month dow\n * * * * * Every minute\n 0 9 * * * Every day at 9:00\n*/5 * * * * Every 5 minutes\n 0 9 * * 1-5 Weekdays at 9:00\n```\n\nSupports `*`, `*/N`, `N`, `N-M`, `N,M,...`.\n\n### cron_matches: 5-Field Matching\n\nStandard cron semantics: minute, hour, month must all match; day-of-month (DOM) and day-of-week (DOW) use OR when both are constrained:\n\n```python\ndef cron_matches(cron_expr: str, dt: datetime) -> bool:\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return False\n minute, hour, dom, month, dow = fields\n dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0\n\n m = _cron_field_matches(minute, dt.minute)\n h = _cron_field_matches(hour, dt.hour)\n dom_ok = _cron_field_matches(dom, dt.day)\n month_ok = _cron_field_matches(month, dt.month)\n dow_ok = _cron_field_matches(dow, dow_val)\n\n if not (m and h and month_ok):\n return False\n # DOM and DOW: both constrained → either matching is enough (OR)\n dom_unconstrained = dom == \"*\"\n dow_unconstrained = dow == \"*\"\n if dom_unconstrained and dow_unconstrained:\n return True\n if dom_unconstrained:\n return dow_ok\n if dow_unconstrained:\n return dom_ok\n return dom_ok or dow_ok\n```\n\n### Independent Scheduler Thread: 1-Second Polling\n\nThe scheduler runs in an independent daemon thread, not dependent on whether agent_loop is executing. Individual job errors don't kill the entire thread:\n\n```python\ndef cron_scheduler_loop():\n while True:\n time.sleep(1)\n now = datetime.now()\n minute_marker = now.strftime(\"%Y-%m-%d %H:%M\")\n with cron_lock:\n for job in list(scheduled_jobs.values()):\n try:\n if cron_matches(job.cron, now):\n if _last_fired.get(job.id) != minute_marker:\n cron_queue.append(job)\n _last_fired[job.id] = minute_marker\n if not job.recurring:\n scheduled_jobs.pop(job.id, None)\n if job.durable:\n save_durable_jobs()\n except Exception as e:\n print(f\"[cron error] {job.id}: {e}\")\n```\n\nKey design:\n- **Independent of agent_loop**: scheduler checks time in background even when agent_loop isn't running\n- **Date-aware minute_marker**: uses `\"YYYY-MM-DD HH:MM\"` to prevent same-minute double-fire while not skipping on the next day\n- **Per-job try/except**: one bad job doesn't crash the scheduler thread\n- **One-shot jobs**: auto-removed from scheduled_jobs after firing\n\n### Queue Processor + agent_loop: Delivery\n\nThe queue processor does not check time. It only starts a turn when queued work exists and the agent is idle:\n\n```python\ndef queue_processor_loop():\n while True:\n time.sleep(0.2)\n if not has_cron_queue():\n continue\n if not agent_lock.acquire(blocking=False):\n continue\n try:\n if has_cron_queue():\n run_agent_turn_locked()\n finally:\n agent_lock.release()\n```\n\nagent_loop also doesn't check time. It only takes fired tasks from `cron_queue` and injects them into messages:\n\n```python\nfired = consume_cron_queue()\nfor job in fired:\n messages.append({\"role\": \"user\",\n \"content\": f\"[Scheduled] {job.prompt}\"})\n```\n\nProducer (scheduler thread), deliverer (queue processor), and consumer (agent_loop) are decoupled via `cron_queue`, `cron_lock`, and `agent_lock`.\n\n### Validation: Prevent Bad Cron from Killing the Scheduler\n\n`schedule_job` validates the cron expression before registering, returning an error for invalid input:\n\n```python\ndef schedule_job(cron, prompt, recurring=True, durable=True):\n err = validate_cron(cron)\n if err:\n return err\n # ... register job\n```\n\nLoading durable jobs from disk also skips invalid expressions, preventing a single bad task from breaking startup.\n\n### Durable vs Session-only\n\n- **Durable**: Task definition written to `.scheduled_tasks.json`. Loaded on agent restart.\n- **Session-only**: In-memory only. Gone when the agent closes.\n\n> **Important caveat**: The cron scheduler must run inside the agent process. Process exits, scheduler stops. Durable only means the task definition survives restarts — next time the agent starts, the scheduler discovers \"it should fire\" and fires. If you need \"run even when the app is closed\", use system crontab or systemd timer.\n\n### Putting It Together\n\n```\n1. On startup:\n load_durable_jobs() → restore durable tasks from .scheduled_tasks.json\n Thread(cron_scheduler_loop, daemon=True).start() → scheduler begins polling\n Thread(queue_processor_loop, daemon=True).start() → processor waits to deliver\n\n2. Register a task:\n schedule_cron(cron=\"*/2 * * * *\", prompt=\"run date\", durable=True)\n → CronJob written to scheduled_jobs + .scheduled_tasks.json\n\n3. Every 2 minutes:\n Scheduler checks → cron_matches returns True → cron_queue.append(job)\n → queue processor sees idle agent → agent_loop consume_cron_queue\n → injects \"[Scheduled] run date\"\n → LLM receives message, runs date command\n\n4. Process shutdown:\n Scheduler thread stops (daemon=True)\n .scheduled_tasks.json stays on disk\n Next startup → load_durable_jobs → tasks restored\n```\n\n---\n\n## Changes from s13\n\n| Component | Before (s13) | After (s14) |\n|-----------|-------------|-------------|\n| Trigger method | User manual trigger | Scheduler thread auto-enqueues |\n| New types | — | CronJob dataclass (id, cron, prompt, recurring, durable) |\n| New functions | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop |\n| New storage | — | .scheduled_tasks.json (durable) + memory (session-only) |\n| Threads | Background execution thread | + Scheduler thread (daemon, 1s polling) + queue processor thread |\n| Queue | background_results | + cron_queue (scheduler writes, queue processor delivers, agent_loop consumes) |\n| Tools | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s14_cron_scheduler/code.py\n```\n\nTry these prompts:\n\n1. `Schedule a task to print the current date every 2 minutes`\n2. `List all cron jobs`\n3. `Create a one-shot reminder in 1 minute to check the build status`\n4. `Cancel the recurring job and verify with list_crons`\n\nWhat to observe: Is the scheduler thread running independently? Do cron tasks fire at the correct time? Without a new prompt, do you see `[queue processor]` and automatic execution? Is the durable job written to `.scheduled_tasks.json`?\n\n---\n\n## What's Next\n\nOne agent can do a lot now: plan, compress, background, schedule. But some tasks are too big for one agent.\n\n\"Refactor the entire backend\" — overhaul auth, database layer, API routes, and tests. One agent's attention is limited. This needs a team.\n\ns15 Agent Teams → One agent isn't enough, form a team. Persistent teammates + async inboxes.\n\n
\nDeep Dive into CC Source\n\n> The following is a complete analysis based on CC source code `CronCreateTool.ts`, `cronScheduler.ts`, `cron.ts`, `cronTasks.ts`, `cronTasksLock.ts`, `useScheduledTasks.ts` (139 lines).\n\n### 1. Three Cron Tools\n\nCC exposes three cron tools to the model: `CronCreate`, `CronDelete`, `CronList`. All controlled by compile-time gate `feature('AGENT_TRIGGERS')` and runtime GrowthBook flag `tengu_kairos_cron`. There's also a `CLAUDE_CODE_DISABLE_CRON` env var for local override.\n\n### 2. Storage: `.claude/scheduled_tasks.json`\n\n```json\n{ \"tasks\": [{ \"id\": \"abc12345\", \"cron\": \"0 9 * * *\", \"prompt\": \"...\", \"recurring\": true, \"durable\": true, \"createdAt\": 1714567890000 }] }\n```\n\nDurable tasks write to disk; session-only tasks live in `STATE.sessionCronTasks` memory array (lost on process restart). A `.scheduled_tasks.lock` file prevents duplicate firing across multiple sessions of the same project.\n\n### 3. Scheduler: 1-Second Polling\n\n`cronScheduler.ts` checks every second (`CHECK_INTERVAL_MS = 1000`). Whoever holds the lock triggers file tasks; all sessions trigger session-only tasks. A `chokidar` file watcher monitors `scheduled_tasks.json` changes.\n\n### 4. Cron Expression: Standard 5 Fields\n\nMinute hour day month weekday. Supports `*`, `*/N`, `N`, `N-M`, `N-M/S`, `N,M,...`. Doesn't support `L`, `W`, `?`. All times interpreted in local timezone. Day-of-month and day-of-week use OR semantics when both are constrained.\n\n### 5. Jitter (Thundering Herd Prevention)\n\n- Recurring tasks: trigger delay up to 10% of period (max 15 min), deterministic hash based on task ID\n- One-shot tasks: up to 90s early when firing time falls on `:00` or `:30`\n- Jitter config adjustable via GrowthBook, refreshed every 60 seconds\n\n### 6. Auto-Expiration\n\nRecurring tasks auto-expire after 7 days (configurable, max 30 days). Fire one last time before expiry, then auto-delete.\n\n### 7. Job Limit\n\n`MAX_JOBS = 50` (`CronCreateTool.ts:25`). Returns error when exceeded: \"Too many scheduled jobs (max 50). Cancel one first.\"\n\n### 8. Trigger Injection\n\nAfter firing, enqueued via `enqueuePendingNotification()` with `priority: 'later'` into the command queue. Tagged `workload: WORKLOAD_CRON` — API serves cron-initiated requests at lower QoS when capacity is tight.\n\n### 9. Queue Processor: Automatic Delivery\n\nReal CC auto-triggers processing through `useQueueProcessor.ts:48-60` when no query is active, UI isn't blocked, and queue is non-empty. `queueProcessor.ts:52-87` dispatches commands to `handlePromptSubmit()` by queue priority. The teaching version keeps the core behavior with `queue_processor_loop`: when queued work exists and the agent is idle, it starts one agent_loop turn automatically.\n\n
\n\n\n" + "content": "# s14: Cron Scheduler — Producing Work on a Schedule\n\ns01 → ... → s12 → s13 → `s14` → [s15](/en/s15) → s16 → ... → s20\n> *\"Produce work on a schedule, decouple scheduling from execution\"* — Cron scheduling, durable or session-level.\n>\n> **Harness Layer**: Scheduling — Independent thread checks time, queue delivers triggers.\n\n---\n\n## The Problem\n\nAn alarm clock doesn't need you to watch it. You set 7:00, it rings at 7:00. You could be sleeping, showering, cooking, it rings regardless.\n\ns13 lets the agent run slow operations in the background, but every operation is still triggered manually. You say something, the agent acts. \"Run tests every morning at 9am\", \"Check CI status every 30 minutes\": these recurring tasks shouldn't need a human to push them each time.\n\n---\n\n## The Solution\n\n![Cron Scheduler Overview](/course-assets/s14_cron_scheduler/cron-scheduler-overview.svg)\n\nTeaching code carries forward S13's simplified task system, background execution, and prompt assembly; to stay focused on the scheduler, it omits full error recovery, memory, and skill systems. Added: an independent cron scheduler thread that polls every second, queues matching jobs into `cron_queue`, and a queue processor that delivers them when the agent is idle.\n\nManual vs Scheduled:\n\n| | Manual (s13) | Scheduled (s14) |\n|---|---|---|\n| Triggered by | User input | Scheduler thread |\n| Trigger timing | Anytime | Specified by cron expression |\n| Human involvement | Yes | No (scheduler auto-enqueues, idle agent auto-delivers) |\n| Persistence | — | Durable survives restart |\n\n---\n\n## How It Works\n\n### Four-Layer Model\n\nCron scheduling has four layers:\n\n1. **Scheduler**: daemon thread, polls every second, checks if it's time\n2. **Queue**: `cron_queue`, scheduler writes fired jobs\n3. **Queue Processor**: sees non-empty queue and idle agent, starts one agent_loop turn\n4. **Consumer**: agent_loop consumes queue and injects into messages\n\nThe teaching version implements a minimal queue processor: `agent_lock` tells whether the agent is idle, and queued cron work is delivered automatically. Real Claude Code's `useQueueProcessor.ts` also handles UI blocking, queue priority, and different message modes.\n\n### CronJob: Data Structure\n\nEach cron task is a `CronJob` object:\n\n```python\n@dataclass\nclass CronJob:\n id: str\n cron: str # \"0 9 * * *\" (5-field cron expression)\n prompt: str # Message injected to the agent when fired\n recurring: bool # True=recurring, False=one-shot\n durable: bool # True=write to disk, survives sessions\n```\n\nCron expression, 5 fields, used by Unix for 50 years:\n\n```\nmin hour dom month dow\n * * * * * Every minute\n 0 9 * * * Every day at 9:00\n*/5 * * * * Every 5 minutes\n 0 9 * * 1-5 Weekdays at 9:00\n```\n\nSupports `*`, `*/N`, `N`, `N-M`, `N,M,...`.\n\n### cron_matches: 5-Field Matching\n\nStandard cron semantics: minute, hour, month must all match; day-of-month (DOM) and day-of-week (DOW) use OR when both are constrained:\n\n```python\ndef cron_matches(cron_expr: str, dt: datetime) -> bool:\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return False\n minute, hour, dom, month, dow = fields\n dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0\n\n m = _cron_field_matches(minute, dt.minute)\n h = _cron_field_matches(hour, dt.hour)\n dom_ok = _cron_field_matches(dom, dt.day)\n month_ok = _cron_field_matches(month, dt.month)\n dow_ok = _cron_field_matches(dow, dow_val)\n\n if not (m and h and month_ok):\n return False\n # DOM and DOW: both constrained → either matching is enough (OR)\n dom_unconstrained = dom == \"*\"\n dow_unconstrained = dow == \"*\"\n if dom_unconstrained and dow_unconstrained:\n return True\n if dom_unconstrained:\n return dow_ok\n if dow_unconstrained:\n return dom_ok\n return dom_ok or dow_ok\n```\n\n### Independent Scheduler Thread: 1-Second Polling\n\nThe scheduler runs in an independent daemon thread, not dependent on whether agent_loop is executing. Individual job errors don't kill the entire thread:\n\n```python\ndef cron_scheduler_loop():\n while True:\n time.sleep(1)\n now = datetime.now()\n minute_marker = now.strftime(\"%Y-%m-%d %H:%M\")\n with cron_lock:\n for job in list(scheduled_jobs.values()):\n try:\n if cron_matches(job.cron, now):\n if _last_fired.get(job.id) != minute_marker:\n cron_queue.append(job)\n _last_fired[job.id] = minute_marker\n if not job.recurring:\n scheduled_jobs.pop(job.id, None)\n if job.durable:\n save_durable_jobs()\n except Exception as e:\n print(f\"[cron error] {job.id}: {e}\")\n```\n\nKey design:\n- **Independent of agent_loop**: scheduler checks time in background even when agent_loop isn't running\n- **Date-aware minute_marker**: uses `\"YYYY-MM-DD HH:MM\"` to prevent same-minute double-fire while not skipping on the next day\n- **Per-job try/except**: one bad job doesn't crash the scheduler thread\n- **One-shot jobs**: auto-removed from scheduled_jobs after firing\n\n### Queue Processor + agent_loop: Delivery\n\nThe queue processor does not check time. It only starts a turn when queued work exists and the agent is idle:\n\n```python\ndef queue_processor_loop():\n while True:\n time.sleep(0.2)\n if not has_cron_queue():\n continue\n if not agent_lock.acquire(blocking=False):\n continue\n try:\n if has_cron_queue():\n run_agent_turn_locked()\n finally:\n agent_lock.release()\n```\n\nagent_loop also doesn't check time. It only takes fired tasks from `cron_queue` and injects them into messages:\n\n```python\nfired = consume_cron_queue()\nfor job in fired:\n messages.append({\"role\": \"user\",\n \"content\": f\"[Scheduled] {job.prompt}\"})\n```\n\nProducer (scheduler thread), deliverer (queue processor), and consumer (agent_loop) are decoupled via `cron_queue`, `cron_lock`, and `agent_lock`.\n\n### Validation: Prevent Bad Cron from Killing the Scheduler\n\n`schedule_job` validates the cron expression before registering, returning an error for invalid input:\n\n```python\ndef schedule_job(cron, prompt, recurring=True, durable=True):\n err = validate_cron(cron)\n if err:\n return err\n # ... register job\n```\n\nLoading durable jobs from disk also skips invalid expressions, preventing a single bad task from breaking startup.\n\n### Durable vs Session-only\n\n- **Durable**: Task definition written to `.scheduled_tasks.json`. Loaded on agent restart.\n- **Session-only**: In-memory only. Gone when the agent closes.\n\n> **Important caveat**: The cron scheduler must run inside the agent process. Process exits, scheduler stops. Durable only means the task definition survives restarts. Next time the agent starts, the scheduler checks whether a job is overdue and fires it. If you need \"run even when the app is closed\", use system crontab or systemd timer.\n\n### Putting It Together\n\n```\n1. On startup:\n load_durable_jobs() → restore durable tasks from .scheduled_tasks.json\n Thread(cron_scheduler_loop, daemon=True).start() → scheduler begins polling\n Thread(queue_processor_loop, daemon=True).start() → processor waits to deliver\n\n2. Register a task:\n schedule_cron(cron=\"*/2 * * * *\", prompt=\"run date\", durable=True)\n → CronJob written to scheduled_jobs + .scheduled_tasks.json\n\n3. Every 2 minutes:\n Scheduler checks → cron_matches returns True → cron_queue.append(job)\n → queue processor sees idle agent → agent_loop consume_cron_queue\n → injects \"[Scheduled] run date\"\n → LLM receives message, runs date command\n\n4. Process shutdown:\n Scheduler thread stops (daemon=True)\n .scheduled_tasks.json stays on disk\n Next startup → load_durable_jobs → tasks restored\n```\n\n---\n\n## Changes from s13\n\n| Component | Before (s13) | After (s14) |\n|-----------|-------------|-------------|\n| Trigger method | User manual trigger | Scheduler thread auto-enqueues |\n| New types | — | CronJob dataclass (id, cron, prompt, recurring, durable) |\n| New functions | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop |\n| New storage | — | .scheduled_tasks.json (durable) + memory (session-only) |\n| Threads | Background execution thread | + Scheduler thread (daemon, 1s polling) + queue processor thread |\n| Queue | background_results | + cron_queue (scheduler writes, queue processor delivers, agent_loop consumes) |\n| Tools | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s14_cron_scheduler/code.py\n```\n\nTry these prompts:\n\n1. `Schedule a task to print the current date every 2 minutes`\n2. `List all cron jobs`\n3. `Create a one-shot reminder in 1 minute to check the build status`\n4. `Cancel the recurring job and verify with list_crons`\n\nWhat to observe: Is the scheduler thread running independently? Do cron tasks fire at the correct time? Without a new prompt, do you see `[queue processor]` and automatic execution? Is the durable job written to `.scheduled_tasks.json`?\n\n---\n\n## What's Next\n\nOne agent can plan, compress, background, and schedule. But some tasks are too big: \"refactor the entire backend\" means overhauling auth, database layer, API routes, and tests. One agent's context window can't hold all of that.\n\ns15 Agent Teams → Multiple agents, persistent teammates + async inboxes.\n\n
\nDeep Dive into Claude Code Source\n\n> The following is a complete analysis based on Claude Code source code `CronCreateTool.ts`, `cronScheduler.ts`, `cron.ts`, `cronTasks.ts`, `cronTasksLock.ts`, `useScheduledTasks.ts` (139 lines).\n\n### 1. Three Cron Tools\n\nClaude Code exposes three cron tools to the model: `CronCreate`, `CronDelete`, `CronList`. All controlled by compile-time gate `feature('AGENT_TRIGGERS')` and runtime GrowthBook flag `tengu_kairos_cron`. There's also a `CLAUDE_CODE_DISABLE_CRON` env var for local override.\n\n### 2. Storage: `.claude/scheduled_tasks.json`\n\n```json\n{ \"tasks\": [{ \"id\": \"abc12345\", \"cron\": \"0 9 * * *\", \"prompt\": \"...\", \"recurring\": true, \"durable\": true, \"createdAt\": 1714567890000 }] }\n```\n\nDurable tasks write to disk; session-only tasks live in `STATE.sessionCronTasks` memory array (lost on process restart). A `.scheduled_tasks.lock` file prevents duplicate firing across multiple sessions of the same project.\n\n### 3. Scheduler: 1-Second Polling\n\n`cronScheduler.ts` checks every second (`CHECK_INTERVAL_MS = 1000`). Whoever holds the lock triggers file tasks; all sessions trigger session-only tasks. A `chokidar` file watcher monitors `scheduled_tasks.json` changes.\n\n### 4. Cron Expression: Standard 5 Fields\n\nMinute hour day month weekday. Supports `*`, `*/N`, `N`, `N-M`, `N-M/S`, `N,M,...`. Doesn't support `L`, `W`, `?`. All times interpreted in local timezone. Day-of-month and day-of-week use OR semantics when both are constrained.\n\n### 5. Jitter (Thundering Herd Prevention)\n\n- Recurring tasks: trigger delay up to 10% of period (max 15 min), deterministic hash based on task ID\n- One-shot tasks: up to 90s early when firing time falls on `:00` or `:30`\n- Jitter config adjustable via GrowthBook, refreshed every 60 seconds\n\n### 6. Auto-Expiration\n\nRecurring tasks auto-expire after 7 days (configurable, max 30 days). Fire one last time before expiry, then auto-delete.\n\n### 7. Job Limit\n\n`MAX_JOBS = 50` (`CronCreateTool.ts:25`). Returns error when exceeded: \"Too many scheduled jobs (max 50). Cancel one first.\"\n\n### 8. Trigger Injection\n\nAfter firing, enqueued via `enqueuePendingNotification()` with `priority: 'later'` into the command queue. Tagged `workload: WORKLOAD_CRON` — API serves cron-initiated requests at lower QoS when capacity is tight.\n\n### 9. Queue Processor: Automatic Delivery\n\nReal Claude Code auto-triggers processing through `useQueueProcessor.ts:48-60` when no query is active, UI isn't blocked, and queue is non-empty. `queueProcessor.ts:52-87` dispatches commands to `handlePromptSubmit()` by queue priority. The teaching version keeps the core behavior with `queue_processor_loop`: when queued work exists and the agent is idle, it starts one agent_loop turn automatically.\n\n
\n\n\n" }, { "version": "s14", "locale": "zh", "title": "s14: Cron Scheduler — 按时间表生产工作", - "content": "# s14: Cron Scheduler — 按时间表生产工作\n\ns01 → ... → s12 → s13 → `s14` → [s15](/zh/s15) → s16 → ... → s20\n> *\"按时间表生产工作, 调度与执行解耦\"* — cron 调度, 持久化或会话级。\n>\n> **Harness 层**: 调度 — 独立线程判断时间, 队列传递触发。\n\n---\n\n## 问题\n\n闹钟不需要你盯着它才会响。你设好 7:00,到点它自己响,你在睡觉、在洗澡、在做饭,它都照响不误。\n\ns13 让 Agent 能后台执行慢操作,但所有操作仍然是你手动触发的。你说一句,Agent 动一下。\"每天早上 9 点跑测试\"、\"每 30 分钟检查 CI 状态\",这些周期性任务不该需要人每次来推。\n\n---\n\n## 解决方案\n\n![Cron Scheduler Overview](/course-assets/s14_cron_scheduler/cron-scheduler-overview.svg)\n\n教学代码沿用 S13 的简化任务系统、后台执行和 prompt 组装;为了聚焦调度器,省略完整错误恢复、记忆和技能系统。新增:独立的 cron 调度线程,每秒检查一次,时间到了把任务塞进 `cron_queue`;再由 queue processor 在 Agent 空闲时自动交付。\n\n手动 vs 定时:\n\n| | 手动触发 (s13) | 定时触发 (s14) |\n|---|---|---|\n| 触发者 | 用户输入 | 调度线程 |\n| 触发时机 | 随时 | cron 表达式指定 |\n| 需要人参与 | 是 | 否(调度器自动入队,空闲时自动交付) |\n| 持久性 | — | durable 跨重启 |\n\n---\n\n## 工作原理\n\n### 四层模型\n\nCron 调度分四层:\n\n1. **Scheduler**:daemon 线程,每秒轮询,判断时间到了没有\n2. **Queue**:`cron_queue`,调度线程写入已触发任务\n3. **Queue Processor**:发现队列非空且 Agent 空闲,启动一轮 agent_loop\n4. **Consumer**:agent_loop 从队列消费,注入到 messages\n\n教学版实现的是最小 queue processor:用 `agent_lock` 判断 Agent 是否空闲,空闲时自动交付定时任务。真实 CC 的 `useQueueProcessor.ts` 还会处理 UI 阻塞、队列优先级和不同消息模式。\n\n### CronJob: 数据结构\n\n每个 cron 任务是一个 `CronJob` 对象:\n\n```python\n@dataclass\nclass CronJob:\n id: str\n cron: str # \"0 9 * * *\" (五段式 cron 表达式)\n prompt: str # 触发时注入给 Agent 的消息\n recurring: bool # True=周期性,False=一次性\n durable: bool # True=写磁盘,跨会话保留\n```\n\nCron 表达式,五段式,Unix 用了 50 年:\n\n```\n分钟 小时 日 月 星期\n * * * * * 每分钟\n 0 9 * * * 每天早上 9:00\n */5 * * * * 每 5 分钟\n 0 9 * * 1-5 工作日早上 9:00\n```\n\n支持 `*`、`*/N`、`N`、`N-M`、`N,M,...`。\n\n### cron_matches: 五段式匹配\n\n标准 cron 语义:分钟、小时、月必须全部匹配;日(DOM)和星期(DOW)同时被约束时任一匹配即可(OR):\n\n```python\ndef cron_matches(cron_expr: str, dt: datetime) -> bool:\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return False\n minute, hour, dom, month, dow = fields\n dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0\n\n m = _cron_field_matches(minute, dt.minute)\n h = _cron_field_matches(hour, dt.hour)\n dom_ok = _cron_field_matches(dom, dt.day)\n month_ok = _cron_field_matches(month, dt.month)\n dow_ok = _cron_field_matches(dow, dow_val)\n\n if not (m and h and month_ok):\n return False\n # DOM and DOW: both constrained → either matching is enough (OR)\n dom_unconstrained = dom == \"*\"\n dow_unconstrained = dow == \"*\"\n if dom_unconstrained and dow_unconstrained:\n return True\n if dom_unconstrained:\n return dow_ok\n if dow_unconstrained:\n return dom_ok\n return dom_ok or dow_ok\n```\n\n### 独立调度线程: 每秒轮询\n\n调度器跑在独立的 daemon 线程里,不依赖 agent_loop 是否在执行。单个 job 异常不会杀掉整个线程:\n\n```python\ndef cron_scheduler_loop():\n while True:\n time.sleep(1)\n now = datetime.now()\n minute_marker = now.strftime(\"%Y-%m-%d %H:%M\")\n with cron_lock:\n for job in list(scheduled_jobs.values()):\n try:\n if cron_matches(job.cron, now):\n if _last_fired.get(job.id) != minute_marker:\n cron_queue.append(job)\n _last_fired[job.id] = minute_marker\n if not job.recurring:\n scheduled_jobs.pop(job.id, None)\n if job.durable:\n save_durable_jobs()\n except Exception as e:\n print(f\"[cron error] {job.id}: {e}\")\n```\n\n关键设计:\n- **独立于 agent_loop**:即使 agent_loop 没在跑,调度器也在后台检查时间\n- **date-aware minute_marker**:用 `\"YYYY-MM-DD HH:MM\"` 防止同一分钟重复触发,同时不会在第二天跳过\n- **单 job try/except**:一个坏 job 不会拖垮整个调度线程\n- **一次性任务**:触发后自动从 scheduled_jobs 里删除\n\n### Queue Processor + agent_loop: 交付端\n\nqueue processor 不检查时间,只负责在队列有任务且 Agent 空闲时拉起一轮执行:\n\n```python\ndef queue_processor_loop():\n while True:\n time.sleep(0.2)\n if not has_cron_queue():\n continue\n if not agent_lock.acquire(blocking=False):\n continue\n try:\n if has_cron_queue():\n run_agent_turn_locked()\n finally:\n agent_lock.release()\n```\n\nagent_loop 也不负责检查时间,它只从 `cron_queue` 里拿已触发的任务,注入到 messages 里:\n\n```python\nfired = consume_cron_queue()\nfor job in fired:\n messages.append({\"role\": \"user\",\n \"content\": f\"[Scheduled] {job.prompt}\"})\n```\n\n生产者(调度线程)、交付者(queue processor)和消费者(agent_loop)通过 `cron_queue`、`cron_lock`、`agent_lock` 解耦。\n\n### 校验:防止坏 cron 杀掉调度器\n\n`schedule_job` 在注册前校验 cron 表达式,非法的直接返回错误:\n\n```python\ndef schedule_job(cron, prompt, recurring=True, durable=True):\n err = validate_cron(cron)\n if err:\n return err\n # ... register job\n```\n\n从磁盘加载 durable job 时也会跳过非法表达式,避免单个坏任务拖垮启动。\n\n### Durable vs Session-only\n\n- **Durable**:任务定义写进 `.scheduled_tasks.json`。Agent 重启后加载文件,恢复任务。\n- **Session-only**:只在内存里。Agent 关闭就没了。\n\n> **重要前提**:cron 调度器必须在 Agent 进程内跑。进程关闭,调度也停。Durable 只意味着任务定义跨重启保留,下次 Agent 启动时调度器才会发现\"该触发了\"并触发。如果需要\"即使应用关闭也能定时跑\",请用系统 crontab 或 systemd timer。\n\n### 合起来跑\n\n```\n1. 启动时:\n load_durable_jobs() → 从 .scheduled_tasks.json 恢复持久化任务\n Thread(cron_scheduler_loop, daemon=True).start() → 调度线程开始轮询\n Thread(queue_processor_loop, daemon=True).start() → 队列处理器等待交付\n\n2. 注册任务:\n schedule_cron(cron=\"*/2 * * * *\", prompt=\"run date\", durable=True)\n → CronJob 写入 scheduled_jobs + .scheduled_tasks.json\n\n3. 每 2 分钟:\n 调度线程检查 → cron_matches 返回 True → cron_queue.append(job)\n → queue processor 发现 Agent 空闲 → agent_loop consume_cron_queue\n → 注入 \"[Scheduled] run date\"\n → LLM 收到消息,执行 date 命令\n\n4. 关闭进程:\n 调度线程跟着停(daemon=True)\n .scheduled_tasks.json 还在磁盘上\n 下次启动 → load_durable_jobs → 任务恢复\n```\n\n---\n\n## 相对 s13 的变更\n\n| 组件 | 之前 (s13) | 之后 (s14) |\n|------|-----------|-----------|\n| 触发方式 | 用户手动触发 | 调度线程自动入队 |\n| 新类型 | — | CronJob dataclass (id, cron, prompt, recurring, durable) |\n| 新函数 | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop |\n| 新存储 | — | .scheduled_tasks.json (durable) + 内存 (session-only) |\n| 线程 | 后台执行线程 | + 调度线程 (daemon, 1s 轮询) + queue processor 线程 |\n| 队列 | background_results | + cron_queue (调度线程写, queue processor 交付, agent_loop 消费) |\n| 工具 | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s14_cron_scheduler/code.py\n```\n\n试试这些 prompt:\n\n1. `Schedule a task to print the current date every 2 minutes`\n2. `List all cron jobs`\n3. `Create a one-shot reminder in 1 minute to check the build status`\n4. `Cancel the recurring job and verify with list_crons`\n\n观察重点:调度线程是否在独立运行?cron 任务是否在正确的时间点触发?不输入新 prompt 时,是否也出现 `[queue processor]` 并自动执行?durable job 是否写入了 `.scheduled_tasks.json`?\n\n---\n\n## 接下来\n\n一个 Agent 能做很多事了,能计划、能压缩、能后台、能定时。但有些任务太大了,不是一个 Agent 能搞定的。\n\n\"重构整个后端\",把认证模块、数据库层、API 路由、测试全部翻新。一个 Agent 的注意力是有限的,这需要一个团队。\n\ns15 Agent Teams → 一个 Agent 不够,组队吧。持久队友 + 异步收件箱。\n\n
\n深入 CC 源码\n\n> 以下基于 CC 源码 `CronCreateTool.ts`、`cronScheduler.ts`、`cron.ts`、`cronTasks.ts`、`cronTasksLock.ts`、`useScheduledTasks.ts`(139 行)的完整分析。\n\n### 一、三个 Cron 工具\n\nCC 暴露了三个 cron 工具给模型:`CronCreate`、`CronDelete`、`CronList`。全部由编译时门控 `feature('AGENT_TRIGGERS')` 和运行时 GrowthBook 标志 `tengu_kairos_cron` 控制。还有一个 `CLAUDE_CODE_DISABLE_CRON` 环境变量做本地覆盖。\n\n### 二、存储:`.claude/scheduled_tasks.json`\n\n```json\n{ \"tasks\": [{ \"id\": \"abc12345\", \"cron\": \"0 9 * * *\", \"prompt\": \"...\", \"recurring\": true, \"durable\": true, \"createdAt\": 1714567890000 }] }\n```\n\nDurable 任务写磁盘;session-only 任务存于 `STATE.sessionCronTasks` 内存数组(进程重启丢失)。还有一个 `.scheduled_tasks.lock` 文件防止同项目的多个 session 重复触发。\n\n### 三、调度器:1 秒轮询\n\n`cronScheduler.ts` 每秒检查一次(`CHECK_INTERVAL_MS = 1000`)。谁持有锁谁触发文件任务;所有 session 都触发仅 session 任务。还有一个 `chokidar` 文件观察者监视 `scheduled_tasks.json` 变更。\n\n### 四、Cron 表达式:标准 5 字段\n\n分钟 小时 日 月 星期。支持 `*`、`*/N`、`N`、`N-M`、`N-M/S`、`N,M,...`。不支持 `L`、`W`、`?`。所有时间以本地时区解释。Day-of-month 和 day-of-week 同时约束时用 OR 语义。\n\n### 五、抖动(防惊群效应)\n\n- 重复性任务:触发延迟最多可达期间的 10%(上限 15 分钟),基于任务 ID 的确定性哈希\n- 一次性任务:当触发时间落在 `:00` 或 `:30` 时,最多提前 90 秒触发\n- 抖动配置可通过 GrowthBook 实时调整,60 秒刷新一次\n\n### 六、自动过期\n\n重复性任务 7 天后自动过期(可配置,上限 30 天)。过期前最后一次触发,触发后自动删除。\n\n### 七、作业数上限\n\n`MAX_JOBS = 50`(`CronCreateTool.ts:25`)。超限时返回错误:\"Too many scheduled jobs (max 50). Cancel one first.\"\n\n### 八、触发注入\n\n触发后通过 `enqueuePendingNotification()` 以 `priority: 'later'` 入队命令队列。标记 `workload: WORKLOAD_CRON`,API 在容量紧张时以更低的 QoS 为 cron 发起的请求服务。\n\n### 九、Queue Processor:自动交付\n\n真实 CC 通过 `useQueueProcessor.ts:48-60` 在无 query、无阻塞 UI、队列非空时自动触发处理。`queueProcessor.ts:52-87` 按队列优先级把命令交给 `handlePromptSubmit()`。教学版用 `queue_processor_loop` 保留核心行为:队列有任务且 Agent 空闲时,自动启动一轮 agent_loop。\n\n
\n\n\n" + "content": "# s14: Cron Scheduler — 按时间表生产工作\n\ns01 → ... → s12 → s13 → `s14` → [s15](/zh/s15) → s16 → ... → s20\n> *\"按时间表生产工作, 调度与执行解耦\"* — cron 调度, 持久化或会话级。\n>\n> **Harness 层**: 调度 — 独立线程判断时间, 队列传递触发。\n\n---\n\n## 问题\n\n闹钟不需要你盯着它才会响。你设好 7:00,到点它自己响,你在睡觉、在洗澡、在做饭,它都照响不误。\n\ns13 让 Agent 能后台执行慢操作,但所有操作仍然是你手动触发的。你说一句,Agent 动一下。\"每天早上 9 点跑测试\"、\"每 30 分钟检查 CI 状态\",这些周期性任务不该需要人每次来推。\n\n---\n\n## 解决方案\n\n![Cron Scheduler Overview](/course-assets/s14_cron_scheduler/cron-scheduler-overview.svg)\n\n教学代码沿用 S13 的简化任务系统、后台执行和 prompt 组装;为了聚焦调度器,省略完整错误恢复、记忆和技能系统。新增:独立的 cron 调度线程,每秒检查一次,时间到了把任务塞进 `cron_queue`;再由 queue processor 在 Agent 空闲时自动交付。\n\n手动 vs 定时:\n\n| | 手动触发 (s13) | 定时触发 (s14) |\n|---|---|---|\n| 触发者 | 用户输入 | 调度线程 |\n| 触发时机 | 随时 | cron 表达式指定 |\n| 需要人参与 | 是 | 否(调度器自动入队,空闲时自动交付) |\n| 持久性 | — | durable 跨重启 |\n\n---\n\n## 工作原理\n\n### 四层模型\n\nCron 调度分四层:\n\n1. **Scheduler**:daemon 线程,每秒轮询,判断时间到了没有\n2. **Queue**:`cron_queue`,调度线程写入已触发任务\n3. **Queue Processor**:发现队列非空且 Agent 空闲,启动一轮 agent_loop\n4. **Consumer**:agent_loop 从队列消费,注入到 messages\n\n教学版实现的是最小 queue processor:用 `agent_lock` 判断 Agent 是否空闲,空闲时自动交付定时任务。真实 Claude Code 的 `useQueueProcessor.ts` 还会处理 UI 阻塞、队列优先级和不同消息模式。\n\n### CronJob: 数据结构\n\n每个 cron 任务是一个 `CronJob` 对象:\n\n```python\n@dataclass\nclass CronJob:\n id: str\n cron: str # \"0 9 * * *\" (五段式 cron 表达式)\n prompt: str # 触发时注入给 Agent 的消息\n recurring: bool # True=周期性,False=一次性\n durable: bool # True=写磁盘,跨会话保留\n```\n\nCron 表达式,五段式,Unix 用了 50 年:\n\n```\n分钟 小时 日 月 星期\n * * * * * 每分钟\n 0 9 * * * 每天早上 9:00\n */5 * * * * 每 5 分钟\n 0 9 * * 1-5 工作日早上 9:00\n```\n\n支持 `*`、`*/N`、`N`、`N-M`、`N,M,...`。\n\n### cron_matches: 五段式匹配\n\n标准 cron 语义:分钟、小时、月必须全部匹配;日(DOM)和星期(DOW)同时被约束时任一匹配即可(OR):\n\n```python\ndef cron_matches(cron_expr: str, dt: datetime) -> bool:\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return False\n minute, hour, dom, month, dow = fields\n dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0\n\n m = _cron_field_matches(minute, dt.minute)\n h = _cron_field_matches(hour, dt.hour)\n dom_ok = _cron_field_matches(dom, dt.day)\n month_ok = _cron_field_matches(month, dt.month)\n dow_ok = _cron_field_matches(dow, dow_val)\n\n if not (m and h and month_ok):\n return False\n # DOM and DOW: both constrained → either matching is enough (OR)\n dom_unconstrained = dom == \"*\"\n dow_unconstrained = dow == \"*\"\n if dom_unconstrained and dow_unconstrained:\n return True\n if dom_unconstrained:\n return dow_ok\n if dow_unconstrained:\n return dom_ok\n return dom_ok or dow_ok\n```\n\n### 独立调度线程: 每秒轮询\n\n调度器跑在独立的 daemon 线程里,不依赖 agent_loop 是否在执行。单个 job 异常不会杀掉整个线程:\n\n```python\ndef cron_scheduler_loop():\n while True:\n time.sleep(1)\n now = datetime.now()\n minute_marker = now.strftime(\"%Y-%m-%d %H:%M\")\n with cron_lock:\n for job in list(scheduled_jobs.values()):\n try:\n if cron_matches(job.cron, now):\n if _last_fired.get(job.id) != minute_marker:\n cron_queue.append(job)\n _last_fired[job.id] = minute_marker\n if not job.recurring:\n scheduled_jobs.pop(job.id, None)\n if job.durable:\n save_durable_jobs()\n except Exception as e:\n print(f\"[cron error] {job.id}: {e}\")\n```\n\n关键设计:\n- **独立于 agent_loop**:即使 agent_loop 没在跑,调度器也在后台检查时间\n- **date-aware minute_marker**:用 `\"YYYY-MM-DD HH:MM\"` 防止同一分钟重复触发,同时不会在第二天跳过\n- **单 job try/except**:一个坏 job 不会拖垮整个调度线程\n- **一次性任务**:触发后自动从 scheduled_jobs 里删除\n\n### Queue Processor + agent_loop: 交付端\n\nqueue processor 不检查时间,只负责在队列有任务且 Agent 空闲时拉起一轮执行:\n\n```python\ndef queue_processor_loop():\n while True:\n time.sleep(0.2)\n if not has_cron_queue():\n continue\n if not agent_lock.acquire(blocking=False):\n continue\n try:\n if has_cron_queue():\n run_agent_turn_locked()\n finally:\n agent_lock.release()\n```\n\nagent_loop 也不负责检查时间,它只从 `cron_queue` 里拿已触发的任务,注入到 messages 里:\n\n```python\nfired = consume_cron_queue()\nfor job in fired:\n messages.append({\"role\": \"user\",\n \"content\": f\"[Scheduled] {job.prompt}\"})\n```\n\n生产者(调度线程)、交付者(queue processor)和消费者(agent_loop)通过 `cron_queue`、`cron_lock`、`agent_lock` 解耦。\n\n### 校验:防止坏 cron 杀掉调度器\n\n`schedule_job` 在注册前校验 cron 表达式,非法的直接返回错误:\n\n```python\ndef schedule_job(cron, prompt, recurring=True, durable=True):\n err = validate_cron(cron)\n if err:\n return err\n # ... register job\n```\n\n从磁盘加载 durable job 时也会跳过非法表达式,避免单个坏任务拖垮启动。\n\n### Durable vs Session-only\n\n- **Durable**:任务定义写进 `.scheduled_tasks.json`。Agent 重启后加载文件,恢复任务。\n- **Session-only**:只在内存里。Agent 关闭就没了。\n\n> **重要前提**:cron 调度器必须在 Agent 进程内跑。进程关闭,调度也停。Durable 只意味着任务定义跨重启保留,下次 Agent 启动时调度器才会发现\"该触发了\"并触发。如果需要\"即使应用关闭也能定时跑\",请用系统 crontab 或 systemd timer。\n\n### 合起来跑\n\n```\n1. 启动时:\n load_durable_jobs() → 从 .scheduled_tasks.json 恢复持久化任务\n Thread(cron_scheduler_loop, daemon=True).start() → 调度线程开始轮询\n Thread(queue_processor_loop, daemon=True).start() → 队列处理器等待交付\n\n2. 注册任务:\n schedule_cron(cron=\"*/2 * * * *\", prompt=\"run date\", durable=True)\n → CronJob 写入 scheduled_jobs + .scheduled_tasks.json\n\n3. 每 2 分钟:\n 调度线程检查 → cron_matches 返回 True → cron_queue.append(job)\n → queue processor 发现 Agent 空闲 → agent_loop consume_cron_queue\n → 注入 \"[Scheduled] run date\"\n → LLM 收到消息,执行 date 命令\n\n4. 关闭进程:\n 调度线程跟着停(daemon=True)\n .scheduled_tasks.json 还在磁盘上\n 下次启动 → load_durable_jobs → 任务恢复\n```\n\n---\n\n## 相对 s13 的变更\n\n| 组件 | 之前 (s13) | 之后 (s14) |\n|------|-----------|-----------|\n| 触发方式 | 用户手动触发 | 调度线程自动入队 |\n| 新类型 | — | CronJob dataclass (id, cron, prompt, recurring, durable) |\n| 新函数 | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop |\n| 新存储 | — | .scheduled_tasks.json (durable) + 内存 (session-only) |\n| 线程 | 后台执行线程 | + 调度线程 (daemon, 1s 轮询) + queue processor 线程 |\n| 队列 | background_results | + cron_queue (调度线程写, queue processor 交付, agent_loop 消费) |\n| 工具 | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s14_cron_scheduler/code.py\n```\n\n试试这些 prompt:\n\n1. `Schedule a task to print the current date every 2 minutes`\n2. `List all cron jobs`\n3. `Create a one-shot reminder in 1 minute to check the build status`\n4. `Cancel the recurring job and verify with list_crons`\n\n观察重点:调度线程是否在独立运行?cron 任务是否在正确的时间点触发?不输入新 prompt 时,是否也出现 `[queue processor]` 并自动执行?durable job 是否写入了 `.scheduled_tasks.json`?\n\n---\n\n## 接下来\n\n一个 Agent 能计划、压缩、后台执行、定时调度。但有些任务太大了,比如\"重构整个后端\",认证模块、数据库层、API 路由、测试全部翻新,一个 Agent 的上下文窗口装不下。\n\ns15 Agent Teams → 多 Agent 协作,持久队友 + 异步收件箱。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `CronCreateTool.ts`、`cronScheduler.ts`、`cron.ts`、`cronTasks.ts`、`cronTasksLock.ts`、`useScheduledTasks.ts`(139 行)的完整分析。\n\n### 一、三个 Cron 工具\n\nClaude Code 暴露了三个 cron 工具给模型:`CronCreate`、`CronDelete`、`CronList`。全部由编译时门控 `feature('AGENT_TRIGGERS')` 和运行时 GrowthBook 标志 `tengu_kairos_cron` 控制。还有一个 `CLAUDE_CODE_DISABLE_CRON` 环境变量做本地覆盖。\n\n### 二、存储:`.claude/scheduled_tasks.json`\n\n```json\n{ \"tasks\": [{ \"id\": \"abc12345\", \"cron\": \"0 9 * * *\", \"prompt\": \"...\", \"recurring\": true, \"durable\": true, \"createdAt\": 1714567890000 }] }\n```\n\nDurable 任务写磁盘;session-only 任务存于 `STATE.sessionCronTasks` 内存数组(进程重启丢失)。还有一个 `.scheduled_tasks.lock` 文件防止同项目的多个 session 重复触发。\n\n### 三、调度器:1 秒轮询\n\n`cronScheduler.ts` 每秒检查一次(`CHECK_INTERVAL_MS = 1000`)。谁持有锁谁触发文件任务;所有 session 都触发仅 session 任务。还有一个 `chokidar` 文件观察者监视 `scheduled_tasks.json` 变更。\n\n### 四、Cron 表达式:标准 5 字段\n\n分钟 小时 日 月 星期。支持 `*`、`*/N`、`N`、`N-M`、`N-M/S`、`N,M,...`。不支持 `L`、`W`、`?`。所有时间以本地时区解释。Day-of-month 和 day-of-week 同时约束时用 OR 语义。\n\n### 五、抖动(防惊群效应)\n\n- 重复性任务:触发延迟最多可达期间的 10%(上限 15 分钟),基于任务 ID 的确定性哈希\n- 一次性任务:当触发时间落在 `:00` 或 `:30` 时,最多提前 90 秒触发\n- 抖动配置可通过 GrowthBook 实时调整,60 秒刷新一次\n\n### 六、自动过期\n\n重复性任务 7 天后自动过期(可配置,上限 30 天)。过期前最后一次触发,触发后自动删除。\n\n### 七、作业数上限\n\n`MAX_JOBS = 50`(`CronCreateTool.ts:25`)。超限时返回错误:\"Too many scheduled jobs (max 50). Cancel one first.\"\n\n### 八、触发注入\n\n触发后通过 `enqueuePendingNotification()` 以 `priority: 'later'` 入队命令队列。标记 `workload: WORKLOAD_CRON`,API 在容量紧张时以更低的 QoS 为 cron 发起的请求服务。\n\n### 九、Queue Processor:自动交付\n\n真实 Claude Code 通过 `useQueueProcessor.ts:48-60` 在无 query、无阻塞 UI、队列非空时自动触发处理。`queueProcessor.ts:52-87` 按队列优先级把命令交给 `handlePromptSubmit()`。教学版用 `queue_processor_loop` 保留核心行为:队列有任务且 Agent 空闲时,自动启动一轮 agent_loop。\n\n
\n\n\n" }, { "version": "s14", "locale": "ja", "title": "s14: Cron Scheduler — スケジュールに従って作業を生産", - "content": "# s14: Cron Scheduler — スケジュールに従って作業を生産\n\ns01 → ... → s12 → s13 → `s14` → [s15](/ja/s15) → s16 → ... → s20\n> *\"スケジュールに従って作業を生産、スケジューリングと実行を分離\"* — cron スケジューリング、永続またはセッションレベル。\n>\n> **Harness 層**: スケジューリング — 独立スレッドが時刻を判定、キューがトリガーを配信。\n\n---\n\n## 課題\n\n目覚まし時計はあなたが見ていないと鳴らないわけではない。7:00 にセットすれば、7:00 に鳴る。寝ていても、シャワーを浴びていても、料理をしていても、鳴る。\n\ns13 で Agent は遅い操作をバックグラウンドで実行できるようになった。しかし、すべての操作は手動でトリガーされる。一言言えば、Agent が動く。「毎朝 9 時にテストを実行」「30 分ごとに CI ステータスを確認」、これらの定期的なタスクに人が毎回押す必要はないはずだ。\n\n---\n\n## ソリューション\n\n![Cron Scheduler Overview](/course-assets/s14_cron_scheduler/cron-scheduler-overview.ja.svg)\n\n教学版は S13 の簡易タスクシステム、バックグラウンド実行、プロンプト組み立てを踏襲。スケジューラに集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。追加:独立した cron スケジューラスレッド、1 秒ごとにポーリング、時間が来たらタスクを `cron_queue` に投入し、queue processor が Agent のアイドル時に自動配信。\n\n手動 vs スケジュール:\n\n| | 手動 (s13) | スケジュール (s14) |\n|---|---|---|\n| トリガー | ユーザー入力 | スケジューラスレッド |\n| トリガー時刻 | いつでも | cron 式で指定 |\n| 人の関与 | あり | なし(スケジューラが自動キュー投入、アイドル時に自動配信) |\n| 永続性 | — | durable は再起動後も保持 |\n\n---\n\n## 仕組み\n\n### 4 層モデル\n\ncron スケジューリングは 4 層に分かれる:\n\n1. **Scheduler**:daemon スレッド、1 秒ごとにポーリング、時刻が来たか判定\n2. **Queue**:`cron_queue`、スケジューラが発火済みタスクを書き込み\n3. **Queue Processor**:キューが空でなく Agent がアイドルなら、一回の agent_loop を開始\n4. **Consumer**:agent_loop がキューから消費、messages に注入\n\n教学版は最小の queue processor を実装する。`agent_lock` で Agent がアイドルかを判定し、キューに入った cron 作業を自動配信する。実際の CC の `useQueueProcessor.ts` はさらに UI ブロック、キュープライオリティ、メッセージモードを扱う。\n\n### CronJob: データ構造\n\n各 cron タスクは `CronJob` オブジェクト:\n\n```python\n@dataclass\nclass CronJob:\n id: str\n cron: str # \"0 9 * * *\"(5 フィールド cron 式)\n prompt: str # 発火時に Agent に注入するメッセージ\n recurring: bool # True=定期的、False=一回限り\n durable: bool # True=ディスク書き込み、セッション横断\n```\n\ncron 式、5 フィールド、Unix で 50 年使われている:\n\n```\n分 時 日 月 曜日\n * * * * * 毎分\n 0 9 * * * 毎日 9:00\n*/5 * * * * 5 分ごと\n 0 9 * * 1-5 平日 9:00\n```\n\n`*`、`*/N`、`N`、`N-M`、`N,M,...` をサポート。\n\n### cron_matches: 5 フィールドマッチング\n\n標準 cron セマンティクス:分、時、月はすべてマッチ必須。日(DOM)と曜日(DOW)が両方制約されている場合は、いずれかのマッチで十分(OR):\n\n```python\ndef cron_matches(cron_expr: str, dt: datetime) -> bool:\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return False\n minute, hour, dom, month, dow = fields\n dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0\n\n m = _cron_field_matches(minute, dt.minute)\n h = _cron_field_matches(hour, dt.hour)\n dom_ok = _cron_field_matches(dom, dt.day)\n month_ok = _cron_field_matches(month, dt.month)\n dow_ok = _cron_field_matches(dow, dow_val)\n\n if not (m and h and month_ok):\n return False\n # DOM and DOW: both constrained → either matching is enough (OR)\n dom_unconstrained = dom == \"*\"\n dow_unconstrained = dow == \"*\"\n if dom_unconstrained and dow_unconstrained:\n return True\n if dom_unconstrained:\n return dow_ok\n if dow_unconstrained:\n return dom_ok\n return dom_ok or dow_ok\n```\n\n### 独立スケジューラスレッド:1 秒ポーリング\n\nスケジューラは独立した daemon スレッドで動作、agent_loop が実行中かどうかに依存しない。個々のジョブエラーはスレッド全体を殺さない:\n\n```python\ndef cron_scheduler_loop():\n while True:\n time.sleep(1)\n now = datetime.now()\n minute_marker = now.strftime(\"%Y-%m-%d %H:%M\")\n with cron_lock:\n for job in list(scheduled_jobs.values()):\n try:\n if cron_matches(job.cron, now):\n if _last_fired.get(job.id) != minute_marker:\n cron_queue.append(job)\n _last_fired[job.id] = minute_marker\n if not job.recurring:\n scheduled_jobs.pop(job.id, None)\n if job.durable:\n save_durable_jobs()\n except Exception as e:\n print(f\"[cron error] {job.id}: {e}\")\n```\n\n重要な設計:\n- **agent_loop から独立**:agent_loop が動いていなくても、スケジューラはバックグラウンドで時刻をチェック\n- **日付認識 minute_marker**:`\"YYYY-MM-DD HH:MM\"` を使用、同じ分の重複発火を防ぎつつ翌日のスキップも防止\n- **ジョブ単位の try/except**:一つの悪いジョブがスケジューラスレッド全体をクラッシュさせない\n- **一回限りジョブ**:発火後、scheduled_jobs から自動削除\n\n### Queue Processor + agent_loop: 配信側\n\nqueue processor は時刻をチェックしない。キューに作業があり、Agent がアイドルの時だけ一回の実行を開始する:\n\n```python\ndef queue_processor_loop():\n while True:\n time.sleep(0.2)\n if not has_cron_queue():\n continue\n if not agent_lock.acquire(blocking=False):\n continue\n try:\n if has_cron_queue():\n run_agent_turn_locked()\n finally:\n agent_lock.release()\n```\n\nagent_loop も時刻をチェックしない。`cron_queue` から発火済みタスクを取り出し、messages に注入するだけ:\n\n```python\nfired = consume_cron_queue()\nfor job in fired:\n messages.append({\"role\": \"user\",\n \"content\": f\"[Scheduled] {job.prompt}\"})\n```\n\n生産者(スケジューラスレッド)、配信者(queue processor)、消費者(agent_loop)は `cron_queue`、`cron_lock`、`agent_lock` で分離されている。\n\n### バリデーション:不正 cron がスケジューラを殺すのを防止\n\n`schedule_job` は登録前に cron 式をバリデーションし、不正な場合はエラーを返す:\n\n```python\ndef schedule_job(cron, prompt, recurring=True, durable=True):\n err = validate_cron(cron)\n if err:\n return err\n # ... ジョブ登録\n```\n\nディスクから durable ジョブを読み込む際も不正な式をスキップし、一つの悪いタスクが起動を妨げない。\n\n### Durable vs Session-only\n\n- **Durable**:タスク定義を `.scheduled_tasks.json` に書き込み。Agent 再起動後にファイルから復元。\n- **Session-only**:メモリ内のみ。Agent 終了で消失。\n\n> **重要な前提**:cron スケジューラは Agent プロセス内で実行される必要がある。プロセスが終了するとスケジューラも停止。Durable はタスク定義が再起動後も保持されることを意味するだけで、次回 Agent 起動時にスケジューラが「発火すべき」と判定して初めて発火する。「アプリケーションが閉じていても定期的に実行」が必要な場合は、システム crontab または systemd timer を使用。\n\n### 組み合わせて実行\n\n```\n1. 起動時:\n load_durable_jobs() → .scheduled_tasks.json から永続タスクを復元\n Thread(cron_scheduler_loop, daemon=True).start() → スケジューラスレッドがポーリング開始\n Thread(queue_processor_loop, daemon=True).start() → processor が配信待機\n\n2. タスク登録:\n schedule_cron(cron=\"*/2 * * * *\", prompt=\"run date\", durable=True)\n → CronJob を scheduled_jobs + .scheduled_tasks.json に書き込み\n\n3. 2 分ごと:\n スケジューラチェック → cron_matches が True → cron_queue.append(job)\n → queue processor がアイドル状態を検知 → agent_loop consume_cron_queue\n → \"[Scheduled] run date\" を注入\n → LLM がメッセージを受信、date コマンドを実行\n\n4. プロセス終了:\n スケジューラスレッドも停止(daemon=True)\n .scheduled_tasks.json はディスクに残存\n 次回起動 → load_durable_jobs → タスク復元\n```\n\n---\n\n## s13 からの変更\n\n| コンポーネント | 変更前 (s13) | 変更後 (s14) |\n|--------------|------------|------------|\n| トリガー方式 | ユーザー手動トリガー | スケジューラスレッドが自動キュー投入 |\n| 新規型 | — | CronJob データクラス (id, cron, prompt, recurring, durable) |\n| 新規関数 | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop |\n| 新規ストレージ | — | .scheduled_tasks.json (durable) + メモリ (session-only) |\n| スレッド | バックグラウンド実行スレッド | + スケジューラスレッド (daemon, 1s ポーリング) + queue processor スレッド |\n| キュー | background_results | + cron_queue(スケジューラ書き込み、queue processor 配信、agent_loop 消費) |\n| ツール | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s14_cron_scheduler/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `Schedule a task to print the current date every 2 minutes`\n2. `List all cron jobs`\n3. `Create a one-shot reminder in 1 minute to check the build status`\n4. `Cancel the recurring job and verify with list_crons`\n\n観察ポイント:スケジューラスレッドが独立して動いているか?cron タスクが正しい時刻に発火しているか?新しい prompt を入力しなくても `[queue processor]` が出て自動実行されるか?durable ジョブが `.scheduled_tasks.json` に書き込まれているか?\n\n---\n\n## 次の章\n\n一つの Agent でできることは増えた。計画、圧縮、バックグラウンド、スケジューリング。しかし、一部のタスクは一つの Agent では大きすぎる。\n\n「バックエンド全体をリファクタリング」、認証モジュール、データベース層、API ルート、テストを全面的に刷新。一つの Agent の注意力には限界がある。これにはチームが必要だ。\n\ns15 Agent Teams → 一人の Agent では足りない、チームを組もう。永続的なチームメイト + 非同期受信箱。\n\n
\nCC ソースコード深掘り\n\n> 以下は CC ソースコード `CronCreateTool.ts`、`cronScheduler.ts`、`cron.ts`、`cronTasks.ts`、`cronTasksLock.ts`、`useScheduledTasks.ts`(139 行)の完全分析に基づく。\n\n### 一、3 つの Cron ツール\n\nCC はモデルに 3 つの cron ツールを公開:`CronCreate`、`CronDelete`、`CronList`。すべてコンパイル時ゲート `feature('AGENT_TRIGGERS')` とランタイム GrowthBook フラグ `tengu_kairos_cron` で制御。`CLAUDE_CODE_DISABLE_CRON` 環境変数でローカル上書きも可能。\n\n### 二、ストレージ:`.claude/scheduled_tasks.json`\n\n```json\n{ \"tasks\": [{ \"id\": \"abc12345\", \"cron\": \"0 9 * * *\", \"prompt\": \"...\", \"recurring\": true, \"durable\": true, \"createdAt\": 1714567890000 }] }\n```\n\ndurable タスクはディスクに書き込み。session-only タスクは `STATE.sessionCronTasks` メモリ配列に格納(プロセス再起動で消失)。`.scheduled_tasks.lock` ファイルで同じプロジェクトの複数セッション間の重複発火を防止。\n\n### 三、スケジューラ:1 秒ポーリング\n\n`cronScheduler.ts` は毎秒チェック(`CHECK_INTERVAL_MS = 1000`)。ロックを保持しているセッションがファイルタスクをトリガー。すべてのセッションが session-only タスクをトリガー。`chokidar` ファイルウォッチャーが `scheduled_tasks.json` の変更を監視。\n\n### 四、cron 式:標準 5 フィールド\n\n分 時 日 月 曜日。`*`、`*/N`、`N`、`N-M`、`N-M/S`、`N,M,...` をサポート。`L`、`W`、`?` は非サポート。すべての時間はローカルタイムゾーンで解釈。day-of-month と day-of-week が両方制約されている場合は OR セマンティクス。\n\n### 五、ジッター(サンダリングハード防止)\n\n- 定期タスク:トリガー遅延は期間の最大 10%(上限 15 分)、タスク ID ベースの決定的ハッシュ\n- 一回限りタスク:発火時刻が `:00` または `:30` の場合、最大 90 秒早く発火\n- ジッター設定は GrowthBook でリアルタイム調整可能、60 秒ごとにリフレッシュ\n\n### 六、自動期限切れ\n\n定期タスクは 7 日後に自動期限切れ(設定可能、上限 30 日)。期限切れ前に最後の一回を発火、その後自動削除。\n\n### 七、ジョブ数上限\n\n`MAX_JOBS = 50`(`CronCreateTool.ts:25`)。超過時はエラーを返す:\"Too many scheduled jobs (max 50). Cancel one first.\"\n\n### 八、トリガー注入\n\n発火後、`enqueuePendingNotification()` で `priority: 'later'` としてコマンドキューにエンキュー。`workload: WORKLOAD_CRON` タグ付き、API は容量が逼迫している時に cron 発信リクエストを低い QoS で処理。\n\n### 九、Queue Processor:自動配信\n\n実際の CC は `useQueueProcessor.ts:48-60` により、アクティブな query がなく、UI がブロックされておらず、キューが空でない場合に自動的に処理をトリガーする。`queueProcessor.ts:52-87` がキュープライオリティに従ってコマンドを `handlePromptSubmit()` にディスパッチ。教学版は `queue_processor_loop` で核心動作を保つ:キューに作業があり Agent がアイドルなら、自動的に一回の agent_loop を開始する。\n\n
\n\n\n" + "content": "# s14: Cron Scheduler — スケジュールに従って作業を生産\n\ns01 → ... → s12 → s13 → `s14` → [s15](/ja/s15) → s16 → ... → s20\n> *\"スケジュールに従って作業を生産、スケジューリングと実行を分離\"* — cron スケジューリング、永続またはセッションレベル。\n>\n> **Harness 層**: スケジューリング — 独立スレッドが時刻を判定、キューがトリガーを配信。\n\n---\n\n## 課題\n\n目覚まし時計はあなたが見ていないと鳴らないわけではない。7:00 にセットすれば、7:00 に鳴る。寝ていても、シャワーを浴びていても、料理をしていても、鳴る。\n\ns13 で Agent は遅い操作をバックグラウンドで実行できるようになった。しかし、すべての操作は手動でトリガーされる。一言言えば、Agent が動く。「毎朝 9 時にテストを実行」「30 分ごとに CI ステータスを確認」、これらの定期的なタスクに人が毎回押す必要はないはずだ。\n\n---\n\n## ソリューション\n\n![Cron Scheduler Overview](/course-assets/s14_cron_scheduler/cron-scheduler-overview.svg)\n\n教学版は S13 の簡易タスクシステム、バックグラウンド実行、プロンプト組み立てを踏襲。スケジューラに集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。追加:独立した cron スケジューラスレッド、1 秒ごとにポーリング、時間が来たらタスクを `cron_queue` に投入し、queue processor が Agent のアイドル時に自動配信。\n\n手動 vs スケジュール:\n\n| | 手動 (s13) | スケジュール (s14) |\n|---|---|---|\n| トリガー | ユーザー入力 | スケジューラスレッド |\n| トリガー時刻 | いつでも | cron 式で指定 |\n| 人の関与 | あり | なし(スケジューラが自動キュー投入、アイドル時に自動配信) |\n| 永続性 | — | durable は再起動後も保持 |\n\n---\n\n## 仕組み\n\n### 4 層モデル\n\ncron スケジューリングは 4 層に分かれる:\n\n1. **Scheduler**:daemon スレッド、1 秒ごとにポーリング、時刻が来たか判定\n2. **Queue**:`cron_queue`、スケジューラが発火済みタスクを書き込み\n3. **Queue Processor**:キューが空でなく Agent がアイドルなら、一回の agent_loop を開始\n4. **Consumer**:agent_loop がキューから消費、messages に注入\n\n教学版は最小の queue processor を実装する。`agent_lock` で Agent がアイドルかを判定し、キューに入った cron 作業を自動配信する。実際の Claude Code の `useQueueProcessor.ts` はさらに UI ブロック、キュープライオリティ、メッセージモードを扱う。\n\n### CronJob: データ構造\n\n各 cron タスクは `CronJob` オブジェクト:\n\n```python\n@dataclass\nclass CronJob:\n id: str\n cron: str # \"0 9 * * *\"(5 フィールド cron 式)\n prompt: str # 発火時に Agent に注入するメッセージ\n recurring: bool # True=定期的、False=一回限り\n durable: bool # True=ディスク書き込み、セッション横断\n```\n\ncron 式、5 フィールド、Unix で 50 年使われている:\n\n```\n分 時 日 月 曜日\n * * * * * 毎分\n 0 9 * * * 毎日 9:00\n*/5 * * * * 5 分ごと\n 0 9 * * 1-5 平日 9:00\n```\n\n`*`、`*/N`、`N`、`N-M`、`N,M,...` をサポート。\n\n### cron_matches: 5 フィールドマッチング\n\n標準 cron セマンティクス:分、時、月はすべてマッチ必須。日(DOM)と曜日(DOW)が両方制約されている場合は、いずれかのマッチで十分(OR):\n\n```python\ndef cron_matches(cron_expr: str, dt: datetime) -> bool:\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return False\n minute, hour, dom, month, dow = fields\n dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0\n\n m = _cron_field_matches(minute, dt.minute)\n h = _cron_field_matches(hour, dt.hour)\n dom_ok = _cron_field_matches(dom, dt.day)\n month_ok = _cron_field_matches(month, dt.month)\n dow_ok = _cron_field_matches(dow, dow_val)\n\n if not (m and h and month_ok):\n return False\n # DOM and DOW: both constrained → either matching is enough (OR)\n dom_unconstrained = dom == \"*\"\n dow_unconstrained = dow == \"*\"\n if dom_unconstrained and dow_unconstrained:\n return True\n if dom_unconstrained:\n return dow_ok\n if dow_unconstrained:\n return dom_ok\n return dom_ok or dow_ok\n```\n\n### 独立スケジューラスレッド:1 秒ポーリング\n\nスケジューラは独立した daemon スレッドで動作、agent_loop が実行中かどうかに依存しない。個々のジョブエラーはスレッド全体を殺さない:\n\n```python\ndef cron_scheduler_loop():\n while True:\n time.sleep(1)\n now = datetime.now()\n minute_marker = now.strftime(\"%Y-%m-%d %H:%M\")\n with cron_lock:\n for job in list(scheduled_jobs.values()):\n try:\n if cron_matches(job.cron, now):\n if _last_fired.get(job.id) != minute_marker:\n cron_queue.append(job)\n _last_fired[job.id] = minute_marker\n if not job.recurring:\n scheduled_jobs.pop(job.id, None)\n if job.durable:\n save_durable_jobs()\n except Exception as e:\n print(f\"[cron error] {job.id}: {e}\")\n```\n\n重要な設計:\n- **agent_loop から独立**:agent_loop が動いていなくても、スケジューラはバックグラウンドで時刻をチェック\n- **日付認識 minute_marker**:`\"YYYY-MM-DD HH:MM\"` を使用、同じ分の重複発火を防ぎつつ翌日のスキップも防止\n- **ジョブ単位の try/except**:一つの悪いジョブがスケジューラスレッド全体をクラッシュさせない\n- **一回限りジョブ**:発火後、scheduled_jobs から自動削除\n\n### Queue Processor + agent_loop: 配信側\n\nqueue processor は時刻をチェックしない。キューに作業があり、Agent がアイドルの時だけ一回の実行を開始する:\n\n```python\ndef queue_processor_loop():\n while True:\n time.sleep(0.2)\n if not has_cron_queue():\n continue\n if not agent_lock.acquire(blocking=False):\n continue\n try:\n if has_cron_queue():\n run_agent_turn_locked()\n finally:\n agent_lock.release()\n```\n\nagent_loop も時刻をチェックしない。`cron_queue` から発火済みタスクを取り出し、messages に注入するだけ:\n\n```python\nfired = consume_cron_queue()\nfor job in fired:\n messages.append({\"role\": \"user\",\n \"content\": f\"[Scheduled] {job.prompt}\"})\n```\n\n生産者(スケジューラスレッド)、配信者(queue processor)、消費者(agent_loop)は `cron_queue`、`cron_lock`、`agent_lock` で分離されている。\n\n### バリデーション:不正 cron がスケジューラを殺すのを防止\n\n`schedule_job` は登録前に cron 式をバリデーションし、不正な場合はエラーを返す:\n\n```python\ndef schedule_job(cron, prompt, recurring=True, durable=True):\n err = validate_cron(cron)\n if err:\n return err\n # ... ジョブ登録\n```\n\nディスクから durable ジョブを読み込む際も不正な式をスキップし、一つの悪いタスクが起動を妨げない。\n\n### Durable vs Session-only\n\n- **Durable**:タスク定義を `.scheduled_tasks.json` に書き込み。Agent 再起動後にファイルから復元。\n- **Session-only**:メモリ内のみ。Agent 終了で消失。\n\n> **重要な前提**:cron スケジューラは Agent プロセス内で実行される必要がある。プロセスが終了するとスケジューラも停止。Durable はタスク定義が再起動後も保持されることを意味するだけで、次回 Agent 起動時にスケジューラが「発火すべき」と判定して初めて発火する。「アプリケーションが閉じていても定期的に実行」が必要な場合は、システム crontab または systemd timer を使用。\n\n### 組み合わせて実行\n\n```\n1. 起動時:\n load_durable_jobs() → .scheduled_tasks.json から永続タスクを復元\n Thread(cron_scheduler_loop, daemon=True).start() → スケジューラスレッドがポーリング開始\n Thread(queue_processor_loop, daemon=True).start() → processor が配信待機\n\n2. タスク登録:\n schedule_cron(cron=\"*/2 * * * *\", prompt=\"run date\", durable=True)\n → CronJob を scheduled_jobs + .scheduled_tasks.json に書き込み\n\n3. 2 分ごと:\n スケジューラチェック → cron_matches が True → cron_queue.append(job)\n → queue processor がアイドル状態を検知 → agent_loop consume_cron_queue\n → \"[Scheduled] run date\" を注入\n → LLM がメッセージを受信、date コマンドを実行\n\n4. プロセス終了:\n スケジューラスレッドも停止(daemon=True)\n .scheduled_tasks.json はディスクに残存\n 次回起動 → load_durable_jobs → タスク復元\n```\n\n---\n\n## s13 からの変更\n\n| コンポーネント | 変更前 (s13) | 変更後 (s14) |\n|--------------|------------|------------|\n| トリガー方式 | ユーザー手動トリガー | スケジューラスレッドが自動キュー投入 |\n| 新規型 | — | CronJob データクラス (id, cron, prompt, recurring, durable) |\n| 新規関数 | — | cron_matches, validate_cron, schedule_job, cancel_job, cron_scheduler_loop, queue_processor_loop |\n| 新規ストレージ | — | .scheduled_tasks.json (durable) + メモリ (session-only) |\n| スレッド | バックグラウンド実行スレッド | + スケジューラスレッド (daemon, 1s ポーリング) + queue processor スレッド |\n| キュー | background_results | + cron_queue(スケジューラ書き込み、queue processor 配信、agent_loop 消費) |\n| ツール | 8 (s12/s13) | + schedule_cron, list_crons, cancel_cron (11) |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s14_cron_scheduler/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `Schedule a task to print the current date every 2 minutes`\n2. `List all cron jobs`\n3. `Create a one-shot reminder in 1 minute to check the build status`\n4. `Cancel the recurring job and verify with list_crons`\n\n観察ポイント:スケジューラスレッドが独立して動いているか?cron タスクが正しい時刻に発火しているか?新しい prompt を入力しなくても `[queue processor]` が出て自動実行されるか?durable ジョブが `.scheduled_tasks.json` に書き込まれているか?\n\n---\n\n## 次の章\n\n一つの Agent で計画、圧縮、バックグラウンド実行、スケジューリングができるようになった。しかし「バックエンド全体をリファクタリング」のように、認証モジュール、データベース層、API ルート、テストを全面的に刷新するタスクは、一つの Agent のコンテキストウィンドウに収まらない。\n\ns15 Agent Teams → 複数 Agent の協調、永続的なチームメイト + 非同期受信箱。\n\n
\nClaude Code ソースコード深掘り\n\n> 以下は Claude Code ソースコード `CronCreateTool.ts`、`cronScheduler.ts`、`cron.ts`、`cronTasks.ts`、`cronTasksLock.ts`、`useScheduledTasks.ts`(139 行)の完全分析に基づく。\n\n### 一、3 つの Cron ツール\n\nClaude Code はモデルに 3 つの cron ツールを公開:`CronCreate`、`CronDelete`、`CronList`。すべてコンパイル時ゲート `feature('AGENT_TRIGGERS')` とランタイム GrowthBook フラグ `tengu_kairos_cron` で制御。`CLAUDE_CODE_DISABLE_CRON` 環境変数でローカル上書きも可能。\n\n### 二、ストレージ:`.claude/scheduled_tasks.json`\n\n```json\n{ \"tasks\": [{ \"id\": \"abc12345\", \"cron\": \"0 9 * * *\", \"prompt\": \"...\", \"recurring\": true, \"durable\": true, \"createdAt\": 1714567890000 }] }\n```\n\ndurable タスクはディスクに書き込み。session-only タスクは `STATE.sessionCronTasks` メモリ配列に格納(プロセス再起動で消失)。`.scheduled_tasks.lock` ファイルで同じプロジェクトの複数セッション間の重複発火を防止。\n\n### 三、スケジューラ:1 秒ポーリング\n\n`cronScheduler.ts` は毎秒チェック(`CHECK_INTERVAL_MS = 1000`)。ロックを保持しているセッションがファイルタスクをトリガー。すべてのセッションが session-only タスクをトリガー。`chokidar` ファイルウォッチャーが `scheduled_tasks.json` の変更を監視。\n\n### 四、cron 式:標準 5 フィールド\n\n分 時 日 月 曜日。`*`、`*/N`、`N`、`N-M`、`N-M/S`、`N,M,...` をサポート。`L`、`W`、`?` は非サポート。すべての時間はローカルタイムゾーンで解釈。day-of-month と day-of-week が両方制約されている場合は OR セマンティクス。\n\n### 五、ジッター(サンダリングハード防止)\n\n- 定期タスク:トリガー遅延は期間の最大 10%(上限 15 分)、タスク ID ベースの決定的ハッシュ\n- 一回限りタスク:発火時刻が `:00` または `:30` の場合、最大 90 秒早く発火\n- ジッター設定は GrowthBook でリアルタイム調整可能、60 秒ごとにリフレッシュ\n\n### 六、自動期限切れ\n\n定期タスクは 7 日後に自動期限切れ(設定可能、上限 30 日)。期限切れ前に最後の一回を発火、その後自動削除。\n\n### 七、ジョブ数上限\n\n`MAX_JOBS = 50`(`CronCreateTool.ts:25`)。超過時はエラーを返す:\"Too many scheduled jobs (max 50). Cancel one first.\"\n\n### 八、トリガー注入\n\n発火後、`enqueuePendingNotification()` で `priority: 'later'` としてコマンドキューにエンキュー。`workload: WORKLOAD_CRON` タグ付き、API は容量が逼迫している時に cron 発信リクエストを低い QoS で処理。\n\n### 九、Queue Processor:自動配信\n\n実際の Claude Code は `useQueueProcessor.ts:48-60` により、アクティブな query がなく、UI がブロックされておらず、キューが空でない場合に自動的に処理をトリガーする。`queueProcessor.ts:52-87` がキュープライオリティに従ってコマンドを `handlePromptSubmit()` にディスパッチ。教学版は `queue_processor_loop` で核心動作を保つ:キューに作業があり Agent がアイドルなら、自動的に一回の agent_loop を開始する。\n\n
\n\n\n" }, { "version": "s15", "locale": "en", "title": "s15: Agent Teams — One Agent Isn't Enough, Form a Team", - "content": "# s15: Agent Teams — One Agent Isn't Enough, Form a Team\n\ns01 → ... → s13 → s14 → `s15` → [s16](/en/s16) → s17 → s18 → s19 → s20\n> *\"One agent isn't enough, form a team\"* — File-based inboxes + teammate threads.\n>\n> **Harness Layer**: Teams — Multi-agent collaboration, message bus.\n\n---\n\n## The Problem\n\n\"Refactor the entire backend\" touches auth, database layer, API routes, and tests. One agent working on API routes no longer has auth module details in context. The context window is limited, a single agent can't cover every module.\n\ns06's sub-agents are temps, called in for one job, then gone. Some tasks need teammates that can communicate and collaborate.\n\n---\n\n## The Solution\n\n![Agent Teams Overview](/course-assets/s15_agent_teams/agent-teams-overview.en.svg)\n\nTeaching code carries forward S14's capabilities (prompt assembly, task system, background execution, cron scheduling). To stay focused on the team mechanism, it omits full error recovery, memory, and skill systems. Added: **MessageBus** (file-based inboxes), **spawn_teammate_thread** (launch teammate threads), **inbox injection** (Lead receives teammate messages and injects into history).\n\nSub-agent vs Teammate:\n\n| | s06 Sub-agent | s15 Teammate |\n|---|---|---|\n| Lifetime | One-shot, destroyed after use | Multi-turn (teaching: 10 rounds; real CC: idle loop) |\n| Communication | Only returns conclusion | Async inbox, communicate anytime |\n| Context | Fully isolated | Shared via messages |\n| Count | One lead + occasional sub-agent | One Lead + multiple teammates |\n\n---\n\n## How It Works\n\n![Team Topology](/course-assets/s15_agent_teams/team-topology.en.svg)\n\n### MessageBus: File-Based Inboxes\n\nEach agent (including Lead and teammates) has a `.jsonl` inbox. Send = append a JSON line to the target's file. Read = read file + delete (consumption):\n\n```python\nclass MessageBus:\n def send(self, from_agent: str, to_agent: str,\n content: str, msg_type: str = \"message\"):\n msg = {\"from\": from_agent, \"to\": to_agent,\n \"content\": content, \"type\": msg_type,\n \"ts\": time.time()}\n inbox = MAILBOX_DIR / f\"{to_agent}.jsonl\"\n with open(inbox, \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n\n def read_inbox(self, agent: str) -> list[dict]:\n inbox = MAILBOX_DIR / f\"{agent}.jsonl\"\n if not inbox.exists():\n return []\n msgs = [json.loads(line) for line in inbox.read_text().splitlines()]\n inbox.unlink() # consume: read + delete\n return msgs\n```\n\nWhy files instead of in-memory queues? Teaching code uses files because they're intuitive and observable across threads. Real CC also uses file inboxes (`~/.claude/teams/{team}/inboxes/`) but adds `proper-lockfile` for concurrent write safety. The teaching version's `read_inbox` has a read + unlink race, concurrent reads could lose messages, acceptable for teaching purposes.\n\n### spawn_teammate_thread: Launching a Teammate\n\nLead calls the `spawn_teammate` tool to start a teammate. The teammate runs in its own daemon thread with its own system prompt, messages, and simplified tool set:\n\n```python\ndef spawn_teammate_thread(name: str, role: str, prompt: str) -> str:\n system = f\"You are '{name}', a {role}. Use tools to complete tasks.\"\n\n def run():\n messages = [{\"role\": \"user\", \"content\": prompt}]\n sub_tools = [bash, read_file, write_file, send_message]\n for _ in range(10): # max 10 rounds\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"{json.dumps(inbox)}\"})\n response = client.messages.create(\n model=MODEL, system=system, messages=messages[-20:],\n tools=sub_tools, max_tokens=8000)\n # ... execute tools, process results\n # Send final summary to Lead\n BUS.send(name, \"lead\", summary, \"result\")\n\n threading.Thread(target=run, daemon=True).start()\n```\n\nKey design:\n- **Simplified tool set**: bash, read, write, send_message. Teaching code omits tasks and cron to focus on communication. Real CC teammates also have TaskCreate, TaskUpdate, etc., the task system is shared across the team\n- **Teaching: 10 rounds max**: prevents infinite loops. Real CC uses idle loop: after each round, send `idle_notification`, wait for inbox messages, resume on arrival, exit only on `shutdown_request`\n- **Auto-report on completion**: `BUS.send(name, \"lead\", summary)` sends the final result to Lead's inbox\n\n### Lead's Inbox Injection\n\nLead checks inbox after each main loop iteration. Teammate messages are injected into history so the LLM can see and react to them:\n\n```python\n# After main loop iteration\ninbox = BUS.read_inbox(\"lead\")\nif inbox:\n inbox_text = \"\\n\".join(\n f\"From {m['from']}: {m['content'][:200]}\" for m in inbox)\n history.append({\"role\": \"user\",\n \"content\": f\"[Inbox]\\n{inbox_text}\"})\n```\n\nTeaching code injects in the user input loop. Real CC is more refined, Lead's `useInboxPoller` checks every 1 second, submitting messages as new turns without waiting for user input.\n\n### Permission Bubbling\n\nTeaching code omits permission bubbling. Real CC's flow (`permissionSync.ts`, `useSwarmPermissionPoller.ts`):\n\n1. Teammate encounters an operation needing approval → sends `permission_request` to Lead's inbox\n2. Lead's `useInboxPoller` detects the request → routes to approval queue\n3. User approves → Lead sends `permission_response` back to teammate\n4. Teammate's `useSwarmPermissionPoller` (polls every 500ms) receives reply → continue or reject\n\n### Putting It Together\n\n```\n1. Lead: \"Build the backend: one agent isn't enough, form a team\"\n2. Lead → spawn_teammate(\"alice\", \"backend dev\", \"Create database schema\")\n3. Lead → spawn_teammate(\"bob\", \"frontend dev\", \"Write API client\")\n4. Alice thread starts → her own LLM call → bash \"python manage.py migrate\"\n5. Bob thread starts → his own LLM call → write_file(\"client.ts\", ...)\n6. Alice done → BUS.send(\"alice\", \"lead\", \"Schema done: users, orders tables\")\n7. Bob done → BUS.send(\"bob\", \"lead\", \"Client written with types\")\n8. Lead next iteration → inbox injected into history → LLM sees both results\n```\n\nTwo teammates work in parallel.\n\n---\n\n## Changes from s14\n\n| Component | Before (s14) | After (s15) |\n|-----------|-------------|-------------|\n| Agent count | 1 | 1 Lead + N teammate threads |\n| Communication | None | MessageBus + .mailboxes/*.jsonl |\n| New classes | — | MessageBus, active_teammates dict |\n| New functions | — | spawn_teammate_thread, run_send_message, run_check_inbox |\n| Lead tools | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) |\n| Teammate tools | — | bash, read_file, write_file, send_message (4) |\n| Permissions | Local decisions | Teaching code omits (real CC has bubbling) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s15_agent_teams/code.py\n```\n\nTry these prompts:\n\n1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.`\n2. `Check your inbox for alice's result.`\n3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.`\n\nWhat to observe: How does Lead spawn teammates? What do the `.mailboxes/` JSONL files look like? After teammates finish, is Lead's inbox injected into history?\n\n---\n\n## What's Next\n\nTeammates can work and communicate. But if Lead wants Alice to shut down, killing the thread outright could leave half-written files. A graceful shutdown protocol is needed: Lead sends shutdown_request, teammate wraps up and exits.\n\ns16 Team Protocols → Shutdown handshake and message conventions.\n\n
\nDeep Dive into CC Source\n\n> The following is a complete analysis based on CC source code `spawnMultiAgent.ts`, `useInboxPoller.ts` (969 lines), `useSwarmPermissionPoller.ts` (330 lines), `teammateMailbox.ts`, `teamHelpers.ts`.\n\n### 1. No Central Message Bus, It's the Filesystem\n\nTeaching code uses a `MessageBus` class to send and receive messages. Real CC is more direct, each agent writes directly to other agents' inbox files.\n\nInbox path: `~/.claude/teams/{teamName}/inboxes/{agentName}.json`\n\nWrites use `proper-lockfile` for concurrent write safety (up to 10 retries). Each file is a JSON array; appending reads → appends → writes back.\n\n### 2. 15 Message Types\n\nCC team communication has 15 structured message types (`teammateMailbox.ts`):\n\n| Type | Direction | Purpose |\n|------|-----------|---------|\n| `plain text` | Both ways | Normal inter-teammate communication |\n| `idle_notification` | Teammate→Lead | Teammate finished a turn, now idle |\n| `permission_request` | Teammate→Lead | Teammate needs operation approval |\n| `permission_response` | Lead→Teammate | Lead's approval result |\n| `plan_approval_request` | Teammate→Lead | Teammate submits plan for review |\n| `plan_approval_response` | Lead→Teammate | Lead's plan review |\n| `shutdown_request` | Lead→Teammate | Request graceful shutdown |\n| `shutdown_approved` | Teammate→Lead | Confirm shutdown |\n| `shutdown_rejected` | Teammate→Lead | Reject shutdown (with reason) |\n| `task_assignment` | Lead→Teammate | Assign a task |\n| `team_permission_update` | Lead→Teammate | Broadcast permission changes |\n| `mode_set_request` | Lead→Teammate | Change teammate's permission mode |\n| `sandbox_permission_*` | Both ways | Network permission request/reply |\n| `teammate_terminated` | System | Teammate removed notification |\n\nText messages are wrapped in `` XML tags for delivery to the model.\n\n### 3. Permission Bubbling: Bidirectional Polling\n\nTeaching code omits permission bubbling. Real CC's flow (`permissionSync.ts`):\n\n1. **Teammate** encounters operation needing approval → sends `permission_request` to Lead's inbox\n2. **Lead's** `useInboxPoller` (polls every 1s) detects request → routes to `ToolUseConfirmQueue`\n3. Lead's UI shows approval dialog with teammate name and color\n4. User approves → Lead sends `permission_response` back to teammate's inbox\n5. **Teammate's** `useSwarmPermissionPoller` (polls every 500ms) receives reply → continue or reject\n\n### 4. Teammate Lifecycle\n\nCC teammates are created by `spawnTeammate()` (`spawnMultiAgent.ts`):\n\n1. **Spawn**: Create tmux pane (or in-process), assign color, write team config\n2. **Work**: `useInboxPoller` checks inbox every 1s → submit as new turn when messages arrive\n3. **Idle**: Stop hook fires → send `idle_notification` to Lead\n4. **Shutdown**: Lead sends `shutdown_request` → teammate replies `shutdown_approved` → Lead cleans up\n\n### 5. Team Config\n\nTeam registry at `~/.claude/teams/{teamName}/config.json` (`teamHelpers.ts`):\n\n```json\n{\n \"name\": \"my-team\",\n \"leadAgentId\": \"lead@my-team\",\n \"members\": [{\n \"agentId\": \"researcher@my-team\",\n \"name\": \"researcher\",\n \"agentType\": \"general-purpose\",\n \"color\": \"blue\",\n \"isActive\": true\n }]\n}\n```\n\nTeammates cannot be nested (`AgentTool.tsx:273` explicitly forbids \"teammates spawning other teammates\").\n\n
\n\n\n" + "content": "# s15: Agent Teams — One Agent Isn't Enough, Form a Team\n\ns01 → ... → s13 → s14 → `s15` → [s16](/en/s16) → s17 → s18 → s19 → s20\n> *\"One agent isn't enough, form a team\"* — File-based inboxes + teammate threads.\n>\n> **Harness Layer**: Teams — Multi-agent collaboration, message bus.\n\n---\n\n## The Problem\n\n\"Refactor the entire backend\" touches auth, database layer, API routes, and tests. One agent working on API routes no longer has auth module details in context. The context window is limited, a single agent can't cover every module.\n\ns06's sub-agents are temps, called in for one job, then gone. Some tasks need teammates that can communicate and collaborate.\n\n---\n\n## The Solution\n\n![Agent Teams Overview](/course-assets/s15_agent_teams/agent-teams-overview.svg)\n\nTeaching code carries forward S14's capabilities (prompt assembly, task system, background execution, cron scheduling). To stay focused on the team mechanism, it omits full error recovery, memory, and skill systems. Added: **MessageBus** (file-based inboxes), **spawn_teammate_thread** (launch teammate threads), **inbox injection** (Lead receives teammate messages and injects into history).\n\nSub-agent vs Teammate:\n\n| | s06 Sub-agent | s15 Teammate |\n|---|---|---|\n| Lifetime | One-shot, destroyed after use | Multi-turn (teaching: 10 rounds; real Claude Code: idle loop) |\n| Communication | Only returns conclusion | Async inbox, communicate anytime |\n| Context | Fully isolated | Shared via messages |\n| Count | One lead + occasional sub-agent | One Lead + multiple teammates |\n\n---\n\n## How It Works\n\n![Team Topology](/course-assets/s15_agent_teams/team-topology.svg)\n\n### MessageBus: File-Based Inboxes\n\nEach agent (including Lead and teammates) has a `.jsonl` inbox. Send = append a JSON line to the target's file. Read = read file + delete (consumption):\n\n```python\nclass MessageBus:\n def send(self, from_agent: str, to_agent: str,\n content: str, msg_type: str = \"message\"):\n msg = {\"from\": from_agent, \"to\": to_agent,\n \"content\": content, \"type\": msg_type,\n \"ts\": time.time()}\n inbox = MAILBOX_DIR / f\"{to_agent}.jsonl\"\n with open(inbox, \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n\n def read_inbox(self, agent: str) -> list[dict]:\n inbox = MAILBOX_DIR / f\"{agent}.jsonl\"\n if not inbox.exists():\n return []\n msgs = [json.loads(line) for line in inbox.read_text().splitlines()]\n inbox.unlink() # consume: read + delete\n return msgs\n```\n\nWhy files instead of in-memory queues? Teaching code uses files because they're intuitive and observable across threads. Real Claude Code also uses file inboxes (`~/.claude/teams/{team}/inboxes/`) but adds `proper-lockfile` for concurrent write safety. The teaching version's `read_inbox` has a read + unlink race, concurrent reads could lose messages, acceptable for teaching purposes.\n\n### spawn_teammate_thread: Launching a Teammate\n\nLead calls the `spawn_teammate` tool to start a teammate. The teammate runs in its own daemon thread with its own system prompt, messages, and simplified tool set:\n\n```python\ndef spawn_teammate_thread(name: str, role: str, prompt: str) -> str:\n system = f\"You are '{name}', a {role}. Use tools to complete tasks.\"\n\n def run():\n messages = [{\"role\": \"user\", \"content\": prompt}]\n sub_tools = [bash, read_file, write_file, send_message]\n for _ in range(10): # max 10 rounds\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"{json.dumps(inbox)}\"})\n response = client.messages.create(\n model=MODEL, system=system, messages=messages[-20:],\n tools=sub_tools, max_tokens=8000)\n # ... execute tools, process results\n # Send final summary to Lead\n BUS.send(name, \"lead\", summary, \"result\")\n\n threading.Thread(target=run, daemon=True).start()\n```\n\nKey design:\n- **Simplified tool set**: bash, read, write, send_message. Teaching code omits tasks and cron to focus on communication. Real Claude Code teammates also have TaskCreate, TaskUpdate, etc., the task system is shared across the team\n- **Teaching: 10 rounds max**: prevents infinite loops. Real Claude Code uses idle loop: after each round, send `idle_notification`, wait for inbox messages, resume on arrival, exit only on `shutdown_request`\n- **Auto-report on completion**: `BUS.send(name, \"lead\", summary)` sends the final result to Lead's inbox\n\n### Lead's Inbox Injection\n\nLead checks inbox after each main loop iteration. Teammate messages are injected into history so the LLM can see and react to them:\n\n```python\n# After main loop iteration\ninbox = BUS.read_inbox(\"lead\")\nif inbox:\n inbox_text = \"\\n\".join(\n f\"From {m['from']}: {m['content'][:200]}\" for m in inbox)\n history.append({\"role\": \"user\",\n \"content\": f\"[Inbox]\\n{inbox_text}\"})\n```\n\nTeaching code injects in the user input loop. Real Claude Code is more refined, Lead's `useInboxPoller` checks every 1 second, submitting messages as new turns without waiting for user input.\n\n### Permission Bubbling\n\nTeaching code omits permission bubbling. Real Claude Code's flow (`permissionSync.ts`, `useSwarmPermissionPoller.ts`):\n\n1. Teammate encounters an operation needing approval → sends `permission_request` to Lead's inbox\n2. Lead's `useInboxPoller` detects the request → routes to approval queue\n3. User approves → Lead sends `permission_response` back to teammate\n4. Teammate's `useSwarmPermissionPoller` (polls every 500ms) receives reply → continue or reject\n\n### Putting It Together\n\n```\n1. Lead: \"Build the backend: split into three modules, spawn teammates\"\n2. Lead → spawn_teammate(\"alice\", \"backend dev\", \"Create database schema\")\n3. Lead → spawn_teammate(\"bob\", \"frontend dev\", \"Write API client\")\n4. Alice thread starts → her own LLM call → bash \"python manage.py migrate\"\n5. Bob thread starts → his own LLM call → write_file(\"client.ts\", ...)\n6. Alice done → BUS.send(\"alice\", \"lead\", \"Schema done: users, orders tables\")\n7. Bob done → BUS.send(\"bob\", \"lead\", \"Client written with types\")\n8. Lead next iteration → inbox injected into history → LLM sees both results\n```\n\nTwo teammates work in parallel.\n\n---\n\n## Changes from s14\n\n| Component | Before (s14) | After (s15) |\n|-----------|-------------|-------------|\n| Agent count | 1 | 1 Lead + N teammate threads |\n| Communication | None | MessageBus + .mailboxes/*.jsonl |\n| New classes | — | MessageBus, active_teammates dict |\n| New functions | — | spawn_teammate_thread, run_send_message, run_check_inbox |\n| Lead tools | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) |\n| Teammate tools | — | bash, read_file, write_file, send_message (4) |\n| Permissions | Local decisions | Teaching code omits (real Claude Code has bubbling) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s15_agent_teams/code.py\n```\n\nTry these prompts:\n\n1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.`\n2. `Check your inbox for alice's result.`\n3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.`\n\nWhat to observe: How does Lead spawn teammates? What do the `.mailboxes/` JSONL files look like? After teammates finish, is Lead's inbox injected into history?\n\n---\n\n## What's Next\n\nTeammates can work and communicate. But if Lead wants Alice to shut down, killing the thread outright could leave half-written files. A graceful shutdown protocol is needed: Lead sends shutdown_request, teammate wraps up and exits.\n\ns16 Team Protocols → Shutdown handshake and message conventions.\n\n
\nDeep Dive into Claude Code Source\n\n> The following is a complete analysis based on Claude Code source code `spawnMultiAgent.ts`, `useInboxPoller.ts` (969 lines), `useSwarmPermissionPoller.ts` (330 lines), `teammateMailbox.ts`, `teamHelpers.ts`.\n\n### 1. No Central Message Bus, It's the Filesystem\n\nTeaching code uses a `MessageBus` class to send and receive messages. Real Claude Code is more direct, each agent writes directly to other agents' inbox files.\n\nInbox path: `~/.claude/teams/{teamName}/inboxes/{agentName}.json`\n\nWrites use `proper-lockfile` for concurrent write safety (up to 10 retries). Each file is a JSON array; appending reads → appends → writes back.\n\n### 2. 15 Message Types\n\nClaude Code team communication has 15 structured message types (`teammateMailbox.ts`):\n\n| Type | Direction | Purpose |\n|------|-----------|---------|\n| `plain text` | Both ways | Normal inter-teammate communication |\n| `idle_notification` | Teammate→Lead | Teammate finished a turn, now idle |\n| `permission_request` | Teammate→Lead | Teammate needs operation approval |\n| `permission_response` | Lead→Teammate | Lead's approval result |\n| `plan_approval_request` | Teammate→Lead | Teammate submits plan for review |\n| `plan_approval_response` | Lead→Teammate | Lead's plan review |\n| `shutdown_request` | Lead→Teammate | Request graceful shutdown |\n| `shutdown_approved` | Teammate→Lead | Confirm shutdown |\n| `shutdown_rejected` | Teammate→Lead | Reject shutdown (with reason) |\n| `task_assignment` | Lead→Teammate | Assign a task |\n| `team_permission_update` | Lead→Teammate | Broadcast permission changes |\n| `mode_set_request` | Lead→Teammate | Change teammate's permission mode |\n| `sandbox_permission_*` | Both ways | Network permission request/reply |\n| `teammate_terminated` | System | Teammate removed notification |\n\nText messages are wrapped in `` XML tags for delivery to the model.\n\n### 3. Permission Bubbling: Bidirectional Polling\n\nTeaching code omits permission bubbling. Real Claude Code's flow (`permissionSync.ts`):\n\n1. **Teammate** encounters operation needing approval → sends `permission_request` to Lead's inbox\n2. **Lead's** `useInboxPoller` (polls every 1s) detects request → routes to `ToolUseConfirmQueue`\n3. Lead's UI shows approval dialog with teammate name and color\n4. User approves → Lead sends `permission_response` back to teammate's inbox\n5. **Teammate's** `useSwarmPermissionPoller` (polls every 500ms) receives reply → continue or reject\n\n### 4. Teammate Lifecycle\n\nClaude Code teammates are created by `spawnTeammate()` (`spawnMultiAgent.ts`):\n\n1. **Spawn**: Create tmux pane (or in-process), assign color, write team config\n2. **Work**: `useInboxPoller` checks inbox every 1s → submit as new turn when messages arrive\n3. **Idle**: Stop hook fires → send `idle_notification` to Lead\n4. **Shutdown**: Lead sends `shutdown_request` → teammate replies `shutdown_approved` → Lead cleans up\n\n### 5. Team Config\n\nTeam registry at `~/.claude/teams/{teamName}/config.json` (`teamHelpers.ts`):\n\n```json\n{\n \"name\": \"my-team\",\n \"leadAgentId\": \"lead@my-team\",\n \"members\": [{\n \"agentId\": \"researcher@my-team\",\n \"name\": \"researcher\",\n \"agentType\": \"general-purpose\",\n \"color\": \"blue\",\n \"isActive\": true\n }]\n}\n```\n\nTeammates cannot be nested (`AgentTool.tsx:273` explicitly forbids \"teammates spawning other teammates\").\n\n
\n\n\n" }, { "version": "s15", "locale": "zh", "title": "s15: Agent Teams — 一个搞不定,组队来", - "content": "# s15: Agent Teams — 一个搞不定,组队来\n\ns01 → ... → s13 → s14 → `s15` → [s16](/zh/s16) → s17 → s18 → s19 → s20\n> *\"一个搞不定, 组队来\"* — 文件收件箱 + 队友线程。\n>\n> **Harness 层**: 团队 — 多 Agent 协作, 消息总线。\n\n---\n\n## 问题\n\n\"重构整个后端\"涉及认证模块、数据库层、API 路由、测试。一个 Agent 在修 API 路由时,认证模块的细节已经不在上下文里了。上下文窗口就那么大,单个 Agent 的注意力覆盖不了所有模块。\n\ns06 的子 Agent 是临时工,叫来干一件事就走了。但有些任务需要能通信、能协作的队友。\n\n---\n\n## 解决方案\n\n![Agent Teams Overview](/course-assets/s15_agent_teams/agent-teams-overview.svg)\n\n教学代码沿用 S14 的能力(prompt 组装、任务系统、后台执行、cron 调度)。为了聚焦团队机制,省略了完整错误恢复、记忆和技能系统。新增三样:**MessageBus**(文件收件箱)、**spawn_teammate_thread**(启动队友线程)、**inbox 注入**(Lead 接收队友消息并注入 history)。\n\n子 Agent vs 队友:\n\n| | s06 子 Agent | s15 队友 |\n|---|---|---|\n| 生命周期 | 一次性,用完销毁 | 多轮(教学版限 10 轮,真实 CC 用 idle loop) |\n| 通信 | 只回传结论 | 异步收件箱,随时通信 |\n| 上下文 | 完全隔离 | 通过消息共享信息 |\n| 数量 | 一个主 Agent + 偶尔子 Agent | 一个 Lead + 多个队友 |\n\n---\n\n## 工作原理\n\n![Team Topology](/course-assets/s15_agent_teams/team-topology.svg)\n\n### MessageBus: 文件收件箱\n\n每个 Agent(包括 Lead 和队友)有一个 `.jsonl` 邮箱。发消息 = 往对方的文件里 append 一行 JSON。读消息 = 读文件 + 删除(消费式):\n\n```python\nclass MessageBus:\n def send(self, from_agent: str, to_agent: str,\n content: str, msg_type: str = \"message\"):\n msg = {\"from\": from_agent, \"to\": to_agent,\n \"content\": content, \"type\": msg_type,\n \"ts\": time.time()}\n inbox = MAILBOX_DIR / f\"{to_agent}.jsonl\"\n with open(inbox, \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n\n def read_inbox(self, agent: str) -> list[dict]:\n inbox = MAILBOX_DIR / f\"{agent}.jsonl\"\n if not inbox.exists():\n return []\n msgs = [json.loads(line) for line in inbox.read_text().splitlines()]\n inbox.unlink() # 消费式:读完删除\n return msgs\n```\n\n为什么用文件而不是内存队列?教学版选文件是因为直观、跨线程可观察。真实 CC 也用文件收件箱(`~/.claude/teams/{team}/inboxes/`),但加了 `proper-lockfile` 防并发写冲突。教学版的 `read_inbox` 有 read + unlink 竞态,多线程同时读可能丢消息,对教学场景可以接受。\n\n### spawn_teammate_thread: 启动队友\n\nLead 调用 `spawn_teammate` 工具启动一个队友。队友跑在自己的 daemon 线程里,有自己的 system prompt、自己的 messages、自己的简化工具集:\n\n```python\ndef spawn_teammate_thread(name: str, role: str, prompt: str) -> str:\n system = f\"You are '{name}', a {role}. Use tools to complete tasks.\"\n\n def run():\n messages = [{\"role\": \"user\", \"content\": prompt}]\n sub_tools = [bash, read_file, write_file, send_message]\n for _ in range(10): # 最多 10 轮\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"{json.dumps(inbox)}\"})\n response = client.messages.create(\n model=MODEL, system=system, messages=messages[-20:],\n tools=sub_tools, max_tokens=8000)\n # ... 执行工具、处理结果\n # 完成后发 summary 给 Lead\n BUS.send(name, \"lead\", summary, \"result\")\n\n threading.Thread(target=run, daemon=True).start()\n```\n\n关键设计:\n- **队友有简化工具集**:bash、read、write、send_message。教学版省略了任务和 cron,聚焦通信机制。真实 CC 的队友也有 TaskCreate、TaskUpdate 等工具,任务系统是团队共享的\n- **教学版限 10 轮**:防止队友无限循环。真实 CC 用 idle loop:跑完一轮后发 `idle_notification`,等 inbox 消息,收到后继续,直到 `shutdown_request` 才退出\n- **完成后自动汇报**:`BUS.send(name, \"lead\", summary)` 把最终结果发到 Lead 的收件箱\n\n### Lead 的 inbox 注入\n\nLead 在每轮主循环结束后检查收件箱。队友发来的消息注入到 history 里,让 LLM 能看到并做出反应:\n\n```python\n# 主循环结束后\ninbox = BUS.read_inbox(\"lead\")\nif inbox:\n inbox_text = \"\\n\".join(\n f\"From {m['from']}: {m['content'][:200]}\" for m in inbox)\n history.append({\"role\": \"user\",\n \"content\": f\"[Inbox]\\n{inbox_text}\"})\n```\n\n教学版在用户输入循环外注入。CC 更精细,Lead 的 `useInboxPoller` 每 1 秒检查一次,有消息就提交为新的 turn,不需要等用户输入。\n\n### 权限冒泡\n\n教学版省略了权限冒泡。真实 CC 的流程(`permissionSync.ts`、`useSwarmPermissionPoller.ts`):\n\n1. 队友遇到需要审批的操作 → 发 `permission_request` 到 Lead 收件箱\n2. Lead 的 `useInboxPoller` 检测到请求 → 路由到审批队列\n3. 用户审批后 → Lead 发 `permission_response` 回队友\n4. 队友的 `useSwarmPermissionPoller`(每 500ms 轮询)收到回复 → 继续或拒绝\n\n### 合起来跑\n\n```\n1. Lead: \"搭建后端:一个人搞不定,组队吧\"\n2. Lead → spawn_teammate(\"alice\", \"backend dev\", \"创建数据库 schema\")\n3. Lead → spawn_teammate(\"bob\", \"frontend dev\", \"写 API 客户端\")\n4. alice 线程启动 → 自己的 LLM 调用 → bash \"python manage.py migrate\"\n5. bob 线程启动 → 自己的 LLM 调用 → write_file(\"client.ts\", ...)\n6. alice 完成 → BUS.send(\"alice\", \"lead\", \"Schema done: users, orders tables\")\n7. bob 完成 → BUS.send(\"bob\", \"lead\", \"Client written with types\")\n8. Lead 下次循环 → inbox 注入 history → LLM 看到 alice 和 bob 的结果\n```\n\n两个队友并行工作。\n\n---\n\n## 相对 s14 的变更\n\n| 组件 | 之前 (s14) | 之后 (s15) |\n|------|-----------|-----------|\n| Agent 数量 | 1 | 1 Lead + N 队友线程 |\n| 通信 | 无 | MessageBus + .mailboxes/*.jsonl |\n| 新类 | — | MessageBus, active_teammates dict |\n| 新函数 | — | spawn_teammate_thread, run_send_message, run_check_inbox |\n| Lead 工具 | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) |\n| 队友工具 | — | bash, read_file, write_file, send_message (4) |\n| 权限 | 本地决策 | 教学版省略(真实 CC 有冒泡机制) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s15_agent_teams/code.py\n```\n\n试试这些 prompt:\n\n1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.`\n2. `Check your inbox for alice's result.`\n3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.`\n\n观察重点:Lead 如何启动队友?`.mailboxes/` 目录下的 JSONL 文件长什么样?队友完成后 Lead 的 inbox 有没有注入到 history?\n\n---\n\n## 接下来\n\n队友能干活、能通信。但如果 Lead 想让 Alice 关机,直接杀线程会留下写到一半的文件。需要一个体面的关机协议:Lead 发 shutdown_request,队友收尾后退出。\n\ns16 Team Protocols → 关机握手与消息约定。\n\n
\n深入 CC 源码\n\n> 以下基于 CC 源码 `spawnMultiAgent.ts`、`useInboxPoller.ts`(969 行)、`useSwarmPermissionPoller.ts`(330 行)、`teammateMailbox.ts`、`teamHelpers.ts` 的完整分析。\n\n### 一、没有中央消息总线,是文件系统\n\n教学版用 `MessageBus` 类收发消息。CC 的做法更直接,每个 Agent 直接写其他 Agent 的收件箱文件。\n\n收件箱路径:`~/.claude/teams/{teamName}/inboxes/{agentName}.json`\n\n写入时用 `proper-lockfile` 文件锁保证并发安全(最多重试 10 次)。每个文件是一个 JSON 数组,append 新消息时读→追加→写回。\n\n### 二、15 种消息类型\n\nCC 的团队通信有 15 种结构化消息(`teammateMailbox.ts`):\n\n| 类型 | 方向 | 用途 |\n|------|------|------|\n| `plain text` | 双向 | 普通队友间通信 |\n| `idle_notification` | 队友→Lead | 队友完成一轮工作,进入空闲 |\n| `permission_request` | 队友→Lead | 队友需要操作审批 |\n| `permission_response` | Lead→队友 | Lead 审批结果 |\n| `plan_approval_request` | 队友→Lead | 队友提交计划待审 |\n| `plan_approval_response` | Lead→队友 | Lead 审批计划 |\n| `shutdown_request` | Lead→队友 | 请求体面关机 |\n| `shutdown_approved` | 队友→Lead | 确认关机 |\n| `shutdown_rejected` | 队友→Lead | 拒绝关机(附原因) |\n| `task_assignment` | Lead→队友 | 分配任务 |\n| `team_permission_update` | Lead→队友 | 广播权限变更 |\n| `mode_set_request` | Lead→队友 | 修改队友的权限模式 |\n| `sandbox_permission_*` | 双向 | 网络权限请求/回复 |\n| `teammate_terminated` | 系统 | 队友被移除通知 |\n\n文本消息被包装在 `` XML 标签中交付给模型。\n\n### 三、权限冒泡:双向轮询\n\n教学版省略了权限冒泡。CC 的实际流程(`permissionSync.ts`):\n\n1. **队友**遇到需要审批的操作 → 发 `permission_request` 到 Lead 的收件箱\n2. **Lead** 的 `useInboxPoller`(每 1 秒轮询)检测到请求 → 路由到 `ToolUseConfirmQueue`\n3. Lead 的 UI 显示审批对话框,带队友名字和颜色\n4. 用户审批后 → Lead 发 `permission_response` 回队友的收件箱\n5. **队友**的 `useSwarmPermissionPoller`(每 500ms 轮询)收到回复 → 继续或拒绝执行\n\n### 四、队友生命周期\n\nCC 的队友由 `spawnTeammate()`(`spawnMultiAgent.ts`)创建:\n\n1. **Spawn**:创建 tmux 窗格(或进程内),分配颜色,写入 team config\n2. **Work**:`useInboxPoller` 每 1 秒检查收件箱 → 有消息就提交为新的 turn\n3. **Idle**:Stop hook 触发 → 发 `idle_notification` 给 Lead\n4. **Shutdown**:Lead 发 `shutdown_request` → 队友回复 `shutdown_approved` → Lead 清理\n\n### 五、Team Config\n\n团队注册表在 `~/.claude/teams/{teamName}/config.json`(`teamHelpers.ts`):\n\n```json\n{\n \"name\": \"my-team\",\n \"leadAgentId\": \"lead@my-team\",\n \"members\": [{\n \"agentId\": \"researcher@my-team\",\n \"name\": \"researcher\",\n \"agentType\": \"general-purpose\",\n \"color\": \"blue\",\n \"isActive\": true\n }]\n}\n```\n\n队友之间不能嵌套(`AgentTool.tsx:273` 明确禁止 \"teammates spawning other teammates\")。\n\n
\n\n\n" + "content": "# s15: Agent Teams — 一个搞不定,组队来\n\ns01 → ... → s13 → s14 → `s15` → [s16](/zh/s16) → s17 → s18 → s19 → s20\n> *\"一个搞不定, 组队来\"* — 文件收件箱 + 队友线程。\n>\n> **Harness 层**: 团队 — 多 Agent 协作, 消息总线。\n\n---\n\n## 问题\n\n\"重构整个后端\"涉及认证模块、数据库层、API 路由、测试。一个 Agent 在修 API 路由时,认证模块的细节已经不在上下文里了。上下文窗口有限,单个 Agent 覆盖不了所有模块。\n\ns06 的子 Agent 是临时工,叫来干一件事就走了。但有些任务需要能通信、能协作的队友。\n\n---\n\n## 解决方案\n\n![Agent Teams Overview](/course-assets/s15_agent_teams/agent-teams-overview.svg)\n\n教学代码沿用 S14 的能力(prompt 组装、任务系统、后台执行、cron 调度)。为了聚焦团队机制,省略了完整错误恢复、记忆和技能系统。新增三样:**MessageBus**(文件收件箱)、**spawn_teammate_thread**(启动队友线程)、**inbox 注入**(Lead 接收队友消息并注入 history)。\n\n子 Agent vs 队友:\n\n| | s06 子 Agent | s15 队友 |\n|---|---|---|\n| 生命周期 | 一次性,用完销毁 | 多轮(教学版限 10 轮,真实 Claude Code 用 idle loop) |\n| 通信 | 只回传结论 | 异步收件箱,随时通信 |\n| 上下文 | 完全隔离 | 通过消息共享信息 |\n| 数量 | 一个主 Agent + 偶尔子 Agent | 一个 Lead + 多个队友 |\n\n---\n\n## 工作原理\n\n![Team Topology](/course-assets/s15_agent_teams/team-topology.svg)\n\n### MessageBus: 文件收件箱\n\n每个 Agent(包括 Lead 和队友)有一个 `.jsonl` 邮箱。发消息 = 往对方的文件里 append 一行 JSON。读消息 = 读文件 + 删除(消费式):\n\n```python\nclass MessageBus:\n def send(self, from_agent: str, to_agent: str,\n content: str, msg_type: str = \"message\"):\n msg = {\"from\": from_agent, \"to\": to_agent,\n \"content\": content, \"type\": msg_type,\n \"ts\": time.time()}\n inbox = MAILBOX_DIR / f\"{to_agent}.jsonl\"\n with open(inbox, \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n\n def read_inbox(self, agent: str) -> list[dict]:\n inbox = MAILBOX_DIR / f\"{agent}.jsonl\"\n if not inbox.exists():\n return []\n msgs = [json.loads(line) for line in inbox.read_text().splitlines()]\n inbox.unlink() # 消费式:读完删除\n return msgs\n```\n\n为什么用文件而不是内存队列?教学版选文件是因为直观、跨线程可观察。真实 Claude Code 也用文件收件箱(`~/.claude/teams/{team}/inboxes/`),但加了 `proper-lockfile` 防并发写冲突。教学版的 `read_inbox` 有 read + unlink 竞态,多线程同时读可能丢消息,对教学场景可以接受。\n\n### spawn_teammate_thread: 启动队友\n\nLead 调用 `spawn_teammate` 工具启动一个队友。队友跑在自己的 daemon 线程里,有自己的 system prompt、自己的 messages、自己的简化工具集:\n\n```python\ndef spawn_teammate_thread(name: str, role: str, prompt: str) -> str:\n system = f\"You are '{name}', a {role}. Use tools to complete tasks.\"\n\n def run():\n messages = [{\"role\": \"user\", \"content\": prompt}]\n sub_tools = [bash, read_file, write_file, send_message]\n for _ in range(10): # 最多 10 轮\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"{json.dumps(inbox)}\"})\n response = client.messages.create(\n model=MODEL, system=system, messages=messages[-20:],\n tools=sub_tools, max_tokens=8000)\n # ... 执行工具、处理结果\n # 完成后发 summary 给 Lead\n BUS.send(name, \"lead\", summary, \"result\")\n\n threading.Thread(target=run, daemon=True).start()\n```\n\n关键设计:\n- **队友有简化工具集**:bash、read、write、send_message。教学版省略了任务和 cron,聚焦通信机制。真实 Claude Code 的队友也有 TaskCreate、TaskUpdate 等工具,任务系统是团队共享的\n- **教学版限 10 轮**:防止队友无限循环。真实 Claude Code 用 idle loop:跑完一轮后发 `idle_notification`,等 inbox 消息,收到后继续,直到 `shutdown_request` 才退出\n- **完成后自动汇报**:`BUS.send(name, \"lead\", summary)` 把最终结果发到 Lead 的收件箱\n\n### Lead 的 inbox 注入\n\nLead 在每轮主循环结束后检查收件箱。队友发来的消息注入到 history 里,让 LLM 能看到并做出反应:\n\n```python\n# 主循环结束后\ninbox = BUS.read_inbox(\"lead\")\nif inbox:\n inbox_text = \"\\n\".join(\n f\"From {m['from']}: {m['content'][:200]}\" for m in inbox)\n history.append({\"role\": \"user\",\n \"content\": f\"[Inbox]\\n{inbox_text}\"})\n```\n\n教学版在用户输入循环外注入。Claude Code 更精细,Lead 的 `useInboxPoller` 每 1 秒检查一次,有消息就提交为新的 turn,不需要等用户输入。\n\n### 权限冒泡\n\n教学版省略了权限冒泡。真实 Claude Code 的流程(`permissionSync.ts`、`useSwarmPermissionPoller.ts`):\n\n1. 队友遇到需要审批的操作 → 发 `permission_request` 到 Lead 收件箱\n2. Lead 的 `useInboxPoller` 检测到请求 → 路由到审批队列\n3. 用户审批后 → Lead 发 `permission_response` 回队友\n4. 队友的 `useSwarmPermissionPoller`(每 500ms 轮询)收到回复 → 继续或拒绝\n\n### 合起来跑\n\n```\n1. Lead: \"搭建后端:拆成三个模块,分别启动队友\"\n2. Lead → spawn_teammate(\"alice\", \"backend dev\", \"创建数据库 schema\")\n3. Lead → spawn_teammate(\"bob\", \"frontend dev\", \"写 API 客户端\")\n4. alice 线程启动 → 自己的 LLM 调用 → bash \"python manage.py migrate\"\n5. bob 线程启动 → 自己的 LLM 调用 → write_file(\"client.ts\", ...)\n6. alice 完成 → BUS.send(\"alice\", \"lead\", \"Schema done: users, orders tables\")\n7. bob 完成 → BUS.send(\"bob\", \"lead\", \"Client written with types\")\n8. Lead 下次循环 → inbox 注入 history → LLM 看到 alice 和 bob 的结果\n```\n\n两个队友并行工作。\n\n---\n\n## 相对 s14 的变更\n\n| 组件 | 之前 (s14) | 之后 (s15) |\n|------|-----------|-----------|\n| Agent 数量 | 1 | 1 Lead + N 队友线程 |\n| 通信 | 无 | MessageBus + .mailboxes/*.jsonl |\n| 新类 | — | MessageBus, active_teammates dict |\n| 新函数 | — | spawn_teammate_thread, run_send_message, run_check_inbox |\n| Lead 工具 | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) |\n| 队友工具 | — | bash, read_file, write_file, send_message (4) |\n| 权限 | 本地决策 | 教学版省略(真实 Claude Code 有冒泡机制) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s15_agent_teams/code.py\n```\n\n试试这些 prompt:\n\n1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.`\n2. `Check your inbox for alice's result.`\n3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.`\n\n观察重点:Lead 如何启动队友?`.mailboxes/` 目录下的 JSONL 文件长什么样?队友完成后 Lead 的 inbox 有没有注入到 history?\n\n---\n\n## 接下来\n\n队友能干活、能通信。但如果 Lead 想让 Alice 关机,直接杀线程会留下写到一半的文件。需要一个体面的关机协议:Lead 发 shutdown_request,队友收尾后退出。\n\ns16 Team Protocols → 关机握手与消息约定。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `spawnMultiAgent.ts`、`useInboxPoller.ts`(969 行)、`useSwarmPermissionPoller.ts`(330 行)、`teammateMailbox.ts`、`teamHelpers.ts` 的完整分析。\n\n### 一、没有中央消息总线,是文件系统\n\n教学版用 `MessageBus` 类收发消息。Claude Code 的做法更直接,每个 Agent 直接写其他 Agent 的收件箱文件。\n\n收件箱路径:`~/.claude/teams/{teamName}/inboxes/{agentName}.json`\n\n写入时用 `proper-lockfile` 文件锁保证并发安全(最多重试 10 次)。每个文件是一个 JSON 数组,append 新消息时读→追加→写回。\n\n### 二、15 种消息类型\n\nClaude Code 的团队通信有 15 种结构化消息(`teammateMailbox.ts`):\n\n| 类型 | 方向 | 用途 |\n|------|------|------|\n| `plain text` | 双向 | 普通队友间通信 |\n| `idle_notification` | 队友→Lead | 队友完成一轮工作,进入空闲 |\n| `permission_request` | 队友→Lead | 队友需要操作审批 |\n| `permission_response` | Lead→队友 | Lead 审批结果 |\n| `plan_approval_request` | 队友→Lead | 队友提交计划待审 |\n| `plan_approval_response` | Lead→队友 | Lead 审批计划 |\n| `shutdown_request` | Lead→队友 | 请求体面关机 |\n| `shutdown_approved` | 队友→Lead | 确认关机 |\n| `shutdown_rejected` | 队友→Lead | 拒绝关机(附原因) |\n| `task_assignment` | Lead→队友 | 分配任务 |\n| `team_permission_update` | Lead→队友 | 广播权限变更 |\n| `mode_set_request` | Lead→队友 | 修改队友的权限模式 |\n| `sandbox_permission_*` | 双向 | 网络权限请求/回复 |\n| `teammate_terminated` | 系统 | 队友被移除通知 |\n\n文本消息被包装在 `` XML 标签中交付给模型。\n\n### 三、权限冒泡:双向轮询\n\n教学版省略了权限冒泡。Claude Code 的实际流程(`permissionSync.ts`):\n\n1. **队友**遇到需要审批的操作 → 发 `permission_request` 到 Lead 的收件箱\n2. **Lead** 的 `useInboxPoller`(每 1 秒轮询)检测到请求 → 路由到 `ToolUseConfirmQueue`\n3. Lead 的 UI 显示审批对话框,带队友名字和颜色\n4. 用户审批后 → Lead 发 `permission_response` 回队友的收件箱\n5. **队友**的 `useSwarmPermissionPoller`(每 500ms 轮询)收到回复 → 继续或拒绝执行\n\n### 四、队友生命周期\n\nClaude Code 的队友由 `spawnTeammate()`(`spawnMultiAgent.ts`)创建:\n\n1. **Spawn**:创建 tmux 窗格(或进程内),分配颜色,写入 team config\n2. **Work**:`useInboxPoller` 每 1 秒检查收件箱 → 有消息就提交为新的 turn\n3. **Idle**:Stop hook 触发 → 发 `idle_notification` 给 Lead\n4. **Shutdown**:Lead 发 `shutdown_request` → 队友回复 `shutdown_approved` → Lead 清理\n\n### 五、Team Config\n\n团队注册表在 `~/.claude/teams/{teamName}/config.json`(`teamHelpers.ts`):\n\n```json\n{\n \"name\": \"my-team\",\n \"leadAgentId\": \"lead@my-team\",\n \"members\": [{\n \"agentId\": \"researcher@my-team\",\n \"name\": \"researcher\",\n \"agentType\": \"general-purpose\",\n \"color\": \"blue\",\n \"isActive\": true\n }]\n}\n```\n\n队友之间不能嵌套(`AgentTool.tsx:273` 明确禁止 \"teammates spawning other teammates\")。\n\n
\n\n\n" }, { "version": "s15", "locale": "ja", "title": "s15: Agent Teams — 一人では無理、チームを組もう", - "content": "# s15: Agent Teams — 一人では無理、チームを組もう\n\ns01 → ... → s13 → s14 → `s15` → [s16](/ja/s16) → s17 → s18 → s19 → s20\n> *\"一人では無理、チームを組もう\"* — ファイル受信箱 + チームメイトスレッド。\n>\n> **Harness 層**: チーム — マルチ Agent 協調、メッセージバス。\n\n---\n\n## 課題\n\n「バックエンド全体をリファクタリング」は認証モジュール、データベース層、API ルート、テストに及ぶ。一つの Agent が API ルートを修正中、認証モジュールの詳細はコンテキストから外れている。コンテキストウィンドウには限界があり、単一 Agent の注意は全モジュールをカバーできない。\n\ns06 のサブ Agent は臨時スタッフ、一つの仕事を終えたら去る。だが、通信でき、協力できるチームメイトが必要なタスクもある。\n\n---\n\n## ソリューション\n\n![Agent Teams Overview](/course-assets/s15_agent_teams/agent-teams-overview.ja.svg)\n\n教学版は S14 の能力(プロンプト組み立て、タスクシステム、バックグラウンド実行、cron スケジューリング)を踏襲。チーム機構に集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。追加:**MessageBus**(ファイル受信箱)、**spawn_teammate_thread**(チームメイトスレッド起動)、**inbox 注入**(Lead がチームメイトメッセージを受信し history に注入)。\n\nサブ Agent vs チームメイト:\n\n| | s06 サブ Agent | s15 チームメイト |\n|---|---|---|\n| ライフサイクル | 一回きり、終了後に破棄 | マルチターン(教学版は 10 ラウンド制限、真实 CC は idle loop) |\n| 通信 | 結果のみ返却 | 非同期受信箱、いつでも通信可能 |\n| コンテキスト | 完全に隔離 | メッセージで情報共有 |\n| 数 | メイン Agent + たまにサブ Agent | 1 Lead + 複数チームメイト |\n\n---\n\n## 仕組み\n\n![Team Topology](/course-assets/s15_agent_teams/team-topology.ja.svg)\n\n### MessageBus: ファイル受信箱\n\n各 Agent(Lead とチームメイトを含む)には `.jsonl` 受信箱がある。メッセージ送信 = 相手のファイルに 1 行 JSON を append。メッセージ読み取り = ファイル読み込み + 削除(消費式):\n\n```python\nclass MessageBus:\n def send(self, from_agent: str, to_agent: str,\n content: str, msg_type: str = \"message\"):\n msg = {\"from\": from_agent, \"to\": to_agent,\n \"content\": content, \"type\": msg_type,\n \"ts\": time.time()}\n inbox = MAILBOX_DIR / f\"{to_agent}.jsonl\"\n with open(inbox, \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n\n def read_inbox(self, agent: str) -> list[dict]:\n inbox = MAILBOX_DIR / f\"{agent}.jsonl\"\n if not inbox.exists():\n return []\n msgs = [json.loads(line) for line in inbox.read_text().splitlines()]\n inbox.unlink() # 消費式:読んだら削除\n return msgs\n```\n\nなぜファイルか、メモリキューではなく?教学版がファイルを選ぶ理由は、直感的でスレッドをまたいで観察可能だから。真实 CC もファイル受信箱(`~/.claude/teams/{team}/inboxes/`)を使うが、`proper-lockfile` で並行書き込みの安全性を確保。教学版の `read_inbox` には read + unlink の競合状態があり、マルチスレッド同時読みでメッセージを損失する可能性があるが、教学目的には許容範囲。\n\n### spawn_teammate_thread: チームメイト起動\n\nLead が `spawn_teammate` ツールを呼び出してチームメイトを起動。チームメイトは独自の daemon スレッドで動作、独自の system prompt、messages、簡易ツールセットを持つ:\n\n```python\ndef spawn_teammate_thread(name: str, role: str, prompt: str) -> str:\n system = f\"You are '{name}', a {role}. Use tools to complete tasks.\"\n\n def run():\n messages = [{\"role\": \"user\", \"content\": prompt}]\n sub_tools = [bash, read_file, write_file, send_message]\n for _ in range(10): # 最大 10 ラウンド\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"{json.dumps(inbox)}\"})\n response = client.messages.create(\n model=MODEL, system=system, messages=messages[-20:],\n tools=sub_tools, max_tokens=8000)\n # ... ツール実行、結果処理\n # 完了後 summary を Lead に送信\n BUS.send(name, \"lead\", summary, \"result\")\n\n threading.Thread(target=run, daemon=True).start()\n```\n\n重要な設計:\n- **チームメイトの簡易ツールセット**:bash、read、write、send_message。教学版は通信機構に集中するためタスクと cron を省略。真实 CC のチームメイトには TaskCreate、TaskUpdate 等のツールもあり、タスクシステムはチーム全体で共有\n- **教学版は 10 ラウンド制限**:無限ループを防止。真实 CC は idle loop:1 ラウンド終了後に `idle_notification` を送信、inbox メッセージを待機、到着後に再開、`shutdown_request` でのみ終了\n- **完了時自動報告**:`BUS.send(name, \"lead\", summary)` で最終結果を Lead の受信箱に送信\n\n### Lead の inbox 注入\n\nLead はメインループの各反復後に受信箱を確認。チームメイトからのメッセージを history に注入し、LLM が確認して反応できるようにする:\n\n```python\n# メインループ反復後\ninbox = BUS.read_inbox(\"lead\")\nif inbox:\n inbox_text = \"\\n\".join(\n f\"From {m['from']}: {m['content'][:200]}\" for m in inbox)\n history.append({\"role\": \"user\",\n \"content\": f\"[Inbox]\\n{inbox_text}\"})\n```\n\n教学版はユーザー入力ループ内で注入。真实 CC はより精密、Lead の `useInboxPoller` が毎秒チェックし、ユーザー入力を待たずにメッセージを新しい turn として送信。\n\n### 権限バブリング\n\n教学版は権限バブリングを省略。真实 CC のフロー(`permissionSync.ts`、`useSwarmPermissionPoller.ts`):\n\n1. チームメイトが承認が必要な操作に遭遇 → `permission_request` を Lead の受信箱に送信\n2. Lead の `useInboxPoller` がリクエストを検出 → 承認キューにルーティング\n3. ユーザーが承認 → Lead が `permission_response` をチームメイトに返信\n4. チームメイトの `useSwarmPermissionPoller`(500ms ごとにポーリング)が返信を受信 → 続行または拒否\n\n### 組み合わせて実行\n\n```\n1. Lead: \"バックエンド構築:一人では無理、チームを組もう\"\n2. Lead → spawn_teammate(\"alice\", \"backend dev\", \"データベーススキーマを作成\")\n3. Lead → spawn_teammate(\"bob\", \"frontend dev\", \"API クライアントを作成\")\n4. alice スレッド起動 → 独自の LLM 呼び出し → bash \"python manage.py migrate\"\n5. bob スレッド起動 → 独自の LLM 呼び出し → write_file(\"client.ts\", ...)\n6. alice 完了 → BUS.send(\"alice\", \"lead\", \"Schema done: users, orders tables\")\n7. bob 完了 → BUS.send(\"bob\", \"lead\", \"Client written with types\")\n8. Lead 次回反復 → inbox を history に注入 → LLM が alice と bob の結果を確認\n```\n\n2 人のチームメイトが並行作業。\n\n---\n\n## s14 からの変更\n\n| コンポーネント | 変更前 (s14) | 変更後 (s15) |\n|--------------|------------|------------|\n| Agent 数 | 1 | 1 Lead + N チームメイトスレッド |\n| 通信 | なし | MessageBus + .mailboxes/*.jsonl |\n| 新規クラス | — | MessageBus, active_teammates dict |\n| 新規関数 | — | spawn_teammate_thread, run_send_message, run_check_inbox |\n| Lead ツール | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) |\n| チームメイトツール | — | bash, read_file, write_file, send_message (4) |\n| 権限 | ローカル判断 | 教学版は省略(真实 CC はバブリング機構あり) |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s15_agent_teams/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.`\n2. `Check your inbox for alice's result.`\n3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.`\n\n観察ポイント:Lead はチームメイトをどう起動するか?`.mailboxes/` ディレクトリの JSONL ファイルの中身は?チームメイト完了後、Lead の inbox は history に注入されているか?\n\n---\n\n## 次の章\n\nチームメイトは仕事をし、通信できる。しかし、Lead が Alice にシャットダウンを頼む場合、スレッドを強制終了すると書きかけのファイルが残る。丁寧なシャットダウンプロトコルが必要:Lead が shutdown_request を送信、チームメイトは收尾後に終了。\n\ns16 Team Protocols → シャットダウンハンドシェイクとメッセージの取り決め。\n\n
\nCC ソースコード深掘り\n\n> 以下は CC ソースコード `spawnMultiAgent.ts`、`useInboxPoller.ts`(969 行)、`useSwarmPermissionPoller.ts`(330 行)、`teammateMailbox.ts`、`teamHelpers.ts` の完全分析に基づく。\n\n### 一、中央メッセージバスはない、ファイルシステム\n\n教学版は `MessageBus` クラスでメッセージを送受信。真实 CC はもっと直接的、各 Agent が他の Agent の受信箱ファイルに直接書き込む。\n\n受信箱パス:`~/.claude/teams/{teamName}/inboxes/{agentName}.json`\n\n書き込み時は `proper-lockfile` で並行安全性を確保(最大 10 回リトライ)。各ファイルは JSON 配列、append 時に読み取り→追加→書き戻し。\n\n### 二、15 種のメッセージ型\n\nCC のチーム通信には 15 種の構造化メッセージ(`teammateMailbox.ts`)がある:\n\n| 型 | 方向 | 用途 |\n|------|------|------|\n| `plain text` | 双方向 | 通常のチームメイト間通信 |\n| `idle_notification` | チームメイト→Lead | チームメイトが 1 ターン完了、アイドル状態に |\n| `permission_request` | チームメイト→Lead | 操作承認が必要 |\n| `permission_response` | Lead→チームメイト | Lead の承認結果 |\n| `plan_approval_request` | チームメイト→Lead | 計画提出、審査待ち |\n| `plan_approval_response` | Lead→チームメイト | Lead の計画審査 |\n| `shutdown_request` | Lead→チームメイト | 丁寧なシャットダウン要求 |\n| `shutdown_approved` | チームメイト→Lead | シャットダウン確認 |\n| `shutdown_rejected` | チームメイト→Lead | シャットダウン拒否(理由付き) |\n| `task_assignment` | Lead→チームメイト | タスク割り当て |\n| `team_permission_update` | Lead→チームメイト | 権限変更のブロードキャスト |\n| `mode_set_request` | Lead→チームメイト | チームメイトの権限モード変更 |\n| `sandbox_permission_*` | 双方向 | ネットワーク権限リクエスト/返信 |\n| `teammate_terminated` | システム | チームメイト削除通知 |\n\nテキストメッセージは `` XML タグでラップされモデルに配信。\n\n### 三、権限バブリング:双方向ポーリング\n\n教学版は権限バブリングを省略。真实 CC のフロー(`permissionSync.ts`):\n\n1. **チームメイト**が承認が必要な操作に遭遇 → `permission_request` を Lead の受信箱に送信\n2. **Lead** の `useInboxPoller`(1 秒ごとにポーリング)がリクエストを検出 → `ToolUseConfirmQueue` にルーティング\n3. Lead の UI にチームメイト名と色付きの承認ダイアログを表示\n4. ユーザー承認後 → Lead が `permission_response` をチームメイトの受信箱に返信\n5. **チームメイト**の `useSwarmPermissionPoller`(500ms ごとにポーリング)が返信を受信 → 続行または拒否\n\n### 四、チームメイトライフサイクル\n\nCC のチームメイトは `spawnTeammate()`(`spawnMultiAgent.ts`)で作成:\n\n1. **Spawn**:tmux ペイン(またはプロセス内)を作成、色を割り当て、team config に書き込み\n2. **Work**:`useInboxPoller` が毎秒受信箱をチェック → メッセージ到着時に新しい turn として送信\n3. **Idle**:Stop hook 発火 → `idle_notification` を Lead に送信\n4. **Shutdown**:Lead が `shutdown_request` を送信 → チームメイトが `shutdown_approved` で返信 → Lead がクリーンアップ\n\n### 五、Team Config\n\nチーム登録は `~/.claude/teams/{teamName}/config.json`(`teamHelpers.ts`):\n\n```json\n{\n \"name\": \"my-team\",\n \"leadAgentId\": \"lead@my-team\",\n \"members\": [{\n \"agentId\": \"researcher@my-team\",\n \"name\": \"researcher\",\n \"agentType\": \"general-purpose\",\n \"color\": \"blue\",\n \"isActive\": true\n }]\n}\n```\n\nチームメイトのネストは禁止(`AgentTool.tsx:273` で \"teammates spawning other teammates\" を明示的に禁止)。\n\n
\n\n\n" + "content": "# s15: Agent Teams — 一人では無理、チームを組もう\n\ns01 → ... → s13 → s14 → `s15` → [s16](/ja/s16) → s17 → s18 → s19 → s20\n> *\"一人では無理、チームを組もう\"* — ファイル受信箱 + チームメイトスレッド。\n>\n> **Harness 層**: チーム — マルチ Agent 協調、メッセージバス。\n\n---\n\n## 課題\n\n「バックエンド全体をリファクタリング」は認証モジュール、データベース層、API ルート、テストに及ぶ。一つの Agent が API ルートを修正中、認証モジュールの詳細はコンテキストから外れている。コンテキストウィンドウには限界があり、単一 Agent の注意は全モジュールをカバーできない。\n\ns06 のサブ Agent は臨時スタッフ、一つの仕事を終えたら去る。だが、通信でき、協力できるチームメイトが必要なタスクもある。\n\n---\n\n## ソリューション\n\n![Agent Teams Overview](/course-assets/s15_agent_teams/agent-teams-overview.svg)\n\n教学版は S14 の能力(プロンプト組み立て、タスクシステム、バックグラウンド実行、cron スケジューリング)を踏襲。チーム機構に集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。追加:**MessageBus**(ファイル受信箱)、**spawn_teammate_thread**(チームメイトスレッド起動)、**inbox 注入**(Lead がチームメイトメッセージを受信し history に注入)。\n\nサブ Agent vs チームメイト:\n\n| | s06 サブ Agent | s15 チームメイト |\n|---|---|---|\n| ライフサイクル | 一回きり、終了後に破棄 | マルチターン(教学版は 10 ラウンド制限、真实 Claude Code は idle loop) |\n| 通信 | 結果のみ返却 | 非同期受信箱、いつでも通信可能 |\n| コンテキスト | 完全に隔離 | メッセージで情報共有 |\n| 数 | メイン Agent + たまにサブ Agent | 1 Lead + 複数チームメイト |\n\n---\n\n## 仕組み\n\n![Team Topology](/course-assets/s15_agent_teams/team-topology.svg)\n\n### MessageBus: ファイル受信箱\n\n各 Agent(Lead とチームメイトを含む)には `.jsonl` 受信箱がある。メッセージ送信 = 相手のファイルに 1 行 JSON を append。メッセージ読み取り = ファイル読み込み + 削除(消費式):\n\n```python\nclass MessageBus:\n def send(self, from_agent: str, to_agent: str,\n content: str, msg_type: str = \"message\"):\n msg = {\"from\": from_agent, \"to\": to_agent,\n \"content\": content, \"type\": msg_type,\n \"ts\": time.time()}\n inbox = MAILBOX_DIR / f\"{to_agent}.jsonl\"\n with open(inbox, \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n\n def read_inbox(self, agent: str) -> list[dict]:\n inbox = MAILBOX_DIR / f\"{agent}.jsonl\"\n if not inbox.exists():\n return []\n msgs = [json.loads(line) for line in inbox.read_text().splitlines()]\n inbox.unlink() # 消費式:読んだら削除\n return msgs\n```\n\nなぜファイルか、メモリキューではなく?教学版がファイルを選ぶ理由は、直感的でスレッドをまたいで観察可能だから。真实 Claude Code もファイル受信箱(`~/.claude/teams/{team}/inboxes/`)を使うが、`proper-lockfile` で並行書き込みの安全性を確保。教学版の `read_inbox` には read + unlink の競合状態があり、マルチスレッド同時読みでメッセージを損失する可能性があるが、教学目的には許容範囲。\n\n### spawn_teammate_thread: チームメイト起動\n\nLead が `spawn_teammate` ツールを呼び出してチームメイトを起動。チームメイトは独自の daemon スレッドで動作、独自の system prompt、messages、簡易ツールセットを持つ:\n\n```python\ndef spawn_teammate_thread(name: str, role: str, prompt: str) -> str:\n system = f\"You are '{name}', a {role}. Use tools to complete tasks.\"\n\n def run():\n messages = [{\"role\": \"user\", \"content\": prompt}]\n sub_tools = [bash, read_file, write_file, send_message]\n for _ in range(10): # 最大 10 ラウンド\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"{json.dumps(inbox)}\"})\n response = client.messages.create(\n model=MODEL, system=system, messages=messages[-20:],\n tools=sub_tools, max_tokens=8000)\n # ... ツール実行、結果処理\n # 完了後 summary を Lead に送信\n BUS.send(name, \"lead\", summary, \"result\")\n\n threading.Thread(target=run, daemon=True).start()\n```\n\n重要な設計:\n- **チームメイトの簡易ツールセット**:bash、read、write、send_message。教学版は通信機構に集中するためタスクと cron を省略。真实 Claude Code のチームメイトには TaskCreate、TaskUpdate 等のツールもあり、タスクシステムはチーム全体で共有\n- **教学版は 10 ラウンド制限**:無限ループを防止。真实 Claude Code は idle loop:1 ラウンド終了後に `idle_notification` を送信、inbox メッセージを待機、到着後に再開、`shutdown_request` でのみ終了\n- **完了時自動報告**:`BUS.send(name, \"lead\", summary)` で最終結果を Lead の受信箱に送信\n\n### Lead の inbox 注入\n\nLead はメインループの各反復後に受信箱を確認。チームメイトからのメッセージを history に注入し、LLM が確認して反応できるようにする:\n\n```python\n# メインループ反復後\ninbox = BUS.read_inbox(\"lead\")\nif inbox:\n inbox_text = \"\\n\".join(\n f\"From {m['from']}: {m['content'][:200]}\" for m in inbox)\n history.append({\"role\": \"user\",\n \"content\": f\"[Inbox]\\n{inbox_text}\"})\n```\n\n教学版はユーザー入力ループ内で注入。真实 Claude Code はより精密、Lead の `useInboxPoller` が毎秒チェックし、ユーザー入力を待たずにメッセージを新しい turn として送信。\n\n### 権限バブリング\n\n教学版は権限バブリングを省略。真实 Claude Code のフロー(`permissionSync.ts`、`useSwarmPermissionPoller.ts`):\n\n1. チームメイトが承認が必要な操作に遭遇 → `permission_request` を Lead の受信箱に送信\n2. Lead の `useInboxPoller` がリクエストを検出 → 承認キューにルーティング\n3. ユーザーが承認 → Lead が `permission_response` をチームメイトに返信\n4. チームメイトの `useSwarmPermissionPoller`(500ms ごとにポーリング)が返信を受信 → 続行または拒否\n\n### 組み合わせて実行\n\n```\n1. Lead: \"バックエンド構築:3 モジュールに分割、チームメイトを起動\"\n2. Lead → spawn_teammate(\"alice\", \"backend dev\", \"データベーススキーマを作成\")\n3. Lead → spawn_teammate(\"bob\", \"frontend dev\", \"API クライアントを作成\")\n4. alice スレッド起動 → 独自の LLM 呼び出し → bash \"python manage.py migrate\"\n5. bob スレッド起動 → 独自の LLM 呼び出し → write_file(\"client.ts\", ...)\n6. alice 完了 → BUS.send(\"alice\", \"lead\", \"Schema done: users, orders tables\")\n7. bob 完了 → BUS.send(\"bob\", \"lead\", \"Client written with types\")\n8. Lead 次回反復 → inbox を history に注入 → LLM が alice と bob の結果を確認\n```\n\n2 人のチームメイトが並行作業。\n\n---\n\n## s14 からの変更\n\n| コンポーネント | 変更前 (s14) | 変更後 (s15) |\n|--------------|------------|------------|\n| Agent 数 | 1 | 1 Lead + N チームメイトスレッド |\n| 通信 | なし | MessageBus + .mailboxes/*.jsonl |\n| 新規クラス | — | MessageBus, active_teammates dict |\n| 新規関数 | — | spawn_teammate_thread, run_send_message, run_check_inbox |\n| Lead ツール | 11 (s14) | + spawn_teammate, send_message, check_inbox (14) |\n| チームメイトツール | — | bash, read_file, write_file, send_message (4) |\n| 権限 | ローカル判断 | 教学版は省略(真实 Claude Code はバブリング機構あり) |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s15_agent_teams/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `Spawn alice as a backend developer. Ask her to create a file called schema.sql with a users table.`\n2. `Check your inbox for alice's result.`\n3. `Spawn bob as a tester. Ask him to check if schema.sql exists and list its contents.`\n\n観察ポイント:Lead はチームメイトをどう起動するか?`.mailboxes/` ディレクトリの JSONL ファイルの中身は?チームメイト完了後、Lead の inbox は history に注入されているか?\n\n---\n\n## 次の章\n\nチームメイトは仕事をし、通信できる。しかし、Lead が Alice にシャットダウンを頼む場合、スレッドを強制終了すると書きかけのファイルが残る。丁寧なシャットダウンプロトコルが必要:Lead が shutdown_request を送信、チームメイトは收尾後に終了。\n\ns16 Team Protocols → シャットダウンハンドシェイクとメッセージの取り決め。\n\n
\nClaude Code ソースコード深掘り\n\n> 以下は Claude Code ソースコード `spawnMultiAgent.ts`、`useInboxPoller.ts`(969 行)、`useSwarmPermissionPoller.ts`(330 行)、`teammateMailbox.ts`、`teamHelpers.ts` の完全分析に基づく。\n\n### 一、中央メッセージバスはない、ファイルシステム\n\n教学版は `MessageBus` クラスでメッセージを送受信。真实 Claude Code はもっと直接的、各 Agent が他の Agent の受信箱ファイルに直接書き込む。\n\n受信箱パス:`~/.claude/teams/{teamName}/inboxes/{agentName}.json`\n\n書き込み時は `proper-lockfile` で並行安全性を確保(最大 10 回リトライ)。各ファイルは JSON 配列、append 時に読み取り→追加→書き戻し。\n\n### 二、15 種のメッセージ型\n\nClaude Code のチーム通信には 15 種の構造化メッセージ(`teammateMailbox.ts`)がある:\n\n| 型 | 方向 | 用途 |\n|------|------|------|\n| `plain text` | 双方向 | 通常のチームメイト間通信 |\n| `idle_notification` | チームメイト→Lead | チームメイトが 1 ターン完了、アイドル状態に |\n| `permission_request` | チームメイト→Lead | 操作承認が必要 |\n| `permission_response` | Lead→チームメイト | Lead の承認結果 |\n| `plan_approval_request` | チームメイト→Lead | 計画提出、審査待ち |\n| `plan_approval_response` | Lead→チームメイト | Lead の計画審査 |\n| `shutdown_request` | Lead→チームメイト | 丁寧なシャットダウン要求 |\n| `shutdown_approved` | チームメイト→Lead | シャットダウン確認 |\n| `shutdown_rejected` | チームメイト→Lead | シャットダウン拒否(理由付き) |\n| `task_assignment` | Lead→チームメイト | タスク割り当て |\n| `team_permission_update` | Lead→チームメイト | 権限変更のブロードキャスト |\n| `mode_set_request` | Lead→チームメイト | チームメイトの権限モード変更 |\n| `sandbox_permission_*` | 双方向 | ネットワーク権限リクエスト/返信 |\n| `teammate_terminated` | システム | チームメイト削除通知 |\n\nテキストメッセージは `` XML タグでラップされモデルに配信。\n\n### 三、権限バブリング:双方向ポーリング\n\n教学版は権限バブリングを省略。真实 Claude Code のフロー(`permissionSync.ts`):\n\n1. **チームメイト**が承認が必要な操作に遭遇 → `permission_request` を Lead の受信箱に送信\n2. **Lead** の `useInboxPoller`(1 秒ごとにポーリング)がリクエストを検出 → `ToolUseConfirmQueue` にルーティング\n3. Lead の UI にチームメイト名と色付きの承認ダイアログを表示\n4. ユーザー承認後 → Lead が `permission_response` をチームメイトの受信箱に返信\n5. **チームメイト**の `useSwarmPermissionPoller`(500ms ごとにポーリング)が返信を受信 → 続行または拒否\n\n### 四、チームメイトライフサイクル\n\nClaude Code のチームメイトは `spawnTeammate()`(`spawnMultiAgent.ts`)で作成:\n\n1. **Spawn**:tmux ペイン(またはプロセス内)を作成、色を割り当て、team config に書き込み\n2. **Work**:`useInboxPoller` が毎秒受信箱をチェック → メッセージ到着時に新しい turn として送信\n3. **Idle**:Stop hook 発火 → `idle_notification` を Lead に送信\n4. **Shutdown**:Lead が `shutdown_request` を送信 → チームメイトが `shutdown_approved` で返信 → Lead がクリーンアップ\n\n### 五、Team Config\n\nチーム登録は `~/.claude/teams/{teamName}/config.json`(`teamHelpers.ts`):\n\n```json\n{\n \"name\": \"my-team\",\n \"leadAgentId\": \"lead@my-team\",\n \"members\": [{\n \"agentId\": \"researcher@my-team\",\n \"name\": \"researcher\",\n \"agentType\": \"general-purpose\",\n \"color\": \"blue\",\n \"isActive\": true\n }]\n}\n```\n\nチームメイトのネストは禁止(`AgentTool.tsx:273` で \"teammates spawning other teammates\" を明示的に禁止)。\n\n
\n\n\n" }, { "version": "s16", "locale": "en", "title": "s16: Team Protocols — Teammates Need Agreements", - "content": "# s16: Team Protocols — Teammates Need Agreements\n\ns01 → ... → s14 → s15 → `s16` → [s17](/en/s17) → s18 → s19 → s20\n> *\"Teammates need agreements\"* — request-response pattern drives all negotiation.\n>\n> **Harness Layer**: Protocols — Structured handshakes between agents.\n\n---\n\n## The Problem\n\ns15's teammates can work, but coordination is loose: Lead sends a message, teammate replies, no structured protocol. Two scenarios expose the gap:\n\n**Shutdown**: Lead wants Alice to shut down. Killing the thread outright leaves half-written files on disk. A handshake is needed: Lead sends a request, Alice confirms after wrapping up.\n\n**Plan approval**: Bob wants to refactor the auth module, a high-risk operation. Lead should review Bob's plan first, approve before Bob proceeds.\n\nBoth scenarios share the same structure: one side sends a request, the other replies, both linked by the same ID. A state machine tracks: pending → approved / rejected.\n\n---\n\n## The Solution\n\n![Team Protocols Overview](/course-assets/s16_team_protocols/team-protocols-overview.en.svg)\n\nTeaching code continues the agent capability arc from earlier chapters and adds structured protocols on top of S15's team communication. To stay focused on the protocol mechanism, it omits full error recovery, memory, and skill systems. Added: **ProtocolState** (request state tracking), **dispatch_message** (routes incoming messages by type to handlers), **match_response** (correlates response to request via request_id, with type validation).\n\nTwo protocols, one mechanism:\n\n| Protocol | Direction | Purpose |\n|----------|-----------|---------|\n| shutdown_request / response | Lead → Teammate | Graceful shutdown handshake |\n| plan_approval_request / response | Teammate → Lead | Plan approval protocol example |\n\n> Teaching version demonstrates the request-response message flow for plan approval, but does not implement execution gating (intercepting bash/write_file when not approved). Real CC has a permission gating mechanism for teammates.\n\n---\n\n## How It Works\n\n### ProtocolState: Request State\n\nEach protocol request creates a state record tracking who sent it, to whom, current status, and payload:\n\n```python\n@dataclass\nclass ProtocolState:\n request_id: str # Unique ID, e.g. \"req_004281\"\n type: str # \"shutdown\" | \"plan_approval\"\n sender: str # Sender\n target: str # Recipient\n status: str # pending | approved | rejected\n payload: str # Plan text or shutdown reason\n created_at: float # Timestamp\n\npending_requests: dict[str, ProtocolState] = {}\n```\n\nA record is created when sending a request, found via `request_id` when receiving a response, and its status updated.\n\n### Four-Step Protocol Flow\n\nUsing shutdown as an example, the full chain:\n\n```\n1. Lead sends request\n req_id = new_request_id() # \"req_004281\"\n pending_requests[req_id] = ProtocolState(type=\"shutdown\", status=\"pending\", ...)\n BUS.send(\"lead\", \"alice\", \"shutdown_request\", metadata={\"request_id\": req_id})\n\n2. Teammate receives → dispatch\n inbox = BUS.read_inbox(\"alice\")\n msg_type = msg[\"type\"] # \"shutdown_request\"\n → routed to handle_shutdown_request()\n\n3. Teammate replies\n BUS.send(\"alice\", \"lead\", \"shutdown_response\",\n metadata={\"request_id\": req_id, \"approve\": True})\n\n4. Lead receives response → match\n match_response(\"shutdown_response\", req_id, approve=True)\n pending_requests[req_id].status = \"approved\"\n```\n\n`request_id` is the correlation key across the entire chain: the request carries it out, the response carries it back.\n\n### dispatch_message: Route by Type\n\nA teammate's inbox receives both plain messages and protocol messages. `handle_inbox_message` dispatches by message type:\n\n```python\ndef handle_inbox_message(name, msg, messages):\n msg_type = msg.get(\"type\", \"message\")\n req_id = msg.get(\"metadata\", {}).get(\"request_id\", \"\")\n\n if msg_type == \"shutdown_request\":\n BUS.send(name, \"lead\", \"Shutting down.\", \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n return True # Stop the loop\n\n if msg_type == \"plan_approval_response\":\n approve = msg[\"metadata\"].get(\"approve\", False)\n messages.append({\"role\": \"user\",\n \"content\": \"[Plan approved]\" if approve else \"[Plan rejected]\"})\n return False # Continue\n```\n\nAdding a new protocol type means adding a new `if` branch.\n\n### match_response: Type Validation\n\n`match_response` doesn't just find state by `request_id`, it also validates that the response type matches the request type:\n\n```python\ndef match_response(response_type, request_id, approve):\n state = pending_requests.get(request_id)\n if not state:\n return\n if state.type == \"shutdown\" and response_type != \"shutdown_response\":\n return # type mismatch, skip\n if state.type == \"plan_approval\" and response_type != \"plan_approval_response\":\n return\n if state.status != \"pending\":\n return # already resolved, skip duplicate\n state.status = \"approved\" if approve else \"rejected\"\n```\n\nA shutdown_response cannot accidentally approve a plan_approval request.\n\n### Unified Inbox Consumer: consume_lead_inbox\n\nBoth the `check_inbox` tool and the main loop call the same `consume_lead_inbox()` function, routing protocol messages before returning remaining content. This prevents messages from being consumed without protocol state updates:\n\n```python\ndef consume_lead_inbox(route_protocol=True) -> list[dict]:\n msgs = BUS.read_inbox(\"lead\")\n if route_protocol:\n for msg in msgs:\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n msg_type = msg.get(\"type\", \"\")\n if req_id and msg_type.endswith(\"_response\"):\n match_response(msg_type, req_id, meta.get(\"approve\", False))\n return msgs\n```\n\nThe main loop also injects inbox messages into `history` so the LLM can see and react to them.\n\n### Teammate Idle Loop: Wait Instead of Exit\n\ns15's teammates exit after 10 rounds. s16's teammates enter idle waiting after the LLM returns a non-tool_use response: poll inbox, respond to shutdown_request and exit, or continue working on new messages.\n\n```\nLLM returns non-tool_use\n → idle: poll inbox every second\n → receives shutdown_request → reply shutdown_response → exit\n → receives new message → inject into messages → continue LLM turn\n```\n\nTeaching version omits idle_notification to Lead. Real CC sends `idle_notification` when idle, so Lead knows the teammate is free for new tasks.\n\n### Putting It Together\n\n```\n1. Lead: \"Have Alice create a file, then shut her down\"\n2. Lead → spawn_teammate(\"alice\", \"backend\", \"Create config.py\")\n3. alice thread starts → write_file(\"config.py\", \"...\") → done → idle\n4. Lead → request_shutdown(\"alice\")\n → BUS.send(\"shutdown_request\", {request_id: \"req_000142\"})\n5. alice idle poll receives → handle_shutdown_request\n → BUS.send(\"shutdown_response\", {request_id: \"req_000142\", approve: True})\n6. Lead consume_lead_inbox → match_response(\"req_000142\", approve=True)\n → pending_requests[\"req_000142\"].status = \"approved\"\n → inbox message injected into history, LLM sees shutdown result\n```\n\nShutdown handshake complete: request → confirm → shutdown. Every step tracked by `request_id`.\n\n---\n\n## Changes from s15\n\n| Component | Before (s15) | After (s16) |\n|-----------|-------------|-------------|\n| Coordination | Loose text messages | Structured request-response protocol |\n| Request tracking | None | ProtocolState + pending_requests dict |\n| Message routing | All treated as text | dispatch_message routes by type |\n| Shutdown | Natural exit or kill thread | request_id handshake mechanism |\n| Plan approval | None | Message flow example (no execution gating) |\n| New message types | message, result | + shutdown_request/response, plan_approval_request/response |\n| Teammate lifecycle | Max 10 rounds | Idle loop (waits for inbox messages) |\n| Lead inbox | check_inbox and main loop read separately | Unified consume_lead_inbox |\n| Lead tools | 14 (s15) | 14 (core tool set plus request_shutdown, request_plan, review_plan) |\n| Teammate tools | 4 (s15) | + submit_plan (5) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s16_team_protocols/code.py\n```\n\nTry these prompts:\n\n1. `Spawn alice as a backend dev. Ask her to create a file. Then request her shutdown.`\n2. `Spawn bob with a refactoring task. Have him submit a plan first. Then review and approve it.`\n\nWhat to observe: Is the shutdown handshake complete (request → confirm → shutdown)? Does `pending_requests` state transition correctly? Is `request_id` consistent between request and response? Can the idle teammate receive shutdown_request?\n\n---\n\n## What's Next\n\nIn s15-s16, Lead must assign tasks to each teammate. \"Alice does this, Bob does that.\" With 10 unclaimed tasks on the board, Lead has to manually assign each one.\n\nWhat if teammates could check the board and claim tasks themselves? Lead only needs to create tasks; teammates discover, claim, and complete them on their own.\n\ns17 Autonomous Agents → Self-organizing teammates, no leader assignment needed.\n\n
\nDeep Dive into CC Source\n\nCC's team protocol implementation (`teammateMailbox.ts`, 1184 lines) shares the same core structure as the teaching version: request_id + approve/reject request-response pattern. Differences:\n\n**Shutdown protocol**: CC's shutdown is three-way communication (`teammateMailbox.ts:720-763`, `SendMessageTool.ts:268-430`). Lead sends `shutdown_request`, teammate replies `shutdown_approved` (or `shutdown_rejected` with reason), system sends `teammate_terminated` to notify all parties. After confirmation, system cleans up pane (tmux/iTerm2), unassigns tasks, removes member from team config (`useInboxPoller.ts:677-800`). Teaching version uses `shutdown_response` as a unified name; real source splits into `shutdown_approved` and `shutdown_rejected` as two separate message types.\n\n**Plan approval**: In the real source, plan approval request is generated by `ExitPlanModeV2Tool.ts:263-312` when a plan-mode-required teammate exits plan mode. `useInboxPoller.ts:599-661` currently auto-writes approval and passes the request to Lead as context (regular message). `SendMessageTool.ts:434-518` retains explicit approve/reject response capability — approval can simultaneously set `permissionMode` (e.g. \"approved but run in plan mode\"), response can include `feedback` string for teammate to revise and resubmit. Not a simple \"Lead manually uses review_plan tool\" flow.\n\n**Message format**: CC's protocol messages are structured JSON (with Zod schema validation), teaching version uses simple type + metadata dict. Field names are also inconsistent: permission uses `request_id` (`teammateMailbox.ts:453-462`), shutdown and plan approval use `requestId` (`teammateMailbox.ts:684-763`).\n\n**Execution gating**: CC's teammates have full permission gating. Unapproved high-risk operations are intercepted, not optional. Teaching version only demonstrates the message flow without execution interception.\n\n**Generality**: Teaching version's single FSM (pending → approved | rejected) maps to two protocols. This simplification is correct. CC's protocol messages all share the same request id correlation mechanism.\n\n
\n\n\n" + "content": "# s16: Team Protocols — Teammates Need Agreements\n\ns01 → ... → s14 → s15 → `s16` → [s17](/en/s17) → s18 → s19 → s20\n> *\"Teammates need agreements\"* — request-response pattern drives all negotiation.\n>\n> **Harness Layer**: Protocols — Structured handshakes between agents.\n\n---\n\n## The Problem\n\ns15's teammates can work, but coordination is loose: Lead sends a message, teammate replies, no structured protocol. Two scenarios expose the gap:\n\n**Shutdown**: Lead wants Alice to shut down. Killing the thread outright leaves half-written files on disk. A handshake is needed: Lead sends a request, Alice confirms after wrapping up.\n\n**Plan approval**: Bob wants to refactor the auth module, a high-risk operation. Lead should review Bob's plan first, approve before Bob proceeds.\n\nBoth scenarios share the same structure: one side sends a request, the other replies, both linked by the same ID. A state machine tracks: pending → approved / rejected.\n\n---\n\n## The Solution\n\n![Team Protocols Overview](/course-assets/s16_team_protocols/team-protocols-overview.svg)\n\nTeaching code continues the agent capability arc from earlier chapters and adds structured protocols on top of S15's team communication. To stay focused on the protocol mechanism, it omits full error recovery, memory, and skill systems. Added: **ProtocolState** (request state tracking), **dispatch_message** (routes incoming messages by type to handlers), **match_response** (correlates response to request via request_id, with type validation).\n\nTwo protocols, one mechanism:\n\n| Protocol | Direction | Purpose |\n|----------|-----------|---------|\n| shutdown_request / response | Lead → Teammate | Graceful shutdown handshake |\n| plan_approval_request / response | Teammate → Lead | Plan approval protocol example |\n\n> Teaching version demonstrates the request-response message flow for plan approval, but does not implement execution gating (intercepting bash/write_file when not approved). Real Claude Code has a permission gating mechanism for teammates.\n\n---\n\n## How It Works\n\n### ProtocolState: Request State\n\nEach protocol request creates a state record tracking who sent it, to whom, current status, and payload:\n\n```python\n@dataclass\nclass ProtocolState:\n request_id: str # Unique ID, e.g. \"req_004281\"\n type: str # \"shutdown\" | \"plan_approval\"\n sender: str # Sender\n target: str # Recipient\n status: str # pending | approved | rejected\n payload: str # Plan text or shutdown reason\n created_at: float # Timestamp\n\npending_requests: dict[str, ProtocolState] = {}\n```\n\nA record is created when sending a request, found via `request_id` when receiving a response, and its status updated.\n\n### Four-Step Protocol Flow\n\nUsing shutdown as an example, the full chain:\n\n```\n1. Lead sends request\n req_id = new_request_id() # \"req_004281\"\n pending_requests[req_id] = ProtocolState(type=\"shutdown\", status=\"pending\", ...)\n BUS.send(\"lead\", \"alice\", \"shutdown_request\", metadata={\"request_id\": req_id})\n\n2. Teammate receives → dispatch\n inbox = BUS.read_inbox(\"alice\")\n msg_type = msg[\"type\"] # \"shutdown_request\"\n → routed to handle_shutdown_request()\n\n3. Teammate replies\n BUS.send(\"alice\", \"lead\", \"shutdown_response\",\n metadata={\"request_id\": req_id, \"approve\": True})\n\n4. Lead receives response → match\n match_response(\"shutdown_response\", req_id, approve=True)\n pending_requests[req_id].status = \"approved\"\n```\n\n`request_id` is the correlation key across the entire chain: the request carries it out, the response carries it back.\n\n### dispatch_message: Route by Type\n\nA teammate's inbox receives both plain messages and protocol messages. `handle_inbox_message` dispatches by message type:\n\n```python\ndef handle_inbox_message(name, msg, messages):\n msg_type = msg.get(\"type\", \"message\")\n req_id = msg.get(\"metadata\", {}).get(\"request_id\", \"\")\n\n if msg_type == \"shutdown_request\":\n BUS.send(name, \"lead\", \"Shutting down.\", \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n return True # Stop the loop\n\n if msg_type == \"plan_approval_response\":\n approve = msg[\"metadata\"].get(\"approve\", False)\n messages.append({\"role\": \"user\",\n \"content\": \"[Plan approved]\" if approve else \"[Plan rejected]\"})\n return False # Continue\n```\n\nAdding a new protocol type means adding a new `if` branch.\n\n### match_response: Type Validation\n\n`match_response` doesn't just find state by `request_id`, it also validates that the response type matches the request type:\n\n```python\ndef match_response(response_type, request_id, approve):\n state = pending_requests.get(request_id)\n if not state:\n return\n if state.type == \"shutdown\" and response_type != \"shutdown_response\":\n return # type mismatch, skip\n if state.type == \"plan_approval\" and response_type != \"plan_approval_response\":\n return\n if state.status != \"pending\":\n return # already resolved, skip duplicate\n state.status = \"approved\" if approve else \"rejected\"\n```\n\nA shutdown_response cannot accidentally approve a plan_approval request.\n\n### Unified Inbox Consumer: consume_lead_inbox\n\nBoth the `check_inbox` tool and the main loop call the same `consume_lead_inbox()` function, routing protocol messages before returning remaining content. This prevents messages from being consumed without protocol state updates:\n\n```python\ndef consume_lead_inbox(route_protocol=True) -> list[dict]:\n msgs = BUS.read_inbox(\"lead\")\n if route_protocol:\n for msg in msgs:\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n msg_type = msg.get(\"type\", \"\")\n if req_id and msg_type.endswith(\"_response\"):\n match_response(msg_type, req_id, meta.get(\"approve\", False))\n return msgs\n```\n\nThe main loop also injects inbox messages into `history` so the LLM can see and react to them.\n\n### Teammate Idle Loop: Wait Instead of Exit\n\ns15's teammates exit after 10 rounds. s16's teammates enter idle waiting after the LLM returns a non-tool_use response: poll inbox, respond to shutdown_request and exit, or continue working on new messages.\n\n```\nLLM returns non-tool_use\n → idle: poll inbox every second\n → receives shutdown_request → reply shutdown_response → exit\n → receives new message → inject into messages → continue LLM turn\n```\n\nTeaching version omits idle_notification to Lead. Real Claude Code sends `idle_notification` when idle, so Lead knows the teammate is free for new tasks.\n\n### Putting It Together\n\n```\n1. Lead: \"Have Alice create a file, then shut her down\"\n2. Lead → spawn_teammate(\"alice\", \"backend\", \"Create config.py\")\n3. alice thread starts → write_file(\"config.py\", \"...\") → done → idle\n4. Lead → request_shutdown(\"alice\")\n → BUS.send(\"shutdown_request\", {request_id: \"req_000142\"})\n5. alice idle poll receives → handle_shutdown_request\n → BUS.send(\"shutdown_response\", {request_id: \"req_000142\", approve: True})\n6. Lead consume_lead_inbox → match_response(\"req_000142\", approve=True)\n → pending_requests[\"req_000142\"].status = \"approved\"\n → inbox message injected into history, LLM sees shutdown result\n```\n\nShutdown handshake complete: request → confirm → shutdown. Every step tracked by `request_id`.\n\n---\n\n## Changes from s15\n\n| Component | Before (s15) | After (s16) |\n|-----------|-------------|-------------|\n| Coordination | Loose text messages | Structured request-response protocol |\n| Request tracking | None | ProtocolState + pending_requests dict |\n| Message routing | All treated as text | dispatch_message routes by type |\n| Shutdown | Natural exit or kill thread | request_id handshake mechanism |\n| Plan approval | None | Message flow example (no execution gating) |\n| New message types | message, result | + shutdown_request/response, plan_approval_request/response |\n| Teammate lifecycle | Max 10 rounds | Idle loop (waits for inbox messages) |\n| Lead inbox | check_inbox and main loop read separately | Unified consume_lead_inbox |\n| Lead tools | 14 (s15) | 14 (core tool set plus request_shutdown, request_plan, review_plan) |\n| Teammate tools | 4 (s15) | + submit_plan (5) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s16_team_protocols/code.py\n```\n\nTry these prompts:\n\n1. `Spawn alice as a backend dev. Ask her to create a file. Then request her shutdown.`\n2. `Spawn bob with a refactoring task. Have him submit a plan first. Then review and approve it.`\n\nWhat to observe: Is the shutdown handshake complete (request → confirm → shutdown)? Does `pending_requests` state transition correctly? Is `request_id` consistent between request and response? Can the idle teammate receive shutdown_request?\n\n---\n\n## What's Next\n\nIn s15-s16, Lead must assign tasks to each teammate. \"Alice does this, Bob does that.\" With 10 unclaimed tasks on the board, Lead has to manually assign each one.\n\nWhat if teammates could check the board and claim tasks themselves? Lead only needs to create tasks; teammates discover, claim, and complete them on their own.\n\ns17 Autonomous Agents → Self-organizing teammates, no leader assignment needed.\n\n
\nDeep Dive into Claude Code Source\n\nClaude Code's team protocol implementation (`teammateMailbox.ts`, 1184 lines) shares the same core structure as the teaching version: request_id + approve/reject request-response pattern. Differences:\n\n**Shutdown protocol**: Claude Code's shutdown is three-way communication (`teammateMailbox.ts:720-763`, `SendMessageTool.ts:268-430`). Lead sends `shutdown_request`, teammate replies `shutdown_approved` (or `shutdown_rejected` with reason), system sends `teammate_terminated` to notify all parties. After confirmation, system cleans up pane (tmux/iTerm2), unassigns tasks, removes member from team config (`useInboxPoller.ts:677-800`). Teaching version uses `shutdown_response` as a unified name; real source splits into `shutdown_approved` and `shutdown_rejected` as two separate message types.\n\n**Plan approval**: In the real source, plan approval request is generated by `ExitPlanModeV2Tool.ts:263-312` when a plan-mode-required teammate exits plan mode. `useInboxPoller.ts:599-661` currently auto-writes approval and passes the request to Lead as context (regular message). `SendMessageTool.ts:434-518` retains explicit approve/reject response capability — approval can simultaneously set `permissionMode` (e.g. \"approved but run in plan mode\"), response can include `feedback` string for teammate to revise and resubmit. Not a simple \"Lead manually uses review_plan tool\" flow.\n\n**Message format**: Claude Code's protocol messages are structured JSON (with Zod schema validation), teaching version uses simple type + metadata dict. Field names are also inconsistent: permission uses `request_id` (`teammateMailbox.ts:453-462`), shutdown and plan approval use `requestId` (`teammateMailbox.ts:684-763`).\n\n**Execution gating**: Claude Code's teammates have full permission gating. Unapproved high-risk operations are intercepted, not optional. Teaching version only demonstrates the message flow without execution interception.\n\n**Generality**: Teaching version's single FSM (pending → approved | rejected) maps to two protocols. This simplification is correct. Claude Code's protocol messages all share the same request id correlation mechanism.\n\n
\n\n\n" }, { "version": "s16", "locale": "zh", "title": "s16: Team Protocols — 队友之间要有约定", - "content": "# s16: Team Protocols — 队友之间要有约定\n\ns01 → ... → s14 → s15 → `s16` → [s17](/zh/s17) → s18 → s19 → s20\n> *\"队友之间要有约定\"* — request-response 模式驱动协商。\n>\n> **Harness 层**: 协议 — Agent 之间的结构化握手。\n\n---\n\n## 问题\n\ns15 的队友能干活了,但协调是松散的:Lead 发消息,队友回复,没有结构化的协议。两个场景暴露了问题:\n\n**关机**:Lead 想让 Alice 关机。直接杀线程,Alice 写了一半的文件留在磁盘上。需要握手:Lead 发请求,Alice 确认收尾后关机。\n\n**计划审批**:Bob 想重构认证模块,属于高风险操作。应该先让 Lead 看 Bob 的计划,审批通过后再动手。\n\n这两个场景结构完全一样:一方发请求,另一方给回复,请求和回复通过同一个 ID 关联。有状态机追踪:pending → approved / rejected。\n\n---\n\n## 解决方案\n\n![Team Protocols Overview](/course-assets/s16_team_protocols/team-protocols-overview.svg)\n\n教学代码承接前面章节的 Agent 能力脉络,在 S15 团队通信基础上加入结构化协议。为了聚焦协议机制,省略了完整错误恢复、记忆和技能系统。新增三样:**ProtocolState**(请求状态追踪)、**dispatch_message**(按消息类型路由到处理器)、**match_response**(通过 request_id 关联回复与请求,含类型校验)。\n\n两种协议,一套机制:\n\n| 协议 | 方向 | 用途 |\n|------|------|------|\n| shutdown_request / response | Lead → 队友 | 体面关机握手 |\n| plan_approval_request / response | 队友 → Lead | 计划审批协议示例 |\n\n> 教学版演示了计划审批的请求-响应消息流程,没有实现执行门控(未 approved 时拦截 bash/write_file)。真实 CC 的队友有 permission gating 机制。\n\n---\n\n## 工作原理\n\n### ProtocolState: 请求状态\n\n每个协议请求创建一条状态记录,记录谁发的、发给谁、当前状态、附带内容:\n\n```python\n@dataclass\nclass ProtocolState:\n request_id: str # 唯一 ID,如 \"req_004281\"\n type: str # \"shutdown\" | \"plan_approval\"\n sender: str # 发起方\n target: str # 接收方\n status: str # pending | approved | rejected\n payload: str # 计划文本或关机原因\n created_at: float # 时间戳\n\npending_requests: dict[str, ProtocolState] = {}\n```\n\n发请求时创建记录,收回复时通过 `request_id` 找到对应记录,更新状态。\n\n### 四步协议流程\n\n以关机为例,完整链路:\n\n```\n① Lead 发请求\n req_id = new_request_id() # \"req_004281\"\n pending_requests[req_id] = ProtocolState(type=\"shutdown\", status=\"pending\", ...)\n BUS.send(\"lead\", \"alice\", \"shutdown_request\", metadata={\"request_id\": req_id})\n\n② 队友收到 → dispatch\n inbox = BUS.read_inbox(\"alice\")\n msg_type = msg[\"type\"] # \"shutdown_request\"\n → 路由到 handle_shutdown_request()\n\n③ 队友回复\n BUS.send(\"alice\", \"lead\", \"shutdown_response\",\n metadata={\"request_id\": req_id, \"approve\": True})\n\n④ Lead 收响应 → match\n match_response(\"shutdown_response\", req_id, approve=True)\n pending_requests[req_id].status = \"approved\"\n```\n\n`request_id` 是贯穿全链路的关联键,请求带着它出去,回复带着它回来。\n\n> 教学版用 `shutdown_response` 统一命名(approve 字段区分同意/拒绝)。真实源码拆成 `shutdown_approved` 和 `shutdown_rejected` 两种独立消息类型(`teammateMailbox.ts:720-763`)。\n\n### dispatch_message: 按类型路由\n\n队友的 inbox 不只收普通消息,还收协议消息。`handle_inbox_message` 按消息类型分发:\n\n```python\ndef handle_inbox_message(name, msg, messages):\n msg_type = msg.get(\"type\", \"message\")\n req_id = msg.get(\"metadata\", {}).get(\"request_id\", \"\")\n\n if msg_type == \"shutdown_request\":\n BUS.send(name, \"lead\", \"Shutting down.\", \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n return True # 停止循环\n\n if msg_type == \"plan_approval_response\":\n approve = msg[\"metadata\"].get(\"approve\", False)\n messages.append({\"role\": \"user\",\n \"content\": \"[Plan approved]\" if approve else \"[Plan rejected]\"})\n return False # 继续循环\n```\n\n新增协议类型只需加新的 `if` 分支。\n\n### match_response: 类型校验\n\n`match_response` 不只按 `request_id` 找状态,还会校验响应类型是否匹配请求类型:\n\n```python\ndef match_response(response_type, request_id, approve):\n state = pending_requests.get(request_id)\n if not state:\n return\n if state.type == \"shutdown\" and response_type != \"shutdown_response\":\n return # type mismatch, skip\n if state.type == \"plan_approval\" and response_type != \"plan_approval_response\":\n return\n if state.status != \"pending\":\n return # already resolved, skip duplicate\n state.status = \"approved\" if approve else \"rejected\"\n```\n\n一个 shutdown_response 不会意外 approve 一个 plan_approval 请求。\n\n### 统一 inbox 消费:consume_lead_inbox\n\n`check_inbox` 工具和主循环末尾都调用同一个 `consume_lead_inbox()` 函数,先路由协议消息再返回剩余内容,避免消息被读走但协议状态没更新:\n\n```python\ndef consume_lead_inbox(route_protocol=True) -> list[dict]:\n msgs = BUS.read_inbox(\"lead\")\n if route_protocol:\n for msg in msgs:\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n msg_type = msg.get(\"type\", \"\")\n if req_id and msg_type.endswith(\"_response\"):\n match_response(msg_type, req_id, meta.get(\"approve\", False))\n return msgs\n```\n\n主循环末尾还会把 inbox 消息注入到 `history`,让 LLM 能看到并做出反应。\n\n### 队友 idle loop:等待而不是退出\n\ns15 的队友跑完 10 轮就退出。s16 的队友在 LLM 返回非 tool_use 后进入 idle 等待:轮询 inbox,收到 shutdown_request 就响应退出,收到新消息就继续工作。\n\n```\nLLM 返回非 tool_use\n → idle: 每秒轮询 inbox\n → 收到 shutdown_request → 回复 shutdown_response → 退出\n → 收到新消息 → 注入 messages → 继续 LLM turn\n```\n\n教学版省略了 idle_notification 给 Lead 的通知。真实 CC 在 idle 时发 `idle_notification`,Lead 收到后知道队友空闲,可以分配新任务。\n\n### 合起来跑\n\n```\n1. Lead: \"让 Alice 创建一个文件,然后关机\"\n2. Lead → spawn_teammate(\"alice\", \"backend\", \"创建 config.py\")\n3. alice 线程启动 → write_file(\"config.py\", \"...\") → 完成 → idle\n4. Lead → request_shutdown(\"alice\")\n → BUS.send(\"shutdown_request\", {request_id: \"req_000142\"})\n5. alice idle 轮询收到 → handle_shutdown_request\n → BUS.send(\"shutdown_response\", {request_id: \"req_000142\", approve: True})\n6. Lead consume_lead_inbox → match_response(\"req_000142\", approve=True)\n → pending_requests[\"req_000142\"].status = \"approved\"\n → inbox 消息注入 history,LLM 看到关机结果\n```\n\n关机握手完整:请求 → 确认 → 关机。每一步有 `request_id` 追溯。\n\n---\n\n## 相对 s15 的变更\n\n| 组件 | 之前 (s15) | 之后 (s16) |\n|------|-----------|-----------|\n| 协调方式 | 松散文本消息 | 结构化请求-响应协议 |\n| 请求追踪 | 无 | ProtocolState + pending_requests dict |\n| 消息路由 | 全部当文本处理 | dispatch_message 按类型分发 |\n| 关机 | 自然退出或杀线程 | request_id 握手机制 |\n| 计划审批 | 无 | 消息流程示例(未实现执行门控) |\n| 新消息类型 | message, result | + shutdown_request/response, plan_approval_request/response |\n| 队友生命周期 | 最多 10 轮 | idle loop(等待 inbox 消息) |\n| Lead inbox | check_inbox 和主循环分别读 | 统一 consume_lead_inbox |\n| Lead 工具 | 14 (s15) | 14(核心工具集加入 request_shutdown, request_plan, review_plan) |\n| 队友工具 | 4 (s15) | + submit_plan (5) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s16_team_protocols/code.py\n```\n\n试试这些 prompt:\n\n1. `Spawn alice as a backend dev. Ask her to create a file. Then request her shutdown.`\n2. `Spawn bob with a refactoring task. Have him submit a plan first. Then review and approve it.`\n\n观察重点:关机握手是否完整(请求 → 确认 → 关机)?`pending_requests` 的状态是否正确转换?`request_id` 是否在请求和响应之间保持一致?队友 idle 后是否能收到 shutdown_request?\n\n---\n\n## 接下来\n\ns15-s16 中,Lead 必须给每个队友分配任务。\"Alice 做这个,Bob 做那个\"。任务看板上有 10 个未认领的任务,Lead 得手动 assign。\n\n能不能让队友自己看板、自己认领?Lead 只需要创建任务,队友自己发现、自己认领、自己完成。\n\ns17 Autonomous Agents → 队友自组织,不需要领导分配。\n\n
\n深入 CC 源码\n\nCC 的团队协议实现(`teammateMailbox.ts`,1184 行)和教学版在核心结构上一致:request_id + approve/reject 的请求-响应模式。差异在于:\n\n**关机协议**:CC 的 shutdown 是三向通信(`teammateMailbox.ts:720-763`、`SendMessageTool.ts:268-430`)。Lead 发 `shutdown_request`,队友回复 `shutdown_approved`(或 `shutdown_rejected` 附原因),系统发送 `teammate_terminated` 通知所有相关方。关机确认后系统自动清理 pane(tmux/iTerm2)、unassign 任务、从 team config 移除成员(`useInboxPoller.ts:677-800`)。教学版用 `shutdown_response` 统一命名,真实源码拆成 approved/rejected 两种独立消息。\n\n**计划审批**:真实源码里 plan approval request 由 `ExitPlanModeV2Tool.ts:263-312` 在 plan-mode-required 队友退出 plan mode 时产生。`useInboxPoller.ts:599-661` 当前会自动回写 approval,并把请求交给 Lead 作为上下文(regular message)。`SendMessageTool.ts:434-518` 仍保留显式 approve/reject response 能力,审批时可同时设置 `permissionMode`(如\"批准但以 plan mode 运行\"),响应中可包含 `feedback` 字符串供队友修正后重新提交。不是简单的\"Lead 手动 review_plan 工具\"流程。\n\n**消息格式**:CC 的协议消息是结构化的 JSON(有 Zod schema 验证),教学版用简单的 type + metadata 字典。字段名也不统一:permission 用 `request_id`(`teammateMailbox.ts:453-462`),shutdown 和 plan approval 用 `requestId`(`teammateMailbox.ts:684-763`)。\n\n**执行门控**:CC 的队友有完整的 permission gating。未获批准的高风险操作会被拦截,不是可选的。教学版只演示了消息流程,没有实现执行拦截。\n\n**通用性**:教学版的一个 FSM(pending → approved | rejected)对应两种协议,这个简化完全正确。CC 的所有协议消息共用同一个 request id 关联机制。\n\n
\n\n\n" + "content": "# s16: Team Protocols — 队友之间要有约定\n\ns01 → ... → s14 → s15 → `s16` → [s17](/zh/s17) → s18 → s19 → s20\n> *\"队友之间要有约定\"* — request-response 模式驱动协商。\n>\n> **Harness 层**: 协议 — Agent 之间的结构化握手。\n\n---\n\n## 问题\n\ns15 的队友能干活了,但协调是松散的:Lead 发消息,队友回复,没有结构化的协议。两个场景暴露了问题:\n\n**关机**:Lead 想让 Alice 关机。直接杀线程,Alice 写了一半的文件留在磁盘上。需要握手:Lead 发请求,Alice 确认收尾后关机。\n\n**计划审批**:Bob 想重构认证模块,属于高风险操作。应该先让 Lead 看 Bob 的计划,审批通过后再动手。\n\n这两个场景结构完全一样:一方发请求,另一方给回复,请求和回复通过同一个 ID 关联。有状态机追踪:pending → approved / rejected。\n\n---\n\n## 解决方案\n\n![Team Protocols Overview](/course-assets/s16_team_protocols/team-protocols-overview.svg)\n\n教学代码承接前面章节的 Agent 能力脉络,在 S15 团队通信基础上加入结构化协议。为了聚焦协议机制,省略了完整错误恢复、记忆和技能系统。新增三样:**ProtocolState**(请求状态追踪)、**dispatch_message**(按消息类型路由到处理器)、**match_response**(通过 request_id 关联回复与请求,含类型校验)。\n\n两种协议,一套机制:\n\n| 协议 | 方向 | 用途 |\n|------|------|------|\n| shutdown_request / response | Lead → 队友 | 体面关机握手 |\n| plan_approval_request / response | 队友 → Lead | 计划审批协议示例 |\n\n> 教学版演示了计划审批的请求-响应消息流程,没有实现执行门控(未 approved 时拦截 bash/write_file)。真实 Claude Code 的队友有 permission gating 机制。\n\n---\n\n## 工作原理\n\n### ProtocolState: 请求状态\n\n每个协议请求创建一条状态记录,记录谁发的、发给谁、当前状态、附带内容:\n\n```python\n@dataclass\nclass ProtocolState:\n request_id: str # 唯一 ID,如 \"req_004281\"\n type: str # \"shutdown\" | \"plan_approval\"\n sender: str # 发起方\n target: str # 接收方\n status: str # pending | approved | rejected\n payload: str # 计划文本或关机原因\n created_at: float # 时间戳\n\npending_requests: dict[str, ProtocolState] = {}\n```\n\n发请求时创建记录,收回复时通过 `request_id` 找到对应记录,更新状态。\n\n### 四步协议流程\n\n以关机为例,完整链路:\n\n```\n① Lead 发请求\n req_id = new_request_id() # \"req_004281\"\n pending_requests[req_id] = ProtocolState(type=\"shutdown\", status=\"pending\", ...)\n BUS.send(\"lead\", \"alice\", \"shutdown_request\", metadata={\"request_id\": req_id})\n\n② 队友收到 → dispatch\n inbox = BUS.read_inbox(\"alice\")\n msg_type = msg[\"type\"] # \"shutdown_request\"\n → 路由到 handle_shutdown_request()\n\n③ 队友回复\n BUS.send(\"alice\", \"lead\", \"shutdown_response\",\n metadata={\"request_id\": req_id, \"approve\": True})\n\n④ Lead 收响应 → match\n match_response(\"shutdown_response\", req_id, approve=True)\n pending_requests[req_id].status = \"approved\"\n```\n\n`request_id` 是贯穿全链路的关联键,请求带着它出去,回复带着它回来。\n\n> 教学版用 `shutdown_response` 统一命名(approve 字段区分同意/拒绝)。真实源码拆成 `shutdown_approved` 和 `shutdown_rejected` 两种独立消息类型(`teammateMailbox.ts:720-763`)。\n\n### dispatch_message: 按类型路由\n\n队友的 inbox 不只收普通消息,还收协议消息。`handle_inbox_message` 按消息类型分发:\n\n```python\ndef handle_inbox_message(name, msg, messages):\n msg_type = msg.get(\"type\", \"message\")\n req_id = msg.get(\"metadata\", {}).get(\"request_id\", \"\")\n\n if msg_type == \"shutdown_request\":\n BUS.send(name, \"lead\", \"Shutting down.\", \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n return True # 停止循环\n\n if msg_type == \"plan_approval_response\":\n approve = msg[\"metadata\"].get(\"approve\", False)\n messages.append({\"role\": \"user\",\n \"content\": \"[Plan approved]\" if approve else \"[Plan rejected]\"})\n return False # 继续循环\n```\n\n新增协议类型只需加新的 `if` 分支。\n\n### match_response: 类型校验\n\n`match_response` 不只按 `request_id` 找状态,还会校验响应类型是否匹配请求类型:\n\n```python\ndef match_response(response_type, request_id, approve):\n state = pending_requests.get(request_id)\n if not state:\n return\n if state.type == \"shutdown\" and response_type != \"shutdown_response\":\n return # type mismatch, skip\n if state.type == \"plan_approval\" and response_type != \"plan_approval_response\":\n return\n if state.status != \"pending\":\n return # already resolved, skip duplicate\n state.status = \"approved\" if approve else \"rejected\"\n```\n\n一个 shutdown_response 不会意外 approve 一个 plan_approval 请求。\n\n### 统一 inbox 消费:consume_lead_inbox\n\n`check_inbox` 工具和主循环末尾都调用同一个 `consume_lead_inbox()` 函数,先路由协议消息再返回剩余内容,避免消息被读走但协议状态没更新:\n\n```python\ndef consume_lead_inbox(route_protocol=True) -> list[dict]:\n msgs = BUS.read_inbox(\"lead\")\n if route_protocol:\n for msg in msgs:\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n msg_type = msg.get(\"type\", \"\")\n if req_id and msg_type.endswith(\"_response\"):\n match_response(msg_type, req_id, meta.get(\"approve\", False))\n return msgs\n```\n\n主循环末尾还会把 inbox 消息注入到 `history`,让 LLM 能看到并做出反应。\n\n### 队友 idle loop:等待而不是退出\n\ns15 的队友跑完 10 轮就退出。s16 的队友在 LLM 返回非 tool_use 后进入 idle 等待:轮询 inbox,收到 shutdown_request 就响应退出,收到新消息就继续工作。\n\n```\nLLM 返回非 tool_use\n → idle: 每秒轮询 inbox\n → 收到 shutdown_request → 回复 shutdown_response → 退出\n → 收到新消息 → 注入 messages → 继续 LLM turn\n```\n\n教学版省略了 idle_notification 给 Lead 的通知。真实 Claude Code 在 idle 时发 `idle_notification`,Lead 收到后知道队友空闲,可以分配新任务。\n\n### 合起来跑\n\n```\n1. Lead: \"让 Alice 创建一个文件,然后关机\"\n2. Lead → spawn_teammate(\"alice\", \"backend\", \"创建 config.py\")\n3. alice 线程启动 → write_file(\"config.py\", \"...\") → 完成 → idle\n4. Lead → request_shutdown(\"alice\")\n → BUS.send(\"shutdown_request\", {request_id: \"req_000142\"})\n5. alice idle 轮询收到 → handle_shutdown_request\n → BUS.send(\"shutdown_response\", {request_id: \"req_000142\", approve: True})\n6. Lead consume_lead_inbox → match_response(\"req_000142\", approve=True)\n → pending_requests[\"req_000142\"].status = \"approved\"\n → inbox 消息注入 history,LLM 看到关机结果\n```\n\n关机握手完整:请求 → 确认 → 关机。每一步有 `request_id` 追溯。\n\n---\n\n## 相对 s15 的变更\n\n| 组件 | 之前 (s15) | 之后 (s16) |\n|------|-----------|-----------|\n| 协调方式 | 松散文本消息 | 结构化请求-响应协议 |\n| 请求追踪 | 无 | ProtocolState + pending_requests dict |\n| 消息路由 | 全部当文本处理 | dispatch_message 按类型分发 |\n| 关机 | 自然退出或杀线程 | request_id 握手机制 |\n| 计划审批 | 无 | 消息流程示例(未实现执行门控) |\n| 新消息类型 | message, result | + shutdown_request/response, plan_approval_request/response |\n| 队友生命周期 | 最多 10 轮 | idle loop(等待 inbox 消息) |\n| Lead inbox | check_inbox 和主循环分别读 | 统一 consume_lead_inbox |\n| Lead 工具 | 14 (s15) | 14(核心工具集加入 request_shutdown, request_plan, review_plan) |\n| 队友工具 | 4 (s15) | + submit_plan (5) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s16_team_protocols/code.py\n```\n\n试试这些 prompt:\n\n1. `Spawn alice as a backend dev. Ask her to create a file. Then request her shutdown.`\n2. `Spawn bob with a refactoring task. Have him submit a plan first. Then review and approve it.`\n\n观察重点:关机握手是否完整(请求 → 确认 → 关机)?`pending_requests` 的状态是否正确转换?`request_id` 是否在请求和响应之间保持一致?队友 idle 后是否能收到 shutdown_request?\n\n---\n\n## 接下来\n\ns15-s16 中,Lead 必须给每个队友分配任务。\"Alice 做这个,Bob 做那个\"。任务看板上有 10 个未认领的任务,Lead 得手动 assign。\n\n能不能让队友自己看板、自己认领?Lead 只需要创建任务,队友自己发现、自己认领、自己完成。\n\ns17 Autonomous Agents → 队友自组织,不需要领导分配。\n\n
\n深入 Claude Code 源码\n\nClaude Code 的团队协议实现(`teammateMailbox.ts`,1184 行)和教学版在核心结构上一致:request_id + approve/reject 的请求-响应模式。差异在于:\n\n**关机协议**:Claude Code 的 shutdown 是三向通信(`teammateMailbox.ts:720-763`、`SendMessageTool.ts:268-430`)。Lead 发 `shutdown_request`,队友回复 `shutdown_approved`(或 `shutdown_rejected` 附原因),系统发送 `teammate_terminated` 通知所有相关方。关机确认后系统自动清理 pane(tmux/iTerm2)、unassign 任务、从 team config 移除成员(`useInboxPoller.ts:677-800`)。教学版用 `shutdown_response` 统一命名,真实源码拆成 approved/rejected 两种独立消息。\n\n**计划审批**:真实源码里 plan approval request 由 `ExitPlanModeV2Tool.ts:263-312` 在 plan-mode-required 队友退出 plan mode 时产生。`useInboxPoller.ts:599-661` 当前会自动回写 approval,并把请求交给 Lead 作为上下文(regular message)。`SendMessageTool.ts:434-518` 仍保留显式 approve/reject response 能力,审批时可同时设置 `permissionMode`(如\"批准但以 plan mode 运行\"),响应中可包含 `feedback` 字符串供队友修正后重新提交。不是简单的\"Lead 手动 review_plan 工具\"流程。\n\n**消息格式**:Claude Code 的协议消息是结构化的 JSON(有 Zod schema 验证),教学版用简单的 type + metadata 字典。字段名也不统一:permission 用 `request_id`(`teammateMailbox.ts:453-462`),shutdown 和 plan approval 用 `requestId`(`teammateMailbox.ts:684-763`)。\n\n**执行门控**:Claude Code 的队友有完整的 permission gating。未获批准的高风险操作会被拦截,不是可选的。教学版只演示了消息流程,没有实现执行拦截。\n\n**通用性**:教学版的一个 FSM(pending → approved | rejected)对应两种协议,这个简化完全正确。Claude Code 的所有协议消息共用同一个 request id 关联机制。\n\n
\n\n\n" }, { "version": "s16", "locale": "ja", "title": "s16: Team Protocols — チームメイト間には取り決めが必要", - "content": "# s16: Team Protocols — チームメイト間には取り決めが必要\n\ns01 → ... → s14 → s15 → `s16` → [s17](/ja/s17) → s18 → s19 → s20\n> *\"チームメイト間には取り決めが必要\"* — request-response パターンが全てのネゴシエーションを駆動。\n>\n> **Harness 層**: プロトコル — Agent 間の構造化ハンドシェイク。\n\n---\n\n## 課題\n\ns15 のチームメイトは仕事ができるが、連携は緩い:Lead がメッセージを送り、チームメイトが返信するだけで、構造化されたプロトコルがない。2 つのシナリオで問題が露呈する:\n\n**シャットダウン**:Lead が Alice にシャットダウンを頼む。スレッドを強制終了すると、書きかけのファイルがディスクに残る。ハンドシェイクが必要:Lead がリクエストを送信、Alice が收尾後に確認。\n\n**計画承認**:Bob が認証モジュールのリファクタリングを提案、高リスク操作。Lead が Bob の計画を確認し、承認後に実行すべき。\n\nこれら 2 つのシナリオは同じ構造:一方がリクエストを送信、もう一方が返信、両者は同じ ID で関連付けられる。状態機械が追跡:pending → approved / rejected。\n\n---\n\n## ソリューション\n\n![Team Protocols Overview](/course-assets/s16_team_protocols/team-protocols-overview.ja.svg)\n\n教学版は前章までの Agent 能力の流れを受け継ぎ、S15 のチーム通信の上に構造化プロトコルを追加する。プロトコル機構に集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。追加:**ProtocolState**(リクエスト状態追跡)、**dispatch_message**(メッセージタイプ別ルーティング)、**match_response**(request_id でリクエストとレスポンスを関連付け、型検証付き)。\n\n2 つのプロトコル、1 つの仕組み:\n\n| プロトコル | 方向 | 用途 |\n|-----------|------|------|\n| shutdown_request / response | Lead → チームメイト | 丁寧なシャットダウンハンドシェイク |\n| plan_approval_request / response | チームメイト → Lead | 計画承認プロトコルの例 |\n\n> 教学版は計画承認の request-response メッセージフローをデモするが、実行ゲーティング(未承認時の bash/write_file 拦截)は未実装。真实 CC にはチームメイト向けの permission gating 機構がある。\n\n---\n\n## 仕組み\n\n### ProtocolState: リクエスト状態\n\n各プロトコルリクエストは、送信者、受信者、現在の状態、ペイロードを記録する状態レコードを作成:\n\n```python\n@dataclass\nclass ProtocolState:\n request_id: str # 一意 ID、例 \"req_004281\"\n type: str # \"shutdown\" | \"plan_approval\"\n sender: str # 送信者\n target: str # 受信者\n status: str # pending | approved | rejected\n payload: str # 計画テキストまたはシャットダウン理由\n created_at: float # タイムスタンプ\n\npending_requests: dict[str, ProtocolState] = {}\n```\n\nリクエスト送信時にレコードを作成、レスポンス受信時に `request_id` で該当レコードを見つけて状態を更新。\n\n### 4 ステッププロトコルフロー\n\nシャットダウンを例にした完全な流れ:\n\n```\n1. Lead がリクエスト送信\n req_id = new_request_id() # \"req_004281\"\n pending_requests[req_id] = ProtocolState(type=\"shutdown\", status=\"pending\", ...)\n BUS.send(\"lead\", \"alice\", \"shutdown_request\", metadata={\"request_id\": req_id})\n\n2. チームメイト受信 → dispatch\n inbox = BUS.read_inbox(\"alice\")\n msg_type = msg[\"type\"] # \"shutdown_request\"\n → handle_shutdown_request() にルーティング\n\n3. チームメイト返信\n BUS.send(\"alice\", \"lead\", \"shutdown_response\",\n metadata={\"request_id\": req_id, \"approve\": True})\n\n4. Lead がレスポンス受信 → match\n match_response(\"shutdown_response\", req_id, approve=True)\n pending_requests[req_id].status = \"approved\"\n```\n\n`request_id` はチェーン全体を貫く関連キー、リクエストが持ち出し、レスポンスが持ち帰る。\n\n### dispatch_message: タイプ別ルーティング\n\nチームメイトの inbox は通常メッセージとプロトコルメッセージの両方を受信。`handle_inbox_message` がメッセージタイプで振り分け:\n\n```python\ndef handle_inbox_message(name, msg, messages):\n msg_type = msg.get(\"type\", \"message\")\n req_id = msg.get(\"metadata\", {}).get(\"request_id\", \"\")\n\n if msg_type == \"shutdown_request\":\n BUS.send(name, \"lead\", \"Shutting down.\", \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n return True # ループ停止\n\n if msg_type == \"plan_approval_response\":\n approve = msg[\"metadata\"].get(\"approve\", False)\n messages.append({\"role\": \"user\",\n \"content\": \"[Plan approved]\" if approve else \"[Plan rejected]\"})\n return False # 継続\n```\n\n新しいプロトコルタイプの追加は新しい `if` 分岐を追加するだけ。\n\n### match_response: 型検証\n\n`match_response` は `request_id` で状態を見つけるだけでなく、レスポンスタイプがリクエストタイプと一致するか検証:\n\n```python\ndef match_response(response_type, request_id, approve):\n state = pending_requests.get(request_id)\n if not state:\n return\n if state.type == \"shutdown\" and response_type != \"shutdown_response\":\n return # タイプ不一致、スキップ\n if state.type == \"plan_approval\" and response_type != \"plan_approval_response\":\n return\n if state.status != \"pending\":\n return # 既に解決済み、重複をスキップ\n state.status = \"approved\" if approve else \"rejected\"\n```\n\nshutdown_response が誤って plan_approval リクエストを承認することはない。\n\n### 統一 inbox コンシューマ:consume_lead_inbox\n\n`check_inbox` ツールとメインループ末尾の両方が同じ `consume_lead_inbox()` 関数を呼び出す。プロトコルメッセージを先にルーティングしてから残りの内容を返す。メッセージが消費されてもプロトコル状態が更新されない問題を防ぐ:\n\n```python\ndef consume_lead_inbox(route_protocol=True) -> list[dict]:\n msgs = BUS.read_inbox(\"lead\")\n if route_protocol:\n for msg in msgs:\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n msg_type = msg.get(\"type\", \"\")\n if req_id and msg_type.endswith(\"_response\"):\n match_response(msg_type, req_id, meta.get(\"approve\", False))\n return msgs\n```\n\nメインループは inbox メッセージを `history` に注入し、LLM が確認して反応できるようにする。\n\n### チームメイト idle loop:終了ではなく待機\n\ns15 のチームメイトは 10 ラウンドで終了。s16 のチームメイトは LLM が非 tool_use を返した後 idle 待機に入る:inbox をポーリング、shutdown_request に応答して終了、または新メッセージで作業継続。\n\n```\nLLM が非 tool_use を返す\n → idle: 毎秒 inbox をポーリング\n → shutdown_request 受信 → shutdown_response 返信 → 終了\n → 新メッセージ受信 → messages に注入 → LLM ターン継続\n```\n\n教学版は Lead への idle_notification を省略。真实 CC は idle 時に `idle_notification` を送信、Lead はチームメイトが空いていることを知り、新しいタスクを割り当て可能。\n\n### 組み合わせて実行\n\n```\n1. Lead: \"Alice にファイルを作成させ、その後シャットダウン\"\n2. Lead → spawn_teammate(\"alice\", \"backend\", \"config.py を作成\")\n3. alice スレッド起動 → write_file(\"config.py\", \"...\") → 完了 → idle\n4. Lead → request_shutdown(\"alice\")\n → BUS.send(\"shutdown_request\", {request_id: \"req_000142\"})\n5. alice idle ポーリング受信 → handle_shutdown_request\n → BUS.send(\"shutdown_response\", {request_id: \"req_000142\", approve: True})\n6. Lead consume_lead_inbox → match_response(\"req_000142\", approve=True)\n → pending_requests[\"req_000142\"].status = \"approved\"\n → inbox メッセージが history に注入、LLM がシャットダウン結果を確認\n```\n\nシャットダウンハンドシェイク完了:リクエスト → 確認 → シャットダウン。各ステップは `request_id` で追跡。\n\n---\n\n## s15 からの変更\n\n| コンポーネント | 変更前 (s15) | 変更後 (s16) |\n|--------------|------------|------------|\n| 連携方法 | 緩いテキストメッセージ | 構造化 request-response プロトコル |\n| リクエスト追跡 | なし | ProtocolState + pending_requests dict |\n| メッセージルーティング | 全てテキストとして処理 | dispatch_message がタイプ別にルーティング |\n| シャットダウン | 自然終了またはスレッド強制終了 | request_id ハンドシェイク機構 |\n| 計画承認 | なし | メッセージフローの例(実行ゲーティングなし) |\n| 新規メッセージ型 | message, result | + shutdown_request/response, plan_approval_request/response |\n| チームメイトライフサイクル | 最大 10 ラウンド | idle loop(inbox メッセージを待機) |\n| Lead inbox | check_inbox とメインループが別々に読み取り | 統一 consume_lead_inbox |\n| Lead ツール | 14 (s15) | 14(コアツールセットに request_shutdown、request_plan、review_plan を追加) |\n| チームメイトツール | 4 (s15) | + submit_plan (5) |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s16_team_protocols/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `Spawn alice as a backend dev. Ask her to create a file. Then request her shutdown.`\n2. `Spawn bob with a refactoring task. Have him submit a plan first. Then review and approve it.`\n\n観察ポイント:シャットダウンハンドシェイクは完了しているか(リクエスト → 確認 → シャットダウン)?`pending_requests` の状態は正しく遷移しているか?`request_id` はリクエストとレスポンス間で一貫しているか?idle チームメイトは shutdown_request を受信できるか?\n\n---\n\n## 次の章\n\ns15-s16 では、Lead が各チームメイトにタスクを割り当てる必要がある。\"Alice はこれ、Bob はあれ\"。ボードに 10 個の未認領タスクがあれば、Lead が手動で assign しなければならない。\n\nチームメイトが自分でボードを見て認領できたらどうか?Lead はタスクを作成するだけで、チームメイトが自分で発見、認領、完了する。\n\ns17 Autonomous Agents → チームメイトの自己組織化、リーダーの割り当て不要。\n\n
\nCC ソースコード深掘り\n\nCC のチームプロトコル実装(`teammateMailbox.ts`、1184 行)は教学版と同じコア構造:request_id + approve/reject の request-response パターン。違いは以下の通り:\n\n**シャットダウンプロトコル**:CC のシャットダウンは三方向通信(`teammateMailbox.ts:720-763`、`SendMessageTool.ts:268-430`)。Lead が `shutdown_request` を送信、チームメイトが `shutdown_approved`(または理由付き `shutdown_rejected`)で返信、システムが `teammate_terminated` で全関係者に通知。確認後、システムが自動的に pane(tmux/iTerm2)をクリーンアップ、タスクを unassign、team config からメンバーを削除(`useInboxPoller.ts:677-800`)。教学版は `shutdown_response` で統一命名、真实源码は `shutdown_approved` と `shutdown_rejected` の 2 つの独立したメッセージ型に分割。\n\n**計画承認**:真实源码では plan approval request は `ExitPlanModeV2Tool.ts:263-312` で plan-mode-required チームメイトが plan mode を終了する際に生成される。`useInboxPoller.ts:599-661` は現在自動的に approval を書き戻し、リクエストを Lead にコンテキスト(regular message)として渡す。`SendMessageTool.ts:434-518` は明示的な approve/reject response 能力を保持、承認時に同時に `permissionMode` を設定可能(例:\"承認するが plan mode で実行\")、レスポンスにはチームメイトが修正して再提出するための `feedback` 文字列を含めることができる。単純な「Lead が手動で review_plan ツールを使う」フローではない。\n\n**メッセージ形式**:CC のプロトコルメッセージは構造化 JSON(Zod schema 検証付き)、教学版はシンプルな type + metadata dict。フィールド名も統一されていない:permission は `request_id`(`teammateMailbox.ts:453-462`)、shutdown と plan approval は `requestId`(`teammateMailbox.ts:684-763`)。\n\n**実行ゲーティング**:CC のチームメイトには完全な permission gating がある。未承認の高リスク操作は拦截され、オプションではない。教学版はメッセージフローのみをデモ。\n\n**汎用性**:教学版の 1 つの FSM(pending → approved | rejected)が 2 つのプロトコルに対応する簡略化は正しい。CC の全プロトコルメッセージは同じ request id 関連機構を共有。\n\n
\n\n\n" + "content": "# s16: Team Protocols — チームメイト間には取り決めが必要\n\ns01 → ... → s14 → s15 → `s16` → [s17](/ja/s17) → s18 → s19 → s20\n> *\"チームメイト間には取り決めが必要\"* — request-response パターンが全てのネゴシエーションを駆動。\n>\n> **Harness 層**: プロトコル — Agent 間の構造化ハンドシェイク。\n\n---\n\n## 課題\n\ns15 のチームメイトは仕事ができるが、連携は緩い:Lead がメッセージを送り、チームメイトが返信するだけで、構造化されたプロトコルがない。2 つのシナリオで問題が露呈する:\n\n**シャットダウン**:Lead が Alice にシャットダウンを頼む。スレッドを強制終了すると、書きかけのファイルがディスクに残る。ハンドシェイクが必要:Lead がリクエストを送信、Alice が收尾後に確認。\n\n**計画承認**:Bob が認証モジュールのリファクタリングを提案、高リスク操作。Lead が Bob の計画を確認し、承認後に実行すべき。\n\nこれら 2 つのシナリオは同じ構造:一方がリクエストを送信、もう一方が返信、両者は同じ ID で関連付けられる。状態機械が追跡:pending → approved / rejected。\n\n---\n\n## ソリューション\n\n![Team Protocols Overview](/course-assets/s16_team_protocols/team-protocols-overview.svg)\n\n教学版は前章までの Agent 能力の流れを受け継ぎ、S15 のチーム通信の上に構造化プロトコルを追加する。プロトコル機構に集中するため、完全なエラーリカバリ、メモリ、スキルシステムは省略。追加:**ProtocolState**(リクエスト状態追跡)、**dispatch_message**(メッセージタイプ別ルーティング)、**match_response**(request_id でリクエストとレスポンスを関連付け、型検証付き)。\n\n2 つのプロトコル、1 つの仕組み:\n\n| プロトコル | 方向 | 用途 |\n|-----------|------|------|\n| shutdown_request / response | Lead → チームメイト | 丁寧なシャットダウンハンドシェイク |\n| plan_approval_request / response | チームメイト → Lead | 計画承認プロトコルの例 |\n\n> 教学版は計画承認の request-response メッセージフローをデモするが、実行ゲーティング(未承認時の bash/write_file 拦截)は未実装。真实 Claude Code にはチームメイト向けの permission gating 機構がある。\n\n---\n\n## 仕組み\n\n### ProtocolState: リクエスト状態\n\n各プロトコルリクエストは、送信者、受信者、現在の状態、ペイロードを記録する状態レコードを作成:\n\n```python\n@dataclass\nclass ProtocolState:\n request_id: str # 一意 ID、例 \"req_004281\"\n type: str # \"shutdown\" | \"plan_approval\"\n sender: str # 送信者\n target: str # 受信者\n status: str # pending | approved | rejected\n payload: str # 計画テキストまたはシャットダウン理由\n created_at: float # タイムスタンプ\n\npending_requests: dict[str, ProtocolState] = {}\n```\n\nリクエスト送信時にレコードを作成、レスポンス受信時に `request_id` で該当レコードを見つけて状態を更新。\n\n### 4 ステッププロトコルフロー\n\nシャットダウンを例にした完全な流れ:\n\n```\n1. Lead がリクエスト送信\n req_id = new_request_id() # \"req_004281\"\n pending_requests[req_id] = ProtocolState(type=\"shutdown\", status=\"pending\", ...)\n BUS.send(\"lead\", \"alice\", \"shutdown_request\", metadata={\"request_id\": req_id})\n\n2. チームメイト受信 → dispatch\n inbox = BUS.read_inbox(\"alice\")\n msg_type = msg[\"type\"] # \"shutdown_request\"\n → handle_shutdown_request() にルーティング\n\n3. チームメイト返信\n BUS.send(\"alice\", \"lead\", \"shutdown_response\",\n metadata={\"request_id\": req_id, \"approve\": True})\n\n4. Lead がレスポンス受信 → match\n match_response(\"shutdown_response\", req_id, approve=True)\n pending_requests[req_id].status = \"approved\"\n```\n\n`request_id` はチェーン全体を貫く関連キー、リクエストが持ち出し、レスポンスが持ち帰る。\n\n### dispatch_message: タイプ別ルーティング\n\nチームメイトの inbox は通常メッセージとプロトコルメッセージの両方を受信。`handle_inbox_message` がメッセージタイプで振り分け:\n\n```python\ndef handle_inbox_message(name, msg, messages):\n msg_type = msg.get(\"type\", \"message\")\n req_id = msg.get(\"metadata\", {}).get(\"request_id\", \"\")\n\n if msg_type == \"shutdown_request\":\n BUS.send(name, \"lead\", \"Shutting down.\", \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n return True # ループ停止\n\n if msg_type == \"plan_approval_response\":\n approve = msg[\"metadata\"].get(\"approve\", False)\n messages.append({\"role\": \"user\",\n \"content\": \"[Plan approved]\" if approve else \"[Plan rejected]\"})\n return False # 継続\n```\n\n新しいプロトコルタイプの追加は新しい `if` 分岐を追加するだけ。\n\n### match_response: 型検証\n\n`match_response` は `request_id` で状態を見つけるだけでなく、レスポンスタイプがリクエストタイプと一致するか検証:\n\n```python\ndef match_response(response_type, request_id, approve):\n state = pending_requests.get(request_id)\n if not state:\n return\n if state.type == \"shutdown\" and response_type != \"shutdown_response\":\n return # タイプ不一致、スキップ\n if state.type == \"plan_approval\" and response_type != \"plan_approval_response\":\n return\n if state.status != \"pending\":\n return # 既に解決済み、重複をスキップ\n state.status = \"approved\" if approve else \"rejected\"\n```\n\nshutdown_response が誤って plan_approval リクエストを承認することはない。\n\n### 統一 inbox コンシューマ:consume_lead_inbox\n\n`check_inbox` ツールとメインループ末尾の両方が同じ `consume_lead_inbox()` 関数を呼び出す。プロトコルメッセージを先にルーティングしてから残りの内容を返す。メッセージが消費されてもプロトコル状態が更新されない問題を防ぐ:\n\n```python\ndef consume_lead_inbox(route_protocol=True) -> list[dict]:\n msgs = BUS.read_inbox(\"lead\")\n if route_protocol:\n for msg in msgs:\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n msg_type = msg.get(\"type\", \"\")\n if req_id and msg_type.endswith(\"_response\"):\n match_response(msg_type, req_id, meta.get(\"approve\", False))\n return msgs\n```\n\nメインループは inbox メッセージを `history` に注入し、LLM が確認して反応できるようにする。\n\n### チームメイト idle loop:終了ではなく待機\n\ns15 のチームメイトは 10 ラウンドで終了。s16 のチームメイトは LLM が非 tool_use を返した後 idle 待機に入る:inbox をポーリング、shutdown_request に応答して終了、または新メッセージで作業継続。\n\n```\nLLM が非 tool_use を返す\n → idle: 毎秒 inbox をポーリング\n → shutdown_request 受信 → shutdown_response 返信 → 終了\n → 新メッセージ受信 → messages に注入 → LLM ターン継続\n```\n\n教学版は Lead への idle_notification を省略。真实 Claude Code は idle 時に `idle_notification` を送信、Lead はチームメイトが空いていることを知り、新しいタスクを割り当て可能。\n\n### 組み合わせて実行\n\n```\n1. Lead: \"Alice にファイルを作成させ、その後シャットダウン\"\n2. Lead → spawn_teammate(\"alice\", \"backend\", \"config.py を作成\")\n3. alice スレッド起動 → write_file(\"config.py\", \"...\") → 完了 → idle\n4. Lead → request_shutdown(\"alice\")\n → BUS.send(\"shutdown_request\", {request_id: \"req_000142\"})\n5. alice idle ポーリング受信 → handle_shutdown_request\n → BUS.send(\"shutdown_response\", {request_id: \"req_000142\", approve: True})\n6. Lead consume_lead_inbox → match_response(\"req_000142\", approve=True)\n → pending_requests[\"req_000142\"].status = \"approved\"\n → inbox メッセージが history に注入、LLM がシャットダウン結果を確認\n```\n\nシャットダウンハンドシェイク完了:リクエスト → 確認 → シャットダウン。各ステップは `request_id` で追跡。\n\n---\n\n## s15 からの変更\n\n| コンポーネント | 変更前 (s15) | 変更後 (s16) |\n|--------------|------------|------------|\n| 連携方法 | 緩いテキストメッセージ | 構造化 request-response プロトコル |\n| リクエスト追跡 | なし | ProtocolState + pending_requests dict |\n| メッセージルーティング | 全てテキストとして処理 | dispatch_message がタイプ別にルーティング |\n| シャットダウン | 自然終了またはスレッド強制終了 | request_id ハンドシェイク機構 |\n| 計画承認 | なし | メッセージフローの例(実行ゲーティングなし) |\n| 新規メッセージ型 | message, result | + shutdown_request/response, plan_approval_request/response |\n| チームメイトライフサイクル | 最大 10 ラウンド | idle loop(inbox メッセージを待機) |\n| Lead inbox | check_inbox とメインループが別々に読み取り | 統一 consume_lead_inbox |\n| Lead ツール | 14 (s15) | 14(コアツールセットに request_shutdown、request_plan、review_plan を追加) |\n| チームメイトツール | 4 (s15) | + submit_plan (5) |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s16_team_protocols/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `Spawn alice as a backend dev. Ask her to create a file. Then request her shutdown.`\n2. `Spawn bob with a refactoring task. Have him submit a plan first. Then review and approve it.`\n\n観察ポイント:シャットダウンハンドシェイクは完了しているか(リクエスト → 確認 → シャットダウン)?`pending_requests` の状態は正しく遷移しているか?`request_id` はリクエストとレスポンス間で一貫しているか?idle チームメイトは shutdown_request を受信できるか?\n\n---\n\n## 次の章\n\ns15-s16 では、Lead が各チームメイトにタスクを割り当てる必要がある。\"Alice はこれ、Bob はあれ\"。ボードに 10 個の未認領タスクがあれば、Lead が手動で assign しなければならない。\n\nチームメイトが自分でボードを見て認領できたらどうか?Lead はタスクを作成するだけで、チームメイトが自分で発見、認領、完了する。\n\ns17 Autonomous Agents → チームメイトの自己組織化、リーダーの割り当て不要。\n\n
\nClaude Code ソースコード深掘り\n\nClaude Code のチームプロトコル実装(`teammateMailbox.ts`、1184 行)は教学版と同じコア構造:request_id + approve/reject の request-response パターン。違いは以下の通り:\n\n**シャットダウンプロトコル**:Claude Code のシャットダウンは三方向通信(`teammateMailbox.ts:720-763`、`SendMessageTool.ts:268-430`)。Lead が `shutdown_request` を送信、チームメイトが `shutdown_approved`(または理由付き `shutdown_rejected`)で返信、システムが `teammate_terminated` で全関係者に通知。確認後、システムが自動的に pane(tmux/iTerm2)をクリーンアップ、タスクを unassign、team config からメンバーを削除(`useInboxPoller.ts:677-800`)。教学版は `shutdown_response` で統一命名、真实源码は `shutdown_approved` と `shutdown_rejected` の 2 つの独立したメッセージ型に分割。\n\n**計画承認**:真实源码では plan approval request は `ExitPlanModeV2Tool.ts:263-312` で plan-mode-required チームメイトが plan mode を終了する際に生成される。`useInboxPoller.ts:599-661` は現在自動的に approval を書き戻し、リクエストを Lead にコンテキスト(regular message)として渡す。`SendMessageTool.ts:434-518` は明示的な approve/reject response 能力を保持、承認時に同時に `permissionMode` を設定可能(例:\"承認するが plan mode で実行\")、レスポンスにはチームメイトが修正して再提出するための `feedback` 文字列を含めることができる。単純な「Lead が手動で review_plan ツールを使う」フローではない。\n\n**メッセージ形式**:Claude Code のプロトコルメッセージは構造化 JSON(Zod schema 検証付き)、教学版はシンプルな type + metadata dict。フィールド名も統一されていない:permission は `request_id`(`teammateMailbox.ts:453-462`)、shutdown と plan approval は `requestId`(`teammateMailbox.ts:684-763`)。\n\n**実行ゲーティング**:Claude Code のチームメイトには完全な permission gating がある。未承認の高リスク操作は拦截され、オプションではない。教学版はメッセージフローのみをデモ。\n\n**汎用性**:教学版の 1 つの FSM(pending → approved | rejected)が 2 つのプロトコルに対応する簡略化は正しい。Claude Code の全プロトコルメッセージは同じ request id 関連機構を共有。\n\n
\n\n\n" }, { "version": "s17", "locale": "en", "title": "s17: Autonomous Agents — Check the Board, Claim the Task", - "content": "# s17: Autonomous Agents — Check the Board, Claim the Task\n\ns01 → ... → s15 → s16 → `s17` → [s18](/en/s18) → s19 → s20\n\n> *\"Check the board, claim the task\"* — poll when idle, work when found.\n>\n> **Harness Layer**: Autonomy — Self-organizing teammates, no leader assignment needed.\n\n---\n\n## The Problem\n\ns16's teammates can communicate and handshake shutdown. But each teammate waits for Lead to assign tasks — with 10 unclaimed tasks on the board, Lead has to manually assign 10 times. This doesn't scale. Teammates should check the task board themselves, claim unowned tasks, and look for the next one when done.\n\n---\n\n## The Solution\n\n![Autonomous Agents Overview](/course-assets/s17_autonomous_agents/autonomous-agents-overview.en.svg)\n\nCarries forward S16's teaching-version MessageBus and protocol tools. This chapter adds: **idle_poll** (poll every 5 seconds when idle), **scan_unclaimed_tasks** (scan the board for claimable tasks), **auto-claim** (claim on sight, no Lead needed).\n\nTeammate lifecycle expands from two phases to three:\n\n| Phase | Behavior | Exit condition |\n|-------|----------|----------------|\n| WORK | inbox → LLM → tool loop | `stop_reason != tool_use` |\n| IDLE | 5s poll inbox + task board | 60s timeout |\n| SHUTDOWN | Send summary, exit | — |\n\n---\n\n## How It Works\n\n### idle_poll: Idle Polling\n\nAfter completing a task, the teammate doesn't exit. It enters the IDLE phase — checking every 5 seconds for new work:\n\n```python\nIDLE_POLL_INTERVAL = 5 # seconds\nIDLE_TIMEOUT = 60 # seconds\n\ndef idle_poll(agent_name, messages, name, role) -> str:\n \"\"\"Return 'work', 'shutdown', or 'timeout'.\"\"\"\n for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):\n time.sleep(IDLE_POLL_INTERVAL)\n\n # ① Check inbox (priority)\n inbox = BUS.read_inbox(agent_name)\n if inbox:\n # shutdown_request handled immediately\n for msg in inbox:\n if msg.get(\"type\") == \"shutdown_request\":\n # ... reply shutdown_response\n return \"shutdown\"\n # Regular messages: inject into context, return to WORK\n messages.append(...)\n return \"work\"\n\n # ② Scan task board\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n task = unclaimed[0]\n result = claim_task(task[\"id\"], agent_name)\n if \"Claimed\" in result:\n messages.append(...)\n return \"work\"\n return \"timeout\"\n```\n\nInbox takes priority (may contain protocol messages like shutdown_request), task board second. A shutdown_request received during IDLE is dispatched immediately — no need to wait for the next WORK phase.\n\n### scan_unclaimed_tasks: Scan the Task Board\n\nFind tasks that are pending, unowned, with all dependencies completed (`can_start`):\n\n```python\ndef scan_unclaimed_tasks() -> list[dict]:\n unclaimed = []\n for f in sorted(TASKS_DIR.glob(\"task_*.json\")):\n task = json.loads(f.read_text())\n if (task.get(\"status\") == \"pending\"\n and not task.get(\"owner\")\n and can_start(task[\"id\"])):\n unclaimed.append(task)\n return unclaimed\n```\n\nThree conditions: must be pending, no owner, all blockedBy dependencies completed. `can_start` checks dependency task status — having dependencies doesn't mean the task can't start, only unresolved dependencies block it. Teaching version picks the first by filename; CC uses file locks to prevent multiple teammates from claiming the same task.\n\n### claim_task: Owner Check\n\nAuto-claim checks the claim result, not treating failure as success:\n\n```python\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if task.owner:\n return f\"Task {task_id} already owned by {task.owner}\"\n if not can_start(task_id):\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n return f\"Claimed {task.id} ({task.subject})\"\n```\n\nTeaching version has no file locks, so concurrent claims may still race. But the `task.owner` check avoids the most obvious \"last writer wins\" problem. CC uses `proper-lockfile` to protect task files, with `claimTask` doing read-modify-write inside a file lock (`utils/tasks.ts:541-612`).\n\n### Teammate Lifecycle: WORK → IDLE → SHUTDOWN\n\ns16's teammates exit after finishing. s17 adds the IDLE phase — teammates cycle through WORK → IDLE in an outer loop:\n\n```python\n# Outer loop: WORK → IDLE cycle\nwhile True:\n # WORK phase: inner loop (max 10 LLM rounds)\n for _ in range(10):\n # Check inbox, dispatch protocol, call LLM, execute tools\n ...\n if response.stop_reason != \"tool_use\":\n break # WORK phase ends\n\n # IDLE phase\n idle_result = idle_poll(name, messages, name, role)\n if idle_result == \"shutdown\":\n break\n if idle_result == \"timeout\":\n break # 60s timeout → SHUTDOWN\n\n# SHUTDOWN: send summary to Lead\nBUS.send(name, \"lead\", summary, \"result\")\n```\n\nKey design:\n- **Outer while True**: WORK and IDLE alternate until timeout or shutdown request\n- **Inner for 10**: WORK phase caps at 10 LLM rounds (prevents infinite loops)\n- **IDLE timeout 60s**: 12 polls × 5s = 60s. Timeout sends summary and exits\n- **shutdown_request works in both phases**: WORK phase dispatches via `handle_inbox_message`; IDLE phase's `idle_poll` checks and replies directly\n\n### Identity Re-injection\n\nAfter autoCompact (s08), a teammate's messages list may be compressed into a summary. On each new WORK phase entry, check:\n\n```python\nif len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"You are '{name}', role: {role}. \"\n f\"Continue your work.\"})\n```\n\nShort messages suggest compression happened — re-inject identity. In real CC, context compaction preserves the system prompt; the teaching version's simplified implementation needs manual handling.\n\n### consume_lead_inbox: Unified Inbox Consumer\n\nBoth the `check_inbox` tool and the main loop call the same `consume_lead_inbox()` function: route protocol responses to update state first, then inject all messages into Lead's conversation history. Teammates' summaries and results don't just print to terminal — Lead's LLM can see them and coordinate next steps.\n\n### Putting It Together\n\n```\n1. Lead: \"Build the backend — too many tasks, let teammates self-claim\"\n2. Lead → create_task(\"Create database schema\")\n3. Lead → create_task(\"Write API routes\")\n4. Lead → create_task(\"Write unit tests\")\n5. Lead → spawn_teammate(\"alice\", \"backend\", \"You are a backend developer\")\n6. Lead → spawn_teammate(\"bob\", \"backend\", \"You are a backend developer\")\n\n7. alice thread starts → WORK: no initial inbox → spins → IDLE\n8. bob thread starts → WORK: no initial inbox → spins → IDLE\n\n9. alice IDLE poll 1 → scan_unclaimed → finds \"Create database schema\"\n10. alice → claim_task → \"Create database schema\" → back to WORK\n11. bob IDLE poll 1 → scan_unclaimed → finds \"Write API routes\"\n12. bob → claim_task → \"Write API routes\" → back to WORK\n\n13. alice WORK: write_file(\"schema.sql\", ...) → complete_task → WORK ends\n14. alice IDLE → scan → \"Write unit tests\" → claim → WORK\n15. alice WORK: write_file(\"test_api.py\", ...) → complete_task → WORK ends\n16. alice IDLE → 60s no new tasks → SHUTDOWN\n\n17. bob similar flow → done → SHUTDOWN\n18. Lead consume_lead_inbox → sees alice and bob's summaries\n```\n\nTwo teammates claim and work in parallel. Lead only creates tasks and spawns teammates — no manual assignment needed.\n\n---\n\n## Changes from s16\n\n| Component | Before (s16) | After (s17) |\n|-----------|-------------|-------------|\n| Task assignment | Lead manually assigns | Teammates auto-claim (can_start checks deps) |\n| Teammate state | WORK or exit | WORK → IDLE (60s poll) → SHUTDOWN |\n| claim_task | No owner check | Rejects tasks that already have an owner |\n| IDLE phase shutdown | Doesn't handle shutdown_request | Dispatches shutdown immediately and exits |\n| Lead inbox | Prints only, not in context | consume_lead_inbox injects into history |\n| New functions | — | idle_poll, scan_unclaimed_tasks, consume_lead_inbox |\n| Identity persistence | System prompt only | Auto re-inject after compression |\n| Lead tools | 14 (s16) | 14 (unchanged) |\n| Teammate tools | 5 | 8 (+ list_tasks, claim_task, complete_task) |\n| Teammate exit | Exit after task done | Exit only after 60s idle timeout |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s17_autonomous_agents/code.py\n```\n\nTry this prompt:\n\n`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.`\n\nWhat to observe: Do teammates auto-claim unassigned tasks? Are tasks with blockedBy dependencies claimed only after their dependencies complete? Does idle timeout trigger shutdown? Does a shutdown_request in IDLE phase get an immediate response? How do task states change in `.tasks/`?\n\n---\n\n## What's Next\n\nTeammates self-organize now. But Alice and Bob both work in the same directory — Alice edits `config.py`, Bob also edits `config.py`, overwriting each other.\n\ns18 Worktree Isolation → Each task gets its own working directory, no conflicts.\n\n
\nDeep Dive into CC Source\n\n> Teaching note: This chapter's idle_poll + auto-claim mechanism is a teaching design, using a unified polling function to demonstrate \"find work when idle.\" CC's actual implementation combines multiple mechanisms, but shares the same goal — reducing Lead's manual assignment burden.\n\n### 1. CC's Idle Mechanism: Combined Approach, Not Single Polling\n\nTeaching version uses a single `idle_poll()` to handle both inbox checking and task claiming during idle. CC's actual implementation combines four mechanisms:\n\n**idle_notification**: After completing a round of work, `sendIdleNotification()` (`inProcessRunner.ts:569-589`) sends an idle notification to Lead. Lead knows the teammate is available and can assign new tasks or request shutdown.\n\n**mailbox polling**: `waitForNextPromptOrShutdown()` (`inProcessRunner.ts:689-868`) is a **500ms polling loop** that continuously checks three sources: pending user messages, mailbox file messages, and task list. Shutdown requests are prioritized (`inProcessRunner.ts:768-804`), preventing starvation by regular messages.\n\n**task watcher**: `useTaskListWatcher` (`hooks/useTaskListWatcher.ts:34-189`) uses `fs.watch()` to monitor the `.claude/tasks/` directory with 1-second debounce, triggering checks when new tasks are created or dependencies unblock. The dependency check (`L197-207`) verifies \"no incomplete tasks in blockedBy\", not \"blockedBy is empty\".\n\n**active claiming**: The polling loop also calls `tryClaimNextTask()` (`inProcessRunner.ts:853-860`) — actively claiming tasks from the task list while waiting. So \"teammates don't actively poll for tasks\" is inaccurate; CC has both passive notification and active claiming.\n\n### 2. Task Claiming: File Locks + Atomic Operations\n\n`claimTask()` (`utils/tasks.ts:541-612`) uses `proper-lockfile` task-level locks, performing read-check-modify-write within the lock. Checks: owner already exists (`L575-576`), already completed (`L580-581`), unresolved blockers in blockedBy (`L585-594`). `claimTaskWithBusyCheck()` (`utils/tasks.ts:614-692`) uses task-list level locks, making busy check and claim atomic to avoid TOCTOU.\n\n`findAvailableTask()` (`inProcessRunner.ts:595-604`) checks \"all blockedBy completed\" using `task.blockedBy.every(id => !unresolvedTaskIds.has(id))`. `tryClaimNextTask()` (`inProcessRunner.ts:624-657`) updates status to `in_progress` after claiming, so the UI immediately reflects the change.\n\n### 3. Teaching Version vs CC Comparison\n\n| Dimension | Teaching (s17) | CC |\n|-----------|----------------|-----|\n| Idle mechanism | idle_poll unified polling (5s) | idle_notification + 500ms mailbox polling + task watcher |\n| Task discovery | scan_unclaimed_tasks (polling) | useTaskListWatcher (file watching) + tryClaimNextTask (active polling) |\n| Dependency check | can_start (all blockedBy completed) | findAvailableTask (same semantics) |\n| Concurrency safety | Owner check (no file lock) | proper-lockfile task lock + task-list lock |\n| Shutdown handling | IDLE dispatches directly, WORK via handle_inbox_message | 500ms polling loop prioritizes shutdown_request |\n| Timeout exit | 60s with no new tasks | No fixed timeout, Lead manual shutdown |\n| Identity persistence | Messages length detection | Context compaction preserves system prompt |\n| Claim failure handling | Check return value, skip on failure | File locks guarantee atomicity |\n\nTeaching version's `idle_poll()` merges CC's four mechanisms into one polling function — a reasonable simplification since the core semantics (find work when idle, claim after deps resolve, prioritize shutdown) are consistent.\n\n
\n\n\n" + "content": "# s17: Autonomous Agents — Check the Board, Claim the Task\n\ns01 → ... → s15 → s16 → `s17` → [s18](/en/s18) → s19 → s20\n\n> *\"Check the board, claim the task\"* — poll when idle, work when found.\n>\n> **Harness Layer**: Autonomy — Self-organizing teammates, no leader assignment needed.\n\n---\n\n## The Problem\n\ns16's teammates can communicate and handshake shutdown. But each teammate waits for Lead to assign tasks. With 10 unclaimed tasks on the board, Lead has to manually assign 10 times. This doesn't scale. Teammates should check the task board themselves, claim unowned tasks, and look for the next one when done.\n\n---\n\n## The Solution\n\n![Autonomous Agents Overview](/course-assets/s17_autonomous_agents/autonomous-agents-overview.svg)\n\nCarries forward S16's teaching-version MessageBus and protocol tools. This chapter adds: **idle_poll** (poll every 5 seconds when idle), **scan_unclaimed_tasks** (scan the board for claimable tasks), **auto-claim** (claim on sight, no Lead needed).\n\nTeammate lifecycle expands from two phases to three:\n\n| Phase | Behavior | Exit condition |\n|-------|----------|----------------|\n| WORK | inbox → LLM → tool loop | `stop_reason != tool_use` |\n| IDLE | 5s poll inbox + task board | 60s timeout |\n| SHUTDOWN | Send summary, exit | — |\n\n---\n\n## How It Works\n\n### idle_poll: Idle Polling\n\nAfter completing a task, the teammate doesn't exit. It enters the IDLE phase, checking every 5 seconds for new work:\n\n```python\nIDLE_POLL_INTERVAL = 5 # seconds\nIDLE_TIMEOUT = 60 # seconds\n\ndef idle_poll(agent_name, messages, name, role) -> str:\n \"\"\"Return 'work', 'shutdown', or 'timeout'.\"\"\"\n for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):\n time.sleep(IDLE_POLL_INTERVAL)\n\n # ① Check inbox (priority)\n inbox = BUS.read_inbox(agent_name)\n if inbox:\n # shutdown_request handled immediately\n for msg in inbox:\n if msg.get(\"type\") == \"shutdown_request\":\n # ... reply shutdown_response\n return \"shutdown\"\n # Regular messages: inject into context, return to WORK\n messages.append(...)\n return \"work\"\n\n # ② Scan task board\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n task = unclaimed[0]\n result = claim_task(task[\"id\"], agent_name)\n if \"Claimed\" in result:\n messages.append(...)\n return \"work\"\n return \"timeout\"\n```\n\nInbox takes priority (may contain protocol messages like shutdown_request), task board second. A shutdown_request received during IDLE is dispatched immediately; no need to wait for the next WORK phase.\n\n### scan_unclaimed_tasks: Scan the Task Board\n\nFind tasks that are pending, unowned, with all dependencies completed (`can_start`):\n\n```python\ndef scan_unclaimed_tasks() -> list[dict]:\n unclaimed = []\n for f in sorted(TASKS_DIR.glob(\"task_*.json\")):\n task = json.loads(f.read_text())\n if (task.get(\"status\") == \"pending\"\n and not task.get(\"owner\")\n and can_start(task[\"id\"])):\n unclaimed.append(task)\n return unclaimed\n```\n\nThree conditions: must be pending, no owner, all blockedBy dependencies completed. `can_start` checks dependency task status: having dependencies doesn't mean the task can't start, only unresolved dependencies block it. Teaching version picks the first by filename; Claude Code uses file locks to prevent multiple teammates from claiming the same task.\n\n### claim_task: Owner Check\n\nAuto-claim checks the claim result, not treating failure as success:\n\n```python\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if task.owner:\n return f\"Task {task_id} already owned by {task.owner}\"\n if not can_start(task_id):\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n return f\"Claimed {task.id} ({task.subject})\"\n```\n\nTeaching version has no file locks, so concurrent claims may still race. But the `task.owner` check avoids the most obvious \"last writer wins\" problem. Claude Code uses `proper-lockfile` to protect task files, with `claimTask` doing read-modify-write inside a file lock (`utils/tasks.ts:541-612`).\n\n### Teammate Lifecycle: WORK → IDLE → SHUTDOWN\n\ns16's teammates exit after finishing. s17 adds the IDLE phase. Teammates cycle through WORK → IDLE in an outer loop:\n\n```python\n# Outer loop: WORK → IDLE cycle\nwhile True:\n # WORK phase: inner loop (max 10 LLM rounds)\n for _ in range(10):\n # Check inbox, dispatch protocol, call LLM, execute tools\n ...\n if response.stop_reason != \"tool_use\":\n break # WORK phase ends\n\n # IDLE phase\n idle_result = idle_poll(name, messages, name, role)\n if idle_result == \"shutdown\":\n break\n if idle_result == \"timeout\":\n break # 60s timeout → SHUTDOWN\n\n# SHUTDOWN: send summary to Lead\nBUS.send(name, \"lead\", summary, \"result\")\n```\n\nKey design:\n- **Outer while True**: WORK and IDLE alternate until timeout or shutdown request\n- **Inner for 10**: WORK phase caps at 10 LLM rounds (prevents infinite loops)\n- **IDLE timeout 60s**: 12 polls × 5s = 60s. Timeout sends summary and exits\n- **shutdown_request works in both phases**: WORK phase dispatches via `handle_inbox_message`; IDLE phase's `idle_poll` checks and replies directly\n\n### Identity Re-injection\n\nAfter autoCompact (s08), a teammate's messages list may be compressed into a summary. On each new WORK phase entry, check:\n\n```python\nif len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"You are '{name}', role: {role}. \"\n f\"Continue your work.\"})\n```\n\nShort messages suggest compression happened, so re-inject identity. In real Claude Code, context compaction preserves the system prompt; the teaching version's simplified implementation needs manual handling.\n\n### consume_lead_inbox: Unified Inbox Consumer\n\nBoth the `check_inbox` tool and the main loop call the same `consume_lead_inbox()` function: route protocol responses to update state first, then inject all messages into Lead's conversation history. Teammates' summaries and results don't just print to terminal. Lead's LLM can see them and coordinate next steps.\n\n### Putting It Together\n\n```\n1. Lead: \"Build the backend — too many tasks, let teammates self-claim\"\n2. Lead → create_task(\"Create database schema\")\n3. Lead → create_task(\"Write API routes\")\n4. Lead → create_task(\"Write unit tests\")\n5. Lead → spawn_teammate(\"alice\", \"backend\", \"You are a backend developer\")\n6. Lead → spawn_teammate(\"bob\", \"backend\", \"You are a backend developer\")\n\n7. alice thread starts → WORK: no initial inbox → spins → IDLE\n8. bob thread starts → WORK: no initial inbox → spins → IDLE\n\n9. alice IDLE poll 1 → scan_unclaimed → finds \"Create database schema\"\n10. alice → claim_task → \"Create database schema\" → back to WORK\n11. bob IDLE poll 1 → scan_unclaimed → finds \"Write API routes\"\n12. bob → claim_task → \"Write API routes\" → back to WORK\n\n13. alice WORK: write_file(\"schema.sql\", ...) → complete_task → WORK ends\n14. alice IDLE → scan → \"Write unit tests\" → claim → WORK\n15. alice WORK: write_file(\"test_api.py\", ...) → complete_task → WORK ends\n16. alice IDLE → 60s no new tasks → SHUTDOWN\n\n17. bob similar flow → done → SHUTDOWN\n18. Lead consume_lead_inbox → sees alice and bob's summaries\n```\n\nTwo teammates claim and work in parallel.\n\n---\n\n## Changes from s16\n\n| Component | Before (s16) | After (s17) |\n|-----------|-------------|-------------|\n| Task assignment | Lead manually assigns | Teammates auto-claim (can_start checks deps) |\n| Teammate state | WORK or exit | WORK → IDLE (60s poll) → SHUTDOWN |\n| claim_task | No owner check | Rejects tasks that already have an owner |\n| IDLE phase shutdown | Doesn't handle shutdown_request | Dispatches shutdown immediately and exits |\n| Lead inbox | Prints only, not in context | consume_lead_inbox injects into history |\n| New functions | — | idle_poll, scan_unclaimed_tasks, consume_lead_inbox |\n| Identity persistence | System prompt only | Auto re-inject after compression |\n| Lead tools | 14 (s16) | 14 (unchanged) |\n| Teammate tools | 5 | 8 (+ list_tasks, claim_task, complete_task) |\n| Teammate exit | Exit after task done | Exit only after 60s idle timeout |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s17_autonomous_agents/code.py\n```\n\nTry this prompt:\n\n`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.`\n\nWhat to observe: Do teammates auto-claim unassigned tasks? Are tasks with blockedBy dependencies claimed only after their dependencies complete? Does idle timeout trigger shutdown? Does a shutdown_request in IDLE phase get an immediate response? How do task states change in `.tasks/`?\n\n---\n\n## What's Next\n\nTeammates self-organize now. But Alice and Bob both work in the same directory: Alice edits `config.py`, Bob also edits `config.py`, overwriting each other.\n\ns18 Worktree Isolation → Each task gets its own working directory, no conflicts.\n\n
\nDeep Dive into Claude Code Source\n\n> Teaching note: This chapter's idle_poll + auto-claim mechanism is a teaching design, using a unified polling function to demonstrate \"find work when idle.\" Claude Code's actual implementation combines multiple mechanisms, but shares the same goal: reducing Lead's manual assignment burden.\n\n### 1. Claude Code's Idle Mechanism: Combined Approach, Not Single Polling\n\nTeaching version uses a single `idle_poll()` to handle both inbox checking and task claiming during idle. Claude Code's actual implementation combines four mechanisms:\n\n**idle_notification**: After completing a round of work, `sendIdleNotification()` (`inProcessRunner.ts:569-589`) sends an idle notification to Lead. Lead knows the teammate is available and can assign new tasks or request shutdown.\n\n**mailbox polling**: `waitForNextPromptOrShutdown()` (`inProcessRunner.ts:689-868`) is a **500ms polling loop** that continuously checks three sources: pending user messages, mailbox file messages, and task list. Shutdown requests are prioritized (`inProcessRunner.ts:768-804`), preventing starvation by regular messages.\n\n**task watcher**: `useTaskListWatcher` (`hooks/useTaskListWatcher.ts:34-189`) uses `fs.watch()` to monitor the `.claude/tasks/` directory with 1-second debounce, triggering checks when new tasks are created or dependencies unblock. The dependency check (`L197-207`) verifies \"no incomplete tasks in blockedBy\", not \"blockedBy is empty\".\n\n**active claiming**: The polling loop also calls `tryClaimNextTask()` (`inProcessRunner.ts:853-860`), actively claiming tasks from the task list while waiting. So \"teammates don't actively poll for tasks\" is inaccurate; Claude Code has both passive notification and active claiming.\n\n### 2. Task Claiming: File Locks + Atomic Operations\n\n`claimTask()` (`utils/tasks.ts:541-612`) uses `proper-lockfile` task-level locks, performing read-check-modify-write within the lock. Checks: owner already exists (`L575-576`), already completed (`L580-581`), unresolved blockers in blockedBy (`L585-594`). `claimTaskWithBusyCheck()` (`utils/tasks.ts:614-692`) uses task-list level locks, making busy check and claim atomic to avoid TOCTOU.\n\n`findAvailableTask()` (`inProcessRunner.ts:595-604`) checks \"all blockedBy completed\" using `task.blockedBy.every(id => !unresolvedTaskIds.has(id))`. `tryClaimNextTask()` (`inProcessRunner.ts:624-657`) updates status to `in_progress` after claiming, so the UI immediately reflects the change.\n\n### 3. Teaching Version vs Claude Code Comparison\n\n| Dimension | Teaching (s17) | Claude Code |\n|-----------|----------------|-----|\n| Idle mechanism | idle_poll unified polling (5s) | idle_notification + 500ms mailbox polling + task watcher |\n| Task discovery | scan_unclaimed_tasks (polling) | useTaskListWatcher (file watching) + tryClaimNextTask (active polling) |\n| Dependency check | can_start (all blockedBy completed) | findAvailableTask (same semantics) |\n| Concurrency safety | Owner check (no file lock) | proper-lockfile task lock + task-list lock |\n| Shutdown handling | IDLE dispatches directly, WORK via handle_inbox_message | 500ms polling loop prioritizes shutdown_request |\n| Timeout exit | 60s with no new tasks | No fixed timeout, Lead manual shutdown |\n| Identity persistence | Messages length detection | Context compaction preserves system prompt |\n| Claim failure handling | Check return value, skip on failure | File locks guarantee atomicity |\n\nTeaching version's `idle_poll()` merges Claude Code's four mechanisms into one polling function. This is a reasonable simplification since the core semantics (find work when idle, claim after deps resolve, prioritize shutdown) are consistent.\n\n
\n\n\n" }, { "version": "s17", "locale": "zh", - "title": "s17: Autonomous Agents — 自己看板,自己认领", - "content": "# s17: Autonomous Agents — 自己看板,自己认领\n\ns01 → ... → s15 → s16 → `s17` → [s18](/zh/s18) → s19 → s20\n\n> *\"自己看板,自己认领\"* — 空闲时轮询,有活就干。\n>\n> **Harness 层**: 自治 — 队友自组织,不依赖 Lead 分配。\n\n---\n\n## 问题\n\ns16 的队友能通信、能握手关机。但每个队友等 Lead 分配任务——如果任务看板上有 10 个未认领任务,Lead 得手动 assign 10 次。这不能扩展。队友应该自己看任务看板,发现没人做的任务就认领,做完再找下一个。\n\n---\n\n## 解决方案\n\n![Autonomous Agents Overview](/course-assets/s17_autonomous_agents/autonomous-agents-overview.svg)\n\n沿用 S16 的教学版 MessageBus 和协议工具。本章新增:**idle_poll**(空闲时每 5 秒轮询一次)、**scan_unclaimed_tasks**(扫描看板上可认领的任务)、**自动认领**(找到任务就 claim,不用 Lead 操心)。\n\n队友生命周期从两阶段变成三阶段:\n\n| 阶段 | 行为 | 退出条件 |\n|------|------|---------|\n| WORK | inbox → LLM → 工具循环 | `stop_reason != tool_use` |\n| IDLE | 每 5s 轮询 inbox + 任务板 | 60s 超时 |\n| SHUTDOWN | 发 summary,退出 | — |\n\n---\n\n## 工作原理\n\n### idle_poll: 空闲轮询\n\n队友完成当前任务后不退出,进入 IDLE 阶段——每 5 秒检查一次有没有新工作:\n\n```python\nIDLE_POLL_INTERVAL = 5 # seconds\nIDLE_TIMEOUT = 60 # seconds\n\ndef idle_poll(agent_name, messages, name, role) -> str:\n \"\"\"Return 'work', 'shutdown', or 'timeout'.\"\"\"\n for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):\n time.sleep(IDLE_POLL_INTERVAL)\n\n # ① 检查收件箱(优先)\n inbox = BUS.read_inbox(agent_name)\n if inbox:\n # shutdown_request 立即处理\n for msg in inbox:\n if msg.get(\"type\") == \"shutdown_request\":\n # ... 回复 shutdown_response\n return \"shutdown\"\n # 普通消息注入上下文,回到 WORK\n messages.append(...)\n return \"work\"\n\n # ② 扫描任务看板\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n task = unclaimed[0]\n result = claim_task(task[\"id\"], agent_name)\n if \"Claimed\" in result:\n messages.append(...)\n return \"work\"\n return \"timeout\"\n```\n\ninbox 优先(可能包含 shutdown_request 等协议消息),任务板其次。IDLE 阶段收到 shutdown_request 会直接回复并退出,不等到下一轮 WORK。\n\n### scan_unclaimed_tasks: 扫描任务看板\n\n找 pending 状态、无 owner、所有依赖已完成(`can_start`)的任务:\n\n```python\ndef scan_unclaimed_tasks() -> list[dict]:\n unclaimed = []\n for f in sorted(TASKS_DIR.glob(\"task_*.json\")):\n task = json.loads(f.read_text())\n if (task.get(\"status\") == \"pending\"\n and not task.get(\"owner\")\n and can_start(task[\"id\"])):\n unclaimed.append(task)\n return unclaimed\n```\n\n三个条件:必须是 pending、没有 owner、所有 blockedBy 依赖已完成。`can_start` 检查依赖任务的状态——有依赖不代表不能做,只有被未完成的任务阻塞才不能做。教学版按文件名排序取第一个;CC 用文件锁防止多个队友同时认领同一个任务。\n\n### claim_task: owner 检查\n\n自动认领时检查 claim 结果,不把失败当成功:\n\n```python\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if task.owner:\n return f\"Task {task_id} already owned by {task.owner}\"\n if not can_start(task_id):\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n return f\"Claimed {task.id} ({task.subject})\"\n```\n\n教学版没有文件锁,并发认领可能出现竞争。但至少 `task.owner` 检查避免了最明显的\"后写覆盖\"问题。CC 用 `proper-lockfile` 保护任务文件,`claimTask` 在文件锁内完成读-改-写(`utils/tasks.ts:541-612`)。\n\n### 队友生命周期: WORK → IDLE → SHUTDOWN\n\ns16 的队友做完任务就退出。s17 加了 IDLE 阶段,队友在外层循环中反复 WORK → IDLE:\n\n```python\n# Outer loop: WORK → IDLE cycle\nwhile True:\n # WORK phase: 内层循环(最多 10 轮 LLM 调用)\n for _ in range(10):\n # 检查 inbox、处理协议消息、调 LLM、执行工具\n ...\n if response.stop_reason != \"tool_use\":\n break # WORK 阶段结束\n\n # IDLE phase\n idle_result = idle_poll(name, messages, name, role)\n if idle_result == \"shutdown\":\n break\n if idle_result == \"timeout\":\n break # 60s 超时 → SHUTDOWN\n\n# SHUTDOWN: 发 summary 给 Lead\nBUS.send(name, \"lead\", summary, \"result\")\n```\n\n关键设计:\n- **外层 while True**:WORK 和 IDLE 交替进行,直到超时或收到关机请求\n- **内层 for 10**:WORK 阶段最多 10 轮 LLM 调用(防止无限循环)\n- **IDLE 超时 60 秒**:12 次轮询 × 5 秒 = 60 秒。超时后发送 summary 并退出\n- **shutdown_request 两阶段都能响应**:WORK 阶段通过 `handle_inbox_message` 分发;IDLE 阶段 `idle_poll` 直接检查并回复\n\n### 身份重注入\n\nautoCompact(s08)之后,队友的 messages 列表可能被压缩成一段摘要。每次进入新的 WORK 阶段时检查:\n\n```python\nif len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"You are '{name}', role: {role}. \"\n f\"Continue your work.\"})\n```\n\n消息过短说明发生了压缩,此时重新注入身份信息。真实 CC 中 context compaction 会保留 system prompt,教学版的简化实现需要手动处理。\n\n### consume_lead_inbox: 统一 inbox 消费\n\n`check_inbox` 工具和主循环末尾都调用同一个 `consume_lead_inbox()` 函数:先路由协议 response 更新状态,再把所有消息注入 Lead 的对话历史。队友发来的 summary/result 不会只打印在终端,Lead 的 LLM 能看到并协调下一步。\n\n### 合起来跑\n\n```\n1. Lead: \"搭建后端——任务太多,让队友自己认领\"\n2. Lead → create_task(\"创建数据库 schema\")\n3. Lead → create_task(\"写 API 路由\")\n4. Lead → create_task(\"写单元测试\")\n5. Lead → spawn_teammate(\"alice\", \"backend\", \"你是后端开发者\")\n6. Lead → spawn_teammate(\"bob\", \"backend\", \"你是后端开发者\")\n\n7. alice 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE\n8. bob 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE\n\n9. alice IDLE 第 1 次轮询 → scan_unclaimed → 发现\"创建数据库 schema\"\n10. alice → claim_task → \"创建数据库 schema\" → 回到 WORK\n11. bob IDLE 第 1 次轮询 → scan_unclaimed → 发现\"写 API 路由\"\n12. bob → claim_task → \"写 API 路由\" → 回到 WORK\n\n13. alice WORK: write_file(\"schema.sql\", ...) → complete_task → WORK 结束\n14. alice IDLE → scan → \"写单元测试\" → claim → WORK\n15. alice WORK: write_file(\"test_api.py\", ...) → complete_task → WORK 结束\n16. alice IDLE → 60s 无新任务 → SHUTDOWN\n\n17. bob 类似流程 → 做完 → SHUTDOWN\n18. Lead consume_lead_inbox → 看到 alice 和 bob 的 summary\n```\n\n两个队友并行认领、并行工作。Lead 只需要创建任务和启动队友,不需要手动分配。\n\n---\n\n## 相对 s16 的变更\n\n| 组件 | 之前 (s16) | 之后 (s17) |\n|------|-----------|-----------|\n| 任务分配 | Lead 手动 assign | 队友自动认领(can_start 检查依赖) |\n| 队友状态 | WORK 或退出 | WORK → IDLE(轮询 60s) → SHUTDOWN |\n| claim_task | 无 owner 检查 | 拒绝已有 owner 的任务 |\n| IDLE 阶段关机 | 不处理 shutdown_request | 直接 dispatch shutdown 并退出 |\n| Lead inbox | 只打印,不进上下文 | consume_lead_inbox 统一注入 history |\n| 新函数 | — | idle_poll, scan_unclaimed_tasks, consume_lead_inbox |\n| 身份保持 | 仅 system prompt | 压缩后自动重注入 |\n| Lead 工具 | 14 (s16) | 14(不变) |\n| 队友工具 | 5 | 8(+ list_tasks, claim_task, complete_task) |\n| 队友退出条件 | 完成任务即退出 | 60s 无新任务才退出 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s17_autonomous_agents/code.py\n```\n\n试试这个 prompt:\n\n`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.`\n\n观察重点:队友是否自动认领了未分配的任务?有 blockedBy 依赖的任务是否在前置完成后被正确认领?空闲超时后是否自动关机?IDLE 阶段收到 shutdown_request 是否立即响应?`.tasks/` 目录下的任务状态如何变化?\n\n---\n\n## 接下来\n\n队友自组织了。但 Alice 和 Bob 都在同一个目录下工作——Alice 改 `config.py`,Bob 也改 `config.py`,互相覆盖。\n\ns18 Worktree Isolation → 每个任务有自己的工作目录,互不干扰。\n\n
\n深入 CC 源码\n\n> 教学说明:本章的 idle_poll + auto-claim 机制是教学设计,用统一的轮询函数演示\"空闲后找活干\"。CC 的实际实现是多个机制的组合,但目标一致——减少 Lead 的手动分配负担。\n\n### 一、CC 的空闲机制:组合路径,不是单一轮询\n\n教学版用一个 `idle_poll()` 统一处理空闲时的 inbox 检查和任务认领。CC 的实际实现是四个机制的组合:\n\n**idle_notification**:队友完成一轮工作后,`sendIdleNotification()`(`inProcessRunner.ts:569-589`)向 Lead 发送空闲通知。Lead 知道队友可用了,可以分配新任务或请求关机。\n\n**mailbox 轮询**:`waitForNextPromptOrShutdown()`(`inProcessRunner.ts:689-868`)是一个 **500ms 轮询循环**,持续检查三类来源:pending user messages、mailbox 文件消息、task list。shutdown_request 被优先处理(`inProcessRunner.ts:768-804`),不会被普通消息饿死。\n\n**task watcher**:`useTaskListWatcher`(`hooks/useTaskListWatcher.ts:34-189`)用 `fs.watch()` 监听 `.claude/tasks/` 目录变化,1 秒 debounce,当新任务创建或依赖解锁时触发检查。依赖判断(`L197-207`)是\"blockedBy 中没有未完成的任务\",不是\"blockedBy 为空\"。\n\n**主动 claim**:轮询循环内部也会调用 `tryClaimNextTask()`(`inProcessRunner.ts:853-860`)——在等待期间主动从 task list 领取任务。所以\"队友不主动轮询任务\"不准确,CC 同时有被动通知和主动认领。\n\n### 二、任务认领:文件锁 + 原子操作\n\n`claimTask()`(`utils/tasks.ts:541-612`)用 `proper-lockfile` 的任务文件锁,在锁内完成读-检查-改-写。检查项:owner 是否已存在(`L575-576`)、是否已完成(`L580-581`)、blockedBy 中是否有未完成任务(`L585-594`)。`claimTaskWithBusyCheck()`(`utils/tasks.ts:614-692`)用 task-list 级别锁,把 busy check 和 claim 做成原子操作,避免 TOCTOU。\n\n`findAvailableTask()`(`inProcessRunner.ts:595-604`)的依赖判断也是\"所有 blockedBy 已完成\",用 `task.blockedBy.every(id => !unresolvedTaskIds.has(id))` 实现。`tryClaimNextTask()`(`inProcessRunner.ts:624-657`)在认领后把状态更新为 `in_progress`,让 UI 立即反映变化。\n\n### 三、教学版 vs CC 对比\n\n| 维度 | 教学版 (s17) | CC |\n|------|-------------|-----|\n| 空闲机制 | idle_poll 统一轮询(5s) | idle_notification + 500ms mailbox 轮询 + task watcher |\n| 任务发现 | scan_unclaimed_tasks(轮询) | useTaskListWatcher(文件监听)+ tryClaimNextTask(主动轮询) |\n| 依赖判断 | can_start(所有 blockedBy 已完成) | findAvailableTask(同样语义) |\n| 并发安全 | owner 检查(无文件锁) | proper-lockfile 任务锁 + task-list 锁 |\n| shutdown 处理 | IDLE 直接分发,WORK 通过 handle_inbox_message | 500ms 轮询中优先处理 shutdown_request |\n| 超时退出 | 60s 无新任务 | 无固定超时,Lead 手动 shutdown |\n| 身份保持 | messages 长度检测 | context compaction 保留 system prompt |\n| claim 失败处理 | 检查返回值,失败不注入 | 文件锁保证原子性 |\n\n教学版的 `idle_poll()` 把 CC 的四个机制合并成一个轮询函数——简化合理,因为核心语义(空闲时找活干、依赖解锁后可认领、shutdown 优先)是一致的。\n\n
\n\n\n" + "title": "s17: Autonomous Agents — 不等 Lead 派活,空了自己上看板认领", + "content": "# s17: Autonomous Agents — 不等 Lead 派活,空了自己上看板认领\n\ns01 → ... → s15 → s16 → `s17` → [s18](/zh/s18) → s19 → s20\n\n> *\"不等 Lead 派活,空了自己上看板认领\"* — 空闲时轮询,有任务就认领。\n>\n> **Harness 层**: 自治 — 队友自组织,不依赖 Lead 分配。\n\n---\n\n## 问题\n\ns16 的队友能通信、能握手关机。但每个队友等 Lead 分配任务。如果任务看板上有 10 个未认领任务,Lead 得手动 assign 10 次。这不能扩展。队友应该自己看任务看板,发现没人做的任务就认领,做完再找下一个。\n\n---\n\n## 解决方案\n\n![Autonomous Agents Overview](/course-assets/s17_autonomous_agents/autonomous-agents-overview.svg)\n\n沿用 S16 的教学版 MessageBus 和协议工具。本章新增:**idle_poll**(空闲时每 5 秒轮询一次)、**scan_unclaimed_tasks**(扫描看板上可认领的任务)、**自动认领**(找到任务就 claim,无需 Lead 分配)。\n\n队友生命周期从两阶段变成三阶段:\n\n| 阶段 | 行为 | 退出条件 |\n|------|------|---------|\n| WORK | inbox → LLM → 工具循环 | `stop_reason != tool_use` |\n| IDLE | 每 5s 轮询 inbox + 任务板 | 60s 超时 |\n| SHUTDOWN | 发 summary,退出 | — |\n\n---\n\n## 工作原理\n\n### idle_poll: 空闲轮询\n\n队友完成当前任务后不退出,进入 IDLE 阶段,每 5 秒检查一次有没有新工作:\n\n```python\nIDLE_POLL_INTERVAL = 5 # seconds\nIDLE_TIMEOUT = 60 # seconds\n\ndef idle_poll(agent_name, messages, name, role) -> str:\n \"\"\"Return 'work', 'shutdown', or 'timeout'.\"\"\"\n for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):\n time.sleep(IDLE_POLL_INTERVAL)\n\n # ① 检查收件箱(优先)\n inbox = BUS.read_inbox(agent_name)\n if inbox:\n # shutdown_request 立即处理\n for msg in inbox:\n if msg.get(\"type\") == \"shutdown_request\":\n # ... 回复 shutdown_response\n return \"shutdown\"\n # 普通消息注入上下文,回到 WORK\n messages.append(...)\n return \"work\"\n\n # ② 扫描任务看板\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n task = unclaimed[0]\n result = claim_task(task[\"id\"], agent_name)\n if \"Claimed\" in result:\n messages.append(...)\n return \"work\"\n return \"timeout\"\n```\n\ninbox 优先(可能包含 shutdown_request 等协议消息),任务板其次。IDLE 阶段收到 shutdown_request 会直接回复并退出,不等到下一轮 WORK。\n\n### scan_unclaimed_tasks: 扫描任务看板\n\n找 pending 状态、无 owner、所有依赖已完成(`can_start`)的任务:\n\n```python\ndef scan_unclaimed_tasks() -> list[dict]:\n unclaimed = []\n for f in sorted(TASKS_DIR.glob(\"task_*.json\")):\n task = json.loads(f.read_text())\n if (task.get(\"status\") == \"pending\"\n and not task.get(\"owner\")\n and can_start(task[\"id\"])):\n unclaimed.append(task)\n return unclaimed\n```\n\n三个条件:必须是 pending、没有 owner、所有 blockedBy 依赖已完成。`can_start` 检查依赖任务的状态:有依赖不代表不能做,只有被未完成的任务阻塞才不能做。教学版按文件名排序取第一个;Claude Code 用文件锁防止多个队友同时认领同一个任务。\n\n### claim_task: owner 检查\n\n自动认领时检查 claim 结果,不把失败当成功:\n\n```python\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if task.owner:\n return f\"Task {task_id} already owned by {task.owner}\"\n if not can_start(task_id):\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n return f\"Claimed {task.id} ({task.subject})\"\n```\n\n教学版没有文件锁,并发认领可能出现竞争。但至少 `task.owner` 检查避免了最明显的\"后写覆盖\"问题。Claude Code 用 `proper-lockfile` 保护任务文件,`claimTask` 在文件锁内完成读-改-写(`utils/tasks.ts:541-612`)。\n\n### 队友生命周期: WORK → IDLE → SHUTDOWN\n\ns16 的队友做完任务就退出。s17 加了 IDLE 阶段,队友在外层循环中反复 WORK → IDLE:\n\n```python\n# Outer loop: WORK → IDLE cycle\nwhile True:\n # WORK phase: 内层循环(最多 10 轮 LLM 调用)\n for _ in range(10):\n # 检查 inbox、处理协议消息、调 LLM、执行工具\n ...\n if response.stop_reason != \"tool_use\":\n break # WORK 阶段结束\n\n # IDLE phase\n idle_result = idle_poll(name, messages, name, role)\n if idle_result == \"shutdown\":\n break\n if idle_result == \"timeout\":\n break # 60s 超时 → SHUTDOWN\n\n# SHUTDOWN: 发 summary 给 Lead\nBUS.send(name, \"lead\", summary, \"result\")\n```\n\n关键设计:\n- **外层 while True**:WORK 和 IDLE 交替进行,直到超时或收到关机请求\n- **内层 for 10**:WORK 阶段最多 10 轮 LLM 调用(防止无限循环)\n- **IDLE 超时 60 秒**:12 次轮询 × 5 秒 = 60 秒。超时后发送 summary 并退出\n- **shutdown_request 两阶段都能响应**:WORK 阶段通过 `handle_inbox_message` 分发;IDLE 阶段 `idle_poll` 直接检查并回复\n\n### 身份重注入\n\nautoCompact(s08)之后,队友的 messages 列表可能被压缩成一段摘要。每次进入新的 WORK 阶段时检查:\n\n```python\nif len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"You are '{name}', role: {role}. \"\n f\"Continue your work.\"})\n```\n\n消息过短说明发生了压缩,此时重新注入身份信息。真实 Claude Code 中 context compaction 会保留 system prompt,教学版的简化实现需要手动处理。\n\n### consume_lead_inbox: 统一 inbox 消费\n\n`check_inbox` 工具和主循环末尾都调用同一个 `consume_lead_inbox()` 函数:先路由协议 response 更新状态,再把所有消息注入 Lead 的对话历史。队友发来的 summary/result 不会只打印在终端,Lead 的 LLM 能看到并协调下一步。\n\n### 合起来跑\n\n```\n1. Lead: \"搭建后端——任务太多,让队友自己认领\"\n2. Lead → create_task(\"创建数据库 schema\")\n3. Lead → create_task(\"写 API 路由\")\n4. Lead → create_task(\"写单元测试\")\n5. Lead → spawn_teammate(\"alice\", \"backend\", \"你是后端开发者\")\n6. Lead → spawn_teammate(\"bob\", \"backend\", \"你是后端开发者\")\n\n7. alice 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE\n8. bob 线程启动 → WORK: 没有初始 inbox → 空转 → IDLE\n\n9. alice IDLE 第 1 次轮询 → scan_unclaimed → 发现\"创建数据库 schema\"\n10. alice → claim_task → \"创建数据库 schema\" → 回到 WORK\n11. bob IDLE 第 1 次轮询 → scan_unclaimed → 发现\"写 API 路由\"\n12. bob → claim_task → \"写 API 路由\" → 回到 WORK\n\n13. alice WORK: write_file(\"schema.sql\", ...) → complete_task → WORK 结束\n14. alice IDLE → scan → \"写单元测试\" → claim → WORK\n15. alice WORK: write_file(\"test_api.py\", ...) → complete_task → WORK 结束\n16. alice IDLE → 60s 无新任务 → SHUTDOWN\n\n17. bob 类似流程 → 做完 → SHUTDOWN\n18. Lead consume_lead_inbox → 看到 alice 和 bob 的 summary\n```\n\n两个队友并行认领、并行工作。\n\n---\n\n## 相对 s16 的变更\n\n| 组件 | 之前 (s16) | 之后 (s17) |\n|------|-----------|-----------|\n| 任务分配 | Lead 手动 assign | 队友自动认领(can_start 检查依赖) |\n| 队友状态 | WORK 或退出 | WORK → IDLE(轮询 60s) → SHUTDOWN |\n| claim_task | 无 owner 检查 | 拒绝已有 owner 的任务 |\n| IDLE 阶段关机 | 不处理 shutdown_request | 直接 dispatch shutdown 并退出 |\n| Lead inbox | 只打印,不进上下文 | consume_lead_inbox 统一注入 history |\n| 新函数 | — | idle_poll, scan_unclaimed_tasks, consume_lead_inbox |\n| 身份保持 | 仅 system prompt | 压缩后自动重注入 |\n| Lead 工具 | 14 (s16) | 14(不变) |\n| 队友工具 | 5 | 8(+ list_tasks, claim_task, complete_task) |\n| 队友退出条件 | 完成任务即退出 | 60s 无新任务才退出 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s17_autonomous_agents/code.py\n```\n\n试试这个 prompt:\n\n`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.`\n\n观察重点:队友是否自动认领了未分配的任务?有 blockedBy 依赖的任务是否在前置完成后被正确认领?空闲超时后是否自动关机?IDLE 阶段收到 shutdown_request 是否立即响应?`.tasks/` 目录下的任务状态如何变化?\n\n---\n\n## 接下来\n\n队友自组织了。但 Alice 和 Bob 都在同一个目录下工作:Alice 改 `config.py`,Bob 也改 `config.py`,互相覆盖。\n\ns18 Worktree Isolation → 每个任务有自己的工作目录,互不干扰。\n\n
\n深入 Claude Code 源码\n\n> 教学说明:本章的 idle_poll + auto-claim 机制是教学设计,用统一的轮询函数演示\"空闲后找活干\"。Claude Code 的实际实现是多个机制的组合,但目标一致,都是减少 Lead 的手动分配负担。\n\n### 一、Claude Code 的空闲机制:组合路径,不是单一轮询\n\n教学版用一个 `idle_poll()` 统一处理空闲时的 inbox 检查和任务认领。Claude Code 的实际实现是四个机制的组合:\n\n**idle_notification**:队友完成一轮工作后,`sendIdleNotification()`(`inProcessRunner.ts:569-589`)向 Lead 发送空闲通知。Lead 知道队友可用了,可以分配新任务或请求关机。\n\n**mailbox 轮询**:`waitForNextPromptOrShutdown()`(`inProcessRunner.ts:689-868`)是一个 **500ms 轮询循环**,持续检查三类来源:pending user messages、mailbox 文件消息、task list。shutdown_request 被优先处理(`inProcessRunner.ts:768-804`),不会被普通消息饿死。\n\n**task watcher**:`useTaskListWatcher`(`hooks/useTaskListWatcher.ts:34-189`)用 `fs.watch()` 监听 `.claude/tasks/` 目录变化,1 秒 debounce,当新任务创建或依赖解锁时触发检查。依赖判断(`L197-207`)是\"blockedBy 中没有未完成的任务\",不是\"blockedBy 为空\"。\n\n**主动 claim**:轮询循环内部也会调用 `tryClaimNextTask()`(`inProcessRunner.ts:853-860`),在等待期间主动从 task list 领取任务。所以\"队友不主动轮询任务\"不准确,Claude Code 同时有被动通知和主动认领。\n\n### 二、任务认领:文件锁 + 原子操作\n\n`claimTask()`(`utils/tasks.ts:541-612`)用 `proper-lockfile` 的任务文件锁,在锁内完成读-检查-改-写。检查项:owner 是否已存在(`L575-576`)、是否已完成(`L580-581`)、blockedBy 中是否有未完成任务(`L585-594`)。`claimTaskWithBusyCheck()`(`utils/tasks.ts:614-692`)用 task-list 级别锁,把 busy check 和 claim 做成原子操作,避免 TOCTOU。\n\n`findAvailableTask()`(`inProcessRunner.ts:595-604`)的依赖判断也是\"所有 blockedBy 已完成\",用 `task.blockedBy.every(id => !unresolvedTaskIds.has(id))` 实现。`tryClaimNextTask()`(`inProcessRunner.ts:624-657`)在认领后把状态更新为 `in_progress`,让 UI 立即反映变化。\n\n### 三、教学版 vs Claude Code 对比\n\n| 维度 | 教学版 (s17) | Claude Code |\n|------|-------------|-----|\n| 空闲机制 | idle_poll 统一轮询(5s) | idle_notification + 500ms mailbox 轮询 + task watcher |\n| 任务发现 | scan_unclaimed_tasks(轮询) | useTaskListWatcher(文件监听)+ tryClaimNextTask(主动轮询) |\n| 依赖判断 | can_start(所有 blockedBy 已完成) | findAvailableTask(同样语义) |\n| 并发安全 | owner 检查(无文件锁) | proper-lockfile 任务锁 + task-list 锁 |\n| shutdown 处理 | IDLE 直接分发,WORK 通过 handle_inbox_message | 500ms 轮询中优先处理 shutdown_request |\n| 超时退出 | 60s 无新任务 | 无固定超时,Lead 手动 shutdown |\n| 身份保持 | messages 长度检测 | context compaction 保留 system prompt |\n| claim 失败处理 | 检查返回值,失败不注入 | 文件锁保证原子性 |\n\n教学版的 `idle_poll()` 把 Claude Code 的四个机制合并成一个轮询函数。简化合理,因为核心语义(空闲时找活干、依赖解锁后可认领、shutdown 优先)是一致的。\n\n
\n\n\n" }, { "version": "s17", "locale": "ja", "title": "s17: Autonomous Agents — ボードを見て、自分で認領", - "content": "# s17: Autonomous Agents — ボードを見て、自分で認領\n\ns01 → ... → s15 → s16 → `s17` → [s18](/ja/s18) → s19 → s20\n\n> *\"ボードを見て、自分で認領\"* — 空き時にポーリング、仕事があれば開始。\n>\n> **Harness 層**: 自治 — チームメイトが自己組織化、リーダーの割り当て不要。\n\n---\n\n## 課題\n\ns16 のチームメイトは通信でき、シャットダウンハンドシェイクもできる。しかし各チームメイトは Lead がタスクを割り当てるのを待つ——ボードに 10 個の未認領タスクがあれば、Lead は 10 回手動で assign しなければならない。これはスケールしない。チームメイトは自分でタスクボードを見て、未認領のタスクを見つけて認領し、終わったら次を探すべき。\n\n---\n\n## ソリューション\n\n![Autonomous Agents Overview](/course-assets/s17_autonomous_agents/autonomous-agents-overview.ja.svg)\n\nS16 の教学版 MessageBus とプロトコルツールを踏襲。本章の追加:**idle_poll**(空き時に 5 秒ごとにポーリング)、**scan_unclaimed_tasks**(ボード上の認領可能なタスクをスキャン)、**自動認領**(見つけたら即座に claim、Lead 不要)。\n\nチームメイトのライフサイクルは 2 フェーズから 3 フェーズに:\n\n| フェーズ | 動作 | 終了条件 |\n|----------|------|---------|\n| WORK | inbox → LLM → ツールループ | `stop_reason != tool_use` |\n| IDLE | 5s ポーリング inbox + タスクボード | 60s タイムアウト |\n| SHUTDOWN | summary を送信、終了 | — |\n\n---\n\n## 仕組み\n\n### idle_poll: 空き時ポーリング\n\nチームメイトはタスク完了後も終了せず、IDLE フェーズに入る——5 秒ごとに新しい仕事がないか確認:\n\n```python\nIDLE_POLL_INTERVAL = 5 # seconds\nIDLE_TIMEOUT = 60 # seconds\n\ndef idle_poll(agent_name, messages, name, role) -> str:\n \"\"\"Return 'work', 'shutdown', or 'timeout'.\"\"\"\n for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):\n time.sleep(IDLE_POLL_INTERVAL)\n\n # ① 受信箱確認(優先)\n inbox = BUS.read_inbox(agent_name)\n if inbox:\n # shutdown_request は即座に処理\n for msg in inbox:\n if msg.get(\"type\") == \"shutdown_request\":\n # ... shutdown_response 返信\n return \"shutdown\"\n # 通常メッセージ:コンテキストに注入、WORK に戻る\n messages.append(...)\n return \"work\"\n\n # ② タスクボードスキャン\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n task = unclaimed[0]\n result = claim_task(task[\"id\"], agent_name)\n if \"Claimed\" in result:\n messages.append(...)\n return \"work\"\n return \"timeout\"\n```\n\ninbox を優先(shutdown_request 等のプロトコルメッセージの可能性)、タスクボードが次。IDLE フェーズで shutdown_request を受信すると即座に返信して終了し、次の WORK を待つ必要がない。\n\n### scan_unclaimed_tasks: タスクボードスキャン\n\npending 状態、owner なし、全依存関係完了(`can_start`)のタスクを検索:\n\n```python\ndef scan_unclaimed_tasks() -> list[dict]:\n unclaimed = []\n for f in sorted(TASKS_DIR.glob(\"task_*.json\")):\n task = json.loads(f.read_text())\n if (task.get(\"status\") == \"pending\"\n and not task.get(\"owner\")\n and can_start(task[\"id\"])):\n unclaimed.append(task)\n return unclaimed\n```\n\n3 つの条件:pending であること、owner がないこと、全 blockedBy 依存が完了していること。`can_start` は依存タスクの状態を確認——依存があるからといってタスクを開始できないわけではなく、未解決の依存のみがブロックする。教学版はファイル名順で最初のものを選択、CC はファイルロックで複数チームメイトの同時認領を防止。\n\n### claim_task: owner チェック\n\n自動認領時に claim 結果を確認し、失敗を成功として扱わない:\n\n```python\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if task.owner:\n return f\"Task {task_id} already owned by {task.owner}\"\n if not can_start(task_id):\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n return f\"Claimed {task.id} ({task.subject})\"\n```\n\n教学版にはファイルロックがないため、並行認領で競合する可能性がある。しかし `task.owner` チェックで最も明白な「後書き上書き」問題を回避。CC は `proper-lockfile` でタスクファイルを保護、`claimTask` はファイルロック内で read-modify-write を実行(`utils/tasks.ts:541-612`)。\n\n### チームメイトライフサイクル: WORK → IDLE → SHUTDOWN\n\ns16 のチームメイトはタスク完了後に終了。s17 は IDLE フェーズを追加——外側ループで WORK → IDLE を繰り返す:\n\n```python\n# 外側ループ: WORK → IDLE サイクル\nwhile True:\n # WORK フェーズ: 内側ループ(最大 10 ラウンド LLM 呼び出し)\n for _ in range(10):\n # inbox 確認、プロトコルメッセージ処理、LLM 呼び出し、ツール実行\n ...\n if response.stop_reason != \"tool_use\":\n break # WORK フェーズ終了\n\n # IDLE フェーズ\n idle_result = idle_poll(name, messages, name, role)\n if idle_result == \"shutdown\":\n break\n if idle_result == \"timeout\":\n break # 60s タイムアウト → SHUTDOWN\n\n# SHUTDOWN: summary を Lead に送信\nBUS.send(name, \"lead\", summary, \"result\")\n```\n\n主要設計:\n- **外側 while True**:WORK と IDLE がタイムアウトまたはシャットダウン要求まで交互に続く\n- **内側 for 10**:WORK フェーズは最大 10 ラウンドの LLM 呼び出し(無限ループ防止)\n- **IDLE タイムアウト 60 秒**:12 回ポーリング × 5 秒 = 60 秒。タイムアウト後 summary を送信して終了\n- **shutdown_request は両フェーズで応答**:WORK フェーズは `handle_inbox_message` でディスパッチ、IDLE フェーズは `idle_poll` が直接確認して返信\n\n### 身份再注入\n\nautoCompact(s08)後、チームメイトの messages リストが要約に圧縮される可能性がある。新しい WORK フェーズに入るたびに確認:\n\n```python\nif len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"You are '{name}', role: {role}. \"\n f\"Continue your work.\"})\n```\n\nメッセージが短い場合、圧縮が発生したことを示す——身份情報を再注入。真实 CC では context compaction が system prompt を保持、教学版の簡略実装は手動処理が必要。\n\n### consume_lead_inbox: 統一 inbox コンシューマ\n\n`check_inbox` ツールとメインループ末尾の両方が同じ `consume_lead_inbox()` 関数を呼び出す:プロトコル response を先にルーティングして状態を更新し、全メッセージを Lead の会話履歴に注入。チームメイトからの summary/result は端末に表示されるだけでなく、Lead の LLM も確認して次のステップを調整可能。\n\n### 組み合わせて実行\n\n```\n1. Lead: \"バックエンド構築——タスクが多すぎる、チームメイトに自己認領させる\"\n2. Lead → create_task(\"データベーススキーマを作成\")\n3. Lead → create_task(\"API ルートを書く\")\n4. Lead → create_task(\"ユニットテストを書く\")\n5. Lead → spawn_teammate(\"alice\", \"backend\", \"あなたはバックエンド開発者\")\n6. Lead → spawn_teammate(\"bob\", \"backend\", \"あなたはバックエンド開発者\")\n\n7. alice スレッド起動 → WORK: 初期 inbox なし → 空転 → IDLE\n8. bob スレッド起動 → WORK: 初期 inbox なし → 空転 → IDLE\n\n9. alice IDLE ポーリング 1 回目 → scan_unclaimed → \"データベーススキーマを作成\" を発見\n10. alice → claim_task → \"データベーススキーマを作成\" → WORK に戻る\n11. bob IDLE ポーリング 1 回目 → scan_unclaimed → \"API ルートを書く\" を発見\n12. bob → claim_task → \"API ルートを書く\" → WORK に戻る\n\n13. alice WORK: write_file(\"schema.sql\", ...) → complete_task → WORK 終了\n14. alice IDLE → scan → \"ユニットテストを書く\" → claim → WORK\n15. alice WORK: write_file(\"test_api.py\", ...) → complete_task → WORK 終了\n16. alice IDLE → 60s 新しいタスクなし → SHUTDOWN\n\n17. bob も同様のフロー → 完了 → SHUTDOWN\n18. Lead consume_lead_inbox → alice と bob の summary を確認\n```\n\n2 人のチームメイトが並行して認領・作業。Lead はタスクを作成してチームメイトを起動するだけで、手動割り当て不要。\n\n---\n\n## s16 からの変更\n\n| コンポーネント | 変更前 (s16) | 変更後 (s17) |\n|--------------|------------|------------|\n| タスク割り当て | Lead が手動 assign | チームメイトが自動認領(can_start で依存確認) |\n| チームメイト状態 | WORK または終了 | WORK → IDLE(60s ポーリング) → SHUTDOWN |\n| claim_task | owner チェックなし | 既に owner があるタスクを拒否 |\n| IDLE フェーズシャットダウン | shutdown_request を処理しない | 即座にシャットダウンをディスパッチして終了 |\n| Lead inbox | 印刷のみ、コンテキストに入らない | consume_lead_inbox で history に注入 |\n| 新規関数 | — | idle_poll, scan_unclaimed_tasks, consume_lead_inbox |\n| 身份保持 | system prompt のみ | 圧縮後に自動再注入 |\n| Lead ツール | 14 (s16) | 14(変更なし) |\n| チームメイトツール | 5 | 8(+ list_tasks, claim_task, complete_task) |\n| チームメイト終了条件 | タスク完了後即終了 | 60s アイドルタイムアウト後のみ終了 |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s17_autonomous_agents/code.py\n```\n\n以下のプロンプトを試してください:\n\n`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.`\n\n観察ポイント:チームメイトは未割り当てのタスクを自動認領したか?blockedBy 依存のあるタスクは依存完了後に正しく認領されたか?アイドルタイムアウトでシャットダウンしたか?IDLE フェーズで shutdown_request に即座に応答したか?`.tasks/` ディレクトリのタスク状態はどう変化したか?\n\n---\n\n## 次の章\n\nチームメイトが自己組織化した。しかし Alice も Bob も同じディレクトリで作業——Alice が `config.py` を編集し、Bob も `config.py` を編集して互いに上書きしてしまう。\n\ns18 Worktree Isolation → 各タスクに専用の作業ディレクトリ、競合なし。\n\n
\nCC ソースコード深掘り\n\n> 教学注記:本章の idle_poll + auto-claim 機構は教学設計であり、統一ポーリング関数で「空き時に仕事を探す」をデモ。CC の実際の実装は複数機構の組み合わせだが、目標は同じ——Lead の手動割り当て負担を軽減。\n\n### 一、CC の空き機構:組み合わせ路径、単一ポーリングではない\n\n教学版は 1 つの `idle_poll()` で空き時の inbox 確認とタスク認領を統一処理。CC の実際の実装は 4 つの機構の組み合わせ:\n\n**idle_notification**:チームメイトが 1 ラウンドの作業を完了後、`sendIdleNotification()`(`inProcessRunner.ts:569-589`)が Lead に空き通知を送信。Lead はチームメイトが利用可能であることを知り、新しいタスクを割り当てたりシャットダウンを要求可能。\n\n**mailbox ポーリング**:`waitForNextPromptOrShutdown()`(`inProcessRunner.ts:689-868`)は **500ms ポーリングループ**で、3 つのソースを継続チェック:pending user messages、mailbox ファイルメッセージ、task list。shutdown_request は優先処理(`inProcessRunner.ts:768-804`)、通常メッセージによる飢餓を防止。\n\n**task watcher**:`useTaskListWatcher`(`hooks/useTaskListWatcher.ts:34-189`)が `fs.watch()` で `.claude/tasks/` ディレクトリの変化を監視、1 秒 debounce で新タスク作成や依存アンロック時にチェックをトリガー。依存判断(`L197-207`)は「blockedBy に未完了タスクがない」で、「blockedBy が空」ではない。\n\n**能動 claim**:ポーリングループ内でも `tryClaimNextTask()`(`inProcessRunner.ts:853-860`)を呼び出し——待機中に task list から能動的にタスクを認領。したがって「チームメイトは能動的にタスクをポーリングしない」は不正確、CC は受動通知と能動認領の両方を持つ。\n\n### 二、タスク認領:ファイルロック + 原子操作\n\n`claimTask()`(`utils/tasks.ts:541-612`)は `proper-lockfile` のタスクファイルロックを使用、ロック内で read-check-modify-write を実行。チェック項目:owner が既に存在(`L575-576`)、完了済み(`L580-581`)、blockedBy に未完了タスクがあるか(`L585-594`)。`claimTaskWithBusyCheck()`(`utils/tasks.ts:614-692`)はタスクリストレベルロックを使用、busy check と claim を原子操作にして TOCTOU を回避。\n\n`findAvailableTask()`(`inProcessRunner.ts:595-604`)の依存判断も「全 blockedBy 完了」で、`task.blockedBy.every(id => !unresolvedTaskIds.has(id))` で実装。`tryClaimNextTask()`(`inProcessRunner.ts:624-657`)は認領後 status を `in_progress` に更新、UI に即座に反映。\n\n### 三、教学版 vs CC 対比\n\n| 次元 | 教学版 (s17) | CC |\n|------|-------------|-----|\n| 空き機構 | idle_poll 統一ポーリング(5s) | idle_notification + 500ms mailbox ポーリング + task watcher |\n| タスク発見 | scan_unclaimed_tasks(ポーリング) | useTaskListWatcher(ファイル監視)+ tryClaimNextTask(能動ポーリング) |\n| 依存チェック | can_start(全 blockedBy 完了) | findAvailableTask(同じセマンティクス) |\n| 並行安全性 | owner チェック(ファイルロックなし) | proper-lockfile タスクロック + タスクリストロック |\n| shutdown 処理 | IDLE 直接ディスパッチ、WORK は handle_inbox_message | 500ms ポーリングループで shutdown_request を優先 |\n| タイムアウト終了 | 60s 新しいタスクなし | 固定タイムアウトなし、Lead 手動 shutdown |\n| 身份保持 | messages 長さ検出 | context compaction が system prompt を保持 |\n| claim 失敗処理 | 戻り値を確認、失敗時はスキップ | ファイルロックで原子性を保証 |\n\n教学版の `idle_poll()` は CC の 4 つの機構を 1 つのポーリング関数に統合——核心セマンティクス(空き時に仕事を探す、依存アンロック後に認領、shutdown 優先)が一致するため、合理的な簡略化。\n\n
\n\n\n" + "content": "# s17: Autonomous Agents — ボードを見て、自分で認領\n\ns01 → ... → s15 → s16 → `s17` → [s18](/ja/s18) → s19 → s20\n\n> *\"ボードを見て、自分で認領\"* — 空き時にポーリング、仕事があれば開始。\n>\n> **Harness 層**: 自治 — チームメイトが自己組織化、リーダーの割り当て不要。\n\n---\n\n## 課題\n\ns16 のチームメイトは通信でき、シャットダウンハンドシェイクもできる。しかし各チームメイトは Lead がタスクを割り当てるのを待つ。ボードに 10 個の未認領タスクがあれば、Lead は 10 回手動で assign しなければならない。これはスケールしない。チームメイトは自分でタスクボードを見て、未認領のタスクを見つけて認領し、終わったら次を探すべき。\n\n---\n\n## ソリューション\n\n![Autonomous Agents Overview](/course-assets/s17_autonomous_agents/autonomous-agents-overview.svg)\n\nS16 の教学版 MessageBus とプロトコルツールを踏襲。本章の追加:**idle_poll**(空き時に 5 秒ごとにポーリング)、**scan_unclaimed_tasks**(ボード上の認領可能なタスクをスキャン)、**自動認領**(見つけたら即座に claim、Lead 不要)。\n\nチームメイトのライフサイクルは 2 フェーズから 3 フェーズに:\n\n| フェーズ | 動作 | 終了条件 |\n|----------|------|---------|\n| WORK | inbox → LLM → ツールループ | `stop_reason != tool_use` |\n| IDLE | 5s ポーリング inbox + タスクボード | 60s タイムアウト |\n| SHUTDOWN | summary を送信、終了 | — |\n\n---\n\n## 仕組み\n\n### idle_poll: 空き時ポーリング\n\nチームメイトはタスク完了後も終了せず、IDLE フェーズに入り、5 秒ごとに新しい仕事がないか確認する:\n\n```python\nIDLE_POLL_INTERVAL = 5 # seconds\nIDLE_TIMEOUT = 60 # seconds\n\ndef idle_poll(agent_name, messages, name, role) -> str:\n \"\"\"Return 'work', 'shutdown', or 'timeout'.\"\"\"\n for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):\n time.sleep(IDLE_POLL_INTERVAL)\n\n # ① 受信箱確認(優先)\n inbox = BUS.read_inbox(agent_name)\n if inbox:\n # shutdown_request は即座に処理\n for msg in inbox:\n if msg.get(\"type\") == \"shutdown_request\":\n # ... shutdown_response 返信\n return \"shutdown\"\n # 通常メッセージ:コンテキストに注入、WORK に戻る\n messages.append(...)\n return \"work\"\n\n # ② タスクボードスキャン\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n task = unclaimed[0]\n result = claim_task(task[\"id\"], agent_name)\n if \"Claimed\" in result:\n messages.append(...)\n return \"work\"\n return \"timeout\"\n```\n\ninbox を優先(shutdown_request 等のプロトコルメッセージの可能性)、タスクボードが次。IDLE フェーズで shutdown_request を受信すると即座に返信して終了し、次の WORK を待つ必要がない。\n\n### scan_unclaimed_tasks: タスクボードスキャン\n\npending 状態、owner なし、全依存関係完了(`can_start`)のタスクを検索:\n\n```python\ndef scan_unclaimed_tasks() -> list[dict]:\n unclaimed = []\n for f in sorted(TASKS_DIR.glob(\"task_*.json\")):\n task = json.loads(f.read_text())\n if (task.get(\"status\") == \"pending\"\n and not task.get(\"owner\")\n and can_start(task[\"id\"])):\n unclaimed.append(task)\n return unclaimed\n```\n\n3 つの条件:pending であること、owner がないこと、全 blockedBy 依存が完了していること。`can_start` は依存タスクの状態を確認する。依存があるからといってタスクを開始できないわけではなく、未解決の依存のみがブロックする。教学版はファイル名順で最初のものを選択、Claude Code はファイルロックで複数チームメイトの同時認領を防止。\n\n### claim_task: owner チェック\n\n自動認領時に claim 結果を確認し、失敗を成功として扱わない:\n\n```python\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if task.owner:\n return f\"Task {task_id} already owned by {task.owner}\"\n if not can_start(task_id):\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n return f\"Claimed {task.id} ({task.subject})\"\n```\n\n教学版にはファイルロックがないため、並行認領で競合する可能性がある。しかし `task.owner` チェックで最も明白な「後書き上書き」問題を回避。Claude Code は `proper-lockfile` でタスクファイルを保護、`claimTask` はファイルロック内で read-modify-write を実行(`utils/tasks.ts:541-612`)。\n\n### チームメイトライフサイクル: WORK → IDLE → SHUTDOWN\n\ns16 のチームメイトはタスク完了後に終了。s17 は IDLE フェーズを追加し、外側ループで WORK → IDLE を繰り返す:\n\n```python\n# 外側ループ: WORK → IDLE サイクル\nwhile True:\n # WORK フェーズ: 内側ループ(最大 10 ラウンド LLM 呼び出し)\n for _ in range(10):\n # inbox 確認、プロトコルメッセージ処理、LLM 呼び出し、ツール実行\n ...\n if response.stop_reason != \"tool_use\":\n break # WORK フェーズ終了\n\n # IDLE フェーズ\n idle_result = idle_poll(name, messages, name, role)\n if idle_result == \"shutdown\":\n break\n if idle_result == \"timeout\":\n break # 60s タイムアウト → SHUTDOWN\n\n# SHUTDOWN: summary を Lead に送信\nBUS.send(name, \"lead\", summary, \"result\")\n```\n\n主要設計:\n- **外側 while True**:WORK と IDLE がタイムアウトまたはシャットダウン要求まで交互に続く\n- **内側 for 10**:WORK フェーズは最大 10 ラウンドの LLM 呼び出し(無限ループ防止)\n- **IDLE タイムアウト 60 秒**:12 回ポーリング × 5 秒 = 60 秒。タイムアウト後 summary を送信して終了\n- **shutdown_request は両フェーズで応答**:WORK フェーズは `handle_inbox_message` でディスパッチ、IDLE フェーズは `idle_poll` が直接確認して返信\n\n### 身份再注入\n\nautoCompact(s08)後、チームメイトの messages リストが要約に圧縮される可能性がある。新しい WORK フェーズに入るたびに確認:\n\n```python\nif len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"You are '{name}', role: {role}. \"\n f\"Continue your work.\"})\n```\n\nメッセージが短い場合、圧縮が発生したことを示すため、アイデンティティ情報を再注入する。真实 Claude Code では context compaction が system prompt を保持、教学版の簡略実装は手動処理が必要。\n\n### consume_lead_inbox: 統一 inbox コンシューマ\n\n`check_inbox` ツールとメインループ末尾の両方が同じ `consume_lead_inbox()` 関数を呼び出す:プロトコル response を先にルーティングして状態を更新し、全メッセージを Lead の会話履歴に注入。チームメイトからの summary/result は端末に表示されるだけでなく、Lead の LLM も確認して次のステップを調整可能。\n\n### 組み合わせて実行\n\n```\n1. Lead: \"バックエンド構築——タスクが多すぎる、チームメイトに自己認領させる\"\n2. Lead → create_task(\"データベーススキーマを作成\")\n3. Lead → create_task(\"API ルートを書く\")\n4. Lead → create_task(\"ユニットテストを書く\")\n5. Lead → spawn_teammate(\"alice\", \"backend\", \"あなたはバックエンド開発者\")\n6. Lead → spawn_teammate(\"bob\", \"backend\", \"あなたはバックエンド開発者\")\n\n7. alice スレッド起動 → WORK: 初期 inbox なし → 空転 → IDLE\n8. bob スレッド起動 → WORK: 初期 inbox なし → 空転 → IDLE\n\n9. alice IDLE ポーリング 1 回目 → scan_unclaimed → \"データベーススキーマを作成\" を発見\n10. alice → claim_task → \"データベーススキーマを作成\" → WORK に戻る\n11. bob IDLE ポーリング 1 回目 → scan_unclaimed → \"API ルートを書く\" を発見\n12. bob → claim_task → \"API ルートを書く\" → WORK に戻る\n\n13. alice WORK: write_file(\"schema.sql\", ...) → complete_task → WORK 終了\n14. alice IDLE → scan → \"ユニットテストを書く\" → claim → WORK\n15. alice WORK: write_file(\"test_api.py\", ...) → complete_task → WORK 終了\n16. alice IDLE → 60s 新しいタスクなし → SHUTDOWN\n\n17. bob も同様のフロー → 完了 → SHUTDOWN\n18. Lead consume_lead_inbox → alice と bob の summary を確認\n```\n\n2 人のチームメイトが並行して認領・作業。\n\n---\n\n## s16 からの変更\n\n| コンポーネント | 変更前 (s16) | 変更後 (s17) |\n|--------------|------------|------------|\n| タスク割り当て | Lead が手動 assign | チームメイトが自動認領(can_start で依存確認) |\n| チームメイト状態 | WORK または終了 | WORK → IDLE(60s ポーリング) → SHUTDOWN |\n| claim_task | owner チェックなし | 既に owner があるタスクを拒否 |\n| IDLE フェーズシャットダウン | shutdown_request を処理しない | 即座にシャットダウンをディスパッチして終了 |\n| Lead inbox | 印刷のみ、コンテキストに入らない | consume_lead_inbox で history に注入 |\n| 新規関数 | — | idle_poll, scan_unclaimed_tasks, consume_lead_inbox |\n| 身份保持 | system prompt のみ | 圧縮後に自動再注入 |\n| Lead ツール | 14 (s16) | 14(変更なし) |\n| チームメイトツール | 5 | 8(+ list_tasks, claim_task, complete_task) |\n| チームメイト終了条件 | タスク完了後即終了 | 60s アイドルタイムアウト後のみ終了 |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s17_autonomous_agents/code.py\n```\n\n以下のプロンプトを試してください:\n\n`Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim and work.`\n\n観察ポイント:チームメイトは未割り当てのタスクを自動認領したか?blockedBy 依存のあるタスクは依存完了後に正しく認領されたか?アイドルタイムアウトでシャットダウンしたか?IDLE フェーズで shutdown_request に即座に応答したか?`.tasks/` ディレクトリのタスク状態はどう変化したか?\n\n---\n\n## 次の章\n\nチームメイトが自己組織化した。しかし Alice も Bob も同じディレクトリで作業している。Alice が `config.py` を編集し、Bob も `config.py` を編集して互いに上書きしてしまう。\n\ns18 Worktree Isolation → 各タスクに専用の作業ディレクトリ、競合なし。\n\n
\nClaude Code ソースコード深掘り\n\n> 教学注記:本章の idle_poll + auto-claim 機構は教学設計であり、統一ポーリング関数で「空き時に仕事を探す」をデモ。Claude Code の実際の実装は複数機構の組み合わせだが、目標は同じで、Lead の手動割り当て負担を軽減すること。\n\n### 一、Claude Code の空き機構:組み合わせ路径、単一ポーリングではない\n\n教学版は 1 つの `idle_poll()` で空き時の inbox 確認とタスク認領を統一処理。Claude Code の実際の実装は 4 つの機構の組み合わせ:\n\n**idle_notification**:チームメイトが 1 ラウンドの作業を完了後、`sendIdleNotification()`(`inProcessRunner.ts:569-589`)が Lead に空き通知を送信。Lead はチームメイトが利用可能であることを知り、新しいタスクを割り当てたりシャットダウンを要求可能。\n\n**mailbox ポーリング**:`waitForNextPromptOrShutdown()`(`inProcessRunner.ts:689-868`)は **500ms ポーリングループ**で、3 つのソースを継続チェック:pending user messages、mailbox ファイルメッセージ、task list。shutdown_request は優先処理(`inProcessRunner.ts:768-804`)、通常メッセージによる飢餓を防止。\n\n**task watcher**:`useTaskListWatcher`(`hooks/useTaskListWatcher.ts:34-189`)が `fs.watch()` で `.claude/tasks/` ディレクトリの変化を監視、1 秒 debounce で新タスク作成や依存アンロック時にチェックをトリガー。依存判断(`L197-207`)は「blockedBy に未完了タスクがない」で、「blockedBy が空」ではない。\n\n**能動 claim**:ポーリングループ内でも `tryClaimNextTask()`(`inProcessRunner.ts:853-860`)を呼び出し、待機中に task list から能動的にタスクを認領。したがって「チームメイトは能動的にタスクをポーリングしない」は不正確、Claude Code は受動通知と能動認領の両方を持つ。\n\n### 二、タスク認領:ファイルロック + 原子操作\n\n`claimTask()`(`utils/tasks.ts:541-612`)は `proper-lockfile` のタスクファイルロックを使用、ロック内で read-check-modify-write を実行。チェック項目:owner が既に存在(`L575-576`)、完了済み(`L580-581`)、blockedBy に未完了タスクがあるか(`L585-594`)。`claimTaskWithBusyCheck()`(`utils/tasks.ts:614-692`)はタスクリストレベルロックを使用、busy check と claim を原子操作にして TOCTOU を回避。\n\n`findAvailableTask()`(`inProcessRunner.ts:595-604`)の依存判断も「全 blockedBy 完了」で、`task.blockedBy.every(id => !unresolvedTaskIds.has(id))` で実装。`tryClaimNextTask()`(`inProcessRunner.ts:624-657`)は認領後 status を `in_progress` に更新、UI に即座に反映。\n\n### 三、教学版 vs Claude Code 対比\n\n| 次元 | 教学版 (s17) | Claude Code |\n|------|-------------|-----|\n| 空き機構 | idle_poll 統一ポーリング(5s) | idle_notification + 500ms mailbox ポーリング + task watcher |\n| タスク発見 | scan_unclaimed_tasks(ポーリング) | useTaskListWatcher(ファイル監視)+ tryClaimNextTask(能動ポーリング) |\n| 依存チェック | can_start(全 blockedBy 完了) | findAvailableTask(同じセマンティクス) |\n| 並行安全性 | owner チェック(ファイルロックなし) | proper-lockfile タスクロック + タスクリストロック |\n| shutdown 処理 | IDLE 直接ディスパッチ、WORK は handle_inbox_message | 500ms ポーリングループで shutdown_request を優先 |\n| タイムアウト終了 | 60s 新しいタスクなし | 固定タイムアウトなし、Lead 手動 shutdown |\n| 身份保持 | messages 長さ検出 | context compaction が system prompt を保持 |\n| claim 失敗処理 | 戻り値を確認、失敗時はスキップ | ファイルロックで原子性を保証 |\n\n教学版の `idle_poll()` は Claude Code の 4 つの機構を 1 つのポーリング関数に統合した。核心セマンティクス(空き時に仕事を探す、依存アンロック後に認領、shutdown 優先)が一致するため、合理的な簡略化。\n\n
\n\n\n" }, { "version": "s18", "locale": "en", "title": "s18: Worktree Isolation — Separate Directories, No Conflicts", - "content": "# s18: Worktree Isolation — Separate Directories, No Conflicts\n\ns01 → ... → s16 → s17 → `s18` → [s19](/en/s19) → s20\n\n> *\"Separate directories, no conflicts\"* — Tasks own the goal, worktrees own the directory, bound by ID.\n>\n> **Harness Layer**: Isolation — Parallel execution in separate directories.\n\n---\n\n## The Problem\n\nIn s17, Alice and Bob both work in the same directory. Alice's task is \"refactor auth module\", Bob's task is \"refactor UI login page\".\n\nAlice calls `write_file(\"config.py\", ...)`. Bob also calls `write_file(\"config.py\", ...)`. Both edit the same file, overwriting each other. And there's no clean rollback — you can't tell whose changes are whose.\n\ns15-s17 solved \"who does what\" (task system) and \"how to communicate\" (message bus), but not \"where to work\".\n\n---\n\n## The Solution\n\n![Worktree Overview](/course-assets/s18_worktree_isolation/worktree-overview.en.svg)\n\nGit worktree lets you create multiple independent working directories in the same repo, each with its own branch. Alice works in `.worktrees/auth-refactor/`, Bob in `.worktrees/ui-login/` — no conflicts.\n\nCarries forward S17's teaching-version MessageBus, protocols, and autonomous claiming. This chapter adds:\n\n| Capability | Purpose |\n|------------|---------|\n| create_worktree | Create isolated directory + branch for a task |\n| bind_task_to_worktree | Bind task and directory (no status change) |\n| remove_worktree / keep_worktree | Cleanup or preserve after completion |\n| validate_worktree_name | Reject path traversal and illegal characters |\n\n---\n\n## How It Works\n\n### Creation: Task-Worktree Binding\n\n```python\ndef create_worktree(name: str, task_id: str = \"\") -> str:\n validate_worktree_name(name) # Only [A-Za-z0-9._-]{1,64}\n path = WORKTREES_DIR / name\n ok, result = run_git([\"worktree\", \"add\", str(path), \"-b\", f\"wt/{name}\", \"HEAD\"])\n if not ok:\n return f\"Git error: {result}\"\n if task_id:\n bind_task_to_worktree(task_id, name)\n log_event(\"create\", name, task_id)\n return f\"Worktree '{name}' created at {path}\"\n\ndef bind_task_to_worktree(task_id: str, worktree_name: str):\n task = load_task(task_id)\n task.worktree = worktree_name # Write worktree field only\n save_task(task) # Status stays pending, waits for teammate claim\n```\n\nBinding rule: one task binds to one worktree. Binding does NOT change task status — the task stays `pending`, and advances to `in_progress` only when a teammate claims it. This way Lead can pre-create tasks and worktrees, and teammates naturally claim worktree-bound tasks during idle.\n\n### Teammate Tool Cwd Switching\n\nTeaching version maintains a `wt_ctx` dict per teammate, tracking the current worktree path. When a teammate claims a task with a worktree, `wt_ctx` is automatically set to the worktree path; the teammate's `bash`, `read_file`, `write_file` execute in the worktree directory:\n\n```python\n# Inside teammate thread\nwt_ctx = {\"path\": None}\n\ndef _run_claim_task(task_id):\n result = claim_task(task_id, owner=name)\n if \"Claimed\" in result:\n task = load_task(task_id)\n if task.worktree:\n wt_ctx[\"path\"] = str(WORKTREES_DIR / task.worktree)\n return result\n\ndef _run_bash(command):\n return run_bash(command, cwd=wt_ctx[\"path\"]) # Execute in worktree\n```\n\nThis is a teaching simplification. Real CC's EnterWorktree uses `process.chdir()` to switch the entire process directory, and AgentTool isolation uses `cwdOverride` to wrap sub-agent execution.\n\n### Cleanup: Keep or Remove\n\nAfter task completion, two choices:\n\n```python\ndef remove_worktree(name: str, discard_changes: bool = False) -> str:\n # Safety check: refuse by default if changes exist\n if not discard_changes:\n files, commits = _count_worktree_changes(path)\n if files > 0 or commits > 0:\n return \"Has uncommitted changes. Use discard_changes=true to force, or keep_worktree\"\n ok, _ = run_git([\"worktree\", \"remove\", str(path), \"--force\"])\n if not ok:\n return \"Remove failed\"\n run_git([\"branch\", \"-D\", f\"wt/{name}\"])\n log_event(\"remove\", name)\n\ndef keep_worktree(name: str) -> str:\n log_event(\"keep\", name)\n return f\"Worktree '{name}' kept for review (branch: wt/{name})\"\n```\n\nKeep = preserve branch for manual review and merge. Remove = refuse by default if uncommitted changes; requires `discard_changes=true` to confirm. Does NOT auto-complete task — task completion is triggered explicitly by the teammate's `complete_task`.\n\n### Event Log: Auditable\n\nEach lifecycle operation writes to a log for auditing:\n\n```python\ndef log_event(event_type: str, worktree_name: str, task_id: str = \"\"):\n event = {\"type\": event_type, \"worktree\": worktree_name,\n \"task_id\": task_id, \"ts\": time.time()}\n # append to .worktrees/events.jsonl\n```\n\nEvent types: `create`, `remove`, `keep`. Teaching version logs events for manual auditing; full recovery would need an index or `git worktree list` scanning.\n\n### run_git: Returns Success/Failure\n\n```python\ndef run_git(args: list[str]) -> tuple[bool, str]:\n r = subprocess.run([\"git\"] + args, cwd=WORKDIR, ...)\n return r.returncode == 0, output\n```\n\n`create_worktree` and `remove_worktree` only write event logs after successful git commands, ensuring logs reflect actual state.\n\n---\n\n## Changes from s17\n\n| Component | Before (s17) | After (s18) |\n|-----------|-------------|-------------|\n| Working directory | All agents share WORKDIR | Each task can bind to a git worktree |\n| Task data | id/subject/status/owner/blockedBy | + worktree field |\n| Teammate tool cwd | Always WORKDIR | Auto-switches when claiming worktree-bound task |\n| New functions | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name |\n| Worktree safety | None | Name validation + refuse removal with changes |\n| Event log | None | events.jsonl lifecycle auditing |\n| Lead tools | 14 (s17) | + create_worktree, remove_worktree, keep_worktree (17) |\n| Teammate tools | 8 (s17) | 8 (bash/read/write execute in worktree cwd) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s18_worktree_isolation/code.py\n```\n\nTry this prompt:\n\n`Create two tasks, then create worktrees for each (bind with task_id). Spawn alice and bob. Watch them auto-claim and work in isolated directories.`\n\nWhat to observe: Do both worktrees show different branches in `git status`? After claiming a worktree-bound task, does the teammate's bash run in the worktree directory? Does `remove_worktree` refuse when there are changes? Is task status still `pending` after binding?\n\n---\n\n## What's Next\n\nAgent teams can now self-organize in isolated workspaces. But Agent capabilities are limited to the tools we wrote — bash, read, write, task...\n\nWhat if users already have their own tools? Like an internal Jira API, or a custom deployment system?\n\ns19 MCP Plugin → Give Agent a plugin system. External tools connect via standard protocol; Agent doesn't need to know who wrote them.\n\n
\nDeep Dive into CC Source\n\nCC's worktree system has two paths: **EnterWorktree** (current session switches in) and **AgentTool isolation** (sub-agent isolation).\n\n### EnterWorktree: Current Session Switch\n\n`EnterWorktreeTool.ts:92-97` after creating the worktree, immediately calls `process.chdir(worktreePath)`, `setCwd()`, `setOriginalCwd()`, `saveWorktreeState()`. The current session's working directory switches directly to the worktree — not a prompt hint, but a process-level directory change.\n\n`ExitWorktreeTool.ts:261-320` both keep and remove call `restoreSessionToOriginalCwd()` to restore the original directory. Remove checks for uncommitted changes (`ExitWorktreeTool.ts:190-220`), refusing without `discard_changes: true`.\n\n### AgentTool Isolation: Sub-Agent Isolation\n\n`AgentTool.tsx:590-641` when `isolation: \"worktree\"`, calls `createAgentWorktree()` to create a worktree, uses `cwdOverridePath` to wrap sub-agent execution. All sub-agent operations automatically run in the worktree directory. `AgentTool/prompt.ts:272` tells the model: this is a temporary worktree, auto-cleanup if no changes, return path and branch if changes exist.\n\n`worktree.ts:902-951` `createAgentWorktree()` does NOT modify global session cwd, only for sub-agent use. `worktree.ts:961-1020` `removeAgentWorktree()` deletes from the main repo root.\n\n### Name Validation\n\n`worktree.ts:76-84` validates slug: rejects `.`/`..`, allows `[a-zA-Z0-9._-]`. `worktree.ts:48` defines `VALID_WORKTREE_SLUG_SEGMENT`. Teaching version's `validate_worktree_name` uses the same rule.\n\n### Path and Branch Naming\n\nReal path is `.claude/worktrees/`, branch name `worktree-{slug}` (`worktree.ts:204-227`, slashes replaced with `+`). Teaching version uses `.worktrees/` and `wt/{name}` for simplicity.\n\nCreation uses `git worktree add -B` (`worktree.ts:326-328`), preferring `origin/` over current HEAD.\n\n### State Management\n\nCC has no task-worktree binding. Worktree state is managed through `PersistedWorktreeSession` (`worktree.ts:756-768`), with fields including `originalCwd`, `worktreePath`, `worktreeName`, `worktreeBranch`, `originalBranch`, `originalHeadCommit`, `sessionId`, etc. — no taskId field. `saveWorktreeState()` (`sessionStorage.ts:2883-2920`) writes to session transcript with `type: 'worktree-state'`.\n\nTeaching version uses the task's `worktree` field for binding, a teaching simplification. CC treats worktree and task as two independent systems, connected through the Agent's context understanding.\n\n
\n\n\n" + "content": "# s18: Worktree Isolation — Separate Directories, No Conflicts\n\ns01 → ... → s16 → s17 → `s18` → [s19](/en/s19) → s20\n\n> *\"Separate directories, no conflicts\"* — Tasks own the goal, worktrees own the directory, bound by ID.\n>\n> **Harness Layer**: Isolation — Parallel execution in separate directories.\n\n---\n\n## The Problem\n\nIn s17, Alice and Bob both work in the same directory. Alice's task is \"refactor auth module\", Bob's task is \"refactor UI login page\".\n\nAlice calls `write_file(\"config.py\", ...)`. Bob also calls `write_file(\"config.py\", ...)`. Both edit the same file, overwriting each other. And there's no clean rollback; you can't tell whose changes are whose.\n\ns15-s17 solved \"who does what\" (task system) and \"how to communicate\" (message bus), but not \"where to work\".\n\n---\n\n## The Solution\n\n![Worktree Overview](/course-assets/s18_worktree_isolation/worktree-overview.svg)\n\nGit worktree lets you create multiple independent working directories in the same repo, each with its own branch. Alice works in `.worktrees/auth-refactor/`, Bob in `.worktrees/ui-login/`, with completely independent file systems.\n\nCarries forward S17's teaching-version MessageBus, protocols, and autonomous claiming. This chapter adds:\n\n| Capability | Purpose |\n|------------|---------|\n| create_worktree | Create isolated directory + branch for a task |\n| bind_task_to_worktree | Bind task and directory (no status change) |\n| remove_worktree / keep_worktree | Cleanup or preserve after completion |\n| validate_worktree_name | Reject path traversal and illegal characters |\n\n---\n\n## How It Works\n\n### Creation: Task-Worktree Binding\n\n```python\ndef create_worktree(name: str, task_id: str = \"\") -> str:\n validate_worktree_name(name) # Only [A-Za-z0-9._-]{1,64}\n path = WORKTREES_DIR / name\n ok, result = run_git([\"worktree\", \"add\", str(path), \"-b\", f\"wt/{name}\", \"HEAD\"])\n if not ok:\n return f\"Git error: {result}\"\n if task_id:\n bind_task_to_worktree(task_id, name)\n log_event(\"create\", name, task_id)\n return f\"Worktree '{name}' created at {path}\"\n\ndef bind_task_to_worktree(task_id: str, worktree_name: str):\n task = load_task(task_id)\n task.worktree = worktree_name # Write worktree field only\n save_task(task) # Status stays pending, waits for teammate claim\n```\n\nBinding rule: one task binds to one worktree. Binding does NOT change task status: the task stays `pending`, and advances to `in_progress` only when a teammate claims it. This way Lead can pre-create tasks and worktrees, and teammates naturally claim worktree-bound tasks during idle.\n\n### Teammate Tool Cwd Switching\n\nTeaching version maintains a `wt_ctx` dict per teammate, tracking the current worktree path. When a teammate claims a task with a worktree, `wt_ctx` is automatically set to the worktree path; the teammate's `bash`, `read_file`, `write_file` execute in the worktree directory:\n\n```python\n# Inside teammate thread\nwt_ctx = {\"path\": None}\n\ndef _run_claim_task(task_id):\n result = claim_task(task_id, owner=name)\n if \"Claimed\" in result:\n task = load_task(task_id)\n if task.worktree:\n wt_ctx[\"path\"] = str(WORKTREES_DIR / task.worktree)\n return result\n\ndef _run_bash(command):\n return run_bash(command, cwd=wt_ctx[\"path\"]) # Execute in worktree\n```\n\nThis is a teaching simplification. Real Claude Code's EnterWorktree uses `process.chdir()` to switch the entire process directory, and AgentTool isolation uses `cwdOverride` to wrap sub-agent execution.\n\n### Cleanup: Keep or Remove\n\nAfter task completion, two choices:\n\n```python\ndef remove_worktree(name: str, discard_changes: bool = False) -> str:\n # Safety check: refuse by default if changes exist\n if not discard_changes:\n files, commits = _count_worktree_changes(path)\n if files > 0 or commits > 0:\n return \"Has uncommitted changes. Use discard_changes=true to force, or keep_worktree\"\n ok, _ = run_git([\"worktree\", \"remove\", str(path), \"--force\"])\n if not ok:\n return \"Remove failed\"\n run_git([\"branch\", \"-D\", f\"wt/{name}\"])\n log_event(\"remove\", name)\n\ndef keep_worktree(name: str) -> str:\n log_event(\"keep\", name)\n return f\"Worktree '{name}' kept for review (branch: wt/{name})\"\n```\n\nKeep = preserve branch for manual review and merge. Remove = refuse by default if uncommitted changes; requires `discard_changes=true` to confirm. Does NOT auto-complete task. Task completion is triggered explicitly by the teammate's `complete_task`.\n\n### Event Log: Auditable\n\nEach lifecycle operation writes to a log for auditing:\n\n```python\ndef log_event(event_type: str, worktree_name: str, task_id: str = \"\"):\n event = {\"type\": event_type, \"worktree\": worktree_name,\n \"task_id\": task_id, \"ts\": time.time()}\n # append to .worktrees/events.jsonl\n```\n\nEvent types: `create`, `remove`, `keep`. Teaching version logs events for manual auditing; full recovery would need an index or `git worktree list` scanning.\n\n### run_git: Returns Success/Failure\n\n```python\ndef run_git(args: list[str]) -> tuple[bool, str]:\n r = subprocess.run([\"git\"] + args, cwd=WORKDIR, ...)\n return r.returncode == 0, output\n```\n\n`create_worktree` and `remove_worktree` only write event logs after successful git commands, ensuring logs reflect actual state.\n\n---\n\n## Changes from s17\n\n| Component | Before (s17) | After (s18) |\n|-----------|-------------|-------------|\n| Working directory | All agents share WORKDIR | Each task can bind to a git worktree |\n| Task data | id/subject/status/owner/blockedBy | + worktree field |\n| Teammate tool cwd | Always WORKDIR | Auto-switches when claiming worktree-bound task |\n| New functions | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name |\n| Worktree safety | None | Name validation + refuse removal with changes |\n| Event log | None | events.jsonl lifecycle auditing |\n| Lead tools | 14 (s17) | + create_worktree, remove_worktree, keep_worktree (17) |\n| Teammate tools | 8 (s17) | 8 (bash/read/write execute in worktree cwd) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s18_worktree_isolation/code.py\n```\n\nTry this prompt:\n\n`Create two tasks, then create worktrees for each (bind with task_id). Spawn alice and bob. Watch them auto-claim and work in isolated directories.`\n\nWhat to observe: Do both worktrees show different branches in `git status`? After claiming a worktree-bound task, does the teammate's bash run in the worktree directory? Does `remove_worktree` refuse when there are changes? Is task status still `pending` after binding?\n\n---\n\n## What's Next\n\nAgent teams can now self-organize in isolated workspaces. But Agent capabilities are limited to the tools we wrote: bash, read, write, task...\n\nWhat if users already have their own tools? Like an internal Jira API, or a custom deployment system?\n\ns19 MCP Plugin → Give Agent a plugin system. External tools connect via standard protocol; Agent doesn't need to know who wrote them.\n\n
\nDeep Dive into Claude Code Source\n\nClaude Code's worktree system has two paths: **EnterWorktree** (current session switches in) and **AgentTool isolation** (sub-agent isolation).\n\n### EnterWorktree: Current Session Switch\n\n`EnterWorktreeTool.ts:92-97` after creating the worktree, immediately calls `process.chdir(worktreePath)`, `setCwd()`, `setOriginalCwd()`, `saveWorktreeState()`. The current session's working directory switches directly to the worktree, not a prompt hint but a process-level directory change.\n\n`ExitWorktreeTool.ts:261-320` both keep and remove call `restoreSessionToOriginalCwd()` to restore the original directory. Remove checks for uncommitted changes (`ExitWorktreeTool.ts:190-220`), refusing without `discard_changes: true`.\n\n### AgentTool Isolation: Sub-Agent Isolation\n\n`AgentTool.tsx:590-641` when `isolation: \"worktree\"`, calls `createAgentWorktree()` to create a worktree, uses `cwdOverridePath` to wrap sub-agent execution. All sub-agent operations automatically run in the worktree directory. `AgentTool/prompt.ts:272` tells the model: this is a temporary worktree, auto-cleanup if no changes, return path and branch if changes exist.\n\n`worktree.ts:902-951` `createAgentWorktree()` does NOT modify global session cwd, only for sub-agent use. `worktree.ts:961-1020` `removeAgentWorktree()` deletes from the main repo root.\n\n### Name Validation\n\n`worktree.ts:76-84` validates slug: rejects `.`/`..`, allows `[a-zA-Z0-9._-]`. `worktree.ts:48` defines `VALID_WORKTREE_SLUG_SEGMENT`. Teaching version's `validate_worktree_name` uses the same rule.\n\n### Path and Branch Naming\n\nReal path is `.claude/worktrees/`, branch name `worktree-{slug}` (`worktree.ts:204-227`, slashes replaced with `+`). Teaching version uses `.worktrees/` and `wt/{name}` for simplicity.\n\nCreation uses `git worktree add -B` (`worktree.ts:326-328`), preferring `origin/` over current HEAD.\n\n### State Management\n\nClaude Code has no task-worktree binding. Worktree state is managed through `PersistedWorktreeSession` (`worktree.ts:756-768`), with fields including `originalCwd`, `worktreePath`, `worktreeName`, `worktreeBranch`, `originalBranch`, `originalHeadCommit`, `sessionId`, etc. — no taskId field. `saveWorktreeState()` (`sessionStorage.ts:2883-2920`) writes to session transcript with `type: 'worktree-state'`.\n\nTeaching version uses the task's `worktree` field for binding, a teaching simplification. Claude Code treats worktree and task as two independent systems, connected through the Agent's context understanding.\n\n
\n\n\n" }, { "version": "s18", "locale": "zh", "title": "s18: Worktree Isolation — 各干各的,互不干扰", - "content": "# s18: Worktree Isolation — 各干各的,互不干扰\n\ns01 → ... → s16 → s17 → `s18` → [s19](/zh/s19) → s20\n\n> *\"各干各的目录, 互不干扰\"* — 任务管目标, worktree 管目录, 按 ID 绑定。\n>\n> **Harness 层**: 隔离 — 并行执行的目录隔离。\n\n---\n\n## 问题\n\ns17 中,Alice 和 Bob 都在同一个目录下工作。Alice 的任务是\"重构认证模块\",Bob 的任务是\"重构 UI 登录页\"。\n\nAlice `write_file(\"config.py\", ...)`。Bob 也 `write_file(\"config.py\", ...)`。两个人改同一个文件,互相覆盖。而且无法干净地回滚——分不清哪些改动是谁的。\n\ns15-s17 解决了\"谁干什么\"(任务系统)和\"怎么通信\"(消息总线),但没解决\"在哪干\"。\n\n---\n\n## 解决方案\n\n![Worktree Overview](/course-assets/s18_worktree_isolation/worktree-overview.svg)\n\nGit worktree 让你在同一仓库中创建多个独立的工作目录,每个有自己的分支。Alice 在 `.worktrees/auth-refactor/` 下工作,Bob 在 `.worktrees/ui-login/` 下工作——互不干扰。\n\n沿用 S17 的教学版 MessageBus、协议和自治认领机制。本章新增:\n\n| 能力 | 作用 |\n|------|------|\n| create_worktree | 为任务创建独立目录 + 独立分支 |\n| bind_task_to_worktree | 把任务和工作目录绑定(不改状态) |\n| remove_worktree / keep_worktree | 完成后清理或保留 |\n| validate_worktree_name | 拒绝路径穿越和非法字符 |\n\n---\n\n## 工作原理\n\n### 创建:任务-Worktree 绑定\n\n```python\ndef create_worktree(name: str, task_id: str = \"\") -> str:\n validate_worktree_name(name) # 只允许 [A-Za-z0-9._-]{1,64}\n path = WORKTREES_DIR / name\n ok, result = run_git([\"worktree\", \"add\", str(path), \"-b\", f\"wt/{name}\", \"HEAD\"])\n if not ok:\n return f\"Git error: {result}\"\n if task_id:\n bind_task_to_worktree(task_id, name)\n log_event(\"create\", name, task_id)\n return f\"Worktree '{name}' created at {path}\"\n\ndef bind_task_to_worktree(task_id: str, worktree_name: str):\n task = load_task(task_id)\n task.worktree = worktree_name # 只写 worktree 字段\n save_task(task) # 状态保持 pending,等队友 claim\n```\n\n绑定规则:一个任务绑定一个 worktree。绑定不改任务状态——任务仍是 `pending`,队友自动认领时才推进到 `in_progress`。这样 Lead 可以提前创建任务和 worktree,队友 idle 时自然认领带 worktree 的任务。\n\n### 队友工具的 cwd 切换\n\n教学版给每个队友维护一个 `wt_ctx` 字典,记录当前 worktree 路径。队友认领带 worktree 的任务时,`wt_ctx` 自动设置为 worktree 路径;队友的 `bash`、`read_file`、`write_file` 在 worktree 目录下执行:\n\n```python\n# 队友线程内部\nwt_ctx = {\"path\": None}\n\ndef _run_claim_task(task_id):\n result = claim_task(task_id, owner=name)\n if \"Claimed\" in result:\n task = load_task(task_id)\n if task.worktree:\n wt_ctx[\"path\"] = str(WORKTREES_DIR / task.worktree)\n return result\n\ndef _run_bash(command):\n return run_bash(command, cwd=wt_ctx[\"path\"]) # 在 worktree 下执行\n```\n\n这是教学简化。真实 CC 的 EnterWorktree 用 `process.chdir()` 切换整个进程目录,AgentTool isolation 用 `cwdOverride` 包住子 agent 执行。\n\n### 收尾:Keep 还是 Remove\n\n任务完成后,两个选择:\n\n```python\ndef remove_worktree(name: str, discard_changes: bool = False) -> str:\n # 安全检查:有改动时默认拒绝\n if not discard_changes:\n files, commits = _count_worktree_changes(path)\n if files > 0 or commits > 0:\n return \"有未提交改动,使用 discard_changes=true 强制删除,或 keep_worktree 保留\"\n ok, _ = run_git([\"worktree\", \"remove\", str(path), \"--force\"])\n if not ok:\n return \"删除失败\"\n run_git([\"branch\", \"-D\", f\"wt/{name}\"])\n log_event(\"remove\", name)\n\ndef keep_worktree(name: str) -> str:\n log_event(\"keep\", name)\n return f\"Worktree '{name}' kept for review (branch: wt/{name})\"\n```\n\nKeep = 留着分支,等人工 review 后合并到主分支。Remove = 有改动时默认拒绝,需要 `discard_changes=true` 确认。不自动 complete task——任务完成由队友的 `complete_task` 显式触发。\n\n### 事件流:可审计\n\n每次生命周期操作写入日志,方便排查:\n\n```python\ndef log_event(event_type: str, worktree_name: str, task_id: str = \"\"):\n event = {\"type\": event_type, \"worktree\": worktree_name,\n \"task_id\": task_id, \"ts\": time.time()}\n # append to .worktrees/events.jsonl\n```\n\n事件类型:`create`(创建)、`remove`(删除)、`keep`(保留)。教学版只记录事件用于人工排查;完整恢复还需要 index 或 `git worktree list` 扫描。\n\n### run_git:返回成功/失败\n\n```python\ndef run_git(args: list[str]) -> tuple[bool, str]:\n r = subprocess.run([\"git\"] + args, cwd=WORKDIR, ...)\n return r.returncode == 0, output\n```\n\n`create_worktree` 和 `remove_worktree` 只在 git 命令成功后才写事件日志,保证日志反映真实状态。\n\n---\n\n## 相对 s17 的变更\n\n| 组件 | 之前 (s17) | 之后 (s18) |\n|------|-----------|-----------|\n| 工作目录 | 所有 Agent 共享 WORKDIR | 每个任务可绑定独立 git worktree |\n| Task 数据 | id/subject/status/owner/blockedBy | + worktree 字段 |\n| 队友工具 cwd | 始终 WORKDIR | 认领带 worktree 的任务时自动切换 |\n| 新函数 | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name |\n| worktree 安全 | 无 | name 校验 + 有改动时拒绝删除 |\n| 事件日志 | 无 | events.jsonl 生命周期审计 |\n| Lead 工具 | 14 (s17) | + create_worktree, remove_worktree, keep_worktree (17) |\n| 队友工具 | 8 (s17) | 8(bash/read/write 在 worktree cwd 执行) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s18_worktree_isolation/code.py\n```\n\n试试这个 prompt:\n\n`Create two tasks, then create worktrees for each (bind with task_id). Spawn alice and bob. Watch them auto-claim and work in isolated directories.`\n\n观察重点:两个 worktree 的 `git status` 输出是否显示不同的分支?队友认领带 worktree 的任务后,bash 命令是否在 worktree 目录下执行?`remove_worktree` 对有改动的 worktree 是否拒绝?`.tasks/` 中的任务在绑定后状态是否仍为 `pending`?\n\n---\n\n## 接下来\n\nAgent 团队能在隔离的工作空间中自组织了。但 Agent 的能力受限于我们给它写的工具——bash、read、write、task...\n\n如果用户已经有了自己的工具怎么办?比如一个公司内部的 Jira API、一个自建的部署系统?\n\ns19 MCP Plugin → 给 Agent 装一个插件系统。外部工具通过标准协议接入,Agent 不需要知道它们是谁写的。\n\n
\n深入 CC 源码\n\nCC 的 worktree 系统有两条路径:**EnterWorktree**(当前会话切入)和 **AgentTool isolation**(子 agent 隔离)。\n\n### EnterWorktree:当前会话切换\n\n`EnterWorktreeTool.ts:92-97` 创建 worktree 后立即 `process.chdir(worktreePath)`、`setCwd()`、`setOriginalCwd()`、`saveWorktreeState()`。当前会话的工作目录直接切换到 worktree——不是 prompt 提醒,而是进程级目录变更。\n\n`ExitWorktreeTool.ts:261-320` 的 keep/remove 都会 `restoreSessionToOriginalCwd()` 恢复原目录。Remove 时检查未提交改动(`ExitWorktreeTool.ts:190-220`),没有 `discard_changes: true` 就拒绝删除。\n\n### AgentTool isolation:子 agent 隔离\n\n`AgentTool.tsx:590-641` 在 `isolation: \"worktree\"` 时调用 `createAgentWorktree()` 创建 worktree,用 `cwdOverridePath` 包住子 agent 执行。子 agent 的所有操作自动在 worktree 目录下进行。`AgentTool/prompt.ts:272` 告诉模型:这是临时 worktree,无改动自动清理,有改动返回路径和分支。\n\n`worktree.ts:902-951` 的 `createAgentWorktree()` 不修改全局 session cwd,只给子 agent 用。`worktree.ts:961-1020` 的 `removeAgentWorktree()` 从主 repo root 删除。\n\n### name 校验\n\n`worktree.ts:76-84` 校验 slug:拒绝 `.`/`..`,允许 `[a-zA-Z0-9._-]`。`worktree.ts:48` 定义 `VALID_WORKTREE_SLUG_SEGMENT`。教学版的 `validate_worktree_name` 用同样的规则。\n\n### 路径和分支命名\n\n真实路径是 `.claude/worktrees/`,分支名 `worktree-{slug}`(`worktree.ts:204-227`,斜杠用 `+` 替代)。教学版用 `.worktrees/` 和 `wt/{name}` 简化。\n\n创建时用 `git worktree add -B`(`worktree.ts:326-328`),优先基于 `origin/` 而非当前 HEAD。\n\n### 状态管理\n\nCC 没有 task-worktree 绑定。Worktree 状态通过 `PersistedWorktreeSession`(`worktree.ts:756-768`)管理,字段包括 `originalCwd`、`worktreePath`、`worktreeName`、`worktreeBranch`、`originalBranch`、`originalHeadCommit`、`sessionId` 等——没有 taskId。`saveWorktreeState()`(`sessionStorage.ts:2883-2920`)以 `type: 'worktree-state'` 写入 session transcript。\n\n教学版用 task 的 `worktree` 字段做绑定,是教学简化。CC 把 worktree 和 task 作为两个独立系统,通过 Agent 理解上下文来关联。\n\n
\n\n\n" + "content": "# s18: Worktree Isolation — 各干各的,互不干扰\n\ns01 → ... → s16 → s17 → `s18` → [s19](/zh/s19) → s20\n\n> *\"各干各的目录, 互不干扰\"* — 任务管目标, worktree 管目录, 按 ID 绑定。\n>\n> **Harness 层**: 隔离 — 并行执行的目录隔离。\n\n---\n\n## 问题\n\ns17 中,Alice 和 Bob 都在同一个目录下工作。Alice 的任务是\"重构认证模块\",Bob 的任务是\"重构 UI 登录页\"。\n\nAlice `write_file(\"config.py\", ...)`。Bob 也 `write_file(\"config.py\", ...)`。两个人改同一个文件,互相覆盖。而且无法干净地回滚,分不清哪些改动是谁的。\n\ns15-s17 解决了\"谁干什么\"(任务系统)和\"怎么通信\"(消息总线),但没解决\"在哪干\"。\n\n---\n\n## 解决方案\n\n![Worktree Overview](/course-assets/s18_worktree_isolation/worktree-overview.svg)\n\nGit worktree 让你在同一仓库中创建多个独立的工作目录,每个有自己的分支。Alice 在 `.worktrees/auth-refactor/` 下工作,Bob 在 `.worktrees/ui-login/` 下工作,文件操作完全独立。\n\n沿用 S17 的教学版 MessageBus、协议和自治认领机制。本章新增:\n\n| 能力 | 作用 |\n|------|------|\n| create_worktree | 为任务创建独立目录 + 独立分支 |\n| bind_task_to_worktree | 把任务和工作目录绑定(不改状态) |\n| remove_worktree / keep_worktree | 完成后清理或保留 |\n| validate_worktree_name | 拒绝路径穿越和非法字符 |\n\n---\n\n## 工作原理\n\n### 创建:任务-Worktree 绑定\n\n```python\ndef create_worktree(name: str, task_id: str = \"\") -> str:\n validate_worktree_name(name) # 只允许 [A-Za-z0-9._-]{1,64}\n path = WORKTREES_DIR / name\n ok, result = run_git([\"worktree\", \"add\", str(path), \"-b\", f\"wt/{name}\", \"HEAD\"])\n if not ok:\n return f\"Git error: {result}\"\n if task_id:\n bind_task_to_worktree(task_id, name)\n log_event(\"create\", name, task_id)\n return f\"Worktree '{name}' created at {path}\"\n\ndef bind_task_to_worktree(task_id: str, worktree_name: str):\n task = load_task(task_id)\n task.worktree = worktree_name # 只写 worktree 字段\n save_task(task) # 状态保持 pending,等队友 claim\n```\n\n绑定规则:一个任务绑定一个 worktree。绑定不改任务状态,任务仍是 `pending`,队友自动认领时才推进到 `in_progress`。这样 Lead 可以提前创建任务和 worktree,队友 idle 时自然认领带 worktree 的任务。\n\n### 队友工具的 cwd 切换\n\n教学版给每个队友维护一个 `wt_ctx` 字典,记录当前 worktree 路径。队友认领带 worktree 的任务时,`wt_ctx` 自动设置为 worktree 路径;队友的 `bash`、`read_file`、`write_file` 在 worktree 目录下执行:\n\n```python\n# 队友线程内部\nwt_ctx = {\"path\": None}\n\ndef _run_claim_task(task_id):\n result = claim_task(task_id, owner=name)\n if \"Claimed\" in result:\n task = load_task(task_id)\n if task.worktree:\n wt_ctx[\"path\"] = str(WORKTREES_DIR / task.worktree)\n return result\n\ndef _run_bash(command):\n return run_bash(command, cwd=wt_ctx[\"path\"]) # 在 worktree 下执行\n```\n\n这是教学简化。真实 Claude Code 的 EnterWorktree 用 `process.chdir()` 切换整个进程目录,AgentTool isolation 用 `cwdOverride` 包住子 agent 执行。\n\n### 收尾:Keep 还是 Remove\n\n任务完成后,两个选择:\n\n```python\ndef remove_worktree(name: str, discard_changes: bool = False) -> str:\n # 安全检查:有改动时默认拒绝\n if not discard_changes:\n files, commits = _count_worktree_changes(path)\n if files > 0 or commits > 0:\n return \"有未提交改动,使用 discard_changes=true 强制删除,或 keep_worktree 保留\"\n ok, _ = run_git([\"worktree\", \"remove\", str(path), \"--force\"])\n if not ok:\n return \"删除失败\"\n run_git([\"branch\", \"-D\", f\"wt/{name}\"])\n log_event(\"remove\", name)\n\ndef keep_worktree(name: str) -> str:\n log_event(\"keep\", name)\n return f\"Worktree '{name}' kept for review (branch: wt/{name})\"\n```\n\nKeep = 留着分支,等人工 review 后合并到主分支。Remove = 有改动时默认拒绝,需要 `discard_changes=true` 确认。不自动 complete task。任务完成由队友的 `complete_task` 显式触发。\n\n### 事件流:可审计\n\n每次生命周期操作写入日志,方便排查:\n\n```python\ndef log_event(event_type: str, worktree_name: str, task_id: str = \"\"):\n event = {\"type\": event_type, \"worktree\": worktree_name,\n \"task_id\": task_id, \"ts\": time.time()}\n # append to .worktrees/events.jsonl\n```\n\n事件类型:`create`(创建)、`remove`(删除)、`keep`(保留)。教学版只记录事件用于人工排查;完整恢复还需要 index 或 `git worktree list` 扫描。\n\n### run_git:返回成功/失败\n\n```python\ndef run_git(args: list[str]) -> tuple[bool, str]:\n r = subprocess.run([\"git\"] + args, cwd=WORKDIR, ...)\n return r.returncode == 0, output\n```\n\n`create_worktree` 和 `remove_worktree` 只在 git 命令成功后才写事件日志,保证日志反映真实状态。\n\n---\n\n## 相对 s17 的变更\n\n| 组件 | 之前 (s17) | 之后 (s18) |\n|------|-----------|-----------|\n| 工作目录 | 所有 Agent 共享 WORKDIR | 每个任务可绑定独立 git worktree |\n| Task 数据 | id/subject/status/owner/blockedBy | + worktree 字段 |\n| 队友工具 cwd | 始终 WORKDIR | 认领带 worktree 的任务时自动切换 |\n| 新函数 | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name |\n| worktree 安全 | 无 | name 校验 + 有改动时拒绝删除 |\n| 事件日志 | 无 | events.jsonl 生命周期审计 |\n| Lead 工具 | 14 (s17) | + create_worktree, remove_worktree, keep_worktree (17) |\n| 队友工具 | 8 (s17) | 8(bash/read/write 在 worktree cwd 执行) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s18_worktree_isolation/code.py\n```\n\n试试这个 prompt:\n\n`Create two tasks, then create worktrees for each (bind with task_id). Spawn alice and bob. Watch them auto-claim and work in isolated directories.`\n\n观察重点:两个 worktree 的 `git status` 输出是否显示不同的分支?队友认领带 worktree 的任务后,bash 命令是否在 worktree 目录下执行?`remove_worktree` 对有改动的 worktree 是否拒绝?`.tasks/` 中的任务在绑定后状态是否仍为 `pending`?\n\n---\n\n## 接下来\n\nAgent 团队能在隔离的工作空间中自组织了。但 Agent 的能力受限于我们给它写的工具:bash、read、write、task...\n\n如果用户已经有了自己的工具怎么办?比如一个公司内部的 Jira API、一个自建的部署系统?\n\ns19 MCP Plugin → 给 Agent 装一个插件系统。外部工具通过标准协议接入,Agent 不需要知道它们是谁写的。\n\n
\n深入 Claude Code 源码\n\nClaude Code 的 worktree 系统有两条路径:**EnterWorktree**(当前会话切入)和 **AgentTool isolation**(子 agent 隔离)。\n\n### EnterWorktree:当前会话切换\n\n`EnterWorktreeTool.ts:92-97` 创建 worktree 后立即 `process.chdir(worktreePath)`、`setCwd()`、`setOriginalCwd()`、`saveWorktreeState()`。当前会话的工作目录直接切换到 worktree,不是 prompt 提醒,而是进程级目录变更。\n\n`ExitWorktreeTool.ts:261-320` 的 keep/remove 都会 `restoreSessionToOriginalCwd()` 恢复原目录。Remove 时检查未提交改动(`ExitWorktreeTool.ts:190-220`),没有 `discard_changes: true` 就拒绝删除。\n\n### AgentTool isolation:子 agent 隔离\n\n`AgentTool.tsx:590-641` 在 `isolation: \"worktree\"` 时调用 `createAgentWorktree()` 创建 worktree,用 `cwdOverridePath` 包住子 agent 执行。子 agent 的所有操作自动在 worktree 目录下进行。`AgentTool/prompt.ts:272` 告诉模型:这是临时 worktree,无改动自动清理,有改动返回路径和分支。\n\n`worktree.ts:902-951` 的 `createAgentWorktree()` 不修改全局 session cwd,只给子 agent 用。`worktree.ts:961-1020` 的 `removeAgentWorktree()` 从主 repo root 删除。\n\n### name 校验\n\n`worktree.ts:76-84` 校验 slug:拒绝 `.`/`..`,允许 `[a-zA-Z0-9._-]`。`worktree.ts:48` 定义 `VALID_WORKTREE_SLUG_SEGMENT`。教学版的 `validate_worktree_name` 用同样的规则。\n\n### 路径和分支命名\n\n真实路径是 `.claude/worktrees/`,分支名 `worktree-{slug}`(`worktree.ts:204-227`,斜杠用 `+` 替代)。教学版用 `.worktrees/` 和 `wt/{name}` 简化。\n\n创建时用 `git worktree add -B`(`worktree.ts:326-328`),优先基于 `origin/` 而非当前 HEAD。\n\n### 状态管理\n\nClaude Code 没有 task-worktree 绑定。Worktree 状态通过 `PersistedWorktreeSession`(`worktree.ts:756-768`)管理,字段包括 `originalCwd`、`worktreePath`、`worktreeName`、`worktreeBranch`、`originalBranch`、`originalHeadCommit`、`sessionId` 等——没有 taskId。`saveWorktreeState()`(`sessionStorage.ts:2883-2920`)以 `type: 'worktree-state'` 写入 session transcript。\n\n教学版用 task 的 `worktree` 字段做绑定,是教学简化。Claude Code 把 worktree 和 task 作为两个独立系统,通过 Agent 理解上下文来关联。\n\n
\n\n\n" }, { "version": "s18", "locale": "ja", "title": "s18: Worktree Isolation — それぞれのディレクトリ、互いに干渉しない", - "content": "# s18: Worktree Isolation — それぞれのディレクトリ、互いに干渉しない\n\ns01 → ... → s16 → s17 → `s18` → [s19](/ja/s19) → s20\n\n> *\"それぞれのディレクトリ、互いに干渉しない\"* — タスクは目標を管理、worktree はディレクトリを管理、ID で紐付け。\n>\n> **Harness 層**: 隔離 — 並列実行のディレクトリ分離。\n\n---\n\n## 課題\n\ns17 では、Alice も Bob も同じディレクトリで作業。Alice のタスクは「認証モジュールのリファクタリング」、Bob のタスクは「UI ログインページのリファクタリング」。\n\nAlice が `write_file(\"config.py\", ...)` を呼び出し、Bob も `write_file(\"config.py\", ...)` を呼び出す。両者が同じファイルを編集し、互いに上書き。クリーンなロールバックもできない——どの変更が誰のものか区別できない。\n\ns15-s17 は「誰が何をするか」(タスクシステム)と「どう通信するか」(メッセージバス)を解決したが、「どこで作業するか」は未解決。\n\n---\n\n## ソリューション\n\n![Worktree Overview](/course-assets/s18_worktree_isolation/worktree-overview.ja.svg)\n\nGit worktree を使うと、同じリポジトリ内に複数の独立した作業ディレクトリを作成でき、それぞれが独自のブランチを持つ。Alice は `.worktrees/auth-refactor/` で作業、Bob は `.worktrees/ui-login/` で作業——互いに干渉しない。\n\nS17 の教学版 MessageBus、プロトコル、自治認領機構を踏襲。本章の追加:\n\n| 機能 | 目的 |\n|------|------|\n| create_worktree | タスク用の独立ディレクトリ + 独立ブランチを作成 |\n| bind_task_to_worktree | タスクとディレクトリを紐付け(状態は変更しない) |\n| remove_worktree / keep_worktree | 完了後のクリーンアップまたは保持 |\n| validate_worktree_name | パストラバーサルと不正文字を拒否 |\n\n---\n\n## 仕組み\n\n### 作成:タスク-Worktree 紐付け\n\n```python\ndef create_worktree(name: str, task_id: str = \"\") -> str:\n validate_worktree_name(name) # [A-Za-z0-9._-]{1,64} のみ許可\n path = WORKTREES_DIR / name\n ok, result = run_git([\"worktree\", \"add\", str(path), \"-b\", f\"wt/{name}\", \"HEAD\"])\n if not ok:\n return f\"Git error: {result}\"\n if task_id:\n bind_task_to_worktree(task_id, name)\n log_event(\"create\", name, task_id)\n return f\"Worktree '{name}' created at {path}\"\n\ndef bind_task_to_worktree(task_id: str, worktree_name: str):\n task = load_task(task_id)\n task.worktree = worktree_name # worktree フィールドのみ書き込み\n save_task(task) # 状態は pending のまま、チームメイトの claim を待つ\n```\n\n紐付けルール:1 つのタスクに 1 つの worktree を紐付け。紐付けはタスクの状態を変更しない——タスクは `pending` のままで、チームメイトが認領した時に `in_progress` に進む。これにより Lead は事前にタスクと worktree を作成でき、チームメイトは idle 時に自然に worktree 紐付け済みタスクを認領する。\n\n### チームメイトツールの cwd 切り替え\n\n教学版は各チームメイトに `wt_ctx` 辞書を維持し、現在の worktree パスを追跡。チームメイトが worktree 紐付けタスクを認領すると、`wt_ctx` が自動的に worktree パスに設定され、チームメイトの `bash`、`read_file`、`write_file` は worktree ディレクトリで実行される:\n\n```python\n# チームメイトスレッド内部\nwt_ctx = {\"path\": None}\n\ndef _run_claim_task(task_id):\n result = claim_task(task_id, owner=name)\n if \"Claimed\" in result:\n task = load_task(task_id)\n if task.worktree:\n wt_ctx[\"path\"] = str(WORKTREES_DIR / task.worktree)\n return result\n\ndef _run_bash(command):\n return run_bash(command, cwd=wt_ctx[\"path\"]) # worktree で実行\n```\n\nこれは教学簡略化。真实 CC の EnterWorktree は `process.chdir()` でプロセス全体のディレクトリを切り替え、AgentTool isolation は `cwdOverride` でサブエージェント実行をラップする。\n\n### クリーンアップ:Keep または Remove\n\nタスク完了後、2 つの選択肢:\n\n```python\ndef remove_worktree(name: str, discard_changes: bool = False) -> str:\n # 安全チェック:変更がある場合デフォルトで拒否\n if not discard_changes:\n files, commits = _count_worktree_changes(path)\n if files > 0 or commits > 0:\n return \"未コミットの変更あり。discard_changes=true で強制削除、または keep_worktree で保持\"\n ok, _ = run_git([\"worktree\", \"remove\", str(path), \"--force\"])\n if not ok:\n return \"削除失敗\"\n run_git([\"branch\", \"-D\", f\"wt/{name}\"])\n log_event(\"remove\", name)\n\ndef keep_worktree(name: str) -> str:\n log_event(\"keep\", name)\n return f\"Worktree '{name}' kept for review (branch: wt/{name})\"\n```\n\nKeep = ブランチを保持し、手動 review 後にマージ。Remove = 未コミット変更がある場合デフォルトで拒否、`discard_changes=true` で確認が必要。タスクの自動 complete はしない——タスク完了はチームメイトの `complete_task` で明示的にトリガー。\n\n### イベントログ:監査可能\n\n各ライフサイクル操作はログに記録され、監査に利用:\n\n```python\ndef log_event(event_type: str, worktree_name: str, task_id: str = \"\"):\n event = {\"type\": event_type, \"worktree\": worktree_name,\n \"task_id\": task_id, \"ts\": time.time()}\n # .worktrees/events.jsonl に append\n```\n\nイベントタイプ:`create`、`remove`、`keep`。教学版はイベントを記録するだけで手動監査用。完全な復元には index または `git worktree list` スキャンが必要。\n\n### run_git:成功/失敗を返す\n\n```python\ndef run_git(args: list[str]) -> tuple[bool, str]:\n r = subprocess.run([\"git\"] + args, cwd=WORKDIR, ...)\n return r.returncode == 0, output\n```\n\n`create_worktree` と `remove_worktree` は git コマンド成功後のみイベントログに書き込み、ログが実際の状態を反映することを保証。\n\n---\n\n## s17 からの変更\n\n| コンポーネント | 変更前 (s17) | 変更後 (s18) |\n|--------------|------------|------------|\n| 作業ディレクトリ | 全 Agent が WORKDIR を共有 | 各タスクが git worktree に紐付け可能 |\n| タスクデータ | id/subject/status/owner/blockedBy | + worktree フィールド |\n| チームメイトツール cwd | 常に WORKDIR | worktree 紐付けタスク認領時に自動切り替え |\n| 新規関数 | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name |\n| worktree 安全性 | なし | name 検証 + 変更ありの場合削除拒否 |\n| イベントログ | なし | events.jsonl ライフサイクル監査 |\n| Lead ツール | 14 (s17) | + create_worktree, remove_worktree, keep_worktree (17) |\n| チームメイトツール | 8 (s17) | 8(bash/read/write が worktree cwd で実行) |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s18_worktree_isolation/code.py\n```\n\n以下のプロンプトを試してください:\n\n`Create two tasks, then create worktrees for each (bind with task_id). Spawn alice and bob. Watch them auto-claim and work in isolated directories.`\n\n観察ポイント:2 つの worktree の `git status` 出力は異なるブランチを表示しているか?チームメイトが worktree 紐付けタスクを認領後、bash コマンドは worktree ディレクトリで実行されているか?`remove_worktree` は変更がある場合に拒否するか?紐付け後のタスク状態は `pending` のままか?\n\n---\n\n## 次の章\n\nAgent チームが隔離されたワークスペースで自己組織化できるようになった。しかし Agent の能力はツールに制限される——bash、read、write、task...\n\nもしユーザーが独自のツールを持っていたら?例えば社内 Jira API や独自デプロイシステム?\n\ns19 MCP Plugin → Agent にプラグインシステムを追加。外部ツールが標準プロトコルで接続、Agent は誰が書いたか知る必要がない。\n\n
\nCC ソースコード深掘り\n\nCC の worktree システムには 2 つのパスがある:**EnterWorktree**(現在のセッションが切り替え)と **AgentTool isolation**(サブエージェント隔離)。\n\n### EnterWorktree:現在のセッション切り替え\n\n`EnterWorktreeTool.ts:92-97` worktree 作成後、直ちに `process.chdir(worktreePath)`、`setCwd()`、`setOriginalCwd()`、`saveWorktreeState()` を呼び出し。現在のセッションの作業ディレクトリが直接 worktree に切り替わる——プロンプトのヒントではなく、プロセスレベルのディレクトリ変更。\n\n`ExitWorktreeTool.ts:261-320` keep/remove どちらも `restoreSessionToOriginalCwd()` で元のディレクトリに復元。Remove は未コミット変更をチェック(`ExitWorktreeTool.ts:190-220`)、`discard_changes: true` なしでは拒否。\n\n### AgentTool Isolation:サブエージェント隔離\n\n`AgentTool.tsx:590-641` `isolation: \"worktree\"` の場合、`createAgentWorktree()` を呼び出して worktree を作成し、`cwdOverridePath` でサブエージェント実行をラップ。サブエージェントの全操作が自動的に worktree ディレクトリで実行される。`AgentTool/prompt.ts:272` はモデルに伝える:これは一時的な worktree、変更なしで自動クリーンアップ、変更ありの場合はパスとブランチを返す。\n\n`worktree.ts:902-951` `createAgentWorktree()` はグローバル session cwd を変更せず、サブエージェント専用。`worktree.ts:961-1020` `removeAgentWorktree()` はメインリポジトリルートから削除。\n\n### name 検証\n\n`worktree.ts:76-84` slug を検証:`.`/`..` を拒否、`[a-zA-Z0-9._-]` を許可。`worktree.ts:48` で `VALID_WORKTREE_SLUG_SEGMENT` を定義。教学版の `validate_worktree_name` も同じルールを使用。\n\n### パスとブランチ命名\n\n実際のパスは `.claude/worktrees/`、ブランチ名は `worktree-{slug}`(`worktree.ts:204-227`、スラッシュは `+` に置換)。教学版は `.worktrees/` と `wt/{name}` で簡略化。\n\n作成時は `git worktree add -B`(`worktree.ts:326-328`)を使用し、現在の HEAD より `origin/` を優先。\n\n### 状態管理\n\nCC にはタスク-worktree 紐付けがない。Worktree 状態は `PersistedWorktreeSession`(`worktree.ts:756-768`)で管理、フィールドは `originalCwd`、`worktreePath`、`worktreeName`、`worktreeBranch`、`originalBranch`、`originalHeadCommit`、`sessionId` 等を含む——taskId フィールドはない。`saveWorktreeState()`(`sessionStorage.ts:2883-2920`)は `type: 'worktree-state'` で session transcript に書き込み。\n\n教学版はタスクの `worktree` フィールドで紐付けを行う教学簡略化。CC は worktree とタスクを 2 つの独立システムとして扱い、Agent のコンテキスト理解で関連付ける。\n\n
\n\n\n" + "content": "# s18: Worktree Isolation — それぞれのディレクトリ、互いに干渉しない\n\ns01 → ... → s16 → s17 → `s18` → [s19](/ja/s19) → s20\n\n> *\"それぞれのディレクトリ、互いに干渉しない\"* — タスクは目標を管理、worktree はディレクトリを管理、ID で紐付け。\n>\n> **Harness 層**: 隔離 — 並列実行のディレクトリ分離。\n\n---\n\n## 課題\n\ns17 では、Alice も Bob も同じディレクトリで作業。Alice のタスクは「認証モジュールのリファクタリング」、Bob のタスクは「UI ログインページのリファクタリング」。\n\nAlice が `write_file(\"config.py\", ...)` を呼び出し、Bob も `write_file(\"config.py\", ...)` を呼び出す。両者が同じファイルを編集し、互いに上書き。クリーンなロールバックもできず、どの変更が誰のものか区別できない。\n\ns15-s17 は「誰が何をするか」(タスクシステム)と「どう通信するか」(メッセージバス)を解決したが、「どこで作業するか」は未解決。\n\n---\n\n## ソリューション\n\n![Worktree Overview](/course-assets/s18_worktree_isolation/worktree-overview.svg)\n\nGit worktree を使うと、同じリポジトリ内に複数の独立した作業ディレクトリを作成でき、それぞれが独自のブランチを持つ。Alice は `.worktrees/auth-refactor/` で作業、Bob は `.worktrees/ui-login/` で作業し、ファイル操作は完全に独立する。\n\nS17 の教学版 MessageBus、プロトコル、自治認領機構を踏襲。本章の追加:\n\n| 機能 | 目的 |\n|------|------|\n| create_worktree | タスク用の独立ディレクトリ + 独立ブランチを作成 |\n| bind_task_to_worktree | タスクとディレクトリを紐付け(状態は変更しない) |\n| remove_worktree / keep_worktree | 完了後のクリーンアップまたは保持 |\n| validate_worktree_name | パストラバーサルと不正文字を拒否 |\n\n---\n\n## 仕組み\n\n### 作成:タスク-Worktree 紐付け\n\n```python\ndef create_worktree(name: str, task_id: str = \"\") -> str:\n validate_worktree_name(name) # [A-Za-z0-9._-]{1,64} のみ許可\n path = WORKTREES_DIR / name\n ok, result = run_git([\"worktree\", \"add\", str(path), \"-b\", f\"wt/{name}\", \"HEAD\"])\n if not ok:\n return f\"Git error: {result}\"\n if task_id:\n bind_task_to_worktree(task_id, name)\n log_event(\"create\", name, task_id)\n return f\"Worktree '{name}' created at {path}\"\n\ndef bind_task_to_worktree(task_id: str, worktree_name: str):\n task = load_task(task_id)\n task.worktree = worktree_name # worktree フィールドのみ書き込み\n save_task(task) # 状態は pending のまま、チームメイトの claim を待つ\n```\n\n紐付けルール:1 つのタスクに 1 つの worktree を紐付け。紐付けはタスクの状態を変更しない。タスクは `pending` のままで、チームメイトが認領した時に `in_progress` に進む。これにより Lead は事前にタスクと worktree を作成でき、チームメイトは idle 時に自然に worktree 紐付け済みタスクを認領する。\n\n### チームメイトツールの cwd 切り替え\n\n教学版は各チームメイトに `wt_ctx` 辞書を維持し、現在の worktree パスを追跡。チームメイトが worktree 紐付けタスクを認領すると、`wt_ctx` が自動的に worktree パスに設定され、チームメイトの `bash`、`read_file`、`write_file` は worktree ディレクトリで実行される:\n\n```python\n# チームメイトスレッド内部\nwt_ctx = {\"path\": None}\n\ndef _run_claim_task(task_id):\n result = claim_task(task_id, owner=name)\n if \"Claimed\" in result:\n task = load_task(task_id)\n if task.worktree:\n wt_ctx[\"path\"] = str(WORKTREES_DIR / task.worktree)\n return result\n\ndef _run_bash(command):\n return run_bash(command, cwd=wt_ctx[\"path\"]) # worktree で実行\n```\n\nこれは教学簡略化。真实 Claude Code の EnterWorktree は `process.chdir()` でプロセス全体のディレクトリを切り替え、AgentTool isolation は `cwdOverride` でサブエージェント実行をラップする。\n\n### クリーンアップ:Keep または Remove\n\nタスク完了後、2 つの選択肢:\n\n```python\ndef remove_worktree(name: str, discard_changes: bool = False) -> str:\n # 安全チェック:変更がある場合デフォルトで拒否\n if not discard_changes:\n files, commits = _count_worktree_changes(path)\n if files > 0 or commits > 0:\n return \"未コミットの変更あり。discard_changes=true で強制削除、または keep_worktree で保持\"\n ok, _ = run_git([\"worktree\", \"remove\", str(path), \"--force\"])\n if not ok:\n return \"削除失敗\"\n run_git([\"branch\", \"-D\", f\"wt/{name}\"])\n log_event(\"remove\", name)\n\ndef keep_worktree(name: str) -> str:\n log_event(\"keep\", name)\n return f\"Worktree '{name}' kept for review (branch: wt/{name})\"\n```\n\nKeep = ブランチを保持し、手動 review 後にマージ。Remove = 未コミット変更がある場合デフォルトで拒否、`discard_changes=true` で確認が必要。タスクの自動 complete はしない。タスク完了はチームメイトの `complete_task` で明示的にトリガー。\n\n### イベントログ:監査可能\n\n各ライフサイクル操作はログに記録され、監査に利用:\n\n```python\ndef log_event(event_type: str, worktree_name: str, task_id: str = \"\"):\n event = {\"type\": event_type, \"worktree\": worktree_name,\n \"task_id\": task_id, \"ts\": time.time()}\n # .worktrees/events.jsonl に append\n```\n\nイベントタイプ:`create`、`remove`、`keep`。教学版はイベントを記録するだけで手動監査用。完全な復元には index または `git worktree list` スキャンが必要。\n\n### run_git:成功/失敗を返す\n\n```python\ndef run_git(args: list[str]) -> tuple[bool, str]:\n r = subprocess.run([\"git\"] + args, cwd=WORKDIR, ...)\n return r.returncode == 0, output\n```\n\n`create_worktree` と `remove_worktree` は git コマンド成功後のみイベントログに書き込み、ログが実際の状態を反映することを保証。\n\n---\n\n## s17 からの変更\n\n| コンポーネント | 変更前 (s17) | 変更後 (s18) |\n|--------------|------------|------------|\n| 作業ディレクトリ | 全 Agent が WORKDIR を共有 | 各タスクが git worktree に紐付け可能 |\n| タスクデータ | id/subject/status/owner/blockedBy | + worktree フィールド |\n| チームメイトツール cwd | 常に WORKDIR | worktree 紐付けタスク認領時に自動切り替え |\n| 新規関数 | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name |\n| worktree 安全性 | なし | name 検証 + 変更ありの場合削除拒否 |\n| イベントログ | なし | events.jsonl ライフサイクル監査 |\n| Lead ツール | 14 (s17) | + create_worktree, remove_worktree, keep_worktree (17) |\n| チームメイトツール | 8 (s17) | 8(bash/read/write が worktree cwd で実行) |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s18_worktree_isolation/code.py\n```\n\n以下のプロンプトを試してください:\n\n`Create two tasks, then create worktrees for each (bind with task_id). Spawn alice and bob. Watch them auto-claim and work in isolated directories.`\n\n観察ポイント:2 つの worktree の `git status` 出力は異なるブランチを表示しているか?チームメイトが worktree 紐付けタスクを認領後、bash コマンドは worktree ディレクトリで実行されているか?`remove_worktree` は変更がある場合に拒否するか?紐付け後のタスク状態は `pending` のままか?\n\n---\n\n## 次の章\n\nAgent チームが隔離されたワークスペースで自己組織化できるようになった。しかし Agent の能力はツールに制限される:bash、read、write、task...\n\nもしユーザーが独自のツールを持っていたら?例えば社内 Jira API や独自デプロイシステム?\n\ns19 MCP Plugin → Agent にプラグインシステムを追加。外部ツールが標準プロトコルで接続、Agent は誰が書いたか知る必要がない。\n\n
\nClaude Code ソースコード深掘り\n\nClaude Code の worktree システムには 2 つのパスがある:**EnterWorktree**(現在のセッションが切り替え)と **AgentTool isolation**(サブエージェント隔離)。\n\n### EnterWorktree:現在のセッション切り替え\n\n`EnterWorktreeTool.ts:92-97` worktree 作成後、直ちに `process.chdir(worktreePath)`、`setCwd()`、`setOriginalCwd()`、`saveWorktreeState()` を呼び出し。現在のセッションの作業ディレクトリが直接 worktree に切り替わる。プロンプトのヒントではなく、プロセスレベルのディレクトリ変更。\n\n`ExitWorktreeTool.ts:261-320` keep/remove どちらも `restoreSessionToOriginalCwd()` で元のディレクトリに復元。Remove は未コミット変更をチェック(`ExitWorktreeTool.ts:190-220`)、`discard_changes: true` なしでは拒否。\n\n### AgentTool Isolation:サブエージェント隔離\n\n`AgentTool.tsx:590-641` `isolation: \"worktree\"` の場合、`createAgentWorktree()` を呼び出して worktree を作成し、`cwdOverridePath` でサブエージェント実行をラップ。サブエージェントの全操作が自動的に worktree ディレクトリで実行される。`AgentTool/prompt.ts:272` はモデルに伝える:これは一時的な worktree、変更なしで自動クリーンアップ、変更ありの場合はパスとブランチを返す。\n\n`worktree.ts:902-951` `createAgentWorktree()` はグローバル session cwd を変更せず、サブエージェント専用。`worktree.ts:961-1020` `removeAgentWorktree()` はメインリポジトリルートから削除。\n\n### name 検証\n\n`worktree.ts:76-84` slug を検証:`.`/`..` を拒否、`[a-zA-Z0-9._-]` を許可。`worktree.ts:48` で `VALID_WORKTREE_SLUG_SEGMENT` を定義。教学版の `validate_worktree_name` も同じルールを使用。\n\n### パスとブランチ命名\n\n実際のパスは `.claude/worktrees/`、ブランチ名は `worktree-{slug}`(`worktree.ts:204-227`、スラッシュは `+` に置換)。教学版は `.worktrees/` と `wt/{name}` で簡略化。\n\n作成時は `git worktree add -B`(`worktree.ts:326-328`)を使用し、現在の HEAD より `origin/` を優先。\n\n### 状態管理\n\nClaude Code にはタスク-worktree 紐付けがない。Worktree 状態は `PersistedWorktreeSession`(`worktree.ts:756-768`)で管理、フィールドは `originalCwd`、`worktreePath`、`worktreeName`、`worktreeBranch`、`originalBranch`、`originalHeadCommit`、`sessionId` 等を含む——taskId フィールドはない。`saveWorktreeState()`(`sessionStorage.ts:2883-2920`)は `type: 'worktree-state'` で session transcript に書き込み。\n\n教学版はタスクの `worktree` フィールドで紐付けを行う教学簡略化。Claude Code は worktree とタスクを 2 つの独立システムとして扱い、Agent のコンテキスト理解で関連付ける。\n\n
\n\n\n" }, { "version": "s19", "locale": "en", "title": "s19: MCP Tools — External Tools, Standard Protocol", - "content": "# s19: MCP Tools — External Tools, Standard Protocol\n\ns01 → ... → s17 → s18 → `s19` → [s20](/en/s20)\n\n> *\"External tools, standard protocol\"* — Discover, assemble, invoke. Agent doesn't need to know who wrote them.\n>\n> **Harness layer**: Plugins — External capabilities via a standard protocol.\n\n---\n\n## The Problem\n\nFrom s01 through s18, every tool the agent uses was hand-written — bash, read, write, task, worktree. Input validation, execution logic, error handling — all written line by line.\n\nNow you have 3 external services to integrate: the company's Jira API (query issues, create tickets), an in-house deployment system (trigger deploys, view logs), and the team's Notion knowledge base (search docs, create pages). You don't want to rewrite tool code for every service.\n\nYou need a standard protocol — as long as an external service implements it, the agent can call its tools directly, regardless of what language the service is written in.\n\n---\n\n## The Solution\n\n![MCP Architecture](/course-assets/s19_mcp_plugin/mcp-architecture.en.svg)\n\nMCP (Model Context Protocol) defines how agents discover and invoke external tools. Core concepts:\n\n| Concept | Purpose |\n|------|------|\n| MCPClient | The agent-side client — connects to servers, discovers tools, invokes tools |\n| MCP Server | The external service — implements `tools/list` + `tools/call` |\n| assemble_tool_pool | Assembles built-in tools and MCP tools into one tool pool |\n| mcp\\_\\_server\\_\\_tool naming | Prevents tool name collisions across different servers |\n\nCarries forward s18's teaching-version worktree isolation, autonomous claiming, idle polling, and protocol system. This chapter adds: the `connect_mcp` tool — connect to external services, discover tools, add them to the tool pool.\n\nThe tutorial uses mock handlers to simulate external servers. The real version would spawn subprocesses and communicate via stdin/stdout JSON-RPC. Mocks let you run the full flow without external dependencies; the tradeoff is you don't see real network communication or process management.\n\n---\n\n## How It Works\n\n### MCPClient: Discovery + Invocation\n\n```python\nclass MCPClient:\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs, handlers):\n \"\"\"Simulates tools/list discovery.\"\"\"\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n \"\"\"Simulates tools/call.\"\"\"\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n return handler(**args)\n```\n\nThe tutorial uses Python functions to simulate server tool implementations. The real version communicates with subprocesses via stdio JSON-RPC.\n\n### connect_mcp: Connect + Discover\n\n```python\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n return f\"Unknown server '{name}'. Available: ...\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n return f\"Connected to '{name}'. Discovered: ...\"\n```\n\nAfter connecting, the server's tools are immediately available.\n\n### normalize_mcp_name: Name Normalization\n\n```python\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\ndef normalize_mcp_name(name: str) -> str:\n return _DISALLOWED_CHARS.sub('_', name)\n```\n\nAll non-`[a-zA-Z0-9_-]` characters are replaced with `_`. Prevents special characters in server or tool names from causing naming conflicts or injection issues.\n\n### assemble_tool_pool: Assemble Tool Pool\n\n```python\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append(...)\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw:\n c.call_tool(t, kw))\n return tools, handlers\n```\n\nThe prefix `mcp__{server}__{tool}` prevents tool name collisions across different servers. Names are normalized through `normalize_mcp_name`.\n\nMCP tool descriptions include `(readOnly)` or `(destructive)` annotations — the tutorial uses text annotations, while real CC uses structured tool annotations for the permission system.\n\n### No Cache: Tool Pool Changes, Prompt Changes Too\n\ns10-s18's agent_loop used prompt caching to avoid re-serialization. s19 removes the cache:\n\n```python\ndef agent_loop(messages, context):\n tools, handlers = assemble_tool_pool() # Rebuild every time\n system = assemble_system_prompt(context) # Regenerate every time\n ...\n if any(b.name == \"connect_mcp\" ...):\n tools, handlers = assemble_tool_pool() # Rebuild after connection\n system = assemble_system_prompt(context)\n```\n\nReason: after `connect_mcp`, the tool pool changes — new tools like `mcp__docs__search` are added. The cached tool list is stale; continuing to use it means the model can't call the new tools. The tutorial simply removes caching, at the cost of slightly more serialization time.\n\n### MCP Tools: Lead Only\n\nIn the tutorial, `connect_mcp` is a Lead tool, and `assemble_tool_pool` only serves the Lead's agent_loop. Teammates still use a fixed 8-tool subset (bash, read_file, write_file, send_message, submit_plan, list_tasks, claim_task, complete_task).\n\nThis is a teaching simplification. In real CC, MCP tools are available to both the main agent and sub-agents — sub-agents inherit the parent's MCP configuration.\n\n---\n\n## Changes from s18\n\n| Component | Before (s18) | After (s19) |\n|------|-----------|-----------|\n| Tool source | All hand-written built-in | Hand-written + MCP external tools with dynamic discovery |\n| Tool pool | Fixed BUILTIN_TOOLS | assemble_tool_pool dynamically assembles mcp\\_\\_ prefixed tools |\n| Name safety | None | normalize_mcp_name normalization |\n| New type | — | MCPClient class (simulates tools/list + tools/call) |\n| Namespace | — | mcp\\_\\_server\\_\\_tool prevents collisions |\n| Tool descriptions | No annotations | (readOnly)/(destructive) annotations |\n| Prompt cache | Yes (since s10) | Removed — tool pool is dynamic, cache goes stale |\n| Lead tools | 17 (s18) | 18 (+connect_mcp) |\n| Teammate tools | 8 (s18) | 8 (unchanged, MCP tools are Lead-only) |\n| Extension method | Write code to add tools | Standard protocol, implement servers in any language |\n\n---\n\n## Try It Out\n\n```sh\ncd learn-claude-code\npython s19_mcp_plugin/code.py\n```\n\nTry these prompts:\n\n1. `Connect to the docs MCP server and search for something`\n2. `Connect to the deploy server and trigger a deployment`\n3. `Connect both servers — what tools are now available?`\n\nWhat to observe: After connecting to an MCP server, do tool names have `mcp__docs__` or `mcp__deploy__` prefixes? Are both servers' tools available simultaneously? Do MCP tool descriptions include (readOnly)/(destructive) annotations?\n\n---\n\n## What's Next\n\nThe Agent can now connect external tools through a standard protocol. But the first 19 chapters each add one mechanism in isolation; a real Agent does not run as 19 separate demos.\n\nTools, permissions, hooks, todo, task graph, memory, compact, background work, cron, teams, worktrees, and MCP should all attach to the same loop, not live in separate examples.\n\ns20 Comprehensive Agent → Combine the first 19 chapters into one complete harness. Many mechanisms, one loop.\n\n
\nDeep Dive into CC Source\n\n> The following is based on analysis of CC source: `services/mcp/client.ts`, `auth.ts`, `config.ts`, `channelNotification.ts`.\n\n### 1. Six Transport Types\n\nThe tutorial only shows a stdio mock. CC supports 6 transport types (`types.ts:23-25`):\n\n| Transport | Communication method |\n|-----------|---------|\n| `stdio` | Subprocess stdin/stdout (cross-platform default) |\n| `sse` | HTTP Server-Sent Events |\n| `http` | Streamable HTTP (POST/SSE bidirectional) |\n| `ws` | WebSocket |\n| `sse-ide` | IDE-embedded SSE transport |\n| `sdk` | In-process SDK transport |\n\nOn connection, local (stdio) and remote (http/sse/ws) servers are batched concurrently: local batch of 3, remote batch of 20.\n\n### 2. Tool Pool Merging Algorithm\n\n`assembleToolPool()` (`tools.ts:345-364`):\n\n```typescript\n// Dedup with priority: built-in tools win on name collision (sorted first)\nreturn uniqBy(\n [...builtInTools.sort(byName), ...filteredMcpTools.sort(byName)],\n 'name',\n)\n```\n\nBuilt-in and MCP tools are sorted separately, not together. The reason is CC's `claude_code_system_cache_policy` places a global cache breakpoint after the last built-in tool at a specific position — mixing the sort would break this design.\n\n### 3. Naming Convention: `mcp__server__tool`\n\n`buildMcpToolName()` (`mcpStringUtils.ts:50-52`):\n\n```\nmcp____\n```\n\nAll non-`[a-zA-Z0-9_-]` characters are replaced with `_` (`normalization.ts:17-23`). The tutorial's `normalize_mcp_name` uses the same rule.\n\n### 4. Permission Checks\n\nCC has a separate permission system for MCP tools. `checkPermissions()` applies different logic for MCP tools than for built-in tools — MCP tools can declare their own permission requirements (readOnly, destructive, etc.), and CC decides whether user confirmation is needed based on the declaration. The tutorial only uses text annotations `(readOnly)` / `(destructive)` in descriptions, without permission enforcement.\n\n### 5. Configuration Sources and Priority\n\nMCP server configuration comes from multiple sources. CC's priority from lowest to highest:\n\n```\nclaude.ai connectors < plugin < user settings.json < approved project .mcp.json < local settings.local.json\n```\n\n`claude.ai` connectors are fetched separately, deduplicated by content signature, and merged at the lowest precedence (`config.ts:1267-1289`). When enterprise `managed-mcp.json` exists, all other configurations are excluded.\n\nThe tutorial passes server names directly to the `MOCK_SERVERS` dict, without config merging.\n\n### 6. Channel Notifications: Servers Push Messages Back\n\nThe tutorial only covers agent → MCP Server unidirectional calls. CC also supports reverse notifications (`channelNotification.ts`):\n\n1. Server declares `capabilities.experimental['claude/channel']`\n2. Server sends messages to agent via MCP notification `notifications/claude/channel`\n3. Messages are wrapped in `...` XML tags\n4. Agent is woken up by SleepTool (within 1 second)\n\nServers can also request permissions: `notifications/claude/channel/permission_request` → Agent replies `notifications/claude/channel/permission`. Users confirm/deny via a 5-letter short ID.\n\n### 7. OAuth Authentication Flow\n\nCC's MCP authentication (`auth.ts`) supports a full OAuth 2.0 + PKCE flow:\n- OAuth metadata discovery via public client + PKCE (RFC 8414 / RFC 9728)\n- Local callback server receives authorization code\n- Tokens persisted via `getSecureStorage()` (macOS Keychain / Linux encrypted file / Windows Credential Manager)\n- Auto-refresh 5 minutes before expiry\n- Cross-application access (XAA): browser gets id_token → RFC 8693 + RFC 7523 exchange → no repeated browser popups\n\n### 8. Connection Lifecycle Error Handling\n\nCC has fine-grained error classification and retry for MCP connections (`client.ts:1266-1402`):\n- Terminal errors (ECONNRESET, ETIMEDOUT, EPIPE, etc.): 3 consecutive failures → close + reconnect\n- Tool call 401: Token expired → throw `McpAuthError` → trigger re-authentication\n- Tool call timeout: `Promise.race` timeout (configurable, default ~28 hours)\n- Stdio disconnect: Kill process in SIGINT → SIGTERM → SIGKILL order\n\n### The Tutorial's Simplifications\n\n- 6 transport types → 1 (mock stdio): Manageable concept count\n- Channel reverse notifications → omitted: Tutorial agent is always the initiator\n- OAuth flow → omitted: Tutorial assumes servers need no auth\n- Multi-layer config priority → omitted: Tutorial passes server name directly\n- Complex error classification → omitted: Tutorial uses try/except as fallback\n- MCP tools Lead-only → omitted sub-agent inheritance: Simplifies code structure\n\n
\n\n\n" + "content": "# s19: MCP Tools — External Tools, Standard Protocol\n\ns01 → ... → s17 → s18 → `s19` → [s20](/en/s20)\n\n> *\"External tools, standard protocol\"* — Discover, assemble, invoke. Agent doesn't need to know who wrote them.\n>\n> **Harness layer**: Plugins — External capabilities via a standard protocol.\n\n---\n\n## The Problem\n\nFrom s01 through s18, every tool the agent uses was hand-written: bash, read, write, task, worktree. Input validation, execution logic, error handling, all written line by line.\n\nNow you have 3 external services to integrate: the company's Jira API (query issues, create tickets), an in-house deployment system (trigger deploys, view logs), and the team's Notion knowledge base (search docs, create pages). You don't want to rewrite tool code for every service.\n\nYou need a standard protocol. As long as an external service implements it, the agent can call its tools directly, regardless of what language the service is written in.\n\n---\n\n## The Solution\n\n![MCP Architecture](/course-assets/s19_mcp_plugin/mcp-architecture.svg)\n\nMCP (Model Context Protocol) defines how agents discover and invoke external tools. Core concepts:\n\n| Concept | Purpose |\n|------|------|\n| MCPClient | Agent-side client that connects to servers, discovers tools, invokes tools |\n| MCP Server | External service implementing `tools/list` + `tools/call` |\n| assemble_tool_pool | Assembles built-in tools and MCP tools into one tool pool |\n| mcp\\_\\_server\\_\\_tool naming | Prevents tool name collisions across different servers |\n\nCarries forward s18's teaching-version worktree isolation, autonomous claiming, idle polling, and protocol system. This chapter adds the `connect_mcp` tool, which connects to external services, discovers tools, and adds them to the tool pool.\n\nThe tutorial uses mock handlers to simulate external servers. The real version would spawn subprocesses and communicate via stdin/stdout JSON-RPC. Mocks let you run the full flow without external dependencies; the tradeoff is you don't see real network communication or process management.\n\n---\n\n## How It Works\n\n### MCPClient: Discovery + Invocation\n\n```python\nclass MCPClient:\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs, handlers):\n \"\"\"Simulates tools/list discovery.\"\"\"\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n \"\"\"Simulates tools/call.\"\"\"\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n return handler(**args)\n```\n\nThe tutorial uses Python functions to simulate server tool implementations. The real version communicates with subprocesses via stdio JSON-RPC.\n\n### connect_mcp: Connect + Discover\n\n```python\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n return f\"Unknown server '{name}'. Available: ...\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n return f\"Connected to '{name}'. Discovered: ...\"\n```\n\nAfter connecting, the server's tools are immediately available.\n\n### normalize_mcp_name: Name Normalization\n\n```python\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\ndef normalize_mcp_name(name: str) -> str:\n return _DISALLOWED_CHARS.sub('_', name)\n```\n\nAll non-`[a-zA-Z0-9_-]` characters are replaced with `_`. Prevents special characters in server or tool names from causing naming conflicts or injection issues.\n\n### assemble_tool_pool: Assemble Tool Pool\n\n```python\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append(...)\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw:\n c.call_tool(t, kw))\n return tools, handlers\n```\n\nThe prefix `mcp__{server}__{tool}` namespaces each server's tools. Names are normalized through `normalize_mcp_name`.\n\nMCP tool descriptions include `(readOnly)` or `(destructive)` annotations. The tutorial uses text annotations; real Claude Code uses structured tool annotations for the permission system.\n\n### No Cache: Tool Pool Changes, Prompt Changes Too\n\ns10-s18's agent_loop used prompt caching to avoid re-serialization. s19 removes the cache:\n\n```python\ndef agent_loop(messages, context):\n tools, handlers = assemble_tool_pool() # Rebuild every time\n system = assemble_system_prompt(context) # Regenerate every time\n ...\n if any(b.name == \"connect_mcp\" ...):\n tools, handlers = assemble_tool_pool() # Rebuild after connection\n system = assemble_system_prompt(context)\n```\n\nReason: after `connect_mcp`, the tool pool changes. New tools like `mcp__docs__search` are added. The cached tool list is stale; continuing to use it means the model can't call the new tools. The tutorial simply removes caching, at the cost of slightly more serialization time.\n\n### MCP Tools: Lead Only\n\nIn the tutorial, `connect_mcp` is a Lead tool, and `assemble_tool_pool` only serves the Lead's agent_loop. Teammates still use a fixed 8-tool subset (bash, read_file, write_file, send_message, submit_plan, list_tasks, claim_task, complete_task).\n\nThis is a teaching simplification. In real Claude Code, MCP tools are available to both the main agent and sub-agents. Sub-agents inherit the parent's MCP configuration.\n\n---\n\n## Changes from s18\n\n| Component | Before (s18) | After (s19) |\n|------|-----------|-----------|\n| Tool source | All hand-written built-in | Hand-written + MCP external tools with dynamic discovery |\n| Tool pool | Fixed BUILTIN_TOOLS | assemble_tool_pool dynamically assembles mcp\\_\\_ prefixed tools |\n| Name safety | None | normalize_mcp_name normalization |\n| New type | — | MCPClient class (simulates tools/list + tools/call) |\n| Namespace | — | mcp\\_\\_server\\_\\_tool prevents collisions |\n| Tool descriptions | No annotations | (readOnly)/(destructive) annotations |\n| Prompt cache | Yes (since s10) | Removed because tool pool is dynamic; cache goes stale |\n| Lead tools | 17 (s18) | 18 (+connect_mcp) |\n| Teammate tools | 8 (s18) | 8 (unchanged, MCP tools are Lead-only) |\n| Extension method | Write code to add tools | MCP protocol; implement servers in any language |\n\n---\n\n## Try It Out\n\n```sh\ncd learn-claude-code\npython s19_mcp_plugin/code.py\n```\n\nTry these prompts:\n\n1. `Connect to the docs MCP server and search for something`\n2. `Connect to the deploy server and trigger a deployment`\n3. `Connect both servers — what tools are now available?`\n\nWhat to observe: After connecting to an MCP server, do tool names have `mcp__docs__` or `mcp__deploy__` prefixes? Are both servers' tools available simultaneously? Do MCP tool descriptions include (readOnly)/(destructive) annotations?\n\n---\n\n## What's Next\n\nThe Agent can now connect external tools through MCP. But the first 19 chapters each add one mechanism in isolation; a real Agent does not run as 19 separate demos.\n\nTools, permissions, hooks, todo, task graph, memory, compact, background work, cron, teams, worktrees, and MCP should all attach to the same loop, not live in separate examples.\n\ns20 Comprehensive Agent → Combine the first 19 chapters into one complete harness. Many mechanisms, one loop.\n\n
\nDeep Dive into Claude Code Source\n\n> The following is based on analysis of Claude Code source: `services/mcp/client.ts`, `auth.ts`, `config.ts`, `channelNotification.ts`.\n\n### 1. Six Transport Types\n\nThe tutorial only shows a stdio mock. Claude Code supports 6 transport types (`types.ts:23-25`):\n\n| Transport | Communication method |\n|-----------|---------|\n| `stdio` | Subprocess stdin/stdout (cross-platform default) |\n| `sse` | HTTP Server-Sent Events |\n| `http` | Streamable HTTP (POST/SSE bidirectional) |\n| `ws` | WebSocket |\n| `sse-ide` | IDE-embedded SSE transport |\n| `sdk` | In-process SDK transport |\n\nOn connection, local (stdio) and remote (http/sse/ws) servers are batched concurrently: local batch of 3, remote batch of 20.\n\n### 2. Tool Pool Merging Algorithm\n\n`assembleToolPool()` (`tools.ts:345-364`):\n\n```typescript\n// Dedup with priority: built-in tools win on name collision (sorted first)\nreturn uniqBy(\n [...builtInTools.sort(byName), ...filteredMcpTools.sort(byName)],\n 'name',\n)\n```\n\nBuilt-in and MCP tools are sorted separately, not together. The reason is Claude Code's `claude_code_system_cache_policy` places a global cache breakpoint after the last built-in tool at a specific position. Mixing the sort would break this design.\n\n### 3. Naming Convention: `mcp__server__tool`\n\n`buildMcpToolName()` (`mcpStringUtils.ts:50-52`):\n\n```\nmcp____\n```\n\nAll non-`[a-zA-Z0-9_-]` characters are replaced with `_` (`normalization.ts:17-23`). The tutorial's `normalize_mcp_name` uses the same rule.\n\n### 4. Permission Checks\n\nClaude Code has a separate permission system for MCP tools. `checkPermissions()` applies different logic for MCP tools than for built-in tools: MCP tools can declare their own permission requirements (readOnly, destructive, etc.), and Claude Code decides whether user confirmation is needed based on the declaration. The tutorial only uses text annotations `(readOnly)` / `(destructive)` in descriptions, without permission enforcement.\n\n### 5. Configuration Sources and Priority\n\nMCP server configuration comes from multiple sources. Claude Code's priority from lowest to highest:\n\n```\nclaude.ai connectors < plugin < user settings.json < approved project .mcp.json < local settings.local.json\n```\n\n`claude.ai` connectors are fetched separately, deduplicated by content signature, and merged at the lowest precedence (`config.ts:1267-1289`). When enterprise `managed-mcp.json` exists, all other configurations are excluded.\n\nThe tutorial passes server names directly to the `MOCK_SERVERS` dict, without config merging.\n\n### 6. Channel Notifications: Servers Push Messages Back\n\nThe tutorial only covers agent → MCP Server unidirectional calls. Claude Code also supports reverse notifications (`channelNotification.ts`):\n\n1. Server declares `capabilities.experimental['claude/channel']`\n2. Server sends messages to agent via MCP notification `notifications/claude/channel`\n3. Messages are wrapped in `...` XML tags\n4. Agent is woken up by SleepTool (within 1 second)\n\nServers can also request permissions: `notifications/claude/channel/permission_request` → Agent replies `notifications/claude/channel/permission`. Users confirm/deny via a 5-letter short ID.\n\n### 7. OAuth Authentication Flow\n\nClaude Code's MCP authentication (`auth.ts`) supports a full OAuth 2.0 + PKCE flow:\n- OAuth metadata discovery via public client + PKCE (RFC 8414 / RFC 9728)\n- Local callback server receives authorization code\n- Tokens persisted via `getSecureStorage()` (macOS Keychain / Linux encrypted file / Windows Credential Manager)\n- Auto-refresh 5 minutes before expiry\n- Cross-application access (XAA): browser gets id_token → RFC 8693 + RFC 7523 exchange → no repeated browser popups\n\n### 8. Connection Lifecycle Error Handling\n\nClaude Code has fine-grained error classification and retry for MCP connections (`client.ts:1266-1402`):\n- Terminal errors (ECONNRESET, ETIMEDOUT, EPIPE, etc.): 3 consecutive failures → close + reconnect\n- Tool call 401: Token expired → throw `McpAuthError` → trigger re-authentication\n- Tool call timeout: `Promise.race` timeout (configurable, default ~28 hours)\n- Stdio disconnect: Kill process in SIGINT → SIGTERM → SIGKILL order\n\n### The Tutorial's Simplifications\n\n- 6 transport types → 1 (mock stdio): Manageable concept count\n- Channel reverse notifications → omitted: Tutorial agent is always the initiator\n- OAuth flow → omitted: Tutorial assumes servers need no auth\n- Multi-layer config priority → omitted: Tutorial passes server name directly\n- Complex error classification → omitted: Tutorial uses try/except as fallback\n- MCP tools Lead-only → omitted sub-agent inheritance: Simplifies code structure\n\n
\n\n\n" }, { "version": "s19", "locale": "zh", "title": "s19: MCP Tools — 外接工具,标准协议", - "content": "# s19: MCP Tools — 外接工具,标准协议\n\ns01 → ... → s17 → s18 → `s19` → [s20](/zh/s20)\n\n> *\"外接工具, 标准协议\"* — 发现、组装、调用,Agent 不需要知道工具是谁写的。\n>\n> **Harness 层**: 插件 — 外部能力通过标准协议接入。\n\n---\n\n## 问题\n\ns01 到 s18,Agent 的所有工具都是手写的——bash、read、write、task、worktree。每个工具的输入验证、执行逻辑、错误处理,都是你一行行写的。\n\n现在你有 3 个外部服务想接入:公司的 Jira API(查 issue、建 ticket)、自建的部署系统(触发 deploy、看日志)、团队的 Notion 知识库(搜文档、建页面)。你不想为每个服务重写一套工具代码。\n\n你需要一个标准协议——外部服务只要实现它,Agent 就能直接调用,不管服务用什么语言写的。\n\n---\n\n## 解决方案\n\n![MCP Architecture](/course-assets/s19_mcp_plugin/mcp-architecture.svg)\n\nMCP(Model Context Protocol)定义了 Agent 如何发现和调用外部工具。核心概念:\n\n| 概念 | 作用 |\n|------|------|\n| MCPClient | Agent 端的客户端,连接 server、发现工具、调用工具 |\n| MCP Server | 外部服务,实现 `tools/list` + `tools/call` |\n| assemble_tool_pool | 把内置工具和 MCP 工具组装成一个工具池 |\n| mcp\\_\\_server\\_\\_tool 命名 | 避免不同 server 的工具名冲突 |\n\n沿用 s18 的教学版 worktree 隔离、自主认领、空闲轮询、协议系统。本章新增:`connect_mcp` 工具——连接外部服务,发现工具,加入工具池。\n\n教学版用 mock handler 模拟外部 server。真实版会启动子进程,通过 stdin/stdout 发送 JSON-RPC 请求。mock 的好处是不依赖外部服务就能跑完整流程;代价是你看不到真正的网络通信和进程管理。\n\n---\n\n## 工作原理\n\n### MCPClient:发现 + 调用\n\n```python\nclass MCPClient:\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs, handlers):\n \"\"\"Simulates tools/list discovery.\"\"\"\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n \"\"\"Simulates tools/call.\"\"\"\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n return handler(**args)\n```\n\n教学版用 Python 函数模拟 server 的工具实现。真实版通过 stdio JSON-RPC 与子进程通信。\n\n### connect_mcp:连接 + 发现\n\n```python\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n return f\"Unknown server '{name}'. Available: ...\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n return f\"Connected to '{name}'. Discovered: ...\"\n```\n\n连接后,server 提供的工具立即可用。\n\n### normalize_mcp_name:名称规范化\n\n```python\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\ndef normalize_mcp_name(name: str) -> str:\n return _DISALLOWED_CHARS.sub('_', name)\n```\n\n所有非 `[a-zA-Z0-9_-]` 的字符替换为 `_`。防止 server 名或工具名中包含特殊字符导致命名冲突或注入问题。\n\n### assemble_tool_pool:组装工具池\n\n```python\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append(...)\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw:\n c.call_tool(t, kw))\n return tools, handlers\n```\n\n前缀 `mcp__{server}__{tool}` 避免不同 server 的工具名冲突。名称经过 `normalize_mcp_name` 规范化。\n\nMCP 工具的 description 带 `(readOnly)` 或 `(destructive)` 标注——教学版用文本标注,真实 CC 用 tool annotations 结构体让权限系统判断。\n\n### 无缓存:工具池变了,prompt 也变\n\ns10-s18 的 agent_loop 用 prompt cache 避免重复序列化。s19 去掉了缓存:\n\n```python\ndef agent_loop(messages, context):\n tools, handlers = assemble_tool_pool() # 每次重新构建\n system = assemble_system_prompt(context) # 每次重新生成\n ...\n if any(b.name == \"connect_mcp\" ...):\n tools, handlers = assemble_tool_pool() # 连接后重建\n system = assemble_system_prompt(context)\n```\n\n原因:`connect_mcp` 之后工具池变化了——新增了 `mcp__docs__search` 等工具。缓存中的工具列表是旧的,继续用会导致模型调用不到新工具。教学版直接去掉缓存,代价是多花一点序列化时间。\n\n### MCP 工具只有 Lead 可用\n\n教学版中,`connect_mcp` 是 Lead 工具,`assemble_tool_pool` 也只服务于 Lead 的 agent_loop。Teammate 仍使用固定的 8 个子集工具(bash、read_file、write_file、send_message、submit_plan、list_tasks、claim_task、complete_task)。\n\n这是教学简化。真实 CC 中,MCP 工具对主 agent 和子 agent 都可用——子 agent 继承父级的 MCP 配置。\n\n---\n\n## 相对 s18 的变更\n\n| 组件 | 之前 (s18) | 之后 (s19) |\n|------|-----------|-----------|\n| 工具来源 | 全部手写 builtin | 手写 + MCP 外部工具动态发现 |\n| 工具池 | 固定 BUILTIN_TOOLS | assemble_tool_pool 动态组装 mcp\\_\\_ 前缀工具 |\n| 名称安全 | 无 | normalize_mcp_name 规范化 |\n| 新类型 | — | MCPClient 类(模拟 tools/list + tools/call) |\n| 命名空间 | — | mcp\\_\\_server\\_\\_tool 避免冲突 |\n| 工具描述 | 无标注 | (readOnly)/(destructive) 标注 |\n| prompt 缓存 | 有(s10 起) | 去掉——工具池动态变化后缓存失效 |\n| Lead 工具 | 17 (s18) | 18 (+connect_mcp) |\n| Teammate 工具 | 8 (s18) | 8(不变,MCP 工具仅 Lead 可用) |\n| 扩展方式 | 写代码加工具 | 标准协议,任意语言实现 server |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s19_mcp_plugin/code.py\n```\n\n试试这些 prompt:\n\n1. `Connect to the docs MCP server and search for something`\n2. `Connect to the deploy server and trigger a deployment`\n3. `Connect both servers — what tools are now available?`\n\n观察重点:连接 MCP server 后,工具名是否带 `mcp__docs__` 或 `mcp__deploy__` 前缀?两个 server 的工具是否同时可用?MCP 工具的 description 是否带 (readOnly)/(destructive) 标注?\n\n---\n\n## 接下来\n\n现在 Agent 可以通过标准协议接入外部工具了。但前面 19 章每章都只加一个机制,真实 Agent 不会这样拆开运行。\n\n工具、权限、hooks、todo、任务图、记忆、压缩、后台、cron、团队、worktree、MCP 这些机制应该挂在同一个循环上,而不是散在 19 个 demo 里。\n\ns20 Comprehensive Agent → 把前 19 章的机制合回一个完整 harness。机制很多,循环一个。\n\n
\n深入 CC 源码\n\n> 以下基于 CC 源码 `services/mcp/client.ts`、`auth.ts`、`config.ts`、`channelNotification.ts` 的分析。\n\n### 一、6 种 Transport 类型\n\n教学版只展示了 stdio mock。CC 支持 6 种传输(`types.ts:23-25`):\n\n| Transport | 通信方式 |\n|-----------|---------|\n| `stdio` | 子进程 stdin/stdout(跨平台默认) |\n| `sse` | HTTP Server-Sent Events |\n| `http` | Streamable HTTP(POST/SSE 双向) |\n| `ws` | WebSocket |\n| `sse-ide` | IDE 内嵌 SSE 传输 |\n| `sdk` | 进程内 SDK 传输 |\n\n连接时本地(stdio)和远程(http/sse/ws)服务器分批并发:本地批量 3 个,远程批量 20 个。\n\n### 二、工具池组装算法\n\n`assembleToolPool()`(`tools.ts:345-364`):\n\n```typescript\n// 去重时优先保留内置工具(name 相同时内置在前)\nreturn uniqBy(\n [...builtInTools.sort(byName), ...filteredMcpTools.sort(byName)],\n 'name',\n)\n```\n\n内置工具和 MCP 工具分开排序,不是合起来排。原因是 CC 的 `claude_code_system_cache_policy` 在最后一个内置工具之后的某个位置放全局缓存断点——混排会破坏这个设计。\n\n### 三、命名规则:`mcp__server__tool`\n\n`buildMcpToolName()`(`mcpStringUtils.ts:50-52`):\n\n```\nmcp____\n```\n\n所有非 `[a-zA-Z0-9_-]` 字符替换为 `_`(`normalization.ts:17-23`)。教学版的 `normalize_mcp_name` 用同样的规则。\n\n### 四、权限检查\n\nCC 对 MCP 工具有独立的权限系统。`checkPermissions()` 对 MCP 工具的检查逻辑不同于内置工具——MCP 工具可以声明自己的权限需求(readOnly、destructive 等),CC 根据声明决定是否需要用户确认。教学版只在 description 中用文本标注 `(readOnly)` / `(destructive)`,不做权限拦截。\n\n### 五、配置来源与优先级\n\nMCP 服务器配置来自多个来源。CC 的配置优先级从低到高:\n\n```\nclaude.ai 连接器 < plugin < user settings.json < approved project .mcp.json < local settings.local.json\n```\n\n`claude.ai` 连接器单独拉取、按内容签名去重,以最低优先级合并(`config.ts:1267-1289`)。企业 `managed-mcp.json` 存在时完全排除其他配置。\n\n教学版直接传 server name 给 `MOCK_SERVERS` 字典,不做配置合并。\n\n### 六、Channel 通知:服务器反向推消息\n\n教学版只讲了 Agent → MCP Server 的单向调用。CC 还支持反向通知(`channelNotification.ts`):\n\n1. Server 声明 `capabilities.experimental['claude/channel']`\n2. Server 通过 MCP 通知 `notifications/claude/channel` 给 Agent 发消息\n3. 消息包装在 `...` XML 标签中\n4. Agent 被 SleepTool 唤醒(1 秒内)\n\nServer 还可以请求权限:`notifications/claude/channel/permission_request` → Agent 回复 `notifications/claude/channel/permission`。用户通过 5 字母短 ID 确认/拒绝。\n\n### 七、OAuth 认证流程\n\nCC 的 MCP 认证(`auth.ts`)支持完整的 OAuth 2.0 + PKCE 流程:\n- 通过公钥客户端 + PKCE 发现 OAuth 元数据(RFC 8414 / RFC 9728)\n- 本地回调服务器接收授权码\n- 令牌通过 `getSecureStorage()` 持久化(macOS Keychain / Linux 加密文件 / Windows 凭据管理器)\n- 过期前 5 分钟自动刷新\n- 支持跨应用访问(XAA):浏览器获取 id_token → RFC 8693 + RFC 7523 交换 → 无需反复弹浏览器\n\n### 八、连接生命周期的错误处理\n\nCC 对 MCP 连接有精细的错误分类和重试(`client.ts:1266-1402`):\n- 终局性错误(ECONNRESET、ETIMEDOUT、EPIPE 等):连续 3 次 → 关闭 + 重连\n- 工具调用 401:令牌过期 → 抛出 `McpAuthError` → 触发重认证\n- 工具调用超时:`Promise.race` 超时(可配置,默认约 28 小时)\n- Stdio 断连:按 SIGINT → SIGTERM → SIGKILL 顺序杀进程\n\n### 教学版的简化\n\n- 6 种 transport → 1 种(mock stdio):概念量可控\n- Channel 反向通知 → 省略:教学版 Agent 是主动方\n- OAuth 流程 → 省略:教学版假设 server 不需要认证\n- 多层配置优先级 → 省略:教学版直接传 server name\n- 复杂的错误分类 → 省略:教学版用 try/except 兜底\n- MCP 工具只给 Lead → 省略子 agent 继承:简化代码结构\n\n
\n\n\n" + "content": "# s19: MCP Tools — 外接工具,标准协议\n\ns01 → ... → s17 → s18 → `s19` → [s20](/zh/s20)\n\n> *\"外接工具, 标准协议\"* — 发现、组装、调用,Agent 不需要知道工具是谁写的。\n>\n> **Harness 层**: 插件 — 外部能力通过标准协议接入。\n\n---\n\n## 问题\n\ns01 到 s18,Agent 的所有工具都是手写的:bash、read、write、task、worktree。每个工具的输入验证、执行逻辑、错误处理,都是你一行行写的。\n\n现在你有 3 个外部服务想接入:公司的 Jira API(查 issue、建 ticket)、自建的部署系统(触发 deploy、看日志)、团队的 Notion 知识库(搜文档、建页面)。你不想为每个服务重写一套工具代码。\n\n你需要一个标准协议——外部服务只要实现它,Agent 就能直接调用,不管服务用什么语言写的。\n\n---\n\n## 解决方案\n\n![MCP Architecture](/course-assets/s19_mcp_plugin/mcp-architecture.svg)\n\nMCP(Model Context Protocol)定义了 Agent 如何发现和调用外部工具。核心概念:\n\n| 概念 | 作用 |\n|------|------|\n| MCPClient | Agent 端的客户端,连接 server、发现工具、调用工具 |\n| MCP Server | 外部服务,实现 `tools/list` + `tools/call` |\n| assemble_tool_pool | 把内置工具和 MCP 工具组装成一个工具池 |\n| mcp\\_\\_server\\_\\_tool 命名 | 不同 server 的工具名隔离 |\n\n沿用 s18 的教学版 worktree 隔离、自主认领、空闲轮询、协议系统。本章新增:`connect_mcp` 工具,用于连接外部服务、发现工具、加入工具池。\n\n教学版用 mock handler 模拟外部 server。真实版会启动子进程,通过 stdin/stdout 发送 JSON-RPC 请求。mock 的好处是不依赖外部服务就能跑完整流程;代价是你看不到真正的网络通信和进程管理。\n\n---\n\n## 工作原理\n\n### MCPClient:发现 + 调用\n\n```python\nclass MCPClient:\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs, handlers):\n \"\"\"Simulates tools/list discovery.\"\"\"\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n \"\"\"Simulates tools/call.\"\"\"\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n return handler(**args)\n```\n\n教学版用 Python 函数模拟 server 的工具实现。真实版通过 stdio JSON-RPC 与子进程通信。\n\n### connect_mcp:连接 + 发现\n\n```python\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n return f\"Unknown server '{name}'. Available: ...\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n return f\"Connected to '{name}'. Discovered: ...\"\n```\n\n连接后,server 提供的工具立即可用。\n\n### normalize_mcp_name:名称规范化\n\n```python\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\ndef normalize_mcp_name(name: str) -> str:\n return _DISALLOWED_CHARS.sub('_', name)\n```\n\n所有非 `[a-zA-Z0-9_-]` 的字符替换为 `_`。防止 server 名或工具名中包含特殊字符导致命名冲突或注入问题。\n\n### assemble_tool_pool:组装工具池\n\n```python\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append(...)\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw:\n c.call_tool(t, kw))\n return tools, handlers\n```\n\n前缀 `mcp__{server}__{tool}` 把每个 server 的工具隔离到独立命名空间。名称经过 `normalize_mcp_name` 规范化。\n\nMCP 工具的 description 带 `(readOnly)` 或 `(destructive)` 标注。教学版用文本标注,真实 Claude Code 用 tool annotations 结构体让权限系统判断。\n\n### 无缓存:工具池变了,prompt 也变\n\ns10-s18 的 agent_loop 用 prompt cache 避免重复序列化。s19 去掉了缓存:\n\n```python\ndef agent_loop(messages, context):\n tools, handlers = assemble_tool_pool() # 每次重新构建\n system = assemble_system_prompt(context) # 每次重新生成\n ...\n if any(b.name == \"connect_mcp\" ...):\n tools, handlers = assemble_tool_pool() # 连接后重建\n system = assemble_system_prompt(context)\n```\n\n原因:`connect_mcp` 之后工具池变化了,新增了 `mcp__docs__search` 等工具。缓存中的工具列表是旧的,继续用会导致模型调用不到新工具。教学版直接去掉缓存,代价是多花一点序列化时间。\n\n### MCP 工具只有 Lead 可用\n\n教学版中,`connect_mcp` 是 Lead 工具,`assemble_tool_pool` 也只服务于 Lead 的 agent_loop。Teammate 仍使用固定的 8 个子集工具(bash、read_file、write_file、send_message、submit_plan、list_tasks、claim_task、complete_task)。\n\n这是教学简化。真实 Claude Code 中,MCP 工具对主 agent 和子 agent 都可用,子 agent 继承父级的 MCP 配置。\n\n---\n\n## 相对 s18 的变更\n\n| 组件 | 之前 (s18) | 之后 (s19) |\n|------|-----------|-----------|\n| 工具来源 | 全部手写 builtin | 手写 + MCP 外部工具动态发现 |\n| 工具池 | 固定 BUILTIN_TOOLS | assemble_tool_pool 动态组装 mcp\\_\\_ 前缀工具 |\n| 名称安全 | 无 | normalize_mcp_name 规范化 |\n| 新类型 | — | MCPClient 类(模拟 tools/list + tools/call) |\n| 命名空间 | — | mcp\\_\\_server\\_\\_tool 避免冲突 |\n| 工具描述 | 无标注 | (readOnly)/(destructive) 标注 |\n| prompt 缓存 | 有(s10 起) | 去掉,工具池动态变化后缓存失效 |\n| Lead 工具 | 17 (s18) | 18 (+connect_mcp) |\n| Teammate 工具 | 8 (s18) | 8(不变,MCP 工具仅 Lead 可用) |\n| 扩展方式 | 写代码加工具 | MCP 协议,任意语言实现 server |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s19_mcp_plugin/code.py\n```\n\n试试这些 prompt:\n\n1. `Connect to the docs MCP server and search for something`\n2. `Connect to the deploy server and trigger a deployment`\n3. `Connect both servers — what tools are now available?`\n\n观察重点:连接 MCP server 后,工具名是否带 `mcp__docs__` 或 `mcp__deploy__` 前缀?两个 server 的工具是否同时可用?MCP 工具的 description 是否带 (readOnly)/(destructive) 标注?\n\n---\n\n## 接下来\n\n现在 Agent 可以通过 MCP 接入外部工具了。但前面 19 章每章都只加一个机制,真实 Agent 不会这样拆开运行。\n\n工具、权限、hooks、todo、任务图、记忆、压缩、后台、cron、团队、worktree、MCP 这些机制应该挂在同一个循环上,而不是散在 19 个 demo 里。\n\ns20 Comprehensive Agent → 把前 19 章的机制合回一个完整 harness。机制很多,循环一个。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `services/mcp/client.ts`、`auth.ts`、`config.ts`、`channelNotification.ts` 的分析。\n\n### 一、6 种 Transport 类型\n\n教学版只展示了 stdio mock。Claude Code 支持 6 种传输(`types.ts:23-25`):\n\n| Transport | 通信方式 |\n|-----------|---------|\n| `stdio` | 子进程 stdin/stdout(跨平台默认) |\n| `sse` | HTTP Server-Sent Events |\n| `http` | Streamable HTTP(POST/SSE 双向) |\n| `ws` | WebSocket |\n| `sse-ide` | IDE 内嵌 SSE 传输 |\n| `sdk` | 进程内 SDK 传输 |\n\n连接时本地(stdio)和远程(http/sse/ws)服务器分批并发:本地批量 3 个,远程批量 20 个。\n\n### 二、工具池组装算法\n\n`assembleToolPool()`(`tools.ts:345-364`):\n\n```typescript\n// 去重时优先保留内置工具(name 相同时内置在前)\nreturn uniqBy(\n [...builtInTools.sort(byName), ...filteredMcpTools.sort(byName)],\n 'name',\n)\n```\n\n内置工具和 MCP 工具分开排序,不是合起来排。原因是 Claude Code 的 `claude_code_system_cache_policy` 在最后一个内置工具之后的某个位置放全局缓存断点,混排会破坏这个设计。\n\n### 三、命名规则:`mcp__server__tool`\n\n`buildMcpToolName()`(`mcpStringUtils.ts:50-52`):\n\n```\nmcp____\n```\n\n所有非 `[a-zA-Z0-9_-]` 字符替换为 `_`(`normalization.ts:17-23`)。教学版的 `normalize_mcp_name` 用同样的规则。\n\n### 四、权限检查\n\nClaude Code 对 MCP 工具有独立的权限系统。`checkPermissions()` 对 MCP 工具的检查逻辑不同于内置工具:MCP 工具可以声明自己的权限需求(readOnly、destructive 等),Claude Code 根据声明决定是否需要用户确认。教学版只在 description 中用文本标注 `(readOnly)` / `(destructive)`,不做权限拦截。\n\n### 五、配置来源与优先级\n\nMCP 服务器配置来自多个来源。Claude Code 的配置优先级从低到高:\n\n```\nclaude.ai 连接器 < plugin < user settings.json < approved project .mcp.json < local settings.local.json\n```\n\n`claude.ai` 连接器单独拉取、按内容签名去重,以最低优先级合并(`config.ts:1267-1289`)。企业 `managed-mcp.json` 存在时完全排除其他配置。\n\n教学版直接传 server name 给 `MOCK_SERVERS` 字典,不做配置合并。\n\n### 六、Channel 通知:服务器反向推消息\n\n教学版只讲了 Agent → MCP Server 的单向调用。Claude Code 还支持反向通知(`channelNotification.ts`):\n\n1. Server 声明 `capabilities.experimental['claude/channel']`\n2. Server 通过 MCP 通知 `notifications/claude/channel` 给 Agent 发消息\n3. 消息包装在 `...` XML 标签中\n4. Agent 被 SleepTool 唤醒(1 秒内)\n\nServer 还可以请求权限:`notifications/claude/channel/permission_request` → Agent 回复 `notifications/claude/channel/permission`。用户通过 5 字母短 ID 确认/拒绝。\n\n### 七、OAuth 认证流程\n\nClaude Code 的 MCP 认证(`auth.ts`)支持完整的 OAuth 2.0 + PKCE 流程:\n- 通过公钥客户端 + PKCE 发现 OAuth 元数据(RFC 8414 / RFC 9728)\n- 本地回调服务器接收授权码\n- 令牌通过 `getSecureStorage()` 持久化(macOS Keychain / Linux 加密文件 / Windows 凭据管理器)\n- 过期前 5 分钟自动刷新\n- 支持跨应用访问(XAA):浏览器获取 id_token → RFC 8693 + RFC 7523 交换 → 无需反复弹浏览器\n\n### 八、连接生命周期的错误处理\n\nClaude Code 对 MCP 连接有精细的错误分类和重试(`client.ts:1266-1402`):\n- 终局性错误(ECONNRESET、ETIMEDOUT、EPIPE 等):连续 3 次 → 关闭 + 重连\n- 工具调用 401:令牌过期 → 抛出 `McpAuthError` → 触发重认证\n- 工具调用超时:`Promise.race` 超时(可配置,默认约 28 小时)\n- Stdio 断连:按 SIGINT → SIGTERM → SIGKILL 顺序杀进程\n\n### 教学版的简化\n\n- 6 种 transport → 1 种(mock stdio):概念量可控\n- Channel 反向通知 → 省略:教学版 Agent 是主动方\n- OAuth 流程 → 省略:教学版假设 server 不需要认证\n- 多层配置优先级 → 省略:教学版直接传 server name\n- 复杂的错误分类 → 省略:教学版用 try/except 兜底\n- MCP 工具只给 Lead → 省略子 agent 继承:简化代码结构\n\n
\n\n\n" }, { "version": "s19", "locale": "ja", "title": "s19: MCP Tools — 外部ツール、標準プロトコル", - "content": "# s19: MCP Tools — 外部ツール、標準プロトコル\n\ns01 → ... → s17 → s18 → `s19` → [s20](/ja/s20)\n\n> *\"外部ツール、標準プロトコル\"* — 発見、組み立て、呼び出し。Agent はツールを誰が書いたか知る必要がない。\n>\n> **Harness 層**: プラグイン — 外部能力を標準プロトコルで接続。\n\n---\n\n## 課題\n\ns01 から s18 まで、Agent の全ツールは手書き — bash、read、write、task、worktree。入力検証、実行ロジック、エラーハンドリング、全て一行ずつ書いた。\n\n今、統合したい外部サービスが 3 つある:社内の Jira API(issue 検索、ticket 作成)、独自のデプロイシステム(deploy トリガー、ログ閲覧)、チームの Notion ナレッジベース(ドキュメント検索、ページ作成)。各サービスのためにツールコードを書き直したくない。\n\n標準プロトコルが必要 — 外部サービスがこのプロトコルを実装していれば、サービスが何の言語で書かれていても、Agent は直接そのツールを呼び出せる。\n\n---\n\n## ソリューション\n\n![MCP Architecture](/course-assets/s19_mcp_plugin/mcp-architecture.ja.svg)\n\nMCP(Model Context Protocol)は、Agent が外部ツールを発見・呼び出しする方法を定義。核心概念:\n\n| 概念 | 目的 |\n|------|------|\n| MCPClient | Agent 側のクライアント — server に接続、ツールを発見、ツールを呼び出し |\n| MCP Server | 外部サービス側 — `tools/list` + `tools/call` を実装 |\n| assemble_tool_pool | 組み込みツールと MCP ツールを一つのツールプールに組み立てる |\n| mcp\\_\\_server\\_\\_tool 命名 | 異なる server 間のツール名衝突を防止 |\n\ns18 の教学版 worktree 隔離、自動認領、空き時ポーリング、プロトコルシステムを踏襲。本章の追加:`connect_mcp` ツール — 外部サービスに接続、ツールを発見、ツールプールに追加。\n\n教学版は mock handler で外部 server をシミュレート。実際の版はサブプロセスを起動し、stdin/stdout で JSON-RPC リクエストを送信。mock の利点は外部サービスなしで完全なフローを実行できること;代償は実際のネットワーク通信やプロセス管理が見えないこと。\n\n---\n\n## 仕組み\n\n### MCPClient:発見 + 呼び出し\n\n```python\nclass MCPClient:\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs, handlers):\n \"\"\"Simulates tools/list discovery.\"\"\"\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n \"\"\"Simulates tools/call.\"\"\"\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n return handler(**args)\n```\n\n教学版は Python 関数で server のツール実装をシミュレート。実際の版は stdio JSON-RPC でサブプロセスと通信。\n\n### connect_mcp:接続 + 発見\n\n```python\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n return f\"Unknown server '{name}'. Available: ...\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n return f\"Connected to '{name}'. Discovered: ...\"\n```\n\n接続後、server が提供するツールが即座に利用可能。\n\n### normalize_mcp_name:名前の正規化\n\n```python\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\ndef normalize_mcp_name(name: str) -> str:\n return _DISALLOWED_CHARS.sub('_', name)\n```\n\n`[a-zA-Z0-9_-]` 以外の全文字を `_` に置換。server 名やツール名の特殊文字による名前衝突やインジェクション問題を防止。\n\n### assemble_tool_pool:ツールプールの組み立て\n\n```python\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append(...)\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw:\n c.call_tool(t, kw))\n return tools, handlers\n```\n\nプレフィックス `mcp__{server}__{tool}` で異なる server 間のツール名衝突を防止。名前は `normalize_mcp_name` で正規化。\n\nMCP ツールの description に `(readOnly)` または `(destructive)` アノテーションを付与 — 教学版はテキストアノテーション、実際の CC は tool annotations 構造体で権限システムが判断。\n\n### キャッシュなし:ツールプールが変われば、プロンプトも変わる\n\ns10-s18 の agent_loop は prompt cache で再シリアライズを回避。s19 はキャッシュを削除:\n\n```python\ndef agent_loop(messages, context):\n tools, handlers = assemble_tool_pool() # 毎回再構築\n system = assemble_system_prompt(context) # 毎回再生成\n ...\n if any(b.name == \"connect_mcp\" ...):\n tools, handlers = assemble_tool_pool() # 接続後に再構築\n system = assemble_system_prompt(context)\n```\n\n理由:`connect_mcp` 後にツールプールが変化 — `mcp__docs__search` などの新ツールが追加される。キャッシュ内のツールリストは古く、使い続けるとモデルが新ツールを呼び出せない。教学版はキャッシュを単に削除、代償はシリアライズ時間の若干の増加。\n\n### MCP ツールは Lead のみ利用可能\n\n教学版では、`connect_mcp` は Lead ツール、`assemble_tool_pool` も Lead の agent_loop のみにサービスを提供。チームメイトは引き続き固定の 8 ツールサブセット(bash、read_file、write_file、send_message、submit_plan、list_tasks、claim_task、complete_task)を使用。\n\nこれは教学簡略化。実際の CC では、MCP ツールはメイン agent とサブ agent の両方で利用可能 — サブ agent は親の MCP 設定を継承。\n\n---\n\n## s18 からの変更\n\n| コンポーネント | 変更前 (s18) | 変更後 (s19) |\n|--------------|------------|------------|\n| ツールソース | 全て手書き builtin | 手書き + MCP 外部ツール動的発見 |\n| ツールプール | 固定 BUILTIN_TOOLS | assemble_tool_pool が動的に mcp\\_\\_ プレフィックスツールを組み立てる |\n| 名前の安全性 | なし | normalize_mcp_name 正規化 |\n| 新規タイプ | — | MCPClient クラス(tools/list + tools/call をシミュレート) |\n| 名前空間 | — | mcp\\_\\_server\\_\\_tool 衝突防止 |\n| ツール説明 | アノテーションなし | (readOnly)/(destructive) アノテーション |\n| プロンプトキャッシュ | あり(s10 から) | 削除 — ツールプールが動的、キャッシュが陳腐化 |\n| Lead ツール | 17 (s18) | 18 (+connect_mcp) |\n| チームメイトツール | 8 (s18) | 8(変更なし、MCP ツールは Lead のみ) |\n| 拡張方法 | ツール追加のコードを書く | 標準プロトコル、任意言語で server を実装 |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s19_mcp_plugin/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `Connect to the docs MCP server and search for something`\n2. `Connect to the deploy server and trigger a deployment`\n3. `Connect both servers — what tools are now available?`\n\n観察ポイント:MCP server 接続後、ツール名に `mcp__docs__` や `mcp__deploy__` プレフィックスが付いているか?両方の server のツールが同時に利用可能か?MCP ツールの description に (readOnly)/(destructive) アノテーションが付いているか?\n\n---\n\n## 次の章\n\nAgent は標準プロトコルで外部ツールに接続できるようになりました。しかし前 19 章は各章で 1 つの仕組みだけを追加しています。実際の Agent は 19 個の demo に分かれて動くわけではありません。\n\ntools、permissions、hooks、todo、task graph、memory、compact、background work、cron、teams、worktree、MCP は、別々の例ではなく同じ loop に接続されるべきです。\n\ns20 Comprehensive Agent → 前 19 章の仕組みを 1 つの完全な harness に統合。仕組みは多く、loop は 1 つ。\n\n
\nCC ソースコード深掘り\n\n> 以下は CC ソースコード `services/mcp/client.ts`、`auth.ts`、`config.ts`、`channelNotification.ts` の分析に基づく。\n\n### 一、6 種の Transport タイプ\n\n教学版は stdio mock のみ。CC は 6 種のトランスポートをサポート(`types.ts:23-25`):\n\n| Transport | 通信方式 |\n|-----------|---------|\n| `stdio` | サブプロセス stdin/stdout(クロスプラットフォームデフォルト) |\n| `sse` | HTTP Server-Sent Events |\n| `http` | Streamable HTTP(POST/SSE 双方向) |\n| `ws` | WebSocket |\n| `sse-ide` | IDE 内蔵 SSE トランスポート |\n| `sdk` | プロセス内 SDK トランスポート |\n\n接続時、ローカル(stdio)とリモート(http/sse/ws)サーバーをバッチで並行処理:ローカルは 3 つずつ、リモートは 20 つずつ。\n\n### 二、ツールプール組み立てアルゴリズム\n\n`assembleToolPool()`(`tools.ts:345-364`):\n\n```typescript\n// 重複排除時に組み込みツールを優先(name が同じ場合、組み込みが先)\nreturn uniqBy(\n [...builtInTools.sort(byName), ...filteredMcpTools.sort(byName)],\n 'name',\n)\n```\n\n組み込みツールと MCP ツールは別々にソート、混ぜてソートしない。理由は CC の `claude_code_system_cache_policy` が最後の組み込みツールの後の特定位置にグローバルキャッシュブレークポイントを置く設計のため — ソートを混ぜるとこの設計が壊れる。\n\n### 三、命名規則:`mcp__server__tool`\n\n`buildMcpToolName()`(`mcpStringUtils.ts:50-52`):\n\n```\nmcp____\n```\n\n`[a-zA-Z0-9_-]` 以外の全文字を `_` に置換(`normalization.ts:17-23`)。教学版の `normalize_mcp_name` も同じルールを使用。\n\n### 四、権限チェック\n\nCC は MCP ツールに対して独立した権限システムを持つ。`checkPermissions()` は MCP ツールに対して組み込みツールとは異なるロジックを適用 — MCP ツールは独自の権限要件(readOnly、destructive 等)を宣言でき、CC は宣言に基づいてユーザー確認が必要かを判断。教学版は description 内のテキストアノテーション `(readOnly)` / `(destructive)` のみで、権限インターセプトは行わない。\n\n### 五、設定ソースと優先度\n\nMCP サーバー設定は複数のソースから。CC の優先度は低い順に:\n\n```\nclaude.ai コネクタ < プラグイン < ユーザー settings.json < 承認済みプロジェクト .mcp.json < ローカル settings.local.json\n```\n\n`claude.ai` コネクタは個別に取得、コンテンツ署名で重複排除し、最低優先度で統合(`config.ts:1267-1289`)。企業 `managed-mcp.json` が存在する場合、他の全設定を完全に除外。\n\n教学版は server 名を直接 `MOCK_SERVERS` 辞書に渡し、設定マージは行わない。\n\n### 六、Channel 通知:サーバーからの逆方向メッセージ\n\n教学版は Agent → MCP Server の一方向呼び出しのみ。CC は逆方向通知もサポート(`channelNotification.ts`):\n\n1. Server が `capabilities.experimental['claude/channel']` を宣言\n2. Server が MCP 通知 `notifications/claude/channel` で Agent にメッセージを送信\n3. メッセージは `...` XML タグでラップ\n4. Agent は SleepTool で起床(1 秒以内)\n\nServer は権限リクエストも可能:`notifications/claude/channel/permission_request` → Agent が `notifications/claude/channel/permission` で応答。ユーザーは 5 文字の短い ID で確認/拒否。\n\n### 七、OAuth 認証フロー\n\nCC の MCP 認証(`auth.ts`)は完全な OAuth 2.0 + PKCE フローをサポート:\n- 公開クライアント + PKCE で OAuth メタデータを発見(RFC 8414 / RFC 9728)\n- ローカルコールバックサーバーが認可コードを受信\n- トークンは `getSecureStorage()` で永続化(macOS Keychain / Linux 暗号化ファイル / Windows 資格情報マネージャー)\n- 有効期限 5 分前に自動リフレッシュ\n- クロスアプリケーションアクセス(XAA):ブラウザが id_token を取得 → RFC 8693 + RFC 7523 交換 → 繰り返しブラウザポップアップ不要\n\n### 八、接続ライフサイクルのエラーハンドリング\n\nCC は MCP 接続にきめ細かいエラー分類とリトライを行う(`client.ts:1266-1402`):\n- 終局エラー(ECONNRESET、ETIMEDOUT、EPIPE 等):連続 3 回 → クローズ + 再接続\n- ツール呼び出し 401:トークン期限切れ → `McpAuthError` スロー → 再認証トリガー\n- ツール呼び出しタイムアウト:`Promise.race` タイムアウト(設定可能、デフォルト約 28 時間)\n- Stdio 切断:SIGINT → SIGTERM → SIGKILL の順でプロセスを kill\n\n### 教学版の簡略化\n\n- 6 種のトランスポート → 1 種(mock stdio):概念量を管理可能に\n- Channel 逆方向通知 → 省略:教学版 Agent は常にイニシエータ\n- OAuth フロー → 省略:教学版は server が認証不要と仮定\n- 多層設定優先度 → 省略:教学版は直接 server 名を渡す\n- 複雑なエラー分類 → 省略:教学版は try/except でフォールバック\n- MCP ツールは Lead のみ → サブ agent 継承を省略:コード構造を簡略化\n\n
\n\n\n" + "content": "# s19: MCP Tools — 外部ツール、標準プロトコル\n\ns01 → ... → s17 → s18 → `s19` → [s20](/ja/s20)\n\n> *\"外部ツール、標準プロトコル\"* — 発見、組み立て、呼び出し。Agent はツールを誰が書いたか知る必要がない。\n>\n> **Harness 層**: プラグイン — 外部能力を標準プロトコルで接続。\n\n---\n\n## 課題\n\ns01 から s18 まで、Agent の全ツールは手書きだった:bash、read、write、task、worktree。入力検証、実行ロジック、エラーハンドリング、全て一行ずつ書いた。\n\n今、統合したい外部サービスが 3 つある:社内の Jira API(issue 検索、ticket 作成)、独自のデプロイシステム(deploy トリガー、ログ閲覧)、チームの Notion ナレッジベース(ドキュメント検索、ページ作成)。各サービスのためにツールコードを書き直したくない。\n\n標準プロトコルが必要だ。外部サービスがこのプロトコルを実装していれば、サービスが何の言語で書かれていても、Agent は直接そのツールを呼び出せる。\n\n---\n\n## ソリューション\n\n![MCP Architecture](/course-assets/s19_mcp_plugin/mcp-architecture.svg)\n\nMCP(Model Context Protocol)は、Agent が外部ツールを発見・呼び出しする方法を定義。核心概念:\n\n| 概念 | 目的 |\n|------|------|\n| MCPClient | Agent 側のクライアント。server に接続、ツールを発見、ツールを呼び出し |\n| MCP Server | 外部サービス。`tools/list` + `tools/call` を実装 |\n| assemble_tool_pool | 組み込みツールと MCP ツールを一つのツールプールに組み立てる |\n| mcp\\_\\_server\\_\\_tool 命名 | 異なる server 間のツール名衝突を防止 |\n\ns18 の教学版 worktree 隔離、自動認領、空き時ポーリング、プロトコルシステムを踏襲。本章の追加:`connect_mcp` ツール。外部サービスに接続し、ツールを発見してツールプールに追加する。\n\n教学版は mock handler で外部 server をシミュレート。実際の版はサブプロセスを起動し、stdin/stdout で JSON-RPC リクエストを送信。mock の利点は外部サービスなしで完全なフローを実行できること;代償は実際のネットワーク通信やプロセス管理が見えないこと。\n\n---\n\n## 仕組み\n\n### MCPClient:発見 + 呼び出し\n\n```python\nclass MCPClient:\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs, handlers):\n \"\"\"Simulates tools/list discovery.\"\"\"\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n \"\"\"Simulates tools/call.\"\"\"\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n return handler(**args)\n```\n\n教学版は Python 関数で server のツール実装をシミュレート。実際の版は stdio JSON-RPC でサブプロセスと通信。\n\n### connect_mcp:接続 + 発見\n\n```python\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n return f\"Unknown server '{name}'. Available: ...\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n return f\"Connected to '{name}'. Discovered: ...\"\n```\n\n接続後、server が提供するツールが即座に利用可能。\n\n### normalize_mcp_name:名前の正規化\n\n```python\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\ndef normalize_mcp_name(name: str) -> str:\n return _DISALLOWED_CHARS.sub('_', name)\n```\n\n`[a-zA-Z0-9_-]` 以外の全文字を `_` に置換。server 名やツール名の特殊文字による名前衝突やインジェクション問題を防止。\n\n### assemble_tool_pool:ツールプールの組み立て\n\n```python\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append(...)\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw:\n c.call_tool(t, kw))\n return tools, handlers\n```\n\nプレフィックス `mcp__{server}__{tool}` で各 server のツールを名前空間で隔離。名前は `normalize_mcp_name` で正規化。\n\nMCP ツールの description に `(readOnly)` または `(destructive)` アノテーションを付与する。教学版はテキストアノテーション、実際の Claude Code は tool annotations 構造体で権限システムが判断。\n\n### キャッシュなし:ツールプールが変われば、プロンプトも変わる\n\ns10-s18 の agent_loop は prompt cache で再シリアライズを回避。s19 はキャッシュを削除:\n\n```python\ndef agent_loop(messages, context):\n tools, handlers = assemble_tool_pool() # 毎回再構築\n system = assemble_system_prompt(context) # 毎回再生成\n ...\n if any(b.name == \"connect_mcp\" ...):\n tools, handlers = assemble_tool_pool() # 接続後に再構築\n system = assemble_system_prompt(context)\n```\n\n理由:`connect_mcp` 後にツールプールが変化し、`mcp__docs__search` などの新ツールが追加される。キャッシュ内のツールリストは古く、使い続けるとモデルが新ツールを呼び出せない。教学版はキャッシュを単に削除、代償はシリアライズ時間の若干の増加。\n\n### MCP ツールは Lead のみ利用可能\n\n教学版では、`connect_mcp` は Lead ツール、`assemble_tool_pool` も Lead の agent_loop のみにサービスを提供。チームメイトは引き続き固定の 8 ツールサブセット(bash、read_file、write_file、send_message、submit_plan、list_tasks、claim_task、complete_task)を使用。\n\nこれは教学簡略化。実際の Claude Code では、MCP ツールはメイン agent とサブ agent の両方で利用可能で、サブ agent は親の MCP 設定を継承する。\n\n---\n\n## s18 からの変更\n\n| コンポーネント | 変更前 (s18) | 変更後 (s19) |\n|--------------|------------|------------|\n| ツールソース | 全て手書き builtin | 手書き + MCP 外部ツール動的発見 |\n| ツールプール | 固定 BUILTIN_TOOLS | assemble_tool_pool が動的に mcp\\_\\_ プレフィックスツールを組み立てる |\n| 名前の安全性 | なし | normalize_mcp_name 正規化 |\n| 新規タイプ | — | MCPClient クラス(tools/list + tools/call をシミュレート) |\n| 名前空間 | — | mcp\\_\\_server\\_\\_tool 衝突防止 |\n| ツール説明 | アノテーションなし | (readOnly)/(destructive) アノテーション |\n| プロンプトキャッシュ | あり(s10 から) | 削除。ツールプールが動的でキャッシュが陳腐化 |\n| Lead ツール | 17 (s18) | 18 (+connect_mcp) |\n| チームメイトツール | 8 (s18) | 8(変更なし、MCP ツールは Lead のみ) |\n| 拡張方法 | ツール追加のコードを書く | MCP プロトコルで任意言語の server を接続 |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s19_mcp_plugin/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `Connect to the docs MCP server and search for something`\n2. `Connect to the deploy server and trigger a deployment`\n3. `Connect both servers — what tools are now available?`\n\n観察ポイント:MCP server 接続後、ツール名に `mcp__docs__` や `mcp__deploy__` プレフィックスが付いているか?両方の server のツールが同時に利用可能か?MCP ツールの description に (readOnly)/(destructive) アノテーションが付いているか?\n\n---\n\n## 次の章\n\nAgent は MCP で外部ツールに接続できるようになった。しかし前 19 章は各章で 1 つの仕組みだけを追加しています。実際の Agent は 19 個の demo に分かれて動くわけではありません。\n\ntools、permissions、hooks、todo、task graph、memory、compact、background work、cron、teams、worktree、MCP は、別々の例ではなく同じ loop に接続されるべきです。\n\ns20 Comprehensive Agent → 前 19 章の仕組みを 1 つの完全な harness に統合。仕組みは多く、loop は 1 つ。\n\n
\nClaude Code ソースコード深掘り\n\n> 以下は Claude Code ソースコード `services/mcp/client.ts`、`auth.ts`、`config.ts`、`channelNotification.ts` の分析に基づく。\n\n### 一、6 種の Transport タイプ\n\n教学版は stdio mock のみ。Claude Code は 6 種のトランスポートをサポート(`types.ts:23-25`):\n\n| Transport | 通信方式 |\n|-----------|---------|\n| `stdio` | サブプロセス stdin/stdout(クロスプラットフォームデフォルト) |\n| `sse` | HTTP Server-Sent Events |\n| `http` | Streamable HTTP(POST/SSE 双方向) |\n| `ws` | WebSocket |\n| `sse-ide` | IDE 内蔵 SSE トランスポート |\n| `sdk` | プロセス内 SDK トランスポート |\n\n接続時、ローカル(stdio)とリモート(http/sse/ws)サーバーをバッチで並行処理:ローカルは 3 つずつ、リモートは 20 つずつ。\n\n### 二、ツールプール組み立てアルゴリズム\n\n`assembleToolPool()`(`tools.ts:345-364`):\n\n```typescript\n// 重複排除時に組み込みツールを優先(name が同じ場合、組み込みが先)\nreturn uniqBy(\n [...builtInTools.sort(byName), ...filteredMcpTools.sort(byName)],\n 'name',\n)\n```\n\n組み込みツールと MCP ツールは別々にソート、混ぜてソートしない。理由は Claude Code の `claude_code_system_cache_policy` が最後の組み込みツールの後の特定位置にグローバルキャッシュブレークポイントを置く設計のためで、ソートを混ぜるとこの設計が壊れる。\n\n### 三、命名規則:`mcp__server__tool`\n\n`buildMcpToolName()`(`mcpStringUtils.ts:50-52`):\n\n```\nmcp____\n```\n\n`[a-zA-Z0-9_-]` 以外の全文字を `_` に置換(`normalization.ts:17-23`)。教学版の `normalize_mcp_name` も同じルールを使用。\n\n### 四、権限チェック\n\nClaude Code は MCP ツールに対して独立した権限システムを持つ。`checkPermissions()` は MCP ツールに対して組み込みツールとは異なるロジックを適用する:MCP ツールは独自の権限要件(readOnly、destructive 等)を宣言でき、Claude Code は宣言に基づいてユーザー確認が必要かを判断。教学版は description 内のテキストアノテーション `(readOnly)` / `(destructive)` のみで、権限インターセプトは行わない。\n\n### 五、設定ソースと優先度\n\nMCP サーバー設定は複数のソースから。Claude Code の優先度は低い順に:\n\n```\nclaude.ai コネクタ < プラグイン < ユーザー settings.json < 承認済みプロジェクト .mcp.json < ローカル settings.local.json\n```\n\n`claude.ai` コネクタは個別に取得、コンテンツ署名で重複排除し、最低優先度で統合(`config.ts:1267-1289`)。企業 `managed-mcp.json` が存在する場合、他の全設定を完全に除外。\n\n教学版は server 名を直接 `MOCK_SERVERS` 辞書に渡し、設定マージは行わない。\n\n### 六、Channel 通知:サーバーからの逆方向メッセージ\n\n教学版は Agent → MCP Server の一方向呼び出しのみ。Claude Code は逆方向通知もサポート(`channelNotification.ts`):\n\n1. Server が `capabilities.experimental['claude/channel']` を宣言\n2. Server が MCP 通知 `notifications/claude/channel` で Agent にメッセージを送信\n3. メッセージは `...` XML タグでラップ\n4. Agent は SleepTool で起床(1 秒以内)\n\nServer は権限リクエストも可能:`notifications/claude/channel/permission_request` → Agent が `notifications/claude/channel/permission` で応答。ユーザーは 5 文字の短い ID で確認/拒否。\n\n### 七、OAuth 認証フロー\n\nClaude Code の MCP 認証(`auth.ts`)は完全な OAuth 2.0 + PKCE フローをサポート:\n- 公開クライアント + PKCE で OAuth メタデータを発見(RFC 8414 / RFC 9728)\n- ローカルコールバックサーバーが認可コードを受信\n- トークンは `getSecureStorage()` で永続化(macOS Keychain / Linux 暗号化ファイル / Windows 資格情報マネージャー)\n- 有効期限 5 分前に自動リフレッシュ\n- クロスアプリケーションアクセス(XAA):ブラウザが id_token を取得 → RFC 8693 + RFC 7523 交換 → 繰り返しブラウザポップアップ不要\n\n### 八、接続ライフサイクルのエラーハンドリング\n\nClaude Code は MCP 接続にきめ細かいエラー分類とリトライを行う(`client.ts:1266-1402`):\n- 終局エラー(ECONNRESET、ETIMEDOUT、EPIPE 等):連続 3 回 → クローズ + 再接続\n- ツール呼び出し 401:トークン期限切れ → `McpAuthError` スロー → 再認証トリガー\n- ツール呼び出しタイムアウト:`Promise.race` タイムアウト(設定可能、デフォルト約 28 時間)\n- Stdio 切断:SIGINT → SIGTERM → SIGKILL の順でプロセスを kill\n\n### 教学版の簡略化\n\n- 6 種のトランスポート → 1 種(mock stdio):概念量を管理可能に\n- Channel 逆方向通知 → 省略:教学版 Agent は常にイニシエータ\n- OAuth フロー → 省略:教学版は server が認証不要と仮定\n- 多層設定優先度 → 省略:教学版は直接 server 名を渡す\n- 複雑なエラー分類 → 省略:教学版は try/except でフォールバック\n- MCP ツールは Lead のみ → サブ agent 継承を省略:コード構造を簡略化\n\n
\n\n\n" }, { "version": "s20", "locale": "en", "title": "s20: Comprehensive Agent — All Mechanisms, One Loop", - "content": "# s20: Comprehensive Agent — All Mechanisms, One Loop\n\ns01 → ... → s18 → s19 → `s20`\n\n> *\"Many mechanisms, one loop\"* — tools, permissions, memory, tasks, teams, and plugins all hang off the same `while True`.\n>\n> **Harness layer**: Comprehensive — put the previous 19 mechanisms back into one runnable system.\n\n---\n\n## Problem\n\nThe first 19 chapters add one mechanism at a time. That is the right way to learn, but a real agent does not run with only one mechanism enabled.\n\nA long-running coding agent needs all of these at once:\n\n- tool dispatch and permission boundaries\n- hook extension points\n- todo planning and task graphs\n- skills, memory, and runtime system prompt assembly\n- compaction and error recovery\n- background tasks and cron scheduling\n- teams, protocols, autonomous claiming\n- worktree isolation\n- MCP external tool integration\n\nThe hard part is not piling up features. The hard part is seeing where each mechanism belongs around the loop. S20 is the endpoint chapter: every component is placed back into one harness.\n\n---\n\n## Solution\n\n![System Architecture](/course-assets/s20_comprehensive/system-architecture.en.svg)\n\nS20 does not invent a new mechanism. It merges the teaching components from the earlier chapters into one complete harness:\n\n```text\nuser input\n → UserPromptSubmit hooks\n → cron/background notification injection\n → context compact\n → memory + skills + MCP state assemble the system prompt\n → LLM\n → has tool_use block?\n no → Stop hooks → return\n yes → PreToolUse hooks + permission\n → TOOL_HANDLERS / MCP handlers / background dispatch\n → PostToolUse hooks\n → tool_result / task_notification back to messages\n → next round\n```\n\nThe loop is still the same structure: call the model, check whether the response contains a `tool_use` block, execute tools, append results back to `messages`. CC source does not directly trust `stop_reason == \"tool_use\"`; the actual presence of a tool_use block is the continuation signal. What changed is that the harness around the loop is now complete.\n\n---\n\n## Where Each Component Sits\n\n| Position | Component | Role |\n|----------|-----------|------|\n| Around user input | `UserPromptSubmit` hooks | Log, inject, or audit user input |\n| Before LLM | cron queue | Inject scheduled prompts into `messages` |\n| Before LLM | background notifications | Inject completed background work as `` |\n| Before LLM | compaction pipeline | Budget large outputs, trim history, compact old tool results, summarize when needed |\n| Before LLM | memory / skills / MCP state | Assemble the system prompt so the model sees current capabilities and long-term context |\n| LLM call | error recovery | Retry 429/529, escalate `max_tokens`, compact on prompt-too-long |\n| Before tool execution | `PreToolUse` hooks + permission | Block dangerous commands, out-of-bounds writes, destructive MCP tools |\n| Tool dispatch | `assemble_tool_pool` | Assemble built-in tools and dynamic MCP tools |\n| During tool execution | background dispatch | Move slow bash work into a daemon thread and return a placeholder result |\n| After tool execution | `PostToolUse` hooks | Large-output warnings, logs, post-processing |\n| Back to loop | tool_result | One `tool_result` per `tool_use`, then the next model round |\n| No tool_use this round / on stop | `Stop` hooks | Stats, cleanup, audit |\n\n---\n\n## What code.py Contains\n\n### Tools and Dispatch\n\nThe built-in tool pool contains 27 tools:\n\n```text\nbash, read_file, write_file, edit_file, glob\ntodo_write, task, load_skill, compact\ncreate_task, list_tasks, get_task, claim_task, complete_task\nschedule_cron, list_crons, cancel_cron\nspawn_teammate, send_message, check_inbox\nrequest_shutdown, request_plan, review_plan\ncreate_worktree, remove_worktree, keep_worktree\nconnect_mcp\n```\n\n`assemble_tool_pool()` assembles these every round:\n\n```text\nBUILTIN_TOOLS + connected MCP tools\nBUILTIN_HANDLERS + mcp__server__tool handlers\n```\n\nAfter `connect_mcp(\"docs\")`, the next round exposes tools like `mcp__docs__search`.\n\n### Permissions and Hooks\n\nPermission is not hardcoded into the tool execution line. It is a `PreToolUse` hook:\n\n```python\nblocked = trigger_hooks(\"PreToolUse\", block)\nif blocked:\n results.append(tool_result(block.id, blocked))\n continue\n```\n\nThat means permission, logging, and audit logic all attach to the same hook point. After execution, `PostToolUse` hooks run.\n\n### Planning and Tasks\n\nS20 keeps two planning layers:\n\n- `todo_write`: lightweight plan for the current session, kept in memory\n- task graph: cross-session, dependency-aware, claimable task files under `.tasks/task_*.json`\n\nThe first keeps a single agent from drifting. The second supports team coordination.\n\n### Subagents and Teams\n\nS20 has two kinds of delegation:\n\n- `task`: one-shot subagent. It uses an isolated `messages[]`, discards intermediate context, and returns only a final summary.\n- `spawn_teammate`: persistent teammate thread. It communicates through `MessageBus`, polls the task board while idle, and can claim work autonomously.\n\nOne-shot subagents solve context isolation. Persistent teammates solve long-running parallel collaboration.\n\n### Memory, Skills, and Prompt\n\n`assemble_system_prompt(context)` assembles each round from:\n\n- identity and tool guidance\n- workspace\n- skills catalog\n- `.memory/MEMORY.md`\n- connected MCP servers\n\nSkills only put their catalog into the system prompt. Full content is loaded on demand through `load_skill(name)`.\n\n### Compaction and Recovery\n\nBefore the LLM call, S20 runs the compaction pipeline:\n\n```text\ntool_result_budget → snip_compact → micro_compact → compact_history\n```\n\nThe model call is wrapped with recovery:\n\n- 429: exponential backoff retry\n- 529: exponential backoff, optionally switch to fallback model after repeated failures\n- `max_tokens`: raise max tokens, then request continuation\n- prompt too long: reactive compact and retry\n\n### Background and Cron\n\nSlow bash work does not block the main loop:\n\n```text\nshould_run_background → start_background_task → placeholder tool_result\nbackground done → task_notification → next round injects messages\n```\n\nThe cron scheduler runs as a daemon thread and checks once per second. The CLI watches `cron_queue`; when a job fires, it injects `[Scheduled] ...` and runs one agent turn automatically.\n\n### Worktree and MCP\n\nWorktree isolation owns directories:\n\n- `create_worktree(name, task_id)` creates an isolated branch and directory\n- the task `worktree` field binds a task to that directory\n- when a teammate claims a task with a worktree, its bash/read/write tools run in that directory\n\nMCP owns external capability:\n\n- `connect_mcp(name)` connects a mock server\n- `assemble_tool_pool()` assembles MCP tools into the tool pool\n- tool names use `mcp__server__tool`\n\n---\n\n## Changes from s19\n\n| Component | s19 | s20 |\n|-----------|-----|-----|\n| tool pool | built-in + MCP | built-in + MCP, with s01-s18 tools restored |\n| permission | omitted in teaching body | runs inside `PreToolUse` hook |\n| hooks | omitted | UserPromptSubmit / PreToolUse / PostToolUse / Stop |\n| todo | omitted | `todo_write` + reminder |\n| skill | omitted | catalog in system prompt + `load_skill` |\n| compact | omitted | pre-LLM compaction + `compact` tool + reactive compact |\n| error recovery | simple try/except | retry / max_tokens / prompt too long |\n| background | omitted | slow-operation thread + task notification |\n| cron | omitted | daemon scheduler + durable jobs |\n| multi-agent | kept | kept; teammates use basic tools in isolated directories |\n| worktree | kept | kept |\n| MCP | new | kept as part of the final tool pool |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s20_comprehensive/code.py\n```\n\nTry:\n\n1. `Create a todo list for inspecting this repo, then list Python files`\n2. `Connect to the docs MCP server and search for agent loop`\n3. `Create two tasks, create worktrees for them, then spawn alice and bob. Ask them to submit plans before claiming tasks.`\n4. `remind me of the meeting in 3 minutes.`\n5. `Run npm install in the background and continue reading README.md`\n\nWatch for:\n\n- whether each tool call passes through hooks/permission\n- whether MCP tools appear on the next round after `connect_mcp`\n- whether slow operations return a background placeholder\n- whether cron automatically reminds you when the time arrives\n- whether teammates submit plans and pause before approval\n- whether teammates can claim tasks after plan approval\n- whether teammates switch to the bound worktree directory\n\n---\n\n## The End Is the Beginning\n\nFrom s01 to s20, the code gets more capable, but the core remains unchanged:\n\n```python\nwhile True:\n response = LLM(messages, tools)\n if not has_tool_use(response.content):\n return\n results = execute_tools(response.content)\n messages.append(tool_results)\n```\n\nClaude Code's complexity is not \"another agent brain.\" It is the complexity of a mature harness. The model decides and chooses actions; the harness organizes environment, tools, permissions, memory, teams, and external capabilities.\n\nThis is the endpoint of the course: many mechanisms, one loop.\n" + "content": "# s20: Comprehensive Agent — All Mechanisms, One Loop\n\ns01 → ... → s18 → s19 → `s20`\n\n> *\"Many mechanisms, one loop\"* — tools, permissions, memory, tasks, teams, and plugins all hang off the same `while True`.\n>\n> **Harness layer**: Comprehensive — put the previous 19 mechanisms back into one runnable system.\n\n---\n\n## Problem\n\nThe first 19 chapters add one mechanism at a time. That is the right way to learn, but a real agent does not run with only one mechanism enabled.\n\nA long-running coding agent needs all of these at once:\n\n- tool dispatch and permission boundaries\n- hook extension points\n- todo planning and task graphs\n- skills, memory, and runtime system prompt assembly\n- compaction and error recovery\n- background tasks and cron scheduling\n- teams, protocols, autonomous claiming\n- worktree isolation\n- MCP external tool integration\n\nThe hard part is not piling up features. The hard part is seeing where each mechanism belongs around the loop. S20 is the endpoint chapter: every component is placed back into one harness.\n\n---\n\n## Solution\n\n![System Architecture](/course-assets/s20_comprehensive/system-architecture.svg)\n\nS20 does not invent a new mechanism. It merges the teaching components from the earlier chapters into one complete harness:\n\n```text\nuser input\n → UserPromptSubmit hooks\n → cron/background notification injection\n → context compact\n → memory + skills + MCP state assemble the system prompt\n → LLM\n → has tool_use block?\n no → Stop hooks → return\n yes → PreToolUse hooks + permission\n → TOOL_HANDLERS / MCP handlers / background dispatch\n → PostToolUse hooks\n → tool_result / task_notification back to messages\n → next round\n```\n\nThe loop is still the same structure: call the model, check whether the response contains a `tool_use` block, execute tools, append results back to `messages`. Claude Code source does not directly trust `stop_reason == \"tool_use\"`; the actual presence of a tool_use block is the continuation signal. What changed is that the harness around the loop is now complete.\n\n---\n\n## Where Each Component Sits\n\n| Position | Component | Role |\n|----------|-----------|------|\n| Around user input | `UserPromptSubmit` hooks | Log, inject, or audit user input |\n| Before LLM | cron queue | Inject scheduled prompts into `messages` |\n| Before LLM | background notifications | Inject completed background work as `` |\n| Before LLM | compaction pipeline | Budget large outputs, trim history, compact old tool results, summarize when needed |\n| Before LLM | memory / skills / MCP state | Assemble the system prompt so the model sees current capabilities and long-term context |\n| LLM call | error recovery | Retry 429/529, escalate `max_tokens`, compact on prompt-too-long |\n| Before tool execution | `PreToolUse` hooks + permission | Block dangerous commands, out-of-bounds writes, destructive MCP tools |\n| Tool dispatch | `assemble_tool_pool` | Assemble built-in tools and dynamic MCP tools |\n| During tool execution | background dispatch | Move slow bash work into a daemon thread and return a placeholder result |\n| After tool execution | `PostToolUse` hooks | Large-output warnings, logs, post-processing |\n| Back to loop | tool_result | One `tool_result` per `tool_use`, then the next model round |\n| No tool_use this round / on stop | `Stop` hooks | Stats, cleanup, audit |\n\n---\n\n## What code.py Contains\n\n### Tools and Dispatch\n\nThe built-in tool pool contains 27 tools:\n\n```text\nbash, read_file, write_file, edit_file, glob\ntodo_write, task, load_skill, compact\ncreate_task, list_tasks, get_task, claim_task, complete_task\nschedule_cron, list_crons, cancel_cron\nspawn_teammate, send_message, check_inbox\nrequest_shutdown, request_plan, review_plan\ncreate_worktree, remove_worktree, keep_worktree\nconnect_mcp\n```\n\n`assemble_tool_pool()` assembles these every round:\n\n```text\nBUILTIN_TOOLS + connected MCP tools\nBUILTIN_HANDLERS + mcp__server__tool handlers\n```\n\nAfter `connect_mcp(\"docs\")`, the next round exposes tools like `mcp__docs__search`.\n\n### Permissions and Hooks\n\nPermission is not hardcoded into the tool execution line. It is a `PreToolUse` hook:\n\n```python\nblocked = trigger_hooks(\"PreToolUse\", block)\nif blocked:\n results.append(tool_result(block.id, blocked))\n continue\n```\n\nThat means permission, logging, and audit logic all attach to the same hook point. After execution, `PostToolUse` hooks run.\n\n### Planning and Tasks\n\nS20 keeps two planning layers:\n\n- `todo_write`: lightweight plan for the current session, kept in memory\n- task graph: cross-session, dependency-aware, claimable task files under `.tasks/task_*.json`\n\nThe first keeps a single agent from drifting. The second supports team coordination.\n\n### Subagents and Teams\n\nS20 has two kinds of delegation:\n\n- `task`: one-shot subagent. It uses an isolated `messages[]`, discards intermediate context, and returns only a final summary.\n- `spawn_teammate`: persistent teammate thread. It communicates through `MessageBus`, polls the task board while idle, and can claim work autonomously.\n\nOne-shot subagents solve context isolation. Persistent teammates solve long-running parallel collaboration.\n\n### Memory, Skills, and Prompt\n\n`assemble_system_prompt(context)` assembles each round from:\n\n- identity and tool guidance\n- workspace\n- skills catalog\n- `.memory/MEMORY.md`\n- connected MCP servers\n\nSkills only put their catalog into the system prompt. Full content is loaded on demand through `load_skill(name)`.\n\n### Compaction and Recovery\n\nBefore the LLM call, S20 runs the compaction pipeline:\n\n```text\ntool_result_budget → snip_compact → micro_compact → compact_history\n```\n\nThe model call is wrapped with recovery:\n\n- 429: exponential backoff retry\n- 529: exponential backoff, optionally switch to fallback model after repeated failures\n- `max_tokens`: raise max tokens, then request continuation\n- prompt too long: reactive compact and retry\n\n### Background and Cron\n\nSlow bash work does not block the main loop:\n\n```text\nshould_run_background → start_background_task → placeholder tool_result\nbackground done → task_notification → next round injects messages\n```\n\nThe cron scheduler runs as a daemon thread and checks once per second. The CLI watches `cron_queue`; when a job fires, it injects `[Scheduled] ...` and runs one agent turn automatically.\n\n### Worktree and MCP\n\nWorktree isolation owns directories:\n\n- `create_worktree(name, task_id)` creates an isolated branch and directory\n- the task `worktree` field binds a task to that directory\n- when a teammate claims a task with a worktree, its bash/read/write tools run in that directory\n\nMCP owns external capability:\n\n- `connect_mcp(name)` connects a mock server\n- `assemble_tool_pool()` assembles MCP tools into the tool pool\n- tool names use `mcp__server__tool`\n\n---\n\n## Changes from s19\n\n| Component | s19 | s20 |\n|-----------|-----|-----|\n| tool pool | built-in + MCP | built-in + MCP, with s01-s18 tools restored |\n| permission | omitted in teaching body | runs inside `PreToolUse` hook |\n| hooks | omitted | UserPromptSubmit / PreToolUse / PostToolUse / Stop |\n| todo | omitted | `todo_write` + reminder |\n| skill | omitted | catalog in system prompt + `load_skill` |\n| compact | omitted | pre-LLM compaction + `compact` tool + reactive compact |\n| error recovery | simple try/except | retry / max_tokens / prompt too long |\n| background | omitted | slow-operation thread + task notification |\n| cron | omitted | daemon scheduler + durable jobs |\n| multi-agent | kept | kept; teammates use basic tools in isolated directories |\n| worktree | kept | kept |\n| MCP | new | kept as part of the final tool pool |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s20_comprehensive/code.py\n```\n\nTry:\n\n1. `Create a todo list for inspecting this repo, then list Python files`\n2. `Connect to the docs MCP server and search for agent loop`\n3. `Create two tasks, create worktrees for them, then spawn alice and bob. Ask them to submit plans before claiming tasks.`\n4. `remind me of the meeting in 3 minutes.`\n5. `Run npm install in the background and continue reading README.md`\n\nWatch for:\n\n- whether each tool call passes through hooks/permission\n- whether MCP tools appear on the next round after `connect_mcp`\n- whether slow operations return a background placeholder\n- whether cron automatically reminds you when the time arrives\n- whether teammates submit plans and pause before approval\n- whether teammates can claim tasks after plan approval\n- whether teammates switch to the bound worktree directory\n\n---\n\n## The End Is the Beginning\n\nFrom s01 to s20, the code gets more capable, but the core remains unchanged:\n\n```python\nwhile True:\n response = LLM(messages, tools)\n if not has_tool_use(response.content):\n return\n results = execute_tools(response.content)\n messages.append(tool_results)\n```\n\nClaude Code's complexity is not \"another agent brain.\" It is the complexity of a mature harness. The model decides and chooses actions; the harness organizes environment, tools, permissions, memory, teams, and external capabilities.\n\nThis is where the s01–s20 main line converges: every component in the same loop.\n\nAnd that loop is always single-step and model-driven — each round, the model picks one tool. When the shape of an orchestration is already fixed (parallel fan-out, per-item pipeline, resume from a break), instead of having the model drive it round by round, write it as a deterministic, resumable script.\n\nWhat's next: [s21 Workflow Runtime](/en/s21) — the model owns each step, the script owns the orchestration.\n" }, { "version": "s20", "locale": "zh", "title": "s20: Comprehensive Agent — 全部机制,归到一个循环", - "content": "# s20: Comprehensive Agent — 全部机制,归到一个循环\n\ns01 → ... → s18 → s19 → `s20`\n\n> *\"机制很多,循环一个\"* — 工具、权限、记忆、任务、团队、插件都挂在同一个 while True 上。\n>\n> **Harness 层**: 综合 — 把前 19 章的机制放回同一个可运行系统。\n\n---\n\n## 问题\n\n前 19 章每章只加一个机制。这样适合学习,但真实 Agent 不会只带一个机制运行。\n\n一个能长期工作的 coding agent 需要同时拥有:\n\n- 工具分发和权限边界\n- hooks 扩展点\n- todo 计划和任务图\n- 技能、记忆、系统 prompt 组装\n- 压缩和错误恢复\n- 后台任务和 cron 调度\n- 团队、协议、自治认领\n- worktree 隔离\n- MCP 外部工具接入\n\n难点不是把功能堆起来,而是看清楚它们都挂在循环的哪个位置。S20 就是终点章:把所有组件归位。\n\n---\n\n## 解决方案\n\n![System Architecture](/course-assets/s20_comprehensive/system-architecture.svg)\n\nS20 不是再发明一个新机制,而是把前面的教学组件合成一个完整 harness:\n\n```text\n用户输入\n → UserPromptSubmit hooks\n → cron/background 通知注入\n → context compact\n → memory + skills + MCP 状态组装 system prompt\n → LLM\n → has tool_use block?\n 否 → Stop hooks → 返回\n 是 → PreToolUse hooks + permission\n → TOOL_HANDLERS / MCP handlers / background dispatch\n → PostToolUse hooks\n → tool_result / task_notification 回 messages\n → 下一轮\n```\n\n循环本身仍然是同一个结构:调用模型,检查响应里是否出现 `tool_use` block,执行工具,把结果追加回 `messages`。CC 源码里也不直接信任 `stop_reason == \"tool_use\"`,而是以实际出现的 tool_use block 作为是否继续工具轮的信号。变化的是循环周围的 harness 变完整了。\n\n---\n\n## 组件在循环中的位置\n\n| 位置 | 组件 | 作用 |\n|------|------|------|\n| 用户输入前后 | `UserPromptSubmit` hooks | 记录、注入、审计用户输入 |\n| LLM 前 | cron queue | 把定时触发的 prompt 注入 `messages` |\n| LLM 前 | background notifications | 后台任务完成后以 `` 注入 |\n| LLM 前 | compaction pipeline | 先压大输出,再裁历史,再压旧 tool_result,必要时摘要 |\n| LLM 前 | memory / skills / MCP state | 组装 system prompt,让模型看到当前能力和长期上下文 |\n| LLM 调用 | error recovery | 429/529 重试,`max_tokens` 升级,prompt too long 触发 reactive compact |\n| 工具执行前 | `PreToolUse` hooks + permission | 拦截危险命令、写越界、破坏性 MCP 工具 |\n| 工具分发 | `assemble_tool_pool` | 组装内置工具和 MCP 动态工具 |\n| 工具执行时 | background dispatch | 慢 bash 操作放 daemon thread,主循环先返回占位结果 |\n| 工具执行后 | `PostToolUse` hooks | 大输出告警、日志等后处理 |\n| 返回循环 | tool_result | 每个 `tool_use` 对应一个 `tool_result`,再回到下一轮 |\n| 本轮没有 tool_use / 停止时 | `Stop` hooks | 统计、清理、审计 |\n\n---\n\n## code.py 包含什么\n\n### 工具与分发\n\n内置工具池包含 27 个工具:\n\n```text\nbash, read_file, write_file, edit_file, glob\ntodo_write, task, load_skill, compact\ncreate_task, list_tasks, get_task, claim_task, complete_task\nschedule_cron, list_crons, cancel_cron\nspawn_teammate, send_message, check_inbox\nrequest_shutdown, request_plan, review_plan\ncreate_worktree, remove_worktree, keep_worktree\nconnect_mcp\n```\n\n`assemble_tool_pool()` 每轮组装:\n\n```text\nBUILTIN_TOOLS + connected MCP tools\nBUILTIN_HANDLERS + mcp__server__tool handlers\n```\n\n所以 `connect_mcp(\"docs\")` 后,下一轮工具池里会出现 `mcp__docs__search`。\n\n### 权限和 hooks\n\n权限不写死在工具执行行里,而是作为 `PreToolUse` hook:\n\n```python\nblocked = trigger_hooks(\"PreToolUse\", block)\nif blocked:\n results.append(tool_result(block.id, blocked))\n continue\n```\n\n这样 permission、log、审计都可以挂在同一个 hook 点上。执行后再触发 `PostToolUse`。\n\n### 计划与任务\n\nS20 同时保留两层计划:\n\n- `todo_write`:当前会话内的轻量计划,保存在内存中\n- task graph:跨会话、可依赖、可认领的任务文件,写入 `.tasks/task_*.json`\n\n前者帮助单个 Agent 不漂移;后者支撑团队协作。\n\n### 子 agent 与团队\n\nS20 有两种 delegation:\n\n- `task`:一次性 subagent。独立 `messages[]`,中间过程丢弃,只返回最终摘要。\n- `spawn_teammate`:持久队友线程。通过 MessageBus 收发消息,能 idle 轮询任务板并自动认领。\n\n一次性 subagent 解决“上下文隔离”;持久队友解决“长期并行协作”。\n\n### 记忆、技能和 prompt\n\n`assemble_system_prompt(context)` 每轮组装:\n\n- 身份和工具说明\n- workspace\n- skills catalog\n- `.memory/MEMORY.md`\n- 已连接 MCP server\n\n技能只在 system prompt 里放目录。完整内容通过 `load_skill(name)` 按需加载。\n\n### 压缩和恢复\n\nLLM 前先跑压缩管线:\n\n```text\ntool_result_budget → snip_compact → micro_compact → compact_history\n```\n\n调用模型时再包一层恢复:\n\n- 429:指数退避重试\n- 529:指数退避,连续失败可切 fallback model\n- `max_tokens`:先提高 max_tokens,再要求 continuation\n- prompt too long:reactive compact 后重试\n\n### 后台和 cron\n\n慢 bash 操作不会阻塞主循环:\n\n```text\nshould_run_background → start_background_task → placeholder tool_result\n后台完成 → task_notification → 下一轮注入 messages\n```\n\ncron 调度器独立 daemon thread 每秒检查一次。CLI 会监听 `cron_queue`,命中后主动把 `[Scheduled] ...` 注入并运行一轮 Agent。\n\n### worktree 与 MCP\n\nworktree 负责隔离目录:\n\n- `create_worktree(name, task_id)` 创建独立分支和目录\n- task 的 `worktree` 字段绑定目录\n- 队友 claim 到带 worktree 的 task 后,bash/read/write 自动在对应目录下执行\n\nMCP 负责外部能力:\n\n- `connect_mcp(name)` 连接 mock server\n- `assemble_tool_pool()` 把 MCP 工具组装进工具池\n- 工具名统一为 `mcp__server__tool`\n\n---\n\n## 相对 s19 的变化\n\n| 组件 | s19 | s20 |\n|------|-----|-----|\n| 工具池 | 内置 + MCP | 内置 + MCP,补齐 s01-s18 的工具 |\n| 权限 | 教学主体省略 | `PreToolUse` hook 中执行 |\n| hooks | 省略 | UserPromptSubmit / PreToolUse / PostToolUse / Stop |\n| todo | 省略 | `todo_write` + reminder |\n| skill | 省略 | catalog in system prompt + `load_skill` |\n| compact | 省略 | LLM 前压缩 + `compact` 工具 + reactive compact |\n| error recovery | 简化 try/except | retry / max_tokens / prompt too long |\n| background | 省略 | 慢操作后台线程 + task notification |\n| cron | 省略 | daemon scheduler + durable jobs |\n| multi-agent | 保留 | 保留;队友使用隔离目录下的基础工具 |\n| worktree | 保留 | 保留 |\n| MCP | 新增 | 保留,作为最终工具池的一部分 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s20_comprehensive/code.py\n```\n\n可以试:\n\n1. `Create a todo list for inspecting this repo, then list Python files`\n2. `Connect to the docs MCP server and search for agent loop`\n3. `Create two tasks, create worktrees for them, then spawn alice and bob. Ask them to submit plans before claiming tasks.`\n4. `remind me of the meeting in 3 minutes.`\n5. `Run npm install in the background and continue reading README.md`\n\n观察重点:\n\n- 工具调用前是否经过 hooks/permission\n- `connect_mcp` 后下一轮是否出现 MCP 工具\n- 慢操作是否返回 background placeholder\n- 到点是不是自动提醒开会\n- 队友是否提交 plan,并在 approval 前暂停\n- plan 批准后,队友是否能认领任务\n- worktree 绑定后,队友是否切到对应目录\n\n---\n\n## 结束亦是开始\n\n从 s01 到 s20,代码表面越来越复杂,但核心始终没变:\n\n```python\nwhile True:\n response = LLM(messages, tools)\n if not has_tool_use(response.content):\n return\n results = execute_tools(response.content)\n messages.append(tool_results)\n```\n\nClaude Code 的复杂性不是“另一个 agent 大脑”,而是一个成熟 harness 的复杂性。模型负责判断和行动选择;harness 负责把环境、工具、权限、记忆、团队和外部能力组织好。\n\n这就是全书的终点:机制很多,循环一个。\n" + "content": "# s20: Comprehensive Agent — 全部机制,归到一个循环\n\ns01 → ... → s18 → s19 → `s20`\n\n> *\"机制很多,循环一个\"* — 工具、权限、记忆、任务、团队、插件都挂在同一个 while True 上。\n>\n> **Harness 层**: 综合 — 把前 19 章的机制放回同一个可运行系统。\n\n---\n\n## 问题\n\n前 19 章每章只加一个机制。这样适合学习,但真实 Agent 不会只带一个机制运行。\n\n一个能长期工作的 coding agent 需要同时拥有:\n\n- 工具分发和权限边界\n- hooks 扩展点\n- todo 计划和任务图\n- 技能、记忆、系统 prompt 组装\n- 压缩和错误恢复\n- 后台任务和 cron 调度\n- 团队、协议、自治认领\n- worktree 隔离\n- MCP 外部工具接入\n\n难点不是把功能堆起来,而是看清楚它们都挂在循环的哪个位置。S20 就是终点章:把所有组件归位。\n\n---\n\n## 解决方案\n\n![System Architecture](/course-assets/s20_comprehensive/system-architecture.svg)\n\nS20 不是再发明一个新机制,而是把前面的教学组件合成一个完整 harness:\n\n```text\n用户输入\n → UserPromptSubmit hooks\n → cron/background 通知注入\n → context compact\n → memory + skills + MCP 状态组装 system prompt\n → LLM\n → has tool_use block?\n 否 → Stop hooks → 返回\n 是 → PreToolUse hooks + permission\n → TOOL_HANDLERS / MCP handlers / background dispatch\n → PostToolUse hooks\n → tool_result / task_notification 回 messages\n → 下一轮\n```\n\n循环本身仍然是同一个结构:调用模型,检查响应里是否出现 `tool_use` block,执行工具,把结果追加回 `messages`。Claude Code 源码里也不直接信任 `stop_reason == \"tool_use\"`,而是以实际出现的 tool_use block 作为是否继续工具轮的信号。变化的是循环周围的 harness 变完整了。\n\n---\n\n## 组件在循环中的位置\n\n| 位置 | 组件 | 作用 |\n|------|------|------|\n| 用户输入前后 | `UserPromptSubmit` hooks | 记录、注入、审计用户输入 |\n| LLM 前 | cron queue | 把定时触发的 prompt 注入 `messages` |\n| LLM 前 | background notifications | 后台任务完成后以 `` 注入 |\n| LLM 前 | compaction pipeline | 先压大输出,再裁历史,再压旧 tool_result,必要时摘要 |\n| LLM 前 | memory / skills / MCP state | 组装 system prompt,让模型看到当前能力和长期上下文 |\n| LLM 调用 | error recovery | 429/529 重试,`max_tokens` 升级,prompt too long 触发 reactive compact |\n| 工具执行前 | `PreToolUse` hooks + permission | 拦截危险命令、写越界、破坏性 MCP 工具 |\n| 工具分发 | `assemble_tool_pool` | 组装内置工具和 MCP 动态工具 |\n| 工具执行时 | background dispatch | 慢 bash 操作放 daemon thread,主循环先返回占位结果 |\n| 工具执行后 | `PostToolUse` hooks | 大输出告警、日志等后处理 |\n| 返回循环 | tool_result | 每个 `tool_use` 对应一个 `tool_result`,再回到下一轮 |\n| 本轮没有 tool_use / 停止时 | `Stop` hooks | 统计、清理、审计 |\n\n---\n\n## code.py 包含什么\n\n### 工具与分发\n\n内置工具池包含 27 个工具:\n\n```text\nbash, read_file, write_file, edit_file, glob\ntodo_write, task, load_skill, compact\ncreate_task, list_tasks, get_task, claim_task, complete_task\nschedule_cron, list_crons, cancel_cron\nspawn_teammate, send_message, check_inbox\nrequest_shutdown, request_plan, review_plan\ncreate_worktree, remove_worktree, keep_worktree\nconnect_mcp\n```\n\n`assemble_tool_pool()` 每轮组装:\n\n```text\nBUILTIN_TOOLS + connected MCP tools\nBUILTIN_HANDLERS + mcp__server__tool handlers\n```\n\n所以 `connect_mcp(\"docs\")` 后,下一轮工具池里会出现 `mcp__docs__search`。\n\n### 权限和 hooks\n\n权限不写死在工具执行行里,而是作为 `PreToolUse` hook:\n\n```python\nblocked = trigger_hooks(\"PreToolUse\", block)\nif blocked:\n results.append(tool_result(block.id, blocked))\n continue\n```\n\n这样 permission、log、审计都可以挂在同一个 hook 点上。执行后再触发 `PostToolUse`。\n\n### 计划与任务\n\nS20 同时保留两层计划:\n\n- `todo_write`:当前会话内的轻量计划,保存在内存中\n- task graph:跨会话、可依赖、可认领的任务文件,写入 `.tasks/task_*.json`\n\n前者帮助单个 Agent 不漂移;后者支撑团队协作。\n\n### 子 agent 与团队\n\nS20 有两种 delegation:\n\n- `task`:一次性 subagent。独立 `messages[]`,中间过程丢弃,只返回最终摘要。\n- `spawn_teammate`:持久队友线程。通过 MessageBus 收发消息,能 idle 轮询任务板并自动认领。\n\n一次性 subagent 解决“上下文隔离”;持久队友解决“长期并行协作”。\n\n### 记忆、技能和 prompt\n\n`assemble_system_prompt(context)` 每轮组装:\n\n- 身份和工具说明\n- workspace\n- skills catalog\n- `.memory/MEMORY.md`\n- 已连接 MCP server\n\n技能只在 system prompt 里放目录。完整内容通过 `load_skill(name)` 按需加载。\n\n### 压缩和恢复\n\nLLM 前先跑压缩管线:\n\n```text\ntool_result_budget → snip_compact → micro_compact → compact_history\n```\n\n调用模型时再包一层恢复:\n\n- 429:指数退避重试\n- 529:指数退避,连续失败可切 fallback model\n- `max_tokens`:先提高 max_tokens,再要求 continuation\n- prompt too long:reactive compact 后重试\n\n### 后台和 cron\n\n慢 bash 操作不会阻塞主循环:\n\n```text\nshould_run_background → start_background_task → placeholder tool_result\n后台完成 → task_notification → 下一轮注入 messages\n```\n\ncron 调度器独立 daemon thread 每秒检查一次。CLI 会监听 `cron_queue`,命中后主动把 `[Scheduled] ...` 注入并运行一轮 Agent。\n\n### worktree 与 MCP\n\nworktree 负责隔离目录:\n\n- `create_worktree(name, task_id)` 创建独立分支和目录\n- task 的 `worktree` 字段绑定目录\n- 队友 claim 到带 worktree 的 task 后,bash/read/write 自动在对应目录下执行\n\nMCP 负责外部能力:\n\n- `connect_mcp(name)` 连接 mock server\n- `assemble_tool_pool()` 把 MCP 工具组装进工具池\n- 工具名统一为 `mcp__server__tool`\n\n---\n\n## 相对 s19 的变化\n\n| 组件 | s19 | s20 |\n|------|-----|-----|\n| 工具池 | 内置 + MCP | 内置 + MCP,补齐 s01-s18 的工具 |\n| 权限 | 教学主体省略 | `PreToolUse` hook 中执行 |\n| hooks | 省略 | UserPromptSubmit / PreToolUse / PostToolUse / Stop |\n| todo | 省略 | `todo_write` + reminder |\n| skill | 省略 | catalog in system prompt + `load_skill` |\n| compact | 省略 | LLM 前压缩 + `compact` 工具 + reactive compact |\n| error recovery | 简化 try/except | retry / max_tokens / prompt too long |\n| background | 省略 | 慢操作后台线程 + task notification |\n| cron | 省略 | daemon scheduler + durable jobs |\n| multi-agent | 保留 | 保留;队友使用隔离目录下的基础工具 |\n| worktree | 保留 | 保留 |\n| MCP | 新增 | 保留,作为最终工具池的一部分 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s20_comprehensive/code.py\n```\n\n可以试:\n\n1. `Create a todo list for inspecting this repo, then list Python files`\n2. `Connect to the docs MCP server and search for agent loop`\n3. `Create two tasks, create worktrees for them, then spawn alice and bob. Ask them to submit plans before claiming tasks.`\n4. `remind me of the meeting in 3 minutes.`\n5. `Run npm install in the background and continue reading README.md`\n\n观察重点:\n\n- 工具调用前是否经过 hooks/permission\n- `connect_mcp` 后下一轮是否出现 MCP 工具\n- 慢操作是否返回 background placeholder\n- 到点是不是自动提醒开会\n- 队友是否提交 plan,并在 approval 前暂停\n- plan 批准后,队友是否能认领任务\n- worktree 绑定后,队友是否切到对应目录\n\n---\n\n## 结束亦是开始\n\n从 s01 到 s20,代码表面越来越复杂,但核心始终没变:\n\n```python\nwhile True:\n response = LLM(messages, tools)\n if not has_tool_use(response.content):\n return\n results = execute_tools(response.content)\n messages.append(tool_results)\n```\n\nClaude Code 的复杂性不是“另一个 agent 大脑”,而是一个成熟 harness 的复杂性。模型负责判断和行动选择;harness 负责把环境、工具、权限、记忆、团队和外部能力组织好。\n\n这是 s01–s20 主线的收束:所有组件都集合在同一个循环里。\n\n而这个循环始终是单步、模型驱动的——每一轮,模型挑一个工具。当编排的形状已经固定(并行扇出、逐项流水、断点续跑),与其让模型一轮轮驱动,不如把它写成一段确定性、可恢复的脚本。\n\n接下来:[s21 Workflow Runtime](/zh/s21) — 模型决定单步,脚本决定编排。\n" }, { "version": "s20", "locale": "ja", "title": "s20: Comprehensive Agent — すべての仕組みを 1 つのループへ", - "content": "# s20: Comprehensive Agent — すべての仕組みを 1 つのループへ\n\ns01 → ... → s18 → s19 → `s20`\n\n> *\"仕組みは多い、ループは 1 つ\"* — tools、permissions、memory、tasks、teams、plugins はすべて同じ `while True` に接続される。\n>\n> **Harness レイヤー**: 総合 — 前 19 章の仕組みを 1 つの実行可能なシステムへ戻す。\n\n---\n\n## 問題\n\n前 19 章では、各章が 1 つの仕組みだけを追加した。学習にはその形が適している。しかし実際の agent は、1 つの仕組みだけで動くわけではない。\n\n長時間動く coding agent には、同時に次のものが必要になる:\n\n- tool dispatch と permission boundary\n- hook extension point\n- todo plan と task graph\n- skill、memory、runtime system prompt assembly\n- compaction と error recovery\n- background task と cron scheduling\n- team、protocol、autonomous claiming\n- worktree isolation\n- MCP external tool integration\n\n難しいのは機能を積み上げることではない。それぞれの仕組みが loop のどこに接続されるかを見抜くことだ。S20 は終点章であり、すべての component を 1 つの harness に戻す。\n\n---\n\n## 解決策\n\n![System Architecture](/course-assets/s20_comprehensive/system-architecture.ja.svg)\n\nS20 は新しい単独 mechanism を発明しない。前章までの teaching component を 1 つの完全な harness に統合する:\n\n```text\nuser input\n → UserPromptSubmit hooks\n → cron/background notification injection\n → context compact\n → memory + skills + MCP state で system prompt を組み立てる\n → LLM\n → has tool_use block?\n no → Stop hooks → return\n yes → PreToolUse hooks + permission\n → TOOL_HANDLERS / MCP handlers / background dispatch\n → PostToolUse hooks\n → tool_result / task_notification を messages へ戻す\n → next round\n```\n\nloop 自体は同じ構造のままだ。model を呼び、response に `tool_use` block があるかを見て、tool を実行し、結果を `messages` に戻す。CC source でも `stop_reason == \"tool_use\"` を直接信頼せず、実際に tool_use block が出たかを continuation signal として扱う。変わったのは、loop の周囲の harness が完成形になったことだけ。\n\n---\n\n## 各 Component の位置\n\n| 位置 | Component | 役割 |\n|------|-----------|------|\n| user input 周辺 | `UserPromptSubmit` hooks | user input の記録、注入、監査 |\n| LLM 前 | cron queue | scheduled prompt を `messages` へ注入 |\n| LLM 前 | background notifications | 完了した background work を `` として注入 |\n| LLM 前 | compaction pipeline | 大きな出力を予算化し、履歴を切り、古い tool_result を圧縮し、必要なら要約 |\n| LLM 前 | memory / skills / MCP state | current capabilities と long-term context を system prompt に組み込む |\n| LLM call | error recovery | 429/529 retry、`max_tokens` escalation、prompt-too-long compact |\n| tool 実行前 | `PreToolUse` hooks + permission | 危険な command、範囲外 write、destructive MCP tool を止める |\n| tool dispatch | `assemble_tool_pool` | built-in tools と dynamic MCP tools を組み立てる |\n| tool 実行中 | background dispatch | 遅い bash work を daemon thread に逃がし、placeholder result を返す |\n| tool 実行後 | `PostToolUse` hooks | large-output warning、log、後処理 |\n| loop へ戻る | tool_result | 1 つの `tool_use` に 1 つの `tool_result`、そして次の model round |\n| tool_use がない round / stop 時 | `Stop` hooks | 統計、cleanup、audit |\n\n---\n\n## code.py に含まれるもの\n\n### Tools と Dispatch\n\nbuilt-in tool pool には 27 個の tool がある:\n\n```text\nbash, read_file, write_file, edit_file, glob\ntodo_write, task, load_skill, compact\ncreate_task, list_tasks, get_task, claim_task, complete_task\nschedule_cron, list_crons, cancel_cron\nspawn_teammate, send_message, check_inbox\nrequest_shutdown, request_plan, review_plan\ncreate_worktree, remove_worktree, keep_worktree\nconnect_mcp\n```\n\n`assemble_tool_pool()` は毎 round で次を組み立てる:\n\n```text\nBUILTIN_TOOLS + connected MCP tools\nBUILTIN_HANDLERS + mcp__server__tool handlers\n```\n\n`connect_mcp(\"docs\")` のあと、次の round では `mcp__docs__search` のような tool が出現する。\n\n### Permission と Hooks\n\npermission は tool 実行行に直接埋め込まない。`PreToolUse` hook として扱う:\n\n```python\nblocked = trigger_hooks(\"PreToolUse\", block)\nif blocked:\n results.append(tool_result(block.id, blocked))\n continue\n```\n\nこれにより permission、logging、audit が同じ hook point に接続できる。実行後には `PostToolUse` hook が走る。\n\n### Plan と Task\n\nS20 には 2 層の plan がある:\n\n- `todo_write`: current session 用の軽量 plan。メモリに保持。\n- task graph: cross-session、dependency-aware、claimable な task file。`.tasks/task_*.json` に保存。\n\n前者は単独 agent の drift を防ぐ。後者は team coordination の土台になる。\n\n### Subagent と Team\n\nS20 には 2 種類の delegation がある:\n\n- `task`: one-shot subagent。独立した `messages[]` を使い、中間 context を捨て、final summary だけ返す。\n- `spawn_teammate`: persistent teammate thread。`MessageBus` で通信し、idle 中に task board を polling して自律的に claim できる。\n\none-shot subagent は context isolation を解決する。persistent teammate は長期並列協作を解決する。\n\n### Memory、Skills、Prompt\n\n`assemble_system_prompt(context)` は毎 round 次を組み立てる:\n\n- identity と tool guidance\n- workspace\n- skills catalog\n- `.memory/MEMORY.md`\n- connected MCP servers\n\nskills は system prompt には catalog だけ置く。全文は `load_skill(name)` で必要な時に読む。\n\n### Compaction と Recovery\n\nLLM call の前に compaction pipeline を走らせる:\n\n```text\ntool_result_budget → snip_compact → micro_compact → compact_history\n```\n\nmodel call は recovery で包む:\n\n- 429: exponential backoff retry\n- 529: exponential backoff、連続失敗時は fallback model へ切替可能\n- `max_tokens`: max tokens を上げ、その後 continuation を要求\n- prompt too long: reactive compact 後に retry\n\n### Background と Cron\n\n遅い bash work は main loop を止めない:\n\n```text\nshould_run_background → start_background_task → placeholder tool_result\nbackground done → task_notification → next round injects messages\n```\n\ncron scheduler は daemon thread として動き、1 秒ごとに確認する。CLI は `cron_queue` を監視し、発火した job を `[Scheduled] ...` として注入して Agent を 1 turn 自動実行する。\n\n### Worktree と MCP\n\nworktree isolation は directory を担当する:\n\n- `create_worktree(name, task_id)` が isolated branch と directory を作る\n- task の `worktree` field が task と directory を紐付ける\n- teammate が worktree 付き task を claim すると、bash/read/write はその directory で実行される\n\nMCP は external capability を担当する:\n\n- `connect_mcp(name)` が mock server に接続する\n- `assemble_tool_pool()` が MCP tools を tool pool に組み立てる\n- tool name は `mcp__server__tool` 形式に統一する\n\n---\n\n## s19 からの変化\n\n| Component | s19 | s20 |\n|-----------|-----|-----|\n| tool pool | built-in + MCP | built-in + MCP、s01-s18 の tool を補完 |\n| permission | teaching body では省略 | `PreToolUse` hook で実行 |\n| hooks | 省略 | UserPromptSubmit / PreToolUse / PostToolUse / Stop |\n| todo | 省略 | `todo_write` + reminder |\n| skill | 省略 | system prompt の catalog + `load_skill` |\n| compact | 省略 | LLM 前 compaction + `compact` tool + reactive compact |\n| error recovery | simple try/except | retry / max_tokens / prompt too long |\n| background | 省略 | slow-operation thread + task notification |\n| cron | 省略 | daemon scheduler + durable jobs |\n| multi-agent | 維持 | 維持。teammate は isolated directory 上の basic tools を使う |\n| worktree | 維持 | 維持 |\n| MCP | 新規 | final tool pool の一部として維持 |\n\n---\n\n## 試す\n\n```sh\ncd learn-claude-code\npython s20_comprehensive/code.py\n```\n\n試す prompt:\n\n1. `Create a todo list for inspecting this repo, then list Python files`\n2. `Connect to the docs MCP server and search for agent loop`\n3. `Create two tasks, create worktrees for them, then spawn alice and bob. Ask them to submit plans before claiming tasks.`\n4. `remind me of the meeting in 3 minutes.`\n5. `Run npm install in the background and continue reading README.md`\n\n見るポイント:\n\n- tool call の前に hooks/permission を通るか\n- `connect_mcp` 後の次 round で MCP tool が出るか\n- 遅い operation が background placeholder を返すか\n- cron が時刻到達時に自動で reminder を返すか\n- teammate が plan を提出し、approval 前に停止するか\n- plan approval 後、teammate が task を claim できるか\n- worktree binding 後、teammate が対応 directory に切り替わるか\n\n---\n\n## 終わりは始まり\n\ns01 から s20 まで、コードの能力は増えていく。しかし中心は変わらない:\n\n```python\nwhile True:\n response = LLM(messages, tools)\n if not has_tool_use(response.content):\n return\n results = execute_tools(response.content)\n messages.append(tool_results)\n```\n\nClaude Code の複雑さは「別の agent brain」ではない。成熟した harness の複雑さだ。model は判断と action selection を担当する。harness は environment、tools、permissions、memory、teams、external capabilities を整理する。\n\nこれが本コースの終点だ:仕組みは多い、ループは 1 つ。\n" + "content": "# s20: Comprehensive Agent — すべての仕組みを 1 つのループへ\n\ns01 → ... → s18 → s19 → `s20`\n\n> *\"仕組みは多い、ループは 1 つ\"* — tools、permissions、memory、tasks、teams、plugins はすべて同じ `while True` に接続される。\n>\n> **Harness レイヤー**: 総合 — 前 19 章の仕組みを 1 つの実行可能なシステムへ戻す。\n\n---\n\n## 問題\n\n前 19 章では、各章が 1 つの仕組みだけを追加した。学習にはその形が適している。しかし実際の agent は、1 つの仕組みだけで動くわけではない。\n\n長時間動く coding agent には、同時に次のものが必要になる:\n\n- tool dispatch と permission boundary\n- hook extension point\n- todo plan と task graph\n- skill、memory、runtime system prompt assembly\n- compaction と error recovery\n- background task と cron scheduling\n- team、protocol、autonomous claiming\n- worktree isolation\n- MCP external tool integration\n\n難しいのは機能を積み上げることではない。それぞれの仕組みが loop のどこに接続されるかを見抜くことだ。S20 は終点章であり、すべての component を 1 つの harness に戻す。\n\n---\n\n## 解決策\n\n![System Architecture](/course-assets/s20_comprehensive/system-architecture.svg)\n\nS20 は新しい単独 mechanism を発明しない。前章までの teaching component を 1 つの完全な harness に統合する:\n\n```text\nuser input\n → UserPromptSubmit hooks\n → cron/background notification injection\n → context compact\n → memory + skills + MCP state で system prompt を組み立てる\n → LLM\n → has tool_use block?\n no → Stop hooks → return\n yes → PreToolUse hooks + permission\n → TOOL_HANDLERS / MCP handlers / background dispatch\n → PostToolUse hooks\n → tool_result / task_notification を messages へ戻す\n → next round\n```\n\nloop 自体は同じ構造のままだ。model を呼び、response に `tool_use` block があるかを見て、tool を実行し、結果を `messages` に戻す。Claude Code source でも `stop_reason == \"tool_use\"` を直接信頼せず、実際に tool_use block が出たかを continuation signal として扱う。変わったのは、loop の周囲の harness が完成形になったことだけ。\n\n---\n\n## 各 Component の位置\n\n| 位置 | Component | 役割 |\n|------|-----------|------|\n| user input 周辺 | `UserPromptSubmit` hooks | user input の記録、注入、監査 |\n| LLM 前 | cron queue | scheduled prompt を `messages` へ注入 |\n| LLM 前 | background notifications | 完了した background work を `` として注入 |\n| LLM 前 | compaction pipeline | 大きな出力を予算化し、履歴を切り、古い tool_result を圧縮し、必要なら要約 |\n| LLM 前 | memory / skills / MCP state | current capabilities と long-term context を system prompt に組み込む |\n| LLM call | error recovery | 429/529 retry、`max_tokens` escalation、prompt-too-long compact |\n| tool 実行前 | `PreToolUse` hooks + permission | 危険な command、範囲外 write、destructive MCP tool を止める |\n| tool dispatch | `assemble_tool_pool` | built-in tools と dynamic MCP tools を組み立てる |\n| tool 実行中 | background dispatch | 遅い bash work を daemon thread に逃がし、placeholder result を返す |\n| tool 実行後 | `PostToolUse` hooks | large-output warning、log、後処理 |\n| loop へ戻る | tool_result | 1 つの `tool_use` に 1 つの `tool_result`、そして次の model round |\n| tool_use がない round / stop 時 | `Stop` hooks | 統計、cleanup、audit |\n\n---\n\n## code.py に含まれるもの\n\n### Tools と Dispatch\n\nbuilt-in tool pool には 27 個の tool がある:\n\n```text\nbash, read_file, write_file, edit_file, glob\ntodo_write, task, load_skill, compact\ncreate_task, list_tasks, get_task, claim_task, complete_task\nschedule_cron, list_crons, cancel_cron\nspawn_teammate, send_message, check_inbox\nrequest_shutdown, request_plan, review_plan\ncreate_worktree, remove_worktree, keep_worktree\nconnect_mcp\n```\n\n`assemble_tool_pool()` は毎 round で次を組み立てる:\n\n```text\nBUILTIN_TOOLS + connected MCP tools\nBUILTIN_HANDLERS + mcp__server__tool handlers\n```\n\n`connect_mcp(\"docs\")` のあと、次の round では `mcp__docs__search` のような tool が出現する。\n\n### Permission と Hooks\n\npermission は tool 実行行に直接埋め込まない。`PreToolUse` hook として扱う:\n\n```python\nblocked = trigger_hooks(\"PreToolUse\", block)\nif blocked:\n results.append(tool_result(block.id, blocked))\n continue\n```\n\nこれにより permission、logging、audit が同じ hook point に接続できる。実行後には `PostToolUse` hook が走る。\n\n### Plan と Task\n\nS20 には 2 層の plan がある:\n\n- `todo_write`: current session 用の軽量 plan。メモリに保持。\n- task graph: cross-session、dependency-aware、claimable な task file。`.tasks/task_*.json` に保存。\n\n前者は単独 agent の drift を防ぐ。後者は team coordination の土台になる。\n\n### Subagent と Team\n\nS20 には 2 種類の delegation がある:\n\n- `task`: one-shot subagent。独立した `messages[]` を使い、中間 context を捨て、final summary だけ返す。\n- `spawn_teammate`: persistent teammate thread。`MessageBus` で通信し、idle 中に task board を polling して自律的に claim できる。\n\none-shot subagent は context isolation を解決する。persistent teammate は長期並列協作を解決する。\n\n### Memory、Skills、Prompt\n\n`assemble_system_prompt(context)` は毎 round 次を組み立てる:\n\n- identity と tool guidance\n- workspace\n- skills catalog\n- `.memory/MEMORY.md`\n- connected MCP servers\n\nskills は system prompt には catalog だけ置く。全文は `load_skill(name)` で必要な時に読む。\n\n### Compaction と Recovery\n\nLLM call の前に compaction pipeline を走らせる:\n\n```text\ntool_result_budget → snip_compact → micro_compact → compact_history\n```\n\nmodel call は recovery で包む:\n\n- 429: exponential backoff retry\n- 529: exponential backoff、連続失敗時は fallback model へ切替可能\n- `max_tokens`: max tokens を上げ、その後 continuation を要求\n- prompt too long: reactive compact 後に retry\n\n### Background と Cron\n\n遅い bash work は main loop を止めない:\n\n```text\nshould_run_background → start_background_task → placeholder tool_result\nbackground done → task_notification → next round injects messages\n```\n\ncron scheduler は daemon thread として動き、1 秒ごとに確認する。CLI は `cron_queue` を監視し、発火した job を `[Scheduled] ...` として注入して Agent を 1 turn 自動実行する。\n\n### Worktree と MCP\n\nworktree isolation は directory を担当する:\n\n- `create_worktree(name, task_id)` が isolated branch と directory を作る\n- task の `worktree` field が task と directory を紐付ける\n- teammate が worktree 付き task を claim すると、bash/read/write はその directory で実行される\n\nMCP は external capability を担当する:\n\n- `connect_mcp(name)` が mock server に接続する\n- `assemble_tool_pool()` が MCP tools を tool pool に組み立てる\n- tool name は `mcp__server__tool` 形式に統一する\n\n---\n\n## s19 からの変化\n\n| Component | s19 | s20 |\n|-----------|-----|-----|\n| tool pool | built-in + MCP | built-in + MCP、s01-s18 の tool を補完 |\n| permission | teaching body では省略 | `PreToolUse` hook で実行 |\n| hooks | 省略 | UserPromptSubmit / PreToolUse / PostToolUse / Stop |\n| todo | 省略 | `todo_write` + reminder |\n| skill | 省略 | system prompt の catalog + `load_skill` |\n| compact | 省略 | LLM 前 compaction + `compact` tool + reactive compact |\n| error recovery | simple try/except | retry / max_tokens / prompt too long |\n| background | 省略 | slow-operation thread + task notification |\n| cron | 省略 | daemon scheduler + durable jobs |\n| multi-agent | 維持 | 維持。teammate は isolated directory 上の basic tools を使う |\n| worktree | 維持 | 維持 |\n| MCP | 新規 | final tool pool の一部として維持 |\n\n---\n\n## 試す\n\n```sh\ncd learn-claude-code\npython s20_comprehensive/code.py\n```\n\n試す prompt:\n\n1. `Create a todo list for inspecting this repo, then list Python files`\n2. `Connect to the docs MCP server and search for agent loop`\n3. `Create two tasks, create worktrees for them, then spawn alice and bob. Ask them to submit plans before claiming tasks.`\n4. `remind me of the meeting in 3 minutes.`\n5. `Run npm install in the background and continue reading README.md`\n\n見るポイント:\n\n- tool call の前に hooks/permission を通るか\n- `connect_mcp` 後の次 round で MCP tool が出るか\n- 遅い operation が background placeholder を返すか\n- cron が時刻到達時に自動で reminder を返すか\n- teammate が plan を提出し、approval 前に停止するか\n- plan approval 後、teammate が task を claim できるか\n- worktree binding 後、teammate が対応 directory に切り替わるか\n\n---\n\n## 終わりは始まり\n\ns01 から s20 まで、コードの能力は増えていく。しかし中心は変わらない:\n\n```python\nwhile True:\n response = LLM(messages, tools)\n if not has_tool_use(response.content):\n return\n results = execute_tools(response.content)\n messages.append(tool_results)\n```\n\nClaude Code の複雑さは「別の agent brain」ではない。成熟した harness の複雑さだ。model は判断と action selection を担当する。harness は environment、tools、permissions、memory、teams、external capabilities を整理する。\n\nここが s01–s20 の本流の収束点だ:すべての component が同じ loop に集結する。\n\nそしてその loop は常に単ステップで model 駆動だ——毎 round、model が tool を 1 つ選ぶ。オーケストレーションの形がすでに固定なら(parallel fan-out、item ごとの pipeline、中断からの resume)、model に round ごと駆動させるより、決定的で再開可能な script として書く。\n\nこれから:[s21 Workflow Runtime](/ja/s21) — モデルは単ステップ、スクリプトはオーケストレーション。\n" + }, + { + "version": "s21", + "locale": "en", + "title": "s21: Workflow Runtime — The Model Owns Each Step, the Script Owns the Orchestration", + "content": "# s21: Workflow Runtime — The Model Owns Each Step, the Script Owns the Orchestration\n\ns01 → ... → s19 → s20 → `s21`\n\n> *\"One tool_use, a whole orchestration runs in the background\"* — the `Workflow` tool launches a deterministic, resumable script runtime that fans out a fleet of subagents.\n>\n> **Harness layer**: Orchestration — above the single-agent loop, a deterministic multi-agent script runtime.\n\n---\n\n## The problem\n\nFrom s01 to s20 the loop is model-driven and single-step: each round the model picks one tool, the result goes back into `messages[]`, and another round begins. For open-ended tasks this is the best you can do — let the model decide the next step on the spot, looking at the context.\n\nBut some work needs to **orchestrate a fleet of agents deterministically**. Take reviewing a large change: find problems across ten dimensions in parallel → dispatch an adversarial verification for each finding → merge and dedup → sort by severity. The shape of this orchestration is fixed, and what you want is:\n\n- **parallel**, not waiting one at a time;\n- **deterministic**, the same input producing the same structure;\n- **resumable**, so that if it dies halfway through, the parts already done are not redone.\n\nHaving the model drive all of this step by step inside the main loop is slow, non-deterministic, and starts over when interrupted. At that point what you want is not \"one more chat round\" but to **write the orchestration as a piece of code**.\n\n## The solution\n\nClaude Code puts a `Workflow` tool in the tool pool. You (or the model, under an `ultracode` trigger) hand it a **script**, and the script uses the primitives `agent() / parallel() / pipeline() / phase()` to write the orchestration as deterministic code.\n\nThe main loop sees a single `tool_use` and **immediately** gets back `async_launched` — the real execution proceeds in a **background runtime** that reports progress and writes a journal to disk. Intermediate results live in script variables, not in the conversation. `resumeFromRunId` lets any unchanged `agent()` hit the journal cache and resume from where it stopped.\n\n![Workflow Runtime overview](/course-assets/s21_workflow_runtime/workflow-runtime-overview.svg)\n\nThe plan is code, not a chat turn:\n\n```python\nSAMPLE_META = {\"name\": \"review-changes\", \"description\": \"...\", \"phases\": [\"Review\", \"Verify\"]}\n\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n results = await ctx.pipeline(DIMENSIONS, audit, verify) # each dimension runs audit -> verify independently\n confirmed = [f for r in results if r for f in r[\"confirmed\"]]\n ctx.log(f\"confirmed {len(confirmed)} real finding(s)\")\n return {\"confirmed\": confirmed}\n```\n\n## How it works\n\n### The Workflow tool: launches in the background, the main loop sees one tool_use\n\n`Workflow` (aliased `RunWorkflow`) sits in the main agent's tool pool. A trigger arrives — an explicit \"run/build workflow\", a saved `/command`, or the high-effort `ultracode` path — and the model emits a `Workflow(...)` `tool_use`. `WorkflowTool.call` parses the arguments, validates the meta, passes permission, registers a `local_workflow` task, and **returns immediately** with `async_launched`. The main loop does not block; it keeps going while the workflow runs in the background.\n\n```python\nclass WorkflowTool:\n async def call(self, meta, script_fn, args=None, resume_from_run_id=None):\n validate_meta(meta)\n check_permission(meta)\n run_id = resume_from_run_id or create_run_id(meta)\n task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)\n task.event(\"async_launched\", runId=run_id, taskId=task.task_id) # returns immediately\n ... # the rest runs in the background\n```\n\n> Real Claude Code: the tool returns immediately with `{status:'async_launched', taskId, taskType:'local_workflow', runId, summary, transcriptDir, scriptPath}`, and the background task completes later.\n\n### Script and meta: the first statement\n\nThe script's **first statement** must be `export const meta = { name, description, phases }`, and it must be a pure literal — no variables, function calls, or interpolation. The runtime parses it before executing anything: `name`/`description` drive the task and the UI, `phases` name the progress groups. Bad input raises `WorkflowInputError` outright.\n\n```python\ndef validate_meta(meta):\n if not meta.get(\"name\") or not meta.get(\"description\"):\n raise WorkflowInputError(\"meta requires `name` and `description`\")\n if \"phases\" in meta and not isinstance(meta[\"phases\"], list):\n raise WorkflowInputError(\"meta.phases must be a list\")\n return meta\n```\n\n> Real Claude Code: `parseWorkflowScript` enforces that meta is the first statement and a pure literal; the teaching version just takes a dict.\n\n### Orchestration primitives: agent / parallel / pipeline / phase / log / workflow\n\nThe script runs in a context whose **only** useful globals are these orchestration primitives. The script itself does not read/write files or run a shell — the actual codebase reads and writes are done by **subagents** through their own tool permissions. The primitives are methods on `ExecutionState`:\n\n| Primitive | What it does |\n|------|------|\n| `agent(prompt, {schema, label, phase})` | fan out a subagent |\n| `parallel(thunks)` | **barrier**: run all concurrently, wait for all together |\n| `pipeline(items, *stages)` | per-item, staged, **no barrier** |\n| `phase(title)` | progress group (upsert) |\n| `log(message)` | a progress line |\n| `workflow(name, args)` | nested sub-workflow (one level only) |\n\n`pipeline` is the default — each item passes through all stages independently, so item A can be at stage 3 while item B is still at stage 1; reach for the `parallel` barrier only when you genuinely need \"all of the previous stage's results at once\".\n\n```python\nasync def pipeline(self, items, *stages):\n async def run_item(item, idx):\n value = item\n for stage in stages: # each item runs through all stages independently\n value = await stage(value, item, idx)\n return value\n return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])\n```\n\n> Real Claude Code: the same primitives are injected into the script context by the VM; there are also `args`, `budget` (`budget.total/spent/remaining`), an agent-count cap (1000), and a concurrency semaphore.\n\n### Structured output: agent({schema}) + StructuredOutput\n\n`agent({schema})` forces the subagent to return a JSON object that matches the schema (via a single `StructuredOutput` call); the runtime validates it against the schema and retries once on a mismatch. This way downstream code consumes an **object**, not prose it has to parse again.\n\n```python\nresult = self.runner.run(prompt, schema, label)\nif schema is not None:\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok: # one nudge retry, then raise\n result = self.runner.run(prompt + \"\\n\\nReturn valid JSON.\", schema, label)\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok:\n raise WorkflowInputError(f\"agent({{schema}}) invalid output: {err}\")\n```\n\n> Real Claude Code: `SimpleJsonSchema` + the `StructuredOutput` tool + schema retry.\n\n### Background task and progress events\n\n`LocalWorkflowTask` holds the status/usage and emits an SDK-style event stream: `task_started` → a series of `task_progress` (carrying batches of `workflow_phase` / `workflow_agent` / `workflow_log`) → a final `task_notification` (completed / failed / stopped, with the output file, token count, tool-call count, and elapsed time). The main session treats these as events; only the final notification re-enters the loop.\n\n```python\nclass LocalWorkflowTask:\n def progress_event(self, ptype, **data): # workflow_phase / workflow_agent / workflow_log\n self.progress.append({\"type\": ptype, **data})\n print(f\" progress {ptype} ...\")\n```\n\n> Real Claude Code: progress is folded into the task status and sent to the UI/SDK as `task_progress.workflow_progress`.\n\n### Storage: snapshot + journal\n\nWhen a run finishes it writes five things, all under `~/.claude/projects///`: a snapshot `.json`, the output `.output.json`, the journal `.journal.jsonl`, the script `scripts/.js`, and the subagent transcripts `subagents/workflows//`. Saved workflows live in `.claude/workflows/` (project) or `~/.claude/workflows/` (user).\n\nThe journal is the key to resume — it records the result of every `agent()`, one line at a time:\n\n```python\nclass WorkflowJournal:\n def record(self, key, value):\n self._f.write(json.dumps({\"key\": key, \"value\": value}) + \"\\n\")\n self._f.flush()\n self.cache[key] = value\n```\n\n### resume: reuse the cache from a runId\n\n`Workflow({scriptPath, resumeFromRunId, args})` **re-runs the script**, but each `agent()` computes a **deterministic semantic key**: a key already in the journal returns its cached result directly (no re-run), so everything unchanged is a hit; only the changed one and whatever follows it actually runs.\n\nThe key here is that the key **must not depend on concurrency order** — inside `parallel`/`pipeline` the completion order of agents is undefined, so the key is computed from a stable hash of the call's content (kind, label, prompt, schema), not from a counter that would race.\n\n```python\ndef key(self, kind, label, prompt, schema):\n basis = f\"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}\"\n return f\"{kind}-{_stable_hash(basis) % 10**10:010d}\"\n\n# inside agent():\ncached = self.journal.cached(key)\nif cached is not MISS:\n self.task.progress_event(\"workflow_agent\", label=label, status=\"cached\")\n return cached\n```\n\n> Real Claude Code: the same \"deterministic semantic key + journal cache\"; within the same session, an `agent()` that completed before resume returns the cache, and what comes after it actually runs.\n\n### Determinism: reproducibility is the prerequisite\n\nFor resume to hold, the script has to be reproducible. So the runtime strips `Date.now()`, the argless `new Date()`, and `Math.random()` out of the script context, and gives it no Node API either. The same script + the same args → the same key → a 100% cache hit. The teaching version reaches the same effect by computing the key from a stable hash (the real version runs the whole JS script in a sandbox VM with those non-deterministic sources removed).\n\n### Putting it together\n\nThe example workflow `review-changes`: `pipeline` runs each review dimension through \"audit → verify\" independently — audit uses an `agent()` with a schema to find problems, verify uses `parallel()` to dispatch one adversarial verification subagent per finding, and at the end only the `isReal` ones survive, sorted by severity.\n\n```python\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n\n async def audit(_v, dimension, _i):\n out = await ctx.agent(f\"Review the changed files for {dimension} issues.\",\n schema=FINDINGS_SCHEMA, label=f\"audit:{dimension}\", phase=\"Review\")\n return {\"dimension\": dimension, \"findings\": out[\"findings\"]}\n\n async def verify(audited, dimension, _i):\n ctx.phase(\"Verify\")\n verdicts = await ctx.parallel([ # verify each finding adversarially, independently\n (lambda f=f: ctx.agent(f\"Adversarially verify ... {f['title']}\",\n schema=VERDICT_SCHEMA, label=f\"verify:{dimension}:{f['title']}\"))\n for f in audited[\"findings\"]])\n return {\"dimension\": dimension,\n \"confirmed\": [f for f, v in zip(audited[\"findings\"], verdicts) if v and v[\"isReal\"]]}\n\n results = await ctx.pipeline(DIMENSIONS, audit, verify)\n ...\n```\n\n## Changes from s20\n\n| | s20 Comprehensive | s21 Workflow Runtime |\n|--|-----------|---------------------|\n| Loop | single, model-driven | main loop unchanged; a deterministic orchestration layer on top |\n| Who decides the next step | the model, round by round | the script, with the orchestration written ahead of time |\n| Multiple agents | s06 subagents, a one-shot fan-out | scripted, reproducible, resumable batch orchestration |\n| New mechanisms | — | script DSL, background task, progress events, journal/resume, structured output, deterministic VM |\n\ns21 does not replace the main loop — it exposes `Workflow` at the tool layer, and behind it starts a `local_workflow` runtime: **one workflow deterministically drives N agent loops**. The s06 subagent is the model fanning out once on the spot; s21 is the orchestration written as a replayable script.\n\n## Try it\n\n```bash\npython s21_workflow_runtime/code.py # launch review-changes, watch the event stream\npython s21_workflow_runtime/code.py resume # resume from the last runId, every agent() hits the journal cache\n```\n\nWatch: one launch → `async_launched` → background `workflow_phase` / `workflow_agent` progress → `task_notification`; the result stays on the task. On `resume`, `agents=0 tokens=0` (all cache hits), and the result is identical down to the character.\n\n## What's next\n\nOrchestration is one more layer on top of agent capability: **the main loop owns the step, the script owns the whole squad**. Write the work as a deterministic, resumable script and the model turns from a \"round-by-round driver\" into an \"execution unit scheduled by a script\" — the same `agent()` can be called on the spot by the model inside the main loop, or orchestrated in batches by a script inside a workflow.\n\nWhat's next: [s22 Goal Loop](/en/s22) — orchestration fans work out, away from the main loop; the next chapter reverses it, with a goal pulling control back in: until it's met, the turn isn't allowed to end.\n\n\n" + }, + { + "version": "s21", + "locale": "zh", + "title": "s21: Workflow Runtime — 模型决定单步,脚本决定编排", + "content": "# s21: Workflow Runtime — 模型决定单步,脚本决定编排\n\ns01 → ... → s19 → s20 → `s21`\n\n> *\"一次 tool_use,后台跑完一整套编排\"* — `Workflow` 工具启动一个确定性、可恢复的脚本运行时,扇出一群子 agent。\n>\n> **Harness 层**: 编排 — 在单 agent 循环之上,加一层确定性的多 agent 脚本运行时。\n\n---\n\n## 问题\n\ns01 到 s20,循环是模型驱动、单步的:每一轮模型挑一个工具,结果塞回 `messages[]`,再来一轮。开放式任务这样最好——下一步做什么,让模型看着上下文临场决定。\n\n但有些活儿需要**确定性地编排一群 agent**。比如审一个大改动:十个维度并行找问题 → 每条发现各自派一个对抗性验证 → 汇总去重 → 按严重度排序。这种编排的形状是固定的,你想要的是:\n\n- **并行**,不是一个个串着等;\n- **确定**,同样的输入跑出同样的结构;\n- **可恢复**,跑到一半断了,已经做完的部分别重来。\n\n让模型在主循环里一步步驱动这套,又慢、又不确定、断了还得从头。这时候你想要的不是\"再聊一轮\",而是**把编排写成一段代码**。\n\n## 解决方案\n\nClaude Code 在工具池里放一个 `Workflow` 工具。你(或者模型在 `ultracode` 触发下)给它一段**脚本**,脚本用 `agent() / parallel() / pipeline() / phase()` 这几个原语,把编排写成确定性的代码。\n\n主循环只看到一次 `tool_use`,并**立即**拿到 `async_launched`——真正的执行在一个**后台运行时**里推进,上报进度、落盘 journal。脚本里的中间结果存在变量里,不进对话。`resumeFromRunId` 能让没改过的 `agent()` 命中 journal 缓存,断点续跑。\n\n![Workflow Runtime 总览](/course-assets/s21_workflow_runtime/workflow-runtime-overview.svg)\n\n计划是代码,不是一个聊天轮次:\n\n```python\nSAMPLE_META = {\"name\": \"review-changes\", \"description\": \"...\", \"phases\": [\"Review\", \"Verify\"]}\n\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n results = await ctx.pipeline(DIMENSIONS, audit, verify) # 每个维度独立走 审计 → 验证\n confirmed = [f for r in results if r for f in r[\"confirmed\"]]\n ctx.log(f\"confirmed {len(confirmed)} real finding(s)\")\n return {\"confirmed\": confirmed}\n```\n\n## 工作原理\n\n### Workflow 工具:后台启动,主循环只见一次 tool_use\n\n`Workflow`(别名 `RunWorkflow`)在主 agent 的工具池里。一个触发到来——显式的\"跑/建 workflow\"、一个保存好的 `/命令`、或 `ultracode` 高强度路径——模型就发出一个 `Workflow(...)` 的 `tool_use`。`WorkflowTool.call` 解析入参、校验 meta、过权限、注册一个 `local_workflow` 任务,然后**立即返回** `async_launched`。主循环不阻塞,继续往下;workflow 在后台跑。\n\n```python\nclass WorkflowTool:\n async def call(self, meta, script_fn, args=None, resume_from_run_id=None):\n validate_meta(meta)\n check_permission(meta)\n run_id = resume_from_run_id or create_run_id(meta)\n task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)\n task.event(\"async_launched\", runId=run_id, taskId=task.task_id) # 立即返回\n ... # 其余在后台\n```\n\n> 真实 Claude Code:工具立刻返回 `{status:'async_launched', taskId, taskType:'local_workflow', runId, summary, transcriptDir, scriptPath}`,后台任务稍后完成。\n\n### 脚本与 meta:第一条语句\n\n脚本的**第一条语句**必须是 `export const meta = { name, description, phases }`,而且是个纯字面量——不能有变量、函数调用、拼接。运行时在执行任何东西之前先解析它:`name`/`description` 驱动任务和 UI,`phases` 给进度分组命名。坏输入直接 `WorkflowInputError`。\n\n```python\ndef validate_meta(meta):\n if not meta.get(\"name\") or not meta.get(\"description\"):\n raise WorkflowInputError(\"meta requires `name` and `description`\")\n if \"phases\" in meta and not isinstance(meta[\"phases\"], list):\n raise WorkflowInputError(\"meta.phases must be a list\")\n return meta\n```\n\n> 真实 Claude Code:`parseWorkflowScript` 强制 meta 必须是第一条语句且是纯字面量;教学版直接收一个 dict。\n\n### 编排原语:agent / parallel / pipeline / phase / log / workflow\n\n脚本跑在一个上下文里,里面有用的全局量**只有**这几个编排原语。脚本本身不直接读写文件、不跑 shell——真正的代码库读写由**子 agent**通过它们自己的工具权限完成。原语是 `ExecutionState` 上的方法:\n\n| 原语 | 作用 |\n|------|------|\n| `agent(prompt, {schema, label, phase})` | 扇出一个子 agent |\n| `parallel(thunks)` | **屏障**:并发跑完全部、一起等回来 |\n| `pipeline(items, *stages)` | 逐项分阶段、**无屏障** |\n| `phase(title)` | 进度分组(upsert) |\n| `log(message)` | 进度行 |\n| `workflow(name, args)` | 嵌套子工作流(仅一层) |\n\n`pipeline` 是默认选择——每个 item 独立穿过所有 stage,item A 在 stage 3 时 item B 可能还在 stage 1;只有真需要\"拿到全部上一阶段结果\"时才用 `parallel` 这个屏障。\n\n```python\nasync def pipeline(self, items, *stages):\n async def run_item(item, idx):\n value = item\n for stage in stages: # 每个 item 独立走完所有 stage\n value = await stage(value, item, idx)\n return value\n return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])\n```\n\n> 真实 Claude Code:同名原语由 VM 注入脚本上下文;还有 `args`、`budget`(`budget.total/spent/remaining`)、agent 数上限(1000)、并发信号量。\n\n### 结构化输出:agent({schema}) + StructuredOutput\n\n`agent({schema})` 强制子 agent 返回一个匹配 schema 的 JSON 对象(通过一次 `StructuredOutput` 调用),运行时按 schema 校验、不匹配就重试一次。这样下游代码消费的是**对象**,不是要再解析的散文。\n\n```python\nresult = self.runner.run(prompt, schema, label)\nif schema is not None:\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok: # 一次 nudge 重试,再不行就报错\n result = self.runner.run(prompt + \"\\n\\nReturn valid JSON.\", schema, label)\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok:\n raise WorkflowInputError(f\"agent({{schema}}) invalid output: {err}\")\n```\n\n> 真实 Claude Code:`SimpleJsonSchema` + `StructuredOutput` 工具 + schema 重试。\n\n### 背景任务与进度事件\n\n`LocalWorkflowTask` 持有 status/usage,并向外发出一条 SDK 风格的事件流:`task_started` → 一串 `task_progress`(装着 `workflow_phase` / `workflow_agent` / `workflow_log` 批次)→ 最后一个 `task_notification`(completed / failed / stopped,带 output 文件、token 数、工具调用数、耗时)。主会话把这些当事件看;只有最终的 notification 会重新进入循环。\n\n```python\nclass LocalWorkflowTask:\n def progress_event(self, ptype, **data): # workflow_phase / workflow_agent / workflow_log\n self.progress.append({\"type\": ptype, **data})\n print(f\" progress {ptype} ...\")\n```\n\n> 真实 Claude Code:进度折叠进任务状态,作为 `task_progress.workflow_progress` 发给 UI/SDK。\n\n### 存储:快照 + journal\n\n跑完写五样东西,都落在 `~/.claude/projects///` 下:快照 `.json`、输出 `.output.json`、journal `.journal.jsonl`、脚本 `scripts/.js`、子 agent transcript `subagents/workflows//`。保存好的 workflow 放 `.claude/workflows/`(项目)或 `~/.claude/workflows/`(用户)。\n\njournal 是 resume 的关键——它逐条记下每个 `agent()` 的结果:\n\n```python\nclass WorkflowJournal:\n def record(self, key, value):\n self._f.write(json.dumps({\"key\": key, \"value\": value}) + \"\\n\")\n self._f.flush()\n self.cache[key] = value\n```\n\n### resume:从 runId 复用缓存\n\n`Workflow({scriptPath, resumeFromRunId, args})` 会**重跑脚本**,但每个 `agent()` 会算一个**确定性的语义 key**:key 在 journal 里就直接返回缓存结果(不重跑),没改过的全部命中;改过的那个及它之后的才真跑。\n\n关键在于 key **不能依赖并发顺序**——`parallel`/`pipeline` 里 agent 的完成次序是不定的,所以 key 由调用内容(kind、label、prompt、schema)的稳定哈希算出,而不是一个会竞争的计数器。\n\n```python\ndef key(self, kind, label, prompt, schema):\n basis = f\"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}\"\n return f\"{kind}-{_stable_hash(basis) % 10**10:010d}\"\n\n# agent() 里:\ncached = self.journal.cached(key)\nif cached is not MISS:\n self.task.progress_event(\"workflow_agent\", label=label, status=\"cached\")\n return cached\n```\n\n> 真实 Claude Code:同样是\"确定性语义 key + journal 缓存\";同会话内 resume 完成过的 `agent()` 返回缓存、之后的实跑。\n\n### 确定性:可复现是前提\n\nresume 要成立,脚本就得可复现。所以运行时把 `Date.now()`、无参 `new Date()`、`Math.random()` 从脚本上下文里去掉,也不给 Node API。同一份脚本 + 同样的 args → 同样的 key → 100% 缓存命中。教学版用稳定哈希算 key 来达到同样的效果(真实版是把整段 JS 脚本跑在去掉这些非确定源的沙箱 VM 里)。\n\n### 合起来跑\n\n示例 workflow `review-changes`:`pipeline` 把每个审查维度独立地走\"审计 → 验证\"——审计用一个带 schema 的 `agent()` 找问题,验证用 `parallel()` 给每条发现各派一个对抗性验证子 agent,最后只留 `isReal` 的、按严重度排序。\n\n```python\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n\n async def audit(_v, dimension, _i):\n out = await ctx.agent(f\"Review the changed files for {dimension} issues.\",\n schema=FINDINGS_SCHEMA, label=f\"audit:{dimension}\", phase=\"Review\")\n return {\"dimension\": dimension, \"findings\": out[\"findings\"]}\n\n async def verify(audited, dimension, _i):\n ctx.phase(\"Verify\")\n verdicts = await ctx.parallel([ # 每条发现独立对抗性验证\n (lambda f=f: ctx.agent(f\"Adversarially verify ... {f['title']}\",\n schema=VERDICT_SCHEMA, label=f\"verify:{dimension}:{f['title']}\"))\n for f in audited[\"findings\"]])\n return {\"dimension\": dimension,\n \"confirmed\": [f for f, v in zip(audited[\"findings\"], verdicts) if v and v[\"isReal\"]]}\n\n results = await ctx.pipeline(DIMENSIONS, audit, verify)\n ...\n```\n\n## 相对 s20 的变更\n\n| | s20 综合体 | s21 Workflow Runtime |\n|--|-----------|---------------------|\n| 循环 | 单个、模型驱动 | 主循环不变;之上加一层确定性编排 |\n| 谁决定下一步 | 模型逐轮决定 | 脚本预先写定编排 |\n| 多 agent | s06 子 agent,一次性扇出 | 脚本化、可复现、可恢复的批量编排 |\n| 新增机制 | — | 脚本 DSL、后台 task、进度事件、journal/resume、结构化输出、确定性 VM |\n\ns21 不替换主循环——它在 tool layer 暴露 `Workflow`,背后启动一个 `local_workflow` 运行时:**一个 workflow 确定性地驱动 N 个 agent 循环**。s06 的子 agent 是模型临场扇出一次;s21 是把编排写成可重放的脚本。\n\n## 试一下\n\n```bash\npython s21_workflow_runtime/code.py # 启动 review-changes,看事件流\npython s21_workflow_runtime/code.py resume # 用上次 runId 续跑,每个 agent() 命中 journal 缓存\n```\n\n观察:一次 launch → `async_launched` → 后台 `workflow_phase` / `workflow_agent` 进度推进 → `task_notification`;结果留在 task 上。`resume` 时 `agents=0 tokens=0`(全部缓存命中),结果一字不差。\n\n## 接下来\n\n编排是 agent 能力之上的又一层:**主循环管单步,脚本管整支队伍**。把工作写成确定性、可恢复的脚本,模型就从\"逐轮驱动者\"变成了\"被脚本调度的执行单元\"——同一个 `agent()`,既能在主循环里被模型临场调用,也能在 workflow 里被脚本批量编排。\n\n接下来:[s22 Goal Loop](/zh/s22) — 编排把工作扇出去、脱离主循环;下一章反过来,一个目标把控制权重入主循环,没达成就不让 turn 结束。\n\n\n" + }, + { + "version": "s21", + "locale": "ja", + "title": "s21: Workflow Runtime — モデルは単ステップ、スクリプトはオーケストレーション", + "content": "# s21: Workflow Runtime — モデルは単ステップ、スクリプトはオーケストレーション\n\ns01 → ... → s19 → s20 → `s21`\n\n> *\"1 回の tool_use で、バックグラウンドでオーケストレーション一式を走らせる\"* — `Workflow` ツールが決定的で再開可能な script runtime を起動し、subagent の群れを fan out する。\n>\n> **Harness 層**: オーケストレーション — single-agent loop の上に、決定的な multi-agent script runtime を一層加える。\n\n---\n\n## 問題\n\ns01 から s20 まで、loop は model 駆動で単ステップだ:毎 round で model が tool を 1 つ選び、結果を `messages[]` に戻し、また 1 round。オープンエンドな task ではこれが最善——次に何をするかは、context を見て model にその場で決めさせる。\n\nしかし、一部の仕事は **agent の群れを決定的にオーケストレーション**する必要がある。大きな変更を review する例:10 個の dimension を並行で問題探し → 各 finding にそれぞれ adversarial な verification を dispatch → 集約して dedup → severity で sort。このオーケストレーションの形は固定で、欲しいのは:\n\n- **並行**、1 つずつ待たない;\n- **決定的**、同じ入力で同じ構造が出る;\n- **再開可能**、途中で落ちても、もう終わった部分はやり直さない。\n\nこれを model が main loop で 1 ステップずつ駆動するのは、遅いし、非決定的だし、落ちたら最初からだ。このとき欲しいのは「もう 1 round 会話する」ことではなく、**オーケストレーションをコードとして書く**ことだ。\n\n## 解決策\n\nClaude Code は tool pool に `Workflow` ツールを置く。あなた(あるいは `ultracode` trigger 下の model)がそれに **script** を渡し、script は `agent() / parallel() / pipeline() / phase()` という primitive で、オーケストレーションを決定的なコードとして書く。\n\nmain loop は 1 回の `tool_use` だけを見て、**即座に** `async_launched` を受け取る——本当の実行は**バックグラウンド runtime** の中で進み、progress を報告し、journal を disk に書く。script 内の中間結果は変数に入り、会話には入らない。`resumeFromRunId` は変更していない `agent()` を journal cache にヒットさせ、中断点から再開できる。\n\n![Workflow Runtime 概観](/course-assets/s21_workflow_runtime/workflow-runtime-overview.svg)\n\n計画は会話の 1 round ではなく、コードだ:\n\n```python\nSAMPLE_META = {\"name\": \"review-changes\", \"description\": \"...\", \"phases\": [\"Review\", \"Verify\"]}\n\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n results = await ctx.pipeline(DIMENSIONS, audit, verify) # 各 dimension が独立で audit -> verify を走る\n confirmed = [f for r in results if r for f in r[\"confirmed\"]]\n ctx.log(f\"confirmed {len(confirmed)} real finding(s)\")\n return {\"confirmed\": confirmed}\n```\n\n## 仕組み\n\n### Workflow ツール:バックグラウンド起動、main loop は 1 回の tool_use だけ\n\n`Workflow`(別名 `RunWorkflow`)は main agent の tool pool にある。trigger が来る——明示的な「workflow を実行/作成」、保存済みの `/コマンド`、あるいは `ultracode` の高 effort path——と、model は `Workflow(...)` の `tool_use` を出す。`WorkflowTool.call` が引数を parse し、meta を検証し、permission を通し、`local_workflow` task を登録し、そして **即座に** `async_launched` を返す。main loop は block せず先へ進む;workflow はバックグラウンドで走る。\n\n```python\nclass WorkflowTool:\n async def call(self, meta, script_fn, args=None, resume_from_run_id=None):\n validate_meta(meta)\n check_permission(meta)\n run_id = resume_from_run_id or create_run_id(meta)\n task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)\n task.event(\"async_launched\", runId=run_id, taskId=task.task_id) # 即座に返す\n ... # 残りはバックグラウンド\n```\n\n> 実際の Claude Code:tool は即座に `{status:'async_launched', taskId, taskType:'local_workflow', runId, summary, transcriptDir, scriptPath}` を返し、バックグラウンド task は後で完了する。\n\n### script と meta:最初の文\n\nscript の**最初の文**は必ず `export const meta = { name, description, phases }` で、しかも純粋な literal でなければならない——変数も、関数呼び出しも、連結も不可。runtime は何かを実行する前にまずそれを parse する:`name`/`description` が task と UI を駆動し、`phases` が progress group に名前を付ける。不正な入力は直ちに `WorkflowInputError`。\n\n```python\ndef validate_meta(meta):\n if not meta.get(\"name\") or not meta.get(\"description\"):\n raise WorkflowInputError(\"meta requires `name` and `description`\")\n if \"phases\" in meta and not isinstance(meta[\"phases\"], list):\n raise WorkflowInputError(\"meta.phases must be a list\")\n return meta\n```\n\n> 実際の Claude Code:`parseWorkflowScript` は meta が最初の文かつ純粋な literal であることを強制する;教学版は dict をそのまま受け取る。\n\n### オーケストレーション primitive:agent / parallel / pipeline / phase / log / workflow\n\nscript は 1 つの context の中で走り、その中で使える global は**これらのオーケストレーション primitive だけ**だ。script 自身は file を直接読み書きせず、shell も走らせない——本当の codebase の読み書きは、**subagent** が自分の tool permission で行う。primitive は `ExecutionState` の method だ:\n\n| primitive | 役割 |\n|------|------|\n| `agent(prompt, {schema, label, phase})` | subagent を 1 つ fan out |\n| `parallel(thunks)` | **barrier**:全部を並行で走らせ、まとめて待つ |\n| `pipeline(items, *stages)` | item ごとに stage 分け、**barrier なし** |\n| `phase(title)` | progress group(upsert) |\n| `log(message)` | progress 行 |\n| `workflow(name, args)` | nested sub-workflow(1 層のみ) |\n\n`pipeline` がデフォルト——各 item が独立で全 stage を通り、item A が stage 3 にいる間に item B はまだ stage 1 かもしれない;「前 stage の全結果をまとめて」が本当に必要なときだけ `parallel` という barrier を使う。\n\n```python\nasync def pipeline(self, items, *stages):\n async def run_item(item, idx):\n value = item\n for stage in stages: # 各 item が独立で全 stage を走る\n value = await stage(value, item, idx)\n return value\n return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])\n```\n\n> 実際の Claude Code:同名の primitive は VM が script context に注入する;さらに `args`、`budget`(`budget.total/spent/remaining`)、agent 数上限(1000)、並行 semaphore もある。\n\n### 構造化出力:agent({schema}) + StructuredOutput\n\n`agent({schema})` は subagent に schema に一致した JSON object を返させる(1 回の `StructuredOutput` 呼び出し経由)。runtime は schema で検証し、不一致なら 1 回 retry する。こうして下流のコードが消費するのは、また parse し直す散文ではなく、**object** だ。\n\n```python\nresult = self.runner.run(prompt, schema, label)\nif schema is not None:\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok: # 1 回 nudge して retry、ダメなら raise\n result = self.runner.run(prompt + \"\\n\\nReturn valid JSON.\", schema, label)\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok:\n raise WorkflowInputError(f\"agent({{schema}}) invalid output: {err}\")\n```\n\n> 実際の Claude Code:`SimpleJsonSchema` + `StructuredOutput` tool + schema retry。\n\n### バックグラウンド task と progress event\n\n`LocalWorkflowTask` は status/usage を持ち、SDK 風の event stream を外へ出す:`task_started` → 一連の `task_progress`(`workflow_phase` / `workflow_agent` / `workflow_log` の batch を載せる)→ 最後の `task_notification`(completed / failed / stopped、output file・token 数・tool call 数・経過時間付き)。main session はこれらを event として見る;loop に再び入るのは最後の notification だけだ。\n\n```python\nclass LocalWorkflowTask:\n def progress_event(self, ptype, **data): # workflow_phase / workflow_agent / workflow_log\n self.progress.append({\"type\": ptype, **data})\n print(f\" progress {ptype} ...\")\n```\n\n> 実際の Claude Code:progress は task status に畳み込まれ、`task_progress.workflow_progress` として UI/SDK へ送られる。\n\n### ストレージ:snapshot + journal\n\nrun が終わると 5 つのものを書く。すべて `~/.claude/projects///` の下だ:snapshot `.json`、output `.output.json`、journal `.journal.jsonl`、script `scripts/.js`、subagent transcript `subagents/workflows//`。保存済みの workflow は `.claude/workflows/`(project)か `~/.claude/workflows/`(user)に置く。\n\njournal が resume の鍵だ——各 `agent()` の結果を、1 行ずつ記録する:\n\n```python\nclass WorkflowJournal:\n def record(self, key, value):\n self._f.write(json.dumps({\"key\": key, \"value\": value}) + \"\\n\")\n self._f.flush()\n self.cache[key] = value\n```\n\n### resume:runId から cache を再利用\n\n`Workflow({scriptPath, resumeFromRunId, args})` は **script を再実行する**が、各 `agent()` は**決定的な semantic key** を計算する:journal にある key はその場で cache 結果を返し(再実行しない)、変更していないものは全部ヒット;変更したものとその後だけが本当に走る。\n\n肝は、key が**並行順序に依存してはいけない**ことだ——`parallel`/`pipeline` 内の agent の完了順は不定なので、key は競合する counter ではなく、呼び出し内容(kind・label・prompt・schema)の安定 hash から計算する。\n\n```python\ndef key(self, kind, label, prompt, schema):\n basis = f\"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}\"\n return f\"{kind}-{_stable_hash(basis) % 10**10:010d}\"\n\n# agent() 内:\ncached = self.journal.cached(key)\nif cached is not MISS:\n self.task.progress_event(\"workflow_agent\", label=label, status=\"cached\")\n return cached\n```\n\n> 実際の Claude Code:同じく「決定的 semantic key + journal cache」;同一 session 内で resume 前に完了した `agent()` は cache を返し、その後のものは実際に走る。\n\n### 決定性:再現可能が前提\n\nresume が成立するには、script が再現可能でなければならない。だから runtime は `Date.now()`、引数なしの `new Date()`、`Math.random()` を script context から取り除き、Node API も与えない。同じ script + 同じ args → 同じ key → 100% cache ヒット。教学版は安定 hash で key を計算して同じ効果に達する(実際の版はこれらの非決定的なソースを除いた sandbox VM の中で JS script 全体を走らせる)。\n\n### まとめて走らせる\n\nサンプル workflow `review-changes`:`pipeline` が各 review dimension を独立に「audit → verify」へ通す——audit は schema 付きの `agent()` で問題を探し、verify は `parallel()` で各 finding にそれぞれ adversarial な verification subagent を割り当て、最後に `isReal` のものだけを残して severity で sort する。\n\n```python\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n\n async def audit(_v, dimension, _i):\n out = await ctx.agent(f\"Review the changed files for {dimension} issues.\",\n schema=FINDINGS_SCHEMA, label=f\"audit:{dimension}\", phase=\"Review\")\n return {\"dimension\": dimension, \"findings\": out[\"findings\"]}\n\n async def verify(audited, dimension, _i):\n ctx.phase(\"Verify\")\n verdicts = await ctx.parallel([ # 各 finding を独立で adversarial に verify\n (lambda f=f: ctx.agent(f\"Adversarially verify ... {f['title']}\",\n schema=VERDICT_SCHEMA, label=f\"verify:{dimension}:{f['title']}\"))\n for f in audited[\"findings\"]])\n return {\"dimension\": dimension,\n \"confirmed\": [f for f, v in zip(audited[\"findings\"], verdicts) if v and v[\"isReal\"]]}\n\n results = await ctx.pipeline(DIMENSIONS, audit, verify)\n ...\n```\n\n## s20 からの変更\n\n| | s20 総合体 | s21 Workflow Runtime |\n|--|-----------|---------------------|\n| loop | 単一、model 駆動 | main loop は不変;その上に決定的なオーケストレーション層 |\n| 次のステップを誰が決めるか | model が round ごとに | script があらかじめ書き定める |\n| 複数 agent | s06 subagent、一度きりの fan-out | スクリプト化・再現可能・再開可能な batch オーケストレーション |\n| 新しい仕組み | — | script DSL、バックグラウンド task、progress event、journal/resume、構造化出力、決定的 VM |\n\ns21 は main loop を置き換えない——tool layer に `Workflow` を露出し、その裏で `local_workflow` runtime を起動する:**1 つの workflow が N 個の agent loop を決定的に駆動する**。s06 の subagent は model がその場で一度 fan out するもの;s21 はオーケストレーションを再生可能な script として書くものだ。\n\n## 試す\n\n```bash\npython s21_workflow_runtime/code.py # review-changes を起動、event stream を見る\npython s21_workflow_runtime/code.py resume # 前回の runId で再開、各 agent() が journal cache にヒット\n```\n\n観察:1 回の launch → `async_launched` → バックグラウンドの `workflow_phase` / `workflow_agent` progress → `task_notification`;結果は task に残る。`resume` では `agents=0 tokens=0`(全部 cache ヒット)、結果は 1 文字も違わない。\n\n## これから\n\nオーケストレーションは agent 能力の上のもう 1 層だ:**main loop は単ステップを、script はチーム全体を司る**。仕事を決定的で再開可能な script として書けば、model は「round ごとの駆動者」から「script に scheduling される実行ユニット」へと変わる——同じ `agent()` が、main loop では model にその場で呼ばれ、workflow では script に batch でオーケストレーションされる。\n\nこれから:[s22 Goal Loop](/ja/s22) — オーケストレーションは仕事を扇出して main loop を離れる;次章はその逆で、目標が制御を引き戻す:満たされるまで turn を終わらせない。\n\n\n" + }, + { + "version": "s22", + "locale": "en", + "title": "s22: Goal Loop — The Goal Decides When to Stop, Not the Model", + "content": "# s22: Goal Loop — The Goal Decides When to Stop, Not the Model\n\ns01 → ... → s20 → s21 → `s22`\n\n> *\"Whether a turn may end is decided by the goal condition, not the model\"* — `/goal` adds a gate at the turn boundary: after every turn a separate evaluator judges whether trusted evidence satisfies the condition, and pushes control back into the next turn if it doesn't.\n>\n> **Harness layer**: Goal loop — a host-owned completion gate at the turn boundary.\n\n---\n\n## The problem\n\nFrom s01 to s21, how does a turn end? The model stops emitting `tool_use` and the loop `return`s. For one-shot tasks that's fine — done is done.\n\nBut some goals need to be **held across many turns**: \"get the tests green\", \"until the deploy succeeds\". Two failure modes are common: the model does half the work, decides it's close enough, and stops; or it just types `tests passed` and tries to wrap up. What you want is — **whether this turn may end is not the model's call; it's judged by an explicit condition, against trusted evidence.**\n\nThis isn't a timer (s14 cron), not a background task (s13), and not the model policing itself. It's a gate the host adds at the turn boundary.\n\n## The solution\n\n`/goal ` sets a session-scoped stopping condition. The host stores it as an active goal, and after every turn a separate small/fast evaluator model judges whether the **trusted evidence** in the transcript meets the condition. Not met → the gate blocks the stop and feeds a continuation into the next turn; met → the goal is cleared and recorded as achieved.\n\n![Goal Loop overview](/course-assets/s22_goal_loop/goal-loop-overview.svg)\n\nAgainst the s01 loop it's just one extra check — when the model wants to stop, ask the goal first:\n\n```python\n# s01: the model says stop -> stop\nif not has_tool_use(response):\n return\n# s22: when it wants to stop, pass the goal gate first\nif not has_tool_use(response):\n verdict = goal.evaluate_after_turn()\n if verdict == \"continuing\":\n continue # not met -> push it back for another turn\n return # met / over budget / no goal -> really stop\n```\n\n## How it works\n\n### /goal: a gate at the turn boundary\n\n`/goal` is a session-scoped, prompt-based Stop hook. It doesn't change the shape of the main loop; it just inserts one `evaluate_after_turn()` at the end of each turn. The gate is **host-owned** — not the model restraining itself. The model doesn't even know it was held back a turn; it simply receives the next input.\n\n```python\ndef submit(self, text, origin=None):\n ... # record input, run one (mock) assistant turn\n return self.goal.evaluate_after_turn() # <-- the Stop gate at the turn boundary\n```\n\n> Real Claude Code: `/goal` is a session-scoped Stop hook, gated by workspace trust and hook restrictions; the binary carries markers like `active_goal`, `goal_status`, `goal_met`, `tengu_goal_achieved`.\n\n### Setting a goal: the evidence window starts after the command\n\n`set_goal` stores an active goal: the objective text, the `max_turns` budget, counters, and `start_index` — the **start of the evidence window**. It takes the current transcript length, so the `/goal` command line is already outside the window. That's the first guard: the command text can't satisfy itself.\n\n```python\ndef set_goal(self, objective, max_turns=20):\n self.active = {\n \"objective\": objective, \"status\": \"active\",\n \"start_index\": len(self.transcript), # evidence window starts here; the command is already outside it\n \"max_turns\": max_turns, \"checks\": 0, \"continuation_turns\": 0,\n }\n```\n\n> Real Claude Code: `GoalRuntime.setGoal()` stores the activeGoal, startIndex, counters, and budget; right after submission `resetEvidenceStart()` aligns the window to just after the command.\n\n### The evaluator: only trusted evidence counts\n\nThis is the heart of it. The evaluator doesn't read the whole conversation — only the messages from **trusted origins** inside the evidence window. Three filters keep \"looks done but isn't\" text out:\n\n```python\nTRUSTED_EVIDENCE_ORIGINS = {\"task-notification\", \"monitor-line\"}\n\ndef evidence_text(self):\n out = []\n for m in self.transcript[self.active[\"start_index\"]:]:\n if m.origin.get(\"kind\") == \"slash-command\": # 1 slash-command origin doesn't count\n continue\n if m.role == \"user\" and m.content.strip().startswith(\"/goal\"): # 2 the /goal command line doesn't count\n continue\n if m.origin.get(\"kind\") not in TRUSTED_EVIDENCE_ORIGINS: # 3 only trusted origins count\n continue\n out.append(f\"{m.role}: {m.content}\")\n return \"\\n\".join(out)\n```\n\nThe effect: one `tests passed` typed by the user does not count; the same line delivered by a `task-notification` does. The model can't bluff its way through — it can't turn the goal \"met\" with a sentence of its own. The teaching `goal_satisfied()` is a deterministic keyword check; the real version hands the window to a small/fast model.\n\n> Real Claude Code: the evaluator is a small/fast model separate from the worker (markers `evaluatorModel`, `default small fast model`), judging transcript evidence rather than arbitrary plausibility.\n\n### Three gate states: completed / continuing / blocked\n\n`evaluate_after_turn` runs once per turn, with three exits: met → clear the goal (completed); not met and budget left → enqueue a continuation and let the next turn run (continuing); budget spent → stop (blocked), so a goal that can't be judged doesn't loop forever.\n\n```python\ndef evaluate_after_turn(self):\n g = self.active\n g[\"checks\"] += 1\n if self.goal_satisfied():\n g[\"status\"] = \"completed\"; self.active = None\n return \"completed\" # met -> clear the goal\n if g[\"continuation_turns\"] < g[\"max_turns\"]:\n g[\"continuation_turns\"] += 1\n self.queue.enqueue(\n value=\"Continue working ... do not treat this reminder as completion evidence.\",\n origin={\"kind\": \"active-goal\"})\n return \"continuing\" # not met -> enqueue a continuation\n g[\"status\"] = \"blocked\"; self.active = None\n return \"blocked\" # over budget -> stop blocking\n```\n\nThe continuation carries its own line `do not treat this reminder as completion evidence` — so even the reminder text is excluded from evidence. All three guards are in place: command text, reminder text, plain text — none of them count as done.\n\n> Real Claude Code: `evaluateAfterTurn` emits `goal_evaluated` and completes / enqueues a continuation / blocks; the default budget is `20`.\n\n### Continuation vs the external async inbox\n\nThe continuation goes into the same `CommandQueue`, but it and external async events (task-completion notifications, monitor lines) are **not the same drain**. `dequeue` takes a switch: an external-inbox drain skips active-goal continuations by default.\n\n```python\ndef dequeue(self, include_goal_continuations=True):\n ...\n for idx, item in enumerate(self.items):\n if include_goal_continuations or item[\"origin\"].get(\"kind\") != \"active-goal\":\n return self.items.pop(idx)\n return None\n```\n\nWhy split them: a real-model test once hit a bug — the model drained the continuation as if it were an external notification, declaring the goal dead before background evidence arrived. After the split, advancing a goal is an explicit step, not something an async event drags along.\n\n> Real Claude Code: `drainCommandQueue` defaults to `includeGoalContinuations=false`, separating active-goal continuations from the external async-inbox drain.\n\n### Putting it together\n\n`code.py` runs a `/goal until tests passed and deploy green`: after the goal is set there's no trusted evidence yet → the gate pushes it back, turn after turn; the user typing `tests passed` doesn't count either (untrusted origin); until a background task lands a `task-notification`, evidence arrives → completed. A second `max_turns=2` goal demonstrates blocked.\n\n```python\ns.submit(\"/goal until tests passed and deploy green\") # set the goal; window starts after the command\ns.submit(\"tests passed, trust me\") # plain text -> does not count\ns.submit(\"tests passed; deploy green\",\n origin={\"kind\": \"task-notification\"}) # trusted evidence -> completed\n```\n\n## Changes from s21\n\n| | s21 Workflow Runtime | s22 Goal Loop |\n|--|---------------------|---------------|\n| Trigger | script-controlled orchestration (leaves the main loop) | condition-controlled continuation (re-enters the main loop) |\n| Where it attaches | tool layer: a `Workflow` tool | turn boundary: a completion gate |\n| Who decides to stop | the script ends when it ends | the goal condition, judged against trusted evidence |\n| New mechanisms | script DSL, background task, journal/resume, structured output | goal gate, evidence trust boundary, continuation split, budget |\n\ns21 writes orchestration as a script and fans work out, away from the main loop; s22 is the reverse — a force that re-enters the main loop: until the goal is met, the turn isn't allowed to end. Neither changes the s01 `while`; they press on it from opposite sides.\n\n## Try it\n\n```bash\npython s22_goal_loop/code.py # /goal until tests pass + deploy green; watch the gate decide\n```\n\nWatch: after the goal is set, every turn ends with a `goal_evaluated`; plain text is `satisfied=False`, a `task-notification` origin is `satisfied=True`; when the budget runs out, `goal_blocked`. The same line `tests passed`, from different origins, gets the opposite verdict — that's where `/goal` refuses to be fooled by a sentence.\n\n## What's next\n\n`/goal` is one way to re-enter the main loop: condition control. It pairs with s21's \"leave the main loop\" — one fans work out, the other pulls control back. Further out there's time control (`/loop`, cron) and event control (`Monitor`), sharing the same task/notification substrate; but the gate's core is already here: **stopping isn't the model's say-so, it's the goal judged against trusted evidence.**\n\n\n" + }, + { + "version": "s22", + "locale": "zh", + "title": "s22: Goal Loop — 终止权从模型移交给目标条件", + "content": "# s22: Goal Loop — 终止权从模型移交给目标条件\n\ns01 → ... → s20 → s21 → `s22`\n\n> *\"一个 turn 能否结束,由目标条件而非模型判定\"* — `/goal` 在主循环的回合收尾处加一道闸门:每个 turn 后,一个独立 evaluator 判断可信证据是否满足条件,不满足就把控制权推回下一轮。\n>\n> **Harness 层**: 目标闭环 — 在 turn 收尾处,加一道 host 拥有的完成闸门。\n\n---\n\n## 问题\n\ns01 到 s21,一个 turn 怎么结束?模型不再发 `tool_use`,循环就 `return`。一次性任务这样没问题——做完就停。\n\n但有些目标要**跨多个 turn 盯到底**:\"把测试跑绿\"\"部署成功为止\"。两种失败都很常见:模型做了一半觉得差不多了就停;或者干脆嘴上说一句 `tests passed` 想收工。你要的是——**这个 turn 能不能结束,不由模型自己说了算,而由一个明确的条件、对着可信证据来判。**\n\n这不是定时(s14 cron),不是后台任务(s13),也不是指望模型自律。是 host 在回合收尾处加一道闸门。\n\n## 解决方案\n\n`/goal <条件>` 设一个 session 级的停止条件。host 把它存进 active goal,每个 turn 结束后,一个独立的小/快模型 evaluator 判断 transcript 里的**可信证据**是否满足条件。不满足 → 闸门挡住这次停止,把一条 continuation 喂进下一轮;满足 → 清除目标,记下达成。\n\n![Goal Loop 总览](/course-assets/s22_goal_loop/goal-loop-overview.svg)\n\n和 s01 的循环比,只多一道判断——模型想停时,先问目标:\n\n```python\n# s01:模型说停就停\nif not has_tool_use(response):\n return\n# s22:想停时,先过目标闸门\nif not has_tool_use(response):\n verdict = goal.evaluate_after_turn()\n if verdict == \"continuing\":\n continue # 没达成 -> 推回去再来一轮\n return # 达成 / 超预算 / 无目标 -> 真停\n```\n\n## 工作原理\n\n### /goal:回合收尾处的一道闸门\n\n`/goal` 是一个 session 级、基于 prompt 的 Stop hook。它不改主循环的形状,只在每个 turn 收尾时插一句 `evaluate_after_turn()`。这道闸门是 **host 拥有的**——不是模型自我约束,模型甚至不知道自己被拦了一道,它只是收到了下一轮的输入。\n\n```python\ndef submit(self, text, origin=None):\n ... # 记录输入、跑一轮 (mock) assistant turn\n return self.goal.evaluate_after_turn() # <-- 回合收尾的 Stop gate\n```\n\n> 真实 Claude Code:`/goal` 是 session 级的 Stop hook,受 workspace trust 和 hook 限制门控;binary 里有 `active_goal`、`goal_status`、`goal_met`、`tengu_goal_achieved` 等 marker。\n\n### 设目标:证据窗口从命令之后开始\n\n`set_goal` 存一个 active goal:目标文本、预算 `max_turns`、计数器,还有 `start_index`——**证据窗口的起点**。它取当前 transcript 长度,所以 `/goal` 命令那一行已经落在窗口外。这是第一道防线:命令文本自己不能满足自己。\n\n```python\ndef set_goal(self, objective, max_turns=20):\n self.active = {\n \"objective\": objective, \"status\": \"active\",\n \"start_index\": len(self.transcript), # 证据窗口从这里开始;命令本身已在窗口外\n \"max_turns\": max_turns, \"checks\": 0, \"continuation_turns\": 0,\n }\n```\n\n> 真实 Claude Code:`GoalRuntime.setGoal()` 存 activeGoal、startIndex、计数器与预算;提交后再 `resetEvidenceStart()` 把窗口对齐到命令之后。\n\n### evaluator 判定:只认可信证据\n\n这是整个机制的核心。evaluator 不看整段对话,只看证据窗口里**可信来源**的消息。三道过滤层层把\"看着像达成、其实不算\"的文本挡在外面:\n\n```python\nTRUSTED_EVIDENCE_ORIGINS = {\"task-notification\", \"monitor-line\"}\n\ndef evidence_text(self):\n out = []\n for m in self.transcript[self.active[\"start_index\"]:]:\n if m.origin.get(\"kind\") == \"slash-command\": # 1 slash 来源不算\n continue\n if m.role == \"user\" and m.content.strip().startswith(\"/goal\"): # 2 命令文本不算\n continue\n if m.origin.get(\"kind\") not in TRUSTED_EVIDENCE_ORIGINS: # 3 只认可信来源\n continue\n out.append(f\"{m.role}: {m.content}\")\n return \"\\n\".join(out)\n```\n\n效果:一句 `tests passed`,user 打字说的不算,`task-notification` 带来的才算。模型糊弄不过去——它没法凭一句自述把目标判成达成。教学版 `goal_satisfied()` 是确定性的关键词匹配;真实版把证据窗口交给一个小/快模型来判。\n\n> 真实 Claude Code:evaluator 是与 worker 分离的 small/fast model(marker `evaluatorModel`、`default small fast model`),判 transcript 证据而非任意可信度。\n\n### 闸门三态:完成 / 继续 / 超预算\n\n`evaluate_after_turn` 每轮跑一次,三种出口:满足就清除目标(completed);没满足且预算没用完,就往队列塞一条 continuation 并放行下一轮(continuing);预算耗尽就停(blocked),避免一个判不出来的目标无限刷下去。\n\n```python\ndef evaluate_after_turn(self):\n g = self.active\n g[\"checks\"] += 1\n if self.goal_satisfied():\n g[\"status\"] = \"completed\"; self.active = None\n return \"completed\" # 达成 -> 清除目标\n if g[\"continuation_turns\"] < g[\"max_turns\"]:\n g[\"continuation_turns\"] += 1\n self.queue.enqueue(\n value=\"Continue working ... do not treat this reminder as completion evidence.\",\n origin={\"kind\": \"active-goal\"})\n return \"continuing\" # 没达成 -> 入队 continuation\n g[\"status\"] = \"blocked\"; self.active = None\n return \"blocked\" # 超预算 -> 放行,不再拦\n```\n\n那条 continuation 自带一句 `do not treat this reminder as completion evidence`——连提醒文本本身也被排除在证据之外。三道防误判到齐:命令文本、提醒文本、普通文本,都不算达成。\n\n> 真实 Claude Code:`evaluateAfterTurn` 发 `goal_evaluated`,按结果 complete / 入队 continuation / block;默认预算 `20`。\n\n### continuation 与外部 async inbox 分流\n\ncontinuation 进的是同一个 `CommandQueue`,但它和外部异步事件(task 完成通知、monitor 行)**不是同一种 drain**。`dequeue` 带一个开关:外部 inbox 的 drain 默认跳过 active-goal 的 continuation。\n\n```python\ndef dequeue(self, include_goal_continuations=True):\n ...\n for idx, item in enumerate(self.items):\n if include_goal_continuations or item[\"origin\"].get(\"kind\") != \"active-goal\":\n return self.items.pop(idx)\n return None\n```\n\n为什么要分开:real-model 测试里发现过一个 bug——模型把 continuation 当成外部通知一起 drain,结果在后台证据还没到之前就把目标判死了。分流之后,goal 的推进是显式的一步,不会被异步事件裹挟。\n\n> 真实 Claude Code:`drainCommandQueue` 默认 `includeGoalContinuations=false`,把 active-goal continuation 和外部 async inbox drain 分开。\n\n### 合起来跑\n\n`code.py` 演示一个 `/goal until tests passed and deploy green`:设目标后没有可信证据 → 闸门一轮轮把它推回;user 直接打 `tests passed` 也不算(来源不可信);直到一个后台任务发来 `task-notification`,证据到位 → completed。再加一个 `max_turns=2` 的小目标演示 blocked。\n\n```python\ns.submit(\"/goal until tests passed and deploy green\") # 设目标,窗口在命令后\ns.submit(\"tests passed, trust me\") # 普通文本 -> 不算达成\ns.submit(\"tests passed; deploy green\",\n origin={\"kind\": \"task-notification\"}) # 可信证据 -> completed\n```\n\n## 相对 s21 的变更\n\n| | s21 Workflow Runtime | s22 Goal Loop |\n|--|---------------------|---------------|\n| 触发方式 | 脚本控制的编排(脱离主循环) | 条件控制的继续(重入主循环) |\n| 加在哪 | tool layer:一个 `Workflow` 工具 | turn 收尾:一道完成闸门 |\n| 谁决定停 | 脚本跑完即止 | 目标条件对着可信证据判 |\n| 新增机制 | 脚本 DSL、后台 task、journal/resume、结构化输出 | 目标闸门、证据信任边界、continuation 分流、预算 |\n\ns21 是把编排写成脚本、扇出去脱离主循环;s22 反过来,是一股力量把控制权**重入**主循环——目标没达成,turn 就不算结束。两者都不改 s01 那个 `while`,只是从两头给它加压。\n\n## 试一下\n\n```bash\npython s22_goal_loop/code.py # /goal until tests pass + deploy green,看闸门怎么判\n```\n\n观察:设目标后,每个 turn 后都有一条 `goal_evaluated`;普通文本 `satisfied=False`,`task-notification` 来源 `satisfied=True`;预算耗尽时 `goal_blocked`。同一句 `tests passed`,来源不同,判定相反——这就是 `/goal` 不被一句空话糊弄的地方。\n\n## 接下来\n\n`/goal` 是\"重入主循环\"的一种触发:条件控制。它和 s21 的\"脱离主循环\"正好成对——一个把工作扇出去,一个把控制权拉回来。再往外,还有时间控制(`/loop`、cron)和事件控制(`Monitor`)的重入,它们共享同一套 task / 通知基底;但闸门的核心已经在这里:**停不停,不由模型一句话说了算,而由目标对着可信证据来判。**\n\n\n" + }, + { + "version": "s22", + "locale": "ja", + "title": "s22: Goal Loop — 終了を決めるのは目標、モデルではない", + "content": "# s22: Goal Loop — 終了を決めるのは目標、モデルではない\n\ns01 → ... → s20 → s21 → `s22`\n\n> *\"turn を終えてよいかは、モデルではなく目標条件が判定する\"* — `/goal` は turn の終わり際に 1 つの gate を挿す:毎 turn 後、独立した evaluator が信頼できる証拠が条件を満たすか判定し、満たさなければ制御を次の turn へ押し戻す。\n>\n> **Harness 層**: 目標ループ — turn の境界に、host が所有する完了 gate を 1 つ加える。\n\n---\n\n## 問題\n\ns01 から s21 まで、turn はどう終わるか? model が `tool_use` を出さなくなると loop は `return` する。一度きりの task ならこれでいい——終わったら終わり。\n\nしかし一部の目標は **複数の turn にまたがって追い続ける** 必要がある:「テストを green にする」「deploy が成功するまで」。よくある失敗は 2 つ:model が半分やって「まあ十分」と止まる;あるいは `tests passed` と打つだけで切り上げようとする。欲しいのは——**この turn を終えてよいかは model の判断ではなく、明示的な条件が信頼できる証拠に対して判定する**ことだ。\n\nこれは timer(s14 cron)でも、background task(s13)でも、model の自制でもない。host が turn の境界に加える gate だ。\n\n## 解決策\n\n`/goal <条件>` は session スコープの停止条件を設定する。host はそれを active goal として保存し、毎 turn 後、独立した small/fast の evaluator model が transcript の中の**信頼できる証拠**が条件を満たすか判定する。満たさない → gate がこの停止を塞ぎ、continuation を次の turn に送り込む;満たす → goal を clear し、達成を記録する。\n\n![Goal Loop 概観](/course-assets/s22_goal_loop/goal-loop-overview.svg)\n\ns01 の loop と比べると、増えるのは判定 1 つだけ——model が止まりたいとき、まず目標に訊く:\n\n```python\n# s01:model が止まると言えば止まる\nif not has_tool_use(response):\n return\n# s22:止まりたいとき、まず目標 gate を通す\nif not has_tool_use(response):\n verdict = goal.evaluate_after_turn()\n if verdict == \"continuing\":\n continue # 未達成 -> 押し戻してもう 1 turn\n return # 達成 / 予算超過 / 目標なし -> 本当に止まる\n```\n\n## 仕組み\n\n### /goal:turn 境界の 1 つの gate\n\n`/goal` は session スコープの prompt ベース Stop hook だ。main loop の形は変えず、各 turn の終わりに `evaluate_after_turn()` を 1 つ差し込むだけ。この gate は **host が所有する**——model の自制ではない。model は 1 turn 引き止められたことすら知らず、ただ次の入力を受け取るだけだ。\n\n```python\ndef submit(self, text, origin=None):\n ... # 入力を記録、1 回の (mock) assistant turn を走らせる\n return self.goal.evaluate_after_turn() # <-- turn 境界の Stop gate\n```\n\n> 実際の Claude Code:`/goal` は session スコープの Stop hook で、workspace trust と hook 制限で門制される;binary に `active_goal`、`goal_status`、`goal_met`、`tengu_goal_achieved` などの marker がある。\n\n### 目標を設定:証拠ウィンドウは命令の後から始まる\n\n`set_goal` は active goal を保存する:目標テキスト、予算 `max_turns`、カウンタ、そして `start_index`——**証拠ウィンドウの起点**だ。現在の transcript 長を取るので、`/goal` 命令の行はすでにウィンドウの外にある。これが第一の防御:命令テキストは自分自身を満たせない。\n\n```python\ndef set_goal(self, objective, max_turns=20):\n self.active = {\n \"objective\": objective, \"status\": \"active\",\n \"start_index\": len(self.transcript), # 証拠ウィンドウはここから;命令自体はすでに外\n \"max_turns\": max_turns, \"checks\": 0, \"continuation_turns\": 0,\n }\n```\n\n> 実際の Claude Code:`GoalRuntime.setGoal()` が activeGoal、startIndex、カウンタ、予算を保存する;提出後に `resetEvidenceStart()` でウィンドウを命令の直後に揃える。\n\n### evaluator の判定:信頼できる証拠だけを認める\n\nここが核心だ。evaluator は会話全体を見ず、証拠ウィンドウ内の**信頼できる origin** のメッセージだけを見る。3 つのフィルタが「達成に見えて実は違う」テキストを締め出す:\n\n```python\nTRUSTED_EVIDENCE_ORIGINS = {\"task-notification\", \"monitor-line\"}\n\ndef evidence_text(self):\n out = []\n for m in self.transcript[self.active[\"start_index\"]:]:\n if m.origin.get(\"kind\") == \"slash-command\": # 1 slash-command 由来は不可\n continue\n if m.role == \"user\" and m.content.strip().startswith(\"/goal\"): # 2 /goal 命令行は不可\n continue\n if m.origin.get(\"kind\") not in TRUSTED_EVIDENCE_ORIGINS: # 3 信頼できる origin だけ\n continue\n out.append(f\"{m.role}: {m.content}\")\n return \"\\n\".join(out)\n```\n\n効果:user が打った `tests passed` は認められず、`task-notification` が運んできた同じ行は認められる。model は誤魔化せない——自分の一言で goal を「達成」にはできない。教学版の `goal_satisfied()` は決定的な keyword チェック;実際の版はウィンドウを small/fast model に渡す。\n\n> 実際の Claude Code:evaluator は worker と分離した small/fast model(marker `evaluatorModel`、`default small fast model`)で、任意の尤もらしさではなく transcript 証拠を判定する。\n\n### gate の 3 状態:completed / continuing / blocked\n\n`evaluate_after_turn` は turn ごとに 1 回走り、出口は 3 つ:満たせば goal を clear(completed);満たさず予算が残っていれば continuation を enqueue して次の turn を走らせる(continuing);予算を使い切れば止める(blocked)——判定できない goal が無限に回り続けないように。\n\n```python\ndef evaluate_after_turn(self):\n g = self.active\n g[\"checks\"] += 1\n if self.goal_satisfied():\n g[\"status\"] = \"completed\"; self.active = None\n return \"completed\" # 達成 -> goal を clear\n if g[\"continuation_turns\"] < g[\"max_turns\"]:\n g[\"continuation_turns\"] += 1\n self.queue.enqueue(\n value=\"Continue working ... do not treat this reminder as completion evidence.\",\n origin={\"kind\": \"active-goal\"})\n return \"continuing\" # 未達成 -> continuation を enqueue\n g[\"status\"] = \"blocked\"; self.active = None\n return \"blocked\" # 予算超過 -> もう塞がない\n```\n\nその continuation は `do not treat this reminder as completion evidence` という一文を自ら抱える——だから reminder テキスト自体も証拠から除外される。3 つの誤判定防御が揃う:命令テキスト、reminder テキスト、ただのテキスト、どれも達成にはならない。\n\n> 実際の Claude Code:`evaluateAfterTurn` は `goal_evaluated` を出し、結果に応じて complete / continuation を enqueue / block する;デフォルト予算は `20`。\n\n### continuation と外部 async inbox の分流\n\ncontinuation は同じ `CommandQueue` に入るが、外部の async イベント(task 完了通知、monitor 行)とは **同じ drain ではない**。`dequeue` は switch を取る:外部 inbox の drain はデフォルトで active-goal の continuation を飛ばす。\n\n```python\ndef dequeue(self, include_goal_continuations=True):\n ...\n for idx, item in enumerate(self.items):\n if include_goal_continuations or item[\"origin\"].get(\"kind\") != \"active-goal\":\n return self.items.pop(idx)\n return None\n```\n\nなぜ分けるか:real-model テストで bug が見つかった——model が continuation を外部通知のように一緒に drain し、background 証拠が届く前に goal を死んだと判定してしまった。分流後は、goal の前進は明示的な一歩になり、async イベントに引きずられない。\n\n> 実際の Claude Code:`drainCommandQueue` はデフォルトで `includeGoalContinuations=false`、active-goal continuation と外部 async inbox drain を分ける。\n\n### まとめて走らせる\n\n`code.py` は `/goal until tests passed and deploy green` を走らせる:goal 設定後はまだ信頼できる証拠がない → gate が turn ごとに押し戻す;user が `tests passed` と打っても認められない(origin が信頼できない);background task が `task-notification` を届けると証拠が揃う → completed。さらに `max_turns=2` の goal で blocked を示す。\n\n```python\ns.submit(\"/goal until tests passed and deploy green\") # goal 設定、ウィンドウは命令の後\ns.submit(\"tests passed, trust me\") # ただのテキスト -> 達成にならない\ns.submit(\"tests passed; deploy green\",\n origin={\"kind\": \"task-notification\"}) # 信頼できる証拠 -> completed\n```\n\n## s21 からの変更\n\n| | s21 Workflow Runtime | s22 Goal Loop |\n|--|---------------------|---------------|\n| トリガー | スクリプト制御のオーケストレーション(main loop を離れる) | 条件制御の継続(main loop に再入する) |\n| どこに付くか | tool layer:`Workflow` ツール 1 つ | turn 境界:完了 gate 1 つ |\n| 誰が止めるか決めるか | スクリプトが終われば終わり | 目標条件が信頼できる証拠に対して判定 |\n| 新しい仕組み | script DSL、background task、journal/resume、構造化出力 | 目標 gate、証拠の信頼境界、continuation 分流、予算 |\n\ns21 はオーケストレーションを script として書き、仕事を扇出して main loop から離す;s22 はその逆——制御を main loop に**再入**させる力だ:goal が満たされるまで、turn を終わらせない。どちらも s01 の `while` は変えず、両側から圧をかけるだけだ。\n\n## 試す\n\n```bash\npython s22_goal_loop/code.py # /goal until tests pass + deploy green、gate の判定を見る\n```\n\n観察:goal 設定後、毎 turn の終わりに `goal_evaluated` が出る;ただのテキストは `satisfied=False`、`task-notification` 由来は `satisfied=True`;予算を使い切ると `goal_blocked`。同じ `tests passed` でも、origin が違えば判定は逆になる——これが `/goal` が一言では騙されない理由だ。\n\n## これから\n\n`/goal` は「main loop に再入する」トリガーの 1 つ:条件制御だ。s21 の「main loop を離れる」とちょうど対になる——一方は仕事を扇出し、もう一方は制御を引き戻す。さらに外には時間制御(`/loop`、cron)と事件制御(`Monitor`)の再入があり、同じ task / 通知の基盤を共有する;だが gate の核心はすでにここにある:**止まるか否かは model の一言ではなく、目標が信頼できる証拠に対して判定する。**\n\n\n" } ] \ No newline at end of file diff --git a/web/src/data/generated/versions.json b/web/src/data/generated/versions.json index 676a20aa5..d9510a6cd 100644 --- a/web/src/data/generated/versions.json +++ b/web/src/data/generated/versions.json @@ -98,10 +98,6 @@ "layer": "tools", "source": "#!/usr/bin/env python3\n\"\"\"\ns02: Tool Use — 在 s01 基础上新增 4 个工具 + 分发映射。\n\n运行: python s02_tool_use/code.py\n需要: pip install anthropic python-dotenv + .env 中配置 ANTHROPIC_API_KEY\n\n本文件 = s01 的全部代码 + 以下新增:\n + run_read / run_write / run_edit / run_glob 四个工具实现\n + TOOL_HANDLERS 分发映射(替代 s01 中硬编码的 run_bash 调用)\n + safe_path 路径安全校验\n\n循环本身(agent_loop)与 s01 完全一致。\n\"\"\"\n\nimport os, subprocess\nfrom pathlib import Path\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\n readline.parse_and_bind('set input-meta on')\n readline.parse_and_bind('set output-meta on')\n readline.parse_and_bind('set convert-meta off')\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\nSYSTEM = f\"You are a coding agent at {WORKDIR}. Use tools to solve tasks. Act, don't explain.\"\n\n\n# ═══════════════════════════════════════════════════════════\n# FROM s01 (unchanged)\n# ═══════════════════════════════════════════════════════════\n\ndef run_bash(command: str) -> str:\n dangerous = [\"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\", \"> /dev/\"]\n if any(d in command for d in dangerous):\n return \"Error: Dangerous command blocked\"\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True,\n encoding=\"utf-8\", errors=\"replace\", timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n except (FileNotFoundError, OSError) as e:\n return f\"Error: {e}\"\n\n\n# ═══════════════════════════════════════════════════════════\n# NEW in s02: 4 个新工具\n# ═══════════════════════════════════════════════════════════\n\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR):\n raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\n\ndef run_read(path: str, limit: int | None = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_write(path: str, content: str) -> str:\n try:\n file_path = safe_path(path)\n file_path.parent.mkdir(parents=True, exist_ok=True)\n file_path.write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_edit(path: str, old_text: str, new_text: str) -> str:\n try:\n file_path = safe_path(path)\n text = file_path.read_text()\n if old_text not in text:\n return f\"Error: text not found in {path}\"\n file_path.write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_glob(pattern: str) -> str:\n import glob as g\n try:\n results = []\n for match in g.glob(pattern, root_dir=WORKDIR):\n if (WORKDIR / match).resolve().is_relative_to(WORKDIR):\n results.append(match)\n return \"\\n\".join(results) if results else \"(no matches)\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\n# ═══════════════════════════════════════════════════════════\n# NEW in s02: 工具定义(s01 只有一个 bash,现在扩展到 5 个)\n# ═══════════════════════════════════════════════════════════\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"limit\": {\"type\": \"integer\"}}, \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"content\": {\"type\": \"string\"}}, \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"old_text\": {\"type\": \"string\"}, \"new_text\": {\"type\": \"string\"}}, \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"pattern\": {\"type\": \"string\"}}, \"required\": [\"pattern\"]}},\n]\n\n# ═══════════════════════════════════════════════════════════\n# NEW in s02: 工具分发映射(s01 是硬编码 run_bash,现在改为查表)\n# ═══════════════════════════════════════════════════════════\n\nTOOL_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"edit_file\": run_edit, \"glob\": run_glob,\n}\n\n\n# ═══════════════════════════════════════════════════════════\n# agent_loop — 与 s01 结构完全一致,只改了工具执行那部分\n# s01: output = run_bash(block.input[\"command\"])\n# s02: output = TOOL_HANDLERS[block.name](**block.input)\n# ═══════════════════════════════════════════════════════════\n\ndef agent_loop(messages: list):\n while True:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n print(f\"\\033[33m> {block.name}\\033[0m\")\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n print(str(output)[:200])\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output})\n\n messages.append({\"role\": \"user\", \"content\": results})\n\n\nif __name__ == \"__main__\":\n print(\"s02: Tool Use — 在 s01 基础上加了 4 个工具\")\n print(\"输入问题,回车发送。输入 q 退出。\\n\")\n\n history = []\n while True:\n try:\n query = input(\"\\033[36ms02 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt):\n break\n if query.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history)\n for block in history[-1][\"content\"]:\n if getattr(block, \"type\", None) == \"text\":\n print(block.text)\n print()\n", "images": [ - { - "src": "/course-assets/s02_tool_use/concurrency-comparison.svg", - "alt": "concurrency comparison" - }, { "src": "/course-assets/s02_tool_use/tool-dispatch.svg", "alt": "tool dispatch" @@ -664,7 +660,7 @@ "filename": "s08_context_compact/code.py", "title": "Context Compact", "subtitle": "Context Will Fill Up", - "loc": 382, + "loc": 414, "tools": [ "bash", "read_file", @@ -755,7 +751,7 @@ }, { "name": "spawn_subagent", - "signature": "def spawn_subagent(task: str)", + "signature": "def spawn_subagent(description: str)", "startLine": 225 }, { @@ -763,74 +759,89 @@ "signature": "def estimate_size(msgs)", "startLine": 269 }, + { + "name": "_block_type", + "signature": "def _block_type(block)", + "startLine": 271 + }, + { + "name": "_message_has_tool_use", + "signature": "def _message_has_tool_use(msg)", + "startLine": 275 + }, + { + "name": "_is_tool_result_message", + "signature": "def _is_tool_result_message(msg)", + "startLine": 284 + }, { "name": "snip_compact", "signature": "def snip_compact(messages, max_messages=50)", - "startLine": 273 + "startLine": 295 }, { "name": "collect_tool_results", "signature": "def collect_tool_results(messages)", - "startLine": 281 + "startLine": 313 }, { "name": "micro_compact", "signature": "def micro_compact(messages)", - "startLine": 290 + "startLine": 322 }, { "name": "persist_large_output", "signature": "def persist_large_output(tool_use_id, output)", - "startLine": 300 + "startLine": 332 }, { "name": "tool_result_budget", "signature": "def tool_result_budget(messages, max_bytes=200_000)", - "startLine": 307 + "startLine": 339 }, { "name": "write_transcript", "signature": "def write_transcript(messages)", - "startLine": 325 + "startLine": 357 }, { "name": "summarize_history", "signature": "def summarize_history(messages)", - "startLine": 332 + "startLine": 364 }, { "name": "compact_history", "signature": "def compact_history(messages)", - "startLine": 343 + "startLine": 375 }, { "name": "reactive_compact", "signature": "def reactive_compact(messages)", - "startLine": 351 + "startLine": 383 }, { "name": "trigger_hooks", "signature": "def trigger_hooks(event, *args)", - "startLine": 391 + "startLine": 428 }, { "name": "permission_hook", "signature": "def permission_hook(block)", - "startLine": 398 + "startLine": 435 }, { "name": "log_hook", "signature": "def log_hook(block)", - "startLine": 403 + "startLine": 440 }, { "name": "agent_loop", "signature": "def agent_loop(messages: list)", - "startLine": 417 + "startLine": 454 } ], "layer": "memory", - "source": "#!/usr/bin/env python3\n\"\"\"\ns08_context_compact.py - Context Compact\n\nFour-layer compaction pipeline inserted before LLM calls:\n\n L1: snip_compact — trim middle messages when count > 50\n L2: micro_compact — replace old tool_results with placeholders\n L3: tool_result_budget — persist large results to disk\n L4: compact_history — LLM full summary (1 API call)\n\n Emergency: reactive_compact — when API still returns prompt_too_long\n\n ┌─────────────────────────────────────────────────────────────┐\n │ messages[] │\n │ ↓ │\n │ L3 budget ─→ L1 snip ─→ L2 micro ─→ [token > threshold?] │\n │ ├─ No → LLM │\n │ └─ Yes → L4 summary │\n │ ↓ │\n │ LLM call │\n │ [prompt_too_long?] │\n │ └─ Yes → reactive │\n └─────────────────────────────────────────────────────────────┘\n\nCore principle: cheap first, expensive last.\nExecution order matches CC source: budget → snip → micro → auto.\n\nBuilds on s07 (skill loading). Usage:\n\n python s08_context_compact/code.py\n Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env\n\"\"\"\n\nimport ast, json, os, subprocess, time\nfrom pathlib import Path\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"): os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nSKILLS_DIR = WORKDIR / \"skills\"\nTRANSCRIPT_DIR = WORKDIR / \".transcripts\"\nTOOL_RESULTS_DIR = WORKDIR / \".task_outputs\" / \"tool-results\"\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\nCURRENT_TODOS: list[dict] = []\n\n# s07: Skill catalog scan (inherited from s07)\ndef _parse_frontmatter(text: str) -> tuple[dict, str]:\n if not text.startswith(\"---\"):\n return {}, text\n parts = text.split(\"---\", 2)\n if len(parts) < 3:\n return {}, text\n meta = {}\n for line in parts[1].strip().splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip().strip('\"').strip(\"'\")\n return meta, parts[2].strip()\n\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills()\n\ndef list_skills() -> str:\n if not SKILL_REGISTRY:\n return \"(no skills found)\"\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n\n# s08: SYSTEM includes skill catalog (inherited from s07 build_system)\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n\n# s08: subagent gets its own system prompt — no compact, no skill loading\nSUB_SYSTEM = (\n f\"You are a coding agent at {WORKDIR}. \"\n \"Complete the task you were given, then return a concise summary. \"\n \"Do not delegate further.\"\n)\n\n\n# ═══════════════════════════════════════════════════════════\n# FROM s02-s07 (unchanged): Basic Tools\n# ═══════════════════════════════════════════════════════════\n\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR): raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\ndef run_bash(command: str) -> str:\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired: return \"Error: Timeout (120s)\"\n\ndef run_read(path: str, limit: int | None = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines): lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e: return f\"Error: {e}\"\n\ndef run_write(path: str, content: str) -> str:\n try:\n file_path = safe_path(path); file_path.parent.mkdir(parents=True, exist_ok=True)\n file_path.write_text(content); return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e: return f\"Error: {e}\"\n\ndef run_edit(path: str, old_text: str, new_text: str) -> str:\n try:\n file_path = safe_path(path)\n text = file_path.read_text()\n if old_text not in text: return f\"Error: text not found in {path}\"\n file_path.write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n except Exception as e: return f\"Error: {e}\"\n\ndef run_glob(pattern: str) -> str:\n import glob as g\n try:\n results = []\n for match in g.glob(pattern, root_dir=WORKDIR):\n if (WORKDIR / match).resolve().is_relative_to(WORKDIR):\n results.append(match)\n return \"\\n\".join(results) if results else \"(no matches)\"\n except Exception as e: return f\"Error: {e}\"\n\ndef _normalize_todos(todos):\n if isinstance(todos, str):\n try:\n todos = json.loads(todos)\n except json.JSONDecodeError:\n try:\n todos = ast.literal_eval(todos)\n except (SyntaxError, ValueError):\n return None, \"Error: todos must be a list or JSON array string\"\n if not isinstance(todos, list):\n return None, \"Error: todos must be a list\"\n for i, t in enumerate(todos):\n if not isinstance(t, dict):\n return None, f\"Error: todos[{i}] must be an object\"\n if \"content\" not in t or \"status\" not in t:\n return None, f\"Error: todos[{i}] missing 'content' or 'status'\"\n if t[\"status\"] not in (\"pending\", \"in_progress\", \"completed\"):\n return None, f\"Error: todos[{i}] has invalid status '{t['status']}'\"\n return todos, None\n\ndef run_todo_write(todos: list) -> str:\n global CURRENT_TODOS\n todos, error = _normalize_todos(todos)\n if error:\n return error\n CURRENT_TODOS = todos\n lines = [\"\\n\\033[33m## Current Tasks\\033[0m\"]\n for t in CURRENT_TODOS:\n icon = {\"pending\": \" \", \"in_progress\": \"\\033[36m▸\\033[0m\", \"completed\": \"\\033[32m✓\\033[0m\"}[t[\"status\"]]\n lines.append(f\" [{icon}] {t['content']}\")\n print(\"\\n\".join(lines))\n return f\"Updated {len(CURRENT_TODOS)} tasks\"\n\ndef extract_text(content) -> str:\n if not isinstance(content, list): return str(content)\n return \"\\n\".join(getattr(b, \"text\", \"\") for b in content if getattr(b, \"type\", None) == \"text\")\n\n\n# ═══════════════════════════════════════════════════════════\n# FROM s06-s07 (unchanged): Subagent\n# ═══════════════════════════════════════════════════════════\n\nSUB_TOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"content\": {\"type\": \"string\"}}, \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"old_text\": {\"type\": \"string\"}, \"new_text\": {\"type\": \"string\"}}, \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"pattern\": {\"type\": \"string\"}}, \"required\": [\"pattern\"]}},\n]\nSUB_HANDLERS = {\"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"edit_file\": run_edit, \"glob\": run_glob}\n\ndef spawn_subagent(task: str) -> str:\n print(f\"\\n\\033[35m[Subagent spawned]\\033[0m\")\n messages = [{\"role\": \"user\", \"content\": task}]\n for _ in range(30):\n response = client.messages.create(model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=SUB_TOOLS, max_tokens=8000)\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n trigger_hooks(\"PostToolUse\", block, output)\n print(f\" \\033[90m[sub] {block.name}: {str(output)[:100]}\\033[0m\")\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n result = extract_text(messages[-1][\"content\"])\n if not result:\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\":\n result = extract_text(msg[\"content\"])\n if result:\n break\n if not result:\n result = \"Subagent stopped after 30 turns without final answer.\"\n print(f\"\\033[35m[Subagent done]\\033[0m\")\n return result\n\n\n# ═══════════════════════════════════════════════════════════\n# NEW in s08: Four-Layer Compaction Pipeline\n# ═══════════════════════════════════════════════════════════\n\nCONTEXT_LIMIT = 50000\nKEEP_RECENT = 3\nPERSIST_THRESHOLD = 30000\n\ndef estimate_size(msgs): return len(str(msgs))\n\n\n# L1: snipCompact — trim middle messages\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages: return messages\n keep_head, keep_tail = 3, max_messages - 3\n snipped = len(messages) - keep_head - keep_tail\n return messages[:keep_head] + [{\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}] + messages[-keep_tail:]\n\n\n# L2: microCompact — old result placeholders\ndef collect_tool_results(messages):\n blocks = []\n for mi, msg in enumerate(messages):\n if msg.get(\"role\") != \"user\" or not isinstance(msg.get(\"content\"), list): continue\n for bi, block in enumerate(msg[\"content\"]):\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n blocks.append((mi, bi, block))\n return blocks\n\ndef micro_compact(messages):\n tool_results = collect_tool_results(messages)\n if len(tool_results) <= KEEP_RECENT: return messages\n for _, _, block in tool_results[:-KEEP_RECENT]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n\n\n# L3: toolResultBudget — persist large results to disk\ndef persist_large_output(tool_use_id, output):\n if len(output) <= PERSIST_THRESHOLD: return output\n TOOL_RESULTS_DIR.mkdir(parents=True, exist_ok=True)\n path = TOOL_RESULTS_DIR / f\"{tool_use_id}.txt\"\n if not path.exists(): path.write_text(output)\n return f\"\\nFull output: {path}\\nPreview:\\n{output[:2000]}\\n\"\n\ndef tool_result_budget(messages, max_bytes=200_000):\n last = messages[-1] if messages else None\n if not last or last.get(\"role\") != \"user\" or not isinstance(last.get(\"content\"), list): return messages\n blocks = [(i, b) for i, b in enumerate(last[\"content\"]) if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes: return messages\n ranked = sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True)\n for _, block in ranked:\n if total <= max_bytes: break\n content = str(block.get(\"content\", \"\"))\n if len(content) <= PERSIST_THRESHOLD: continue\n tid = block.get(\"tool_use_id\", \"unknown\")\n block[\"content\"] = persist_large_output(tid, content)\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return messages\n\n\n# L4: autoCompact — LLM full summary\ndef write_transcript(messages):\n TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)\n path = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with path.open(\"w\") as f:\n for msg in messages: f.write(json.dumps(msg, default=str) + \"\\n\")\n return path\n\ndef summarize_history(messages):\n conversation = json.dumps(messages, default=str)[:80000]\n prompt = (\"Summarize this coding-agent conversation so work can continue.\\n\"\n \"Preserve: 1. current goal, 2. key findings/decisions, 3. files read/changed, \"\n \"4. remaining work, 5. user constraints.\\nBe compact but concrete.\\n\\n\" + conversation)\n response = client.messages.create(model=MODEL, messages=[{\"role\": \"user\", \"content\": prompt}], max_tokens=2000)\n return \"\\n\".join(\n getattr(block, \"text\", \"\")\n for block in response.content\n if getattr(block, \"type\", None) == \"text\").strip() or \"(empty summary)\"\n\ndef compact_history(messages):\n transcript_path = write_transcript(messages)\n print(f\"[transcript saved: {transcript_path}]\")\n summary = summarize_history(messages)\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n\n\n# Emergency: reactiveCompact — on API error\ndef reactive_compact(messages):\n transcript = write_transcript(messages)\n summary = summarize_history(messages)\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *messages[-5:]]\n\n\n# ═══════════════════════════════════════════════════════════\n# FROM s07: Tool Definitions\n# ═══════════════════════════════════════════════════════════\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"limit\": {\"type\": \"integer\"}}, \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"content\": {\"type\": \"string\"}}, \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"old_text\": {\"type\": \"string\"}, \"new_text\": {\"type\": \"string\"}}, \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"pattern\": {\"type\": \"string\"}}, \"required\": [\"pattern\"]}},\n {\"name\": \"todo_write\", \"description\": \"Create and manage a task list for your current coding session.\",\n \"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\"]}},\n {\"name\": \"task\", \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n {\"name\": \"load_skill\", \"description\": \"Load the full content of a skill by name.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"name\": {\"type\": \"string\"}}, \"required\": [\"name\"]}},\n # s08 change: new compact tool — triggers compact_history, not a no-op\n {\"name\": \"compact\", \"description\": \"Summarize earlier conversation to free context space.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"focus\": {\"type\": \"string\"}}}},\n]\n\nTOOL_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"edit_file\": run_edit, \"glob\": run_glob, \"todo_write\": run_todo_write,\n \"task\": spawn_subagent, \"load_skill\": load_skill,\n}\n\n# FROM s04 (unchanged): Hooks\nHOOKS = {\"PreToolUse\": [], \"PostToolUse\": []}\ndef trigger_hooks(event, *args):\n for cb in HOOKS[event]:\n r = cb(*args)\n if r is not None: return r\n return None\n\nDENY_LIST = [\"rm -rf /\", \"sudo\", \"shutdown\"]\ndef permission_hook(block):\n if block.name == \"bash\":\n for p in DENY_LIST:\n if p in block.input.get(\"command\", \"\"): return \"Permission denied\"\n return None\ndef log_hook(block):\n print(f\"\\033[90m[HOOK] {block.name}\\033[0m\")\n return None\n\nHOOKS[\"PreToolUse\"].append(permission_hook)\nHOOKS[\"PreToolUse\"].append(log_hook)\n\n\n# ═══════════════════════════════════════════════════════════\n# agent_loop — s08 core: run compaction pipeline before LLM\n# ═══════════════════════════════════════════════════════════\n\nMAX_REACTIVE_RETRIES = 1 # retry limit for reactive compact\n\ndef agent_loop(messages: list):\n reactive_retries = 0\n while True:\n # s08 change: three preprocessors (0 API calls, cheap first)\n # Order matches CC source: budget → snip → micro\n messages[:] = tool_result_budget(messages) # L3: persist large results first\n messages[:] = snip_compact(messages) # L1: trim middle\n messages[:] = micro_compact(messages) # L2: old result placeholders\n\n # s08 change: tokens still over threshold → LLM summary (1 API call)\n if estimate_size(messages) > CONTEXT_LIMIT:\n print(\"[auto compact]\")\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000)\n reactive_retries = 0 # reset on successful API call\n except Exception as e:\n if (\"prompt_too_long\" in str(e).lower() or \"too many tokens\" in str(e).lower()) and reactive_retries < MAX_REACTIVE_RETRIES:\n print(\"[reactive compact]\")\n messages[:] = reactive_compact(messages)\n reactive_retries += 1\n continue\n raise\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\": return\n\n results = []\n for block in response.content:\n if block.type != \"tool_use\": continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n\n # s08: compact tool triggers compact_history, not a no-op string\n if block.name == \"compact\":\n messages[:] = compact_history(messages)\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": \"[Compacted. Conversation history has been summarized.]\"})\n messages.append({\"role\": \"user\", \"content\": results})\n break # end current turn, start fresh with compacted context\n\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": str(blocked)})\n continue\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n trigger_hooks(\"PostToolUse\", block, output)\n print(str(output)[:200])\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": str(output)})\n else:\n # normal path: no compact was called\n messages.append({\"role\": \"user\", \"content\": results})\n continue\n # compact was called: results already appended above\n continue\n\n\nif __name__ == \"__main__\":\n print(\"s08: Context Compact — four-layer compaction pipeline\")\n print(\"输入问题,回车发送。输入 q 退出。\\n\")\n history = []\n while True:\n try: query = input(\"\\033[36ms08 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt): break\n if query.strip().lower() in (\"q\", \"exit\", \"\"): break\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history)\n for block in history[-1][\"content\"]:\n if getattr(block, \"type\", None) == \"text\": print(block.text)\n print()\n", + "source": "#!/usr/bin/env python3\n\"\"\"\ns08_context_compact.py - Context Compact\n\nFour-layer compaction pipeline inserted before LLM calls:\n\n L1: snip_compact — trim middle messages when count > 50\n L2: micro_compact — replace old tool_results with placeholders\n L3: tool_result_budget — persist large results to disk\n L4: compact_history — LLM full summary (1 API call)\n\n Emergency: reactive_compact — when API still returns prompt_too_long\n\n ┌─────────────────────────────────────────────────────────────┐\n │ messages[] │\n │ ↓ │\n │ L3 budget ─→ L1 snip ─→ L2 micro ─→ [token > threshold?] │\n │ ├─ No → LLM │\n │ └─ Yes → L4 summary │\n │ ↓ │\n │ LLM call │\n │ [prompt_too_long?] │\n │ └─ Yes → reactive │\n └─────────────────────────────────────────────────────────────┘\n\nCore principle: cheap first, expensive last.\nExecution order matches Claude Code source: budget → snip → micro → auto.\n\nBuilds on s07 (skill loading). Usage:\n\n python s08_context_compact/code.py\n Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env\n\"\"\"\n\nimport ast, json, os, subprocess, time\nfrom pathlib import Path\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"): os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nSKILLS_DIR = WORKDIR / \"skills\"\nTRANSCRIPT_DIR = WORKDIR / \".transcripts\"\nTOOL_RESULTS_DIR = WORKDIR / \".task_outputs\" / \"tool-results\"\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\nCURRENT_TODOS: list[dict] = []\n\n# s07: Skill catalog scan (inherited from s07)\ndef _parse_frontmatter(text: str) -> tuple[dict, str]:\n if not text.startswith(\"---\"):\n return {}, text\n parts = text.split(\"---\", 2)\n if len(parts) < 3:\n return {}, text\n meta = {}\n for line in parts[1].strip().splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip().strip('\"').strip(\"'\")\n return meta, parts[2].strip()\n\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills()\n\ndef list_skills() -> str:\n if not SKILL_REGISTRY:\n return \"(no skills found)\"\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n\n# s08: SYSTEM includes skill catalog (inherited from s07 build_system)\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n\n# s08: subagent gets its own system prompt — no compact, no skill loading\nSUB_SYSTEM = (\n f\"You are a coding agent at {WORKDIR}. \"\n \"Complete the task you were given, then return a concise summary. \"\n \"Do not delegate further.\"\n)\n\n\n# ═══════════════════════════════════════════════════════════\n# FROM s02-s07 (unchanged): Basic Tools\n# ═══════════════════════════════════════════════════════════\n\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR): raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\ndef run_bash(command: str) -> str:\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired: return \"Error: Timeout (120s)\"\n\ndef run_read(path: str, limit: int | None = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines): lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e: return f\"Error: {e}\"\n\ndef run_write(path: str, content: str) -> str:\n try:\n file_path = safe_path(path); file_path.parent.mkdir(parents=True, exist_ok=True)\n file_path.write_text(content); return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e: return f\"Error: {e}\"\n\ndef run_edit(path: str, old_text: str, new_text: str) -> str:\n try:\n file_path = safe_path(path)\n text = file_path.read_text()\n if old_text not in text: return f\"Error: text not found in {path}\"\n file_path.write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n except Exception as e: return f\"Error: {e}\"\n\ndef run_glob(pattern: str) -> str:\n import glob as g\n try:\n results = []\n for match in g.glob(pattern, root_dir=WORKDIR):\n if (WORKDIR / match).resolve().is_relative_to(WORKDIR):\n results.append(match)\n return \"\\n\".join(results) if results else \"(no matches)\"\n except Exception as e: return f\"Error: {e}\"\n\ndef _normalize_todos(todos):\n if isinstance(todos, str):\n try:\n todos = json.loads(todos)\n except json.JSONDecodeError:\n try:\n todos = ast.literal_eval(todos)\n except (SyntaxError, ValueError):\n return None, \"Error: todos must be a list or JSON array string\"\n if not isinstance(todos, list):\n return None, \"Error: todos must be a list\"\n for i, t in enumerate(todos):\n if not isinstance(t, dict):\n return None, f\"Error: todos[{i}] must be an object\"\n if \"content\" not in t or \"status\" not in t:\n return None, f\"Error: todos[{i}] missing 'content' or 'status'\"\n if t[\"status\"] not in (\"pending\", \"in_progress\", \"completed\"):\n return None, f\"Error: todos[{i}] has invalid status '{t['status']}'\"\n return todos, None\n\ndef run_todo_write(todos: list) -> str:\n global CURRENT_TODOS\n todos, error = _normalize_todos(todos)\n if error:\n return error\n CURRENT_TODOS = todos\n lines = [\"\\n\\033[33m## Current Tasks\\033[0m\"]\n for t in CURRENT_TODOS:\n icon = {\"pending\": \" \", \"in_progress\": \"\\033[36m▸\\033[0m\", \"completed\": \"\\033[32m✓\\033[0m\"}[t[\"status\"]]\n lines.append(f\" [{icon}] {t['content']}\")\n print(\"\\n\".join(lines))\n return f\"Updated {len(CURRENT_TODOS)} tasks\"\n\ndef extract_text(content) -> str:\n if not isinstance(content, list): return str(content)\n return \"\\n\".join(getattr(b, \"text\", \"\") for b in content if getattr(b, \"type\", None) == \"text\")\n\n\n# ═══════════════════════════════════════════════════════════\n# FROM s06-s07 (unchanged): Subagent\n# ═══════════════════════════════════════════════════════════\n\nSUB_TOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"content\": {\"type\": \"string\"}}, \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"old_text\": {\"type\": \"string\"}, \"new_text\": {\"type\": \"string\"}}, \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"pattern\": {\"type\": \"string\"}}, \"required\": [\"pattern\"]}},\n]\nSUB_HANDLERS = {\"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"edit_file\": run_edit, \"glob\": run_glob}\n\ndef spawn_subagent(description: str) -> str:\n print(f\"\\n\\033[35m[Subagent spawned]\\033[0m\")\n messages = [{\"role\": \"user\", \"content\": description}]\n for _ in range(30):\n response = client.messages.create(model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=SUB_TOOLS, max_tokens=8000)\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n trigger_hooks(\"PostToolUse\", block, output)\n print(f\" \\033[90m[sub] {block.name}: {str(output)[:100]}\\033[0m\")\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n result = extract_text(messages[-1][\"content\"])\n if not result:\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\":\n result = extract_text(msg[\"content\"])\n if result:\n break\n if not result:\n result = \"Subagent stopped after 30 turns without final answer.\"\n print(f\"\\033[35m[Subagent done]\\033[0m\")\n return result\n\n\n# ═══════════════════════════════════════════════════════════\n# NEW in s08: Four-Layer Compaction Pipeline\n# ═══════════════════════════════════════════════════════════\n\nCONTEXT_LIMIT = 50000\nKEEP_RECENT = 3\nPERSIST_THRESHOLD = 30000\n\ndef estimate_size(msgs): return len(str(msgs))\n\ndef _block_type(block):\n return block.get(\"type\") if isinstance(block, dict) else getattr(block, \"type\", None)\n\n\ndef _message_has_tool_use(msg):\n if msg.get(\"role\") != \"assistant\":\n return False\n content = msg.get(\"content\")\n if not isinstance(content, list):\n return False\n return any(_block_type(block) == \"tool_use\" for block in content)\n\n\ndef _is_tool_result_message(msg):\n if msg.get(\"role\") != \"user\":\n return False\n content = msg.get(\"content\")\n if not isinstance(content, list):\n return False\n return any(isinstance(block, dict) and block.get(\"type\") == \"tool_result\"\n for block in content)\n\n\n# L1: snipCompact — trim middle messages\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages: return messages\n keep_head, keep_tail = 3, max_messages - 3\n head_end, tail_start = keep_head, len(messages) - keep_tail\n if head_end > 0 and _message_has_tool_use(messages[head_end - 1]):\n while head_end < len(messages) and _is_tool_result_message(messages[head_end]):\n head_end += 1\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n if head_end >= tail_start:\n return messages\n snipped = tail_start - head_end\n return messages[:head_end] + [{\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}] + messages[tail_start:]\n\n\n# L2: microCompact — old result placeholders\ndef collect_tool_results(messages):\n blocks = []\n for mi, msg in enumerate(messages):\n if msg.get(\"role\") != \"user\" or not isinstance(msg.get(\"content\"), list): continue\n for bi, block in enumerate(msg[\"content\"]):\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n blocks.append((mi, bi, block))\n return blocks\n\ndef micro_compact(messages):\n tool_results = collect_tool_results(messages)\n if len(tool_results) <= KEEP_RECENT: return messages\n for _, _, block in tool_results[:-KEEP_RECENT]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n\n\n# L3: toolResultBudget — persist large results to disk\ndef persist_large_output(tool_use_id, output):\n if len(output) <= PERSIST_THRESHOLD: return output\n TOOL_RESULTS_DIR.mkdir(parents=True, exist_ok=True)\n path = TOOL_RESULTS_DIR / f\"{tool_use_id}.txt\"\n if not path.exists(): path.write_text(output)\n return f\"\\nFull output: {path}\\nPreview:\\n{output[:2000]}\\n\"\n\ndef tool_result_budget(messages, max_bytes=200_000):\n last = messages[-1] if messages else None\n if not last or last.get(\"role\") != \"user\" or not isinstance(last.get(\"content\"), list): return messages\n blocks = [(i, b) for i, b in enumerate(last[\"content\"]) if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes: return messages\n ranked = sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True)\n for _, block in ranked:\n if total <= max_bytes: break\n content = str(block.get(\"content\", \"\"))\n if len(content) <= PERSIST_THRESHOLD: continue\n tid = block.get(\"tool_use_id\", \"unknown\")\n block[\"content\"] = persist_large_output(tid, content)\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return messages\n\n\n# L4: autoCompact — LLM full summary\ndef write_transcript(messages):\n TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)\n path = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with path.open(\"w\") as f:\n for msg in messages: f.write(json.dumps(msg, default=str) + \"\\n\")\n return path\n\ndef summarize_history(messages):\n conversation = json.dumps(messages, default=str)[:80000]\n prompt = (\"Summarize this coding-agent conversation so work can continue.\\n\"\n \"Preserve: 1. current goal, 2. key findings/decisions, 3. files read/changed, \"\n \"4. remaining work, 5. user constraints.\\nBe compact but concrete.\\n\\n\" + conversation)\n response = client.messages.create(model=MODEL, messages=[{\"role\": \"user\", \"content\": prompt}], max_tokens=2000)\n return \"\\n\".join(\n getattr(block, \"text\", \"\")\n for block in response.content\n if getattr(block, \"type\", None) == \"text\").strip() or \"(empty summary)\"\n\ndef compact_history(messages):\n transcript_path = write_transcript(messages)\n print(f\"[transcript saved: {transcript_path}]\")\n summary = summarize_history(messages)\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n\n\n# Emergency: reactiveCompact — on API error\ndef reactive_compact(messages):\n transcript = write_transcript(messages)\n tail_start = max(0, len(messages) - 5)\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n summary = summarize_history(messages[:tail_start])\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *messages[tail_start:]]\n\n\n# ═══════════════════════════════════════════════════════════\n# FROM s07: Tool Definitions\n# ═══════════════════════════════════════════════════════════\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"limit\": {\"type\": \"integer\"}}, \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"content\": {\"type\": \"string\"}}, \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"old_text\": {\"type\": \"string\"}, \"new_text\": {\"type\": \"string\"}}, \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"pattern\": {\"type\": \"string\"}}, \"required\": [\"pattern\"]}},\n {\"name\": \"todo_write\", \"description\": \"Create and manage a task list for your current coding session.\",\n \"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\"]}},\n {\"name\": \"task\", \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n {\"name\": \"load_skill\", \"description\": \"Load the full content of a skill by name.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"name\": {\"type\": \"string\"}}, \"required\": [\"name\"]}},\n # s08 change: new compact tool — triggers compact_history, not a no-op\n {\"name\": \"compact\", \"description\": \"Summarize earlier conversation to free context space.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"focus\": {\"type\": \"string\"}}}},\n]\n\nTOOL_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"edit_file\": run_edit, \"glob\": run_glob, \"todo_write\": run_todo_write,\n \"task\": spawn_subagent, \"load_skill\": load_skill,\n}\n\n# FROM s04 (unchanged): Hooks\nHOOKS = {\"PreToolUse\": [], \"PostToolUse\": []}\ndef trigger_hooks(event, *args):\n for cb in HOOKS[event]:\n r = cb(*args)\n if r is not None: return r\n return None\n\nDENY_LIST = [\"rm -rf /\", \"sudo\", \"shutdown\"]\ndef permission_hook(block):\n if block.name == \"bash\":\n for p in DENY_LIST:\n if p in block.input.get(\"command\", \"\"): return \"Permission denied\"\n return None\ndef log_hook(block):\n print(f\"\\033[90m[HOOK] {block.name}\\033[0m\")\n return None\n\nHOOKS[\"PreToolUse\"].append(permission_hook)\nHOOKS[\"PreToolUse\"].append(log_hook)\n\n\n# ═══════════════════════════════════════════════════════════\n# agent_loop — s08 core: run compaction pipeline before LLM\n# ═══════════════════════════════════════════════════════════\n\nMAX_REACTIVE_RETRIES = 1 # retry limit for reactive compact\n\ndef agent_loop(messages: list):\n reactive_retries = 0\n while True:\n # s08 change: three preprocessors (0 API calls, cheap first)\n # Order matches Claude Code source: budget → snip → micro\n messages[:] = tool_result_budget(messages) # L3: persist large results first\n messages[:] = snip_compact(messages) # L1: trim middle\n messages[:] = micro_compact(messages) # L2: old result placeholders\n\n # s08 change: tokens still over threshold → LLM summary (1 API call)\n if estimate_size(messages) > CONTEXT_LIMIT:\n print(\"[auto compact]\")\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000)\n reactive_retries = 0 # reset on successful API call\n except Exception as e:\n if (\"prompt_too_long\" in str(e).lower() or \"too many tokens\" in str(e).lower()) and reactive_retries < MAX_REACTIVE_RETRIES:\n print(\"[reactive compact]\")\n messages[:] = reactive_compact(messages)\n reactive_retries += 1\n continue\n raise\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\": return\n\n results = []\n for block in response.content:\n if block.type != \"tool_use\": continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n\n # s08: compact tool triggers compact_history, not a no-op string\n if block.name == \"compact\":\n messages[:] = compact_history(messages)\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": \"[Compacted. Conversation history has been summarized.]\"})\n messages.append({\"role\": \"user\", \"content\": results})\n break # end current turn, start fresh with compacted context\n\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": str(blocked)})\n continue\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n trigger_hooks(\"PostToolUse\", block, output)\n print(str(output)[:200])\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": str(output)})\n else:\n # normal path: no compact was called\n messages.append({\"role\": \"user\", \"content\": results})\n continue\n # compact was called: results already appended above\n continue\n\n\nif __name__ == \"__main__\":\n print(\"s08: Context Compact — four-layer compaction pipeline\")\n print(\"输入问题,回车发送。输入 q 退出。\\n\")\n history = []\n while True:\n try: query = input(\"\\033[36ms08 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt): break\n if query.strip().lower() in (\"q\", \"exit\", \"\"): break\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history)\n for block in history[-1][\"content\"]:\n if getattr(block, \"type\", None) == \"text\": print(block.text)\n print()\n", "images": [ { "src": "/course-assets/s08_context_compact/auto-compact.svg", @@ -859,7 +870,7 @@ "filename": "s09_memory/code.py", "title": "Memory", "subtitle": "Keep a Layer That Doesn't Lose Details", - "loc": 498, + "loc": 528, "tools": [ "bash", "read_file", @@ -931,101 +942,116 @@ { "name": "safe_path", "signature": "def safe_path(p: str)", - "startLine": 360 + "startLine": 358 }, { "name": "run_bash", "signature": "def run_bash(command: str)", - "startLine": 365 + "startLine": 363 }, { "name": "run_read", "signature": "def run_read(path: str, limit: int | None = None)", - "startLine": 372 + "startLine": 370 }, { "name": "run_write", "signature": "def run_write(path: str, content: str)", - "startLine": 379 + "startLine": 377 }, { "name": "run_edit", "signature": "def run_edit(path: str, old_text: str, new_text: str)", - "startLine": 385 + "startLine": 383 }, { "name": "run_glob", "signature": "def run_glob(pattern: str)", - "startLine": 394 + "startLine": 392 }, { "name": "extract_text", "signature": "def extract_text(content)", - "startLine": 404 + "startLine": 402 }, { "name": "spawn_subagent", - "signature": "def spawn_subagent(task: str)", - "startLine": 419 + "signature": "def spawn_subagent(description: str)", + "startLine": 417 }, { "name": "estimate_size", "signature": "def estimate_size(msgs)", + "startLine": 450 + }, + { + "name": "_block_type", + "signature": "def _block_type(block)", "startLine": 452 }, + { + "name": "_message_has_tool_use", + "signature": "def _message_has_tool_use(msg)", + "startLine": 455 + }, + { + "name": "_is_tool_result_message", + "signature": "def _is_tool_result_message(msg)", + "startLine": 463 + }, { "name": "snip_compact", "signature": "def snip_compact(msgs, mx=50)", - "startLine": 454 + "startLine": 471 }, { "name": "collect_tool_results", "signature": "def collect_tool_results(msgs)", - "startLine": 458 + "startLine": 485 }, { "name": "micro_compact", "signature": "def micro_compact(msgs)", - "startLine": 466 + "startLine": 493 }, { "name": "persist_large", "signature": "def persist_large(tid, out)", - "startLine": 473 + "startLine": 500 }, { "name": "tool_result_budget", "signature": "def tool_result_budget(msgs, mx=200_000)", - "startLine": 480 + "startLine": 507 }, { "name": "write_transcript", "signature": "def write_transcript(msgs)", - "startLine": 494 + "startLine": 521 }, { "name": "summarize_history", "signature": "def summarize_history(msgs)", - "startLine": 501 + "startLine": 528 }, { "name": "compact_history", "signature": "def compact_history(msgs)", - "startLine": 509 + "startLine": 536 }, { "name": "reactive_compact", "signature": "def reactive_compact(msgs)", - "startLine": 514 + "startLine": 541 }, { "name": "agent_loop", "signature": "def agent_loop(messages: list)", - "startLine": 551 + "startLine": 583 } ], "layer": "memory", - "source": "#!/usr/bin/env python3\n\"\"\"\ns09_memory.py - Memory System\n\nPersistent, cross-session knowledge for the coding agent.\n\nStorage:\n .memory/\n MEMORY.md ← index (one line per memory, ≤200 lines)\n feedback_tabs.md ← individual memory files (Markdown + YAML frontmatter)\n user_profile.md\n project_facts.md\n\nFlow in agent_loop:\n 1. Load MEMORY.md index into SYSTEM prompt (cheap, always present)\n 2. Select relevant memories by filename/description → inject content\n 3. Run compression pipeline from s08\n 4. After each turn ends → extract new memories from original messages\n 5. Periodically consolidate (Dream)\n\nBuilds on s08 (context compact). Usage:\n\n python s09_memory/code.py\n Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env\n\"\"\"\n\nimport os, subprocess, json, time, re\nfrom pathlib import Path\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"): os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nMEMORY_DIR = WORKDIR / \".memory\"; MEMORY_DIR.mkdir(exist_ok=True)\nMEMORY_INDEX = MEMORY_DIR / \"MEMORY.md\"\nSKILLS_DIR = WORKDIR / \"skills\"\nTRANSCRIPT_DIR = WORKDIR / \".transcripts\"\nTOOL_RESULTS_DIR = WORKDIR / \".task_outputs\" / \"tool-results\"\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\n\n# ═══════════════════════════════════════════════════════════\n# NEW in s09: Memory System\n# ═══════════════════════════════════════════════════════════\n\nMEMORY_TYPES = [\"user\", \"feedback\", \"project\", \"reference\"]\n\ndef _parse_frontmatter(text: str) -> tuple[dict, str]:\n if not text.startswith(\"---\"):\n return {}, text\n parts = text.split(\"---\", 2)\n if len(parts) < 3:\n return {}, text\n meta = {}\n for line in parts[1].strip().splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip().strip('\"').strip(\"'\")\n return meta, parts[2].strip()\n\n\ndef write_memory_file(name: str, mem_type: str, description: str, body: str):\n \"\"\"Write a single memory file with YAML frontmatter.\"\"\"\n slug = name.lower().replace(\" \", \"-\").replace(\"/\", \"-\")\n filename = f\"{slug}.md\"\n filepath = MEMORY_DIR / filename\n filepath.write_text(\n f\"---\\nname: {name}\\ndescription: {description}\\ntype: {mem_type}\\n---\\n\\n{body}\\n\"\n )\n _rebuild_index()\n return filepath\n\n\ndef _rebuild_index():\n \"\"\"Rebuild MEMORY.md index from all memory files.\"\"\"\n lines = []\n for f in sorted(MEMORY_DIR.glob(\"*.md\")):\n if f.name == \"MEMORY.md\":\n continue\n raw = f.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", f.stem)\n desc = meta.get(\"description\", body.split(\"\\n\")[0][:80])\n lines.append(f\"- [{name}]({f.name}) — {desc}\")\n MEMORY_INDEX.write_text(\"\\n\".join(lines) + \"\\n\" if lines else \"\")\n\n\ndef read_memory_index() -> str:\n \"\"\"Read MEMORY.md index (injected into SYSTEM every turn).\"\"\"\n if not MEMORY_INDEX.exists():\n return \"\"\n text = MEMORY_INDEX.read_text().strip()\n return text if text else \"\"\n\n\ndef read_memory_file(filename: str) -> str | None:\n \"\"\"Read a single memory file's full content.\"\"\"\n path = MEMORY_DIR / filename\n if not path.exists():\n return None\n return path.read_text()\n\n\ndef list_memory_files() -> list[dict]:\n \"\"\"List all memory files with metadata.\"\"\"\n result = []\n for f in sorted(MEMORY_DIR.glob(\"*.md\")):\n if f.name == \"MEMORY.md\":\n continue\n raw = f.read_text()\n meta, body = _parse_frontmatter(raw)\n result.append({\n \"filename\": f.name,\n \"name\": meta.get(\"name\", f.stem),\n \"description\": meta.get(\"description\", \"\"),\n \"type\": meta.get(\"type\", \"user\"),\n \"body\": body,\n })\n return result\n\n\ndef select_relevant_memories(messages: list, max_items: int = 5) -> list[str]:\n \"\"\"Select relevant memory filenames by matching recent conversation against\n memory names/descriptions. Uses a simple LLM call (or falls back to keyword\n matching on name+description).\"\"\"\n files = list_memory_files()\n if not files:\n return []\n\n # Collect recent user text for context\n recent_texts = []\n for msg in reversed(messages):\n if msg.get(\"role\") == \"user\":\n content = msg.get(\"content\", \"\")\n if isinstance(content, list):\n content = \" \".join(\n str(getattr(b, \"text\", \"\")) for b in content\n if getattr(b, \"type\", None) == \"text\"\n )\n if isinstance(content, str):\n recent_texts.append(content)\n if len(recent_texts) >= 3:\n break\n recent = \" \".join(reversed(recent_texts))[:2000]\n\n if not recent.strip():\n return []\n\n # Build catalog of name + description for LLM to choose from\n catalog_lines = []\n for i, f in enumerate(files):\n catalog_lines.append(f\"{i}: {f['name']} — {f['description']}\")\n catalog = \"\\n\".join(catalog_lines)\n\n prompt = (\n \"Given the recent conversation and the memory catalog below, \"\n \"select the indices of memories that are clearly relevant. \"\n \"Return ONLY a JSON array of integers, e.g. [0, 3]. \"\n \"If none are relevant, return [].\\n\\n\"\n f\"Recent conversation:\\n{recent}\\n\\n\"\n f\"Memory catalog:\\n{catalog}\"\n )\n\n try:\n response = client.messages.create(\n model=MODEL,\n messages=[{\"role\": \"user\", \"content\": prompt}],\n max_tokens=200,\n )\n text = extract_text(response.content).strip()\n # Extract JSON array from response\n match = re.search(r'\\[.*?\\]', text, re.DOTALL)\n if match:\n indices = json.loads(match.group())\n selected = []\n for idx in indices:\n if isinstance(idx, int) and 0 <= idx < len(files):\n selected.append(files[idx][\"filename\"])\n if len(selected) >= max_items:\n break\n return selected\n except Exception:\n pass\n\n # Fallback: keyword matching on name + description\n keywords = [w.lower() for w in recent.split() if len(w) > 3]\n selected = []\n for f in files:\n text = (f[\"name\"] + \" \" + f[\"description\"]).lower()\n if any(kw in text for kw in keywords):\n selected.append(f[\"filename\"])\n if len(selected) >= max_items:\n break\n return selected\n\n\ndef load_memories(messages: list) -> str:\n \"\"\"Load relevant memory content for injection into context.\"\"\"\n selected_files = select_relevant_memories(messages)\n if not selected_files:\n return \"\"\n\n parts = [\"\"]\n for filename in selected_files:\n content = read_memory_file(filename)\n if content:\n parts.append(content)\n parts.append(\"\")\n return \"\\n\\n\".join(parts)\n\n\ndef extract_memories(messages: list):\n \"\"\"Extract new memories from recent dialogue. Runs after each turn.\"\"\"\n # Collect recent conversation text\n dialogue_parts = []\n for msg in messages[-10:]:\n role = msg.get(\"role\", \"?\")\n content = msg.get(\"content\", \"\")\n if isinstance(content, list):\n content = \" \".join(\n str(getattr(b, \"text\", \"\")) for b in content\n if getattr(b, \"type\", None) == \"text\"\n )\n if isinstance(content, str) and content.strip():\n dialogue_parts.append(f\"{role}: {content}\")\n dialogue = \"\\n\".join(dialogue_parts)\n\n if not dialogue.strip():\n return\n\n # Check existing memories to avoid duplicates\n existing = list_memory_files()\n existing_desc = \"\\n\".join(f\"- {m['name']}: {m['description']}\" for m in existing) if existing else \"(none)\"\n\n prompt = (\n \"Extract user preferences, constraints, or project facts from this dialogue.\\n\"\n \"Return a JSON array. Each item: {name, type, description, body}.\\n\"\n \"- name: short kebab-case identifier (e.g. 'user-preference-tabs')\\n\"\n \"- type: one of 'user' (user preference), 'feedback' (guidance), \"\n \"'project' (project fact), 'reference' (external pointer)\\n\"\n \"- description: one-line summary for index lookup\\n\"\n \"- body: full detail in markdown\\n\"\n \"If nothing new or already covered by existing memories, return [].\\n\\n\"\n f\"Existing memories:\\n{existing_desc}\\n\\n\"\n f\"Dialogue:\\n{dialogue[:4000]}\"\n )\n\n try:\n response = client.messages.create(\n model=MODEL, messages=[{\"role\": \"user\", \"content\": prompt}], max_tokens=800\n )\n text = extract_text(response.content).strip()\n # Extract JSON array from response\n match = re.search(r'\\[.*\\]', text, re.DOTALL)\n if not match:\n return\n items = json.loads(match.group())\n if not items:\n return\n count = 0\n for mem in items:\n name = mem.get(\"name\", f\"memory_{int(time.time())}\")\n mem_type = mem.get(\"type\", \"user\")\n desc = mem.get(\"description\", \"\")\n body = mem.get(\"body\", \"\")\n if desc and body:\n write_memory_file(name, mem_type, desc, body)\n count += 1\n if count:\n print(f\"\\n\\033[33m[Memory: extracted {count} new memories]\\033[0m\")\n except Exception:\n pass\n\n\nCONSOLIDATE_THRESHOLD = 10\n\ndef consolidate_memories():\n \"\"\"Merge duplicate/stale memories. Triggered when file count ≥ threshold.\"\"\"\n files = list_memory_files()\n if len(files) < CONSOLIDATE_THRESHOLD:\n return\n\n catalog = \"\\n\\n\".join(\n f\"## {f['filename']}\\nname: {f['name']}\\ndescription: {f['description']}\\n{f['body']}\"\n for f in files\n )\n\n prompt = (\n \"Consolidate the following memory files. Rules:\\n\"\n \"1. Merge duplicates into one\\n\"\n \"2. Remove outdated/contradicted memories\\n\"\n \"3. Keep the total under 30 memories\\n\"\n \"4. Preserve important user preferences above all\\n\"\n \"Return a JSON array. Each item: {name, type, description, body}.\\n\\n\"\n f\"{catalog[:16000]}\"\n )\n\n try:\n response = client.messages.create(\n model=MODEL, messages=[{\"role\": \"user\", \"content\": prompt}], max_tokens=3000\n )\n text = extract_text(response.content).strip()\n match = re.search(r'\\[.*\\]', text, re.DOTALL)\n if not match:\n return\n items = json.loads(match.group())\n\n # Remove old memory files (keep MEMORY.md)\n for f in MEMORY_DIR.glob(\"*.md\"):\n if f.name != \"MEMORY.md\":\n f.unlink()\n\n for mem in items:\n name = mem.get(\"name\", f\"memory_{int(time.time())}\")\n mem_type = mem.get(\"type\", \"user\")\n desc = mem.get(\"description\", \"\")\n body = mem.get(\"body\", \"\")\n if desc and body:\n write_memory_file(name, mem_type, desc, body)\n\n print(f\"\\n\\033[33m[Memory: consolidated {len(files)} → {len(items)} memories]\\033[0m\")\n except Exception:\n pass\n\n\n# Build SYSTEM with memory index\ndef build_system() -> str:\n index = read_memory_index()\n memories_section = f\"\\n\\nMemories available:\\n{index}\" if index else \"\"\n return (\n f\"You are a coding agent at {WORKDIR}.\"\n f\"{memories_section}\\n\"\n \"Relevant memories are injected below. Respect user preferences from memory.\\n\"\n \"When the user says 'remember' or expresses a clear preference, extract it as a memory.\"\n )\n\nSYSTEM = build_system()\n\nSUB_SYSTEM = (\n f\"You are a coding agent at {WORKDIR}. \"\n \"Complete the task you were given, then return a concise summary. \"\n \"Do not delegate further.\"\n)\n\n\n# ═══════════════════════════════════════════════════════════\n# FROM s02-s08 (skeleton): Basic tools\n# ═══════════════════════════════════════════════════════════\n\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR): raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\ndef run_bash(command: str) -> str:\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired: return \"Error: Timeout (120s)\"\n\ndef run_read(path: str, limit: int | None = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines): lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e: return f\"Error: {e}\"\n\ndef run_write(path: str, content: str) -> str:\n try:\n file_path = safe_path(path); file_path.parent.mkdir(parents=True, exist_ok=True)\n file_path.write_text(content); return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e: return f\"Error: {e}\"\n\ndef run_edit(path: str, old_text: str, new_text: str) -> str:\n try:\n file_path = safe_path(path)\n text = file_path.read_text()\n if old_text not in text: return f\"Error: text not found in {path}\"\n file_path.write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n except Exception as e: return f\"Error: {e}\"\n\ndef run_glob(pattern: str) -> str:\n import glob as g\n try:\n results = []\n for match in g.glob(pattern, root_dir=WORKDIR):\n if (WORKDIR / match).resolve().is_relative_to(WORKDIR):\n results.append(match)\n return \"\\n\".join(results) if results else \"(no matches)\"\n except Exception as e: return f\"Error: {e}\"\n\ndef extract_text(content) -> str:\n if not isinstance(content, list): return str(content)\n return \"\\n\".join(getattr(b, \"text\", \"\") for b in content if getattr(b, \"type\", None) == \"text\")\n\n# Subagent (simplified from s06-s07)\nSUB_TOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"content\": {\"type\": \"string\"}}, \"required\": [\"path\", \"content\"]}},\n]\nSUB_HANDLERS = {\"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write}\n\ndef spawn_subagent(task: str) -> str:\n print(f\"\\n\\033[35m[Subagent spawned]\\033[0m\")\n messages = [{\"role\": \"user\", \"content\": task}]\n for _ in range(30):\n response = client.messages.create(model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=SUB_TOOLS, max_tokens=8000)\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\": break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n print(f\" \\033[90m[sub] {block.name}: {str(output)[:100]}\\033[0m\")\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n result = extract_text(messages[-1][\"content\"])\n if not result:\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\":\n result = extract_text(msg[\"content\"])\n if result: break\n if not result: result = \"Subagent stopped after 30 turns without final answer.\"\n print(f\"\\033[35m[Subagent done]\\033[0m\")\n return result\n\n\n# ═══════════════════════════════════════════════════════════\n# FROM s08 (skeleton): Compaction pipeline\n# ═══════════════════════════════════════════════════════════\n\nCONTEXT_LIMIT = 50000; KEEP_RECENT = 3; PERSIST_THRESHOLD = 30000\n\ndef estimate_size(msgs): return len(str(msgs))\n\ndef snip_compact(msgs, mx=50):\n if len(msgs) <= mx: return msgs\n return msgs[:3] + [{\"role\": \"user\", \"content\": f\"[snipped {len(msgs)-mx} msgs]\"}] + msgs[-(mx-3):]\n\ndef collect_tool_results(msgs):\n blocks = []\n for mi, msg in enumerate(msgs):\n if msg.get(\"role\") != \"user\" or not isinstance(msg.get(\"content\"), list): continue\n for bi, block in enumerate(msg[\"content\"]):\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\": blocks.append((mi, bi, block))\n return blocks\n\ndef micro_compact(msgs):\n tr = collect_tool_results(msgs)\n if len(tr) <= KEEP_RECENT: return msgs\n for _, _, b in tr[:-KEEP_RECENT]:\n if len(b.get(\"content\", \"\")) > 120: b[\"content\"] = \"[Earlier tool result compacted.]\"\n return msgs\n\ndef persist_large(tid, out):\n if len(out) <= PERSIST_THRESHOLD: return out\n TOOL_RESULTS_DIR.mkdir(parents=True, exist_ok=True)\n p = TOOL_RESULTS_DIR / f\"{tid}.txt\"\n if not p.exists(): p.write_text(out)\n return f\"\\nFull: {p}\\nPreview:\\n{out[:2000]}\\n\"\n\ndef tool_result_budget(msgs, mx=200_000):\n last = msgs[-1] if msgs else None\n if not last or last.get(\"role\") != \"user\" or not isinstance(last.get(\"content\"), list): return msgs\n blocks = [(i, b) for i, b in enumerate(last[\"content\"]) if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= mx: return msgs\n for _, block in sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True):\n if total <= mx: break\n c = str(block.get(\"content\", \"\"))\n if len(c) <= PERSIST_THRESHOLD: continue\n block[\"content\"] = persist_large(block.get(\"tool_use_id\", \"?\"), c)\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return msgs\n\ndef write_transcript(msgs):\n TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)\n p = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with p.open(\"w\") as f:\n for m in msgs: f.write(json.dumps(m, default=str) + \"\\n\")\n return p\n\ndef summarize_history(msgs):\n conv = json.dumps(msgs, default=str)[:80000]\n r = client.messages.create(model=MODEL, messages=[{\"role\": \"user\", \"content\":\n \"Summarize this coding-agent conversation so work can continue.\\n\"\n \"Preserve: 1. current goal, 2. key findings, 3. files changed, 4. remaining work, 5. user constraints.\\n\\n\" + conv}],\n max_tokens=2000)\n return extract_text(r.content).strip()\n\ndef compact_history(msgs):\n write_transcript(msgs)\n summary = summarize_history(msgs)\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n\ndef reactive_compact(msgs):\n write_transcript(msgs)\n summary = summarize_history(msgs)\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *msgs[-5:]]\n\n\n# ═══════════════════════════════════════════════════════════\n# Tool Definitions (skeleton — fewer tools to focus on memory)\n# ═══════════════════════════════════════════════════════════\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"content\": {\"type\": \"string\"}}, \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"old_text\": {\"type\": \"string\"}, \"new_text\": {\"type\": \"string\"}}, \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"pattern\": {\"type\": \"string\"}}, \"required\": [\"pattern\"]}},\n {\"name\": \"task\", \"description\": \"Launch a subagent to handle a subtask.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n]\n\nTOOL_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"edit_file\": run_edit, \"glob\": run_glob, \"task\": spawn_subagent,\n}\n\n\n# ═══════════════════════════════════════════════════════════\n# agent_loop — s09: inject memories + extract after each turn\n# ═══════════════════════════════════════════════════════════\n\nMAX_REACTIVE_RETRIES = 1\n\ndef agent_loop(messages: list):\n reactive_retries = 0\n # s09: inject relevant memory content into the current user turn\n memories_content = load_memories(messages)\n memory_turn = len(messages) - 1 if messages and isinstance(messages[-1].get(\"content\"), str) else None\n while True:\n # s09: rebuild system with current memory index\n system = build_system()\n\n # s09: save pre-compression snapshot for accurate memory extraction\n pre_compress = [m if isinstance(m, dict) else {\"role\": m.get(\"role\",\"\"),\n \"content\": str(m.get(\"content\",\"\"))} for m in messages]\n\n # s08: compression pipeline (budget → snip → micro)\n messages[:] = tool_result_budget(messages)\n messages[:] = snip_compact(messages)\n messages[:] = micro_compact(messages)\n\n if estimate_size(messages) > CONTEXT_LIMIT:\n print(\"[auto compact]\")\n messages[:] = compact_history(messages)\n\n try:\n request_messages = messages\n if memories_content and memory_turn is not None and memory_turn < len(messages):\n request_messages = messages.copy()\n request_messages[memory_turn] = {\n **messages[memory_turn],\n \"content\": memories_content + \"\\n\\n\" + messages[memory_turn][\"content\"],\n }\n response = client.messages.create(\n model=MODEL, system=system, messages=request_messages, tools=TOOLS, max_tokens=8000\n )\n reactive_retries = 0\n except Exception as e:\n if (\"prompt_too_long\" in str(e).lower() or \"too many tokens\" in str(e).lower()) and reactive_retries < MAX_REACTIVE_RETRIES:\n print(\"[reactive compact]\")\n messages[:] = reactive_compact(messages)\n reactive_retries += 1\n continue\n raise\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n # s09: extract from pre-compression snapshot for full fidelity\n extract_memories(pre_compress)\n consolidate_memories()\n return\n\n results = []\n for block in response.content:\n if block.type != \"tool_use\": continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n print(str(output)[:200])\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n\nif __name__ == \"__main__\":\n print(\"s09: Memory — persistent cross-session knowledge\")\n print(\"输入问题,回车发送。输入 q 退出。\\n\")\n history = []\n while True:\n try: query = input(\"\\033[36ms09 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt): break\n if query.strip().lower() in (\"q\", \"exit\", \"\"): break\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history)\n for block in history[-1][\"content\"]:\n if getattr(block, \"type\", None) == \"text\": print(block.text)\n print()\n", + "source": "#!/usr/bin/env python3\n\"\"\"\ns09_memory.py - Memory System\n\nPersistent, cross-session knowledge for the coding agent.\n\nStorage:\n .memory/\n MEMORY.md ← index (one line per memory, ≤200 lines)\n feedback_tabs.md ← individual memory files (Markdown + YAML frontmatter)\n user_profile.md\n project_facts.md\n\nFlow in agent_loop:\n 1. Load MEMORY.md index into SYSTEM prompt (cheap, always present)\n 2. Select relevant memories by filename/description → inject content\n 3. Run compression pipeline from s08\n 4. After each turn ends → extract new memories from original messages\n 5. Periodically consolidate (Dream)\n\nBuilds on s08 (context compact). Usage:\n\n python s09_memory/code.py\n Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env\n\"\"\"\n\nimport os, subprocess, json, time, re\nfrom pathlib import Path\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"): os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nMEMORY_DIR = WORKDIR / \".memory\"; MEMORY_DIR.mkdir(exist_ok=True)\nMEMORY_INDEX = MEMORY_DIR / \"MEMORY.md\"\nSKILLS_DIR = WORKDIR / \"skills\"\nTRANSCRIPT_DIR = WORKDIR / \".transcripts\"\nTOOL_RESULTS_DIR = WORKDIR / \".task_outputs\" / \"tool-results\"\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\n\n# ═══════════════════════════════════════════════════════════\n# NEW in s09: Memory System\n# ═══════════════════════════════════════════════════════════\n\nMEMORY_TYPES = [\"user\", \"feedback\", \"project\", \"reference\"]\n\ndef _parse_frontmatter(text: str) -> tuple[dict, str]:\n if not text.startswith(\"---\"):\n return {}, text\n parts = text.split(\"---\", 2)\n if len(parts) < 3:\n return {}, text\n meta = {}\n for line in parts[1].strip().splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip().strip('\"').strip(\"'\")\n return meta, parts[2].strip()\n\n\ndef write_memory_file(name: str, mem_type: str, description: str, body: str):\n \"\"\"Write a single memory file with YAML frontmatter.\"\"\"\n slug = name.lower().replace(\" \", \"-\").replace(\"/\", \"-\")\n filename = f\"{slug}.md\"\n filepath = MEMORY_DIR / filename\n filepath.write_text(\n f\"---\\nname: {name}\\ndescription: {description}\\ntype: {mem_type}\\n---\\n\\n{body}\\n\"\n )\n _rebuild_index()\n return filepath\n\n\ndef _rebuild_index():\n \"\"\"Rebuild MEMORY.md index from all memory files.\"\"\"\n lines = []\n for f in sorted(MEMORY_DIR.glob(\"*.md\")):\n if f.name == \"MEMORY.md\":\n continue\n raw = f.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", f.stem)\n desc = meta.get(\"description\", body.split(\"\\n\")[0][:80])\n lines.append(f\"- [{name}]({f.name}) — {desc}\")\n MEMORY_INDEX.write_text(\"\\n\".join(lines) + \"\\n\" if lines else \"\")\n\n\ndef read_memory_index() -> str:\n \"\"\"Read MEMORY.md index (injected into SYSTEM every turn).\"\"\"\n if not MEMORY_INDEX.exists():\n return \"\"\n text = MEMORY_INDEX.read_text().strip()\n return text if text else \"\"\n\n\ndef read_memory_file(filename: str) -> str | None:\n \"\"\"Read a single memory file's full content.\"\"\"\n path = MEMORY_DIR / filename\n if not path.exists():\n return None\n return path.read_text()\n\n\ndef list_memory_files() -> list[dict]:\n \"\"\"List all memory files with metadata.\"\"\"\n result = []\n for f in sorted(MEMORY_DIR.glob(\"*.md\")):\n if f.name == \"MEMORY.md\":\n continue\n raw = f.read_text()\n meta, body = _parse_frontmatter(raw)\n result.append({\n \"filename\": f.name,\n \"name\": meta.get(\"name\", f.stem),\n \"description\": meta.get(\"description\", \"\"),\n \"type\": meta.get(\"type\", \"user\"),\n \"body\": body,\n })\n return result\n\n\ndef select_relevant_memories(messages: list, max_items: int = 5) -> list[str]:\n \"\"\"Select relevant memory filenames by matching recent conversation against\n memory names/descriptions. Uses a simple LLM call (or falls back to keyword\n matching on name+description).\"\"\"\n files = list_memory_files()\n if not files:\n return []\n\n # Collect recent user text for context\n recent_texts = []\n for msg in reversed(messages):\n if msg.get(\"role\") == \"user\":\n content = msg.get(\"content\", \"\")\n if isinstance(content, list):\n content = \" \".join(\n str(getattr(b, \"text\", \"\")) for b in content\n if getattr(b, \"type\", None) == \"text\"\n )\n if isinstance(content, str):\n recent_texts.append(content)\n if len(recent_texts) >= 3:\n break\n recent = \" \".join(reversed(recent_texts))[:2000]\n\n if not recent.strip():\n return []\n\n # Build catalog of name + description for LLM to choose from\n catalog_lines = []\n for i, f in enumerate(files):\n catalog_lines.append(f\"{i}: {f['name']} — {f['description']}\")\n catalog = \"\\n\".join(catalog_lines)\n\n prompt = (\n \"Given the recent conversation and the memory catalog below, \"\n \"select the indices of memories that are clearly relevant. \"\n \"Return ONLY a JSON array of integers, e.g. [0, 3]. \"\n \"If none are relevant, return [].\\n\\n\"\n f\"Recent conversation:\\n{recent}\\n\\n\"\n f\"Memory catalog:\\n{catalog}\"\n )\n\n try:\n response = client.messages.create(\n model=MODEL,\n messages=[{\"role\": \"user\", \"content\": prompt}],\n max_tokens=200,\n )\n text = extract_text(response.content).strip()\n # Extract JSON array from response\n match = re.search(r'\\[.*?\\]', text, re.DOTALL)\n if match:\n indices = json.loads(match.group())\n selected = []\n for idx in indices:\n if isinstance(idx, int) and 0 <= idx < len(files):\n selected.append(files[idx][\"filename\"])\n if len(selected) >= max_items:\n break\n return selected\n except Exception:\n pass\n\n # Fallback: keyword matching on name + description\n keywords = [w.lower() for w in recent.split() if len(w) > 3]\n selected = []\n for f in files:\n text = (f[\"name\"] + \" \" + f[\"description\"]).lower()\n if any(kw in text for kw in keywords):\n selected.append(f[\"filename\"])\n if len(selected) >= max_items:\n break\n return selected\n\n\ndef load_memories(messages: list) -> str:\n \"\"\"Load relevant memory content for injection into context.\"\"\"\n selected_files = select_relevant_memories(messages)\n if not selected_files:\n return \"\"\n\n parts = [\"\"]\n for filename in selected_files:\n content = read_memory_file(filename)\n if content:\n parts.append(content)\n parts.append(\"\")\n return \"\\n\\n\".join(parts)\n\n\ndef extract_memories(messages: list):\n \"\"\"Extract new memories from recent dialogue. Runs after each turn.\"\"\"\n # Collect recent conversation text\n dialogue_parts = []\n for msg in messages[-10:]:\n role = msg.get(\"role\", \"?\")\n content = msg.get(\"content\", \"\")\n if isinstance(content, list):\n content = \" \".join(\n str(getattr(b, \"text\", \"\")) for b in content\n if getattr(b, \"type\", None) == \"text\"\n )\n if isinstance(content, str) and content.strip():\n dialogue_parts.append(f\"{role}: {content}\")\n dialogue = \"\\n\".join(dialogue_parts)\n\n if not dialogue.strip():\n return\n\n # Check existing memories to avoid duplicates\n existing = list_memory_files()\n existing_desc = \"\\n\".join(f\"- {m['name']}: {m['description']}\" for m in existing) if existing else \"(none)\"\n\n prompt = (\n \"Extract user preferences, constraints, or project facts from this dialogue.\\n\"\n \"Return a JSON array. Each item: {name, type, description, body}.\\n\"\n \"- name: short kebab-case identifier (e.g. 'user-preference-tabs')\\n\"\n \"- type: one of 'user' (user preference), 'feedback' (guidance), \"\n \"'project' (project fact), 'reference' (external pointer)\\n\"\n \"- description: one-line summary for index lookup\\n\"\n \"- body: full detail in markdown\\n\"\n \"If nothing new or already covered by existing memories, return [].\\n\\n\"\n f\"Existing memories:\\n{existing_desc}\\n\\n\"\n f\"Dialogue:\\n{dialogue[:4000]}\"\n )\n\n try:\n response = client.messages.create(\n model=MODEL, messages=[{\"role\": \"user\", \"content\": prompt}], max_tokens=800\n )\n text = extract_text(response.content).strip()\n # Extract JSON array from response\n match = re.search(r'\\[.*\\]', text, re.DOTALL)\n if not match:\n return\n items = json.loads(match.group())\n if not items:\n return\n count = 0\n for mem in items:\n name = mem.get(\"name\", f\"memory_{int(time.time())}\")\n mem_type = mem.get(\"type\", \"user\")\n desc = mem.get(\"description\", \"\")\n body = mem.get(\"body\", \"\")\n if desc and body:\n write_memory_file(name, mem_type, desc, body)\n count += 1\n if count:\n print(f\"\\n\\033[33m[Memory: extracted {count} new memories]\\033[0m\")\n except Exception:\n pass\n\n\nCONSOLIDATE_THRESHOLD = 10\n\ndef consolidate_memories():\n \"\"\"Merge duplicate/stale memories. Triggered when file count ≥ threshold.\"\"\"\n files = list_memory_files()\n if len(files) < CONSOLIDATE_THRESHOLD:\n return\n\n catalog = \"\\n\\n\".join(\n f\"## {f['filename']}\\nname: {f['name']}\\ndescription: {f['description']}\\n{f['body']}\"\n for f in files\n )\n\n prompt = (\n \"Consolidate the following memory files. Rules:\\n\"\n \"1. Merge duplicates into one\\n\"\n \"2. Remove outdated/contradicted memories\\n\"\n \"3. Keep the total under 30 memories\\n\"\n \"4. Preserve important user preferences above all\\n\"\n \"Return a JSON array. Each item: {name, type, description, body}.\\n\\n\"\n f\"{catalog[:16000]}\"\n )\n\n try:\n response = client.messages.create(\n model=MODEL, messages=[{\"role\": \"user\", \"content\": prompt}], max_tokens=3000\n )\n text = extract_text(response.content).strip()\n match = re.search(r'\\[.*\\]', text, re.DOTALL)\n if not match:\n return\n items = json.loads(match.group())\n\n # Remove old memory files (keep MEMORY.md)\n for f in MEMORY_DIR.glob(\"*.md\"):\n if f.name != \"MEMORY.md\":\n f.unlink()\n\n for mem in items:\n name = mem.get(\"name\", f\"memory_{int(time.time())}\")\n mem_type = mem.get(\"type\", \"user\")\n desc = mem.get(\"description\", \"\")\n body = mem.get(\"body\", \"\")\n if desc and body:\n write_memory_file(name, mem_type, desc, body)\n\n print(f\"\\n\\033[33m[Memory: consolidated {len(files)} → {len(items)} memories]\\033[0m\")\n except Exception:\n pass\n\n\n# Build SYSTEM with memory index\ndef build_system() -> str:\n index = read_memory_index()\n memories_section = f\"\\n\\nMemories available:\\n{index}\" if index else \"\"\n return (\n f\"You are a coding agent at {WORKDIR}.\"\n f\"{memories_section}\\n\"\n \"Relevant memories are injected below. Respect user preferences from memory.\\n\"\n \"When the user says 'remember' or expresses a clear preference, extract it as a memory.\"\n )\n\nSUB_SYSTEM = (\n f\"You are a coding agent at {WORKDIR}. \"\n \"Complete the task you were given, then return a concise summary. \"\n \"Do not delegate further.\"\n)\n\n\n# ═══════════════════════════════════════════════════════════\n# FROM s02-s08 (skeleton): Basic tools\n# ═══════════════════════════════════════════════════════════\n\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR): raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\ndef run_bash(command: str) -> str:\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired: return \"Error: Timeout (120s)\"\n\ndef run_read(path: str, limit: int | None = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines): lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e: return f\"Error: {e}\"\n\ndef run_write(path: str, content: str) -> str:\n try:\n file_path = safe_path(path); file_path.parent.mkdir(parents=True, exist_ok=True)\n file_path.write_text(content); return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e: return f\"Error: {e}\"\n\ndef run_edit(path: str, old_text: str, new_text: str) -> str:\n try:\n file_path = safe_path(path)\n text = file_path.read_text()\n if old_text not in text: return f\"Error: text not found in {path}\"\n file_path.write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n except Exception as e: return f\"Error: {e}\"\n\ndef run_glob(pattern: str) -> str:\n import glob as g\n try:\n results = []\n for match in g.glob(pattern, root_dir=WORKDIR):\n if (WORKDIR / match).resolve().is_relative_to(WORKDIR):\n results.append(match)\n return \"\\n\".join(results) if results else \"(no matches)\"\n except Exception as e: return f\"Error: {e}\"\n\ndef extract_text(content) -> str:\n if not isinstance(content, list): return str(content)\n return \"\\n\".join(getattr(b, \"text\", \"\") for b in content if getattr(b, \"type\", None) == \"text\")\n\n# Subagent (simplified from s06-s07)\nSUB_TOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"content\": {\"type\": \"string\"}}, \"required\": [\"path\", \"content\"]}},\n]\nSUB_HANDLERS = {\"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write}\n\ndef spawn_subagent(description: str) -> str:\n print(f\"\\n\\033[35m[Subagent spawned]\\033[0m\")\n messages = [{\"role\": \"user\", \"content\": description}]\n for _ in range(30):\n response = client.messages.create(model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=SUB_TOOLS, max_tokens=8000)\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\": break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n print(f\" \\033[90m[sub] {block.name}: {str(output)[:100]}\\033[0m\")\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n result = extract_text(messages[-1][\"content\"])\n if not result:\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\":\n result = extract_text(msg[\"content\"])\n if result: break\n if not result: result = \"Subagent stopped after 30 turns without final answer.\"\n print(f\"\\033[35m[Subagent done]\\033[0m\")\n return result\n\n\n# ═══════════════════════════════════════════════════════════\n# FROM s08 (skeleton): Compaction pipeline\n# ═══════════════════════════════════════════════════════════\n\nCONTEXT_LIMIT = 50000; KEEP_RECENT = 3; PERSIST_THRESHOLD = 30000\n\ndef estimate_size(msgs): return len(str(msgs))\n\ndef _block_type(block):\n return block.get(\"type\") if isinstance(block, dict) else getattr(block, \"type\", None)\n\ndef _message_has_tool_use(msg):\n if msg.get(\"role\") != \"assistant\":\n return False\n content = msg.get(\"content\")\n if not isinstance(content, list):\n return False\n return any(_block_type(block) == \"tool_use\" for block in content)\n\ndef _is_tool_result_message(msg):\n if msg.get(\"role\") != \"user\":\n return False\n content = msg.get(\"content\")\n if not isinstance(content, list):\n return False\n return any(isinstance(block, dict) and block.get(\"type\") == \"tool_result\" for block in content)\n\ndef snip_compact(msgs, mx=50):\n if len(msgs) <= mx: return msgs\n head_end, tail_start = 3, len(msgs) - (mx - 3)\n if head_end > 0 and _message_has_tool_use(msgs[head_end - 1]):\n while head_end < len(msgs) and _is_tool_result_message(msgs[head_end]):\n head_end += 1\n if (tail_start > 0 and tail_start < len(msgs)\n and _is_tool_result_message(msgs[tail_start])\n and _message_has_tool_use(msgs[tail_start - 1])):\n tail_start -= 1\n if head_end >= tail_start:\n return msgs\n return msgs[:head_end] + [{\"role\": \"user\", \"content\": f\"[snipped {tail_start - head_end} msgs]\"}] + msgs[tail_start:]\n\ndef collect_tool_results(msgs):\n blocks = []\n for mi, msg in enumerate(msgs):\n if msg.get(\"role\") != \"user\" or not isinstance(msg.get(\"content\"), list): continue\n for bi, block in enumerate(msg[\"content\"]):\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\": blocks.append((mi, bi, block))\n return blocks\n\ndef micro_compact(msgs):\n tr = collect_tool_results(msgs)\n if len(tr) <= KEEP_RECENT: return msgs\n for _, _, b in tr[:-KEEP_RECENT]:\n if len(b.get(\"content\", \"\")) > 120: b[\"content\"] = \"[Earlier tool result compacted.]\"\n return msgs\n\ndef persist_large(tid, out):\n if len(out) <= PERSIST_THRESHOLD: return out\n TOOL_RESULTS_DIR.mkdir(parents=True, exist_ok=True)\n p = TOOL_RESULTS_DIR / f\"{tid}.txt\"\n if not p.exists(): p.write_text(out)\n return f\"\\nFull: {p}\\nPreview:\\n{out[:2000]}\\n\"\n\ndef tool_result_budget(msgs, mx=200_000):\n last = msgs[-1] if msgs else None\n if not last or last.get(\"role\") != \"user\" or not isinstance(last.get(\"content\"), list): return msgs\n blocks = [(i, b) for i, b in enumerate(last[\"content\"]) if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= mx: return msgs\n for _, block in sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True):\n if total <= mx: break\n c = str(block.get(\"content\", \"\"))\n if len(c) <= PERSIST_THRESHOLD: continue\n block[\"content\"] = persist_large(block.get(\"tool_use_id\", \"?\"), c)\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return msgs\n\ndef write_transcript(msgs):\n TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)\n p = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with p.open(\"w\") as f:\n for m in msgs: f.write(json.dumps(m, default=str) + \"\\n\")\n return p\n\ndef summarize_history(msgs):\n conv = json.dumps(msgs, default=str)[:80000]\n r = client.messages.create(model=MODEL, messages=[{\"role\": \"user\", \"content\":\n \"Summarize this coding-agent conversation so work can continue.\\n\"\n \"Preserve: 1. current goal, 2. key findings, 3. files changed, 4. remaining work, 5. user constraints.\\n\\n\" + conv}],\n max_tokens=2000)\n return extract_text(r.content).strip()\n\ndef compact_history(msgs):\n write_transcript(msgs)\n summary = summarize_history(msgs)\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n\ndef reactive_compact(msgs):\n write_transcript(msgs)\n tail_start = max(0, len(msgs) - 5)\n if (tail_start > 0 and tail_start < len(msgs)\n and _is_tool_result_message(msgs[tail_start])\n and _message_has_tool_use(msgs[tail_start - 1])):\n tail_start -= 1\n summary = summarize_history(msgs[:tail_start])\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *msgs[tail_start:]]\n\n\n# ═══════════════════════════════════════════════════════════\n# Tool Definitions (skeleton — fewer tools to focus on memory)\n# ═══════════════════════════════════════════════════════════\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"content\": {\"type\": \"string\"}}, \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"old_text\": {\"type\": \"string\"}, \"new_text\": {\"type\": \"string\"}}, \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"pattern\": {\"type\": \"string\"}}, \"required\": [\"pattern\"]}},\n {\"name\": \"task\", \"description\": \"Launch a subagent to handle a subtask.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n]\n\nTOOL_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"edit_file\": run_edit, \"glob\": run_glob, \"task\": spawn_subagent,\n}\n\n\n# ═══════════════════════════════════════════════════════════\n# agent_loop — s09: inject memories + extract after each turn\n# ═══════════════════════════════════════════════════════════\n\nMAX_REACTIVE_RETRIES = 1\n\ndef agent_loop(messages: list):\n reactive_retries = 0\n # s09: inject relevant memory content into the current user turn\n memories_content = load_memories(messages)\n memory_turn = len(messages) - 1 if messages and isinstance(messages[-1].get(\"content\"), str) else None\n # s09: build system once per user turn; memory is updated after the loop returns\n system = build_system()\n\n while True:\n # s09: save pre-compression snapshot for accurate memory extraction\n pre_compress = [m if isinstance(m, dict) else {\"role\": m.get(\"role\",\"\"),\n \"content\": str(m.get(\"content\",\"\"))} for m in messages]\n\n # s08: compression pipeline (budget → snip → micro)\n messages[:] = tool_result_budget(messages)\n messages[:] = snip_compact(messages)\n messages[:] = micro_compact(messages)\n\n if estimate_size(messages) > CONTEXT_LIMIT:\n print(\"[auto compact]\")\n messages[:] = compact_history(messages)\n\n try:\n request_messages = messages\n if memories_content and memory_turn is not None and memory_turn < len(messages):\n request_messages = messages.copy()\n request_messages[memory_turn] = {\n **messages[memory_turn],\n \"content\": memories_content + \"\\n\\n\" + messages[memory_turn][\"content\"],\n }\n response = client.messages.create(\n model=MODEL, system=system, messages=request_messages, tools=TOOLS, max_tokens=8000\n )\n reactive_retries = 0\n except Exception as e:\n if (\"prompt_too_long\" in str(e).lower() or \"too many tokens\" in str(e).lower()) and reactive_retries < MAX_REACTIVE_RETRIES:\n print(\"[reactive compact]\")\n messages[:] = reactive_compact(messages)\n reactive_retries += 1\n continue\n raise\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n # s09: extract from pre-compression snapshot for full fidelity\n extract_memories(pre_compress)\n consolidate_memories()\n return\n\n results = []\n for block in response.content:\n if block.type != \"tool_use\": continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n print(str(output)[:200])\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n\nif __name__ == \"__main__\":\n print(\"s09: Memory — persistent cross-session knowledge\")\n print(\"输入问题,回车发送。输入 q 退出。\\n\")\n history = []\n while True:\n try: query = input(\"\\033[36ms09 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt): break\n if query.strip().lower() in (\"q\", \"exit\", \"\"): break\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history)\n for block in history[-1][\"content\"]:\n if getattr(block, \"type\", None) == \"text\": print(block.text)\n print()\n", "images": [ { "src": "/course-assets/s09_memory/memory-overview.svg", @@ -1187,7 +1213,7 @@ } ], "layer": "planning", - "source": "#!/usr/bin/env python3\n\"\"\"\ns11: Error Recovery — three recovery paths + exponential backoff.\n\nRun: python s11_error_recovery/code.py\nNeed: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY\n\nChanges from s10:\n - LLM call wrapped in try/except with three recovery paths\n - Path 1: max_tokens -> escalate 8K->64K (no append on first escalation),\n then continuation prompt (max 3)\n - Path 2: prompt_too_long -> reactive compact -> retry (once)\n - Path 3: 429/529 -> exponential backoff with jitter (max 10),\n fallback model on consecutive 529\n - with_retry wrapper for transient errors\n - RecoveryState tracks escalation / compact / 529 / model\n\nASCII flow:\n messages -> prompt assembly -> compress+load -> [try] LLM [except] -> tools -> loop\n | |\n stop_reason error type\n max_tokens? prompt_too_long? -> compact\n escalate / 429/529? -> backoff\n continue other? -> log + exit\n\"\"\"\n\nimport os, subprocess, time, random, json\nfrom pathlib import Path\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nMEMORY_DIR = WORKDIR / \".memory\"\nMEMORY_INDEX = MEMORY_DIR / \"MEMORY.md\"\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nPRIMARY_MODEL = os.environ[\"MODEL_ID\"]\nFALLBACK_MODEL = os.getenv(\"FALLBACK_MODEL_ID\")\n\n# ── Constants ──\n\nESCALATED_MAX_TOKENS = 64000\nDEFAULT_MAX_TOKENS = 8000\nMAX_RECOVERY_RETRIES = 3\nMAX_RETRIES = 10\nBASE_DELAY_MS = 500\nMAX_CONSECUTIVE_529 = 3\nCONTINUATION_PROMPT = (\n \"Output token limit hit. Resume directly — \"\n \"no apology, no recap. Pick up mid-thought.\"\n)\n\n# ── Prompt Assembly (from s10, synced) ──\n\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n\n\ndef assemble_system_prompt(context: dict) -> str:\n sections = [PROMPT_SECTIONS[\"identity\"],\n PROMPT_SECTIONS[\"tools\"],\n PROMPT_SECTIONS[\"workspace\"]]\n memories = context.get(\"memories\", \"\")\n if memories:\n sections.append(f\"Relevant memories:\\n{memories}\")\n return \"\\n\\n\".join(sections)\n\n\n_last_context_key, _last_prompt = None, None\n\n\ndef get_system_prompt(context: dict) -> str:\n global _last_context_key, _last_prompt\n key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)\n if key == _last_context_key and _last_prompt:\n print(\" \\033[90m[cache hit] system prompt unchanged\\033[0m\")\n return _last_prompt\n _last_context_key = key\n _last_prompt = assemble_system_prompt(context)\n\n loaded = [\"identity\", \"tools\", \"workspace\"]\n if context.get(\"memories\"):\n loaded.append(\"memory\")\n print(f\" \\033[32m[assembled] sections: {', '.join(loaded)}\\033[0m\")\n return _last_prompt\n\n\n# ── Tools (unchanged) ──\n\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR):\n raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\n\ndef run_bash(command: str) -> str:\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\n\ndef run_read(path: str, limit: int | None = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_write(path: str, content: str) -> str:\n try:\n file_path = safe_path(path)\n file_path.parent.mkdir(parents=True, exist_ok=True)\n file_path.write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n]\n\nTOOL_HANDLERS = {\"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write}\n\n\n# ── Error Recovery (s11 new) ──\n\nclass RecoveryState:\n \"\"\"Track recovery attempts across the loop.\"\"\"\n def __init__(self):\n self.has_escalated = False\n self.recovery_count = 0\n self.consecutive_529 = 0\n self.has_attempted_reactive_compact = False\n self.current_model = PRIMARY_MODEL\n\n\ndef retry_delay(attempt, retry_after=None):\n \"\"\"Exponential backoff with jitter. Retry-After takes priority.\"\"\"\n if retry_after:\n return retry_after\n base = min(BASE_DELAY_MS * (2 ** attempt), 32000) / 1000\n jitter = random.uniform(0, base * 0.25)\n return base + jitter\n\n\ndef with_retry(fn, state: RecoveryState):\n \"\"\"Exponential backoff for transient errors (429/529).\n Non-transient errors are re-raised for the outer handler.\"\"\"\n for attempt in range(MAX_RETRIES):\n try:\n result = fn()\n state.consecutive_529 = 0\n return result\n except Exception as e:\n name = type(e).__name__\n msg = str(e).lower()\n\n # 429 rate limit -> exponential backoff\n if \"ratelimit\" in name.lower() or \"429\" in msg:\n delay = retry_delay(attempt)\n print(f\" \\033[33m[429 rate limit] retry {attempt+1}/{MAX_RETRIES},\"\n f\" wait {delay:.1f}s\\033[0m\")\n time.sleep(delay)\n continue\n\n # 529 overloaded -> exponential backoff + fallback model\n if \"overloaded\" in name.lower() or \"529\" in msg or \"overloaded\" in msg:\n state.consecutive_529 += 1\n if state.consecutive_529 >= MAX_CONSECUTIVE_529:\n if FALLBACK_MODEL:\n state.current_model = FALLBACK_MODEL\n state.consecutive_529 = 0\n print(f\" \\033[31m[529 x{MAX_CONSECUTIVE_529}]\"\n f\" switching to {FALLBACK_MODEL}\\033[0m\")\n else:\n state.consecutive_529 = 0\n print(f\" \\033[31m[529 x{MAX_CONSECUTIVE_529}]\"\n f\" no FALLBACK_MODEL_ID configured, continuing retry\\033[0m\")\n delay = retry_delay(attempt)\n print(f\" \\033[33m[529 overloaded] retry {attempt+1}/{MAX_RETRIES},\"\n f\" wait {delay:.1f}s\\033[0m\")\n time.sleep(delay)\n continue\n\n # Not transient -> re-raise for outer try/except\n raise\n raise RuntimeError(f\"Max retries ({MAX_RETRIES}) exceeded\")\n\n\ndef is_prompt_too_long_error(e: Exception) -> bool:\n \"\"\"Check whether an API error indicates prompt/context too long.\"\"\"\n msg = str(e).lower()\n return ((\"prompt\" in msg and \"long\" in msg)\n or \"prompt_is_too_long\" in msg\n or \"context_length_exceeded\" in msg\n or \"max_context_window\" in msg)\n\n\ndef reactive_compact(messages: list) -> list:\n \"\"\"Emergency compact — teaching version keeps last N messages.\n Real CC generates a compact summary via LLM, then retries with\n the compacted message list. Teaching version simplifies to tail\n retention since s08/s09 already cover LLM-based compact.\"\"\"\n print(\" \\033[31m[reactive compact] trimming to last 5 messages\\033[0m\")\n tail = messages[-5:]\n return [{\"role\": \"user\",\n \"content\": \"[Reactive compact] Earlier conversation trimmed. \"\n \"Continue from where you left off.\"}, *tail]\n\n\n# ── Context ──\n\ndef update_context(context: dict, messages: list) -> dict:\n \"\"\"Derive context from real state: which tools exist, whether memory files exist.\"\"\"\n memories = \"\"\n if MEMORY_INDEX.exists():\n content = MEMORY_INDEX.read_text().strip()\n if content:\n memories = content\n return {\n \"enabled_tools\": list(TOOL_HANDLERS.keys()),\n \"workspace\": str(WORKDIR),\n \"memories\": memories,\n }\n\n\n# ── Agent Loop ──\n\ndef agent_loop(messages: list, context: dict):\n \"\"\"Main loop with error recovery wrapping LLM calls.\"\"\"\n system = get_system_prompt(context)\n state = RecoveryState()\n max_tokens = DEFAULT_MAX_TOKENS\n\n while True:\n # ── LLM call: with_retry handles 429/529, outer handles rest ──\n try:\n response = with_retry(\n lambda mt=max_tokens, mdl=state.current_model:\n client.messages.create(\n model=mdl, system=system, messages=messages,\n tools=TOOLS, max_tokens=mt),\n state)\n except Exception as e:\n # Path 2: prompt_too_long -> reactive compact (once)\n if is_prompt_too_long_error(e):\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n print(\" \\033[31m[unrecoverable] still too long after compact\\033[0m\")\n messages.append({\"role\": \"assistant\", \"content\": [\n {\"type\": \"text\",\n \"text\": \"[Error] Context too large, cannot continue.\"}]})\n return\n\n # Unrecoverable\n name = type(e).__name__\n print(f\" \\033[31m[unrecoverable] {name}: {str(e)[:100]}\\033[0m\")\n messages.append({\"role\": \"assistant\", \"content\": [\n {\"type\": \"text\", \"text\": f\"[Error] {name}: {str(e)[:200]}\"}]})\n return\n\n # ── Path 1: max_tokens -> escalate or continue ──\n if response.stop_reason == \"max_tokens\":\n # First escalation: don't append truncated output, retry same request\n if not state.has_escalated:\n max_tokens = ESCALATED_MAX_TOKENS\n state.has_escalated = True\n print(f\" \\033[33m[max_tokens] escalating\"\n f\" {DEFAULT_MAX_TOKENS} -> {ESCALATED_MAX_TOKENS}\\033[0m\")\n continue\n # 64K still truncated: save truncated output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if state.recovery_count < MAX_RECOVERY_RETRIES:\n messages.append({\"role\": \"user\", \"content\": CONTINUATION_PROMPT})\n state.recovery_count += 1\n print(f\" \\033[33m[max_tokens] continuation\"\n f\" {state.recovery_count}/{MAX_RECOVERY_RETRIES}\\033[0m\")\n continue\n print(\" \\033[31m[max_tokens] recovery limit reached\\033[0m\")\n return\n\n # Normal completion: append assistant response\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n # ── Tool execution ──\n results = []\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n print(str(output)[:200])\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n context = update_context(context, messages)\n system = get_system_prompt(context)\n\n\nif __name__ == \"__main__\":\n print(\"s11: error recovery\")\n print(\"Enter a question, press Enter to send. Type q to quit.\\n\")\n history = []\n context = update_context({}, [])\n while True:\n try:\n query = input(\"\\033[36ms11 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt):\n break\n if query.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n turn_start = len(history)\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history, context)\n context = update_context(context, history)\n for msg in history[turn_start:]:\n if msg.get(\"role\") != \"assistant\":\n continue\n for block in msg[\"content\"]:\n if getattr(block, \"type\", None) == \"text\":\n print(block.text)\n print()\n", + "source": "#!/usr/bin/env python3\n\"\"\"\ns11: Error Recovery — three recovery paths + exponential backoff.\n\nRun: python s11_error_recovery/code.py\nNeed: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY\n\nChanges from s10:\n - LLM call wrapped in try/except with three recovery paths\n - Path 1: max_tokens -> escalate 8K->64K (no append on first escalation),\n then continuation prompt (max 3)\n - Path 2: prompt_too_long -> reactive compact -> retry (once)\n - Path 3: 429/529 -> exponential backoff with jitter (max 10),\n fallback model on consecutive 529\n - with_retry wrapper for transient errors\n - RecoveryState tracks escalation / compact / 529 / model\n\nASCII flow:\n messages -> prompt assembly -> compress+load -> [try] LLM [except] -> tools -> loop\n | |\n stop_reason error type\n max_tokens? prompt_too_long? -> compact\n escalate / 429/529? -> backoff\n continue other? -> log + exit\n\"\"\"\n\nimport os, subprocess, time, random, json\nfrom pathlib import Path\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nMEMORY_DIR = WORKDIR / \".memory\"\nMEMORY_INDEX = MEMORY_DIR / \"MEMORY.md\"\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nPRIMARY_MODEL = os.environ[\"MODEL_ID\"]\nFALLBACK_MODEL = os.getenv(\"FALLBACK_MODEL_ID\")\n\n# ── Constants ──\n\nESCALATED_MAX_TOKENS = 64000\nDEFAULT_MAX_TOKENS = 8000\nMAX_RECOVERY_RETRIES = 3\nMAX_RETRIES = 10\nBASE_DELAY_MS = 500\nMAX_CONSECUTIVE_529 = 3\nCONTINUATION_PROMPT = (\n \"Output token limit hit. Resume directly — \"\n \"no apology, no recap. Pick up mid-thought.\"\n)\n\n# ── Prompt Assembly (from s10, synced) ──\n\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n\n\ndef assemble_system_prompt(context: dict) -> str:\n sections = [PROMPT_SECTIONS[\"identity\"],\n PROMPT_SECTIONS[\"tools\"],\n PROMPT_SECTIONS[\"workspace\"]]\n memories = context.get(\"memories\", \"\")\n if memories:\n sections.append(f\"Relevant memories:\\n{memories}\")\n return \"\\n\\n\".join(sections)\n\n\n_last_context_key, _last_prompt = None, None\n\n\ndef get_system_prompt(context: dict) -> str:\n global _last_context_key, _last_prompt\n key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)\n if key == _last_context_key and _last_prompt:\n print(\" \\033[90m[cache hit] system prompt unchanged\\033[0m\")\n return _last_prompt\n _last_context_key = key\n _last_prompt = assemble_system_prompt(context)\n\n loaded = [\"identity\", \"tools\", \"workspace\"]\n if context.get(\"memories\"):\n loaded.append(\"memory\")\n print(f\" \\033[32m[assembled] sections: {', '.join(loaded)}\\033[0m\")\n return _last_prompt\n\n\n# ── Tools (unchanged) ──\n\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR):\n raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\n\ndef run_bash(command: str) -> str:\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\n\ndef run_read(path: str, limit: int | None = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_write(path: str, content: str) -> str:\n try:\n file_path = safe_path(path)\n file_path.parent.mkdir(parents=True, exist_ok=True)\n file_path.write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n]\n\nTOOL_HANDLERS = {\"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write}\n\n\n# ── Error Recovery (s11 new) ──\n\nclass RecoveryState:\n \"\"\"Track recovery attempts across the loop.\"\"\"\n def __init__(self):\n self.has_escalated = False\n self.recovery_count = 0\n self.consecutive_529 = 0\n self.has_attempted_reactive_compact = False\n self.current_model = PRIMARY_MODEL\n\n\ndef retry_delay(attempt, retry_after=None):\n \"\"\"Exponential backoff with jitter. Retry-After takes priority.\"\"\"\n if retry_after:\n return retry_after\n base = min(BASE_DELAY_MS * (2 ** attempt), 32000) / 1000\n jitter = random.uniform(0, base * 0.25)\n return base + jitter\n\n\ndef with_retry(fn, state: RecoveryState):\n \"\"\"Exponential backoff for transient errors (429/529).\n Non-transient errors are re-raised for the outer handler.\"\"\"\n for attempt in range(MAX_RETRIES):\n try:\n result = fn()\n state.consecutive_529 = 0\n return result\n except Exception as e:\n name = type(e).__name__\n msg = str(e).lower()\n\n # 429 rate limit -> exponential backoff\n if \"ratelimit\" in name.lower() or \"429\" in msg:\n delay = retry_delay(attempt)\n print(f\" \\033[33m[429 rate limit] retry {attempt+1}/{MAX_RETRIES},\"\n f\" wait {delay:.1f}s\\033[0m\")\n time.sleep(delay)\n continue\n\n # 529 overloaded -> exponential backoff + fallback model\n if \"overloaded\" in name.lower() or \"529\" in msg or \"overloaded\" in msg:\n state.consecutive_529 += 1\n if state.consecutive_529 >= MAX_CONSECUTIVE_529:\n if FALLBACK_MODEL:\n state.current_model = FALLBACK_MODEL\n state.consecutive_529 = 0\n print(f\" \\033[31m[529 x{MAX_CONSECUTIVE_529}]\"\n f\" switching to {FALLBACK_MODEL}\\033[0m\")\n else:\n state.consecutive_529 = 0\n print(f\" \\033[31m[529 x{MAX_CONSECUTIVE_529}]\"\n f\" no FALLBACK_MODEL_ID configured, continuing retry\\033[0m\")\n delay = retry_delay(attempt)\n print(f\" \\033[33m[529 overloaded] retry {attempt+1}/{MAX_RETRIES},\"\n f\" wait {delay:.1f}s\\033[0m\")\n time.sleep(delay)\n continue\n\n # Not transient -> re-raise for outer try/except\n raise\n raise RuntimeError(f\"Max retries ({MAX_RETRIES}) exceeded\")\n\n\ndef is_prompt_too_long_error(e: Exception) -> bool:\n \"\"\"Check whether an API error indicates prompt/context too long.\"\"\"\n msg = str(e).lower()\n return ((\"prompt\" in msg and \"long\" in msg)\n or \"prompt_is_too_long\" in msg\n or \"context_length_exceeded\" in msg\n or \"max_context_window\" in msg)\n\n\ndef reactive_compact(messages: list) -> list:\n \"\"\"Emergency compact — teaching version keeps last N messages.\n Real Claude Code generates a compact summary via LLM, then retries with\n the compacted message list. Teaching version simplifies to tail\n retention since s08/s09 already cover LLM-based compact.\"\"\"\n print(\" \\033[31m[reactive compact] trimming to last 5 messages\\033[0m\")\n tail = messages[-5:]\n return [{\"role\": \"user\",\n \"content\": \"[Reactive compact] Earlier conversation trimmed. \"\n \"Continue from where you left off.\"}, *tail]\n\n\n# ── Context ──\n\ndef update_context(context: dict, messages: list) -> dict:\n \"\"\"Derive context from real state: which tools exist, whether memory files exist.\"\"\"\n memories = \"\"\n if MEMORY_INDEX.exists():\n content = MEMORY_INDEX.read_text().strip()\n if content:\n memories = content\n return {\n \"enabled_tools\": list(TOOL_HANDLERS.keys()),\n \"workspace\": str(WORKDIR),\n \"memories\": memories,\n }\n\n\n# ── Agent Loop ──\n\ndef agent_loop(messages: list, context: dict):\n \"\"\"Main loop with error recovery wrapping LLM calls.\"\"\"\n system = get_system_prompt(context)\n state = RecoveryState()\n max_tokens = DEFAULT_MAX_TOKENS\n\n while True:\n # ── LLM call: with_retry handles 429/529, outer handles rest ──\n try:\n response = with_retry(\n lambda mt=max_tokens, mdl=state.current_model:\n client.messages.create(\n model=mdl, system=system, messages=messages,\n tools=TOOLS, max_tokens=mt),\n state)\n except Exception as e:\n # Path 2: prompt_too_long -> reactive compact (once)\n if is_prompt_too_long_error(e):\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n print(\" \\033[31m[unrecoverable] still too long after compact\\033[0m\")\n messages.append({\"role\": \"assistant\", \"content\": [\n {\"type\": \"text\",\n \"text\": \"[Error] Context too large, cannot continue.\"}]})\n return\n\n # Unrecoverable\n name = type(e).__name__\n print(f\" \\033[31m[unrecoverable] {name}: {str(e)[:100]}\\033[0m\")\n messages.append({\"role\": \"assistant\", \"content\": [\n {\"type\": \"text\", \"text\": f\"[Error] {name}: {str(e)[:200]}\"}]})\n return\n\n # ── Path 1: max_tokens -> escalate or continue ──\n if response.stop_reason == \"max_tokens\":\n # First escalation: don't append truncated output, retry same request\n if not state.has_escalated:\n max_tokens = ESCALATED_MAX_TOKENS\n state.has_escalated = True\n print(f\" \\033[33m[max_tokens] escalating\"\n f\" {DEFAULT_MAX_TOKENS} -> {ESCALATED_MAX_TOKENS}\\033[0m\")\n continue\n # 64K still truncated: save truncated output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if state.recovery_count < MAX_RECOVERY_RETRIES:\n messages.append({\"role\": \"user\", \"content\": CONTINUATION_PROMPT})\n state.recovery_count += 1\n print(f\" \\033[33m[max_tokens] continuation\"\n f\" {state.recovery_count}/{MAX_RECOVERY_RETRIES}\\033[0m\")\n continue\n print(\" \\033[31m[max_tokens] recovery limit reached\\033[0m\")\n return\n\n # Normal completion: append assistant response\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n # ── Tool execution ──\n results = []\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n print(str(output)[:200])\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n context = update_context(context, messages)\n system = get_system_prompt(context)\n\n\nif __name__ == \"__main__\":\n print(\"s11: error recovery\")\n print(\"Enter a question, press Enter to send. Type q to quit.\\n\")\n history = []\n context = update_context({}, [])\n while True:\n try:\n query = input(\"\\033[36ms11 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt):\n break\n if query.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n turn_start = len(history)\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history, context)\n context = update_context(context, history)\n for msg in history[turn_start:]:\n if msg.get(\"role\") != \"assistant\":\n continue\n for block in msg[\"content\"]:\n if getattr(block, \"type\", None) == \"text\":\n print(block.text)\n print()\n", "images": [ { "src": "/course-assets/s11_error_recovery/error-recovery-overview.svg", @@ -1330,7 +1356,7 @@ } ], "layer": "collaboration", - "source": "#!/usr/bin/env python3\n\"\"\"\ns12: Task System — file-persisted task graph with blockedBy dependencies.\n\nRun: python s12_task_system/code.py\nNeed: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY\n\nChanges from s11:\n - Task dataclass (id, subject, description, status, owner, blockedBy)\n - TASKS_DIR = .tasks/ for persistent JSON storage\n - create_task / save_task / load_task / list_tasks / get_task\n - can_start: checks blockedBy all completed (missing deps = blocked)\n - claim_task: set owner + pending -> in_progress\n - complete_task: set completed + report unblocked downstream\n - 5 new tools: create_task, list_tasks, get_task, claim_task, complete_task\n\nNote: Teaching code keeps a basic agent loop to stay focused on the task\nsystem. S11's full error recovery (RecoveryState, backoff, escalation,\nreactive compact, fallback model) is omitted — in real CC, tasks.ts and\nwithRetry are independent layers that compose naturally.\n\"\"\"\n\nimport os, subprocess, json, time, random\nfrom pathlib import Path\nfrom dataclasses import dataclass, asdict\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nMEMORY_DIR = WORKDIR / \".memory\"\nMEMORY_INDEX = MEMORY_DIR / \"MEMORY.md\"\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\n# ── Task System ──\n\nTASKS_DIR = WORKDIR / \".tasks\"\nTASKS_DIR.mkdir(exist_ok=True)\n\n\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str # pending | in_progress | completed\n owner: str | None # Agent name (multi-agent scenarios)\n blockedBy: list[str] # Dependency task IDs\n\n\ndef _task_path(task_id: str) -> Path:\n return TASKS_DIR / f\"{task_id}.json\"\n\n\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random.randint(0, 9999):04d}\",\n subject=subject,\n description=description,\n status=\"pending\",\n owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n\n\ndef save_task(task: Task):\n _task_path(task.id).write_text(json.dumps(asdict(task), indent=2))\n\n\ndef load_task(task_id: str) -> Task:\n return Task(**json.loads(_task_path(task_id).read_text()))\n\n\ndef list_tasks() -> list[Task]:\n return [Task(**json.loads(p.read_text()))\n for p in sorted(TASKS_DIR.glob(\"task_*.json\"))]\n\n\ndef get_task(task_id: str) -> str:\n \"\"\"Return full task details as JSON.\"\"\"\n task = load_task(task_id)\n return json.dumps(asdict(task), indent=2)\n\n\ndef can_start(task_id: str) -> bool:\n \"\"\"Check if all blockedBy dependencies are completed.\n Missing dependencies are treated as blocked.\"\"\"\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False\n if load_task(dep_id).status != \"completed\":\n return False\n return True\n\n\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if not _task_path(d).exists() or load_task(d).status != \"completed\"]\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n print(f\" \\033[36m[claim] {task.subject} → in_progress (owner: {owner})\\033[0m\")\n return f\"Claimed {task.id} ({task.subject})\"\n\n\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n if task.status != \"in_progress\":\n return f\"Task {task_id} is {task.status}, cannot complete\"\n task.status = \"completed\"\n save_task(task)\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy and can_start(t.id)]\n print(f\" \\033[32m[complete] {task.subject} ✓\\033[0m\")\n msg = f\"Completed {task.id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n print(f\" \\033[33m[unblocked] {', '.join(unblocked)}\\033[0m\")\n return msg\n\n\n# ── Prompt Assembly (from s10, synced) ──\n\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file, \"\n \"create_task, list_tasks, get_task, claim_task, complete_task.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n\n\ndef assemble_system_prompt(context: dict) -> str:\n sections = [PROMPT_SECTIONS[\"identity\"],\n PROMPT_SECTIONS[\"tools\"],\n PROMPT_SECTIONS[\"workspace\"]]\n memories = context.get(\"memories\", \"\")\n if memories:\n sections.append(f\"Relevant memories:\\n{memories}\")\n return \"\\n\\n\".join(sections)\n\n\n_last_context_key, _last_prompt = None, None\n\n\ndef get_system_prompt(context: dict) -> str:\n global _last_context_key, _last_prompt\n key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)\n if key == _last_context_key and _last_prompt:\n return _last_prompt\n _last_context_key = key\n _last_prompt = assemble_system_prompt(context)\n return _last_prompt\n\n\n# ── Tools ──\n\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR):\n raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\n\ndef run_bash(command: str) -> str:\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\n\ndef run_read(path: str, limit: int | None = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_write(path: str, content: str) -> str:\n try:\n fp = safe_path(path)\n fp.parent.mkdir(parents=True, exist_ok=True)\n fp.write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\n# Task tools\n\ndef run_create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> str:\n task = create_task(subject, description, blockedBy)\n deps = f\" (blockedBy: {', '.join(blockedBy)})\" if blockedBy else \"\"\n print(f\" \\033[34m[create] {task.subject}{deps}\\033[0m\")\n return f\"Created {task.id}: {task.subject}{deps}\"\n\n\ndef run_list_tasks() -> str:\n tasks = list_tasks()\n if not tasks:\n return \"No tasks. Use create_task to add some.\"\n lines = []\n for t in tasks:\n icon = {\"pending\": \"○\", \"in_progress\": \"●\",\n \"completed\": \"✓\"}.get(t.status, \"?\")\n deps = f\" (blockedBy: {', '.join(t.blockedBy)})\" if t.blockedBy else \"\"\n owner = f\" [{t.owner}]\" if t.owner else \"\"\n lines.append(f\" {icon} {t.id}: {t.subject} \"\n f\"[{t.status}]{owner}{deps}\")\n return \"\\n\".join(lines)\n\n\ndef run_get_task(task_id: str) -> str:\n try:\n return get_task(task_id)\n except FileNotFoundError:\n return f\"Error: Task {task_id} not found\"\n\n\ndef run_claim_task(task_id: str) -> str:\n return claim_task(task_id, owner=\"agent\")\n\n\ndef run_complete_task(task_id: str) -> str:\n return complete_task(task_id)\n\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"create_task\",\n \"description\": \"Create a new task with optional blockedBy dependencies.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"subject\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n \"blockedBy\": {\"type\": \"array\",\n \"items\": {\"type\": \"string\"}}},\n \"required\": [\"subject\"]}},\n {\"name\": \"list_tasks\",\n \"description\": \"List all tasks with status, owner, and dependencies.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n {\"name\": \"get_task\",\n \"description\": \"Get full details of a specific task by ID.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"claim_task\",\n \"description\": \"Claim a pending task. Sets owner, changes status to in_progress.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\",\n \"description\": \"Complete an in-progress task. Reports unblocked downstream tasks.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n]\n\nTOOL_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"create_task\": run_create_task, \"list_tasks\": run_list_tasks,\n \"get_task\": run_get_task, \"claim_task\": run_claim_task,\n \"complete_task\": run_complete_task,\n}\n\n\n# ── Context ──\n\ndef update_context(context: dict, messages: list) -> dict:\n \"\"\"Derive context from real state.\"\"\"\n memories = \"\"\n if MEMORY_INDEX.exists():\n content = MEMORY_INDEX.read_text().strip()\n if content:\n memories = content\n return {\n \"enabled_tools\": list(TOOL_HANDLERS.keys()),\n \"workspace\": str(WORKDIR),\n \"memories\": memories,\n }\n\n\n# ── Agent Loop (simplified, focused on task system) ──\n\ndef agent_loop(messages: list, context: dict):\n system = get_system_prompt(context)\n while True:\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages,\n tools=TOOLS, max_tokens=8000)\n except Exception as e:\n messages.append({\"role\": \"assistant\", \"content\": [\n {\"type\": \"text\",\n \"text\": f\"[Error] {type(e).__name__}: {e}\"}]})\n return\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n print(str(output)[:300])\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n context = update_context(context, messages)\n system = get_system_prompt(context)\n\n\nif __name__ == \"__main__\":\n print(\"s12: task system\")\n print(\"Enter a question, press Enter to send. Type q to quit.\\n\")\n history = []\n context = update_context({}, [])\n while True:\n try:\n query = input(\"\\033[36ms12 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt):\n break\n if query.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history, context)\n context = update_context(context, history)\n for block in history[-1][\"content\"]:\n if getattr(block, \"type\", None) == \"text\":\n print(block.text)\n print()\n", + "source": "#!/usr/bin/env python3\n\"\"\"\ns12: Task System — file-persisted task graph with blockedBy dependencies.\n\nRun: python s12_task_system/code.py\nNeed: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY\n\nChanges from s11:\n - Task dataclass (id, subject, description, status, owner, blockedBy)\n - TASKS_DIR = .tasks/ for persistent JSON storage\n - create_task / save_task / load_task / list_tasks / get_task\n - can_start: checks blockedBy all completed (missing deps = blocked)\n - claim_task: set owner + pending -> in_progress\n - complete_task: set completed + report unblocked downstream\n - 5 new tools: create_task, list_tasks, get_task, claim_task, complete_task\n\nNote: Teaching code keeps a basic agent loop to stay focused on the task\nsystem. S11's full error recovery (RecoveryState, backoff, escalation,\nreactive compact, fallback model) is omitted — in real Claude Code, tasks.ts and\nwithRetry are independent layers that compose naturally.\n\"\"\"\n\nimport os, subprocess, json, time, random\nfrom pathlib import Path\nfrom dataclasses import dataclass, asdict\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nMEMORY_DIR = WORKDIR / \".memory\"\nMEMORY_INDEX = MEMORY_DIR / \"MEMORY.md\"\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\n# ── Task System ──\n\nTASKS_DIR = WORKDIR / \".tasks\"\nTASKS_DIR.mkdir(exist_ok=True)\n\n\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str # pending | in_progress | completed\n owner: str | None # Agent name (multi-agent scenarios)\n blockedBy: list[str] # Dependency task IDs\n\n\ndef _task_path(task_id: str) -> Path:\n return TASKS_DIR / f\"{task_id}.json\"\n\n\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random.randint(0, 9999):04d}\",\n subject=subject,\n description=description,\n status=\"pending\",\n owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n\n\ndef save_task(task: Task):\n _task_path(task.id).write_text(json.dumps(asdict(task), indent=2))\n\n\ndef load_task(task_id: str) -> Task:\n return Task(**json.loads(_task_path(task_id).read_text()))\n\n\ndef list_tasks() -> list[Task]:\n return [Task(**json.loads(p.read_text()))\n for p in sorted(TASKS_DIR.glob(\"task_*.json\"))]\n\n\ndef get_task(task_id: str) -> str:\n \"\"\"Return full task details as JSON.\"\"\"\n task = load_task(task_id)\n return json.dumps(asdict(task), indent=2)\n\n\ndef can_start(task_id: str) -> bool:\n \"\"\"Check if all blockedBy dependencies are completed.\n Missing dependencies are treated as blocked.\"\"\"\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False\n if load_task(dep_id).status != \"completed\":\n return False\n return True\n\n\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if not _task_path(d).exists() or load_task(d).status != \"completed\"]\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n print(f\" \\033[36m[claim] {task.subject} → in_progress (owner: {owner})\\033[0m\")\n return f\"Claimed {task.id} ({task.subject})\"\n\n\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n if task.status != \"in_progress\":\n return f\"Task {task_id} is {task.status}, cannot complete\"\n task.status = \"completed\"\n save_task(task)\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy and can_start(t.id)]\n print(f\" \\033[32m[complete] {task.subject} ✓\\033[0m\")\n msg = f\"Completed {task.id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n print(f\" \\033[33m[unblocked] {', '.join(unblocked)}\\033[0m\")\n return msg\n\n\n# ── Prompt Assembly (from s10, synced) ──\n\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file, \"\n \"create_task, list_tasks, get_task, claim_task, complete_task.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n\n\ndef assemble_system_prompt(context: dict) -> str:\n sections = [PROMPT_SECTIONS[\"identity\"],\n PROMPT_SECTIONS[\"tools\"],\n PROMPT_SECTIONS[\"workspace\"]]\n memories = context.get(\"memories\", \"\")\n if memories:\n sections.append(f\"Relevant memories:\\n{memories}\")\n return \"\\n\\n\".join(sections)\n\n\n_last_context_key, _last_prompt = None, None\n\n\ndef get_system_prompt(context: dict) -> str:\n global _last_context_key, _last_prompt\n key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)\n if key == _last_context_key and _last_prompt:\n return _last_prompt\n _last_context_key = key\n _last_prompt = assemble_system_prompt(context)\n return _last_prompt\n\n\n# ── Tools ──\n\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR):\n raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\n\ndef run_bash(command: str) -> str:\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\n\ndef run_read(path: str, limit: int | None = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_write(path: str, content: str) -> str:\n try:\n fp = safe_path(path)\n fp.parent.mkdir(parents=True, exist_ok=True)\n fp.write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\n# Task tools\n\ndef run_create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> str:\n task = create_task(subject, description, blockedBy)\n deps = f\" (blockedBy: {', '.join(blockedBy)})\" if blockedBy else \"\"\n print(f\" \\033[34m[create] {task.subject}{deps}\\033[0m\")\n return f\"Created {task.id}: {task.subject}{deps}\"\n\n\ndef run_list_tasks() -> str:\n tasks = list_tasks()\n if not tasks:\n return \"No tasks. Use create_task to add some.\"\n lines = []\n for t in tasks:\n icon = {\"pending\": \"○\", \"in_progress\": \"●\",\n \"completed\": \"✓\"}.get(t.status, \"?\")\n deps = f\" (blockedBy: {', '.join(t.blockedBy)})\" if t.blockedBy else \"\"\n owner = f\" [{t.owner}]\" if t.owner else \"\"\n lines.append(f\" {icon} {t.id}: {t.subject} \"\n f\"[{t.status}]{owner}{deps}\")\n return \"\\n\".join(lines)\n\n\ndef run_get_task(task_id: str) -> str:\n try:\n return get_task(task_id)\n except FileNotFoundError:\n return f\"Error: Task {task_id} not found\"\n\n\ndef run_claim_task(task_id: str) -> str:\n return claim_task(task_id, owner=\"agent\")\n\n\ndef run_complete_task(task_id: str) -> str:\n return complete_task(task_id)\n\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"create_task\",\n \"description\": \"Create a new task with optional blockedBy dependencies.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"subject\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n \"blockedBy\": {\"type\": \"array\",\n \"items\": {\"type\": \"string\"}}},\n \"required\": [\"subject\"]}},\n {\"name\": \"list_tasks\",\n \"description\": \"List all tasks with status, owner, and dependencies.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n {\"name\": \"get_task\",\n \"description\": \"Get full details of a specific task by ID.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"claim_task\",\n \"description\": \"Claim a pending task. Sets owner, changes status to in_progress.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\",\n \"description\": \"Complete an in-progress task. Reports unblocked downstream tasks.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n]\n\nTOOL_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"create_task\": run_create_task, \"list_tasks\": run_list_tasks,\n \"get_task\": run_get_task, \"claim_task\": run_claim_task,\n \"complete_task\": run_complete_task,\n}\n\n\n# ── Context ──\n\ndef update_context(context: dict, messages: list) -> dict:\n \"\"\"Derive context from real state.\"\"\"\n memories = \"\"\n if MEMORY_INDEX.exists():\n content = MEMORY_INDEX.read_text().strip()\n if content:\n memories = content\n return {\n \"enabled_tools\": list(TOOL_HANDLERS.keys()),\n \"workspace\": str(WORKDIR),\n \"memories\": memories,\n }\n\n\n# ── Agent Loop (simplified, focused on task system) ──\n\ndef agent_loop(messages: list, context: dict):\n system = get_system_prompt(context)\n while True:\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages,\n tools=TOOLS, max_tokens=8000)\n except Exception as e:\n messages.append({\"role\": \"assistant\", \"content\": [\n {\"type\": \"text\",\n \"text\": f\"[Error] {type(e).__name__}: {e}\"}]})\n return\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n print(str(output)[:300])\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n context = update_context(context, messages)\n system = get_system_prompt(context)\n\n\nif __name__ == \"__main__\":\n print(\"s12: task system\")\n print(\"Enter a question, press Enter to send. Type q to quit.\\n\")\n history = []\n context = update_context({}, [])\n while True:\n try:\n query = input(\"\\033[36ms12 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt):\n break\n if query.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history, context)\n context = update_context(context, history)\n for block in history[-1][\"content\"]:\n if getattr(block, \"type\", None) == \"text\":\n print(block.text)\n print()\n", "images": [ { "src": "/course-assets/s12_task_system/task-dag.svg", @@ -1758,7 +1784,7 @@ "filename": "s15_agent_teams/code.py", "title": "Agent Teams", "subtitle": "One Agent Isn't Enough, Form a Team", - "loc": 745, + "loc": 784, "tools": [ "bash", "read_file", @@ -1790,13 +1816,13 @@ }, { "name": "CronJob", - "startLine": 353, - "endLine": 360 + "startLine": 360, + "endLine": 367 }, { "name": "MessageBus", - "startLine": 595, - "endLine": 620 + "startLine": 602, + "endLine": 634 } ], "functions": [ @@ -1915,94 +1941,99 @@ "signature": "def collect_background_results()", "startLine": 324 }, + { + "name": "has_pending_background", + "signature": "def has_pending_background()", + "startLine": 347 + }, { "name": "_cron_field_matches", "signature": "def _cron_field_matches(field: str, value: int)", - "startLine": 367 + "startLine": 374 }, { "name": "cron_matches", "signature": "def cron_matches(cron_expr: str, dt: datetime)", - "startLine": 383 + "startLine": 390 }, { "name": "_validate_cron_field", "signature": "def _validate_cron_field(field: str, lo: int, hi: int)", - "startLine": 413 + "startLine": 420 }, { "name": "validate_cron", "signature": "def validate_cron(cron_expr: str)", - "startLine": 448 + "startLine": 455 }, { "name": "save_durable_jobs", "signature": "def save_durable_jobs()", - "startLine": 462 + "startLine": 469 }, { "name": "load_durable_jobs", "signature": "def load_durable_jobs()", - "startLine": 468 + "startLine": 475 }, { "name": "cancel_job", "signature": "def cancel_job(job_id: str)", - "startLine": 507 + "startLine": 514 }, { "name": "cron_scheduler_loop", "signature": "def cron_scheduler_loop()", - "startLine": 519 + "startLine": 526 }, { "name": "consume_cron_queue", "signature": "def consume_cron_queue()", - "startLine": 545 + "startLine": 552 }, { "name": "run_list_crons", "signature": "def run_list_crons()", - "startLine": 569 + "startLine": 576 }, { "name": "run_cancel_cron", "signature": "def run_cancel_cron(job_id: str)", - "startLine": 583 + "startLine": 590 }, { "name": "spawn_teammate_thread", "signature": "def spawn_teammate_thread(name: str, role: str, prompt: str)", - "startLine": 629 + "startLine": 643 }, { "name": "run_spawn_teammate", "signature": "def run_spawn_teammate(name: str, role: str, prompt: str)", - "startLine": 717 + "startLine": 731 }, { "name": "run_send_message", "signature": "def run_send_message(to: str, content: str)", - "startLine": 721 + "startLine": 735 }, { "name": "run_check_inbox", "signature": "def run_check_inbox()", - "startLine": 726 + "startLine": 740 }, { "name": "update_context", "signature": "def update_context(context: dict, messages: list)", - "startLine": 828 + "startLine": 842 }, { "name": "agent_loop", "signature": "def agent_loop(messages: list, context: dict)", - "startLine": 847 + "startLine": 861 } ], "layer": "collaboration", - "source": "#!/usr/bin/env python3\n\"\"\"\ns15: Agent Teams — MessageBus + spawn_teammate_thread + inbox injection.\n\nRun: python s15_agent_teams/code.py\nNeed: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY\n\nChanges from s14:\n - MessageBus class: file-based mailboxes (.mailboxes/*.jsonl)\n - spawn_teammate_thread: creates teammate in background thread\n - Teammate runs own simplified agent_loop (bash, read, write, send_message)\n - Lead tools: spawn_teammate, send_message, check_inbox (3 new)\n - Lead inbox: teammate messages injected into history (not just printed)\n - Teaching version: teammates limited to 10 rounds (real CC uses idle loop)\n\nASCII flow:\n Lead: cron_queue → messages → prompt → LLM → TOOLS ────→ loop\n ↑ ↓ |\n └── inbox ← MessageBus ← teammate.send_message ←┘\n Teammate: inbox → LLM → bash/read/write/send → loop (max 10 turns)\n\"\"\"\n\nimport os, subprocess, json, time, random, threading\nfrom pathlib import Path\nfrom datetime import datetime\nfrom dataclasses import dataclass, asdict\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nMEMORY_DIR = WORKDIR / \".memory\"\nMEMORY_INDEX = MEMORY_DIR / \"MEMORY.md\"\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\n# ── Task System (from s12, synced) ──\n\nTASKS_DIR = WORKDIR / \".tasks\"\nTASKS_DIR.mkdir(exist_ok=True)\n\n\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str # pending | in_progress | completed\n owner: str | None\n blockedBy: list[str]\n\n\ndef _task_path(task_id: str) -> Path:\n return TASKS_DIR / f\"{task_id}.json\"\n\n\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random.randint(0, 9999):04d}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n\n\ndef save_task(task: Task):\n _task_path(task.id).write_text(json.dumps(asdict(task), indent=2))\n\n\ndef load_task(task_id: str) -> Task:\n return Task(**json.loads(_task_path(task_id).read_text()))\n\n\ndef list_tasks() -> list[Task]:\n return [Task(**json.loads(p.read_text()))\n for p in sorted(TASKS_DIR.glob(\"task_*.json\"))]\n\n\ndef get_task(task_id: str) -> str:\n \"\"\"Return full task details as JSON.\"\"\"\n task = load_task(task_id)\n return json.dumps(asdict(task), indent=2)\n\n\ndef can_start(task_id: str) -> bool:\n \"\"\"Check if all blockedBy dependencies are completed.\n Missing dependencies are treated as blocked.\"\"\"\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False\n if load_task(dep_id).status != \"completed\":\n return False\n return True\n\n\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if not _task_path(d).exists() or load_task(d).status != \"completed\"]\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n print(f\" \\033[36m[claim] {task.subject} → in_progress (owner: {owner})\\033[0m\")\n return f\"Claimed {task.id} ({task.subject})\"\n\n\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n if task.status != \"in_progress\":\n return f\"Task {task_id} is {task.status}, cannot complete\"\n task.status = \"completed\"\n save_task(task)\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy and can_start(t.id)]\n print(f\" \\033[32m[complete] {task.subject} ✓\\033[0m\")\n msg = f\"Completed {task.id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n print(f\" \\033[33m[unblocked] {', '.join(unblocked)}\\033[0m\")\n return msg\n\n\n# ── Prompt Assembly (from s10, synced) ──\n\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file, \"\n \"get_task, create_task, list_tasks, claim_task, complete_task, \"\n \"schedule_cron, list_crons, cancel_cron, \"\n \"spawn_teammate, send_message, check_inbox.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n\n\ndef assemble_system_prompt(context: dict) -> str:\n sections = [PROMPT_SECTIONS[\"identity\"],\n PROMPT_SECTIONS[\"tools\"],\n PROMPT_SECTIONS[\"workspace\"]]\n memories = context.get(\"memories\", \"\")\n if memories:\n sections.append(f\"Relevant memories:\\n{memories}\")\n return \"\\n\\n\".join(sections)\n\n\n_last_context_key, _last_prompt = None, None\n\n\ndef get_system_prompt(context: dict) -> str:\n global _last_context_key, _last_prompt\n key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)\n if key == _last_context_key and _last_prompt:\n return _last_prompt\n _last_context_key = key\n _last_prompt = assemble_system_prompt(context)\n return _last_prompt\n\n\n# ── Tools ──\n\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR):\n raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\n\ndef run_bash(command: str, run_in_background: bool = False) -> str:\n # run_in_background is handled by agent_loop dispatch, not here\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\n\ndef run_read(path: str, limit: int | None = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_write(path: str, content: str) -> str:\n try:\n fp = safe_path(path)\n fp.parent.mkdir(parents=True, exist_ok=True)\n fp.write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\n# Task tools\n\ndef run_create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> str:\n task = create_task(subject, description, blockedBy)\n deps = f\" (blockedBy: {', '.join(blockedBy)})\" if blockedBy else \"\"\n print(f\" \\033[34m[create] {task.subject}{deps}\\033[0m\")\n return f\"Created {task.id}: {task.subject}{deps}\"\n\n\ndef run_list_tasks() -> str:\n tasks = list_tasks()\n if not tasks:\n return \"No tasks. Use create_task to add some.\"\n lines = []\n for t in tasks:\n icon = {\"pending\": \"○\", \"in_progress\": \"●\",\n \"completed\": \"✓\"}.get(t.status, \"?\")\n deps = f\" (blockedBy: {', '.join(t.blockedBy)})\" if t.blockedBy else \"\"\n owner = f\" [{t.owner}]\" if t.owner else \"\"\n lines.append(f\" {icon} {t.id}: {t.subject} \"\n f\"[{t.status}]{owner}{deps}\")\n return \"\\n\".join(lines)\n\n\ndef run_get_task(task_id: str) -> str:\n try:\n return get_task(task_id)\n except FileNotFoundError:\n return f\"Error: Task {task_id} not found\"\n\n\ndef run_claim_task(task_id: str) -> str:\n return claim_task(task_id, owner=\"agent\")\n\n\ndef run_complete_task(task_id: str) -> str:\n return complete_task(task_id)\n\n\n# ── Background Tasks (from s13, synced) ──\n\n_bg_counter = 0\nbackground_tasks: dict[str, dict] = {}\nbackground_results: dict[str, str] = {}\nbackground_lock = threading.Lock()\n\n\ndef is_slow_operation(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Fallback heuristic: commands likely to take > 30s.\"\"\"\n if tool_name != \"bash\":\n return False\n cmd = tool_input.get(\"command\", \"\").lower()\n slow_keywords = [\"install\", \"build\", \"test\", \"deploy\", \"compile\",\n \"docker build\", \"pip install\", \"npm install\",\n \"cargo build\", \"pytest\", \"make\"]\n return any(kw in cmd for kw in slow_keywords)\n\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Model explicit request takes priority; fallback to heuristic.\"\"\"\n if tool_input.get(\"run_in_background\"):\n return True\n return is_slow_operation(tool_name, tool_input)\n\n\ndef execute_tool(block) -> str:\n \"\"\"Execute a tool call block, return output.\"\"\"\n handler = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"create_task\": run_create_task, \"list_tasks\": run_list_tasks,\n \"get_task\": run_get_task, \"claim_task\": run_claim_task,\n \"complete_task\": run_complete_task,\n \"schedule_cron\": run_schedule_cron, \"list_crons\": run_list_crons,\n \"cancel_cron\": run_cancel_cron,\n \"spawn_teammate\": run_spawn_teammate,\n \"send_message\": run_send_message, \"check_inbox\": run_check_inbox,\n }.get(block.name)\n if handler:\n return handler(**block.input)\n return f\"Unknown tool: {block.name}\"\n\n\ndef start_background_task(block) -> str:\n \"\"\"Run tool in a daemon thread. Returns background task ID.\"\"\"\n global _bg_counter\n _bg_counter += 1\n bg_id = f\"bg_{_bg_counter:04d}\"\n cmd = block.input.get(\"command\", block.name)\n\n def worker():\n result = execute_tool(block)\n with background_lock:\n background_tasks[bg_id][\"status\"] = \"completed\"\n background_results[bg_id] = result\n\n with background_lock:\n background_tasks[bg_id] = {\n \"tool_use_id\": block.id,\n \"command\": cmd,\n \"status\": \"running\",\n }\n threading.Thread(target=worker, daemon=True).start()\n print(f\" \\033[33m[background] dispatched {bg_id}: {cmd[:40]}\\033[0m\")\n return bg_id\n\n\ndef collect_background_results() -> list[str]:\n \"\"\"Collect completed background results as task_notification messages.\"\"\"\n with background_lock:\n ready_ids = [bid for bid, task in background_tasks.items()\n if task[\"status\"] == \"completed\"]\n notifications = []\n for bg_id in ready_ids:\n with background_lock:\n task = background_tasks.pop(bg_id)\n output = background_results.pop(bg_id, \"\")\n summary = output[:200] if len(output) > 200 else output\n notifications.append(\n f\"\\n\"\n f\" {bg_id}\\n\"\n f\" completed\\n\"\n f\" {task['command']}\\n\"\n f\" {summary}\\n\"\n f\"\")\n print(f\" \\033[32m[background done] {bg_id}: \"\n f\"{task['command'][:40]} ({len(output)} chars)\\033[0m\")\n return notifications\n\n\n# ── Cron Scheduler (from s14, synced) ──\n\nDURABLE_PATH = WORKDIR / \".scheduled_tasks.json\"\n\n\n@dataclass\nclass CronJob:\n id: str\n cron: str # \"0 9 * * *\"\n prompt: str # message to inject when fired\n recurring: bool # True = recurring, False = one-shot\n durable: bool # True = persist to disk\n\n\nscheduled_jobs: dict[str, CronJob] = {}\ncron_queue: list[CronJob] = []\ncron_lock = threading.Lock()\n_last_fired: dict[str, str] = {} # job_id → \"YYYY-MM-DD HH:MM\"\n\n\ndef _cron_field_matches(field: str, value: int) -> bool:\n \"\"\"Match a single cron field against a value.\"\"\"\n if field == \"*\":\n return True\n if field.startswith(\"*/\"):\n step = int(field[2:])\n return step > 0 and value % step == 0\n if \",\" in field:\n return any(_cron_field_matches(f.strip(), value)\n for f in field.split(\",\"))\n if \"-\" in field:\n lo, hi = field.split(\"-\", 1)\n return int(lo) <= value <= int(hi)\n return value == int(field)\n\n\ndef cron_matches(cron_expr: str, dt: datetime) -> bool:\n \"\"\"Check if a 5-field cron expression matches the given datetime.\n Standard cron semantics: DOM and DOW use OR when both are constrained.\"\"\"\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return False\n minute, hour, dom, month, dow = fields\n dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0\n\n m = _cron_field_matches(minute, dt.minute)\n h = _cron_field_matches(hour, dt.hour)\n dom_ok = _cron_field_matches(dom, dt.day)\n month_ok = _cron_field_matches(month, dt.month)\n dow_ok = _cron_field_matches(dow, dow_val)\n\n # Minute, hour, month must all match\n if not (m and h and month_ok):\n return False\n # DOM and DOW: if both constrained, either matching is enough (OR)\n dom_unconstrained = dom == \"*\"\n dow_unconstrained = dow == \"*\"\n if dom_unconstrained and dow_unconstrained:\n return True\n if dom_unconstrained:\n return dow_ok\n if dow_unconstrained:\n return dom_ok\n return dom_ok or dow_ok\n\n\ndef _validate_cron_field(field: str, lo: int, hi: int) -> str | None:\n \"\"\"Validate a single cron field value is within [lo, hi].\"\"\"\n if field == \"*\":\n return None\n if field.startswith(\"*/\"):\n step_str = field[2:]\n if not step_str.isdigit():\n return f\"Invalid step: {field}\"\n step = int(step_str)\n if step <= 0:\n return f\"Step must be > 0: {field}\"\n return None\n if \",\" in field:\n for part in field.split(\",\"):\n err = _validate_cron_field(part.strip(), lo, hi)\n if err: return err\n return None\n if \"-\" in field:\n parts = field.split(\"-\", 1)\n if not parts[0].isdigit() or not parts[1].isdigit():\n return f\"Invalid range: {field}\"\n a, b = int(parts[0]), int(parts[1])\n if a < lo or a > hi or b < lo or b > hi:\n return f\"Range {field} out of bounds [{lo}-{hi}]\"\n if a > b:\n return f\"Range start > end: {field}\"\n return None\n if not field.isdigit():\n return f\"Invalid field: {field}\"\n val = int(field)\n if val < lo or val > hi:\n return f\"Value {val} out of bounds [{lo}-{hi}]\"\n return None\n\n\ndef validate_cron(cron_expr: str) -> str | None:\n \"\"\"Validate a cron expression. Returns error message or None.\"\"\"\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return f\"Expected 5 fields, got {len(fields)}\"\n bounds = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 6)]\n names = [\"minute\", \"hour\", \"day-of-month\", \"month\", \"day-of-week\"]\n for i, (field, (lo, hi), name) in enumerate(zip(fields, bounds, names)):\n err = _validate_cron_field(field, lo, hi)\n if err:\n return f\"{name}: {err}\"\n return None\n\n\ndef save_durable_jobs():\n \"\"\"Persist durable jobs to .scheduled_tasks.json.\"\"\"\n durable = [asdict(j) for j in scheduled_jobs.values() if j.durable]\n DURABLE_PATH.write_text(json.dumps(durable, indent=2))\n\n\ndef load_durable_jobs():\n \"\"\"Load durable jobs from disk on startup.\"\"\"\n if not DURABLE_PATH.exists():\n return\n try:\n jobs = json.loads(DURABLE_PATH.read_text())\n for j in jobs:\n job = CronJob(**j)\n err = validate_cron(job.cron)\n if err:\n print(f\" \\033[31m[cron] skipping invalid job {job.id}: {err}\\033[0m\")\n continue\n scheduled_jobs[job.id] = job\n valid = [j for j in jobs if j[\"id\"] in scheduled_jobs]\n if valid:\n print(f\" \\033[35m[cron] loaded {len(valid)} durable job(s)\\033[0m\")\n except Exception:\n pass\n\n\ndef schedule_job(cron: str, prompt: str, recurring: bool = True,\n durable: bool = True) -> CronJob | str:\n \"\"\"Register a new cron job. Returns CronJob or error string.\"\"\"\n err = validate_cron(cron)\n if err:\n return err\n job = CronJob(\n id=f\"cron_{random.randint(0, 999999):06d}\",\n cron=cron, prompt=prompt,\n recurring=recurring, durable=durable,\n )\n with cron_lock:\n scheduled_jobs[job.id] = job\n if durable:\n save_durable_jobs()\n print(f\" \\033[35m[cron register] {job.id} '{cron}' → {prompt[:40]}\\033[0m\")\n return job\n\n\ndef cancel_job(job_id: str) -> str:\n \"\"\"Cancel a cron job.\"\"\"\n with cron_lock:\n job = scheduled_jobs.pop(job_id, None)\n if not job:\n return f\"Job {job_id} not found\"\n if job.durable:\n save_durable_jobs()\n print(f\" \\033[31m[cron cancel] {job_id}\\033[0m\")\n return f\"Cancelled {job_id}\"\n\n\ndef cron_scheduler_loop():\n \"\"\"Independent daemon thread: poll every 1s, fire matching jobs.\n Individual job errors are caught to prevent one bad job from\n killing the entire scheduler thread.\"\"\"\n while True:\n time.sleep(1)\n now = datetime.now()\n # Date-aware marker prevents daily jobs from skipping on day 2+\n minute_marker = now.strftime(\"%Y-%m-%d %H:%M\")\n with cron_lock:\n for job in list(scheduled_jobs.values()):\n try:\n if cron_matches(job.cron, now):\n if _last_fired.get(job.id) != minute_marker:\n cron_queue.append(job)\n _last_fired[job.id] = minute_marker\n print(f\" \\033[35m[cron fire] {job.id} → \"\n f\"{job.prompt[:40]}\\033[0m\")\n if not job.recurring:\n scheduled_jobs.pop(job.id, None)\n if job.durable:\n save_durable_jobs()\n except Exception as e:\n print(f\" \\033[31m[cron error] {job.id}: {e}\\033[0m\")\n\n\ndef consume_cron_queue() -> list[CronJob]:\n \"\"\"Consume fired jobs from cron_queue (called by agent_loop).\"\"\"\n with cron_lock:\n fired = list(cron_queue)\n cron_queue.clear()\n return fired\n\n\n# Load durable jobs on startup, then start scheduler thread\nload_durable_jobs()\nthreading.Thread(target=cron_scheduler_loop, daemon=True).start()\nprint(\" \\033[35m[cron] scheduler thread started\\033[0m\")\n\n\n# Cron tool handlers\n\ndef run_schedule_cron(cron: str, prompt: str,\n recurring: bool = True, durable: bool = True) -> str:\n result = schedule_job(cron, prompt, recurring, durable)\n if isinstance(result, str):\n return f\"Error: {result}\"\n return f\"Scheduled {result.id}: '{cron}' → {prompt}\"\n\n\ndef run_list_crons() -> str:\n with cron_lock:\n jobs = list(scheduled_jobs.values())\n if not jobs:\n return \"No cron jobs. Use schedule_cron to add one.\"\n lines = []\n for j in jobs:\n tag = \"recurring\" if j.recurring else \"one-shot\"\n dur = \"durable\" if j.durable else \"session\"\n lines.append(f\" {j.id}: '{j.cron}' → {j.prompt[:40]} \"\n f\"[{tag}, {dur}]\")\n return \"\\n\".join(lines)\n\n\ndef run_cancel_cron(job_id: str) -> str:\n return cancel_job(job_id)\n\n\n# ── MessageBus (s15 new) ──\n# Teaching version uses simple file append + unlink.\n# Real CC uses proper-lockfile for concurrent write safety.\n\nMAILBOX_DIR = WORKDIR / \".mailboxes\"\nMAILBOX_DIR.mkdir(exist_ok=True)\n\n\nclass MessageBus:\n \"\"\"File-based message bus. Each agent has a .jsonl inbox.\n Read is destructive: read_text + unlink (consumes messages).\n Teaching version: no file locking; real CC uses proper-lockfile.\"\"\"\n\n def send(self, from_agent: str, to_agent: str, content: str,\n msg_type: str = \"message\"):\n msg = {\"from\": from_agent, \"to\": to_agent,\n \"content\": content, \"type\": msg_type,\n \"ts\": time.time()}\n inbox = MAILBOX_DIR / f\"{to_agent}.jsonl\"\n with open(inbox, \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n print(f\" \\033[33m[bus] {from_agent} → {to_agent}: \"\n f\"{content[:50]}\\033[0m\")\n\n def read_inbox(self, agent: str) -> list[dict]:\n inbox = MAILBOX_DIR / f\"{agent}.jsonl\"\n if not inbox.exists():\n return []\n msgs = [json.loads(line) for line in inbox.read_text().splitlines()\n if line.strip()]\n inbox.unlink() # consume: read + delete\n return msgs\n\n\nBUS = MessageBus()\n\n# Track spawned teammates\nactive_teammates: dict[str, bool] = {}\n\n\n# ── Teammate Thread (s15 new) ──\n\ndef spawn_teammate_thread(name: str, role: str, prompt: str) -> str:\n \"\"\"Spawn a teammate agent in a background thread.\n Teaching version: max 10 rounds per teammate.\n Real CC: teammates use idle loop (wait for inbox, work, repeat)\n until shutdown_request.\"\"\"\n if name in active_teammates:\n return f\"Teammate '{name}' already exists\"\n\n system = (f\"You are '{name}', a {role}. \"\n f\"Use tools to complete tasks. \"\n f\"Send results via send_message to 'lead'.\")\n\n def run():\n messages = [{\"role\": \"user\", \"content\": prompt}]\n sub_tools = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"send_message\",\n \"description\": \"Send a message to another agent.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n ]\n sub_handlers = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"send_message\": lambda to, content: (BUS.send(name, to, content),\n \"Sent\")[1],\n }\n\n for _ in range(10):\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"{json.dumps(inbox)}\"})\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages[-20:],\n tools=sub_tools, max_tokens=8000)\n except Exception:\n break\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n handler = sub_handlers.get(block.name)\n output = handler(**block.input) if handler else \"Unknown\"\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # Send final summary to Lead\n summary = \"Done.\"\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\" and isinstance(msg[\"content\"], list):\n for b in msg[\"content\"]:\n if getattr(b, \"type\", None) == \"text\":\n summary = b.text\n break\n else:\n continue\n break\n BUS.send(name, \"lead\", summary, \"result\")\n active_teammates.pop(name, None)\n print(f\" \\033[32m[teammate] {name} finished\\033[0m\")\n\n active_teammates[name] = True\n threading.Thread(target=run, daemon=True).start()\n print(f\" \\033[36m[teammate] {name} spawned as {role}\\033[0m\")\n return f\"Teammate '{name}' spawned as {role}\"\n\n\n# ── Team Tool Handlers (s15 new) ──\n\ndef run_spawn_teammate(name: str, role: str, prompt: str) -> str:\n return spawn_teammate_thread(name, role, prompt)\n\n\ndef run_send_message(to: str, content: str) -> str:\n BUS.send(\"lead\", to, content)\n return f\"Sent to {to}\"\n\n\ndef run_check_inbox() -> str:\n msgs = BUS.read_inbox(\"lead\")\n if not msgs:\n return \"(inbox empty)\"\n lines = []\n for m in msgs:\n lines.append(f\" [{m['from']}] {m['content'][:200]}\")\n return \"\\n\".join(lines)\n\n\n# ── Tool Definitions ──\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\"},\n \"run_in_background\": {\"type\": \"boolean\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"create_task\",\n \"description\": \"Create a new task with optional blockedBy dependencies.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"subject\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n \"blockedBy\": {\"type\": \"array\",\n \"items\": {\"type\": \"string\"}}},\n \"required\": [\"subject\"]}},\n {\"name\": \"list_tasks\",\n \"description\": \"List all tasks with status, owner, and dependencies.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n {\"name\": \"get_task\",\n \"description\": \"Get full details of a specific task by ID.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"claim_task\",\n \"description\": \"Claim a pending task. Sets owner, changes status to in_progress.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\",\n \"description\": \"Complete an in-progress task. Reports unblocked downstream tasks.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"schedule_cron\",\n \"description\": \"Schedule a cron job. cron is 5-field: min hour dom month dow.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"cron\": {\"type\": \"string\",\n \"description\": \"5-field cron expression\"},\n \"prompt\": {\"type\": \"string\",\n \"description\": \"Message to inject when fired\"},\n \"recurring\": {\"type\": \"boolean\",\n \"description\": \"True=recurring, False=one-shot\"},\n \"durable\": {\"type\": \"boolean\",\n \"description\": \"True=persist to disk\"}},\n \"required\": [\"cron\", \"prompt\"]}},\n {\"name\": \"list_crons\",\n \"description\": \"List all registered cron jobs.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n {\"name\": \"cancel_cron\",\n \"description\": \"Cancel a cron job by ID.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"job_id\": {\"type\": \"string\"}},\n \"required\": [\"job_id\"]}},\n {\"name\": \"spawn_teammate\",\n \"description\": \"Spawn a teammate agent in a background thread.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\"},\n \"role\": {\"type\": \"string\"},\n \"prompt\": {\"type\": \"string\"}},\n \"required\": [\"name\", \"role\", \"prompt\"]}},\n {\"name\": \"send_message\",\n \"description\": \"Send a message to a teammate via MessageBus.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"check_inbox\",\n \"description\": \"Check Lead's inbox for teammate messages.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n]\n\n\n# ── Context ──\n\ndef update_context(context: dict, messages: list) -> dict:\n \"\"\"Derive context from real state.\"\"\"\n memories = \"\"\n if MEMORY_INDEX.exists():\n content = MEMORY_INDEX.read_text().strip()\n if content:\n memories = content\n return {\n \"enabled_tools\": [t[\"name\"] for t in TOOLS],\n \"workspace\": str(WORKDIR),\n \"memories\": memories,\n }\n\n\n# ── Agent Loop ──\n# Teaching code keeps a basic agent loop. S11's full error recovery is omitted.\n# Cron queue is consumed when agent_loop is called; real CC auto-wakes via\n# queue processor (useQueueProcessor.ts) when items arrive.\n\ndef agent_loop(messages: list, context: dict):\n system = get_system_prompt(context)\n while True:\n # Consume fired cron jobs → inject as messages\n fired = consume_cron_queue()\n for job in fired:\n messages.append({\"role\": \"user\",\n \"content\": f\"[Scheduled] {job.prompt}\"})\n print(f\" \\033[35m[inject cron] {job.prompt[:50]}\\033[0m\")\n\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages,\n tools=TOOLS, max_tokens=8000)\n except Exception as e:\n messages.append({\"role\": \"assistant\", \"content\": [\n {\"type\": \"text\",\n \"text\": f\"[Error] {type(e).__name__}: {e}\"}]})\n return\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n\n if should_run_background(block.name, block.input):\n bg_id = start_background_task(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": f\"[Background task {bg_id} started] \"\n f\"Result will be available when complete.\"})\n else:\n output = execute_tool(block)\n print(str(output)[:300])\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output})\n\n # Merge background tool results + notifications into one user message\n user_content = list(results)\n bg_notifications = collect_background_results()\n if bg_notifications:\n for notif in bg_notifications:\n user_content.append({\"type\": \"text\", \"text\": notif})\n messages.append({\"role\": \"user\", \"content\": user_content})\n context = update_context(context, messages)\n system = get_system_prompt(context)\n\n\nif __name__ == \"__main__\":\n print(\"s15: agent teams\")\n print(\"Enter a question, press Enter to send. Type q to quit.\\n\")\n history = []\n context = update_context({}, [])\n while True:\n try:\n query = input(\"\\033[36ms15 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt):\n break\n if query.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history, context)\n context = update_context(context, history)\n for block in history[-1][\"content\"]:\n if getattr(block, \"type\", None) == \"text\":\n print(block.text)\n\n # Check inbox for teammate results → inject into history\n inbox = BUS.read_inbox(\"lead\")\n if inbox:\n inbox_text = \"\\n\".join(\n f\"From {m['from']}: {m['content'][:200]}\" for m in inbox)\n history.append({\"role\": \"user\",\n \"content\": f\"[Inbox]\\n{inbox_text}\"})\n print(f\"\\n\\033[33m[Inbox: {len(inbox)} messages injected]\\033[0m\")\n print()\n", + "source": "#!/usr/bin/env python3\n\"\"\"\ns15: Agent Teams — MessageBus + spawn_teammate_thread + inbox injection.\n\nRun: python s15_agent_teams/code.py\nNeed: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY\n\nChanges from s14:\n - MessageBus class: file-based mailboxes (.mailboxes/*.jsonl)\n - spawn_teammate_thread: creates teammate in background thread\n - Teammate runs own simplified agent_loop (bash, read, write, send_message)\n - Lead tools: spawn_teammate, send_message, check_inbox (3 new)\n - Lead inbox: teammate messages injected into history (not just printed)\n - Teaching version: teammates limited to 10 rounds (real Claude Code uses idle loop)\n\nASCII flow:\n Lead: cron_queue → messages → prompt → LLM → TOOLS ────→ loop\n ↑ ↓ |\n └── inbox ← MessageBus ← teammate.send_message ←┘\n Teammate: inbox → LLM → bash/read/write/send → loop (max 10 turns)\n\"\"\"\n\nimport os, subprocess, json, time, random, threading, queue\nfrom pathlib import Path\nfrom datetime import datetime\nfrom dataclasses import dataclass, asdict\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nMEMORY_DIR = WORKDIR / \".memory\"\nMEMORY_INDEX = MEMORY_DIR / \"MEMORY.md\"\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\n# ── Task System (from s12, synced) ──\n\nTASKS_DIR = WORKDIR / \".tasks\"\nTASKS_DIR.mkdir(exist_ok=True)\n\n\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str # pending | in_progress | completed\n owner: str | None\n blockedBy: list[str]\n\n\ndef _task_path(task_id: str) -> Path:\n return TASKS_DIR / f\"{task_id}.json\"\n\n\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random.randint(0, 9999):04d}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n\n\ndef save_task(task: Task):\n _task_path(task.id).write_text(json.dumps(asdict(task), indent=2))\n\n\ndef load_task(task_id: str) -> Task:\n return Task(**json.loads(_task_path(task_id).read_text()))\n\n\ndef list_tasks() -> list[Task]:\n return [Task(**json.loads(p.read_text()))\n for p in sorted(TASKS_DIR.glob(\"task_*.json\"))]\n\n\ndef get_task(task_id: str) -> str:\n \"\"\"Return full task details as JSON.\"\"\"\n task = load_task(task_id)\n return json.dumps(asdict(task), indent=2)\n\n\ndef can_start(task_id: str) -> bool:\n \"\"\"Check if all blockedBy dependencies are completed.\n Missing dependencies are treated as blocked.\"\"\"\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False\n if load_task(dep_id).status != \"completed\":\n return False\n return True\n\n\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if not _task_path(d).exists() or load_task(d).status != \"completed\"]\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n print(f\" \\033[36m[claim] {task.subject} → in_progress (owner: {owner})\\033[0m\")\n return f\"Claimed {task.id} ({task.subject})\"\n\n\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n if task.status != \"in_progress\":\n return f\"Task {task_id} is {task.status}, cannot complete\"\n task.status = \"completed\"\n save_task(task)\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy and can_start(t.id)]\n print(f\" \\033[32m[complete] {task.subject} ✓\\033[0m\")\n msg = f\"Completed {task.id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n print(f\" \\033[33m[unblocked] {', '.join(unblocked)}\\033[0m\")\n return msg\n\n\n# ── Prompt Assembly (from s10, synced) ──\n\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file, \"\n \"get_task, create_task, list_tasks, claim_task, complete_task, \"\n \"schedule_cron, list_crons, cancel_cron, \"\n \"spawn_teammate, send_message, check_inbox.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n\n\ndef assemble_system_prompt(context: dict) -> str:\n sections = [PROMPT_SECTIONS[\"identity\"],\n PROMPT_SECTIONS[\"tools\"],\n PROMPT_SECTIONS[\"workspace\"]]\n memories = context.get(\"memories\", \"\")\n if memories:\n sections.append(f\"Relevant memories:\\n{memories}\")\n return \"\\n\\n\".join(sections)\n\n\n_last_context_key, _last_prompt = None, None\n\n\ndef get_system_prompt(context: dict) -> str:\n global _last_context_key, _last_prompt\n key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)\n if key == _last_context_key and _last_prompt:\n return _last_prompt\n _last_context_key = key\n _last_prompt = assemble_system_prompt(context)\n return _last_prompt\n\n\n# ── Tools ──\n\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR):\n raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\n\ndef run_bash(command: str, run_in_background: bool = False) -> str:\n # run_in_background is handled by agent_loop dispatch, not here\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\n\ndef run_read(path: str, limit: int | None = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_write(path: str, content: str) -> str:\n try:\n fp = safe_path(path)\n fp.parent.mkdir(parents=True, exist_ok=True)\n fp.write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\n# Task tools\n\ndef run_create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> str:\n task = create_task(subject, description, blockedBy)\n deps = f\" (blockedBy: {', '.join(blockedBy)})\" if blockedBy else \"\"\n print(f\" \\033[34m[create] {task.subject}{deps}\\033[0m\")\n return f\"Created {task.id}: {task.subject}{deps}\"\n\n\ndef run_list_tasks() -> str:\n tasks = list_tasks()\n if not tasks:\n return \"No tasks. Use create_task to add some.\"\n lines = []\n for t in tasks:\n icon = {\"pending\": \"○\", \"in_progress\": \"●\",\n \"completed\": \"✓\"}.get(t.status, \"?\")\n deps = f\" (blockedBy: {', '.join(t.blockedBy)})\" if t.blockedBy else \"\"\n owner = f\" [{t.owner}]\" if t.owner else \"\"\n lines.append(f\" {icon} {t.id}: {t.subject} \"\n f\"[{t.status}]{owner}{deps}\")\n return \"\\n\".join(lines)\n\n\ndef run_get_task(task_id: str) -> str:\n try:\n return get_task(task_id)\n except FileNotFoundError:\n return f\"Error: Task {task_id} not found\"\n\n\ndef run_claim_task(task_id: str) -> str:\n return claim_task(task_id, owner=\"agent\")\n\n\ndef run_complete_task(task_id: str) -> str:\n return complete_task(task_id)\n\n\n# ── Background Tasks (from s13, synced) ──\n\n_bg_counter = 0\nbackground_tasks: dict[str, dict] = {}\nbackground_results: dict[str, str] = {}\nbackground_lock = threading.Lock()\n\n\ndef is_slow_operation(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Fallback heuristic: commands likely to take > 30s.\"\"\"\n if tool_name != \"bash\":\n return False\n cmd = tool_input.get(\"command\", \"\").lower()\n slow_keywords = [\"install\", \"build\", \"test\", \"deploy\", \"compile\",\n \"docker build\", \"pip install\", \"npm install\",\n \"cargo build\", \"pytest\", \"make\"]\n return any(kw in cmd for kw in slow_keywords)\n\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Model explicit request takes priority; fallback to heuristic.\"\"\"\n if tool_input.get(\"run_in_background\"):\n return True\n return is_slow_operation(tool_name, tool_input)\n\n\ndef execute_tool(block) -> str:\n \"\"\"Execute a tool call block, return output.\"\"\"\n handler = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"create_task\": run_create_task, \"list_tasks\": run_list_tasks,\n \"get_task\": run_get_task, \"claim_task\": run_claim_task,\n \"complete_task\": run_complete_task,\n \"schedule_cron\": run_schedule_cron, \"list_crons\": run_list_crons,\n \"cancel_cron\": run_cancel_cron,\n \"spawn_teammate\": run_spawn_teammate,\n \"send_message\": run_send_message, \"check_inbox\": run_check_inbox,\n }.get(block.name)\n if handler:\n return handler(**block.input)\n return f\"Unknown tool: {block.name}\"\n\n\ndef start_background_task(block) -> str:\n \"\"\"Run tool in a daemon thread. Returns background task ID.\"\"\"\n global _bg_counter\n _bg_counter += 1\n bg_id = f\"bg_{_bg_counter:04d}\"\n cmd = block.input.get(\"command\", block.name)\n\n def worker():\n result = execute_tool(block)\n with background_lock:\n background_tasks[bg_id][\"status\"] = \"completed\"\n background_results[bg_id] = result\n\n with background_lock:\n background_tasks[bg_id] = {\n \"tool_use_id\": block.id,\n \"command\": cmd,\n \"status\": \"running\",\n }\n threading.Thread(target=worker, daemon=True).start()\n print(f\" \\033[33m[background] dispatched {bg_id}: {cmd[:40]}\\033[0m\")\n return bg_id\n\n\ndef collect_background_results() -> list[str]:\n \"\"\"Collect completed background results as task_notification messages.\"\"\"\n with background_lock:\n ready_ids = [bid for bid, task in background_tasks.items()\n if task[\"status\"] == \"completed\"]\n notifications = []\n for bg_id in ready_ids:\n with background_lock:\n task = background_tasks.pop(bg_id)\n output = background_results.pop(bg_id, \"\")\n summary = output[:200] if len(output) > 200 else output\n notifications.append(\n f\"\\n\"\n f\" {bg_id}\\n\"\n f\" completed\\n\"\n f\" {task['command']}\\n\"\n f\" {summary}\\n\"\n f\"\")\n print(f\" \\033[32m[background done] {bg_id}: \"\n f\"{task['command'][:40]} ({len(output)} chars)\\033[0m\")\n return notifications\n\n\ndef has_pending_background() -> bool:\n \"\"\"Non-destructive: True if any background task has completed and is\n waiting to be collected. The inbox poller uses this in its wake condition.\"\"\"\n with background_lock:\n return any(t[\"status\"] == \"completed\" for t in background_tasks.values())\n\n\n# ── Cron Scheduler (from s14, synced) ──\n\nDURABLE_PATH = WORKDIR / \".scheduled_tasks.json\"\n\n\n@dataclass\nclass CronJob:\n id: str\n cron: str # \"0 9 * * *\"\n prompt: str # message to inject when fired\n recurring: bool # True = recurring, False = one-shot\n durable: bool # True = persist to disk\n\n\nscheduled_jobs: dict[str, CronJob] = {}\ncron_queue: list[CronJob] = []\ncron_lock = threading.Lock()\n_last_fired: dict[str, str] = {} # job_id → \"YYYY-MM-DD HH:MM\"\n\n\ndef _cron_field_matches(field: str, value: int) -> bool:\n \"\"\"Match a single cron field against a value.\"\"\"\n if field == \"*\":\n return True\n if field.startswith(\"*/\"):\n step = int(field[2:])\n return step > 0 and value % step == 0\n if \",\" in field:\n return any(_cron_field_matches(f.strip(), value)\n for f in field.split(\",\"))\n if \"-\" in field:\n lo, hi = field.split(\"-\", 1)\n return int(lo) <= value <= int(hi)\n return value == int(field)\n\n\ndef cron_matches(cron_expr: str, dt: datetime) -> bool:\n \"\"\"Check if a 5-field cron expression matches the given datetime.\n Standard cron semantics: DOM and DOW use OR when both are constrained.\"\"\"\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return False\n minute, hour, dom, month, dow = fields\n dow_val = (dt.weekday() + 1) % 7 # Python Monday=0 → cron Sunday=0\n\n m = _cron_field_matches(minute, dt.minute)\n h = _cron_field_matches(hour, dt.hour)\n dom_ok = _cron_field_matches(dom, dt.day)\n month_ok = _cron_field_matches(month, dt.month)\n dow_ok = _cron_field_matches(dow, dow_val)\n\n # Minute, hour, month must all match\n if not (m and h and month_ok):\n return False\n # DOM and DOW: if both constrained, either matching is enough (OR)\n dom_unconstrained = dom == \"*\"\n dow_unconstrained = dow == \"*\"\n if dom_unconstrained and dow_unconstrained:\n return True\n if dom_unconstrained:\n return dow_ok\n if dow_unconstrained:\n return dom_ok\n return dom_ok or dow_ok\n\n\ndef _validate_cron_field(field: str, lo: int, hi: int) -> str | None:\n \"\"\"Validate a single cron field value is within [lo, hi].\"\"\"\n if field == \"*\":\n return None\n if field.startswith(\"*/\"):\n step_str = field[2:]\n if not step_str.isdigit():\n return f\"Invalid step: {field}\"\n step = int(step_str)\n if step <= 0:\n return f\"Step must be > 0: {field}\"\n return None\n if \",\" in field:\n for part in field.split(\",\"):\n err = _validate_cron_field(part.strip(), lo, hi)\n if err: return err\n return None\n if \"-\" in field:\n parts = field.split(\"-\", 1)\n if not parts[0].isdigit() or not parts[1].isdigit():\n return f\"Invalid range: {field}\"\n a, b = int(parts[0]), int(parts[1])\n if a < lo or a > hi or b < lo or b > hi:\n return f\"Range {field} out of bounds [{lo}-{hi}]\"\n if a > b:\n return f\"Range start > end: {field}\"\n return None\n if not field.isdigit():\n return f\"Invalid field: {field}\"\n val = int(field)\n if val < lo or val > hi:\n return f\"Value {val} out of bounds [{lo}-{hi}]\"\n return None\n\n\ndef validate_cron(cron_expr: str) -> str | None:\n \"\"\"Validate a cron expression. Returns error message or None.\"\"\"\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return f\"Expected 5 fields, got {len(fields)}\"\n bounds = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 6)]\n names = [\"minute\", \"hour\", \"day-of-month\", \"month\", \"day-of-week\"]\n for i, (field, (lo, hi), name) in enumerate(zip(fields, bounds, names)):\n err = _validate_cron_field(field, lo, hi)\n if err:\n return f\"{name}: {err}\"\n return None\n\n\ndef save_durable_jobs():\n \"\"\"Persist durable jobs to .scheduled_tasks.json.\"\"\"\n durable = [asdict(j) for j in scheduled_jobs.values() if j.durable]\n DURABLE_PATH.write_text(json.dumps(durable, indent=2))\n\n\ndef load_durable_jobs():\n \"\"\"Load durable jobs from disk on startup.\"\"\"\n if not DURABLE_PATH.exists():\n return\n try:\n jobs = json.loads(DURABLE_PATH.read_text())\n for j in jobs:\n job = CronJob(**j)\n err = validate_cron(job.cron)\n if err:\n print(f\" \\033[31m[cron] skipping invalid job {job.id}: {err}\\033[0m\")\n continue\n scheduled_jobs[job.id] = job\n valid = [j for j in jobs if j[\"id\"] in scheduled_jobs]\n if valid:\n print(f\" \\033[35m[cron] loaded {len(valid)} durable job(s)\\033[0m\")\n except Exception:\n pass\n\n\ndef schedule_job(cron: str, prompt: str, recurring: bool = True,\n durable: bool = True) -> CronJob | str:\n \"\"\"Register a new cron job. Returns CronJob or error string.\"\"\"\n err = validate_cron(cron)\n if err:\n return err\n job = CronJob(\n id=f\"cron_{random.randint(0, 999999):06d}\",\n cron=cron, prompt=prompt,\n recurring=recurring, durable=durable,\n )\n with cron_lock:\n scheduled_jobs[job.id] = job\n if durable:\n save_durable_jobs()\n print(f\" \\033[35m[cron register] {job.id} '{cron}' → {prompt[:40]}\\033[0m\")\n return job\n\n\ndef cancel_job(job_id: str) -> str:\n \"\"\"Cancel a cron job.\"\"\"\n with cron_lock:\n job = scheduled_jobs.pop(job_id, None)\n if not job:\n return f\"Job {job_id} not found\"\n if job.durable:\n save_durable_jobs()\n print(f\" \\033[31m[cron cancel] {job_id}\\033[0m\")\n return f\"Cancelled {job_id}\"\n\n\ndef cron_scheduler_loop():\n \"\"\"Independent daemon thread: poll every 1s, fire matching jobs.\n Individual job errors are caught to prevent one bad job from\n killing the entire scheduler thread.\"\"\"\n while True:\n time.sleep(1)\n now = datetime.now()\n # Date-aware marker prevents daily jobs from skipping on day 2+\n minute_marker = now.strftime(\"%Y-%m-%d %H:%M\")\n with cron_lock:\n for job in list(scheduled_jobs.values()):\n try:\n if cron_matches(job.cron, now):\n if _last_fired.get(job.id) != minute_marker:\n cron_queue.append(job)\n _last_fired[job.id] = minute_marker\n print(f\" \\033[35m[cron fire] {job.id} → \"\n f\"{job.prompt[:40]}\\033[0m\")\n if not job.recurring:\n scheduled_jobs.pop(job.id, None)\n if job.durable:\n save_durable_jobs()\n except Exception as e:\n print(f\" \\033[31m[cron error] {job.id}: {e}\\033[0m\")\n\n\ndef consume_cron_queue() -> list[CronJob]:\n \"\"\"Consume fired jobs from cron_queue (called by agent_loop).\"\"\"\n with cron_lock:\n fired = list(cron_queue)\n cron_queue.clear()\n return fired\n\n\n# Load durable jobs on startup, then start scheduler thread\nload_durable_jobs()\nthreading.Thread(target=cron_scheduler_loop, daemon=True).start()\nprint(\" \\033[35m[cron] scheduler thread started\\033[0m\")\n\n\n# Cron tool handlers\n\ndef run_schedule_cron(cron: str, prompt: str,\n recurring: bool = True, durable: bool = True) -> str:\n result = schedule_job(cron, prompt, recurring, durable)\n if isinstance(result, str):\n return f\"Error: {result}\"\n return f\"Scheduled {result.id}: '{cron}' → {prompt}\"\n\n\ndef run_list_crons() -> str:\n with cron_lock:\n jobs = list(scheduled_jobs.values())\n if not jobs:\n return \"No cron jobs. Use schedule_cron to add one.\"\n lines = []\n for j in jobs:\n tag = \"recurring\" if j.recurring else \"one-shot\"\n dur = \"durable\" if j.durable else \"session\"\n lines.append(f\" {j.id}: '{j.cron}' → {j.prompt[:40]} \"\n f\"[{tag}, {dur}]\")\n return \"\\n\".join(lines)\n\n\ndef run_cancel_cron(job_id: str) -> str:\n return cancel_job(job_id)\n\n\n# ── MessageBus (s15 new) ──\n# Teaching version uses simple file append + unlink.\n# Real Claude Code uses proper-lockfile for concurrent write safety.\n\nMAILBOX_DIR = WORKDIR / \".mailboxes\"\nMAILBOX_DIR.mkdir(exist_ok=True)\n\n\nclass MessageBus:\n \"\"\"File-based message bus. Each agent has a .jsonl inbox.\n Read is destructive: read_text + unlink (consumes messages).\n Teaching version: no file locking; real Claude Code uses proper-lockfile.\"\"\"\n\n def send(self, from_agent: str, to_agent: str, content: str,\n msg_type: str = \"message\"):\n msg = {\"from\": from_agent, \"to\": to_agent,\n \"content\": content, \"type\": msg_type,\n \"ts\": time.time()}\n inbox = MAILBOX_DIR / f\"{to_agent}.jsonl\"\n with open(inbox, \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n print(f\" \\033[33m[bus] {from_agent} → {to_agent}: \"\n f\"{content[:50]}\\033[0m\")\n\n def read_inbox(self, agent: str) -> list[dict]:\n inbox = MAILBOX_DIR / f\"{agent}.jsonl\"\n if not inbox.exists():\n return []\n msgs = [json.loads(line) for line in inbox.read_text().splitlines()\n if line.strip()]\n inbox.unlink() # consume: read + delete\n return msgs\n\n def peek(self, agent: str) -> bool:\n \"\"\"Non-destructive: True if the agent has unread inbox messages.\n The Lead's inbox poller uses this to decide whether to wake a turn\n without consuming the mailbox.\"\"\"\n inbox = MAILBOX_DIR / f\"{agent}.jsonl\"\n return inbox.exists() and inbox.stat().st_size > 0\n\n\nBUS = MessageBus()\n\n# Track spawned teammates\nactive_teammates: dict[str, bool] = {}\n\n\n# ── Teammate Thread (s15 new) ──\n\ndef spawn_teammate_thread(name: str, role: str, prompt: str) -> str:\n \"\"\"Spawn a teammate agent in a background thread.\n Teaching version: max 10 rounds per teammate.\n Real Claude Code: teammates use idle loop (wait for inbox, work, repeat)\n until shutdown_request.\"\"\"\n if name in active_teammates:\n return f\"Teammate '{name}' already exists\"\n\n system = (f\"You are '{name}', a {role}. \"\n f\"Use tools to complete tasks. \"\n f\"Send results via send_message to 'lead'.\")\n\n def run():\n messages = [{\"role\": \"user\", \"content\": prompt}]\n sub_tools = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"send_message\",\n \"description\": \"Send a message to another agent.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n ]\n sub_handlers = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"send_message\": lambda to, content: (BUS.send(name, to, content),\n \"Sent\")[1],\n }\n\n for _ in range(10):\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"{json.dumps(inbox)}\"})\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages[-20:],\n tools=sub_tools, max_tokens=8000)\n except Exception:\n break\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n handler = sub_handlers.get(block.name)\n output = handler(**block.input) if handler else \"Unknown\"\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # Send final summary to Lead\n summary = \"Done.\"\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\" and isinstance(msg[\"content\"], list):\n for b in msg[\"content\"]:\n if getattr(b, \"type\", None) == \"text\":\n summary = b.text\n break\n else:\n continue\n break\n BUS.send(name, \"lead\", summary, \"result\")\n active_teammates.pop(name, None)\n print(f\" \\033[32m[teammate] {name} finished\\033[0m\")\n\n active_teammates[name] = True\n threading.Thread(target=run, daemon=True).start()\n print(f\" \\033[36m[teammate] {name} spawned as {role}\\033[0m\")\n return f\"Teammate '{name}' spawned as {role}\"\n\n\n# ── Team Tool Handlers (s15 new) ──\n\ndef run_spawn_teammate(name: str, role: str, prompt: str) -> str:\n return spawn_teammate_thread(name, role, prompt)\n\n\ndef run_send_message(to: str, content: str) -> str:\n BUS.send(\"lead\", to, content)\n return f\"Sent to {to}\"\n\n\ndef run_check_inbox() -> str:\n msgs = BUS.read_inbox(\"lead\")\n if not msgs:\n return \"(inbox empty)\"\n lines = []\n for m in msgs:\n lines.append(f\" [{m['from']}] {m['content'][:200]}\")\n return \"\\n\".join(lines)\n\n\n# ── Tool Definitions ──\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\"},\n \"run_in_background\": {\"type\": \"boolean\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"create_task\",\n \"description\": \"Create a new task with optional blockedBy dependencies.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"subject\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n \"blockedBy\": {\"type\": \"array\",\n \"items\": {\"type\": \"string\"}}},\n \"required\": [\"subject\"]}},\n {\"name\": \"list_tasks\",\n \"description\": \"List all tasks with status, owner, and dependencies.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n {\"name\": \"get_task\",\n \"description\": \"Get full details of a specific task by ID.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"claim_task\",\n \"description\": \"Claim a pending task. Sets owner, changes status to in_progress.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\",\n \"description\": \"Complete an in-progress task. Reports unblocked downstream tasks.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"schedule_cron\",\n \"description\": \"Schedule a cron job. cron is 5-field: min hour dom month dow.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"cron\": {\"type\": \"string\",\n \"description\": \"5-field cron expression\"},\n \"prompt\": {\"type\": \"string\",\n \"description\": \"Message to inject when fired\"},\n \"recurring\": {\"type\": \"boolean\",\n \"description\": \"True=recurring, False=one-shot\"},\n \"durable\": {\"type\": \"boolean\",\n \"description\": \"True=persist to disk\"}},\n \"required\": [\"cron\", \"prompt\"]}},\n {\"name\": \"list_crons\",\n \"description\": \"List all registered cron jobs.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n {\"name\": \"cancel_cron\",\n \"description\": \"Cancel a cron job by ID.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"job_id\": {\"type\": \"string\"}},\n \"required\": [\"job_id\"]}},\n {\"name\": \"spawn_teammate\",\n \"description\": \"Spawn a teammate agent in a background thread.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\"},\n \"role\": {\"type\": \"string\"},\n \"prompt\": {\"type\": \"string\"}},\n \"required\": [\"name\", \"role\", \"prompt\"]}},\n {\"name\": \"send_message\",\n \"description\": \"Send a message to a teammate via MessageBus.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"check_inbox\",\n \"description\": \"Check Lead's inbox for teammate messages.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n]\n\n\n# ── Context ──\n\ndef update_context(context: dict, messages: list) -> dict:\n \"\"\"Derive context from real state.\"\"\"\n memories = \"\"\n if MEMORY_INDEX.exists():\n content = MEMORY_INDEX.read_text().strip()\n if content:\n memories = content\n return {\n \"enabled_tools\": [t[\"name\"] for t in TOOLS],\n \"workspace\": str(WORKDIR),\n \"memories\": memories,\n }\n\n\n# ── Agent Loop ──\n# Teaching code keeps a basic agent loop. S11's full error recovery is omitted.\n# Cron queue is consumed when agent_loop is called; real Claude Code auto-wakes via\n# queue processor (useQueueProcessor.ts) when items arrive.\n\ndef agent_loop(messages: list, context: dict):\n system = get_system_prompt(context)\n while True:\n # Consume fired cron jobs → inject as messages\n fired = consume_cron_queue()\n for job in fired:\n messages.append({\"role\": \"user\",\n \"content\": f\"[Scheduled] {job.prompt}\"})\n print(f\" \\033[35m[inject cron] {job.prompt[:50]}\\033[0m\")\n\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages,\n tools=TOOLS, max_tokens=8000)\n except Exception as e:\n messages.append({\"role\": \"assistant\", \"content\": [\n {\"type\": \"text\",\n \"text\": f\"[Error] {type(e).__name__}: {e}\"}]})\n return\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n\n if should_run_background(block.name, block.input):\n bg_id = start_background_task(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": f\"[Background task {bg_id} started] \"\n f\"Result will be available when complete.\"})\n else:\n output = execute_tool(block)\n print(str(output)[:300])\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output})\n\n # Merge background tool results + notifications into one user message\n user_content = list(results)\n bg_notifications = collect_background_results()\n if bg_notifications:\n for notif in bg_notifications:\n user_content.append({\"type\": \"text\", \"text\": notif})\n messages.append({\"role\": \"user\", \"content\": user_content})\n context = update_context(context, messages)\n system = get_system_prompt(context)\n\n\nif __name__ == \"__main__\":\n print(\"s15: agent teams\")\n print(\"Enter a question, press Enter to send. Type q to quit.\\n\")\n history = []\n context = update_context({}, [])\n\n # input() and a 1s poller (teammate inbox or background results) feed one\n # event queue (issues #291, #46).\n events = queue.Queue()\n\n def input_reader():\n while True:\n try:\n line = input(\"\\033[36ms15 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt):\n events.put((\"quit\", None))\n return\n events.put((\"user\", line))\n\n def inbox_poller():\n # Poll ~1s and wake the Lead when async results are ready: teammate\n # inbox messages or completed background tasks. Don't gate on\n # active_teammates: a teammate sends its result and then removes itself,\n # so the final message can outlive its registry entry.\n while True:\n time.sleep(1)\n if BUS.peek(\"lead\") or has_pending_background():\n events.put((\"wake\", None))\n\n threading.Thread(target=input_reader, daemon=True).start()\n threading.Thread(target=inbox_poller, daemon=True).start()\n\n had_teammates = False\n while True:\n kind, payload = events.get()\n if kind == \"quit\":\n break\n if kind == \"user\":\n if payload.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n history.append({\"role\": \"user\", \"content\": payload})\n else: # \"wake\": teammate inbox or background results are ready\n parts = []\n inbox = BUS.read_inbox(\"lead\")\n if inbox:\n parts.append(\"[Inbox]\\n\" + \"\\n\".join(\n f\"From {m['from']}: {m['content'][:200]}\" for m in inbox))\n bg = collect_background_results()\n parts.extend(bg)\n if not parts:\n continue # already drained by an earlier wake (idempotent)\n history.append({\"role\": \"user\", \"content\": \"\\n\".join(parts)})\n print(f\"\\n\\033[33m[wake: {len(inbox)} inbox + {len(bg)} background \"\n f\"-> new turn]\\033[0m\")\n\n # One turn for whichever source woke us.\n agent_loop(history, context)\n context = update_context(context, history)\n for block in history[-1][\"content\"]:\n if getattr(block, \"type\", None) == \"text\":\n print(block.text)\n\n # Announce once when every teammate has finished and its output drained.\n if active_teammates:\n had_teammates = True\n elif had_teammates and not BUS.peek(\"lead\") and not has_pending_background():\n print(\"\\033[32m[all teammates done]\\033[0m\")\n had_teammates = False\n print()\n", "images": [ { "src": "/course-assets/s15_agent_teams/agent-teams-overview.svg", @@ -2245,7 +2276,7 @@ } ], "layer": "collaboration", - "source": "#!/usr/bin/env python3\n\"\"\"\ns16: Team Protocols — request-response protocol + request_id + dispatch + state machine.\n\nRun: python s16_team_protocols/code.py\nNeed: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY\n\nChanges from s15:\n - ProtocolState dataclass (request_id, type, sender, status, created_at)\n - pending_requests dict: tracks in-flight protocol requests\n - dispatch_message: routes incoming messages by type to handlers\n - request_shutdown: Lead sends shutdown protocol request\n - request_plan: Lead asks teammate to submit plan\n - handle_shutdown_request / handle_plan_response: teammate receives & responds\n - match_response: Lead correlates response to request via request_id (with type validation)\n - Teammate idle loop: waits for inbox messages instead of exiting after 10 rounds\n - Unified consume_lead_inbox: protocol routing + injection into history\n - 3 new Lead tools: request_shutdown, request_plan, review_plan\n - 1 new teammate tool: submit_plan\n\nASCII flow:\n Lead: BUS.send(\"shutdown_request\", {request_id}) ──────→ teammate inbox\n Teammate: dispatch → handler → BUS.send(\"shutdown_response\", {request_id}) ─→ Lead inbox\n Lead: consume_lead_inbox → match_response(request_id) → pending_requests[req_id].status = approved\n\"\"\"\n\nimport os, subprocess, json, time, random, threading\nfrom pathlib import Path\nfrom datetime import datetime\nfrom dataclasses import dataclass, asdict, field\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nMEMORY_DIR = WORKDIR / \".memory\"\nMEMORY_INDEX = MEMORY_DIR / \"MEMORY.md\"\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\n# ── Task System (from s12, synced) ──\n\nTASKS_DIR = WORKDIR / \".tasks\"\nTASKS_DIR.mkdir(exist_ok=True)\n\n\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str # pending | in_progress | completed\n owner: str | None\n blockedBy: list[str]\n\n\ndef _task_path(task_id: str) -> Path:\n return TASKS_DIR / f\"{task_id}.json\"\n\n\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random.randint(0, 9999):04d}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n\n\ndef save_task(task: Task):\n _task_path(task.id).write_text(json.dumps(asdict(task), indent=2))\n\n\ndef load_task(task_id: str) -> Task:\n return Task(**json.loads(_task_path(task_id).read_text()))\n\n\ndef list_tasks() -> list[Task]:\n return [Task(**json.loads(p.read_text()))\n for p in sorted(TASKS_DIR.glob(\"task_*.json\"))]\n\n\ndef get_task(task_id: str) -> str:\n \"\"\"Return full task details as JSON.\"\"\"\n task = load_task(task_id)\n return json.dumps(asdict(task), indent=2)\n\n\ndef can_start(task_id: str) -> bool:\n \"\"\"Check if all blockedBy dependencies are completed.\n Missing dependencies are treated as blocked.\"\"\"\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False\n if load_task(dep_id).status != \"completed\":\n return False\n return True\n\n\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if not _task_path(d).exists() or load_task(d).status != \"completed\"]\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n print(f\" \\033[36m[claim] {task.subject} → in_progress (owner: {owner})\\033[0m\")\n return f\"Claimed {task.id} ({task.subject})\"\n\n\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n if task.status != \"in_progress\":\n return f\"Task {task_id} is {task.status}, cannot complete\"\n task.status = \"completed\"\n save_task(task)\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy and can_start(t.id)]\n print(f\" \\033[32m[complete] {task.subject} ✓\\033[0m\")\n msg = f\"Completed {task.id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n print(f\" \\033[33m[unblocked] {', '.join(unblocked)}\\033[0m\")\n return msg\n\n\n# ── Prompt Assembly (from s10, synced) ──\n\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file, \"\n \"get_task, create_task, list_tasks, claim_task, complete_task, \"\n \"spawn_teammate, send_message, check_inbox, \"\n \"request_shutdown, request_plan, review_plan.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n\n\ndef assemble_system_prompt(context: dict) -> str:\n sections = [PROMPT_SECTIONS[\"identity\"],\n PROMPT_SECTIONS[\"tools\"],\n PROMPT_SECTIONS[\"workspace\"]]\n memories = context.get(\"memories\", \"\")\n if memories:\n sections.append(f\"Relevant memories:\\n{memories}\")\n return \"\\n\\n\".join(sections)\n\n\n_last_context_key, _last_prompt = None, None\n\n\ndef get_system_prompt(context: dict) -> str:\n global _last_context_key, _last_prompt\n key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)\n if key == _last_context_key and _last_prompt:\n return _last_prompt\n _last_context_key = key\n _last_prompt = assemble_system_prompt(context)\n return _last_prompt\n\n\n# ── Tools ──\n\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR):\n raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\n\ndef run_bash(command: str, run_in_background: bool = False) -> str:\n # run_in_background is handled by agent_loop dispatch, not here\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\n\ndef run_read(path: str, limit: int | None = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_write(path: str, content: str) -> str:\n try:\n fp = safe_path(path)\n fp.parent.mkdir(parents=True, exist_ok=True)\n fp.write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\n# Task tools\n\ndef run_create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> str:\n task = create_task(subject, description, blockedBy)\n deps = f\" (blockedBy: {', '.join(blockedBy)})\" if blockedBy else \"\"\n print(f\" \\033[34m[create] {task.subject}{deps}\\033[0m\")\n return f\"Created {task.id}: {task.subject}{deps}\"\n\n\ndef run_list_tasks() -> str:\n tasks = list_tasks()\n if not tasks:\n return \"No tasks. Use create_task to add some.\"\n lines = []\n for t in tasks:\n icon = {\"pending\": \"○\", \"in_progress\": \"●\",\n \"completed\": \"✓\"}.get(t.status, \"?\")\n deps = f\" (blockedBy: {', '.join(t.blockedBy)})\" if t.blockedBy else \"\"\n owner = f\" [{t.owner}]\" if t.owner else \"\"\n lines.append(f\" {icon} {t.id}: {t.subject} \"\n f\"[{t.status}]{owner}{deps}\")\n return \"\\n\".join(lines)\n\n\ndef run_get_task(task_id: str) -> str:\n try:\n return get_task(task_id)\n except FileNotFoundError:\n return f\"Error: Task {task_id} not found\"\n\n\ndef run_claim_task(task_id: str) -> str:\n return claim_task(task_id, owner=\"agent\")\n\n\ndef run_complete_task(task_id: str) -> str:\n return complete_task(task_id)\n\n\n# ── Background Tasks (from s13, synced) ──\n\n_bg_counter = 0\nbackground_tasks: dict[str, dict] = {}\nbackground_results: dict[str, str] = {}\nbackground_lock = threading.Lock()\n\n\ndef is_slow_operation(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Fallback heuristic: commands likely to take > 30s.\"\"\"\n if tool_name != \"bash\":\n return False\n cmd = tool_input.get(\"command\", \"\").lower()\n slow_keywords = [\"install\", \"build\", \"test\", \"deploy\", \"compile\",\n \"docker build\", \"pip install\", \"npm install\",\n \"cargo build\", \"pytest\", \"make\"]\n return any(kw in cmd for kw in slow_keywords)\n\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Model explicit request takes priority; fallback to heuristic.\"\"\"\n if tool_input.get(\"run_in_background\"):\n return True\n return is_slow_operation(tool_name, tool_input)\n\n\ndef start_background_task(block) -> str:\n \"\"\"Run tool in a daemon thread. Returns background task ID.\"\"\"\n global _bg_counter\n _bg_counter += 1\n bg_id = f\"bg_{_bg_counter:04d}\"\n cmd = block.input.get(\"command\", block.name)\n\n def worker():\n result = execute_tool(block)\n with background_lock:\n background_tasks[bg_id][\"status\"] = \"completed\"\n background_results[bg_id] = result\n\n with background_lock:\n background_tasks[bg_id] = {\n \"tool_use_id\": block.id,\n \"command\": cmd,\n \"status\": \"running\",\n }\n threading.Thread(target=worker, daemon=True).start()\n print(f\" \\033[33m[background] dispatched {bg_id}: {cmd[:40]}\\033[0m\")\n return bg_id\n\n\ndef collect_background_results() -> list[str]:\n \"\"\"Collect completed background results as task_notification messages.\"\"\"\n with background_lock:\n ready_ids = [bid for bid, task in background_tasks.items()\n if task[\"status\"] == \"completed\"]\n notifications = []\n for bg_id in ready_ids:\n with background_lock:\n task = background_tasks.pop(bg_id)\n output = background_results.pop(bg_id, \"\")\n summary = output[:200] if len(output) > 200 else output\n notifications.append(\n f\"\\n\"\n f\" {bg_id}\\n\"\n f\" completed\\n\"\n f\" {task['command']}\\n\"\n f\" {summary}\\n\"\n f\"\")\n print(f\" \\033[32m[background done] {bg_id}: \"\n f\"{task['command'][:40]} ({len(output)} chars)\\033[0m\")\n return notifications\n\n\n# ── MessageBus (from s15) ──\n\nMAILBOX_DIR = WORKDIR / \".mailboxes\"\nMAILBOX_DIR.mkdir(exist_ok=True)\n\n\nclass MessageBus:\n \"\"\"File-based message bus. Each agent has a .jsonl inbox.\n Read is destructive: read_text + unlink (consumes messages).\n Teaching version: no file locking; real CC uses proper-lockfile.\"\"\"\n\n def send(self, from_agent: str, to_agent: str, content: str,\n msg_type: str = \"message\", metadata: dict = None):\n msg = {\"from\": from_agent, \"to\": to_agent,\n \"content\": content, \"type\": msg_type,\n \"ts\": time.time(), \"metadata\": metadata or {}}\n inbox = MAILBOX_DIR / f\"{to_agent}.jsonl\"\n with open(inbox, \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n print(f\" \\033[33m[bus] {from_agent} → {to_agent}: \"\n f\"({msg_type}) {content[:50]}\\033[0m\")\n\n def read_inbox(self, agent: str) -> list[dict]:\n inbox = MAILBOX_DIR / f\"{agent}.jsonl\"\n if not inbox.exists():\n return []\n msgs = [json.loads(line) for line in inbox.read_text().splitlines()\n if line.strip()]\n inbox.unlink() # consume: read + delete\n return msgs\n\n\nBUS = MessageBus()\nactive_teammates: dict[str, bool] = {}\n\n# ── Protocol State (s16 new) ──\n\n@dataclass\nclass ProtocolState:\n request_id: str\n type: str # \"shutdown\" | \"plan_approval\"\n sender: str\n target: str\n status: str # pending | approved | rejected\n payload: str # plan text or shutdown reason\n created_at: float = field(default_factory=time.time)\n\n\npending_requests: dict[str, ProtocolState] = {}\n\n\ndef new_request_id() -> str:\n return f\"req_{random.randint(0, 999999):06d}\"\n\n\ndef match_response(response_type: str, request_id: str, approve: bool):\n \"\"\"Correlate a response to the original request via request_id.\n Validates that response_type matches the request type.\"\"\"\n state = pending_requests.get(request_id)\n if not state:\n print(f\" \\033[31m[protocol] unknown request_id: {request_id}\\033[0m\")\n return\n # Validate response type matches request type\n if state.type == \"shutdown\" and response_type != \"shutdown_response\":\n print(f\" \\033[31m[protocol] type mismatch: expected shutdown_response, \"\n f\"got {response_type}\\033[0m\")\n return\n if state.type == \"plan_approval\" and response_type != \"plan_approval_response\":\n print(f\" \\033[31m[protocol] type mismatch: expected plan_approval_response, \"\n f\"got {response_type}\\033[0m\")\n return\n if state.status != \"pending\":\n print(f\" \\033[33m[protocol] {request_id} already {state.status}, \"\n f\"ignoring duplicate\\033[0m\")\n return\n state.status = \"approved\" if approve else \"rejected\"\n icon = \"✓\" if approve else \"✗\"\n color = \"32\" if approve else \"31\"\n print(f\" \\033[{color}m[protocol] {state.type} {icon} \"\n f\"({request_id}: {state.status})\\033[0m\")\n\n\n# ── Unified Lead Inbox Consumer (s16 fix) ──\n# Both check_inbox tool and main loop call this function.\n# Protocol responses are routed via match_response before returning.\n\ndef consume_lead_inbox(route_protocol: bool = True) -> list[dict]:\n \"\"\"Read Lead's inbox. Route protocol responses, return all messages.\n Called by both run_check_inbox() and main loop to avoid\n messages being consumed without protocol routing.\"\"\"\n msgs = BUS.read_inbox(\"lead\")\n if not msgs:\n return []\n if route_protocol:\n for msg in msgs:\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n msg_type = msg.get(\"type\", \"\")\n if req_id and msg_type.endswith(\"_response\"):\n approve = meta.get(\"approve\", False)\n match_response(msg_type, req_id, approve)\n return msgs\n\n\n# ── Teammate Thread (s16: idle loop + dispatch) ──\n\ndef spawn_teammate_thread(name: str, role: str, prompt: str) -> str:\n \"\"\"Spawn a teammate agent in a background thread.\n Uses idle loop: after each LLM turn, waits for inbox messages\n (shutdown_request, new task) instead of exiting.\"\"\"\n if name in active_teammates:\n return f\"Teammate '{name}' already exists\"\n\n system = (f\"You are '{name}', a {role}. \"\n f\"Use tools to complete tasks. \"\n f\"Check inbox for protocol messages (shutdown_request, etc).\")\n\n def handle_inbox_message(name: str, msg: dict, messages: list) -> bool:\n \"\"\"Dispatch incoming protocol messages by type.\n Returns True if teammate should stop.\"\"\"\n msg_type = msg.get(\"type\", \"message\")\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n\n if msg_type == \"shutdown_request\":\n BUS.send(name, \"lead\", \"Shutting down gracefully.\",\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n print(f\" \\033[35m[protocol] {name} approved shutdown \"\n f\"({req_id})\\033[0m\")\n return True # stop the loop\n\n if msg_type == \"plan_approval_response\":\n approve = meta.get(\"approve\", False)\n if approve:\n messages.append({\"role\": \"user\",\n \"content\": f\"[Plan approved] Proceed with the task.\"})\n else:\n messages.append({\"role\": \"user\",\n \"content\": f\"[Plan rejected] Feedback: {msg['content']}\"})\n\n return False # continue\n\n def run():\n messages = [{\"role\": \"user\", \"content\": prompt}]\n sub_tools = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"send_message\",\n \"description\": \"Send message to another agent.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"submit_plan\",\n \"description\": \"Submit a plan for Lead approval.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"plan\": {\"type\": \"string\"}},\n \"required\": [\"plan\"]}},\n ]\n sub_handlers = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"send_message\": lambda to, content: (BUS.send(name, to, content),\n \"Sent\")[1],\n \"submit_plan\": lambda plan: _teammate_submit_plan(name, plan),\n }\n\n shutdown_requested = False\n while not shutdown_requested:\n # Check inbox for protocol messages\n inbox = BUS.read_inbox(name)\n should_stop = False\n non_protocol = []\n for msg in inbox:\n if msg.get(\"type\") in (\"shutdown_request\", \"plan_approval_response\"):\n should_stop = handle_inbox_message(name, msg, messages)\n if should_stop:\n break\n else:\n non_protocol.append(msg)\n if should_stop:\n shutdown_requested = True\n break\n if non_protocol:\n inbox_json = json.dumps(non_protocol)\n messages.append({\"role\": \"user\",\n \"content\": \"\" + inbox_json + \"\"})\n\n # LLM turn\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages[-20:],\n tools=sub_tools, max_tokens=8000)\n except Exception:\n break\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n # Idle: wait for inbox messages instead of exiting\n # Real CC sends idle_notification to Lead here\n while not shutdown_requested:\n time.sleep(1)\n inbox = BUS.read_inbox(name)\n if not inbox:\n continue\n for msg in inbox:\n if msg.get(\"type\") in (\"shutdown_request\", \"plan_approval_response\"):\n should_stop = handle_inbox_message(name, msg, messages)\n if should_stop:\n shutdown_requested = True\n break\n else:\n non_protocol.append(msg)\n if shutdown_requested:\n break\n if non_protocol:\n inbox_json = json.dumps(non_protocol)\n messages.append({\"role\": \"user\",\n \"content\": \"\" + inbox_json + \"\"})\n break # back to LLM turn with new messages\n\n # Execute tool calls\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n handler = sub_handlers.get(block.name)\n output = handler(**block.input) if handler else \"Unknown\"\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # Send final summary to Lead\n summary = \"Done.\"\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\" and isinstance(msg[\"content\"], list):\n for b in msg[\"content\"]:\n if getattr(b, \"type\", None) == \"text\":\n summary = b.text\n break\n else:\n continue\n break\n BUS.send(name, \"lead\", summary, \"result\")\n active_teammates.pop(name, None)\n print(f\" \\033[32m[teammate] {name} finished\\033[0m\")\n\n active_teammates[name] = True\n threading.Thread(target=run, daemon=True).start()\n print(f\" \\033[36m[teammate] {name} spawned as {role}\\033[0m\")\n return f\"Teammate '{name}' spawned as {role}\"\n\n\ndef _teammate_submit_plan(from_name: str, plan: str) -> str:\n \"\"\"Teammate submits a plan to Lead for approval.\n\n Note: This is a protocol-level request, not a code-level gate.\n After submitting, the teammate's thread continues running — it can\n still call bash/write/etc. Real enforcement relies on the model\n waiting for the approval response before acting. Code-level tool\n gating would require blocking the teammate's tool dispatch until\n approval arrives.\n \"\"\"\n req_id = new_request_id()\n pending_requests[req_id] = ProtocolState(\n request_id=req_id, type=\"plan_approval\",\n sender=from_name, target=\"lead\",\n status=\"pending\", payload=plan)\n BUS.send(from_name, \"lead\", plan,\n \"plan_approval_request\",\n {\"request_id\": req_id})\n return f\"Plan submitted ({req_id}). Waiting for approval...\"\n\n\n# ── Lead Protocol Tools (s16 new) ──\n\ndef run_request_shutdown(teammate: str) -> str:\n req_id = new_request_id()\n pending_requests[req_id] = ProtocolState(\n request_id=req_id, type=\"shutdown\",\n sender=\"lead\", target=teammate,\n status=\"pending\", payload=\"\")\n BUS.send(\"lead\", teammate, \"Please shut down gracefully.\",\n \"shutdown_request\",\n {\"request_id\": req_id})\n print(f\" \\033[35m[protocol] shutdown_request → {teammate} \"\n f\"({req_id})\\033[0m\")\n return f\"Shutdown request sent to {teammate} (req: {req_id})\"\n\n\ndef run_request_plan(teammate: str, task: str) -> str:\n \"\"\"Lead asks a teammate to submit a plan for a task.\"\"\"\n BUS.send(\"lead\", teammate, f\"Please submit a plan for: {task}\",\n \"message\")\n return f\"Asked {teammate} to submit a plan\"\n\n\ndef run_review_plan(request_id: str, approve: bool, feedback: str = \"\") -> str:\n state = pending_requests.get(request_id)\n if not state:\n return f\"Request {request_id} not found\"\n if state.status != \"pending\":\n return f\"Request {request_id} already {state.status}\"\n state.status = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", state.sender, feedback or (\"Approved\" if approve else \"Rejected\"),\n \"plan_approval_response\",\n {\"request_id\": request_id, \"approve\": approve})\n icon = \"✓\" if approve else \"✗\"\n print(f\" \\033[32m[protocol] plan {icon} ({request_id})\\033[0m\")\n return f\"Plan {'approved' if approve else 'rejected'} ({request_id})\"\n\n\n# ── Other Lead Tool Handlers ──\n\ndef run_spawn_teammate(name: str, role: str, prompt: str) -> str:\n return spawn_teammate_thread(name, role, prompt)\n\n\ndef run_send_message(to: str, content: str) -> str:\n BUS.send(\"lead\", to, content)\n return f\"Sent to {to}\"\n\n\ndef run_check_inbox() -> str:\n \"\"\"Check Lead's inbox. Routes protocol responses via match_response.\"\"\"\n msgs = consume_lead_inbox(route_protocol=True)\n if not msgs:\n return \"(inbox empty)\"\n lines = []\n for m in msgs:\n meta = m.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n tag = f\" [{m['type']} req:{req_id}]\" if req_id else f\" [{m['type']}]\"\n lines.append(f\" [{m['from']}]{tag} {m['content'][:200]}\")\n return \"\\n\".join(lines)\n\n\n# ── Tool Dispatch ──\n\ndef execute_tool(block) -> str:\n \"\"\"Execute a tool call block, return output.\"\"\"\n handler = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"create_task\": run_create_task, \"list_tasks\": run_list_tasks,\n \"get_task\": run_get_task, \"claim_task\": run_claim_task,\n \"complete_task\": run_complete_task,\n \"spawn_teammate\": run_spawn_teammate,\n \"send_message\": run_send_message, \"check_inbox\": run_check_inbox,\n \"request_shutdown\": run_request_shutdown,\n \"request_plan\": run_request_plan, \"review_plan\": run_review_plan,\n }.get(block.name)\n if handler:\n return handler(**block.input)\n return f\"Unknown tool: {block.name}\"\n\n\n# ── Tool Definitions ──\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\"},\n \"run_in_background\": {\"type\": \"boolean\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"create_task\",\n \"description\": \"Create a new task with optional blockedBy dependencies.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"subject\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n \"blockedBy\": {\"type\": \"array\",\n \"items\": {\"type\": \"string\"}}},\n \"required\": [\"subject\"]}},\n {\"name\": \"list_tasks\",\n \"description\": \"List all tasks with status, owner, and dependencies.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n {\"name\": \"get_task\",\n \"description\": \"Get full details of a specific task by ID.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"claim_task\",\n \"description\": \"Claim a pending task. Sets owner, changes status to in_progress.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\",\n \"description\": \"Complete an in-progress task. Reports unblocked downstream tasks.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"spawn_teammate\",\n \"description\": \"Spawn a teammate agent in a background thread.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\"},\n \"role\": {\"type\": \"string\"},\n \"prompt\": {\"type\": \"string\"}},\n \"required\": [\"name\", \"role\", \"prompt\"]}},\n {\"name\": \"send_message\",\n \"description\": \"Send message to a teammate via MessageBus.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"check_inbox\",\n \"description\": \"Check Lead's inbox. Routes protocol responses automatically.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n {\"name\": \"request_shutdown\",\n \"description\": \"Request a teammate to shut down gracefully.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"teammate\": {\"type\": \"string\"}},\n \"required\": [\"teammate\"]}},\n {\"name\": \"request_plan\",\n \"description\": \"Ask a teammate to submit a plan for review.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"teammate\": {\"type\": \"string\"},\n \"task\": {\"type\": \"string\"}},\n \"required\": [\"teammate\", \"task\"]}},\n {\"name\": \"review_plan\",\n \"description\": \"Approve or reject a submitted plan by request_id.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"request_id\": {\"type\": \"string\"},\n \"approve\": {\"type\": \"boolean\"},\n \"feedback\": {\"type\": \"string\"}},\n \"required\": [\"request_id\", \"approve\"]}},\n]\n\n\n# ── Context ──\n\ndef update_context(context: dict, messages: list) -> dict:\n \"\"\"Derive context from real state.\"\"\"\n memories = \"\"\n if MEMORY_INDEX.exists():\n content = MEMORY_INDEX.read_text().strip()\n if content:\n memories = content\n return {\n \"enabled_tools\": [t[\"name\"] for t in TOOLS],\n \"workspace\": str(WORKDIR),\n \"memories\": memories,\n }\n\n\n# ── Agent Loop ──\n\ndef agent_loop(messages: list, context: dict):\n system = get_system_prompt(context)\n while True:\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages,\n tools=TOOLS, max_tokens=8000)\n except Exception as e:\n messages.append({\"role\": \"assistant\", \"content\": [\n {\"type\": \"text\",\n \"text\": f\"[Error] {type(e).__name__}: {e}\"}]})\n return\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n\n if should_run_background(block.name, block.input):\n bg_id = start_background_task(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": f\"[Background task {bg_id} started] \"\n f\"Result will be available when complete.\"})\n else:\n output = execute_tool(block)\n print(str(output)[:300])\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output})\n\n # Merge background tool results + notifications into one user message\n user_content = list(results)\n bg_notifications = collect_background_results()\n if bg_notifications:\n for notif in bg_notifications:\n user_content.append({\"type\": \"text\", \"text\": notif})\n messages.append({\"role\": \"user\", \"content\": user_content})\n context = update_context(context, messages)\n system = get_system_prompt(context)\n\n\nif __name__ == \"__main__\":\n print(\"s16: team protocols\")\n print(\"Enter a question, press Enter to send. Type q to quit.\\n\")\n history = []\n context = update_context({}, [])\n while True:\n try:\n query = input(\"\\033[36ms16 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt):\n break\n if query.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history, context)\n context = update_context(context, history)\n for block in history[-1][\"content\"]:\n if getattr(block, \"type\", None) == \"text\":\n print(block.text)\n\n # Check inbox → route protocol + inject into history\n inbox_msgs = consume_lead_inbox(route_protocol=True)\n if inbox_msgs:\n inbox_text = \"\\n\".join(\n f\"From {m['from']}: {m['content'][:200]}\" for m in inbox_msgs)\n history.append({\"role\": \"user\",\n \"content\": f\"[Inbox]\\n{inbox_text}\"})\n print(f\"\\n\\033[33m[Inbox: {len(inbox_msgs)} messages injected]\\033[0m\")\n print()\n", + "source": "#!/usr/bin/env python3\n\"\"\"\ns16: Team Protocols — request-response protocol + request_id + dispatch + state machine.\n\nRun: python s16_team_protocols/code.py\nNeed: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY\n\nChanges from s15:\n - ProtocolState dataclass (request_id, type, sender, status, created_at)\n - pending_requests dict: tracks in-flight protocol requests\n - dispatch_message: routes incoming messages by type to handlers\n - request_shutdown: Lead sends shutdown protocol request\n - request_plan: Lead asks teammate to submit plan\n - handle_shutdown_request / handle_plan_response: teammate receives & responds\n - match_response: Lead correlates response to request via request_id (with type validation)\n - Teammate idle loop: waits for inbox messages instead of exiting after 10 rounds\n - Unified consume_lead_inbox: protocol routing + injection into history\n - 3 new Lead tools: request_shutdown, request_plan, review_plan\n - 1 new teammate tool: submit_plan\n\nASCII flow:\n Lead: BUS.send(\"shutdown_request\", {request_id}) ──────→ teammate inbox\n Teammate: dispatch → handler → BUS.send(\"shutdown_response\", {request_id}) ─→ Lead inbox\n Lead: consume_lead_inbox → match_response(request_id) → pending_requests[req_id].status = approved\n\"\"\"\n\nimport os, subprocess, json, time, random, threading\nfrom pathlib import Path\nfrom datetime import datetime\nfrom dataclasses import dataclass, asdict, field\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nMEMORY_DIR = WORKDIR / \".memory\"\nMEMORY_INDEX = MEMORY_DIR / \"MEMORY.md\"\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\n# ── Task System (from s12, synced) ──\n\nTASKS_DIR = WORKDIR / \".tasks\"\nTASKS_DIR.mkdir(exist_ok=True)\n\n\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str # pending | in_progress | completed\n owner: str | None\n blockedBy: list[str]\n\n\ndef _task_path(task_id: str) -> Path:\n return TASKS_DIR / f\"{task_id}.json\"\n\n\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random.randint(0, 9999):04d}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n\n\ndef save_task(task: Task):\n _task_path(task.id).write_text(json.dumps(asdict(task), indent=2))\n\n\ndef load_task(task_id: str) -> Task:\n return Task(**json.loads(_task_path(task_id).read_text()))\n\n\ndef list_tasks() -> list[Task]:\n return [Task(**json.loads(p.read_text()))\n for p in sorted(TASKS_DIR.glob(\"task_*.json\"))]\n\n\ndef get_task(task_id: str) -> str:\n \"\"\"Return full task details as JSON.\"\"\"\n task = load_task(task_id)\n return json.dumps(asdict(task), indent=2)\n\n\ndef can_start(task_id: str) -> bool:\n \"\"\"Check if all blockedBy dependencies are completed.\n Missing dependencies are treated as blocked.\"\"\"\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False\n if load_task(dep_id).status != \"completed\":\n return False\n return True\n\n\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if not _task_path(d).exists() or load_task(d).status != \"completed\"]\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n print(f\" \\033[36m[claim] {task.subject} → in_progress (owner: {owner})\\033[0m\")\n return f\"Claimed {task.id} ({task.subject})\"\n\n\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n if task.status != \"in_progress\":\n return f\"Task {task_id} is {task.status}, cannot complete\"\n task.status = \"completed\"\n save_task(task)\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy and can_start(t.id)]\n print(f\" \\033[32m[complete] {task.subject} ✓\\033[0m\")\n msg = f\"Completed {task.id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n print(f\" \\033[33m[unblocked] {', '.join(unblocked)}\\033[0m\")\n return msg\n\n\n# ── Prompt Assembly (from s10, synced) ──\n\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file, \"\n \"get_task, create_task, list_tasks, claim_task, complete_task, \"\n \"spawn_teammate, send_message, check_inbox, \"\n \"request_shutdown, request_plan, review_plan.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n\n\ndef assemble_system_prompt(context: dict) -> str:\n sections = [PROMPT_SECTIONS[\"identity\"],\n PROMPT_SECTIONS[\"tools\"],\n PROMPT_SECTIONS[\"workspace\"]]\n memories = context.get(\"memories\", \"\")\n if memories:\n sections.append(f\"Relevant memories:\\n{memories}\")\n return \"\\n\\n\".join(sections)\n\n\n_last_context_key, _last_prompt = None, None\n\n\ndef get_system_prompt(context: dict) -> str:\n global _last_context_key, _last_prompt\n key = json.dumps(context, sort_keys=True, ensure_ascii=False, default=str)\n if key == _last_context_key and _last_prompt:\n return _last_prompt\n _last_context_key = key\n _last_prompt = assemble_system_prompt(context)\n return _last_prompt\n\n\n# ── Tools ──\n\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR):\n raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\n\ndef run_bash(command: str, run_in_background: bool = False) -> str:\n # run_in_background is handled by agent_loop dispatch, not here\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\n\ndef run_read(path: str, limit: int | None = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_write(path: str, content: str) -> str:\n try:\n fp = safe_path(path)\n fp.parent.mkdir(parents=True, exist_ok=True)\n fp.write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\n# Task tools\n\ndef run_create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> str:\n task = create_task(subject, description, blockedBy)\n deps = f\" (blockedBy: {', '.join(blockedBy)})\" if blockedBy else \"\"\n print(f\" \\033[34m[create] {task.subject}{deps}\\033[0m\")\n return f\"Created {task.id}: {task.subject}{deps}\"\n\n\ndef run_list_tasks() -> str:\n tasks = list_tasks()\n if not tasks:\n return \"No tasks. Use create_task to add some.\"\n lines = []\n for t in tasks:\n icon = {\"pending\": \"○\", \"in_progress\": \"●\",\n \"completed\": \"✓\"}.get(t.status, \"?\")\n deps = f\" (blockedBy: {', '.join(t.blockedBy)})\" if t.blockedBy else \"\"\n owner = f\" [{t.owner}]\" if t.owner else \"\"\n lines.append(f\" {icon} {t.id}: {t.subject} \"\n f\"[{t.status}]{owner}{deps}\")\n return \"\\n\".join(lines)\n\n\ndef run_get_task(task_id: str) -> str:\n try:\n return get_task(task_id)\n except FileNotFoundError:\n return f\"Error: Task {task_id} not found\"\n\n\ndef run_claim_task(task_id: str) -> str:\n return claim_task(task_id, owner=\"agent\")\n\n\ndef run_complete_task(task_id: str) -> str:\n return complete_task(task_id)\n\n\n# ── Background Tasks (from s13, synced) ──\n\n_bg_counter = 0\nbackground_tasks: dict[str, dict] = {}\nbackground_results: dict[str, str] = {}\nbackground_lock = threading.Lock()\n\n\ndef is_slow_operation(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Fallback heuristic: commands likely to take > 30s.\"\"\"\n if tool_name != \"bash\":\n return False\n cmd = tool_input.get(\"command\", \"\").lower()\n slow_keywords = [\"install\", \"build\", \"test\", \"deploy\", \"compile\",\n \"docker build\", \"pip install\", \"npm install\",\n \"cargo build\", \"pytest\", \"make\"]\n return any(kw in cmd for kw in slow_keywords)\n\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Model explicit request takes priority; fallback to heuristic.\"\"\"\n if tool_input.get(\"run_in_background\"):\n return True\n return is_slow_operation(tool_name, tool_input)\n\n\ndef start_background_task(block) -> str:\n \"\"\"Run tool in a daemon thread. Returns background task ID.\"\"\"\n global _bg_counter\n _bg_counter += 1\n bg_id = f\"bg_{_bg_counter:04d}\"\n cmd = block.input.get(\"command\", block.name)\n\n def worker():\n result = execute_tool(block)\n with background_lock:\n background_tasks[bg_id][\"status\"] = \"completed\"\n background_results[bg_id] = result\n\n with background_lock:\n background_tasks[bg_id] = {\n \"tool_use_id\": block.id,\n \"command\": cmd,\n \"status\": \"running\",\n }\n threading.Thread(target=worker, daemon=True).start()\n print(f\" \\033[33m[background] dispatched {bg_id}: {cmd[:40]}\\033[0m\")\n return bg_id\n\n\ndef collect_background_results() -> list[str]:\n \"\"\"Collect completed background results as task_notification messages.\"\"\"\n with background_lock:\n ready_ids = [bid for bid, task in background_tasks.items()\n if task[\"status\"] == \"completed\"]\n notifications = []\n for bg_id in ready_ids:\n with background_lock:\n task = background_tasks.pop(bg_id)\n output = background_results.pop(bg_id, \"\")\n summary = output[:200] if len(output) > 200 else output\n notifications.append(\n f\"\\n\"\n f\" {bg_id}\\n\"\n f\" completed\\n\"\n f\" {task['command']}\\n\"\n f\" {summary}\\n\"\n f\"\")\n print(f\" \\033[32m[background done] {bg_id}: \"\n f\"{task['command'][:40]} ({len(output)} chars)\\033[0m\")\n return notifications\n\n\n# ── MessageBus (from s15) ──\n\nMAILBOX_DIR = WORKDIR / \".mailboxes\"\nMAILBOX_DIR.mkdir(exist_ok=True)\n\n\nclass MessageBus:\n \"\"\"File-based message bus. Each agent has a .jsonl inbox.\n Read is destructive: read_text + unlink (consumes messages).\n Teaching version: no file locking; real Claude Code uses proper-lockfile.\"\"\"\n\n def send(self, from_agent: str, to_agent: str, content: str,\n msg_type: str = \"message\", metadata: dict = None):\n msg = {\"from\": from_agent, \"to\": to_agent,\n \"content\": content, \"type\": msg_type,\n \"ts\": time.time(), \"metadata\": metadata or {}}\n inbox = MAILBOX_DIR / f\"{to_agent}.jsonl\"\n with open(inbox, \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n print(f\" \\033[33m[bus] {from_agent} → {to_agent}: \"\n f\"({msg_type}) {content[:50]}\\033[0m\")\n\n def read_inbox(self, agent: str) -> list[dict]:\n inbox = MAILBOX_DIR / f\"{agent}.jsonl\"\n if not inbox.exists():\n return []\n msgs = [json.loads(line) for line in inbox.read_text().splitlines()\n if line.strip()]\n inbox.unlink() # consume: read + delete\n return msgs\n\n\nBUS = MessageBus()\nactive_teammates: dict[str, bool] = {}\n\n# ── Protocol State (s16 new) ──\n\n@dataclass\nclass ProtocolState:\n request_id: str\n type: str # \"shutdown\" | \"plan_approval\"\n sender: str\n target: str\n status: str # pending | approved | rejected\n payload: str # plan text or shutdown reason\n created_at: float = field(default_factory=time.time)\n\n\npending_requests: dict[str, ProtocolState] = {}\n\n\ndef new_request_id() -> str:\n return f\"req_{random.randint(0, 999999):06d}\"\n\n\ndef match_response(response_type: str, request_id: str, approve: bool):\n \"\"\"Correlate a response to the original request via request_id.\n Validates that response_type matches the request type.\"\"\"\n state = pending_requests.get(request_id)\n if not state:\n print(f\" \\033[31m[protocol] unknown request_id: {request_id}\\033[0m\")\n return\n # Validate response type matches request type\n if state.type == \"shutdown\" and response_type != \"shutdown_response\":\n print(f\" \\033[31m[protocol] type mismatch: expected shutdown_response, \"\n f\"got {response_type}\\033[0m\")\n return\n if state.type == \"plan_approval\" and response_type != \"plan_approval_response\":\n print(f\" \\033[31m[protocol] type mismatch: expected plan_approval_response, \"\n f\"got {response_type}\\033[0m\")\n return\n if state.status != \"pending\":\n print(f\" \\033[33m[protocol] {request_id} already {state.status}, \"\n f\"ignoring duplicate\\033[0m\")\n return\n state.status = \"approved\" if approve else \"rejected\"\n icon = \"✓\" if approve else \"✗\"\n color = \"32\" if approve else \"31\"\n print(f\" \\033[{color}m[protocol] {state.type} {icon} \"\n f\"({request_id}: {state.status})\\033[0m\")\n\n\n# ── Unified Lead Inbox Consumer (s16 fix) ──\n# Both check_inbox tool and main loop call this function.\n# Protocol responses are routed via match_response before returning.\n\ndef consume_lead_inbox(route_protocol: bool = True) -> list[dict]:\n \"\"\"Read Lead's inbox. Route protocol responses, return all messages.\n Called by both run_check_inbox() and main loop to avoid\n messages being consumed without protocol routing.\"\"\"\n msgs = BUS.read_inbox(\"lead\")\n if not msgs:\n return []\n if route_protocol:\n for msg in msgs:\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n msg_type = msg.get(\"type\", \"\")\n if req_id and msg_type.endswith(\"_response\"):\n approve = meta.get(\"approve\", False)\n match_response(msg_type, req_id, approve)\n return msgs\n\n\n# ── Teammate Thread (s16: idle loop + dispatch) ──\n\ndef spawn_teammate_thread(name: str, role: str, prompt: str) -> str:\n \"\"\"Spawn a teammate agent in a background thread.\n Uses idle loop: after each LLM turn, waits for inbox messages\n (shutdown_request, new task) instead of exiting.\"\"\"\n if name in active_teammates:\n return f\"Teammate '{name}' already exists\"\n\n system = (f\"You are '{name}', a {role}. \"\n f\"Use tools to complete tasks. \"\n f\"Check inbox for protocol messages (shutdown_request, etc).\")\n\n def handle_inbox_message(name: str, msg: dict, messages: list) -> bool:\n \"\"\"Dispatch incoming protocol messages by type.\n Returns True if teammate should stop.\"\"\"\n msg_type = msg.get(\"type\", \"message\")\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n\n if msg_type == \"shutdown_request\":\n BUS.send(name, \"lead\", \"Shutting down gracefully.\",\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n print(f\" \\033[35m[protocol] {name} approved shutdown \"\n f\"({req_id})\\033[0m\")\n return True # stop the loop\n\n if msg_type == \"plan_approval_response\":\n approve = meta.get(\"approve\", False)\n if approve:\n messages.append({\"role\": \"user\",\n \"content\": f\"[Plan approved] Proceed with the task.\"})\n else:\n messages.append({\"role\": \"user\",\n \"content\": f\"[Plan rejected] Feedback: {msg['content']}\"})\n\n return False # continue\n\n def run():\n messages = [{\"role\": \"user\", \"content\": prompt}]\n sub_tools = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"send_message\",\n \"description\": \"Send message to another agent.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"submit_plan\",\n \"description\": \"Submit a plan for Lead approval.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"plan\": {\"type\": \"string\"}},\n \"required\": [\"plan\"]}},\n ]\n sub_handlers = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"send_message\": lambda to, content: (BUS.send(name, to, content),\n \"Sent\")[1],\n \"submit_plan\": lambda plan: _teammate_submit_plan(name, plan),\n }\n\n shutdown_requested = False\n while not shutdown_requested:\n # Check inbox for protocol messages\n inbox = BUS.read_inbox(name)\n should_stop = False\n non_protocol = []\n for msg in inbox:\n if msg.get(\"type\") in (\"shutdown_request\", \"plan_approval_response\"):\n should_stop = handle_inbox_message(name, msg, messages)\n if should_stop:\n break\n else:\n non_protocol.append(msg)\n if should_stop:\n shutdown_requested = True\n break\n if non_protocol:\n inbox_json = json.dumps(non_protocol)\n messages.append({\"role\": \"user\",\n \"content\": \"\" + inbox_json + \"\"})\n\n # LLM turn\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages[-20:],\n tools=sub_tools, max_tokens=8000)\n except Exception:\n break\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n # Idle: wait for inbox messages instead of exiting\n # Real Claude Code sends idle_notification to Lead here\n while not shutdown_requested:\n time.sleep(1)\n inbox = BUS.read_inbox(name)\n if not inbox:\n continue\n for msg in inbox:\n if msg.get(\"type\") in (\"shutdown_request\", \"plan_approval_response\"):\n should_stop = handle_inbox_message(name, msg, messages)\n if should_stop:\n shutdown_requested = True\n break\n else:\n non_protocol.append(msg)\n if shutdown_requested:\n break\n if non_protocol:\n inbox_json = json.dumps(non_protocol)\n messages.append({\"role\": \"user\",\n \"content\": \"\" + inbox_json + \"\"})\n break # back to LLM turn with new messages\n\n # Execute tool calls\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n handler = sub_handlers.get(block.name)\n output = handler(**block.input) if handler else \"Unknown\"\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # Send final summary to Lead\n summary = \"Done.\"\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\" and isinstance(msg[\"content\"], list):\n for b in msg[\"content\"]:\n if getattr(b, \"type\", None) == \"text\":\n summary = b.text\n break\n else:\n continue\n break\n BUS.send(name, \"lead\", summary, \"result\")\n active_teammates.pop(name, None)\n print(f\" \\033[32m[teammate] {name} finished\\033[0m\")\n\n active_teammates[name] = True\n threading.Thread(target=run, daemon=True).start()\n print(f\" \\033[36m[teammate] {name} spawned as {role}\\033[0m\")\n return f\"Teammate '{name}' spawned as {role}\"\n\n\ndef _teammate_submit_plan(from_name: str, plan: str) -> str:\n \"\"\"Teammate submits a plan to Lead for approval.\n\n Note: This is a protocol-level request, not a code-level gate.\n After submitting, the teammate's thread continues running — it can\n still call bash/write/etc. Real enforcement relies on the model\n waiting for the approval response before acting. Code-level tool\n gating would require blocking the teammate's tool dispatch until\n approval arrives.\n \"\"\"\n req_id = new_request_id()\n pending_requests[req_id] = ProtocolState(\n request_id=req_id, type=\"plan_approval\",\n sender=from_name, target=\"lead\",\n status=\"pending\", payload=plan)\n BUS.send(from_name, \"lead\", plan,\n \"plan_approval_request\",\n {\"request_id\": req_id})\n return f\"Plan submitted ({req_id}). Waiting for approval...\"\n\n\n# ── Lead Protocol Tools (s16 new) ──\n\ndef run_request_shutdown(teammate: str) -> str:\n req_id = new_request_id()\n pending_requests[req_id] = ProtocolState(\n request_id=req_id, type=\"shutdown\",\n sender=\"lead\", target=teammate,\n status=\"pending\", payload=\"\")\n BUS.send(\"lead\", teammate, \"Please shut down gracefully.\",\n \"shutdown_request\",\n {\"request_id\": req_id})\n print(f\" \\033[35m[protocol] shutdown_request → {teammate} \"\n f\"({req_id})\\033[0m\")\n return f\"Shutdown request sent to {teammate} (req: {req_id})\"\n\n\ndef run_request_plan(teammate: str, task: str) -> str:\n \"\"\"Lead asks a teammate to submit a plan for a task.\"\"\"\n BUS.send(\"lead\", teammate, f\"Please submit a plan for: {task}\",\n \"message\")\n return f\"Asked {teammate} to submit a plan\"\n\n\ndef run_review_plan(request_id: str, approve: bool, feedback: str = \"\") -> str:\n state = pending_requests.get(request_id)\n if not state:\n return f\"Request {request_id} not found\"\n if state.status != \"pending\":\n return f\"Request {request_id} already {state.status}\"\n state.status = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", state.sender, feedback or (\"Approved\" if approve else \"Rejected\"),\n \"plan_approval_response\",\n {\"request_id\": request_id, \"approve\": approve})\n icon = \"✓\" if approve else \"✗\"\n print(f\" \\033[32m[protocol] plan {icon} ({request_id})\\033[0m\")\n return f\"Plan {'approved' if approve else 'rejected'} ({request_id})\"\n\n\n# ── Other Lead Tool Handlers ──\n\ndef run_spawn_teammate(name: str, role: str, prompt: str) -> str:\n return spawn_teammate_thread(name, role, prompt)\n\n\ndef run_send_message(to: str, content: str) -> str:\n BUS.send(\"lead\", to, content)\n return f\"Sent to {to}\"\n\n\ndef run_check_inbox() -> str:\n \"\"\"Check Lead's inbox. Routes protocol responses via match_response.\"\"\"\n msgs = consume_lead_inbox(route_protocol=True)\n if not msgs:\n return \"(inbox empty)\"\n lines = []\n for m in msgs:\n meta = m.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n tag = f\" [{m['type']} req:{req_id}]\" if req_id else f\" [{m['type']}]\"\n lines.append(f\" [{m['from']}]{tag} {m['content'][:200]}\")\n return \"\\n\".join(lines)\n\n\n# ── Tool Dispatch ──\n\ndef execute_tool(block) -> str:\n \"\"\"Execute a tool call block, return output.\"\"\"\n handler = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"create_task\": run_create_task, \"list_tasks\": run_list_tasks,\n \"get_task\": run_get_task, \"claim_task\": run_claim_task,\n \"complete_task\": run_complete_task,\n \"spawn_teammate\": run_spawn_teammate,\n \"send_message\": run_send_message, \"check_inbox\": run_check_inbox,\n \"request_shutdown\": run_request_shutdown,\n \"request_plan\": run_request_plan, \"review_plan\": run_review_plan,\n }.get(block.name)\n if handler:\n return handler(**block.input)\n return f\"Unknown tool: {block.name}\"\n\n\n# ── Tool Definitions ──\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"command\": {\"type\": \"string\"},\n \"run_in_background\": {\"type\": \"boolean\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"create_task\",\n \"description\": \"Create a new task with optional blockedBy dependencies.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"subject\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n \"blockedBy\": {\"type\": \"array\",\n \"items\": {\"type\": \"string\"}}},\n \"required\": [\"subject\"]}},\n {\"name\": \"list_tasks\",\n \"description\": \"List all tasks with status, owner, and dependencies.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n {\"name\": \"get_task\",\n \"description\": \"Get full details of a specific task by ID.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"claim_task\",\n \"description\": \"Claim a pending task. Sets owner, changes status to in_progress.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\",\n \"description\": \"Complete an in-progress task. Reports unblocked downstream tasks.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"spawn_teammate\",\n \"description\": \"Spawn a teammate agent in a background thread.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"name\": {\"type\": \"string\"},\n \"role\": {\"type\": \"string\"},\n \"prompt\": {\"type\": \"string\"}},\n \"required\": [\"name\", \"role\", \"prompt\"]}},\n {\"name\": \"send_message\",\n \"description\": \"Send message to a teammate via MessageBus.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"check_inbox\",\n \"description\": \"Check Lead's inbox. Routes protocol responses automatically.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n {\"name\": \"request_shutdown\",\n \"description\": \"Request a teammate to shut down gracefully.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"teammate\": {\"type\": \"string\"}},\n \"required\": [\"teammate\"]}},\n {\"name\": \"request_plan\",\n \"description\": \"Ask a teammate to submit a plan for review.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"teammate\": {\"type\": \"string\"},\n \"task\": {\"type\": \"string\"}},\n \"required\": [\"teammate\", \"task\"]}},\n {\"name\": \"review_plan\",\n \"description\": \"Approve or reject a submitted plan by request_id.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\n \"request_id\": {\"type\": \"string\"},\n \"approve\": {\"type\": \"boolean\"},\n \"feedback\": {\"type\": \"string\"}},\n \"required\": [\"request_id\", \"approve\"]}},\n]\n\n\n# ── Context ──\n\ndef update_context(context: dict, messages: list) -> dict:\n \"\"\"Derive context from real state.\"\"\"\n memories = \"\"\n if MEMORY_INDEX.exists():\n content = MEMORY_INDEX.read_text().strip()\n if content:\n memories = content\n return {\n \"enabled_tools\": [t[\"name\"] for t in TOOLS],\n \"workspace\": str(WORKDIR),\n \"memories\": memories,\n }\n\n\n# ── Agent Loop ──\n\ndef agent_loop(messages: list, context: dict):\n system = get_system_prompt(context)\n while True:\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages,\n tools=TOOLS, max_tokens=8000)\n except Exception as e:\n messages.append({\"role\": \"assistant\", \"content\": [\n {\"type\": \"text\",\n \"text\": f\"[Error] {type(e).__name__}: {e}\"}]})\n return\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n\n if should_run_background(block.name, block.input):\n bg_id = start_background_task(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": f\"[Background task {bg_id} started] \"\n f\"Result will be available when complete.\"})\n else:\n output = execute_tool(block)\n print(str(output)[:300])\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output})\n\n # Merge background tool results + notifications into one user message\n user_content = list(results)\n bg_notifications = collect_background_results()\n if bg_notifications:\n for notif in bg_notifications:\n user_content.append({\"type\": \"text\", \"text\": notif})\n messages.append({\"role\": \"user\", \"content\": user_content})\n context = update_context(context, messages)\n system = get_system_prompt(context)\n\n\nif __name__ == \"__main__\":\n print(\"s16: team protocols\")\n print(\"Enter a question, press Enter to send. Type q to quit.\\n\")\n history = []\n context = update_context({}, [])\n while True:\n try:\n query = input(\"\\033[36ms16 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt):\n break\n if query.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history, context)\n context = update_context(context, history)\n for block in history[-1][\"content\"]:\n if getattr(block, \"type\", None) == \"text\":\n print(block.text)\n\n # Check inbox → route protocol + inject into history\n inbox_msgs = consume_lead_inbox(route_protocol=True)\n if inbox_msgs:\n inbox_text = \"\\n\".join(\n f\"From {m['from']}: {m['content'][:200]}\" for m in inbox_msgs)\n history.append({\"role\": \"user\",\n \"content\": f\"[Inbox]\\n{inbox_text}\"})\n print(f\"\\n\\033[33m[Inbox: {len(inbox_msgs)} messages injected]\\033[0m\")\n print()\n", "images": [ { "src": "/course-assets/s16_team_protocols/team-protocols-overview.svg", @@ -3033,7 +3064,7 @@ } ], "layer": "collaboration", - "source": "#!/usr/bin/env python3\n\"\"\"\ns19: MCP Tools — MCPClient + tool discovery + assemble_tool_pool.\n\nRun: python s19_mcp_plugin/code.py\nNeed: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY\n\nChanges from s18:\n - MCPClient class: discovers tools, calls tools via mock handler\n - normalize_mcp_name: normalize tool/server names\n - assemble_tool_pool: assembles builtin + MCP tools into one pool\n - connect_mcp: connect to an MCP server, discover tools\n - Tool naming: mcp__{server}__{tool} with normalization\n - MCP tools have readOnly/destructive annotations\n - agent_loop uses dynamic tool pool (builtin + MCP), no prompt cache\n - Teammate tools: complete_task, worktree cwd (from s17/s18 fixes)\n\nASCII flow:\n connect_mcp(\"docs\") → MCPClient discovers tools →\n assemble_tool_pool → [builtin... , mcp__docs__search, mcp__docs__get_version]\n agent_loop uses assembled pool\n\"\"\"\n\nimport os, subprocess, json, time, random, threading, re\nfrom pathlib import Path\nfrom datetime import datetime\nfrom dataclasses import dataclass, asdict, field\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\n# ── Task System ──\n\nTASKS_DIR = WORKDIR / \".tasks\"\nTASKS_DIR.mkdir(exist_ok=True)\n\n\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str\n owner: str | None\n blockedBy: list[str]\n worktree: str | None = None\n\n\ndef _task_path(task_id: str) -> Path:\n return TASKS_DIR / f\"{task_id}.json\"\n\n\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random.randint(0, 9999):04d}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n\n\ndef save_task(task: Task):\n _task_path(task.id).write_text(json.dumps(asdict(task), indent=2))\n\n\ndef load_task(task_id: str) -> Task:\n return Task(**json.loads(_task_path(task_id).read_text()))\n\n\ndef list_tasks() -> list[Task]:\n return [Task(**json.loads(p.read_text()))\n for p in sorted(TASKS_DIR.glob(\"task_*.json\"))]\n\n\ndef get_task_json(task_id: str) -> str:\n return json.dumps(asdict(load_task(task_id)), indent=2)\n\n\ndef can_start(task_id: str) -> bool:\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False\n if load_task(dep_id).status != \"completed\":\n return False\n return True\n\n\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if task.owner:\n return f\"Task {task_id} already owned by {task.owner}\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if _task_path(d).exists() and load_task(d).status != \"completed\"]\n missing = [d for d in task.blockedBy if not _task_path(d).exists()]\n parts = []\n if deps: parts.append(f\"blocked by: {deps}\")\n if missing: parts.append(f\"missing deps: {missing}\")\n return \"Cannot start — \" + \", \".join(parts)\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n print(f\" \\033[36m[claim] {task.subject} → in_progress\\033[0m\")\n return f\"Claimed {task.id} ({task.subject})\"\n\n\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n if task.status != \"in_progress\":\n return f\"Task {task_id} is {task.status}, cannot complete\"\n task.status = \"completed\"\n save_task(task)\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy and can_start(t.id)]\n print(f\" \\033[32m[complete] {task.subject} ✓\\033[0m\")\n msg = f\"Completed {task.id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n return msg\n\n\n# ── Worktree System ──\n\nWORKTREES_DIR = WORKDIR / \".worktrees\"\nWORKTREES_DIR.mkdir(exist_ok=True)\n\nVALID_WT_NAME = re.compile(r'^[A-Za-z0-9._-]{1,64}$')\n\n\ndef validate_worktree_name(name: str) -> str | None:\n if not name:\n return \"Worktree name cannot be empty\"\n if name in (\".\", \"..\"):\n return f\"'{name}' is not a valid worktree name\"\n if not VALID_WT_NAME.match(name):\n return (f\"Invalid worktree name '{name}': \"\n \"only letters, digits, dots, underscores, dashes (1-64 chars)\")\n return None\n\n\ndef run_git(args: list[str]) -> tuple[bool, str]:\n try:\n r = subprocess.run([\"git\"] + args, cwd=WORKDIR,\n capture_output=True, text=True, timeout=30)\n out = (r.stdout + r.stderr).strip()\n return r.returncode == 0, out[:5000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return False, \"Error: git timeout\"\n\n\ndef log_event(event_type: str, worktree_name: str, task_id: str = \"\"):\n event = {\"type\": event_type, \"worktree\": worktree_name,\n \"task_id\": task_id, \"ts\": time.time()}\n events_file = WORKTREES_DIR / \"events.jsonl\"\n with open(events_file, \"a\") as f:\n f.write(json.dumps(event) + \"\\n\")\n\n\ndef create_worktree(name: str, task_id: str = \"\") -> str:\n err = validate_worktree_name(name)\n if err:\n return f\"Error: {err}\"\n path = WORKTREES_DIR / name\n if path.exists():\n return f\"Worktree '{name}' already exists at {path}\"\n ok, result = run_git([\"worktree\", \"add\", str(path), \"-b\", f\"wt/{name}\", \"HEAD\"])\n if not ok:\n return f\"Git error: {result}\"\n if task_id:\n bind_task_to_worktree(task_id, name)\n log_event(\"create\", name, task_id)\n print(f\" \\033[33m[worktree] created: {name} at {path}\\033[0m\")\n return f\"Worktree '{name}' created at {path}\"\n\n\ndef bind_task_to_worktree(task_id: str, worktree_name: str):\n task = load_task(task_id)\n task.worktree = worktree_name\n save_task(task)\n\n\ndef _count_worktree_changes(path: Path) -> tuple[int, int]:\n try:\n r1 = subprocess.run([\"git\", \"status\", \"--porcelain\"],\n cwd=path, capture_output=True, text=True, timeout=10)\n files = len([l for l in r1.stdout.strip().splitlines() if l.strip()])\n r2 = subprocess.run([\"git\", \"log\", \"@{push}..HEAD\", \"--oneline\"],\n cwd=path, capture_output=True, text=True, timeout=10)\n commits = len([l for l in r2.stdout.strip().splitlines() if l.strip()])\n return files, commits\n except Exception:\n return -1, -1\n\n\ndef remove_worktree(name: str, discard_changes: bool = False) -> str:\n err = validate_worktree_name(name)\n if err:\n return err\n path = WORKTREES_DIR / name\n if not path.exists():\n return f\"Worktree '{name}' not found\"\n if not discard_changes:\n files, commits = _count_worktree_changes(path)\n if files < 0:\n return \"Cannot verify status. Use discard_changes=true to force.\"\n if files > 0 or commits > 0:\n return (f\"Worktree '{name}' has {files} file(s), {commits} commit(s). \"\n \"Use discard_changes=true or keep_worktree.\")\n ok1, _ = run_git([\"worktree\", \"remove\", str(path), \"--force\"])\n if not ok1:\n return f\"Failed to remove worktree '{name}'\"\n run_git([\"branch\", \"-D\", f\"wt/{name}\"])\n log_event(\"remove\", name)\n print(f\" \\033[33m[worktree] removed: {name}\\033[0m\")\n return f\"Worktree '{name}' removed\"\n\n\ndef keep_worktree(name: str) -> str:\n err = validate_worktree_name(name)\n if err:\n return err\n log_event(\"keep\", name)\n return f\"Worktree '{name}' kept for review (branch: wt/{name})\"\n\n\n# ── Prompt Assembly ──\n\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file, \"\n \"create_task, list_tasks, get_task, claim_task, complete_task, \"\n \"spawn_teammate, send_message, check_inbox, \"\n \"request_shutdown, request_plan, review_plan, \"\n \"create_worktree, remove_worktree, keep_worktree, \"\n \"connect_mcp. MCP tools are prefixed mcp__{server}__{tool}.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n\n\ndef assemble_system_prompt(context: dict) -> str:\n sections = [PROMPT_SECTIONS[\"identity\"],\n PROMPT_SECTIONS[\"tools\"],\n PROMPT_SECTIONS[\"workspace\"]]\n if context.get(\"memories\"):\n sections.append(f\"Relevant memories:\\n{context['memories']}\")\n mcp_names = list(mcp_clients.keys())\n if mcp_names:\n sections.append(f\"Connected MCP servers: {', '.join(mcp_names)}\")\n return \"\\n\\n\".join(sections)\n\n\n# ── Basic Tools ──\n\ndef safe_path(p: str, cwd: Path = None) -> Path:\n base = cwd or WORKDIR\n path = (base / p).resolve()\n if not path.is_relative_to(base):\n raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\n\ndef run_bash(command: str, cwd: Path = None) -> str:\n try:\n r = subprocess.run(command, shell=True, cwd=cwd or WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\n\ndef run_read(path: str, limit: int | None = None, cwd: Path = None) -> str:\n try:\n lines = safe_path(path, cwd).read_text().splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_write(path: str, content: str, cwd: Path = None) -> str:\n try:\n fp = safe_path(path, cwd)\n fp.parent.mkdir(parents=True, exist_ok=True)\n fp.write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\n# ── MessageBus ──\n\nMAILBOX_DIR = WORKDIR / \".mailboxes\"\nMAILBOX_DIR.mkdir(exist_ok=True)\n\n\nclass MessageBus:\n def send(self, from_agent: str, to_agent: str, content: str,\n msg_type: str = \"message\", metadata: dict = None):\n msg = {\"from\": from_agent, \"to\": to_agent,\n \"content\": content, \"type\": msg_type,\n \"ts\": time.time(), \"metadata\": metadata or {}}\n inbox = MAILBOX_DIR / f\"{to_agent}.jsonl\"\n with open(inbox, \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n print(f\" \\033[33m[bus] {from_agent} → {to_agent}: \"\n f\"({msg_type}) {content[:50]}\\033[0m\")\n\n def read_inbox(self, agent: str) -> list[dict]:\n inbox = MAILBOX_DIR / f\"{agent}.jsonl\"\n if not inbox.exists():\n return []\n msgs = [json.loads(line) for line in inbox.read_text().splitlines()\n if line.strip()]\n inbox.unlink()\n return msgs\n\n\nBUS = MessageBus()\nactive_teammates: dict[str, bool] = {}\n\n# ── Protocol State ──\n\n@dataclass\nclass ProtocolState:\n request_id: str\n type: str\n sender: str\n target: str\n status: str\n payload: str\n created_at: float = field(default_factory=time.time)\n\n\npending_requests: dict[str, ProtocolState] = {}\n\n\ndef new_request_id() -> str:\n return f\"req_{random.randint(0, 999999):06d}\"\n\n\ndef match_response(response_type: str, request_id: str, approve: bool):\n state = pending_requests.get(request_id)\n if not state:\n return\n if state.type == \"shutdown\" and response_type != \"shutdown_response\":\n return\n if state.type == \"plan_approval\" and response_type != \"plan_approval_response\":\n return\n state.status = \"approved\" if approve else \"rejected\"\n\n\ndef consume_lead_inbox(route_protocol=True) -> list[dict]:\n msgs = BUS.read_inbox(\"lead\")\n if route_protocol:\n for msg in msgs:\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n msg_type = msg.get(\"type\", \"\")\n if req_id and msg_type.endswith(\"_response\"):\n match_response(msg_type, req_id, meta.get(\"approve\", False))\n return msgs\n\n\n# ── Autonomous Agent ──\n\nIDLE_POLL_INTERVAL = 5\nIDLE_TIMEOUT = 60\n\n\ndef scan_unclaimed_tasks() -> list[dict]:\n unclaimed = []\n for f in sorted(TASKS_DIR.glob(\"task_*.json\")):\n task = json.loads(f.read_text())\n if (task.get(\"status\") == \"pending\"\n and not task.get(\"owner\")\n and can_start(task[\"id\"])):\n unclaimed.append(task)\n return unclaimed\n\n\ndef idle_poll(agent_name: str, messages: list,\n name: str, role: str) -> str:\n for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):\n time.sleep(IDLE_POLL_INTERVAL)\n inbox = BUS.read_inbox(agent_name)\n if inbox:\n for msg in inbox:\n if msg.get(\"type\") == \"shutdown_request\":\n req_id = msg.get(\"metadata\", {}).get(\"request_id\", \"\")\n BUS.send(name, \"lead\", \"Shutting down.\",\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n return \"shutdown\"\n messages.append({\"role\": \"user\",\n \"content\": \"\" + json.dumps(inbox) + \"\"})\n return \"work\"\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n task_data = unclaimed[0]\n result = claim_task(task_data[\"id\"], agent_name)\n if \"Claimed\" in result:\n wt_info = \"\"\n if task_data.get(\"worktree\"):\n wt_info = f\"\\nWork directory: {WORKTREES_DIR / task_data['worktree']}\"\n messages.append({\"role\": \"user\",\n \"content\": f\"Task {task_data['id']}: \"\n f\"{task_data['subject']}{wt_info}\"})\n return \"work\"\n return \"timeout\"\n\n\n# ── Teammate Thread ──\n\ndef spawn_teammate_thread(name: str, role: str, prompt: str) -> str:\n if name in active_teammates:\n return f\"Teammate '{name}' already exists\"\n\n system = (f\"You are '{name}', a {role}. \"\n f\"Use tools to complete tasks. \"\n f\"If a task has a worktree, work in that directory.\")\n\n def handle_inbox_message(name: str, msg: dict, messages: list):\n msg_type = msg.get(\"type\", \"message\")\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n if msg_type == \"shutdown_request\":\n BUS.send(name, \"lead\", \"Shutting down.\",\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n return True\n if msg_type == \"plan_approval_response\":\n approve = meta.get(\"approve\", False)\n messages.append({\"role\": \"user\",\n \"content\": \"[Plan approved]\" if approve\n else f\"[Plan rejected] {msg['content']}\"})\n return False\n\n def run():\n wt_ctx = {\"path\": None}\n\n def _wt_cwd():\n p = wt_ctx[\"path\"]\n return Path(p) if p else None\n\n def _run_bash(command: str) -> str:\n return run_bash(command, cwd=_wt_cwd())\n\n def _run_read(path: str) -> str:\n return run_read(path, cwd=_wt_cwd())\n\n def _run_write(path: str, content: str) -> str:\n return run_write(path, content, cwd=_wt_cwd())\n\n def _run_list_tasks():\n tasks = list_tasks()\n if not tasks:\n return \"No tasks.\"\n return \"\\n\".join(\n f\" {t.id}: {t.subject} [{t.status}]\"\n + (f\" (wt:{t.worktree})\" if t.worktree else \"\")\n for t in tasks)\n\n def _run_claim_task(task_id: str):\n result = claim_task(task_id, owner=name)\n if \"Claimed\" in result:\n task = load_task(task_id)\n wt_ctx[\"path\"] = (str(WORKTREES_DIR / task.worktree)\n if task.worktree else None)\n return result\n\n def _run_complete_task(task_id: str):\n result = complete_task(task_id)\n wt_ctx[\"path\"] = None\n return result\n\n messages = [{\"role\": \"user\", \"content\": prompt}]\n sub_tools = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"send_message\",\n \"description\": \"Send message to another agent.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"submit_plan\",\n \"description\": \"Submit a plan for Lead approval.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"plan\": {\"type\": \"string\"}},\n \"required\": [\"plan\"]}},\n {\"name\": \"list_tasks\",\n \"description\": \"List all tasks.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n {\"name\": \"claim_task\",\n \"description\": \"Claim a pending task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\",\n \"description\": \"Mark an in-progress task as completed.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n ]\n\n sub_handlers = {\n \"bash\": _run_bash, \"read_file\": _run_read,\n \"write_file\": _run_write,\n \"send_message\": lambda to, content: (BUS.send(name, to, content),\n \"Sent\")[1],\n \"submit_plan\": lambda plan: _teammate_submit_plan(name, plan),\n \"list_tasks\": _run_list_tasks,\n \"claim_task\": _run_claim_task,\n \"complete_task\": _run_complete_task,\n }\n\n while True:\n if len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"You are '{name}', role: {role}. \"\n f\"Continue your work.\"})\n should_shutdown = False\n for _ in range(10):\n inbox = BUS.read_inbox(name)\n for msg in inbox:\n stopped = handle_inbox_message(name, msg, messages)\n if stopped:\n should_shutdown = True\n break\n if should_shutdown:\n break\n if inbox and not should_shutdown:\n non_protocol = [m for m in inbox\n if m.get(\"type\") == \"message\"]\n if non_protocol:\n messages.append({\"role\": \"user\",\n \"content\": \"\" + json.dumps(non_protocol) + \"\"})\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages[-20:],\n tools=sub_tools, max_tokens=8000)\n except Exception:\n break\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n handler = sub_handlers.get(block.name)\n output = handler(**block.input) if handler else \"Unknown\"\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)})\n messages.append({\"role\": \"user\", \"content\": results})\n if should_shutdown:\n break\n idle_result = idle_poll(name, messages, name, role)\n if idle_result in (\"shutdown\", \"timeout\"):\n break\n\n summary = \"Done.\"\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\" and isinstance(msg[\"content\"], list):\n for b in msg[\"content\"]:\n if getattr(b, \"type\", None) == \"text\":\n summary = b.text\n break\n else:\n continue\n break\n BUS.send(name, \"lead\", summary, \"result\")\n active_teammates.pop(name, None)\n\n active_teammates[name] = True\n threading.Thread(target=run, daemon=True).start()\n return f\"Teammate '{name}' spawned as {role}\"\n\n\ndef _teammate_submit_plan(from_name: str, plan: str) -> str:\n req_id = new_request_id()\n pending_requests[req_id] = ProtocolState(\n request_id=req_id, type=\"plan_approval\",\n sender=from_name, target=\"lead\",\n status=\"pending\", payload=plan)\n BUS.send(from_name, \"lead\", plan,\n \"plan_approval_request\",\n {\"request_id\": req_id})\n return f\"Plan submitted ({req_id})\"\n\n\n# ── Lead Protocol Tools ──\n\ndef run_request_shutdown(teammate: str) -> str:\n req_id = new_request_id()\n pending_requests[req_id] = ProtocolState(\n request_id=req_id, type=\"shutdown\",\n sender=\"lead\", target=teammate,\n status=\"pending\", payload=\"\")\n BUS.send(\"lead\", teammate, \"Shut down.\", \"shutdown_request\",\n {\"request_id\": req_id})\n return f\"Shutdown request sent to {teammate}\"\n\n\ndef run_request_plan(teammate: str, task: str) -> str:\n BUS.send(\"lead\", teammate, f\"Submit plan for: {task}\", \"message\")\n return f\"Asked {teammate} to submit a plan\"\n\n\ndef run_review_plan(request_id: str, approve: bool,\n feedback: str = \"\") -> str:\n state = pending_requests.get(request_id)\n if not state:\n return f\"Request {request_id} not found\"\n state.status = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", state.sender,\n feedback or (\"Approved\" if approve else \"Rejected\"),\n \"plan_approval_response\",\n {\"request_id\": request_id, \"approve\": approve})\n return f\"Plan {'approved' if approve else 'rejected'}\"\n\n\n# ── MCP System (s19 new) ──\n\nclass MCPClient:\n \"\"\"Discovers and calls tools on an MCP server (mock for teaching).\"\"\"\n\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs: list[dict],\n handlers: dict[str, callable]):\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n try:\n return handler(**args)\n except Exception as e:\n return f\"MCP error: {e}\"\n\n\nmcp_clients: dict[str, MCPClient] = {}\n\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\n\ndef normalize_mcp_name(name: str) -> str:\n \"\"\"Replace non [a-zA-Z0-9_-] with underscore.\"\"\"\n return _DISALLOWED_CHARS.sub('_', name)\n\n\ndef _mock_server_docs():\n client = MCPClient(\"docs\")\n client.register(\n tool_defs=[\n {\"name\": \"search\", \"description\": \"Search documentation. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"query\": {\"type\": \"string\"}},\n \"required\": [\"query\"]}},\n {\"name\": \"get_version\", \"description\": \"Get API version. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n ],\n handlers={\n \"search\": lambda query: f\"[docs] Found 3 results for '{query}'\",\n \"get_version\": lambda: \"[docs] API v2.1.0\",\n })\n return client\n\n\ndef _mock_server_deploy():\n client = MCPClient(\"deploy\")\n client.register(\n tool_defs=[\n {\"name\": \"trigger\",\n \"description\": \"Trigger a deployment. (destructive — requires approval in real CC)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"service\": {\"type\": \"string\"}},\n \"required\": [\"service\"]}},\n {\"name\": \"status\", \"description\": \"Check deployment status. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"service\": {\"type\": \"string\"}},\n \"required\": [\"service\"]}},\n ],\n handlers={\n \"trigger\": lambda service: f\"[deploy] Triggered: {service}\",\n \"status\": lambda service: f\"[deploy] {service}: running (v1.4.2)\",\n })\n return client\n\n\nMOCK_SERVERS = {\n \"docs\": _mock_server_docs,\n \"deploy\": _mock_server_deploy,\n}\n\n\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n available = \", \".join(MOCK_SERVERS.keys())\n return f\"Unknown server '{name}'. Available: {available}\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n tool_names = [t[\"name\"] for t in mcp_client.tools]\n print(f\" \\033[31m[mcp] connected: {name} → {tool_names}\\033[0m\")\n return (f\"Connected to MCP server '{name}'. \"\n f\"Discovered {len(mcp_client.tools)} tools: {', '.join(tool_names)}\")\n\n\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n \"\"\"Assemble builtin tools + all MCP tools into one pool.\"\"\"\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append({\n \"name\": prefixed,\n \"description\": tool_def.get(\"description\", \"\"),\n \"input_schema\": tool_def.get(\"inputSchema\", {}),\n })\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw: c.call_tool(t, kw))\n return tools, handlers\n\n\n# ── Lead Worktree Tools ──\n\ndef run_create_worktree(name: str, task_id: str = \"\") -> str:\n return create_worktree(name, task_id)\n\ndef run_remove_worktree(name: str, discard_changes: bool = False) -> str:\n return remove_worktree(name, discard_changes)\n\ndef run_keep_worktree(name: str) -> str:\n return keep_worktree(name)\n\n\n# ── Basic tool handlers ──\n\ndef run_create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> str:\n task = create_task(subject, description, blockedBy)\n deps = f\" (blockedBy: {', '.join(blockedBy)})\" if blockedBy else \"\"\n print(f\" \\033[34m[create] {task.subject}{deps}\\033[0m\")\n return f\"Created {task.id}: {task.subject}{deps}\"\n\n\ndef run_list_tasks() -> str:\n tasks = list_tasks()\n if not tasks:\n return \"No tasks.\"\n return \"\\n\".join(\n f\" {t.id}: {t.subject} [{t.status}]\"\n + (f\" (wt:{t.worktree})\" if t.worktree else \"\")\n for t in tasks)\n\n\ndef run_get_task(task_id: str) -> str:\n return get_task_json(task_id)\n\ndef run_claim_task(task_id: str) -> str:\n return claim_task(task_id, owner=\"agent\")\n\ndef run_complete_task(task_id: str) -> str:\n return complete_task(task_id)\n\ndef run_spawn_teammate(name: str, role: str, prompt: str) -> str:\n return spawn_teammate_thread(name, role, prompt)\n\ndef run_send_message(to: str, content: str) -> str:\n BUS.send(\"lead\", to, content)\n return f\"Sent to {to}\"\n\ndef run_check_inbox() -> str:\n msgs = consume_lead_inbox(route_protocol=True)\n if not msgs:\n return \"(inbox empty)\"\n lines = []\n for m in msgs:\n meta = m.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n tag = f\" [{m['type']} req:{req_id}]\" if req_id else f\" [{m['type']}]\"\n lines.append(f\" [{m['from']}]{tag} {m['content'][:200]}\")\n return \"\\n\".join(lines)\n\ndef run_connect_mcp(name: str) -> str:\n return connect_mcp(name)\n\n\n# ── Tool Definitions ──\n\nBUILTIN_TOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"create_task\", \"description\": \"Create a task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"subject\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n \"blockedBy\": {\"type\": \"array\",\n \"items\": {\"type\": \"string\"}}},\n \"required\": [\"subject\"]}},\n {\"name\": \"list_tasks\", \"description\": \"List all tasks.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {}, \"required\": []}},\n {\"name\": \"get_task\", \"description\": \"Get full task details.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"claim_task\", \"description\": \"Claim a pending task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\", \"description\": \"Complete an in-progress task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"spawn_teammate\", \"description\": \"Spawn an autonomous teammate.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"},\n \"role\": {\"type\": \"string\"},\n \"prompt\": {\"type\": \"string\"}},\n \"required\": [\"name\", \"role\", \"prompt\"]}},\n {\"name\": \"send_message\", \"description\": \"Send message to a teammate.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"check_inbox\",\n \"description\": \"Check inbox for messages and protocol responses.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {}, \"required\": []}},\n {\"name\": \"request_shutdown\",\n \"description\": \"Request a teammate to shut down.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"teammate\": {\"type\": \"string\"}},\n \"required\": [\"teammate\"]}},\n {\"name\": \"request_plan\",\n \"description\": \"Ask a teammate to submit a plan.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"teammate\": {\"type\": \"string\"},\n \"task\": {\"type\": \"string\"}},\n \"required\": [\"teammate\", \"task\"]}},\n {\"name\": \"review_plan\",\n \"description\": \"Approve or reject a submitted plan.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"request_id\": {\"type\": \"string\"},\n \"approve\": {\"type\": \"boolean\"},\n \"feedback\": {\"type\": \"string\"}},\n \"required\": [\"request_id\", \"approve\"]}},\n {\"name\": \"create_worktree\",\n \"description\": \"Create an isolated git worktree.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"},\n \"task_id\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"remove_worktree\",\n \"description\": \"Remove a worktree. Refuses if changes exist.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"},\n \"discard_changes\": {\"type\": \"boolean\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"keep_worktree\",\n \"description\": \"Keep a worktree for manual review.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"connect_mcp\",\n \"description\": \"Connect to an MCP server (docs, deploy) and discover tools.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n]\n\nBUILTIN_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"create_task\": run_create_task, \"list_tasks\": run_list_tasks,\n \"get_task\": run_get_task,\n \"claim_task\": run_claim_task, \"complete_task\": run_complete_task,\n \"spawn_teammate\": run_spawn_teammate,\n \"send_message\": run_send_message, \"check_inbox\": run_check_inbox,\n \"request_shutdown\": run_request_shutdown,\n \"request_plan\": run_request_plan, \"review_plan\": run_review_plan,\n \"create_worktree\": run_create_worktree,\n \"remove_worktree\": run_remove_worktree,\n \"keep_worktree\": run_keep_worktree,\n \"connect_mcp\": run_connect_mcp,\n}\n\n\n# ── Context ──\n\nMEMORY_DIR = WORKDIR / \".memory\"\nMEMORY_INDEX = MEMORY_DIR / \"MEMORY.md\"\n\n\ndef update_context(context: dict, messages: list) -> dict:\n memories = \"\"\n if MEMORY_INDEX.exists():\n memories = MEMORY_INDEX.read_text()[:2000]\n return {\"memories\": memories}\n\n\n# ── Agent Loop (s19: dynamic tool pool, no prompt cache) ──\n\ndef agent_loop(messages: list, context: dict):\n tools, handlers = assemble_tool_pool()\n system = assemble_system_prompt(context)\n while True:\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages,\n tools=tools, max_tokens=8000)\n except Exception as e:\n messages.append({\"role\": \"assistant\", \"content\": [\n {\"type\": \"text\", \"text\": f\"[Error] {type(e).__name__}: {e}\"}]})\n return\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n handler = handlers.get(block.name)\n output = handler(**block.input) if handler else \"Unknown\"\n print(str(output)[:300])\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n if any(b.name == \"connect_mcp\" for b in response.content\n if b.type == \"tool_use\"):\n tools, handlers = assemble_tool_pool()\n context = update_context(context, messages)\n system = assemble_system_prompt(context)\n\n\nif __name__ == \"__main__\":\n print(\"s19: mcp tools\")\n print(\"Enter a question, press Enter to send. Type q to quit.\\n\")\n history = []\n context = {\"memories\": \"\"}\n while True:\n try:\n query = input(\"\\033[36ms19 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt):\n break\n if query.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history, context)\n context = update_context(context, history)\n for block in history[-1][\"content\"]:\n if getattr(block, \"type\", None) == \"text\":\n print(block.text)\n\n inbox = consume_lead_inbox(route_protocol=True)\n if inbox:\n inbox_text = \"\\n\".join(\n f\"From {m['from']} [{m.get('type', 'message')}]: \"\n f\"{m['content'][:200]}\" for m in inbox)\n history.append({\"role\": \"user\",\n \"content\": f\"[Inbox]\\n{inbox_text}\"})\n print()\n", + "source": "#!/usr/bin/env python3\n\"\"\"\ns19: MCP Tools — MCPClient + tool discovery + assemble_tool_pool.\n\nRun: python s19_mcp_plugin/code.py\nNeed: pip install anthropic python-dotenv + .env with ANTHROPIC_API_KEY\n\nChanges from s18:\n - MCPClient class: discovers tools, calls tools via mock handler\n - normalize_mcp_name: normalize tool/server names\n - assemble_tool_pool: assembles builtin + MCP tools into one pool\n - connect_mcp: connect to an MCP server, discover tools\n - Tool naming: mcp__{server}__{tool} with normalization\n - MCP tools have readOnly/destructive annotations\n - agent_loop uses dynamic tool pool (builtin + MCP), no prompt cache\n - Teammate tools: complete_task, worktree cwd (from s17/s18 fixes)\n\nASCII flow:\n connect_mcp(\"docs\") → MCPClient discovers tools →\n assemble_tool_pool → [builtin... , mcp__docs__search, mcp__docs__get_version]\n agent_loop uses assembled pool\n\"\"\"\n\nimport os, subprocess, json, time, random, threading, re\nfrom pathlib import Path\nfrom datetime import datetime\nfrom dataclasses import dataclass, asdict, field\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\n\n# ── Task System ──\n\nTASKS_DIR = WORKDIR / \".tasks\"\nTASKS_DIR.mkdir(exist_ok=True)\n\n\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str\n owner: str | None\n blockedBy: list[str]\n worktree: str | None = None\n\n\ndef _task_path(task_id: str) -> Path:\n return TASKS_DIR / f\"{task_id}.json\"\n\n\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random.randint(0, 9999):04d}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n\n\ndef save_task(task: Task):\n _task_path(task.id).write_text(json.dumps(asdict(task), indent=2))\n\n\ndef load_task(task_id: str) -> Task:\n return Task(**json.loads(_task_path(task_id).read_text()))\n\n\ndef list_tasks() -> list[Task]:\n return [Task(**json.loads(p.read_text()))\n for p in sorted(TASKS_DIR.glob(\"task_*.json\"))]\n\n\ndef get_task_json(task_id: str) -> str:\n return json.dumps(asdict(load_task(task_id)), indent=2)\n\n\ndef can_start(task_id: str) -> bool:\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False\n if load_task(dep_id).status != \"completed\":\n return False\n return True\n\n\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if task.owner:\n return f\"Task {task_id} already owned by {task.owner}\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if _task_path(d).exists() and load_task(d).status != \"completed\"]\n missing = [d for d in task.blockedBy if not _task_path(d).exists()]\n parts = []\n if deps: parts.append(f\"blocked by: {deps}\")\n if missing: parts.append(f\"missing deps: {missing}\")\n return \"Cannot start — \" + \", \".join(parts)\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n print(f\" \\033[36m[claim] {task.subject} → in_progress\\033[0m\")\n return f\"Claimed {task.id} ({task.subject})\"\n\n\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n if task.status != \"in_progress\":\n return f\"Task {task_id} is {task.status}, cannot complete\"\n task.status = \"completed\"\n save_task(task)\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy and can_start(t.id)]\n print(f\" \\033[32m[complete] {task.subject} ✓\\033[0m\")\n msg = f\"Completed {task.id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n return msg\n\n\n# ── Worktree System ──\n\nWORKTREES_DIR = WORKDIR / \".worktrees\"\nWORKTREES_DIR.mkdir(exist_ok=True)\n\nVALID_WT_NAME = re.compile(r'^[A-Za-z0-9._-]{1,64}$')\n\n\ndef validate_worktree_name(name: str) -> str | None:\n if not name:\n return \"Worktree name cannot be empty\"\n if name in (\".\", \"..\"):\n return f\"'{name}' is not a valid worktree name\"\n if not VALID_WT_NAME.match(name):\n return (f\"Invalid worktree name '{name}': \"\n \"only letters, digits, dots, underscores, dashes (1-64 chars)\")\n return None\n\n\ndef run_git(args: list[str]) -> tuple[bool, str]:\n try:\n r = subprocess.run([\"git\"] + args, cwd=WORKDIR,\n capture_output=True, text=True, timeout=30)\n out = (r.stdout + r.stderr).strip()\n return r.returncode == 0, out[:5000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return False, \"Error: git timeout\"\n\n\ndef log_event(event_type: str, worktree_name: str, task_id: str = \"\"):\n event = {\"type\": event_type, \"worktree\": worktree_name,\n \"task_id\": task_id, \"ts\": time.time()}\n events_file = WORKTREES_DIR / \"events.jsonl\"\n with open(events_file, \"a\") as f:\n f.write(json.dumps(event) + \"\\n\")\n\n\ndef create_worktree(name: str, task_id: str = \"\") -> str:\n err = validate_worktree_name(name)\n if err:\n return f\"Error: {err}\"\n path = WORKTREES_DIR / name\n if path.exists():\n return f\"Worktree '{name}' already exists at {path}\"\n ok, result = run_git([\"worktree\", \"add\", str(path), \"-b\", f\"wt/{name}\", \"HEAD\"])\n if not ok:\n return f\"Git error: {result}\"\n if task_id:\n bind_task_to_worktree(task_id, name)\n log_event(\"create\", name, task_id)\n print(f\" \\033[33m[worktree] created: {name} at {path}\\033[0m\")\n return f\"Worktree '{name}' created at {path}\"\n\n\ndef bind_task_to_worktree(task_id: str, worktree_name: str):\n task = load_task(task_id)\n task.worktree = worktree_name\n save_task(task)\n\n\ndef _count_worktree_changes(path: Path) -> tuple[int, int]:\n try:\n r1 = subprocess.run([\"git\", \"status\", \"--porcelain\"],\n cwd=path, capture_output=True, text=True, timeout=10)\n files = len([l for l in r1.stdout.strip().splitlines() if l.strip()])\n r2 = subprocess.run([\"git\", \"log\", \"@{push}..HEAD\", \"--oneline\"],\n cwd=path, capture_output=True, text=True, timeout=10)\n commits = len([l for l in r2.stdout.strip().splitlines() if l.strip()])\n return files, commits\n except Exception:\n return -1, -1\n\n\ndef remove_worktree(name: str, discard_changes: bool = False) -> str:\n err = validate_worktree_name(name)\n if err:\n return err\n path = WORKTREES_DIR / name\n if not path.exists():\n return f\"Worktree '{name}' not found\"\n if not discard_changes:\n files, commits = _count_worktree_changes(path)\n if files < 0:\n return \"Cannot verify status. Use discard_changes=true to force.\"\n if files > 0 or commits > 0:\n return (f\"Worktree '{name}' has {files} file(s), {commits} commit(s). \"\n \"Use discard_changes=true or keep_worktree.\")\n ok1, _ = run_git([\"worktree\", \"remove\", str(path), \"--force\"])\n if not ok1:\n return f\"Failed to remove worktree '{name}'\"\n run_git([\"branch\", \"-D\", f\"wt/{name}\"])\n log_event(\"remove\", name)\n print(f\" \\033[33m[worktree] removed: {name}\\033[0m\")\n return f\"Worktree '{name}' removed\"\n\n\ndef keep_worktree(name: str) -> str:\n err = validate_worktree_name(name)\n if err:\n return err\n log_event(\"keep\", name)\n return f\"Worktree '{name}' kept for review (branch: wt/{name})\"\n\n\n# ── Prompt Assembly ──\n\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file, \"\n \"create_task, list_tasks, get_task, claim_task, complete_task, \"\n \"spawn_teammate, send_message, check_inbox, \"\n \"request_shutdown, request_plan, review_plan, \"\n \"create_worktree, remove_worktree, keep_worktree, \"\n \"connect_mcp. MCP tools are prefixed mcp__{server}__{tool}.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n\n\ndef assemble_system_prompt(context: dict) -> str:\n sections = [PROMPT_SECTIONS[\"identity\"],\n PROMPT_SECTIONS[\"tools\"],\n PROMPT_SECTIONS[\"workspace\"]]\n if context.get(\"memories\"):\n sections.append(f\"Relevant memories:\\n{context['memories']}\")\n mcp_names = list(mcp_clients.keys())\n if mcp_names:\n sections.append(f\"Connected MCP servers: {', '.join(mcp_names)}\")\n return \"\\n\\n\".join(sections)\n\n\n# ── Basic Tools ──\n\ndef safe_path(p: str, cwd: Path = None) -> Path:\n base = cwd or WORKDIR\n path = (base / p).resolve()\n if not path.is_relative_to(base):\n raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\n\ndef run_bash(command: str, cwd: Path = None) -> str:\n try:\n r = subprocess.run(command, shell=True, cwd=cwd or WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\n\ndef run_read(path: str, limit: int | None = None, cwd: Path = None) -> str:\n try:\n lines = safe_path(path, cwd).read_text().splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_write(path: str, content: str, cwd: Path = None) -> str:\n try:\n fp = safe_path(path, cwd)\n fp.parent.mkdir(parents=True, exist_ok=True)\n fp.write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\n# ── MessageBus ──\n\nMAILBOX_DIR = WORKDIR / \".mailboxes\"\nMAILBOX_DIR.mkdir(exist_ok=True)\n\n\nclass MessageBus:\n def send(self, from_agent: str, to_agent: str, content: str,\n msg_type: str = \"message\", metadata: dict = None):\n msg = {\"from\": from_agent, \"to\": to_agent,\n \"content\": content, \"type\": msg_type,\n \"ts\": time.time(), \"metadata\": metadata or {}}\n inbox = MAILBOX_DIR / f\"{to_agent}.jsonl\"\n with open(inbox, \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n print(f\" \\033[33m[bus] {from_agent} → {to_agent}: \"\n f\"({msg_type}) {content[:50]}\\033[0m\")\n\n def read_inbox(self, agent: str) -> list[dict]:\n inbox = MAILBOX_DIR / f\"{agent}.jsonl\"\n if not inbox.exists():\n return []\n msgs = [json.loads(line) for line in inbox.read_text().splitlines()\n if line.strip()]\n inbox.unlink()\n return msgs\n\n\nBUS = MessageBus()\nactive_teammates: dict[str, bool] = {}\n\n# ── Protocol State ──\n\n@dataclass\nclass ProtocolState:\n request_id: str\n type: str\n sender: str\n target: str\n status: str\n payload: str\n created_at: float = field(default_factory=time.time)\n\n\npending_requests: dict[str, ProtocolState] = {}\n\n\ndef new_request_id() -> str:\n return f\"req_{random.randint(0, 999999):06d}\"\n\n\ndef match_response(response_type: str, request_id: str, approve: bool):\n state = pending_requests.get(request_id)\n if not state:\n return\n if state.type == \"shutdown\" and response_type != \"shutdown_response\":\n return\n if state.type == \"plan_approval\" and response_type != \"plan_approval_response\":\n return\n state.status = \"approved\" if approve else \"rejected\"\n\n\ndef consume_lead_inbox(route_protocol=True) -> list[dict]:\n msgs = BUS.read_inbox(\"lead\")\n if route_protocol:\n for msg in msgs:\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n msg_type = msg.get(\"type\", \"\")\n if req_id and msg_type.endswith(\"_response\"):\n match_response(msg_type, req_id, meta.get(\"approve\", False))\n return msgs\n\n\n# ── Autonomous Agent ──\n\nIDLE_POLL_INTERVAL = 5\nIDLE_TIMEOUT = 60\n\n\ndef scan_unclaimed_tasks() -> list[dict]:\n unclaimed = []\n for f in sorted(TASKS_DIR.glob(\"task_*.json\")):\n task = json.loads(f.read_text())\n if (task.get(\"status\") == \"pending\"\n and not task.get(\"owner\")\n and can_start(task[\"id\"])):\n unclaimed.append(task)\n return unclaimed\n\n\ndef idle_poll(agent_name: str, messages: list,\n name: str, role: str) -> str:\n for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):\n time.sleep(IDLE_POLL_INTERVAL)\n inbox = BUS.read_inbox(agent_name)\n if inbox:\n for msg in inbox:\n if msg.get(\"type\") == \"shutdown_request\":\n req_id = msg.get(\"metadata\", {}).get(\"request_id\", \"\")\n BUS.send(name, \"lead\", \"Shutting down.\",\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n return \"shutdown\"\n messages.append({\"role\": \"user\",\n \"content\": \"\" + json.dumps(inbox) + \"\"})\n return \"work\"\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n task_data = unclaimed[0]\n result = claim_task(task_data[\"id\"], agent_name)\n if \"Claimed\" in result:\n wt_info = \"\"\n if task_data.get(\"worktree\"):\n wt_info = f\"\\nWork directory: {WORKTREES_DIR / task_data['worktree']}\"\n messages.append({\"role\": \"user\",\n \"content\": f\"Task {task_data['id']}: \"\n f\"{task_data['subject']}{wt_info}\"})\n return \"work\"\n return \"timeout\"\n\n\n# ── Teammate Thread ──\n\ndef spawn_teammate_thread(name: str, role: str, prompt: str) -> str:\n if name in active_teammates:\n return f\"Teammate '{name}' already exists\"\n\n system = (f\"You are '{name}', a {role}. \"\n f\"Use tools to complete tasks. \"\n f\"If a task has a worktree, work in that directory.\")\n\n def handle_inbox_message(name: str, msg: dict, messages: list):\n msg_type = msg.get(\"type\", \"message\")\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n if msg_type == \"shutdown_request\":\n BUS.send(name, \"lead\", \"Shutting down.\",\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n return True\n if msg_type == \"plan_approval_response\":\n approve = meta.get(\"approve\", False)\n messages.append({\"role\": \"user\",\n \"content\": \"[Plan approved]\" if approve\n else f\"[Plan rejected] {msg['content']}\"})\n return False\n\n def run():\n wt_ctx = {\"path\": None}\n\n def _wt_cwd():\n p = wt_ctx[\"path\"]\n return Path(p) if p else None\n\n def _run_bash(command: str) -> str:\n return run_bash(command, cwd=_wt_cwd())\n\n def _run_read(path: str) -> str:\n return run_read(path, cwd=_wt_cwd())\n\n def _run_write(path: str, content: str) -> str:\n return run_write(path, content, cwd=_wt_cwd())\n\n def _run_list_tasks():\n tasks = list_tasks()\n if not tasks:\n return \"No tasks.\"\n return \"\\n\".join(\n f\" {t.id}: {t.subject} [{t.status}]\"\n + (f\" (wt:{t.worktree})\" if t.worktree else \"\")\n for t in tasks)\n\n def _run_claim_task(task_id: str):\n result = claim_task(task_id, owner=name)\n if \"Claimed\" in result:\n task = load_task(task_id)\n wt_ctx[\"path\"] = (str(WORKTREES_DIR / task.worktree)\n if task.worktree else None)\n return result\n\n def _run_complete_task(task_id: str):\n result = complete_task(task_id)\n wt_ctx[\"path\"] = None\n return result\n\n messages = [{\"role\": \"user\", \"content\": prompt}]\n sub_tools = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"send_message\",\n \"description\": \"Send message to another agent.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"submit_plan\",\n \"description\": \"Submit a plan for Lead approval.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"plan\": {\"type\": \"string\"}},\n \"required\": [\"plan\"]}},\n {\"name\": \"list_tasks\",\n \"description\": \"List all tasks.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n {\"name\": \"claim_task\",\n \"description\": \"Claim a pending task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\",\n \"description\": \"Mark an in-progress task as completed.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n ]\n\n sub_handlers = {\n \"bash\": _run_bash, \"read_file\": _run_read,\n \"write_file\": _run_write,\n \"send_message\": lambda to, content: (BUS.send(name, to, content),\n \"Sent\")[1],\n \"submit_plan\": lambda plan: _teammate_submit_plan(name, plan),\n \"list_tasks\": _run_list_tasks,\n \"claim_task\": _run_claim_task,\n \"complete_task\": _run_complete_task,\n }\n\n while True:\n if len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"You are '{name}', role: {role}. \"\n f\"Continue your work.\"})\n should_shutdown = False\n for _ in range(10):\n inbox = BUS.read_inbox(name)\n for msg in inbox:\n stopped = handle_inbox_message(name, msg, messages)\n if stopped:\n should_shutdown = True\n break\n if should_shutdown:\n break\n if inbox and not should_shutdown:\n non_protocol = [m for m in inbox\n if m.get(\"type\") == \"message\"]\n if non_protocol:\n messages.append({\"role\": \"user\",\n \"content\": \"\" + json.dumps(non_protocol) + \"\"})\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages[-20:],\n tools=sub_tools, max_tokens=8000)\n except Exception:\n break\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n handler = sub_handlers.get(block.name)\n output = handler(**block.input) if handler else \"Unknown\"\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)})\n messages.append({\"role\": \"user\", \"content\": results})\n if should_shutdown:\n break\n idle_result = idle_poll(name, messages, name, role)\n if idle_result in (\"shutdown\", \"timeout\"):\n break\n\n summary = \"Done.\"\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\" and isinstance(msg[\"content\"], list):\n for b in msg[\"content\"]:\n if getattr(b, \"type\", None) == \"text\":\n summary = b.text\n break\n else:\n continue\n break\n BUS.send(name, \"lead\", summary, \"result\")\n active_teammates.pop(name, None)\n\n active_teammates[name] = True\n threading.Thread(target=run, daemon=True).start()\n return f\"Teammate '{name}' spawned as {role}\"\n\n\ndef _teammate_submit_plan(from_name: str, plan: str) -> str:\n req_id = new_request_id()\n pending_requests[req_id] = ProtocolState(\n request_id=req_id, type=\"plan_approval\",\n sender=from_name, target=\"lead\",\n status=\"pending\", payload=plan)\n BUS.send(from_name, \"lead\", plan,\n \"plan_approval_request\",\n {\"request_id\": req_id})\n return f\"Plan submitted ({req_id})\"\n\n\n# ── Lead Protocol Tools ──\n\ndef run_request_shutdown(teammate: str) -> str:\n req_id = new_request_id()\n pending_requests[req_id] = ProtocolState(\n request_id=req_id, type=\"shutdown\",\n sender=\"lead\", target=teammate,\n status=\"pending\", payload=\"\")\n BUS.send(\"lead\", teammate, \"Shut down.\", \"shutdown_request\",\n {\"request_id\": req_id})\n return f\"Shutdown request sent to {teammate}\"\n\n\ndef run_request_plan(teammate: str, task: str) -> str:\n BUS.send(\"lead\", teammate, f\"Submit plan for: {task}\", \"message\")\n return f\"Asked {teammate} to submit a plan\"\n\n\ndef run_review_plan(request_id: str, approve: bool,\n feedback: str = \"\") -> str:\n state = pending_requests.get(request_id)\n if not state:\n return f\"Request {request_id} not found\"\n state.status = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", state.sender,\n feedback or (\"Approved\" if approve else \"Rejected\"),\n \"plan_approval_response\",\n {\"request_id\": request_id, \"approve\": approve})\n return f\"Plan {'approved' if approve else 'rejected'}\"\n\n\n# ── MCP System (s19 new) ──\n\nclass MCPClient:\n \"\"\"Discovers and calls tools on an MCP server (mock for teaching).\"\"\"\n\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs: list[dict],\n handlers: dict[str, callable]):\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n try:\n return handler(**args)\n except Exception as e:\n return f\"MCP error: {e}\"\n\n\nmcp_clients: dict[str, MCPClient] = {}\n\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\n\ndef normalize_mcp_name(name: str) -> str:\n \"\"\"Replace non [a-zA-Z0-9_-] with underscore.\"\"\"\n return _DISALLOWED_CHARS.sub('_', name)\n\n\ndef _mock_server_docs():\n client = MCPClient(\"docs\")\n client.register(\n tool_defs=[\n {\"name\": \"search\", \"description\": \"Search documentation. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"query\": {\"type\": \"string\"}},\n \"required\": [\"query\"]}},\n {\"name\": \"get_version\", \"description\": \"Get API version. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n ],\n handlers={\n \"search\": lambda query: f\"[docs] Found 3 results for '{query}'\",\n \"get_version\": lambda: \"[docs] API v2.1.0\",\n })\n return client\n\n\ndef _mock_server_deploy():\n client = MCPClient(\"deploy\")\n client.register(\n tool_defs=[\n {\"name\": \"trigger\",\n \"description\": \"Trigger a deployment. (destructive — requires approval in real Claude Code)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"service\": {\"type\": \"string\"}},\n \"required\": [\"service\"]}},\n {\"name\": \"status\", \"description\": \"Check deployment status. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"service\": {\"type\": \"string\"}},\n \"required\": [\"service\"]}},\n ],\n handlers={\n \"trigger\": lambda service: f\"[deploy] Triggered: {service}\",\n \"status\": lambda service: f\"[deploy] {service}: running (v1.4.2)\",\n })\n return client\n\n\nMOCK_SERVERS = {\n \"docs\": _mock_server_docs,\n \"deploy\": _mock_server_deploy,\n}\n\n\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n available = \", \".join(MOCK_SERVERS.keys())\n return f\"Unknown server '{name}'. Available: {available}\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n tool_names = [t[\"name\"] for t in mcp_client.tools]\n print(f\" \\033[31m[mcp] connected: {name} → {tool_names}\\033[0m\")\n return (f\"Connected to MCP server '{name}'. \"\n f\"Discovered {len(mcp_client.tools)} tools: {', '.join(tool_names)}\")\n\n\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n \"\"\"Assemble builtin tools + all MCP tools into one pool.\"\"\"\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append({\n \"name\": prefixed,\n \"description\": tool_def.get(\"description\", \"\"),\n \"input_schema\": tool_def.get(\"inputSchema\", {}),\n })\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw: c.call_tool(t, kw))\n return tools, handlers\n\n\n# ── Lead Worktree Tools ──\n\ndef run_create_worktree(name: str, task_id: str = \"\") -> str:\n return create_worktree(name, task_id)\n\ndef run_remove_worktree(name: str, discard_changes: bool = False) -> str:\n return remove_worktree(name, discard_changes)\n\ndef run_keep_worktree(name: str) -> str:\n return keep_worktree(name)\n\n\n# ── Basic tool handlers ──\n\ndef run_create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> str:\n task = create_task(subject, description, blockedBy)\n deps = f\" (blockedBy: {', '.join(blockedBy)})\" if blockedBy else \"\"\n print(f\" \\033[34m[create] {task.subject}{deps}\\033[0m\")\n return f\"Created {task.id}: {task.subject}{deps}\"\n\n\ndef run_list_tasks() -> str:\n tasks = list_tasks()\n if not tasks:\n return \"No tasks.\"\n return \"\\n\".join(\n f\" {t.id}: {t.subject} [{t.status}]\"\n + (f\" (wt:{t.worktree})\" if t.worktree else \"\")\n for t in tasks)\n\n\ndef run_get_task(task_id: str) -> str:\n return get_task_json(task_id)\n\ndef run_claim_task(task_id: str) -> str:\n return claim_task(task_id, owner=\"agent\")\n\ndef run_complete_task(task_id: str) -> str:\n return complete_task(task_id)\n\ndef run_spawn_teammate(name: str, role: str, prompt: str) -> str:\n return spawn_teammate_thread(name, role, prompt)\n\ndef run_send_message(to: str, content: str) -> str:\n BUS.send(\"lead\", to, content)\n return f\"Sent to {to}\"\n\ndef run_check_inbox() -> str:\n msgs = consume_lead_inbox(route_protocol=True)\n if not msgs:\n return \"(inbox empty)\"\n lines = []\n for m in msgs:\n meta = m.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n tag = f\" [{m['type']} req:{req_id}]\" if req_id else f\" [{m['type']}]\"\n lines.append(f\" [{m['from']}]{tag} {m['content'][:200]}\")\n return \"\\n\".join(lines)\n\ndef run_connect_mcp(name: str) -> str:\n return connect_mcp(name)\n\n\n# ── Tool Definitions ──\n\nBUILTIN_TOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"create_task\", \"description\": \"Create a task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"subject\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n \"blockedBy\": {\"type\": \"array\",\n \"items\": {\"type\": \"string\"}}},\n \"required\": [\"subject\"]}},\n {\"name\": \"list_tasks\", \"description\": \"List all tasks.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {}, \"required\": []}},\n {\"name\": \"get_task\", \"description\": \"Get full task details.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"claim_task\", \"description\": \"Claim a pending task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\", \"description\": \"Complete an in-progress task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"spawn_teammate\", \"description\": \"Spawn an autonomous teammate.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"},\n \"role\": {\"type\": \"string\"},\n \"prompt\": {\"type\": \"string\"}},\n \"required\": [\"name\", \"role\", \"prompt\"]}},\n {\"name\": \"send_message\", \"description\": \"Send message to a teammate.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"check_inbox\",\n \"description\": \"Check inbox for messages and protocol responses.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {}, \"required\": []}},\n {\"name\": \"request_shutdown\",\n \"description\": \"Request a teammate to shut down.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"teammate\": {\"type\": \"string\"}},\n \"required\": [\"teammate\"]}},\n {\"name\": \"request_plan\",\n \"description\": \"Ask a teammate to submit a plan.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"teammate\": {\"type\": \"string\"},\n \"task\": {\"type\": \"string\"}},\n \"required\": [\"teammate\", \"task\"]}},\n {\"name\": \"review_plan\",\n \"description\": \"Approve or reject a submitted plan.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"request_id\": {\"type\": \"string\"},\n \"approve\": {\"type\": \"boolean\"},\n \"feedback\": {\"type\": \"string\"}},\n \"required\": [\"request_id\", \"approve\"]}},\n {\"name\": \"create_worktree\",\n \"description\": \"Create an isolated git worktree.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"},\n \"task_id\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"remove_worktree\",\n \"description\": \"Remove a worktree. Refuses if changes exist.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"},\n \"discard_changes\": {\"type\": \"boolean\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"keep_worktree\",\n \"description\": \"Keep a worktree for manual review.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"connect_mcp\",\n \"description\": \"Connect to an MCP server (docs, deploy) and discover tools.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n]\n\nBUILTIN_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"create_task\": run_create_task, \"list_tasks\": run_list_tasks,\n \"get_task\": run_get_task,\n \"claim_task\": run_claim_task, \"complete_task\": run_complete_task,\n \"spawn_teammate\": run_spawn_teammate,\n \"send_message\": run_send_message, \"check_inbox\": run_check_inbox,\n \"request_shutdown\": run_request_shutdown,\n \"request_plan\": run_request_plan, \"review_plan\": run_review_plan,\n \"create_worktree\": run_create_worktree,\n \"remove_worktree\": run_remove_worktree,\n \"keep_worktree\": run_keep_worktree,\n \"connect_mcp\": run_connect_mcp,\n}\n\n\n# ── Context ──\n\nMEMORY_DIR = WORKDIR / \".memory\"\nMEMORY_INDEX = MEMORY_DIR / \"MEMORY.md\"\n\n\ndef update_context(context: dict, messages: list) -> dict:\n memories = \"\"\n if MEMORY_INDEX.exists():\n memories = MEMORY_INDEX.read_text()[:2000]\n return {\"memories\": memories}\n\n\n# ── Agent Loop (s19: dynamic tool pool, no prompt cache) ──\n\ndef agent_loop(messages: list, context: dict):\n tools, handlers = assemble_tool_pool()\n system = assemble_system_prompt(context)\n while True:\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages,\n tools=tools, max_tokens=8000)\n except Exception as e:\n messages.append({\"role\": \"assistant\", \"content\": [\n {\"type\": \"text\", \"text\": f\"[Error] {type(e).__name__}: {e}\"}]})\n return\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n handler = handlers.get(block.name)\n output = handler(**block.input) if handler else \"Unknown\"\n print(str(output)[:300])\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n if any(b.name == \"connect_mcp\" for b in response.content\n if b.type == \"tool_use\"):\n tools, handlers = assemble_tool_pool()\n context = update_context(context, messages)\n system = assemble_system_prompt(context)\n\n\nif __name__ == \"__main__\":\n print(\"s19: mcp tools\")\n print(\"Enter a question, press Enter to send. Type q to quit.\\n\")\n history = []\n context = {\"memories\": \"\"}\n while True:\n try:\n query = input(\"\\033[36ms19 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt):\n break\n if query.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history, context)\n context = update_context(context, history)\n for block in history[-1][\"content\"]:\n if getattr(block, \"type\", None) == \"text\":\n print(block.text)\n\n inbox = consume_lead_inbox(route_protocol=True)\n if inbox:\n inbox_text = \"\\n\".join(\n f\"From {m['from']} [{m.get('type', 'message')}]: \"\n f\"{m['content'][:200]}\" for m in inbox)\n history.append({\"role\": \"user\",\n \"content\": f\"[Inbox]\\n{inbox_text}\"})\n print()\n", "images": [ { "src": "/course-assets/s19_mcp_plugin/mcp-architecture.svg", @@ -3046,7 +3077,7 @@ "filename": "s20_comprehensive/code.py", "title": "Comprehensive Agent", "subtitle": "All Mechanisms, One Loop", - "loc": 1677, + "loc": 1708, "tools": [ "bash", "read_file", @@ -3112,18 +3143,18 @@ }, { "name": "RecoveryState", - "startLine": 1172, - "endLine": 1180 + "startLine": 1208, + "endLine": 1216 }, { "name": "CronJob", - "startLine": 1302, - "endLine": 1309 + "startLine": 1338, + "endLine": 1345 }, { "name": "MCPClient", - "startLine": 1499, - "endLine": 1521 + "startLine": 1535, + "endLine": 1557 } ], "functions": [ @@ -3362,265 +3393,455 @@ "signature": "def estimate_size(messages: list)", "startLine": 1060 }, + { + "name": "block_type", + "signature": "def block_type(block)", + "startLine": 1063 + }, + { + "name": "message_has_tool_use", + "signature": "def message_has_tool_use(message: dict)", + "startLine": 1067 + }, + { + "name": "is_tool_result_message", + "signature": "def is_tool_result_message(message: dict)", + "startLine": 1076 + }, { "name": "collect_tool_results", "signature": "def collect_tool_results(messages: list)", - "startLine": 1064 + "startLine": 1086 }, { "name": "persist_large_output", "signature": "def persist_large_output(tool_use_id: str, output: str)", - "startLine": 1076 + "startLine": 1098 }, { "name": "tool_result_budget", "signature": "def tool_result_budget(messages: list, max_bytes: int = 200_000)", - "startLine": 1087 + "startLine": 1109 }, { "name": "snip_compact", "signature": "def snip_compact(messages: list, max_messages: int = 50)", - "startLine": 1111 + "startLine": 1133 }, { "name": "micro_compact", "signature": "def micro_compact(messages: list)", - "startLine": 1121 + "startLine": 1152 }, { "name": "write_transcript", "signature": "def write_transcript(messages: list)", - "startLine": 1131 + "startLine": 1162 }, { "name": "summarize_history", "signature": "def summarize_history(messages: list)", - "startLine": 1140 + "startLine": 1171 }, { "name": "compact_history", "signature": "def compact_history(messages: list)", - "startLine": 1152 + "startLine": 1183 }, { "name": "reactive_compact", "signature": "def reactive_compact(messages: list)", - "startLine": 1159 + "startLine": 1190 }, { "name": "retry_delay", "signature": "def retry_delay(attempt: int)", - "startLine": 1181 + "startLine": 1217 }, { "name": "with_retry", "signature": "def with_retry(fn, state: RecoveryState)", - "startLine": 1186 + "startLine": 1222 }, { "name": "is_prompt_too_long_error", "signature": "def is_prompt_too_long_error(e: Exception)", - "startLine": 1216 + "startLine": 1252 }, { "name": "is_slow_operation", "signature": "def is_slow_operation(tool_name: str, tool_input: dict)", - "startLine": 1233 + "startLine": 1269 }, { "name": "should_run_background", "signature": "def should_run_background(tool_name: str, tool_input: dict)", - "startLine": 1243 + "startLine": 1279 }, { "name": "start_background_task", "signature": "def start_background_task(block, handlers: dict)", - "startLine": 1249 + "startLine": 1285 }, { "name": "collect_background_results", "signature": "def collect_background_results()", - "startLine": 1274 + "startLine": 1310 }, { "name": "_cron_field_matches", "signature": "def _cron_field_matches(field: str, value: int)", - "startLine": 1316 + "startLine": 1352 }, { "name": "cron_matches", "signature": "def cron_matches(cron_expr: str, dt: datetime)", - "startLine": 1331 + "startLine": 1367 }, { "name": "_validate_cron_field", "signature": "def _validate_cron_field(field: str, lo: int, hi: int)", - "startLine": 1353 + "startLine": 1389 }, { "name": "validate_cron", "signature": "def validate_cron(cron_expr: str)", - "startLine": 1385 + "startLine": 1421 }, { "name": "save_durable_jobs", "signature": "def save_durable_jobs()", - "startLine": 1398 + "startLine": 1434 }, { "name": "load_durable_jobs", "signature": "def load_durable_jobs()", - "startLine": 1403 + "startLine": 1439 }, { "name": "cancel_job", "signature": "def cancel_job(job_id: str)", - "startLine": 1431 + "startLine": 1467 }, { "name": "cron_scheduler_loop", "signature": "def cron_scheduler_loop()", - "startLine": 1441 + "startLine": 1477 }, { "name": "consume_cron_queue", "signature": "def consume_cron_queue()", - "startLine": 1460 + "startLine": 1496 }, { "name": "run_list_crons", "signature": "def run_list_crons()", - "startLine": 1475 + "startLine": 1511 }, { "name": "run_cancel_cron", "signature": "def run_cancel_cron(job_id: str)", - "startLine": 1487 + "startLine": 1523 }, { "name": "normalize_mcp_name", "signature": "def normalize_mcp_name(name: str)", - "startLine": 1527 + "startLine": 1563 }, { "name": "_mock_server_docs", "signature": "def _mock_server_docs()", - "startLine": 1532 + "startLine": 1568 }, { "name": "_mock_server_deploy", "signature": "def _mock_server_deploy()", - "startLine": 1551 + "startLine": 1587 }, { "name": "connect_mcp", "signature": "def connect_mcp(name: str)", - "startLine": 1578 + "startLine": 1614 }, { "name": "assemble_tool_pool", "signature": "def assemble_tool_pool()", - "startLine": 1593 + "startLine": 1629 }, { "name": "run_create_worktree", "signature": "def run_create_worktree(name: str, task_id: str = \"\")", - "startLine": 1614 + "startLine": 1650 }, { "name": "run_remove_worktree", "signature": "def run_remove_worktree(name: str, discard_changes: bool = False)", - "startLine": 1617 + "startLine": 1653 }, { "name": "run_keep_worktree", "signature": "def run_keep_worktree(name: str)", - "startLine": 1620 + "startLine": 1656 }, { "name": "run_list_tasks", "signature": "def run_list_tasks()", - "startLine": 1634 + "startLine": 1670 }, { "name": "run_get_task", "signature": "def run_get_task(task_id: str)", - "startLine": 1644 + "startLine": 1680 }, { "name": "run_claim_task", "signature": "def run_claim_task(task_id: str)", - "startLine": 1650 + "startLine": 1686 }, { "name": "run_complete_task", "signature": "def run_complete_task(task_id: str)", - "startLine": 1656 + "startLine": 1692 }, { "name": "run_spawn_teammate", "signature": "def run_spawn_teammate(name: str, role: str, prompt: str)", - "startLine": 1662 + "startLine": 1698 }, { "name": "run_send_message", "signature": "def run_send_message(to: str, content: str)", - "startLine": 1665 + "startLine": 1701 }, { "name": "run_check_inbox", "signature": "def run_check_inbox()", - "startLine": 1669 + "startLine": 1705 }, { "name": "run_connect_mcp", "signature": "def run_connect_mcp(name: str)", - "startLine": 1681 + "startLine": 1717 }, { "name": "update_context", "signature": "def update_context(context: dict, messages: list)", - "startLine": 1863 + "startLine": 1899 }, { "name": "prepare_context", "signature": "def prepare_context(messages: list)", - "startLine": 1880 + "startLine": 1916 }, { "name": "build_user_content", "signature": "def build_user_content(results: list[dict])", - "startLine": 1890 + "startLine": 1926 }, { "name": "inject_background_notifications", "signature": "def inject_background_notifications(messages: list)", - "startLine": 1899 + "startLine": 1935 }, { "name": "agent_loop", "signature": "def agent_loop(messages: list, context: dict)", - "startLine": 1919 + "startLine": 1955 }, { "name": "print_turn_assistants", "signature": "def print_turn_assistants(messages: list, turn_start: int)", - "startLine": 2025 + "startLine": 2061 }, { "name": "cron_autorun_loop", "signature": "def cron_autorun_loop(history: list, context: dict)", - "startLine": 2034 + "startLine": 2070 } ], "layer": "collaboration", - "source": "#!/usr/bin/env python3\n\"\"\"\ns20: Comprehensive Agent — all teaching components in one loop.\n\nRun: python s20_comprehensive/code.py\nNeed: pip install anthropic python-dotenv pyyaml + .env with ANTHROPIC_API_KEY\n\nThis final chapter intentionally puts the earlier teaching mechanisms back\ntogether: dispatch, permission, hooks, todo, subagent, skills, compaction,\nmemory, prompt assembly, error recovery, task graph, background tasks, cron,\nteams, protocols, autonomous agents, worktrees, and MCP.\n\"\"\"\n\nimport ast, json, os, subprocess, time, random, threading, re\nfrom pathlib import Path\nfrom datetime import datetime\nfrom dataclasses import dataclass, asdict, field\nimport yaml\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\n READLINE_AVAILABLE = True\nexcept ImportError:\n READLINE_AVAILABLE = False\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\nPRIMARY_MODEL = MODEL\nFALLBACK_MODEL = os.getenv(\"FALLBACK_MODEL_ID\")\n\nSKILLS_DIR = WORKDIR / \"skills\"\nTRANSCRIPT_DIR = WORKDIR / \".transcripts\"\nTOOL_RESULTS_DIR = WORKDIR / \".task_outputs\" / \"tool-results\"\n\nDEFAULT_MAX_TOKENS = 8000\nESCALATED_MAX_TOKENS = 16000\nMAX_RETRIES = 3\nMAX_CONSECUTIVE_529 = 2\nMAX_RECOVERY_RETRIES = 2\nBASE_DELAY_MS = 500\nCONTEXT_LIMIT = 50000\nKEEP_RECENT_TOOL_RESULTS = 3\nPERSIST_THRESHOLD = 30000\nCONTINUATION_PROMPT = \"Continue from the previous response. Do not repeat completed work.\"\nPROMPT = \"\\033[36ms20 >> \\033[0m\"\nCLI_ACTIVE = False\n\n\ndef terminal_print(text: str):\n if threading.current_thread() is threading.main_thread() or not CLI_ACTIVE:\n print(text)\n return\n line = \"\"\n if READLINE_AVAILABLE:\n try:\n line = readline.get_line_buffer()\n except Exception:\n line = \"\"\n print(f\"\\r\\033[K{text}\")\n print(PROMPT + line, end=\"\", flush=True)\n\n# ── Task System ──\n\n# Tasks are tiny durable records. Later systems add ownership, dependencies,\n# worktrees, and teammates on top of this same file-backed state.\nTASKS_DIR = WORKDIR / \".tasks\"\nTASKS_DIR.mkdir(exist_ok=True)\nCURRENT_TODOS: list[dict] = []\n\n\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str\n owner: str | None\n blockedBy: list[str]\n worktree: str | None = None\n\n\ndef _task_path(task_id: str) -> Path:\n return TASKS_DIR / f\"{task_id}.json\"\n\n\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random.randint(0, 9999):04d}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n\n\ndef save_task(task: Task):\n _task_path(task.id).write_text(json.dumps(asdict(task), indent=2))\n\n\ndef load_task(task_id: str) -> Task:\n return Task(**json.loads(_task_path(task_id).read_text()))\n\n\ndef list_tasks() -> list[Task]:\n return [Task(**json.loads(p.read_text()))\n for p in sorted(TASKS_DIR.glob(\"task_*.json\"))]\n\n\ndef get_task_json(task_id: str) -> str:\n return json.dumps(asdict(load_task(task_id)), indent=2)\n\n\ndef can_start(task_id: str) -> bool:\n # Dependencies are intentionally simple: every blocker must exist and be\n # completed before the task can be claimed.\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False\n if load_task(dep_id).status != \"completed\":\n return False\n return True\n\n\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if task.owner:\n return f\"Task {task_id} already owned by {task.owner}\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if _task_path(d).exists() and load_task(d).status != \"completed\"]\n missing = [d for d in task.blockedBy if not _task_path(d).exists()]\n parts = []\n if deps: parts.append(f\"blocked by: {deps}\")\n if missing: parts.append(f\"missing deps: {missing}\")\n return \"Cannot start — \" + \", \".join(parts)\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n print(f\" \\033[36m[claim] {task.subject} → in_progress\\033[0m\")\n return f\"Claimed {task.id} ({task.subject})\"\n\n\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n if task.status != \"in_progress\":\n return f\"Task {task_id} is {task.status}, cannot complete\"\n task.status = \"completed\"\n save_task(task)\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy and can_start(t.id)]\n print(f\" \\033[32m[complete] {task.subject} ✓\\033[0m\")\n msg = f\"Completed {task.id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n return msg\n\n\n# ── Worktree System ──\n\n# Worktree names become filesystem paths, so the teaching version keeps the\n# validation rules strict and reuses them for create/remove/keep.\nWORKTREES_DIR = WORKDIR / \".worktrees\"\nWORKTREES_DIR.mkdir(exist_ok=True)\n\nVALID_WT_NAME = re.compile(r'^[A-Za-z0-9._-]{1,64}$')\n\n\ndef validate_worktree_name(name: str) -> str | None:\n if not name:\n return \"Worktree name cannot be empty\"\n if name in (\".\", \"..\"):\n return f\"'{name}' is not a valid worktree name\"\n if not VALID_WT_NAME.match(name):\n return (f\"Invalid worktree name '{name}': \"\n \"only letters, digits, dots, underscores, dashes (1-64 chars)\")\n return None\n\n\ndef run_git(args: list[str]) -> tuple[bool, str]:\n try:\n r = subprocess.run([\"git\"] + args, cwd=WORKDIR,\n capture_output=True, text=True, timeout=30)\n out = (r.stdout + r.stderr).strip()\n return r.returncode == 0, out[:5000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return False, \"Error: git timeout\"\n\n\ndef log_event(event_type: str, worktree_name: str, task_id: str = \"\"):\n event = {\"type\": event_type, \"worktree\": worktree_name,\n \"task_id\": task_id, \"ts\": time.time()}\n events_file = WORKTREES_DIR / \"events.jsonl\"\n with open(events_file, \"a\") as f:\n f.write(json.dumps(event) + \"\\n\")\n\n\ndef create_worktree(name: str, task_id: str = \"\") -> str:\n # Tool-layer validation is part of the safety boundary; do it before git\n # sees the name, not only after git happens to reject something.\n err = validate_worktree_name(name)\n if err:\n return f\"Error: {err}\"\n if task_id:\n try:\n load_task(task_id)\n except FileNotFoundError:\n return f\"Error: task {task_id} not found\"\n path = WORKTREES_DIR / name\n if path.exists():\n return f\"Worktree '{name}' already exists at {path}\"\n ok, result = run_git([\"worktree\", \"add\", str(path), \"-b\", f\"wt/{name}\", \"HEAD\"])\n if not ok:\n return f\"Git error: {result}\"\n if task_id:\n bind_task_to_worktree(task_id, name)\n log_event(\"create\", name, task_id)\n print(f\" \\033[33m[worktree] created: {name} at {path}\\033[0m\")\n return f\"Worktree '{name}' created at {path}\"\n\n\ndef bind_task_to_worktree(task_id: str, worktree_name: str):\n task = load_task(task_id)\n task.worktree = worktree_name\n save_task(task)\n\n\ndef _count_worktree_changes(path: Path) -> tuple[int, int]:\n try:\n r1 = subprocess.run([\"git\", \"status\", \"--porcelain\"],\n cwd=path, capture_output=True, text=True, timeout=10)\n files = len([l for l in r1.stdout.strip().splitlines() if l.strip()])\n r2 = subprocess.run([\"git\", \"log\", \"@{push}..HEAD\", \"--oneline\"],\n cwd=path, capture_output=True, text=True, timeout=10)\n commits = len([l for l in r2.stdout.strip().splitlines() if l.strip()])\n return files, commits\n except Exception:\n return -1, -1\n\n\ndef remove_worktree(name: str, discard_changes: bool = False) -> str:\n err = validate_worktree_name(name)\n if err:\n return err\n path = WORKTREES_DIR / name\n if not path.exists():\n return f\"Worktree '{name}' not found\"\n if not discard_changes:\n files, commits = _count_worktree_changes(path)\n if files < 0:\n return \"Cannot verify status. Use discard_changes=true to force.\"\n if files > 0 or commits > 0:\n return (f\"Worktree '{name}' has {files} file(s), {commits} commit(s). \"\n \"Use discard_changes=true or keep_worktree.\")\n ok1, _ = run_git([\"worktree\", \"remove\", str(path), \"--force\"])\n if not ok1:\n return f\"Failed to remove worktree '{name}'\"\n run_git([\"branch\", \"-D\", f\"wt/{name}\"])\n log_event(\"remove\", name)\n print(f\" \\033[33m[worktree] removed: {name}\\033[0m\")\n return f\"Worktree '{name}' removed\"\n\n\ndef keep_worktree(name: str) -> str:\n err = validate_worktree_name(name)\n if err:\n return err\n log_event(\"keep\", name)\n return f\"Worktree '{name}' kept for review (branch: wt/{name})\"\n\n\n# ── Skill Loading ──\n\nSKILL_REGISTRY: dict[str, dict] = {}\n\n\ndef _parse_frontmatter(text: str) -> tuple[dict, str]:\n if not text.startswith(\"---\"):\n return {}, text\n parts = text.split(\"---\", 2)\n if len(parts) < 3:\n return {}, text\n try:\n meta = yaml.safe_load(parts[1]) or {}\n except yaml.YAMLError:\n meta = {}\n return meta, parts[2].strip()\n\n\ndef scan_skills():\n SKILL_REGISTRY.clear()\n if not SKILLS_DIR.exists():\n return\n for directory in sorted(SKILLS_DIR.iterdir()):\n if not directory.is_dir():\n continue\n manifest = directory / \"SKILL.md\"\n if not manifest.exists():\n continue\n raw = manifest.read_text()\n meta, _ = _parse_frontmatter(raw)\n name = meta.get(\"name\", directory.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\n \"name\": name,\n \"description\": desc,\n \"content\": raw,\n }\n\n\nscan_skills()\n\n\ndef list_skills() -> str:\n if not SKILL_REGISTRY:\n return \"(no skills found)\"\n return \"\\n\".join(\n f\"- {skill['name']}: {skill['description']}\"\n for skill in SKILL_REGISTRY.values())\n\n\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n available = \", \".join(SKILL_REGISTRY.keys()) or \"(none)\"\n return f\"Skill not found: {name}. Available: {available}\"\n return skill[\"content\"]\n\n\n# ── Prompt Assembly ──\n\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file, edit_file, glob, \"\n \"todo_write, task, load_skill, compact, \"\n \"create_task, list_tasks, get_task, claim_task, complete_task, \"\n \"schedule_cron, list_crons, cancel_cron, \"\n \"spawn_teammate, send_message, check_inbox, \"\n \"request_shutdown, request_plan, review_plan, \"\n \"create_worktree, remove_worktree, keep_worktree, \"\n \"connect_mcp. MCP tools are prefixed mcp__{server}__{tool}.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n\n\ndef assemble_system_prompt(context: dict) -> str:\n # The system prompt is rebuilt each turn from live context. This is where\n # memory, skill catalog, MCP state, and active teammates become visible.\n sections = [PROMPT_SECTIONS[\"identity\"],\n PROMPT_SECTIONS[\"tools\"],\n PROMPT_SECTIONS[\"workspace\"]]\n sections.append(f\"Current time: {datetime.now().isoformat(timespec='seconds')}\")\n sections.append(\"Skills catalog:\\n\" + list_skills() +\n \"\\nUse load_skill(name) when a skill is relevant.\")\n if context.get(\"memories\"):\n sections.append(f\"Relevant memories:\\n{context['memories']}\")\n mcp_names = list(mcp_clients.keys())\n if mcp_names:\n sections.append(f\"Connected MCP servers: {', '.join(mcp_names)}\")\n return \"\\n\\n\".join(sections)\n\n\n# ── Basic Tools ──\n\ndef safe_path(p: str, cwd: Path = None) -> Path:\n # File tools stay inside the workspace or teammate worktree. Bash remains\n # powerful on purpose and is controlled by the permission hook instead.\n base = cwd or WORKDIR\n path = (base / p).resolve()\n if not path.is_relative_to(base):\n raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\n\ndef run_bash(command: str, cwd: Path = None,\n run_in_background: bool = False) -> str:\n # run_in_background is consumed by the dispatcher; direct execution ignores it.\n try:\n r = subprocess.run(command, shell=True, cwd=cwd or WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\n\ndef run_read(path: str, limit: int | None = None,\n offset: int = 0, cwd: Path = None) -> str:\n try:\n lines = safe_path(path, cwd).read_text().splitlines()\n offset = max(int(offset or 0), 0)\n limit = int(limit) if limit is not None else None\n lines = lines[offset:]\n if limit is not None and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_write(path: str, content: str, cwd: Path = None) -> str:\n try:\n fp = safe_path(path, cwd)\n fp.parent.mkdir(parents=True, exist_ok=True)\n fp.write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_edit(path: str, old_text: str, new_text: str,\n cwd: Path = None) -> str:\n try:\n fp = safe_path(path, cwd)\n text = fp.read_text()\n if old_text not in text:\n return f\"Error: text not found in {path}\"\n fp.write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_glob(pattern: str, cwd: Path = None) -> str:\n import glob as g\n try:\n base = cwd or WORKDIR\n results = []\n for match in g.glob(pattern, root_dir=base):\n if (base / match).resolve().is_relative_to(base):\n results.append(match)\n return \"\\n\".join(results) if results else \"(no matches)\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef call_tool_handler(handler, args: dict, name: str) -> str:\n if not handler:\n return f\"Unknown: {name}\"\n try:\n return handler(**(args or {}))\n except TypeError as e:\n return f\"Error: {e}\"\n\n\ndef _normalize_todos(todos):\n if isinstance(todos, str):\n try:\n todos = json.loads(todos)\n except json.JSONDecodeError:\n try:\n todos = ast.literal_eval(todos)\n except (SyntaxError, ValueError):\n return None, \"Error: todos must be a list or JSON array string\"\n if not isinstance(todos, list):\n return None, \"Error: todos must be a list\"\n for i, todo in enumerate(todos):\n if not isinstance(todo, dict):\n return None, f\"Error: todos[{i}] must be an object\"\n if \"content\" not in todo or \"status\" not in todo:\n return None, f\"Error: todos[{i}] missing 'content' or 'status'\"\n if todo[\"status\"] not in (\"pending\", \"in_progress\", \"completed\"):\n return None, f\"Error: todos[{i}] has invalid status '{todo['status']}'\"\n return todos, None\n\ndef run_todo_write(todos: list) -> str:\n global CURRENT_TODOS\n todos, error = _normalize_todos(todos)\n if error:\n return error\n CURRENT_TODOS = todos\n print(f\" \\033[33m[todo] updated {len(CURRENT_TODOS)} item(s)\\033[0m\")\n return f\"Updated {len(CURRENT_TODOS)} todos\"\n\n\n# ── MessageBus ──\n\n# Team communication is append-only JSONL mailboxes. This keeps the protocol\n# inspectable on disk and lets background teammates send messages.\nMAILBOX_DIR = WORKDIR / \".mailboxes\"\nMAILBOX_DIR.mkdir(exist_ok=True)\n\n\nclass MessageBus:\n def send(self, from_agent: str, to_agent: str, content: str,\n msg_type: str = \"message\", metadata: dict = None):\n msg = {\"from\": from_agent, \"to\": to_agent,\n \"content\": content, \"type\": msg_type,\n \"ts\": time.time(), \"metadata\": metadata or {}}\n inbox = MAILBOX_DIR / f\"{to_agent}.jsonl\"\n with open(inbox, \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n terminal_print(f\" \\033[33m[bus] {from_agent} → {to_agent}: \"\n f\"({msg_type}) {content[:50]}\\033[0m\")\n\n def read_inbox(self, agent: str) -> list[dict]:\n inbox = MAILBOX_DIR / f\"{agent}.jsonl\"\n if not inbox.exists():\n return []\n msgs = [json.loads(line) for line in inbox.read_text().splitlines()\n if line.strip()]\n inbox.unlink()\n return msgs\n\n\nBUS = MessageBus()\nactive_teammates: dict[str, bool] = {}\n\n# ── Protocol State ──\n\n@dataclass\nclass ProtocolState:\n request_id: str\n type: str\n sender: str\n target: str\n status: str\n payload: str\n created_at: float = field(default_factory=time.time)\n\n\npending_requests: dict[str, ProtocolState] = {}\n\n\ndef new_request_id() -> str:\n return f\"req_{random.randint(0, 999999):06d}\"\n\n\ndef match_response(response_type: str, request_id: str, approve: bool):\n # Responses are matched by request_id so one protocol reply cannot approve\n # a different pending request.\n state = pending_requests.get(request_id)\n if not state:\n return\n if state.type == \"shutdown\" and response_type != \"shutdown_response\":\n return\n if state.type == \"plan_approval\" and response_type != \"plan_approval_response\":\n return\n state.status = \"approved\" if approve else \"rejected\"\n\n\ndef consume_lead_inbox(route_protocol=True) -> list[dict]:\n msgs = BUS.read_inbox(\"lead\")\n if route_protocol:\n for msg in msgs:\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n msg_type = msg.get(\"type\", \"\")\n if req_id and msg_type.endswith(\"_response\"):\n match_response(msg_type, req_id, meta.get(\"approve\", False))\n return msgs\n\n\n# ── Autonomous Agent ──\n\nIDLE_POLL_INTERVAL = 5\nIDLE_TIMEOUT = 60\n\n\ndef scan_unclaimed_tasks() -> list[dict]:\n unclaimed = []\n for f in sorted(TASKS_DIR.glob(\"task_*.json\")):\n task = json.loads(f.read_text())\n if (task.get(\"status\") == \"pending\"\n and not task.get(\"owner\")\n and can_start(task[\"id\"])):\n unclaimed.append(task)\n return unclaimed\n\n\ndef idle_poll(agent_name: str, messages: list,\n name: str, role: str,\n worktree_context: dict | None = None) -> str:\n # Autonomous teammates wake up for inbox messages first, then look for\n # unclaimed tasks. This keeps direct protocol messages higher priority.\n for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):\n time.sleep(IDLE_POLL_INTERVAL)\n inbox = BUS.read_inbox(agent_name)\n if inbox:\n for msg in inbox:\n if msg.get(\"type\") == \"shutdown_request\":\n req_id = msg.get(\"metadata\", {}).get(\"request_id\", \"\")\n BUS.send(name, \"lead\", \"Shutting down.\",\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n return \"shutdown\"\n messages.append({\"role\": \"user\",\n \"content\": \"\" + json.dumps(inbox) + \"\"})\n return \"work\"\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n task_data = unclaimed[0]\n result = claim_task(task_data[\"id\"], agent_name)\n if \"Claimed\" in result:\n wt_info = \"\"\n if task_data.get(\"worktree\"):\n wt_path = WORKTREES_DIR / task_data[\"worktree\"]\n wt_info = f\"\\nWork directory: {wt_path}\"\n if worktree_context is not None:\n worktree_context[\"path\"] = str(wt_path)\n messages.append({\"role\": \"user\",\n \"content\": f\"Task {task_data['id']}: \"\n f\"{task_data['subject']}{wt_info}\"})\n return \"work\"\n return \"timeout\"\n\n\n# ── Teammate Thread ──\n\ndef spawn_teammate_thread(name: str, role: str, prompt: str) -> str:\n if name in active_teammates:\n return f\"Teammate '{name}' already exists\"\n\n # Plan approval is a real gate: after submit_plan, the teammate stops\n # taking model/tool steps until lead sends plan_approval_response.\n protocol_ctx = {\"waiting_plan\": None}\n system = (f\"You are '{name}', a {role}. \"\n f\"Use tools to complete tasks. \"\n f\"If a task has a worktree, work in that directory.\")\n\n def handle_inbox_message(name: str, msg: dict, messages: list):\n msg_type = msg.get(\"type\", \"message\")\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n if msg_type == \"shutdown_request\":\n BUS.send(name, \"lead\", \"Shutting down.\",\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n return True\n if msg_type == \"plan_approval_response\":\n approve = meta.get(\"approve\", False)\n if req_id == protocol_ctx[\"waiting_plan\"]:\n protocol_ctx[\"waiting_plan\"] = None\n messages.append({\"role\": \"user\",\n \"content\": \"[Plan approved]\" if approve\n else f\"[Plan rejected] {msg['content']}\"})\n return False\n\n def run():\n wt_ctx = {\"path\": None}\n\n def _wt_cwd():\n # Once a task with a worktree is claimed, all teammate file tools\n # transparently run inside that isolated directory.\n p = wt_ctx[\"path\"]\n return Path(p) if p else None\n\n def _run_bash(command: str) -> str:\n return run_bash(command, cwd=_wt_cwd())\n\n def _run_read(path: str) -> str:\n return run_read(path, cwd=_wt_cwd())\n\n def _run_write(path: str, content: str) -> str:\n return run_write(path, content, cwd=_wt_cwd())\n\n def _run_list_tasks():\n tasks = list_tasks()\n if not tasks:\n return \"No tasks.\"\n return \"\\n\".join(\n f\" {t.id}: {t.subject} [{t.status}]\"\n + (f\" (wt:{t.worktree})\" if t.worktree else \"\")\n for t in tasks)\n\n def _run_claim_task(task_id: str):\n result = claim_task(task_id, owner=name)\n if \"Claimed\" in result:\n task = load_task(task_id)\n wt_ctx[\"path\"] = (str(WORKTREES_DIR / task.worktree)\n if task.worktree else None)\n return result\n\n def _run_complete_task(task_id: str):\n result = complete_task(task_id)\n wt_ctx[\"path\"] = None\n return result\n\n messages = [{\"role\": \"user\", \"content\": prompt}]\n sub_tools = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"},\n \"offset\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"send_message\",\n \"description\": \"Send message to another agent.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"submit_plan\",\n \"description\": \"Submit a plan for Lead approval.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"plan\": {\"type\": \"string\"}},\n \"required\": [\"plan\"]}},\n {\"name\": \"list_tasks\",\n \"description\": \"List all tasks.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n {\"name\": \"claim_task\",\n \"description\": \"Claim a pending task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\",\n \"description\": \"Mark an in-progress task as completed.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n ]\n\n sub_handlers = {\n \"bash\": _run_bash, \"read_file\": _run_read,\n \"write_file\": _run_write,\n \"send_message\": lambda to, content: (BUS.send(name, to, content),\n \"Sent\")[1],\n \"list_tasks\": _run_list_tasks,\n \"claim_task\": _run_claim_task,\n \"complete_task\": _run_complete_task,\n }\n\n while True:\n if len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"You are '{name}', role: {role}. \"\n f\"Continue your work.\"})\n should_shutdown = False\n for _ in range(10):\n inbox = BUS.read_inbox(name)\n for msg in inbox:\n stopped = handle_inbox_message(name, msg, messages)\n if stopped:\n should_shutdown = True\n break\n if should_shutdown:\n break\n if protocol_ctx[\"waiting_plan\"]:\n # Poll only for protocol replies while the approval gate is\n # closed; do not let the model continue with the task.\n time.sleep(IDLE_POLL_INTERVAL)\n continue\n if inbox and not should_shutdown:\n non_protocol = [m for m in inbox\n if m.get(\"type\") == \"message\"]\n if non_protocol:\n messages.append({\"role\": \"user\",\n \"content\": \"\" + json.dumps(non_protocol) + \"\"})\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages[-20:],\n tools=sub_tools, max_tokens=8000)\n except Exception:\n break\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if not has_tool_use(response.content):\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n if block.name == \"submit_plan\":\n output = _teammate_submit_plan(\n name, block.input.get(\"plan\", \"\"))\n match = re.search(r\"\\((req_\\d+)\\)\", output)\n protocol_ctx[\"waiting_plan\"] = (\n match.group(1) if match else output)\n else:\n handler = sub_handlers.get(block.name)\n output = call_tool_handler(handler, block.input,\n block.name)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)})\n if protocol_ctx[\"waiting_plan\"]:\n # Ignore later tool_use blocks from the same model\n # response; they belong after approval, not before.\n break\n messages.append({\"role\": \"user\", \"content\": results})\n if protocol_ctx[\"waiting_plan\"]:\n break\n if should_shutdown:\n break\n if protocol_ctx[\"waiting_plan\"]:\n continue\n idle_result = idle_poll(name, messages, name, role, wt_ctx)\n if idle_result in (\"shutdown\", \"timeout\"):\n break\n\n summary = \"Done.\"\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\" and isinstance(msg[\"content\"], list):\n for b in msg[\"content\"]:\n if getattr(b, \"type\", None) == \"text\":\n summary = b.text\n break\n else:\n continue\n break\n BUS.send(name, \"lead\", summary, \"result\")\n active_teammates.pop(name, None)\n\n active_teammates[name] = True\n threading.Thread(target=run, daemon=True).start()\n return f\"Teammate '{name}' spawned as {role}\"\n\n\ndef _teammate_submit_plan(from_name: str, plan: str) -> str:\n req_id = new_request_id()\n pending_requests[req_id] = ProtocolState(\n request_id=req_id, type=\"plan_approval\",\n sender=from_name, target=\"lead\",\n status=\"pending\", payload=plan)\n BUS.send(from_name, \"lead\", plan,\n \"plan_approval_request\",\n {\"request_id\": req_id})\n return f\"Plan submitted ({req_id})\"\n\n\n# ── Lead Protocol Tools ──\n\ndef run_request_shutdown(teammate: str) -> str:\n req_id = new_request_id()\n pending_requests[req_id] = ProtocolState(\n request_id=req_id, type=\"shutdown\",\n sender=\"lead\", target=teammate,\n status=\"pending\", payload=\"\")\n BUS.send(\"lead\", teammate, \"Shut down.\", \"shutdown_request\",\n {\"request_id\": req_id})\n return f\"Shutdown request sent to {teammate}\"\n\n\ndef run_request_plan(teammate: str, task: str) -> str:\n BUS.send(\"lead\", teammate, f\"Submit plan for: {task}\", \"message\")\n return f\"Asked {teammate} to submit a plan\"\n\n\ndef run_review_plan(request_id: str, approve: bool,\n feedback: str = \"\") -> str:\n state = pending_requests.get(request_id)\n if not state:\n return f\"Request {request_id} not found\"\n state.status = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", state.sender,\n feedback or (\"Approved\" if approve else \"Rejected\"),\n \"plan_approval_response\",\n {\"request_id\": request_id, \"approve\": approve})\n return f\"Plan {'approved' if approve else 'rejected'}\"\n\n\n# ── Hooks + Permission Pipeline ──\n\n# Hooks are intentionally outside tool handlers. The loop can add permission,\n# logging, and stop behavior without changing each individual tool.\nHOOKS = {\"UserPromptSubmit\": [], \"PreToolUse\": [],\n \"PostToolUse\": [], \"Stop\": []}\n\n\ndef register_hook(event: str, callback):\n HOOKS[event].append(callback)\n\n\ndef trigger_hooks(event: str, *args):\n for callback in HOOKS[event]:\n result = callback(*args)\n if result is not None:\n return result\n return None\n\n\nDENY_LIST = [\"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\", \"mkfs\", \"dd if=\"]\nDESTRUCTIVE = [\"rm \", \"> /etc/\", \"chmod 777\"]\n\n\ndef permission_hook(block):\n # The permission layer sees the raw tool_use before dispatch. It can deny,\n # ask the user, or allow execution to continue.\n if block.name == \"bash\":\n command = block.input.get(\"command\", \"\")\n for pattern in DENY_LIST:\n if pattern in command:\n return f\"Permission denied: '{pattern}' is on the deny list\"\n if any(token in command for token in DESTRUCTIVE):\n print(f\"\\n\\033[33m[permission] destructive command\\033[0m\")\n print(f\" {command}\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n if block.name in (\"write_file\", \"edit_file\"):\n path = block.input.get(\"path\", \"\")\n try:\n safe_path(path)\n except Exception:\n return f\"Permission denied: path escapes workspace: {path}\"\n if block.name.startswith(\"mcp__\") and \"deploy\" in block.name:\n print(f\"\\n\\033[33m[permission] MCP destructive-looking tool: {block.name}\\033[0m\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n return None\n\n\ndef log_hook(block):\n print(f\"\\033[90m[HOOK] {block.name}\\033[0m\")\n return None\n\n\ndef large_output_hook(block, output):\n if len(str(output)) > 100000:\n print(f\"\\033[33m[HOOK] large output from {block.name}: \"\n f\"{len(str(output))} chars\\033[0m\")\n return None\n\n\ndef user_prompt_hook(query: str):\n print(f\"\\033[90m[HOOK] UserPromptSubmit: {WORKDIR}\\033[0m\")\n return None\n\n\ndef stop_hook(messages: list):\n tool_count = 0\n for msg in messages:\n content = msg.get(\"content\")\n if isinstance(content, list):\n tool_count += sum(1 for item in content\n if isinstance(item, dict)\n and item.get(\"type\") == \"tool_result\")\n print(f\"\\033[90m[HOOK] Stop: {tool_count} tool result(s)\\033[0m\")\n return None\n\n\nregister_hook(\"UserPromptSubmit\", user_prompt_hook)\nregister_hook(\"PreToolUse\", permission_hook)\nregister_hook(\"PreToolUse\", log_hook)\nregister_hook(\"PostToolUse\", large_output_hook)\nregister_hook(\"Stop\", stop_hook)\n\n\n# ── Subagent Tool ──\n\nSUB_SYSTEM = (\n f\"You are a coding subagent at {WORKDIR}. \"\n \"Complete the task, then return a concise final summary. \"\n \"Do not spawn more agents.\"\n)\n\n\nSUB_TOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"},\n \"offset\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"old_text\": {\"type\": \"string\"},\n \"new_text\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"pattern\": {\"type\": \"string\"}},\n \"required\": [\"pattern\"]}},\n]\n\n\nSUB_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read,\n \"write_file\": run_write, \"edit_file\": run_edit,\n \"glob\": run_glob,\n}\n\n\ndef extract_text(content) -> str:\n if not isinstance(content, list):\n return str(content)\n return \"\\n\".join(\n getattr(block, \"text\", \"\")\n for block in content\n if getattr(block, \"type\", None) == \"text\").strip()\n\n\ndef has_tool_use(content) -> bool:\n # Do not rely on stop_reason alone; the concrete tool_use block is the\n # continuation signal used by the loop.\n return any(getattr(block, \"type\", None) == \"tool_use\"\n for block in content)\n\n\ndef spawn_subagent(description: str) -> str:\n messages = [{\"role\": \"user\", \"content\": description}]\n for _ in range(30):\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM, messages=messages,\n tools=SUB_TOOLS, max_tokens=8000)\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if not has_tool_use(response.content):\n break\n results = []\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n output = str(blocked)\n else:\n handler = SUB_HANDLERS.get(block.name)\n output = call_tool_handler(handler, block.input, block.name)\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)})\n messages.append({\"role\": \"user\", \"content\": results})\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\":\n text = extract_text(msg[\"content\"])\n if text:\n return text\n return \"Subagent finished without a text summary.\"\n\n\n# ── Context Compaction ──\n\n# Compaction is layered: first shrink oversized tool results, then trim old\n# message ranges, and only call the model for a summary when the context is\n# still too large or the model explicitly asks for compact.\ndef estimate_size(messages: list) -> int:\n return len(json.dumps(messages, default=str))\n\n\ndef collect_tool_results(messages: list):\n found = []\n for mi, msg in enumerate(messages):\n content = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n continue\n for bi, block in enumerate(content):\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n found.append((mi, bi, block))\n return found\n\n\ndef persist_large_output(tool_use_id: str, output: str) -> str:\n if len(output) <= PERSIST_THRESHOLD:\n return output\n TOOL_RESULTS_DIR.mkdir(parents=True, exist_ok=True)\n path = TOOL_RESULTS_DIR / f\"{tool_use_id}.txt\"\n if not path.exists():\n path.write_text(output)\n return (f\"\\nFull output: {path}\\n\"\n f\"Preview:\\n{output[:2000]}\\n\")\n\n\ndef tool_result_budget(messages: list, max_bytes: int = 200_000) -> list:\n if not messages:\n return messages\n last = messages[-1]\n content = last.get(\"content\")\n if last.get(\"role\") != \"user\" or not isinstance(content, list):\n return messages\n blocks = [(i, b) for i, b in enumerate(content)\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes:\n return messages\n for _, block in sorted(blocks,\n key=lambda pair: len(str(pair[1].get(\"content\", \"\"))),\n reverse=True):\n if total <= max_bytes:\n break\n text = str(block.get(\"content\", \"\"))\n block[\"content\"] = persist_large_output(\n block.get(\"tool_use_id\", \"unknown\"), text)\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return messages\n\n\ndef snip_compact(messages: list, max_messages: int = 50) -> list:\n if len(messages) <= max_messages:\n return messages\n keep_head, keep_tail = 3, max_messages - 3\n snipped = len(messages) - keep_head - keep_tail\n return (messages[:keep_head]\n + [{\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}]\n + messages[-keep_tail:])\n\n\ndef micro_compact(messages: list) -> list:\n tool_results = collect_tool_results(messages)\n if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS:\n return messages\n for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]:\n if len(str(block.get(\"content\", \"\"))) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n\n\ndef write_transcript(messages: list) -> Path:\n TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)\n path = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with path.open(\"w\") as f:\n for msg in messages:\n f.write(json.dumps(msg, default=str) + \"\\n\")\n return path\n\n\ndef summarize_history(messages: list) -> str:\n conversation = json.dumps(messages, default=str)[:80000]\n prompt = (\"Summarize this coding-agent conversation so work can continue. \"\n \"Preserve current goal, key findings, changed files, remaining work, \"\n \"and user constraints.\\n\\n\" + conversation)\n response = client.messages.create(\n model=MODEL,\n messages=[{\"role\": \"user\", \"content\": prompt}],\n max_tokens=2000)\n return extract_text(response.content) or \"(empty summary)\"\n\n\ndef compact_history(messages: list) -> list:\n transcript = write_transcript(messages)\n print(f\" \\033[36m[compact] transcript saved: {transcript}\\033[0m\")\n summary = summarize_history(messages)\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n\n\ndef reactive_compact(messages: list) -> list:\n transcript = write_transcript(messages)\n print(f\" \\033[31m[reactive compact] transcript saved: {transcript}\\033[0m\")\n try:\n summary = summarize_history(messages)\n except Exception:\n summary = \"Earlier conversation was trimmed after a prompt-too-long error.\"\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"},\n *messages[-5:]]\n\n\n# ── Error Recovery ──\n\nclass RecoveryState:\n def __init__(self):\n self.has_escalated = False\n self.recovery_count = 0\n self.consecutive_529 = 0\n self.has_attempted_reactive_compact = False\n self.current_model = PRIMARY_MODEL\n\n\ndef retry_delay(attempt: int) -> float:\n base = min(BASE_DELAY_MS * (2 ** attempt), 32000) / 1000\n return base + random.uniform(0, base * 0.25)\n\n\ndef with_retry(fn, state: RecoveryState):\n for attempt in range(MAX_RETRIES):\n try:\n result = fn()\n state.consecutive_529 = 0\n return result\n except Exception as e:\n name = type(e).__name__.lower()\n msg = str(e).lower()\n if \"ratelimit\" in name or \"429\" in msg:\n delay = retry_delay(attempt)\n print(f\" \\033[33m[429] retry {attempt + 1}/{MAX_RETRIES} \"\n f\"after {delay:.1f}s\\033[0m\")\n time.sleep(delay)\n continue\n if \"overloaded\" in name or \"529\" in msg or \"overloaded\" in msg:\n state.consecutive_529 += 1\n if state.consecutive_529 >= MAX_CONSECUTIVE_529 and FALLBACK_MODEL:\n state.current_model = FALLBACK_MODEL\n state.consecutive_529 = 0\n print(f\" \\033[31m[529] switching to {FALLBACK_MODEL}\\033[0m\")\n delay = retry_delay(attempt)\n print(f\" \\033[33m[529] retry {attempt + 1}/{MAX_RETRIES} \"\n f\"after {delay:.1f}s\\033[0m\")\n time.sleep(delay)\n continue\n raise\n raise RuntimeError(f\"Max retries ({MAX_RETRIES}) exceeded\")\n\n\ndef is_prompt_too_long_error(e: Exception) -> bool:\n msg = str(e).lower()\n return ((\"prompt\" in msg and \"long\" in msg)\n or \"context_length_exceeded\" in msg\n or \"max_context_window\" in msg)\n\n\n# ── Background Tasks ──\n\n# Slow tools return a placeholder tool_result immediately. Their real output is\n# later injected as a task_notification, so the main loop can keep moving.\n_bg_counter = 0\nbackground_tasks: dict[str, dict] = {}\nbackground_results: dict[str, str] = {}\nbackground_lock = threading.Lock()\n\n\ndef is_slow_operation(tool_name: str, tool_input: dict) -> bool:\n if tool_name != \"bash\":\n return False\n command = tool_input.get(\"command\", \"\").lower()\n slow_keywords = [\"install\", \"build\", \"test\", \"deploy\", \"compile\",\n \"docker build\", \"pip install\", \"npm install\",\n \"cargo build\", \"pytest\", \"make\"]\n return any(keyword in command for keyword in slow_keywords)\n\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n if tool_name != \"bash\":\n return False\n return bool(tool_input.get(\"run_in_background\")) or is_slow_operation(tool_name, tool_input)\n\n\ndef start_background_task(block, handlers: dict) -> str:\n global _bg_counter\n _bg_counter += 1\n bg_id = f\"bg_{_bg_counter:04d}\"\n command = block.input.get(\"command\", block.name)\n\n def worker():\n handler = handlers.get(block.name)\n result = call_tool_handler(handler, block.input, block.name)\n trigger_hooks(\"PostToolUse\", block, result)\n with background_lock:\n background_tasks[bg_id][\"status\"] = \"completed\"\n background_results[bg_id] = str(result)\n\n with background_lock:\n background_tasks[bg_id] = {\n \"tool_use_id\": block.id,\n \"command\": command,\n \"status\": \"running\",\n }\n threading.Thread(target=worker, daemon=True).start()\n print(f\" \\033[33m[background] {bg_id}: {str(command)[:60]}\\033[0m\")\n return bg_id\n\n\ndef collect_background_results() -> list[str]:\n with background_lock:\n ready = [bg_id for bg_id, task in background_tasks.items()\n if task[\"status\"] == \"completed\"]\n notifications = []\n for bg_id in ready:\n with background_lock:\n task = background_tasks.pop(bg_id)\n output = background_results.pop(bg_id, \"\")\n summary = output[:200] if len(output) > 200 else output\n notifications.append(\n f\"\\n\"\n f\" {bg_id}\\n\"\n f\" completed\\n\"\n f\" {task['command']}\\n\"\n f\" {summary}\\n\"\n f\"\")\n return notifications\n\n\n# ── Cron Scheduler ──\n\n# Cron jobs are stored separately from conversation history. When a job fires,\n# it becomes a scheduled prompt that is injected back into the same agent loop.\nDURABLE_PATH = WORKDIR / \".scheduled_tasks.json\"\n\n\n@dataclass\nclass CronJob:\n id: str\n cron: str\n prompt: str\n recurring: bool\n durable: bool\n\n\nscheduled_jobs: dict[str, CronJob] = {}\ncron_queue: list[CronJob] = []\ncron_lock = threading.Lock()\n_last_fired: dict[str, str] = {}\n\n\ndef _cron_field_matches(field: str, value: int) -> bool:\n if field == \"*\":\n return True\n if field.startswith(\"*/\"):\n step = int(field[2:])\n return step > 0 and value % step == 0\n if \",\" in field:\n return any(_cron_field_matches(part.strip(), value)\n for part in field.split(\",\"))\n if \"-\" in field:\n lo, hi = field.split(\"-\", 1)\n return int(lo) <= value <= int(hi)\n return value == int(field)\n\n\ndef cron_matches(cron_expr: str, dt: datetime) -> bool:\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return False\n minute, hour, dom, month, dow = fields\n dow_val = (dt.weekday() + 1) % 7\n m = _cron_field_matches(minute, dt.minute)\n h = _cron_field_matches(hour, dt.hour)\n dom_ok = _cron_field_matches(dom, dt.day)\n month_ok = _cron_field_matches(month, dt.month)\n dow_ok = _cron_field_matches(dow, dow_val)\n if not (m and h and month_ok):\n return False\n if dom == \"*\" and dow == \"*\":\n return True\n if dom == \"*\":\n return dow_ok\n if dow == \"*\":\n return dom_ok\n return dom_ok or dow_ok\n\n\ndef _validate_cron_field(field: str, lo: int, hi: int) -> str | None:\n if field == \"*\":\n return None\n if field.startswith(\"*/\"):\n step = field[2:]\n if not step.isdigit() or int(step) <= 0:\n return f\"Invalid step: {field}\"\n return None\n if \",\" in field:\n for part in field.split(\",\"):\n err = _validate_cron_field(part.strip(), lo, hi)\n if err:\n return err\n return None\n if \"-\" in field:\n left, right = field.split(\"-\", 1)\n if not left.isdigit() or not right.isdigit():\n return f\"Invalid range: {field}\"\n a, b = int(left), int(right)\n if a < lo or a > hi or b < lo or b > hi:\n return f\"Range {field} out of bounds [{lo}-{hi}]\"\n if a > b:\n return f\"Range start > end: {field}\"\n return None\n if not field.isdigit():\n return f\"Invalid field: {field}\"\n value = int(field)\n if value < lo or value > hi:\n return f\"Value {value} out of bounds [{lo}-{hi}]\"\n return None\n\n\ndef validate_cron(cron_expr: str) -> str | None:\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return f\"Expected 5 fields, got {len(fields)}\"\n bounds = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 6)]\n names = [\"minute\", \"hour\", \"day-of-month\", \"month\", \"day-of-week\"]\n for field, (lo, hi), name in zip(fields, bounds, names):\n err = _validate_cron_field(field, lo, hi)\n if err:\n return f\"{name}: {err}\"\n return None\n\n\ndef save_durable_jobs():\n durable = [asdict(job) for job in scheduled_jobs.values() if job.durable]\n DURABLE_PATH.write_text(json.dumps(durable, indent=2))\n\n\ndef load_durable_jobs():\n if not DURABLE_PATH.exists():\n return\n try:\n for item in json.loads(DURABLE_PATH.read_text()):\n job = CronJob(**item)\n if not validate_cron(job.cron):\n scheduled_jobs[job.id] = job\n except Exception:\n pass\n\n\ndef schedule_job(cron: str, prompt: str,\n recurring: bool = True, durable: bool = True) -> CronJob | str:\n err = validate_cron(cron)\n if err:\n return err\n job = CronJob(\n id=f\"cron_{random.randint(0, 999999):06d}\",\n cron=cron, prompt=prompt,\n recurring=recurring, durable=durable)\n with cron_lock:\n scheduled_jobs[job.id] = job\n if durable:\n save_durable_jobs()\n return job\n\n\ndef cancel_job(job_id: str) -> str:\n with cron_lock:\n job = scheduled_jobs.pop(job_id, None)\n if not job:\n return f\"Job {job_id} not found\"\n if job.durable:\n save_durable_jobs()\n return f\"Cancelled {job_id}\"\n\n\ndef cron_scheduler_loop():\n while True:\n time.sleep(1)\n now = datetime.now()\n marker = now.strftime(\"%Y-%m-%d %H:%M\")\n with cron_lock:\n for job in list(scheduled_jobs.values()):\n try:\n if cron_matches(job.cron, now) and _last_fired.get(job.id) != marker:\n cron_queue.append(job)\n _last_fired[job.id] = marker\n if not job.recurring:\n scheduled_jobs.pop(job.id, None)\n if job.durable:\n save_durable_jobs()\n except Exception as e:\n print(f\" \\033[31m[cron error] {job.id}: {e}\\033[0m\")\n\n\ndef consume_cron_queue() -> list[CronJob]:\n with cron_lock:\n fired = list(cron_queue)\n cron_queue.clear()\n return fired\n\n\ndef run_schedule_cron(cron: str, prompt: str,\n recurring: bool = True, durable: bool = True) -> str:\n result = schedule_job(cron, prompt, recurring, durable)\n if isinstance(result, str):\n return f\"Error: {result}\"\n return f\"Scheduled {result.id}: '{cron}' -> {prompt}\"\n\n\ndef run_list_crons() -> str:\n with cron_lock:\n jobs = list(scheduled_jobs.values())\n if not jobs:\n return \"No cron jobs.\"\n return \"\\n\".join(\n f\" {job.id}: '{job.cron}' -> {job.prompt[:40]} \"\n f\"[{'recurring' if job.recurring else 'one-shot'}, \"\n f\"{'durable' if job.durable else 'session'}]\"\n for job in jobs)\n\n\ndef run_cancel_cron(job_id: str) -> str:\n return cancel_job(job_id)\n\n\nload_durable_jobs()\nthreading.Thread(target=cron_scheduler_loop, daemon=True).start()\n\n\n# ── MCP System ──\n\n# MCP is modeled as late-bound tools: connect first, then discovered server\n# tools are merged into the normal tool pool with mcp__server__tool names.\nclass MCPClient:\n \"\"\"Discovers and calls tools on an MCP server (mock for teaching).\"\"\"\n\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs: list[dict],\n handlers: dict[str, callable]):\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n try:\n return handler(**args)\n except Exception as e:\n return f\"MCP error: {e}\"\n\n\nmcp_clients: dict[str, MCPClient] = {}\n\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\n\ndef normalize_mcp_name(name: str) -> str:\n \"\"\"Replace non [a-zA-Z0-9_-] with underscore.\"\"\"\n return _DISALLOWED_CHARS.sub('_', name)\n\n\ndef _mock_server_docs():\n client = MCPClient(\"docs\")\n client.register(\n tool_defs=[\n {\"name\": \"search\", \"description\": \"Search documentation. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"query\": {\"type\": \"string\"}},\n \"required\": [\"query\"]}},\n {\"name\": \"get_version\", \"description\": \"Get API version. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n ],\n handlers={\n \"search\": lambda query: f\"[docs] Found 3 results for '{query}'\",\n \"get_version\": lambda: \"[docs] API v2.1.0\",\n })\n return client\n\n\ndef _mock_server_deploy():\n client = MCPClient(\"deploy\")\n client.register(\n tool_defs=[\n {\"name\": \"trigger\",\n \"description\": \"Trigger a deployment. (destructive — requires approval in real CC)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"service\": {\"type\": \"string\"}},\n \"required\": [\"service\"]}},\n {\"name\": \"status\", \"description\": \"Check deployment status. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"service\": {\"type\": \"string\"}},\n \"required\": [\"service\"]}},\n ],\n handlers={\n \"trigger\": lambda service: f\"[deploy] Triggered: {service}\",\n \"status\": lambda service: f\"[deploy] {service}: running (v1.4.2)\",\n })\n return client\n\n\nMOCK_SERVERS = {\n \"docs\": _mock_server_docs,\n \"deploy\": _mock_server_deploy,\n}\n\n\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n available = \", \".join(MOCK_SERVERS.keys())\n return f\"Unknown server '{name}'. Available: {available}\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n tool_names = [t[\"name\"] for t in mcp_client.tools]\n print(f\" \\033[31m[mcp] connected: {name} → {tool_names}\\033[0m\")\n return (f\"Connected to MCP server '{name}'. \"\n f\"Discovered {len(mcp_client.tools)} tools: {', '.join(tool_names)}\")\n\n\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n \"\"\"Merge builtin tools + all MCP tools into one pool.\"\"\"\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append({\n \"name\": prefixed,\n \"description\": tool_def.get(\"description\", \"\"),\n \"input_schema\": tool_def.get(\"inputSchema\", {}),\n })\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw: c.call_tool(t, kw))\n return tools, handlers\n\n\n# ── Lead Worktree Tools ──\n\ndef run_create_worktree(name: str, task_id: str = \"\") -> str:\n return create_worktree(name, task_id)\n\ndef run_remove_worktree(name: str, discard_changes: bool = False) -> str:\n return remove_worktree(name, discard_changes)\n\ndef run_keep_worktree(name: str) -> str:\n return keep_worktree(name)\n\n\n# ── Basic tool handlers ──\n\ndef run_create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> str:\n task = create_task(subject, description, blockedBy)\n deps = f\" (blockedBy: {', '.join(blockedBy)})\" if blockedBy else \"\"\n print(f\" \\033[34m[create] {task.subject}{deps}\\033[0m\")\n return f\"Created {task.id}: {task.subject}{deps}\"\n\n\ndef run_list_tasks() -> str:\n tasks = list_tasks()\n if not tasks:\n return \"No tasks.\"\n return \"\\n\".join(\n f\" {t.id}: {t.subject} [{t.status}]\"\n + (f\" (wt:{t.worktree})\" if t.worktree else \"\")\n for t in tasks)\n\n\ndef run_get_task(task_id: str) -> str:\n try:\n return get_task_json(task_id)\n except FileNotFoundError:\n return f\"Error: task {task_id} not found\"\n\ndef run_claim_task(task_id: str) -> str:\n try:\n return claim_task(task_id, owner=\"agent\")\n except FileNotFoundError:\n return f\"Error: task {task_id} not found\"\n\ndef run_complete_task(task_id: str) -> str:\n try:\n return complete_task(task_id)\n except FileNotFoundError:\n return f\"Error: task {task_id} not found\"\n\ndef run_spawn_teammate(name: str, role: str, prompt: str) -> str:\n return spawn_teammate_thread(name, role, prompt)\n\ndef run_send_message(to: str, content: str) -> str:\n BUS.send(\"lead\", to, content)\n return f\"Sent to {to}\"\n\ndef run_check_inbox() -> str:\n msgs = consume_lead_inbox(route_protocol=True)\n if not msgs:\n return \"(inbox empty)\"\n lines = []\n for m in msgs:\n meta = m.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n tag = f\" [{m['type']} req:{req_id}]\" if req_id else f\" [{m['type']}]\"\n lines.append(f\" [{m['from']}]{tag} {m['content'][:200]}\")\n return \"\\n\".join(lines)\n\ndef run_connect_mcp(name: str) -> str:\n return connect_mcp(name)\n\n\n# ── Tool Definitions ──\n\n# The model sees tool schemas; Python executes handlers. S20 keeps both tables\n# explicit so every added capability is visible in one place.\nBUILTIN_TOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"},\n \"run_in_background\": {\"type\": \"boolean\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"},\n \"offset\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"old_text\": {\"type\": \"string\"},\n \"new_text\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"pattern\": {\"type\": \"string\"}},\n \"required\": [\"pattern\"]}},\n {\"name\": \"todo_write\",\n \"description\": \"Create and manage a task list for the current session.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"todos\": {\"type\": \"array\",\n \"items\": {\"type\": \"object\",\n \"properties\": {\n \"content\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\",\n \"enum\": [\"pending\", \"in_progress\", \"completed\"]}},\n \"required\": [\"content\", \"status\"]}}},\n \"required\": [\"todos\"]}},\n {\"name\": \"task\",\n \"description\": \"Launch a focused subagent. Returns only its final summary.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"description\": {\"type\": \"string\"}},\n \"required\": [\"description\"]}},\n {\"name\": \"load_skill\",\n \"description\": \"Load the full content of a skill by name.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"compact\",\n \"description\": \"Summarize earlier conversation and continue with compacted context.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"focus\": {\"type\": \"string\"}},\n \"required\": []}},\n {\"name\": \"create_task\", \"description\": \"Create a task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"subject\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n \"blockedBy\": {\"type\": \"array\",\n \"items\": {\"type\": \"string\"}}},\n \"required\": [\"subject\"]}},\n {\"name\": \"list_tasks\", \"description\": \"List all tasks.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {}, \"required\": []}},\n {\"name\": \"get_task\", \"description\": \"Get full task details.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"claim_task\", \"description\": \"Claim a pending task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\", \"description\": \"Complete an in-progress task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"schedule_cron\",\n \"description\": (\"Schedule a cron job. cron is 5-field: min hour dom \"\n \"month dow. For one-shot reminders, compute the target \"\n \"minute and set recurring=false.\"),\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"cron\": {\"type\": \"string\"},\n \"prompt\": {\"type\": \"string\"},\n \"recurring\": {\"type\": \"boolean\"},\n \"durable\": {\"type\": \"boolean\"}},\n \"required\": [\"cron\", \"prompt\"]}},\n {\"name\": \"list_crons\", \"description\": \"List registered cron jobs.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {}, \"required\": []}},\n {\"name\": \"cancel_cron\", \"description\": \"Cancel a cron job by ID.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"job_id\": {\"type\": \"string\"}},\n \"required\": [\"job_id\"]}},\n {\"name\": \"spawn_teammate\", \"description\": \"Spawn an autonomous teammate.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"},\n \"role\": {\"type\": \"string\"},\n \"prompt\": {\"type\": \"string\"}},\n \"required\": [\"name\", \"role\", \"prompt\"]}},\n {\"name\": \"send_message\", \"description\": \"Send message to a teammate.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"check_inbox\",\n \"description\": \"Check inbox for messages and protocol responses.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {}, \"required\": []}},\n {\"name\": \"request_shutdown\",\n \"description\": \"Request a teammate to shut down.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"teammate\": {\"type\": \"string\"}},\n \"required\": [\"teammate\"]}},\n {\"name\": \"request_plan\",\n \"description\": \"Ask a teammate to submit a plan.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"teammate\": {\"type\": \"string\"},\n \"task\": {\"type\": \"string\"}},\n \"required\": [\"teammate\", \"task\"]}},\n {\"name\": \"review_plan\",\n \"description\": \"Approve or reject a submitted plan.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"request_id\": {\"type\": \"string\"},\n \"approve\": {\"type\": \"boolean\"},\n \"feedback\": {\"type\": \"string\"}},\n \"required\": [\"request_id\", \"approve\"]}},\n {\"name\": \"create_worktree\",\n \"description\": \"Create an isolated git worktree.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"},\n \"task_id\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"remove_worktree\",\n \"description\": \"Remove a worktree. Refuses if changes exist.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"},\n \"discard_changes\": {\"type\": \"boolean\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"keep_worktree\",\n \"description\": \"Keep a worktree for manual review.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"connect_mcp\",\n \"description\": \"Connect to an MCP server (docs, deploy) and discover tools.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n]\n\nBUILTIN_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"edit_file\": run_edit, \"glob\": run_glob,\n \"todo_write\": run_todo_write, \"task\": spawn_subagent,\n \"load_skill\": load_skill,\n \"create_task\": run_create_task, \"list_tasks\": run_list_tasks,\n \"get_task\": run_get_task,\n \"claim_task\": run_claim_task, \"complete_task\": run_complete_task,\n \"schedule_cron\": run_schedule_cron,\n \"list_crons\": run_list_crons,\n \"cancel_cron\": run_cancel_cron,\n \"spawn_teammate\": run_spawn_teammate,\n \"send_message\": run_send_message, \"check_inbox\": run_check_inbox,\n \"request_shutdown\": run_request_shutdown,\n \"request_plan\": run_request_plan, \"review_plan\": run_review_plan,\n \"create_worktree\": run_create_worktree,\n \"remove_worktree\": run_remove_worktree,\n \"keep_worktree\": run_keep_worktree,\n \"connect_mcp\": run_connect_mcp,\n}\n\n\n# ── Context ──\n\nMEMORY_DIR = WORKDIR / \".memory\"\nMEMORY_INDEX = MEMORY_DIR / \"MEMORY.md\"\n\n\ndef update_context(context: dict, messages: list) -> dict:\n memories = \"\"\n if MEMORY_INDEX.exists():\n memories = MEMORY_INDEX.read_text()[:2000]\n return {\n \"memories\": memories,\n \"connected_mcp\": list(mcp_clients.keys()),\n \"active_teammates\": list(active_teammates.keys()),\n }\n\n\n# ── Agent Loop ──\n\nrounds_since_todo = 0\nagent_lock = threading.Lock()\n\n\ndef prepare_context(messages: list) -> list:\n # Every LLM turn enters through the same context budget pipeline.\n messages[:] = tool_result_budget(messages)\n messages[:] = snip_compact(messages)\n messages[:] = micro_compact(messages)\n if estimate_size(messages) > CONTEXT_LIMIT:\n messages[:] = compact_history(messages)\n return messages\n\n\ndef build_user_content(results: list[dict]) -> list[dict]:\n # Tool results and completed background notifications are both returned to\n # the model as user-side content, matching the tool_result feedback loop.\n content = list(results)\n for note in collect_background_results():\n content.append({\"type\": \"text\", \"text\": note})\n return content\n\n\ndef inject_background_notifications(messages: list):\n notes = collect_background_results()\n if notes:\n messages.append({\"role\": \"user\", \"content\": [\n {\"type\": \"text\", \"text\": note} for note in notes]})\n\n\ndef call_llm(messages: list, context: dict, tools: list,\n state: RecoveryState, max_tokens: int):\n system = assemble_system_prompt(context)\n return with_retry(\n lambda: client.messages.create(\n model=state.current_model,\n system=system,\n messages=messages,\n tools=tools,\n max_tokens=max_tokens),\n state)\n\n\ndef agent_loop(messages: list, context: dict):\n global rounds_since_todo\n tools, handlers = assemble_tool_pool()\n state = RecoveryState()\n max_tokens = DEFAULT_MAX_TOKENS\n\n while True:\n # One cycle: inject scheduled/background work, prepare context, call\n # the model, execute tool_use blocks, append tool_results, repeat.\n fired = consume_cron_queue()\n for job in fired:\n messages.append({\"role\": \"user\",\n \"content\": f\"[Scheduled] {job.prompt}\"})\n print(f\" \\033[35m[cron inject] {job.prompt[:60]}\\033[0m\")\n\n inject_background_notifications(messages)\n\n if rounds_since_todo >= 3:\n messages.append({\"role\": \"user\",\n \"content\": \"Update your todos.\"})\n rounds_since_todo = 0\n\n prepare_context(messages)\n context = update_context(context, messages)\n tools, handlers = assemble_tool_pool()\n\n try:\n response = call_llm(messages, context, tools, state, max_tokens)\n except Exception as e:\n if is_prompt_too_long_error(e) and not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n messages.append({\"role\": \"assistant\", \"content\": [\n {\"type\": \"text\", \"text\": f\"[Error] {type(e).__name__}: {e}\"}]})\n return\n\n if response.stop_reason == \"max_tokens\":\n if not state.has_escalated:\n max_tokens = ESCALATED_MAX_TOKENS\n state.has_escalated = True\n print(f\" \\033[33m[max_tokens] retry with {max_tokens}\\033[0m\")\n continue\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if state.recovery_count < MAX_RECOVERY_RETRIES:\n messages.append({\"role\": \"user\", \"content\": CONTINUATION_PROMPT})\n state.recovery_count += 1\n continue\n return\n\n max_tokens = DEFAULT_MAX_TOKENS\n state.has_escalated = False\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if not has_tool_use(response.content):\n trigger_hooks(\"Stop\", messages)\n return\n\n results = []\n compacted_now = False\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n\n if block.name == \"compact\":\n messages[:] = compact_history(messages)\n messages.append({\"role\": \"user\",\n \"content\": \"[Compacted. Continue with summarized context.]\"})\n compacted_now = True\n break\n\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(blocked)})\n continue\n\n if should_run_background(block.name, block.input):\n bg_id = start_background_task(block, handlers)\n output = (f\"[Background task {bg_id} started] \"\n \"Result will arrive as a task_notification.\")\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output})\n continue\n\n handler = handlers.get(block.name)\n output = call_tool_handler(handler, block.input, block.name)\n trigger_hooks(\"PostToolUse\", block, output)\n print(str(output)[:300])\n\n if block.name == \"todo_write\":\n rounds_since_todo = 0\n else:\n rounds_since_todo += 1\n\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n\n if compacted_now:\n continue\n\n messages.append({\"role\": \"user\", \"content\": build_user_content(results)})\n\n\ndef print_turn_assistants(messages: list, turn_start: int):\n for msg in messages[turn_start:]:\n if msg.get(\"role\") != \"assistant\":\n continue\n for block in msg.get(\"content\", []):\n if getattr(block, \"type\", None) == \"text\":\n terminal_print(block.text)\n\n\ndef cron_autorun_loop(history: list, context: dict):\n while True:\n time.sleep(1)\n fired = consume_cron_queue()\n if not fired:\n continue\n with agent_lock:\n turn_start = len(history)\n for job in fired:\n history.append({\"role\": \"user\",\n \"content\": f\"[Scheduled] {job.prompt}\"})\n terminal_print(\n f\" \\033[35m[cron auto] {job.prompt[:60]}\\033[0m\")\n agent_loop(history, context)\n context.update(update_context(context, history))\n print_turn_assistants(history, turn_start)\n\n\nif __name__ == \"__main__\":\n CLI_ACTIVE = True\n print(\"s20: comprehensive agent\")\n print(\"Enter a question, press Enter to send. Type q to quit.\\n\")\n history = []\n context = update_context({}, [])\n threading.Thread(target=cron_autorun_loop,\n args=(history, context), daemon=True).start()\n while True:\n try:\n query = input(PROMPT)\n except (EOFError, KeyboardInterrupt):\n break\n if query.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n trigger_hooks(\"UserPromptSubmit\", query)\n turn_start = len(history)\n history.append({\"role\": \"user\", \"content\": query})\n with agent_lock:\n agent_loop(history, context)\n context = update_context(context, history)\n print_turn_assistants(history, turn_start)\n\n inbox = consume_lead_inbox(route_protocol=True)\n if inbox:\n def inbox_label(msg):\n req_id = msg.get(\"metadata\", {}).get(\"request_id\", \"\")\n suffix = f\" req:{req_id}\" if req_id else \"\"\n return f\"{msg.get('type', 'message')}{suffix}\"\n\n inbox_text = \"\\n\".join(\n f\"From {m['from']} [{inbox_label(m)}]: \"\n f\"{m['content'][:200]}\" for m in inbox)\n history.append({\"role\": \"user\",\n \"content\": f\"[Inbox]\\n{inbox_text}\"})\n print()\n", + "source": "#!/usr/bin/env python3\n\"\"\"\ns20: Comprehensive Agent — all teaching components in one loop.\n\nRun: python s20_comprehensive/code.py\nNeed: pip install anthropic python-dotenv pyyaml + .env with ANTHROPIC_API_KEY\n\nThis final chapter intentionally puts the earlier teaching mechanisms back\ntogether: dispatch, permission, hooks, todo, subagent, skills, compaction,\nmemory, prompt assembly, error recovery, task graph, background tasks, cron,\nteams, protocols, autonomous agents, worktrees, and MCP.\n\"\"\"\n\nimport ast, json, os, subprocess, time, random, threading, re\nfrom pathlib import Path\nfrom datetime import datetime\nfrom dataclasses import dataclass, asdict, field\nimport yaml\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\n READLINE_AVAILABLE = True\nexcept ImportError:\n READLINE_AVAILABLE = False\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\nPRIMARY_MODEL = MODEL\nFALLBACK_MODEL = os.getenv(\"FALLBACK_MODEL_ID\")\n\nSKILLS_DIR = WORKDIR / \"skills\"\nTRANSCRIPT_DIR = WORKDIR / \".transcripts\"\nTOOL_RESULTS_DIR = WORKDIR / \".task_outputs\" / \"tool-results\"\n\nDEFAULT_MAX_TOKENS = 8000\nESCALATED_MAX_TOKENS = 16000\nMAX_RETRIES = 3\nMAX_CONSECUTIVE_529 = 2\nMAX_RECOVERY_RETRIES = 2\nBASE_DELAY_MS = 500\nCONTEXT_LIMIT = 50000\nKEEP_RECENT_TOOL_RESULTS = 3\nPERSIST_THRESHOLD = 30000\nCONTINUATION_PROMPT = \"Continue from the previous response. Do not repeat completed work.\"\nPROMPT = \"\\033[36ms20 >> \\033[0m\"\nCLI_ACTIVE = False\n\n\ndef terminal_print(text: str):\n if threading.current_thread() is threading.main_thread() or not CLI_ACTIVE:\n print(text)\n return\n line = \"\"\n if READLINE_AVAILABLE:\n try:\n line = readline.get_line_buffer()\n except Exception:\n line = \"\"\n print(f\"\\r\\033[K{text}\")\n print(PROMPT + line, end=\"\", flush=True)\n\n# ── Task System ──\n\n# Tasks are tiny durable records. Later systems add ownership, dependencies,\n# worktrees, and teammates on top of this same file-backed state.\nTASKS_DIR = WORKDIR / \".tasks\"\nTASKS_DIR.mkdir(exist_ok=True)\nCURRENT_TODOS: list[dict] = []\n\n\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str\n owner: str | None\n blockedBy: list[str]\n worktree: str | None = None\n\n\ndef _task_path(task_id: str) -> Path:\n return TASKS_DIR / f\"{task_id}.json\"\n\n\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random.randint(0, 9999):04d}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n\n\ndef save_task(task: Task):\n _task_path(task.id).write_text(json.dumps(asdict(task), indent=2))\n\n\ndef load_task(task_id: str) -> Task:\n return Task(**json.loads(_task_path(task_id).read_text()))\n\n\ndef list_tasks() -> list[Task]:\n return [Task(**json.loads(p.read_text()))\n for p in sorted(TASKS_DIR.glob(\"task_*.json\"))]\n\n\ndef get_task_json(task_id: str) -> str:\n return json.dumps(asdict(load_task(task_id)), indent=2)\n\n\ndef can_start(task_id: str) -> bool:\n # Dependencies are intentionally simple: every blocker must exist and be\n # completed before the task can be claimed.\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False\n if load_task(dep_id).status != \"completed\":\n return False\n return True\n\n\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if task.owner:\n return f\"Task {task_id} already owned by {task.owner}\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if _task_path(d).exists() and load_task(d).status != \"completed\"]\n missing = [d for d in task.blockedBy if not _task_path(d).exists()]\n parts = []\n if deps: parts.append(f\"blocked by: {deps}\")\n if missing: parts.append(f\"missing deps: {missing}\")\n return \"Cannot start — \" + \", \".join(parts)\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n print(f\" \\033[36m[claim] {task.subject} → in_progress\\033[0m\")\n return f\"Claimed {task.id} ({task.subject})\"\n\n\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n if task.status != \"in_progress\":\n return f\"Task {task_id} is {task.status}, cannot complete\"\n task.status = \"completed\"\n save_task(task)\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy and can_start(t.id)]\n print(f\" \\033[32m[complete] {task.subject} ✓\\033[0m\")\n msg = f\"Completed {task.id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n return msg\n\n\n# ── Worktree System ──\n\n# Worktree names become filesystem paths, so the teaching version keeps the\n# validation rules strict and reuses them for create/remove/keep.\nWORKTREES_DIR = WORKDIR / \".worktrees\"\nWORKTREES_DIR.mkdir(exist_ok=True)\n\nVALID_WT_NAME = re.compile(r'^[A-Za-z0-9._-]{1,64}$')\n\n\ndef validate_worktree_name(name: str) -> str | None:\n if not name:\n return \"Worktree name cannot be empty\"\n if name in (\".\", \"..\"):\n return f\"'{name}' is not a valid worktree name\"\n if not VALID_WT_NAME.match(name):\n return (f\"Invalid worktree name '{name}': \"\n \"only letters, digits, dots, underscores, dashes (1-64 chars)\")\n return None\n\n\ndef run_git(args: list[str]) -> tuple[bool, str]:\n try:\n r = subprocess.run([\"git\"] + args, cwd=WORKDIR,\n capture_output=True, text=True, timeout=30)\n out = (r.stdout + r.stderr).strip()\n return r.returncode == 0, out[:5000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return False, \"Error: git timeout\"\n\n\ndef log_event(event_type: str, worktree_name: str, task_id: str = \"\"):\n event = {\"type\": event_type, \"worktree\": worktree_name,\n \"task_id\": task_id, \"ts\": time.time()}\n events_file = WORKTREES_DIR / \"events.jsonl\"\n with open(events_file, \"a\") as f:\n f.write(json.dumps(event) + \"\\n\")\n\n\ndef create_worktree(name: str, task_id: str = \"\") -> str:\n # Tool-layer validation is part of the safety boundary; do it before git\n # sees the name, not only after git happens to reject something.\n err = validate_worktree_name(name)\n if err:\n return f\"Error: {err}\"\n if task_id:\n try:\n load_task(task_id)\n except FileNotFoundError:\n return f\"Error: task {task_id} not found\"\n path = WORKTREES_DIR / name\n if path.exists():\n return f\"Worktree '{name}' already exists at {path}\"\n ok, result = run_git([\"worktree\", \"add\", str(path), \"-b\", f\"wt/{name}\", \"HEAD\"])\n if not ok:\n return f\"Git error: {result}\"\n if task_id:\n bind_task_to_worktree(task_id, name)\n log_event(\"create\", name, task_id)\n print(f\" \\033[33m[worktree] created: {name} at {path}\\033[0m\")\n return f\"Worktree '{name}' created at {path}\"\n\n\ndef bind_task_to_worktree(task_id: str, worktree_name: str):\n task = load_task(task_id)\n task.worktree = worktree_name\n save_task(task)\n\n\ndef _count_worktree_changes(path: Path) -> tuple[int, int]:\n try:\n r1 = subprocess.run([\"git\", \"status\", \"--porcelain\"],\n cwd=path, capture_output=True, text=True, timeout=10)\n files = len([l for l in r1.stdout.strip().splitlines() if l.strip()])\n r2 = subprocess.run([\"git\", \"log\", \"@{push}..HEAD\", \"--oneline\"],\n cwd=path, capture_output=True, text=True, timeout=10)\n commits = len([l for l in r2.stdout.strip().splitlines() if l.strip()])\n return files, commits\n except Exception:\n return -1, -1\n\n\ndef remove_worktree(name: str, discard_changes: bool = False) -> str:\n err = validate_worktree_name(name)\n if err:\n return err\n path = WORKTREES_DIR / name\n if not path.exists():\n return f\"Worktree '{name}' not found\"\n if not discard_changes:\n files, commits = _count_worktree_changes(path)\n if files < 0:\n return \"Cannot verify status. Use discard_changes=true to force.\"\n if files > 0 or commits > 0:\n return (f\"Worktree '{name}' has {files} file(s), {commits} commit(s). \"\n \"Use discard_changes=true or keep_worktree.\")\n ok1, _ = run_git([\"worktree\", \"remove\", str(path), \"--force\"])\n if not ok1:\n return f\"Failed to remove worktree '{name}'\"\n run_git([\"branch\", \"-D\", f\"wt/{name}\"])\n log_event(\"remove\", name)\n print(f\" \\033[33m[worktree] removed: {name}\\033[0m\")\n return f\"Worktree '{name}' removed\"\n\n\ndef keep_worktree(name: str) -> str:\n err = validate_worktree_name(name)\n if err:\n return err\n log_event(\"keep\", name)\n return f\"Worktree '{name}' kept for review (branch: wt/{name})\"\n\n\n# ── Skill Loading ──\n\nSKILL_REGISTRY: dict[str, dict] = {}\n\n\ndef _parse_frontmatter(text: str) -> tuple[dict, str]:\n if not text.startswith(\"---\"):\n return {}, text\n parts = text.split(\"---\", 2)\n if len(parts) < 3:\n return {}, text\n try:\n meta = yaml.safe_load(parts[1]) or {}\n except yaml.YAMLError:\n meta = {}\n return meta, parts[2].strip()\n\n\ndef scan_skills():\n SKILL_REGISTRY.clear()\n if not SKILLS_DIR.exists():\n return\n for directory in sorted(SKILLS_DIR.iterdir()):\n if not directory.is_dir():\n continue\n manifest = directory / \"SKILL.md\"\n if not manifest.exists():\n continue\n raw = manifest.read_text()\n meta, _ = _parse_frontmatter(raw)\n name = meta.get(\"name\", directory.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\n \"name\": name,\n \"description\": desc,\n \"content\": raw,\n }\n\n\nscan_skills()\n\n\ndef list_skills() -> str:\n if not SKILL_REGISTRY:\n return \"(no skills found)\"\n return \"\\n\".join(\n f\"- {skill['name']}: {skill['description']}\"\n for skill in SKILL_REGISTRY.values())\n\n\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n available = \", \".join(SKILL_REGISTRY.keys()) or \"(none)\"\n return f\"Skill not found: {name}. Available: {available}\"\n return skill[\"content\"]\n\n\n# ── Prompt Assembly ──\n\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file, edit_file, glob, \"\n \"todo_write, task, load_skill, compact, \"\n \"create_task, list_tasks, get_task, claim_task, complete_task, \"\n \"schedule_cron, list_crons, cancel_cron, \"\n \"spawn_teammate, send_message, check_inbox, \"\n \"request_shutdown, request_plan, review_plan, \"\n \"create_worktree, remove_worktree, keep_worktree, \"\n \"connect_mcp. MCP tools are prefixed mcp__{server}__{tool}.\",\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n\n\ndef assemble_system_prompt(context: dict) -> str:\n # The system prompt is rebuilt each turn from live context. This is where\n # memory, skill catalog, MCP state, and active teammates become visible.\n sections = [PROMPT_SECTIONS[\"identity\"],\n PROMPT_SECTIONS[\"tools\"],\n PROMPT_SECTIONS[\"workspace\"]]\n sections.append(f\"Current time: {datetime.now().isoformat(timespec='seconds')}\")\n sections.append(\"Skills catalog:\\n\" + list_skills() +\n \"\\nUse load_skill(name) when a skill is relevant.\")\n if context.get(\"memories\"):\n sections.append(f\"Relevant memories:\\n{context['memories']}\")\n mcp_names = list(mcp_clients.keys())\n if mcp_names:\n sections.append(f\"Connected MCP servers: {', '.join(mcp_names)}\")\n return \"\\n\\n\".join(sections)\n\n\n# ── Basic Tools ──\n\ndef safe_path(p: str, cwd: Path = None) -> Path:\n # File tools stay inside the workspace or teammate worktree. Bash remains\n # powerful on purpose and is controlled by the permission hook instead.\n base = cwd or WORKDIR\n path = (base / p).resolve()\n if not path.is_relative_to(base):\n raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\n\ndef run_bash(command: str, cwd: Path = None,\n run_in_background: bool = False) -> str:\n # run_in_background is consumed by the dispatcher; direct execution ignores it.\n try:\n r = subprocess.run(command, shell=True, cwd=cwd or WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\n\ndef run_read(path: str, limit: int | None = None,\n offset: int = 0, cwd: Path = None) -> str:\n try:\n lines = safe_path(path, cwd).read_text().splitlines()\n offset = max(int(offset or 0), 0)\n limit = int(limit) if limit is not None else None\n lines = lines[offset:]\n if limit is not None and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_write(path: str, content: str, cwd: Path = None) -> str:\n try:\n fp = safe_path(path, cwd)\n fp.parent.mkdir(parents=True, exist_ok=True)\n fp.write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_edit(path: str, old_text: str, new_text: str,\n cwd: Path = None) -> str:\n try:\n fp = safe_path(path, cwd)\n text = fp.read_text()\n if old_text not in text:\n return f\"Error: text not found in {path}\"\n fp.write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_glob(pattern: str, cwd: Path = None) -> str:\n import glob as g\n try:\n base = cwd or WORKDIR\n results = []\n for match in g.glob(pattern, root_dir=base):\n if (base / match).resolve().is_relative_to(base):\n results.append(match)\n return \"\\n\".join(results) if results else \"(no matches)\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef call_tool_handler(handler, args: dict, name: str) -> str:\n if not handler:\n return f\"Unknown: {name}\"\n try:\n return handler(**(args or {}))\n except TypeError as e:\n return f\"Error: {e}\"\n\n\ndef _normalize_todos(todos):\n if isinstance(todos, str):\n try:\n todos = json.loads(todos)\n except json.JSONDecodeError:\n try:\n todos = ast.literal_eval(todos)\n except (SyntaxError, ValueError):\n return None, \"Error: todos must be a list or JSON array string\"\n if not isinstance(todos, list):\n return None, \"Error: todos must be a list\"\n for i, todo in enumerate(todos):\n if not isinstance(todo, dict):\n return None, f\"Error: todos[{i}] must be an object\"\n if \"content\" not in todo or \"status\" not in todo:\n return None, f\"Error: todos[{i}] missing 'content' or 'status'\"\n if todo[\"status\"] not in (\"pending\", \"in_progress\", \"completed\"):\n return None, f\"Error: todos[{i}] has invalid status '{todo['status']}'\"\n return todos, None\n\ndef run_todo_write(todos: list) -> str:\n global CURRENT_TODOS\n todos, error = _normalize_todos(todos)\n if error:\n return error\n CURRENT_TODOS = todos\n print(f\" \\033[33m[todo] updated {len(CURRENT_TODOS)} item(s)\\033[0m\")\n return f\"Updated {len(CURRENT_TODOS)} todos\"\n\n\n# ── MessageBus ──\n\n# Team communication is append-only JSONL mailboxes. This keeps the protocol\n# inspectable on disk and lets background teammates send messages.\nMAILBOX_DIR = WORKDIR / \".mailboxes\"\nMAILBOX_DIR.mkdir(exist_ok=True)\n\n\nclass MessageBus:\n def send(self, from_agent: str, to_agent: str, content: str,\n msg_type: str = \"message\", metadata: dict = None):\n msg = {\"from\": from_agent, \"to\": to_agent,\n \"content\": content, \"type\": msg_type,\n \"ts\": time.time(), \"metadata\": metadata or {}}\n inbox = MAILBOX_DIR / f\"{to_agent}.jsonl\"\n with open(inbox, \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n terminal_print(f\" \\033[33m[bus] {from_agent} → {to_agent}: \"\n f\"({msg_type}) {content[:50]}\\033[0m\")\n\n def read_inbox(self, agent: str) -> list[dict]:\n inbox = MAILBOX_DIR / f\"{agent}.jsonl\"\n if not inbox.exists():\n return []\n msgs = [json.loads(line) for line in inbox.read_text().splitlines()\n if line.strip()]\n inbox.unlink()\n return msgs\n\n\nBUS = MessageBus()\nactive_teammates: dict[str, bool] = {}\n\n# ── Protocol State ──\n\n@dataclass\nclass ProtocolState:\n request_id: str\n type: str\n sender: str\n target: str\n status: str\n payload: str\n created_at: float = field(default_factory=time.time)\n\n\npending_requests: dict[str, ProtocolState] = {}\n\n\ndef new_request_id() -> str:\n return f\"req_{random.randint(0, 999999):06d}\"\n\n\ndef match_response(response_type: str, request_id: str, approve: bool):\n # Responses are matched by request_id so one protocol reply cannot approve\n # a different pending request.\n state = pending_requests.get(request_id)\n if not state:\n return\n if state.type == \"shutdown\" and response_type != \"shutdown_response\":\n return\n if state.type == \"plan_approval\" and response_type != \"plan_approval_response\":\n return\n state.status = \"approved\" if approve else \"rejected\"\n\n\ndef consume_lead_inbox(route_protocol=True) -> list[dict]:\n msgs = BUS.read_inbox(\"lead\")\n if route_protocol:\n for msg in msgs:\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n msg_type = msg.get(\"type\", \"\")\n if req_id and msg_type.endswith(\"_response\"):\n match_response(msg_type, req_id, meta.get(\"approve\", False))\n return msgs\n\n\n# ── Autonomous Agent ──\n\nIDLE_POLL_INTERVAL = 5\nIDLE_TIMEOUT = 60\n\n\ndef scan_unclaimed_tasks() -> list[dict]:\n unclaimed = []\n for f in sorted(TASKS_DIR.glob(\"task_*.json\")):\n task = json.loads(f.read_text())\n if (task.get(\"status\") == \"pending\"\n and not task.get(\"owner\")\n and can_start(task[\"id\"])):\n unclaimed.append(task)\n return unclaimed\n\n\ndef idle_poll(agent_name: str, messages: list,\n name: str, role: str,\n worktree_context: dict | None = None) -> str:\n # Autonomous teammates wake up for inbox messages first, then look for\n # unclaimed tasks. This keeps direct protocol messages higher priority.\n for _ in range(IDLE_TIMEOUT // IDLE_POLL_INTERVAL):\n time.sleep(IDLE_POLL_INTERVAL)\n inbox = BUS.read_inbox(agent_name)\n if inbox:\n for msg in inbox:\n if msg.get(\"type\") == \"shutdown_request\":\n req_id = msg.get(\"metadata\", {}).get(\"request_id\", \"\")\n BUS.send(name, \"lead\", \"Shutting down.\",\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n return \"shutdown\"\n messages.append({\"role\": \"user\",\n \"content\": \"\" + json.dumps(inbox) + \"\"})\n return \"work\"\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n task_data = unclaimed[0]\n result = claim_task(task_data[\"id\"], agent_name)\n if \"Claimed\" in result:\n wt_info = \"\"\n if task_data.get(\"worktree\"):\n wt_path = WORKTREES_DIR / task_data[\"worktree\"]\n wt_info = f\"\\nWork directory: {wt_path}\"\n if worktree_context is not None:\n worktree_context[\"path\"] = str(wt_path)\n messages.append({\"role\": \"user\",\n \"content\": f\"Task {task_data['id']}: \"\n f\"{task_data['subject']}{wt_info}\"})\n return \"work\"\n return \"timeout\"\n\n\n# ── Teammate Thread ──\n\ndef spawn_teammate_thread(name: str, role: str, prompt: str) -> str:\n if name in active_teammates:\n return f\"Teammate '{name}' already exists\"\n\n # Plan approval is a real gate: after submit_plan, the teammate stops\n # taking model/tool steps until lead sends plan_approval_response.\n protocol_ctx = {\"waiting_plan\": None}\n system = (f\"You are '{name}', a {role}. \"\n f\"Use tools to complete tasks. \"\n f\"If a task has a worktree, work in that directory.\")\n\n def handle_inbox_message(name: str, msg: dict, messages: list):\n msg_type = msg.get(\"type\", \"message\")\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n if msg_type == \"shutdown_request\":\n BUS.send(name, \"lead\", \"Shutting down.\",\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n return True\n if msg_type == \"plan_approval_response\":\n approve = meta.get(\"approve\", False)\n if req_id == protocol_ctx[\"waiting_plan\"]:\n protocol_ctx[\"waiting_plan\"] = None\n messages.append({\"role\": \"user\",\n \"content\": \"[Plan approved]\" if approve\n else f\"[Plan rejected] {msg['content']}\"})\n return False\n\n def run():\n wt_ctx = {\"path\": None}\n\n def _wt_cwd():\n # Once a task with a worktree is claimed, all teammate file tools\n # transparently run inside that isolated directory.\n p = wt_ctx[\"path\"]\n return Path(p) if p else None\n\n def _run_bash(command: str) -> str:\n return run_bash(command, cwd=_wt_cwd())\n\n def _run_read(path: str) -> str:\n return run_read(path, cwd=_wt_cwd())\n\n def _run_write(path: str, content: str) -> str:\n return run_write(path, content, cwd=_wt_cwd())\n\n def _run_list_tasks():\n tasks = list_tasks()\n if not tasks:\n return \"No tasks.\"\n return \"\\n\".join(\n f\" {t.id}: {t.subject} [{t.status}]\"\n + (f\" (wt:{t.worktree})\" if t.worktree else \"\")\n for t in tasks)\n\n def _run_claim_task(task_id: str):\n result = claim_task(task_id, owner=name)\n if \"Claimed\" in result:\n task = load_task(task_id)\n wt_ctx[\"path\"] = (str(WORKTREES_DIR / task.worktree)\n if task.worktree else None)\n return result\n\n def _run_complete_task(task_id: str):\n result = complete_task(task_id)\n wt_ctx[\"path\"] = None\n return result\n\n messages = [{\"role\": \"user\", \"content\": prompt}]\n sub_tools = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"},\n \"offset\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"send_message\",\n \"description\": \"Send message to another agent.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"submit_plan\",\n \"description\": \"Submit a plan for Lead approval.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"plan\": {\"type\": \"string\"}},\n \"required\": [\"plan\"]}},\n {\"name\": \"list_tasks\",\n \"description\": \"List all tasks.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n {\"name\": \"claim_task\",\n \"description\": \"Claim a pending task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\",\n \"description\": \"Mark an in-progress task as completed.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n ]\n\n sub_handlers = {\n \"bash\": _run_bash, \"read_file\": _run_read,\n \"write_file\": _run_write,\n \"send_message\": lambda to, content: (BUS.send(name, to, content),\n \"Sent\")[1],\n \"list_tasks\": _run_list_tasks,\n \"claim_task\": _run_claim_task,\n \"complete_task\": _run_complete_task,\n }\n\n while True:\n if len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"You are '{name}', role: {role}. \"\n f\"Continue your work.\"})\n should_shutdown = False\n for _ in range(10):\n inbox = BUS.read_inbox(name)\n for msg in inbox:\n stopped = handle_inbox_message(name, msg, messages)\n if stopped:\n should_shutdown = True\n break\n if should_shutdown:\n break\n if protocol_ctx[\"waiting_plan\"]:\n # Poll only for protocol replies while the approval gate is\n # closed; do not let the model continue with the task.\n time.sleep(IDLE_POLL_INTERVAL)\n continue\n if inbox and not should_shutdown:\n non_protocol = [m for m in inbox\n if m.get(\"type\") == \"message\"]\n if non_protocol:\n messages.append({\"role\": \"user\",\n \"content\": \"\" + json.dumps(non_protocol) + \"\"})\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages[-20:],\n tools=sub_tools, max_tokens=8000)\n except Exception:\n break\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if not has_tool_use(response.content):\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n if block.name == \"submit_plan\":\n output = _teammate_submit_plan(\n name, block.input.get(\"plan\", \"\"))\n match = re.search(r\"\\((req_\\d+)\\)\", output)\n protocol_ctx[\"waiting_plan\"] = (\n match.group(1) if match else output)\n else:\n handler = sub_handlers.get(block.name)\n output = call_tool_handler(handler, block.input,\n block.name)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)})\n if protocol_ctx[\"waiting_plan\"]:\n # Ignore later tool_use blocks from the same model\n # response; they belong after approval, not before.\n break\n messages.append({\"role\": \"user\", \"content\": results})\n if protocol_ctx[\"waiting_plan\"]:\n break\n if should_shutdown:\n break\n if protocol_ctx[\"waiting_plan\"]:\n continue\n idle_result = idle_poll(name, messages, name, role, wt_ctx)\n if idle_result in (\"shutdown\", \"timeout\"):\n break\n\n summary = \"Done.\"\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\" and isinstance(msg[\"content\"], list):\n for b in msg[\"content\"]:\n if getattr(b, \"type\", None) == \"text\":\n summary = b.text\n break\n else:\n continue\n break\n BUS.send(name, \"lead\", summary, \"result\")\n active_teammates.pop(name, None)\n\n active_teammates[name] = True\n threading.Thread(target=run, daemon=True).start()\n return f\"Teammate '{name}' spawned as {role}\"\n\n\ndef _teammate_submit_plan(from_name: str, plan: str) -> str:\n req_id = new_request_id()\n pending_requests[req_id] = ProtocolState(\n request_id=req_id, type=\"plan_approval\",\n sender=from_name, target=\"lead\",\n status=\"pending\", payload=plan)\n BUS.send(from_name, \"lead\", plan,\n \"plan_approval_request\",\n {\"request_id\": req_id})\n return f\"Plan submitted ({req_id})\"\n\n\n# ── Lead Protocol Tools ──\n\ndef run_request_shutdown(teammate: str) -> str:\n req_id = new_request_id()\n pending_requests[req_id] = ProtocolState(\n request_id=req_id, type=\"shutdown\",\n sender=\"lead\", target=teammate,\n status=\"pending\", payload=\"\")\n BUS.send(\"lead\", teammate, \"Shut down.\", \"shutdown_request\",\n {\"request_id\": req_id})\n return f\"Shutdown request sent to {teammate}\"\n\n\ndef run_request_plan(teammate: str, task: str) -> str:\n BUS.send(\"lead\", teammate, f\"Submit plan for: {task}\", \"message\")\n return f\"Asked {teammate} to submit a plan\"\n\n\ndef run_review_plan(request_id: str, approve: bool,\n feedback: str = \"\") -> str:\n state = pending_requests.get(request_id)\n if not state:\n return f\"Request {request_id} not found\"\n state.status = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", state.sender,\n feedback or (\"Approved\" if approve else \"Rejected\"),\n \"plan_approval_response\",\n {\"request_id\": request_id, \"approve\": approve})\n return f\"Plan {'approved' if approve else 'rejected'}\"\n\n\n# ── Hooks + Permission Pipeline ──\n\n# Hooks are intentionally outside tool handlers. The loop can add permission,\n# logging, and stop behavior without changing each individual tool.\nHOOKS = {\"UserPromptSubmit\": [], \"PreToolUse\": [],\n \"PostToolUse\": [], \"Stop\": []}\n\n\ndef register_hook(event: str, callback):\n HOOKS[event].append(callback)\n\n\ndef trigger_hooks(event: str, *args):\n for callback in HOOKS[event]:\n result = callback(*args)\n if result is not None:\n return result\n return None\n\n\nDENY_LIST = [\"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\", \"mkfs\", \"dd if=\"]\nDESTRUCTIVE = [\"rm \", \"> /etc/\", \"chmod 777\"]\n\n\ndef permission_hook(block):\n # The permission layer sees the raw tool_use before dispatch. It can deny,\n # ask the user, or allow execution to continue.\n if block.name == \"bash\":\n command = block.input.get(\"command\", \"\")\n for pattern in DENY_LIST:\n if pattern in command:\n return f\"Permission denied: '{pattern}' is on the deny list\"\n if any(token in command for token in DESTRUCTIVE):\n print(f\"\\n\\033[33m[permission] destructive command\\033[0m\")\n print(f\" {command}\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n if block.name in (\"write_file\", \"edit_file\"):\n path = block.input.get(\"path\", \"\")\n try:\n safe_path(path)\n except Exception:\n return f\"Permission denied: path escapes workspace: {path}\"\n if block.name.startswith(\"mcp__\") and \"deploy\" in block.name:\n print(f\"\\n\\033[33m[permission] MCP destructive-looking tool: {block.name}\\033[0m\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n return None\n\n\ndef log_hook(block):\n print(f\"\\033[90m[HOOK] {block.name}\\033[0m\")\n return None\n\n\ndef large_output_hook(block, output):\n if len(str(output)) > 100000:\n print(f\"\\033[33m[HOOK] large output from {block.name}: \"\n f\"{len(str(output))} chars\\033[0m\")\n return None\n\n\ndef user_prompt_hook(query: str):\n print(f\"\\033[90m[HOOK] UserPromptSubmit: {WORKDIR}\\033[0m\")\n return None\n\n\ndef stop_hook(messages: list):\n tool_count = 0\n for msg in messages:\n content = msg.get(\"content\")\n if isinstance(content, list):\n tool_count += sum(1 for item in content\n if isinstance(item, dict)\n and item.get(\"type\") == \"tool_result\")\n print(f\"\\033[90m[HOOK] Stop: {tool_count} tool result(s)\\033[0m\")\n return None\n\n\nregister_hook(\"UserPromptSubmit\", user_prompt_hook)\nregister_hook(\"PreToolUse\", permission_hook)\nregister_hook(\"PreToolUse\", log_hook)\nregister_hook(\"PostToolUse\", large_output_hook)\nregister_hook(\"Stop\", stop_hook)\n\n\n# ── Subagent Tool ──\n\nSUB_SYSTEM = (\n f\"You are a coding subagent at {WORKDIR}. \"\n \"Complete the task, then return a concise final summary. \"\n \"Do not spawn more agents.\"\n)\n\n\nSUB_TOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"},\n \"offset\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"old_text\": {\"type\": \"string\"},\n \"new_text\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"pattern\": {\"type\": \"string\"}},\n \"required\": [\"pattern\"]}},\n]\n\n\nSUB_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read,\n \"write_file\": run_write, \"edit_file\": run_edit,\n \"glob\": run_glob,\n}\n\n\ndef extract_text(content) -> str:\n if not isinstance(content, list):\n return str(content)\n return \"\\n\".join(\n getattr(block, \"text\", \"\")\n for block in content\n if getattr(block, \"type\", None) == \"text\").strip()\n\n\ndef has_tool_use(content) -> bool:\n # Do not rely on stop_reason alone; the concrete tool_use block is the\n # continuation signal used by the loop.\n return any(getattr(block, \"type\", None) == \"tool_use\"\n for block in content)\n\n\ndef spawn_subagent(description: str) -> str:\n messages = [{\"role\": \"user\", \"content\": description}]\n for _ in range(30):\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM, messages=messages,\n tools=SUB_TOOLS, max_tokens=8000)\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if not has_tool_use(response.content):\n break\n results = []\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n output = str(blocked)\n else:\n handler = SUB_HANDLERS.get(block.name)\n output = call_tool_handler(handler, block.input, block.name)\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)})\n messages.append({\"role\": \"user\", \"content\": results})\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\":\n text = extract_text(msg[\"content\"])\n if text:\n return text\n return \"Subagent finished without a text summary.\"\n\n\n# ── Context Compaction ──\n\n# Compaction is layered: first shrink oversized tool results, then trim old\n# message ranges, and only call the model for a summary when the context is\n# still too large or the model explicitly asks for compact.\ndef estimate_size(messages: list) -> int:\n return len(json.dumps(messages, default=str))\n\ndef block_type(block):\n return block.get(\"type\") if isinstance(block, dict) else getattr(block, \"type\", None)\n\n\ndef message_has_tool_use(message: dict) -> bool:\n if message.get(\"role\") != \"assistant\":\n return False\n content = message.get(\"content\")\n if not isinstance(content, list):\n return False\n return any(block_type(block) == \"tool_use\" for block in content)\n\n\ndef is_tool_result_message(message: dict) -> bool:\n if message.get(\"role\") != \"user\":\n return False\n content = message.get(\"content\")\n if not isinstance(content, list):\n return False\n return any(isinstance(block, dict) and block.get(\"type\") == \"tool_result\"\n for block in content)\n\n\ndef collect_tool_results(messages: list):\n found = []\n for mi, msg in enumerate(messages):\n content = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n continue\n for bi, block in enumerate(content):\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n found.append((mi, bi, block))\n return found\n\n\ndef persist_large_output(tool_use_id: str, output: str) -> str:\n if len(output) <= PERSIST_THRESHOLD:\n return output\n TOOL_RESULTS_DIR.mkdir(parents=True, exist_ok=True)\n path = TOOL_RESULTS_DIR / f\"{tool_use_id}.txt\"\n if not path.exists():\n path.write_text(output)\n return (f\"\\nFull output: {path}\\n\"\n f\"Preview:\\n{output[:2000]}\\n\")\n\n\ndef tool_result_budget(messages: list, max_bytes: int = 200_000) -> list:\n if not messages:\n return messages\n last = messages[-1]\n content = last.get(\"content\")\n if last.get(\"role\") != \"user\" or not isinstance(content, list):\n return messages\n blocks = [(i, b) for i, b in enumerate(content)\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes:\n return messages\n for _, block in sorted(blocks,\n key=lambda pair: len(str(pair[1].get(\"content\", \"\"))),\n reverse=True):\n if total <= max_bytes:\n break\n text = str(block.get(\"content\", \"\"))\n block[\"content\"] = persist_large_output(\n block.get(\"tool_use_id\", \"unknown\"), text)\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return messages\n\n\ndef snip_compact(messages: list, max_messages: int = 50) -> list:\n if len(messages) <= max_messages:\n return messages\n head_end, tail_start = 3, len(messages) - (max_messages - 3)\n if head_end > 0 and message_has_tool_use(messages[head_end - 1]):\n while head_end < len(messages) and is_tool_result_message(messages[head_end]):\n head_end += 1\n if (tail_start > 0 and tail_start < len(messages)\n and is_tool_result_message(messages[tail_start])\n and message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n if head_end >= tail_start:\n return messages\n snipped = tail_start - head_end\n return (messages[:head_end]\n + [{\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}]\n + messages[tail_start:])\n\n\ndef micro_compact(messages: list) -> list:\n tool_results = collect_tool_results(messages)\n if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS:\n return messages\n for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]:\n if len(str(block.get(\"content\", \"\"))) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n\n\ndef write_transcript(messages: list) -> Path:\n TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)\n path = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with path.open(\"w\") as f:\n for msg in messages:\n f.write(json.dumps(msg, default=str) + \"\\n\")\n return path\n\n\ndef summarize_history(messages: list) -> str:\n conversation = json.dumps(messages, default=str)[:80000]\n prompt = (\"Summarize this coding-agent conversation so work can continue. \"\n \"Preserve current goal, key findings, changed files, remaining work, \"\n \"and user constraints.\\n\\n\" + conversation)\n response = client.messages.create(\n model=MODEL,\n messages=[{\"role\": \"user\", \"content\": prompt}],\n max_tokens=2000)\n return extract_text(response.content) or \"(empty summary)\"\n\n\ndef compact_history(messages: list) -> list:\n transcript = write_transcript(messages)\n print(f\" \\033[36m[compact] transcript saved: {transcript}\\033[0m\")\n summary = summarize_history(messages)\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n\n\ndef reactive_compact(messages: list) -> list:\n transcript = write_transcript(messages)\n print(f\" \\033[31m[reactive compact] transcript saved: {transcript}\\033[0m\")\n tail_start = max(0, len(messages) - 5)\n if (tail_start > 0 and tail_start < len(messages)\n and is_tool_result_message(messages[tail_start])\n and message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n try:\n summary = summarize_history(messages[:tail_start])\n except Exception:\n summary = \"Earlier conversation was trimmed after a prompt-too-long error.\"\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"},\n *messages[tail_start:]]\n\n\n# ── Error Recovery ──\n\nclass RecoveryState:\n def __init__(self):\n self.has_escalated = False\n self.recovery_count = 0\n self.consecutive_529 = 0\n self.has_attempted_reactive_compact = False\n self.current_model = PRIMARY_MODEL\n\n\ndef retry_delay(attempt: int) -> float:\n base = min(BASE_DELAY_MS * (2 ** attempt), 32000) / 1000\n return base + random.uniform(0, base * 0.25)\n\n\ndef with_retry(fn, state: RecoveryState):\n for attempt in range(MAX_RETRIES):\n try:\n result = fn()\n state.consecutive_529 = 0\n return result\n except Exception as e:\n name = type(e).__name__.lower()\n msg = str(e).lower()\n if \"ratelimit\" in name or \"429\" in msg:\n delay = retry_delay(attempt)\n print(f\" \\033[33m[429] retry {attempt + 1}/{MAX_RETRIES} \"\n f\"after {delay:.1f}s\\033[0m\")\n time.sleep(delay)\n continue\n if \"overloaded\" in name or \"529\" in msg or \"overloaded\" in msg:\n state.consecutive_529 += 1\n if state.consecutive_529 >= MAX_CONSECUTIVE_529 and FALLBACK_MODEL:\n state.current_model = FALLBACK_MODEL\n state.consecutive_529 = 0\n print(f\" \\033[31m[529] switching to {FALLBACK_MODEL}\\033[0m\")\n delay = retry_delay(attempt)\n print(f\" \\033[33m[529] retry {attempt + 1}/{MAX_RETRIES} \"\n f\"after {delay:.1f}s\\033[0m\")\n time.sleep(delay)\n continue\n raise\n raise RuntimeError(f\"Max retries ({MAX_RETRIES}) exceeded\")\n\n\ndef is_prompt_too_long_error(e: Exception) -> bool:\n msg = str(e).lower()\n return ((\"prompt\" in msg and \"long\" in msg)\n or \"context_length_exceeded\" in msg\n or \"max_context_window\" in msg)\n\n\n# ── Background Tasks ──\n\n# Slow tools return a placeholder tool_result immediately. Their real output is\n# later injected as a task_notification, so the main loop can keep moving.\n_bg_counter = 0\nbackground_tasks: dict[str, dict] = {}\nbackground_results: dict[str, str] = {}\nbackground_lock = threading.Lock()\n\n\ndef is_slow_operation(tool_name: str, tool_input: dict) -> bool:\n if tool_name != \"bash\":\n return False\n command = tool_input.get(\"command\", \"\").lower()\n slow_keywords = [\"install\", \"build\", \"test\", \"deploy\", \"compile\",\n \"docker build\", \"pip install\", \"npm install\",\n \"cargo build\", \"pytest\", \"make\"]\n return any(keyword in command for keyword in slow_keywords)\n\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n if tool_name != \"bash\":\n return False\n return bool(tool_input.get(\"run_in_background\")) or is_slow_operation(tool_name, tool_input)\n\n\ndef start_background_task(block, handlers: dict) -> str:\n global _bg_counter\n _bg_counter += 1\n bg_id = f\"bg_{_bg_counter:04d}\"\n command = block.input.get(\"command\", block.name)\n\n def worker():\n handler = handlers.get(block.name)\n result = call_tool_handler(handler, block.input, block.name)\n trigger_hooks(\"PostToolUse\", block, result)\n with background_lock:\n background_tasks[bg_id][\"status\"] = \"completed\"\n background_results[bg_id] = str(result)\n\n with background_lock:\n background_tasks[bg_id] = {\n \"tool_use_id\": block.id,\n \"command\": command,\n \"status\": \"running\",\n }\n threading.Thread(target=worker, daemon=True).start()\n print(f\" \\033[33m[background] {bg_id}: {str(command)[:60]}\\033[0m\")\n return bg_id\n\n\ndef collect_background_results() -> list[str]:\n with background_lock:\n ready = [bg_id for bg_id, task in background_tasks.items()\n if task[\"status\"] == \"completed\"]\n notifications = []\n for bg_id in ready:\n with background_lock:\n task = background_tasks.pop(bg_id)\n output = background_results.pop(bg_id, \"\")\n summary = output[:200] if len(output) > 200 else output\n notifications.append(\n f\"\\n\"\n f\" {bg_id}\\n\"\n f\" completed\\n\"\n f\" {task['command']}\\n\"\n f\" {summary}\\n\"\n f\"\")\n return notifications\n\n\n# ── Cron Scheduler ──\n\n# Cron jobs are stored separately from conversation history. When a job fires,\n# it becomes a scheduled prompt that is injected back into the same agent loop.\nDURABLE_PATH = WORKDIR / \".scheduled_tasks.json\"\n\n\n@dataclass\nclass CronJob:\n id: str\n cron: str\n prompt: str\n recurring: bool\n durable: bool\n\n\nscheduled_jobs: dict[str, CronJob] = {}\ncron_queue: list[CronJob] = []\ncron_lock = threading.Lock()\n_last_fired: dict[str, str] = {}\n\n\ndef _cron_field_matches(field: str, value: int) -> bool:\n if field == \"*\":\n return True\n if field.startswith(\"*/\"):\n step = int(field[2:])\n return step > 0 and value % step == 0\n if \",\" in field:\n return any(_cron_field_matches(part.strip(), value)\n for part in field.split(\",\"))\n if \"-\" in field:\n lo, hi = field.split(\"-\", 1)\n return int(lo) <= value <= int(hi)\n return value == int(field)\n\n\ndef cron_matches(cron_expr: str, dt: datetime) -> bool:\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return False\n minute, hour, dom, month, dow = fields\n dow_val = (dt.weekday() + 1) % 7\n m = _cron_field_matches(minute, dt.minute)\n h = _cron_field_matches(hour, dt.hour)\n dom_ok = _cron_field_matches(dom, dt.day)\n month_ok = _cron_field_matches(month, dt.month)\n dow_ok = _cron_field_matches(dow, dow_val)\n if not (m and h and month_ok):\n return False\n if dom == \"*\" and dow == \"*\":\n return True\n if dom == \"*\":\n return dow_ok\n if dow == \"*\":\n return dom_ok\n return dom_ok or dow_ok\n\n\ndef _validate_cron_field(field: str, lo: int, hi: int) -> str | None:\n if field == \"*\":\n return None\n if field.startswith(\"*/\"):\n step = field[2:]\n if not step.isdigit() or int(step) <= 0:\n return f\"Invalid step: {field}\"\n return None\n if \",\" in field:\n for part in field.split(\",\"):\n err = _validate_cron_field(part.strip(), lo, hi)\n if err:\n return err\n return None\n if \"-\" in field:\n left, right = field.split(\"-\", 1)\n if not left.isdigit() or not right.isdigit():\n return f\"Invalid range: {field}\"\n a, b = int(left), int(right)\n if a < lo or a > hi or b < lo or b > hi:\n return f\"Range {field} out of bounds [{lo}-{hi}]\"\n if a > b:\n return f\"Range start > end: {field}\"\n return None\n if not field.isdigit():\n return f\"Invalid field: {field}\"\n value = int(field)\n if value < lo or value > hi:\n return f\"Value {value} out of bounds [{lo}-{hi}]\"\n return None\n\n\ndef validate_cron(cron_expr: str) -> str | None:\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return f\"Expected 5 fields, got {len(fields)}\"\n bounds = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 6)]\n names = [\"minute\", \"hour\", \"day-of-month\", \"month\", \"day-of-week\"]\n for field, (lo, hi), name in zip(fields, bounds, names):\n err = _validate_cron_field(field, lo, hi)\n if err:\n return f\"{name}: {err}\"\n return None\n\n\ndef save_durable_jobs():\n durable = [asdict(job) for job in scheduled_jobs.values() if job.durable]\n DURABLE_PATH.write_text(json.dumps(durable, indent=2))\n\n\ndef load_durable_jobs():\n if not DURABLE_PATH.exists():\n return\n try:\n for item in json.loads(DURABLE_PATH.read_text()):\n job = CronJob(**item)\n if not validate_cron(job.cron):\n scheduled_jobs[job.id] = job\n except Exception:\n pass\n\n\ndef schedule_job(cron: str, prompt: str,\n recurring: bool = True, durable: bool = True) -> CronJob | str:\n err = validate_cron(cron)\n if err:\n return err\n job = CronJob(\n id=f\"cron_{random.randint(0, 999999):06d}\",\n cron=cron, prompt=prompt,\n recurring=recurring, durable=durable)\n with cron_lock:\n scheduled_jobs[job.id] = job\n if durable:\n save_durable_jobs()\n return job\n\n\ndef cancel_job(job_id: str) -> str:\n with cron_lock:\n job = scheduled_jobs.pop(job_id, None)\n if not job:\n return f\"Job {job_id} not found\"\n if job.durable:\n save_durable_jobs()\n return f\"Cancelled {job_id}\"\n\n\ndef cron_scheduler_loop():\n while True:\n time.sleep(1)\n now = datetime.now()\n marker = now.strftime(\"%Y-%m-%d %H:%M\")\n with cron_lock:\n for job in list(scheduled_jobs.values()):\n try:\n if cron_matches(job.cron, now) and _last_fired.get(job.id) != marker:\n cron_queue.append(job)\n _last_fired[job.id] = marker\n if not job.recurring:\n scheduled_jobs.pop(job.id, None)\n if job.durable:\n save_durable_jobs()\n except Exception as e:\n print(f\" \\033[31m[cron error] {job.id}: {e}\\033[0m\")\n\n\ndef consume_cron_queue() -> list[CronJob]:\n with cron_lock:\n fired = list(cron_queue)\n cron_queue.clear()\n return fired\n\n\ndef run_schedule_cron(cron: str, prompt: str,\n recurring: bool = True, durable: bool = True) -> str:\n result = schedule_job(cron, prompt, recurring, durable)\n if isinstance(result, str):\n return f\"Error: {result}\"\n return f\"Scheduled {result.id}: '{cron}' -> {prompt}\"\n\n\ndef run_list_crons() -> str:\n with cron_lock:\n jobs = list(scheduled_jobs.values())\n if not jobs:\n return \"No cron jobs.\"\n return \"\\n\".join(\n f\" {job.id}: '{job.cron}' -> {job.prompt[:40]} \"\n f\"[{'recurring' if job.recurring else 'one-shot'}, \"\n f\"{'durable' if job.durable else 'session'}]\"\n for job in jobs)\n\n\ndef run_cancel_cron(job_id: str) -> str:\n return cancel_job(job_id)\n\n\nload_durable_jobs()\nthreading.Thread(target=cron_scheduler_loop, daemon=True).start()\n\n\n# ── MCP System ──\n\n# MCP is modeled as late-bound tools: connect first, then discovered server\n# tools are merged into the normal tool pool with mcp__server__tool names.\nclass MCPClient:\n \"\"\"Discovers and calls tools on an MCP server (mock for teaching).\"\"\"\n\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs: list[dict],\n handlers: dict[str, callable]):\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n try:\n return handler(**args)\n except Exception as e:\n return f\"MCP error: {e}\"\n\n\nmcp_clients: dict[str, MCPClient] = {}\n\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\n\ndef normalize_mcp_name(name: str) -> str:\n \"\"\"Replace non [a-zA-Z0-9_-] with underscore.\"\"\"\n return _DISALLOWED_CHARS.sub('_', name)\n\n\ndef _mock_server_docs():\n client = MCPClient(\"docs\")\n client.register(\n tool_defs=[\n {\"name\": \"search\", \"description\": \"Search documentation. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"query\": {\"type\": \"string\"}},\n \"required\": [\"query\"]}},\n {\"name\": \"get_version\", \"description\": \"Get API version. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n ],\n handlers={\n \"search\": lambda query: f\"[docs] Found 3 results for '{query}'\",\n \"get_version\": lambda: \"[docs] API v2.1.0\",\n })\n return client\n\n\ndef _mock_server_deploy():\n client = MCPClient(\"deploy\")\n client.register(\n tool_defs=[\n {\"name\": \"trigger\",\n \"description\": \"Trigger a deployment. (destructive — requires approval in real Claude Code)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"service\": {\"type\": \"string\"}},\n \"required\": [\"service\"]}},\n {\"name\": \"status\", \"description\": \"Check deployment status. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"service\": {\"type\": \"string\"}},\n \"required\": [\"service\"]}},\n ],\n handlers={\n \"trigger\": lambda service: f\"[deploy] Triggered: {service}\",\n \"status\": lambda service: f\"[deploy] {service}: running (v1.4.2)\",\n })\n return client\n\n\nMOCK_SERVERS = {\n \"docs\": _mock_server_docs,\n \"deploy\": _mock_server_deploy,\n}\n\n\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n available = \", \".join(MOCK_SERVERS.keys())\n return f\"Unknown server '{name}'. Available: {available}\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n tool_names = [t[\"name\"] for t in mcp_client.tools]\n print(f\" \\033[31m[mcp] connected: {name} → {tool_names}\\033[0m\")\n return (f\"Connected to MCP server '{name}'. \"\n f\"Discovered {len(mcp_client.tools)} tools: {', '.join(tool_names)}\")\n\n\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n \"\"\"Merge builtin tools + all MCP tools into one pool.\"\"\"\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append({\n \"name\": prefixed,\n \"description\": tool_def.get(\"description\", \"\"),\n \"input_schema\": tool_def.get(\"inputSchema\", {}),\n })\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw: c.call_tool(t, kw))\n return tools, handlers\n\n\n# ── Lead Worktree Tools ──\n\ndef run_create_worktree(name: str, task_id: str = \"\") -> str:\n return create_worktree(name, task_id)\n\ndef run_remove_worktree(name: str, discard_changes: bool = False) -> str:\n return remove_worktree(name, discard_changes)\n\ndef run_keep_worktree(name: str) -> str:\n return keep_worktree(name)\n\n\n# ── Basic tool handlers ──\n\ndef run_create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> str:\n task = create_task(subject, description, blockedBy)\n deps = f\" (blockedBy: {', '.join(blockedBy)})\" if blockedBy else \"\"\n print(f\" \\033[34m[create] {task.subject}{deps}\\033[0m\")\n return f\"Created {task.id}: {task.subject}{deps}\"\n\n\ndef run_list_tasks() -> str:\n tasks = list_tasks()\n if not tasks:\n return \"No tasks.\"\n return \"\\n\".join(\n f\" {t.id}: {t.subject} [{t.status}]\"\n + (f\" (wt:{t.worktree})\" if t.worktree else \"\")\n for t in tasks)\n\n\ndef run_get_task(task_id: str) -> str:\n try:\n return get_task_json(task_id)\n except FileNotFoundError:\n return f\"Error: task {task_id} not found\"\n\ndef run_claim_task(task_id: str) -> str:\n try:\n return claim_task(task_id, owner=\"agent\")\n except FileNotFoundError:\n return f\"Error: task {task_id} not found\"\n\ndef run_complete_task(task_id: str) -> str:\n try:\n return complete_task(task_id)\n except FileNotFoundError:\n return f\"Error: task {task_id} not found\"\n\ndef run_spawn_teammate(name: str, role: str, prompt: str) -> str:\n return spawn_teammate_thread(name, role, prompt)\n\ndef run_send_message(to: str, content: str) -> str:\n BUS.send(\"lead\", to, content)\n return f\"Sent to {to}\"\n\ndef run_check_inbox() -> str:\n msgs = consume_lead_inbox(route_protocol=True)\n if not msgs:\n return \"(inbox empty)\"\n lines = []\n for m in msgs:\n meta = m.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n tag = f\" [{m['type']} req:{req_id}]\" if req_id else f\" [{m['type']}]\"\n lines.append(f\" [{m['from']}]{tag} {m['content'][:200]}\")\n return \"\\n\".join(lines)\n\ndef run_connect_mcp(name: str) -> str:\n return connect_mcp(name)\n\n\n# ── Tool Definitions ──\n\n# The model sees tool schemas; Python executes handlers. S20 keeps both tables\n# explicit so every added capability is visible in one place.\nBUILTIN_TOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"},\n \"run_in_background\": {\"type\": \"boolean\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"},\n \"offset\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"old_text\": {\"type\": \"string\"},\n \"new_text\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"pattern\": {\"type\": \"string\"}},\n \"required\": [\"pattern\"]}},\n {\"name\": \"todo_write\",\n \"description\": \"Create and manage a task list for the current session.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"todos\": {\"type\": \"array\",\n \"items\": {\"type\": \"object\",\n \"properties\": {\n \"content\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\",\n \"enum\": [\"pending\", \"in_progress\", \"completed\"]}},\n \"required\": [\"content\", \"status\"]}}},\n \"required\": [\"todos\"]}},\n {\"name\": \"task\",\n \"description\": \"Launch a focused subagent. Returns only its final summary.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"description\": {\"type\": \"string\"}},\n \"required\": [\"description\"]}},\n {\"name\": \"load_skill\",\n \"description\": \"Load the full content of a skill by name.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"compact\",\n \"description\": \"Summarize earlier conversation and continue with compacted context.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"focus\": {\"type\": \"string\"}},\n \"required\": []}},\n {\"name\": \"create_task\", \"description\": \"Create a task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"subject\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n \"blockedBy\": {\"type\": \"array\",\n \"items\": {\"type\": \"string\"}}},\n \"required\": [\"subject\"]}},\n {\"name\": \"list_tasks\", \"description\": \"List all tasks.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {}, \"required\": []}},\n {\"name\": \"get_task\", \"description\": \"Get full task details.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"claim_task\", \"description\": \"Claim a pending task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\", \"description\": \"Complete an in-progress task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"schedule_cron\",\n \"description\": (\"Schedule a cron job. cron is 5-field: min hour dom \"\n \"month dow. For one-shot reminders, compute the target \"\n \"minute and set recurring=false.\"),\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"cron\": {\"type\": \"string\"},\n \"prompt\": {\"type\": \"string\"},\n \"recurring\": {\"type\": \"boolean\"},\n \"durable\": {\"type\": \"boolean\"}},\n \"required\": [\"cron\", \"prompt\"]}},\n {\"name\": \"list_crons\", \"description\": \"List registered cron jobs.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {}, \"required\": []}},\n {\"name\": \"cancel_cron\", \"description\": \"Cancel a cron job by ID.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"job_id\": {\"type\": \"string\"}},\n \"required\": [\"job_id\"]}},\n {\"name\": \"spawn_teammate\", \"description\": \"Spawn an autonomous teammate.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"},\n \"role\": {\"type\": \"string\"},\n \"prompt\": {\"type\": \"string\"}},\n \"required\": [\"name\", \"role\", \"prompt\"]}},\n {\"name\": \"send_message\", \"description\": \"Send message to a teammate.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"check_inbox\",\n \"description\": \"Check inbox for messages and protocol responses.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {}, \"required\": []}},\n {\"name\": \"request_shutdown\",\n \"description\": \"Request a teammate to shut down.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"teammate\": {\"type\": \"string\"}},\n \"required\": [\"teammate\"]}},\n {\"name\": \"request_plan\",\n \"description\": \"Ask a teammate to submit a plan.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"teammate\": {\"type\": \"string\"},\n \"task\": {\"type\": \"string\"}},\n \"required\": [\"teammate\", \"task\"]}},\n {\"name\": \"review_plan\",\n \"description\": \"Approve or reject a submitted plan.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"request_id\": {\"type\": \"string\"},\n \"approve\": {\"type\": \"boolean\"},\n \"feedback\": {\"type\": \"string\"}},\n \"required\": [\"request_id\", \"approve\"]}},\n {\"name\": \"create_worktree\",\n \"description\": \"Create an isolated git worktree.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"},\n \"task_id\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"remove_worktree\",\n \"description\": \"Remove a worktree. Refuses if changes exist.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"},\n \"discard_changes\": {\"type\": \"boolean\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"keep_worktree\",\n \"description\": \"Keep a worktree for manual review.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"connect_mcp\",\n \"description\": \"Connect to an MCP server (docs, deploy) and discover tools.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n]\n\nBUILTIN_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"edit_file\": run_edit, \"glob\": run_glob,\n \"todo_write\": run_todo_write, \"task\": spawn_subagent,\n \"load_skill\": load_skill,\n \"create_task\": run_create_task, \"list_tasks\": run_list_tasks,\n \"get_task\": run_get_task,\n \"claim_task\": run_claim_task, \"complete_task\": run_complete_task,\n \"schedule_cron\": run_schedule_cron,\n \"list_crons\": run_list_crons,\n \"cancel_cron\": run_cancel_cron,\n \"spawn_teammate\": run_spawn_teammate,\n \"send_message\": run_send_message, \"check_inbox\": run_check_inbox,\n \"request_shutdown\": run_request_shutdown,\n \"request_plan\": run_request_plan, \"review_plan\": run_review_plan,\n \"create_worktree\": run_create_worktree,\n \"remove_worktree\": run_remove_worktree,\n \"keep_worktree\": run_keep_worktree,\n \"connect_mcp\": run_connect_mcp,\n}\n\n\n# ── Context ──\n\nMEMORY_DIR = WORKDIR / \".memory\"\nMEMORY_INDEX = MEMORY_DIR / \"MEMORY.md\"\n\n\ndef update_context(context: dict, messages: list) -> dict:\n memories = \"\"\n if MEMORY_INDEX.exists():\n memories = MEMORY_INDEX.read_text()[:2000]\n return {\n \"memories\": memories,\n \"connected_mcp\": list(mcp_clients.keys()),\n \"active_teammates\": list(active_teammates.keys()),\n }\n\n\n# ── Agent Loop ──\n\nrounds_since_todo = 0\nagent_lock = threading.Lock()\n\n\ndef prepare_context(messages: list) -> list:\n # Every LLM turn enters through the same context budget pipeline.\n messages[:] = tool_result_budget(messages)\n messages[:] = snip_compact(messages)\n messages[:] = micro_compact(messages)\n if estimate_size(messages) > CONTEXT_LIMIT:\n messages[:] = compact_history(messages)\n return messages\n\n\ndef build_user_content(results: list[dict]) -> list[dict]:\n # Tool results and completed background notifications are both returned to\n # the model as user-side content, matching the tool_result feedback loop.\n content = list(results)\n for note in collect_background_results():\n content.append({\"type\": \"text\", \"text\": note})\n return content\n\n\ndef inject_background_notifications(messages: list):\n notes = collect_background_results()\n if notes:\n messages.append({\"role\": \"user\", \"content\": [\n {\"type\": \"text\", \"text\": note} for note in notes]})\n\n\ndef call_llm(messages: list, context: dict, tools: list,\n state: RecoveryState, max_tokens: int):\n system = assemble_system_prompt(context)\n return with_retry(\n lambda: client.messages.create(\n model=state.current_model,\n system=system,\n messages=messages,\n tools=tools,\n max_tokens=max_tokens),\n state)\n\n\ndef agent_loop(messages: list, context: dict):\n global rounds_since_todo\n tools, handlers = assemble_tool_pool()\n state = RecoveryState()\n max_tokens = DEFAULT_MAX_TOKENS\n\n while True:\n # One cycle: inject scheduled/background work, prepare context, call\n # the model, execute tool_use blocks, append tool_results, repeat.\n fired = consume_cron_queue()\n for job in fired:\n messages.append({\"role\": \"user\",\n \"content\": f\"[Scheduled] {job.prompt}\"})\n print(f\" \\033[35m[cron inject] {job.prompt[:60]}\\033[0m\")\n\n inject_background_notifications(messages)\n\n if rounds_since_todo >= 3:\n messages.append({\"role\": \"user\",\n \"content\": \"Update your todos.\"})\n rounds_since_todo = 0\n\n prepare_context(messages)\n context = update_context(context, messages)\n tools, handlers = assemble_tool_pool()\n\n try:\n response = call_llm(messages, context, tools, state, max_tokens)\n except Exception as e:\n if is_prompt_too_long_error(e) and not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n messages.append({\"role\": \"assistant\", \"content\": [\n {\"type\": \"text\", \"text\": f\"[Error] {type(e).__name__}: {e}\"}]})\n return\n\n if response.stop_reason == \"max_tokens\":\n if not state.has_escalated:\n max_tokens = ESCALATED_MAX_TOKENS\n state.has_escalated = True\n print(f\" \\033[33m[max_tokens] retry with {max_tokens}\\033[0m\")\n continue\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if state.recovery_count < MAX_RECOVERY_RETRIES:\n messages.append({\"role\": \"user\", \"content\": CONTINUATION_PROMPT})\n state.recovery_count += 1\n continue\n return\n\n max_tokens = DEFAULT_MAX_TOKENS\n state.has_escalated = False\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if not has_tool_use(response.content):\n trigger_hooks(\"Stop\", messages)\n return\n\n results = []\n compacted_now = False\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n\n if block.name == \"compact\":\n messages[:] = compact_history(messages)\n messages.append({\"role\": \"user\",\n \"content\": \"[Compacted. Continue with summarized context.]\"})\n compacted_now = True\n break\n\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(blocked)})\n continue\n\n if should_run_background(block.name, block.input):\n bg_id = start_background_task(block, handlers)\n output = (f\"[Background task {bg_id} started] \"\n \"Result will arrive as a task_notification.\")\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output})\n continue\n\n handler = handlers.get(block.name)\n output = call_tool_handler(handler, block.input, block.name)\n trigger_hooks(\"PostToolUse\", block, output)\n print(str(output)[:300])\n\n if block.name == \"todo_write\":\n rounds_since_todo = 0\n else:\n rounds_since_todo += 1\n\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n\n if compacted_now:\n continue\n\n messages.append({\"role\": \"user\", \"content\": build_user_content(results)})\n\n\ndef print_turn_assistants(messages: list, turn_start: int):\n for msg in messages[turn_start:]:\n if msg.get(\"role\") != \"assistant\":\n continue\n for block in msg.get(\"content\", []):\n if getattr(block, \"type\", None) == \"text\":\n terminal_print(block.text)\n\n\ndef cron_autorun_loop(history: list, context: dict):\n while True:\n time.sleep(1)\n fired = consume_cron_queue()\n if not fired:\n continue\n with agent_lock:\n turn_start = len(history)\n for job in fired:\n history.append({\"role\": \"user\",\n \"content\": f\"[Scheduled] {job.prompt}\"})\n terminal_print(\n f\" \\033[35m[cron auto] {job.prompt[:60]}\\033[0m\")\n agent_loop(history, context)\n context.update(update_context(context, history))\n print_turn_assistants(history, turn_start)\n\n\nif __name__ == \"__main__\":\n CLI_ACTIVE = True\n print(\"s20: comprehensive agent\")\n print(\"Enter a question, press Enter to send. Type q to quit.\\n\")\n history = []\n context = update_context({}, [])\n threading.Thread(target=cron_autorun_loop,\n args=(history, context), daemon=True).start()\n while True:\n try:\n query = input(PROMPT)\n except (EOFError, KeyboardInterrupt):\n break\n if query.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n trigger_hooks(\"UserPromptSubmit\", query)\n turn_start = len(history)\n history.append({\"role\": \"user\", \"content\": query})\n with agent_lock:\n agent_loop(history, context)\n context = update_context(context, history)\n print_turn_assistants(history, turn_start)\n\n inbox = consume_lead_inbox(route_protocol=True)\n if inbox:\n def inbox_label(msg):\n req_id = msg.get(\"metadata\", {}).get(\"request_id\", \"\")\n suffix = f\" req:{req_id}\" if req_id else \"\"\n return f\"{msg.get('type', 'message')}{suffix}\"\n\n inbox_text = \"\\n\".join(\n f\"From {m['from']} [{inbox_label(m)}]: \"\n f\"{m['content'][:200]}\" for m in inbox)\n history.append({\"role\": \"user\",\n \"content\": f\"[Inbox]\\n{inbox_text}\"})\n print()\n", "images": [ { "src": "/course-assets/s20_comprehensive/system-architecture.svg", "alt": "system architecture" } ] + }, + { + "id": "s21", + "filename": "s21_workflow_runtime/code.py", + "title": "s21", + "subtitle": "", + "loc": 384, + "tools": [ + "review-changes" + ], + "newTools": [ + "review-changes" + ], + "coreAddition": "", + "keyInsight": "", + "classes": [ + { + "name": "WorkflowInputError", + "startLine": 59, + "endLine": 65 + }, + { + "name": "SimpleJsonSchema", + "startLine": 90, + "endLine": 131 + }, + { + "name": "MockAgentRunner", + "startLine": 150, + "endLine": 180 + }, + { + "name": "WorkflowJournal", + "startLine": 181, + "endLine": 218 + }, + { + "name": "Budget", + "startLine": 219, + "endLine": 239 + }, + { + "name": "LocalWorkflowTask", + "startLine": 240, + "endLine": 264 + }, + { + "name": "ExecutionState", + "startLine": 265, + "endLine": 370 + }, + { + "name": "WorkflowTool", + "startLine": 371, + "endLine": 413 + } + ], + "functions": [ + { + "name": "_stable_hash", + "signature": "def _stable_hash(s: str)", + "startLine": 40 + }, + { + "name": "create_run_id", + "signature": "def create_run_id(meta)", + "startLine": 46 + }, + { + "name": "create_task_id", + "signature": "def create_task_id(run_id)", + "startLine": 52 + }, + { + "name": "validate_meta", + "signature": "def validate_meta(meta)", + "startLine": 66 + }, + { + "name": "check_permission", + "signature": "def check_permission(meta, settings=None)", + "startLine": 78 + }, + { + "name": "_fill_schema", + "signature": "def _fill_schema(schema, seed)", + "startLine": 132 + }, + { + "name": "_write_json", + "signature": "def _write_json(path, value)", + "startLine": 414 + }, + { + "name": "_save_last_run", + "signature": "def _save_last_run(run_id)", + "startLine": 419 + }, + { + "name": "_read_last_run", + "signature": "def _read_last_run()", + "startLine": 423 + } + ], + "layer": "tools", + "source": "\"\"\"\ns21_workflow_runtime — Dynamic Workflow runtime (teaching version)\n\nClean-room behavioral reconstruction of Claude Code's `Workflow` tool / dynamic\nworkflow runtime. Grounded in @anthropic-ai/claude-code@2.1.177 observed\nbehavior (reverse-research/cc_workflow), NOT leaked source.\n\nIdea:\n s01-s20 build a single, model-driven agent loop. s21 adds a deterministic\n orchestration LAYER on top: the main loop exposes a `Workflow` tool that\n launches a background runtime; a script written with agent()/parallel()/\n pipeline()/phase() drives many subagents deterministically, reports progress,\n persists a journal, and can resume from a runId.\n\nRun:\n python code.py # run the sample workflow, print the event stream\n python code.py resume # resume the last run; unchanged agent() calls hit cache\n\nTeaching simplifications (vs real runtime.mjs):\n - The \"subagent\" is a deterministic MockAgentRunner, not a real LLM.\n - A workflow is a plain async Python function, not a sandboxed JS script\n string. The real runtime runs the script in an isolated JS VM with\n Date.now()/Math.random() removed so resume is reproducible.\n - Storage is a local .runtime/ dir instead of ~/.claude/projects/.../workflows/.\n\"\"\"\n\nimport asyncio\nimport hashlib\nimport json\nimport sys\nfrom pathlib import Path\n\n# ---- knobs that mirror the real runtime's guards ----\nAGENT_CAP = 1000 # hard cap on agent() calls per run\nCONCURRENCY = 8 # parallelism cap (semaphore)\nSTORE = Path(__file__).parent / \".runtime\" # snapshots + journals live here\nMISS = object() # journal cache miss sentinel\n\n\ndef _stable_hash(s: str) -> int:\n \"\"\"Process-stable hash (Python's hash() is salted per process, which would\n break resume keys across `run` and `resume`).\"\"\"\n return int(hashlib.sha256(s.encode()).hexdigest(), 16)\n\n\ndef create_run_id(meta) -> str:\n # Deterministic in the teaching version so the journal path is predictable\n # and `resume` lands on the same file. The real runtime mints a random id.\n return f\"wf_{meta['name']}_{_stable_hash(meta['name']) % 10000:04d}\"\n\n\ndef create_task_id(run_id) -> str:\n return f\"local_workflow_{run_id}\"\n\n\n# ============================================================\n# Errors\n# ============================================================\nclass WorkflowInputError(Exception):\n \"\"\"Bad script / meta / schema input (mirrors WorkflowInputError).\"\"\"\n\n\n# ============================================================\n# meta validation\n# ============================================================\ndef validate_meta(meta):\n \"\"\"Real runtime requires `export const meta = {...}` as the FIRST statement,\n a pure literal, with name + description (+ optional phases). We take a dict.\"\"\"\n if not isinstance(meta, dict):\n raise WorkflowInputError(\"meta must be an object literal\")\n if not meta.get(\"name\") or not meta.get(\"description\"):\n raise WorkflowInputError(\"meta requires `name` and `description`\")\n if \"phases\" in meta and not isinstance(meta[\"phases\"], list):\n raise WorkflowInputError(\"meta.phases must be a list\")\n return meta\n\n\ndef check_permission(meta, settings=None):\n \"\"\"allow / deny / ask gate before launch (s03 permission system, applied to\n Workflow). Teaching version allows by default; a deny rule blocks.\"\"\"\n settings = settings or {}\n if meta[\"name\"] in settings.get(\"deny\", []):\n raise WorkflowInputError(f\"workflow '{meta['name']}' denied by settings\")\n return \"allow\"\n\n\n# ============================================================\n# Minimal JSON-schema for structured output (SimpleJsonSchema)\n# ============================================================\nclass SimpleJsonSchema:\n \"\"\"Tiny validator backing agent({schema}). Just enough for teaching:\n object/array/string/boolean/number + required keys.\"\"\"\n\n def __init__(self, schema):\n self.schema = schema\n\n def validate(self, value, schema=None):\n schema = self.schema if schema is None else schema\n t = schema.get(\"type\")\n if t == \"object\":\n if not isinstance(value, dict):\n return False, \"expected object\"\n for key in schema.get(\"required\", []):\n if key not in value:\n return False, f\"missing required key '{key}'\"\n for key, sub in schema.get(\"properties\", {}).items():\n if key in value:\n ok, err = self.validate(value[key], sub)\n if not ok:\n return False, f\"{key}: {err}\"\n return True, None\n if t == \"array\":\n if not isinstance(value, list):\n return False, \"expected array\"\n items = schema.get(\"items\")\n if items:\n for i, el in enumerate(value):\n ok, err = self.validate(el, items)\n if not ok:\n return False, f\"[{i}]: {err}\"\n return True, None\n if t == \"string\":\n return (isinstance(value, str), None if isinstance(value, str) else \"expected string\")\n if t == \"boolean\":\n return (isinstance(value, bool), None if isinstance(value, bool) else \"expected boolean\")\n if t in (\"number\", \"integer\"):\n ok = isinstance(value, (int, float)) and not isinstance(value, bool)\n return (ok, None if ok else \"expected number\")\n return True, None\n\n\ndef _fill_schema(schema, seed):\n \"\"\"Deterministic generic filler used for schemas the mock doesn't special-case.\"\"\"\n t = schema.get(\"type\")\n if t == \"object\":\n keys = schema.get(\"required\") or list(schema.get(\"properties\", {}))\n return {k: _fill_schema(schema[\"properties\"][k], f\"{seed}/{k}\") for k in keys}\n if t == \"array\":\n return [_fill_schema(schema[\"items\"], f\"{seed}/0\")]\n if t == \"boolean\":\n return _stable_hash(seed) % 4 != 0\n if t in (\"number\", \"integer\"):\n return _stable_hash(seed) % 5\n return seed.rsplit(\"/\", 1)[-1]\n\n\n# ============================================================\n# Subagent runner (mock for teaching; real path = an LLM tool loop)\n# ============================================================\nclass MockAgentRunner:\n \"\"\"Stands in for a spawned subagent. Deterministic so resume is reproducible.\n A real runner would run an isolated agent loop that calls repo tools and is\n forced to emit StructuredOutput when a schema is present.\"\"\"\n\n def run(self, prompt, schema=None, label=None):\n if schema is None:\n return f\"[mock] {(label or prompt)[:60]}\"\n props = schema.get(\"properties\", {})\n if \"findings\" in props: # an audit agent\n n = 1 + (_stable_hash(prompt) % 2) # 1-2 findings\n sev = [\"high\", \"medium\", \"low\"]\n return {\"findings\": [\n {\"title\": f\"{label or 'audit'} #{i + 1}\",\n \"severity\": sev[_stable_hash(prompt + str(i)) % 3]}\n for i in range(n)\n ]}\n if \"isReal\" in props: # a verifier agent\n real = _stable_hash(prompt) % 4 != 0 # ~75% confirmed\n return {\"isReal\": real,\n \"reason\": \"reproduced\" if real else \"could not reproduce\"}\n return _fill_schema(schema, prompt)\n\n @staticmethod\n def tokens(prompt, result):\n return len(prompt) // 4 + len(json.dumps(result, default=str)) // 4\n\n\n# ============================================================\n# Journal (resume cache): started/result per agent under a semantic key\n# ============================================================\nclass WorkflowJournal:\n \"\"\"Append-only .journal.jsonl. On resume, agent() calls whose\n semantic key is already present are replayed from cache instead of re-run.\"\"\"\n\n def __init__(self, run_id, resume, store=STORE):\n store.mkdir(parents=True, exist_ok=True)\n self.path = store / f\"{run_id}.journal.jsonl\"\n self.resume = resume\n self.cache = {}\n if resume and self.path.exists():\n for line in self.path.read_text().splitlines():\n rec = json.loads(line)\n self.cache[rec[\"key\"]] = rec[\"value\"]\n self._f = self.path.open(\"a\")\n else:\n self._f = self.path.open(\"w\") # fresh run truncates\n\n def key(self, kind, label, prompt, schema):\n # Deterministic semantic key — independent of concurrency order, so a\n # parallel/pipeline call gets the same key on resume.\n basis = f\"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}\"\n return f\"{kind}-{_stable_hash(basis) % 10**10:010d}\"\n\n def cached(self, key):\n return self.cache.get(key, MISS)\n\n def record(self, key, value):\n self._f.write(json.dumps({\"key\": key, \"value\": value}) + \"\\n\")\n self._f.flush()\n self.cache[key] = value\n\n def close(self):\n self._f.close()\n\n\n# ============================================================\n# Token budget\n# ============================================================\nclass Budget:\n \"\"\"budget.total / spent() / remaining(). Once spent reaches total, agent()\n calls raise (the real runtime enforces the same ceiling).\"\"\"\n\n def __init__(self, total=None):\n self.total = total\n self._spent = 0\n\n def add(self, n):\n self._spent += n\n\n def spent(self):\n return self._spent\n\n def remaining(self):\n return float(\"inf\") if self.total is None else max(0, self.total - self._spent)\n\n\n# ============================================================\n# Background task state + progress events (the outer event stream)\n# ============================================================\nclass LocalWorkflowTask:\n \"\"\"type local_workflow. Holds status/usage and emits the SDK-like event\n stream: task_started, task_progress (workflow_phase/agent/log), task_notification.\"\"\"\n\n def __init__(self, task_id, run_id, meta):\n self.task_id = task_id\n self.run_id = run_id\n self.meta = meta\n self.status = \"running\"\n self.usage = {\"agents\": 0, \"tokens\": 0}\n self.progress = []\n\n def event(self, name, **data):\n line = \" \".join(f\"{k}={v}\" for k, v in data.items())\n print(f\" event {name:<18} {line}\")\n\n def progress_event(self, ptype, **data):\n self.progress.append({\"type\": ptype, **data})\n line = \" \".join(f\"{k}={v}\" for k, v in data.items())\n print(f\" progress {ptype:<16} {line}\")\n\n\n# ============================================================\n# ExecutionState: the DSL the workflow script sees as `ctx`\n# ============================================================\nclass ExecutionState:\n \"\"\"Injected into the workflow script. Provides the orchestration primitives.\n Mirrors ExecutionState in runtime.mjs.\"\"\"\n\n def __init__(self, task, journal, runner, budget, args, depth=0):\n self.task = task\n self.journal = journal\n self.runner = runner\n self.budget = budget\n self.args = args\n self._depth = depth\n self._phase = None\n self._phases_seen = set()\n self._agents = 0\n self._sem = asyncio.Semaphore(CONCURRENCY)\n\n def phase(self, title):\n \"\"\"Start a phase; subsequent agent()s group under it. Upsert: emitting the\n same phase again (e.g. from each pipeline item) does not re-announce it.\"\"\"\n self._phase = title\n if title not in self._phases_seen:\n self._phases_seen.add(title)\n self.task.progress_event(\"workflow_phase\", title=title)\n\n def log(self, message):\n \"\"\"Emit a workflow_log progress line.\"\"\"\n self.task.progress_event(\"workflow_log\", message=message)\n\n async def agent(self, prompt, schema=None, label=None, phase=None):\n \"\"\"Spawn one subagent. With a schema, force StructuredOutput + validate\n (retry once). On resume, a cached key short-circuits the run.\"\"\"\n label = label or (prompt[:24] + \"…\")\n self._agents += 1\n if self._agents > AGENT_CAP:\n raise WorkflowInputError(f\"agent() cap reached ({AGENT_CAP})\")\n if self.budget.remaining() <= 0:\n raise WorkflowInputError(\"token budget exceeded\")\n\n key = self.journal.key(\"agent\", label, prompt, schema)\n cached = self.journal.cached(key)\n if cached is not MISS:\n self.task.progress_event(\"workflow_agent\", label=label,\n phase=phase or self._phase, status=\"cached\")\n return cached\n\n async with self._sem:\n await asyncio.sleep(0) # yield: real subagents are async\n result = self.runner.run(prompt, schema, label)\n\n if schema is not None:\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok: # one nudge/retry, then fail\n result = self.runner.run(prompt + \"\\n\\nReturn valid JSON.\", schema, label)\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok:\n raise WorkflowInputError(f\"agent({{schema}}) invalid output: {err}\")\n\n toks = self.runner.tokens(prompt, result)\n self.budget.add(toks)\n self.task.usage[\"agents\"] += 1\n self.task.usage[\"tokens\"] += toks\n self.journal.record(key, result)\n self.task.progress_event(\"workflow_agent\", label=label,\n phase=phase or self._phase, status=\"done\")\n return result\n\n async def parallel(self, thunks):\n \"\"\"BARRIER: run all thunks concurrently, await all. A thunk that throws\n resolves to None (filter before use).\"\"\"\n async def safe(t):\n try:\n return await t()\n except Exception:\n return None\n return await asyncio.gather(*[safe(t) for t in thunks])\n\n async def pipeline(self, items, *stages):\n \"\"\"Per-item staged flow, NO barrier between stages: item A can be in\n stage 3 while item B is still in stage 1. Each stage gets\n (prev_result, original_item, index). A throwing stage drops that item.\"\"\"\n async def run_item(item, idx):\n value = item\n for stage in stages:\n try:\n value = await stage(value, item, idx)\n except Exception:\n return None\n return value\n return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])\n\n async def workflow(self, name, args=None):\n \"\"\"Run a saved workflow inline as a child (one level), sharing this run's\n journal + budget + agent counter.\"\"\"\n if self._depth >= 1:\n raise WorkflowInputError(\"workflow() nesting is one level only\")\n if name not in WORKFLOWS:\n raise WorkflowInputError(f\"unknown workflow '{name}'\")\n meta, fn = WORKFLOWS[name]\n child = ExecutionState(self.task, self.journal, self.runner, self.budget,\n args or {}, depth=self._depth + 1)\n return await fn(child, args or {})\n\n\n# ============================================================\n# WorkflowTool: the tool entry (WorkflowTool.call)\n# ============================================================\nclass WorkflowTool:\n \"\"\"The Workflow tool. .call() validates meta, runs the permission check,\n creates runId/taskId, registers a LocalWorkflowTask, and runs the script in\n the background — driving progress, persisting the journal, returning the\n final result. Supports resumeFromRunId. Mirrors WorkflowTool.call in runtime.mjs.\"\"\"\n\n async def call(self, meta, script_fn, args=None, resume_from_run_id=None):\n validate_meta(meta)\n check_permission(meta)\n args = args or {}\n run_id = resume_from_run_id or create_run_id(meta)\n task_id = create_task_id(run_id)\n resuming = resume_from_run_id is not None\n journal = WorkflowJournal(run_id, resume=resuming)\n\n task = LocalWorkflowTask(task_id, run_id, meta)\n # The real tool returns this immediately and runs the rest in background.\n launched = {\"status\": \"async_launched\", \"taskId\": task_id,\n \"taskType\": \"local_workflow\", \"runId\": run_id,\n \"workflowName\": meta[\"name\"]}\n task.event(\"async_launched\", runId=run_id, taskId=task_id)\n task.event(\"task_started\", workflow=meta[\"name\"],\n phases=\",\".join(meta.get(\"phases\", [])) or \"-\",\n resume=resuming)\n\n ctx = ExecutionState(task, journal, MockAgentRunner(), Budget(args.get(\"budget\")), args)\n try:\n result = await script_fn(ctx, args)\n task.status = \"completed\"\n except Exception as e: # failed / stopped close the loop too\n task.status = \"failed\"\n result = {\"error\": str(e)}\n finally:\n journal.close()\n\n _write_json(STORE / f\"{run_id}.output.json\", result)\n _save_last_run(run_id)\n task.event(\"task_notification\", status=task.status,\n agents=task.usage[\"agents\"], tokens=task.usage[\"tokens\"],\n outputFile=f\".runtime/{run_id}.output.json\")\n return {\"launched\": launched, \"result\": result, \"task\": task}\n\n\ndef _write_json(path, value):\n path.parent.mkdir(parents=True, exist_ok=True)\n path.write_text(json.dumps(value, indent=2, default=str))\n\n\ndef _save_last_run(run_id):\n (STORE / \"last_run.txt\").write_text(run_id)\n\n\ndef _read_last_run():\n p = STORE / \"last_run.txt\"\n return p.read_text().strip() if p.exists() else None\n\n\n# ============================================================\n# Sample workflow: review changed code across dimensions, verify each finding.\n# Mirrors cc_workflow/runtime/workflows/review_workflow.js (pipeline + parallel).\n# ============================================================\nFINDINGS_SCHEMA = {\n \"type\": \"object\", \"required\": [\"findings\"],\n \"properties\": {\"findings\": {\"type\": \"array\", \"items\": {\n \"type\": \"object\", \"required\": [\"title\", \"severity\"],\n \"properties\": {\"title\": {\"type\": \"string\"}, \"severity\": {\"type\": \"string\"}}}}},\n}\nVERDICT_SCHEMA = {\n \"type\": \"object\", \"required\": [\"isReal\", \"reason\"],\n \"properties\": {\"isReal\": {\"type\": \"boolean\"}, \"reason\": {\"type\": \"string\"}},\n}\n\nSAMPLE_META = {\n \"name\": \"review-changes\",\n \"description\": \"Review changed files across dimensions, verify each finding\",\n \"phases\": [\"Review\", \"Verify\"],\n}\n\nDIMENSIONS = [\"correctness\", \"security\", \"performance\", \"style\"]\n\n\nasync def sample_workflow(ctx, args):\n \"\"\"pipeline over review dimensions (audit -> verify-each), then keep only the\n findings a verifier confirms. The plan is code, not a chat turn.\"\"\"\n ctx.phase(\"Review\")\n\n async def audit(_value, dimension, _idx):\n out = await ctx.agent(\n f\"Review the changed files for {dimension} issues.\",\n schema=FINDINGS_SCHEMA, label=f\"audit:{dimension}\", phase=\"Review\")\n return {\"dimension\": dimension, \"findings\": out[\"findings\"]}\n\n async def verify(audited, dimension, _idx):\n ctx.phase(\"Verify\")\n # Each finding is verified by its own adversarial subagent, concurrently.\n verdicts = await ctx.parallel([\n (lambda f=f: ctx.agent(\n f\"Adversarially verify this {dimension} finding — is it real? {f['title']}\",\n schema=VERDICT_SCHEMA, label=f\"verify:{dimension}:{f['title']}\", phase=\"Verify\"))\n for f in audited[\"findings\"]])\n confirmed = [f for f, v in zip(audited[\"findings\"], verdicts)\n if v and v.get(\"isReal\")]\n return {\"dimension\": dimension, \"confirmed\": confirmed}\n\n results = await ctx.pipeline(DIMENSIONS, audit, verify)\n confirmed = [{\"dimension\": r[\"dimension\"], **f}\n for r in results if r for f in r[\"confirmed\"]]\n confirmed.sort(key=lambda f: {\"high\": 0, \"medium\": 1, \"low\": 2}.get(f[\"severity\"], 3))\n ctx.log(f\"confirmed {len(confirmed)} real finding(s)\")\n return {\"confirmed\": confirmed}\n\n\n# saved workflow registry (.claude/workflows/ analogue)\nWORKFLOWS = {SAMPLE_META[\"name\"]: (SAMPLE_META, sample_workflow)}\n\n\n# ============================================================\n# Demo\n# ============================================================\nasync def main(argv):\n resume_id = None\n if argv and argv[0] == \"resume\":\n resume_id = _read_last_run()\n if not resume_id:\n print(\"nothing to resume — run `python code.py` first.\")\n return\n print(f\"resuming {resume_id} — unchanged agent() calls hit the journal cache\\n\")\n else:\n print(\"launching workflow `review-changes`\\n\")\n\n tool = WorkflowTool()\n out = await tool.call(SAMPLE_META, sample_workflow,\n args={\"budget\": None}, resume_from_run_id=resume_id)\n\n print(\"\\nresult:\")\n for f in out[\"result\"].get(\"confirmed\", []):\n print(f\" [{f['severity']:<6}] {f['dimension']}: {f['title']}\")\n t = out[\"task\"]\n print(f\"\\nstatus={t.status} agents={t.usage['agents']} tokens={t.usage['tokens']}\"\n f\" journal=.runtime/{t.run_id}.journal.jsonl\")\n\n\nif __name__ == \"__main__\":\n asyncio.run(main(sys.argv[1:]))\n", + "images": [ + { + "src": "/course-assets/s21_workflow_runtime/workflow-runtime-overview.svg", + "alt": "workflow runtime overview" + } + ] + }, + { + "id": "s22", + "filename": "s22_goal_loop/code.py", + "title": "s22", + "subtitle": "", + "loc": 202, + "tools": [], + "newTools": [], + "coreAddition": "", + "keyInsight": "", + "classes": [ + { + "name": "Message", + "startLine": 61, + "endLine": 70 + }, + { + "name": "CommandQueue", + "startLine": 71, + "endLine": 104 + }, + { + "name": "GoalRuntime", + "startLine": 105, + "endLine": 195 + }, + { + "name": "Session", + "startLine": 196, + "endLine": 244 + } + ], + "functions": [ + { + "name": "make_id", + "signature": "def make_id(prefix)", + "startLine": 47 + }, + { + "name": "event", + "signature": "def event(lane, etype, detail=\"\")", + "startLine": 51 + }, + { + "name": "banner", + "signature": "def banner(text)", + "startLine": 245 + }, + { + "name": "main", + "signature": "def main(argv)", + "startLine": 249 + } + ], + "layer": "tools", + "source": "\"\"\"\ns22_goal_loop — /goal session goal loop (teaching version)\n\nClean-room behavioral reconstruction of Claude Code's `/goal` command. Grounded\nin @anthropic-ai/claude-code@2.1.177 observed behavior\n(reverse-research/cc_goal_loop), NOT leaked source.\n\nIdea:\n s01-s21 end a turn when the model emits no tool_use. `/goal` adds a\n host-owned turn-completion GATE: the user sets a stopping CONDITION, and after\n every turn a separate evaluator judges whether trusted transcript evidence\n satisfies it. Not satisfied -> the gate blocks the stop and feeds a\n continuation into the next turn. Satisfied -> the active goal is cleared.\n\n So the core contrast with s01 is one extra check before \"return\":\n\n # s01: the model says stop -> stop\n if not has_tool_use(response):\n return\n # s22: when it wants to stop, pass the goal gate first\n if not has_tool_use(response):\n verdict = goal.evaluate_after_turn()\n if verdict == \"continuing\":\n continue # not met -> push it back\n return # met / over budget / no goal -> really stop\n\nRun:\n python code.py # /goal until tests pass + deploy green; watch the gate\n\nTeaching simplifications (vs real /goal and runtime.mjs):\n - The evaluator is a deterministic keyword check, not a small/fast model.\n - One mock task-notification produces the trusted evidence; the loop / monitor\n / background-task plane (s13/s14) is out of scope — this chapter is just the\n goal gate.\n - The evidence trust boundary is the faithful part: only task-notification /\n monitor-line origins count as evidence, so the `/goal` command text, the\n continuation reminder, and plain assistant prose can NOT satisfy the goal.\n\"\"\"\n\nimport itertools\nimport sys\n\n# ---- ids + a one-line event stream so the gate is visible ----\n_ids = itertools.count(1)\n\n\ndef make_id(prefix):\n return f\"{prefix}-{next(_ids):03d}\"\n\n\ndef event(lane, etype, detail=\"\"):\n print(f\" · {lane:<6} {etype:<26} {detail}\")\n\n\n# A message's origin.kind is the TRUST LABEL that decides whether it can count\n# as goal evidence. Trusted async origins land real tool/task evidence; user /\n# slash-command / active-goal (the continuation reminder) / assistant do not.\nTRUSTED_EVIDENCE_ORIGINS = {\"task-notification\", \"monitor-line\"}\n\n\nclass Message:\n def __init__(self, role, content, origin):\n self.role = role\n self.content = content\n self.origin = origin or {\"kind\": \"user\"}\n\n\n# ============================================================\n# CommandQueue — continuation prompts live here (mirrors CommandQueue)\n# ============================================================\nclass CommandQueue:\n PRIORITY = {\"now\": 0, \"next\": 1, \"later\": 2}\n\n def __init__(self):\n self.items = []\n\n def enqueue(self, value, priority=\"next\", origin=None):\n item = {\"id\": make_id(\"cmd\"), \"priority\": priority,\n \"origin\": origin or {}, \"value\": value}\n self.items.append(item)\n return item\n\n def dequeue(self, include_goal_continuations=True):\n # Goal continuations and the external async inbox are NOT the same drain.\n # With include_goal_continuations=False an inbox drain skips them, so a\n # goal can't be advanced (or blocked) before real evidence arrives.\n self.items.sort(key=lambda i: self.PRIORITY.get(i[\"priority\"], 1))\n for idx, item in enumerate(self.items):\n if include_goal_continuations or item[\"origin\"].get(\"kind\") != \"active-goal\":\n return self.items.pop(idx)\n return None\n\n def remove_by_origin(self, kind):\n before = len(self.items)\n self.items = [i for i in self.items if i[\"origin\"].get(\"kind\") != kind]\n return before - len(self.items)\n\n def __len__(self):\n return len(self.items)\n\n\n# ============================================================\n# GoalRuntime — the turn-completion gate (mirrors GoalRuntime)\n# ============================================================\nclass GoalRuntime:\n def __init__(self, transcript, queue):\n self.transcript = transcript # shared session transcript\n self.queue = queue\n self.active = None\n\n def set_goal(self, objective, max_turns=20):\n # start_index marks the evidence window. The /goal command line is\n # already recorded, so it sits OUTSIDE the window and can't satisfy\n # itself.\n self.active = {\n \"id\": make_id(\"goal\"), \"objective\": objective, \"status\": \"active\",\n \"start_index\": len(self.transcript), \"max_turns\": max_turns,\n \"checks\": 0, \"continuation_turns\": 0,\n }\n event(\"goal\", \"goal_started\", f\"{self.active['id']} :: {objective}\")\n return self.active\n\n def clear(self, reason=\"cleared\"):\n if not self.active:\n return\n self.active[\"status\"] = reason\n self.queue.remove_by_origin(\"active-goal\")\n event(\"goal\", \"goal_cleared\", reason)\n self.active = None\n\n def evidence_text(self):\n \"\"\"The trust boundary. Three filters keep self-satisfying text out:\n drop slash-command origins, drop /goal command lines, and keep ONLY\n trusted external async origins (task-notification / monitor-line).\"\"\"\n if not self.active:\n return \"\"\n out = []\n for m in self.transcript[self.active[\"start_index\"]:]:\n if m.origin.get(\"kind\") == \"slash-command\":\n continue\n if m.role == \"user\" and m.content.strip().startswith(\"/goal\"):\n continue\n if m.origin.get(\"kind\") not in TRUSTED_EVIDENCE_ORIGINS:\n continue\n out.append(f\"{m.role}: {m.content}\")\n return \"\\n\".join(out)\n\n def goal_satisfied(self):\n # Real Claude Code routes this to a small/fast evaluator model reading\n # the evidence window. The teaching version is a deterministic keyword\n # check so the lifecycle is reproducible.\n objective = self.active[\"objective\"].lower()\n evidence = self.evidence_text().lower()\n wants_tests = \"test\" in objective\n wants_deploy = \"deploy\" in objective or \"green\" in objective\n tests_ok = not wants_tests or \"tests passed\" in evidence or \"test passed\" in evidence\n deploy_ok = not wants_deploy or \"deploy green\" in evidence or \"deployment green\" in evidence\n if any(k in objective for k in (\"until\", \"pass\", \"green\")):\n return tests_ok and deploy_ok\n return objective in evidence\n\n def evaluate_after_turn(self):\n \"\"\"The gate, run after every turn. Returns completed / continuing /\n blocked / none.\"\"\"\n g = self.active\n if not g or g[\"status\"] != \"active\":\n return \"none\"\n g[\"checks\"] += 1\n satisfied = self.goal_satisfied()\n event(\"goal\", \"goal_evaluated\", f\"check #{g['checks']} satisfied={satisfied}\")\n if satisfied:\n g[\"status\"] = \"completed\"\n self.queue.remove_by_origin(\"active-goal\")\n event(\"goal\", \"goal_completed\", g[\"id\"])\n self.active = None\n return \"completed\"\n if g[\"continuation_turns\"] < g[\"max_turns\"]:\n g[\"continuation_turns\"] += 1\n self.queue.enqueue(\n value=(f\"Continue working toward active goal {g['id']}. Use tool/task \"\n \"evidence; do not treat this reminder as completion evidence.\"),\n priority=\"next\", origin={\"kind\": \"active-goal\", \"goal_id\": g[\"id\"]})\n event(\"goal\", \"goal_continuation_enqueued\",\n f\"turn {g['continuation_turns']}/{g['max_turns']}\")\n return \"continuing\"\n g[\"status\"] = \"blocked\"\n self.queue.remove_by_origin(\"active-goal\")\n event(\"goal\", \"goal_blocked\", f\"exceeded {g['max_turns']} turns\")\n self.active = None\n return \"blocked\"\n\n\n# ============================================================\n# Session — the main loop host with a Stop gate (mirrors submit / drain)\n# ============================================================\nclass Session:\n def __init__(self):\n self.transcript = []\n self.queue = CommandQueue()\n self.goal = GoalRuntime(self.transcript, self.queue)\n\n def _add(self, role, content, origin):\n self.transcript.append(Message(role, content, origin))\n\n def submit(self, text, origin=None):\n \"\"\"One turn: record the input, run a (mock) assistant turn, then let the\n goal gate evaluate. `origin` carries the trust label.\"\"\"\n origin = origin or {\"kind\": \"user\"}\n self._add(\"user\", text, origin) # input recorded with its origin\n kind = origin[\"kind\"]\n\n if kind == \"user\" and text.strip().startswith(\"/goal\"):\n arg = text.strip()[5:].strip()\n self._add(\"assistant\", f\"(slash) /goal {arg}\", {\"kind\": \"slash-command\"})\n if arg in (\"\", \"clear\", \"stop\", \"off\"):\n self.goal.clear()\n else:\n self.goal.set_goal(arg)\n elif kind in TRUSTED_EVIDENCE_ORIGINS:\n # The input itself (recorded above with a trusted origin) is the\n # evidence; the assistant just observes it.\n event(\"turn\", f\"observe {kind}\", text[:48])\n self._add(\"assistant\", f\"Observed {kind}: {text}\", origin)\n elif kind == \"active-goal\":\n event(\"turn\", \"continue-goal\", \"(reminder is not evidence)\")\n self._add(\"assistant\", \"Continuing the goal; checking task/monitor evidence.\", origin)\n else:\n event(\"turn\", \"assistant-turn\", text[:48])\n self._add(\"assistant\", f\"assistant handled: {text}\", {\"kind\": \"assistant\"})\n\n return self.goal.evaluate_after_turn() # <-- the Stop gate\n\n def drain_goal_continuation(self):\n \"\"\"Pull one goal continuation back into the loop — explicit, separate\n from any external async-inbox drain.\"\"\"\n item = self.queue.dequeue(include_goal_continuations=True)\n if item and item[\"origin\"].get(\"kind\") == \"active-goal\":\n return self.submit(item[\"value\"], origin=item[\"origin\"])\n return None\n\n\n# ============================================================\n# Demo\n# ============================================================\ndef banner(text):\n print(f\"\\n— {text} —\")\n\n\ndef main(argv):\n s = Session()\n\n banner(\"1. set a goal (the gate is now armed; window starts after the command)\")\n print(\"user> /goal until tests passed and deploy green\")\n s.submit(\"/goal until tests passed and deploy green\")\n\n banner(\"2. model works, no TRUSTED evidence yet -> the gate keeps it going\")\n s.drain_goal_continuation()\n s.submit(\"Inspecting the failing tests and the deploy config.\")\n\n banner(\"3. plain user text 'tests passed' is NOT trusted -> still not satisfied\")\n s.submit(\"tests passed, trust me\")\n s.drain_goal_continuation()\n print(f\" active goal still open: {s.goal.active is not None}\")\n\n banner(\"4. a background task lands a task-notification (trusted) -> satisfied\")\n verdict = s.submit(\"tests passed; deploy green\", origin={\"kind\": \"task-notification\"})\n print(f\" final verdict: goal {verdict}\")\n\n banner(\"5. budget: a goal that never gets evidence blocks after max_turns\")\n s2 = Session()\n s2.goal.set_goal(\"until tests passed\", max_turns=2)\n verdict = \"continuing\"\n while verdict == \"continuing\":\n verdict = s2.submit(\"still working, no task evidence yet\")\n print(f\" final verdict: goal {verdict}\")\n\n\nif __name__ == \"__main__\":\n main(sys.argv[1:])\n", + "images": [ + { + "src": "/course-assets/s22_goal_loop/goal-loop-overview.svg", + "alt": "goal loop overview" + } + ] } ], "diffs": [ @@ -3720,6 +3941,9 @@ "newClasses": [], "newFunctions": [ "estimate_size", + "_block_type", + "_message_has_tool_use", + "_is_tool_result_message", "snip_compact", "collect_tool_results", "micro_compact", @@ -3733,7 +3957,7 @@ "newTools": [ "compact" ], - "locDelta": 47 + "locDelta": 79 }, { "from": "s08", @@ -3752,7 +3976,7 @@ "persist_large" ], "newTools": [], - "locDelta": 116 + "locDelta": 114 }, { "from": "s09", @@ -3764,7 +3988,7 @@ "update_context" ], "newTools": [], - "locDelta": -332 + "locDelta": -362 }, { "from": "s10", @@ -3861,6 +4085,7 @@ "MessageBus" ], "newFunctions": [ + "has_pending_background", "spawn_teammate_thread", "run_spawn_teammate", "run_send_message", @@ -3871,7 +4096,7 @@ "spawn_teammate", "check_inbox" ], - "locDelta": 100 + "locDelta": 139 }, { "from": "s15", @@ -3894,7 +4119,7 @@ "request_plan", "review_plan" ], - "locDelta": -36 + "locDelta": -75 }, { "from": "s16", @@ -3982,6 +4207,9 @@ "has_tool_use", "spawn_subagent", "estimate_size", + "block_type", + "message_has_tool_use", + "is_tool_result_message", "collect_tool_results", "persist_large_output", "tool_result_budget", @@ -4026,7 +4254,7 @@ "list_crons", "cancel_cron" ], - "locDelta": 842 + "locDelta": 873 } ] -} +} \ No newline at end of file From cde64108b18878f1d03ebfb3564afe912d61d5cf Mon Sep 17 00:00:00 2001 From: Haoran Date: Sat, 27 Jun 2026 01:14:01 +0800 Subject: [PATCH 2/5] Apply dethan3's zh wording suggestions for s08 README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt dethan3's inline review suggestions on README.zh.md: sentence-flow polish, full-width colons, backticks on code identifiers, and a few more accurate wordings (压缩管线, 裁掉中间的旧对话). A few were adjusted rather than taken verbatim: keep "用满" instead of the suggested "撑满", drop a stray double period, leave the chapter title unchanged, and preserve the L3-to-L4 transition sentence that one suggestion had removed. Co-Authored-By: Claude Opus 4.8 (1M context) --- s08_context_compact/README.zh.md | 60 ++++++++++++++++---------------- web/src/data/generated/docs.json | 2 +- 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/s08_context_compact/README.zh.md b/s08_context_compact/README.zh.md index f089114bd..2d360aa4c 100644 --- a/s08_context_compact/README.zh.md +++ b/s08_context_compact/README.zh.md @@ -3,21 +3,21 @@ [中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 -> *"上下文总会满, 要有办法腾地方"* — 四层压缩策略, 便宜的先跑贵的后跑。 +> *"上下文总会满, 要有办法腾地方"* — 四层压缩管线,便宜的先跑,贵的后跑。 > -> **Harness 层**: 压缩 — 上下文超限时自动摘要,保持会话可持续。 +> **Harness 层**:压缩 — 上下文接近上限时先整理、再摘要,让长任务能持续跑下去。 --- ## 问题 -上一章给 Agent 加了 Skills,它开始有了一点"领域经验":遇到 PDF、MCP、代码审查,会先加载对应的操作说明再动手。 +上一章给 Agent 加了 Skills。它开始有了一点"领域经验":遇到 PDF、MCP server 或代码审查任务,会先加载对应的操作说明,再开始动手。 -但 Agent 越会做事,另一个问题越明显。它读一个 1000 行的文件就是 ~4000 token,又读了 30 个文件,跑了 20 条命令。每条命令的输出、每个文件的内容,都被塞回 `messages` 列表,一点点堆起来。 +但 Agent 越能干,另一个问题就越明显。它读一个 1000 行文件,可能就是约 4000 个 token;再读 30 个文件、跑 20 条命令,上下文很快就会被堆满。每条命令的输出、每个文件的内容,都被塞回 `messages` 列表,一点点堆起来。 -普通聊天几十轮不算什么。代码 Agent 不一样:一次读取就是几千行,一次测试就是一大段日志。任务还没做完,上下文窗口可能先满了。 +普通聊天聊几十轮,通常问题不大。但代码 Agent 不一样:一次读文件就是几千行,一次跑测试就是一大段日志。任务还没做完,上下文窗口可能先满了。 -满了之后,问题不是"模型答得差一点",而是 API 直接拒绝:`prompt_too_long`。不压缩,Agent 根本没法在大项目里干活。 +上下文满了之后,问题不是"模型回答质量变差一点",而是 API 会直接拒绝请求:`prompt_too_long`。不压缩,Agent 根本没法在大项目里干活。 --- @@ -25,11 +25,11 @@ s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_m ![Compact Overview](images/compact-overview.svg) -保留 s07 的 hook 结构、技能加载、子 Agent 骨架,这一章只往里加一层:每次调用 LLM 之前,先整理 `messages`。 +s07 里的 hook 结构、技能加载、子 Agent 骨架都保留。s08 只加一层:每次调用 LLM 之前,先整理 `messages`。 -最直接的想法是满了就让模型总结一下。但这里有两个问题。一是总结要多花一次 API 调用,每次上下文变大就摘要,成本很快上去。二是并不是所有内容都值得总结:很多旧工具结果早就不需要了;有些内容只是大,比如一次 `cat` 出几百 KB 日志,它不需要被"理解",只需要从上下文里挪走,必要时再读回来。 +最直觉的做法,是上下文快满时让模型总结一下。但这里有两个问题。一是总结要多花一次 API 调用,只要上下文变大就摘要,成本很快就会上去。二是并不是所有内容都值得总结:很多旧工具结果早就不需要了;有些内容只是大,比如一次 `cat` 出几百 KB 日志,它不一定需要被模型"理解",更多时候只是需要先从上下文里挪出去,必要时再读回来。 -所以 compact 不是一个动作,是一条管线。**便宜的先跑,贵的后跑**:先做几步不调模型的本地整理,能裁的裁、能占位的占位、能落盘的落盘;只有这些都不够,才让 LLM 做一次真正的摘要。 +所以,上下文压缩(compact)不是一个单点动作,而是一条管线。**便宜的先跑,贵的后跑**:先做几步不调模型的本地整理,能裁掉的先裁掉,能占位的先占位,能落盘的先落盘;只有这些都不够,才让 LLM 做一次真正的摘要。 --- @@ -37,11 +37,11 @@ s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_m ![四层压缩管线](images/compaction-layers.svg) -### L1: snip_compact — 裁掉无关的旧对话 +### L1: snip_compact — 裁掉中间的旧对话 -Agent 跑了 80 轮,`messages` 攒了 160 条。最前面那句"帮我创建 hello.py"和当前工作几乎无关了,但还占着位置。 +Agent 跑了 80 轮,`messages` 攒了 160 条。最开始那句"帮我创建 hello.py",和当前工作几乎已经没关系了,但还在上下文里占着位置。 -消息数超过 50 条 → 保留头部 3 条(最初的任务、约束)和尾部 47 条(当前工作),中间裁掉。唯一要小心的边界:不能把 `assistant(tool_use)` 和后面的 `user(tool_result)` 拆开,否则模型会看到一个不知道对应哪次调用的孤立结果。 +当消息数超过 50 条时,保留头部 3 条(最初的任务和约束)以及尾部 47 条(当前工作),中间部分裁掉。唯一要小心的边界:不能把 `assistant(tool_use)` 和后面的 `user(tool_result)` 拆开,否则模型会看到一条凭空出现的 `tool_result`,却不知道它对应哪一次工具调用。 ```python def snip_compact(messages, max_messages=50): @@ -60,7 +60,7 @@ def snip_compact(messages, max_messages=50): return messages[:head_end] + [{"role": "user", "content": f"[snipped {snipped} messages]"}] + messages[tail_start:] ``` -裁掉的是消息本身,在切口处多做一步保护。但剩下的消息里,`tool_result` 内容仍在累积,第 34 条消息里可能还躺着 30KB 的旧文件内容。消息数少了,token 没少。→ L2。 +裁掉的是消息本身,在切口处多做一步保护。但剩下的消息里,`tool_result` 内容仍在累积,第 34 条消息里可能还躺着 30KB 的旧文件内容。消息数是少了,但 token 未必真的少。→ L2。 ### L2: micro_compact — 旧工具结果占位 @@ -68,7 +68,7 @@ def snip_compact(messages, max_messages=50): 最容易把上下文撑大的,往往不是对话本身,而是工具结果。Agent 连续读了 10 个文件,第 1 到第 7 个的完整内容早就不需要了,却还原样躺在上下文里。 -保留最近 3 条 `tool_result` 的完整内容,更早的换成一行占位符。想法很朴素:旧结果真要用,模型重新读一次就行,不该一直占着位置。 +保留最近 3 条 `tool_result` 的完整内容,更早的换成一行占位符。想法很朴素:旧结果如果真有用,模型可以重新读一次;它不应该一直占着上下文。 ```python KEEP_RECENT = 3 @@ -82,15 +82,15 @@ def micro_compact(messages): return messages ``` -旧结果清掉了,但还挡不住一种情况:单条新结果就有 500KB,一次 `cat` 大文件的输出就能用满上下文,而它还很新,micro_compact 不会动它。→ L3。 +旧结果清掉了,但还挡不住一种情况:单条新结果就有 500KB,一次 `cat` 大文件的输出,就可能把上下文用满;但因为它是最新结果,`micro_compact` 不会处理它。→ L3。 ### L3: tool_result_budget — 大结果落盘 ![大结果落盘](images/layer1-budget.svg) -有些结果不是"多",是"单条太大"。模型一次读了 5 个大文件,最后一条 user 消息里的 `tool_result` 加起来超过 200KB,这时候只保留最近 3 条没用,因为最新的那条本身就能用满上下文。 +有些问题不是结果太多,而是单条结果太大。比如模型一次读了 5 个大文件,最后一条 user 消息里的 `tool_result` 加起来超过 200KB。此时只保留最近 3 条也没用,因为最新结果本身就可能用满上下文。 -给工具结果设一个预算。统计最后一条 user 消息里所有 `tool_result` 的总大小,超过 200KB 就从最大的开始落盘到 `.task_outputs/tool-results/`,上下文里只留一个 `` 标记 + 前 2000 字符预览。模型看到标记就知道完整内容在磁盘上,需要时再读回来。 +给工具结果设一个预算。统计最后一条 user 消息里所有 `tool_result` 的总大小,超过 200KB 就从最大的开始落盘到 `.task_outputs/tool-results/`,上下文里只留下一个 `` 标记,以及前 2000 个字符作为预览。模型看到这个标记就知道完整内容已经在磁盘上,后面需要时可以再读回来。 ```python def tool_result_budget(messages, max_bytes=200_000): @@ -108,15 +108,15 @@ def tool_result_budget(messages, max_bytes=200_000): return messages ``` -这一步重要的不是丢,是把内容从"活跃上下文"移到"可恢复的外部存储"。前三层到这就齐了:纯文本/结构操作,0 API 调用,各盯一种冗余。但它们都有同一个限制:读不懂对话在说什么,不知道哪些发现重要、哪些约束必须留。如果上下文还是太大,就只能让模型出手。→ L4。 +这一步的关键不是丢弃内容,而是把内容从"活跃上下文"移到"可恢复的外部存储"。前三层到这就齐了:纯文本或结构操作,0 API 调用,各自处理一种上下文膨胀。但它们有同一个限制:不理解对话内容,判断不了哪些发现重要、哪些约束必须留下。如果上下文还是太大,就只能让模型出手。→ L4。 ### L4: compact_history — LLM 全量摘要 ![LLM 全量摘要](images/auto-compact.svg) -前三层全跑完,token 还是超阈值。这一步才是很多人直觉里的"上下文压缩":把历史交给模型,摘成一段更短的状态。 +前三层全跑完,token 还是超阈值。这一步才是很多人直觉里的"上下文压缩":把历史交给模型,压成一段更短的状态。 -三步:先把完整对话写进 `.transcripts/`(JSONL),活跃上下文里只剩摘要,但磁盘上留着完整记录;再让 LLM 生成摘要,要求保留当前目标、重要发现、已改文件、剩余工作、用户约束;最后用这一条摘要替换掉所有旧消息。 +三步:先把完整对话写进 `.transcripts/`(JSONL),这样活跃上下文里只保留摘要,但磁盘上仍然保存完整记录;再让 LLM 生成摘要,要求保留当前目标、重要发现、已改文件、剩余工作、用户约束;最后用这一条摘要替换掉所有旧消息。 ```python def compact_history(messages): @@ -125,13 +125,13 @@ def compact_history(messages): return [{"role": "user", "content": f"[Compacted]\n\n{summary}"}] ``` -这一步是有损的:transcript 里有完整历史,但模型当下看不到那些细节了,只能靠摘要继续。所以前面才要先跑 L1/L2/L3:能不让模型总结就不总结,因为一旦进摘要,细节就不可避免地丢。教学版还加了个熔断器:连续 compact 失败 3 次就停,不无限重试浪费 API。 +这一步是有损的:transcript 里有完整历史,但模型当下看不到那些细节了,只能靠摘要继续。所以前面才要先跑 L1/L2/L3:能不让模型总结就不总结,因为一旦进入摘要,细节就不可避免会丢失。教学版还加了个熔断器:连续 compact 失败 3 次就停,避免无限重试浪费 API。 -### 应急: reactive_compact +### 应急:reactive_compact -正常情况下,我们在调用模型之前就把上下文整理好。但上下文增长太快、或 token 估算不准时,API 还是可能返回 `prompt_too_long`。 +正常情况下,我们在调用模型之前就把上下文整理好。但如果上下文增长太快、或 token 估算不准,API 还是可能返回 `prompt_too_long`。 -这时走 reactive_compact:和 compact_history 很像,但更激进:先存 transcript,对前面大半段生成摘要,只保留最后 5 条作尾部上下文(同样避免留下孤立 `tool_result`)。 +这时就走 `reactive_compact`,它和 `compact_history` 很像,但更激进:先保存 transcript,再把前面大半段压成摘要,只保留最后 5 条作为尾部上下文(同样避免留下孤立 `tool_result`)。 ```python def reactive_compact(messages): @@ -145,11 +145,11 @@ def reactive_compact(messages): return [{"role": "user", "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]] ``` -reactive 是兜底路径,不是常规路径,默认只重试 1 次,再失败就抛异常,不无限循环。完整的错误恢复逻辑留给 s11。 +reactive 是兜底路径,不是常规路径,默认只重试 1 次;如果再次失败,就抛出异常,避免无限循环。完整的错误恢复逻辑留给 s11。 ### 合起来跑 -把这些挂回 Agent Loop:每轮调用 LLM 之前,先跑三层本地整理,不够再摘要;调用真报错了,再走应急。 +把这些机制接回 Agent Loop:每轮调用 LLM 之前,先跑三层本地整理;如果还不够,再做摘要;如果调用时真的报错,再走应急路径。 ```python def agent_loop(messages): @@ -174,11 +174,11 @@ def agent_loop(messages): # ... 工具执行 ... ``` -**顺序不能换。** L3(budget)必须在 L2(micro)前面:micro 会把旧的大 `tool_result` 换成一行占位符,要是它先跑,budget 就没机会把完整内容落盘了。先 budget 把大内容存好,再做占位和裁剪。这也是 Claude Code 源码把 `applyToolResultBudget` 放在最前面的原因。 +**顺序不能换。** L3(budget)必须在 L2(micro)前面,`micro_compact` 会把旧的大 `tool_result` 替换成一行占位符。如果它先跑,`tool_result_budget` 就拿不到完整内容,也就没机会把它落盘。先 budget 把大内容存好,再做占位和裁剪。这也是 Claude Code 源码中 `applyToolResultBudget` 要放在最前面的原因。 ### compact 工具:让模型也能主动请求 -除了自动压缩,模型自己也能要求整理:当它觉得上下文太长、或任务阶段切换了,可以主动调 `compact` 工具。在教学版里,这个工具触发 `compact_history`,然后结束当前 turn,用压缩后的上下文重新开一轮。和手动 `/compact` 的感觉很像,区别是这次是模型自己意识到要整理。 +除了自动压缩,模型自己也能要求整理,当模型觉得上下文太长,或者任务已经切到新阶段时,可以主动调用 `compact` 工具。在教学版里,这个工具触发 `compact_history`,然后结束当前 turn,用压缩后的上下文重新开始下一轮。和手动 `/compact` 的感觉很像,区别在于,这次是模型自己意识到该整理上下文了。 --- @@ -190,9 +190,9 @@ def agent_loop(messages): | 新函数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact | | 工具 | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) | | 循环 | LLM 调用 → 工具执行 | 每轮前跑三层预处理器 + 阈值触发 compact_history | -| 设计原则 | 让 Agent 会做事 | 让 Agent 做久一点也不崩 | +| 设计原则 | 让 Agent 会做事 | 让 Agent 运行久一点也不崩 | -这一步不算给 Agent 加"能力",更像加"体力":s07 让它更会做专业任务,s08 让它在长任务里不被自己的历史拖垮。 +这一步不算是在给 Agent 加"能力",更像是在加"体力"。s07 让它更会做专业任务,s08 让它在长任务里不被自己的历史拖垮。 --- diff --git a/web/src/data/generated/docs.json b/web/src/data/generated/docs.json index 84eccf0e4..103f77956 100644 --- a/web/src/data/generated/docs.json +++ b/web/src/data/generated/docs.json @@ -135,7 +135,7 @@ "version": "s08", "locale": "zh", "title": "s08: Context Compact — 上下文总会满,要有办法腾地方", - "content": "# s08: Context Compact — 上下文总会满,要有办法腾地方\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/zh/s09) → s10 → ... → s20\n> *\"上下文总会满, 要有办法腾地方\"* — 四层压缩策略, 便宜的先跑贵的后跑。\n>\n> **Harness 层**: 压缩 — 上下文超限时自动摘要,保持会话可持续。\n\n---\n\n## 问题\n\n上一章给 Agent 加了 Skills,它开始有了一点\"领域经验\":遇到 PDF、MCP、代码审查,会先加载对应的操作说明再动手。\n\n但 Agent 越会做事,另一个问题越明显。它读一个 1000 行的文件就是 ~4000 token,又读了 30 个文件,跑了 20 条命令。每条命令的输出、每个文件的内容,都被塞回 `messages` 列表,一点点堆起来。\n\n普通聊天几十轮不算什么。代码 Agent 不一样:一次读取就是几千行,一次测试就是一大段日志。任务还没做完,上下文窗口可能先满了。\n\n满了之后,问题不是\"模型答得差一点\",而是 API 直接拒绝:`prompt_too_long`。不压缩,Agent 根本没法在大项目里干活。\n\n---\n\n## 解决方案\n\n![Compact Overview](/course-assets/s08_context_compact/compact-overview.svg)\n\n保留 s07 的 hook 结构、技能加载、子 Agent 骨架,这一章只往里加一层:每次调用 LLM 之前,先整理 `messages`。\n\n最直接的想法是满了就让模型总结一下。但这里有两个问题。一是总结要多花一次 API 调用,每次上下文变大就摘要,成本很快上去。二是并不是所有内容都值得总结:很多旧工具结果早就不需要了;有些内容只是大,比如一次 `cat` 出几百 KB 日志,它不需要被\"理解\",只需要从上下文里挪走,必要时再读回来。\n\n所以 compact 不是一个动作,是一条管线。**便宜的先跑,贵的后跑**:先做几步不调模型的本地整理,能裁的裁、能占位的占位、能落盘的落盘;只有这些都不够,才让 LLM 做一次真正的摘要。\n\n---\n\n## 工作原理\n\n![四层压缩管线](/course-assets/s08_context_compact/compaction-layers.svg)\n\n### L1: snip_compact — 裁掉无关的旧对话\n\nAgent 跑了 80 轮,`messages` 攒了 160 条。最前面那句\"帮我创建 hello.py\"和当前工作几乎无关了,但还占着位置。\n\n消息数超过 50 条 → 保留头部 3 条(最初的任务、约束)和尾部 47 条(当前工作),中间裁掉。唯一要小心的边界:不能把 `assistant(tool_use)` 和后面的 `user(tool_result)` 拆开,否则模型会看到一个不知道对应哪次调用的孤立结果。\n\n```python\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages: return messages\n keep_head, keep_tail = 3, max_messages - 3\n head_end, tail_start = keep_head, len(messages) - keep_tail\n if head_end > 0 and _message_has_tool_use(messages[head_end - 1]):\n while head_end < len(messages) and _is_tool_result_message(messages[head_end]):\n head_end += 1\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n if head_end >= tail_start: return messages\n snipped = tail_start - head_end\n return messages[:head_end] + [{\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}] + messages[tail_start:]\n```\n\n裁掉的是消息本身,在切口处多做一步保护。但剩下的消息里,`tool_result` 内容仍在累积,第 34 条消息里可能还躺着 30KB 的旧文件内容。消息数少了,token 没少。→ L2。\n\n### L2: micro_compact — 旧工具结果占位\n\n![旧结果占位](/course-assets/s08_context_compact/micro-compact.svg)\n\n最容易把上下文撑大的,往往不是对话本身,而是工具结果。Agent 连续读了 10 个文件,第 1 到第 7 个的完整内容早就不需要了,却还原样躺在上下文里。\n\n保留最近 3 条 `tool_result` 的完整内容,更早的换成一行占位符。想法很朴素:旧结果真要用,模型重新读一次就行,不该一直占着位置。\n\n```python\nKEEP_RECENT = 3\n\ndef micro_compact(messages):\n tool_results = collect_tool_results(messages)\n if len(tool_results) <= KEEP_RECENT: return messages\n for _, _, block in tool_results[:-KEEP_RECENT]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n```\n\n旧结果清掉了,但还挡不住一种情况:单条新结果就有 500KB,一次 `cat` 大文件的输出就能用满上下文,而它还很新,micro_compact 不会动它。→ L3。\n\n### L3: tool_result_budget — 大结果落盘\n\n![大结果落盘](/course-assets/s08_context_compact/layer1-budget.svg)\n\n有些结果不是\"多\",是\"单条太大\"。模型一次读了 5 个大文件,最后一条 user 消息里的 `tool_result` 加起来超过 200KB,这时候只保留最近 3 条没用,因为最新的那条本身就能用满上下文。\n\n给工具结果设一个预算。统计最后一条 user 消息里所有 `tool_result` 的总大小,超过 200KB 就从最大的开始落盘到 `.task_outputs/tool-results/`,上下文里只留一个 `` 标记 + 前 2000 字符预览。模型看到标记就知道完整内容在磁盘上,需要时再读回来。\n\n```python\ndef tool_result_budget(messages, max_bytes=200_000):\n last = messages[-1] if messages else None\n if not last or last.get(\"role\") != \"user\" or not isinstance(last.get(\"content\"), list): return messages\n blocks = [(i, b) for i, b in enumerate(last[\"content\"])\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes: return messages\n ranked = sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True)\n for _, block in ranked:\n if total <= max_bytes: break\n block[\"content\"] = persist_large_output(block.get(\"tool_use_id\", \"unknown\"), str(block.get(\"content\", \"\")))\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return messages\n```\n\n这一步重要的不是丢,是把内容从\"活跃上下文\"移到\"可恢复的外部存储\"。前三层到这就齐了:纯文本/结构操作,0 API 调用,各盯一种冗余。但它们都有同一个限制:读不懂对话在说什么,不知道哪些发现重要、哪些约束必须留。如果上下文还是太大,就只能让模型出手。→ L4。\n\n### L4: compact_history — LLM 全量摘要\n\n![LLM 全量摘要](/course-assets/s08_context_compact/auto-compact.svg)\n\n前三层全跑完,token 还是超阈值。这一步才是很多人直觉里的\"上下文压缩\":把历史交给模型,摘成一段更短的状态。\n\n三步:先把完整对话写进 `.transcripts/`(JSONL),活跃上下文里只剩摘要,但磁盘上留着完整记录;再让 LLM 生成摘要,要求保留当前目标、重要发现、已改文件、剩余工作、用户约束;最后用这一条摘要替换掉所有旧消息。\n\n```python\ndef compact_history(messages):\n transcript_path = write_transcript(messages) # 先存完整对话\n summary = summarize_history(messages) # LLM 生成摘要\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n```\n\n这一步是有损的:transcript 里有完整历史,但模型当下看不到那些细节了,只能靠摘要继续。所以前面才要先跑 L1/L2/L3:能不让模型总结就不总结,因为一旦进摘要,细节就不可避免地丢。教学版还加了个熔断器:连续 compact 失败 3 次就停,不无限重试浪费 API。\n\n### 应急: reactive_compact\n\n正常情况下,我们在调用模型之前就把上下文整理好。但上下文增长太快、或 token 估算不准时,API 还是可能返回 `prompt_too_long`。\n\n这时走 reactive_compact:和 compact_history 很像,但更激进:先存 transcript,对前面大半段生成摘要,只保留最后 5 条作尾部上下文(同样避免留下孤立 `tool_result`)。\n\n```python\ndef reactive_compact(messages):\n transcript = write_transcript(messages)\n tail_start = max(0, len(messages) - 5)\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n summary = summarize_history(messages[:tail_start])\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *messages[tail_start:]]\n```\n\nreactive 是兜底路径,不是常规路径,默认只重试 1 次,再失败就抛异常,不无限循环。完整的错误恢复逻辑留给 s11。\n\n### 合起来跑\n\n把这些挂回 Agent Loop:每轮调用 LLM 之前,先跑三层本地整理,不够再摘要;调用真报错了,再走应急。\n\n```python\ndef agent_loop(messages):\n reactive_retries = 0\n while True:\n # 三个预处理器(0 API),顺序:budget → snip → micro\n messages[:] = tool_result_budget(messages) # L3: 大结果落盘\n messages[:] = snip_compact(messages) # L1: 裁中间\n messages[:] = micro_compact(messages) # L2: 旧结果占位\n\n if estimate_size(messages) > CONTEXT_LIMIT: # 还不够,LLM 摘要(1 API)\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000)\n except Exception as e:\n if \"prompt_too_long\" in str(e).lower() and reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = reactive_compact(messages) # 应急\n reactive_retries += 1\n continue\n raise\n # ... 工具执行 ...\n```\n\n**顺序不能换。** L3(budget)必须在 L2(micro)前面:micro 会把旧的大 `tool_result` 换成一行占位符,要是它先跑,budget 就没机会把完整内容落盘了。先 budget 把大内容存好,再做占位和裁剪。这也是 Claude Code 源码把 `applyToolResultBudget` 放在最前面的原因。\n\n### compact 工具:让模型也能主动请求\n\n除了自动压缩,模型自己也能要求整理:当它觉得上下文太长、或任务阶段切换了,可以主动调 `compact` 工具。在教学版里,这个工具触发 `compact_history`,然后结束当前 turn,用压缩后的上下文重新开一轮。和手动 `/compact` 的感觉很像,区别是这次是模型自己意识到要整理。\n\n---\n\n## 相对 s07 的变更\n\n| 组件 | 之前 (s07) | 之后 (s08) |\n|------|-----------|-----------|\n| 上下文管理 | 无(上下文无限膨胀) | 四层压缩管线 + 应急 |\n| 新函数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |\n| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) |\n| 循环 | LLM 调用 → 工具执行 | 每轮前跑三层预处理器 + 阈值触发 compact_history |\n| 设计原则 | 让 Agent 会做事 | 让 Agent 做久一点也不崩 |\n\n这一步不算给 Agent 加\"能力\",更像加\"体力\":s07 让它更会做专业任务,s08 让它在长任务里不被自己的历史拖垮。\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\n试试这些 prompt:\n\n1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(连续读多个文件,观察 L2 压缩旧结果)\n2. `Read every file in s08_context_compact/`(一次性读大量内容,观察 L3 落盘)\n3. 反复对话 20+ 轮,观察是否出现 `[auto compact]` 或 `[reactive compact]`\n\n观察重点:每次工具执行后,旧 `tool_result` 是否被替换?大输出有没有落盘?token 超阈值时是否生成了摘要?\n\n---\n\n## 接下来\n\n压缩让 Agent 能跑很久不崩。但每次压缩都会丢一些细节:用户之前说过的偏好、项目里的长期约束、某些跨任务都重要的信息,不一定能完整留在摘要里。\n\ncompact 解决的是\"当前会话快满了,怎么继续跑下去\"。它没解决\"哪些信息值得长期留下来\"。\n\ns09 Memory → 三个子系统:选择记什么、提取关键信息、整理巩固。跨压缩、跨会话。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` 的分析。\n\n### 执行顺序对照\n\n教学版为了讲解方便按 L1/L2/L3/L4 编号,但实际执行顺序和编号不完全对应:\n\n| 维度 | 教学版 | Claude Code |\n|------|--------|-------------|\n| 执行顺序 | budget → snip → micro → auto | budget → snip → micro → collapse → auto(`query.ts:379-468`) |\n| snip_compact | 保留头 3 + 尾 47 | Claude Code 仅主线程启用;实现不在开源仓库中(`HISTORY_SNIP` feature gate),但接口可见:`snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`,还暴露了 `SnipTool` 工具让模型主动调用。教学版的 3/47 是简化参数 |\n| micro_compact | 文本占位符替换 | 两条路径:time-based 直接清内容,cached 走 API `cache_edits`(legacy path 已移除) |\n| micro_compact 白名单 | 按位置(最近 3 条) | time-based 按时间阈值触发;cached 按计数触发(`microCompact.ts`) |\n| tool_result_budget | 200KB 字符 | 200,000 字符(`toolLimits.ts:49`) |\n| compact_history 阈值 | 字符数估算 | 精确 token:`contextWindow - maxOutputTokens - 13_000` |\n| 摘要要求 | 5 类信息 | 9 个部分 + ``/`` 双标签 |\n| 压缩 prompt | 简单 prompt | 首尾双重防呆禁止调工具 |\n| PTL retry | 有(简化) | `truncateHeadForPTLRetry()` 按消息组回退(`compact.ts:243-290`) |\n| 后压缩恢复 | 无(教学版只保留摘要) | 自动重新读取最近文件、计划、agent/skill/tool 等 |\n| 熔断器 | 3 次 | 3 次(`autoCompact.ts:70`) |\n| reactive 重试 | 1 次 | Claude Code 有更精细的分级重试 |\n\n### 执行顺序详解\n\nClaude Code 源码 `query.ts` 中的真实顺序:\n\n1. `applyToolResultBudget`(L379):先处理大结果,确保完整内容落盘\n2. `snipCompact`(L403):裁中间消息\n3. `microcompact`(L414):旧结果占位\n4. `contextCollapse`(L441):独立的上下文管理系统(教学版无)\n5. `autoCompact`(L454):LLM 全量摘要\n\n教学版的 budget → snip → micro 顺序与此一致。教学版没有 contextCollapse 机制。\n\n### read_file 的取舍\n\n教学版的 `micro_compact` 会把旧 `tool_result` 统一替换成占位符,包括 `read_file`。这通常不影响功能正确性:如果后续还需要文件内容,模型可以重新读一次。代价是可能多一次工具调用,也可能降低 prompt cache 命中率。\n\nClaude Code 没有用教学版这种简单规则解决这个问题。它把 `Read` 也放进可 microcompact 的工具集合,但同时维护 `readFileState`:重复读取未变化文件时返回 `FILE_UNCHANGED_STUB`,compact 后再按预算恢复最近读过的文件内容(例如最多 5 个文件、每个 5K token、总预算 50K token)。这是生产级实现里的缓存和恢复机制,教学版不展开,保留“压缩旧结果,必要时重新读取”的简单 trade-off。\n\n### 完整常量参考\n\n| 常量 | 值 | 源文件 |\n|------|-----|--------|\n| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |\n| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |\n| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |\n| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |\n| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |\n| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |\n| 时间 micro_compact 间隔 | 60 分钟 | `timeBasedMCConfig.ts` |\n| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |\n\n### contextCollapse 和 sessionMemoryCompact\n\nClaude Code 源码中还有两个机制本教学版没有展开:\n\n- **contextCollapse**:独立的上下文管理系统,启用时抑制 proactive autocompact(`autoCompact.ts:215-222`),由 collapse 的 commit/blocking 流程接管上下文管理。但 manual `/compact` 和 reactive fallback 仍是独立路径,不受 contextCollapse 影响。\n- **sessionMemoryCompact**:compact_history 之前,Claude Code 会先尝试用已有的 session memory(s09 会讲到)做轻量摘要,不调 LLM。这个机制等学完 s09 之后回头看会更清楚。\n\n### 压缩 prompt 长什么样?\n\nClaude Code 的压缩 prompt 有两个硬性要求:\n\n1. **绝对禁止调用工具**:开头就是 `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`,末尾还会再 REMINDER 一次\n2. **先分析再总结**:模型需要先在 `` 标签里理清思路,然后在 `` 标签里输出正式摘要。analysis 在格式化时被剥离\n\n### 教学版的简化是刻意的\n\n- micro_compact 用文本占位 → 我们没有 API 层的 `cache_edits` 权限\n- read_file 不特殊处理 → 教学版接受必要时重新读取,避免引入 readFileState 和后压缩恢复机制\n- token 用字符数估算 → 精确 tokenizer 不在教学范围内\n- 后压缩恢复省略 → 教学版只保留摘要,不自动重新附加文件\n- 两个辅助机制不展开 → 属于 10% 的细节\n\n核心设计思想完整保留。\n\n
\n\n\n" + "content": "# s08: Context Compact — 上下文总会满,要有办法腾地方\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/zh/s09) → s10 → ... → s20\n> *\"上下文总会满, 要有办法腾地方\"* — 四层压缩管线,便宜的先跑,贵的后跑。\n>\n> **Harness 层**:压缩 — 上下文接近上限时先整理、再摘要,让长任务能持续跑下去。\n\n---\n\n## 问题\n\n上一章给 Agent 加了 Skills。它开始有了一点\"领域经验\":遇到 PDF、MCP server 或代码审查任务,会先加载对应的操作说明,再开始动手。\n\n但 Agent 越能干,另一个问题就越明显。它读一个 1000 行文件,可能就是约 4000 个 token;再读 30 个文件、跑 20 条命令,上下文很快就会被堆满。每条命令的输出、每个文件的内容,都被塞回 `messages` 列表,一点点堆起来。\n\n普通聊天聊几十轮,通常问题不大。但代码 Agent 不一样:一次读文件就是几千行,一次跑测试就是一大段日志。任务还没做完,上下文窗口可能先满了。\n\n上下文满了之后,问题不是\"模型回答质量变差一点\",而是 API 会直接拒绝请求:`prompt_too_long`。不压缩,Agent 根本没法在大项目里干活。\n\n---\n\n## 解决方案\n\n![Compact Overview](/course-assets/s08_context_compact/compact-overview.svg)\n\ns07 里的 hook 结构、技能加载、子 Agent 骨架都保留。s08 只加一层:每次调用 LLM 之前,先整理 `messages`。\n\n最直觉的做法,是上下文快满时让模型总结一下。但这里有两个问题。一是总结要多花一次 API 调用,只要上下文变大就摘要,成本很快就会上去。二是并不是所有内容都值得总结:很多旧工具结果早就不需要了;有些内容只是大,比如一次 `cat` 出几百 KB 日志,它不一定需要被模型\"理解\",更多时候只是需要先从上下文里挪出去,必要时再读回来。\n\n所以,上下文压缩(compact)不是一个单点动作,而是一条管线。**便宜的先跑,贵的后跑**:先做几步不调模型的本地整理,能裁掉的先裁掉,能占位的先占位,能落盘的先落盘;只有这些都不够,才让 LLM 做一次真正的摘要。\n\n---\n\n## 工作原理\n\n![四层压缩管线](/course-assets/s08_context_compact/compaction-layers.svg)\n\n### L1: snip_compact — 裁掉中间的旧对话\n\nAgent 跑了 80 轮,`messages` 攒了 160 条。最开始那句\"帮我创建 hello.py\",和当前工作几乎已经没关系了,但还在上下文里占着位置。\n\n当消息数超过 50 条时,保留头部 3 条(最初的任务和约束)以及尾部 47 条(当前工作),中间部分裁掉。唯一要小心的边界:不能把 `assistant(tool_use)` 和后面的 `user(tool_result)` 拆开,否则模型会看到一条凭空出现的 `tool_result`,却不知道它对应哪一次工具调用。\n\n```python\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages: return messages\n keep_head, keep_tail = 3, max_messages - 3\n head_end, tail_start = keep_head, len(messages) - keep_tail\n if head_end > 0 and _message_has_tool_use(messages[head_end - 1]):\n while head_end < len(messages) and _is_tool_result_message(messages[head_end]):\n head_end += 1\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n if head_end >= tail_start: return messages\n snipped = tail_start - head_end\n return messages[:head_end] + [{\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}] + messages[tail_start:]\n```\n\n裁掉的是消息本身,在切口处多做一步保护。但剩下的消息里,`tool_result` 内容仍在累积,第 34 条消息里可能还躺着 30KB 的旧文件内容。消息数是少了,但 token 未必真的少。→ L2。\n\n### L2: micro_compact — 旧工具结果占位\n\n![旧结果占位](/course-assets/s08_context_compact/micro-compact.svg)\n\n最容易把上下文撑大的,往往不是对话本身,而是工具结果。Agent 连续读了 10 个文件,第 1 到第 7 个的完整内容早就不需要了,却还原样躺在上下文里。\n\n保留最近 3 条 `tool_result` 的完整内容,更早的换成一行占位符。想法很朴素:旧结果如果真有用,模型可以重新读一次;它不应该一直占着上下文。\n\n```python\nKEEP_RECENT = 3\n\ndef micro_compact(messages):\n tool_results = collect_tool_results(messages)\n if len(tool_results) <= KEEP_RECENT: return messages\n for _, _, block in tool_results[:-KEEP_RECENT]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n```\n\n旧结果清掉了,但还挡不住一种情况:单条新结果就有 500KB,一次 `cat` 大文件的输出,就可能把上下文用满;但因为它是最新结果,`micro_compact` 不会处理它。→ L3。\n\n### L3: tool_result_budget — 大结果落盘\n\n![大结果落盘](/course-assets/s08_context_compact/layer1-budget.svg)\n\n有些问题不是结果太多,而是单条结果太大。比如模型一次读了 5 个大文件,最后一条 user 消息里的 `tool_result` 加起来超过 200KB。此时只保留最近 3 条也没用,因为最新结果本身就可能用满上下文。\n\n给工具结果设一个预算。统计最后一条 user 消息里所有 `tool_result` 的总大小,超过 200KB 就从最大的开始落盘到 `.task_outputs/tool-results/`,上下文里只留下一个 `` 标记,以及前 2000 个字符作为预览。模型看到这个标记就知道完整内容已经在磁盘上,后面需要时可以再读回来。\n\n```python\ndef tool_result_budget(messages, max_bytes=200_000):\n last = messages[-1] if messages else None\n if not last or last.get(\"role\") != \"user\" or not isinstance(last.get(\"content\"), list): return messages\n blocks = [(i, b) for i, b in enumerate(last[\"content\"])\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes: return messages\n ranked = sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True)\n for _, block in ranked:\n if total <= max_bytes: break\n block[\"content\"] = persist_large_output(block.get(\"tool_use_id\", \"unknown\"), str(block.get(\"content\", \"\")))\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return messages\n```\n\n这一步的关键不是丢弃内容,而是把内容从\"活跃上下文\"移到\"可恢复的外部存储\"。前三层到这就齐了:纯文本或结构操作,0 API 调用,各自处理一种上下文膨胀。但它们有同一个限制:不理解对话内容,判断不了哪些发现重要、哪些约束必须留下。如果上下文还是太大,就只能让模型出手。→ L4。\n\n### L4: compact_history — LLM 全量摘要\n\n![LLM 全量摘要](/course-assets/s08_context_compact/auto-compact.svg)\n\n前三层全跑完,token 还是超阈值。这一步才是很多人直觉里的\"上下文压缩\":把历史交给模型,压成一段更短的状态。\n\n三步:先把完整对话写进 `.transcripts/`(JSONL),这样活跃上下文里只保留摘要,但磁盘上仍然保存完整记录;再让 LLM 生成摘要,要求保留当前目标、重要发现、已改文件、剩余工作、用户约束;最后用这一条摘要替换掉所有旧消息。\n\n```python\ndef compact_history(messages):\n transcript_path = write_transcript(messages) # 先存完整对话\n summary = summarize_history(messages) # LLM 生成摘要\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n```\n\n这一步是有损的:transcript 里有完整历史,但模型当下看不到那些细节了,只能靠摘要继续。所以前面才要先跑 L1/L2/L3:能不让模型总结就不总结,因为一旦进入摘要,细节就不可避免会丢失。教学版还加了个熔断器:连续 compact 失败 3 次就停,避免无限重试浪费 API。\n\n### 应急:reactive_compact\n\n正常情况下,我们在调用模型之前就把上下文整理好。但如果上下文增长太快、或 token 估算不准,API 还是可能返回 `prompt_too_long`。\n\n这时就走 `reactive_compact`,它和 `compact_history` 很像,但更激进:先保存 transcript,再把前面大半段压成摘要,只保留最后 5 条作为尾部上下文(同样避免留下孤立 `tool_result`)。\n\n```python\ndef reactive_compact(messages):\n transcript = write_transcript(messages)\n tail_start = max(0, len(messages) - 5)\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n summary = summarize_history(messages[:tail_start])\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *messages[tail_start:]]\n```\n\nreactive 是兜底路径,不是常规路径,默认只重试 1 次;如果再次失败,就抛出异常,避免无限循环。完整的错误恢复逻辑留给 s11。\n\n### 合起来跑\n\n把这些机制接回 Agent Loop:每轮调用 LLM 之前,先跑三层本地整理;如果还不够,再做摘要;如果调用时真的报错,再走应急路径。\n\n```python\ndef agent_loop(messages):\n reactive_retries = 0\n while True:\n # 三个预处理器(0 API),顺序:budget → snip → micro\n messages[:] = tool_result_budget(messages) # L3: 大结果落盘\n messages[:] = snip_compact(messages) # L1: 裁中间\n messages[:] = micro_compact(messages) # L2: 旧结果占位\n\n if estimate_size(messages) > CONTEXT_LIMIT: # 还不够,LLM 摘要(1 API)\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000)\n except Exception as e:\n if \"prompt_too_long\" in str(e).lower() and reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = reactive_compact(messages) # 应急\n reactive_retries += 1\n continue\n raise\n # ... 工具执行 ...\n```\n\n**顺序不能换。** L3(budget)必须在 L2(micro)前面,`micro_compact` 会把旧的大 `tool_result` 替换成一行占位符。如果它先跑,`tool_result_budget` 就拿不到完整内容,也就没机会把它落盘。先 budget 把大内容存好,再做占位和裁剪。这也是 Claude Code 源码中 `applyToolResultBudget` 要放在最前面的原因。\n\n### compact 工具:让模型也能主动请求\n\n除了自动压缩,模型自己也能要求整理,当模型觉得上下文太长,或者任务已经切到新阶段时,可以主动调用 `compact` 工具。在教学版里,这个工具触发 `compact_history`,然后结束当前 turn,用压缩后的上下文重新开始下一轮。和手动 `/compact` 的感觉很像,区别在于,这次是模型自己意识到该整理上下文了。\n\n---\n\n## 相对 s07 的变更\n\n| 组件 | 之前 (s07) | 之后 (s08) |\n|------|-----------|-----------|\n| 上下文管理 | 无(上下文无限膨胀) | 四层压缩管线 + 应急 |\n| 新函数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |\n| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) |\n| 循环 | LLM 调用 → 工具执行 | 每轮前跑三层预处理器 + 阈值触发 compact_history |\n| 设计原则 | 让 Agent 会做事 | 让 Agent 运行久一点也不崩 |\n\n这一步不算是在给 Agent 加\"能力\",更像是在加\"体力\"。s07 让它更会做专业任务,s08 让它在长任务里不被自己的历史拖垮。\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\n试试这些 prompt:\n\n1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(连续读多个文件,观察 L2 压缩旧结果)\n2. `Read every file in s08_context_compact/`(一次性读大量内容,观察 L3 落盘)\n3. 反复对话 20+ 轮,观察是否出现 `[auto compact]` 或 `[reactive compact]`\n\n观察重点:每次工具执行后,旧 `tool_result` 是否被替换?大输出有没有落盘?token 超阈值时是否生成了摘要?\n\n---\n\n## 接下来\n\n压缩让 Agent 能跑很久不崩。但每次压缩都会丢一些细节:用户之前说过的偏好、项目里的长期约束、某些跨任务都重要的信息,不一定能完整留在摘要里。\n\ncompact 解决的是\"当前会话快满了,怎么继续跑下去\"。它没解决\"哪些信息值得长期留下来\"。\n\ns09 Memory → 三个子系统:选择记什么、提取关键信息、整理巩固。跨压缩、跨会话。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` 的分析。\n\n### 执行顺序对照\n\n教学版为了讲解方便按 L1/L2/L3/L4 编号,但实际执行顺序和编号不完全对应:\n\n| 维度 | 教学版 | Claude Code |\n|------|--------|-------------|\n| 执行顺序 | budget → snip → micro → auto | budget → snip → micro → collapse → auto(`query.ts:379-468`) |\n| snip_compact | 保留头 3 + 尾 47 | Claude Code 仅主线程启用;实现不在开源仓库中(`HISTORY_SNIP` feature gate),但接口可见:`snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`,还暴露了 `SnipTool` 工具让模型主动调用。教学版的 3/47 是简化参数 |\n| micro_compact | 文本占位符替换 | 两条路径:time-based 直接清内容,cached 走 API `cache_edits`(legacy path 已移除) |\n| micro_compact 白名单 | 按位置(最近 3 条) | time-based 按时间阈值触发;cached 按计数触发(`microCompact.ts`) |\n| tool_result_budget | 200KB 字符 | 200,000 字符(`toolLimits.ts:49`) |\n| compact_history 阈值 | 字符数估算 | 精确 token:`contextWindow - maxOutputTokens - 13_000` |\n| 摘要要求 | 5 类信息 | 9 个部分 + ``/`` 双标签 |\n| 压缩 prompt | 简单 prompt | 首尾双重防呆禁止调工具 |\n| PTL retry | 有(简化) | `truncateHeadForPTLRetry()` 按消息组回退(`compact.ts:243-290`) |\n| 后压缩恢复 | 无(教学版只保留摘要) | 自动重新读取最近文件、计划、agent/skill/tool 等 |\n| 熔断器 | 3 次 | 3 次(`autoCompact.ts:70`) |\n| reactive 重试 | 1 次 | Claude Code 有更精细的分级重试 |\n\n### 执行顺序详解\n\nClaude Code 源码 `query.ts` 中的真实顺序:\n\n1. `applyToolResultBudget`(L379):先处理大结果,确保完整内容落盘\n2. `snipCompact`(L403):裁中间消息\n3. `microcompact`(L414):旧结果占位\n4. `contextCollapse`(L441):独立的上下文管理系统(教学版无)\n5. `autoCompact`(L454):LLM 全量摘要\n\n教学版的 budget → snip → micro 顺序与此一致。教学版没有 contextCollapse 机制。\n\n### read_file 的取舍\n\n教学版的 `micro_compact` 会把旧 `tool_result` 统一替换成占位符,包括 `read_file`。这通常不影响功能正确性:如果后续还需要文件内容,模型可以重新读一次。代价是可能多一次工具调用,也可能降低 prompt cache 命中率。\n\nClaude Code 没有用教学版这种简单规则解决这个问题。它把 `Read` 也放进可 microcompact 的工具集合,但同时维护 `readFileState`:重复读取未变化文件时返回 `FILE_UNCHANGED_STUB`,compact 后再按预算恢复最近读过的文件内容(例如最多 5 个文件、每个 5K token、总预算 50K token)。这是生产级实现里的缓存和恢复机制,教学版不展开,保留“压缩旧结果,必要时重新读取”的简单 trade-off。\n\n### 完整常量参考\n\n| 常量 | 值 | 源文件 |\n|------|-----|--------|\n| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |\n| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |\n| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |\n| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |\n| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |\n| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |\n| 时间 micro_compact 间隔 | 60 分钟 | `timeBasedMCConfig.ts` |\n| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |\n\n### contextCollapse 和 sessionMemoryCompact\n\nClaude Code 源码中还有两个机制本教学版没有展开:\n\n- **contextCollapse**:独立的上下文管理系统,启用时抑制 proactive autocompact(`autoCompact.ts:215-222`),由 collapse 的 commit/blocking 流程接管上下文管理。但 manual `/compact` 和 reactive fallback 仍是独立路径,不受 contextCollapse 影响。\n- **sessionMemoryCompact**:compact_history 之前,Claude Code 会先尝试用已有的 session memory(s09 会讲到)做轻量摘要,不调 LLM。这个机制等学完 s09 之后回头看会更清楚。\n\n### 压缩 prompt 长什么样?\n\nClaude Code 的压缩 prompt 有两个硬性要求:\n\n1. **绝对禁止调用工具**:开头就是 `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`,末尾还会再 REMINDER 一次\n2. **先分析再总结**:模型需要先在 `` 标签里理清思路,然后在 `` 标签里输出正式摘要。analysis 在格式化时被剥离\n\n### 教学版的简化是刻意的\n\n- micro_compact 用文本占位 → 我们没有 API 层的 `cache_edits` 权限\n- read_file 不特殊处理 → 教学版接受必要时重新读取,避免引入 readFileState 和后压缩恢复机制\n- token 用字符数估算 → 精确 tokenizer 不在教学范围内\n- 后压缩恢复省略 → 教学版只保留摘要,不自动重新附加文件\n- 两个辅助机制不展开 → 属于 10% 的细节\n\n核心设计思想完整保留。\n\n
\n\n\n" }, { "version": "s08", From ae58af86f785c9fe98ec1bad53e7500abbc6f70c Mon Sep 17 00:00:00 2001 From: Haoran Date: Sat, 27 Jun 2026 02:38:15 +0800 Subject: [PATCH 3/5] Polish s06 prose (zh/en/ja) with the s08 narrative technique MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the same narrative upgrade s08 got. The problem section now opens by carrying over from s05's todo_write; the solution leads with a why-not and an anchor ("把脏活外包出去,只收一句结论"); the code block is sandwiched with a "key points" recap after it; and the "three key design decisions" header is corrected to "four" (the table always had four rows). Word-level de-AI-toning per the user's review: 跟踪 over the clipped 追, "实际改动" over the opaque 副作用, "无限循环" over the slangy 跑飞, and "被填满" over 爆了. All three languages synced to v2; web docs regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) --- s06_subagent/README.ja.md | 26 ++++++++++------- s06_subagent/README.md | 26 ++++++++++------- s06_subagent/README.zh.md | 50 +++++++++++++++++--------------- web/src/data/generated/docs.json | 6 ++-- 4 files changed, 60 insertions(+), 48 deletions(-) diff --git a/s06_subagent/README.ja.md b/s06_subagent/README.ja.md index c15ea528c..9c3b10bd0 100644 --- a/s06_subagent/README.ja.md +++ b/s06_subagent/README.ja.md @@ -4,7 +4,7 @@ s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20 -> *"大きなタスクは小さく、小さなタスクごとにクリーンなコンテキスト"* — Subagent は独立した messages[] を使い、メイン会話を汚染しない。 +> *"大きなタスクは小さく、小さなタスクごとにクリーンなコンテキスト"* — Subagent は独立した `messages[]` を使い、メイン会話を汚染しない。 > > **Harness レイヤー**: サブエージェント — コンテキストの隔離、注意の散漫を防ぐ。 @@ -12,11 +12,11 @@ s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) ## 課題 -Agent がバグを修正している。呼び出しチェーンを追跡するために 30 のファイルを読み、途中で 60 ラウンドやり取りした。messages リストは 120 件に膨らみ、その大部分は「呼び出しチェーンの追跡」という中間過程であり、「バグ修正」という最終目標とは無関係。 +前章で Agent に `todo_write` を加えた。大きなタスクをチェックリストに分解し、一歩ずつ進められる。しかし分解したサブタスクは、まだ同じ `messages[]` の中で動いている。 -この中間過程がコンテキストの席を占め、初期の重要な情報が有効ウィンドウから押し出され、元の問題に対するモデルの応答品質が低下する。 +Agent がバグを修正している:呼び出しチェーンを追跡するために 30 のファイルを読み、60 ラウンドやり取りし、`messages` は 120 件に膨らむ。その大半は「呼び出しチェーンの追跡」という中間過程で、「バグ修正」という最終目標とはもう関係がないのに、まだコンテキストを占めている。初期の重要な情報は有効ウィンドウから押し出され、モデルは最初のバグの説明を、かえって覚えていられなくなる。 -別の見方をすると:バグを修正するとき、あなたは「新しいターミナルを開いて」呼び出しチェーンを追跡するだろう。追跡が終わったらターミナルを閉じ、結果をメモに書き、元のターミナルに戻ってバグ修正を続ける。Agent にも同じ仕組みが必要になる。独立したサブプロセスを spawn し、専用のメッセージリストで単一タスクに限定する。 +別の見方をしよう:バグを修正するとき、あなたは「新しいターミナルを開いて」呼び出しチェーンを追跡し、終わったら結果をメモして、ターミナルを閉じ、元のターミナルに戻って修正を続ける。Agent にも同じ能力が要る:独立したサブプロセスを spawn し、専用のメッセージリストを与え、一つのことに集中させ、終わったら結論だけ持ち帰らせる。 --- @@ -24,15 +24,17 @@ Agent がバグを修正している。呼び出しチェーンを追跡する ![Subagent Overview](images/subagent-overview.svg) -前章の最小フック構造と `todo_write` ツールを保持し、本章は新規の `task` ツールに注目する。呼び出されると、サブエージェントを spawn する。新しい `messages[]` を持ち、自分自身のループを実行し、終了後に要約テキストのみをメイン Agent に返す。会話コンテキストは破棄されるが、ファイルシステムの副作用(書き込み、編集、コマンド実行)は作業ディレクトリに残る。 +直感的なやり方は、メイン Agent 自身に呼び出しチェーンを追跡させ、そのまま修正を続けさせることだ。だが追跡の過程はすべてメイン会話に残る。これがまさに上の問題だ。 -サブエージェントのツールは制限される:bash/read/write/edit/glob を持つが、task はない。再帰 spawn を防止する。サブエージェントのツール呼び出しも権限フックを経由する。コンテキスト分離は権限のバイパスではない。 +そこで発想を変える:**面倒な作業は外に出し、戻ってくるのは一言の結論だけにする。** 前章のフック構造と `todo_write` はそのまま残し、本章で `task` ツールを 1 つ加える。メイン Agent がそれを呼ぶと、サブエージェントを spawn し、新しい `messages[]` を与えて自分のループを走らせる。終わったら要約テキストだけが返り、間の 60 ラウンドはすべて破棄される。会話コンテキストはメイン Agent に入らないが、サブエージェントがファイルシステムに加えた実際の変更(書き込み、編集、コマンド実行)は作業ディレクトリに残る。 + +サブエージェントのツールは制限されている:`bash`/`read`/`write`/`edit`/`glob` はあるが `task` はなく、再帰的に新しいサブエージェントを spawn できない。そして各ツール呼び出しはやはり権限フックを経由する。コンテキストを隔離しても、セキュリティポリシーは飛ばさない。 --- ## 仕組み -**spawn_subagent**、サブエージェントに新しいメッセージリストを与え、自分自身のループを実行し、結論のみを返す: +サブエージェントは本質的に、あの s01 のループのもう 1 つのインスタンスにすぎない。空の `messages[]` と、より狭いツールセットに差し替えただけだ: ```python def spawn_subagent(description: str) -> str: @@ -65,7 +67,9 @@ def spawn_subagent(description: str) -> str: return extract_text(messages[-1]["content"]) ``` -メイン Agent の呼び出しは、他のツールと同じ: +このループの要点をいくつか:ツールセットに `task` がない(もう spawn できず、再帰はここで止まる);`for _ in range(30)` はラウンド数の上限(サブエージェントは最多 30 ラウンド、無限ループを避ける);各ツール呼び出しの前にやはり `PreToolUse` フックを通る(コンテキストは隔離するが、権限は隔離しない);最後は `extract_text(messages[-1])` で結論を 1 つ取り出すだけで、中間過程はまるごと捨てる。 + +メイン Agent の側では、それを呼ぶのは他のツールを呼ぶのとまったく同じだ: ```python TOOLS = [ @@ -84,7 +88,7 @@ TOOLS = [ TOOL_HANDLERS["task"] = spawn_subagent ``` -三つの重要な設計決定: +四つの重要な設計決定: | 決定 | 選択 | 理由 | |------|------|------| @@ -127,7 +131,7 @@ python s06_subagent/code.py ## 次へ -Agent はタスクを分割できるようになった。しかし各タスクに必要な知識は異なる。フロントエンドコンポーネントの変更には React 規約が必要で、SQL を書くにはテーブル構造を知る必要がある。これらの知識をすべて system prompt に詰め込むと、コンテキストが溢れてしまう。 +Agent はタスクを分割できるようになった。しかし各タスクに必要な知識は異なる。フロントエンドコンポーネントの変更には React 規約が必要で、SQL を書くにはテーブル構造を知る必要がある。これらの知識をすべて system prompt に詰め込むと、コンテキストが一気に埋まってしまうこともある。 → s07 Skill Loading:スキルをオンデマンドで注入する。system prompt にドキュメントを積み上げるのではなく、必要なときだけ読み込む。ファイルを読むのと同じくらい自然に。 @@ -186,4 +190,4 @@ Fork Agent の `permissionMode: 'bubble'`(`forkSubagent.ts:67`)は、サブ
- + diff --git a/s06_subagent/README.md b/s06_subagent/README.md index 96c0d395d..68ccd76ef 100644 --- a/s06_subagent/README.md +++ b/s06_subagent/README.md @@ -4,7 +4,7 @@ s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20 -> *"Break large tasks small, each with clean context"* — Subagent uses an independent messages[], no pollution in the main conversation. +> *"Break large tasks small, each with clean context"* — Subagent uses an independent `messages[]`, no pollution in the main conversation. > > **Harness Layer**: Sub-Agent — Context isolation, attention doesn't drift. @@ -12,11 +12,11 @@ s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) ## The Problem -The Agent is fixing a bug. It reads 30 files to trace the call chain, chatting for 60 rounds along the way. The messages list grows to 120 entries, most of which are intermediate steps from "tracing the call chain," unrelated to the final goal of "fixing the bug." +The last chapter gave the Agent `todo_write`, which breaks a big task into a checklist and works through it step by step. But the subtasks it breaks out still run in the same `messages[]`. -These intermediate steps occupy context space, pushing early information out of the effective window and degrading the model's response quality for the original problem. +The Agent is fixing a bug: it reads 30 files to trace the call chain, goes back and forth for 60 rounds, and `messages` swells to 120 entries. Most of that is the "trace the call chain" intermediate work, no longer related to the final goal of "fixing the bug", yet still taking up context. Early key information gets pushed out of the effective window, and the model half-forgets the original bug description it started with. -Think of it differently: when you fix a bug, you'd "open a new terminal" to trace the call chain. When done, close the terminal, write the result into your notes, and return to the original terminal to keep fixing. The Agent needs the same capability: spawn an independent sub-process with its own message list, scoped to a single task. +Think of it differently: when you fix a bug you "open a new terminal" to trace the call chain, note down the result when done, close the terminal, and go back to the original one to keep fixing. The Agent needs the same ability: spawn an independent sub-process, give it its own message list, let it focus on one thing, and bring back only the conclusion. --- @@ -24,15 +24,17 @@ Think of it differently: when you fix a bug, you'd "open a new terminal" to trac ![Subagent Overview](images/subagent-overview.svg) -The minimal hook structure and `todo_write` tool from the previous chapter are preserved; this chapter focuses on the new `task` tool. When called, it spawns a sub-Agent with a fresh `messages[]`, running its own loop, and returning only a summary text to the main Agent. Conversation context is discarded, but file system side effects (writes, edits, commands) remain in the working directory. +One intuitive approach is to let the main Agent trace the call chain itself and then keep fixing. But the tracing would all stay in the main conversation, which is exactly the problem above. -The sub-Agent's tools are restricted: it has bash/read/write/edit/glob, but no task, preventing recursive spawning. The sub-Agent's tool calls still go through permission hooks; context isolation does not bypass security. +So take a different angle: **outsource the grunt work, take back only a one-line conclusion.** The hook structure and `todo_write` from the previous chapter stay; this chapter adds a `task` tool. When the main Agent calls it, it spawns a sub-Agent with a fresh `messages[]` and lets it run its own loop; when done, only a summary text comes back, and those 60 intermediate rounds are discarded. The conversation context never enters the main Agent, but the actual changes the sub-Agent makes on the file system (writing files, editing files, running commands) stay in the working directory. + +The sub-Agent's tools are restricted: it has `bash`/`read`/`write`/`edit`/`glob`, but no `task`, so it can't recursively spawn more sub-Agents. And every one of its tool calls still goes through the permission hook; context isolation doesn't skip the security policy. --- ## How It Works -**spawn_subagent**, gives the sub-Agent a fresh messages list, runs its own loop, returns only the conclusion: +A sub-Agent is essentially another instance of that s01 loop, just with an empty `messages[]` and a narrower toolset: ```python def spawn_subagent(description: str) -> str: @@ -65,7 +67,9 @@ def spawn_subagent(description: str) -> str: return extract_text(messages[-1]["content"]) ``` -The main Agent calls it just like any other tool: +Note a few key points in this loop: the toolset has no `task` (no more spawning, recursion stops here); `for _ in range(30)` caps the rounds (the sub-Agent runs at most 30, avoiding an infinite loop); every tool call still goes through the `PreToolUse` hook (context is isolated, but permissions are not); and at the end only `extract_text(messages[-1])` takes a single conclusion, discarding the whole intermediate process. + +On the main Agent's side, calling it is exactly like calling any other tool: ```python TOOLS = [ @@ -84,7 +88,7 @@ TOOLS = [ TOOL_HANDLERS["task"] = spawn_subagent ``` -Three key design decisions: +Four key design decisions: | Decision | Choice | Reason | |----------|--------|--------| @@ -127,7 +131,7 @@ What to watch for: Do `[Subagent spawned]` / `[Subagent done]` appear? Do sub-Ag ## What's Next -The Agent can now break tasks apart. But different tasks require different knowledge: editing frontend components needs React conventions, writing SQL needs table schemas. Stuffing all this knowledge into the system prompt would blow up the context. +The Agent can now break tasks apart. But different tasks require different knowledge: editing frontend components needs React conventions, writing SQL needs table schemas. Stuffing all this knowledge into the system prompt could fill up the context outright. → s07 Skill Loading: Inject skills on demand instead of piling documents into the system prompt. Load only when needed, as natural as reading a file. @@ -186,4 +190,4 @@ The teaching version only shows synchronous sub-Agents (parent waits for child t
- + diff --git a/s06_subagent/README.zh.md b/s06_subagent/README.zh.md index a04423bdc..d4a29a816 100644 --- a/s06_subagent/README.zh.md +++ b/s06_subagent/README.zh.md @@ -4,7 +4,7 @@ s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) → s08 → ... → s20 -> *"大任务拆小, 每个小任务干净的上下文"* — Subagent 用独立 messages[], 不污染主对话。 +> *"大任务拆小, 每个小任务干净的上下文"* — Subagent 用独立 `messages[]`, 不污染主对话。 > > **Harness 层**: 子 Agent — 上下文隔离, 注意力不漂移。 @@ -12,11 +12,11 @@ s01 → s02 → s03 → s04 → s05 → `s06` → [s07](../s07_skill_loading/) ## 问题 -Agent 在修一个 bug。它读了 30 个文件来追踪调用链,中间聊了 60 轮。messages 列表涨到 120 条,其中大部分是"追踪调用链"的中间过程,和"修 bug"这个最终目标无关。 +上一章给 Agent 加了 `todo_write`,它能把大任务拆成一张清单,一步步推进。但拆出来的子任务,还是在同一个 `messages[]` 里跑。 -这些中间过程占着上下文位置,早期的关键信息被挤出有效窗口,模型对最初的问题描述的响应质量下降。 +Agent 在修一个 bug:读了 30 个文件跟踪调用链,来回聊了 60 轮,`messages` 涨到 120 条。其中大半是"跟踪调用链"的中间过程,和"修 bug"这个最终目标已经没关系了,却还占着上下文。早期的关键信息被挤出有效窗口,模型对最初那个 bug 的描述,反而记不清了。 -换个角度:你修 bug 的时候,会"开一个新终端"来追踪调用链。追踪完了,终端关掉,结果写进笔记,回到原来的终端继续修 bug。Agent 也需要这个能力:开一个独立的子进程,给它一个独立的消息列表,让它专心做一件事。 +换个角度想:你修 bug 时会"开一个新终端"去跟踪调用链,跟踪完把结果记下来,关掉终端,回到原来的终端接着修。Agent 也需要这个能力:开一个独立的子进程,给它一份独立的消息列表,让它专心做一件事,做完只把结论带回来。 --- @@ -24,15 +24,17 @@ Agent 在修一个 bug。它读了 30 个文件来追踪调用链,中间聊了 ![Subagent Overview](images/subagent-overview.svg) -保留上一章的最小 hook 结构和 `todo_write` 工具,本章重点转向新增的 `task` 工具。调用它时,spawn 一个子 Agent,拥有全新的 `messages[]`,跑自己的循环,结束后只把摘要文本回传给主 Agent。对话上下文被丢弃,但文件系统的副作用(写文件、改文件、跑命令)保留在工作目录中。 +一个符合直觉的做法,是让主 Agent 自己把调用链跟踪完、接着修。但跟踪的过程会全部留在主对话里,这正是上面那个问题。 -子 Agent 的工具受限:有 bash/read/write/edit/glob,但没有 task,不能递归 spawn 新的子 Agent。子 Agent 的工具调用仍经过权限 hook,安全策略不因上下文隔离而跳过。 +所以换个思路:**把脏活外包出去,只收一句结论。** 上一章的 hook 结构和 `todo_write` 都保留,本章新增一个 `task` 工具。主 Agent 调它时,spawn 一个子 Agent,给它全新的 `messages[]`,让它跑自己的循环;结束后只回传一段摘要文本,中间那 60 轮全部丢弃。对话上下文不进主 Agent,但子 Agent 在文件系统上做的实际改动(写文件、改文件、跑命令)会留在工作目录里。 + +子 Agent 的工具是受限的:有 `bash`/`read`/`write`/`edit`/`glob`,但没有 `task`,不能再递归 spawn 新的子 Agent。而且它的每次工具调用仍走权限 hook,安全策略不因为上下文隔离就跳过。 --- ## 工作原理 -**spawn_subagent**,给子 Agent 一个全新的 messages 列表,跑自己的循环,只回传结论: +子 Agent 本质上就是 s01 那个循环的另一份实例,只是换了一份空的 `messages[]` 和一套更窄的工具: ```python def spawn_subagent(description: str) -> str: @@ -69,7 +71,9 @@ def spawn_subagent(description: str) -> str: return extract_text(messages[-1]["content"]) ``` -主 Agent 调用时,跟调其他工具一样: +注意这个循环里的几处关键点:工具集里没有 `task`(不能再 spawn,递归到此为止);`for _ in range(30)` 是循环轮次的安全上限(子 Agent 最多跑 30 轮,避免无限循环);每次工具调用前仍走 `PreToolUse` hook(隔离了上下文,但没隔离权限);最后只用 `extract_text(messages[-1])` 取一句结论,中间过程整份丢掉。 + +主 Agent 这边,调它跟调其他工具完全一样: ```python TOOLS = [ @@ -88,16 +92,16 @@ TOOLS = [ TOOL_HANDLERS["task"] = spawn_subagent ``` -三个关键设计决策: +四个关键设计决策: | 决策 | 选择 | 原因 | |------|------|------| | 上下文隔离 | 全新 `messages[]` | 子 Agent 的中间过程不污染主 Agent 的上下文 | -| 只回传结论 | `extract_text(last_message)` | 不是回传整个 messages 列表 | -| 禁止递归 | 子 Agent 无 task 工具 | 防止子 Agent 再 spawn 新的子 Agent | +| 只回传结论 | `extract_text(last_message)` | 不是回传整个 `messages` 列表 | +| 禁止递归 | 子 Agent 无 `task` 工具 | 防止子 Agent 再 spawn 新的子 Agent | | 安全策略不跳过 | 子 Agent 工具调用也走 PreToolUse hook | 上下文隔离不代表权限隔离 | -dispatch 机制不变,task 工具通过 `TOOL_HANDLERS[block.name]` 分发。子 Agent 有独立的 `SUB_SYSTEM` 提示,明确要求"直接完成任务,不要再委派"。 +dispatch 机制不变,`task` 工具通过 `TOOL_HANDLERS[block.name]` 分发。子 Agent 还有独立的 `SUB_SYSTEM` 提示,明确要求"直接完成任务,不要再委派"。 --- @@ -105,10 +109,10 @@ dispatch 机制不变,task 工具通过 `TOOL_HANDLERS[block.name]` 分发。 | 组件 | 之前 (s05) | 之后 (s06) | |------|-----------|-----------| -| 工具数量 | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) | -| 新函数 | — | spawn_subagent(独立 messages[] + 30 轮安全限制) | -| 上下文隔离 | 全部在主对话中 | 子 Agent 用全新的 messages[] | -| 循环 | 不变 | dispatch 不变,子 Agent 有独立 SUB_SYSTEM 和 hook 保护的循环 | +| 工具数量 | 6 (`bash`, `read`, `write`, `edit`, `glob`, `todo_write`) | 7 (+`task`) | +| 新函数 | — | `spawn_subagent`(独立 `messages[]` + 30 轮安全限制) | +| 上下文隔离 | 全部在主对话中 | 子 Agent 用全新的 `messages[]` | +| 循环 | 不变 | dispatch 不变,子 Agent 有独立 `SUB_SYSTEM` 和 hook 保护的循环 | --- @@ -131,7 +135,7 @@ python s06_subagent/code.py ## 接下来 -Agent 现在能拆任务了。但每个任务需要的知识不一样:改前端组件需要知道 React 规范,写 SQL 需要知道表结构。这些知识全塞进 system prompt,上下文直接爆了。 +Agent 现在能拆任务了。但每个任务需要的知识不一样:改前端组件需要知道 React 规范,写 SQL 需要知道表结构。这些知识全塞进 system prompt,上下文有可能会直接被填满。 s07 Skill Loading → 技能按需注入,不在 system prompt 里堆文档。用到的时候才加载,和读文件一样自然。 @@ -142,11 +146,11 @@ s07 Skill Loading → 技能按需注入,不在 system prompt 里堆文档。 ### 一、不是一种模式,是三种 -教学版只讲了"全新的 messages[]"。Claude Code 实际有三种执行模式: +教学版只讲了"全新的 `messages[]`"。Claude Code 实际有三种执行模式: | 模式 | 触发条件 | 上下文 | |------|---------|--------| -| **Normal Subagent** | 指定了 `subagent_type`(normal path) | 全新 messages[],只有 prompt | +| **Normal Subagent** | 指定了 `subagent_type`(normal path) | 全新 `messages[]`,只有 prompt | | **Fork Subagent** | 没指定 `subagent_type`,fork gate 开启 | 通过 `buildForkedMessages()` 构造 cache-friendly 前缀,共享 prompt cache | | **General-Purpose** | 没指定 `subagent_type`,fork gate 关闭 | 同 Normal | @@ -171,7 +175,7 @@ s07 Skill Loading → 技能按需注入,不在 system prompt 里堆文档。 ### 四、递归 Fork 防护 -教学版用"子 Agent 不给 task 工具"表达递归保护。真实实现更精细:`isInForkChild()`(`forkSubagent.ts:78-89`)检查对话历史中是否有 `FORK_BOILERPLATE_TAG`,有就拒绝。但 `constants/tools.ts:36-46` 中 `Agent` 工具默认在所有 agent 的禁用集合里,`USER_TYPE === 'ant'` 时例外;`forkSubagent.ts:73-89` 针对 fork child 有专门的递归保护;`agentToolUtils.ts:100-110` 在 teammate 场景下有特殊放行。不是简单的"禁止新的子 Agent"。 +教学版用"子 Agent 不给 `task` 工具"表达递归保护。真实实现更精细:`isInForkChild()`(`forkSubagent.ts:78-89`)检查对话历史中是否有 `FORK_BOILERPLATE_TAG`,有就拒绝。但 `constants/tools.ts:36-46` 中 `Agent` 工具默认在所有 agent 的禁用集合里,`USER_TYPE === 'ant'` 时例外;`forkSubagent.ts:73-89` 针对 fork child 有专门的递归保护;`agentToolUtils.ts:100-110` 在 teammate 场景下有特殊放行。不是简单的"禁止新的子 Agent"。 ### 五、Permission Bubbling @@ -183,11 +187,11 @@ Fork Agent 的 `permissionMode: 'bubble'`(`forkSubagent.ts:67`)意味着子 ### 教学版的简化是刻意的 -- 三种模式 → 一种(fresh messages):概念清晰 +- 三种模式 → 一种(全新 `messages[]`):概念清晰 - Prompt cache 共享 → 省略:教学版不涉及 API 层优化 -- 递归 fork 防护 → 简化为"子 Agent 无 task 工具" +- 递归 fork 防护 → 简化为"子 Agent 无 `task` 工具" - Async → 省略(留给 s13):s06 先理解同步模型
- + diff --git a/web/src/data/generated/docs.json b/web/src/data/generated/docs.json index 103f77956..6b460913f 100644 --- a/web/src/data/generated/docs.json +++ b/web/src/data/generated/docs.json @@ -93,19 +93,19 @@ "version": "s06", "locale": "en", "title": "s06: Subagent — Break Large Tasks into Small Ones with Clean Context", - "content": "# s06: Subagent — Break Large Tasks into Small Ones with Clean Context\n\ns01 → s02 → s03 → s04 → s05 → `s06` → [s07](/en/s07) → s08 → ... → s20\n\n> *\"Break large tasks small, each with clean context\"* — Subagent uses an independent messages[], no pollution in the main conversation.\n>\n> **Harness Layer**: Sub-Agent — Context isolation, attention doesn't drift.\n\n---\n\n## The Problem\n\nThe Agent is fixing a bug. It reads 30 files to trace the call chain, chatting for 60 rounds along the way. The messages list grows to 120 entries, most of which are intermediate steps from \"tracing the call chain,\" unrelated to the final goal of \"fixing the bug.\"\n\nThese intermediate steps occupy context space, pushing early information out of the effective window and degrading the model's response quality for the original problem.\n\nThink of it differently: when you fix a bug, you'd \"open a new terminal\" to trace the call chain. When done, close the terminal, write the result into your notes, and return to the original terminal to keep fixing. The Agent needs the same capability: spawn an independent sub-process with its own message list, scoped to a single task.\n\n---\n\n## The Solution\n\n![Subagent Overview](/course-assets/s06_subagent/subagent-overview.svg)\n\nThe minimal hook structure and `todo_write` tool from the previous chapter are preserved; this chapter focuses on the new `task` tool. When called, it spawns a sub-Agent with a fresh `messages[]`, running its own loop, and returning only a summary text to the main Agent. Conversation context is discarded, but file system side effects (writes, edits, commands) remain in the working directory.\n\nThe sub-Agent's tools are restricted: it has bash/read/write/edit/glob, but no task, preventing recursive spawning. The sub-Agent's tool calls still go through permission hooks; context isolation does not bypass security.\n\n---\n\n## How It Works\n\n**spawn_subagent**, gives the sub-Agent a fresh messages list, runs its own loop, returns only the conclusion:\n\n```python\ndef spawn_subagent(description: str) -> str:\n # Sub-Agent tools: base tools, but no task (no recursion)\n sub_tools = [...]\n messages = [{\"role\": \"user\", \"content\": description}] # fresh messages[]\n\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=sub_tools, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({... \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown\"\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({... \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # Return only the final text conclusion, all intermediate steps discarded\n return extract_text(messages[-1][\"content\"])\n```\n\nThe main Agent calls it just like any other tool:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n {\"name\": \"todo_write\", ...},\n # s06: new task tool\n {\"name\": \"task\",\n \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n]\n\nTOOL_HANDLERS[\"task\"] = spawn_subagent\n```\n\nThree key design decisions:\n\n| Decision | Choice | Reason |\n|----------|--------|--------|\n| Context isolation | Fresh `messages[]` | Sub-Agent's intermediate steps don't pollute main Agent's context |\n| Return only conclusion | `extract_text(last_message)` | Not returning the entire messages list |\n| No recursion | Sub-Agent has no task tool | Prevents sub-Agent from spawning further sub-Agents |\n| Security not bypassed | Sub-Agent tool calls go through PreToolUse hook | Context isolation does not mean permission isolation |\n\nThe dispatch mechanism is unchanged; the task tool is routed through `TOOL_HANDLERS[block.name]`. The sub-Agent has its own `SUB_SYSTEM` prompt, explicitly instructing \"complete the task, do not delegate further.\"\n\n---\n\n## Changes from s05\n\n| Component | Before (s05) | After (s06) |\n|-----------|-------------|-------------|\n| Tool count | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) |\n| New function | — | spawn_subagent (independent messages[] + 30-round safety limit) |\n| Context isolation | Everything in the main conversation | Sub-Agent uses fresh messages[] |\n| Loop | Unchanged | Dispatch unchanged, sub-Agent has independent SUB_SYSTEM and hook-protected loop |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s06_subagent/code.py\n```\n\nTry these prompts:\n\n1. `Use a subtask to find what testing framework this project uses` (sub-Agent reads files, main Agent receives only the conclusion)\n2. `Delegate: read all .py files in agents/ and summarize what each one does`\n3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`\n\nWhat to watch for: Do `[Subagent spawned]` / `[Subagent done]` appear? Do sub-Agent tool calls print as `[sub] ...`? Does the parent Agent continue with only the summary returned by the sub-Agent?\n\n---\n\n## What's Next\n\nThe Agent can now break tasks apart. But different tasks require different knowledge: editing frontend components needs React conventions, writing SQL needs table schemas. Stuffing all this knowledge into the system prompt would blow up the context.\n\n→ s07 Skill Loading: Inject skills on demand instead of piling documents into the system prompt. Load only when needed, as natural as reading a file.\n\n
\nDive into Claude Code Source Code\n\n> The following is based on a complete analysis of Claude Code source code `AgentTool.tsx`, `runAgent.ts`, `forkSubagent.ts`, and `forkedAgent.ts`.\n\n### 1. Not One Pattern, but Three\n\nThe teaching version covers only \"fresh messages[]\". Claude Code actually has three execution modes:\n\n| Mode | Trigger | Context |\n|------|---------|---------|\n| **Normal Subagent** | `subagent_type` specified (normal path) | Truly fresh messages[], only the prompt |\n| **Fork Subagent** | No `subagent_type`, fork gate enabled | Constructs cache-friendly prefix via `buildForkedMessages()`, shares prompt cache |\n| **General-Purpose** | No `subagent_type`, fork gate disabled | Same as Normal |\n\n### 2. Fork Mode: Sharing Prompt Cache\n\nThis is a core concept the teaching version omits. Fork mode (`forkSubagent.ts:60-71`) doesn't create a fresh context. Instead, it constructs a cache-friendly message prefix via `buildForkedMessages()` (`forkSubagent.ts:107-168`), preserving the parent assistant message and generating placeholder tool results. The goal isn't isolation, but making the Anthropic API's prompt cache hit: parent and child Agent's system prompt, tools, and message prefix are byte-identical, so the API doesn't need to recompute.\n\nFive key components for cache hit (`forkedAgent.ts:57-68`): system prompt, tools, model, message prefix, thinking config, must be byte-identical.\n\n### 3. Context Isolation's Precise Granularity\n\n`createSubagentContext()` (`forkedAgent.ts:345-462`) creates the sub-Agent's `ToolUseContext`:\n\n| Field | Behavior |\n|-------|----------|\n| `abortController` | New child controller; parent abort propagates down |\n| `setAppState` | Default no-op; but sync agents share via `shareSetAppState` (`runAgent.ts:697-714`) |\n| `readFileState` | **Cloned from parent** (avoids re-reading same files) |\n| `queryTracking` | New chainId, `depth = parentDepth + 1` |\n\nThe sub-Agent isn't fully isolated: file read state is shared. The degree of UI and notification isolation varies by execution path (sync/async/fork/teammate differ).\n\n### 4. Recursive Fork Protection\n\nThe teaching version uses \"sub-Agent has no task tool\" for recursion protection. The real implementation is more nuanced: `isInForkChild()` (`forkSubagent.ts:78-89`) checks for `FORK_BOILERPLATE_TAG` in history. But `constants/tools.ts:36-46` defaults `Agent` to all agents' disabled set (with `USER_TYPE === 'ant'` exception); `forkSubagent.ts:73-89` has fork-child-specific recursion protection; `agentToolUtils.ts:100-110` has special allowances in teammate scenarios. Not simply \"no further sub-Agents.\"\n\n### 5. Permission Bubbling\n\nFork Agent's `permissionMode: 'bubble'` (`forkSubagent.ts:67`) means the sub-Agent's permission prompts bubble up to the parent terminal: the user approves sub-Agent operations in the main terminal.\n\n### 6. Async vs Sync\n\nThe teaching version only shows synchronous sub-Agents (parent waits for child to finish). Claude Code also supports async paths (`AgentTool.tsx:686-764`): when `run_in_background: true`, the sub-Agent launches asynchronously, returning `{ status: 'async_launched' }` immediately to the parent, and notifies the parent when complete. Actual triggers go beyond `run_in_background`, including auto-background, assistant force async, and coordinator/proactive paths.\n\n### Teaching Version Simplifications Are Intentional\n\n- Three modes → one (fresh messages): conceptually clear\n- Prompt cache sharing → omitted: teaching version doesn't involve API-layer optimization\n- Recursive fork protection → simplified to \"sub-Agent has no task tool\"\n- Async → omitted (left for s13): s06 focuses on the synchronous model first\n\n
\n\n\n" + "content": "# s06: Subagent — Break Large Tasks into Small Ones with Clean Context\n\ns01 → s02 → s03 → s04 → s05 → `s06` → [s07](/en/s07) → s08 → ... → s20\n\n> *\"Break large tasks small, each with clean context\"* — Subagent uses an independent `messages[]`, no pollution in the main conversation.\n>\n> **Harness Layer**: Sub-Agent — Context isolation, attention doesn't drift.\n\n---\n\n## The Problem\n\nThe last chapter gave the Agent `todo_write`, which breaks a big task into a checklist and works through it step by step. But the subtasks it breaks out still run in the same `messages[]`.\n\nThe Agent is fixing a bug: it reads 30 files to trace the call chain, goes back and forth for 60 rounds, and `messages` swells to 120 entries. Most of that is the \"trace the call chain\" intermediate work, no longer related to the final goal of \"fixing the bug\", yet still taking up context. Early key information gets pushed out of the effective window, and the model half-forgets the original bug description it started with.\n\nThink of it differently: when you fix a bug you \"open a new terminal\" to trace the call chain, note down the result when done, close the terminal, and go back to the original one to keep fixing. The Agent needs the same ability: spawn an independent sub-process, give it its own message list, let it focus on one thing, and bring back only the conclusion.\n\n---\n\n## The Solution\n\n![Subagent Overview](/course-assets/s06_subagent/subagent-overview.svg)\n\nOne intuitive approach is to let the main Agent trace the call chain itself and then keep fixing. But the tracing would all stay in the main conversation, which is exactly the problem above.\n\nSo take a different angle: **outsource the grunt work, take back only a one-line conclusion.** The hook structure and `todo_write` from the previous chapter stay; this chapter adds a `task` tool. When the main Agent calls it, it spawns a sub-Agent with a fresh `messages[]` and lets it run its own loop; when done, only a summary text comes back, and those 60 intermediate rounds are discarded. The conversation context never enters the main Agent, but the actual changes the sub-Agent makes on the file system (writing files, editing files, running commands) stay in the working directory.\n\nThe sub-Agent's tools are restricted: it has `bash`/`read`/`write`/`edit`/`glob`, but no `task`, so it can't recursively spawn more sub-Agents. And every one of its tool calls still goes through the permission hook; context isolation doesn't skip the security policy.\n\n---\n\n## How It Works\n\nA sub-Agent is essentially another instance of that s01 loop, just with an empty `messages[]` and a narrower toolset:\n\n```python\ndef spawn_subagent(description: str) -> str:\n # Sub-Agent tools: base tools, but no task (no recursion)\n sub_tools = [...]\n messages = [{\"role\": \"user\", \"content\": description}] # fresh messages[]\n\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=sub_tools, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({... \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown\"\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({... \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # Return only the final text conclusion, all intermediate steps discarded\n return extract_text(messages[-1][\"content\"])\n```\n\nNote a few key points in this loop: the toolset has no `task` (no more spawning, recursion stops here); `for _ in range(30)` caps the rounds (the sub-Agent runs at most 30, avoiding an infinite loop); every tool call still goes through the `PreToolUse` hook (context is isolated, but permissions are not); and at the end only `extract_text(messages[-1])` takes a single conclusion, discarding the whole intermediate process.\n\nOn the main Agent's side, calling it is exactly like calling any other tool:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n {\"name\": \"todo_write\", ...},\n # s06: new task tool\n {\"name\": \"task\",\n \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n]\n\nTOOL_HANDLERS[\"task\"] = spawn_subagent\n```\n\nFour key design decisions:\n\n| Decision | Choice | Reason |\n|----------|--------|--------|\n| Context isolation | Fresh `messages[]` | Sub-Agent's intermediate steps don't pollute main Agent's context |\n| Return only conclusion | `extract_text(last_message)` | Not returning the entire messages list |\n| No recursion | Sub-Agent has no task tool | Prevents sub-Agent from spawning further sub-Agents |\n| Security not bypassed | Sub-Agent tool calls go through PreToolUse hook | Context isolation does not mean permission isolation |\n\nThe dispatch mechanism is unchanged; the task tool is routed through `TOOL_HANDLERS[block.name]`. The sub-Agent has its own `SUB_SYSTEM` prompt, explicitly instructing \"complete the task, do not delegate further.\"\n\n---\n\n## Changes from s05\n\n| Component | Before (s05) | After (s06) |\n|-----------|-------------|-------------|\n| Tool count | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) |\n| New function | — | spawn_subagent (independent messages[] + 30-round safety limit) |\n| Context isolation | Everything in the main conversation | Sub-Agent uses fresh messages[] |\n| Loop | Unchanged | Dispatch unchanged, sub-Agent has independent SUB_SYSTEM and hook-protected loop |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s06_subagent/code.py\n```\n\nTry these prompts:\n\n1. `Use a subtask to find what testing framework this project uses` (sub-Agent reads files, main Agent receives only the conclusion)\n2. `Delegate: read all .py files in agents/ and summarize what each one does`\n3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`\n\nWhat to watch for: Do `[Subagent spawned]` / `[Subagent done]` appear? Do sub-Agent tool calls print as `[sub] ...`? Does the parent Agent continue with only the summary returned by the sub-Agent?\n\n---\n\n## What's Next\n\nThe Agent can now break tasks apart. But different tasks require different knowledge: editing frontend components needs React conventions, writing SQL needs table schemas. Stuffing all this knowledge into the system prompt could fill up the context outright.\n\n→ s07 Skill Loading: Inject skills on demand instead of piling documents into the system prompt. Load only when needed, as natural as reading a file.\n\n
\nDive into Claude Code Source Code\n\n> The following is based on a complete analysis of Claude Code source code `AgentTool.tsx`, `runAgent.ts`, `forkSubagent.ts`, and `forkedAgent.ts`.\n\n### 1. Not One Pattern, but Three\n\nThe teaching version covers only \"fresh messages[]\". Claude Code actually has three execution modes:\n\n| Mode | Trigger | Context |\n|------|---------|---------|\n| **Normal Subagent** | `subagent_type` specified (normal path) | Truly fresh messages[], only the prompt |\n| **Fork Subagent** | No `subagent_type`, fork gate enabled | Constructs cache-friendly prefix via `buildForkedMessages()`, shares prompt cache |\n| **General-Purpose** | No `subagent_type`, fork gate disabled | Same as Normal |\n\n### 2. Fork Mode: Sharing Prompt Cache\n\nThis is a core concept the teaching version omits. Fork mode (`forkSubagent.ts:60-71`) doesn't create a fresh context. Instead, it constructs a cache-friendly message prefix via `buildForkedMessages()` (`forkSubagent.ts:107-168`), preserving the parent assistant message and generating placeholder tool results. The goal isn't isolation, but making the Anthropic API's prompt cache hit: parent and child Agent's system prompt, tools, and message prefix are byte-identical, so the API doesn't need to recompute.\n\nFive key components for cache hit (`forkedAgent.ts:57-68`): system prompt, tools, model, message prefix, thinking config, must be byte-identical.\n\n### 3. Context Isolation's Precise Granularity\n\n`createSubagentContext()` (`forkedAgent.ts:345-462`) creates the sub-Agent's `ToolUseContext`:\n\n| Field | Behavior |\n|-------|----------|\n| `abortController` | New child controller; parent abort propagates down |\n| `setAppState` | Default no-op; but sync agents share via `shareSetAppState` (`runAgent.ts:697-714`) |\n| `readFileState` | **Cloned from parent** (avoids re-reading same files) |\n| `queryTracking` | New chainId, `depth = parentDepth + 1` |\n\nThe sub-Agent isn't fully isolated: file read state is shared. The degree of UI and notification isolation varies by execution path (sync/async/fork/teammate differ).\n\n### 4. Recursive Fork Protection\n\nThe teaching version uses \"sub-Agent has no task tool\" for recursion protection. The real implementation is more nuanced: `isInForkChild()` (`forkSubagent.ts:78-89`) checks for `FORK_BOILERPLATE_TAG` in history. But `constants/tools.ts:36-46` defaults `Agent` to all agents' disabled set (with `USER_TYPE === 'ant'` exception); `forkSubagent.ts:73-89` has fork-child-specific recursion protection; `agentToolUtils.ts:100-110` has special allowances in teammate scenarios. Not simply \"no further sub-Agents.\"\n\n### 5. Permission Bubbling\n\nFork Agent's `permissionMode: 'bubble'` (`forkSubagent.ts:67`) means the sub-Agent's permission prompts bubble up to the parent terminal: the user approves sub-Agent operations in the main terminal.\n\n### 6. Async vs Sync\n\nThe teaching version only shows synchronous sub-Agents (parent waits for child to finish). Claude Code also supports async paths (`AgentTool.tsx:686-764`): when `run_in_background: true`, the sub-Agent launches asynchronously, returning `{ status: 'async_launched' }` immediately to the parent, and notifies the parent when complete. Actual triggers go beyond `run_in_background`, including auto-background, assistant force async, and coordinator/proactive paths.\n\n### Teaching Version Simplifications Are Intentional\n\n- Three modes → one (fresh messages): conceptually clear\n- Prompt cache sharing → omitted: teaching version doesn't involve API-layer optimization\n- Recursive fork protection → simplified to \"sub-Agent has no task tool\"\n- Async → omitted (left for s13): s06 focuses on the synchronous model first\n\n
\n\n\n" }, { "version": "s06", "locale": "zh", "title": "s06: Subagent — 大任务拆小,每个拿到的都是干净上下文", - "content": "# s06: Subagent — 大任务拆小,每个拿到的都是干净上下文\n\ns01 → s02 → s03 → s04 → s05 → `s06` → [s07](/zh/s07) → s08 → ... → s20\n\n> *\"大任务拆小, 每个小任务干净的上下文\"* — Subagent 用独立 messages[], 不污染主对话。\n>\n> **Harness 层**: 子 Agent — 上下文隔离, 注意力不漂移。\n\n---\n\n## 问题\n\nAgent 在修一个 bug。它读了 30 个文件来追踪调用链,中间聊了 60 轮。messages 列表涨到 120 条,其中大部分是\"追踪调用链\"的中间过程,和\"修 bug\"这个最终目标无关。\n\n这些中间过程占着上下文位置,早期的关键信息被挤出有效窗口,模型对最初的问题描述的响应质量下降。\n\n换个角度:你修 bug 的时候,会\"开一个新终端\"来追踪调用链。追踪完了,终端关掉,结果写进笔记,回到原来的终端继续修 bug。Agent 也需要这个能力:开一个独立的子进程,给它一个独立的消息列表,让它专心做一件事。\n\n---\n\n## 解决方案\n\n![Subagent Overview](/course-assets/s06_subagent/subagent-overview.svg)\n\n保留上一章的最小 hook 结构和 `todo_write` 工具,本章重点转向新增的 `task` 工具。调用它时,spawn 一个子 Agent,拥有全新的 `messages[]`,跑自己的循环,结束后只把摘要文本回传给主 Agent。对话上下文被丢弃,但文件系统的副作用(写文件、改文件、跑命令)保留在工作目录中。\n\n子 Agent 的工具受限:有 bash/read/write/edit/glob,但没有 task,不能递归 spawn 新的子 Agent。子 Agent 的工具调用仍经过权限 hook,安全策略不因上下文隔离而跳过。\n\n---\n\n## 工作原理\n\n**spawn_subagent**,给子 Agent 一个全新的 messages 列表,跑自己的循环,只回传结论:\n\n```python\ndef spawn_subagent(description: str) -> str:\n # 子 Agent 的工具:基础工具,但没有 task(禁止递归)\n sub_tools = [\n {\"name\": \"bash\", ...}, {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...}, {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n ]\n messages = [{\"role\": \"user\", \"content\": description}] # 全新 messages[]\n\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=sub_tools, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({... \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown\"\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({... \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # 只返回最后的文本结论,中间过程全部丢弃\n return extract_text(messages[-1][\"content\"])\n```\n\n主 Agent 调用时,跟调其他工具一样:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n {\"name\": \"todo_write\", ...},\n # s06: 新增 task 工具\n {\"name\": \"task\",\n \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n]\n\nTOOL_HANDLERS[\"task\"] = spawn_subagent\n```\n\n三个关键设计决策:\n\n| 决策 | 选择 | 原因 |\n|------|------|------|\n| 上下文隔离 | 全新 `messages[]` | 子 Agent 的中间过程不污染主 Agent 的上下文 |\n| 只回传结论 | `extract_text(last_message)` | 不是回传整个 messages 列表 |\n| 禁止递归 | 子 Agent 无 task 工具 | 防止子 Agent 再 spawn 新的子 Agent |\n| 安全策略不跳过 | 子 Agent 工具调用也走 PreToolUse hook | 上下文隔离不代表权限隔离 |\n\ndispatch 机制不变,task 工具通过 `TOOL_HANDLERS[block.name]` 分发。子 Agent 有独立的 `SUB_SYSTEM` 提示,明确要求\"直接完成任务,不要再委派\"。\n\n---\n\n## 相对 s05 的变更\n\n| 组件 | 之前 (s05) | 之后 (s06) |\n|------|-----------|-----------|\n| 工具数量 | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) |\n| 新函数 | — | spawn_subagent(独立 messages[] + 30 轮安全限制) |\n| 上下文隔离 | 全部在主对话中 | 子 Agent 用全新的 messages[] |\n| 循环 | 不变 | dispatch 不变,子 Agent 有独立 SUB_SYSTEM 和 hook 保护的循环 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s06_subagent/code.py\n```\n\n试试这些 prompt:\n\n1. `Use a subtask to find what testing framework this project uses`(子 Agent 去读文件,主 Agent 只收结论)\n2. `Delegate: read all .py files in agents/ and summarize what each one does`\n3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`\n\n观察重点:是否出现 `[Subagent spawned]` / `[Subagent done]`?子 Agent 的工具调用是否以 `[sub] ...` 输出?主 Agent 最后是否只继续处理子 Agent 返回的摘要?\n\n---\n\n## 接下来\n\nAgent 现在能拆任务了。但每个任务需要的知识不一样:改前端组件需要知道 React 规范,写 SQL 需要知道表结构。这些知识全塞进 system prompt,上下文直接爆了。\n\ns07 Skill Loading → 技能按需注入,不在 system prompt 里堆文档。用到的时候才加载,和读文件一样自然。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` 的完整分析。\n\n### 一、不是一种模式,是三种\n\n教学版只讲了\"全新的 messages[]\"。Claude Code 实际有三种执行模式:\n\n| 模式 | 触发条件 | 上下文 |\n|------|---------|--------|\n| **Normal Subagent** | 指定了 `subagent_type`(normal path) | 全新 messages[],只有 prompt |\n| **Fork Subagent** | 没指定 `subagent_type`,fork gate 开启 | 通过 `buildForkedMessages()` 构造 cache-friendly 前缀,共享 prompt cache |\n| **General-Purpose** | 没指定 `subagent_type`,fork gate 关闭 | 同 Normal |\n\n### 二、Fork 模式:为了共享 Prompt Cache\n\n这是教学版没有的核心概念。Fork 模式(`forkSubagent.ts:60-71`)不创建全新上下文,而是通过 `buildForkedMessages()`(`forkSubagent.ts:107-168`)构造 cache-friendly 消息前缀,保留父 assistant message 并生成 placeholder tool results。目的不是隔离,而是让 Anthropic API 的 prompt cache 命中:父子 Agent 的 system prompt、tools、messages 前缀完全一致,API 端不需要重算。\n\n缓存命中的五个关键组件(`forkedAgent.ts:57-68`):system prompt、tools、model、messages 前缀、thinking config,必须字节级一致。\n\n### 三、Context Isolation 的精确粒度\n\n`createSubagentContext()`(`forkedAgent.ts:345-462`)创建子 Agent 的 `ToolUseContext`:\n\n| 字段 | 行为 |\n|------|------|\n| `abortController` | 新的 child controller,父 abort 向下传播 |\n| `setAppState` | 默认 no-op;但 sync agent 通过 `shareSetAppState` 共享(`runAgent.ts:697-714`) |\n| `readFileState` | **从父克隆**(避免重复读相同文件) |\n| `queryTracking` | 新 chainId,`depth = parentDepth + 1` |\n\n子 Agent 不是完全隔离的:文件读取状态是共享的。UI 和通知的隔离程度取决于执行路径(sync/async/fork/teammate 各不同)。\n\n### 四、递归 Fork 防护\n\n教学版用\"子 Agent 不给 task 工具\"表达递归保护。真实实现更精细:`isInForkChild()`(`forkSubagent.ts:78-89`)检查对话历史中是否有 `FORK_BOILERPLATE_TAG`,有就拒绝。但 `constants/tools.ts:36-46` 中 `Agent` 工具默认在所有 agent 的禁用集合里,`USER_TYPE === 'ant'` 时例外;`forkSubagent.ts:73-89` 针对 fork child 有专门的递归保护;`agentToolUtils.ts:100-110` 在 teammate 场景下有特殊放行。不是简单的\"禁止新的子 Agent\"。\n\n### 五、Permission Bubbling\n\nFork Agent 的 `permissionMode: 'bubble'`(`forkSubagent.ts:67`)意味着子 Agent 的权限弹窗冒泡到父终端,用户在主终端里审批子 Agent 的操作。\n\n### 六、Async vs Sync\n\n教学版只展示了同步子 Agent(父等着子跑完)。Claude Code 还支持异步路径(`AgentTool.tsx:686-764`):`run_in_background: true` 时异步启动,返回 `{ status: 'async_launched' }` 立即给父 Agent,子 Agent 完成后通过通知机制告知父 Agent。实际触发条件不止 `run_in_background`,还有 auto-background、assistant force async、coordinator/proactive 等路径。\n\n### 教学版的简化是刻意的\n\n- 三种模式 → 一种(fresh messages):概念清晰\n- Prompt cache 共享 → 省略:教学版不涉及 API 层优化\n- 递归 fork 防护 → 简化为\"子 Agent 无 task 工具\"\n- Async → 省略(留给 s13):s06 先理解同步模型\n\n
\n\n\n" + "content": "# s06: Subagent — 大任务拆小,每个拿到的都是干净上下文\n\ns01 → s02 → s03 → s04 → s05 → `s06` → [s07](/zh/s07) → s08 → ... → s20\n\n> *\"大任务拆小, 每个小任务干净的上下文\"* — Subagent 用独立 `messages[]`, 不污染主对话。\n>\n> **Harness 层**: 子 Agent — 上下文隔离, 注意力不漂移。\n\n---\n\n## 问题\n\n上一章给 Agent 加了 `todo_write`,它能把大任务拆成一张清单,一步步推进。但拆出来的子任务,还是在同一个 `messages[]` 里跑。\n\nAgent 在修一个 bug:读了 30 个文件跟踪调用链,来回聊了 60 轮,`messages` 涨到 120 条。其中大半是\"跟踪调用链\"的中间过程,和\"修 bug\"这个最终目标已经没关系了,却还占着上下文。早期的关键信息被挤出有效窗口,模型对最初那个 bug 的描述,反而记不清了。\n\n换个角度想:你修 bug 时会\"开一个新终端\"去跟踪调用链,跟踪完把结果记下来,关掉终端,回到原来的终端接着修。Agent 也需要这个能力:开一个独立的子进程,给它一份独立的消息列表,让它专心做一件事,做完只把结论带回来。\n\n---\n\n## 解决方案\n\n![Subagent Overview](/course-assets/s06_subagent/subagent-overview.svg)\n\n一个符合直觉的做法,是让主 Agent 自己把调用链跟踪完、接着修。但跟踪的过程会全部留在主对话里,这正是上面那个问题。\n\n所以换个思路:**把脏活外包出去,只收一句结论。** 上一章的 hook 结构和 `todo_write` 都保留,本章新增一个 `task` 工具。主 Agent 调它时,spawn 一个子 Agent,给它全新的 `messages[]`,让它跑自己的循环;结束后只回传一段摘要文本,中间那 60 轮全部丢弃。对话上下文不进主 Agent,但子 Agent 在文件系统上做的实际改动(写文件、改文件、跑命令)会留在工作目录里。\n\n子 Agent 的工具是受限的:有 `bash`/`read`/`write`/`edit`/`glob`,但没有 `task`,不能再递归 spawn 新的子 Agent。而且它的每次工具调用仍走权限 hook,安全策略不因为上下文隔离就跳过。\n\n---\n\n## 工作原理\n\n子 Agent 本质上就是 s01 那个循环的另一份实例,只是换了一份空的 `messages[]` 和一套更窄的工具:\n\n```python\ndef spawn_subagent(description: str) -> str:\n # 子 Agent 的工具:基础工具,但没有 task(禁止递归)\n sub_tools = [\n {\"name\": \"bash\", ...}, {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...}, {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n ]\n messages = [{\"role\": \"user\", \"content\": description}] # 全新 messages[]\n\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=sub_tools, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({... \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown\"\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({... \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # 只返回最后的文本结论,中间过程全部丢弃\n return extract_text(messages[-1][\"content\"])\n```\n\n注意这个循环里的几处关键点:工具集里没有 `task`(不能再 spawn,递归到此为止);`for _ in range(30)` 是循环轮次的安全上限(子 Agent 最多跑 30 轮,避免无限循环);每次工具调用前仍走 `PreToolUse` hook(隔离了上下文,但没隔离权限);最后只用 `extract_text(messages[-1])` 取一句结论,中间过程整份丢掉。\n\n主 Agent 这边,调它跟调其他工具完全一样:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n {\"name\": \"todo_write\", ...},\n # s06: 新增 task 工具\n {\"name\": \"task\",\n \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n]\n\nTOOL_HANDLERS[\"task\"] = spawn_subagent\n```\n\n四个关键设计决策:\n\n| 决策 | 选择 | 原因 |\n|------|------|------|\n| 上下文隔离 | 全新 `messages[]` | 子 Agent 的中间过程不污染主 Agent 的上下文 |\n| 只回传结论 | `extract_text(last_message)` | 不是回传整个 `messages` 列表 |\n| 禁止递归 | 子 Agent 无 `task` 工具 | 防止子 Agent 再 spawn 新的子 Agent |\n| 安全策略不跳过 | 子 Agent 工具调用也走 PreToolUse hook | 上下文隔离不代表权限隔离 |\n\ndispatch 机制不变,`task` 工具通过 `TOOL_HANDLERS[block.name]` 分发。子 Agent 还有独立的 `SUB_SYSTEM` 提示,明确要求\"直接完成任务,不要再委派\"。\n\n---\n\n## 相对 s05 的变更\n\n| 组件 | 之前 (s05) | 之后 (s06) |\n|------|-----------|-----------|\n| 工具数量 | 6 (`bash`, `read`, `write`, `edit`, `glob`, `todo_write`) | 7 (+`task`) |\n| 新函数 | — | `spawn_subagent`(独立 `messages[]` + 30 轮安全限制) |\n| 上下文隔离 | 全部在主对话中 | 子 Agent 用全新的 `messages[]` |\n| 循环 | 不变 | dispatch 不变,子 Agent 有独立 `SUB_SYSTEM` 和 hook 保护的循环 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s06_subagent/code.py\n```\n\n试试这些 prompt:\n\n1. `Use a subtask to find what testing framework this project uses`(子 Agent 去读文件,主 Agent 只收结论)\n2. `Delegate: read all .py files in agents/ and summarize what each one does`\n3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`\n\n观察重点:是否出现 `[Subagent spawned]` / `[Subagent done]`?子 Agent 的工具调用是否以 `[sub] ...` 输出?主 Agent 最后是否只继续处理子 Agent 返回的摘要?\n\n---\n\n## 接下来\n\nAgent 现在能拆任务了。但每个任务需要的知识不一样:改前端组件需要知道 React 规范,写 SQL 需要知道表结构。这些知识全塞进 system prompt,上下文有可能会直接被填满。\n\ns07 Skill Loading → 技能按需注入,不在 system prompt 里堆文档。用到的时候才加载,和读文件一样自然。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` 的完整分析。\n\n### 一、不是一种模式,是三种\n\n教学版只讲了\"全新的 `messages[]`\"。Claude Code 实际有三种执行模式:\n\n| 模式 | 触发条件 | 上下文 |\n|------|---------|--------|\n| **Normal Subagent** | 指定了 `subagent_type`(normal path) | 全新 `messages[]`,只有 prompt |\n| **Fork Subagent** | 没指定 `subagent_type`,fork gate 开启 | 通过 `buildForkedMessages()` 构造 cache-friendly 前缀,共享 prompt cache |\n| **General-Purpose** | 没指定 `subagent_type`,fork gate 关闭 | 同 Normal |\n\n### 二、Fork 模式:为了共享 Prompt Cache\n\n这是教学版没有的核心概念。Fork 模式(`forkSubagent.ts:60-71`)不创建全新上下文,而是通过 `buildForkedMessages()`(`forkSubagent.ts:107-168`)构造 cache-friendly 消息前缀,保留父 assistant message 并生成 placeholder tool results。目的不是隔离,而是让 Anthropic API 的 prompt cache 命中:父子 Agent 的 system prompt、tools、messages 前缀完全一致,API 端不需要重算。\n\n缓存命中的五个关键组件(`forkedAgent.ts:57-68`):system prompt、tools、model、messages 前缀、thinking config,必须字节级一致。\n\n### 三、Context Isolation 的精确粒度\n\n`createSubagentContext()`(`forkedAgent.ts:345-462`)创建子 Agent 的 `ToolUseContext`:\n\n| 字段 | 行为 |\n|------|------|\n| `abortController` | 新的 child controller,父 abort 向下传播 |\n| `setAppState` | 默认 no-op;但 sync agent 通过 `shareSetAppState` 共享(`runAgent.ts:697-714`) |\n| `readFileState` | **从父克隆**(避免重复读相同文件) |\n| `queryTracking` | 新 chainId,`depth = parentDepth + 1` |\n\n子 Agent 不是完全隔离的:文件读取状态是共享的。UI 和通知的隔离程度取决于执行路径(sync/async/fork/teammate 各不同)。\n\n### 四、递归 Fork 防护\n\n教学版用\"子 Agent 不给 `task` 工具\"表达递归保护。真实实现更精细:`isInForkChild()`(`forkSubagent.ts:78-89`)检查对话历史中是否有 `FORK_BOILERPLATE_TAG`,有就拒绝。但 `constants/tools.ts:36-46` 中 `Agent` 工具默认在所有 agent 的禁用集合里,`USER_TYPE === 'ant'` 时例外;`forkSubagent.ts:73-89` 针对 fork child 有专门的递归保护;`agentToolUtils.ts:100-110` 在 teammate 场景下有特殊放行。不是简单的\"禁止新的子 Agent\"。\n\n### 五、Permission Bubbling\n\nFork Agent 的 `permissionMode: 'bubble'`(`forkSubagent.ts:67`)意味着子 Agent 的权限弹窗冒泡到父终端,用户在主终端里审批子 Agent 的操作。\n\n### 六、Async vs Sync\n\n教学版只展示了同步子 Agent(父等着子跑完)。Claude Code 还支持异步路径(`AgentTool.tsx:686-764`):`run_in_background: true` 时异步启动,返回 `{ status: 'async_launched' }` 立即给父 Agent,子 Agent 完成后通过通知机制告知父 Agent。实际触发条件不止 `run_in_background`,还有 auto-background、assistant force async、coordinator/proactive 等路径。\n\n### 教学版的简化是刻意的\n\n- 三种模式 → 一种(全新 `messages[]`):概念清晰\n- Prompt cache 共享 → 省略:教学版不涉及 API 层优化\n- 递归 fork 防护 → 简化为\"子 Agent 无 `task` 工具\"\n- Async → 省略(留给 s13):s06 先理解同步模型\n\n
\n\n\n" }, { "version": "s06", "locale": "ja", "title": "s06: Subagent — 大きなタスクを分割、それぞれがクリーンなコンテキストを取得", - "content": "# s06: Subagent — 大きなタスクを分割、それぞれがクリーンなコンテキストを取得\n\ns01 → s02 → s03 → s04 → s05 → `s06` → [s07](/ja/s07) → s08 → ... → s20\n\n> *\"大きなタスクは小さく、小さなタスクごとにクリーンなコンテキスト\"* — Subagent は独立した messages[] を使い、メイン会話を汚染しない。\n>\n> **Harness レイヤー**: サブエージェント — コンテキストの隔離、注意の散漫を防ぐ。\n\n---\n\n## 課題\n\nAgent がバグを修正している。呼び出しチェーンを追跡するために 30 のファイルを読み、途中で 60 ラウンドやり取りした。messages リストは 120 件に膨らみ、その大部分は「呼び出しチェーンの追跡」という中間過程であり、「バグ修正」という最終目標とは無関係。\n\nこの中間過程がコンテキストの席を占め、初期の重要な情報が有効ウィンドウから押し出され、元の問題に対するモデルの応答品質が低下する。\n\n別の見方をすると:バグを修正するとき、あなたは「新しいターミナルを開いて」呼び出しチェーンを追跡するだろう。追跡が終わったらターミナルを閉じ、結果をメモに書き、元のターミナルに戻ってバグ修正を続ける。Agent にも同じ仕組みが必要になる。独立したサブプロセスを spawn し、専用のメッセージリストで単一タスクに限定する。\n\n---\n\n## ソリューション\n\n![Subagent Overview](/course-assets/s06_subagent/subagent-overview.svg)\n\n前章の最小フック構造と `todo_write` ツールを保持し、本章は新規の `task` ツールに注目する。呼び出されると、サブエージェントを spawn する。新しい `messages[]` を持ち、自分自身のループを実行し、終了後に要約テキストのみをメイン Agent に返す。会話コンテキストは破棄されるが、ファイルシステムの副作用(書き込み、編集、コマンド実行)は作業ディレクトリに残る。\n\nサブエージェントのツールは制限される:bash/read/write/edit/glob を持つが、task はない。再帰 spawn を防止する。サブエージェントのツール呼び出しも権限フックを経由する。コンテキスト分離は権限のバイパスではない。\n\n---\n\n## 仕組み\n\n**spawn_subagent**、サブエージェントに新しいメッセージリストを与え、自分自身のループを実行し、結論のみを返す:\n\n```python\ndef spawn_subagent(description: str) -> str:\n # サブエージェントのツール:基本ツールのみ、task なし(再帰禁止)\n sub_tools = [...]\n messages = [{\"role\": \"user\", \"content\": description}] # 新規 messages[]\n\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=sub_tools, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({... \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown\"\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({... \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # 最後のテキスト結論のみを返す、中間過程はすべて破棄\n return extract_text(messages[-1][\"content\"])\n```\n\nメイン Agent の呼び出しは、他のツールと同じ:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n {\"name\": \"todo_write\", ...},\n # s06: 新規 task ツール\n {\"name\": \"task\",\n \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n]\n\nTOOL_HANDLERS[\"task\"] = spawn_subagent\n```\n\n三つの重要な設計決定:\n\n| 決定 | 選択 | 理由 |\n|------|------|------|\n| コンテキスト隔離 | 新規 `messages[]` | サブエージェントの中間過程がメイン Agent のコンテキストを汚染しない |\n| 結論のみ返却 | `extract_text(last_message)` | messages リスト全体を返すのではない |\n| 再帰禁止 | サブエージェントに task ツールなし | サブエージェントがさらにサブエージェントを spawn するのを防止 |\n| セキュリティのバイパスなし | サブエージェントのツール呼び出しも PreToolUse フックを経由 | コンテキスト分離は権限分離ではない |\n\nディスパッチ機構は変わらず、task ツールは `TOOL_HANDLERS[block.name]` を経由する。サブエージェントは独立した `SUB_SYSTEM` プロンプトを持ち、「タスクを完了し、さらに委託しない」と明示される。\n\n---\n\n## s05 からの変更\n\n| コンポーネント | 変更前 (s05) | 変更後 (s06) |\n|--------------|-------------|-------------|\n| ツール数 | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) |\n| 新規関数 | — | spawn_subagent(独立 messages[] + 30 ラウンド安全制限) |\n| コンテキスト隔離 | すべてメイン会話内 | サブエージェントが新規 messages[] を使用 |\n| ループ | 不変 | ディスパッチは不変、サブエージェントに独立した SUB_SYSTEM とフック保護されたループ |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s06_subagent/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Use a subtask to find what testing framework this project uses`(サブエージェントがファイルを読み、メイン Agent は結論のみ受け取る)\n2. `Delegate: read all .py files in agents/ and summarize what each one does`\n3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`\n\n観察のポイント:`[Subagent spawned]` / `[Subagent done]` が表示されるか? サブエージェントのツール呼び出しが `[sub] ...` として出力されるか? 親 Agent はサブエージェントが返した要約だけを受け取って続行するか?\n\n---\n\n## 次へ\n\nAgent はタスクを分割できるようになった。しかし各タスクに必要な知識は異なる。フロントエンドコンポーネントの変更には React 規約が必要で、SQL を書くにはテーブル構造を知る必要がある。これらの知識をすべて system prompt に詰め込むと、コンテキストが溢れてしまう。\n\n→ s07 Skill Loading:スキルをオンデマンドで注入する。system prompt にドキュメントを積み上げるのではなく、必要なときだけ読み込む。ファイルを読むのと同じくらい自然に。\n\n
\nClaude Code ソースコードを深掘り\n\n> 以下は Claude Code ソースコード `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` の完全分析に基づく。\n\n### 一、一つのパターンではなく三つ\n\n教育版は「新規 messages[]」のみを取り上げる。Claude Code には実際に三つの実行モードがある:\n\n| モード | トリガー | コンテキスト |\n|--------|---------|-------------|\n| **Normal Subagent** | `subagent_type` 指定時(normal path) | 新規 messages[]、プロンプトのみ |\n| **Fork Subagent** | `subagent_type` 未指定、fork gate 有効時 | `buildForkedMessages()` でキャッシュフレンドリーなプレフィックスを構築、プロンプトキャッシュを共有 |\n| **General-Purpose** | `subagent_type` 未指定、fork gate 無効時 | Normal と同じ |\n\n### 二、Fork モード:プロンプトキャッシュの共有のため\n\nこれは教育版にはない核心概念。Fork モード(`forkSubagent.ts:60-71`)は新規コンテキストを作成せず、`buildForkedMessages()`(`forkSubagent.ts:107-168`)でキャッシュフレンドリーなメッセージプレフィックスを構築する。親の assistant message を保持し、placeholder tool results を生成する。目的は隔離ではなく、Anthropic API のプロンプトキャッシュをヒットさせること:親子 Agent の system prompt、tools、messages プレフィックスがバイトレベルで一致するため、API 側で再計算が不要になる。\n\nキャッシュヒットの五つの重要コンポーネント(`forkedAgent.ts:57-68`):system prompt、tools、model、messages プレフィックス、thinking config、バイトレベルで一致する必要がある。\n\n### 三、コンテキスト隔離の精密な粒度\n\n`createSubagentContext()`(`forkedAgent.ts:345-462`)はサブエージェントの `ToolUseContext` を作成:\n\n| フィールド | 挙動 |\n|-----------|------|\n| `abortController` | 新しい子コントローラ、親の abort は下に伝播 |\n| `setAppState` | デフォルトは no-op、ただし sync agent は `shareSetAppState` で共有(`runAgent.ts:697-714`) |\n| `readFileState` | **親からクローン**(同じファイルの再読み込みを回避) |\n| `queryTracking` | 新しい chainId、`depth = parentDepth + 1` |\n\nサブエージェントは完全に隔離されているわけではない。ファイル読み取り状態は共有される。UI と通知の隔離度は実行パスにより異なる(sync/async/fork/teammate でそれぞれ異なる)。\n\n### 四、再帰 Fork 防護\n\n教育版は「サブエージェントに task ツールなし」で再帰防止を表現する。実際の実装はより精密:`isInForkChild()`(`forkSubagent.ts:78-89`)が会話履歴内の `FORK_BOILERPLATE_TAG` をチェックする。しかし `constants/tools.ts:36-46` では `Agent` ツールが全エージェントの無効セットにデフォルト設定(`USER_TYPE === 'ant'` 時は例外)、`forkSubagent.ts:73-89` は fork child 向けの専用再帰保護があり、`agentToolUtils.ts:100-110` は teammate シナリオで特別な許可がある。単純な「サブエージェントの再 spawn 禁止」ではない。\n\n### 五、Permission Bubbling\n\nFork Agent の `permissionMode: 'bubble'`(`forkSubagent.ts:67`)は、サブエージェントの権限プロンプトが親ターミナルにバブルアップすることを意味する。ユーザーはメインターミナルでサブエージェントの操作を承認する。\n\n### 六、Async vs Sync\n\n教育版は同期サブエージェントのみ(親が子の完了を待つ)を示す。Claude Code は非同期パスもサポート(`AgentTool.tsx:686-764`):`run_in_background: true` の場合、サブエージェントは非同期で起動し、`{ status: 'async_launched' }` を直ちに親に返し、完了時に通知機構で親に知らせる。実際のトリガーは `run_in_background` だけでなく、auto-background、assistant force async、coordinator/proactive パスもある。\n\n### 教育版の簡略化は意図的\n\n- 三つのモード → 一つ(新規 messages):概念的に明確\n- プロンプトキャッシュ共有 → 省略:教育版は API 層の最適化を扱わない\n- 再帰 fork 防護 → 「サブエージェントに task ツールなし」に簡略化\n- Async → 省略(s13 に委ねる):s06 はまず同期モデルを理解する\n\n
\n\n\n" + "content": "# s06: Subagent — 大きなタスクを分割、それぞれがクリーンなコンテキストを取得\n\ns01 → s02 → s03 → s04 → s05 → `s06` → [s07](/ja/s07) → s08 → ... → s20\n\n> *\"大きなタスクは小さく、小さなタスクごとにクリーンなコンテキスト\"* — Subagent は独立した `messages[]` を使い、メイン会話を汚染しない。\n>\n> **Harness レイヤー**: サブエージェント — コンテキストの隔離、注意の散漫を防ぐ。\n\n---\n\n## 課題\n\n前章で Agent に `todo_write` を加えた。大きなタスクをチェックリストに分解し、一歩ずつ進められる。しかし分解したサブタスクは、まだ同じ `messages[]` の中で動いている。\n\nAgent がバグを修正している:呼び出しチェーンを追跡するために 30 のファイルを読み、60 ラウンドやり取りし、`messages` は 120 件に膨らむ。その大半は「呼び出しチェーンの追跡」という中間過程で、「バグ修正」という最終目標とはもう関係がないのに、まだコンテキストを占めている。初期の重要な情報は有効ウィンドウから押し出され、モデルは最初のバグの説明を、かえって覚えていられなくなる。\n\n別の見方をしよう:バグを修正するとき、あなたは「新しいターミナルを開いて」呼び出しチェーンを追跡し、終わったら結果をメモして、ターミナルを閉じ、元のターミナルに戻って修正を続ける。Agent にも同じ能力が要る:独立したサブプロセスを spawn し、専用のメッセージリストを与え、一つのことに集中させ、終わったら結論だけ持ち帰らせる。\n\n---\n\n## ソリューション\n\n![Subagent Overview](/course-assets/s06_subagent/subagent-overview.svg)\n\n直感的なやり方は、メイン Agent 自身に呼び出しチェーンを追跡させ、そのまま修正を続けさせることだ。だが追跡の過程はすべてメイン会話に残る。これがまさに上の問題だ。\n\nそこで発想を変える:**面倒な作業は外に出し、戻ってくるのは一言の結論だけにする。** 前章のフック構造と `todo_write` はそのまま残し、本章で `task` ツールを 1 つ加える。メイン Agent がそれを呼ぶと、サブエージェントを spawn し、新しい `messages[]` を与えて自分のループを走らせる。終わったら要約テキストだけが返り、間の 60 ラウンドはすべて破棄される。会話コンテキストはメイン Agent に入らないが、サブエージェントがファイルシステムに加えた実際の変更(書き込み、編集、コマンド実行)は作業ディレクトリに残る。\n\nサブエージェントのツールは制限されている:`bash`/`read`/`write`/`edit`/`glob` はあるが `task` はなく、再帰的に新しいサブエージェントを spawn できない。そして各ツール呼び出しはやはり権限フックを経由する。コンテキストを隔離しても、セキュリティポリシーは飛ばさない。\n\n---\n\n## 仕組み\n\nサブエージェントは本質的に、あの s01 のループのもう 1 つのインスタンスにすぎない。空の `messages[]` と、より狭いツールセットに差し替えただけだ:\n\n```python\ndef spawn_subagent(description: str) -> str:\n # サブエージェントのツール:基本ツールのみ、task なし(再帰禁止)\n sub_tools = [...]\n messages = [{\"role\": \"user\", \"content\": description}] # 新規 messages[]\n\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=sub_tools, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({... \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown\"\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({... \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # 最後のテキスト結論のみを返す、中間過程はすべて破棄\n return extract_text(messages[-1][\"content\"])\n```\n\nこのループの要点をいくつか:ツールセットに `task` がない(もう spawn できず、再帰はここで止まる);`for _ in range(30)` はラウンド数の上限(サブエージェントは最多 30 ラウンド、無限ループを避ける);各ツール呼び出しの前にやはり `PreToolUse` フックを通る(コンテキストは隔離するが、権限は隔離しない);最後は `extract_text(messages[-1])` で結論を 1 つ取り出すだけで、中間過程はまるごと捨てる。\n\nメイン Agent の側では、それを呼ぶのは他のツールを呼ぶのとまったく同じだ:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n {\"name\": \"todo_write\", ...},\n # s06: 新規 task ツール\n {\"name\": \"task\",\n \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n]\n\nTOOL_HANDLERS[\"task\"] = spawn_subagent\n```\n\n四つの重要な設計決定:\n\n| 決定 | 選択 | 理由 |\n|------|------|------|\n| コンテキスト隔離 | 新規 `messages[]` | サブエージェントの中間過程がメイン Agent のコンテキストを汚染しない |\n| 結論のみ返却 | `extract_text(last_message)` | messages リスト全体を返すのではない |\n| 再帰禁止 | サブエージェントに task ツールなし | サブエージェントがさらにサブエージェントを spawn するのを防止 |\n| セキュリティのバイパスなし | サブエージェントのツール呼び出しも PreToolUse フックを経由 | コンテキスト分離は権限分離ではない |\n\nディスパッチ機構は変わらず、task ツールは `TOOL_HANDLERS[block.name]` を経由する。サブエージェントは独立した `SUB_SYSTEM` プロンプトを持ち、「タスクを完了し、さらに委託しない」と明示される。\n\n---\n\n## s05 からの変更\n\n| コンポーネント | 変更前 (s05) | 変更後 (s06) |\n|--------------|-------------|-------------|\n| ツール数 | 6 (bash, read, write, edit, glob, todo_write) | 7 (+task) |\n| 新規関数 | — | spawn_subagent(独立 messages[] + 30 ラウンド安全制限) |\n| コンテキスト隔離 | すべてメイン会話内 | サブエージェントが新規 messages[] を使用 |\n| ループ | 不変 | ディスパッチは不変、サブエージェントに独立した SUB_SYSTEM とフック保護されたループ |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s06_subagent/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `Use a subtask to find what testing framework this project uses`(サブエージェントがファイルを読み、メイン Agent は結論のみ受け取る)\n2. `Delegate: read all .py files in agents/ and summarize what each one does`\n3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`\n\n観察のポイント:`[Subagent spawned]` / `[Subagent done]` が表示されるか? サブエージェントのツール呼び出しが `[sub] ...` として出力されるか? 親 Agent はサブエージェントが返した要約だけを受け取って続行するか?\n\n---\n\n## 次へ\n\nAgent はタスクを分割できるようになった。しかし各タスクに必要な知識は異なる。フロントエンドコンポーネントの変更には React 規約が必要で、SQL を書くにはテーブル構造を知る必要がある。これらの知識をすべて system prompt に詰め込むと、コンテキストが一気に埋まってしまうこともある。\n\n→ s07 Skill Loading:スキルをオンデマンドで注入する。system prompt にドキュメントを積み上げるのではなく、必要なときだけ読み込む。ファイルを読むのと同じくらい自然に。\n\n
\nClaude Code ソースコードを深掘り\n\n> 以下は Claude Code ソースコード `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` の完全分析に基づく。\n\n### 一、一つのパターンではなく三つ\n\n教育版は「新規 messages[]」のみを取り上げる。Claude Code には実際に三つの実行モードがある:\n\n| モード | トリガー | コンテキスト |\n|--------|---------|-------------|\n| **Normal Subagent** | `subagent_type` 指定時(normal path) | 新規 messages[]、プロンプトのみ |\n| **Fork Subagent** | `subagent_type` 未指定、fork gate 有効時 | `buildForkedMessages()` でキャッシュフレンドリーなプレフィックスを構築、プロンプトキャッシュを共有 |\n| **General-Purpose** | `subagent_type` 未指定、fork gate 無効時 | Normal と同じ |\n\n### 二、Fork モード:プロンプトキャッシュの共有のため\n\nこれは教育版にはない核心概念。Fork モード(`forkSubagent.ts:60-71`)は新規コンテキストを作成せず、`buildForkedMessages()`(`forkSubagent.ts:107-168`)でキャッシュフレンドリーなメッセージプレフィックスを構築する。親の assistant message を保持し、placeholder tool results を生成する。目的は隔離ではなく、Anthropic API のプロンプトキャッシュをヒットさせること:親子 Agent の system prompt、tools、messages プレフィックスがバイトレベルで一致するため、API 側で再計算が不要になる。\n\nキャッシュヒットの五つの重要コンポーネント(`forkedAgent.ts:57-68`):system prompt、tools、model、messages プレフィックス、thinking config、バイトレベルで一致する必要がある。\n\n### 三、コンテキスト隔離の精密な粒度\n\n`createSubagentContext()`(`forkedAgent.ts:345-462`)はサブエージェントの `ToolUseContext` を作成:\n\n| フィールド | 挙動 |\n|-----------|------|\n| `abortController` | 新しい子コントローラ、親の abort は下に伝播 |\n| `setAppState` | デフォルトは no-op、ただし sync agent は `shareSetAppState` で共有(`runAgent.ts:697-714`) |\n| `readFileState` | **親からクローン**(同じファイルの再読み込みを回避) |\n| `queryTracking` | 新しい chainId、`depth = parentDepth + 1` |\n\nサブエージェントは完全に隔離されているわけではない。ファイル読み取り状態は共有される。UI と通知の隔離度は実行パスにより異なる(sync/async/fork/teammate でそれぞれ異なる)。\n\n### 四、再帰 Fork 防護\n\n教育版は「サブエージェントに task ツールなし」で再帰防止を表現する。実際の実装はより精密:`isInForkChild()`(`forkSubagent.ts:78-89`)が会話履歴内の `FORK_BOILERPLATE_TAG` をチェックする。しかし `constants/tools.ts:36-46` では `Agent` ツールが全エージェントの無効セットにデフォルト設定(`USER_TYPE === 'ant'` 時は例外)、`forkSubagent.ts:73-89` は fork child 向けの専用再帰保護があり、`agentToolUtils.ts:100-110` は teammate シナリオで特別な許可がある。単純な「サブエージェントの再 spawn 禁止」ではない。\n\n### 五、Permission Bubbling\n\nFork Agent の `permissionMode: 'bubble'`(`forkSubagent.ts:67`)は、サブエージェントの権限プロンプトが親ターミナルにバブルアップすることを意味する。ユーザーはメインターミナルでサブエージェントの操作を承認する。\n\n### 六、Async vs Sync\n\n教育版は同期サブエージェントのみ(親が子の完了を待つ)を示す。Claude Code は非同期パスもサポート(`AgentTool.tsx:686-764`):`run_in_background: true` の場合、サブエージェントは非同期で起動し、`{ status: 'async_launched' }` を直ちに親に返し、完了時に通知機構で親に知らせる。実際のトリガーは `run_in_background` だけでなく、auto-background、assistant force async、coordinator/proactive パスもある。\n\n### 教育版の簡略化は意図的\n\n- 三つのモード → 一つ(新規 messages):概念的に明確\n- プロンプトキャッシュ共有 → 省略:教育版は API 層の最適化を扱わない\n- 再帰 fork 防護 → 「サブエージェントに task ツールなし」に簡略化\n- Async → 省略(s13 に委ねる):s06 はまず同期モデルを理解する\n\n
\n\n\n" }, { "version": "s07", From 354473dcbbcc63a41a70b4eb50f868f539f9067e Mon Sep 17 00:00:00 2001 From: Haoran Date: Sat, 27 Jun 2026 03:45:16 +0800 Subject: [PATCH 4/5] Polish s07 prose (zh/en/ja) with the s08 narrative technique MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The problem section now opens by carrying over from s06's sub-Agent (a sub-Agent taking over needs the task's rules; where do those rules come from). The solution leads with a why-not (let the Agent read_file the docs itself, but it doesn't know what files exist) and an anchor: 目录常驻, 内容按需 / catalog always resident, content on demand. Level 1's code block now ends with a cascade hook (you only get the name and one-line description, the full content is still out of reach -> Level 2). `messages` and `tool_result` get backticks; one stray dash becomes a comma. All three languages synced to v3; web docs regenerated. Co-Authored-By: Claude Opus 4.8 (1M context) --- s07_skill_loading/README.ja.md | 18 ++++++++++++------ s07_skill_loading/README.md | 18 ++++++++++++------ s07_skill_loading/README.zh.md | 24 +++++++++++++++--------- web/src/data/generated/docs.json | 8 ++++---- 4 files changed, 43 insertions(+), 25 deletions(-) diff --git a/s07_skill_loading/README.ja.md b/s07_skill_loading/README.ja.md index 91134a6bb..4f1e43f26 100644 --- a/s07_skill_loading/README.ja.md +++ b/s07_skill_loading/README.ja.md @@ -3,7 +3,7 @@ [中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20 -> *"Load when needed, don't stuff the prompt"* — tool_result で注入、system prompt には詰め込まない。 +> *"Load when needed, don't stuff the prompt"* — `tool_result` で注入、system prompt には詰め込まない。 > > **Harness レイヤー**: 知識 — 必要に応じて読み込み、コンテキストに詰め込まない。 @@ -11,7 +11,9 @@ s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_c ## 課題 -プロジェクトには React コンポーネント仕様、SQL スタイルガイド、API 設計ドキュメントがある。Agent にこれらの仕様を自動的に守らせたい。最も直接的な方法は、すべて system prompt に詰め込むこと: +前章では Agent が大きなタスクをサブ Agent に渡せるようになった。だがサブ Agent が引き継ぐとき、そのタスクの決まりを知る必要がある:React コンポーネントを変更するなら component spec を、SQL を書くなら style guide を守る。その決まりはどこから来るのか? + +プロジェクトには React コンポーネント仕様、SQL スタイルガイド、API 設計ドキュメントがある。最も直接的な方法は、すべて system prompt に詰め込むこと: ```python SYSTEM = ( @@ -30,7 +32,9 @@ SYSTEM = ( ![Skill Overview](images/skill-overview.svg) -前章の最小フック構造、`todo_write`、サブ Agent を維持し、本章は新規の `load_skill` ツールに注目する。起動時にスキルカタログを SYSTEM prompt に注入し、実行時に完全な内容を読み込むツールを登録する。使ったときだけトークンを消費。 +折衷案は、ドキュメントを複数のファイルに分け、必要なものを Agent 自身に `read_file` させることだ。だが Agent はそもそもどんなファイルが読めるか分からない。「何があるか」を先に知らなければ、「どれを使うか」は選べない。 + +そこで 2 層に分ける:**カタログは常駐、内容はオンデマンド。** 前章のフック構造、`todo_write`、サブ Agent はそのまま残し、本章で `load_skill` ツールを 1 つ加える。起動時にスキルのカタログ(名前 + 一言の説明)を SYSTEM prompt に入れる。毎ターン携帯するが軽い。実行時に Agent が実際にあるスキルを使うとき、`load_skill` を呼んで完全な内容を取り出す。トークンを使うのはそのときだけだ。 2 層設計: @@ -90,6 +94,8 @@ def build_system() -> str: SYSTEM = build_system() ``` +だが Agent は毎ターン、名前と一言の説明しか受け取らない。実際に SQL スタイルガイドを使うとなると、あの 1500 行の完全な内容にはまだ手が届かない。→ 第 2 層。 + **第 2 層:load_skill**:Agent が「SQL スタイルガイドが必要」と判断し、`load_skill("sql-style")` を呼び出す。レジストリを通じて検索し、ファイルパスを経由しないため、パストラバーサルのリスクがない。SKILL.md の内容は `tool_result` を通じて注入され、既存の file および bash ツールを通じて、参照される `references/`、`scripts/`、`assets/` へのその後のアクセスも含められる。 ```python @@ -100,7 +106,7 @@ def load_skill(name: str) -> str: return skill["content"] ``` -重要な違い:スキル内容は system prompt の一部ではなく、ツール結果として現在の messages に入る。後続の呼び出しでは履歴とともに携帯され、コンテキスト圧縮、切り捨て、またはセッション終了まで保持される。これは s08 の compact と自然に接続する:オンデマンド読み込みにより、無関係なドキュメントが system prompt に入らなくなる。compact が「捨てるべきものをどう捨てるか」を解決する。 +重要な違い:スキル内容は system prompt の一部ではなく、ツール結果として現在の `messages` に入る。後続の呼び出しでは履歴とともに携帯され、コンテキスト圧縮、切り捨て、またはセッション終了まで保持される。これは s08 の compact と自然に接続する:スキル内容は system prompt ではなく `tool_result` として `messages` に入り、compact が「捨てるべきものをどう捨てるか」を解決する。 --- @@ -135,7 +141,7 @@ python s07_skill_loading/code.py ## 次へ -load_skill で起動時のトークン浪費は解消した。しかし別の問題が待っている:Agent が 30 分連続で作業すると、messages リストが中間プロセスで埋め尽くされる。古い tool_result、期限切れのファイル内容、コンテキストを占領しているが価値を生まない。 +load_skill で起動時のトークン浪費は解消した。しかし別の問題が待っている:Agent が 30 分連続で作業すると、`messages` リストが中間プロセスで埋め尽くされる。古い `tool_result`、期限切れのファイル内容、コンテキストを占領しているが価値を生まない。 → s08 Context Compact:4 層圧縮戦略。安価な層を先に実行、高価な層を後に実行。 @@ -179,4 +185,4 @@ Claude Code の SKILL.md YAML frontmatter は `parseSkillFrontmatterFields()`(
- + diff --git a/s07_skill_loading/README.md b/s07_skill_loading/README.md index 31d6f6cb5..d2a30c709 100644 --- a/s07_skill_loading/README.md +++ b/s07_skill_loading/README.md @@ -3,7 +3,7 @@ [中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20 -> *"Load when needed, don't stuff the prompt"* — Inject via tool_result, not system prompt. +> *"Load when needed, don't stuff the prompt"* — Inject via `tool_result`, not system prompt. > > **Harness Layer**: Knowledge — load on demand, don't fill the context. @@ -11,7 +11,9 @@ s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_c ## The Problem -Your project has a React component spec, a SQL style guide, and an API design doc. You want the Agent to follow these specs automatically. The most straightforward idea: stuff them all into the system prompt: +The last chapter let the Agent hand a big task off to a sub-Agent. But when the sub-Agent takes over, it needs to know the rules of that task: editing React components means following the component spec, writing SQL means following the style guide. Where do those rules come from? + +Your project has a React component spec, a SQL style guide, and an API design doc. The most straightforward idea: stuff them all into the system prompt: ```python SYSTEM = ( @@ -30,7 +32,9 @@ SYSTEM = ( ![Skill Overview](images/skill-overview.svg) -The minimal hook structure, `todo_write`, and sub-Agent from the previous chapter are preserved. This chapter focuses on the new `load_skill` tool. At startup, inject the skill catalog into the SYSTEM prompt; at runtime, register one more tool to load full content, spending tokens only when used. +A middle-ground idea is to split the docs into separate files and let the Agent `read_file` whichever it needs. But the Agent has no idea which files exist to read; it has to know "what's there" before it can pick "which one to use". + +So split it into two levels: **catalog always resident, content on demand.** The hook structure, `todo_write`, and sub-Agent from the previous chapter stay; this chapter adds a `load_skill` tool. At startup, the skill catalog (name + one-line description) goes into the SYSTEM prompt, carried every turn but light; at runtime, when the Agent actually needs a skill, it calls `load_skill` to pull the full content, spending those tokens only then. Two-level design: @@ -90,6 +94,8 @@ def build_system() -> str: SYSTEM = build_system() ``` +But every turn the Agent only gets the name and one-line description; to actually use the SQL style guide, those 1500 lines of full content are still out of reach. → Level 2. + **Level 2: load_skill**: the Agent decides "I need the SQL style guide" and calls `load_skill("sql-style")`. Lookup goes through the registry, not file paths, eliminating path traversal risk. The SKILL.md content is injected via `tool_result`, and can include later access to referenced `references/`, `scripts/`, or `assets/` through the existing file and bash tools. ```python @@ -100,7 +106,7 @@ def load_skill(name: str) -> str: return skill["content"] ``` -The key distinction: skill content is not part of the system prompt. It enters the current messages as a tool result. Subsequent calls carry it along with the history until context compaction, truncation, or session end. This naturally connects to s08's compact: on-demand loading keeps irrelevant docs out of the system prompt, compact solves "how to drop what you should." +The key distinction: skill content is not part of the system prompt. It enters the current `messages` as a `tool_result`. Subsequent calls carry it along with the history until context compaction, truncation, or session end. This naturally connects to s08's compact: skill content enters `messages` as a `tool_result` rather than the system prompt, and compact solves "how to drop what you should." --- @@ -135,7 +141,7 @@ What to watch for: Does the Agent know available skills from the SYSTEM catalog? ## What's Next -load_skill eliminated the startup token waste. But another problem looms: after the Agent works for 30 minutes, the messages list fills up with intermediate process. Old tool_results, stale file contents, occupying context but adding no value. +load_skill eliminated the startup token waste. But another problem looms: after the Agent works for 30 minutes, the `messages` list fills up with intermediate process. Old `tool_result`s, stale file contents, occupying context but adding no value. → s08 Context Compact: A four-layer compaction strategy. Cheap layers run first, expensive layers run last. @@ -179,4 +185,4 @@ The complete field list changes across versions; above are the core fields relev
- + diff --git a/s07_skill_loading/README.zh.md b/s07_skill_loading/README.zh.md index 183b2afeb..a1b33a47f 100644 --- a/s07_skill_loading/README.zh.md +++ b/s07_skill_loading/README.zh.md @@ -3,7 +3,7 @@ [中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_compact/) → s09 → ... → s20 -> *"用到时再加载, 别全塞 prompt 里"* — 通过 tool_result 注入, 不塞 system prompt。 +> *"用到时再加载, 别全塞 prompt 里"* — 通过 `tool_result` 注入, 不塞 system prompt。 > > **Harness 层**: 知识 — 按需加载, 不堆满上下文。 @@ -11,7 +11,9 @@ s01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](../s08_context_c ## 问题 -你的项目有一套 React 组件规范、一份 SQL 风格指南、一份 API 设计文档。你希望 Agent 自动遵守这些规范。最直接的想法,全塞进 system prompt: +上一章 Agent 能把大任务拆给子 Agent 了。但子 Agent 接手时,得知道这个任务的规矩:改 React 组件要守组件规范,写 SQL 要守风格指南。这些规范从哪来? + +你的项目里有一套 React 组件规范、一份 SQL 风格指南、一份 API 设计文档。最直接的想法,全塞进 system prompt: ```python SYSTEM = ( @@ -22,7 +24,7 @@ SYSTEM = ( ) ``` -6500 行 system prompt。Agent 每次调用 LLM 都带着这些文档——不管是在改 CSS 颜色还是修 SQL 查询。99% 的内容和当前任务无关,白白消耗 token。 +6500 行 system prompt。Agent 每次调用 LLM 都带着这些文档,不管在改 CSS 颜色还是修 SQL 查询。99% 的内容和当前任务无关,白白消耗 token。 --- @@ -30,7 +32,9 @@ SYSTEM = ( ![Skill Overview](images/skill-overview.svg) -保留上一章的最小 hook 结构、`todo_write` 和子 Agent,本章重点转向新增的 `load_skill` 工具。启动时把技能目录注入 SYSTEM prompt,运行时多注册一个工具加载完整内容,用到才花 token。 +一个折中的想法,是把文档拆成几个文件,用哪个让 Agent 自己 `read_file`。但 Agent 根本不知道有哪些文件可读,它得先知道"有什么",才谈得上"用哪个"。 + +所以分两层:**目录常驻,内容按需。** 上一章的 hook 结构、`todo_write` 和子 Agent 都保留,本章新增一个 `load_skill` 工具。启动时把技能目录(名字 + 一句话描述)注入 SYSTEM prompt,每轮都带、但很轻;运行时 Agent 真要用某个技能,再调 `load_skill` 把完整内容取过来,用到才花那部分 token。 两层设计: @@ -39,7 +43,7 @@ SYSTEM = ( | 1. 目录 | system prompt | 启动时注入(harness 扫描 skills/) | ~100 tokens/skill,每轮都带 | | 2. 内容 | tool_result | Agent 调用 load_skill 时;SKILL.md 可指引后续的 read_file/bash 调用,用于按需访问额外资源 | ~2000 tokens/skill,按需 | -dispatch 机制不变,load_skill 通过 `TOOL_HANDLERS[block.name]` 分发。 +dispatch 机制不变,`load_skill` 通过 `TOOL_HANDLERS[block.name]` 分发。 --- @@ -90,6 +94,8 @@ def build_system() -> str: SYSTEM = build_system() ``` +但 Agent 每轮只拿到名字和一句话描述,真要用 SQL 风格指南,那 1500 行的完整内容还够不到。→ 第二级。 + **第二级:load_skill**:Agent 决定"我需要 SQL 风格指南",调用 `load_skill("sql-style")`。通过注册表查找,不走文件路径,没有路径遍历风险。SKILL.md 内容通过 `tool_result` 注入,并可通过现有的 file 和 bash 工具进一步访问引用的 `references/`、`scripts/` 或 `assets/`。 ```python @@ -100,7 +106,7 @@ def load_skill(name: str) -> str: return skill["content"] ``` -关键区别:技能内容不是 system prompt 的一部分,它作为一次工具结果进入当前 messages。后续调用会随历史一起携带,直到上下文压缩、截断或会话结束。这和 s08 的 compact 自然衔接:技能内容以 tool_result 形式进入 messages,不塞 system prompt,compact 解决"该丢的怎么丢"。 +关键区别:技能内容不是 system prompt 的一部分,它作为一次工具结果进入当前 `messages`。后续调用会随历史一起携带,直到上下文压缩、截断或会话结束。这和 s08 的 compact 自然衔接:技能内容以 `tool_result` 形式进入 `messages`,不塞 system prompt,compact 解决"该丢的怎么丢"。 --- @@ -135,7 +141,7 @@ python s07_skill_loading/code.py ## 接下来 -load_skill 解决了启动时的 token 浪费。但另一个问题来了:Agent 连续工作 30 分钟后,messages 列表塞满了中间过程。旧的 tool_result、过时的文件内容,占着上下文但不产生价值。 +`load_skill` 解决了启动时的 token 浪费。但另一个问题来了:Agent 连续工作 30 分钟后,`messages` 列表塞满了中间过程。旧的 `tool_result`、过时的文件内容,占着上下文但不产生价值。 s08 Context Compact → 四层压缩策略。便宜的先跑,贵的后跑。 @@ -168,7 +174,7 @@ Claude Code 的 SKILL.md YAML frontmatter 由 `parseSkillFrontmatterFields()` ### 三、两级加载的精确实现 1. **Catalog(启动时)**:`getSkillDirCommands()` 扫描目录 → 注册为 `Command` 对象,只包含元数据。`getSkillListingAttachments()` 把技能列表格式化为附件,预算为上下文窗口的 ~1%(上限 8000 字符)。 -2. **Load(调用时)**:模型调 `Skill` 工具(输入字段是 `skill` + 可选 `args`,教学版用 `name`)→ `getPromptForCommand()` 展开完整 SKILL.md 内容 → `SkillTool` 返回的 tool_result 展示文本只是 `"Launching skill: {name}"`,真正的技能内容通过 `newMessages` 注入对话。教学版把两者合并为"通过 tool_result 注入"是一种简化;加载后的 SKILL.md 仍可作为指引,帮助模型后续通过现有 file/bash 工具访问相关资源。 +2. **Load(调用时)**:模型调 `Skill` 工具(输入字段是 `skill` + 可选 `args`,教学版用 `name`)→ `getPromptForCommand()` 展开完整 SKILL.md 内容 → `SkillTool` 返回的 `tool_result` 展示文本只是 `"Launching skill: {name}"`,真正的技能内容通过 `newMessages` 注入对话。教学版把两者合并为"通过 tool_result 注入"是一种简化;加载后的 SKILL.md 仍可作为指引,帮助模型后续通过现有 file/bash 工具访问相关资源。 ### 教学版的简化是刻意的 @@ -179,4 +185,4 @@ Claude Code 的 SKILL.md YAML frontmatter 由 `parseSkillFrontmatterFields()`
- + diff --git a/web/src/data/generated/docs.json b/web/src/data/generated/docs.json index 6b460913f..6492a34c7 100644 --- a/web/src/data/generated/docs.json +++ b/web/src/data/generated/docs.json @@ -99,7 +99,7 @@ "version": "s06", "locale": "zh", "title": "s06: Subagent — 大任务拆小,每个拿到的都是干净上下文", - "content": "# s06: Subagent — 大任务拆小,每个拿到的都是干净上下文\n\ns01 → s02 → s03 → s04 → s05 → `s06` → [s07](/zh/s07) → s08 → ... → s20\n\n> *\"大任务拆小, 每个小任务干净的上下文\"* — Subagent 用独立 `messages[]`, 不污染主对话。\n>\n> **Harness 层**: 子 Agent — 上下文隔离, 注意力不漂移。\n\n---\n\n## 问题\n\n上一章给 Agent 加了 `todo_write`,它能把大任务拆成一张清单,一步步推进。但拆出来的子任务,还是在同一个 `messages[]` 里跑。\n\nAgent 在修一个 bug:读了 30 个文件跟踪调用链,来回聊了 60 轮,`messages` 涨到 120 条。其中大半是\"跟踪调用链\"的中间过程,和\"修 bug\"这个最终目标已经没关系了,却还占着上下文。早期的关键信息被挤出有效窗口,模型对最初那个 bug 的描述,反而记不清了。\n\n换个角度想:你修 bug 时会\"开一个新终端\"去跟踪调用链,跟踪完把结果记下来,关掉终端,回到原来的终端接着修。Agent 也需要这个能力:开一个独立的子进程,给它一份独立的消息列表,让它专心做一件事,做完只把结论带回来。\n\n---\n\n## 解决方案\n\n![Subagent Overview](/course-assets/s06_subagent/subagent-overview.svg)\n\n一个符合直觉的做法,是让主 Agent 自己把调用链跟踪完、接着修。但跟踪的过程会全部留在主对话里,这正是上面那个问题。\n\n所以换个思路:**把脏活外包出去,只收一句结论。** 上一章的 hook 结构和 `todo_write` 都保留,本章新增一个 `task` 工具。主 Agent 调它时,spawn 一个子 Agent,给它全新的 `messages[]`,让它跑自己的循环;结束后只回传一段摘要文本,中间那 60 轮全部丢弃。对话上下文不进主 Agent,但子 Agent 在文件系统上做的实际改动(写文件、改文件、跑命令)会留在工作目录里。\n\n子 Agent 的工具是受限的:有 `bash`/`read`/`write`/`edit`/`glob`,但没有 `task`,不能再递归 spawn 新的子 Agent。而且它的每次工具调用仍走权限 hook,安全策略不因为上下文隔离就跳过。\n\n---\n\n## 工作原理\n\n子 Agent 本质上就是 s01 那个循环的另一份实例,只是换了一份空的 `messages[]` 和一套更窄的工具:\n\n```python\ndef spawn_subagent(description: str) -> str:\n # 子 Agent 的工具:基础工具,但没有 task(禁止递归)\n sub_tools = [\n {\"name\": \"bash\", ...}, {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...}, {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n ]\n messages = [{\"role\": \"user\", \"content\": description}] # 全新 messages[]\n\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=sub_tools, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({... \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown\"\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({... \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # 只返回最后的文本结论,中间过程全部丢弃\n return extract_text(messages[-1][\"content\"])\n```\n\n注意这个循环里的几处关键点:工具集里没有 `task`(不能再 spawn,递归到此为止);`for _ in range(30)` 是循环轮次的安全上限(子 Agent 最多跑 30 轮,避免无限循环);每次工具调用前仍走 `PreToolUse` hook(隔离了上下文,但没隔离权限);最后只用 `extract_text(messages[-1])` 取一句结论,中间过程整份丢掉。\n\n主 Agent 这边,调它跟调其他工具完全一样:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n {\"name\": \"todo_write\", ...},\n # s06: 新增 task 工具\n {\"name\": \"task\",\n \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n]\n\nTOOL_HANDLERS[\"task\"] = spawn_subagent\n```\n\n四个关键设计决策:\n\n| 决策 | 选择 | 原因 |\n|------|------|------|\n| 上下文隔离 | 全新 `messages[]` | 子 Agent 的中间过程不污染主 Agent 的上下文 |\n| 只回传结论 | `extract_text(last_message)` | 不是回传整个 `messages` 列表 |\n| 禁止递归 | 子 Agent 无 `task` 工具 | 防止子 Agent 再 spawn 新的子 Agent |\n| 安全策略不跳过 | 子 Agent 工具调用也走 PreToolUse hook | 上下文隔离不代表权限隔离 |\n\ndispatch 机制不变,`task` 工具通过 `TOOL_HANDLERS[block.name]` 分发。子 Agent 还有独立的 `SUB_SYSTEM` 提示,明确要求\"直接完成任务,不要再委派\"。\n\n---\n\n## 相对 s05 的变更\n\n| 组件 | 之前 (s05) | 之后 (s06) |\n|------|-----------|-----------|\n| 工具数量 | 6 (`bash`, `read`, `write`, `edit`, `glob`, `todo_write`) | 7 (+`task`) |\n| 新函数 | — | `spawn_subagent`(独立 `messages[]` + 30 轮安全限制) |\n| 上下文隔离 | 全部在主对话中 | 子 Agent 用全新的 `messages[]` |\n| 循环 | 不变 | dispatch 不变,子 Agent 有独立 `SUB_SYSTEM` 和 hook 保护的循环 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s06_subagent/code.py\n```\n\n试试这些 prompt:\n\n1. `Use a subtask to find what testing framework this project uses`(子 Agent 去读文件,主 Agent 只收结论)\n2. `Delegate: read all .py files in agents/ and summarize what each one does`\n3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`\n\n观察重点:是否出现 `[Subagent spawned]` / `[Subagent done]`?子 Agent 的工具调用是否以 `[sub] ...` 输出?主 Agent 最后是否只继续处理子 Agent 返回的摘要?\n\n---\n\n## 接下来\n\nAgent 现在能拆任务了。但每个任务需要的知识不一样:改前端组件需要知道 React 规范,写 SQL 需要知道表结构。这些知识全塞进 system prompt,上下文有可能会直接被填满。\n\ns07 Skill Loading → 技能按需注入,不在 system prompt 里堆文档。用到的时候才加载,和读文件一样自然。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` 的完整分析。\n\n### 一、不是一种模式,是三种\n\n教学版只讲了\"全新的 `messages[]`\"。Claude Code 实际有三种执行模式:\n\n| 模式 | 触发条件 | 上下文 |\n|------|---------|--------|\n| **Normal Subagent** | 指定了 `subagent_type`(normal path) | 全新 `messages[]`,只有 prompt |\n| **Fork Subagent** | 没指定 `subagent_type`,fork gate 开启 | 通过 `buildForkedMessages()` 构造 cache-friendly 前缀,共享 prompt cache |\n| **General-Purpose** | 没指定 `subagent_type`,fork gate 关闭 | 同 Normal |\n\n### 二、Fork 模式:为了共享 Prompt Cache\n\n这是教学版没有的核心概念。Fork 模式(`forkSubagent.ts:60-71`)不创建全新上下文,而是通过 `buildForkedMessages()`(`forkSubagent.ts:107-168`)构造 cache-friendly 消息前缀,保留父 assistant message 并生成 placeholder tool results。目的不是隔离,而是让 Anthropic API 的 prompt cache 命中:父子 Agent 的 system prompt、tools、messages 前缀完全一致,API 端不需要重算。\n\n缓存命中的五个关键组件(`forkedAgent.ts:57-68`):system prompt、tools、model、messages 前缀、thinking config,必须字节级一致。\n\n### 三、Context Isolation 的精确粒度\n\n`createSubagentContext()`(`forkedAgent.ts:345-462`)创建子 Agent 的 `ToolUseContext`:\n\n| 字段 | 行为 |\n|------|------|\n| `abortController` | 新的 child controller,父 abort 向下传播 |\n| `setAppState` | 默认 no-op;但 sync agent 通过 `shareSetAppState` 共享(`runAgent.ts:697-714`) |\n| `readFileState` | **从父克隆**(避免重复读相同文件) |\n| `queryTracking` | 新 chainId,`depth = parentDepth + 1` |\n\n子 Agent 不是完全隔离的:文件读取状态是共享的。UI 和通知的隔离程度取决于执行路径(sync/async/fork/teammate 各不同)。\n\n### 四、递归 Fork 防护\n\n教学版用\"子 Agent 不给 `task` 工具\"表达递归保护。真实实现更精细:`isInForkChild()`(`forkSubagent.ts:78-89`)检查对话历史中是否有 `FORK_BOILERPLATE_TAG`,有就拒绝。但 `constants/tools.ts:36-46` 中 `Agent` 工具默认在所有 agent 的禁用集合里,`USER_TYPE === 'ant'` 时例外;`forkSubagent.ts:73-89` 针对 fork child 有专门的递归保护;`agentToolUtils.ts:100-110` 在 teammate 场景下有特殊放行。不是简单的\"禁止新的子 Agent\"。\n\n### 五、Permission Bubbling\n\nFork Agent 的 `permissionMode: 'bubble'`(`forkSubagent.ts:67`)意味着子 Agent 的权限弹窗冒泡到父终端,用户在主终端里审批子 Agent 的操作。\n\n### 六、Async vs Sync\n\n教学版只展示了同步子 Agent(父等着子跑完)。Claude Code 还支持异步路径(`AgentTool.tsx:686-764`):`run_in_background: true` 时异步启动,返回 `{ status: 'async_launched' }` 立即给父 Agent,子 Agent 完成后通过通知机制告知父 Agent。实际触发条件不止 `run_in_background`,还有 auto-background、assistant force async、coordinator/proactive 等路径。\n\n### 教学版的简化是刻意的\n\n- 三种模式 → 一种(全新 `messages[]`):概念清晰\n- Prompt cache 共享 → 省略:教学版不涉及 API 层优化\n- 递归 fork 防护 → 简化为\"子 Agent 无 `task` 工具\"\n- Async → 省略(留给 s13):s06 先理解同步模型\n\n
\n\n\n" + "content": "# s06: Subagent — 大任务拆小,每个拿到的都是干净上下文\n\ns01 → s02 → s03 → s04 → s05 → `s06` → [s07](/zh/s07) → s08 → ... → s20\n\n> *\"大任务拆小, 每个小任务干净的上下文\"* — Subagent 用独立 `messages[]`, 不污染主对话。\n>\n> **Harness 层**: 子 Agent — 上下文隔离, 注意力不漂移。\n\n---\n\n## 问题\n\n上一章给 Agent 加了 `todo_write`,它能把大任务拆成一张清单,一步步推进。但拆出来的子任务,还是在同一个 `messages[]` 里跑。\n\nAgent 在修一个 bug:读了 30 个文件跟踪调用链,来回聊了 60 轮,`messages` 涨到 120 条。其中大半是\"跟踪调用链\"的中间过程,和\"修 bug\"这个最终目标已经没关系了,却还占着上下文。早期的关键信息被挤出有效窗口,模型对最初那个 bug 的描述,反而记不清了。\n\n换个角度想:你修 bug 时会\"开一个新终端\"去跟踪调用链,跟踪完把结果记下来,关掉终端,回到原来的终端接着修。Agent 也需要这个能力:开一个独立的子进程,给它一份独立的消息列表,让它专心做一件事,做完只把结论带回来。\n\n---\n\n## 解决方案\n\n![Subagent Overview](/course-assets/s06_subagent/subagent-overview.svg)\n\n一个符合直觉的做法,是让主 Agent 自己把调用链跟踪完、接着修。但跟踪的过程会全部留在主对话里,这正是上面那个问题。\n\n所以换个思路:**把脏活外包出去,只收一句结论。** 上一章的 hook 结构和 `todo_write` 都保留,本章新增一个 `task` 工具。主 Agent 调它时,spawn 一个子 Agent,给它全新的 `messages[]`,让它跑自己的循环;结束后只回传一段摘要文本,中间那 60 轮全部丢弃。对话上下文不进主 Agent,但子 Agent 在文件系统上做的实际改动(写文件、改文件、跑命令)会留在工作目录里。\n\n子 Agent 的工具是受限的:有 `bash`/`read`/`write`/`edit`/`glob`,但没有 `task`,不能再递归 spawn 新的子 Agent。而且它的每次工具调用仍走权限 hook,安全策略不因为上下文隔离就跳过。\n\n---\n\n## 工作原理\n\n子 Agent 本质上就是 s01 那个循环的另一份实例,只是换了一份空的 `messages[]` 和一套更窄的工具:\n\n```python\ndef spawn_subagent(description: str) -> str:\n # 子 Agent 的工具:基础工具,但没有 task(禁止递归)\n sub_tools = [\n {\"name\": \"bash\", ...}, {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...}, {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n ]\n messages = [{\"role\": \"user\", \"content\": description}] # 全新 messages[]\n\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=sub_tools, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({... \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown\"\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({... \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n\n # 只返回最后的文本结论,中间过程全部丢弃\n return extract_text(messages[-1][\"content\"])\n```\n\n注意这个循环里的几处关键点:工具集里没有 `task`(不能再 spawn,递归到此为止);`for _ in range(30)` 是循环轮次的安全上限(子 Agent 最多跑 30 轮,避免无限循环);每次工具调用前仍走 `PreToolUse` hook(隔离了上下文,但没隔离权限);最后只用 `extract_text(messages[-1])` 取一句结论,中间过程整份丢掉。\n\n主 Agent 这边,调它跟调其他工具完全一样:\n\n```python\nTOOLS = [\n {\"name\": \"bash\", ...},\n {\"name\": \"read_file\", ...},\n {\"name\": \"write_file\", ...},\n {\"name\": \"edit_file\", ...},\n {\"name\": \"glob\", ...},\n {\"name\": \"todo_write\", ...},\n # s06: 新增 task 工具\n {\"name\": \"task\",\n \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n]\n\nTOOL_HANDLERS[\"task\"] = spawn_subagent\n```\n\n四个关键设计决策:\n\n| 决策 | 选择 | 原因 |\n|------|------|------|\n| 上下文隔离 | 全新 `messages[]` | 子 Agent 的中间过程不污染主 Agent 的上下文 |\n| 只回传结论 | `extract_text(last_message)` | 不是回传整个 `messages` 列表 |\n| 禁止递归 | 子 Agent 无 `task` 工具 | 防止子 Agent 再 spawn 新的子 Agent |\n| 安全策略不跳过 | 子 Agent 工具调用也走 PreToolUse hook | 上下文隔离不代表权限隔离 |\n\ndispatch 机制不变,`task` 工具通过 `TOOL_HANDLERS[block.name]` 分发。子 Agent 还有独立的 `SUB_SYSTEM` 提示,明确要求\"直接完成任务,不要再委派\"。\n\n---\n\n## 相对 s05 的变更\n\n| 组件 | 之前 (s05) | 之后 (s06) |\n|------|-----------|-----------|\n| 工具数量 | 6 (`bash`, `read`, `write`, `edit`, `glob`, `todo_write`) | 7 (+`task`) |\n| 新函数 | — | `spawn_subagent`(独立 `messages[]` + 30 轮安全限制) |\n| 上下文隔离 | 全部在主对话中 | 子 Agent 用全新的 `messages[]` |\n| 循环 | 不变 | dispatch 不变,子 Agent 有独立 `SUB_SYSTEM` 和 hook 保护的循环 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s06_subagent/code.py\n```\n\n试试这些 prompt:\n\n1. `Use a subtask to find what testing framework this project uses`(子 Agent 去读文件,主 Agent 只收结论)\n2. `Delegate: read all .py files in agents/ and summarize what each one does`\n3. `Use a task to create s06_subagent/example/string_tools.py with a slugify(text: str) function, then verify it from the parent agent`\n\n观察重点:是否出现 `[Subagent spawned]` / `[Subagent done]`?子 Agent 的工具调用是否以 `[sub] ...` 输出?主 Agent 最后是否只继续处理子 Agent 返回的摘要?\n\n---\n\n## 接下来\n\nAgent 现在能拆任务了。但每个任务需要的知识不一样:改前端组件需要知道 React 规范,写 SQL 需要知道表结构。这些知识全塞进 system prompt,上下文有可能会直接被填满。\n\ns07 Skill Loading → 技能按需注入,不在 system prompt 里堆文档。用到的时候才加载,和读文件一样自然。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `AgentTool.tsx`、`runAgent.ts`、`forkSubagent.ts`、`forkedAgent.ts` 的完整分析。\n\n### 一、不是一种模式,是三种\n\n教学版只讲了\"全新的 `messages[]`\"。Claude Code 实际有三种执行模式:\n\n| 模式 | 触发条件 | 上下文 |\n|------|---------|--------|\n| **Normal Subagent** | 指定了 `subagent_type`(normal path) | 全新 `messages[]`,只有 prompt |\n| **Fork Subagent** | 没指定 `subagent_type`,fork gate 开启 | 通过 `buildForkedMessages()` 构造 cache-friendly 前缀,共享 prompt cache |\n| **General-Purpose** | 没指定 `subagent_type`,fork gate 关闭 | 同 Normal |\n\n### 二、Fork 模式:为了共享 Prompt Cache\n\n这是教学版没有的核心概念。Fork 模式(`forkSubagent.ts:60-71`)不创建全新上下文,而是通过 `buildForkedMessages()`(`forkSubagent.ts:107-168`)构造 cache-friendly 消息前缀,保留父 assistant message 并生成 placeholder tool results。目的不是隔离,而是让 Anthropic API 的 prompt cache 命中:父子 Agent 的 system prompt、tools、messages 前缀完全一致,API 端不需要重算。\n\n缓存命中的五个关键组件(`forkedAgent.ts:57-68`):system prompt、tools、model、messages 前缀、thinking config,必须字节级一致。\n\n### 三、Context Isolation 的精确粒度\n\n`createSubagentContext()`(`forkedAgent.ts:345-462`)创建子 Agent 的 `ToolUseContext`:\n\n| 字段 | 行为 |\n|------|------|\n| `abortController` | 新的 child controller,父 abort 向下传播 |\n| `setAppState` | 默认 no-op;但 sync agent 通过 `shareSetAppState` 共享(`runAgent.ts:697-714`) |\n| `readFileState` | **从父克隆**(避免重复读相同文件) |\n| `queryTracking` | 新 chainId,`depth = parentDepth + 1` |\n\n子 Agent 不是完全隔离的:文件读取状态是共享的。UI 和通知的隔离程度取决于执行路径(sync/async/fork/teammate 各不同)。\n\n### 四、递归 Fork 防护\n\n教学版用\"子 Agent 不给 `task` 工具\"表达递归保护。真实实现更精细:`isInForkChild()`(`forkSubagent.ts:78-89`)检查对话历史中是否有 `FORK_BOILERPLATE_TAG`,有就拒绝。但 `constants/tools.ts:36-46` 中 `Agent` 工具默认在所有 agent 的禁用集合里,`USER_TYPE === 'ant'` 时例外;`forkSubagent.ts:73-89` 针对 fork child 有专门的递归保护;`agentToolUtils.ts:100-110` 在 teammate 场景下有特殊放行。不是简单的\"禁止新的子 Agent\"。\n\n### 五、Permission Bubbling\n\nFork Agent 的 `permissionMode: 'bubble'`(`forkSubagent.ts:67`)意味着子 Agent 的权限弹窗冒泡到父终端,用户在主终端里审批子 Agent 的操作。\n\n### 六、Async vs Sync\n\n教学版只展示了同步子 Agent(父等着子跑完)。Claude Code 还支持异步路径(`AgentTool.tsx:686-764`):`run_in_background: true` 时异步启动,返回 `{ status: 'async_launched' }` 立即给父 Agent,子 Agent 完成后通过通知机制告知父 Agent。实际触发条件不止 `run_in_background`,还有 auto-background、assistant force async、coordinator/proactive 等路径。\n\n### 教学版的简化是刻意的\n\n- 三种模式 → 一种(全新 `messages[]`):概念清晰\n- Prompt cache 共享 → 省略:教学版不涉及 API 层优化\n- 递归 fork 防护 → 简化为\"子 Agent 无 `task` 工具\"\n- Async → 省略(留给 s13):s06 先理解同步模型\n\n
\n\n\n" }, { "version": "s06", @@ -111,19 +111,19 @@ "version": "s07", "locale": "en", "title": "s07: Skill Loading — Load Only When Needed", - "content": "# s07: Skill Loading — Load Only When Needed\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/en/s08) → s09 → ... → s20\n> *\"Load when needed, don't stuff the prompt\"* — Inject via tool_result, not system prompt.\n>\n> **Harness Layer**: Knowledge — load on demand, don't fill the context.\n\n---\n\n## The Problem\n\nYour project has a React component spec, a SQL style guide, and an API design doc. You want the Agent to follow these specs automatically. The most straightforward idea: stuff them all into the system prompt:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read() # 2000 lines\n + open(\"docs/sql-style.md\").read() # 1500 lines\n + open(\"docs/api-design.md\").read() # 3000 lines\n)\n```\n\n6500 lines of system prompt. The Agent carries these docs on every LLM call, whether it's changing a CSS color or fixing a SQL query. 99% of the content is irrelevant to the current task, burning tokens for nothing.\n\n---\n\n## The Solution\n\n![Skill Overview](/course-assets/s07_skill_loading/skill-overview.svg)\n\nThe minimal hook structure, `todo_write`, and sub-Agent from the previous chapter are preserved. This chapter focuses on the new `load_skill` tool. At startup, inject the skill catalog into the SYSTEM prompt; at runtime, register one more tool to load full content, spending tokens only when used.\n\nTwo-level design:\n\n| Level | Location | Timing | Cost |\n|-------|----------|--------|------|\n| 1. Catalog | system prompt | Injected at startup (harness scans skills/) | ~100 tokens/skill, carried every turn |\n| 2. Content | tool_result | When Agent calls load_skill; SKILL.md can guide later read_file/bash access to extra resources | ~2000 tokens/skill, on demand |\n\nThe dispatch mechanism is unchanged, `load_skill` auto-dispatches via `TOOL_HANDLERS[block.name]`.\n\n---\n\n## How It Works\n\n**skills/ directory**, one subdirectory per skill, each containing a `SKILL.md` file:\n\n```\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n**Level 1: Inject catalog at startup**: the harness calls `_scan_skills()` at startup to scan the skills/ directory, parsing each SKILL.md's YAML frontmatter (`name`, `description`) into a `SKILL_REGISTRY` dictionary. `list_skills()` generates the catalog from the registry, injected into the SYSTEM prompt. The Agent sees \"which skills I have available\" every turn, with no extra API calls:\n\n```python\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills() # runs once at startup\n\ndef list_skills() -> str:\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n```\n\n**Level 2: load_skill**: the Agent decides \"I need the SQL style guide\" and calls `load_skill(\"sql-style\")`. Lookup goes through the registry, not file paths, eliminating path traversal risk. The SKILL.md content is injected via `tool_result`, and can include later access to referenced `references/`, `scripts/`, or `assets/` through the existing file and bash tools.\n\n```python\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n```\n\nThe key distinction: skill content is not part of the system prompt. It enters the current messages as a tool result. Subsequent calls carry it along with the history until context compaction, truncation, or session end. This naturally connects to s08's compact: on-demand loading keeps irrelevant docs out of the system prompt, compact solves \"how to drop what you should.\"\n\n---\n\n## Changes from s06\n\n| Component | Before (s06) | After (s07) |\n|-----------|-------------|-------------|\n| Tool count | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |\n| Knowledge loading | None | Two-level: startup catalog in SYSTEM + runtime load_skill; SKILL.md may guide later resource access |\n| SYSTEM prompt | Static string | Startup scan of skills/ injects catalog |\n| Skill registry | None | SKILL_REGISTRY (populated at startup, prevents path traversal) |\n| Loop | Unchanged | Unchanged (skill tool auto-dispatches) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\nTry these prompts:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n\nWhat to watch for: Does the Agent know available skills from the SYSTEM catalog? Does `[HOOK] load_skill` appear when full instructions are needed? Does the answer use the loaded skill's instructions?\n\n---\n\n## What's Next\n\nload_skill eliminated the startup token waste. But another problem looms: after the Agent works for 30 minutes, the messages list fills up with intermediate process. Old tool_results, stale file contents, occupying context but adding no value.\n\n→ s08 Context Compact: A four-layer compaction strategy. Cheap layers run first, expensive layers run last.\n\n
\nDive into Claude Code Source Code\n\n> The following is based on analysis of Claude Code source code `loadSkillsDir.ts`, `SkillTool.ts`, `bundledSkills.ts`, `commands.ts`.\n\n### 1. Skill Sources: Not Just One skills/ Directory\n\nThe teaching version assumes all skills live in a `skills/` directory. Claude Code loads from multiple sources spread across multiple files: `loadSkillsDir.ts` handles user/project/`--add-dir` directories and legacy commands (`.claude/commands/`); `bundledSkills.ts` handles built-in skills; `SkillTool.ts` handles MCP remote skills; `commands.ts` handles command aggregation. Types include managed/policy skills, user skills (`~/.claude/skills/`), project skills (`.claude/skills/`), `--add-dir` skills, legacy commands, dynamic skills, conditional skills (with `paths` frontmatter, activated by file path), bundled skills, plugin skills, MCP skills.\n\n### 2. SKILL.md Frontmatter — Common Fields\n\nClaude Code's SKILL.md YAML frontmatter is parsed by `parseSkillFrontmatterFields()` in `loadSkillsDir.ts`. Common fields include:\n\n| Field | Purpose |\n|-------|---------|\n| `name` / `description` | Display name and description |\n| `when_to_use` | Guides the model on when to invoke |\n| `allowed-tools` | Auto-allow list of tools available to the skill |\n| `context` | `inline` (default) or `fork` (run as sub-Agent) |\n| `model` | Model override (haiku/sonnet/opus/inherit) |\n| `hooks` | Skill-level hook configuration |\n| `paths` | Glob patterns for conditional activation |\n| `user-invocable` | Users can invoke via `/name` |\n\nThe complete field list changes across versions; above are the core fields relevant to the teaching version.\n\n### 3. Precise Implementation of Two-Level Loading\n\n1. **Catalog (at startup)**: `getSkillDirCommands()` scans directory → registers as `Command` objects containing only metadata. `getSkillListingAttachments()` formats the skill list as attachments, budgeted at ~1% of the context window (cap 8000 characters).\n2. **Load (on invocation)**: Model calls `Skill` tool (input fields are `skill` + optional `args`; teaching version uses `name`) → `getPromptForCommand()` expands full SKILL.md content → `SkillTool` returns a tool_result with display text `\"Launching skill: {name}\"`, while the actual skill content is injected via `newMessages`. The teaching version merges both into \"injected via tool_result\" as a simplification; the loaded SKILL.md can still guide later access to referenced resources through existing file/bash tools.\n\n### The Teaching Version's Simplification Is Intentional\n\n- Multiple files and sources → 1 `skills/` directory: sufficient to demonstrate the core concept of two-level loading\n- Multiple frontmatter fields → only parse name/description: reduces parsing complexity\n- Forked skills (`context: 'fork'`) → omitted: the teaching version only expands inline skill loading\n- `Skill` tool input `skill`+`args` → teaching version uses `name`: avoids extra argument parsing complexity\n\n
\n\n\n" + "content": "# s07: Skill Loading — Load Only When Needed\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/en/s08) → s09 → ... → s20\n> *\"Load when needed, don't stuff the prompt\"* — Inject via `tool_result`, not system prompt.\n>\n> **Harness Layer**: Knowledge — load on demand, don't fill the context.\n\n---\n\n## The Problem\n\nThe last chapter let the Agent hand a big task off to a sub-Agent. But when the sub-Agent takes over, it needs to know the rules of that task: editing React components means following the component spec, writing SQL means following the style guide. Where do those rules come from?\n\nYour project has a React component spec, a SQL style guide, and an API design doc. The most straightforward idea: stuff them all into the system prompt:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read() # 2000 lines\n + open(\"docs/sql-style.md\").read() # 1500 lines\n + open(\"docs/api-design.md\").read() # 3000 lines\n)\n```\n\n6500 lines of system prompt. The Agent carries these docs on every LLM call, whether it's changing a CSS color or fixing a SQL query. 99% of the content is irrelevant to the current task, burning tokens for nothing.\n\n---\n\n## The Solution\n\n![Skill Overview](/course-assets/s07_skill_loading/skill-overview.svg)\n\nA middle-ground idea is to split the docs into separate files and let the Agent `read_file` whichever it needs. But the Agent has no idea which files exist to read; it has to know \"what's there\" before it can pick \"which one to use\".\n\nSo split it into two levels: **catalog always resident, content on demand.** The hook structure, `todo_write`, and sub-Agent from the previous chapter stay; this chapter adds a `load_skill` tool. At startup, the skill catalog (name + one-line description) goes into the SYSTEM prompt, carried every turn but light; at runtime, when the Agent actually needs a skill, it calls `load_skill` to pull the full content, spending those tokens only then.\n\nTwo-level design:\n\n| Level | Location | Timing | Cost |\n|-------|----------|--------|------|\n| 1. Catalog | system prompt | Injected at startup (harness scans skills/) | ~100 tokens/skill, carried every turn |\n| 2. Content | tool_result | When Agent calls load_skill; SKILL.md can guide later read_file/bash access to extra resources | ~2000 tokens/skill, on demand |\n\nThe dispatch mechanism is unchanged, `load_skill` auto-dispatches via `TOOL_HANDLERS[block.name]`.\n\n---\n\n## How It Works\n\n**skills/ directory**, one subdirectory per skill, each containing a `SKILL.md` file:\n\n```\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n**Level 1: Inject catalog at startup**: the harness calls `_scan_skills()` at startup to scan the skills/ directory, parsing each SKILL.md's YAML frontmatter (`name`, `description`) into a `SKILL_REGISTRY` dictionary. `list_skills()` generates the catalog from the registry, injected into the SYSTEM prompt. The Agent sees \"which skills I have available\" every turn, with no extra API calls:\n\n```python\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills() # runs once at startup\n\ndef list_skills() -> str:\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n```\n\nBut every turn the Agent only gets the name and one-line description; to actually use the SQL style guide, those 1500 lines of full content are still out of reach. → Level 2.\n\n**Level 2: load_skill**: the Agent decides \"I need the SQL style guide\" and calls `load_skill(\"sql-style\")`. Lookup goes through the registry, not file paths, eliminating path traversal risk. The SKILL.md content is injected via `tool_result`, and can include later access to referenced `references/`, `scripts/`, or `assets/` through the existing file and bash tools.\n\n```python\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n```\n\nThe key distinction: skill content is not part of the system prompt. It enters the current `messages` as a `tool_result`. Subsequent calls carry it along with the history until context compaction, truncation, or session end. This naturally connects to s08's compact: skill content enters `messages` as a `tool_result` rather than the system prompt, and compact solves \"how to drop what you should.\"\n\n---\n\n## Changes from s06\n\n| Component | Before (s06) | After (s07) |\n|-----------|-------------|-------------|\n| Tool count | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |\n| Knowledge loading | None | Two-level: startup catalog in SYSTEM + runtime load_skill; SKILL.md may guide later resource access |\n| SYSTEM prompt | Static string | Startup scan of skills/ injects catalog |\n| Skill registry | None | SKILL_REGISTRY (populated at startup, prevents path traversal) |\n| Loop | Unchanged | Unchanged (skill tool auto-dispatches) |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\nTry these prompts:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n\nWhat to watch for: Does the Agent know available skills from the SYSTEM catalog? Does `[HOOK] load_skill` appear when full instructions are needed? Does the answer use the loaded skill's instructions?\n\n---\n\n## What's Next\n\nload_skill eliminated the startup token waste. But another problem looms: after the Agent works for 30 minutes, the `messages` list fills up with intermediate process. Old `tool_result`s, stale file contents, occupying context but adding no value.\n\n→ s08 Context Compact: A four-layer compaction strategy. Cheap layers run first, expensive layers run last.\n\n
\nDive into Claude Code Source Code\n\n> The following is based on analysis of Claude Code source code `loadSkillsDir.ts`, `SkillTool.ts`, `bundledSkills.ts`, `commands.ts`.\n\n### 1. Skill Sources: Not Just One skills/ Directory\n\nThe teaching version assumes all skills live in a `skills/` directory. Claude Code loads from multiple sources spread across multiple files: `loadSkillsDir.ts` handles user/project/`--add-dir` directories and legacy commands (`.claude/commands/`); `bundledSkills.ts` handles built-in skills; `SkillTool.ts` handles MCP remote skills; `commands.ts` handles command aggregation. Types include managed/policy skills, user skills (`~/.claude/skills/`), project skills (`.claude/skills/`), `--add-dir` skills, legacy commands, dynamic skills, conditional skills (with `paths` frontmatter, activated by file path), bundled skills, plugin skills, MCP skills.\n\n### 2. SKILL.md Frontmatter — Common Fields\n\nClaude Code's SKILL.md YAML frontmatter is parsed by `parseSkillFrontmatterFields()` in `loadSkillsDir.ts`. Common fields include:\n\n| Field | Purpose |\n|-------|---------|\n| `name` / `description` | Display name and description |\n| `when_to_use` | Guides the model on when to invoke |\n| `allowed-tools` | Auto-allow list of tools available to the skill |\n| `context` | `inline` (default) or `fork` (run as sub-Agent) |\n| `model` | Model override (haiku/sonnet/opus/inherit) |\n| `hooks` | Skill-level hook configuration |\n| `paths` | Glob patterns for conditional activation |\n| `user-invocable` | Users can invoke via `/name` |\n\nThe complete field list changes across versions; above are the core fields relevant to the teaching version.\n\n### 3. Precise Implementation of Two-Level Loading\n\n1. **Catalog (at startup)**: `getSkillDirCommands()` scans directory → registers as `Command` objects containing only metadata. `getSkillListingAttachments()` formats the skill list as attachments, budgeted at ~1% of the context window (cap 8000 characters).\n2. **Load (on invocation)**: Model calls `Skill` tool (input fields are `skill` + optional `args`; teaching version uses `name`) → `getPromptForCommand()` expands full SKILL.md content → `SkillTool` returns a tool_result with display text `\"Launching skill: {name}\"`, while the actual skill content is injected via `newMessages`. The teaching version merges both into \"injected via tool_result\" as a simplification; the loaded SKILL.md can still guide later access to referenced resources through existing file/bash tools.\n\n### The Teaching Version's Simplification Is Intentional\n\n- Multiple files and sources → 1 `skills/` directory: sufficient to demonstrate the core concept of two-level loading\n- Multiple frontmatter fields → only parse name/description: reduces parsing complexity\n- Forked skills (`context: 'fork'`) → omitted: the teaching version only expands inline skill loading\n- `Skill` tool input `skill`+`args` → teaching version uses `name`: avoids extra argument parsing complexity\n\n
\n\n\n" }, { "version": "s07", "locale": "zh", "title": "s07: Skill Loading — 用到的时候才加载", - "content": "# s07: Skill Loading — 用到的时候才加载\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/zh/s08) → s09 → ... → s20\n> *\"用到时再加载, 别全塞 prompt 里\"* — 通过 tool_result 注入, 不塞 system prompt。\n>\n> **Harness 层**: 知识 — 按需加载, 不堆满上下文。\n\n---\n\n## 问题\n\n你的项目有一套 React 组件规范、一份 SQL 风格指南、一份 API 设计文档。你希望 Agent 自动遵守这些规范。最直接的想法,全塞进 system prompt:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read() # 2000 行\n + open(\"docs/sql-style.md\").read() # 1500 行\n + open(\"docs/api-design.md\").read() # 3000 行\n)\n```\n\n6500 行 system prompt。Agent 每次调用 LLM 都带着这些文档——不管是在改 CSS 颜色还是修 SQL 查询。99% 的内容和当前任务无关,白白消耗 token。\n\n---\n\n## 解决方案\n\n![Skill Overview](/course-assets/s07_skill_loading/skill-overview.svg)\n\n保留上一章的最小 hook 结构、`todo_write` 和子 Agent,本章重点转向新增的 `load_skill` 工具。启动时把技能目录注入 SYSTEM prompt,运行时多注册一个工具加载完整内容,用到才花 token。\n\n两层设计:\n\n| 层 | 位置 | 时机 | 代价 |\n|---|------|------|------|\n| 1. 目录 | system prompt | 启动时注入(harness 扫描 skills/) | ~100 tokens/skill,每轮都带 |\n| 2. 内容 | tool_result | Agent 调用 load_skill 时;SKILL.md 可指引后续的 read_file/bash 调用,用于按需访问额外资源 | ~2000 tokens/skill,按需 |\n\ndispatch 机制不变,load_skill 通过 `TOOL_HANDLERS[block.name]` 分发。\n\n---\n\n## 工作原理\n\n**skills/ 目录**,每个技能一个子目录,包含 `SKILL.md` 文件:\n\n```\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n**第一级:启动时注入目录**:harness 启动时调用 `_scan_skills()` 扫描 skills/ 目录,解析每个 SKILL.md 的 YAML frontmatter(`name`、`description`),存入 `SKILL_REGISTRY` 字典。`list_skills()` 从注册表生成目录,注入 SYSTEM prompt。Agent 每轮都能看到\"我有哪些技能可用\",不花额外 API 调用:\n\n```python\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills() # runs once at startup\n\ndef list_skills() -> str:\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n```\n\n**第二级:load_skill**:Agent 决定\"我需要 SQL 风格指南\",调用 `load_skill(\"sql-style\")`。通过注册表查找,不走文件路径,没有路径遍历风险。SKILL.md 内容通过 `tool_result` 注入,并可通过现有的 file 和 bash 工具进一步访问引用的 `references/`、`scripts/` 或 `assets/`。\n\n```python\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n```\n\n关键区别:技能内容不是 system prompt 的一部分,它作为一次工具结果进入当前 messages。后续调用会随历史一起携带,直到上下文压缩、截断或会话结束。这和 s08 的 compact 自然衔接:技能内容以 tool_result 形式进入 messages,不塞 system prompt,compact 解决\"该丢的怎么丢\"。\n\n---\n\n## 相对 s06 的变更\n\n| 组件 | 之前 (s06) | 之后 (s07) |\n|------|-----------|-----------|\n| 工具数量 | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |\n| 知识加载 | 无 | 两级:启动时目录注入 SYSTEM + 运行时 load_skill;SKILL.md 可指引后续资源访问 |\n| SYSTEM 提示 | 静态字符串 | 启动时扫描 skills/ 注入目录 |\n| 技能注册表 | 无 | SKILL_REGISTRY(启动时填充,防路径遍历) |\n| 循环 | 不变 | 不变(skill 工具自动分发) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\n试试这些 prompt:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n\n观察重点:Agent 是否直接从 SYSTEM 里的目录知道有哪些技能?需要完整规范时是否出现 `[HOOK] load_skill`?加载后回答是否使用了对应 skill 的说明?\n\n---\n\n## 接下来\n\nload_skill 解决了启动时的 token 浪费。但另一个问题来了:Agent 连续工作 30 分钟后,messages 列表塞满了中间过程。旧的 tool_result、过时的文件内容,占着上下文但不产生价值。\n\ns08 Context Compact → 四层压缩策略。便宜的先跑,贵的后跑。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` 的分析。\n\n### 一、技能来源:不是只有一个 skills/ 目录\n\n教学版假设所有技能在 `skills/` 目录下。Claude Code 实际从多个来源加载,分布在多个文件中:`loadSkillsDir.ts` 负责从 user/project/`--add-dir` 目录和 legacy commands(`.claude/commands/`)加载;`bundledSkills.ts` 负责内置技能;`SkillTool.ts` 处理 MCP 远程技能;`commands.ts` 负责命令聚合。类型包括 managed/policy skills、user skills(`~/.claude/skills/`)、project skills(`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills(带 `paths` frontmatter,按文件路径激活)、bundled skills、plugin skills、MCP skills。\n\n### 二、SKILL.md Frontmatter 常见字段\n\nClaude Code 的 SKILL.md YAML frontmatter 由 `parseSkillFrontmatterFields()` 解析(`loadSkillsDir.ts`),常见字段包括:\n\n| 字段 | 用途 |\n|------|------|\n| `name` / `description` | 显示名称和描述 |\n| `when_to_use` | 指导模型何时调用 |\n| `allowed-tools` | 技能可用工具的自动允许列表 |\n| `context` | `inline`(默认)或 `fork`(作为子 Agent 运行) |\n| `model` | 模型覆盖(haiku/sonnet/opus/inherit) |\n| `hooks` | 技能级别的 hook 配置 |\n| `paths` | 条件激活的 glob 模式 |\n| `user-invocable` | 用户可以通过 `/name` 调用 |\n\n完整字段列表随版本迭代会变化,以上仅列出教学版涉及的核心字段。\n\n### 三、两级加载的精确实现\n\n1. **Catalog(启动时)**:`getSkillDirCommands()` 扫描目录 → 注册为 `Command` 对象,只包含元数据。`getSkillListingAttachments()` 把技能列表格式化为附件,预算为上下文窗口的 ~1%(上限 8000 字符)。\n2. **Load(调用时)**:模型调 `Skill` 工具(输入字段是 `skill` + 可选 `args`,教学版用 `name`)→ `getPromptForCommand()` 展开完整 SKILL.md 内容 → `SkillTool` 返回的 tool_result 展示文本只是 `\"Launching skill: {name}\"`,真正的技能内容通过 `newMessages` 注入对话。教学版把两者合并为\"通过 tool_result 注入\"是一种简化;加载后的 SKILL.md 仍可作为指引,帮助模型后续通过现有 file/bash 工具访问相关资源。\n\n### 教学版的简化是刻意的\n\n- 多文件多来源 → 1 个 `skills/` 目录:足以展示两级加载的核心概念\n- 多个 frontmatter 字段 → 只解析 name/description:减少解析复杂度\n- forked skills(`context: 'fork'`)→ 省略:教学版只展开 inline 技能加载\n- `Skill` 工具输入 `skill`+`args` → 教学版用 `name`:避免参数解析的额外复杂度\n\n
\n\n\n" + "content": "# s07: Skill Loading — 用到的时候才加载\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/zh/s08) → s09 → ... → s20\n> *\"用到时再加载, 别全塞 prompt 里\"* — 通过 `tool_result` 注入, 不塞 system prompt。\n>\n> **Harness 层**: 知识 — 按需加载, 不堆满上下文。\n\n---\n\n## 问题\n\n上一章 Agent 能把大任务拆给子 Agent 了。但子 Agent 接手时,得知道这个任务的规矩:改 React 组件要守组件规范,写 SQL 要守风格指南。这些规范从哪来?\n\n你的项目里有一套 React 组件规范、一份 SQL 风格指南、一份 API 设计文档。最直接的想法,全塞进 system prompt:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read() # 2000 行\n + open(\"docs/sql-style.md\").read() # 1500 行\n + open(\"docs/api-design.md\").read() # 3000 行\n)\n```\n\n6500 行 system prompt。Agent 每次调用 LLM 都带着这些文档,不管在改 CSS 颜色还是修 SQL 查询。99% 的内容和当前任务无关,白白消耗 token。\n\n---\n\n## 解决方案\n\n![Skill Overview](/course-assets/s07_skill_loading/skill-overview.svg)\n\n一个折中的想法,是把文档拆成几个文件,用哪个让 Agent 自己 `read_file`。但 Agent 根本不知道有哪些文件可读,它得先知道\"有什么\",才谈得上\"用哪个\"。\n\n所以分两层:**目录常驻,内容按需。** 上一章的 hook 结构、`todo_write` 和子 Agent 都保留,本章新增一个 `load_skill` 工具。启动时把技能目录(名字 + 一句话描述)注入 SYSTEM prompt,每轮都带、但很轻;运行时 Agent 真要用某个技能,再调 `load_skill` 把完整内容取过来,用到才花那部分 token。\n\n两层设计:\n\n| 层 | 位置 | 时机 | 代价 |\n|---|------|------|------|\n| 1. 目录 | system prompt | 启动时注入(harness 扫描 skills/) | ~100 tokens/skill,每轮都带 |\n| 2. 内容 | tool_result | Agent 调用 load_skill 时;SKILL.md 可指引后续的 read_file/bash 调用,用于按需访问额外资源 | ~2000 tokens/skill,按需 |\n\ndispatch 机制不变,`load_skill` 通过 `TOOL_HANDLERS[block.name]` 分发。\n\n---\n\n## 工作原理\n\n**skills/ 目录**,每个技能一个子目录,包含 `SKILL.md` 文件:\n\n```\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n**第一级:启动时注入目录**:harness 启动时调用 `_scan_skills()` 扫描 skills/ 目录,解析每个 SKILL.md 的 YAML frontmatter(`name`、`description`),存入 `SKILL_REGISTRY` 字典。`list_skills()` 从注册表生成目录,注入 SYSTEM prompt。Agent 每轮都能看到\"我有哪些技能可用\",不花额外 API 调用:\n\n```python\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills() # runs once at startup\n\ndef list_skills() -> str:\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n```\n\n但 Agent 每轮只拿到名字和一句话描述,真要用 SQL 风格指南,那 1500 行的完整内容还够不到。→ 第二级。\n\n**第二级:load_skill**:Agent 决定\"我需要 SQL 风格指南\",调用 `load_skill(\"sql-style\")`。通过注册表查找,不走文件路径,没有路径遍历风险。SKILL.md 内容通过 `tool_result` 注入,并可通过现有的 file 和 bash 工具进一步访问引用的 `references/`、`scripts/` 或 `assets/`。\n\n```python\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n```\n\n关键区别:技能内容不是 system prompt 的一部分,它作为一次工具结果进入当前 `messages`。后续调用会随历史一起携带,直到上下文压缩、截断或会话结束。这和 s08 的 compact 自然衔接:技能内容以 `tool_result` 形式进入 `messages`,不塞 system prompt,compact 解决\"该丢的怎么丢\"。\n\n---\n\n## 相对 s06 的变更\n\n| 组件 | 之前 (s06) | 之后 (s07) |\n|------|-----------|-----------|\n| 工具数量 | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |\n| 知识加载 | 无 | 两级:启动时目录注入 SYSTEM + 运行时 load_skill;SKILL.md 可指引后续资源访问 |\n| SYSTEM 提示 | 静态字符串 | 启动时扫描 skills/ 注入目录 |\n| 技能注册表 | 无 | SKILL_REGISTRY(启动时填充,防路径遍历) |\n| 循环 | 不变 | 不变(skill 工具自动分发) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\n试试这些 prompt:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n\n观察重点:Agent 是否直接从 SYSTEM 里的目录知道有哪些技能?需要完整规范时是否出现 `[HOOK] load_skill`?加载后回答是否使用了对应 skill 的说明?\n\n---\n\n## 接下来\n\n`load_skill` 解决了启动时的 token 浪费。但另一个问题来了:Agent 连续工作 30 分钟后,`messages` 列表塞满了中间过程。旧的 `tool_result`、过时的文件内容,占着上下文但不产生价值。\n\ns08 Context Compact → 四层压缩策略。便宜的先跑,贵的后跑。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` 的分析。\n\n### 一、技能来源:不是只有一个 skills/ 目录\n\n教学版假设所有技能在 `skills/` 目录下。Claude Code 实际从多个来源加载,分布在多个文件中:`loadSkillsDir.ts` 负责从 user/project/`--add-dir` 目录和 legacy commands(`.claude/commands/`)加载;`bundledSkills.ts` 负责内置技能;`SkillTool.ts` 处理 MCP 远程技能;`commands.ts` 负责命令聚合。类型包括 managed/policy skills、user skills(`~/.claude/skills/`)、project skills(`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills(带 `paths` frontmatter,按文件路径激活)、bundled skills、plugin skills、MCP skills。\n\n### 二、SKILL.md Frontmatter 常见字段\n\nClaude Code 的 SKILL.md YAML frontmatter 由 `parseSkillFrontmatterFields()` 解析(`loadSkillsDir.ts`),常见字段包括:\n\n| 字段 | 用途 |\n|------|------|\n| `name` / `description` | 显示名称和描述 |\n| `when_to_use` | 指导模型何时调用 |\n| `allowed-tools` | 技能可用工具的自动允许列表 |\n| `context` | `inline`(默认)或 `fork`(作为子 Agent 运行) |\n| `model` | 模型覆盖(haiku/sonnet/opus/inherit) |\n| `hooks` | 技能级别的 hook 配置 |\n| `paths` | 条件激活的 glob 模式 |\n| `user-invocable` | 用户可以通过 `/name` 调用 |\n\n完整字段列表随版本迭代会变化,以上仅列出教学版涉及的核心字段。\n\n### 三、两级加载的精确实现\n\n1. **Catalog(启动时)**:`getSkillDirCommands()` 扫描目录 → 注册为 `Command` 对象,只包含元数据。`getSkillListingAttachments()` 把技能列表格式化为附件,预算为上下文窗口的 ~1%(上限 8000 字符)。\n2. **Load(调用时)**:模型调 `Skill` 工具(输入字段是 `skill` + 可选 `args`,教学版用 `name`)→ `getPromptForCommand()` 展开完整 SKILL.md 内容 → `SkillTool` 返回的 `tool_result` 展示文本只是 `\"Launching skill: {name}\"`,真正的技能内容通过 `newMessages` 注入对话。教学版把两者合并为\"通过 tool_result 注入\"是一种简化;加载后的 SKILL.md 仍可作为指引,帮助模型后续通过现有 file/bash 工具访问相关资源。\n\n### 教学版的简化是刻意的\n\n- 多文件多来源 → 1 个 `skills/` 目录:足以展示两级加载的核心概念\n- 多个 frontmatter 字段 → 只解析 name/description:减少解析复杂度\n- forked skills(`context: 'fork'`)→ 省略:教学版只展开 inline 技能加载\n- `Skill` 工具输入 `skill`+`args` → 教学版用 `name`:避免参数解析的额外复杂度\n\n
\n\n\n" }, { "version": "s07", "locale": "ja", "title": "s07: Skill Loading — 必要なときにだけ読み込む", - "content": "# s07: Skill Loading — 必要なときにだけ読み込む\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/ja/s08) → s09 → ... → s20\n> *\"Load when needed, don't stuff the prompt\"* — tool_result で注入、system prompt には詰め込まない。\n>\n> **Harness レイヤー**: 知識 — 必要に応じて読み込み、コンテキストに詰め込まない。\n\n---\n\n## 課題\n\nプロジェクトには React コンポーネント仕様、SQL スタイルガイド、API 設計ドキュメントがある。Agent にこれらの仕様を自動的に守らせたい。最も直接的な方法は、すべて system prompt に詰め込むこと:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read() # 2000 行\n + open(\"docs/sql-style.md\").read() # 1500 行\n + open(\"docs/api-design.md\").read() # 3000 行\n)\n```\n\n6500 行の system prompt。Agent は LLM を呼び出すたびにこれらのドキュメントを運ぶ。CSS の色を変えるときも SQL クエリを修正するときも同様だ。99% の内容が現在のタスクと無関係で、トークンを無駄に消費する。\n\n---\n\n## ソリューション\n\n![Skill Overview](/course-assets/s07_skill_loading/skill-overview.svg)\n\n前章の最小フック構造、`todo_write`、サブ Agent を維持し、本章は新規の `load_skill` ツールに注目する。起動時にスキルカタログを SYSTEM prompt に注入し、実行時に完全な内容を読み込むツールを登録する。使ったときだけトークンを消費。\n\n2 層設計:\n\n| 層 | 場所 | タイミング | コスト |\n|---|------|-----------|--------|\n| 1. カタログ | system prompt | 起動時に注入(harness が skills/ をスキャン) | ~100 トークン/スキル、毎ターン携帯 |\n| 2. 内容 | tool_result | Agent が load_skill を呼び出したとき。SKILL.md は、必要に応じて read_file/bash で追加リソースへアクセスするための手がかりになる | ~2000 トークン/スキル、オンデマンド |\n\nディスパッチ機構は変わらず、`load_skill` は `TOOL_HANDLERS[block.name]` を通じて自動的にディスパッチされる。\n\n---\n\n## 仕組み\n\n**skills/ ディレクトリ**、スキルごとに 1 つのサブディレクトリ、それぞれに `SKILL.md` ファイルを含む:\n\n```\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n**第 1 層:起動時にカタログを注入**:harness は起動時に `_scan_skills()` を呼び出して skills/ ディレクトリをスキャンし、各 SKILL.md の YAML frontmatter(`name`、`description`)を解析して `SKILL_REGISTRY` 辞書に格納する。`list_skills()` はレジストリからカタログを生成し、SYSTEM prompt に注入する。Agent は毎ターン「どのスキルが利用可能か」を確認できる。追加の API 呼び出しは不要:\n\n```python\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills() # runs once at startup\n\ndef list_skills() -> str:\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n```\n\n**第 2 層:load_skill**:Agent が「SQL スタイルガイドが必要」と判断し、`load_skill(\"sql-style\")` を呼び出す。レジストリを通じて検索し、ファイルパスを経由しないため、パストラバーサルのリスクがない。SKILL.md の内容は `tool_result` を通じて注入され、既存の file および bash ツールを通じて、参照される `references/`、`scripts/`、`assets/` へのその後のアクセスも含められる。\n\n```python\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n```\n\n重要な違い:スキル内容は system prompt の一部ではなく、ツール結果として現在の messages に入る。後続の呼び出しでは履歴とともに携帯され、コンテキスト圧縮、切り捨て、またはセッション終了まで保持される。これは s08 の compact と自然に接続する:オンデマンド読み込みにより、無関係なドキュメントが system prompt に入らなくなる。compact が「捨てるべきものをどう捨てるか」を解決する。\n\n---\n\n## s06 からの変更点\n\n| コンポーネント | 変更前 (s06) | 変更後 (s07) |\n|---------------|-------------|-------------|\n| ツール数 | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |\n| 知識読み込み | なし | 2 層:起動時カタログ注入 SYSTEM + 実行時 load_skill。SKILL.md がその後のリソースアクセスを案内できる |\n| SYSTEM プロンプト | 静的文字列 | 起動時に skills/ をスキャンしてカタログ注入 |\n| スキルレジストリ | なし | SKILL_REGISTRY(起動時に充填、パストラバーサル防止) |\n| ループ | 変更なし | 変更なし(スキルツールは自動ディスパッチ) |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n\n観察のポイント:Agent は SYSTEM 内のカタログから利用可能なスキルを知っているか? 完全な手順が必要なときに `[HOOK] load_skill` が表示されるか? 読み込んだスキルの説明を使って回答しているか?\n\n---\n\n## 次へ\n\nload_skill で起動時のトークン浪費は解消した。しかし別の問題が待っている:Agent が 30 分連続で作業すると、messages リストが中間プロセスで埋め尽くされる。古い tool_result、期限切れのファイル内容、コンテキストを占領しているが価値を生まない。\n\n→ s08 Context Compact:4 層圧縮戦略。安価な層を先に実行、高価な層を後に実行。\n\n
\nClaude Code ソースコードを深掘り\n\n> 以下は Claude Code ソースコード `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` の分析に基づく。\n\n### 一、スキルソース:skills/ ディレクトリだけではない\n\n教育版はすべてのスキルが `skills/` ディレクトリにあると想定している。Claude Code は実際に複数のファイルに分散したソースから読み込む:`loadSkillsDir.ts` は user/project/`--add-dir` ディレクトリと legacy commands(`.claude/commands/`)を担当、`bundledSkills.ts` は組み込みスキル、`SkillTool.ts` は MCP リモートスキル、`commands.ts` はコマンド集約を担当。タイプには managed/policy skills、user skills(`~/.claude/skills/`)、project skills(`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills(`paths` frontmatter を持ち、ファイルパスでアクティベート)、bundled skills、plugin skills、MCP skills が含まれる。\n\n### 二、SKILL.md Frontmatter の一般的なフィールド\n\nClaude Code の SKILL.md YAML frontmatter は `parseSkillFrontmatterFields()`(`loadSkillsDir.ts`)で解析される。一般的なフィールド:\n\n| フィールド | 用途 |\n|-----------|------|\n| `name` / `description` | 表示名と説明 |\n| `when_to_use` | モデルにいつ呼び出すかを指導 |\n| `allowed-tools` | スキルが使用可能なツールの自動許可リスト |\n| `context` | `inline`(デフォルト)または `fork`(サブ Agent として実行) |\n| `model` | モデルオーバーライド(haiku/sonnet/opus/inherit) |\n| `hooks` | スキルレベルのフック設定 |\n| `paths` | 条件付きアクティベーションの glob パターン |\n| `user-invocable` | ユーザーが `/name` で呼び出し可能 |\n\n完全なフィールドリストはバージョンによって変動する。上記は教育版に関連するコアフィールドのみ。\n\n### 三、2 層読み込みの正確な実装\n\n1. **カタログ(起動時)**:`getSkillDirCommands()` がディレクトリをスキャン → メタデータのみを含む `Command` オブジェクトとして登録。`getSkillListingAttachments()` がスキルリストを添付ファイルとしてフォーマット、コンテキストウィンドウの ~1% を予算とする(上限 8000 文字)。\n2. **読み込み(呼び出し時)**:モデルが `Skill` ツールを呼び出す(入力フィールドは `skill` + オプションの `args`、教育版は `name` を使用)→ `getPromptForCommand()` が完全な SKILL.md 内容を展開 → `SkillTool` が返す tool_result の表示テキストは `\"Launching skill: {name}\"` のみ、実際のスキル内容は `newMessages` を通じて注入される。教育版では両者を「tool_result を通じて注入」として簡略化している。読み込まれた SKILL.md は、モデルが後続で既存の file/bash ツールから関連リソースへアクセスする際の手がかりにもなる。\n\n### 教育版の単純化は意図的\n\n- 複数ファイル・複数ソース → 1 つの `skills/` ディレクトリ:2 層読み込みの核心概念を示すのに十分\n- 複数の frontmatter フィールド → name/description のみ解析:解析の複雑さを削減\n- forked skills(`context: 'fork'`)→ 省略:教育版では inline skill loading のみ展開する\n- `Skill` ツールの入力 `skill`+`args` → 教育版は `name` を使用:追加の引数解析の複雑さを回避\n\n
\n\n\n" + "content": "# s07: Skill Loading — 必要なときにだけ読み込む\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/ja/s08) → s09 → ... → s20\n> *\"Load when needed, don't stuff the prompt\"* — `tool_result` で注入、system prompt には詰め込まない。\n>\n> **Harness レイヤー**: 知識 — 必要に応じて読み込み、コンテキストに詰め込まない。\n\n---\n\n## 課題\n\n前章では Agent が大きなタスクをサブ Agent に渡せるようになった。だがサブ Agent が引き継ぐとき、そのタスクの決まりを知る必要がある:React コンポーネントを変更するなら component spec を、SQL を書くなら style guide を守る。その決まりはどこから来るのか?\n\nプロジェクトには React コンポーネント仕様、SQL スタイルガイド、API 設計ドキュメントがある。最も直接的な方法は、すべて system prompt に詰め込むこと:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read() # 2000 行\n + open(\"docs/sql-style.md\").read() # 1500 行\n + open(\"docs/api-design.md\").read() # 3000 行\n)\n```\n\n6500 行の system prompt。Agent は LLM を呼び出すたびにこれらのドキュメントを運ぶ。CSS の色を変えるときも SQL クエリを修正するときも同様だ。99% の内容が現在のタスクと無関係で、トークンを無駄に消費する。\n\n---\n\n## ソリューション\n\n![Skill Overview](/course-assets/s07_skill_loading/skill-overview.svg)\n\n折衷案は、ドキュメントを複数のファイルに分け、必要なものを Agent 自身に `read_file` させることだ。だが Agent はそもそもどんなファイルが読めるか分からない。「何があるか」を先に知らなければ、「どれを使うか」は選べない。\n\nそこで 2 層に分ける:**カタログは常駐、内容はオンデマンド。** 前章のフック構造、`todo_write`、サブ Agent はそのまま残し、本章で `load_skill` ツールを 1 つ加える。起動時にスキルのカタログ(名前 + 一言の説明)を SYSTEM prompt に入れる。毎ターン携帯するが軽い。実行時に Agent が実際にあるスキルを使うとき、`load_skill` を呼んで完全な内容を取り出す。トークンを使うのはそのときだけだ。\n\n2 層設計:\n\n| 層 | 場所 | タイミング | コスト |\n|---|------|-----------|--------|\n| 1. カタログ | system prompt | 起動時に注入(harness が skills/ をスキャン) | ~100 トークン/スキル、毎ターン携帯 |\n| 2. 内容 | tool_result | Agent が load_skill を呼び出したとき。SKILL.md は、必要に応じて read_file/bash で追加リソースへアクセスするための手がかりになる | ~2000 トークン/スキル、オンデマンド |\n\nディスパッチ機構は変わらず、`load_skill` は `TOOL_HANDLERS[block.name]` を通じて自動的にディスパッチされる。\n\n---\n\n## 仕組み\n\n**skills/ ディレクトリ**、スキルごとに 1 つのサブディレクトリ、それぞれに `SKILL.md` ファイルを含む:\n\n```\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n**第 1 層:起動時にカタログを注入**:harness は起動時に `_scan_skills()` を呼び出して skills/ ディレクトリをスキャンし、各 SKILL.md の YAML frontmatter(`name`、`description`)を解析して `SKILL_REGISTRY` 辞書に格納する。`list_skills()` はレジストリからカタログを生成し、SYSTEM prompt に注入する。Agent は毎ターン「どのスキルが利用可能か」を確認できる。追加の API 呼び出しは不要:\n\n```python\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills() # runs once at startup\n\ndef list_skills() -> str:\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n```\n\nだが Agent は毎ターン、名前と一言の説明しか受け取らない。実際に SQL スタイルガイドを使うとなると、あの 1500 行の完全な内容にはまだ手が届かない。→ 第 2 層。\n\n**第 2 層:load_skill**:Agent が「SQL スタイルガイドが必要」と判断し、`load_skill(\"sql-style\")` を呼び出す。レジストリを通じて検索し、ファイルパスを経由しないため、パストラバーサルのリスクがない。SKILL.md の内容は `tool_result` を通じて注入され、既存の file および bash ツールを通じて、参照される `references/`、`scripts/`、`assets/` へのその後のアクセスも含められる。\n\n```python\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n```\n\n重要な違い:スキル内容は system prompt の一部ではなく、ツール結果として現在の `messages` に入る。後続の呼び出しでは履歴とともに携帯され、コンテキスト圧縮、切り捨て、またはセッション終了まで保持される。これは s08 の compact と自然に接続する:スキル内容は system prompt ではなく `tool_result` として `messages` に入り、compact が「捨てるべきものをどう捨てるか」を解決する。\n\n---\n\n## s06 からの変更点\n\n| コンポーネント | 変更前 (s06) | 変更後 (s07) |\n|---------------|-------------|-------------|\n| ツール数 | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |\n| 知識読み込み | なし | 2 層:起動時カタログ注入 SYSTEM + 実行時 load_skill。SKILL.md がその後のリソースアクセスを案内できる |\n| SYSTEM プロンプト | 静的文字列 | 起動時に skills/ をスキャンしてカタログ注入 |\n| スキルレジストリ | なし | SKILL_REGISTRY(起動時に充填、パストラバーサル防止) |\n| ループ | 変更なし | 変更なし(スキルツールは自動ディスパッチ) |\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\n以下のプロンプトを試してみよう:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n\n観察のポイント:Agent は SYSTEM 内のカタログから利用可能なスキルを知っているか? 完全な手順が必要なときに `[HOOK] load_skill` が表示されるか? 読み込んだスキルの説明を使って回答しているか?\n\n---\n\n## 次へ\n\nload_skill で起動時のトークン浪費は解消した。しかし別の問題が待っている:Agent が 30 分連続で作業すると、`messages` リストが中間プロセスで埋め尽くされる。古い `tool_result`、期限切れのファイル内容、コンテキストを占領しているが価値を生まない。\n\n→ s08 Context Compact:4 層圧縮戦略。安価な層を先に実行、高価な層を後に実行。\n\n
\nClaude Code ソースコードを深掘り\n\n> 以下は Claude Code ソースコード `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` の分析に基づく。\n\n### 一、スキルソース:skills/ ディレクトリだけではない\n\n教育版はすべてのスキルが `skills/` ディレクトリにあると想定している。Claude Code は実際に複数のファイルに分散したソースから読み込む:`loadSkillsDir.ts` は user/project/`--add-dir` ディレクトリと legacy commands(`.claude/commands/`)を担当、`bundledSkills.ts` は組み込みスキル、`SkillTool.ts` は MCP リモートスキル、`commands.ts` はコマンド集約を担当。タイプには managed/policy skills、user skills(`~/.claude/skills/`)、project skills(`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills(`paths` frontmatter を持ち、ファイルパスでアクティベート)、bundled skills、plugin skills、MCP skills が含まれる。\n\n### 二、SKILL.md Frontmatter の一般的なフィールド\n\nClaude Code の SKILL.md YAML frontmatter は `parseSkillFrontmatterFields()`(`loadSkillsDir.ts`)で解析される。一般的なフィールド:\n\n| フィールド | 用途 |\n|-----------|------|\n| `name` / `description` | 表示名と説明 |\n| `when_to_use` | モデルにいつ呼び出すかを指導 |\n| `allowed-tools` | スキルが使用可能なツールの自動許可リスト |\n| `context` | `inline`(デフォルト)または `fork`(サブ Agent として実行) |\n| `model` | モデルオーバーライド(haiku/sonnet/opus/inherit) |\n| `hooks` | スキルレベルのフック設定 |\n| `paths` | 条件付きアクティベーションの glob パターン |\n| `user-invocable` | ユーザーが `/name` で呼び出し可能 |\n\n完全なフィールドリストはバージョンによって変動する。上記は教育版に関連するコアフィールドのみ。\n\n### 三、2 層読み込みの正確な実装\n\n1. **カタログ(起動時)**:`getSkillDirCommands()` がディレクトリをスキャン → メタデータのみを含む `Command` オブジェクトとして登録。`getSkillListingAttachments()` がスキルリストを添付ファイルとしてフォーマット、コンテキストウィンドウの ~1% を予算とする(上限 8000 文字)。\n2. **読み込み(呼び出し時)**:モデルが `Skill` ツールを呼び出す(入力フィールドは `skill` + オプションの `args`、教育版は `name` を使用)→ `getPromptForCommand()` が完全な SKILL.md 内容を展開 → `SkillTool` が返す tool_result の表示テキストは `\"Launching skill: {name}\"` のみ、実際のスキル内容は `newMessages` を通じて注入される。教育版では両者を「tool_result を通じて注入」として簡略化している。読み込まれた SKILL.md は、モデルが後続で既存の file/bash ツールから関連リソースへアクセスする際の手がかりにもなる。\n\n### 教育版の単純化は意図的\n\n- 複数ファイル・複数ソース → 1 つの `skills/` ディレクトリ:2 層読み込みの核心概念を示すのに十分\n- 複数の frontmatter フィールド → name/description のみ解析:解析の複雑さを削減\n- forked skills(`context: 'fork'`)→ 省略:教育版では inline skill loading のみ展開する\n- `Skill` ツールの入力 `skill`+`args` → 教育版は `name` を使用:追加の引数解析の複雑さを回避\n\n
\n\n\n" }, { "version": "s08", From baec693845972946e4f4142bd872fddb219a6057 Mon Sep 17 00:00:00 2001 From: Haoran Date: Fri, 3 Jul 2026 16:19:28 +0800 Subject: [PATCH 5/5] Rewrite s08 in execution-order style (zh/en/ja, code, diagrams) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chapter taught the compaction pipeline as layers L1-L4 while the code executes budget -> snip -> micro -> summarize, so readers had to remap the numbering on every cross-reference, and the header quoted the chapter's own title back as a fake epigraph. The chapter is rewritten around execution order as Step 1-4 in a plain one-idea-per-paragraph style: the title becomes 先整理,再总结 (tidy first, summarize last), the epigraph block is gone, every snippet uses the real function names and strings from code.py, and the source-code appendix is dropped. code.py extracts the snip boundary logic into safe_head/safe_tail and reuses safe_tail in reactive_compact, fuzz-verified identical to the old implementation; its section comments follow the step numbering. The five diagrams are renumbered to the same step order, camelCase labels become snake_case, and three captions that described real Claude Code behavior instead of the teaching version are corrected. English and Japanese are retranslated from the new Chinese text; sync markers move to v5; web docs regenerated. Co-Authored-By: Claude Fable 5 --- s08_context_compact/README.ja.md | 387 ++++++++--------- s08_context_compact/README.md | 389 +++++++++--------- s08_context_compact/README.zh.md | 387 ++++++++--------- s08_context_compact/code.py | 91 ++-- s08_context_compact/images/auto-compact.svg | 22 +- .../images/compact-overview.svg | 8 +- .../images/compaction-layers.svg | 32 +- s08_context_compact/images/layer1-budget.svg | 2 +- s08_context_compact/images/micro-compact.svg | 10 +- .../s08_context_compact/auto-compact.svg | 22 +- .../s08_context_compact/compact-overview.svg | 8 +- .../s08_context_compact/compaction-layers.svg | 32 +- .../s08_context_compact/layer1-budget.svg | 2 +- .../s08_context_compact/micro-compact.svg | 10 +- web/src/data/generated/docs.json | 14 +- web/src/data/generated/versions.json | 46 ++- 16 files changed, 741 insertions(+), 721 deletions(-) diff --git a/s08_context_compact/README.ja.md b/s08_context_compact/README.ja.md index 73229c4de..e6395d81f 100644 --- a/s08_context_compact/README.ja.md +++ b/s08_context_compact/README.ja.md @@ -1,305 +1,306 @@ -# s08: Context Compact — コンテキストはいつか満杯になる、場所を空ける方法が必要 +# s08: Context Compact — コンテキストはいつか満杯になる、まず整理、それから要約 [中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 -> *"Context will fill up — have a way to make room"* — 4層圧縮戦略、安価なものを先に、高価なものを後に実行。 -> -> **Harness レイヤー**: 圧縮 — コンテキスト超過時に自動要約し、セッションを持続可能に保つ。 --- -## 課題 +長いタスクでは、ファイルを 1 つ読むだけで数千 token、テストを 1 回走らせればログがまた大量に出る。ファイル内容、コマンド出力、ツール結果はすべて `messages` に戻され、どんどん積み上がっていく。 -前章で Agent に Skills を加えた。少し「領域経験」を持ち始め、PDF や MCP、コードレビューに出くわすと、対応する操作説明を先に読み込んでから動くようになった。 +コンテキストが増えるほどモデルの注意は散漫になり、本当に満杯になった時点でリクエストはそのまま失敗する:`prompt_too_long`。 -だが Agent が仕事をこなせるほど、別の問題が目立ってくる。1000 行のファイルを 1 つ読めば ~4000 token、さらに 30 個のファイルを読み、20 個のコマンドを走らせる。コマンドの出力もファイルの内容も、すべて `messages` リストに戻され、少しずつ積み上がる。 +だから s08 が解決するのは一つ: -普通のチャットなら数十ターンは何でもない。コードエージェントは違う。一度の読み取りが数千行、一度のテストが大量のログだ。タスクが終わらないうちに、コンテキストウィンドウが先に満杯になりかねない。 +> 長いタスクでも Agent が働き続けられるようにする。 -満杯になると、問題は「モデルの答えが少し悪い」ではない。API がリクエストを直接拒否する:`prompt_too_long`。圧縮しなければ、Agent は大きなプロジェクトでまともに動けない。 +![Context Compact 全体像](images/compact-overview.svg) --- -## ソリューション +## いきなり履歴を要約しない + +最も直感的なのは、モデルに履歴を要約させることだ。 -![Compact Overview](images/compact-overview.svg) +だが、それを最初の一手にしてはいけない。 -s07 の hook 構造、skill ロード、サブエージェントの骨格は残し、この章では一層だけ加える。LLM を呼ぶ前に、まず `messages` を整える。 +要約する必要のない内容は多い。古いログ、古いファイル内容、すでに役目を終えたツール結果。これらは場所を取っているだけで、もう重要とは限らない。こうした内容にはまず整理で対応する:ディスクに退避できるものは退避し、プレースホルダで置き換えられるものは置き換え、切り詰められるものは切り詰める。 -いちばん素直な発想は、満杯になったらモデルに要約させることだ。だがここには 2 つの問題がある。1 つ目、要約は API 呼び出しを 1 回余分に使う。コンテキストが大きくなるたびに要約していては、コストがすぐ膨らむ。2 つ目、すべての内容が要約に値するわけではない。古いツール結果の多くはとっくに不要だし、ただ大きいだけの内容もある。たとえば `cat` が数百 KB のログを吐いた場合、それは「理解」される必要はなく、コンテキストから外して必要なときに読み直せばいい。 +これらをすべてやってもまだ上限に近いときに、はじめてモデルに要約を生成させる。 -だから compact は 1 つの動作ではなく、1 本のパイプラインだ。**安いものを先に、高いものを後に**。まずモデルを呼ばないローカルな整理を数ステップ走らせる。切れるものは切り、プレースホルダにできるものは置き換え、ディスクに退避できるものは退避する。それでも足りないときに初めて、LLM に本当の要約をさせる。 +理由は単純だ。前の 3 ステップはほぼ復元可能だが、要約は不可逆。要約が履歴を置き換えた瞬間、細部は現在のコンテキストから消える。 --- -## 仕組み +## 全体の流れ + +モデルを呼び出す前に、毎回 `messages` を一度整理する: + +```python +messages = tool_result_budget(messages) # 大きな結果を先に退避 +messages = snip_compact(messages) # 中間の古い対話を切り詰め +messages = micro_compact(messages) # 古いツール結果をプレースホルダ化 + +if estimate_size(messages) > CONTEXT_LIMIT: + messages = compact_history(messages) # それでも足りなければ要約 +``` + +![4 ステップ圧縮パイプライン](images/compaction-layers.svg) + +> この順序は入れ替えられない。 +> +> 特に `tool_result_budget` は `micro_compact` より先に走らなければならない。`micro_compact` は古いツール結果をプレースホルダに置き換えるので、先に走ると完全な内容が手に入らなくなり、大きな結果を退避できなくなる。 + +--- + +## Step 1:tool_result_budget — 大きな結果を先に退避する + +問題は履歴の長さではなく、1 件のツール結果が大きすぎることもある。 + +たとえば Agent が大きなファイルを何個か一気に読むと、最後の `tool_result` は 200KB を超えうる。最新の結果だから単純に捨てるわけにはいかない。だが、全文をコンテキストに置いておくべきでもない。 + +やり方:全文をディスクに書き出し、コンテキストにはパスと短いプレビューだけを残す。 + +![大きな結果をディスクへ退避](images/layer1-budget.svg) + +```python +def tool_result_budget(messages, max_bytes=200_000): + blocks = [b for b in messages[-1]["content"] if b.get("type") == "tool_result"] + total = sum(len(str(b["content"])) for b in blocks) + + if total <= max_bytes: + return messages -![4層圧縮パイプライン](images/compaction-layers.svg) + for block in sorted(blocks, key=lambda b: len(str(b["content"])), reverse=True): + block["content"] = persist_large_output(block["tool_use_id"], str(block["content"])) + total = sum(len(str(b["content"])) for b in blocks) + if total <= max_bytes: + break -### L1: snip_compact — 無関係な古い会話を切り捨て + return messages +``` + +このステップは内容を失わない。「現在のコンテキスト」からディスクへ移すだけだ。 + +モデルには、その内容がどこに保存されたか、冒頭がどんな様子かは見えている。後で全文が必要になったら読み戻せばいい。 + +--- -Agent が 80 ターン走り、`messages` は 160 件たまった。先頭の「hello.py を作って」は今の作業とほぼ無関係になっているが、まだ場所を占めている。 +## Step 2:snip_compact — 古い対話を切り詰める -メッセージ数が 50 を超えたら → 先頭 3 件(最初のタスクと制約)と末尾 47 件(今の作業)を残し、中間を切る。気をつける境界は 1 つだけ:`assistant(tool_use)` とその後ろの `user(tool_result)` を切り離してはいけない。さもないとモデルは、どの呼び出しに対応するか分からない孤立した結果を見ることになる。 +メッセージが多すぎるときは、先頭と末尾を残せばいい。 + +先頭にはたいてい元のタスクと制約があり、末尾は今やっている作業だ。中間の古い履歴は、一行の説明に置き換えられる。 ```python def snip_compact(messages, max_messages=50): - if len(messages) <= max_messages: return messages - keep_head, keep_tail = 3, max_messages - 3 - head_end, tail_start = keep_head, len(messages) - keep_tail - if head_end > 0 and _message_has_tool_use(messages[head_end - 1]): - while head_end < len(messages) and _is_tool_result_message(messages[head_end]): - head_end += 1 - if (tail_start > 0 and tail_start < len(messages) - and _is_tool_result_message(messages[tail_start]) - and _message_has_tool_use(messages[tail_start - 1])): - tail_start -= 1 - if head_end >= tail_start: return messages - snipped = tail_start - head_end - return messages[:head_end] + [{"role": "user", "content": f"[snipped {snipped} messages]"}] + messages[tail_start:] + if len(messages) <= max_messages: + return messages + + head = safe_head(messages, 3) + tail = safe_tail(messages, max_messages - 3) + snipped = len(messages) - len(head) - len(tail) + + return head + [ + {"role": "user", "content": f"[snipped {snipped} messages]"} + ] + tail ``` -切るのはメッセージそのもので、切れ目に保護を 1 つ入れるだけだ。だが残ったメッセージの中では、`tool_result` の内容がまだ積み上がっている。34 番目のメッセージには 30KB の古いファイル内容が眠っているかもしれない。メッセージ数は減っても、token は減っていない。→ L2。 +注意点が一つ:`assistant` の `tool_use` と対応する `tool_result` を切り離してはいけない。切り離すと、モデルには出所不明のツール結果が見え、API はリクエストをそのまま拒否する。 -### L2: micro_compact — 古いツール結果をプレースホルダに置換 +だから `safe_head` と `safe_tail` は単純なスライスではない。こうした断点を避けて切る(実装は `code.py`)。 -![古い結果のプレースホルダ化](images/micro-compact.svg) +このステップが減らすのはメッセージの数だ。 -コンテキストを膨らませる最大の原因は、会話そのものよりツール結果であることが多い。Agent が 10 個のファイルを続けて読んだとして、1 番目から 7 番目までの完全な内容はとっくに不要なのに、そのままコンテキストに居座っている。 +だが 1 件のメッセージ内の大きな内容は扱えない。古い `tool_result` に数十 KB のファイル内容が残っていれば、それはコンテキストを占め続ける。 -直近 3 件の `tool_result` の完全な内容を残し、それより古いものは 1 行のプレースホルダに置き換える。発想は素朴だ。古い結果が本当に要るなら、モデルがもう一度読めばいい。ずっと場所を占めるべきではない。 +だからツール結果の整理を続ける。 + +--- + +## Step 3:micro_compact — 古いツール結果をプレースホルダに置き換える + +コンテキストを膨らませるのは、対話そのものよりツール結果であることが多い。 + +Agent がファイルを 10 個続けて読んだとき、最初の数個の全文をコンテキストに置き続ける必要はまずない。直近の数件を残せば足りる。より古い結果は、本当に必要になったら再取得すればいい。 + +![古い結果をプレースホルダ化](images/micro-compact.svg) ```python KEEP_RECENT = 3 def micro_compact(messages): - tool_results = collect_tool_results(messages) - if len(tool_results) <= KEEP_RECENT: return messages - for _, _, block in tool_results[:-KEEP_RECENT]: + results = collect_tool_results(messages) + + for _, _, block in results[:-KEEP_RECENT]: if len(block.get("content", "")) > 120: block["content"] = "[Earlier tool result compacted. Re-run if needed.]" + return messages ``` -古い結果は片付いたが、まだ防げないケースがある。1 件の新しい結果だけで 500KB になることだ。大きなファイルを `cat` した出力 1 つでコンテキストを使い切ってしまう。しかもそれは新しすぎて、micro_compact は手を付けない。→ L3。 +このステップは内容を要約しない。古い全文を一行の説明に置き換えるだけだ。 -### L3: tool_result_budget — 大きな結果をディスクに退避 +「ツール結果が多い」場合には効くが、「整理してもまだ大きい」場合には効かない。ここまでやってなお超過しているなら、残る手はモデルによる要約だけだ。 -![大きな結果のディスク退避](images/layer1-budget.svg) +--- -結果には「多い」ではなく「1 件が大きすぎる」ものもある。モデルが大きなファイルを一度に 5 つ読み、最後の user メッセージ内の `tool_result` が合計 200KB を超える。こうなると直近 3 件を残しても無駄だ。最新の 1 件だけでコンテキストを使い切れるからだ。 +## Step 4:compact_history — 整理しても超過するなら、要約する -ツール結果に予算を設ける。最後の user メッセージ内のすべての `tool_result` の合計サイズを数え、200KB を超えたら大きいものから `.task_outputs/tool-results/` に退避し、コンテキストには `` マーカーと先頭 2000 文字のプレビューだけを残す。モデルはマーカーを見れば完全な内容がディスク上にあると分かり、必要なときに読み戻せる。 +前の 3 ステップを終えてもコンテキストが大きすぎるなら、モデルに履歴を要約させる。 -```python -def tool_result_budget(messages, max_bytes=200_000): - last = messages[-1] if messages else None - if not last or last.get("role") != "user" or not isinstance(last.get("content"), list): return messages - blocks = [(i, b) for i, b in enumerate(last["content"]) - if isinstance(b, dict) and b.get("type") == "tool_result"] - total = sum(len(str(b.get("content", ""))) for _, b in blocks) - if total <= max_bytes: return messages - ranked = sorted(blocks, key=lambda p: len(str(p[1].get("content", ""))), reverse=True) - for _, block in ranked: - if total <= max_bytes: break - block["content"] = persist_large_output(block.get("tool_use_id", "unknown"), str(block.get("content", ""))) - total = sum(len(str(b.get("content", ""))) for _, b in blocks) - return messages -``` - -ここで大事なのは捨てることではない。内容を「アクティブなコンテキスト」から「復元可能な外部ストレージ」へ移すことだ。これで最初の 3 層がそろう:純粋なテキスト/構造の操作、API 呼び出し 0、それぞれが 1 種類の冗長さを見張る。だが共通の限界がある:会話が何の話か読めない。どの発見が重要か、どの制約を残すべきか分からない。それでもコンテキストが大きすぎるなら、モデルに出てもらうしかない。→ L4。 +やることは三つ: -### L4: compact_history — LLM 全量要約 +まず完全な対話をディスクに書き出す。 +次にモデルに要約を生成させる。 +最後に要約で古い履歴を置き換える。 ![LLM 全量要約](images/auto-compact.svg) -3 層を走らせ切っても、token はまだ閾値を超える。この一手こそ、多くの人が直感的に思い描く「コンテキスト圧縮」だ。履歴をモデルに渡し、より短い状態に要約させる。 - -3 ステップ。まず完全な会話を `.transcripts/`(JSONL)に書き出す。アクティブなコンテキストには要約だけが残るが、ディスクには完全な記録が残る。次に LLM に要約を生成させ、現在の目標、重要な発見、変更済みのファイル、残りの作業、ユーザーの制約を保持するよう求める。最後にこの 1 件の要約で古いメッセージをすべて置き換える。 - ```python def compact_history(messages): - transcript_path = write_transcript(messages) # 先に完全な会話を保存 - summary = summarize_history(messages) # LLM が要約を生成 - return [{"role": "user", "content": f"[Compacted]\n\n{summary}"}] + transcript_path = write_transcript(messages) # ① 完全な対話をディスクへ + summary = summarize_history(messages) # ② 要約を生成 + return [{ + "role": "user", + "content": f"[Compacted]\n\n{summary}", # ③ 要約で古い履歴を置換 + }] ``` -この一手はロッシー(不可逆)だ:transcript には完全な履歴があるが、モデルは今やその細部を見られず、要約だけを頼りに続ける。だから先に L1/L2/L3 を走らせる:モデルに要約させずに済むなら済ませる。いったん要約に入れば、細部は取り返しなく失われるからだ。教学版にはサーキットブレーカーも加えてある:compact が 3 回連続で失敗したら止め、API 呼び出しを無限に浪費しない。 +要約には 5 種類の情報を残すよう求める:現在の目標、ユーザーの制約、重要な発見、変更したファイル、次の作業。 + +このステップは最も効果的で、最もリスクが高い。 -### 緊急: reactive_compact +完全な履歴はディスクに残っているが、モデルに今見えるのは要約だけだ。要約に書かれなかった細部は、以降のすべてのターンにとって、当面見えないのと同じになる。 -通常は、モデルを呼ぶ前にコンテキストを整えておく。だがコンテキストの増加が速すぎたり、token の見積もりがずれたりすると、API はやはり `prompt_too_long` を返しうる。 +だから要約は必ず最後に置く。 -このとき reactive_compact に入る。compact_history によく似ているが、より激しい:先に transcript を保存し、前半の大部分を要約し、末尾 5 件だけを末尾コンテキストとして残す(こちらも孤立した `tool_result` を残さないようにする)。 +--- + +## エラー後の応急整理 + +通常は、モデルを呼び出す前にコンテキストを整理し終えている。 + +だが token の見積もりが外れることも、あるターンのツール出力が突然膨らむこともあり、API は依然として `prompt_too_long` を返しうる。そのときはもう一段激しい整理をする:完全な記録を保存し、前方の大部分を要約に潰し、最後の数件だけを残す。 ```python def reactive_compact(messages): - transcript = write_transcript(messages) - tail_start = max(0, len(messages) - 5) - if (tail_start > 0 and tail_start < len(messages) - and _is_tool_result_message(messages[tail_start]) - and _message_has_tool_use(messages[tail_start - 1])): - tail_start -= 1 - summary = summarize_history(messages[:tail_start]) - return [{"role": "user", "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]] + write_transcript(messages) + tail = safe_tail(messages, 5) # 末尾スライス、同じく断点を回避 + summary = summarize_history(messages[:len(messages) - len(tail)]) + + return [{ + "role": "user", + "content": f"[Reactive compact]\n\n{summary}", + }] + tail ``` -reactive は通常パスではなくフォールバックだ。デフォルトでは 1 回だけリトライし、もう一度失敗したら無限ループせず例外を投げる。完全なエラー回復ロジックは s11 に譲る。 +これは通常経路ではない。 -### 合わせて実行 +すでにエラーが起きたときだけ使い、リトライ回数も限られる(教学版は 1 回)。さもないと、要約自体が失敗したときに無限リトライに陥りかねない。 + +--- -これらを Agent Loop に戻して組み込む。各ラウンドで LLM を呼ぶ前に 3 層のローカル整理を走らせ、足りなければ要約し、呼び出しが実際にエラーになったら緊急パスに回る。 +## Agent Loop に組み戻す + +整理ロジックは最終的に Agent Loop へ戻す。 ```python def agent_loop(messages): reactive_retries = 0 while True: - # 3 つの前処理(API 呼び出し 0)、順序:budget -> snip -> micro - messages[:] = tool_result_budget(messages) # L3: 大きな結果を退避 - messages[:] = snip_compact(messages) # L1: 中間を切る - messages[:] = micro_compact(messages) # L2: 古い結果をプレースホルダ化 + messages[:] = tool_result_budget(messages) + messages[:] = snip_compact(messages) + messages[:] = micro_compact(messages) - if estimate_size(messages) > CONTEXT_LIMIT: # まだ足りない -> LLM 要約(API 1 回) + if estimate_size(messages) > CONTEXT_LIMIT: messages[:] = compact_history(messages) try: - response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000) + response = client.messages.create( + model=MODEL, system=SYSTEM, + messages=messages, tools=TOOLS, max_tokens=8000) except Exception as e: if "prompt_too_long" in str(e).lower() and reactive_retries < MAX_REACTIVE_RETRIES: - messages[:] = reactive_compact(messages) # 緊急 + messages[:] = reactive_compact(messages) reactive_retries += 1 continue raise - # ... ツール実行 ... -``` - -**順序は変えられない。** L3(budget)は L2(micro)より前でなければならない:micro は古い大きな `tool_result` を 1 行のプレースホルダに置き換えるので、もし先に走れば budget が完全な内容を退避する機会を失う。先に budget で大きな内容を保存し、それからプレースホルダ化と切り捨てを行う。これが Claude Code のソースが `applyToolResultBudget` を最前に置く理由でもある。 - -### compact ツール — モデルからも要求できる - -自動圧縮のほかに、モデル自身が整理を要求することもできる。コンテキストが長すぎる、あるいはタスクの段階が切り替わったと感じたとき、モデルは能動的に `compact` ツールを呼べる。教学版ではこのツールが `compact_history` をトリガーし、現在の turn を終え、圧縮後のコンテキストで新たな 1 ラウンドを始める。手動の `/compact` によく似ているが、違いは今回モデル自身が整理どきだと気づいた点だ。 - ---- - -## s07 からの変更点 - -| コンポーネント | 変更前 (s07) | 変更後 (s08) | -|------|-----------|-----------| -| コンテキスト管理 | なし(無制限に膨張) | 4 層圧縮パイプライン + 緊急 | -| 新しい関数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact | -| ツール | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) | -| ループ | LLM 呼び出し → ツール実行 | 各ラウンド前に 3 層の前処理 + 閾値で compact_history を起動 | -| 設計原則 | Agent を仕事できるように | Agent が長く走っても崩れないように | - -この一手は「能力」を加えるというより「体力」を加えるに近い:s07 は Agent を専門的な作業に強くし、s08 は長いタスクで自分の履歴に押しつぶされないようにする。 ---- - -## 試してみよう - -```sh -cd learn-claude-code -python s08_context_compact/code.py + # ... ツールを実行し、結果を messages に戻す ... ``` -次の prompt を試してみよう: +ここで最も重要なのは順序だ: -1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(複数ファイルを続けて読み、L2 が古い結果を圧縮する様子を観察) -2. `Read every file in s08_context_compact/`(一度に大量に読み、L3 のディスク退避を観察) -3. 会話を 20 ターン以上続け、`[auto compact]` や `[reactive compact]` が出るか観察 +```text +大きな結果を退避 → 中間の古い対話を切り詰め → 古い結果をプレースホルダ化 → それでも超過、そこで要約 +``` -観察ポイント:各ツール実行後、古い `tool_result` は置き換えられるか?大きな出力は退避されるか?token が閾値を超えたとき、要約は生成されるか? +前の 3 ステップにモデルは関与しない。主に空間の整理だ。Step 4 だけが本当に履歴を書き換える。だから必ず最後に置く。 --- -## 次へ - -圧縮のおかげで Agent は長く走っても崩れない。だが圧縮のたびに細部がいくらか失われる:ユーザーが前に述べた好み、プロジェクトの長期的な制約、複数のタスクにまたがって重要な情報。どれも要約に完全に残る保証はない。 - -compact が答えるのは「今のセッションがもうすぐ満杯だ、どう走り続けるか」だ。「どの情報を長く残す価値があるか」には答えない。 - -s09 Memory → 3 つのサブシステム:何を覚えるか選ぶ、重要な情報を抽出する、整理して定着させる。圧縮をまたぎ、セッションをまたいで。 - -
-Claude Code ソースコードの詳細 +## compact ツール:モデル自身が整理を求める -> 以下は Claude Code ソースコード `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` の分析に基づく。 +自動整理のほかに、モデルに `compact` ツールを持たせることもできる。 -### 実行順序の対応 +コンテキストが長すぎると気づいたとき、あるいはタスクが新しい段階に入ったとき、モデルはこのツールを自分から呼べる。呼ばれるとプログラムは `compact_history` を実行して現在のターンを終え、整理後のコンテキストで次のターンを始める。 -教学版は説明の便宜上 L1/L2/L3/L4 と番号を振っているが、実際の実行順序は番号と完全には一致しない: +これで整理はプログラムの自動トリガーだけでなく、モデルが適切なタイミングで自分から言い出せるものになる。 -| 項目 | 教学版 | Claude Code | -|------|--------|-------------| -| 実行順序 | budget → snip → micro → auto | budget → snip → micro → collapse → auto(`query.ts:379-468`) | -| snip_compact | 先頭 3 + 末尾 47 を保持 | Claude Code はメインスレッドのみ有効;実装はオープンソースリポジトリにない(`HISTORY_SNIP` feature gate)、インターフェースは確認可能:`snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`、`SnipTool` もモデルが能動的に呼び出し可能。教学版の 3/47 は簡略パラメータ | -| micro_compact | テキストプレースホルダで置換 | 2 つのパス:time-based は直接内容をクリア、cached は API の `cache_edits` を使用(legacy パスは削除済み) | -| micro_compact ホワイトリスト | 位置による(直近 3 件) | time-based は時間閾値でトリガー、cached はカウントでトリガー(`microCompact.ts`) | -| tool_result_budget | 200KB 文字 | 200,000 文字(`toolLimits.ts:49`) | -| compact_history 閾値 | 文字数で推定 | 精密な token 数:`contextWindow - maxOutputTokens - 13_000` | -| 要約の要求 | 5 種類の情報 | 9 つのセクション + ``/`` デュアルタグ | -| 圧縮プロンプト | シンプルなプロンプト | 先頭と末尾に二重の安全ガードでツール呼び出しを禁止 | -| PTL retry | あり(簡略版) | `truncateHeadForPTLRetry()` がメッセージグループ単位でロールバック(`compact.ts:243-290`) | -| 圧縮後のリカバリ | なし(教学版は要約のみ保持) | 直近のファイル、計画、agent/skill/tool などの自動再付加 | -| サーキットブレーカー | 3 回 | 3 回(`autoCompact.ts:70`) | -| reactive リトライ | 1 回 | Claude Code にはより精緻な段階別リトライがある | - -### 実行順序の詳細 +--- -Claude Code ソース `query.ts` での実際の順序: +## s07 からの変更 -1. `applyToolResultBudget`(L379):まず大きな結果を処理し、完全な内容を退避 -2. `snipCompact`(L403):中間メッセージを切り捨て -3. `microcompact`(L414):古い結果のプレースホルダ化 -4. `contextCollapse`(L441):独立したコンテキスト管理システム(教学版にはなし) -5. `autoCompact`(L454):LLM 全量要約 +| コンポーネント | s07 | s08 | +|------|------|------| +| コンテキスト管理 | なし | 毎回呼び出し前に整理 | +| ツール結果 | ずっとコンテキストに残る | 大きな結果は退避、古い結果はプレースホルダ | +| 履歴メッセージ | 蓄積し続ける | 中間の古い履歴は省略可能 | +| 上限超過時 | そのまま失敗 | まず整理、足りなければ要約 | +| 新ツール | なし | `compact` | -教学版の budget → snip → micro の順序はこれと一致する。教学版には contextCollapse メカニズムがない。 +s07 は Agent の仕事の腕を上げた。 +s08 は長いタスクで Agent が自分の履歴に押し潰されないようにする。 -### read_file のトレードオフ +--- -教学版の `micro_compact` は、古い `tool_result` を一律にプレースホルダへ置き換える。`read_file` も例外ではない。これは通常、機能的な正しさには影響しない。後でファイル内容が必要になれば、モデルはもう一度そのファイルを読めばよい。代償は、追加のツール呼び出しが発生し得ることと、prompt cache のヒット率が下がり得ること。 +## 試してみる -Claude Code は、この問題を教学版のような単純なルールでは処理していない。`Read` も microcompact 可能なツール集合に入れる一方で、別途 `readFileState` を維持している。変更されていないファイルの再読込では `FILE_UNCHANGED_STUB` を返し、compact 後には予算内で直近に読んだファイル内容を復元する(例:最大 5 ファイル、1 ファイル 5K token、合計 50K token)。これは本番実装向けのキャッシュと復元メカニズムである。教学版ではそこまで展開せず、「古い結果を圧縮し、必要なら再読込する」という単純な trade-off を残している。 +```bash +cd learn-claude-code +python s08_context_compact/code.py +``` -### 完全な定数リファレンス +試すタスク: -| 定数 | 値 | ソースファイル | -|------|-----|--------| -| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` | -| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` | -| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` | -| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` | -| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` | -| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` | -| 時間ベース micro_compact 間隔 | 60 分 | `timeBasedMCConfig.ts` | -| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` | +```text +Read README.md, then read code.py, then read s01_agent_loop/README.md +``` -### contextCollapse と sessionMemoryCompact +古いツール結果がプレースホルダに置き換わるかを観察する。 -Claude Code ソースコードには、この教学版では展開していない 2 つのメカニズムが存在する: +```text +Read every file in s08_context_compact/ +``` -- **contextCollapse**:独立したコンテキスト管理システム。有効時には proactive autocompact を抑制し(`autoCompact.ts:215-222`)、collapse の commit/blocking フローがコンテキスト管理を引き継ぐ。ただし manual `/compact` と reactive fallback は独立パスのままで、contextCollapse の影響を受けない。 -- **sessionMemoryCompact**:compact_history の前に、Claude Code は既存の session memory(s09 で解説)を使った軽量要約を先に試みる。LLM を呼び出さない。このメカニズムは s09 を学んだ後に振り返るとより理解しやすい。 +大きな出力がディスクへ退避されるかを観察する。 -### 圧縮プロンプトの中身 +```text +Keep discussing and editing for more than 20 turns +``` -Claude Code の圧縮プロンプトには 2 つの厳格な要件がある: +コンテキストが上限に近づいたとき、要約がトリガーされるかを観察する。 -1. **ツール呼び出しの絶対禁止**:冒頭が `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.` で、末尾にも再度 REMINDER がある -2. **先に分析してから要約**:モデルはまず `` タグで思考を整理し、その後 `` タグで正式な要約を出力する。analysis はフォーマット時に除去される +--- -### 教学版の簡略化は意図的 +## まとめ -- micro_compact でテキストプレースホルダを使用 → API 層の `cache_edits` 権限がないため -- read_file は特別扱いしない → 教学版では必要時の再読込を受け入れ、readFileState と圧縮後復元の仕組みを導入しない -- token を文字数で推定 → 精密な tokenizer は教学の対象外 -- 圧縮後のリカバリを省略 → 教学版は要約のみを保持し、ファイルの自動再付加を行わない -- 2 つの補助メカニズムを展開しない → 10% の細部に属する +Context Compact の核心となる原則は一行だ: -コア設計思想は完全に保持されている。 +> 整理できるものはまず整理する。復元できるものは要約しない。それでも足りないときだけ、モデルに履歴を要約させる。 -
+s08 で長いタスクは続けられるようになった。 +s09 は次の問題に取り組む:どの情報を長く残す価値があるか。 - + diff --git a/s08_context_compact/README.md b/s08_context_compact/README.md index 433fe89c1..92be44604 100644 --- a/s08_context_compact/README.md +++ b/s08_context_compact/README.md @@ -1,305 +1,306 @@ -# s08: Context Compact — Context Will Fill Up, Have a Way to Make Room +# s08: Context Compact — The Context Will Fill Up: Tidy First, Summarize Last [中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 -> *"Context will fill up — have a way to make room"* — Four-layer compaction pipeline: cheap first, expensive last. -> -> **Harness Layer**: Compaction — auto-summarize when context exceeds limits, keeping sessions sustainable. --- -## The Problem +On a long task, reading one file can cost thousands of tokens, and one test run dumps another wall of logs. File contents, command output, tool results: everything gets appended back into `messages`, and the pile keeps growing. -The last chapter gave the agent Skills, so it picked up a bit of "domain experience": hand it a PDF, an MCP server, or a code review, and it loads the right playbook before acting. +The more context, the more the model's attention spreads thin; once it is truly full, the request simply fails: `prompt_too_long`. -But the more capable the agent gets, the worse a second problem becomes. It reads one 1000-line file (that's ~4000 tokens), then 30 more files, then runs 20 commands. Every command's output and every file's contents get appended back into the `messages` list, piling up turn after turn. +So s08 solves one thing: -A few dozen turns of ordinary chat is nothing. A coding agent is different: one read is thousands of lines, one test run is a wall of logs. The task isn't even done, and the context window may already be full. +> Keep the agent working through long tasks. -Once it's full, the problem isn't "the model answered a little worse." The API rejects the call outright: `prompt_too_long`. Without compaction, an agent simply can't work on a large project. +![Context Compact overview](images/compact-overview.svg) --- -## The Solution +## Don't Start by Summarizing the History + +The most intuitive move is to have the model summarize the history. + +But that should not be the first step. + +A lot of content doesn't need summarizing: old logs, old file contents, tool results that have already served their purpose. They just take up space, and much of it no longer matters. For content like that, tidy first: persist to disk what can be persisted, replace with a placeholder what can be placeholdered, snip what can be snipped. + +Only when all of that is done and the context is still close to the limit do you let the model generate a summary. + +The reason is simple: the first three steps are mostly recoverable, while a summary is lossy. Once a summary replaces the history, the details are no longer in the current context. -![Compact Overview](images/compact-overview.svg) +--- + +## The Overall Flow + +Before every model call, tidy `messages` once: + +```python +messages = tool_result_budget(messages) # park large results first +messages = snip_compact(messages) # trim the middle of the history +messages = micro_compact(messages) # placeholder older tool results -The hook structure, skill loading, and subagent from s07 all stay; this chapter adds just one layer: before every LLM call, tidy up `messages` first. +if estimate_size(messages) > CONTEXT_LIMIT: + messages = compact_history(messages) # still too big, only then summarize +``` -The obvious idea is to let the model summarize once things fill up. But that has two problems. First, a summary costs an extra API call, and summarizing every time the context grows makes the bill climb fast. Second, not everything is worth summarizing: plenty of old tool results aren't needed anymore, and some content is merely big: a `cat` that dumps a few hundred KB of logs doesn't need to be *understood*, it just needs to move out of the context and be re-read if it ever matters again. +![Four-step compaction pipeline](images/compaction-layers.svg) -So compaction isn't one action, it's a pipeline. **Cheap first, expensive last**: run a few local passes that never call the model: trim what can be trimmed, swap placeholders in for old results, spill big outputs to disk. Only when none of that is enough do you let the LLM do a real summary. +> The order is not arbitrary. +> +> In particular, `tool_result_budget` must run before `micro_compact`. `micro_compact` replaces old tool results with placeholders; if it ran first, the full content would already be gone, and there would be nothing left to persist. --- -## How It Works +## Step 1: tool_result_budget — Park Large Results First -![Four-Layer Compaction Pipeline](images/compaction-layers.svg) +Sometimes the problem isn't a long history but a single oversized tool result. -### L1: snip_compact — Trim Irrelevant Old Conversation +Say the agent reads several large files at once: the last `tool_result` can easily exceed 200KB. It is the newest result, so it can't just be dropped; but it shouldn't sit in the context in full either. -The agent has run 80 turns and `messages` holds 160 entries. That opening "create hello.py for me" has almost nothing to do with the current work, yet it still takes up space. +The move: write the full content to disk, and keep only the path plus a short preview in context. -Once the count passes 50 → keep the first 3 (the original task and constraints) and the last 47 (the current work), and cut the middle. The one boundary to respect: don't split an `assistant(tool_use)` from the `user(tool_result)` that follows it, or the model sees an orphaned result with no idea which call it belongs to. +![Park large results to disk](images/layer1-budget.svg) ```python -def snip_compact(messages, max_messages=50): - if len(messages) <= max_messages: return messages - keep_head, keep_tail = 3, max_messages - 3 - head_end, tail_start = keep_head, len(messages) - keep_tail - if head_end > 0 and _message_has_tool_use(messages[head_end - 1]): - while head_end < len(messages) and _is_tool_result_message(messages[head_end]): - head_end += 1 - if (tail_start > 0 and tail_start < len(messages) - and _is_tool_result_message(messages[tail_start]) - and _message_has_tool_use(messages[tail_start - 1])): - tail_start -= 1 - if head_end >= tail_start: return messages - snipped = tail_start - head_end - return messages[:head_end] + [{"role": "user", "content": f"[snipped {snipped} messages]"}] + messages[tail_start:] +def tool_result_budget(messages, max_bytes=200_000): + blocks = [b for b in messages[-1]["content"] if b.get("type") == "tool_result"] + total = sum(len(str(b["content"])) for b in blocks) + + if total <= max_bytes: + return messages + + for block in sorted(blocks, key=lambda b: len(str(b["content"])), reverse=True): + block["content"] = persist_large_output(block["tool_use_id"], str(block["content"])) + total = sum(len(str(b["content"])) for b in blocks) + if total <= max_bytes: + break + + return messages ``` -What gets cut is the messages themselves, with one guard at the seam. But in the messages that remain, `tool_result` content is still piling up, and message 34 might still be sitting on 30KB of an old file. Fewer messages, but not fewer tokens. → L2. +This step loses nothing. It only moves content from "current context" to disk. -### L2: micro_compact — Placeholder for Old Tool Results +The model can still see where the content was saved and roughly how it starts. If the full content is needed later, read it back. -![Old Result Placeholders](images/micro-compact.svg) +--- -What blows up the context is usually not the conversation itself but the tool results. The agent read 10 files in a row; the full contents of files 1 through 7 stopped being useful long ago, yet they sit there verbatim. +## Step 2: snip_compact — Trim the Old Conversation -Keep the full content of the 3 most recent `tool_result`s and replace anything older with a one-line placeholder. The idea is plain: if an old result is really needed, the model can just read it again; it shouldn't hog space the whole time. +When there are too many messages, keep the beginning and the end. + +The beginning usually holds the original task and constraints; the end is what is being worked on right now. The old stretch in the middle can be replaced with a single note. ```python -KEEP_RECENT = 3 +def snip_compact(messages, max_messages=50): + if len(messages) <= max_messages: + return messages -def micro_compact(messages): - tool_results = collect_tool_results(messages) - if len(tool_results) <= KEEP_RECENT: return messages - for _, _, block in tool_results[:-KEEP_RECENT]: - if len(block.get("content", "")) > 120: - block["content"] = "[Earlier tool result compacted. Re-run if needed.]" - return messages + head = safe_head(messages, 3) + tail = safe_tail(messages, max_messages - 3) + snipped = len(messages) - len(head) - len(tail) + + return head + [ + {"role": "user", "content": f"[snipped {snipped} messages]"} + ] + tail ``` -The old results are cleared, but one case still slips through: a single new result can be 500KB on its own: one `cat` of a big file is enough to fill the context, and it's too fresh for micro_compact to touch. → L3. +One thing to watch: never separate an `assistant` `tool_use` from its matching `tool_result`. Otherwise the model sees a tool result that came from nowhere, and the API rejects the request outright. -### L3: tool_result_budget — Persist Large Results to Disk +That is why `safe_head` and `safe_tail` are not plain slices: they move the cut away from such break points (see `code.py`). + +This step reduces the number of messages. + +But it does nothing about large content inside a single message. If an old `tool_result` still carries tens of KB of file content, it keeps occupying the context. + +So the tool results still need tidying. + +--- -![Persist Large Results](images/layer1-budget.svg) +## Step 3: micro_compact — Replace Older Tool Results with Placeholders -Some results aren't a problem of *many*, but of *one too big*. The model read 5 large files at once, and the `tool_result`s in that last user message add up to over 200KB, and keeping the 3 most recent doesn't help here, because the newest one alone can fill the context. +Tool results usually take more space than the conversation itself. -Give tool results a budget. Add up the size of every `tool_result` in the last user message; if it's over 200KB, persist them to `.task_outputs/tool-results/` starting with the largest, leaving only a `` marker plus the first 2000 characters as a preview. The model sees the marker and knows the full content is on disk, to be re-read when needed. +The agent reads ten files in a row; the full contents of the first several rarely need to stay in context. Keeping the most recent few is enough. If an older result turns out to matter later, fetch it again. + +![Placeholder older results](images/micro-compact.svg) ```python -def tool_result_budget(messages, max_bytes=200_000): - last = messages[-1] if messages else None - if not last or last.get("role") != "user" or not isinstance(last.get("content"), list): return messages - blocks = [(i, b) for i, b in enumerate(last["content"]) - if isinstance(b, dict) and b.get("type") == "tool_result"] - total = sum(len(str(b.get("content", ""))) for _, b in blocks) - if total <= max_bytes: return messages - ranked = sorted(blocks, key=lambda p: len(str(p[1].get("content", ""))), reverse=True) - for _, block in ranked: - if total <= max_bytes: break - block["content"] = persist_large_output(block.get("tool_use_id", "unknown"), str(block.get("content", ""))) - total = sum(len(str(b.get("content", ""))) for _, b in blocks) +KEEP_RECENT = 3 + +def micro_compact(messages): + results = collect_tool_results(messages) + + for _, _, block in results[:-KEEP_RECENT]: + if len(block.get("content", "")) > 120: + block["content"] = "[Earlier tool result compacted. Re-run if needed.]" + return messages ``` -What matters here isn't discarding; it's moving content from "active context" to "recoverable external storage". That completes the first three layers: pure text/structure operations, 0 API calls, each watching one kind of bloat. But they share one limit: they can't read what the conversation is about, can't tell which findings matter or which constraints must stay. If the context is still too big, the model has to step in. → L4. +This step summarizes nothing. It just swaps older full results for a one-line note. -### L4: compact_history — Full LLM Summary +It handles "too many tool results", not "still too big after tidying". If the context is still over the limit at this point, the only option left is a model-generated summary. -![Full LLM Summary](images/auto-compact.svg) +--- -All three layers have run and the token count still tops the threshold. This is what most people picture as "context compaction": hand the history to the model and have it summarize into a shorter state. +## Step 4: compact_history — Still Over the Limit, Then Summarize -Three steps: first write the full conversation to `.transcripts/` (JSONL), so the active context keeps only the summary while the complete record stays on disk; then have the LLM produce a summary that preserves the current goal, key findings, files already changed, remaining work, and user constraints; finally replace all the old messages with that one summary. +If the context is still too big after the first three steps, let the model summarize the history. + +Three things happen: + +Write the full conversation to disk. +Have the model generate a summary. +Replace the old history with the summary. + +![Full LLM summary](images/auto-compact.svg) ```python def compact_history(messages): - transcript_path = write_transcript(messages) # save the full conversation first - summary = summarize_history(messages) # LLM generates the summary - return [{"role": "user", "content": f"[Compacted]\n\n{summary}"}] + transcript_path = write_transcript(messages) # ① write the full conversation to disk + summary = summarize_history(messages) # ② generate the summary + return [{ + "role": "user", + "content": f"[Compacted]\n\n{summary}", # ③ replace old history with the summary + }] ``` -This step is lossy: the transcript holds the full history, but the model can no longer see those details; it has only the summary to go on. That's why L1/L2/L3 run first: don't make the model summarize if you can avoid it, because once you do, detail is gone for good. The teaching version also adds a circuit breaker: stop after 3 consecutive compaction failures instead of burning API calls in a loop. +The summary is required to preserve five kinds of information: current goal, user constraints, key findings, files changed, next steps. -### Reactive: reactive_compact +This step is the most effective, and the riskiest. -Normally we tidy the context before calling the model. But when context grows too fast, or the token estimate is off, the API can still come back with `prompt_too_long`. +The full history is still on disk, but the model can now see only the summary. Any detail that didn't make it into the summary is, for every later turn, effectively invisible. + +That is why summarizing must come last. + +--- -That's when reactive_compact kicks in: much like compact_history but more aggressive: save the transcript, summarize most of the front, and keep only the last 5 messages as tail context (again avoiding an orphaned `tool_result`). +## Emergency Compaction After an Error + +Normally the context is tidied before the model is called. + +But token estimates can be off, or one turn's tool output suddenly balloons, and the API may still return `prompt_too_long`. At that point, do one more aggressive pass: save the full record, squash most of the earlier history into a summary, and keep only the last few messages. ```python def reactive_compact(messages): - transcript = write_transcript(messages) - tail_start = max(0, len(messages) - 5) - if (tail_start > 0 and tail_start < len(messages) - and _is_tool_result_message(messages[tail_start]) - and _message_has_tool_use(messages[tail_start - 1])): - tail_start -= 1 - summary = summarize_history(messages[:tail_start]) - return [{"role": "user", "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]] + write_transcript(messages) + tail = safe_tail(messages, 5) # tail slice, same boundary guard + summary = summarize_history(messages[:len(messages) - len(tail)]) + + return [{ + "role": "user", + "content": f"[Reactive compact]\n\n{summary}", + }] + tail ``` -Reactive is the fallback, not the normal path: it retries once by default, and on another failure it raises rather than looping forever. The full error-recovery logic is left to s11. +This is not the normal path. + +It runs only after an error has already occurred, and it retries a limited number of times (once, in the teaching version). Otherwise, if the summary itself fails, you can end up retrying forever. + +--- -### Putting It All Together +## Back Into the Agent Loop -Wire it all back into the Agent Loop: before each LLM call, run the three local passes, summarize if that's not enough, and fall back to the emergency path only if the call actually errors. +The tidying logic ultimately plugs back into the agent loop. ```python def agent_loop(messages): reactive_retries = 0 while True: - # three preprocessors (0 API calls), order: budget -> snip -> micro - messages[:] = tool_result_budget(messages) # L3: persist large results - messages[:] = snip_compact(messages) # L1: trim the middle - messages[:] = micro_compact(messages) # L2: old result placeholders + messages[:] = tool_result_budget(messages) + messages[:] = snip_compact(messages) + messages[:] = micro_compact(messages) - if estimate_size(messages) > CONTEXT_LIMIT: # still too big -> LLM summary (1 API call) + if estimate_size(messages) > CONTEXT_LIMIT: messages[:] = compact_history(messages) try: - response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000) + response = client.messages.create( + model=MODEL, system=SYSTEM, + messages=messages, tools=TOOLS, max_tokens=8000) except Exception as e: if "prompt_too_long" in str(e).lower() and reactive_retries < MAX_REACTIVE_RETRIES: - messages[:] = reactive_compact(messages) # emergency + messages[:] = reactive_compact(messages) reactive_retries += 1 continue raise - # ... tool execution ... -``` - -**The order can't change.** L3 (budget) has to run before L2 (micro): micro replaces old large `tool_result`s with a one-line placeholder, so if it ran first, budget would never get to persist the full content. Save the big content first, then do the placeholdering and trimming. That's also why Claude Code's source puts `applyToolResultBudget` first. - -### The compact Tool — Let the Model Ask, Too - -Beyond automatic compaction, the model can ask for a tidy-up itself: when it feels the context is too long, or the task has shifted phase, it can call the `compact` tool. In the teaching version that tool triggers `compact_history`, then ends the current turn and starts fresh with the compacted context. It feels much like a manual `/compact`, except this time the model itself realized it was time. - ---- - -## Changes From s07 - -| Component | Before (s07) | After (s08) | -|-----------|-------------|-------------| -| Context management | None (context grows without bound) | Four-layer compaction pipeline + emergency | -| New functions | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact | -| Tools | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) | -| Loop | LLM call → tool execution | Run three preprocessors each round + threshold-triggered compact_history | -| Design principle | Make the agent capable | Keep the agent from crashing on long runs | - -This step doesn't add a *capability* so much as *stamina*: s07 made the agent better at specialized work, s08 keeps it from being dragged down by its own history on a long task. - ---- - -## Try It -```sh -cd learn-claude-code -python s08_context_compact/code.py + # ... run tools, append results back into messages ... ``` -Try these prompts: +What matters most here is the order: -1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md` (read several files in a row, watch L2 compact old results) -2. `Read every file in s08_context_compact/` (read a lot at once, watch L3 persist to disk) -3. Keep the conversation going 20+ turns, watch for `[auto compact]` or `[reactive compact]` +```text +park large results → trim middle history → placeholder older results → still over the limit, then summarize +``` -What to watch: after each tool runs, does the old `tool_result` get replaced? Do large outputs get persisted? When tokens pass the threshold, does a summary get generated? +The first three steps involve no model at all; they mostly clear space. Step 4 actually rewrites history, which is why it must come last. --- -## What's Next - -Compaction lets the agent run a long time without crashing. But every compaction loses some detail: a preference the user stated earlier, a long-standing project constraint, a fact that matters across tasks. None of it is guaranteed to survive in the summary. - -Compaction answers "the current session is nearly full, how do we keep going". It doesn't answer "which information is worth keeping for the long haul". - -s09 Memory → three subsystems: choosing what to remember, extracting the key information, and consolidating it. Across compactions, across sessions. - -
-Deep Dive Into Claude Code Source Code - -> The following is based on analysis of Claude Code source code `compact.ts`, `autoCompact.ts`, `microCompact.ts`, and `query.ts`. +## The compact Tool: Let the Model Ask -### Execution Order Comparison +Besides automatic tidying, the model can be given a `compact` tool. -The teaching version labels layers L1/L2/L3/L4 for pedagogical clarity, but actual execution order does not match the numbering: +When the model notices the context getting long, or the task moving into a new phase, it can call the tool itself. The program then runs `compact_history`, ends the current turn, and starts the next one with the compacted context. -| Dimension | Teaching Version | Claude Code | -|-----------|-----------------|-------------| -| Execution order | budget → snip → micro → auto | budget → snip → micro → collapse → auto (`query.ts:379-468`) | -| snip_compact | Keep head 3 + tail 47 | Claude Code only enables on main thread; implementation not in open-source repo (`HISTORY_SNIP` feature gate), but interface is visible: `snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`, also exposes `SnipTool` for model-initiated snipping. Teaching version's 3/47 are simplified parameters | -| micro_compact | Text placeholder replacement | Two paths: time-based clears content directly, cached uses API `cache_edits` (legacy path removed) | -| micro_compact whitelist | By position (most recent 3) | time-based triggers by time threshold; cached triggers by count (`microCompact.ts`) | -| tool_result_budget | 200KB characters | 200,000 characters (`toolLimits.ts:49`) | -| compact_history threshold | Character count estimate | Precise tokens: `contextWindow - maxOutputTokens - 13_000` | -| Summary requirements | 5 categories of info | 9 sections + ``/`` dual tags | -| Compression prompt | Simple prompt | Double-ended hard guardrails forbidding tool calls | -| PTL retry | Yes (simplified) | `truncateHeadForPTLRetry()` retreats by message groups (`compact.ts:243-290`) | -| Post-compaction recovery | None (teaching version only keeps summary) | Auto re-read recent files, plans, agent/skill/tool context | -| Circuit breaker | 3 times | 3 times (`autoCompact.ts:70`) | -| Reactive retry | 1 time | Claude Code has more granular tiered retries | +This way compaction isn't only program-triggered; the model can also request it at the right moment. -### Execution Order Details +--- -The real order in Claude Code source `query.ts`: +## Changes from s07 -1. `applyToolResultBudget` (L379): persist large results first, ensuring full content is saved -2. `snipCompact` (L403): trim middle messages -3. `microcompact` (L414): old result placeholders -4. `contextCollapse` (L441): independent context management system (not in teaching version) -5. `autoCompact` (L454): LLM full summary +| Component | s07 | s08 | +|-----------|-----|-----| +| Context management | None | Tidy before every model call | +| Tool results | Stay in context forever | Large ones persisted, old ones placeholdered | +| Message history | Accumulates forever | Middle history can be snipped | +| Over the limit | Request fails | Tidy first, summarize only if needed | +| New tool | None | `compact` | -The teaching version's budget → snip → micro order matches this. The teaching version does not have the contextCollapse mechanism. +s07 made the agent better at its work. +s08 keeps the agent from being crushed by its own history on long tasks. -### read_file Trade-off +--- -The teaching version's `micro_compact` replaces old `tool_result` blocks with placeholders uniformly, including `read_file`. This usually does not affect functional correctness: if the model needs the file contents later, it can read the file again. The cost is an extra tool call and potentially lower prompt cache hit rates. +## Try It -Claude Code does not solve this with the teaching version's simple rule. It also puts `Read` in the microcompactable tool set, but maintains a separate `readFileState`: repeated reads of unchanged files return `FILE_UNCHANGED_STUB`, and after compaction it restores recently read file contents within a budget (for example, up to 5 files, 5K tokens per file, 50K tokens total). That is a production-level cache and recovery mechanism. The teaching version does not expand into that machinery; it keeps the simpler trade-off of compacting old results and re-reading when needed. +```bash +cd learn-claude-code +python s08_context_compact/code.py +``` -### Full Constant Reference +Tasks to try: -| Constant | Value | Source File | -|----------|-------|-------------| -| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` | -| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` | -| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` | -| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` | -| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` | -| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` | -| Time micro_compact interval | 60 minutes | `timeBasedMCConfig.ts` | -| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` | +```text +Read README.md, then read code.py, then read s01_agent_loop/README.md +``` -### contextCollapse and sessionMemoryCompact +Watch whether older tool results get replaced with placeholders. -Claude Code source code has two additional mechanisms not covered in this teaching version: +```text +Read every file in s08_context_compact/ +``` -- **contextCollapse**: An independent context management system that, when enabled, suppresses proactive autocompact (`autoCompact.ts:215-222`), with collapse's commit/blocking flow taking over context management. Manual `/compact` and reactive fallback remain independent paths, unaffected by contextCollapse. -- **sessionMemoryCompact**: Before compact_history, Claude Code first attempts a lightweight summary using existing session memory (covered in s09) without calling the LLM. This mechanism becomes clearer after learning s09. +Watch whether large outputs get persisted to disk. -### What Does the Compression Prompt Look Like? +```text +Keep discussing and editing for more than 20 turns +``` -Claude Code's compression prompt has two hard requirements: +Watch whether a summary is triggered as the context nears the limit. -1. **Absolutely no tool calls**: It begins with `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`, and appends another REMINDER at the end -2. **Analyze first, then summarize**: The model must first reason in an `` tag, then output the formal summary in a `` tag. The analysis is stripped during formatting +--- -### Teaching Version Simplifications Are Intentional +## Recap -- micro_compact uses text placeholders → we don't have API-level `cache_edits` access -- read_file is not special-cased → the teaching version accepts re-reading when needed instead of introducing readFileState and post-compaction recovery -- Tokens estimated via character count → precise tokenizers are out of scope -- Post-compaction recovery omitted → teaching version only keeps summary, does not auto re-attach files -- Two auxiliary mechanisms not covered → they fall in the 10% detail category +The core principle of Context Compact fits in one line: -The core design principle is fully preserved. +> Tidy what you can, don't summarize what you can recover; only when that's still not enough, have the model summarize the history. -
+s08 lets long tasks continue. +s09 tackles the next question: which information deserves to be kept for the long haul. - + diff --git a/s08_context_compact/README.zh.md b/s08_context_compact/README.zh.md index 2d360aa4c..82ffd4f0f 100644 --- a/s08_context_compact/README.zh.md +++ b/s08_context_compact/README.zh.md @@ -1,305 +1,306 @@ -# s08: Context Compact — 上下文总会满,要有办法腾地方 +# s08: Context Compact — 上下文总会满,先整理,再总结 [中文](README.zh.md) · [English](README.md) · [日本語](README.ja.md) s01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](../s09_memory/) → s10 → ... → s20 -> *"上下文总会满, 要有办法腾地方"* — 四层压缩管线,便宜的先跑,贵的后跑。 -> -> **Harness 层**:压缩 — 上下文接近上限时先整理、再摘要,让长任务能持续跑下去。 --- -## 问题 +Agent 在做长任务时,读一个文件,可能就是几千 token;跑一次测试,日志又是一大段。文件内容、命令输出、工具结果都会被塞回 `messages`,越堆越多。 -上一章给 Agent 加了 Skills。它开始有了一点"领域经验":遇到 PDF、MCP server 或代码审查任务,会先加载对应的操作说明,再开始动手。 +上下文越多,模型的注意力越分散;等到真正装满,请求会直接失败:`prompt_too_long`。 -但 Agent 越能干,另一个问题就越明显。它读一个 1000 行文件,可能就是约 4000 个 token;再读 30 个文件、跑 20 条命令,上下文很快就会被堆满。每条命令的输出、每个文件的内容,都被塞回 `messages` 列表,一点点堆起来。 +所以 s08 要解决一件事: -普通聊天聊几十轮,通常问题不大。但代码 Agent 不一样:一次读文件就是几千行,一次跑测试就是一大段日志。任务还没做完,上下文窗口可能先满了。 +> 让 Agent 在长任务里能一直工作下去。 -上下文满了之后,问题不是"模型回答质量变差一点",而是 API 会直接拒绝请求:`prompt_too_long`。不压缩,Agent 根本没法在大项目里干活。 +![Context Compact 全景](images/compact-overview.svg) --- -## 解决方案 +## 不要一上来就总结历史对话 + +最直觉的办法,是让模型把历史总结一下。 + +但这不该是第一步。 + +很多内容并不需要总结,比如旧日志、旧文件内容、已经用过的工具结果。它们只是占地方,不一定还重要。对这些内容,更合适的做法是先整理:能存到磁盘的先存到磁盘,能用占位符代替的先用占位符,能裁掉的先裁掉。 + +这些都做完,上下文还是快要超限,才让模型生成摘要。 + +原因也很简单:前三步基本可恢复,摘要是有损的。摘要一旦替换历史,细节就不在当前上下文里了。 -![Compact Overview](images/compact-overview.svg) +--- + +## 整体流程 + +每次调用模型前,先整理一次 `messages`: + +```python +messages = tool_result_budget(messages) # 大结果先存起来 +messages = snip_compact(messages) # 中间旧对话裁掉 +messages = micro_compact(messages) # 较早工具结果换成占位符 -s07 里的 hook 结构、技能加载、子 Agent 骨架都保留。s08 只加一层:每次调用 LLM 之前,先整理 `messages`。 +if estimate_size(messages) > CONTEXT_LIMIT: + messages = compact_history(messages) # 还不够,才摘要 +``` -最直觉的做法,是上下文快满时让模型总结一下。但这里有两个问题。一是总结要多花一次 API 调用,只要上下文变大就摘要,成本很快就会上去。二是并不是所有内容都值得总结:很多旧工具结果早就不需要了;有些内容只是大,比如一次 `cat` 出几百 KB 日志,它不一定需要被模型"理解",更多时候只是需要先从上下文里挪出去,必要时再读回来。 +![四步压缩管线](images/compaction-layers.svg) -所以,上下文压缩(compact)不是一个单点动作,而是一条管线。**便宜的先跑,贵的后跑**:先做几步不调模型的本地整理,能裁掉的先裁掉,能占位的先占位,能落盘的先落盘;只有这些都不够,才让 LLM 做一次真正的摘要。 +> 这个顺序不能随意调换。 +> +> 尤其是 `tool_result_budget` 必须在 `micro_compact` 前面。因为 `micro_compact` 会把旧工具结果替换成占位符。如果它先跑,后面就拿不到完整内容,也就没法把大结果存起来。 --- -## 工作原理 +## 第一步:tool_result_budget — 大结果先暂存 -![四层压缩管线](images/compaction-layers.svg) +有时不是历史太长,而是单条工具结果太大。 -### L1: snip_compact — 裁掉中间的旧对话 +比如 Agent 一次读了几个大文件,最后一条 `tool_result` 就可能超过 200KB。这个结果是最新的,不能简单丢掉;但它也不应该完整塞在上下文里。 -Agent 跑了 80 轮,`messages` 攒了 160 条。最开始那句"帮我创建 hello.py",和当前工作几乎已经没关系了,但还在上下文里占着位置。 +做法是:把完整内容写到磁盘,上下文里只留下路径和一小段预览。 -当消息数超过 50 条时,保留头部 3 条(最初的任务和约束)以及尾部 47 条(当前工作),中间部分裁掉。唯一要小心的边界:不能把 `assistant(tool_use)` 和后面的 `user(tool_result)` 拆开,否则模型会看到一条凭空出现的 `tool_result`,却不知道它对应哪一次工具调用。 +![大结果先暂存](images/layer1-budget.svg) + +```python +def tool_result_budget(messages, max_bytes=200_000): + blocks = [b for b in messages[-1]["content"] if b.get("type") == "tool_result"] + total = sum(len(str(b["content"])) for b in blocks) + + if total <= max_bytes: + return messages + + for block in sorted(blocks, key=lambda b: len(str(b["content"])), reverse=True): + block["content"] = persist_large_output(block["tool_use_id"], str(block["content"])) + total = sum(len(str(b["content"])) for b in blocks) + if total <= max_bytes: + break + + return messages +``` + +这一步不丢内容,只是把内容从"当前上下文"挪到磁盘。 + +模型还能看到:这段内容已经保存在哪里、开头大概是什么样。后面真需要完整内容,再读回来即可。 + +--- + +## 第二步:snip_compact — 裁减旧对话 + +消息太多时,可以保留开头和结尾。 + +开头通常有原始任务和约束,结尾是当前正在做的事。中间那段旧历史,可以换成一条说明。 ```python def snip_compact(messages, max_messages=50): - if len(messages) <= max_messages: return messages - keep_head, keep_tail = 3, max_messages - 3 - head_end, tail_start = keep_head, len(messages) - keep_tail - if head_end > 0 and _message_has_tool_use(messages[head_end - 1]): - while head_end < len(messages) and _is_tool_result_message(messages[head_end]): - head_end += 1 - if (tail_start > 0 and tail_start < len(messages) - and _is_tool_result_message(messages[tail_start]) - and _message_has_tool_use(messages[tail_start - 1])): - tail_start -= 1 - if head_end >= tail_start: return messages - snipped = tail_start - head_end - return messages[:head_end] + [{"role": "user", "content": f"[snipped {snipped} messages]"}] + messages[tail_start:] + if len(messages) <= max_messages: + return messages + + head = safe_head(messages, 3) + tail = safe_tail(messages, max_messages - 3) + snipped = len(messages) - len(head) - len(tail) + + return head + [ + {"role": "user", "content": f"[snipped {snipped} messages]"} + ] + tail ``` -裁掉的是消息本身,在切口处多做一步保护。但剩下的消息里,`tool_result` 内容仍在累积,第 34 条消息里可能还躺着 30KB 的旧文件内容。消息数是少了,但 token 未必真的少。→ L2。 +这里需要注意:不能把 `assistant` 的 `tool_use` 和对应的 `tool_result` 拆开。否则模型会看到一条来历不明的工具结果,API 会直接报错。 + +所以 `safe_head` 和 `safe_tail` 都不是简单切片,它们会避开这种断点(实现见 `code.py`)。 -### L2: micro_compact — 旧工具结果占位 +这一步减少的是消息数量。 -![旧结果占位](images/micro-compact.svg) +但它不处理单条消息里的大内容。如果某条旧 `tool_result` 里还有几十 KB 的文件内容,它仍然会占上下文。 -最容易把上下文撑大的,往往不是对话本身,而是工具结果。Agent 连续读了 10 个文件,第 1 到第 7 个的完整内容早就不需要了,却还原样躺在上下文里。 +所以还要继续整理工具结果。 + +--- -保留最近 3 条 `tool_result` 的完整内容,更早的换成一行占位符。想法很朴素:旧结果如果真有用,模型可以重新读一次;它不应该一直占着上下文。 +## 第三步:micro_compact — 较早的工具结果换成占位符 + +工具结果往往比对话更占地方。 + +Agent 连续读了十个文件,前几个文件的完整内容通常已经不需要一直放在上下文里。保留最近几条就够了。更早的结果,如果之后真的有用,可以重新读取。 + +![旧结果换占位符](images/micro-compact.svg) ```python KEEP_RECENT = 3 def micro_compact(messages): - tool_results = collect_tool_results(messages) - if len(tool_results) <= KEEP_RECENT: return messages - for _, _, block in tool_results[:-KEEP_RECENT]: + results = collect_tool_results(messages) + + for _, _, block in results[:-KEEP_RECENT]: if len(block.get("content", "")) > 120: block["content"] = "[Earlier tool result compacted. Re-run if needed.]" + return messages ``` -旧结果清掉了,但还挡不住一种情况:单条新结果就有 500KB,一次 `cat` 大文件的输出,就可能把上下文用满;但因为它是最新结果,`micro_compact` 不会处理它。→ L3。 +这一步不会总结内容,只是把较早的完整结果换成一句说明。 -### L3: tool_result_budget — 大结果落盘 +它适合处理"工具结果很多"的情况,但处理不了"整理完还是太大"的情况。到这里如果上下文仍然超限,就只能让模型生成摘要。 -![大结果落盘](images/layer1-budget.svg) +--- -有些问题不是结果太多,而是单条结果太大。比如模型一次读了 5 个大文件,最后一条 user 消息里的 `tool_result` 加起来超过 200KB。此时只保留最近 3 条也没用,因为最新结果本身就可能用满上下文。 +## 第四步:compact_history — 整理后仍超限,再生成摘要 -给工具结果设一个预算。统计最后一条 user 消息里所有 `tool_result` 的总大小,超过 200KB 就从最大的开始落盘到 `.task_outputs/tool-results/`,上下文里只留下一个 `` 标记,以及前 2000 个字符作为预览。模型看到这个标记就知道完整内容已经在磁盘上,后面需要时可以再读回来。 +前三步都做完,如果上下文还是太大,才让模型摘要历史。 -```python -def tool_result_budget(messages, max_bytes=200_000): - last = messages[-1] if messages else None - if not last or last.get("role") != "user" or not isinstance(last.get("content"), list): return messages - blocks = [(i, b) for i, b in enumerate(last["content"]) - if isinstance(b, dict) and b.get("type") == "tool_result"] - total = sum(len(str(b.get("content", ""))) for _, b in blocks) - if total <= max_bytes: return messages - ranked = sorted(blocks, key=lambda p: len(str(p[1].get("content", ""))), reverse=True) - for _, block in ranked: - if total <= max_bytes: break - block["content"] = persist_large_output(block.get("tool_use_id", "unknown"), str(block.get("content", ""))) - total = sum(len(str(b.get("content", ""))) for _, b in blocks) - return messages -``` +这一步分三件事: -这一步的关键不是丢弃内容,而是把内容从"活跃上下文"移到"可恢复的外部存储"。前三层到这就齐了:纯文本或结构操作,0 API 调用,各自处理一种上下文膨胀。但它们有同一个限制:不理解对话内容,判断不了哪些发现重要、哪些约束必须留下。如果上下文还是太大,就只能让模型出手。→ L4。 - -### L4: compact_history — LLM 全量摘要 +先把完整对话写到磁盘。 +再让模型生成摘要。 +最后用摘要替换旧历史。 ![LLM 全量摘要](images/auto-compact.svg) -前三层全跑完,token 还是超阈值。这一步才是很多人直觉里的"上下文压缩":把历史交给模型,压成一段更短的状态。 - -三步:先把完整对话写进 `.transcripts/`(JSONL),这样活跃上下文里只保留摘要,但磁盘上仍然保存完整记录;再让 LLM 生成摘要,要求保留当前目标、重要发现、已改文件、剩余工作、用户约束;最后用这一条摘要替换掉所有旧消息。 - ```python def compact_history(messages): - transcript_path = write_transcript(messages) # 先存完整对话 - summary = summarize_history(messages) # LLM 生成摘要 - return [{"role": "user", "content": f"[Compacted]\n\n{summary}"}] + transcript_path = write_transcript(messages) # ① 完整对话先写到磁盘 + summary = summarize_history(messages) # ② 生成摘要 + return [{ + "role": "user", + "content": f"[Compacted]\n\n{summary}", # ③ 用摘要替换旧历史 + }] ``` -这一步是有损的:transcript 里有完整历史,但模型当下看不到那些细节了,只能靠摘要继续。所以前面才要先跑 L1/L2/L3:能不让模型总结就不总结,因为一旦进入摘要,细节就不可避免会丢失。教学版还加了个熔断器:连续 compact 失败 3 次就停,避免无限重试浪费 API。 +摘要要求保留五类信息:当前目标、用户约束、重要发现、已修改文件、下一步工作。 -### 应急:reactive_compact +这一步最有效,也最具风险。 -正常情况下,我们在调用模型之前就把上下文整理好。但如果上下文增长太快、或 token 估算不准,API 还是可能返回 `prompt_too_long`。 +虽然完整历史还在磁盘里,但是模型当前只能看到摘要。摘要中没有写进去的细节,对之后的每一轮来说就等于暂时看不见了。 + +所以摘要一定要放在最后。 + +--- -这时就走 `reactive_compact`,它和 `compact_history` 很像,但更激进:先保存 transcript,再把前面大半段压成摘要,只保留最后 5 条作为尾部上下文(同样避免留下孤立 `tool_result`)。 +## 报错后的补救整理 + +正常情况下,调用模型前就会把上下文整理好。 + +但 token 估算可能不准,或者某一轮工具输出突然变得很大,接口仍然可能返回 `prompt_too_long`。这时再做一次更激进的整理:保存完整记录,把前面大部分历史压成摘要,只保留最后几条消息。 ```python def reactive_compact(messages): - transcript = write_transcript(messages) - tail_start = max(0, len(messages) - 5) - if (tail_start > 0 and tail_start < len(messages) - and _is_tool_result_message(messages[tail_start]) - and _message_has_tool_use(messages[tail_start - 1])): - tail_start -= 1 - summary = summarize_history(messages[:tail_start]) - return [{"role": "user", "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]] + write_transcript(messages) + tail = safe_tail(messages, 5) # 尾部切片,同样避开断点 + summary = summarize_history(messages[:len(messages) - len(tail)]) + + return [{ + "role": "user", + "content": f"[Reactive compact]\n\n{summary}", + }] + tail ``` -reactive 是兜底路径,不是常规路径,默认只重试 1 次;如果再次失败,就抛出异常,避免无限循环。完整的错误恢复逻辑留给 s11。 +这不是常规路径。 + +它只在已经报错时使用,而且只重试有限次数(教学版是 1 次)。否则一旦摘要也失败,就可能陷入反复重试。 + +--- -### 合起来跑 +## 放回 Agent Loop -把这些机制接回 Agent Loop:每轮调用 LLM 之前,先跑三层本地整理;如果还不够,再做摘要;如果调用时真的报错,再走应急路径。 +整理逻辑最终要接回 Agent Loop。 ```python def agent_loop(messages): reactive_retries = 0 while True: - # 三个预处理器(0 API),顺序:budget → snip → micro - messages[:] = tool_result_budget(messages) # L3: 大结果落盘 - messages[:] = snip_compact(messages) # L1: 裁中间 - messages[:] = micro_compact(messages) # L2: 旧结果占位 + messages[:] = tool_result_budget(messages) + messages[:] = snip_compact(messages) + messages[:] = micro_compact(messages) - if estimate_size(messages) > CONTEXT_LIMIT: # 还不够,LLM 摘要(1 API) + if estimate_size(messages) > CONTEXT_LIMIT: messages[:] = compact_history(messages) try: - response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000) + response = client.messages.create( + model=MODEL, system=SYSTEM, + messages=messages, tools=TOOLS, max_tokens=8000) except Exception as e: if "prompt_too_long" in str(e).lower() and reactive_retries < MAX_REACTIVE_RETRIES: - messages[:] = reactive_compact(messages) # 应急 + messages[:] = reactive_compact(messages) reactive_retries += 1 continue raise - # ... 工具执行 ... -``` - -**顺序不能换。** L3(budget)必须在 L2(micro)前面,`micro_compact` 会把旧的大 `tool_result` 替换成一行占位符。如果它先跑,`tool_result_budget` 就拿不到完整内容,也就没机会把它落盘。先 budget 把大内容存好,再做占位和裁剪。这也是 Claude Code 源码中 `applyToolResultBudget` 要放在最前面的原因。 - -### compact 工具:让模型也能主动请求 - -除了自动压缩,模型自己也能要求整理,当模型觉得上下文太长,或者任务已经切到新阶段时,可以主动调用 `compact` 工具。在教学版里,这个工具触发 `compact_history`,然后结束当前 turn,用压缩后的上下文重新开始下一轮。和手动 `/compact` 的感觉很像,区别在于,这次是模型自己意识到该整理上下文了。 - ---- - -## 相对 s07 的变更 - -| 组件 | 之前 (s07) | 之后 (s08) | -|------|-----------|-----------| -| 上下文管理 | 无(上下文无限膨胀) | 四层压缩管线 + 应急 | -| 新函数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact | -| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) | -| 循环 | LLM 调用 → 工具执行 | 每轮前跑三层预处理器 + 阈值触发 compact_history | -| 设计原则 | 让 Agent 会做事 | 让 Agent 运行久一点也不崩 | - -这一步不算是在给 Agent 加"能力",更像是在加"体力"。s07 让它更会做专业任务,s08 让它在长任务里不被自己的历史拖垮。 - ---- - -## 试一下 -```sh -cd learn-claude-code -python s08_context_compact/code.py + # ... 执行工具,把结果塞回 messages ... ``` -试试这些 prompt: +这里最重要的是顺序: -1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(连续读多个文件,观察 L2 压缩旧结果) -2. `Read every file in s08_context_compact/`(一次性读大量内容,观察 L3 落盘) -3. 反复对话 20+ 轮,观察是否出现 `[auto compact]` 或 `[reactive compact]` +```text +大结果先存 → 中间旧对话裁掉 → 较早工具结果占位 → 仍然超限,再生成摘要 +``` -观察重点:每次工具执行后,旧 `tool_result` 是否被替换?大输出有没有落盘?token 超阈值时是否生成了摘要? +前三步都不需要模型参与,主要是在整理空间。第四步才是真的改写历史,所以必须放到最后。 --- -## 接下来 - -压缩让 Agent 能跑很久不崩。但每次压缩都会丢一些细节:用户之前说过的偏好、项目里的长期约束、某些跨任务都重要的信息,不一定能完整留在摘要里。 - -compact 解决的是"当前会话快满了,怎么继续跑下去"。它没解决"哪些信息值得长期留下来"。 - -s09 Memory → 三个子系统:选择记什么、提取关键信息、整理巩固。跨压缩、跨会话。 - -
-深入 Claude Code 源码 - -> 以下基于 Claude Code 源码 `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` 的分析。 +## compact 工具:让模型自己提出整理 -### 执行顺序对照 +除了自动整理,也可以给模型一个 `compact` 工具。 -教学版为了讲解方便按 L1/L2/L3/L4 编号,但实际执行顺序和编号不完全对应: +当模型发现上下文太长,或者任务已经进入新阶段时,可以主动调用这个工具。调用后,程序执行 `compact_history`,结束当前轮,再用整理后的上下文开始下一轮。 -| 维度 | 教学版 | Claude Code | -|------|--------|-------------| -| 执行顺序 | budget → snip → micro → auto | budget → snip → micro → collapse → auto(`query.ts:379-468`) | -| snip_compact | 保留头 3 + 尾 47 | Claude Code 仅主线程启用;实现不在开源仓库中(`HISTORY_SNIP` feature gate),但接口可见:`snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`,还暴露了 `SnipTool` 工具让模型主动调用。教学版的 3/47 是简化参数 | -| micro_compact | 文本占位符替换 | 两条路径:time-based 直接清内容,cached 走 API `cache_edits`(legacy path 已移除) | -| micro_compact 白名单 | 按位置(最近 3 条) | time-based 按时间阈值触发;cached 按计数触发(`microCompact.ts`) | -| tool_result_budget | 200KB 字符 | 200,000 字符(`toolLimits.ts:49`) | -| compact_history 阈值 | 字符数估算 | 精确 token:`contextWindow - maxOutputTokens - 13_000` | -| 摘要要求 | 5 类信息 | 9 个部分 + ``/`` 双标签 | -| 压缩 prompt | 简单 prompt | 首尾双重防呆禁止调工具 | -| PTL retry | 有(简化) | `truncateHeadForPTLRetry()` 按消息组回退(`compact.ts:243-290`) | -| 后压缩恢复 | 无(教学版只保留摘要) | 自动重新读取最近文件、计划、agent/skill/tool 等 | -| 熔断器 | 3 次 | 3 次(`autoCompact.ts:70`) | -| reactive 重试 | 1 次 | Claude Code 有更精细的分级重试 | +这样,整理不只由程序自动触发,也可以由模型在合适的时候主动提出。 -### 执行顺序详解 +--- -Claude Code 源码 `query.ts` 中的真实顺序: +## 相对 s07 的变化 -1. `applyToolResultBudget`(L379):先处理大结果,确保完整内容落盘 -2. `snipCompact`(L403):裁中间消息 -3. `microcompact`(L414):旧结果占位 -4. `contextCollapse`(L441):独立的上下文管理系统(教学版无) -5. `autoCompact`(L454):LLM 全量摘要 +| 组件 | s07 | s08 | +| ---------- | ---------------- | ---------------------- | +| 上下文管理 | 无 | 每轮调用前先整理 | +| 工具结果 | 一直留在上下文里 | 大结果转存,旧结果占位 | +| 历史消息 | 一直累积 | 中间旧历史可省略 | +| 超限处理 | 直接失败 | 先整理,不够再摘要 | +| 新增工具 | 无 | `compact` | -教学版的 budget → snip → micro 顺序与此一致。教学版没有 contextCollapse 机制。 +s07 让 Agent 更会做事。 +s08 让 Agent 在长任务里不被自己的历史拖垮。 -### read_file 的取舍 +--- -教学版的 `micro_compact` 会把旧 `tool_result` 统一替换成占位符,包括 `read_file`。这通常不影响功能正确性:如果后续还需要文件内容,模型可以重新读一次。代价是可能多一次工具调用,也可能降低 prompt cache 命中率。 +## 试一下 -Claude Code 没有用教学版这种简单规则解决这个问题。它把 `Read` 也放进可 microcompact 的工具集合,但同时维护 `readFileState`:重复读取未变化文件时返回 `FILE_UNCHANGED_STUB`,compact 后再按预算恢复最近读过的文件内容(例如最多 5 个文件、每个 5K token、总预算 50K token)。这是生产级实现里的缓存和恢复机制,教学版不展开,保留“压缩旧结果,必要时重新读取”的简单 trade-off。 +```bash +cd learn-claude-code +python s08_context_compact/code.py +``` -### 完整常量参考 +可以试这几个任务: -| 常量 | 值 | 源文件 | -|------|-----|--------| -| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` | -| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` | -| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` | -| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` | -| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` | -| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` | -| 时间 micro_compact 间隔 | 60 分钟 | `timeBasedMCConfig.ts` | -| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` | +```text +Read README.md, then read code.py, then read s01_agent_loop/README.md +``` -### contextCollapse 和 sessionMemoryCompact +观察较早的工具结果是否被换成占位符。 -Claude Code 源码中还有两个机制本教学版没有展开: +```text +Read every file in s08_context_compact/ +``` -- **contextCollapse**:独立的上下文管理系统,启用时抑制 proactive autocompact(`autoCompact.ts:215-222`),由 collapse 的 commit/blocking 流程接管上下文管理。但 manual `/compact` 和 reactive fallback 仍是独立路径,不受 contextCollapse 影响。 -- **sessionMemoryCompact**:compact_history 之前,Claude Code 会先尝试用已有的 session memory(s09 会讲到)做轻量摘要,不调 LLM。这个机制等学完 s09 之后回头看会更清楚。 +观察大输出是否被转存到磁盘。 -### 压缩 prompt 长什么样? +```text +Keep discussing and editing for more than 20 turns +``` -Claude Code 的压缩 prompt 有两个硬性要求: +观察上下文接近上限时,是否触发摘要。 -1. **绝对禁止调用工具**:开头就是 `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`,末尾还会再 REMINDER 一次 -2. **先分析再总结**:模型需要先在 `` 标签里理清思路,然后在 `` 标签里输出正式摘要。analysis 在格式化时被剥离 +--- -### 教学版的简化是刻意的 +## 小结 -- micro_compact 用文本占位 → 我们没有 API 层的 `cache_edits` 权限 -- read_file 不特殊处理 → 教学版接受必要时重新读取,避免引入 readFileState 和后压缩恢复机制 -- token 用字符数估算 → 精确 tokenizer 不在教学范围内 -- 后压缩恢复省略 → 教学版只保留摘要,不自动重新附加文件 -- 两个辅助机制不展开 → 属于 10% 的细节 +Context Compact 的核心原则只有一句: -核心设计思想完整保留。 +> 能整理就先整理,能恢复就别摘要;实在不够,再让模型总结历史对话。 -
+s08 让长任务可以继续。 +s09 要解决下一个问题:哪些信息值得长期留下来。 - + diff --git a/s08_context_compact/code.py b/s08_context_compact/code.py index 6a44e6e9b..e576df712 100644 --- a/s08_context_compact/code.py +++ b/s08_context_compact/code.py @@ -2,25 +2,25 @@ """ s08_context_compact.py - Context Compact -Four-layer compaction pipeline inserted before LLM calls: +Four-step compaction pipeline inserted before LLM calls, in execution order: - L1: snip_compact — trim middle messages when count > 50 - L2: micro_compact — replace old tool_results with placeholders - L3: tool_result_budget — persist large results to disk - L4: compact_history — LLM full summary (1 API call) + Step 1: tool_result_budget — persist large results to disk + Step 2: snip_compact — trim middle messages when count > 50 + Step 3: micro_compact — replace old tool_results with placeholders + Step 4: compact_history — LLM full summary (1 API call) Emergency: reactive_compact — when API still returns prompt_too_long ┌─────────────────────────────────────────────────────────────┐ │ messages[] │ │ ↓ │ - │ L3 budget ─→ L1 snip ─→ L2 micro ─→ [token > threshold?] │ - │ ├─ No → LLM │ - │ └─ Yes → L4 summary │ - │ ↓ │ - │ LLM call │ - │ [prompt_too_long?] │ - │ └─ Yes → reactive │ + │ 1 budget ─→ 2 snip ─→ 3 micro ─→ [token > threshold?] │ + │ ├─ No → LLM │ + │ └─ Yes → 4 summary │ + │ ↓ │ + │ LLM call │ + │ [prompt_too_long?] │ + │ └─ Yes → reactive │ └─────────────────────────────────────────────────────────────┘ Core principle: cheap first, expensive last. @@ -259,7 +259,7 @@ def spawn_subagent(description: str) -> str: # ═══════════════════════════════════════════════════════════ -# NEW in s08: Four-Layer Compaction Pipeline +# NEW in s08: Four-Step Compaction Pipeline # ═══════════════════════════════════════════════════════════ CONTEXT_LIMIT = 50000 @@ -291,25 +291,34 @@ def _is_tool_result_message(msg): for block in content) -# L1: snipCompact — trim middle messages +# Step 2: snip_compact — trim middle messages +def safe_head(messages, keep_head): + # extend the cut so a trailing tool_use keeps its tool_result(s) + end = keep_head + if end > 0 and _message_has_tool_use(messages[end - 1]): + while end < len(messages) and _is_tool_result_message(messages[end]): + end += 1 + return messages[:end] + +def safe_tail(messages, keep_tail): + # move the cut back so the tail never starts with an orphaned tool_result + start = max(0, len(messages) - keep_tail) + if (start > 0 and start < len(messages) + and _is_tool_result_message(messages[start]) + and _message_has_tool_use(messages[start - 1])): + start -= 1 + return messages[start:] + def snip_compact(messages, max_messages=50): if len(messages) <= max_messages: return messages - keep_head, keep_tail = 3, max_messages - 3 - head_end, tail_start = keep_head, len(messages) - keep_tail - if head_end > 0 and _message_has_tool_use(messages[head_end - 1]): - while head_end < len(messages) and _is_tool_result_message(messages[head_end]): - head_end += 1 - if (tail_start > 0 and tail_start < len(messages) - and _is_tool_result_message(messages[tail_start]) - and _message_has_tool_use(messages[tail_start - 1])): - tail_start -= 1 - if head_end >= tail_start: - return messages - snipped = tail_start - head_end - return messages[:head_end] + [{"role": "user", "content": f"[snipped {snipped} messages]"}] + messages[tail_start:] - - -# L2: microCompact — old result placeholders + head = safe_head(messages, 3) + tail = safe_tail(messages, max_messages - 3) + snipped = len(messages) - len(head) - len(tail) + if snipped <= 0: return messages + return head + [{"role": "user", "content": f"[snipped {snipped} messages]"}] + tail + + +# Step 3: micro_compact — old result placeholders def collect_tool_results(messages): blocks = [] for mi, msg in enumerate(messages): @@ -328,7 +337,7 @@ def micro_compact(messages): return messages -# L3: toolResultBudget — persist large results to disk +# Step 1: tool_result_budget — persist large results to disk def persist_large_output(tool_use_id, output): if len(output) <= PERSIST_THRESHOLD: return output TOOL_RESULTS_DIR.mkdir(parents=True, exist_ok=True) @@ -353,7 +362,7 @@ def tool_result_budget(messages, max_bytes=200_000): return messages -# L4: autoCompact — LLM full summary +# Step 4: compact_history — LLM full summary def write_transcript(messages): TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True) path = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl" @@ -379,16 +388,12 @@ def compact_history(messages): return [{"role": "user", "content": f"[Compacted]\n\n{summary}"}] -# Emergency: reactiveCompact — on API error +# Emergency: reactive_compact — on API error def reactive_compact(messages): transcript = write_transcript(messages) - tail_start = max(0, len(messages) - 5) - if (tail_start > 0 and tail_start < len(messages) - and _is_tool_result_message(messages[tail_start]) - and _message_has_tool_use(messages[tail_start - 1])): - tail_start -= 1 - summary = summarize_history(messages[:tail_start]) - return [{"role": "user", "content": f"[Reactive compact]\n\n{summary}"}, *messages[tail_start:]] + tail = safe_tail(messages, 5) + summary = summarize_history(messages[:len(messages) - len(tail)]) + return [{"role": "user", "content": f"[Reactive compact]\n\n{summary}"}, *tail] # ═══════════════════════════════════════════════════════════ @@ -456,9 +461,9 @@ def agent_loop(messages: list): while True: # s08 change: three preprocessors (0 API calls, cheap first) # Order matches Claude Code source: budget → snip → micro - messages[:] = tool_result_budget(messages) # L3: persist large results first - messages[:] = snip_compact(messages) # L1: trim middle - messages[:] = micro_compact(messages) # L2: old result placeholders + messages[:] = tool_result_budget(messages) # Step 1: persist large results first + messages[:] = snip_compact(messages) # Step 2: trim middle + messages[:] = micro_compact(messages) # Step 3: old result placeholders # s08 change: tokens still over threshold → LLM summary (1 API call) if estimate_size(messages) > CONTEXT_LIMIT: diff --git a/s08_context_compact/images/auto-compact.svg b/s08_context_compact/images/auto-compact.svg index ca0c21d17..afe850a13 100644 --- a/s08_context_compact/images/auto-compact.svg +++ b/s08_context_compact/images/auto-compact.svg @@ -12,17 +12,17 @@ - L4: compact_history - Full LLM Summary — when L1-L3 are exhausted and tokens still exceed threshold + Step 4: compact_history + Full LLM Summary — after steps 1-3, when size still exceeds the limit Trigger Condition - L1-L3 pre-processing complete; estimated tokens > contextWindow - maxOutputTokens - 13,000 + Steps 1-3 complete; estimate_size(messages) > CONTEXT_LIMIT (real Claude Code: contextWindow - maxOutputTokens - 13,000) - Step 1 + Step 4a Save Transcript Full conversation written to @@ -38,14 +38,14 @@ - Step 2 + Step 4b LLM Generates Summary Send history to LLM Summary must include: - goals, concepts, files, errors, - resolutions, user msgs, TODOs, - current state, next steps + current goal, key findings/decisions, + files read/changed, remaining work, + user constraints @@ -53,13 +53,13 @@ - Step 3 + Step 4c Replace Messages All old msgs replaced with 1 summary message - Attaches recently_read - file list for re-access + Context now holds only + this one summary message diff --git a/s08_context_compact/images/compact-overview.svg b/s08_context_compact/images/compact-overview.svg index d8aa12a11..751d33410 100644 --- a/s08_context_compact/images/compact-overview.svg +++ b/s08_context_compact/images/compact-overview.svg @@ -36,21 +36,21 @@ - L3 tool_result_budget + Step 1: tool_result_budget - L1 snip_compact + Step 2: snip_compact - L2 micro_compact + Step 3: micro_compact @@ -74,7 +74,7 @@ - L4 compact_history + Step 4: compact_history diff --git a/s08_context_compact/images/compaction-layers.svg b/s08_context_compact/images/compaction-layers.svg index 02a6c551a..cde91e76d 100644 --- a/s08_context_compact/images/compaction-layers.svg +++ b/s08_context_compact/images/compaction-layers.svg @@ -12,7 +12,7 @@ - Context Compaction — Four-Layer Pipeline + Context Compaction — Four-Step Pipeline Cheap first, expensive last — 3 pre-processors (0 API) + 1 LLM summary + emergency fallback @@ -34,14 +34,14 @@ Pre-Processing Pipeline - (Execution order: L3 → L1 → L2 | runs before each LLM call | 0 API cost) + (Steps 1-3, in execution order | runs before each LLM call | 0 API cost) - L3 - toolResultBudget + 1 + tool_result_budget tool_result total > 200KB → persist largest items to disk Preserves full content in .task_outputs/ before placeholders @@ -59,14 +59,14 @@ - L1 - snipCompact + 2 + snip_compact messages > 50 → keep head 3 + tail 47, trim middle Boundary guard: tool_use must stay paired with tool_result len(msgs) > 50? - head[:3] + tail[47:] + safe_head + safe_tail 0 API @@ -78,13 +78,13 @@ - L2 - microCompact + 3 + micro_compact Old tool_result → one-line placeholder (keep most recent 3) Clears stale content while preserving recent working state - keep_recent = 3 + KEEP_RECENT = 3 older → placeholder @@ -103,10 +103,10 @@ - L4 - compactHistory - tokens > threshold → LLM summary → replace messages - Threshold: contextWindow - maxOutputTokens - 13K | 3-fail breaker + 4 + compact_history + size > limit → LLM summary → replace messages + Threshold: estimate_size(messages) > CONTEXT_LIMIT | 3-fail breaker write_transcript() @@ -129,8 +129,8 @@ EM - reactiveCompact - API returns 413 / prompt_too_long → aggressive byte-level trim + reactive_compact + API returns prompt_too_long → summarize head, keep tail Keep last 5 msgs + summary | Retry: 1 | More aggressive diff --git a/s08_context_compact/images/layer1-budget.svg b/s08_context_compact/images/layer1-budget.svg index d27b6dad4..fbdd5376d 100644 --- a/s08_context_compact/images/layer1-budget.svg +++ b/s08_context_compact/images/layer1-budget.svg @@ -12,7 +12,7 @@ - L3: toolResultBudget + Step 1: tool_result_budget Persist Large Results to Disk diff --git a/s08_context_compact/images/micro-compact.svg b/s08_context_compact/images/micro-compact.svg index 2fef06240..085625d79 100644 --- a/s08_context_compact/images/micro-compact.svg +++ b/s08_context_compact/images/micro-compact.svg @@ -9,7 +9,7 @@ - L2: micro_compact + Step 3: micro_compact Replace old tool_result with one-line placeholder @@ -46,9 +46,9 @@ - [Compacted. Re-run if needed.] - [Compacted. Re-run if needed.] - [Compacted. Re-run if needed.] + [Earlier tool result compacted. Re-run if needed.] + [Earlier tool result compacted. Re-run if needed.] + [Earlier tool result compacted. Re-run if needed.] ... 4 more compacted ... Read J: (full content, 2800 chars) @@ -60,7 +60,7 @@ How it works Iterate tool_result blocks; keep most recent 3 intact, replace older ones with placeholder. Scope - Applies to COMPACTABLE_TOOLS: Read, Bash, Grep, Glob, WebSearch, WebFetch, Edit, Write. + All tool_result blocks over 120 chars (real Claude Code limits this to a tool whitelist). Cost 0 API calls. Pure text replacement. If model needs old content, re-run the tool.
\ No newline at end of file diff --git a/web/public/course-assets/s08_context_compact/auto-compact.svg b/web/public/course-assets/s08_context_compact/auto-compact.svg index ca0c21d17..afe850a13 100644 --- a/web/public/course-assets/s08_context_compact/auto-compact.svg +++ b/web/public/course-assets/s08_context_compact/auto-compact.svg @@ -12,17 +12,17 @@ - L4: compact_history - Full LLM Summary — when L1-L3 are exhausted and tokens still exceed threshold + Step 4: compact_history + Full LLM Summary — after steps 1-3, when size still exceeds the limit Trigger Condition - L1-L3 pre-processing complete; estimated tokens > contextWindow - maxOutputTokens - 13,000 + Steps 1-3 complete; estimate_size(messages) > CONTEXT_LIMIT (real Claude Code: contextWindow - maxOutputTokens - 13,000) - Step 1 + Step 4a Save Transcript Full conversation written to @@ -38,14 +38,14 @@ - Step 2 + Step 4b LLM Generates Summary Send history to LLM Summary must include: - goals, concepts, files, errors, - resolutions, user msgs, TODOs, - current state, next steps + current goal, key findings/decisions, + files read/changed, remaining work, + user constraints @@ -53,13 +53,13 @@ - Step 3 + Step 4c Replace Messages All old msgs replaced with 1 summary message - Attaches recently_read - file list for re-access + Context now holds only + this one summary message diff --git a/web/public/course-assets/s08_context_compact/compact-overview.svg b/web/public/course-assets/s08_context_compact/compact-overview.svg index d8aa12a11..751d33410 100644 --- a/web/public/course-assets/s08_context_compact/compact-overview.svg +++ b/web/public/course-assets/s08_context_compact/compact-overview.svg @@ -36,21 +36,21 @@ - L3 tool_result_budget + Step 1: tool_result_budget - L1 snip_compact + Step 2: snip_compact - L2 micro_compact + Step 3: micro_compact @@ -74,7 +74,7 @@ - L4 compact_history + Step 4: compact_history diff --git a/web/public/course-assets/s08_context_compact/compaction-layers.svg b/web/public/course-assets/s08_context_compact/compaction-layers.svg index 02a6c551a..cde91e76d 100644 --- a/web/public/course-assets/s08_context_compact/compaction-layers.svg +++ b/web/public/course-assets/s08_context_compact/compaction-layers.svg @@ -12,7 +12,7 @@ - Context Compaction — Four-Layer Pipeline + Context Compaction — Four-Step Pipeline Cheap first, expensive last — 3 pre-processors (0 API) + 1 LLM summary + emergency fallback @@ -34,14 +34,14 @@ Pre-Processing Pipeline - (Execution order: L3 → L1 → L2 | runs before each LLM call | 0 API cost) + (Steps 1-3, in execution order | runs before each LLM call | 0 API cost) - L3 - toolResultBudget + 1 + tool_result_budget tool_result total > 200KB → persist largest items to disk Preserves full content in .task_outputs/ before placeholders @@ -59,14 +59,14 @@ - L1 - snipCompact + 2 + snip_compact messages > 50 → keep head 3 + tail 47, trim middle Boundary guard: tool_use must stay paired with tool_result len(msgs) > 50? - head[:3] + tail[47:] + safe_head + safe_tail 0 API @@ -78,13 +78,13 @@ - L2 - microCompact + 3 + micro_compact Old tool_result → one-line placeholder (keep most recent 3) Clears stale content while preserving recent working state - keep_recent = 3 + KEEP_RECENT = 3 older → placeholder @@ -103,10 +103,10 @@ - L4 - compactHistory - tokens > threshold → LLM summary → replace messages - Threshold: contextWindow - maxOutputTokens - 13K | 3-fail breaker + 4 + compact_history + size > limit → LLM summary → replace messages + Threshold: estimate_size(messages) > CONTEXT_LIMIT | 3-fail breaker write_transcript() @@ -129,8 +129,8 @@ EM - reactiveCompact - API returns 413 / prompt_too_long → aggressive byte-level trim + reactive_compact + API returns prompt_too_long → summarize head, keep tail Keep last 5 msgs + summary | Retry: 1 | More aggressive diff --git a/web/public/course-assets/s08_context_compact/layer1-budget.svg b/web/public/course-assets/s08_context_compact/layer1-budget.svg index d27b6dad4..fbdd5376d 100644 --- a/web/public/course-assets/s08_context_compact/layer1-budget.svg +++ b/web/public/course-assets/s08_context_compact/layer1-budget.svg @@ -12,7 +12,7 @@ - L3: toolResultBudget + Step 1: tool_result_budget Persist Large Results to Disk diff --git a/web/public/course-assets/s08_context_compact/micro-compact.svg b/web/public/course-assets/s08_context_compact/micro-compact.svg index 2fef06240..085625d79 100644 --- a/web/public/course-assets/s08_context_compact/micro-compact.svg +++ b/web/public/course-assets/s08_context_compact/micro-compact.svg @@ -9,7 +9,7 @@ - L2: micro_compact + Step 3: micro_compact Replace old tool_result with one-line placeholder @@ -46,9 +46,9 @@ - [Compacted. Re-run if needed.] - [Compacted. Re-run if needed.] - [Compacted. Re-run if needed.] + [Earlier tool result compacted. Re-run if needed.] + [Earlier tool result compacted. Re-run if needed.] + [Earlier tool result compacted. Re-run if needed.] ... 4 more compacted ... Read J: (full content, 2800 chars) @@ -60,7 +60,7 @@ How it works Iterate tool_result blocks; keep most recent 3 intact, replace older ones with placeholder. Scope - Applies to COMPACTABLE_TOOLS: Read, Bash, Grep, Glob, WebSearch, WebFetch, Edit, Write. + All tool_result blocks over 120 chars (real Claude Code limits this to a tool whitelist). Cost 0 API calls. Pure text replacement. If model needs old content, re-run the tool.
\ No newline at end of file diff --git a/web/src/data/generated/docs.json b/web/src/data/generated/docs.json index 6492a34c7..15cfe164e 100644 --- a/web/src/data/generated/docs.json +++ b/web/src/data/generated/docs.json @@ -117,7 +117,7 @@ "version": "s07", "locale": "zh", "title": "s07: Skill Loading — 用到的时候才加载", - "content": "# s07: Skill Loading — 用到的时候才加载\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/zh/s08) → s09 → ... → s20\n> *\"用到时再加载, 别全塞 prompt 里\"* — 通过 `tool_result` 注入, 不塞 system prompt。\n>\n> **Harness 层**: 知识 — 按需加载, 不堆满上下文。\n\n---\n\n## 问题\n\n上一章 Agent 能把大任务拆给子 Agent 了。但子 Agent 接手时,得知道这个任务的规矩:改 React 组件要守组件规范,写 SQL 要守风格指南。这些规范从哪来?\n\n你的项目里有一套 React 组件规范、一份 SQL 风格指南、一份 API 设计文档。最直接的想法,全塞进 system prompt:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read() # 2000 行\n + open(\"docs/sql-style.md\").read() # 1500 行\n + open(\"docs/api-design.md\").read() # 3000 行\n)\n```\n\n6500 行 system prompt。Agent 每次调用 LLM 都带着这些文档,不管在改 CSS 颜色还是修 SQL 查询。99% 的内容和当前任务无关,白白消耗 token。\n\n---\n\n## 解决方案\n\n![Skill Overview](/course-assets/s07_skill_loading/skill-overview.svg)\n\n一个折中的想法,是把文档拆成几个文件,用哪个让 Agent 自己 `read_file`。但 Agent 根本不知道有哪些文件可读,它得先知道\"有什么\",才谈得上\"用哪个\"。\n\n所以分两层:**目录常驻,内容按需。** 上一章的 hook 结构、`todo_write` 和子 Agent 都保留,本章新增一个 `load_skill` 工具。启动时把技能目录(名字 + 一句话描述)注入 SYSTEM prompt,每轮都带、但很轻;运行时 Agent 真要用某个技能,再调 `load_skill` 把完整内容取过来,用到才花那部分 token。\n\n两层设计:\n\n| 层 | 位置 | 时机 | 代价 |\n|---|------|------|------|\n| 1. 目录 | system prompt | 启动时注入(harness 扫描 skills/) | ~100 tokens/skill,每轮都带 |\n| 2. 内容 | tool_result | Agent 调用 load_skill 时;SKILL.md 可指引后续的 read_file/bash 调用,用于按需访问额外资源 | ~2000 tokens/skill,按需 |\n\ndispatch 机制不变,`load_skill` 通过 `TOOL_HANDLERS[block.name]` 分发。\n\n---\n\n## 工作原理\n\n**skills/ 目录**,每个技能一个子目录,包含 `SKILL.md` 文件:\n\n```\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n**第一级:启动时注入目录**:harness 启动时调用 `_scan_skills()` 扫描 skills/ 目录,解析每个 SKILL.md 的 YAML frontmatter(`name`、`description`),存入 `SKILL_REGISTRY` 字典。`list_skills()` 从注册表生成目录,注入 SYSTEM prompt。Agent 每轮都能看到\"我有哪些技能可用\",不花额外 API 调用:\n\n```python\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills() # runs once at startup\n\ndef list_skills() -> str:\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n```\n\n但 Agent 每轮只拿到名字和一句话描述,真要用 SQL 风格指南,那 1500 行的完整内容还够不到。→ 第二级。\n\n**第二级:load_skill**:Agent 决定\"我需要 SQL 风格指南\",调用 `load_skill(\"sql-style\")`。通过注册表查找,不走文件路径,没有路径遍历风险。SKILL.md 内容通过 `tool_result` 注入,并可通过现有的 file 和 bash 工具进一步访问引用的 `references/`、`scripts/` 或 `assets/`。\n\n```python\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n```\n\n关键区别:技能内容不是 system prompt 的一部分,它作为一次工具结果进入当前 `messages`。后续调用会随历史一起携带,直到上下文压缩、截断或会话结束。这和 s08 的 compact 自然衔接:技能内容以 `tool_result` 形式进入 `messages`,不塞 system prompt,compact 解决\"该丢的怎么丢\"。\n\n---\n\n## 相对 s06 的变更\n\n| 组件 | 之前 (s06) | 之后 (s07) |\n|------|-----------|-----------|\n| 工具数量 | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |\n| 知识加载 | 无 | 两级:启动时目录注入 SYSTEM + 运行时 load_skill;SKILL.md 可指引后续资源访问 |\n| SYSTEM 提示 | 静态字符串 | 启动时扫描 skills/ 注入目录 |\n| 技能注册表 | 无 | SKILL_REGISTRY(启动时填充,防路径遍历) |\n| 循环 | 不变 | 不变(skill 工具自动分发) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\n试试这些 prompt:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n\n观察重点:Agent 是否直接从 SYSTEM 里的目录知道有哪些技能?需要完整规范时是否出现 `[HOOK] load_skill`?加载后回答是否使用了对应 skill 的说明?\n\n---\n\n## 接下来\n\n`load_skill` 解决了启动时的 token 浪费。但另一个问题来了:Agent 连续工作 30 分钟后,`messages` 列表塞满了中间过程。旧的 `tool_result`、过时的文件内容,占着上下文但不产生价值。\n\ns08 Context Compact → 四层压缩策略。便宜的先跑,贵的后跑。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` 的分析。\n\n### 一、技能来源:不是只有一个 skills/ 目录\n\n教学版假设所有技能在 `skills/` 目录下。Claude Code 实际从多个来源加载,分布在多个文件中:`loadSkillsDir.ts` 负责从 user/project/`--add-dir` 目录和 legacy commands(`.claude/commands/`)加载;`bundledSkills.ts` 负责内置技能;`SkillTool.ts` 处理 MCP 远程技能;`commands.ts` 负责命令聚合。类型包括 managed/policy skills、user skills(`~/.claude/skills/`)、project skills(`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills(带 `paths` frontmatter,按文件路径激活)、bundled skills、plugin skills、MCP skills。\n\n### 二、SKILL.md Frontmatter 常见字段\n\nClaude Code 的 SKILL.md YAML frontmatter 由 `parseSkillFrontmatterFields()` 解析(`loadSkillsDir.ts`),常见字段包括:\n\n| 字段 | 用途 |\n|------|------|\n| `name` / `description` | 显示名称和描述 |\n| `when_to_use` | 指导模型何时调用 |\n| `allowed-tools` | 技能可用工具的自动允许列表 |\n| `context` | `inline`(默认)或 `fork`(作为子 Agent 运行) |\n| `model` | 模型覆盖(haiku/sonnet/opus/inherit) |\n| `hooks` | 技能级别的 hook 配置 |\n| `paths` | 条件激活的 glob 模式 |\n| `user-invocable` | 用户可以通过 `/name` 调用 |\n\n完整字段列表随版本迭代会变化,以上仅列出教学版涉及的核心字段。\n\n### 三、两级加载的精确实现\n\n1. **Catalog(启动时)**:`getSkillDirCommands()` 扫描目录 → 注册为 `Command` 对象,只包含元数据。`getSkillListingAttachments()` 把技能列表格式化为附件,预算为上下文窗口的 ~1%(上限 8000 字符)。\n2. **Load(调用时)**:模型调 `Skill` 工具(输入字段是 `skill` + 可选 `args`,教学版用 `name`)→ `getPromptForCommand()` 展开完整 SKILL.md 内容 → `SkillTool` 返回的 `tool_result` 展示文本只是 `\"Launching skill: {name}\"`,真正的技能内容通过 `newMessages` 注入对话。教学版把两者合并为\"通过 tool_result 注入\"是一种简化;加载后的 SKILL.md 仍可作为指引,帮助模型后续通过现有 file/bash 工具访问相关资源。\n\n### 教学版的简化是刻意的\n\n- 多文件多来源 → 1 个 `skills/` 目录:足以展示两级加载的核心概念\n- 多个 frontmatter 字段 → 只解析 name/description:减少解析复杂度\n- forked skills(`context: 'fork'`)→ 省略:教学版只展开 inline 技能加载\n- `Skill` 工具输入 `skill`+`args` → 教学版用 `name`:避免参数解析的额外复杂度\n\n
\n\n\n" + "content": "# s07: Skill Loading — 用到的时候才加载\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/zh/s08) → s09 → ... → s20\n> *\"用到时再加载, 别全塞 prompt 里\"* — 通过 `tool_result` 注入, 不塞 system prompt。\n>\n> **Harness 层**: 知识 — 按需加载, 不堆满上下文。\n\n---\n\n## 问题\n\n上一章 Agent 能把大任务拆给子 Agent 了。但子 Agent 接手时,得知道这个任务的规矩:改 React 组件要守组件规范,写 SQL 要守风格指南。这些规范从哪来?\n\n你的项目里有一套 React 组件规范、一份 SQL 风格指南、一份 API 设计文档。最直接的想法,全塞进 system prompt:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read() # 2000 行\n + open(\"docs/sql-style.md\").read() # 1500 行\n + open(\"docs/api-design.md\").read() # 3000 行\n)\n```\n\n6500 行 system prompt。Agent 每次调用 LLM 都带着这些文档,不管在改 CSS 颜色还是修 SQL 查询。99% 的内容和当前任务无关,白白消耗 token。\n\n---\n\n## 解决方案\n\n![Skill Overview](/course-assets/s07_skill_loading/skill-overview.svg)\n\n一个折中的想法,是把文档拆成几个文件,用哪个让 Agent 自己 `read_file`。但 Agent 根本不知道有哪些文件可读,它得先知道\"有什么\",才谈得上\"用哪个\"。\n\n所以分两层:**目录常驻,内容按需。** 上一章的 hook 结构、`todo_write` 和子 Agent 都保留,本章新增一个 `load_skill` 工具。启动时把技能目录(名字 + 一句话描述)注入 SYSTEM prompt,每轮都带、但很轻;运行时 Agent 真要用某个技能,再调 `load_skill` 把完整内容取过来,用到才花那部分 token。\n\n两层设计:\n\n| 层 | 位置 | 时机 | 代价 |\n|---|------|------|------|\n| 1. 目录 | system prompt | 启动时注入(harness 扫描 skills/) | ~100 tokens/skill,每轮都带 |\n| 2. 内容 | tool_result | Agent 调用 load_skill 时;SKILL.md 可指引后续的 read_file/bash 调用,用于按需访问额外资源 | ~2000 tokens/skill,按需 |\n\ndispatch 机制不变,`load_skill` 通过 `TOOL_HANDLERS[block.name]` 分发。\n\n---\n\n## 工作原理\n\n**skills/ 目录**,每个技能一个子目录,包含 `SKILL.md` 文件:\n\n```\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n**第一级:启动时注入目录**:harness 启动时调用 `_scan_skills()` 扫描 skills/ 目录,解析每个 SKILL.md 的 YAML frontmatter(`name`、`description`),存入 `SKILL_REGISTRY` 字典。`list_skills()` 从注册表生成目录,注入 SYSTEM prompt。Agent 每轮都能看到\"我有哪些技能可用\",不花额外 API 调用:\n\n```python\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills() # runs once at startup\n\ndef list_skills() -> str:\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n```\n\n但 Agent 每轮只拿到名字和一句话描述,真要用 SQL 风格指南,那 1500 行的完整内容还够不到。→ 第二级。\n\n**第二级:load_skill**:Agent 决定\"我需要 SQL 风格指南\",调用 `load_skill(\"sql-style\")`。通过注册表查找,不走文件路径,没有路径遍历风险。SKILL.md 内容通过 `tool_result` 注入,并可通过现有的 file 和 bash 工具进一步访问引用的 `references/`、`scripts/` 或 `assets/`。\n\n```python\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n```\n\n关键区别:技能内容不是 system prompt 的一部分,它作为一次工具结果进入当前 `messages`。后续调用会随历史一起携带,直到上下文压缩、截断或会话结束。这和 s08 的 compact 自然衔接:技能内容以 `tool_result` 形式进入 `messages`,不塞 system prompt,compact 解决\"该丢的怎么丢\"。\n\n---\n\n## 相对 s06 的变更\n\n| 组件 | 之前 (s06) | 之后 (s07) |\n|------|-----------|-----------|\n| 工具数量 | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |\n| 知识加载 | 无 | 两级:启动时目录注入 SYSTEM + 运行时 load_skill;SKILL.md 可指引后续资源访问 |\n| SYSTEM 提示 | 静态字符串 | 启动时扫描 skills/ 注入目录 |\n| 技能注册表 | 无 | SKILL_REGISTRY(启动时填充,防路径遍历) |\n| 循环 | 不变 | 不变(skill 工具自动分发) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\n试试这些 prompt:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n\n观察重点:Agent 是否直接从 SYSTEM 里的目录知道有哪些技能?需要完整规范时是否出现 `[HOOK] load_skill`?加载后回答是否使用了对应 skill 的说明?\n\n---\n\n## 接下来\n\n`load_skill` 解决了启动时的 token 浪费。但另一个问题来了:Agent 连续工作 30 分钟后,`messages` 列表塞满了中间过程。旧的 `tool_result`、过时的文件内容,占着上下文但不产生价值。\n\ns08 Context Compact → 四层压缩策略。便宜的先跑,贵的后跑。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `loadSkillsDir.ts`、`SkillTool.ts`、`bundledSkills.ts`、`commands.ts` 的分析。\n\n### 一、技能来源:不是只有一个 skills/ 目录\n\n教学版假设所有技能在 `skills/` 目录下。Claude Code 实际从多个来源加载,分布在多个文件中:`loadSkillsDir.ts` 负责从 user/project/`--add-dir` 目录和 legacy commands(`.claude/commands/`)加载;`bundledSkills.ts` 负责内置技能;`SkillTool.ts` 处理 MCP 远程技能;`commands.ts` 负责命令聚合。类型包括 managed/policy skills、user skills(`~/.claude/skills/`)、project skills(`.claude/skills/`)、`--add-dir` skills、legacy commands、dynamic skills、conditional skills(带 `paths` frontmatter,按文件路径激活)、bundled skills、plugin skills、MCP skills。\n\n### 二、SKILL.md Frontmatter 常见字段\n\nClaude Code 的 SKILL.md YAML frontmatter 由 `parseSkillFrontmatterFields()` 解析(`loadSkillsDir.ts`),常见字段包括:\n\n| 字段 | 用途 |\n|------|------|\n| `name` / `description` | 显示名称和描述 |\n| `when_to_use` | 指导模型何时调用 |\n| `allowed-tools` | 技能可用工具的自动允许列表 |\n| `context` | `inline`(默认)或 `fork`(作为子 Agent 运行) |\n| `model` | 模型覆盖(haiku/sonnet/opus/inherit) |\n| `hooks` | 技能级别的 hook 配置 |\n| `paths` | 条件激活的 glob 模式 |\n| `user-invocable` | 用户可以通过 `/name` 调用 |\n\n完整字段列表随版本迭代会变化,以上仅列出教学版涉及的核心字段。\n\n### 三、两级加载的精确实现\n\n1. **Catalog(启动时)**:`getSkillDirCommands()` 扫描目录 → 注册为 `Command` 对象,只包含元数据。`getSkillListingAttachments()` 把技能列表格式化为附件,预算为上下文窗口的 ~1%(上限 8000 字符)。\n2. **Load(调用时)**:模型调 `Skill` 工具(输入字段是 `skill` + 可选 `args`,教学版用 `name`)→ `getPromptForCommand()` 展开完整 SKILL.md 内容 → `SkillTool` 返回的 `tool_result` 展示文本只是 `\"Launching skill: {name}\"`,真正的技能内容通过 `newMessages` 注入对话。教学版把两者合并为\"通过 tool_result 注入\"是一种简化;加载后的 SKILL.md 仍可作为指引,帮助模型后续通过现有 file/bash 工具访问相关资源。\n\n### 教学版的简化是刻意的\n\n- 多文件多来源 → 1 个 `skills/` 目录:足以展示两级加载的核心概念\n- 多个 frontmatter 字段 → 只解析 name/description:减少解析复杂度\n- forked skills(`context: 'fork'`)→ 省略:教学版只展开 inline 技能加载\n- `Skill` 工具输入 `skill`+`args` → 教学版用 `name`:避免参数解析的额外复杂度\n\n
\n\n\n" }, { "version": "s07", @@ -128,20 +128,20 @@ { "version": "s08", "locale": "en", - "title": "s08: Context Compact — Context Will Fill Up, Have a Way to Make Room", - "content": "# s08: Context Compact — Context Will Fill Up, Have a Way to Make Room\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/en/s09) → s10 → ... → s20\n> *\"Context will fill up — have a way to make room\"* — Four-layer compaction pipeline: cheap first, expensive last.\n>\n> **Harness Layer**: Compaction — auto-summarize when context exceeds limits, keeping sessions sustainable.\n\n---\n\n## The Problem\n\nThe last chapter gave the agent Skills, so it picked up a bit of \"domain experience\": hand it a PDF, an MCP server, or a code review, and it loads the right playbook before acting.\n\nBut the more capable the agent gets, the worse a second problem becomes. It reads one 1000-line file (that's ~4000 tokens), then 30 more files, then runs 20 commands. Every command's output and every file's contents get appended back into the `messages` list, piling up turn after turn.\n\nA few dozen turns of ordinary chat is nothing. A coding agent is different: one read is thousands of lines, one test run is a wall of logs. The task isn't even done, and the context window may already be full.\n\nOnce it's full, the problem isn't \"the model answered a little worse.\" The API rejects the call outright: `prompt_too_long`. Without compaction, an agent simply can't work on a large project.\n\n---\n\n## The Solution\n\n![Compact Overview](/course-assets/s08_context_compact/compact-overview.svg)\n\nThe hook structure, skill loading, and subagent from s07 all stay; this chapter adds just one layer: before every LLM call, tidy up `messages` first.\n\nThe obvious idea is to let the model summarize once things fill up. But that has two problems. First, a summary costs an extra API call, and summarizing every time the context grows makes the bill climb fast. Second, not everything is worth summarizing: plenty of old tool results aren't needed anymore, and some content is merely big: a `cat` that dumps a few hundred KB of logs doesn't need to be *understood*, it just needs to move out of the context and be re-read if it ever matters again.\n\nSo compaction isn't one action, it's a pipeline. **Cheap first, expensive last**: run a few local passes that never call the model: trim what can be trimmed, swap placeholders in for old results, spill big outputs to disk. Only when none of that is enough do you let the LLM do a real summary.\n\n---\n\n## How It Works\n\n![Four-Layer Compaction Pipeline](/course-assets/s08_context_compact/compaction-layers.svg)\n\n### L1: snip_compact — Trim Irrelevant Old Conversation\n\nThe agent has run 80 turns and `messages` holds 160 entries. That opening \"create hello.py for me\" has almost nothing to do with the current work, yet it still takes up space.\n\nOnce the count passes 50 → keep the first 3 (the original task and constraints) and the last 47 (the current work), and cut the middle. The one boundary to respect: don't split an `assistant(tool_use)` from the `user(tool_result)` that follows it, or the model sees an orphaned result with no idea which call it belongs to.\n\n```python\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages: return messages\n keep_head, keep_tail = 3, max_messages - 3\n head_end, tail_start = keep_head, len(messages) - keep_tail\n if head_end > 0 and _message_has_tool_use(messages[head_end - 1]):\n while head_end < len(messages) and _is_tool_result_message(messages[head_end]):\n head_end += 1\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n if head_end >= tail_start: return messages\n snipped = tail_start - head_end\n return messages[:head_end] + [{\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}] + messages[tail_start:]\n```\n\nWhat gets cut is the messages themselves, with one guard at the seam. But in the messages that remain, `tool_result` content is still piling up, and message 34 might still be sitting on 30KB of an old file. Fewer messages, but not fewer tokens. → L2.\n\n### L2: micro_compact — Placeholder for Old Tool Results\n\n![Old Result Placeholders](/course-assets/s08_context_compact/micro-compact.svg)\n\nWhat blows up the context is usually not the conversation itself but the tool results. The agent read 10 files in a row; the full contents of files 1 through 7 stopped being useful long ago, yet they sit there verbatim.\n\nKeep the full content of the 3 most recent `tool_result`s and replace anything older with a one-line placeholder. The idea is plain: if an old result is really needed, the model can just read it again; it shouldn't hog space the whole time.\n\n```python\nKEEP_RECENT = 3\n\ndef micro_compact(messages):\n tool_results = collect_tool_results(messages)\n if len(tool_results) <= KEEP_RECENT: return messages\n for _, _, block in tool_results[:-KEEP_RECENT]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n```\n\nThe old results are cleared, but one case still slips through: a single new result can be 500KB on its own: one `cat` of a big file is enough to fill the context, and it's too fresh for micro_compact to touch. → L3.\n\n### L3: tool_result_budget — Persist Large Results to Disk\n\n![Persist Large Results](/course-assets/s08_context_compact/layer1-budget.svg)\n\nSome results aren't a problem of *many*, but of *one too big*. The model read 5 large files at once, and the `tool_result`s in that last user message add up to over 200KB, and keeping the 3 most recent doesn't help here, because the newest one alone can fill the context.\n\nGive tool results a budget. Add up the size of every `tool_result` in the last user message; if it's over 200KB, persist them to `.task_outputs/tool-results/` starting with the largest, leaving only a `` marker plus the first 2000 characters as a preview. The model sees the marker and knows the full content is on disk, to be re-read when needed.\n\n```python\ndef tool_result_budget(messages, max_bytes=200_000):\n last = messages[-1] if messages else None\n if not last or last.get(\"role\") != \"user\" or not isinstance(last.get(\"content\"), list): return messages\n blocks = [(i, b) for i, b in enumerate(last[\"content\"])\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes: return messages\n ranked = sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True)\n for _, block in ranked:\n if total <= max_bytes: break\n block[\"content\"] = persist_large_output(block.get(\"tool_use_id\", \"unknown\"), str(block.get(\"content\", \"\")))\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return messages\n```\n\nWhat matters here isn't discarding; it's moving content from \"active context\" to \"recoverable external storage\". That completes the first three layers: pure text/structure operations, 0 API calls, each watching one kind of bloat. But they share one limit: they can't read what the conversation is about, can't tell which findings matter or which constraints must stay. If the context is still too big, the model has to step in. → L4.\n\n### L4: compact_history — Full LLM Summary\n\n![Full LLM Summary](/course-assets/s08_context_compact/auto-compact.svg)\n\nAll three layers have run and the token count still tops the threshold. This is what most people picture as \"context compaction\": hand the history to the model and have it summarize into a shorter state.\n\nThree steps: first write the full conversation to `.transcripts/` (JSONL), so the active context keeps only the summary while the complete record stays on disk; then have the LLM produce a summary that preserves the current goal, key findings, files already changed, remaining work, and user constraints; finally replace all the old messages with that one summary.\n\n```python\ndef compact_history(messages):\n transcript_path = write_transcript(messages) # save the full conversation first\n summary = summarize_history(messages) # LLM generates the summary\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n```\n\nThis step is lossy: the transcript holds the full history, but the model can no longer see those details; it has only the summary to go on. That's why L1/L2/L3 run first: don't make the model summarize if you can avoid it, because once you do, detail is gone for good. The teaching version also adds a circuit breaker: stop after 3 consecutive compaction failures instead of burning API calls in a loop.\n\n### Reactive: reactive_compact\n\nNormally we tidy the context before calling the model. But when context grows too fast, or the token estimate is off, the API can still come back with `prompt_too_long`.\n\nThat's when reactive_compact kicks in: much like compact_history but more aggressive: save the transcript, summarize most of the front, and keep only the last 5 messages as tail context (again avoiding an orphaned `tool_result`).\n\n```python\ndef reactive_compact(messages):\n transcript = write_transcript(messages)\n tail_start = max(0, len(messages) - 5)\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n summary = summarize_history(messages[:tail_start])\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *messages[tail_start:]]\n```\n\nReactive is the fallback, not the normal path: it retries once by default, and on another failure it raises rather than looping forever. The full error-recovery logic is left to s11.\n\n### Putting It All Together\n\nWire it all back into the Agent Loop: before each LLM call, run the three local passes, summarize if that's not enough, and fall back to the emergency path only if the call actually errors.\n\n```python\ndef agent_loop(messages):\n reactive_retries = 0\n while True:\n # three preprocessors (0 API calls), order: budget -> snip -> micro\n messages[:] = tool_result_budget(messages) # L3: persist large results\n messages[:] = snip_compact(messages) # L1: trim the middle\n messages[:] = micro_compact(messages) # L2: old result placeholders\n\n if estimate_size(messages) > CONTEXT_LIMIT: # still too big -> LLM summary (1 API call)\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000)\n except Exception as e:\n if \"prompt_too_long\" in str(e).lower() and reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = reactive_compact(messages) # emergency\n reactive_retries += 1\n continue\n raise\n # ... tool execution ...\n```\n\n**The order can't change.** L3 (budget) has to run before L2 (micro): micro replaces old large `tool_result`s with a one-line placeholder, so if it ran first, budget would never get to persist the full content. Save the big content first, then do the placeholdering and trimming. That's also why Claude Code's source puts `applyToolResultBudget` first.\n\n### The compact Tool — Let the Model Ask, Too\n\nBeyond automatic compaction, the model can ask for a tidy-up itself: when it feels the context is too long, or the task has shifted phase, it can call the `compact` tool. In the teaching version that tool triggers `compact_history`, then ends the current turn and starts fresh with the compacted context. It feels much like a manual `/compact`, except this time the model itself realized it was time.\n\n---\n\n## Changes From s07\n\n| Component | Before (s07) | After (s08) |\n|-----------|-------------|-------------|\n| Context management | None (context grows without bound) | Four-layer compaction pipeline + emergency |\n| New functions | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |\n| Tools | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) |\n| Loop | LLM call → tool execution | Run three preprocessors each round + threshold-triggered compact_history |\n| Design principle | Make the agent capable | Keep the agent from crashing on long runs |\n\nThis step doesn't add a *capability* so much as *stamina*: s07 made the agent better at specialized work, s08 keeps it from being dragged down by its own history on a long task.\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\nTry these prompts:\n\n1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md` (read several files in a row, watch L2 compact old results)\n2. `Read every file in s08_context_compact/` (read a lot at once, watch L3 persist to disk)\n3. Keep the conversation going 20+ turns, watch for `[auto compact]` or `[reactive compact]`\n\nWhat to watch: after each tool runs, does the old `tool_result` get replaced? Do large outputs get persisted? When tokens pass the threshold, does a summary get generated?\n\n---\n\n## What's Next\n\nCompaction lets the agent run a long time without crashing. But every compaction loses some detail: a preference the user stated earlier, a long-standing project constraint, a fact that matters across tasks. None of it is guaranteed to survive in the summary.\n\nCompaction answers \"the current session is nearly full, how do we keep going\". It doesn't answer \"which information is worth keeping for the long haul\".\n\ns09 Memory → three subsystems: choosing what to remember, extracting the key information, and consolidating it. Across compactions, across sessions.\n\n
\nDeep Dive Into Claude Code Source Code\n\n> The following is based on analysis of Claude Code source code `compact.ts`, `autoCompact.ts`, `microCompact.ts`, and `query.ts`.\n\n### Execution Order Comparison\n\nThe teaching version labels layers L1/L2/L3/L4 for pedagogical clarity, but actual execution order does not match the numbering:\n\n| Dimension | Teaching Version | Claude Code |\n|-----------|-----------------|-------------|\n| Execution order | budget → snip → micro → auto | budget → snip → micro → collapse → auto (`query.ts:379-468`) |\n| snip_compact | Keep head 3 + tail 47 | Claude Code only enables on main thread; implementation not in open-source repo (`HISTORY_SNIP` feature gate), but interface is visible: `snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`, also exposes `SnipTool` for model-initiated snipping. Teaching version's 3/47 are simplified parameters |\n| micro_compact | Text placeholder replacement | Two paths: time-based clears content directly, cached uses API `cache_edits` (legacy path removed) |\n| micro_compact whitelist | By position (most recent 3) | time-based triggers by time threshold; cached triggers by count (`microCompact.ts`) |\n| tool_result_budget | 200KB characters | 200,000 characters (`toolLimits.ts:49`) |\n| compact_history threshold | Character count estimate | Precise tokens: `contextWindow - maxOutputTokens - 13_000` |\n| Summary requirements | 5 categories of info | 9 sections + ``/`` dual tags |\n| Compression prompt | Simple prompt | Double-ended hard guardrails forbidding tool calls |\n| PTL retry | Yes (simplified) | `truncateHeadForPTLRetry()` retreats by message groups (`compact.ts:243-290`) |\n| Post-compaction recovery | None (teaching version only keeps summary) | Auto re-read recent files, plans, agent/skill/tool context |\n| Circuit breaker | 3 times | 3 times (`autoCompact.ts:70`) |\n| Reactive retry | 1 time | Claude Code has more granular tiered retries |\n\n### Execution Order Details\n\nThe real order in Claude Code source `query.ts`:\n\n1. `applyToolResultBudget` (L379): persist large results first, ensuring full content is saved\n2. `snipCompact` (L403): trim middle messages\n3. `microcompact` (L414): old result placeholders\n4. `contextCollapse` (L441): independent context management system (not in teaching version)\n5. `autoCompact` (L454): LLM full summary\n\nThe teaching version's budget → snip → micro order matches this. The teaching version does not have the contextCollapse mechanism.\n\n### read_file Trade-off\n\nThe teaching version's `micro_compact` replaces old `tool_result` blocks with placeholders uniformly, including `read_file`. This usually does not affect functional correctness: if the model needs the file contents later, it can read the file again. The cost is an extra tool call and potentially lower prompt cache hit rates.\n\nClaude Code does not solve this with the teaching version's simple rule. It also puts `Read` in the microcompactable tool set, but maintains a separate `readFileState`: repeated reads of unchanged files return `FILE_UNCHANGED_STUB`, and after compaction it restores recently read file contents within a budget (for example, up to 5 files, 5K tokens per file, 50K tokens total). That is a production-level cache and recovery mechanism. The teaching version does not expand into that machinery; it keeps the simpler trade-off of compacting old results and re-reading when needed.\n\n### Full Constant Reference\n\n| Constant | Value | Source File |\n|----------|-------|-------------|\n| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |\n| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |\n| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |\n| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |\n| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |\n| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |\n| Time micro_compact interval | 60 minutes | `timeBasedMCConfig.ts` |\n| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |\n\n### contextCollapse and sessionMemoryCompact\n\nClaude Code source code has two additional mechanisms not covered in this teaching version:\n\n- **contextCollapse**: An independent context management system that, when enabled, suppresses proactive autocompact (`autoCompact.ts:215-222`), with collapse's commit/blocking flow taking over context management. Manual `/compact` and reactive fallback remain independent paths, unaffected by contextCollapse.\n- **sessionMemoryCompact**: Before compact_history, Claude Code first attempts a lightweight summary using existing session memory (covered in s09) without calling the LLM. This mechanism becomes clearer after learning s09.\n\n### What Does the Compression Prompt Look Like?\n\nClaude Code's compression prompt has two hard requirements:\n\n1. **Absolutely no tool calls**: It begins with `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`, and appends another REMINDER at the end\n2. **Analyze first, then summarize**: The model must first reason in an `` tag, then output the formal summary in a `` tag. The analysis is stripped during formatting\n\n### Teaching Version Simplifications Are Intentional\n\n- micro_compact uses text placeholders → we don't have API-level `cache_edits` access\n- read_file is not special-cased → the teaching version accepts re-reading when needed instead of introducing readFileState and post-compaction recovery\n- Tokens estimated via character count → precise tokenizers are out of scope\n- Post-compaction recovery omitted → teaching version only keeps summary, does not auto re-attach files\n- Two auxiliary mechanisms not covered → they fall in the 10% detail category\n\nThe core design principle is fully preserved.\n\n
\n\n\n" + "title": "s08: Context Compact — The Context Will Fill Up: Tidy First, Summarize Last", + "content": "# s08: Context Compact — The Context Will Fill Up: Tidy First, Summarize Last\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/en/s09) → s10 → ... → s20\n\n---\n\nOn a long task, reading one file can cost thousands of tokens, and one test run dumps another wall of logs. File contents, command output, tool results: everything gets appended back into `messages`, and the pile keeps growing.\n\nThe more context, the more the model's attention spreads thin; once it is truly full, the request simply fails: `prompt_too_long`.\n\nSo s08 solves one thing:\n\n> Keep the agent working through long tasks.\n\n![Context Compact overview](/course-assets/s08_context_compact/compact-overview.svg)\n\n---\n\n## Don't Start by Summarizing the History\n\nThe most intuitive move is to have the model summarize the history.\n\nBut that should not be the first step.\n\nA lot of content doesn't need summarizing: old logs, old file contents, tool results that have already served their purpose. They just take up space, and much of it no longer matters. For content like that, tidy first: persist to disk what can be persisted, replace with a placeholder what can be placeholdered, snip what can be snipped.\n\nOnly when all of that is done and the context is still close to the limit do you let the model generate a summary.\n\nThe reason is simple: the first three steps are mostly recoverable, while a summary is lossy. Once a summary replaces the history, the details are no longer in the current context.\n\n---\n\n## The Overall Flow\n\nBefore every model call, tidy `messages` once:\n\n```python\nmessages = tool_result_budget(messages) # park large results first\nmessages = snip_compact(messages) # trim the middle of the history\nmessages = micro_compact(messages) # placeholder older tool results\n\nif estimate_size(messages) > CONTEXT_LIMIT:\n messages = compact_history(messages) # still too big, only then summarize\n```\n\n![Four-step compaction pipeline](/course-assets/s08_context_compact/compaction-layers.svg)\n\n> The order is not arbitrary.\n>\n> In particular, `tool_result_budget` must run before `micro_compact`. `micro_compact` replaces old tool results with placeholders; if it ran first, the full content would already be gone, and there would be nothing left to persist.\n\n---\n\n## Step 1: tool_result_budget — Park Large Results First\n\nSometimes the problem isn't a long history but a single oversized tool result.\n\nSay the agent reads several large files at once: the last `tool_result` can easily exceed 200KB. It is the newest result, so it can't just be dropped; but it shouldn't sit in the context in full either.\n\nThe move: write the full content to disk, and keep only the path plus a short preview in context.\n\n![Park large results to disk](/course-assets/s08_context_compact/layer1-budget.svg)\n\n```python\ndef tool_result_budget(messages, max_bytes=200_000):\n blocks = [b for b in messages[-1][\"content\"] if b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b[\"content\"])) for b in blocks)\n\n if total <= max_bytes:\n return messages\n\n for block in sorted(blocks, key=lambda b: len(str(b[\"content\"])), reverse=True):\n block[\"content\"] = persist_large_output(block[\"tool_use_id\"], str(block[\"content\"]))\n total = sum(len(str(b[\"content\"])) for b in blocks)\n if total <= max_bytes:\n break\n\n return messages\n```\n\nThis step loses nothing. It only moves content from \"current context\" to disk.\n\nThe model can still see where the content was saved and roughly how it starts. If the full content is needed later, read it back.\n\n---\n\n## Step 2: snip_compact — Trim the Old Conversation\n\nWhen there are too many messages, keep the beginning and the end.\n\nThe beginning usually holds the original task and constraints; the end is what is being worked on right now. The old stretch in the middle can be replaced with a single note.\n\n```python\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages:\n return messages\n\n head = safe_head(messages, 3)\n tail = safe_tail(messages, max_messages - 3)\n snipped = len(messages) - len(head) - len(tail)\n\n return head + [\n {\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}\n ] + tail\n```\n\nOne thing to watch: never separate an `assistant` `tool_use` from its matching `tool_result`. Otherwise the model sees a tool result that came from nowhere, and the API rejects the request outright.\n\nThat is why `safe_head` and `safe_tail` are not plain slices: they move the cut away from such break points (see `code.py`).\n\nThis step reduces the number of messages.\n\nBut it does nothing about large content inside a single message. If an old `tool_result` still carries tens of KB of file content, it keeps occupying the context.\n\nSo the tool results still need tidying.\n\n---\n\n## Step 3: micro_compact — Replace Older Tool Results with Placeholders\n\nTool results usually take more space than the conversation itself.\n\nThe agent reads ten files in a row; the full contents of the first several rarely need to stay in context. Keeping the most recent few is enough. If an older result turns out to matter later, fetch it again.\n\n![Placeholder older results](/course-assets/s08_context_compact/micro-compact.svg)\n\n```python\nKEEP_RECENT = 3\n\ndef micro_compact(messages):\n results = collect_tool_results(messages)\n\n for _, _, block in results[:-KEEP_RECENT]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n\n return messages\n```\n\nThis step summarizes nothing. It just swaps older full results for a one-line note.\n\nIt handles \"too many tool results\", not \"still too big after tidying\". If the context is still over the limit at this point, the only option left is a model-generated summary.\n\n---\n\n## Step 4: compact_history — Still Over the Limit, Then Summarize\n\nIf the context is still too big after the first three steps, let the model summarize the history.\n\nThree things happen:\n\nWrite the full conversation to disk.\nHave the model generate a summary.\nReplace the old history with the summary.\n\n![Full LLM summary](/course-assets/s08_context_compact/auto-compact.svg)\n\n```python\ndef compact_history(messages):\n transcript_path = write_transcript(messages) # ① write the full conversation to disk\n summary = summarize_history(messages) # ② generate the summary\n return [{\n \"role\": \"user\",\n \"content\": f\"[Compacted]\\n\\n{summary}\", # ③ replace old history with the summary\n }]\n```\n\nThe summary is required to preserve five kinds of information: current goal, user constraints, key findings, files changed, next steps.\n\nThis step is the most effective, and the riskiest.\n\nThe full history is still on disk, but the model can now see only the summary. Any detail that didn't make it into the summary is, for every later turn, effectively invisible.\n\nThat is why summarizing must come last.\n\n---\n\n## Emergency Compaction After an Error\n\nNormally the context is tidied before the model is called.\n\nBut token estimates can be off, or one turn's tool output suddenly balloons, and the API may still return `prompt_too_long`. At that point, do one more aggressive pass: save the full record, squash most of the earlier history into a summary, and keep only the last few messages.\n\n```python\ndef reactive_compact(messages):\n write_transcript(messages)\n tail = safe_tail(messages, 5) # tail slice, same boundary guard\n summary = summarize_history(messages[:len(messages) - len(tail)])\n\n return [{\n \"role\": \"user\",\n \"content\": f\"[Reactive compact]\\n\\n{summary}\",\n }] + tail\n```\n\nThis is not the normal path.\n\nIt runs only after an error has already occurred, and it retries a limited number of times (once, in the teaching version). Otherwise, if the summary itself fails, you can end up retrying forever.\n\n---\n\n## Back Into the Agent Loop\n\nThe tidying logic ultimately plugs back into the agent loop.\n\n```python\ndef agent_loop(messages):\n reactive_retries = 0\n while True:\n messages[:] = tool_result_budget(messages)\n messages[:] = snip_compact(messages)\n messages[:] = micro_compact(messages)\n\n if estimate_size(messages) > CONTEXT_LIMIT:\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(\n model=MODEL, system=SYSTEM,\n messages=messages, tools=TOOLS, max_tokens=8000)\n except Exception as e:\n if \"prompt_too_long\" in str(e).lower() and reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = reactive_compact(messages)\n reactive_retries += 1\n continue\n raise\n\n # ... run tools, append results back into messages ...\n```\n\nWhat matters most here is the order:\n\n```text\npark large results → trim middle history → placeholder older results → still over the limit, then summarize\n```\n\nThe first three steps involve no model at all; they mostly clear space. Step 4 actually rewrites history, which is why it must come last.\n\n---\n\n## The compact Tool: Let the Model Ask\n\nBesides automatic tidying, the model can be given a `compact` tool.\n\nWhen the model notices the context getting long, or the task moving into a new phase, it can call the tool itself. The program then runs `compact_history`, ends the current turn, and starts the next one with the compacted context.\n\nThis way compaction isn't only program-triggered; the model can also request it at the right moment.\n\n---\n\n## Changes from s07\n\n| Component | s07 | s08 |\n|-----------|-----|-----|\n| Context management | None | Tidy before every model call |\n| Tool results | Stay in context forever | Large ones persisted, old ones placeholdered |\n| Message history | Accumulates forever | Middle history can be snipped |\n| Over the limit | Request fails | Tidy first, summarize only if needed |\n| New tool | None | `compact` |\n\ns07 made the agent better at its work.\ns08 keeps the agent from being crushed by its own history on long tasks.\n\n---\n\n## Try It\n\n```bash\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\nTasks to try:\n\n```text\nRead README.md, then read code.py, then read s01_agent_loop/README.md\n```\n\nWatch whether older tool results get replaced with placeholders.\n\n```text\nRead every file in s08_context_compact/\n```\n\nWatch whether large outputs get persisted to disk.\n\n```text\nKeep discussing and editing for more than 20 turns\n```\n\nWatch whether a summary is triggered as the context nears the limit.\n\n---\n\n## Recap\n\nThe core principle of Context Compact fits in one line:\n\n> Tidy what you can, don't summarize what you can recover; only when that's still not enough, have the model summarize the history.\n\ns08 lets long tasks continue.\ns09 tackles the next question: which information deserves to be kept for the long haul.\n\n\n" }, { "version": "s08", "locale": "zh", - "title": "s08: Context Compact — 上下文总会满,要有办法腾地方", - "content": "# s08: Context Compact — 上下文总会满,要有办法腾地方\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/zh/s09) → s10 → ... → s20\n> *\"上下文总会满, 要有办法腾地方\"* — 四层压缩管线,便宜的先跑,贵的后跑。\n>\n> **Harness 层**:压缩 — 上下文接近上限时先整理、再摘要,让长任务能持续跑下去。\n\n---\n\n## 问题\n\n上一章给 Agent 加了 Skills。它开始有了一点\"领域经验\":遇到 PDF、MCP server 或代码审查任务,会先加载对应的操作说明,再开始动手。\n\n但 Agent 越能干,另一个问题就越明显。它读一个 1000 行文件,可能就是约 4000 个 token;再读 30 个文件、跑 20 条命令,上下文很快就会被堆满。每条命令的输出、每个文件的内容,都被塞回 `messages` 列表,一点点堆起来。\n\n普通聊天聊几十轮,通常问题不大。但代码 Agent 不一样:一次读文件就是几千行,一次跑测试就是一大段日志。任务还没做完,上下文窗口可能先满了。\n\n上下文满了之后,问题不是\"模型回答质量变差一点\",而是 API 会直接拒绝请求:`prompt_too_long`。不压缩,Agent 根本没法在大项目里干活。\n\n---\n\n## 解决方案\n\n![Compact Overview](/course-assets/s08_context_compact/compact-overview.svg)\n\ns07 里的 hook 结构、技能加载、子 Agent 骨架都保留。s08 只加一层:每次调用 LLM 之前,先整理 `messages`。\n\n最直觉的做法,是上下文快满时让模型总结一下。但这里有两个问题。一是总结要多花一次 API 调用,只要上下文变大就摘要,成本很快就会上去。二是并不是所有内容都值得总结:很多旧工具结果早就不需要了;有些内容只是大,比如一次 `cat` 出几百 KB 日志,它不一定需要被模型\"理解\",更多时候只是需要先从上下文里挪出去,必要时再读回来。\n\n所以,上下文压缩(compact)不是一个单点动作,而是一条管线。**便宜的先跑,贵的后跑**:先做几步不调模型的本地整理,能裁掉的先裁掉,能占位的先占位,能落盘的先落盘;只有这些都不够,才让 LLM 做一次真正的摘要。\n\n---\n\n## 工作原理\n\n![四层压缩管线](/course-assets/s08_context_compact/compaction-layers.svg)\n\n### L1: snip_compact — 裁掉中间的旧对话\n\nAgent 跑了 80 轮,`messages` 攒了 160 条。最开始那句\"帮我创建 hello.py\",和当前工作几乎已经没关系了,但还在上下文里占着位置。\n\n当消息数超过 50 条时,保留头部 3 条(最初的任务和约束)以及尾部 47 条(当前工作),中间部分裁掉。唯一要小心的边界:不能把 `assistant(tool_use)` 和后面的 `user(tool_result)` 拆开,否则模型会看到一条凭空出现的 `tool_result`,却不知道它对应哪一次工具调用。\n\n```python\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages: return messages\n keep_head, keep_tail = 3, max_messages - 3\n head_end, tail_start = keep_head, len(messages) - keep_tail\n if head_end > 0 and _message_has_tool_use(messages[head_end - 1]):\n while head_end < len(messages) and _is_tool_result_message(messages[head_end]):\n head_end += 1\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n if head_end >= tail_start: return messages\n snipped = tail_start - head_end\n return messages[:head_end] + [{\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}] + messages[tail_start:]\n```\n\n裁掉的是消息本身,在切口处多做一步保护。但剩下的消息里,`tool_result` 内容仍在累积,第 34 条消息里可能还躺着 30KB 的旧文件内容。消息数是少了,但 token 未必真的少。→ L2。\n\n### L2: micro_compact — 旧工具结果占位\n\n![旧结果占位](/course-assets/s08_context_compact/micro-compact.svg)\n\n最容易把上下文撑大的,往往不是对话本身,而是工具结果。Agent 连续读了 10 个文件,第 1 到第 7 个的完整内容早就不需要了,却还原样躺在上下文里。\n\n保留最近 3 条 `tool_result` 的完整内容,更早的换成一行占位符。想法很朴素:旧结果如果真有用,模型可以重新读一次;它不应该一直占着上下文。\n\n```python\nKEEP_RECENT = 3\n\ndef micro_compact(messages):\n tool_results = collect_tool_results(messages)\n if len(tool_results) <= KEEP_RECENT: return messages\n for _, _, block in tool_results[:-KEEP_RECENT]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n```\n\n旧结果清掉了,但还挡不住一种情况:单条新结果就有 500KB,一次 `cat` 大文件的输出,就可能把上下文用满;但因为它是最新结果,`micro_compact` 不会处理它。→ L3。\n\n### L3: tool_result_budget — 大结果落盘\n\n![大结果落盘](/course-assets/s08_context_compact/layer1-budget.svg)\n\n有些问题不是结果太多,而是单条结果太大。比如模型一次读了 5 个大文件,最后一条 user 消息里的 `tool_result` 加起来超过 200KB。此时只保留最近 3 条也没用,因为最新结果本身就可能用满上下文。\n\n给工具结果设一个预算。统计最后一条 user 消息里所有 `tool_result` 的总大小,超过 200KB 就从最大的开始落盘到 `.task_outputs/tool-results/`,上下文里只留下一个 `` 标记,以及前 2000 个字符作为预览。模型看到这个标记就知道完整内容已经在磁盘上,后面需要时可以再读回来。\n\n```python\ndef tool_result_budget(messages, max_bytes=200_000):\n last = messages[-1] if messages else None\n if not last or last.get(\"role\") != \"user\" or not isinstance(last.get(\"content\"), list): return messages\n blocks = [(i, b) for i, b in enumerate(last[\"content\"])\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes: return messages\n ranked = sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True)\n for _, block in ranked:\n if total <= max_bytes: break\n block[\"content\"] = persist_large_output(block.get(\"tool_use_id\", \"unknown\"), str(block.get(\"content\", \"\")))\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return messages\n```\n\n这一步的关键不是丢弃内容,而是把内容从\"活跃上下文\"移到\"可恢复的外部存储\"。前三层到这就齐了:纯文本或结构操作,0 API 调用,各自处理一种上下文膨胀。但它们有同一个限制:不理解对话内容,判断不了哪些发现重要、哪些约束必须留下。如果上下文还是太大,就只能让模型出手。→ L4。\n\n### L4: compact_history — LLM 全量摘要\n\n![LLM 全量摘要](/course-assets/s08_context_compact/auto-compact.svg)\n\n前三层全跑完,token 还是超阈值。这一步才是很多人直觉里的\"上下文压缩\":把历史交给模型,压成一段更短的状态。\n\n三步:先把完整对话写进 `.transcripts/`(JSONL),这样活跃上下文里只保留摘要,但磁盘上仍然保存完整记录;再让 LLM 生成摘要,要求保留当前目标、重要发现、已改文件、剩余工作、用户约束;最后用这一条摘要替换掉所有旧消息。\n\n```python\ndef compact_history(messages):\n transcript_path = write_transcript(messages) # 先存完整对话\n summary = summarize_history(messages) # LLM 生成摘要\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n```\n\n这一步是有损的:transcript 里有完整历史,但模型当下看不到那些细节了,只能靠摘要继续。所以前面才要先跑 L1/L2/L3:能不让模型总结就不总结,因为一旦进入摘要,细节就不可避免会丢失。教学版还加了个熔断器:连续 compact 失败 3 次就停,避免无限重试浪费 API。\n\n### 应急:reactive_compact\n\n正常情况下,我们在调用模型之前就把上下文整理好。但如果上下文增长太快、或 token 估算不准,API 还是可能返回 `prompt_too_long`。\n\n这时就走 `reactive_compact`,它和 `compact_history` 很像,但更激进:先保存 transcript,再把前面大半段压成摘要,只保留最后 5 条作为尾部上下文(同样避免留下孤立 `tool_result`)。\n\n```python\ndef reactive_compact(messages):\n transcript = write_transcript(messages)\n tail_start = max(0, len(messages) - 5)\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n summary = summarize_history(messages[:tail_start])\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *messages[tail_start:]]\n```\n\nreactive 是兜底路径,不是常规路径,默认只重试 1 次;如果再次失败,就抛出异常,避免无限循环。完整的错误恢复逻辑留给 s11。\n\n### 合起来跑\n\n把这些机制接回 Agent Loop:每轮调用 LLM 之前,先跑三层本地整理;如果还不够,再做摘要;如果调用时真的报错,再走应急路径。\n\n```python\ndef agent_loop(messages):\n reactive_retries = 0\n while True:\n # 三个预处理器(0 API),顺序:budget → snip → micro\n messages[:] = tool_result_budget(messages) # L3: 大结果落盘\n messages[:] = snip_compact(messages) # L1: 裁中间\n messages[:] = micro_compact(messages) # L2: 旧结果占位\n\n if estimate_size(messages) > CONTEXT_LIMIT: # 还不够,LLM 摘要(1 API)\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000)\n except Exception as e:\n if \"prompt_too_long\" in str(e).lower() and reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = reactive_compact(messages) # 应急\n reactive_retries += 1\n continue\n raise\n # ... 工具执行 ...\n```\n\n**顺序不能换。** L3(budget)必须在 L2(micro)前面,`micro_compact` 会把旧的大 `tool_result` 替换成一行占位符。如果它先跑,`tool_result_budget` 就拿不到完整内容,也就没机会把它落盘。先 budget 把大内容存好,再做占位和裁剪。这也是 Claude Code 源码中 `applyToolResultBudget` 要放在最前面的原因。\n\n### compact 工具:让模型也能主动请求\n\n除了自动压缩,模型自己也能要求整理,当模型觉得上下文太长,或者任务已经切到新阶段时,可以主动调用 `compact` 工具。在教学版里,这个工具触发 `compact_history`,然后结束当前 turn,用压缩后的上下文重新开始下一轮。和手动 `/compact` 的感觉很像,区别在于,这次是模型自己意识到该整理上下文了。\n\n---\n\n## 相对 s07 的变更\n\n| 组件 | 之前 (s07) | 之后 (s08) |\n|------|-----------|-----------|\n| 上下文管理 | 无(上下文无限膨胀) | 四层压缩管线 + 应急 |\n| 新函数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |\n| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) |\n| 循环 | LLM 调用 → 工具执行 | 每轮前跑三层预处理器 + 阈值触发 compact_history |\n| 设计原则 | 让 Agent 会做事 | 让 Agent 运行久一点也不崩 |\n\n这一步不算是在给 Agent 加\"能力\",更像是在加\"体力\"。s07 让它更会做专业任务,s08 让它在长任务里不被自己的历史拖垮。\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\n试试这些 prompt:\n\n1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(连续读多个文件,观察 L2 压缩旧结果)\n2. `Read every file in s08_context_compact/`(一次性读大量内容,观察 L3 落盘)\n3. 反复对话 20+ 轮,观察是否出现 `[auto compact]` 或 `[reactive compact]`\n\n观察重点:每次工具执行后,旧 `tool_result` 是否被替换?大输出有没有落盘?token 超阈值时是否生成了摘要?\n\n---\n\n## 接下来\n\n压缩让 Agent 能跑很久不崩。但每次压缩都会丢一些细节:用户之前说过的偏好、项目里的长期约束、某些跨任务都重要的信息,不一定能完整留在摘要里。\n\ncompact 解决的是\"当前会话快满了,怎么继续跑下去\"。它没解决\"哪些信息值得长期留下来\"。\n\ns09 Memory → 三个子系统:选择记什么、提取关键信息、整理巩固。跨压缩、跨会话。\n\n
\n深入 Claude Code 源码\n\n> 以下基于 Claude Code 源码 `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` 的分析。\n\n### 执行顺序对照\n\n教学版为了讲解方便按 L1/L2/L3/L4 编号,但实际执行顺序和编号不完全对应:\n\n| 维度 | 教学版 | Claude Code |\n|------|--------|-------------|\n| 执行顺序 | budget → snip → micro → auto | budget → snip → micro → collapse → auto(`query.ts:379-468`) |\n| snip_compact | 保留头 3 + 尾 47 | Claude Code 仅主线程启用;实现不在开源仓库中(`HISTORY_SNIP` feature gate),但接口可见:`snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`,还暴露了 `SnipTool` 工具让模型主动调用。教学版的 3/47 是简化参数 |\n| micro_compact | 文本占位符替换 | 两条路径:time-based 直接清内容,cached 走 API `cache_edits`(legacy path 已移除) |\n| micro_compact 白名单 | 按位置(最近 3 条) | time-based 按时间阈值触发;cached 按计数触发(`microCompact.ts`) |\n| tool_result_budget | 200KB 字符 | 200,000 字符(`toolLimits.ts:49`) |\n| compact_history 阈值 | 字符数估算 | 精确 token:`contextWindow - maxOutputTokens - 13_000` |\n| 摘要要求 | 5 类信息 | 9 个部分 + ``/`` 双标签 |\n| 压缩 prompt | 简单 prompt | 首尾双重防呆禁止调工具 |\n| PTL retry | 有(简化) | `truncateHeadForPTLRetry()` 按消息组回退(`compact.ts:243-290`) |\n| 后压缩恢复 | 无(教学版只保留摘要) | 自动重新读取最近文件、计划、agent/skill/tool 等 |\n| 熔断器 | 3 次 | 3 次(`autoCompact.ts:70`) |\n| reactive 重试 | 1 次 | Claude Code 有更精细的分级重试 |\n\n### 执行顺序详解\n\nClaude Code 源码 `query.ts` 中的真实顺序:\n\n1. `applyToolResultBudget`(L379):先处理大结果,确保完整内容落盘\n2. `snipCompact`(L403):裁中间消息\n3. `microcompact`(L414):旧结果占位\n4. `contextCollapse`(L441):独立的上下文管理系统(教学版无)\n5. `autoCompact`(L454):LLM 全量摘要\n\n教学版的 budget → snip → micro 顺序与此一致。教学版没有 contextCollapse 机制。\n\n### read_file 的取舍\n\n教学版的 `micro_compact` 会把旧 `tool_result` 统一替换成占位符,包括 `read_file`。这通常不影响功能正确性:如果后续还需要文件内容,模型可以重新读一次。代价是可能多一次工具调用,也可能降低 prompt cache 命中率。\n\nClaude Code 没有用教学版这种简单规则解决这个问题。它把 `Read` 也放进可 microcompact 的工具集合,但同时维护 `readFileState`:重复读取未变化文件时返回 `FILE_UNCHANGED_STUB`,compact 后再按预算恢复最近读过的文件内容(例如最多 5 个文件、每个 5K token、总预算 50K token)。这是生产级实现里的缓存和恢复机制,教学版不展开,保留“压缩旧结果,必要时重新读取”的简单 trade-off。\n\n### 完整常量参考\n\n| 常量 | 值 | 源文件 |\n|------|-----|--------|\n| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |\n| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |\n| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |\n| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |\n| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |\n| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |\n| 时间 micro_compact 间隔 | 60 分钟 | `timeBasedMCConfig.ts` |\n| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |\n\n### contextCollapse 和 sessionMemoryCompact\n\nClaude Code 源码中还有两个机制本教学版没有展开:\n\n- **contextCollapse**:独立的上下文管理系统,启用时抑制 proactive autocompact(`autoCompact.ts:215-222`),由 collapse 的 commit/blocking 流程接管上下文管理。但 manual `/compact` 和 reactive fallback 仍是独立路径,不受 contextCollapse 影响。\n- **sessionMemoryCompact**:compact_history 之前,Claude Code 会先尝试用已有的 session memory(s09 会讲到)做轻量摘要,不调 LLM。这个机制等学完 s09 之后回头看会更清楚。\n\n### 压缩 prompt 长什么样?\n\nClaude Code 的压缩 prompt 有两个硬性要求:\n\n1. **绝对禁止调用工具**:开头就是 `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.`,末尾还会再 REMINDER 一次\n2. **先分析再总结**:模型需要先在 `` 标签里理清思路,然后在 `` 标签里输出正式摘要。analysis 在格式化时被剥离\n\n### 教学版的简化是刻意的\n\n- micro_compact 用文本占位 → 我们没有 API 层的 `cache_edits` 权限\n- read_file 不特殊处理 → 教学版接受必要时重新读取,避免引入 readFileState 和后压缩恢复机制\n- token 用字符数估算 → 精确 tokenizer 不在教学范围内\n- 后压缩恢复省略 → 教学版只保留摘要,不自动重新附加文件\n- 两个辅助机制不展开 → 属于 10% 的细节\n\n核心设计思想完整保留。\n\n
\n\n\n" + "title": "s08: Context Compact — 上下文总会满,先整理,再总结", + "content": "# s08: Context Compact — 上下文总会满,先整理,再总结\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/zh/s09) → s10 → ... → s20\n\n---\n\nAgent 在做长任务时,读一个文件,可能就是几千 token;跑一次测试,日志又是一大段。文件内容、命令输出、工具结果都会被塞回 `messages`,越堆越多。\n\n上下文越多,模型的注意力越分散;等到真正装满,请求会直接失败:`prompt_too_long`。\n\n所以 s08 要解决一件事:\n\n> 让 Agent 在长任务里能一直工作下去。\n\n![Context Compact 全景](/course-assets/s08_context_compact/compact-overview.svg)\n\n---\n\n## 不要一上来就总结历史对话\n\n最直觉的办法,是让模型把历史总结一下。\n\n但这不该是第一步。\n\n很多内容并不需要总结,比如旧日志、旧文件内容、已经用过的工具结果。它们只是占地方,不一定还重要。对这些内容,更合适的做法是先整理:能存到磁盘的先存到磁盘,能用占位符代替的先用占位符,能裁掉的先裁掉。\n\n这些都做完,上下文还是快要超限,才让模型生成摘要。\n\n原因也很简单:前三步基本可恢复,摘要是有损的。摘要一旦替换历史,细节就不在当前上下文里了。\n\n---\n\n## 整体流程\n\n每次调用模型前,先整理一次 `messages`:\n\n```python\nmessages = tool_result_budget(messages) # 大结果先存起来\nmessages = snip_compact(messages) # 中间旧对话裁掉\nmessages = micro_compact(messages) # 较早工具结果换成占位符\n\nif estimate_size(messages) > CONTEXT_LIMIT:\n messages = compact_history(messages) # 还不够,才摘要\n```\n\n![四步压缩管线](/course-assets/s08_context_compact/compaction-layers.svg)\n\n> 这个顺序不能随意调换。\n>\n> 尤其是 `tool_result_budget` 必须在 `micro_compact` 前面。因为 `micro_compact` 会把旧工具结果替换成占位符。如果它先跑,后面就拿不到完整内容,也就没法把大结果存起来。\n\n---\n\n## 第一步:tool_result_budget — 大结果先暂存\n\n有时不是历史太长,而是单条工具结果太大。\n\n比如 Agent 一次读了几个大文件,最后一条 `tool_result` 就可能超过 200KB。这个结果是最新的,不能简单丢掉;但它也不应该完整塞在上下文里。\n\n做法是:把完整内容写到磁盘,上下文里只留下路径和一小段预览。\n\n![大结果先暂存](/course-assets/s08_context_compact/layer1-budget.svg)\n\n```python\ndef tool_result_budget(messages, max_bytes=200_000):\n blocks = [b for b in messages[-1][\"content\"] if b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b[\"content\"])) for b in blocks)\n\n if total <= max_bytes:\n return messages\n\n for block in sorted(blocks, key=lambda b: len(str(b[\"content\"])), reverse=True):\n block[\"content\"] = persist_large_output(block[\"tool_use_id\"], str(block[\"content\"]))\n total = sum(len(str(b[\"content\"])) for b in blocks)\n if total <= max_bytes:\n break\n\n return messages\n```\n\n这一步不丢内容,只是把内容从\"当前上下文\"挪到磁盘。\n\n模型还能看到:这段内容已经保存在哪里、开头大概是什么样。后面真需要完整内容,再读回来即可。\n\n---\n\n## 第二步:snip_compact — 裁减旧对话\n\n消息太多时,可以保留开头和结尾。\n\n开头通常有原始任务和约束,结尾是当前正在做的事。中间那段旧历史,可以换成一条说明。\n\n```python\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages:\n return messages\n\n head = safe_head(messages, 3)\n tail = safe_tail(messages, max_messages - 3)\n snipped = len(messages) - len(head) - len(tail)\n\n return head + [\n {\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}\n ] + tail\n```\n\n这里需要注意:不能把 `assistant` 的 `tool_use` 和对应的 `tool_result` 拆开。否则模型会看到一条来历不明的工具结果,API 会直接报错。\n\n所以 `safe_head` 和 `safe_tail` 都不是简单切片,它们会避开这种断点(实现见 `code.py`)。\n\n这一步减少的是消息数量。\n\n但它不处理单条消息里的大内容。如果某条旧 `tool_result` 里还有几十 KB 的文件内容,它仍然会占上下文。\n\n所以还要继续整理工具结果。\n\n---\n\n## 第三步:micro_compact — 较早的工具结果换成占位符\n\n工具结果往往比对话更占地方。\n\nAgent 连续读了十个文件,前几个文件的完整内容通常已经不需要一直放在上下文里。保留最近几条就够了。更早的结果,如果之后真的有用,可以重新读取。\n\n![旧结果换占位符](/course-assets/s08_context_compact/micro-compact.svg)\n\n```python\nKEEP_RECENT = 3\n\ndef micro_compact(messages):\n results = collect_tool_results(messages)\n\n for _, _, block in results[:-KEEP_RECENT]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n\n return messages\n```\n\n这一步不会总结内容,只是把较早的完整结果换成一句说明。\n\n它适合处理\"工具结果很多\"的情况,但处理不了\"整理完还是太大\"的情况。到这里如果上下文仍然超限,就只能让模型生成摘要。\n\n---\n\n## 第四步:compact_history — 整理后仍超限,再生成摘要\n\n前三步都做完,如果上下文还是太大,才让模型摘要历史。\n\n这一步分三件事:\n\n先把完整对话写到磁盘。\n再让模型生成摘要。\n最后用摘要替换旧历史。\n\n![LLM 全量摘要](/course-assets/s08_context_compact/auto-compact.svg)\n\n```python\ndef compact_history(messages):\n transcript_path = write_transcript(messages) # ① 完整对话先写到磁盘\n summary = summarize_history(messages) # ② 生成摘要\n return [{\n \"role\": \"user\",\n \"content\": f\"[Compacted]\\n\\n{summary}\", # ③ 用摘要替换旧历史\n }]\n```\n\n摘要要求保留五类信息:当前目标、用户约束、重要发现、已修改文件、下一步工作。\n\n这一步最有效,也最具风险。\n\n虽然完整历史还在磁盘里,但是模型当前只能看到摘要。摘要中没有写进去的细节,对之后的每一轮来说就等于暂时看不见了。\n\n所以摘要一定要放在最后。\n\n---\n\n## 报错后的补救整理\n\n正常情况下,调用模型前就会把上下文整理好。\n\n但 token 估算可能不准,或者某一轮工具输出突然变得很大,接口仍然可能返回 `prompt_too_long`。这时再做一次更激进的整理:保存完整记录,把前面大部分历史压成摘要,只保留最后几条消息。\n\n```python\ndef reactive_compact(messages):\n write_transcript(messages)\n tail = safe_tail(messages, 5) # 尾部切片,同样避开断点\n summary = summarize_history(messages[:len(messages) - len(tail)])\n\n return [{\n \"role\": \"user\",\n \"content\": f\"[Reactive compact]\\n\\n{summary}\",\n }] + tail\n```\n\n这不是常规路径。\n\n它只在已经报错时使用,而且只重试有限次数(教学版是 1 次)。否则一旦摘要也失败,就可能陷入反复重试。\n\n---\n\n## 放回 Agent Loop\n\n整理逻辑最终要接回 Agent Loop。\n\n```python\ndef agent_loop(messages):\n reactive_retries = 0\n while True:\n messages[:] = tool_result_budget(messages)\n messages[:] = snip_compact(messages)\n messages[:] = micro_compact(messages)\n\n if estimate_size(messages) > CONTEXT_LIMIT:\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(\n model=MODEL, system=SYSTEM,\n messages=messages, tools=TOOLS, max_tokens=8000)\n except Exception as e:\n if \"prompt_too_long\" in str(e).lower() and reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = reactive_compact(messages)\n reactive_retries += 1\n continue\n raise\n\n # ... 执行工具,把结果塞回 messages ...\n```\n\n这里最重要的是顺序:\n\n```text\n大结果先存 → 中间旧对话裁掉 → 较早工具结果占位 → 仍然超限,再生成摘要\n```\n\n前三步都不需要模型参与,主要是在整理空间。第四步才是真的改写历史,所以必须放到最后。\n\n---\n\n## compact 工具:让模型自己提出整理\n\n除了自动整理,也可以给模型一个 `compact` 工具。\n\n当模型发现上下文太长,或者任务已经进入新阶段时,可以主动调用这个工具。调用后,程序执行 `compact_history`,结束当前轮,再用整理后的上下文开始下一轮。\n\n这样,整理不只由程序自动触发,也可以由模型在合适的时候主动提出。\n\n---\n\n## 相对 s07 的变化\n\n| 组件 | s07 | s08 |\n| ---------- | ---------------- | ---------------------- |\n| 上下文管理 | 无 | 每轮调用前先整理 |\n| 工具结果 | 一直留在上下文里 | 大结果转存,旧结果占位 |\n| 历史消息 | 一直累积 | 中间旧历史可省略 |\n| 超限处理 | 直接失败 | 先整理,不够再摘要 |\n| 新增工具 | 无 | `compact` |\n\ns07 让 Agent 更会做事。\ns08 让 Agent 在长任务里不被自己的历史拖垮。\n\n---\n\n## 试一下\n\n```bash\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\n可以试这几个任务:\n\n```text\nRead README.md, then read code.py, then read s01_agent_loop/README.md\n```\n\n观察较早的工具结果是否被换成占位符。\n\n```text\nRead every file in s08_context_compact/\n```\n\n观察大输出是否被转存到磁盘。\n\n```text\nKeep discussing and editing for more than 20 turns\n```\n\n观察上下文接近上限时,是否触发摘要。\n\n---\n\n## 小结\n\nContext Compact 的核心原则只有一句:\n\n> 能整理就先整理,能恢复就别摘要;实在不够,再让模型总结历史对话。\n\ns08 让长任务可以继续。\ns09 要解决下一个问题:哪些信息值得长期留下来。\n\n\n" }, { "version": "s08", "locale": "ja", - "title": "s08: Context Compact — コンテキストはいつか満杯になる、場所を空ける方法が必要", - "content": "# s08: Context Compact — コンテキストはいつか満杯になる、場所を空ける方法が必要\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/ja/s09) → s10 → ... → s20\n> *\"Context will fill up — have a way to make room\"* — 4層圧縮戦略、安価なものを先に、高価なものを後に実行。\n>\n> **Harness レイヤー**: 圧縮 — コンテキスト超過時に自動要約し、セッションを持続可能に保つ。\n\n---\n\n## 課題\n\n前章で Agent に Skills を加えた。少し「領域経験」を持ち始め、PDF や MCP、コードレビューに出くわすと、対応する操作説明を先に読み込んでから動くようになった。\n\nだが Agent が仕事をこなせるほど、別の問題が目立ってくる。1000 行のファイルを 1 つ読めば ~4000 token、さらに 30 個のファイルを読み、20 個のコマンドを走らせる。コマンドの出力もファイルの内容も、すべて `messages` リストに戻され、少しずつ積み上がる。\n\n普通のチャットなら数十ターンは何でもない。コードエージェントは違う。一度の読み取りが数千行、一度のテストが大量のログだ。タスクが終わらないうちに、コンテキストウィンドウが先に満杯になりかねない。\n\n満杯になると、問題は「モデルの答えが少し悪い」ではない。API がリクエストを直接拒否する:`prompt_too_long`。圧縮しなければ、Agent は大きなプロジェクトでまともに動けない。\n\n---\n\n## ソリューション\n\n![Compact Overview](/course-assets/s08_context_compact/compact-overview.svg)\n\ns07 の hook 構造、skill ロード、サブエージェントの骨格は残し、この章では一層だけ加える。LLM を呼ぶ前に、まず `messages` を整える。\n\nいちばん素直な発想は、満杯になったらモデルに要約させることだ。だがここには 2 つの問題がある。1 つ目、要約は API 呼び出しを 1 回余分に使う。コンテキストが大きくなるたびに要約していては、コストがすぐ膨らむ。2 つ目、すべての内容が要約に値するわけではない。古いツール結果の多くはとっくに不要だし、ただ大きいだけの内容もある。たとえば `cat` が数百 KB のログを吐いた場合、それは「理解」される必要はなく、コンテキストから外して必要なときに読み直せばいい。\n\nだから compact は 1 つの動作ではなく、1 本のパイプラインだ。**安いものを先に、高いものを後に**。まずモデルを呼ばないローカルな整理を数ステップ走らせる。切れるものは切り、プレースホルダにできるものは置き換え、ディスクに退避できるものは退避する。それでも足りないときに初めて、LLM に本当の要約をさせる。\n\n---\n\n## 仕組み\n\n![4層圧縮パイプライン](/course-assets/s08_context_compact/compaction-layers.svg)\n\n### L1: snip_compact — 無関係な古い会話を切り捨て\n\nAgent が 80 ターン走り、`messages` は 160 件たまった。先頭の「hello.py を作って」は今の作業とほぼ無関係になっているが、まだ場所を占めている。\n\nメッセージ数が 50 を超えたら → 先頭 3 件(最初のタスクと制約)と末尾 47 件(今の作業)を残し、中間を切る。気をつける境界は 1 つだけ:`assistant(tool_use)` とその後ろの `user(tool_result)` を切り離してはいけない。さもないとモデルは、どの呼び出しに対応するか分からない孤立した結果を見ることになる。\n\n```python\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages: return messages\n keep_head, keep_tail = 3, max_messages - 3\n head_end, tail_start = keep_head, len(messages) - keep_tail\n if head_end > 0 and _message_has_tool_use(messages[head_end - 1]):\n while head_end < len(messages) and _is_tool_result_message(messages[head_end]):\n head_end += 1\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n if head_end >= tail_start: return messages\n snipped = tail_start - head_end\n return messages[:head_end] + [{\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}] + messages[tail_start:]\n```\n\n切るのはメッセージそのもので、切れ目に保護を 1 つ入れるだけだ。だが残ったメッセージの中では、`tool_result` の内容がまだ積み上がっている。34 番目のメッセージには 30KB の古いファイル内容が眠っているかもしれない。メッセージ数は減っても、token は減っていない。→ L2。\n\n### L2: micro_compact — 古いツール結果をプレースホルダに置換\n\n![古い結果のプレースホルダ化](/course-assets/s08_context_compact/micro-compact.svg)\n\nコンテキストを膨らませる最大の原因は、会話そのものよりツール結果であることが多い。Agent が 10 個のファイルを続けて読んだとして、1 番目から 7 番目までの完全な内容はとっくに不要なのに、そのままコンテキストに居座っている。\n\n直近 3 件の `tool_result` の完全な内容を残し、それより古いものは 1 行のプレースホルダに置き換える。発想は素朴だ。古い結果が本当に要るなら、モデルがもう一度読めばいい。ずっと場所を占めるべきではない。\n\n```python\nKEEP_RECENT = 3\n\ndef micro_compact(messages):\n tool_results = collect_tool_results(messages)\n if len(tool_results) <= KEEP_RECENT: return messages\n for _, _, block in tool_results[:-KEEP_RECENT]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n```\n\n古い結果は片付いたが、まだ防げないケースがある。1 件の新しい結果だけで 500KB になることだ。大きなファイルを `cat` した出力 1 つでコンテキストを使い切ってしまう。しかもそれは新しすぎて、micro_compact は手を付けない。→ L3。\n\n### L3: tool_result_budget — 大きな結果をディスクに退避\n\n![大きな結果のディスク退避](/course-assets/s08_context_compact/layer1-budget.svg)\n\n結果には「多い」ではなく「1 件が大きすぎる」ものもある。モデルが大きなファイルを一度に 5 つ読み、最後の user メッセージ内の `tool_result` が合計 200KB を超える。こうなると直近 3 件を残しても無駄だ。最新の 1 件だけでコンテキストを使い切れるからだ。\n\nツール結果に予算を設ける。最後の user メッセージ内のすべての `tool_result` の合計サイズを数え、200KB を超えたら大きいものから `.task_outputs/tool-results/` に退避し、コンテキストには `` マーカーと先頭 2000 文字のプレビューだけを残す。モデルはマーカーを見れば完全な内容がディスク上にあると分かり、必要なときに読み戻せる。\n\n```python\ndef tool_result_budget(messages, max_bytes=200_000):\n last = messages[-1] if messages else None\n if not last or last.get(\"role\") != \"user\" or not isinstance(last.get(\"content\"), list): return messages\n blocks = [(i, b) for i, b in enumerate(last[\"content\"])\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes: return messages\n ranked = sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True)\n for _, block in ranked:\n if total <= max_bytes: break\n block[\"content\"] = persist_large_output(block.get(\"tool_use_id\", \"unknown\"), str(block.get(\"content\", \"\")))\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return messages\n```\n\nここで大事なのは捨てることではない。内容を「アクティブなコンテキスト」から「復元可能な外部ストレージ」へ移すことだ。これで最初の 3 層がそろう:純粋なテキスト/構造の操作、API 呼び出し 0、それぞれが 1 種類の冗長さを見張る。だが共通の限界がある:会話が何の話か読めない。どの発見が重要か、どの制約を残すべきか分からない。それでもコンテキストが大きすぎるなら、モデルに出てもらうしかない。→ L4。\n\n### L4: compact_history — LLM 全量要約\n\n![LLM 全量要約](/course-assets/s08_context_compact/auto-compact.svg)\n\n3 層を走らせ切っても、token はまだ閾値を超える。この一手こそ、多くの人が直感的に思い描く「コンテキスト圧縮」だ。履歴をモデルに渡し、より短い状態に要約させる。\n\n3 ステップ。まず完全な会話を `.transcripts/`(JSONL)に書き出す。アクティブなコンテキストには要約だけが残るが、ディスクには完全な記録が残る。次に LLM に要約を生成させ、現在の目標、重要な発見、変更済みのファイル、残りの作業、ユーザーの制約を保持するよう求める。最後にこの 1 件の要約で古いメッセージをすべて置き換える。\n\n```python\ndef compact_history(messages):\n transcript_path = write_transcript(messages) # 先に完全な会話を保存\n summary = summarize_history(messages) # LLM が要約を生成\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n```\n\nこの一手はロッシー(不可逆)だ:transcript には完全な履歴があるが、モデルは今やその細部を見られず、要約だけを頼りに続ける。だから先に L1/L2/L3 を走らせる:モデルに要約させずに済むなら済ませる。いったん要約に入れば、細部は取り返しなく失われるからだ。教学版にはサーキットブレーカーも加えてある:compact が 3 回連続で失敗したら止め、API 呼び出しを無限に浪費しない。\n\n### 緊急: reactive_compact\n\n通常は、モデルを呼ぶ前にコンテキストを整えておく。だがコンテキストの増加が速すぎたり、token の見積もりがずれたりすると、API はやはり `prompt_too_long` を返しうる。\n\nこのとき reactive_compact に入る。compact_history によく似ているが、より激しい:先に transcript を保存し、前半の大部分を要約し、末尾 5 件だけを末尾コンテキストとして残す(こちらも孤立した `tool_result` を残さないようにする)。\n\n```python\ndef reactive_compact(messages):\n transcript = write_transcript(messages)\n tail_start = max(0, len(messages) - 5)\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n summary = summarize_history(messages[:tail_start])\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *messages[tail_start:]]\n```\n\nreactive は通常パスではなくフォールバックだ。デフォルトでは 1 回だけリトライし、もう一度失敗したら無限ループせず例外を投げる。完全なエラー回復ロジックは s11 に譲る。\n\n### 合わせて実行\n\nこれらを Agent Loop に戻して組み込む。各ラウンドで LLM を呼ぶ前に 3 層のローカル整理を走らせ、足りなければ要約し、呼び出しが実際にエラーになったら緊急パスに回る。\n\n```python\ndef agent_loop(messages):\n reactive_retries = 0\n while True:\n # 3 つの前処理(API 呼び出し 0)、順序:budget -> snip -> micro\n messages[:] = tool_result_budget(messages) # L3: 大きな結果を退避\n messages[:] = snip_compact(messages) # L1: 中間を切る\n messages[:] = micro_compact(messages) # L2: 古い結果をプレースホルダ化\n\n if estimate_size(messages) > CONTEXT_LIMIT: # まだ足りない -> LLM 要約(API 1 回)\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000)\n except Exception as e:\n if \"prompt_too_long\" in str(e).lower() and reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = reactive_compact(messages) # 緊急\n reactive_retries += 1\n continue\n raise\n # ... ツール実行 ...\n```\n\n**順序は変えられない。** L3(budget)は L2(micro)より前でなければならない:micro は古い大きな `tool_result` を 1 行のプレースホルダに置き換えるので、もし先に走れば budget が完全な内容を退避する機会を失う。先に budget で大きな内容を保存し、それからプレースホルダ化と切り捨てを行う。これが Claude Code のソースが `applyToolResultBudget` を最前に置く理由でもある。\n\n### compact ツール — モデルからも要求できる\n\n自動圧縮のほかに、モデル自身が整理を要求することもできる。コンテキストが長すぎる、あるいはタスクの段階が切り替わったと感じたとき、モデルは能動的に `compact` ツールを呼べる。教学版ではこのツールが `compact_history` をトリガーし、現在の turn を終え、圧縮後のコンテキストで新たな 1 ラウンドを始める。手動の `/compact` によく似ているが、違いは今回モデル自身が整理どきだと気づいた点だ。\n\n---\n\n## s07 からの変更点\n\n| コンポーネント | 変更前 (s07) | 変更後 (s08) |\n|------|-----------|-----------|\n| コンテキスト管理 | なし(無制限に膨張) | 4 層圧縮パイプライン + 緊急 |\n| 新しい関数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |\n| ツール | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) |\n| ループ | LLM 呼び出し → ツール実行 | 各ラウンド前に 3 層の前処理 + 閾値で compact_history を起動 |\n| 設計原則 | Agent を仕事できるように | Agent が長く走っても崩れないように |\n\nこの一手は「能力」を加えるというより「体力」を加えるに近い:s07 は Agent を専門的な作業に強くし、s08 は長いタスクで自分の履歴に押しつぶされないようにする。\n\n---\n\n## 試してみよう\n\n```sh\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\n次の prompt を試してみよう:\n\n1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(複数ファイルを続けて読み、L2 が古い結果を圧縮する様子を観察)\n2. `Read every file in s08_context_compact/`(一度に大量に読み、L3 のディスク退避を観察)\n3. 会話を 20 ターン以上続け、`[auto compact]` や `[reactive compact]` が出るか観察\n\n観察ポイント:各ツール実行後、古い `tool_result` は置き換えられるか?大きな出力は退避されるか?token が閾値を超えたとき、要約は生成されるか?\n\n---\n\n## 次へ\n\n圧縮のおかげで Agent は長く走っても崩れない。だが圧縮のたびに細部がいくらか失われる:ユーザーが前に述べた好み、プロジェクトの長期的な制約、複数のタスクにまたがって重要な情報。どれも要約に完全に残る保証はない。\n\ncompact が答えるのは「今のセッションがもうすぐ満杯だ、どう走り続けるか」だ。「どの情報を長く残す価値があるか」には答えない。\n\ns09 Memory → 3 つのサブシステム:何を覚えるか選ぶ、重要な情報を抽出する、整理して定着させる。圧縮をまたぎ、セッションをまたいで。\n\n
\nClaude Code ソースコードの詳細\n\n> 以下は Claude Code ソースコード `compact.ts`、`autoCompact.ts`、`microCompact.ts`、`query.ts` の分析に基づく。\n\n### 実行順序の対応\n\n教学版は説明の便宜上 L1/L2/L3/L4 と番号を振っているが、実際の実行順序は番号と完全には一致しない:\n\n| 項目 | 教学版 | Claude Code |\n|------|--------|-------------|\n| 実行順序 | budget → snip → micro → auto | budget → snip → micro → collapse → auto(`query.ts:379-468`) |\n| snip_compact | 先頭 3 + 末尾 47 を保持 | Claude Code はメインスレッドのみ有効;実装はオープンソースリポジトリにない(`HISTORY_SNIP` feature gate)、インターフェースは確認可能:`snipCompactIfNeeded(messages)` → `{ messages, tokensFreed, boundaryMessage? }`、`SnipTool` もモデルが能動的に呼び出し可能。教学版の 3/47 は簡略パラメータ |\n| micro_compact | テキストプレースホルダで置換 | 2 つのパス:time-based は直接内容をクリア、cached は API の `cache_edits` を使用(legacy パスは削除済み) |\n| micro_compact ホワイトリスト | 位置による(直近 3 件) | time-based は時間閾値でトリガー、cached はカウントでトリガー(`microCompact.ts`) |\n| tool_result_budget | 200KB 文字 | 200,000 文字(`toolLimits.ts:49`) |\n| compact_history 閾値 | 文字数で推定 | 精密な token 数:`contextWindow - maxOutputTokens - 13_000` |\n| 要約の要求 | 5 種類の情報 | 9 つのセクション + ``/`` デュアルタグ |\n| 圧縮プロンプト | シンプルなプロンプト | 先頭と末尾に二重の安全ガードでツール呼び出しを禁止 |\n| PTL retry | あり(簡略版) | `truncateHeadForPTLRetry()` がメッセージグループ単位でロールバック(`compact.ts:243-290`) |\n| 圧縮後のリカバリ | なし(教学版は要約のみ保持) | 直近のファイル、計画、agent/skill/tool などの自動再付加 |\n| サーキットブレーカー | 3 回 | 3 回(`autoCompact.ts:70`) |\n| reactive リトライ | 1 回 | Claude Code にはより精緻な段階別リトライがある |\n\n### 実行順序の詳細\n\nClaude Code ソース `query.ts` での実際の順序:\n\n1. `applyToolResultBudget`(L379):まず大きな結果を処理し、完全な内容を退避\n2. `snipCompact`(L403):中間メッセージを切り捨て\n3. `microcompact`(L414):古い結果のプレースホルダ化\n4. `contextCollapse`(L441):独立したコンテキスト管理システム(教学版にはなし)\n5. `autoCompact`(L454):LLM 全量要約\n\n教学版の budget → snip → micro の順序はこれと一致する。教学版には contextCollapse メカニズムがない。\n\n### read_file のトレードオフ\n\n教学版の `micro_compact` は、古い `tool_result` を一律にプレースホルダへ置き換える。`read_file` も例外ではない。これは通常、機能的な正しさには影響しない。後でファイル内容が必要になれば、モデルはもう一度そのファイルを読めばよい。代償は、追加のツール呼び出しが発生し得ることと、prompt cache のヒット率が下がり得ること。\n\nClaude Code は、この問題を教学版のような単純なルールでは処理していない。`Read` も microcompact 可能なツール集合に入れる一方で、別途 `readFileState` を維持している。変更されていないファイルの再読込では `FILE_UNCHANGED_STUB` を返し、compact 後には予算内で直近に読んだファイル内容を復元する(例:最大 5 ファイル、1 ファイル 5K token、合計 50K token)。これは本番実装向けのキャッシュと復元メカニズムである。教学版ではそこまで展開せず、「古い結果を圧縮し、必要なら再読込する」という単純な trade-off を残している。\n\n### 完全な定数リファレンス\n\n| 定数 | 値 | ソースファイル |\n|------|-----|--------|\n| `AUTOCOMPACT_BUFFER_TOKENS` | 13,000 | `autoCompact.ts:62` |\n| `MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES` | 3 | `autoCompact.ts:70` |\n| `MAX_OUTPUT_TOKENS_FOR_SUMMARY` | 20,000 | `autoCompact.ts:30` |\n| `POST_COMPACT_TOKEN_BUDGET` | 50,000 | `compact.ts:123` |\n| `POST_COMPACT_MAX_FILES_TO_RESTORE` | 5 | `compact.ts:122` |\n| `POST_COMPACT_MAX_TOKENS_PER_FILE` | 5,000 | `compact.ts:124` |\n| 時間ベース micro_compact 間隔 | 60 分 | `timeBasedMCConfig.ts` |\n| `MAX_COMPACT_STREAMING_RETRIES` | 2 | `compact.ts:131` |\n\n### contextCollapse と sessionMemoryCompact\n\nClaude Code ソースコードには、この教学版では展開していない 2 つのメカニズムが存在する:\n\n- **contextCollapse**:独立したコンテキスト管理システム。有効時には proactive autocompact を抑制し(`autoCompact.ts:215-222`)、collapse の commit/blocking フローがコンテキスト管理を引き継ぐ。ただし manual `/compact` と reactive fallback は独立パスのままで、contextCollapse の影響を受けない。\n- **sessionMemoryCompact**:compact_history の前に、Claude Code は既存の session memory(s09 で解説)を使った軽量要約を先に試みる。LLM を呼び出さない。このメカニズムは s09 を学んだ後に振り返るとより理解しやすい。\n\n### 圧縮プロンプトの中身\n\nClaude Code の圧縮プロンプトには 2 つの厳格な要件がある:\n\n1. **ツール呼び出しの絶対禁止**:冒頭が `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.` で、末尾にも再度 REMINDER がある\n2. **先に分析してから要約**:モデルはまず `` タグで思考を整理し、その後 `` タグで正式な要約を出力する。analysis はフォーマット時に除去される\n\n### 教学版の簡略化は意図的\n\n- micro_compact でテキストプレースホルダを使用 → API 層の `cache_edits` 権限がないため\n- read_file は特別扱いしない → 教学版では必要時の再読込を受け入れ、readFileState と圧縮後復元の仕組みを導入しない\n- token を文字数で推定 → 精密な tokenizer は教学の対象外\n- 圧縮後のリカバリを省略 → 教学版は要約のみを保持し、ファイルの自動再付加を行わない\n- 2 つの補助メカニズムを展開しない → 10% の細部に属する\n\nコア設計思想は完全に保持されている。\n\n
\n\n\n" + "title": "s08: Context Compact — コンテキストはいつか満杯になる、まず整理、それから要約", + "content": "# s08: Context Compact — コンテキストはいつか満杯になる、まず整理、それから要約\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/ja/s09) → s10 → ... → s20\n\n---\n\n長いタスクでは、ファイルを 1 つ読むだけで数千 token、テストを 1 回走らせればログがまた大量に出る。ファイル内容、コマンド出力、ツール結果はすべて `messages` に戻され、どんどん積み上がっていく。\n\nコンテキストが増えるほどモデルの注意は散漫になり、本当に満杯になった時点でリクエストはそのまま失敗する:`prompt_too_long`。\n\nだから s08 が解決するのは一つ:\n\n> 長いタスクでも Agent が働き続けられるようにする。\n\n![Context Compact 全体像](/course-assets/s08_context_compact/compact-overview.svg)\n\n---\n\n## いきなり履歴を要約しない\n\n最も直感的なのは、モデルに履歴を要約させることだ。\n\nだが、それを最初の一手にしてはいけない。\n\n要約する必要のない内容は多い。古いログ、古いファイル内容、すでに役目を終えたツール結果。これらは場所を取っているだけで、もう重要とは限らない。こうした内容にはまず整理で対応する:ディスクに退避できるものは退避し、プレースホルダで置き換えられるものは置き換え、切り詰められるものは切り詰める。\n\nこれらをすべてやってもまだ上限に近いときに、はじめてモデルに要約を生成させる。\n\n理由は単純だ。前の 3 ステップはほぼ復元可能だが、要約は不可逆。要約が履歴を置き換えた瞬間、細部は現在のコンテキストから消える。\n\n---\n\n## 全体の流れ\n\nモデルを呼び出す前に、毎回 `messages` を一度整理する:\n\n```python\nmessages = tool_result_budget(messages) # 大きな結果を先に退避\nmessages = snip_compact(messages) # 中間の古い対話を切り詰め\nmessages = micro_compact(messages) # 古いツール結果をプレースホルダ化\n\nif estimate_size(messages) > CONTEXT_LIMIT:\n messages = compact_history(messages) # それでも足りなければ要約\n```\n\n![4 ステップ圧縮パイプライン](/course-assets/s08_context_compact/compaction-layers.svg)\n\n> この順序は入れ替えられない。\n>\n> 特に `tool_result_budget` は `micro_compact` より先に走らなければならない。`micro_compact` は古いツール結果をプレースホルダに置き換えるので、先に走ると完全な内容が手に入らなくなり、大きな結果を退避できなくなる。\n\n---\n\n## Step 1:tool_result_budget — 大きな結果を先に退避する\n\n問題は履歴の長さではなく、1 件のツール結果が大きすぎることもある。\n\nたとえば Agent が大きなファイルを何個か一気に読むと、最後の `tool_result` は 200KB を超えうる。最新の結果だから単純に捨てるわけにはいかない。だが、全文をコンテキストに置いておくべきでもない。\n\nやり方:全文をディスクに書き出し、コンテキストにはパスと短いプレビューだけを残す。\n\n![大きな結果をディスクへ退避](/course-assets/s08_context_compact/layer1-budget.svg)\n\n```python\ndef tool_result_budget(messages, max_bytes=200_000):\n blocks = [b for b in messages[-1][\"content\"] if b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b[\"content\"])) for b in blocks)\n\n if total <= max_bytes:\n return messages\n\n for block in sorted(blocks, key=lambda b: len(str(b[\"content\"])), reverse=True):\n block[\"content\"] = persist_large_output(block[\"tool_use_id\"], str(block[\"content\"]))\n total = sum(len(str(b[\"content\"])) for b in blocks)\n if total <= max_bytes:\n break\n\n return messages\n```\n\nこのステップは内容を失わない。「現在のコンテキスト」からディスクへ移すだけだ。\n\nモデルには、その内容がどこに保存されたか、冒頭がどんな様子かは見えている。後で全文が必要になったら読み戻せばいい。\n\n---\n\n## Step 2:snip_compact — 古い対話を切り詰める\n\nメッセージが多すぎるときは、先頭と末尾を残せばいい。\n\n先頭にはたいてい元のタスクと制約があり、末尾は今やっている作業だ。中間の古い履歴は、一行の説明に置き換えられる。\n\n```python\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages:\n return messages\n\n head = safe_head(messages, 3)\n tail = safe_tail(messages, max_messages - 3)\n snipped = len(messages) - len(head) - len(tail)\n\n return head + [\n {\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}\n ] + tail\n```\n\n注意点が一つ:`assistant` の `tool_use` と対応する `tool_result` を切り離してはいけない。切り離すと、モデルには出所不明のツール結果が見え、API はリクエストをそのまま拒否する。\n\nだから `safe_head` と `safe_tail` は単純なスライスではない。こうした断点を避けて切る(実装は `code.py`)。\n\nこのステップが減らすのはメッセージの数だ。\n\nだが 1 件のメッセージ内の大きな内容は扱えない。古い `tool_result` に数十 KB のファイル内容が残っていれば、それはコンテキストを占め続ける。\n\nだからツール結果の整理を続ける。\n\n---\n\n## Step 3:micro_compact — 古いツール結果をプレースホルダに置き換える\n\nコンテキストを膨らませるのは、対話そのものよりツール結果であることが多い。\n\nAgent がファイルを 10 個続けて読んだとき、最初の数個の全文をコンテキストに置き続ける必要はまずない。直近の数件を残せば足りる。より古い結果は、本当に必要になったら再取得すればいい。\n\n![古い結果をプレースホルダ化](/course-assets/s08_context_compact/micro-compact.svg)\n\n```python\nKEEP_RECENT = 3\n\ndef micro_compact(messages):\n results = collect_tool_results(messages)\n\n for _, _, block in results[:-KEEP_RECENT]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n\n return messages\n```\n\nこのステップは内容を要約しない。古い全文を一行の説明に置き換えるだけだ。\n\n「ツール結果が多い」場合には効くが、「整理してもまだ大きい」場合には効かない。ここまでやってなお超過しているなら、残る手はモデルによる要約だけだ。\n\n---\n\n## Step 4:compact_history — 整理しても超過するなら、要約する\n\n前の 3 ステップを終えてもコンテキストが大きすぎるなら、モデルに履歴を要約させる。\n\nやることは三つ:\n\nまず完全な対話をディスクに書き出す。\n次にモデルに要約を生成させる。\n最後に要約で古い履歴を置き換える。\n\n![LLM 全量要約](/course-assets/s08_context_compact/auto-compact.svg)\n\n```python\ndef compact_history(messages):\n transcript_path = write_transcript(messages) # ① 完全な対話をディスクへ\n summary = summarize_history(messages) # ② 要約を生成\n return [{\n \"role\": \"user\",\n \"content\": f\"[Compacted]\\n\\n{summary}\", # ③ 要約で古い履歴を置換\n }]\n```\n\n要約には 5 種類の情報を残すよう求める:現在の目標、ユーザーの制約、重要な発見、変更したファイル、次の作業。\n\nこのステップは最も効果的で、最もリスクが高い。\n\n完全な履歴はディスクに残っているが、モデルに今見えるのは要約だけだ。要約に書かれなかった細部は、以降のすべてのターンにとって、当面見えないのと同じになる。\n\nだから要約は必ず最後に置く。\n\n---\n\n## エラー後の応急整理\n\n通常は、モデルを呼び出す前にコンテキストを整理し終えている。\n\nだが token の見積もりが外れることも、あるターンのツール出力が突然膨らむこともあり、API は依然として `prompt_too_long` を返しうる。そのときはもう一段激しい整理をする:完全な記録を保存し、前方の大部分を要約に潰し、最後の数件だけを残す。\n\n```python\ndef reactive_compact(messages):\n write_transcript(messages)\n tail = safe_tail(messages, 5) # 末尾スライス、同じく断点を回避\n summary = summarize_history(messages[:len(messages) - len(tail)])\n\n return [{\n \"role\": \"user\",\n \"content\": f\"[Reactive compact]\\n\\n{summary}\",\n }] + tail\n```\n\nこれは通常経路ではない。\n\nすでにエラーが起きたときだけ使い、リトライ回数も限られる(教学版は 1 回)。さもないと、要約自体が失敗したときに無限リトライに陥りかねない。\n\n---\n\n## Agent Loop に組み戻す\n\n整理ロジックは最終的に Agent Loop へ戻す。\n\n```python\ndef agent_loop(messages):\n reactive_retries = 0\n while True:\n messages[:] = tool_result_budget(messages)\n messages[:] = snip_compact(messages)\n messages[:] = micro_compact(messages)\n\n if estimate_size(messages) > CONTEXT_LIMIT:\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(\n model=MODEL, system=SYSTEM,\n messages=messages, tools=TOOLS, max_tokens=8000)\n except Exception as e:\n if \"prompt_too_long\" in str(e).lower() and reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = reactive_compact(messages)\n reactive_retries += 1\n continue\n raise\n\n # ... ツールを実行し、結果を messages に戻す ...\n```\n\nここで最も重要なのは順序だ:\n\n```text\n大きな結果を退避 → 中間の古い対話を切り詰め → 古い結果をプレースホルダ化 → それでも超過、そこで要約\n```\n\n前の 3 ステップにモデルは関与しない。主に空間の整理だ。Step 4 だけが本当に履歴を書き換える。だから必ず最後に置く。\n\n---\n\n## compact ツール:モデル自身が整理を求める\n\n自動整理のほかに、モデルに `compact` ツールを持たせることもできる。\n\nコンテキストが長すぎると気づいたとき、あるいはタスクが新しい段階に入ったとき、モデルはこのツールを自分から呼べる。呼ばれるとプログラムは `compact_history` を実行して現在のターンを終え、整理後のコンテキストで次のターンを始める。\n\nこれで整理はプログラムの自動トリガーだけでなく、モデルが適切なタイミングで自分から言い出せるものになる。\n\n---\n\n## s07 からの変更\n\n| コンポーネント | s07 | s08 |\n|------|------|------|\n| コンテキスト管理 | なし | 毎回呼び出し前に整理 |\n| ツール結果 | ずっとコンテキストに残る | 大きな結果は退避、古い結果はプレースホルダ |\n| 履歴メッセージ | 蓄積し続ける | 中間の古い履歴は省略可能 |\n| 上限超過時 | そのまま失敗 | まず整理、足りなければ要約 |\n| 新ツール | なし | `compact` |\n\ns07 は Agent の仕事の腕を上げた。\ns08 は長いタスクで Agent が自分の履歴に押し潰されないようにする。\n\n---\n\n## 試してみる\n\n```bash\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\n試すタスク:\n\n```text\nRead README.md, then read code.py, then read s01_agent_loop/README.md\n```\n\n古いツール結果がプレースホルダに置き換わるかを観察する。\n\n```text\nRead every file in s08_context_compact/\n```\n\n大きな出力がディスクへ退避されるかを観察する。\n\n```text\nKeep discussing and editing for more than 20 turns\n```\n\nコンテキストが上限に近づいたとき、要約がトリガーされるかを観察する。\n\n---\n\n## まとめ\n\nContext Compact の核心となる原則は一行だ:\n\n> 整理できるものはまず整理する。復元できるものは要約しない。それでも足りないときだけ、モデルに履歴を要約させる。\n\ns08 で長いタスクは続けられるようになった。\ns09 は次の問題に取り組む:どの情報を長く残す価値があるか。\n\n\n" }, { "version": "s09", diff --git a/web/src/data/generated/versions.json b/web/src/data/generated/versions.json index d9510a6cd..b7943eb79 100644 --- a/web/src/data/generated/versions.json +++ b/web/src/data/generated/versions.json @@ -660,7 +660,7 @@ "filename": "s08_context_compact/code.py", "title": "Context Compact", "subtitle": "Context Will Fill Up", - "loc": 414, + "loc": 415, "tools": [ "bash", "read_file", @@ -774,74 +774,84 @@ "signature": "def _is_tool_result_message(msg)", "startLine": 284 }, + { + "name": "safe_head", + "signature": "def safe_head(messages, keep_head)", + "startLine": 295 + }, + { + "name": "safe_tail", + "signature": "def safe_tail(messages, keep_tail)", + "startLine": 303 + }, { "name": "snip_compact", "signature": "def snip_compact(messages, max_messages=50)", - "startLine": 295 + "startLine": 312 }, { "name": "collect_tool_results", "signature": "def collect_tool_results(messages)", - "startLine": 313 + "startLine": 322 }, { "name": "micro_compact", "signature": "def micro_compact(messages)", - "startLine": 322 + "startLine": 331 }, { "name": "persist_large_output", "signature": "def persist_large_output(tool_use_id, output)", - "startLine": 332 + "startLine": 341 }, { "name": "tool_result_budget", "signature": "def tool_result_budget(messages, max_bytes=200_000)", - "startLine": 339 + "startLine": 348 }, { "name": "write_transcript", "signature": "def write_transcript(messages)", - "startLine": 357 + "startLine": 366 }, { "name": "summarize_history", "signature": "def summarize_history(messages)", - "startLine": 364 + "startLine": 373 }, { "name": "compact_history", "signature": "def compact_history(messages)", - "startLine": 375 + "startLine": 384 }, { "name": "reactive_compact", "signature": "def reactive_compact(messages)", - "startLine": 383 + "startLine": 392 }, { "name": "trigger_hooks", "signature": "def trigger_hooks(event, *args)", - "startLine": 428 + "startLine": 433 }, { "name": "permission_hook", "signature": "def permission_hook(block)", - "startLine": 435 + "startLine": 440 }, { "name": "log_hook", "signature": "def log_hook(block)", - "startLine": 440 + "startLine": 445 }, { "name": "agent_loop", "signature": "def agent_loop(messages: list)", - "startLine": 454 + "startLine": 459 } ], "layer": "memory", - "source": "#!/usr/bin/env python3\n\"\"\"\ns08_context_compact.py - Context Compact\n\nFour-layer compaction pipeline inserted before LLM calls:\n\n L1: snip_compact — trim middle messages when count > 50\n L2: micro_compact — replace old tool_results with placeholders\n L3: tool_result_budget — persist large results to disk\n L4: compact_history — LLM full summary (1 API call)\n\n Emergency: reactive_compact — when API still returns prompt_too_long\n\n ┌─────────────────────────────────────────────────────────────┐\n │ messages[] │\n │ ↓ │\n │ L3 budget ─→ L1 snip ─→ L2 micro ─→ [token > threshold?] │\n │ ├─ No → LLM │\n │ └─ Yes → L4 summary │\n │ ↓ │\n │ LLM call │\n │ [prompt_too_long?] │\n │ └─ Yes → reactive │\n └─────────────────────────────────────────────────────────────┘\n\nCore principle: cheap first, expensive last.\nExecution order matches Claude Code source: budget → snip → micro → auto.\n\nBuilds on s07 (skill loading). Usage:\n\n python s08_context_compact/code.py\n Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env\n\"\"\"\n\nimport ast, json, os, subprocess, time\nfrom pathlib import Path\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"): os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nSKILLS_DIR = WORKDIR / \"skills\"\nTRANSCRIPT_DIR = WORKDIR / \".transcripts\"\nTOOL_RESULTS_DIR = WORKDIR / \".task_outputs\" / \"tool-results\"\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\nCURRENT_TODOS: list[dict] = []\n\n# s07: Skill catalog scan (inherited from s07)\ndef _parse_frontmatter(text: str) -> tuple[dict, str]:\n if not text.startswith(\"---\"):\n return {}, text\n parts = text.split(\"---\", 2)\n if len(parts) < 3:\n return {}, text\n meta = {}\n for line in parts[1].strip().splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip().strip('\"').strip(\"'\")\n return meta, parts[2].strip()\n\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills()\n\ndef list_skills() -> str:\n if not SKILL_REGISTRY:\n return \"(no skills found)\"\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n\n# s08: SYSTEM includes skill catalog (inherited from s07 build_system)\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n\n# s08: subagent gets its own system prompt — no compact, no skill loading\nSUB_SYSTEM = (\n f\"You are a coding agent at {WORKDIR}. \"\n \"Complete the task you were given, then return a concise summary. \"\n \"Do not delegate further.\"\n)\n\n\n# ═══════════════════════════════════════════════════════════\n# FROM s02-s07 (unchanged): Basic Tools\n# ═══════════════════════════════════════════════════════════\n\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR): raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\ndef run_bash(command: str) -> str:\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired: return \"Error: Timeout (120s)\"\n\ndef run_read(path: str, limit: int | None = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines): lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e: return f\"Error: {e}\"\n\ndef run_write(path: str, content: str) -> str:\n try:\n file_path = safe_path(path); file_path.parent.mkdir(parents=True, exist_ok=True)\n file_path.write_text(content); return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e: return f\"Error: {e}\"\n\ndef run_edit(path: str, old_text: str, new_text: str) -> str:\n try:\n file_path = safe_path(path)\n text = file_path.read_text()\n if old_text not in text: return f\"Error: text not found in {path}\"\n file_path.write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n except Exception as e: return f\"Error: {e}\"\n\ndef run_glob(pattern: str) -> str:\n import glob as g\n try:\n results = []\n for match in g.glob(pattern, root_dir=WORKDIR):\n if (WORKDIR / match).resolve().is_relative_to(WORKDIR):\n results.append(match)\n return \"\\n\".join(results) if results else \"(no matches)\"\n except Exception as e: return f\"Error: {e}\"\n\ndef _normalize_todos(todos):\n if isinstance(todos, str):\n try:\n todos = json.loads(todos)\n except json.JSONDecodeError:\n try:\n todos = ast.literal_eval(todos)\n except (SyntaxError, ValueError):\n return None, \"Error: todos must be a list or JSON array string\"\n if not isinstance(todos, list):\n return None, \"Error: todos must be a list\"\n for i, t in enumerate(todos):\n if not isinstance(t, dict):\n return None, f\"Error: todos[{i}] must be an object\"\n if \"content\" not in t or \"status\" not in t:\n return None, f\"Error: todos[{i}] missing 'content' or 'status'\"\n if t[\"status\"] not in (\"pending\", \"in_progress\", \"completed\"):\n return None, f\"Error: todos[{i}] has invalid status '{t['status']}'\"\n return todos, None\n\ndef run_todo_write(todos: list) -> str:\n global CURRENT_TODOS\n todos, error = _normalize_todos(todos)\n if error:\n return error\n CURRENT_TODOS = todos\n lines = [\"\\n\\033[33m## Current Tasks\\033[0m\"]\n for t in CURRENT_TODOS:\n icon = {\"pending\": \" \", \"in_progress\": \"\\033[36m▸\\033[0m\", \"completed\": \"\\033[32m✓\\033[0m\"}[t[\"status\"]]\n lines.append(f\" [{icon}] {t['content']}\")\n print(\"\\n\".join(lines))\n return f\"Updated {len(CURRENT_TODOS)} tasks\"\n\ndef extract_text(content) -> str:\n if not isinstance(content, list): return str(content)\n return \"\\n\".join(getattr(b, \"text\", \"\") for b in content if getattr(b, \"type\", None) == \"text\")\n\n\n# ═══════════════════════════════════════════════════════════\n# FROM s06-s07 (unchanged): Subagent\n# ═══════════════════════════════════════════════════════════\n\nSUB_TOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"content\": {\"type\": \"string\"}}, \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"old_text\": {\"type\": \"string\"}, \"new_text\": {\"type\": \"string\"}}, \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"pattern\": {\"type\": \"string\"}}, \"required\": [\"pattern\"]}},\n]\nSUB_HANDLERS = {\"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"edit_file\": run_edit, \"glob\": run_glob}\n\ndef spawn_subagent(description: str) -> str:\n print(f\"\\n\\033[35m[Subagent spawned]\\033[0m\")\n messages = [{\"role\": \"user\", \"content\": description}]\n for _ in range(30):\n response = client.messages.create(model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=SUB_TOOLS, max_tokens=8000)\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n trigger_hooks(\"PostToolUse\", block, output)\n print(f\" \\033[90m[sub] {block.name}: {str(output)[:100]}\\033[0m\")\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n result = extract_text(messages[-1][\"content\"])\n if not result:\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\":\n result = extract_text(msg[\"content\"])\n if result:\n break\n if not result:\n result = \"Subagent stopped after 30 turns without final answer.\"\n print(f\"\\033[35m[Subagent done]\\033[0m\")\n return result\n\n\n# ═══════════════════════════════════════════════════════════\n# NEW in s08: Four-Layer Compaction Pipeline\n# ═══════════════════════════════════════════════════════════\n\nCONTEXT_LIMIT = 50000\nKEEP_RECENT = 3\nPERSIST_THRESHOLD = 30000\n\ndef estimate_size(msgs): return len(str(msgs))\n\ndef _block_type(block):\n return block.get(\"type\") if isinstance(block, dict) else getattr(block, \"type\", None)\n\n\ndef _message_has_tool_use(msg):\n if msg.get(\"role\") != \"assistant\":\n return False\n content = msg.get(\"content\")\n if not isinstance(content, list):\n return False\n return any(_block_type(block) == \"tool_use\" for block in content)\n\n\ndef _is_tool_result_message(msg):\n if msg.get(\"role\") != \"user\":\n return False\n content = msg.get(\"content\")\n if not isinstance(content, list):\n return False\n return any(isinstance(block, dict) and block.get(\"type\") == \"tool_result\"\n for block in content)\n\n\n# L1: snipCompact — trim middle messages\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages: return messages\n keep_head, keep_tail = 3, max_messages - 3\n head_end, tail_start = keep_head, len(messages) - keep_tail\n if head_end > 0 and _message_has_tool_use(messages[head_end - 1]):\n while head_end < len(messages) and _is_tool_result_message(messages[head_end]):\n head_end += 1\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n if head_end >= tail_start:\n return messages\n snipped = tail_start - head_end\n return messages[:head_end] + [{\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}] + messages[tail_start:]\n\n\n# L2: microCompact — old result placeholders\ndef collect_tool_results(messages):\n blocks = []\n for mi, msg in enumerate(messages):\n if msg.get(\"role\") != \"user\" or not isinstance(msg.get(\"content\"), list): continue\n for bi, block in enumerate(msg[\"content\"]):\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n blocks.append((mi, bi, block))\n return blocks\n\ndef micro_compact(messages):\n tool_results = collect_tool_results(messages)\n if len(tool_results) <= KEEP_RECENT: return messages\n for _, _, block in tool_results[:-KEEP_RECENT]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n\n\n# L3: toolResultBudget — persist large results to disk\ndef persist_large_output(tool_use_id, output):\n if len(output) <= PERSIST_THRESHOLD: return output\n TOOL_RESULTS_DIR.mkdir(parents=True, exist_ok=True)\n path = TOOL_RESULTS_DIR / f\"{tool_use_id}.txt\"\n if not path.exists(): path.write_text(output)\n return f\"\\nFull output: {path}\\nPreview:\\n{output[:2000]}\\n\"\n\ndef tool_result_budget(messages, max_bytes=200_000):\n last = messages[-1] if messages else None\n if not last or last.get(\"role\") != \"user\" or not isinstance(last.get(\"content\"), list): return messages\n blocks = [(i, b) for i, b in enumerate(last[\"content\"]) if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes: return messages\n ranked = sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True)\n for _, block in ranked:\n if total <= max_bytes: break\n content = str(block.get(\"content\", \"\"))\n if len(content) <= PERSIST_THRESHOLD: continue\n tid = block.get(\"tool_use_id\", \"unknown\")\n block[\"content\"] = persist_large_output(tid, content)\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return messages\n\n\n# L4: autoCompact — LLM full summary\ndef write_transcript(messages):\n TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)\n path = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with path.open(\"w\") as f:\n for msg in messages: f.write(json.dumps(msg, default=str) + \"\\n\")\n return path\n\ndef summarize_history(messages):\n conversation = json.dumps(messages, default=str)[:80000]\n prompt = (\"Summarize this coding-agent conversation so work can continue.\\n\"\n \"Preserve: 1. current goal, 2. key findings/decisions, 3. files read/changed, \"\n \"4. remaining work, 5. user constraints.\\nBe compact but concrete.\\n\\n\" + conversation)\n response = client.messages.create(model=MODEL, messages=[{\"role\": \"user\", \"content\": prompt}], max_tokens=2000)\n return \"\\n\".join(\n getattr(block, \"text\", \"\")\n for block in response.content\n if getattr(block, \"type\", None) == \"text\").strip() or \"(empty summary)\"\n\ndef compact_history(messages):\n transcript_path = write_transcript(messages)\n print(f\"[transcript saved: {transcript_path}]\")\n summary = summarize_history(messages)\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n\n\n# Emergency: reactiveCompact — on API error\ndef reactive_compact(messages):\n transcript = write_transcript(messages)\n tail_start = max(0, len(messages) - 5)\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n summary = summarize_history(messages[:tail_start])\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *messages[tail_start:]]\n\n\n# ═══════════════════════════════════════════════════════════\n# FROM s07: Tool Definitions\n# ═══════════════════════════════════════════════════════════\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"limit\": {\"type\": \"integer\"}}, \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"content\": {\"type\": \"string\"}}, \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"old_text\": {\"type\": \"string\"}, \"new_text\": {\"type\": \"string\"}}, \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"pattern\": {\"type\": \"string\"}}, \"required\": [\"pattern\"]}},\n {\"name\": \"todo_write\", \"description\": \"Create and manage a task list for your current coding session.\",\n \"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\"]}},\n {\"name\": \"task\", \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n {\"name\": \"load_skill\", \"description\": \"Load the full content of a skill by name.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"name\": {\"type\": \"string\"}}, \"required\": [\"name\"]}},\n # s08 change: new compact tool — triggers compact_history, not a no-op\n {\"name\": \"compact\", \"description\": \"Summarize earlier conversation to free context space.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"focus\": {\"type\": \"string\"}}}},\n]\n\nTOOL_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"edit_file\": run_edit, \"glob\": run_glob, \"todo_write\": run_todo_write,\n \"task\": spawn_subagent, \"load_skill\": load_skill,\n}\n\n# FROM s04 (unchanged): Hooks\nHOOKS = {\"PreToolUse\": [], \"PostToolUse\": []}\ndef trigger_hooks(event, *args):\n for cb in HOOKS[event]:\n r = cb(*args)\n if r is not None: return r\n return None\n\nDENY_LIST = [\"rm -rf /\", \"sudo\", \"shutdown\"]\ndef permission_hook(block):\n if block.name == \"bash\":\n for p in DENY_LIST:\n if p in block.input.get(\"command\", \"\"): return \"Permission denied\"\n return None\ndef log_hook(block):\n print(f\"\\033[90m[HOOK] {block.name}\\033[0m\")\n return None\n\nHOOKS[\"PreToolUse\"].append(permission_hook)\nHOOKS[\"PreToolUse\"].append(log_hook)\n\n\n# ═══════════════════════════════════════════════════════════\n# agent_loop — s08 core: run compaction pipeline before LLM\n# ═══════════════════════════════════════════════════════════\n\nMAX_REACTIVE_RETRIES = 1 # retry limit for reactive compact\n\ndef agent_loop(messages: list):\n reactive_retries = 0\n while True:\n # s08 change: three preprocessors (0 API calls, cheap first)\n # Order matches Claude Code source: budget → snip → micro\n messages[:] = tool_result_budget(messages) # L3: persist large results first\n messages[:] = snip_compact(messages) # L1: trim middle\n messages[:] = micro_compact(messages) # L2: old result placeholders\n\n # s08 change: tokens still over threshold → LLM summary (1 API call)\n if estimate_size(messages) > CONTEXT_LIMIT:\n print(\"[auto compact]\")\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000)\n reactive_retries = 0 # reset on successful API call\n except Exception as e:\n if (\"prompt_too_long\" in str(e).lower() or \"too many tokens\" in str(e).lower()) and reactive_retries < MAX_REACTIVE_RETRIES:\n print(\"[reactive compact]\")\n messages[:] = reactive_compact(messages)\n reactive_retries += 1\n continue\n raise\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\": return\n\n results = []\n for block in response.content:\n if block.type != \"tool_use\": continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n\n # s08: compact tool triggers compact_history, not a no-op string\n if block.name == \"compact\":\n messages[:] = compact_history(messages)\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": \"[Compacted. Conversation history has been summarized.]\"})\n messages.append({\"role\": \"user\", \"content\": results})\n break # end current turn, start fresh with compacted context\n\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": str(blocked)})\n continue\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n trigger_hooks(\"PostToolUse\", block, output)\n print(str(output)[:200])\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": str(output)})\n else:\n # normal path: no compact was called\n messages.append({\"role\": \"user\", \"content\": results})\n continue\n # compact was called: results already appended above\n continue\n\n\nif __name__ == \"__main__\":\n print(\"s08: Context Compact — four-layer compaction pipeline\")\n print(\"输入问题,回车发送。输入 q 退出。\\n\")\n history = []\n while True:\n try: query = input(\"\\033[36ms08 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt): break\n if query.strip().lower() in (\"q\", \"exit\", \"\"): break\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history)\n for block in history[-1][\"content\"]:\n if getattr(block, \"type\", None) == \"text\": print(block.text)\n print()\n", + "source": "#!/usr/bin/env python3\n\"\"\"\ns08_context_compact.py - Context Compact\n\nFour-step compaction pipeline inserted before LLM calls, in execution order:\n\n Step 1: tool_result_budget — persist large results to disk\n Step 2: snip_compact — trim middle messages when count > 50\n Step 3: micro_compact — replace old tool_results with placeholders\n Step 4: compact_history — LLM full summary (1 API call)\n\n Emergency: reactive_compact — when API still returns prompt_too_long\n\n ┌─────────────────────────────────────────────────────────────┐\n │ messages[] │\n │ ↓ │\n │ 1 budget ─→ 2 snip ─→ 3 micro ─→ [token > threshold?] │\n │ ├─ No → LLM │\n │ └─ Yes → 4 summary │\n │ ↓ │\n │ LLM call │\n │ [prompt_too_long?] │\n │ └─ Yes → reactive │\n └─────────────────────────────────────────────────────────────┘\n\nCore principle: cheap first, expensive last.\nExecution order matches Claude Code source: budget → snip → micro → auto.\n\nBuilds on s07 (skill loading). Usage:\n\n python s08_context_compact/code.py\n Needs: pip install anthropic python-dotenv + ANTHROPIC_API_KEY in .env\n\"\"\"\n\nimport ast, json, os, subprocess, time\nfrom pathlib import Path\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\nexcept ImportError:\n pass\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"): os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nSKILLS_DIR = WORKDIR / \"skills\"\nTRANSCRIPT_DIR = WORKDIR / \".transcripts\"\nTOOL_RESULTS_DIR = WORKDIR / \".task_outputs\" / \"tool-results\"\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\nCURRENT_TODOS: list[dict] = []\n\n# s07: Skill catalog scan (inherited from s07)\ndef _parse_frontmatter(text: str) -> tuple[dict, str]:\n if not text.startswith(\"---\"):\n return {}, text\n parts = text.split(\"---\", 2)\n if len(parts) < 3:\n return {}, text\n meta = {}\n for line in parts[1].strip().splitlines():\n if \":\" in line:\n k, v = line.split(\":\", 1)\n meta[k.strip()] = v.strip().strip('\"').strip(\"'\")\n return meta, parts[2].strip()\n\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills()\n\ndef list_skills() -> str:\n if not SKILL_REGISTRY:\n return \"(no skills found)\"\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n\n# s08: SYSTEM includes skill catalog (inherited from s07 build_system)\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n\n# s08: subagent gets its own system prompt — no compact, no skill loading\nSUB_SYSTEM = (\n f\"You are a coding agent at {WORKDIR}. \"\n \"Complete the task you were given, then return a concise summary. \"\n \"Do not delegate further.\"\n)\n\n\n# ═══════════════════════════════════════════════════════════\n# FROM s02-s07 (unchanged): Basic Tools\n# ═══════════════════════════════════════════════════════════\n\ndef safe_path(p: str) -> Path:\n path = (WORKDIR / p).resolve()\n if not path.is_relative_to(WORKDIR): raise ValueError(f\"Path escapes workspace: {p}\")\n return path\n\ndef run_bash(command: str) -> str:\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired: return \"Error: Timeout (120s)\"\n\ndef run_read(path: str, limit: int | None = None) -> str:\n try:\n lines = safe_path(path).read_text().splitlines()\n if limit and limit < len(lines): lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e: return f\"Error: {e}\"\n\ndef run_write(path: str, content: str) -> str:\n try:\n file_path = safe_path(path); file_path.parent.mkdir(parents=True, exist_ok=True)\n file_path.write_text(content); return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e: return f\"Error: {e}\"\n\ndef run_edit(path: str, old_text: str, new_text: str) -> str:\n try:\n file_path = safe_path(path)\n text = file_path.read_text()\n if old_text not in text: return f\"Error: text not found in {path}\"\n file_path.write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n except Exception as e: return f\"Error: {e}\"\n\ndef run_glob(pattern: str) -> str:\n import glob as g\n try:\n results = []\n for match in g.glob(pattern, root_dir=WORKDIR):\n if (WORKDIR / match).resolve().is_relative_to(WORKDIR):\n results.append(match)\n return \"\\n\".join(results) if results else \"(no matches)\"\n except Exception as e: return f\"Error: {e}\"\n\ndef _normalize_todos(todos):\n if isinstance(todos, str):\n try:\n todos = json.loads(todos)\n except json.JSONDecodeError:\n try:\n todos = ast.literal_eval(todos)\n except (SyntaxError, ValueError):\n return None, \"Error: todos must be a list or JSON array string\"\n if not isinstance(todos, list):\n return None, \"Error: todos must be a list\"\n for i, t in enumerate(todos):\n if not isinstance(t, dict):\n return None, f\"Error: todos[{i}] must be an object\"\n if \"content\" not in t or \"status\" not in t:\n return None, f\"Error: todos[{i}] missing 'content' or 'status'\"\n if t[\"status\"] not in (\"pending\", \"in_progress\", \"completed\"):\n return None, f\"Error: todos[{i}] has invalid status '{t['status']}'\"\n return todos, None\n\ndef run_todo_write(todos: list) -> str:\n global CURRENT_TODOS\n todos, error = _normalize_todos(todos)\n if error:\n return error\n CURRENT_TODOS = todos\n lines = [\"\\n\\033[33m## Current Tasks\\033[0m\"]\n for t in CURRENT_TODOS:\n icon = {\"pending\": \" \", \"in_progress\": \"\\033[36m▸\\033[0m\", \"completed\": \"\\033[32m✓\\033[0m\"}[t[\"status\"]]\n lines.append(f\" [{icon}] {t['content']}\")\n print(\"\\n\".join(lines))\n return f\"Updated {len(CURRENT_TODOS)} tasks\"\n\ndef extract_text(content) -> str:\n if not isinstance(content, list): return str(content)\n return \"\\n\".join(getattr(b, \"text\", \"\") for b in content if getattr(b, \"type\", None) == \"text\")\n\n\n# ═══════════════════════════════════════════════════════════\n# FROM s06-s07 (unchanged): Subagent\n# ═══════════════════════════════════════════════════════════\n\nSUB_TOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}}, \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"content\": {\"type\": \"string\"}}, \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"old_text\": {\"type\": \"string\"}, \"new_text\": {\"type\": \"string\"}}, \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"pattern\": {\"type\": \"string\"}}, \"required\": [\"pattern\"]}},\n]\nSUB_HANDLERS = {\"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"edit_file\": run_edit, \"glob\": run_glob}\n\ndef spawn_subagent(description: str) -> str:\n print(f\"\\n\\033[35m[Subagent spawned]\\033[0m\")\n messages = [{\"role\": \"user\", \"content\": description}]\n for _ in range(30):\n response = client.messages.create(model=MODEL, system=SUB_SYSTEM,\n messages=messages, tools=SUB_TOOLS, max_tokens=8000)\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": str(blocked)})\n continue\n handler = SUB_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n trigger_hooks(\"PostToolUse\", block, output)\n print(f\" \\033[90m[sub] {block.name}: {str(output)[:100]}\\033[0m\")\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": output})\n messages.append({\"role\": \"user\", \"content\": results})\n result = extract_text(messages[-1][\"content\"])\n if not result:\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\":\n result = extract_text(msg[\"content\"])\n if result:\n break\n if not result:\n result = \"Subagent stopped after 30 turns without final answer.\"\n print(f\"\\033[35m[Subagent done]\\033[0m\")\n return result\n\n\n# ═══════════════════════════════════════════════════════════\n# NEW in s08: Four-Step Compaction Pipeline\n# ═══════════════════════════════════════════════════════════\n\nCONTEXT_LIMIT = 50000\nKEEP_RECENT = 3\nPERSIST_THRESHOLD = 30000\n\ndef estimate_size(msgs): return len(str(msgs))\n\ndef _block_type(block):\n return block.get(\"type\") if isinstance(block, dict) else getattr(block, \"type\", None)\n\n\ndef _message_has_tool_use(msg):\n if msg.get(\"role\") != \"assistant\":\n return False\n content = msg.get(\"content\")\n if not isinstance(content, list):\n return False\n return any(_block_type(block) == \"tool_use\" for block in content)\n\n\ndef _is_tool_result_message(msg):\n if msg.get(\"role\") != \"user\":\n return False\n content = msg.get(\"content\")\n if not isinstance(content, list):\n return False\n return any(isinstance(block, dict) and block.get(\"type\") == \"tool_result\"\n for block in content)\n\n\n# Step 2: snip_compact — trim middle messages\ndef safe_head(messages, keep_head):\n # extend the cut so a trailing tool_use keeps its tool_result(s)\n end = keep_head\n if end > 0 and _message_has_tool_use(messages[end - 1]):\n while end < len(messages) and _is_tool_result_message(messages[end]):\n end += 1\n return messages[:end]\n\ndef safe_tail(messages, keep_tail):\n # move the cut back so the tail never starts with an orphaned tool_result\n start = max(0, len(messages) - keep_tail)\n if (start > 0 and start < len(messages)\n and _is_tool_result_message(messages[start])\n and _message_has_tool_use(messages[start - 1])):\n start -= 1\n return messages[start:]\n\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages: return messages\n head = safe_head(messages, 3)\n tail = safe_tail(messages, max_messages - 3)\n snipped = len(messages) - len(head) - len(tail)\n if snipped <= 0: return messages\n return head + [{\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}] + tail\n\n\n# Step 3: micro_compact — old result placeholders\ndef collect_tool_results(messages):\n blocks = []\n for mi, msg in enumerate(messages):\n if msg.get(\"role\") != \"user\" or not isinstance(msg.get(\"content\"), list): continue\n for bi, block in enumerate(msg[\"content\"]):\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n blocks.append((mi, bi, block))\n return blocks\n\ndef micro_compact(messages):\n tool_results = collect_tool_results(messages)\n if len(tool_results) <= KEEP_RECENT: return messages\n for _, _, block in tool_results[:-KEEP_RECENT]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n\n\n# Step 1: tool_result_budget — persist large results to disk\ndef persist_large_output(tool_use_id, output):\n if len(output) <= PERSIST_THRESHOLD: return output\n TOOL_RESULTS_DIR.mkdir(parents=True, exist_ok=True)\n path = TOOL_RESULTS_DIR / f\"{tool_use_id}.txt\"\n if not path.exists(): path.write_text(output)\n return f\"\\nFull output: {path}\\nPreview:\\n{output[:2000]}\\n\"\n\ndef tool_result_budget(messages, max_bytes=200_000):\n last = messages[-1] if messages else None\n if not last or last.get(\"role\") != \"user\" or not isinstance(last.get(\"content\"), list): return messages\n blocks = [(i, b) for i, b in enumerate(last[\"content\"]) if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes: return messages\n ranked = sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True)\n for _, block in ranked:\n if total <= max_bytes: break\n content = str(block.get(\"content\", \"\"))\n if len(content) <= PERSIST_THRESHOLD: continue\n tid = block.get(\"tool_use_id\", \"unknown\")\n block[\"content\"] = persist_large_output(tid, content)\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return messages\n\n\n# Step 4: compact_history — LLM full summary\ndef write_transcript(messages):\n TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)\n path = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with path.open(\"w\") as f:\n for msg in messages: f.write(json.dumps(msg, default=str) + \"\\n\")\n return path\n\ndef summarize_history(messages):\n conversation = json.dumps(messages, default=str)[:80000]\n prompt = (\"Summarize this coding-agent conversation so work can continue.\\n\"\n \"Preserve: 1. current goal, 2. key findings/decisions, 3. files read/changed, \"\n \"4. remaining work, 5. user constraints.\\nBe compact but concrete.\\n\\n\" + conversation)\n response = client.messages.create(model=MODEL, messages=[{\"role\": \"user\", \"content\": prompt}], max_tokens=2000)\n return \"\\n\".join(\n getattr(block, \"text\", \"\")\n for block in response.content\n if getattr(block, \"type\", None) == \"text\").strip() or \"(empty summary)\"\n\ndef compact_history(messages):\n transcript_path = write_transcript(messages)\n print(f\"[transcript saved: {transcript_path}]\")\n summary = summarize_history(messages)\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n\n\n# Emergency: reactive_compact — on API error\ndef reactive_compact(messages):\n transcript = write_transcript(messages)\n tail = safe_tail(messages, 5)\n summary = summarize_history(messages[:len(messages) - len(tail)])\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *tail]\n\n\n# ═══════════════════════════════════════════════════════════\n# FROM s07: Tool Definitions\n# ═══════════════════════════════════════════════════════════\n\nTOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"command\": {\"type\": \"string\"}}, \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"limit\": {\"type\": \"integer\"}}, \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"content\": {\"type\": \"string\"}}, \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"path\": {\"type\": \"string\"}, \"old_text\": {\"type\": \"string\"}, \"new_text\": {\"type\": \"string\"}}, \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"pattern\": {\"type\": \"string\"}}, \"required\": [\"pattern\"]}},\n {\"name\": \"todo_write\", \"description\": \"Create and manage a task list for your current coding session.\",\n \"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\"]}},\n {\"name\": \"task\", \"description\": \"Launch a subagent to handle a complex subtask. Returns only the final conclusion.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"description\": {\"type\": \"string\"}}, \"required\": [\"description\"]}},\n {\"name\": \"load_skill\", \"description\": \"Load the full content of a skill by name.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"name\": {\"type\": \"string\"}}, \"required\": [\"name\"]}},\n # s08 change: new compact tool — triggers compact_history, not a no-op\n {\"name\": \"compact\", \"description\": \"Summarize earlier conversation to free context space.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {\"focus\": {\"type\": \"string\"}}}},\n]\n\nTOOL_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"edit_file\": run_edit, \"glob\": run_glob, \"todo_write\": run_todo_write,\n \"task\": spawn_subagent, \"load_skill\": load_skill,\n}\n\n# FROM s04 (unchanged): Hooks\nHOOKS = {\"PreToolUse\": [], \"PostToolUse\": []}\ndef trigger_hooks(event, *args):\n for cb in HOOKS[event]:\n r = cb(*args)\n if r is not None: return r\n return None\n\nDENY_LIST = [\"rm -rf /\", \"sudo\", \"shutdown\"]\ndef permission_hook(block):\n if block.name == \"bash\":\n for p in DENY_LIST:\n if p in block.input.get(\"command\", \"\"): return \"Permission denied\"\n return None\ndef log_hook(block):\n print(f\"\\033[90m[HOOK] {block.name}\\033[0m\")\n return None\n\nHOOKS[\"PreToolUse\"].append(permission_hook)\nHOOKS[\"PreToolUse\"].append(log_hook)\n\n\n# ═══════════════════════════════════════════════════════════\n# agent_loop — s08 core: run compaction pipeline before LLM\n# ═══════════════════════════════════════════════════════════\n\nMAX_REACTIVE_RETRIES = 1 # retry limit for reactive compact\n\ndef agent_loop(messages: list):\n reactive_retries = 0\n while True:\n # s08 change: three preprocessors (0 API calls, cheap first)\n # Order matches Claude Code source: budget → snip → micro\n messages[:] = tool_result_budget(messages) # Step 1: persist large results first\n messages[:] = snip_compact(messages) # Step 2: trim middle\n messages[:] = micro_compact(messages) # Step 3: old result placeholders\n\n # s08 change: tokens still over threshold → LLM summary (1 API call)\n if estimate_size(messages) > CONTEXT_LIMIT:\n print(\"[auto compact]\")\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(model=MODEL, system=SYSTEM, messages=messages, tools=TOOLS, max_tokens=8000)\n reactive_retries = 0 # reset on successful API call\n except Exception as e:\n if (\"prompt_too_long\" in str(e).lower() or \"too many tokens\" in str(e).lower()) and reactive_retries < MAX_REACTIVE_RETRIES:\n print(\"[reactive compact]\")\n messages[:] = reactive_compact(messages)\n reactive_retries += 1\n continue\n raise\n\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\": return\n\n results = []\n for block in response.content:\n if block.type != \"tool_use\": continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n\n # s08: compact tool triggers compact_history, not a no-op string\n if block.name == \"compact\":\n messages[:] = compact_history(messages)\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id,\n \"content\": \"[Compacted. Conversation history has been summarized.]\"})\n messages.append({\"role\": \"user\", \"content\": results})\n break # end current turn, start fresh with compacted context\n\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": str(blocked)})\n continue\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler else f\"Unknown: {block.name}\"\n trigger_hooks(\"PostToolUse\", block, output)\n print(str(output)[:200])\n results.append({\"type\": \"tool_result\", \"tool_use_id\": block.id, \"content\": str(output)})\n else:\n # normal path: no compact was called\n messages.append({\"role\": \"user\", \"content\": results})\n continue\n # compact was called: results already appended above\n continue\n\n\nif __name__ == \"__main__\":\n print(\"s08: Context Compact — four-layer compaction pipeline\")\n print(\"输入问题,回车发送。输入 q 退出。\\n\")\n history = []\n while True:\n try: query = input(\"\\033[36ms08 >> \\033[0m\")\n except (EOFError, KeyboardInterrupt): break\n if query.strip().lower() in (\"q\", \"exit\", \"\"): break\n history.append({\"role\": \"user\", \"content\": query})\n agent_loop(history)\n for block in history[-1][\"content\"]:\n if getattr(block, \"type\", None) == \"text\": print(block.text)\n print()\n", "images": [ { "src": "/course-assets/s08_context_compact/auto-compact.svg", @@ -3944,6 +3954,8 @@ "_block_type", "_message_has_tool_use", "_is_tool_result_message", + "safe_head", + "safe_tail", "snip_compact", "collect_tool_results", "micro_compact", @@ -3957,7 +3969,7 @@ "newTools": [ "compact" ], - "locDelta": 79 + "locDelta": 80 }, { "from": "s08", @@ -3976,7 +3988,7 @@ "persist_large" ], "newTools": [], - "locDelta": 114 + "locDelta": 113 }, { "from": "s09",