fix(openclaw): normalize engine messages for discord

This commit is contained in:
JerrettDavis 2026-04-06 22:18:37 -05:00
parent 8af7971fb2
commit 2edff08668
6 changed files with 313 additions and 25 deletions

View file

@ -20,6 +20,7 @@ export interface OpenAIMessage {
tool_calls?: any[];
tool_call_id?: string;
name?: string;
_headroomMeta?: Record<string, unknown>;
}
/**
@ -29,12 +30,24 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] {
const result: OpenAIMessage[] = [];
for (const msg of messages) {
const role = msg.role;
const normalized = normalizeAgentMessage(msg);
const role = normalized.role;
const buildMeta = (): Record<string, unknown> => {
const meta = { ...normalized } as Record<string, unknown>;
delete meta.role;
delete meta.content;
return meta;
};
if (role === "system") {
result.push({
role: "system",
content: typeof msg.content === "string" ? msg.content : extractText(msg.content),
content:
typeof normalized.content === "string"
? normalized.content
: extractText(normalized.content),
_headroomMeta: buildMeta(),
});
continue;
}
@ -42,15 +55,19 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] {
if (role === "user") {
result.push({
role: "user",
content: typeof msg.content === "string" ? msg.content : extractText(msg.content),
content:
typeof normalized.content === "string"
? normalized.content
: extractText(normalized.content),
_headroomMeta: buildMeta(),
});
continue;
}
if (role === "assistant") {
const content = msg.content;
const content = normalized.content;
if (typeof content === "string") {
result.push({ role: "assistant", content });
result.push({ role: "assistant", content, _headroomMeta: buildMeta() });
continue;
}
@ -87,6 +104,7 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] {
const openaiMsg: OpenAIMessage = {
role: "assistant",
content: textParts.length > 0 ? textParts.join("") : null,
_headroomMeta: buildMeta(),
};
if (toolCalls.length > 0) {
openaiMsg.tool_calls = toolCalls;
@ -98,16 +116,21 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] {
if (role === "toolResult" || role === "tool_result") {
const content =
typeof msg.content === "string"
? msg.content
: Array.isArray(msg.content)
? extractText(msg.content)
: JSON.stringify(msg.content);
typeof normalized.content === "string"
? normalized.content
: Array.isArray(normalized.content)
? extractText(normalized.content)
: JSON.stringify(normalized.content);
result.push({
role: "tool",
content,
tool_call_id: msg.tool_use_id ?? msg.toolCallId ?? msg.id ?? "unknown",
tool_call_id:
normalized.toolCallId ??
normalized.tool_use_id ??
normalized.id ??
"unknown",
_headroomMeta: buildMeta(),
});
continue;
}
@ -115,7 +138,11 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] {
// Fallback: pass through as user message
result.push({
role: "user",
content: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content),
content:
typeof normalized.content === "string"
? normalized.content
: JSON.stringify(normalized.content),
_headroomMeta: buildMeta(),
});
}
@ -129,11 +156,15 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] {
const result: any[] = [];
for (const msg of messages) {
const meta = (msg._headroomMeta ?? {}) as Record<string, unknown>;
const timestamp =
typeof meta.timestamp === "number" ? meta.timestamp : Date.now();
if (msg.role === "system") {
result.push({
role: "system",
content: msg.content ?? "",
timestamp: Date.now(),
timestamp,
});
continue;
}
@ -142,7 +173,7 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] {
result.push({
role: "user",
content: msg.content ?? "",
timestamp: Date.now(),
timestamp,
});
continue;
}
@ -172,9 +203,26 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] {
// OpenClaw's Pi agent expects content to always be an array for assistant messages
// (it calls .flatMap() on it). Never flatten to a string.
result.push({
...(meta as object),
role: "assistant",
content: blocks,
timestamp: Date.now(),
api: typeof meta.api === "string" ? meta.api : "headroom",
provider: typeof meta.provider === "string" ? meta.provider : "headroom",
model: typeof meta.model === "string" ? meta.model : "headroom",
usage:
isRecord(meta.usage)
? meta.usage
: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason:
typeof meta.stopReason === "string" ? meta.stopReason : "stop",
timestamp,
});
continue;
}
@ -188,12 +236,18 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] {
: JSON.stringify(msg.content);
const toolCallId = msg.tool_call_id ?? "unknown";
result.push({
...(meta as object),
role: "toolResult",
// OpenClaw transport layers expect toolResult content blocks, not a raw string.
content: [{ type: "text", text: textContent }],
toolCallId,
tool_use_id: toolCallId,
timestamp: Date.now(),
toolCallId:
typeof meta.toolCallId === "string" ? meta.toolCallId : toolCallId,
tool_use_id:
typeof meta.tool_use_id === "string" ? meta.tool_use_id : toolCallId,
toolName:
typeof meta.toolName === "string" ? meta.toolName : "headroom",
isError: typeof meta.isError === "boolean" ? meta.isError : false,
timestamp,
});
continue;
}
@ -202,6 +256,10 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] {
return result;
}
export function normalizeAgentMessages(messages: any[]): any[] {
return messages.map((message) => normalizeAgentMessage(message));
}
/**
* Extract text from content blocks.
*/
@ -221,3 +279,140 @@ function extractText(content: any): string {
.filter(Boolean)
.join("\n");
}
function normalizeAgentMessage(message: any): any {
if (!isRecord(message)) return message;
if (message.role === "assistant") {
return normalizeAssistantMessage(message);
}
if (message.role === "toolResult" || message.role === "tool_result") {
return normalizeToolResultMessage(message);
}
return message;
}
function normalizeAssistantMessage(message: Record<string, any>): Record<string, any> {
const normalizedContent = normalizeAssistantContent(message.content);
return {
...message,
content: normalizedContent,
api: typeof message.api === "string" ? message.api : "headroom",
provider: typeof message.provider === "string" ? message.provider : "headroom",
model: typeof message.model === "string" ? message.model : "headroom",
usage: isRecord(message.usage)
? message.usage
: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: typeof message.stopReason === "string" ? message.stopReason : "stop",
timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now(),
};
}
function normalizeToolResultMessage(message: Record<string, any>): Record<string, any> {
const normalizedContent = normalizeToolResultContent(message.content);
const toolCallId =
typeof message.toolCallId === "string"
? message.toolCallId
: typeof message.tool_use_id === "string"
? message.tool_use_id
: typeof message.id === "string"
? message.id
: "unknown";
return {
...message,
role: "toolResult",
content: normalizedContent,
toolCallId,
tool_use_id:
typeof message.tool_use_id === "string" ? message.tool_use_id : toolCallId,
toolName: typeof message.toolName === "string" ? message.toolName : "headroom",
isError: typeof message.isError === "boolean" ? message.isError : false,
timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now(),
};
}
function normalizeAssistantContent(content: unknown): any[] {
if (Array.isArray(content)) {
return content.flatMap((block) => {
if (typeof block === "string") return [{ type: "text", text: block }];
if (!isRecord(block) || typeof block.type !== "string") return [];
if (block.type === "text" && typeof block.text === "string") return [block];
if (block.type === "thinking" && typeof block.thinking === "string") return [block];
if (
(block.type === "toolCall" || block.type === "tool_use") &&
typeof block.name === "string"
) {
return [
{
type: "toolCall",
id: typeof block.id === "string" ? block.id : "unknown",
name: block.name,
arguments:
"arguments" in block
? block.arguments
: "input" in block
? block.input
: {},
},
];
}
return [];
});
}
if (typeof content === "string" && content.length > 0) {
return [{ type: "text", text: content }];
}
if (content == null) {
return [];
}
return [{ type: "text", text: JSON.stringify(content) }];
}
function normalizeToolResultContent(content: unknown): any[] {
if (Array.isArray(content)) {
return content.flatMap((block) => {
if (typeof block === "string") return [{ type: "text", text: block }];
if (!isRecord(block) || typeof block.type !== "string") return [];
if (block.type === "text" && typeof block.text === "string") return [block];
if (
block.type === "image" &&
typeof block.data === "string" &&
typeof block.mimeType === "string"
) {
return [block];
}
if (block.type === "tool_result" && "content" in block) {
return normalizeToolResultContent(block.content);
}
return [];
});
}
if (typeof content === "string" && content.length > 0) {
return [{ type: "text", text: content }];
}
if (content == null) {
return [];
}
return [{ type: "text", text: JSON.stringify(content) }];
}
function isRecord(value: unknown): value is Record<string, any> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -9,7 +9,7 @@
import { compress } from "headroom-ai";
import { ProxyManager, defaultLogger, type ProxyManagerConfig, type ProxyManagerLogger } from "./proxy-manager.js";
import { agentToOpenAI, openAIToAgent } from "./convert.js";
import { agentToOpenAI, normalizeAgentMessages, openAIToAgent } from "./convert.js";
export interface HeadroomEngineConfig extends ProxyManagerConfig {
enabled?: boolean;
@ -96,7 +96,7 @@ export class HeadroomContextEngine {
}> {
if (!this.proxyUrl || this.config.enabled === false) {
// Fallback: return messages unchanged
return { messages: params.messages, estimatedTokens: 0 };
return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
}
try {
@ -112,7 +112,10 @@ export class HeadroomContextEngine {
} as any);
if (!result.compressed || result.tokensSaved === 0) {
return { messages: params.messages, estimatedTokens: result.tokensBefore };
return {
messages: normalizeAgentMessages(params.messages),
estimatedTokens: result.tokensBefore,
};
}
// Convert back to AgentMessage format
@ -138,7 +141,7 @@ export class HeadroomContextEngine {
} catch (error) {
this.logger.error(`Assemble failed: ${error}`);
// Graceful fallback: return original messages
return { messages: params.messages, estimatedTokens: 0 };
return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
}
}

View file

@ -1,5 +1,5 @@
export { default } from "./plugin/index.js";
export { HeadroomContextEngine } from "./engine.js";
export { ProxyManager, normalizeAndValidateProxyUrl, isLocalProxyUrl, defaultLogger, probeHeadroomProxy } from "./proxy-manager.js";
export { agentToOpenAI, openAIToAgent } from "./convert.js";
export { agentToOpenAI, normalizeAgentMessages, openAIToAgent } from "./convert.js";
export { createHeadroomRetrieveTool } from "./tools/headroom-retrieve.js";

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { openAIToAgent, type OpenAIMessage } from "../src/convert";
import { agentToOpenAI, normalizeAgentMessages, openAIToAgent, type OpenAIMessage } from "../src/convert";
describe("openAIToAgent", () => {
it("emits toolResult content as blocks so transports can safely filter", () => {
@ -26,3 +26,71 @@ describe("openAIToAgent", () => {
expect(toolResult.tool_use_id).toBe("call_123");
});
});
describe("normalizeAgentMessages", () => {
it("normalizes assistant string content into OpenClaw blocks", () => {
const result = normalizeAgentMessages([
{
role: "assistant",
content: "hello from headroom",
},
]);
expect(result[0]).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "hello from headroom" }],
api: "headroom",
provider: "headroom",
model: "headroom",
stopReason: "stop",
});
});
it("normalizes tool result string content into OpenClaw blocks", () => {
const result = normalizeAgentMessages([
{
role: "toolResult",
content: "tool output",
},
]);
expect(result[0]).toMatchObject({
role: "toolResult",
content: [{ type: "text", text: "tool output" }],
toolCallId: "unknown",
tool_use_id: "unknown",
toolName: "headroom",
isError: false,
});
});
});
describe("agentToOpenAI", () => {
it("captures assistant metadata needed for OpenClaw round-trips", () => {
const result = agentToOpenAI([
{
role: "assistant",
content: "hello",
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "stop",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
},
]);
expect(result[0]._headroomMeta).toMatchObject({
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
stopReason: "stop",
});
});
});

View file

@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { HeadroomContextEngine } from "../src/engine.js";
describe("HeadroomContextEngine", () => {
it("normalizes pass-through assistant messages when no proxy is available", async () => {
const engine = new HeadroomContextEngine({ enabled: false });
const result = await engine.assemble({
sessionId: "test-session",
messages: [
{ role: "user", content: "hi", timestamp: Date.now() },
{ role: "assistant", content: "hello there", timestamp: Date.now() },
],
});
expect(result.messages[1]).toMatchObject({
role: "assistant",
content: [{ type: "text", text: "hello there" }],
});
});
});

View file

@ -25,7 +25,8 @@ describe("AgentMessage conversion", () => {
it("converts user message", () => {
const agent = [{ role: "user", content: "hello", timestamp: Date.now() }];
const openai = agentToOpenAI(agent);
expect(openai).toEqual([{ role: "user", content: "hello" }]);
expect(openai).toHaveLength(1);
expect(openai[0]).toMatchObject({ role: "user", content: "hello" });
});
it("converts assistant with tool_use blocks", () => {