mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 20:14:30 +00:00
gateway: hard-cap chat.history oversized payloads
This commit is contained in:
committed by
Peter Steinberger
parent
97e0f8d551
commit
5d9a026a9e
@@ -60,6 +60,10 @@ type AbortedPartialSnapshot = {
|
|||||||
abortOrigin: AbortOrigin;
|
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 {
|
function stripDisallowedChatControlChars(message: string): string {
|
||||||
let output = "";
|
let output = "";
|
||||||
for (const char of message) {
|
for (const char of message) {
|
||||||
@@ -81,6 +85,165 @@ export function sanitizeChatSendMessageInput(
|
|||||||
return { ok: true, message: stripDisallowedChatControlChars(normalized) };
|
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<string, unknown>) };
|
||||||
|
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<string, unknown>) };
|
||||||
|
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<string, unknown> {
|
||||||
|
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: {
|
function resolveTranscriptPath(params: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
storePath: string | undefined;
|
storePath: string | undefined;
|
||||||
@@ -408,7 +571,9 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
const max = Math.min(hardMax, requested);
|
const max = Math.min(hardMax, requested);
|
||||||
const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
||||||
const sanitized = stripEnvelopeFromMessages(sliced);
|
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;
|
let thinkingLevel = entry?.thinkingLevel;
|
||||||
if (!thinkingLevel) {
|
if (!thinkingLevel) {
|
||||||
const configured = cfg.agents?.defaults?.thinkingDefault;
|
const configured = cfg.agents?.defaults?.thinkingDefault;
|
||||||
@@ -430,7 +595,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
respond(true, {
|
respond(true, {
|
||||||
sessionKey,
|
sessionKey,
|
||||||
sessionId,
|
sessionId,
|
||||||
messages: capped,
|
messages: bounded,
|
||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
verboseLevel,
|
verboseLevel,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
test("smoke: supports abort and idempotent completion", async () => {
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
|
|||||||
Reference in New Issue
Block a user