跳到主要内容

工具执行循环 — agent 调用前端函数的核心机制

这是 CopilotKit 最核心的机制,也是「agent-ui」这个 area 的灵魂:agent 说要调一个函数 → 这个函数在用户浏览器里跑 → 结果回灌给 agent → agent 接着想。本章把这条循环一步步拆开。

1. 它要解决的小问题

普通的「后端工具」由 agent 框架自己执行。但 CopilotKit 的卖点是前端工具:工具的 handler 要在用户的浏览器里跑(因为它要改 DOM、读页面状态、调用只有前端有 token 的 API)。

于是问题变成:agent 在远端决定「调用 setTheme(dark)」,这个决定通过 SSE 流回前端,前端得

  1. 认出「这是一次工具调用」,
  2. 找到本地注册的同名工具,
  3. 跑它的 handler,
  4. 把返回值变成一条 tool 消息塞进对话,
  5. 再把整个对话发回 agent,让它基于结果继续生成。

第 5 步是精髓:工具结果必须回灌,否则 agent 永远不知道它的工具调用成功了没。这整套就是 RunHandler 干的(run-handler.ts)。

2. 直觉:一次「问答 + 干活」的来回

用一个简化模型先建立直觉(不是源码,只为讲清楚循环形状):

# 示意,非源码——把核心循环的形状演出来
def run_until_no_more_tools(agent):
while True:
new_messages = agent.run() # 发请求,收 SSE,累积消息
needs_followup = False
for msg in new_messages:
for call in msg.tool_calls:
if not already_has_result(call):
result = run_frontend_handler(call) # 在浏览器里跑
agent.insert_tool_message(call, result)
needs_followup = True
if not needs_followup:
break # 没有工具要跑了,结束
# 否则:带着工具结果再跑一次,让 agent 看到结果

真实实现不是 while,而是递归调用 runAgent——但形状一样。重点看:有工具跑过 → 必须再跑一轮。

3. 主线:runAgent → processAgentResult

core.runAgent({ agent }) core.ts:1122 → run-handler.ts:289

├─ applyHeadersToAgent / detachActiveRun (清场)
├─ 顶层才建 AbortController + 拦截 agent.abortRun()
├─ agent.runAgent({ tools, context, forwardedProps }) ← 发 HTTP,收 SSE
│ tools = buildFrontendTools(agentId)
│ context = getContextForAgent(agentId)
└─ processAgentResult({ runAgentResult, agent }) run-handler.ts:383
扫 newMessages 里每个 assistant 的 toolCalls:
├─ 已有对应 tool 结果? → 跳过
├─ 查到具名工具 → executeSpecificTool
└─ 没查到但有 "*" → executeWildcardTool
若任一工具 needsFollowUp 且未中止:
waitForPendingFrameworkUpdates() ← 让一拍给 React
return runAgent({ agent }) ← 递归续跑

发请求时打包的三样东西(run-handler.ts:344):前端工具清单、按当前 agent 过滤后的上下文、forwardedProps。工具清单由 buildFrontendTools 生成——它把注册的工具转成 AG-UI 的 Tool 形状(name/description/JSON-schema 参数),并过滤掉 available === false 的和「绑定了别的 agentId」的(run-handler.ts:887)。

4. 认出工具调用、避免重复执行

processAgentResultnewMessages,对每个 assistant 消息的每个 toolCall,先检查这批消息里是否已经有对应的 tool 结果消息(run-handler.ts:399):

// 真实源码 packages/core/src/core/run-handler.ts:399
if (newMessages.findIndex(
(m) => m.role === "tool" && m.toolCallId === toolCall.id,
) === -1) {
// 没有结果 → 这是个需要前端执行的工具调用
}

为什么要查?因为后端工具的结果会随 SSE 一起流回来(已经有 tool 消息),前端不该再执行一遍。只有「有调用、没结果」的才是真正要前端跑的。

5. 执行一个具名工具

executeSpecificTool(run-handler.ts:573)的步骤:

  1. 作用域检查:工具若绑了 agentId 且不等于当前 agent,跳过。
  2. 跑 handler(经共享逻辑 executeToolHandler,run-handler.ts:462):解析参数 → 通知 onToolExecutionStartawait tool.handler(args, { toolCall, agent, signal }) → 结果字符串化 → 通知 onToolExecutionEnd
  3. 插入结果消息:在原 assistant 消息后面插一条 role: "tool" 消息(run-handler.ts:612)。
  4. 判断是否续跑:if (!error && tool.followUp !== false) return true

