JerrettDavis commited on
Commit
14ba239
·
1 Parent(s): 88e38ed

fix(openclaw): normalize engine messages for discord

Browse files
plugins/openclaw/src/convert.ts CHANGED
@@ -20,6 +20,7 @@ export interface OpenAIMessage {
20
  tool_calls?: any[];
21
  tool_call_id?: string;
22
  name?: string;
 
23
  }
24
 
25
  /**
@@ -29,12 +30,24 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] {
29
  const result: OpenAIMessage[] = [];
30
 
31
  for (const msg of messages) {
32
- const role = msg.role;
 
 
 
 
 
 
 
 
33
 
34
  if (role === "system") {
35
  result.push({
36
  role: "system",
37
- content: typeof msg.content === "string" ? msg.content : extractText(msg.content),
 
 
 
 
38
  });
39
  continue;
40
  }
@@ -42,15 +55,19 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] {
42
  if (role === "user") {
43
  result.push({
44
  role: "user",
45
- content: typeof msg.content === "string" ? msg.content : extractText(msg.content),
 
 
 
 
46
  });
47
  continue;
48
  }
49
 
50
  if (role === "assistant") {
51
- const content = msg.content;
52
  if (typeof content === "string") {
53
- result.push({ role: "assistant", content });
54
  continue;
55
  }
56
 
@@ -87,6 +104,7 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] {
87
  const openaiMsg: OpenAIMessage = {
88
  role: "assistant",
89
  content: textParts.length > 0 ? textParts.join("") : null,
 
90
  };
91
  if (toolCalls.length > 0) {
92
  openaiMsg.tool_calls = toolCalls;
@@ -98,16 +116,21 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] {
98
 
99
  if (role === "toolResult" || role === "tool_result") {
100
  const content =
101
- typeof msg.content === "string"
102
- ? msg.content
103
- : Array.isArray(msg.content)
104
- ? extractText(msg.content)
105
- : JSON.stringify(msg.content);
106
 
107
  result.push({
108
  role: "tool",
109
  content,
110
- tool_call_id: msg.tool_use_id ?? msg.toolCallId ?? msg.id ?? "unknown",
 
 
 
 
 
111
  });
112
  continue;
113
  }
@@ -115,7 +138,11 @@ export function agentToOpenAI(messages: any[]): OpenAIMessage[] {
115
  // Fallback: pass through as user message
116
  result.push({
117
  role: "user",
118
- content: typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content),
 
 
 
 
119
  });
120
  }
121
 
@@ -129,11 +156,15 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] {
129
  const result: any[] = [];
130
 
131
  for (const msg of messages) {
 
 
 
 
132
  if (msg.role === "system") {
133
  result.push({
134
  role: "system",
135
  content: msg.content ?? "",
136
- timestamp: Date.now(),
137
  });
138
  continue;
139
  }
@@ -142,7 +173,7 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] {
142
  result.push({
143
  role: "user",
144
  content: msg.content ?? "",
145
- timestamp: Date.now(),
146
  });
147
  continue;
148
  }
@@ -172,9 +203,26 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] {
172
  // OpenClaw's Pi agent expects content to always be an array for assistant messages
173
  // (it calls .flatMap() on it). Never flatten to a string.
174
  result.push({
 
175
  role: "assistant",
176
  content: blocks,
177
- timestamp: Date.now(),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  });
179
  continue;
180
  }
@@ -188,12 +236,18 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] {
188
  : JSON.stringify(msg.content);
189
  const toolCallId = msg.tool_call_id ?? "unknown";
190
  result.push({
 
191
  role: "toolResult",
192
  // OpenClaw transport layers expect toolResult content blocks, not a raw string.
193
  content: [{ type: "text", text: textContent }],
194
- toolCallId,
195
- tool_use_id: toolCallId,
196
- timestamp: Date.now(),
 
 
 
 
 
197
  });
198
  continue;
199
  }
@@ -202,6 +256,10 @@ export function openAIToAgent(messages: OpenAIMessage[]): any[] {
202
  return result;
203
  }
204
 
 
 
 
 
205
  /**
206
  * Extract text from content blocks.
207
  */
@@ -221,3 +279,140 @@ function extractText(content: any): string {
221
  .filter(Boolean)
222
  .join("\n");
223
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  tool_calls?: any[];
21
  tool_call_id?: string;
22
  name?: string;
23
+ _headroomMeta?: Record<string, unknown>;
24
  }
25
 
