‹ ScaleSync / Agent Lab 机制导图
AGENT LAB · S01–S17(S14–S17 planned)

Agent Lab 机制导图

按新版 17 章编号整理。S01–S13 按 agent-lab/agent_lab/ 实际源码逐项核对;S14–S17 还没进入实际代码,按教材参考实现(从 S13d 实际代码连续生成的教学快照)写,单独标为 planned。每章统一写五项:解决什么问题、关键数据结构、执行步骤、边界和已知问题、一句话讲法。

本人手写 参考实现接入(助手编写或本人照参考代码贴入,手写复习待做) planned(教材参考实现,尚未进入实际代码) 缺陷源码里确认存在、尚未修复

总览导图

点任一章节跳到下方的详细说明。

Agent Lab Python 学习型 coding agent
代码已拆为 runtime / tools / context / tasks / teams

Lead 主循环一轮里,各模块按什么顺序出场

来自 runtime/loop.py 的 run_agent_loop。先把这条顺序讲清楚,再看各章。

  1. 进循环前:按最近 3 条用户输入,选出相关记忆,整轮复用S09
  2. 收外部事件:后台命令结果 → 到期的定时任务 → 团队消息(收信时自动批准有效的队员方案)S11 · S12 · S13
  3. 重组 system prompt:工具名、工作目录、记忆索引;这三样没变就直接用上次的字符串旧 s10
  4. 保存压缩前的历史快照,留给结束时的记忆提取S09
  5. 依次执行四层压缩S08
  6. 待办清单连续 3 轮没更新,就插入提醒S05
  7. 把选中的记忆拼进本轮请求的副本,调用模型;遇到 429 / 503 / 529 自动重试S09 · 旧 s11
  8. 报上下文超限:应急压缩后重试一次;输出被截断:提高输出上限或要求续写S08 · 旧 s11
  9. 模型没有调用工具:执行 Stop 回调 → 提取并合并记忆 → 本轮结束S04 · S09
  10. 模型调用了工具:PreToolUse(含权限)→ 前台执行或放到后台 → PostToolUse → 回填 tool_result → 释放已完成任务的分配记录 → 回到第 1 步S02–S04 · S11 · S13c
S01–S02

让模型能动手

S01

Agent Loop

本人手写runtime/loop.py · runtime/messages.py · runtime/bootstrap.py

解决什么问题

模型只会输出文字,它说"我要执行命令"并不会真的执行。需要一个程序接住模型的工具调用请求,真正去执行,再把结果交回模型,让它决定下一步,直到它给出最终答复。

关键数据结构

  • messages 列表:每条是 {role, content}。assistant 的 content 直接存 SDK 返回的内容块对象;工具结果是字典 {type: "tool_result", tool_use_id, content}。messages.py 负责兼容这两种写法。
  • stop_reason:tool_use 表示要执行工具;max_tokens 表示输出被截断(交给错误恢复);其他值表示答复结束。
  • LEAD_MESSAGE_HISTORY:Lead 唯一的一份历史,用户输入回合和后台事件回合共用;agent_lock 保证同一时间只有一个回合在跑。

执行步骤

  1. 用户输入:触发 UserPromptSubmit 回调,追加为一条 user 消息。
  2. 调用模型:带上 system、messages、工具菜单 TOOLS 和 max_tokens。
  3. 把模型返回的整个 content 追加为一条 assistant 消息。
  4. stop_reason 不是 tool_use:触发 Stop 回调,结束本轮,打印最后一条的文字。
  5. 是 tool_use:逐个执行这次返回的所有工具调用,每个结果按 tool_use_id 写成一个 tool_result。
  6. 本批全部 tool_result 合成一条 user 消息追加到历史,回到第 2 步。主循环本身没有轮数上限。

边界和已知问题

  • 每个 tool_use 都必须有对应的 tool_result,否则下一次请求 API 会报错。所以被拒绝、放到后台、工具名不存在、参数错误时,也都回一条文字结果。
  • 后台通知、定时任务、团队消息都用 append_user_content_blocks 追加到最后一条 user 消息里,不会插在 tool_use 和 tool_result 之间。
  • 缺陷 Lead 循环只把参数错误(TypeError)转成文字,工具函数里抛出的其他异常不捕获。大部分工具函数自己捕获了异常,但子代理里的模型调用没有;例如子代理遇到 529,异常会一路抛到 bootstrap.main,前台命令行直接退出。后台事件回合有捕获,只打印失败。队员循环捕获所有异常。

一句话讲法:主循环就是"调模型,有工具调用就执行,并按调用编号回填结果,再调模型",直到模型不再调用工具。核心约束是每个 tool_use 都必须有对应的 tool_result。

S02

Tool Use

本人手写tools/registry.py · tools/files.py · tools/shell.py

解决什么问题

只给模型一个 bash,读写文件都要拼命令,容易出错,也很难做检查。把常用操作做成专门的工具,每个工具有明确的参数说明,程序按名字找到对应函数执行。

关键数据结构

  • TOOLS:给模型看的工具菜单,每项有 name、description、input_schema(JSON Schema)。
  • TOOL_HANDLERS:工具名到 Python 函数的字典,启动时由 configure_tools() 填充。
  • 三份菜单:Lead 25 个工具,队员 10 个,子代理 5 个(SUB_TOOLS / SUB_HANDLERS)。

执行步骤

  1. 模型按 schema 给出工具名和参数。
  2. 在 TOOL_HANDLERS 里查函数;查不到就返回 Unknown: 工具名。
  3. 用 handler(**参数) 调用;参数名对不上会抛 TypeError,转成 Error: bad argument。
  4. 函数内部的文件或命令错误,也以 Error: 开头的文字返回,让模型自己修正。

五个基础工具的具体做法

  • read_file:按 UTF-8 读取;可按行数截断,并注明还剩多少行没读。
  • write_file:自动创建缺少的父目录,整个文件覆盖。
  • edit_file:原文必须精确出现,否则报错;只替换第一处。
  • glob:在工作目录内匹配,经过符号链接后仍不在目录内的结果会去掉。
  • bash:用 bash -lc 执行,默认超时 120 秒;stdout 和 stderr 合并后最多保留 50000 字符;退出码非 0 时加错误标题。

边界和已知问题

  • schema 只是调用说明,不是权限检查;权限在 S03。
  • Windows 适配:PATH 里没有 bash 时,从 Git 安装目录找 Git Bash;超时用 taskkill /F /T 结束整个进程树;程序退出时清理仍在运行的 shell 进程。
  • S13d 起,所有文件和命令函数都接收 cwd 参数,按当前任务决定在哪个目录执行。

