跳到主要内容

内核:无状态的工具调用循环(AgentRuntime)

本章讲什么: 整个 Cline 的心脏 —— AgentRuntime。它就是一个 while 循环,但把「流式 token 拼成消息」「从乱序的 delta 里组装工具调用」「判断什么时候收尾」这几件难事做对了。读懂这章,你就读懂了任何工具调用 agent 的骨架。

1. 它要解决的小问题

大模型只能输出文本。要让它「做事」,业界的约定是:让模型输出一种特殊的「工具调用」(tool call),里面写清楚要调哪个工具、参数是什么。然后由运行时真的去执行那个工具,把结果再喂回模型。

所以一个 agent 循环的本质是:

模型说话 → 里面有工具调用吗?
├─ 没有 → 它觉得做完了,结束
└─ 有 → 执行这些工具 → 把结果当新消息追加 → 再问一次模型

AgentRuntime 就是这个循环的精确实现。它无状态的意思是:它不负责持久化、不负责跨会话记忆 —— 你给它一组消息和一组工具,它跑到收尾,返回结果就完事了。记忆是上层 SessionRuntime 的事(见 03 章)。

2. 思路/直觉:两个类,一个别名

这个包刻意做得很薄。AgentRuntimeAgent同一个类的两个名字(agent-runtime.ts:1639 export const Agent = AgentRuntime):

  • 独立用户写 new Agent({ providerId, modelId, apiKey }) —— 友好,自己内部建模型。
  • @cline/corenew AgentRuntime({ model, ... }) —— 自己造好模型传进来,共享网关。

构造时 resolveRuntimeConfig(agent-runtime.ts:95)分流这两种:有 model 字段就直接用,否则用 providerId/modelId 通过 @cline/llmscreateGateway 建一个。

3. 主循环:一步步看 execute

核心方法是 AgentRuntime.execute(agent-runtime.ts:570)。把它的骨架抽出来(去掉事件发射和错误处理):

// 示意,非源码 —— 提炼自 agent-runtime.ts:604-712 的 execute 主循环
while (iteration < maxIterations) { // 没设上限就一直转
throwIfAborted(); // 用户按了 Esc?立刻退出
iteration += 1;

// ① 调模型,流式拿回一条 assistant 消息 + finishReason
const { message, finishReason } = await generateAssistantMessage();
messages.push(message);

// ② 这条消息里有几个工具调用?
const toolCalls = message.content.filter((p) => p.type === "tool-call");

if (toolCalls.length === 0) {
// ③ 没有工具调用 = 模型认为做完了 → 收尾(除非有「完成提醒」要补)
return finishRun("completed", message);
}

// ④ 有工具调用 → 执行它们,把结果作为 tool 消息追加
const toolMessages = await executeToolCalls(toolCalls);
for (const tm of toolMessages) messages.push(tm);

// ⑤ 如果其中一个是「完成工具」(如 submit_and_exit)且成功 → 收尾
const terminal = findCompletingToolMessage(toolCalls, toolMessages);
if (terminal) return finishRun("completed", message, textFromToolMessage(terminal));
}

重点看三个退出口:

  1. 模型不再要工具(toolCalls.length === 0)—— 最常见的「自然收尾」。
  2. 调用了「完成工具」(completesRun: true 的工具,如 submit_and_exit)—— 显式收尾,常用于无头/CI 模式。
  3. 超过 maxIterations —— 抛错兜底,防止无限转。

4. 难点一:把流式 token 拼成一条消息

模型是流式返回的:文本一个个字蹦,工具调用的参数也是一段段碎着来。generateAssistantMessage(agent-runtime.ts:773)要把这些碎片重新拼成一条结构化消息。

4.1 保持顺序的小技巧

它维护一个 sequence 数组,按到达顺序记录「这是一段文本」还是「这是某个工具调用的占位」:

// 示意,非源码 —— 提炼自 agent-runtime.ts:853-955 的流处理
for await (const event of stream) {
switch (event.type) {
case "text-delta": // 文本碎片:如果上一项还是文本就拼上去,否则新开一段
case "reasoning-delta": // 思维链碎片,同理
case "tool-call-delta": // 工具调用碎片:按 toolCallId/index 归并到同一个 assembly
case "usage": // token 用量,累加
case "finish": // 流结束,记下 finishReason
}
}

工具调用的碎片用一个 Map<string, PendingToolAssembly> 归并 —— 同一个 toolCallId(或 index)的多段参数文本拼到一起(agent-runtime.ts:905-942)。sequence 里只放一个占位 { type: "tool", key },等流结束后再回填,这样文本和工具调用的相对顺序不会乱。

