mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
fix(gateway): preserve chat.history context under hard caps
This commit is contained in:
@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Fixes
|
||||
|
||||
- Gateway/Channels: wire `gateway.channelHealthCheckMinutes` into strict config validation, treat implicit account status as managed for health checks, and harden channel auto-restart flow (preserve restart-attempt caps across crash loops, propagate enabled/configured runtime flags, and stop pending restart backoff after manual stop). Thanks @steipete.
|
||||
- Gateway/WebChat: hard-cap `chat.history` oversized payloads by truncating high-cost fields and replacing over-budget entries with placeholders, so history fetches stay within configured byte limits and avoid chat UI freezes. (#18505)
|
||||
- UI/Usage: replace lingering undefined `var(--text-muted)` usage with `var(--muted)` in usage date-range and chart styles to keep muted text visible across themes. (#17975) Thanks @jogelin.
|
||||
- UI/Usage: preserve selected-range totals when timeline data is downsampled by bucket-aggregating timeseries points (instead of dropping intermediate points), so filtered tokens/cost stay accurate. (#17959) Thanks @jogelin.
|
||||
- UI/Sessions: refresh the sessions table only after successful deletes and preserve delete errors on cancel/failure paths, so deleted sessions disappear automatically without masking delete failures. (#18507)
|
||||
|
||||
@@ -92,6 +92,7 @@ Cron jobs panel notes:
|
||||
|
||||
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
|
||||
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
|
||||
- `chat.history` responses are size-bounded for UI safety. When transcript entries are too large, Gateway may truncate long text fields, omit heavy metadata blocks, and replace oversized messages with a placeholder (`[chat.history omitted: message too large]`).
|
||||
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
|
||||
- Stop:
|
||||
- Click **Stop** (calls `chat.abort`)
|
||||
|
||||
@@ -24,6 +24,7 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
|
||||
## How it works (behavior)
|
||||
|
||||
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
|
||||
- `chat.history` is bounded for stability: Gateway may truncate long text fields, omit heavy metadata, and replace oversized entries with `[chat.history omitted: message too large]`.
|
||||
- `chat.inject` appends an assistant note directly to the transcript and broadcasts it to the UI (no agent run).
|
||||
- Aborted runs can keep partial assistant output visible in the UI.
|
||||
- Gateway persists aborted partial assistant text into transcript history when buffered output exists, and marks those entries with abort metadata.
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import { resolveThinkingDefault } from "../../agents/model-selection.js";
|
||||
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
|
||||
import { dispatchInboundMessage } from "../../auto-reply/dispatch.js";
|
||||
import { createReplyDispatcher } from "../../auto-reply/reply/reply-dispatcher.js";
|
||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
||||
import { resolveSessionFilePath } from "../../config/sessions.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
@@ -41,7 +42,6 @@ import {
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
import { injectTimestamp, timestampOptsFromConfig } from "./agent-timestamp.js";
|
||||
import { normalizeRpcAttachmentsToChatAttachments } from "./attachment-normalize.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
type TranscriptAppendResult = {
|
||||
ok: boolean;
|
||||
@@ -63,6 +63,7 @@ type AbortedPartialSnapshot = {
|
||||
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]";
|
||||
let chatHistoryPlaceholderEmitCount = 0;
|
||||
|
||||
function stripDisallowedChatControlChars(message: string): string {
|
||||
let output = "";
|
||||
@@ -219,29 +220,45 @@ function buildOversizedHistoryPlaceholder(message?: unknown): Record<string, unk
|
||||
};
|
||||
}
|
||||
|
||||
function enforceChatHistoryHardCap(messages: unknown[], maxBytes: number): unknown[] {
|
||||
function replaceOversizedChatHistoryMessages(params: {
|
||||
messages: unknown[];
|
||||
maxSingleMessageBytes: number;
|
||||
}): { messages: unknown[]; replacedCount: number } {
|
||||
const { messages, maxSingleMessageBytes } = params;
|
||||
if (messages.length === 0) {
|
||||
return messages;
|
||||
return { messages, replacedCount: 0 };
|
||||
}
|
||||
const normalized = messages.map((message) => {
|
||||
if (jsonUtf8Bytes(message) <= CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES) {
|
||||
let replacedCount = 0;
|
||||
const next = messages.map((message) => {
|
||||
if (jsonUtf8Bytes(message) <= maxSingleMessageBytes) {
|
||||
return message;
|
||||
}
|
||||
replacedCount += 1;
|
||||
return buildOversizedHistoryPlaceholder(message);
|
||||
});
|
||||
const softCapped = capArrayByJsonBytes(normalized, maxBytes).items;
|
||||
if (jsonUtf8Bytes(softCapped) <= maxBytes) {
|
||||
return softCapped;
|
||||
return { messages: replacedCount > 0 ? next : messages, replacedCount };
|
||||
}
|
||||
|
||||
function enforceChatHistoryFinalBudget(params: { messages: unknown[]; maxBytes: number }): {
|
||||
messages: unknown[];
|
||||
placeholderCount: number;
|
||||
} {
|
||||
const { messages, maxBytes } = params;
|
||||
if (messages.length === 0) {
|
||||
return { messages, placeholderCount: 0 };
|
||||
}
|
||||
const last = softCapped.at(-1);
|
||||
if (jsonUtf8Bytes(messages) <= maxBytes) {
|
||||
return { messages, placeholderCount: 0 };
|
||||
}
|
||||
const last = messages.at(-1);
|
||||
if (last && jsonUtf8Bytes([last]) <= maxBytes) {
|
||||
return [last];
|
||||
return { messages: [last], placeholderCount: 0 };
|
||||
}
|
||||
const placeholder = buildOversizedHistoryPlaceholder();
|
||||
const placeholder = buildOversizedHistoryPlaceholder(last);
|
||||
if (jsonUtf8Bytes([placeholder]) <= maxBytes) {
|
||||
return [placeholder];
|
||||
return { messages: [placeholder], placeholderCount: 1 };
|
||||
}
|
||||
return [];
|
||||
return { messages: [], placeholderCount: 0 };
|
||||
}
|
||||
|
||||
function resolveTranscriptPath(params: {
|
||||
@@ -572,8 +589,21 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
||||
const sanitized = stripEnvelopeFromMessages(sliced);
|
||||
const normalized = sanitizeChatHistoryMessages(sanitized);
|
||||
const capped = capArrayByJsonBytes(normalized, getMaxChatHistoryMessagesBytes()).items;
|
||||
const bounded = enforceChatHistoryHardCap(capped, getMaxChatHistoryMessagesBytes());
|
||||
const maxHistoryBytes = getMaxChatHistoryMessagesBytes();
|
||||
const perMessageHardCap = Math.min(CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, maxHistoryBytes);
|
||||
const replaced = replaceOversizedChatHistoryMessages({
|
||||
messages: normalized,
|
||||
maxSingleMessageBytes: perMessageHardCap,
|
||||
});
|
||||
const capped = capArrayByJsonBytes(replaced.messages, maxHistoryBytes).items;
|
||||
const bounded = enforceChatHistoryFinalBudget({ messages: capped, maxBytes: maxHistoryBytes });
|
||||
const placeholderCount = replaced.replacedCount + bounded.placeholderCount;
|
||||
if (placeholderCount > 0) {
|
||||
chatHistoryPlaceholderEmitCount += placeholderCount;
|
||||
context.logGateway.debug(
|
||||
`chat.history omitted oversized payloads placeholders=${placeholderCount} total=${chatHistoryPlaceholderEmitCount}`,
|
||||
);
|
||||
}
|
||||
let thinkingLevel = entry?.thinkingLevel;
|
||||
if (!thinkingLevel) {
|
||||
const configured = cfg.agents?.defaults?.thinkingDefault;
|
||||
@@ -595,7 +625,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
respond(true, {
|
||||
sessionKey,
|
||||
sessionId,
|
||||
messages: bounded,
|
||||
messages: bounded.messages,
|
||||
thinkingLevel,
|
||||
verboseLevel,
|
||||
});
|
||||
|
||||
@@ -184,6 +184,89 @@ describe("gateway server chat", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("chat.history keeps recent small messages when latest message is oversized", 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 baseText = "s".repeat(1_200);
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < 30; i += 1) {
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "user",
|
||||
timestamp: Date.now() + i,
|
||||
content: [{ type: "text", text: `small-${i}:${baseText}` }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const hugeNestedText = "z".repeat(450_000);
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
message: {
|
||||
role: "assistant",
|
||||
timestamp: Date.now() + 1_000,
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
toolUseId: "tool-1",
|
||||
output: {
|
||||
nested: {
|
||||
payload: hugeNestedText,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(sessionDir, "sess-main.jsonl"),
|
||||
`${lines.join("\n")}\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 ?? [];
|
||||
const serialized = JSON.stringify(messages);
|
||||
const bytes = Buffer.byteLength(serialized, "utf8");
|
||||
|
||||
expect(bytes).toBeLessThanOrEqual(historyMaxBytes);
|
||||
expect(messages.length).toBeGreaterThan(1);
|
||||
expect(serialized).toContain("small-29:");
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user