Agent Loop 最小闭环图解

先从 queryLoop 的主循环建立心智模型,再回到正文阅读每个源码片段。

flowchart TD
  A[用户输入 messages] --> B[query params]
  B --> C[初始化 State]
  C --> D[构造 messagesForQuery]
  D --> E[调用 LLM]
  E --> F{assistant 是否包含 tool_use}
  F -->|否| G[输出最终回答]
  F -->|是| H[runTools 执行工具]
  H --> I[tool_result 回填 messages]
  I --> D
Phase 1 阅读路线图
State跨轮循环状态
messagesForQuery本轮模型可见上下文
tool_result工具执行结果回填

总览程序员视角的执行流程图

Agent Loop 主循环

query(params)接收完整参数包,不是单条 prompt
queryLoop()进入 while(true) 主循环
构造 messagesForQuery从 state.messages 取本轮模型输入
上下文处理budget / snip / microcompact / collapse / autocompact
callModel()流式调用模型并收集 assistant message
提取 tool_use有工具请求则进入工具层
runTools()本地 runtime 执行工具
更新 statetool_result 回填后继续下一轮

消息协议泳道图

User / Runtime
user: 原始需求user: tool_resultattachment: memory / command / skill
Assistant / Model
assistant: textassistant: tool_use(Read/Grep/...)assistant: final answer
Local Tools
canUseTool 权限判断runTools 执行normalizeMessagesForAPI

前言为什么要读这份源码

阅读 Claude Code 源码,并不是要把 50w+ 行代码全部消化掉。我们真正要学习的是它作为 AI Coding Agent 的核心思想:

  • 模型如何拿到上下文
  • 模型如何发起工具调用
  • 本地 runtime 如何执行工具
  • 工具结果如何回填给模型
  • 为什么 Agent 不是一次 API 调用,而是一个循环

这一阶段重点不放在前端 UI,也不放在终端渲染,而是放在模型编排、工具调用、消息结构、上下文处理这些 AI 应用核心机制上。

Phase 1Agent 最小闭环

学习目标

Phase 1 先只追求 Agent 最小闭环:

用户 messages
  |
  v
调用模型
  |
  v
assistant 返回 text 或 tool_use
  |
  +-- 如果是 text,结束
  |
  +-- 如果是 tool_use,本地执行工具
        |
        v
      把 tool_result 作为新 message 放回 messages
        |
        v
      继续下一轮模型调用

这条闭环是 Claude Code 最核心的 AI 编排逻辑。后面的权限系统、上下文压缩、subagent、hook、任务队列,本质上都是围绕这条主线增加工程能力。

本阶段核心源码

  • src/query.ts
  • src/services/tools/toolOrchestration.ts
  • src/utils/messages.ts
  • src/services/api/claude.ts

1源码地图:先知道 query.ts 里有什么

query.ts 的顶层结构

src/query.ts 的结构可以先这样记:

src/query.ts:129-168    yieldMissingToolResultBlocks() 辅助函数
src/query.ts:181-185    isWithheldMaxOutputTokens() 辅助函数
src/query.ts:187-205    QueryParams 类型
src/query.ts:210-223    State 类型
src/query.ts:225-245    query() 外层包装器
src/query.ts:247-1741   queryLoop() 主循环

也就是说,query.ts 的主体几乎全是 queryLoop()

阅读策略

不要逐行硬啃 queryLoop 的 1500 行。
先按 Agent Loop 主线分段读。
compact、fallback、hook、token budget 这些支线先知道位置和作用,后续再深挖。

2Phase 1 主线地图

query(params)
  |
  v
queryLoop(params)
  |
  v
State 初始化
  |
  v
while (true)
  |
  v
messagesForQuery = getMessagesAfterCompactBoundary(state.messages)
  |
  v
上下文处理:budget / snip / microcompact / collapse / autocompact
  |
  v
deps.callModel(messagesForQuery, tools, systemPrompt, ...)
  |
  v
assistantMessages.push(message)
  |
  v
