Skip to content

Commit 65deda8

Browse files
committed
fix: uniform assemble tokenBudget and messages-only currentTokenCount
tokenBudget passed to ContextEngine.assemble now means the same thing on every harness path: the budget available for assembled messages, computed by the shared computeContextEngineMessageBudget helper (context window minus compaction reserve minus rendered system/user prompt pressure). currentTokenCount is now a messages-only estimate (estimateTranscriptTokenPressure), so engines size systemPromptAddition as tokenBudget - currentTokenCount with no double-counting. - PI main assemble: budget block now uses the shared helper; count is messages-only - PI tool loop: new assembleTokenBudget hook param carries the loop budget; afterTurn keeps the raw window per its contract; the hook resolves getRuntimeContext once per iteration instead of twice - Codex harness: derives the budget with reserve 0 (native compaction owns the reserve concept there) - plugin SDK re-exports both helpers instead of the removed estimatePrePromptTokens alias; docs and interface comments updated
1 parent d1a90d7 commit 65deda8

10 files changed

Lines changed: 131 additions & 51 deletions

File tree

docs/concepts/context-engine.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Every time OpenClaw runs a model prompt, the context engine participates at four
7474
Called when a new message is added to the session. The engine can store or index the message in its own data store.
7575
</Accordion>
7676
<Accordion title="2. Assemble">
77-
Called before each model run. The engine returns an ordered set of messages (and an optional `systemPromptAddition`) that fit within the token budget. OpenClaw also passes an optional `currentTokenCount` — a best-effort estimate of tokens already consumed by `messages + system prompt + incoming prompt` — so engines can bound any `systemPromptAddition` against the remaining headroom.
77+
Called before each model run. The engine returns an ordered set of messages (and an optional `systemPromptAddition`) that fit within the token budget. OpenClaw also passes an optional `currentTokenCount` — a best-effort estimate of the tokens the pre-assembly `messages` already consume — so engines can bound any `systemPromptAddition` against the remaining headroom.
7878
</Accordion>
7979
<Accordion title="3. Compact">
8080
Called when the context window is full, or when the user runs `/compact`. The engine summarizes older history to free space.
@@ -108,19 +108,18 @@ The `assemble` method can return a `systemPromptAddition` string. OpenClaw prepe
108108
`assemble` receives two values that together let engines bound their injection:
109109

110110
<ParamField path="tokenBudget" type="number">
111-
The model's full input context window for this run (e.g. `200_000`, `1_048_576`). Not the headroom remaining — engines must subtract what is already consumed.
111+
The budget available for the assembled messages. The runtime computes this as the model's context window minus its compaction reserve (when the harness holds one) and the rendered system/user prompt pressure — engines do not subtract those themselves.
112112
</ParamField>
113113
<ParamField path="currentTokenCount" type="number">
114-
Best-effort pre-assembly estimate of tokens already consumed by `messages + system prompt + incoming prompt`. Computed by the runtime via the same estimator the preemptive overflow precheck uses, so engines see the same number the runtime would. Optional — may be `undefined` on older OpenClaw runtimes; treat absence as "no headroom info, inject conservatively or skip the guard."
114+
Best-effort estimate of the tokens the pre-assembly `messages` already consume, computed with the same transcript estimator the runtime uses (`estimateTranscriptTokenPressure` on `openclaw/plugin-sdk/agent-harness-runtime`), so engines see the same number the runtime would. Optional — `undefined` on older OpenClaw runtimes; treat absence as "no headroom info, inject conservatively or skip the guard."
115115
</ParamField>
116116

117-
A typical guard looks like:
117+
Both values live on the same scale, so the remaining headroom for a `systemPromptAddition` is their difference:
118118

119119
```ts
120-
const responseReserve = 8_192; // assistant response budget; not in currentTokenCount
121120
const remaining =
122121
tokenBudget !== undefined && currentTokenCount !== undefined
123-
? Math.max(0, tokenBudget - currentTokenCount - responseReserve)
122+
? Math.max(0, tokenBudget - currentTokenCount)
124123
: undefined;
125124

126125
if (remaining !== undefined && estimateTokens(additionContent) > remaining) {

extensions/codex/src/app-server/run-attempt.context-engine.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,12 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
408408
>[0];
409409
expect(assembleParams.sessionId).toBe("session-1");
410410
expect(assembleParams.sessionKey).toBe("agent:main:session-1");
411-
expect(assembleParams.tokenBudget).toBe(321);
411+
// The harness passes the message budget: contextTokenBudget (321) minus the
412+
// rendered developer-instructions/prompt pressure, with no reserve on the
413+
// codex path (native compaction).
414+
expect(typeof assembleParams.tokenBudget).toBe("number");
415+
expect(assembleParams.tokenBudget as number).toBeGreaterThan(0);
416+
expect(assembleParams.tokenBudget as number).toBeLessThan(321);
412417
expect(assembleParams.citationsMode).toBe("on");
413418
expect(assembleParams.model).toBe("gpt-5.4-codex");
414419
expect(assembleParams.runtimeSettings).toMatchObject({

extensions/codex/src/app-server/run-attempt.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import {
1111
clearActiveEmbeddedRun,
1212
embeddedAgentLog,
1313
emitAgentEvent as emitGlobalAgentEvent,
14-
estimateLlmBoundaryTokenPressure,
14+
computeContextEngineMessageBudget,
15+
estimateTranscriptTokenPressure,
1516
finalizeHarnessContextEngineTurn,
1617
FAST_MODE_AUTO_PROGRESS_KIND,
1718
formatFastModeAutoProgressText,
@@ -1025,18 +1026,27 @@ export async function runCodexAppServerAttempt(
10251026
if (!activeContextEngine) {
10261027
return;
10271028
}
1028-
// Pre-assembly token estimate so engines can bound any systemPromptAddition against tokenBudget.
1029-
const preassemblyCurrentTokenCount = estimateLlmBoundaryTokenPressure({
1030-
messages: historyMessages,
1031-
systemPrompt: developerInstructions,
1032-
prompt: params.prompt ?? "",
1033-
});
1029+
// Codex app-server owns compaction natively, so no OpenClaw compaction
1030+
// reserve is held back on this path — the budget only sets aside the
1031+
// rendered developer-instructions/prompt pressure.
1032+
const assembleTokenBudget =
1033+
typeof params.contextTokenBudget === "number"
1034+
? computeContextEngineMessageBudget({
1035+
contextWindowTokens: params.contextTokenBudget,
1036+
compactionReserveTokens: 0,
1037+
systemPrompt: developerInstructions,
1038+
prompt: params.prompt ?? "",
1039+
})
1040+
: undefined;
1041+
// Messages-only estimate: engines size any systemPromptAddition as
1042+
// tokenBudget - currentTokenCount.
1043+
const preassemblyCurrentTokenCount = estimateTranscriptTokenPressure(historyMessages);
10341044
const assembled = await assembleHarnessContextEngine({
10351045
contextEngine: activeContextEngine,
10361046
sessionId: activeSessionId,
10371047
sessionKey: contextSessionKey,
10381048
messages: historyMessages,
1039-
tokenBudget: params.contextTokenBudget,
1049+
tokenBudget: assembleTokenBudget,
10401050
currentTokenCount: preassemblyCurrentTokenCount,
10411051
availableTools: new Set(
10421052
flattenCodexDynamicToolFunctions(toolBridge.availableSpecs)

src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3013,6 +3013,11 @@ describe("runEmbeddedAttempt context engine mid-turn precheck integration", () =
30133013
});
30143014
expect(typeof runtimeContext?.currentTokenCount).toBe("number");
30153015
expect(runtimeContext?.currentTokenCount as number).toBeGreaterThan(0);
3016+
3017+
// The install site also derives a loop assemble budget (window minus
3018+
// reserve and rendered system-prompt pressure) for the hook.
3019+
expect(typeof loopHookParams.assembleTokenBudget).toBe("number");
3020+
expect(loopHookParams.assembleTokenBudget as number).toBeGreaterThan(0);
30163021
});
30173022

30183023
it("recovers when the runtime persists the mid-turn precheck as an assistant error", async () => {

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

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -499,8 +499,9 @@ import {
499499
import {
500500
PREEMPTIVE_OVERFLOW_ERROR_TEXT,
501501
buildPrePromptContextBudgetStatus,
502+
computeContextEngineMessageBudget,
502503
estimateLlmBoundaryTokenPressure,
503-
estimateRenderedLlmBoundaryTokenPressure,
504+
estimateTranscriptTokenPressure,
504505
formatPrePromptPrecheckLog,
505506
shouldPreemptivelyCompactBeforePrompt,
506507
} from "./preemptive-compaction.js";
@@ -2671,13 +2672,23 @@ export async function runEmbeddedAttempt(
26712672
fallbackReason: params.fallbackReason,
26722673
degradedReason: params.degradedReason,
26732674
});
2675+
// Mid-tool-loop assemble calls carry no new user prompt — the original
2676+
// prompt is already part of the transcript — so the loop budget only
2677+
// sets aside the system prompt and the compaction reserve.
2678+
const loopAssembleTokenBudget = computeContextEngineMessageBudget({
2679+
contextWindowTokens: contextTokenBudgetForGuard,
2680+
compactionReserveTokens: settingsManager.getCompactionReserveTokens(),
2681+
systemPrompt: systemPromptText,
2682+
prompt: "",
2683+
});
26742684
const removeContextEngineLoopHook = installContextEngineLoopHook({
26752685
agent: activeSession.agent,
26762686
contextEngine: activeContextEngine,
26772687
sessionId: params.sessionId,
26782688
sessionKey: params.sessionKey,
26792689
sessionFile: params.sessionFile,
26802690
tokenBudget: params.contextTokenBudget,
2691+
assembleTokenBudget: loopAssembleTokenBudget,
26812692
modelId: params.modelId,
26822693
...(transcriptPolicy.repairToolUseResultPairing
26832694
? {
@@ -2696,14 +2707,9 @@ export async function runEmbeddedAttempt(
26962707
cwd: effectiveCwd,
26972708
agentDir,
26982709
tokenBudget: params.contextTokenBudget,
2699-
// Mid-tool-loop the next model call has no new user prompt — the
2700-
// continuation is driven by tool results that are already in
2701-
// `messages`. Estimate against messages + system prompt only.
2702-
currentTokenCount: estimateLlmBoundaryTokenPressure({
2703-
messages,
2704-
systemPrompt: systemPromptText,
2705-
prompt: "",
2706-
}),
2710+
// Messages-only estimate to match the assemble contract: the loop
2711+
// assemble budget already sets aside the system prompt and reserve.
2712+
currentTokenCount: estimateTranscriptTokenPressure(messages),
27072713
promptCache:
27082714
promptCache ??
27092715
buildLoopPromptCacheInfo({
@@ -3372,25 +3378,18 @@ export async function runEmbeddedAttempt(
33723378
DEFAULT_CONTEXT_TOKENS,
33733379
),
33743380
);
3375-
const contextEngineAssemblePromptBudget = Math.max(
3376-
1,
3377-
contextEngineAssembleContextTokenBudget - contextEngineAssembleReserveTokens,
3378-
);
3379-
const contextEngineAssembleRenderedPromptTokens =
3380-
estimateRenderedLlmBoundaryTokenPressure({
3381-
systemPrompt: systemPromptText,
3382-
prompt: params.prompt ?? "",
3383-
});
3384-
const contextEngineAssembleMessageBudget = Math.max(
3385-
1,
3386-
contextEngineAssemblePromptBudget - contextEngineAssembleRenderedPromptTokens,
3387-
);
3388-
// Pre-assembly token estimate so engines can bound any systemPromptAddition against tokenBudget.
3389-
const preassemblyCurrentTokenCount = estimateLlmBoundaryTokenPressure({
3390-
messages: activeSession.messages,
3381+
const contextEngineAssembleMessageBudget = computeContextEngineMessageBudget({
3382+
contextWindowTokens: contextEngineAssembleContextTokenBudget,
3383+
compactionReserveTokens: contextEngineAssembleReserveTokens,
33913384
systemPrompt: systemPromptText,
33923385
prompt: params.prompt ?? "",
33933386
});
3387+
// Messages-only estimate: tokenBudget already accounts for the rendered
3388+
// system/user prompt and the compaction reserve, so engines size any
3389+
// systemPromptAddition as tokenBudget - currentTokenCount.
3390+
const preassemblyCurrentTokenCount = estimateTranscriptTokenPressure(
3391+
activeSession.messages,
3392+
);
33943393
const assembled = await assembleAttemptContextEngine({
33953394
contextEngine: activeContextEngine,
33963395
sessionId: params.sessionId,

src/agents/embedded-agent-runner/run/preemptive-compaction.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,41 @@ export function estimateRenderedLlmBoundaryTokenPressure(params: {
287287
return Math.max(0, Math.ceil((systemTokens + promptTokens) * SAFETY_MARGIN));
288288
}
289289

290+
/**
291+
* Estimates the token pressure of transcript messages alone — no system prompt or
292+
* incoming prompt. This is the estimator behind `ContextEngine.assemble`'s
293+
* `currentTokenCount`: it pairs with `computeContextEngineMessageBudget` so engines
294+
* can compute assemble headroom as `tokenBudget - currentTokenCount`.
295+
*/
296+
export function estimateTranscriptTokenPressure(messages: AgentMessage[]): number {
297+
const historyTokens = messages.reduce(
298+
(sum, message) => sum + estimateMessageTokenPressure(message),
299+
0,
300+
);
301+
return Math.max(0, Math.ceil(historyTokens * SAFETY_MARGIN));
302+
}
303+
304+
/**
305+
* Budget available to a context engine's assembled messages: the resolved context
306+
* window minus the runtime's compaction reserve and the rendered system/user prompt
307+
* pressure. Every `ContextEngine.assemble` call site derives `tokenBudget` through
308+
* this helper so the field means the same thing on every harness path.
309+
*/
310+
export function computeContextEngineMessageBudget(params: {
311+
contextWindowTokens: number;
312+
compactionReserveTokens: number;
313+
systemPrompt?: string;
314+
prompt: string;
315+
}): number {
316+
const reserveTokens = Math.max(0, Math.floor(params.compactionReserveTokens));
317+
const promptBudget = Math.max(1, Math.floor(params.contextWindowTokens) - reserveTokens);
318+
const renderedPromptTokens = estimateRenderedLlmBoundaryTokenPressure({
319+
systemPrompt: params.systemPrompt,
320+
prompt: params.prompt,
321+
});
322+
return Math.max(1, promptBudget - renderedPromptTokens);
323+
}
324+
290325
function normalizeLlmBoundaryTokenPressure(
291326
pressure: LlmBoundaryTokenPressure | undefined,
292327
): LlmBoundaryTokenPressure | undefined {

src/agents/embedded-agent-runner/tool-result-context-guard.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,29 @@ describe("installContextEngineLoopHook", () => {
737737
expect(assembleParams).not.toHaveProperty("currentTokenCount");
738738
});
739739

740+
it("prefers assembleTokenBudget for assemble while afterTurn keeps tokenBudget", async () => {
741+
const agent = makeGuardableAgent();
742+
const engine = makeMockEngine();
743+
installContextEngineLoopHook({
744+
agent,
745+
contextEngine: engine,
746+
sessionId,
747+
sessionKey,
748+
sessionFile,
749+
tokenBudget,
750+
assembleTokenBudget: 2048,
751+
modelId,
752+
getPrePromptMessageCount: () => 1,
753+
});
754+
755+
const messages = [makeUser("first"), makeToolResult("call_1", "result")];
756+
await callTransform(agent, messages);
757+
758+
expect(recordMockArg(engine.afterTurn).tokenBudget).toBe(tokenBudget);
759+
const assembleParams = engine.assemble.mock.calls.at(0)?.[0];
760+
expect(assembleParams?.tokenBudget).toBe(2048);
761+
});
762+
740763
it("passes loop messages and the prompt fence into the runtimeContext callback", async () => {
741764
const agent = makeGuardableAgent();
742765
const engine = makeMockEngine();

src/agents/embedded-agent-runner/tool-result-context-guard.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,8 @@ export function installContextEngineLoopHook(params: {
330330
sessionKey?: string;
331331
sessionFile: string;
332332
tokenBudget?: number;
333+
/** Budget for assemble calls: context window minus compaction reserve and rendered system-prompt pressure. Falls back to `tokenBudget` when absent. */
334+
assembleTokenBudget?: number;
333335
modelId: string;
334336
repairAssembledMessages?: (messages: AgentMessage[]) => AgentMessage[];
335337
getPrePromptMessageCount?: () => number;
@@ -393,6 +395,12 @@ export function installContextEngineLoopHook(params: {
393395
return lastAssembledView ?? providerMessages;
394396
}
395397
try {
398+
// Resolved once per iteration: afterTurn and assemble share it, so the
399+
// currentTokenCount estimate inside the callback is computed once.
400+
const loopRuntimeContext = params.getRuntimeContext?.({
401+
messages: transcriptMessages,
402+
prePromptMessageCount,
403+
});
396404
if (typeof contextEngine.afterTurn === "function") {
397405
await contextEngine.afterTurn({
398406
sessionId,
@@ -401,10 +409,7 @@ export function installContextEngineLoopHook(params: {
401409
messages: transcriptMessages,
402410
prePromptMessageCount,
403411
tokenBudget,
404-
runtimeContext: params.getRuntimeContext?.({
405-
messages: transcriptMessages,
406-
prePromptMessageCount,
407-
}),
412+
runtimeContext: loopRuntimeContext,
408413
runtimeSettings: params.runtimeSettings,
409414
isHeartbeat: params.isHeartbeat,
410415
});
@@ -433,15 +438,12 @@ export function installContextEngineLoopHook(params: {
433438
lastSeenLength = transcriptMessages.length;
434439
params.onAfterTurnCheckpoint?.(lastSeenLength);
435440
lastSourceMessages = transcriptMessages;
436-
const loopCurrentTokenCount = params.getRuntimeContext?.({
437-
messages: sourceMessages,
438-
prePromptMessageCount,
439-
})?.currentTokenCount;
441+
const loopCurrentTokenCount = loopRuntimeContext?.currentTokenCount;
440442
const assembled = await contextEngine.assemble({
441443
sessionId,
442444
sessionKey,
443445
messages: providerMessages,
444-
tokenBudget,
446+
tokenBudget: params.assembleTokenBudget ?? tokenBudget,
445447
...(typeof loopCurrentTokenCount === "number"
446448
? { currentTokenCount: loopCurrentTokenCount }
447449
: {}),

src/context-engine/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,8 +381,9 @@ export interface ContextEngine {
381381
sessionId: string;
382382
sessionKey?: string;
383383
messages: AgentMessage[];
384+
/** Budget available for the assembled messages. The runtime has already subtracted its compaction reserve (when it holds one) and the rendered system/user prompt pressure from the context window. */
384385
tokenBudget?: number;
385-
/** Best-effort pre-assembly prompt token estimate so engines can bound any `systemPromptAddition` against `tokenBudget`. */
386+
/** Best-effort token estimate of `messages` alone, from the runtime's transcript estimator. Remaining headroom for any `systemPromptAddition` is `tokenBudget - currentTokenCount`. Omitted on runtimes that predate the field. */
386387
currentTokenCount?: number;
387388
/** Tool names available for this run so engines can align prompt guidance with runtime tool access. */
388389
availableTools?: Set<string>;

src/plugin-sdk/agent-harness-runtime.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,8 +357,9 @@ export {
357357
resolveCompactionTimeoutMs,
358358
} from "../agents/embedded-agent-runner/compaction-safety-timeout.js";
359359
export {
360-
estimateLlmBoundaryTokenPressure,
360+
computeContextEngineMessageBudget,
361361
estimateRenderedLlmBoundaryTokenPressure,
362+
estimateTranscriptTokenPressure,
362363
formatPrePromptPrecheckLog,
363364
PREEMPTIVE_OVERFLOW_ERROR_TEXT,
364365
shouldPreemptivelyCompactBeforePrompt,

0 commit comments

Comments
 (0)