feat(fabric): coalesce a split agent turn into ONE message (deterministic)
OpenClaw delivers an agent turn whose blocks are text -> thinking/tool -> text via multiple inbound deliver() calls (a non-text block is a delivery boundary), so one turn became N Fabric messages. Fix: buffer deliver() segments per channel (src/coalesce.ts) and flush them as ONE postMessage at a deterministic boundary — the finally after dispatchInboundReplyWithBase() resolves, which provably runs only after every deliver() of the turn (verified: deliver,deliver -> dispatch returned -> flush). No hooks, no timers, no idle guessing. The agent_end hook was rejected: it fires BEFORE deliver(). gateway_stop flushes any leftover; a long safety timeout is a leak-guard only. channels.fabric.coalesce=false restores raw per-segment posting. Verified on local openclaw + Fabric with a fake text/thinking/text model: single trigger -> exactly one merged message. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
dist/fabric/src/accounts.js
vendored
5
dist/fabric/src/accounts.js
vendored
@@ -13,6 +13,11 @@ function section(cfg) {
|
||||
export function resolveCommandsSyncKey(cfg) {
|
||||
return (section(cfg).commandsSyncKey ?? '').trim();
|
||||
}
|
||||
// Whether to coalesce a split agent turn into one Fabric message
|
||||
// (channel-level). Default true.
|
||||
export function resolveCoalesce(cfg) {
|
||||
return (cfg.channels?.fabric ?? {}).coalesce !== false;
|
||||
}
|
||||
export function listFabricAccountIds(cfg) {
|
||||
const accts = section(cfg).accounts ?? {};
|
||||
const ids = Object.keys(accts);
|
||||
|
||||
5
dist/fabric/src/channel.js
vendored
5
dist/fabric/src/channel.js
vendored
@@ -137,7 +137,10 @@ export const fabricChannelPlugin = createChatChannelPlugin({
|
||||
attachedResults: {
|
||||
channel: 'fabric',
|
||||
sendText: async (ctx) => {
|
||||
// openclaw passes config under cfg or config depending on path
|
||||
// openclaw passes config under cfg or config depending on path.
|
||||
// Note: inbound agent replies go through inbound.ts `deliver`
|
||||
// (where turn coalescing happens). This path is for any direct
|
||||
// outbound sends and posts immediately.
|
||||
const cfg = (ctx.cfg ?? ctx.config ?? {});
|
||||
try {
|
||||
const r = await sendToFabric(cfg, ctx.accountId ?? null, ctx.to, ctx.text);
|
||||
|
||||
75
dist/fabric/src/coalesce.js
vendored
Normal file
75
dist/fabric/src/coalesce.js
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
// Deterministic turn coalescer.
|
||||
//
|
||||
// OpenClaw calls the Fabric `deliver` callback once per assistant text
|
||||
// segment; a thinking/tool block between two text blocks is a delivery
|
||||
// boundary, so one agent turn of `text → thinking/tool → text` arrives as
|
||||
// multiple deliver() calls. There is no turn id on the delivery, so we
|
||||
// BUFFER segments by Fabric channelId and post the merged message when the
|
||||
// turn truly ends. The flush is driven by inbound.ts right after
|
||||
// `dispatchInboundReplyWithBase` resolves — that only happens AFTER every
|
||||
// deliver() of the turn, a deterministic boundary (NOT a timer, NOT the
|
||||
// agent_end hook, which fires before deliver()). `coalesce=false` posts
|
||||
// each segment immediately.
|
||||
const SAFETY_FLUSH_MS = 120_000; // leak-guard only; not the flush mechanism
|
||||
export function normChannelId(x) {
|
||||
const s = String(x ?? '');
|
||||
return s.startsWith('fabric:') ? s.slice('fabric:'.length) : s;
|
||||
}
|
||||
const pendingByChannel = new Map();
|
||||
async function flushChannel(channelId, reason) {
|
||||
const p = pendingByChannel.get(channelId);
|
||||
if (!p)
|
||||
return;
|
||||
pendingByChannel.delete(channelId);
|
||||
clearTimeout(p.safety);
|
||||
const text = p.parts.join('\n\n').trim();
|
||||
if (!text)
|
||||
return;
|
||||
try {
|
||||
await p.post(text);
|
||||
p.log?.(`fabric: flushed ${p.parts.length} segment(s) channel=${channelId} (${reason})`);
|
||||
}
|
||||
catch (e) {
|
||||
p.log?.(`fabric: flush FAILED channel=${channelId} (${reason}): ${String(e)}`);
|
||||
}
|
||||
}
|
||||
// Buffer one delivered segment (or send immediately when coalesce=false).
|
||||
// `post` performs the real Fabric postMessage with the caller's already
|
||||
// resolved guild/token; on flush it is called once with the merged text.
|
||||
export async function enqueueDelivery(params) {
|
||||
const cid = normChannelId(params.channelId);
|
||||
const text = (params.text ?? '').trim();
|
||||
if (!text)
|
||||
return;
|
||||
if (!params.coalesce) {
|
||||
await params.post(text);
|
||||
return;
|
||||
}
|
||||
const existing = pendingByChannel.get(cid);
|
||||
if (existing) {
|
||||
existing.parts.push(text);
|
||||
existing.post = params.post; // freshest guild/token closure
|
||||
existing.log = params.log;
|
||||
}
|
||||
else {
|
||||
pendingByChannel.set(cid, {
|
||||
parts: [text],
|
||||
post: params.post,
|
||||
log: params.log,
|
||||
safety: setTimeout(() => void flushChannel(cid, 'safety-timeout'), SAFETY_FLUSH_MS),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Called by the agent_end hook with the hook ctx's channelId (bare or
|
||||
// fabric:-prefixed). Deterministic per-turn boundary.
|
||||
export async function flushFabricForChannel(rawChannelId) {
|
||||
const cid = normChannelId(rawChannelId);
|
||||
if (cid)
|
||||
await flushChannel(cid, 'dispatch-end');
|
||||
}
|
||||
// gateway_stop: flush anything still buffered.
|
||||
export async function flushAllFabric() {
|
||||
for (const cid of [...pendingByChannel.keys()]) {
|
||||
await flushChannel(cid, 'gateway_stop');
|
||||
}
|
||||
}
|
||||
21
dist/fabric/src/inbound.js
vendored
21
dist/fabric/src/inbound.js
vendored
@@ -3,6 +3,8 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { io } from 'socket.io-client';
|
||||
import { dispatchInboundReplyWithBase } from 'openclaw/plugin-sdk/inbound-reply-dispatch';
|
||||
import { resolveCoalesce } from './accounts.js';
|
||||
import { enqueueDelivery, flushFabricForChannel } from './coalesce.js';
|
||||
export class FabricInbound {
|
||||
core;
|
||||
cfg;
|
||||
@@ -244,8 +246,16 @@ export class FabricInbound {
|
||||
this.log.info(`fabric: deliver agent=${agentId} channel=${channelId} len=${text.length}`);
|
||||
if (!text || !gt)
|
||||
return;
|
||||
await this.client.postMessage(guild.endpoint, gt, channelId, text, session.user.id);
|
||||
this.log.info(`fabric: posted reply agent=${agentId} channel=${channelId}`);
|
||||
// Buffer segments; the merged message is posted right after
|
||||
// dispatch returns (the deterministic turn boundary, see the
|
||||
// finally below). Disable per channel: channels.fabric.coalesce.
|
||||
await enqueueDelivery({
|
||||
channelId,
|
||||
text,
|
||||
coalesce: resolveCoalesce(this.cfg),
|
||||
post: (t) => this.client.postMessage(guild.endpoint, gt, channelId, t, session.user.id),
|
||||
log: (m) => this.log.info(m),
|
||||
});
|
||||
},
|
||||
onRecordError: (err) => this.log.warn(`fabric: session record failed agent=${agentId}: ${String(err)}`),
|
||||
onDispatchError: (err, info) => this.log.warn(`fabric: ${info.kind} dispatch failed agent=${agentId}: ${String(err)}`),
|
||||
@@ -270,5 +280,12 @@ export class FabricInbound {
|
||||
catch (err) {
|
||||
this.log.warn(`fabric: dispatch failed agent=${agentId} channel=${channelId}: ${String(err)}`);
|
||||
}
|
||||
finally {
|
||||
// Deterministic per-turn boundary: dispatchInboundReplyWithBase only
|
||||
// resolves AFTER every deliver() call of this turn has run, so the
|
||||
// buffer now holds all segments — flush them as ONE Fabric message.
|
||||
// No hooks, no timers, no idle guessing.
|
||||
await flushFabricForChannel(channelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user