Skip to content

Commit 4258496

Browse files
fix(context-engine): honor assembled prompt authority in precheck (#74255)
Merged via squash. Prepared head SHA: 650b023 Co-authored-by: 100yenadmin <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
1 parent 0ce0509 commit 4258496

8 files changed

Lines changed: 177 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ Docs: https://docs.openclaw.ai
7070
- MCP/stdio: settle MCP stdio transport send() from the write callback instead of resolving immediately on buffer acceptance, so async write errors reject the promise instead of being lost. Refs #75438.
7171
- Process/exec: add stdin error listener in runCommandWithTimeout so EPIPE from a prematurely-exited child is swallowed instead of escaping to uncaughtException. Refs #75438.
7272
- Voice Call/realtime: add default-off fast memory/session context for `openclaw_agent_consult`, giving live calls a bounded answer-or-miss path before the full agent consult. Fixes #71849. Thanks @amzzzzzzz.
73+
7374
- Google Meet: interrupt Realtime provider output when local barge-in clears playback, so command-pair audio stops model speech instead of only restarting Chrome playback. Fixes #73850. (#73834) Thanks @shhtheonlyperson.
7475
- Gateway/config: cap oversized plugin-owned schemas in the full `config.schema` response so large installed plugin sets cannot balloon Gateway RSS or crash schema clients. Thanks @vincentkoc.
7576
- Plugins/update: skip ClawHub and marketplace plugin updates when the bundled version is newer than the recorded installed version, so `openclaw update` no longer overwrites working bundled plugins with older external packages. Fixes #75447. Thanks @amknight.
@@ -145,6 +146,7 @@ Docs: https://docs.openclaw.ai
145146
- Pairing: surface unexpected allowlist filesystem stat errors instead of treating the allowlist as missing, so permission and I/O failures are visible during pairing authorization checks. (#63324) Thanks @franciscomaestre.
146147
- macOS app: reserve layout space for exec approval command details so the allow dialog no longer overlaps the command, context, and action buttons. (#75470) Thanks @ngutman.
147148
- Agents/failover: carry `sessionId`, `lane`, `provider`, `model`, and `profileId` attribution through `FailoverError` and `describeFailoverError`/`coerceToFailoverError` so structured error logs (e.g. `gateway.err.log` ingestion) can attribute exhausted-fallback wrapper errors to the originating session and last-attempted provider instead of dropping the metadata after the per-profile errors. Fixes #42713. (#73506) Thanks @wenxu007.
149+
- Context Engine: treat assembled prompt as the default authority for preemptive overflow prechecks so engines that return a windowed, self-contained context no longer trigger false hard-fail compactions on huge raw history. Engines whose assembled view can hide overflow risk can opt back into the legacy behavior with `AssembleResult.promptAuthority: "preassembly_may_overflow"`. (#74255) Thanks @100yenadmin.
148150

149151
## 2026.4.29
150152

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
c1446005a26262d6b817d72493471d11c618b98441fad2014f1cf422bfe64bc9 plugin-sdk-api-baseline.json
2-
1b7d71eaabcae7d957396e7ff242598ef22b51851bc3fe1f4b58f2c2e5bf1459 plugin-sdk-api-baseline.jsonl
1+
37787172adf7a55a32097599b4bf5729fc7138c8743c6f4c9d58fc8d01df72a1 plugin-sdk-api-baseline.json
2+
0ec4957528477832085c638a5f7f691c878ba199f3e81f330f162c27cfd9ebf4 plugin-sdk-api-baseline.jsonl

docs/concepts/context-engine.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,17 @@ Required members:
197197
<ParamField path="systemPromptAddition" type="string">
198198
Prepended to the system prompt.
199199
</ParamField>
200+
<ParamField path="promptAuthority" type='"assembled" | "preassembly_may_overflow"'>
201+
Controls which token estimate the runner uses for preemptive overflow
202+
prechecks. Defaults to `"assembled"`, which means only the assembled
203+
prompt's estimate is checked — appropriate for engines that return a
204+
windowed, self-contained context. Set to `"preassembly_may_overflow"` only
205+
when your assembled view can hide overflow risk in the underlying
206+
transcript; the runner then takes the maximum of the assembled estimate
207+
and the pre-assembly (unwindowed) session-history estimate when deciding
208+
whether to preemptively compact. Either way, the messages you return are
209+
still what the model sees — `promptAuthority` only affects the precheck.
210+
</ParamField>
200211

201212
`compact` returns a `CompactResult`. When compaction rotates the active
202213
transcript, `result.sessionId` and `result.sessionFile` identify the successor

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

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,118 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
322322
);
323323
});
324324

325+
it("uses assembled context as the default precheck authority", async () => {
326+
let sawPrompt = false;
327+
const hugeHistory = "large raw history ".repeat(25_000);
328+
329+
const result = await createContextEngineAttemptRunner({
330+
contextEngine: createTestContextEngine({
331+
assemble: async () => ({
332+
messages: [
333+
{ role: "user", content: "small assembled context", timestamp: 1 },
334+
] as AgentMessage[],
335+
estimatedTokens: 8,
336+
}),
337+
}),
338+
sessionKey,
339+
tempPaths,
340+
sessionMessages: [{ role: "user", content: hugeHistory, timestamp: 1 }] as AgentMessage[],
341+
attemptOverrides: {
342+
contextTokenBudget: 500,
343+
},
344+
sessionPrompt: async (session) => {
345+
sawPrompt = true;
346+
session.messages = [
347+
...session.messages,
348+
{ role: "assistant", content: "done", timestamp: 2 },
349+
];
350+
},
351+
});
352+
353+
expect(sawPrompt).toBe(true);
354+
expect(result.promptError).toBeNull();
355+
expect(result.promptErrorSource).toBeNull();
356+
expect(hoisted.preemptiveCompactionCalls.at(-1)).not.toHaveProperty("unwindowedMessages");
357+
});
358+
359+
it("honors context engines that opt into preassembly overflow authority", async () => {
360+
let sawPrompt = false;
361+
const hugeHistory = "large raw history ".repeat(25_000);
362+
363+
const result = await createContextEngineAttemptRunner({
364+
contextEngine: createTestContextEngine({
365+
assemble: async () => ({
366+
messages: [
367+
{ role: "user", content: "small assembled context", timestamp: 1 },
368+
] as AgentMessage[],
369+
estimatedTokens: 8,
370+
promptAuthority: "preassembly_may_overflow",
371+
}),
372+
}),
373+
sessionKey,
374+
tempPaths,
375+
sessionMessages: [{ role: "user", content: hugeHistory, timestamp: 1 }] as AgentMessage[],
376+
attemptOverrides: {
377+
contextTokenBudget: 500,
378+
},
379+
sessionPrompt: async (session) => {
380+
sawPrompt = true;
381+
session.messages = [
382+
...session.messages,
383+
{ role: "assistant", content: "done", timestamp: 2 },
384+
];
385+
},
386+
});
387+
388+
expect(sawPrompt).toBe(false);
389+
expect(result.promptErrorSource).toBe("precheck");
390+
expect(result.preflightRecovery?.route).toBe("compact_only");
391+
expect(hoisted.preemptiveCompactionCalls.at(-1)).toHaveProperty("unwindowedMessages");
392+
});
393+
394+
it("snapshots pre-assembly messages before assemble even when the engine windows in place", async () => {
395+
const hugeHistory = "large raw history ".repeat(25_000);
396+
const preassemblyMarker = { role: "user", content: hugeHistory, timestamp: 1 } as AgentMessage;
397+
398+
await createContextEngineAttemptRunner({
399+
contextEngine: createTestContextEngine({
400+
assemble: async ({ messages }: { messages: AgentMessage[] }) => {
401+
// Simulate an engine that windows the input array IN PLACE.
402+
// The assemble contract does not require immutability, so the
403+
// runner must have already snapshotted before calling us.
404+
messages.length = 0;
405+
messages.push({ role: "user", content: "windowed", timestamp: 2 } as AgentMessage);
406+
return {
407+
messages: [
408+
{ role: "user", content: "small assembled context", timestamp: 1 },
409+
] as AgentMessage[],
410+
estimatedTokens: 8,
411+
promptAuthority: "preassembly_may_overflow",
412+
};
413+
},
414+
}),
415+
sessionKey,
416+
tempPaths,
417+
sessionMessages: [preassemblyMarker],
418+
attemptOverrides: {
419+
contextTokenBudget: 500,
420+
},
421+
sessionPrompt: async (session) => {
422+
session.messages = [
423+
...session.messages,
424+
{ role: "assistant", content: "done", timestamp: 3 },
425+
];
426+
},
427+
});
428+
429+
const lastCall = hoisted.preemptiveCompactionCalls.at(-1);
430+
expect(lastCall).toHaveProperty("unwindowedMessages");
431+
const unwindowed = (lastCall as { unwindowedMessages?: AgentMessage[] }).unwindowedMessages;
432+
// The snapshot must reflect the true pre-assembly state, not the in-place
433+
// windowed array that assemble mutated.
434+
expect(unwindowed).toEqual([preassemblyMarker]);
435+
});
436+
325437
it("keeps gateway model runs independent from agent context and session history", async () => {
326438
const bootstrap = vi.fn(async () => ({ bootstrapped: true }));
327439
const assemble = vi.fn(async ({ messages }: { messages: AgentMessage[] }) => ({

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ type SubscribeEmbeddedPiSessionFn =
2626
typeof import("../../pi-embedded-subscribe.js").subscribeEmbeddedPiSession;
2727
type AcquireSessionWriteLockFn =
2828
typeof import("../../session-write-lock.js").acquireSessionWriteLock;
29+
type ShouldPreemptivelyCompactBeforePromptFn =
30+
typeof import("./preemptive-compaction.js").shouldPreemptivelyCompactBeforePrompt;
2931

3032
type SubscriptionMock = ReturnType<SubscribeEmbeddedPiSessionFn>;
3133
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
@@ -78,6 +80,7 @@ type AttemptSpawnWorkspaceHoisted = {
7880
(sessionKey: string | undefined, config: unknown) => number | undefined
7981
>;
8082
limitHistoryTurnsMock: Mock<<T>(messages: T, limit: number | undefined) => T>;
83+
preemptiveCompactionCalls: Parameters<ShouldPreemptivelyCompactBeforePromptFn>[0][];
8184
sessionManager: SessionManagerMocks;
8285
};
8386

@@ -148,6 +151,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
148151
const limitHistoryTurnsMock = vi.fn<<T>(messages: T, limit: number | undefined) => T>(
149152
(messages) => messages,
150153
);
154+
const preemptiveCompactionCalls: Parameters<ShouldPreemptivelyCompactBeforePromptFn>[0][] = [];
151155
const sessionManager = {
152156
getLeafEntry: vi.fn(() => null),
153157
branch: vi.fn(),
@@ -181,6 +185,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
181185
runContextEngineMaintenanceMock,
182186
getDmHistoryLimitFromSessionKeyMock,
183187
limitHistoryTurnsMock,
188+
preemptiveCompactionCalls,
184189
sessionManager,
185190
};
186191
});
@@ -583,6 +588,19 @@ vi.mock("../compaction-runtime-context.js", () => ({
583588
buildEmbeddedCompactionRuntimeContext: () => ({}),
584589
}));
585590

591+
vi.mock("./preemptive-compaction.js", async (importOriginal) => {
592+
const actual = await importOriginal<typeof import("./preemptive-compaction.js")>();
593+
return {
594+
...actual,
595+
shouldPreemptivelyCompactBeforePrompt: (
596+
params: Parameters<typeof actual.shouldPreemptivelyCompactBeforePrompt>[0],
597+
) => {
598+
hoisted.preemptiveCompactionCalls.push(params);
599+
return actual.shouldPreemptivelyCompactBeforePrompt(params);
600+
},
601+
};
602+
});
603+
586604
vi.mock("../compaction-safety-timeout.js", () => ({
587605
resolveCompactionTimeoutMs: () => undefined,
588606
}));
@@ -770,6 +788,7 @@ export function resetEmbeddedAttemptHarness(
770788
hoisted.runContextEngineMaintenanceMock.mockReset().mockResolvedValue(undefined);
771789
hoisted.getDmHistoryLimitFromSessionKeyMock.mockReset().mockReturnValue(undefined);
772790
hoisted.limitHistoryTurnsMock.mockReset().mockImplementation((messages) => messages);
791+
hoisted.preemptiveCompactionCalls.length = 0;
773792
hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null);
774793
hoisted.sessionManager.branch.mockReset();
775794
hoisted.sessionManager.resetLeaf.mockReset();

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

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { isAcpRuntimeSpawnAvailable } from "../../../acp/runtime/availability.js";
1111
import { filterHeartbeatPairs } from "../../../auto-reply/heartbeat-filter.js";
1212
import { getRuntimeConfig } from "../../../config/config.js";
13+
import type { AssembleResult } from "../../../context-engine/types.js";
1314
import { emitTrustedDiagnosticEvent } from "../../../infra/diagnostic-events.js";
1415
import {
1516
createChildDiagnosticTraceContext,
@@ -1527,6 +1528,8 @@ export async function runEmbeddedAttempt(
15271528
}
15281529
let prePromptMessageCount = activeSession.messages.length;
15291530
let unwindowedContextEngineMessagesForPrecheck: AgentMessage[] | undefined;
1531+
let contextEnginePromptAuthority: NonNullable<AssembleResult["promptAuthority"]> =
1532+
"assembled";
15301533
abortSessionForYield = () => {
15311534
yieldAbortSettled = Promise.resolve(activeSession.abort());
15321535
};
@@ -2071,7 +2074,11 @@ export async function runEmbeddedAttempt(
20712074

20722075
if (activeContextEngine) {
20732076
try {
2074-
unwindowedContextEngineMessagesForPrecheck = activeSession.messages.slice();
2077+
// Snapshot before assemble: the assemble contract does not require
2078+
// the input array to be treated immutably, so an engine that windows
2079+
// history in place would otherwise leave the precheck reading
2080+
// already-windowed messages instead of the true pre-assembly state.
2081+
const preassemblyContextEngineMessagesForPrecheck = activeSession.messages.slice();
20752082
const assembled = await assembleAttemptContextEngine({
20762083
contextEngine: activeContextEngine,
20772084
sessionId: params.sessionId,
@@ -2089,6 +2096,11 @@ export async function runEmbeddedAttempt(
20892096
if (assembled.messages !== activeSession.messages) {
20902097
activeSession.agent.state.messages = assembled.messages;
20912098
}
2099+
contextEnginePromptAuthority = assembled.promptAuthority ?? "assembled";
2100+
if (contextEnginePromptAuthority === "preassembly_may_overflow") {
2101+
unwindowedContextEngineMessagesForPrecheck =
2102+
preassemblyContextEngineMessagesForPrecheck;
2103+
}
20922104
if (assembled.systemPromptAddition) {
20932105
systemPromptText = prependSystemPromptAddition({
20942106
systemPrompt: systemPromptText,
@@ -2760,7 +2772,9 @@ export async function runEmbeddedAttempt(
27602772

27612773
const preemptiveCompaction = shouldPreemptivelyCompactBeforePrompt({
27622774
messages: activeSession.messages,
2763-
unwindowedMessages: unwindowedContextEngineMessagesForPrecheck,
2775+
...(contextEnginePromptAuthority === "preassembly_may_overflow"
2776+
? { unwindowedMessages: unwindowedContextEngineMessagesForPrecheck }
2777+
: {}),
27642778
systemPrompt: systemPromptText,
27652779
prompt: effectivePrompt,
27662780
contextTokenBudget,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ describe("preemptive-compaction", () => {
9393
expect(result.estimatedPromptTokens).toBeLessThan(result.promptBudgetBeforeReserve);
9494
});
9595

96-
it("uses the larger unwindowed message estimate when context engine assembly windows history", () => {
96+
it("uses the larger unwindowed message estimate when explicitly provided", () => {
9797
const result = shouldPreemptivelyCompactBeforePrompt({
9898
messages: [makeAssistantHistory("small assembled window")],
9999
unwindowedMessages: [makeAssistantHistory(verboseHistory.repeat(4))],

src/context-engine/types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,20 @@ export type AssembleResult = {
88
messages: AgentMessage[];
99
/** Estimated total tokens in assembled context */
1010
estimatedTokens: number;
11+
/**
12+
* Controls which token estimate the runner treats as authoritative for
13+
* preemptive overflow prechecks. The returned `messages` are always the
14+
* prompt sent to the model; this only affects the precheck's token comparison.
15+
*
16+
* - "assembled": the precheck uses only the assembled prompt's estimate.
17+
* - "preassembly_may_overflow": the precheck takes the maximum of the
18+
* assembled estimate and the pre-assembly (unwindowed) session-history
19+
* estimate. Engines opt into this when their assembled view can hide an
20+
* overflow that would still affect the underlying transcript.
21+
*
22+
* Defaults to "assembled".
23+
*/
24+
promptAuthority?: "assembled" | "preassembly_may_overflow";
1125
/** Optional context-engine-provided instructions prepended to the runtime system prompt */
1226
systemPromptAddition?: string;
1327
};

0 commit comments

Comments
 (0)