3 Commits

Author SHA1 Message Date
h z
689e3da0ba Merge pull request 'fix(bridge): recover inline-prefixed metadata in user message body' (#4) from fix/inline-metadata-strip into main 2026-05-31 19:52:39 +00:00
0f49edf59c fix(bridge): recover inline-prefixed metadata in user message body
OpenClaw's 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 whole.

Fabric.OpenclawPlugin's dispatch does not split: it passes metadata
blocks and the real user content as ONE merged user-message body,
separated by blank lines. With the prior filter that meant the entire
turn was dropped with "no user message found" (HTTP 400) because the
first line matched a sentinel — the actual prompt sitting after the
metadata blocks never reached the bridge.

When the whole-body check fails for a single-message body, walk past
leading sentinel-prefixed blocks (sentinel header + optional ```json
code fence + blank-line separator) and use whatever non-metadata block
follows. Falls back to the previous "skip entirely" semantics when the
body is metadata-only.

End-user symptom that surfaced this: every contractor agent (Claude /
Gemini) subscribed to a Fabric channel silently failed to reply to
sub-discussion messages during recruitment — fabric dispatch said
"completed" in 1.6s but trajectory had `assistantTexts: []`,
`terminalError: non_deliverable_terminal_turn`,
`errorMessage: "400 \"no user message found\""`. Surfaced
recruiting developer1 on prod-t2 2026-05-31.
2026-05-31 20:52:15 +01:00
037e92b421 fix(bridge): /mcp/execute handles raw-object tool results (not just AgentToolResult)
OpenClaw plugins return tool results in one of two shapes:

  (a) AgentToolResult — { content: [{type:'text', text:'...'}] }
      used when the plugin wraps via asContent() helper. Every
      Dialectic.OpenclawPlugin tool follows this pattern.

  (b) raw JSON-able object — { ok:true, ...domain fields }
      used when the plugin returns data directly. Every
      Fabric.OpenclawPlugin tool follows this pattern
      (fabric-channel-list, fabric-guild-list, fabric-send-message,
      fabric-channel-set-purpose, etc).

The bridge's /mcp/execute handler only handled shape (a). When a
contractor agent (developer / contractor-test) called any fabric
tool through Claude Code, the bridge ran the tool successfully but
fell back to the literal string '(no result)' because
toolResult.content was undefined. Claude Code then dutifully
rendered '(no result)' as the tool result.

Reproduced on prod:
  openclaw agent --agent developer -m 'Call fabric-channel-list ...'
  → claude code session called mcp__openclaw__fabric-channel-list
  → bridge logged: mcp/execute tool=fabric-channel-list ...
  → bridge replied: { result: '(no result)' }
  → claude code rendered: ''

Fix: normalize the result in the bridge. If toolResult is null →
empty string; if it has a .content array → join the text segments
(shape a); if it's a string → use directly; else → JSON.stringify
the whole thing (shape b). Falls back to '(no result)' only when
all of those produce empty string.

Verified on prod after fix:
  agent receives real {"ok":true,"count":1,"channels":[...]}
  JSON payload (one real prod-push-test channel) in the response.
2026-05-24 09:33:12 +01:00
2 changed files with 97 additions and 8 deletions

View File

@@ -61,6 +61,51 @@ function isRuntimeContextMessage(text: string): boolean {
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.
*
@@ -85,7 +130,18 @@ export function extractLatestUserMessage(req: BridgeInboundRequest): string {
for (let i = userMessages.length - 1; i >= 0; i -= 1) {
const raw = messageText(userMessages[i]);
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 "";

View File

@@ -596,14 +596,47 @@ export function createBridgeServer(config: BridgeServerConfig): http.Server {
return;
}
const execFn = (toolInstance as { execute: (id: string, args: unknown) => Promise<{ content?: Array<{ type: string; text?: string }> }> }).execute;
const toolResult = await execFn(randomUUID(), toolArgs);
const execFn = (toolInstance as { execute: (id: string, args: unknown) => Promise<unknown> }).execute;
const toolResultRaw = await execFn(randomUUID(), toolArgs);
// Extract text content from AgentToolResult
const text = (toolResult.content ?? [])
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text as string)
.join("\n");
// Normalize the result shape. OpenClaw plugins return one of:
// (a) AgentToolResult — { content: [{type:'text', text:'...'}, ...] }
// used by tools that wrap via asContent() (e.g. all
// Dialectic.OpenclawPlugin tools).
// (b) raw JSON-able object — { ok:true, ...domain fields }
// used by tools that return data directly (e.g. every
// Fabric.OpenclawPlugin tool: fabric-channel-list,
// fabric-guild-list, fabric-send-message, etc).
// (c) null / undefined — fire-and-forget (rare).
//
// Pre-2026-05-24 the bridge ONLY handled shape (a); shape (b)
// silently returned "(no result)" to Claude Code (bug:
// fabric-channel-list and friends were unusable from contractor
// agents even though the tool actually ran successfully).
let text = "";
if (toolResultRaw == null) {
text = "";
} else if (
typeof toolResultRaw === "object" &&
Array.isArray((toolResultRaw as { content?: unknown }).content)
) {
// Shape (a)
const content = (toolResultRaw as { content: Array<{ type: string; text?: string }> }).content;
text = content
.filter((c) => c.type === "text" && c.text)
.map((c) => c.text as string)
.join("\n");
} else if (typeof toolResultRaw === "string") {
text = toolResultRaw;
} else {
// Shape (b) — serialize the whole object as JSON. Claude Code
// is happy to parse JSON tool results.
try {
text = JSON.stringify(toolResultRaw);
} catch {
text = String(toolResultRaw);
}
}
sendJson(res, 200, { result: text || "(no result)" });
} catch (err) {