一句话讲法:工具分两部分:给模型看的 schema 菜单,和从名字到函数的分发表。参数错误和执行错误都转成文字结果回给模型,让它自己修正。

S03–S04

执行前先检查

S03

Permission

本人手写runtime/permissions.py · tools/files.py

解决什么问题

模型可能提出危险操作,例如删文件、提权、写到工作目录外面。执行前要判断三种结果之一:直接拒绝、直接放行、问用户。

关键数据结构

  • DENY_LIST:rm -rf /、sudo、shutdown、reboot、mkfs、dd if=、> /dev/sda,按子串匹配。
  • PERMISSION_RULES:每条是 {tools, check, message}。当前两条:spawn_teammate 一律需要确认;bash 命令含 rm 、> /etc/、chmod 777 需要确认。
  • _unattended_turn:当前回合是否无人值守(由定时任务或团队事件触发)。

执行步骤(check_permission)

  1. bash 命令命中禁止清单:直接拒绝,用户同意也不能放行。
  2. write_file / edit_file 的路径解析后不在当前工作目录内:需要确认。
  3. 命中 PERMISSION_RULES:需要确认。
  4. 需要确认时:无人值守回合和队员线程没人可问,直接拒绝;前台回合在终端显示工具和参数,输入 y 或 yes 才放行,其他输入、Ctrl+C、输入结束都算拒绝。
  5. 拒绝时把原因写成 tool_result:Permission denied due to '…'。

边界和已知问题

  • 文件工具自己还会用 resolve_workspace_path 再检查一次路径(解析符号链接之后),所以即使用户同意,越界写入也会失败;越界读取同样报错。
  • bash 不受路径限制,命令能访问整台机器;禁止清单只是子串匹配,换个写法就能绕过。CHANGELOG 记录过子代理把被拒绝的 rm 改成 unlink 后删除成功的案例。所以这是应用层检查,不是沙箱。
  • S13d 起,权限检查和实际执行使用同一个任务目录,避免检查的是一个目录、执行的是另一个目录。

一句话讲法:三道检查:禁止清单直接拒绝,规则命中就问用户,文件路径不能出工作目录;没人可问时一律拒绝。它是应用层检查,不是进程隔离。

S04

Hooks

本人手写runtime/hooks.py · runtime/bootstrap.py

解决什么问题

日志、权限、提醒这类附加逻辑如果都写进主循环,循环会越来越难读。改成在几个固定时机调用登记好的函数,主循环只负责在这些时机触发。

关键数据结构

HOOKS 字典:四个事件各对应一个回调列表。当前登记(bootstrap.initialize):

事件回调作用
UserPromptSubmitlog_user_prompt用户输入和时间追加到 logs/prompts.log
PreToolUselog_tool_call打印工具名和前两个参数
permission_hookS03 的权限检查
PostToolUsewarn_large_tool_output输出超过 10 万字符时提醒,不裁剪
Stopprint_session_tool_summary统计历史里的工具结果条数

执行步骤(trigger_hooks)

  1. 按登记顺序逐个调用该事件的回调。
  2. 某个回调返回非 None 就停止,后面的不再调用。
  3. PreToolUse 返回非 None:这次工具调用被拦下,返回值作为拒绝原因。
  4. Stop 返回非 None:主循环把返回值作为新的 user 消息,继续下一轮,而不是结束。当前 Stop 回调都返回 None。
  5. 队员调用时传 skip_permission=True,跳过权限回调(队员另做权限检查),日志回调照常执行;prompt_user 参数只传给权限回调。

边界和已知问题

  • trigger_hooks 不捕获回调异常,回调出错会直接抛出。
  • Stop 能让循环继续,这是 S17 Goal Loop 要用的扩展点;现在还没有判断"目标是否真的完成"的回调。

一句话讲法:四个固定时机,按登记顺序执行回调,第一个非 None 的返回值决定结果。权限检查本身就是一个 PreToolUse 回调;Stop 的返回值可以让循环继续。

S05–S07

复杂任务拆开做

S05

TodoWrite

本人手写tools/todo.py · runtime/state.py · runtime/loop.py

解决什么问题

多步骤任务中,模型容易漏步骤或忘了做到哪一步。让它先列计划、边做边更新,用户也能在终端看到进度。

关键数据结构

  • LEAD_TODOS:[{content, status}],status 只能是 pending / in_progress / completed。
  • rounds_since_todo:连续多少轮工具调用没有更新清单。

执行步骤

  1. system prompt 要求多步骤任务先调用 todo_write 列计划。
  2. 每次传完整清单,整表替换,不是增量更新。参数是字符串时先按 JSON、再按 Python 字面量解析;检查每项都有 content 和合法的 status。
  3. 成功后在终端打印清单,计数清零。
  4. 每完成一批工具调用,计数加 1;达到 3 时往历史里追加 <reminder>Update your todos.</reminder>,计数清零。

边界和已知问题

  • 只存在内存、只属于 Lead;队员没有这个工具,程序退出就没了。
  • 它只是给模型看的清单,不会自动执行,也不检查依赖。需要持久化、依赖和负责人时用 S10 的任务系统。
  • 提醒会写进历史,占用上下文。

一句话讲法:会话内的计划清单,每次整表替换,三轮不更新就提醒。它不执行任何东西,需要持久化和依赖时用任务系统。

S06

Subagent

本人手写runtime/subagent.py · tools/registry.py

解决什么问题

调查一个报错可能要读十几个文件,过程全留在主历史里会占满上下文。把这类工作交给一个独立的子代理,只把结论带回来。

关键数据结构

  • 子代理自己的 messages:只从一条任务描述开始。
  • SUB_TOOLS / SUB_HANDLERS:bash、read_file、write_file、edit_file、glob。
  • SUBAGENT_SYSTEM_PROMPT:说明在哪个目录、完成后给简短总结、不要再委派。

执行步骤

  1. Lead 调用 task(description)。
  2. 新建只含这条描述的历史,循环调用模型,最多 30 轮。
  3. 每个工具调用同样经过 PreToolUse(含权限)和 PostToolUse 回调。
  4. 模型不再调用工具就结束;取最后一条消息的文字,没有就往前找最近一条有文字的 assistant 消息。
  5. 只把这段文字作为 tool_result 返回 Lead,子代理的历史全部丢弃。

边界和已知问题

  • 同步执行,Lead 在这期间等待;不是常驻队员。
  • 菜单里没有 task,所以不能再派子代理;也没有任务、定时、组队工具。
  • 缺陷 子代理的模型调用没有走错误恢复,也没有上下文压缩;遇到 429 / 529 会直接抛出,见 S01 的异常说明。
  • 始终在默认 workspace 执行,不跟随 Lead 当前任务绑定的 worktree。
  • 30 轮用完时,返回的是最近一条有文字的 assistant 内容,不一定是结论。

