乐于分享
好东西不私藏

深度解析:Claude Code 源码

深度解析:Claude Code 源码

本文以深度解析:Harness Engineering为理论支点,通过 codex 对 npm claude-code-2.1.88.tgz 压缩包中的 cli.js.map 逆向源码进行深度分析,整理成此文。

注:本文并不可作为 Agent 架构设计指导,源码解析仅供学习参考。对源码交流群感兴趣的朋友,可在公众号私信 cc 获取。

关于 Claude Code 源码泄漏的八卦这里就不讨论了,可以肯定的是这份源码很有分量,因为目前在 npm 发版历史中已经找不到 v2.1.88 了。

正式开始前,先来个小插曲,Andrej Karpathy 在最近的推文中提到 ”LLM 知识库“就是对 Harness 的一个侧面注解。比如:把状态持续沉淀成工件,on-the-loop 等。原推文:

 LLM 知识库

我最近发现一件非常有用的事:用 LLM 为自己感兴趣的研究主题构建个人知识库。这样一来,我最近消耗的大量 token,就不再主要用于处理代码,而是更多用于处理知识本身(以 markdown 和图片的形式存储)。最新一代的 LLM 在这件事上已经做得相当不错。所以:

数据摄取:我会先把源文档(文章、论文、仓库、数据集、图片等等)索引到一个 raw/ 目录里,然后再用 LLM 逐步“编译”出一个 wiki。本质上,它就是按目录结构组织起来的一组 .md 文件。这个 wiki 会包含 raw/ 中所有数据的摘要、反向链接,然后再把这些数据归类到不同概念之下,为这些概念撰写条目,并把它们彼此链接起来。把网页文章转成 .md 文件时,我喜欢用 Obsidian Web Clipper 扩展;同时我还会用一个快捷键,把相关图片全部下载到本地,这样 LLM 就能更方便地引用它们。

IDE:我把 Obsidian 当作 IDE 的“前端”,在里面查看原始数据、编译后的 wiki,以及衍生出的各种可视化结果。这里有一点很重要:wiki 中的所有数据,基本都是由 LLM 负责撰写和维护的,我自己很少直接去碰它。我也试过一些 Obsidian 插件,用不同方式来渲染和查看这些数据,比如用 Marp 来做幻灯片。

问答:真正有意思的是,一旦你的 wiki 足够大了(比如我最近某个研究主题的 wiki,大概有 100 篇文章、40 万字),你就可以让 LLM agent 围绕这个 wiki 回答各种复杂问题,它会自己去查找、研究并组织答案。原本我以为得上更复杂的 RAG 方案,但在这种“小规模”下,LLM 自己维护索引文件和各文档简要摘要的能力已经相当不错,也能比较轻松地读完所有关键相关数据。

输出:相比直接在文本界面或终端里拿答案,我更喜欢让它帮我生成 markdown 文件、幻灯片(Marp 格式)或者 matplotlib 图片,然后我再回到 Obsidian 里查看。当然,根据查询内容,也完全可以想象出更多别的可视化输出形式。很多时候,我最终还会把这些输出重新“归档”回 wiki 中,进一步增强它,以便支持后续查询。这样一来,我自己的探索和提问,都会不断沉淀进知识库里,形成积累。

Lint / 健康检查:我还会让 LLM 对整个 wiki 跑一些“健康检查”,比如找出不一致的数据、补全缺失的信息(借助网页搜索工具)、发现一些有趣的联系并建议可以新增哪些条目等等,从而逐步清理 wiki、提升整体数据完整性。LLM 在提示你还应该继续问什么、往哪些方向深入这件事上,也表现得相当不错。

额外工具:我发现自己也在不断开发一些额外工具来处理这些数据。比如,我随手 vibe code 了一个小而简陋的 wiki 搜索引擎,我既会直接用它(通过一个 web UI),但更多时候,我会把它作为 CLI 工具交给 LLM 调用,用于处理更大规模的查询任务。

进一步探索:随着仓库不断变大,一个很自然的想法就是:是否可以结合合成数据生成和微调,让 LLM 直接把这些数据“学进权重里”,而不仅仅是依赖上下文窗口来读取它们。

TL;DR:来自若干源头的原始数据先被收集起来,再由 LLM 编译成一个 .md wiki,随后 LLM 通过各种 CLI 工具对它进行问答和持续增强,而所有这些内容都可以在 Obsidian 中查看。你几乎不再需要手动撰写或编辑这个 wiki,它基本上成了 LLM 的工作领域。我觉得,这里面完全有机会长出一个惊人的新产品,而不只是现在这种拼拼凑凑、带点 hack 味道的脚本集合。

另外一条消息则是彻底封杀 OpenClaw:Anthropic 表示,从明天太平洋时间中午 12 点起,Claude 订阅将不再覆盖 OpenClaw 等第三方工具中的使用量;用户之后若仍想通过 Claude 账号在这些工具里继续使用服务,需要额外购买打折后的 usage bundles,或直接使用 Claude API key。官方同时说明,现有订阅用户会获得一次性补偿额度,金额等同于当月订阅费用;若不接受这一调整,也可通过次日邮件中的链接申请全额退款。Anthropic 给出的理由是,希望更有节制地管理增长,以便长期、可持续地服务客户。

阅读边界

这份材料来自 source map 反解后的源码,不是按作者架构意图整理过的正式文档。因此,阅读时最需要防止的,不是“看漏模块”,而是把生产系统为了兼容历史、修补断裂、维持线上可恢复性而追加的补偿机制,误抄成 agent runtime 的理想蓝图。

这份代码更适合被当成三类材料同时阅读:

  • 约束样本:真正无法回避的 runtime 约束会反复浮现,例如 tool_use -> tool_result 不能断、prompt cache prefix 不能漂、能力面不能一次性敞开、子代理必须可恢复。
  • 病理切片:某些最“惊艳”的实现,首先说明底层边界已经破过一次,例如 progressBridgeapplySnipRemovalsrecoverOrphanedParallelToolResults、synthetic continuation、orphaned permission replay。
  • 反证材料:当 attachments.ts 演化成第二调度器、ToolUseContext 过胖、continuity ownership 分散、恢复协议依赖 JSON 键顺序时,真正重要的信息不是“补得真巧”,而是这些边界原本就不该如此耦合。

全文默认沿四个过滤器阅读源码:

  1. principle
  2. workaround
  3. legacy bridge
  4. debt

真正值得迁移到未来 agent 设计里的,主要是 principle,以及少量已经被证明有效的 workaroundlegacy bridge 与 debt 更适合作为警报,而不是模板。

理论支点:Harness Engineering

本文的底层分析框架并不是“按目录讲源码”,而是沿着 Harness Engineering 去识别一套 agent 外循环系统。

那份方法论里有两条判断,对这份源码尤其关键:

  1. 评估的对象不是模型单体,而是 model + harness:因此,这份报告关心的不是 Claude 自身多聪明,而是围绕它搭出来的外循环如何持久化状态、裁剪上下文、暴露能力、拦截风险、恢复轨迹、调度异步执行体。
  2. 真正的难点不在单步推理,而在 durable state、tool mediation、feedback loops、legibility、human boundary:这恰好对应到这份源码里最厚、也最容易被低估的几层:transcript 与 sidecar、Tool/ToolSearch/REPL、permissions/hooks/classifier、attachments/task output/SDK projection、coordinator/mailbox/remote agent。

因此,下面的“总/分”结构不是为了排版,而是为了让源码现象和 Harness Engineering 的几个核心构件对齐:

  • 总篇:先把这套 harness 的总体蓝图画出来,明确有哪些控制面、状态载体和执行底座。
  • 分篇:再逐层下潜到源码实现,看 durable state、tool mediation、policy plane、feedback loops、task substrate 在代码里分别落在哪里。
  • 收束:最后再区分哪些属于 principle,哪些只是 workaround、legacy bridge、debt。

换句话说,这篇报告不是把 Harness Engineering 当成背景引文挂在角落,而是把它当成识别源码结构的理论支点。

总篇:Runtime Blueprint

1. 这份系统真正维护的是四条不变量

把 claude-code/src 源码按目录阅读,很容易被拆散成 query / compact / tools / memory / skills / agent 几个平铺模块。按这种方式阅读,得到的是功能清单;运行时秩序看不见。

真正把这套系统撑起来的,不是模块目录,而是四条同时成立的不变量:

  1. 轨迹拓扑不能破:一个 assistant 轨迹里,tool_use 不能悬空;thinking block 不能在错误的边界被切断;错误恢复不能把 SDK 暴露到半成品状态。
  2. 缓存前缀不能漂:一旦某段内容已经被模型看过,后续轮次里它的命运就被冻结了。该替换的永远以同一字节串替换,不该替换的永远不能补替换;工具顺序、子代理前缀、cache_edits 插入位置都围绕这条约束收敛。
  3. 能力面不能一次性敞开:工具、MCP、skill、REPL primitive、agent 能力都不是全部预加载,而是分层暴露、延迟发现、按路径/时机激活。能力可见性本身就是上下文治理的一部分。
  4. 连续性不能只寄托在 transcript 上messages 只是连续性的一层。真正稳定的连续性分散在 CLAUDE.md、nested memory、MEMORY.md、daily logs、session memory、task state、sidechain transcript、content replacement records 这些外化工件上。

