上篇已经把主运行链梳理清楚了:用户输入先经过预处理和会话装配,再进入 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 子命令规则、路径安全检查、是否需要用户交互。 运行模式: default、plan、bypassPermissions、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.tssrc/Tool.tssrc/services/tools/toolOrchestration.tssrc/services/tools/StreamingToolExecutor.tssrc/query/stopHooks.tssrc/utils/permissions/permissions.tssrc/hooks/useCanUseTool.tsxsrc/utils/sessionStorage.ts
结语
Claude Code 值得学习的地方,除了 agent loop,还有它如何把 agent loop 变成一个真正可运行的系统。
如果把 agent loop 看成发动机,harness 就更接近整套传动、制动、仪表和容错系统。它真正决定的是这个系统能不能连续跑完复杂任务而不散架。
动手练习
打开 src/query.ts,从queryLoop开始,顺着一次迭代看完整的 request、tool、recovery、termination 流程。打开 src/hooks/useCanUseTool.tsx和src/utils/permissions/permissions.ts,把一次工具调用的 allow / ask / deny 决策链跟一遍。打开 src/query/stopHooks.ts和src/services/tools/StreamingToolExecutor.ts,看看 Claude Code 是怎么把“结束治理”和“流式执行”接进主循环的。
夜雨聆风