Skip to content

Commit f3e8a8a

Browse files
committed
fix(agents): persist compaction token snapshots
1 parent f9946eb commit f3e8a8a

19 files changed

Lines changed: 275 additions & 11 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
4545
- Agents/tools: scope tool-loop detection history to the active run when available, so scheduled heartbeat cycles no longer inherit stale repeated-call counts from previous runs. Fixes #40144. Thanks @mattbrown319.
4646
- Agents/subagents: preserve requester delivery for completion announces when a child agent is bound to a different channel account while keeping same-channel thread completions routed to the child thread. Thanks @sfuminya.
4747
- Agents/subagents: fail closed instead of selecting a single child thread binding when completion delivery lacks requester conversation signal. Thanks @suyua9.
48+
- Agents/status: persist the post-compaction token estimate from auto-compaction when providers omit usage metadata, so `/status` and session lists keep showing fresh context usage after compaction. Fixes #67667; carries forward #72822. Thanks @Jimmy-xuzimo and @skylight-9.
4849
- Control UI: show loading, reload, and retry states when a lazy dashboard panel cannot load after an upgrade, so the Logs tab no longer appears blank on stale browser bundles. Fixes #72450. Thanks @sobergou.
4950
- Gateway/plugins: start the Gateway in degraded mode when a single plugin entry has invalid schema config, and let `openclaw doctor --fix` quarantine that plugin config instead of crash-looping every channel. Fixes #62976 and #70371. Thanks @Doraemon-Claw and @pksidekyk.
5051
- Agents/plugins: skip malformed plugin tools with missing schema objects and report plugin diagnostics, so one broken tool no longer crashes Anthropic agent runs. Fixes #69423. Thanks @jmnickels.

src/agents/command/session-store.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -564,6 +564,95 @@ describe("updateSessionStoreAfterAgentRun", () => {
564564
});
565565
});
566566