一句话讲法:子代理用一份全新的历史和 5 个基础工具做一件事,最多 30 轮,只把最后的文字结论交回主 Agent,用来隔离上下文。

S07

Skill Loading

本人手写context/skills.py · context/prompts.py

解决什么问题

操作指南(例如怎么写测试、怎么部署)如果全放进 system prompt,每轮都占上下文。先只告诉模型有哪些技能,需要时再取全文。

关键数据结构

  • 技能文件:workspace/skills/<目录>/SKILL.md,开头 YAML 写 name、description。
  • SKILL_REGISTRY:技能名到 {name, description, content} 的字典,content 是含 YAML 头的原文。

执行步骤

  1. 启动时扫描每个子目录的 SKILL.md,用 yaml.safe_load 解析文件头;缺 name 用目录名,缺 description 用第一行。
  2. 全文读进内存注册表。
  3. 每轮 system prompt 只放"名字:简介"目录,并说明需要时用 load_skill。
  4. 模型调用 load_skill(name),全文作为 tool_result 进入上下文;名字不对返回 Skill not found,让模型改正。

边界和已知问题

  • "按需"指按需进入上下文,不是按需读文件;启动后新加的技能要重启才能看到。
  • 加载后的全文就是普通 tool_result,之后会被 S08 第 3 层当作旧结果清理掉。

一句话讲法:两级披露:system prompt 里只放技能目录,模型用 load_skill 按名字取全文;全文作为工具结果进入上下文,之后照常参与压缩。

S08–S09 · 旧 s10

上下文和记忆

S08

Context Compact:四层压缩,成本从低到高

本人手写context/compression.py · runtime/loop.py:53-61

解决什么问题

对话和工具输出越积越多,请求会超出模型的上下文上限,成本也会上升。每轮调模型前先减少要发送的内容,优先使用不调用模型的办法。

关键数据结构(阈值和存放位置)

单条结果存盘阈值 PERSIST_THRESHOLD30000 字符
一批工具结果总量上限200000 字符
历史条数上限(保留头 3 尾 47)50 条
保留全文的最近工具结果 KEEP_RECENT3 个
触发模型摘要 CONTEXT_LIMIT50000 字符
摘要输入截取前 80000 字符
应急压缩保留最近原文5 条
大结果目录 / 历史存档目录.task_outputs/tool-results/ · .transcripts/

执行步骤

每轮调用模型前依次执行。前三层只处理数据、不调用模型;第四层只在前三层做完仍超阈值时才调用一次模型。长度都用 len(str(...)) 按字符数估算,不是字节数,也不是 token 数。

第 1 层 · limit_tool_output_chars

大结果存盘

检查范围
只看最后一条消息里的工具结果
触发
这批结果总长超过 200000 字符
做法
从最长的一条开始,把单条超过 30000 字符的结果全文写到 .task_outputs/tool-results/<id>.txt,上下文里只留文件路径和前 2000 字符原文
丢不丢
是截取,不是摘要;全文还在磁盘上
第 2 层 · trim_message_history

历史裁剪

触发
消息超过 50 条(按条数,不按轮数)
做法
保留前 3 条和最近 47 条,中间换成一条 [snipped N messages]
边界
切点不能把 tool_use 和对应的 tool_result 分开,会挪动切点,所以 50 不是严格上限
丢不丢
中间部分直接丢弃,不做摘要
第 3 层 · clear_old_tool_outputs

清理旧工具结果

触发
每轮执行;工具结果超过 3 个时才有效果
做法
最近 3 个保留全文;更早且超过 120 字符的,正文换成 [Earlier tool result compacted. Re-run if needed.]
丢不丢
丢,不备份;需要时让模型重新执行工具
第 4 层 · summarize_message_history

模型摘要

触发
前三层之后,整段历史仍超过 50000 字符
做法
先把完整历史写到 .transcripts/transcript_<时间>.jsonl,再让模型按五项摘要:当前目标、关键发现和决策、读过或改过的文件、剩余工作、用户约束
结果
整段历史换成一条 [Compacted] 消息
丢不丢
有损;唯一调用模型的一层
另外两个入口
  • 手动压缩:模型调用 compact 工具,走第 4 层,本批剩余的工具调用不再执行。
  • 应急压缩:API 报上下文超限(按错误文字识别,例如 prompt_too_long)→ 存档 → 摘要较早部分、保留最近 5 条原文 → 重试,最多 1 次。

边界和已知问题(2026-10-14 复核,未修)

  • 缺陷 大结果存盘目录在 workspace/ 外,模型用 read_file 读不到,只能用 bash 分段读(每次最多 50000 字符)。
  • 缺陷 完整历史的存档路径只打印到终端,模型不知道去哪读;应急压缩连路径都不打印。
  • 缺陷 模型摘要只取序列化后的前 80000 字符,历史很长时最近的内容进不了摘要。
  • config.py 注释把阈值写成"字节",实际是字符数。
  • compact 工具的 focus 参数目前没有被使用。
  • 主动摘要把整段历史换成一条,不保留最近几轮原文;也没有评估过摘要丢了多少信息。

一句话讲法:每轮调模型前分四层整理上下文,前三层只截取、裁剪、替换,不调用模型;最后仍超限才调一次模型摘要,摘要前先存完整记录。前三层按成本从低到高排,任何切分都不拆开工具调用和它的结果。

S09

Memory:存在哪、何时选、何时写

本人手写context/memory.py · context/prompts.py · runtime/loop.py

解决什么问题

新会话里 Agent 不记得用户偏好和项目背景,每次都要重说。把值得长期保留的信息存成文件,下次按需取用。

关键数据结构

  • 记忆文件:.memory/<name>.md,一条记忆一个文件,文件头写 name、description、type。type 分四类:user / feedback / project / reference。
  • 索引:.memory/MEMORY.md,每行"名字 + 文件名 + 简介",每次写入记忆后自动重建。
  • 合并阈值 CONSOLIDATE_THRESHOLD = 10。