26
  /**
 
30
  const result: OpenAIMessage[] = [];
31
 
32
  for (const msg of messages) {
33
+ const normalized = normalizeAgentMessage(msg);
34
+ const role = normalized.role;
35
+
36
+ const buildMeta = (): Record<string, unknown> => {
37
+ const meta = { ...normalized } as Record<string, unknown>;
38
+ delete meta.role;
39
+ delete meta.content;
40
+ return meta;
41
+ };
42
 
43
  if (role === "system") {
44
  result.push({
45
  role: "system",
46
+ content:
47
+ typeof normalized.content === "string"
48
+ ? normalized.content
49
+ : extractText(normalized.content),
50
+ _headroomMeta: buildMeta(),
51
  });
52
  continue;
53
  }
 
55
  if (role === "user") {
56
  result.push({
57
  role: "user",
58
+ content:
59
+ typeof normalized.content === "string"
60
+ ? normalized.content
61
+ : extractText(normalized.content),
62
+ _headroomMeta: buildMeta(),
63
  });
64
  continue;
65
  }
66
 
67
  if (role === "assistant") {
68
+ const content = normalized.content;
69
  if (typeof content === "string") {
70
+ result.push({ role: "assistant", content, _headroomMeta: buildMeta() });
71
  continue;
72
  }
73
 
 
104
  const openaiMsg: OpenAIMessage = {
105
  role: "assistant",
106
  content: textParts.length > 0 ? textParts.join("") : null,
107
+ _headroomMeta: buildMeta(),
108
  };
109
  if (toolCalls.length > 0) {
110
  openaiMsg.tool_calls = toolCalls;
 
116
 
117
  if (role === "toolResult" || role === "tool_result") {
118
  const content =
119
+ typeof normalized.content === "string"
120
+ ? normalized.content
121
+ : Array.isArray(normalized.content)
122
+ ? extractText(normalized.content)
123
+ : JSON.stringify(normalized.content);
124
 
125
  result.push({
126
  role: "tool",
127
  content,
128
+ tool_call_id:
129
+ normalized.toolCallId ??
130
+ normalized.tool_use_id ??
131
+ normalized.id ??
132
+ "unknown",
133
+ _headroomMeta: buildMeta(),
134
  });
135
  continue;
136
  }
 
138
  // Fallback: pass through as user message
139
  result.push({
140
  role: "user",
141
+ content:
142
+ typeof normalized.content === "string"
143
+ ? normalized.content
144
+ : JSON.stringify(normalized.content),
145
+ _headroomMeta: buildMeta(),
146
  });
147
  }
148
 
 
156
  const result: any[] = [];
157
 
158
  for (const msg of messages) {
159
+ const meta = (msg._headroomMeta ?? {}) as Record<string, unknown>;
160
+ const timestamp =
161
+ typeof meta.timestamp === "number" ? meta.timestamp : Date.now();
162
+
163
  if (msg.role === "system") {
164
  result.push({
165
  role: "system",
166
  content: msg.content ?? "",
167
+ timestamp,
168
  });
169
  continue;
170
  }
 
173
  result.push({
174
  role: "user",
175
  content: msg.content ?? "",
176
+ timestamp,
177
  });
178
  continue;
179
  }
 
203
  // OpenClaw's Pi agent expects content to always be an array for assistant messages
204
  // (it calls .flatMap() on it). Never flatten to a string.
205
  result.push({
206
+ ...(meta as object),
207
  role: "assistant",
208
  content: blocks,
209
+ api: typeof meta.api === "string" ? meta.api : "headroom",
210
+ provider: typeof meta.provider === "string" ? meta.provider : "headroom",
211
+ model: typeof meta.model === "string" ? meta.model : "headroom",
212
+ usage:
213
+ isRecord(meta.usage)
214
+ ? meta.usage
215
+ : {
216
+ input: 0,
217
+ output: 0,
218
+ cacheRead: 0,
219
+ cacheWrite: 0,
220
+ totalTokens: 0,
221
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
222
+ },
223
+ stopReason:
224
+ typeof meta.stopReason === "string" ? meta.stopReason : "stop",
225
+ timestamp,
226
  });
227
  continue;
228
  }
 
236
  : JSON.stringify(msg.content);
237
  const toolCallId = msg.tool_call_id ?? "unknown";
238
  result.push({
239
+ ...(meta as object),
240
  role: "toolResult",
241
  // OpenClaw transport layers expect toolResult content blocks, not a raw string.
242
  content: [{ type: "text", text: textContent }],
243
+ toolCallId:
244
+ typeof meta.toolCallId === "string" ? meta.toolCallId : toolCallId,
245
+ tool_use_id:
246
+ typeof meta.tool_use_id === "string" ? meta.tool_use_id : toolCallId,
247
+ toolName:
248
+ typeof meta.toolName === "string" ? meta.toolName : "headroom",
249
+ isError: typeof meta.isError === "boolean" ? meta.isError : false,
250
+ timestamp,
251
  });
252
  continue;
253
  }
 
256
  return result;
257
  }
258
 
259
+ export function normalizeAgentMessages(messages: any[]): any[] {
260
+ return messages.map((message) => normalizeAgentMessage(message));
261
+ }
262
+
263
  /**
264
  * Extract text from content blocks.
265
  */
 