参数解析有个容错点:某些 LLM 会把空参数发成 ""/null/undefined 而非 "{}",parseToolArguments 把这些归一成 {},避免 JSON.parse("") 崩(run-handler.ts:1058);解析出来若不是对象还会抛结构化错误(ensureObjectArgs,run-handler.ts:1034)。

handler 拿到的 signal 来自顶层的 AbortController——这样长跑的工具能配合取消(human-in-the-loop 就靠它,见 §8)。

6. 一个关键防御:执行期间线程被切走

插结果消息前,代码重新查原 assistant 消息还在不在 agent.messages 里(run-handler.ts:605):

// 真实源码 packages/core/src/core/run-handler.ts:605
const messageIndex = agent.messages.findIndex((m) => m.id === message.id);
if (messageIndex === -1) {
// 父消息没了(handler 执行期间用户切了线程)——
// 不插结果、不续跑,以免改错线程
return false;
}

handler 是 await 的,期间用户完全可能切到别的对话。若不查就 splice,会把结果插进错误的线程。这种「await 前后世界变了」的防御是异步 UI 的常见陷阱,CopilotKit 在多处都做了。

7. 续跑:递归 runAgent + 中止控制只在最外层

有工具跑成功 → processAgentResult 直接再调 runAgent(run-handler.ts:449)。这就是续跑。问题:递归调用时,AbortControlleragent.abortRun() 的拦截不能重复装/拆。解法是 _runDepth 计数,只在 top-level 处理(run-handler.ts:324):

// 真实源码 packages/core/src/core/run-handler.ts:324
const isTopLevel = this._runDepth === 0;
if (isTopLevel) {
this._runAbortController = new AbortController();
// 拦截 agent.abortRun(),让直接调它也能中止工具执行 + 阻止续跑
originalAbortRun = agent.abortRun.bind(agent);
agent.abortRun = () => { controller.abort(); originalAbortRun!(); };
}
this._runDepth++;

续跑前还检查 !this._runAbortController?.signal.aborted(run-handler.ts:443)——用户中途按了停,就不再续跑。finally_runDepth--,整条递归链结束时才还原 abortRun(run-handler.ts:370)。

8. 续跑前为什么要「让一拍」

续跑前有一句不起眼但关键的 await(run-handler.ts:448):

await this._internal.waitForPendingFrameworkUpdates();
return await this.runAgent({ agent });

基类里它是空操作,但 React 子类覆写成 await setTimeout(0)(react-core.ts:153)。原因:前端工具的 handler 常调 setState,React 18 把更新批处理、异步 commit(走 MessageChannel);useAgentContextuseLayoutEffect 在 commit 后才把新上下文写进 store。若立刻续跑,runAgent同步读到旧上下文。让一个宏任务,React 的 commit 先跑、useLayoutEffect 把新上下文写好,续跑才读到新鲜值。这是「前端工具改了状态、下一轮 agent 要看到」能成立的前提。

9. 通配工具与编程式执行

  • 通配工具 "*":agent 调了一个没注册具名 handler 的工具时,回落到名为 "*" 的工具(run-handler.ts:421)。它的参数被包成 { toolName, args },所以单独有一份逻辑 executeWildcardTool(run-handler.ts:634)。常用于「把所有未知工具统一渲染/转发」。
  • runTool:不经过 LLM 直接编程式跑一个前端工具(run-handler.ts:771)。它手动造 assistant + tool 消息塞进对话,followUp 可选:false 只执行、"generate" 续跑、其他字符串则先加一条 user 消息再续跑。用于「按钮点击直接触发某工具并让 agent 接话」。

10. 代码地图

主题文件符号
跑 agent + 顶层中止packages/core/src/core/run-handler.tsrunAgent_runDepth_runAbortController
扫结果 + 续跑packages/core/src/core/run-handler.tsprocessAgentResult
跑具名工具packages/core/src/core/run-handler.tsexecuteSpecificToolexecuteToolHandler
通配工具packages/core/src/core/run-handler.tsexecuteWildcardTool
编程式执行packages/core/src/core/run-handler.tsrunTool
工具清单打包packages/core/src/core/run-handler.tsbuildFrontendToolscreateToolSchema
参数容错packages/core/src/core/run-handler.tsparseToolArgumentsensureObjectArgs
让一拍给 Reactpackages/react-core/src/v2/lib/react-core.tswaitForPendingFrameworkUpdates
工具类型packages/core/src/types.tsFrontendToolFrontendToolHandlerContext