这四条不变量分别由不同模块共同维护:

  • query.ts 维护轨迹拓扑与恢复图。
  • toolResultStorage.tsmicroCompact.tstools.ts、fork 逻辑维护缓存前缀稳定性。
  • Tool.tstoolExecution.tsToolSearchToolREPLloadSkillsDir.ts 维护能力面可见性。
  • attachments.tsclaudemd.tsmemdir/*SessionMemory/*sessionStorage.tsrunAgent.ts 维护连续性外化。

从这个角度再回看那些“亮点实现”,价值就更清楚了:值得迁移的不是某个函数,而是它在替哪条不变量付账。

2. 总体结构:一个循环外套着多个调度面

最外层结构可以先抽成下面这张图:

用户输入  -> attachments 聚合  -> query while(true)       -> context shaping       -> model streaming       -> tool mediation       -> recovery / continue / stop  -> transcript / sidechain / tasks 持久化并行存在的调度面:1. capability surface   tools / deferred tools / REPL virtual tools / skills / agent definitions2. continuity surface   CLAUDE.md / nested memory / MEMORY.md / session memory / sidechain logs3. cache surface   contentReplacementState / cached microcompact / tool ordering / fork prefix

query.ts 是编排中心,但不是唯一调度器。真正的运行时控制同时散布在:

  • query.ts
  • attachments.ts
  • toolExecution.ts
  • toolResultStorage.ts
  • microCompact.ts
  • runAgent.ts
  • sessionStorage.ts

如果继续拆细,它至少又能分出七条一等控制面:

  1. 宿主控制面QueryEngine.ts、CLI/SDK/REPL 入口,负责 turn 之外的会话寿命、投影、结果封装、headless 行为。
  2. turn 控制面query.ts,负责一轮 agentic turn 内部的状态机、恢复图、错误分流、工具回合推进。
  3. 能力控制面Tool.tstools.tsToolSearchREPL、skills、agent definitions,负责模型“此刻能看到什么能力”。
  4. 策略控制面:permissions、hooks、policy limits、classifier,负责模型“即使想做,也是否允许这样做”。
  5. 连续性控制面CLAUDE.md、nested memory、MEMORY.md、daily logs、session memory、transcript、sidechain transcript,负责过去如何带到下一轮。
  6. 缓存控制面toolResultStorage.tsmicroCompact.ts、工具排序、fork 前缀复制,负责 prompt cache prefix 稳定性。
  7. 执行底座:task registry、coordinator、subagent、remote agent、mailbox,负责异步执行体如何存在、被追踪、被恢复、被终止。

也正因为如此,这套系统的亮点和缺陷都不在单模块内部,而在跨模块耦合处。

3. 主流程不是“问模型一次”,而是“不断修正同一条轨迹”

系统的主流程可以压成下面这条线:

用户输入  -> processUserInput  -> transcript 先落盘  -> attachments / context injection  -> query loop       -> 采样       -> 工具       -> permission / hook / classifier       -> compact / retry / continue -> result projection  -> transcript / sidechain / task output 持久化

这条线里最重要的不是模型“采样”,而是每一步都在维护几个不变量:

  • tool_use 不能悬空
  • 已见内容的命运不能反悔
  • 能力面不能暴露过量
  • 连续性不能只压在 transcript 上
  • 异步执行体必须能被追踪、恢复、终止

这条线在运行时又落到四类核心状态载体上:

  1. mutableMessages / messages:当前会话视图。不是唯一真相,但仍是大部分 turn 逻辑的主干。
  2. AppState:跨模块共享的宿主总线。UI、tasks、permissions、MCP、plugins、agent definitions、todo、diagnostics 都会落到这里。
  3. ToolUseContext:tool runtime 的能力总线。工具池、read file cache、AppState bridge、notification、task hooks、memory triggers 都从这里渗透进工具执行。
  4. 持久化 sidecar:transcript JSONL、task output files、remote agent metadata、content replacement records、memory files。真正的续航不靠单一 messages 数组,而靠这批外化工件。

这四类载体之间没有清晰一对一 owner。这既是系统的生产性来源,也是后文结构债的源头。后面的“分篇”就围绕这几条线索展开。

分篇一:宿主与主循环

1. QueryEngine.ts 不是内核,它是会话宿主

query.ts 解决的是“一轮 turn 如何推进”,QueryEngine.ts 解决的是“这套 turn engine 如何成为 SDK/headless 可用的会话接口”。

源码开头的类定义已经把职责写得很明确:

exportclass QueryEngine {  private mutableMessagesMessage[]  private abortControllerAbortController  private permissionDenialsSDKPermissionDenial[]  private totalUsageNonNullableUsage  private hasHandledOrphanedPermission = false  private readFileStateFileStateCache  private discoveredSkillNames = new Set<string>()  private loadedNestedMemoryPaths = new Set<string>()}

这几个字段说明 QueryEngine 拥有的不是“当前一步推理”,而是 conversation host:

  • 跨 turn 的消息视图
  • 累计 usage
  • file cache
  • orphaned permission 状态
  • nested memory / skill discovery 的宿主持有

真正关键的是,它坚持在进入 query() 前先把用户消息落进 transcript:

// Persist the user's message(s) to transcript BEFORE entering the query loop.if (persistSession && messagesFromUserInput.length > 0) {const transcriptPromise = recordTranscript(messages)  ...}

这一步保证的是宿主级不变量:用户消息一旦被接受,就先进入可恢复轨迹,哪怕 API 还没开始返回。

真正进入 query() 之前,QueryEngine 还会把宿主壳一次性装好:

constprocessUserInputContextProcessUserInputContext = {  canUseToolthis.config.canUseTool,  getUpdatedContext() => ({    commands,    tools,    agents,    mcpClients,discoveredSkillNamesthis.discoveredSkillNames,loadedNestedMemoryPathsthis.loadedNestedMemoryPaths,    ...  }),}const systemPrompt = asSystemPrompt([  ...(customPrompt !== undefined ? [customPrompt] : defaultSystemPrompt),  ...(memoryMechanicsPrompt ? [memoryMechanicsPrompt] : []),  ...(appendSystemPrompt ? [appendSystemPrompt] : []),])

这说明它真正负责的是会话级装配:

  • processUserInput
  • system prompt parts
  • memory mechanics prompt
  • plugins cache-only preload
  • nested memory / discovered skills 的跨 turn 持有

也就是说,QueryEngine 不只是把 prompt 转给 query.ts,而是在不同宿主模式下,拼出一份真正可运行的 runtime surface。

同一层还负责 orphaned permission 的 delayed continuation。收到外部 permission response 后,逻辑不是直接执行工具,而是先把原始 assistant tool_use 补回 mutableMessages / transcript,再继续执行:

if (!alreadyPresent) {  mutableMessages.push(assistantMessage)if (persistSession) {awaitrecordTranscript(mutableMessages)  }}

这里的关注点不是权限 UI,而是轨迹拓扑。如果 tool_result 落地时前面的 tool_use 没被补回去,resume 得到的会是一条悬空结果。

2. QueryEngine 同时也是宿主协议适配器

query() 内部产出的消息很多,QueryEngine 并不原样透传,而是做宿主投影:

  • 内部 Message 投成 SDK 事件
  • 内部 system message 挑选后再暴露
  • terminal result 在这一层统一收口

这一层的投影逻辑本身也很具体:

switch (message.type) {  case 'assistant':this.mutableMessages.push(message)yieldnormalizeMessage(message)break  case 'progress':this.mutableMessages.push(message)if (persistSession) {      messages.push(message)voidrecordTranscript(messages)    }yieldnormalizeMessage(message)break  case 'system':if (message.subtype === 'compact_boundary') {yield {type'system',subtype'compact_boundary',compact_metadatatoSDKCompactMetadata(message.compactMetadata),      }    }break}

注意这里的“投影”不是 cosmetic 层:

  • progress 既进入本地可变消息,又可能被持久化
  • compact boundary 会被翻译成 SDK 专用结构
  • terminal result 只在这一层收口,query.ts 本身并不懂 SDK envelope

headless 模式还有一个 REPL 没有的差异:snip replay 会真的裁掉本地 mutableMessages,而不是只做 UI 投影。这意味着 SDK 路径优先控制长会话内存,而 REPL 路径优先保留 scrollback。

对应实现是:

// Snip boundary: replay on our store to remove zombie messages and// stale markers. The yielded boundary is a signal, not data to push —// the replay produces its own equivalent boundary. Without this,// markers persist and re-trigger on every turn, and mutableMessages// never shrinks (memory leak in long SDK sessions). The subtype// check lives inside the injected callback so feature-gated strings// stay out of this file (excluded-strings check).const snipResult = this.config.snipReplay?.(  message,  this.mutableMessages,)if (snipResult !== undefined) {  if (snipResult.executed) {this.mutableMessages.length = 0this.mutableMessages.push(...snipResult.messages)  }  break}

这条分叉很重要,因为它说明宿主差异不只体现在 UI,而体现在本地状态模型本身:REPL 保住 scrollback,headless 保住内存占用。

structured output 也不是普通工具增强,而是宿主输出契约。

QueryEngine 先注册 Stop hook:

addFunctionHook(  setAppState,  sessionId,'Stop','',messages =>hasSuccessfulToolCall(messages, SYNTHETIC_OUTPUT_TOOL_NAME),`You MUST call the ${SYNTHETIC_OUTPUT_TOOL_NAME} tool to complete this request. Call this tool now.`,  { timeout5000 },)

再把外部 JSON schema 编译成 AJV 校验过的 SyntheticOutputTool。这里的结构不是“最后 parse 一下 JSON”,而是:

  1. 宿主先声明“必须结构化结束”
  2. 再把 schema 编译成一次性工具契约
  3. query loop 在这个契约之下继续运行

这比“模型直接输出 JSON,再赌解析成功”严格得多。

合成工具的关键实现也值得直接看:

const validateSchema = ajv.compile(jsonSchema)async call(input) {  const isValid = validateSchema(input)  if (!isValid) {throw new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS(`Output does not match required schema: ${errors}`,      ...,    )  }  return {data'Structured output provided successfully',structured_output: input,  }}

这里把“输出必须满足 schema” 前移成了工具执行期约束,而不是后置解析约束。对于生产接口来说,这是本质差别。

3. query.ts 不是 linear pipeline,而是恢复图

query.ts 的形状并不是“采样 -> 工具 -> 返回”。

typeState = {  messagesMessage[]  toolUseContextToolUseContext  autoCompactTrackingAutoCompactTrackingState | undefined  maxOutputTokensRecoveryCountnumber  hasAttemptedReactiveCompactboolean  maxOutputTokensOverridenumber | undefined  pendingToolUseSummaryPromise<ToolUseSummaryMessage | null> | undefined  stopHookActiveboolean | undefined  turnCountnumber  transitionContinue | undefined}

真正关键的字段不是 messages,而是 transition。它把“上一轮为什么继续”提升成显式状态,整个 turn engine 因此更接近一张恢复图:

first_turn  -> next_turn  -> collapse_drain_retry  -> reactive_compact_retry  -> max_output_tokens_escalate  -> max_output_tokens_recovery  -> stop_hook_blocking  -> token_budget_continuation

顺着这张图读代码,比顺着文件从上到下读更接近真实运行路径。

显式状态机的另一个重要信号,是它把很多通常会藏进局部变量的东西提升成了一等状态:

  • pendingToolUseSummary
  • stopHookActive
  • autoCompactTracking
  • maxOutputTokensRecoveryCount
  • hasAttemptedReactiveCompact

这不是写法偏好,而是在承认:这些东西都跨 iteration 生效,必须被当成恢复图的一部分。

4. 这条主循环维护的不是文本输出,而是合法轨迹

query.ts 里最硬的约束不是“尽量拿到回答”,而是“轨迹不能断”。

首先,任何已发出的 tool_use 都必须得到匹配的 tool_result,哪怕是 synthetic error:

functionyieldMissingToolResultBlocks(  assistantMessages: AssistantMessage[],  errorMessage: string,) {  for (const assistantMessage of assistantMessages) {const toolUseBlocks = assistantMessage.message.content.filter(content => content.type === 'tool_use',    ) as ToolUseBlock[]for (const toolUse of toolUseBlocks) {yield createUserMessage({content: [{type'tool_result',content: errorMessage,is_errortrue,tool_use_id: toolUse.id,        }],sourceToolAssistantUUID: assistantMessage.uuid,      })    }  }}

其次,thinking block 不是任意剪裁的富文本,它受 trajectory 约束:

/** * The rules of thinking are lengthy and fortuitous. They require plenty of thinking * of most long duration and deep meditation for a wizard to wrap one's noggin around. * * The rules follow: * 1. A message that contains a thinking or redacted_thinking block must be part of a query whose max_thinking_length > 0 * 2. A thinking block may not be the last message in a block * 3. Thinking blocks must be preserved for the duration of an assistant trajectory (a single turn, or if that turn includes a tool_use block then also its subsequent tool_result and the following assistant message) * * Heed these rules well, young wizard. For they are the rules of thinking, and * the rules of thinking are the rules of the universe. If ye does not heed these * rules, ye will be punished with an entire day of debugging and hair pulling. */

这解释了很多后面看似“多余”的恢复行为。系统要维护的不是 assistant 文本,而是一个带工具、thinking、fallback、recovery 的合法消息拓扑。

错误分流也因此不是普通异常处理,而是轨迹保护:

  • prompt_too_long
  • withheld media oversize
  • max_output_tokens
  • fallback trigger
  • 真正不可恢复的 API error

有些 error 必须被 SDK 暂时看不见,直到系统确认恢复路径已经失败。这就是 isWithheldMaxOutputTokens(...) 那类逻辑真正存在的原因。

5. prompt_too_long 和 max_output_tokens 都不是错误处理,而是上下文调度

prompt_too_long 的路径不是“失败就 compact”,而是两级恢复:

  1. 先走 context collapse drain
  2. 再走 reactive compact

顺序不能反,因为 collapse 仍保留细粒度结构,reactive compact 一旦发生就进入全局摘要化。

max_output_tokens 也是同样的思路:

  1. 先试放大单次输出上限
  2. 再试 meta continuation 续写
  3. 超过恢复上限才真的落错误

这条主循环真正做的,是不断修正同一条轨迹,而不是反复发起彼此独立的模型请求。

这里还有一个容易漏掉的点:pendingToolUseSummary 与 stopHookActive 都说明 loop 不是“本轮全做完再说”,而是在为跨轮回收与防死循环留显式锚点。 这已经是成熟 runtime 才会出现的形状,而不是 demo agent loop。

分篇二:提示词 & 系统指令栈

1. system prompt 在这里不是前言,而是第一控制面

这份源码里,system prompt 不是一段静态文案,而是一层真正参与运行时治理的控制面。它决定:

  • 当前会话究竟以什么身份运行
  • 哪些行为被当成硬约束
  • 哪些能力应被优先暴露
  • 哪些宿主模式会直接替换整套默认规则

最关键的入口不是 constants/prompts.ts,而是 utils/systemPrompt.ts 里的优先级组装器:

/** * Builds the effective system prompt array based on priority: * 0. Override system prompt * 1. Coordinator system prompt * 2. Agent system prompt * 3. Custom system prompt * 4. Default system prompt */export function buildEffectiveSystemPrompt({...}): SystemPrompt {  if (overrideSystemPrompt) {return asSystemPrompt([overrideSystemPrompt])  }  if (coordinatorModeActive) {return asSystemPrompt([getCoordinatorSystemPrompt(),      ...(appendSystemPrompt ? [appendSystemPrompt] : []),    ])  }  ...}

这段代码的重要性在于,它把 prompt 明确建模成了宿主协议的一部分

  • loop mode / REPL override 可以整套替换默认 prompt
  • coordinator mode 不是“多加几句 delegation 建议”,而是直接换成另一套系统法则
  • agent prompt 在 proactive mode 下甚至不是 replace,而是 append 到 default prompt 上

这说明 prompt 在这里不是文案层,而是 runtime law。 哪个 prompt 赢,直接决定了后续能力面、任务边界、消息协议、输出语义。

2. system prompt 还是一个带缓存边界的结构化对象

这套系统不只是“拼一段长字符串”。它显式区分了 static prefix 与 dynamic tail:

/** * Everything BEFORE this marker in the system prompt array can use scope: 'global'. * Everything AFTER contains user/session-specific content and should not be cached. */exportconstSYSTEM_PROMPT_DYNAMIC_BOUNDARY ='__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'

getSystemPrompt() 也围绕这个边界来组织内容:

return [  // --- Static content (cacheable) ---  getSimpleIntroSection(outputStyleConfig),  getSimpleSystemSection(),  getSimpleDoingTasksSection(),  getActionsSection(),  getUsingYourToolsSection(enabledTools),  getSimpleToneAndStyleSection(),  getOutputEfficiencySection(),  ...(shouldUseGlobalCacheScope() ? [SYSTEM_PROMPT_DYNAMIC_BOUNDARY] : []),  // --- Dynamic content (registry-managed) ---  ...resolvedDynamicSections,].filter(s => s !== null)

这意味着 system prompt 在这里同时具有两种属性:

  • 它是行为法则
  • 它还是 prompt cache 的可计算对象

配套的 section registry 进一步把 prompt 变成结构化资产,而不是随手字符串拼接:

exportfunctionsystemPromptSection(  name: string,  compute: ComputeFn,): SystemPromptSection {return { name, compute, cacheBreakfalse }}exportfunctionDANGEROUS_uncachedSystemPromptSection(  name: string,  compute: ComputeFn,  _reason: string,): SystemPromptSection {return { name, compute, cacheBreaktrue }}

这里最值得 agent 开发者记住的,不是 API 形状,而是设计态度

  • prompt section 默认应该 memoize
  • 只有真正会跨 turn 漂移、且无法 attachment 化的内容,才允许进入 DANGEROUS_uncachedSystemPromptSection
  • /clear 与 /compact 不只是清消息,也会清 prompt section 状态

比如 mcp_instructions 就被单列成危险 section,因为 MCP server 可能在会话中途连上或断开;如果仍放在 per-turn sys-prompt append 里,会直接打碎 prompt cache。

3. 值得单独学习的几段 system prompt,不是文风,而是架构意图

默认主 prompt 里有几段非常有价值,因为它们不是“写得漂亮”,而是在弥补模型默认行为的工程偏差。

第一类是 任务执行法则

In general, do not propose changes to code you haven't read....Don't create helpers, utilities, or abstractions for one-time operations....Report outcomes faithfully...

这三句分别在纠正三类常见 agent 失真:

  • 没读就改
  • 过度抽象
  • 把不完整结果包装成完成

第二类是 风险边界法则

Carefully consider the reversibility and blast radius of actions....for actions that are hard to reverse ... ask for confirmation before proceeding

这不是礼貌提醒,而是 autonomy boundary 的 prompt 版表达。权限系统在代码层再兜一遍,但默认 system prompt 已经先把“高风险动作需要确认”写成了行为规范。

第三类是 工具使用法则

DoNOT use the Bash tool when a relevant dedicated tool is provided....You can call multiple tools in a single response.If there are no dependencies between them, make all independent tool calls in parallel.

这两句看似普通,实际上把两种非常关键的 runtime 设计提前灌进了模型:

  • dedicated tools 优先于自由 shell
  • 并行工具调用是鼓励项,但要服从依赖关系

第四类是 团队通信法则。它不在默认 prompt 主体里,而在 teammate addendum:

IMPORTANTYou are running as an agent in a team....Just writing a response in text is not visible to others on your team - you MUST use the SendMessage tool.

这段 prompt 的价值不在“提醒发消息”,而在把 actor mailbox 的协议直接写进模型的世界观。

第五类是 延迟能力法则ToolSearch prompt 直白地告诉模型:

Until fetched, only the name is known — there is no parameter schema,so the tool cannot be invoked.

这是非常少见、但非常成熟的能力暴露语义。它把“名字已可见、schema 未加载”的半激活状态明确写给模型,避免模型把 deferred capability 当成 fully-loaded capability。

4. system-reminder、critical reminder、attachment 提醒,构成第二条指令通道

这套 runtime 的指令不只存在于顶层 system prompt。 它还通过 meta user message 与 attachment 在运行中持续注入约束。

utils/api.ts 里会把额外上下文包装成 <system-reminder> user meta message:

return [createUserMessage({content`<system-reminder>\nAs you answer the user's questions, you can use the following context:\n...`,isMetatrue,  }),  ...messages,]

与此同时,attachments.ts 还能补一类更硬的 reminder:

functiongetCriticalSystemReminderAttachment(  toolUseContext: ToolUseContext,): Attachment[] {const reminder = toolUseContext.criticalSystemReminder_EXPERIMENTALif (!reminder) return []return [{ type'critical_system_reminder'content: reminder }]}

这说明系统提示词在这里有两个层次:

  • 顶层 system prompt:定义长期法则
  • 运行中 reminder / attachment:补充当前 turn 的临时约束、上下文和能力提醒

这对 agent 开发者很重要。很多约束不应该永久写进 system prompt;它们更适合在真正相关的 turn 被短暂、强提示地注入。

5. 这套 runtime 里不止一个 prompt,还有一群“辅助模型 worker”

如果只盯着主对话模型,会漏掉一个很重要的架构事实:这套系统内部已经有多类专用 prompt worker,在替主 loop 做窄任务判断。

第一类是 memory selector

const result = awaitsideQuery({modelgetDefaultSonnetModel(),systemSELECT_MEMORIES_SYSTEM_PROMPT,  ...output_format: { type'json_schema'schema: ... },})

它不是让主模型自己从一堆 memory 文件里挑,而是先用 side query 做 relevance filtering。

第二类是 auto mode classifier。它甚至有一套独立的 system prompt 和 XML 协议:

constXML_S1_SUFFIX = '\nErr on the side of blocking. <block> immediately.'constXML_S2_SUFFIX ='\n... explicit user confirmation is required ... Use <thinking> before responding with <block>.'

这里的 classifier 已经不是启发式判断,而是一个小型二阶段 policy judge。

第三类是 tool use summary worker

constTOOL_USE_SUMMARY_SYSTEM_PROMPT = `Write a short summary label ...it truncates around 30 characters, so think git-commit-subject`

它负责把工具批次压成移动端友好的短标签,服务的是宿主展示,而不是主 loop 推理。

第四类是 session memory updater

YourONLY task is to use the Edit tool to update the notes file, then stop....NEVER modify, delete, or add section headers

这其实是在用一个专门 prompt 驱动“结构化工作卡维护器”,而不是把记忆更新混在主 loop 里让它顺便做。

对于 agent 开发者,这里最有启发的一点是:不要把所有智能都塞给主对话模型。把 recall、policy judgment、UI summary、memory maintenance 这些窄任务分配给专用 prompt worker,主 loop 才能保持干净。

分篇三:能力、工具与策略

1. Tool.ts 把工具定义成能力契约,不是函数接口

Tool 抽象里真正重要的不是 call(),而是它同时携带:

  • 输入 schema
  • 并发语义
  • 读写 / destructive 语义
  • permission check
  • UI 渲染
  • result mapping
  • 最大结果长度
  • defer loading 语义

默认值在 buildTool() 里集中定义,隐含的架构态度很清楚:

  • 并发安全默认否定
  • 读写语义必须显式声明
  • 权限既可由工具自己处理,也可落到通用 permission system

这使工具层更像“能力登记处”,而不是松散 handler 集合。

默认值本身也很能说明这份 runtime 的工程倾向:

constTOOL_DEFAULTS = {  isEnabled() => true,  isConcurrencySafe(_input?: unknown) => false,  isReadOnly(_input?: unknown) => false,  isDestructive(_input?: unknown) => false,  checkPermissions(...) =>Promise.resolve({ behavior'allow'updatedInput: input }),  toAutoClassifierInput(_input?: unknown) => '',  userFacingName(_input?: unknown) => '',}

这里最重要的并不是“默认 allow”,而是其余语义都要求显式声明:

  • 并发安全默认不成立
  • 只读默认不成立
  • 破坏性默认不成立

这意味着工具作者必须显式告诉 runtime 这把工具在调度和安全上应如何被对待。

2. ToolUseContext 是工具 runtime 的总线

ToolUseContext 暴露的不是几个工具参数,而是几乎整个 runtime:

  • 工具池与 MCP clients
  • AppState getter/setter
  • file cache
  • notifications
  • memory triggers
  • agent definitions
  • permission bridge
  • task hooks
  • telemetry

它带来极高的执行力,也带来一个直接后果:工具很容易“顺手”跨边界访问宿主状态。

这就是后文结构债里 AppState 与 ToolUseContext 双重总线的来源。

源码里的定义比文字更直观:

exporttype ToolUseContext = {  options: {commandsCommand[]mainLoopModelstringtoolsToolsmcpClientsMCPServerConnection[]mcpResourcesRecord<stringServerResource[]>isNonInteractiveSessionbooleanagentDefinitionsAgentDefinitionsResult    refreshTools?: () => Tools  }  abortControllerAbortController  readFileStateFileStateCache  getAppState(): AppState  setAppState(f(prev: AppState) => AppState): void  ...}

这就是典型的“高能力总线”形态:一旦某个子系统被塞进 ToolUseContext,它几乎立即获得了被所有工具访问的资格。

3. toolExecution.ts 是完整流水线,不是 tool.call(input)

工具调用真正的形状是一条多阶段流水线:

const parsedInput = tool.inputSchema.safeParse(input)...const isValidCall = await tool.validateInput?.(...)...startSpeculativeClassifierCheck(...)...runPreToolUseHooks(...)...resolveHookPermissionDecision(...)...tool.call(...)...runPostToolUseHooks(...)...processToolResultBlock(...)

这条流水线里真正先进的地方,是权限、hooks、classifier、result rewrite 都和工具调用同构。系统不是“工具跑完后再考虑安全和上下文”,而是把这些控制点写进执行路径本身。

并发调度也不是“能并发就都并发”,而是显式区分执行与提交:

for (const { isConcurrencySafe, blocks } of partitionToolCalls(...)) {if (isConcurrencySafe) {const queuedContextModifiersRecord<string,      ((context: ToolUseContext) => ToolUseContext)[]    > = {}for await (const update of runToolsConcurrently(...)) {if (update.contextModifier) {        ...      }yield { message: update.messagenewContext: currentContext }    }for (const block of blocks) {const modifiers = queuedContextModifiers[block.id]      ...      currentContext = modifier(currentContext)    }  }}

这一步非常关键:读工具可以并发执行,但上下文副作用必须按原工具顺序提交,否则后续模型状态会被乱序污染。

4. ToolSearch、REPL、skills 解决的是同一个问题:能力面控制

这三套机制表面差别很大,底层目标其实一致:

  • ToolSearch 解决 MCP schema 不能一次性暴露
  • REPL 把大量 primitive tools 虚拟化到一个入口里
  • skills 让 prompt artifact 也变成按路径/时机出现的能力面

这意味着 runtime 真正管理的不是“有哪些工具”,而是“模型当前能看到多少可执行表面”。

这里的关键不只是节省 token,而是维持能力面稳定性与 cache prefix 稳定性。一旦工具池、schema 描述、skills 说明在每轮都剧烈漂移,模型的工作面就失去连续性了。

这一层在实现上有三种不同手法:

  • tools.ts 对 built-ins 与 MCP 工具分区排序,保持 cache-stable prefix
  • ToolSearch 先暴露名字,后暴露 schema
  • REPL 把大量 primitive tools 收束进一个虚拟化入口

它们表面不同,实际上都在回答同一个问题:模型本轮应该看到多大的 capability surface。

tools.ts 里的排序逻辑其实已经把 cache 语义写在注释里:

// Sort each partition for prompt-cache stability, keeping built-ins as a// contiguous prefix.constbyName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)returnuniqBy(  [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),'name',)

这不是美化输出顺序,而是在保护 system prompt 的稳定前缀。一旦 MCP 工具插进 built-ins 中间,后面的缓存键整体漂移,系统提示的复用价值会迅速下降。

REPL 则在执行面上做了另一层虚拟化。工具池组装阶段就会把 primitive tools 从模型直视面中拿掉:

// When REPL mode is enabled, hide primitive tools from direct use.// They're still accessible inside REPL via the VM context.if (isReplModeEnabled()) {const replEnabled = allowedTools.some(tool =>toolMatchesName(tool, REPL_TOOL_NAME),  )if (replEnabled) {    allowedTools = allowedTools.filter(tool => !REPL_ONLY_TOOLS.has(tool.name),    )  }}

这说明 REPL 在这里不是“多一个工具”,而是一个虚拟化层:

  • 模型表面上看到的是一个收束后的执行入口
  • 内层仍可重用 Bash / Read / Edit / Glob / Grep 等原始能力
  • 权限系统继续在内层细粒度生效,而不是被 REPL 绕开

skills 再往前走了一步,把 prompt artifact 也纳入 capability surface 管理。loadSkillsDir.ts 里 skill 并不是一段 markdown,而是一个结构化 command:

exportfunction createSkillCommand({...}): Command {  return {type'prompt',name: skillName,    description,    allowedTools,    whenToUse,    model,    disableModelInvocation,context: executionContext,    agent,    effort,    paths,    ...  }}

也就是说,skill 同时携带:

  • 可调用工具约束
  • 推荐模型
  • 是否 fork 执行
  • 绑定 agent
  • path 条件与 effort

它已经不是“快捷提示词”,而是 capability artifact。

条件激活逻辑也并不是抽象配置,而是明确按工作面触发:

const skillIgnore = ignore().add(skill.paths)for (const filePath of filePaths) {const relativePath = isAbsolute(filePath)    ? relative(cwd, filePath)    : filePath  ...if (skillIgnore.ignores(relativePath)) {    dynamicSkills.set(name, skill)    conditionalSkills.delete(name)    activated.push(name)break  }}

这和 deferred tools 的 discovered-set 延续其实是一类机制。toolSearch.ts 里把 compaction 之后仍要保留的工具发现状态显式挂到 compact boundary 上:

// Compaction replaces tool_reference-bearing messages with a summary, so it// snapshots the discovered set onto compactMetadata.preCompactDiscoveredToolsif (msg.type === 'system' && msg.subtype === 'compact_boundary') {const carried = msg.compactMetadata?.preCompactDiscoveredToolsif (carried) {for (const name of carried) discoveredTools.add(name)  }}

把这几处放在一起看,能力面治理就不再只是“少给几个工具”这么简单,而是一个持续演化的 surface:

  • 初始只给稳定前缀
  • REPL 隐藏 primitive entrypoints
  • deferred tools 名字先出现,schema 按需展开
  • skills 随路径和工作面激活
  • compaction 后 discovered set 继续带过去

这才是这套 runtime 的 capability continuity。

5. 权限系统已经是独立的 Policy Control Plane

源码里的 permissions 不是工具执行前的一层确认框,而是一条独立的策略栈。

它至少同时包含四层来源:

  1. 组织级 policy limits:从 API 拉取、ETag 缓存、后台轮询,默认 fail open,但在特定隐私等级上对个别 policy fail closed:

    Fetches organization-level policy restrictions from the API and uses them* to disable CLI features.* (fail open, ETag caching, background polling, retry logic)
    constESSENTIAL_TRAFFIC_DENY_ON_MISS = newSet(['allow_product_feedback'])
  2. 多来源规则层:user settings、project settings、local settings、cli arg、session rule、managed rule 共同进入 ToolPermissionContext,并且支持 replacement sync,而不是简单追加。

  3. tool 自身的 checkPermissions():工具可以声明更细粒度的安全检查。

  4. auto mode classifier:这不是“提示词小技巧”,而是权限系统内部的二级模型裁决链。

permissions.ts 里最说明问题的是这段:

if (feature('TRANSCRIPT_CLASSIFIER') &&  (appState.toolPermissionContext.mode === 'auto' ||    (appState.toolPermissionContext.mode === 'plan' &&      (autoModeStateModule?.isAutoModeActive() ?? false)))) {  ...  const acceptEditsResult = await tool.checkPermissions(... mode'acceptEdits')  ...  if (classifierDecisionModule!.isAutoModeAllowlistedTool(tool.name)) {    ...  }  const action = formatActionForClassifier(tool.name, input)  classifierResult = await classifyYoloAction(...)}

这说明 auto mode 不是单一开关,而是一条次级决策链:

  • 先看安全检查是否允许进入 classifier
  • 再看 accept-edits fast path
  • 再看 allowlist
  • 最后才跑 classifier
  • denial tracking 再把历史结果反馈回下一次决策

因此,权限系统真正负责的是 agent autonomy boundary。它决定模型此刻能否代表用户继续行动,而不只是“工具按钮要不要点确认”。

还有一条更隐蔽、但很有生产味道的设计:规则同步不是简单追加,而是 replacement sync。

exportfunction syncPermissionRulesFromDisk(  toolPermissionContext: ToolPermissionContext,  rules: PermissionRule[],): ToolPermissionContext {  ...  for (const diskSource of diskSources) {for (const behavior of ['allow''deny''ask'as PermissionBehavior[]) {      context = applyPermissionUpdate(context, {type'replaceRules',rules: [],        behavior,destination: diskSource,      })    }  }  const updates = convertRulesToUpdates(rules, 'replaceRules')  return applyPermissionUpdates(context, updates)}

这说明权限上下文被当成一份需要长期同步的一等状态,而不是临时 prompt 附件。否则删除一条规则时,旧规则会幽灵般残留在 runtime 里。

分篇四:连续性、缓存与恢复

1. attachments.ts 是上下文路由器,不是附件工具

attachments.ts 是整个 runtime 里最容易被误判的文件之一。它已经不是“给消息补几个附件”,而是在多个控制面之间做路由:

  • user input attachments
  • nested memory
  • relevant memories
  • dynamic skills
  • task attachments
  • teammate messages
  • date change
  • deferred tools delta
  • IDE selection / diagnostics
  • todo reminders
  • system reminders

换句话说,attachments.ts 不是一个边缘辅助模块,而是主循环前的 context router。这也是它同时成为亮点与结构债的原因。

这一层已经在做次级调度,而不是静态拼装。相关记忆注入的路径就很典型:

const allResults = await Promise.all(  dirs.map(dir =>findRelevantMemories(      input,      dir,      signal,      recentTools,      alreadySurfaced,    ).catch(() => []),  ),)const selected = allResults  .flat()  .filter(m => !readFileState.has(m.path) && !alreadySurfaced.has(m.path))  .slice(05)return [{ type'relevant_memories' as const, memories }]

这里不是“扫目录然后全塞进去”,而是:

  • @agent-x 时切到 agent 自己的 memory dir,保持隔离
  • alreadySurfaced 在 selector 前就过滤,避免 Sonnet 浪费 5 个名额去重选旧记忆
  • readFileState 再拦一次,避免模型刚读过文件又被 attachment 重复注入

更细的一笔在于,surfaced memory 的去重预算不是挂在某个长寿命对象上,而是从消息本身反扫:

if (m.type === 'attachment' && m.attachment.type === 'relevant_memories') {for (const mem of m.attachment.memories) {    paths.add(mem.path)    totalBytes += mem.content.length  }}

这意味着 compact 之后旧 attachment 消失,记忆允许重新浮现。去重范围因此天然和“当前仍在上下文中的历史”绑定,而不是和整个进程寿命绑定。

它甚至还要处理任务通知注入带来的调度副作用:

constINLINE_NOTIFICATION_MODES = newSet(['prompt''task-notification'])// During proactive agentic loops, task-notification commands would otherwise// stay in the queue permanently ... causing Sleep to wake immediately// with 0ms duration in an infinite loop.

这已经不是附件构造,而是 loop liveness 的一部分。所以说 attachments.ts 是“第二调度器”,并不是修辞。

2. 这套系统没有单一 memory 子系统

如果硬找 “memory 模块”,会得到错误答案。这份代码里的连续性至少分成四类:

  1. instruction continuity(指令连续性)CLAUDE.md、rules、conditional rules
  2. capability continuity(能力连续性):discovered tools、dynamic skills、agent definitions
  3. semantic continuity(语义连续性)MEMORY.md、topic files、daily logs、relevant memories
  4. operational continuity(操作连续性):transcript、session memory、task state、sidechain transcript、content replacement records

这就是为什么 “memory” 在这里不能被理解成某个目录,而应该被理解成一组外化机制。

最容易被低估的一点是:query-time recall 也不是“向量库召回”,而是一次专用 side query。findRelevantMemories.ts 的实现很直接:

const result = await sideQuery({  modelgetDefaultSonnetModel(),  systemSELECT_MEMORIES_SYSTEM_PROMPT,  skipSystemPromptPrefixtrue,  messages: [    {role'user',content`Query: ${query}\n\nAvailable memories:\n${manifest}${toolsSection}`,    },  ],  max_tokens256,  output_format: {type'json_schema',schema: {type'object',properties: {selected_memories: { type'array'items: { type'string' } },      },required: ['selected_memories'],additionalPropertiesfalse,    },  },  signal,  querySource'memdir_relevance',})

它的关键设计不是“又调了一次模型”,而是:

  • 只扫 frontmatter / header manifest,不预读整篇正文
  • 用小模型做 relevance selection,把主模型前缀预算留给当前任务
  • recentTools 会显式传进 selector,避免“当前正在用 spawn 工具,于是又召回 spawn 文档”这种伪相关

所以这里的 memory 不是一个大而化之的 recall 黑箱,而是几种不同时间尺度的外化物,再加几条不同精度的召回路径。

3. MEMORY.md、daily logs、session memory 是三种不同工件

MEMORY.md 与 daily logs 更像 log/index 分离:

  • 高频写入进 append-only 日志
  • 低频蒸馏成 topic files 与 MEMORY.md

session memory 又是另一种工件。它不是摘要日志,而是 compaction 之后的工作卡,关注:

  • 当前状态
  • 当前任务规格
  • 关键文件与函数
  • 工作流
  • 错误与修正
  • next step

这三者混在一起会很乱,但分开看就清楚:MEMORY.md 管长期语义入口,daily logs 管原始积累,session memory 管断点续航。

memdir.ts 对 MEMORY.md 的边界也给得非常明确:

exportconstENTRYPOINT_NAME = 'MEMORY.md'exportconstMAX_ENTRYPOINT_LINES = 200exportconstMAX_ENTRYPOINT_BYTES = 25_000

同一文件里又把 daily log 的语义写成 append-only:

// append-only to a date-named log file rather than maintaining MEMORY.md as// a live index. A separate nightly /dream skill distills logs into topic// files + MEMORY.md.

这就是典型的 log/index 分离思路:

  • 高频写入进入 date-named append-only daily log
  • 低频蒸馏生成 topic files 与 MEMORY.md
  • MEMORY.md 是受预算限制的入口页,不是实时写热点

session memory 的粒度要求在模板里写得很死,不是随意摘要:

# Current State# Task specification# Files and Functions# Workflow# Errors & Corrections# Codebase and System Documentation# Learnings# Key results# Worklog

更新 prompt 还额外规定了几条硬约束:

  • 不能改 section header,也不能改斜体说明行
  • Current State 必须永远反映“最近一次工作现场”
  • Key results 要写“用户要求的完整结果”,不是抽象总结
  • 超 budget 时优先压缩旧内容,但尽量保住 Current State 与 Errors & Corrections

这说明 session memory 在这里并不是“可有可无的笔记文件”,而是 compact 之后的工作卡。它的任务是让 runtime 在失去大量原始 turn 细节后,仍保留足够强的 continuation anchor。

nested memory 注入还带着很强的读写一致性防护。当注入内容和磁盘原文不一致时,runtime 不会假装“模型已经看过完整文件”,而是把原始磁盘字节缓存成 partial view:

toolUseContext.readFileState.set(memoryFile.path, {content: memoryFile.contentDiffersFromDisk    ? (memoryFile.rawContent ?? memoryFile.content)    : memoryFile.content,timestampDate.now(),offsetundefined,limitundefined,isPartialView: memoryFile.contentDiffersFromDisk,})

这一步的价值在于:如果注入时剥掉了 frontmatter、裁掉了大文件尾部、或去掉了某些不可见内容,后续 Edit/Write 工具不会误以为自己持有的是完整文件视图,而会先要求真实 Read。 这是一种很少被显式讨论、但对 agent 改文件安全性非常重要的 continuity guard。

4. 缓存控制面管理的不是 token 总量,而是 prefix 命运

toolResultStorage.ts 里最硬的状态不是预算数字,而是:

exporttypeContentReplacementState = {seenIdsSet<string>replacementsMap<stringstring>}

注释点得很准:

// Once seen, a result's fate is frozen for the conversation.

这条不变量意味着:

  • 某个 tool_use_id 一旦被看过,它之后是否替换就被冻结
  • 已替换的永远复用同一个 replacement string
  • 未替换的以后也不能补替换

这里真正被管理的不是“总共多少 token”,而是“历史前缀不能反悔”。

配套的持久化记录也不是可推导元数据,而是把“模型实际看到了什么字串”直接写盘:

exporttypeContentReplacementRecord = {kind'tool-result'toolUseIdstringreplacementstring}

源码注释把原因说得很直白:replacement 要直接存 exact string,而不是 resume 时重算;否则一旦 preview 模板、大小格式、路径布局改了,prompt cache 就会静默失配。

cached microcompact 进一步把本地消息视图与 API 前缀视图拆开:本地消息不改,API 层通过 cache_edits 缩上下文。

这个分层在 microCompact.ts 和 services/api/claude.ts 里非常明确:

// Return messages unchanged - cache_reference and cache_edits are added at API layerreturn {  messages,compactionInfo: {pendingCacheEdits: {trigger'auto',deletedToolIds: toolsToDelete,      ...    },  },}

真正把 deletion 编辑钉到 API 消息上的,是后面的注入层:

for (const pinned of pinnedEdits ?? []) {const msg = result[pinned.userMessageIndex]  ...insertBlockAfterToolResults(msg.content, dedupedBlock)}if (newCacheEdits && result.length > 0) {  ...insertBlockAfterToolResults(msg.content, dedupedNewEdits)pinCacheEdits(i, newCacheEdits)}

这里有两个很少在通用 agent 设计里被认真处理的问题:

  • cache_edits 必须固定插回原用户消息位置,否则后续 wire prefix 还是漂
  • 已 pin 的删除块要每轮重送,而且要跨 block 去重,否则 API 层会反复删除同一 cache_reference

这条链加上工具排序、fork prefix 复制、resume gap fill,一起构成了整个系统最稀缺的一层:prompt cache 被当成一等架构约束,而不是部署细节。

5. sessionStorage.ts 维护的不是 JSONL,而是可修补的因果链

sessionStorage.ts 的真正对象不是日志文件,而是一条 parentUuid 因果链。

第一步,是把 progress 从持久链里明确踢出去:

exportfunction isTranscriptMessage(entry: Entry): entry is TranscriptMessage {  return (    entry.type === 'user' ||    entry.type === 'assistant' ||    entry.type === 'attachment' ||    entry.type === 'system'  )}export function isChainParticipant(m: Pick<Message, 'type'>): boolean {  return m.type !== 'progress'}

第二步,是加载旧 transcript 时不能只过滤 progress,还要桥接旧链:

const progressBridge = newMap<UUIDUUID | null>()...if (entry.parentUuid && progressBridge.has(entry.parentUuid)) {  entry.parentUuid = progressBridge.get(entry.parentUuid) ?? null}

第三步,是 snip 之后恢复时必须重放“删中段并 relink”:

functionapplySnipRemovals(messages: Map<UUID, TranscriptMessage>): void {  ...  messages.set(uuid, { ...msg, parentUuidresolve(msg.parentUuid) })}

第四步,是并行 tool_use 把拓扑从链退化成 DAG,读侧必须恢复 orphaned sibling assistant 与 tool_result

returnrecoverOrphanedParallelToolResults(messages, transcript, seen)

还有一条非常硬的恢复约束:大 transcript 的反向扫描依赖 parentUuid 作为序列化后的首键。这说明序列化对象键序本身都进入了恢复协议。

源码甚至把这个假设单独写成 invariant:

 *   1.Transcript messages always serialize with parentUuid as the first key. *      JSON.stringify emits keys in insertion order and recordTranscript's *      object literal puts parentUuid first. So `{"parentUuid":` is a stable *      line prefix that distinguishes transcript messages from metadata.

这背后的含义很重:恢复路径并不是“把 JSONL 全读出来再慢慢整理”,而是先靠 append-only 与前缀扫描找到参与链的候选,再走 parentUuid 逆向回溯。所以 progress bridge、snip relink、parallel tool result orphan recovery 这些补丁,都不是读盘装饰,而是链式恢复的必要条件。

6. conversationRecovery.ts 的目标不是反序列化,而是重回 API 合法状态

从 transcript 读出消息之后,还要再过一轮 conversationRecovery.ts

const filteredToolUses = filterUnresolvedToolUses(migratedMessages)const filteredThinking =filterOrphanedThinkingOnlyMessages(filteredToolUses)const filteredMessages =filterWhitespaceOnlyAssistantMessages(filteredThinking)

这一步不是“忠实读盘”,而是把已经无法继续送 API 的残片先剥掉。

中断恢复更进一步:

createUserMessage({content'Continue from where you left off.',isMetatrue,})

interrupted turn 会被归一化成 synthetic continuation。如果最后一条有效消息是 user,还会补 synthetic assistant sentinel,确保会话在不立刻恢复时依然 API 合法。

这个 sentinel 不是抽象概念,而是真正插回消息数组:

filteredMessages.splice(  lastRelevantIdx + 1,0,createAssistantMessage({contentNO_RESPONSE_REQUESTED,  }) asNormalizedMessage,)

这说明恢复目标不是“把文件原样吐出来”,而是“把系统重新放回可继续运转的状态”。

分篇五:任务、子代理与远程执行

1. task framework 是异步执行底座,不是 UI 面板配套

读源码时很容易把 task system 低估成了 UI 层配套。实际上 utils/task/framework.ts 已经是 runtime substrate。

Task.ts 先定义了统一的任务类型与状态:

exporttypeTaskType =  | 'local_bash'  | 'local_agent'  | 'remote_agent'  | 'in_process_teammate'  | 'local_workflow'  | 'monitor_mcp'  | 'dream'

framework.ts 再提供统一的任务注册、替换、SDK 事件、增量输出、终态 GC:

exportfunction registerTask(task: TaskState, setAppState: SetAppState): void {  let isReplacement = false  setAppState(prev => {const existing = prev.tasks[task.id]    isReplacement = existing !== undefinedconst merged =      existing && 'retain' in existing        ? {            ...task,retain: existing.retain,startTime: existing.startTime,messages: existing.messages,diskLoaded: existing.diskLoaded,pendingMessages: existing.pendingMessages,          }        : taskreturn { ...prev, tasks: { ...prev.tasks, [task.id]: merged } }  })  if (isReplacement) return  enqueueSdkEvent({type'system',subtype'task_started',task_id: task.id,    ...  })}

这层真正管理的是:

  • 异步执行体的 identity
  • 它的 output file
  • SDK task_started / terminated bookend
  • retain / evict / GC
  • 增量 output attachment

所以它不是“后台面板实现细节”,而是 agent runtime 里承载异步工作的主底座之一。

这里最有生产味道的一点,是 re-register 不会把 UI 态和未落盘态洗掉。resumeAgentBackground 替换 task 时,用户刚 append 的 prompt、当前 detail panel 的 retain、pending teammate messages 都会被带过去。

这类细节如果没有 task substrate 统一兜住,就只能在每个任务类型里各自打补丁。

2. coordinator mode 不是 prompt 变体,而是消息协议变体

coordinatorMode.ts 最大的架构意义,不在 prompt 更长,而在它重新定义了 worker 完成消息怎样进入主会话:

<task-notification><task-id>{agentId}</task-id><status>completed|failed|killed</status><summary>{human-readable status summary}</summary><result>{agent's final text response}</result></task-notification>

源码明确规定:

  • worker 结果以 user-role 消息送回 coordinator
  • coordinator 不能把它当 conversation partner
  • 每个 <task-notification> 都是独立 turn 之间插入的外部信号

这使 coordinator mode 不再只是“一个更善于 delegation 的 prompt”,而是 turn topology 本身变了。主会话已经不再只接收用户输入,也接收任务系统注入的外部 completion event。

源码甚至明确提醒 coordinator:这些看起来像 user message,但不是普通 user turn:

Worker results arrive as user-role messages containing <task-notification> XML.They look like user messages but are not.

再加上 prompt 里规定的 phase 切分,coordinator runtime 的真实形状就出来了:

  • 研究阶段可以并行起 worker
  • 综合判断必须回到 coordinator
  • 实现和验证再下发
  • worker 完成后以 <task-notification> 作为外部事件回灌

所以 coordinator mode 实际上是 “message protocol + task substrate + prompt law” 三件事一起成立。

3. 本地子代理已经是 actor,不是函数调用

fork 子代理最重要的不是分叉,而是 cache-safe prefix 复制。forkSubagent.ts 复制完整 assistant message,再补 placeholder tool_result,不是为了语义自然,而是为了尽量复用父线程前缀缓存。

相关实现几乎把意图写在注释里:

exportfunction buildForkedMessages(  directive: string,  assistantMessage: AssistantMessage,): MessageType[] {  const fullAssistantMessageAssistantMessage = {    ...assistantMessage,uuidrandomUUID(),message: {      ...assistantMessage.message,content: [...assistantMessage.message.content],    },  }  const toolResultBlocks = toolUseBlocks.map(block => ({type'tool_result'asconst,tool_use_id: block.id,content: [{ type'text'asconsttextFORK_PLACEHOLDER_RESULT }],  }))  const toolResultMessage = createUserMessage({content: [...toolResultBlocks, { type'text'asconsttextbuildChildMessage(directive) }],  })  return [fullAssistantMessage, toolResultMessage]}

关键不是“子代理拿到了上文”,而是:

  • 保留 parent assistant 的全部 thinking / text / tool_use
  • 用统一 placeholder 补全所有 tool_result
  • 只让最后一段 directive 文本随 child 变化

这正是 cache-friendly fork 的典型写法:最大化共享前缀,最小化分叉尾部。

createSubagentContext() 也不是简单 clone,而是一张共享矩阵:

  • abort 默认隔离
  • AppState 默认 no-op/只读
  • permission prompt 默认规避
  • replacement state 默认克隆
  • task registration 仍回根 store

这表明子代理在这里不是普通函数调用,而是带隔离边界的执行体。

其中最重要的一行是 content replacement state 的继承策略:

contentReplacementState:  overrides?.contentReplacementState ??  (parentContext.contentReplacementState    ? cloneContentReplacementState(parentContext.contentReplacementState)    : undefined),

这里默认 clone 而不是 fresh state,不是历史包袱,而是缓存语义要求。fork child 会继续处理 parent 已经出现过的 tool_use_id;如果 replacement 决策重新洗牌,wire prefix 就变了,cache hit 也跟着消失。

真正让子代理获得 operational continuity 的,不只是 fork 输入,还有 sidechain transcript。runAgent.ts 在 query loop 开始前就先把初始消息和 agent metadata 异步写盘:

// Record initial messages before the query loop starts, plus the agentType// so resume can route correctly when subagent_type is omitted.voidrecordSidechainTranscript(initialMessages, agentId).catch(...)voidwriteAgentMetadata(agentId, {agentType: agentDefinition.agentType,  ...(worktreePath && { worktreePath }),  ...(description && { description }),}).catch(...)letlastRecordedUuidUUID | null = initialMessages.at(-1)?.uuid ?? null

随后每条 recordable message 都按正确 parent 继续接进 sidechain:

if (isRecordableMessage(message)) {awaitrecordSidechainTranscript(    [message],    agentId,    lastRecordedUuid,  ).catch(...)if (message.type !== 'progress') {    lastRecordedUuid = message.uuid  }yield message}

这里不是简单“把子代理输出写日志”,而是在维护:

  • sidechain 独立 transcript
  • 正确的 parentUuid 连续性
  • agentType / worktreePath / description 等 resume 路由信息

所以 sidechain transcript 是子代理可恢复性的骨架,不是 debug artifact。

还有一条很容易漏掉的宿主语义:不是所有子代理都默认保留 toolUseResult

只有带可查看 transcript 的子代理才会显式打开这项能力:

// Preserve tool use results for subagents with viewable transcriptsif (preserveToolUseResults) {  agentToolUseContext.preserveToolUseResults = true}

这说明 transcript 保真度在这里是按可恢复/可查看需求分层的。临时 explore 型子代理可以省掉部分 result 负担;需要被单独查看、回放、恢复的执行体,则必须把 toolUseResult 留住。

4. mailbox 是 swarm 的真实总线

teammate 协作并不靠 shared transcript,而靠 mailbox 协议。

permissionSync.ts 直接把流程写明:

 * Workers send permission requests to the leader's mailbox * Leaders send permission responses to the worker's mailbox

teammatePromptAddendum.ts 又把这件事写进 prompt 规约:

Just writing a response in text is not visible to others on your team - you MUST use the SendMessage tool.

这意味着 SendMessage 不是协作糖衣,而是显式 RPC。纯文本回复只存在于本 agent 本地轨迹里,不会天然变成团队共享状态。

LocalAgentTask.tsx 更进一步把这条总线和 runtime 边界绑在一起:teammate 消息不是随发随达,而是先进入 pendingMessages,在 tool-round 边界 drain,再并入下一轮 API 输入。

具体实现也非常直接:

exportfunction queuePendingMessage(taskId: string, msg: string, setAppState: ...): void {  updateTaskState(taskId, setAppState, task => ({    ...task,pendingMessages: [...task.pendingMessages, msg]  }))}export function drainPendingMessages(taskId: string, getAppState: ..., setAppState: ...): string[] {  const task = getAppState().tasks[taskId]  if (!isLocalAgentTask(task) || task.pendingMessages.length === 0) {return []  }  const drained = task.pendingMessages  updateTaskState(taskId, setAppState, t => ({ ...t, pendingMessages: [] }))  return drained}

这意味着 teammate message 的投递边界是“下一轮工具回合开始前”,不是任意时刻中断当前推理。这是一种非常务实的 actor mailbox 语义:牺牲即时性,换取 turn 内部状态不被异步注入打穿。

更关键的是,in-process teammate 和外部 transport teammate 在模型看到的通信表面上被强制对齐成同一协议。inProcessRunner.ts 会把消息包装成统一的 XML:

functionformatAsTeammateMessage(fromstring,  content: string,  color?: string,  summary?: string,): string {const colorAttr = color ? ` color="${color}"` : ''const summaryAttr = summary ? ` summary="${summary}"` : ''return`<${TEAMMATE_MESSAGE_TAG} teammate_id="${from}"${colorAttr}${summaryAttr}>\n${content}\n</${TEAMMATE_MESSAGE_TAG}>`}

这一步的含义非常大:无论 teammate 实际跑在同进程、tmux、还是其他宿主里,模型侧看到的协作协议都尽量保持同构。这样 prompt 规约、消息解析、权限同步、回放逻辑就不需要各写一套方言。

5. inProcessRunner.ts 不是子调用包装,而是本地 actor loop

inProcessRunner.ts 的重要性,不在“能起一个 teammate”,而在它自己拥有:

  • 独立 allMessages
  • actor 级别 compaction
  • replacement state reset
  • abort 生命周期管理
  • mailbox message wait loop

换句话说,in-process teammate 和普通本地函数已经不属于同一种执行模型。

最关键的 while-loop 里,既在维护 actor 自己的 allMessages,也在维护自己的缓存连续性:

let teammateReplacementState = toolUseContext.contentReplacementState  ? createContentReplacementState()  : undefinedwhile (!abortController.signal.aborted && !shouldExit) {  const userMessage = createUserMessage({ content: currentPrompt })  let contextMessages = allMessages  const tokenCount = tokenCountWithEstimation(allMessages)  if (tokenCount > getAutoCompactThreshold(toolUseContext.options.mainLoopModel)) {const compactedSummary = await compactConversation(...)    contextMessages = buildPostCompactMessages(compactedSummary)resetMicrocompactState()if (teammateReplacementState) {      teammateReplacementState = createContentReplacementState()    }    allMessages.length = 0    allMessages.push(...contextMessages)  }}

注意这里 reset 的不是一个东西,而是两套状态:

  • microcompact 状态,因为 full compact 后旧 tool_use_id 已经不在新消息里
  • teammateReplacementState,因为 replacement 绑定的是“当前仍在 actor 历史里的候选结果”

这正说明 in-process teammate 已经是完整 actor loop,而不是“帮主线程再调一次 query”。

6. RemoteAgentTask 说明执行底座已经跨到云端

RemoteAgentTask 不是产品边缘功能,而是 runtime 把执行底座扩展到远端 session 的完整路径。

注册远程任务时,它会:

  1. 初始化 output file
  2. 注册 remote_agent task state
  3. 把远程 session identity 持久化到 sidecar
  4. 启动 poller
consttaskStateRemoteAgentTaskState = {  ...createTaskStateBase(taskId, 'remote_agent', session.title, toolUseId),  type'remote_agent',  remoteTaskType,  status'running',  sessionId: session.id,  command,  title: session.title,  ...}registerTask(taskState, context.setAppState)void persistRemoteAgentMetadata({  taskId,  remoteTaskType,  sessionId: session.id,  title: session.title,  command,  spawnedAtDate.now(),  ...})

恢复时:

const persisted = await listRemoteAgentMetadata()for (const meta of persisted) {  try {const session = await fetchSession(meta.sessionId)    remoteStatus = session.session_status  } catch (e) {if (e instanceof Error && e.message.startsWith('Session not found:')) {void removeRemoteAgentMetadata(meta.taskId)    }continue  }  if (remoteStatus === 'archived') {void removeRemoteAgentMetadata(meta.taskId)continue  }  registerTask(taskState, context.setAppState)  startRemoteSessionPolling(meta.taskId, context)}

终止时:

if (killed) {emitTaskTerminatedSdk(taskId, 'stopped', { toolUseId, summary: description })if (sessionId) {voidarchiveRemoteSession(sessionId).catch(...)  }}

这条路径说明 durable execution boundary 已经不再局限于本地 transcript。而且恢复逻辑把 404 和 recoverable auth/network error 区分开了:前者说明远端 session 真没了,应删除 sidecar;后者说明远端可能还活着,只是当前客户端暂时接不上,不应贸然丢失本地跟踪。

这比“远程跑一个任务,定时 poll 一下”要厚得多。系统维护的对象已经扩展成“本地 session + 本地 task registry + 远程 session handle” 的组合。

收束:结构债、可迁移原则

1. 这份代码最值得继承的,不是模块拆分,而是几个硬约束

真正值得进入未来 agent runtime 设计文档的,是下面这些约束:

  • tool_use -> tool_result 必须闭合
  • thinking trajectory 不能被任意切断
  • once seen, fate frozen
  • 能力面必须按时机、路径、模式渐进展开
  • 连续性必须外化成多种 durable artifact
  • 策略系统必须独立于 tool handler
  • 异步执行体必须有统一 task substrate

这几条比任何一个具体 API 都更重要。

2. 结构债不在“功能太多”,而在 owner 模糊

这份 runtime 的主要问题,不是 feature 太多,而是若干一等约束没有单独 owner:

  • query.ts 已经是 God Loop
  • attachments.ts 变成第二调度器
  • ToolUseContext 过胖
  • cache invariants 散落在多文件
  • continuity surface 太多,但没有统一 taxonomy
  • permissions / tasks / remote execution 已经上升成控制面,却还分散在多个模块里

一旦 owner 不清晰,后续演化就只能继续靠补偿机制维持。

3. 这份源码里最亮眼的部分,常常首先是疤痕

最能打动人的地方往往也是最危险的地方。像 progressBridge、snip relink、orphaned permission replay、synthetic continuation、remote resume reconnect,这些都极具工程含量,但它们首先证明的是系统曾经在边界上破过一次。

它们当然重要,但更适合作为:

  • 边界识别器
  • 约束提炼器
  • 重构切口提示器

而不宜直接抄成下一代系统的默认结构。

4. 更稳的下一代形态,会把这些控制面显式拆开

从这份代码可以直接反推出一套更清晰的未来形态:

  • ConversationHost
  • TurnEngine
  • CapabilityManager
  • PermissionControlPlane
  • ContextManager
  • CacheManager
  • TaskRuntime
  • MemoryRuntime

眼下这份代码已经在事实层面拥有这些层,只是它们还没有完全在模块边界上显形。

5. 这份源码真正稀缺的价值,在它已经开始为“长时间工作”付账

它和大量只够支撑 demo 的 agent 框架之间,真正的差别不在工具数、模型数、UI 丰富度,而在这些已经付掉的账:

  • 工具结果太大时,不是简单截断,而是冻结命运、持久化 replacement、跨 resume 重放
  • 子代理恢复时,不是只有 transcript,而是补 content replacement gap
  • 工具太多时,不是平铺 schema,而是延迟发现与渐进暴露
  • 长会话 memory 不是单一摘要,而是 log / index / session card / transcript 并存
  • 错误恢复不是单纯 retry,而是保护轨迹拓扑与宿主观察语义
  • 异步工作不是“后台线程”,而是有 task identity、notification、resume、GC、remote handle 的执行底座

这恰好构成了这份材料最值得继承的部分:它已经不再假设“模型足够聪明就会自然收束”,而是在外层持续维护结构不变量。这也是生产级 agent runtime 和 demo agent loop 的真正分界线。

6. 还有一条不该忽略的控制面:可观测性

对照源码再看,文章还有一条之前没有单列、但对生产 agent 非常关键的架构层:observability

queryProfiler.ts 不是简单计时器,而是在把 query pipeline 拆成可诊断阶段:

 * - query_context_loading_start/end * - query_microcompact_start/end * - query_autocompact_start/end * - query_tool_schema_build_start/end * - query_api_request_sent * - query_first_chunk_received * - query_tool_execution_start/end

这意味着系统不满足于“这轮慢了”,而是要知道慢在:

  • system/context 装配
  • microcompact / autocompact
  • tool schema 构造
  • API 往返
  • 工具执行

promptCacheBreakDetection.ts 又在另一层回答“为什么这轮缓存失效了”:

parts.push(`system prompt changed${charInfo}`)parts.push(`tools changed${toolDiff}`)parts.push('fast mode toggled')parts.push(`global cache strategy changed (...)`)

这类诊断在 agent runtime 里非常少见,但非常必要。只要 prompt、tools、betas、cache scope 任一层发生漂移,性能与成本都会发生非线性变化;如果系统不能指出 root cause,开发者就只能凭感觉调。

analyzeContext.ts 则把“上下文到底被谁吃掉了”做成了可计算问题。它不是粗算 message token,而是重建 effective system prompt,再分别统计:

  • system prompt sections
  • CLAUDE.md / memory 文件
  • built-in tools / deferred tools / MCP tools
  • agent definitions
  • slash commands
  • message breakdown

对 agent 开发者来说,这一层的启发非常直接:

  • 没有可观测性,就没有可演化的 harness
  • 要能解释慢在哪、贵在哪、cache 为什么断、上下文被谁吃掉
  • 主 loop、prompt 栈、tool surface、memory surface 都应该有自己的诊断面

一旦把这层也加回整体蓝图,agent runtime 的轮廓就更完整了:它不只是一个会跑的系统,还是一个能解释自己为何这样跑、何时开始跑坏、坏在何处的系统。

7. 工程化收尾:把状态显式化,尽量少靠 vibe coding

如果把这份源码当成下一代 runtime 的反向需求文档,最直接的一条工程建议就是:不要再让关键流转主要靠布尔变量、注释、约定和“开发者脑内状态机”维持。

query.ts 现在已经事实上是一台状态机,只是它的状态分散在:

  • transition.reason
  • stopHookActive
  • pendingToolUseSummary
  • hasAttemptedReactiveCompact
  • maxOutputTokensRecoveryCount
  • 外围 attachment / permission / task queue 副作用

这种形态还能跑,但很难局部验证,也很难安全演化。对下一代实现来说,最值得认真考虑的是把高风险流转改成显式状态图,比如用 xstate[1] 这类状态机/状态图框架,或至少采用同类建模方法。

最适合状态机化的,不是全系统,而是下面几条最复杂、最容易出错的流:

  1. turn engine
    • 把 sampling -> tool mediation -> continuation -> compact retry -> recovery exhausted -> completed 画成显式 machine。
    • prompt_too_longmax_output_tokens、stop hook、fallback、permission wait 都应该是事件,而不是 scattered branches。
  2. permission flow
    • requested -> classifier_precheck -> waiting_user -> approved/denied -> replay_tool_use -> continue
    • 现在 orphaned permission replay 之所以显得“巧”,本质是状态所有权不清。状态机化之后,这条 replay 会自然变成一条合法迁移,而不是异常补丁。
  3. task / remote-agent lifecycle
    • created -> running -> idle-but-not-finished -> completed | failed | killed | archived
    • 远程 session 的 404 / recoverable auth failure / archived 差异很适合放到 machine guards 里,而不是散落在 poller 分支里。
  4. mailbox / teammate delivery
    • queued -> drained -> injected -> acknowledged
    • 现在 pendingMessages 已经在做这件事,但语义还埋在 task helper 里。显式化之后,协作协议会更容易测试和可视化。

这里不是说 “用了 XState 就会自动变好”,而是说这类 runtime 已经到了该用显式状态图取代隐式流程拼接的阶段。是否选 XState 可以讨论,但“把主循环、权限恢复、任务生命周期、协作协议都建成可视化、可穷举、可测试的状态图”这件事本身,已经不太像可选优化,而更像必要的工程升级。

如果真要落地,比较稳的方式不是一次性把全系统迁进去,而是:

  • 先从 query 的恢复图抽 machine
  • 再把 permission replay 和 remote task poller 抽出来
  • 让旧逻辑做 invoked services,新 machine 先只接管状态与迁移
  • 配套补 event log、snapshot tests、failure matrix

这样可以逐步把 God Loop 切开,而不是做一次高风险重写。

另一条同样重要的工程建议,是尽量避免 agent/runtime 开发里的 vibe coding 通病。这类系统最容易出现的坏味道,不是代码丑,而是 “局部补丁持续成功,于是全局结构悄悄失控”

最常见的几种通病大概是:

  1. 把补丁误当架构:一次 orphaned replay、一次 synthetic sentinel、一次 attachment 注入都能修问题,于是系统最后变成补丁编排器。解决方式不是禁止补丁,而是每补一次就追问:这在修哪条不变量?owner 是谁?以后该收敛到哪个控制面?
  2. 把字符串 prompt 当自由文本,而不是协议:一句 “IMPORTANT:” 看起来加得很快,但久而久之 system prompt、attachment prompt、tool prompt、classifier prompt 会互相打架。解决方式是把 prompt section registry、cache boundary、priority order、attachment channel 明文化,并做 prompt diff 测试。
  3. 把共享上下文当万能胶AppState 和 ToolUseContext 一旦什么都能放,就会什么都想放。解决方式是区分只读查询、事件提交、状态所有权,不要让所有子系统都能直接 mutate 同一片状态。
  4. 只验证 happy path,不验证恢复图:生产 agent 最难的不是首轮成功,而是中断、拒绝、续写、compact、resume、fork、remote reconnect。解决方式是把 failure matrix 当一等测试对象,而不是只测“普通提问 -> 普通回答”。
  5. 只追求功能上线,不追踪 cache / token / latency 退化:agent 系统很容易功能越来越强,但每轮越来越贵、越来越慢、越来越不稳定。解决方式是把 prompt cache、context usage、tool schema size、TTFT、恢复次数都当成 release 指标,而不是 debug 时临时看看。

可以把这几条压成一句更硬的工程纪律:agent runtime 不适合靠“先写起来,后面再收”推进;任何会跨 turn、生存于恢复路径、影响 cache 或影响权限边界的逻辑,都应该先有状态模型、约束说明和诊断面,再谈实现细节。

这也是这份源码给人的最终启发。真正成熟的 agent 工程,不是功能堆得多快,而是是否能持续把“聪明行为”压回到可推理、可恢复、可测、可观测的结构里。

要是做不到这一点,系统迟早会从 agent runtime 退化成一团勉强还能工作的 prompt middleware。

References

[1]

xstate:https://github.com/statelyai/xstate