执行步骤

  1. 索引放进 system prompt每轮 · collect_lead_prompt_context
    • 只放 MEMORY.md 里的目录(名字和简介),不放正文,让模型知道有哪些记忆。
  2. 选出相关记忆每次进入主循环时一次 · select_relevant_memories
    • 取最近 3 条用户文字(最多 2000 字符),连同记忆目录交给模型,要求只返回编号的 JSON 数组,最多选 5 条。
    • 模型调用或解析失败时,改用关键词匹配:长度大于 3 的词出现在名字或简介里就选中。
  3. 注入本轮请求每次调模型 · run_agent_loop
    • 选中记忆的全文包在 <relevant_memories> 标签里,拼到本轮用户消息前面。
    • 只改发给模型的副本,不写进历史,所以不会被压缩,也不会每轮重复累积。
  4. 结束时提取模型不再调用工具、且不是无人值守回合 · extract_and_save_memories
    • 用压缩前的快照,取最后 10 条消息(最多 4000 字符)和现有记忆目录交给模型,提取用户偏好、约束、项目事实,返回 JSON。
    • description 和 body 都有的条目才写文件;同名会覆盖;失败不打断主流程。
  5. 数量多了就合并记忆文件达到 10 个 · consolidate_memories
    • 让模型去重、删除过时或矛盾的内容、总数控制在 30 条以内,返回新的 JSON。
    • 先校验模型结果,再把旧文件移到 .memory/.bak,写新文件;写入中途失败就删掉新文件、把旧文件搬回来。

边界和已知问题

  • 记忆保存的是筛选后的长期信息,不是完整会话恢复。压缩处理本次会话的历史,记忆服务下一次会话,两者目的不同。
  • 记忆一共有三处调用模型:选择、提取、合并;都没有走错误恢复的重试。
  • 缺陷 rebuild_memory_index 读取的键拼成了 descrption,所以索引里显示的是正文首行,不是 description。
  • 缺陷 选择记忆时,列表形式的内容只按 SDK 对象提取文字,字典形式的 text 块取不到。
  • 把旧文件移到 .bak 这一步本身不在回滚保护内。

一句话讲法:记忆是 Markdown 文件加一个索引。索引每轮进 system prompt;进循环前让模型按最近输入挑最多 5 条全文,只拼进请求副本;对话结束时让模型从最近对话里提取新记忆,满 10 条再让模型合并,合并前先备份、失败回滚。

旧 s10

动态 System Prompt

本人手写context/prompts.py

解决什么问题

写死的 system prompt 不知道当前有哪些工具、记忆和技能。每轮按真实状态重新组装,同时避免内容没变也重复拼接。

关键数据结构

  • context 字典:{enabled_tools, workspace, memories},memories 是 MEMORY.md 索引。
  • 缓存:_cached_lead_context_key 和 _cached_lead_system_prompt。
  • 三种角色提示词:Lead(每轮组装)、队员(按名字和职责填模板)、子代理(启动时生成一次)。

执行步骤

  1. collect_lead_prompt_context:读取工具名列表、工作目录、MEMORY.md 索引。
  2. 用 json.dumps(sort_keys=True) 把这三样序列化成缓存键。
  3. 键和上次相同就返回缓存字符串;不同才调用 build_lead_system_prompt 重新拼接。
  4. Lead 提示词包含:角色和目录、可用工具、多步任务先用 todo_write、task 工具与任务系统的区别、组队前先征得用户同意、组队后结束本轮不要轮询信箱、后台和定时的用法、技能目录、记忆索引、团队和 worktree 规则。

边界和已知问题

  • 这是本地字符串缓存,不是模型服务端的 prompt cache。
  • 技能目录不在缓存键里,技能变化不会触发重建;目前技能只在启动时加载,所以没有实际影响。
  • 新版课程把这部分并入 S07 和 S15;不能据此说 S15 已经完成。

一句话讲法:每轮按工具、工作目录、记忆索引重组 Lead 的提示词,三样都没变就复用上次的字符串。

旧 s11 → 新 S15 的一部分

出错不中断

旧 s11

Error Recovery

本人手写runtime/recovery.py · runtime/loop.py · config.py

解决什么问题

调用模型会遇到限流、服务过载、输出被截断、上下文超限。不处理的话程序直接中断,这一轮的工作也就丢了。

关键数据结构

  • RecoveryState(每次进入主循环新建):current_model、max_tokens、has_escalated、continuations、reactive_retries、consecutive_529。
最多尝试次数 MAX_RETRIES10
退避基数 / 上限500ms / 32s
连续过载切换阈值3 次
输出上限:默认 / 提高后8000 / 64000
续写最多次数3
应急压缩最多次数1

执行步骤(按错误类型)

  1. 429 限流:响应带 Retry-After 就按它等;否则等 min(500ms × 2ⁿ, 32s),再加 0–25% 随机延迟,然后重试同一请求。
  2. 503 / 529 过载:同样退避;连续 3 次、且配置了 FALLBACK_MODEL_ID,就切换到备用模型。成功一次计数清零。总共最多 10 次,用完抛错。
  3. 输出截断(stop_reason = max_tokens):第一次把上限从 8000 提到 64000,丢弃截断的回复重发;还被截断,就把已有文字存为 assistant、追加续写提示(照 Claude Code 原文:不道歉、不复述、从断处接着写),最多 3 次;截断处只有工具调用、没有文字时放弃。
  4. 上下文超限:按错误文字识别(prompt_too_long、too many tokens、context_length_exceeded 等),执行应急压缩后重试。每次调用成功后计数清零,所以连续超限只应急一次。
  5. 其他错误,或以上都失败:错误写进对话作为一条 assistant 消息,本轮结束,命令行不退出。

边界和已知问题

  • 只有 Lead 和队员的主模型调用走这套重试;子代理、记忆、摘要的模型调用没有。
  • 上下文超限靠错误文字识别,不同服务商的措辞不一定都能覆盖。
  • 新版课程把它并入 S15;不能说 S15 已经完成。

一句话讲法:限流和过载用带上限的指数退避,连续过载切换备用模型;输出截断先提高上限再续写;上下文超限应急压缩后重试一次;实在恢复不了就把错误写进对话,程序不退出。

S10–S12

工作能保存、能排期

S10

Task System

本人手写tasks/store.py · tasks/assignments.py

解决什么问题

Todo 只在内存里,也没有依赖和负责人。需要一份落盘的工作账本:程序会检查"前置任务没完成就不能开工",进程重启后任务还在。

关键数据结构

  • Task:id、subject、description、status、owner、blockedBy、worktree(S13d 加入,旧文件缺这个字段时按 None 处理)。
  • 文件:.tasks/task_xxxxxxxx.json,编号必须匹配 ^task_[0-9a-f]{8}$,防止路径穿越。
  • 状态流转:pending → in_progress → completed。Lead 认领时 owner 固定为 agent,模型不能自己填负责人。

