mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:10:45 +00:00
context-engine: pass runtime context to ContextEngineFactory
This commit is contained in:
committed by
Josh Lehman
parent
12c52963ea
commit
8063d773c2
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user