KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
06 · 子 Agent、MCP 与会话恢复 — keel 龙骨
前五章讲的是「一个 Agent 怎么跑」。这一章讲「多个 Agent、外部工具、以及会话如何被记住」。三者放在一起,是因为它们被同一个约束反复塑形——prompt cache 的字节级稳定性。读完之后你会发现,几处看起来最反直觉的设计(工具池排序、fork 复用父级 system prompt 字节、并行批上下文延后 apply)其实是同一个理由的不同投影。
前五章讲的是「一个 Agent 怎么跑」。这一章讲「多个 Agent、外部工具、以及会话如何被记住」。三者放在一起,是因为它们被同一个约束反复塑形——prompt cache 的字节级稳定性。读完之后你会发现,几处看起来最反直觉的设计(工具池排序、fork 复用父级 system prompt 字节、并行批上下文延后 apply)其实是同一个理由的不同投影。
这一章回答:
- 子 Agent 怎么被定义、加载、裁剪工具集;
- 「上下文默认不共享」这条规则在什么情况下被打破;
- MCP 工具的命名与合池规则;
- 会话文件长什么样、rewind 怎么实现;
- 哪些设计是被 prompt cache 逼出来的。
一、Agent 定义与加载
tools/AgentTool/loadAgentsDir.ts: getAgentDefinitionsWithOverrides(第 296 行)是加载总入口,本身 memoize。它调用 loadMarkdownFilesForSubdir('agents', cwd)(第 308 行)扫描各 setting source 的 agents 子目录,再合并 plugin agent 与内置 agent。
Markdown agent 的 frontmatter 由 parseAgentFromMarkdown(第 541 行起)逐字段手工解析并校验(非法 permissionMode / isolation / maxTurns 各自给出带合法选项的报错);JSON 形式的 agent 才走 AgentJsonSchema(第 73 行定义,第 451 行使用)。两类定义最终都归一成同一套字段,可归为六类:
- 身份:
name/description——必填;缺失会报Missing required "name" field in frontmatter或... "description" field ...; - 工具面:
tools/disallowedTools(字符串数组,支持'*'); - 运行参数:
model、permissionMode(PERMISSION_MODES枚举,非法值会列出合法选项)、maxTurns(走parsePositiveIntFromFrontmatter)、effort(支持字符串档位与整数)、background; - 上下文:
initialPrompt、skills(预加载技能名)、memory('user' | 'project' | 'local',开启后自动注入 Write/Edit/Read)、isolation('worktree' | 'remote',其中remote是 ant-only,external build 在解析期就拒绝); - 外部集成:
mcpServers(用与 JSON agent 相同的 zod 校验)、hooks(走parseHooksFromFrontmatter); - 展示:
color。
内置 agent 由 tools/AgentTool/builtInAgents.ts: getBuiltInAgents()(第 22 行)组装,共六个:GENERAL_PURPOSE_AGENT、PLAN_AGENT、EXPLORE_AGENT、STATUSLINE_SETUP_AGENT、CLAUDE_CODE_GUIDE_AGENT、VERIFICATION_AGENT(其中 Explore / Plan 受 areExplorePlanAgentsEnabled() 门控)。
tools/AgentTool/constants.ts 里有一个与 token 成本直接相关的常量:
export const ONE_SHOT_BUILTIN_AGENT_TYPES: ReadonlySet<string> =
new Set(['Explore', 'Plan'])
注释解释了动机:这两个 agent 一次返回报告,「parent never SendMessages back to continue them」,所以跳过 agentId / SendMessage / usage trailer,省下约 135 字符 × 每周 3400 万次 Explore 运行。同一文件另有 AGENT_TOOL_NAME = 'Agent' 与 LEGACY_AGENT_TOOL_NAME = 'Task'——后者用于权限规则、hook 与已恢复会话的向后兼容。
二、工具过滤
tools/AgentTool/agentToolUtils.ts: filterToolsForAgent(第 70 行)的判断顺序是:
1. tool.name.startsWith('mcp__') → 放行(所有 agent 都能用 MCP)
2. ExitPlanMode 且 permissionMode === 'plan' → 放行(绕过下面两道过滤)
3. ALL_AGENT_DISALLOWED_TOOLS.has(tool.name) → 剔除
4. !isBuiltIn && CUSTOM_AGENT_DISALLOWED_TOOLS.has() → 剔除
5. isAsync && !ASYNC_AGENT_ALLOWED_TOOLS.has() → 剔除
第 5 步有一个例外分支:当 agent swarms 开启且当前处于 in-process teammate 时,Agent 工具本身与 IN_PROCESS_TEAMMATE_ALLOWED_TOOLS 会被放行,让 teammate 能派发同步子 agent 并共享任务列表。
黑名单在 constants/tools.ts: ALL_AGENT_DISALLOWED_TOOLS(第 36 行):TASK_OUTPUT_TOOL_NAME、EXIT_PLAN_MODE_V2_TOOL_NAME、ENTER_PLAN_MODE_TOOL_NAME、非 ant 时的 AGENT_TOOL_NAME(禁止嵌套派发)、ASK_USER_QUESTION_TOOL_NAME、TASK_STOP_TOOL_NAME,以及 feature('WORKFLOW_SCRIPTS') 时的 WORKFLOW_TOOL_NAME(防递归 workflow)。CUSTOM_AGENT_DISALLOWED_TOOLS(第 48 行)目前就是它的展开——一个留给未来分化的占位。
ASYNC_AGENT_ALLOWED_TOOLS(第 55 行)则是白名单:Read、WebSearch、TodoWrite、Grep、WebFetch、Glob、全部 shell(SHELL_TOOL_NAMES)、Edit、Write、NotebookEdit、Skill、SyntheticOutput、ToolSearch、EnterWorktree、ExitWorktree。
resolveAgentTools(第 122 行)负责把定义里的 '*' 展开成实际工具集并校验;它有一个 isMainThread 参数,为真时完全跳过 filterToolsForAgent——主线程不该被当成子 agent 对待。
三、上下文共享与 fork
默认情况下子 Agent 不继承父级对话。tools/AgentTool/runAgent.ts: contextMessages(第 370 行):
const contextMessages: Message[] = forkContextMessages
? filterIncompleteToolCalls(forkContextMessages)
: []
const initialMessages: Message[] = [...contextMessages, ...promptMessages]
filterIncompleteToolCalls(第 866 行)的注释说明它的目的:「prevents API errors when sending messages with orphaned tool calls」——父级里可能有 assistant 消息发了 tool_use 而结果还没回来,直接 fork 给子 agent 会触发 API 报错。
文件状态遵循同一开关(第 376 行):forkContextMessages !== undefined 时 cloneFileStateCache(toolUseContext.readFileState),否则新建一个受 READ_FILE_STATE_CACHE_SIZE 限制的缓存。
另有一条省 token 的规则(第 390 行):shouldOmitClaudeMd = agentDefinition.omitClaudeMd && !override?.userContext && getFeatureValue_CACHED_MAY_BE_STALE('tengu_slim_subagent_claudemd', true)。注释给出量级:「Dropping claudeMd here saves ~5-15 Gtok/week across 34M+ Explore spawns.」三个 && 意味着只有 agent 自己声明要省、调用方没有显式传 userContext、且 kill switch 未关闭时才生效。
fork 模式是共享上下文的唯一路径。tools/AgentTool/forkSubagent.ts: FORK_AGENT(第 60 行)是一个不注册进 builtInAgents 的合成定义:
export const FORK_AGENT = {
agentType: 'fork', tools: ['*'], maxTurns: 200,
model: 'inherit', permissionMode: 'bubble',
source: 'built-in', baseDir: 'built-in', getSystemPrompt: () => '',
} satisfies BuiltInAgentDefinition
四个字段各有明确理由,注释写全了:tools: ['*'] 配合 useExactTools——子 agent 拿到父级完全相同的工具池,为的是 API 前缀逐字节一致;permissionMode: 'bubble'——子 agent 没有自己的终端,权限提示必须冒泡到父终端;model: 'inherit'——保持上下文长度语义一致;getSystemPrompt 返回空串,因为 fork 路径走 override.systemPrompt,注入的是父级已渲染的 system prompt 字节(经 toolUseContext.renderedSystemPrompt 传递)。注释解释了为什么不重算:「Reconstructing by re-calling getSystemPrompt() can diverge (GrowthBook cold→warm) and bust the prompt cache; threading the rendered bytes is byte-exact.」
反递归保护是 isInForkChild(messages)(第 78 行):因为 fork 子级为了工具池一致性保留了 Agent 工具,所以只能在调用时拒绝——它扫描历史里是否已出现 <FORK_BOILERPLATE_TAG>(该 tag 定义在 constants/xml.ts,由 buildChildMessage 拼进子消息)。同文件另有 FORK_SUBAGENT_TYPE = 'fork'、FORK_PLACEHOLDER_RESULT = 'Fork started — processing in background'、buildForkedMessages 与 buildWorktreeNotice。
四、MCP 的命名与合池
MCP 命名规则集中在 services/mcp/mcpStringUtils.ts:getMcpPrefix(serverName) 返回 `mcp__${normalizeNameForMCP(serverName)}__`,buildMcpToolName(server, tool) 拼出全限定名。mcpInfoFromString 的注释给了切分规则:按 server 段之后的第一个分隔符切,所以 mcp__my__server__tool 会被解析成 server = 'my'、tool = 'server__tool'。
Tool 契约上的 mcpInfo?: { serverName, toolName } 保存未归一化的原始名字;注释补充:无论 name 有没有前缀(CLAUDE_AGENT_SDK_MCP_NO_PREFIX 模式下可以不加)都保留,供权限规则与展示层使用。getToolNameForPermissionCheck 则刻意用全限定名查权限,避免不同 server 下的同名工具互相串权。
合池逻辑见第 03 章的 tools.ts: assembleToolPool:其注释已把理由写清(内置在前 + 保序 uniqBy,否则 MCP 工具插进内置之间会击穿下游全部 cache key),这里不重复。
五、会话存储与恢复
utils/sessionStorage.ts 是会话持久化的唯一出口,全部是 append-only 的 JSONL:
getProjectsDir() = <claudeConfigHome>/projects
getTranscriptPath() = <projectDir>/<sessionId>.jsonl
getAgentTranscriptPath(agentId) = <base>/agent-<agentId>.jsonl(+ 同名 .meta.json)
两处值得注意的实现取舍:
getTranscriptPathForSession(第 213 行)对当前 session 要和getTranscriptPath()一样尊重sessionProjectDir,否则 hook 拿到的transcript_path会按originalCwd算、而真实文件写在别处——注释原话是「一个说文件在这,另一个却去别处找」;对其它 sessionId 只能退回originalCwd猜测,因为系统没有维护sessionId → projectDir映射。MAX_TRANSCRIPT_READ_BYTES = 50 * 1024 * 1024(第 229 行)。注释解释得很具体:「session JSONL 可能长到几个 GB」,所以直接读原始 transcript 的调用方(如components/Feedback.tsx、submitTranscriptShare.ts)都要先过这道体积闸门。
JSONL 里除了消息还有元数据条目:agent-name、agent-color、agent-setting,以及 mode、pr-link。读取时按 entry.type 重建状态(第 1180 行起有一串 else if)。恢复路径是 loadConversationForResume / ResumeConversation.tsx,它还会连带恢复 agent 定义与 contextCollapse 的提交日志(services/contextCollapse/persist.js: restoreFromEntries);对应字段是 types/logs.ts: contextCollapseCommits / contextCollapseSnapshot。
**rewind(回退)**实现在 utils/fileHistory.ts:
| 函数 | 行 | 作用 |
|---|---|---|
fileHistoryTrackEdit |
86 | 跟踪编辑 |
fileHistoryMakeSnapshot |
198 | 为当前文件状态建快照 |
fileHistoryRewind |
347 | 回退到某个快照 |
fileHistoryCanRestore |
399 | 可回退性判断 |
fileHistoryGetDiffStats |
414 | 差异统计 |
fileHistoryRestoreStateFromLog |
888 | 从会话日志恢复快照状态 |
copyFileHistoryForResume |
922 | 恢复会话时复制快照 |
快照总数有上限:MAX_SNAPSHOTS = 100(第 54 行),超出时 allSnapshots.slice(-MAX_SNAPSHOTS) 只保留最近 100 个。
六、被 prompt cache 逼出来的反直觉设计
把散落在各章的证据集中看,这一组设计共享同一个动机。
1. assembleToolPool 内置在前 + 保序去重。(第 03 章)扁平排序会让 MCP 工具插进内置之间,而服务端在最后一个前缀匹配的内置工具后放了 cache breakpoint;顺序一变,命中前缀就变,下游所有 cache key 全部失效。
2. fork 复用父级已渲染的 system prompt 字节。(本章第三节)重算会因 GrowthBook 冷/热状态产出不同字节,直接击穿缓存。
3. 压缩 fork 传 skipCacheWrite: true。(第 05 章)压缩会产生全新前缀,旧的 cache 写入毫无复用价值。
4. 1h TTL 由 GrowthBook 按 querySource 前缀 allowlist 决定。services/api/claude.ts: getCacheControl(第 358 行)返回 { type: 'ephemeral', ...(should1hCacheTTL(querySource) && { ttl: '1h' }), ...(scope === 'global' && { scope }) }。should1hCacheTTL(第 393 行)的注释给了 allowlist 形状与语义:{ allowlist: ["repl_main_thread*", "sdk"] },模式支持尾随 * 前缀匹配("agent:*" 可额外放行子 agent)。它还解释了一个看似多余的缓存:「The allowlist is cached in STATE for session stability — prevents mixed TTLs when GrowthBook's disk cache updates mid-request.」对应状态与访问器在 bootstrap/state.ts:getPromptCache1hAllowlist / setPromptCache1hAllowlist / getPromptCache1hEligible / setPromptCache1hEligible(第 1692 行起)。另有 3P Bedrock 例外通道 ENABLE_PROMPT_CACHING_1H_BEDROCK。
5. 并行批的 contextModifier 延后 apply。(第 03 章)这一条没有官方注释把它与 cache 绑定,属(推断):若上下文修改在多工具并发下顺序不确定,受它影响的下游请求内容就会抖动,从而影响缓存命中;延后到批次末按序 apply 至少让顺序可复现。
6. 缓存失效检测是一等公民。services/api/promptCacheBreakDetection.ts: notifyCompaction(querySource, agentId) 被 autocompact 与压缩路径显式调用——第 05 章那处 BQ 2026-03-01 注释说明,漏调一次就让 20% 的 tengu_prompt_cache_break 变成误报。
顺带记下两个与重试/诊断相关的常数:services/api/withRetry.ts: DEFAULT_MAX_RETRIES = 10 与 BASE_DELAY_MS = 500(第 52 / 55 行,退避式 BASE_DELAY_MS * Math.pow(2, attempt - 1));LSP 诊断的每文件上限 services/lsp/LSPDiagnosticRegistry.ts: MAX_DIAGNOSTICS_PER_FILE = 10(第 42 行),超出部分被裁剪并在调试日志里报告数量。
代码地图
| 机制 | 位置 | 要点 |
|---|---|---|
| Agent 定义加载 | tools/AgentTool/loadAgentsDir.ts: getAgentDefinitionsWithOverrides |
第 296 行;memoize;agents 子目录 + plugin + 内置 |
| Markdown 扫描 | tools/AgentTool/loadAgentsDir.ts: loadMarkdownFilesForSubdir |
第 308 行;参数 ('agents', cwd) |
| frontmatter schema | tools/AgentTool/loadAgentsDir.ts: AgentJsonSchema |
第 73 行;JSON agent 用;markdown 由 parseAgentFromMarkdown 手工校验 |
| frontmatter 解析 | tools/AgentTool/loadAgentsDir.ts: parseAgentFromMarkdown |
第 541 行;逐字段校验并给出可读报错 |
| 内置 agent 组装 | tools/AgentTool/builtInAgents.ts: getBuiltInAgents |
第 22 行;六个内置 agent,Explore/Plan 受门控 |
| 一次性 agent | tools/AgentTool/constants.ts: ONE_SHOT_BUILTIN_AGENT_TYPES |
只含 Explore / Plan,跳过 agentId+SendMessage trailer |
| 工具名常量 | tools/AgentTool/constants.ts: AGENT_TOOL_NAME |
'Agent';legacy 名为 'Task' |
| Agent 工具过滤 | tools/AgentTool/agentToolUtils.ts: filterToolsForAgent |
第 70 行;mcp__ 放行 → 黑名单 → 非内置黑名单 → async 白名单 |
| 全体黑名单 | constants/tools.ts: ALL_AGENT_DISALLOWED_TOOLS |
第 36 行;含非 ant 时的 Agent 本身与 Workflow |
| async 白名单 | constants/tools.ts: ASYNC_AGENT_ALLOWED_TOOLS |
第 55 行;Read/Grep/Glob/Edit/Write/shell/Skill 等 |
| 工具解析 | tools/AgentTool/agentToolUtils.ts: resolveAgentTools |
第 122 行;展开 '*';isMainThread 时跳过过滤 |
| 上下文不共享 | tools/AgentTool/runAgent.ts: filterIncompleteToolCalls |
第 370 行;fork 才继承,并滤掉孤儿 tool call |
| 文件状态继承 | tools/AgentTool/runAgent.ts: cloneFileStateCache |
第 376 行;仅 fork 路径克隆父级 readFileState |
| 省 claudeMd | tools/AgentTool/runAgent.ts: shouldOmitClaudeMd |
第 390 行;受 omitClaudeMd 与 tengu_slim_subagent_claudemd 门控 |
| fork agent 定义 | tools/AgentTool/forkSubagent.ts: FORK_AGENT |
第 60 行;tools ['*'] / maxTurns 200 / model inherit / permissionMode bubble |
| fork 反递归 | tools/AgentTool/forkSubagent.ts: isInForkChild |
第 78 行;扫历史里的 <FORK_BOILERPLATE_TAG> |
| MCP 前缀 | services/mcp/mcpStringUtils.ts: getMcpPrefix |
mcp__<server>__,server 需 normalize |
| MCP 名解析 | services/mcp/mcpStringUtils.ts: mcpInfoFromString |
第 19 行;mcp__my__server__tool → server=my |
| 会话目录 | utils/sessionStorage.ts: getProjectsDir |
第 204 行;<claudeConfigHome>/projects |
| 会话文件 | utils/sessionStorage.ts: getTranscriptPath |
第 208 行;<projectDir>/<sessionId>.jsonl |
| 子 Agent 文件 | utils/sessionStorage.ts: getAgentTranscriptPath |
第 247 行;agent-<agentId>.jsonl 与 .meta.json |
| 读取保护 | utils/sessionStorage.ts: MAX_TRANSCRIPT_READ_BYTES |
第 229 行;50 MB |
| rewind 快照 | utils/fileHistory.ts: fileHistoryMakeSnapshot |
第 198 行;MAX_SNAPSHOTS = 100(第 54 行) |
| rewind 回退 | utils/fileHistory.ts: fileHistoryRewind |
第 347 行 |
| 缓存控制头 | services/api/claude.ts: getCacheControl |
第 358 行;ephemeral + 条件式 ttl '1h' |
| 1h TTL 判定 | services/api/claude.ts: should1hCacheTTL |
第 393 行;GrowthBook allowlist 前缀匹配 + Bedrock 例外 |
| 1h 状态缓存 | bootstrap/state.ts: getPromptCache1hAllowlist |
第 1692 行;会话内稳定,避免混合 TTL |
| 缓存断裂检测 | services/api/promptCacheBreakDetection.ts: notifyCompaction |
由压缩路径显式调用 |
| 重试策略 | services/api/withRetry.ts: DEFAULT_MAX_RETRIES |
第 52 行;10 次、BASE_DELAY_MS = 500 指数退避 |
| LSP 诊断裁剪 | services/lsp/LSPDiagnosticRegistry.ts: MAX_DIAGNOSTICS_PER_FILE |
第 42 行;每文件 10 条 |
关键取舍
子 Agent 默认不共享上下文,代价是每个 agent 都要重新建立对代码库的认识。
只有 fork 模式继承对话,且继承时还要先 filterIncompleteToolCalls 清掉孤儿 tool call。好处是子 agent 的上下文完全可控、可预测;代价是「让子 agent 知道刚才发生的三件事」必须靠 prompt 转述,而 fork 模式是全有或全无。
fork 复用父级已渲染的 system prompt 字节,代价是 fork 路径无法独立演进。getSystemPrompt: () => '' 是刻意的空实现——真实内容来自 toolUseContext.renderedSystemPrompt。任何「给 fork 子 agent 加一句系统提示」的想法都会破坏缓存一致性,因此必须改走 user 消息或附件。
工具过滤用「黑名单 + async 白名单」两级,代价是判断逻辑分散在两个文件。mcp__* 无条件放行、ALL_AGENT_DISALLOWED_TOOLS 剔除、非内置再查 CUSTOM_AGENT_DISALLOWED_TOOLS、async 再查白名单,外加 plan 模式与 in-process teammate 两个例外——只有对照读 agentToolUtils.ts 与 constants/tools.ts 才能串起来。
会话 append-only 让恢复简单,代价是文件无界增长。
单条 JSONL 可能到几个 GB,所以读取侧要设 50MB 闸门、rewind 侧要设 100 快照上限。好处是不需要事务、崩溃后最坏只丢最后一行;代价是所有「当前状态」都得靠重放条目重建。
1h TTL 用 GrowthBook allowlist 而不是全局开关,代价是多一层状态一致性要求。
按 querySource 前缀放行意味着「主线程用 1h、子 agent 用 5m」是可能的,而一旦 allowlist 在请求中途变化,同一会话内就会出现混合 TTL。bootstrap/state.ts 那两个 promptCache1h* 字段就是为了把 allowlist 与 eligibility 钉死在会话内。
自测题
FORK_AGENT的getSystemPrompt返回空串,真实内容靠renderedSystemPrompt注入。如果某个功能确实需要给 fork 子 agent 加一段系统级指令,你会怎么落地才不破坏 cache?isInForkChild靠扫描历史里的<FORK_BOILERPLATE_TAG>阻止递归 fork,而不是摘掉 Agent 工具。请说明这个取舍背后的 cache 论证,并指出它在什么情况下会失效。filterIncompleteToolCalls是 fork 路径的必经步骤。请描述一个「父级 assistant 发了两个 tool_use、只回来一个结果」的时刻,并说明不清洗会得到什么 API 错误。getTranscriptPathForSession对「非当前 session」只能退回originalCwd猜测。这会在什么场景下指错文件?如果要修,最小改动是什么?- 把
assembleToolPool、skipCacheWrite、renderedSystemPrompt、getCacheControl四处放在一起看,请总结出一条可推广到其它 Harness 的原则:什么时候应该牺牲一致性或简洁性去换取缓存命中?