4.2 容错解析工具参数

模型偶尔会吐出非法 JSON 当工具参数。parseToolInput(agent-runtime.ts:1537)分级处理:有结构化 inputValue 直接用;否则尝试 JSON.parse;失败了就记一个 invalid_arguments,并在 parseToolInput 内部(agent-runtime.ts:1565)就地拼好一句明确的纠错文本(...emitted invalid JSON arguments: ${parsed.error})喂回模型,而不是直接崩。底层的 parseToolArguments(agent-runtime.ts:1583)只负责返回 { ok, error },错误文案的拼装在调用它的 parseToolInput 里完成。这是「让模型自我修正」的关键 —— 把错误当成消息喂回去,下一轮模型自己改。

5. 难点二:工具的批准与执行

执行工具前要过一道「准备」(prepareToolExecution,agent-runtime.ts:1173):

toolCall ──▶ ① 有解析错误?标记跳过
──▶ ② provider 端执行的工具?标记跳过
──▶ ③ 跑 beforeTool hook(可改参数 / 改策略 / 直接 skip)
──▶ ④ 查 toolPolicy:enabled=false → 跳过;autoApprove=false → 请求人工批准
──▶ 通过 → 真正 execute

这里有个干净的设计:批准逻辑不在内核里写死。内核只调一个 requestToolApproval 回调(agent-runtime.ts:1263),具体「弹窗问用户 / 自动批 / CI 里一律拒」由宿主决定。autoApprove === false 时才触发批准请求。

执行本身在 executePreparedTool(agent-runtime.ts:1303):try/catch 包住,工具抛错不会终止循环 —— 错误被包成 { output: { error }, isError: true } 当作消息喂回模型,让模型看到失败、自己重试或换路。串行还是并行由 toolExecution 配置决定(executeToolCalls,agent-runtime.ts:1130)。

6. 生命周期 hook:内核怎么被扩展

内核本身只跑循环,但留了七个 hook 让上层注入行为(HookBag,agent-runtime.ts:218):

Hook时机典型用途
beforeRun / afterRun一次 run 的首尾起检查点、统计
beforeModel / afterModel每次调模型前后改消息/工具/参数、打检查点快照
beforeTool / afterTool每个工具前后循环检测、改参数、改结果
onEvent每个事件自定义观测

这些 hook 是 Cline 实现护栏和检查点的统一机制 —— 比如检查点就是挂在 beforeModel 上的(见 05 章),循环检测挂在 beforeTool 上(见 03 章)。applyStopControl(agent-runtime.ts:1500)让任何 hook 都能返回 { stop: true, reason } 来主动中止运行。

7. 巧妙之处

  • 「完成」是工具的一个属性,不是特判。 收尾不靠硬编码工具名,而是看工具的 lifecycle.completesRun === true(findCompletingToolMessage,agent-runtime.ts:1151)。任何工具都能被标成「完成工具」,内核完全通用。
  • 错误即消息。 工具失败、参数非法,都不抛断循环,而是变成喂回模型的 isError 结果 —— 让模型有机会自我纠正,这是 agent 鲁棒性的根。
  • 无状态 = 可重放。 因为 AgentRuntime 不持有跨 run 的记忆,上层可以为每一轮造一个新实例(见 03 章),这让 OAuth 重试、run 重放变得简单。

8. 边界与局限

  • 内核不带任何工具,也不带存储 —— 单用 @cline/agents 你得自己提供工具和持久化(README 明说)。
  • maxIterations 未设时循环可以无限转,防失控完全靠上层的循环检测 / 连错熔断。
  • 流式拼装假设供应商事件遵循 text-delta / tool-call-delta / finish 这套抽象 —— 各家差异由 @cline/llms 网关(04 章)抹平,不是内核的事。

9. 代码地图

主题文件路径符号名
主循环sdk/packages/agents/src/agent-runtime.tsAgentRuntime.execute
流式拼消息sdk/packages/agents/src/agent-runtime.tsgenerateAssistantMessage
工具参数容错解析sdk/packages/agents/src/agent-runtime.tsparseToolInputparseToolArguments
工具批准/准备sdk/packages/agents/src/agent-runtime.tsprepareToolExecutionrequestToolApproval
工具执行sdk/packages/agents/src/agent-runtime.tsexecutePreparedToolexecuteToolCalls
完成工具判定sdk/packages/agents/src/agent-runtime.tsfindCompletingToolMessage
hook 容器sdk/packages/agents/src/agent-runtime.tsHookBagapplyStopControl
友好别名sdk/packages/agents/src/agent-runtime.tsAgentcreateAgent