从 assistant content 中提取 tool_use -> toolUseBlocks
  |
  +-- 没有 tool_use --> return completed
  |
  +-- 有 tool_use
        |
        v
      runTools(toolUseBlocks, ...)
        |
        v
      toolResults.push(user/tool_result)
        |
        v
      state.messages =
        messagesForQuery + assistantMessages + toolResults
        |
        v
      continue 下一轮

3入口层:从 query(params) 进入 Agent Loop

QueryParams:启动 Agent Loop 的参数包

位置:src/query.ts:187-205

export type QueryParams = {
  messages: Message[]
  systemPrompt: SystemPrompt
  userContext: { [k: string]: string }
  systemContext: { [k: string]: string }
  canUseTool: CanUseToolFn
  toolUseContext: ToolUseContext
  fallbackModel?: string
  querySource: QuerySource
  maxOutputTokensOverride?: number
  maxTurns?: number
  skipCacheWrite?: boolean
  taskBudget?: { total: number }
  deps?: QueryDeps
}

QueryParams 不是用户最新输入的一句话,而是启动一次 agent loop 需要的完整参数包。

最关键的三个字段

  • messages:当前对话历史,不是单条 prompt。
  • canUseTool:工具权限判断函数。模型只能请求工具,是否允许执行由本地 runtime 决定。
  • toolUseContext:工具执行上下文,包含工具列表、权限状态、abortController、agentId 等运行环境。

为什么不能只传用户一句话?

如果只是一次普通 API 调用,可能只需要:

std::string prompt = "帮我读一下 src/query.ts";

但 Agent Loop 不一样。它要继续工作时,还需要知道:

之前聊了什么?
当前 system prompt 是什么?
当前有哪些工具?
这些工具能不能用?
现在是什么权限模式?
当前是不是 subagent?
最多能跑几轮?
之前有没有 compact?

所以它更像:

QueryParams params = {
    .messages = 当前完整对话历史,
    .systemPrompt = 系统提示词,
    .userContext = 用户环境信息,
    .systemContext = 系统环境信息,
    .canUseTool = 工具权限判断函数,
    .toolUseContext = 工具运行环境,
    .querySource = 这次 query 来自哪里,
    .maxTurns = 最大循环轮数,
};
query(params);
一句话总结:QueryParams = 让 agent loop 能从当前状态继续跑下去的一整包输入。

query():外层包装器

位置:src/query.ts:225-245

export async function* query(
  params: QueryParams,
): AsyncGenerator<
  | StreamEvent
  | RequestStartEvent
  | Message
  | TombstoneMessage
  | ToolUseSummaryMessage,
  Terminal
> {
  const consumedCommandUuids: string[] = []
  const terminal = yield* queryLoop(params, consumedCommandUuids)

  for (const uuid of consumedCommandUuids) {
    notifyCommandLifecycle(uuid, 'completed')
  }
  return terminal
}

query() 是一个异步生成器函数。它运行过程中可以多次 yield 中间事件,最后 return terminal

这里的 Terminal 不是终端窗口,而是"这次 query 为什么结束",例如:

completed
model_error
aborted_tools
max_turns

核心代码

const terminal = yield* queryLoop(params, consumedCommandUuids)
query() 把 queryLoop() 产生的所有中间事件原样转发出去。
queryLoop() 正常结束后,query() 拿到它的 return 值 terminal。

C++ 协程类比

using QueryEvent = std::variant<
    StreamEvent,
    RequestStartEvent,
    Message,
    TombstoneMessage,
    ToolUseSummaryMessage
>;

AsyncGenerator<QueryEvent, Terminal> query(QueryParams params) {
    std::vector<std::string> consumedCommandUuids;

    Terminal terminal =
        co_yield_from queryLoop(params, consumedCommandUuids);

    for (const std::string& uuid : consumedCommandUuids) {
        notifyCommandLifecycle(uuid, "completed");
    }

    co_return terminal;
}
结论:query() 是外层代理生成器。真正的 Agent Loop 在 queryLoop()

