From d8a600f2ad01dc8171a649c34136fb6491e099ed Mon Sep 17 00:00:00 2001 From: Jari Mustonen Date: Wed, 29 Apr 2026 04:21:14 +0300 Subject: [PATCH] context-engine: pass runtime context to ContextEngineFactory (#67243) Merged via squash. Prepared head SHA: 9aca6a5af109faf29bf51a6b153e159e2cd0f076 Co-authored-by: jarimustonen <1272053+jarimustonen@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman --- CHANGELOG.md | 1 + .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/concepts/context-engine.md | 6 +- docs/plugins/architecture-internals.md | 7 +- .../pi-embedded-runner/compact.queued.ts | 8 +- src/agents/pi-embedded-runner/run.ts | 5 +- src/agents/subagent-registry-lifecycle.ts | 3 + src/agents/subagent-registry-run-manager.ts | 4 + src/agents/subagent-registry.test.ts | 79 +++++++++++++ src/agents/subagent-registry.ts | 26 ++++- src/agents/subagent-registry.types.ts | 1 + src/agents/subagent-spawn.ts | 3 + src/context-engine/context-engine.test.ts | 110 +++++++++++++++++- src/context-engine/registry.ts | 61 ++++++++-- src/plugin-sdk/index.ts | 5 +- 15 files changed, 298 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e593308dca..71c4a30664f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 32aa2a41b67..6802c7ccf84 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/concepts/context-engine.md b/docs/concepts/context-engine.md index 74eb2512f62..393896e1a42 100644 --- a/docs/concepts/context-engine.md +++ b/docs/concepts/context-engine.md @@ -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 diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index f637d836e2f..303598ae0b0 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -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", diff --git a/src/agents/pi-embedded-runner/compact.queued.ts b/src/agents/pi-embedded-runner/compact.queued.ts index 79d84f6e04d..9ba6ba58c45 100644 --- a/src/agents/pi-embedded-runner/compact.queued.ts +++ b/src/agents/pi-embedded-runner/compact.queued.ts @@ -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; diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 03517631f94..221898a9151 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -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; diff --git a/src/agents/subagent-registry-lifecycle.ts b/src/agents/subagent-registry-lifecycle.ts index c4696dd4e57..781885b86ba 100644 --- a/src/agents/subagent-registry-lifecycle.ts +++ b/src/agents/subagent-registry-lifecycle.ts @@ -72,6 +72,7 @@ export function createSubagentRegistryLifecycleController(params: { notifyContextEngineSubagentEnded(args: { childSessionKey: string; reason: "completed" | "deleted"; + agentDir?: string; workspaceDir?: string; }): Promise; 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; diff --git a/src/agents/subagent-registry-run-manager.ts b/src/agents/subagent-registry-run-manager.ts index d6184a66178..514f6078f6e 100644 --- a/src/agents/subagent-registry-run-manager.ts +++ b/src/agents/subagent-registry-run-manager.ts @@ -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; 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, }); } diff --git a/src/agents/subagent-registry.test.ts b/src/agents/subagent-registry.test.ts index df6a44f34dd..5a263580717 100644 --- a/src/agents/subagent-registry.test.ts +++ b/src/agents/subagent-registry.test.ts @@ -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", + }), + ); + }); }); }); diff --git a/src/agents/subagent-registry.ts b/src/agents/subagent-registry.ts index d1fe9ae5bbd..564c7ac5169 100644 --- a/src/agents/subagent-registry.ts +++ b/src/agents/subagent-registry.ts @@ -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; + resolveContextEngine?: ( + cfg?: OpenClawConfig, + options?: ResolveContextEngineOptions, + ) => Promise; }; let subagentAnnouncePromise: Promise | null = null; @@ -140,7 +144,10 @@ type ContextEngineInitModule = Pick< >; type ContextEngineRegistryModule = Pick< { - resolveContextEngine: (cfg: OpenClawConfig) => Promise; + resolveContextEngine: ( + cfg?: OpenClawConfig, + options?: ResolveContextEngineOptions, + ) => Promise; }, "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, }); } diff --git a/src/agents/subagent-registry.types.ts b/src/agents/subagent-registry.types.ts index 8752ec812fe..19577062ec1 100644 --- a/src/agents/subagent-registry.types.ts +++ b/src/agents/subagent-registry.types.ts @@ -14,6 +14,7 @@ export type SubagentRunRecord = { cleanup: "delete" | "keep"; label?: string; model?: string; + agentDir?: string; workspaceDir?: string; runTimeoutSeconds?: number; spawnMode?: SpawnSubagentMode; diff --git a/src/agents/subagent-spawn.ts b/src/agents/subagent-spawn.ts index 06985b9fba1..5de497a9247 100644 --- a/src/agents/subagent-spawn.ts +++ b/src/agents/subagent-spawn.ts @@ -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, diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index fee4864f92a..0bfb890aed2 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -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 // ═══════════════════════════════════════════════════════════════════════════ diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index 4183b8a8a91..04eacf829a8 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -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; +export type ContextEngineFactory = ( + ctx: ContextEngineFactoryContext, +) => ContextEngine | Promise; 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 { +export async function resolveContextEngine( + config?: OpenClawConfig, + options?: ResolveContextEngineOptions, +): Promise { const slotValue = config?.plugins?.slots?.contextEngine; const engineId = typeof slotValue === "string" && slotValue.trim() @@ -476,6 +510,12 @@ export async function resolveContextEngine(config?: OpenClawConfig): Promise { +async function resolveDefaultContextEngine( + defaultEngineId: string, + factoryCtx: ContextEngineFactoryContext, +): Promise { const defaultEntry = getContextEngineRegistryState().engines.get(defaultEngineId); if (!defaultEntry) { throw new Error( @@ -548,7 +591,7 @@ async function resolveDefaultContextEngine(defaultEngineId: string): Promise