279
  .filter(Boolean)
280
  .join("\n");
281
  }
282
+
283
+ function normalizeAgentMessage(message: any): any {
284
+ if (!isRecord(message)) return message;
285
+
286
+ if (message.role === "assistant") {
287
+ return normalizeAssistantMessage(message);
288
+ }
289
+
290
+ if (message.role === "toolResult" || message.role === "tool_result") {
291
+ return normalizeToolResultMessage(message);
292
+ }
293
+
294
+ return message;
295
+ }
296
+
297
+ function normalizeAssistantMessage(message: Record<string, any>): Record<string, any> {
298
+ const normalizedContent = normalizeAssistantContent(message.content);
299
+
300
+ return {
301
+ ...message,
302
+ content: normalizedContent,
303
+ api: typeof message.api === "string" ? message.api : "headroom",
304
+ provider: typeof message.provider === "string" ? message.provider : "headroom",
305
+ model: typeof message.model === "string" ? message.model : "headroom",
306
+ usage: isRecord(message.usage)
307
+ ? message.usage
308
+ : {
309
+ input: 0,
310
+ output: 0,
311
+ cacheRead: 0,
312
+ cacheWrite: 0,
313
+ totalTokens: 0,
314
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
315
+ },
316
+ stopReason: typeof message.stopReason === "string" ? message.stopReason : "stop",
317
+ timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now(),
318
+ };
319
+ }
320
+
321
+ function normalizeToolResultMessage(message: Record<string, any>): Record<string, any> {
322
+ const normalizedContent = normalizeToolResultContent(message.content);
323
+ const toolCallId =
324
+ typeof message.toolCallId === "string"
325
+ ? message.toolCallId
326
+ : typeof message.tool_use_id === "string"
327
+ ? message.tool_use_id
328
+ : typeof message.id === "string"
329
+ ? message.id
330
+ : "unknown";
331
+
332
+ return {
333
+ ...message,
334
+ role: "toolResult",
335
+ content: normalizedContent,
336
+ toolCallId,
337
+ tool_use_id:
338
+ typeof message.tool_use_id === "string" ? message.tool_use_id : toolCallId,
339
+ toolName: typeof message.toolName === "string" ? message.toolName : "headroom",
340
+ isError: typeof message.isError === "boolean" ? message.isError : false,
341
+ timestamp: typeof message.timestamp === "number" ? message.timestamp : Date.now(),
342
+ };
343
+ }
344
+
345
+ function normalizeAssistantContent(content: unknown): any[] {
346
+ if (Array.isArray(content)) {
347
+ return content.flatMap((block) => {
348
+ if (typeof block === "string") return [{ type: "text", text: block }];
349
+ if (!isRecord(block) || typeof block.type !== "string") return [];
350
+ if (block.type === "text" && typeof block.text === "string") return [block];
351
+ if (block.type === "thinking" && typeof block.thinking === "string") return [block];
352
+ if (
353
+ (block.type === "toolCall" || block.type === "tool_use") &&
354
+ typeof block.name === "string"
355
+ ) {
356
+ return [
357
+ {
358
+ type: "toolCall",
359
+ id: typeof block.id === "string" ? block.id : "unknown",
360
+ name: block.name,
361
+ arguments:
362
+ "arguments" in block
363
+ ? block.arguments
364
+ : "input" in block
365
+ ? block.input
366
+ : {},
367
+ },
368
+ ];
369
+ }
370
+ return [];
371
+ });
372
+ }
373
+
374
+ if (typeof content === "string" && content.length > 0) {
375
+ return [{ type: "text", text: content }];
376
+ }
377
+
378
+ if (content == null) {
379
+ return [];
380
+ }
381
+
382
+ return [{ type: "text", text: JSON.stringify(content) }];
383
+ }
384
+
385
+ function normalizeToolResultContent(content: unknown): any[] {
386
+ if (Array.isArray(content)) {
387
+ return content.flatMap((block) => {
388
+ if (typeof block === "string") return [{ type: "text", text: block }];
389
+ if (!isRecord(block) || typeof block.type !== "string") return [];
390
+ if (block.type === "text" && typeof block.text === "string") return [block];
391
+ if (
392
+ block.type === "image" &&
393
+ typeof block.data === "string" &&
394
+ typeof block.mimeType === "string"
395
+ ) {
396
+ return [block];
397
+ }
398
+ if (block.type === "tool_result" && "content" in block) {
399
+ return normalizeToolResultContent(block.content);
400
+ }
401
+ return [];
402
+ });
403
+ }
404
+
405
+ if (typeof content === "string" && content.length > 0) {
406
+ return [{ type: "text", text: content }];
407
+ }
408
+
409
+ if (content == null) {
410
+ return [];
411
+ }
412
+
413
+ return [{ type: "text", text: JSON.stringify(content) }];
414
+ }
415
+
416
+ function isRecord(value: unknown): value is Record<string, any> {
417
+ return typeof value === "object" && value !== null && !Array.isArray(value);
418
+ }
plugins/openclaw/src/engine.ts CHANGED
@@ -9,7 +9,7 @@
9
 
