Skip to content

Commit e78129a

Browse files
danhdoanjalehman
andauthored
feat(context-engine): pass incoming prompt to assemble (openclaw#50848)
Merged via squash. Prepared head SHA: 282dc92 Co-authored-by: danhdoan <12591333+danhdoan@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman
1 parent 6a6f1b5 commit e78129a

5 files changed

Lines changed: 297 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ Docs: https://docs.openclaw.ai
190190
- Telegram/routing: fail loud when `message send` targets an unknown non-default Telegram `accountId`, instead of silently falling back to the channel-level bot token and sending through the wrong bot. (#50853) Thanks @hclsys.
191191
- Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras.
192192
- Agents/replay: sanitize malformed assistant tool-call replay blocks before provider replay so follow-up Anthropic requests do not inherit the downstream `replace` crash. (#50005) Thanks @jalehman.
193+
- Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan.
193194

194195
### Breaking
195196

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2426,6 +2426,7 @@ export async function runEmbeddedAttempt(
24262426
messages: activeSession.messages,
24272427
tokenBudget: params.contextTokenBudget,
24282428
model: params.modelId,
2429+
...(params.prompt !== undefined ? { prompt: params.prompt } : {}),
24292430
});
24302431
if (assembled.messages !== activeSession.messages) {
24312432
activeSession.agent.replaceMessages(assembled.messages);

src/context-engine/context-engine.test.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ class LegacySessionKeyStrictEngine implements ContextEngine {
145145
sessionKey?: string;
146146
messages: AgentMessage[];
147147
tokenBudget?: number;
148+
prompt?: string;
148149
}): Promise<AssembleResult> {
149150
this.assembleCalls.push({ ...params });
150151
this.rejectSessionKey(params);
@@ -234,6 +235,58 @@ class SessionKeyRuntimeErrorEngine implements ContextEngine {
234235
}
235236
}
236237

238+
class LegacyAssembleStrictEngine implements ContextEngine {
239+
readonly info: ContextEngineInfo = {
240+
id: "legacy-assemble-strict",
241+
name: "Legacy Assemble Strict Engine",
242+
};
243+
readonly assembleCalls: Array<Record<string, unknown>> = [];
244+
245+
async ingest(_params: {
246+
sessionId: string;
247+
sessionKey?: string;
248+
message: AgentMessage;
249+
isHeartbeat?: boolean;
250+
}): Promise<IngestResult> {
251+
return { ingested: true };
252+
}
253+
254+
async assemble(params: {
255+
sessionId: string;
256+
sessionKey?: string;
257+
messages: AgentMessage[];
258+
tokenBudget?: number;
259+
prompt?: string;
260+
}): Promise<AssembleResult> {
261+
this.assembleCalls.push({ ...params });
262+
if (Object.prototype.hasOwnProperty.call(params, "sessionKey")) {
263+
throw new Error("Unrecognized key(s) in object: 'sessionKey'");
264+
}
265+
if (Object.prototype.hasOwnProperty.call(params, "prompt")) {
266+
throw new Error("Unrecognized key(s) in object: 'prompt'");
267+
}
268+
return {
269+
messages: params.messages,
270+
estimatedTokens: 3,
271+
};
272+
}
273+
274+
async compact(_params: {
275+
sessionId: string;
276+
sessionKey?: string;
277+
sessionFile: string;
278+
tokenBudget?: number;
279+
compactionTarget?: "budget" | "threshold";
280+
customInstructions?: string;
281+
runtimeContext?: Record<string, unknown>;
282+
}): Promise<CompactResult> {
283+
return {
284+
ok: true,
285+
compacted: false,
286+
};
287+
}
288+
}
289+
237290
// ═══════════════════════════════════════════════════════════════════════════
238291
// 1. Engine contract tests
239292
// ═══════════════════════════════════════════════════════════════════════════
@@ -640,6 +693,124 @@ describe("LegacyContextEngine parity", () => {
640693
});
641694
});
642695

