diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 1fe8d06beef..7525a43dcca 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -60,6 +60,10 @@ type AbortedPartialSnapshot = { abortOrigin: AbortOrigin; }; +const CHAT_HISTORY_TEXT_MAX_CHARS = 12_000; +const CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES = 128 * 1024; +const CHAT_HISTORY_OVERSIZED_PLACEHOLDER = "[chat.history omitted: message too large]"; + function stripDisallowedChatControlChars(message: string): string { let output = ""; for (const char of message) { @@ -81,6 +85,165 @@ export function sanitizeChatSendMessageInput( return { ok: true, message: stripDisallowedChatControlChars(normalized) }; } +function truncateChatHistoryText(text: string): { text: string; truncated: boolean } { + if (text.length <= CHAT_HISTORY_TEXT_MAX_CHARS) { + return { text, truncated: false }; + } + return { + text: `${text.slice(0, CHAT_HISTORY_TEXT_MAX_CHARS)}\n...(truncated)...`, + truncated: true, + }; +} + +function sanitizeChatHistoryContentBlock(block: unknown): { block: unknown; changed: boolean } { + if (!block || typeof block !== "object") { + return { block, changed: false }; + } + const entry = { ...(block as Record) }; + let changed = false; + if (typeof entry.text === "string") { + const res = truncateChatHistoryText(entry.text); + entry.text = res.text; + changed ||= res.truncated; + } + if (typeof entry.partialJson === "string") { + const res = truncateChatHistoryText(entry.partialJson); + entry.partialJson = res.text; + changed ||= res.truncated; + } + if (typeof entry.arguments === "string") { + const res = truncateChatHistoryText(entry.arguments); + entry.arguments = res.text; + changed ||= res.truncated; + } + if (typeof entry.thinking === "string") { + const res = truncateChatHistoryText(entry.thinking); + entry.thinking = res.text; + changed ||= res.truncated; + } + if ("thinkingSignature" in entry) { + delete entry.thinkingSignature; + changed = true; + } + const type = typeof entry.type === "string" ? entry.type : ""; + if (type === "image" && typeof entry.data === "string") { + const bytes = Buffer.byteLength(entry.data, "utf8"); + delete entry.data; + entry.omitted = true; + entry.bytes = bytes; + changed = true; + } + return { block: changed ? entry : block, changed }; +} + +function sanitizeChatHistoryMessage(message: unknown): { message: unknown; changed: boolean } { + if (!message || typeof message !== "object") { + return { message, changed: false }; + } + const entry = { ...(message as Record) }; + let changed = false; + + if ("details" in entry) { + delete entry.details; + changed = true; + } + if ("usage" in entry) { + delete entry.usage; + changed = true; + } + if ("cost" in entry) { + delete entry.cost; + changed = true; + } + + if (typeof entry.content === "string") { + const res = truncateChatHistoryText(entry.content); + entry.content = res.text; + changed ||= res.truncated; + } else if (Array.isArray(entry.content)) { + const updated = entry.content.map((block) => sanitizeChatHistoryContentBlock(block)); + if (updated.some((item) => item.changed)) { + entry.content = updated.map((item) => item.block); + changed = true; + } + } + + if (typeof entry.text === "string") { + const res = truncateChatHistoryText(entry.text); + entry.text = res.text; + changed ||= res.truncated; + } + + return { message: changed ? entry : message, changed }; +} + +function sanitizeChatHistoryMessages(messages: unknown[]): unknown[] { + if (messages.length === 0) { + return messages; + } + let changed = false; + const next = messages.map((message) => { + const res = sanitizeChatHistoryMessage(message); + changed ||= res.changed; + return res.message; + }); + return changed ? next : messages; +} + +function jsonUtf8Bytes(value: unknown): number { + try { + return Buffer.byteLength(JSON.stringify(value), "utf8"); + } catch { + return Buffer.byteLength(String(value), "utf8"); + } +} + +function buildOversizedHistoryPlaceholder(message?: unknown): Record { + const role = + message && + typeof message === "object" && + typeof (message as { role?: unknown }).role === "string" + ? (message as { role: string }).role + : "assistant"; + const timestamp = + message && + typeof message === "object" && + typeof (message as { timestamp?: unknown }).timestamp === "number" + ? (message as { timestamp: number }).timestamp + : Date.now(); + return { + role, + timestamp, + content: [{ type: "text", text: CHAT_HISTORY_OVERSIZED_PLACEHOLDER }], + __openclaw: { truncated: true, reason: "oversized" }, + }; +} + +function enforceChatHistoryHardCap(messages: unknown[], maxBytes: number): unknown[] { + if (messages.length === 0) { + return messages; + } + const normalized = messages.map((message) => { + if (jsonUtf8Bytes(message) <= CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES) { + return message; + } + return buildOversizedHistoryPlaceholder(message); + }); + const softCapped = capArrayByJsonBytes(normalized, maxBytes).items; + if (jsonUtf8Bytes(softCapped) <= maxBytes) { + return softCapped; + } + const last = softCapped.at(-1); + if (last && jsonUtf8Bytes([last]) <= maxBytes) { + return [last]; + } + const placeholder = buildOversizedHistoryPlaceholder(); + if (jsonUtf8Bytes([placeholder]) <= maxBytes) { + return [placeholder]; + } + return []; +} + function resolveTranscriptPath(params: { sessionId: string; storePath: string | undefined; @@ -408,7 +571,9 @@ export const chatHandlers: GatewayRequestHandlers = { const max = Math.min(hardMax, requested); const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages; const sanitized = stripEnvelopeFromMessages(sliced); - const capped = capArrayByJsonBytes(sanitized, getMaxChatHistoryMessagesBytes()).items; + const normalized = sanitizeChatHistoryMessages(sanitized); + const capped = capArrayByJsonBytes(normalized, getMaxChatHistoryMessagesBytes()).items; + const bounded = enforceChatHistoryHardCap(capped, getMaxChatHistoryMessagesBytes()); let thinkingLevel = entry?.thinkingLevel; if (!thinkingLevel) { const configured = cfg.agents?.defaults?.thinkingDefault; @@ -430,7 +595,7 @@ export const chatHandlers: GatewayRequestHandlers = { respond(true, { sessionKey, sessionId, - messages: capped, + messages: bounded, thinkingLevel, verboseLevel, }); diff --git a/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts b/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts index a188437807f..3f8b4b11d9d 100644 --- a/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts +++ b/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts @@ -124,6 +124,66 @@ describe("gateway server chat", () => { } }); + test("chat.history hard-caps single oversized nested payloads", async () => { + const tempDirs: string[] = []; + const { server, ws } = await startServerWithClient(); + try { + const historyMaxBytes = 64 * 1024; + __setMaxChatHistoryMessagesBytesForTest(historyMaxBytes); + await connectOk(ws); + + const sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-")); + tempDirs.push(sessionDir); + testState.sessionStorePath = path.join(sessionDir, "sessions.json"); + + await writeSessionStore({ + entries: { + main: { sessionId: "sess-main", updatedAt: Date.now() }, + }, + }); + + const hugeNestedText = "n".repeat(450_000); + const oversizedLine = JSON.stringify({ + message: { + role: "assistant", + timestamp: Date.now(), + content: [ + { + type: "tool_result", + toolUseId: "tool-1", + output: { + nested: { + payload: hugeNestedText, + }, + }, + }, + ], + }, + }); + await fs.writeFile(path.join(sessionDir, "sess-main.jsonl"), `${oversizedLine}\n`, "utf-8"); + + const historyRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", { + sessionKey: "main", + limit: 1000, + }); + expect(historyRes.ok).toBe(true); + const messages = historyRes.payload?.messages ?? []; + expect(messages.length).toBe(1); + + const serialized = JSON.stringify(messages); + const bytes = Buffer.byteLength(serialized, "utf8"); + expect(bytes).toBeLessThanOrEqual(historyMaxBytes); + expect(serialized).toContain("[chat.history omitted: message too large]"); + expect(serialized.includes(hugeNestedText.slice(0, 256))).toBe(false); + } finally { + __setMaxChatHistoryMessagesBytesForTest(); + testState.sessionStorePath = undefined; + ws.close(); + await server.close(); + await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true }))); + } + }); + test("smoke: supports abort and idempotent completion", async () => { const tempDirs: string[] = []; const { server, ws } = await startServerWithClient();