10
  import { compress } from "headroom-ai";
11
  import { ProxyManager, defaultLogger, type ProxyManagerConfig, type ProxyManagerLogger } from "./proxy-manager.js";
12
- import { agentToOpenAI, openAIToAgent } from "./convert.js";
13
 
14
  export interface HeadroomEngineConfig extends ProxyManagerConfig {
15
  enabled?: boolean;
@@ -96,7 +96,7 @@ export class HeadroomContextEngine {
96
  }> {
97
  if (!this.proxyUrl || this.config.enabled === false) {
98
  // Fallback: return messages unchanged
99
- return { messages: params.messages, estimatedTokens: 0 };
100
  }
101
 
102
  try {
@@ -112,7 +112,10 @@ export class HeadroomContextEngine {
112
  } as any);
113
 
114
  if (!result.compressed || result.tokensSaved === 0) {
115
- return { messages: params.messages, estimatedTokens: result.tokensBefore };
 
 
 
116
  }
117
 
118
  // Convert back to AgentMessage format
@@ -138,7 +141,7 @@ export class HeadroomContextEngine {
138
  } catch (error) {
139
  this.logger.error(`Assemble failed: ${error}`);
140
  // Graceful fallback: return original messages
141
- return { messages: params.messages, estimatedTokens: 0 };
142
  }
143
  }
144
 
 
9
 
10
  import { compress } from "headroom-ai";
11
  import { ProxyManager, defaultLogger, type ProxyManagerConfig, type ProxyManagerLogger } from "./proxy-manager.js";
12
+ import { agentToOpenAI, normalizeAgentMessages, openAIToAgent } from "./convert.js";
13
 
14
  export interface HeadroomEngineConfig extends ProxyManagerConfig {
15
  enabled?: boolean;
 
96
  }> {
97
  if (!this.proxyUrl || this.config.enabled === false) {
98
  // Fallback: return messages unchanged
99
+ return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
100
  }
101
 
102
  try {
 
112
  } as any);
113
 
114
  if (!result.compressed || result.tokensSaved === 0) {
115
+ return {
116
+ messages: normalizeAgentMessages(params.messages),
117
+ estimatedTokens: result.tokensBefore,
118
+ };
119
  }
120
 
121
  // Convert back to AgentMessage format
 
141
  } catch (error) {
142
  this.logger.error(`Assemble failed: ${error}`);
143
  // Graceful fallback: return original messages
144
+ return { messages: normalizeAgentMessages(params.messages), estimatedTokens: 0 };
145
  }
146
  }
147
 
plugins/openclaw/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
  export { default } from "./plugin/index.js";
2
  export { HeadroomContextEngine } from "./engine.js";
3
  export { ProxyManager, normalizeAndValidateProxyUrl, isLocalProxyUrl, defaultLogger, probeHeadroomProxy } from "./proxy-manager.js";
4
- export { agentToOpenAI, openAIToAgent } from "./convert.js";
5
  export { createHeadroomRetrieveTool } from "./tools/headroom-retrieve.js";
 
1
  export { default } from "./plugin/index.js";
2
  export { HeadroomContextEngine } from "./engine.js";
3
  export { ProxyManager, normalizeAndValidateProxyUrl, isLocalProxyUrl, defaultLogger, probeHeadroomProxy } from "./proxy-manager.js";
4
+ export { agentToOpenAI, normalizeAgentMessages, openAIToAgent } from "./convert.js";
5
  export { createHeadroomRetrieveTool } from "./tools/headroom-retrieve.js";
plugins/openclaw/test/convert.test.ts CHANGED
@@ -1,5 +1,5 @@
1
  import { describe, expect, it } from "vitest";
