From 892db9f9bed9595dd46ce095ee9babe5abf005c4 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 16 May 2026 10:42:36 +0100 Subject: [PATCH] feat(plugin): record non-wakeup messages as session history (no model) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously a non-wakeup message returned immediately and was fully discarded — the agent kept zero record of it, so when later woken in a discuss/work channel it replied without the conversation context. Now non-wakeup messages are ingested into the agent's OpenClaw session via recordInboundSession (createIfMissing) WITHOUT dispatch: the real model is not invoked and nothing is sent back to Fabric. This is correct for the turn engine — only the woken speaker emits a normal message or /no-reply; non-woken agents stay silent — while still giving the agent full channel context whenever it IS woken. Verified live: report-channel (all recipients wakeup=false) message logs 'recorded (no wakeup, history only)' with 0 dispatch/deliver/ posted; wakeup path unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- dist/fabric/src/inbound.js | 38 +++++++++++++++++------- src/inbound.ts | 59 ++++++++++++++++++++++++++++---------- 2 files changed, 71 insertions(+), 26 deletions(-) diff --git a/dist/fabric/src/inbound.js b/dist/fabric/src/inbound.js index 317bd73..5e82e79 100644 --- a/dist/fabric/src/inbound.js +++ b/dist/fabric/src/inbound.js @@ -162,12 +162,6 @@ export class FabricInbound { return out; } async dispatch(agentId, guild, channelId, m, session) { - // wakeup === false -> drop (Fabric already decided this agent is silent) - if (m.wakeup !== true) { - this.log.info(`fabric: drop (no wakeup) agent=${agentId} channel=${channelId}`); - return; - } - this.log.info(`fabric: dispatch agent=${agentId} channel=${channelId}`); const core = this.core; const cfg = this.cfg; try { @@ -180,11 +174,7 @@ export class FabricInbound { const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId: route.agentId, }); - const gt = await this.freshGuildToken(agentId, guild.nodeId, session); - // Fetch any uploaded files for the agent: download to a temp dir and - // hand openclaw local MediaPaths (+types) so the model receives them. - const media = await this.fetchAttachments(agentId, guild.endpoint, gt, m); - const ctxPayload = core.channel.reply.finalizeInboundContext({ + const baseCtx = { Body: m.content, BodyForAgent: m.content, RawBody: m.content, @@ -202,6 +192,32 @@ export class FabricInbound { Timestamp: m.createdAt ? Date.parse(m.createdAt) : Date.now(), OriginatingChannel: 'fabric', OriginatingTo: `fabric:${channelId}`, + }; + // Non-wakeup: Fabric has already decided this agent is NOT the speaker + // this round. Do NOT run the model and do NOT send anything back — the + // discuss/work turn engine expects silence from non-woken agents (only + // the woken speaker emits a normal message or /no-reply). We still + // record the message into the agent's session so it has the full + // channel conversation as context whenever it IS later woken. + if (m.wakeup !== true) { + const ctxPayload = core.channel.reply.finalizeInboundContext(baseCtx); + await core.channel.session.recordInboundSession({ + storePath, + sessionKey: route.sessionKey, + ctx: ctxPayload, + createIfMissing: true, + onRecordError: (err) => this.log.warn(`fabric: history record failed agent=${agentId}: ${String(err)}`), + }); + this.log.info(`fabric: recorded (no wakeup, history only) agent=${agentId} channel=${channelId}`); + return; + } + this.log.info(`fabric: dispatch agent=${agentId} channel=${channelId}`); + const gt = await this.freshGuildToken(agentId, guild.nodeId, session); + // Fetch any uploaded files for the agent: download to a temp dir and + // hand openclaw local MediaPaths (+types) so the model receives them. + const media = await this.fetchAttachments(agentId, guild.endpoint, gt, m); + const ctxPayload = core.channel.reply.finalizeInboundContext({ + ...baseCtx, // Provide ONLY local paths. The guild file URL is on a private host // (e.g. localhost); openclaw's SSRF guard blocks re-fetching it, so // passing MediaUrls is both redundant (we already downloaded the diff --git a/src/inbound.ts b/src/inbound.ts index 0d82a57..65ef4d5 100644 --- a/src/inbound.ts +++ b/src/inbound.ts @@ -16,7 +16,16 @@ import type { IdentityRegistry } from './identity.js'; type Core = { channel: { routing: { resolveAgentRoute: (p: unknown) => { agentId: string; sessionKey: string; accountId?: string } }; - session: { resolveStorePath: (store: unknown, o: { agentId: string }) => string }; + session: { + resolveStorePath: (store: unknown, o: { agentId: string }) => string; + recordInboundSession: (p: { + storePath: string; + sessionKey: string; + ctx: unknown; + createIfMissing?: boolean; + onRecordError: (e: unknown) => void; + }) => Promise; + }; reply: { finalizeInboundContext: (p: Record) => unknown }; }; }; @@ -202,13 +211,6 @@ export class FabricInbound { m: FabricMessage, session: FabricSession, ): Promise { - // wakeup === false -> drop (Fabric already decided this agent is silent) - if (m.wakeup !== true) { - this.log.info(`fabric: drop (no wakeup) agent=${agentId} channel=${channelId}`); - return; - } - this.log.info(`fabric: dispatch agent=${agentId} channel=${channelId}`); - const core = this.core as Core & Record; const cfg = this.cfg as { session?: { store?: unknown } }; try { @@ -222,13 +224,7 @@ export class FabricInbound { agentId: route.agentId, }); - const gt = await this.freshGuildToken(agentId, guild.nodeId, session); - - // Fetch any uploaded files for the agent: download to a temp dir and - // hand openclaw local MediaPaths (+types) so the model receives them. - const media = await this.fetchAttachments(agentId, guild.endpoint, gt, m); - - const ctxPayload = core.channel.reply.finalizeInboundContext({ + const baseCtx: Record = { Body: m.content, BodyForAgent: m.content, RawBody: m.content, @@ -246,6 +242,39 @@ export class FabricInbound { Timestamp: m.createdAt ? Date.parse(m.createdAt) : Date.now(), OriginatingChannel: 'fabric', OriginatingTo: `fabric:${channelId}`, + }; + + // Non-wakeup: Fabric has already decided this agent is NOT the speaker + // this round. Do NOT run the model and do NOT send anything back — the + // discuss/work turn engine expects silence from non-woken agents (only + // the woken speaker emits a normal message or /no-reply). We still + // record the message into the agent's session so it has the full + // channel conversation as context whenever it IS later woken. + if (m.wakeup !== true) { + const ctxPayload = core.channel.reply.finalizeInboundContext(baseCtx); + await core.channel.session.recordInboundSession({ + storePath, + sessionKey: route.sessionKey, + ctx: ctxPayload, + createIfMissing: true, + onRecordError: (err: unknown) => + this.log.warn(`fabric: history record failed agent=${agentId}: ${String(err)}`), + }); + this.log.info( + `fabric: recorded (no wakeup, history only) agent=${agentId} channel=${channelId}`, + ); + return; + } + + this.log.info(`fabric: dispatch agent=${agentId} channel=${channelId}`); + const gt = await this.freshGuildToken(agentId, guild.nodeId, session); + + // Fetch any uploaded files for the agent: download to a temp dir and + // hand openclaw local MediaPaths (+types) so the model receives them. + const media = await this.fetchAttachments(agentId, guild.endpoint, gt, m); + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + ...baseCtx, // Provide ONLY local paths. The guild file URL is on a private host // (e.g. localhost); openclaw's SSRF guard blocks re-fetching it, so // passing MediaUrls is both redundant (we already downloaded the