4状态层:State 和 while(true) 如何驱动多轮循环

State:每一轮循环之间传递的状态

位置:src/query.ts:210-223

type State = {
  messages: Message[]
  toolUseContext: ToolUseContext
  autoCompactTracking: AutoCompactTrackingState | undefined
  maxOutputTokensRecoveryCount: number
  hasAttemptedReactiveCompact: boolean
  maxOutputTokensOverride: number | undefined
  pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
  stopHookActive: boolean | undefined
  turnCount: number
  transition: Continue | undefined
}

QueryParams 是外部传入的初始参数。

StatequeryLoop() 内部 while(true) 每一轮之间传递的可变状态。

Phase 1 最重要的是

  • messages
  • toolUseContext

因为 agent loop 每一轮最核心的变化就是:

messages 变长,或者被 compact 后替换;
toolUseContext 可能因为工具执行而更新。

例子

第一轮:
messages = [
  user: "读一下 src/query.ts"
]

模型返回:
assistant: tool_use Read(src/query.ts)

本地工具执行:
user/tool_result: "文件内容是..."

第二轮:
messages = [
  user: "读一下 src/query.ts",
  assistant: tool_use Read(src/query.ts),
  user/tool_result: "文件内容是..."
]

初始化 State

位置:src/query.ts:271-286

let state: State = {
  messages: params.messages,
  toolUseContext: params.toolUseContext,
  maxOutputTokensOverride: params.maxOutputTokensOverride,
  autoCompactTracking: undefined,
  stopHookActive: undefined,
  maxOutputTokensRecoveryCount: 0,
  hasAttemptedReactiveCompact: false,
  turnCount: 1,
  pendingToolUseSummary: undefined,
  transition: undefined,
}

这一步把外部参数里的 messagestoolUseContext 放入内部状态。

源码注释说:

Continue sites write `state = { ... }` instead of 9 separate assignments.

也就是说后面进入下一轮时,不是单独改字段,而是整体构造新 state:

state = {
  messages: newMessages,
  toolUseContext: newContext,
  ...
}
continue

这个风格让每个 continue 分支都明确表达:下一轮到底带着什么状态继续。

while(true):Agent Loop 正式开始

位置:src/query.ts:312-327