执行步骤

  1. create_task:在锁内用随机数生成编号(最多试 100 次防重复),只建节点,依赖为空。
  2. update_task(task_id, addBlockedBy):只允许改 pending 且无人负责的任务;整批检查不能依赖自己、依赖必须存在、从新依赖往前追溯不能回到自己(成环);全部通过才写入。
  3. claim_task:在锁内重新读文件,检查 pending、无人负责、自己手上没有别的任务、前置都已完成、worktree 绑定有效,才写入 owner 和 in_progress。
  4. complete_task:检查是本人的 in_progress 任务、不在 plan 模式、目录没变,写入 completed,并报告这次新解锁了哪些任务。
  5. list_tasks 每个任务只显示一行摘要;get_task 才返回完整 JSON。
  6. 每次保存先写同目录的临时文件并 fsync,再用 os.replace 替换正式文件;读取时校验文件名与 id 一致、status 合法。

边界和已知问题

  • 依赖文件缺失或损坏时按"未完成"处理,宁可挡住也不放行。
  • 依赖只能在开工前添加,不能删除。S13c 起才改为"先建节点,再用 update_task 连边"。
  • 跨会话保留的是任务状态,不是对话;新会话要靠 list_tasks / get_task 重新了解进度。
  • 任意一个任务文件损坏,list_tasks 会整体报错。
  • 离线测试 15/15 通过;在线验收待补。

一句话讲法:每个任务一个 JSON 文件,记录状态、负责人和前置依赖;认领、完成、改依赖都在锁内重新读取再检查,写文件用临时文件加原子替换。

S11

Background Tasks

本人手写tasks/background.py · runtime/events.py · runtime/loop.py

解决什么问题

跑测试或构建可能要几分钟,前台等待时模型什么也做不了。把明确不急着要结果的命令放到后台,先继续别的工作。

关键数据结构

  • BackgroundManager:jobs(bg_0001 → tool_use_id、command、cwd、status)、results、_ready 待领取列表,一把锁保护。
  • 全部只在内存中。

执行步骤

  1. 模型调用 bash 并设 run_in_background=true,必须是布尔值 True,字符串 "true" 不算。
  2. 先照常经过 PreToolUse 权限检查。
  3. 登记作业(编号递增,固定当前任务目录),启动后台线程;立刻回一条 tool_result:"[Background task bg_0001 started] The result will be collected on a later turn."
  4. 线程里执行命令,超时 600 秒;退出码为 0 记 completed,否则 failed;结果放进待领取列表。
  5. Lead 下一轮开头取出所有已完成作业,每个生成一段 <task_notification>(编号、状态、命令、输出前 500 字符),追加到最后一条 user 消息;取走后作业记录删除。

边界和已知问题

  • 完成通知不会唤醒空闲的 Lead:事件线程只检查定时队列和 Lead 信箱,要等用户下次输入或其他事件触发。
  • 通知只带前 500 字符,完整输出不保存。
  • 作业记录不落盘,进程退出就没了;退出时会结束仍在运行的进程树。
  • 只有 Lead 能用;队员和子代理没有后台通道。
  • 离线测试 28/28 通过;在线验收待补。

一句话讲法:显式标记的 bash 命令放到后台线程,立刻回"已启动";完成后进入待领取列表,Lead 下一轮开头以 task_notification 插入历史,只带输出的前 500 字符。

S12

Cron Scheduler

本人手写tasks/cron.py · runtime/events.py · runtime/loop.py · runtime/bootstrap.py

解决什么问题

有些工作要到某个时间再做,例如每天早上检查一次构建。不能靠用户到点手动输入,需要程序按时间把指令交给 Agent。

关键数据结构

  • CronJob:id(cron_xxxxxxxx)、cron、prompt、recurring、durable。
  • CronScheduler:jobs、queue(已触发、还没交给 Lead)、_last_fired(去重记录)、一把锁。
  • .scheduled_tasks.json:只保存 durable 的任务定义。

执行步骤

  1. schedule_cron 注册:校验 5 段(分 0–59、时 0–23、日 1–31、月 1–12、星期 0–6,0 是周日),支持 *、*/n、逗号列表、a-b 区间;prompt 不能为空;recurring、durable 必须是布尔值;最多 50 个;durable 的立即写文件。
  2. 启动时加载 durable 定义,每条重新校验,坏的跳过。
  3. 调度线程每秒检查一次:分、时、月必须都匹配;日和星期都有限制时,任一匹配即可(标准 cron 规则)。
  4. 同一任务同一分钟只入队一次(用"年-月-日 时:分"作去重标记);一次性任务触发后立即删除定义。
  5. Lead 事件线程每 0.2 秒检查;有到期任务且 Lead 空闲(非阻塞地拿到锁),就标记为无人值守回合,把每个任务以 [Scheduled] <prompt> 追加进历史,跑一轮 Lead。

边界和已知问题

  • 不补发:程序没运行期间错过的时间点不会补触发。
  • 已触发但还没交给 Lead 的队列只在内存里;取消任务不会撤回已经入队的触发。
  • 无人值守回合里,需要用户确认的操作一律拒绝。
  • 按本地时间计算,不处理时区。
  • 离线测试 55/55 通过;在线验收待补。

一句话讲法:用 5 段 cron 表达式注册,durable 的写文件、重启后加载;调度线程每秒检查,同一分钟只触发一次,到期指令进队列,Lead 空闲时以 [Scheduled] 消息跑一轮,这一轮里需要确认的操作一律拒绝。

S13a–S13d

多 Agent 团队

S13

Agent Teams

teams/runtime.py · teams/mailbox.py · teams/protocols.py · tasks/assignments.py · tasks/worktrees.py · runtime/events.py

解决什么问题

S06 的子代理做完一件事就销毁,不能长期分工,也不能并行。需要几名常驻队员各自工作,用消息和共享任务板协作,并且不会抢同一个任务、不会在同一个目录里互相覆盖改动。

关键数据结构

Lead常驻队员 TeammateRuntimeS06 子代理
运行方式主循环;前台用户输入和后台事件共用一把锁每人一个后台线程,接一次活最多 30 轮,做完不销毁同步执行一次,结束即丢弃
历史LEAD_MESSAGE_HISTORY每人自己的 messages,不与他人共享临时的空历史
工具全部 25 个,包括组队、任务板、定时、create_worktree10 个:基础文件和命令、发消息、交方案、列出 / 认领 / 完成任务;不能再组队、开后台、建定时5 个基础工具
结果怎么回来—写入 Lead 信箱,Lead 空闲时自动处理作为 tool_result 直接返回
共享状态保存位置并发控制
信箱.mailboxes/<name>.jsonl,一人一个同一进程内的可重入锁和条件变量;不是跨进程文件锁
任务板.tasks/task_xxxxxxxx.json线程锁 + 文件锁 .tasks/.lock(filelock),两把都拿到才读改写
分配记录内存 teammate_assignments:谁在做哪个任务、在哪个目录每次认领或释放都把分配版本加 1,旧的方案批复随之作废
队员状态TeammateRuntime 实例字段:status、permission_mode、require_plan、awaiting_plan_approval、pending_plan_request_id、shutdown_requested、stop_event、task_id、work_versionteam_lock
工作目录.worktrees/<name>,分支 wt/<name>任务里的 worktree 字段决定目录;每次调用工具都重新核对

