Pass runtime context into assemble

This commit is contained in:
Codex 2026-03-21 02:18:55 +08:00
parent c38295c7a2
commit b8458f0de6
7 changed files with 282 additions and 44 deletions

View File

@ -128,7 +128,7 @@ export default function register(api) {
return { ingested: true };
},
async assemble({ sessionId, messages, tokenBudget }) {
async assemble({ sessionId, messages, tokenBudget, runtimeContext }) {
// Return messages that fit the budget
return {
messages: buildContext(messages, tokenBudget),
@ -181,6 +181,11 @@ Required members:
decisions and diagnostic reporting.
- `systemPromptAddition` (optional, `string`) — prepended to the system prompt.
`assemble(params)` also receives an optional `runtimeContext` object. OpenClaw
uses it to pass caller-owned budget signals such as system-prompt size,
tool-schema size, and current prompt size so plugin engines can make more
accurate token-budget decisions without having to guess from inside the plugin.
Optional members:
| Member | Kind | Purpose |

View File

@ -0,0 +1,51 @@
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
export type EmbeddedAssembleRuntimeContext = {
systemPromptChars?: number;
systemPromptTokensEstimate?: number;
skillsPromptChars?: number;
toolListChars?: number;
toolSchemaChars?: number;
currentPromptChars?: number;
currentPromptTokensEstimate?: number;
reservedContextCharsEstimate?: number;
reservedContextTokensEstimate?: number;
};
function normalizeChars(value: unknown): number {
if (!Number.isFinite(value) || Number(value) <= 0) {
return 0;
}
return Math.max(0, Math.floor(Number(value)));
}
function estimateTokensFromChars(chars: number): number {
if (chars <= 0) {
return 0;
}
return Math.max(1, Math.ceil(chars / 4));
}
export function buildEmbeddedAssembleRuntimeContext(params: {
systemPromptText?: string | null;
prompt?: string | null;
systemPromptReport?: Pick<SessionSystemPromptReport, "systemPrompt" | "skills" | "tools"> | null;
}): EmbeddedAssembleRuntimeContext {
const report = params.systemPromptReport ?? undefined;
const systemPromptChars = normalizeChars(
report?.systemPrompt?.chars ?? params.systemPromptText?.length ?? 0,
);
const currentPromptChars = normalizeChars(params.prompt?.length ?? 0);
const reservedContextCharsEstimate = Math.max(0, systemPromptChars + currentPromptChars);
return {
systemPromptChars,
systemPromptTokensEstimate: estimateTokensFromChars(systemPromptChars),
skillsPromptChars: normalizeChars(report?.skills?.promptChars ?? 0),
toolListChars: normalizeChars(report?.tools?.listChars ?? 0),
toolSchemaChars: normalizeChars(report?.tools?.schemaChars ?? 0),
currentPromptChars,
currentPromptTokensEstimate: estimateTokensFromChars(currentPromptChars),
reservedContextCharsEstimate,
reservedContextTokensEstimate: estimateTokensFromChars(reservedContextCharsEstimate),
};
}

View File

@ -100,6 +100,7 @@ import { normalizeToolName } from "../../tool-policy.js";
import { resolveTranscriptPolicy } from "../../transcript-policy.js";
import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js";
import { isRunnerAbortError } from "../abort.js";
import { buildEmbeddedAssembleRuntimeContext } from "../assemble-runtime-context.js";
import { appendCacheTtlTimestamp, isCacheTtlEligibleProvider } from "../cache-ttl.js";
import type { CompactEmbeddedPiSessionParams } from "../compact.js";
import { buildEmbeddedCompactionRuntimeContext } from "../compaction-runtime-context.js";
@ -1327,6 +1328,23 @@ export function buildAfterTurnRuntimeContext(params: {
});
}
/** Build runtime context passed into context-engine assemble hooks. */
export function buildAssembleRuntimeContext(params: {
prompt?: string | null;
systemPromptText?: string | null;
systemPromptReport?: {
systemPrompt?: { chars?: number };
skills?: { promptChars?: number };
tools?: { listChars?: number; schemaChars?: number };
} | null;
}) {
return buildEmbeddedAssembleRuntimeContext({
prompt: params.prompt,
systemPromptText: params.systemPromptText,
systemPromptReport: params.systemPromptReport,
});
}
function summarizeMessagePayload(msg: AgentMessage): { textChars: number; imageBlocks: number } {
const content = (msg as { content?: unknown }).content;
if (typeof content === "string") {
@ -2167,6 +2185,11 @@ export async function runEmbeddedAttempt(
sessionKey: params.sessionKey,
messages: activeSession.messages,
tokenBudget: params.contextTokenBudget,
runtimeContext: buildAssembleRuntimeContext({
prompt: params.prompt,
systemPromptText,
systemPromptReport,
}),
});
if (assembled.messages !== activeSession.messages) {
activeSession.agent.replaceMessages(assembled.messages);

View File

@ -76,6 +76,7 @@ class MockContextEngine implements ContextEngine {
sessionKey?: string;
messages: AgentMessage[];
tokenBudget?: number;
runtimeContext?: Record<string, unknown>;
}): Promise<AssembleResult> {
return {
messages: params.messages,
@ -143,6 +144,7 @@ class LegacySessionKeyStrictEngine implements ContextEngine {
sessionKey?: string;
messages: AgentMessage[];
tokenBudget?: number;
runtimeContext?: Record<string, unknown>;
}): Promise<AssembleResult> {
this.assembleCalls.push({ ...params });
this.rejectSessionKey(params);
@ -174,6 +176,59 @@ class LegacySessionKeyStrictEngine implements ContextEngine {
}
}
class LegacyRuntimeContextStrictEngine implements ContextEngine {
readonly info: ContextEngineInfo = {
id: "legacy-runtimecontext-strict",
name: "Legacy RuntimeContext Strict Engine",
};
readonly assembleCalls: Array<Record<string, unknown>> = [];
private rejectRuntimeContext(params: { runtimeContext?: Record<string, unknown> }): void {
if (Object.prototype.hasOwnProperty.call(params, "runtimeContext")) {
throw new Error("Unrecognized key(s) in object: 'runtimeContext'");
}
}
async ingest(_params: {
sessionId: string;
sessionKey?: string;
message: AgentMessage;
isHeartbeat?: boolean;
}): Promise<IngestResult> {
return { ingested: true };
}
async assemble(params: {
sessionId: string;
sessionKey?: string;
messages: AgentMessage[];
tokenBudget?: number;
runtimeContext?: Record<string, unknown>;
}): Promise<AssembleResult> {
this.assembleCalls.push({ ...params });
this.rejectRuntimeContext(params);
return {
messages: params.messages,
estimatedTokens: 9,
};
}
async compact(_params: {
sessionId: string;
sessionKey?: string;
sessionFile: string;
tokenBudget?: number;
compactionTarget?: "budget" | "threshold";
customInstructions?: string;
runtimeContext?: Record<string, unknown>;
}): Promise<CompactResult> {
return {
ok: true,
compacted: false,
};
}
}
class SessionKeyRuntimeErrorEngine implements ContextEngine {
readonly info: ContextEngineInfo = {
id: "sessionkey-runtime-error",
@ -196,6 +251,7 @@ class SessionKeyRuntimeErrorEngine implements ContextEngine {
sessionKey?: string;
messages: AgentMessage[];
tokenBudget?: number;
runtimeContext?: Record<string, unknown>;
}): Promise<AssembleResult> {
this.assembleCalls += 1;
throw new Error(this.errorMessage);
@ -463,6 +519,38 @@ describe("Legacy sessionKey compatibility", () => {
expect(strictEngine.ingestedMessages).toEqual([firstMessage, secondMessage]);
});
it("retries strict assemble once when runtimeContext is rejected and memoizes that field", async () => {
const engineId = `legacy-runtimecontext-${Date.now().toString(36)}`;
const strictEngine = new LegacyRuntimeContextStrictEngine();
registerContextEngine(engineId, () => strictEngine);
const engine = await resolveContextEngine(configWithSlot(engineId));
const runtimeContext = { reservedContextTokensEstimate: 321 };
const firstAssembled = await engine.assemble({
sessionId: "s1",
sessionKey: "agent:main:test",
messages: [makeMockMessage()],
runtimeContext,
});
const secondAssembled = await engine.assemble({
sessionId: "s1",
sessionKey: "agent:main:test",
messages: [makeMockMessage("assistant", "second")],
runtimeContext,
});
expect(firstAssembled.estimatedTokens).toBe(9);
expect(secondAssembled.estimatedTokens).toBe(9);
expect(strictEngine.assembleCalls).toHaveLength(3);
expect(strictEngine.assembleCalls[0]).toHaveProperty("runtimeContext", runtimeContext);
expect(strictEngine.assembleCalls[0]).toHaveProperty("sessionKey", "agent:main:test");
expect(strictEngine.assembleCalls[1]).not.toHaveProperty("runtimeContext");
expect(strictEngine.assembleCalls[1]).toHaveProperty("sessionKey", "agent:main:test");
expect(strictEngine.assembleCalls[2]).not.toHaveProperty("runtimeContext");
expect(strictEngine.assembleCalls[2]).toHaveProperty("sessionKey", "agent:main:test");
});
it("does not retry non-compat runtime errors", async () => {
const engineId = `sessionkey-runtime-${Date.now().toString(36)}`;
const runtimeErrorEngine = new SessionKeyRuntimeErrorEngine();

View File

@ -40,6 +40,7 @@ export class LegacyContextEngine implements ContextEngine {
sessionKey?: string;
messages: AgentMessage[];
tokenBudget?: number;
runtimeContext?: ContextEngineRuntimeContext;
}): Promise<AssembleResult> {
// Pass-through: the existing sanitize -> validate -> limit -> repair pipeline
// in attempt.ts handles context assembly for the legacy engine.

View File

@ -1,6 +1,6 @@
import type { OpenClawConfig } from "../config/config.js";
import { defaultSlotIdForKey } from "../plugins/slots.js";
import type { ContextEngine } from "./types.js";
import type { ContextEngine, ContextEngineRuntimeContext } from "./types.js";
/**
* A factory that creates a ContextEngine instance.
@ -24,8 +24,11 @@ const SESSION_KEY_COMPAT_METHODS = [
] as const;
type SessionKeyCompatMethodName = (typeof SESSION_KEY_COMPAT_METHODS)[number];
const LEGACY_COMPAT_FIELDS = ["sessionKey", "runtimeContext"] as const;
type LegacyCompatFieldName = (typeof LEGACY_COMPAT_FIELDS)[number];
type SessionKeyCompatParams = {
sessionKey?: string;
runtimeContext?: ContextEngineRuntimeContext;
};
function isSessionKeyCompatMethodName(value: PropertyKey): value is SessionKeyCompatMethodName {
@ -34,21 +37,29 @@ function isSessionKeyCompatMethodName(value: PropertyKey): value is SessionKeyCo
);
}
function hasOwnSessionKey(params: unknown): params is SessionKeyCompatParams {
return (
params !== null &&
typeof params === "object" &&
Object.prototype.hasOwnProperty.call(params, "sessionKey")
);
function getOwnLegacyCompatFields(params: unknown): LegacyCompatFieldName[] {
if (!params || typeof params !== "object") {
return [];
}
return LEGACY_COMPAT_FIELDS.filter((field) => Object.prototype.hasOwnProperty.call(params, field));
}
function withoutSessionKey<T extends SessionKeyCompatParams>(params: T): T {
function hasOwnLegacyCompatFields(params: unknown): params is SessionKeyCompatParams {
return getOwnLegacyCompatFields(params).length > 0;
}
function withoutLegacyCompatFields<T extends SessionKeyCompatParams>(
params: T,
fields: readonly LegacyCompatFieldName[],
): T {
const legacyParams = { ...params };
delete legacyParams.sessionKey;
for (const field of fields) {
delete legacyParams[field];
}
return legacyParams;
}
function issueRejectsSessionKeyStrictly(issue: unknown): boolean {
function issueRejectsLegacyFieldStrictly(issue: unknown, field: LegacyCompatFieldName): boolean {
if (!issue || typeof issue !== "object") {
return false;
}
@ -61,12 +72,12 @@ function issueRejectsSessionKeyStrictly(issue: unknown): boolean {
if (
issueRecord.code === "unrecognized_keys" &&
Array.isArray(issueRecord.keys) &&
issueRecord.keys.some((key) => key === "sessionKey")
issueRecord.keys.some((key) => key === field)
) {
return true;
}
return isSessionKeyCompatibilityError(issueRecord.message);
return isLegacyCompatFieldCompatibilityError(issueRecord.message, field);
}
function* iterateErrorChain(error: unknown) {
@ -82,31 +93,50 @@ function* iterateErrorChain(error: unknown) {
}
}
const SESSION_KEY_UNKNOWN_FIELD_PATTERNS = [
/\bunrecognized key(?:\(s\)|s)? in object:.*['"`]sessionKey['"`]/i,
/\badditional propert(?:y|ies)\b.*['"`]sessionKey['"`]/i,
/\bmust not have additional propert(?:y|ies)\b.*['"`]sessionKey['"`]/i,
/\b(?:unexpected|extraneous)\s+(?:property|properties|field|fields|key|keys)\b.*['"`]sessionKey['"`]/i,
/\b(?:unknown|invalid)\s+(?:property|properties|field|fields|key|keys)\b.*['"`]sessionKey['"`]/i,
/['"`]sessionKey['"`].*\b(?:was|is)\s+not allowed\b/i,
/"code"\s*:\s*"unrecognized_keys"[^]*"sessionKey"/i,
] as const;
function isSessionKeyUnknownFieldValidationMessage(message: string): boolean {
return SESSION_KEY_UNKNOWN_FIELD_PATTERNS.some((pattern) => pattern.test(message));
function buildUnknownFieldPatterns(field: LegacyCompatFieldName): readonly RegExp[] {
return [
new RegExp(`\\bunrecognized key(?:\\(s\\)|s)? in object:.*['"\`]${field}['"\`]`, "i"),
new RegExp(`\\badditional propert(?:y|ies)\\b.*['"\`]${field}['"\`]`, "i"),
new RegExp(`\\bmust not have additional propert(?:y|ies)\\b.*['"\`]${field}['"\`]`, "i"),
new RegExp(
`\\b(?:unexpected|extraneous)\\s+(?:property|properties|field|fields|key|keys)\\b.*['"\`]${field}['"\`]`,
"i",
),
new RegExp(
`\\b(?:unknown|invalid)\\s+(?:property|properties|field|fields|key|keys)\\b.*['"\`]${field}['"\`]`,
"i",
),
new RegExp(`['"\`]${field}['"\`].*\\b(?:was|is)\\s+not allowed\\b`, "i"),
new RegExp(`"code"\\s*:\\s*"unrecognized_keys"[^]*"${field}"`, "i"),
] as const;
}
function isSessionKeyCompatibilityError(error: unknown): boolean {
const LEGACY_UNKNOWN_FIELD_PATTERNS = {
sessionKey: buildUnknownFieldPatterns("sessionKey"),
runtimeContext: buildUnknownFieldPatterns("runtimeContext"),
} as const satisfies Record<LegacyCompatFieldName, readonly RegExp[]>;
function isLegacyCompatFieldUnknownFieldValidationMessage(
message: string,
field: LegacyCompatFieldName,
): boolean {
return LEGACY_UNKNOWN_FIELD_PATTERNS[field].some((pattern) => pattern.test(message));
}
function isLegacyCompatFieldCompatibilityError(
error: unknown,
field: LegacyCompatFieldName,
): boolean {
for (const candidate of iterateErrorChain(error)) {
if (Array.isArray(candidate)) {
if (candidate.some((entry) => issueRejectsSessionKeyStrictly(entry))) {
if (candidate.some((entry) => issueRejectsLegacyFieldStrictly(entry, field))) {
return true;
}
continue;
}
if (typeof candidate === "string") {
if (isSessionKeyUnknownFieldValidationMessage(candidate)) {
if (isLegacyCompatFieldUnknownFieldValidationMessage(candidate, field)) {
return true;
}
continue;
@ -124,21 +154,21 @@ function isSessionKeyCompatibilityError(error: unknown): boolean {
if (
Array.isArray(issueContainer.issues) &&
issueContainer.issues.some((issue) => issueRejectsSessionKeyStrictly(issue))
issueContainer.issues.some((issue) => issueRejectsLegacyFieldStrictly(issue, field))
) {
return true;
}
if (
Array.isArray(issueContainer.errors) &&
issueContainer.errors.some((issue) => issueRejectsSessionKeyStrictly(issue))
issueContainer.errors.some((issue) => issueRejectsLegacyFieldStrictly(issue, field))
) {
return true;
}
if (
typeof issueContainer.message === "string" &&
isSessionKeyUnknownFieldValidationMessage(issueContainer.message)
isLegacyCompatFieldUnknownFieldValidationMessage(issueContainer.message, field)
) {
return true;
}
@ -147,25 +177,35 @@ function isSessionKeyCompatibilityError(error: unknown): boolean {
return false;
}
function collectLegacyCompatRejectedFields(error: unknown): LegacyCompatFieldName[] {
return LEGACY_COMPAT_FIELDS.filter((field) => isLegacyCompatFieldCompatibilityError(error, field));
}
async function invokeWithLegacySessionKeyCompat<TResult, TParams extends SessionKeyCompatParams>(
method: (params: TParams) => Promise<TResult> | TResult,
params: TParams,
opts?: {
onLegacyModeDetected?: () => void;
onLegacyModeDetected?: (fields: LegacyCompatFieldName[]) => void;
},
): Promise<TResult> {
if (!hasOwnSessionKey(params)) {
if (!hasOwnLegacyCompatFields(params)) {
return await method(params);
}
try {
return await method(params);
} catch (error) {
if (!isSessionKeyCompatibilityError(error)) {
throw error;
let currentParams = params;
while (true) {
try {
return await method(currentParams);
} catch (error) {
const presentRejectedFields = collectLegacyCompatRejectedFields(error).filter((field) =>
getOwnLegacyCompatFields(currentParams).includes(field),
);
if (presentRejectedFields.length === 0) {
throw error;
}
opts?.onLegacyModeDetected?.(presentRejectedFields);
currentParams = withoutLegacyCompatFields(currentParams, presentRejectedFields);
}
opts?.onLegacyModeDetected?.();
return await method(withoutSessionKey(params));
}
}
@ -178,6 +218,7 @@ function wrapContextEngineWithSessionKeyCompat(engine: ContextEngine): ContextEn
}
let isLegacy = false;
const legacyFields = new Set<LegacyCompatFieldName>();
const proxy: ContextEngine = new Proxy(engine, {
get(target, property, receiver) {
if (property === LEGACY_SESSION_KEY_COMPAT) {
@ -195,12 +236,18 @@ function wrapContextEngineWithSessionKeyCompat(engine: ContextEngine): ContextEn
return (params: SessionKeyCompatParams) => {
const method = value.bind(target) as (params: SessionKeyCompatParams) => unknown;
if (isLegacy && hasOwnSessionKey(params)) {
return method(withoutSessionKey(params));
const knownLegacyFields = getOwnLegacyCompatFields(params).filter((field) =>
legacyFields.has(field),
);
if (isLegacy && knownLegacyFields.length > 0) {
return method(withoutLegacyCompatFields(params, knownLegacyFields));
}
return invokeWithLegacySessionKeyCompat(method, params, {
onLegacyModeDetected: () => {
onLegacyModeDetected: (detectedFields) => {
isLegacy = true;
for (const field of detectedFields) {
legacyFields.add(field);
}
},
});
};

View File

@ -57,7 +57,28 @@ export type SubagentSpawnPreparation = {
};
export type SubagentEndReason = "deleted" | "completed" | "swept" | "released";
export type ContextEngineRuntimeContext = Record<string, unknown>;
export type ContextEngineRuntimeContext = Record<string, unknown> & {
/**
* Approximate size of the host-owned system prompt before context-engine
* additions are prepended.
*/
systemPromptChars?: number;
systemPromptTokensEstimate?: number;
/** Skill blocks are already included in systemPromptChars; these are breakdowns. */
skillsPromptChars?: number;
/** Tool list/schema chars are already included in systemPromptChars; these are breakdowns. */
toolListChars?: number;
toolSchemaChars?: number;
/** Approximate size of the current user prompt for the pending run. */
currentPromptChars?: number;
currentPromptTokensEstimate?: number;
/**
* Host-owned context reserved outside the engine-controlled history slice.
* This usually includes the base system prompt and current user prompt.
*/
reservedContextCharsEstimate?: number;
reservedContextTokensEstimate?: number;
};
/**
* ContextEngine defines the pluggable contract for context management.
@ -131,6 +152,8 @@ export interface ContextEngine {
sessionKey?: string;
messages: AgentMessage[];
tokenBudget?: number;
/** Optional runtime-owned context for engines that need caller state. */
runtimeContext?: ContextEngineRuntimeContext;
}): Promise<AssembleResult>;
/**