2
- import { openAIToAgent, type OpenAIMessage } from "../src/convert";
3
 
4
  describe("openAIToAgent", () => {
5
  it("emits toolResult content as blocks so transports can safely filter", () => {
@@ -26,3 +26,71 @@ describe("openAIToAgent", () => {
26
  expect(toolResult.tool_use_id).toBe("call_123");
27
  });
28
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import { describe, expect, it } from "vitest";
2
+ import { agentToOpenAI, normalizeAgentMessages, openAIToAgent, type OpenAIMessage } from "../src/convert";
3
 
4
  describe("openAIToAgent", () => {
5
  it("emits toolResult content as blocks so transports can safely filter", () => {
 
26
  expect(toolResult.tool_use_id).toBe("call_123");
27
  });
28
  });
29
+
30
+ describe("normalizeAgentMessages", () => {
31
+ it("normalizes assistant string content into OpenClaw blocks", () => {
32
+ const result = normalizeAgentMessages([
33
+ {
34
+ role: "assistant",
35
+ content: "hello from headroom",
36
+ },
37
+ ]);
38
+
39
+ expect(result[0]).toMatchObject({
40
+ role: "assistant",
41
+ content: [{ type: "text", text: "hello from headroom" }],
42
+ api: "headroom",
43
+ provider: "headroom",
44
+ model: "headroom",
45
+ stopReason: "stop",
46
+ });
47
+ });
48
+
49
+ it("normalizes tool result string content into OpenClaw blocks", () => {
50
+ const result = normalizeAgentMessages([
51
+ {
52
+ role: "toolResult",
53
+ content: "tool output",
54
+ },
55
+ ]);
56
+
57
+ expect(result[0]).toMatchObject({
58
+ role: "toolResult",
59
+ content: [{ type: "text", text: "tool output" }],
60
+ toolCallId: "unknown",
61
+ tool_use_id: "unknown",
62
+ toolName: "headroom",
63
+ isError: false,
64
+ });
65
+ });
66
+ });
67
+
68
+ describe("agentToOpenAI", () => {
69
+ it("captures assistant metadata needed for OpenClaw round-trips", () => {
70
+ const result = agentToOpenAI([
71
+ {
72
+ role: "assistant",
73
+ content: "hello",
74
+ api: "anthropic-messages",
75
+ provider: "anthropic",
76
+ model: "claude-sonnet-4-5",
77
+ stopReason: "stop",
78
+ usage: {
79
+ input: 1,
80
+ output: 2,
81
+ cacheRead: 0,
82
+ cacheWrite: 0,
83
+ totalTokens: 3,
84
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
85
+ },
86
+ },
87
+ ]);
88
+
89
+ expect(result[0]._headroomMeta).toMatchObject({
90
+ api: "anthropic-messages",
91
+ provider: "anthropic",
92
+ model: "claude-sonnet-4-5",
93
+ stopReason: "stop",
94
+ });
95
+ });
96
+ });
plugins/openclaw/test/engine-normalization.test.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from "vitest";
2
+ import { HeadroomContextEngine } from "../src/engine.js";
3
+
4
+ describe("HeadroomContextEngine", () => {
5
+ it("normalizes pass-through assistant messages when no proxy is available", async () => {
6
+ const engine = new HeadroomContextEngine({ enabled: false });
7
+
8
+ const result = await engine.assemble({
9
+ sessionId: "test-session",
10
+ messages: [
11
+ { role: "user", content: "hi", timestamp: Date.now() },
12
+ { role: "assistant", content: "hello there", timestamp: Date.now() },
13
+ ],
14
+ });
15
+
16
+ expect(result.messages[1]).toMatchObject({
17
+ role: "assistant",
18
+ content: [{ type: "text", text: "hello there" }],
19
+ });
20
+ });
21
+ });
plugins/openclaw/test/engine.test.ts CHANGED
@@ -25,7 +25,8 @@ describe("AgentMessage conversion", () => {
25
  it("converts user message", () => {
26
  const agent = [{ role: "user", content: "hello", timestamp: Date.now() }];
27
  const openai = agentToOpenAI(agent);
28
- expect(openai).toEqual([{ role: "user", content: "hello" }]);
 
29
  });
30
 
31
  it("converts assistant with tool_use blocks", () => {
 
25
  it("converts user message", () => {
26
  const agent = [{ role: "user", content: "hello", timestamp: Date.now() }];
27
  const openai = agentToOpenAI(agent);
28
+ expect(openai).toHaveLength(1);
29
+ expect(openai[0]).toMatchObject({ role: "user", content: "hello" });
30
  });
31
 
32
  it("converts assistant with tool_use blocks", () => {