拆 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
如果像 read、write、edit 这种文件工具缺关键参数,它报错不是一句很硬的异常。
它会构造成更适合模型理解和重试的提示。
这点我很喜欢。
因为在 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 不是把审批当外围功能。
这里支持几种模式:
requestreportdenydefer
某个 tool call 在调用前到底:
- 直接执行
- 先报备
- 直接拒绝
- 延迟审批
这些都已经进入统一 runtime 逻辑了。
7.4 被 block 的 tool call 也不是简单 throw
这一点我专门记了。
buildBlockedToolResult(...) 会构造一个正式结果:
content里有 reasondetails.status = "blocked"details.deniedReason = ...
就算没执行成功,它也会被包装成一份结构化结果。
这很重要。
因为对 agent loop 来说,block 不是“程序崩了”, 而是一次有结论的工具调用结果。
7.5 参数如果被调整了,还会专门记录下来
recordAdjustedParamsForToolCall(...) 这一层也挺值。
前面的 hook、policy、code mode 之类逻辑可能会把参数改掉。
OpenClaw 不会让这件事悄悄发生。
它会把 adjusted params 按 toolCallId 和 runId 记下来。
这件事的意义我现在理解成两点:
- 方便 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.startedtool.execution.blockedtool.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 里把它跑掉。
它会:
- 先走 before hook
- 记录这次 client tool call
- 返回一个:
{
"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. 这篇先记几个我觉得最重要的点
- OpenClaw 的工具系统首先在解决“这一轮到底暴露哪些工具”,而不是先解决“怎么执行工具”
- tool call 真正执行前,会先经过策略过滤、schema 归一化、参数预处理、参数校验和 before hook
agent-tools.before-tool-call.ts是整个 tool call 治理链最重的一层,里面把 approval、loop detection、diagnostics、参数调整都串起来了- 被 block 的工具调用也会被包装成结构化结果,而不是简单抛异常,这样 agent loop 才能继续保持一致的处理模型
- tool result 回写成正式消息以后,工具系统才真正和多轮 agent loop 接上
这篇先记到这里。
