better doc

This commit is contained in:
CrazyBoyM committed 2026-02-27 01:11:57 +08:00
1 parent aea8844bac
commit 665831c774
46 files changed
+1217 -3505

No files matched your search

+2 -30
View File
@@ -32,8 +32,8 @@
{
"id": "task-default-todo-coexistence",
"title": "Task as Course Default, Todo Still Useful",
"description": "TaskManager extends the Todo mental model and becomes the default workflow from s07 onward in this course. This 'default' is a course sequencing choice, not a universal runtime default claim. Both track work items with statuses, but TaskManager adds file persistence (survives crashes), dependency tracking (blocks/blockedBy), ownership fields, and multi-process coordination. Todo remains useful for short, linear, one-shot tracking where heavyweight coordination is unnecessary.",
"alternatives": "Using only Todo keeps the model minimal but weak for long-running or collaborative work. Using only Task everywhere maximizes consistency but can feel heavy for tiny one-off tasks. Reminder signals are omission-reduction hints, not implicit mode switches; Task/Todo choice should still come from workflow intent and available tools.",
"description": "TaskManager extends the Todo mental model and becomes the default workflow from s07 onward in this course. Both track work items with statuses, but TaskManager adds file persistence (survives crashes), dependency tracking (blocks/blockedBy), ownership fields, and multi-process coordination. Todo remains useful for short, linear, one-shot tracking where heavyweight coordination is unnecessary.",
"alternatives": "Using only Todo keeps the model minimal but weak for long-running or collaborative work. Using only Task everywhere maximizes consistency but can feel heavy for tiny one-off tasks.",
"zh": {
"title": "Task 为课程主线,Todo 仍有适用场景",
"description": "TaskManager 延续了 Todo 的心智模型,并在本课程 s07 之后成为默认主线。两者都管理带状态的任务项,但 TaskManager 增加了文件持久化(崩溃后可恢复)、依赖追踪(blocks/blockedBy)、owner 字段与多进程协作能力。Todo 仍适合短、线性、一次性的轻量跟踪。"
@@ -56,34 +56,6 @@
"title": "耐久性には書き込み規律が必要",
"description": "ファイル永続化だけでは並行書き込み競合は防げない。更新前に JSON を再読込し、`status/blockedBy` を検証して原子的に保存することで、他エージェントの遷移上書きを防ぐ。"
}
},
{
"id": "reminder-advisory-not-switch",
"title": "Reminder Is Advisory, Not a Mode Switch",
"description": "Reminder signals should be treated as omission-reduction hints, not as control-plane switches. Choosing Task vs Todo should come from workflow intent and currently available tools, while reminders only nudge usage when tracking appears stale.",
"alternatives": "Treating reminders as implicit mode selectors looks convenient, but it hides decision boundaries and makes behavior harder to reason about during long sessions.",
"zh": {
"title": "Reminder 是提示,不是模式开关",
"description": "Reminder 信号用于降低遗漏,不应当被当作控制面的模式切换器。Task/Todo 的选择应由工作流意图与可用工具决定,提醒只在追踪滞后时提供轻量提示。"
},
"ja": {
"title": "Reminder は助言でありモード切替ではない",
"description": "Reminder は取りこぼしを減らすための助言であり、制御面のモード切替として扱わない。Task/Todo の選択はワークフロー意図と利用可能ツールで決め、Reminder は追跡が滞ったときに軽く促す。"
}
},
{
"id": "todo-task-fast-matrix",
"title": "Todo/Task Fast Decision Matrix",
"description": "Use Todo for short one-session linear checklists. Use Task for cross-session work, dependencies, or teammate coordination. If uncertain, start with Task because downscoping is cheaper than migrating state mid-run.",
"alternatives": "Always using Todo keeps the model minimal but breaks durability and collaboration. Always using Task maximizes consistency but may feel heavy for tiny one-shot notes.",
"zh": {
"title": "Todo/Task 快速判定矩阵",
"description": "短时单会话线性清单用 Todo;跨会话、依赖、多人协作用 Task;拿不准时先用 Task,因为后续降级比半途迁移状态更便宜。"
},
"ja": {
"title": "Todo/Task クイック判定マトリクス",
"description": "短い単一セッションの直線タスクは Todo、セッション跨ぎや依存・協調がある作業は Task。迷うなら Task 開始が安全で、後で簡略化する方が途中移行より低コスト。"
}
}
]
}
+12 -12
View File
@@ -4,43 +4,43 @@
{
"id": "teammate-vs-subagent",
"title": "Persistent Teammates vs One-Shot Subagents",
"description": "In v3, subagents are ephemeral: spawn, do one task, return result, die. Their knowledge dies with them. In v8, teammates are persistent threads with identity (name, role) and config files. A teammate can complete task A, then be assigned task B, carrying forward everything it learned. This is the difference between hiring a contractor for one job and having a team member. Persistent teammates accumulate project knowledge, understand established patterns, and don't need to re-read the same files for every task.",
"alternatives": "One-shot subagents (v3 style) are simpler and provide perfect context isolation -- no risk of one task's context polluting another. But the re-learning cost is high: every new task starts from zero. A middle ground (subagents with shared memory/knowledge base) was considered but adds complexity without the full benefit of persistent identity and state.",
"description": "In s04, subagents are ephemeral: spawn, do one task, return result, die. Their knowledge dies with them. In s09, teammates are persistent threads with identity (name, role) and config files. A teammate can complete task A, then be assigned task B, carrying forward everything it learned. Persistent teammates accumulate project knowledge, understand established patterns, and don't need to re-read the same files for every task.",
"alternatives": "One-shot subagents (s04 style) are simpler and provide perfect context isolation -- no risk of one task's context polluting another. But the re-learning cost is high: every new task starts from zero. A middle ground (subagents with shared memory/knowledge base) was considered but adds complexity without the full benefit of persistent identity and state.",
"zh": {
"title": "持久化队友 vs 一次性子代理",
"description": "在 v3 中,子代理是临时的:创建、执行一个任务、返回结果、销毁。它们的知识随之消亡。在 v8 中,队友是具有身份(名称、角色)和配置文件的持久化线程。队友可以完成任务 A,然后被分配任务 B,并携带之前学到的所有知识。这就是雇一个临时工做一个项目和拥有一个团队成员之间的区别。持久化队友积累项目知识,理解已建立的模式,不需要为每个任务重新阅读相同的文件。"
"title": "持久化队友 vs 一次性子智能体",
"description": "在 s04 中,子智能体是临时的:创建、执行一个任务、返回结果、销毁。它们的知识随之消亡。在 s09 中,队友是具有身份(名称、角色)和配置文件的持久化线程。队友可以完成任务 A,然后被分配任务 B,并携带之前学到的所有知识。持久化队友积累项目知识,理解已建立的模式,不需要为每个任务重新阅读相同的文件。"
},
"ja": {
"title": "永続的なチームメイト vs 使い捨てサブエージェント",
"description": "v3 ではサブエージェントは一時的です:生成、1つのタスクを実行、結果を返却、消滅。その知識も一緒に消えます。v8 ではチームメイトはアイデンティティ(名前、役割)と設定ファイルを持つ永続的なスレッドです。チームメイトはタスク A を完了した後、学んだ全てを引き継いでタスク B に割り当てられます。これは1つの仕事のために請負業者を雇うことと、チームメンバーを持つことの違いです。永続的なチームメイトはプロジェクトの知識を蓄積し、確立されたパターンを理解し、タスクごとに同じファイルを再読する必要がありません。"
"description": "s04 ではサブエージェントは一時的です:生成、1つのタスクを実行、結果を返却、消滅。その知識も一緒に消えます。s09 ではチームメイトはアイデンティティ(名前、役割)と設定ファイルを持つ永続的なスレッドです。チームメイトはタスク A を完了した後、学んだ全てを引き継いでタスク B に割り当てられます。永続的なチームメイトはプロジェクトの知識を蓄積し、確立されたパターンを理解し、タスクごとに同じファイルを再読する必要がありません。"
}
},
{
"id": "file-based-team-config",
"title": "Team Config Persisted to .teams/{name}/config.json",
"description": "Team structure (member names, roles, agent IDs) is stored in a JSON config file, not in any agent's memory. Any agent can discover its teammates by reading the config file -- no need for a discovery service or shared memory. If an agent crashes and restarts, it reads the config to find out who else is on the team. This is consistent with the v6 philosophy: the filesystem is the coordination layer. Config files are also human-readable, making it easy to manually add/remove team members or debug team setup issues.",
"description": "Team structure (member names, roles, agent IDs) is stored in a JSON config file, not in any agent's memory. Any agent can discover its teammates by reading the config file -- no need for a discovery service or shared memory. If an agent crashes and restarts, it reads the config to find out who else is on the team. This is consistent with the s07 philosophy: the filesystem is the coordination layer.",
"alternatives": "In-memory team registries are faster but don't survive process restarts and require a central process to maintain. Service discovery (like DNS or a discovery server) is more robust at scale but overkill for a local multi-agent system. File-based config is the simplest approach that works across independent processes.",
"zh": {
"title": "团队配置持久化到 .teams/{name}/config.json",
"description": "团队结构(成员名称、角色、agent ID)存储在 JSON 配置文件中,而非任何 agent 的内存中。任何 agent 都可以通过读取配置文件发现队友——无需发现服务或共享内存。如果 agent 崩溃并重启,它读取配置即可知道团队中还有谁。这与 v6 的理念一致:文件系统就是协调层。配置文件人类可读,便于手动添加或移除团队成员、调试团队配置问题。"
"description": "团队结构(成员名称、角色、agent ID)存储在 JSON 配置文件中,而非任何 agent 的内存中。任何 agent 都可以通过读取配置文件发现队友——无需发现服务或共享内存。如果 agent 崩溃并重启,它读取配置即可知道团队中还有谁。这与 s07 的理念一致:文件系统就是协调层。配置文件人类可读,便于手动添加或移除团队成员、调试团队配置问题。"
},
"ja": {
"title": "チーム設定を .teams/{name}/config.json に永続化",
"description": "チーム構成(メンバー名、役割、エージェント ID)はエージェントのメモリではなく JSON 設定ファイルに保存されます。どのエージェントも設定ファイルを読むことでチームメイトを発見できます――ディスカバリーサービスや共有メモリは不要です。エージェントがクラッシュして再起動した場合、設定を読んで他のチームメンバーを把握します。これは v6 の思想と一貫しています:ファイルシステムが連携レイヤーです。設定ファイルは人間が読めるため、チームメンバーの手動追加・削除やチーム設定問題のデバッグが容易です。"
"description": "チーム構成(メンバー名、役割、エージェント ID)はエージェントのメモリではなく JSON 設定ファイルに保存されます。どのエージェントも設定ファイルを読むことでチームメイトを発見できます――ディスカバリーサービスや共有メモリは不要です。エージェントがクラッシュして再起動した場合、設定を読んで他のチームメンバーを把握します。これは s07 の思想と一貫しています:ファイルシステムが連携レイヤーです。"
}
},
{
"id": "tool-filtering-by-role",
"title": "Teammates Get Subset of Tools, Lead Gets All",
"description": "The team lead receives ALL_TOOLS (including TeamCreate, SendMessage, TaskCreate, etc.) while teammates receive TEAMMATE_TOOLS (a reduced set focused on task execution). This enforces a clear separation of concerns: teammates focus on doing work (coding, testing, researching), while the lead focuses on coordination (creating tasks, assigning work, managing communication). Giving teammates coordination tools would let them create their own sub-teams or reassign tasks, undermining the lead's ability to maintain a coherent plan.",
"alternatives": "Giving all agents identical tools is simpler and more egalitarian, but in practice leads to coordination chaos -- multiple agents trying to manage each other, creating conflicting task assignments. A permission system (any agent can request elevation) adds flexibility but also complexity. Static role-based filtering is predictable and easy to reason about.",
"description": "The team lead receives ALL_TOOLS (including spawn, send, read_inbox, etc.) while teammates receive TEAMMATE_TOOLS (a reduced set focused on task execution). This enforces a clear separation of concerns: teammates focus on doing work (coding, testing, researching), while the lead focuses on coordination (creating tasks, assigning work, managing communication). Giving teammates coordination tools would let them create their own sub-teams or reassign tasks, undermining the lead's ability to maintain a coherent plan.",
"alternatives": "Giving all agents identical tools is simpler and more egalitarian, but in practice leads to coordination chaos -- multiple agents trying to manage each other, creating conflicting task assignments. Static role-based filtering is predictable and easy to reason about.",
"zh": {
"title": "队友获得工具子集,组长获得全部工具",
"description": "团队组长获得 ALL_TOOLS(包括 TeamCreate、SendMessage、TaskCreate 等),而队友获得 TEAMMATE_TOOLS(专注于任务执行的精简工具集)。这强制了清晰的职责分离:队友专注于做事(编码、测试、研究),组长专注于协调(创建任务、分配工作、管理沟通)。给队友协调工具会让他们创建自己的子团队或重新分配任务,破坏组长维持连贯计划的能力。"
"description": "团队组长获得 ALL_TOOLS(包括 spawn、send、read_inbox 等),而队友获得 TEAMMATE_TOOLS(专注于任务执行的精简工具集)。这强制了清晰的职责分离:队友专注于做事(编码、测试、研究),组长专注于协调(创建任务、分配工作、管理沟通)。给队友协调工具会让他们创建自己的子团队或重新分配任务,破坏组长维持连贯计划的能力。"
},
"ja": {
"title": "チームメイトはツールのサブセット、リーダーは全ツール",
"description": "チームリーダーは ALL_TOOLS(TeamCreate、SendMessage、TaskCreate など含む)を受け取り、チームメイトは TEAMMATE_TOOLS(タスク実行に特化した縮小セット)を受け取ります。これにより明確な関心の分離が強制されます:チームメイトは作業(コーディング、テスト、調査)に集中し、リーダーは調整(タスク作成、作業割り当て、コミュニケーション管理)に集中します。チームメイトに調整ツールを与えると、独自のサブチーム作成やタスクの再割り当てが可能になり、リーダーの一貫した計画維持能力が損なわれます。"
"description": "チームリーダーは ALL_TOOLS(spawn、send、read_inbox など含む)を受け取り、チームメイトは TEAMMATE_TOOLS(タスク実行に特化した縮小セット)を受け取ります。これにより明確な関心の分離が強制されます:チームメイトは作業(コーディング、テスト、調査)に集中し、リーダーは調整(タスク作成、作業割り当て、コミュニケーション管理)に集中します。"
}
}
]
+6 -6
View File
@@ -12,21 +12,21 @@
},
"ja": {
"title": "共有メモリではなく JSONL インボックスファイル",
"description": "各チームメイトはチームディレクトリ内に独自のインボックスファイル(JSONL ファイル)を持ちます。メッセージの送信は受信者のインボックスファイルに JSON 行を追記することです。メッセージの読み取りはインボックスファイルを読んで最後に読んだ行を追跡することです。JSONL は本質的に追記専用で、並行ライターが互いのデータを破壊しません(異なるファイル位置への追記)。共有メモリ、ミューテックス、IPC メカニズムなしにプロセス間で動作します。クラッシュにも安全です:ライターが追記途中でクラッシュしても、最悪の場合は不完全な1行だけでリーダーはスキップできます。"
"description": "各チームメイトはチームディレクトリ内に独自のインボックスファイル(JSONL ファイル)を持ちます。メッセージの送信は受信者のインボックスファイルに JSON 行を追記することです。メッセージの読み取りはインボックスファイルを読んで最後に読んだ行を追跡することです。JSONL は本質的に追記専用で、並行ライターが互いのデータを破壊しません(異なるファイル位置への追記)。共有メモリ、ミューテックス、IPC メカニズムなしにプロセス間で動作します。"
}
},
{
"id": "five-message-types",
"title": "Exactly Five Message Types Cover All Coordination Patterns",
"description": "The messaging system supports exactly five types: (1) 'message' for point-to-point communication between two agents, (2) 'broadcast' for team-wide announcements, (3) 'shutdown_request' for graceful termination, (4) 'shutdown_response' for acknowledging shutdown, (5) 'plan_approval_response' for the lead to approve or reject a teammate's plan. These five types map to the fundamental coordination patterns: direct communication, broadcast, lifecycle management, and approval workflows. Adding more types (e.g., priority_message, status_update) would increase complexity without enabling new coordination patterns.",
"alternatives": "A single generic message type with metadata fields would be more flexible but makes it harder to enforce protocol correctness. Many more types (10+) would provide finer-grained semantics but increase the model's decision burden. Five types is the sweet spot where every type has a clear, distinct purpose and the model can reliably choose the right one.",
"description": "The messaging system supports exactly five types: (1) 'message' for point-to-point communication between two agents, (2) 'broadcast' for team-wide announcements, (3) 'shutdown_request' for graceful termination, (4) 'shutdown_response' for acknowledging shutdown, (5) 'plan_approval_response' for the lead to approve or reject a teammate's plan. These five types map to the fundamental coordination patterns: direct communication, broadcast, lifecycle management, and approval workflows.",
"alternatives": "A single generic message type with metadata fields would be more flexible but makes it harder to enforce protocol correctness. Many more types (10+) would provide finer-grained semantics but increase the model's decision burden. Five types is the sweet spot where every type has a clear, distinct purpose.",
"zh": {
"title": "恰好五种消息类型覆盖所有协调模式",
"description": "消息系统恰好支持五种类型:(1) message 用于两个 agent 间的点对点通信;(2) broadcast 用于全团队公告;(3) shutdown_request 用于优雅终止;(4) shutdown_response 用于确认终止;(5) plan_approval_response 用于组长批准或拒绝队友的计划。这五种类型映射到基本协调模式:直接通信、广播、生命周期管理和审批流程。增加更多类型(如 priority_message、status_update)只会增加复杂度而不会启用新的协调模式。"
"description": "消息系统恰好支持五种类型:(1) message 用于两个 agent 间的点对点通信;(2) broadcast 用于全团队公告;(3) shutdown_request 用于优雅终止;(4) shutdown_response 用于确认终止;(5) plan_approval_response 用于组长批准或拒绝队友的计划。这五种类型映射到基本协调模式:直接通信、广播、生命周期管理和审批流程。"
},
"ja": {
"title": "正確に5つのメッセージタイプで全連携パターンをカバー",
"description": "メッセージングシステムは正確に5つのタイプをサポートします:(1) message は2つのエージェント間のポイントツーポイント通信、(2) broadcast はチーム全体への通知、(3) shutdown_request はグレースフルな終了要求、(4) shutdown_response はシャットダウンの確認応答、(5) plan_approval_response はリーダーによるチームメイトの計画の承認・却下。これら5タイプは基本的な連携パターンに対応します:直接通信、ブロードキャスト、ライフサイクル管理、承認ワークフロー。タイプを増やしても(priority_message、status_update など)新たな連携パターンは生まれず、複雑さが増すだけです。"
"description": "メッセージングシステムは正確に5つのタイプをサポートします:(1) message は2つのエージェント間のポイントツーポイント通信、(2) broadcast はチーム全体への通知、(3) shutdown_request はグレースフルな終了要求、(4) shutdown_response はシャットダウンの確認応答、(5) plan_approval_response はリーダーによるチームメイトの計画の承認・却下。"
}
},
{
@@ -40,7 +40,7 @@
},
"ja": {
"title": "毎回の LLM 呼び出し前にインボックスを確認",
"description": "チームメイトはエージェントループの各イテレーションの冒頭、LLM API を呼び出す前にインボックスファイルを確認します。これにより受信メッセージへの応答性を最大化します:シャットダウンリクエストは1ループイテレーション以内(通常数秒)で確認され、現在のタスク完了後(数分かかる可能性)ではありません。インボックスの確認は安価で(小さなファイルを読み、新しい行があるか確認)、LLM 呼び出し(秒単位のレイテンシ、数千トークン)と比べて微々たるものです。この配置により受信メッセージが次の LLM 呼び出しに影響できます――「X の作業を止めて Y に切り替えて」というメッセージが即座に有効になります。"
"description": "チームメイトはエージェントループの各イテレーションの冒頭、LLM API を呼び出す前にインボックスファイルを確認します。これにより受信メッセージへの応答性を最大化します:シャットダウンリクエストは1ループイテレーション以内(通常数秒)で確認され、現在のタスク完了後(数分かかる可能性)ではありません。"
}
}
]
+4 -4
View File
@@ -12,7 +12,7 @@
},
"ja": {
"title": "イベント駆動通知ではなくポーリングで未割り当てタスクを発見",
"description": "自律的なチームメイトはイベント駆動の通知を待つのではなく、約1秒ごとに共有タスクボードをポーリングして未割り当てタスクを探します。ポーリングはパブ/サブより根本的にシンプルです:サブスクリプション管理、イベントルーティング、イベント欠落バグがありません。ファイルベースの永続化では、ポーリングは「ディレクトリ一覧を読む」だけで、実行中のエージェント数に関係なく動作する安価な操作です。1秒間隔は応答性(新タスクの迅速な発見)とファイルシステムのオーバーヘッド(ディスク読み取りの過負荷回避)のバランスを取っています。"
"description": "自律的なチームメイトはイベント駆動の通知を待つのではなく、約1秒ごとに共有タスクボードをポーリングして未割り当てタスクを探します。ポーリングはパブ/サブより根本的にシンプルです:サブスクリプション管理、イベントルーティング、イベント欠落バグがありません。ファイルベースの永続化では、ポーリングは「ディレクトリ一覧を読む」だけで、実行中のエージェント数に関係なく動作する安価な操作です。"
}
},
{
@@ -26,21 +26,21 @@
},
"ja": {
"title": "60秒のアイドルタイムアウトで自動終了",
"description": "自律的なチームメイトが作業するタスクもインボックスのメッセージもない場合、最大60秒待ってから諦めてシャットダウンします。これにより永遠に来ない仕事を待ち続けるゾンビチームメイトを防ぎます――リーダーがシャットダウンリクエストの送信を忘れたり、残りのタスクが全て外部イベントでブロックされている場合に実際に起こる問題です。60秒のウィンドウはタスク完了から新タスク作成までの短い間隔で早期シャットダウンが起きない十分な長さであり、かつ未使用のチームメイトがリソースを浪費しない十分な短さです。"
"description": "自律的なチームメイトが作業するタスクもインボックスのメッセージもない場合、最大60秒待ってから諦めてシャットダウンします。これにより永遠に来ない仕事を待ち続けるゾンビチームメイトを防ぎます。60秒のウィンドウはタスク完了から新タスク作成までの短い間隔で早期シャットダウンが起きない十分な長さであり、かつ未使用のチームメイトがリソースを浪費しない十分な短さです。"
}
},
{
"id": "identity-after-compression",
"title": "Re-Inject Teammate Identity After Context Compression",
"description": "When auto_compact compresses the conversation, the resulting summary loses crucial metadata: the teammate's name, which team it belongs to, and its agent_id. Without this information, the teammate can't claim tasks (tasks are owned by name), can't check its inbox (inbox files are keyed by agent_id), and can't identify itself in messages. So after every auto_compact, the system re-injects a structured identity block into the conversation: 'You are [name] on team [team], your agent_id is [id], your inbox is at [path].' This is the minimum context needed for the teammate to remain functional after memory loss.",
"alternatives": "Putting identity in the system prompt (which survives compression) would avoid this problem, but violates the cache-friendly static-system-prompt design from v4. Embedding identity in the summary prompt ('when summarizing, always include your name and team') is unreliable -- the LLM might omit it. Explicit post-compression injection is deterministic and guaranteed to work.",
"alternatives": "Putting identity in the system prompt (which survives compression) would avoid this problem, but violates the cache-friendly static-system-prompt design from s05. Embedding identity in the summary prompt ('when summarizing, always include your name and team') is unreliable -- the LLM might omit it. Explicit post-compression injection is deterministic and guaranteed to work.",
"zh": {
"title": "上下文压缩后重新注入队友身份",
"description": "自动压缩对话时,生成的摘要会丢失关键元数据:队友的名称、所属团队和 agent_id。没有这些信息,队友无法认领任务(任务按名称归属)、无法检查收件箱(收件箱文件以 agent_id 为键)、也无法在消息中表明身份。因此每次自动压缩后,系统会向对话中重新注入一个结构化的身份块:'你是 [team] 团队的 [name],你的 agent_id 是 [id],你的收件箱在 [path]。'这是队友在记忆丢失后保持功能所需的最小上下文。"
},
"ja": {
"title": "コンテキスト圧縮後にチームメイトのアイデンティティを再注入",
"description": "自動コンパクトが会話を圧縮すると、生成された要約は重要なメタデータを失います:チームメイトの名前、所属チーム、agent_id。この情報がなければチームメイトはタスクを申告できず(タスクは名前で所有)、インボックスを確認できず(インボックスファイルは agent_id をキーとする)、メッセージで自分を識別できません。そのため自動コンパクトの後、システムは構造化されたアイデンティティブロックを会話に再注入します:「あなたは [team] チームの [name] です。agent_id は [id]、インボックスは [path] にあります。」これはメモリ喪失後もチームメイトが機能し続けるために必要な最小限のコンテキストです。"
"description": "自動コンパクトが会話を圧縮すると、生成された要約は重要なメタデータを失います:チームメイトの名前、所属チーム、agent_id。この情報がなければチームメイトはタスクを申告できず(タスクは名前で所有)、インボックスを確認できず(インボックスファイルは agent_id をキーとする)、メッセージで自分を識別できません。そのため自動コンパクトの後、システムは構造化されたアイデンティティブロックを会話に再注入します。これはメモリ喪失後もチームメイトが機能し続けるために必要な最小限のコンテキストです。"
}
}
]
+6 -20
View File
@@ -32,15 +32,15 @@
{
"id": "lane-cwd-routing-and-reentry-guard",
"title": "Lane-Scoped CWD Routing + Re-entry Guard",
"description": "This course runtime uses lane-scoped cwd routing (`worktree_run(name, command)`). Other runtimes may choose session-level cwd switches. The design goal is predictable lane context with a re-entry guard when already inside an active worktree context.",
"description": "Commands are routed to a worktree's directory via `worktree_run(name, command)` using the `cwd` parameter. A re-entry guard prevents accidentally running inside an already-active worktree context, keeping lifecycle ownership unambiguous.",
"alternatives": "Global cwd mutation is easy to implement but can leak context across parallel work. Allowing silent re-entry makes lifecycle ownership ambiguous and complicates teardown behavior.",
"zh": {
"title": "按通道 cwd 路由 + 禁止重入",
"description": "本课程运行时采用按通道 `cwd` 路由(`worktree_run(name, command)`)。其他运行时也可能选择会话级 cwd 切换。设计目标是让并行通道可预测,并在已处于 active worktree 上下文时通过重入保护避免二次进入。"
"description": "命令通过 `worktree_run(name, command)` 使用 `cwd` 参数路由到 worktree 目录。重入保护避免了在已激活的 worktree 上下文中意外二次进入,保持生命周期归属清晰。"
},
"ja": {
"title": "レーン単位 cwd ルーティング + 再入防止",
"description": "本コース実装では `worktree_run(name, command)` によるレーン単位 cwd ルーティングを採用する。実装によってはセッション単位で cwd を切り替える場合もある。狙いは並列レーンの予測可能性を保ち、active な worktree 文脈での再入を防ぐこと。"
"description": "`worktree_run(name, command)` で `cwd` パラメータを使いコマンドを worktree ディレクトリへ転送する。再入ガードにより active な worktree への二重入場を防ぎ、ライフサイクルの帰属を明確に保つ。"
}
},
{
@@ -57,32 +57,18 @@
"description": "ライフサイクルイベントを `.worktrees/events.jsonl` に追記する(`worktree.create.*`、`worktree.remove.*`、`task.completed` など)。遷移が可観測になり、失敗も `*.failed` として明示できる。"
}
},
{
"id": "hook-style-extension",
"title": "Hook-Style Extensions via Event Triplets",
"description": "Treat `before/after/failed` lifecycle emissions as extension points. Keep source-of-truth state writes in task/worktree files, and run side effects (audit, notification, policy checks) in event consumers.",
"alternatives": "Embedding every side effect directly in create/remove logic couples concerns tightly and makes failure handling harder. Moving source-of-truth to event replay is also risky without strict idempotency/repair semantics.",
"zh": {
"title": "通过三段事件实现 Hook 风格扩展",
"description": "把 `before/after/failed` 生命周期事件当作扩展插槽。真实状态写入仍留在 task/worktree 文件,审计、通知、策略检查等副作用交给事件消费者。"
},
"ja": {
"title": "三段イベントによる Hook 風拡張",
"description": "`before/after/failed` ライフサイクルイベントを拡張ポイントとして使う。正準状態は task/worktree ファイルに残し、副作用(監査・通知・ポリシーチェック)はイベント購読側で処理する。"
}
},
{
"id": "task-worktree-closeout",
"title": "Close Task and Workspace Together",
"description": "`worktree_remove(..., complete_task=true)` allows a single closeout step: remove the isolated directory and mark the bound task completed. In this course model, closeout remains an explicit tool-driven transition (`worktree_keep` / `worktree_remove`) rather than hidden automatic cleanup. This reduces dangling state where a task says done but its temporary lane remains active (or the reverse).",
"description": "`worktree_remove(..., complete_task=true)` allows a single closeout step: remove the isolated directory and mark the bound task completed. Closeout remains an explicit tool-driven transition (`worktree_keep` / `worktree_remove`) rather than hidden automatic cleanup. This reduces dangling state where a task says done but its temporary lane remains active (or the reverse).",
"alternatives": "Keeping closeout fully manual gives flexibility but increases operational drift. Fully automatic removal on every completion risks deleting a workspace before final review.",
"zh": {
"title": "任务与工作区一起收尾",
"description": "`worktree_remove(..., complete_task=true)` 允许在一个动作里完成收尾:删除隔离目录并把绑定任务标记为 completed。在本课程模型里,收尾保持为显式工具驱动迁移(`worktree_keep` / `worktree_remove`),而不是隐藏的自动清理。这样可减少状态悬挂(任务已完成但临时工作区仍活跃,或反过来)。"
"description": "`worktree_remove(..., complete_task=true)` 允许在一个动作里完成收尾:删除隔离目录并把绑定任务标记为 completed。收尾保持为显式工具驱动迁移(`worktree_keep` / `worktree_remove`),而不是隐藏的自动清理。这样可减少状态悬挂(任务已完成但临时工作区仍活跃,或反过来)。"
},
"ja": {
"title": "タスクとワークスペースを同時にクローズ",
"description": "`worktree_remove(..., complete_task=true)` により、分離ディレクトリ削除とタスク完了更新を1ステップで実行できる。本コースのモデルでは、クローズ処理は `worktree_keep` / `worktree_remove` の明示ツール遷移として扱い、暗黙の自動清掃にはしない。完了済みタスクに未回収レーンが残る、といったズレを減らせる。"
"description": "`worktree_remove(..., complete_task=true)` により、分離ディレクトリ削除とタスク完了更新を1ステップで実行できる。クローズ処理は `worktree_keep` / `worktree_remove` の明示ツール遷移として扱い、暗黙の自動清掃にはしない。"
}
},
{
+45 -45
View File
@@ -3,216 +3,216 @@
"version": "s01",
"locale": "en",
"title": "s01: The Agent Loop",
"content": "# s01: The Agent Loop\n\n> The core of a coding agent is a while loop that feeds tool results back to the model until the model decides to stop.\n\n## The Problem\n\nWhy can't a language model just answer a coding question? Because coding\nrequires _interaction with the real world_. The model needs to read files,\nrun tests, check errors, and iterate. A single prompt-response pair cannot\ndo this.\n\nWithout the agent loop, you would have to copy-paste outputs back into the\nmodel yourself. The user becomes the loop. The agent loop automates this:\ncall the model, execute whatever tools it asks for, feed the results back,\nrepeat until the model says \"I'm done.\"\n\nConsider a simple task: \"Create a Python file that prints hello.\" The model\nneeds to (1) decide to write a file, (2) write it, (3) verify it works.\nThat is three tool calls minimum. Without a loop, each one requires manual\nhuman intervention.\n\n## The Solution\n\n```\n+----------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tool |\n| prompt | | | | execute |\n+----------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +---------------+\n (loop continues)\n\nThe loop terminates when stop_reason != \"tool_use\".\nThat single condition is the entire control flow.\n```\n\n## How It Works\n\n1. The user provides a prompt. It becomes the first message.\n\n```python\nhistory.append({\"role\": \"user\", \"content\": query})\n```\n\n2. The messages array is sent to the LLM along with the tool definitions.\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n3. The assistant response is appended to messages.\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\n```\n\n4. We check the stop reason. If the model did not call a tool, the loop\n ends. In this minimal lesson implementation, this is the only loop exit\n condition.\n\n```python\nif response.stop_reason != \"tool_use\":\n return\n```\n\n5. For each tool_use block in the response, execute the tool (bash in this\n session) and collect results.\n\n```python\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\n6. The results are appended as a user message, and the loop continues.\n\n```python\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n## Key Code\n\nThe minimum viable agent -- the entire pattern in under 30 lines\n(from `agents/s01_agent_loop.py`, lines 66-86):\n\n```python\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 if response.stop_reason != \"tool_use\":\n return\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## What Changed\n\nThis is session 1 -- the starting point. There is no prior session.\n\n| Component | Before | After |\n|---------------|------------|--------------------------------|\n| Agent loop | (none) | `while True` + stop_reason |\n| Tools | (none) | `bash` (one tool) |\n| Messages | (none) | Accumulating list |\n| Control flow | (none) | `stop_reason != \"tool_use\"` |\n\n## Design Rationale\n\nThis loop is the foundation of LLM-based agents. Production implementations add error handling, token counting, streaming, retry logic, permission policy, and lifecycle orchestration, but the core interaction pattern still starts here. The simplicity is the point for this session: in this minimal implementation, one exit condition (`stop_reason != \"tool_use\"`) controls the flow we need to learn first. Everything else in this course layers on top of this loop. Understanding this loop gives you the base model, not the full production architecture.\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s01_agent_loop.py\n```\n\nExample prompts to try:\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 gLine truncated
"content": "# s01: The Agent Loop\n\n> *\"One loop & Bash is all you need\"* -- one tool + one loop = an agent.\n\n## Problem\n\nA language model can reason about code, but it can't *touch* the real world. It can't read files, run tests, or check errors. Without a loop, every tool call requires you to manually copy-paste results back. You become the loop.\n\n## Solution\n\n```\n+--------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tool |\n| prompt | | | | execute |\n+--------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +----------------+\n (loop until stop_reason != \"tool_use\")\n```\n\nOne exit condition controls the entire flow. The loop runs until the model stops calling tools.\n\n## How It Works\n\n1. User prompt becomes the first message.\n\n```python\nmessages.append({\"role\": \"user\", \"content\": query})\n```\n\n2. Send messages + 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\n3. Append the assistant response. Check `stop_reason` -- if the model didn't call a tool, we're done.\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\nif response.stop_reason != \"tool_use\":\n return\n```\n\n4. Execute each tool call, collect results, append as a user message. Loop back to step 2.\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 })\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\nAssembled into one function:\n\n```python\ndef agent_loop(query):\n messages = [{\"role\": \"user\", \"content\": query}]\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\nThat's the entire agent in under 30 lines. Everything else in this course layers on top of this loop -- without changing it.\n\n## What Changed\n\n| Component | Before | After |\n|---------------|------------|--------------------------------|\n| Agent loop | (none) | `while True` + stop_reason |\n| Tools | (none) | `bash` (one tool) |\n| Messages | (none) | Accumulating list |\n| Control flow | (none) | `stop_reason != \"tool_use\"` |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s01_agent_loop.py\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?`\n4. `Create a directory called test_output and write 3 files in it`\n"
},
{
"version": "s02",
"locale": "en",
"title": "s02: Tools",
"content": "# s02: Tools\n\n> A dispatch map routes tool calls to handler functions -- the loop itself does not change at all.\n\n## The Problem\n\nWith only `bash`, the agent shells out for everything: reading files,\nwriting files, editing files. This works but is fragile. `cat` output\ngets truncated unpredictably. `sed` replacements fail on special\ncharacters. The model wastes tokens constructing shell pipelines when\na direct function call would be simpler.\n\nMore importantly, bash is a security surface. Every bash call can do\nanything the shell can do. With dedicated tools like `read_file` and\n`write_file`, you can enforce path sandboxing and block dangerous\npatterns at the tool level rather than hoping the model avoids them.\n\nThe insight is that adding tools does not require changing the loop.\nThe loop from s01 stays identical. You add entries to the tools array,\nadd handler functions, and wire them together with a dispatch map.\n\n## The Solution\n\n```\n+----------+ +-------+ +------------------+\n| User | ---> | LLM | ---> | Tool Dispatch |\n| prompt | | | | { |\n+----------+ +---+---+ | bash: run_bash |\n ^ | read: run_read |\n | | write: run_wr |\n +----------+ edit: run_edit |\n tool_result| } |\n +------------------+\n\nThe dispatch map is a dict: {tool_name: handler_function}\nOne lookup replaces any if/elif chain.\n```\n\n## How It Works\n\n1. Define handler functions for each tool. Each takes keyword arguments\n matching the tool's input_schema and returns a string result.\n\n```python\ndef run_read(path: str, limit: int = None) -> str:\n text = safe_path(path).read_text()\n lines = text.splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit]\n return \"\\n\".join(lines)[:50000]\n```\n\n2. Create the dispatch map linking tool names to handlers.\n\n```python\nTOOL_HANDLERS = {\n \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n \"read_file\": lambda **kw: run_read(kw[\"path\"], kw.get(\"limit\")),\n \"write_file\": lambda **kw: run_write(kw[\"path\"], kw[\"content\"]),\n \"edit_file\": lambda **kw: run_edit(kw[\"path\"], kw[\"old_text\"],\n kw[\"new_text\"]),\n}\n```\n\n3. In the agent loop, look up the handler by name instead of hardcoding.\n\n```python\nfor block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input)\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n4. Path sandboxing prevents the model from escaping the workspace.\n\n```python\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\n## Key Code\n\nThe dispatch pattern (from `agents/s02_tool_use.py`, lines 93-129):\n\n```python\nTOOL_HANDLERS = {\n \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n \"read_file\": lambda **kw: run_read(kw[\"path\"], kw.get(\"limit\")),\n \"write_file\": lambda **kw: run_write(kw[\"path\"], kw[\"content\"]),\n \"edit_file\": lambda **kw: run_edit(kw[\"path\"], kw[\"old_text\"],\n kw[\"new_text\"]),\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 if response.stop_reason != \"tool_use\":\n return\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler \\\n else f\"Unknown tool: {block.name}\"\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## What Changed From s01\n\n| Component | Before (s01) | After (s02) |\n|----------------|--------------------|----------------------------|\n| Tools | 1 (bash only) | 4 (bash, read, write, edit)|\n| Dispatch | Hardcoded bash call | `TOOL_HANDLERS` dict |\n| Path safety | None | `safe_path()` sandbox |\n| Agent loop | Unchanged | Unchanged |\n\n## Design Rationale\n\nThe dispaLine truncated
"title": "s02: Tool Use",
"content": "# s02: Tool Use\n\n> *\"The loop didn't change\"* -- adding tools means adding handlers, not rewriting the loop.\n\n## Problem\n\nWith only `bash`, the agent shells out for everything. `cat` truncates unpredictably, `sed` fails on special characters, and every bash call is an unconstrained security surface. Dedicated tools like `read_file` and `write_file` let you enforce path sandboxing at the tool level.\n\nThe key insight: adding tools does not require changing the loop.\n\n## Solution\n\n```\n+--------+ +-------+ +------------------+\n| User | ---> | LLM | ---> | Tool Dispatch |\n| prompt | | | | { |\n+--------+ +---+---+ | bash: run_bash |\n ^ | read: run_read |\n | | write: run_wr |\n +-----------+ edit: run_edit |\n tool_result | } |\n +------------------+\n\nThe dispatch map is a dict: {tool_name: handler_function}.\nOne lookup replaces any if/elif chain.\n```\n\n## How It Works\n\n1. Each tool gets a handler function. Path sandboxing prevents workspace escape.\n\n```python\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\ndef run_read(path: str, limit: int = None) -> str:\n text = safe_path(path).read_text()\n lines = text.splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit]\n return \"\\n\".join(lines)[:50000]\n```\n\n2. The dispatch map links tool names to handlers.\n\n```python\nTOOL_HANDLERS = {\n \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n \"read_file\": lambda **kw: run_read(kw[\"path\"], kw.get(\"limit\")),\n \"write_file\": lambda **kw: run_write(kw[\"path\"], kw[\"content\"]),\n \"edit_file\": lambda **kw: run_edit(kw[\"path\"], kw[\"old_text\"],\n kw[\"new_text\"]),\n}\n```\n\n3. In the loop, look up the handler by name. The loop body itself is unchanged from s01.\n\n```python\nfor block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler \\\n else f\"Unknown tool: {block.name}\"\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\nAdd a tool = add a handler + add a schema entry. The loop never changes.\n\n## What Changed From s01\n\n| Component | Before (s01) | After (s02) |\n|----------------|--------------------|----------------------------|\n| Tools | 1 (bash only) | 4 (bash, read, write, edit)|\n| Dispatch | Hardcoded bash call | `TOOL_HANDLERS` dict |\n| Path safety | None | `safe_path()` sandbox |\n| Agent loop | Unchanged | Unchanged |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s02_tool_use.py\n```\n\n1. `Read the file requirements.txt`\n2. `Create a file called greet.py with a greet(name) function`\n3. `Edit greet.py to add a docstring to the function`\n4. `Read greet.py to verify the edit worked`\n"
},
{
"version": "s03",
"locale": "en",
"title": "s03: TodoWrite",
"content": "# s03: TodoWrite\n\n> A TodoManager lets the agent track its own progress, and a nag reminder injection forces it to keep updating when it forgets.\n\n## The Problem\n\nWhen an agent works on a multi-step task, it often loses track of what it\nhas done and what remains. Without explicit planning, the model might repeat\nwork, skip steps, or wander off on tangents. The user has no visibility\ninto the agent's internal plan.\n\nThis is worse than it sounds. Long conversations cause the model to \"drift\"\n-- the system prompt fades in influence as the context window fills with\ntool results. A 10-step refactoring task might complete steps 1-3, then\nthe model starts improvising because it forgot steps 4-10 existed.\n\nThe solution is structured state: a TodoManager that the model writes to\nexplicitly. The model creates a plan, marks items in_progress as it works,\nand marks them completed when done. A nag reminder injects a nudge if the\nmodel goes 3+ rounds without updating its todos.\n\nNote: the nag threshold of 3 rounds is low for visibility. Production systems tune higher. From s07, this course switches to the Task board for durable multi-step work; TodoWrite remains available for quick checklists.\n\n## The Solution\n\n```\n+----------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tools |\n| prompt | | | | + todo |\n+----------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +---------------+\n |\n +-----------+-----------+\n | TodoManager state |\n | [ ] task A |\n | [>] task B <- doing |\n | [x] task C |\n +-----------------------+\n |\n if rounds_since_todo >= 3:\n inject <reminder> into tool_result\n```\n\n## How It Works\n\n1. The TodoManager validates and stores a list of items with statuses.\n Only one item can be `in_progress` at a time.\n\n```python\nclass TodoManager:\n def __init__(self):\n self.items = []\n\n def update(self, items: list) -> str:\n validated = []\n in_progress_count = 0\n for item in items:\n status = item.get(\"status\", \"pending\")\n if status == \"in_progress\":\n in_progress_count += 1\n validated.append({\n \"id\": item[\"id\"],\n \"text\": item[\"text\"],\n \"status\": status,\n })\n if in_progress_count > 1:\n raise ValueError(\"Only one task can be in_progress\")\n self.items = validated\n return self.render()\n```\n\n2. The `todo` tool is added to the dispatch map like any other tool.\n\n```python\nTOOL_HANDLERS = {\n \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n # ...other tools...\n \"todo\": lambda **kw: TODO.update(kw[\"items\"]),\n}\n```\n\n3. The nag reminder injects a `<reminder>` tag into the tool_result\n messages when the model goes 3+ rounds without calling `todo`.\n\n```python\ndef agent_loop(messages: list):\n rounds_since_todo = 0\n while True:\n if rounds_since_todo >= 3 and messages:\n last = messages[-1]\n if (last[\"role\"] == \"user\"\n and isinstance(last.get(\"content\"), list)):\n last[\"content\"].insert(0, {\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n # ... rest of loop ...\n rounds_since_todo = 0 if used_todo else rounds_since_todo + 1\n```\n\n4. The system prompt instructs the model to use todos for planning.\n\n```python\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nUse the todo tool to plan multi-step tasks.\nMark in_progress before starting, completed when done.\nPrefer tools over prose.\"\"\"\n```\n\n## Key Code\n\nThe TodoManager and nag injection (from `agents/s03_todo_write.py`,\nlines 51-85 and 158-187):\n\n```python\nclass TodoManager:\n def update(self, items: list) -> str:\n validated = []\n in_progress_count = 0\n for item in items:\n status = item.get(\"status\", \"pending\")\n if status == \"in_progress\":\n in_progress_count += 1\n validated.append({\n \"id\": item[\"id\"],\n \"text\": item[\"text\"],\n \"status\": status,\n })\n if in_progress_count > 1:\n raise ValueError(\"Only one in_progress\")\n self.items = validated\n return self.render()\n\n# In agent_loop:\nif rounds_since_todo >= 3:\n last[\"content\"].insert(0, {\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n```\n\n## What ChangLine truncated
"content": "# s03: TodoWrite\n\n> *\"Plan before you act\"* -- visible plans improve task completion.\n\n## Problem\n\nOn multi-step tasks, the model loses track. It repeats work, skips steps, or wanders off. Long conversations make this worse -- the system prompt fades as tool results fill the context. A 10-step refactoring might complete steps 1-3, then the model starts improvising because it forgot steps 4-10.\n\n## Solution\n\n```\n+--------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tools |\n| prompt | | | | + todo |\n+--------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +----------------+\n |\n +-----------+-----------+\n | TodoManager state |\n | [ ] task A |\n | [>] task B <- doing |\n | [x] task C |\n +-----------------------+\n |\n if rounds_since_todo >= 3:\n inject <reminder> into tool_result\n```\n\n## How It Works\n\n1. TodoManager stores items with statuses. Only one item can be `in_progress` at a time.\n\n```python\nclass TodoManager:\n def update(self, items: list) -> str:\n validated, in_progress_count = [], 0\n for item in items:\n status = item.get(\"status\", \"pending\")\n if status == \"in_progress\":\n in_progress_count += 1\n validated.append({\"id\": item[\"id\"], \"text\": item[\"text\"],\n \"status\": status})\n if in_progress_count > 1:\n raise ValueError(\"Only one task can be in_progress\")\n self.items = validated\n return self.render()\n```\n\n2. The `todo` tool goes into the dispatch map like any other tool.\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"todo\": lambda **kw: TODO.update(kw[\"items\"]),\n}\n```\n\n3. A nag reminder injects a nudge if the model goes 3+ rounds without calling `todo`.\n\n```python\nif rounds_since_todo >= 3 and messages:\n last = messages[-1]\n if last[\"role\"] == \"user\" and isinstance(last.get(\"content\"), list):\n last[\"content\"].insert(0, {\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n```\n\nThe \"one in_progress at a time\" constraint forces sequential focus. The nag reminder creates accountability.\n\n## What Changed From s02\n\n| Component | Before (s02) | After (s03) |\n|----------------|------------------|----------------------------|\n| Tools | 4 | 5 (+todo) |\n| Planning | None | TodoManager with statuses |\n| Nag injection | None | `<reminder>` after 3 rounds|\n| Agent loop | Simple dispatch | + rounds_since_todo counter|\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s03_todo_write.py\n```\n\n1. `Refactor the file hello.py: add type hints, docstrings, and a main guard`\n2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py`\n3. `Review all Python files and fix any style issues`\n"
},
{
"version": "s04",
"locale": "en",
"title": "s04: Subagents",
"content": "# s04: Subagents\n\n> A subagent runs with a fresh messages list, shares the filesystem with the parent, and returns only a summary -- keeping the parent context clean.\n\n## The Problem\n\nAs the agent works, its messages array grows. Every tool call, every file\nread, every bash output accumulates. After 20-30 tool calls, the context\nwindow is crowded with irrelevant history. Reading a 500-line file to\nanswer a quick question permanently adds 500 lines to the context.\n\nThis is particularly bad for exploratory tasks. \"What testing framework\ndoes this project use?\" might require reading 5 files, but the parent\nagent does not need all 5 file contents in its history -- it just needs\nthe answer: \"pytest with conftest.py configuration.\"\n\nIn this course, a practical solution is fresh-context isolation: spawn a child agent with `messages=[]`.\nThe child explores, reads files, runs commands. When it finishes, only its\nfinal text response returns to the parent. The child's entire message\nhistory is discarded.\n\n## The Solution\n\n```\nParent agent Subagent\n+------------------+ +------------------+\n| messages=[...] | | messages=[] | <-- fresh\n| | dispatch | |\n| tool: task | ---------->| while tool_use: |\n| prompt=\"...\" | | call tools |\n| | summary | append results |\n| result = \"...\" | <--------- | return last text |\n+------------------+ +------------------+\n |\nParent context stays clean.\nSubagent context is discarded.\n```\n\n## How It Works\n\n1. The parent agent gets a `task` tool that triggers subagent spawning.\n The child gets all base tools except `task` (no recursive spawning).\n\n```python\nPARENT_TOOLS = CHILD_TOOLS + [\n {\"name\": \"task\",\n \"description\": \"Spawn a subagent with fresh context.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"prompt\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n },\n \"required\": [\"prompt\"],\n }},\n]\n```\n\n2. The subagent starts with a fresh messages list containing only\n the delegated prompt. It shares the same filesystem.\n\n```python\ndef run_subagent(prompt: str) -> str:\n sub_messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUBAGENT_SYSTEM,\n messages=sub_messages,\n tools=CHILD_TOOLS, max_tokens=8000,\n )\n sub_messages.append({\n \"role\": \"assistant\", \"content\": response.content\n })\n if response.stop_reason != \"tool_use\":\n break\n # execute tools, append results...\n```\n\n3. Only the final text returns to the parent. The child's 30+ tool\n call history is discarded.\n\n```python\n return \"\".join(\n b.text for b in response.content if hasattr(b, \"text\")\n ) or \"(no summary)\"\n```\n\n4. The parent receives this summary as a normal tool_result.\n\n```python\nif block.name == \"task\":\n output = run_subagent(block.input[\"prompt\"])\nresults.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output),\n})\n```\n\n## Key Code\n\nThe subagent function (from `agents/s04_subagent.py`,\nlines 110-128):\n\n```python\ndef run_subagent(prompt: str) -> str:\n sub_messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(30):\n response = client.messages.create(\n model=MODEL, system=SUBAGENT_SYSTEM,\n messages=sub_messages,\n tools=CHILD_TOOLS, max_tokens=8000,\n )\n sub_messages.append({\"role\": \"assistant\",\n \"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 = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)[:50000]})\n sub_messages.append({\"role\": \"user\", \"content\": results})\n return \"\".join(\n b.text for b in response.content if hasattr(b, \"text\")\n ) or \"(no summary)\"\n```\n\n## What Changed From s03\n\n| Component | Before (s03) | After (s04) |\n|----------------|------------------|---------------------------|\n| Tools | 5 | 5 (base) + task (parent) |\n| Context | Single shared | Parent + child isolation |\n| Subagent | None | `run_subagent()` function |\n| Return value | N/A Line truncated
"content": "# s04: Subagents\n\n> *\"Process isolation = context isolation\"* -- fresh messages[] per subagent.\n\n## Problem\n\nAs the agent works, its messages array grows. Every file read, every bash output stays in context permanently. \"What testing framework does this project use?\" might require reading 5 files, but the parent only needs the answer: \"pytest.\"\n\n## Solution\n\n```\nParent agent Subagent\n+------------------+ +------------------+\n| messages=[...] | | messages=[] | <-- fresh\n| | dispatch | |\n| tool: task | ----------> | while tool_use: |\n| prompt=\"...\" | | call tools |\n| | summary | append results |\n| result = \"...\" | <---------- | return last text |\n+------------------+ +------------------+\n\nParent context stays clean. Subagent context is discarded.\n```\n\n## How It Works\n\n1. The parent gets a `task` tool. The child gets all base tools except `task` (no recursive spawning).\n\n```python\nPARENT_TOOLS = CHILD_TOOLS + [\n {\"name\": \"task\",\n \"description\": \"Spawn a subagent with fresh context.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\"prompt\": {\"type\": \"string\"}},\n \"required\": [\"prompt\"],\n }},\n]\n```\n\n2. The subagent starts with `messages=[]` and runs its own loop. Only the final text returns to the parent.\n\n```python\ndef run_subagent(prompt: str) -> str:\n sub_messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUBAGENT_SYSTEM,\n messages=sub_messages,\n tools=CHILD_TOOLS, max_tokens=8000,\n )\n sub_messages.append({\"role\": \"assistant\",\n \"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 = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)[:50000]})\n sub_messages.append({\"role\": \"user\", \"content\": results})\n return \"\".join(\n b.text for b in response.content if hasattr(b, \"text\")\n ) or \"(no summary)\"\n```\n\nThe child's entire message history (possibly 30+ tool calls) is discarded. The parent receives a one-paragraph summary as a normal `tool_result`.\n\n## What Changed From s03\n\n| Component | Before (s03) | After (s04) |\n|----------------|------------------|---------------------------|\n| Tools | 5 | 5 (base) + task (parent) |\n| Context | Single shared | Parent + child isolation |\n| Subagent | None | `run_subagent()` function |\n| Return value | N/A | Summary text only |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s04_subagent.py\n```\n\n1. `Use a subtask to find what testing framework this project uses`\n2. `Delegate: read all .py files and summarize what each one does`\n3. `Use a task to create a new module, then verify it from here`\n"
},
{
"version": "s05",
"locale": "en",
"title": "s05: Skills",
"content": "# s05: Skills\n\n> Two-layer skill injection avoids system prompt bloat by putting skill names in the system prompt (cheap) and full skill bodies in tool_result (on demand).\n\n## The Problem\n\nYou want the agent to follow specific workflows for different domains:\ngit conventions, testing patterns, code review checklists. The naive\napproach is to put everything in the system prompt. But the system prompt\nhas limited effective attention -- too much text and the model starts\nignoring parts of it.\n\nIf you have 10 skills at 2000 tokens each, that is 20,000 tokens of system\nprompt. The model pays attention to the beginning and end but skims the\nmiddle. Worse, most of those skills are irrelevant to any given task. A\nfile editing task does not need the git workflow instructions.\n\nThe two-layer approach solves this: Layer 1 puts short skill descriptions\nin the system prompt (~100 tokens per skill). Layer 2 loads the full skill\nbody into a tool_result only when the model calls `load_skill`. The model\nlearns what skills exist (cheap) and loads them on demand (only when\nrelevant).\n\n## The Solution\n\n```\nSystem prompt (Layer 1 -- always present):\n+--------------------------------------+\n| You are a coding agent. |\n| Skills available: |\n| - git: Git workflow helpers | ~100 tokens/skill\n| - test: Testing best practices |\n+--------------------------------------+\n\nWhen model calls load_skill(\"git\"):\n+--------------------------------------+\n| tool_result (Layer 2 -- on demand): |\n| <skill name=\"git\"> |\n| Full git workflow instructions... | ~2000 tokens\n| Step 1: ... |\n| Step 2: ... |\n| </skill> |\n+--------------------------------------+\n```\n\n## How It Works\n\n1. Skill files live in `.skills/` as Markdown with YAML frontmatter.\n\n```\n.skills/\n git.md # ---\\n description: Git workflow\\n ---\\n ...\n test.md # ---\\n description: Testing patterns\\n ---\\n ...\n```\n\n2. The SkillLoader parses frontmatter and separates metadata from body.\n\n```python\nclass SkillLoader:\n def _parse_frontmatter(self, text: str) -> tuple:\n match = re.match(\n r\"^---\\n(.*?)\\n---\\n(.*)\", text, re.DOTALL\n )\n if not match:\n return {}, text\n meta = {}\n for line in match.group(1).strip().splitlines():\n if \":\" in line:\n key, val = line.split(\":\", 1)\n meta[key.strip()] = val.strip()\n return meta, match.group(2).strip()\n```\n\n3. Layer 1: `get_descriptions()` returns short lines for the system prompt.\n\n```python\ndef get_descriptions(self) -> str:\n lines = []\n for name, skill in self.skills.items():\n desc = skill[\"meta\"].get(\"description\", \"No description\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\n\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nSkills available:\n{SKILL_LOADER.get_descriptions()}\"\"\"\n```\n\n4. Layer 2: `get_content()` returns the full body wrapped in `<skill>` tags.\n\n```python\ndef get_content(self, name: str) -> str:\n skill = self.skills.get(name)\n if not skill:\n return f\"Error: Unknown skill '{name}'.\"\n return f\"<skill name=\\\"{name}\\\">\\n{skill['body']}\\n</skill>\"\n```\n\n5. The `load_skill` tool is just another entry in the dispatch map.\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"load_skill\": lambda **kw: SKILL_LOADER.get_content(kw[\"name\"]),\n}\n```\n\n## Key Code\n\nThe SkillLoader class (from `agents/s05_skill_loading.py`,\nlines 51-97):\n\n```python\nclass SkillLoader:\n def __init__(self, skills_dir: Path):\n self.skills = {}\n for f in sorted(skills_dir.glob(\"*.md\")):\n text = f.read_text()\n meta, body = self._parse_frontmatter(text)\n self.skills[f.stem] = {\n \"meta\": meta, \"body\": body\n }\n\n def get_descriptions(self) -> str:\n lines = []\n for name, skill in self.skills.items():\n desc = skill[\"meta\"].get(\"description\", \"\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\n\n def get_content(self, name: str) -> str:\n skill = self.skills.get(name)\n if not skill:\n return f\"Error: Unknown skill '{name}'.\"\n return (f\"<skill name=\\\"{name}\\\">\\n\"\n f\"{skill['body']}\\n</skill>\")\n```\n\n## What Changed From s04\n\n| Component | Before (s04) | After (s05) |\n|----------------|------------------|----------------------------|\n| Tools | 5 (base + task) | 5 (base + load_skill) |\n| System prompt | Static string | + skill descriptions |\n| Knowledge | None | .skills/*.md files Line truncated
"content": "# s05: Skills\n\n> *\"Load on demand, not upfront\"* -- inject knowledge via tool_result, not system prompt.\n\n## Problem\n\nYou want the agent to follow domain-specific workflows: git conventions, testing patterns, code review checklists. Putting everything in the system prompt wastes tokens on unused skills. 10 skills at 2000 tokens each = 20,000 tokens, most of which are irrelevant to any given task.\n\n## Solution\n\n```\nSystem prompt (Layer 1 -- always present):\n+--------------------------------------+\n| You are a coding agent. |\n| Skills available: |\n| - git: Git workflow helpers | ~100 tokens/skill\n| - test: Testing best practices |\n+--------------------------------------+\n\nWhen model calls load_skill(\"git\"):\n+--------------------------------------+\n| tool_result (Layer 2 -- on demand): |\n| <skill name=\"git\"> |\n| Full git workflow instructions... | ~2000 tokens\n| Step 1: ... |\n| </skill> |\n+--------------------------------------+\n```\n\nLayer 1: skill *names* in system prompt (cheap). Layer 2: full *body* via tool_result (on demand).\n\n## How It Works\n\n1. Skill files live in `.skills/` as Markdown with YAML frontmatter.\n\n```\n.skills/\n git.md # ---\\n description: Git workflow\\n ---\\n ...\n test.md # ---\\n description: Testing patterns\\n ---\\n ...\n```\n\n2. SkillLoader parses frontmatter, separates metadata from body.\n\n```python\nclass SkillLoader:\n def __init__(self, skills_dir: Path):\n self.skills = {}\n for f in sorted(skills_dir.glob(\"*.md\")):\n text = f.read_text()\n meta, body = self._parse_frontmatter(text)\n self.skills[f.stem] = {\"meta\": meta, \"body\": body}\n\n def get_descriptions(self) -> str:\n lines = []\n for name, skill in self.skills.items():\n desc = skill[\"meta\"].get(\"description\", \"\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\n\n def get_content(self, name: str) -> str:\n skill = self.skills.get(name)\n if not skill:\n return f\"Error: Unknown skill '{name}'.\"\n return f\"<skill name=\\\"{name}\\\">\\n{skill['body']}\\n</skill>\"\n```\n\n3. Layer 1 goes into the system prompt. Layer 2 is just another tool handler.\n\n```python\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nSkills available:\n{SKILL_LOADER.get_descriptions()}\"\"\"\n\nTOOL_HANDLERS = {\n # ...base tools...\n \"load_skill\": lambda **kw: SKILL_LOADER.get_content(kw[\"name\"]),\n}\n```\n\nThe model learns what skills exist (cheap) and loads them when relevant (expensive).\n\n## What Changed From s04\n\n| Component | Before (s04) | After (s05) |\n|----------------|------------------|----------------------------|\n| Tools | 5 (base + task) | 5 (base + load_skill) |\n| System prompt | Static string | + skill descriptions |\n| Knowledge | None | .skills/*.md files |\n| Injection | None | Two-layer (system + result)|\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s05_skill_loading.py\n```\n\n1. `What skills are available?`\n2. `Load the agent-builder skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n4. `Build an MCP server using the mcp-builder skill`\n"
},
{
"version": "s06",
"locale": "en",
"title": "s06: Compact",
"content": "# s06: Compact\n\n> A three-layer compression pipeline lets the agent work indefinitely by strategically forgetting old tool results, auto-summarizing when tokens exceed a threshold, and allowing manual compression on demand.\n\n## The Problem\n\nThe context window is finite. After enough tool calls, the messages array\nexceeds the model's context limit and the API call fails. Even before\nhitting the hard limit, performance degrades: the model becomes slower,\nless accurate, and starts ignoring earlier messages.\n\nA 200,000 token context window sounds large, but a single `read_file` on\na 1000-line source file consumes ~4000 tokens. After reading 30 files and\nrunning 20 bash commands, you are at 100,000+ tokens. The agent cannot\nwork on large codebases without some form of compression.\n\nThe three-layer pipeline addresses this with increasing aggressiveness:\nLayer 1 (micro-compact) silently replaces old tool results every turn.\nLayer 2 (auto-compact) triggers a full summarization when tokens exceed\na threshold. Layer 3 (manual compact) lets the model trigger compression\nitself.\n\nTeaching simplification: the token estimation here uses a rough\ncharacters/4 heuristic. Production systems use proper tokenizer\nlibraries for accurate counts.\n\n## The Solution\n\n```\nEvery turn:\n+------------------+\n| Tool call result |\n+------------------+\n |\n v\n[Layer 1: micro_compact] (silent, every turn)\n Replace tool_result > 3 turns old\n with \"[Previous: used {tool_name}]\"\n |\n v\n[Check: tokens > 50000?]\n | |\n no yes\n | |\n v v\ncontinue [Layer 2: auto_compact]\n Save transcript to .transcripts/\n LLM summarizes conversation.\n Replace all messages with [summary].\n |\n v\n [Layer 3: compact tool]\n Model calls compact explicitly.\n Same summarization as auto_compact.\n```\n\n## How It Works\n\n1. **Layer 1 -- micro_compact**: Before each LLM call, find all\n tool_result entries older than the last 3 and replace their content.\n\n```python\ndef micro_compact(messages: list) -> list:\n tool_results = []\n for i, msg in enumerate(messages):\n if msg[\"role\"] == \"user\" and isinstance(msg.get(\"content\"), list):\n for j, part in enumerate(msg[\"content\"]):\n if isinstance(part, dict) and part.get(\"type\") == \"tool_result\":\n tool_results.append((i, j, part))\n if len(tool_results) <= KEEP_RECENT:\n return messages\n to_clear = tool_results[:-KEEP_RECENT]\n for _, _, part in to_clear:\n if len(part.get(\"content\", \"\")) > 100:\n tool_id = part.get(\"tool_use_id\", \"\")\n tool_name = tool_name_map.get(tool_id, \"unknown\")\n part[\"content\"] = f\"[Previous: used {tool_name}]\"\n return messages\n```\n\n2. **Layer 2 -- auto_compact**: When estimated tokens exceed 50,000,\n save the full transcript and ask the LLM to summarize.\n\n```python\ndef auto_compact(messages: list) -> list:\n TRANSCRIPT_DIR.mkdir(exist_ok=True)\n transcript_path = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with open(transcript_path, \"w\") as f:\n for msg in messages:\n f.write(json.dumps(msg, default=str) + \"\\n\")\n response = client.messages.create(\n model=MODEL,\n messages=[{\"role\": \"user\", \"content\":\n \"Summarize this conversation for continuity...\"\n + json.dumps(messages, default=str)[:80000]}],\n max_tokens=2000,\n )\n summary = response.content[0].text\n return [\n {\"role\": \"user\", \"content\": f\"[Compressed]\\n\\n{summary}\"},\n {\"role\": \"assistant\", \"content\": \"Understood. Continuing.\"},\n ]\n```\n\n3. **Layer 3 -- manual compact**: The `compact` tool triggers the same\n summarization on demand.\n\n```python\nif manual_compact:\n messages[:] = auto_compact(messages)\n```\n\n4. The agent loop integrates all three layers.\n\n```python\ndef agent_loop(messages: list):\n while True:\n micro_compact(messages)\n if estimate_tokens(messages) > THRESHOLD:\n messages[:] = auto_compact(messages)\n response = client.messages.create(...)\n # ... tool execution ...\n if manual_compact:\n messages[:] = auto_compact(messages)\n```\n\n## Key Code\n\nThe three-layer pipeline (from `agents/s06_context_compact.py`,\nlines 67-93 and 189-223):\n\n```python\nTHRESHOLD = 50000\nKEEP_RECENT = 3\n\ndef micro_compact(messages):\n # Replace old tool results with placeholders\n ...\n\ndef auto_compact(messages):\n # Save transcript, LLM summarize, replace messages\n ...\n\ndef agent_loop(messages):\n while True:\n micro_compact(messages) # Layer 1\n Line truncated
"title": "s06: Context Compact",
"content": "# s06: Context Compact\n\n> *\"Strategic forgetting\"* -- forget old context to enable infinite sessions.\n\n## Problem\n\nThe context window is finite. A single `read_file` on a 1000-line file costs ~4000 tokens. After reading 30 files and running 20 bash commands, you hit 100,000+ tokens. The agent cannot work on large codebases without compression.\n\n## Solution\n\nThree layers, increasing in aggressiveness:\n\n```\nEvery turn:\n+------------------+\n| Tool call result |\n+------------------+\n |\n v\n[Layer 1: micro_compact] (silent, every turn)\n Replace tool_result > 3 turns old\n with \"[Previous: used {tool_name}]\"\n |\n v\n[Check: tokens > 50000?]\n | |\n no yes\n | |\n v v\ncontinue [Layer 2: auto_compact]\n Save transcript to .transcripts/\n LLM summarizes conversation.\n Replace all messages with [summary].\n |\n v\n [Layer 3: compact tool]\n Model calls compact explicitly.\n Same summarization as auto_compact.\n```\n\n## How It Works\n\n1. **Layer 1 -- micro_compact**: Before each LLM call, replace old tool results with placeholders.\n\n```python\ndef micro_compact(messages: list) -> list:\n tool_results = []\n for i, msg in enumerate(messages):\n if msg[\"role\"] == \"user\" and isinstance(msg.get(\"content\"), list):\n for j, part in enumerate(msg[\"content\"]):\n if isinstance(part, dict) and part.get(\"type\") == \"tool_result\":\n tool_results.append((i, j, part))\n if len(tool_results) <= KEEP_RECENT:\n return messages\n for _, _, part in tool_results[:-KEEP_RECENT]:\n if len(part.get(\"content\", \"\")) > 100:\n part[\"content\"] = f\"[Previous: used {tool_name}]\"\n return messages\n```\n\n2. **Layer 2 -- auto_compact**: When tokens exceed threshold, save full transcript to disk, then ask the LLM to summarize.\n\n```python\ndef auto_compact(messages: list) -> list:\n # Save transcript for recovery\n transcript_path = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with open(transcript_path, \"w\") as f:\n for msg in messages:\n f.write(json.dumps(msg, default=str) + \"\\n\")\n # LLM summarizes\n response = client.messages.create(\n model=MODEL,\n messages=[{\"role\": \"user\", \"content\":\n \"Summarize this conversation for continuity...\"\n + json.dumps(messages, default=str)[:80000]}],\n max_tokens=2000,\n )\n return [\n {\"role\": \"user\", \"content\": f\"[Compressed]\\n\\n{response.content[0].text}\"},\n {\"role\": \"assistant\", \"content\": \"Understood. Continuing.\"},\n ]\n```\n\n3. **Layer 3 -- manual compact**: The `compact` tool triggers the same summarization on demand.\n\n4. The loop integrates all three:\n\n```python\ndef agent_loop(messages: list):\n while True:\n micro_compact(messages) # Layer 1\n if estimate_tokens(messages) > THRESHOLD:\n messages[:] = auto_compact(messages) # Layer 2\n response = client.messages.create(...)\n # ... tool execution ...\n if manual_compact:\n messages[:] = auto_compact(messages) # Layer 3\n```\n\nTranscripts preserve full history on disk. Nothing is truly lost -- just moved out of active context.\n\n## What Changed From s05\n\n| Component | Before (s05) | After (s06) |\n|----------------|------------------|----------------------------|\n| Tools | 5 | 5 (base + compact) |\n| Context mgmt | None | Three-layer compression |\n| Micro-compact | None | Old results -> placeholders|\n| Auto-compact | None | Token threshold trigger |\n| Transcripts | None | Saved to .transcripts/ |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s06_context_compact.py\n```\n\n1. `Read every Python file in the agents/ directory one by one` (watch micro-compact replace old results)\n2. `Keep reading files until compression triggers automatically`\n3. `Use the compact tool to manually compress the conversation`\n"
},
{
"version": "s07",
"locale": "en",
"title": "s07: Tasks",
"content": "# s07: Tasks\n\n> Tasks are persisted as JSON files with a dependency graph, so state survives context compression and can be shared across agents.\n\n## Problem\n\nIn-memory state (for example the TodoManager from s03) is fragile under compression (s06). Once earlier turns are compacted into summaries, in-memory todo state is gone.\n\ns06 -> s07 is the key transition:\n\n1. Todo list state in memory is conversational and lossy.\n2. Task board state on disk is durable and recoverable.\n\nA second issue is visibility: in-memory structures are process-local, so teammates cannot reliably share that state.\n\n## When to Use Task vs Todo\n\nFrom s07 onward, Task is the default. Todo remains for short linear checklists.\n\n## Quick Decision Matrix\n\n| Situation | Prefer | Why |\n|---|---|---|\n| Short, single-session checklist | Todo | Lowest ceremony, fastest capture |\n| Cross-session work, dependencies, or teammates | Task | Durable state, dependency graph, shared visibility |\n| Unsure which one to use | Task | Easier to simplify later than migrate mid-run |\n\n## Solution\n\n```\n.tasks/\n task_1.json {\"id\":1, \"status\":\"completed\", ...}\n task_2.json {\"id\":2, \"blockedBy\":[1], \"status\":\"pending\"}\n task_3.json {\"id\":3, \"blockedBy\":[2], \"status\":\"pending\"}\n\nDependency resolution:\n+----------+ +----------+ +----------+\n| task 1 | --> | task 2 | --> | task 3 |\n| complete | | blocked | | blocked |\n+----------+ +----------+ +----------+\n | ^\n +--- completing task 1 removes it from\n task 2's blockedBy list\n```\n\n## How It Works\n\n1. TaskManager provides CRUD with one JSON file per task.\n\n```python\nclass TaskManager:\n def create(self, subject: str, description: str = \"\") -> str:\n task = {\n \"id\": self._next_id,\n \"subject\": subject,\n \"description\": description,\n \"status\": \"pending\",\n \"blockedBy\": [],\n \"blocks\": [],\n \"owner\": \"\",\n }\n self._save(task)\n self._next_id += 1\n return json.dumps(task, indent=2)\n```\n\n2. Completing a task clears that dependency from other tasks.\n\n```python\ndef _clear_dependency(self, completed_id: int):\n for f in self.dir.glob(\"task_*.json\"):\n task = json.loads(f.read_text())\n if completed_id in task.get(\"blockedBy\", []):\n task[\"blockedBy\"].remove(completed_id)\n self._save(task)\n```\n\n3. `update` handles status transitions and dependency wiring.\n\n```python\ndef update(self, task_id, status=None,\n add_blocked_by=None, add_blocks=None):\n task = self._load(task_id)\n if status:\n task[\"status\"] = status\n if status == \"completed\":\n self._clear_dependency(task_id)\n if add_blocks:\n task[\"blocks\"] = list(set(task[\"blocks\"] + add_blocks))\n for blocked_id in add_blocks:\n blocked = self._load(blocked_id)\n if task_id not in blocked[\"blockedBy\"]:\n blocked[\"blockedBy\"].append(task_id)\n self._save(blocked)\n self._save(task)\n```\n\n4. Task tools are added to the dispatch map.\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"task_create\": lambda **kw: TASKS.create(kw[\"subject\"]),\n \"task_update\": lambda **kw: TASKS.update(kw[\"task_id\"],\n kw.get(\"status\")),\n \"task_list\": lambda **kw: TASKS.list_all(),\n \"task_get\": lambda **kw: TASKS.get(kw[\"task_id\"]),\n}\n```\n\n## Key Code\n\nTaskManager with dependency graph (from `agents/s07_task_system.py`, lines 46-123):\n\n```python\nclass TaskManager:\n def __init__(self, tasks_dir: Path):\n self.dir = tasks_dir\n self.dir.mkdir(exist_ok=True)\n self._next_id = self._max_id() + 1\n\n def _load(self, task_id: int) -> dict:\n path = self.dir / f\"task_{task_id}.json\"\n return json.loads(path.read_text())\n\n def _save(self, task: dict):\n path = self.dir / f\"task_{task['id']}.json\"\n path.write_text(json.dumps(task, indent=2))\n\n def create(self, subject, description=\"\"):\n task = {\"id\": self._next_id, \"subject\": subject,\n \"status\": \"pending\", \"blockedBy\": [],\n \"blocks\": [], \"owner\": \"\"}\n self._save(task)\n self._next_id += 1\n return json.dumps(task, indent=2)\n\n def _clear_dependency(self, completed_id):\n for f in self.dir.glob(\"task_*.json\"):\n task = json.loads(f.read_text())\n if completed_id in task.get(\"blockedBy\", []):\n task[\"blockedBy\"].remove(completed_id)\n self._save(task)\n```\n\n## What Changed From s06\n\n| Component | Before (s06) | After (s07) |\n|---|---|---|\n| Tools | 5 | 8 (`task_create/update/list/get`) |\n| StLine truncated
"content": "# s07: Tasks\n\n> *\"State survives /compact\"* -- file-based state outlives context compression.\n\n## Problem\n\nIn-memory state (TodoManager from s03) dies when context compresses (s06). After auto_compact replaces messages with a summary, the todo list is gone. The agent can only reconstruct from summary text -- lossy and error-prone.\n\nFile-based tasks solve this: write state to disk, and it survives compression, process restarts, and eventually multi-agent sharing (s09+).\n\n## Solution\n\n```\n.tasks/\n task_1.json {\"id\":1, \"status\":\"completed\", ...}\n task_2.json {\"id\":2, \"blockedBy\":[1], \"status\":\"pending\"}\n task_3.json {\"id\":3, \"blockedBy\":[2], \"status\":\"pending\"}\n\nDependency resolution:\n+----------+ +----------+ +----------+\n| task 1 | --> | task 2 | --> | task 3 |\n| complete | | blocked | | blocked |\n+----------+ +----------+ +----------+\n | ^\n +--- completing task 1 removes it from\n task 2's blockedBy list\n```\n\n## How It Works\n\n1. TaskManager: one JSON file per task, CRUD with dependency graph.\n\n```python\nclass TaskManager:\n def __init__(self, tasks_dir: Path):\n self.dir = tasks_dir\n self.dir.mkdir(exist_ok=True)\n self._next_id = self._max_id() + 1\n\n def create(self, subject, description=\"\"):\n task = {\"id\": self._next_id, \"subject\": subject,\n \"status\": \"pending\", \"blockedBy\": [],\n \"blocks\": [], \"owner\": \"\"}\n self._save(task)\n self._next_id += 1\n return json.dumps(task, indent=2)\n```\n\n2. Completing a task clears its ID from every other task's `blockedBy` list.\n\n```python\ndef _clear_dependency(self, completed_id):\n for f in self.dir.glob(\"task_*.json\"):\n task = json.loads(f.read_text())\n if completed_id in task.get(\"blockedBy\", []):\n task[\"blockedBy\"].remove(completed_id)\n self._save(task)\n```\n\n3. `update` handles status transitions and dependency wiring.\n\n```python\ndef update(self, task_id, status=None,\n add_blocked_by=None, add_blocks=None):\n task = self._load(task_id)\n if status:\n task[\"status\"] = status\n if status == \"completed\":\n self._clear_dependency(task_id)\n self._save(task)\n```\n\n4. Four task tools go into the dispatch map.\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"task_create\": lambda **kw: TASKS.create(kw[\"subject\"]),\n \"task_update\": lambda **kw: TASKS.update(kw[\"task_id\"], kw.get(\"status\")),\n \"task_list\": lambda **kw: TASKS.list_all(),\n \"task_get\": lambda **kw: TASKS.get(kw[\"task_id\"]),\n}\n```\n\nFrom s07 onward, Task is the default for multi-step work. Todo remains for quick checklists.\n\n## What Changed From s06\n\n| Component | Before (s06) | After (s07) |\n|---|---|---|\n| Tools | 5 | 8 (`task_create/update/list/get`) |\n| State storage | In-memory only | JSON files in `.tasks/` |\n| Dependencies | None | `blockedBy + blocks` graph |\n| Persistence | Lost on compact | Survives compression |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s07_task_system.py\n```\n\n1. `Create 3 tasks: \"Setup project\", \"Write code\", \"Write tests\". Make them depend on each other in order.`\n2. `List all tasks and show the dependency graph`\n3. `Complete task 1 and then list tasks to see task 2 unblocked`\n4. `Create a task board for refactoring: parse -> transform -> emit -> test`\n"
},
{
"version": "s08",
"locale": "en",
"title": "s08: Background Tasks",
"content": "# s08: Background Tasks\n\n> A BackgroundManager runs commands in separate threads and drains a notification queue before each LLM call, so the agent never blocks on long-running operations.\n\n## The Problem\n\nSome commands take minutes: `npm install`, `pytest`, `docker build`. With\na blocking agent loop, the model sits idle waiting for the subprocess to\nfinish. It cannot do anything else. If the user asked \"install dependencies\nand while that runs, create the config file,\" the agent would install\nfirst, _then_ create the config -- sequentially, not in parallel.\n\nThe agent needs concurrency. Not full multi-threading of the agent loop\nitself, but the ability to fire off a long command and continue working\nwhile it runs. When the command finishes, its result should appear\nnaturally in the conversation.\n\nThe solution is a BackgroundManager that runs commands in daemon threads\nand collects results in a notification queue. Before each LLM call, the\nqueue is drained and results are injected into the messages.\n\n## The Solution\n\n```\nMain thread Background thread\n+-----------------+ +-----------------+\n| agent loop | | task executes |\n| ... | | ... |\n| [LLM call] <---+------- | enqueue(result) |\n| ^drain queue | +-----------------+\n+-----------------+\n\nTimeline:\nAgent --[spawn A]--[spawn B]--[other work]----\n | |\n v v\n [A runs] [B runs] (parallel)\n | |\n +-- notification queue --+\n |\n [results injected before\n next LLM call]\n```\n\n## How It Works\n\n1. The BackgroundManager tracks tasks and maintains a thread-safe\n notification queue.\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self._notification_queue = []\n self._lock = threading.Lock()\n```\n\n2. `run()` starts a daemon thread and returns a task_id immediately.\n\n```python\ndef run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n self.tasks[task_id] = {\n \"status\": \"running\",\n \"result\": None,\n \"command\": command,\n }\n thread = threading.Thread(\n target=self._execute,\n args=(task_id, command),\n daemon=True,\n )\n thread.start()\n return f\"Background task {task_id} started\"\n```\n\n3. The thread target `_execute` runs the subprocess and pushes\n results to the notification queue.\n\n```python\ndef _execute(self, task_id: str, command: str):\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=300)\n output = (r.stdout + r.stderr).strip()[:50000]\n status = \"completed\"\n except subprocess.TimeoutExpired:\n output = \"Error: Timeout (300s)\"\n status = \"timeout\"\n self.tasks[task_id][\"status\"] = status\n self.tasks[task_id][\"result\"] = output\n with self._lock:\n self._notification_queue.append({\n \"task_id\": task_id,\n \"status\": status,\n \"result\": output[:500],\n })\n```\n\n4. `drain_notifications()` returns and clears pending results.\n\n```python\ndef drain_notifications(self) -> list:\n with self._lock:\n notifs = list(self._notification_queue)\n self._notification_queue.clear()\n return notifs\n```\n\n5. The agent loop drains notifications before each LLM call.\n\n```python\ndef agent_loop(messages: list):\n while True:\n notifs = BG.drain_notifications()\n if notifs and messages:\n notif_text = \"\\n\".join(\n f\"[bg:{n['task_id']}] {n['status']}: \"\n f\"{n['result']}\" for n in notifs\n )\n messages.append({\"role\": \"user\",\n \"content\": f\"<background-results>\"\n f\"\\n{notif_text}\\n\"\n f\"</background-results>\"})\n messages.append({\"role\": \"assistant\",\n \"content\": \"Noted background results.\"})\n response = client.messages.create(...)\n```\n\n## Key Code\n\nThe BackgroundManager (from `agents/s08_background_tasks.py`, lines 49-107):\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self._notification_queue = []\n self._lock = threading.Lock()\n\n def run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n self.tasks[task_id] = {\"status\": \"running\",\n \"result\": None,\n \"command\": command}\n thread = threading.Thread(\n target=self._execute,\n args=(task_id, command), daemon=True)\n thread.start()\n return f\"BackgrounLine truncated
"content": "# s08: Background Tasks\n\n> *\"Fire and forget\"* -- non-blocking threads + notification queue.\n\n## Problem\n\nSome commands take minutes: `npm install`, `pytest`, `docker build`. With a blocking loop, the model sits idle waiting. If the user asks \"install dependencies and while that runs, create the config file,\" the agent does them sequentially, not in parallel.\n\n## Solution\n\n```\nMain thread Background thread\n+-----------------+ +-----------------+\n| agent loop | | subprocess runs |\n| ... | | ... |\n| [LLM call] <---+------- | enqueue(result) |\n| ^drain queue | +-----------------+\n+-----------------+\n\nTimeline:\nAgent --[spawn A]--[spawn B]--[other work]----\n | |\n v v\n [A runs] [B runs] (parallel)\n | |\n +-- results injected before next LLM call --+\n```\n\n## How It Works\n\n1. BackgroundManager tracks tasks with a thread-safe notification queue.\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self._notification_queue = []\n self._lock = threading.Lock()\n```\n\n2. `run()` starts a daemon thread and returns immediately.\n\n```python\ndef run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n self.tasks[task_id] = {\"status\": \"running\", \"command\": command}\n thread = threading.Thread(\n target=self._execute, args=(task_id, command), daemon=True)\n thread.start()\n return f\"Background task {task_id} started\"\n```\n\n3. When the subprocess finishes, its result goes into the notification queue.\n\n```python\ndef _execute(self, task_id, command):\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=300)\n output = (r.stdout + r.stderr).strip()[:50000]\n except subprocess.TimeoutExpired:\n output = \"Error: Timeout (300s)\"\n with self._lock:\n self._notification_queue.append({\n \"task_id\": task_id, \"result\": output[:500]})\n```\n\n4. The agent loop drains notifications before each LLM call.\n\n```python\ndef agent_loop(messages: list):\n while True:\n notifs = BG.drain_notifications()\n if notifs:\n notif_text = \"\\n\".join(\n f\"[bg:{n['task_id']}] {n['result']}\" for n in notifs)\n messages.append({\"role\": \"user\",\n \"content\": f\"<background-results>\\n{notif_text}\\n\"\n f\"</background-results>\"})\n messages.append({\"role\": \"assistant\",\n \"content\": \"Noted background results.\"})\n response = client.messages.create(...)\n```\n\nThe loop stays single-threaded. Only subprocess I/O is parallelized.\n\n## What Changed From s07\n\n| Component | Before (s07) | After (s08) |\n|----------------|------------------|----------------------------|\n| Tools | 8 | 6 (base + background_run + check)|\n| Execution | Blocking only | Blocking + background threads|\n| Notification | None | Queue drained per loop |\n| Concurrency | None | Daemon threads |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s08_background_tasks.py\n```\n\n1. `Run \"sleep 5 && echo done\" in the background, then create a file while it runs`\n2. `Start 3 background tasks: \"sleep 2\", \"sleep 4\", \"sleep 6\". Check their status.`\n3. `Run pytest in the background and keep working on other things`\n"
},
{
"version": "s09",
"locale": "en",
"title": "s09: Agent Teams",
"content": "# s09: Agent Teams\n\n> Persistent teammates with JSONL inboxes are one teaching protocol for turning isolated agents into a communicating team -- spawn, message, broadcast, and drain.\n\n## The Problem\n\nSubagents (s04) are disposable: spawn, work, return summary, die. They\nhave no identity, no memory between invocations, and no way to receive\nfollow-up instructions. Background tasks (s08) run shell commands but\ncannot make LLM-guided decisions or communicate findings.\n\nFor real teamwork you need three things: (1) persistent agents that\nsurvive beyond a single prompt, (2) identity and lifecycle management,\nand (3) a communication channel between agents. Without messaging, even\npersistent teammates are deaf and mute -- they can work in parallel but\nnever coordinate.\n\nThe solution combines a TeammateManager for spawning persistent named\nagents with a MessageBus using JSONL inbox files. Each teammate runs\nits own agent loop in a thread, checks its inbox before every LLM call,\nand can send messages to any other teammate or the lead.\n\nNote on the s06-to-s07 bridge: TodoManager items from s03 die with\ncompression (s06). File-based tasks (s07) survive compression because\nthey live on disk. Teams build on this same principle -- config.json and\ninbox files persist outside the context window.\n\n## The Solution\n\n```\nTeammate lifecycle:\n spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN\n\nCommunication:\n .team/\n config.json <- team roster + statuses\n inbox/\n alice.jsonl <- append-only, drain-on-read\n bob.jsonl\n lead.jsonl\n\n +--------+ send(\"alice\",\"bob\",\"...\") +--------+\n | alice | -----------------------------> | bob |\n | loop | bob.jsonl << {json_line} | loop |\n +--------+ +--------+\n ^ |\n | BUS.read_inbox(\"alice\") |\n +---- alice.jsonl -> read + drain ---------+\n\n5 message types:\n+-------------------------+------------------------------+\n| message | Normal text between agents |\n| broadcast | Sent to all teammates |\n| shutdown_request | Request graceful shutdown |\n| shutdown_response | Approve/reject shutdown |\n| plan_approval_response | Approve/reject plan |\n+-------------------------+------------------------------+\n```\n\n## How It Works\n\n1. The TeammateManager maintains config.json with the team roster.\n Each member has a name, role, and status.\n\n```python\nclass TeammateManager:\n def __init__(self, team_dir: Path):\n self.dir = team_dir\n self.dir.mkdir(exist_ok=True)\n self.config_path = self.dir / \"config.json\"\n self.config = self._load_config()\n self.threads = {}\n```\n\n2. `spawn()` creates a teammate and starts its agent loop in a thread.\n Re-spawning an idle teammate reactivates it.\n\n```python\ndef spawn(self, name: str, role: str, prompt: str) -> str:\n member = self._find_member(name)\n if member:\n if member[\"status\"] not in (\"idle\", \"shutdown\"):\n return f\"Error: '{name}' is currently {member['status']}\"\n member[\"status\"] = \"working\"\n else:\n member = {\"name\": name, \"role\": role, \"status\": \"working\"}\n self.config[\"members\"].append(member)\n self._save_config()\n thread = threading.Thread(\n target=self._teammate_loop,\n args=(name, role, prompt), daemon=True)\n self.threads[name] = thread\n thread.start()\n return f\"Spawned teammate '{name}' (role: {role})\"\n```\n\n3. The MessageBus handles JSONL inbox files. `send()` appends a JSON\n line; `read_inbox()` reads all lines and drains the file.\n\n```python\nclass MessageBus:\n def send(self, sender, to, content,\n msg_type=\"message\", extra=None):\n msg = {\"type\": msg_type, \"from\": sender,\n \"content\": content,\n \"timestamp\": time.time()}\n if extra:\n msg.update(extra)\n with open(self.dir / f\"{to}.jsonl\", \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n return f\"Sent {msg_type} to {to}\"\n\n def read_inbox(self, name):\n path = self.dir / f\"{name}.jsonl\"\n if not path.exists():\n return \"[]\"\n msgs = [json.loads(l)\n for l in path.read_text().strip().splitlines()\n if l]\n path.write_text(\"\") # drain\n return json.dumps(msgs, indent=2)\n```\n\n4. Each teammate checks its inbox before every LLM call and injects\n received messages into the conversation context.\n\n```python\ndef _teammate_loop(self, name, role, prompt):\n sys_prompt = f\"You are '{name}', role: {role}, at {WORKDIR}.\"\n Line truncated
"content": "# s09: Agent Teams\n\n> *\"Append to send, drain to read\"* -- async mailboxes for persistent teammates.\n\n## Problem\n\nSubagents (s04) are disposable: spawn, work, return summary, die. They have no identity, no memory between invocations. Background tasks (s08) run shell commands but can't make LLM-guided decisions.\n\nFor real teamwork you need: (1) persistent agents that outlive a single prompt, (2) identity and lifecycle management, (3) a communication channel between agents.\n\n## Solution\n\n```\nTeammate lifecycle:\n spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN\n\nCommunication:\n .team/\n config.json <- team roster + statuses\n inbox/\n alice.jsonl <- append-only, drain-on-read\n bob.jsonl\n lead.jsonl\n\n +--------+ send(\"alice\",\"bob\",\"...\") +--------+\n | alice | -----------------------------> | bob |\n | loop | bob.jsonl << {json_line} | loop |\n +--------+ +--------+\n ^ |\n | BUS.read_inbox(\"alice\") |\n +---- alice.jsonl -> read + drain ---------+\n```\n\n## How It Works\n\n1. TeammateManager maintains config.json with the team roster.\n\n```python\nclass TeammateManager:\n def __init__(self, team_dir: Path):\n self.dir = team_dir\n self.dir.mkdir(exist_ok=True)\n self.config_path = self.dir / \"config.json\"\n self.config = self._load_config()\n self.threads = {}\n```\n\n2. `spawn()` creates a teammate and starts its agent loop in a thread.\n\n```python\ndef spawn(self, name: str, role: str, prompt: str) -> str:\n member = {\"name\": name, \"role\": role, \"status\": \"working\"}\n self.config[\"members\"].append(member)\n self._save_config()\n thread = threading.Thread(\n target=self._teammate_loop,\n args=(name, role, prompt), daemon=True)\n thread.start()\n return f\"Spawned teammate '{name}' (role: {role})\"\n```\n\n3. MessageBus: append-only JSONL inboxes. `send()` appends a JSON line; `read_inbox()` reads all and drains.\n\n```python\nclass MessageBus:\n def send(self, sender, to, content, msg_type=\"message\", extra=None):\n msg = {\"type\": msg_type, \"from\": sender,\n \"content\": content, \"timestamp\": time.time()}\n if extra:\n msg.update(extra)\n with open(self.dir / f\"{to}.jsonl\", \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n\n def read_inbox(self, name):\n path = self.dir / f\"{name}.jsonl\"\n if not path.exists(): return \"[]\"\n msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]\n path.write_text(\"\") # drain\n return json.dumps(msgs, indent=2)\n```\n\n4. Each teammate checks its inbox before every LLM call, injecting received messages into context.\n\n```python\ndef _teammate_loop(self, name, role, prompt):\n messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(50):\n inbox = BUS.read_inbox(name)\n if inbox != \"[]\":\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n messages.append({\"role\": \"assistant\",\n \"content\": \"Noted inbox messages.\"})\n response = client.messages.create(...)\n if response.stop_reason != \"tool_use\":\n break\n # execute tools, append results...\n self._find_member(name)[\"status\"] = \"idle\"\n```\n\n## What Changed From s08\n\n| Component | Before (s08) | After (s09) |\n|----------------|------------------|----------------------------|\n| Tools | 6 | 9 (+spawn/send/read_inbox) |\n| Agents | Single | Lead + N teammates |\n| Persistence | None | config.json + JSONL inboxes|\n| Threads | Background cmds | Full agent loops per thread|\n| Lifecycle | Fire-and-forget | idle -> working -> idle |\n| Communication | None | message + broadcast |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s09_agent_teams.py\n```\n\n1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.`\n2. `Broadcast \"status update: phase 1 complete\" to all teammates`\n3. `Check the lead inbox for any messages`\n4. Type `/team` to see the team roster with statuses\n5. Type `/inbox` to manually check the lead's inbox\n"
},
{
"version": "s10",
"locale": "en",
"title": "s10: Team Protocols",
"content": "# s10: Team Protocols\n\n> The same request_id handshake pattern powers both shutdown and plan approval -- one FSM, two applications.\n\n## The Problem\n\nIn s09, teammates work and communicate but there is no structured\ncoordination. Two problems arise:\n\n**Shutdown**: How do you stop a teammate cleanly? Killing the thread\nleaves files partially written and config.json in a wrong state.\nGraceful shutdown requires a handshake: the lead requests, the teammate\ndecides whether to approve (finish and exit) or reject (keep working).\n\n**Plan approval**: How do you gate execution? When the lead says\n\"refactor the auth module,\" the teammate starts immediately. For\nhigh-risk changes, the lead should review the plan before execution\nbegins. A junior proposes, a senior approves.\n\nBoth problems share the same structure: one side sends a request with a\nunique ID, the other side responds referencing that ID. A finite state\nmachine tracks each request through pending -> approved | rejected.\n\n## The Solution\n\n```\nShutdown Protocol Plan Approval Protocol\n================== ======================\n\nLead Teammate Teammate Lead\n | | | |\n |--shutdown_req-->| |--plan_req------>|\n | {req_id:\"abc\"} | | {req_id:\"xyz\"} |\n | | | |\n |<--shutdown_resp-| |<--plan_resp-----|\n | {req_id:\"abc\", | | {req_id:\"xyz\", |\n | approve:true} | | approve:true} |\n | | | |\n v v v v\ntracker[\"abc\"] exits proceeds tracker[\"xyz\"]\n = approved = approved\n\nShared FSM (identical for both protocols):\n [pending] --approve--> [approved]\n [pending] --reject---> [rejected]\n\nTrackers:\n shutdown_requests = {req_id: {target, status}}\n plan_requests = {req_id: {from, plan, status}}\n```\n\n## How It Works\n\n1. The lead initiates shutdown by generating a request_id and sending\n a shutdown_request through the inbox.\n\n```python\nshutdown_requests = {}\n\ndef handle_shutdown_request(teammate: str) -> str:\n req_id = str(uuid.uuid4())[:8]\n shutdown_requests[req_id] = {\n \"target\": teammate, \"status\": \"pending\",\n }\n BUS.send(\"lead\", teammate, \"Please shut down gracefully.\",\n \"shutdown_request\", {\"request_id\": req_id})\n return f\"Shutdown request {req_id} sent (status: pending)\"\n```\n\n2. The teammate receives the request in its inbox and calls the\n `shutdown_response` tool to approve or reject.\n\n```python\nif tool_name == \"shutdown_response\":\n req_id = args[\"request_id\"]\n approve = args[\"approve\"]\n if req_id in shutdown_requests:\n shutdown_requests[req_id][\"status\"] = \\\n \"approved\" if approve else \"rejected\"\n BUS.send(sender, \"lead\", args.get(\"reason\", \"\"),\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": approve})\n return f\"Shutdown {'approved' if approve else 'rejected'}\"\n```\n\n3. The teammate loop checks for approved shutdown and exits.\n\n```python\nif (block.name == \"shutdown_response\"\n and block.input.get(\"approve\")):\n should_exit = True\n# ...\nmember[\"status\"] = \"shutdown\" if should_exit else \"idle\"\n```\n\n4. Plan approval follows the identical pattern. The teammate submits\n a plan, generating a request_id.\n\n```python\nplan_requests = {}\n\nif tool_name == \"plan_approval\":\n plan_text = args.get(\"plan\", \"\")\n req_id = str(uuid.uuid4())[:8]\n plan_requests[req_id] = {\n \"from\": sender, \"plan\": plan_text,\n \"status\": \"pending\",\n }\n BUS.send(sender, \"lead\", plan_text,\n \"plan_approval_request\",\n {\"request_id\": req_id, \"plan\": plan_text})\n return f\"Plan submitted (request_id={req_id})\"\n```\n\n5. The lead reviews and responds with the same request_id.\n\n```python\ndef handle_plan_review(request_id, approve, feedback=\"\"):\n req = plan_requests.get(request_id)\n if not req:\n return f\"Error: Unknown request_id '{request_id}'\"\n req[\"status\"] = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", req[\"from\"], feedback,\n \"plan_approval_response\",\n {\"request_id\": request_id,\n \"approve\": approve,\n \"feedback\": feedback})\n return f\"Plan {req['status']} for '{req['from']}'\"\n```\n\n6. Both protocols use the same `plan_approval` tool name with two\n modes: teammates submit (no request_id), the lead reviews (with\n request_id).\n\n```python\n# Lead tool dispatch:\n\"plan_approval\": lambda **kw: handle_plan_review(\n kw[\"request_id\"], kw[\"approve\"],\n kw.get(\"feedback\", \"\")),\n# Line truncated
"content": "# s10: Team Protocols\n\n> *\"Same request_id, two protocols\"* -- one FSM pattern powers shutdown + plan approval.\n\n## Problem\n\nIn s09, teammates work and communicate but lack structured coordination:\n\n**Shutdown**: Killing a thread leaves files half-written and config.json stale. You need a handshake: the lead requests, the teammate approves (finish and exit) or rejects (keep working).\n\n**Plan approval**: When the lead says \"refactor the auth module,\" the teammate starts immediately. For high-risk changes, the lead should review the plan first.\n\nBoth share the same structure: one side sends a request with a unique ID, the other responds referencing that ID.\n\n## Solution\n\n```\nShutdown Protocol Plan Approval Protocol\n================== ======================\n\nLead Teammate Teammate Lead\n | | | |\n |--shutdown_req-->| |--plan_req------>|\n | {req_id:\"abc\"} | | {req_id:\"xyz\"} |\n | | | |\n |<--shutdown_resp-| |<--plan_resp-----|\n | {req_id:\"abc\", | | {req_id:\"xyz\", |\n | approve:true} | | approve:true} |\n\nShared FSM:\n [pending] --approve--> [approved]\n [pending] --reject---> [rejected]\n\nTrackers:\n shutdown_requests = {req_id: {target, status}}\n plan_requests = {req_id: {from, plan, status}}\n```\n\n## How It Works\n\n1. The lead initiates shutdown by generating a request_id and sending through the inbox.\n\n```python\nshutdown_requests = {}\n\ndef handle_shutdown_request(teammate: str) -> str:\n req_id = str(uuid.uuid4())[:8]\n shutdown_requests[req_id] = {\"target\": teammate, \"status\": \"pending\"}\n BUS.send(\"lead\", teammate, \"Please shut down gracefully.\",\n \"shutdown_request\", {\"request_id\": req_id})\n return f\"Shutdown request {req_id} sent (status: pending)\"\n```\n\n2. The teammate receives the request and responds with approve/reject.\n\n```python\nif tool_name == \"shutdown_response\":\n req_id = args[\"request_id\"]\n approve = args[\"approve\"]\n shutdown_requests[req_id][\"status\"] = \"approved\" if approve else \"rejected\"\n BUS.send(sender, \"lead\", args.get(\"reason\", \"\"),\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": approve})\n```\n\n3. Plan approval follows the identical pattern. The teammate submits a plan (generating a request_id), the lead reviews (referencing the same request_id).\n\n```python\nplan_requests = {}\n\ndef handle_plan_review(request_id, approve, feedback=\"\"):\n req = plan_requests[request_id]\n req[\"status\"] = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", req[\"from\"], feedback,\n \"plan_approval_response\",\n {\"request_id\": request_id, \"approve\": approve})\n```\n\nOne FSM, two applications. The same `pending -> approved | rejected` state machine handles any request-response protocol.\n\n## What Changed From s09\n\n| Component | Before (s09) | After (s10) |\n|----------------|------------------|------------------------------|\n| Tools | 9 | 12 (+shutdown_req/resp +plan)|\n| Shutdown | Natural exit only| Request-response handshake |\n| Plan gating | None | Submit/review with approval |\n| Correlation | None | request_id per request |\n| FSM | None | pending -> approved/rejected |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s10_team_protocols.py\n```\n\n1. `Spawn alice as a coder. Then request her shutdown.`\n2. `List teammates to see alice's status after shutdown approval`\n3. `Spawn bob with a risky refactoring task. Review and reject his plan.`\n4. `Spawn charlie, have him submit a plan, then approve it.`\n5. Type `/team` to monitor statuses\n"
},
{
"version": "s11",
"locale": "en",
"title": "s11: Autonomous Agents",
"content": "# s11: Autonomous Agents\n\n> An idle cycle with task board polling lets teammates find and claim work themselves, with identity re-injection after context compression.\n\n## The Problem\n\nIn s09-s10, teammates only work when explicitly told to. The lead must\nspawn each teammate with a specific prompt. If the task board has 10\nunclaimed tasks, the lead must manually assign each one. This does not\nscale.\n\nTrue autonomy means teammates find work themselves. When a teammate\nfinishes its current task, it should scan the task board for unclaimed\nwork, claim a task, and start working -- without any instruction from\nthe lead.\n\nBut autonomous agents face a subtlety: after context compression, the\nagent might forget who it is. If the messages are summarized, the\noriginal system prompt identity (\"you are alice, role: coder\") fades.\nIdentity re-injection solves this by inserting an identity block at the\nstart of compressed contexts.\n\nNote: token estimation here uses characters/4 (rough). The nag threshold of 3 rounds is low for teaching visibility.\n\n## The Solution\n\n```\nTeammate lifecycle with idle cycle:\n\n+-------+\n| spawn |\n+---+---+\n |\n v\n+-------+ tool_use +-------+\n| WORK | <------------- | LLM |\n+---+---+ +-------+\n |\n | stop_reason != tool_use\n | (or idle tool called)\n v\n+--------+\n| IDLE | poll every 5s for up to 60s\n+---+----+\n |\n +---> check inbox --> message? ----------> WORK\n |\n +---> scan .tasks/ --> unclaimed? -------> claim -> WORK\n |\n +---> 60s timeout ----------------------> SHUTDOWN\n\nIdentity re-injection after compression:\n if len(messages) <= 3:\n messages.insert(0, identity_block)\n \"You are 'alice', role: coder, team: my-team\"\n```\n\n## How It Works\n\n1. The teammate loop has two phases: WORK and IDLE. WORK runs the\n standard agent loop. When the LLM stops calling tools (or calls\n the `idle` tool), the teammate enters the IDLE phase.\n\n```python\ndef _loop(self, name, role, prompt):\n while True:\n # -- WORK PHASE --\n messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(50):\n inbox = BUS.read_inbox(name)\n for msg in inbox:\n if msg.get(\"type\") == \"shutdown_request\":\n self._set_status(name, \"shutdown\")\n return\n messages.append(...)\n response = client.messages.create(...)\n if response.stop_reason != \"tool_use\":\n break\n # execute tools...\n if idle_requested:\n break\n\n # -- IDLE PHASE --\n self._set_status(name, \"idle\")\n resume = self._idle_poll(name, messages)\n if not resume:\n self._set_status(name, \"shutdown\")\n return\n self._set_status(name, \"working\")\n```\n\n2. The idle phase polls the inbox and task board in a loop.\n\n```python\ndef _idle_poll(self, name, messages):\n polls = IDLE_TIMEOUT // POLL_INTERVAL # 60s / 5s = 12\n for _ in range(polls):\n time.sleep(POLL_INTERVAL)\n # Check inbox for new messages\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n return True\n # Scan task board for unclaimed tasks\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n task = unclaimed[0]\n claim_task(task[\"id\"], name)\n messages.append({\"role\": \"user\",\n \"content\": f\"<auto-claimed>Task #{task['id']}: \"\n f\"{task['subject']}</auto-claimed>\"})\n return True\n return False # timeout -> shutdown\n```\n\n3. Task board scanning looks for pending, unowned, unblocked tasks.\n\n```python\ndef scan_unclaimed_tasks() -> list:\n TASKS_DIR.mkdir(exist_ok=True)\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 not task.get(\"blockedBy\")):\n unclaimed.append(task)\n return unclaimed\n\ndef claim_task(task_id: int, owner: str):\n path = TASKS_DIR / f\"task_{task_id}.json\"\n task = json.loads(path.read_text())\n task[\"status\"] = \"in_progress\"\n task[\"owner\"] = owner\n path.write_text(json.dumps(task, indent=2))\n```\n\n4. Identity re-injection inserts an identity block when the context\n is too short, indicating compression has occurred.\n\n```python\ndef make_identity_block(name, role, team_name):\n return {\"role\": \"user\",\n \"content\": f\"<identity>You are '{name}', \"\n f\"role: {role}, team: {team_name}. \"\n f\"Continue your work.<Line truncated
"content": "# s11: Autonomous Agents\n\n> *\"Poll, claim, work, repeat\"* -- no coordinator needed, agents self-organize.\n\n## Problem\n\nIn s09-s10, teammates only work when explicitly told to. The lead must spawn each teammate with a specific prompt. If the task board has 10 unclaimed tasks, the lead must manually assign each one. This doesn't scale.\n\nTrue autonomy means teammates find work themselves: scan the task board, claim an unclaimed task, work on it, then look for more.\n\nOne subtlety: after context compression (s06), the agent might forget who it is. Identity re-injection solves this.\n\n## Solution\n\n```\nTeammate lifecycle with idle cycle:\n\n+-------+\n| spawn |\n+---+---+\n |\n v\n+-------+ tool_use +-------+\n| WORK | <------------- | LLM |\n+---+---+ +-------+\n |\n | stop_reason != tool_use (or idle tool called)\n v\n+--------+\n| IDLE | poll every 5s for up to 60s\n+---+----+\n |\n +---> check inbox --> message? ----------> WORK\n |\n +---> scan .tasks/ --> unclaimed? -------> claim -> WORK\n |\n +---> 60s timeout ----------------------> SHUTDOWN\n\nIdentity re-injection after compression:\n if len(messages) <= 3:\n messages.insert(0, identity_block)\n```\n\n## How It Works\n\n1. The teammate loop has two phases: WORK and IDLE. When the LLM stops calling tools (or calls `idle`), the teammate enters IDLE.\n\n```python\ndef _loop(self, name, role, prompt):\n while True:\n # -- WORK PHASE --\n messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(50):\n response = client.messages.create(...)\n if response.stop_reason != \"tool_use\":\n break\n # execute tools...\n if idle_requested:\n break\n\n # -- IDLE PHASE --\n self._set_status(name, \"idle\")\n resume = self._idle_poll(name, messages)\n if not resume:\n self._set_status(name, \"shutdown\")\n return\n self._set_status(name, \"working\")\n```\n\n2. The idle phase polls inbox and task board in a loop.\n\n```python\ndef _idle_poll(self, name, messages):\n for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12\n time.sleep(POLL_INTERVAL)\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n return True\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n claim_task(unclaimed[0][\"id\"], name)\n messages.append({\"role\": \"user\",\n \"content\": f\"<auto-claimed>Task #{unclaimed[0]['id']}: \"\n f\"{unclaimed[0]['subject']}</auto-claimed>\"})\n return True\n return False # timeout -> shutdown\n```\n\n3. Task board scanning: find pending, unowned, unblocked tasks.\n\n```python\ndef scan_unclaimed_tasks() -> list:\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 not task.get(\"blockedBy\")):\n unclaimed.append(task)\n return unclaimed\n```\n\n4. Identity re-injection: when context is too short (compression happened), insert an identity block.\n\n```python\nif len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"<identity>You are '{name}', role: {role}, \"\n f\"team: {team_name}. Continue your work.</identity>\"})\n messages.insert(1, {\"role\": \"assistant\",\n \"content\": f\"I am {name}. Continuing.\"})\n```\n\n## What Changed From s10\n\n| Component | Before (s10) | After (s11) |\n|----------------|------------------|----------------------------|\n| Tools | 12 | 14 (+idle, +claim_task) |\n| Autonomy | Lead-directed | Self-organizing |\n| Idle phase | None | Poll inbox + task board |\n| Task claiming | Manual only | Auto-claim unclaimed tasks |\n| Identity | System prompt | + re-injection after compress|\n| Timeout | None | 60s idle -> auto shutdown |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s11_autonomous_agents.py\n```\n\n1. `Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.`\n2. `Spawn a coder teammate and let it find work from the task board itself`\n3. `Create tasks with dependencies. Watch teammates respect the blocked order.`\n4. Type `/tasks` to see the task board with owners\n5. Type `/team` to monitor who is working vs idle\n"
},
{
"version": "s12",
"locale": "en",
"title": "s12: Worktree + Task Isolation",
"content": "# s12: Worktree + Task Isolation\n\n> Isolate by directory, coordinate by task ID -- tasks are the control plane, worktrees are the execution plane, and an event stream makes every lifecycle step observable.\n\n## The Problem\n\nBy s11, agents can claim and complete tasks autonomously. But every task runs in one shared directory. Ask two agents to refactor different modules at the same time and you hit three failure modes:\n\nAgent A edits `auth.py`. Agent B edits `auth.py`. Neither knows the other touched it. Unstaged changes collide, task status says \"in_progress\" but the directory is a mess, and when something breaks there is no way to roll back one agent's work without destroying the other's. The task board tracks _what to do_ but has no opinion about _where to do it_.\n\nThe fix is to separate the two concerns. Tasks manage goals. Worktrees manage execution context. Bind them by task ID, and each agent gets its own directory, its own branch, and a clean teardown path.\n\n## The Solution\n\n```\nControl Plane (.tasks/) Execution Plane (.worktrees/)\n+---------------------------+ +---------------------------+\n| task_1.json | | index.json |\n| id: 1 | | name: \"auth-refactor\" |\n| subject: \"Auth refactor\"| bind | path: \".worktrees/...\" |\n| status: \"in_progress\" | <----> | branch: \"wt/auth-...\" |\n| worktree: \"auth-refactor\"| | task_id: 1 |\n+---------------------------+ | status: \"active\" |\n +---------------------------+\n| task_2.json | | |\n| id: 2 | bind | name: \"ui-login\" |\n| subject: \"Login page\" | <----> | task_id: 2 |\n| worktree: \"ui-login\" | | status: \"active\" |\n+---------------------------+ +---------------------------+\n |\n +---------------------------+\n | events.jsonl (append-only)|\n | worktree.create.before |\n | worktree.create.after |\n | worktree.remove.after |\n | task.completed |\n +---------------------------+\n```\n\nThree state layers make this work:\n\n1. **Control plane** (`.tasks/task_*.json`) -- what is assigned, in progress, or done. Key fields: `id`, `subject`, `status`, `owner`, `worktree`.\n2. **Execution plane** (`.worktrees/index.json`) -- where commands run and whether the workspace is still valid. Key fields: `name`, `path`, `branch`, `task_id`, `status`.\n3. **Runtime state** (in-memory) -- per-turn execution continuity: `current_task`, `current_worktree`, `tool_result`, `error`.\n\n## How It Works\n\nThe lifecycle has five steps. Each step is a tool call.\n\n1. **Create a task.** Persist the goal first. The task starts as `pending` with an empty `worktree` field.\n\n```python\ntask = {\n \"id\": self._next_id,\n \"subject\": subject,\n \"status\": \"pending\",\n \"owner\": \"\",\n \"worktree\": \"\",\n}\nself._save(task)\n```\n\n2. **Create a worktree.** Allocate an isolated directory and branch. If you pass `task_id`, the task auto-advances to `in_progress` and the binding is written to both sides.\n\n```python\nself._run_git([\"worktree\", \"add\", \"-b\", branch, str(path), base_ref])\n\nentry = {\n \"name\": name,\n \"path\": str(path),\n \"branch\": branch,\n \"task_id\": task_id,\n \"status\": \"active\",\n}\nidx[\"worktrees\"].append(entry)\nself._save_index(idx)\n\nif task_id is not None:\n self.tasks.bind_worktree(task_id, name)\n```\n\n3. **Run commands in the worktree.** `worktree_run` sets `cwd` to the worktree path. Edits happen in the isolated directory, not the shared workspace.\n\n```python\nr = subprocess.run(\n command,\n shell=True,\n cwd=path,\n capture_output=True,\n text=True,\n timeout=300,\n)\n```\n\n4. **Observe.** `worktree_status` shows git state inside the isolated context. `worktree_events` queries the append-only event stream.\n\n5. **Close out.** Two choices:\n - `worktree_keep(name)` -- preserve the directory, mark lifecycle as `kept`.\n - `worktree_remove(name, complete_task=True)` -- remove the directory, complete the bound task, unbind, and emit `task.completed`. This is the closeout pattern: one call handles teardown and task completion together.\n\n## State Machines\n\n```\nTask: pending -------> in_progress -------> completed\n (worktree_create (worktree_remove\n with task_id) with complete_task=true)\n\nWorktree: absent --------> active -----------> removed | kept\n Line truncated
"content": "# s12: Worktree + Task Isolation\n\n> *\"Isolate by directory, coordinate by task ID\"* -- task board + optional worktree lanes.\n\n## Problem\n\nBy s11, agents can claim and complete tasks autonomously. But every task runs in one shared directory. Two agents refactoring different modules at the same time will collide: agent A edits `config.py`, agent B edits `config.py`, unstaged changes mix, and neither can roll back cleanly.\n\nThe task board tracks *what to do* but has no opinion about *where to do it*. The fix: give each task its own git worktree directory. Tasks manage goals, worktrees manage execution context. Bind them by task ID.\n\n## Solution\n\n```\nControl plane (.tasks/) Execution plane (.worktrees/)\n+------------------+ +------------------------+\n| task_1.json | | auth-refactor/ |\n| status: in_progress <------> branch: wt/auth-refactor\n| worktree: \"auth-refactor\" | task_id: 1 |\n+------------------+ +------------------------+\n| task_2.json | | ui-login/ |\n| status: pending <------> branch: wt/ui-login\n| worktree: \"ui-login\" | task_id: 2 |\n+------------------+ +------------------------+\n |\n index.json (worktree registry)\n events.jsonl (lifecycle log)\n\nState machines:\n Task: pending -> in_progress -> completed\n Worktree: absent -> active -> removed | kept\n```\n\n## How It Works\n\n1. **Create a task.** Persist the goal first.\n\n```python\nTASKS.create(\"Implement auth refactor\")\n# -> .tasks/task_1.json status=pending worktree=\"\"\n```\n\n2. **Create a worktree and bind to the task.** Passing `task_id` auto-advances the task to `in_progress`.\n\n```python\nWORKTREES.create(\"auth-refactor\", task_id=1)\n# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD\n# -> index.json gets new entry, task_1.json gets worktree=\"auth-refactor\"\n```\n\nThe binding writes state to both sides:\n\n```python\ndef bind_worktree(self, task_id, worktree):\n task = self._load(task_id)\n task[\"worktree\"] = worktree\n if task[\"status\"] == \"pending\":\n task[\"status\"] = \"in_progress\"\n self._save(task)\n```\n\n3. **Run commands in the worktree.** `cwd` points to the isolated directory.\n\n```python\nsubprocess.run(command, shell=True, cwd=worktree_path,\n capture_output=True, text=True, timeout=300)\n```\n\n4. **Close out.** Two choices:\n - `worktree_keep(name)` -- preserve the directory for later.\n - `worktree_remove(name, complete_task=True)` -- remove directory, complete the bound task, emit event. One call handles teardown + completion.\n\n```python\ndef remove(self, name, force=False, complete_task=False):\n self._run_git([\"worktree\", \"remove\", wt[\"path\"]])\n if complete_task and wt.get(\"task_id\") is not None:\n self.tasks.update(wt[\"task_id\"], status=\"completed\")\n self.tasks.unbind_worktree(wt[\"task_id\"])\n self.events.emit(\"task.completed\", ...)\n```\n\n5. **Event stream.** Every lifecycle step emits to `.worktrees/events.jsonl`:\n\n```json\n{\n \"event\": \"worktree.remove.after\",\n \"task\": {\"id\": 1, \"status\": \"completed\"},\n \"worktree\": {\"name\": \"auth-refactor\", \"status\": \"removed\"},\n \"ts\": 1730000000\n}\n```\n\nEvents emitted: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`.\n\nAfter a crash, state reconstructs from `.tasks/` + `.worktrees/index.json` on disk. Conversation memory is volatile; file state is durable.\n\n## What Changed From s11\n\n| Component | Before (s11) | After (s12) |\n|--------------------|----------------------------|----------------------------------------------|\n| Coordination | Task board (owner/status) | Task board + explicit worktree binding |\n| Execution scope | Shared directory | Task-scoped isolated directory |\n| Recoverability | Task status only | Task status + worktree index |\n| Teardown | Task completion | Task completion + explicit keep/remove |\n| Lifecycle visibility | Implicit in logs | Explicit events in `.worktrees/events.jsonl` |\n\n## Try It\n\n```sh\ncd learn-claude-code\npython agents/s12_worktree_task_isolation.py\n```\n\n1. `Create tasks for backend auth and frontend login page, then list tasks.`\n2. `Create worktree \"auth-refactor\" for task 1, then bind task 2 to a new worktree \"ui-login\".`\n3. `Run \"git status --short\" in worktree \"auth-refactor\".`\n4. `Keep worktree \"ui-login\", then list worktrees and inspect events.`\n5. `Remove worktree \"auth-refactor\" with complete_task=true, then liLine truncated
},
{
"version": "s01",
"locale": "zh",
"title": "s01: Agent Loop (智能体循环)",
"content": "# s01: Agent Loop (智能体循环)\n\n> AI 编程智能体的核心是一个 while 循环 -- 把工具执行结果反馈给模型, 直到模型决定停止。\n\n## 问题\n\n为什么语言模型不能直接回答编程问题? 因为编程需要**与真实世界交互**。模型需要读取文件、运行测试、检查错误、反复迭代。单次的提示-响应交互无法做到这些。\n\n没有 agent loop, 你就得手动把输出复制粘贴回模型。用户自己变成了那个循环。Agent loop 将这个过程自动化: 调用模型, 执行它要求的工具, 把结果送回去, 重复 -- 直到模型说 \"我完成了\"。\n\n考虑一个简单任务: \"创建一个打印 hello 的 Python 文件。\" 模型需要 (1) 决定写文件, (2) 写入文件, (3) 验证是否正常工作。至少三次工具调用。没有循环的话, 每一次都需要人工干预。\n\n## 解决方案\n\n```\n+----------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tool |\n| prompt | | | | execute |\n+----------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +---------------+\n (loop continues)\n\nThe loop terminates when stop_reason != \"tool_use\".\nThat single condition is the entire control flow.\n```\n\n## 工作原理\n\n1. 用户提供一个 prompt, 成为第一条消息。\n\n```python\nhistory.append({\"role\": \"user\", \"content\": query})\n```\n\n2. 消息数组连同工具定义一起发送给 LLM。\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n3. 助手的响应被追加到消息列表中。\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\n```\n\n4. 检查 stop_reason。如果模型没有调用工具, 循环结束。在本节最小实现里, 这是唯一的循环退出条件。\n\n```python\nif response.stop_reason != \"tool_use\":\n return\n```\n\n5. 对响应中的每个 tool_use 块, 执行工具 (本节课中是 bash) 并收集结果。\n\n```python\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\n6. 结果作为 user 消息追加, 循环继续。\n\n```python\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n## 核心代码\n\n最小可行智能体 -- 不到 30 行代码实现整个模式\n(来自 `agents/s01_agent_loop.py`, 第 66-86 行):\n\n```python\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 if response.stop_reason != \"tool_use\":\n return\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## 变更内容\n\n这是第 1 节课 -- 起点。没有前置课程。\n\n| 组件 | 之前 | 之后 |\n|---------------|------------|--------------------------------|\n| Agent loop | (无) | `while True` + stop_reason |\n| Tools | (无) | `bash` (单一工具) |\n| Messages | (无) | 累积式消息列表 |\n| Control flow | (无) | `stop_reason != \"tool_use\"` |\n\n## 设计原理\n\n这个循环是所有基于 LLM 的智能体基础。生产实现还会增加错误处理、token 计数、流式输出、重试、权限策略与生命周期编排, 但核心交互模式仍从这里开始。本节强调简洁性: 在本节最小实现里, 一个退出条件 (`stop_reason != \"tool_use\"`) 就能支撑我们先学会主流程。本课程中的其他内容都在这个循环上叠加。理解这个循环是建立基础心智模型, 不是完整的生产架构。\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s01_agent_loop.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?`\n4. `Create a directory called test_output and write 3 files in it`\n"
"title": "s01: The Agent Loop (智能体循环)",
"content": "# s01: The Agent Loop (智能体循环)\n\n> *\"One loop & Bash is all you need\"* -- 一个工具 + 一个循环 = 一个智能体。\n\n## 问题\n\n语言模型能推理代码, 但无法触及真实世界。它不能读文件、跑测试、检查错误。没有循环, 每次工具调用都需要你手动把结果复制粘贴回去。你自己变成了那个循环。\n\n## 解决方案\n\n```\n+--------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tool |\n| prompt | | | | execute |\n+--------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +----------------+\n (loop until stop_reason != \"tool_use\")\n```\n\n一个退出条件控制整个流程。循环持续运行, 直到模型不再调用工具。\n\n## 工作原理\n\n1. 用户 prompt 作为第一条消息。\n\n```python\nmessages.append({\"role\": \"user\", \"content\": query})\n```\n\n2. 将消息和工具定义一起发给 LLM。\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n3. 追加助手响应。检查 `stop_reason` -- 如果模型没有调用工具, 结束。\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\nif response.stop_reason != \"tool_use\":\n return\n```\n\n4. 执行每个工具调用, 收集结果, 作为 user 消息追加。回到第 2 步。\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 })\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n组装为一个完整函数:\n\n```python\ndef agent_loop(query):\n messages = [{\"role\": \"user\", \"content\": query}]\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 行代码。本课程后续的所有内容都在这个循环上叠加, 而不会改动它。\n\n## 变更内容\n\n| 组件 | 之前 | 之后 |\n|---------------|------------|--------------------------------|\n| Agent loop | (无) | `while True` + stop_reason |\n| Tools | (无) | `bash` (单一工具) |\n| Messages | (无) | 累积式消息列表 |\n| Control flow | (无) | `stop_reason != \"tool_use\"` |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s01_agent_loop.py\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?`\n4. `Create a directory called test_output and write 3 files in it`\n"
},
{
"version": "s02",
"locale": "zh",
"title": "s02: Tools (工具)",
"content": "# s02: Tools (工具)\n\n> 一个分发映射表 (dispatch map) 将工具调用路由到处理函数 -- 循环本身完全不需要改动。\n\n## 问题\n\n只有 `bash` 时, 智能体所有操作都通过 shell: 读文件、写文件、编辑文件。这能用但很脆弱。`cat` 的输出会被不可预测地截断。`sed` 替换遇到特殊字符就会失败。模型浪费大量 token 构造 shell 管道, 而一个直接的函数调用会简单得多。\n\n更重要的是, bash 存在安全风险。每次 bash 调用都能做 shell 能做的一切。有了专用工具如 `read_file` 和 `write_file`, 你可以在工具层面强制路径沙箱化, 阻止危险模式, 而不是寄希望于模型自觉回避。\n\n关键洞察: 添加工具不需要修改循环。s01 的循环保持不变。你只需在工具数组中添加条目, 编写处理函数, 然后通过 dispatch map 把它们关联起来。\n\n## 解决方案\n\n```\n+----------+ +-------+ +------------------+\n| User | ---> | LLM | ---> | Tool Dispatch |\n| prompt | | | | { |\n+----------+ +---+---+ | bash: run_bash |\n ^ | read: run_read |\n | | write: run_wr |\n +----------+ edit: run_edit |\n tool_result| } |\n +------------------+\n\nThe dispatch map is a dict: {tool_name: handler_function}\nOne lookup replaces any if/elif chain.\n```\n\n## 工作原理\n\n1. 为每个工具定义处理函数。每个函数接受与工具 input_schema 对应的关键字参数, 返回字符串结果。\n\n```python\ndef run_read(path: str, limit: int = None) -> str:\n text = safe_path(path).read_text()\n lines = text.splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit]\n return \"\\n\".join(lines)[:50000]\n```\n\n2. 创建 dispatch map, 将工具名映射到处理函数。\n\n```python\nTOOL_HANDLERS = {\n \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n \"read_file\": lambda **kw: run_read(kw[\"path\"], kw.get(\"limit\")),\n \"write_file\": lambda **kw: run_write(kw[\"path\"], kw[\"content\"]),\n \"edit_file\": lambda **kw: run_edit(kw[\"path\"], kw[\"old_text\"],\n kw[\"new_text\"]),\n}\n```\n\n3. 在 agent loop 中, 按名称查找处理函数, 而不是硬编码。\n\n```python\nfor block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input)\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n4. 路径沙箱化防止模型逃逸出工作区。\n\n```python\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\n## 核心代码\n\ndispatch 模式 (来自 `agents/s02_tool_use.py`, 第 93-129 行):\n\n```python\nTOOL_HANDLERS = {\n \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n \"read_file\": lambda **kw: run_read(kw[\"path\"], kw.get(\"limit\")),\n \"write_file\": lambda **kw: run_write(kw[\"path\"], kw[\"content\"]),\n \"edit_file\": lambda **kw: run_edit(kw[\"path\"], kw[\"old_text\"],\n kw[\"new_text\"]),\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 if response.stop_reason != \"tool_use\":\n return\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler \\\n else f\"Unknown tool: {block.name}\"\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## 相对 s01 的变更\n\n| 组件 | 之前 (s01) | 之后 (s02) |\n|----------------|--------------------|----------------------------|\n| Tools | 1 (仅 bash) | 4 (bash, read, write, edit)|\n| Dispatch | 硬编码 bash 调用 | `TOOL_HANDLERS` 字典 |\n| 路径安全 | 无 | `safe_path()` 沙箱 |\n| Agent loop | 不变 | 不变 |\n\n## 设计原理\n\ndispatch map 模式可以线性扩Line truncated
"title": "s02: Tool Use (工具使用)",
"content": "# s02: Tool Use (工具使用)\n\n> *\"The loop didn't change\"* -- 添加工具意味着添加处理函数, 而非重写循环。\n\n## 问题\n\n只有 `bash` 时, 智能体所有操作都走 shell。`cat` 截断不可预测, `sed` 遇到特殊字符就崩。每次 bash 调用都是一个不受约束的安全面。专用工具如 `read_file` 和 `write_file` 允许在工具层面强制路径沙箱。\n\n关键洞察: 添加工具不需要改循环。\n\n## 解决方案\n\n```\n+--------+ +-------+ +------------------+\n| User | ---> | LLM | ---> | Tool Dispatch |\n| prompt | | | | { |\n+--------+ +---+---+ | bash: run_bash |\n ^ | read: run_read |\n | | write: run_wr |\n +-----------+ edit: run_edit |\n tool_result | } |\n +------------------+\n\nThe dispatch map is a dict: {tool_name: handler_function}.\nOne lookup replaces any if/elif chain.\n```\n\n## 工作原理\n\n1. 每个工具有一个处理函数。路径沙箱防止逃逸工作区。\n\n```python\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\ndef run_read(path: str, limit: int = None) -> str:\n text = safe_path(path).read_text()\n lines = text.splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit]\n return \"\\n\".join(lines)[:50000]\n```\n\n2. dispatch map 将工具名映射到处理函数。\n\n```python\nTOOL_HANDLERS = {\n \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n \"read_file\": lambda **kw: run_read(kw[\"path\"], kw.get(\"limit\")),\n \"write_file\": lambda **kw: run_write(kw[\"path\"], kw[\"content\"]),\n \"edit_file\": lambda **kw: run_edit(kw[\"path\"], kw[\"old_text\"],\n kw[\"new_text\"]),\n}\n```\n\n3. 循环中按名称查找处理函数。循环体本身与 s01 完全一致。\n\n```python\nfor block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler \\\n else f\"Unknown tool: {block.name}\"\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n添加工具 = 添加处理函数 + 添加 schema 条目。循环永远不变。\n\n## 相对 s01 的变更\n\n| 组件 | 之前 (s01) | 之后 (s02) |\n|----------------|--------------------|--------------------------------|\n| Tools | 1 (仅 bash) | 4 (bash, read, write, edit) |\n| Dispatch | 硬编码 bash 调用 | `TOOL_HANDLERS` 字典 |\n| 路径安全 | 无 | `safe_path()` 沙箱 |\n| Agent loop | 不变 | 不变 |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s02_tool_use.py\n```\n\n1. `Read the file requirements.txt`\n2. `Create a file called greet.py with a greet(name) function`\n3. `Edit greet.py to add a docstring to the function`\n4. `Read greet.py to verify the edit worked`\n"
},
{
"version": "s03",
"locale": "zh",
"title": "s03: TodoWrite (待办写入)",
"content": "# s03: TodoWrite (待办写入)\n\n> TodoManager 让智能体能追踪自己的进度, 而 nag reminder 注入机制在它忘记更新时强制提醒。\n\n## 问题\n\n当智能体处理多步骤任务时, 它经常丢失对已完成和待办事项的追踪。没有显式的计划, 模型可能重复工作、跳过步骤或跑偏。用户也无法看到智能体内部的计划。\n\n这个问题比听起来更严重。长对话会导致模型 \"漂移\" -- 随着上下文窗口被工具结果填满, 系统提示的影响力逐渐减弱。一个 10 步的重构任务可能完成了 1-3 步, 然后模型就开始即兴发挥, 因为它忘了第 4-10 步的存在。\n\n解决方案是结构化状态: 一个模型显式写入的 TodoManager。模型创建计划, 工作时将项目标记为 in_progress, 完成后标记为 completed。nag reminder 机制在模型连续 3 轮以上不更新待办时注入提醒。\n\n注: nag 阈值 3 轮是为教学可见性设的低值, 生产环境通常更高。从 s07 起, 课程转向 Task 看板处理持久化多步工作; TodoWrite 仍可用于轻量清单。\n\n## 解决方案\n\n```\n+----------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tools |\n| prompt | | | | + todo |\n+----------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +---------------+\n |\n +-----------+-----------+\n | TodoManager state |\n | [ ] task A |\n | [>] task B <- doing |\n | [x] task C |\n +-----------------------+\n |\n if rounds_since_todo >= 3:\n inject <reminder> into tool_result\n```\n\n## 工作原理\n\n1. TodoManager 验证并存储一组带状态的项目。同一时间只允许一个项目处于 `in_progress` 状态。\n\n```python\nclass TodoManager:\n def __init__(self):\n self.items = []\n\n def update(self, items: list) -> str:\n validated = []\n in_progress_count = 0\n for item in items:\n status = item.get(\"status\", \"pending\")\n if status == \"in_progress\":\n in_progress_count += 1\n validated.append({\n \"id\": item[\"id\"],\n \"text\": item[\"text\"],\n \"status\": status,\n })\n if in_progress_count > 1:\n raise ValueError(\"Only one task can be in_progress\")\n self.items = validated\n return self.render()\n```\n\n2. `todo` 工具和其他工具一样添加到 dispatch map 中。\n\n```python\nTOOL_HANDLERS = {\n \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n # ...other tools...\n \"todo\": lambda **kw: TODO.update(kw[\"items\"]),\n}\n```\n\n3. nag reminder 在模型连续 3 轮以上不调用 `todo` 时, 向 tool_result 消息中注入 `<reminder>` 标签。\n\n```python\ndef agent_loop(messages: list):\n rounds_since_todo = 0\n while True:\n if rounds_since_todo >= 3 and messages:\n last = messages[-1]\n if (last[\"role\"] == \"user\"\n and isinstance(last.get(\"content\"), list)):\n last[\"content\"].insert(0, {\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n # ... rest of loop ...\n rounds_since_todo = 0 if used_todo else rounds_since_todo + 1\n```\n\n4. 系统提示指导模型使用 todo 进行规划。\n\n```python\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nUse the todo tool to plan multi-step tasks.\nMark in_progress before starting, completed when done.\nPrefer tools over prose.\"\"\"\n```\n\n## 核心代码\n\nTodoManager 和 nag 注入 (来自 `agents/s03_todo_write.py`,\n第 51-85 行和第 158-187 行):\n\n```python\nclass TodoManager:\n def update(self, items: list) -> str:\n validated = []\n in_progress_count = 0\n for item in items:\n status = item.get(\"status\", \"pending\")\n if status == \"in_progress\":\n in_progress_count += 1\n validated.append({\n \"id\": item[\"id\"],\n \"text\": item[\"text\"],\n \"status\": status,\n })\n if in_progress_count > 1:\n raise ValueError(\"Only one in_progress\")\n self.items = validated\n return self.render()\n\n# In agent_loop:\nif rounds_since_todo >= 3:\n last[\"content\"].insert(0, {\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n```\n\n## 相对 s02 的变更\n\n| 组件 | 之前 (s02) | 之后 (s03) |\n|----------------|------------------|------Line truncated
"content": "# s03: TodoWrite (待办写入)\n\n> *\"Plan before you act\"* -- 可见的计划提升任务完成率。\n\n## 问题\n\n处理多步骤任务时, 模型会丢失进度。它重复工作、跳过步骤、或者跑偏。长对话让问题更严重 -- 随着工具结果填满上下文, 系统提示的影响力逐渐减弱。一个 10 步的重构可能完成了 1-3 步, 然后模型就开始即兴发挥, 因为它忘了 4-10 步。\n\n## 解决方案\n\n```\n+--------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tools |\n| prompt | | | | + todo |\n+--------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +----------------+\n |\n +-----------+-----------+\n | TodoManager state |\n | [ ] task A |\n | [>] task B <- doing |\n | [x] task C |\n +-----------------------+\n |\n if rounds_since_todo >= 3:\n inject <reminder> into tool_result\n```\n\n## 工作原理\n\n1. TodoManager 存储带状态的项目。同一时间只允许一个 `in_progress`。\n\n```python\nclass TodoManager:\n def update(self, items: list) -> str:\n validated, in_progress_count = [], 0\n for item in items:\n status = item.get(\"status\", \"pending\")\n if status == \"in_progress\":\n in_progress_count += 1\n validated.append({\"id\": item[\"id\"], \"text\": item[\"text\"],\n \"status\": status})\n if in_progress_count > 1:\n raise ValueError(\"Only one task can be in_progress\")\n self.items = validated\n return self.render()\n```\n\n2. `todo` 工具和其他工具一样加入 dispatch map。\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"todo\": lambda **kw: TODO.update(kw[\"items\"]),\n}\n```\n\n3. nag reminder: 模型连续 3 轮以上不调用 `todo` 时注入提醒。\n\n```python\nif rounds_since_todo >= 3 and messages:\n last = messages[-1]\n if last[\"role\"] == \"user\" and isinstance(last.get(\"content\"), list):\n last[\"content\"].insert(0, {\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n```\n\n\"同时只允许一个 in_progress\" 强制顺序聚焦。nag reminder 创造问责性。\n\n## 相对 s02 的变更\n\n| 组件 | 之前 (s02) | 之后 (s03) |\n|----------------|------------------|--------------------------------|\n| Tools | 4 | 5 (+todo) |\n| 规划 | 无 | 带状态的 TodoManager |\n| Nag 注入 | 无 | 3 轮后注入 `<reminder>` |\n| Agent loop | 简单分发 | + rounds_since_todo 计数器 |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s03_todo_write.py\n```\n\n1. `Refactor the file hello.py: add type hints, docstrings, and a main guard`\n2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py`\n3. `Review all Python files and fix any style issues`\n"
},
{
"version": "s04",
"locale": "zh",
"title": "s04: Subagent (子智能体)",
"content": "# s04: Subagent (子智能体)\n\n> 子智能体使用全新的消息列表运行, 与父智能体共享文件系统, 仅返回摘要 -- 保持父上下文的整洁。\n\n## 问题\n\n随着智能体工作, 它的消息数组不断增长。每次工具调用、每次文件读取、每次 bash 输出都在累积。20-30 次工具调用后, 上下文窗口充满了无关的历史。为了回答一个简单问题而读取的 500 行文件, 会永久占据上下文中的 500 行空间。\n\n这对探索性任务尤其糟糕。\"这个项目用了什么测试框架?\" 可能需要读取 5 个文件, 但父智能体的历史中并不需要这 5 个文件的全部内容 -- 它只需要答案: \"pytest, 使用 conftest.py 配置。\"\n\n在本课程里, 一个实用解法是 fresh `messages[]` 隔离: 以 `messages=[]` 启动一个子智能体。子智能体进行探索、读取文件、运行命令。完成后, 只有最终的文本响应返回给父智能体。子智能体的全部消息历史被丢弃。\n\n## 解决方案\n\n```\nParent agent Subagent\n+------------------+ +------------------+\n| messages=[...] | | messages=[] | <-- fresh\n| | dispatch | |\n| tool: task | ---------->| while tool_use: |\n| prompt=\"...\" | | call tools |\n| | summary | append results |\n| result = \"...\" | <--------- | return last text |\n+------------------+ +------------------+\n |\nParent context stays clean.\nSubagent context is discarded.\n```\n\n## 工作原理\n\n1. 父智能体拥有一个 `task` 工具用于触发子智能体的生成。子智能体获得除 `task` 外的所有基础工具 (不允许递归生成)。\n\n```python\nPARENT_TOOLS = CHILD_TOOLS + [\n {\"name\": \"task\",\n \"description\": \"Spawn a subagent with fresh context.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"prompt\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n },\n \"required\": [\"prompt\"],\n }},\n]\n```\n\n2. 子智能体以全新的消息列表启动, 仅包含委派的 prompt。它共享相同的文件系统。\n\n```python\ndef run_subagent(prompt: str) -> str:\n sub_messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUBAGENT_SYSTEM,\n messages=sub_messages,\n tools=CHILD_TOOLS, max_tokens=8000,\n )\n sub_messages.append({\n \"role\": \"assistant\", \"content\": response.content\n })\n if response.stop_reason != \"tool_use\":\n break\n # execute tools, append results...\n```\n\n3. 只有最终文本返回给父智能体。子智能体 30+ 次工具调用的历史被丢弃。\n\n```python\n return \"\".join(\n b.text for b in response.content if hasattr(b, \"text\")\n ) or \"(no summary)\"\n```\n\n4. 父智能体将此摘要作为普通的 tool_result 接收。\n\n```python\nif block.name == \"task\":\n output = run_subagent(block.input[\"prompt\"])\nresults.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output),\n})\n```\n\n## 核心代码\n\n子智能体函数 (来自 `agents/s04_subagent.py`, 第 110-128 行):\n\n```python\ndef run_subagent(prompt: str) -> str:\n sub_messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(30):\n response = client.messages.create(\n model=MODEL, system=SUBAGENT_SYSTEM,\n messages=sub_messages,\n tools=CHILD_TOOLS, max_tokens=8000,\n )\n sub_messages.append({\"role\": \"assistant\",\n \"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 = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)[:50000]})\n sub_messages.append({\"role\": \"user\", \"content\": results})\n return \"\".join(\n b.text for b in response.content if hasattr(b, \"text\")\n ) or \"(no summary)\"\n```\n\n## 相对 s03 的变更\n\n| 组件 | 之前 (s03) | 之后 (s04) |\n|----------------|------------------|---------------------------|\n| Tools | 5 | 5 (基础) + task (仅父端) |\n| 上下文 | 单一共享 | 父 + 子隔离 |\n| Subagent | 无 | `run_subagent()` 函数 |\n| 返回值 Line truncated
"title": "s04: Subagents (子智能体)",
"content": "# s04: Subagents (子智能体)\n\n> *\"Process isolation = context isolation\"* -- 每个子智能体用全新的 messages[]。\n\n## 问题\n\n随着智能体工作, 消息数组不断膨胀。每次文件读取、每次 bash 输出都永久留在上下文中。\"这个项目用了什么测试框架?\" 可能需要读 5 个文件, 但父智能体只需要答案: \"pytest。\"\n\n## 解决方案\n\n```\nParent agent Subagent\n+------------------+ +------------------+\n| messages=[...] | | messages=[] | <-- fresh\n| | dispatch | |\n| tool: task | ----------> | while tool_use: |\n| prompt=\"...\" | | call tools |\n| | summary | append results |\n| result = \"...\" | <---------- | return last text |\n+------------------+ +------------------+\n\nParent context stays clean. Subagent context is discarded.\n```\n\n## 工作原理\n\n1. 父智能体有一个 `task` 工具。子智能体拥有除 `task` 外的所有基础工具 (禁止递归生成)。\n\n```python\nPARENT_TOOLS = CHILD_TOOLS + [\n {\"name\": \"task\",\n \"description\": \"Spawn a subagent with fresh context.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\"prompt\": {\"type\": \"string\"}},\n \"required\": [\"prompt\"],\n }},\n]\n```\n\n2. 子智能体以 `messages=[]` 启动, 运行自己的循环。只有最终文本返回给父智能体。\n\n```python\ndef run_subagent(prompt: str) -> str:\n sub_messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUBAGENT_SYSTEM,\n messages=sub_messages,\n tools=CHILD_TOOLS, max_tokens=8000,\n )\n sub_messages.append({\"role\": \"assistant\",\n \"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 = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)[:50000]})\n sub_messages.append({\"role\": \"user\", \"content\": results})\n return \"\".join(\n b.text for b in response.content if hasattr(b, \"text\")\n ) or \"(no summary)\"\n```\n\n子智能体可能执行了 30+ 次工具调用, 整个消息历史被丢弃。父智能体收到的只是一段摘要, 作为普通 `tool_result`。\n\n## 相对 s03 的变更\n\n| 组件 | 之前 (s03) | 之后 (s04) |\n|----------------|------------------|-------------------------------|\n| Tools | 5 | 5 (基础) + task (仅父端) |\n| 上下文 | 单一共享 | 父 + 子隔离 |\n| Subagent | 无 | `run_subagent()` 函数 |\n| 返回值 | 不适用 | 仅摘要文本 |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s04_subagent.py\n```\n\n1. `Use a subtask to find what testing framework this project uses`\n2. `Delegate: read all .py files and summarize what each one does`\n3. `Use a task to create a new module, then verify it from here`\n"
},
{
"version": "s05",
"locale": "zh",
"title": "s05: Skills (技能加载)",
"content": "# s05: Skills (技能加载)\n\n> 两层技能注入避免了系统提示膨胀: 在系统提示中放技能名称 (低成本), 在 tool_result 中按需放入完整技能内容。\n\n## 问题\n\n智能体需要针对不同领域遵循特定的工作流: git 约定、测试模式、代码审查清单。简单粗暴的做法是把所有内容都塞进系统提示。但系统提示的有效注意力是有限的 -- 文本太多, 模型就会开始忽略其中一部分。\n\n如果你有 10 个技能, 每个 2000 token, 那就是 20,000 token 的系统提示。模型关注开头和结尾, 但会略过中间部分。更糟糕的是, 这些技能中大部分与当前任务无关。文件编辑任务不需要 git 工作流说明。\n\n两层方案解决了这个问题: 第一层在系统提示中放入简短的技能描述 (每个技能约 100 token)。第二层只在模型调用 `load_skill` 时, 才将完整的技能内容加载到 tool_result 中。模型知道有哪些技能可用 (低成本), 按需加载它们 (只在相关时)。\n\n## 解决方案\n\n```\nSystem prompt (Layer 1 -- always present):\n+--------------------------------------+\n| You are a coding agent. |\n| Skills available: |\n| - git: Git workflow helpers | ~100 tokens/skill\n| - test: Testing best practices |\n+--------------------------------------+\n\nWhen model calls load_skill(\"git\"):\n+--------------------------------------+\n| tool_result (Layer 2 -- on demand): |\n| <skill name=\"git\"> |\n| Full git workflow instructions... | ~2000 tokens\n| Step 1: ... |\n| Step 2: ... |\n| </skill> |\n+--------------------------------------+\n```\n\n## 工作原理\n\n1. 技能文件以 Markdown 格式存放在 `.skills/` 目录中, 带有 YAML frontmatter。\n\n```\n.skills/\n git.md # ---\\n description: Git workflow\\n ---\\n ...\n test.md # ---\\n description: Testing patterns\\n ---\\n ...\n```\n\n2. SkillLoader 解析 frontmatter, 分离元数据和正文。\n\n```python\nclass SkillLoader:\n def _parse_frontmatter(self, text: str) -> tuple:\n match = re.match(\n r\"^---\\n(.*?)\\n---\\n(.*)\", text, re.DOTALL\n )\n if not match:\n return {}, text\n meta = {}\n for line in match.group(1).strip().splitlines():\n if \":\" in line:\n key, val = line.split(\":\", 1)\n meta[key.strip()] = val.strip()\n return meta, match.group(2).strip()\n```\n\n3. 第一层: `get_descriptions()` 返回简短描述, 用于系统提示。\n\n```python\ndef get_descriptions(self) -> str:\n lines = []\n for name, skill in self.skills.items():\n desc = skill[\"meta\"].get(\"description\", \"No description\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\n\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nSkills available:\n{SKILL_LOADER.get_descriptions()}\"\"\"\n```\n\n4. 第二层: `get_content()` 返回用 `<skill>` 标签包裹的完整正文。\n\n```python\ndef get_content(self, name: str) -> str:\n skill = self.skills.get(name)\n if not skill:\n return f\"Error: Unknown skill '{name}'.\"\n return f\"<skill name=\\\"{name}\\\">\\n{skill['body']}\\n</skill>\"\n```\n\n5. `load_skill` 工具只是 dispatch map 中的又一个条目。\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"load_skill\": lambda **kw: SKILL_LOADER.get_content(kw[\"name\"]),\n}\n```\n\n## 核心代码\n\nSkillLoader 类 (来自 `agents/s05_skill_loading.py`, 第 51-97 行):\n\n```python\nclass SkillLoader:\n def __init__(self, skills_dir: Path):\n self.skills = {}\n for f in sorted(skills_dir.glob(\"*.md\")):\n text = f.read_text()\n meta, body = self._parse_frontmatter(text)\n self.skills[f.stem] = {\n \"meta\": meta, \"body\": body\n }\n\n def get_descriptions(self) -> str:\n lines = []\n for name, skill in self.skills.items():\n desc = skill[\"meta\"].get(\"description\", \"\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\n\n def get_content(self, name: str) -> str:\n skill = self.skills.get(name)\n if not skill:\n return f\"Error: Unknown skill '{name}'.\"\n return (f\"<skill name=\\\"{name}\\\">\\n\"\n f\"{skill['body']}\\n</skill>\")\n```\n\n## 相对 s04 的变更\n\n| 组件 | 之前 (s04) | 之后 (s05) |\n|----------------|------------------|----------------------------|\n| Tools | 5 (基础 + task) | 5 (基础 + load_skill) |\n| 系统提示 | 静态字符串 | + 技能描述列表 |\n| 知识库 | 无 | .skills/*.md 文件 Line truncated
"content": "# s05: Skills (技能加载)\n\n> *\"Load on demand, not upfront\"* -- 通过 tool_result 按需注入知识, 而非塞满系统提示。\n\n## 问题\n\n你希望智能体遵循特定领域的工作流: git 约定、测试模式、代码审查清单。把所有内容塞进系统提示会浪费 token。10 个技能, 每个 2000 token = 20,000 token, 大部分与当前任务无关。\n\n## 解决方案\n\n```\nSystem prompt (Layer 1 -- always present):\n+--------------------------------------+\n| You are a coding agent. |\n| Skills available: |\n| - git: Git workflow helpers | ~100 tokens/skill\n| - test: Testing best practices |\n+--------------------------------------+\n\nWhen model calls load_skill(\"git\"):\n+--------------------------------------+\n| tool_result (Layer 2 -- on demand): |\n| <skill name=\"git\"> |\n| Full git workflow instructions... | ~2000 tokens\n| Step 1: ... |\n| </skill> |\n+--------------------------------------+\n```\n\n第一层: 系统提示中放技能名称 (低成本)。第二层: tool_result 中按需放完整内容。\n\n## 工作原理\n\n1. 技能文件以 Markdown 格式存放在 `.skills/`, 带 YAML frontmatter。\n\n```\n.skills/\n git.md # ---\\n description: Git workflow\\n ---\\n ...\n test.md # ---\\n description: Testing patterns\\n ---\\n ...\n```\n\n2. SkillLoader 解析 frontmatter, 分离元数据和正文。\n\n```python\nclass SkillLoader:\n def __init__(self, skills_dir: Path):\n self.skills = {}\n for f in sorted(skills_dir.glob(\"*.md\")):\n text = f.read_text()\n meta, body = self._parse_frontmatter(text)\n self.skills[f.stem] = {\"meta\": meta, \"body\": body}\n\n def get_descriptions(self) -> str:\n lines = []\n for name, skill in self.skills.items():\n desc = skill[\"meta\"].get(\"description\", \"\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\n\n def get_content(self, name: str) -> str:\n skill = self.skills.get(name)\n if not skill:\n return f\"Error: Unknown skill '{name}'.\"\n return f\"<skill name=\\\"{name}\\\">\\n{skill['body']}\\n</skill>\"\n```\n\n3. 第一层写入系统提示。第二层不过是 dispatch map 中的又一个工具。\n\n```python\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nSkills available:\n{SKILL_LOADER.get_descriptions()}\"\"\"\n\nTOOL_HANDLERS = {\n # ...base tools...\n \"load_skill\": lambda **kw: SKILL_LOADER.get_content(kw[\"name\"]),\n}\n```\n\n模型知道有哪些技能 (低成本), 在相关时按需加载 (高成本)。\n\n## 相对 s04 的变更\n\n| 组件 | 之前 (s04) | 之后 (s05) |\n|----------------|------------------|--------------------------------|\n| Tools | 5 (基础 + task) | 5 (基础 + load_skill) |\n| 系统提示 | 静态字符串 | + 技能描述列表 |\n| 知识库 | 无 | .skills/*.md 文件 |\n| 注入方式 | 无 | 两层 (系统提示 + result) |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s05_skill_loading.py\n```\n\n1. `What skills are available?`\n2. `Load the agent-builder skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n4. `Build an MCP server using the mcp-builder skill`\n"
},
{
"version": "s06",
"locale": "zh",
"title": "s06: Compact (上下文压缩)",
"content": "# s06: Compact (上下文压缩)\n\n> 三层压缩管道让智能体可以无限期工作: 策略性地遗忘旧的工具结果, token 超过阈值时自动摘要, 以及支持手动触发压缩。\n\n## 问题\n\n上下文窗口是有限的。工具调用积累到足够多时, 消息数组会超过模型的上下文限制, API 调用直接失败。即使在到达硬限制之前, 性能也会下降: 模型变慢、准确率降低, 开始忽略早期消息。\n\n200,000 token 的上下文窗口听起来很大, 但一次 `read_file` 读取 1000 行源文件就消耗约 4000 token。读取 30 个文件、运行 20 条 bash 命令后, 你就已经用掉 100,000+ token 了。没有某种压缩机制, 智能体无法在大型代码库上工作。\n\n三层管道以递增的激进程度来应对这个问题:\n第一层 (micro-compact) 每轮静默替换旧的工具结果。\n第二层 (auto-compact) 在 token 超过阈值时触发完整摘要。\n第三层 (manual compact) 让模型自己触发压缩。\n\n教学简化说明: 这里的 token 估算使用粗略的 字符数/4 启发式方法。生产系统使用专业的 tokenizer 库进行精确计数。\n\n## 解决方案\n\n```\nEvery turn:\n+------------------+\n| Tool call result |\n+------------------+\n |\n v\n[Layer 1: micro_compact] (silent, every turn)\n Replace tool_result > 3 turns old\n with \"[Previous: used {tool_name}]\"\n |\n v\n[Check: tokens > 50000?]\n | |\n no yes\n | |\n v v\ncontinue [Layer 2: auto_compact]\n Save transcript to .transcripts/\n LLM summarizes conversation.\n Replace all messages with [summary].\n |\n v\n [Layer 3: compact tool]\n Model calls compact explicitly.\n Same summarization as auto_compact.\n```\n\n## 工作原理\n\n1. **第一层 -- micro_compact**: 每次 LLM 调用前, 找到最近 3 条之前的所有 tool_result 条目, 替换其内容。\n\n```python\ndef micro_compact(messages: list) -> list:\n tool_results = []\n for i, msg in enumerate(messages):\n if msg[\"role\"] == \"user\" and isinstance(msg.get(\"content\"), list):\n for j, part in enumerate(msg[\"content\"]):\n if isinstance(part, dict) and part.get(\"type\") == \"tool_result\":\n tool_results.append((i, j, part))\n if len(tool_results) <= KEEP_RECENT:\n return messages\n to_clear = tool_results[:-KEEP_RECENT]\n for _, _, part in to_clear:\n if len(part.get(\"content\", \"\")) > 100:\n tool_id = part.get(\"tool_use_id\", \"\")\n tool_name = tool_name_map.get(tool_id, \"unknown\")\n part[\"content\"] = f\"[Previous: used {tool_name}]\"\n return messages\n```\n\n2. **第二层 -- auto_compact**: 当估算 token 超过 50,000 时, 保存完整对话记录并请求 LLM 进行摘要。\n\n```python\ndef auto_compact(messages: list) -> list:\n TRANSCRIPT_DIR.mkdir(exist_ok=True)\n transcript_path = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with open(transcript_path, \"w\") as f:\n for msg in messages:\n f.write(json.dumps(msg, default=str) + \"\\n\")\n response = client.messages.create(\n model=MODEL,\n messages=[{\"role\": \"user\", \"content\":\n \"Summarize this conversation for continuity...\"\n + json.dumps(messages, default=str)[:80000]}],\n max_tokens=2000,\n )\n summary = response.content[0].text\n return [\n {\"role\": \"user\", \"content\": f\"[Compressed]\\n\\n{summary}\"},\n {\"role\": \"assistant\", \"content\": \"Understood. Continuing.\"},\n ]\n```\n\n3. **第三层 -- manual compact**: `compact` 工具按需触发相同的摘要机制。\n\n```python\nif manual_compact:\n messages[:] = auto_compact(messages)\n```\n\n4. Agent loop 整合了全部三层。\n\n```python\ndef agent_loop(messages: list):\n while True:\n micro_compact(messages)\n if estimate_tokens(messages) > THRESHOLD:\n messages[:] = auto_compact(messages)\n response = client.messages.create(...)\n # ... tool execution ...\n if manual_compact:\n messages[:] = auto_compact(messages)\n```\n\n## 核心代码\n\n三层管道 (来自 `agents/s06_context_compact.py`, 第 67-93 行和第 189-223 行):\n\n```python\nTHRESHOLD = 50000\nKEEP_RECENT = 3\n\ndef micro_compact(messages):\n # Replace old tool results with placeholders\n ...\n\ndef auto_compact(messages):\n # Save transcript, LLM summarize, replace messages\n ...\n\ndef agent_loop(messages):\n while True:\n micro_compact(messages) # Layer 1\n if estimate_tokens(messages) > THRESHOLD:\n messages[:] = auto_compact(messages) # Layer 2\n response = client.Line truncated
"title": "s06: Context Compact (上下文压缩)",
"content": "# s06: Context Compact (上下文压缩)\n\n> *\"Strategic forgetting\"* -- 策略性遗忘, 让会话可以无限延续。\n\n## 问题\n\n上下文窗口是有限的。一次 `read_file` 读 1000 行就消耗约 4000 token。读 30 个文件、跑 20 条 bash 命令, 就超过 100,000 token 了。没有压缩机制, 智能体无法处理大型代码库。\n\n## 解决方案\n\n三层压缩, 激进程度递增:\n\n```\nEvery turn:\n+------------------+\n| Tool call result |\n+------------------+\n |\n v\n[Layer 1: micro_compact] (silent, every turn)\n Replace tool_result > 3 turns old\n with \"[Previous: used {tool_name}]\"\n |\n v\n[Check: tokens > 50000?]\n | |\n no yes\n | |\n v v\ncontinue [Layer 2: auto_compact]\n Save transcript to .transcripts/\n LLM summarizes conversation.\n Replace all messages with [summary].\n |\n v\n [Layer 3: compact tool]\n Model calls compact explicitly.\n Same summarization as auto_compact.\n```\n\n## 工作原理\n\n1. **第一层 -- micro_compact**: 每次 LLM 调用前, 将旧的 tool result 替换为占位符。\n\n```python\ndef micro_compact(messages: list) -> list:\n tool_results = []\n for i, msg in enumerate(messages):\n if msg[\"role\"] == \"user\" and isinstance(msg.get(\"content\"), list):\n for j, part in enumerate(msg[\"content\"]):\n if isinstance(part, dict) and part.get(\"type\") == \"tool_result\":\n tool_results.append((i, j, part))\n if len(tool_results) <= KEEP_RECENT:\n return messages\n for _, _, part in tool_results[:-KEEP_RECENT]:\n if len(part.get(\"content\", \"\")) > 100:\n part[\"content\"] = f\"[Previous: used {tool_name}]\"\n return messages\n```\n\n2. **第二层 -- auto_compact**: token 超过阈值时, 保存完整对话到磁盘, 让 LLM 做摘要。\n\n```python\ndef auto_compact(messages: list) -> list:\n # Save transcript for recovery\n transcript_path = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with open(transcript_path, \"w\") as f:\n for msg in messages:\n f.write(json.dumps(msg, default=str) + \"\\n\")\n # LLM summarizes\n response = client.messages.create(\n model=MODEL,\n messages=[{\"role\": \"user\", \"content\":\n \"Summarize this conversation for continuity...\"\n + json.dumps(messages, default=str)[:80000]}],\n max_tokens=2000,\n )\n return [\n {\"role\": \"user\", \"content\": f\"[Compressed]\\n\\n{response.content[0].text}\"},\n {\"role\": \"assistant\", \"content\": \"Understood. Continuing.\"},\n ]\n```\n\n3. **第三层 -- manual compact**: `compact` 工具按需触发同样的摘要机制。\n\n4. 循环整合三层:\n\n```python\ndef agent_loop(messages: list):\n while True:\n micro_compact(messages) # Layer 1\n if estimate_tokens(messages) > THRESHOLD:\n messages[:] = auto_compact(messages) # Layer 2\n response = client.messages.create(...)\n # ... tool execution ...\n if manual_compact:\n messages[:] = auto_compact(messages) # Layer 3\n```\n\n完整历史通过 transcript 保存在磁盘上。不是真的丢了, 只是移出了活跃上下文。\n\n## 相对 s05 的变更\n\n| 组件 | 之前 (s05) | 之后 (s06) |\n|----------------|------------------|--------------------------------|\n| Tools | 5 | 5 (基础 + compact) |\n| 上下文管理 | 无 | 三层压缩 |\n| Micro-compact | 无 | 旧结果 -> 占位符 |\n| Auto-compact | 无 | token 阈值触发 |\n| Transcripts | 无 | 保存到 .transcripts/ |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s06_context_compact.py\n```\n\n1. `Read every Python file in the agents/ directory one by one` (观察 micro-compact 替换旧结果)\n2. `Keep reading files until compression triggers automatically`\n3. `Use the compact tool to manually compress the conversation`\n"
},
{
"version": "s07",
"locale": "zh",
"title": "s07: Tasks (任务系统)",
"content": "# s07: Tasks (任务系统)\n\n> 任务以 JSON 文件形式持久化在文件系统上, 带有依赖图, 因此它们能在上下文压缩后存活, 也可以跨智能体共享。\n\n## 问题\n\n内存中的状态 (如 s03 的 TodoManager) 在上下文压缩 (s06) 时会丢失。auto_compact 用摘要替换消息后, 待办列表就没了。智能体只能从摘要文本中重建它, 这是有损且容易出错的。\n\n这就是 s06 到 s07 的关键桥梁: TodoManager 的条目随压缩消亡; 基于文件的任务不会。将状态移到文件系统上使其不受压缩影响。\n\n更根本地说, 内存中的状态对其他智能体不可见。当我们最终构建团队 (s09+) 时, 队友需要一个共享的任务看板。内存中的数据结构是进程局部的。\n\n解决方案是将任务作为 JSON 文件持久化在 `.tasks/` 目录中。每个任务是一个单独的文件, 包含 ID、主题、状态和依赖图。完成任务 1 会自动解除任务 2 的阻塞 (如果任务 2 有 `blockedBy: [1]`)。在本教学实现里, 文件系统是任务状态的真实来源。\n\n## Task vs Todo: 何时用哪个\n\n从 s07 起, Task 是默认主线。Todo 仍可用于短期线性清单。\n\n## 快速判定矩阵\n\n| 场景 | 优先选择 | 原因 |\n|---|---|---|\n| 短时、单会话、线性清单 | Todo | 心智负担最低,记录最快 |\n| 跨会话、存在依赖、多人协作 | Task | 状态可持久、依赖可表达、协作可见 |\n| 一时拿不准 | Task | 后续降级更容易,半途迁移成本更低 |\n\n## 解决方案\n\n```\n.tasks/\n task_1.json {\"id\":1, \"status\":\"completed\", ...}\n task_2.json {\"id\":2, \"blockedBy\":[1], \"status\":\"pending\"}\n task_3.json {\"id\":3, \"blockedBy\":[2], \"status\":\"pending\"}\n\nDependency resolution:\n+----------+ +----------+ +----------+\n| task 1 | --> | task 2 | --> | task 3 |\n| complete | | blocked | | blocked |\n+----------+ +----------+ +----------+\n | ^\n +--- completing task 1 removes it from\n task 2's blockedBy list\n```\n\n## 工作原理\n\n1. TaskManager 提供 CRUD 操作。每个任务是一个 JSON 文件。\n\n```python\nclass TaskManager:\n def create(self, subject: str, description: str = \"\") -> str:\n task = {\n \"id\": self._next_id,\n \"subject\": subject,\n \"description\": description,\n \"status\": \"pending\",\n \"blockedBy\": [],\n \"blocks\": [],\n \"owner\": \"\",\n }\n self._save(task)\n self._next_id += 1\n return json.dumps(task, indent=2)\n```\n\n2. 当任务标记为 completed 时, `_clear_dependency` 将其 ID 从所有其他任务的 `blockedBy` 列表中移除。\n\n```python\ndef _clear_dependency(self, completed_id: int):\n for f in self.dir.glob(\"task_*.json\"):\n task = json.loads(f.read_text())\n if completed_id in task.get(\"blockedBy\", []):\n task[\"blockedBy\"].remove(completed_id)\n self._save(task)\n```\n\n3. `update` 方法处理状态变更和双向依赖关联。\n\n```python\ndef update(self, task_id, status=None,\n add_blocked_by=None, add_blocks=None):\n task = self._load(task_id)\n if status:\n task[\"status\"] = status\n if status == \"completed\":\n self._clear_dependency(task_id)\n if add_blocks:\n task[\"blocks\"] = list(set(task[\"blocks\"] + add_blocks))\n for blocked_id in add_blocks:\n blocked = self._load(blocked_id)\n if task_id not in blocked[\"blockedBy\"]:\n blocked[\"blockedBy\"].append(task_id)\n self._save(blocked)\n self._save(task)\n```\n\n4. 四个任务工具添加到 dispatch map。\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"task_create\": lambda **kw: TASKS.create(kw[\"subject\"]),\n \"task_update\": lambda **kw: TASKS.update(kw[\"task_id\"],\n kw.get(\"status\")),\n \"task_list\": lambda **kw: TASKS.list_all(),\n \"task_get\": lambda **kw: TASKS.get(kw[\"task_id\"]),\n}\n```\n\n## 核心代码\n\n带依赖图的 TaskManager (来自 `agents/s07_task_system.py`, 第 46-123 行):\n\n```python\nclass TaskManager:\n def __init__(self, tasks_dir: Path):\n self.dir = tasks_dir\n self.dir.mkdir(exist_ok=True)\n self._next_id = self._max_id() + 1\n\n def _load(self, task_id: int) -> dict:\n path = self.dir / f\"task_{task_id}.json\"\n return json.loads(path.read_text())\n\n def _save(self, task: dict):\n path = self.dir / f\"task_{task['id']}.json\"\n path.write_text(json.dumps(task, indent=2))\n\n def create(self, subject, description=\"\"):\n task = {\"id\": self._next_id, \"subject\": subject,\n \"status\": \"pending\", \"blockedBy\": [],\n \"blocks\Line truncated
"content": "# s07: Tasks (任务系统)\n\n> *\"State survives /compact\"* -- 基于文件的状态能扛住上下文压缩。\n\n## 问题\n\n内存中的状态 (s03 的 TodoManager) 在上下文压缩 (s06) 时会丢失。auto_compact 用摘要替换消息后, 待办列表就没了。智能体只能从摘要文本重建 -- 有损且容易出错。\n\n基于文件的任务解决了这个问题: 状态写入磁盘, 就能扛住压缩、进程重启, 以及后续的多智能体共享 (s09+)。\n\n## 解决方案\n\n```\n.tasks/\n task_1.json {\"id\":1, \"status\":\"completed\", ...}\n task_2.json {\"id\":2, \"blockedBy\":[1], \"status\":\"pending\"}\n task_3.json {\"id\":3, \"blockedBy\":[2], \"status\":\"pending\"}\n\nDependency resolution:\n+----------+ +----------+ +----------+\n| task 1 | --> | task 2 | --> | task 3 |\n| complete | | blocked | | blocked |\n+----------+ +----------+ +----------+\n | ^\n +--- completing task 1 removes it from\n task 2's blockedBy list\n```\n\n## 工作原理\n\n1. TaskManager: 每个任务一个 JSON 文件, CRUD + 依赖图。\n\n```python\nclass TaskManager:\n def __init__(self, tasks_dir: Path):\n self.dir = tasks_dir\n self.dir.mkdir(exist_ok=True)\n self._next_id = self._max_id() + 1\n\n def create(self, subject, description=\"\"):\n task = {\"id\": self._next_id, \"subject\": subject,\n \"status\": \"pending\", \"blockedBy\": [],\n \"blocks\": [], \"owner\": \"\"}\n self._save(task)\n self._next_id += 1\n return json.dumps(task, indent=2)\n```\n\n2. 完成任务时, 自动将其 ID 从其他任务的 `blockedBy` 中移除。\n\n```python\ndef _clear_dependency(self, completed_id):\n for f in self.dir.glob(\"task_*.json\"):\n task = json.loads(f.read_text())\n if completed_id in task.get(\"blockedBy\", []):\n task[\"blockedBy\"].remove(completed_id)\n self._save(task)\n```\n\n3. `update` 处理状态变更和依赖关联。\n\n```python\ndef update(self, task_id, status=None,\n add_blocked_by=None, add_blocks=None):\n task = self._load(task_id)\n if status:\n task[\"status\"] = status\n if status == \"completed\":\n self._clear_dependency(task_id)\n self._save(task)\n```\n\n4. 四个任务工具加入 dispatch map。\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"task_create\": lambda **kw: TASKS.create(kw[\"subject\"]),\n \"task_update\": lambda **kw: TASKS.update(kw[\"task_id\"], kw.get(\"status\")),\n \"task_list\": lambda **kw: TASKS.list_all(),\n \"task_get\": lambda **kw: TASKS.get(kw[\"task_id\"]),\n}\n```\n\n从 s07 起, Task 是多步工作的默认选择。Todo 仍可用于快速清单。\n\n## 相对 s06 的变更\n\n| 组件 | 之前 (s06) | 之后 (s07) |\n|---|---|---|\n| Tools | 5 | 8 (`task_create/update/list/get`) |\n| 状态存储 | 仅内存 | `.tasks/` 中的 JSON 文件 |\n| 依赖关系 | 无 | `blockedBy + blocks` 图 |\n| 持久化 | 压缩后丢失 | 压缩后存活 |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s07_task_system.py\n```\n\n1. `Create 3 tasks: \"Setup project\", \"Write code\", \"Write tests\". Make them depend on each other in order.`\n2. `List all tasks and show the dependency graph`\n3. `Complete task 1 and then list tasks to see task 2 unblocked`\n4. `Create a task board for refactoring: parse -> transform -> emit -> test`\n"
},
{
"version": "s08",
"locale": "zh",
"title": "s08: Background Tasks (后台任务)",
"content": "# s08: Background Tasks (后台任务)\n\n> BackgroundManager 在独立线程中运行命令, 在每次 LLM 调用前排空通知队列, 使智能体永远不会因长时间运行的操作而阻塞。\n\n## 问题\n\n有些命令需要几分钟: `npm install`、`pytest`、`docker build`。在阻塞式的 agent loop 中, 模型只能干等子进程结束, 什么也做不了。如果用户要求 \"安装依赖, 同时创建配置文件\", 智能体会先安装, 然后才创建配置 -- 串行执行, 而非并行。\n\n智能体需要并发能力。不是将 agent loop 本身完全多线程化, 而是能够发起一个长时间命令然后继续工作。当命令完成时, 结果自然地出现在对话中。\n\n解决方案是一个 BackgroundManager, 它在守护线程中运行命令, 将结果收集到通知队列中。每次 LLM 调用前, 队列被排空, 结果注入到消息中。\n\n## 解决方案\n\n```\nMain thread Background thread\n+-----------------+ +-----------------+\n| agent loop | | task executes |\n| ... | | ... |\n| [LLM call] <---+------- | enqueue(result) |\n| ^drain queue | +-----------------+\n+-----------------+\n\nTimeline:\nAgent --[spawn A]--[spawn B]--[other work]----\n | |\n v v\n [A runs] [B runs] (parallel)\n | |\n +-- notification queue --+\n |\n [results injected before\n next LLM call]\n```\n\n## 工作原理\n\n1. BackgroundManager 追踪任务并维护一个线程安全的通知队列。\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self._notification_queue = []\n self._lock = threading.Lock()\n```\n\n2. `run()` 启动一个守护线程并立即返回 task_id。\n\n```python\ndef run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n self.tasks[task_id] = {\n \"status\": \"running\",\n \"result\": None,\n \"command\": command,\n }\n thread = threading.Thread(\n target=self._execute,\n args=(task_id, command),\n daemon=True,\n )\n thread.start()\n return f\"Background task {task_id} started\"\n```\n\n3. 线程目标函数 `_execute` 运行子进程并将结果推入通知队列。\n\n```python\ndef _execute(self, task_id: str, command: str):\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=300)\n output = (r.stdout + r.stderr).strip()[:50000]\n status = \"completed\"\n except subprocess.TimeoutExpired:\n output = \"Error: Timeout (300s)\"\n status = \"timeout\"\n self.tasks[task_id][\"status\"] = status\n self.tasks[task_id][\"result\"] = output\n with self._lock:\n self._notification_queue.append({\n \"task_id\": task_id,\n \"status\": status,\n \"result\": output[:500],\n })\n```\n\n4. `drain_notifications()` 返回并清空待处理的结果。\n\n```python\ndef drain_notifications(self) -> list:\n with self._lock:\n notifs = list(self._notification_queue)\n self._notification_queue.clear()\n return notifs\n```\n\n5. Agent loop 在每次 LLM 调用前排空通知。\n\n```python\ndef agent_loop(messages: list):\n while True:\n notifs = BG.drain_notifications()\n if notifs and messages:\n notif_text = \"\\n\".join(\n f\"[bg:{n['task_id']}] {n['status']}: \"\n f\"{n['result']}\" for n in notifs\n )\n messages.append({\"role\": \"user\",\n \"content\": f\"<background-results>\"\n f\"\\n{notif_text}\\n\"\n f\"</background-results>\"})\n messages.append({\"role\": \"assistant\",\n \"content\": \"Noted background results.\"})\n response = client.messages.create(...)\n```\n\n## 核心代码\n\nBackgroundManager (来自 `agents/s08_background_tasks.py`, 第 49-107 行):\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self._notification_queue = []\n self._lock = threading.Lock()\n\n def run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n self.tasks[task_id] = {\"status\": \"running\",\n \"result\": None,\n \"command\": command}\n thread = threading.Thread(\n target=self._execute,\n args=(task_id, command), daemon=True)\n thread.start()\n return f\"Background task {task_id} started\"\n\n def _execute(self, task_id, command):\n # run subprocess, push to queue\n ...\n\n def drain_notifications(self) -> list:\n Line truncated
"content": "# s08: Background Tasks (后台任务)\n\n> *\"Fire and forget\"* -- 非阻塞线程 + 通知队列。\n\n## 问题\n\n有些命令需要几分钟: `npm install`、`pytest`、`docker build`。阻塞式循环下, 模型只能干等。用户说 \"装依赖, 同时创建配置文件\", 智能体却只能串行处理, 无法并行。\n\n## 解决方案\n\n```\nMain thread Background thread\n+-----------------+ +-----------------+\n| agent loop | | subprocess runs |\n| ... | | ... |\n| [LLM call] <---+------- | enqueue(result) |\n| ^drain queue | +-----------------+\n+-----------------+\n\nTimeline:\nAgent --[spawn A]--[spawn B]--[other work]----\n | |\n v v\n [A runs] [B runs] (parallel)\n | |\n +-- results injected before next LLM call --+\n```\n\n## 工作原理\n\n1. BackgroundManager 用线程安全的通知队列追踪任务。\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self._notification_queue = []\n self._lock = threading.Lock()\n```\n\n2. `run()` 启动守护线程, 立即返回。\n\n```python\ndef run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n self.tasks[task_id] = {\"status\": \"running\", \"command\": command}\n thread = threading.Thread(\n target=self._execute, args=(task_id, command), daemon=True)\n thread.start()\n return f\"Background task {task_id} started\"\n```\n\n3. 子进程完成后, 结果进入通知队列。\n\n```python\ndef _execute(self, task_id, command):\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=300)\n output = (r.stdout + r.stderr).strip()[:50000]\n except subprocess.TimeoutExpired:\n output = \"Error: Timeout (300s)\"\n with self._lock:\n self._notification_queue.append({\n \"task_id\": task_id, \"result\": output[:500]})\n```\n\n4. 每次 LLM 调用前排空通知队列。\n\n```python\ndef agent_loop(messages: list):\n while True:\n notifs = BG.drain_notifications()\n if notifs:\n notif_text = \"\\n\".join(\n f\"[bg:{n['task_id']}] {n['result']}\" for n in notifs)\n messages.append({\"role\": \"user\",\n \"content\": f\"<background-results>\\n{notif_text}\\n\"\n f\"</background-results>\"})\n messages.append({\"role\": \"assistant\",\n \"content\": \"Noted background results.\"})\n response = client.messages.create(...)\n```\n\n循环保持单线程。只有子进程 I/O 被并行化。\n\n## 相对 s07 的变更\n\n| 组件 | 之前 (s07) | 之后 (s08) |\n|----------------|------------------|------------------------------------|\n| Tools | 8 | 6 (基础 + background_run + check) |\n| 执行方式 | 仅阻塞 | 阻塞 + 后台线程 |\n| 通知机制 | 无 | 每轮排空的队列 |\n| 并发 | 无 | 守护线程 |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s08_background_tasks.py\n```\n\n1. `Run \"sleep 5 && echo done\" in the background, then create a file while it runs`\n2. `Start 3 background tasks: \"sleep 2\", \"sleep 4\", \"sleep 6\". Check their status.`\n3. `Run pytest in the background and keep working on other things`\n"
},
{
"version": "s09",
"locale": "zh",
"title": "s09: Agent Teams (智能体团队)",
"content": "# s09: Agent Teams (智能体团队)\n\n> 持久化的队友通过 JSONL 收件箱提供了一种教学协议, 将孤立的智能体转变为可通信的团队 -- spawn、message、broadcast 和 drain。\n\n## 问题\n\n子智能体 (s04) 是一次性的: 生成、工作、返回摘要、消亡。它们没有身份, 没有跨调用的记忆, 也无法接收后续指令。后台任务 (s08) 运行 shell 命令, 但不能做 LLM 引导的决策或交流发现。\n\n真正的团队协作需要三样东西: (1) 存活时间超过单次 prompt 的持久化智能体, (2) 身份和生命周期管理, (3) 智能体之间的通信通道。没有消息机制, 即使持久化的队友也是又聋又哑的 -- 它们可以并行工作但永远无法协调。\n\n解决方案将 TeammateManager (用于生成持久化的命名智能体) 与使用 JSONL 收件箱文件的 MessageBus 结合。每个队友在独立线程中运行自己的 agent loop, 每次 LLM 调用前检查收件箱, 可以向任何其他队友或领导发送消息。\n\n关于 s06 到 s07 的桥梁: s03 的 TodoManager 条目随压缩 (s06) 消亡。基于文件的任务 (s07) 因为存储在磁盘上而能存活压缩。团队建立在同样的原则上 -- config.json 和收件箱文件持久化在上下文窗口之外。\n\n## 解决方案\n\n```\nTeammate lifecycle:\n spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN\n\nCommunication:\n .team/\n config.json <- team roster + statuses\n inbox/\n alice.jsonl <- append-only, drain-on-read\n bob.jsonl\n lead.jsonl\n\n +--------+ send(\"alice\",\"bob\",\"...\") +--------+\n | alice | -----------------------------> | bob |\n | loop | bob.jsonl << {json_line} | loop |\n +--------+ +--------+\n ^ |\n | BUS.read_inbox(\"alice\") |\n +---- alice.jsonl -> read + drain ---------+\n\n5 message types:\n+-------------------------+------------------------------+\n| message | Normal text between agents |\n| broadcast | Sent to all teammates |\n| shutdown_request | Request graceful shutdown |\n| shutdown_response | Approve/reject shutdown |\n| plan_approval_response | Approve/reject plan |\n+-------------------------+------------------------------+\n```\n\n## 工作原理\n\n1. TeammateManager 通过 config.json 维护团队名册。每个成员有名称、角色和状态。\n\n```python\nclass TeammateManager:\n def __init__(self, team_dir: Path):\n self.dir = team_dir\n self.dir.mkdir(exist_ok=True)\n self.config_path = self.dir / \"config.json\"\n self.config = self._load_config()\n self.threads = {}\n```\n\n2. `spawn()` 创建队友并在线程中启动其 agent loop。重新 spawn 一个 idle 状态的队友会将其重新激活。\n\n```python\ndef spawn(self, name: str, role: str, prompt: str) -> str:\n member = self._find_member(name)\n if member:\n if member[\"status\"] not in (\"idle\", \"shutdown\"):\n return f\"Error: '{name}' is currently {member['status']}\"\n member[\"status\"] = \"working\"\n else:\n member = {\"name\": name, \"role\": role, \"status\": \"working\"}\n self.config[\"members\"].append(member)\n self._save_config()\n thread = threading.Thread(\n target=self._teammate_loop,\n args=(name, role, prompt), daemon=True)\n self.threads[name] = thread\n thread.start()\n return f\"Spawned teammate '{name}' (role: {role})\"\n```\n\n3. MessageBus 处理 JSONL 收件箱文件。`send()` 追加一行 JSON; `read_inbox()` 读取所有行并清空文件。\n\n```python\nclass MessageBus:\n def send(self, sender, to, content,\n msg_type=\"message\", extra=None):\n msg = {\"type\": msg_type, \"from\": sender,\n \"content\": content,\n \"timestamp\": time.time()}\n if extra:\n msg.update(extra)\n with open(self.dir / f\"{to}.jsonl\", \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n return f\"Sent {msg_type} to {to}\"\n\n def read_inbox(self, name):\n path = self.dir / f\"{name}.jsonl\"\n if not path.exists():\n return \"[]\"\n msgs = [json.loads(l)\n for l in path.read_text().strip().splitlines()\n if l]\n path.write_text(\"\") # drain\n return json.dumps(msgs, indent=2)\n```\n\n4. 每个队友在每次 LLM 调用前检查收件箱, 将收到的消息注入对话上下文。\n\n```python\ndef _teammate_loop(self, name, role, prompt):\n sys_prompt = f\"You are '{name}', role: {role}, at {WORKDIR}.\"\n messages = [{\"role\": \"user\", \"content\": prompt}]\n for Line truncated
"content": "# s09: Agent Teams (智能体团队)\n\n> *\"Append to send, drain to read\"* -- 异步邮箱让持久化队友能互相通信。\n\n## 问题\n\n子智能体 (s04) 是一次性的: 生成、工作、返回摘要、消亡。没有身份, 没有跨调用的记忆。后台任务 (s08) 能跑 shell 命令, 但无法做 LLM 引导的决策。\n\n真正的团队协作需要: (1) 存活时间超过单次 prompt 的持久化智能体, (2) 身份和生命周期管理, (3) 智能体间的通信通道。\n\n## 解决方案\n\n```\nTeammate lifecycle:\n spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN\n\nCommunication:\n .team/\n config.json <- team roster + statuses\n inbox/\n alice.jsonl <- append-only, drain-on-read\n bob.jsonl\n lead.jsonl\n\n +--------+ send(\"alice\",\"bob\",\"...\") +--------+\n | alice | -----------------------------> | bob |\n | loop | bob.jsonl << {json_line} | loop |\n +--------+ +--------+\n ^ |\n | BUS.read_inbox(\"alice\") |\n +---- alice.jsonl -> read + drain ---------+\n```\n\n## 工作原理\n\n1. TeammateManager 通过 config.json 维护团队名册。\n\n```python\nclass TeammateManager:\n def __init__(self, team_dir: Path):\n self.dir = team_dir\n self.dir.mkdir(exist_ok=True)\n self.config_path = self.dir / \"config.json\"\n self.config = self._load_config()\n self.threads = {}\n```\n\n2. `spawn()` 创建队友并在线程中启动 agent loop。\n\n```python\ndef spawn(self, name: str, role: str, prompt: str) -> str:\n member = {\"name\": name, \"role\": role, \"status\": \"working\"}\n self.config[\"members\"].append(member)\n self._save_config()\n thread = threading.Thread(\n target=self._teammate_loop,\n args=(name, role, prompt), daemon=True)\n thread.start()\n return f\"Spawned teammate '{name}' (role: {role})\"\n```\n\n3. MessageBus: append-only 的 JSONL 收件箱。`send()` 追加一行; `read_inbox()` 读取全部并清空。\n\n```python\nclass MessageBus:\n def send(self, sender, to, content, msg_type=\"message\", extra=None):\n msg = {\"type\": msg_type, \"from\": sender,\n \"content\": content, \"timestamp\": time.time()}\n if extra:\n msg.update(extra)\n with open(self.dir / f\"{to}.jsonl\", \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n\n def read_inbox(self, name):\n path = self.dir / f\"{name}.jsonl\"\n if not path.exists(): return \"[]\"\n msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]\n path.write_text(\"\") # drain\n return json.dumps(msgs, indent=2)\n```\n\n4. 每个队友在每次 LLM 调用前检查收件箱, 将消息注入上下文。\n\n```python\ndef _teammate_loop(self, name, role, prompt):\n messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(50):\n inbox = BUS.read_inbox(name)\n if inbox != \"[]\":\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n messages.append({\"role\": \"assistant\",\n \"content\": \"Noted inbox messages.\"})\n response = client.messages.create(...)\n if response.stop_reason != \"tool_use\":\n break\n # execute tools, append results...\n self._find_member(name)[\"status\"] = \"idle\"\n```\n\n## 相对 s08 的变更\n\n| 组件 | 之前 (s08) | 之后 (s09) |\n|----------------|------------------|------------------------------------|\n| Tools | 6 | 9 (+spawn/send/read_inbox) |\n| 智能体数量 | 单一 | 领导 + N 个队友 |\n| 持久化 | 无 | config.json + JSONL 收件箱 |\n| 线程 | 后台命令 | 每线程完整 agent loop |\n| 生命周期 | 一次性 | idle -> working -> idle |\n| 通信 | 无 | message + broadcast |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s09_agent_teams.py\n```\n\n1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.`\n2. `Broadcast \"status update: phase 1 complete\" to all teammates`\n3. `Check the lead inbox for any messages`\n4. 输入 `/team` 查看团队名册和状态\n5. 输入 `/inbox` 手动检查领导的收件箱\n"
},
{
"version": "s10",
"locale": "zh",
"title": "s10: Team Protocols (团队协议)",
"content": "# s10: Team Protocols (团队协议)\n\n> 同一个 request_id 握手模式驱动了关机和计划审批两种协议 -- 一个 FSM, 两种应用。\n\n## 问题\n\n在 s09 中, 队友可以工作和通信, 但没有结构化的协调。出现了两个问题:\n\n**关机**: 如何干净地停止一个队友? 直接杀线程会留下写了一半的文件和错误状态的 config.json。优雅关机需要握手: 领导发起请求, 队友决定是批准 (完成并退出) 还是拒绝 (继续工作)。\n\n**计划审批**: 如何控制执行门槛? 当领导说 \"重构认证模块\", 队友会立即开始。对于高风险变更, 领导应该在执行开始前审查计划。初级提出方案, 高级批准。\n\n两个问题共享相同的结构: 一方发送带唯一 ID 的请求, 另一方引用该 ID 作出响应。一个有限状态机 (FSM) 跟踪每个请求经历 pending -> approved | rejected 的状态变迁。\n\n## 解决方案\n\n```\nShutdown Protocol Plan Approval Protocol\n================== ======================\n\nLead Teammate Teammate Lead\n | | | |\n |--shutdown_req-->| |--plan_req------>|\n | {req_id:\"abc\"} | | {req_id:\"xyz\"} |\n | | | |\n |<--shutdown_resp-| |<--plan_resp-----|\n | {req_id:\"abc\", | | {req_id:\"xyz\", |\n | approve:true} | | approve:true} |\n | | | |\n v v v v\ntracker[\"abc\"] exits proceeds tracker[\"xyz\"]\n = approved = approved\n\nShared FSM (identical for both protocols):\n [pending] --approve--> [approved]\n [pending] --reject---> [rejected]\n\nTrackers:\n shutdown_requests = {req_id: {target, status}}\n plan_requests = {req_id: {from, plan, status}}\n```\n\n## 工作原理\n\n1. 领导通过生成 request_id 并通过收件箱发送 shutdown_request 来发起关机。\n\n```python\nshutdown_requests = {}\n\ndef handle_shutdown_request(teammate: str) -> str:\n req_id = str(uuid.uuid4())[:8]\n shutdown_requests[req_id] = {\n \"target\": teammate, \"status\": \"pending\",\n }\n BUS.send(\"lead\", teammate, \"Please shut down gracefully.\",\n \"shutdown_request\", {\"request_id\": req_id})\n return f\"Shutdown request {req_id} sent (status: pending)\"\n```\n\n2. 队友在收件箱中收到请求, 调用 `shutdown_response` 工具来批准或拒绝。\n\n```python\nif tool_name == \"shutdown_response\":\n req_id = args[\"request_id\"]\n approve = args[\"approve\"]\n if req_id in shutdown_requests:\n shutdown_requests[req_id][\"status\"] = \\\n \"approved\" if approve else \"rejected\"\n BUS.send(sender, \"lead\", args.get(\"reason\", \"\"),\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": approve})\n return f\"Shutdown {'approved' if approve else 'rejected'}\"\n```\n\n3. 队友的循环检查是否批准了关机并退出。\n\n```python\nif (block.name == \"shutdown_response\"\n and block.input.get(\"approve\")):\n should_exit = True\n# ...\nmember[\"status\"] = \"shutdown\" if should_exit else \"idle\"\n```\n\n4. 计划审批遵循完全相同的模式。队友提交计划时生成一个 request_id。\n\n```python\nplan_requests = {}\n\nif tool_name == \"plan_approval\":\n plan_text = args.get(\"plan\", \"\")\n req_id = str(uuid.uuid4())[:8]\n plan_requests[req_id] = {\n \"from\": sender, \"plan\": plan_text,\n \"status\": \"pending\",\n }\n BUS.send(sender, \"lead\", plan_text,\n \"plan_approval_request\",\n {\"request_id\": req_id, \"plan\": plan_text})\n return f\"Plan submitted (request_id={req_id})\"\n```\n\n5. 领导审查后使用同一个 request_id 作出响应。\n\n```python\ndef handle_plan_review(request_id, approve, feedback=\"\"):\n req = plan_requests.get(request_id)\n if not req:\n return f\"Error: Unknown request_id '{request_id}'\"\n req[\"status\"] = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", req[\"from\"], feedback,\n \"plan_approval_response\",\n {\"request_id\": request_id,\n \"approve\": approve,\n \"feedback\": feedback})\n return f\"Plan {req['status']} for '{req['from']}'\"\n```\n\n6. 两个协议使用同一个 `plan_approval` 工具名, 有两种模式: 队友提交 (无 request_id), 领导审查 (带 request_id)。\n\n```python\n# Lead tool dispatch:\n\"plan_approval\": lambda **kw: handle_plan_review(\n kw[\"request_id\"], kw[\"approve\"],\n kw.get(\"feedback\", \"\")),\n# Teammate: submit mode (generate request_id)\n```\n\n## 核心代码\n\n双协议处理器 (来自 `agents/s10_team_protocols.py`):\n\n```pythoLine truncated
"content": "# s10: Team Protocols (团队协议)\n\n> *\"Same request_id, two protocols\"* -- 一个 FSM 模式驱动关机和计划审批两种协议。\n\n## 问题\n\ns09 中, 队友能工作和通信, 但缺少结构化的协调:\n\n**关机**: 直接杀线程会留下写了一半的文件和过期的 config.json。需要握手: 领导发起请求, 队友批准 (完成并退出) 或拒绝 (继续工作)。\n\n**计划审批**: 领导说 \"重构认证模块\", 队友立刻开干。对于高风险变更, 领导应该先审查计划。\n\n两者共享同一个结构: 一方发送带唯一 ID 的请求, 另一方引用该 ID 作出响应。\n\n## 解决方案\n\n```\nShutdown Protocol Plan Approval Protocol\n================== ======================\n\nLead Teammate Teammate Lead\n | | | |\n |--shutdown_req-->| |--plan_req------>|\n | {req_id:\"abc\"} | | {req_id:\"xyz\"} |\n | | | |\n |<--shutdown_resp-| |<--plan_resp-----|\n | {req_id:\"abc\", | | {req_id:\"xyz\", |\n | approve:true} | | approve:true} |\n\nShared FSM:\n [pending] --approve--> [approved]\n [pending] --reject---> [rejected]\n\nTrackers:\n shutdown_requests = {req_id: {target, status}}\n plan_requests = {req_id: {from, plan, status}}\n```\n\n## 工作原理\n\n1. 领导生成 request_id, 通过收件箱发起关机请求。\n\n```python\nshutdown_requests = {}\n\ndef handle_shutdown_request(teammate: str) -> str:\n req_id = str(uuid.uuid4())[:8]\n shutdown_requests[req_id] = {\"target\": teammate, \"status\": \"pending\"}\n BUS.send(\"lead\", teammate, \"Please shut down gracefully.\",\n \"shutdown_request\", {\"request_id\": req_id})\n return f\"Shutdown request {req_id} sent (status: pending)\"\n```\n\n2. 队友收到请求后, 用 approve/reject 响应。\n\n```python\nif tool_name == \"shutdown_response\":\n req_id = args[\"request_id\"]\n approve = args[\"approve\"]\n shutdown_requests[req_id][\"status\"] = \"approved\" if approve else \"rejected\"\n BUS.send(sender, \"lead\", args.get(\"reason\", \"\"),\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": approve})\n```\n\n3. 计划审批遵循完全相同的模式。队友提交计划 (生成 request_id), 领导审查 (引用同一个 request_id)。\n\n```python\nplan_requests = {}\n\ndef handle_plan_review(request_id, approve, feedback=\"\"):\n req = plan_requests[request_id]\n req[\"status\"] = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", req[\"from\"], feedback,\n \"plan_approval_response\",\n {\"request_id\": request_id, \"approve\": approve})\n```\n\n一个 FSM, 两种应用。同样的 `pending -> approved | rejected` 状态机适用于任何请求-响应协议。\n\n## 相对 s09 的变更\n\n| 组件 | 之前 (s09) | 之后 (s10) |\n|----------------|------------------|--------------------------------------|\n| Tools | 9 | 12 (+shutdown_req/resp +plan) |\n| 关机 | 仅自然退出 | 请求-响应握手 |\n| 计划门控 | 无 | 提交/审查与审批 |\n| 关联 | 无 | 每个请求一个 request_id |\n| FSM | 无 | pending -> approved/rejected |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s10_team_protocols.py\n```\n\n1. `Spawn alice as a coder. Then request her shutdown.`\n2. `List teammates to see alice's status after shutdown approval`\n3. `Spawn bob with a risky refactoring task. Review and reject his plan.`\n4. `Spawn charlie, have him submit a plan, then approve it.`\n5. 输入 `/team` 监控状态\n"
},
{
"version": "s11",
"locale": "zh",
"title": "s11: Autonomous Agents (自治智能体)",
"content": "# s11: Autonomous Agents (自治智能体)\n\n> 带任务看板轮询的空闲循环让队友能自己发现和认领工作, 上下文压缩后通过身份重注入保持角色认知。\n\n## 问题\n\n在 s09-s10 中, 队友只在被明确指示时才工作。领导必须用特定的 prompt 生成每个队友。如果任务看板上有 10 个未认领的任务, 领导必须手动分配每一个。这无法扩展。\n\n真正的自治意味着队友自己寻找工作。当一个队友完成当前任务后, 它应该扫描任务看板寻找未认领的工作, 认领一个任务, 然后开始工作 -- 不需要领导的任何指令。\n\n但自治智能体面临一个微妙问题: 上下文压缩后, 智能体可能忘记自己是谁。如果消息被摘要化, 原始系统提示中的身份 (\"你是 alice, 角色: coder\") 就会淡化。身份重注入通过在压缩后的上下文开头插入身份块来解决这个问题。\n\n注: token 估算使用字符数/4 (粗略)。nag 阈值 3 轮是为教学可见性设的低值。\n\n## 解决方案\n\n```\nTeammate lifecycle with idle cycle:\n\n+-------+\n| spawn |\n+---+---+\n |\n v\n+-------+ tool_use +-------+\n| WORK | <------------- | LLM |\n+---+---+ +-------+\n |\n | stop_reason != tool_use\n | (or idle tool called)\n v\n+--------+\n| IDLE | poll every 5s for up to 60s\n+---+----+\n |\n +---> check inbox --> message? ----------> WORK\n |\n +---> scan .tasks/ --> unclaimed? -------> claim -> WORK\n |\n +---> 60s timeout ----------------------> SHUTDOWN\n\nIdentity re-injection after compression:\n if len(messages) <= 3:\n messages.insert(0, identity_block)\n \"You are 'alice', role: coder, team: my-team\"\n```\n\n## 工作原理\n\n1. 队友循环有两个阶段: WORK 和 IDLE。WORK 阶段运行标准的 agent loop。当 LLM 停止调用工具 (或调用了 `idle` 工具) 时, 队友进入 IDLE 阶段。\n\n```python\ndef _loop(self, name, role, prompt):\n while True:\n # -- WORK PHASE --\n messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(50):\n inbox = BUS.read_inbox(name)\n for msg in inbox:\n if msg.get(\"type\") == \"shutdown_request\":\n self._set_status(name, \"shutdown\")\n return\n messages.append(...)\n response = client.messages.create(...)\n if response.stop_reason != \"tool_use\":\n break\n # execute tools...\n if idle_requested:\n break\n\n # -- IDLE PHASE --\n self._set_status(name, \"idle\")\n resume = self._idle_poll(name, messages)\n if not resume:\n self._set_status(name, \"shutdown\")\n return\n self._set_status(name, \"working\")\n```\n\n2. 空闲阶段循环轮询收件箱和任务看板。\n\n```python\ndef _idle_poll(self, name, messages):\n polls = IDLE_TIMEOUT // POLL_INTERVAL # 60s / 5s = 12\n for _ in range(polls):\n time.sleep(POLL_INTERVAL)\n # Check inbox for new messages\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n return True\n # Scan task board for unclaimed tasks\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n task = unclaimed[0]\n claim_task(task[\"id\"], name)\n messages.append({\"role\": \"user\",\n \"content\": f\"<auto-claimed>Task #{task['id']}: \"\n f\"{task['subject']}</auto-claimed>\"})\n return True\n return False # timeout -> shutdown\n```\n\n3. 任务看板扫描查找 pending 状态、无 owner、未被阻塞的任务。\n\n```python\ndef scan_unclaimed_tasks() -> list:\n TASKS_DIR.mkdir(exist_ok=True)\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 not task.get(\"blockedBy\")):\n unclaimed.append(task)\n return unclaimed\n\ndef claim_task(task_id: int, owner: str):\n path = TASKS_DIR / f\"task_{task_id}.json\"\n task = json.loads(path.read_text())\n task[\"status\"] = \"in_progress\"\n task[\"owner\"] = owner\n path.write_text(json.dumps(task, indent=2))\n```\n\n4. 身份重注入: 当上下文过短时插入身份块, 表明发生了压缩。\n\n```python\ndef make_identity_block(name, role, team_name):\n return {\"role\": \"user\",\n \"content\": f\"<identity>You are '{name}', \"\n f\"role: {role}, team: {team_name}. \"\n f\"Continue your work.</identity>\"}\n\n# Before resuming work after idle:\nif len(messages) <= 3:\Line truncated
"content": "# s11: Autonomous Agents (自治智能体)\n\n> *\"Poll, claim, work, repeat\"* -- 无需协调者, 智能体自组织。\n\n## 问题\n\ns09-s10 中, 队友只在被明确指派时才工作。领导必须为每个队友指定 prompt。任务看板上有 10 个未认领的任务, 领导得手动分配每一个。无法扩展。\n\n真正的自治意味着队友自己找活干: 扫描任务看板, 认领未分配的任务, 完成后继续找。\n\n一个细节: 上下文压缩 (s06) 后, 智能体可能忘记自己是谁。身份重注入解决这个问题。\n\n## 解决方案\n\n```\nTeammate lifecycle with idle cycle:\n\n+-------+\n| spawn |\n+---+---+\n |\n v\n+-------+ tool_use +-------+\n| WORK | <------------- | LLM |\n+---+---+ +-------+\n |\n | stop_reason != tool_use (or idle tool called)\n v\n+--------+\n| IDLE | poll every 5s for up to 60s\n+---+----+\n |\n +---> check inbox --> message? ----------> WORK\n |\n +---> scan .tasks/ --> unclaimed? -------> claim -> WORK\n |\n +---> 60s timeout ----------------------> SHUTDOWN\n\nIdentity re-injection after compression:\n if len(messages) <= 3:\n messages.insert(0, identity_block)\n```\n\n## 工作原理\n\n1. 队友循环分两个阶段: WORK 和 IDLE。LLM 停止调用工具 (或调用了 `idle`) 时, 进入 IDLE。\n\n```python\ndef _loop(self, name, role, prompt):\n while True:\n # -- WORK PHASE --\n messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(50):\n response = client.messages.create(...)\n if response.stop_reason != \"tool_use\":\n break\n # execute tools...\n if idle_requested:\n break\n\n # -- IDLE PHASE --\n self._set_status(name, \"idle\")\n resume = self._idle_poll(name, messages)\n if not resume:\n self._set_status(name, \"shutdown\")\n return\n self._set_status(name, \"working\")\n```\n\n2. 空闲阶段循环轮询收件箱和任务看板。\n\n```python\ndef _idle_poll(self, name, messages):\n for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12\n time.sleep(POLL_INTERVAL)\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n return True\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n claim_task(unclaimed[0][\"id\"], name)\n messages.append({\"role\": \"user\",\n \"content\": f\"<auto-claimed>Task #{unclaimed[0]['id']}: \"\n f\"{unclaimed[0]['subject']}</auto-claimed>\"})\n return True\n return False # timeout -> shutdown\n```\n\n3. 任务看板扫描: 找 pending 状态、无 owner、未被阻塞的任务。\n\n```python\ndef scan_unclaimed_tasks() -> list:\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 not task.get(\"blockedBy\")):\n unclaimed.append(task)\n return unclaimed\n```\n\n4. 身份重注入: 上下文过短 (说明发生了压缩) 时, 在开头插入身份块。\n\n```python\nif len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"<identity>You are '{name}', role: {role}, \"\n f\"team: {team_name}. Continue your work.</identity>\"})\n messages.insert(1, {\"role\": \"assistant\",\n \"content\": f\"I am {name}. Continuing.\"})\n```\n\n## 相对 s10 的变更\n\n| 组件 | 之前 (s10) | 之后 (s11) |\n|----------------|------------------|----------------------------------|\n| Tools | 12 | 14 (+idle, +claim_task) |\n| 自治性 | 领导指派 | 自组织 |\n| 空闲阶段 | 无 | 轮询收件箱 + 任务看板 |\n| 任务认领 | 仅手动 | 自动认领未分配任务 |\n| 身份 | 系统提示 | + 压缩后重注入 |\n| 超时 | 无 | 60 秒空闲 -> 自动关机 |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s11_autonomous_agents.py\n```\n\n1. `Create 3 tasks on the board, then spawn alice and bob. Watch them auto-claim.`\n2. `Spawn a coder teammate and let it find work from the task board itself`\n3. `Create tasks with dependencies. Watch teammates respect the blocked order.`\n4. 输入 `/tasks` 查看带 owner 的任务看板\n5. 输入 `/team` 监控谁在工作、谁在空闲\n"
},
{
"version": "s12",
"locale": "zh",
"title": "s12: Worktree + 任务隔离",
"content": "# s12: Worktree + 任务隔离\n\n> 目录隔离, 任务 ID 协调 -- 用\"任务板 (控制面) + worktree (执行面)\"把并行改动从互相污染变成可追踪、可恢复、可收尾。\n\n## 问题\n\ns11 时, agent 已经能认领任务并协同推进。但所有任务共享同一个工作目录。两个 agent 同时改同一棵文件树时, 未提交的变更互相干扰, 任务状态和实际改动对不上, 收尾时也无法判断该保留还是清理哪些文件。\n\n考虑一个具体场景: agent A 在做 auth 重构, agent B 在做登录页。两者都修改了 `config.py`。A 的半成品改动被 B 的 `git status` 看到, B 以为是自己的遗留, 尝试提交 -- 结果两个任务都坏了。\n\n根因是\"做什么\"和\"在哪里做\"没有分开。任务板管目标, 但执行上下文是共享的。解决方案: 给每个任务分配独立的 git worktree 目录, 用任务 ID 把两边关联起来。\n\n## 解决方案\n\n```\n控制面 (.tasks/) 执行面 (.worktrees/)\n+------------------+ +------------------------+\n| task_1.json | | auth-refactor/ |\n| status: in_progress <----> branch: wt/auth-refactor\n| worktree: \"auth-refactor\" | task_id: 1 |\n+------------------+ +------------------------+\n| task_2.json | | ui-login/ |\n| status: pending <----> branch: wt/ui-login\n| worktree: \"ui-login\" | task_id: 2 |\n+------------------+ +------------------------+\n |\n index.json (worktree registry)\n events.jsonl (lifecycle log)\n```\n\n三层状态:\n1. 控制面 (What): `.tasks/task_*.json` -- 任务目标、责任归属、完成状态\n2. 执行面 (Where): `.worktrees/index.json` -- 隔离目录路径、分支、存活状态\n3. 运行态 (Now): 单轮内存上下文 -- 当前任务、当前 worktree、工具结果\n\n状态机:\n```text\nTask: pending -> in_progress -> completed\nWorktree: absent -> active -> removed | kept\n```\n\n## 工作原理\n\n1. 创建任务, 把目标写入任务板。\n\n```python\nTASKS.create(\"Implement auth refactor\")\n# -> .tasks/task_1.json status=pending worktree=\"\"\n```\n\n2. 创建 worktree 并绑定任务。传入 `task_id` 时自动把任务推进到 `in_progress`。\n\n```python\nWORKTREES.create(\"auth-refactor\", task_id=1)\n# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD\n# -> index.json 追加 entry, task_1.json 绑定 worktree=\"auth-refactor\"\n```\n\n3. 在隔离目录中执行命令。`cwd` 指向 worktree 路径, 主目录不受影响。\n\n```python\nWORKTREES.run(\"auth-refactor\", \"git status --short\")\n# -> subprocess.run(command, cwd=\".worktrees/auth-refactor\", ...)\n```\n\n4. 观测和回写。`worktree_status` 查看 git 状态, `task_update` 维护进度。\n\n```python\nWORKTREES.status(\"auth-refactor\") # git status inside worktree\nTASKS.update(1, owner=\"agent-A\") # update task metadata\n```\n\n5. 收尾: 选择 keep 或 remove。`remove` 配合 `complete_task=true` 会同时完成任务并解绑 worktree。\n\n```python\nWORKTREES.remove(\"auth-refactor\", complete_task=True)\n# -> git worktree remove\n# -> task_1.json status=completed, worktree=\"\"\n# -> index.json status=removed\n# -> events.jsonl 写入 task.completed + worktree.remove.after\n```\n\n6. 进程中断后, 从 `.tasks/` + `.worktrees/index.json` 重建现场。会话记忆是易失的, 磁盘状态是持久的。\n\n## 核心代码\n\n事件流 -- append-only 生命周期日志 (来自 `agents/s12_worktree_task_isolation.py`):\n\n```python\nclass EventBus:\n def emit(self, event, task=None, worktree=None, error=None):\n payload = {\n \"event\": event,\n \"ts\": time.time(),\n \"task\": task or {},\n \"worktree\": worktree or {},\n }\n if error:\n payload[\"error\"] = error\n with self.path.open(\"a\", encoding=\"utf-8\") as f:\n f.write(json.dumps(payload) + \"\\n\")\n```\n\n事件流写入 `.worktrees/events.jsonl`, 每个关键操作发出三段式事件:\n- `worktree.create.before / after / failed`\n- `worktree.remove.before / after / failed`\n- `task.completed` (当 `complete_task=true` 成功时)\n\n事件负载形状:\n\n```json\n{\n \"event\": \"worktree.remove.after\",\n \"task\": {\"id\": 7, \"status\": \"completed\"},\n \"worktree\": {\"name\": \"auth-refactor\", \"path\": \"...\", \"status\": \"removed\"},\n \"ts\": 1730000000\n}\n```\n\n任务绑定 -- Task 侧持有 worktree 名称:\n\n```python\ndef bind_worktree(self, task_id: int, worktree: str, owner: str = \"\") -> str:\n task = self._load(task_id)\n task[\"worktree\"] = worktree\n if task[\"status\"] == \"pending\":\n task[\"status\"] = \"in_progress\"\n self._save(task)\n```\n\n隔离执行 -- cwd 路由Line truncated
"title": "s12: Worktree + Task Isolation (Worktree 任务隔离)",
"content": "# s12: Worktree + Task Isolation (Worktree 任务隔离)\n\n> *\"Isolate by directory, coordinate by task ID\"* -- 任务板 + 可选的 worktree 执行通道。\n\n## 问题\n\n到 s11, 智能体已经能自主认领和完成任务。但所有任务共享一个工作目录。两个智能体同时重构不同模块时会冲突: A 编辑 `config.py`, B 也编辑 `config.py`, 未提交的改动互相污染, 谁也无法干净回滚。\n\n任务板管 \"做什么\" 但不管 \"在哪做\"。解决方案: 给每个任务分配独立的 git worktree 目录, 用任务 ID 关联两边。\n\n## 解决方案\n\n```\nControl plane (.tasks/) Execution plane (.worktrees/)\n+------------------+ +------------------------+\n| task_1.json | | auth-refactor/ |\n| status: in_progress <------> branch: wt/auth-refactor\n| worktree: \"auth-refactor\" | task_id: 1 |\n+------------------+ +------------------------+\n| task_2.json | | ui-login/ |\n| status: pending <------> branch: wt/ui-login\n| worktree: \"ui-login\" | task_id: 2 |\n+------------------+ +------------------------+\n |\n index.json (worktree registry)\n events.jsonl (lifecycle log)\n\nState machines:\n Task: pending -> in_progress -> completed\n Worktree: absent -> active -> removed | kept\n```\n\n## 工作原理\n\n1. **创建任务。** 先把目标持久化。\n\n```python\nTASKS.create(\"Implement auth refactor\")\n# -> .tasks/task_1.json status=pending worktree=\"\"\n```\n\n2. **创建 worktree 并绑定任务。** 传入 `task_id` 自动将任务推进到 `in_progress`。\n\n```python\nWORKTREES.create(\"auth-refactor\", task_id=1)\n# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD\n# -> index.json gets new entry, task_1.json gets worktree=\"auth-refactor\"\n```\n\n绑定同时写入两侧状态:\n\n```python\ndef bind_worktree(self, task_id, worktree):\n task = self._load(task_id)\n task[\"worktree\"] = worktree\n if task[\"status\"] == \"pending\":\n task[\"status\"] = \"in_progress\"\n self._save(task)\n```\n\n3. **在 worktree 中执行命令。** `cwd` 指向隔离目录。\n\n```python\nsubprocess.run(command, shell=True, cwd=worktree_path,\n capture_output=True, text=True, timeout=300)\n```\n\n4. **收尾。** 两种选择:\n - `worktree_keep(name)` -- 保留目录供后续使用。\n - `worktree_remove(name, complete_task=True)` -- 删除目录, 完成绑定任务, 发出事件。一个调用搞定拆除 + 完成。\n\n```python\ndef remove(self, name, force=False, complete_task=False):\n self._run_git([\"worktree\", \"remove\", wt[\"path\"]])\n if complete_task and wt.get(\"task_id\") is not None:\n self.tasks.update(wt[\"task_id\"], status=\"completed\")\n self.tasks.unbind_worktree(wt[\"task_id\"])\n self.events.emit(\"task.completed\", ...)\n```\n\n5. **事件流。** 每个生命周期步骤写入 `.worktrees/events.jsonl`:\n\n```json\n{\n \"event\": \"worktree.remove.after\",\n \"task\": {\"id\": 1, \"status\": \"completed\"},\n \"worktree\": {\"name\": \"auth-refactor\", \"status\": \"removed\"},\n \"ts\": 1730000000\n}\n```\n\n事件类型: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`。\n\n进程崩溃后, 从 `.tasks/` + `.worktrees/index.json` 重建现场。会话记忆是易失的, 磁盘状态是持久的。\n\n## 相对 s11 的变更\n\n| 组件 | 之前 (s11) | 之后 (s12) |\n|--------------------|----------------------------|----------------------------------------------|\n| 协调 | 任务板 (owner/status) | 任务板 + worktree 显式绑定 |\n| 执行范围 | 共享目录 | 每个任务独立目录 |\n| 可恢复性 | 仅任务状态 | 任务状态 + worktree 索引 |\n| 收尾 | 任务完成 | 任务完成 + 显式 keep/remove |\n| 生命周期可见性 | 隐式日志 | `.worktrees/events.jsonl` 显式事件流 |\n\n## 试一试\n\n```sh\ncd learn-claude-code\npython agents/s12_worktree_task_isolation.py\n```\n\n1. `Create tasks for backend auth and frontend login page, then list tasks.`\n2. `Create worktree \"auth-refactor\" for task 1, then bind task 2 to a new worktree \"ui-login\".`\n3. `Run \"git status --short\" in worktree \"auth-refactor\".`\n4. `Keep worktree \"ui-login\", then list worktrees and inspect events.`\n5. `Remove worktree \"auth-refactor\" with complete_task=true, then list tasks/worktrees/evenLine truncated
},
{
"version": "s01",
"locale": "ja",
"title": "s01: The Agent Loop",
"content": "# s01: The Agent Loop\n\n> AIコーディングエージェントの中核は、モデルが「終了」と判断するまでツール結果をモデルにフィードバックし続ける while ループにある。\n\n## 問題\n\nなぜ言語モデルは単体でコーディングの質問に答えられないのか。それはコーディングが「現実世界とのインタラクション」を必要とするからだ。モデルはファイルを読み、テストを実行し、エラーを確認し、反復する必要がある。一回のプロンプト-レスポンスのやり取りではこれは実現できない。\n\nagent loopがなければ、ユーザーが自分でモデルの出力をコピーペーストして戻す必要がある。つまりユーザー自身がループの役割を果たすことになる。agent loopはこれを自動化する: モデルを呼び出し、モデルが要求したツールを実行し、結果をフィードバックし、モデルが「完了」と言うまで繰り返す。\n\n単純なタスクを考えてみよう: 「helloと出力するPythonファイルを作成せよ」。モデルは(1)ファイルを書くことを決定し、(2)書き、(3)動作を検証する必要がある。最低でも3回のツール呼び出しが必要だ。ループがなければ、そのたびに手動の介入が必要になる。\n\n## 解決策\n\n```\n+----------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tool |\n| prompt | | | | execute |\n+----------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +---------------+\n (loop continues)\n\nThe loop terminates when stop_reason != \"tool_use\".\nThat single condition is the entire control flow.\n```\n\n## 仕組み\n\n1. ユーザーがプロンプトを入力する。これが最初のメッセージになる。\n\n```python\nhistory.append({\"role\": \"user\", \"content\": query})\n```\n\n2. メッセージ配列がツール定義と共にLLMに送信される。\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n3. アシスタントのレスポンスがメッセージに追加される。\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\n```\n\n4. stop reasonを確認する。モデルがツールを呼び出さなかった場合、ループは終了する。この最小実装では、これが唯一のループ終了条件だ。\n\n```python\nif response.stop_reason != \"tool_use\":\n return\n```\n\n5. レスポンス中の各tool_useブロックについて、ツール(このセッションではbash)を実行し、結果を収集する。\n\n```python\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\n6. 結果がuserメッセージとして追加され、ループが続行する。\n\n```python\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n## 主要コード\n\n最小限のエージェント -- パターン全体が30行未満\n(`agents/s01_agent_loop.py` 66-86行目):\n\n```python\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 if response.stop_reason != \"tool_use\":\n return\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## 変更点\n\nこれはセッション1 -- 出発点である。前のセッションは存在しない。\n\n| Component | Before | After |\n|---------------|------------|--------------------------------|\n| Agent loop | (none) | `while True` + stop_reason |\n| Tools | (none) | `bash` (one tool) |\n| Messages | (none) | Accumulating list |\n| Control flow | (none) | `stop_reason != \"tool_use\"` |\n\n## 設計原理\n\nこのループは LLM ベースエージェントの土台だ。本番実装ではエラーハンドリング、トークン計測、ストリーミング、リトライにLine truncated
"content": "# s01: The Agent Loop\n\n> *\"One loop & Bash is all you need\"* -- 1つのツール + 1つのループ = エージェント。\n\n## 問題\n\n言語モデルはコードについて推論できるが、現実世界に触れられない。ファイルを読めず、テストを実行できず、エラーを確認できない。ループがなければ、ツール呼び出しのたびにユーザーが手動で結果をコピーペーストする必要がある。つまりユーザー自身がループになる。\n\n## 解決策\n\n```\n+--------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tool |\n| prompt | | | | execute |\n+--------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +----------------+\n (loop until stop_reason != \"tool_use\")\n```\n\n1つの終了条件がフロー全体を制御する。モデルがツール呼び出しを止めるまでループが回り続ける。\n\n## 仕組み\n\n1. ユーザーのプロンプトが最初のメッセージになる。\n\n```python\nmessages.append({\"role\": \"user\", \"content\": query})\n```\n\n2. メッセージとツール定義をLLMに送信する。\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n3. アシスタントのレスポンスを追加し、`stop_reason`を確認する。ツールが呼ばれなければ終了。\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\nif response.stop_reason != \"tool_use\":\n return\n```\n\n4. 各ツール呼び出しを実行し、結果を収集してuserメッセージとして追加。ステップ2に戻る。\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 })\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n1つの関数にまとめると:\n\n```python\ndef agent_loop(query):\n messages = [{\"role\": \"user\", \"content\": query}]\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行未満に収まる。本コースの残りはすべてこのループの上に積み重なる -- ループ自体は変わらない。\n\n## 変更点\n\n| Component | Before | After |\n|---------------|------------|--------------------------------|\n| Agent loop | (none) | `while True` + stop_reason |\n| Tools | (none) | `bash` (one tool) |\n| Messages | (none) | Accumulating list |\n| Control flow | (none) | `stop_reason != \"tool_use\"` |\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s01_agent_loop.py\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?`\n4. `Create a directory called test_output and write 3 files in it`\n"
},
{
"version": "s02",
"locale": "ja",
"title": "s02: Tools",
"content": "# s02: Tools\n\n> ディスパッチマップがツール呼び出しをハンドラ関数にルーティングする -- ループ自体はまったく変更しない。\n\n## 問題\n\n`bash`だけでは、エージェントはすべてをシェル経由で行う: ファイルの読み取り、書き込み、編集。これは動くが脆弱だ。`cat`の出力は予期しないタイミングで切り詰められる。`sed`による置換は特殊文字で失敗する。直接的な関数呼び出しの方がシンプルなのに、モデルはシェルパイプラインの構築にトークンを浪費する。\n\nさらに重要なのは、bashがセキュリティ上の攻撃面であること。bashの呼び出しはシェルでできることなら何でもできてしまう。`read_file`や`write_file`のような専用ツールがあれば、モデルが危険な操作を避けることを期待するのではなく、ツールレベルでパスのサンドボックス化や危険なパターンのブロックを強制できる。\n\n重要な洞察は、ツールを追加してもループを変更する必要がないということだ。s01のループはそのまま同一で維持される。ツール配列にエントリを追加し、ハンドラ関数を追加し、ディスパッチマップで接続するだけだ。\n\n## 解決策\n\n```\n+----------+ +-------+ +------------------+\n| User | ---> | LLM | ---> | Tool Dispatch |\n| prompt | | | | { |\n+----------+ +---+---+ | bash: run_bash |\n ^ | read: run_read |\n | | write: run_wr |\n +----------+ edit: run_edit |\n tool_result| } |\n +------------------+\n\nThe dispatch map is a dict: {tool_name: handler_function}\nOne lookup replaces any if/elif chain.\n```\n\n## 仕組み\n\n1. 各ツールのハンドラ関数を定義する。各関数はツールのinput_schemaに対応するキーワード引数を受け取り、文字列の結果を返す。\n\n```python\ndef run_read(path: str, limit: int = None) -> str:\n text = safe_path(path).read_text()\n lines = text.splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit]\n return \"\\n\".join(lines)[:50000]\n```\n\n2. ツール名とハンドラを結びつけるディスパッチマップを作成する。\n\n```python\nTOOL_HANDLERS = {\n \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n \"read_file\": lambda **kw: run_read(kw[\"path\"], kw.get(\"limit\")),\n \"write_file\": lambda **kw: run_write(kw[\"path\"], kw[\"content\"]),\n \"edit_file\": lambda **kw: run_edit(kw[\"path\"], kw[\"old_text\"],\n kw[\"new_text\"]),\n}\n```\n\n3. agent loop内で、ハードコードの代わりに名前でハンドラをルックアップする。\n\n```python\nfor block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input)\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n4. パスのサンドボックス化により、モデルがワークスペースの外に出ることを防ぐ。\n\n```python\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\n## 主要コード\n\nディスパッチパターン(`agents/s02_tool_use.py` 93-129行目):\n\n```python\nTOOL_HANDLERS = {\n \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n \"read_file\": lambda **kw: run_read(kw[\"path\"], kw.get(\"limit\")),\n \"write_file\": lambda **kw: run_write(kw[\"path\"], kw[\"content\"]),\n \"edit_file\": lambda **kw: run_edit(kw[\"path\"], kw[\"old_text\"],\n kw[\"new_text\"]),\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 if response.stop_reason != \"tool_use\":\n return\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler \\\n else f\"Unknown tool: {block.name}\"\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n Line truncated
"title": "s02: Tool Use",
"content": "# s02: Tool Use\n\n> *\"The loop didn't change\"* -- ツール追加はハンドラ追加であり、ループの書き換えではない。\n\n## 問題\n\n`bash`だけでは、エージェントは何でもシェル経由で行う。`cat`は予測不能に切り詰め、`sed`は特殊文字で壊れ、すべてのbash呼び出しが制約のないセキュリティ面になる。`read_file`や`write_file`のような専用ツールなら、ツールレベルでパスのサンドボックス化を強制できる。\n\n重要な点: ツールを追加してもループの変更は不要。\n\n## 解決策\n\n```\n+--------+ +-------+ +------------------+\n| User | ---> | LLM | ---> | Tool Dispatch |\n| prompt | | | | { |\n+--------+ +---+---+ | bash: run_bash |\n ^ | read: run_read |\n | | write: run_wr |\n +-----------+ edit: run_edit |\n tool_result | } |\n +------------------+\n\nThe dispatch map is a dict: {tool_name: handler_function}.\nOne lookup replaces any if/elif chain.\n```\n\n## 仕組み\n\n1. 各ツールにハンドラ関数を定義する。パスのサンドボックス化でワークスペース外への脱出を防ぐ。\n\n```python\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\ndef run_read(path: str, limit: int = None) -> str:\n text = safe_path(path).read_text()\n lines = text.splitlines()\n if limit and limit < len(lines):\n lines = lines[:limit]\n return \"\\n\".join(lines)[:50000]\n```\n\n2. ディスパッチマップがツール名とハンドラを結びつける。\n\n```python\nTOOL_HANDLERS = {\n \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n \"read_file\": lambda **kw: run_read(kw[\"path\"], kw.get(\"limit\")),\n \"write_file\": lambda **kw: run_write(kw[\"path\"], kw[\"content\"]),\n \"edit_file\": lambda **kw: run_edit(kw[\"path\"], kw[\"old_text\"],\n kw[\"new_text\"]),\n}\n```\n\n3. ループ内で名前によりハンドラをルックアップする。ループ本体はs01から不変。\n\n```python\nfor block in response.content:\n if block.type == \"tool_use\":\n handler = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input) if handler \\\n else f\"Unknown tool: {block.name}\"\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\nツール追加 = ハンドラ追加 + スキーマ追加。ループは決して変わらない。\n\n## s01からの変更点\n\n| Component | Before (s01) | After (s02) |\n|----------------|--------------------|----------------------------|\n| Tools | 1 (bash only) | 4 (bash, read, write, edit)|\n| Dispatch | Hardcoded bash call | `TOOL_HANDLERS` dict |\n| Path safety | None | `safe_path()` sandbox |\n| Agent loop | Unchanged | Unchanged |\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s02_tool_use.py\n```\n\n1. `Read the file requirements.txt`\n2. `Create a file called greet.py with a greet(name) function`\n3. `Edit greet.py to add a docstring to the function`\n4. `Read greet.py to verify the edit worked`\n"
},
{
"version": "s03",
"locale": "ja",
"title": "s03: TodoWrite",
"content": "# s03: TodoWrite\n\n> TodoManagerによりエージェントが自身の進捗を追跡でき、nagリマインダーの注入により更新を忘れた場合に強制的に更新させる。\n\n## 問題\n\nエージェントがマルチステップのタスクに取り組むとき、何を完了し何が残っているかを見失うことが多い。明示的な計画がなければ、モデルは作業を繰り返したり、ステップを飛ばしたり、脱線したりする可能性がある。ユーザーにはエージェントの内部計画が見えない。\n\nこれは見た目以上に深刻だ。長い会話ではモデルが「ドリフト」する -- コンテキストウィンドウがツール結果で埋まるにつれ、システムプロンプトの影響力が薄れていく。10ステップのリファクタリングタスクでステップ1-3を完了した後、モデルはステップ4-10の存在を忘れて即興で行動し始めるかもしれない。\n\n解決策は構造化された状態管理だ: モデルが明示的に書き込むTodoManager。モデルは計画を作成し、作業中のアイテムをin_progressとしてマークし、完了時にcompletedとマークする。nagリマインダーは、モデルが3ラウンド以上todoを更新しなかった場合にナッジを注入する。\n\n注: nag 閾値 3 ラウンドは可視化のために低く設定。本番ではより高い値に調整される。s07 以降は永続的なマルチステップ作業に Task ボードを使用。TodoWrite は軽量チェックリストとして引き続き利用可能。\n\n## 解決策\n\n```\n+----------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tools |\n| prompt | | | | + todo |\n+----------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +---------------+\n |\n +-----------+-----------+\n | TodoManager state |\n | [ ] task A |\n | [>] task B <- doing |\n | [x] task C |\n +-----------------------+\n |\n if rounds_since_todo >= 3:\n inject <reminder> into tool_result\n```\n\n## 仕組み\n\n1. TodoManagerはアイテムのリストをバリデーションして保持する。`in_progress`にできるのは一度に1つだけ。\n\n```python\nclass TodoManager:\n def __init__(self):\n self.items = []\n\n def update(self, items: list) -> str:\n validated = []\n in_progress_count = 0\n for item in items:\n status = item.get(\"status\", \"pending\")\n if status == \"in_progress\":\n in_progress_count += 1\n validated.append({\n \"id\": item[\"id\"],\n \"text\": item[\"text\"],\n \"status\": status,\n })\n if in_progress_count > 1:\n raise ValueError(\"Only one task can be in_progress\")\n self.items = validated\n return self.render()\n```\n\n2. `todo`ツールは他のツールと同様にディスパッチマップに追加される。\n\n```python\nTOOL_HANDLERS = {\n \"bash\": lambda **kw: run_bash(kw[\"command\"]),\n # ...other tools...\n \"todo\": lambda **kw: TODO.update(kw[\"items\"]),\n}\n```\n\n3. nagリマインダーは、モデルが3ラウンド以上`todo`を呼び出さなかった場合にtool_resultメッセージに`<reminder>`タグを注入する。\n\n```python\ndef agent_loop(messages: list):\n rounds_since_todo = 0\n while True:\n if rounds_since_todo >= 3 and messages:\n last = messages[-1]\n if (last[\"role\"] == \"user\"\n and isinstance(last.get(\"content\"), list)):\n last[\"content\"].insert(0, {\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n # ... rest of loop ...\n rounds_since_todo = 0 if used_todo else rounds_since_todo + 1\n```\n\n4. システムプロンプトがモデルにtodoによる計画を指示する。\n\n```python\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nUse the todo tool to plan multi-step tasks.\nMark in_progress before starting, completed when done.\nPrefer tools over prose.\"\"\"\n```\n\n## 主要コード\n\nTodoManagerとnag注入(`agents/s03_todo_write.py` 51-85行目および158-187行目):\n\n```python\nclass TodoManager:\n def update(self, items: list) -> str:\n validated = []\n in_progress_count = 0\n for item in items:\n status = item.get(\"status\", \"pending\")\n if status == \"in_progress\":\n in_progress_count += 1\n validLine truncated
"content": "# s03: TodoWrite\n\n> *\"Plan before you act\"* -- 可視化された計画がタスク完了率を向上させる。\n\n## 問題\n\nマルチステップのタスクで、モデルは途中で迷子になる。作業を繰り返したり、ステップを飛ばしたり、脱線したりする。長い会話になるほど悪化する -- ツール結果がコンテキストを埋めるにつれ、システムプロンプトの影響力が薄れる。10ステップのリファクタリングでステップ1-3を完了した後、残りを忘れて即興を始めてしまう。\n\n## 解決策\n\n```\n+--------+ +-------+ +---------+\n| User | ---> | LLM | ---> | Tools |\n| prompt | | | | + todo |\n+--------+ +---+---+ +----+----+\n ^ |\n | tool_result |\n +----------------+\n |\n +-----------+-----------+\n | TodoManager state |\n | [ ] task A |\n | [>] task B <- doing |\n | [x] task C |\n +-----------------------+\n |\n if rounds_since_todo >= 3:\n inject <reminder> into tool_result\n```\n\n## 仕組み\n\n1. TodoManagerはアイテムのリストをステータス付きで保持する。`in_progress`にできるのは同時に1つだけ。\n\n```python\nclass TodoManager:\n def update(self, items: list) -> str:\n validated, in_progress_count = [], 0\n for item in items:\n status = item.get(\"status\", \"pending\")\n if status == \"in_progress\":\n in_progress_count += 1\n validated.append({\"id\": item[\"id\"], \"text\": item[\"text\"],\n \"status\": status})\n if in_progress_count > 1:\n raise ValueError(\"Only one task can be in_progress\")\n self.items = validated\n return self.render()\n```\n\n2. `todo`ツールは他のツールと同様にディスパッチマップに追加される。\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"todo\": lambda **kw: TODO.update(kw[\"items\"]),\n}\n```\n\n3. nagリマインダーが、モデルが3ラウンド以上`todo`を呼ばなかった場合にナッジを注入する。\n\n```python\nif rounds_since_todo >= 3 and messages:\n last = messages[-1]\n if last[\"role\"] == \"user\" and isinstance(last.get(\"content\"), list):\n last[\"content\"].insert(0, {\n \"type\": \"text\",\n \"text\": \"<reminder>Update your todos.</reminder>\",\n })\n```\n\n「一度にin_progressは1つだけ」の制約が逐次的な集中を強制し、nagリマインダーが説明責任を生む。\n\n## s02からの変更点\n\n| Component | Before (s02) | After (s03) |\n|----------------|------------------|----------------------------|\n| Tools | 4 | 5 (+todo) |\n| Planning | None | TodoManager with statuses |\n| Nag injection | None | `<reminder>` after 3 rounds|\n| Agent loop | Simple dispatch | + rounds_since_todo counter|\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s03_todo_write.py\n```\n\n1. `Refactor the file hello.py: add type hints, docstrings, and a main guard`\n2. `Create a Python package with __init__.py, utils.py, and tests/test_utils.py`\n3. `Review all Python files and fix any style issues`\n"
},
{
"version": "s04",
"locale": "ja",
"title": "s04: Subagents",
"content": "# s04: Subagents\n\n> サブエージェントは新しいメッセージリストで実行され、親とファイルシステムを共有し、要約のみを返す -- 親のコンテキストをクリーンに保つ。\n\n## 問題\n\nエージェントが作業するにつれ、メッセージ配列は膨張する。すべてのツール呼び出し、ファイル読み取り、bash出力が蓄積されていく。20-30回のツール呼び出しの後、コンテキストウィンドウは無関係な履歴で溢れる。ちょっとした質問に答えるために500行のファイルを読むと、永久に500行がコンテキストに追加される。\n\nこれは探索的タスクで特に深刻だ。「このプロジェクトはどのテストフレームワークを使っているか」という質問には5つのファイルを読む必要があるかもしれないが、親エージェントには5つのファイルの内容すべては不要だ -- 「pytest with conftest.py configuration」という回答だけが必要なのだ。\n\nこのコースでの実用的な解決策は fresh `messages[]` 分離だ: `messages=[]`で子エージェントを生成する。子は探索し、ファイルを読み、コマンドを実行する。終了時には最終的なテキストレスポンスだけが親に返される。子のメッセージ履歴全体は破棄される。\n\n## 解決策\n\n```\nParent agent Subagent\n+------------------+ +------------------+\n| messages=[...] | | messages=[] | <-- fresh\n| | dispatch | |\n| tool: task | ---------->| while tool_use: |\n| prompt=\"...\" | | call tools |\n| | summary | append results |\n| result = \"...\" | <--------- | return last text |\n+------------------+ +------------------+\n |\nParent context stays clean.\nSubagent context is discarded.\n```\n\n## 仕組み\n\n1. 親エージェントにサブエージェント生成をトリガーする`task`ツールが追加される。子は`task`を除くすべての基本ツールを取得する(再帰的な生成は不可)。\n\n```python\nPARENT_TOOLS = CHILD_TOOLS + [\n {\"name\": \"task\",\n \"description\": \"Spawn a subagent with fresh context.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"prompt\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n },\n \"required\": [\"prompt\"],\n }},\n]\n```\n\n2. サブエージェントは委譲されたプロンプトのみを含む新しいメッセージリストで開始する。ファイルシステムは共有される。\n\n```python\ndef run_subagent(prompt: str) -> str:\n sub_messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUBAGENT_SYSTEM,\n messages=sub_messages,\n tools=CHILD_TOOLS, max_tokens=8000,\n )\n sub_messages.append({\n \"role\": \"assistant\", \"content\": response.content\n })\n if response.stop_reason != \"tool_use\":\n break\n # execute tools, append results...\n```\n\n3. 最終テキストのみが親に返される。子の30回以上のツール呼び出し履歴は破棄される。\n\n```python\n return \"\".join(\n b.text for b in response.content if hasattr(b, \"text\")\n ) or \"(no summary)\"\n```\n\n4. 親はこの要約を通常のtool_resultとして受け取る。\n\n```python\nif block.name == \"task\":\n output = run_subagent(block.input[\"prompt\"])\nresults.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output),\n})\n```\n\n## 主要コード\n\nサブエージェント関数(`agents/s04_subagent.py` 110-128行目):\n\n```python\ndef run_subagent(prompt: str) -> str:\n sub_messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(30):\n response = client.messages.create(\n model=MODEL, system=SUBAGENT_SYSTEM,\n messages=sub_messages,\n tools=CHILD_TOOLS, max_tokens=8000,\n )\n sub_messages.append({\"role\": \"assistant\",\n \"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 = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)[:50000]})\n sub_messages.append({\"role\": \"user\", \"content\": resuLine truncated
"content": "# s04: Subagents\n\n> *\"Process isolation = context isolation\"* -- サブエージェントごとに新しいmessages[]。\n\n## 問題\n\nエージェントが作業するにつれ、messages配列は膨張し続ける。すべてのファイル読み取り、すべてのbash出力がコンテキストに永久に残る。「このプロジェクトはどのテストフレームワークを使っているか」という質問は5つのファイルを読む必要があるかもしれないが、親に必要なのは「pytest」という答えだけだ。\n\n## 解決策\n\n```\nParent agent Subagent\n+------------------+ +------------------+\n| messages=[...] | | messages=[] | <-- fresh\n| | dispatch | |\n| tool: task | ----------> | while tool_use: |\n| prompt=\"...\" | | call tools |\n| | summary | append results |\n| result = \"...\" | <---------- | return last text |\n+------------------+ +------------------+\n\nParent context stays clean. Subagent context is discarded.\n```\n\n## 仕組み\n\n1. 親に`task`ツールを追加する。子は`task`を除くすべての基本ツールを取得する(再帰的な生成は不可)。\n\n```python\nPARENT_TOOLS = CHILD_TOOLS + [\n {\"name\": \"task\",\n \"description\": \"Spawn a subagent with fresh context.\",\n \"input_schema\": {\n \"type\": \"object\",\n \"properties\": {\"prompt\": {\"type\": \"string\"}},\n \"required\": [\"prompt\"],\n }},\n]\n```\n\n2. サブエージェントは`messages=[]`で開始し、自身のループを実行する。最終テキストだけが親に返る。\n\n```python\ndef run_subagent(prompt: str) -> str:\n sub_messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(30): # safety limit\n response = client.messages.create(\n model=MODEL, system=SUBAGENT_SYSTEM,\n messages=sub_messages,\n tools=CHILD_TOOLS, max_tokens=8000,\n )\n sub_messages.append({\"role\": \"assistant\",\n \"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 = TOOL_HANDLERS.get(block.name)\n output = handler(**block.input)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)[:50000]})\n sub_messages.append({\"role\": \"user\", \"content\": results})\n return \"\".join(\n b.text for b in response.content if hasattr(b, \"text\")\n ) or \"(no summary)\"\n```\n\n子のメッセージ履歴全体(30回以上のツール呼び出し)は破棄される。親は1段落の要約を通常の`tool_result`として受け取る。\n\n## s03からの変更点\n\n| Component | Before (s03) | After (s04) |\n|----------------|------------------|---------------------------|\n| Tools | 5 | 5 (base) + task (parent) |\n| Context | Single shared | Parent + child isolation |\n| Subagent | None | `run_subagent()` function |\n| Return value | N/A | Summary text only |\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s04_subagent.py\n```\n\n1. `Use a subtask to find what testing framework this project uses`\n2. `Delegate: read all .py files and summarize what each one does`\n3. `Use a task to create a new module, then verify it from here`\n"
},
{
"version": "s05",
"locale": "ja",
"title": "s05: Skills",
"content": "# s05: Skills\n\n> 2層のスキル注入により、スキル名をシステムプロンプトに(低コスト)、スキル本体をtool_resultに(オンデマンド)配置することで、システムプロンプトの肥大化を回避する。\n\n## 問題\n\nエージェントに特定のドメインのワークフローを遵守させたい: gitの規約、テストパターン、コードレビューのチェックリストなど。単純なアプローチはすべてをシステムプロンプトに入れることだ。しかしシステムプロンプトの実効的な注意力は有限であり、テキストが多すぎるとモデルはその一部を無視し始める。\n\n10個のスキルが各2000トークンあれば、20,000トークンのシステムプロンプトになる。モデルは先頭と末尾に注意を払い、中間部分は飛ばし読みする。さらに悪いことに、ほとんどのスキルは任意のタスクに対して無関係だ。ファイル編集のタスクにgitワークフローの指示は不要だ。\n\n2層アプローチがこれを解決する: 第1層はシステムプロンプトにスキルの短い説明を置く(スキルあたり約100トークン)。第2層はモデルが`load_skill`を呼び出した時だけ、スキル本体の全文をtool_resultに読み込む。モデルはどのスキルが存在するかを知り(低コスト)、必要な時だけ読み込む(関連する時のみ)。\n\n## 解決策\n\n```\nSystem prompt (Layer 1 -- always present):\n+--------------------------------------+\n| You are a coding agent. |\n| Skills available: |\n| - git: Git workflow helpers | ~100 tokens/skill\n| - test: Testing best practices |\n+--------------------------------------+\n\nWhen model calls load_skill(\"git\"):\n+--------------------------------------+\n| tool_result (Layer 2 -- on demand): |\n| <skill name=\"git\"> |\n| Full git workflow instructions... | ~2000 tokens\n| Step 1: ... |\n| Step 2: ... |\n| </skill> |\n+--------------------------------------+\n```\n\n## 仕組み\n\n1. スキルファイルは`.skills/`にYAMLフロントマター付きMarkdownとして配置される。\n\n```\n.skills/\n git.md # ---\\n description: Git workflow\\n ---\\n ...\n test.md # ---\\n description: Testing patterns\\n ---\\n ...\n```\n\n2. SkillLoaderがフロントマターを解析し、メタデータと本体を分離する。\n\n```python\nclass SkillLoader:\n def _parse_frontmatter(self, text: str) -> tuple:\n match = re.match(\n r\"^---\\n(.*?)\\n---\\n(.*)\", text, re.DOTALL\n )\n if not match:\n return {}, text\n meta = {}\n for line in match.group(1).strip().splitlines():\n if \":\" in line:\n key, val = line.split(\":\", 1)\n meta[key.strip()] = val.strip()\n return meta, match.group(2).strip()\n```\n\n3. 第1層: `get_descriptions()`がシステムプロンプト用の短い行を返す。\n\n```python\ndef get_descriptions(self) -> str:\n lines = []\n for name, skill in self.skills.items():\n desc = skill[\"meta\"].get(\"description\", \"No description\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\n\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nSkills available:\n{SKILL_LOADER.get_descriptions()}\"\"\"\n```\n\n4. 第2層: `get_content()`が`<skill>`タグで囲まれた本体全文を返す。\n\n```python\ndef get_content(self, name: str) -> str:\n skill = self.skills.get(name)\n if not skill:\n return f\"Error: Unknown skill '{name}'.\"\n return f\"<skill name=\\\"{name}\\\">\\n{skill['body']}\\n</skill>\"\n```\n\n5. `load_skill`ツールはディスパッチマップの単なる一エントリだ。\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"load_skill\": lambda **kw: SKILL_LOADER.get_content(kw[\"name\"]),\n}\n```\n\n## 主要コード\n\nSkillLoaderクラス(`agents/s05_skill_loading.py` 51-97行目):\n\n```python\nclass SkillLoader:\n def __init__(self, skills_dir: Path):\n self.skills = {}\n for f in sorted(skills_dir.glob(\"*.md\")):\n text = f.read_text()\n meta, body = self._parse_frontmatter(text)\n self.skills[f.stem] = {\n \"meta\": meta, \"body\": body\n }\n\n def get_descriptions(self) -> str:\n lines = []\n for name, skill in self.skills.items():\n desc = skill[\"meta\"].get(\"description\", \"\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\n\n def get_content(self, name: str) -> str:\n skill = self.skills.get(name)\n if not skill:\n return f\"Error: Unknown skill '{namLine truncated
"content": "# s05: Skills\n\n> *\"Load on demand, not upfront\"* -- 知識はsystem promptではなくtool_result経由で注入する。\n\n## 問題\n\nエージェントにドメイン固有のワークフローを遵守させたい: gitの規約、テストパターン、コードレビューチェックリスト。すべてをシステムプロンプトに入れると、使われないスキルにトークンを浪費する。10スキル x 2000トークン = 20,000トークン、ほとんどが任意のタスクに無関係だ。\n\n## 解決策\n\n```\nSystem prompt (Layer 1 -- always present):\n+--------------------------------------+\n| You are a coding agent. |\n| Skills available: |\n| - git: Git workflow helpers | ~100 tokens/skill\n| - test: Testing best practices |\n+--------------------------------------+\n\nWhen model calls load_skill(\"git\"):\n+--------------------------------------+\n| tool_result (Layer 2 -- on demand): |\n| <skill name=\"git\"> |\n| Full git workflow instructions... | ~2000 tokens\n| Step 1: ... |\n| </skill> |\n+--------------------------------------+\n```\n\n第1層: スキル*名*をシステムプロンプトに(低コスト)。第2層: スキル*本体*をtool_resultに(オンデマンド)。\n\n## 仕組み\n\n1. スキルファイルは`.skills/`にYAMLフロントマター付きMarkdownとして配置される。\n\n```\n.skills/\n git.md # ---\\n description: Git workflow\\n ---\\n ...\n test.md # ---\\n description: Testing patterns\\n ---\\n ...\n```\n\n2. SkillLoaderがフロントマターを解析し、メタデータと本体を分離する。\n\n```python\nclass SkillLoader:\n def __init__(self, skills_dir: Path):\n self.skills = {}\n for f in sorted(skills_dir.glob(\"*.md\")):\n text = f.read_text()\n meta, body = self._parse_frontmatter(text)\n self.skills[f.stem] = {\"meta\": meta, \"body\": body}\n\n def get_descriptions(self) -> str:\n lines = []\n for name, skill in self.skills.items():\n desc = skill[\"meta\"].get(\"description\", \"\")\n lines.append(f\" - {name}: {desc}\")\n return \"\\n\".join(lines)\n\n def get_content(self, name: str) -> str:\n skill = self.skills.get(name)\n if not skill:\n return f\"Error: Unknown skill '{name}'.\"\n return f\"<skill name=\\\"{name}\\\">\\n{skill['body']}\\n</skill>\"\n```\n\n3. 第1層はシステムプロンプトに配置。第2層は通常のツールハンドラ。\n\n```python\nSYSTEM = f\"\"\"You are a coding agent at {WORKDIR}.\nSkills available:\n{SKILL_LOADER.get_descriptions()}\"\"\"\n\nTOOL_HANDLERS = {\n # ...base tools...\n \"load_skill\": lambda **kw: SKILL_LOADER.get_content(kw[\"name\"]),\n}\n```\n\nモデルはどのスキルが存在するかを知り(低コスト)、関連する時にだけ読み込む(高コスト)。\n\n## s04からの変更点\n\n| Component | Before (s04) | After (s05) |\n|----------------|------------------|----------------------------|\n| Tools | 5 (base + task) | 5 (base + load_skill) |\n| System prompt | Static string | + skill descriptions |\n| Knowledge | None | .skills/*.md files |\n| Injection | None | Two-layer (system + result)|\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s05_skill_loading.py\n```\n\n1. `What skills are available?`\n2. `Load the agent-builder skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n4. `Build an MCP server using the mcp-builder skill`\n"
},
{
"version": "s06",
"locale": "ja",
"title": "s06: Compact",
"content": "# s06: Compact\n\n> 3層の圧縮パイプラインにより、古いツール結果の戦略的な忘却、トークンが閾値を超えた時の自動要約、オンデマンドの手動圧縮を組み合わせて、エージェントを無期限に動作可能にする。\n\n## 問題\n\nコンテキストウィンドウは有限だ。十分なツール呼び出しの後、メッセージ配列がモデルのコンテキスト上限を超え、API呼び出しが失敗する。ハード制限に達する前でも、パフォーマンスは劣化する: モデルは遅くなり、精度が落ち、以前のメッセージを無視し始める。\n\n200,000トークンのコンテキストウィンドウは大きく聞こえるが、1000行のソースファイルに対する一回の`read_file`で約4000トークンを消費する。30ファイルを読み20回のbashコマンドを実行すると、100,000トークン以上になる。何らかの圧縮がなければ、エージェントは大規模なコードベースで作業できない。\n\n3層のパイプラインは積極性を段階的に上げて対処する:\n第1層(micro-compact)は毎ターン静かに古いツール結果を置換する。\n第2層(auto-compact)はトークンが閾値を超えた時に完全な要約を発動する。\n第3層(manual compact)はモデル自身が圧縮をトリガーできる。\n\n教育上の簡略化: ここでのトークン推定は大まかな「文字数/4」ヒューリスティックを使用している。本番システムでは正確なカウントのために適切なトークナイザーライブラリを使用する。\n\n## 解決策\n\n```\nEvery turn:\n+------------------+\n| Tool call result |\n+------------------+\n |\n v\n[Layer 1: micro_compact] (silent, every turn)\n Replace tool_result > 3 turns old\n with \"[Previous: used {tool_name}]\"\n |\n v\n[Check: tokens > 50000?]\n | |\n no yes\n | |\n v v\ncontinue [Layer 2: auto_compact]\n Save transcript to .transcripts/\n LLM summarizes conversation.\n Replace all messages with [summary].\n |\n v\n [Layer 3: compact tool]\n Model calls compact explicitly.\n Same summarization as auto_compact.\n```\n\n## 仕組み\n\n1. **第1層 -- micro_compact**: 各LLM呼び出しの前に、直近3件以前のすべてのtool_resultエントリを見つけて内容を置換する。\n\n```python\ndef micro_compact(messages: list) -> list:\n tool_results = []\n for i, msg in enumerate(messages):\n if msg[\"role\"] == \"user\" and isinstance(msg.get(\"content\"), list):\n for j, part in enumerate(msg[\"content\"]):\n if isinstance(part, dict) and part.get(\"type\") == \"tool_result\":\n tool_results.append((i, j, part))\n if len(tool_results) <= KEEP_RECENT:\n return messages\n to_clear = tool_results[:-KEEP_RECENT]\n for _, _, part in to_clear:\n if len(part.get(\"content\", \"\")) > 100:\n tool_id = part.get(\"tool_use_id\", \"\")\n tool_name = tool_name_map.get(tool_id, \"unknown\")\n part[\"content\"] = f\"[Previous: used {tool_name}]\"\n return messages\n```\n\n2. **第2層 -- auto_compact**: 推定トークン数が50,000を超えた時、完全なトランスクリプトを保存し、LLMに要約を依頼する。\n\n```python\ndef auto_compact(messages: list) -> list:\n TRANSCRIPT_DIR.mkdir(exist_ok=True)\n transcript_path = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with open(transcript_path, \"w\") as f:\n for msg in messages:\n f.write(json.dumps(msg, default=str) + \"\\n\")\n response = client.messages.create(\n model=MODEL,\n messages=[{\"role\": \"user\", \"content\":\n \"Summarize this conversation for continuity...\"\n + json.dumps(messages, default=str)[:80000]}],\n max_tokens=2000,\n )\n summary = response.content[0].text\n return [\n {\"role\": \"user\", \"content\": f\"[Compressed]\\n\\n{summary}\"},\n {\"role\": \"assistant\", \"content\": \"Understood. Continuing.\"},\n ]\n```\n\n3. **第3層 -- manual compact**: `compact`ツールが同じ要約処理をオンデマンドでトリガーする。\n\n```python\nif manual_compact:\n messages[:] = auto_compact(messages)\n```\n\n4. agent loopが3つの層すべてを統合する。\n\n```python\ndef agent_loop(messages: list):\n while True:\n micro_compact(messages)\n if estimate_tokens(messages) > THRESHOLD:\n messages[:] = auto_compact(messages)\n response = client.messages.create(...)\n # ... tool execution ...\n if manual_compact:\n messages[:] = auto_compLine truncated
"title": "s06: Context Compact",
"content": "# s06: Context Compact\n\n> *\"Strategic forgetting\"* -- 古いコンテキストを忘れることで無限セッションを実現する。\n\n## 問題\n\nコンテキストウィンドウは有限だ。1000行のファイルに対する`read_file`1回で約4000トークンを消費する。30ファイルを読み20回のbashコマンドを実行すると、100,000トークン超。圧縮なしでは、エージェントは大規模コードベースで作業できない。\n\n## 解決策\n\n積極性を段階的に上げる3層構成:\n\n```\nEvery turn:\n+------------------+\n| Tool call result |\n+------------------+\n |\n v\n[Layer 1: micro_compact] (silent, every turn)\n Replace tool_result > 3 turns old\n with \"[Previous: used {tool_name}]\"\n |\n v\n[Check: tokens > 50000?]\n | |\n no yes\n | |\n v v\ncontinue [Layer 2: auto_compact]\n Save transcript to .transcripts/\n LLM summarizes conversation.\n Replace all messages with [summary].\n |\n v\n [Layer 3: compact tool]\n Model calls compact explicitly.\n Same summarization as auto_compact.\n```\n\n## 仕組み\n\n1. **第1層 -- micro_compact**: 各LLM呼び出しの前に、古いツール結果をプレースホルダーに置換する。\n\n```python\ndef micro_compact(messages: list) -> list:\n tool_results = []\n for i, msg in enumerate(messages):\n if msg[\"role\"] == \"user\" and isinstance(msg.get(\"content\"), list):\n for j, part in enumerate(msg[\"content\"]):\n if isinstance(part, dict) and part.get(\"type\") == \"tool_result\":\n tool_results.append((i, j, part))\n if len(tool_results) <= KEEP_RECENT:\n return messages\n for _, _, part in tool_results[:-KEEP_RECENT]:\n if len(part.get(\"content\", \"\")) > 100:\n part[\"content\"] = f\"[Previous: used {tool_name}]\"\n return messages\n```\n\n2. **第2層 -- auto_compact**: トークンが閾値を超えたら、完全なトランスクリプトをディスクに保存し、LLMに要約を依頼する。\n\n```python\ndef auto_compact(messages: list) -> list:\n # Save transcript for recovery\n transcript_path = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with open(transcript_path, \"w\") as f:\n for msg in messages:\n f.write(json.dumps(msg, default=str) + \"\\n\")\n # LLM summarizes\n response = client.messages.create(\n model=MODEL,\n messages=[{\"role\": \"user\", \"content\":\n \"Summarize this conversation for continuity...\"\n + json.dumps(messages, default=str)[:80000]}],\n max_tokens=2000,\n )\n return [\n {\"role\": \"user\", \"content\": f\"[Compressed]\\n\\n{response.content[0].text}\"},\n {\"role\": \"assistant\", \"content\": \"Understood. Continuing.\"},\n ]\n```\n\n3. **第3層 -- manual compact**: `compact`ツールが同じ要約処理をオンデマンドでトリガーする。\n\n4. ループが3層すべてを統合する:\n\n```python\ndef agent_loop(messages: list):\n while True:\n micro_compact(messages) # Layer 1\n if estimate_tokens(messages) > THRESHOLD:\n messages[:] = auto_compact(messages) # Layer 2\n response = client.messages.create(...)\n # ... tool execution ...\n if manual_compact:\n messages[:] = auto_compact(messages) # Layer 3\n```\n\nトランスクリプトがディスク上に完全な履歴を保持する。何も真に失われず、アクティブなコンテキストの外に移動されるだけ。\n\n## s05からの変更点\n\n| Component | Before (s05) | After (s06) |\n|----------------|------------------|----------------------------|\n| Tools | 5 | 5 (base + compact) |\n| Context mgmt | None | Three-layer compression |\n| Micro-compact | None | Old results -> placeholders|\n| Auto-compact | None | Token threshold trigger |\n| Transcripts | None | Saved to .transcripts/ |\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s06_context_compact.py\n```\n\n1. `Read every Python file in the agents/ directory one by one` (micro-compactが古い結果を置換するのを観察する)\n2. `Keep reading files until compression triggers automatically`\n3. `Use the compact tool to manually compress the conversation`\n"
},
{
"version": "s07",
"locale": "ja",
"title": "s07: Tasks",
"content": "# s07: Tasks\n\n> タスクを依存グラフ付き JSON として永続化し、コンテキスト圧縮後も状態を保持し、複数エージェントで共有できるようにする。\n\n## 問題\n\nインメモリ状態(s03 の TodoManager など)は、s06 の圧縮後に失われやすい。古いターンが要約化されると、Todo 状態は会話の外に残らない。\n\ns06 -> s07 の本質は次の切替:\n\n1. メモリ上 Todo は会話依存で失われやすい。\n2. ディスク上 Task は永続で復元しやすい。\n\nさらに可視性の問題がある。インメモリ構造はプロセスローカルであり、チームメイト間の共有が不安定になる。\n\n## Task vs Todo: 使い分け\n\ns07 以降は Task がデフォルト。Todo は短い直線的チェックリスト用に残る。\n\n## クイック判定マトリクス\n\n| 状況 | 優先 | 理由 |\n|---|---|---|\n| 短時間・単一セッション・直線的チェック | Todo | 儀式が最小で記録が速い |\n| セッション跨ぎ・依存関係・複数担当 | Task | 永続性、依存表現、協調可視性が必要 |\n| 迷う場合 | Task | 後で簡略化する方が、途中移行より低コスト |\n\n## 解決策\n\n```\n.tasks/\n task_1.json {\"id\":1, \"status\":\"completed\", ...}\n task_2.json {\"id\":2, \"blockedBy\":[1], \"status\":\"pending\"}\n task_3.json {\"id\":3, \"blockedBy\":[2], \"status\":\"pending\"}\n\nDependency resolution:\n+----------+ +----------+ +----------+\n| task 1 | --> | task 2 | --> | task 3 |\n| complete | | blocked | | blocked |\n+----------+ +----------+ +----------+\n | ^\n +--- completing task 1 removes it from\n task 2's blockedBy list\n```\n\n## 仕組み\n\n1. TaskManager はタスクごとに1 JSON ファイルで CRUD を提供する。\n\n```python\nclass TaskManager:\n def create(self, subject: str, description: str = \"\") -> str:\n task = {\n \"id\": self._next_id,\n \"subject\": subject,\n \"description\": description,\n \"status\": \"pending\",\n \"blockedBy\": [],\n \"blocks\": [],\n \"owner\": \"\",\n }\n self._save(task)\n self._next_id += 1\n return json.dumps(task, indent=2)\n```\n\n2. タスク完了時、他タスクの依存を解除する。\n\n```python\ndef _clear_dependency(self, completed_id: int):\n for f in self.dir.glob(\"task_*.json\"):\n task = json.loads(f.read_text())\n if completed_id in task.get(\"blockedBy\", []):\n task[\"blockedBy\"].remove(completed_id)\n self._save(task)\n```\n\n3. `update` が状態遷移と依存配線を担う。\n\n```python\ndef update(self, task_id, status=None,\n add_blocked_by=None, add_blocks=None):\n task = self._load(task_id)\n if status:\n task[\"status\"] = status\n if status == \"completed\":\n self._clear_dependency(task_id)\n if add_blocks:\n task[\"blocks\"] = list(set(task[\"blocks\"] + add_blocks))\n for blocked_id in add_blocks:\n blocked = self._load(blocked_id)\n if task_id not in blocked[\"blockedBy\"]:\n blocked[\"blockedBy\"].append(task_id)\n self._save(blocked)\n self._save(task)\n```\n\n4. タスクツール群をディスパッチへ追加する。\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"task_create\": lambda **kw: TASKS.create(kw[\"subject\"]),\n \"task_update\": lambda **kw: TASKS.update(kw[\"task_id\"],\n kw.get(\"status\")),\n \"task_list\": lambda **kw: TASKS.list_all(),\n \"task_get\": lambda **kw: TASKS.get(kw[\"task_id\"]),\n}\n```\n\n## 主要コード\n\n依存グラフ付き TaskManager(`agents/s07_task_system.py` 46-123行):\n\n```python\nclass TaskManager:\n def __init__(self, tasks_dir: Path):\n self.dir = tasks_dir\n self.dir.mkdir(exist_ok=True)\n self._next_id = self._max_id() + 1\n\n def _load(self, task_id: int) -> dict:\n path = self.dir / f\"task_{task_id}.json\"\n return json.loads(path.read_text())\n\n def _save(self, task: dict):\n path = self.dir / f\"task_{task['id']}.json\"\n path.write_text(json.dumps(task, indent=2))\n\n def create(self, subject, description=\"\"):\n task = {\"id\": self._next_id, \"subject\": subject,\n \"status\": \"pending\", \"blockedBy\": [],\n \"blocks\": [], \"owner\": \"\"}\n self._save(task)\n self._next_id += 1\n return json.dumps(task, indent=2)\n\n def _clear_dependency(self, completed_id):\n for f in self.dir.glob(\"task_*.json\"):\n task = json.loads(f.read_text())\n if completed_id in task.get(\"blockedBy\", []):\n task[\"blocLine truncated
"content": "# s07: Tasks\n\n> *\"State survives /compact\"* -- ファイルベースの状態はコンテキスト圧縮を生き延びる。\n\n## 問題\n\nインメモリ状態(s03のTodoManager)はコンテキスト圧縮(s06)で消える。auto_compactがメッセージを要約に置換した後、todoリストは失われる。要約テキストからの復元は不正確で脆い。\n\nファイルベースのタスクがこれを解決する: 状態をディスクに書き込めば、圧縮もプロセス再起動も生き延び、やがてマルチエージェントでの共有(s09+)も可能になる。\n\n## 解決策\n\n```\n.tasks/\n task_1.json {\"id\":1, \"status\":\"completed\", ...}\n task_2.json {\"id\":2, \"blockedBy\":[1], \"status\":\"pending\"}\n task_3.json {\"id\":3, \"blockedBy\":[2], \"status\":\"pending\"}\n\nDependency resolution:\n+----------+ +----------+ +----------+\n| task 1 | --> | task 2 | --> | task 3 |\n| complete | | blocked | | blocked |\n+----------+ +----------+ +----------+\n | ^\n +--- completing task 1 removes it from\n task 2's blockedBy list\n```\n\n## 仕組み\n\n1. TaskManager: タスクごとに1つのJSONファイル、依存グラフ付きCRUD。\n\n```python\nclass TaskManager:\n def __init__(self, tasks_dir: Path):\n self.dir = tasks_dir\n self.dir.mkdir(exist_ok=True)\n self._next_id = self._max_id() + 1\n\n def create(self, subject, description=\"\"):\n task = {\"id\": self._next_id, \"subject\": subject,\n \"status\": \"pending\", \"blockedBy\": [],\n \"blocks\": [], \"owner\": \"\"}\n self._save(task)\n self._next_id += 1\n return json.dumps(task, indent=2)\n```\n\n2. タスク完了時に、他タスクの`blockedBy`リストから完了IDを除去する。\n\n```python\ndef _clear_dependency(self, completed_id):\n for f in self.dir.glob(\"task_*.json\"):\n task = json.loads(f.read_text())\n if completed_id in task.get(\"blockedBy\", []):\n task[\"blockedBy\"].remove(completed_id)\n self._save(task)\n```\n\n3. `update`が状態遷移と依存配線を担う。\n\n```python\ndef update(self, task_id, status=None,\n add_blocked_by=None, add_blocks=None):\n task = self._load(task_id)\n if status:\n task[\"status\"] = status\n if status == \"completed\":\n self._clear_dependency(task_id)\n self._save(task)\n```\n\n4. 4つのタスクツールをディスパッチマップに追加する。\n\n```python\nTOOL_HANDLERS = {\n # ...base tools...\n \"task_create\": lambda **kw: TASKS.create(kw[\"subject\"]),\n \"task_update\": lambda **kw: TASKS.update(kw[\"task_id\"], kw.get(\"status\")),\n \"task_list\": lambda **kw: TASKS.list_all(),\n \"task_get\": lambda **kw: TASKS.get(kw[\"task_id\"]),\n}\n```\n\ns07以降、Taskがマルチステップ作業のデフォルト。Todoは軽量チェックリスト用に残る。\n\n## s06からの変更点\n\n| Component | Before (s06) | After (s07) |\n|---|---|---|\n| Tools | 5 | 8 (`task_create/update/list/get`) |\n| State storage | In-memory only | JSON files in `.tasks/` |\n| Dependencies | None | `blockedBy + blocks` graph |\n| Persistence | Lost on compact | Survives compression |\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s07_task_system.py\n```\n\n1. `Create 3 tasks: \"Setup project\", \"Write code\", \"Write tests\". Make them depend on each other in order.`\n2. `List all tasks and show the dependency graph`\n3. `Complete task 1 and then list tasks to see task 2 unblocked`\n4. `Create a task board for refactoring: parse -> transform -> emit -> test`\n"
},
{
"version": "s08",
"locale": "ja",
"title": "s08: Background Tasks",
"content": "# s08: Background Tasks\n\n> BackgroundManagerがコマンドを別スレッドで実行し、各LLM呼び出しの前に通知キューをドレインすることで、エージェントは長時間実行操作でブロックされなくなる。\n\n## 問題\n\n一部のコマンドは数分かかる: `npm install`、`pytest`、`docker build`。ブロッキングのagent loopでは、モデルはサブプロセスの終了を待って待機する。他のことは何もできない。ユーザーが「依存関係をインストールして、その間にconfigファイルを作成して」と言った場合、エージェントはまずインストールを行い、その後configを作成する -- 並列ではなく逐次的に。\n\nエージェントには並行性が必要だ。agent loop自体の完全なマルチスレッディングではなく、長いコマンドを発射して実行中に作業を続ける能力だ。コマンドが終了したら、その結果は自然に会話に現れるべきだ。\n\n解決策は、BackgroundManagerがコマンドをデーモンスレッドで実行し、結果を通知キューに収集すること。各LLM呼び出しの前にキューがドレインされ、結果がメッセージに注入される。\n\n## 解決策\n\n```\nMain thread Background thread\n+-----------------+ +-----------------+\n| agent loop | | task executes |\n| ... | | ... |\n| [LLM call] <---+------- | enqueue(result) |\n| ^drain queue | +-----------------+\n+-----------------+\n\nTimeline:\nAgent --[spawn A]--[spawn B]--[other work]----\n | |\n v v\n [A runs] [B runs] (parallel)\n | |\n +-- notification queue --+\n |\n [results injected before\n next LLM call]\n```\n\n## 仕組み\n\n1. BackgroundManagerがタスクを追跡し、スレッドセーフな通知キューを維持する。\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self._notification_queue = []\n self._lock = threading.Lock()\n```\n\n2. `run()`がデーモンスレッドを開始し、task_idを即座に返す。\n\n```python\ndef run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n self.tasks[task_id] = {\n \"status\": \"running\",\n \"result\": None,\n \"command\": command,\n }\n thread = threading.Thread(\n target=self._execute,\n args=(task_id, command),\n daemon=True,\n )\n thread.start()\n return f\"Background task {task_id} started\"\n```\n\n3. スレッドのターゲットである`_execute`がサブプロセスを実行し、結果を通知キューにプッシュする。\n\n```python\ndef _execute(self, task_id: str, command: str):\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=300)\n output = (r.stdout + r.stderr).strip()[:50000]\n status = \"completed\"\n except subprocess.TimeoutExpired:\n output = \"Error: Timeout (300s)\"\n status = \"timeout\"\n self.tasks[task_id][\"status\"] = status\n self.tasks[task_id][\"result\"] = output\n with self._lock:\n self._notification_queue.append({\n \"task_id\": task_id,\n \"status\": status,\n \"result\": output[:500],\n })\n```\n\n4. `drain_notifications()`が保留中の結果を返してクリアする。\n\n```python\ndef drain_notifications(self) -> list:\n with self._lock:\n notifs = list(self._notification_queue)\n self._notification_queue.clear()\n return notifs\n```\n\n5. agent loopが各LLM呼び出しの前に通知をドレインする。\n\n```python\ndef agent_loop(messages: list):\n while True:\n notifs = BG.drain_notifications()\n if notifs and messages:\n notif_text = \"\\n\".join(\n f\"[bg:{n['task_id']}] {n['status']}: \"\n f\"{n['result']}\" for n in notifs\n )\n messages.append({\"role\": \"user\",\n \"content\": f\"<background-results>\"\n f\"\\n{notif_text}\\n\"\n f\"</background-results>\"})\n messages.append({\"role\": \"assistant\",\n \"content\": \"Noted background results.\"})\n response = client.messages.create(...)\n```\n\n## 主要コード\n\nBackgroundManager(`agents/s08_background_tasks.py` 49-107行目):\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self._notification_queue = []\n self._lock = threading.Lock()\n\n def run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n selfLine truncated
"content": "# s08: Background Tasks\n\n> *\"Fire and forget\"* -- ノンブロッキングスレッド + 通知キュー。\n\n## 問題\n\n一部のコマンドは数分かかる: `npm install`、`pytest`、`docker build`。ブロッキングループでは、モデルはサブプロセスの完了を待って座っている。ユーザーが「依存関係をインストールして、その間にconfigファイルを作って」と言っても、エージェントは並列ではなく逐次的に処理する。\n\n## 解決策\n\n```\nMain thread Background thread\n+-----------------+ +-----------------+\n| agent loop | | subprocess runs |\n| ... | | ... |\n| [LLM call] <---+------- | enqueue(result) |\n| ^drain queue | +-----------------+\n+-----------------+\n\nTimeline:\nAgent --[spawn A]--[spawn B]--[other work]----\n | |\n v v\n [A runs] [B runs] (parallel)\n | |\n +-- results injected before next LLM call --+\n```\n\n## 仕組み\n\n1. BackgroundManagerがスレッドセーフな通知キューでタスクを追跡する。\n\n```python\nclass BackgroundManager:\n def __init__(self):\n self.tasks = {}\n self._notification_queue = []\n self._lock = threading.Lock()\n```\n\n2. `run()`がデーモンスレッドを開始し、即座にリターンする。\n\n```python\ndef run(self, command: str) -> str:\n task_id = str(uuid.uuid4())[:8]\n self.tasks[task_id] = {\"status\": \"running\", \"command\": command}\n thread = threading.Thread(\n target=self._execute, args=(task_id, command), daemon=True)\n thread.start()\n return f\"Background task {task_id} started\"\n```\n\n3. サブプロセス完了時に、結果を通知キューへ。\n\n```python\ndef _execute(self, task_id, command):\n try:\n r = subprocess.run(command, shell=True, cwd=WORKDIR,\n capture_output=True, text=True, timeout=300)\n output = (r.stdout + r.stderr).strip()[:50000]\n except subprocess.TimeoutExpired:\n output = \"Error: Timeout (300s)\"\n with self._lock:\n self._notification_queue.append({\n \"task_id\": task_id, \"result\": output[:500]})\n```\n\n4. エージェントループが各LLM呼び出しの前に通知をドレインする。\n\n```python\ndef agent_loop(messages: list):\n while True:\n notifs = BG.drain_notifications()\n if notifs:\n notif_text = \"\\n\".join(\n f\"[bg:{n['task_id']}] {n['result']}\" for n in notifs)\n messages.append({\"role\": \"user\",\n \"content\": f\"<background-results>\\n{notif_text}\\n\"\n f\"</background-results>\"})\n messages.append({\"role\": \"assistant\",\n \"content\": \"Noted background results.\"})\n response = client.messages.create(...)\n```\n\nループはシングルスレッドのまま。サブプロセスI/Oだけが並列化される。\n\n## s07からの変更点\n\n| Component | Before (s07) | After (s08) |\n|----------------|------------------|----------------------------|\n| Tools | 8 | 6 (base + background_run + check)|\n| Execution | Blocking only | Blocking + background threads|\n| Notification | None | Queue drained per loop |\n| Concurrency | None | Daemon threads |\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s08_background_tasks.py\n```\n\n1. `Run \"sleep 5 && echo done\" in the background, then create a file while it runs`\n2. `Start 3 background tasks: \"sleep 2\", \"sleep 4\", \"sleep 6\". Check their status.`\n3. `Run pytest in the background and keep working on other things`\n"
},
{
"version": "s09",
"locale": "ja",
"title": "s09: Agent Teams",
"content": "# s09: Agent Teams\n\n> JSONL 形式のインボックスを持つ永続的なチームメイトは、孤立したエージェントを連携可能なチームへ変えるための教材プロトコルの一つだ -- spawn、message、broadcast、drain。\n\n## 問題\n\nサブエージェント(s04)は使い捨てだ: 生成し、作業し、要約を返し、消滅する。アイデンティティもなく、呼び出し間の記憶もなく、フォローアップの指示を受け取る方法もない。バックグラウンドタスク(s08)はシェルコマンドを実行するが、LLM誘導の意思決定やフィードバックの伝達はできない。\n\n本物のチームワークには3つのものが必要だ: (1)単一のプロンプトを超えて存続する永続的なエージェント、(2)アイデンティティとライフサイクル管理、(3)エージェント間の通信チャネル。メッセージングがなければ、永続的なチームメイトでさえ聾唖だ -- 並列に作業できるが協調することはない。\n\n解決策は、名前付きの永続的エージェントを生成するTeammateManagerと、JSONL インボックスファイルを使うMessageBusの組み合わせだ。各チームメイトは自身のagent loopをスレッドで実行し、各LLM呼び出しの前にインボックスを確認し、他のチームメイトやリーダーにメッセージを送れる。\n\ns06からs07への橋渡しについての注記: s03のTodoManagerアイテムは圧縮(s06)と共に死ぬ。ファイルベースのタスク(s07)はディスク上に存在するため圧縮後も生き残る。チームも同じ原則の上に構築されている -- config.jsonとインボックスファイルはコンテキストウィンドウの外に永続化される。\n\n## 解決策\n\n```\nTeammate lifecycle:\n spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN\n\nCommunication:\n .team/\n config.json <- team roster + statuses\n inbox/\n alice.jsonl <- append-only, drain-on-read\n bob.jsonl\n lead.jsonl\n\n +--------+ send(\"alice\",\"bob\",\"...\") +--------+\n | alice | -----------------------------> | bob |\n | loop | bob.jsonl << {json_line} | loop |\n +--------+ +--------+\n ^ |\n | BUS.read_inbox(\"alice\") |\n +---- alice.jsonl -> read + drain ---------+\n\n5 message types:\n+-------------------------+------------------------------+\n| message | Normal text between agents |\n| broadcast | Sent to all teammates |\n| shutdown_request | Request graceful shutdown |\n| shutdown_response | Approve/reject shutdown |\n| plan_approval_response | Approve/reject plan |\n+-------------------------+------------------------------+\n```\n\n## 仕組み\n\n1. TeammateManagerがチームの名簿としてconfig.jsonを管理する。各メンバーは名前、役割、ステータスを持つ。\n\n```python\nclass TeammateManager:\n def __init__(self, team_dir: Path):\n self.dir = team_dir\n self.dir.mkdir(exist_ok=True)\n self.config_path = self.dir / \"config.json\"\n self.config = self._load_config()\n self.threads = {}\n```\n\n2. `spawn()`がチームメイトを作成し、そのagent loopをスレッドで開始する。アイドル状態のチームメイトを再spawnすると再活性化される。\n\n```python\ndef spawn(self, name: str, role: str, prompt: str) -> str:\n member = self._find_member(name)\n if member:\n if member[\"status\"] not in (\"idle\", \"shutdown\"):\n return f\"Error: '{name}' is currently {member['status']}\"\n member[\"status\"] = \"working\"\n else:\n member = {\"name\": name, \"role\": role, \"status\": \"working\"}\n self.config[\"members\"].append(member)\n self._save_config()\n thread = threading.Thread(\n target=self._teammate_loop,\n args=(name, role, prompt), daemon=True)\n self.threads[name] = thread\n thread.start()\n return f\"Spawned teammate '{name}' (role: {role})\"\n```\n\n3. MessageBusがJSONLインボックスファイルを処理する。`send()`がJSON行を追記し、`read_inbox()`がすべての行を読み取ってファイルをドレインする。\n\n```python\nclass MessageBus:\n def send(self, sender, to, content,\n msg_type=\"message\", extra=None):\n msg = {\"type\": msg_type, \"from\": sender,\n \"content\": content,\n \"timestamp\": time.time()}\n if extra:\n msg.update(extra)\n with open(self.dir / f\"{to}.jsonl\", \"a\") as f:\n f.write(json.dumps(msgLine truncated
"content": "# s09: Agent Teams\n\n> *\"Append to send, drain to read\"* -- 永続的なチームメイトのための非同期メールボックス。\n\n## 問題\n\nサブエージェント(s04)は使い捨てだ: 生成し、作業し、要約を返し、消滅する。アイデンティティもなく、呼び出し間の記憶もない。バックグラウンドタスク(s08)はシェルコマンドを実行するが、LLM誘導の意思決定はできない。\n\n本物のチームワークには: (1)単一プロンプトを超えて存続する永続エージェント、(2)アイデンティティとライフサイクル管理、(3)エージェント間の通信チャネルが必要だ。\n\n## 解決策\n\n```\nTeammate lifecycle:\n spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN\n\nCommunication:\n .team/\n config.json <- team roster + statuses\n inbox/\n alice.jsonl <- append-only, drain-on-read\n bob.jsonl\n lead.jsonl\n\n +--------+ send(\"alice\",\"bob\",\"...\") +--------+\n | alice | -----------------------------> | bob |\n | loop | bob.jsonl << {json_line} | loop |\n +--------+ +--------+\n ^ |\n | BUS.read_inbox(\"alice\") |\n +---- alice.jsonl -> read + drain ---------+\n```\n\n## 仕組み\n\n1. TeammateManagerがconfig.jsonでチーム名簿を管理する。\n\n```python\nclass TeammateManager:\n def __init__(self, team_dir: Path):\n self.dir = team_dir\n self.dir.mkdir(exist_ok=True)\n self.config_path = self.dir / \"config.json\"\n self.config = self._load_config()\n self.threads = {}\n```\n\n2. `spawn()`がチームメイトを作成し、そのエージェントループをスレッドで開始する。\n\n```python\ndef spawn(self, name: str, role: str, prompt: str) -> str:\n member = {\"name\": name, \"role\": role, \"status\": \"working\"}\n self.config[\"members\"].append(member)\n self._save_config()\n thread = threading.Thread(\n target=self._teammate_loop,\n args=(name, role, prompt), daemon=True)\n thread.start()\n return f\"Spawned teammate '{name}' (role: {role})\"\n```\n\n3. MessageBus: 追記専用のJSONLインボックス。`send()`がJSON行を追記し、`read_inbox()`がすべて読み取ってドレインする。\n\n```python\nclass MessageBus:\n def send(self, sender, to, content, msg_type=\"message\", extra=None):\n msg = {\"type\": msg_type, \"from\": sender,\n \"content\": content, \"timestamp\": time.time()}\n if extra:\n msg.update(extra)\n with open(self.dir / f\"{to}.jsonl\", \"a\") as f:\n f.write(json.dumps(msg) + \"\\n\")\n\n def read_inbox(self, name):\n path = self.dir / f\"{name}.jsonl\"\n if not path.exists(): return \"[]\"\n msgs = [json.loads(l) for l in path.read_text().strip().splitlines() if l]\n path.write_text(\"\") # drain\n return json.dumps(msgs, indent=2)\n```\n\n4. 各チームメイトは各LLM呼び出しの前にインボックスを確認し、受信メッセージをコンテキストに注入する。\n\n```python\ndef _teammate_loop(self, name, role, prompt):\n messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(50):\n inbox = BUS.read_inbox(name)\n if inbox != \"[]\":\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n messages.append({\"role\": \"assistant\",\n \"content\": \"Noted inbox messages.\"})\n response = client.messages.create(...)\n if response.stop_reason != \"tool_use\":\n break\n # execute tools, append results...\n self._find_member(name)[\"status\"] = \"idle\"\n```\n\n## s08からの変更点\n\n| Component | Before (s08) | After (s09) |\n|----------------|------------------|----------------------------|\n| Tools | 6 | 9 (+spawn/send/read_inbox) |\n| Agents | Single | Lead + N teammates |\n| Persistence | None | config.json + JSONL inboxes|\n| Threads | Background cmds | Full agent loops per thread|\n| Lifecycle | Fire-and-forget | idle -> working -> idle |\n| Communication | None | message + broadcast |\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s09_agent_teams.py\n```\n\n1. `Spawn alice (coder) and bob (tester). Have alice send bob a message.`\n2. `Broadcast \"status update: phase 1 complete\" to all teammates`\n3. `Check the lead inbox for any messages`\n4. `/team`と入力してステータス付きのチーム名簿を確認する\n5. `/inbox`と入力してリーダーのイLine truncated
},
{
"version": "s10",
"locale": "ja",
"title": "s10: Team Protocols",
"content": "# s10: Team Protocols\n\n> 同じrequest_idハンドシェイクパターンがシャットダウンとプラン承認の両方を支える -- 1つのFSM、2つの適用。\n\n## 問題\n\ns09ではチームメイトが作業しコミュニケーションするが、構造化された協調はない。2つの問題が生じる:\n\n**シャットダウン**: チームメイトをどうやってクリーンに停止するか。スレッドを強制終了するとファイルが中途半端に書かれ、config.jsonが不正な状態になる。グレースフルシャットダウンにはハンドシェイクが必要だ: リーダーが要求し、チームメイトが承認(終了処理を行い退出)するか拒否(作業を継続)するかを判断する。\n\n**プラン承認**: 実行をどうやってゲーティングするか。リーダーが「認証モジュールをリファクタリングして」と言うと、チームメイトは即座に開始する。リスクの高い変更では、実行開始前にリーダーが計画をレビューすべきだ。ジュニアが提案し、シニアが承認する。\n\n両方の問題は同じ構造を共有している: 一方がユニークなIDを持つリクエストを送り、もう一方がそのIDを参照してレスポンスする。有限状態機械が各リクエストをpending -> approved | rejectedの遷移で追跡する。\n\n## 解決策\n\n```\nShutdown Protocol Plan Approval Protocol\n================== ======================\n\nLead Teammate Teammate Lead\n | | | |\n |--shutdown_req-->| |--plan_req------>|\n | {req_id:\"abc\"} | | {req_id:\"xyz\"} |\n | | | |\n |<--shutdown_resp-| |<--plan_resp-----|\n | {req_id:\"abc\", | | {req_id:\"xyz\", |\n | approve:true} | | approve:true} |\n | | | |\n v v v v\ntracker[\"abc\"] exits proceeds tracker[\"xyz\"]\n = approved = approved\n\nShared FSM (identical for both protocols):\n [pending] --approve--> [approved]\n [pending] --reject---> [rejected]\n\nTrackers:\n shutdown_requests = {req_id: {target, status}}\n plan_requests = {req_id: {from, plan, status}}\n```\n\n## 仕組み\n\n1. リーダーがrequest_idを生成し、インボックス経由でshutdown_requestを送信してシャットダウンを開始する。\n\n```python\nshutdown_requests = {}\n\ndef handle_shutdown_request(teammate: str) -> str:\n req_id = str(uuid.uuid4())[:8]\n shutdown_requests[req_id] = {\n \"target\": teammate, \"status\": \"pending\",\n }\n BUS.send(\"lead\", teammate, \"Please shut down gracefully.\",\n \"shutdown_request\", {\"request_id\": req_id})\n return f\"Shutdown request {req_id} sent (status: pending)\"\n```\n\n2. チームメイトはインボックスでリクエストを受信し、`shutdown_response`ツールを呼び出して承認または拒否する。\n\n```python\nif tool_name == \"shutdown_response\":\n req_id = args[\"request_id\"]\n approve = args[\"approve\"]\n if req_id in shutdown_requests:\n shutdown_requests[req_id][\"status\"] = \\\n \"approved\" if approve else \"rejected\"\n BUS.send(sender, \"lead\", args.get(\"reason\", \"\"),\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": approve})\n return f\"Shutdown {'approved' if approve else 'rejected'}\"\n```\n\n3. チームメイトのループが承認済みシャットダウンを確認して終了する。\n\n```python\nif (block.name == \"shutdown_response\"\n and block.input.get(\"approve\")):\n should_exit = True\n# ...\nmember[\"status\"] = \"shutdown\" if should_exit else \"idle\"\n```\n\n4. プラン承認も同一のパターンに従う。チームメイトがプランを提出し、request_idを生成する。\n\n```python\nplan_requests = {}\n\nif tool_name == \"plan_approval\":\n plan_text = args.get(\"plan\", \"\")\n req_id = str(uuid.uuid4())[:8]\n plan_requests[req_id] = {\n \"from\": sender, \"plan\": plan_text,\n \"status\": \"pending\",\n }\n BUS.send(sender, \"lead\", plan_text,\n \"plan_approval_request\",\n {\"request_id\": req_id, \"plan\": plan_text})\n return f\"Plan submitted (request_id={req_id})\"\n```\n\n5. リーダーがレビューし、同じrequest_idでレスポンスする。\n\n```python\ndef handle_plan_review(request_id, approve, feedback=\"\"):\n req = plan_requests.get(request_id)\n if not req:\n return f\"Error: Unknown request_id '{request_id}'\"\n req[\"status\"] = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", req[\"from\"], fLine truncated
"content": "# s10: Team Protocols\n\n> *\"Same request_id, two protocols\"* -- 1つのFSMパターンがシャットダウンとプラン承認の両方を支える。\n\n## 問題\n\ns09ではチームメイトが作業し通信するが、構造化された協調がない:\n\n**シャットダウン**: スレッドを強制終了するとファイルが中途半端に書かれ、config.jsonが不正な状態になる。ハンドシェイクが必要 -- リーダーが要求し、チームメイトが承認(完了して退出)か拒否(作業継続)する。\n\n**プラン承認**: リーダーが「認証モジュールをリファクタリングして」と言うと、チームメイトは即座に開始する。リスクの高い変更では、実行前にリーダーが計画をレビューすべきだ。\n\n両方とも同じ構造: 一方がユニークIDを持つリクエストを送り、他方がそのIDで応答する。\n\n## 解決策\n\n```\nShutdown Protocol Plan Approval Protocol\n================== ======================\n\nLead Teammate Teammate Lead\n | | | |\n |--shutdown_req-->| |--plan_req------>|\n | {req_id:\"abc\"} | | {req_id:\"xyz\"} |\n | | | |\n |<--shutdown_resp-| |<--plan_resp-----|\n | {req_id:\"abc\", | | {req_id:\"xyz\", |\n | approve:true} | | approve:true} |\n\nShared FSM:\n [pending] --approve--> [approved]\n [pending] --reject---> [rejected]\n\nTrackers:\n shutdown_requests = {req_id: {target, status}}\n plan_requests = {req_id: {from, plan, status}}\n```\n\n## 仕組み\n\n1. リーダーがrequest_idを生成し、インボックス経由でシャットダウンを開始する。\n\n```python\nshutdown_requests = {}\n\ndef handle_shutdown_request(teammate: str) -> str:\n req_id = str(uuid.uuid4())[:8]\n shutdown_requests[req_id] = {\"target\": teammate, \"status\": \"pending\"}\n BUS.send(\"lead\", teammate, \"Please shut down gracefully.\",\n \"shutdown_request\", {\"request_id\": req_id})\n return f\"Shutdown request {req_id} sent (status: pending)\"\n```\n\n2. チームメイトがリクエストを受信し、承認または拒否で応答する。\n\n```python\nif tool_name == \"shutdown_response\":\n req_id = args[\"request_id\"]\n approve = args[\"approve\"]\n shutdown_requests[req_id][\"status\"] = \"approved\" if approve else \"rejected\"\n BUS.send(sender, \"lead\", args.get(\"reason\", \"\"),\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": approve})\n```\n\n3. プラン承認も同一パターン。チームメイトがプランを提出(request_idを生成)、リーダーがレビュー(同じrequest_idを参照)。\n\n```python\nplan_requests = {}\n\ndef handle_plan_review(request_id, approve, feedback=\"\"):\n req = plan_requests[request_id]\n req[\"status\"] = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", req[\"from\"], feedback,\n \"plan_approval_response\",\n {\"request_id\": request_id, \"approve\": approve})\n```\n\n1つのFSM、2つの応用。同じ`pending -> approved | rejected`状態機械が、あらゆるリクエスト-レスポンスプロトコルに適用できる。\n\n## s09からの変更点\n\n| Component | Before (s09) | After (s10) |\n|----------------|------------------|------------------------------|\n| Tools | 9 | 12 (+shutdown_req/resp +plan)|\n| Shutdown | Natural exit only| Request-response handshake |\n| Plan gating | None | Submit/review with approval |\n| Correlation | None | request_id per request |\n| FSM | None | pending -> approved/rejected |\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s10_team_protocols.py\n```\n\n1. `Spawn alice as a coder. Then request her shutdown.`\n2. `List teammates to see alice's status after shutdown approval`\n3. `Spawn bob with a risky refactoring task. Review and reject his plan.`\n4. `Spawn charlie, have him submit a plan, then approve it.`\n5. `/team`と入力してステータスを監視する\n"
},
{
"version": "s11",
"locale": "ja",
"title": "s11: Autonomous Agents",
"content": "# s11: Autonomous Agents\n\n> タスクボードポーリング付きのアイドルサイクルにより、チームメイトが自分で作業を見つけて確保できるようになり、コンテキスト圧縮後にはアイデンティティの再注入が行われる。\n\n## 問題\n\ns09-s10では、チームメイトは明示的に指示された時のみ作業する。リーダーは各チームメイトを特定のプロンプトでspawnしなければならない。タスクボードに未割り当てのタスクが10個あっても、リーダーが手動で各タスクを割り当てなければならない。これはスケールしない。\n\n真の自律性とは、チームメイトが自分で作業を見つけることだ。チームメイトが現在のタスクを完了したら、タスクボードで未確保の作業をスキャンし、タスクを確保し、作業を開始すべきだ -- リーダーからの指示なしに。\n\nしかし自律エージェントには微妙な問題がある: コンテキスト圧縮後に、エージェントが自分が誰かを忘れる可能性がある。メッセージが要約されると、元のシステムプロンプトのアイデンティティ(「あなたはalice、役割はcoder」)が薄れる。アイデンティティの再注入は、圧縮されたコンテキストの先頭にアイデンティティブロックを挿入することでこれを解決する。\n\n注: トークン推定は文字数/4(大まか)。nag 閾値 3 ラウンドは可視化のために低く設定。\n\n## 解決策\n\n```\nTeammate lifecycle with idle cycle:\n\n+-------+\n| spawn |\n+---+---+\n |\n v\n+-------+ tool_use +-------+\n| WORK | <------------- | LLM |\n+---+---+ +-------+\n |\n | stop_reason != tool_use\n | (or idle tool called)\n v\n+--------+\n| IDLE | poll every 5s for up to 60s\n+---+----+\n |\n +---> check inbox --> message? ----------> WORK\n |\n +---> scan .tasks/ --> unclaimed? -------> claim -> WORK\n |\n +---> 60s timeout ----------------------> SHUTDOWN\n\nIdentity re-injection after compression:\n if len(messages) <= 3:\n messages.insert(0, identity_block)\n \"You are 'alice', role: coder, team: my-team\"\n```\n\n## 仕組み\n\n1. チームメイトのループにはWORKとIDLEの2つのフェーズがある。WORKは標準的なagent loopを実行する。LLMがツール呼び出しを停止した時(または`idle`ツールを呼び出した時)、チームメイトはIDLEフェーズに入る。\n\n```python\ndef _loop(self, name, role, prompt):\n while True:\n # -- WORK PHASE --\n messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(50):\n inbox = BUS.read_inbox(name)\n for msg in inbox:\n if msg.get(\"type\") == \"shutdown_request\":\n self._set_status(name, \"shutdown\")\n return\n messages.append(...)\n response = client.messages.create(...)\n if response.stop_reason != \"tool_use\":\n break\n # execute tools...\n if idle_requested:\n break\n\n # -- IDLE PHASE --\n self._set_status(name, \"idle\")\n resume = self._idle_poll(name, messages)\n if not resume:\n self._set_status(name, \"shutdown\")\n return\n self._set_status(name, \"working\")\n```\n\n2. IDLEフェーズがインボックスとタスクボードをループでポーリングする。\n\n```python\ndef _idle_poll(self, name, messages):\n polls = IDLE_TIMEOUT // POLL_INTERVAL # 60s / 5s = 12\n for _ in range(polls):\n time.sleep(POLL_INTERVAL)\n # Check inbox for new messages\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n return True\n # Scan task board for unclaimed tasks\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n task = unclaimed[0]\n claim_task(task[\"id\"], name)\n messages.append({\"role\": \"user\",\n \"content\": f\"<auto-claimed>Task #{task['id']}: \"\n f\"{task['subject']}</auto-claimed>\"})\n return True\n return False # timeout -> shutdown\n```\n\n3. タスクボードスキャンがpendingかつ未割り当てかつブロックされていないタスクを探す。\n\n```python\ndef scan_unclaimed_tasks() -> list:\n TASKS_DIR.mkdir(exist_ok=True)\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 not task.get(\"blockedBy\")):\n Line truncated
"content": "# s11: Autonomous Agents\n\n> *\"Poll, claim, work, repeat\"* -- コーディネーター不要、エージェントが自己組織化する。\n\n## 問題\n\ns09-s10では、チームメイトは明示的に指示された時のみ作業する。リーダーは各チームメイトを特定のプロンプトでspawnしなければならない。タスクボードに未割り当てのタスクが10個あっても、リーダーが手動で各タスクを割り当てる。これはスケールしない。\n\n真の自律性とは、チームメイトが自分で作業を見つけること: タスクボードをスキャンし、未確保のタスクを確保し、作業し、完了したら次を探す。\n\nもう1つの問題: コンテキスト圧縮(s06)後にエージェントが自分の正体を忘れる可能性がある。アイデンティティ再注入がこれを解決する。\n\n## 解決策\n\n```\nTeammate lifecycle with idle cycle:\n\n+-------+\n| spawn |\n+---+---+\n |\n v\n+-------+ tool_use +-------+\n| WORK | <------------- | LLM |\n+---+---+ +-------+\n |\n | stop_reason != tool_use (or idle tool called)\n v\n+--------+\n| IDLE | poll every 5s for up to 60s\n+---+----+\n |\n +---> check inbox --> message? ----------> WORK\n |\n +---> scan .tasks/ --> unclaimed? -------> claim -> WORK\n |\n +---> 60s timeout ----------------------> SHUTDOWN\n\nIdentity re-injection after compression:\n if len(messages) <= 3:\n messages.insert(0, identity_block)\n```\n\n## 仕組み\n\n1. チームメイトのループはWORKとIDLEの2フェーズ。LLMがツール呼び出しを止めた時(または`idle`ツールを呼んだ時)、IDLEフェーズに入る。\n\n```python\ndef _loop(self, name, role, prompt):\n while True:\n # -- WORK PHASE --\n messages = [{\"role\": \"user\", \"content\": prompt}]\n for _ in range(50):\n response = client.messages.create(...)\n if response.stop_reason != \"tool_use\":\n break\n # execute tools...\n if idle_requested:\n break\n\n # -- IDLE PHASE --\n self._set_status(name, \"idle\")\n resume = self._idle_poll(name, messages)\n if not resume:\n self._set_status(name, \"shutdown\")\n return\n self._set_status(name, \"working\")\n```\n\n2. IDLEフェーズがインボックスとタスクボードをポーリングする。\n\n```python\ndef _idle_poll(self, name, messages):\n for _ in range(IDLE_TIMEOUT // POLL_INTERVAL): # 60s / 5s = 12\n time.sleep(POLL_INTERVAL)\n inbox = BUS.read_inbox(name)\n if inbox:\n messages.append({\"role\": \"user\",\n \"content\": f\"<inbox>{inbox}</inbox>\"})\n return True\n unclaimed = scan_unclaimed_tasks()\n if unclaimed:\n claim_task(unclaimed[0][\"id\"], name)\n messages.append({\"role\": \"user\",\n \"content\": f\"<auto-claimed>Task #{unclaimed[0]['id']}: \"\n f\"{unclaimed[0]['subject']}</auto-claimed>\"})\n return True\n return False # timeout -> shutdown\n```\n\n3. タスクボードスキャン: pendingかつ未割り当てかつブロックされていないタスクを探す。\n\n```python\ndef scan_unclaimed_tasks() -> list:\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 not task.get(\"blockedBy\")):\n unclaimed.append(task)\n return unclaimed\n```\n\n4. アイデンティティ再注入: コンテキストが短すぎる(圧縮が起きた)場合にアイデンティティブロックを挿入する。\n\n```python\nif len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"<identity>You are '{name}', role: {role}, \"\n f\"team: {team_name}. Continue your work.</identity>\"})\n messages.insert(1, {\"role\": \"assistant\",\n \"content\": f\"I am {name}. Continuing.\"})\n```\n\n## s10からの変更点\n\n| Component | Before (s10) | After (s11) |\n|----------------|------------------|----------------------------|\n| Tools | 12 | 14 (+idle, +claim_task) |\n| Autonomy | Lead-directed | Self-organizing |\n| Idle phase | None | Poll inbox + task board |\n| Task claiming | Manual only | Auto-claim unclaimed tasks |\n| Identity | System prompt | + re-injection after compress|\n| Timeout | None | 60s idle -> auto shutdown |\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython agents/s11_autonomous_agents.py\n```\n\n1. `Create 3 tasks on the board, then spawn alLine truncated
},
{
"version": "s12",
"locale": "ja",
"title": "s12: Worktree + Task Isolation",
"content": "# s12: Worktree + Task Isolation\n\n> ディレクトリで分離し、タスクIDで調整する -- タスクボード(制御面)と worktree(実行面)の組み合わせで、並行編集を衝突しやすい状態から追跡可能・復元可能・後片付け可能な状態に変える。\n\n## 問題\n\ns11 でエージェントはタスクを自律的に処理できるようになった。だが全タスクが同じ作業ディレクトリで走ると、3つの障害が現れる。\n\nあるエージェントが認証リファクタリングに取り組みながら、別のエージェントがログインページを作っている。両者が `src/auth.py` を編集する。未コミットの変更が混ざり合い、`git diff` は2つのタスクの差分が入り混じった結果を返す。どちらのエージェントの変更かを後から特定するのは困難になり、片方のタスクを巻き戻すと他方の編集も消える。\n\n1. 変更汚染: 未コミット変更が相互に干渉する。\n2. 責務の曖昧化: タスク状態とファイル変更がずれる。\n3. 終了処理の難化: 実行コンテキストを残すか削除するかの判断が曖昧になる。\n\n解決の核は「何をやるか」と「どこでやるか」の分離だ。\n\n## 解決策\n\n```\nControl Plane (.tasks/) Execution Plane (.worktrees/)\n+---------------------+ +------------------------+\n| task_1.json | | auth-refactor/ |\n| status: in_progress| bind | branch: wt/auth-ref |\n| worktree: auth-ref|-------->| cwd for commands |\n+---------------------+ +------------------------+\n| task_2.json | | ui-login/ |\n| status: pending | bind | branch: wt/ui-login |\n| worktree: ui-login|-------->| cwd for commands |\n+---------------------+ +------------------------+\n | |\n v v\n \"what to do\" \"where to execute\"\n\nEvents (.worktrees/events.jsonl)\n worktree.create.before -> worktree.create.after\n worktree.remove.before -> worktree.remove.after\n task.completed\n```\n\n## 仕組み\n\n1. 状態は3つの層に分かれる。制御面はタスクの目標と担当を管理し、実行面は worktree のパスとブランチを管理し、実行時状態はメモリ上の1ターン情報を保持する。\n\n```text\n制御面 (.tasks/task_*.json) -> id/subject/status/owner/worktree\n実行面 (.worktrees/index.json) -> name/path/branch/task_id/status\n実行時状態 (メモリ) -> current_task/current_worktree/error\n```\n\n2. Task と worktree はそれぞれ独立した状態機械を持つ。\n\n```text\nTask: pending -> in_progress -> completed\nWorktree: absent -> active -> removed | kept\n```\n\n3. `task_create` でまず目標を永続化する。worktree はまだ不要だ。\n\n```python\ntask = {\n \"id\": self._next_id,\n \"subject\": subject,\n \"status\": \"pending\",\n \"owner\": \"\",\n \"worktree\": \"\",\n \"created_at\": time.time(),\n \"updated_at\": time.time(),\n}\nself._save(task)\n```\n\n4. `worktree_create(name, task_id?)` で分離ディレクトリとブランチを作る。`task_id` を渡すと、タスクが `pending` なら自動的に `in_progress` に遷移する。\n\n```python\nentry = {\n \"name\": name,\n \"path\": str(path),\n \"branch\": branch,\n \"task_id\": task_id,\n \"status\": \"active\",\n \"created_at\": time.time(),\n}\nidx[\"worktrees\"].append(entry)\nself._save_index(idx)\n\nif task_id is not None:\n self.tasks.bind_worktree(task_id, name)\n```\n\n5. `worktree_run(name, command)` で分離ディレクトリ内のコマンドを実行する。`cwd=worktree_path` が実質的な「enter」だ。\n\n```python\nr = subprocess.run(\n command,\n shell=True,\n cwd=path,\n capture_output=True,\n text=True,\n timeout=300,\n)\n```\n\n6. 終了処理では `keep` か `remove` を明示的に選ぶ。`worktree_remove(name, complete_task=true)` はディレクトリ削除とタスク完了を一度に行う。\n\n```python\ndef remove(self, name: str, force: bool = False, complete_task: bool = False) -> str:\n self._run_git([\"worktree\", \"remove\", wt[\"path\"]])\n if complete_task and wt.get(\"task_id\") is not None:\n self.tasks.update(wt[\"task_id\"], status=\"completed\")\n self.tasks.unbind_worktree(wt[\"task_id\"])\n self.events.emit(\"task.completed\", ...)\n```\n\n7. `.worktrees/events.jsonl` にライフサイクルイベントが append-only で記録される。重要な遷移には `before / after / failed` の三段イベントが出力される。\n\n```json\n{\n \"event\": \"worktree.remove.after\",\n \"task\": {\"id\": 7, \"status\": \"completed\"},\n \"worktree\": {\Line truncated
"content": "# s12: Worktree + Task Isolation\n\n> *\"Isolate by directory, coordinate by task ID\"* -- タスクボード + worktreeレーンで並行作業を分離する。\n\n## 問題\n\ns11までにエージェントはタスクを自律的に確保して完了できるようになった。しかし全タスクが1つの共有ディレクトリで走る。2つのエージェントが同時に異なるモジュールをリファクタリングすると衝突する: 片方が`config.py`を編集し、もう片方も`config.py`を編集し、未コミットの変更が混ざり合い、どちらもクリーンにロールバックできない。\n\nタスクボードは*何をやるか*を追跡するが、*どこでやるか*には関知しない。解決策: 各タスクに専用のgit worktreeディレクトリを与える。タスクが目標を管理し、worktreeが実行コンテキストを管理する。タスクIDで紐付ける。\n\n## 解決策\n\n```\nControl plane (.tasks/) Execution plane (.worktrees/)\n+------------------+ +------------------------+\n| task_1.json | | auth-refactor/ |\n| status: in_progress <------> branch: wt/auth-refactor\n| worktree: \"auth-refactor\" | task_id: 1 |\n+------------------+ +------------------------+\n| task_2.json | | ui-login/ |\n| status: pending <------> branch: wt/ui-login\n| worktree: \"ui-login\" | task_id: 2 |\n+------------------+ +------------------------+\n |\n index.json (worktree registry)\n events.jsonl (lifecycle log)\n\nState machines:\n Task: pending -> in_progress -> completed\n Worktree: absent -> active -> removed | kept\n```\n\n## 仕組み\n\n1. **タスクを作成する。** まず目標を永続化する。\n\n```python\nTASKS.create(\"Implement auth refactor\")\n# -> .tasks/task_1.json status=pending worktree=\"\"\n```\n\n2. **worktreeを作成してタスクに紐付ける。** `task_id`を渡すと、タスクが自動的に`in_progress`に遷移する。\n\n```python\nWORKTREES.create(\"auth-refactor\", task_id=1)\n# -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD\n# -> index.json gets new entry, task_1.json gets worktree=\"auth-refactor\"\n```\n\n紐付けは両側に状態を書き込む:\n\n```python\ndef bind_worktree(self, task_id, worktree):\n task = self._load(task_id)\n task[\"worktree\"] = worktree\n if task[\"status\"] == \"pending\":\n task[\"status\"] = \"in_progress\"\n self._save(task)\n```\n\n3. **worktree内でコマンドを実行する。** `cwd`が分離ディレクトリを指す。\n\n```python\nsubprocess.run(command, shell=True, cwd=worktree_path,\n capture_output=True, text=True, timeout=300)\n```\n\n4. **終了処理。** 2つの選択肢:\n - `worktree_keep(name)` -- ディレクトリを保持する。\n - `worktree_remove(name, complete_task=True)` -- ディレクトリを削除し、紐付けられたタスクを完了し、イベントを発行する。1回の呼び出しで後片付けと完了を処理する。\n\n```python\ndef remove(self, name, force=False, complete_task=False):\n self._run_git([\"worktree\", \"remove\", wt[\"path\"]])\n if complete_task and wt.get(\"task_id\") is not None:\n self.tasks.update(wt[\"task_id\"], status=\"completed\")\n self.tasks.unbind_worktree(wt[\"task_id\"])\n self.events.emit(\"task.completed\", ...)\n```\n\n5. **イベントストリーム。** ライフサイクルの各ステップが`.worktrees/events.jsonl`に記録される:\n\n```json\n{\n \"event\": \"worktree.remove.after\",\n \"task\": {\"id\": 1, \"status\": \"completed\"},\n \"worktree\": {\"name\": \"auth-refactor\", \"status\": \"removed\"},\n \"ts\": 1730000000\n}\n```\n\n発行されるイベント: `worktree.create.before/after/failed`, `worktree.remove.before/after/failed`, `worktree.keep`, `task.completed`。\n\nクラッシュ後も`.tasks/` + `.worktrees/index.json`から状態を再構築できる。会話メモリは揮発性だが、ファイル状態は永続的だ。\n\n## s11からの変更点\n\n| Component | Before (s11) | After (s12) |\n|--------------------|----------------------------|----------------------------------------------|\n| Coordination | Task board (owner/status) | Task board + explicit worktree binding |\n| Execution scope | Shared directory | Task-scoped isolated directory |\n| Recoverability | Task status only | Task status + worktree index |\n| Teardown | Task completion | Task completion + explicit keep/remove |\n| Lifecycle visibility | Implicit inLine truncated
}
]