From 2affb3f345582fe7e617a7de72adf6809e28f0cf Mon Sep 17 00:00:00 2001 From: Haoran Date: Fri, 31 Jul 2026 03:54:12 +0800 Subject: [PATCH] docs: simplify Chinese course prose --- s01_agent_loop/README.zh.md | 2 +- s03_permission/README.zh.md | 8 ++++---- s07_skill_loading/README.zh.md | 2 +- s08_context_compact/README.zh.md | 4 ++-- s11_error_recovery/README.zh.md | 12 ++++++------ s12_task_system/README.zh.md | 2 +- s13_background_tasks/README.zh.md | 2 +- s15_agent_teams/README.zh.md | 2 +- s16_autonomous_agents/README.zh.md | 2 +- s17_worktree_isolation/README.zh.md | 10 +++++----- s18_mcp_plugin/README.zh.md | 6 +++--- s19_comprehensive/README.zh.md | 2 +- s20_workflow_runtime/README.zh.md | 4 ++-- s21_goal_loop/README.zh.md | 2 +- web/src/data/generated/docs.json | 28 ++++++++++++++-------------- 15 files changed, 44 insertions(+), 44 deletions(-) diff --git a/s01_agent_loop/README.zh.md b/s01_agent_loop/README.zh.md index 6682ca5f..a5e67030 100644 --- a/s01_agent_loop/README.zh.md +++ b/s01_agent_loop/README.zh.md @@ -107,7 +107,7 @@ def agent_loop(messages): messages.append({"role": "user", "content": results}) ``` -不到 30 行,这就是最小可运行的 agent harness 内核。它不是智能本身,而是让模型能持续行动的最小运行框架,模型负责决策(要不要调工具、调哪个),harness 负责执行(调了就跑、结果喂回去)。后面 18 个章节都在这个循环上叠加机制,循环本身始终不变。 +不到 30 行,这就是最小可运行的 agent harness 内核。它为模型提供持续行动的最小运行框架:模型负责决策(要不要调工具、调哪个),harness 负责执行(调了就跑、结果喂回去)。后面 20 个章节都在这个循环上叠加机制,循环本身始终不变。 --- diff --git a/s03_permission/README.zh.md b/s03_permission/README.zh.md index e5f09931..3688265b 100644 --- a/s03_permission/README.zh.md +++ b/s03_permission/README.zh.md @@ -13,7 +13,7 @@ s01 → s02 → `s03` → [s04](../s04_hooks/) → s05 → ... → s20 → s21 s02 的 Agent 有 5 个工具。file tools 受 `safe_path` 保护,但 bash 不受限制。让它"清理一下项目",可能执行 `rm -rf /`。 -安全不能靠信任模型,要靠代码——在工具执行之前做判断。 +安全边界由代码负责,判断发生在工具执行之前。 --- @@ -21,7 +21,7 @@ s02 的 Agent 有 5 个工具。file tools 受 `safe_path` 保护,但 bash 不 ![Permission Overview](images/permission-overview.svg) -s02 的循环完全保留。唯一的变动在工具执行前插入 `check_permission()`——每个工具调用经过三道闸门,顺序固定:硬拒绝优先,软询问次之,都没命中就放行。 +s02 的循环完全保留。唯一的变动是在工具执行前插入 `check_permission()`。每个工具调用依次经过三道闸门:硬拒绝优先,软询问次之,都没命中就放行。 三道闸门对应三种决策: @@ -54,7 +54,7 @@ def check_deny_list(command: str) -> str | None: return None ``` -**闸门 2**:规则匹配——描述"什么时候需要问用户"。每条规则指定工具和检查条件。 +**闸门 2**负责规则匹配,用来描述"什么时候需要问用户"。每条规则指定工具和检查条件。 ```python PERMISSION_RULES = [ @@ -149,7 +149,7 @@ python s03_permission/code.py ## 接下来 -权限检查做了——但每次都在循环里硬编码 `check_permission()`。如果我想在每次工具执行前后加日志?如果想在某些操作后自动触发 git commit?这些扩展逻辑散落在 loop 里,循环很快就会膨胀。 +当前权限检查每次都在循环里硬编码 `check_permission()`。如果我想在每次工具执行前后加日志?如果想在某些操作后自动触发 git commit?这些扩展逻辑散落在 loop 里,循环很快就会膨胀。 s04 Hooks → 给循环加钩子,扩展逻辑挂在钩子上,循环保持干净。 diff --git a/s07_skill_loading/README.zh.md b/s07_skill_loading/README.zh.md index 01f87d04..3e1348ed 100644 --- a/s07_skill_loading/README.zh.md +++ b/s07_skill_loading/README.zh.md @@ -22,7 +22,7 @@ SYSTEM = ( ) ``` -6500 行 system prompt。Agent 每次调用 LLM 都带着这些文档——不管是在改 CSS 颜色还是修 SQL 查询。99% 的内容和当前任务无关,白白消耗 token。 +6500 行 system prompt。Agent 每次调用 LLM 都带着这些文档,无论是在改 CSS 颜色还是修 SQL 查询。99% 的内容和当前任务无关,白白消耗 token。 --- diff --git a/s08_context_compact/README.zh.md b/s08_context_compact/README.zh.md index 1f65ee78..e2c48b30 100644 --- a/s08_context_compact/README.zh.md +++ b/s08_context_compact/README.zh.md @@ -60,7 +60,7 @@ def snip_compact(messages, max_messages=50): return messages[:head_end] + [placeholder] + messages[tail_start:] ``` -裁掉的是消息本身,只是在切口处多做一步保护;剩下的消息里 `tool_result` 内容仍在累积——第 34 条消息里可能躺着 30KB 的旧文件内容。→ L2。 +裁掉的是消息本身,只是在切口处多做一步保护;剩下的消息里 `tool_result` 内容仍在累积。第 34 条消息里可能躺着 30KB 的旧文件内容。→ L2。 ### L2: micro_compact — 旧工具结果占位 @@ -83,7 +83,7 @@ def micro_compact(messages): return messages ``` -旧结果清掉了,但单条新结果可能就有 500KB——一个 `cat` 大文件的输出就能打满上下文。→ L3。 +旧结果清掉了,但单条新结果可能就有 500KB。一次 `cat` 大文件的输出就能打满上下文。→ L3。 ### L3: tool_result_budget — 大结果落盘 diff --git a/s11_error_recovery/README.zh.md b/s11_error_recovery/README.zh.md index da3ffcb0..daf5b7b9 100644 --- a/s11_error_recovery/README.zh.md +++ b/s11_error_recovery/README.zh.md @@ -17,7 +17,7 @@ Agent 跑着跑着报错了: Error: 529 overloaded ``` -Agent 崩溃了。它没有重试,没有换模型,没有减少上下文——直接崩溃。 +Agent 崩溃了。它没有重试、切换模型或减少上下文,调用直接终止。 LLM API 调用可能失败。本章处理三种情况:输出截断、上下文超限和临时故障(429/529)。 @@ -45,7 +45,7 @@ s10 的循环、prompt 组装全部保留。唯一的变动:LLM 调用包裹 模型话说一半,`max_tokens` 用完了。默认 8000 token 不够它输出完整回答。 -第一次发生时,直接把 `max_tokens` 从 8K 升级到 64K(8 倍空间),重试同一请求——此时不追加截断输出到 messages,保持原始请求不变。如果 64K 还是不够,才保存截断输出并注入续写提示让模型接着刚才的话继续说,最多 3 次: +第一次发生时,直接把 `max_tokens` 从 8K 升级到 64K(8 倍空间),然后重试同一请求。这个阶段不追加截断输出到 messages,保持原始请求不变。如果 64K 还是不够,才保存截断输出并注入续写提示让模型接着刚才的话继续说,最多 3 次: ```python if response.stop_reason == "max_tokens": @@ -67,7 +67,7 @@ if response.stop_reason == "max_tokens": messages.append({"role": "assistant", "content": response.content}) ``` -升级只有一次机会,续写最多 3 次。超过就退出——继续续写也不会有实质产出。 +升级只有一次机会,续写最多 3 次。超过这个上限就退出,因为继续续写也不会有实质产出。 ### 路径 2: 上下文超限 @@ -86,7 +86,7 @@ except PromptTooLongError: ### 路径 3: 临时故障 -网络抖动、429 限流、529 过载——这些不是 bug,是分布式系统的常态。 +网络抖动、429 限流和 529 过载是分布式系统的常态,并不表示代码存在 bug。 429 和 529 统一走指数退避 + 抖动:第一次等 0.5 秒,第二次等 1 秒,第三次等 2 秒,最多 10 次。加随机抖动让并发请求不在同一时刻重试。连续 3 次 529 过载 → 切换到备用模型(若配置了 `FALLBACK_MODEL_ID` 环境变量): @@ -190,9 +190,9 @@ python s11_error_recovery/code.py ## 接下来 -Agent 现在能在错误中自动恢复了。但它处理的任务仍然是"一次性"的——你给它一个任务,它做完,结束。 +Agent 现在能在错误中自动恢复了,但仍然一次只处理一个任务:接收任务、完成任务,然后结束。 -能不能让 Agent 管理一个**任务列表**——有依赖关系、持久化到磁盘、跨会话能恢复?TODO 列表不是任务系统。 +下一步要让 Agent 管理一个具备依赖关系、磁盘持久化和跨会话恢复能力的**任务列表**。TODO 列表无法承担任务系统的职责。 s12 Task System → 任务是有依赖、有状态、持久化的图。这是多 Agent 协作的基础。 diff --git a/s12_task_system/README.zh.md b/s12_task_system/README.zh.md index 9caf1472..4a86b51f 100644 --- a/s12_task_system/README.zh.md +++ b/s12_task_system/README.zh.md @@ -220,7 +220,7 @@ python s12_task_system/code.py ## 接下来 -任务图有了。但有些任务要跑很久——比如全量测试、部署到服务器。Agent 调 LLM 按量计费,不能干等一个慢操作。 +任务图有了,但全量测试、部署到服务器等任务需要很长时间。Agent 调 LLM 按量计费,不能干等一个慢操作。 s13 Background Tasks → 慢操作放后台。Agent 继续处理其他任务,后台跑完了通知它。 diff --git a/s13_background_tasks/README.zh.md b/s13_background_tasks/README.zh.md index ce615334..60c642db 100644 --- a/s13_background_tasks/README.zh.md +++ b/s13_background_tasks/README.zh.md @@ -12,7 +12,7 @@ s01 → ... → s11 → s12 → `s13` → [s14](../s14_cron_scheduler/) → s15 ## 问题 -你用过洗衣机吗?把衣服扔进去,按下启动,然后去干别的——做饭、回消息、看论文。30 分钟后洗衣机"滴滴滴"提醒你:好了。你不会站在洗衣机前面干等 30 分钟。 +你用过洗衣机吗?把衣服扔进去,按下启动,然后去做饭、回消息或看论文。30 分钟后洗衣机"滴滴滴"提醒你:好了。你不会站在洗衣机前面干等 30 分钟。 Agent 的 bash 工具也一样。`pip install torch` 要 10 分钟,`npm run build` 要 3 分钟。这些命令一跑,Agent 就在等 bash 工具返回,没法利用这段时间处理别的任务。 diff --git a/s15_agent_teams/README.zh.md b/s15_agent_teams/README.zh.md index b00592a1..4eaf75c8 100644 --- a/s15_agent_teams/README.zh.md +++ b/s15_agent_teams/README.zh.md @@ -21,7 +21,7 @@ s01 → ... → s13 → s14 → `s15` → [s16](../s16_autonomous_agents/) → s 保持现有接口兼容,并确保测试通过。 ``` -因此,Harness 需要解决的不只是“再启动几个 Agent”,而是四个连续问题: +因此,Harness 需要连续解决四个问题: 1. 谁判断任务是否值得并行,以及如何征得用户确认? 2. 队友如何保留自己的身份和上下文,持续接收工作? diff --git a/s16_autonomous_agents/README.zh.md b/s16_autonomous_agents/README.zh.md index e29bfdc1..c49ce6e0 100644 --- a/s16_autonomous_agents/README.zh.md +++ b/s16_autonomous_agents/README.zh.md @@ -142,7 +142,7 @@ while True: → 再次扫描 ``` -自治不是再造一个 Agent Loop,而是给既有循环增加一个由共享状态触发的入口。 +自治是在既有 Agent Loop 上增加一个由共享状态触发的入口。 --- diff --git a/s17_worktree_isolation/README.zh.md b/s17_worktree_isolation/README.zh.md index 16329707..d5a40b97 100644 --- a/s17_worktree_isolation/README.zh.md +++ b/s17_worktree_isolation/README.zh.md @@ -14,7 +14,7 @@ s01 → ... → s15 → s16 → `s17` → [s18](../s18_mcp_plugin/) → s19 → s16 中,Alice 和 Bob 都在同一个目录下工作。Alice 的任务是"重构认证模块",Bob 的任务是"重构 UI 登录页"。 -Alice `write_file("config.py", ...)`。Bob 也 `write_file("config.py", ...)`。两个人改同一个文件,互相覆盖。而且无法干净地回滚——分不清哪些改动是谁的。 +Alice `write_file("config.py", ...)`。Bob 也 `write_file("config.py", ...)`。两个人改同一个文件,互相覆盖,而且无法干净地回滚,因为已经分不清每处改动来自谁。 s15-s16 解决了"谁干什么"(任务系统)和"怎么通信"(消息总线),但没解决"在哪干"。 @@ -24,7 +24,7 @@ s15-s16 解决了"谁干什么"(任务系统)和"怎么通信"(消息总 ![Worktree Overview](images/worktree-overview.svg) -Git worktree 让你在同一仓库中创建多个独立的工作目录,每个有自己的分支。Alice 在 `.worktrees/auth-refactor/` 下工作,Bob 在 `.worktrees/ui-login/` 下工作——互不干扰。 +Git worktree 让你在同一仓库中创建多个独立的工作目录,每个目录都有自己的分支。Alice 在 `.worktrees/auth-refactor/` 下工作,Bob 在 `.worktrees/ui-login/` 下工作,两者互不干扰。 沿用 s16 的 MessageBus、协议和自治认领机制。本章新增: @@ -59,7 +59,7 @@ def bind_task_to_worktree(task_id: str, worktree_name: str): save_task(task) # 状态保持 pending,等队友 claim ``` -绑定规则:一个任务绑定一个 worktree。绑定不改任务状态——任务仍是 `pending`,队友自动认领时才推进到 `in_progress`。这样 Lead 可以提前创建任务和 worktree,队友 idle 时自然认领带 worktree 的任务。 +绑定规则:一个任务绑定一个 worktree。绑定不会改变任务状态。任务仍是 `pending`,队友自动认领时才推进到 `in_progress`。这样 Lead 可以提前创建任务和 worktree,队友 idle 时自然认领带 worktree 的任务。 ### 队友工具的 cwd 切换 @@ -103,7 +103,7 @@ def keep_worktree(name: str) -> str: return f"Worktree '{name}' kept for review (branch: wt/{name})" ``` -Keep = 留着分支,等人工 review 后合并到主分支。Remove = 有改动时默认拒绝,需要 `discard_changes=true` 确认。不自动 complete task——任务完成由队友的 `complete_task` 显式触发。 +Keep = 留着分支,等人工 review 后合并到主分支。Remove = 有改动时默认拒绝,需要 `discard_changes=true` 确认。系统不会自动 complete task,任务完成由队友的 `complete_task` 显式触发。 ### 事件流:可审计 @@ -162,7 +162,7 @@ python s17_worktree_isolation/code.py ## 接下来 -Agent 团队能在隔离的工作空间中自组织了。但 Agent 的能力受限于我们给它写的工具——bash、read、write、task... +Agent 团队能在隔离的工作空间中自组织了,但 Agent 的能力仅限于我们为它编写的 bash、read、write、task 等工具。 如果用户已经有了自己的工具怎么办?比如一个公司内部的 Jira API、一个自建的部署系统? diff --git a/s18_mcp_plugin/README.zh.md b/s18_mcp_plugin/README.zh.md index a445aa04..8f76b0d5 100644 --- a/s18_mcp_plugin/README.zh.md +++ b/s18_mcp_plugin/README.zh.md @@ -12,11 +12,11 @@ s01 → ... → s16 → s17 → `s18` → [s19](../s19_comprehensive/) → s20 ## 问题 -s01 到 s17,Agent 的所有工具都是手写的——bash、read、write、task、worktree。每个工具的输入验证、执行逻辑、错误处理,都是你一行行写的。 +s01 到 s17,Agent 的所有工具都是手写的,包括 bash、read、write、task 和 worktree。每个工具的输入验证、执行逻辑、错误处理,都是你一行行写的。 现在你有 3 个外部服务想接入:公司的 Jira API(查 issue、建 ticket)、自建的部署系统(触发 deploy、看日志)、团队的 Notion 知识库(搜文档、建页面)。你不想为每个服务重写一套工具代码。 -你需要一个标准协议——外部服务只要实现它,Agent 就能直接调用,不管服务用什么语言写的。 +你需要一个标准协议。外部服务只要实现它,Agent 就能直接调用,不管服务用什么语言写的。 --- @@ -146,7 +146,7 @@ def agent_loop(messages, context): | 新类型 | — | MCPClient 类(模拟 tools/list + tools/call) | | 命名空间 | — | mcp\_\_server\_\_tool 避免冲突 | | 工具描述 | 无标注 | (readOnly)/(destructive) 标注 | -| prompt 缓存 | 有(s10 起) | 去掉——工具池动态变化后缓存失效 | +| prompt 缓存 | 有(s10 起) | 去掉,因为工具池动态变化后缓存失效 | | Lead 工具 | worktree 与团队工具 | + connect_mcp 和动态发现的 MCP 工具 | | Teammate 工具 | 任务、文件、消息与计划工具 | 不变 | | 扩展方式 | 写代码加工具 | 标准协议,任意语言实现 server | diff --git a/s19_comprehensive/README.zh.md b/s19_comprehensive/README.zh.md index b19c143d..1a0e8230 100644 --- a/s19_comprehensive/README.zh.md +++ b/s19_comprehensive/README.zh.md @@ -26,7 +26,7 @@ s01 → ... → s17 → s18 → `s19` → [s20](../s20_workflow_runtime/) → s2 - worktree 隔离 - MCP 外部工具接入 -难点不是把功能堆起来,而是看清楚它们都挂在循环的哪个位置。S19 是集成检查点:先把此前组件归位,再由 s20-s21 在外层加入编排与目标闭环。 +本章的难点在于看清楚每项功能挂在循环的哪个位置。S19 是集成检查点:先把此前组件归位,再由 s20-s21 在外层加入编排与目标闭环。 --- diff --git a/s20_workflow_runtime/README.zh.md b/s20_workflow_runtime/README.zh.md index 21b18f40..44cbd34f 100644 --- a/s20_workflow_runtime/README.zh.md +++ b/s20_workflow_runtime/README.zh.md @@ -20,7 +20,7 @@ s01 → ... → s18 → s19 → `s20` → [s21](../s21_goal_loop/) - **确定**,同样的输入跑出来同样的结果结构; - **可恢复**,跑到一半断了,已经做完的部分别从头再来。 -让模型在主循环里一步一步驱动这套流程,又慢、结果又不确定,断了还得从头跑。这时候你要的不是"再聊一轮",而是把这套编排直接写成代码。 +让模型在主循环里一步一步驱动这套流程,会拖慢执行速度、增加结果的不确定性,中断后还得从头运行。更合适的做法是把整套编排直接写成代码。 ## 计划写在代码里,不是靠聊天一轮轮凑 @@ -62,7 +62,7 @@ class WorkflowTool: 每个 workflow 都要注册一个元数据对象,包含 `name`、`description` 和可选的 `phases`。运行时会在执行任何 workflow 代码之前校验它:`name` 和 `description` 用来标识任务,`phases` 给进度条分组命名。 -不对的输入直接抛 `WorkflowInputError`,注册的时候就拦住——这和 s14 校验 cron 表达式是一个思路:坏脚本别让它跑到执行的时候才炸。 +运行时在注册阶段直接拒绝错误输入并抛出 `WorkflowInputError`。这和 s14 校验 cron 表达式是一个思路:坏脚本别让它跑到执行的时候才炸。 运行时会把 `meta.name` 用在本地产物文件名中,因此还要求它是 1-64 个字符的安全 slug,只能包含字母、数字、`.`、`_`、`-`。 diff --git a/s21_goal_loop/README.zh.md b/s21_goal_loop/README.zh.md index 4324b67c..d03e02d0 100644 --- a/s21_goal_loop/README.zh.md +++ b/s21_goal_loop/README.zh.md @@ -40,7 +40,7 @@ if not has_tool_use(response): ## 设目标:证据从命令之后开始算 -`set_goal` 会存一个活跃目标:目标文本、最大轮数预算、计数器,还有 `start_index`——也就是证据窗口的起点。它取当前对话记录的长度,所以 `/goal` 这行命令本身在窗口外面。这是第一道防线:命令自己不能证明自己完成了。 +`set_goal` 会存一个活跃目标:目标文本、最大轮数预算、计数器和 `start_index`。其中,`start_index` 表示证据窗口的起点。它取当前对话记录的长度,所以 `/goal` 这行命令本身在窗口外面。这是第一道防线:命令自己不能证明自己完成了。 ```python def set_goal(self, objective, max_turns=20): diff --git a/web/src/data/generated/docs.json b/web/src/data/generated/docs.json index 9dea77c5..ead69a85 100644 --- a/web/src/data/generated/docs.json +++ b/web/src/data/generated/docs.json @@ -9,7 +9,7 @@ "version": "s01", "locale": "zh", "title": "s01: Agent Loop — 一个循环就够了", - "content": "# s01: Agent Loop — 一个循环就够了\n\n`s01` → [s02](/zh/s02) → s03 → s04 → ... → s20 → s21\n> *\"One loop & Bash is all you need\"* — 一个工具 + 一个循环 = 一个 Agent。\n>\n> **Harness 层**: 循环 — 模型与真实世界的第一道连接。\n\n---\n\n## 问题\n\n你提出了一个问题给大模型:“帮我读取下我的目录下有哪些文件,并且执行XXX.py”。\n\n模型能输出一条 bash 命令,但输出完了就停了,它不会自己跑,也不会看到结果后继续推理。\n\n你可以手动跑一遍,把输出粘贴回对话框,让它接着干。下一个命令出来,你再跑一遍、再贴回去。\n\n每一个来回,你都在做中间层。而把它自动化,就是这一章要做的事。\n\n---\n\n## 解决方案\n\n![Agent Loop](/course-assets/s01_agent_loop/agent-loop.svg)\n\n一个 `while True` 循环,模型调用工具就继续,不调用就停。整个过程只有两个信号:\n\n| 信号 | 含义 | 循环动作 |\n|------|------|---------|\n| `stop_reason == \"tool_use\"` | 模型举手说\"我要用工具\" | 执行 → 结果喂回去 → 继续 |\n| `stop_reason != \"tool_use\"` | 模型说\"我做完了\" | 退出循环 |\n\n---\n\n## 工作原理\n\n将这个过程翻译成代码。分步来看:\n\n**第 1 步**:把用户的问题作为第一条消息。\n\n```python\nmessages = [{\"role\": \"user\", \"content\": query}]\n```\n\n**第 2 步**:将消息和工具定义一起发给 LLM。\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n**第 3 步**:追加模型回答,检查它是否调了工具。没调 → 结束。\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\nif response.stop_reason != \"tool_use\":\n return\n```\n\n**第 4 步**:执行模型要求的工具,收集结果。\n\n```python\nresults = []\nfor block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n**第 5 步**:把工具结果作为新消息追加,回到第 2 步。\n\n```python\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n组装为一个完整函数:\n\n```python\ndef agent_loop(messages):\n while True:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n messages.append({\"role\": \"user\", \"content\": results})\n```\n\n不到 30 行,这就是最小可运行的 agent harness 内核。它不是智能本身,而是让模型能持续行动的最小运行框架,模型负责决策(要不要调工具、调哪个),harness 负责执行(调了就跑、结果喂回去)。后面 18 个章节都在这个循环上叠加机制,循环本身始终不变。\n\n---\n\n## 试一下\n\n> **安全提示**:代码会执行模型生成的 shell 命令。建议在一个临时测试目录中运行,避免影响你的项目文件。s03 会加入权限控制。\n\n**准备**(首次运行):\n\n```sh\npip install -r requirements.txt\ncp .env.example .env\n# 编辑 .env,填入 ANTHROPIC_API_KEY 和 MODEL_ID\n```\n\n**运行**:\n\n```sh\npython s01_agent_loop/code.py\n```\n\n试试这些 prompt:\n\n1. `Create a file called hello.py that prints \"Hello, World!\"`\n2. `List all Python files in this directory`\n3. `What is the current git branch?`\n\n观察重点:模型什么时候调用工具(循环继续),什么时候不调用(循环结束)?\n\n---\n\n## 接下来\n\n现在模型手里只有 bash 一个工具,读文件要 `cat`,写文件要 `echo ... >`,找个文件要 `find`,又丑又容易出错。\n\ns02 Tool Use → 给它 5 个真正的工具,会发生什么?模型会不会一次调用多个工具?几个工具同时跑会不会互相踩?\n\n\n\n" + "content": "# s01: Agent Loop — 一个循环就够了\n\n`s01` → [s02](/zh/s02) → s03 → s04 → ... → s20 → s21\n> *\"One loop & Bash is all you need\"* — 一个工具 + 一个循环 = 一个 Agent。\n>\n> **Harness 层**: 循环 — 模型与真实世界的第一道连接。\n\n---\n\n## 问题\n\n你提出了一个问题给大模型:“帮我读取下我的目录下有哪些文件,并且执行XXX.py”。\n\n模型能输出一条 bash 命令,但输出完了就停了,它不会自己跑,也不会看到结果后继续推理。\n\n你可以手动跑一遍,把输出粘贴回对话框,让它接着干。下一个命令出来,你再跑一遍、再贴回去。\n\n每一个来回,你都在做中间层。而把它自动化,就是这一章要做的事。\n\n---\n\n## 解决方案\n\n![Agent Loop](/course-assets/s01_agent_loop/agent-loop.svg)\n\n一个 `while True` 循环,模型调用工具就继续,不调用就停。整个过程只有两个信号:\n\n| 信号 | 含义 | 循环动作 |\n|------|------|---------|\n| `stop_reason == \"tool_use\"` | 模型举手说\"我要用工具\" | 执行 → 结果喂回去 → 继续 |\n| `stop_reason != \"tool_use\"` | 模型说\"我做完了\" | 退出循环 |\n\n---\n\n## 工作原理\n\n将这个过程翻译成代码。分步来看:\n\n**第 1 步**:把用户的问题作为第一条消息。\n\n```python\nmessages = [{\"role\": \"user\", \"content\": query}]\n```\n\n**第 2 步**:将消息和工具定义一起发给 LLM。\n\n```python\nresponse = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n)\n```\n\n**第 3 步**:追加模型回答,检查它是否调了工具。没调 → 结束。\n\n```python\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\nif response.stop_reason != \"tool_use\":\n return\n```\n\n**第 4 步**:执行模型要求的工具,收集结果。\n\n```python\nresults = []\nfor block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n```\n\n**第 5 步**:把工具结果作为新消息追加,回到第 2 步。\n\n```python\nmessages.append({\"role\": \"user\", \"content\": results})\n```\n\n组装为一个完整函数:\n\n```python\ndef agent_loop(messages):\n while True:\n response = client.messages.create(\n model=MODEL, system=SYSTEM, messages=messages,\n tools=TOOLS, max_tokens=8000,\n )\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n\n results = []\n for block in response.content:\n if block.type == \"tool_use\":\n output = run_bash(block.input[\"command\"])\n results.append({\n \"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": output,\n })\n messages.append({\"role\": \"user\", \"content\": results})\n```\n\n不到 30 行,这就是最小可运行的 agent harness 内核。它为模型提供持续行动的最小运行框架:模型负责决策(要不要调工具、调哪个),harness 负责执行(调了就跑、结果喂回去)。后面 20 个章节都在这个循环上叠加机制,循环本身始终不变。\n\n---\n\n## 试一下\n\n> **安全提示**:代码会执行模型生成的 shell 命令。建议在一个临时测试目录中运行,避免影响你的项目文件。s03 会加入权限控制。\n\n**准备**(首次运行):\n\n```sh\npip install -r requirements.txt\ncp .env.example .env\n# 编辑 .env,填入 ANTHROPIC_API_KEY 和 MODEL_ID\n```\n\n**运行**:\n\n```sh\npython s01_agent_loop/code.py\n```\n\n试试这些 prompt:\n\n1. `Create a file called hello.py that prints \"Hello, World!\"`\n2. `List all Python files in this directory`\n3. `What is the current git branch?`\n\n观察重点:模型什么时候调用工具(循环继续),什么时候不调用(循环结束)?\n\n---\n\n## 接下来\n\n现在模型手里只有 bash 一个工具,读文件要 `cat`,写文件要 `echo ... >`,找个文件要 `find`,又丑又容易出错。\n\ns02 Tool Use → 给它 5 个真正的工具,会发生什么?模型会不会一次调用多个工具?几个工具同时跑会不会互相踩?\n\n\n\n" }, { "version": "s01", @@ -45,7 +45,7 @@ "version": "s03", "locale": "zh", "title": "s03: Permission — 执行前做权限判断", - "content": "# s03: Permission — 执行前做权限判断\n\ns01 → s02 → `s03` → [s04](/zh/s04) → s05 → ... → s20 → s21\n> *\"工具执行前先做权限判断\"* — 权限管线决定哪些操作需要审批。\n>\n> **Harness 层**: 权限 — 在工具执行前加一道门。\n\n---\n\n## 问题\n\ns02 的 Agent 有 5 个工具。file tools 受 `safe_path` 保护,但 bash 不受限制。让它\"清理一下项目\",可能执行 `rm -rf /`。\n\n安全不能靠信任模型,要靠代码——在工具执行之前做判断。\n\n---\n\n## 解决方案\n\n![Permission Overview](/course-assets/s03_permission/permission-overview.svg)\n\ns02 的循环完全保留。唯一的变动在工具执行前插入 `check_permission()`——每个工具调用经过三道闸门,顺序固定:硬拒绝优先,软询问次之,都没命中就放行。\n\n三道闸门对应三种决策:\n\n| 闸门 | 作用 | 命中后 |\n|------|------|--------|\n| 1. 拒绝列表 | 永远禁止的操作(`rm -rf /`、`sudo`) | 直接拒绝,不执行 |\n| 2. 规则匹配 | 取决于上下文的操作(读/写工作区外、`rm` 文件) | 交给闸门 3 |\n| 3. 用户审批 | 闸门 2 命中后,暂停等用户确认 | 用户决定允许或拒绝 |\n\n三道都没命中 → 直接执行。大部分日常操作走这条路。\n\n---\n\n## 工作原理\n\n![Permission Pipeline](/course-assets/s03_permission/permission-pipeline.svg)\n\n**闸门 1**:一张硬拒绝表,先查,命中就返回阻止信息。这张表使用简单字符串匹配来说明权限闸门的位置,不能视为完整的安全边界。\n\n```python\nDENY_LIST = [\n \"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\",\n \"mkfs\", \"dd if=\", \"> /dev/sda\",\n]\n\ndef check_deny_list(command: str) -> str | None:\n for pattern in DENY_LIST:\n if pattern in command:\n return f\"Blocked: '{pattern}' is on the deny list\"\n return None\n```\n\n**闸门 2**:规则匹配——描述\"什么时候需要问用户\"。每条规则指定工具和检查条件。\n\n```python\nPERMISSION_RULES = [\n {\n \"tools\": [\"read_file\", \"write_file\", \"edit_file\"],\n \"check\": lambda args: not (WORKDIR / args.get(\"path\", \"\")).resolve().is_relative_to(WORKDIR),\n \"message\": \"Access outside workspace\",\n },\n {\n \"tools\": [\"bash\"],\n \"check\": lambda args: any(kw in args.get(\"command\", \"\") for kw in [\"rm \", \"> /etc/\", \"chmod 777\"]),\n \"message\": \"Potentially destructive command\",\n },\n]\n\ndef check_rules(tool_name: str, args: dict) -> str | None:\n for rule in PERMISSION_RULES:\n if tool_name in rule[\"tools\"] and rule[\"check\"](args):\n return rule[\"message\"]\n return None\n```\n\n**闸门 3**:规则命中后,暂停等用户输入。\n\n```python\ndef ask_user(tool_name: str, args: dict, reason: str) -> str:\n print(f\"\\n⚠ {reason}\")\n print(f\" Tool: {tool_name}({args})\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n return \"allow\" if choice in (\"y\", \"yes\") else \"deny\"\n```\n\n**三道闸门串在一起**,插在工具执行之前:\n\n```python\ndef check_permission(block) -> bool:\n # 闸门 1: 硬拒绝\n if block.name == \"bash\":\n reason = check_deny_list(block.input.get(\"command\", \"\"))\n if reason:\n print(f\"\\n⛔ {reason}\")\n return False\n\n # 闸门 2 + 3: 规则匹配 → 用户审批\n reason = check_rules(block.name, block.input)\n if reason:\n decision = ask_user(block.name, block.input, reason)\n if decision == \"deny\":\n return False\n\n return True\n\n# 在 agent_loop 中——s02 的循环只加了一行:\nfor block in response.content:\n if block.type == \"tool_use\":\n if not check_permission(block): # ← 新增\n results.append({... \"content\": \"Permission denied.\"})\n continue\n output = TOOL_HANDLERS[block.name](**block.input) # s02 原有\n results.append(...)\n```\n\n---\n\n## 相对 s02 的变更\n\n| 组件 | 之前 (s02) | 之后 (s03) |\n|------|-----------|-----------|\n| 安全模型 | 无(信任模型) | 三道闸门权限管线 |\n| 新函数 | — | check_deny_list, check_rules, ask_user, check_permission |\n| 循环 | 直接执行所有工具 | 执行前插入 check_permission() |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s03_permission/code.py\n```\n\n试试这些 prompt:\n\n1. `Create a file called test.txt in the current directory`(应该直接通过)\n2. `Delete the file test.txt`(bash + rm 会触发闸门 2)\n3. `What files are in the current directory?`(只读,全部通过)\n4. `Try to write a file to /etc/something`(写工作区外,触发闸门 2)\n\n观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝?\n\n---\n\n## 接下来\n\n权限检查做了——但每次都在循环里硬编码 `check_permission()`。如果我想在每次工具执行前后加日志?如果想在某些操作后自动触发 git commit?这些扩展逻辑散落在 loop 里,循环很快就会膨胀。\n\ns04 Hooks → 给循环加钩子,扩展逻辑挂在钩子上,循环保持干净。\n\n\n\n" + "content": "# s03: Permission — 执行前做权限判断\n\ns01 → s02 → `s03` → [s04](/zh/s04) → s05 → ... → s20 → s21\n> *\"工具执行前先做权限判断\"* — 权限管线决定哪些操作需要审批。\n>\n> **Harness 层**: 权限 — 在工具执行前加一道门。\n\n---\n\n## 问题\n\ns02 的 Agent 有 5 个工具。file tools 受 `safe_path` 保护,但 bash 不受限制。让它\"清理一下项目\",可能执行 `rm -rf /`。\n\n安全边界由代码负责,判断发生在工具执行之前。\n\n---\n\n## 解决方案\n\n![Permission Overview](/course-assets/s03_permission/permission-overview.svg)\n\ns02 的循环完全保留。唯一的变动是在工具执行前插入 `check_permission()`。每个工具调用依次经过三道闸门:硬拒绝优先,软询问次之,都没命中就放行。\n\n三道闸门对应三种决策:\n\n| 闸门 | 作用 | 命中后 |\n|------|------|--------|\n| 1. 拒绝列表 | 永远禁止的操作(`rm -rf /`、`sudo`) | 直接拒绝,不执行 |\n| 2. 规则匹配 | 取决于上下文的操作(读/写工作区外、`rm` 文件) | 交给闸门 3 |\n| 3. 用户审批 | 闸门 2 命中后,暂停等用户确认 | 用户决定允许或拒绝 |\n\n三道都没命中 → 直接执行。大部分日常操作走这条路。\n\n---\n\n## 工作原理\n\n![Permission Pipeline](/course-assets/s03_permission/permission-pipeline.svg)\n\n**闸门 1**:一张硬拒绝表,先查,命中就返回阻止信息。这张表使用简单字符串匹配来说明权限闸门的位置,不能视为完整的安全边界。\n\n```python\nDENY_LIST = [\n \"rm -rf /\", \"sudo\", \"shutdown\", \"reboot\",\n \"mkfs\", \"dd if=\", \"> /dev/sda\",\n]\n\ndef check_deny_list(command: str) -> str | None:\n for pattern in DENY_LIST:\n if pattern in command:\n return f\"Blocked: '{pattern}' is on the deny list\"\n return None\n```\n\n**闸门 2**负责规则匹配,用来描述\"什么时候需要问用户\"。每条规则指定工具和检查条件。\n\n```python\nPERMISSION_RULES = [\n {\n \"tools\": [\"read_file\", \"write_file\", \"edit_file\"],\n \"check\": lambda args: not (WORKDIR / args.get(\"path\", \"\")).resolve().is_relative_to(WORKDIR),\n \"message\": \"Access outside workspace\",\n },\n {\n \"tools\": [\"bash\"],\n \"check\": lambda args: any(kw in args.get(\"command\", \"\") for kw in [\"rm \", \"> /etc/\", \"chmod 777\"]),\n \"message\": \"Potentially destructive command\",\n },\n]\n\ndef check_rules(tool_name: str, args: dict) -> str | None:\n for rule in PERMISSION_RULES:\n if tool_name in rule[\"tools\"] and rule[\"check\"](args):\n return rule[\"message\"]\n return None\n```\n\n**闸门 3**:规则命中后,暂停等用户输入。\n\n```python\ndef ask_user(tool_name: str, args: dict, reason: str) -> str:\n print(f\"\\n⚠ {reason}\")\n print(f\" Tool: {tool_name}({args})\")\n choice = input(\" Allow? [y/N] \").strip().lower()\n return \"allow\" if choice in (\"y\", \"yes\") else \"deny\"\n```\n\n**三道闸门串在一起**,插在工具执行之前:\n\n```python\ndef check_permission(block) -> bool:\n # 闸门 1: 硬拒绝\n if block.name == \"bash\":\n reason = check_deny_list(block.input.get(\"command\", \"\"))\n if reason:\n print(f\"\\n⛔ {reason}\")\n return False\n\n # 闸门 2 + 3: 规则匹配 → 用户审批\n reason = check_rules(block.name, block.input)\n if reason:\n decision = ask_user(block.name, block.input, reason)\n if decision == \"deny\":\n return False\n\n return True\n\n# 在 agent_loop 中——s02 的循环只加了一行:\nfor block in response.content:\n if block.type == \"tool_use\":\n if not check_permission(block): # ← 新增\n results.append({... \"content\": \"Permission denied.\"})\n continue\n output = TOOL_HANDLERS[block.name](**block.input) # s02 原有\n results.append(...)\n```\n\n---\n\n## 相对 s02 的变更\n\n| 组件 | 之前 (s02) | 之后 (s03) |\n|------|-----------|-----------|\n| 安全模型 | 无(信任模型) | 三道闸门权限管线 |\n| 新函数 | — | check_deny_list, check_rules, ask_user, check_permission |\n| 循环 | 直接执行所有工具 | 执行前插入 check_permission() |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s03_permission/code.py\n```\n\n试试这些 prompt:\n\n1. `Create a file called test.txt in the current directory`(应该直接通过)\n2. `Delete the file test.txt`(bash + rm 会触发闸门 2)\n3. `What files are in the current directory?`(只读,全部通过)\n4. `Try to write a file to /etc/something`(写工作区外,触发闸门 2)\n\n观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝?\n\n---\n\n## 接下来\n\n当前权限检查每次都在循环里硬编码 `check_permission()`。如果我想在每次工具执行前后加日志?如果想在某些操作后自动触发 git commit?这些扩展逻辑散落在 loop 里,循环很快就会膨胀。\n\ns04 Hooks → 给循环加钩子,扩展逻辑挂在钩子上,循环保持干净。\n\n\n\n" }, { "version": "s03", @@ -117,7 +117,7 @@ "version": "s07", "locale": "zh", "title": "s07: Skill Loading — 用到的时候才加载", - "content": "# s07: Skill Loading — 用到的时候才加载\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/zh/s08) → s09 → ... → s20 → s21\n> *\"用到时再加载, 别全塞 prompt 里\"* — 通过 tool_result 注入, 不塞 system prompt。\n>\n> **Harness 层**: 知识 — 按需加载, 不堆满上下文。\n\n---\n\n## 问题\n\n你的项目有一套 React 组件规范、一份 SQL 风格指南、一份 API 设计文档。你希望 Agent 自动遵守这些规范。最直接的想法,全塞进 system prompt:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read() # 2000 行\n + open(\"docs/sql-style.md\").read() # 1500 行\n + open(\"docs/api-design.md\").read() # 3000 行\n)\n```\n\n6500 行 system prompt。Agent 每次调用 LLM 都带着这些文档——不管是在改 CSS 颜色还是修 SQL 查询。99% 的内容和当前任务无关,白白消耗 token。\n\n---\n\n## 解决方案\n\n![Skill Overview](/course-assets/s07_skill_loading/skill-overview.svg)\n\n保留上一章的最小 hook 结构、`todo_write` 和子 Agent,本章重点转向新增的 `load_skill` 工具。启动时把技能目录注入 SYSTEM prompt,运行时多注册一个工具加载完整内容,用到才花 token。\n\n两层设计:\n\n| 层 | 位置 | 时机 | 代价 |\n|---|------|------|------|\n| 1. 目录 | system prompt | 启动时注入(harness 扫描 skills/) | ~100 tokens/skill,每轮都带 |\n| 2. 内容 | tool_result | Agent 调用 load_skill 时;SKILL.md 可指引后续的 read_file/bash 调用,用于按需访问额外资源 | ~2000 tokens/skill,按需 |\n\ndispatch 机制不变,load_skill 通过 `TOOL_HANDLERS[block.name]` 分发。\n\n---\n\n## 工作原理\n\n**skills/ 目录**,每个技能一个子目录,包含 `SKILL.md` 文件:\n\n```\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n**第一级:启动时注入目录**:harness 启动时调用 `_scan_skills()` 扫描 skills/ 目录,解析每个 SKILL.md 的 YAML frontmatter(`name`、`description`),存入 `SKILL_REGISTRY` 字典。`list_skills()` 从注册表生成目录,注入 SYSTEM prompt。Agent 每轮都能看到\"我有哪些技能可用\",不花额外 API 调用:\n\n```python\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills() # runs once at startup\n\ndef list_skills() -> str:\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n```\n\n**第二级:load_skill**:Agent 决定\"我需要 SQL 风格指南\",调用 `load_skill(\"sql-style\")`。通过注册表查找,不走文件路径,没有路径遍历风险。SKILL.md 内容通过 `tool_result` 注入,并可通过现有的 file 和 bash 工具进一步访问引用的 `references/`、`scripts/` 或 `assets/`。\n\n```python\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n```\n\n关键区别:技能内容不是 system prompt 的一部分,它作为一次工具结果进入当前 messages。后续调用会随历史一起携带,直到上下文压缩、截断或会话结束。这和 s08 的 compact 自然衔接:按需加载解决了\"不该提前带的不要带\",compact 解决\"该丢的怎么丢\"。\n\n---\n\n## 相对 s06 的变更\n\n| 组件 | 之前 (s06) | 之后 (s07) |\n|------|-----------|-----------|\n| 工具数量 | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |\n| 知识加载 | 无 | 两级:启动时目录注入 SYSTEM + 运行时 load_skill;SKILL.md 可指引后续资源访问 |\n| SYSTEM 提示 | 静态字符串 | 启动时扫描 skills/ 注入目录 |\n| 技能注册表 | 无 | SKILL_REGISTRY(启动时填充,防路径遍历) |\n| 循环 | 不变 | 不变(skill 工具自动分发) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\n试试这些 prompt:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n\n观察重点:Agent 是否直接从 SYSTEM 里的目录知道有哪些技能?需要完整规范时是否出现 `[HOOK] load_skill`?加载后回答是否使用了对应 skill 的说明?\n\n---\n\n## 接下来\n\n按需加载解决了\"不该带的不要带\"。但另一个问题来了:Agent 连续工作 30 分钟后,messages 列表塞满了中间过程。旧的 tool_result、过时的文件内容,占着上下文但不产生价值。\n\ns08 Context Compact → 四层压缩策略。便宜的先跑,贵的后跑。\n\n\n\n" + "content": "# s07: Skill Loading — 用到的时候才加载\n\ns01 → s02 → s03 → s04 → s05 → s06 → `s07` → [s08](/zh/s08) → s09 → ... → s20 → s21\n> *\"用到时再加载, 别全塞 prompt 里\"* — 通过 tool_result 注入, 不塞 system prompt。\n>\n> **Harness 层**: 知识 — 按需加载, 不堆满上下文。\n\n---\n\n## 问题\n\n你的项目有一套 React 组件规范、一份 SQL 风格指南、一份 API 设计文档。你希望 Agent 自动遵守这些规范。最直接的想法,全塞进 system prompt:\n\n```python\nSYSTEM = (\n f\"You are a coding agent. \"\n + open(\"docs/react-style.md\").read() # 2000 行\n + open(\"docs/sql-style.md\").read() # 1500 行\n + open(\"docs/api-design.md\").read() # 3000 行\n)\n```\n\n6500 行 system prompt。Agent 每次调用 LLM 都带着这些文档,无论是在改 CSS 颜色还是修 SQL 查询。99% 的内容和当前任务无关,白白消耗 token。\n\n---\n\n## 解决方案\n\n![Skill Overview](/course-assets/s07_skill_loading/skill-overview.svg)\n\n保留上一章的最小 hook 结构、`todo_write` 和子 Agent,本章重点转向新增的 `load_skill` 工具。启动时把技能目录注入 SYSTEM prompt,运行时多注册一个工具加载完整内容,用到才花 token。\n\n两层设计:\n\n| 层 | 位置 | 时机 | 代价 |\n|---|------|------|------|\n| 1. 目录 | system prompt | 启动时注入(harness 扫描 skills/) | ~100 tokens/skill,每轮都带 |\n| 2. 内容 | tool_result | Agent 调用 load_skill 时;SKILL.md 可指引后续的 read_file/bash 调用,用于按需访问额外资源 | ~2000 tokens/skill,按需 |\n\ndispatch 机制不变,load_skill 通过 `TOOL_HANDLERS[block.name]` 分发。\n\n---\n\n## 工作原理\n\n**skills/ 目录**,每个技能一个子目录,包含 `SKILL.md` 文件:\n\n```\nskills/\n agent-builder/SKILL.md\n code-review/SKILL.md\n mcp-builder/SKILL.md\n pdf/SKILL.md\n```\n\n**第一级:启动时注入目录**:harness 启动时调用 `_scan_skills()` 扫描 skills/ 目录,解析每个 SKILL.md 的 YAML frontmatter(`name`、`description`),存入 `SKILL_REGISTRY` 字典。`list_skills()` 从注册表生成目录,注入 SYSTEM prompt。Agent 每轮都能看到\"我有哪些技能可用\",不花额外 API 调用:\n\n```python\nSKILL_REGISTRY: dict[str, dict] = {}\n\ndef _scan_skills():\n if not SKILLS_DIR.exists():\n return\n for d in sorted(SKILLS_DIR.iterdir()):\n if not d.is_dir():\n continue\n manifest = d / \"SKILL.md\"\n if manifest.exists():\n raw = manifest.read_text()\n meta, body = _parse_frontmatter(raw)\n name = meta.get(\"name\", d.name)\n desc = meta.get(\"description\", raw.split(\"\\n\")[0].lstrip(\"#\").strip())\n SKILL_REGISTRY[name] = {\"name\": name, \"description\": desc, \"content\": raw}\n\n_scan_skills() # runs once at startup\n\ndef list_skills() -> str:\n return \"\\n\".join(f\"- **{s['name']}**: {s['description']}\" for s in SKILL_REGISTRY.values())\n\ndef build_system() -> str:\n catalog = list_skills()\n return (\n f\"You are a coding agent at {WORKDIR}. \"\n f\"Skills available:\\n{catalog}\\n\"\n \"Use load_skill to get full details when needed.\"\n )\n\nSYSTEM = build_system()\n```\n\n**第二级:load_skill**:Agent 决定\"我需要 SQL 风格指南\",调用 `load_skill(\"sql-style\")`。通过注册表查找,不走文件路径,没有路径遍历风险。SKILL.md 内容通过 `tool_result` 注入,并可通过现有的 file 和 bash 工具进一步访问引用的 `references/`、`scripts/` 或 `assets/`。\n\n```python\ndef load_skill(name: str) -> str:\n skill = SKILL_REGISTRY.get(name)\n if not skill:\n return f\"Skill not found: {name}\"\n return skill[\"content\"]\n```\n\n关键区别:技能内容不是 system prompt 的一部分,它作为一次工具结果进入当前 messages。后续调用会随历史一起携带,直到上下文压缩、截断或会话结束。这和 s08 的 compact 自然衔接:按需加载解决了\"不该提前带的不要带\",compact 解决\"该丢的怎么丢\"。\n\n---\n\n## 相对 s06 的变更\n\n| 组件 | 之前 (s06) | 之后 (s07) |\n|------|-----------|-----------|\n| 工具数量 | 7 (bash, read, write, edit, glob, todo_write, task) | 8 (+load_skill) |\n| 知识加载 | 无 | 两级:启动时目录注入 SYSTEM + 运行时 load_skill;SKILL.md 可指引后续资源访问 |\n| SYSTEM 提示 | 静态字符串 | 启动时扫描 skills/ 注入目录 |\n| 技能注册表 | 无 | SKILL_REGISTRY(启动时填充,防路径遍历) |\n| 循环 | 不变 | 不变(skill 工具自动分发) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s07_skill_loading/code.py\n```\n\n试试这些 prompt:\n\n1. `What skills are available?`\n2. `Load the code-review skill and follow its instructions`\n3. `I need to do a code review -- load the relevant skill first`\n\n观察重点:Agent 是否直接从 SYSTEM 里的目录知道有哪些技能?需要完整规范时是否出现 `[HOOK] load_skill`?加载后回答是否使用了对应 skill 的说明?\n\n---\n\n## 接下来\n\n按需加载解决了\"不该带的不要带\"。但另一个问题来了:Agent 连续工作 30 分钟后,messages 列表塞满了中间过程。旧的 tool_result、过时的文件内容,占着上下文但不产生价值。\n\ns08 Context Compact → 四层压缩策略。便宜的先跑,贵的后跑。\n\n\n\n" }, { "version": "s07", @@ -135,7 +135,7 @@ "version": "s08", "locale": "zh", "title": "s08: Context Compact — 上下文总会满,要有办法腾地方", - "content": "# s08: Context Compact — 上下文总会满,要有办法腾地方\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/zh/s09) → s10 → ... → s20 → s21\n> *\"上下文总会满, 要有办法腾地方\"* — 四层压缩策略, 便宜的先跑贵的后跑。\n>\n> **Harness 层**: 压缩 — 干净的记忆, 无限的会话。\n\n---\n\n## 问题\n\nAgent 跑着跑着,不动了。\n\n手里有 bash、有 read、有 write,能力是够的。但它读了一个 1000 行的文件(~4000 token),又读了 30 个文件,跑了 20 条命令。每条命令的输出、每个文件的内容,全都堆在 `messages` 列表里。\n\n上下文窗口是有限的。满了之后,API 直接拒绝:`prompt_too_long`。\n\n不压缩,Agent 根本没法在大项目里干活。\n\n---\n\n## 解决方案\n\n![Compact Overview](/course-assets/s08_context_compact/compact-overview.svg)\n\n保留 s07 的 hook 结构、技能加载、子 Agent 等骨架,省略部分工具细节以聚焦压缩。核心变动:每轮 LLM 调用前插入三层预处理器(0 API),token 仍超阈值时触发 LLM 摘要(1 API),API 报错时应急裁剪。\n\n核心设计:便宜的先跑,贵的后跑。\n\n> **与 s09 的边界:** s08 管理当前会话有限的上下文,压缩时允许丢失细节;s09 另建持久存储,只保留需要跨压缩、跨会话存在的信息。两章解决的是不同故障,因此不合并。\n\n---\n\n## 工作原理\n\n![四层压缩管线](/course-assets/s08_context_compact/compaction-layers.svg)\n\n### L1: snip_compact — 裁掉无关的旧对话\n\nAgent 跑了 80 轮对话,`messages` 攒了 160 条。最前面的\"帮我创建 hello.py\"和当前工作几乎无关了,但全占着位置。\n\n消息数超过 50 条 → 保留头部 3 条(初始上下文)和尾部 47 条(当前工作),中间裁掉;唯一额外边界条件是,不能把 `assistant(tool_use)` 和后面的 `user(tool_result)` 拆开:\n\n```python\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages:\n return messages\n head_end, tail_start = 3, len(messages) - (max_messages - 3)\n if 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 snipped = tail_start - head_end\n placeholder = {\"role\": \"user\", \"content\": f\"[snipped {snipped} messages from conversation middle]\"}\n return messages[:head_end] + [placeholder] + messages[tail_start:]\n```\n\n裁掉的是消息本身,只是在切口处多做一步保护;剩下的消息里 `tool_result` 内容仍在累积——第 34 条消息里可能躺着 30KB 的旧文件内容。→ L2。\n\n### L2: micro_compact — 旧工具结果占位\n\n![旧结果占位](/course-assets/s08_context_compact/micro-compact.svg)\n\nAgent 连续读了 10 个文件。第 1-7 次的完整内容还躺在上下文里,早就不需要了,但占着大量空间。\n\n只保留最近 3 条 `tool_result` 的完整内容,更旧的替换为一行占位符:\n\n```python\nKEEP_RECENT_TOOL_RESULTS = 3\n\ndef micro_compact(messages):\n tool_results = collect_tool_result_blocks(messages)\n if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS:\n return messages\n for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n```\n\n旧结果清掉了,但单条新结果可能就有 500KB——一个 `cat` 大文件的输出就能打满上下文。→ L3。\n\n### L3: tool_result_budget — 大结果落盘\n\n![大结果落盘](/course-assets/s08_context_compact/layer1-budget.svg)\n\n模型一次读了 5 个大文件,单条 user 消息里所有 `tool_result` 加起来 500KB。\n\n统计最后一条 user 消息里所有 `tool_result` 的总大小。超过 200KB → 按大小排序,从最大的开始落盘到 `.task_outputs/tool-results/`,上下文里只留 `` 标记 + 前 2000 字符预览。模型看到标记后知道完整内容在磁盘上,需要时可以重新读。\n\n```python\ndef tool_result_budget(messages, max_bytes=200_000):\n last = messages[-1]\n blocks = [(i, b) for i, b in enumerate(last[\"content\"])\n if b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes:\n return messages\n ranked = sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True)\n for idx, block in ranked:\n if total <= max_bytes:\n break\n block[\"content\"] = persist_large_output(block[\"tool_use_id\"], str(block[\"content\"]))\n total = recalculate_total(blocks)\n return messages\n```\n\n前三层都是纯文本/结构操作,0 API 调用,但也无法\"理解\"对话内容。上下文可能仍然太大。→ L4。\n\n### L4: compact_history — LLM 全量摘要\n\n![LLM 全量摘要](/course-assets/s08_context_compact/auto-compact.svg)\n\n前三层全跑完了,但在超大项目中连续工作 30 分钟后,token 仍然超过阈值。\n\n三步流程:\n\n1. **保存 transcript**:完整对话写入 `.transcripts/`,JSONL 格式。transcript 保留完整记录;消息列表只保留摘要,原始细节不再进入后续模型调用。\n2. **LLM 生成摘要**:把对话历史发给 LLM,要求保留当前目标、重要发现、已改文件、剩余工作、用户约束等关键信息。\n3. **替换消息列表**:所有旧消息被替换为一条摘要。\n\n```python\ndef compact_history(messages):\n transcript_path = write_transcript(messages) # 先保存完整对话\n summary = summarize_history(messages) # LLM 生成摘要\n return [{\"role\": \"user\",\n \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n```\n\n**熔断器**:连续失败 3 次后停止重试,防止死循环浪费 API 调用。\n\n### 应急: reactive_compact\n\n有时候 API 还是返回 `prompt_too_long`(413),上下文增长速度快于压缩触发速度时。\n\n这时触发 **reactive_compact**:触发方式比 compact_history 更激进(API 报错后的应急手段),但压缩策略更温和,保留最近约 5 条原始消息,只总结较早历史。同样避免留下孤立 `tool_result`。\n\n```python\ndef reactive_compact(messages):\n transcript = write_transcript(messages)\n tail_start = max(0, len(messages) - 5)\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n summary = summarize_history(messages[:tail_start])\n return [{\"role\": \"user\",\n \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *messages[tail_start:]]\n```\n\nreactive compact 有重试上限(默认 1 次)。再失败就抛出异常,不无限循环。完整的错误恢复逻辑留给 s11。\n\n### 合起来跑\n\n```python\ndef agent_loop(messages):\n reactive_retries = 0\n while True:\n # 三个预处理器(0 API 调用)\n # 顺序:budget 先跑,确保大内容落盘后再做占位和裁剪\n messages[:] = tool_result_budget(messages) # L3: 大结果落盘\n messages[:] = snip_compact(messages) # L1: 裁中间\n messages[:] = micro_compact(messages) # L2: 旧结果占位\n\n # 还不够?LLM 摘要(1 API 调用)\n if estimate_token_count(messages) > THRESHOLD:\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(...)\n except PromptTooLongError:\n if reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = reactive_compact(messages) # 应急\n reactive_retries += 1\n continue\n raise # 超过重试上限,抛出异常\n # ... 工具执行 ...\n\n # compact 工具:模型主动调用时触发 compact_history\n if block.name == \"compact\":\n messages[:] = compact_history(messages)\n results.append({..., \"content\": \"[Compacted. History summarized.]\"})\n messages.append({\"role\": \"user\", \"content\": results})\n break # 结束当前 turn,用压缩后的上下文开始新一轮\n```\n\n**顺序不能换。** L3(budget)在 L2(micro)前面,因为 micro 会把旧的大 `tool_result` 替换成一行占位符,budget 必须在那之前保存完整内容。\n\n---\n\n## 相对 s07 的变更\n\n| 组件 | 之前 (s07) | 之后 (s08) |\n|------|-----------|-----------|\n| 上下文管理 | 无(上下文无限膨胀) | 四层压缩管线 + 应急 |\n| 新函数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |\n| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) |\n| 循环 | LLM 调用 → 工具执行 | 每轮前跑三层预处理器 + 阈值触发 compact_history |\n| 设计原则 | — | 便宜的先跑,贵的后跑 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\n试试这些 prompt:\n\n1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(连续读多个文件,观察 L2 压缩旧结果)\n2. `Read every file in s08_context_compact/`(一次性读大量内容,观察 L3 落盘)\n3. 反复对话 20+ 轮,观察是否出现 `[auto compact]` 或 `[reactive compact]`\n\n观察重点:每次工具执行后,旧 tool_result 是否被压缩?连续对话后 token 超阈值时,是否自动触发了摘要?\n\n---\n\n## 接下来\n\n上下文压缩让 Agent 能跑很久不会崩。但每次压缩后,用户之前告诉它的偏好、约束也跟着丢了。能不能让 Agent 有选择地记住重要的事?\n\ns09 Memory → 三个子系统:选择记什么、提取关键信息、整理巩固。跨压缩、跨会话。\n\n\n\n" + "content": "# s08: Context Compact — 上下文总会满,要有办法腾地方\n\ns01 → s02 → s03 → s04 → s05 → s06 → s07 → `s08` → [s09](/zh/s09) → s10 → ... → s20 → s21\n> *\"上下文总会满, 要有办法腾地方\"* — 四层压缩策略, 便宜的先跑贵的后跑。\n>\n> **Harness 层**: 压缩 — 干净的记忆, 无限的会话。\n\n---\n\n## 问题\n\nAgent 跑着跑着,不动了。\n\n手里有 bash、有 read、有 write,能力是够的。但它读了一个 1000 行的文件(~4000 token),又读了 30 个文件,跑了 20 条命令。每条命令的输出、每个文件的内容,全都堆在 `messages` 列表里。\n\n上下文窗口是有限的。满了之后,API 直接拒绝:`prompt_too_long`。\n\n不压缩,Agent 根本没法在大项目里干活。\n\n---\n\n## 解决方案\n\n![Compact Overview](/course-assets/s08_context_compact/compact-overview.svg)\n\n保留 s07 的 hook 结构、技能加载、子 Agent 等骨架,省略部分工具细节以聚焦压缩。核心变动:每轮 LLM 调用前插入三层预处理器(0 API),token 仍超阈值时触发 LLM 摘要(1 API),API 报错时应急裁剪。\n\n核心设计:便宜的先跑,贵的后跑。\n\n> **与 s09 的边界:** s08 管理当前会话有限的上下文,压缩时允许丢失细节;s09 另建持久存储,只保留需要跨压缩、跨会话存在的信息。两章解决的是不同故障,因此不合并。\n\n---\n\n## 工作原理\n\n![四层压缩管线](/course-assets/s08_context_compact/compaction-layers.svg)\n\n### L1: snip_compact — 裁掉无关的旧对话\n\nAgent 跑了 80 轮对话,`messages` 攒了 160 条。最前面的\"帮我创建 hello.py\"和当前工作几乎无关了,但全占着位置。\n\n消息数超过 50 条 → 保留头部 3 条(初始上下文)和尾部 47 条(当前工作),中间裁掉;唯一额外边界条件是,不能把 `assistant(tool_use)` 和后面的 `user(tool_result)` 拆开:\n\n```python\ndef snip_compact(messages, max_messages=50):\n if len(messages) <= max_messages:\n return messages\n head_end, tail_start = 3, len(messages) - (max_messages - 3)\n if 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 snipped = tail_start - head_end\n placeholder = {\"role\": \"user\", \"content\": f\"[snipped {snipped} messages from conversation middle]\"}\n return messages[:head_end] + [placeholder] + messages[tail_start:]\n```\n\n裁掉的是消息本身,只是在切口处多做一步保护;剩下的消息里 `tool_result` 内容仍在累积。第 34 条消息里可能躺着 30KB 的旧文件内容。→ L2。\n\n### L2: micro_compact — 旧工具结果占位\n\n![旧结果占位](/course-assets/s08_context_compact/micro-compact.svg)\n\nAgent 连续读了 10 个文件。第 1-7 次的完整内容还躺在上下文里,早就不需要了,但占着大量空间。\n\n只保留最近 3 条 `tool_result` 的完整内容,更旧的替换为一行占位符:\n\n```python\nKEEP_RECENT_TOOL_RESULTS = 3\n\ndef micro_compact(messages):\n tool_results = collect_tool_result_blocks(messages)\n if len(tool_results) <= KEEP_RECENT_TOOL_RESULTS:\n return messages\n for _, _, block in tool_results[:-KEEP_RECENT_TOOL_RESULTS]:\n if len(block.get(\"content\", \"\")) > 120:\n block[\"content\"] = \"[Earlier tool result compacted. Re-run if needed.]\"\n return messages\n```\n\n旧结果清掉了,但单条新结果可能就有 500KB。一次 `cat` 大文件的输出就能打满上下文。→ L3。\n\n### L3: tool_result_budget — 大结果落盘\n\n![大结果落盘](/course-assets/s08_context_compact/layer1-budget.svg)\n\n模型一次读了 5 个大文件,单条 user 消息里所有 `tool_result` 加起来 500KB。\n\n统计最后一条 user 消息里所有 `tool_result` 的总大小。超过 200KB → 按大小排序,从最大的开始落盘到 `.task_outputs/tool-results/`,上下文里只留 `` 标记 + 前 2000 字符预览。模型看到标记后知道完整内容在磁盘上,需要时可以重新读。\n\n```python\ndef tool_result_budget(messages, max_bytes=200_000):\n last = messages[-1]\n blocks = [(i, b) for i, b in enumerate(last[\"content\"])\n if b.get(\"type\") == \"tool_result\"]\n total = sum(len(str(b.get(\"content\", \"\"))) for _, b in blocks)\n if total <= max_bytes:\n return messages\n ranked = sorted(blocks, key=lambda p: len(str(p[1].get(\"content\", \"\"))), reverse=True)\n for idx, block in ranked:\n if total <= max_bytes:\n break\n block[\"content\"] = persist_large_output(block[\"tool_use_id\"], str(block[\"content\"]))\n total = recalculate_total(blocks)\n return messages\n```\n\n前三层都是纯文本/结构操作,0 API 调用,但也无法\"理解\"对话内容。上下文可能仍然太大。→ L4。\n\n### L4: compact_history — LLM 全量摘要\n\n![LLM 全量摘要](/course-assets/s08_context_compact/auto-compact.svg)\n\n前三层全跑完了,但在超大项目中连续工作 30 分钟后,token 仍然超过阈值。\n\n三步流程:\n\n1. **保存 transcript**:完整对话写入 `.transcripts/`,JSONL 格式。transcript 保留完整记录;消息列表只保留摘要,原始细节不再进入后续模型调用。\n2. **LLM 生成摘要**:把对话历史发给 LLM,要求保留当前目标、重要发现、已改文件、剩余工作、用户约束等关键信息。\n3. **替换消息列表**:所有旧消息被替换为一条摘要。\n\n```python\ndef compact_history(messages):\n transcript_path = write_transcript(messages) # 先保存完整对话\n summary = summarize_history(messages) # LLM 生成摘要\n return [{\"role\": \"user\",\n \"content\": f\"[Compacted]\\n\\n{summary}\"}]\n```\n\n**熔断器**:连续失败 3 次后停止重试,防止死循环浪费 API 调用。\n\n### 应急: reactive_compact\n\n有时候 API 还是返回 `prompt_too_long`(413),上下文增长速度快于压缩触发速度时。\n\n这时触发 **reactive_compact**:触发方式比 compact_history 更激进(API 报错后的应急手段),但压缩策略更温和,保留最近约 5 条原始消息,只总结较早历史。同样避免留下孤立 `tool_result`。\n\n```python\ndef reactive_compact(messages):\n transcript = write_transcript(messages)\n tail_start = max(0, len(messages) - 5)\n if (tail_start > 0 and tail_start < len(messages)\n and _is_tool_result_message(messages[tail_start])\n and _message_has_tool_use(messages[tail_start - 1])):\n tail_start -= 1\n summary = summarize_history(messages[:tail_start])\n return [{\"role\": \"user\",\n \"content\": f\"[Reactive compact]\\n\\n{summary}\"}, *messages[tail_start:]]\n```\n\nreactive compact 有重试上限(默认 1 次)。再失败就抛出异常,不无限循环。完整的错误恢复逻辑留给 s11。\n\n### 合起来跑\n\n```python\ndef agent_loop(messages):\n reactive_retries = 0\n while True:\n # 三个预处理器(0 API 调用)\n # 顺序:budget 先跑,确保大内容落盘后再做占位和裁剪\n messages[:] = tool_result_budget(messages) # L3: 大结果落盘\n messages[:] = snip_compact(messages) # L1: 裁中间\n messages[:] = micro_compact(messages) # L2: 旧结果占位\n\n # 还不够?LLM 摘要(1 API 调用)\n if estimate_token_count(messages) > THRESHOLD:\n messages[:] = compact_history(messages)\n\n try:\n response = client.messages.create(...)\n except PromptTooLongError:\n if reactive_retries < MAX_REACTIVE_RETRIES:\n messages[:] = reactive_compact(messages) # 应急\n reactive_retries += 1\n continue\n raise # 超过重试上限,抛出异常\n # ... 工具执行 ...\n\n # compact 工具:模型主动调用时触发 compact_history\n if block.name == \"compact\":\n messages[:] = compact_history(messages)\n results.append({..., \"content\": \"[Compacted. History summarized.]\"})\n messages.append({\"role\": \"user\", \"content\": results})\n break # 结束当前 turn,用压缩后的上下文开始新一轮\n```\n\n**顺序不能换。** L3(budget)在 L2(micro)前面,因为 micro 会把旧的大 `tool_result` 替换成一行占位符,budget 必须在那之前保存完整内容。\n\n---\n\n## 相对 s07 的变更\n\n| 组件 | 之前 (s07) | 之后 (s08) |\n|------|-----------|-----------|\n| 上下文管理 | 无(上下文无限膨胀) | 四层压缩管线 + 应急 |\n| 新函数 | — | snip_compact, micro_compact, tool_result_budget, compact_history, reactive_compact |\n| 工具 | bash, read, write, edit, glob, todo_write, task, load_skill (8) | 8 + compact (9) |\n| 循环 | LLM 调用 → 工具执行 | 每轮前跑三层预处理器 + 阈值触发 compact_history |\n| 设计原则 | — | 便宜的先跑,贵的后跑 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s08_context_compact/code.py\n```\n\n试试这些 prompt:\n\n1. `Read the file README.md, then read code.py, then read s01_agent_loop/README.md`(连续读多个文件,观察 L2 压缩旧结果)\n2. `Read every file in s08_context_compact/`(一次性读大量内容,观察 L3 落盘)\n3. 反复对话 20+ 轮,观察是否出现 `[auto compact]` 或 `[reactive compact]`\n\n观察重点:每次工具执行后,旧 tool_result 是否被压缩?连续对话后 token 超阈值时,是否自动触发了摘要?\n\n---\n\n## 接下来\n\n上下文压缩让 Agent 能跑很久不会崩。但每次压缩后,用户之前告诉它的偏好、约束也跟着丢了。能不能让 Agent 有选择地记住重要的事?\n\ns09 Memory → 三个子系统:选择记什么、提取关键信息、整理巩固。跨压缩、跨会话。\n\n\n\n" }, { "version": "s08", @@ -189,7 +189,7 @@ "version": "s11", "locale": "zh", "title": "s11: Error Recovery — 错误不是结束,是重试的开始", - "content": "# s11: Error Recovery — 错误不是结束,是重试的开始\n\ns01 → ... → s09 → s10 → `s11` → [s12](/zh/s12) → s13 → ... → s20 → s21\n> *\"错误不是终点, 是重试的起点\"* — 升级 token、压缩上下文、切换模型。\n>\n> **Harness 层**: 韧性 — 主循环遇到错误时分类并恢复。\n\n---\n\n## 问题\n\nAgent 跑着跑着报错了:\n\n```\nError: 529 overloaded\n```\n\nAgent 崩溃了。它没有重试,没有换模型,没有减少上下文——直接崩溃。\n\nLLM API 调用可能失败。本章处理三种情况:输出截断、上下文超限和临时故障(429/529)。\n\n---\n\n## 解决方案\n\n![Error Recovery Overview](/course-assets/s11_error_recovery/error-recovery-overview.svg)\n\ns10 的循环、prompt 组装全部保留。唯一的变动:LLM 调用包裹在 try/except 里,根据错误类型走不同的恢复路径。恢复后 `continue` 回到循环开头重新调用 LLM。\n\n本章实现三种恢复模式:\n\n| 模式 | 触发 | 恢复动作 |\n|------|------|---------|\n| 输出截断 | `max_tokens` | 升级 8K→64K / 续写提示 |\n| 上下文超限 | `prompt_too_long` | reactive compact → 重试 |\n| 临时故障 | 429 / 529 | 指数退避 + 抖动,连续 529 可切换备用模型 |\n\n---\n\n## 工作原理\n\n### 路径 1: 输出被截断\n\n模型话说一半,`max_tokens` 用完了。默认 8000 token 不够它输出完整回答。\n\n第一次发生时,直接把 `max_tokens` 从 8K 升级到 64K(8 倍空间),重试同一请求——此时不追加截断输出到 messages,保持原始请求不变。如果 64K 还是不够,才保存截断输出并注入续写提示让模型接着刚才的话继续说,最多 3 次:\n\n```python\nif response.stop_reason == \"max_tokens\":\n # First escalation: don't append truncated output, retry same request\n if not state.has_escalated:\n max_tokens = ESCALATED_MAX_TOKENS\n state.has_escalated = True\n continue # messages unchanged, same request with more tokens\n # 64K still truncated: save output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if state.recovery_count < MAX_RECOVERY_RETRIES:\n messages.append({\"role\": \"user\", \"content\":\n \"Output token limit hit. Resume directly — \"\n \"no apology, no recap. Pick up mid-thought.\"})\n state.recovery_count += 1\n continue\n return # still truncated after 3 continuations\n# Normal: append after max_tokens check\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\n```\n\n升级只有一次机会,续写最多 3 次。超过就退出——继续续写也不会有实质产出。\n\n### 路径 2: 上下文超限\n\nLLM 说\"你的上下文太长了\"(`prompt_too_long`)。s08 的四层压缩全跑过了,还是超。\n\n触发 reactive compact:保留最后 5 条消息后重试一次。如果仍然超限,则退出:\n\n```python\nexcept PromptTooLongError:\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n return # 压缩过了还是超限,只能退出\n```\n\n### 路径 3: 临时故障\n\n网络抖动、429 限流、529 过载——这些不是 bug,是分布式系统的常态。\n\n429 和 529 统一走指数退避 + 抖动:第一次等 0.5 秒,第二次等 1 秒,第三次等 2 秒,最多 10 次。加随机抖动让并发请求不在同一时刻重试。连续 3 次 529 过载 → 切换到备用模型(若配置了 `FALLBACK_MODEL_ID` 环境变量):\n\n```python\ndef retry_delay(attempt, retry_after=None):\n if retry_after:\n return retry_after\n base = min(500 * (2 ** attempt), 32000) / 1000\n return base + random.uniform(0, base * 0.25)\n\ndef with_retry(fn, state, max_retries=10):\n for attempt in range(max_retries):\n try:\n return fn()\n except (RateLimitError, OverloadedError):\n delay = retry_delay(attempt)\n time.sleep(delay)\n if is_overloaded:\n state.consecutive_529 += 1\n if state.consecutive_529 >= 3 and FALLBACK_MODEL:\n state.current_model = FALLBACK_MODEL\n raise MaxRetriesExceeded()\n```\n\n退避公式:`min(500 × 2^attempt, 32000) + random(0~25%)`。如果服务器返回 `Retry-After` header,优先用那个值。\n\n### 合起来跑\n\n```python\ndef agent_loop(messages, context):\n system = get_system_prompt(context)\n state = RecoveryState()\n max_tokens = 8000\n\n while True:\n try:\n response = with_retry(\n lambda: client.messages.create(\n model=state.current_model, system=system,\n messages=messages, tools=TOOLS,\n max_tokens=max_tokens),\n state)\n except Exception as e:\n if is_prompt_too_long_error(e):\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n return\n log_error(e)\n return\n\n # max_tokens check BEFORE appending to messages\n if response.stop_reason == \"max_tokens\":\n if not state.has_escalated:\n max_tokens = 64000\n state.has_escalated = True\n continue # retry same request, messages unchanged\n # save truncated output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n messages.append({\"role\": \"user\", \"content\": CONTINUATION_PROMPT})\n continue\n # Normal completion\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n # ... tool execution ...\n```\n\n外层 try/except 捕获 API 异常(prompt_too_long 等),`with_retry` 处理瞬态错误(429/529),`stop_reason` 检查处理截断。三种恢复机制各管各的错误类型。\n\n---\n\n## 相对 s10 的变更\n\n| 组件 | 之前 (s10) | 之后 (s11) |\n|------|-----------|-----------|\n| 错误处理 | 无(一碰就崩溃) | 三种恢复模式 + 指数退避 |\n| 新常量 | — | ESCALATED_MAX_TOKENS=64000, MAX_RETRIES=10, BASE_DELAY_MS=500, FALLBACK_MODEL |\n| 新函数 | — | with_retry, retry_delay, reactive_compact, is_prompt_too_long_error, RecoveryState |\n| 工具 | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 不变 |\n| 循环 | 裸调用 LLM | try/except 包裹 + continue 重试 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s11_error_recovery/code.py\n```\n\n试试这些 prompt:\n\n1. 让 Agent 生成一段很长的代码,观察截断后是否自动续写(看 `[max_tokens] escalating` 日志)\n2. 连续读取大量文件撑大上下文,观察 reactive compact\n3. 如果遇到 429/529,观察指数退避的日志输出\n\n---\n\n## 接下来\n\nAgent 现在能在错误中自动恢复了。但它处理的任务仍然是\"一次性\"的——你给它一个任务,它做完,结束。\n\n能不能让 Agent 管理一个**任务列表**——有依赖关系、持久化到磁盘、跨会话能恢复?TODO 列表不是任务系统。\n\ns12 Task System → 任务是有依赖、有状态、持久化的图。这是多 Agent 协作的基础。\n\n\n\n" + "content": "# s11: Error Recovery — 错误不是结束,是重试的开始\n\ns01 → ... → s09 → s10 → `s11` → [s12](/zh/s12) → s13 → ... → s20 → s21\n> *\"错误不是终点, 是重试的起点\"* — 升级 token、压缩上下文、切换模型。\n>\n> **Harness 层**: 韧性 — 主循环遇到错误时分类并恢复。\n\n---\n\n## 问题\n\nAgent 跑着跑着报错了:\n\n```\nError: 529 overloaded\n```\n\nAgent 崩溃了。它没有重试、切换模型或减少上下文,调用直接终止。\n\nLLM API 调用可能失败。本章处理三种情况:输出截断、上下文超限和临时故障(429/529)。\n\n---\n\n## 解决方案\n\n![Error Recovery Overview](/course-assets/s11_error_recovery/error-recovery-overview.svg)\n\ns10 的循环、prompt 组装全部保留。唯一的变动:LLM 调用包裹在 try/except 里,根据错误类型走不同的恢复路径。恢复后 `continue` 回到循环开头重新调用 LLM。\n\n本章实现三种恢复模式:\n\n| 模式 | 触发 | 恢复动作 |\n|------|------|---------|\n| 输出截断 | `max_tokens` | 升级 8K→64K / 续写提示 |\n| 上下文超限 | `prompt_too_long` | reactive compact → 重试 |\n| 临时故障 | 429 / 529 | 指数退避 + 抖动,连续 529 可切换备用模型 |\n\n---\n\n## 工作原理\n\n### 路径 1: 输出被截断\n\n模型话说一半,`max_tokens` 用完了。默认 8000 token 不够它输出完整回答。\n\n第一次发生时,直接把 `max_tokens` 从 8K 升级到 64K(8 倍空间),然后重试同一请求。这个阶段不追加截断输出到 messages,保持原始请求不变。如果 64K 还是不够,才保存截断输出并注入续写提示让模型接着刚才的话继续说,最多 3 次:\n\n```python\nif response.stop_reason == \"max_tokens\":\n # First escalation: don't append truncated output, retry same request\n if not state.has_escalated:\n max_tokens = ESCALATED_MAX_TOKENS\n state.has_escalated = True\n continue # messages unchanged, same request with more tokens\n # 64K still truncated: save output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n if state.recovery_count < MAX_RECOVERY_RETRIES:\n messages.append({\"role\": \"user\", \"content\":\n \"Output token limit hit. Resume directly — \"\n \"no apology, no recap. Pick up mid-thought.\"})\n state.recovery_count += 1\n continue\n return # still truncated after 3 continuations\n# Normal: append after max_tokens check\nmessages.append({\"role\": \"assistant\", \"content\": response.content})\n```\n\n升级只有一次机会,续写最多 3 次。超过这个上限就退出,因为继续续写也不会有实质产出。\n\n### 路径 2: 上下文超限\n\nLLM 说\"你的上下文太长了\"(`prompt_too_long`)。s08 的四层压缩全跑过了,还是超。\n\n触发 reactive compact:保留最后 5 条消息后重试一次。如果仍然超限,则退出:\n\n```python\nexcept PromptTooLongError:\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n return # 压缩过了还是超限,只能退出\n```\n\n### 路径 3: 临时故障\n\n网络抖动、429 限流和 529 过载是分布式系统的常态,并不表示代码存在 bug。\n\n429 和 529 统一走指数退避 + 抖动:第一次等 0.5 秒,第二次等 1 秒,第三次等 2 秒,最多 10 次。加随机抖动让并发请求不在同一时刻重试。连续 3 次 529 过载 → 切换到备用模型(若配置了 `FALLBACK_MODEL_ID` 环境变量):\n\n```python\ndef retry_delay(attempt, retry_after=None):\n if retry_after:\n return retry_after\n base = min(500 * (2 ** attempt), 32000) / 1000\n return base + random.uniform(0, base * 0.25)\n\ndef with_retry(fn, state, max_retries=10):\n for attempt in range(max_retries):\n try:\n return fn()\n except (RateLimitError, OverloadedError):\n delay = retry_delay(attempt)\n time.sleep(delay)\n if is_overloaded:\n state.consecutive_529 += 1\n if state.consecutive_529 >= 3 and FALLBACK_MODEL:\n state.current_model = FALLBACK_MODEL\n raise MaxRetriesExceeded()\n```\n\n退避公式:`min(500 × 2^attempt, 32000) + random(0~25%)`。如果服务器返回 `Retry-After` header,优先用那个值。\n\n### 合起来跑\n\n```python\ndef agent_loop(messages, context):\n system = get_system_prompt(context)\n state = RecoveryState()\n max_tokens = 8000\n\n while True:\n try:\n response = with_retry(\n lambda: client.messages.create(\n model=state.current_model, system=system,\n messages=messages, tools=TOOLS,\n max_tokens=max_tokens),\n state)\n except Exception as e:\n if is_prompt_too_long_error(e):\n if not state.has_attempted_reactive_compact:\n messages[:] = reactive_compact(messages)\n state.has_attempted_reactive_compact = True\n continue\n return\n log_error(e)\n return\n\n # max_tokens check BEFORE appending to messages\n if response.stop_reason == \"max_tokens\":\n if not state.has_escalated:\n max_tokens = 64000\n state.has_escalated = True\n continue # retry same request, messages unchanged\n # save truncated output + continuation prompt\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n messages.append({\"role\": \"user\", \"content\": CONTINUATION_PROMPT})\n continue\n # Normal completion\n messages.append({\"role\": \"assistant\", \"content\": response.content})\n\n if response.stop_reason != \"tool_use\":\n return\n # ... tool execution ...\n```\n\n外层 try/except 捕获 API 异常(prompt_too_long 等),`with_retry` 处理瞬态错误(429/529),`stop_reason` 检查处理截断。三种恢复机制各管各的错误类型。\n\n---\n\n## 相对 s10 的变更\n\n| 组件 | 之前 (s10) | 之后 (s11) |\n|------|-----------|-----------|\n| 错误处理 | 无(一碰就崩溃) | 三种恢复模式 + 指数退避 |\n| 新常量 | — | ESCALATED_MAX_TOKENS=64000, MAX_RETRIES=10, BASE_DELAY_MS=500, FALLBACK_MODEL |\n| 新函数 | — | with_retry, retry_delay, reactive_compact, is_prompt_too_long_error, RecoveryState |\n| 工具 | bash, read_file, write_file (3) | bash, read_file, write_file (3) — 不变 |\n| 循环 | 裸调用 LLM | try/except 包裹 + continue 重试 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s11_error_recovery/code.py\n```\n\n试试这些 prompt:\n\n1. 让 Agent 生成一段很长的代码,观察截断后是否自动续写(看 `[max_tokens] escalating` 日志)\n2. 连续读取大量文件撑大上下文,观察 reactive compact\n3. 如果遇到 429/529,观察指数退避的日志输出\n\n---\n\n## 接下来\n\nAgent 现在能在错误中自动恢复了,但仍然一次只处理一个任务:接收任务、完成任务,然后结束。\n\n下一步要让 Agent 管理一个具备依赖关系、磁盘持久化和跨会话恢复能力的**任务列表**。TODO 列表无法承担任务系统的职责。\n\ns12 Task System → 任务是有依赖、有状态、持久化的图。这是多 Agent 协作的基础。\n\n\n\n" }, { "version": "s11", @@ -207,7 +207,7 @@ "version": "s12", "locale": "zh", "title": "s12: Task System — 目标太大,拆成小任务", - "content": "# s12: Task System — 目标太大,拆成小任务\n\ns01 → ... → s10 → s11 → `s12` → [s13](/zh/s13) → s14 → ... → s20 → s21\n\n> *\"大目标拆成小任务, 排好序, 持久化\"* — 文件持久化的任务图, 多 agent 协作的基础。\n>\n> **Harness 层**: 任务 — 持久化的目标, 可恢复的进度。\n\n---\n\n## 问题\n\nAgent 接到一个项目:搭数据库、写 API、加测试。它用 s05 的 TodoWrite 列了一张清单,然后开始写 API,写到一半发现没数据库表,回头补;加测试时发现 API 接口签名又变了...\n\n盖房子不能先盖屋顶再打地基。任务之间有先后。任务之间的前置依赖可以表示为有向无环图(DAG),本章用 `blockedBy` 记录这些依赖。\n\ns05 的 TodoWrite 是当前任务的执行清单,保存在会话内存中。这里需要的是**任务系统**:每个任务是一个 JSON 文件,任务之间有 `blockedBy` 依赖,跨会话持久化在磁盘上。\n\n---\n\n## 解决方案\n\n![Task System Overview](/course-assets/s12_task_system/task-system-overview.svg)\n\n本章新增 5 个任务工具、`.tasks/` 目录持久化和 `blockedBy` 依赖检查。\n\nTodoWrite vs Task System:\n\n| | TodoWrite (s05) | Task System (s12) |\n|---|---|---|\n| 定位 | 当前任务的执行清单 | 可恢复的任务系统 |\n| 存储 | 进程内 / 会话状态 | `.tasks/{id}.json` |\n| 依赖 | 无 | `blockedBy` / `blocks` 依赖图 |\n| 生命周期 | 当前会话 / 当前任务 | 跨会话保留 |\n| 分工 | 不负责任务认领 | `owner` / claim |\n| 状态 | pending / in_progress / completed | pending / in_progress / completed |\n| 粒度 | Agent 自己的步骤 | 可被认领、追踪、解锁的任务 |\n| 更新契约 | 整表替换 | 对单条记录执行创建、读取、更新、列举 |\n\n---\n\n## 工作原理\n\n![Task DAG](/course-assets/s12_task_system/task-dag.svg)\n\n### Task: 数据结构\n\n每个任务是一个 JSON 文件,存于 `.tasks/` 目录:\n\n```python\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str # pending | in_progress | completed\n owner: str | None # Agent 名(多 Agent 场景)\n blockedBy: list[str] # 依赖的任务 ID 列表\n```\n\nID 使用 `timestamp + random hex` 生成。\n\n### create_task: 创建任务\n\n```python\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random_hex(4)}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n```\n\n创建时自动 `save_task` 到 `.tasks/{id}.json`。`blockedBy` 声明依赖,比如 \"写 API\" 的 `blockedBy` 是 `[\"task_schema\"]`。\n\n### can_start: 依赖检查\n\n一个任务只能在它的 `blockedBy` **全部 completed** 之后才能开始:\n\n```python\ndef can_start(task_id: str) -> bool:\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False # missing dependency = blocked\n dep = load_task(dep_id)\n if dep.status != \"completed\":\n return False\n return True\n```\n\n`can_start` 是 `claim_task` 的前置检查:`blockedBy` 里有任何一个不是 completed,就不能认领。不存在的依赖视为 blocked,避免引用错误 ID 时崩溃。\n\n### claim_task: 认领任务\n\nAgent 开始做一个任务时,调用 `claim_task`:设置 `owner`,状态从 `pending` → `in_progress`。`owner` 字段记录谁在做这个任务,多 Agent 场景下防止重复认领:\n\n```python\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if load_task(d).status != \"completed\"]\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n return f\"Claimed {task_id} ({task.subject})\"\n```\n\n如果任务已被别人认领(`status != \"pending\"`),或者依赖没完成(`can_start` 返回 False),拒绝认领。\n\n### complete_task: 完成与解锁\n\n任务做完后,设为 `completed`。同时扫描所有其他任务,找出**刚刚被解锁**的下游任务:\n\n```python\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n task.status = \"completed\"\n save_task(task)\n # 找出被解锁的下游任务\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy\n and can_start(t.id)]\n msg = f\"Completed {task_id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n return msg\n```\n\n完成 \"schema\" 后,\"endpoints\" 和 \"docs\" 的 `can_start` 返回 True,它们可以开始。\n\n### get_task: 查看完整细节\n\n`list_tasks` 只显示一行摘要。`get_task` 返回完整的任务 JSON,包括 description 和依赖细节。跨会话恢复时,Agent 需要读取完整描述才能继续工作:\n\n```python\ndef get_task(task_id: str) -> str:\n task = load_task(task_id)\n return json.dumps(asdict(task), indent=2)\n```\n\n### 状态机: 两个动作,三个状态\n\n```\npending ──claim──→ in_progress ──complete──→ completed\n```\n\n这里的 `claim` / `complete` 是动作,`pending` / `in_progress` / `completed` 是状态:\n\n- **claim_task**: `pending` → `in_progress`。设置 owner,开始工作。\n- **complete_task**: `in_progress` → `completed`。把任务标记为完成,并解锁下游。\n\n### 合起来跑\n\n```python\n# 创建有依赖的任务\nschema = create_task(\"setup database schema\")\nendpoints = create_task(\"create API endpoints\", blockedBy=[schema.id])\ntests = create_task(\"write tests\", blockedBy=[endpoints.id])\ndocs = create_task(\"write docs\", blockedBy=[schema.id])\n\n# Agent 认领第一个可做的任务\nclaim_task(schema.id) # ✓ Claimed (无依赖)\ncomplete_task(schema.id) # ✓ Completed → 解锁 endpoints, docs\n\nclaim_task(endpoints.id) # ✓ Claimed (schema 已完成)\ncomplete_task(endpoints.id) # ✓ Completed → 解锁 tests\n\nclaim_task(docs.id) # ✓ Claimed (schema 已完成)\ncomplete_task(docs.id) # ✓ Completed\n\nclaim_task(tests.id) # ✓ Claimed (endpoints 已完成)\ncomplete_task(tests.id) # ✓ Completed\n```\n\n每个 `create_task` 写一个 JSON 文件,每个 `claim_task` / `complete_task` 更新文件。跨会话时,`.tasks/` 目录还在,Agent 读文件就能恢复进度。\n\n---\n\n## 相对 s11 的变更\n\n| 组件 | 之前 (s11) | 之后 (s12) |\n|------|-----------|-----------|\n| 任务管理 | 无 | Task dataclass + 5 个工具 |\n| 新类型 | — | Task(id, subject, description, status, owner, blockedBy) |\n| 存储 | 无持久化 | `.tasks/{id}.json` 跨会话 |\n| 依赖 | 无 | `blockedBy` 图 + `can_start` 检查 |\n| 工具 | bash, read_file, write_file (3) | + create_task, list_tasks, get_task, claim_task, complete_task (8) |\n| 生命周期 | — | pending → in_progress → completed(无 release 回退) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s12_task_system/code.py\n```\n\n试试这些 prompt:\n\n1. `Create tasks: setup database schema, create API endpoints (depends on schema), write tests (depends on endpoints), write docs (depends on schema)`\n2. `List all tasks and their statuses`\n3. `Claim the first unblocked task and complete it`\n4. `List tasks again — which ones are now unblocked?`\n\n观察重点:`.tasks/` 目录下是否生成了 JSON 文件?完成任务后,被阻塞的任务是否解锁?\n\n---\n\n## 接下来\n\n任务图有了。但有些任务要跑很久——比如全量测试、部署到服务器。Agent 调 LLM 按量计费,不能干等一个慢操作。\n\ns13 Background Tasks → 慢操作放后台。Agent 继续处理其他任务,后台跑完了通知它。\n\n\n\n" + "content": "# s12: Task System — 目标太大,拆成小任务\n\ns01 → ... → s10 → s11 → `s12` → [s13](/zh/s13) → s14 → ... → s20 → s21\n\n> *\"大目标拆成小任务, 排好序, 持久化\"* — 文件持久化的任务图, 多 agent 协作的基础。\n>\n> **Harness 层**: 任务 — 持久化的目标, 可恢复的进度。\n\n---\n\n## 问题\n\nAgent 接到一个项目:搭数据库、写 API、加测试。它用 s05 的 TodoWrite 列了一张清单,然后开始写 API,写到一半发现没数据库表,回头补;加测试时发现 API 接口签名又变了...\n\n盖房子不能先盖屋顶再打地基。任务之间有先后。任务之间的前置依赖可以表示为有向无环图(DAG),本章用 `blockedBy` 记录这些依赖。\n\ns05 的 TodoWrite 是当前任务的执行清单,保存在会话内存中。这里需要的是**任务系统**:每个任务是一个 JSON 文件,任务之间有 `blockedBy` 依赖,跨会话持久化在磁盘上。\n\n---\n\n## 解决方案\n\n![Task System Overview](/course-assets/s12_task_system/task-system-overview.svg)\n\n本章新增 5 个任务工具、`.tasks/` 目录持久化和 `blockedBy` 依赖检查。\n\nTodoWrite vs Task System:\n\n| | TodoWrite (s05) | Task System (s12) |\n|---|---|---|\n| 定位 | 当前任务的执行清单 | 可恢复的任务系统 |\n| 存储 | 进程内 / 会话状态 | `.tasks/{id}.json` |\n| 依赖 | 无 | `blockedBy` / `blocks` 依赖图 |\n| 生命周期 | 当前会话 / 当前任务 | 跨会话保留 |\n| 分工 | 不负责任务认领 | `owner` / claim |\n| 状态 | pending / in_progress / completed | pending / in_progress / completed |\n| 粒度 | Agent 自己的步骤 | 可被认领、追踪、解锁的任务 |\n| 更新契约 | 整表替换 | 对单条记录执行创建、读取、更新、列举 |\n\n---\n\n## 工作原理\n\n![Task DAG](/course-assets/s12_task_system/task-dag.svg)\n\n### Task: 数据结构\n\n每个任务是一个 JSON 文件,存于 `.tasks/` 目录:\n\n```python\n@dataclass\nclass Task:\n id: str\n subject: str\n description: str\n status: str # pending | in_progress | completed\n owner: str | None # Agent 名(多 Agent 场景)\n blockedBy: list[str] # 依赖的任务 ID 列表\n```\n\nID 使用 `timestamp + random hex` 生成。\n\n### create_task: 创建任务\n\n```python\ndef create_task(subject: str, description: str = \"\",\n blockedBy: list[str] | None = None) -> Task:\n task = Task(\n id=f\"task_{int(time.time())}_{random_hex(4)}\",\n subject=subject, description=description,\n status=\"pending\", owner=None,\n blockedBy=blockedBy or [],\n )\n save_task(task)\n return task\n```\n\n创建时自动 `save_task` 到 `.tasks/{id}.json`。`blockedBy` 声明依赖,比如 \"写 API\" 的 `blockedBy` 是 `[\"task_schema\"]`。\n\n### can_start: 依赖检查\n\n一个任务只能在它的 `blockedBy` **全部 completed** 之后才能开始:\n\n```python\ndef can_start(task_id: str) -> bool:\n task = load_task(task_id)\n for dep_id in task.blockedBy:\n if not _task_path(dep_id).exists():\n return False # missing dependency = blocked\n dep = load_task(dep_id)\n if dep.status != \"completed\":\n return False\n return True\n```\n\n`can_start` 是 `claim_task` 的前置检查:`blockedBy` 里有任何一个不是 completed,就不能认领。不存在的依赖视为 blocked,避免引用错误 ID 时崩溃。\n\n### claim_task: 认领任务\n\nAgent 开始做一个任务时,调用 `claim_task`:设置 `owner`,状态从 `pending` → `in_progress`。`owner` 字段记录谁在做这个任务,多 Agent 场景下防止重复认领:\n\n```python\ndef claim_task(task_id: str, owner: str = \"agent\") -> str:\n task = load_task(task_id)\n if task.status != \"pending\":\n return f\"Task {task_id} is {task.status}, cannot claim\"\n if not can_start(task_id):\n deps = [d for d in task.blockedBy\n if load_task(d).status != \"completed\"]\n return f\"Blocked by: {deps}\"\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n return f\"Claimed {task_id} ({task.subject})\"\n```\n\n如果任务已被别人认领(`status != \"pending\"`),或者依赖没完成(`can_start` 返回 False),拒绝认领。\n\n### complete_task: 完成与解锁\n\n任务做完后,设为 `completed`。同时扫描所有其他任务,找出**刚刚被解锁**的下游任务:\n\n```python\ndef complete_task(task_id: str) -> str:\n task = load_task(task_id)\n task.status = \"completed\"\n save_task(task)\n # 找出被解锁的下游任务\n unblocked = [t.subject for t in list_tasks()\n if t.status == \"pending\" and t.blockedBy\n and can_start(t.id)]\n msg = f\"Completed {task_id} ({task.subject})\"\n if unblocked:\n msg += f\"\\nUnblocked: {', '.join(unblocked)}\"\n return msg\n```\n\n完成 \"schema\" 后,\"endpoints\" 和 \"docs\" 的 `can_start` 返回 True,它们可以开始。\n\n### get_task: 查看完整细节\n\n`list_tasks` 只显示一行摘要。`get_task` 返回完整的任务 JSON,包括 description 和依赖细节。跨会话恢复时,Agent 需要读取完整描述才能继续工作:\n\n```python\ndef get_task(task_id: str) -> str:\n task = load_task(task_id)\n return json.dumps(asdict(task), indent=2)\n```\n\n### 状态机: 两个动作,三个状态\n\n```\npending ──claim──→ in_progress ──complete──→ completed\n```\n\n这里的 `claim` / `complete` 是动作,`pending` / `in_progress` / `completed` 是状态:\n\n- **claim_task**: `pending` → `in_progress`。设置 owner,开始工作。\n- **complete_task**: `in_progress` → `completed`。把任务标记为完成,并解锁下游。\n\n### 合起来跑\n\n```python\n# 创建有依赖的任务\nschema = create_task(\"setup database schema\")\nendpoints = create_task(\"create API endpoints\", blockedBy=[schema.id])\ntests = create_task(\"write tests\", blockedBy=[endpoints.id])\ndocs = create_task(\"write docs\", blockedBy=[schema.id])\n\n# Agent 认领第一个可做的任务\nclaim_task(schema.id) # ✓ Claimed (无依赖)\ncomplete_task(schema.id) # ✓ Completed → 解锁 endpoints, docs\n\nclaim_task(endpoints.id) # ✓ Claimed (schema 已完成)\ncomplete_task(endpoints.id) # ✓ Completed → 解锁 tests\n\nclaim_task(docs.id) # ✓ Claimed (schema 已完成)\ncomplete_task(docs.id) # ✓ Completed\n\nclaim_task(tests.id) # ✓ Claimed (endpoints 已完成)\ncomplete_task(tests.id) # ✓ Completed\n```\n\n每个 `create_task` 写一个 JSON 文件,每个 `claim_task` / `complete_task` 更新文件。跨会话时,`.tasks/` 目录还在,Agent 读文件就能恢复进度。\n\n---\n\n## 相对 s11 的变更\n\n| 组件 | 之前 (s11) | 之后 (s12) |\n|------|-----------|-----------|\n| 任务管理 | 无 | Task dataclass + 5 个工具 |\n| 新类型 | — | Task(id, subject, description, status, owner, blockedBy) |\n| 存储 | 无持久化 | `.tasks/{id}.json` 跨会话 |\n| 依赖 | 无 | `blockedBy` 图 + `can_start` 检查 |\n| 工具 | bash, read_file, write_file (3) | + create_task, list_tasks, get_task, claim_task, complete_task (8) |\n| 生命周期 | — | pending → in_progress → completed(无 release 回退) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s12_task_system/code.py\n```\n\n试试这些 prompt:\n\n1. `Create tasks: setup database schema, create API endpoints (depends on schema), write tests (depends on endpoints), write docs (depends on schema)`\n2. `List all tasks and their statuses`\n3. `Claim the first unblocked task and complete it`\n4. `List tasks again — which ones are now unblocked?`\n\n观察重点:`.tasks/` 目录下是否生成了 JSON 文件?完成任务后,被阻塞的任务是否解锁?\n\n---\n\n## 接下来\n\n任务图有了,但全量测试、部署到服务器等任务需要很长时间。Agent 调 LLM 按量计费,不能干等一个慢操作。\n\ns13 Background Tasks → 慢操作放后台。Agent 继续处理其他任务,后台跑完了通知它。\n\n\n\n" }, { "version": "s12", @@ -225,7 +225,7 @@ "version": "s13", "locale": "zh", "title": "s13: Background Tasks — 慢操作放后台", - "content": "# s13: Background Tasks — 慢操作放后台\n\ns01 → ... → s11 → s12 → `s13` → [s14](/zh/s14) → s15 → ... → s20 → s21\n\n> *\"慢操作丢后台, agent 继续处理\"* — 后台线程跑命令, 完成后注入通知。\n>\n> **Harness 层**: 后台 — 异步执行, 不阻塞主循环。\n\n---\n\n## 问题\n\n你用过洗衣机吗?把衣服扔进去,按下启动,然后去干别的——做饭、回消息、看论文。30 分钟后洗衣机\"滴滴滴\"提醒你:好了。你不会站在洗衣机前面干等 30 分钟。\n\nAgent 的 bash 工具也一样。`pip install torch` 要 10 分钟,`npm run build` 要 3 分钟。这些命令一跑,Agent 就在等 bash 工具返回,没法利用这段时间处理别的任务。\n\n读文件是毫秒级,不等。`git status` 一秒内返回,不等。但 `npm install`?分钟级。Agent 等 10 分钟什么都不做,而 LLM 按 token 计费,空转就是浪费。\n\n---\n\n## 解决方案\n\n![Background Tasks Overview](/course-assets/s13_background_tasks/background-tasks-overview.svg)\n\n本章把慢操作放入后台线程,Agent 继续运行循环;任务完成后,结果以通知形式注入对话。\n\n同步 vs 后台:\n\n| | 同步 (s12) | 后台 (s13) |\n|---|---|---|\n| 慢操作 | Agent 干等 | 后台线程执行 |\n| Agent 空闲 | 是 | 否,继续处理 |\n| 结果 | 立即返回 | 下轮注入通知 |\n| 判断标准 | — | `run_in_background` 参数(模型显式请求),启发式兜底 |\n\n---\n\n## 工作原理\n\n### should_run_background: 显式请求优先,启发式兜底\n\n模型通过 bash 工具的 `run_in_background` 参数显式请求后台执行。如果模型没有指定,则使用关键词启发式判断:\n\n```python\ndef is_slow_operation(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Fallback heuristic: commands likely to take > 30s.\"\"\"\n if tool_name != \"bash\":\n return False\n cmd = tool_input.get(\"command\", \"\").lower()\n slow_keywords = [\"install\", \"build\", \"test\", \"deploy\", \"compile\",\n \"docker build\", \"pip install\", \"npm install\",\n \"cargo build\", \"pytest\", \"make\"]\n return any(kw in cmd for kw in slow_keywords)\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Model explicit request takes priority; fallback to heuristic.\"\"\"\n if tool_input.get(\"run_in_background\"):\n return True\n return is_slow_operation(tool_name, tool_input)\n```\n\n### start_background_task: 后台执行与生命周期\n\n把工具调用包装成 worker 函数,扔到 daemon 线程里执行。每个后台任务有唯一 ID,状态存在 `background_tasks` 字典里:\n\n```python\n_bg_counter = 0\nbackground_tasks: dict[str, dict] = {} # bg_id → {tool_use_id, command, status}\nbackground_results: dict[str, str] = {} # bg_id → output\nbackground_lock = threading.Lock()\n\ndef start_background_task(block) -> str:\n \"\"\"Run tool in a daemon thread. Returns background task ID.\"\"\"\n global _bg_counter\n _bg_counter += 1\n bg_id = f\"bg_{_bg_counter:04d}\"\n\n def worker():\n result = execute_tool(block)\n with background_lock:\n background_tasks[bg_id][\"status\"] = \"completed\"\n background_results[bg_id] = result\n\n with background_lock:\n background_tasks[bg_id] = {\n \"tool_use_id\": block.id,\n \"command\": block.input.get(\"command\", \"\"),\n \"status\": \"running\",\n }\n thread = threading.Thread(target=worker, daemon=True)\n thread.start()\n return bg_id\n```\n\n`start_background_task()` 返回 `bg_id`。`daemon=True` 确保 Agent 进程退出时线程一起退出。\n\n### collect_background_results: 通知收集\n\n后台任务完成后,收集结果并格式化为 `` 通知:\n\n```python\ndef collect_background_results() -> list[str]:\n \"\"\"Collect completed results as task_notification messages.\"\"\"\n with background_lock:\n ready_ids = [bid for bid, task in background_tasks.items()\n if task[\"status\"] == \"completed\"]\n notifications = []\n for bg_id in ready_ids:\n with background_lock:\n task = background_tasks.pop(bg_id)\n output = background_results.pop(bg_id, \"\")\n notifications.append(\n f\"\\n\"\n f\" {bg_id}\\n\"\n f\" completed\\n\"\n f\" {task['command']}\\n\"\n f\" {output[:200]}\\n\"\n f\"\")\n return notifications\n```\n\n通知不复用原始 `tool_use_id`。原始 tool call 已经用占位 `tool_result` 回复了,后台完成是独立事件,用 `task_notification` 格式注入。这符合 Messages API 的工具配对语义:一个 `tool_use` 只对应一个 `tool_result`。\n\n### 循环中的集成\n\nagent_loop 里,工具执行分两条路,通知和结果合并为一条 user 消息:\n\n```python\nresults = []\nfor block in response.content:\n if block.type != \"tool_use\":\n continue\n if should_run_background(block.name, block.input):\n bg_id = start_background_task(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": f\"[Background task {bg_id} started] \"\n f\"Result will be available when complete.\"})\n else:\n output = execute_tool(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n\n# 通知和工具结果合入同一条 user 消息\nuser_content = []\nbg_notifications = collect_background_results()\nif bg_notifications:\n for notif in bg_notifications:\n user_content.append({\"type\": \"text\", \"text\": notif})\nuser_content.extend(results)\nmessages.append({\"role\": \"user\", \"content\": user_content})\n```\n\n慢操作先回一个带 `bg_id` 的占位 tool_result,LLM 知道这个命令还在跑,可以先做别的事。后台完成后,通知作为独立 text block 和当前轮的 tool_result 一起组成 user 消息。\n\n### 合起来跑\n\n```\nTurn 1:\n LLM → bash \"npm install\" (run_in_background=true)\n → start_background_task → bg_0001\n → tool_result: \"[Background task bg_0001 started]...\"\n → LLM: \"OK, I'll check later. Let me also read the config.\"\n\nTurn 2:\n LLM → read_file \"package.json\" (fast, sync)\n → tool_result: file content\n → collect: bg_0001 done! inject \n → LLM sees: config file + install notification in one message\n```\n\nAgent 没干等,npm install 跑后台的时候,它去读了配置文件。\n\n---\n\n## 相对 s12 的变更\n\n| 组件 | 之前 (s12) | 之后 (s13) |\n|------|-----------|-----------|\n| 执行模型 | 全部同步 | 慢操作后台线程 + 通知注入 |\n| bash schema | `command` | `command` + `run_in_background` |\n| 新函数 | — | `should_run_background`, `is_slow_operation`, `start_background_task`, `collect_background_results` |\n| 新类型 | — | `background_tasks: dict`, `background_results: dict`, `background_lock: Lock` |\n| 通知格式 | — | ``(不复用 tool_use_id) |\n| 循环行为 | 工具串行执行 | 慢操作异步,快操作同步,通知每轮收集 |\n| 工具 | 8 (s12) | 8(不变,执行策略变了) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s13_background_tasks/code.py\n```\n\n试试这些 prompt:\n\n1. `Run pip list in the background and find all Python files in this directory`\n2. `Run npm install (use run_in_background) and while waiting, read package.json`\n3. `Create a task to setup the project, then run pip list in the background`\n\n观察重点:慢操作有没有被送到后台?`bg_id` 是否返回?后台通知有没有以 `` 格式注入?\n\n---\n\n## 接下来\n\n后台任务解决了\"慢操作不阻塞\"。但如果想定时做某件事呢?比如\"每天早上 9 点跑测试\"、\"每 5 分钟检查一次服务器状态\"。\n\ns14 Cron Scheduler → 给 Agent 装一个闹钟。\n\n\n\n" + "content": "# s13: Background Tasks — 慢操作放后台\n\ns01 → ... → s11 → s12 → `s13` → [s14](/zh/s14) → s15 → ... → s20 → s21\n\n> *\"慢操作丢后台, agent 继续处理\"* — 后台线程跑命令, 完成后注入通知。\n>\n> **Harness 层**: 后台 — 异步执行, 不阻塞主循环。\n\n---\n\n## 问题\n\n你用过洗衣机吗?把衣服扔进去,按下启动,然后去做饭、回消息或看论文。30 分钟后洗衣机\"滴滴滴\"提醒你:好了。你不会站在洗衣机前面干等 30 分钟。\n\nAgent 的 bash 工具也一样。`pip install torch` 要 10 分钟,`npm run build` 要 3 分钟。这些命令一跑,Agent 就在等 bash 工具返回,没法利用这段时间处理别的任务。\n\n读文件是毫秒级,不等。`git status` 一秒内返回,不等。但 `npm install`?分钟级。Agent 等 10 分钟什么都不做,而 LLM 按 token 计费,空转就是浪费。\n\n---\n\n## 解决方案\n\n![Background Tasks Overview](/course-assets/s13_background_tasks/background-tasks-overview.svg)\n\n本章把慢操作放入后台线程,Agent 继续运行循环;任务完成后,结果以通知形式注入对话。\n\n同步 vs 后台:\n\n| | 同步 (s12) | 后台 (s13) |\n|---|---|---|\n| 慢操作 | Agent 干等 | 后台线程执行 |\n| Agent 空闲 | 是 | 否,继续处理 |\n| 结果 | 立即返回 | 下轮注入通知 |\n| 判断标准 | — | `run_in_background` 参数(模型显式请求),启发式兜底 |\n\n---\n\n## 工作原理\n\n### should_run_background: 显式请求优先,启发式兜底\n\n模型通过 bash 工具的 `run_in_background` 参数显式请求后台执行。如果模型没有指定,则使用关键词启发式判断:\n\n```python\ndef is_slow_operation(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Fallback heuristic: commands likely to take > 30s.\"\"\"\n if tool_name != \"bash\":\n return False\n cmd = tool_input.get(\"command\", \"\").lower()\n slow_keywords = [\"install\", \"build\", \"test\", \"deploy\", \"compile\",\n \"docker build\", \"pip install\", \"npm install\",\n \"cargo build\", \"pytest\", \"make\"]\n return any(kw in cmd for kw in slow_keywords)\n\ndef should_run_background(tool_name: str, tool_input: dict) -> bool:\n \"\"\"Model explicit request takes priority; fallback to heuristic.\"\"\"\n if tool_input.get(\"run_in_background\"):\n return True\n return is_slow_operation(tool_name, tool_input)\n```\n\n### start_background_task: 后台执行与生命周期\n\n把工具调用包装成 worker 函数,扔到 daemon 线程里执行。每个后台任务有唯一 ID,状态存在 `background_tasks` 字典里:\n\n```python\n_bg_counter = 0\nbackground_tasks: dict[str, dict] = {} # bg_id → {tool_use_id, command, status}\nbackground_results: dict[str, str] = {} # bg_id → output\nbackground_lock = threading.Lock()\n\ndef start_background_task(block) -> str:\n \"\"\"Run tool in a daemon thread. Returns background task ID.\"\"\"\n global _bg_counter\n _bg_counter += 1\n bg_id = f\"bg_{_bg_counter:04d}\"\n\n def worker():\n result = execute_tool(block)\n with background_lock:\n background_tasks[bg_id][\"status\"] = \"completed\"\n background_results[bg_id] = result\n\n with background_lock:\n background_tasks[bg_id] = {\n \"tool_use_id\": block.id,\n \"command\": block.input.get(\"command\", \"\"),\n \"status\": \"running\",\n }\n thread = threading.Thread(target=worker, daemon=True)\n thread.start()\n return bg_id\n```\n\n`start_background_task()` 返回 `bg_id`。`daemon=True` 确保 Agent 进程退出时线程一起退出。\n\n### collect_background_results: 通知收集\n\n后台任务完成后,收集结果并格式化为 `` 通知:\n\n```python\ndef collect_background_results() -> list[str]:\n \"\"\"Collect completed results as task_notification messages.\"\"\"\n with background_lock:\n ready_ids = [bid for bid, task in background_tasks.items()\n if task[\"status\"] == \"completed\"]\n notifications = []\n for bg_id in ready_ids:\n with background_lock:\n task = background_tasks.pop(bg_id)\n output = background_results.pop(bg_id, \"\")\n notifications.append(\n f\"\\n\"\n f\" {bg_id}\\n\"\n f\" completed\\n\"\n f\" {task['command']}\\n\"\n f\" {output[:200]}\\n\"\n f\"\")\n return notifications\n```\n\n通知不复用原始 `tool_use_id`。原始 tool call 已经用占位 `tool_result` 回复了,后台完成是独立事件,用 `task_notification` 格式注入。这符合 Messages API 的工具配对语义:一个 `tool_use` 只对应一个 `tool_result`。\n\n### 循环中的集成\n\nagent_loop 里,工具执行分两条路,通知和结果合并为一条 user 消息:\n\n```python\nresults = []\nfor block in response.content:\n if block.type != \"tool_use\":\n continue\n if should_run_background(block.name, block.input):\n bg_id = start_background_task(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id,\n \"content\": f\"[Background task {bg_id} started] \"\n f\"Result will be available when complete.\"})\n else:\n output = execute_tool(block)\n results.append({\"type\": \"tool_result\",\n \"tool_use_id\": block.id, \"content\": output})\n\n# 通知和工具结果合入同一条 user 消息\nuser_content = []\nbg_notifications = collect_background_results()\nif bg_notifications:\n for notif in bg_notifications:\n user_content.append({\"type\": \"text\", \"text\": notif})\nuser_content.extend(results)\nmessages.append({\"role\": \"user\", \"content\": user_content})\n```\n\n慢操作先回一个带 `bg_id` 的占位 tool_result,LLM 知道这个命令还在跑,可以先做别的事。后台完成后,通知作为独立 text block 和当前轮的 tool_result 一起组成 user 消息。\n\n### 合起来跑\n\n```\nTurn 1:\n LLM → bash \"npm install\" (run_in_background=true)\n → start_background_task → bg_0001\n → tool_result: \"[Background task bg_0001 started]...\"\n → LLM: \"OK, I'll check later. Let me also read the config.\"\n\nTurn 2:\n LLM → read_file \"package.json\" (fast, sync)\n → tool_result: file content\n → collect: bg_0001 done! inject \n → LLM sees: config file + install notification in one message\n```\n\nAgent 没干等,npm install 跑后台的时候,它去读了配置文件。\n\n---\n\n## 相对 s12 的变更\n\n| 组件 | 之前 (s12) | 之后 (s13) |\n|------|-----------|-----------|\n| 执行模型 | 全部同步 | 慢操作后台线程 + 通知注入 |\n| bash schema | `command` | `command` + `run_in_background` |\n| 新函数 | — | `should_run_background`, `is_slow_operation`, `start_background_task`, `collect_background_results` |\n| 新类型 | — | `background_tasks: dict`, `background_results: dict`, `background_lock: Lock` |\n| 通知格式 | — | ``(不复用 tool_use_id) |\n| 循环行为 | 工具串行执行 | 慢操作异步,快操作同步,通知每轮收集 |\n| 工具 | 8 (s12) | 8(不变,执行策略变了) |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s13_background_tasks/code.py\n```\n\n试试这些 prompt:\n\n1. `Run pip list in the background and find all Python files in this directory`\n2. `Run npm install (use run_in_background) and while waiting, read package.json`\n3. `Create a task to setup the project, then run pip list in the background`\n\n观察重点:慢操作有没有被送到后台?`bg_id` 是否返回?后台通知有没有以 `` 格式注入?\n\n---\n\n## 接下来\n\n后台任务解决了\"慢操作不阻塞\"。但如果想定时做某件事呢?比如\"每天早上 9 点跑测试\"、\"每 5 分钟检查一次服务器状态\"。\n\ns14 Cron Scheduler → 给 Agent 装一个闹钟。\n\n\n\n" }, { "version": "s13", @@ -261,7 +261,7 @@ "version": "s15", "locale": "zh", "title": "s15: Agent Teams — 团队运行时与协作协议", - "content": "# s15: Agent Teams — 团队运行时与协作协议\n\ns01 → ... → s13 → s14 → `s15` → [s16](/zh/s16) → s17 → s18 → s19 → s20 → s21\n\n> *\"一个 Agent 顾不过来,就让队友分工协作。\"* — 持久队友、消息投递与协作协议。\n>\n> **Harness 层**:团队 — 多个 Agent 如何并行工作,又如何保持可控。\n\n---\n\n## 问题\n\n当我们需要 Agent 帮助我们重构整个后端时,任务可能同时涉及配置加载、认证逻辑和测试。一个 Agent 依次处理所有模块,不但耗时更长,早期细节也会逐渐退出上下文。\n\n这类任务适合拆给多个 Agent,但用户通常只会描述需求,不会先设计一套团队:\n\n```text\n请重构这个示例后端,分别整理配置加载、认证逻辑和测试,\n保持现有接口兼容,并确保测试通过。\n```\n\n因此,Harness 需要解决的不只是“再启动几个 Agent”,而是四个连续问题:\n\n1. 谁判断任务是否值得并行,以及如何征得用户确认?\n2. 队友如何保留自己的身份和上下文,持续接收工作?\n3. 队友的结果如何自动回到 Lead,而不是依赖模型反复检查邮箱?\n4. 关机与计划审批如何变成可追踪、可执行的协议?\n\n---\n\n## 解决方案\n\n![Agent Teams Overview](/course-assets/s15_agent_teams/agent-teams-overview.svg)\n\ns15 在单 Agent Harness 外增加一个由 Lead 管理的团队运行时:\n\n- **Lead** 保持用户对话,判断是否需要团队,提出分工并等待确认。\n- **队友** 在独立线程中运行自己的 Agent Loop,完成工作后进入空闲。\n- **MessageBus** 用文件邮箱传递普通消息、结果和控制事件。\n- **运行时投递** 自动消费 Lead 的邮箱,把团队事件注入下一轮上下文。\n- **协作协议** 用 `type`、`request_id` 和状态机处理关机与计划审批。\n- **计划闸门** 在计划未批准时拦截队友的 `bash` 和 `write_file`。\n\n模型负责理解任务与分工,代码负责消息投递、生命周期和协议约束。\n\n---\n\n## 工作原理\n\n### 1. Lead 先提出团队,再等待用户确认\n\n是否创建团队会改变成本、并发度和可写入范围,不应该被隐藏在一次普通工具调用里。Lead 的 system prompt 明确规定:\n\n```python\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\n第一次输入需求时,Lead 只需要说明建议的拆分:\n\n```text\n我建议分成三个方向并行处理:\n- config:整理配置加载\n- auth:重构认证逻辑\n- tests:补齐回归测试\n\n确认后我会启动队友并协调结果。\n```\n\n用户回复“开始吧”后,Lead 才调用 `spawn_teammate`。用户表达目标,Lead 设计团队,用户确认执行边界;三者的职责不会混在一起。\n\n### 2. 每个队友拥有独立循环\n\ns06 的子 Agent 是一次性调用,返回结果后就结束。队友则是持久执行单元:\n\n| | s06 子 Agent | s15 队友 |\n|---|---|---|\n| 生命周期 | 完成一次调用后结束 | `WORK → IDLE → WORK`,直到收到关机请求 |\n| 上下文 | 只服务当前任务 | 在多轮协作中保留 |\n| 通信 | 返回一次结果 | 持续接收消息并上报事件 |\n| 协调 | 主 Agent 单向委派 | Lead 与队友双向协作 |\n\n`spawn_teammate_thread()` 为队友创建独立的 system prompt、messages 和工具集,并把循环放入 daemon 线程。Lead 不必等待某个队友结束,仍可继续派发任务或处理其他结果。\n\n### 3. MessageBus 把通信放在上下文之外\n\nLead 和队友不能共享同一份 messages,否则一个队友的工具结果会混入另一个队友的推理。`MessageBus` 为每个 Agent 建立 `.mailboxes/.jsonl`:\n\n```python\nclass MessageBus:\n def send(self, from_agent, to_agent, content,\n msg_type=\"message\", metadata=None):\n msg = {\n \"from\": from_agent,\n \"to\": to_agent,\n \"content\": content,\n \"type\": msg_type,\n \"metadata\": metadata or {},\n }\n with self._changed:\n append_jsonl(self._path(to_agent), msg)\n self._changed.notify_all()\n\n def wait_for_messages(self, agent):\n with self._changed:\n while not self.peek(agent):\n self._changed.wait()\n return self._read_unlocked(agent)\n```\n\n锁保证同一进程中的多个队友不会同时破坏邮箱文件,`Condition` 让空闲队友等待事件,而不是持续轮询。\n\n### 4. 收件箱由运行时自动投递\n\n`read_inbox()` 是消费式读取:读出后删除邮箱文件。因此,Lead 只保留一个消费入口 `consume_lead_inbox()`:\n\n```python\ndef consume_lead_inbox():\n messages = BUS.read_inbox(\"lead\")\n for message in messages:\n if message[\"type\"].endswith(\"_response\"):\n match_response(...)\n return messages\n```\n\n主循环旁的事件线程发现新消息后,会唤醒 Lead:\n\n```text\nMessageBus → consume_lead_inbox\n → 更新协议状态\n → [Team events] 注入 history\n → Lead 开始新一轮\n```\n\n`check_inbox` 不再是模型工具。消息何时到达属于运行时职责;模型只需要处理已经送入上下文的事件。\n\n### 5. 结果与空闲是两个不同事件\n\n队友完成一项工作时,运行时依次发送:\n\n```text\nresult: \"认证逻辑已重构,相关测试通过。\"\nidle_notification: \"Waiting for more work.\"\n```\n\n`result` 回答“这次工作产出了什么”,`idle_notification` 表示“这个队友现在可以接新任务”。如果把两者合成一个模糊的“done”,Lead 就无法区分任务结果和资源状态。\n\n队友进入 IDLE 后不会退出。新普通消息会让它回到 WORK;`shutdown_request` 则让它完成关机握手并结束线程。\n\n### 6. 控制消息使用类型和 request_id\n\n普通消息可以交给模型理解,关机和审批不能依赖自由文本猜测。它们使用结构化消息:\n\n![Team Protocols](/course-assets/s15_agent_teams/team-protocols-overview.svg)\n\n```python\n@dataclass\nclass ProtocolState:\n request_id: str\n type: str\n sender: str\n target: str\n status: str\n payload: str\n\n\npending_requests: dict[str, ProtocolState] = {}\n```\n\n关机协议的完整路径是:\n\n```text\nLead 创建 shutdown 请求,状态为 pending\n → shutdown_request(request_id) 发给队友\n → 队友完成当前步骤并回复 shutdown_response(request_id)\n → Lead 用 request_id 找到原请求\n → pending 变为 approved,队友线程退出\n```\n\n`request_id` 负责关联请求与回复,`type` 防止错误类型的回复修改状态,`status` 防止重复响应被再次处理。\n\n### 7. 计划审批不仅传消息,还约束执行\n\n计划协议沿相反方向流动:\n\n```text\nLead → plan_request\n队友 → plan_approval_request(request_id, plan)\nLead → plan_approval_response(request_id, approve, feedback)\n```\n\n只告诉队友“请等待批准”并不可靠,所以工具分发器检查计划状态:\n\n```python\ndef _run_teammate_tool(name, block, handlers):\n gate = plan_gates.get(name, \"not_required\")\n if block.name in {\"bash\", \"write_file\"} and gate not in {\n \"not_required\", \"approved\"\n }:\n return f\"Blocked: plan status is {gate}.\"\n return handlers[block.name](**block.input)\n```\n\n当状态为 `required`、`pending` 或 `rejected` 时,队友仍可读取文件、提交或修改计划,但不能执行 Shell 或写文件。批准消息到达后,状态变为 `approved`,工具才会放行。\n\n---\n\n## 一次完整运行\n\n```text\ns15 >> 请重构这个示例后端,分别整理配置加载、认证逻辑和测试,\n 保持现有接口兼容,并确保测试通过。\n\nLead: 建议由 config、auth、tests 三个方向并行处理,是否开始?\n\ns15 >> 开始吧\n\n[teammate] config spawned\n[teammate] auth spawned\n[teammate] tests spawned\n[bus] auth → lead (result) ...\n[bus] auth → lead (idle_notification) ...\n[wake: 2 team events → new turn]\nLead: 已收到认证部分结果,继续等待并协调其他队友。\n```\n\n终端中显示的是用户需求、Lead 分工、队友启动、消息流、结果、空闲和关机事件。用户不需要在提示词里指定谁是 Lead,也不需要手动要求检查邮箱。\n\n---\n\n## 相对 s14 的变化\n\n| 组件 | s14 | s15 |\n|---|---|---|\n| Agent 数量 | 一个 Agent | 一个 Lead + 多个持久队友 |\n| 用户交互 | 直接执行任务 | 先提出团队方案,再确认启动 |\n| 通信 | 无 | 文件邮箱 + 自动事件投递 |\n| 生命周期 | 单循环 | 队友 `WORK / IDLE / shutdown` |\n| 结果上报 | 当前 Agent 输出 | `result` 与 `idle_notification` 分离 |\n| 控制协议 | 无 | 关机与计划审批 |\n| 执行约束 | 无团队约束 | 未批准计划会拦截写入类工具 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s15_agent_teams/code.py\n```\n\n先输入一个自然需求:\n\n```text\n请重构这个示例后端,分别整理配置加载、认证逻辑和测试,\n保持现有接口兼容,并确保测试通过。\n```\n\n看到 Lead 给出分工后,再回复:\n\n```text\n开始吧\n```\n\n观察终端中的 `spawned`、`result`、`idle_notification`、`plan_approval_*` 和 `shutdown_*` 事件,以及 `.mailboxes/` 中消息写入和消费的过程。\n\n---\n\n## 接下来\n\ns15 中,Lead 仍然要明确告诉每个队友做什么。下一章把共享任务看板交给空闲队友,让它们自己发现并认领可执行任务。\n\n下一章:[s16 Autonomous Agents](/zh/s16)。\n\n\n" + "content": "# s15: Agent Teams — 团队运行时与协作协议\n\ns01 → ... → s13 → s14 → `s15` → [s16](/zh/s16) → s17 → s18 → s19 → s20 → s21\n\n> *\"一个 Agent 顾不过来,就让队友分工协作。\"* — 持久队友、消息投递与协作协议。\n>\n> **Harness 层**:团队 — 多个 Agent 如何并行工作,又如何保持可控。\n\n---\n\n## 问题\n\n当我们需要 Agent 帮助我们重构整个后端时,任务可能同时涉及配置加载、认证逻辑和测试。一个 Agent 依次处理所有模块,不但耗时更长,早期细节也会逐渐退出上下文。\n\n这类任务适合拆给多个 Agent,但用户通常只会描述需求,不会先设计一套团队:\n\n```text\n请重构这个示例后端,分别整理配置加载、认证逻辑和测试,\n保持现有接口兼容,并确保测试通过。\n```\n\n因此,Harness 需要连续解决四个问题:\n\n1. 谁判断任务是否值得并行,以及如何征得用户确认?\n2. 队友如何保留自己的身份和上下文,持续接收工作?\n3. 队友的结果如何自动回到 Lead,而不是依赖模型反复检查邮箱?\n4. 关机与计划审批如何变成可追踪、可执行的协议?\n\n---\n\n## 解决方案\n\n![Agent Teams Overview](/course-assets/s15_agent_teams/agent-teams-overview.svg)\n\ns15 在单 Agent Harness 外增加一个由 Lead 管理的团队运行时:\n\n- **Lead** 保持用户对话,判断是否需要团队,提出分工并等待确认。\n- **队友** 在独立线程中运行自己的 Agent Loop,完成工作后进入空闲。\n- **MessageBus** 用文件邮箱传递普通消息、结果和控制事件。\n- **运行时投递** 自动消费 Lead 的邮箱,把团队事件注入下一轮上下文。\n- **协作协议** 用 `type`、`request_id` 和状态机处理关机与计划审批。\n- **计划闸门** 在计划未批准时拦截队友的 `bash` 和 `write_file`。\n\n模型负责理解任务与分工,代码负责消息投递、生命周期和协议约束。\n\n---\n\n## 工作原理\n\n### 1. Lead 先提出团队,再等待用户确认\n\n是否创建团队会改变成本、并发度和可写入范围,不应该被隐藏在一次普通工具调用里。Lead 的 system prompt 明确规定:\n\n```python\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\n第一次输入需求时,Lead 只需要说明建议的拆分:\n\n```text\n我建议分成三个方向并行处理:\n- config:整理配置加载\n- auth:重构认证逻辑\n- tests:补齐回归测试\n\n确认后我会启动队友并协调结果。\n```\n\n用户回复“开始吧”后,Lead 才调用 `spawn_teammate`。用户表达目标,Lead 设计团队,用户确认执行边界;三者的职责不会混在一起。\n\n### 2. 每个队友拥有独立循环\n\ns06 的子 Agent 是一次性调用,返回结果后就结束。队友则是持久执行单元:\n\n| | s06 子 Agent | s15 队友 |\n|---|---|---|\n| 生命周期 | 完成一次调用后结束 | `WORK → IDLE → WORK`,直到收到关机请求 |\n| 上下文 | 只服务当前任务 | 在多轮协作中保留 |\n| 通信 | 返回一次结果 | 持续接收消息并上报事件 |\n| 协调 | 主 Agent 单向委派 | Lead 与队友双向协作 |\n\n`spawn_teammate_thread()` 为队友创建独立的 system prompt、messages 和工具集,并把循环放入 daemon 线程。Lead 不必等待某个队友结束,仍可继续派发任务或处理其他结果。\n\n### 3. MessageBus 把通信放在上下文之外\n\nLead 和队友不能共享同一份 messages,否则一个队友的工具结果会混入另一个队友的推理。`MessageBus` 为每个 Agent 建立 `.mailboxes/.jsonl`:\n\n```python\nclass MessageBus:\n def send(self, from_agent, to_agent, content,\n msg_type=\"message\", metadata=None):\n msg = {\n \"from\": from_agent,\n \"to\": to_agent,\n \"content\": content,\n \"type\": msg_type,\n \"metadata\": metadata or {},\n }\n with self._changed:\n append_jsonl(self._path(to_agent), msg)\n self._changed.notify_all()\n\n def wait_for_messages(self, agent):\n with self._changed:\n while not self.peek(agent):\n self._changed.wait()\n return self._read_unlocked(agent)\n```\n\n锁保证同一进程中的多个队友不会同时破坏邮箱文件,`Condition` 让空闲队友等待事件,而不是持续轮询。\n\n### 4. 收件箱由运行时自动投递\n\n`read_inbox()` 是消费式读取:读出后删除邮箱文件。因此,Lead 只保留一个消费入口 `consume_lead_inbox()`:\n\n```python\ndef consume_lead_inbox():\n messages = BUS.read_inbox(\"lead\")\n for message in messages:\n if message[\"type\"].endswith(\"_response\"):\n match_response(...)\n return messages\n```\n\n主循环旁的事件线程发现新消息后,会唤醒 Lead:\n\n```text\nMessageBus → consume_lead_inbox\n → 更新协议状态\n → [Team events] 注入 history\n → Lead 开始新一轮\n```\n\n`check_inbox` 不再是模型工具。消息何时到达属于运行时职责;模型只需要处理已经送入上下文的事件。\n\n### 5. 结果与空闲是两个不同事件\n\n队友完成一项工作时,运行时依次发送:\n\n```text\nresult: \"认证逻辑已重构,相关测试通过。\"\nidle_notification: \"Waiting for more work.\"\n```\n\n`result` 回答“这次工作产出了什么”,`idle_notification` 表示“这个队友现在可以接新任务”。如果把两者合成一个模糊的“done”,Lead 就无法区分任务结果和资源状态。\n\n队友进入 IDLE 后不会退出。新普通消息会让它回到 WORK;`shutdown_request` 则让它完成关机握手并结束线程。\n\n### 6. 控制消息使用类型和 request_id\n\n普通消息可以交给模型理解,关机和审批不能依赖自由文本猜测。它们使用结构化消息:\n\n![Team Protocols](/course-assets/s15_agent_teams/team-protocols-overview.svg)\n\n```python\n@dataclass\nclass ProtocolState:\n request_id: str\n type: str\n sender: str\n target: str\n status: str\n payload: str\n\n\npending_requests: dict[str, ProtocolState] = {}\n```\n\n关机协议的完整路径是:\n\n```text\nLead 创建 shutdown 请求,状态为 pending\n → shutdown_request(request_id) 发给队友\n → 队友完成当前步骤并回复 shutdown_response(request_id)\n → Lead 用 request_id 找到原请求\n → pending 变为 approved,队友线程退出\n```\n\n`request_id` 负责关联请求与回复,`type` 防止错误类型的回复修改状态,`status` 防止重复响应被再次处理。\n\n### 7. 计划审批不仅传消息,还约束执行\n\n计划协议沿相反方向流动:\n\n```text\nLead → plan_request\n队友 → plan_approval_request(request_id, plan)\nLead → plan_approval_response(request_id, approve, feedback)\n```\n\n只告诉队友“请等待批准”并不可靠,所以工具分发器检查计划状态:\n\n```python\ndef _run_teammate_tool(name, block, handlers):\n gate = plan_gates.get(name, \"not_required\")\n if block.name in {\"bash\", \"write_file\"} and gate not in {\n \"not_required\", \"approved\"\n }:\n return f\"Blocked: plan status is {gate}.\"\n return handlers[block.name](**block.input)\n```\n\n当状态为 `required`、`pending` 或 `rejected` 时,队友仍可读取文件、提交或修改计划,但不能执行 Shell 或写文件。批准消息到达后,状态变为 `approved`,工具才会放行。\n\n---\n\n## 一次完整运行\n\n```text\ns15 >> 请重构这个示例后端,分别整理配置加载、认证逻辑和测试,\n 保持现有接口兼容,并确保测试通过。\n\nLead: 建议由 config、auth、tests 三个方向并行处理,是否开始?\n\ns15 >> 开始吧\n\n[teammate] config spawned\n[teammate] auth spawned\n[teammate] tests spawned\n[bus] auth → lead (result) ...\n[bus] auth → lead (idle_notification) ...\n[wake: 2 team events → new turn]\nLead: 已收到认证部分结果,继续等待并协调其他队友。\n```\n\n终端中显示的是用户需求、Lead 分工、队友启动、消息流、结果、空闲和关机事件。用户不需要在提示词里指定谁是 Lead,也不需要手动要求检查邮箱。\n\n---\n\n## 相对 s14 的变化\n\n| 组件 | s14 | s15 |\n|---|---|---|\n| Agent 数量 | 一个 Agent | 一个 Lead + 多个持久队友 |\n| 用户交互 | 直接执行任务 | 先提出团队方案,再确认启动 |\n| 通信 | 无 | 文件邮箱 + 自动事件投递 |\n| 生命周期 | 单循环 | 队友 `WORK / IDLE / shutdown` |\n| 结果上报 | 当前 Agent 输出 | `result` 与 `idle_notification` 分离 |\n| 控制协议 | 无 | 关机与计划审批 |\n| 执行约束 | 无团队约束 | 未批准计划会拦截写入类工具 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s15_agent_teams/code.py\n```\n\n先输入一个自然需求:\n\n```text\n请重构这个示例后端,分别整理配置加载、认证逻辑和测试,\n保持现有接口兼容,并确保测试通过。\n```\n\n看到 Lead 给出分工后,再回复:\n\n```text\n开始吧\n```\n\n观察终端中的 `spawned`、`result`、`idle_notification`、`plan_approval_*` 和 `shutdown_*` 事件,以及 `.mailboxes/` 中消息写入和消费的过程。\n\n---\n\n## 接下来\n\ns15 中,Lead 仍然要明确告诉每个队友做什么。下一章把共享任务看板交给空闲队友,让它们自己发现并认领可执行任务。\n\n下一章:[s16 Autonomous Agents](/zh/s16)。\n\n\n" }, { "version": "s15", @@ -279,7 +279,7 @@ "version": "s16", "locale": "zh", "title": "s16: Autonomous Agents — 自己看板,自己认领", - "content": "# s16: Autonomous Agents — 自己看板,自己认领\n\ns01 → ... → s14 → s15 → `s16` → [s17](/zh/s17) → s18 → s19 → s20 → s21\n\n> *\"空闲时不只等消息,也主动寻找可以开始的工作。\"* — 共享任务板、自动发现与原子认领。\n>\n> **Harness 层**:自治 — Lead 管目标,队友从任务状态中发现下一步。\n\n---\n\n## 问题\n\ns15 的队友会在完成一项工作后进入 IDLE,等待 Lead 继续派发。如果任务板上已经有十个待办任务,Lead 仍然要逐个选择队友、发送消息,再等待结果。\n\n当任务已经被拆分,并且依赖关系也写进了任务板,谁来执行下一项工作不一定需要 Lead 再做一次模型决策。空闲队友可以直接读取共享状态,找到已经满足条件的任务并认领它。\n\n---\n\n## 解决方案\n\n![Autonomous Agents Overview](/course-assets/s16_autonomous_agents/autonomous-agents-overview.svg)\n\ns16 不改变 s15 的团队生命周期,只扩展 IDLE 状态:\n\n```text\ns15: WORK → result → IDLE → 等待消息\ns16: WORK → result → IDLE → 等待消息\n └→ 扫描任务板 → 认领 → WORK\n```\n\n新增两个函数:\n\n- `scan_unclaimed_tasks()`:找出当前可以开始的任务。\n- `claim_next_task(name)`:尝试原子认领其中一个任务。\n\n队友工具集同时增加 `list_tasks`、`claim_task` 和 `complete_task`,让认领后的工作能在同一个循环中闭合。\n\n---\n\n## 工作原理\n\n### 1. 发现任务和认领任务是两步\n\n扫描只读取状态,不修改任务:\n\n```python\ndef scan_unclaimed_tasks() -> list[Task]:\n return [\n task for task in list_tasks()\n if (\n task.status == \"pending\"\n and task.owner is None\n and can_start(task.id)\n )\n ]\n```\n\n一个任务必须同时满足三个条件:\n\n- 状态是 `pending`;\n- 还没有 `owner`;\n- `blockedBy` 中的任务都已经完成。\n\n扫描得到的只是候选列表。另一个队友可能在下一瞬间认领同一任务,因此不能把“扫描到”当成“已经拥有”。\n\n### 2. claim 在锁内完成读、检查和写入\n\n`claim_task()` 使用同一把 `task_lock` 包住完整的读改写过程:\n\n```python\ndef claim_task(task_id: str, owner: str) -> str:\n with task_lock:\n task = load_task(task_id)\n if task.status != \"pending\" or task.owner:\n return \"Task is no longer available\"\n if not can_start(task_id):\n return \"Task is blocked\"\n\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n return f\"Claimed {task.id}\"\n```\n\n`claim_next_task()` 依次尝试候选任务。某次认领失败时,它会继续尝试下一个,而不是把失败误当成成功:\n\n```python\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\n扫描负责发现,claim 负责所有权。把两者分开后,多个队友可以同时观察任务板,但每个任务只能有一个最终 owner。\n\n### 3. 消息优先,任务扫描其次\n\n队友进入 IDLE 后,先等待一小段时间的收件箱事件:\n\n```python\nwhile True:\n inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL)\n if inbox:\n handle_messages(inbox)\n break\n\n task = claim_next_task(name)\n if task:\n messages.append({\n \"role\": \"user\",\n \"content\": (\n f\"[Auto-claimed task {task.id}] \"\n f\"{task.subject}\\n{task.description}\"\n ),\n })\n break\n```\n\n这样安排有两个原因:\n\n- 关机、计划审批和 Lead 的直接消息应该尽快响应;\n- 没有消息时,空闲时间才用于寻找共享任务。\n\n如果既没有消息也没有可认领任务,队友继续保持 IDLE,不会因为一次扫描为空就退出。\n\n### 4. 自动认领后复用同一个 WORK 循环\n\n认领成功后,运行时把任务 ID、标题和描述写入队友 messages。对模型来说,它只是收到了一项新工作;文件、Shell、计划闸门、结果上报都继续使用 s15 的机制。\n\n```text\n任务板出现 ready task\n → 空闲队友扫描到候选\n → claim_task 写入 owner 和 in_progress\n → 任务进入队友 messages\n → WORK\n → complete_task\n → result + idle_notification\n → 再次扫描\n```\n\n自治不是再造一个 Agent Loop,而是给既有循环增加一个由共享状态触发的入口。\n\n---\n\n## 为什么这样设计\n\n**为什么不是 Lead 每次分配?**\n\n任务依赖已经编码在 `status`、`owner` 和 `blockedBy` 中。让 Lead 反复解释同一状态,只会增加协调轮次。\n\n**为什么不是扫描时直接改 owner?**\n\n扫描可能并发发生。把认领集中到带锁的函数中,所有调用方共享同一个所有权规则。\n\n**为什么不在没有任务时关闭队友?**\n\n暂时没有 ready task 可能只是因为依赖尚未完成。保持 IDLE 后,前置任务完成时队友可以自动接上后续工作。\n\n---\n\n## 相对 s15 的变化\n\n| 组件 | s15 | s16 |\n|---|---|---|\n| IDLE 行为 | 等待团队消息 | 先等消息,再扫描任务板 |\n| 任务分配 | Lead 明确派发 | 队友可自动认领 |\n| 任务所有权 | 调用方发起 claim | `task_lock` 保证认领原子性 |\n| 队友工具 | 文件、Shell、消息、计划 | 增加 list / claim / complete task |\n| 结果与关机 | `result`、`idle_notification`、shutdown 协议 | 保持不变 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s16_autonomous_agents/code.py\n```\n\n输入一个自然需求:\n\n```text\n请把后端改造拆到共享任务板,按依赖关系并行完成配置、认证和测试,\n保持现有接口兼容,并在最后汇总结果。\n```\n\nLead 提出团队方案后回复:\n\n```text\n开始吧\n```\n\n观察 `.tasks/` 中任务如何从 `pending` 进入 `in_progress` 和 `completed`,以及两个空闲队友是否会认领不同任务。带 `blockedBy` 的任务应该只在前置任务完成后出现为候选。\n\n---\n\n## 接下来\n\n队友已经能自己找到任务,但仍然在同一个工作目录里修改文件。下一章把任务所有权和工作目录绑定起来,让并行工作彼此隔离。\n\n下一章:[s17 Worktree Isolation](/zh/s17)。\n\n\n" + "content": "# s16: Autonomous Agents — 自己看板,自己认领\n\ns01 → ... → s14 → s15 → `s16` → [s17](/zh/s17) → s18 → s19 → s20 → s21\n\n> *\"空闲时不只等消息,也主动寻找可以开始的工作。\"* — 共享任务板、自动发现与原子认领。\n>\n> **Harness 层**:自治 — Lead 管目标,队友从任务状态中发现下一步。\n\n---\n\n## 问题\n\ns15 的队友会在完成一项工作后进入 IDLE,等待 Lead 继续派发。如果任务板上已经有十个待办任务,Lead 仍然要逐个选择队友、发送消息,再等待结果。\n\n当任务已经被拆分,并且依赖关系也写进了任务板,谁来执行下一项工作不一定需要 Lead 再做一次模型决策。空闲队友可以直接读取共享状态,找到已经满足条件的任务并认领它。\n\n---\n\n## 解决方案\n\n![Autonomous Agents Overview](/course-assets/s16_autonomous_agents/autonomous-agents-overview.svg)\n\ns16 不改变 s15 的团队生命周期,只扩展 IDLE 状态:\n\n```text\ns15: WORK → result → IDLE → 等待消息\ns16: WORK → result → IDLE → 等待消息\n └→ 扫描任务板 → 认领 → WORK\n```\n\n新增两个函数:\n\n- `scan_unclaimed_tasks()`:找出当前可以开始的任务。\n- `claim_next_task(name)`:尝试原子认领其中一个任务。\n\n队友工具集同时增加 `list_tasks`、`claim_task` 和 `complete_task`,让认领后的工作能在同一个循环中闭合。\n\n---\n\n## 工作原理\n\n### 1. 发现任务和认领任务是两步\n\n扫描只读取状态,不修改任务:\n\n```python\ndef scan_unclaimed_tasks() -> list[Task]:\n return [\n task for task in list_tasks()\n if (\n task.status == \"pending\"\n and task.owner is None\n and can_start(task.id)\n )\n ]\n```\n\n一个任务必须同时满足三个条件:\n\n- 状态是 `pending`;\n- 还没有 `owner`;\n- `blockedBy` 中的任务都已经完成。\n\n扫描得到的只是候选列表。另一个队友可能在下一瞬间认领同一任务,因此不能把“扫描到”当成“已经拥有”。\n\n### 2. claim 在锁内完成读、检查和写入\n\n`claim_task()` 使用同一把 `task_lock` 包住完整的读改写过程:\n\n```python\ndef claim_task(task_id: str, owner: str) -> str:\n with task_lock:\n task = load_task(task_id)\n if task.status != \"pending\" or task.owner:\n return \"Task is no longer available\"\n if not can_start(task_id):\n return \"Task is blocked\"\n\n task.owner = owner\n task.status = \"in_progress\"\n save_task(task)\n return f\"Claimed {task.id}\"\n```\n\n`claim_next_task()` 依次尝试候选任务。某次认领失败时,它会继续尝试下一个,而不是把失败误当成成功:\n\n```python\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\n扫描负责发现,claim 负责所有权。把两者分开后,多个队友可以同时观察任务板,但每个任务只能有一个最终 owner。\n\n### 3. 消息优先,任务扫描其次\n\n队友进入 IDLE 后,先等待一小段时间的收件箱事件:\n\n```python\nwhile True:\n inbox = BUS.wait_for_messages(name, IDLE_SCAN_INTERVAL)\n if inbox:\n handle_messages(inbox)\n break\n\n task = claim_next_task(name)\n if task:\n messages.append({\n \"role\": \"user\",\n \"content\": (\n f\"[Auto-claimed task {task.id}] \"\n f\"{task.subject}\\n{task.description}\"\n ),\n })\n break\n```\n\n这样安排有两个原因:\n\n- 关机、计划审批和 Lead 的直接消息应该尽快响应;\n- 没有消息时,空闲时间才用于寻找共享任务。\n\n如果既没有消息也没有可认领任务,队友继续保持 IDLE,不会因为一次扫描为空就退出。\n\n### 4. 自动认领后复用同一个 WORK 循环\n\n认领成功后,运行时把任务 ID、标题和描述写入队友 messages。对模型来说,它只是收到了一项新工作;文件、Shell、计划闸门、结果上报都继续使用 s15 的机制。\n\n```text\n任务板出现 ready task\n → 空闲队友扫描到候选\n → claim_task 写入 owner 和 in_progress\n → 任务进入队友 messages\n → WORK\n → complete_task\n → result + idle_notification\n → 再次扫描\n```\n\n自治是在既有 Agent Loop 上增加一个由共享状态触发的入口。\n\n---\n\n## 为什么这样设计\n\n**为什么不是 Lead 每次分配?**\n\n任务依赖已经编码在 `status`、`owner` 和 `blockedBy` 中。让 Lead 反复解释同一状态,只会增加协调轮次。\n\n**为什么不是扫描时直接改 owner?**\n\n扫描可能并发发生。把认领集中到带锁的函数中,所有调用方共享同一个所有权规则。\n\n**为什么不在没有任务时关闭队友?**\n\n暂时没有 ready task 可能只是因为依赖尚未完成。保持 IDLE 后,前置任务完成时队友可以自动接上后续工作。\n\n---\n\n## 相对 s15 的变化\n\n| 组件 | s15 | s16 |\n|---|---|---|\n| IDLE 行为 | 等待团队消息 | 先等消息,再扫描任务板 |\n| 任务分配 | Lead 明确派发 | 队友可自动认领 |\n| 任务所有权 | 调用方发起 claim | `task_lock` 保证认领原子性 |\n| 队友工具 | 文件、Shell、消息、计划 | 增加 list / claim / complete task |\n| 结果与关机 | `result`、`idle_notification`、shutdown 协议 | 保持不变 |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s16_autonomous_agents/code.py\n```\n\n输入一个自然需求:\n\n```text\n请把后端改造拆到共享任务板,按依赖关系并行完成配置、认证和测试,\n保持现有接口兼容,并在最后汇总结果。\n```\n\nLead 提出团队方案后回复:\n\n```text\n开始吧\n```\n\n观察 `.tasks/` 中任务如何从 `pending` 进入 `in_progress` 和 `completed`,以及两个空闲队友是否会认领不同任务。带 `blockedBy` 的任务应该只在前置任务完成后出现为候选。\n\n---\n\n## 接下来\n\n队友已经能自己找到任务,但仍然在同一个工作目录里修改文件。下一章把任务所有权和工作目录绑定起来,让并行工作彼此隔离。\n\n下一章:[s17 Worktree Isolation](/zh/s17)。\n\n\n" }, { "version": "s16", @@ -297,7 +297,7 @@ "version": "s17", "locale": "zh", "title": "s17: Worktree Isolation — 各干各的,互不干扰", - "content": "# s17: Worktree Isolation — 各干各的,互不干扰\n\ns01 → ... → s15 → s16 → `s17` → [s18](/zh/s18) → s19 → s20 → s21\n\n> *\"各干各的目录, 互不干扰\"* — 任务管目标, worktree 管目录, 按 ID 绑定。\n>\n> **Harness 层**: 隔离 — 并行执行的目录隔离。\n\n---\n\n## 问题\n\ns16 中,Alice 和 Bob 都在同一个目录下工作。Alice 的任务是\"重构认证模块\",Bob 的任务是\"重构 UI 登录页\"。\n\nAlice `write_file(\"config.py\", ...)`。Bob 也 `write_file(\"config.py\", ...)`。两个人改同一个文件,互相覆盖。而且无法干净地回滚——分不清哪些改动是谁的。\n\ns15-s16 解决了\"谁干什么\"(任务系统)和\"怎么通信\"(消息总线),但没解决\"在哪干\"。\n\n---\n\n## 解决方案\n\n![Worktree Overview](/course-assets/s17_worktree_isolation/worktree-overview.svg)\n\nGit worktree 让你在同一仓库中创建多个独立的工作目录,每个有自己的分支。Alice 在 `.worktrees/auth-refactor/` 下工作,Bob 在 `.worktrees/ui-login/` 下工作——互不干扰。\n\n沿用 s16 的 MessageBus、协议和自治认领机制。本章新增:\n\n| 能力 | 作用 |\n|------|------|\n| create_worktree | 为任务创建独立目录 + 独立分支 |\n| bind_task_to_worktree | 把任务和工作目录绑定(不改状态) |\n| remove_worktree / keep_worktree | 完成后清理或保留 |\n| validate_worktree_name | 拒绝路径穿越和非法字符 |\n\n---\n\n## 工作原理\n\n### 创建:任务-Worktree 绑定\n\n```python\ndef create_worktree(name: str, task_id: str = \"\") -> str:\n validate_worktree_name(name) # 只允许 [A-Za-z0-9._-]{1,64}\n path = WORKTREES_DIR / name\n ok, result = run_git([\"worktree\", \"add\", str(path), \"-b\", f\"wt/{name}\", \"HEAD\"])\n if not ok:\n return f\"Git error: {result}\"\n if task_id:\n bind_task_to_worktree(task_id, name)\n log_event(\"create\", name, task_id)\n return f\"Worktree '{name}' created at {path}\"\n\ndef bind_task_to_worktree(task_id: str, worktree_name: str):\n task = load_task(task_id)\n task.worktree = worktree_name # 只写 worktree 字段\n save_task(task) # 状态保持 pending,等队友 claim\n```\n\n绑定规则:一个任务绑定一个 worktree。绑定不改任务状态——任务仍是 `pending`,队友自动认领时才推进到 `in_progress`。这样 Lead 可以提前创建任务和 worktree,队友 idle 时自然认领带 worktree 的任务。\n\n### 队友工具的 cwd 切换\n\n每个队友都有一个 `wt_ctx` 字典,用来记录当前 worktree 路径。队友认领绑定了 worktree 的任务后,运行时会更新 `wt_ctx`;该队友的 `bash`、`read_file`、`write_file` 随后都在对应的 worktree 目录下执行:\n\n```python\n# 队友线程内部\nwt_ctx = {\"path\": None}\n\ndef _run_claim_task(task_id):\n result = claim_task(task_id, owner=name)\n if \"Claimed\" in result:\n task = load_task(task_id)\n if task.worktree:\n wt_ctx[\"path\"] = str(WORKTREES_DIR / task.worktree)\n return result\n\ndef _run_bash(command):\n return run_bash(command, cwd=wt_ctx[\"path\"]) # 在 worktree 下执行\n```\n\n### 收尾:Keep 还是 Remove\n\n任务完成后,两个选择:\n\n```python\ndef remove_worktree(name: str, discard_changes: bool = False) -> str:\n # 安全检查:有改动时默认拒绝\n if not discard_changes:\n files, commits = _count_worktree_changes(path)\n if files > 0 or commits > 0:\n return \"有未提交改动,使用 discard_changes=true 强制删除,或 keep_worktree 保留\"\n ok, _ = run_git([\"worktree\", \"remove\", str(path), \"--force\"])\n if not ok:\n return \"删除失败\"\n run_git([\"branch\", \"-D\", f\"wt/{name}\"])\n log_event(\"remove\", name)\n\ndef keep_worktree(name: str) -> str:\n log_event(\"keep\", name)\n return f\"Worktree '{name}' kept for review (branch: wt/{name})\"\n```\n\nKeep = 留着分支,等人工 review 后合并到主分支。Remove = 有改动时默认拒绝,需要 `discard_changes=true` 确认。不自动 complete task——任务完成由队友的 `complete_task` 显式触发。\n\n### 事件流:可审计\n\n每次生命周期操作写入日志,方便排查:\n\n```python\ndef log_event(event_type: str, worktree_name: str, task_id: str = \"\"):\n event = {\"type\": event_type, \"worktree\": worktree_name,\n \"task_id\": task_id, \"ts\": time.time()}\n # append to .worktrees/events.jsonl\n```\n\n事件类型包括 `create`(创建)、`remove`(删除)和 `keep`(保留)。日志用于人工排查;恢复流程可以通过 `git worktree list` 重建当前 worktree 集合。\n\n### run_git:返回成功/失败\n\n```python\ndef run_git(args: list[str]) -> tuple[bool, str]:\n r = subprocess.run([\"git\"] + args, cwd=WORKDIR, ...)\n return r.returncode == 0, output\n```\n\n`create_worktree` 和 `remove_worktree` 只在 git 命令成功后才写事件日志,保证日志反映真实状态。\n\n---\n\n## 相对 s16 的变更\n\n| 组件 | 之前 (s16) | 之后 (s17) |\n|------|-----------|-----------|\n| 工作目录 | 所有 Agent 共享 WORKDIR | 每个任务可绑定独立 git worktree |\n| Task 数据 | id/subject/status/owner/blockedBy | + worktree 字段 |\n| 队友工具 cwd | 始终 WORKDIR | 认领带 worktree 的任务时自动切换 |\n| 新函数 | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name |\n| worktree 安全 | 无 | name 校验 + 有改动时拒绝删除 |\n| 事件日志 | 无 | events.jsonl 生命周期审计 |\n| Lead 工具 | 团队与任务工具 | + create_worktree、remove_worktree、keep_worktree |\n| 队友工具 | 任务与文件工具 | 工具不变,bash/read/write 使用已认领任务的 worktree cwd |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s17_worktree_isolation/code.py\n```\n\n试试这个 prompt:\n\n`请并行重构认证模块和登录页面,确保两部分改动不会互相干扰。`\n\n观察重点:两个 worktree 的 `git status` 输出是否显示不同的分支?队友认领带 worktree 的任务后,bash 命令是否在 worktree 目录下执行?`remove_worktree` 对有改动的 worktree 是否拒绝?`.tasks/` 中的任务在绑定后状态是否仍为 `pending`?\n\n---\n\n## 接下来\n\nAgent 团队能在隔离的工作空间中自组织了。但 Agent 的能力受限于我们给它写的工具——bash、read、write、task...\n\n如果用户已经有了自己的工具怎么办?比如一个公司内部的 Jira API、一个自建的部署系统?\n\ns18 MCP Plugin → 给 Agent 装一个插件系统。外部工具通过标准协议接入,Agent 不需要知道它们是谁写的。\n\n\n\n" + "content": "# s17: Worktree Isolation — 各干各的,互不干扰\n\ns01 → ... → s15 → s16 → `s17` → [s18](/zh/s18) → s19 → s20 → s21\n\n> *\"各干各的目录, 互不干扰\"* — 任务管目标, worktree 管目录, 按 ID 绑定。\n>\n> **Harness 层**: 隔离 — 并行执行的目录隔离。\n\n---\n\n## 问题\n\ns16 中,Alice 和 Bob 都在同一个目录下工作。Alice 的任务是\"重构认证模块\",Bob 的任务是\"重构 UI 登录页\"。\n\nAlice `write_file(\"config.py\", ...)`。Bob 也 `write_file(\"config.py\", ...)`。两个人改同一个文件,互相覆盖,而且无法干净地回滚,因为已经分不清每处改动来自谁。\n\ns15-s16 解决了\"谁干什么\"(任务系统)和\"怎么通信\"(消息总线),但没解决\"在哪干\"。\n\n---\n\n## 解决方案\n\n![Worktree Overview](/course-assets/s17_worktree_isolation/worktree-overview.svg)\n\nGit worktree 让你在同一仓库中创建多个独立的工作目录,每个目录都有自己的分支。Alice 在 `.worktrees/auth-refactor/` 下工作,Bob 在 `.worktrees/ui-login/` 下工作,两者互不干扰。\n\n沿用 s16 的 MessageBus、协议和自治认领机制。本章新增:\n\n| 能力 | 作用 |\n|------|------|\n| create_worktree | 为任务创建独立目录 + 独立分支 |\n| bind_task_to_worktree | 把任务和工作目录绑定(不改状态) |\n| remove_worktree / keep_worktree | 完成后清理或保留 |\n| validate_worktree_name | 拒绝路径穿越和非法字符 |\n\n---\n\n## 工作原理\n\n### 创建:任务-Worktree 绑定\n\n```python\ndef create_worktree(name: str, task_id: str = \"\") -> str:\n validate_worktree_name(name) # 只允许 [A-Za-z0-9._-]{1,64}\n path = WORKTREES_DIR / name\n ok, result = run_git([\"worktree\", \"add\", str(path), \"-b\", f\"wt/{name}\", \"HEAD\"])\n if not ok:\n return f\"Git error: {result}\"\n if task_id:\n bind_task_to_worktree(task_id, name)\n log_event(\"create\", name, task_id)\n return f\"Worktree '{name}' created at {path}\"\n\ndef bind_task_to_worktree(task_id: str, worktree_name: str):\n task = load_task(task_id)\n task.worktree = worktree_name # 只写 worktree 字段\n save_task(task) # 状态保持 pending,等队友 claim\n```\n\n绑定规则:一个任务绑定一个 worktree。绑定不会改变任务状态。任务仍是 `pending`,队友自动认领时才推进到 `in_progress`。这样 Lead 可以提前创建任务和 worktree,队友 idle 时自然认领带 worktree 的任务。\n\n### 队友工具的 cwd 切换\n\n每个队友都有一个 `wt_ctx` 字典,用来记录当前 worktree 路径。队友认领绑定了 worktree 的任务后,运行时会更新 `wt_ctx`;该队友的 `bash`、`read_file`、`write_file` 随后都在对应的 worktree 目录下执行:\n\n```python\n# 队友线程内部\nwt_ctx = {\"path\": None}\n\ndef _run_claim_task(task_id):\n result = claim_task(task_id, owner=name)\n if \"Claimed\" in result:\n task = load_task(task_id)\n if task.worktree:\n wt_ctx[\"path\"] = str(WORKTREES_DIR / task.worktree)\n return result\n\ndef _run_bash(command):\n return run_bash(command, cwd=wt_ctx[\"path\"]) # 在 worktree 下执行\n```\n\n### 收尾:Keep 还是 Remove\n\n任务完成后,两个选择:\n\n```python\ndef remove_worktree(name: str, discard_changes: bool = False) -> str:\n # 安全检查:有改动时默认拒绝\n if not discard_changes:\n files, commits = _count_worktree_changes(path)\n if files > 0 or commits > 0:\n return \"有未提交改动,使用 discard_changes=true 强制删除,或 keep_worktree 保留\"\n ok, _ = run_git([\"worktree\", \"remove\", str(path), \"--force\"])\n if not ok:\n return \"删除失败\"\n run_git([\"branch\", \"-D\", f\"wt/{name}\"])\n log_event(\"remove\", name)\n\ndef keep_worktree(name: str) -> str:\n log_event(\"keep\", name)\n return f\"Worktree '{name}' kept for review (branch: wt/{name})\"\n```\n\nKeep = 留着分支,等人工 review 后合并到主分支。Remove = 有改动时默认拒绝,需要 `discard_changes=true` 确认。系统不会自动 complete task,任务完成由队友的 `complete_task` 显式触发。\n\n### 事件流:可审计\n\n每次生命周期操作写入日志,方便排查:\n\n```python\ndef log_event(event_type: str, worktree_name: str, task_id: str = \"\"):\n event = {\"type\": event_type, \"worktree\": worktree_name,\n \"task_id\": task_id, \"ts\": time.time()}\n # append to .worktrees/events.jsonl\n```\n\n事件类型包括 `create`(创建)、`remove`(删除)和 `keep`(保留)。日志用于人工排查;恢复流程可以通过 `git worktree list` 重建当前 worktree 集合。\n\n### run_git:返回成功/失败\n\n```python\ndef run_git(args: list[str]) -> tuple[bool, str]:\n r = subprocess.run([\"git\"] + args, cwd=WORKDIR, ...)\n return r.returncode == 0, output\n```\n\n`create_worktree` 和 `remove_worktree` 只在 git 命令成功后才写事件日志,保证日志反映真实状态。\n\n---\n\n## 相对 s16 的变更\n\n| 组件 | 之前 (s16) | 之后 (s17) |\n|------|-----------|-----------|\n| 工作目录 | 所有 Agent 共享 WORKDIR | 每个任务可绑定独立 git worktree |\n| Task 数据 | id/subject/status/owner/blockedBy | + worktree 字段 |\n| 队友工具 cwd | 始终 WORKDIR | 认领带 worktree 的任务时自动切换 |\n| 新函数 | — | create_worktree, bind_task_to_worktree, remove_worktree, keep_worktree, validate_worktree_name |\n| worktree 安全 | 无 | name 校验 + 有改动时拒绝删除 |\n| 事件日志 | 无 | events.jsonl 生命周期审计 |\n| Lead 工具 | 团队与任务工具 | + create_worktree、remove_worktree、keep_worktree |\n| 队友工具 | 任务与文件工具 | 工具不变,bash/read/write 使用已认领任务的 worktree cwd |\n\n---\n\n## 试一下\n\n```sh\ncd learn-claude-code\npython s17_worktree_isolation/code.py\n```\n\n试试这个 prompt:\n\n`请并行重构认证模块和登录页面,确保两部分改动不会互相干扰。`\n\n观察重点:两个 worktree 的 `git status` 输出是否显示不同的分支?队友认领带 worktree 的任务后,bash 命令是否在 worktree 目录下执行?`remove_worktree` 对有改动的 worktree 是否拒绝?`.tasks/` 中的任务在绑定后状态是否仍为 `pending`?\n\n---\n\n## 接下来\n\nAgent 团队能在隔离的工作空间中自组织了,但 Agent 的能力仅限于我们为它编写的 bash、read、write、task 等工具。\n\n如果用户已经有了自己的工具怎么办?比如一个公司内部的 Jira API、一个自建的部署系统?\n\ns18 MCP Plugin → 给 Agent 装一个插件系统。外部工具通过标准协议接入,Agent 不需要知道它们是谁写的。\n\n\n\n" }, { "version": "s17", @@ -315,7 +315,7 @@ "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 Agent Harness 集成 → 把 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", @@ -333,7 +333,7 @@ "version": "s19", "locale": "zh", "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" + "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", @@ -351,7 +351,7 @@ "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 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" + "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", @@ -369,7 +369,7 @@ "version": "s21", "locale": "zh", "title": "s21: Goal Loop — 什么时候停,目标说了算,不是模型说了算", - "content": "# s21: Goal Loop — 什么时候停,目标说了算,不是模型说了算\n\ns01 → ... → s19 → s20 → `s21`\n\n> *\"一轮能不能结束,看目标条件满不满足,不是模型说停就停\"* — `/goal` 在主循环每轮收尾的地方加一道闸门:每轮结束后,一个独立的判断器看可信证据够不够,不够就把模型推回去再来一轮。\n>\n> **Harness 层**: 目标闭环 — 在轮次收尾处,加一道程序控制的完成闸门。\n\n---\n\n从 s01 到 s20,一轮对话怎么结束?模型不再发 `tool_use`,循环就直接 `return` 了。一次性任务这么干没问题,做完就停。\n\n但有些目标你得盯着它做到底:\"把测试跑过\"、\"部署成功了再说\"。这时候经常出两种问题:模型做了一半觉得差不多了,自己就停了;更过分的是,它嘴上说一句 `tests passed` 就想收工。你要的其实很简单:这一轮能不能结束,不能模型自己说了算,得有个明确的条件,对着实打实的证据来判断。\n\n这条线其实从第一课就埋着了。s01 说过,退出循环本来是模型的一个决定;s04 的 Stop hook 第一次给了程序否决权。这一课把那个否决权做成完整的闭环:条件、证据、预算,三样缺一不可。\n\n## /goal:每轮收尾加一道闸门\n\n输入 `/goal <条件>` 就设了一个会话级的停止条件。程序把它存成当前活跃目标,每轮结束后,判断器检查对话记录里的可信证据够不够满足条件。不够,闸门就把这次结束拦住,塞一条\"继续干\"的提示进下一轮;够了,就清除目标,标记完成。\n\n![Goal Loop 总览](/course-assets/s21_goal_loop/goal-loop-overview.svg)\n\n和 s01 的循环比,只多了一道判断,模型想停的时候先过目标这关:\n\n```python\n# s01:模型说停就停\nif not has_tool_use(response):\n return\n# s21:想停?先过目标闸门\nif not has_tool_use(response):\n verdict = goal.evaluate_after_turn()\n if verdict == \"continuing\":\n continue # 没达成 -> 推回去再来一轮\n return # 达成/超预算/没目标 -> 真停\n```\n\n这道闸门是程序自己控制的。不是模型自己约束自己,模型甚至不知道有这么一道闸门,它只是收到了下一轮的输入,接着干就是了。\n\n## 设目标:证据从命令之后开始算\n\n`set_goal` 会存一个活跃目标:目标文本、最大轮数预算、计数器,还有 `start_index`——也就是证据窗口的起点。它取当前对话记录的长度,所以 `/goal` 这行命令本身在窗口外面。这是第一道防线:命令自己不能证明自己完成了。\n\n```python\ndef set_goal(self, objective, max_turns=20):\n self.active = {\n \"objective\": objective, \"status\": \"active\",\n \"start_index\": len(self.transcript), # 证据窗口从这里开始;命令本身在窗口外\n \"max_turns\": max_turns, \"checks\": 0, \"continuation_turns\": 0,\n }\n```\n\n## 判断器:只信实打实的证据\n\n这是整个机制最核心的地方。判断器不看整段对话,只看证据窗口里来自可信来源的消息。三层过滤,把\"嘴上说完成了但不算数\"的内容全挡在外面:\n\n```python\nTRUSTED_EVIDENCE_ORIGINS = {\"task-notification\", \"monitor-line\"}\n\ndef evidence_text(self):\n out = []\n for m in self.transcript[self.active[\"start_index\"]:]:\n if m.origin.get(\"kind\") == \"slash-command\": # 1 斜杠命令本身不算\n continue\n if m.role == \"user\" and m.content.strip().startswith(\"/goal\"): # 2 /goal 命令文本不算\n continue\n if m.origin.get(\"kind\") not in TRUSTED_EVIDENCE_ORIGINS: # 3 只信可信来源\n continue\n out.append(f\"{m.role}: {m.content}\")\n return \"\\n\".join(out)\n```\n\n效果很明显:同样一句 `tests passed`,你打字说的不算,后台任务通知带回来的才算。模型糊弄不过去,它没法靠自己说一句\"我做完了\"就把目标判成完成。这是全课程反复出现的那条信任边界的最后一次登场:s15 说协议靠字段不靠理解,s18 说注解是申报、申报可以撒谎,s21 说完成证据只看来源不看内容。\n\n`goal_satisfied()` 使用确定的关键词匹配,让示例保持离线和可复现。把判断与执行分开,才能守住可信证据边界。\n\n## 闸门三态:完成/继续/超预算\n\n`evaluate_after_turn` 每轮跑一次,三种结果:满足条件就清除目标(completed);没满足而且预算还没花完,就往队列塞一条\"继续干\"的提示,放行下一轮(continuing);预算花完就停(blocked),别让一个永远判不出来的目标无限烧钱。\n\n```python\ndef evaluate_after_turn(self):\n g = self.active\n g[\"checks\"] += 1\n if self.goal_satisfied():\n g[\"status\"] = \"completed\"; self.active = None\n return \"completed\" # 达成 -> 清除目标\n if g[\"continuation_turns\"] < g[\"max_turns\"]:\n g[\"continuation_turns\"] += 1\n self.queue.enqueue(\n value=\"继续干活,别把这条提醒当成完成证据。\",\n origin={\"kind\": \"active-goal\"})\n return \"continuing\" # 没达成 -> 塞提示,下一轮\n g[\"status\"] = \"blocked\"; self.active = None\n return \"blocked\" # 超预算 -> 放行,不再拦\n```\n\n那条\"继续干\"的提示里特意写了\"别把这条提醒当成完成证据\",连提醒本身都被排除在证据之外。三层防误判就齐了:命令文本不算、提醒文本不算、普通聊天文本不算。预算则是 s11 教过的老规矩:任何自动重试的机制都得有上限,不然一个永远判不满足的目标就是个烧钱的永动机。\n\n## 继续提示和外部异步消息分开走\n\n继续提示进的是同一个 `CommandQueue`,但它和外部异步事件(任务完成通知、监控行)不是同一种消费方式。`dequeue` 带个开关:消费外部收件箱的时候,默认跳过目标的继续提示。\n\n```python\ndef dequeue(self, include_goal_continuations=True):\n ...\n for idx, item in enumerate(self.items):\n if include_goal_continuations or item[\"origin\"].get(\"kind\") != \"active-goal\":\n return self.items.pop(idx)\n return None\n```\n\n为什么要分开?如果同一个消费者把继续提示和外部通知一起取走,后台结果还没到,提醒文本就可能被误当成新证据。分开之后,目标的推进是显式的一步,不会被异步事件带着走。\n\n## 跑起来看看\n\n`code.py` 演示了一个 `/goal until tests passed and deploy green`:设了目标之后没有可信证据,闸门一轮轮把它推回去;你直接打 `tests passed` 也不算(来源不可信);直到后台任务发来 `task-notification`,证据到位,才标记完成。还加了一个 `max_turns=2` 的小目标演示超预算拦截。\n\n```python\ns.submit(\"/goal until tests passed and deploy green\") # 设目标,窗口在命令之后\ns.submit(\"tests passed, trust me\") # 普通文本 -> 不算完成\ns.deliver_host_event(\"tests passed; deploy green\",\n source=\"task-notification\") # 可信宿主事件 -> 完成\n```\n\n`submit()` 只接受普通用户文本。可信标签必须走独立的宿主事件通道,来源由 harness 白名单校验;用户或模型文本不能给自己贴上 `task-notification` 标签。\n\n## 相对 s20 的变更\n\n| | s20 Workflow Runtime | s21 Goal Loop |\n|--|---------------------|---------------|\n| 触发方式 | 脚本控制的编排(脱离主循环) | 条件控制的继续(拉回主循环) |\n| 加在哪 | 工具层:一个 `Workflow` 工具 | 轮次收尾:一道完成闸门 |\n| 谁决定停 | 脚本跑完就停 | 目标条件对着可信证据判 |\n| 新增机制 | 脚本 DSL、后台任务、journal/续跑、结构化输出 | 目标闸门、证据信任边界、继续提示分流、预算 |\n\ns20 是把编排写成脚本、派出去脱离主循环;s21 反过来,是一股力量把控制权重拉回主循环:目标没达成,这一轮就不算结束。两个都不改 s01 那个 `while` 循环,只是从两头给它加约束。\n\n## 试一下\n\n```bash\npython s21_goal_loop/code.py # /goal until tests pass + deploy green,看闸门怎么判\n```\n\n观察:设了目标之后,每轮结束都有一条 `goal_evaluated`;普通文本判 `satisfied=False`,`task-notification` 来源判 `satisfied=True`;预算花完的时候出 `goal_blocked`。同样一句 `tests passed`,来源不同,结果完全相反。这就是 `/goal` 不会被一句空话糊弄的地方。\n\n## 接下来\n\n`/goal` 是\"拉回主循环\"的一种触发:条件控制。它和 s20 的\"脱离主循环\"正好成对,一个把工作派出去,一个把控制权拉回来。再往外,还有时间控制(`/loop`、cron)和事件控制(`Monitor`)的重入,它们共享同一套任务/通知基底;但闸门的核心已经在这里:**停不停,不是模型一句话说了算,得目标对着可信证据来判。**\n\n\n" + "content": "# s21: Goal Loop — 什么时候停,目标说了算,不是模型说了算\n\ns01 → ... → s19 → s20 → `s21`\n\n> *\"一轮能不能结束,看目标条件满不满足,不是模型说停就停\"* — `/goal` 在主循环每轮收尾的地方加一道闸门:每轮结束后,一个独立的判断器看可信证据够不够,不够就把模型推回去再来一轮。\n>\n> **Harness 层**: 目标闭环 — 在轮次收尾处,加一道程序控制的完成闸门。\n\n---\n\n从 s01 到 s20,一轮对话怎么结束?模型不再发 `tool_use`,循环就直接 `return` 了。一次性任务这么干没问题,做完就停。\n\n但有些目标你得盯着它做到底:\"把测试跑过\"、\"部署成功了再说\"。这时候经常出两种问题:模型做了一半觉得差不多了,自己就停了;更过分的是,它嘴上说一句 `tests passed` 就想收工。你要的其实很简单:这一轮能不能结束,不能模型自己说了算,得有个明确的条件,对着实打实的证据来判断。\n\n这条线其实从第一课就埋着了。s01 说过,退出循环本来是模型的一个决定;s04 的 Stop hook 第一次给了程序否决权。这一课把那个否决权做成完整的闭环:条件、证据、预算,三样缺一不可。\n\n## /goal:每轮收尾加一道闸门\n\n输入 `/goal <条件>` 就设了一个会话级的停止条件。程序把它存成当前活跃目标,每轮结束后,判断器检查对话记录里的可信证据够不够满足条件。不够,闸门就把这次结束拦住,塞一条\"继续干\"的提示进下一轮;够了,就清除目标,标记完成。\n\n![Goal Loop 总览](/course-assets/s21_goal_loop/goal-loop-overview.svg)\n\n和 s01 的循环比,只多了一道判断,模型想停的时候先过目标这关:\n\n```python\n# s01:模型说停就停\nif not has_tool_use(response):\n return\n# s21:想停?先过目标闸门\nif not has_tool_use(response):\n verdict = goal.evaluate_after_turn()\n if verdict == \"continuing\":\n continue # 没达成 -> 推回去再来一轮\n return # 达成/超预算/没目标 -> 真停\n```\n\n这道闸门是程序自己控制的。不是模型自己约束自己,模型甚至不知道有这么一道闸门,它只是收到了下一轮的输入,接着干就是了。\n\n## 设目标:证据从命令之后开始算\n\n`set_goal` 会存一个活跃目标:目标文本、最大轮数预算、计数器和 `start_index`。其中,`start_index` 表示证据窗口的起点。它取当前对话记录的长度,所以 `/goal` 这行命令本身在窗口外面。这是第一道防线:命令自己不能证明自己完成了。\n\n```python\ndef set_goal(self, objective, max_turns=20):\n self.active = {\n \"objective\": objective, \"status\": \"active\",\n \"start_index\": len(self.transcript), # 证据窗口从这里开始;命令本身在窗口外\n \"max_turns\": max_turns, \"checks\": 0, \"continuation_turns\": 0,\n }\n```\n\n## 判断器:只信实打实的证据\n\n这是整个机制最核心的地方。判断器不看整段对话,只看证据窗口里来自可信来源的消息。三层过滤,把\"嘴上说完成了但不算数\"的内容全挡在外面:\n\n```python\nTRUSTED_EVIDENCE_ORIGINS = {\"task-notification\", \"monitor-line\"}\n\ndef evidence_text(self):\n out = []\n for m in self.transcript[self.active[\"start_index\"]:]:\n if m.origin.get(\"kind\") == \"slash-command\": # 1 斜杠命令本身不算\n continue\n if m.role == \"user\" and m.content.strip().startswith(\"/goal\"): # 2 /goal 命令文本不算\n continue\n if m.origin.get(\"kind\") not in TRUSTED_EVIDENCE_ORIGINS: # 3 只信可信来源\n continue\n out.append(f\"{m.role}: {m.content}\")\n return \"\\n\".join(out)\n```\n\n效果很明显:同样一句 `tests passed`,你打字说的不算,后台任务通知带回来的才算。模型糊弄不过去,它没法靠自己说一句\"我做完了\"就把目标判成完成。这是全课程反复出现的那条信任边界的最后一次登场:s15 说协议靠字段不靠理解,s18 说注解是申报、申报可以撒谎,s21 说完成证据只看来源不看内容。\n\n`goal_satisfied()` 使用确定的关键词匹配,让示例保持离线和可复现。把判断与执行分开,才能守住可信证据边界。\n\n## 闸门三态:完成/继续/超预算\n\n`evaluate_after_turn` 每轮跑一次,三种结果:满足条件就清除目标(completed);没满足而且预算还没花完,就往队列塞一条\"继续干\"的提示,放行下一轮(continuing);预算花完就停(blocked),别让一个永远判不出来的目标无限烧钱。\n\n```python\ndef evaluate_after_turn(self):\n g = self.active\n g[\"checks\"] += 1\n if self.goal_satisfied():\n g[\"status\"] = \"completed\"; self.active = None\n return \"completed\" # 达成 -> 清除目标\n if g[\"continuation_turns\"] < g[\"max_turns\"]:\n g[\"continuation_turns\"] += 1\n self.queue.enqueue(\n value=\"继续干活,别把这条提醒当成完成证据。\",\n origin={\"kind\": \"active-goal\"})\n return \"continuing\" # 没达成 -> 塞提示,下一轮\n g[\"status\"] = \"blocked\"; self.active = None\n return \"blocked\" # 超预算 -> 放行,不再拦\n```\n\n那条\"继续干\"的提示里特意写了\"别把这条提醒当成完成证据\",连提醒本身都被排除在证据之外。三层防误判就齐了:命令文本不算、提醒文本不算、普通聊天文本不算。预算则是 s11 教过的老规矩:任何自动重试的机制都得有上限,不然一个永远判不满足的目标就是个烧钱的永动机。\n\n## 继续提示和外部异步消息分开走\n\n继续提示进的是同一个 `CommandQueue`,但它和外部异步事件(任务完成通知、监控行)不是同一种消费方式。`dequeue` 带个开关:消费外部收件箱的时候,默认跳过目标的继续提示。\n\n```python\ndef dequeue(self, include_goal_continuations=True):\n ...\n for idx, item in enumerate(self.items):\n if include_goal_continuations or item[\"origin\"].get(\"kind\") != \"active-goal\":\n return self.items.pop(idx)\n return None\n```\n\n为什么要分开?如果同一个消费者把继续提示和外部通知一起取走,后台结果还没到,提醒文本就可能被误当成新证据。分开之后,目标的推进是显式的一步,不会被异步事件带着走。\n\n## 跑起来看看\n\n`code.py` 演示了一个 `/goal until tests passed and deploy green`:设了目标之后没有可信证据,闸门一轮轮把它推回去;你直接打 `tests passed` 也不算(来源不可信);直到后台任务发来 `task-notification`,证据到位,才标记完成。还加了一个 `max_turns=2` 的小目标演示超预算拦截。\n\n```python\ns.submit(\"/goal until tests passed and deploy green\") # 设目标,窗口在命令之后\ns.submit(\"tests passed, trust me\") # 普通文本 -> 不算完成\ns.deliver_host_event(\"tests passed; deploy green\",\n source=\"task-notification\") # 可信宿主事件 -> 完成\n```\n\n`submit()` 只接受普通用户文本。可信标签必须走独立的宿主事件通道,来源由 harness 白名单校验;用户或模型文本不能给自己贴上 `task-notification` 标签。\n\n## 相对 s20 的变更\n\n| | s20 Workflow Runtime | s21 Goal Loop |\n|--|---------------------|---------------|\n| 触发方式 | 脚本控制的编排(脱离主循环) | 条件控制的继续(拉回主循环) |\n| 加在哪 | 工具层:一个 `Workflow` 工具 | 轮次收尾:一道完成闸门 |\n| 谁决定停 | 脚本跑完就停 | 目标条件对着可信证据判 |\n| 新增机制 | 脚本 DSL、后台任务、journal/续跑、结构化输出 | 目标闸门、证据信任边界、继续提示分流、预算 |\n\ns20 是把编排写成脚本、派出去脱离主循环;s21 反过来,是一股力量把控制权重拉回主循环:目标没达成,这一轮就不算结束。两个都不改 s01 那个 `while` 循环,只是从两头给它加约束。\n\n## 试一下\n\n```bash\npython s21_goal_loop/code.py # /goal until tests pass + deploy green,看闸门怎么判\n```\n\n观察:设了目标之后,每轮结束都有一条 `goal_evaluated`;普通文本判 `satisfied=False`,`task-notification` 来源判 `satisfied=True`;预算花完的时候出 `goal_blocked`。同样一句 `tests passed`,来源不同,结果完全相反。这就是 `/goal` 不会被一句空话糊弄的地方。\n\n## 接下来\n\n`/goal` 是\"拉回主循环\"的一种触发:条件控制。它和 s20 的\"脱离主循环\"正好成对,一个把工作派出去,一个把控制权拉回来。再往外,还有时间控制(`/loop`、cron)和事件控制(`Monitor`)的重入,它们共享同一套任务/通知基底;但闸门的核心已经在这里:**停不停,不是模型一句话说了算,得目标对着可信证据来判。**\n\n\n" }, { "version": "s21",