Home
avatar

.𝙃𝙖𝙣

拆 OpenClaw 的 Tool Call:从模型吐出调用到结果回写

上一篇我已经把 OpenClaw 的大执行链路顺了一遍,这一篇不再铺开讲,只盯一个点:tool call 系统到底是怎么转起来的。

我这次给自己的问题也很具体:

  • 模型把工具调用吐出来以后,谁先接住
  • tool call 在真正执行前过了哪些关
  • 参数是在哪里校验和修的
  • 为什么 OpenClaw 的工具系统看起来不像“挂几个函数”那么简单

1. 我这次先下了个结论:OpenClaw 的 tool call 分成三层看最清楚

只看表面,tool call 好像就是这样:

模型输出工具名和参数
→ 系统执行工具
→ 把结果返回给模型

但我这次对着源码看完以后,估计至少得拆成三层:

第一层:工具表面长什么样

也就是模型最终能看到哪些工具、每个工具叫什么、参数 schema 长什么样。

第二层:工具调用前后怎么被系统包起来

也就是:

  • before hook
  • policy
  • approval
  • 参数修正
  • 诊断事件
  • 结果回写

第三层:工具结果怎么重新进入 agent loop

也就是执行完以后,不是简单 return 一下,是要重新变成一条正式消息,继续推动下一轮。

这三层一分开,OpenClaw 的 tool call 系统就顺很多了。

2. 工具不是临时拼出来的,先是在 agent-tools.ts 里被组装成一个“可暴露工具面”

还是先看:

src/agents/agent-tools.ts

这个文件很关键,是在准备本轮到底有哪些工具可以给模型看

我对 createOpenClawCodingToolsInternal(...) 的理解是:

它是在做一整轮工具面构建:

  • 先根据 session / sandbox / channel / sender / subagent 身份算 capability profile
  • 再根据 profile、provider、group、sender、sandbox 等策略做多层过滤
  • 再把剩下的工具 schema 标准化
  • 再统一包 before tool call hook
  • 再补 abort 包装和 deferred follow-up 描述

模型最终看到的那个 tools 列表,不是一个固定常量。

它是这轮运行环境算完以后,动态筛出来的结果。

这一点挺重要。

因为这说明 OpenClaw 的工具系统不是“仓库里实现了什么工具,模型就都能调”。

而是:

当前这轮、当前这个会话、当前这个身份,允许你看到哪些工具。

3. createOpenClawCodingToolsInternal(...) 里我最想记住的是“先收口,再暴露”

这个函数很长,主要看后半段。

它前面先把各种工具组装进来:

  • base coding tools
  • shell tools
  • channel tools
  • OpenClaw 自带工具
  • plugin tools

3.1 先走策略管道

applyToolPolicyPipeline(...) 这里,会把工具丢进一条多层 policy pipeline:

  • profile policy
  • provider profile policy
  • global policy
  • global provider policy
  • agent policy
  • agent provider policy
  • group policy
  • sender policy
  • sandbox policy
  • subagent policy
  • inherited tool policy

看到这里时,有感觉很明确吗?

OpenClaw 的 tool call 系统先想的是“该不该给你这个工具”,而不是“怎么让你更方便地调工具”。

这个顺序我很认同。

因为智能体一旦真进:

  • 群聊
  • channel
  • 子 agent
  • sandbox
  • runtime plugin

这种环境,不先收权限,后面一定会乱。

3.2 再统一做 schema 归一化

策略过滤完以后,它会跑:

normalizeToolParameters(...)

注释里也写得很直白:

  • 不同 provider 对 schema 的要求不一样
  • 有些 provider 不接受 root-level union schema
  • Gemini 和 Anthropic 对约束关键字的接受程度也不一样

这一层让我意识到一个很实际的问题:

tool call 系统不只是“本地怎么执行工具”,还包括“怎么把工具描述稳定交给不同模型”。

所以工具系统一半是执行问题,一半是协议兼容问题。

3.3 最后再统一包 hook

后面每个工具都会被统一走一遍:

wrapToolWithBeforeToolCallHook(...)

