
近期在围绕鼎道 PSUIP 等核心产品研发时,我们一直通过 OpenClaw 提升研发效率和产品体验,其灵活的事件分发能力为开发提供了关键支撑。但在实际落地适配中,常会观察到下面这种现象:
- 某条 WS 连接发起一条消息后,这条连接自己能收到
stream: "tool"的事件 - 另一个独立的 WS 连接,即使连的是同一个 gateway、同一个 session,也可能收不到这条
tool事件 - 但这个独立 WS 连接通常仍然能收到:
assistantlifecyclechatagent
这会让人误以为:
- 是前端渲染出了问题
- 是当前连接 gateway 的 WS 客户端没有正确解析
tool - 或者不同 WS 连接应该天然共享同一条 run 的所有事件
这一问题直接影响前端调试需要攻克的核心卡点。本文结合实际研发场景,深度解析该现象的底层逻辑,并给出适配我们业务场景的解决方案。
核心结论:OpenClaw 事件分发的默认语义
结合鼎道的研发场景拆解,我先明确核心结论:OpenClaw 后端对不同事件的分发策略存在本质差异,这也是导致上述问题的核心原因,而非鼎道产品层的代码问题:
- 普通消息事件,例如
assistant、lifecycle、chat- 走广播逻辑
tool事件- 走定向分发逻辑
- 默认只发给"发起这次 run 的那条 WS 连接"
所以:
- A 连接发起
chat.send- A 能收到这次 run 的
tool - B 通常收不到
- A 能收到这次 run 的
- B 连接发起
chat.send- B 能收到这次 run 的
tool - A 通常收不到
- B 能收到这次 run 的
这并非异常,而是 OpenClaw 的默认设计语义,也是我们适配鼎道产品时需要重点关注的底层逻辑。
根本原因:从源码到鼎道场景的落地分析
tool 事件不走广播逻辑
OpenClaw 在处理 agent 事件时,会先判断事件类型是否为 tool,并分流处理:
ts
const isToolEvent = evt.stream === "tool";
然后按两种路径处理:
- 如果是
tool事件,调用broadcastToConnIds(...)定向发送; - 如果不是
tool事件,调用broadcast(...)广播。
这意味着,在 DingOS 和 PSUIP 的研发场景中,tool 是"连接级定向消息",而其他事件是"会话级广播消息",二者的分发范围天然不同。
tool 仅发给已注册的连接
OpenClaw 在处理 chat.send(对应 DingOS 智能助手的交互触发、PSUIP 的前端指令提交)时,会将发起请求的 connId 注册为本次 runId 的 tool 事件接收方,注册条件为:
- 当前连接存在有效
connId; - 连接在
connect.caps中声明了tool-events。
满足条件时执行:
ts
registerToolEventRecipient(runId, connId);
后续该 runId 产生的 tool 事件,仅会发送给这个注册的 connId------这也是鼎道不同研发页面收不到跨连接 tool 事件的核心原因。
tool 只发给已注册的 recipient
OpenClaw 在处理 chat.send 启动一次 run 时,把当前发起请求的 connId 注册到本次 runId 的 tool 事件接收方,注册条件为:。
- 当前连接存在有效
connId - 连接在
connect.caps里声明了tool-events
只有满足这两个条件,后端才会执行:
ts
registerToolEventRecipient(runId, connId)
后续该 runId 产生的 tool 事件,仅会发送给这个注册的 connId,这也是鼎道不同研发页面收不到跨连接 tool 事件的核心原因。
新 WS 连接会生成新 connId
在 OpenClaw 网关源码中,每次新建 WS 连接都会生成全新的 connId(UUID):
ts
const connId = randomUUID();
这意味着,即便 DingOS 控制台和 PSUIP 调试页面使用相同的 gatewayUrl、token、device.id、client.id,只要是两次独立的 WS 连接,就会生成不同的 connId。例如:
- DingOS 控制台的 WS 连接:
connId=fd95xxxx; - PSUIP 调试页面的 WS 连接:
connId=abf396xxxx。
二者属于完全独立的连接,自然无法共享定向分发的 tool 事件。
研发场景下的源码链路参考
结合 OpenClaw 的实际适配,我梳理了与该问题直接相关的核心源码位置,便于定位和调整:
chat.send 时注册 tool 接收方
文件:src/gateway/server-methods/chat.ts
关键逻辑:
ts
const connId = typeof client?.connId === "string" ? client.connId : undefined;
const wantsToolEvents = hasGatewayClientCap(
client?.connect?.caps,
GATEWAY_CLIENT_CAPS.TOOL_EVENTS,
);
if (connId && wantsToolEvents) {
context.registerToolEventRecipient(runId, connId);
}
在 DingOS 场景中,这段逻辑决定了"哪条连接发起智能助手交互,哪条连接就会被注册为 tool 接收方"。
tool 与普通消息分开发送
文件:src/gateway/server-chat.ts
关键逻辑:
ts
const isToolEvent = evt.stream === "tool";
if (isToolEvent) {
const recipients = toolEventRecipients.get(evt.runId);
if (recipients && recipients.size > 0) {
broadcastToConnIds("agent", toolPayload, recipients);
}
} else {
broadcast("agent", agentPayload);
}
这也是鼎道多端场景下,tool 事件仅单端可见的核心代码逻辑。
定向发送的核心实现
文件:src/gateway/server-broadcast.ts
关键逻辑:
ts
const broadcastToConnIds = (event, payload, connIds, opts) => {
if (connIds.size === 0) {
return;
}
broadcastInternal(event, payload, opts, connIds);
};
该方法仅向指定 connIds 发送消息,而非所有在线客户端,直接导致多端无法共享 tool 事件。
为什么普通消息在多端能正常同步?
在 调试中,stream=assistant、stream=lifecycle、event chat 等普通消息能正常同步,核心原因是这些事件走广播路径,不受 connId 限制。这也解释了研发过程中"文本回复多端可见,但工具调用仅单端可见"的现象------并非我们前端代码问题,而是 OpenClaw 后端分发策略的差异。
在研发场景下的日志排查方法
在调试相关功能时,可通过以下日志特征定位 tool 事件问题:
判断 tool 是否定向发送
日志中若出现:
text
stream=tool ... targeted clients=2 targets=1
说明当前网关下有 2 条在线连接,但该 tool 仅定向发送给 1 条,符合默认逻辑。
定位发起 run 的连接
日志中若出现:
text
[ws] ⇄ res ✓ chat.send ... conn=fd95...
说明本次 chat.send由 fd95... 连接发起,后续 tool 仅会发送给该连接。
确认前端连接的 connId
日志中若出现:
text
[ws] webchat connected conn=abf396...
说明当前调试页面的连接 ID 为 abf396...,若本次 run 由 fd95... 发起,则调试页面仅能收到广播事件,无法收到 tool。
研发过程中的常见误区(鼎道场景适配)
误区一:同一 sessionKey 应共享 tool 事件
实际:OpenClaw 中 tool 按 runId -> connId 绑定分发,而非仅按 sessionKey 广播,需针对性调整。
误区二:相同 token/device/client 参数复用 connId
实际:connId 是连接级 UUID,每次新建连接都会重新生成,即便 调试页面参数完全一致,也会生成不同 connId。
误区三:前端未显示 tool 就是渲染 Bug
实际:更常见的原因是后端仅将 tool 发送给发起 run 的连接,当前前端并非该连接,因此天然收不到。
公司业务场景下的解决方案
针对公司业务场景需求,我们梳理了三种适配方案,兼顾"无源码修改""快速魔改""长期适配"的不同诉求:
无源码修改:利用 session.tool 订阅能力
若不想修改 OpenClaw 核心源码,可借助 OpenClaw 源码层面的 session.tool 事件能力(非官方文档明确特性),适配 DingOS/PSUIP 的多端观测需求:
- WS 连接成功后,客户端主动调用订阅指令:
json
{
"method": "sessions.subscribe",
"params": {}
}
- 前端监听
session.tool事件(而非原生tool事件)。
该方案已在鼎道研发环境验证有效:调试页面通过订阅 session.tool,可同步接收 run 产生的工具生命周期事件,无需修改后端源码,快速解决联调过程中的信息断层问题。
快速魔改:调整 tool 事件分发逻辑
可直接修改 OpenClaw 安装包中的事件分发逻辑,让 tool 既定向发送又广播:
js
if (isToolEvent) {
if ((typeof evt.data?.phase === "string" ? evt.data.phase : "") === "start" && isControlUiVisible && sessionKey && !isAborted) {
flushBufferedChatDeltaIfNeeded(sessionKey, clientRunId, evt.runId, evt.seq);
}
const recipients = toolEventRecipients.get(evt.runId);
if (recipients && recipients.size > 0) {
broadcastToConnIds("agent", sessionKey ? {
...toolPayload,
...buildSessionEventSnapshot(sessionKey)
} : toolPayload, recipients);
}
// 新增广播逻辑,适配鼎道多端场景
broadcast("agent", toolPayload);
}
该修改已在鼎道测试环境验证:
- 原始逻辑下,只有发起当前 run 的连接能收到
tool - 打开
broadcast("agent", toolPayload)后,其他 WS 客户端也能同步收到tool
这说明当前问题并不是客户端无法解析 tool,而是后端默认没有把 tool 做广播。
总结
在鼎道各项产品的研发过程中,"不同 WS 连接无法共享 OpenClaw tool 事件"的问题,核心根源是 OpenClaw 对 tool 事件的定向分发设计------tool 仅发送给发起 run 的连接,且每次新 WS 连接都会生成新 connId。
我们研发团队通过深入解析 OpenClaw 源码,结合自身业务场景,梳理出"无源码修改""快速魔改"等解决方案,既解决了当下多端调试的痛点,也为后续产品迭代提供了技术支撑。未来,鼎道智联将持续深耕智能交互、前端一体化等领域,结合 OpenClaw 等优秀开源技术的适配经验,不断优化 DingOS、PSUIP 等产品的研发效率与用户体验,同时积极回馈开源社区,推动技术生态的共同发展。