Skip to content

Commit 6e8b642

Browse files
authored
refactor: assemble context partitioning (Instruction/Archive/Session/Reserved) (#1446)
Extract flat message assembly into 4-layer partition architecture as foundation for progressive compression (L1-L5). Add allocateContextBudget with Archive 15%/8K cap, Session as remainder, Reserved 15%/20K floor. Add buildArchiveMemory with over-budget trim protection. Switch estimatedTokens from backend value to client-side roughEstimate. Enhance diag with partition-level metrics. Made-with: Cursor
1 parent b441622 commit 6e8b642

3 files changed

Lines changed: 166 additions & 83 deletions

File tree

examples/openclaw-plugin/context-engine.ts

Lines changed: 164 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,26 @@ type Logger = {
104104
error: (msg: string) => void;
105105
};
106106

107+
interface ContextBudgets {
108+
archiveMemory: number;
109+
sessionContext: number;
110+
reserved: number;
111+
}
112+
113+
const BUDGET_UNLIMITED = -1;
114+
const ARCHIVE_BUDGET_RATIO = 0.15;
115+
const ARCHIVE_BUDGET_CAP = 8_000;
116+
const RESERVED_MIN = 20_000;
117+
const RESERVED_RATIO = 0.15;
118+
const ARCHIVE_INDEX_TRIM_LIMIT = 10;
119+
120+
function allocateContextBudget(totalBudget: number): ContextBudgets {
121+
const reserved = Math.max(totalBudget * RESERVED_RATIO, RESERVED_MIN);
122+
const archiveMemory = Math.min(totalBudget * ARCHIVE_BUDGET_RATIO, ARCHIVE_BUDGET_CAP);
123+
const sessionContext = Math.max(totalBudget - archiveMemory - reserved, 0);
124+
return { archiveMemory, sessionContext, reserved };
125+
}
126+
107127
function estimateTokens(messages: AgentMessage[]): number {
108128
return Math.max(1, messages.length * 80);
109129
}
@@ -419,6 +439,70 @@ function buildSystemPromptAddition(): string {
419439
].join("\n");
420440
}
421441