while (true) {
  let { toolUseContext } = state
  const {
    messages,
    autoCompactTracking,
    maxOutputTokensRecoveryCount,
    hasAttemptedReactiveCompact,
    maxOutputTokensOverride,
    pendingToolUseSummary,
    stopHookActive,
    turnCount,
  } = state
while(true) 不是死循环 bug,而是 Agent Loop 的核心结构:

只要模型还在请求 tool_use,就继续循环;
直到模型没有 tool_use,才 completed。

toolUseContextlet,因为本轮内部可能重新赋值。

其他字段用 const,本轮内通常不直接改;如果需要进入下一轮,会整体重建 state

5上下文层:模型这一轮到底看到哪些 messages

messagesForQuery:本轮模型输入的工作副本

位置:src/query.ts:371-373

let messagesForQuery = [...getMessagesAfterCompactBoundary(messages)]

let tracking = autoCompactTracking

这里要分清:

messages
= state.messages,完整历史 / 当前长期状态

messagesForQuery
= 本轮模型实际输入的工作副本

messagesForQuery 后面会继续被裁剪、压缩、预算处理。

getMessagesAfterCompactBoundary()

定义位置:src/utils/messages.ts:4643-4656

export function getMessagesAfterCompactBoundary<
  T extends Message | NormalizedMessage,
>(messages: T[], options?: { includeSnipped?: boolean }): T[] {
  const boundaryIndex = findLastCompactBoundaryIndex(messages)
  const sliced = boundaryIndex === -1 ? messages : messages.slice(boundaryIndex)
  if (!options?.includeSnipped && feature('HISTORY_SNIP')) {
    const { projectSnippedView } =
      require('../services/compact/snipProjection.js') as typeof import('../services/compact/snipProjection.js')
    return projectSnippedView(sliced as Message[]) as T[]
  }
  return sliced
}

它的作用:

如果没有 compact boundary,返回全部 messages。
如果有 compact boundary,只返回最后一个 boundary 之后的消息。

查找 boundary 的函数在 src/utils/messages.ts:4618-4629

export function findLastCompactBoundaryIndex<
  T extends Message | NormalizedMessage,
>(messages: T[]): number {
  for (let i = messages.length - 1; i >= 0; i--) {
    const message = messages[i]
    if (message && isCompactBoundaryMessage(message)) {
      return i
    }
  }
  return -1
}
Claude Code 不一定把完整历史都发给模型。它先从最近一次 compact 边界之后取消息,形成本轮模型输入基础。

上下文处理流水线

src/query.ts:375-549,代码连续做了几类上下文处理。

它们都在处理同一个对象:messagesForQuery

可以理解成流水线:

state.messages
  |
  v
getMessagesAfterCompactBoundary
  |
  v
messagesForQuery
  |
  +-> applyToolResultBudget
  |
  +-> HISTORY_SNIP
  |
  +-> microcompact
  |
  +-> contextCollapse
  |
  v
最终本轮发给模型的 messagesForQuery

applyToolResultBudget:限制工具结果大小

位置:src/query.ts:375-400

messagesForQuery = await applyToolResultBudget(
  messagesForQuery,
  toolUseContext.contentReplacementState,
  persistReplacements
    ? records =>
        void recordContentReplacement(
          records,
          toolUseContext.agentId,
        ).catch(logError)
    : undefined,
  new Set(
    toolUseContext.options.tools
      .filter(t => !Number.isFinite(t.maxResultSizeChars))
      .map(t => t.name),
  ),
)

解决的问题:

工具结果可能很大。
比如 Read 读了一个 5MB 文件,或者 Grep 返回几万行。
如果完整放入下一轮 messages,模型上下文会爆。

所以它专门处理 tool_result 的体积。

HISTORY_SNIP:裁剪历史视图

位置:src/query.ts:402-416

let snipTokensFreed = 0
if (feature('HISTORY_SNIP')) {
  queryCheckpoint('query_snip_start')
  const snipResult = snipModule!.snipCompactIfNeeded(messagesForQuery)
  messagesForQuery = snipResult.messages
  snipTokensFreed = snipResult.tokensFreed
  if (snipResult.boundaryMessage) {
    yield snipResult.boundaryMessage
  }
  queryCheckpoint('query_snip_end')
}

作用:

模型调用前进一步减少 messagesForQuery。
它偏向"裁剪模型看到的历史视图"。

microcompact:小粒度压缩

位置:src/query.ts:418-432

const microcompactResult = await deps.microcompact(
  messagesForQuery,
  toolUseContext,
  querySource,
)
messagesForQuery = microcompactResult.messages
const pendingCacheEdits = feature('CACHED_MICROCOMPACT')
  ? microcompactResult.compactionInfo?.pendingCacheEdits
  : undefined
microcompact 是 autocompact 之前的小粒度上下文压缩/整理。

contextCollapse:折叠上下文视图

位置:src/query.ts:434-453

if (feature('CONTEXT_COLLAPSE') && contextCollapse) {
  const collapseResult = await contextCollapse.applyCollapsesIfNeeded(
    messagesForQuery,
    toolUseContext,
    querySource,
  )
  messagesForQuery = collapseResult.messages
}

它不是简单删除,也不一定是生成一个大 summary,而是在读取时把上下文投影成折叠后的视图。

autocompact:兜底的大型自动压缩

位置:src/query.ts:455-549

const fullSystemPrompt = asSystemPrompt(
  appendSystemContext(systemPrompt, systemContext),
)

const { compactionResult, consecutiveFailures } = await deps.autocompact(
  messagesForQuery,
  toolUseContext,
  {
    systemPrompt,
    userContext,
    systemContext,
    toolUseContext,
    forkContextMessages: messagesForQuery,
  },
  querySource,
  tracking,
  snipTokensFreed,
)

如果成功:src/query.ts:534-542

const postCompactMessages = buildPostCompactMessages(compactionResult)

for (const message of postCompactMessages) {
  yield message
}

messagesForQuery = postCompactMessages

如果失败:src/query.ts:542-549

} else if (consecutiveFailures !== undefined) {
  tracking = {
    ...(tracking ?? { compacted: false, turnId: '', turnCounter: 0 }),
    consecutiveFailures,
  }
}