执行步骤

  1. 组队S13a 本人手写
    • 提示词要求 Lead 先向用户说明为什么并行、每人负责什么;spawn_teammate 在权限规则里一律要求用户输入 y 确认。
    • 启动顺序:先占住名字 → 原子认领指定任务 → 认领成功才创建实例、登记、启动线程;中途失败就把任务退回。lead、agent 是保留名。
  2. 通信与汇报S13a 本人手写
    • MessageBus.send 在收件人的 JSONL 末尾追加一行,并通知等待中的线程;drain_inbox 读完就删文件;等待用条件变量,不让模型反复查信箱。
    • 队员做完一段,给 Lead 发 result 和 idle_notification。Lead 事件线程每 0.2 秒检查信箱,Lead 空闲(拿到锁)时把本批消息合成一条 [Team events] 插入历史,跑一轮。
  3. 控制协议:方案审批和停机S13b 参考实现接入
    • 控制消息的正文是 JSON,带 type 和 requestId(req_ 加 24 位十六进制);收件方核对类型、发件人和编号,不认普通聊天文字。
    • 方案审批:要求先交方案的队员从 plan 模式开始,bash、write_file、edit_file、complete_task 被拦下,只能读文件和 submit_plan(方案存到 .plans/<name>/<requestId>.md)。Lead 收信时由程序自动批准有效请求,不是用户审,也不是模型审。队员收到回复,核对 requestId、任务编号、分配版本都对得上,才切回 default 模式;普通权限检查照常进行。
    • 停机:Lead 调 request_shutdown → 队员模型调 respond_shutdown 同意或拒绝,拒绝必须写原因 → 同意则设置 stop_event,在两次模型调用之间退出,不打断正在执行的工具。
    • 控制消息只在两次模型调用之间处理,保证一批 tool_use 的结果都补齐后才插入新消息。
  4. 空闲时自动认领S13c 本人手写
    • 队员空闲时先等自己的信箱 2 秒;没有信,再扫描任务板里"pending、无人负责、前置任务都已完成"的任务。
    • 扫描结果只是候选;认领时在线程锁和文件锁内重新读文件、重新检查,成功才写入 owner 和 in_progress。每人同时只能持有一个任务。
    • 完成任务后不立即释放,等这一批工具结果全部写回再释放分配记录,避免同一批工具中途换目录。队员退出时把未完成的任务退回 pending。
    • 只有 Lead 能用 update_task 加依赖,添加时检查是否成环。
  5. 工作目录隔离S13d 参考实现接入
    • Lead 调 create_worktree(name, task_id),给一个 pending、无人负责、未绑定的任务执行 git worktree add -b wt/<name> .worktrees/<name> HEAD,成功后把名字写进任务。
    • 队员和 Lead 每次用 bash、读写文件、查找文件时,都按当前任务重新解析目录:核对 Git 登记、目录存在、分支名一致。绑定坏了就报错,不退回默认目录。权限检查和实际执行用同一个目录;后台命令启动时就固定目录。
    • 删除只能由宿主调用 remove_worktree:任务已完成、没有人和后台命令在用、没有未提交文件(含被忽略的文件),才执行 git worktree remove,分支保留。
S13a 本人手写2026-09-17 完成
S13b 参考实现接入助手编写,本人手写复习待做
S13c 本人手写09-21 完成,之后补齐查漏项
S13d 参考实现接入10-14 接入;离线测试和临时仓库检查有记录,未做真实模型验收

边界和已知问题

  • 缺陷 respond_shutdown 的处理函数在队员工具表里,但它的 schema 定义在 TEAMMATE_PROTOCOL_TOOLS,没有并入发给模型的 TEAMMATE_TOOLS。模型看不到这个工具,停机流程目前不能由队员模型完成。
  • 待准备 workspace/ 现在还不是独立的 Git 仓库,create_worktree 会按设计拒绝;演示 S13d 前要先把它建成独立仓库。
  • 信箱读完即删,是最多一次投递:取出后、处理前进程退出,消息就丢了;消息也没有编号,不能去重。完成任务和向 Lead 发结果是两次写入,中间退出会导致任务已完成但 Lead 收不到结果。
  • 队员线程、分配记录、方案状态都在内存里,进程重启后不能恢复。
  • worktree 只隔离改动,不是安全沙箱;不会自动合并回主分支。
  • 自动认领有锁和状态检查,但不是完整的分布式调度,没有崩溃恢复。

一句话讲法:Lead 先把工作建成任务板上的任务,再启动常驻队员;每个队员有自己的线程、历史和 JSONL 信箱,结果通过信箱回给 Lead,Lead 空闲时自动处理。停机和方案审批用带 requestId 的结构化消息;队员空闲时在锁内认领可做的任务;有 worktree 绑定的任务,工具调用都在它自己的检出目录里执行。

S14–S17 · planned

尚未实现的四章

这四章的模块在 agent-lab 实际代码里还没有。下面按教材参考实现写:它们是从 S13d 实际代码(提交 ed34862)逐章连续生成的教学快照,文件路径指快照里的位置。教材快照有离线检查记录,但没有接入实际项目,也没有用真实模型运行过。面试时只能说"正在学、计划实现"。

S14

MCP Plugin

planned快照:tools/mcp.py · runtime/loop.py · runtime/permissions.py · context/prompts.py

解决什么问题

内置工具写死在代码里。想接入外部系统(例如文档检索、部署平台)时,不应每接一个就改一次 TOOLS 和 TOOL_HANDLERS。MCP 让外部服务器自己公布有哪些工具,宿主在运行时发现并加进模型菜单,同时由宿主决定这些工具能不能直接执行。

关键数据结构

  • MCPClient:name、tools(服务器公布的工具定义,用 MCP 的 inputSchema 格式)、_handlers。register() 整份检查:名称非空、不重复、每个工具都有处理函数,全部合格才替换。
  • mcp_clients:已连接服务器的登记表。
  • MCP_HOST_POLICY:宿主配置,(服务器, 原始工具名) → allow / confirm。教材里 docs.search、docs.get_version、deploy.status 是 allow,deploy.trigger 是 confirm。
  • mcp_tool_policies:本轮快照生成的"带前缀工具名 → 策略"。
  • 两个模拟服务器 docs 和 deploy:本地函数返回固定文本,不连网络。

