context-engine: pass runtime context to ContextEngineFactory

This commit is contained in:
Jari Mustonen
2026-04-15 09:00:04 +03:00
committed by Josh Lehman
parent 12c52963ea
commit 8063d773c2
5 changed files with 177 additions and 13 deletions

View File

@@ -51,8 +51,12 @@ export async function compactEmbeddedPiSession(
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
});
ensureContextEnginesInitialized();
const contextEngine = await resolveContextEngine(params.config);
const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
const contextEngine = await resolveContextEngine(params.config, {
agentDir,
workspaceDir: resolveUserPath(params.workspaceDir),
sessionKey: params.sessionKey,
});
let contextTokenBudget = params.contextTokenBudget;
if (!contextTokenBudget || !Number.isFinite(contextTokenBudget) || contextTokenBudget <= 0) {
const resolvedCompactionTarget = resolveEmbeddedCompactionTarget({

View File

@@ -740,7 +740,11 @@ export async function runEmbeddedPiAgent(
// Resolve the context engine once and reuse across retries to avoid
// repeated initialization/connection overhead per attempt.
ensureContextEnginesInitialized();
const contextEngine = await resolveContextEngine(params.config);
const contextEngine = await resolveContextEngine(params.config, {
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
sessionKey: params.sessionKey,
});
try {
let activeSessionId = params.sessionId;
let activeSessionFile = params.sessionFile;

View File

@@ -17,7 +17,11 @@ import {
listContextEngineIds,
resolveContextEngine,
} from "./registry.js";
import type { ContextEngineFactory, ContextEngineRegistrationResult } from "./registry.js";
import type {
ContextEngineFactory,
ContextEngineFactoryContext,
ContextEngineRegistrationResult,
} from "./registry.js";
import type {
ContextEngine,
ContextEngineInfo,
@@ -693,6 +697,110 @@ describe("Default engine selection", () => {
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 3b. Factory context passing
// ═══════════════════════════════════════════════════════════════════════════
describe("Factory context passing", () => {
it("passes ContextEngineFactoryContext to factories that accept a parameter", async () => {
const engineId = `factory-ctx-${Date.now().toString(36)}`;
let receivedCtx: ContextEngineFactoryContext | undefined;
const factory: ContextEngineFactory = (ctx?: ContextEngineFactoryContext) => {
receivedCtx = ctx;
return {
info: { id: engineId, name: "Ctx Engine" },
async ingest() {
return { ingested: true };
},
async assemble({ messages }: { messages: AgentMessage[] }) {
return { messages, estimatedTokens: 0 };
},
async compact() {
return { ok: true, compacted: false };
},
};
};
registerContextEngine(engineId, factory);
const cfg = configWithSlot(engineId);
await resolveContextEngine(cfg, {
agentDir: "/tmp/agent",
workspaceDir: "/tmp/workspace",
sessionKey: "agent:main:test",
});
expect(receivedCtx).toBeDefined();
expect(receivedCtx!.config).toBe(cfg);
expect(receivedCtx!.agentDir).toBe("/tmp/agent");
expect(receivedCtx!.workspaceDir).toBe("/tmp/workspace");
expect(receivedCtx!.sessionKey).toBe("agent:main:test");
});
it("no-arg factories still work when context is passed", async () => {
const engineId = `factory-noarg-${Date.now().toString(36)}`;
let called = false;
const factory: ContextEngineFactory = () => {
called = true;
return {
info: { id: engineId, name: "No-Arg Engine" },
async ingest() {
return { ingested: true };
},
async assemble({ messages }: { messages: AgentMessage[] }) {
return { messages, estimatedTokens: 0 };
},
async compact() {
return { ok: true, compacted: false };
},
};
};
registerContextEngine(engineId, factory);
const engine = await resolveContextEngine(configWithSlot(engineId), {
agentDir: "/tmp/agent",
workspaceDir: "/tmp/workspace",
sessionKey: "agent:main:test",
});
expect(called).toBe(true);
expect(engine.info.id).toBe(engineId);
});
it("provides empty config when resolveContextEngine is called without config", async () => {
const engineId = `factory-noconfig-${Date.now().toString(36)}`;
let receivedCtx: ContextEngineFactoryContext | undefined;
registerContextEngine(engineId, (ctx?: ContextEngineFactoryContext) => {
receivedCtx = ctx;
return {
info: { id: engineId, name: "NoConfig Engine" },
async ingest() {
return { ingested: true };
},
async assemble({ messages }: { messages: AgentMessage[] }) {
return { messages, estimatedTokens: 0 };
},
async compact() {
return { ok: true, compacted: false };
},
};
});
// Call with undefined config — should still resolve the default engine,
// but our engine is not the default slot so register as default temporarily.
// Instead, just verify the factory type works as a ContextEngineFactory.
await resolveContextEngine(configWithSlot(engineId));
expect(receivedCtx).toBeDefined();
expect(receivedCtx!.config).toBeDefined();
expect(receivedCtx!.agentDir).toBeUndefined();
expect(receivedCtx!.workspaceDir).toBeUndefined();
expect(receivedCtx!.sessionKey).toBeUndefined();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 4. Invalid engine fallback
// ═══════════════════════════════════════════════════════════════════════════

View File

@@ -4,11 +4,29 @@ import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { sanitizeForLog } from "../terminal/ansi.js";
import type { ContextEngine } from "./types.js";
/**
* Runtime context passed to context engine factories during resolution.
* Provides config and path information so plugins can initialize engines
* without fragile workarounds.
*/
export type ContextEngineFactoryContext = {
config: OpenClawConfig;
agentDir?: string;
workspaceDir?: string;
sessionKey?: string;
};
/**
* A factory that creates a ContextEngine instance.
* Supports async creation for engines that need DB connections etc.
*
* Factories may accept an optional {@link ContextEngineFactoryContext} parameter
* for runtime context. No-arg factories remain supported for backward compatibility
* since the parameter is optional.
*/
export type ContextEngineFactory = () => ContextEngine | Promise<ContextEngine>;
export type ContextEngineFactory = (
ctx?: ContextEngineFactoryContext,
) => ContextEngine | Promise<ContextEngine>;
export type ContextEngineRegistrationResult = { ok: true } | { ok: false; existingOwner: string };
type RegisterContextEngineForOwnerOptions = {
@@ -455,6 +473,15 @@ function describeResolvedContextEngineContractError(
// Resolution
// ---------------------------------------------------------------------------
/**
* Options for {@link resolveContextEngine}.
*/
export type ResolveContextEngineOptions = {
agentDir?: string;
workspaceDir?: string;
sessionKey?: string;
};
/**
* Resolve which ContextEngine to use based on plugin slot configuration.
*
@@ -462,11 +489,19 @@ function describeResolvedContextEngineContractError(
* 1. `config.plugins.slots.contextEngine` (explicit slot override)
* 2. Default slot value ("legacy")
*
* When `config` is provided it is forwarded to the factory as part of a
* {@link ContextEngineFactoryContext}. Additional runtime paths and keys
* can be supplied via `options`. No-arg factories still work — the context
* parameter is silently ignored by factories that don't declare it.
*
* Non-default engines that fail (unregistered, factory throw, or contract
* violation) are logged and silently replaced by the default engine.
* Throws only when the default engine itself cannot be resolved.
*/
export async function resolveContextEngine(config?: OpenClawConfig): Promise<ContextEngine> {
export async function resolveContextEngine(
config?: OpenClawConfig,
options?: ResolveContextEngineOptions,
): Promise<ContextEngine> {
const slotValue = config?.plugins?.slots?.contextEngine;
const engineId =
typeof slotValue === "string" && slotValue.trim()
@@ -476,6 +511,13 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise<Con
const defaultEngineId = defaultSlotIdForKey("contextEngine");
const isDefaultEngine = engineId === defaultEngineId;
const factoryCtx: ContextEngineFactoryContext = {
config: config ?? ({} as OpenClawConfig),
agentDir: options?.agentDir,
workspaceDir: options?.workspaceDir,
sessionKey: options?.sessionKey,
};
const entry = getContextEngineRegistryState().engines.get(engineId);
if (!entry) {
if (isDefaultEngine) {
@@ -488,12 +530,12 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise<Con
`[context-engine] Context engine "${sanitizeForLog(engineId)}" is not registered; ` +
`falling back to default engine "${defaultEngineId}".`,
);
return resolveDefaultContextEngine(defaultEngineId);
return resolveDefaultContextEngine(defaultEngineId, factoryCtx);
}
let engine: ContextEngine;
try {
engine = await entry.factory();
engine = await entry.factory(factoryCtx);
} catch (factoryError) {
if (isDefaultEngine) {
throw factoryError;
@@ -503,7 +545,7 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise<Con
`${sanitizeForLog(factoryError instanceof Error ? factoryError.message : String(factoryError))}; ` +
`falling back to default engine "${defaultEngineId}".`,
);
return resolveDefaultContextEngine(defaultEngineId);
return resolveDefaultContextEngine(defaultEngineId, factoryCtx);
}
let contractError: string | null;
@@ -518,7 +560,7 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise<Con
`${sanitizeForLog(validationError instanceof Error ? validationError.message : String(validationError))}; ` +
`falling back to default engine "${defaultEngineId}".`,
);
return resolveDefaultContextEngine(defaultEngineId);
return resolveDefaultContextEngine(defaultEngineId, factoryCtx);
}
if (contractError) {
if (isDefaultEngine) {
@@ -528,7 +570,7 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise<Con
console.error(
`[context-engine] ${sanitizeForLog(contractError)}; falling back to default engine "${defaultEngineId}".`,
);
return resolveDefaultContextEngine(defaultEngineId);
return resolveDefaultContextEngine(defaultEngineId, factoryCtx);
}
return wrapContextEngineWithSessionKeyCompat(engine);
@@ -540,7 +582,10 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise<Con
* This helper is intentionally strict: if the default engine itself fails,
* there is no further fallback and the error must propagate.
*/
async function resolveDefaultContextEngine(defaultEngineId: string): Promise<ContextEngine> {
async function resolveDefaultContextEngine(
defaultEngineId: string,
factoryCtx: ContextEngineFactoryContext,
): Promise<ContextEngine> {
const defaultEntry = getContextEngineRegistryState().engines.get(defaultEngineId);
if (!defaultEntry) {
throw new Error(
@@ -548,7 +593,7 @@ async function resolveDefaultContextEngine(defaultEngineId: string): Promise<Con
`Available engines: ${listContextEngineIds().join(", ") || "(none)"}`,
);
}
const engine = await defaultEntry.factory();
const engine = await defaultEntry.factory(factoryCtx);
const contractError = describeResolvedContextEngineContractError(defaultEngineId, engine);
if (contractError) {
throw new Error(`[context-engine] ${contractError}`);

View File

@@ -95,7 +95,10 @@ export type { RuntimeEnv } from "../runtime.js";
export type { HookEntry } from "../hooks/types.js";
export type { ReplyPayload } from "./reply-payload.js";
export type { WizardPrompter } from "../wizard/prompts.js";
export type { ContextEngineFactory } from "../context-engine/registry.js";
export type {
ContextEngineFactory,
ContextEngineFactoryContext,
} from "../context-engine/registry.js";
export type { DiagnosticEventPayload } from "../infra/diagnostic-events.js";
export type { DiagnosticTraceContext } from "../infra/diagnostic-trace-context.js";
export type {