Skip to content

Commit 4d9c658

Browse files
authored
perf: bound async transcript history reads (#75977)
Summary: - The PR bounds async transcript history reads and shares async transcript-index builds across gateway history, embedded/TUI history, restart recovery, fork token checks, and preflight compaction paths. - Reproducibility: not applicable. this is a performance PR rather than a user bug report. The verification pa ... ource review plus the added unit coverage for bounded reads, usage snapshots, and concurrent index sharing. ClawSweeper fixups: - No separate fixup commits were needed after automerge opt-in. Validation: - ClawSweeper review passed for head ccfe336. - Required merge gates passed before the squash merge. Prepared head SHA: ccfe336 Review: #75977 (comment) Co-authored-by: Peter Steinberger <[email protected]>
1 parent 3ec5afb commit 4d9c658

21 files changed

Lines changed: 889 additions & 145 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ Docs: https://docs.openclaw.ai
4747
- Plugins/ClawHub: preserve official source-linked trust through archive installs, so OpenClaw can install trusted ClawHub plugin packages that trigger the built-in dangerous-pattern scanner. Thanks @vincentkoc.
4848
- Plugins/ClawHub: install package runtime dependencies for archive-backed plugin installs, so ClawHub packages such as WhatsApp load declared dependencies after download. Thanks @vincentkoc.
4949
- Providers/LM Studio: allow `models.providers.lmstudio.params.preload: false` to skip OpenClaw's native model-load call so LM Studio JIT loading, idle TTL, and auto-evict can own model lifecycle. Fixes #75921. Thanks @garyd9.
50+
- Agents/transcripts: keep chat history, restart recovery, fork token checks, and stale-token compaction checks on bounded async transcript reads or cached async indexes instead of reparsing large session files. Thanks @mariozechner.
5051
- Telegram: inherit the process DNS result order for Bot API transport and downgrade recovered sticky IPv4 fallback promotions to debug logs, while keeping pinned-IP escalation warnings visible. Fixes #75904. Thanks @highfly-hi and @neeravmakwana.
5152
- Sessions: keep durable external conversation pointers, including group and thread-scoped chat sessions, out of age, count, and disk-budget maintenance eviction while still allowing synthetic runtime entries to age out. Fixes #58088. Thanks @drinkflav.
5253
- Web search/MiniMax: allow `MINIMAX_OAUTH_TOKEN` to satisfy MiniMax Search credentials, so OAuth-authorized MiniMax Token Plan setups do not need a separate web-search key. Fixes #65768. Thanks @kikibrian and @zhouhe-xydt.

docs/reference/session-management-compaction.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,12 @@ OpenClaw persists sessions in two layers:
5454
transcript exceeds the checkpoint size cap, avoiding a second giant
5555
`.checkpoint.*.jsonl` copy.
5656

57+
Gateway history readers should avoid materializing the whole transcript unless
58+
the surface explicitly needs arbitrary historical access. First-page history,
59+
embedded chat history, restart recovery, and token/usage checks use bounded tail
60+
reads. Full transcript scans go through the async transcript index, which is
61+
cached by file path plus `mtimeMs`/`size` and shared across concurrent readers.
62+
5763
---
5864

5965
## On-disk locations

src/agents/main-session-restart-recovery.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,11 @@ async function recoverStore(params: {
230230
entry.sessionId,
231231
params.storePath,
232232
entry.sessionFile,
233+
{
234+
mode: "recent",
235+
maxMessages: 20,
236+
maxBytes: 256 * 1024,
237+
},
233238
);
234239
} catch (err) {
235240
log.warn(`failed to read transcript for ${sessionKey}: ${String(err)}`);

src/agents/subagent-orphan-recovery.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,11 @@ export async function recoverOrphanedSubagentSessions(params: {
354354
entry.sessionId,
355355
storePath,
356356
entry.sessionFile,
357+
{
358+
mode: "recent",
359+
maxMessages: 200,
360+
maxBytes: 1024 * 1024,
361+
},
357362
);
358363
const lastHumanMessage = [...messages]
359364
.toReversed()

src/agents/tools/embedded-gateway-stub.test.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,20 @@ describe("embedded gateway stub", () => {
9191
maxChars: 100_000,
9292
maxMessages: 200,
9393
});
94+
expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith(
95+
"sess-main",
96+
"/tmp/openclaw-sessions.json",
97+
undefined,
98+
{
99+
mode: "recent",
100+
maxMessages: 200,
101+
maxBytes: 1024 * 1024,
102+
},
103+
);
94104
expect(result.messages).toEqual(projectedMessages);
95105
});
96106

97-
it("passes the full raw history to projection before limiting visible messages", async () => {
107+
it("passes the requested recent history window to projection", async () => {
98108
const rawMessages = [
99109
{ role: "user", content: "visible older" },
100110
{ role: "assistant", content: "hidden newer" },
@@ -111,5 +121,15 @@ describe("embedded gateway stub", () => {
111121
maxChars: 100_000,
112122
maxMessages: 1,
113123
});
124+
expect(runtime.readSessionMessagesAsync).toHaveBeenCalledWith(
125+
"sess-main",
126+
"/tmp/openclaw-sessions.json",
127+
undefined,
128+
{
129+
mode: "recent",
130+
maxMessages: 1,
131+
maxBytes: 1024 * 1024,
132+
},
133+
);
114134
});
115135
});

src/agents/tools/embedded-gateway-stub.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { OpenClawConfig } from "../../config/types.openclaw.js";
22
import type { CallGatewayOptions } from "../../gateway/call.js";
33
import type { SessionsListParams, SessionsResolveParams } from "../../gateway/protocol/index.js";
4+
import type { ReadSessionMessagesAsyncOptions } from "../../gateway/session-utils.fs.js";
45
import type { SessionsListResult } from "../../gateway/session-utils.types.js";
56
import type { SessionsResolveResult } from "../../gateway/sessions-resolve.js";
67

@@ -52,7 +53,8 @@ interface EmbeddedGatewayRuntime {
5253
readSessionMessagesAsync: (
5354
sessionId: string,
5455
storePath: string,
55-
sessionFile?: string,
56+
sessionFile: string | undefined,
57+
opts: ReadSessionMessagesAsyncOptions,
5658
) => Promise<unknown[]>;
5759
resolveSessionModelRef: (
5860
cfg: OpenClawConfig,
@@ -112,13 +114,23 @@ async function handleChatHistory(params: Record<string, unknown>): Promise<{
112114
const sessionId = entry?.sessionId as string | undefined;
113115
const sessionAgentId = rt.resolveSessionAgentId({ sessionKey, config: cfg });
114116
const resolvedSessionModel = rt.resolveSessionModelRef(cfg, entry, sessionAgentId);
117+
const hardMax = 1000;
118+
const defaultLimit = 200;
119+
const requested = typeof limit === "number" ? limit : defaultLimit;
120+
const max = Math.min(hardMax, requested);
121+
const maxHistoryBytes = rt.getMaxChatHistoryMessagesBytes();
115122

116123
const localMessages =
117124
sessionId && storePath
118125
? await rt.readSessionMessagesAsync(
119126
sessionId,
120127
storePath,
121128
entry?.sessionFile as string | undefined,
129+
{
130+
mode: "recent",
131+
maxMessages: max,
132+
maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024),
133+
},
122134
)
123135
: [];
124136

@@ -128,10 +140,6 @@ async function handleChatHistory(params: Record<string, unknown>): Promise<{
128140
localMessages,
129141
});
130142

131-
const hardMax = 1000;
132-
const defaultLimit = 200;
133-
const requested = typeof limit === "number" ? limit : defaultLimit;
134-
const max = Math.min(hardMax, requested);
135143
const effectiveMaxChars = rt.resolveEffectiveChatHistoryMaxChars(cfg);
136144

137145
const normalized = rt.augmentChatHistoryWithCanvasBlocks(
@@ -141,7 +149,6 @@ async function handleChatHistory(params: Record<string, unknown>): Promise<{
141149
}),
142150
);
143151

144-
const maxHistoryBytes = rt.getMaxChatHistoryMessagesBytes();
145152
const perMessageHardCap = Math.min(rt.CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, maxHistoryBytes);
146153
const replaced = rt.replaceOversizedChatHistoryMessages({
147154
messages: normalized,

src/auto-reply/reply/agent-runner-memory.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,168 @@ describe("runMemoryFlushIfNeeded", () => {
429429
});
430430
});
431431

432+
it("includes recent output tokens when deciding preflight compaction", async () => {
433+
const sessionFile = path.join(rootDir, "session-usage.jsonl");
434+
await fs.writeFile(
435+
sessionFile,
436+
`${JSON.stringify({
437+
message: {
438+
role: "assistant",
439+
content: "large answer",
440+
usage: { input: 90_000, output: 10_000 },
441+
},
442+
})}\n`,
443+
"utf8",
444+
);
445+
registerMemoryFlushPlanResolver(() => ({
446+
softThresholdTokens: 4_000,
447+
forceFlushTranscriptBytes: 1_000_000_000,
448+
reserveTokensFloor: 0,
449+
prompt: "Pre-compaction memory flush.\nNO_REPLY",
450+
systemPrompt: "Write memory to memory/YYYY-MM-DD.md.",
451+
relativePath: "memory/2023-11-14.md",
452+
}));
453+
const sessionEntry: SessionEntry = {
454+
sessionId: "session",
455+
sessionFile,
456+
updatedAt: Date.now(),
457+
totalTokensFresh: false,
458+
};
459+
460+
await runPreflightCompactionIfNeeded({
461+
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
462+
followupRun: createTestFollowupRun({
463+
sessionId: "session",
464+
sessionFile,
465+
sessionKey: "main",
466+
}),
467+
defaultModel: "anthropic/claude-opus-4-6",
468+
agentCfgContextTokens: 100_000,
469+
sessionEntry,
470+
sessionStore: { main: sessionEntry },
471+
sessionKey: "main",
472+
storePath: path.join(rootDir, "sessions.json"),
473+
isHeartbeat: false,
474+
replyOperation: createReplyOperation(),
475+
});
476+
477+
const compactCall = compactEmbeddedPiSessionMock.mock.calls[0]?.[0] as {
478+
currentTokenCount?: number;
479+
};
480+
expect(compactCall.currentTokenCount).toBeGreaterThanOrEqual(100_000);
481+
});
482+
483+
it("uses the active run sessionFile when the session entry has no transcript path", async () => {
484+
const sessionFile = path.join(rootDir, "active-run-session.jsonl");
485+
await fs.writeFile(
486+
sessionFile,
487+
`${JSON.stringify({
488+
message: {
489+
role: "assistant",
490+
content: "large answer",
491+
usage: { input: 90_000, output: 8_000 },
492+
},
493+
})}\n`,
494+
"utf8",
495+
);
496+
registerMemoryFlushPlanResolver(() => ({
497+
softThresholdTokens: 4_000,
498+
forceFlushTranscriptBytes: 1_000_000_000,
499+
reserveTokensFloor: 0,
500+
prompt: "Pre-compaction memory flush.\nNO_REPLY",
501+
systemPrompt: "Write memory to memory/YYYY-MM-DD.md.",
502+
relativePath: "memory/2023-11-14.md",
503+
}));
504+
const sessionEntry: SessionEntry = {
505+
sessionId: "session",
506+
updatedAt: Date.now(),
507+
totalTokensFresh: false,
508+
};
509+
510+
await runPreflightCompactionIfNeeded({
511+
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
512+
followupRun: createTestFollowupRun({
513+
sessionId: "session",
514+
sessionFile,
515+
sessionKey: "main",
516+
}),
517+
defaultModel: "anthropic/claude-opus-4-6",
518+
agentCfgContextTokens: 100_000,
519+
sessionEntry,
520+
sessionStore: { main: sessionEntry },
521+
sessionKey: "main",
522+
storePath: path.join(rootDir, "sessions.json"),
523+
isHeartbeat: false,
524+
replyOperation: createReplyOperation(),
525+
});
526+
527+
expect(compactEmbeddedPiSessionMock).toHaveBeenCalledWith(
528+
expect.objectContaining({
529+
sessionId: "session",
530+
sessionFile: expect.stringContaining("active-run-session.jsonl"),
531+
}),
532+
);
533+
});
534+
535+
it("keeps preflight compaction conservative for content appended after latest usage", async () => {
536+
const sessionFile = path.join(rootDir, "post-usage-tail-session.jsonl");
537+
await fs.writeFile(
538+
sessionFile,
539+
[
540+
JSON.stringify({
541+
message: {
542+
role: "assistant",
543+
content: "small answer",
544+
usage: { input: 40_000, output: 2_000 },
545+
},
546+
}),
547+
JSON.stringify({
548+
message: {
549+
role: "tool",
550+
content: `large interrupted tool output ${"x".repeat(450_000)}`,
551+
},
552+
}),
553+
].join("\n"),
554+
"utf8",
555+
);
556+
registerMemoryFlushPlanResolver(() => ({
557+
softThresholdTokens: 4_000,
558+
forceFlushTranscriptBytes: 1_000_000_000,
559+
reserveTokensFloor: 0,
560+
prompt: "Pre-compaction memory flush.\nNO_REPLY",
561+
systemPrompt: "Write memory to memory/YYYY-MM-DD.md.",
562+
relativePath: "memory/2023-11-14.md",
563+
}));
564+
const sessionEntry: SessionEntry = {
565+
sessionId: "session",
566+
sessionFile,
567+
updatedAt: Date.now(),
568+
totalTokensFresh: false,
569+
};
570+
571+
await runPreflightCompactionIfNeeded({
572+
cfg: { agents: { defaults: { compaction: { memoryFlush: {} } } } },
573+
followupRun: createTestFollowupRun({
574+
sessionId: "session",
575+
sessionFile,
576+
sessionKey: "main",
577+
}),
578+
defaultModel: "anthropic/claude-opus-4-6",
579+
agentCfgContextTokens: 100_000,
580+
sessionEntry,
581+
sessionStore: { main: sessionEntry },
582+
sessionKey: "main",
583+
storePath: path.join(rootDir, "sessions.json"),
584+
isHeartbeat: false,
585+
replyOperation: createReplyOperation(),
586+
});
587+
588+
const compactCall = compactEmbeddedPiSessionMock.mock.calls[0]?.[0] as {
589+
currentTokenCount?: number;
590+
};
591+
expect(compactCall.currentTokenCount).toBeGreaterThan(100_000);
592+
});
593+
432594
it("triggers preflight compaction when the active transcript exceeds the configured byte threshold", async () => {
433595
const sessionFile = path.join(rootDir, "large-session.jsonl");
434596
await fs.writeFile(

0 commit comments

Comments
 (0)