乐于分享
好东西不私藏

Claude Code 源码深度解析之 Harness Engineering 下篇:如何治理工具、权限、恢复与长会话

Claude Code 源码深度解析之 Harness Engineering 下篇:如何治理工具、权限、恢复与长会话

上篇已经把主运行链梳理清楚了:用户输入先经过预处理和会话装配,再进入 queryLoop 这个持续推进的执行内核。

下篇接着看另一半:当系统已经进入 queryLoop 之后,Claude Code 是怎么让这条循环在真实任务里持续、稳定、可控地跑下去的?

如果上篇更像在讲“发动机怎么点火”,那下篇更像在讲“传动、制动、治理和容错系统是怎么接进来的”。

系列回顾:

先补几个下篇会频繁出现的术语

  • 权限检查:它是一条动态决策链,每次工具执行前,都可能重新进入这条链。
  • hook:可以把它看成“在某个关键时机插进来的扩展点”。Claude Code 会在输入提交、turn 结束、失败恢复等时机执行不同的 hooks。
  • stop hooks:在一轮回复接近结束时参与治理的钩子。它们可以阻止继续、插入消息,或触发额外的会话逻辑。
  • budget:这里主要指 token budget 或工具预算。它会参与 loop 的状态转移,不只是一个简单的停止阈值。
  • fallback:主路径失败时的替代执行方式,比如 model fallback、streaming fallback。它的作用是让系统降级后还能继续往前跑。
  • compact:长会话里的上下文收缩机制。它会压缩上下文,帮系统在上下文成本受限时继续工作。
  • MCP:可以先把它看成 Claude Code 接入外部工具和资源的一层协议化通道,MCP 客户端和资源会通过运行时上下文接进来。
  • contextModifier:某些工具除了返回 tool_result,还会改写后续上下文。它描述的就是这种“工具执行会影响下一轮输入”的能力。

ToolUseContext:Claude Code 的运行时容器

下篇先从 ToolUseContext 开始,因为后面的权限判断、工具执行和恢复逻辑,几乎都默认这层运行时容器已经在场。

ToolUseContext 在 src/Tool.ts 里定义得非常重。

如果你之前没接触过这类系统,很容易把工具调用想成“模型说一句,系统执行一个函数”。可在 Claude Code 里,工具运行要读文件、访问会话状态、看权限、接 MCP 客户端、响应中断,甚至还得知道预算和 UI 状态。所以 ToolUseContext 更接近一个小型运行时容器,不是轻量参数对象。

来看它的实际定义(节选):

// src/Tool.ts
exporttype ToolUseContext = {
  options: {
    commands: Command[]
    tools: Tools
    mcpClients: MCPServerConnection[]
    mcpResources: Record<string, ServerResource[]>
    agentDefinitions: AgentDefinitionsResult
    maxBudgetUsd?: number
    customSystemPrompt?: string
    appendSystemPrompt?: string
    refreshTools?: () => Tools
// ... 还有 debug, verbose, thinkingConfig 等
  }
  abortController: AbortController
  readFileState: FileStateCache
  getAppState(): AppState
  setAppState(f: (prev: AppState) => AppState): void
// ... 总计 20+ 字段
}

commands、tools、MCP 客户端、文件读取缓存、AppState、预算、任务等能力,都会从这里进入工具执行过程。

从这个设计也能看出来:Claude Code 的工具运行依赖一整套 runtime,不是一个孤立的调用环境。

权限不是装配阶段的一次性校验

Claude Code 确实会在会话装配时把权限上下文塞进 ToolUseContext,但真正的权限判断发生在每次工具执行时,是一条动态决策链。

真实链路更接近这样:

query.ts
  -> runTools / StreamingToolExecutor
  -> runToolUse
  -> canUseTool
  -> hasPermissionsToUseTool
  -> allow / ask / deny

这条权限管线里,Claude Code 会综合很多因素:

  • 规则层:整个工具是否 always allow / always ask / deny。
  • 工具自身检查:例如 bash 子命令规则、路径安全检查、是否需要用户交互。
  • 运行模式defaultplanbypassPermissions、headless agent 等模式会改变决策行为。
  • 交互方式:REPL 会弹权限确认,headless/async agent 则可能走 hooks 或直接拒绝。