失败时不改 messagesForQuery,只更新失败计数,避免后续无限重试。

几种上下文处理的区别

机制 处理对象 主要目的 粒度 记忆方式
applyToolResultBudget tool_result 防止工具输出撑爆上下文 工具结果级 管工具输出
HISTORY_SNIP 历史视图 裁剪模型看到的旧历史 历史片段级 管历史裁剪
microcompact 局部 messages 小规模压缩/缓存友好处理 小粒度 小压缩
contextCollapse 上下文视图 折叠旧上下文,保留可投影结构 视图级 折叠视图
autocompact 整体上下文 上下文太长时总结压缩 大粒度 兜底压缩

同步 messagesForQuery 到 toolUseContext

位置:src/query.ts:551-555

toolUseContext = {
  ...toolUseContext,
  messages: messagesForQuery,
}

这一步不是更新 state.messages

它只是把本轮处理后的模型输入上下文同步到当前 toolUseContext,让后面的工具执行、权限判断、hook、subagent 等逻辑能看到本轮实际使用的 messages。

6模型层:调用模型并提取 tool_use

初始化本轮临时容器

位置:src/query.ts:557-564

const assistantMessages: AssistantMessage[] = []
const toolResults: (UserMessage | AttachmentMessage)[] = []
const toolUseBlocks: ToolUseBlock[] = []
let needsFollowUp = false

这四个变量是 Agent Loop 主线的关键。

变量 作用
assistantMessages 收集本轮模型返回的 assistant message
toolUseBlocks 收集 assistant message 里的 tool_use
toolResults 收集工具执行后产生的 tool_result 和后续附件
needsFollowUp 标记本轮是否需要继续执行工具 / 进入下一轮
重要协议:

assistant 产生 tool_use。
本地 runtime 执行工具。
工具结果作为 user message 中的 tool_result 回填给模型。

调模型前准备

位置:src/query.ts:566-586

const useStreamingToolExecution = config.gates.streamingToolExecution
let streamingToolExecutor = useStreamingToolExecution
  ? new StreamingToolExecutor(
      toolUseContext.options.tools,
      canUseTool,
      toolUseContext,
    )
  : null

const appState = toolUseContext.getAppState()
const permissionMode = appState.toolPermissionContext.mode
let currentModel = getRuntimeMainLoopModel({
  permissionMode,
  mainLoopModel: toolUseContext.options.mainLoopModel,
  exceeds200kTokens:
    permissionMode === 'plan' &&
    doesMostRecentAssistantMessageExceed200k(messagesForQuery),
})

这里还没有真正调用模型,只是在准备:

  • 是否启用 StreamingToolExecutor
  • 本轮实际使用哪个模型 currentModel
  • 当前权限模式 permissionMode

调用模型:deps.callModel

位置:src/query.ts:661-719

let attemptWithFallback = true

while (attemptWithFallback) {
  attemptWithFallback = false
  try {
    let streamingFallbackOccured = false
    for await (const message of deps.callModel({
      messages: prependUserContext(messagesForQuery, userContext),
      systemPrompt: fullSystemPrompt,
      thinkingConfig: toolUseContext.options.thinkingConfig,
      tools: toolUseContext.options.tools,
      signal: toolUseContext.abortController.signal,
      options: {
        async getToolPermissionContext() {
          const appState = toolUseContext.getAppState()
          return appState.toolPermissionContext
        },
        model: currentModel,
        fallbackModel,
        querySource,
        ...
      },
    })) {
      ...
    }
  } catch (innerError) {
    ...
  }
}

