数据截至 (上游 commit a5ac26f27989)
内核:无状态的工具调用循环(AgentRuntime)
本章讲什么: 整个 Cline 的心脏 ——
AgentRuntime。它就是一个 while 循环,但把「流式 token 拼成消息」「从乱序的 delta 里组装工具调用」「判断什么时候收尾」这几件难事做对了。读懂这章,你就读懂了任何工具调用 agent 的骨架。
1. 它要解决的小问题
大模型只能输出文本。要让它「做事」,业界的约定是:让模型输出一种特殊的「工具调用」(tool call),里面写清楚要调哪个工具、参数是什么。然后由运行时真的去执行那个工具,把结果再喂回模型。
所以一个 agent 循环的本质是:
模型说话 → 里面有工具调用吗?
├─ 没有 → 它觉得做完了,结束
└─ 有 → 执行这些工具 → 把结果当新消息追加 → 再问一次模型
AgentRuntime 就是这个循环的精确实现。它无状态的意思是:它不负责持久化、不负责跨会话记忆 —— 你给它一组消息和一组工具,它跑到收尾,返回结果就完事了。记忆是上层 SessionRuntime 的事(见 03 章)。
2. 思路/直觉:两个类,一个别名
这个包刻意做得很薄。AgentRuntime 和 Agent 是同一个类的两个名字(agent-runtime.ts:1785 export const Agent = AgentRuntime):
- 独立用户写
new Agent({ providerId, modelId, apiKey })—— 友好,自己内部建模型。 @cline/core写new AgentRuntime({ model, ... })—— 自己造好模型传进来,共享网关。
构造时 resolveRuntimeConfig(agent-runtime.ts:96)分流这两种:有 model 字段就直接用,否则用 providerId/modelId 通过 @cline/llms 的 createGateway 建一个。
3. 主循环:一步步看 execute
核心方法是 AgentRuntime.execute(agent-runtime.ts:610)。把它的骨架抽出来(去掉事件发射和错误处理):
// 示意,非源码 —— 提炼自 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));
}
重点看三个退出口:
- 模型不再要工具(
toolCalls.length === 0)—— 最常见的「自然收尾」。 - 调用了「完成工具」(
completesRun: true的工具,如submit_and_exit)—— 显式收尾,常用于无头/CI 模式。 - 超过
maxIterations—— 抛错兜底,防止无限转。
4. 难点一:把流式 token 拼成一条消息
模型是流式返回的:文本一个个字蹦,工具调用的参数也是一段段碎着来。generateAssistantMessage(agent-runtime.ts:842)要把这些碎片重新拼成一条结构化消息。
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:974-1011)。sequence 里只放一个占位 { type: "tool", key },等流结束后再回填,这样文 本和工具调用的相对顺序不会乱。
4.2 容错解析工具参数
模型偶尔会吐出非法 JSON 当工具参数。parseToolInput(agent-runtime.ts:1674)分级处理:有结构化 inputValue 直接用;否则尝试 JSON.parse;失败了就记一个 invalid_arguments,并在 parseToolInput 内部(agent-runtime.ts:1702)就地拼好一句明确的纠错文本(...emitted invalid JSON arguments: ${parsed.error})喂回模型,而不是直接崩。底层的 parseToolArguments(agent-runtime.ts:1720)只负责返回 { ok, error },错误文案的拼装在调用它的 parseToolInput 里完成。这是「让模型自我修正」的关键 —— 把错误当成消息喂回去,下一轮模型自己改。
5. 难点二:工具的批准与执行
执行工具前要过一道「准备」(prepareToolExecution,agent-runtime.ts:1302):
toolCall ──▶ ① 有 解析错误?标记跳过
──▶ ② provider 端执行的工具?标记跳过
──▶ ③ 跑 beforeTool hook(可改参数 / 改策略 / 直接 skip)
──▶ ④ 查 toolPolicy:enabled=false → 跳过;autoApprove=false → 请求人工批准
──▶ 通过 → 真正 execute
这里有个干净的设计:批准逻辑不在内核里写死。内核只调一个 requestToolApproval 回调(agent-runtime.ts:1399),具体「弹窗问用户 / 自动批 / CI 里一律拒」由宿主决定。autoApprove === false 时才触发批准请求。
执行本身在 executePreparedTool(agent-runtime.ts:1439):try/catch 包住,工具抛错不会终止循环 —— 错误被包成 { output: { error }, isError: true } 当作消息喂回模型,让模型看到失败、自己重试或换路。串行还是并行由 toolExecution 配置决定(executeToolCalls,agent-runtime.ts:1244)。
6. 生命周期 hook:内核怎么被扩展
内核本身只跑循环,但留了七个 hook 让上层注入行为(HookBag,agent-runtime.ts:219):
| Hook | 时机 | 典型用途 |
|---|---|---|
beforeRun / afterRun | 一次 run 的首尾 | 起检查点、统计 |
beforeModel / afterModel | 每次调模型前后 | 改消息/工具/参数、打检查点快照 |
beforeTool / afterTool | 每个工具前后 | 循环检测、改参数、改结果 |
onEvent | 每个事件 | 自定义观测 |
这些 hook 是 Cline 实现护栏和检查点的统一机制 —— 比如检查点就是挂在 beforeModel 上的(见 05 章),循环检测挂在 beforeTool 上(见 03 章)。applyStopControl(agent-runtime.ts:1637)让任何 hook 都能返回 { stop: true, reason } 来主动中止运行。
7. 巧妙之处
- 「完成」是工具的一个属性,不是特判。 收尾不靠硬编码工具名,而是看工具的
lifecycle.completesRun === true(findCompletingToolMessage,agent-runtime.ts:1223)。任何工具都能被标成「完成工具」,内核完全通用。 - 错误即消息。 工具失败、参数非法,都不抛断循环,而是变成喂回模型的
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.ts | AgentRuntime.execute |
| 流式拼消息 | sdk/packages/agents/src/agent-runtime.ts | generateAssistantMessage |
| 工具参数容错解析 | sdk/packages/agents/src/agent-runtime.ts | parseToolInput、parseToolArguments |
| 工具批准/准备 | sdk/packages/agents/src/agent-runtime.ts | prepareToolExecution、requestToolApproval |
| 工具执行 | sdk/packages/agents/src/agent-runtime.ts | executePreparedTool、executeToolCalls |
| 完成工具判定 | sdk/packages/agents/src/agent-runtime.ts | findCompletingToolMessage |
| hook 容器 | sdk/packages/agents/src/agent-runtime.ts | HookBag、applyStopControl |
| 友好别名 | sdk/packages/agents/src/agent-runtime.ts | Agent、createAgent |