Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,11 @@
"tokenBudgetFraction": {
"type": "number"
},
"tokenBudgetMax": {
"type": "number",
"minimum": 0,
"description": "Absolute ceiling (in tokens) on memory injection per turn, independent of the model's context window. Without it, injection is sized as tokenBudgetFraction × window, so large-window models balloon injection. The daemon budget is pre-capped to min(window, tokenBudgetMax / tokenBudgetFraction), then the combined system prompt addition is truncated to tokenBudgetMax after all injection paths land. Only injection is bounded; the usable conversation window is untouched. Unset disables the cap."
},
"compactThreshold": {
"type": "number"
},
Expand Down
63 changes: 61 additions & 2 deletions src/context-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,33 @@ function resolveEffectiveAssembleBudget(tokenBudget: number | undefined): number
return Math.max(1, normalized - headroom);
}

// tokenBudgetMax — stage 1 (base cap). Daemon-side injection is sized as
// tokenBudgetFraction × tokenBudget, so on a large-window model (e.g. 1M)
// injection balloons proportionally. This caps the budget handed to the daemon
// to min(window, tokenBudgetMax / fraction) so the daemon pre-trims its
// sub-channels. Returns tokenBudget unchanged when tokenBudgetMax is unset.
// NOTE: this only makes the daemon pre-trim — the post-assembly trim
// (truncateSystemPromptAdditionToTokenBudget) is the actual ceiling enforcer,
// because several injection paths size against the real window, not this budget.
function resolveCappedAssembleBudget(
tokenBudget: number | undefined,
tokenBudgetMax: number | undefined,
tokenBudgetFraction: number | undefined,
): number | undefined {
const normalized = normalizeTokenBudget(tokenBudget);
if (normalized == null) return tokenBudget;
if (typeof tokenBudgetMax !== "number" || !Number.isFinite(tokenBudgetMax) || tokenBudgetMax <= 0) {
return tokenBudget;
}
// The daemon's true default injection fraction is unknown; 0.2 is a safe
// fallback. It only affects how aggressively the daemon pre-trims.
const fraction = typeof tokenBudgetFraction === "number" && tokenBudgetFraction > 0
? tokenBudgetFraction
: 0.2;
const cap = Math.max(1, Math.floor(tokenBudgetMax / fraction));
return Math.min(normalized, cap);
}

function normalizeThresholdFraction(fraction: number | undefined): number {
if (typeof fraction !== "number" || !Number.isFinite(fraction)) {
return DEFAULT_COMPACTION_THRESHOLD_FRACTION;
Expand Down Expand Up @@ -2874,15 +2901,22 @@ export function buildContextEngineFactory(
}

const assembleTimeout = cfg.assembleTimeoutMs ?? 30000;
// tokenBudgetMax stage 1: cap the budget the daemon sizes injection
// against (no-op when tokenBudgetMax is unset).
const cappedAssembleBudget = resolveCappedAssembleBudget(
args.tokenBudget,
cfg.tokenBudgetMax,
cfg.tokenBudgetFraction,
) ?? args.tokenBudget;
const resp = await Promise.race([
client.assembleContextInternal({
sessionId,
sessionKey: args.sessionKey,
userId,
prompt: retrievalQuery,
messages: messages as any,
tokenBudget: args.tokenBudget,
config: buildAssemblyConfig(args.tokenBudget),
tokenBudget: cappedAssembleBudget,
config: buildAssemblyConfig(cappedAssembleBudget),
emitDebug: true,
}),
new Promise<never>((_, reject) =>
Expand Down Expand Up @@ -3012,6 +3046,31 @@ export function buildContextEngineFactory(
});
}

// tokenBudgetMax stage 2 (enforcer): truncate the combined injection to
// the configured ceiling after all paths (main assemble, continuity,
// exact-recall, predictive_context, beforeTurn) have landed. Applied to
// systemPromptAddition only — never to the conversation, which
// enforceAssembleBudget governs against the real window.
if (typeof cfg.tokenBudgetMax === "number" && Number.isFinite(cfg.tokenBudgetMax) && cfg.tokenBudgetMax > 0) {
const injectionBefore = approximateTokenCount(enforced.systemPromptAddition);
if (injectionBefore > cfg.tokenBudgetMax) {
const trimmed = truncateSystemPromptAdditionToTokenBudget(
enforced.systemPromptAddition,
cfg.tokenBudgetMax,
);
const injectionAfter = approximateTokenCount(trimmed);
enforced = {
...enforced,
systemPromptAddition: trimmed,
estimatedTokens: Math.max(0, enforced.estimatedTokens - (injectionBefore - injectionAfter)),
};
logger.info?.(
`LibraVDB tokenBudgetMax trim sessionId=${sessionId} ` +
`injectionBefore=${injectionBefore} injectionAfter=${injectionAfter} ` +
`cap=${cfg.tokenBudgetMax}`,
);
}
}
enforced = enforceAssembleBudget(
enforced,
args.tokenBudget,
Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ export interface PluginConfig {
recencyLambdaUser?: number;
recencyLambdaGlobal?: number;
tokenBudgetFraction?: number;
/** Absolute ceiling (in tokens) on memory injection per turn, independent of
* the model's context window. Without it, injection is sized as
* tokenBudgetFraction × window, so a large-window model balloons injection.
* Two stages: the daemon budget is pre-capped to min(window,
* tokenBudgetMax / tokenBudgetFraction), then the combined systemPromptAddition
* is truncated to tokenBudgetMax after all injection paths land. Only the
* injection is bounded — the usable conversation window is untouched.
* Unset disables the cap. */
tokenBudgetMax?: number;
authoredHardBudgetFraction?: number;
authoredSoftBudgetFraction?: number;
elevatedGuidanceBudgetFraction?: number;
Expand Down
84 changes: 84 additions & 0 deletions test/unit/context-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2752,6 +2752,89 @@ test("context engine assemble drain handles empty queue gracefully", async () =>
// ---------------------------------------------------------------------------

// ---------------------------------------------------------------------------

// tokenBudgetMax — absolute injection ceiling (window-independent)
// ---------------------------------------------------------------------------

test("tokenBudgetMax caps the daemon budget and truncates the injected system prompt", async () => {
const client = new FakeClient();
// ~9000 tokens of plain injection (sanitization is a no-op for plain text).
const bigInjection = "alpha ".repeat(6000);
client.assembleResponse = {
messages: [],
estimatedTokens: 9000,
systemPromptAddition: bigInjection,
};
const engine = buildContextEngineFactory(fakeRuntime(client), {
userId: "fixed-user",
tokenBudgetMax: 1000,
tokenBudgetFraction: 0.2,
crossSessionRecall: false, // skip exact recall
beforeTurnEnabled: false, // skip beforeTurn injection
});

const messages = [
{ role: "user", content: "earlier", id: "u0" },
{ role: "assistant", content: "ok", id: "a0" },
{ role: "user", content: "what is the status", id: "u1" },
];
const result = await engine.assemble({
sessionId: "s-cap",
sessionKey: "agent:main:session:s-cap",
messages,
tokenBudget: 1_000_000, // 1M window
prompt: "what is the status",
});

// Stage 1: the daemon received min(1M, 1000 / 0.2) = 5000, not the full window.
const assembleCall = client.calls.find((c) => c.method === "assembleContextInternal");
assert.ok(assembleCall, "assembleContextInternal should be called");
assert.equal(assembleCall.params.tokenBudget, 5000);

// Stage 2: combined injection truncated to tokenBudgetMax (1000 tokens =
// 4000 chars at APPROX_CHARS_PER_TOKEN=4).
assert.ok(
result.systemPromptAddition.length <= 4000,
`injection ${result.systemPromptAddition.length} chars should be <= 4000`,
);
assert.ok(result.systemPromptAddition.length > 0, "some injection survives the trim");
assert.ok(
result.systemPromptAddition.length < bigInjection.length,
"injection was actually truncated",
);
});

test("tokenBudgetMax unset: daemon budget passes through uncapped", async () => {
const client = new FakeClient();
client.assembleResponse = {
messages: [],
estimatedTokens: 100,
systemPromptAddition: "small note",
};
const engine = buildContextEngineFactory(fakeRuntime(client), {
userId: "fixed-user",
crossSessionRecall: false,
beforeTurnEnabled: false,
});

const messages = [
{ role: "user", content: "earlier", id: "u0" },
{ role: "assistant", content: "ok", id: "a0" },
{ role: "user", content: "hi", id: "u1" },
];
await engine.assemble({
sessionId: "s-uncapped",
sessionKey: "agent:main:session:s-uncapped",
messages,
tokenBudget: 1_000_000,
prompt: "hi",
});

const assembleCall = client.calls.find((c) => c.method === "assembleContextInternal");
assert.ok(assembleCall, "assembleContextInternal should be called");
assert.equal(assembleCall.params.tokenBudget, 1_000_000, "full window passed when uncapped");
});

// Per-agent / per-subagent exclusion (excludeAgents / excludeSubagents)
// ---------------------------------------------------------------------------

Expand Down Expand Up @@ -2964,4 +3047,5 @@ test("excludeSubagents off by default: a subagent is granted a normal expansion
client.calls.find((c) => c.method === "bootstrapSessionKernel"),
"a non-excluded subagent still bootstraps via the daemon",
);

});
Loading