这里有两个循环,不要混:

外层 while(true):agent loop。
内层 while(attemptWithFallback):当前这一轮模型调用的 fallback 重试。

真正的模型调用是:

for await (const message of deps.callModel(...)) {

这说明 callModel() 也是异步流,不是一次性返回完整答案。

核心参数

  • messages: prependUserContext(messagesForQuery, userContext)
  • systemPrompt: 完整 system prompt
  • tools: 当前可用工具列表
  • model: 当前模型
  • signal: abort 信号

从 assistant 输出中收集 tool_use

位置:src/query.ts:834-856

if (!withheld) {
  yield yieldMessage
}

if (message.type === 'assistant') {
  assistantMessages.push(message)

  const msgToolUseBlocks = message.message.content.filter(
    content => content.type === 'tool_use',
  ) as ToolUseBlock[]
  if (msgToolUseBlocks.length > 0) {
    toolUseBlocks.push(...msgToolUseBlocks)
    needsFollowUp = true
  }

  if (
    streamingToolExecutor &&
    !toolUseContext.abortController.signal.aborted
  ) {
    for (const toolBlock of msgToolUseBlocks) {
      streamingToolExecutor.addTool(toolBlock, message)
    }
  }
}

这段做了四件事:

  1. 正常情况下,把模型消息 yield 给外部/UI。
  2. 如果是 assistant message,保存到 assistantMessages
  3. 从 assistant content 中筛出 type === 'tool_use' 的 block。
  4. 如果有 tool_use,加入 toolUseBlocks,并把 needsFollowUp 置为 true
这就是模型输出到本地 runtime 的关键转折点:

模型说:我想调用工具。
本地 runtime 记录下来:待会儿我来执行。

没有 tool_use:Agent Loop 结束

位置:src/query.ts:1073-1368

if (!needsFollowUp) {

needsFollowUp 是前面发现 tool_use 时才置为 true 的。

!needsFollowUp
= 模型这一轮没有请求工具
= 不需要 runTools
= agent loop 可以结束

这个分支里有很多工程增强逻辑:

  • prompt too long recovery
  • media recovery
  • max output tokens recovery
  • stop hooks
  • token budget continuation

但主线最终是:src/query.ts:1368

return { reason: 'completed' }

7工具层:执行 tool_use 并收集 tool_result

有 tool_use:进入工具执行

如果 needsFollowUp === true,说明本轮 assistant 输出了工具调用请求,于是跳过 completed 分支,继续往下执行工具。

位置:src/query.ts:1371-1394

let shouldPreventContinuation = false
let updatedToolUseContext = toolUseContext

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

两条路径:

streamingToolExecutor 存在:
  工具可能已经执行了一部分,这里取剩余结果。

streamingToolExecutor 不存在:
  调用 runTools(...) 执行 toolUseBlocks。

runTools 定义

runTools 定义在 src/services/tools/toolOrchestration.ts:19-24

export async function* runTools(
  toolUseMessages: ToolUseBlock[],
  assistantMessages: AssistantMessage[],
  canUseTool: CanUseToolFn,
  toolUseContext: ToolUseContext,
): AsyncGenerator<MessageUpdate, void> {

MessageUpdate 类型在 src/services/tools/toolOrchestration.ts:14-17

export type MessageUpdate = {
  message?: Message
  newContext: ToolUseContext
}

收集工具执行产生的 tool_result

位置:src/query.ts:1396-1420

for await (const update of toolUpdates) {
  if (update.message) {
    yield update.message

    if (
      update.message.type === 'attachment' &&
      update.message.attachment.type === 'hook_stopped_continuation'
    ) {
      shouldPreventContinuation = true
    }

    toolResults.push(
      ...normalizeMessagesForAPI(
        [update.message],
        toolUseContext.options.tools,
      ).filter(_ => _.type === 'user'),
    )
  }
  if (update.newContext) {
    updatedToolUseContext = {
      ...update.newContext,
      queryTracking,
    }
  }
}

这段是工具执行结果进入下一轮模型上下文的关键。

工具结果先 yield 给外部

yield update.message

这个 yield 是给 UI / transcript / 上层调用方看的,不是直接发给模型。

工具结果规范化成 user message

toolResults.push(
  ...normalizeMessagesForAPI(
    [update.message],
    toolUseContext.options.tools,
  ).filter(_ => _.type === 'user'),
)

为什么只保留 type === 'user'

因为工具协议里:

assistant 发出 tool_use。
user 回填 tool_result。

所以工具结果要作为 user message 进入下一轮模型输入。

工具执行可能更新上下文

if (update.newContext) {
  updatedToolUseContext = {
    ...update.newContext,
    queryTracking,
  }
}

工具执行不仅会产生消息,也可能更新工具上下文。下一轮要用更新后的 updatedToolUseContext

工具执行后的收尾

位置:src/query.ts:1421-1532

这段还没有进入下一轮,只是在工具执行后做收尾。

可选生成 tool use summary

位置:src/query.ts:1423-1494

它会把每个 tool_use 和对应 tool_result 配对:

位置:src/query.ts:1450-1477

const toolInfoForSummary = toolUseBlocks.map(block => {
  const toolResult = toolResults.find(
    result =>
      result.type === 'user' &&
      Array.isArray(result.message.content) &&
      result.message.content.some(
        content =>
          content.type === 'tool_result &&
          content.tool_use_id === block.id,
      ),
  )
  ...
  return {
    name: block.name,
    input: block.input,
    output:
      resultContent && 'content' in resultContent
        ? resultContent.content
        : null,
  }
})
核心匹配关系:

tool_use.id === tool_result.tool_use_id

这也是工具协议中最重要的关联方式。

工具阶段被中断或 hook 阻止

位置:src/query.ts:1496-1532

if (toolUseContext.abortController.signal.aborted) {
  if (toolUseContext.abortController.signal.reason !== 'interrupt') {
    yield createUserInterruptionMessage({
      toolUse: true,
    })
  }
  return { reason: 'aborted_tools' }
}

if (shouldPreventContinuation) {
  return { reason: 'hook_stopped' }
}

如果工具执行时用户中断,或 hook 要求停止,agent loop 就不会进入下一轮。

8附件层:工具结果之外,还会给下一轮补充什么

位置:src/query.ts:1535-1688

这一段容易误解:它不是普通工具执行,而是把额外上下文附件也放进 toolResults

关键注释在 src/query.ts:1547-1548

// Be careful to do this after tool calls are done, because the API
// will error if we interleave tool_result messages with regular user messages.
必须先收完 tool_result,再加入普通附件/用户上下文消息。
API 不允许 tool_result 和普通 user message 乱序交错。

queued command attachments

位置:src/query.ts:1592-1602

for await (const attachment of getAttachmentMessages(
  null,
  updatedToolUseContext,
  null,
  queuedCommandsSnapshot,
  [...messagesForQuery, ...assistantMessages, ...toolResults],
  querySource,
)) {
  yield attachment
  toolResults.push(attachment)
}

这一步会生成附件消息:

yield 给外部/UI;
push 到 toolResults;
因此下一轮模型也能看到这些附件。

memory attachments

位置:src/query.ts:1611-1626

const memoryAttachments = filterDuplicateMemoryAttachments(
  await pendingMemoryPrefetch.promise,
  toolUseContext.readFileState,
)
for (const memAttachment of memoryAttachments) {
  const msg = createAttachmentMessage(memAttachment)
  yield msg
  toolResults.push(msg)
}

memory prefetch 完成后,也会变成 attachment message,加入 toolResults

skill discovery attachments

位置:src/query.ts:1632-1640

const skillAttachments =
  await skillPrefetch.collectSkillDiscoveryPrefetch(pendingSkillPrefetch)
for (const att of skillAttachments) {
  const msg = createAttachmentMessage(att)
  yield msg
  toolResults.push(msg)
}

skill discovery 结果也会作为附件进入下一轮上下文。

所以这时 toolResults 的含义变宽了:

toolResults =
  工具执行结果 tool_result
  + queued command attachments
  + memory attachments
  + skill attachments
  + 其他 attachment messages

刷新工具列表

位置:src/query.ts:1671-1688

if (updatedToolUseContext.options.refreshTools) {
  const refreshedTools = updatedToolUseContext.options.refreshTools()
  if (refreshedTools !== updatedToolUseContext.options.tools) {
    updatedToolUseContext = {
      ...updatedToolUseContext,
      options: {
        ...updatedToolUseContext.options,
        tools: refreshedTools,
      },
    }
  }
}

const toolUseContextWithQueryTracking = {
  ...updatedToolUseContext,
  queryTracking,
}

进入下一轮前刷新工具列表。比如新的 MCP server 连接后,工具列表可能变化。

9闭环层:把结果写回 State,进入下一轮

turnCount 和 maxTurns

位置:src/query.ts:1690-1724

// Each time we have tool results and are about to recurse, that's a turn
const nextTurnCount = turnCount + 1

if (maxTurns && nextTurnCount > maxTurns) {
  yield createAttachmentMessage({
    type: 'max_turns_reached',
    maxTurns,
    turnCount: nextTurnCount,
  })
  return { reason: 'max_turns', turnCount: nextTurnCount }
}

只要有工具结果并准备进入下一轮,就算一个新 turn。

如果超过最大轮数,就不再继续。

构造下一轮 State

位置:src/query.ts:1727-1739

const next: State = {
  messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
  toolUseContext: toolUseContextWithQueryTracking,
  autoCompactTracking: tracking,
  turnCount: nextTurnCount,
  maxOutputTokensRecoveryCount: 0,
  hasAttemptedReactiveCompact: false,
  pendingToolUseSummary: nextPendingToolUseSummary,
  maxOutputTokensOverride: undefined,
  stopHookActive,
  transition: { reason: 'next_turn' },
}
state = next

这行是整个 agent loop 的闭环:

messages: [...messagesForQuery, ...assistantMessages, ...toolResults]

它把三段拼起来:

messagesForQuery
  |
  v
本轮模型看到的上下文
  |
  v
assistantMessages
  |
  v
本轮模型输出,包括 text / tool_use
  |
  v
toolResults
  |
  v
本地工具执行结果,包括 user/tool_result 和附件
state = next

然后 while(true) 自然进入下一次循环。

C++ 伪代码

State next{
    .messages = concat(
        messagesForQuery,
        assistantMessages,
        toolResults
    ),
    .toolUseContext = toolUseContextWithQueryTracking,
    .turnCount = turnCount + 1,
    .transition = Continue{ .reason = "next_turn" },
};

state = next;
// while(true) 下一轮开始

这就是最小闭环:

模型 tool_use
  |
  v
本地 runTools
  |
  v
tool_result
  |
  v
写回 state.messages
  |
  v
下一轮 callModel

总结Phase 1 真正要记住什么

一句话总结

Claude Code 的 Agent Loop 不是一次 API 调用,而是一个不断更新 messages 的循环。

五个关键概念

1. 模型不执行工具,只输出 tool_use。
2. 本地 runtime 执行工具。
3. 工具结果以 user/tool_result 的形式回填。
4. 下一轮模型调用必须看到 tool_result,才能继续推理。
5. 所以 Agent 的核心状态就是不断增长或被压缩后的 messages。

最小闭环伪代码

while True:
    response = model.call(messages)

    if response.type == "text":
        print(response.text)
        break

    if response.type == "tool_use":
        result = tool_executor.run(response.tool_use)
        messages.append({
            "role": "assistant",
            "content": response.tool_use,
        })
        messages.append({
            "role": "user",
            "content": {
                "type": "tool_result",
                "tool_use_id": response.tool_use["id"],
                "content": result,
            },
        })