执行步骤

  1. Lead 调用 connect_mcp(name):只能连接宿主登记过的服务器,连上后记入 mcp_clients。
  2. 每轮调用模型前,build_tool_registry_snapshot() 复制内置的 TOOLS 和 TOOL_HANDLERS,加入 connect_mcp,再把每个已连接服务器的工具改名为 mcp__服务器__工具名 加进去;名字里不支持的字符换成下划线。
  3. 生成时检查:完整名字不超过 64 个字符、规范化后不能和已有工具重名、inputSchema 必须是 object。有一项不合格,整份快照报错,不做部分发布。
  4. 本轮的模型请求和工具执行都用这一份快照;刚连接的服务器从下一轮起才出现在菜单里,正在进行的请求不受影响。
  5. 权限:mcp__ 开头的工具查 mcp_tool_policies,allow 直接放行;其余(包括查不到配置的)都需要确认。无人值守回合、队员线程、非主线程不能问人,直接拒绝;前台在终端问 y/N。
  6. 执行时按服务器的原始工具名调用 client.call_tool;工具不存在和处理函数异常都转成 MCP error 文字。
  7. system prompt 加上说明:用 connect_mcp 发现工具、工具名格式、当前已连接哪些服务器。

边界和已知问题

  • 只是模拟:没有真实的 stdio / HTTP 连接,没有 tools/list 协议往返,没有 OAuth,也不处理服务器推送的工具变更通知。
  • 服务器给的 readOnlyHint / destructiveHint 只是服务器自己的说法,不参与授权;授权只看宿主配置。
  • MCP 工具只进 Lead 的菜单,队员和子代理的菜单不变。
  • 动态工具不写进 registry.TOOLS,每轮重新生成,所以断开或重连后下一轮就会反映出来。
  • 快照生成报错时不在 try 里,会让这一轮直接中断,和 S01 的异常问题同类。

一句话讲法:服务器公布工具清单,宿主每轮把它改名成 mcp__服务器__工具名,和内置工具合成一份本轮快照,请求和执行都用这份快照;能否直接执行由宿主配置决定,服务器自己的注解不算授权。

S15

Integrated Harness

planned快照:runtime/session.py · runtime/loop.py · runtime/events.py · runtime/state.py · runtime/subagent.py · tasks/background.py · tasks/cron.py

解决什么问题

到 S14 为止,会话状态散在 state.py 的全局变量里(历史、待办计数、无人值守标记),各机制各自往主循环里加代码。S15 把一次会话的状态收进一个对象,把整理上下文的步骤收成一个函数,并补上几处跨机制的缺口:压缩后丢了当前请求、定时任务取走后请求失败就丢失、后台结果叫不醒空闲的 Lead。

关键数据结构

  • AgentSession(dataclass):name、role、messages、context、active_request(当前用户请求)、unattended、rounds_since_todo、todos。
  • shared.LEAD_SESSION:Lead 的会话,第一次使用时包住已有的 LEAD_MESSAGE_HISTORY。
  • CronScheduler._unacknowledged:已经触发、但还没确认送达模型的一次性任务。
  • BackgroundManager 新增 has_pending()(有待领取结果)和 has_running()(有命令在跑)。

执行步骤

  1. run_lead_turn 把新输入记为 session.active_request,再调用 run_session(session)。
  2. run_session 把会话里的无人值守标记、待办和计数临时换进全局状态,主循环跑完再写回并恢复;出异常也会恢复。
  3. 每轮整理上下文改为 prepare_session_messages:照旧执行四层压缩,然后检查当前请求的文字是否还在历史里,不在就追加一条 [Active request] …。手动 compact 和应急压缩之后也做同样检查。
  4. 定时任务改成"请求成功才算送达":取出的任务先记为待确认,模型调用成功后才 acknowledge;调用失败或本轮异常退出时,按对象身份删掉注入的文本块,把任务放回队首重新投递。一次性 durable 任务在确认之前仍留在文件里。
  5. has_pending_runtime_events 加上后台结果检查,后台命令完成后能唤醒空闲的 Lead。
  6. 判断本轮是否结束,从看 stop_reason 改为看返回内容里有没有 tool_use 块。
  7. 子代理改用一个独立的 AgentSession 保存自己的消息。

边界和已知问题

  • run_session 是临时替换全局变量,不是并发隔离;调用方仍要保证 Lead 回合串行执行。
  • 判断当前请求是否还在,只是文本包含检查。
  • 定时任务从"最多一次"变成"至少一次":请求失败会重投,可能重复执行,没有去重。
  • 子代理只换了消息容器,仍然不走错误恢复、不做压缩;队员没有改成 AgentSession。

一句话讲法:把一次会话的状态收进 AgentSession,上下文整理收成一个函数,压缩后补回当前请求;定时任务改为模型请求成功才确认,失败就放回重投;后台结果也能唤醒空闲的 Lead。

S16

Workflow Runtime

planned快照:workflows/tool.py · catalog.py · execution.py · runners.py · schema.py · storage.py · tools/mcp.py · runtime/bootstrap.py

解决什么问题

有些多步骤任务的编排是固定的,例如"按四个维度审查代码,再逐条复核发现的问题"。交给 Lead 临场决定每一步,顺序和结果格式都不稳定,中途失败也只能从头来。S16 让宿主预先登记流程脚本:代码决定步骤顺序和并发,每一步是一次独立的模型调用并检查输出结构,成功的步骤写进日志,中断后可以恢复,已完成的步骤不用再付费。

关键数据结构

  • WORKFLOWS:流程名到 (meta, 异步脚本函数) 的注册表;meta 包含 name、description、phases。示例流程是 review-changes。
  • 运行产物:运行编号 wf_<流程名>_<16 位十六进制>,文件放在 workspace/.runtime/:<run>.json 快照(参数、状态、用量、进度)、<run>.journal.jsonl(成功步骤的 key 和结果)、<run>.output.json(最终结果),另记最近一次运行编号。
  • ExecutionState(ctx):给脚本用的 agent()、parallel()、pipeline()、workflow()、phase()、log()。
  • 限制:Budget(可选的 token 总额度)、AGENT_CAP 1000 个步骤(缓存命中也计数)、CONCURRENCY 8 个并发名额。
  • SimpleJsonSchema:只支持 object、array、基础类型、required、enum。

