mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(openclaw): normalize engine messages for discord
This commit is contained in:
parent
8af7971fb2
commit
2edff08668
6 changed files with 313 additions and 25 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
21
plugins/openclaw/test/engine-normalization.test.ts
Normal file
21
plugins/openclaw/test/engine-normalization.test.ts
Normal 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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue