Skip to content

Commit 05c6e7a

Browse files
authored
feat(agents): expose estimated context budget status
Expose a path-free estimated context budget status on session entries and gateway session rows, render it in status when fresh provider usage is unavailable, and clear stale estimates across reset, refresh, compaction, and session-rotation boundaries. Verification: focused local Vitest covered session persistence, status rendering, gateway rows, model resets, compaction, and session rotation; GitHub CI passed on clean head cad199e. Refs #80594, #54996, #77992, #84490, #83177, #43009, #83526, #8635.
1 parent cd102ef commit 05c6e7a

26 files changed

Lines changed: 794 additions & 5 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ Docs: https://docs.openclaw.ai
7171
- Agents/OpenAI Responses: retry non-visible reasoning-only turns for OpenAI Responses API families instead of treating them as empty failed turns. (#85603) Thanks @SebTardif.
7272
- Directive tags: preserve message and content-part object identity when display stripping makes no directive-tag changes. (#85682) Thanks @willamhou.
7373
- Telegram: send local `path`/`filePath` and structured attachment media from `sendMessage` actions instead of dropping them or sending text-only messages. (#85219) Thanks @keshavbotagent.
74+
- Sessions/status: show the estimated context budget when fresh provider usage is unavailable and clear stale estimates across session resets and compaction boundaries. (#84830) Thanks @giodl73-repo.
7475
- Gateway/config: pin relative `OPENCLAW_STATE_DIR` overrides to an absolute path at startup so later working-directory changes cannot retarget gateway state. (#52264) Thanks @PerfectPan.
7576
- Checks/Parallels: make changed-lane scripts, shrinkwrap generation, and Parallels package smoke host commands run through native Windows-safe paths and `npm`/`pnpm` shims.
7677
- Release/package: run npm release, prepublish, and postpublish verification through Windows-safe npm command shims so native Windows checks can execute `npm.cmd` instead of treating it as a binary.

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

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,140 @@ describe("updateSessionStoreAfterAgentRun", () => {
636636
});
637637
});
638638

639+
it("persists estimated context budget status without marking stale usage fresh", async () => {
640+
await withTempSessionStore(async ({ storePath }) => {
641+
const cfg = {} as OpenClawConfig;
642+
const sessionKey = "agent:main:explicit:test-context-budget-status";
643+
const sessionId = "test-context-budget-status-session";
644+
const sessionStore: Record<string, SessionEntry> = {
645+
[sessionKey]: {
646+
sessionId,
647+
updatedAt: 1,
648+
totalTokens: 21225,
649+
totalTokensFresh: true,
650+
},
651+
};
652+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
653+
654+
const result: EmbeddedPiRunResult = {
655+
meta: {
656+
durationMs: 500,
657+
agentMeta: {
658+
sessionId,
659+
provider: "minimax",
660+
model: "MiniMax-M2.7",
661+
contextBudgetStatus: {
662+
schemaVersion: 1,
663+
source: "pre-prompt-estimate",
664+
updatedAt: 123,
665+
provider: "minimax",
666+
model: "MiniMax-M2.7",
667+
route: "fits",
668+
shouldCompact: false,
669+
estimatedPromptTokens: 18_000,
670+
contextTokenBudget: 32_000,
671+
promptBudgetBeforeReserve: 28_000,
672+
reserveTokens: 4_000,
673+
effectiveReserveTokens: 4_000,
674+
remainingPromptBudgetTokens: 10_000,
675+
overflowTokens: 0,
676+
toolResultReducibleChars: 0,
677+
messageCount: 4,
678+
unwindowedMessageCount: 4,
679+
},
680+
},
681+
},
682+
};
683+
684+
await updateSessionStoreAfterAgentRun({
685+
cfg,
686+
sessionId,
687+
sessionKey,
688+
storePath,
689+
sessionStore,
690+
defaultProvider: "minimax",
691+
defaultModel: "MiniMax-M2.7",
692+
result,
693+
});
694+
695+
expect(sessionStore[sessionKey]?.totalTokens).toBe(21225);
696+
expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(false);
697+
expect(sessionStore[sessionKey]?.contextBudgetStatus).toMatchObject({
698+
source: "pre-prompt-estimate",
699+
estimatedPromptTokens: 18_000,
700+
contextTokenBudget: 32_000,
701+
});
702+
703+
const persisted = loadSessionStore(storePath);
704+
expect(persisted[sessionKey]?.contextBudgetStatus?.estimatedPromptTokens).toBe(18_000);
705+
});
706+
});
707+
708+
it("clears stale estimated context budget status when a runtime refresh has no current estimate", async () => {
709+
await withTempSessionStore(async ({ storePath }) => {
710+
const cfg = {} as OpenClawConfig;
711+
const sessionKey = "agent:main:explicit:test-clear-context-budget-status";
712+
const sessionId = "test-clear-context-budget-status-session";
713+
const sessionStore: Record<string, SessionEntry> = {
714+
[sessionKey]: {
715+
sessionId,
716+
updatedAt: 1,
717+
totalTokens: 21225,
718+
totalTokensFresh: false,
719+
contextBudgetStatus: {
720+
schemaVersion: 1,
721+
source: "pre-prompt-estimate",
722+
updatedAt: 123,
723+
provider: "anthropic",
724+
model: "claude-sonnet-4.6",
725+
route: "fits",
726+
shouldCompact: false,
727+
estimatedPromptTokens: 18_000,
728+
contextTokenBudget: 32_000,
729+
promptBudgetBeforeReserve: 28_000,
730+
reserveTokens: 4_000,
731+
effectiveReserveTokens: 4_000,
732+
remainingPromptBudgetTokens: 10_000,
733+
overflowTokens: 0,
734+
toolResultReducibleChars: 0,
735+
messageCount: 4,
736+
unwindowedMessageCount: 4,
737+
},
738+
},
739+
};
740+
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
741+
742+
const result: EmbeddedPiRunResult = {
743+
meta: {
744+
durationMs: 500,
745+
agentMeta: {
746+
sessionId,
747+
provider: "minimax",
748+
model: "MiniMax-M2.7",
749+
},
750+
},
751+
};
752+
753+
await updateSessionStoreAfterAgentRun({
754+
cfg,
755+
sessionId,
756+
sessionKey,
757+
storePath,
758+
sessionStore,
759+
defaultProvider: "minimax",
760+
defaultModel: "MiniMax-M2.7",
761+
result,
762+
});
763+
764+
expect(sessionStore[sessionKey]?.modelProvider).toBe("minimax");
765+
expect(sessionStore[sessionKey]?.model).toBe("MiniMax-M2.7");
766+
expect(sessionStore[sessionKey]?.contextBudgetStatus).toBeUndefined();
767+
768+
const persisted = loadSessionStore(storePath);
769+
expect(persisted[sessionKey]?.contextBudgetStatus).toBeUndefined();
770+
});
771+
});
772+
639773
it("does not treat CLI cumulative usage as a fresh context snapshot", async () => {
640774
await withTempSessionStore(async ({ storePath }) => {
641775
const cfg = {
@@ -1067,6 +1201,25 @@ describe("updateSessionStoreAfterAgentRun", () => {
10671201
modelProvider: "anthropic",
10681202
model: "claude-opus-4-6",
10691203
contextTokens: 1_000_000,
1204+
contextBudgetStatus: {
1205+
schemaVersion: 1,
1206+
source: "pre-prompt-estimate",
1207+
updatedAt: 100,
1208+
provider: "anthropic",
1209+
model: "claude-opus-4-6",
1210+
route: "fits",
1211+
shouldCompact: false,
1212+
estimatedPromptTokens: 640_000,
1213+
contextTokenBudget: 1_000_000,
1214+
promptBudgetBeforeReserve: 900_000,
1215+
reserveTokens: 100_000,
1216+
effectiveReserveTokens: 100_000,
1217+
remainingPromptBudgetTokens: 260_000,
1218+
overflowTokens: 0,
1219+
toolResultReducibleChars: 0,
1220+
messageCount: 12,
1221+
unwindowedMessageCount: 12,
1222+
},
10701223
},
10711224
};
10721225
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
@@ -1080,6 +1233,25 @@ describe("updateSessionStoreAfterAgentRun", () => {
10801233
provider: "ollama",
10811234
model: "llama3.2:1b",
10821235
contextTokens: 128_000,
1236+
contextBudgetStatus: {
1237+
schemaVersion: 1,
1238+
source: "pre-prompt-estimate",
1239+
updatedAt: 200,
1240+
provider: "ollama",
1241+
model: "llama3.2:1b",
1242+
route: "fits",
1243+
shouldCompact: false,
1244+
estimatedPromptTokens: 40_000,
1245+
contextTokenBudget: 128_000,
1246+
promptBudgetBeforeReserve: 112_000,
1247+
reserveTokens: 16_000,
1248+
effectiveReserveTokens: 16_000,
1249+
remainingPromptBudgetTokens: 72_000,
1250+
overflowTokens: 0,
1251+
toolResultReducibleChars: 0,
1252+
messageCount: 3,
1253+
unwindowedMessageCount: 3,
1254+
},
10831255
},
10841256
},
10851257
};
@@ -1100,11 +1272,15 @@ describe("updateSessionStoreAfterAgentRun", () => {
11001272
expect(sessionStore[sessionKey]?.model).toBe("claude-opus-4-6");
11011273
expect(sessionStore[sessionKey]?.modelProvider).toBe("anthropic");
11021274
expect(sessionStore[sessionKey]?.contextTokens).toBe(1_000_000);
1275+
expect(sessionStore[sessionKey]?.contextBudgetStatus?.provider).toBe("anthropic");
1276+
expect(sessionStore[sessionKey]?.contextBudgetStatus?.estimatedPromptTokens).toBe(640_000);
11031277

11041278
const persisted = loadSessionStore(storePath);
11051279
expect(persisted[sessionKey]?.model).toBe("claude-opus-4-6");
11061280
expect(persisted[sessionKey]?.modelProvider).toBe("anthropic");
11071281
expect(persisted[sessionKey]?.contextTokens).toBe(1_000_000);
1282+
expect(persisted[sessionKey]?.contextBudgetStatus?.provider).toBe("anthropic");
1283+
expect(persisted[sessionKey]?.contextBudgetStatus?.estimatedPromptTokens).toBe(640_000);
11081284
});
11091285
});
11101286

@@ -1313,6 +1489,25 @@ describe("recordCliCompactionInStore", () => {
13131489
outputTokens: 100,
13141490
cacheRead: 2_900,
13151491
cacheWrite: 0,
1492+
contextBudgetStatus: {
1493+
schemaVersion: 1,
1494+
source: "pre-prompt-estimate",
1495+
updatedAt: 123,
1496+
provider: "codex",
1497+
model: "gpt-5.5",
1498+
route: "fits",
1499+
shouldCompact: false,
1500+
estimatedPromptTokens: 18_000,
1501+
contextTokenBudget: 32_000,
1502+
promptBudgetBeforeReserve: 28_000,
1503+
reserveTokens: 4_000,
1504+
effectiveReserveTokens: 4_000,
1505+
remainingPromptBudgetTokens: 10_000,
1506+
overflowTokens: 0,
1507+
toolResultReducibleChars: 0,
1508+
messageCount: 4,
1509+
unwindowedMessageCount: 4,
1510+
},
13161511
cliSessionBindings: {
13171512
codex: {
13181513
sessionId: "stale-cli-session",
@@ -1341,10 +1536,12 @@ describe("recordCliCompactionInStore", () => {
13411536
expect(sessionStore[sessionKey]?.outputTokens).toBeUndefined();
13421537
expect(sessionStore[sessionKey]?.cacheRead).toBeUndefined();
13431538
expect(sessionStore[sessionKey]?.cacheWrite).toBeUndefined();
1539+
expect(sessionStore[sessionKey]?.contextBudgetStatus).toBeUndefined();
13441540
expect(sessionStore[sessionKey]?.cliSessionBindings?.codex).toBeUndefined();
13451541
expect(sessionStore[sessionKey]?.cliSessionIds?.codex).toBeUndefined();
13461542
expect(persisted[sessionKey]?.totalTokens).toBe(0);
13471543
expect(persisted[sessionKey]?.totalTokensFresh).toBe(true);
1544+
expect(persisted[sessionKey]?.contextBudgetStatus).toBeUndefined();
13481545
});
13491546
});
13501547

@@ -1362,6 +1559,25 @@ describe("recordCliCompactionInStore", () => {
13621559
outputTokens: 100,
13631560
cacheRead: 6_900,
13641561
cacheWrite: 0,
1562+
contextBudgetStatus: {
1563+
schemaVersion: 1,
1564+
source: "pre-prompt-estimate",
1565+
updatedAt: 123,
1566+
provider: "codex",
1567+
model: "gpt-5.5",
1568+
route: "compact_only",
1569+
shouldCompact: true,
1570+
estimatedPromptTokens: 48_000,
1571+
contextTokenBudget: 32_000,
1572+
promptBudgetBeforeReserve: 28_000,
1573+
reserveTokens: 4_000,
1574+
effectiveReserveTokens: 4_000,
1575+
remainingPromptBudgetTokens: 0,
1576+
overflowTokens: 20_000,
1577+
toolResultReducibleChars: 0,
1578+
messageCount: 40,
1579+
unwindowedMessageCount: 40,
1580+
},
13651581
},
13661582
};
13671583
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2));
@@ -1381,8 +1597,10 @@ describe("recordCliCompactionInStore", () => {
13811597
expect(sessionStore[sessionKey]?.outputTokens).toBeUndefined();
13821598
expect(sessionStore[sessionKey]?.cacheRead).toBeUndefined();
13831599
expect(sessionStore[sessionKey]?.cacheWrite).toBeUndefined();
1600+
expect(sessionStore[sessionKey]?.contextBudgetStatus).toBeUndefined();
13841601
expect(persisted[sessionKey]?.totalTokens).toBe(37_000);
13851602
expect(persisted[sessionKey]?.totalTokensFresh).toBe(false);
1603+
expect(persisted[sessionKey]?.contextBudgetStatus).toBeUndefined();
13861604
});
13871605
});
13881606

src/agents/command/session-store.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export async function updateSessionStoreAfterAgentRun(params: {
101101
const providerUsed = result.meta.agentMeta?.provider ?? fallbackProvider ?? defaultProvider;
102102
const agentHarnessId = normalizeOptionalString(result.meta.agentMeta?.agentHarnessId);
103103
const runtimeContextTokens = resolvePositiveInteger(result.meta.agentMeta?.contextTokens);
104+
const contextBudgetStatus = result.meta.agentMeta?.contextBudgetStatus;
104105
const contextTokens =
105106
runtimeContextTokens !== undefined
106107
? runtimeContextTokens
@@ -180,6 +181,9 @@ export async function updateSessionStoreAfterAgentRun(params: {
180181
if (result.meta.systemPromptReport) {
181182
next.systemPromptReport = result.meta.systemPromptReport;
182183
}
184+
if (!preserveRuntimeModel) {
185+
next.contextBudgetStatus = contextBudgetStatus;
186+
}
183187
if (hasNonzeroUsage(usage)) {
184188
const { estimateUsageCost, resolveModelCostConfig } = await getUsageFormatModule();
185189
const input = usage.input ?? 0;
@@ -315,6 +319,7 @@ export async function recordCliCompactionInStore(params: {
315319
next.sessionFile = explicitNewSessionFile;
316320
}
317321
const tokensAfterCompaction = resolveNonNegativeNumber(params.tokensAfter);
322+
next.contextBudgetStatus = undefined;
318323
if (tokensAfterCompaction !== undefined) {
319324
next.totalTokens = Math.floor(tokensAfterCompaction);
320325
next.totalTokensFresh = true;

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1030,6 +1030,7 @@ export async function runEmbeddedPiAgent(
10301030
let lastRunPromptUsage: ReturnType<typeof normalizeUsage> | undefined;
10311031
let autoCompactionCount = 0;
10321032
let lastCompactionTokensAfter: number | undefined;
1033+
let lastContextBudgetStatus: EmbeddedPiAgentMeta["contextBudgetStatus"];
10331034
let runLoopIterations = 0;
10341035
let overloadProfileRotations = 0;
10351036
let planningOnlyRetryAttempts = 0;
@@ -1640,6 +1641,9 @@ export async function runEmbeddedPiAgent(
16401641
) {
16411642
lastCompactionTokensAfter = Math.floor(attempt.compactionTokensAfter);
16421643
}
1644+
if (attempt.contextBudgetStatus) {
1645+
lastContextBudgetStatus = attempt.contextBudgetStatus;
1646+
}
16431647
const activeErrorContext = resolveActiveErrorContext({
16441648
provider,
16451649
model: modelId,
@@ -2676,6 +2680,7 @@ export async function runEmbeddedPiAgent(
26762680
usage: usageMeta.usage,
26772681
lastCallUsage: usageMeta.lastCallUsage,
26782682
promptTokens: usageMeta.promptTokens,
2683+
...(lastContextBudgetStatus ? { contextBudgetStatus: lastContextBudgetStatus } : {}),
26792684
compactionCount: autoCompactionCount > 0 ? autoCompactionCount : undefined,
26802685
compactionTokensAfter: lastCompactionTokensAfter,
26812686
};

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,7 @@ import {
396396
} from "./midturn-precheck.js";
397397
import {
398398
PREEMPTIVE_OVERFLOW_ERROR_TEXT,
399+
buildPrePromptContextBudgetStatus,
399400
formatPrePromptPrecheckLog,
400401
shouldPreemptivelyCompactBeforePrompt,
401402
} from "./preemptive-compaction.js";
@@ -3370,6 +3371,7 @@ export async function runEmbeddedAttempt(
33703371
let cacheBreak: PromptCacheBreak | null = null;
33713372
let promptCache: EmbeddedRunAttemptResult["promptCache"];
33723373
let lastCallUsage: NormalizedUsage | undefined;
3374+
let contextBudgetStatus: EmbeddedRunAttemptResult["contextBudgetStatus"];
33733375
let compactionOccurredThisAttempt = false;
33743376
let finalPromptText: string | undefined;
33753377
if (params.replyOperation) {
@@ -4066,6 +4068,19 @@ export async function runEmbeddedAttempt(
40664068
}),
40674069
});
40684070
if (preemptiveCompaction) {
4071+
contextBudgetStatus = buildPrePromptContextBudgetStatus({
4072+
result: preemptiveCompaction,
4073+
provider: params.provider,
4074+
modelId: params.modelId,
4075+
messageCount: activeSession.messages.length,
4076+
contextTokenBudget,
4077+
reserveTokens,
4078+
...(params.sessionId ? { sessionId: params.sessionId } : {}),
4079+
...(contextEnginePromptAuthority === "preassembly_may_overflow" &&
4080+
unwindowedContextEngineMessagesForPrecheck
4081+
? { unwindowedMessageCount: unwindowedContextEngineMessagesForPrecheck.length }
4082+
: {}),
4083+
});
40694084
log.debug(
40704085
formatPrePromptPrecheckLog({
40714086
result: preemptiveCompaction,
@@ -4880,6 +4895,7 @@ export async function runEmbeddedAttempt(
48804895
),
48814896
attemptUsage,
48824897
promptCache,
4898+
contextBudgetStatus,
48834899
compactionCount: getCompactionCount(),
48844900
compactionTokensAfter: getLastCompactionTokensAfter(),
48854901
// Client tool calls detected (OpenResponses hosted tools).

0 commit comments

Comments
 (0)