"alternatives":"We could have started with a richer toolset (file I/O, HTTP, database), but that would obscure the core insight: an LLM with a shell is already a general-purpose agent. Starting minimal also makes it obvious what each subsequent version actually adds.",
"title":"Recursive Process Spawning as Subagent Mechanism",
"description":"When the agent runs `python v0.py \"subtask\"`, it spawns a completely new process with a fresh LLM context. This child process is effectively a subagent: it has its own system prompt, its own conversation history, and its own task focus. When it finishes, the parent gets the stdout result. This is subagent delegation without any framework -- just Unix process semantics. Each child process naturally isolates concerns because it literally cannot see the parent's context.",
"description":"When the agent runs `java -jar agent.jar \"subtask\"`, it uses ProcessBuilder to spawn a completely new JVM process with a fresh LLM context. This child process is effectively a subagent: it has its own system prompt, its own conversation history, and its own task focus. When it finishes, the parent gets the stdout result. This is subagent delegation without any framework -- just process semantics. Each child process naturally isolates concerns because it literally cannot see the parent's context.",
"alternatives":"A framework-level subagent system (like v3's Task tool) gives more control over what tools the subagent can access and how results are returned. But at v0, the point is to show that process spawning is the most primitive form of agent delegation -- no shared memory, no message passing, just stdin/stdout.",
"alternatives":"Later versions (v2) add explicit planning via TodoWrite. But v0 proves that implicit planning through the model's reasoning is sufficient for many tasks. The planning framework only becomes necessary when you need external visibility into the agent's intentions.",
"alternatives":"We could add specialized tools (list_directory, search_files, http_request), and later versions do. But at this stage, bash already covers those use cases. The split from v0's single tool to v1's four tools is specifically about giving the model structured I/O for file operations, where bash's quoting and escaping often trips up the model.",
"alternatives":"Many agent frameworks add elaborate orchestration layers: ReAct loops with explicit Thought/Action/Observation parsing, LangChain-style chains, AutoGPT-style goal decomposition. These frameworks assume the model needs scaffolding to behave as an agent. Our approach assumes the model already knows how to be an agent -- it just needs tools to act on the world.",
"alternatives":"Some agent systems let the model output free-form text that gets parsed with regex or heuristics (e.g., extracting code from markdown blocks). This is fragile -- the model might format output slightly differently and break the parser. JSON schemas trade flexibility for reliability.",
"alternatives":"The model could plan internally via chain-of-thought reasoning (as it does in v0/v1). Internal planning works but is invisible and ephemeral -- once the thinking scrolls out of context, the plan is lost. Claude's extended thinking is another option, but it's not inspectable by the user or by downstream tools.",
"alternatives":"Allowing multiple in-progress items would let the agent context-switch between tasks, which seems more flexible. In practice, LLMs handle context-switching poorly -- they lose track of which task they were working on and mix up details between tasks. The single-focus constraint is a guardrail that improves output quality.",
"alternatives":"No cap would give the model full flexibility, but in practice leads to absurdly detailed plans. A dynamic cap (proportional to task complexity) would be smarter but adds complexity. The fixed cap of 20 is a simple heuristic that works well empirically -- most real coding tasks can be expressed in 5-15 meaningful steps.",
"alternatives":"Sharing the parent's full context would give the subagent more information, but it would also flood the subagent with irrelevant details. Context window is finite -- filling it with parent history leaves less room for the subagent's own work. Fork-based approaches (copy the parent context) are a middle ground but still waste tokens on irrelevant history.",
"alternatives":"Giving all subagents full tool access is simpler to implement but violates least privilege. A permission-request system (subagent asks parent for write access) adds complexity and latency. Static tool filtering by role is the pragmatic middle ground -- simple to implement, effective at preventing accidents.",
"zh":{
"title":"Explore 代理不能写入文件",
"description":"创建 Explore 类型的子代理时,它只获得只读工具:bash(有限制)、read_file 和搜索工具,不能调用 write_file 或 edit_file。这实现了最小权限原则:一个被委派'查找函数 X 所有使用位置'的代理不需要写权限。移除写工具消除了探索过程中误修改文件的风险,同时缩小了工具空间,让模型在更少的选项中做出更好的决策。"
"description":"创建 Explore 类型的子代理时,它只获得只读工具:bash(有限制)、read_file 和搜索工具,不能调用 write_file 或 edit_file。这实现了最小权限原则:一个被委派'查找函数 X 所有使用位置'的代理不需要写权限。移除写工具消除了探索过程中误修改文件的风险,同时缩小了工具空间,让模型在更少的选项中做出更好的决策。",
"alternatives":"Allowing recursive delegation (bounded by depth) would handle deeply nested tasks but adds complexity and the risk of runaway token consumption. In practice, single-level delegation covers most real-world coding tasks. Multi-level delegation is addressed in later versions (v6+) through persistent team structures instead of recursive spawning.",
"alternatives":"Injecting skills into the system prompt is simpler and gives skills higher priority in the model's attention. But it breaks prompt caching (every skill load creates a new system prompt variant) and bloats the system prompt over time as skills accumulate. The tool_result approach keeps things cache-friendly at the cost of slightly lower attention priority.",
"title":"On-Demand Skill Loading Instead of Upfront",
"description":"Skills are not loaded at startup. The agent starts with only the skill names and descriptions (from frontmatter). When the agent decides it needs a specific skill, it calls the Skill tool, which loads the full SKILL.md body into context. This keeps the initial prompt small and focused. An agent solving a Python bug doesn't need the Kubernetes deployment skill loaded -- that would waste context window space and potentially confuse the model with irrelevant instructions.",
"description":"Skills are not loaded at startup. The agent starts with only the skill names and descriptions (from frontmatter). When the agent decides it needs a specific skill, it calls the Skill tool, which loads the full SKILL.md body into context. This keeps the initial prompt small and focused. An agent solving a Spring Boot bug doesn't need the Kubernetes deployment skill loaded -- that would waste context window space and potentially confuse the model with irrelevant instructions.",
"alternatives":"Loading all skills upfront guarantees the model always has all knowledge available, but wastes tokens on irrelevant skills and may hit context limits. A recommendation system (model suggests skills, human approves) adds latency. Lazy loading lets the model self-serve the knowledge it needs, when it needs it.",
"alternatives":"A separate metadata file (skill.yaml + skill.md) would work but doubles the number of files. Embedding metadata in the markdown (as headings or comments) requires parsing the full file to extract metadata. Frontmatter is a well-established convention (Jekyll, Hugo, Astro) that keeps metadata and content co-located but separately parseable.",
"alternatives":"A single compression strategy (e.g., always summarize at 80% capacity) would be simpler but wasteful -- most of the time, microcompact alone keeps things manageable. A sliding window (drop oldest N messages) is cheap but loses important context. The three-layer approach gives the best token efficiency: cheap cleanup constantly, expensive summarization rarely.",
"alternatives":"単一の圧縮戦略(例:常に80%の容量で要約)はシンプルですが無駄です――ほとんどの場合、マイクロコンパクトだけで管理可能です。スライディングウィンドウ(最も古い N 件を破棄)は安価ですが重要なコンテキストを失います。3層アプローチは最良のトークン効率を提供します:安価なクリーンアップを常時、高価な要約をまれに実行します。"
}
},
{
@@ -22,11 +24,13 @@
"alternatives":"A percentage-based threshold (compress when context is 80% full) adapts to different context window sizes but doesn't account for the fixed cost of generating a summary. A fixed threshold of 10K would compress more aggressively but often isn't worth it. The 20K value was chosen empirically: it's the point where compression savings consistently outweigh the quality loss from summarization.",
"zh":{
"title":"最小节省量 = 20,000 Token 才触发压缩",
"description":"自动压缩仅在估算节省量(当前 token 数减去预估摘要大小)超过 20,000 token 时才触发。压缩不是免费的:摘要本身会消耗 token,还有生成摘要的 API 调用成本。如果对话只有 25,000 token,压缩可能节省 5,000 token,但需要一次 API 调用,且产出的摘要可能不如原文连贯。20K 的阈值确保只在节省量明显超过开销时才进行压缩。"
"description":"自动压缩仅在估算节省量(当前 token 数减去预估摘要大小)超过 20,000 token 时才触发。压缩不是免费的:摘要本身会消耗 token,还有生成摘要的 API 调用成本。如果对话只有 25,000 token,压缩可能节省 5,000 token,但需要一次 API 调用,且产出的摘要可能不如原文连贯。20K 的阈值确保只在节省量明显超过开销时才进行压缩。",
"description":"自動コンパクトは推定節約量(現在のトークン数マイナス推定要約サイズ)が20,000トークンを超えた場合にのみ発動します。圧縮は無料ではありません:要約自体がトークンを消費し、さらに生成のための API コール費用がかかります。会話が25,000トークンしかない場合、圧縮で5,000トークン節約できても、API コールが必要で元の会話より一貫性の低い要約になる可能性があります。20K の閾値は、節約量がオーバーヘッドを確実に上回る場合にのみ圧縮を実行することを保証します。"
"description":"自動コンパクトは推定節約量(現在のトークン数マイナス推定要約サイズ)が20,000トークンを超えた場合にのみ発動します。圧縮は無料ではありません:要約自体がトークンを消費し、さらに生成のための API コール費用がかかります。会話が25,000トークンしかない場合、圧縮で5,000トークン節約できても、API コールが必要で元の会話より一貫性の低い要約になる可能性があります。20K の閾値は、節約量がオーバーヘッドを確実に上回る場合にのみ圧縮を実行することを保証します。",
"alternatives":"Keeping the last 5-10 messages alongside the summary preserves recent detail and gives the model more to work with. But it creates the overlap problem described above, and makes the total context size less predictable. Some systems use a 'sliding window + summary' approach which works but requires careful tuning of the overlap region.",
"zh":{
"title":"摘要替换全部消息,而非保留部分历史",
"description":"自动压缩触发时,生成摘要并替换全部消息历史,不会在摘要旁保留最近的 N 条消息。这避免了一个微妙的连贯性问题:如果同时保留近期消息和旧消息的摘要,模型会看到重叠内容的两种表示。摘要可能说'我们决定使用方案 X',而近期消息仍在展示讨论过程,产生矛盾信号。干净的摘要是一个连贯的单一叙述。"
"description":"自动压缩触发时,生成摘要并替换全部消息历史,不会在摘要旁保留最近的 N 条消息。这避免了一个微妙的连贯性问题:如果同时保留近期消息和旧消息的摘要,模型会看到重叠内容的两种表示。摘要可能说'我们决定使用方案 X',而近期消息仍在展示讨论过程,产生矛盾信号。干净的摘要是一个连贯的单一叙述。",
"description":"自動コンパクトが発動すると、要約を生成してメッセージ履歴の全体をその要約で置換します。要約と並べて直近 N 件のメッセージを保持することはしません。これにより微妙な一貫性の問題を回避します:直近のメッセージと古いメッセージの要約を併存させると、モデルは重複するコンテンツの2つの表現を見ることになります。要約が「アプローチ X を使うことに決めた」と言う一方で、直近のメッセージにはまだ検討過程が表示されているかもしれず、矛盾するシグナルを生じます。クリーンな要約は単一の一貫した物語です。"
"description":"自動コンパクトが発動すると、要約を生成してメッセージ履歴の全体をその要約で置換します。要約と並べて直近 N 件のメッセージを保持することはしません。これにより微妙な一貫性の問題を回避します:直近のメッセージと古いメッセージの要約を併存させると、モデルは重複するコンテンツの2つの表現を見ることになります。要約が「アプローチ X を使うことに決めた」と言う一方で、直近のメッセージにはまだ検討過程が表示されているかもしれず、矛盾するシグナルを生じます。クリーンな要約は単一の一貫した物語です。",
"alternatives":"Not archiving saves disk space but makes debugging hard -- when the agent makes a mistake, you can't see what it was 'thinking' 200 messages ago because that context was compressed away. Database storage (SQLite) would provide queryability but adds a dependency. JSONL is the simplest format that supports append-only writes and line-by-line processing.",
"alternatives":"In-memory storage (like v2's TodoWrite) is simpler and faster but loses state on crash and doesn't work across multiple agent processes. A proper database (SQLite, Redis) would provide ACID guarantees and better concurrency, but adds a dependency and operational complexity. Files are the zero-dependency persistence layer that works everywhere.",
"alternatives":"Simple priority ordering (high/medium/low) doesn't capture 'task B literally cannot start until task A finishes.' A centralized coordinator that assigns tasks in order would work but creates a single point of failure and bottleneck. Declarative dependencies let each agent independently determine what it can work on by reading the task files.",
"zh":{
"title":"任务具有 blocks/blockedBy 依赖字段",
"description":"每个任务可以声明它阻塞哪些任务(下游依赖)以及它被哪些任务阻塞(上游依赖)。Agent 不会开始有未解决 blockedBy 依赖的任务。这对多代理协调至关重要:当 Agent A 在编写数据库 schema、Agent B 需要写查询时,Agent B 的任务被 Agent A 的任务阻塞。没有依赖关系,两个 agent 可能同时开始,而 Agent B 会针对一个尚不存在的 schema 工作。"
"description":"每个任务可以声明它阻塞哪些任务(下游依赖)以及它被哪些任务阻塞(上游依赖)。Agent 不会开始有未解决 blockedBy 依赖的任务。这对多代理协调至关重要:当 Agent A 在编写数据库 schema、Agent B 需要写查询时,Agent B 的任务被 Agent A 的任务阻塞。没有依赖关系,两个 agent 可能同时开始,而 Agent B 会针对一个尚不存在的 schema 工作。",
"alternatives":"简单的优先级排序(高/中/低)无法表达'任务 B 必须等任务 A 完成才能开始'。中央协调器按顺序分配任务可行,但会形成单点故障和瓶颈。声明式依赖让每个 agent 通过读取任务文件独立判断可以处理什么。"
},
"ja":{
"title":"タスクに blocks/blockedBy 依存関係フィールド",
"description":"各タスクは、自分がブロックするタスク(下流の依存先)と、自分をブロックするタスク(上流の依存元)を宣言できます。エージェントは未解決の blockedBy 依存がある タスクを開始しません。これはマルチエージェント連携に不可欠です:エージェント A がデータベーススキーマを書いていてエージェント B がそれに対するクエリを書く必要がある場合、B のタスクは A のタスクにブロックされます。依存関係がなければ両エージェントが同時に開始し、B はまだ存在しないスキーマに対して作業することになります。"
"description":"各タスクは、自分がブロックするタスク(下流の依存先)と、自分をブロックするタスク(上流の依存元)を宣言できます。エージェントは未解決の blockedBy 依存がある タスクを開始しません。これはマルチエージェント連携に不可欠です:エージェント A がデータベーススキーマを書いていてエージェント B がそれに対するクエリを書く必要がある場合、B のタスクは A のタスクにブロックされます。依存関係がなければ両エージェントが同時に開始し、B はまだ存在しないスキーマに対して作業することになります。",
"alternatives":"単純な優先度順序(高/中/低)では「タスク B はタスク A が完了するまで文字通り開始できない」を表現できません。タスクを順番に割り当てる中央コーディネーターは機能しますが、単一障害点とボトルネックを生みます。宣言的な依存関係により、各エージェントがタスクファイルを読んで独立に作業可能なものを判断できます。"
}
},
{
@@ -36,11 +40,13 @@
"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 仍适合短、线性、一次性的轻量跟踪。"
"description":"TaskManager 延续了 Todo 的心智模型,并在本课程 s07 之后成为默认主线。两者都管理带状态的任务项,但 TaskManager 增加了文件持久化(崩溃后可恢复)、依赖追踪(blocks/blockedBy)、owner 字段与多进程协作能力。Todo 仍适合短、线性、一次性的轻量跟踪。",
"alternatives":"仅使用 Todo 保持模型最小化,但对长期或协作任务能力不足。全部使用 Task 最大化一致性,但对微小的一次性任务显得过重。"
},
"ja":{
"title":"Task を主線にしつつ Todo も併存",
"description":"TaskManager は Todo のメンタルモデルを拡張し、本コースでは s07 以降のデフォルトになる。どちらもステータス付き作業項目を扱うが、TaskManager にはファイル永続化(クラッシュ耐性)、依存関係追跡(blocks/blockedBy)、owner、マルチプロセス協調がある。Todo は短く直線的な単発作業では引き続き有効。"
"description":"TaskManager は Todo のメンタルモデルを拡張し、本コースでは s07 以降のデフォルトになる。どちらもステータス付き作業項目を扱うが、TaskManager にはファイル永続化(クラッシュ耐性)、依存関係追跡(blocks/blockedBy)、owner、マルチプロセス協調がある。Todo は短く直線的な単発作業では引き続き有効。",
"alternatives":"Blind overwrite writes are simpler but can corrupt coordination state under parallel execution. A database with optimistic locking would enforce stronger safety, but the course keeps file-based state for zero-dependency teaching.",
"title":"threading.Queue as the Notification Bus",
"description":"Background task results are delivered via a threading.Queue instead of direct callbacks. The background thread puts a notification on the queue when its work completes. The main agent loop polls the queue before each LLM call. This decoupling is important: the background thread doesn't need to know anything about the main loop's state or timing. It just drops a message on the queue and moves on. The main loop picks it up at its own pace -- never mid-API-call, never mid-tool-execution. No race conditions, no callback hell.",
"alternatives":"Direct callbacks (background thread calls a function in the main thread) would deliver results faster but create thread-safety issues -- the callback might fire while the main thread is in the middle of building a request. Event-driven systems (asyncio, event emitters) work but add complexity. A queue is the simplest thread-safe communication primitive.",
"title":"ConcurrentLinkedQueue as the Notification Bus",
"description":"Background task results are delivered via a ConcurrentLinkedQueue instead of direct callbacks. The background thread puts a notification on the queue when its work completes. The main agent loop polls the queue before each LLM call. This decoupling is important: the background thread doesn't need to know anything about the main loop's state or timing. It just drops a message on the queue and moves on. The main loop picks it up at its own pace -- never mid-API-call, never mid-tool-execution. No race conditions, no callback hell.",
"alternatives":"Direct callbacks (background thread calls a function in the main thread) would deliver results faster but create thread-safety issues -- the callback might fire while the main thread is in the middle of building a request. Event-driven systems (CompletableFuture, event emitters) work but add complexity. A queue is the simplest thread-safe communication primitive.",
"zh":{
"title":"用 threading.Queue 作为通知总线",
"description":"后台任务结果通过 threading.Queue 传递,而非直接回调。后台线程在工作完成时向队列放入通知,主 agent 循环在每次 LLM 调用前轮询队列。这种解耦很重要:后台线程无需了解主循环的状态或时序,只需往队列放入消息然后继续。主循环按自己的节奏取出消息——永远不会在 API 调用中途或工具执行中途。没有竞争条件,没有回调地狱。"
"title":"用 ConcurrentLinkedQueue 作为通知总线",
"description":"后台任务结果通过 ConcurrentLinkedQueue 传递,而非直接回调。后台线程在工作完成时向队列放入通知,主 agent 循环在每次 LLM 调用前轮询队列。这种解耦很重要:后台线程无需了解主循环的状态或时序,只需往队列放入消息然后继续。主循环按自己的节奏取出消息——永远不会在 API 调用中途或工具执行中途。没有竞争条件,没有回调地狱。",
"description":"Background task threads are created with daemon=True. In Python, daemon threads are killed automatically when the main thread exits. This prevents a common problem: if the main agent completes its work and exits, but a background thread is still running (waiting on a long API call, stuck in a loop), the process would hang indefinitely. With daemon threads, exit is clean -- the main thread finishes, all daemon threads die, process exits. No zombie processes, no cleanup code needed.",
"alternatives":"Non-daemon threads with explicit cleanup (join with timeout, then terminate) give more control over shutdown but require careful lifecycle management. Process-based parallelism (multiprocessing) provides stronger isolation but higher overhead. Daemon threads are the pragmatic choice: minimal code, correct behavior in the common case.",
"title":"Background Tasks Run as Virtual Threads",
"description":"Background tasks are launched as virtual threads via Thread.startVirtualThread(). In Java 21+, virtual threads are lightweight, managed by the JVM, and do not prevent the main thread from exiting. This prevents a common problem: if the main agent completes its work and exits, but a background thread is still running (waiting on a long API call, stuck in a loop), the process would hang indefinitely. With virtual threads (which are daemon by default), exit is clean -- the main thread finishes, all virtual threads are terminated, process exits. No zombie processes, no cleanup code needed.",
"alternatives":"Platform threads with explicit cleanup (join with timeout, then interrupt) give more control over shutdown but require careful lifecycle management. Process-based parallelism (ProcessBuilder) provides stronger isolation but higher overhead. Virtual threads are the pragmatic choice: minimal code, correct behavior in the common case.",
"zh":{
"title":"后台任务以守护线程运行",
"description":"后台任务线程以 daemon=True 创建。在 Python 中,守护线程在主线程退出时自动被终止。这防止了一个常见问题:如果主 agent 完成工作并退出,但后台线程仍在运行(等待一个长时间 API 调用或陷入循环),进程会无限挂起。使用守护线程,退出是干净的——主线程结束,所有守护线程自动终止,进程退出。没有僵尸进程,不需要清理代码。"
"title":"Structured Notification Format with Type Tags",
"description":"Notifications from background tasks use a structured format: {\"type\": \"attachment\", \"attachment\": {status, result, ...}} instead of plain text strings. The type tag lets the main loop handle different notification types differently: an 'attachment' might be injected into the conversation as a tool_result, while a 'status_update' might just update a progress indicator. Machine-readable notifications also enable programmatic filtering (show only errors, suppress progress updates) and UI rendering (display status as a progress bar, not raw text).",
"alternatives":"Plain text notifications are simpler but lose structure. The main loop would have to parse free-form text to determine what happened, which is fragile. A class hierarchy (StatusNotification, ResultNotification, ErrorNotification) is more Pythonic but less portable -- JSON structures work the same way regardless of language or serialization format.",
"alternatives":"Plain text notifications are simpler but lose structure. The main loop would have to parse free-form text to determine what happened, which is fragile. A class hierarchy (StatusNotification, ResultNotification, ErrorNotification) is more idiomatic in Java but less portable -- JSON structures work the same way regardless of language or serialization format.",
"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.",
"description":"s04 ではサブエージェントは一時的です:生成、1つのタスクを実行、結果を返却、消滅。その知識も一緒に消えます。s09 ではチームメイトはアイデンティティ(名前、役割)と設定ファイルを持つ永続的なスレッドです。チームメイトはタスク A を完了した後、学んだ全てを引き継いでタスク B に割り当てられます。永続的なチームメイトはプロジェクトの知識を蓄積し、確立されたパターンを理解し、タスクごとに同じファイルを再読する必要がありません。"
"description":"s04 ではサブエージェントは一時的です:生成、1つのタスクを実行、結果を返却、消滅。その知識も一緒に消えます。s09 ではチームメイトはアイデンティティ(名前、役割)と設定ファイルを持つ永続的なスレッドです。チームメイトはタスク A を完了した後、学んだ全てを引き継いでタスク B に割り当てられます。永続的なチームメイトはプロジェクトの知識を蓄積し、確立されたパターンを理解し、タスクごとに同じファイルを再読する必要がありません。",
"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.",
"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.",
"title":"JSONL Inbox Files Instead of Shared Memory",
"description":"Each teammate has its own inbox file (a JSONL file in the team directory). Sending a message means appending a JSON line to the recipient's inbox file. Reading messages means reading the inbox file and tracking which line was last read. JSONL is append-only by nature, which means concurrent writers don't corrupt each other's data (appends to different file positions). This works across processes without any shared memory, mutex, or IPC mechanism. It's also crash-safe: if the writer crashes mid-append, the worst case is one partial line that the reader can skip.",
"alternatives":"Shared memory (Python multiprocessing.Queue) would be faster but doesn't work if agents are separate processes launched independently. A message broker (Redis, RabbitMQ) provides robust pub/sub but adds infrastructure dependencies. Unix domain sockets would work but are harder to debug (no human-readable message log). JSONL files are the simplest approach that provides persistence, cross-process communication, and debuggability.",
"alternatives":"Shared memory (Java ConcurrentLinkedQueue or BlockingQueue) would be faster but doesn't work if agents are separate processes launched independently. A message broker (Redis, RabbitMQ) provides robust pub/sub but adds infrastructure dependencies. Unix domain sockets would work but are harder to debug (no human-readable message log). JSONL files are the simplest approach that provides persistence, cross-process communication, and debuggability.",
"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.",
"alternatives":"Checking inbox after each tool execution would be more responsive but adds overhead to every tool call, which is more frequent than LLM calls. A separate watcher thread could monitor the inbox continuously but adds threading complexity. Checking once per LLM call is the pragmatic sweet spot: responsive enough for coordination, cheap enough to not impact performance.",
"description":"チームメイトはエージェントループの各イテレーションの冒頭、LLM API を呼び出す前にインボックスファイルを確認します。これにより受信メッセージへの応答性を最大化します:シャットダウンリクエストは1ループイテレーション以内(通常数秒)で確認され、現在のタスク完了後(数分かかる可能性)ではありません。"
"description":"チームメイトはエージェントループの各イテレーションの冒頭、LLM API を呼び出す前にインボックスファイルを確認します。これにより受信メッセージへの応答性を最大化します:シャットダウンリクエストは1ループイテレーション以内(通常数秒)で確認され、現在のタスク完了後(数分かかる可能性)ではありません。",
"alternatives":"Event-driven notification (file watchers via inotify/fsevents, or a pub/sub channel) would reduce latency from seconds to milliseconds. But file watchers are platform-specific and unreliable across network filesystems. A message broker would work but adds infrastructure. For a system where tasks take minutes to complete, discovering new tasks in 1 second instead of 10 milliseconds makes no practical difference.",
"alternatives":"No timeout (wait forever) risks zombie processes. A very short timeout (5s) causes premature exits when the lead is simply thinking or typing. A heartbeat system (lead periodically pings teammates to keep them alive) works but adds protocol complexity. The 60-second fixed timeout is a good default that balances false-positive exits against resource waste.",
"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.",
"alternatives":"A single shared workspace is simpler but causes edit collisions and mixed git state. Fully independent task stores per lane avoid collisions but lose team-level visibility and make planning harder.",
"alternatives":"Relying only on `git worktree list` removes local bookkeeping but loses task binding metadata and custom lifecycle states. Keeping all state only in memory is simpler in code but breaks recoverability.",
"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.",
"alternatives":"Relying only on console logs is lighter but fragile during long sessions and hard to audit. A full event bus infrastructure is powerful but heavier than needed for this teaching baseline.",
"alternatives":"Keeping closeout fully manual gives flexibility but increases operational drift. Fully automatic removal on every completion risks deleting a workspace before final review.",
"alternatives":"Using logs alone hides structured transitions; using events as the only state source risks drift when replay/repair semantics are undefined.",
"content":"# s01: The Agent Loop\n\n`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\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 -- 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 -- without changing the loop.\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"
"content":"# s01: The Agent Loop\r\n\r\n`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"One loop & Bash is all you need\"* -- one tool + one loop = an agent.\r\n>\r\n> **Harness layer**: The loop -- the model's first connection to the real world.\r\n\r\n## Problem\r\n\r\nA language model can reason about code, but it can't *touch* the real world -- 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.\r\n\r\n## Solution\r\n\r\n```\r\n+--------+ +-------+ +---------+\r\n| User | ---> | LLM | ---> | Tool |\r\n| prompt | | | | execute |\r\n+--------+ +---+---+ +----+----+\r\n ^ |\r\n | tool_result |\r\n +----------------+\r\n (ChatClient.call() auto-loops until no tool calls)\r\n```\r\n\r\nA single `call()` invocation controls the entire flow. Spring AI loops automatically until the model stops calling tools.\r\n\r\n## How It Works\r\n\r\n### 1. Build ChatClient: Inject Model + Register Tools\r\n\r\nInject `ChatModel` via Spring Boot auto-configuration, build the client with `ChatClient.builder()`, set the system prompt and tools.\r\n\r\n```java\r\n// TIP: The Python version creates client = Anthropic() and MODEL at module level.\r\n// Spring AI injects ChatModel via auto-configuration, then builds ChatClient with builder.\r\npublic S01AgentLoop(ChatModel chatModel) {\r\n this.chatClient = ChatClient.builder(chatModel)\r\n .defaultSystem(\"You are a coding agent at \" + System.getProperty(\"user.dir\")\r\n + \". Use bash to solve tasks. Act, don't explain.\")\r\n .defaultTools(new BashTool()) // Tool object with @Tool annotation\r\n .build();\r\n}\r\n```\r\n\r\n### 2. `@Tool` Annotation: Declarative Tool Registration\r\n\r\nSpring AI automatically discovers and registers tools via the `@Tool` annotation. At startup, the framework scans objects passed to `defaultTools()`, extracts all `@Tool` method signatures and descriptions, generates the tool schema the LLM needs (name, parameters, description), and automatically includes it in every `call()` request.\r\n\r\n```java\r\n// BashTool -- corresponds to the Python version's run_bash() function\r\npublic class BashTool {\r\n @Tool(description = \"Run a shell command and return stdout + stderr\")\r\n public String bash(@ToolParam(description = \"The shell command to execute\")\r\n String command) {\r\n // Dangerous command check + ProcessBuilder execution + timeout control + output truncation\r\n // ...\r\n }\r\n}\r\n```\r\n\r\n> Comparison with Python's manual registration:\r\n> - Python: `TOOLS = [{\"name\": \"bash\", \"input_schema\": {...}}]` + `TOOL_HANDLERS = {\"bash\": run_bash}`\r\n> - Java: Just `@Tool` + `@ToolParam` annotations; the framework auto-generates schemas and dispatches methods\r\n\r\n### 3. Spring AI Internal Auto-Loop: How `call()` Works Under the Hood\r\n\r\n**This is the most critical difference between the Java and Python versions.** The Python version requires a hand-written while loop to drive tool calls:\r\n\r\n```python\r\n# Python version -- manual loop\r\ndef agent_loop(messages):\r\n while True:\r\n response = client.messages.create(model=MODEL, messages=messages, tools=TOOLS)\r\n # Collect assistant message\r\n messages.append({\"role\": \"assistant\", \"content\": response.content})\r\n if response.stop_reason != \"tool_use\":\r\n return response # Model no longer calling tools, exit loop\r\n # Execute tools and feed back results\r\n for block in response.content:\r\n if block.type == \"tool_use\":\r\n result = TOOL_HANDLERS[block.name](block.input)\r\n messages.append({\"role\": \"user\", \"content\": [{\"type\": \"tool_result\",...}]})\r\n```\r\n\r\nSpringAI's`ChatClient.call()`**encapsulatesfullyequivalentlogicinternally**:\r\n\r\n```\r\ncall()internalflow:\r\n┌─────────────────────────────────────────────────────┐\r\n│1.Assemblerequest:systemprompt+usermsg+tools│\r\n│2.SendtoLLM│\r\n│3.Parseresponse│\r\n│├──Hastool_use?──→Yes:│\r\n││a.Extracttoolnameandarguments│\r\n││b.Invokecorresponding@Toolmethodviareflection│\r\n││c.Appendtool_resulttomessagelist│\r\n││d.Gobacktostep2(auto-loop)│\r\n│└──No──→ReturnfinaltextLine truncated
},
{
"version":"s02",
"locale":"en",
"title":"s02: Tool Use",
"content":"# s02: Tool Use\n\n`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"Adding a tool means adding one handler\"* -- the loop stays the same; new tools register into the dispatch map.\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"
"content":"# s02: Tool Use\r\n\r\n`s01 > [ s02 ] s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"Adding a tool means adding one @Tool method\"* -- the loop stays the same; new tools are passed into `defaultTools()`.\r\n>\r\n> **Harness layer**: Tool dispatch -- expanding what the model can reach.\r\n\r\n## Problem\r\n\r\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 (`read_file`, `write_file`) let you enforce path sandboxing at the tool level.\r\n\r\nThe key insight: adding tools does not require changing the loop.\r\n\r\n## Solution\r\n\r\n```\r\n+--------+ +-------+ +--------------------+\r\n| User | ---> | LLM | ---> | defaultTools() |\r\n| prompt | | | | { |\r\n+--------+ +---+---+ | BashTool |\r\n ^ | ReadFileTool |\r\n | | WriteFileTool |\r\n +-----------+ EditFileTool |\r\n tool_result | } |\r\n +--------------------+\r\n\r\nSpring AI auto-registers and dispatches via @Tool annotations.\r\nNo hand-written dispatch map needed -- the framework scans annotated methods on tool objects.\r\n```\r\n\r\n## How It Works\r\n\r\n1. Each tool is a standalone class declared with `@Tool` annotation. `PathValidator` provides path sandboxing to prevent workspace escape.\r\n\r\n```java\r\n// PathValidator -- corresponds to the Python version's safe_path() function\r\npublic class PathValidator {\r\n private final Path workDir;\r\n\r\n public Path resolve(String relativePath) {\r\n Path resolved = workDir.resolve(relativePath).toAbsolutePath().normalize();\r\n if (!resolved.startsWith(workDir)) {\r\n throw new IllegalArgumentException(\"Path escapes workspace: \" + relativePath);\r\n }\r\n return resolved;\r\n }\r\n}\r\n\r\n// ReadFileTool -- corresponds to the Python version's run_read() function\r\npublic class ReadFileTool {\r\n private final PathValidator pathValidator;\r\n\r\n @Tool(description = \"Read file contents. Optionally limit the number of lines returned.\")\r\n public String readFile(\r\n @ToolParam(description = \"Relative path to the file\") String path,\r\n @ToolParam(description = \"Maximum number of lines to read\", required = false) Integer limit) {\r\n Path filePath = pathValidator.resolve(path);\r\n List<String> lines = Files.readAllLines(filePath);\r\n if (limit != null && limit > 0 && limit < lines.size()) {\r\n lines = lines.subList(0, limit);\r\n }\r\n return String.join(\"\\n\", lines);\r\n }\r\n}\r\n```\r\n\r\n2. Tool registration simply passes objects to `defaultTools()`. Spring AI scans `@Tool` annotated methods and automatically handles name mapping and parameter binding.\r\n\r\n```java\r\n// Corresponds to the Python version's TOOL_HANDLERS dict\r\n// Python: TOOL_HANDLERS = {\"bash\": fn, \"read_file\": fn, \"write_file\": fn, \"edit_file\": fn}\r\n// Java: Just pass tool objects; @Tool annotations handle auto-registration\r\nthis.chatClient = ChatClient.builder(chatModel)\r\n .defaultSystem(\"You are a coding agent ...\")\r\n .defaultTools(\r\n new BashTool(), // bash command execution\r\n new ReadFileTool(), // file reading\r\n new WriteFileTool(), // file writing\r\n new EditFileTool() // file editing (find & replace)\r\n )\r\n .build();\r\n```\r\n\r\n3. The calling code is identical to s01. The loop is managed by the framework; developers only focus on tool implementation.\r\n\r\n```java\r\n// Compared to s01, the only change is that defaultTools() receives 3 more tool objects\r\n// The loop code is exactly the same -- this is the core insight of s02\r\nAgentRunner.interactive(\"s02\", userMessage ->\r\n chatClient.prompt()\r\n .user(userMessage)\r\n .call()\r\n .content()\r\n);\r\n```\r\n\r\nAdd a tool = add a `@Tool` class + pass it to `defaultTools()`. The loop never changes.\r\n\r\n> **TIPS — Key Python → Java Adaptations:**\r\n> - Python's `TOOL_HANDLERS` dict → Spring AI `@Tool` annotation + `defaultTools()` auto-registration and dispatch\r\n> - Python's `safe_path()` function → `PathValidator` class (same path escape check logic)\r\n> - Python's `lambda **kw` parameter unpacking → `@ToolParam` annotation auto-binds parameters\r\n> - Python's `block.type == \"tool_use\"`check→SpringAIhandlesdetectionanddispatchinternally\r\n\r\n##WhatChangedFroms01\r\n\r\n|Component|Before(s01)|After(s02)|\r\n|----------------|-----Line truncated
},
{
"version":"s03",
"locale":"en",
"title":"s03: TodoWrite",
"content":"# s03: TodoWrite\n\n`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"An agent without a plan drifts\"* -- list the steps first, then execute.\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"
"content":"# s03: TodoWrite\r\n\r\n`s01 > s02 > [ s03 ] s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"An agent without a plan drifts\"* -- list the steps first, then execute. Doubles the completion rate.\r\n>\r\n> **Harness layer**: Planning -- keeping the model on course without scripting the route.\r\n\r\n## Problem\r\n\r\nOn multi-step tasks, the model loses track -- repeats work, skips steps, or wanders off. Long conversations make this worse: tool results keep filling the context, gradually diluting the system prompt's influence. A 10-step refactoring might complete steps 1-3, then the model starts improvising because steps 4-10 have been pushed out of attention.\r\n\r\n## Solution\r\n\r\n```\r\n+--------+ +-------+ +---------+\r\n| User | ---> | LLM | ---> | Tools |\r\n| prompt | | | | + todo |\r\n+--------+ +---+---+ +----+----+\r\n ^ |\r\n | tool_result |\r\n +----------------+\r\n |\r\n +-----------+-----------+\r\n | TodoManager state |\r\n | [ ] task A |\r\n | [>] task B <- doing |\r\n | [x] task C |\r\n +-----------------------+\r\n |\r\n Inject latest todo state into\r\n system prompt via defaultSystem()\r\n on each request\r\n```\r\n\r\n## How It Works\r\n\r\n1. TodoManager stores items with statuses. Only one item can be `in_progress` at a time.\r\n\r\n```java\r\npublic class TodoManager {\r\n\r\n public record TodoItem(String id, String text, String status) {}\r\n\r\n private List<TodoItem> items = new ArrayList<>();\r\n\r\n @Tool(description = \"Update the full task list to track progress. \"\r\n + \"Each item must have id, text, status (pending/in_progress/completed). \"\r\n + \"Only one task can be in_progress at a time. Max 20 items.\")\r\n public String updateTodos(\r\n @ToolParam(description = \"The complete list of todo items\")\r\n List<TodoItem> items) {\r\n if (items.size() > 20) return \"Error: Max 20 todos allowed\";\r\n List<TodoItem> validated = new ArrayList<>();\r\n int inProgressCount = 0;\r\n for (TodoItem item : items) {\r\n String status = (item.status() != null)\r\n ? item.status().toLowerCase() : \"pending\";\r\n if (\"in_progress\".equals(status)) inProgressCount++;\r\n validated.add(new TodoItem(item.id(), item.text().trim(), status));\r\n }\r\n if (inProgressCount > 1)\r\n return \"Error: Only one task can be in_progress at a time\";\r\n this.items = validated;\r\n return render();\r\n }\r\n}\r\n```\r\n\r\n2. `TodoManager` is registered via `defaultTools()`; the `@Tool` annotated method is automatically exposed as a tool.\r\n\r\n```java\r\nChatClient chatClient = ChatClient.builder(chatModel)\r\n .defaultSystem(system)\r\n .defaultTools(\r\n new BashTool(),\r\n new ReadFileTool(),\r\n new WriteFileTool(),\r\n new EditFileTool(),\r\n todoManager // @Tool annotated method auto-registered\r\n )\r\n .build();\r\n```\r\n\r\n3. System prompt injection: on each user input, inject the latest todo state into the system prompt with emphasis on update instructions.\r\n\r\n```java\r\n// Dynamic system prompt: includes current todo state\r\nString system = \"You are a coding agent at \" + workDir + \".\\n\"\r\n + \"Use the todo tool to plan multi-step tasks. \"\r\n + \"Mark in_progress before starting, completed when done.\\n\"\r\n + \"IMPORTANT: You MUST call updateTodos regularly.\\n\\n\"\r\n + \"<current-todos>\\n\" + todoManager.render() + \"\\n</current-todos>\";\r\n```\r\n\r\nThe \"only one in_progress at a time\"constraintforcessequentialfocus.Continuouslyinjectingtodostateintothesystempromptcreatesaccountabilitypressure--themodelseesitsownplaneveryturnandwon'tforgettoupdateit.\r\n\r\n>**TIP**:ThePythonversiontracks`rounds_since_todo`insidethetoolloopandinjects`<reminder>`textafter3consecutiveroundswithoutatodocall.SpringAI'sChatClientmanagesthetoolloopautomaticallyanddoesn'tallowmid-loopinjection,sosystempromptinjectionisusedinsteadtoachievethesameeffect.\r\n\r\n##WhatChangedFroms02\r\n\r\n|Component|Before(s02)|After(s03)|\r\n|----------------|------------------|--------------------------------------|\r\n|Tools|4|5(+TodoManager`@Tool`)|\r\n|Planning|None|TodoManagerwithstatuses|\r\n|Stateinjection|None|SystLine truncated
},
{
"version":"s04",
"locale":"en",
"title":"s04: Subagents",
"content":"# s04: Subagents\n\n`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"Break big tasks down; each subtask gets a clean context\"* -- subagents use independent messages[], keeping the main conversation clean.\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"
"content":"# s04: Subagents\r\n\r\n`s01 > s02 > s03 > [ s04 ] s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"Break big tasks down; each subtask gets a clean context\"* -- subagents use independent messages[], keeping the main conversation clean.\r\n>\r\n> **Harness layer**: Context isolation -- protecting the model's clarity of thought.\r\n\r\n## Problem\r\n\r\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 one word: \"pytest.\"\r\n\r\n## Solution\r\n\r\n```\r\nParent agent Subagent\r\n+------------------+ +------------------+\r\n| messages=[...] | | messages=[] | <-- fresh\r\n| | dispatch | |\r\n| tool: task | ----------> | while tool_use: |\r\n| prompt=\"...\" | | call tools |\r\n| | summary | append results |\r\n| result = \"...\" | <---------- | return last text |\r\n+------------------+ +------------------+\r\n\r\nParent context stays clean. Subagent context is discarded.\r\n```\r\n\r\n## How It Works\r\n\r\n1. The parent agent has a `task` tool. The subagent gets all base tools except `task` (no recursive spawning).\r\n\r\n```java\r\n// Parent Agent: has base tools + SubagentTool\r\nthis.chatClient = ChatClient.builder(chatModel)\r\n .defaultSystem(\"You are a coding agent. \"\r\n + \"Use the task tool to delegate subtasks.\")\r\n .defaultTools(\r\n new BashTool(),\r\n new ReadFileTool(),\r\n new WriteFileTool(),\r\n new EditFileTool(),\r\n new SubagentTool(chatModel) // Parent Agent exclusive\r\n )\r\n .build();\r\n```\r\n\r\n2. The subagent starts with a brand new `ChatClient` and an independent context. Only the final text returns to the parent.\r\n\r\n```java\r\n@Tool(description = \"Spawn a subagent with fresh context. \"\r\n + \"Use for exploration or subtasks that might pollute the main context.\")\r\npublic String task(\r\n @ToolParam(description = \"The task prompt\") String prompt,\r\n @ToolParam(description = \"Short description\", required = false)\r\n String description) {\r\n\r\n // Create a brand new ChatClient -- this IS \"context isolation\"\r\n ChatClient subClient = ChatClient.builder(chatModel)\r\n .defaultSystem(\"You are a coding subagent. \"\r\n + \"Complete the task, then summarize findings.\")\r\n .defaultTools( // Base tools, no task (prevents recursion)\r\n new BashTool(),\r\n new ReadFileTool(),\r\n new WriteFileTool(),\r\n new EditFileTool()\r\n )\r\n .build();\r\n\r\n String result = subClient.prompt()\r\n .user(prompt)\r\n .call()\r\n .content();\r\n\r\n // Only the final text is returned; subagent context is discarded\r\n return (result != null) ? result : \"(no summary)\";\r\n}\r\n```\r\n\r\nThe subagent may have run multiple tool calls, but its entire message history is discarded. The parent receives only a summary text, returned as a normal `tool_result`. Spring AI's `ChatClient.call()` manages the tool loop internally -- no need to manually limit iteration count.\r\n\r\n## What Changed From s03\r\n\r\n| Component | Before (s03) | After (s04) |\r\n|----------------|------------------|---------------------------------------|\r\n| Tools | 5 | 5 (base) + SubagentTool (parent only) |\r\n| Context | Single shared | Parent + child isolation (independent ChatClient) |\r\n| Subagent | None | `SubagentTool.task()` method |\r\n| Return value | N/A | Summary text only |\r\n\r\n## Try It\r\n\r\n```sh\r\ncd learn-claude-code\r\nmvn exec:java -Dexec.mainClass=io.mybatis.learn.s04.S04Subagent\r\n```\r\n\r\nTry these prompts (English prompts work better with LLMs, but Chinese also works):\r\n\r\n1. `Use a subtask to find what testing framework this project uses`\r\n2. `Delegate: read all .java files and summarize what each one does`\r\n3. `Use a task to create a new module, then verify it from here`\r\n"
},
{
"version":"s05",
"locale":"en",
"title":"s05: Skills",
"content":"# s05: Skills\n\n`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"Load knowledge when you need it, not upfront\"* -- inject via tool_result, not the 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. Each skill is a directory containing a `SKILL.md` with YAML frontmatter.\n\n```\nskills/\n pdf/\n SKILL.md # ---\\n name: pdf\\n description: Process PDF files\\n ---\\n ...\n code-review/\n SKILL.md # ---\\n name: code-review\\n description: Review code\\n ---\\n ...\n```\n\n2. SkillLoader scans for `SKILL.md` files, uses the directory name as the skill identifier.\n\n```python\nclass SkillLoader:\n def __init__(self, skills_dir: Path):\n self.skills = {}\n for f in sorted(skills_dir.rglob(\"SKILL.md\")):\n text = f.read_text()\n meta, body = self._parse_frontmatter(text)\n name = meta.get(\"name\", f.parent.name)\n self.skills[name] = {\"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/\\*/SKILL.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"
"content":"# s05: Skills\r\n\r\n`s01 > s02 > s03 > s04 > [ s05 ] s06 | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"Load knowledge when you need it, not upfront\"* -- inject via tool_result, not the system prompt.\r\n>\r\n> **Harness layer**: On-demand knowledge -- domain expertise, loaded when the model asks.\r\n\r\n## Problem\r\n\r\nYou want the agent to follow domain-specific workflows: git conventions, testing patterns, code review checklists. Putting everything in the system prompt wastes tokens -- 10 skills at 2000 tokens each = 20,000 tokens, most of which are irrelevant to any given task.\r\n\r\n## Solution\r\n\r\n```\r\nSystem prompt (Layer 1 -- always present):\r\n+--------------------------------------+\r\n| You are a coding agent. |\r\n| Skills available: |\r\n| - git: Git workflow helpers | ~100 tokens/skill\r\n| - test: Testing best practices |\r\n+--------------------------------------+\r\n\r\nWhen model calls load_skill(\"git\"):\r\n+--------------------------------------+\r\n| tool_result (Layer 2 -- on demand): |\r\n| <skill name=\"git\"> |\r\n| Full git workflow instructions... | ~2000 tokens\r\n| Step 1: ... |\r\n| </skill> |\r\n+--------------------------------------+\r\n```\r\n\r\nLayer 1: skill *names* in system prompt (cheap). Layer 2: full *body* via tool_result (on demand).\r\n\r\n## How It Works\r\n\r\n1. Each skill is a directory containing a `SKILL.md` file with YAML frontmatter.\r\n\r\n```\r\nskills/\r\n pdf/\r\n SKILL.md # ---\\n name: pdf\\n description: Process PDF files\\n ---\\n ...\r\n code-review/\r\n SKILL.md # ---\\n name: code-review\\n description: Review code\\n ---\\n ...\r\n```\r\n\r\n2. SkillLoader recursively scans for `SKILL.md` files, using the directory name as the skill identifier.\r\n\r\n```java\r\npublic class SkillLoader {\r\n\r\n private static final Pattern FRONTMATTER_PATTERN =\r\n Pattern.compile(\"^---\\\\n(.*?)\\\\n---\\\\n(.*)\", Pattern.DOTALL);\r\n\r\n private final Map<String, SkillInfo> skills = new LinkedHashMap<>();\r\n\r\n record SkillInfo(Map<String, String> meta, String body, String path) {}\r\n\r\n public SkillLoader(Path skillsDir) {\r\n loadAll(skillsDir);\r\n }\r\n\r\n /** Recursively scan all SKILL.md files under the skills directory */\r\n private void loadAll(Path skillsDir) {\r\n if (!Files.exists(skillsDir)) return;\r\n try (Stream<Path> paths = Files.walk(skillsDir)) {\r\n paths.filter(p -> p.getFileName().toString().equals(\"SKILL.md\"))\r\n .sorted()\r\n .forEach(p -> {\r\n String text = Files.readString(p);\r\n var parsed = parseFrontmatter(text);\r\n String name = parsed.meta().getOrDefault(\"name\",\r\n p.getParent().getFileName().toString());\r\n skills.put(name, new SkillInfo(\r\n parsed.meta(), parsed.body(), p.toString()));\r\n });\r\n }\r\n }\r\n\r\n /** Layer 1: Get short descriptions of all skills (for system prompt injection) */\r\n public String getDescriptions() {\r\n if (skills.isEmpty()) return \"(no skills available)\";\r\n StringBuilder sb = new StringBuilder();\r\n for (var entry : skills.entrySet()) {\r\n String desc = entry.getValue().meta()\r\n .getOrDefault(\"description\", \"No description\");\r\n sb.append(\" - \").append(entry.getKey())\r\n .append(\": \").append(desc).append(\"\\n\");\r\n }\r\n return sb.toString().stripTrailing();\r\n }\r\n\r\n /** Layer 2: Load full content of a specified skill (as @Tool method) */\r\n @Tool(description = \"Load specialized knowledge by name.\")\r\n public String loadSkill(\r\n @ToolParam(description = \"Skill name to load\") String name) {\r\n SkillInfo skill = skills.get(name);\r\n if (skill == null)\r\n return \"Error: Unknown skill '\" + name + \"'. Available: \"\r\n + String.join(\", \", skills.keySet());\r\n return \"<skill name=\\\"\" + name + \"\\\">\\n\"\r\n + skill.body() + \"\\n</skill>\";\r\n }\r\n}\r\n```\r\n\r\n3. Layer 1 goes into the system prompt. Layer 2 is loaded on demand via the `@Tool` annotated method on SkillLoader.\r\n\r\n```java\r\npublic S05SkillLoading(ChatModel chatModel) {\r\n Path skillsDir = Path.of(System.getProperty(\"user.dir\"), \"skills\");\r\n SkillLoader skillLoader = new SkillLoader(skillsDir);\r\n\r\n // Layer 1: Skill metadata injected into system prompt\r\n String system = \"You are a coding agent at \" + System.getProperty(\"user.dir\") + \".\\n\"\r\n + \"UseloadSkilltoaccessspeLine truncated
},
{
"version":"s06",
"locale":"en",
"title":"s06: Context Compact",
"content":"# s06: Context Compact\n\n`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`\n\n> *\"Context will fill up; you need a way to make room\"* -- three-layer compression strategy for 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"
"content":"# s06: Context Compact\r\n\r\n`s01 > s02 > s03 > s04 > s05 > [ s06 ] | s07 > s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"Context will fill up; you need a way to make room\"* -- three-layer compression strategy for infinite sessions.\r\n>\r\n> **Harness layer**: Compression -- clean memory for infinite sessions.\r\n\r\n## Problem\r\n\r\nThe context window is finite. A single `read_file` on a 1000-line file costs ~4000 tokens; after reading 30 files and running 20 commands, you easily blow past 100k tokens. Without compression, the agent simply cannot work on large codebases.\r\n\r\n## Solution\r\n\r\nThree layers, increasing in aggressiveness:\r\n\r\n```\r\nEvery turn:\r\n+------------------+\r\n| Tool call result |\r\n+------------------+\r\n |\r\n v\r\n[Layer 1: micro_compact] (silent, every turn)\r\n Replace tool_result > 3 turns old\r\n with \"[Previous: used {tool_name}]\"\r\n |\r\n v\r\n[Check: tokens > 50000?]\r\n | |\r\n no yes\r\n | |\r\n v v\r\ncontinue [Layer 2: auto_compact]\r\n Save transcript to .transcripts/\r\n LLM summarizes conversation.\r\n Replace all messages with [summary].\r\n |\r\n v\r\n [Layer 3: compact tool]\r\n Model calls compact explicitly.\r\n Same summarization as auto_compact.\r\n```\r\n\r\n## How It Works\r\n\r\n1. **Layer 1 -- Context window management**: Spring AI's ChatClient manages the tool loop automatically and doesn't allow mid-loop compression injection. The Java version achieves an equivalent effect by limiting the number of conversation turns injected into the system prompt (keeping only the most recent N turns) and truncating content.\r\n\r\n```java\r\n/** Estimate token count: rough estimate of 4 chars ≈ 1 token */\r\npublic int estimateTokens() {\r\n int chars = history.stream().mapToInt(t -> t.content().length()).sum();\r\n return chars / 4;\r\n}\r\n\r\n/** Get conversation history summary (for system prompt injection, keeping only recent turns) */\r\npublic String getContextSummary() {\r\n if (history.isEmpty()) return \"\";\r\n StringBuilder sb = new StringBuilder(\"\\n<conversation-context>\\n\");\r\n int start = Math.max(0, history.size() - KEEP_RECENT * 2);\r\n for (int i = start; i < history.size(); i++) {\r\n ConversationTurn turn = history.get(i);\r\n sb.append(\"[\").append(turn.role()).append(\"]: \")\r\n .append(turn.content(), 0, Math.min(500, turn.content().length()))\r\n .append(\"\\n\");\r\n }\r\n sb.append(\"</conversation-context>\");\r\n return sb.toString();\r\n}\r\n```\r\n\r\n2. **Layer 2 -- auto_compact**: When tokens exceed the threshold, save the full conversation to disk and have the LLM summarize it.\r\n\r\n```java\r\npublic String compact() {\r\n // Save transcript to disk (full history is not lost)\r\n Files.createDirectories(transcriptDir);\r\n Path transcriptPath = transcriptDir.resolve(\r\n \"transcript_\" + System.currentTimeMillis() + \".jsonl\");\r\n try (BufferedWriter writer = Files.newBufferedWriter(transcriptPath)) {\r\n for (ConversationTurn turn : history) {\r\n writer.write(objectMapper.writeValueAsString(turn));\r\n writer.newLine();\r\n }\r\n }\r\n\r\n // LLM generates summary\r\n String conversationText = history.stream()\r\n .map(t -> t.role() + \": \" + t.content())\r\n .reduce(\"\", (a, b) -> a + \"\\n\" + b);\r\n if (conversationText.length() > 80000) {\r\n conversationText = conversationText.substring(0, 80000);\r\n }\r\n\r\n ChatClient summaryClient = ChatClient.builder(chatModel).build();\r\n String summary = summaryClient.prompt()\r\n .user(\"Summarize this conversation for continuity. Include: \"\r\n + \"1) What was accomplished, 2) Current state, \"\r\n + \"3) Key decisions.\\n\\n\" + conversationText)\r\n .call().content();\r\n\r\n // Replace history with summary\r\n history.clear();\r\n history.add(new ConversationTurn(\"system\",\r\n \"[Conversation compressed. Transcript: \" + transcriptPath\r\n + \"]\\n\\n\" + summary));\r\n return summary;\r\n}\r\n```\r\n\r\n3. **Layer 3 -- manual compact**: The `CompactTool` triggers the same summarization mechanism on demand.\r\n\r\n```java\r\npublic class CompactTool {\r\n private final ContextCompactor compactor;\r\n\r\n public CompactTool(ContextCompactor compactor) {\r\n this.compactor = compactor;\r\n }\r\n\r\n @Tool(description = \"Trigger manual conversation compression to free up context space.\")\r\n public String compact(\r\n @ToolParam(description = \"What to preserve in summary\",\r\nrequired=falLine truncated
},
{
"version":"s07",
"locale":"en",
"title":"s07: Task System",
"content":"# s07: Task System\n\n`s01 > s02 > s03 > s04 > s05 > s06 | [ s07 ] s08 > s09 > s10 > s11 > s12`\n\n> *\"Break big goals into small tasks, order them, persist to disk\"* -- a file-based task graph with dependencies, laying the foundation for multi-agent collaboration.\n\n## Problem\n\ns03's TodoManager is a flat checklist in memory: no ordering, no dependencies, no status beyond done-or-not. Real goals have structure -- task B depends on task A, tasks C and D can run in parallel, task E waits for both C and D.\n\nWithout explicit relationships, the agent can't tell what's ready, what's blocked, or what can run concurrently. And because the list lives only in memory, context compression (s06) wipes it clean.\n\n## Solution\n\nPromote the checklist into a **task graph** persisted to disk. Each task is a JSON file with status, dependencies (`blockedBy`), and dependents (`blocks`). The graph answers three questions at any moment:\n\n- **What's ready?** -- tasks with `pending` status and empty `blockedBy`.\n- **What's blocked?** -- tasks waiting on unfinished dependencies.\n- **What's done?** -- `completed` tasks, whose completion automatically unblocks dependents.\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\":[1], \"status\":\"pending\"}\n task_4.json {\"id\":4, \"blockedBy\":[2,3], \"status\":\"pending\"}\n\nTask graph (DAG):\n +----------+\n +--> | task 2 | --+\n | | pending | |\n+----------+ +----------+ +--> +----------+\n| task 1 | | task 4 |\n| completed| --> +----------+ +--> | blocked |\n+----------+ | task 3 | --+ +----------+\n | pending |\n +----------+\n\nOrdering: task 1 must finish before 2 and 3\nParallelism: tasks 2 and 3 can run at the same time\nDependencies: task 4 waits for both 2 and 3\nStatus: pending -> in_progress -> completed\n```\n\nThis task graph becomes the coordination backbone for everything after s07: background execution (s08), multi-agent teams (s09+), and worktree isolation (s12) all read from and write to this same structure.\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. **Dependency resolution**: completing a task clears its ID from every other task's `blockedBy` list, automatically unblocking dependents.\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. **Status + dependency wiring**: `update` handles transitions and dependency edges.\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, the task graph is the default for multi-step work. s03's Todo remains for quick single-session 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| Planning model | Flat checklist (in-memory) | Task graph with dependencies (on disk) |\n| Relationships | None | `blockedBy` + `blocks` edges |\n| Status tracking | Done or not | `pending` -> `in_progress` -> `completed` |\n| Persistence | Lost on compression | Survives compression and restarts |\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\".Makethemdependoneachotherinorder.`\n2.`Listalltasksandshowthedependencygraph`\n3.`Completetask1andthenlisttaskstoseetask2unblocked`\n4.`CreateataskboaLine truncated
"content":"# s07: Task System\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | [ s07 ] s08 > s09 > s10 > s11 > s12`\r\n\r\n> *\"Break big goals into small tasks, order them, persist to disk\"* -- a file-based task graph with dependencies, laying the foundation for multi-agent collaboration.\r\n>\r\n> **Harness layer**: Persistent tasks -- goals that outlive any single conversation.\r\n\r\n## Problem\r\n\r\ns03's TodoManager is a flat checklist in memory: no ordering, no dependencies, no status beyond done-or-not. Real goals have structure -- task B depends on task A, tasks C and D can run in parallel, task E waits for both C and D.\r\n\r\nWithout explicit relationships, the agent can't tell what's ready, what's blocked, or what can run concurrently. And because the list lives only in memory, context compaction (s06) wipes it clean.\r\n\r\n## Solution\r\n\r\nPromote the checklist into a **task graph** persisted to disk. Each task is a JSON file with status, dependencies (`blockedBy`), and dependents (`blocks`). The graph answers three questions at any moment:\r\n\r\n- **What's ready?** -- tasks with `pending` status and empty `blockedBy`.\r\n- **What's blocked?** -- tasks waiting on unfinished dependencies.\r\n- **What's done?** -- `completed` tasks, whose completion automatically unblocks dependents.\r\n\r\n```\r\n.tasks/\r\n task_1.json {\"id\":1, \"status\":\"completed\"}\r\n task_2.json {\"id\":2, \"blockedBy\":[1], \"status\":\"pending\"}\r\n task_3.json {\"id\":3, \"blockedBy\":[1], \"status\":\"pending\"}\r\n task_4.json {\"id\":4, \"blockedBy\":[2,3], \"status\":\"pending\"}\r\n\r\nTask graph (DAG):\r\n +----------+\r\n +--> | task 2 | --+\r\n | | pending | |\r\n+----------+ +----------+ +--> +----------+\r\n| task 1 | | task 4 |\r\n| completed| --> +----------+ +--> | blocked |\r\n+----------+ | task 3 | --+ +----------+\r\n | pending |\r\n +----------+\r\n\r\nOrdering: task 1 must finish before 2 and 3\r\nParallelism: tasks 2 and 3 can run at the same time\r\nDependencies: task 4 waits for both 2 and 3\r\nStatus: pending -> in_progress -> completed\r\n```\r\n\r\nThis task graph becomes the coordination backbone for everything after s07: background execution (s08), multi-agent teams (s09+), and worktree isolation (s12) all read from and write to this same structure.\r\n\r\n## How It Works\r\n\r\n1. **TaskManager**: one JSON file per task, CRUD with dependency graph. Uses Jackson `ObjectMapper` for JSON serialization.\r\n\r\n```java\r\npublic class TaskManager {\r\n private static final ObjectMapper MAPPER = new ObjectMapper();\r\n private final Path dir;\r\n private int nextId;\r\n\r\n public TaskManager(Path tasksDir) {\r\n this.dir = tasksDir;\r\n Files.createDirectories(dir);\r\n this.nextId = maxId() + 1;\r\n }\r\n\r\n @Tool(description = \"Create a new task with subject and optional description\")\r\n public String taskCreate(\r\n @ToolParam(description = \"Short subject of the task\") String subject,\r\n @ToolParam(description = \"Detailed description\", required = false) String description) {\r\n Map<String, Object> task = new LinkedHashMap<>();\r\n task.put(\"id\", nextId);\r\n task.put(\"subject\", subject);\r\n task.put(\"status\", \"pending\");\r\n task.put(\"blockedBy\", new ArrayList<>());\r\n task.put(\"blocks\", new ArrayList<>());\r\n save(task);\r\n nextId++;\r\n return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(task);\r\n }\r\n}\r\n```\r\n\r\n2. **Dependency resolution**: completing a task clears its ID from every other task's `blockedBy` list, automatically unblocking dependents.\r\n\r\n```java\r\nprivate void clearDependency(int completedId) {\r\n try (Stream<Path> files = Files.list(dir)) {\r\n files.filter(f -> f.getFileName().toString().matches(\"task_\\\\d+\\\\.json\"))\r\n .forEach(f -> {\r\n Map<String, Object> task = MAPPER.readValue(\r\n Files.readString(f), new TypeReference<>() {});\r\n List<Integer> blockedBy = (List<Integer>) task.get(\"blockedBy\");\r\n if (blockedBy != null && blockedBy.remove(Integer.valueOf(completedId))) {\r\n save(task);\r\n }\r\n });\r\n }\r\n}\r\n```\r\n\r\n3. **Status transitions + dependency wiring**: `taskUpdate` handles status transitions and dependency edges. When status changes to `completed`, it automatically calls `clearDependency`; `blockedBy`/`blocks` are bidirectional relationships.\r\n\r\n```java\r\n@Tool(description = \"Update a task's status or dependencies.\")\r\npublic String taskUpdate(\r\n @ToolParam(description = \"Task ID\")inttaskId,\r\n@ToolParam(desLine truncated
},
{
"version":"s08",
"locale":"en",
"title":"s08: Background Tasks",
"content":"# s08: Background Tasks\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`\n\n> *\"Run slow operations in the background; the agent keeps thinking\"* -- daemon threads run commands, inject notifications on completion.\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"
"content":"# s08: Background Tasks\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > [ s08 ] s09 > s10 > s11 > s12`\r\n\r\n> *\"Run slow operations in the background; the agent keeps thinking\"* -- background threads run commands, inject notifications on completion.\r\n>\r\n> **Harness layer**: Background execution -- the model thinks while the harness waits.\r\n\r\n## Problem\r\n\r\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.\r\n\r\n## Solution\r\n\r\n```\r\nMain thread Background thread\r\n+-----------------+ +-----------------+\r\n| agent loop | | subprocess runs |\r\n| ... | | ... |\r\n| [LLM call] <---+------- | enqueue(result) |\r\n| ^drain queue | +-----------------+\r\n+-----------------+\r\n\r\nTimeline:\r\nAgent --[spawn A]--[spawn B]--[other work]----\r\n | |\r\n v v\r\n [A runs] [B runs] (parallel)\r\n | |\r\n +-- results injected before next LLM call --+\r\n```\r\n\r\n## How It Works\r\n\r\n1. BackgroundManager tracks tasks with thread-safe concurrent containers. Java uses `ConcurrentHashMap` and `CopyOnWriteArrayList` instead of Python's manual locking.\r\n\r\n```java\r\npublic class BackgroundManager {\r\n private static final int TIMEOUT_SECONDS = 300;\r\n\r\n private final Map<String, TaskInfo> tasks = new ConcurrentHashMap<>();\r\n private final List<Notification> notificationQueue = new CopyOnWriteArrayList<>();\r\n private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();\r\n\r\n record TaskInfo(String status, String result, String command) {}\r\n public record Notification(String taskId, String status, String command, String result) {}\r\n}\r\n```\r\n\r\n2. `backgroundRun()` submits a virtual thread (Java 21) and returns immediately. Compared to Python's `daemon=True` threads, virtual threads are lighter and scheduled by the JVM.\r\n\r\n```java\r\n@Tool(description = \"Run a command in a background thread. Returns task_id immediately without waiting.\")\r\npublic String backgroundRun(\r\n @ToolParam(description = \"The shell command to run in background\") String command) {\r\n String taskId = UUID.randomUUID().toString().substring(0, 8);\r\n tasks.put(taskId, new TaskInfo(\"running\", null, command));\r\n\r\n executor.submit(() -> execute(taskId, command));\r\n\r\n return \"Background task \" + taskId + \" started: \"\r\n + command.substring(0, Math.min(80, command.length()));\r\n}\r\n```\r\n\r\n3. When the subprocess finishes, the result goes into the notification queue. Uses `ProcessBuilder` for command execution with timeout control.\r\n\r\n```java\r\nprivate void execute(String taskId, String command) {\r\n String status, output;\r\n try {\r\n ProcessBuilder pb = new ProcessBuilder(\"sh\", \"-c\", command);\r\n pb.redirectErrorStream(true);\r\n Process process = pb.start();\r\n try (BufferedReader reader = new BufferedReader(\r\n new InputStreamReader(process.getInputStream()))) {\r\n output = reader.lines().collect(Collectors.joining(\"\\n\"));\r\n }\r\n boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS);\r\n if (!finished) { process.destroyForcibly(); status = \"timeout\"; }\r\n else { status = \"completed\"; }\r\n } catch (Exception e) { output = \"Error: \" + e.getMessage(); status = \"error\"; }\r\n\r\n tasks.put(taskId, new TaskInfo(status, output, command));\r\n notificationQueue.add(new Notification(taskId, status, command, output));\r\n}\r\n```\r\n\r\n4. Drain the notification queue on each user input and inject into the system prompt. Spring AI's `ChatClient` manages the internal tool loop, so notifications are drained and built into the system prompt on each user input instead -- the core concept remains the same: fire and forget.\r\n\r\n```java\r\nAgentRunner.interactive(\"s08\", userMessage -> {\r\n // Drain background task notifications (corresponds to Python's pre-loop drain_notifications)\r\n var notifs = bgManager.drainNotifications();\r\n String bgContext = \"\";\r\n if (!notifs.isEmpty()) {\r\n String notifText = notifs.stream()\r\n .map(n -> \"[bg:\" + n.taskId() + \"] \" + n.status() + \": \" + n.result())\r\n .collect(Collectors.joining(\"\\n\"));\r\n bgContext = \"\\n\\n<background-results>\\n\" + notifText + \"\\n</background-results>\";\r\n }\r\n\r\n String system = \"You are a coding agent. Use backgroundRun for long-running commands.\"\r\n+bgContext;\r\n\r\nChatClientchatClient=ChatClient.builder(chatModel)\Line truncated
},
{
"version":"s09",
"locale":"en",
"title":"s09: Agent Teams",
"content":"# s09: Agent Teams\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`\n\n> *\"When the task is too big for one, delegate to teammates\"* -- persistent teammates + async mailboxes.\n\n## Problem\n\nSubagents (s04) are disposable: spawn, work, return summary, die. No identity, no memory between invocations. Background tasks (s08) run shell commands but can't make LLM-guided decisions.\n\nReal teamwork needs: (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"
"content":"# s09: Agent Teams\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > [ s09 ] s10 > s11 > s12`\r\n\r\n> *\"When the task is too big for one, delegate to teammates\"* -- persistent teammates + JSONL mailboxes.\r\n>\r\n> **Harness layer**: Team mailboxes -- multiple models, coordinated through files.\r\n\r\n## Problem\r\n\r\nSubagents (s04) are disposable: spawn, work, return summary, die. No identity, no memory between invocations. Background tasks (s08) run shell commands but can't make LLM-guided decisions.\r\n\r\nReal teamwork needs three things: (1) persistent agents that outlive a single prompt, (2) identity and lifecycle management, (3) a communication channel between agents.\r\n\r\n## Solution\r\n\r\n```\r\nTeammate lifecycle:\r\n spawn -> WORKING -> IDLE -> WORKING -> ... -> SHUTDOWN\r\n\r\nCommunication:\r\n .team/\r\n config.json <- team roster + statuses\r\n inbox/\r\n alice.jsonl <- append-only, drain-on-read\r\n bob.jsonl\r\n lead.jsonl\r\n\r\n +--------+ send(\"alice\",\"bob\",\"...\") +--------+\r\n | alice | -----------------------------> | bob |\r\n | loop | bob.jsonl << {json_line} | loop |\r\n +--------+ +--------+\r\n ^ |\r\n | BUS.read_inbox(\"alice\") |\r\n +---- alice.jsonl -> read + drain ---------+\r\n```\r\n\r\n## How It Works\r\n\r\n1. TeammateManager maintains the team roster via config.json.\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s09/TeammateManager.java\r\npublic class TeammateManager {\r\n private final ChatModel chatModel;\r\n private final MessageBus bus;\r\n private final Path configPath;\r\n private final ObjectMapper mapper = new ObjectMapper();\r\n private Map<String, Object> config;\r\n // Python uses threading.Thread + dict; Java uses ConcurrentHashMap for natural thread safety\r\n private final Map<String, Thread> threads = new ConcurrentHashMap<>();\r\n\r\n public TeammateManager(ChatModel chatModel, MessageBus bus, Path teamDir) {\r\n this.chatModel = chatModel;\r\n this.bus = bus;\r\n this.configPath = teamDir.resolve(\"config.json\");\r\n Files.createDirectories(teamDir);\r\n this.config = loadConfig();\r\n }\r\n```\r\n\r\n2. `spawn()` creates a teammate and starts its agent loop in a thread.\r\n\r\n```java\r\n// Python uses threading.Thread; Java uses Thread.startVirtualThread() for virtual threads\r\npublic synchronized String spawn(String name, String role, String prompt) {\r\n Map<String, Object> member = new LinkedHashMap<>();\r\n member.put(\"name\", name);\r\n member.put(\"role\", role);\r\n member.put(\"status\", \"working\");\r\n ((List<Map<String, Object>>) config.get(\"members\")).add(member);\r\n saveConfig();\r\n\r\n // Virtual thread: lightweight, JVM-scheduled, doesn't occupy OS threads\r\n Thread thread = Thread.startVirtualThread(\r\n () -> teammateLoop(name, role, prompt));\r\n threads.put(name, thread);\r\n return \"Spawned '\" + name + \"' (role: \" + role + \")\";\r\n}\r\n```\r\n\r\n3. MessageBus: append-only JSONL inboxes. `send()` appends a JSON line; `read_inbox()` reads all and drains.\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/core/team/MessageBus.java\r\n// Python relies on GIL for implicit thread safety; Java uses synchronized for explicit safety\r\npublic class MessageBus {\r\n private final Path inboxDir;\r\n private final ObjectMapper mapper = new ObjectMapper();\r\n\r\n public synchronized String send(String sender, String to, String content,\r\n String msgType, Map<String, Object> extra) {\r\n Map<String, Object> msg = new LinkedHashMap<>();\r\n msg.put(\"type\", msgType);\r\n msg.put(\"from\", sender);\r\n msg.put(\"content\", content);\r\n msg.put(\"timestamp\", System.currentTimeMillis() / 1000.0);\r\n if (extra != null) msg.putAll(extra);\r\n\r\n Path inbox = inboxDir.resolve(to + \".jsonl\");\r\n Files.writeString(inbox, mapper.writeValueAsString(msg) + \"\\n\",\r\n StandardOpenOption.CREATE, StandardOpenOption.APPEND);\r\n return \"Sent \" + msgType + \" to \" + to;\r\n }\r\n\r\n public synchronized List<Map<String, Object>> readInbox(String name) {\r\n Path inbox = inboxDir.resolve(name + \".jsonl\");\r\n if (!Files.exists(inbox)) return List.of();\r\n List<Map<String, Object>> messages = new ArrayList<>();\r\n for (String line : Files.readAllLines(inbox)) {\r\n if (!line.isBlank())\r\n messages.add(mapper.readValue(line, new TypeReference<>() {}));\r\n }\r\n Files.writeString(inbox, \"\");//drain\r\nreturnmessages;\r\n}\r\nLine truncated
},
{
"version":"s10",
"locale":"en",
"title":"s10: Team Protocols",
"content":"# s10: Team Protocols\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > [ s10 ] s11 > s12`\n\n> *\"Teammates need shared communication rules\"* -- one request-response pattern drives all negotiation.\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"
"content":"# s10: Team Protocols\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > [ s10 ] s11 > s12`\r\n\r\n> *\"Teammates need shared communication rules\"* -- one request-response pattern drives all negotiation.\r\n>\r\n> **Harness layer**: Protocols -- structured handshakes between models.\r\n\r\n## Problem\r\n\r\nIn s09, teammates work and communicate but lack structured coordination:\r\n\r\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).\r\n\r\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.\r\n\r\nBoth share the same structure: one side sends a request with a unique ID, the other responds referencing that ID.\r\n\r\n## Solution\r\n\r\n```\r\nShutdown Protocol Plan Approval Protocol\r\n================== ======================\r\n\r\nLead Teammate Teammate Lead\r\n | | | |\r\n |--shutdown_req-->| |--plan_req------>|\r\n | {req_id:\"abc\"} | | {req_id:\"xyz\"} |\r\n | | | |\r\n |<--shutdown_resp-| |<--plan_resp-----|\r\n | {req_id:\"abc\", | | {req_id:\"xyz\", |\r\n | approve:true} | | approve:true} |\r\n\r\nShared FSM:\r\n [pending] --approve--> [approved]\r\n [pending] --reject---> [rejected]\r\n\r\nTrackers:\r\n shutdown_requests = {req_id: {target, status}}\r\n plan_requests = {req_id: {from, plan, status}}\r\n```\r\n\r\n## How It Works\r\n\r\n1. The lead initiates shutdown by generating a request_id and sending through the inbox.\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s10/ProtocolTracker.java\r\n// Python uses dict + threading.Lock; Java uses ConcurrentHashMap for natural thread safety\r\nprivate final ConcurrentHashMap<String, Map<String, String>> shutdownRequests\r\n = new ConcurrentHashMap<>();\r\n\r\npublic String handleShutdownRequest(String teammate) {\r\n String reqId = UUID.randomUUID().toString().substring(0, 8);\r\n shutdownRequests.put(reqId, new ConcurrentHashMap<>(Map.of(\r\n \"target\", teammate, \"status\", \"pending\")));\r\n bus.send(\"lead\", teammate, \"Please shut down gracefully.\",\r\n \"shutdown_request\", Map.of(\"request_id\", reqId));\r\n return \"Shutdown request \" + reqId + \" sent to '\" + teammate\r\n + \"' (status: pending)\";\r\n}\r\n```\r\n\r\n2. The teammate receives the request and responds with approve/reject.\r\n\r\n```java\r\n// TeammateProtocolTool - teammates respond to shutdown requests via @Tool annotation\r\n@Tool(description = \"Respond to a shutdown request\")\r\npublic String shutdownResponse(\r\n @ToolParam(description = \"The request_id\") String requestId,\r\n @ToolParam(description = \"true to approve\") boolean approve,\r\n @ToolParam(description = \"Reason for decision\") String reason) {\r\n return tracker.respondToShutdown(name, requestId, approve, reason);\r\n}\r\n\r\n// ProtocolTracker - updates tracker + sends response message\r\npublic String respondToShutdown(String sender, String requestId,\r\n boolean approve, String reason) {\r\n var req = shutdownRequests.get(requestId);\r\n if (req != null) {\r\n req.put(\"status\", approve ? \"approved\" : \"rejected\");\r\n }\r\n bus.send(sender, \"lead\", reason != null ? reason : \"\",\r\n \"shutdown_response\",\r\n Map.of(\"request_id\", requestId, \"approve\", approve));\r\n return \"Shutdown \" + (approve ? \"approved\" : \"rejected\");\r\n}\r\n```\r\n\r\n3. Plan approval follows the identical pattern. The teammate submits a plan (generating a request_id), the lead reviews (referencing the same request_id).\r\n\r\n```java\r\n// ProtocolTracker - same request_id correlation pattern, two use cases\r\nprivate final ConcurrentHashMap<String, Map<String, String>> planRequests\r\n = new ConcurrentHashMap<>();\r\n\r\npublic String reviewPlan(String requestId, boolean approve, String feedback) {\r\n var req = planRequests.get(requestId);\r\n if (req == null) return \"Error: Unknown plan request_id '\" + requestId + \"'\";\r\n req.put(\"status\", approve ? \"approved\" : \"rejected\");\r\n bus.send(\"lead\", req.get(\"from\"), feedback != null ? feedback : \"\",\r\n \"plan_approval_response\",\r\n Map.of(\"request_id\", requestId, \"approve\", approve,\r\n \"feedback\", feedback != null ? feedback : \"\"));\r\n return \"Plan \" + req.get(\"status\") + \" for '\" + req.get(\"from\") + \"'\";\r\n}\r\n```\r\n\r\nOneFSM,twoapplications.Thesame`pending->approved|rejected`statemachinehandlesanyrequest-Line truncated
},
{
"version":"s11",
"locale":"en",
"title":"s11: Autonomous Agents",
"content":"# s11: Autonomous Agents\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`\n\n> *\"Teammates scan the board and claim tasks themselves\"* -- no need for the lead to assign each one.\n\n## Problem\n\nIn s09-s10, teammates only work when explicitly told to. The lead must spawn each one with a specific prompt. 10 unclaimed tasks on the board? The lead assigns each one manually. Doesn't scale.\n\nTrue autonomy: teammates scan the task board themselves, claim unclaimed tasks, work on them, then look for more.\n\nOne subtlety: after context compression (s06), the agent might forget who it is. Identity re-injection fixes 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"
"content":"# s11: Autonomous Agents\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > [ s11 ] s12`\r\n\r\n> *\"Teammates scan the board and claim tasks themselves\"* -- no need for the lead to assign each one. Self-organizing.\r\n>\r\n> **Harness layer**: Autonomy -- models that find work without being told.\r\n\r\n## Problem\r\n\r\nIn s09-s10, teammates only work when explicitly told to. The lead must write a prompt for each teammate. 10 unclaimed tasks on the board? The lead assigns each one manually. Doesn't scale.\r\n\r\nTrue autonomy: teammates scan the task board themselves, claim unclaimed tasks, work on them, then look for more.\r\n\r\nOne subtlety: after context compaction (s06), the agent might forget who it is. Identity re-injection fixes this.\r\n\r\n## Solution\r\n\r\n```\r\nTeammate lifecycle with idle cycle:\r\n\r\n+-------+\r\n| spawn |\r\n+---+---+\r\n |\r\n v\r\n+-------+ tool_use +-------+\r\n| WORK | <------------- | LLM |\r\n+---+---+ +-------+\r\n |\r\n | stop_reason != tool_use (or idle tool called)\r\n v\r\n+--------+\r\n| IDLE | poll every 5s for up to 60s\r\n+---+----+\r\n |\r\n +---> check inbox --> message? ----------> WORK\r\n |\r\n +---> scan .tasks/ --> unclaimed? -------> claim -> WORK\r\n |\r\n +---> 60s timeout ----------------------> SHUTDOWN\r\n\r\nIdentity via system prompt (always present):\r\n ChatClient.builder(chatModel)\r\n .defaultSystem(identityPrompt) // automatically included in every call\r\n```\r\n\r\n## How It Works\r\n\r\n1. The teammate loop has two phases: WORK and IDLE. When the LLM stops calling tools (or calls `idle`), the teammate enters IDLE.\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s11/S11AutonomousAgents.java\r\n// AutonomousTeammateManager.autonomousLoop()\r\n\r\nprivate void autonomousLoop(String name, String role, String initialPrompt) {\r\n // idle flag: set by tool call, detected by outer loop\r\n AtomicBoolean idleRequested = new AtomicBoolean(false);\r\n var idleTool = new IdleTool(idleRequested);\r\n\r\n ChatClient client = ChatClient.builder(chatModel)\r\n .defaultSystem(sysPrompt)\r\n .defaultTools(new BashTool(), new ReadFileTool(),\r\n new WriteFileTool(), new EditFileTool(),\r\n messageTool, protocolTool, idleTool, claimTool)\r\n .build();\r\n\r\n while (true) {\r\n // -- WORK PHASE --\r\n String nextMsg = initialPrompt;\r\n for (int round = 0; round < 50 && nextMsg != null; round++) {\r\n var inbox = bus.readInbox(name);\r\n // ... merge inbox messages into nextMsg ...\r\n idleRequested.set(false);\r\n String response = client.prompt(sb.toString()).call().content();\r\n if (idleRequested.get()) break; // idle tool was called\r\n nextMsg = null; // subsequent rounds are inbox-driven\r\n }\r\n\r\n // -- IDLE PHASE --\r\n setStatus(name, \"idle\");\r\n // ... poll inbox + task board (see below) ...\r\n if (!resume) { setStatus(name, \"shutdown\"); return; }\r\n setStatus(name, \"working\");\r\n }\r\n}\r\n```\r\n\r\n2. The idle phase polls inbox and task board in a loop.\r\n\r\n```java\r\n// IDLE PHASE: poll inbox + task board\r\nsetStatus(name, \"idle\");\r\nboolean resume = false;\r\nint polls = IDLE_TIMEOUT / Math.max(POLL_INTERVAL, 1); // 60/5 = 12\r\n\r\nfor (int p = 0; p < polls; p++) {\r\n Thread.sleep(POLL_INTERVAL * 1000L);\r\n\r\n // Check inbox\r\n var inbox = bus.readInbox(name);\r\n if (!inbox.isEmpty()) {\r\n initialPrompt = \"<inbox>\" + mapper.writeValueAsString(inbox) + \"</inbox>\";\r\n resume = true;\r\n break;\r\n }\r\n\r\n // Scan task board\r\n var unclaimed = scanUnclaimedTasks(tasksDir);\r\n if (!unclaimed.isEmpty()) {\r\n var task = unclaimed.get(0);\r\n int taskId = ((Number) task.get(\"id\")).intValue();\r\n claimTask(tasksDir, taskId, name);\r\n initialPrompt = String.format(\r\n \"<auto-claimed>Task #%d: %s\\n%s</auto-claimed>\",\r\n taskId, task.get(\"subject\"),\r\n task.getOrDefault(\"description\", \"\"));\r\n resume = true;\r\n break;\r\n }\r\n}\r\n\r\nif (!resume) { setStatus(name, \"shutdown\"); return; }\r\nsetStatus(name, \"working\");\r\n```\r\n\r\n3. Task board scanning: find pending, unowned, unblocked tasks.\r\n\r\n```java\r\nstatic List<Map<String, Object>> scanUnclaimedTasks(Path tasksDir) {\r\n if (!Files.exists(tasksDir)) return List.of();\r\n List<Map<String, Object>> unclaimed = new ArrayList<>();\r\n ObjectMapper mapper = new ObjectMapper();\r\n try (var files = Files.list(tasksDir)) {\r\n files.filter(f -> f.getFileName().toString().startsWith(\"task_\")\r\n && f.getFileName().toString().endsWith(\".jLine truncated
},
{
"version":"s12",
"locale":"en",
"title":"s12: Worktree + Task Isolation",
"content":"# s12: Worktree + Task Isolation\n\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`\n\n> *\"Each works in its own directory, no interference\"* -- tasks manage goals, worktrees manage directories, bound by ID.\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\",theLine truncated
"content":"# s12: Worktree + Task Isolation\r\n\r\n`s01 > s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > [ s12 ]`\r\n\r\n> *\"Each works in its own directory, no interference\"* -- tasks manage goals, worktrees manage directories, bound by ID.\r\n>\r\n> **Harness layer**: Directory isolation -- parallel execution lanes that never collide.\r\n\r\n## Problem\r\n\r\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.java`, agent B also edits `Config.java`, unstaged changes mix, and neither can roll back cleanly.\r\n\r\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.\r\n\r\n## Solution\r\n\r\n```\r\nControl plane (.tasks/) Execution plane (.worktrees/)\r\n+------------------+ +------------------------+\r\n| task_1.json | | auth-refactor/ |\r\n| status: in_progress <------> branch: wt/auth-refactor\r\n| worktree: \"auth-refactor\" | task_id: 1 |\r\n+------------------+ +------------------------+\r\n| task_2.json | | ui-login/ |\r\n| status: pending <------> branch: wt/ui-login\r\n| worktree: \"ui-login\" | task_id: 2 |\r\n+------------------+ +------------------------+\r\n |\r\n index.json (worktree registry)\r\n events.jsonl (lifecycle log)\r\n\r\nState machines:\r\n Task: pending -> in_progress -> completed\r\n Worktree: absent -> active -> removed | kept\r\n```\r\n\r\n## How It Works\r\n\r\n1. **Create a task.** Persist the goal first.\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java\r\ntasks.create(\"Implement auth refactor\", \"\");\r\n// -> .tasks/task_1.json status=pending worktree=\"\"\r\n```\r\n\r\n2. **Create a worktree and bind to the task.** Passing `task_id` auto-advances the task to `in_progress`.\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeManager.java\r\nworktrees.create(\"auth-refactor\", 1, \"HEAD\");\r\n// -> git worktree add -b wt/auth-refactor .worktrees/auth-refactor HEAD\r\n// -> index.json gets new entry, task_1.json gets worktree=\"auth-refactor\"\r\n```\r\n\r\nThe binding writes state to both sides:\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeTaskManager.java\r\npublic String bindWorktree(int taskId, String worktree, String owner) {\r\n var task = load(taskId);\r\n task.put(\"worktree\", worktree);\r\n if (owner != null && !owner.isEmpty()) task.put(\"owner\", owner);\r\n if (\"pending\".equals(task.get(\"status\"))) task.put(\"status\", \"in_progress\");\r\n task.put(\"updated_at\", System.currentTimeMillis() / 1000.0);\r\n save(task);\r\n return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(task);\r\n}\r\n```\r\n\r\n3. **Run commands in the worktree.** `cwd` points to the isolated directory.\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeManager.java - run()\r\nboolean isWindows = System.getProperty(\"os.name\").toLowerCase().contains(\"win\");\r\nProcessBuilder pb = isWindows\r\n ? new ProcessBuilder(\"cmd\", \"/c\", command)\r\n : new ProcessBuilder(\"sh\", \"-c\", command);\r\npb.directory(path.toFile());\r\npb.redirectErrorStream(true);\r\nProcess p = pb.start();\r\nString out = new String(p.getInputStream().readAllBytes()).trim();\r\nboolean finished = p.waitFor(300, java.util.concurrent.TimeUnit.SECONDS);\r\n```\r\n\r\n4. **Close out.** Two choices:\r\n - `worktree_keep(name)` -- preserve the directory for later.\r\n - `worktree_remove(name, complete_task=True)` -- remove directory, complete the bound task, emit event. One call handles teardown + completion.\r\n\r\n```java\r\n// src/main/java/io/mybatis/learn/s12/WorktreeManager.java\r\npublic String remove(String name, boolean force, boolean completeTask) {\r\n var wt = findWorktree(name);\r\n events.emit(\"worktree.remove.before\", ...);\r\n runGit(\"worktree\", \"remove\", wt.get(\"path\").toString());\r\n if (completeTask && wt.get(\"task_id\") != null) {\r\n int taskId = ((Number) wt.get(\"task_id\")).intValue();\r\n tasks.update(taskId, \"completed\", null);\r\n tasks.unbindWorktree(taskId);\r\n events.emit(\"task.completed\",\r\n Map.of(\"id\", taskId, \"status\", \"completed\"),\r\n Map.of(\"name\", name), null);\r\n }\r\n // Update index.json: status -> \"removed\"\r\n}\r\n```\r\n\r\n5. **Event stream.** Every lifecycle step emits to `.worktrees/events.jsonl`:\r\n\r\n```json\r\n{\r\n \"event\": \"worktree.remove.aftLine truncated
},
{
"version":"s01",
"locale":"zh",
"title":"s01: The Agent Loop (智能体循环)",
"content":"# s01: The Agent Loop (智能体循环)\n\n`[ s01 ] s02 > s03 > s04 > s05 > s06 | s07 > s08 > s09 > s10 > s11 > s12`\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 行, 这就是整个智能体。后面 11 个章节都在这个循环上叠加机制 -- 循环本身始终不变。\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\n试试这些 prompt (英文 prompt 对 LLM 效果更好, 也可以用中文):\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"
"annotation":"TodoManager creates a visible plan BEFORE any code is written"
},
@@ -22,7 +22,7 @@
},
{
"type":"tool_call",
"content":"calc.py\n---\nimport sys\n\ndef calculate(op, a, b):\n ops = {'+': a+b, '-': a-b, '*': a*b, '/': a/b}\n return ops.get(op, 'Unknown operator')\n\nif __name__ == '__main__':\n a, op, b = float(sys.argv[1]), sys.argv[2], float(sys.argv[3])\n print(calculate(op, a, b))",
"content":"Calc.java\n---\npublic class Calc {\n public static double calculate(String op, double a, double b) {\n switch (op) {\n case \"+\": return a + b;\n case \"-\": return a - b;\n case \"*\": return a * b;\n case \"/\": return a / b;\n default: throw new IllegalArgumentException(\"Unknown operator: \" + op);\n }\n }\n\n public static void main(String[] args) {\n double a = Double.parseDouble(args[0]);\n String op = args[1];\n double b = Double.parseDouble(args[2]);\n System.out.println(calculate(op, a, b));\n }\n}",
"toolName":"write_file",
"annotation":"Executing task #1 and #2: create file with operations"
"empty_hint":"Select two versions above to compare them.",
"architecture":"Architecture"
"architecture":"Architecture",
"select_placeholder":"-- select --"
},
"diff":{
"new_classes":"New Classes",
"new_tools":"New Tools",
"new_functions":"New Functions",
"loc_delta":"LOC Delta"
"loc_delta":"LOC Delta",
"lines":"lines",
"source_diff":"Source Code Diff",
"version_not_found":"Version not found",
"back_to_timeline":"Back to timeline",
"back_to":"Back to",
"first_version_hint":"This is the first version -- there is no previous version to compare against."
},
"sessions":{
"s01":"The Agent Loop",
@@ -72,5 +79,19 @@
"s10":"FSM Team Protocols",
"s11":"Autonomous Agent Cycle",
"s12":"Worktree Task Isolation"
},
"version_meta":{
"s01":{"title_local":"The Agent Loop","subtitle":"Bash is All You Need","keyInsight":"The minimal agent kernel is a while loop + one tool","coreAddition":"Single-tool agent loop"},
"s02":{"title_local":"Tools","subtitle":"One Handler Per Tool","keyInsight":"The loop stays the same; new tools register via @Tool and defaultTools()","coreAddition":"@Tool annotation + defaultTools()"},
"s03":{"title_local":"TodoWrite","subtitle":"Plan Before You Act","keyInsight":"An agent without a plan drifts; list the steps first, then execute","coreAddition":"TodoManager + nag reminder"},
"s04":{"title_local":"Subagents","subtitle":"Clean Context Per Subtask","keyInsight":"Subagents use independent messages[], keeping the main conversation clean","coreAddition":"Subagent spawn with isolated messages[]"},
"s05":{"title_local":"Skills","subtitle":"Load on Demand","keyInsight":"Inject knowledge via tool_result when needed, not upfront in the system prompt","coreAddition":"SkillLoader + two-layer injection"},
"s06":{"title_local":"Compact","subtitle":"Three-Layer Compression","keyInsight":"Context will fill up; three-layer compression strategy enables infinite sessions","coreAddition":"micro-compact + auto-compact + archival"},
"s07":{"title_local":"Tasks","subtitle":"Task Graph + Dependencies","keyInsight":"A file-based task graph with ordering, parallelism, and dependencies -- the coordination backbone for multi-agent work","coreAddition":"TaskManager with file-based state + dependency graph"},
"s08":{"title_local":"Background Tasks","subtitle":"Background Threads + Notifications","keyInsight":"Run slow operations in the background; the agent keeps thinking ahead","coreAddition":"BackgroundManager + notification queue"},
"s09":{"title_local":"Agent Teams","subtitle":"Teammates + Mailboxes","keyInsight":"When one agent can't finish, delegate to persistent teammates via async mailboxes","coreAddition":"TeammateManager + file-based mailbox"},
"s10":{"title_local":"Team Protocols","subtitle":"Shared Communication Rules","keyInsight":"One request-response pattern drives all team negotiation","coreAddition":"request_id correlation for two protocols"},
"s11":{"title_local":"Autonomous Agents","subtitle":"Scan Board, Claim Tasks","keyInsight":"Teammates scan the board and claim tasks themselves; no need for the lead to assign each one","coreAddition":"Task board polling + timeout-based self-governance"},
"s12":{"title_local":"Worktree + Task Isolation","subtitle":"Isolate by Directory","keyInsight":"Each works in its own directory; tasks manage goals, worktrees manage directories, bound by ID","coreAddition":"Composable worktree lifecycle + event stream over a shared task board"}
s01:{title:"The Agent Loop",subtitle:"Bash is All You Need",coreAddition:"Single-tool agent loop",keyInsight:"The minimal agent kernel is a while loop + one tool",layer:"tools",prevVersion: null},
s02:{title:"Tools",subtitle:"One Handler Per Tool",coreAddition:"Tool dispatch map",keyInsight:"The loop stays the same; new tools register into the dispatch map",layer:"tools",prevVersion:"s01"},
s02:{title:"Tools",subtitle:"One Handler Per Tool",coreAddition:"@Tool annotation + defaultTools()",keyInsight:"The loop stays the same; new tools register via @Tool and defaultTools()",layer:"tools",prevVersion:"s01"},
s03:{title:"TodoWrite",subtitle:"Plan Before You Act",coreAddition:"TodoManager + nag reminder",keyInsight:"An agent without a plan drifts; list the steps first, then execute",layer:"planning",prevVersion:"s02"},
s04:{title:"Subagents",subtitle:"Clean Context Per Subtask",coreAddition:"Subagent spawn with isolated messages[]",keyInsight:"Subagents use independent messages[], keeping the main conversation clean",layer:"planning",prevVersion:"s03"},
s05:{title:"Skills",subtitle:"Load on Demand",coreAddition:"SkillLoader + two-layer injection",keyInsight:"Inject knowledge via tool_result when needed, not upfront in the system prompt",layer:"planning",prevVersion:"s04"},
s12:{title:"Worktree + Task Isolation",subtitle:"Isolate by Directory",coreAddition:"Composable worktree lifecycle + event stream over a shared task board",keyInsight:"Each works in its own directory; tasks manage goals, worktrees manage directories, bound by ID",layer:"collaboration",prevVersion:"s11"},
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.