442+
function buildInstructionPrompt(): { text: string; tokens: number } {
443+
const text = buildSystemPromptAddition();
444+
return { text, tokens: Math.ceil(text.length / 4) };
445+
}
446+
447+
function buildArchiveMemory(
448+
archiveOverview: string | undefined,
449+
preAbstracts: Array<{ archive_id: string; abstract: string }>,
450+
budget: number,
451+
): { messages: AgentMessage[]; tokens: number } {
452+
const messages: AgentMessage[] = [];
453+
454+
if (archiveOverview) {
455+
messages.push({
456+
role: "user",
457+
content: `[Session History Summary]\n${archiveOverview}`,
458+
});
459+
}
460+
461+
if (preAbstracts.length > 0) {
462+
const lines = preAbstracts.map((a) => `${a.archive_id}: ${a.abstract}`);
463+
messages.push({
464+
role: "user",
465+
content: `[Archive Index]\n${lines.join("\n")}`,
466+
});
467+
}
468+
469+
let tokens = roughEstimate(messages);
470+
if (budget === BUDGET_UNLIMITED || tokens <= budget || preAbstracts.length <= ARCHIVE_INDEX_TRIM_LIMIT) {
471+
return { messages, tokens };
472+
}
473+
474+
const trimmed = preAbstracts.slice(-ARCHIVE_INDEX_TRIM_LIMIT);
475+
const trimmedMessages: AgentMessage[] = [];
476+
if (archiveOverview) {
477+
trimmedMessages.push({
478+
role: "user",
479+
content: `[Session History Summary]\n${archiveOverview}`,
480+
});
481+
}
482+
trimmedMessages.push({
483+
role: "user",
484+
content: `[Archive Index]\n${trimmed.map((a) => `${a.archive_id}: ${a.abstract}`).join("\n")}`,
485+
});
486+
tokens = roughEstimate(trimmedMessages);
487+
return { messages: trimmedMessages, tokens };
488+
}
489+
490+
function buildSessionContext(
491+
ovMessages: OVMessage[],
492+
budget: number,
493+
): { messages: AgentMessage[]; tokens: number } {
494+
const messages = ovMessages.flatMap((m) => convertToAgentMessages(m));
495+
const tokens = roughEstimate(messages);
496+
if (budget === BUDGET_UNLIMITED || tokens <= budget) {
497+
return { messages, tokens };
498+
}
499+
const trimmed = [...messages];
500+
while (trimmed.length > 0 && roughEstimate(trimmed) > budget) {
501+
trimmed.shift();
502+
}
503+
return { messages: trimmed, tokens: roughEstimate(trimmed) };
504+
}
505+
422506
function sleep(ms: number): Promise<void> {
423507
return new Promise((resolve) => setTimeout(resolve, ms));
424508
}
@@ -598,6 +682,64 @@ export function createMemoryOpenVikingContextEngine(params: {
598682
return false;
599683
}
600684

685+
function assemblePassthrough(
686+
ovSessionId: string,
687+
reason: string,
688+
liveMessages: AgentMessage[],
689+
originalTokens: number,
690+
extra?: Record<string, unknown>,
691+
): AssembleResult {
692+
diag("assemble_result", ovSessionId, {
693+
passthrough: true,
694+
reason,
695+
outputMessagesCount: liveMessages.length,
696+
inputTokenEstimate: originalTokens,
697+
estimatedTokens: originalTokens,
698+
tokensSaved: 0,
699+
savingPct: 0,
700+
...extra,
701+
});
702+
return { messages: liveMessages, estimatedTokens: originalTokens };
703+
}
704+
705+
function buildAssembledContext(
706+
overview: string | undefined,
707+
preAbstracts: Array<{ archive_id: string; abstract: string }>,
708+
ovMessages: OVMessage[],
709+
tokenBudget: number,
710+
ovSessionId: string,
711+
): {
712+
sanitized: AgentMessage[];
713+
archive: { messages: AgentMessage[]; tokens: number };
714+
session: { messages: AgentMessage[]; tokens: number };
715+
budgets: ContextBudgets;
716+
instruction: { text: string; tokens: number };
717+
} {
718+
// 4-layer context partitioning (budget computed for diag; BUDGET_UNLIMITED bypasses limits):
719+
// Instruction — system prompt guide (Archive Index / Session History usage)
720+
// Archive — session history summary + per-archive one-line abstracts
721+
// Session — active OV messages converted to AgentMessage format
722+
// Reserved — headroom for model output (not consumed here)
723+
const budgets = allocateContextBudget(tokenBudget);
724+
const instruction = buildInstructionPrompt();
725+
const archive = buildArchiveMemory(overview, preAbstracts, BUDGET_UNLIMITED);
726+
const session = buildSessionContext(ovMessages, BUDGET_UNLIMITED);
727+
const assembled = [...archive.messages, ...session.messages];
728+
729+
logger.info(
730+
`openviking: assemble entering session content for ${ovSessionId}: ` +
731+
JSON.stringify(assembled.map((m) => ({
732+
role: m.role,
733+
content: typeof m.content === "string" ? m.content.substring(0, 100) : "[complex]",
734+
})), null, 2),
735+
);
736+
737+
normalizeAssistantContent(assembled);
738+
const sanitized = sanitizeToolUseResultPairing(assembled as never[]) as AgentMessage[];
739+
740+
return { sanitized, archive, session, budgets, instruction };
741+
}
742+
601743
return {
602744
info: {
603745
id,
@@ -641,131 +783,72 @@ export function createMemoryOpenVikingContextEngine(params: {
641783
});
642784

643785
if (isBypassedSession({ sessionId: assembleParams.sessionId, sessionKey })) {
644-
diag("assemble_result", OVSessionId, {
645-
passthrough: true,
646-
reason: "session_bypassed",
647-
outputMessagesCount: messages.length,
648-
inputTokenEstimate: originalTokens,
649-
estimatedTokens: originalTokens,
650-
tokensSaved: 0,
651-
savingPct: 0,
652-
});
653-
return { messages, estimatedTokens: originalTokens };
786+
return assemblePassthrough(OVSessionId, "session_bypassed", messages, originalTokens);
654787
}
655788

656789
try {
657-
if (!(await runLocalPrecheck("assemble", OVSessionId, {
658-
tokenBudget,
659-
}))) {
790+
if (!(await runLocalPrecheck("assemble", OVSessionId, { tokenBudget }))) {
660791
return { messages, estimatedTokens: roughEstimate(messages) };
661792
}
662793
const client = await getClient();
663-
const routingRef =
664-
assembleParams.sessionId ?? sessionKey ?? OVSessionId;
794+
const routingRef = assembleParams.sessionId ?? sessionKey ?? OVSessionId;
665795
const agentId = resolveAgentId(routingRef, sessionKey, OVSessionId);
666-
const ctx = await client.getSessionContext(
667-
OVSessionId,
668-
tokenBudget,
669-
agentId,
670-
);
796+
const ctx = await client.getSessionContext(OVSessionId, tokenBudget, agentId);
671797

672798
const preAbstracts = ctx?.pre_archive_abstracts ?? [];
673799
const hasArchives = !!ctx?.latest_archive_overview || preAbstracts.length > 0;
674800
const activeCount = ctx?.messages?.length ?? 0;
675801

676802
if (!ctx || (!hasArchives && activeCount === 0)) {
677-
diag("assemble_result", OVSessionId, {
678-
passthrough: true, reason: "no_ov_data",
803+
return assemblePassthrough(OVSessionId, "no_ov_data", messages, originalTokens, {
679804
archiveCount: 0, activeCount: 0,
680-
outputMessagesCount: messages.length,
681-
inputTokenEstimate: originalTokens,
682-
estimatedTokens: originalTokens,
683-
tokensSaved: 0, savingPct: 0,
684805
});
685-
return { messages, estimatedTokens: roughEstimate(messages) };
686806
}
687-
688807
if (!hasArchives && ctx.messages.length < messages.length) {
689-
diag("assemble_result", OVSessionId, {
690-
passthrough: true, reason: "ov_msgs_fewer_than_input",
808+
return assemblePassthrough(OVSessionId, "ov_msgs_fewer_than_input", messages, originalTokens, {
691809
archiveCount: 0, activeCount,
692-
outputMessagesCount: messages.length,
693-
inputTokenEstimate: originalTokens,
694-
estimatedTokens: originalTokens,
695-
tokensSaved: 0, savingPct: 0,
696-
});
697-
return { messages, estimatedTokens: roughEstimate(messages) };
698-
}
699-
700-
const assembled: AgentMessage[] = [];
701-
702-
if (ctx.latest_archive_overview) {
703-
assembled.push({
704-
role: "user" as const,
705-
content: `[Session History Summary]\n${ctx.latest_archive_overview}`,
706-
});
707-
}
708-
709-
if (preAbstracts.length > 0) {
710-
const lines: string[] = preAbstracts.map(
711-
(a) => `${a.archive_id}: ${a.abstract}`,
712-
);
713-
assembled.push({
714-
role: "user" as const,
715-
content: `[Archive Index]\n${lines.join("\n")}`,
716810
});
717811
}
718812

719-
assembled.push(...ctx.messages.flatMap((m) => convertToAgentMessages(m)));
720-
721-
// 打印进入 session 的完整内容
722-
logger.info(
723-
`openviking: assemble entering session content for ${OVSessionId}: ` +
724-
JSON.stringify(assembled.map((m) => ({
725-
role: m.role,
726-
content: typeof m.content === "string" ? m.content.substring(0, 100) : "[complex]",
727-
})), null, 2),
813+
const { sanitized, archive, session, budgets, instruction } = buildAssembledContext(
814+
ctx.latest_archive_overview,
815+
preAbstracts,
816+
ctx.messages,
817+
tokenBudget,
818+
OVSessionId,
728819
);
729820

730-
normalizeAssistantContent(assembled);
731-
const sanitized = sanitizeToolUseResultPairing(assembled as never[]) as AgentMessage[];
732-
733821
if (sanitized.length === 0 && messages.length > 0) {
734-
diag("assemble_result", OVSessionId, {
735-
passthrough: true, reason: "sanitized_empty",
736-
archiveCount: preAbstracts.length,
737-
activeCount,
738-
outputMessagesCount: messages.length,
739-
inputTokenEstimate: originalTokens,
740-
estimatedTokens: originalTokens,
741-
tokensSaved: 0, savingPct: 0,
822+
return assemblePassthrough(OVSessionId, "sanitized_empty", messages, originalTokens, {
823+
archiveCount: preAbstracts.length, activeCount,
742824
});
743-
return { messages, estimatedTokens: roughEstimate(messages) };
744825
}
745826

746827
const assembledTokens = roughEstimate(sanitized);
747-
const archiveCount = preAbstracts.length;
748828
const tokensSaved = originalTokens - assembledTokens;
749829
const savingPct = originalTokens > 0 ? Math.round((tokensSaved / originalTokens) * 100) : 0;
750830

751831
diag("assemble_result", OVSessionId, {
752832
passthrough: false,
753-
archiveCount,
833+
archiveCount: preAbstracts.length,
754834
activeCount,
755835
outputMessagesCount: sanitized.length,
756836
inputTokenEstimate: originalTokens,
757837
estimatedTokens: assembledTokens,
758838
tokensSaved,
759839
savingPct,
840+
archiveTokens: archive.tokens,
841+
archiveBudget: budgets.archiveMemory,
842+
sessionTokens: session.tokens,
843+
sessionBudget: budgets.sessionContext,
844+
reservedBudget: budgets.reserved,
760845
messages: messageDigest(sanitized),
761846
});
762847

763848
return {
764849
messages: sanitized,
765-
estimatedTokens: ctx.estimatedTokens,
766-
...(hasArchives
767-
? { systemPromptAddition: buildSystemPromptAddition() }
768-
: {}),
850+
estimatedTokens: assembledTokens,
851+
...(hasArchives ? { systemPromptAddition: instruction.text } : {}),
769852
};
770853
} catch (err) {
771854
logger.warn?.(

examples/openclaw-plugin/tests/context-engine-assemble.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ describe("context-engine assemble()", () => {
110110

111111
expect(resolveAgentId).toHaveBeenCalledWith("session-1", undefined, "session-1");
112112
expect(client.getSessionContext).toHaveBeenCalledWith("session-1", 4096, "agent:session-1");
113-
expect(result.estimatedTokens).toBe(321);
113+
expect(result.estimatedTokens).toBe(roughEstimate(result.messages));
114114
expect(result.systemPromptAddition).toContain("Session Context Guide");
115115
expect(result.messages).toEqual([
116116
{

examples/openclaw-plugin/tests/ut/context-engine-assemble.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ describe("context-engine assemble()", () => {
123123

124124
expect(resolveAgentId).toHaveBeenCalledWith("session-1", undefined, "session-1");
125125
expect(client.getSessionContext).toHaveBeenCalledWith("session-1", 4096, "agent:session-1");
126-
expect(result.estimatedTokens).toBe(321);
126+
expect(result.estimatedTokens).toBe(roughEstimate(result.messages));
127127
expect(result.systemPromptAddition).toContain("Session Context Guide");
128128
expect(result.messages[0]).toEqual({
129129
role: "user",

0 commit comments

Comments
 (0)