mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
context-engine: pass runtime context to ContextEngineFactory (#67243)
Merged via squash.
Prepared head SHA: 9aca6a5af1
Co-authored-by: jarimustonen <1272053+jarimustonen@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
This commit is contained in:
@@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Auto-reply/session: carry the tail of user/assistant turns into the freshly-rotated transcript on silent in-reply session resets (compaction failure, role-ordering conflict) so direct-chat continuity survives the rebind. Fixes #70853. (#70898) Thanks @neeravmakwana.
|
||||
- Config: skip malformed non-string `env.vars` entries before env-reference checks, so config loading no longer crashes on JSON values like numbers or booleans. (#42402) Thanks @MiltonHeYan.
|
||||
- Docker Compose: default missing config and workspace bind mounts to `${HOME:-/tmp}/.openclaw` so manual compose runs do not create invalid empty-source volume specs. (#64485) Thanks @jlapenna.
|
||||
- Agents/context engines: preserve the child agent's configured `agentDir` when subagent cleanup re-resolves a context engine, so `onSubagentEnded` hooks keep operating on the correct per-agent state. (#67243) Thanks @jarimustonen.
|
||||
- Channels/WhatsApp: restrict pairing verification replies to real inbound user content, preventing unsolicited prompts from receipts, typing indicators, presence updates, and other non-message Baileys upserts. Fixes #73797. (#73823) Thanks @hclsys.
|
||||
|
||||
## 2026.4.27
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
46476e7b4fee105ca27aed9c769c507f70f02b8ce8586c135feb18e751db0de1 plugin-sdk-api-baseline.json
|
||||
4bc1c0dc66d910c80694fa1a6b7ba3ab488bf737b3566e53b8a5857c16d2e0b1 plugin-sdk-api-baseline.jsonl
|
||||
e7d03a0d5aed4f1afb5c7d5e235a166e1e248090632248eaa92b0016531e7f3b plugin-sdk-api-baseline.json
|
||||
b9bbf8e444b358485cb33c634d3f6f6588004a5c32482c1a473167957269ae58 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -122,7 +122,7 @@ A plugin can register a context engine using the plugin API:
|
||||
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
|
||||
|
||||
export default function register(api) {
|
||||
api.registerContextEngine("my-engine", () => ({
|
||||
api.registerContextEngine("my-engine", (ctx) => ({
|
||||
info: {
|
||||
id: "my-engine",
|
||||
name: "My Context Engine",
|
||||
@@ -154,6 +154,10 @@ export default function register(api) {
|
||||
}
|
||||
```
|
||||
|
||||
The factory `ctx` includes optional `config`, `agentDir`, and `workspaceDir`
|
||||
values so plugins can initialize per-agent or per-workspace state before the
|
||||
first lifecycle hook runs.
|
||||
|
||||
Then enable it in config:
|
||||
|
||||
```json5
|
||||
|
||||
@@ -960,7 +960,7 @@ pipeline rather than just add memory search or hooks.
|
||||
import { buildMemorySystemPromptAddition } from "openclaw/plugin-sdk/core";
|
||||
|
||||
export default function (api) {
|
||||
api.registerContextEngine("lossless-claw", () => ({
|
||||
api.registerContextEngine("lossless-claw", (ctx) => ({
|
||||
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
|
||||
async ingest() {
|
||||
return { ingested: true };
|
||||
@@ -982,6 +982,9 @@ export default function (api) {
|
||||
}
|
||||
```
|
||||
|
||||
The factory `ctx` exposes optional `config`, `agentDir`, and `workspaceDir`
|
||||
values for construction-time initialization.
|
||||
|
||||
If your engine does **not** own the compaction algorithm, keep `compact()`
|
||||
implemented and delegate it explicitly:
|
||||
|
||||
@@ -992,7 +995,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
|
||||
export default function (api) {
|
||||
api.registerContextEngine("my-memory-engine", () => ({
|
||||
api.registerContextEngine("my-memory-engine", (ctx) => ({
|
||||
info: {
|
||||
id: "my-memory-engine",
|
||||
name: "My Memory Engine",
|
||||
|
||||
@@ -51,8 +51,12 @@ export async function compactEmbeddedPiSession(
|
||||
allowGatewaySubagentBinding: params.allowGatewaySubagentBinding,
|
||||
});
|
||||
ensureContextEnginesInitialized();
|
||||
const contextEngine = await resolveContextEngine(params.config);
|
||||
const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
|
||||
const resolvedWorkspaceDir = resolveUserPath(params.workspaceDir);
|
||||
const contextEngine = await resolveContextEngine(params.config, {
|
||||
agentDir,
|
||||
workspaceDir: resolvedWorkspaceDir,
|
||||
});
|
||||
let contextTokenBudget = params.contextTokenBudget;
|
||||
if (!contextTokenBudget || !Number.isFinite(contextTokenBudget) || contextTokenBudget <= 0) {
|
||||
const resolvedCompactionTarget = resolveEmbeddedCompactionTarget({
|
||||
@@ -129,7 +133,7 @@ export async function compactEmbeddedPiSession(
|
||||
sessionId: params.sessionId,
|
||||
agentId: sessionAgentId,
|
||||
sessionKey: hookSessionKey,
|
||||
workspaceDir: resolveUserPath(params.workspaceDir),
|
||||
workspaceDir: resolvedWorkspaceDir,
|
||||
messageProvider: resolvedMessageProvider,
|
||||
};
|
||||
const runtimeContext = contextEngineRuntimeContext;
|
||||
|
||||
@@ -740,7 +740,10 @@ 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,
|
||||
workspaceDir: resolvedWorkspace,
|
||||
});
|
||||
try {
|
||||
let activeSessionId = params.sessionId;
|
||||
let activeSessionFile = params.sessionFile;
|
||||
|
||||
@@ -72,6 +72,7 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
notifyContextEngineSubagentEnded(args: {
|
||||
childSessionKey: string;
|
||||
reason: "completed" | "deleted";
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
}): Promise<void>;
|
||||
resumeSubagentRun(runId: string): void;
|
||||
@@ -421,6 +422,7 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
void params.notifyContextEngineSubagentEnded({
|
||||
childSessionKey: cleanupParams.entry.childSessionKey,
|
||||
reason: "deleted",
|
||||
agentDir: cleanupParams.entry.agentDir,
|
||||
workspaceDir: cleanupParams.entry.workspaceDir,
|
||||
});
|
||||
params.runs.delete(cleanupParams.runId);
|
||||
@@ -431,6 +433,7 @@ export function createSubagentRegistryLifecycleController(params: {
|
||||
void params.notifyContextEngineSubagentEnded({
|
||||
childSessionKey: cleanupParams.entry.childSessionKey,
|
||||
reason: "completed",
|
||||
agentDir: cleanupParams.entry.agentDir,
|
||||
workspaceDir: cleanupParams.entry.workspaceDir,
|
||||
});
|
||||
cleanupParams.entry.cleanupCompletedAt = cleanupParams.completedAt;
|
||||
|
||||
@@ -91,6 +91,7 @@ export type RegisterSubagentRunParams = {
|
||||
cleanup: "delete" | "keep";
|
||||
label?: string;
|
||||
model?: string;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
runTimeoutSeconds?: number;
|
||||
expectsCompletionMessage?: boolean;
|
||||
@@ -124,6 +125,7 @@ export function createSubagentRunManager(params: {
|
||||
notifyContextEngineSubagentEnded(args: {
|
||||
childSessionKey: string;
|
||||
reason: "completed" | "deleted" | "released";
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
}): Promise<void>;
|
||||
completeCleanupBookkeeping(args: {
|
||||
@@ -402,6 +404,7 @@ export function createSubagentRunManager(params: {
|
||||
spawnMode,
|
||||
label: registerParams.label,
|
||||
model: registerParams.model,
|
||||
agentDir: registerParams.agentDir,
|
||||
workspaceDir: registerParams.workspaceDir,
|
||||
runTimeoutSeconds,
|
||||
createdAt: now,
|
||||
@@ -458,6 +461,7 @@ export function createSubagentRunManager(params: {
|
||||
void params.notifyContextEngineSubagentEnded({
|
||||
childSessionKey: entry.childSessionKey,
|
||||
reason: "released",
|
||||
agentDir: entry.agentDir,
|
||||
workspaceDir: entry.workspaceDir,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -851,6 +851,7 @@ describe("subagent registry seam flow", () => {
|
||||
cleanup: "keep",
|
||||
expectsCompletionMessage: undefined,
|
||||
spawnMode: "run",
|
||||
agentDir: "/tmp/agent-alt",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
createdAt: 1,
|
||||
startedAt: 1,
|
||||
@@ -863,6 +864,7 @@ describe("subagent registry seam flow", () => {
|
||||
|
||||
await waitForFast(() => {
|
||||
expect(mocks.onSubagentEnded).toHaveBeenCalledWith({
|
||||
agentDir: "/tmp/agent-alt",
|
||||
childSessionKey: "agent:main:session:child",
|
||||
reason: "released",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
@@ -877,5 +879,82 @@ describe("subagent registry seam flow", () => {
|
||||
allowGatewaySubagentBinding: true,
|
||||
});
|
||||
expect(mocks.ensureContextEnginesInitialized).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.resolveContextEngine).toHaveBeenCalledWith(
|
||||
{
|
||||
agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } },
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
},
|
||||
{
|
||||
agentDir: "/tmp/agent-alt",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("passes stored agentDir through swept context-engine cleanup paths", async () => {
|
||||
const now = Date.parse("2026-03-24T12:00:00Z");
|
||||
mod.addSubagentRunForTests({
|
||||
runId: "run-session-swept-context-engine",
|
||||
childSessionKey: "agent:alt:session:child-session",
|
||||
controllerSessionKey: "agent:main:session:parent",
|
||||
requesterSessionKey: "agent:main:session:parent",
|
||||
requesterOrigin: undefined,
|
||||
requesterDisplayKey: "parent",
|
||||
task: "session cleanup",
|
||||
cleanup: "keep",
|
||||
expectsCompletionMessage: undefined,
|
||||
spawnMode: "session",
|
||||
agentDir: "/tmp/agent-session",
|
||||
workspaceDir: "/tmp/workspace-session",
|
||||
createdAt: now - 20_000,
|
||||
startedAt: now - 10_000,
|
||||
sessionStartedAt: now - 10_000,
|
||||
accumulatedRuntimeMs: 0,
|
||||
endedAt: now - 8_000,
|
||||
outcome: { status: "ok", startedAt: now - 10_000, endedAt: now - 8_000, elapsedMs: 2_000 },
|
||||
cleanupHandled: true,
|
||||
cleanupCompletedAt: now - 6 * 60_000,
|
||||
});
|
||||
mod.addSubagentRunForTests({
|
||||
runId: "run-archive-swept-context-engine",
|
||||
childSessionKey: "agent:alt:session:child-archive",
|
||||
controllerSessionKey: "agent:main:session:parent",
|
||||
requesterSessionKey: "agent:main:session:parent",
|
||||
requesterOrigin: undefined,
|
||||
requesterDisplayKey: "parent",
|
||||
task: "archive cleanup",
|
||||
cleanup: "delete",
|
||||
expectsCompletionMessage: undefined,
|
||||
spawnMode: "run",
|
||||
agentDir: "/tmp/agent-archive",
|
||||
workspaceDir: "/tmp/workspace-archive",
|
||||
createdAt: now - 20_000,
|
||||
startedAt: now - 10_000,
|
||||
sessionStartedAt: now - 10_000,
|
||||
accumulatedRuntimeMs: 0,
|
||||
endedAt: now - 8_000,
|
||||
outcome: { status: "ok", startedAt: now - 10_000, endedAt: now - 8_000, elapsedMs: 2_000 },
|
||||
archiveAtMs: now - 1,
|
||||
cleanupHandled: true,
|
||||
});
|
||||
|
||||
await mod.__testing.sweepOnceForTests();
|
||||
|
||||
await waitForFast(() => {
|
||||
expect(mocks.resolveContextEngine).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
agentDir: "/tmp/agent-session",
|
||||
workspaceDir: "/tmp/workspace-session",
|
||||
}),
|
||||
);
|
||||
expect(mocks.resolveContextEngine).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
agentDir: "/tmp/agent-archive",
|
||||
workspaceDir: "/tmp/workspace-archive",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type SessionEntry,
|
||||
} from "../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { ResolveContextEngineOptions } from "../context-engine/registry.js";
|
||||
import type { ContextEngine, SubagentEndReason } from "../context-engine/types.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { getAgentRunContext, onAgentEvent } from "../infra/agent-events.js";
|
||||
@@ -97,7 +98,10 @@ type SubagentRegistryDeps = {
|
||||
runSubagentAnnounceFlow: SubagentAnnounceModule["runSubagentAnnounceFlow"];
|
||||
ensureContextEnginesInitialized?: () => void;
|
||||
ensureRuntimePluginsLoaded?: typeof ensureRuntimePluginsLoadedFn;
|
||||
resolveContextEngine?: (cfg: OpenClawConfig) => Promise<ContextEngine>;
|
||||
resolveContextEngine?: (
|
||||
cfg?: OpenClawConfig,
|
||||
options?: ResolveContextEngineOptions,
|
||||
) => Promise<ContextEngine>;
|
||||
};
|
||||
|
||||
let subagentAnnouncePromise: Promise<SubagentAnnounceModule> | null = null;
|
||||
@@ -140,7 +144,10 @@ type ContextEngineInitModule = Pick<
|
||||
>;
|
||||
type ContextEngineRegistryModule = Pick<
|
||||
{
|
||||
resolveContextEngine: (cfg: OpenClawConfig) => Promise<ContextEngine>;
|
||||
resolveContextEngine: (
|
||||
cfg?: OpenClawConfig,
|
||||
options?: ResolveContextEngineOptions,
|
||||
) => Promise<ContextEngine>;
|
||||
},
|
||||
"resolveContextEngine"
|
||||
>;
|
||||
@@ -308,7 +315,10 @@ async function ensureSubagentRegistryPluginRuntimeLoaded(params: {
|
||||
(await loadRuntimePluginsModule()).ensureRuntimePluginsLoaded(params);
|
||||
}
|
||||
|
||||
async function resolveSubagentRegistryContextEngine(cfg: OpenClawConfig) {
|
||||
async function resolveSubagentRegistryContextEngine(
|
||||
cfg: OpenClawConfig,
|
||||
options?: ResolveContextEngineOptions,
|
||||
) {
|
||||
const initModule = await loadContextEngineInitModule();
|
||||
const registryModule = await loadContextEngineRegistryModule();
|
||||
const ensureContextEnginesInitialized =
|
||||
@@ -317,7 +327,7 @@ async function resolveSubagentRegistryContextEngine(cfg: OpenClawConfig) {
|
||||
const resolveContextEngine =
|
||||
subagentRegistryDeps.resolveContextEngine ?? registryModule.resolveContextEngine;
|
||||
ensureContextEnginesInitialized();
|
||||
return await resolveContextEngine(cfg);
|
||||
return await resolveContextEngine(cfg, options);
|
||||
}
|
||||
|
||||
function persistSubagentRuns() {
|
||||
@@ -469,6 +479,7 @@ function schedulePendingLifecycleTimeout(params: { runId: string; endedAt: numbe
|
||||
async function notifyContextEngineSubagentEnded(params: {
|
||||
childSessionKey: string;
|
||||
reason: SubagentEndReason;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
}) {
|
||||
try {
|
||||
@@ -478,7 +489,10 @@ async function notifyContextEngineSubagentEnded(params: {
|
||||
workspaceDir: params.workspaceDir,
|
||||
allowGatewaySubagentBinding: true,
|
||||
});
|
||||
const engine = await resolveSubagentRegistryContextEngine(cfg);
|
||||
const engine = await resolveSubagentRegistryContextEngine(cfg, {
|
||||
agentDir: params.agentDir,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
if (!engine.onSubagentEnded) {
|
||||
return;
|
||||
}
|
||||
@@ -812,6 +826,7 @@ async function sweepSubagentRuns() {
|
||||
void notifyContextEngineSubagentEnded({
|
||||
childSessionKey: entry.childSessionKey,
|
||||
reason: "swept",
|
||||
agentDir: entry.agentDir,
|
||||
workspaceDir: entry.workspaceDir,
|
||||
});
|
||||
subagentRuns.delete(runId);
|
||||
@@ -851,6 +866,7 @@ async function sweepSubagentRuns() {
|
||||
void notifyContextEngineSubagentEnded({
|
||||
childSessionKey: entry.childSessionKey,
|
||||
reason: "swept",
|
||||
agentDir: entry.agentDir,
|
||||
workspaceDir: entry.workspaceDir,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export type SubagentRunRecord = {
|
||||
cleanup: "delete" | "keep";
|
||||
label?: string;
|
||||
model?: string;
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
runTimeoutSeconds?: number;
|
||||
spawnMode?: SpawnSubagentMode;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js";
|
||||
import { isValidAgentId, normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
|
||||
import { normalizeOptionalString } from "../shared/string-coerce.js";
|
||||
import type { DeliveryContext } from "../utils/delivery-context.types.js";
|
||||
import { resolveAgentDir } from "./agent-scope-config.js";
|
||||
import type { BootstrapContextMode } from "./bootstrap-files.js";
|
||||
import {
|
||||
mapToolContextToSpawnedRunMetadata,
|
||||
@@ -789,6 +790,7 @@ export async function spawnSubagentDirect(
|
||||
depth: childDepth,
|
||||
maxSpawnDepth,
|
||||
});
|
||||
const targetAgentDir = resolveAgentDir(cfg, targetAgentId);
|
||||
const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId);
|
||||
const plan = resolveSubagentModelAndThinkingPlan({
|
||||
cfg,
|
||||
@@ -1163,6 +1165,7 @@ export async function spawnSubagentDirect(
|
||||
cleanup,
|
||||
label: label || undefined,
|
||||
model: resolvedModel,
|
||||
agentDir: targetAgentDir,
|
||||
workspaceDir: spawnedMetadata.workspaceDir,
|
||||
runTimeoutSeconds,
|
||||
expectsCompletionMessage: shouldAnnounceCompletion,
|
||||
|
||||
@@ -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,
|
||||
@@ -356,7 +360,7 @@ describe("Engine contract tests", () => {
|
||||
const resolved = getContextEngineFactory("mock");
|
||||
expect(resolved).toBe(factory);
|
||||
|
||||
const engine = await resolved!();
|
||||
const engine = await resolved!({});
|
||||
expect(engine).toBeInstanceOf(MockContextEngine);
|
||||
expect(engine.info.id).toBe("mock");
|
||||
});
|
||||
@@ -693,6 +697,108 @@ 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",
|
||||
});
|
||||
|
||||
expect(receivedCtx).toBeDefined();
|
||||
expect(receivedCtx!.config).toBe(cfg);
|
||||
expect(receivedCtx!.agentDir).toBe("/tmp/agent");
|
||||
expect(receivedCtx!.workspaceDir).toBe("/tmp/workspace");
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
expect(called).toBe(true);
|
||||
expect(engine.info.id).toBe(engineId);
|
||||
});
|
||||
|
||||
it("passes undefined config when resolveContextEngine is called without config", async () => {
|
||||
let receivedCtx: ContextEngineFactoryContext | undefined;
|
||||
|
||||
// Override the default "legacy" engine to intercept the no-config path
|
||||
registerContextEngineForOwner(
|
||||
"legacy",
|
||||
(ctx: ContextEngineFactoryContext) => {
|
||||
receivedCtx = ctx;
|
||||
return {
|
||||
info: { id: "legacy", name: "NoConfig Engine", version: "1" },
|
||||
async ingest() {
|
||||
return { ingested: true };
|
||||
},
|
||||
async assemble({ messages }: { messages: AgentMessage[] }) {
|
||||
return { messages, estimatedTokens: 0 };
|
||||
},
|
||||
async compact() {
|
||||
return { ok: true, compacted: false };
|
||||
},
|
||||
};
|
||||
},
|
||||
"core",
|
||||
{ allowSameOwnerRefresh: true },
|
||||
);
|
||||
|
||||
await resolveContextEngine(undefined);
|
||||
|
||||
expect(receivedCtx).toBeDefined();
|
||||
expect(receivedCtx!.config).toBeUndefined();
|
||||
expect(receivedCtx!.agentDir).toBeUndefined();
|
||||
expect(receivedCtx!.workspaceDir).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;
|
||||
};
|
||||
|
||||
/**
|
||||
* A factory that creates a ContextEngine instance.
|
||||
* Supports async creation for engines that need DB connections etc.
|
||||
*
|
||||
* The factory receives a {@link ContextEngineFactoryContext} with runtime
|
||||
* environment context (config, paths). Existing no-arg factories remain
|
||||
* backward compatible because TypeScript permits assigning functions with
|
||||
* fewer parameters to wider signatures.
|
||||
*/
|
||||
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,14 @@ function describeResolvedContextEngineContractError(
|
||||
// Resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Options for {@link resolveContextEngine}.
|
||||
*/
|
||||
export type ResolveContextEngineOptions = {
|
||||
agentDir?: string;
|
||||
workspaceDir?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve which ContextEngine to use based on plugin slot configuration.
|
||||
*
|
||||
@@ -462,11 +488,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 can be
|
||||
* supplied via `options`. Existing no-arg factories continue to work
|
||||
* because JavaScript permits extra arguments at call sites.
|
||||
*
|
||||
* 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 +510,12 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise<Con
|
||||
const defaultEngineId = defaultSlotIdForKey("contextEngine");
|
||||
const isDefaultEngine = engineId === defaultEngineId;
|
||||
|
||||
const factoryCtx: ContextEngineFactoryContext = {
|
||||
config,
|
||||
agentDir: options?.agentDir,
|
||||
workspaceDir: options?.workspaceDir,
|
||||
};
|
||||
|
||||
const entry = getContextEngineRegistryState().engines.get(engineId);
|
||||
if (!entry) {
|
||||
if (isDefaultEngine) {
|
||||
@@ -488,12 +528,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 +543,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 +558,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 +568,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 +580,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 +591,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