567+
it("persists compaction tokensAfter when provider usage is unavailable", async () => {
568+
await withTempSessionStore(async ({ storePath }) => {
569+
const cfg = {} as OpenClawConfig;
570+
const sessionKey = "agent:main:explicit:test-compaction-tokens-after";
571+
const sessionId = "test-compaction-tokens-after-session";
572+
const sessionStore: Record<string, SessionEntry> = {
573+
[sessionKey]: {
574+
sessionId,
575+
updatedAt: 1,
576+
},
577+
};
578+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
579+
580+
const result: EmbeddedPiRunResult = {
581+
meta: {
582+
durationMs: 500,
583+
agentMeta: {
584+
sessionId,
585+
provider: "minimax",
586+
model: "MiniMax-M2.7",
587+
compactionCount: 1,
588+
compactionTokensAfter: 21_225,
589+
},
590+
},
591+
};
592+
593+
await updateSessionStoreAfterAgentRun({
594+
cfg,
595+
sessionId,
596+
sessionKey,
597+
storePath,
598+
sessionStore,
599+
defaultProvider: "minimax",
600+
defaultModel: "MiniMax-M2.7",
601+
result,
602+
});
603+
604+
expect(sessionStore[sessionKey]?.totalTokens).toBe(21_225);
605+
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(true);
606+
expect(sessionStore[sessionKey]?.compactionCount).toBe(1);
607+
608+
const persisted = loadSessionStore(storePath);
609+
expect(persisted[sessionKey]?.totalTokens).toBe(21_225);
610+
expect(persisted[sessionKey]?.totalTokensFresh).toBe(true);
611+
});
612+
});
613+
614+
it("ignores non-finite compaction tokensAfter values", async () => {
615+
await withTempSessionStore(async ({ storePath }) => {
616+
const cfg = {} as OpenClawConfig;
617+
const sessionKey = "agent:main:explicit:test-compaction-tokens-after-invalid";
618+
const sessionId = "test-compaction-tokens-after-invalid-session";
619+
const sessionStore: Record<string, SessionEntry> = {
620+
[sessionKey]: {
621+
sessionId,
622+
updatedAt: 1,
623+
totalTokens: 12_000,
624+
totalTokensFresh: true,
625+
},
626+
};
627+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
628+
629+
await updateSessionStoreAfterAgentRun({
630+
cfg,
631+
sessionId,
632+
sessionKey,
633+
storePath,
634+
sessionStore,
635+
defaultProvider: "minimax",
636+
defaultModel: "MiniMax-M2.7",
637+
result: {
638+
meta: {
639+
durationMs: 500,
640+
agentMeta: {
641+
sessionId,
642+
provider: "minimax",
643+
model: "MiniMax-M2.7",
644+
compactionCount: 1,
645+
compactionTokensAfter: Number.POSITIVE_INFINITY,
646+
},
647+
},
648+
},
649+
});
650+
651+
expect(sessionStore[sessionKey]?.totalTokens).toBe(12_000);
652+
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(false);
653+
});
654+
});
655+
567656
it("snapshots cost instead of accumulating (fixes #69347)", async () => {
568657
await withTempSessionStore(async ({ storePath }) => {
569658
const cfg = {

src/agents/command/session-store.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ export async function updateSessionStoreAfterAgentRun(params: {
6868

6969
const usage = result.meta.agentMeta?.usage;
7070
const promptTokens = result.meta.agentMeta?.promptTokens;
71+
const compactionTokensAfter =
72+
typeof result.meta.agentMeta?.compactionTokensAfter === "number" &&
73+
Number.isFinite(result.meta.agentMeta.compactionTokensAfter) &&
74+
result.meta.agentMeta.compactionTokensAfter > 0
75+
? Math.floor(result.meta.agentMeta.compactionTokensAfter)
76+
: undefined;
7177
const compactionsThisRun = Math.max(0, result.meta.agentMeta?.compactionCount ?? 0);
7278
const modelUsed = result.meta.agentMeta?.model ?? fallbackModel ?? defaultModel;
7379
const providerUsed = result.meta.agentMeta?.provider ?? fallbackProvider ?? defaultProvider;
@@ -147,6 +153,9 @@ export async function updateSessionStoreAfterAgentRun(params: {
147153
if (typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0) {
148154
next.totalTokens = totalTokens;
149155
next.totalTokensFresh = true;
156+
} else if (compactionTokensAfter !== undefined) {
157+
next.totalTokens = compactionTokensAfter;
158+
next.totalTokensFresh = true;
150159
} else {
151160
next.totalTokens = undefined;
152161
next.totalTokensFresh = false;
@@ -159,6 +168,9 @@ export async function updateSessionStoreAfterAgentRun(params: {
159168
if (runEstimatedCostUsd !== undefined) {
160169
next.estimatedCostUsd = runEstimatedCostUsd;
161170
}
171+
} else if (compactionTokensAfter !== undefined) {
172+
next.totalTokens = compactionTokensAfter;
173+
next.totalTokensFresh = true;
162174
} else if (
163175
typeof entry.totalTokens === "number" &&
164176
Number.isFinite(entry.totalTokens) &&

src/agents/pi-embedded-runner/run.overflow-compaction.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
448448
}),
449449
);
450450

451-
await runEmbeddedPiAgent(overflowBaseRunParams);
451+
const result = await runEmbeddedPiAgent(overflowBaseRunParams);
452452

453453
expect(mockedCompactDirect).toHaveBeenCalledWith(
454454
expect.objectContaining({
@@ -469,6 +469,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
469469
}),
470470
}),
471471
);
472+
expect(result.meta.agentMeta?.compactionTokensAfter).toBe(80_000);
472473
});
473474

474475
it("passes observed overflow token counts into compaction when providers report them", async () => {

src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ describe("timeout-triggered compaction", () => {
100100
);
101101
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
102102
expect(result.meta.error).toBeUndefined();
103+
expect(result.meta.agentMeta?.compactionTokensAfter).toBe(80_000);
103104
});
104105

105106
it("retries the prompt after successful timeout compaction", async () => {

src/agents/pi-embedded-runner/run.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,7 @@ export async function runEmbeddedPiAgent(
639639
const usageAccumulator = createUsageAccumulator();
640640
let lastRunPromptUsage: ReturnType<typeof normalizeUsage> | undefined;
641641
let autoCompactionCount = 0;
642+
let lastCompactionTokensAfter: number | undefined;
642643
let runLoopIterations = 0;
643644
let overloadProfileRotations = 0;
644645
let planningOnlyRetryAttempts = 0;
@@ -1033,6 +1034,13 @@ export async function runEmbeddedPiAgent(
10331034
lastTurnTotal = lastAssistantUsage?.total ?? attemptUsage?.total;
10341035
const attemptCompactionCount = Math.max(0, attempt.compactionCount ?? 0);
10351036
autoCompactionCount += attemptCompactionCount;
1037+
if (
1038+
typeof attempt.compactionTokensAfter === "number" &&
1039+
Number.isFinite(attempt.compactionTokensAfter) &&
1040+
attempt.compactionTokensAfter > 0
1041+
) {
1042+
lastCompactionTokensAfter = Math.floor(attempt.compactionTokensAfter);
1043+
}
10361044
const activeErrorContext = resolveActiveErrorContext({
10371045
provider,
10381046
model: modelId,
@@ -1180,6 +1188,13 @@ export async function runEmbeddedPiAgent(
11801188
await runOwnsCompactionAfterHook("timeout recovery", timeoutCompactResult);
11811189
if (timeoutCompactResult.compacted) {
11821190
autoCompactionCount += 1;
1191+
if (
1192+
typeof timeoutCompactResult.result?.tokensAfter === "number" &&
1193+
Number.isFinite(timeoutCompactResult.result.tokensAfter) &&
1194+
timeoutCompactResult.result.tokensAfter > 0
1195+
) {
1196+
lastCompactionTokensAfter = Math.floor(timeoutCompactResult.result.tokensAfter);
1197+
}
11831198
if (contextEngine.info.ownsCompaction === true) {
11841199
await runPostCompactionSideEffects({
11851200
config: params.config,
@@ -1339,6 +1354,13 @@ export async function runEmbeddedPiAgent(
13391354
await runOwnsCompactionAfterHook("overflow recovery", compactResult);
13401355
if (compactResult.compacted) {
13411356
adoptCompactionTranscript(compactResult);
1357+
if (
1358+
typeof compactResult.result?.tokensAfter === "number" &&
1359+
Number.isFinite(compactResult.result.tokensAfter) &&
1360+
compactResult.result.tokensAfter > 0
1361+
) {
1362+
lastCompactionTokensAfter = Math.floor(compactResult.result.tokensAfter);
1363+
}
13421364
if (preflightRecovery?.route === "compact_then_truncate") {
13431365
const truncResult = await truncateOversizedToolResultsInSession({
13441366
sessionFile: activeSessionFile,
@@ -1856,6 +1878,7 @@ export async function runEmbeddedPiAgent(
18561878
lastCallUsage: usageMeta.lastCallUsage,
18571879
promptTokens: usageMeta.promptTokens,
18581880
compactionCount: autoCompactionCount > 0 ? autoCompactionCount : undefined,
1881+
compactionTokensAfter: lastCompactionTokensAfter,
18591882
};
18601883
const finalAssistantVisibleText = resolveFinalAssistantVisibleText(sessionLastAssistant);
18611884
const finalAssistantRawText = resolveFinalAssistantRawText(sessionLastAssistant);

src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export function createSubscriptionMock(): SubscriptionMock {
101101
getLastToolError: () => undefined,
102102
getUsageTotals: () => undefined,
103103
getCompactionCount: () => 0,
104+
getLastCompactionTokensAfter: () => undefined,
104105
getItemLifecycle: () => ({ startedCount: 0, completedCount: 0, activeCount: 0 }),
105106
isCompacting: () => false,
106107
isCompactionInFlight: () => false,

src/agents/pi-embedded-runner/run/attempt.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2093,6 +2093,7 @@ export async function runEmbeddedAttempt(
20932093
setTerminalLifecycleMeta,
20942094
getUsageTotals,
20952095
getCompactionCount,
2096+
getLastCompactionTokensAfter,
20962097
} = subscription;
20972098

20982099
const queueHandle: EmbeddedPiQueueHandle & {
@@ -3195,6 +3196,7 @@ export async function runEmbeddedAttempt(
31953196
attemptUsage,
31963197
promptCache,
31973198
compactionCount: getCompactionCount(),
3199+
compactionTokensAfter: getLastCompactionTokensAfter(),
31983200
// Client tool call detected (OpenResponses hosted tools)
31993201
clientToolCall: clientToolCallDetected ?? undefined,
32003202
yieldDetected: yieldDetected || undefined,

src/agents/pi-embedded-runner/run/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export type EmbeddedRunAttemptResult = {
102102
attemptUsage?: NormalizedUsage;
103103
promptCache?: ContextEnginePromptCacheInfo;
104104
compactionCount?: number;
105+
compactionTokensAfter?: number;
105106
/** Client tool call detected (OpenResponses hosted tools). */
106107
clientToolCall?: { name: string; params: Record<string, unknown> };
107108
/** True when sessions_yield tool was called during this attempt. */

src/agents/pi-embedded-runner/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ export type EmbeddedPiAgentMeta = {
1111
agentHarnessId?: string;
1212
cliSessionBinding?: CliSessionBinding;
1313
compactionCount?: number;
14+
/**
15+
* Token count estimate after the most recent successful auto-compaction.
16+
* Used as the freshest context snapshot when the follow-up model call omits
17+
* usage metadata.
18+
*/
19+
compactionTokensAfter?: number;
1420
/**
1521
* Prompt/context snapshot from the latest model request. Prefer this for
1622
* context-window utilization because provider usage totals can include cached

0 commit comments

Comments
 (0)