这背后有一个很重要的工程含义:权限配置不会在启动时一次定死,运行时的每个 action 都要重新经过这条控制回路。 在很多简化版 agent 里,大家会把“工具能不能用”理解成启动时的一个布尔开关;放到 Claude Code 里,它更接近一条实时的 policy pipeline。

工具执行有两条路径:批处理编排与流式执行器

如果把所有工具执行都理解成 toolOrchestration.ts 里的批处理,会漏掉 Claude Code 很重要的一半。

实际上它有两条路径:

  • 批处理路径:模型已经完整输出本轮 tool_use,这时由 runTools() 按并发安全性分批执行。
  • 流式路径:模型还在 streaming,StreamingToolExecutor 就已经开始接收并调度 tool_use

这个分支在 src/query.ts 里写得很直接:先看是否开启 streamingToolExecution,后面再决定最终消费 StreamingToolExecutor 还是 runTools()

// src/query.ts
const useStreamingToolExecution = config.gates.streamingToolExecution
let streamingToolExecutor = useStreamingToolExecution
  ? new StreamingToolExecutor(
      toolUseContext.options.tools,
      canUseTool,
      toolUseContext,
    )
  : undefined

const toolUpdates = streamingToolExecutor
  ? streamingToolExecutor.getRemainingResults()
  : runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)

这里的“流式”不只是 UI 上一边打字一边显示文字。更关键的是,模型输出还没完整结束,系统就已经开始接收结构化工具调用请求,并在线调度执行了。

runTools() 的核心思路仍然很值得看。它不会把所有工具直接扔进 Promise.all(),而是先按 isConcurrencySafe 分批:

// src/services/tools/toolOrchestration.ts
functionpartitionToolCalls(
  toolUseMessages: ToolUseBlock[],
  toolUseContext: ToolUseContext,
): Batch[] 
{
return toolUseMessages.reduce((acc: Batch[], toolUse) => {
const tool = findToolByName(toolUseContext.options.tools, toolUse.name)
const parsedInput = tool?.inputSchema.safeParse(toolUse.input)
const isConcurrencySafe = parsedInput?.success
      ? (() => {
try {
returnBoolean(tool?.isConcurrencySafe(parsedInput.data))
          } catch {
returnfalse
          }
        }
)()
      : false
if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) {
acc[acc.length - 1]!.blocks.push(toolUse)
    } else {
acc.push({ isConcurrencySafe, blocks: [toolUse] })
    }
returnacc
  }, [])
}

它背后的工程判断是:Claude Code 优先保证状态确定性,然后才在这个前提下尽量并行。

StreamingToolExecutor 处理的是另一类问题:工具请求会边流出边到达,系统必须同时保持输出顺序、处理并发、响应中断,并在 fallback 时丢弃无效结果。与其把它看成普通的“函数调用”,不如把它看成一个在线执行器。

再往前看一步,工具执行不仅会产出结果,还会通过 contextModifier 改写后续上下文。工具会直接参与 loop 的状态推进,本身就是主循环里的一个组成部分。

Stop hooks:turn 结束前的治理层

只讲模型采样和工具调用,还不足以把 Claude Code 的 harness 讲完整。query.ts 在 assistant 输出结束、准备判断“这一轮是不是该停下”时,还会进入 handleStopHooks()

这层逻辑很重要,因为它说明 Claude Code 的 runtime 里还有一层治理机制

  • hook 可以产出阻塞错误,把新消息重新塞回 loop。
  • hook 可以明确阻止 continuation,让这一轮在工具执行之后停止。
  • hook 还能触发 teammate idle、task completed 之类的后续治理逻辑。

这里也可以顺手解释一下“hook”这个词。它不是 Claude Code 独有概念,你可以把它看成“在某个关键时机插进来的扩展点”。stop hooks 就是在一轮即将结束时插进来的治理逻辑。

但这里还有一个很关键的边界:正常的 stop hooks 只会在模型真正产出了可评估的 assistant 响应后执行。

如果这一轮走到的是 API error、prompt-too-long、media error 之类的失败路径,query.ts 会刻意跳过正常 stop hooks,只执行 failure hooks 或直接返回。这样做是为了避免把错误消息再交给 stop hooks 处理,形成“错误 -> hook 阻断 -> 重试 -> 再错误”的 death spiral。

Claude Code 的回合结束,并不等于“模型没再调用工具就自然结束”。后面还有一层 policy 检查;不过这层治理只在有效响应路径上运行,不会无差别覆盖所有失败分支。

