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>
94 lines
3.2 KiB
TypeScript
94 lines
3.2 KiB
TypeScript
// agent = openclaw channel account.
|
|
// Config shape:
|
|
// channels.fabric.centerApiBase = "http://localhost:7001/api" (shared)
|
|
// channels.fabric.accounts.<agentId> = { fabricApiKey, centerApiBase? }
|
|
// Each account id IS the openclaw agentId that owns that Fabric identity.
|
|
|
|
export type FabricAccountConfig = {
|
|
fabricApiKey?: string;
|
|
centerApiBase?: string;
|
|
enabled?: boolean;
|
|
allowFrom?: string[];
|
|
dmPolicy?: string;
|
|
};
|
|
|
|
export type FabricChannelConfig = {
|
|
centerApiBase?: string;
|
|
// Shared secret matching the guild's FABRIC_BACKEND_GUILD_COMMANDS_SYNC_KEY
|
|
// (Guild C-2). Required by the channel config schema; sourced from config
|
|
// only — never from the environment.
|
|
commandsSyncKey?: string;
|
|
// Coalesce an agent turn that OpenClaw split into multiple deliveries
|
|
// (text → thinking/tool → text => N sendText calls) into ONE Fabric
|
|
// message. The flush boundary is the deterministic `agent_end` hook (not
|
|
// a timer). Default true; set false for raw per-segment posting.
|
|
coalesce?: boolean;
|
|
accounts?: Record<string, FabricAccountConfig>;
|
|
defaultAccount?: string;
|
|
} & FabricAccountConfig;
|
|
|
|
type Cfg = { channels?: { fabric?: FabricChannelConfig }; [k: string]: unknown };
|
|
|
|
export type ResolvedFabricAccount = {
|
|
accountId: string;
|
|
enabled: boolean;
|
|
centerApiBase: string;
|
|
fabricApiKey: string;
|
|
allowFrom: string[];
|
|
dmPolicy: string | undefined;
|
|
};
|
|
|
|
const DEFAULT_CENTER = 'http://localhost:7001/api';
|
|
|
|
function section(cfg: Cfg): FabricChannelConfig {
|
|
return cfg.channels?.fabric ?? {};
|
|
}
|
|
|
|
// The commands-sync shared secret (channel-level only). Empty string when
|
|
// unconfigured — callers decide how to handle (slash-command sync is then
|
|
// rejected by the guild).
|
|
export function resolveCommandsSyncKey(cfg: Cfg): string {
|
|
return (section(cfg).commandsSyncKey ?? '').trim();
|
|
}
|
|
|
|
// Whether to coalesce a split agent turn into one Fabric message
|
|
// (channel-level). Default true.
|
|
export function resolveCoalesce(cfg: Cfg): boolean {
|
|
return (cfg.channels?.fabric ?? {}).coalesce !== false;
|
|
}
|
|
|
|
export function listFabricAccountIds(cfg: Cfg): string[] {
|
|
const accts = section(cfg).accounts ?? {};
|
|
const ids = Object.keys(accts);
|
|
return ids.length ? ids : ['default'];
|
|
}
|
|
|
|
export function resolveDefaultFabricAccountId(cfg: Cfg): string {
|
|
const s = section(cfg);
|
|
if (s.defaultAccount) return s.defaultAccount;
|
|
const ids = listFabricAccountIds(cfg);
|
|
return ids[0] ?? 'default';
|
|
}
|
|
|
|
export function resolveFabricAccount(cfg: Cfg, accountId?: string | null): ResolvedFabricAccount {
|
|
const s = section(cfg);
|
|
const id = accountId ?? resolveDefaultFabricAccountId(cfg);
|
|
const acc = s.accounts?.[id] ?? {};
|
|
const fabricApiKey = (acc.fabricApiKey ?? '').trim();
|
|
const centerApiBase = (acc.centerApiBase ?? s.centerApiBase ?? DEFAULT_CENTER).trim();
|
|
return {
|
|
accountId: id,
|
|
enabled: acc.enabled !== false && s.enabled !== false,
|
|
centerApiBase,
|
|
fabricApiKey,
|
|
allowFrom: acc.allowFrom ?? s.allowFrom ?? [],
|
|
dmPolicy: acc.dmPolicy ?? s.dmPolicy,
|
|
};
|
|
}
|
|
|
|
export function listEnabledFabricAccounts(cfg: Cfg): ResolvedFabricAccount[] {
|
|
return listFabricAccountIds(cfg)
|
|
.map((id) => resolveFabricAccount(cfg, id))
|
|
.filter((a) => a.enabled && a.fabricApiKey);
|
|
}
|