如果原来已经包过了,就 rewrapToolWithBeforeToolCallHook(...)

这一步一出来,我对 OpenClaw 的理解又更清楚了一点:

把调用前的治理逻辑抽到统一包装层

这就很像后端里的中间件思路。

4. 真到模型输出 tool call 以后,接球的是 packages/agent-core/src/agent-loop.ts

工具面准备好以后,真正运行时还是要回到 agent loop。

这块我主要看的是:

packages/agent-core/src/agent-loop.ts

前一篇已经说过,这里是主循环。

4.1 先看 assistant message 的 stop reason

runLoop(...) 里,assistant message 流式拼完以后,会看:

  • message.stopReason === "toolUse"
  • 并且 toolCalls.length > 0

满足这个条件,才会进入:

executeToolCalls(...)

这说明 OpenClaw 对 tool call 的理解还是很严谨的。

不是“assistant message 里只要出现了 toolCall block 就一定跑”, 而是要结合这轮停止原因一起判断。

4.2 executeToolCalls(...) 先决定并行还是串行

这里我觉得很值得单独记一下。

OpenClaw 不是一股脑全并发,也不是一股脑全串行。

它会先看:

  • 全局配置 config.toolExecution
  • 每个工具自己的 executionMode

如果有工具声明自己必须 sequential,那这一批就走串行。

这一步说明它已经不是“函数调用器”思路了。

它已经在做调度。

我自己现在会把它理解成:

tool call 到了这里,已经从模型输出问题变成 runtime 调度问题了。

5. 工具真正执行前,最关键的是 prepareToolCall(...)

这一层我这次看得最仔细。

因为我原来脑子里总会自然地想成:

  • 模型给工具名和参数
  • 系统直接执行

prepareToolCall(...) 告诉我,中间其实还有一大截。

5.1 先解析工具对象

它会先通过 resolveToolCallTool(...) 找当前 tool call 对应的工具。

这里不只是从 currentContext.tools 里找。

如果没找到,它还会尝试走:

  • config.resolveDeferredTool?.(...)

工具不一定全都得在最开始静态塞好。

有些工具可以是 deferred resolve。

这个设计我觉得挺灵活。

5.2 再跑 prepareArguments

如果工具自己实现了 prepareArguments,这里会先让工具改一次参数。

我会把这一步理解成:

  • 把模型给的原始参数
  • 先整理成更适合这个工具自己执行的形状

5.3 再跑 validateToolArguments(...)

到这里才开始做参数校验。

OpenClaw 的顺序是:

找到工具
→ 工具自己预处理参数
→ runtime 再校验参数

这个顺序挺合理。

因为有些参数问题,工具自己先改一次以后再校验,能少很多误杀。

5.4 再跑 config.beforeToolCall(...)

这一层说明 agent loop 还给 runtime 留了统一拦截点。

如果这里判断 block,那会直接返回一个 error result,不会进真正执行。

所以我这次会把 prepareToolCall(...) 记成一句话:

OpenClaw 在真正执行工具前,先把“找不找得到、参对不对、当前允不允许”都过一遍。

6. agent-tools.params.ts 这块让我看到:它连模型常见胡参都在专门修

这个文件我看完以后印象挺深。

src/agents/agent-tools.params.ts

它处理的不是宏大问题,而是很具体的“模型参数有时会给歪”。

比如里面专门在做这些事:

  • 文件工具必须参数校验
  • path 这类字段的统一处理
  • 去掉奇怪的 XML </arg_value> 尾巴
  • 把模型幻觉出来的错误 Office 后缀修正掉

像:

  • .doccodex
  • .pptxodex
  • .xlsxcodex

这类情况,文件里都在防。

这一下我就觉得很真实。

因为只聊 agent 规划、多步推理这些东西时,很容易忘记一个事实:

模型调用工具时,参数经常并不干净。

OpenClaw 这里明显不是假设模型永远给对,而是在专门给它做一层“工具参数纠偏”。

6.1 它还会给 retryable error guidance

如果像 readwriteedit 这种文件工具缺关键参数,它报错不是一句很硬的异常。

它会构造成更适合模型理解和重试的提示。