为什么 budget、compact、retry、cancel 都属于 harness

很多人会把这些东西看成优化项,但在 Claude Code 里,它们都会直接影响 runtime 能不能稳定继续跑。

在 queryLoop 里,至少有几组非常核心的控制逻辑:

  • 上下文收缩:tool result budget、snip、microcompact、context collapse、autocompact。
  • 错误恢复:prompt-too-long 会先尝试 collapse drain,再尝试 reactive compact;max_output_tokens 会先尝试放宽上限,再尝试插入 meta 恢复消息继续当前思路;model fallback 和 streaming fallback 还会重建当前尝试的执行状态。
  • 中断与取消:用户 abort、工具报错、兄弟工具取消、streaming fallback discard,都必须保证 tool_use 不会留下悬空状态。
  • 继续与终止判定maxTurns、token budget、stop hooks、abort reason、是否还需要 follow-up,共同决定是结束、阻断,还是继续下一轮。

其中 token budget 这一层,也不能只理解成“超了就停”。在某些路径下,Claude Code 会根据当前 turn 的 token 消耗,主动插入一条 isMeta 提示消息,要求模型缩小后续工作粒度并继续当前任务。budget 在这里也参与了 loop 的转移逻辑,不只是一个被动阈值。

其中“中断一致性”尤其能体现 harness engineering 的含量。Claude Code 不会简单 abort() 一下就结束;它会尽量补齐 synthetic tool_result、处理 interruptBehavior、在 streaming fallback 时 tombstone 旧消息,并丢弃已经失效的工具结果。这样做的目标很明确:无论回合怎样被打断,transcript 和后续状态都要保持可恢复、可继续。

transcript 与 session state 也是 harness

Claude Code 还做了大量会话级持久化,包括 transcript 记录、compact boundary、session resume,以及消息的归档与重放。

如果你之前主要接触的是简单聊天 API,这里最容易低估的一点是:一旦系统要支持长会话、恢复、压缩、工具执行和中断继续,状态管理本身就会变成 runtime 设计的一部分。

不过比起只看一个超大的全局状态对象,更值得把它理解成三层状态:

  • 会话层状态QueryEngine 持有 mutableMessages、usage、permission denials、read file cache 等跨 turn 信息。
  • 回合层状态query.ts 的 State 驱动当前 loop 如何继续、恢复或终止。
  • 工具运行时状态ToolUseContext 和 AppState 把权限、UI、MCP、文件缓存、abort signal 等能力接进工具调用。

正是这三层配合,Claude Code 才能把一次次 assistant/tool 交互串成一个长生命周期会话,而不至于沦为一堆互不关联的 API 调用。

harness engineering 的真实价值

它回答的是一个比“模型怎么回答”更重要的问题:

如何让模型在复杂任务里持续、稳定、可控地工作。

Claude Code 给出的答案,并不靠某一个神奇 prompt。它把 runtime 拆成了几层清晰的工程机制:入口层负责接入,会话层负责装配,queryLoop 负责推进,工具执行器负责 action,权限与 hooks 负责治理,compact/recovery/transcript 负责把长会话托住。

源码锚点

建议重点看:

  • src/query.ts
  • src/Tool.ts
  • src/services/tools/toolOrchestration.ts
  • src/services/tools/StreamingToolExecutor.ts
  • src/query/stopHooks.ts
  • src/utils/permissions/permissions.ts
  • src/hooks/useCanUseTool.tsx
  • src/utils/sessionStorage.ts

结语

Claude Code 值得学习的地方,除了 agent loop,还有它如何把 agent loop 变成一个真正可运行的系统。

如果把 agent loop 看成发动机,harness 就更接近整套传动、制动、仪表和容错系统。它真正决定的是这个系统能不能连续跑完复杂任务而不散架。

动手练习

  1. 打开 src/query.ts,从 queryLoop 开始,顺着一次迭代看完整的 request、tool、recovery、termination 流程。
  2. 打开 src/hooks/useCanUseTool.tsx 和 src/utils/permissions/permissions.ts,把一次工具调用的 allow / ask / deny 决策链跟一遍。
  3. 打开 src/query/stopHooks.ts 和 src/services/tools/StreamingToolExecutor.ts,看看 Claude Code 是怎么把“结束治理”和“流式执行”接进主循环的。