696+
// ═══════════════════════════════════════════════════════════════════════════
697+
// 5b. assemble() prompt forwarding
698+
// ═══════════════════════════════════════════════════════════════════════════
699+
700+
describe("assemble() prompt forwarding", () => {
701+
it("forwards prompt to the underlying engine", async () => {
702+
const engineId = `prompt-fwd-${Date.now().toString(36)}`;
703+
const calls: Array<Record<string, unknown>> = [];
704+
registerContextEngine(engineId, () => ({
705+
info: { id: engineId, name: "Prompt Tracker", version: "0.0.0" },
706+
async ingest() {
707+
return { ingested: false };
708+
},
709+
async assemble(params) {
710+
calls.push({ ...params });
711+
return { messages: params.messages, estimatedTokens: 0 };
712+
},
713+
async compact() {
714+
return { ok: true, compacted: false };
715+
},
716+
}));
717+
718+
const engine = await resolveContextEngine(configWithSlot(engineId));
719+
await engine.assemble({
720+
sessionId: "s1",
721+
messages: [makeMockMessage("user", "hello")],
722+
prompt: "hello",
723+
});
724+
725+
expect(calls).toHaveLength(1);
726+
expect(calls[0]).toHaveProperty("prompt", "hello");
727+
});
728+
729+
it("omits prompt when not provided", async () => {
730+
const engineId = `prompt-omit-${Date.now().toString(36)}`;
731+
const calls: Array<Record<string, unknown>> = [];
732+
registerContextEngine(engineId, () => ({
733+
info: { id: engineId, name: "Prompt Tracker", version: "0.0.0" },
734+
async ingest() {
735+
return { ingested: false };
736+
},
737+
async assemble(params) {
738+
calls.push({ ...params });
739+
return { messages: params.messages, estimatedTokens: 0 };
740+
},
741+
async compact() {
742+
return { ok: true, compacted: false };
743+
},
744+
}));
745+
746+
const engine = await resolveContextEngine(configWithSlot(engineId));
747+
await engine.assemble({
748+
sessionId: "s1",
749+
messages: [makeMockMessage("user", "hello")],
750+
});
751+
752+
expect(calls).toHaveLength(1);
753+
expect(calls[0]).not.toHaveProperty("prompt");
754+
});
755+
756+
it("does not leak prompt key when caller spreads undefined", async () => {
757+
// Guards against the pattern `{ prompt: params.prompt }` when params.prompt
758+
// is undefined — JavaScript keeps the key present with value undefined,
759+
// which breaks engines that guard with `'prompt' in params`.
760+
const engineId = `prompt-undef-${Date.now().toString(36)}`;
761+
const calls: Array<Record<string, unknown>> = [];
762+
registerContextEngine(engineId, () => ({
763+
info: { id: engineId, name: "Prompt Tracker", version: "0.0.0" },
764+
async ingest() {
765+
return { ingested: false };
766+
},
767+
async assemble(params) {
768+
calls.push({ ...params });
769+
return { messages: params.messages, estimatedTokens: 0 };
770+
},
771+
async compact() {
772+
return { ok: true, compacted: false };
773+
},
774+
}));
775+
776+
const engine = await resolveContextEngine(configWithSlot(engineId));
777+
// Simulate the attempt.ts call-site pattern: conditional spread
778+
const callerPrompt: string | undefined = undefined;
779+
await engine.assemble({
780+
sessionId: "s1",
781+
messages: [makeMockMessage("user", "hello")],
782+
...(callerPrompt !== undefined ? { prompt: callerPrompt } : {}),
783+
});
784+
785+
expect(calls).toHaveLength(1);
786+
expect(calls[0]).not.toHaveProperty("prompt");
787+
expect(Object.keys(calls[0] as object)).not.toContain("prompt");
788+
});
789+
790+
it("retries strict legacy assemble without sessionKey and prompt", async () => {
791+
const engineId = `prompt-legacy-${Date.now().toString(36)}`;
792+
const strictEngine = new LegacyAssembleStrictEngine();
793+
registerContextEngine(engineId, () => strictEngine);
794+
795+
const engine = await resolveContextEngine(configWithSlot(engineId));
796+
const result = await engine.assemble({
797+
sessionId: "s1",
798+
sessionKey: "agent:main:test",
799+
messages: [makeMockMessage("user", "hello")],
800+
prompt: "hello",
801+
});
802+
803+
expect(result.estimatedTokens).toBe(3);
804+
expect(strictEngine.assembleCalls).toHaveLength(3);
805+
expect(strictEngine.assembleCalls[0]).toHaveProperty("sessionKey", "agent:main:test");
806+
expect(strictEngine.assembleCalls[0]).toHaveProperty("prompt", "hello");
807+
expect(strictEngine.assembleCalls[1]).not.toHaveProperty("sessionKey");
808+
expect(strictEngine.assembleCalls[1]).toHaveProperty("prompt", "hello");
809+
expect(strictEngine.assembleCalls[2]).not.toHaveProperty("sessionKey");
810+
expect(strictEngine.assembleCalls[2]).not.toHaveProperty("prompt");
811+
});
812+
});
813+
643814
// ═══════════════════════════════════════════════════════════════════════════
644815
// 6. Initialization guard
645816
// ═══════════════════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)