这点我很喜欢。

因为在 tool call 语境里,报错不是为了让人看舒服,而是为了让下一轮模型更有机会修正调用。

7. 真正最重的一层,其实是 agent-tools.before-tool-call.ts

如果前面那些还算“工具准备”, 那这个文件就是 tool call 系统真正开始显露复杂度的地方。

src/agents/agent-tools.before-tool-call.ts

我这次看完以后,对它的理解很明确:

这是 OpenClaw 的 tool call 中间件核心。

7.1 runBeforeToolCallHook(...) 不只是 hook,它是一条策略链

这个函数里塞了很多东西:

  • plugin hook
  • trusted tool policy
  • approval
  • diagnostics
  • loop detection
  • adjusted parameter tracking
  • skill usage telemetry

所以它不是单一功能。

它已经是一条调用前治理链了。

7.2 它会先做 loop detection

这块我觉得挺有代表性。

如果当前 session 里某个 tool call 已经表现出“卡在同一类调用上反复打转”的趋势, 这里会直接识别出来。

严重时可以直接 block。

这说明 OpenClaw 看 tool call,不是只看“这一调用合不合法”, 还会看:

它在这个 session 的执行轨迹里,是不是已经开始形成坏循环。

这个视角一下就把 tool call 提到了运行时稳定性问题上。

7.3 approval 不是额外插件,而是主链里的正式节点

approvalMode 那几段分支也能看出来,OpenClaw 不是把审批当外围功能。

这里支持几种模式:

  • request
  • report
  • deny
  • defer

某个 tool call 在调用前到底:

  • 直接执行
  • 先报备
  • 直接拒绝
  • 延迟审批

这些都已经进入统一 runtime 逻辑了。

7.4 被 block 的 tool call 也不是简单 throw

这一点我专门记了。

buildBlockedToolResult(...) 会构造一个正式结果:

  • content 里有 reason
  • details.status = "blocked"
  • details.deniedReason = ...

就算没执行成功,它也会被包装成一份结构化结果。

这很重要。

因为对 agent loop 来说,block 不是“程序崩了”, 而是一次有结论的工具调用结果

7.5 参数如果被调整了,还会专门记录下来

recordAdjustedParamsForToolCall(...) 这一层也挺值。

前面的 hook、policy、code mode 之类逻辑可能会把参数改掉。

OpenClaw 不会让这件事悄悄发生。

它会把 adjusted params 按 toolCallIdrunId 记下来。

这件事的意义我现在理解成两点:

  • 方便 replay / diagnostics
  • 方便后面知道“模型原来给了什么、系统最后真正执行了什么”

这个差异在 tool call 系统里其实很关键。

8. wrapToolWithBeforeToolCallHook(...) 基本把“真正执行工具前要做的事”全包了

如果我只能挑一个文件段落给别人看 OpenClaw 的 tool call 复杂度, 我大概率会挑这里。

因为这个 wrapper 把前后边界都包得很完整。

8.1 执行前先准备参数

它先会尝试调:

  • prepareBeforeToolCallParams

这里是给工具最后一次机会,把参数处理成更适合 hook / approval / diagnostics 使用的样子。

8.2 然后进 runBeforeToolCallHook(...)

这里会决定:

  • 能不能过
  • 会不会被 veto
  • 需不需要审批
  • 参数要不要调整

8.3 过了以后再 reconcile / finalize

这里还有两层:

  • reconcileCodeModeExecBeforeHookParams(...)
  • finalizeBeforeToolCallParams(...)

OpenClaw 明确区分了几件事:

  • 原始参数
  • hook 看到的参数
  • 调整后的执行参数
  • 最终落地执行的参数

这个分层看起来啰嗦,但我觉得是对的。

因为 tool call 一旦接进:

  • 审批
  • 代码模式
  • 风控
  • 参数纠偏

这些能力,不把参数生命周期分开,后面一定会乱。

8.4 执行前后还会打诊断事件

wrapper 里还能看到:

  • tool.execution.started
  • tool.execution.blocked
  • tool.execution.error

这些诊断事件。

