diff --git a/README-ja.md b/README-ja.md index 3cea3f27..245ee114 100644 --- a/README-ja.md +++ b/README-ja.md @@ -262,7 +262,7 @@ def agent_loop(messages): | 旧 s10 | 現行 s15 | Team Protocols | | 旧 s11 | 現行 s16 | Autonomous Agents | | 旧 s12 | 現行 s17 | Worktree Isolation | -| 現行版のみ | s03、s04、s09、s10、s11、s14、s18、s19、s20、s21 | Permission、Hooks、Memory、Context Assembly、Error Recovery、Cron、MCP、Comprehensive Agent、Workflow Runtime、Goal Loop | +| 現行版のみ | s03、s04、s09、s10、s11、s14、s18、s19、s20、s21 | Permission、Hooks、Memory、Context Assembly、Error Recovery、Cron、MCP、Integrated Harness、Workflow Runtime、Goal Loop | ## コースの範囲 @@ -335,7 +335,7 @@ flowchart TD S5["第5段階:複数 Agent の協作
━━━━━━━━━━━━━
s15 Agent Teams
└─ チームメイト + 配信 + プロトコル

s16 Autonomous Agents
└─ 実行可能な仕事をボードから認領

s17 Worktree Isolation
└─ 別ディレクトリで作業"]:::stage5 - S6["第6段階:外部能力と統合
━━━━━━━━━━━━━
s07 Skill Loading
└─ スキルを必要時に展開

s18 MCP Plugin
└─ 外部ツールを同じプールへ

s19 Comprehensive Agent
└─ すべてを1つのループへ"]:::stage6 + S6["第6段階:外部能力と統合
━━━━━━━━━━━━━
s07 Skill Loading
└─ スキルを必要時に展開

s18 MCP Plugin
└─ 外部ツールを同じプールへ

s19 Integrated Harness
└─ すべてを1つのループへ"]:::stage6 S4 ==> S5 ==> S6 end @@ -375,7 +375,7 @@ flowchart TD | [s16](./s16_autonomous_agents/) | Autonomous Agents | タスクボード走査 / 原子的認領 / 自己組織化 | | [s17](./s17_worktree_isolation/) | Worktree Isolation | `WorktreeRecord` / タスク-ディレクトリ紐付け | | [s18](./s18_mcp_plugin/) | MCP Plugin | ツール発見 / 名前空間 / ツールプール組み立て | -| [s19](./s19_comprehensive/) | Comprehensive Agent | すべての仕組みを 1 つのループへ | +| [s19](./s19_comprehensive/) | Integrated Harness | すべての仕組みを 1 つのループへ | | [s20](./s20_workflow_runtime/) | Workflow Runtime | スクリプト編成 / バックグラウンド実行 / ジャーナル再開 | | [s21](./s21_goal_loop/) | Goal Loop | 目標ゲート / 信頼できる証拠 / 自動継続 | diff --git a/README-zh.md b/README-zh.md index c29857c1..566e3975 100644 --- a/README-zh.md +++ b/README-zh.md @@ -198,7 +198,7 @@ Claude Code = 一个 agent loop > > **s18**   *"能力不够? 插上 MCP"* — 把外部工具接进同一个工具池 > -> **s19**   *"机制很多,循环一个"* — 前面所有机制回到一个完整 harness +> **s19**   *"机制很多,循环一个"* — 前面所有机制集成到同一个 harness > > **s20**   *"编排形状固定时,就把它写进代码"* — 可恢复 journal 支撑确定性 workflow > @@ -262,7 +262,7 @@ def agent_loop(messages): | 旧 s10 | 新 s15 | Team Protocols | | 旧 s11 | 新 s16 | Autonomous Agents | | 旧 s12 | 新 s17 | Worktree Isolation | -| 新版新增 | s03、s04、s09、s10、s11、s14、s18、s19、s20、s21 | Permission、Hooks、Memory、Context Assembly、Error Recovery、Cron、MCP、Comprehensive Agent、Workflow Runtime、Goal Loop | +| 新版新增 | s03、s04、s09、s10、s11、s14、s18、s19、s20、s21 | Permission、Hooks、Memory、Context Assembly、Error Recovery、Cron、MCP、Agent Harness 集成、Workflow Runtime、Goal Loop | ## 课程边界 @@ -335,7 +335,7 @@ flowchart TD S5["第五阶段:让多个 Agent 协作
━━━━━━━━━━━━━
s15 Agent Teams
└─ 队友 + 消息投递 + 协作协议

s16 Autonomous Agents
└─ 自己看板认领就绪任务

s17 Worktree Isolation
└─ 隔离目录"]:::stage5 - S6["第六阶段:接外部能力合体
━━━━━━━━━━━━━
s07 Skill Loading
└─ 技能按需展开

s18 MCP Plugin
└─ 外部接进工具池

s19 Comprehensive Agent
└─ 全机制回单循环"]:::stage6 + S6["第六阶段:接外部能力合体
━━━━━━━━━━━━━
s07 Skill Loading
└─ 技能按需展开

s18 MCP Plugin
└─ 外部接进工具池

s19 Agent Harness 集成
└─ 全机制回单循环"]:::stage6 S4 ==> S5 ==> S6 end @@ -376,7 +376,7 @@ flowchart TD | [s16](./s16_autonomous_agents/) | Autonomous Agents | 扫描任务板 / 原子认领 / 自组织 | | [s17](./s17_worktree_isolation/) | Worktree Isolation | `WorktreeRecord` / 任务-目录绑定 | | [s18](./s18_mcp_plugin/) | MCP Plugin | 工具发现 / 命名空间 / 工具池组装 | -| [s19](./s19_comprehensive/) | Comprehensive Agent | 全部机制归到一个循环 | +| [s19](./s19_comprehensive/) | Agent Harness 集成 | 全部机制归到一个循环 | | [s20](./s20_workflow_runtime/) | Workflow Runtime | 脚本编排 / 后台运行 / journal 续跑 | | [s21](./s21_goal_loop/) | Goal Loop | 目标闸门 / 可信证据 / 自动续轮 | diff --git a/README.md b/README.md index 49da1fa9..23544421 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ If you are starting now, read the root-level `s01_agent_loop/` through `s21_goal | old s10 | new s15 | Team Protocols | | old s11 | new s16 | Autonomous Agents | | old s12 | new s17 | Worktree Isolation | -| new only | s03, s04, s09, s10, s11, s14, s18, s19, s20, s21 | Permission, Hooks, Memory, Context Assembly, Error Recovery, Cron, MCP, Comprehensive Agent, Workflow Runtime, Goal Loop | +| new only | s03, s04, s09, s10, s11, s14, s18, s19, s20, s21 | Permission, Hooks, Memory, Context Assembly, Error Recovery, Cron, MCP, Integrated Harness, Workflow Runtime, Goal Loop | --- @@ -240,7 +240,7 @@ This is a 0-to-1 harness engineering course. Each chapter isolates one mechanism > > **s18**   *"Not enough capability? Plug in more via MCP"* — connect external tools into the same tool pool > -> **s19**   *"Many mechanisms, one loop"* — all previous mechanisms return to one complete harness +> **s19**   *"Many mechanisms, one loop"* — all previous mechanisms return to one integrated harness > > **s20**   *"When the orchestration shape is fixed, put it in code"* — deterministic workflows with resumable journals > @@ -284,7 +284,7 @@ flowchart TD S5["5. Coordinate many Agents
━━━━━━━━━━━━━
s15 Agent Teams
└─ teammates + delivery + protocols

s16 Autonomous Agents
└─ claim ready work from the board

s17 Worktree Isolation
└─ separate directories"]:::stage5 - S6["6. Extend and assemble
━━━━━━━━━━━━━
s07 Skill Loading
└─ expand skills on demand

s18 MCP Plugin
└─ external tools, one pool

s19 Comprehensive Agent
└─ all mechanisms, one loop"]:::stage6 + S6["6. Extend and assemble
━━━━━━━━━━━━━
s07 Skill Loading
└─ expand skills on demand

s18 MCP Plugin
└─ external tools, one pool

s19 Integrated Harness
└─ all mechanisms, one loop"]:::stage6 S4 ==> S5 ==> S6 end @@ -326,7 +326,7 @@ flowchart TD | [s16](./s16_autonomous_agents/) | Autonomous Agents | task-board scan / atomic claim / self-organization | | [s17](./s17_worktree_isolation/) | Worktree Isolation | `WorktreeRecord` / task-directory binding | | [s18](./s18_mcp_plugin/) | MCP Plugin | tool discovery / namespaced tools / tool pool assembly | -| [s19](./s19_comprehensive/) | Comprehensive Agent | all mechanisms around one loop | +| [s19](./s19_comprehensive/) | Integrated Harness | all mechanisms around one loop | | [s20](./s20_workflow_runtime/) | Workflow Runtime | script orchestration / background execution / journal resume | | [s21](./s21_goal_loop/) | Goal Loop | goal gate / trusted evidence / automatic continuation | diff --git a/s18_mcp_plugin/README.ja.md b/s18_mcp_plugin/README.ja.md index 7cf2a918..a540539c 100644 --- a/s18_mcp_plugin/README.ja.md +++ b/s18_mcp_plugin/README.ja.md @@ -176,7 +176,7 @@ Agent は標準プロトコルで外部ツールに接続できるようにな tools、permissions、hooks、todo、task graph、memory、compact、background work、cron、teams、worktree、MCP は、別々の例ではなく同じ loop に接続されるべきです。 -s19 Comprehensive Agent → s01-s18 の仕組みを 1 つの完全な harness に統合。仕組みは多く、loop は 1 つ。 +s19 Integrated Harness → s01-s18 の仕組みを 1 つの harness に統合。仕組みは多く、loop は 1 つ。 diff --git a/s18_mcp_plugin/README.md b/s18_mcp_plugin/README.md index bdcc86d1..62b20b42 100644 --- a/s18_mcp_plugin/README.md +++ b/s18_mcp_plugin/README.md @@ -176,7 +176,7 @@ The Agent can now connect external tools through a standard protocol. The first Tools, permissions, hooks, todo, task graph, memory, compact, background work, cron, teams, worktrees, and MCP should all attach to the same loop, not live in separate examples. -s19 Comprehensive Agent → Combine the mechanisms from s01-s18 into one complete harness. Many mechanisms, one loop. +s19 Integrated Harness → Combine the mechanisms from s01-s18 into one harness. Many mechanisms, one loop. diff --git a/s18_mcp_plugin/README.zh.md b/s18_mcp_plugin/README.zh.md index 9431e152..a445aa04 100644 --- a/s18_mcp_plugin/README.zh.md +++ b/s18_mcp_plugin/README.zh.md @@ -176,7 +176,7 @@ python s18_mcp_plugin/code.py 工具、权限、hooks、todo、任务图、记忆、压缩、后台、cron、团队、worktree、MCP 这些机制应该挂在同一个循环上,而不是散在 19 个 demo 里。 -s19 Comprehensive Agent → 把 s01-s18 的机制合回一个完整 harness。机制很多,循环一个。 +s19 Agent Harness 集成 → 把 s01-s18 的机制合回同一个 harness。机制很多,循环一个。 diff --git a/s19_comprehensive/README.ja.md b/s19_comprehensive/README.ja.md index 19cc0e18..b90a4223 100644 --- a/s19_comprehensive/README.ja.md +++ b/s19_comprehensive/README.ja.md @@ -1,4 +1,4 @@ -# s19: Comprehensive Agent — すべての仕組みを 1 つのループへ +# s19: Integrated Harness — 多くの仕組みを 1 つのループへ [English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md) @@ -6,7 +6,7 @@ s01 → ... → s17 → s18 → `s19` → [s20](../s20_workflow_runtime/) → s2 > *"仕組みは多い、ループは 1 つ"* — tools、permissions、memory、tasks、teams、plugins はすべて同じ `while True` に接続される。 > -> **Harness レイヤー**: 総合 — s01-s18 の仕組みを 1 つの実行可能なシステムへ戻す。 +> **Harness レイヤー**: 統合 — s01-s18 の仕組みを 1 つの実行可能なシステムへ戻す。 --- @@ -34,7 +34,7 @@ s01 → ... → s17 → s18 → `s19` → [s20](../s20_workflow_runtime/) → s2 ![System Architecture](images/system-architecture.ja.svg) -S19 は新しい mechanism を追加せず、前章までの component を 1 つの完全な harness に統合する: +S19 は新しい mechanism を追加せず、前章までの component を同じ harness に統合する: ```text user input diff --git a/s19_comprehensive/README.md b/s19_comprehensive/README.md index 935dbb22..05722520 100644 --- a/s19_comprehensive/README.md +++ b/s19_comprehensive/README.md @@ -1,4 +1,4 @@ -# s19: Comprehensive Agent — All Mechanisms, One Loop +# s19: Integrated Harness — Many Mechanisms, One Loop [English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md) @@ -6,7 +6,7 @@ s01 → ... → s17 → s18 → `s19` → [s20](../s20_workflow_runtime/) → s2 > *"Many mechanisms, one loop"* — tools, permissions, memory, tasks, teams, and plugins all hang off the same `while True`. > -> **Harness layer**: Comprehensive — put the mechanisms from s01-s18 into one runnable system. +> **Harness layer**: Integration — put the mechanisms from s01-s18 into one runnable system. --- @@ -34,7 +34,7 @@ The hard part is not piling up features. The hard part is seeing where each mech ![System Architecture](images/system-architecture.en.svg) -S19 does not introduce a new mechanism. It connects the components from the earlier chapters in one complete harness: +S19 does not introduce a new mechanism. It connects the components from the earlier chapters in one integrated harness: ```text user input diff --git a/s19_comprehensive/README.zh.md b/s19_comprehensive/README.zh.md index 52562107..b19c143d 100644 --- a/s19_comprehensive/README.zh.md +++ b/s19_comprehensive/README.zh.md @@ -1,4 +1,4 @@ -# s19: Comprehensive Agent — 全部机制,归到一个循环 +# s19: Agent Harness 集成 — 多种机制,一个循环 [English](README.md) · [中文](README.zh.md) · [日本語](README.ja.md) @@ -6,7 +6,7 @@ s01 → ... → s17 → s18 → `s19` → [s20](../s20_workflow_runtime/) → s2 > *"机制很多,循环一个"* — 工具、权限、记忆、任务、团队、插件都挂在同一个 while True 上。 > -> **Harness 层**: 综合 — 把 s01-s18 的机制放回同一个可运行系统。 +> **Harness 层**: 集成 — 把 s01-s18 的机制放回同一个可运行系统。 --- @@ -34,7 +34,7 @@ s01 → ... → s17 → s18 → `s19` → [s20](../s20_workflow_runtime/) → s2 ![System Architecture](images/system-architecture.svg) -S19 不再引入新机制,而是把前面各章的组件接入一个完整 harness: +S19 不再引入新机制,而是把前面各章的组件集成到同一个 harness: ```text 用户输入 diff --git a/s19_comprehensive/code.py b/s19_comprehensive/code.py index fe20cdfb..d7b6ef96 100644 --- a/s19_comprehensive/code.py +++ b/s19_comprehensive/code.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -s19: Comprehensive Agent — all course mechanisms in one loop. +s19: Integrated Harness — many mechanisms in one loop. Run: python s19_comprehensive/code.py Need: pip install anthropic python-dotenv pyyaml + .env with ANTHROPIC_API_KEY @@ -2259,7 +2259,7 @@ def async_event_loop(history: list, context: dict): if __name__ == "__main__": CLI_ACTIVE = True - print("s19: comprehensive agent") + print("s19: integrated harness") print("Enter a question, press Enter to send. Type q to quit.\n") history = [] context = update_context({}, []) diff --git a/s19_comprehensive/images/system-architecture.en.svg b/s19_comprehensive/images/system-architecture.en.svg index 9958a972..7b74936c 100644 --- a/s19_comprehensive/images/system-architecture.en.svg +++ b/s19_comprehensive/images/system-architecture.en.svg @@ -20,7 +20,7 @@ - s19 Comprehensive Agent — Every Mechanism Around One Loop + s19 Integrated Harness — Many Mechanisms, One Loop Core Agent Loop diff --git a/s19_comprehensive/images/system-architecture.ja.svg b/s19_comprehensive/images/system-architecture.ja.svg index 0e5076a4..c6411952 100644 --- a/s19_comprehensive/images/system-architecture.ja.svg +++ b/s19_comprehensive/images/system-architecture.ja.svg @@ -20,7 +20,7 @@ - s19 Comprehensive Agent — すべての仕組みを 1 つのループへ + s19 Integrated Harness — 多くの仕組みを 1 つのループへ Core Agent Loop diff --git a/s19_comprehensive/images/system-architecture.svg b/s19_comprehensive/images/system-architecture.svg index e37fb1e8..4a0f130e 100644 --- a/s19_comprehensive/images/system-architecture.svg +++ b/s19_comprehensive/images/system-architecture.svg @@ -21,7 +21,7 @@ - s19 Comprehensive Agent — 全部机制挂在同一个循环上 + s19 Agent Harness 集成 — 多种机制,一个循环 diff --git a/s20_workflow_runtime/README.ja.md b/s20_workflow_runtime/README.ja.md index b734a524..97002dfa 100644 --- a/s20_workflow_runtime/README.ja.md +++ b/s20_workflow_runtime/README.ja.md @@ -201,7 +201,7 @@ async def sample_workflow(ctx, args): ## s19 からの変更点 -| | s19 Comprehensive Agent | s20 Workflow Runtime | +| | s19 Integrated Harness | s20 Workflow Runtime | |--|-----------|---------------------| | loop | 1 つ、モデル駆動 | main loop は不変。その上に決定的 orchestration を追加 | | 次の step を決めるもの | モデルが毎ラウンド判断 | script が orchestration flow を事前に定義 | diff --git a/s20_workflow_runtime/README.md b/s20_workflow_runtime/README.md index 82f55c76..45649c8e 100644 --- a/s20_workflow_runtime/README.md +++ b/s20_workflow_runtime/README.md @@ -201,7 +201,7 @@ async def sample_workflow(ctx, args): ## Changes from s19 -| | s19 Comprehensive Agent | s20 Workflow Runtime | +| | s19 Integrated Harness | s20 Workflow Runtime | |--|-----------|---------------------| | Loop | One model-driven loop | Main loop unchanged; deterministic orchestration added above it | | Who decides the next step | Model decides each round | Script declares the orchestration in advance | diff --git a/s20_workflow_runtime/README.zh.md b/s20_workflow_runtime/README.zh.md index 5bcf8d30..21b18f40 100644 --- a/s20_workflow_runtime/README.zh.md +++ b/s20_workflow_runtime/README.zh.md @@ -201,7 +201,7 @@ async def sample_workflow(ctx, args): ## 相对 s19 的变更 -| | s19 综合体 | s20 Workflow Runtime | +| | s19 Agent Harness 集成 | s20 Workflow Runtime | |--|-----------|---------------------| | 循环 | 单个、模型驱动 | 主循环不变;上面加一层确定的编排 | | 谁决定下一步 | 模型逐轮决定 | 脚本预先写好编排流程 | diff --git a/web/public/course-assets/s19_comprehensive/system-architecture.en.svg b/web/public/course-assets/s19_comprehensive/system-architecture.en.svg index 9958a972..7b74936c 100644 --- a/web/public/course-assets/s19_comprehensive/system-architecture.en.svg +++ b/web/public/course-assets/s19_comprehensive/system-architecture.en.svg @@ -20,7 +20,7 @@ - s19 Comprehensive Agent — Every Mechanism Around One Loop + s19 Integrated Harness — Many Mechanisms, One Loop Core Agent Loop diff --git a/web/public/course-assets/s19_comprehensive/system-architecture.ja.svg b/web/public/course-assets/s19_comprehensive/system-architecture.ja.svg index 0e5076a4..c6411952 100644 --- a/web/public/course-assets/s19_comprehensive/system-architecture.ja.svg +++ b/web/public/course-assets/s19_comprehensive/system-architecture.ja.svg @@ -20,7 +20,7 @@ - s19 Comprehensive Agent — すべての仕組みを 1 つのループへ + s19 Integrated Harness — 多くの仕組みを 1 つのループへ Core Agent Loop diff --git a/web/public/course-assets/s19_comprehensive/system-architecture.svg b/web/public/course-assets/s19_comprehensive/system-architecture.svg index e37fb1e8..4a0f130e 100644 --- a/web/public/course-assets/s19_comprehensive/system-architecture.svg +++ b/web/public/course-assets/s19_comprehensive/system-architecture.svg @@ -21,7 +21,7 @@ - s19 Comprehensive Agent — 全部机制挂在同一个循环上 + s19 Agent Harness 集成 — 多种机制,一个循环 diff --git a/web/src/components/visualizations/s20-comprehensive.tsx b/web/src/components/visualizations/s20-comprehensive.tsx index 1b50a7a5..56ff900b 100644 --- a/web/src/components/visualizations/s20-comprehensive.tsx +++ b/web/src/components/visualizations/s20-comprehensive.tsx @@ -101,7 +101,7 @@ const STEPS: { }[] = [ { title: "A Turn Starts as a Packet", - desc: "The comprehensive agent first gathers everything the model should see, instead of scattering context across hidden places.", + desc: "The integrated harness first gathers everything the model should see, instead of scattering context across hidden places.", stage: "intake", used: ["intake"], packet: { @@ -281,7 +281,7 @@ export default function ComprehensiveVisualization({ title }: { title?: string } return (

- {title || "Comprehensive Agent Turn"} + {title || "Integrated Harness Turn"}

diff --git a/web/src/data/annotations/s19.json b/web/src/data/annotations/s19.json index 5f2c49bf..5dcc47fc 100644 --- a/web/src/data/annotations/s19.json +++ b/web/src/data/annotations/s19.json @@ -3,16 +3,16 @@ "decisions": [ { "id": "composition-over-new-loop", - "title": "The Final Agent Composes Previous Layers", - "description": "The comprehensive agent does not replace the loop with a new architecture. It composes memory, tasks, skills, background work, teams, worktrees, and MCP around the same core model-tool-result cycle.", + "title": "The Harness Composes Previous Layers", + "description": "The integrated harness does not replace the loop with a new architecture. It composes memory, tasks, skills, background work, teams, worktrees, and MCP around the same core model-tool-result cycle.", "alternatives": "A new orchestration framework would look more impressive, but it would hide the continuity across the course.", "zh": { - "title": "最终 Agent 是组合既有层,而不是换掉循环", - "description": "综合 Agent 没有用新架构替换循环,而是把 memory、task、skill、后台任务、团队、worktree、MCP 组合到同一个模型-工具-结果循环周围。" + "title": "Harness 组合既有层,而不是换掉循环", + "description": "集成后的 Harness 没有用新架构替换循环,而是把 memory、task、skill、后台任务、团队、worktree、MCP 组合到同一个模型-工具-结果循环周围。" }, "ja": { - "title": "最終エージェントは既存レイヤーの合成", - "description": "総合エージェントはループを新しい構造で置き換えません。memory、task、skill、バックグラウンド処理、チーム、worktree、MCP を同じ model-tool-result サイクルの周囲に合成します。" + "title": "Harness は既存レイヤーを統合する", + "description": "統合された Harness はループを新しい構造で置き換えません。memory、task、skill、バックグラウンド処理、チーム、worktree、MCP を同じ model-tool-result サイクルの周囲に合成します。" } }, { diff --git a/web/src/data/generated/docs.json b/web/src/data/generated/docs.json index 63bd9083..9dea77c5 100644 --- a/web/src/data/generated/docs.json +++ b/web/src/data/generated/docs.json @@ -309,55 +309,55 @@ "version": "s18", "locale": "en", "title": "s18: MCP Tools — External Tools, Standard Protocol", - "content": "# s18: MCP Tools — External Tools, Standard Protocol\n\ns01 → ... → s16 → s17 → `s18` → [s19](/en/s19) → s20 → s21\n\n> *\"External tools, standard protocol\"* — Discover, assemble, invoke. Agent doesn't need to know who wrote them.\n>\n> **Harness layer**: Plugins — External capabilities via a standard protocol.\n\n---\n\n## The Problem\n\nFrom s01 through s17, every tool the agent uses was hand-written — bash, read, write, task, worktree. Input validation, execution logic, error handling — all written line by line.\n\nNow you have 3 external services to integrate: the company's Jira API (query issues, create tickets), an in-house deployment system (trigger deploys, view logs), and the team's Notion knowledge base (search docs, create pages). You don't want to rewrite tool code for every service.\n\nYou need a standard protocol — as long as an external service implements it, the agent can call its tools directly, regardless of what language the service is written in.\n\n---\n\n## The Solution\n\n![MCP Architecture](/course-assets/s18_mcp_plugin/mcp-architecture.en.svg)\n\nMCP (Model Context Protocol) defines how agents discover and invoke external tools. Core concepts:\n\n| Concept | Purpose |\n|------|------|\n| MCPClient | The agent-side client — connects to servers, discovers tools, invokes tools |\n| MCP Server | The external service — implements `tools/list` + `tools/call` |\n| assemble_tool_pool | Assembles built-in tools and MCP tools into one tool pool |\n| mcp\\_\\_server\\_\\_tool naming | Prevents tool name collisions across different servers |\n\nCarries forward s17's worktree isolation, autonomous claiming, and team protocols. This chapter adds the `connect_mcp` tool, which connects to a service, discovers its tools, and adds them to the tool pool.\n\nThe chapter registers in-process server handlers so the full discovery and invocation flow runs offline. Each handler exposes the two operations the client needs: `tools/list` and `tools/call`.\n\n---\n\n## How It Works\n\n### MCPClient: Discovery + Invocation\n\n```python\nclass MCPClient:\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs, handlers):\n \"\"\"Simulates tools/list discovery.\"\"\"\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n \"\"\"Simulates tools/call.\"\"\"\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n return handler(**args)\n```\n\nThe registered Python functions provide the server-side tool implementations used by `tools/call`.\n\n### connect_mcp: Connect + Discover\n\n```python\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n return f\"Unknown server '{name}'. Available: ...\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n return f\"Connected to '{name}'. Discovered: ...\"\n```\n\nAfter connecting, the server's tools are immediately available.\n\n### normalize_mcp_name: Name Normalization\n\n```python\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\ndef normalize_mcp_name(name: str) -> str:\n return _DISALLOWED_CHARS.sub('_', name)\n```\n\nAll non-`[a-zA-Z0-9_-]` characters are replaced with `_`. Prevents special characters in server or tool names from causing naming conflicts or injection issues.\n\n### assemble_tool_pool: Assemble Tool Pool\n\n```python\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append(...)\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw:\n c.call_tool(t, kw))\n return tools, handlers\n```\n\nThe prefix `mcp__{server}__{tool}` prevents tool name collisions across different servers. Names are normalized through `normalize_mcp_name`.\n\nMCP tool descriptions include `(readOnly)` or `(destructive)` labels, making the distinction visible in the tool metadata.\n\n### No Cache: Tool Pool Changes, Prompt Changes Too\n\ns10-s17's agent_loop used prompt caching to avoid re-serialization. s18 removes the cache:\n\n```python\ndef agent_loop(messages, context):\n tools, handlers = assemble_tool_pool() # Rebuild every time\n system = assemble_system_prompt(context) # Regenerate every time\n ...\n if any(b.name == \"connect_mcp\" ...):\n tools, handlers = assemble_tool_pool() # Rebuild after connection\n system = assemble_system_prompt(context)\n```\n\nAfter `connect_mcp`, the tool pool gains entries such as `mcp__docs__search`. Reusing the old serialized tool list would hide those entries from the model, so the loop rebuilds the pool and system prompt after every connection.\n\n### MCP Tools: Lead Only\n\n`connect_mcp` belongs to the Lead, and `assemble_tool_pool` serves the Lead's agent loop. Teammates keep their task, file, message, and plan tools; the Lead invokes external services and dispatches the resulting work.\n\n---\n\n## Changes from s17\n\n| Component | Before (s17) | After (s18) |\n|------|-----------|-----------|\n| Tool source | All hand-written built-in | Hand-written + MCP external tools with dynamic discovery |\n| Tool pool | Fixed BUILTIN_TOOLS | assemble_tool_pool dynamically assembles mcp\\_\\_ prefixed tools |\n| Name safety | None | normalize_mcp_name normalization |\n| New type | — | MCPClient class (simulates tools/list + tools/call) |\n| Namespace | — | mcp\\_\\_server\\_\\_tool prevents collisions |\n| Tool descriptions | No annotations | (readOnly)/(destructive) annotations |\n| Prompt cache | Yes (since s10) | Removed — tool pool is dynamic, cache goes stale |\n| Lead tools | Worktree and team tools | + connect_mcp and dynamically discovered MCP tools |\n| Teammate tools | Task, file, message, and plan tools | Unchanged |\n| Extension method | Write code to add tools | Standard protocol, implement servers in any language |\n\n---\n\n## Try It Out\n\n```sh\ncd learn-claude-code\npython s18_mcp_plugin/code.py\n```\n\nTry these prompts:\n\n1. `Search the docs for the worktree cleanup policy.`\n2. `Deploy the current project and report the result.`\n3. `What documentation and deployment actions can you perform?`\n\nWhat to observe: After connecting to an MCP server, do tool names have `mcp__docs__` or `mcp__deploy__` prefixes? Are both servers' tools available simultaneously? Do MCP tool descriptions include (readOnly)/(destructive) annotations?\n\n---\n\n## What's Next\n\nThe Agent can now connect external tools through a standard protocol. The first 18 chapters introduced these mechanisms one at a time so each boundary stayed visible.\n\nTools, permissions, hooks, todo, task graph, memory, compact, background work, cron, teams, worktrees, and MCP should all attach to the same loop, not live in separate examples.\n\ns19 Comprehensive Agent → Combine the mechanisms from s01-s18 into one complete harness. Many mechanisms, one loop.\n\n\n\n" + "content": "# s18: MCP Tools — External Tools, Standard Protocol\n\ns01 → ... → s16 → s17 → `s18` → [s19](/en/s19) → s20 → s21\n\n> *\"External tools, standard protocol\"* — Discover, assemble, invoke. Agent doesn't need to know who wrote them.\n>\n> **Harness layer**: Plugins — External capabilities via a standard protocol.\n\n---\n\n## The Problem\n\nFrom s01 through s17, every tool the agent uses was hand-written — bash, read, write, task, worktree. Input validation, execution logic, error handling — all written line by line.\n\nNow you have 3 external services to integrate: the company's Jira API (query issues, create tickets), an in-house deployment system (trigger deploys, view logs), and the team's Notion knowledge base (search docs, create pages). You don't want to rewrite tool code for every service.\n\nYou need a standard protocol — as long as an external service implements it, the agent can call its tools directly, regardless of what language the service is written in.\n\n---\n\n## The Solution\n\n![MCP Architecture](/course-assets/s18_mcp_plugin/mcp-architecture.en.svg)\n\nMCP (Model Context Protocol) defines how agents discover and invoke external tools. Core concepts:\n\n| Concept | Purpose |\n|------|------|\n| MCPClient | The agent-side client — connects to servers, discovers tools, invokes tools |\n| MCP Server | The external service — implements `tools/list` + `tools/call` |\n| assemble_tool_pool | Assembles built-in tools and MCP tools into one tool pool |\n| mcp\\_\\_server\\_\\_tool naming | Prevents tool name collisions across different servers |\n\nCarries forward s17's worktree isolation, autonomous claiming, and team protocols. This chapter adds the `connect_mcp` tool, which connects to a service, discovers its tools, and adds them to the tool pool.\n\nThe chapter registers in-process server handlers so the full discovery and invocation flow runs offline. Each handler exposes the two operations the client needs: `tools/list` and `tools/call`.\n\n---\n\n## How It Works\n\n### MCPClient: Discovery + Invocation\n\n```python\nclass MCPClient:\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs, handlers):\n \"\"\"Simulates tools/list discovery.\"\"\"\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n \"\"\"Simulates tools/call.\"\"\"\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n return handler(**args)\n```\n\nThe registered Python functions provide the server-side tool implementations used by `tools/call`.\n\n### connect_mcp: Connect + Discover\n\n```python\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n return f\"Unknown server '{name}'. Available: ...\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n return f\"Connected to '{name}'. Discovered: ...\"\n```\n\nAfter connecting, the server's tools are immediately available.\n\n### normalize_mcp_name: Name Normalization\n\n```python\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\ndef normalize_mcp_name(name: str) -> str:\n return _DISALLOWED_CHARS.sub('_', name)\n```\n\nAll non-`[a-zA-Z0-9_-]` characters are replaced with `_`. Prevents special characters in server or tool names from causing naming conflicts or injection issues.\n\n### assemble_tool_pool: Assemble Tool Pool\n\n```python\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append(...)\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw:\n c.call_tool(t, kw))\n return tools, handlers\n```\n\nThe prefix `mcp__{server}__{tool}` prevents tool name collisions across different servers. Names are normalized through `normalize_mcp_name`.\n\nMCP tool descriptions include `(readOnly)` or `(destructive)` labels, making the distinction visible in the tool metadata.\n\n### No Cache: Tool Pool Changes, Prompt Changes Too\n\ns10-s17's agent_loop used prompt caching to avoid re-serialization. s18 removes the cache:\n\n```python\ndef agent_loop(messages, context):\n tools, handlers = assemble_tool_pool() # Rebuild every time\n system = assemble_system_prompt(context) # Regenerate every time\n ...\n if any(b.name == \"connect_mcp\" ...):\n tools, handlers = assemble_tool_pool() # Rebuild after connection\n system = assemble_system_prompt(context)\n```\n\nAfter `connect_mcp`, the tool pool gains entries such as `mcp__docs__search`. Reusing the old serialized tool list would hide those entries from the model, so the loop rebuilds the pool and system prompt after every connection.\n\n### MCP Tools: Lead Only\n\n`connect_mcp` belongs to the Lead, and `assemble_tool_pool` serves the Lead's agent loop. Teammates keep their task, file, message, and plan tools; the Lead invokes external services and dispatches the resulting work.\n\n---\n\n## Changes from s17\n\n| Component | Before (s17) | After (s18) |\n|------|-----------|-----------|\n| Tool source | All hand-written built-in | Hand-written + MCP external tools with dynamic discovery |\n| Tool pool | Fixed BUILTIN_TOOLS | assemble_tool_pool dynamically assembles mcp\\_\\_ prefixed tools |\n| Name safety | None | normalize_mcp_name normalization |\n| New type | — | MCPClient class (simulates tools/list + tools/call) |\n| Namespace | — | mcp\\_\\_server\\_\\_tool prevents collisions |\n| Tool descriptions | No annotations | (readOnly)/(destructive) annotations |\n| Prompt cache | Yes (since s10) | Removed — tool pool is dynamic, cache goes stale |\n| Lead tools | Worktree and team tools | + connect_mcp and dynamically discovered MCP tools |\n| Teammate tools | Task, file, message, and plan tools | Unchanged |\n| Extension method | Write code to add tools | Standard protocol, implement servers in any language |\n\n---\n\n## Try It Out\n\n```sh\ncd learn-claude-code\npython s18_mcp_plugin/code.py\n```\n\nTry these prompts:\n\n1. `Search the docs for the worktree cleanup policy.`\n2. `Deploy the current project and report the result.`\n3. `What documentation and deployment actions can you perform?`\n\nWhat to observe: After connecting to an MCP server, do tool names have `mcp__docs__` or `mcp__deploy__` prefixes? Are both servers' tools available simultaneously? Do MCP tool descriptions include (readOnly)/(destructive) annotations?\n\n---\n\n## What's Next\n\nThe Agent can now connect external tools through a standard protocol. The first 18 chapters introduced these mechanisms one at a time so each boundary stayed visible.\n\nTools, permissions, hooks, todo, task graph, memory, compact, background work, cron, teams, worktrees, and MCP should all attach to the same loop, not live in separate examples.\n\ns19 Integrated Harness → Combine the mechanisms from s01-s18 into one harness. Many mechanisms, one loop.\n\n\n\n" }, { "version": "s18", "locale": "zh", "title": "s18: MCP Tools — 外接工具,标准协议", - "content": "# s18: MCP Tools — 外接工具,标准协议\n\ns01 → ... → s16 → s17 → `s18` → [s19](/zh/s19) → s20 → s21\n\n> *\"外接工具, 标准协议\"* — 发现、组装、调用,Agent 不需要知道工具是谁写的。\n>\n> **Harness 层**: 插件 — 外部能力通过标准协议接入。\n\n---\n\n## 问题\n\ns01 到 s17,Agent 的所有工具都是手写的——bash、read、write、task、worktree。每个工具的输入验证、执行逻辑、错误处理,都是你一行行写的。\n\n现在你有 3 个外部服务想接入:公司的 Jira API(查 issue、建 ticket)、自建的部署系统(触发 deploy、看日志)、团队的 Notion 知识库(搜文档、建页面)。你不想为每个服务重写一套工具代码。\n\n你需要一个标准协议——外部服务只要实现它,Agent 就能直接调用,不管服务用什么语言写的。\n\n---\n\n## 解决方案\n\n![MCP Architecture](/course-assets/s18_mcp_plugin/mcp-architecture.svg)\n\nMCP(Model Context Protocol)定义了 Agent 如何发现和调用外部工具。核心概念:\n\n| 概念 | 作用 |\n|------|------|\n| MCPClient | Agent 端的客户端,连接 server、发现工具、调用工具 |\n| MCP Server | 外部服务,实现 `tools/list` + `tools/call` |\n| assemble_tool_pool | 把内置工具和 MCP 工具组装成一个工具池 |\n| mcp\\_\\_server\\_\\_tool 命名 | 避免不同 server 的工具名冲突 |\n\n沿用 s17 的 worktree 隔离、自主认领和团队协议。本章新增 `connect_mcp` 工具,用于连接服务、发现工具并加入工具池。\n\n本章注册进程内 server handler,让工具发现和调用流程可以离线运行。每个 handler 都提供客户端需要的 `tools/list` 和 `tools/call` 两个操作。\n\n---\n\n## 工作原理\n\n### MCPClient:发现 + 调用\n\n```python\nclass MCPClient:\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs, handlers):\n \"\"\"Simulates tools/list discovery.\"\"\"\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n \"\"\"Simulates tools/call.\"\"\"\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n return handler(**args)\n```\n\n注册的 Python 函数提供 `tools/call` 所调用的 server 端工具实现。\n\n### connect_mcp:连接 + 发现\n\n```python\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n return f\"Unknown server '{name}'. Available: ...\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n return f\"Connected to '{name}'. Discovered: ...\"\n```\n\n连接后,server 提供的工具立即可用。\n\n### normalize_mcp_name:名称规范化\n\n```python\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\ndef normalize_mcp_name(name: str) -> str:\n return _DISALLOWED_CHARS.sub('_', name)\n```\n\n所有非 `[a-zA-Z0-9_-]` 的字符替换为 `_`。防止 server 名或工具名中包含特殊字符导致命名冲突或注入问题。\n\n### assemble_tool_pool:组装工具池\n\n```python\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append(...)\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw:\n c.call_tool(t, kw))\n return tools, handlers\n```\n\n前缀 `mcp__{server}__{tool}` 避免不同 server 的工具名冲突。名称经过 `normalize_mcp_name` 规范化。\n\nMCP 工具的 description 带 `(readOnly)` 或 `(destructive)` 标注,让只读操作和修改操作在工具元数据中直接可见。\n\n### 无缓存:工具池变了,prompt 也变\n\ns10-s17 的 agent_loop 用 prompt cache 避免重复序列化。s18 去掉了缓存:\n\n```python\ndef agent_loop(messages, context):\n tools, handlers = assemble_tool_pool() # 每次重新构建\n system = assemble_system_prompt(context) # 每次重新生成\n ...\n if any(b.name == \"connect_mcp\" ...):\n tools, handlers = assemble_tool_pool() # 连接后重建\n system = assemble_system_prompt(context)\n```\n\n`connect_mcp` 之后,工具池会新增 `mcp__docs__search` 等条目。继续复用旧的序列化工具列表,模型就看不到这些工具,所以每次连接后都要重建工具池和 system prompt。\n\n### MCP 工具只有 Lead 可用\n\n`connect_mcp` 属于 Lead,`assemble_tool_pool` 也服务于 Lead 的 agent loop。Teammate 保留任务、文件、消息和计划工具,由 Lead 调用外部服务,再把得到的工作分派下去。\n\n---\n\n## 相对 s17 的变更\n\n| 组件 | 之前 (s17) | 之后 (s18) |\n|------|-----------|-----------|\n| 工具来源 | 全部手写 builtin | 手写 + MCP 外部工具动态发现 |\n| 工具池 | 固定 BUILTIN_TOOLS | assemble_tool_pool 动态组装 mcp\\_\\_ 前缀工具 |\n| 名称安全 | 无 | normalize_mcp_name 规范化 |\n| 新类型 | — | MCPClient 类(模拟 tools/list + tools/call) |\n| 命名空间 | — | mcp\\_\\_server\\_\\_tool 避免冲突 |\n| 工具描述 | 无标注 | (readOnly)/(destructive) 标注 |\n| prompt 缓存 | 有(s10 起) | 去掉——工具池动态变化后缓存失效 |\n| Lead 工具 | worktree 与团队工具 | + connect_mcp 和动态发现的 MCP 工具 |\n| Teammate 工具 | 任务、文件、消息与计划工具 | 不变 |\n| 扩展方式 | 写代码加工具 | 标准协议,任意语言实现 server |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s18_mcp_plugin/code.py\n```\n\n试试这些 prompt:\n\n1. `查一下文档里的 worktree 清理策略。`\n2. `部署当前项目,并告诉我结果。`\n3. `你现在可以执行哪些文档和部署操作?`\n\n观察重点:连接 MCP server 后,工具名是否带 `mcp__docs__` 或 `mcp__deploy__` 前缀?两个 server 的工具是否同时可用?MCP 工具的 description 是否带 (readOnly)/(destructive) 标注?\n\n---\n\n## 接下来\n\n现在 Agent 可以通过标准协议接入外部工具了。前 18 章逐个引入这些机制,让每个边界都能单独观察。\n\n工具、权限、hooks、todo、任务图、记忆、压缩、后台、cron、团队、worktree、MCP 这些机制应该挂在同一个循环上,而不是散在 19 个 demo 里。\n\ns19 Comprehensive Agent → 把 s01-s18 的机制合回一个完整 harness。机制很多,循环一个。\n\n\n\n" + "content": "# s18: MCP Tools — 外接工具,标准协议\n\ns01 → ... → s16 → s17 → `s18` → [s19](/zh/s19) → s20 → s21\n\n> *\"外接工具, 标准协议\"* — 发现、组装、调用,Agent 不需要知道工具是谁写的。\n>\n> **Harness 层**: 插件 — 外部能力通过标准协议接入。\n\n---\n\n## 问题\n\ns01 到 s17,Agent 的所有工具都是手写的——bash、read、write、task、worktree。每个工具的输入验证、执行逻辑、错误处理,都是你一行行写的。\n\n现在你有 3 个外部服务想接入:公司的 Jira API(查 issue、建 ticket)、自建的部署系统(触发 deploy、看日志)、团队的 Notion 知识库(搜文档、建页面)。你不想为每个服务重写一套工具代码。\n\n你需要一个标准协议——外部服务只要实现它,Agent 就能直接调用,不管服务用什么语言写的。\n\n---\n\n## 解决方案\n\n![MCP Architecture](/course-assets/s18_mcp_plugin/mcp-architecture.svg)\n\nMCP(Model Context Protocol)定义了 Agent 如何发现和调用外部工具。核心概念:\n\n| 概念 | 作用 |\n|------|------|\n| MCPClient | Agent 端的客户端,连接 server、发现工具、调用工具 |\n| MCP Server | 外部服务,实现 `tools/list` + `tools/call` |\n| assemble_tool_pool | 把内置工具和 MCP 工具组装成一个工具池 |\n| mcp\\_\\_server\\_\\_tool 命名 | 避免不同 server 的工具名冲突 |\n\n沿用 s17 的 worktree 隔离、自主认领和团队协议。本章新增 `connect_mcp` 工具,用于连接服务、发现工具并加入工具池。\n\n本章注册进程内 server handler,让工具发现和调用流程可以离线运行。每个 handler 都提供客户端需要的 `tools/list` 和 `tools/call` 两个操作。\n\n---\n\n## 工作原理\n\n### MCPClient:发现 + 调用\n\n```python\nclass MCPClient:\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs, handlers):\n \"\"\"Simulates tools/list discovery.\"\"\"\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n \"\"\"Simulates tools/call.\"\"\"\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n return handler(**args)\n```\n\n注册的 Python 函数提供 `tools/call` 所调用的 server 端工具实现。\n\n### connect_mcp:连接 + 发现\n\n```python\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n return f\"Unknown server '{name}'. Available: ...\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n return f\"Connected to '{name}'. Discovered: ...\"\n```\n\n连接后,server 提供的工具立即可用。\n\n### normalize_mcp_name:名称规范化\n\n```python\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\ndef normalize_mcp_name(name: str) -> str:\n return _DISALLOWED_CHARS.sub('_', name)\n```\n\n所有非 `[a-zA-Z0-9_-]` 的字符替换为 `_`。防止 server 名或工具名中包含特殊字符导致命名冲突或注入问题。\n\n### assemble_tool_pool:组装工具池\n\n```python\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append(...)\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw:\n c.call_tool(t, kw))\n return tools, handlers\n```\n\n前缀 `mcp__{server}__{tool}` 避免不同 server 的工具名冲突。名称经过 `normalize_mcp_name` 规范化。\n\nMCP 工具的 description 带 `(readOnly)` 或 `(destructive)` 标注,让只读操作和修改操作在工具元数据中直接可见。\n\n### 无缓存:工具池变了,prompt 也变\n\ns10-s17 的 agent_loop 用 prompt cache 避免重复序列化。s18 去掉了缓存:\n\n```python\ndef agent_loop(messages, context):\n tools, handlers = assemble_tool_pool() # 每次重新构建\n system = assemble_system_prompt(context) # 每次重新生成\n ...\n if any(b.name == \"connect_mcp\" ...):\n tools, handlers = assemble_tool_pool() # 连接后重建\n system = assemble_system_prompt(context)\n```\n\n`connect_mcp` 之后,工具池会新增 `mcp__docs__search` 等条目。继续复用旧的序列化工具列表,模型就看不到这些工具,所以每次连接后都要重建工具池和 system prompt。\n\n### MCP 工具只有 Lead 可用\n\n`connect_mcp` 属于 Lead,`assemble_tool_pool` 也服务于 Lead 的 agent loop。Teammate 保留任务、文件、消息和计划工具,由 Lead 调用外部服务,再把得到的工作分派下去。\n\n---\n\n## 相对 s17 的变更\n\n| 组件 | 之前 (s17) | 之后 (s18) |\n|------|-----------|-----------|\n| 工具来源 | 全部手写 builtin | 手写 + MCP 外部工具动态发现 |\n| 工具池 | 固定 BUILTIN_TOOLS | assemble_tool_pool 动态组装 mcp\\_\\_ 前缀工具 |\n| 名称安全 | 无 | normalize_mcp_name 规范化 |\n| 新类型 | — | MCPClient 类(模拟 tools/list + tools/call) |\n| 命名空间 | — | mcp\\_\\_server\\_\\_tool 避免冲突 |\n| 工具描述 | 无标注 | (readOnly)/(destructive) 标注 |\n| prompt 缓存 | 有(s10 起) | 去掉——工具池动态变化后缓存失效 |\n| Lead 工具 | worktree 与团队工具 | + connect_mcp 和动态发现的 MCP 工具 |\n| Teammate 工具 | 任务、文件、消息与计划工具 | 不变 |\n| 扩展方式 | 写代码加工具 | 标准协议,任意语言实现 server |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s18_mcp_plugin/code.py\n```\n\n试试这些 prompt:\n\n1. `查一下文档里的 worktree 清理策略。`\n2. `部署当前项目,并告诉我结果。`\n3. `你现在可以执行哪些文档和部署操作?`\n\n观察重点:连接 MCP server 后,工具名是否带 `mcp__docs__` 或 `mcp__deploy__` 前缀?两个 server 的工具是否同时可用?MCP 工具的 description 是否带 (readOnly)/(destructive) 标注?\n\n---\n\n## 接下来\n\n现在 Agent 可以通过标准协议接入外部工具了。前 18 章逐个引入这些机制,让每个边界都能单独观察。\n\n工具、权限、hooks、todo、任务图、记忆、压缩、后台、cron、团队、worktree、MCP 这些机制应该挂在同一个循环上,而不是散在 19 个 demo 里。\n\ns19 Agent Harness 集成 → 把 s01-s18 的机制合回同一个 harness。机制很多,循环一个。\n\n\n\n" }, { "version": "s18", "locale": "ja", "title": "s18: MCP Tools — 外部ツール、標準プロトコル", - "content": "# s18: MCP Tools — 外部ツール、標準プロトコル\n\ns01 → ... → s16 → s17 → `s18` → [s19](/ja/s19) → s20 → s21\n\n> *\"外部ツール、標準プロトコル\"* — 発見、組み立て、呼び出し。Agent はツールを誰が書いたか知る必要がない。\n>\n> **Harness 層**: プラグイン — 外部能力を標準プロトコルで接続。\n\n---\n\n## 課題\n\ns01 から s17 まで、Agent の全ツールは手書き — bash、read、write、task、worktree。入力検証、実行ロジック、エラーハンドリング、全て一行ずつ書いた。\n\n今、統合したい外部サービスが 3 つある:社内の Jira API(issue 検索、ticket 作成)、独自のデプロイシステム(deploy トリガー、ログ閲覧)、チームの Notion ナレッジベース(ドキュメント検索、ページ作成)。各サービスのためにツールコードを書き直したくない。\n\n標準プロトコルが必要 — 外部サービスがこのプロトコルを実装していれば、サービスが何の言語で書かれていても、Agent は直接そのツールを呼び出せる。\n\n---\n\n## ソリューション\n\n![MCP Architecture](/course-assets/s18_mcp_plugin/mcp-architecture.ja.svg)\n\nMCP(Model Context Protocol)は、Agent が外部ツールを発見・呼び出しする方法を定義。核心概念:\n\n| 概念 | 目的 |\n|------|------|\n| MCPClient | Agent 側のクライアント — server に接続、ツールを発見、ツールを呼び出し |\n| MCP Server | 外部サービス側 — `tools/list` + `tools/call` を実装 |\n| assemble_tool_pool | 組み込みツールと MCP ツールを一つのツールプールに組み立てる |\n| mcp\\_\\_server\\_\\_tool 命名 | 異なる server 間のツール名衝突を防止 |\n\ns17 の worktree 分離、自動認領、チームプロトコルを引き継ぐ。本章では `connect_mcp` ツールを追加し、サービスへの接続、ツール発見、ツールプールへの追加を行う。\n\n本章はプロセス内の server handler を登録し、発見から呼び出しまでをオフラインで実行する。各 handler はクライアントが必要とする `tools/list` と `tools/call` を提供する。\n\n---\n\n## 仕組み\n\n### MCPClient:発見 + 呼び出し\n\n```python\nclass MCPClient:\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs, handlers):\n \"\"\"Simulates tools/list discovery.\"\"\"\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n \"\"\"Simulates tools/call.\"\"\"\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n return handler(**args)\n```\n\n登録した Python 関数が、`tools/call` から呼ばれる server 側のツール実装になる。\n\n### connect_mcp:接続 + 発見\n\n```python\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n return f\"Unknown server '{name}'. Available: ...\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n return f\"Connected to '{name}'. Discovered: ...\"\n```\n\n接続後、server が提供するツールが即座に利用可能。\n\n### normalize_mcp_name:名前の正規化\n\n```python\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\ndef normalize_mcp_name(name: str) -> str:\n return _DISALLOWED_CHARS.sub('_', name)\n```\n\n`[a-zA-Z0-9_-]` 以外の全文字を `_` に置換。server 名やツール名の特殊文字による名前衝突やインジェクション問題を防止。\n\n### assemble_tool_pool:ツールプールの組み立て\n\n```python\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append(...)\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw:\n c.call_tool(t, kw))\n return tools, handlers\n```\n\nプレフィックス `mcp__{server}__{tool}` で異なる server 間のツール名衝突を防止。名前は `normalize_mcp_name` で正規化。\n\nMCP ツールの description に `(readOnly)` または `(destructive)` を付け、読み取りと変更の区別をツールメタデータ上で明示する。\n\n### キャッシュなし:ツールプールが変われば、プロンプトも変わる\n\ns10-s17 の agent_loop は prompt cache で再シリアライズを回避。s18 はキャッシュを削除:\n\n```python\ndef agent_loop(messages, context):\n tools, handlers = assemble_tool_pool() # 毎回再構築\n system = assemble_system_prompt(context) # 毎回再生成\n ...\n if any(b.name == \"connect_mcp\" ...):\n tools, handlers = assemble_tool_pool() # 接続後に再構築\n system = assemble_system_prompt(context)\n```\n\n`connect_mcp` の後には `mcp__docs__search` などがツールプールへ加わる。古いシリアライズ済みツール一覧を再利用するとモデルから新しいツールが見えないため、接続後にツールプールと system prompt を再構築する。\n\n### MCP ツールは Lead のみ利用可能\n\n`connect_mcp` は Lead のツールであり、`assemble_tool_pool` も Lead の agent loop に使われる。チームメイトはタスク、ファイル、メッセージ、プランの各ツールを保持し、Lead が外部サービスを呼び出して得た仕事を割り当てる。\n\n---\n\n## s17 からの変更\n\n| コンポーネント | 変更前 (s17) | 変更後 (s18) |\n|--------------|------------|------------|\n| ツールソース | 全て手書き builtin | 手書き + MCP 外部ツール動的発見 |\n| ツールプール | 固定 BUILTIN_TOOLS | assemble_tool_pool が動的に mcp\\_\\_ プレフィックスツールを組み立てる |\n| 名前の安全性 | なし | normalize_mcp_name 正規化 |\n| 新規タイプ | — | MCPClient クラス(tools/list + tools/call をシミュレート) |\n| 名前空間 | — | mcp\\_\\_server\\_\\_tool 衝突防止 |\n| ツール説明 | アノテーションなし | (readOnly)/(destructive) アノテーション |\n| プロンプトキャッシュ | あり(s10 から) | 削除 — ツールプールが動的、キャッシュが陳腐化 |\n| Lead ツール | worktree・チームツール | + connect_mcp と動的に発見した MCP ツール |\n| チームメイトツール | タスク、ファイル、メッセージ、プランのツール | 変更なし |\n| 拡張方法 | ツール追加のコードを書く | 標準プロトコル、任意言語で server を実装 |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s18_mcp_plugin/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `ドキュメントから worktree のクリーンアップ方針を調べてください。`\n2. `現在のプロジェクトをデプロイし、結果を報告してください。`\n3. `現在実行できるドキュメント操作とデプロイ操作を教えてください。`\n\n観察ポイント:MCP server 接続後、ツール名に `mcp__docs__` や `mcp__deploy__` プレフィックスが付いているか?両方の server のツールが同時に利用可能か?MCP ツールの description に (readOnly)/(destructive) アノテーションが付いているか?\n\n---\n\n## 次の章\n\nAgent は標準プロトコルで外部ツールに接続できるようになった。前 18 章では、各境界を観察できるように仕組みを一つずつ追加してきた。\n\ntools、permissions、hooks、todo、task graph、memory、compact、background work、cron、teams、worktree、MCP は、別々の例ではなく同じ loop に接続されるべきです。\n\ns19 Comprehensive Agent → s01-s18 の仕組みを 1 つの完全な harness に統合。仕組みは多く、loop は 1 つ。\n\n\n\n" + "content": "# s18: MCP Tools — 外部ツール、標準プロトコル\n\ns01 → ... → s16 → s17 → `s18` → [s19](/ja/s19) → s20 → s21\n\n> *\"外部ツール、標準プロトコル\"* — 発見、組み立て、呼び出し。Agent はツールを誰が書いたか知る必要がない。\n>\n> **Harness 層**: プラグイン — 外部能力を標準プロトコルで接続。\n\n---\n\n## 課題\n\ns01 から s17 まで、Agent の全ツールは手書き — bash、read、write、task、worktree。入力検証、実行ロジック、エラーハンドリング、全て一行ずつ書いた。\n\n今、統合したい外部サービスが 3 つある:社内の Jira API(issue 検索、ticket 作成)、独自のデプロイシステム(deploy トリガー、ログ閲覧)、チームの Notion ナレッジベース(ドキュメント検索、ページ作成)。各サービスのためにツールコードを書き直したくない。\n\n標準プロトコルが必要 — 外部サービスがこのプロトコルを実装していれば、サービスが何の言語で書かれていても、Agent は直接そのツールを呼び出せる。\n\n---\n\n## ソリューション\n\n![MCP Architecture](/course-assets/s18_mcp_plugin/mcp-architecture.ja.svg)\n\nMCP(Model Context Protocol)は、Agent が外部ツールを発見・呼び出しする方法を定義。核心概念:\n\n| 概念 | 目的 |\n|------|------|\n| MCPClient | Agent 側のクライアント — server に接続、ツールを発見、ツールを呼び出し |\n| MCP Server | 外部サービス側 — `tools/list` + `tools/call` を実装 |\n| assemble_tool_pool | 組み込みツールと MCP ツールを一つのツールプールに組み立てる |\n| mcp\\_\\_server\\_\\_tool 命名 | 異なる server 間のツール名衝突を防止 |\n\ns17 の worktree 分離、自動認領、チームプロトコルを引き継ぐ。本章では `connect_mcp` ツールを追加し、サービスへの接続、ツール発見、ツールプールへの追加を行う。\n\n本章はプロセス内の server handler を登録し、発見から呼び出しまでをオフラインで実行する。各 handler はクライアントが必要とする `tools/list` と `tools/call` を提供する。\n\n---\n\n## 仕組み\n\n### MCPClient:発見 + 呼び出し\n\n```python\nclass MCPClient:\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs, handlers):\n \"\"\"Simulates tools/list discovery.\"\"\"\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n \"\"\"Simulates tools/call.\"\"\"\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n return handler(**args)\n```\n\n登録した Python 関数が、`tools/call` から呼ばれる server 側のツール実装になる。\n\n### connect_mcp:接続 + 発見\n\n```python\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n return f\"Unknown server '{name}'. Available: ...\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n return f\"Connected to '{name}'. Discovered: ...\"\n```\n\n接続後、server が提供するツールが即座に利用可能。\n\n### normalize_mcp_name:名前の正規化\n\n```python\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\ndef normalize_mcp_name(name: str) -> str:\n return _DISALLOWED_CHARS.sub('_', name)\n```\n\n`[a-zA-Z0-9_-]` 以外の全文字を `_` に置換。server 名やツール名の特殊文字による名前衝突やインジェクション問題を防止。\n\n### assemble_tool_pool:ツールプールの組み立て\n\n```python\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append(...)\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw:\n c.call_tool(t, kw))\n return tools, handlers\n```\n\nプレフィックス `mcp__{server}__{tool}` で異なる server 間のツール名衝突を防止。名前は `normalize_mcp_name` で正規化。\n\nMCP ツールの description に `(readOnly)` または `(destructive)` を付け、読み取りと変更の区別をツールメタデータ上で明示する。\n\n### キャッシュなし:ツールプールが変われば、プロンプトも変わる\n\ns10-s17 の agent_loop は prompt cache で再シリアライズを回避。s18 はキャッシュを削除:\n\n```python\ndef agent_loop(messages, context):\n tools, handlers = assemble_tool_pool() # 毎回再構築\n system = assemble_system_prompt(context) # 毎回再生成\n ...\n if any(b.name == \"connect_mcp\" ...):\n tools, handlers = assemble_tool_pool() # 接続後に再構築\n system = assemble_system_prompt(context)\n```\n\n`connect_mcp` の後には `mcp__docs__search` などがツールプールへ加わる。古いシリアライズ済みツール一覧を再利用するとモデルから新しいツールが見えないため、接続後にツールプールと system prompt を再構築する。\n\n### MCP ツールは Lead のみ利用可能\n\n`connect_mcp` は Lead のツールであり、`assemble_tool_pool` も Lead の agent loop に使われる。チームメイトはタスク、ファイル、メッセージ、プランの各ツールを保持し、Lead が外部サービスを呼び出して得た仕事を割り当てる。\n\n---\n\n## s17 からの変更\n\n| コンポーネント | 変更前 (s17) | 変更後 (s18) |\n|--------------|------------|------------|\n| ツールソース | 全て手書き builtin | 手書き + MCP 外部ツール動的発見 |\n| ツールプール | 固定 BUILTIN_TOOLS | assemble_tool_pool が動的に mcp\\_\\_ プレフィックスツールを組み立てる |\n| 名前の安全性 | なし | normalize_mcp_name 正規化 |\n| 新規タイプ | — | MCPClient クラス(tools/list + tools/call をシミュレート) |\n| 名前空間 | — | mcp\\_\\_server\\_\\_tool 衝突防止 |\n| ツール説明 | アノテーションなし | (readOnly)/(destructive) アノテーション |\n| プロンプトキャッシュ | あり(s10 から) | 削除 — ツールプールが動的、キャッシュが陳腐化 |\n| Lead ツール | worktree・チームツール | + connect_mcp と動的に発見した MCP ツール |\n| チームメイトツール | タスク、ファイル、メッセージ、プランのツール | 変更なし |\n| 拡張方法 | ツール追加のコードを書く | 標準プロトコル、任意言語で server を実装 |\n\n---\n\n## 試してみる\n\n```sh\ncd learn-claude-code\npython s18_mcp_plugin/code.py\n```\n\n以下のプロンプトを試してください:\n\n1. `ドキュメントから worktree のクリーンアップ方針を調べてください。`\n2. `現在のプロジェクトをデプロイし、結果を報告してください。`\n3. `現在実行できるドキュメント操作とデプロイ操作を教えてください。`\n\n観察ポイント:MCP server 接続後、ツール名に `mcp__docs__` や `mcp__deploy__` プレフィックスが付いているか?両方の server のツールが同時に利用可能か?MCP ツールの description に (readOnly)/(destructive) アノテーションが付いているか?\n\n---\n\n## 次の章\n\nAgent は標準プロトコルで外部ツールに接続できるようになった。前 18 章では、各境界を観察できるように仕組みを一つずつ追加してきた。\n\ntools、permissions、hooks、todo、task graph、memory、compact、background work、cron、teams、worktree、MCP は、別々の例ではなく同じ loop に接続されるべきです。\n\ns19 Integrated Harness → s01-s18 の仕組みを 1 つの harness に統合。仕組みは多く、loop は 1 つ。\n\n\n\n" }, { "version": "s19", "locale": "en", - "title": "s19: Comprehensive Agent — All Mechanisms, One Loop", - "content": "# s19: Comprehensive Agent — All Mechanisms, One Loop\n\ns01 → ... → s17 → s18 → `s19` → [s20](/en/s20) → s21\n\n> *\"Many mechanisms, one loop\"* — tools, permissions, memory, tasks, teams, and plugins all hang off the same `while True`.\n>\n> **Harness layer**: Comprehensive — put the mechanisms from s01-s18 into one runnable system.\n\n---\n\n## Problem\n\nThe first 18 chapters add one mechanism at a time so each boundary stays visible. This chapter connects them in one runtime.\n\nA long-running coding agent needs all of these at once:\n\n- tool dispatch and permission boundaries\n- hook extension points\n- todo planning and task graphs\n- skills, memory, and runtime system prompt assembly\n- compaction and error recovery\n- background tasks and cron scheduling\n- teams, protocols, autonomous claiming\n- worktree isolation\n- MCP external tool integration\n\nThe hard part is not piling up features. The hard part is seeing where each mechanism belongs around the loop. S19 is the integration checkpoint: every earlier component is placed back into one harness before s20-s21 add orchestration and goal closure around it.\n\n---\n\n## Solution\n\n![System Architecture](/course-assets/s19_comprehensive/system-architecture.en.svg)\n\nS19 does not introduce a new mechanism. It connects the components from the earlier chapters in one complete harness:\n\n```text\nuser input\n → UserPromptSubmit hooks\n → cron/background notification injection\n → context compact\n → memory + skills + MCP state assemble the system prompt\n → LLM\n → has tool_use block?\n no → Stop hooks → return\n yes → PreToolUse hooks + permission\n → TOOL_HANDLERS / MCP handlers / background dispatch\n → PostToolUse hooks\n → tool_result / task_notification back to messages\n → next round\n```\n\nThe loop keeps the same structure: call the model, check whether the response contains a `tool_use` block, execute tools, and append results to `messages`. The presence of a `tool_use` block decides whether tool execution continues.\n\n---\n\n## Where Each Component Sits\n\n| Position | Component | Role |\n|----------|-----------|------|\n| Around user input | `UserPromptSubmit` hooks | Log, inject, or audit user input |\n| Before LLM | cron queue | Inject scheduled prompts into `messages` |\n| Before LLM | background notifications | Inject completed background work as `` |\n| Before LLM | compaction pipeline | Budget large outputs, trim history, compact old tool results, summarize when needed |\n| Before LLM | memory / skills / MCP state | Assemble the system prompt so the model sees current capabilities and long-term context |\n| LLM call | error recovery | Retry 429/529, escalate `max_tokens`, compact on prompt-too-long |\n| Before tool execution | `PreToolUse` hooks + permission | Block dangerous commands, out-of-bounds writes, destructive MCP tools |\n| Tool dispatch | `assemble_tool_pool` | Assemble built-in tools and dynamic MCP tools |\n| During tool execution | background dispatch | Move slow bash work into a daemon thread and return a placeholder result |\n| After tool execution | `PostToolUse` hooks | Large-output warnings, logs, post-processing |\n| Back to loop | tool_result | One `tool_result` per `tool_use`, then the next model round |\n| No tool_use this round / on stop | `Stop` hooks | Stats, cleanup, audit |\n\n---\n\n## What code.py Contains\n\n### Tools and Dispatch\n\nThe built-in tool pool contains 26 tools:\n\n```text\nbash, read_file, write_file, edit_file, glob\ntodo_write, task, load_skill, compact\ncreate_task, list_tasks, get_task, claim_task, complete_task\nschedule_cron, list_crons, cancel_cron\nspawn_teammate, send_message\nrequest_shutdown, request_plan, review_plan\ncreate_worktree, remove_worktree, keep_worktree\nconnect_mcp\n```\n\n`assemble_tool_pool()` assembles these every round:\n\n```text\nBUILTIN_TOOLS + connected MCP tools\nBUILTIN_HANDLERS + mcp__server__tool handlers\n```\n\nAfter `connect_mcp(\"docs\")`, the next round exposes tools like `mcp__docs__search`.\n\n### Permissions and Hooks\n\nPermission is not hardcoded into the tool execution line. It is a `PreToolUse` hook:\n\n```python\nblocked = trigger_hooks(\"PreToolUse\", block)\nif blocked:\n results.append(tool_result(block.id, blocked))\n continue\n```\n\nThat means permission, logging, and audit logic all attach to the same hook point. After execution, `PostToolUse` hooks run.\n\n### Planning and Tasks\n\nS19 keeps two planning layers:\n\n- `todo_write`: lightweight plan for the current session, kept in memory\n- task graph: cross-session, dependency-aware, claimable task files under `.tasks/task_*.json`\n\nThe first keeps a single agent from drifting. The second supports team coordination.\n\nThey share an intent, not an implementation: `todo_write` replaces one session checklist, while task records have stable IDs and individual lifecycle updates. The separate `task` tool below means \"dispatch one isolated subagent\"; it is not the Task System.\n\n### Subagents and Teams\n\nS19 has two kinds of delegation:\n\n- `task`: one-shot subagent. It uses an isolated `messages[]`, discards intermediate context, and returns only a final summary.\n- `spawn_teammate`: persistent teammate thread. The runtime delivers `MessageBus` events, and the teammate scans the task board while idle so it can claim work autonomously.\n\nOne-shot subagents solve context isolation. Persistent teammates solve long-running parallel collaboration.\n\n### Memory, Skills, and Prompt\n\n`assemble_system_prompt(context)` assembles each round from:\n\n- identity and tool guidance\n- workspace\n- skills catalog\n- `.memory/MEMORY.md`\n- connected MCP servers\n\nSkills only put their catalog into the system prompt. Full content is loaded on demand through `load_skill(name)`.\n\n### Compaction and Recovery\n\nBefore the LLM call, S19 runs the compaction pipeline:\n\n```text\ntool_result_budget → snip_compact → micro_compact → compact_history\n```\n\nThe model call is wrapped with recovery:\n\n- 429: exponential backoff retry\n- 529: exponential backoff, optionally switch to fallback model after repeated failures\n- `max_tokens`: raise max tokens, then request continuation\n- prompt too long: reactive compact and retry\n\n### Background and Cron\n\nSlow bash work does not block the main loop:\n\n```text\nshould_run_background → start_background_task → placeholder tool_result\nbackground done → task_notification → next round injects messages\n```\n\nThe cron scheduler runs as a daemon thread and checks once per second. The CLI watches `cron_queue`; when a job fires, it injects `[Scheduled] ...` and runs one agent turn automatically.\n\n### Worktree and MCP\n\nWorktree isolation owns directories:\n\n- `create_worktree(name, task_id)` creates an isolated branch and directory\n- the task `worktree` field binds a task to that directory\n- when a teammate claims a task with a worktree, its bash/read/write tools run in that directory\n\nMCP owns external capability:\n\n- `connect_mcp(name)` connects a mock server\n- `assemble_tool_pool()` assembles MCP tools into the tool pool\n- tool names use `mcp__server__tool`\n\n---\n\n## Changes from s18\n\n| Component | s18 | s19 |\n|-----------|-----|-----|\n| tool pool | built-in + MCP | built-in + MCP, with s01-s17 tools restored |\n| permission | outside s18's scope | runs inside `PreToolUse` hook |\n| hooks | outside s18's scope | UserPromptSubmit / PreToolUse / PostToolUse / Stop |\n| todo | outside s18's scope | `todo_write` + reminder |\n| skill | outside s18's scope | catalog in system prompt + `load_skill` |\n| compact | outside s18's scope | pre-LLM compaction + `compact` tool + reactive compact |\n| error recovery | simple try/except | retry / max_tokens / prompt too long |\n| background | outside s18's scope | slow-operation thread + task notification |\n| cron | outside s18's scope | daemon scheduler + durable jobs |\n| multi-agent | kept | kept; teammates use basic tools in isolated directories |\n| worktree | kept | kept |\n| MCP | new | kept as part of the final tool pool |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s19_comprehensive/code.py\n```\n\nTry:\n\n1. `Inspect this repository and tell me which Python files matter most.`\n2. `Search the connected documentation for agent loop guidance.`\n3. `Refactor the authentication module and login page in parallel in isolated worktrees. Show me each plan before editing.`\n4. `Remind me about the meeting in 3 minutes.`\n5. `Install the dependencies in the background while you read README.md.`\n\nWatch for:\n\n- whether each tool call passes through hooks/permission\n- whether MCP tools appear on the next round after `connect_mcp`\n- whether slow operations return a background placeholder\n- whether cron automatically reminds you when the time arrives\n- whether teammates submit plans and pause before approval\n- whether teammates can claim tasks after plan approval\n- whether teammates switch to the bound worktree directory\n\n---\n\n## The End Is the Beginning\n\nFrom s01 to s19, the code gets more capable, but the core remains unchanged:\n\n```python\nwhile True:\n response = LLM(messages, tools)\n if not has_tool_use(response.content):\n return\n results = execute_tools(response.content)\n messages.append(tool_results)\n```\n\nA mature harness gets its complexity from coordination around the model. The model chooses actions; the harness organizes the environment, tools, permissions, memory, teams, and external capabilities.\n\nThis is the course's integration checkpoint: many mechanisms, one loop.\n\nNext: [s20 Workflow Runtime](/en/s20) — when the orchestration shape is fixed, move it out of chat turns and into deterministic, resumable code.\n" + "title": "s19: Integrated Harness — Many Mechanisms, One Loop", + "content": "# s19: Integrated Harness — Many Mechanisms, One Loop\n\ns01 → ... → s17 → s18 → `s19` → [s20](/en/s20) → s21\n\n> *\"Many mechanisms, one loop\"* — tools, permissions, memory, tasks, teams, and plugins all hang off the same `while True`.\n>\n> **Harness layer**: Integration — put the mechanisms from s01-s18 into one runnable system.\n\n---\n\n## Problem\n\nThe first 18 chapters add one mechanism at a time so each boundary stays visible. This chapter connects them in one runtime.\n\nA long-running coding agent needs all of these at once:\n\n- tool dispatch and permission boundaries\n- hook extension points\n- todo planning and task graphs\n- skills, memory, and runtime system prompt assembly\n- compaction and error recovery\n- background tasks and cron scheduling\n- teams, protocols, autonomous claiming\n- worktree isolation\n- MCP external tool integration\n\nThe hard part is not piling up features. The hard part is seeing where each mechanism belongs around the loop. S19 is the integration checkpoint: every earlier component is placed back into one harness before s20-s21 add orchestration and goal closure around it.\n\n---\n\n## Solution\n\n![System Architecture](/course-assets/s19_comprehensive/system-architecture.en.svg)\n\nS19 does not introduce a new mechanism. It connects the components from the earlier chapters in one integrated harness:\n\n```text\nuser input\n → UserPromptSubmit hooks\n → cron/background notification injection\n → context compact\n → memory + skills + MCP state assemble the system prompt\n → LLM\n → has tool_use block?\n no → Stop hooks → return\n yes → PreToolUse hooks + permission\n → TOOL_HANDLERS / MCP handlers / background dispatch\n → PostToolUse hooks\n → tool_result / task_notification back to messages\n → next round\n```\n\nThe loop keeps the same structure: call the model, check whether the response contains a `tool_use` block, execute tools, and append results to `messages`. The presence of a `tool_use` block decides whether tool execution continues.\n\n---\n\n## Where Each Component Sits\n\n| Position | Component | Role |\n|----------|-----------|------|\n| Around user input | `UserPromptSubmit` hooks | Log, inject, or audit user input |\n| Before LLM | cron queue | Inject scheduled prompts into `messages` |\n| Before LLM | background notifications | Inject completed background work as `` |\n| Before LLM | compaction pipeline | Budget large outputs, trim history, compact old tool results, summarize when needed |\n| Before LLM | memory / skills / MCP state | Assemble the system prompt so the model sees current capabilities and long-term context |\n| LLM call | error recovery | Retry 429/529, escalate `max_tokens`, compact on prompt-too-long |\n| Before tool execution | `PreToolUse` hooks + permission | Block dangerous commands, out-of-bounds writes, destructive MCP tools |\n| Tool dispatch | `assemble_tool_pool` | Assemble built-in tools and dynamic MCP tools |\n| During tool execution | background dispatch | Move slow bash work into a daemon thread and return a placeholder result |\n| After tool execution | `PostToolUse` hooks | Large-output warnings, logs, post-processing |\n| Back to loop | tool_result | One `tool_result` per `tool_use`, then the next model round |\n| No tool_use this round / on stop | `Stop` hooks | Stats, cleanup, audit |\n\n---\n\n## What code.py Contains\n\n### Tools and Dispatch\n\nThe built-in tool pool contains 26 tools:\n\n```text\nbash, read_file, write_file, edit_file, glob\ntodo_write, task, load_skill, compact\ncreate_task, list_tasks, get_task, claim_task, complete_task\nschedule_cron, list_crons, cancel_cron\nspawn_teammate, send_message\nrequest_shutdown, request_plan, review_plan\ncreate_worktree, remove_worktree, keep_worktree\nconnect_mcp\n```\n\n`assemble_tool_pool()` assembles these every round:\n\n```text\nBUILTIN_TOOLS + connected MCP tools\nBUILTIN_HANDLERS + mcp__server__tool handlers\n```\n\nAfter `connect_mcp(\"docs\")`, the next round exposes tools like `mcp__docs__search`.\n\n### Permissions and Hooks\n\nPermission is not hardcoded into the tool execution line. It is a `PreToolUse` hook:\n\n```python\nblocked = trigger_hooks(\"PreToolUse\", block)\nif blocked:\n results.append(tool_result(block.id, blocked))\n continue\n```\n\nThat means permission, logging, and audit logic all attach to the same hook point. After execution, `PostToolUse` hooks run.\n\n### Planning and Tasks\n\nS19 keeps two planning layers:\n\n- `todo_write`: lightweight plan for the current session, kept in memory\n- task graph: cross-session, dependency-aware, claimable task files under `.tasks/task_*.json`\n\nThe first keeps a single agent from drifting. The second supports team coordination.\n\nThey share an intent, not an implementation: `todo_write` replaces one session checklist, while task records have stable IDs and individual lifecycle updates. The separate `task` tool below means \"dispatch one isolated subagent\"; it is not the Task System.\n\n### Subagents and Teams\n\nS19 has two kinds of delegation:\n\n- `task`: one-shot subagent. It uses an isolated `messages[]`, discards intermediate context, and returns only a final summary.\n- `spawn_teammate`: persistent teammate thread. The runtime delivers `MessageBus` events, and the teammate scans the task board while idle so it can claim work autonomously.\n\nOne-shot subagents solve context isolation. Persistent teammates solve long-running parallel collaboration.\n\n### Memory, Skills, and Prompt\n\n`assemble_system_prompt(context)` assembles each round from:\n\n- identity and tool guidance\n- workspace\n- skills catalog\n- `.memory/MEMORY.md`\n- connected MCP servers\n\nSkills only put their catalog into the system prompt. Full content is loaded on demand through `load_skill(name)`.\n\n### Compaction and Recovery\n\nBefore the LLM call, S19 runs the compaction pipeline:\n\n```text\ntool_result_budget → snip_compact → micro_compact → compact_history\n```\n\nThe model call is wrapped with recovery:\n\n- 429: exponential backoff retry\n- 529: exponential backoff, optionally switch to fallback model after repeated failures\n- `max_tokens`: raise max tokens, then request continuation\n- prompt too long: reactive compact and retry\n\n### Background and Cron\n\nSlow bash work does not block the main loop:\n\n```text\nshould_run_background → start_background_task → placeholder tool_result\nbackground done → task_notification → next round injects messages\n```\n\nThe cron scheduler runs as a daemon thread and checks once per second. The CLI watches `cron_queue`; when a job fires, it injects `[Scheduled] ...` and runs one agent turn automatically.\n\n### Worktree and MCP\n\nWorktree isolation owns directories:\n\n- `create_worktree(name, task_id)` creates an isolated branch and directory\n- the task `worktree` field binds a task to that directory\n- when a teammate claims a task with a worktree, its bash/read/write tools run in that directory\n\nMCP owns external capability:\n\n- `connect_mcp(name)` connects a mock server\n- `assemble_tool_pool()` assembles MCP tools into the tool pool\n- tool names use `mcp__server__tool`\n\n---\n\n## Changes from s18\n\n| Component | s18 | s19 |\n|-----------|-----|-----|\n| tool pool | built-in + MCP | built-in + MCP, with s01-s17 tools restored |\n| permission | outside s18's scope | runs inside `PreToolUse` hook |\n| hooks | outside s18's scope | UserPromptSubmit / PreToolUse / PostToolUse / Stop |\n| todo | outside s18's scope | `todo_write` + reminder |\n| skill | outside s18's scope | catalog in system prompt + `load_skill` |\n| compact | outside s18's scope | pre-LLM compaction + `compact` tool + reactive compact |\n| error recovery | simple try/except | retry / max_tokens / prompt too long |\n| background | outside s18's scope | slow-operation thread + task notification |\n| cron | outside s18's scope | daemon scheduler + durable jobs |\n| multi-agent | kept | kept; teammates use basic tools in isolated directories |\n| worktree | kept | kept |\n| MCP | new | kept as part of the final tool pool |\n\n---\n\n## Try It\n\n```sh\ncd learn-claude-code\npython s19_comprehensive/code.py\n```\n\nTry:\n\n1. `Inspect this repository and tell me which Python files matter most.`\n2. `Search the connected documentation for agent loop guidance.`\n3. `Refactor the authentication module and login page in parallel in isolated worktrees. Show me each plan before editing.`\n4. `Remind me about the meeting in 3 minutes.`\n5. `Install the dependencies in the background while you read README.md.`\n\nWatch for:\n\n- whether each tool call passes through hooks/permission\n- whether MCP tools appear on the next round after `connect_mcp`\n- whether slow operations return a background placeholder\n- whether cron automatically reminds you when the time arrives\n- whether teammates submit plans and pause before approval\n- whether teammates can claim tasks after plan approval\n- whether teammates switch to the bound worktree directory\n\n---\n\n## The End Is the Beginning\n\nFrom s01 to s19, the code gets more capable, but the core remains unchanged:\n\n```python\nwhile True:\n response = LLM(messages, tools)\n if not has_tool_use(response.content):\n return\n results = execute_tools(response.content)\n messages.append(tool_results)\n```\n\nA mature harness gets its complexity from coordination around the model. The model chooses actions; the harness organizes the environment, tools, permissions, memory, teams, and external capabilities.\n\nThis is the course's integration checkpoint: many mechanisms, one loop.\n\nNext: [s20 Workflow Runtime](/en/s20) — when the orchestration shape is fixed, move it out of chat turns and into deterministic, resumable code.\n" }, { "version": "s19", "locale": "zh", - "title": "s19: Comprehensive Agent — 全部机制,归到一个循环", - "content": "# s19: Comprehensive Agent — 全部机制,归到一个循环\n\ns01 → ... → s17 → s18 → `s19` → [s20](/zh/s20) → s21\n\n> *\"机制很多,循环一个\"* — 工具、权限、记忆、任务、团队、插件都挂在同一个 while True 上。\n>\n> **Harness 层**: 综合 — 把 s01-s18 的机制放回同一个可运行系统。\n\n---\n\n## 问题\n\n前 18 章每章只加一个机制,让每个边界都能单独观察。本章把它们接入同一个运行时。\n\n一个能长期工作的 coding agent 需要同时拥有:\n\n- 工具分发和权限边界\n- hooks 扩展点\n- todo 计划和任务图\n- 技能、记忆、系统 prompt 组装\n- 压缩和错误恢复\n- 后台任务和 cron 调度\n- 团队、协议、自治认领\n- worktree 隔离\n- MCP 外部工具接入\n\n难点不是把功能堆起来,而是看清楚它们都挂在循环的哪个位置。S19 是集成检查点:先把此前组件归位,再由 s20-s21 在外层加入编排与目标闭环。\n\n---\n\n## 解决方案\n\n![System Architecture](/course-assets/s19_comprehensive/system-architecture.svg)\n\nS19 不再引入新机制,而是把前面各章的组件接入一个完整 harness:\n\n```text\n用户输入\n → UserPromptSubmit hooks\n → cron/background 通知注入\n → context compact\n → memory + skills + MCP 状态组装 system prompt\n → LLM\n → has tool_use block?\n 否 → Stop hooks → 返回\n 是 → PreToolUse hooks + permission\n → TOOL_HANDLERS / MCP handlers / background dispatch\n → PostToolUse hooks\n → tool_result / task_notification 回 messages\n → 下一轮\n```\n\n循环仍是同一个结构:调用模型,检查响应里是否出现 `tool_use` block,执行工具,再把结果追加回 `messages`。是否继续工具轮,由响应中有没有实际的 `tool_use` block 决定。\n\n---\n\n## 组件在循环中的位置\n\n| 位置 | 组件 | 作用 |\n|------|------|------|\n| 用户输入前后 | `UserPromptSubmit` hooks | 记录、注入、审计用户输入 |\n| LLM 前 | cron queue | 把定时触发的 prompt 注入 `messages` |\n| LLM 前 | background notifications | 后台任务完成后以 `` 注入 |\n| LLM 前 | compaction pipeline | 先压大输出,再裁历史,再压旧 tool_result,必要时摘要 |\n| LLM 前 | memory / skills / MCP state | 组装 system prompt,让模型看到当前能力和长期上下文 |\n| LLM 调用 | error recovery | 429/529 重试,`max_tokens` 升级,prompt too long 触发 reactive compact |\n| 工具执行前 | `PreToolUse` hooks + permission | 拦截危险命令、写越界、破坏性 MCP 工具 |\n| 工具分发 | `assemble_tool_pool` | 组装内置工具和 MCP 动态工具 |\n| 工具执行时 | background dispatch | 慢 bash 操作放 daemon thread,主循环先返回占位结果 |\n| 工具执行后 | `PostToolUse` hooks | 大输出告警、日志等后处理 |\n| 返回循环 | tool_result | 每个 `tool_use` 对应一个 `tool_result`,再回到下一轮 |\n| 本轮没有 tool_use / 停止时 | `Stop` hooks | 统计、清理、审计 |\n\n---\n\n## code.py 包含什么\n\n### 工具与分发\n\n内置工具池包含 26 个工具:\n\n```text\nbash, read_file, write_file, edit_file, glob\ntodo_write, task, load_skill, compact\ncreate_task, list_tasks, get_task, claim_task, complete_task\nschedule_cron, list_crons, cancel_cron\nspawn_teammate, send_message\nrequest_shutdown, request_plan, review_plan\ncreate_worktree, remove_worktree, keep_worktree\nconnect_mcp\n```\n\n`assemble_tool_pool()` 每轮组装:\n\n```text\nBUILTIN_TOOLS + connected MCP tools\nBUILTIN_HANDLERS + mcp__server__tool handlers\n```\n\n所以 `connect_mcp(\"docs\")` 后,下一轮工具池里会出现 `mcp__docs__search`。\n\n### 权限和 hooks\n\n权限不写死在工具执行行里,而是作为 `PreToolUse` hook:\n\n```python\nblocked = trigger_hooks(\"PreToolUse\", block)\nif blocked:\n results.append(tool_result(block.id, blocked))\n continue\n```\n\n这样 permission、log、审计都可以挂在同一个 hook 点上。执行后再触发 `PostToolUse`。\n\n### 计划与任务\n\nS19 同时保留两层计划:\n\n- `todo_write`:当前会话内的轻量计划,保存在内存中\n- task graph:跨会话、可依赖、可认领的任务文件,写入 `.tasks/task_*.json`\n\n前者帮助单个 Agent 不漂移;后者支撑团队协作。\n\n两者目标相近,但实现不同:`todo_write` 整表替换当前会话清单,task record 则有稳定 ID 和单条生命周期更新。下面单独出现的 `task` 工具表示“一次性派发隔离 subagent”,不是 Task System。\n\n### 子 agent 与团队\n\nS19 有两种 delegation:\n\n- `task`:一次性 subagent。独立 `messages[]`,中间过程丢弃,只返回最终摘要。\n- `spawn_teammate`:持久队友线程。运行时自动投递 MessageBus 事件,队友在 idle 时扫描任务板并自主认领。\n\n一次性 subagent 解决“上下文隔离”;持久队友解决“长期并行协作”。\n\n### 记忆、技能和 prompt\n\n`assemble_system_prompt(context)` 每轮组装:\n\n- 身份和工具说明\n- workspace\n- skills catalog\n- `.memory/MEMORY.md`\n- 已连接 MCP server\n\n技能只在 system prompt 里放目录。完整内容通过 `load_skill(name)` 按需加载。\n\n### 压缩和恢复\n\nLLM 前先跑压缩管线:\n\n```text\ntool_result_budget → snip_compact → micro_compact → compact_history\n```\n\n调用模型时再包一层恢复:\n\n- 429:指数退避重试\n- 529:指数退避,连续失败可切 fallback model\n- `max_tokens`:先提高 max_tokens,再要求 continuation\n- prompt too long:reactive compact 后重试\n\n### 后台和 cron\n\n慢 bash 操作不会阻塞主循环:\n\n```text\nshould_run_background → start_background_task → placeholder tool_result\n后台完成 → task_notification → 下一轮注入 messages\n```\n\ncron 调度器独立 daemon thread 每秒检查一次。CLI 会监听 `cron_queue`,命中后主动把 `[Scheduled] ...` 注入并运行一轮 Agent。\n\n### worktree 与 MCP\n\nworktree 负责隔离目录:\n\n- `create_worktree(name, task_id)` 创建独立分支和目录\n- task 的 `worktree` 字段绑定目录\n- 队友 claim 到带 worktree 的 task 后,bash/read/write 自动在对应目录下执行\n\nMCP 负责外部能力:\n\n- `connect_mcp(name)` 连接 mock server\n- `assemble_tool_pool()` 把 MCP 工具组装进工具池\n- 工具名统一为 `mcp__server__tool`\n\n---\n\n## 相对 s18 的变化\n\n| 组件 | s18 | s19 |\n|------|-----|-----|\n| 工具池 | 内置 + MCP | 内置 + MCP,补齐 s01-s17 的工具 |\n| 权限 | 不在 s18 范围内 | `PreToolUse` hook 中执行 |\n| hooks | 不在 s18 范围内 | UserPromptSubmit / PreToolUse / PostToolUse / Stop |\n| todo | 不在 s18 范围内 | `todo_write` + reminder |\n| skill | 不在 s18 范围内 | catalog in system prompt + `load_skill` |\n| compact | 不在 s18 范围内 | LLM 前压缩 + `compact` 工具 + reactive compact |\n| error recovery | 简化 try/except | retry / max_tokens / prompt too long |\n| background | 不在 s18 范围内 | 慢操作后台线程 + task notification |\n| cron | 不在 s18 范围内 | daemon scheduler + durable jobs |\n| multi-agent | 保留 | 保留;队友使用隔离目录下的基础工具 |\n| worktree | 保留 | 保留 |\n| MCP | 新增 | 保留,作为最终工具池的一部分 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s19_comprehensive/code.py\n```\n\n可以试:\n\n1. `检查这个仓库,告诉我哪些 Python 文件最重要。`\n2. `从已连接的文档中查一下 agent loop 的相关说明。`\n3. `请在隔离的 worktree 中并行重构认证模块和登录页,修改前先把各自的计划给我看。`\n4. `3 分钟后提醒我开会。`\n5. `在后台安装依赖,同时继续阅读 README.md。`\n\n观察重点:\n\n- 工具调用前是否经过 hooks/permission\n- `connect_mcp` 后下一轮是否出现 MCP 工具\n- 慢操作是否返回 background placeholder\n- 到点是不是自动提醒开会\n- 队友是否提交 plan,并在 approval 前暂停\n- plan 批准后,队友是否能认领任务\n- worktree 绑定后,队友是否切到对应目录\n\n---\n\n## 结束亦是开始\n\n从 s01 到 s19,代码表面越来越复杂,但核心始终没变:\n\n```python\nwhile True:\n response = LLM(messages, tools)\n if not has_tool_use(response.content):\n return\n results = execute_tools(response.content)\n messages.append(tool_results)\n```\n\n成熟 harness 的复杂性来自模型周围的协作机制。模型负责判断和行动选择,harness 负责组织环境、工具、权限、记忆、团队和外部能力。\n\n这是课程的集成检查点:机制很多,循环一个。\n\n下一章:[s20 Workflow Runtime](/zh/s20) — 当编排形状固定时,把它从多轮对话移入确定性、可恢复的代码。\n" + "title": "s19: Agent Harness 集成 — 多种机制,一个循环", + "content": "# s19: Agent Harness 集成 — 多种机制,一个循环\n\ns01 → ... → s17 → s18 → `s19` → [s20](/zh/s20) → s21\n\n> *\"机制很多,循环一个\"* — 工具、权限、记忆、任务、团队、插件都挂在同一个 while True 上。\n>\n> **Harness 层**: 集成 — 把 s01-s18 的机制放回同一个可运行系统。\n\n---\n\n## 问题\n\n前 18 章每章只加一个机制,让每个边界都能单独观察。本章把它们接入同一个运行时。\n\n一个能长期工作的 coding agent 需要同时拥有:\n\n- 工具分发和权限边界\n- hooks 扩展点\n- todo 计划和任务图\n- 技能、记忆、系统 prompt 组装\n- 压缩和错误恢复\n- 后台任务和 cron 调度\n- 团队、协议、自治认领\n- worktree 隔离\n- MCP 外部工具接入\n\n难点不是把功能堆起来,而是看清楚它们都挂在循环的哪个位置。S19 是集成检查点:先把此前组件归位,再由 s20-s21 在外层加入编排与目标闭环。\n\n---\n\n## 解决方案\n\n![System Architecture](/course-assets/s19_comprehensive/system-architecture.svg)\n\nS19 不再引入新机制,而是把前面各章的组件集成到同一个 harness:\n\n```text\n用户输入\n → UserPromptSubmit hooks\n → cron/background 通知注入\n → context compact\n → memory + skills + MCP 状态组装 system prompt\n → LLM\n → has tool_use block?\n 否 → Stop hooks → 返回\n 是 → PreToolUse hooks + permission\n → TOOL_HANDLERS / MCP handlers / background dispatch\n → PostToolUse hooks\n → tool_result / task_notification 回 messages\n → 下一轮\n```\n\n循环仍是同一个结构:调用模型,检查响应里是否出现 `tool_use` block,执行工具,再把结果追加回 `messages`。是否继续工具轮,由响应中有没有实际的 `tool_use` block 决定。\n\n---\n\n## 组件在循环中的位置\n\n| 位置 | 组件 | 作用 |\n|------|------|------|\n| 用户输入前后 | `UserPromptSubmit` hooks | 记录、注入、审计用户输入 |\n| LLM 前 | cron queue | 把定时触发的 prompt 注入 `messages` |\n| LLM 前 | background notifications | 后台任务完成后以 `` 注入 |\n| LLM 前 | compaction pipeline | 先压大输出,再裁历史,再压旧 tool_result,必要时摘要 |\n| LLM 前 | memory / skills / MCP state | 组装 system prompt,让模型看到当前能力和长期上下文 |\n| LLM 调用 | error recovery | 429/529 重试,`max_tokens` 升级,prompt too long 触发 reactive compact |\n| 工具执行前 | `PreToolUse` hooks + permission | 拦截危险命令、写越界、破坏性 MCP 工具 |\n| 工具分发 | `assemble_tool_pool` | 组装内置工具和 MCP 动态工具 |\n| 工具执行时 | background dispatch | 慢 bash 操作放 daemon thread,主循环先返回占位结果 |\n| 工具执行后 | `PostToolUse` hooks | 大输出告警、日志等后处理 |\n| 返回循环 | tool_result | 每个 `tool_use` 对应一个 `tool_result`,再回到下一轮 |\n| 本轮没有 tool_use / 停止时 | `Stop` hooks | 统计、清理、审计 |\n\n---\n\n## code.py 包含什么\n\n### 工具与分发\n\n内置工具池包含 26 个工具:\n\n```text\nbash, read_file, write_file, edit_file, glob\ntodo_write, task, load_skill, compact\ncreate_task, list_tasks, get_task, claim_task, complete_task\nschedule_cron, list_crons, cancel_cron\nspawn_teammate, send_message\nrequest_shutdown, request_plan, review_plan\ncreate_worktree, remove_worktree, keep_worktree\nconnect_mcp\n```\n\n`assemble_tool_pool()` 每轮组装:\n\n```text\nBUILTIN_TOOLS + connected MCP tools\nBUILTIN_HANDLERS + mcp__server__tool handlers\n```\n\n所以 `connect_mcp(\"docs\")` 后,下一轮工具池里会出现 `mcp__docs__search`。\n\n### 权限和 hooks\n\n权限不写死在工具执行行里,而是作为 `PreToolUse` hook:\n\n```python\nblocked = trigger_hooks(\"PreToolUse\", block)\nif blocked:\n results.append(tool_result(block.id, blocked))\n continue\n```\n\n这样 permission、log、审计都可以挂在同一个 hook 点上。执行后再触发 `PostToolUse`。\n\n### 计划与任务\n\nS19 同时保留两层计划:\n\n- `todo_write`:当前会话内的轻量计划,保存在内存中\n- task graph:跨会话、可依赖、可认领的任务文件,写入 `.tasks/task_*.json`\n\n前者帮助单个 Agent 不漂移;后者支撑团队协作。\n\n两者目标相近,但实现不同:`todo_write` 整表替换当前会话清单,task record 则有稳定 ID 和单条生命周期更新。下面单独出现的 `task` 工具表示“一次性派发隔离 subagent”,不是 Task System。\n\n### 子 agent 与团队\n\nS19 有两种 delegation:\n\n- `task`:一次性 subagent。独立 `messages[]`,中间过程丢弃,只返回最终摘要。\n- `spawn_teammate`:持久队友线程。运行时自动投递 MessageBus 事件,队友在 idle 时扫描任务板并自主认领。\n\n一次性 subagent 解决“上下文隔离”;持久队友解决“长期并行协作”。\n\n### 记忆、技能和 prompt\n\n`assemble_system_prompt(context)` 每轮组装:\n\n- 身份和工具说明\n- workspace\n- skills catalog\n- `.memory/MEMORY.md`\n- 已连接 MCP server\n\n技能只在 system prompt 里放目录。完整内容通过 `load_skill(name)` 按需加载。\n\n### 压缩和恢复\n\nLLM 前先跑压缩管线:\n\n```text\ntool_result_budget → snip_compact → micro_compact → compact_history\n```\n\n调用模型时再包一层恢复:\n\n- 429:指数退避重试\n- 529:指数退避,连续失败可切 fallback model\n- `max_tokens`:先提高 max_tokens,再要求 continuation\n- prompt too long:reactive compact 后重试\n\n### 后台和 cron\n\n慢 bash 操作不会阻塞主循环:\n\n```text\nshould_run_background → start_background_task → placeholder tool_result\n后台完成 → task_notification → 下一轮注入 messages\n```\n\ncron 调度器独立 daemon thread 每秒检查一次。CLI 会监听 `cron_queue`,命中后主动把 `[Scheduled] ...` 注入并运行一轮 Agent。\n\n### worktree 与 MCP\n\nworktree 负责隔离目录:\n\n- `create_worktree(name, task_id)` 创建独立分支和目录\n- task 的 `worktree` 字段绑定目录\n- 队友 claim 到带 worktree 的 task 后,bash/read/write 自动在对应目录下执行\n\nMCP 负责外部能力:\n\n- `connect_mcp(name)` 连接 mock server\n- `assemble_tool_pool()` 把 MCP 工具组装进工具池\n- 工具名统一为 `mcp__server__tool`\n\n---\n\n## 相对 s18 的变化\n\n| 组件 | s18 | s19 |\n|------|-----|-----|\n| 工具池 | 内置 + MCP | 内置 + MCP,补齐 s01-s17 的工具 |\n| 权限 | 不在 s18 范围内 | `PreToolUse` hook 中执行 |\n| hooks | 不在 s18 范围内 | UserPromptSubmit / PreToolUse / PostToolUse / Stop |\n| todo | 不在 s18 范围内 | `todo_write` + reminder |\n| skill | 不在 s18 范围内 | catalog in system prompt + `load_skill` |\n| compact | 不在 s18 范围内 | LLM 前压缩 + `compact` 工具 + reactive compact |\n| error recovery | 简化 try/except | retry / max_tokens / prompt too long |\n| background | 不在 s18 范围内 | 慢操作后台线程 + task notification |\n| cron | 不在 s18 范围内 | daemon scheduler + durable jobs |\n| multi-agent | 保留 | 保留;队友使用隔离目录下的基础工具 |\n| worktree | 保留 | 保留 |\n| MCP | 新增 | 保留,作为最终工具池的一部分 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s19_comprehensive/code.py\n```\n\n可以试:\n\n1. `检查这个仓库,告诉我哪些 Python 文件最重要。`\n2. `从已连接的文档中查一下 agent loop 的相关说明。`\n3. `请在隔离的 worktree 中并行重构认证模块和登录页,修改前先把各自的计划给我看。`\n4. `3 分钟后提醒我开会。`\n5. `在后台安装依赖,同时继续阅读 README.md。`\n\n观察重点:\n\n- 工具调用前是否经过 hooks/permission\n- `connect_mcp` 后下一轮是否出现 MCP 工具\n- 慢操作是否返回 background placeholder\n- 到点是不是自动提醒开会\n- 队友是否提交 plan,并在 approval 前暂停\n- plan 批准后,队友是否能认领任务\n- worktree 绑定后,队友是否切到对应目录\n\n---\n\n## 结束亦是开始\n\n从 s01 到 s19,代码表面越来越复杂,但核心始终没变:\n\n```python\nwhile True:\n response = LLM(messages, tools)\n if not has_tool_use(response.content):\n return\n results = execute_tools(response.content)\n messages.append(tool_results)\n```\n\n成熟 harness 的复杂性来自模型周围的协作机制。模型负责判断和行动选择,harness 负责组织环境、工具、权限、记忆、团队和外部能力。\n\n这是课程的集成检查点:机制很多,循环一个。\n\n下一章:[s20 Workflow Runtime](/zh/s20) — 当编排形状固定时,把它从多轮对话移入确定性、可恢复的代码。\n" }, { "version": "s19", "locale": "ja", - "title": "s19: Comprehensive Agent — すべての仕組みを 1 つのループへ", - "content": "# s19: Comprehensive Agent — すべての仕組みを 1 つのループへ\n\ns01 → ... → s17 → s18 → `s19` → [s20](/ja/s20) → s21\n\n> *\"仕組みは多い、ループは 1 つ\"* — tools、permissions、memory、tasks、teams、plugins はすべて同じ `while True` に接続される。\n>\n> **Harness レイヤー**: 総合 — s01-s18 の仕組みを 1 つの実行可能なシステムへ戻す。\n\n---\n\n## 問題\n\n前 18 章では、各境界を観察できるように仕組みを一つずつ追加した。本章では、それらを一つのランタイムへ接続する。\n\n長時間動く coding agent には、同時に次のものが必要になる:\n\n- tool dispatch と permission boundary\n- hook extension point\n- todo plan と task graph\n- skill、memory、runtime system prompt assembly\n- compaction と error recovery\n- background task と cron scheduling\n- team、protocol、autonomous claiming\n- worktree isolation\n- MCP external tool integration\n\n難しいのは機能を積み上げることではない。それぞれの仕組みが loop のどこに接続されるかを見抜くことだ。S19 は統合チェックポイントであり、これまでの component を 1 つの harness に戻してから、s20-s21 が編成と目標完了を外側に追加する。\n\n---\n\n## 解決策\n\n![System Architecture](/course-assets/s19_comprehensive/system-architecture.ja.svg)\n\nS19 は新しい mechanism を追加せず、前章までの component を 1 つの完全な harness に統合する:\n\n```text\nuser input\n → UserPromptSubmit hooks\n → cron/background notification injection\n → context compact\n → memory + skills + MCP state で system prompt を組み立てる\n → LLM\n → has tool_use block?\n no → Stop hooks → return\n yes → PreToolUse hooks + permission\n → TOOL_HANDLERS / MCP handlers / background dispatch\n → PostToolUse hooks\n → tool_result / task_notification を messages へ戻す\n → next round\n```\n\nloop 自体は同じ構造のままだ。model を呼び、response に `tool_use` block があるかを見て、tool を実行し、結果を `messages` に戻す。tool 実行を続けるかどうかは、実際の `tool_use` block の有無で決まる。\n\n---\n\n## 各 Component の位置\n\n| 位置 | Component | 役割 |\n|------|-----------|------|\n| user input 周辺 | `UserPromptSubmit` hooks | user input の記録、注入、監査 |\n| LLM 前 | cron queue | scheduled prompt を `messages` へ注入 |\n| LLM 前 | background notifications | 完了した background work を `` として注入 |\n| LLM 前 | compaction pipeline | 大きな出力を予算化し、履歴を切り、古い tool_result を圧縮し、必要なら要約 |\n| LLM 前 | memory / skills / MCP state | current capabilities と long-term context を system prompt に組み込む |\n| LLM call | error recovery | 429/529 retry、`max_tokens` escalation、prompt-too-long compact |\n| tool 実行前 | `PreToolUse` hooks + permission | 危険な command、範囲外 write、destructive MCP tool を止める |\n| tool dispatch | `assemble_tool_pool` | built-in tools と dynamic MCP tools を組み立てる |\n| tool 実行中 | background dispatch | 遅い bash work を daemon thread に逃がし、placeholder result を返す |\n| tool 実行後 | `PostToolUse` hooks | large-output warning、log、後処理 |\n| loop へ戻る | tool_result | 1 つの `tool_use` に 1 つの `tool_result`、そして次の model round |\n| tool_use がない round / stop 時 | `Stop` hooks | 統計、cleanup、audit |\n\n---\n\n## code.py に含まれるもの\n\n### Tools と Dispatch\n\nbuilt-in tool pool には 26 個の tool がある:\n\n```text\nbash, read_file, write_file, edit_file, glob\ntodo_write, task, load_skill, compact\ncreate_task, list_tasks, get_task, claim_task, complete_task\nschedule_cron, list_crons, cancel_cron\nspawn_teammate, send_message\nrequest_shutdown, request_plan, review_plan\ncreate_worktree, remove_worktree, keep_worktree\nconnect_mcp\n```\n\n`assemble_tool_pool()` は毎 round で次を組み立てる:\n\n```text\nBUILTIN_TOOLS + connected MCP tools\nBUILTIN_HANDLERS + mcp__server__tool handlers\n```\n\n`connect_mcp(\"docs\")` のあと、次の round では `mcp__docs__search` のような tool が出現する。\n\n### Permission と Hooks\n\npermission は tool 実行行に直接埋め込まない。`PreToolUse` hook として扱う:\n\n```python\nblocked = trigger_hooks(\"PreToolUse\", block)\nif blocked:\n results.append(tool_result(block.id, blocked))\n continue\n```\n\nこれにより permission、logging、audit が同じ hook point に接続できる。実行後には `PostToolUse` hook が走る。\n\n### Plan と Task\n\nS19 には 2 層の plan がある:\n\n- `todo_write`: current session 用の軽量 plan。メモリに保持。\n- task graph: cross-session、dependency-aware、claimable な task file。`.tasks/task_*.json` に保存。\n\n前者は単独 agent の drift を防ぐ。後者は team coordination の土台になる。\n\n目的は近いが実装は別である。`todo_write` は現在のセッションのチェックリスト全体を置き換え、task record は安定 ID と個別のライフサイクル更新を持つ。次節の独立した `task` ツールは「隔離 subagent を一度派遣する」意味であり、Task System ではない。\n\n### Subagent と Team\n\nS19 には 2 種類の delegation がある:\n\n- `task`: one-shot subagent。独立した `messages[]` を使い、中間 context を捨て、final summary だけ返す。\n- `spawn_teammate`: persistent teammate thread。ランタイムが `MessageBus` event を自動配信し、teammate は idle 中に task board を確認して自律的に claim できる。\n\none-shot subagent は context isolation を解決する。persistent teammate は長期並列協作を解決する。\n\n### Memory、Skills、Prompt\n\n`assemble_system_prompt(context)` は毎 round 次を組み立てる:\n\n- identity と tool guidance\n- workspace\n- skills catalog\n- `.memory/MEMORY.md`\n- connected MCP servers\n\nskills は system prompt には catalog だけ置く。全文は `load_skill(name)` で必要な時に読む。\n\n### Compaction と Recovery\n\nLLM call の前に compaction pipeline を走らせる:\n\n```text\ntool_result_budget → snip_compact → micro_compact → compact_history\n```\n\nmodel call は recovery で包む:\n\n- 429: exponential backoff retry\n- 529: exponential backoff、連続失敗時は fallback model へ切替可能\n- `max_tokens`: max tokens を上げ、その後 continuation を要求\n- prompt too long: reactive compact 後に retry\n\n### Background と Cron\n\n遅い bash work は main loop を止めない:\n\n```text\nshould_run_background → start_background_task → placeholder tool_result\nbackground done → task_notification → next round injects messages\n```\n\ncron scheduler は daemon thread として動き、1 秒ごとに確認する。CLI は `cron_queue` を監視し、発火した job を `[Scheduled] ...` として注入して Agent を 1 turn 自動実行する。\n\n### Worktree と MCP\n\nworktree isolation は directory を担当する:\n\n- `create_worktree(name, task_id)` が isolated branch と directory を作る\n- task の `worktree` field が task と directory を紐付ける\n- teammate が worktree 付き task を claim すると、bash/read/write はその directory で実行される\n\nMCP は external capability を担当する:\n\n- `connect_mcp(name)` が mock server に接続する\n- `assemble_tool_pool()` が MCP tools を tool pool に組み立てる\n- tool name は `mcp__server__tool` 形式に統一する\n\n---\n\n## s18 からの変化\n\n| Component | s18 | s19 |\n|-----------|-----|-----|\n| tool pool | built-in + MCP | built-in + MCP、s01-s17 の tool を補完 |\n| permission | s18 の対象外 | `PreToolUse` hook で実行 |\n| hooks | s18 の対象外 | UserPromptSubmit / PreToolUse / PostToolUse / Stop |\n| todo | s18 の対象外 | `todo_write` + reminder |\n| skill | s18 の対象外 | system prompt の catalog + `load_skill` |\n| compact | s18 の対象外 | LLM 前 compaction + `compact` tool + reactive compact |\n| error recovery | simple try/except | retry / max_tokens / prompt too long |\n| background | s18 の対象外 | slow-operation thread + task notification |\n| cron | s18 の対象外 | daemon scheduler + durable jobs |\n| multi-agent | 維持 | 維持。teammate は isolated directory 上の basic tools を使う |\n| worktree | 維持 | 維持 |\n| MCP | 新規 | final tool pool の一部として維持 |\n\n---\n\n## 試す\n\n```sh\ncd learn-claude-code\npython s19_comprehensive/code.py\n```\n\n試す prompt:\n\n1. `このリポジトリを調べ、重要な Python ファイルを教えてください。`\n2. `接続済みのドキュメントから agent loop の説明を探してください。`\n3. `認証モジュールとログインページを隔離した worktree で並行してリファクタリングし、編集前にそれぞれのプランを見せてください。`\n4. `3 分後に会議を知らせてください。`\n5. `依存関係をバックグラウンドでインストールしながら README.md を読んでください。`\n\n見るポイント:\n\n- tool call の前に hooks/permission を通るか\n- `connect_mcp` 後の次 round で MCP tool が出るか\n- 遅い operation が background placeholder を返すか\n- cron が時刻到達時に自動で reminder を返すか\n- teammate が plan を提出し、approval 前に停止するか\n- plan approval 後、teammate が task を claim できるか\n- worktree binding 後、teammate が対応 directory に切り替わるか\n\n---\n\n## 終わりは始まり\n\ns01 から s19 まで、コードの能力は増えていく。しかし中心は変わらない:\n\n```python\nwhile True:\n response = LLM(messages, tools)\n if not has_tool_use(response.content):\n return\n results = execute_tools(response.content)\n messages.append(tool_results)\n```\n\n成熟した harness の複雑さは model 周辺の協調機構から生まれる。model は判断と action selection を担当し、harness は environment、tools、permissions、memory、teams、external capabilities を整理する。\n\nこれは本コースの統合チェックポイントだ:仕組みは多い、ループは 1 つ。\n\n次へ:[s20 Workflow Runtime](/ja/s20) — 編成の形が固定なら、多数の会話ターンではなく、決定的で再開可能なコードへ移す。\n" + "title": "s19: Integrated Harness — 多くの仕組みを 1 つのループへ", + "content": "# s19: Integrated Harness — 多くの仕組みを 1 つのループへ\n\ns01 → ... → s17 → s18 → `s19` → [s20](/ja/s20) → s21\n\n> *\"仕組みは多い、ループは 1 つ\"* — tools、permissions、memory、tasks、teams、plugins はすべて同じ `while True` に接続される。\n>\n> **Harness レイヤー**: 統合 — s01-s18 の仕組みを 1 つの実行可能なシステムへ戻す。\n\n---\n\n## 問題\n\n前 18 章では、各境界を観察できるように仕組みを一つずつ追加した。本章では、それらを一つのランタイムへ接続する。\n\n長時間動く coding agent には、同時に次のものが必要になる:\n\n- tool dispatch と permission boundary\n- hook extension point\n- todo plan と task graph\n- skill、memory、runtime system prompt assembly\n- compaction と error recovery\n- background task と cron scheduling\n- team、protocol、autonomous claiming\n- worktree isolation\n- MCP external tool integration\n\n難しいのは機能を積み上げることではない。それぞれの仕組みが loop のどこに接続されるかを見抜くことだ。S19 は統合チェックポイントであり、これまでの component を 1 つの harness に戻してから、s20-s21 が編成と目標完了を外側に追加する。\n\n---\n\n## 解決策\n\n![System Architecture](/course-assets/s19_comprehensive/system-architecture.ja.svg)\n\nS19 は新しい mechanism を追加せず、前章までの component を同じ harness に統合する:\n\n```text\nuser input\n → UserPromptSubmit hooks\n → cron/background notification injection\n → context compact\n → memory + skills + MCP state で system prompt を組み立てる\n → LLM\n → has tool_use block?\n no → Stop hooks → return\n yes → PreToolUse hooks + permission\n → TOOL_HANDLERS / MCP handlers / background dispatch\n → PostToolUse hooks\n → tool_result / task_notification を messages へ戻す\n → next round\n```\n\nloop 自体は同じ構造のままだ。model を呼び、response に `tool_use` block があるかを見て、tool を実行し、結果を `messages` に戻す。tool 実行を続けるかどうかは、実際の `tool_use` block の有無で決まる。\n\n---\n\n## 各 Component の位置\n\n| 位置 | Component | 役割 |\n|------|-----------|------|\n| user input 周辺 | `UserPromptSubmit` hooks | user input の記録、注入、監査 |\n| LLM 前 | cron queue | scheduled prompt を `messages` へ注入 |\n| LLM 前 | background notifications | 完了した background work を `` として注入 |\n| LLM 前 | compaction pipeline | 大きな出力を予算化し、履歴を切り、古い tool_result を圧縮し、必要なら要約 |\n| LLM 前 | memory / skills / MCP state | current capabilities と long-term context を system prompt に組み込む |\n| LLM call | error recovery | 429/529 retry、`max_tokens` escalation、prompt-too-long compact |\n| tool 実行前 | `PreToolUse` hooks + permission | 危険な command、範囲外 write、destructive MCP tool を止める |\n| tool dispatch | `assemble_tool_pool` | built-in tools と dynamic MCP tools を組み立てる |\n| tool 実行中 | background dispatch | 遅い bash work を daemon thread に逃がし、placeholder result を返す |\n| tool 実行後 | `PostToolUse` hooks | large-output warning、log、後処理 |\n| loop へ戻る | tool_result | 1 つの `tool_use` に 1 つの `tool_result`、そして次の model round |\n| tool_use がない round / stop 時 | `Stop` hooks | 統計、cleanup、audit |\n\n---\n\n## code.py に含まれるもの\n\n### Tools と Dispatch\n\nbuilt-in tool pool には 26 個の tool がある:\n\n```text\nbash, read_file, write_file, edit_file, glob\ntodo_write, task, load_skill, compact\ncreate_task, list_tasks, get_task, claim_task, complete_task\nschedule_cron, list_crons, cancel_cron\nspawn_teammate, send_message\nrequest_shutdown, request_plan, review_plan\ncreate_worktree, remove_worktree, keep_worktree\nconnect_mcp\n```\n\n`assemble_tool_pool()` は毎 round で次を組み立てる:\n\n```text\nBUILTIN_TOOLS + connected MCP tools\nBUILTIN_HANDLERS + mcp__server__tool handlers\n```\n\n`connect_mcp(\"docs\")` のあと、次の round では `mcp__docs__search` のような tool が出現する。\n\n### Permission と Hooks\n\npermission は tool 実行行に直接埋め込まない。`PreToolUse` hook として扱う:\n\n```python\nblocked = trigger_hooks(\"PreToolUse\", block)\nif blocked:\n results.append(tool_result(block.id, blocked))\n continue\n```\n\nこれにより permission、logging、audit が同じ hook point に接続できる。実行後には `PostToolUse` hook が走る。\n\n### Plan と Task\n\nS19 には 2 層の plan がある:\n\n- `todo_write`: current session 用の軽量 plan。メモリに保持。\n- task graph: cross-session、dependency-aware、claimable な task file。`.tasks/task_*.json` に保存。\n\n前者は単独 agent の drift を防ぐ。後者は team coordination の土台になる。\n\n目的は近いが実装は別である。`todo_write` は現在のセッションのチェックリスト全体を置き換え、task record は安定 ID と個別のライフサイクル更新を持つ。次節の独立した `task` ツールは「隔離 subagent を一度派遣する」意味であり、Task System ではない。\n\n### Subagent と Team\n\nS19 には 2 種類の delegation がある:\n\n- `task`: one-shot subagent。独立した `messages[]` を使い、中間 context を捨て、final summary だけ返す。\n- `spawn_teammate`: persistent teammate thread。ランタイムが `MessageBus` event を自動配信し、teammate は idle 中に task board を確認して自律的に claim できる。\n\none-shot subagent は context isolation を解決する。persistent teammate は長期並列協作を解決する。\n\n### Memory、Skills、Prompt\n\n`assemble_system_prompt(context)` は毎 round 次を組み立てる:\n\n- identity と tool guidance\n- workspace\n- skills catalog\n- `.memory/MEMORY.md`\n- connected MCP servers\n\nskills は system prompt には catalog だけ置く。全文は `load_skill(name)` で必要な時に読む。\n\n### Compaction と Recovery\n\nLLM call の前に compaction pipeline を走らせる:\n\n```text\ntool_result_budget → snip_compact → micro_compact → compact_history\n```\n\nmodel call は recovery で包む:\n\n- 429: exponential backoff retry\n- 529: exponential backoff、連続失敗時は fallback model へ切替可能\n- `max_tokens`: max tokens を上げ、その後 continuation を要求\n- prompt too long: reactive compact 後に retry\n\n### Background と Cron\n\n遅い bash work は main loop を止めない:\n\n```text\nshould_run_background → start_background_task → placeholder tool_result\nbackground done → task_notification → next round injects messages\n```\n\ncron scheduler は daemon thread として動き、1 秒ごとに確認する。CLI は `cron_queue` を監視し、発火した job を `[Scheduled] ...` として注入して Agent を 1 turn 自動実行する。\n\n### Worktree と MCP\n\nworktree isolation は directory を担当する:\n\n- `create_worktree(name, task_id)` が isolated branch と directory を作る\n- task の `worktree` field が task と directory を紐付ける\n- teammate が worktree 付き task を claim すると、bash/read/write はその directory で実行される\n\nMCP は external capability を担当する:\n\n- `connect_mcp(name)` が mock server に接続する\n- `assemble_tool_pool()` が MCP tools を tool pool に組み立てる\n- tool name は `mcp__server__tool` 形式に統一する\n\n---\n\n## s18 からの変化\n\n| Component | s18 | s19 |\n|-----------|-----|-----|\n| tool pool | built-in + MCP | built-in + MCP、s01-s17 の tool を補完 |\n| permission | s18 の対象外 | `PreToolUse` hook で実行 |\n| hooks | s18 の対象外 | UserPromptSubmit / PreToolUse / PostToolUse / Stop |\n| todo | s18 の対象外 | `todo_write` + reminder |\n| skill | s18 の対象外 | system prompt の catalog + `load_skill` |\n| compact | s18 の対象外 | LLM 前 compaction + `compact` tool + reactive compact |\n| error recovery | simple try/except | retry / max_tokens / prompt too long |\n| background | s18 の対象外 | slow-operation thread + task notification |\n| cron | s18 の対象外 | daemon scheduler + durable jobs |\n| multi-agent | 維持 | 維持。teammate は isolated directory 上の basic tools を使う |\n| worktree | 維持 | 維持 |\n| MCP | 新規 | final tool pool の一部として維持 |\n\n---\n\n## 試す\n\n```sh\ncd learn-claude-code\npython s19_comprehensive/code.py\n```\n\n試す prompt:\n\n1. `このリポジトリを調べ、重要な Python ファイルを教えてください。`\n2. `接続済みのドキュメントから agent loop の説明を探してください。`\n3. `認証モジュールとログインページを隔離した worktree で並行してリファクタリングし、編集前にそれぞれのプランを見せてください。`\n4. `3 分後に会議を知らせてください。`\n5. `依存関係をバックグラウンドでインストールしながら README.md を読んでください。`\n\n見るポイント:\n\n- tool call の前に hooks/permission を通るか\n- `connect_mcp` 後の次 round で MCP tool が出るか\n- 遅い operation が background placeholder を返すか\n- cron が時刻到達時に自動で reminder を返すか\n- teammate が plan を提出し、approval 前に停止するか\n- plan approval 後、teammate が task を claim できるか\n- worktree binding 後、teammate が対応 directory に切り替わるか\n\n---\n\n## 終わりは始まり\n\ns01 から s19 まで、コードの能力は増えていく。しかし中心は変わらない:\n\n```python\nwhile True:\n response = LLM(messages, tools)\n if not has_tool_use(response.content):\n return\n results = execute_tools(response.content)\n messages.append(tool_results)\n```\n\n成熟した harness の複雑さは model 周辺の協調機構から生まれる。model は判断と action selection を担当し、harness は environment、tools、permissions、memory、teams、external capabilities を整理する。\n\nこれは本コースの統合チェックポイントだ:仕組みは多い、ループは 1 つ。\n\n次へ:[s20 Workflow Runtime](/ja/s20) — 編成の形が固定なら、多数の会話ターンではなく、決定的で再開可能なコードへ移す。\n" }, { "version": "s20", "locale": "en", "title": "s20: Workflow Runtime — The Model Decides Each Step; a Script Decides the Orchestration", - "content": "# s20: Workflow Runtime — The Model Decides Each Step; a Script Decides the Orchestration\n\ns01 → ... → s18 → s19 → `s20` → [s21](/en/s21)\n\n> *\"One tool_use starts an entire orchestration in the background\"* — The `Workflow` tool starts a deterministic, recoverable script runtime that dispatches many subagents in bulk.\n>\n> **Harness layer**: Orchestration — a deterministic multi-agent script runtime above the single-agent loop.\n\n`code.py` keeps the demo deterministic: it emits `async_launched` and then awaits completion in one process. This demonstrates the lifecycle and journal without requiring a long-running background service.\n\n---\n\nFrom s01 through s19, our loop has always been model-driven and step-by-step: the model chooses one tool each round, its result enters `messages[]`, and another round begins. That is ideal for open-ended tasks because the model can inspect the current context and decide the next step on the spot.\n\nSome jobs, however, require deterministic command of a group of agents. Consider reviewing a large change: inspect ten dimensions in parallel → send each finding to a separate agent for adversarial verification → combine and deduplicate the results → sort by severity. The shape is fixed, and you really need three properties:\n\n- **Parallelism**, rather than waiting for one item at a time;\n- **Determinism**, so the same input produces the same result structure;\n- **Recoverability**, so an interruption does not rerun work that is already complete.\n\nMaking the model drive this process one round at a time in the main loop is slow and nondeterministic, and an interruption starts everything over. At that point, you do not need \"one more conversation turn.\" You need to encode the orchestration directly as code.\n\n## Put the Plan in Code, Not in a Sequence of Chat Turns\n\nAdd a `Workflow` tool to the harness tool pool. The user or model provides a script that expresses deterministic orchestration through a few simple primitives: `agent()`, `parallel()`, `pipeline()`, and `phase()`.\n\nThe main loop sees only one `tool_use` and immediately receives a \"started in the background\" result. Real execution continues inside the background runtime, which reports progress in real time and records every step in a journal on disk. Intermediate script results live in variables instead of taking space in conversation history. When restarted with `resume_from_run_id`, unchanged `agent()` calls hit the journal cache and reuse previous results, resuming from the checkpoint.\n\n![Workflow Runtime Overview](/course-assets/s20_workflow_runtime/workflow-runtime-overview.svg)\n\n```python\nSAMPLE_META = {\"name\": \"review-changes\", \"description\": \"Review code changes\", \"phases\": [\"Review\", \"Verify\"]}\n\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n results = await ctx.pipeline(DIMENSIONS, audit, verify) # Each dimension independently runs audit → verify\n confirmed = [f for r in results if r for f in r[\"confirmed\"]]\n ctx.log(f\"Confirmed {len(confirmed)} real issues\")\n return {\"confirmed\": confirmed}\n```\n\n## The Workflow Tool: Start in the Background; the Main Loop Sees One Call\n\n`Workflow` lives in the main agent's tool pool. The user can request a saved workflow, or the model can select the tool when a task matches a known orchestration. In either case, the model emits one `Workflow(...)` tool call.\n\nThe tool parses the arguments, validates metadata, checks permissions, registers a local workflow task, and immediately returns \"started asynchronously.\" The main loop does not block and can continue with other work while the workflow runs in the background. This is the claim-ticket pattern from s13 at a larger scale: hand over the ticket now, notify the user when the result is ready.\n\n```python\nclass WorkflowTool:\n async def call(self, meta, script_fn, args=None, resume_from_run_id=None):\n validate_meta(meta)\n check_permission(meta)\n run_id = resume_from_run_id or create_run_id(meta)\n task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)\n task.event(\"async_launched\", runId=run_id, taskId=task.task_id) # Return immediately\n ... # The rest proceeds in the background\n```\n\n## Workflow Metadata: Validate Before Launch\n\nEach workflow registers a metadata object with `name`, `description`, and optional `phases`. The runtime validates it before executing any workflow code. `name` and `description` identify the task in the UI, while `phases` names groups in the progress display.\n\nInvalid input raises `WorkflowInputError` immediately and is rejected during registration. This is the same idea as validating cron expressions in s14: do not wait until execution to discover a bad script.\n\nBecause the runtime uses `meta.name` in local artifact filenames, it also requires a 1-64 character safe slug containing letters, numbers, `.`, `_`, or `-`.\n\n```python\ndef validate_meta(meta):\n if not isinstance(meta, dict):\n raise WorkflowInputError(\"meta must be an object literal\")\n if not meta.get(\"name\") or not meta.get(\"description\"):\n raise WorkflowInputError(\"meta requires name and description\")\n if not isinstance(meta[\"name\"], str) or not WORKFLOW_NAME_RE.fullmatch(meta[\"name\"]):\n raise WorkflowInputError(\"meta.name must be a safe 1-64 character slug\")\n if \"phases\" in meta and (\n not isinstance(meta[\"phases\"], list)\n or not all(isinstance(p, str) and p for p in meta[\"phases\"])\n ):\n raise WorkflowInputError(\"meta.phases must contain non-empty strings\")\n return meta\n```\n\n## Orchestration Primitives: A Small Set Is Enough for Every Flow\n\nA script runs in an isolated context with only a small set of orchestration primitives as globals. The script does not read files or run shell commands directly. All real code operations are performed by dispatched subagents under their own tool permissions. These primitives are methods on `ExecutionState`:\n\n| Primitive | Purpose |\n|------|------|\n| `agent(prompt, {schema, label, phase})` | Dispatch one subagent |\n| `parallel(thunks)` | **Barrier**: run every task concurrently and wait until all results return |\n| `pipeline(items, *stages)` | Run each item through stages **without a barrier**; finished items proceed immediately |\n| `phase(title)` | Mark the current progress phase and update the progress display |\n| `log(message)` | Emit a progress log line |\n| `workflow(name, args)` | Run a nested sub-workflow, one level only |\n\n`pipeline` should be the default. Each item independently crosses every stage. Item A may reach stage three while item B is still in stage one. Use the `parallel` barrier only when the next stage truly requires every result from the previous stage. A barrier waits for the slowest task, so do not add one without need.\n\n```python\nasync def pipeline(self, items, *stages):\n async def run_item(item, idx):\n value = item\n for stage in stages: # Each item independently completes every stage\n value = await stage(value, item, idx)\n return value\n return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])\n```\n\n## Structured Output: Do Not Let Subagents Return Essays\n\n`agent({schema})` requires a subagent to return a JSON object matching the schema, internally through one structured-output call. The runtime validates the result and retries once if it does not match. Downstream code receives a regular object instead of a long essay that must be parsed again.\n\ns05 warned that tool arguments cannot be trusted completely. This is the same lesson in reverse: subagent output cannot be trusted completely either. Validate at the orchestration boundary, give one retry, and keep uncertainty out of the rest of the flow.\n\n```python\nresult = self.runner.run(prompt, schema, label)\nif schema is not None:\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok: # Retry once with a reminder, then fail\n result = self.runner.run(prompt + \"\\n\\nReturn valid JSON.\", schema, label)\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok:\n raise WorkflowInputError(f\"agent({{schema}}) returned invalid output: {err}\")\n```\n\n## Background Tasks and Progress Events\n\n`LocalWorkflowTask` maintains status and token usage and emits an SDK-style event stream: `task_started` → a sequence of `task_progress` events containing phase changes, subagent starts, and log batches → one final `task_notification` reporting completion, failure, or stop, plus output files, token count, tool calls, and elapsed time.\n\nThe main session treats these as ordinary events. Only the final completion notification re-enters the main loop.\n\n```python\nclass LocalWorkflowTask:\n def progress_event(self, ptype, **data): # Phase/subagent/log\n self.progress.append({\"type\": ptype, **data})\n print(f\" progress {ptype} ...\")\n```\n\n## Storage: Snapshot + Journal for Resuming after Interruptions\n\nThe runtime stores each run under `s20_workflow_runtime/.runtime/`: a `.json` snapshot, `.output.json` output, and `.journal.jsonl` journal. The snapshot and journal share a stable `runId`, so resume can locate one run's state and completed steps.\n\nThe journal is the core of checkpointed resume. It records every `agent()` result one line at a time:\n\n```python\nclass WorkflowJournal:\n def record(self, key, value):\n self._f.write(json.dumps({\"key\": key, \"value\": value}) + \"\\n\")\n self._f.flush()\n self.cache[key] = value\n```\n\n## Resume: Continue by runId and Reuse Everything Unchanged\n\nCalling the workflow again with `resume_from_run_id` reruns the script, but every `agent()` computes a deterministic semantic key. If that key is present in the journal, it returns the cached result without executing again. Every unchanged call hits the cache; only a changed call and the downstream steps that depend on it actually rerun.\n\nThe key detail is that keys cannot depend on concurrency order. Agents in `parallel` and `pipeline` finish in nondeterministic order. If \"the nth completion\" became the key, cache entries would map to the wrong calls on the next run. A key therefore uses a stable hash of call content, including type, label, prompt, and schema, rather than a shared counter:\n\n```python\ndef key(self, kind, label, prompt, schema):\n basis = f\"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}\"\n return f\"{kind}-{_stable_hash(basis) % 10**10:010d}\"\n\n# Inside agent():\ncached = self.journal.cached(key)\nif cached is not MISS:\n self.task.progress_event(\"workflow_agent\", label=label, status=\"cached\")\n return cached\n```\n\n## Determinism: Reproducibility Makes Resume Meaningful\n\nResume works only if the workflow is reproducible. Stable hashes and a deterministic runner make the same workflow plus the same arguments produce the same keys. Workflow code must therefore avoid uncontrolled clocks, randomness, filesystem state, and other inputs that would change those keys between runs.\n\n## See It Run\n\nThe sample `review-changes` workflow uses `pipeline` to send each review dimension independently through audit → verify. An `agent()` with a schema finds issues during audit. During verification, `parallel()` dispatches a separate adversarial subagent for every finding. Only confirmed issues remain, sorted by severity.\n\n```python\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n\n async def audit(_v, dimension, _i):\n out = await ctx.agent(f\"Inspect the changed code for {dimension} issues\",\n schema=FINDINGS_SCHEMA, label=f\"audit:{dimension}\", phase=\"Review\")\n return {\"dimension\": dimension, \"findings\": out[\"findings\"]}\n\n async def verify(audited, dimension, _i):\n ctx.phase(\"Verify\")\n verdicts = await ctx.parallel([ # Verify every finding independently\n (lambda f=f: ctx.agent(f\"Adversarially verify whether this issue is real: {f['title']}\",\n schema=VERDICT_SCHEMA, label=f\"verify:{dimension}:{f['title']}\"))\n for f in audited[\"findings\"]])\n return {\"dimension\": dimension,\n \"confirmed\": [f for f, v in zip(audited[\"findings\"], verdicts) if v and v[\"isReal\"]]}\n\n results = await ctx.pipeline(DIMENSIONS, audit, verify)\n ...\n```\n\n## Changes from s19\n\n| | s19 Comprehensive Agent | s20 Workflow Runtime |\n|--|-----------|---------------------|\n| Loop | One model-driven loop | Main loop unchanged; deterministic orchestration added above it |\n| Who decides the next step | Model decides each round | Script declares the orchestration in advance |\n| Multiple agents | One-shot s06 subagents | Scripted, reproducible, recoverable bulk orchestration |\n| New mechanisms | — | Script DSL, background tasks, progress events, journal/resume, structured output, deterministic VM |\n\ns20 does not replace the main loop. It exposes `Workflow` at the tool layer and starts a local workflow runtime behind it: one workflow deterministically drives N agent loops. An s06 subagent is dispatched once at the model's discretion; s20 turns orchestration into a replayable script.\n\n## Try It\n\n```bash\npython s20_workflow_runtime/code.py # Start review-changes and watch the event stream\npython s20_workflow_runtime/code.py resume # Resume by the last runId; every agent() hits the journal cache\n```\n\nWatch one launch produce `async_launched`, followed by background phase changes and subagent progress, then `task_notification`; the result is stored on the task object. A resumed run reports `agents=0 tokens=0` because every call hits the cache, and its result is byte-for-byte identical.\n\n## Next\n\nOrchestration adds a layer above agent capabilities: the main loop handles individual operations, while a script manages the whole team's flow. Once work becomes a deterministic, recoverable script, the model changes from the round-by-round driver into an execution unit scheduled by that script. The same `agent()` can be invoked ad hoc by the model in the main loop or orchestrated in bulk inside a workflow.\n\nNext: [s21 Goal Loop](/en/s21) — Orchestration fans work out and leaves the main loop. The next chapter moves in the opposite direction: a goal pulls control back into the main loop and refuses to let the turn end until the objective is achieved.\n\n\n" + "content": "# s20: Workflow Runtime — The Model Decides Each Step; a Script Decides the Orchestration\n\ns01 → ... → s18 → s19 → `s20` → [s21](/en/s21)\n\n> *\"One tool_use starts an entire orchestration in the background\"* — The `Workflow` tool starts a deterministic, recoverable script runtime that dispatches many subagents in bulk.\n>\n> **Harness layer**: Orchestration — a deterministic multi-agent script runtime above the single-agent loop.\n\n`code.py` keeps the demo deterministic: it emits `async_launched` and then awaits completion in one process. This demonstrates the lifecycle and journal without requiring a long-running background service.\n\n---\n\nFrom s01 through s19, our loop has always been model-driven and step-by-step: the model chooses one tool each round, its result enters `messages[]`, and another round begins. That is ideal for open-ended tasks because the model can inspect the current context and decide the next step on the spot.\n\nSome jobs, however, require deterministic command of a group of agents. Consider reviewing a large change: inspect ten dimensions in parallel → send each finding to a separate agent for adversarial verification → combine and deduplicate the results → sort by severity. The shape is fixed, and you really need three properties:\n\n- **Parallelism**, rather than waiting for one item at a time;\n- **Determinism**, so the same input produces the same result structure;\n- **Recoverability**, so an interruption does not rerun work that is already complete.\n\nMaking the model drive this process one round at a time in the main loop is slow and nondeterministic, and an interruption starts everything over. At that point, you do not need \"one more conversation turn.\" You need to encode the orchestration directly as code.\n\n## Put the Plan in Code, Not in a Sequence of Chat Turns\n\nAdd a `Workflow` tool to the harness tool pool. The user or model provides a script that expresses deterministic orchestration through a few simple primitives: `agent()`, `parallel()`, `pipeline()`, and `phase()`.\n\nThe main loop sees only one `tool_use` and immediately receives a \"started in the background\" result. Real execution continues inside the background runtime, which reports progress in real time and records every step in a journal on disk. Intermediate script results live in variables instead of taking space in conversation history. When restarted with `resume_from_run_id`, unchanged `agent()` calls hit the journal cache and reuse previous results, resuming from the checkpoint.\n\n![Workflow Runtime Overview](/course-assets/s20_workflow_runtime/workflow-runtime-overview.svg)\n\n```python\nSAMPLE_META = {\"name\": \"review-changes\", \"description\": \"Review code changes\", \"phases\": [\"Review\", \"Verify\"]}\n\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n results = await ctx.pipeline(DIMENSIONS, audit, verify) # Each dimension independently runs audit → verify\n confirmed = [f for r in results if r for f in r[\"confirmed\"]]\n ctx.log(f\"Confirmed {len(confirmed)} real issues\")\n return {\"confirmed\": confirmed}\n```\n\n## The Workflow Tool: Start in the Background; the Main Loop Sees One Call\n\n`Workflow` lives in the main agent's tool pool. The user can request a saved workflow, or the model can select the tool when a task matches a known orchestration. In either case, the model emits one `Workflow(...)` tool call.\n\nThe tool parses the arguments, validates metadata, checks permissions, registers a local workflow task, and immediately returns \"started asynchronously.\" The main loop does not block and can continue with other work while the workflow runs in the background. This is the claim-ticket pattern from s13 at a larger scale: hand over the ticket now, notify the user when the result is ready.\n\n```python\nclass WorkflowTool:\n async def call(self, meta, script_fn, args=None, resume_from_run_id=None):\n validate_meta(meta)\n check_permission(meta)\n run_id = resume_from_run_id or create_run_id(meta)\n task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)\n task.event(\"async_launched\", runId=run_id, taskId=task.task_id) # Return immediately\n ... # The rest proceeds in the background\n```\n\n## Workflow Metadata: Validate Before Launch\n\nEach workflow registers a metadata object with `name`, `description`, and optional `phases`. The runtime validates it before executing any workflow code. `name` and `description` identify the task in the UI, while `phases` names groups in the progress display.\n\nInvalid input raises `WorkflowInputError` immediately and is rejected during registration. This is the same idea as validating cron expressions in s14: do not wait until execution to discover a bad script.\n\nBecause the runtime uses `meta.name` in local artifact filenames, it also requires a 1-64 character safe slug containing letters, numbers, `.`, `_`, or `-`.\n\n```python\ndef validate_meta(meta):\n if not isinstance(meta, dict):\n raise WorkflowInputError(\"meta must be an object literal\")\n if not meta.get(\"name\") or not meta.get(\"description\"):\n raise WorkflowInputError(\"meta requires name and description\")\n if not isinstance(meta[\"name\"], str) or not WORKFLOW_NAME_RE.fullmatch(meta[\"name\"]):\n raise WorkflowInputError(\"meta.name must be a safe 1-64 character slug\")\n if \"phases\" in meta and (\n not isinstance(meta[\"phases\"], list)\n or not all(isinstance(p, str) and p for p in meta[\"phases\"])\n ):\n raise WorkflowInputError(\"meta.phases must contain non-empty strings\")\n return meta\n```\n\n## Orchestration Primitives: A Small Set Is Enough for Every Flow\n\nA script runs in an isolated context with only a small set of orchestration primitives as globals. The script does not read files or run shell commands directly. All real code operations are performed by dispatched subagents under their own tool permissions. These primitives are methods on `ExecutionState`:\n\n| Primitive | Purpose |\n|------|------|\n| `agent(prompt, {schema, label, phase})` | Dispatch one subagent |\n| `parallel(thunks)` | **Barrier**: run every task concurrently and wait until all results return |\n| `pipeline(items, *stages)` | Run each item through stages **without a barrier**; finished items proceed immediately |\n| `phase(title)` | Mark the current progress phase and update the progress display |\n| `log(message)` | Emit a progress log line |\n| `workflow(name, args)` | Run a nested sub-workflow, one level only |\n\n`pipeline` should be the default. Each item independently crosses every stage. Item A may reach stage three while item B is still in stage one. Use the `parallel` barrier only when the next stage truly requires every result from the previous stage. A barrier waits for the slowest task, so do not add one without need.\n\n```python\nasync def pipeline(self, items, *stages):\n async def run_item(item, idx):\n value = item\n for stage in stages: # Each item independently completes every stage\n value = await stage(value, item, idx)\n return value\n return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])\n```\n\n## Structured Output: Do Not Let Subagents Return Essays\n\n`agent({schema})` requires a subagent to return a JSON object matching the schema, internally through one structured-output call. The runtime validates the result and retries once if it does not match. Downstream code receives a regular object instead of a long essay that must be parsed again.\n\ns05 warned that tool arguments cannot be trusted completely. This is the same lesson in reverse: subagent output cannot be trusted completely either. Validate at the orchestration boundary, give one retry, and keep uncertainty out of the rest of the flow.\n\n```python\nresult = self.runner.run(prompt, schema, label)\nif schema is not None:\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok: # Retry once with a reminder, then fail\n result = self.runner.run(prompt + \"\\n\\nReturn valid JSON.\", schema, label)\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok:\n raise WorkflowInputError(f\"agent({{schema}}) returned invalid output: {err}\")\n```\n\n## Background Tasks and Progress Events\n\n`LocalWorkflowTask` maintains status and token usage and emits an SDK-style event stream: `task_started` → a sequence of `task_progress` events containing phase changes, subagent starts, and log batches → one final `task_notification` reporting completion, failure, or stop, plus output files, token count, tool calls, and elapsed time.\n\nThe main session treats these as ordinary events. Only the final completion notification re-enters the main loop.\n\n```python\nclass LocalWorkflowTask:\n def progress_event(self, ptype, **data): # Phase/subagent/log\n self.progress.append({\"type\": ptype, **data})\n print(f\" progress {ptype} ...\")\n```\n\n## Storage: Snapshot + Journal for Resuming after Interruptions\n\nThe runtime stores each run under `s20_workflow_runtime/.runtime/`: a `.json` snapshot, `.output.json` output, and `.journal.jsonl` journal. The snapshot and journal share a stable `runId`, so resume can locate one run's state and completed steps.\n\nThe journal is the core of checkpointed resume. It records every `agent()` result one line at a time:\n\n```python\nclass WorkflowJournal:\n def record(self, key, value):\n self._f.write(json.dumps({\"key\": key, \"value\": value}) + \"\\n\")\n self._f.flush()\n self.cache[key] = value\n```\n\n## Resume: Continue by runId and Reuse Everything Unchanged\n\nCalling the workflow again with `resume_from_run_id` reruns the script, but every `agent()` computes a deterministic semantic key. If that key is present in the journal, it returns the cached result without executing again. Every unchanged call hits the cache; only a changed call and the downstream steps that depend on it actually rerun.\n\nThe key detail is that keys cannot depend on concurrency order. Agents in `parallel` and `pipeline` finish in nondeterministic order. If \"the nth completion\" became the key, cache entries would map to the wrong calls on the next run. A key therefore uses a stable hash of call content, including type, label, prompt, and schema, rather than a shared counter:\n\n```python\ndef key(self, kind, label, prompt, schema):\n basis = f\"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}\"\n return f\"{kind}-{_stable_hash(basis) % 10**10:010d}\"\n\n# Inside agent():\ncached = self.journal.cached(key)\nif cached is not MISS:\n self.task.progress_event(\"workflow_agent\", label=label, status=\"cached\")\n return cached\n```\n\n## Determinism: Reproducibility Makes Resume Meaningful\n\nResume works only if the workflow is reproducible. Stable hashes and a deterministic runner make the same workflow plus the same arguments produce the same keys. Workflow code must therefore avoid uncontrolled clocks, randomness, filesystem state, and other inputs that would change those keys between runs.\n\n## See It Run\n\nThe sample `review-changes` workflow uses `pipeline` to send each review dimension independently through audit → verify. An `agent()` with a schema finds issues during audit. During verification, `parallel()` dispatches a separate adversarial subagent for every finding. Only confirmed issues remain, sorted by severity.\n\n```python\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n\n async def audit(_v, dimension, _i):\n out = await ctx.agent(f\"Inspect the changed code for {dimension} issues\",\n schema=FINDINGS_SCHEMA, label=f\"audit:{dimension}\", phase=\"Review\")\n return {\"dimension\": dimension, \"findings\": out[\"findings\"]}\n\n async def verify(audited, dimension, _i):\n ctx.phase(\"Verify\")\n verdicts = await ctx.parallel([ # Verify every finding independently\n (lambda f=f: ctx.agent(f\"Adversarially verify whether this issue is real: {f['title']}\",\n schema=VERDICT_SCHEMA, label=f\"verify:{dimension}:{f['title']}\"))\n for f in audited[\"findings\"]])\n return {\"dimension\": dimension,\n \"confirmed\": [f for f, v in zip(audited[\"findings\"], verdicts) if v and v[\"isReal\"]]}\n\n results = await ctx.pipeline(DIMENSIONS, audit, verify)\n ...\n```\n\n## Changes from s19\n\n| | s19 Integrated Harness | s20 Workflow Runtime |\n|--|-----------|---------------------|\n| Loop | One model-driven loop | Main loop unchanged; deterministic orchestration added above it |\n| Who decides the next step | Model decides each round | Script declares the orchestration in advance |\n| Multiple agents | One-shot s06 subagents | Scripted, reproducible, recoverable bulk orchestration |\n| New mechanisms | — | Script DSL, background tasks, progress events, journal/resume, structured output, deterministic VM |\n\ns20 does not replace the main loop. It exposes `Workflow` at the tool layer and starts a local workflow runtime behind it: one workflow deterministically drives N agent loops. An s06 subagent is dispatched once at the model's discretion; s20 turns orchestration into a replayable script.\n\n## Try It\n\n```bash\npython s20_workflow_runtime/code.py # Start review-changes and watch the event stream\npython s20_workflow_runtime/code.py resume # Resume by the last runId; every agent() hits the journal cache\n```\n\nWatch one launch produce `async_launched`, followed by background phase changes and subagent progress, then `task_notification`; the result is stored on the task object. A resumed run reports `agents=0 tokens=0` because every call hits the cache, and its result is byte-for-byte identical.\n\n## Next\n\nOrchestration adds a layer above agent capabilities: the main loop handles individual operations, while a script manages the whole team's flow. Once work becomes a deterministic, recoverable script, the model changes from the round-by-round driver into an execution unit scheduled by that script. The same `agent()` can be invoked ad hoc by the model in the main loop or orchestrated in bulk inside a workflow.\n\nNext: [s21 Goal Loop](/en/s21) — Orchestration fans work out and leaves the main loop. The next chapter moves in the opposite direction: a goal pulls control back into the main loop and refuses to let the turn end until the objective is achieved.\n\n\n" }, { "version": "s20", "locale": "zh", "title": "s20: Workflow Runtime — 模型决定单步,脚本决定编排", - "content": "# s20: Workflow Runtime — 模型决定单步,脚本决定编排\n\ns01 → ... → s18 → s19 → `s20` → [s21](/zh/s21)\n\n> *\"一次 tool_use,后台跑完一整套编排\"* — `Workflow` 工具启动一个确定、可恢复的脚本运行时,批量派出去一堆子 agent。\n>\n> **Harness 层**: 编排 — 在单 agent 循环之上,加一层确定的多 agent 脚本运行时。\n\n`code.py` 为了让演示保持确定,会先发出 `async_launched`,随后在同一进程里等待执行完成。这样不用启动常驻后台服务,也能看清生命周期和 journal。\n\n---\n\n从 s01 到 s19,我们的循环一直是模型驱动、一步一步来的:每一轮模型挑一个工具,结果塞回 `messages[]`,再来一轮。开放式任务这么干最合适,下一步做什么,让模型看着上下文临场决定就好。\n\n但有些活,你需要的是确定地指挥一群 agent 干活。比如审一个大改动:十个维度并行找问题 → 每条发现各自派一个 agent 做对抗性验证 → 结果汇总去重 → 按严重度排序。这种流程的形状是固定的,你要的其实是三样东西:\n\n- **并行**,别一个一个串着等;\n- **确定**,同样的输入跑出来同样的结果结构;\n- **可恢复**,跑到一半断了,已经做完的部分别从头再来。\n\n让模型在主循环里一步一步驱动这套流程,又慢、结果又不确定,断了还得从头跑。这时候你要的不是\"再聊一轮\",而是把这套编排直接写成代码。\n\n## 计划写在代码里,不是靠聊天一轮轮凑\n\n在 harness 的工具池里加入一个 `Workflow` 工具。用户或模型给它一段脚本,脚本用 `agent() / parallel() / pipeline() / phase()` 这几个简单的原语,把编排写成确定的代码。\n\n主循环这边只看到一次 `tool_use`,立刻拿到\"已在后台启动\"的返回:真正的执行在后台运行时里推进,实时上报进度,所有过程都写到磁盘的 journal 文件里。脚本里的中间结果存在变量里,不会塞进对话历史占地方。下次用 `resume_from_run_id` 重启时,没改过的 `agent()` 直接命中 journal 缓存,直接用之前的结果,断点续跑。\n\n![Workflow Runtime 总览](/course-assets/s20_workflow_runtime/workflow-runtime-overview.svg)\n\n```python\nSAMPLE_META = {\"name\": \"review-changes\", \"description\": \"审查代码改动\", \"phases\": [\"Review\", \"Verify\"]}\n\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n results = await ctx.pipeline(DIMENSIONS, audit, verify) # 每个维度独立走 审计 → 验证\n confirmed = [f for r in results if r for f in r[\"confirmed\"]]\n ctx.log(f\"确认了 {len(confirmed)} 个真实问题\")\n return {\"confirmed\": confirmed}\n```\n\n## Workflow 工具:后台启动,主循环只看到一次调用\n\n`Workflow` 就在主 agent 的工具池里。用户可以要求运行一个保存好的 workflow,模型也可以在任务匹配已知编排时选择这个工具;两种情况最终都只发出一次 `Workflow(...)` 工具调用。\n\n工具收到后会解析参数、校验 meta 信息、过权限检查、注册一个本地 workflow 任务,然后立刻返回\"已异步启动\"。主循环不阻塞,该干嘛干嘛;workflow 自己在后台跑。这其实就是 s13 后台任务那套\"凭条模式\"的放大版:先给你个取件条,结果好了再通知你。\n\n```python\nclass WorkflowTool:\n async def call(self, meta, script_fn, args=None, resume_from_run_id=None):\n validate_meta(meta)\n check_permission(meta)\n run_id = resume_from_run_id or create_run_id(meta)\n task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)\n task.event(\"async_launched\", runId=run_id, taskId=task.task_id) # 立刻返回\n ... # 剩下的后台慢慢跑\n```\n\n## Workflow 元数据:启动前先校验\n\n每个 workflow 都要注册一个元数据对象,包含 `name`、`description` 和可选的 `phases`。运行时会在执行任何 workflow 代码之前校验它:`name` 和 `description` 用来标识任务,`phases` 给进度条分组命名。\n\n不对的输入直接抛 `WorkflowInputError`,注册的时候就拦住——这和 s14 校验 cron 表达式是一个思路:坏脚本别让它跑到执行的时候才炸。\n\n运行时会把 `meta.name` 用在本地产物文件名中,因此还要求它是 1-64 个字符的安全 slug,只能包含字母、数字、`.`、`_`、`-`。\n\n```python\ndef validate_meta(meta):\n if not isinstance(meta, dict):\n raise WorkflowInputError(\"meta 必须是对象字面量\")\n if not meta.get(\"name\") or not meta.get(\"description\"):\n raise WorkflowInputError(\"meta 必须包含 name 和 description\")\n if not isinstance(meta[\"name\"], str) or not WORKFLOW_NAME_RE.fullmatch(meta[\"name\"]):\n raise WorkflowInputError(\"meta.name 必须是 1-64 字符的安全 slug\")\n if \"phases\" in meta and (\n not isinstance(meta[\"phases\"], list)\n or not all(isinstance(p, str) and p for p in meta[\"phases\"])\n ):\n raise WorkflowInputError(\"meta.phases 必须包含非空字符串\")\n return meta\n```\n\n## 编排原语:就这几个,够写所有流程\n\n脚本跑在一个独立的上下文里,能用的全局变量就这几个编排原语。脚本本身不直接读写文件、不跑 shell,真正的代码操作都由派出去的子 agent 用它们自己的工具权限完成。这些原语都是 `ExecutionState` 上的方法:\n\n| 原语 | 作用 |\n|------|------|\n| `agent(prompt, {schema, label, phase})` | 派一个子 agent 干活 |\n| `parallel(thunks)` | **等齐屏障**:所有任务并行跑完,一起等结果回来 |\n| `pipeline(items, *stages)` | 每个 item 分阶段跑,**不等齐**,跑完一个往下走一个 |\n| `phase(title)` | 标记当前进度阶段(更新进度条) |\n| `log(message)` | 打一行进度日志 |\n| `workflow(name, args)` | 嵌套子工作流(只支持一层) |\n\n`pipeline` 是你默认该用的:每个 item 独立穿过所有 stage,item A 跑到第 3 阶段的时候,item B 可能还在第 1 阶段;只有真的需要\"拿到上一阶段所有结果才能往下走\"的时候,才用 `parallel` 这个屏障。屏障的代价是等最慢的那个任务,没必要就别立。\n\n```python\nasync def pipeline(self, items, *stages):\n async def run_item(item, idx):\n value = item\n for stage in stages: # 每个 item 独立跑完所有 stage\n value = await stage(value, item, idx)\n return value\n return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])\n```\n\n## 结构化输出:别让子 agent 回来写散文\n\n`agent({schema})` 会强制子 agent 返回一个匹配 schema 的 JSON 对象(内部通过一次结构化输出调用实现),运行时会按 schema 校验结果,不对就重试一次。这样下游代码拿到的是规整的对象,不是需要再解析的一大段散文。\n\ns05 就说过,工具的参数不能全信;这里是同一个道理反过来:子 agent 的输出也不能全信。加一层校验,不对就给一次机会重试,把不确定性挡在编排层外面。\n\n```python\nresult = self.runner.run(prompt, schema, label)\nif schema is not None:\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok: # 提醒一次重试,再不对就报错\n result = self.runner.run(prompt + \"\\n\\n返回合法的 JSON。\", schema, label)\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok:\n raise WorkflowInputError(f\"agent({{schema}}) 输出不合法: {err}\")\n```\n\n## 后台任务和进度事件\n\n`LocalWorkflowTask` 维护状态和 token 用量,向外发一条 SDK 风格的事件流:`task_started` → 一串 `task_progress`(包含阶段切换、子 agent 启动、日志输出这些批次)→ 最后一个 `task_notification`(完成/失败/停止,带输出文件、token 数、工具调用数、耗时)。\n\n主会话把这些当普通事件处理;只有最终的完成通知会重新进入主循环。\n\n```python\nclass LocalWorkflowTask:\n def progress_event(self, ptype, **data): # 阶段/子agent/日志\n self.progress.append({\"type\": ptype, **data})\n print(f\" 进度 {ptype} ...\")\n```\n\n## 存储:快照 + journal,断了能续\n\n运行时把每次运行的数据存在 `s20_workflow_runtime/.runtime/`:快照 `.json`、输出 `.output.json` 和 journal `.journal.jsonl`。快照与 journal 共享稳定的 `runId`,续跑时才能找到同一次运行的状态和已完成步骤。\n\njournal 是断点续跑的核心,它一条一条记下来每个 `agent()` 的结果:\n\n```python\nclass WorkflowJournal:\n def record(self, key, value):\n self._f.write(json.dumps({\"key\": key, \"value\": value}) + \"\\n\")\n self._f.flush()\n self.cache[key] = value\n```\n\n## resume:用 runId 续跑,没改的直接用缓存\n\n带着 `resume_from_run_id` 再次调用 workflow 时,脚本会重新执行,但每个 `agent()` 都会计算一个确定的语义 key:key 在 journal 里有记录,就直接返回缓存结果;只有改过的调用以及依赖它的后续步骤才会真的运行。\n\n这里有个关键点:key 不能依赖并发顺序。`parallel` 和 `pipeline` 里 agent 完成的顺序是不确定的,用\"第几个完成\"当 key,两次跑缓存就对错位了。所以 key 是根据调用内容(类型、标签、prompt、schema)算的稳定哈希,不是一个会竞争的计数器:\n\n```python\ndef key(self, kind, label, prompt, schema):\n basis = f\"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}\"\n return f\"{kind}-{_stable_hash(basis) % 10**10:010d}\"\n\n# agent() 内部:\ncached = self.journal.cached(key)\nif cached is not MISS:\n self.task.progress_event(\"workflow_agent\", label=label, status=\"cached\")\n return cached\n```\n\n## 确定性:能复现,续跑才有意义\n\n续跑要能工作,workflow 首先得可复现。稳定哈希和确定性的 runner 让同一份 workflow + 同样的参数产生同样的 key。因此 workflow 代码要避免不受控的时钟、随机数、文件系统状态等会让 key 在两次运行间变化的输入。\n\n## 跑起来看看\n\n示例 workflow `review-changes`:用 `pipeline` 让每个审查维度独立走\"审计 → 验证\"流程。审计用一个带 schema 的 `agent()` 找问题,验证用 `parallel()` 给每条发现各派一个对抗性验证的子 agent,最后只留确认真实的问题,按严重度排序。\n\n```python\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n\n async def audit(_v, dimension, _i):\n out = await ctx.agent(f\"检查改动的代码里有没有{dimension}相关的问题\",\n schema=FINDINGS_SCHEMA, label=f\"audit:{dimension}\", phase=\"Review\")\n return {\"dimension\": dimension, \"findings\": out[\"findings\"]}\n\n async def verify(audited, dimension, _i):\n ctx.phase(\"Verify\")\n verdicts = await ctx.parallel([ # 每条发现独立做对抗性验证\n (lambda f=f: ctx.agent(f\"请对抗性验证这个问题是不是真的:{f['title']}\",\n schema=VERDICT_SCHEMA, label=f\"verify:{dimension}:{f['title']}\"))\n for f in audited[\"findings\"]])\n return {\"dimension\": dimension,\n \"confirmed\": [f for f, v in zip(audited[\"findings\"], verdicts) if v and v[\"isReal\"]]}\n\n results = await ctx.pipeline(DIMENSIONS, audit, verify)\n ...\n```\n\n## 相对 s19 的变更\n\n| | s19 综合体 | s20 Workflow Runtime |\n|--|-----------|---------------------|\n| 循环 | 单个、模型驱动 | 主循环不变;上面加一层确定的编排 |\n| 谁决定下一步 | 模型逐轮决定 | 脚本预先写好编排流程 |\n| 多 agent | s06 子 agent,一次性派出去 | 脚本化、可复现、可恢复的批量编排 |\n| 新增机制 | — | 脚本 DSL、后台任务、进度事件、journal/续跑、结构化输出、确定性 VM |\n\ns20 不替换主循环,它只是在工具层暴露了 `Workflow`,背后启动一个本地 workflow 运行时:一个 workflow 确定地驱动 N 个 agent 循环。s06 的子 agent 是模型临场派一次;s20 是把编排写成可以重放的脚本。\n\n## 试一下\n\n```bash\npython s20_workflow_runtime/code.py # 启动 review-changes,看事件流\npython s20_workflow_runtime/code.py resume # 用上次的 runId 续跑,每个 agent() 都命中 journal 缓存\n```\n\n观察:一次启动 → `async_launched` → 后台阶段切换/子agent进度推进 → `task_notification`;结果存在任务对象上。续跑的时候会显示 `agents=0 tokens=0`(全部命中缓存),结果和上次一字不差。\n\n## 接下来\n\n编排是在 agent 能力之上又加了一层:主循环管单步操作,脚本管整支队伍的流程。把工作写成确定、可恢复的脚本,模型就从\"逐轮驱动者\"变成了\"被脚本调度的执行单元\"。同一个 `agent()`,既能在主循环里被模型临场调用,也能在 workflow 里被脚本批量编排。\n\n下一章:[s21 Goal Loop](/zh/s21) — 编排是把工作扇出去、脱离主循环;下一章反过来,一个目标把控制权重拉回主循环,没达成就不让这一轮结束。\n\n\n" + "content": "# s20: Workflow Runtime — 模型决定单步,脚本决定编排\n\ns01 → ... → s18 → s19 → `s20` → [s21](/zh/s21)\n\n> *\"一次 tool_use,后台跑完一整套编排\"* — `Workflow` 工具启动一个确定、可恢复的脚本运行时,批量派出去一堆子 agent。\n>\n> **Harness 层**: 编排 — 在单 agent 循环之上,加一层确定的多 agent 脚本运行时。\n\n`code.py` 为了让演示保持确定,会先发出 `async_launched`,随后在同一进程里等待执行完成。这样不用启动常驻后台服务,也能看清生命周期和 journal。\n\n---\n\n从 s01 到 s19,我们的循环一直是模型驱动、一步一步来的:每一轮模型挑一个工具,结果塞回 `messages[]`,再来一轮。开放式任务这么干最合适,下一步做什么,让模型看着上下文临场决定就好。\n\n但有些活,你需要的是确定地指挥一群 agent 干活。比如审一个大改动:十个维度并行找问题 → 每条发现各自派一个 agent 做对抗性验证 → 结果汇总去重 → 按严重度排序。这种流程的形状是固定的,你要的其实是三样东西:\n\n- **并行**,别一个一个串着等;\n- **确定**,同样的输入跑出来同样的结果结构;\n- **可恢复**,跑到一半断了,已经做完的部分别从头再来。\n\n让模型在主循环里一步一步驱动这套流程,又慢、结果又不确定,断了还得从头跑。这时候你要的不是\"再聊一轮\",而是把这套编排直接写成代码。\n\n## 计划写在代码里,不是靠聊天一轮轮凑\n\n在 harness 的工具池里加入一个 `Workflow` 工具。用户或模型给它一段脚本,脚本用 `agent() / parallel() / pipeline() / phase()` 这几个简单的原语,把编排写成确定的代码。\n\n主循环这边只看到一次 `tool_use`,立刻拿到\"已在后台启动\"的返回:真正的执行在后台运行时里推进,实时上报进度,所有过程都写到磁盘的 journal 文件里。脚本里的中间结果存在变量里,不会塞进对话历史占地方。下次用 `resume_from_run_id` 重启时,没改过的 `agent()` 直接命中 journal 缓存,直接用之前的结果,断点续跑。\n\n![Workflow Runtime 总览](/course-assets/s20_workflow_runtime/workflow-runtime-overview.svg)\n\n```python\nSAMPLE_META = {\"name\": \"review-changes\", \"description\": \"审查代码改动\", \"phases\": [\"Review\", \"Verify\"]}\n\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n results = await ctx.pipeline(DIMENSIONS, audit, verify) # 每个维度独立走 审计 → 验证\n confirmed = [f for r in results if r for f in r[\"confirmed\"]]\n ctx.log(f\"确认了 {len(confirmed)} 个真实问题\")\n return {\"confirmed\": confirmed}\n```\n\n## Workflow 工具:后台启动,主循环只看到一次调用\n\n`Workflow` 就在主 agent 的工具池里。用户可以要求运行一个保存好的 workflow,模型也可以在任务匹配已知编排时选择这个工具;两种情况最终都只发出一次 `Workflow(...)` 工具调用。\n\n工具收到后会解析参数、校验 meta 信息、过权限检查、注册一个本地 workflow 任务,然后立刻返回\"已异步启动\"。主循环不阻塞,该干嘛干嘛;workflow 自己在后台跑。这其实就是 s13 后台任务那套\"凭条模式\"的放大版:先给你个取件条,结果好了再通知你。\n\n```python\nclass WorkflowTool:\n async def call(self, meta, script_fn, args=None, resume_from_run_id=None):\n validate_meta(meta)\n check_permission(meta)\n run_id = resume_from_run_id or create_run_id(meta)\n task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)\n task.event(\"async_launched\", runId=run_id, taskId=task.task_id) # 立刻返回\n ... # 剩下的后台慢慢跑\n```\n\n## Workflow 元数据:启动前先校验\n\n每个 workflow 都要注册一个元数据对象,包含 `name`、`description` 和可选的 `phases`。运行时会在执行任何 workflow 代码之前校验它:`name` 和 `description` 用来标识任务,`phases` 给进度条分组命名。\n\n不对的输入直接抛 `WorkflowInputError`,注册的时候就拦住——这和 s14 校验 cron 表达式是一个思路:坏脚本别让它跑到执行的时候才炸。\n\n运行时会把 `meta.name` 用在本地产物文件名中,因此还要求它是 1-64 个字符的安全 slug,只能包含字母、数字、`.`、`_`、`-`。\n\n```python\ndef validate_meta(meta):\n if not isinstance(meta, dict):\n raise WorkflowInputError(\"meta 必须是对象字面量\")\n if not meta.get(\"name\") or not meta.get(\"description\"):\n raise WorkflowInputError(\"meta 必须包含 name 和 description\")\n if not isinstance(meta[\"name\"], str) or not WORKFLOW_NAME_RE.fullmatch(meta[\"name\"]):\n raise WorkflowInputError(\"meta.name 必须是 1-64 字符的安全 slug\")\n if \"phases\" in meta and (\n not isinstance(meta[\"phases\"], list)\n or not all(isinstance(p, str) and p for p in meta[\"phases\"])\n ):\n raise WorkflowInputError(\"meta.phases 必须包含非空字符串\")\n return meta\n```\n\n## 编排原语:就这几个,够写所有流程\n\n脚本跑在一个独立的上下文里,能用的全局变量就这几个编排原语。脚本本身不直接读写文件、不跑 shell,真正的代码操作都由派出去的子 agent 用它们自己的工具权限完成。这些原语都是 `ExecutionState` 上的方法:\n\n| 原语 | 作用 |\n|------|------|\n| `agent(prompt, {schema, label, phase})` | 派一个子 agent 干活 |\n| `parallel(thunks)` | **等齐屏障**:所有任务并行跑完,一起等结果回来 |\n| `pipeline(items, *stages)` | 每个 item 分阶段跑,**不等齐**,跑完一个往下走一个 |\n| `phase(title)` | 标记当前进度阶段(更新进度条) |\n| `log(message)` | 打一行进度日志 |\n| `workflow(name, args)` | 嵌套子工作流(只支持一层) |\n\n`pipeline` 是你默认该用的:每个 item 独立穿过所有 stage,item A 跑到第 3 阶段的时候,item B 可能还在第 1 阶段;只有真的需要\"拿到上一阶段所有结果才能往下走\"的时候,才用 `parallel` 这个屏障。屏障的代价是等最慢的那个任务,没必要就别立。\n\n```python\nasync def pipeline(self, items, *stages):\n async def run_item(item, idx):\n value = item\n for stage in stages: # 每个 item 独立跑完所有 stage\n value = await stage(value, item, idx)\n return value\n return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])\n```\n\n## 结构化输出:别让子 agent 回来写散文\n\n`agent({schema})` 会强制子 agent 返回一个匹配 schema 的 JSON 对象(内部通过一次结构化输出调用实现),运行时会按 schema 校验结果,不对就重试一次。这样下游代码拿到的是规整的对象,不是需要再解析的一大段散文。\n\ns05 就说过,工具的参数不能全信;这里是同一个道理反过来:子 agent 的输出也不能全信。加一层校验,不对就给一次机会重试,把不确定性挡在编排层外面。\n\n```python\nresult = self.runner.run(prompt, schema, label)\nif schema is not None:\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok: # 提醒一次重试,再不对就报错\n result = self.runner.run(prompt + \"\\n\\n返回合法的 JSON。\", schema, label)\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok:\n raise WorkflowInputError(f\"agent({{schema}}) 输出不合法: {err}\")\n```\n\n## 后台任务和进度事件\n\n`LocalWorkflowTask` 维护状态和 token 用量,向外发一条 SDK 风格的事件流:`task_started` → 一串 `task_progress`(包含阶段切换、子 agent 启动、日志输出这些批次)→ 最后一个 `task_notification`(完成/失败/停止,带输出文件、token 数、工具调用数、耗时)。\n\n主会话把这些当普通事件处理;只有最终的完成通知会重新进入主循环。\n\n```python\nclass LocalWorkflowTask:\n def progress_event(self, ptype, **data): # 阶段/子agent/日志\n self.progress.append({\"type\": ptype, **data})\n print(f\" 进度 {ptype} ...\")\n```\n\n## 存储:快照 + journal,断了能续\n\n运行时把每次运行的数据存在 `s20_workflow_runtime/.runtime/`:快照 `.json`、输出 `.output.json` 和 journal `.journal.jsonl`。快照与 journal 共享稳定的 `runId`,续跑时才能找到同一次运行的状态和已完成步骤。\n\njournal 是断点续跑的核心,它一条一条记下来每个 `agent()` 的结果:\n\n```python\nclass WorkflowJournal:\n def record(self, key, value):\n self._f.write(json.dumps({\"key\": key, \"value\": value}) + \"\\n\")\n self._f.flush()\n self.cache[key] = value\n```\n\n## resume:用 runId 续跑,没改的直接用缓存\n\n带着 `resume_from_run_id` 再次调用 workflow 时,脚本会重新执行,但每个 `agent()` 都会计算一个确定的语义 key:key 在 journal 里有记录,就直接返回缓存结果;只有改过的调用以及依赖它的后续步骤才会真的运行。\n\n这里有个关键点:key 不能依赖并发顺序。`parallel` 和 `pipeline` 里 agent 完成的顺序是不确定的,用\"第几个完成\"当 key,两次跑缓存就对错位了。所以 key 是根据调用内容(类型、标签、prompt、schema)算的稳定哈希,不是一个会竞争的计数器:\n\n```python\ndef key(self, kind, label, prompt, schema):\n basis = f\"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}\"\n return f\"{kind}-{_stable_hash(basis) % 10**10:010d}\"\n\n# agent() 内部:\ncached = self.journal.cached(key)\nif cached is not MISS:\n self.task.progress_event(\"workflow_agent\", label=label, status=\"cached\")\n return cached\n```\n\n## 确定性:能复现,续跑才有意义\n\n续跑要能工作,workflow 首先得可复现。稳定哈希和确定性的 runner 让同一份 workflow + 同样的参数产生同样的 key。因此 workflow 代码要避免不受控的时钟、随机数、文件系统状态等会让 key 在两次运行间变化的输入。\n\n## 跑起来看看\n\n示例 workflow `review-changes`:用 `pipeline` 让每个审查维度独立走\"审计 → 验证\"流程。审计用一个带 schema 的 `agent()` 找问题,验证用 `parallel()` 给每条发现各派一个对抗性验证的子 agent,最后只留确认真实的问题,按严重度排序。\n\n```python\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n\n async def audit(_v, dimension, _i):\n out = await ctx.agent(f\"检查改动的代码里有没有{dimension}相关的问题\",\n schema=FINDINGS_SCHEMA, label=f\"audit:{dimension}\", phase=\"Review\")\n return {\"dimension\": dimension, \"findings\": out[\"findings\"]}\n\n async def verify(audited, dimension, _i):\n ctx.phase(\"Verify\")\n verdicts = await ctx.parallel([ # 每条发现独立做对抗性验证\n (lambda f=f: ctx.agent(f\"请对抗性验证这个问题是不是真的:{f['title']}\",\n schema=VERDICT_SCHEMA, label=f\"verify:{dimension}:{f['title']}\"))\n for f in audited[\"findings\"]])\n return {\"dimension\": dimension,\n \"confirmed\": [f for f, v in zip(audited[\"findings\"], verdicts) if v and v[\"isReal\"]]}\n\n results = await ctx.pipeline(DIMENSIONS, audit, verify)\n ...\n```\n\n## 相对 s19 的变更\n\n| | s19 Agent Harness 集成 | s20 Workflow Runtime |\n|--|-----------|---------------------|\n| 循环 | 单个、模型驱动 | 主循环不变;上面加一层确定的编排 |\n| 谁决定下一步 | 模型逐轮决定 | 脚本预先写好编排流程 |\n| 多 agent | s06 子 agent,一次性派出去 | 脚本化、可复现、可恢复的批量编排 |\n| 新增机制 | — | 脚本 DSL、后台任务、进度事件、journal/续跑、结构化输出、确定性 VM |\n\ns20 不替换主循环,它只是在工具层暴露了 `Workflow`,背后启动一个本地 workflow 运行时:一个 workflow 确定地驱动 N 个 agent 循环。s06 的子 agent 是模型临场派一次;s20 是把编排写成可以重放的脚本。\n\n## 试一下\n\n```bash\npython s20_workflow_runtime/code.py # 启动 review-changes,看事件流\npython s20_workflow_runtime/code.py resume # 用上次的 runId 续跑,每个 agent() 都命中 journal 缓存\n```\n\n观察:一次启动 → `async_launched` → 后台阶段切换/子agent进度推进 → `task_notification`;结果存在任务对象上。续跑的时候会显示 `agents=0 tokens=0`(全部命中缓存),结果和上次一字不差。\n\n## 接下来\n\n编排是在 agent 能力之上又加了一层:主循环管单步操作,脚本管整支队伍的流程。把工作写成确定、可恢复的脚本,模型就从\"逐轮驱动者\"变成了\"被脚本调度的执行单元\"。同一个 `agent()`,既能在主循环里被模型临场调用,也能在 workflow 里被脚本批量编排。\n\n下一章:[s21 Goal Loop](/zh/s21) — 编排是把工作扇出去、脱离主循环;下一章反过来,一个目标把控制权重拉回主循环,没达成就不让这一轮结束。\n\n\n" }, { "version": "s20", "locale": "ja", "title": "s20: Workflow Runtime — モデルが単一 step を決め、script が orchestration を決める", - "content": "# s20: Workflow Runtime — モデルが単一 step を決め、script が orchestration を決める\n\ns01 → ... → s18 → s19 → `s20` → [s21](/ja/s21)\n\n> *「1 回の tool_use で、バックグラウンドに一式の orchestration を走らせる」* — `Workflow` ツールが決定的で復元可能な script runtime を起動し、多数の subagent をまとめて送り出します。\n>\n> **Harness 層**: Orchestration — single-agent loop の上に、決定的な multi-agent script runtime を追加します。\n\n`code.py` は demo を決定的に保つため、`async_launched` を出した後、同じ process で完了を待ちます。常駐 background service を用意しなくても、lifecycle と journal を確認できます。\n\n---\n\ns01 から s19 まで、loop は常にモデル駆動で 1 step ずつ進みました。各ラウンドでモデルが 1 つのツールを選び、結果を `messages[]` へ入れ、次のラウンドへ進みます。open-ended なタスクには最適です。次に何をするかを、モデルが context を見てその場で決められます。\n\nしかし、複数の Agent を決定的に指揮したい仕事もあります。大きな変更の review を考えてください。10 の観点から並行して問題を探す → 各 finding へ別 Agent を送り adversarial verification を行う → 結果を集約して重複を除く → severity 順に並べる。この流れの形は固定されており、本当に必要なのは 3 つです。\n\n- **並行性**: 1 件ずつ順番に待たないこと。\n- **決定性**: 同じ入力から同じ結果構造が得られること。\n- **復元可能性**: 途中で止まっても、完了済みの部分を最初からやり直さないこと。\n\nこの流れをモデルに main loop で 1 ラウンドずつ動かさせると、遅く、結果は不確定で、中断すれば最初からです。ここで必要なのは「もう 1 turn 話す」ことではなく、orchestration をそのままコードにすることです。\n\n## 計画は chat のラウンドを重ねず、コードに書く\n\nharness の tool pool に `Workflow` ツールを追加します。ユーザーまたはモデルが渡す script は、`agent() / parallel() / pipeline() / phase()` という少数の primitive を使い、orchestration を決定的なコードとして表します。\n\nmain loop から見えるのは 1 回の `tool_use` だけで、すぐ「バックグラウンドで起動済み」という結果を受け取ります。本当の実行は background runtime で進み、進捗をリアルタイムに報告し、全過程をディスク上の journal へ記録します。script の中間結果は変数に保存され、会話履歴の場所を取りません。`resume_from_run_id` で再開すると、変更されていない `agent()` は journal cache に当たり、以前の結果を直接使って checkpoint から続行します。\n\n![Workflow Runtime Overview](/course-assets/s20_workflow_runtime/workflow-runtime-overview.svg)\n\n```python\nSAMPLE_META = {\"name\": \"review-changes\", \"description\": \"コード変更を review\", \"phases\": [\"Review\", \"Verify\"]}\n\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n results = await ctx.pipeline(DIMENSIONS, audit, verify) # 各 dimension が独立して audit → verify を通る\n confirmed = [f for r in results if r for f in r[\"confirmed\"]]\n ctx.log(f\"{len(confirmed)} 件の実在する問題を確認\")\n return {\"confirmed\": confirmed}\n```\n\n## Workflow ツール: バックグラウンド起動、main loop には 1 回の call だけ\n\n`Workflow` は main Agent の tool pool にあります。ユーザーが保存済み workflow の実行を求めるか、タスクが既知の orchestration に一致したときにモデルがこのツールを選びます。どちらも 1 回の `Workflow(...)` tool call になります。\n\nツールは argument を parse し、meta 情報を検証し、permission check を通し、local workflow task を登録すると、すぐ「非同期で起動済み」と返します。main loop は block せず別の仕事を続け、workflow は background で実行されます。これは s13 の引換券 pattern を拡大したものです。先に引換券を渡し、結果ができたら通知します。\n\n```python\nclass WorkflowTool:\n async def call(self, meta, script_fn, args=None, resume_from_run_id=None):\n validate_meta(meta)\n check_permission(meta)\n run_id = resume_from_run_id or create_run_id(meta)\n task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)\n task.event(\"async_launched\", runId=run_id, taskId=task.task_id) # すぐ return\n ... # 残りはバックグラウンドで進む\n```\n\n## Workflow metadata: 起動前に検証する\n\n各 workflow は `name`、`description`、任意の `phases` を持つ metadata object を登録します。runtime は workflow code を実行する前に検証します。`name` と `description` は task と UI の表示に使い、`phases` は progress bar の group 名を定義します。\n\n不正な入力はすぐ `WorkflowInputError` になり、登録時に止まります。s14 の cron 式検証と同じ考えです。不正な script が実行時まで進んでから壊れないようにします。\n\nruntime は `meta.name` をローカル artifact のファイル名に使うため、英数字で始まり、英数字、`.`、`_`、`-` のみからなる 1-64 文字の安全な slug も要求する。\n\n```python\ndef validate_meta(meta):\n if not isinstance(meta, dict):\n raise WorkflowInputError(\"meta は object literal でなければなりません\")\n if not meta.get(\"name\") or not meta.get(\"description\"):\n raise WorkflowInputError(\"meta には name と description が必要です\")\n if not isinstance(meta[\"name\"], str) or not WORKFLOW_NAME_RE.fullmatch(meta[\"name\"]):\n raise WorkflowInputError(\"meta.name は安全な 1-64 文字の slug が必要です\")\n if \"phases\" in meta and (\n not isinstance(meta[\"phases\"], list)\n or not all(isinstance(p, str) and p for p in meta[\"phases\"])\n ):\n raise WorkflowInputError(\"meta.phases は空でない文字列だけを含む必要があります\")\n return meta\n```\n\n## Orchestration primitive: この少数だけで、すべての flow を書ける\n\nscript は独立した context で動き、global variable として使えるのは少数の orchestration primitive だけです。script 自身はファイルを直接読み書きせず、shell も実行しません。実際のコード操作は、派遣された subagent が自分の tool permission で行います。primitive はすべて `ExecutionState` の method です。\n\n| Primitive | 役割 |\n|------|------|\n| `agent(prompt, {schema, label, phase})` | 1 つの subagent を派遣 |\n| `parallel(thunks)` | **barrier**: すべての task を並行実行し、全結果が戻るまで待つ |\n| `pipeline(items, *stages)` | 各 item を **barrier なし**で stage ごとに実行し、終わった item から先へ進める |\n| `phase(title)` | 現在の progress phase を記録し、progress bar を更新 |\n| `log(message)` | progress log を 1 行出力 |\n| `workflow(name, args)` | nested sub-workflow(1 階層だけ) |\n\n既定では `pipeline` を使うべきです。各 item がすべての stage を独立して通り、item A が stage 3 にいる間、item B はまだ stage 1 かもしれません。次の stage へ進むために前 stage の全結果が本当に必要なときだけ、`parallel` barrier を使います。barrier は最も遅い task を待つため、不要なら置かないでください。\n\n```python\nasync def pipeline(self, items, *stages):\n async def run_item(item, idx):\n value = item\n for stage in stages: # 各 item がすべての stage を独立して完走\n value = await stage(value, item, idx)\n return value\n return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])\n```\n\n## 構造化出力: Subagent に散文を返させない\n\n`agent({schema})` は、schema に一致する JSON object を subagent に要求します。内部では structured output call を 1 回使い、runtime が結果を schema で検証し、不一致なら 1 回 retry します。下流コードが受け取るのは規則的な object であり、再 parse が必要な長文ではありません。\n\ns05 では tool argument を全面的に信頼できないと説明しました。ここでは同じ教訓を逆向きに使います。subagent の出力も全面的には信頼できません。orchestration boundary で検証し、1 回 retry の機会を与え、不確実性を後続 flow の外へ止めます。\n\n```python\nresult = self.runner.run(prompt, schema, label)\nif schema is not None:\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok: # 1 回だけ注意して retry、それでも不正なら error\n result = self.runner.run(prompt + \"\\n\\n有効な JSON を返してください。\", schema, label)\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok:\n raise WorkflowInputError(f\"agent({{schema}}) の出力が不正です: {err}\")\n```\n\n## Background task と progress event\n\n`LocalWorkflowTask` は status と token usage を管理し、SDK style の event stream を外へ出します。`task_started` → phase change、subagent start、log batch を含む一連の `task_progress` → 完了、失敗、停止に加え、output file、token 数、tool call 数、所要時間を含む最後の `task_notification` です。\n\nmain session は通常 event として処理し、最後の完了通知だけが main loop へ再び入ります。\n\n```python\nclass LocalWorkflowTask:\n def progress_event(self, ptype, **data): # phase/subagent/log\n self.progress.append({\"type\": ptype, **data})\n print(f\" progress {ptype} ...\")\n```\n\n## 保存: Snapshot + journal で中断から再開する\n\nruntime は各 run を `s20_workflow_runtime/.runtime/` に保存します。`.json` snapshot、`.output.json` output、`.journal.jsonl` journal です。snapshot と journal は安定した `runId` を共有し、resume 時に同じ run の状態と完了済み step を特定できるようにします。\n\njournal は checkpoint resume の中心で、各 `agent()` の結果を 1 行ずつ記録します。\n\n```python\nclass WorkflowJournal:\n def record(self, key, value):\n self._f.write(json.dumps({\"key\": key, \"value\": value}) + \"\\n\")\n self._f.flush()\n self.cache[key] = value\n```\n\n## Resume: runId から続行し、変更のないものを再利用する\n\n`resume_from_run_id` を渡して workflow を再度呼ぶと script を再実行しますが、各 `agent()` は決定的な semantic key を計算します。journal に key があれば、再実行せず cached result を返します。変更された call と、それに依存する後続 step だけが本当に動きます。\n\nkey は concurrency の完了順に依存してはいけません。`parallel` と `pipeline` の Agent は不定の順番で完了します。「何番目に完了したか」を key にすると、次回の cache が別の call へ対応してしまいます。そのため key は競合する counter ではなく、call の内容、つまり type、label、prompt、schema の stable hash です。\n\n```python\ndef key(self, kind, label, prompt, schema):\n basis = f\"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}\"\n return f\"{kind}-{_stable_hash(basis) % 10**10:010d}\"\n\n# agent() の内部:\ncached = self.journal.cached(key)\nif cached is not MISS:\n self.task.progress_event(\"workflow_agent\", label=label, status=\"cached\")\n return cached\n```\n\n## 決定性: Resume に意味を持たせる再現性\n\nresume が動くには、workflow が再現可能でなければなりません。stable hash と決定的な runner は、同じ workflow + 同じ argument から同じ key を作ります。そのため workflow code は、制御されていない clock、randomness、filesystem state など、run ごとに key を変える入力を避けます。\n\n## 実際に動かす\n\nsample workflow `review-changes` は `pipeline` を使い、各 review dimension を独立して audit → verify へ通します。audit では schema 付き `agent()` が問題を探し、verify では `parallel()` が各 finding に別の adversarial verification subagent を送ります。実在すると確認された問題だけを残し、severity 順に並べます。\n\n```python\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n\n async def audit(_v, dimension, _i):\n out = await ctx.agent(f\"変更されたコードに {dimension} 関連の問題がないか確認してください\",\n schema=FINDINGS_SCHEMA, label=f\"audit:{dimension}\", phase=\"Review\")\n return {\"dimension\": dimension, \"findings\": out[\"findings\"]}\n\n async def verify(audited, dimension, _i):\n ctx.phase(\"Verify\")\n verdicts = await ctx.parallel([ # 各 finding を独立して verify\n (lambda f=f: ctx.agent(f\"この問題が実在するか adversarial に検証してください: {f['title']}\",\n schema=VERDICT_SCHEMA, label=f\"verify:{dimension}:{f['title']}\"))\n for f in audited[\"findings\"]])\n return {\"dimension\": dimension,\n \"confirmed\": [f for f, v in zip(audited[\"findings\"], verdicts) if v and v[\"isReal\"]]}\n\n results = await ctx.pipeline(DIMENSIONS, audit, verify)\n ...\n```\n\n## s19 からの変更点\n\n| | s19 Comprehensive Agent | s20 Workflow Runtime |\n|--|-----------|---------------------|\n| loop | 1 つ、モデル駆動 | main loop は不変。その上に決定的 orchestration を追加 |\n| 次の step を決めるもの | モデルが毎ラウンド判断 | script が orchestration flow を事前に定義 |\n| multi-agent | s06 subagent を一度だけ派遣 | script 化された、再現可能で復元可能な一括 orchestration |\n| 新しい仕組み | — | script DSL、background task、progress event、journal/resume、structured output、deterministic VM |\n\ns20 は main loop を置き換えません。tool layer に `Workflow` を公開し、背後で local workflow runtime を起動します。1 つの workflow が N 個の Agent loop を決定的に駆動します。s06 の subagent はモデルがその場で 1 回派遣し、s20 は orchestration を replay 可能な script にします。\n\n## 試してみる\n\n```bash\npython s20_workflow_runtime/code.py # review-changes を起動し、event stream を確認\npython s20_workflow_runtime/code.py resume # 前回の runId から resume。すべての agent() が journal cache に当たる\n```\n\n1 回の起動から `async_launched`、background の phase change と subagent progress、最後の `task_notification` までを観察してください。結果は task object に保存されます。resume 時はすべて cache hit するため `agents=0 tokens=0` と表示され、結果は前回と 1 byte も違いません。\n\n## 次へ\n\norchestration は Agent 能力の上にもう 1 層を加えます。main loop は個々の操作を管理し、script はチーム全体の flow を管理します。仕事が決定的で復元可能な script になると、モデルは「ラウンドごとの driver」から「script に schedule される実行 unit」へ変わります。同じ `agent()` を main loop でモデルがその場で呼ぶことも、workflow 内で script がまとめて編成することもできます。\n\n次へ: [s21 Goal Loop](/ja/s21) — Orchestration は仕事を fan-out し、main loop から離れます。次章は逆に、1 つの goal が control を main loop へ引き戻し、objective が達成されるまで turn の終了を認めません。\n\n\n" + "content": "# s20: Workflow Runtime — モデルが単一 step を決め、script が orchestration を決める\n\ns01 → ... → s18 → s19 → `s20` → [s21](/ja/s21)\n\n> *「1 回の tool_use で、バックグラウンドに一式の orchestration を走らせる」* — `Workflow` ツールが決定的で復元可能な script runtime を起動し、多数の subagent をまとめて送り出します。\n>\n> **Harness 層**: Orchestration — single-agent loop の上に、決定的な multi-agent script runtime を追加します。\n\n`code.py` は demo を決定的に保つため、`async_launched` を出した後、同じ process で完了を待ちます。常駐 background service を用意しなくても、lifecycle と journal を確認できます。\n\n---\n\ns01 から s19 まで、loop は常にモデル駆動で 1 step ずつ進みました。各ラウンドでモデルが 1 つのツールを選び、結果を `messages[]` へ入れ、次のラウンドへ進みます。open-ended なタスクには最適です。次に何をするかを、モデルが context を見てその場で決められます。\n\nしかし、複数の Agent を決定的に指揮したい仕事もあります。大きな変更の review を考えてください。10 の観点から並行して問題を探す → 各 finding へ別 Agent を送り adversarial verification を行う → 結果を集約して重複を除く → severity 順に並べる。この流れの形は固定されており、本当に必要なのは 3 つです。\n\n- **並行性**: 1 件ずつ順番に待たないこと。\n- **決定性**: 同じ入力から同じ結果構造が得られること。\n- **復元可能性**: 途中で止まっても、完了済みの部分を最初からやり直さないこと。\n\nこの流れをモデルに main loop で 1 ラウンドずつ動かさせると、遅く、結果は不確定で、中断すれば最初からです。ここで必要なのは「もう 1 turn 話す」ことではなく、orchestration をそのままコードにすることです。\n\n## 計画は chat のラウンドを重ねず、コードに書く\n\nharness の tool pool に `Workflow` ツールを追加します。ユーザーまたはモデルが渡す script は、`agent() / parallel() / pipeline() / phase()` という少数の primitive を使い、orchestration を決定的なコードとして表します。\n\nmain loop から見えるのは 1 回の `tool_use` だけで、すぐ「バックグラウンドで起動済み」という結果を受け取ります。本当の実行は background runtime で進み、進捗をリアルタイムに報告し、全過程をディスク上の journal へ記録します。script の中間結果は変数に保存され、会話履歴の場所を取りません。`resume_from_run_id` で再開すると、変更されていない `agent()` は journal cache に当たり、以前の結果を直接使って checkpoint から続行します。\n\n![Workflow Runtime Overview](/course-assets/s20_workflow_runtime/workflow-runtime-overview.svg)\n\n```python\nSAMPLE_META = {\"name\": \"review-changes\", \"description\": \"コード変更を review\", \"phases\": [\"Review\", \"Verify\"]}\n\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n results = await ctx.pipeline(DIMENSIONS, audit, verify) # 各 dimension が独立して audit → verify を通る\n confirmed = [f for r in results if r for f in r[\"confirmed\"]]\n ctx.log(f\"{len(confirmed)} 件の実在する問題を確認\")\n return {\"confirmed\": confirmed}\n```\n\n## Workflow ツール: バックグラウンド起動、main loop には 1 回の call だけ\n\n`Workflow` は main Agent の tool pool にあります。ユーザーが保存済み workflow の実行を求めるか、タスクが既知の orchestration に一致したときにモデルがこのツールを選びます。どちらも 1 回の `Workflow(...)` tool call になります。\n\nツールは argument を parse し、meta 情報を検証し、permission check を通し、local workflow task を登録すると、すぐ「非同期で起動済み」と返します。main loop は block せず別の仕事を続け、workflow は background で実行されます。これは s13 の引換券 pattern を拡大したものです。先に引換券を渡し、結果ができたら通知します。\n\n```python\nclass WorkflowTool:\n async def call(self, meta, script_fn, args=None, resume_from_run_id=None):\n validate_meta(meta)\n check_permission(meta)\n run_id = resume_from_run_id or create_run_id(meta)\n task = LocalWorkflowTask(create_task_id(run_id), run_id, meta)\n task.event(\"async_launched\", runId=run_id, taskId=task.task_id) # すぐ return\n ... # 残りはバックグラウンドで進む\n```\n\n## Workflow metadata: 起動前に検証する\n\n各 workflow は `name`、`description`、任意の `phases` を持つ metadata object を登録します。runtime は workflow code を実行する前に検証します。`name` と `description` は task と UI の表示に使い、`phases` は progress bar の group 名を定義します。\n\n不正な入力はすぐ `WorkflowInputError` になり、登録時に止まります。s14 の cron 式検証と同じ考えです。不正な script が実行時まで進んでから壊れないようにします。\n\nruntime は `meta.name` をローカル artifact のファイル名に使うため、英数字で始まり、英数字、`.`、`_`、`-` のみからなる 1-64 文字の安全な slug も要求する。\n\n```python\ndef validate_meta(meta):\n if not isinstance(meta, dict):\n raise WorkflowInputError(\"meta は object literal でなければなりません\")\n if not meta.get(\"name\") or not meta.get(\"description\"):\n raise WorkflowInputError(\"meta には name と description が必要です\")\n if not isinstance(meta[\"name\"], str) or not WORKFLOW_NAME_RE.fullmatch(meta[\"name\"]):\n raise WorkflowInputError(\"meta.name は安全な 1-64 文字の slug が必要です\")\n if \"phases\" in meta and (\n not isinstance(meta[\"phases\"], list)\n or not all(isinstance(p, str) and p for p in meta[\"phases\"])\n ):\n raise WorkflowInputError(\"meta.phases は空でない文字列だけを含む必要があります\")\n return meta\n```\n\n## Orchestration primitive: この少数だけで、すべての flow を書ける\n\nscript は独立した context で動き、global variable として使えるのは少数の orchestration primitive だけです。script 自身はファイルを直接読み書きせず、shell も実行しません。実際のコード操作は、派遣された subagent が自分の tool permission で行います。primitive はすべて `ExecutionState` の method です。\n\n| Primitive | 役割 |\n|------|------|\n| `agent(prompt, {schema, label, phase})` | 1 つの subagent を派遣 |\n| `parallel(thunks)` | **barrier**: すべての task を並行実行し、全結果が戻るまで待つ |\n| `pipeline(items, *stages)` | 各 item を **barrier なし**で stage ごとに実行し、終わった item から先へ進める |\n| `phase(title)` | 現在の progress phase を記録し、progress bar を更新 |\n| `log(message)` | progress log を 1 行出力 |\n| `workflow(name, args)` | nested sub-workflow(1 階層だけ) |\n\n既定では `pipeline` を使うべきです。各 item がすべての stage を独立して通り、item A が stage 3 にいる間、item B はまだ stage 1 かもしれません。次の stage へ進むために前 stage の全結果が本当に必要なときだけ、`parallel` barrier を使います。barrier は最も遅い task を待つため、不要なら置かないでください。\n\n```python\nasync def pipeline(self, items, *stages):\n async def run_item(item, idx):\n value = item\n for stage in stages: # 各 item がすべての stage を独立して完走\n value = await stage(value, item, idx)\n return value\n return await asyncio.gather(*[run_item(it, i) for i, it in enumerate(items)])\n```\n\n## 構造化出力: Subagent に散文を返させない\n\n`agent({schema})` は、schema に一致する JSON object を subagent に要求します。内部では structured output call を 1 回使い、runtime が結果を schema で検証し、不一致なら 1 回 retry します。下流コードが受け取るのは規則的な object であり、再 parse が必要な長文ではありません。\n\ns05 では tool argument を全面的に信頼できないと説明しました。ここでは同じ教訓を逆向きに使います。subagent の出力も全面的には信頼できません。orchestration boundary で検証し、1 回 retry の機会を与え、不確実性を後続 flow の外へ止めます。\n\n```python\nresult = self.runner.run(prompt, schema, label)\nif schema is not None:\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok: # 1 回だけ注意して retry、それでも不正なら error\n result = self.runner.run(prompt + \"\\n\\n有効な JSON を返してください。\", schema, label)\n ok, err = SimpleJsonSchema(schema).validate(result)\n if not ok:\n raise WorkflowInputError(f\"agent({{schema}}) の出力が不正です: {err}\")\n```\n\n## Background task と progress event\n\n`LocalWorkflowTask` は status と token usage を管理し、SDK style の event stream を外へ出します。`task_started` → phase change、subagent start、log batch を含む一連の `task_progress` → 完了、失敗、停止に加え、output file、token 数、tool call 数、所要時間を含む最後の `task_notification` です。\n\nmain session は通常 event として処理し、最後の完了通知だけが main loop へ再び入ります。\n\n```python\nclass LocalWorkflowTask:\n def progress_event(self, ptype, **data): # phase/subagent/log\n self.progress.append({\"type\": ptype, **data})\n print(f\" progress {ptype} ...\")\n```\n\n## 保存: Snapshot + journal で中断から再開する\n\nruntime は各 run を `s20_workflow_runtime/.runtime/` に保存します。`.json` snapshot、`.output.json` output、`.journal.jsonl` journal です。snapshot と journal は安定した `runId` を共有し、resume 時に同じ run の状態と完了済み step を特定できるようにします。\n\njournal は checkpoint resume の中心で、各 `agent()` の結果を 1 行ずつ記録します。\n\n```python\nclass WorkflowJournal:\n def record(self, key, value):\n self._f.write(json.dumps({\"key\": key, \"value\": value}) + \"\\n\")\n self._f.flush()\n self.cache[key] = value\n```\n\n## Resume: runId から続行し、変更のないものを再利用する\n\n`resume_from_run_id` を渡して workflow を再度呼ぶと script を再実行しますが、各 `agent()` は決定的な semantic key を計算します。journal に key があれば、再実行せず cached result を返します。変更された call と、それに依存する後続 step だけが本当に動きます。\n\nkey は concurrency の完了順に依存してはいけません。`parallel` と `pipeline` の Agent は不定の順番で完了します。「何番目に完了したか」を key にすると、次回の cache が別の call へ対応してしまいます。そのため key は競合する counter ではなく、call の内容、つまり type、label、prompt、schema の stable hash です。\n\n```python\ndef key(self, kind, label, prompt, schema):\n basis = f\"{kind}|{label}|{prompt}|{json.dumps(schema, sort_keys=True)}\"\n return f\"{kind}-{_stable_hash(basis) % 10**10:010d}\"\n\n# agent() の内部:\ncached = self.journal.cached(key)\nif cached is not MISS:\n self.task.progress_event(\"workflow_agent\", label=label, status=\"cached\")\n return cached\n```\n\n## 決定性: Resume に意味を持たせる再現性\n\nresume が動くには、workflow が再現可能でなければなりません。stable hash と決定的な runner は、同じ workflow + 同じ argument から同じ key を作ります。そのため workflow code は、制御されていない clock、randomness、filesystem state など、run ごとに key を変える入力を避けます。\n\n## 実際に動かす\n\nsample workflow `review-changes` は `pipeline` を使い、各 review dimension を独立して audit → verify へ通します。audit では schema 付き `agent()` が問題を探し、verify では `parallel()` が各 finding に別の adversarial verification subagent を送ります。実在すると確認された問題だけを残し、severity 順に並べます。\n\n```python\nasync def sample_workflow(ctx, args):\n ctx.phase(\"Review\")\n\n async def audit(_v, dimension, _i):\n out = await ctx.agent(f\"変更されたコードに {dimension} 関連の問題がないか確認してください\",\n schema=FINDINGS_SCHEMA, label=f\"audit:{dimension}\", phase=\"Review\")\n return {\"dimension\": dimension, \"findings\": out[\"findings\"]}\n\n async def verify(audited, dimension, _i):\n ctx.phase(\"Verify\")\n verdicts = await ctx.parallel([ # 各 finding を独立して verify\n (lambda f=f: ctx.agent(f\"この問題が実在するか adversarial に検証してください: {f['title']}\",\n schema=VERDICT_SCHEMA, label=f\"verify:{dimension}:{f['title']}\"))\n for f in audited[\"findings\"]])\n return {\"dimension\": dimension,\n \"confirmed\": [f for f, v in zip(audited[\"findings\"], verdicts) if v and v[\"isReal\"]]}\n\n results = await ctx.pipeline(DIMENSIONS, audit, verify)\n ...\n```\n\n## s19 からの変更点\n\n| | s19 Integrated Harness | s20 Workflow Runtime |\n|--|-----------|---------------------|\n| loop | 1 つ、モデル駆動 | main loop は不変。その上に決定的 orchestration を追加 |\n| 次の step を決めるもの | モデルが毎ラウンド判断 | script が orchestration flow を事前に定義 |\n| multi-agent | s06 subagent を一度だけ派遣 | script 化された、再現可能で復元可能な一括 orchestration |\n| 新しい仕組み | — | script DSL、background task、progress event、journal/resume、structured output、deterministic VM |\n\ns20 は main loop を置き換えません。tool layer に `Workflow` を公開し、背後で local workflow runtime を起動します。1 つの workflow が N 個の Agent loop を決定的に駆動します。s06 の subagent はモデルがその場で 1 回派遣し、s20 は orchestration を replay 可能な script にします。\n\n## 試してみる\n\n```bash\npython s20_workflow_runtime/code.py # review-changes を起動し、event stream を確認\npython s20_workflow_runtime/code.py resume # 前回の runId から resume。すべての agent() が journal cache に当たる\n```\n\n1 回の起動から `async_launched`、background の phase change と subagent progress、最後の `task_notification` までを観察してください。結果は task object に保存されます。resume 時はすべて cache hit するため `agents=0 tokens=0` と表示され、結果は前回と 1 byte も違いません。\n\n## 次へ\n\norchestration は Agent 能力の上にもう 1 層を加えます。main loop は個々の操作を管理し、script はチーム全体の flow を管理します。仕事が決定的で復元可能な script になると、モデルは「ラウンドごとの driver」から「script に schedule される実行 unit」へ変わります。同じ `agent()` を main loop でモデルがその場で呼ぶことも、workflow 内で script がまとめて編成することもできます。\n\n次へ: [s21 Goal Loop](/ja/s21) — Orchestration は仕事を fan-out し、main loop から離れます。次章は逆に、1 つの goal が control を main loop へ引き戻し、objective が達成されるまで turn の終了を認めません。\n\n\n" }, { "version": "s21", diff --git a/web/src/data/generated/versions.json b/web/src/data/generated/versions.json index a79cf529..4c103e58 100644 --- a/web/src/data/generated/versions.json +++ b/web/src/data/generated/versions.json @@ -3076,8 +3076,8 @@ { "id": "s19", "filename": "s19_comprehensive/code.py", - "title": "Comprehensive Agent", - "subtitle": "All Mechanisms, One Loop", + "title": "Integrated Harness", + "subtitle": "Many Mechanisms, One Loop", "loc": 1858, "tools": [ "bash", @@ -3124,7 +3124,7 @@ "cancel_cron" ], "coreAddition": "Integrated harness", - "keyInsight": "The complete harness is still one loop, surrounded by the systems introduced across the course.", + "keyInsight": "The integrated harness is still one loop, surrounded by the systems introduced across the course.", "classes": [ { "name": "Task", @@ -3685,7 +3685,7 @@ } ], "layer": "collaboration", - "source": "#!/usr/bin/env python3\n\"\"\"\ns19: Comprehensive Agent — all course mechanisms in one loop.\n\nRun: python s19_comprehensive/code.py\nNeed: pip install anthropic python-dotenv pyyaml + .env with ANTHROPIC_API_KEY\n\nThis final chapter puts the earlier mechanisms back together: dispatch,\npermission, hooks, todo, subagent, skills, compaction,\nmemory, prompt assembly, error recovery, task graph, background tasks, cron,\nteams, protocols, autonomous agents, worktrees, and MCP.\n\"\"\"\n\nimport ast, json, os, subprocess, time, random, threading, re\nfrom pathlib import Path\nfrom datetime import datetime\nfrom dataclasses import dataclass, asdict, field\nimport yaml\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\n READLINE_AVAILABLE = True\nexcept ImportError:\n READLINE_AVAILABLE = False\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\nPRIMARY_MODEL = MODEL\nFALLBACK_MODEL = os.getenv(\"FALLBACK_MODEL_ID\")\n\nSKILLS_DIR = WORKDIR / \"skills\"\nTRANSCRIPT_DIR = WORKDIR / \".transcripts\"\nTOOL_RESULTS_DIR = WORKDIR / \".task_outputs\" / \"tool-results\"\n\nDEFAULT_MAX_TOKENS = 8000\nESCALATED_MAX_TOKENS = 16000\nMAX_RETRIES = 3\nMAX_CONSECUTIVE_529 = 2\nMAX_RECOVERY_RETRIES = 2\nBASE_DELAY_MS = 500\nCONTEXT_LIMIT = 50000\nKEEP_RECENT_TOOL_RESULTS = 3\nPERSIST_THRESHOLD = 30000\nCONTINUATION_PROMPT = \"Continue from the previous response. Do not repeat completed work.\"\nPROMPT = \"\\033[36ms19 >> \\033[0m\"\nCLI_ACTIVE = False\n\n\ndef terminal_print(text: str):\n if threading.current_thread() is threading.main_thread() or not CLI_ACTIVE:\n print(text)\n return\n line = \"\"\n if READLINE_AVAILABLE:\n try:\n line = readline.get_line_buffer()\n except Exception:\n line = \"\"\n print(f\"\\r\\033[K{text}\")\n print(PROMPT + line, end=\"\", flush=True)\n\n# ── Task System ──\n\n# Tasks are tiny durable records. Later systems add ownership, dependencies,\n# worktrees, and teammates on top of this same file-backed state.\nTASKS_DIR = WORKDIR / \".tasks\"\nTASKS_DIR.mkdir(exist_ok=True)\ntask_lock = threading.RLock()\nCURRENT_TODOS: list[dict] = []\n\n\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str\n owner: str | None\n blockedBy: list[str]\n worktree: str | None = None\n\n\ndef _task_path(task_id: str) -> Path:\n return TASKS_DIR / f\"{task_id}.json\"\n\n\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random.randint(0, 9999):04d}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n\n\ndef save_task(task: Task):\n _task_path(task.id).write_text(json.dumps(asdict(task), indent=2))\n\n\ndef load_task(task_id: str) -> Task:\n return Task(**json.loads(_task_path(task_id).read_text()))\n\n\ndef list_tasks() -> list[Task]:\n return [Task(**json.loads(p.read_text()))\n for p in sorted(TASKS_DIR.glob(\"task_*.json\"))]\n\n\ndef get_task_json(task_id: str) -> str:\n return json.dumps(asdict(load_task(task_id)), indent=2)\n\n\ndef can_start(task_id: str) -> bool:\n # Dependencies are intentionally simple: every blocker must exist and be\n # completed before the task can be claimed.\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False\n if load_task(dep_id).status != \"completed\":\n return False\n return True\n\n\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n with task_lock:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if task.owner:\n return f\"Task {task_id} already owned by {task.owner}\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if (_task_path(d).exists()\n and load_task(d).status != \"completed\")]\n missing = [d for d in task.blockedBy\n if not _task_path(d).exists()]\n parts = []\n if deps:\n parts.append(f\"blocked by: {deps}\")\n if missing:\n parts.append(f\"missing deps: {missing}\")\n return \"Cannot start: \" + \", \".join(parts)\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n print(f\" \\033[36m[claim] {task.subject} → in_progress\\033[0m\")\n return f\"Claimed {task.id} ({task.subject})\"\n\n\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n if task.status != \"in_progress\":\n return f\"Task {task_id} is {task.status}, cannot complete\"\n task.status = \"completed\"\n save_task(task)\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy and can_start(t.id)]\n print(f\" \\033[32m[complete] {task.subject} ✓\\033[0m\")\n msg = f\"Completed {task.id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n return msg\n\n\n# ── Worktree System ──\n\n# Worktree names become filesystem paths, so validation stays strict and is\n# reused for create/remove/keep.\nWORKTREES_DIR = WORKDIR / \".worktrees\"\nWORKTREES_DIR.mkdir(exist_ok=True)\n\nVALID_WT_NAME = re.compile(r'^[A-Za-z0-9._-]{1,64}$')\n\n\ndef validate_worktree_name(name: str) -> str | None:\n if not name:\n return \"Worktree name cannot be empty\"\n if name in (\".\", \"..\"):\n return f\"'{name}' is not a valid worktree name\"\n if not VALID_WT_NAME.match(name):\n return (f\"Invalid worktree name '{name}': \"\n \"only letters, digits, dots, underscores, dashes (1-64 chars)\")\n return None\n\n\ndef run_git(args: list[str]) -> tuple[bool, str]:\n try:\n r = subprocess.run([\"git\"] + args, cwd=WORKDIR,\n capture_output=True, text=True, timeout=30)\n out = (r.stdout + r.stderr).strip()\n return r.returncode == 0, out[:5000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return False, \"Error: git timeout\"\n\n\ndef log_event(event_type: str, worktree_name: str, task_id: str = \"\"):\n event = {\"type\": event_type, \"worktree\": worktree_name,\n \"task_id\": task_id, \"ts\": time.time()}\n events_file = WORKTREES_DIR / \"events.jsonl\"\n with open(events_file, \"a\") as f:\n f.write(json.dumps(event) + \"\\n\")\n\n\ndef create_worktree(name: str, task_id: str = \"\") -> str:\n # Tool-layer validation is part of the safety boundary; do it before git\n # sees the name, not only after git happens to reject something.\n err = validate_worktree_name(name)\n if err:\n return f\"Error: {err}\"\n if task_id:\n try:\n load_task(task_id)\n except FileNotFoundError:\n return f\"Error: task {task_id} not found\"\n path = WORKTREES_DIR / name\n if path.exists():\n return f\"Worktree '{name}' already exists at {path}\"\n ok, result = run_git([\"worktree\", \"add\", str(path), \"-b\", f\"wt/{name}\", \"HEAD\"])\n if not ok:\n return f\"Git error: {result}\"\n if task_id:\n bind_task_to_worktree(task_id, name)\n log_event(\"create\", name, task_id)\n print(f\" \\033[33m[worktree] created: {name} at {path}\\033[0m\")\n return f\"Worktree '{name}' created at {path}\"\n\n\ndef bind_task_to_worktree(task_id: str, worktree_name: str):\n task = load_task(task_id)\n task.worktree = worktree_name\n save_task(task)\n\n\ndef _count_worktree_changes(path: Path) -> tuple[int, int]:\n try:\n r1 = subprocess.run([\"git\", \"status\", \"--porcelain\"],\n cwd=path, capture_output=True, text=True, timeout=10)\n files = len([l for l in r1.stdout.strip().splitlines() if l.strip()])\n r2 = subprocess.run([\"git\", \"log\", \"@{push}..HEAD\", \"--oneline\"],\n cwd=path, capture_output=True, text=True, timeout=10)\n commits = len([l for l in r2.stdout.strip().splitlines() if l.strip()])\n return files, commits\n except Exception:\n return -1, -1\n\n\ndef remove_worktree(name: str, discard_changes: bool = False) -> str:\n err = validate_worktree_name(name)\n if err:\n return err\n path = WORKTREES_DIR / name\n if not path.exists():\n return f\"Worktree '{name}' not found\"\n if not discard_changes:\n files, commits = _count_worktree_changes(path)\n if files < 0:\n return \"Cannot verify status. Use discard_changes=true to force.\"\n if files > 0 or commits > 0:\n return (f\"Worktree '{name}' has {files} file(s), {commits} commit(s). \"\n \"Use discard_changes=true or keep_worktree.\")\n ok1, _ = run_git([\"worktree\", \"remove\", str(path), \"--force\"])\n if not ok1:\n return f\"Failed to remove worktree '{name}'\"\n run_git([\"branch\", \"-D\", f\"wt/{name}\"])\n log_event(\"remove\", name)\n print(f\" \\033[33m[worktree] removed: {name}\\033[0m\")\n return f\"Worktree '{name}' removed\"\n\n\ndef keep_worktree(name: str) -> str:\n err = validate_worktree_name(name)\n if err:\n return err\n log_event(\"keep\", name)\n return f\"Worktree '{name}' kept for review (branch: wt/{name})\"\n\n\n# ── Skill Loading ──\n\nSKILL_REGISTRY: dict[str, dict] = {}\n\n\ndef _parse_frontmatter(text: str) -> tuple[dict, str]:\n if not text.startswith(\"---\"):\n return {}, text\n parts = text.split(\"---\", 2)\n if len(parts) < 3:\n return {}, text\n try:\n meta = yaml.safe_load(parts[1]) or {}\n except yaml.YAMLError:\n meta = {}\n return meta, parts[2].strip()\n\n\ndef scan_skills():\n SKILL_REGISTRY.clear()\n if not SKILLS_DIR.exists():\n return\n for directory in sorted(SKILLS_DIR.iterdir()):\n if not directory.is_dir():\n continue\n manifest = directory / \"SKILL.md\"\n if not manifest.exists():\n continue\n raw = manifest.read_text()\n meta, _ = _parse_frontmatter(raw)\n name = meta.get(\"name\", directory.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\n \"name\": name,\n \"description\": desc,\n \"content\": raw,\n }\n\n\nscan_skills()\n\n\ndef list_skills() -> str:\n if not SKILL_REGISTRY:\n return \"(no skills found)\"\n return \"\\n\".join(\n f\"- {skill['name']}: {skill['description']}\"\n for skill in SKILL_REGISTRY.values())\n\n\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n available = \", \".join(SKILL_REGISTRY.keys()) or \"(none)\"\n return f\"Skill not found: {name}. Available: {available}\"\n return skill[\"content\"]\n\n\n# ── Prompt Assembly ──\n\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file, edit_file, glob, \"\n \"todo_write, task, load_skill, compact, \"\n \"create_task, list_tasks, get_task, claim_task, complete_task, \"\n \"schedule_cron, list_crons, cancel_cron, \"\n \"spawn_teammate, send_message, \"\n \"request_shutdown, request_plan, review_plan, \"\n \"create_worktree, remove_worktree, keep_worktree, \"\n \"connect_mcp. MCP tools are prefixed mcp__{server}__{tool}.\",\n \"teams\": (\n \"When parallel work would help, first propose a small team with clear \"\n \"responsibilities and wait for the user's confirmation. Do not call \"\n \"spawn_teammate before the user confirms.\"\n ),\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n\n\ndef assemble_system_prompt(context: dict) -> str:\n # The system prompt is rebuilt each turn from live context. This is where\n # memory, skill catalog, MCP state, and active teammates become visible.\n sections = [PROMPT_SECTIONS[\"identity\"],\n PROMPT_SECTIONS[\"tools\"],\n PROMPT_SECTIONS[\"teams\"],\n PROMPT_SECTIONS[\"workspace\"]]\n sections.append(f\"Current time: {datetime.now().isoformat(timespec='seconds')}\")\n sections.append(\"Skills catalog:\\n\" + list_skills() +\n \"\\nUse load_skill(name) when a skill is relevant.\")\n if context.get(\"memories\"):\n sections.append(f\"Relevant memories:\\n{context['memories']}\")\n mcp_names = list(mcp_clients.keys())\n if mcp_names:\n sections.append(f\"Connected MCP servers: {', '.join(mcp_names)}\")\n return \"\\n\\n\".join(sections)\n\n\n# ── Basic Tools ──\n\ndef run_bash(command: str, cwd: Path = None,\n run_in_background: bool = False) -> str:\n # run_in_background is consumed by the dispatcher; direct execution ignores it.\n try:\n r = subprocess.run(command, shell=True, cwd=cwd or WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\n\ndef run_read(path: str, limit: int | None = None,\n offset: int = 0, cwd: Path = None) -> str:\n try:\n base = cwd or WORKDIR\n file_path = (base / path).resolve()\n lines = file_path.read_text().splitlines()\n offset = max(int(offset or 0), 0)\n limit = int(limit) if limit is not None else None\n lines = lines[offset:]\n if limit is not None and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_write(path: str, content: str, cwd: Path = None) -> str:\n try:\n base = cwd or WORKDIR\n fp = (base / path).resolve()\n fp.parent.mkdir(parents=True, exist_ok=True)\n fp.write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_edit(path: str, old_text: str, new_text: str,\n cwd: Path = None) -> str:\n try:\n base = cwd or WORKDIR\n fp = (base / path).resolve()\n text = fp.read_text()\n if old_text not in text:\n return f\"Error: text not found in {path}\"\n fp.write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_glob(pattern: str, cwd: Path = None) -> str:\n import glob as g\n try:\n base = cwd or WORKDIR\n results = []\n for match in g.glob(pattern, root_dir=base):\n if (base / match).resolve().is_relative_to(base):\n results.append(match)\n return \"\\n\".join(results) if results else \"(no matches)\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef call_tool_handler(handler, args: dict, name: str) -> str:\n if not handler:\n return f\"Unknown: {name}\"\n try:\n return handler(**(args or {}))\n except TypeError as e:\n return f\"Error: {e}\"\n\n\ndef _normalize_todos(todos):\n if isinstance(todos, str):\n try:\n todos = json.loads(todos)\n except json.JSONDecodeError:\n try:\n todos = ast.literal_eval(todos)\n except (SyntaxError, ValueError):\n return None, \"Error: todos must be a list or JSON array string\"\n if not isinstance(todos, list):\n return None, \"Error: todos must be a list\"\n for i, todo in enumerate(todos):\n if not isinstance(todo, dict):\n return None, f\"Error: todos[{i}] must be an object\"\n if \"content\" not in todo or \"status\" not in todo:\n return None, f\"Error: todos[{i}] missing 'content' or 'status'\"\n if todo[\"status\"] not in (\"pending\", \"in_progress\", \"completed\"):\n return None, f\"Error: todos[{i}] has invalid status '{todo['status']}'\"\n return todos, None\n\ndef run_todo_write(todos: list) -> str:\n global CURRENT_TODOS\n todos, error = _normalize_todos(todos)\n if error:\n return error\n CURRENT_TODOS = todos\n print(f\" \\033[33m[todo] updated {len(CURRENT_TODOS)} item(s)\\033[0m\")\n return f\"Updated {len(CURRENT_TODOS)} todos\"\n\n\n# ── MessageBus (from s15) ──\n\nMAILBOX_DIR = WORKDIR / \".mailboxes\"\nMAILBOX_DIR.mkdir(exist_ok=True)\nMAILBOX_ROOT = MAILBOX_DIR.resolve()\nVALID_AGENT_NAME = re.compile(r\"^[A-Za-z0-9_-]{1,64}$\")\n\n\ndef is_valid_agent_name(name: str) -> bool:\n return bool(VALID_AGENT_NAME.fullmatch(name))\n\n\nclass MessageBus:\n def __init__(self):\n self._lock = threading.RLock()\n self._changed = threading.Condition(self._lock)\n\n def _path(self, agent: str) -> Path:\n if not is_valid_agent_name(agent):\n raise ValueError(f\"Invalid mailbox recipient: {agent!r}\")\n path = (MAILBOX_DIR / f\"{agent}.jsonl\").resolve()\n if not path.is_relative_to(MAILBOX_ROOT):\n raise ValueError(f\"Mailbox path escapes directory: {agent!r}\")\n return path\n\n def _read_unlocked(self, agent: str) -> list[dict]:\n inbox = self._path(agent)\n if not inbox.exists():\n return []\n msgs = [json.loads(line) for line in inbox.read_text().splitlines()\n if line.strip()]\n inbox.unlink()\n return msgs\n\n def send(self, from_agent: str, to_agent: str, content: str,\n msg_type: str = \"message\", metadata: dict | None = None):\n msg = {\"from\": from_agent, \"to\": to_agent,\n \"content\": content, \"type\": msg_type,\n \"ts\": time.time(), \"metadata\": metadata or {}}\n with self._changed:\n with open(self._path(to_agent), \"a\") as f:\n f.write(json.dumps(msg, ensure_ascii=False) + \"\\n\")\n self._changed.notify_all()\n print(f\" \\033[33m[bus] {from_agent} → {to_agent}: \"\n f\"({msg_type}) {content[:50]}\\033[0m\")\n\n def read_inbox(self, agent: str) -> list[dict]:\n with self._lock:\n return self._read_unlocked(agent)\n\n def peek(self, agent: str) -> bool:\n with self._lock:\n inbox = self._path(agent)\n return inbox.exists() and inbox.stat().st_size > 0\n\n def wait_for_messages(self, agent: str,\n timeout: float | None = None) -> list[dict]:\n deadline = None if timeout is None else time.monotonic() + timeout\n with self._changed:\n while not self.peek(agent):\n remaining = (None if deadline is None\n else deadline - time.monotonic())\n if remaining is not None and remaining <= 0:\n return []\n self._changed.wait(remaining)\n return self._read_unlocked(agent)\n\n\nBUS = MessageBus()\nactive_teammates: dict[str, str] = {}\nplan_gates: dict[str, str] = {}\nplan_request_ids: dict[str, str] = {}\nteam_lock = threading.RLock()\n\n# ── Protocol State (from s15) ──\n\n@dataclass\nclass ProtocolState:\n request_id: str\n type: str\n sender: str\n target: str\n status: str\n payload: str\n created_at: float = field(default_factory=time.time)\n\n\npending_requests: dict[str, ProtocolState] = {}\n\n\ndef new_request_id() -> str:\n while True:\n request_id = f\"req_{random.randint(0, 999999):06d}\"\n if request_id not in pending_requests:\n return request_id\n\n\ndef match_response(response_type: str, request_id: str, approve: bool,\n from_agent: str, to_agent: str) -> bool:\n with team_lock:\n state = pending_requests.get(request_id)\n if not state:\n print(f\" \\033[31m[protocol] unknown request_id: {request_id}\\033[0m\")\n return False\n expected = {\n \"shutdown\": \"shutdown_response\",\n \"plan_approval\": \"plan_approval_response\",\n }[state.type]\n if response_type != expected:\n print(f\" \\033[31m[protocol] expected {expected}, \"\n f\"got {response_type}\\033[0m\")\n return False\n if from_agent != state.target or to_agent != state.sender:\n print(f\" \\033[31m[protocol] {request_id} responder mismatch\\033[0m\")\n return False\n if state.status != \"pending\":\n return False\n state.status = \"approved\" if approve else \"rejected\"\n icon = \"✓\" if approve else \"✗\"\n color = \"32\" if approve else \"31\"\n print(f\" \\033[{color}m[protocol] {state.type} {icon} \"\n f\"({request_id}: {state.status})\\033[0m\")\n return True\n\n\ndef consume_lead_inbox(route_protocol=True) -> list[dict]:\n msgs = BUS.read_inbox(\"lead\")\n if route_protocol:\n for msg in msgs:\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n msg_type = msg.get(\"type\", \"\")\n if req_id and msg_type.endswith(\"_response\"):\n match_response(msg_type, req_id, meta.get(\"approve\", False),\n msg.get(\"from\", \"\"), msg.get(\"to\", \"\"))\n return msgs\n\n\ndef format_team_events(msgs: list[dict]) -> str:\n lines = []\n for msg in msgs:\n request_id = msg.get(\"metadata\", {}).get(\"request_id\")\n suffix = f\" request_id={request_id}\" if request_id else \"\"\n lines.append(\n f\"[{msg['type']}{suffix}] {msg['from']}: {msg['content']}\"\n )\n return \"[Team events]\\n\" + \"\\n\".join(lines)\n\n\n# ── Autonomous Agent (from s16, + worktree cwd) ──\n\nIDLE_SCAN_INTERVAL = 2.0\n\n\ndef scan_unclaimed_tasks() -> list[Task]:\n return [\n task for task in list_tasks()\n if (task.status == \"pending\"\n and task.owner is None\n and can_start(task.id))\n ]\n\n\ndef claim_next_task(name: str) -> Task | None:\n for task in scan_unclaimed_tasks():\n result = claim_task(task.id, owner=name)\n if result.startswith(\"Claimed \"):\n return load_task(task.id)\n return None\n\n\ndef _last_assistant_text(content) -> str:\n for block in content:\n if getattr(block, \"type\", None) == \"text\":\n return block.text.strip()\n if isinstance(block, dict) and block.get(\"type\") == \"text\":\n return str(block.get(\"text\", \"\")).strip()\n return \"\"\n\n\ndef _run_teammate_tool(name: str, block, handlers: dict) -> str:\n gate = plan_gates.get(name, \"not_required\")\n if (block.name in {\"bash\", \"write_file\"}\n and gate not in {\"not_required\", \"approved\"}):\n return f\"Blocked: plan status is {gate}.\"\n handler = handlers.get(block.name)\n return str(handler(**block.input)) if handler else f\"Unknown tool: {block.name}\"\n\n\ndef apply_plan_response(name: str, msg: dict) -> tuple[bool, str]:\n \"\"\"Apply only the Lead response for this teammate's current plan.\"\"\"\n metadata = msg.get(\"metadata\", {})\n request_id = metadata.get(\"request_id\", \"\")\n with team_lock:\n state = pending_requests.get(request_id)\n expected_id = plan_request_ids.get(name)\n valid = (\n msg.get(\"from\") == \"lead\"\n and msg.get(\"to\") == name\n and request_id == expected_id\n and state is not None\n and state.type == \"plan_approval\"\n and state.sender == name\n and state.target == \"lead\"\n and state.status in {\"approved\", \"rejected\"}\n and metadata.get(\"approve\", False)\n == (state.status == \"approved\")\n )\n if not valid:\n return False, \"[Ignored plan response: request mismatch]\"\n plan_gates[name] = state.status\n active_teammates[name] = \"working\"\n plan_request_ids.pop(name, None)\n outcome = state.status\n return True, f\"[Plan {outcome}] {msg['content']}\"\n\n\ndef apply_shutdown_request(name: str, msg: dict) -> tuple[bool, str]:\n \"\"\"Accept only a pending shutdown request sent by Lead to this teammate.\"\"\"\n request_id = msg.get(\"metadata\", {}).get(\"request_id\", \"\")\n with team_lock:\n state = pending_requests.get(request_id)\n valid = (\n msg.get(\"from\") == \"lead\"\n and msg.get(\"to\") == name\n and state is not None\n and state.type == \"shutdown\"\n and state.sender == \"lead\"\n and state.target == name\n and state.status == \"pending\"\n and active_teammates.get(name) != \"stopping\"\n )\n if not valid:\n return False, \"[Ignored shutdown request: request mismatch]\"\n active_teammates[name] = \"stopping\"\n return True, request_id\n\n\ndef _teammate_send_message(from_name: str, to: str, content: str) -> str:\n with team_lock:\n if to != \"lead\" and to not in active_teammates:\n return f\"Agent '{to}' is not active\"\n BUS.send(from_name, to, content)\n return f\"Sent to {to}\"\n\n\n# ── Teammate Thread ──\n\ndef spawn_teammate_thread(name: str, role: str, prompt: str) -> str:\n if not is_valid_agent_name(name):\n return (\"Invalid teammate name: use 1-64 letters, digits, \"\n \"underscores, or dashes\")\n with team_lock:\n if name in active_teammates:\n return f\"Teammate '{name}' already exists\"\n active_teammates[name] = \"working\"\n plan_gates[name] = \"not_required\"\n\n system = (f\"You are '{name}', a {role}. \"\n \"Use tools to complete tasks. \"\n \"You can list and claim tasks from the board. \"\n \"If a task has a worktree, work in that directory. \"\n \"When asked for a plan, submit it before bash or write_file \"\n \"and wait for approval.\")\n\n def handle_inbox_message(name: str, msg: dict, messages: list):\n msg_type = msg.get(\"type\", \"message\")\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n\n if msg_type == \"shutdown_request\":\n accepted, notice = apply_shutdown_request(name, msg)\n if not accepted:\n messages.append({\"role\": \"user\", \"content\": notice})\n return False\n req_id = notice\n BUS.send(name, \"lead\", \"Shutting down gracefully.\",\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n print(f\" \\033[35m[protocol] {name} approved shutdown \"\n f\"({req_id})\\033[0m\")\n return True\n\n if msg_type == \"plan_approval_response\":\n _, notice = apply_plan_response(name, msg)\n messages.append({\"role\": \"user\",\n \"content\": notice})\n elif msg_type == \"plan_request\":\n messages.append({\"role\": \"user\",\n \"content\": f\"[Plan required] {msg['content']}\"})\n elif msg_type == \"message\":\n messages.append({\"role\": \"user\",\n \"content\": f\"[Message from {msg['from']}] {msg['content']}\"})\n return False\n\n def run():\n # Track current worktree for this teammate's cwd\n wt_ctx = {\"path\": None}\n\n def _wt_cwd() -> Path | None:\n p = wt_ctx[\"path\"]\n return Path(p) if p else None\n\n def _run_bash(command: str) -> str:\n return run_bash(command, cwd=_wt_cwd())\n\n def _run_read(path: str) -> str:\n return run_read(path, cwd=_wt_cwd())\n\n def _run_write(path: str, content: str) -> str:\n return run_write(path, content, cwd=_wt_cwd())\n\n def _run_list_tasks():\n tasks = list_tasks()\n if not tasks:\n return \"No tasks.\"\n return \"\\n\".join(\n f\" {t.id}: {t.subject} [{t.status}]\"\n + (f\" (wt:{t.worktree})\" if t.worktree else \"\")\n for t in tasks)\n\n def _run_claim_task(task_id: str):\n result = claim_task(task_id, owner=name)\n if \"Claimed\" in result:\n # Set worktree cwd if task has one\n task = load_task(task_id)\n if task.worktree:\n wt_ctx[\"path\"] = str(WORKTREES_DIR / task.worktree)\n else:\n wt_ctx[\"path\"] = None\n return result\n\n def _run_complete_task(task_id: str):\n result = complete_task(task_id)\n wt_ctx[\"path\"] = None\n return result\n\n messages = [{\"role\": \"user\", \"content\": prompt}]\n sub_tools = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"send_message\",\n \"description\": \"Send message to another agent.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"submit_plan\",\n \"description\": \"Submit a plan for Lead approval.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"plan\": {\"type\": \"string\"}},\n \"required\": [\"plan\"]}},\n {\"name\": \"list_tasks\",\n \"description\": \"List all tasks on the board.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n {\"name\": \"claim_task\",\n \"description\": \"Claim a pending task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\",\n \"description\": \"Mark an in-progress task as completed.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n ]\n\n sub_handlers = {\n \"bash\": _run_bash, \"read_file\": _run_read,\n \"write_file\": _run_write,\n \"send_message\": lambda to, content: _teammate_send_message(\n name, to, content),\n \"submit_plan\": lambda plan: _teammate_submit_plan(name, plan),\n \"list_tasks\": _run_list_tasks,\n \"claim_task\": _run_claim_task,\n \"complete_task\": _run_complete_task,\n }\n\n # Outer loop: WORK → IDLE cycle\n while True:\n if len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"You are '{name}', role: {role}. \"\n f\"Continue your work.\"})\n\n # WORK phase\n should_shutdown = False\n for _ in range(10):\n inbox = BUS.read_inbox(name)\n for msg in inbox:\n stopped = handle_inbox_message(name, msg, messages)\n if stopped:\n should_shutdown = True\n break\n if should_shutdown:\n break\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages[-20:],\n tools=sub_tools, max_tokens=8000)\n except Exception:\n break\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n summary = _last_assistant_text(response.content)\n gate = plan_gates.get(name, \"not_required\")\n if gate != \"pending\" and summary:\n BUS.send(name, \"lead\", summary, \"result\")\n if gate == \"pending\":\n with team_lock:\n active_teammates[name] = \"waiting_approval\"\n else:\n with team_lock:\n active_teammates[name] = \"idle\"\n BUS.send(name, \"lead\", \"Waiting for more work.\",\n \"idle_notification\")\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n output = _run_teammate_tool(name, block, sub_handlers)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)})\n messages.append({\"role\": \"user\", \"content\": results})\n\n if should_shutdown:\n break\n\n # IDLE phase: messages take priority, then scan the task board.\n while True:\n inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL)\n if inbox:\n for msg in inbox:\n if handle_inbox_message(name, msg, messages):\n should_shutdown = True\n break\n if should_shutdown or messages[-1][\"role\"] == \"user\":\n break\n continue\n\n task = claim_next_task(name)\n if not task:\n continue\n wt_ctx[\"path\"] = (str(WORKTREES_DIR / task.worktree)\n if task.worktree else None)\n workdir = (f\"\\nWork directory: {wt_ctx['path']}\"\n if wt_ctx[\"path\"] else \"\")\n messages.append({\n \"role\": \"user\",\n \"content\": (\n f\"[Auto-claimed task {task.id}] \"\n f\"{task.subject}\\n{task.description}{workdir}\"\n ),\n })\n print(f\" \\033[32m[idle] {name} claimed \"\n f\"{task.id}: {task.subject}\\033[0m\")\n break\n if should_shutdown:\n break\n\n with team_lock:\n active_teammates.pop(name, None)\n plan_gates.pop(name, None)\n plan_request_ids.pop(name, None)\n print(f\" \\033[32m[teammate] {name} finished\\033[0m\")\n\n threading.Thread(target=run, daemon=True).start()\n print(f\" \\033[36m[teammate] {name} spawned as {role}\\033[0m\")\n return f\"Teammate '{name}' spawned as {role} (autonomous)\"\n\n\ndef _teammate_submit_plan(from_name: str, plan: str) -> str:\n with team_lock:\n if plan_gates.get(from_name) == \"pending\":\n return \"A plan is already waiting for review.\"\n req_id = new_request_id()\n pending_requests[req_id] = ProtocolState(\n request_id=req_id, type=\"plan_approval\",\n sender=from_name, target=\"lead\",\n status=\"pending\", payload=plan)\n plan_gates[from_name] = \"pending\"\n plan_request_ids[from_name] = req_id\n active_teammates[from_name] = \"waiting_approval\"\n BUS.send(from_name, \"lead\", plan,\n \"plan_approval_request\",\n {\"request_id\": req_id})\n return f\"Plan submitted ({req_id}). Waiting for approval...\"\n\n\n# ── Lead Protocol Tools (from s15) ──\n\ndef run_request_shutdown(teammate: str) -> str:\n if teammate not in active_teammates:\n return f\"Teammate '{teammate}' is not active\"\n with team_lock:\n req_id = new_request_id()\n pending_requests[req_id] = ProtocolState(\n request_id=req_id, type=\"shutdown\",\n sender=\"lead\", target=teammate,\n status=\"pending\", payload=\"\")\n BUS.send(\"lead\", teammate, \"Please shut down gracefully.\",\n \"shutdown_request\",\n {\"request_id\": req_id})\n print(f\" \\033[35m[protocol] shutdown_request → {teammate} \"\n f\"({req_id})\\033[0m\")\n return f\"Shutdown request sent to {teammate} (req: {req_id})\"\n\n\ndef run_request_plan(teammate: str, task: str) -> str:\n if teammate not in active_teammates:\n return f\"Teammate '{teammate}' is not active\"\n with team_lock:\n plan_gates[teammate] = \"required\"\n BUS.send(\"lead\", teammate, task, \"plan_request\")\n return f\"Asked {teammate} to submit a plan\"\n\n\ndef run_review_plan(request_id: str, approve: bool,\n feedback: str = \"\") -> str:\n with team_lock:\n state = pending_requests.get(request_id)\n if not state:\n return f\"Request {request_id} not found\"\n if state.type != \"plan_approval\":\n return f\"Request {request_id} is not a plan\"\n if state.status != \"pending\":\n return f\"Request {request_id} already {state.status}\"\n if plan_request_ids.get(state.sender) != request_id:\n return f\"Request {request_id} is not the current plan\"\n state.status = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", state.sender,\n feedback or (\"Approved\" if approve else \"Rejected\"),\n \"plan_approval_response\",\n {\"request_id\": request_id, \"approve\": approve})\n icon = \"✓\" if approve else \"✗\"\n print(f\" \\033[32m[protocol] plan {icon} ({request_id})\\033[0m\")\n return f\"Plan {'approved' if approve else 'rejected'} ({request_id})\"\n\n\n# ── Hooks + Permission Pipeline ──\n\n# Hooks are intentionally outside tool handlers. The loop can add permission,\n# logging, and stop behavior without changing each individual tool.\nHOOKS = {\"UserPromptSubmit\": [], \"PreToolUse\": [],\n \"PostToolUse\": [], \"Stop\": []}\n\n\ndef register_hook(event: str, callback):\n HOOKS[event].append(callback)\n\n\ndef trigger_hooks(event: str, *args):\n for callback in HOOKS[event]:\n result = callback(*args)\n if result is not None:\n return result\n return None\n\n\nDENY_LIST = [\"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\", \"mkfs\", \"dd if=\"]\nDESTRUCTIVE = [\"rm \", \"> /etc/\", \"chmod 777\"]\n\n\ndef permission_hook(block):\n # The permission layer sees the raw tool_use before dispatch. It can deny,\n # ask the user, or allow execution to continue.\n if block.name == \"bash\":\n command = block.input.get(\"command\", \"\")\n for pattern in DENY_LIST:\n if pattern in command:\n return f\"Permission denied: '{pattern}' is on the deny list\"\n if any(token in command for token in DESTRUCTIVE):\n print(f\"\\n\\033[33m[permission] destructive command\\033[0m\")\n print(f\" {command}\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n if block.name in (\"read_file\", \"write_file\", \"edit_file\"):\n path = block.input.get(\"path\", \"\")\n if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):\n print(f\"\\n\\033[33m[permission] Access outside workspace\\033[0m\")\n print(f\" {block.name}: {path}\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n if block.name.startswith(\"mcp__\") and \"deploy\" in block.name:\n print(f\"\\n\\033[33m[permission] MCP destructive-looking tool: {block.name}\\033[0m\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n return None\n\n\ndef log_hook(block):\n print(f\"\\033[90m[HOOK] {block.name}\\033[0m\")\n return None\n\n\ndef large_output_hook(block, output):\n if len(str(output)) > 100000:\n print(f\"\\033[33m[HOOK] large output from {block.name}: \"\n f\"{len(str(output))} chars\\033[0m\")\n return None\n\n\ndef user_prompt_hook(query: str):\n print(f\"\\033[90m[HOOK] UserPromptSubmit: {WORKDIR}\\033[0m\")\n return None\n\n\ndef stop_hook(messages: list):\n tool_count = 0\n for msg in messages:\n content = msg.get(\"content\")\n if isinstance(content, list):\n tool_count += sum(1 for item in content\n if isinstance(item, dict)\n and item.get(\"type\") == \"tool_result\")\n print(f\"\\033[90m[HOOK] Stop: {tool_count} tool result(s)\\033[0m\")\n return None\n\n\nregister_hook(\"UserPromptSubmit\", user_prompt_hook)\nregister_hook(\"PreToolUse\", permission_hook)\nregister_hook(\"PreToolUse\", log_hook)\nregister_hook(\"PostToolUse\", large_output_hook)\nregister_hook(\"Stop\", stop_hook)\n\n\n# ── Subagent Tool ──\n\nSUB_SYSTEM = (\n f\"You are a coding subagent at {WORKDIR}. \"\n \"Complete the task, then return a concise final summary. \"\n \"Do not spawn more agents.\"\n)\n\n\nSUB_TOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"},\n \"offset\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"old_text\": {\"type\": \"string\"},\n \"new_text\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"pattern\": {\"type\": \"string\"}},\n \"required\": [\"pattern\"]}},\n]\n\n\nSUB_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read,\n \"write_file\": run_write, \"edit_file\": run_edit,\n \"glob\": run_glob,\n}\n\n\ndef extract_text(content) -> str:\n if not isinstance(content, list):\n return str(content)\n return \"\\n\".join(\n getattr(block, \"text\", \"\")\n for block in content\n if getattr(block, \"type\", None) == \"text\").strip()\n\n\ndef has_tool_use(content) -> bool:\n # Do not rely on stop_reason alone; the concrete tool_use block is the\n # continuation signal used by the loop.\n return any(getattr(block, \"type\", None) == \"tool_use\"\n for block in content)\n\n\ndef spawn_subagent(description: str) -> str:\n messages = [{\"role\": \"user\", \"content\": description}]\n for _ in range(30):\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM, messages=messages,\n tools=SUB_TOOLS, max_tokens=8000)\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if not has_tool_use(response.content):\n break\n results = []\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n output = str(blocked)\n else:\n handler = SUB_HANDLERS.get(block.name)\n output = call_tool_handler(handler, block.input, block.name)\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)})\n messages.append({\"role\": \"user\", \"content\": results})\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\":\n text = extract_text(msg[\"content\"])\n if text:\n return text\n return \"Subagent finished without a text summary.\"\n\n\n# ── Context Compaction ──\n\n# Compaction is layered: first shrink oversized tool results, then trim old\n# message ranges, and only call the model for a summary when the context is\n# still too large or the model explicitly asks for compact.\ndef estimate_size(messages: list) -> int:\n return len(json.dumps(messages, default=str))\n\ndef block_type(block):\n return block.get(\"type\") if isinstance(block, dict) else getattr(block, \"type\", None)\n\n\ndef message_has_tool_use(message: dict) -> bool:\n if message.get(\"role\") != \"assistant\":\n return False\n content = message.get(\"content\")\n if not isinstance(content, list):\n return False\n return any(block_type(block) == \"tool_use\" for block in content)\n\n\ndef is_tool_result_message(message: dict) -> bool:\n if message.get(\"role\") != \"user\":\n return False\n content = message.get(\"content\")\n if not isinstance(content, list):\n return False\n return any(isinstance(block, dict) and block.get(\"type\") == \"tool_result\"\n for block in content)\n\n\ndef collect_tool_results(messages: list):\n found = []\n for mi, msg in enumerate(messages):\n content = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n continue\n for bi, block in enumerate(content):\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n found.append((mi, bi, block))\n return found\n\n\ndef persist_large_output(tool_use_id: str, output: str) -> str:\n if len(output) <= PERSIST_THRESHOLD:\n return output\n TOOL_RESULTS_DIR.mkdir(parents=True, exist_ok=True)\n path = TOOL_RESULTS_DIR / f\"{tool_use_id}.txt\"\n if not path.exists():\n path.write_text(output)\n return (f\"\\nFull output: {path}\\n\"\n f\"Preview:\\n{output[:2000]}\\n\")\n\n\ndef tool_result_budget(messages: list, max_bytes: int = 200_000) -> list:\n if not messages:\n return messages\n last = messages[-1]\n content = last.get(\"content\")\n if last.get(\"role\") != \"user\" or not isinstance(content, list):\n return messages\n blocks = [(i, b) for i, b in enumerate(content)\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes:\n return messages\n for _, block in sorted(blocks,\n key=lambda pair: len(str(pair[1].get(\"content\", \"\"))),\n reverse=True):\n if total <= max_bytes:\n break\n text = str(block.get(\"content\", \"\"))\n block[\"content\"] = persist_large_output(\n block.get(\"tool_use_id\", \"unknown\"), text)\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return messages\n\n\ndef snip_compact(messages: list, max_messages: int = 50) -> list:\n if len(messages) <= max_messages:\n return messages\n head_end, tail_start = 3, len(messages) - (max_messages - 3)\n if head_end > 0 and message_has_tool_use(messages[head_end - 1]):\n while head_end < len(messages) and is_tool_result_message(messages[head_end]):\n head_end += 1\n if (tail_start > 0 and tail_start < len(messages)\n and is_tool_result_message(messages[tail_start])\n and message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n if head_end >= tail_start:\n return messages\n snipped = tail_start - head_end\n return (messages[:head_end]\n + [{\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}]\n + messages[tail_start:])\n\n\ndef micro_compact(messages: list) -> list:\n tool_results = collect_tool_results(messages)\n if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS:\n return messages\n for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]:\n if len(str(block.get(\"content\", \"\"))) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n\n\ndef write_transcript(messages: list) -> Path:\n TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)\n path = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with path.open(\"w\") as f:\n for msg in messages:\n f.write(json.dumps(msg, default=str) + \"\\n\")\n return path\n\n\ndef summarize_history(messages: list) -> str:\n conversation = json.dumps(messages, default=str)[:80000]\n prompt = (\"Summarize this coding-agent conversation so work can continue. \"\n \"Preserve current goal, key findings, changed files, remaining work, \"\n \"and user constraints.\\n\\n\" + conversation)\n response = client.messages.create(\n model=MODEL,\n messages=[{\"role\": \"user\", \"content\": prompt}],\n max_tokens=2000)\n return extract_text(response.content) or \"(empty summary)\"\n\n\ndef compact_history(messages: list) -> list:\n transcript = write_transcript(messages)\n print(f\" \\033[36m[compact] transcript saved: {transcript}\\033[0m\")\n summary = summarize_history(messages)\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n\n\ndef reactive_compact(messages: list) -> list:\n transcript = write_transcript(messages)\n print(f\" \\033[31m[reactive compact] transcript saved: {transcript}\\033[0m\")\n tail_start = max(0, len(messages) - 5)\n if (tail_start > 0 and tail_start < len(messages)\n and is_tool_result_message(messages[tail_start])\n and message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n try:\n summary = summarize_history(messages[:tail_start])\n except Exception:\n summary = \"Earlier conversation was trimmed after a prompt-too-long error.\"\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"},\n *messages[tail_start:]]\n\n\n# ── Error Recovery ──\n\nclass RecoveryState:\n def __init__(self):\n self.has_escalated = False\n self.recovery_count = 0\n self.consecutive_529 = 0\n self.has_attempted_reactive_compact = False\n self.current_model = PRIMARY_MODEL\n\n\ndef retry_delay(attempt: int) -> float:\n base = min(BASE_DELAY_MS * (2 ** attempt), 32000) / 1000\n return base + random.uniform(0, base * 0.25)\n\n\ndef with_retry(fn, state: RecoveryState):\n for attempt in range(MAX_RETRIES):\n try:\n result = fn()\n state.consecutive_529 = 0\n return result\n except Exception as e:\n name = type(e).__name__.lower()\n msg = str(e).lower()\n if \"ratelimit\" in name or \"429\" in msg:\n delay = retry_delay(attempt)\n print(f\" \\033[33m[429] retry {attempt + 1}/{MAX_RETRIES} \"\n f\"after {delay:.1f}s\\033[0m\")\n time.sleep(delay)\n continue\n if \"overloaded\" in name or \"529\" in msg or \"overloaded\" in msg:\n state.consecutive_529 += 1\n if state.consecutive_529 >= MAX_CONSECUTIVE_529 and FALLBACK_MODEL:\n state.current_model = FALLBACK_MODEL\n state.consecutive_529 = 0\n print(f\" \\033[31m[529] switching to {FALLBACK_MODEL}\\033[0m\")\n delay = retry_delay(attempt)\n print(f\" \\033[33m[529] retry {attempt + 1}/{MAX_RETRIES} \"\n f\"after {delay:.1f}s\\033[0m\")\n time.sleep(delay)\n continue\n raise\n raise RuntimeError(f\"Max retries ({MAX_RETRIES}) exceeded\")\n\n\ndef is_prompt_too_long_error(e: Exception) -> bool:\n msg = str(e).lower()\n return ((\"prompt\" in msg and \"long\" in msg)\n or \"context_length_exceeded\" in msg\n or \"max_context_window\" in msg)\n\n\n# ── Background Tasks ──\n\n# Slow tools return a placeholder tool_result immediately. Their real output is\n# later injected as a task_notification, so the main loop can keep moving.\n_bg_counter = 0\nbackground_tasks: dict[str, dict] = {}\nbackground_results: dict[str, str] = {}\nbackground_lock = threading.Lock()\n\n\ndef is_slow_operation(tool_name: str, tool_input: dict) -> bool:\n if tool_name != \"bash\":\n return False\n command = tool_input.get(\"command\", \"\").lower()\n slow_keywords = [\"install\", \"build\", \"test\", \"deploy\", \"compile\",\n \"docker build\", \"pip install\", \"npm install\",\n \"cargo build\", \"pytest\", \"make\"]\n return any(keyword in command for keyword in slow_keywords)\n\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n if tool_name != \"bash\":\n return False\n return bool(tool_input.get(\"run_in_background\")) or is_slow_operation(tool_name, tool_input)\n\n\ndef start_background_task(block, handlers: dict) -> str:\n global _bg_counter\n _bg_counter += 1\n bg_id = f\"bg_{_bg_counter:04d}\"\n command = block.input.get(\"command\", block.name)\n\n def worker():\n handler = handlers.get(block.name)\n result = call_tool_handler(handler, block.input, block.name)\n trigger_hooks(\"PostToolUse\", block, result)\n with background_lock:\n background_tasks[bg_id][\"status\"] = \"completed\"\n background_results[bg_id] = str(result)\n\n with background_lock:\n background_tasks[bg_id] = {\n \"tool_use_id\": block.id,\n \"command\": command,\n \"status\": \"running\",\n }\n threading.Thread(target=worker, daemon=True).start()\n print(f\" \\033[33m[background] {bg_id}: {str(command)[:60]}\\033[0m\")\n return bg_id\n\n\ndef collect_background_results() -> list[str]:\n with background_lock:\n ready = [bg_id for bg_id, task in background_tasks.items()\n if task[\"status\"] == \"completed\"]\n notifications = []\n for bg_id in ready:\n with background_lock:\n task = background_tasks.pop(bg_id)\n output = background_results.pop(bg_id, \"\")\n summary = output[:200] if len(output) > 200 else output\n notifications.append(\n f\"\\n\"\n f\" {bg_id}\\n\"\n f\" completed\\n\"\n f\" {task['command']}\\n\"\n f\" {summary}\\n\"\n f\"\")\n return notifications\n\n\n# ── Cron Scheduler ──\n\n# Cron jobs are stored separately from conversation history. When a job fires,\n# it becomes a scheduled prompt that is injected back into the same agent loop.\nDURABLE_PATH = WORKDIR / \".scheduled_tasks.json\"\n\n\n@dataclass\nclass CronJob:\n id: str\n cron: str\n prompt: str\n recurring: bool\n durable: bool\n\n\nscheduled_jobs: dict[str, CronJob] = {}\ncron_queue: list[CronJob] = []\ncron_lock = threading.Lock()\n_last_fired: dict[str, str] = {}\n\n\ndef _cron_field_matches(field: str, value: int) -> bool:\n if field == \"*\":\n return True\n if field.startswith(\"*/\"):\n step = int(field[2:])\n return step > 0 and value % step == 0\n if \",\" in field:\n return any(_cron_field_matches(part.strip(), value)\n for part in field.split(\",\"))\n if \"-\" in field:\n lo, hi = field.split(\"-\", 1)\n return int(lo) <= value <= int(hi)\n return value == int(field)\n\n\ndef cron_matches(cron_expr: str, dt: datetime) -> bool:\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return False\n minute, hour, dom, month, dow = fields\n dow_val = (dt.weekday() + 1) % 7\n m = _cron_field_matches(minute, dt.minute)\n h = _cron_field_matches(hour, dt.hour)\n dom_ok = _cron_field_matches(dom, dt.day)\n month_ok = _cron_field_matches(month, dt.month)\n dow_ok = _cron_field_matches(dow, dow_val)\n if not (m and h and month_ok):\n return False\n if dom == \"*\" and dow == \"*\":\n return True\n if dom == \"*\":\n return dow_ok\n if dow == \"*\":\n return dom_ok\n return dom_ok or dow_ok\n\n\ndef _validate_cron_field(field: str, lo: int, hi: int) -> str | None:\n if field == \"*\":\n return None\n if field.startswith(\"*/\"):\n step = field[2:]\n if not step.isdigit() or int(step) <= 0:\n return f\"Invalid step: {field}\"\n return None\n if \",\" in field:\n for part in field.split(\",\"):\n err = _validate_cron_field(part.strip(), lo, hi)\n if err:\n return err\n return None\n if \"-\" in field:\n left, right = field.split(\"-\", 1)\n if not left.isdigit() or not right.isdigit():\n return f\"Invalid range: {field}\"\n a, b = int(left), int(right)\n if a < lo or a > hi or b < lo or b > hi:\n return f\"Range {field} out of bounds [{lo}-{hi}]\"\n if a > b:\n return f\"Range start > end: {field}\"\n return None\n if not field.isdigit():\n return f\"Invalid field: {field}\"\n value = int(field)\n if value < lo or value > hi:\n return f\"Value {value} out of bounds [{lo}-{hi}]\"\n return None\n\n\ndef validate_cron(cron_expr: str) -> str | None:\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return f\"Expected 5 fields, got {len(fields)}\"\n bounds = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 6)]\n names = [\"minute\", \"hour\", \"day-of-month\", \"month\", \"day-of-week\"]\n for field, (lo, hi), name in zip(fields, bounds, names):\n err = _validate_cron_field(field, lo, hi)\n if err:\n return f\"{name}: {err}\"\n return None\n\n\ndef save_durable_jobs():\n durable = [asdict(job) for job in scheduled_jobs.values() if job.durable]\n DURABLE_PATH.write_text(json.dumps(durable, indent=2))\n\n\ndef load_durable_jobs():\n if not DURABLE_PATH.exists():\n return\n try:\n for item in json.loads(DURABLE_PATH.read_text()):\n job = CronJob(**item)\n if not validate_cron(job.cron):\n scheduled_jobs[job.id] = job\n except Exception:\n pass\n\n\ndef schedule_job(cron: str, prompt: str,\n recurring: bool = True, durable: bool = True) -> CronJob | str:\n err = validate_cron(cron)\n if err:\n return err\n job = CronJob(\n id=f\"cron_{random.randint(0, 999999):06d}\",\n cron=cron, prompt=prompt,\n recurring=recurring, durable=durable)\n with cron_lock:\n scheduled_jobs[job.id] = job\n if durable:\n save_durable_jobs()\n return job\n\n\ndef cancel_job(job_id: str) -> str:\n with cron_lock:\n job = scheduled_jobs.pop(job_id, None)\n if not job:\n return f\"Job {job_id} not found\"\n if job.durable:\n save_durable_jobs()\n return f\"Cancelled {job_id}\"\n\n\ndef cron_scheduler_loop():\n while True:\n time.sleep(1)\n now = datetime.now()\n marker = now.strftime(\"%Y-%m-%d %H:%M\")\n with cron_lock:\n for job in list(scheduled_jobs.values()):\n try:\n if cron_matches(job.cron, now) and _last_fired.get(job.id) != marker:\n cron_queue.append(job)\n _last_fired[job.id] = marker\n if not job.recurring:\n scheduled_jobs.pop(job.id, None)\n if job.durable:\n save_durable_jobs()\n except Exception as e:\n print(f\" \\033[31m[cron error] {job.id}: {e}\\033[0m\")\n\n\ndef consume_cron_queue() -> list[CronJob]:\n with cron_lock:\n fired = list(cron_queue)\n cron_queue.clear()\n return fired\n\n\ndef run_schedule_cron(cron: str, prompt: str,\n recurring: bool = True, durable: bool = True) -> str:\n result = schedule_job(cron, prompt, recurring, durable)\n if isinstance(result, str):\n return f\"Error: {result}\"\n return f\"Scheduled {result.id}: '{cron}' -> {prompt}\"\n\n\ndef run_list_crons() -> str:\n with cron_lock:\n jobs = list(scheduled_jobs.values())\n if not jobs:\n return \"No cron jobs.\"\n return \"\\n\".join(\n f\" {job.id}: '{job.cron}' -> {job.prompt[:40]} \"\n f\"[{'recurring' if job.recurring else 'one-shot'}, \"\n f\"{'durable' if job.durable else 'session'}]\"\n for job in jobs)\n\n\ndef run_cancel_cron(job_id: str) -> str:\n return cancel_job(job_id)\n\n\nload_durable_jobs()\nthreading.Thread(target=cron_scheduler_loop, daemon=True).start()\n\n\n# ── MCP System ──\n\n# MCP is modeled as late-bound tools: connect first, then discovered server\n# tools are merged into the normal tool pool with mcp__server__tool names.\nclass MCPClient:\n \"\"\"Discovers and calls tools on an in-process MCP server.\"\"\"\n\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs: list[dict],\n handlers: dict[str, callable]):\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n try:\n return handler(**args)\n except Exception as e:\n return f\"MCP error: {e}\"\n\n\nmcp_clients: dict[str, MCPClient] = {}\n\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\n\ndef normalize_mcp_name(name: str) -> str:\n \"\"\"Replace non [a-zA-Z0-9_-] with underscore.\"\"\"\n return _DISALLOWED_CHARS.sub('_', name)\n\n\ndef _mock_server_docs():\n client = MCPClient(\"docs\")\n client.register(\n tool_defs=[\n {\"name\": \"search\", \"description\": \"Search documentation. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"query\": {\"type\": \"string\"}},\n \"required\": [\"query\"]}},\n {\"name\": \"get_version\", \"description\": \"Get API version. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n ],\n handlers={\n \"search\": lambda query: f\"[docs] Found 3 results for '{query}'\",\n \"get_version\": lambda: \"[docs] API v2.1.0\",\n })\n return client\n\n\ndef _mock_server_deploy():\n client = MCPClient(\"deploy\")\n client.register(\n tool_defs=[\n {\"name\": \"trigger\",\n \"description\": \"Trigger a deployment. (destructive; requires approval)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"service\": {\"type\": \"string\"}},\n \"required\": [\"service\"]}},\n {\"name\": \"status\", \"description\": \"Check deployment status. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"service\": {\"type\": \"string\"}},\n \"required\": [\"service\"]}},\n ],\n handlers={\n \"trigger\": lambda service: f\"[deploy] Triggered: {service}\",\n \"status\": lambda service: f\"[deploy] {service}: running (v1.4.2)\",\n })\n return client\n\n\nMOCK_SERVERS = {\n \"docs\": _mock_server_docs,\n \"deploy\": _mock_server_deploy,\n}\n\n\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n available = \", \".join(MOCK_SERVERS.keys())\n return f\"Unknown server '{name}'. Available: {available}\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n tool_names = [t[\"name\"] for t in mcp_client.tools]\n print(f\" \\033[31m[mcp] connected: {name} → {tool_names}\\033[0m\")\n return (f\"Connected to MCP server '{name}'. \"\n f\"Discovered {len(mcp_client.tools)} tools: {', '.join(tool_names)}\")\n\n\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n \"\"\"Merge builtin tools + all MCP tools into one pool.\"\"\"\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append({\n \"name\": prefixed,\n \"description\": tool_def.get(\"description\", \"\"),\n \"input_schema\": tool_def.get(\"inputSchema\", {}),\n })\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw: c.call_tool(t, kw))\n return tools, handlers\n\n\n# ── Lead Worktree Tools ──\n\ndef run_create_worktree(name: str, task_id: str = \"\") -> str:\n return create_worktree(name, task_id)\n\ndef run_remove_worktree(name: str, discard_changes: bool = False) -> str:\n return remove_worktree(name, discard_changes)\n\ndef run_keep_worktree(name: str) -> str:\n return keep_worktree(name)\n\n\n# ── Basic tool handlers ──\n\ndef run_create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> str:\n task = create_task(subject, description, blockedBy)\n deps = f\" (blockedBy: {', '.join(blockedBy)})\" if blockedBy else \"\"\n print(f\" \\033[34m[create] {task.subject}{deps}\\033[0m\")\n return f\"Created {task.id}: {task.subject}{deps}\"\n\n\ndef run_list_tasks() -> str:\n tasks = list_tasks()\n if not tasks:\n return \"No tasks.\"\n return \"\\n\".join(\n f\" {t.id}: {t.subject} [{t.status}]\"\n + (f\" (wt:{t.worktree})\" if t.worktree else \"\")\n for t in tasks)\n\n\ndef run_get_task(task_id: str) -> str:\n try:\n return get_task_json(task_id)\n except FileNotFoundError:\n return f\"Error: task {task_id} not found\"\n\ndef run_claim_task(task_id: str) -> str:\n try:\n return claim_task(task_id, owner=\"agent\")\n except FileNotFoundError:\n return f\"Error: task {task_id} not found\"\n\ndef run_complete_task(task_id: str) -> str:\n try:\n return complete_task(task_id)\n except FileNotFoundError:\n return f\"Error: task {task_id} not found\"\n\ndef run_spawn_teammate(name: str, role: str, prompt: str) -> str:\n return spawn_teammate_thread(name, role, prompt)\n\ndef run_send_message(to: str, content: str) -> str:\n if to not in active_teammates:\n return f\"Teammate '{to}' is not active\"\n BUS.send(\"lead\", to, content)\n return f\"Sent to {to}\"\n\ndef run_connect_mcp(name: str) -> str:\n return connect_mcp(name)\n\n\n# ── Tool Definitions ──\n\n# The model sees tool schemas; Python executes handlers. S19 keeps both tables\n# explicit so every added capability is visible in one place.\nBUILTIN_TOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"},\n \"run_in_background\": {\"type\": \"boolean\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"},\n \"offset\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"old_text\": {\"type\": \"string\"},\n \"new_text\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"pattern\": {\"type\": \"string\"}},\n \"required\": [\"pattern\"]}},\n {\"name\": \"todo_write\",\n \"description\": \"Create and manage a task list for the current session.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"todos\": {\"type\": \"array\",\n \"items\": {\"type\": \"object\",\n \"properties\": {\n \"content\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\",\n \"enum\": [\"pending\", \"in_progress\", \"completed\"]}},\n \"required\": [\"content\", \"status\"]}}},\n \"required\": [\"todos\"]}},\n {\"name\": \"task\",\n \"description\": \"Launch a focused subagent. Returns only its final summary.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"description\": {\"type\": \"string\"}},\n \"required\": [\"description\"]}},\n {\"name\": \"load_skill\",\n \"description\": \"Load the full content of a skill by name.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"compact\",\n \"description\": \"Summarize earlier conversation and continue with compacted context.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"focus\": {\"type\": \"string\"}},\n \"required\": []}},\n {\"name\": \"create_task\", \"description\": \"Create a task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"subject\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n \"blockedBy\": {\"type\": \"array\",\n \"items\": {\"type\": \"string\"}}},\n \"required\": [\"subject\"]}},\n {\"name\": \"list_tasks\", \"description\": \"List all tasks.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {}, \"required\": []}},\n {\"name\": \"get_task\", \"description\": \"Get full task details.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"claim_task\", \"description\": \"Claim a pending task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\", \"description\": \"Complete an in-progress task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"schedule_cron\",\n \"description\": (\"Schedule a cron job. cron is 5-field: min hour dom \"\n \"month dow. For one-shot reminders, compute the target \"\n \"minute and set recurring=false.\"),\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"cron\": {\"type\": \"string\"},\n \"prompt\": {\"type\": \"string\"},\n \"recurring\": {\"type\": \"boolean\"},\n \"durable\": {\"type\": \"boolean\"}},\n \"required\": [\"cron\", \"prompt\"]}},\n {\"name\": \"list_crons\", \"description\": \"List registered cron jobs.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {}, \"required\": []}},\n {\"name\": \"cancel_cron\", \"description\": \"Cancel a cron job by ID.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"job_id\": {\"type\": \"string\"}},\n \"required\": [\"job_id\"]}},\n {\"name\": \"spawn_teammate\", \"description\": \"Spawn an autonomous teammate.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\n \"type\": \"string\",\n \"pattern\": \"^[A-Za-z0-9_-]{1,64}$\",\n },\n \"role\": {\"type\": \"string\"},\n \"prompt\": {\"type\": \"string\"}},\n \"required\": [\"name\", \"role\", \"prompt\"]}},\n {\"name\": \"send_message\", \"description\": \"Send message to a teammate.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"request_shutdown\",\n \"description\": \"Request a teammate to shut down.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"teammate\": {\"type\": \"string\"}},\n \"required\": [\"teammate\"]}},\n {\"name\": \"request_plan\",\n \"description\": \"Ask a teammate to submit a plan.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"teammate\": {\"type\": \"string\"},\n \"task\": {\"type\": \"string\"}},\n \"required\": [\"teammate\", \"task\"]}},\n {\"name\": \"review_plan\",\n \"description\": \"Approve or reject a submitted plan.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"request_id\": {\"type\": \"string\"},\n \"approve\": {\"type\": \"boolean\"},\n \"feedback\": {\"type\": \"string\"}},\n \"required\": [\"request_id\", \"approve\"]}},\n {\"name\": \"create_worktree\",\n \"description\": \"Create an isolated git worktree.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"},\n \"task_id\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"remove_worktree\",\n \"description\": \"Remove a worktree. Refuses if changes exist.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"},\n \"discard_changes\": {\"type\": \"boolean\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"keep_worktree\",\n \"description\": \"Keep a worktree for manual review.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"connect_mcp\",\n \"description\": \"Connect to an MCP server (docs, deploy) and discover tools.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n]\n\nBUILTIN_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"edit_file\": run_edit, \"glob\": run_glob,\n \"todo_write\": run_todo_write, \"task\": spawn_subagent,\n \"load_skill\": load_skill,\n \"create_task\": run_create_task, \"list_tasks\": run_list_tasks,\n \"get_task\": run_get_task,\n \"claim_task\": run_claim_task, \"complete_task\": run_complete_task,\n \"schedule_cron\": run_schedule_cron,\n \"list_crons\": run_list_crons,\n \"cancel_cron\": run_cancel_cron,\n \"spawn_teammate\": run_spawn_teammate,\n \"send_message\": run_send_message,\n \"request_shutdown\": run_request_shutdown,\n \"request_plan\": run_request_plan, \"review_plan\": run_review_plan,\n \"create_worktree\": run_create_worktree,\n \"remove_worktree\": run_remove_worktree,\n \"keep_worktree\": run_keep_worktree,\n \"connect_mcp\": run_connect_mcp,\n}\n\n\n# ── Context ──\n\nMEMORY_DIR = WORKDIR / \".memory\"\nMEMORY_INDEX = MEMORY_DIR / \"MEMORY.md\"\n\n\ndef update_context(context: dict, messages: list) -> dict:\n memories = \"\"\n if MEMORY_INDEX.exists():\n memories = MEMORY_INDEX.read_text()[:2000]\n return {\n \"memories\": memories,\n \"connected_mcp\": list(mcp_clients.keys()),\n \"active_teammates\": list(active_teammates.keys()),\n }\n\n\n# ── Agent Loop ──\n\nrounds_since_todo = 0\nagent_lock = threading.Lock()\n\n\ndef prepare_context(messages: list) -> list:\n # Every LLM turn enters through the same context budget pipeline.\n messages[:] = tool_result_budget(messages)\n messages[:] = snip_compact(messages)\n messages[:] = micro_compact(messages)\n if estimate_size(messages) > CONTEXT_LIMIT:\n messages[:] = compact_history(messages)\n return messages\n\n\ndef build_user_content(results: list[dict]) -> list[dict]:\n # Tool results and completed background notifications are both returned to\n # the model as user-side content, matching the tool_result feedback loop.\n content = list(results)\n for note in collect_background_results():\n content.append({\"type\": \"text\", \"text\": note})\n return content\n\n\ndef inject_background_notifications(messages: list):\n notes = collect_background_results()\n if notes:\n messages.append({\"role\": \"user\", \"content\": [\n {\"type\": \"text\", \"text\": note} for note in notes]})\n\n\ndef call_llm(messages: list, context: dict, tools: list,\n state: RecoveryState, max_tokens: int):\n system = assemble_system_prompt(context)\n return with_retry(\n lambda: client.messages.create(\n model=state.current_model,\n system=system,\n messages=messages,\n tools=tools,\n max_tokens=max_tokens),\n state)\n\n\ndef agent_loop(messages: list, context: dict):\n global rounds_since_todo\n tools, handlers = assemble_tool_pool()\n state = RecoveryState()\n max_tokens = DEFAULT_MAX_TOKENS\n\n while True:\n # One cycle: inject scheduled/background work, prepare context, call\n # the model, execute tool_use blocks, append tool_results, repeat.\n fired = consume_cron_queue()\n for job in fired:\n messages.append({\"role\": \"user\",\n \"content\": f\"[Scheduled] {job.prompt}\"})\n print(f\" \\033[35m[cron inject] {job.prompt[:60]}\\033[0m\")\n\n inject_background_notifications(messages)\n\n if rounds_since_todo >= 3:\n messages.append({\"role\": \"user\",\n \"content\": \"Update your todos.\"})\n rounds_since_todo = 0\n\n prepare_context(messages)\n context = update_context(context, messages)\n tools, handlers = assemble_tool_pool()\n\n try:\n response = call_llm(messages, context, tools, state, max_tokens)\n except Exception as e:\n if is_prompt_too_long_error(e) and not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n messages.append({\"role\": \"assistant\", \"content\": [\n {\"type\": \"text\", \"text\": f\"[Error] {type(e).__name__}: {e}\"}]})\n return\n\n if response.stop_reason == \"max_tokens\":\n if not state.has_escalated:\n max_tokens = ESCALATED_MAX_TOKENS\n state.has_escalated = True\n print(f\" \\033[33m[max_tokens] retry with {max_tokens}\\033[0m\")\n continue\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if state.recovery_count < MAX_RECOVERY_RETRIES:\n messages.append({\"role\": \"user\", \"content\": CONTINUATION_PROMPT})\n state.recovery_count += 1\n continue\n return\n\n max_tokens = DEFAULT_MAX_TOKENS\n state.has_escalated = False\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if not has_tool_use(response.content):\n trigger_hooks(\"Stop\", messages)\n return\n\n results = []\n compacted_now = False\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n\n if block.name == \"compact\":\n messages[:] = compact_history(messages)\n messages.append({\"role\": \"user\",\n \"content\": \"[Compacted. Continue with summarized context.]\"})\n compacted_now = True\n break\n\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(blocked)})\n continue\n\n if should_run_background(block.name, block.input):\n bg_id = start_background_task(block, handlers)\n output = (f\"[Background task {bg_id} started] \"\n \"Result will arrive as a task_notification.\")\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output})\n continue\n\n handler = handlers.get(block.name)\n output = call_tool_handler(handler, block.input, block.name)\n trigger_hooks(\"PostToolUse\", block, output)\n print(str(output)[:300])\n\n if block.name == \"todo_write\":\n rounds_since_todo = 0\n else:\n rounds_since_todo += 1\n\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n\n if compacted_now:\n continue\n\n messages.append({\"role\": \"user\", \"content\": build_user_content(results)})\n\n\ndef print_turn_assistants(messages: list, turn_start: int):\n for msg in messages[turn_start:]:\n if msg.get(\"role\") != \"assistant\":\n continue\n for block in msg.get(\"content\", []):\n if block_type(block) == \"text\":\n terminal_print(block[\"text\"] if isinstance(block, dict) else block.text)\n\n\ndef async_event_loop(history: list, context: dict):\n while True:\n time.sleep(1)\n with agent_lock:\n fired = consume_cron_queue()\n inbox = consume_lead_inbox(route_protocol=True)\n if not fired and not inbox:\n continue\n turn_start = len(history)\n for job in fired:\n history.append({\"role\": \"user\",\n \"content\": f\"[Scheduled] {job.prompt}\"})\n terminal_print(\n f\" \\033[35m[cron auto] {job.prompt[:60]}\\033[0m\")\n if inbox:\n history.append({\"role\": \"user\",\n \"content\": format_team_events(inbox)})\n terminal_print(\n f\" \\033[33m[team auto] {len(inbox)} events\\033[0m\")\n agent_loop(history, context)\n context.update(update_context(context, history))\n print_turn_assistants(history, turn_start)\n\n\nif __name__ == \"__main__\":\n CLI_ACTIVE = True\n print(\"s19: comprehensive agent\")\n print(\"Enter a question, press Enter to send. Type q to quit.\\n\")\n history = []\n context = update_context({}, [])\n threading.Thread(target=async_event_loop,\n args=(history, context), daemon=True).start()\n while True:\n try:\n query = input(PROMPT)\n except (EOFError, KeyboardInterrupt):\n break\n if query.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n trigger_hooks(\"UserPromptSubmit\", query)\n turn_start = len(history)\n history.append({\"role\": \"user\", \"content\": query})\n with agent_lock:\n agent_loop(history, context)\n context = update_context(context, history)\n print_turn_assistants(history, turn_start)\n print()\n", + "source": "#!/usr/bin/env python3\n\"\"\"\ns19: Integrated Harness — many mechanisms in one loop.\n\nRun: python s19_comprehensive/code.py\nNeed: pip install anthropic python-dotenv pyyaml + .env with ANTHROPIC_API_KEY\n\nThis final chapter puts the earlier mechanisms back together: dispatch,\npermission, hooks, todo, subagent, skills, compaction,\nmemory, prompt assembly, error recovery, task graph, background tasks, cron,\nteams, protocols, autonomous agents, worktrees, and MCP.\n\"\"\"\n\nimport ast, json, os, subprocess, time, random, threading, re\nfrom pathlib import Path\nfrom datetime import datetime\nfrom dataclasses import dataclass, asdict, field\nimport yaml\n\ntry:\n import readline\n readline.parse_and_bind('set bind-tty-special-chars off')\n READLINE_AVAILABLE = True\nexcept ImportError:\n READLINE_AVAILABLE = False\n\nfrom anthropic import Anthropic\nfrom dotenv import load_dotenv\n\nload_dotenv(override=True)\nif os.getenv(\"ANTHROPIC_BASE_URL\"):\n os.environ.pop(\"ANTHROPIC_AUTH_TOKEN\", None)\n\nWORKDIR = Path.cwd()\nclient = Anthropic(base_url=os.getenv(\"ANTHROPIC_BASE_URL\"))\nMODEL = os.environ[\"MODEL_ID\"]\nPRIMARY_MODEL = MODEL\nFALLBACK_MODEL = os.getenv(\"FALLBACK_MODEL_ID\")\n\nSKILLS_DIR = WORKDIR / \"skills\"\nTRANSCRIPT_DIR = WORKDIR / \".transcripts\"\nTOOL_RESULTS_DIR = WORKDIR / \".task_outputs\" / \"tool-results\"\n\nDEFAULT_MAX_TOKENS = 8000\nESCALATED_MAX_TOKENS = 16000\nMAX_RETRIES = 3\nMAX_CONSECUTIVE_529 = 2\nMAX_RECOVERY_RETRIES = 2\nBASE_DELAY_MS = 500\nCONTEXT_LIMIT = 50000\nKEEP_RECENT_TOOL_RESULTS = 3\nPERSIST_THRESHOLD = 30000\nCONTINUATION_PROMPT = \"Continue from the previous response. Do not repeat completed work.\"\nPROMPT = \"\\033[36ms19 >> \\033[0m\"\nCLI_ACTIVE = False\n\n\ndef terminal_print(text: str):\n if threading.current_thread() is threading.main_thread() or not CLI_ACTIVE:\n print(text)\n return\n line = \"\"\n if READLINE_AVAILABLE:\n try:\n line = readline.get_line_buffer()\n except Exception:\n line = \"\"\n print(f\"\\r\\033[K{text}\")\n print(PROMPT + line, end=\"\", flush=True)\n\n# ── Task System ──\n\n# Tasks are tiny durable records. Later systems add ownership, dependencies,\n# worktrees, and teammates on top of this same file-backed state.\nTASKS_DIR = WORKDIR / \".tasks\"\nTASKS_DIR.mkdir(exist_ok=True)\ntask_lock = threading.RLock()\nCURRENT_TODOS: list[dict] = []\n\n\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str\n owner: str | None\n blockedBy: list[str]\n worktree: str | None = None\n\n\ndef _task_path(task_id: str) -> Path:\n return TASKS_DIR / f\"{task_id}.json\"\n\n\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random.randint(0, 9999):04d}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n\n\ndef save_task(task: Task):\n _task_path(task.id).write_text(json.dumps(asdict(task), indent=2))\n\n\ndef load_task(task_id: str) -> Task:\n return Task(**json.loads(_task_path(task_id).read_text()))\n\n\ndef list_tasks() -> list[Task]:\n return [Task(**json.loads(p.read_text()))\n for p in sorted(TASKS_DIR.glob(\"task_*.json\"))]\n\n\ndef get_task_json(task_id: str) -> str:\n return json.dumps(asdict(load_task(task_id)), indent=2)\n\n\ndef can_start(task_id: str) -> bool:\n # Dependencies are intentionally simple: every blocker must exist and be\n # completed before the task can be claimed.\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False\n if load_task(dep_id).status != \"completed\":\n return False\n return True\n\n\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n with task_lock:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if task.owner:\n return f\"Task {task_id} already owned by {task.owner}\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if (_task_path(d).exists()\n and load_task(d).status != \"completed\")]\n missing = [d for d in task.blockedBy\n if not _task_path(d).exists()]\n parts = []\n if deps:\n parts.append(f\"blocked by: {deps}\")\n if missing:\n parts.append(f\"missing deps: {missing}\")\n return \"Cannot start: \" + \", \".join(parts)\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n print(f\" \\033[36m[claim] {task.subject} → in_progress\\033[0m\")\n return f\"Claimed {task.id} ({task.subject})\"\n\n\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n if task.status != \"in_progress\":\n return f\"Task {task_id} is {task.status}, cannot complete\"\n task.status = \"completed\"\n save_task(task)\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy and can_start(t.id)]\n print(f\" \\033[32m[complete] {task.subject} ✓\\033[0m\")\n msg = f\"Completed {task.id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n return msg\n\n\n# ── Worktree System ──\n\n# Worktree names become filesystem paths, so validation stays strict and is\n# reused for create/remove/keep.\nWORKTREES_DIR = WORKDIR / \".worktrees\"\nWORKTREES_DIR.mkdir(exist_ok=True)\n\nVALID_WT_NAME = re.compile(r'^[A-Za-z0-9._-]{1,64}$')\n\n\ndef validate_worktree_name(name: str) -> str | None:\n if not name:\n return \"Worktree name cannot be empty\"\n if name in (\".\", \"..\"):\n return f\"'{name}' is not a valid worktree name\"\n if not VALID_WT_NAME.match(name):\n return (f\"Invalid worktree name '{name}': \"\n \"only letters, digits, dots, underscores, dashes (1-64 chars)\")\n return None\n\n\ndef run_git(args: list[str]) -> tuple[bool, str]:\n try:\n r = subprocess.run([\"git\"] + args, cwd=WORKDIR,\n capture_output=True, text=True, timeout=30)\n out = (r.stdout + r.stderr).strip()\n return r.returncode == 0, out[:5000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return False, \"Error: git timeout\"\n\n\ndef log_event(event_type: str, worktree_name: str, task_id: str = \"\"):\n event = {\"type\": event_type, \"worktree\": worktree_name,\n \"task_id\": task_id, \"ts\": time.time()}\n events_file = WORKTREES_DIR / \"events.jsonl\"\n with open(events_file, \"a\") as f:\n f.write(json.dumps(event) + \"\\n\")\n\n\ndef create_worktree(name: str, task_id: str = \"\") -> str:\n # Tool-layer validation is part of the safety boundary; do it before git\n # sees the name, not only after git happens to reject something.\n err = validate_worktree_name(name)\n if err:\n return f\"Error: {err}\"\n if task_id:\n try:\n load_task(task_id)\n except FileNotFoundError:\n return f\"Error: task {task_id} not found\"\n path = WORKTREES_DIR / name\n if path.exists():\n return f\"Worktree '{name}' already exists at {path}\"\n ok, result = run_git([\"worktree\", \"add\", str(path), \"-b\", f\"wt/{name}\", \"HEAD\"])\n if not ok:\n return f\"Git error: {result}\"\n if task_id:\n bind_task_to_worktree(task_id, name)\n log_event(\"create\", name, task_id)\n print(f\" \\033[33m[worktree] created: {name} at {path}\\033[0m\")\n return f\"Worktree '{name}' created at {path}\"\n\n\ndef bind_task_to_worktree(task_id: str, worktree_name: str):\n task = load_task(task_id)\n task.worktree = worktree_name\n save_task(task)\n\n\ndef _count_worktree_changes(path: Path) -> tuple[int, int]:\n try:\n r1 = subprocess.run([\"git\", \"status\", \"--porcelain\"],\n cwd=path, capture_output=True, text=True, timeout=10)\n files = len([l for l in r1.stdout.strip().splitlines() if l.strip()])\n r2 = subprocess.run([\"git\", \"log\", \"@{push}..HEAD\", \"--oneline\"],\n cwd=path, capture_output=True, text=True, timeout=10)\n commits = len([l for l in r2.stdout.strip().splitlines() if l.strip()])\n return files, commits\n except Exception:\n return -1, -1\n\n\ndef remove_worktree(name: str, discard_changes: bool = False) -> str:\n err = validate_worktree_name(name)\n if err:\n return err\n path = WORKTREES_DIR / name\n if not path.exists():\n return f\"Worktree '{name}' not found\"\n if not discard_changes:\n files, commits = _count_worktree_changes(path)\n if files < 0:\n return \"Cannot verify status. Use discard_changes=true to force.\"\n if files > 0 or commits > 0:\n return (f\"Worktree '{name}' has {files} file(s), {commits} commit(s). \"\n \"Use discard_changes=true or keep_worktree.\")\n ok1, _ = run_git([\"worktree\", \"remove\", str(path), \"--force\"])\n if not ok1:\n return f\"Failed to remove worktree '{name}'\"\n run_git([\"branch\", \"-D\", f\"wt/{name}\"])\n log_event(\"remove\", name)\n print(f\" \\033[33m[worktree] removed: {name}\\033[0m\")\n return f\"Worktree '{name}' removed\"\n\n\ndef keep_worktree(name: str) -> str:\n err = validate_worktree_name(name)\n if err:\n return err\n log_event(\"keep\", name)\n return f\"Worktree '{name}' kept for review (branch: wt/{name})\"\n\n\n# ── Skill Loading ──\n\nSKILL_REGISTRY: dict[str, dict] = {}\n\n\ndef _parse_frontmatter(text: str) -> tuple[dict, str]:\n if not text.startswith(\"---\"):\n return {}, text\n parts = text.split(\"---\", 2)\n if len(parts) < 3:\n return {}, text\n try:\n meta = yaml.safe_load(parts[1]) or {}\n except yaml.YAMLError:\n meta = {}\n return meta, parts[2].strip()\n\n\ndef scan_skills():\n SKILL_REGISTRY.clear()\n if not SKILLS_DIR.exists():\n return\n for directory in sorted(SKILLS_DIR.iterdir()):\n if not directory.is_dir():\n continue\n manifest = directory / \"SKILL.md\"\n if not manifest.exists():\n continue\n raw = manifest.read_text()\n meta, _ = _parse_frontmatter(raw)\n name = meta.get(\"name\", directory.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\n \"name\": name,\n \"description\": desc,\n \"content\": raw,\n }\n\n\nscan_skills()\n\n\ndef list_skills() -> str:\n if not SKILL_REGISTRY:\n return \"(no skills found)\"\n return \"\\n\".join(\n f\"- {skill['name']}: {skill['description']}\"\n for skill in SKILL_REGISTRY.values())\n\n\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n available = \", \".join(SKILL_REGISTRY.keys()) or \"(none)\"\n return f\"Skill not found: {name}. Available: {available}\"\n return skill[\"content\"]\n\n\n# ── Prompt Assembly ──\n\nPROMPT_SECTIONS = {\n \"identity\": \"You are a coding agent. Act, don't explain.\",\n \"tools\": \"Available tools: bash, read_file, write_file, edit_file, glob, \"\n \"todo_write, task, load_skill, compact, \"\n \"create_task, list_tasks, get_task, claim_task, complete_task, \"\n \"schedule_cron, list_crons, cancel_cron, \"\n \"spawn_teammate, send_message, \"\n \"request_shutdown, request_plan, review_plan, \"\n \"create_worktree, remove_worktree, keep_worktree, \"\n \"connect_mcp. MCP tools are prefixed mcp__{server}__{tool}.\",\n \"teams\": (\n \"When parallel work would help, first propose a small team with clear \"\n \"responsibilities and wait for the user's confirmation. Do not call \"\n \"spawn_teammate before the user confirms.\"\n ),\n \"workspace\": f\"Working directory: {WORKDIR}\",\n \"memory\": \"Relevant memories are injected below when available.\",\n}\n\n\ndef assemble_system_prompt(context: dict) -> str:\n # The system prompt is rebuilt each turn from live context. This is where\n # memory, skill catalog, MCP state, and active teammates become visible.\n sections = [PROMPT_SECTIONS[\"identity\"],\n PROMPT_SECTIONS[\"tools\"],\n PROMPT_SECTIONS[\"teams\"],\n PROMPT_SECTIONS[\"workspace\"]]\n sections.append(f\"Current time: {datetime.now().isoformat(timespec='seconds')}\")\n sections.append(\"Skills catalog:\\n\" + list_skills() +\n \"\\nUse load_skill(name) when a skill is relevant.\")\n if context.get(\"memories\"):\n sections.append(f\"Relevant memories:\\n{context['memories']}\")\n mcp_names = list(mcp_clients.keys())\n if mcp_names:\n sections.append(f\"Connected MCP servers: {', '.join(mcp_names)}\")\n return \"\\n\\n\".join(sections)\n\n\n# ── Basic Tools ──\n\ndef run_bash(command: str, cwd: Path = None,\n run_in_background: bool = False) -> str:\n # run_in_background is consumed by the dispatcher; direct execution ignores it.\n try:\n r = subprocess.run(command, shell=True, cwd=cwd or WORKDIR,\n capture_output=True, text=True, timeout=120)\n out = (r.stdout + r.stderr).strip()\n return out[:50000] if out else \"(no output)\"\n except subprocess.TimeoutExpired:\n return \"Error: Timeout (120s)\"\n\n\ndef run_read(path: str, limit: int | None = None,\n offset: int = 0, cwd: Path = None) -> str:\n try:\n base = cwd or WORKDIR\n file_path = (base / path).resolve()\n lines = file_path.read_text().splitlines()\n offset = max(int(offset or 0), 0)\n limit = int(limit) if limit is not None else None\n lines = lines[offset:]\n if limit is not None and limit < len(lines):\n lines = lines[:limit] + [f\"... ({len(lines) - limit} more lines)\"]\n return \"\\n\".join(lines)\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_write(path: str, content: str, cwd: Path = None) -> str:\n try:\n base = cwd or WORKDIR\n fp = (base / path).resolve()\n fp.parent.mkdir(parents=True, exist_ok=True)\n fp.write_text(content)\n return f\"Wrote {len(content)} bytes to {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_edit(path: str, old_text: str, new_text: str,\n cwd: Path = None) -> str:\n try:\n base = cwd or WORKDIR\n fp = (base / path).resolve()\n text = fp.read_text()\n if old_text not in text:\n return f\"Error: text not found in {path}\"\n fp.write_text(text.replace(old_text, new_text, 1))\n return f\"Edited {path}\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef run_glob(pattern: str, cwd: Path = None) -> str:\n import glob as g\n try:\n base = cwd or WORKDIR\n results = []\n for match in g.glob(pattern, root_dir=base):\n if (base / match).resolve().is_relative_to(base):\n results.append(match)\n return \"\\n\".join(results) if results else \"(no matches)\"\n except Exception as e:\n return f\"Error: {e}\"\n\n\ndef call_tool_handler(handler, args: dict, name: str) -> str:\n if not handler:\n return f\"Unknown: {name}\"\n try:\n return handler(**(args or {}))\n except TypeError as e:\n return f\"Error: {e}\"\n\n\ndef _normalize_todos(todos):\n if isinstance(todos, str):\n try:\n todos = json.loads(todos)\n except json.JSONDecodeError:\n try:\n todos = ast.literal_eval(todos)\n except (SyntaxError, ValueError):\n return None, \"Error: todos must be a list or JSON array string\"\n if not isinstance(todos, list):\n return None, \"Error: todos must be a list\"\n for i, todo in enumerate(todos):\n if not isinstance(todo, dict):\n return None, f\"Error: todos[{i}] must be an object\"\n if \"content\" not in todo or \"status\" not in todo:\n return None, f\"Error: todos[{i}] missing 'content' or 'status'\"\n if todo[\"status\"] not in (\"pending\", \"in_progress\", \"completed\"):\n return None, f\"Error: todos[{i}] has invalid status '{todo['status']}'\"\n return todos, None\n\ndef run_todo_write(todos: list) -> str:\n global CURRENT_TODOS\n todos, error = _normalize_todos(todos)\n if error:\n return error\n CURRENT_TODOS = todos\n print(f\" \\033[33m[todo] updated {len(CURRENT_TODOS)} item(s)\\033[0m\")\n return f\"Updated {len(CURRENT_TODOS)} todos\"\n\n\n# ── MessageBus (from s15) ──\n\nMAILBOX_DIR = WORKDIR / \".mailboxes\"\nMAILBOX_DIR.mkdir(exist_ok=True)\nMAILBOX_ROOT = MAILBOX_DIR.resolve()\nVALID_AGENT_NAME = re.compile(r\"^[A-Za-z0-9_-]{1,64}$\")\n\n\ndef is_valid_agent_name(name: str) -> bool:\n return bool(VALID_AGENT_NAME.fullmatch(name))\n\n\nclass MessageBus:\n def __init__(self):\n self._lock = threading.RLock()\n self._changed = threading.Condition(self._lock)\n\n def _path(self, agent: str) -> Path:\n if not is_valid_agent_name(agent):\n raise ValueError(f\"Invalid mailbox recipient: {agent!r}\")\n path = (MAILBOX_DIR / f\"{agent}.jsonl\").resolve()\n if not path.is_relative_to(MAILBOX_ROOT):\n raise ValueError(f\"Mailbox path escapes directory: {agent!r}\")\n return path\n\n def _read_unlocked(self, agent: str) -> list[dict]:\n inbox = self._path(agent)\n if not inbox.exists():\n return []\n msgs = [json.loads(line) for line in inbox.read_text().splitlines()\n if line.strip()]\n inbox.unlink()\n return msgs\n\n def send(self, from_agent: str, to_agent: str, content: str,\n msg_type: str = \"message\", metadata: dict | None = None):\n msg = {\"from\": from_agent, \"to\": to_agent,\n \"content\": content, \"type\": msg_type,\n \"ts\": time.time(), \"metadata\": metadata or {}}\n with self._changed:\n with open(self._path(to_agent), \"a\") as f:\n f.write(json.dumps(msg, ensure_ascii=False) + \"\\n\")\n self._changed.notify_all()\n print(f\" \\033[33m[bus] {from_agent} → {to_agent}: \"\n f\"({msg_type}) {content[:50]}\\033[0m\")\n\n def read_inbox(self, agent: str) -> list[dict]:\n with self._lock:\n return self._read_unlocked(agent)\n\n def peek(self, agent: str) -> bool:\n with self._lock:\n inbox = self._path(agent)\n return inbox.exists() and inbox.stat().st_size > 0\n\n def wait_for_messages(self, agent: str,\n timeout: float | None = None) -> list[dict]:\n deadline = None if timeout is None else time.monotonic() + timeout\n with self._changed:\n while not self.peek(agent):\n remaining = (None if deadline is None\n else deadline - time.monotonic())\n if remaining is not None and remaining <= 0:\n return []\n self._changed.wait(remaining)\n return self._read_unlocked(agent)\n\n\nBUS = MessageBus()\nactive_teammates: dict[str, str] = {}\nplan_gates: dict[str, str] = {}\nplan_request_ids: dict[str, str] = {}\nteam_lock = threading.RLock()\n\n# ── Protocol State (from s15) ──\n\n@dataclass\nclass ProtocolState:\n request_id: str\n type: str\n sender: str\n target: str\n status: str\n payload: str\n created_at: float = field(default_factory=time.time)\n\n\npending_requests: dict[str, ProtocolState] = {}\n\n\ndef new_request_id() -> str:\n while True:\n request_id = f\"req_{random.randint(0, 999999):06d}\"\n if request_id not in pending_requests:\n return request_id\n\n\ndef match_response(response_type: str, request_id: str, approve: bool,\n from_agent: str, to_agent: str) -> bool:\n with team_lock:\n state = pending_requests.get(request_id)\n if not state:\n print(f\" \\033[31m[protocol] unknown request_id: {request_id}\\033[0m\")\n return False\n expected = {\n \"shutdown\": \"shutdown_response\",\n \"plan_approval\": \"plan_approval_response\",\n }[state.type]\n if response_type != expected:\n print(f\" \\033[31m[protocol] expected {expected}, \"\n f\"got {response_type}\\033[0m\")\n return False\n if from_agent != state.target or to_agent != state.sender:\n print(f\" \\033[31m[protocol] {request_id} responder mismatch\\033[0m\")\n return False\n if state.status != \"pending\":\n return False\n state.status = \"approved\" if approve else \"rejected\"\n icon = \"✓\" if approve else \"✗\"\n color = \"32\" if approve else \"31\"\n print(f\" \\033[{color}m[protocol] {state.type} {icon} \"\n f\"({request_id}: {state.status})\\033[0m\")\n return True\n\n\ndef consume_lead_inbox(route_protocol=True) -> list[dict]:\n msgs = BUS.read_inbox(\"lead\")\n if route_protocol:\n for msg in msgs:\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n msg_type = msg.get(\"type\", \"\")\n if req_id and msg_type.endswith(\"_response\"):\n match_response(msg_type, req_id, meta.get(\"approve\", False),\n msg.get(\"from\", \"\"), msg.get(\"to\", \"\"))\n return msgs\n\n\ndef format_team_events(msgs: list[dict]) -> str:\n lines = []\n for msg in msgs:\n request_id = msg.get(\"metadata\", {}).get(\"request_id\")\n suffix = f\" request_id={request_id}\" if request_id else \"\"\n lines.append(\n f\"[{msg['type']}{suffix}] {msg['from']}: {msg['content']}\"\n )\n return \"[Team events]\\n\" + \"\\n\".join(lines)\n\n\n# ── Autonomous Agent (from s16, + worktree cwd) ──\n\nIDLE_SCAN_INTERVAL = 2.0\n\n\ndef scan_unclaimed_tasks() -> list[Task]:\n return [\n task for task in list_tasks()\n if (task.status == \"pending\"\n and task.owner is None\n and can_start(task.id))\n ]\n\n\ndef claim_next_task(name: str) -> Task | None:\n for task in scan_unclaimed_tasks():\n result = claim_task(task.id, owner=name)\n if result.startswith(\"Claimed \"):\n return load_task(task.id)\n return None\n\n\ndef _last_assistant_text(content) -> str:\n for block in content:\n if getattr(block, \"type\", None) == \"text\":\n return block.text.strip()\n if isinstance(block, dict) and block.get(\"type\") == \"text\":\n return str(block.get(\"text\", \"\")).strip()\n return \"\"\n\n\ndef _run_teammate_tool(name: str, block, handlers: dict) -> str:\n gate = plan_gates.get(name, \"not_required\")\n if (block.name in {\"bash\", \"write_file\"}\n and gate not in {\"not_required\", \"approved\"}):\n return f\"Blocked: plan status is {gate}.\"\n handler = handlers.get(block.name)\n return str(handler(**block.input)) if handler else f\"Unknown tool: {block.name}\"\n\n\ndef apply_plan_response(name: str, msg: dict) -> tuple[bool, str]:\n \"\"\"Apply only the Lead response for this teammate's current plan.\"\"\"\n metadata = msg.get(\"metadata\", {})\n request_id = metadata.get(\"request_id\", \"\")\n with team_lock:\n state = pending_requests.get(request_id)\n expected_id = plan_request_ids.get(name)\n valid = (\n msg.get(\"from\") == \"lead\"\n and msg.get(\"to\") == name\n and request_id == expected_id\n and state is not None\n and state.type == \"plan_approval\"\n and state.sender == name\n and state.target == \"lead\"\n and state.status in {\"approved\", \"rejected\"}\n and metadata.get(\"approve\", False)\n == (state.status == \"approved\")\n )\n if not valid:\n return False, \"[Ignored plan response: request mismatch]\"\n plan_gates[name] = state.status\n active_teammates[name] = \"working\"\n plan_request_ids.pop(name, None)\n outcome = state.status\n return True, f\"[Plan {outcome}] {msg['content']}\"\n\n\ndef apply_shutdown_request(name: str, msg: dict) -> tuple[bool, str]:\n \"\"\"Accept only a pending shutdown request sent by Lead to this teammate.\"\"\"\n request_id = msg.get(\"metadata\", {}).get(\"request_id\", \"\")\n with team_lock:\n state = pending_requests.get(request_id)\n valid = (\n msg.get(\"from\") == \"lead\"\n and msg.get(\"to\") == name\n and state is not None\n and state.type == \"shutdown\"\n and state.sender == \"lead\"\n and state.target == name\n and state.status == \"pending\"\n and active_teammates.get(name) != \"stopping\"\n )\n if not valid:\n return False, \"[Ignored shutdown request: request mismatch]\"\n active_teammates[name] = \"stopping\"\n return True, request_id\n\n\ndef _teammate_send_message(from_name: str, to: str, content: str) -> str:\n with team_lock:\n if to != \"lead\" and to not in active_teammates:\n return f\"Agent '{to}' is not active\"\n BUS.send(from_name, to, content)\n return f\"Sent to {to}\"\n\n\n# ── Teammate Thread ──\n\ndef spawn_teammate_thread(name: str, role: str, prompt: str) -> str:\n if not is_valid_agent_name(name):\n return (\"Invalid teammate name: use 1-64 letters, digits, \"\n \"underscores, or dashes\")\n with team_lock:\n if name in active_teammates:\n return f\"Teammate '{name}' already exists\"\n active_teammates[name] = \"working\"\n plan_gates[name] = \"not_required\"\n\n system = (f\"You are '{name}', a {role}. \"\n \"Use tools to complete tasks. \"\n \"You can list and claim tasks from the board. \"\n \"If a task has a worktree, work in that directory. \"\n \"When asked for a plan, submit it before bash or write_file \"\n \"and wait for approval.\")\n\n def handle_inbox_message(name: str, msg: dict, messages: list):\n msg_type = msg.get(\"type\", \"message\")\n meta = msg.get(\"metadata\", {})\n req_id = meta.get(\"request_id\", \"\")\n\n if msg_type == \"shutdown_request\":\n accepted, notice = apply_shutdown_request(name, msg)\n if not accepted:\n messages.append({\"role\": \"user\", \"content\": notice})\n return False\n req_id = notice\n BUS.send(name, \"lead\", \"Shutting down gracefully.\",\n \"shutdown_response\",\n {\"request_id\": req_id, \"approve\": True})\n print(f\" \\033[35m[protocol] {name} approved shutdown \"\n f\"({req_id})\\033[0m\")\n return True\n\n if msg_type == \"plan_approval_response\":\n _, notice = apply_plan_response(name, msg)\n messages.append({\"role\": \"user\",\n \"content\": notice})\n elif msg_type == \"plan_request\":\n messages.append({\"role\": \"user\",\n \"content\": f\"[Plan required] {msg['content']}\"})\n elif msg_type == \"message\":\n messages.append({\"role\": \"user\",\n \"content\": f\"[Message from {msg['from']}] {msg['content']}\"})\n return False\n\n def run():\n # Track current worktree for this teammate's cwd\n wt_ctx = {\"path\": None}\n\n def _wt_cwd() -> Path | None:\n p = wt_ctx[\"path\"]\n return Path(p) if p else None\n\n def _run_bash(command: str) -> str:\n return run_bash(command, cwd=_wt_cwd())\n\n def _run_read(path: str) -> str:\n return run_read(path, cwd=_wt_cwd())\n\n def _run_write(path: str, content: str) -> str:\n return run_write(path, content, cwd=_wt_cwd())\n\n def _run_list_tasks():\n tasks = list_tasks()\n if not tasks:\n return \"No tasks.\"\n return \"\\n\".join(\n f\" {t.id}: {t.subject} [{t.status}]\"\n + (f\" (wt:{t.worktree})\" if t.worktree else \"\")\n for t in tasks)\n\n def _run_claim_task(task_id: str):\n result = claim_task(task_id, owner=name)\n if \"Claimed\" in result:\n # Set worktree cwd if task has one\n task = load_task(task_id)\n if task.worktree:\n wt_ctx[\"path\"] = str(WORKTREES_DIR / task.worktree)\n else:\n wt_ctx[\"path\"] = None\n return result\n\n def _run_complete_task(task_id: str):\n result = complete_task(task_id)\n wt_ctx[\"path\"] = None\n return result\n\n messages = [{\"role\": \"user\", \"content\": prompt}]\n sub_tools = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"send_message\",\n \"description\": \"Send message to another agent.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"submit_plan\",\n \"description\": \"Submit a plan for Lead approval.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"plan\": {\"type\": \"string\"}},\n \"required\": [\"plan\"]}},\n {\"name\": \"list_tasks\",\n \"description\": \"List all tasks on the board.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n {\"name\": \"claim_task\",\n \"description\": \"Claim a pending task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\",\n \"description\": \"Mark an in-progress task as completed.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n ]\n\n sub_handlers = {\n \"bash\": _run_bash, \"read_file\": _run_read,\n \"write_file\": _run_write,\n \"send_message\": lambda to, content: _teammate_send_message(\n name, to, content),\n \"submit_plan\": lambda plan: _teammate_submit_plan(name, plan),\n \"list_tasks\": _run_list_tasks,\n \"claim_task\": _run_claim_task,\n \"complete_task\": _run_complete_task,\n }\n\n # Outer loop: WORK → IDLE cycle\n while True:\n if len(messages) <= 3:\n messages.insert(0, {\"role\": \"user\",\n \"content\": f\"You are '{name}', role: {role}. \"\n f\"Continue your work.\"})\n\n # WORK phase\n should_shutdown = False\n for _ in range(10):\n inbox = BUS.read_inbox(name)\n for msg in inbox:\n stopped = handle_inbox_message(name, msg, messages)\n if stopped:\n should_shutdown = True\n break\n if should_shutdown:\n break\n try:\n response = client.messages.create(\n model=MODEL, system=system, messages=messages[-20:],\n tools=sub_tools, max_tokens=8000)\n except Exception:\n break\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if response.stop_reason != \"tool_use\":\n summary = _last_assistant_text(response.content)\n gate = plan_gates.get(name, \"not_required\")\n if gate != \"pending\" and summary:\n BUS.send(name, \"lead\", summary, \"result\")\n if gate == \"pending\":\n with team_lock:\n active_teammates[name] = \"waiting_approval\"\n else:\n with team_lock:\n active_teammates[name] = \"idle\"\n BUS.send(name, \"lead\", \"Waiting for more work.\",\n \"idle_notification\")\n break\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n output = _run_teammate_tool(name, block, sub_handlers)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)})\n messages.append({\"role\": \"user\", \"content\": results})\n\n if should_shutdown:\n break\n\n # IDLE phase: messages take priority, then scan the task board.\n while True:\n inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL)\n if inbox:\n for msg in inbox:\n if handle_inbox_message(name, msg, messages):\n should_shutdown = True\n break\n if should_shutdown or messages[-1][\"role\"] == \"user\":\n break\n continue\n\n task = claim_next_task(name)\n if not task:\n continue\n wt_ctx[\"path\"] = (str(WORKTREES_DIR / task.worktree)\n if task.worktree else None)\n workdir = (f\"\\nWork directory: {wt_ctx['path']}\"\n if wt_ctx[\"path\"] else \"\")\n messages.append({\n \"role\": \"user\",\n \"content\": (\n f\"[Auto-claimed task {task.id}] \"\n f\"{task.subject}\\n{task.description}{workdir}\"\n ),\n })\n print(f\" \\033[32m[idle] {name} claimed \"\n f\"{task.id}: {task.subject}\\033[0m\")\n break\n if should_shutdown:\n break\n\n with team_lock:\n active_teammates.pop(name, None)\n plan_gates.pop(name, None)\n plan_request_ids.pop(name, None)\n print(f\" \\033[32m[teammate] {name} finished\\033[0m\")\n\n threading.Thread(target=run, daemon=True).start()\n print(f\" \\033[36m[teammate] {name} spawned as {role}\\033[0m\")\n return f\"Teammate '{name}' spawned as {role} (autonomous)\"\n\n\ndef _teammate_submit_plan(from_name: str, plan: str) -> str:\n with team_lock:\n if plan_gates.get(from_name) == \"pending\":\n return \"A plan is already waiting for review.\"\n req_id = new_request_id()\n pending_requests[req_id] = ProtocolState(\n request_id=req_id, type=\"plan_approval\",\n sender=from_name, target=\"lead\",\n status=\"pending\", payload=plan)\n plan_gates[from_name] = \"pending\"\n plan_request_ids[from_name] = req_id\n active_teammates[from_name] = \"waiting_approval\"\n BUS.send(from_name, \"lead\", plan,\n \"plan_approval_request\",\n {\"request_id\": req_id})\n return f\"Plan submitted ({req_id}). Waiting for approval...\"\n\n\n# ── Lead Protocol Tools (from s15) ──\n\ndef run_request_shutdown(teammate: str) -> str:\n if teammate not in active_teammates:\n return f\"Teammate '{teammate}' is not active\"\n with team_lock:\n req_id = new_request_id()\n pending_requests[req_id] = ProtocolState(\n request_id=req_id, type=\"shutdown\",\n sender=\"lead\", target=teammate,\n status=\"pending\", payload=\"\")\n BUS.send(\"lead\", teammate, \"Please shut down gracefully.\",\n \"shutdown_request\",\n {\"request_id\": req_id})\n print(f\" \\033[35m[protocol] shutdown_request → {teammate} \"\n f\"({req_id})\\033[0m\")\n return f\"Shutdown request sent to {teammate} (req: {req_id})\"\n\n\ndef run_request_plan(teammate: str, task: str) -> str:\n if teammate not in active_teammates:\n return f\"Teammate '{teammate}' is not active\"\n with team_lock:\n plan_gates[teammate] = \"required\"\n BUS.send(\"lead\", teammate, task, \"plan_request\")\n return f\"Asked {teammate} to submit a plan\"\n\n\ndef run_review_plan(request_id: str, approve: bool,\n feedback: str = \"\") -> str:\n with team_lock:\n state = pending_requests.get(request_id)\n if not state:\n return f\"Request {request_id} not found\"\n if state.type != \"plan_approval\":\n return f\"Request {request_id} is not a plan\"\n if state.status != \"pending\":\n return f\"Request {request_id} already {state.status}\"\n if plan_request_ids.get(state.sender) != request_id:\n return f\"Request {request_id} is not the current plan\"\n state.status = \"approved\" if approve else \"rejected\"\n BUS.send(\"lead\", state.sender,\n feedback or (\"Approved\" if approve else \"Rejected\"),\n \"plan_approval_response\",\n {\"request_id\": request_id, \"approve\": approve})\n icon = \"✓\" if approve else \"✗\"\n print(f\" \\033[32m[protocol] plan {icon} ({request_id})\\033[0m\")\n return f\"Plan {'approved' if approve else 'rejected'} ({request_id})\"\n\n\n# ── Hooks + Permission Pipeline ──\n\n# Hooks are intentionally outside tool handlers. The loop can add permission,\n# logging, and stop behavior without changing each individual tool.\nHOOKS = {\"UserPromptSubmit\": [], \"PreToolUse\": [],\n \"PostToolUse\": [], \"Stop\": []}\n\n\ndef register_hook(event: str, callback):\n HOOKS[event].append(callback)\n\n\ndef trigger_hooks(event: str, *args):\n for callback in HOOKS[event]:\n result = callback(*args)\n if result is not None:\n return result\n return None\n\n\nDENY_LIST = [\"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\", \"mkfs\", \"dd if=\"]\nDESTRUCTIVE = [\"rm \", \"> /etc/\", \"chmod 777\"]\n\n\ndef permission_hook(block):\n # The permission layer sees the raw tool_use before dispatch. It can deny,\n # ask the user, or allow execution to continue.\n if block.name == \"bash\":\n command = block.input.get(\"command\", \"\")\n for pattern in DENY_LIST:\n if pattern in command:\n return f\"Permission denied: '{pattern}' is on the deny list\"\n if any(token in command for token in DESTRUCTIVE):\n print(f\"\\n\\033[33m[permission] destructive command\\033[0m\")\n print(f\" {command}\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n if block.name in (\"read_file\", \"write_file\", \"edit_file\"):\n path = block.input.get(\"path\", \"\")\n if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):\n print(f\"\\n\\033[33m[permission] Access outside workspace\\033[0m\")\n print(f\" {block.name}: {path}\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n if block.name.startswith(\"mcp__\") and \"deploy\" in block.name:\n print(f\"\\n\\033[33m[permission] MCP destructive-looking tool: {block.name}\\033[0m\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n if choice not in (\"y\", \"yes\"):\n return \"Permission denied by user\"\n return None\n\n\ndef log_hook(block):\n print(f\"\\033[90m[HOOK] {block.name}\\033[0m\")\n return None\n\n\ndef large_output_hook(block, output):\n if len(str(output)) > 100000:\n print(f\"\\033[33m[HOOK] large output from {block.name}: \"\n f\"{len(str(output))} chars\\033[0m\")\n return None\n\n\ndef user_prompt_hook(query: str):\n print(f\"\\033[90m[HOOK] UserPromptSubmit: {WORKDIR}\\033[0m\")\n return None\n\n\ndef stop_hook(messages: list):\n tool_count = 0\n for msg in messages:\n content = msg.get(\"content\")\n if isinstance(content, list):\n tool_count += sum(1 for item in content\n if isinstance(item, dict)\n and item.get(\"type\") == \"tool_result\")\n print(f\"\\033[90m[HOOK] Stop: {tool_count} tool result(s)\\033[0m\")\n return None\n\n\nregister_hook(\"UserPromptSubmit\", user_prompt_hook)\nregister_hook(\"PreToolUse\", permission_hook)\nregister_hook(\"PreToolUse\", log_hook)\nregister_hook(\"PostToolUse\", large_output_hook)\nregister_hook(\"Stop\", stop_hook)\n\n\n# ── Subagent Tool ──\n\nSUB_SYSTEM = (\n f\"You are a coding subagent at {WORKDIR}. \"\n \"Complete the task, then return a concise final summary. \"\n \"Do not spawn more agents.\"\n)\n\n\nSUB_TOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"},\n \"offset\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"old_text\": {\"type\": \"string\"},\n \"new_text\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"pattern\": {\"type\": \"string\"}},\n \"required\": [\"pattern\"]}},\n]\n\n\nSUB_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read,\n \"write_file\": run_write, \"edit_file\": run_edit,\n \"glob\": run_glob,\n}\n\n\ndef extract_text(content) -> str:\n if not isinstance(content, list):\n return str(content)\n return \"\\n\".join(\n getattr(block, \"text\", \"\")\n for block in content\n if getattr(block, \"type\", None) == \"text\").strip()\n\n\ndef has_tool_use(content) -> bool:\n # Do not rely on stop_reason alone; the concrete tool_use block is the\n # continuation signal used by the loop.\n return any(getattr(block, \"type\", None) == \"tool_use\"\n for block in content)\n\n\ndef spawn_subagent(description: str) -> str:\n messages = [{\"role\": \"user\", \"content\": description}]\n for _ in range(30):\n response = client.messages.create(\n model=MODEL, system=SUB_SYSTEM, messages=messages,\n tools=SUB_TOOLS, max_tokens=8000)\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if not has_tool_use(response.content):\n break\n results = []\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n output = str(blocked)\n else:\n handler = SUB_HANDLERS.get(block.name)\n output = call_tool_handler(handler, block.input, block.name)\n trigger_hooks(\"PostToolUse\", block, output)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(output)})\n messages.append({\"role\": \"user\", \"content\": results})\n for msg in reversed(messages):\n if msg[\"role\"] == \"assistant\":\n text = extract_text(msg[\"content\"])\n if text:\n return text\n return \"Subagent finished without a text summary.\"\n\n\n# ── Context Compaction ──\n\n# Compaction is layered: first shrink oversized tool results, then trim old\n# message ranges, and only call the model for a summary when the context is\n# still too large or the model explicitly asks for compact.\ndef estimate_size(messages: list) -> int:\n return len(json.dumps(messages, default=str))\n\ndef block_type(block):\n return block.get(\"type\") if isinstance(block, dict) else getattr(block, \"type\", None)\n\n\ndef message_has_tool_use(message: dict) -> bool:\n if message.get(\"role\") != \"assistant\":\n return False\n content = message.get(\"content\")\n if not isinstance(content, list):\n return False\n return any(block_type(block) == \"tool_use\" for block in content)\n\n\ndef is_tool_result_message(message: dict) -> bool:\n if message.get(\"role\") != \"user\":\n return False\n content = message.get(\"content\")\n if not isinstance(content, list):\n return False\n return any(isinstance(block, dict) and block.get(\"type\") == \"tool_result\"\n for block in content)\n\n\ndef collect_tool_results(messages: list):\n found = []\n for mi, msg in enumerate(messages):\n content = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n continue\n for bi, block in enumerate(content):\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n found.append((mi, bi, block))\n return found\n\n\ndef persist_large_output(tool_use_id: str, output: str) -> str:\n if len(output) <= PERSIST_THRESHOLD:\n return output\n TOOL_RESULTS_DIR.mkdir(parents=True, exist_ok=True)\n path = TOOL_RESULTS_DIR / f\"{tool_use_id}.txt\"\n if not path.exists():\n path.write_text(output)\n return (f\"\\nFull output: {path}\\n\"\n f\"Preview:\\n{output[:2000]}\\n\")\n\n\ndef tool_result_budget(messages: list, max_bytes: int = 200_000) -> list:\n if not messages:\n return messages\n last = messages[-1]\n content = last.get(\"content\")\n if last.get(\"role\") != \"user\" or not isinstance(content, list):\n return messages\n blocks = [(i, b) for i, b in enumerate(content)\n if isinstance(b, dict) and b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes:\n return messages\n for _, block in sorted(blocks,\n key=lambda pair: len(str(pair[1].get(\"content\", \"\"))),\n reverse=True):\n if total <= max_bytes:\n break\n text = str(block.get(\"content\", \"\"))\n block[\"content\"] = persist_large_output(\n block.get(\"tool_use_id\", \"unknown\"), text)\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n return messages\n\n\ndef snip_compact(messages: list, max_messages: int = 50) -> list:\n if len(messages) <= max_messages:\n return messages\n head_end, tail_start = 3, len(messages) - (max_messages - 3)\n if head_end > 0 and message_has_tool_use(messages[head_end - 1]):\n while head_end < len(messages) and is_tool_result_message(messages[head_end]):\n head_end += 1\n if (tail_start > 0 and tail_start < len(messages)\n and is_tool_result_message(messages[tail_start])\n and message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n if head_end >= tail_start:\n return messages\n snipped = tail_start - head_end\n return (messages[:head_end]\n + [{\"role\": \"user\", \"content\": f\"[snipped {snipped} messages]\"}]\n + messages[tail_start:])\n\n\ndef micro_compact(messages: list) -> list:\n tool_results = collect_tool_results(messages)\n if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS:\n return messages\n for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]:\n if len(str(block.get(\"content\", \"\"))) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n\n\ndef write_transcript(messages: list) -> Path:\n TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)\n path = TRANSCRIPT_DIR / f\"transcript_{int(time.time())}.jsonl\"\n with path.open(\"w\") as f:\n for msg in messages:\n f.write(json.dumps(msg, default=str) + \"\\n\")\n return path\n\n\ndef summarize_history(messages: list) -> str:\n conversation = json.dumps(messages, default=str)[:80000]\n prompt = (\"Summarize this coding-agent conversation so work can continue. \"\n \"Preserve current goal, key findings, changed files, remaining work, \"\n \"and user constraints.\\n\\n\" + conversation)\n response = client.messages.create(\n model=MODEL,\n messages=[{\"role\": \"user\", \"content\": prompt}],\n max_tokens=2000)\n return extract_text(response.content) or \"(empty summary)\"\n\n\ndef compact_history(messages: list) -> list:\n transcript = write_transcript(messages)\n print(f\" \\033[36m[compact] transcript saved: {transcript}\\033[0m\")\n summary = summarize_history(messages)\n return [{\"role\": \"user\", \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n\n\ndef reactive_compact(messages: list) -> list:\n transcript = write_transcript(messages)\n print(f\" \\033[31m[reactive compact] transcript saved: {transcript}\\033[0m\")\n tail_start = max(0, len(messages) - 5)\n if (tail_start > 0 and tail_start < len(messages)\n and is_tool_result_message(messages[tail_start])\n and message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n try:\n summary = summarize_history(messages[:tail_start])\n except Exception:\n summary = \"Earlier conversation was trimmed after a prompt-too-long error.\"\n return [{\"role\": \"user\", \"content\": f\"[Reactive compact]\\n\\n{summary}\"},\n *messages[tail_start:]]\n\n\n# ── Error Recovery ──\n\nclass RecoveryState:\n def __init__(self):\n self.has_escalated = False\n self.recovery_count = 0\n self.consecutive_529 = 0\n self.has_attempted_reactive_compact = False\n self.current_model = PRIMARY_MODEL\n\n\ndef retry_delay(attempt: int) -> float:\n base = min(BASE_DELAY_MS * (2 ** attempt), 32000) / 1000\n return base + random.uniform(0, base * 0.25)\n\n\ndef with_retry(fn, state: RecoveryState):\n for attempt in range(MAX_RETRIES):\n try:\n result = fn()\n state.consecutive_529 = 0\n return result\n except Exception as e:\n name = type(e).__name__.lower()\n msg = str(e).lower()\n if \"ratelimit\" in name or \"429\" in msg:\n delay = retry_delay(attempt)\n print(f\" \\033[33m[429] retry {attempt + 1}/{MAX_RETRIES} \"\n f\"after {delay:.1f}s\\033[0m\")\n time.sleep(delay)\n continue\n if \"overloaded\" in name or \"529\" in msg or \"overloaded\" in msg:\n state.consecutive_529 += 1\n if state.consecutive_529 >= MAX_CONSECUTIVE_529 and FALLBACK_MODEL:\n state.current_model = FALLBACK_MODEL\n state.consecutive_529 = 0\n print(f\" \\033[31m[529] switching to {FALLBACK_MODEL}\\033[0m\")\n delay = retry_delay(attempt)\n print(f\" \\033[33m[529] retry {attempt + 1}/{MAX_RETRIES} \"\n f\"after {delay:.1f}s\\033[0m\")\n time.sleep(delay)\n continue\n raise\n raise RuntimeError(f\"Max retries ({MAX_RETRIES}) exceeded\")\n\n\ndef is_prompt_too_long_error(e: Exception) -> bool:\n msg = str(e).lower()\n return ((\"prompt\" in msg and \"long\" in msg)\n or \"context_length_exceeded\" in msg\n or \"max_context_window\" in msg)\n\n\n# ── Background Tasks ──\n\n# Slow tools return a placeholder tool_result immediately. Their real output is\n# later injected as a task_notification, so the main loop can keep moving.\n_bg_counter = 0\nbackground_tasks: dict[str, dict] = {}\nbackground_results: dict[str, str] = {}\nbackground_lock = threading.Lock()\n\n\ndef is_slow_operation(tool_name: str, tool_input: dict) -> bool:\n if tool_name != \"bash\":\n return False\n command = tool_input.get(\"command\", \"\").lower()\n slow_keywords = [\"install\", \"build\", \"test\", \"deploy\", \"compile\",\n \"docker build\", \"pip install\", \"npm install\",\n \"cargo build\", \"pytest\", \"make\"]\n return any(keyword in command for keyword in slow_keywords)\n\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n if tool_name != \"bash\":\n return False\n return bool(tool_input.get(\"run_in_background\")) or is_slow_operation(tool_name, tool_input)\n\n\ndef start_background_task(block, handlers: dict) -> str:\n global _bg_counter\n _bg_counter += 1\n bg_id = f\"bg_{_bg_counter:04d}\"\n command = block.input.get(\"command\", block.name)\n\n def worker():\n handler = handlers.get(block.name)\n result = call_tool_handler(handler, block.input, block.name)\n trigger_hooks(\"PostToolUse\", block, result)\n with background_lock:\n background_tasks[bg_id][\"status\"] = \"completed\"\n background_results[bg_id] = str(result)\n\n with background_lock:\n background_tasks[bg_id] = {\n \"tool_use_id\": block.id,\n \"command\": command,\n \"status\": \"running\",\n }\n threading.Thread(target=worker, daemon=True).start()\n print(f\" \\033[33m[background] {bg_id}: {str(command)[:60]}\\033[0m\")\n return bg_id\n\n\ndef collect_background_results() -> list[str]:\n with background_lock:\n ready = [bg_id for bg_id, task in background_tasks.items()\n if task[\"status\"] == \"completed\"]\n notifications = []\n for bg_id in ready:\n with background_lock:\n task = background_tasks.pop(bg_id)\n output = background_results.pop(bg_id, \"\")\n summary = output[:200] if len(output) > 200 else output\n notifications.append(\n f\"\\n\"\n f\" {bg_id}\\n\"\n f\" completed\\n\"\n f\" {task['command']}\\n\"\n f\" {summary}\\n\"\n f\"\")\n return notifications\n\n\n# ── Cron Scheduler ──\n\n# Cron jobs are stored separately from conversation history. When a job fires,\n# it becomes a scheduled prompt that is injected back into the same agent loop.\nDURABLE_PATH = WORKDIR / \".scheduled_tasks.json\"\n\n\n@dataclass\nclass CronJob:\n id: str\n cron: str\n prompt: str\n recurring: bool\n durable: bool\n\n\nscheduled_jobs: dict[str, CronJob] = {}\ncron_queue: list[CronJob] = []\ncron_lock = threading.Lock()\n_last_fired: dict[str, str] = {}\n\n\ndef _cron_field_matches(field: str, value: int) -> bool:\n if field == \"*\":\n return True\n if field.startswith(\"*/\"):\n step = int(field[2:])\n return step > 0 and value % step == 0\n if \",\" in field:\n return any(_cron_field_matches(part.strip(), value)\n for part in field.split(\",\"))\n if \"-\" in field:\n lo, hi = field.split(\"-\", 1)\n return int(lo) <= value <= int(hi)\n return value == int(field)\n\n\ndef cron_matches(cron_expr: str, dt: datetime) -> bool:\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return False\n minute, hour, dom, month, dow = fields\n dow_val = (dt.weekday() + 1) % 7\n m = _cron_field_matches(minute, dt.minute)\n h = _cron_field_matches(hour, dt.hour)\n dom_ok = _cron_field_matches(dom, dt.day)\n month_ok = _cron_field_matches(month, dt.month)\n dow_ok = _cron_field_matches(dow, dow_val)\n if not (m and h and month_ok):\n return False\n if dom == \"*\" and dow == \"*\":\n return True\n if dom == \"*\":\n return dow_ok\n if dow == \"*\":\n return dom_ok\n return dom_ok or dow_ok\n\n\ndef _validate_cron_field(field: str, lo: int, hi: int) -> str | None:\n if field == \"*\":\n return None\n if field.startswith(\"*/\"):\n step = field[2:]\n if not step.isdigit() or int(step) <= 0:\n return f\"Invalid step: {field}\"\n return None\n if \",\" in field:\n for part in field.split(\",\"):\n err = _validate_cron_field(part.strip(), lo, hi)\n if err:\n return err\n return None\n if \"-\" in field:\n left, right = field.split(\"-\", 1)\n if not left.isdigit() or not right.isdigit():\n return f\"Invalid range: {field}\"\n a, b = int(left), int(right)\n if a < lo or a > hi or b < lo or b > hi:\n return f\"Range {field} out of bounds [{lo}-{hi}]\"\n if a > b:\n return f\"Range start > end: {field}\"\n return None\n if not field.isdigit():\n return f\"Invalid field: {field}\"\n value = int(field)\n if value < lo or value > hi:\n return f\"Value {value} out of bounds [{lo}-{hi}]\"\n return None\n\n\ndef validate_cron(cron_expr: str) -> str | None:\n fields = cron_expr.strip().split()\n if len(fields) != 5:\n return f\"Expected 5 fields, got {len(fields)}\"\n bounds = [(0, 59), (0, 23), (1, 31), (1, 12), (0, 6)]\n names = [\"minute\", \"hour\", \"day-of-month\", \"month\", \"day-of-week\"]\n for field, (lo, hi), name in zip(fields, bounds, names):\n err = _validate_cron_field(field, lo, hi)\n if err:\n return f\"{name}: {err}\"\n return None\n\n\ndef save_durable_jobs():\n durable = [asdict(job) for job in scheduled_jobs.values() if job.durable]\n DURABLE_PATH.write_text(json.dumps(durable, indent=2))\n\n\ndef load_durable_jobs():\n if not DURABLE_PATH.exists():\n return\n try:\n for item in json.loads(DURABLE_PATH.read_text()):\n job = CronJob(**item)\n if not validate_cron(job.cron):\n scheduled_jobs[job.id] = job\n except Exception:\n pass\n\n\ndef schedule_job(cron: str, prompt: str,\n recurring: bool = True, durable: bool = True) -> CronJob | str:\n err = validate_cron(cron)\n if err:\n return err\n job = CronJob(\n id=f\"cron_{random.randint(0, 999999):06d}\",\n cron=cron, prompt=prompt,\n recurring=recurring, durable=durable)\n with cron_lock:\n scheduled_jobs[job.id] = job\n if durable:\n save_durable_jobs()\n return job\n\n\ndef cancel_job(job_id: str) -> str:\n with cron_lock:\n job = scheduled_jobs.pop(job_id, None)\n if not job:\n return f\"Job {job_id} not found\"\n if job.durable:\n save_durable_jobs()\n return f\"Cancelled {job_id}\"\n\n\ndef cron_scheduler_loop():\n while True:\n time.sleep(1)\n now = datetime.now()\n marker = now.strftime(\"%Y-%m-%d %H:%M\")\n with cron_lock:\n for job in list(scheduled_jobs.values()):\n try:\n if cron_matches(job.cron, now) and _last_fired.get(job.id) != marker:\n cron_queue.append(job)\n _last_fired[job.id] = marker\n if not job.recurring:\n scheduled_jobs.pop(job.id, None)\n if job.durable:\n save_durable_jobs()\n except Exception as e:\n print(f\" \\033[31m[cron error] {job.id}: {e}\\033[0m\")\n\n\ndef consume_cron_queue() -> list[CronJob]:\n with cron_lock:\n fired = list(cron_queue)\n cron_queue.clear()\n return fired\n\n\ndef run_schedule_cron(cron: str, prompt: str,\n recurring: bool = True, durable: bool = True) -> str:\n result = schedule_job(cron, prompt, recurring, durable)\n if isinstance(result, str):\n return f\"Error: {result}\"\n return f\"Scheduled {result.id}: '{cron}' -> {prompt}\"\n\n\ndef run_list_crons() -> str:\n with cron_lock:\n jobs = list(scheduled_jobs.values())\n if not jobs:\n return \"No cron jobs.\"\n return \"\\n\".join(\n f\" {job.id}: '{job.cron}' -> {job.prompt[:40]} \"\n f\"[{'recurring' if job.recurring else 'one-shot'}, \"\n f\"{'durable' if job.durable else 'session'}]\"\n for job in jobs)\n\n\ndef run_cancel_cron(job_id: str) -> str:\n return cancel_job(job_id)\n\n\nload_durable_jobs()\nthreading.Thread(target=cron_scheduler_loop, daemon=True).start()\n\n\n# ── MCP System ──\n\n# MCP is modeled as late-bound tools: connect first, then discovered server\n# tools are merged into the normal tool pool with mcp__server__tool names.\nclass MCPClient:\n \"\"\"Discovers and calls tools on an in-process MCP server.\"\"\"\n\n def __init__(self, name: str):\n self.name = name\n self.tools: list[dict] = []\n self._handlers: dict[str, callable] = {}\n\n def register(self, tool_defs: list[dict],\n handlers: dict[str, callable]):\n self.tools = tool_defs\n self._handlers = handlers\n\n def call_tool(self, tool_name: str, args: dict) -> str:\n handler = self._handlers.get(tool_name)\n if not handler:\n return f\"MCP error: unknown tool '{tool_name}'\"\n try:\n return handler(**args)\n except Exception as e:\n return f\"MCP error: {e}\"\n\n\nmcp_clients: dict[str, MCPClient] = {}\n\n_DISALLOWED_CHARS = re.compile(r'[^a-zA-Z0-9_-]')\n\n\ndef normalize_mcp_name(name: str) -> str:\n \"\"\"Replace non [a-zA-Z0-9_-] with underscore.\"\"\"\n return _DISALLOWED_CHARS.sub('_', name)\n\n\ndef _mock_server_docs():\n client = MCPClient(\"docs\")\n client.register(\n tool_defs=[\n {\"name\": \"search\", \"description\": \"Search documentation. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"query\": {\"type\": \"string\"}},\n \"required\": [\"query\"]}},\n {\"name\": \"get_version\", \"description\": \"Get API version. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\", \"properties\": {},\n \"required\": []}},\n ],\n handlers={\n \"search\": lambda query: f\"[docs] Found 3 results for '{query}'\",\n \"get_version\": lambda: \"[docs] API v2.1.0\",\n })\n return client\n\n\ndef _mock_server_deploy():\n client = MCPClient(\"deploy\")\n client.register(\n tool_defs=[\n {\"name\": \"trigger\",\n \"description\": \"Trigger a deployment. (destructive; requires approval)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"service\": {\"type\": \"string\"}},\n \"required\": [\"service\"]}},\n {\"name\": \"status\", \"description\": \"Check deployment status. (readOnly)\",\n \"inputSchema\": {\"type\": \"object\",\n \"properties\": {\"service\": {\"type\": \"string\"}},\n \"required\": [\"service\"]}},\n ],\n handlers={\n \"trigger\": lambda service: f\"[deploy] Triggered: {service}\",\n \"status\": lambda service: f\"[deploy] {service}: running (v1.4.2)\",\n })\n return client\n\n\nMOCK_SERVERS = {\n \"docs\": _mock_server_docs,\n \"deploy\": _mock_server_deploy,\n}\n\n\ndef connect_mcp(name: str) -> str:\n if name in mcp_clients:\n return f\"MCP server '{name}' already connected\"\n factory = MOCK_SERVERS.get(name)\n if not factory:\n available = \", \".join(MOCK_SERVERS.keys())\n return f\"Unknown server '{name}'. Available: {available}\"\n mcp_client = factory()\n mcp_clients[name] = mcp_client\n tool_names = [t[\"name\"] for t in mcp_client.tools]\n print(f\" \\033[31m[mcp] connected: {name} → {tool_names}\\033[0m\")\n return (f\"Connected to MCP server '{name}'. \"\n f\"Discovered {len(mcp_client.tools)} tools: {', '.join(tool_names)}\")\n\n\ndef assemble_tool_pool() -> tuple[list[dict], dict]:\n \"\"\"Merge builtin tools + all MCP tools into one pool.\"\"\"\n tools = list(BUILTIN_TOOLS)\n handlers = dict(BUILTIN_HANDLERS)\n for server_name, mcp_client in mcp_clients.items():\n safe_server = normalize_mcp_name(server_name)\n for tool_def in mcp_client.tools:\n safe_tool = normalize_mcp_name(tool_def[\"name\"])\n prefixed = f\"mcp__{safe_server}__{safe_tool}\"\n tools.append({\n \"name\": prefixed,\n \"description\": tool_def.get(\"description\", \"\"),\n \"input_schema\": tool_def.get(\"inputSchema\", {}),\n })\n handlers[prefixed] = (\n lambda *, c=mcp_client, t=tool_def[\"name\"], **kw: c.call_tool(t, kw))\n return tools, handlers\n\n\n# ── Lead Worktree Tools ──\n\ndef run_create_worktree(name: str, task_id: str = \"\") -> str:\n return create_worktree(name, task_id)\n\ndef run_remove_worktree(name: str, discard_changes: bool = False) -> str:\n return remove_worktree(name, discard_changes)\n\ndef run_keep_worktree(name: str) -> str:\n return keep_worktree(name)\n\n\n# ── Basic tool handlers ──\n\ndef run_create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> str:\n task = create_task(subject, description, blockedBy)\n deps = f\" (blockedBy: {', '.join(blockedBy)})\" if blockedBy else \"\"\n print(f\" \\033[34m[create] {task.subject}{deps}\\033[0m\")\n return f\"Created {task.id}: {task.subject}{deps}\"\n\n\ndef run_list_tasks() -> str:\n tasks = list_tasks()\n if not tasks:\n return \"No tasks.\"\n return \"\\n\".join(\n f\" {t.id}: {t.subject} [{t.status}]\"\n + (f\" (wt:{t.worktree})\" if t.worktree else \"\")\n for t in tasks)\n\n\ndef run_get_task(task_id: str) -> str:\n try:\n return get_task_json(task_id)\n except FileNotFoundError:\n return f\"Error: task {task_id} not found\"\n\ndef run_claim_task(task_id: str) -> str:\n try:\n return claim_task(task_id, owner=\"agent\")\n except FileNotFoundError:\n return f\"Error: task {task_id} not found\"\n\ndef run_complete_task(task_id: str) -> str:\n try:\n return complete_task(task_id)\n except FileNotFoundError:\n return f\"Error: task {task_id} not found\"\n\ndef run_spawn_teammate(name: str, role: str, prompt: str) -> str:\n return spawn_teammate_thread(name, role, prompt)\n\ndef run_send_message(to: str, content: str) -> str:\n if to not in active_teammates:\n return f\"Teammate '{to}' is not active\"\n BUS.send(\"lead\", to, content)\n return f\"Sent to {to}\"\n\ndef run_connect_mcp(name: str) -> str:\n return connect_mcp(name)\n\n\n# ── Tool Definitions ──\n\n# The model sees tool schemas; Python executes handlers. S19 keeps both tables\n# explicit so every added capability is visible in one place.\nBUILTIN_TOOLS = [\n {\"name\": \"bash\", \"description\": \"Run a shell command.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"command\": {\"type\": \"string\"},\n \"run_in_background\": {\"type\": \"boolean\"}},\n \"required\": [\"command\"]}},\n {\"name\": \"read_file\", \"description\": \"Read file contents.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"limit\": {\"type\": \"integer\"},\n \"offset\": {\"type\": \"integer\"}},\n \"required\": [\"path\"]}},\n {\"name\": \"write_file\", \"description\": \"Write content to a file.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"content\"]}},\n {\"name\": \"edit_file\", \"description\": \"Replace exact text in a file once.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"path\": {\"type\": \"string\"},\n \"old_text\": {\"type\": \"string\"},\n \"new_text\": {\"type\": \"string\"}},\n \"required\": [\"path\", \"old_text\", \"new_text\"]}},\n {\"name\": \"glob\", \"description\": \"Find files matching a glob pattern.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"pattern\": {\"type\": \"string\"}},\n \"required\": [\"pattern\"]}},\n {\"name\": \"todo_write\",\n \"description\": \"Create and manage a task list for the current session.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"todos\": {\"type\": \"array\",\n \"items\": {\"type\": \"object\",\n \"properties\": {\n \"content\": {\"type\": \"string\"},\n \"status\": {\"type\": \"string\",\n \"enum\": [\"pending\", \"in_progress\", \"completed\"]}},\n \"required\": [\"content\", \"status\"]}}},\n \"required\": [\"todos\"]}},\n {\"name\": \"task\",\n \"description\": \"Launch a focused subagent. Returns only its final summary.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"description\": {\"type\": \"string\"}},\n \"required\": [\"description\"]}},\n {\"name\": \"load_skill\",\n \"description\": \"Load the full content of a skill by name.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"compact\",\n \"description\": \"Summarize earlier conversation and continue with compacted context.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"focus\": {\"type\": \"string\"}},\n \"required\": []}},\n {\"name\": \"create_task\", \"description\": \"Create a task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"subject\": {\"type\": \"string\"},\n \"description\": {\"type\": \"string\"},\n \"blockedBy\": {\"type\": \"array\",\n \"items\": {\"type\": \"string\"}}},\n \"required\": [\"subject\"]}},\n {\"name\": \"list_tasks\", \"description\": \"List all tasks.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {}, \"required\": []}},\n {\"name\": \"get_task\", \"description\": \"Get full task details.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"claim_task\", \"description\": \"Claim a pending task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"complete_task\", \"description\": \"Complete an in-progress task.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"task_id\": {\"type\": \"string\"}},\n \"required\": [\"task_id\"]}},\n {\"name\": \"schedule_cron\",\n \"description\": (\"Schedule a cron job. cron is 5-field: min hour dom \"\n \"month dow. For one-shot reminders, compute the target \"\n \"minute and set recurring=false.\"),\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"cron\": {\"type\": \"string\"},\n \"prompt\": {\"type\": \"string\"},\n \"recurring\": {\"type\": \"boolean\"},\n \"durable\": {\"type\": \"boolean\"}},\n \"required\": [\"cron\", \"prompt\"]}},\n {\"name\": \"list_crons\", \"description\": \"List registered cron jobs.\",\n \"input_schema\": {\"type\": \"object\", \"properties\": {}, \"required\": []}},\n {\"name\": \"cancel_cron\", \"description\": \"Cancel a cron job by ID.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"job_id\": {\"type\": \"string\"}},\n \"required\": [\"job_id\"]}},\n {\"name\": \"spawn_teammate\", \"description\": \"Spawn an autonomous teammate.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\n \"type\": \"string\",\n \"pattern\": \"^[A-Za-z0-9_-]{1,64}$\",\n },\n \"role\": {\"type\": \"string\"},\n \"prompt\": {\"type\": \"string\"}},\n \"required\": [\"name\", \"role\", \"prompt\"]}},\n {\"name\": \"send_message\", \"description\": \"Send message to a teammate.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"to\": {\"type\": \"string\"},\n \"content\": {\"type\": \"string\"}},\n \"required\": [\"to\", \"content\"]}},\n {\"name\": \"request_shutdown\",\n \"description\": \"Request a teammate to shut down.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"teammate\": {\"type\": \"string\"}},\n \"required\": [\"teammate\"]}},\n {\"name\": \"request_plan\",\n \"description\": \"Ask a teammate to submit a plan.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"teammate\": {\"type\": \"string\"},\n \"task\": {\"type\": \"string\"}},\n \"required\": [\"teammate\", \"task\"]}},\n {\"name\": \"review_plan\",\n \"description\": \"Approve or reject a submitted plan.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"request_id\": {\"type\": \"string\"},\n \"approve\": {\"type\": \"boolean\"},\n \"feedback\": {\"type\": \"string\"}},\n \"required\": [\"request_id\", \"approve\"]}},\n {\"name\": \"create_worktree\",\n \"description\": \"Create an isolated git worktree.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"},\n \"task_id\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"remove_worktree\",\n \"description\": \"Remove a worktree. Refuses if changes exist.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"},\n \"discard_changes\": {\"type\": \"boolean\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"keep_worktree\",\n \"description\": \"Keep a worktree for manual review.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n {\"name\": \"connect_mcp\",\n \"description\": \"Connect to an MCP server (docs, deploy) and discover tools.\",\n \"input_schema\": {\"type\": \"object\",\n \"properties\": {\"name\": {\"type\": \"string\"}},\n \"required\": [\"name\"]}},\n]\n\nBUILTIN_HANDLERS = {\n \"bash\": run_bash, \"read_file\": run_read, \"write_file\": run_write,\n \"edit_file\": run_edit, \"glob\": run_glob,\n \"todo_write\": run_todo_write, \"task\": spawn_subagent,\n \"load_skill\": load_skill,\n \"create_task\": run_create_task, \"list_tasks\": run_list_tasks,\n \"get_task\": run_get_task,\n \"claim_task\": run_claim_task, \"complete_task\": run_complete_task,\n \"schedule_cron\": run_schedule_cron,\n \"list_crons\": run_list_crons,\n \"cancel_cron\": run_cancel_cron,\n \"spawn_teammate\": run_spawn_teammate,\n \"send_message\": run_send_message,\n \"request_shutdown\": run_request_shutdown,\n \"request_plan\": run_request_plan, \"review_plan\": run_review_plan,\n \"create_worktree\": run_create_worktree,\n \"remove_worktree\": run_remove_worktree,\n \"keep_worktree\": run_keep_worktree,\n \"connect_mcp\": run_connect_mcp,\n}\n\n\n# ── Context ──\n\nMEMORY_DIR = WORKDIR / \".memory\"\nMEMORY_INDEX = MEMORY_DIR / \"MEMORY.md\"\n\n\ndef update_context(context: dict, messages: list) -> dict:\n memories = \"\"\n if MEMORY_INDEX.exists():\n memories = MEMORY_INDEX.read_text()[:2000]\n return {\n \"memories\": memories,\n \"connected_mcp\": list(mcp_clients.keys()),\n \"active_teammates\": list(active_teammates.keys()),\n }\n\n\n# ── Agent Loop ──\n\nrounds_since_todo = 0\nagent_lock = threading.Lock()\n\n\ndef prepare_context(messages: list) -> list:\n # Every LLM turn enters through the same context budget pipeline.\n messages[:] = tool_result_budget(messages)\n messages[:] = snip_compact(messages)\n messages[:] = micro_compact(messages)\n if estimate_size(messages) > CONTEXT_LIMIT:\n messages[:] = compact_history(messages)\n return messages\n\n\ndef build_user_content(results: list[dict]) -> list[dict]:\n # Tool results and completed background notifications are both returned to\n # the model as user-side content, matching the tool_result feedback loop.\n content = list(results)\n for note in collect_background_results():\n content.append({\"type\": \"text\", \"text\": note})\n return content\n\n\ndef inject_background_notifications(messages: list):\n notes = collect_background_results()\n if notes:\n messages.append({\"role\": \"user\", \"content\": [\n {\"type\": \"text\", \"text\": note} for note in notes]})\n\n\ndef call_llm(messages: list, context: dict, tools: list,\n state: RecoveryState, max_tokens: int):\n system = assemble_system_prompt(context)\n return with_retry(\n lambda: client.messages.create(\n model=state.current_model,\n system=system,\n messages=messages,\n tools=tools,\n max_tokens=max_tokens),\n state)\n\n\ndef agent_loop(messages: list, context: dict):\n global rounds_since_todo\n tools, handlers = assemble_tool_pool()\n state = RecoveryState()\n max_tokens = DEFAULT_MAX_TOKENS\n\n while True:\n # One cycle: inject scheduled/background work, prepare context, call\n # the model, execute tool_use blocks, append tool_results, repeat.\n fired = consume_cron_queue()\n for job in fired:\n messages.append({\"role\": \"user\",\n \"content\": f\"[Scheduled] {job.prompt}\"})\n print(f\" \\033[35m[cron inject] {job.prompt[:60]}\\033[0m\")\n\n inject_background_notifications(messages)\n\n if rounds_since_todo >= 3:\n messages.append({\"role\": \"user\",\n \"content\": \"Update your todos.\"})\n rounds_since_todo = 0\n\n prepare_context(messages)\n context = update_context(context, messages)\n tools, handlers = assemble_tool_pool()\n\n try:\n response = call_llm(messages, context, tools, state, max_tokens)\n except Exception as e:\n if is_prompt_too_long_error(e) and not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n messages.append({\"role\": \"assistant\", \"content\": [\n {\"type\": \"text\", \"text\": f\"[Error] {type(e).__name__}: {e}\"}]})\n return\n\n if response.stop_reason == \"max_tokens\":\n if not state.has_escalated:\n max_tokens = ESCALATED_MAX_TOKENS\n state.has_escalated = True\n print(f\" \\033[33m[max_tokens] retry with {max_tokens}\\033[0m\")\n continue\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if state.recovery_count < MAX_RECOVERY_RETRIES:\n messages.append({\"role\": \"user\", \"content\": CONTINUATION_PROMPT})\n state.recovery_count += 1\n continue\n return\n\n max_tokens = DEFAULT_MAX_TOKENS\n state.has_escalated = False\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if not has_tool_use(response.content):\n trigger_hooks(\"Stop\", messages)\n return\n\n results = []\n compacted_now = False\n for block in response.content:\n if block.type != \"tool_use\":\n continue\n print(f\"\\033[36m> {block.name}\\033[0m\")\n\n if block.name == \"compact\":\n messages[:] = compact_history(messages)\n messages.append({\"role\": \"user\",\n \"content\": \"[Compacted. Continue with summarized context.]\"})\n compacted_now = True\n break\n\n blocked = trigger_hooks(\"PreToolUse\", block)\n if blocked:\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": str(blocked)})\n continue\n\n if should_run_background(block.name, block.input):\n bg_id = start_background_task(block, handlers)\n output = (f\"[Background task {bg_id} started] \"\n \"Result will arrive as a task_notification.\")\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output})\n continue\n\n handler = handlers.get(block.name)\n output = call_tool_handler(handler, block.input, block.name)\n trigger_hooks(\"PostToolUse\", block, output)\n print(str(output)[:300])\n\n if block.name == \"todo_write\":\n rounds_since_todo = 0\n else:\n rounds_since_todo += 1\n\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n\n if compacted_now:\n continue\n\n messages.append({\"role\": \"user\", \"content\": build_user_content(results)})\n\n\ndef print_turn_assistants(messages: list, turn_start: int):\n for msg in messages[turn_start:]:\n if msg.get(\"role\") != \"assistant\":\n continue\n for block in msg.get(\"content\", []):\n if block_type(block) == \"text\":\n terminal_print(block[\"text\"] if isinstance(block, dict) else block.text)\n\n\ndef async_event_loop(history: list, context: dict):\n while True:\n time.sleep(1)\n with agent_lock:\n fired = consume_cron_queue()\n inbox = consume_lead_inbox(route_protocol=True)\n if not fired and not inbox:\n continue\n turn_start = len(history)\n for job in fired:\n history.append({\"role\": \"user\",\n \"content\": f\"[Scheduled] {job.prompt}\"})\n terminal_print(\n f\" \\033[35m[cron auto] {job.prompt[:60]}\\033[0m\")\n if inbox:\n history.append({\"role\": \"user\",\n \"content\": format_team_events(inbox)})\n terminal_print(\n f\" \\033[33m[team auto] {len(inbox)} events\\033[0m\")\n agent_loop(history, context)\n context.update(update_context(context, history))\n print_turn_assistants(history, turn_start)\n\n\nif __name__ == \"__main__\":\n CLI_ACTIVE = True\n print(\"s19: integrated harness\")\n print(\"Enter a question, press Enter to send. Type q to quit.\\n\")\n history = []\n context = update_context({}, [])\n threading.Thread(target=async_event_loop,\n args=(history, context), daemon=True).start()\n while True:\n try:\n query = input(PROMPT)\n except (EOFError, KeyboardInterrupt):\n break\n if query.strip().lower() in (\"q\", \"exit\", \"\"):\n break\n trigger_hooks(\"UserPromptSubmit\", query)\n turn_start = len(history)\n history.append({\"role\": \"user\", \"content\": query})\n with agent_lock:\n agent_loop(history, context)\n context = update_context(context, history)\n print_turn_assistants(history, turn_start)\n print()\n", "images": [ { "src": "/course-assets/s19_comprehensive/system-architecture.svg", diff --git a/web/src/data/scenarios/s19.json b/web/src/data/scenarios/s19.json index f944758a..ae3b1e7e 100644 --- a/web/src/data/scenarios/s19.json +++ b/web/src/data/scenarios/s19.json @@ -1,7 +1,7 @@ { "version": "s19", - "title": "Comprehensive Agent", - "description": "The final agent composes context assembly, tools, memory, teams, background work, cron, worktrees, and MCP.", + "title": "Integrated Harness", + "description": "The harness composes context assembly, tools, memory, teams, background work, cron, worktrees, and MCP.", "steps": [ { "type": "user_message", @@ -51,7 +51,7 @@ { "type": "assistant_text", "content": "Release preparation is ready: checks passed, docs are updated in the teammate worktree, and deployment status is available.", - "annotation": "The comprehensive agent returns a single user-facing outcome." + "annotation": "The integrated harness returns a single user-facing outcome." } ] } diff --git a/web/src/i18n/messages/en.json b/web/src/i18n/messages/en.json index 7343aa0a..afb5099a 100644 --- a/web/src/i18n/messages/en.json +++ b/web/src/i18n/messages/en.json @@ -57,7 +57,7 @@ "s16": "Autonomous Agents", "s17": "Worktree Isolation", "s18": "MCP Tools", - "s19": "Comprehensive Agent", + "s19": "Integrated Harness", "s20": "Workflow Runtime", "s21": "Goal Loop" }, @@ -87,7 +87,7 @@ "s16": "Autonomous Agent Cycle", "s17": "Worktree Task Isolation", "s18": "MCP Tool Bridge", - "s19": "Comprehensive Agent Turn", + "s19": "Integrated Harness Turn", "s20": "Workflow Runtime", "s21": "Goal Completion Gate" } diff --git a/web/src/i18n/messages/ja.json b/web/src/i18n/messages/ja.json index 1fd906e3..592da531 100644 --- a/web/src/i18n/messages/ja.json +++ b/web/src/i18n/messages/ja.json @@ -57,7 +57,7 @@ "s16": "自律エージェント", "s17": "Worktree 分離", "s18": "MCP ツール", - "s19": "Comprehensive Agent", + "s19": "Integrated Harness", "s20": "Workflow Runtime", "s21": "Goal Loop" }, @@ -87,7 +87,7 @@ "s16": "自律エージェントサイクル", "s17": "Worktree タスク分離", "s18": "MCP ツールブリッジ", - "s19": "Comprehensive Agent Turn", + "s19": "Integrated Harness のターン", "s20": "Workflow Runtime", "s21": "目標完了ゲート" } diff --git a/web/src/i18n/messages/zh.json b/web/src/i18n/messages/zh.json index 58aec7f1..d5bfbc50 100644 --- a/web/src/i18n/messages/zh.json +++ b/web/src/i18n/messages/zh.json @@ -57,7 +57,7 @@ "s16": "Autonomous Agents", "s17": "Worktree Isolation", "s18": "MCP Tools", - "s19": "Comprehensive Agent", + "s19": "Agent Harness 集成", "s20": "Workflow Runtime", "s21": "Goal Loop" }, @@ -87,7 +87,7 @@ "s16": "Autonomous Agent Cycle", "s17": "Worktree Task Isolation", "s18": "MCP Tool Bridge", - "s19": "Comprehensive Agent Turn", + "s19": "Agent Harness 集成流程", "s20": "Workflow Runtime", "s21": "目标完成闸门" } diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 7fb5a563..27408eef 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -181,10 +181,10 @@ export const VERSION_META: Record