fix(bridge): recover inline-prefixed metadata in user message body #4

Merged
hzhang merged 1 commits from fix/inline-metadata-strip into main 2026-05-31 19:52:40 +00:00
Showing only changes of commit 0f49edf59c - Show all commits

View File

@@ -61,6 +61,51 @@ function isRuntimeContextMessage(text: string): boolean {
return INBOUND_META_SENTINELS.includes(firstLine); return INBOUND_META_SENTINELS.includes(firstLine);
} }
/**
* Strip *prefixed* metadata blocks from a single user message body.
*
* Background: OpenClaw's older / canonical convention is to emit metadata
* envelopes (chat_id, sender, reply target, …) as SEPARATE user-role
* messages folded into the openai-completions request right after the real
* one. `extractLatestUserMessage` skips those separate messages whole.
*
* The Fabric channel plugin (Fabric.OpenclawPlugin) does not split: it
* passes the metadata blocks and the actual user content as ONE merged
* user-message body, separated by blank lines. Result: a Fabric inbound
* turn has a single user message whose first line matches a sentinel, so
* `isRuntimeContextMessage` returned true and the whole turn was dropped
* with "no user message found" (HTTP 400). Symptom: every contractor agent
* subscribed to a Fabric channel silently fails to reply.
*
* Strip leading sentinel-prefixed blocks (sentinel header + optional code
* fence + blank-line separator) until we hit a block whose first line is
* NOT a sentinel — that's the real prompt. Returns "" if the entire body
* is metadata-only (still a no-op turn, same as before).
*/
function stripPrefixedMetadataBlocks(raw: string): string {
// Split on blank-line block boundaries. Within a metadata block the JSON
// code fence has only single newlines, so it stays in the same chunk as
// its sentinel header.
const blocks = raw.split(/\n\n+/);
const kept: string[] = [];
let stillStripping = true;
for (const block of blocks) {
if (stillStripping) {
const trimmed = block.trimStart();
const firstLine = trimmed.split("\n", 1)[0].trim();
if (
INBOUND_META_SENTINELS.includes(firstLine) ||
trimmed.startsWith(LEGACY_RUNTIME_CONTEXT_MARKER)
) {
continue; // drop this prefix block
}
stillStripping = false;
}
kept.push(block);
}
return kept.join("\n\n").trim();
}
/** /**
* Extract the latest user-authored message from the OpenClaw request. * Extract the latest user-authored message from the OpenClaw request.
* *
@@ -85,7 +130,18 @@ export function extractLatestUserMessage(req: BridgeInboundRequest): string {
for (let i = userMessages.length - 1; i >= 0; i -= 1) { for (let i = userMessages.length - 1; i >= 0; i -= 1) {
const raw = messageText(userMessages[i]); const raw = messageText(userMessages[i]);
if (!raw) continue; if (!raw) continue;
if (isRuntimeContextMessage(raw)) continue; // First try the separate-message convention (Discord/Telegram path): a
// user message whose entire body is one metadata envelope — skip whole.
if (isRuntimeContextMessage(raw)) {
// ...but also try inline-prefixed-metadata recovery (Fabric path):
// some channels splice metadata + real content into one body. Walk
// past leading sentinel blocks; if any non-metadata block follows,
// that's the prompt. If nothing follows, this turn really is pure
// metadata and we keep skipping.
const stripped = stripPrefixedMetadataBlocks(raw);
if (stripped) return stripOpenClawTimestampPrefix(stripped);
continue;
}
return stripOpenClawTimestampPrefix(raw); return stripOpenClawTimestampPrefix(raw);
} }
return ""; return "";