这就说明 OpenClaw 的工具系统是天然可观测的。

它不只是“执行完再看结果”, 而是把每次工具调用在生命周期里的关键节点都打出来。

8.5 执行后还会做 loop outcome 和 terminal presentation

工具跑完以后,这个 wrapper 还会:

  • recordLoopOutcome(...)
  • rememberPendingTerminalPresentation(...)

结果不仅要返回给模型, 还要进入:

  • loop detection 的结果统计
  • 给终端或上层展示的 tool terminal presentation

所以我现在再看 tool call,就不会只想“执行完返回结果”这一步了。

它后面还有:

  • 统计
  • 展示
  • 诊断
  • 重放辅助

9. client tool 这块也很有意思:它不是本地执行,而是先返回 pending

上一篇我已经提过一点,这次单独再记一下。

agent-tool-definition-adapter.ts 里,toClientToolDefinitions(...) 这一块能看到一个很重要的思路。

对于 client-hosted 工具,OpenClaw 不会直接在 runtime 里把它跑掉。

它会:

  1. 先走 before hook
  2. 记录这次 client tool call
  3. 返回一个:
{
  "status": "pending",
  "tool": "...",
  "message": "Tool execution delegated to client"
}

而且这个结果还会带:

  • terminate: true

这一下我就更能理解 OpenClaw 的工具抽象了。

它不是默认“工具一定在当前 runtime 进程里执行”。

它允许工具调用变成一种跨执行端的任务委托

这一点很值。

因为很多智能体产品最后都会碰到这个问题:

  • 有的工具在 host 上
  • 有的工具在 client 上
  • 有的工具在 node / mobile / remote surface 上

如果没有统一的 pending / delegation 语义,后面很难扩。

10. 工具结果为什么还要回写成 toolResult message,这次我也想通了

前面我一直知道 OpenClaw 会把 tool result 再写回上下文, 但这次只盯 tool call 系统以后,我对这一步的必要性更清楚了。

agent-loop.ts 里,工具执行完以后,最终会走到:

  • createToolResultMessage(...)
  • 然后把它 append 回 currentContext.messages

这意味着什么?

意味着对 OpenClaw 来说:

工具结果不是执行副作用,而是下一轮推理的正式输入。

如果不这么做,后面模型根本拿不到:

  • 工具到底返回了什么
  • 这次执行有没有报错
  • 这次调用是不是被 block
  • 下一步应该继续调工具,还是该开始组织答案

所以 tool result message 不是附属物, 它其实是 tool call 系统和 agent loop 接起来的桥。

11. 如果只从源码里抽一条 tool call 主链,我现在会记成这样

agent-tools.ts 先构造本轮允许暴露的工具面
→ schema normalization
→ before-tool-call wrapper 统一包上去
→ agent-loop.ts 检测 assistant message 是否进入 toolUse
→ executeToolCalls(...) 决定并行还是串行
→ prepareToolCall(...) 找工具、预处理参数、校验参数、跑 beforeToolCall
→ 真正 execute
→ 结果进入 after / diagnostics / loop outcome
→ createToolResultMessage(...) 回写上下文
→ 进入下一轮 agent 推进

这个链路一旦在脑子里立起来,我看 OpenClaw 的 tool call 系统就不再是“模型会调工具”这么一句话了。

它更像一个完整的 runtime 子系统。

12. 这篇先记几个我觉得最重要的点

  1. OpenClaw 的工具系统首先在解决“这一轮到底暴露哪些工具”,而不是先解决“怎么执行工具”
  2. tool call 真正执行前,会先经过策略过滤、schema 归一化、参数预处理、参数校验和 before hook
  3. agent-tools.before-tool-call.ts 是整个 tool call 治理链最重的一层,里面把 approval、loop detection、diagnostics、参数调整都串起来了
  4. 被 block 的工具调用也会被包装成结构化结果,而不是简单抛异常,这样 agent loop 才能继续保持一致的处理模型
  5. tool result 回写成正式消息以后,工具系统才真正和多轮 agent loop 接上

这篇先记到这里。

OpenClaw 工具调用 Tool Call 源码拆解 智能体