执行步骤

  1. Lead 调用 Workflow 工具 {name, args, resume_from_run_id}。只能选注册表里的流程名,模型不能上传代码。
  2. 新运行用独占创建(O_EXCL)占一个运行编号;恢复时校验编号格式。整次运行期间持有这个编号的文件锁。
  3. 恢复时先核对流程名一致、参数与原运行相同(不传就沿用旧参数),并完整读取 journal;有坏行就报错,不覆盖旧产物。
  4. 写入 running 快照,从脚本开头执行。每次 ctx.agent(prompt, schema):先计步骤数、查预算;用"种类 + 标签 + 提示词 + 结构"算缓存键,命中且结构合格就直接返回;没命中才占一个并发名额,发起一次独立模型调用(不带工具,也不共享 Lead 的历史)。
  5. 结构不合格就追加"Return valid JSON."修正一次,还不合格就报错;合格后计入预算和用量,追加写入 journal。
  6. parallel 用 asyncio.gather 并发;pipeline 让每个输入依次走完各阶段,不同输入之间并行;workflow() 可以调一层子流程,共享预算和限制。
  7. 结束时状态记为 completed 或 failed,写结果文件和快照,结果作为 JSON 文本返回给 Lead。

示例 review-changes:correctness、security、performance、style 四个维度各审查一次 → 每条发现单独做一次反向复核(isReal)→ 汇总后按严重程度排序。

边界和已知问题

  • 同步执行:工具内部用 asyncio.run 等整个流程结束,Lead 在此期间阻塞。事件名叫 async_launched,但它不是后台运行。
  • 恢复是从头重跑脚本、复用已缓存的步骤,不恢复协程栈;预算和用量从零重新计算。
  • journal 只 flush 不 fsync,断电后不保证还在;预算在调用返回后才记账,超限报错时 token 已经花掉了。
  • 结构修正的重试不占并发名额;并发的相同步骤不会合并成一次;parallel 里一个步骤失败不会取消其余步骤。
  • 每一步只能看到 prompt 里的材料,不能读文件或跑命令;isReal 是模型判断,不是测试复现。步骤的模型调用也不走错误恢复。
  • Workflow 工具只进 Lead 的菜单,调用时不需要用户确认。

一句话讲法:宿主登记固定的流程脚本,代码决定顺序和并发,每一步是一次带结构校验的独立模型调用;成功步骤写进 journal,恢复时从头重跑脚本,已完成的步骤直接读缓存,不再调用模型。

S17

Goal Loop

planned快照:goals/controller.py · goals/gate.py · runtime/loop.py · runtime/session.py · context/prompts.py

解决什么问题

到 S16 为止,模型一不再调用工具,本轮就结束;它说"做完了"不等于真的做完了。S17 让用户设定一个完成条件,由一个独立的验收模型根据对话里留下的证据判断是否达标,没达标就要求主 Agent 继续,并设上限防止无限返工。

关键数据结构

  • GoalState:condition、iterations(拿到有效结论的次数)、set_at、tokens_at_start、last_reason。
  • GoalEvaluation:ok、reason、impossible。
  • StopDecision.action:allow(没有目标)、block(继续)、defer(先等后台结果)、achieved、failed、error、limit。
  • GoalController:active、consecutive_blocks、events(目标状态事件列表)、last_status。
  • 限制:连续返工最多 8 次;单次运行最多循环 80 次(有没有目标都生效);条件最长 4000 字符;验收证据取最近约 24000 字符;验收回答最多 512 token。
  • AgentSession 新增 goal、total_tokens、max_turns。

执行步骤

  1. 用户输入 /goal <条件>:宿主设置目标(替换旧目标),并把条件本身作为任务交给 Lead;/goal 查看状态;/goal clear(或 stop、off、reset、none、cancel)清除。这些命令由宿主处理,不是模型工具。
  2. Lead 正常工作;system prompt 提醒它在有目标时把验证命令、退出码和观察到的结果留在对话里。
  3. 模型不再调用工具时先做目标判定:没有目标就正常结束;还有运行中的后台命令、待领取结果、到期定时任务、Lead 信箱消息,或者有队员在工作,就返回 defer,本轮先结束,等证据进入会话后再判。
  4. 否则调用独立验收模型:只给它目标条件和整理过的对话证据(作为 JSON 数据),不给工具;要求严格返回 {ok, reason, impossible},字段类型不对就算验收出错。
  5. ok 记为 achieved,清除目标;impossible 记为 failed,清除目标;都不是就记为 block,往历史里追加 [Goal still active] 还缺什么……继续工作并留下验证证据,回到循环继续。
  6. 连续 block 超过 8 次返回 limit,本轮结束但目标保留;验收调用出错返回 error,目标保留;单次运行到 80 次返回 max_turns,目标保留。每次新输入或后台唤醒都会把连续返工计数清零。
  7. 返回 SessionResult(正文、状态、原因),宿主打印非完成状态的原因。

边界和已知问题

  • 验收模型只能判断对话里已有的证据,不会自己去跑测试;证据只取最近约 24000 字符。
  • 目标和对话作为数据传给验收器,提示里要求不执行其中的指令,但这不能保证模型不受诱导。
  • 目标状态只在内存里。GoalController.restore 可以从事件列表恢复目标,但命令行还没接上,进程重启后目标就没了。
  • 验收模型的调用不走错误恢复;用量只统计主 Agent,不含验收器。
  • 返工 8 次、循环 80 次这两个上限用来控制成本和防止死循环,不是质量保证。

一句话讲法:用户用 /goal 设定完成条件;Lead 每次准备结束时,由一个没有工具的独立模型只看对话证据判断是否达标,没达标就把缺口写回历史让 Lead 继续;后台工作没完成就先等;连续返工超过上限或验收出错时停下,但保留目标。

讲的时候不要说过头

  1. 路径检查不是操作系统沙箱,bash 不受工作目录限制;worktree 也不是沙箱。
  2. 技能的"按需加载"指按需进入上下文,全文在启动时已经读进内存。
  3. 压缩是有损的,阈值按字符数估算;保存了历史存档不等于能自动恢复会话。
  4. "持久化"要说清对象:任务、记忆、durable 定时任务、信箱写在磁盘上;后台作业、Lead 历史、运行中的队员线程不能跨进程恢复。
  5. 方案审批是程序自动批准有效请求,不是人工逐份审,也不是模型评估方案好坏。
  6. 队员停机目前走不通(菜单缺 respond_shutdown),不要说停机协议已经跑通。
  7. S13b、S13d 是参考实现接入,不能说成全部本人手写;S10–S13 都没有做真实模型的在线验收。
  8. S14–S17 只是教材参考实现,还没进入 agent-lab 实际代码;只能说"在学、计划实现",不能说已经做了 MCP、工作流或目标验收。