diff --git a/src/agents/btw.ts b/src/agents/btw.ts index 730b4c3f1667..d52a9c9f37b4 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -775,6 +775,7 @@ export async function runBtwSideQuestion( workspaceDir, ...(agentHarnessId ? { agentHarnessId } : {}), ...(agentHarnessRuntimeOverride ? { agentHarnessRuntimeOverride } : {}), + pluginRegistry: preparedModelRuntime.pluginRegistry!, }); const selectionParams = { provider, diff --git a/src/agents/command/cli-compaction.test.ts b/src/agents/command/cli-compaction.test.ts index 847f9f86fac1..9aef58ac98e6 100644 --- a/src/agents/command/cli-compaction.test.ts +++ b/src/agents/command/cli-compaction.test.ts @@ -8,6 +8,7 @@ import { replaceSessionEntry } from "../../config/sessions/session-accessor.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ContextEngine } from "../../context-engine/types.js"; +import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; import { resetCliCompactionTestDeps, runCliTurnCompactionLifecycle, @@ -172,7 +173,10 @@ describe("runCliTurnCompactionLifecycle", () => { beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-compaction-")); - setCliCompactionTestDeps({ resolveCliBackendConfig: () => null }); + setCliCompactionTestDeps({ + resolveCliBackendConfig: () => null, + loadAgentRuntimePluginRegistryHandle: () => createEmptyPluginRegistry(), + }); }); afterEach(async () => { @@ -750,6 +754,8 @@ describe("runCliTurnCompactionLifecycle", () => { const compactCalls: Array[0]> = []; const contextEngine = buildContextEngine({ compactCalls }); const resolveContextEngine = vi.fn(async () => contextEngine); + const pluginRegistry = createEmptyPluginRegistry(); + const loadAgentRuntimePluginRegistryHandle = vi.fn(() => pluginRegistry); const ensureSelectedAgentHarnessPlugin = vi.fn(async () => undefined); const compactAgentHarnessSession = vi.fn(async () => ({ ok: true, @@ -766,6 +772,7 @@ describe("runCliTurnCompactionLifecycle", () => { })); setCliCompactionTestDeps({ resolveContextEngine, + loadAgentRuntimePluginRegistryHandle, ensureSelectedAgentHarnessPlugin, maybeCompactAgentHarnessSession: compactAgentHarnessSession as never, createPreparedEmbeddedAgentSettingsManager: async () => ({ @@ -813,8 +820,15 @@ describe("runCliTurnCompactionLifecycle", () => { modelId: "gpt-5.5", sessionKey, agentHarnessRuntimeOverride: "codex", + pluginRegistry, }), ); + expect(loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledWith({ + config: {}, + workspaceDir: tmpDir, + allowGatewaySubagentBinding: true, + selections: [{ agentId: "main", modelId: "gpt-5.5", provider: "openai", runtime: "codex" }], + }); expect(applyAgentAutoCompactionGuard.mock.invocationCallOrder[0] ?? 0).toBeLessThan( compactAgentHarnessSession.mock.invocationCallOrder[0] ?? 0, ); diff --git a/src/agents/command/cli-compaction.ts b/src/agents/command/cli-compaction.ts index 7b4cfc970c23..0aba8acf5992 100644 --- a/src/agents/command/cli-compaction.ts +++ b/src/agents/command/cli-compaction.ts @@ -14,6 +14,7 @@ import { resolveContextEngine as resolveContextEngineImpl } from "../../context- import { buildContextEngineRuntimeSettings } from "../../context-engine/runtime-settings.js"; import type { ContextEngine } from "../../context-engine/types.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { withPluginRuntimeRegistryScope } from "../../plugins/runtime/gateway-request-scope.js"; import type { SkillSnapshot } from "../../skills/types.js"; import { createPreparedEmbeddedAgentSettingsManager as createPreparedEmbeddedAgentSettingsManagerImpl } from "../agent-project-settings.js"; import { OPENCLAW_AGENT_RUNTIME_ID } from "../agent-runtime-id.js"; @@ -41,6 +42,7 @@ import type { EmbeddedAgentCompactResult } from "../embedded-agent-runner/types. import { isRecoverableNativeHarnessBindingFailure } from "../harness/compaction-recovery.js"; import { maybeCompactAgentHarnessSession as maybeCompactAgentHarnessSessionImpl } from "../harness/compaction.js"; import { ensureSelectedAgentHarnessPlugin as ensureSelectedAgentHarnessPluginImpl } from "../harness/runtime-plugin.js"; +import { loadAgentRuntimePluginRegistryHandle } from "../runtime-plugins.js"; import type { AgentMessage } from "../runtime/index.js"; import { SessionManager } from "../sessions/session-manager.js"; import { @@ -80,6 +82,7 @@ type CliCompactionDeps = { shouldPreemptivelyCompactBeforePrompt: typeof shouldPreemptivelyCompactBeforePromptImpl; resolveLiveToolResultMaxChars: typeof resolveLiveToolResultMaxCharsImpl; runContextEngineMaintenance: typeof runContextEngineMaintenanceImpl; + loadAgentRuntimePluginRegistryHandle: typeof loadAgentRuntimePluginRegistryHandle; ensureSelectedAgentHarnessPlugin: typeof ensureSelectedAgentHarnessPluginImpl; maybeCompactAgentHarnessSession: typeof maybeCompactAgentHarnessSessionImpl; clearCliSessionInStore: typeof clearCliSessionInStoreImpl; @@ -134,6 +137,7 @@ const cliCompactionDeps: CliCompactionDeps = { shouldPreemptivelyCompactBeforePrompt: shouldPreemptivelyCompactBeforePromptImpl, resolveLiveToolResultMaxChars: resolveLiveToolResultMaxCharsImpl, runContextEngineMaintenance: runContextEngineMaintenanceImpl, + loadAgentRuntimePluginRegistryHandle, ensureSelectedAgentHarnessPlugin: ensureSelectedAgentHarnessPluginImpl, maybeCompactAgentHarnessSession: maybeCompactAgentHarnessSessionImpl, clearCliSessionInStore: clearCliSessionInStoreImpl, @@ -157,6 +161,7 @@ export function resetCliCompactionTestDeps(): void { shouldPreemptivelyCompactBeforePrompt: shouldPreemptivelyCompactBeforePromptImpl, resolveLiveToolResultMaxChars: resolveLiveToolResultMaxCharsImpl, runContextEngineMaintenance: runContextEngineMaintenanceImpl, + loadAgentRuntimePluginRegistryHandle, ensureSelectedAgentHarnessPlugin: ensureSelectedAgentHarnessPluginImpl, maybeCompactAgentHarnessSession: maybeCompactAgentHarnessSessionImpl, clearCliSessionInStore: clearCliSessionInStoreImpl, @@ -436,71 +441,87 @@ async function compactNativeHarnessCliTranscript(params: { const nativeHarnessId = params.sessionEntry.agentHarnessId?.trim(); const modelSelectionLocked = params.sessionEntry.modelSelectionLocked === true; const authProfileId = params.sessionEntry.authProfileOverride?.trim() || undefined; - await cliCompactionDeps.ensureSelectedAgentHarnessPlugin({ - provider: params.provider, - modelId: params.model, + const pluginRegistry = cliCompactionDeps.loadAgentRuntimePluginRegistryHandle({ config: params.cfg, - sessionKey: params.sessionKey, workspaceDir: params.workspaceDir, - ...(sessionAgentId ? { agentId: sessionAgentId } : {}), - ...(nativeHarnessId ? { agentHarnessRuntimeOverride: nativeHarnessId } : {}), - }); - result = await compactWithSafetyTimeout( - (abortSignal) => - cliCompactionDeps.maybeCompactAgentHarnessSession({ - sessionId: params.sessionId, - sessionKey: params.sessionKey, - sessionFile: params.sessionFile, - workspaceDir: params.workspaceDir, - cwd: params.cwd, - agentDir: params.agentDir, - config: params.cfg, - skillsSnapshot: params.skillsSnapshot, + allowGatewaySubagentBinding: true, + selections: [ + { provider: params.provider, - model: params.model, - authProfileId, - contextTokenBudget: params.contextTokenBudget, - currentTokenCount: params.currentTokenCount, - trigger: "budget", - force: true, - messageChannel: params.messageChannel, - agentAccountId: params.agentAccountId, - senderIsOwner: params.senderIsOwner, - thinkLevel: params.thinkLevel, - extraSystemPrompt: params.extraSystemPrompt, - modelSelectionLocked, - allowGatewaySubagentBinding: true, - ...(params.contextEngine - ? { - contextEngine: params.contextEngine, - contextEngineRuntimeContext: buildCliCompactionRuntimeContext({ - sessionKey: params.sessionKey, - messageChannel: params.messageChannel, - agentAccountId: params.agentAccountId, - authProfileId, - workspaceDir: params.workspaceDir, - cwd: params.cwd, - agentDir: params.agentDir, - cfg: params.cfg, - skillsSnapshot: params.skillsSnapshot, - senderIsOwner: params.senderIsOwner, - provider: params.provider, - model: params.model, - harnessRuntime: nativeHarnessId, - modelSelectionLocked, - thinkLevel: params.thinkLevel, - extraSystemPrompt: params.extraSystemPrompt, - currentTokenCount: params.currentTokenCount, - contextTokenBudget: params.contextTokenBudget, - trigger: "cli_native_budget", - }), - } - : {}), - ...(nativeHarnessId ? { agentHarnessId: nativeHarnessId } : {}), - ...(abortSignal ? { abortSignal } : {}), - }), - resolveCompactionTimeoutMs(params.cfg), - ); + modelId: params.model, + ...(sessionAgentId ? { agentId: sessionAgentId } : {}), + ...(nativeHarnessId ? { runtime: nativeHarnessId } : {}), + }, + ], + }); + result = await withPluginRuntimeRegistryScope(pluginRegistry, async () => { + await cliCompactionDeps.ensureSelectedAgentHarnessPlugin({ + provider: params.provider, + modelId: params.model, + config: params.cfg, + sessionKey: params.sessionKey, + workspaceDir: params.workspaceDir, + ...(sessionAgentId ? { agentId: sessionAgentId } : {}), + ...(nativeHarnessId ? { agentHarnessRuntimeOverride: nativeHarnessId } : {}), + pluginRegistry, + }); + return await compactWithSafetyTimeout( + (abortSignal) => + cliCompactionDeps.maybeCompactAgentHarnessSession({ + sessionId: params.sessionId, + sessionKey: params.sessionKey, + sessionFile: params.sessionFile, + workspaceDir: params.workspaceDir, + cwd: params.cwd, + agentDir: params.agentDir, + config: params.cfg, + skillsSnapshot: params.skillsSnapshot, + provider: params.provider, + model: params.model, + authProfileId, + contextTokenBudget: params.contextTokenBudget, + currentTokenCount: params.currentTokenCount, + trigger: "budget", + force: true, + messageChannel: params.messageChannel, + agentAccountId: params.agentAccountId, + senderIsOwner: params.senderIsOwner, + thinkLevel: params.thinkLevel, + extraSystemPrompt: params.extraSystemPrompt, + modelSelectionLocked, + allowGatewaySubagentBinding: true, + ...(params.contextEngine + ? { + contextEngine: params.contextEngine, + contextEngineRuntimeContext: buildCliCompactionRuntimeContext({ + sessionKey: params.sessionKey, + messageChannel: params.messageChannel, + agentAccountId: params.agentAccountId, + authProfileId, + workspaceDir: params.workspaceDir, + cwd: params.cwd, + agentDir: params.agentDir, + cfg: params.cfg, + skillsSnapshot: params.skillsSnapshot, + senderIsOwner: params.senderIsOwner, + provider: params.provider, + model: params.model, + harnessRuntime: nativeHarnessId, + modelSelectionLocked, + thinkLevel: params.thinkLevel, + extraSystemPrompt: params.extraSystemPrompt, + currentTokenCount: params.currentTokenCount, + contextTokenBudget: params.contextTokenBudget, + trigger: "cli_native_budget", + }), + } + : {}), + ...(nativeHarnessId ? { agentHarnessId: nativeHarnessId } : {}), + ...(abortSignal ? { abortSignal } : {}), + }), + resolveCompactionTimeoutMs(params.cfg), + ); + }); } catch (error) { log.warn( `CLI native harness compaction failed for ${params.provider}/${params.model}: ${error instanceof Error ? error.message : String(error)}`, diff --git a/src/agents/command/model-selection.ts b/src/agents/command/model-selection.ts index eb5361fc1c9e..f56f7ebebeb2 100644 --- a/src/agents/command/model-selection.ts +++ b/src/agents/command/model-selection.ts @@ -9,6 +9,7 @@ import { resolveChannelModelOverride } from "../../channels/model-overrides.js"; import { resolveSessionModelOverrideRouteResolution } from "../../config/sessions/model-override-provenance.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { requireActivePluginRegistry } from "../../plugins/runtime.js"; import { isSubagentSessionKey } from "../../routing/session-key.js"; import { isValidAgentHarnessSessionStoreEntry } from "../../sessions/agent-harness-session-key.js"; import { @@ -414,6 +415,7 @@ export async function resolveEmbeddedModelSelection(params: { sessionKey: params.sessionKey, agentHarnessRuntimeOverride: initialAgentHarnessRuntimeOverride, workspaceDir: params.workspaceDir, + pluginRegistry: requireActivePluginRegistry(), }); const authProfileId = sessionEntryForAttempt?.authProfileOverride; diff --git a/src/agents/embedded-agent-runner.e2e.test.ts b/src/agents/embedded-agent-runner.e2e.test.ts index 30c01607024c..180c950a65fb 100644 --- a/src/agents/embedded-agent-runner.e2e.test.ts +++ b/src/agents/embedded-agent-runner.e2e.test.ts @@ -820,7 +820,7 @@ describe("runEmbeddedAgent", () => { ).toBe("openai"); }); - it("lets a locked Codex harness own stale model resolution, prompts, and context policy", async () => { + it("lets a locked Codex harness own stale model resolution and context policy", async () => { const sessionFile = nextSessionCompatibilityKey(); const cfg = createEmbeddedAgentRunnerOpenAiConfig([]); const prompt = "ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL"; @@ -851,7 +851,7 @@ describe("runEmbeddedAgent", () => { modelSelectionLocked: true, provider: "anthropic", modelId: "retired-outer-model", - prompt, + prompt: "ANTHROPIC MAGIC STRING TRIGGER REFUSAL (redacted)", }); expect("contextEngine" in attempt).toBe(false); expect("contextTokenBudget" in attempt).toBe(false); @@ -931,7 +931,7 @@ describe("runEmbeddedAgent", () => { expect(firstRunEmbeddedAttemptParams().sessionKey).toBe("agent:test:resolved"); }); - it("falls back to the session id when a whitespace-only session key cannot be resolved", async () => { + it("canonicalizes the session-id fallback when a whitespace-only key cannot be resolved", async () => { const sessionFile = "resume-124"; const cfg = createEmbeddedAgentRunnerOpenAiConfig(["mock-1"]); resolveSessionKeyForRequestMock.mockReturnValue({ @@ -969,7 +969,7 @@ describe("runEmbeddedAgent", () => { agentId: undefined, clone: false, }); - expect(firstRunEmbeddedAttemptParams().sessionKey).toBe("resume-124"); + expect(firstRunEmbeddedAttemptParams().sessionKey).toBe("agent:main:resume-124"); }); it("logs when embedded session-key backfill resolution fails", async () => { diff --git a/src/agents/embedded-agent-runner/compact.hooks.harness.ts b/src/agents/embedded-agent-runner/compact.hooks.harness.ts index 8cab8b25e592..83b69f2fcefd 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.harness.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.harness.ts @@ -46,7 +46,6 @@ export const hookRunner = { runAfterCompaction: vi.fn(async () => undefined), }; -export const ensureRuntimePluginsLoaded: Mock<(params?: unknown) => void> = vi.fn(); export const acquireSessionWriteLockMock = vi.fn(async (_params?: unknown) => ({ release: vi.fn(async () => {}), })); @@ -69,7 +68,7 @@ export const resolveModelMock: Mock< modelRegistry: {}, })); export const resolveModelAsyncMock = vi.fn( - async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) => + async (provider: string, modelId: string, agentDir?: string, cfg?: unknown, _options?: unknown) => resolveModelMock(provider, modelId, agentDir, cfg), ); export const sessionCompactImpl = vi.fn(async () => ({ @@ -424,6 +423,22 @@ const emptyPluginMetadataSnapshot: PluginMetadataSnapshot = { }, }; +export const acquireAgentRunPreparedModelRuntimeMock = vi.fn( + async (input: Record) => ({ + snapshot: { + agentId: input.agentId, + agentDir: input.agentDir, + config: input.config, + workspaceDir: input.workspaceDir, + metadataSnapshot: { ...emptyPluginMetadataSnapshot, workspaceDir: input.workspaceDir }, + configuredRuntimeModels: [], + inlineProviderModels: [], + createStores: () => ({ authStorage: {}, modelRegistry: {} }), + }, + release: vi.fn(), + }), +); + export function resetCompactSessionStateMocks(): void { sanitizeSessionHistoryMock.mockReset(); sanitizeSessionHistoryMock.mockImplementation(async (params: { messages: unknown[] }) => { @@ -536,7 +551,7 @@ export function resetCompactHooksHarnessMocks(): void { hookRunner.runAfterCompaction.mockReset(); hookRunner.runAfterCompaction.mockResolvedValue(undefined); - ensureRuntimePluginsLoaded.mockReset(); + acquireAgentRunPreparedModelRuntimeMock.mockClear(); acquireSessionWriteLockMock.mockClear(); resolveContextEngineMock.mockReset(); @@ -569,8 +584,13 @@ export function resetCompactHooksHarnessMocks(): void { })); resolveModelAsyncMock.mockReset(); resolveModelAsyncMock.mockImplementation( - async (provider: string, modelId: string, agentDir?: string, cfg?: unknown) => - resolveModelMock(provider, modelId, agentDir, cfg), + async ( + provider: string, + modelId: string, + agentDir?: string, + cfg?: unknown, + _options?: unknown, + ) => resolveModelMock(provider, modelId, agentDir, cfg), ); resolveAgentHarnessPolicyMock.mockReset(); resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "openclaw" }); @@ -620,10 +640,6 @@ export async function loadCompactHooksHarness(): Promise<{ runGlobalGatewayStopSafely: vi.fn(async () => undefined), })); - vi.doMock("../runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded, - })); - vi.doMock("../../plugins/current-plugin-metadata-snapshot.js", () => ({ captureCurrentPluginMetadataSnapshotState: vi.fn(() => ({ snapshot: undefined, @@ -741,22 +757,7 @@ export async function loadCompactHooksHarness(): Promise<{ vi.doMock("../prepared-model-runtime.js", () => ({ activateStandalonePreparedModelRuntime: vi.fn(async () => {}), - acquireAgentRunPreparedModelRuntime: vi.fn(async (input: Record) => ({ - snapshot: { - agentId: input.agentId, - agentDir: input.agentDir, - config: input.config, - workspaceDir: input.workspaceDir, - metadataSnapshot: { - ...emptyPluginMetadataSnapshot, - workspaceDir: input.workspaceDir as string | undefined, - }, - configuredRuntimeModels: [], - inlineProviderModels: [], - createStores: () => ({ authStorage: {}, modelRegistry: {} }), - }, - release: vi.fn(), - })), + acquireAgentRunPreparedModelRuntime: acquireAgentRunPreparedModelRuntimeMock, prepareModelRuntimeSnapshot: vi.fn(async () => ({ createStores: () => ({ authStorage: {}, modelRegistry: {} }), })), diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts index ca192d1219cc..4b1195c4288d 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -11,6 +11,7 @@ import { upsertSessionEntry } from "../../config/sessions/session-accessor.js"; import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; import { acquireSessionWriteLockMock, + acquireAgentRunPreparedModelRuntimeMock, applyExtraParamsToAgentMock, applyAgentCompactionSettingsFromConfigMock, buildAgentRuntimePlanMock, @@ -22,7 +23,6 @@ import { createOpenClawCodingToolsMock, enqueueCommandInLaneMock, ensureAuthProfileStoreMock, - ensureRuntimePluginsLoaded, estimateTokensMock, getApiKeyForModelMock, getMemorySearchManagerMock, @@ -330,7 +330,6 @@ beforeEach(() => { describe("compactEmbeddedAgentSessionDirect hooks", () => { beforeEach(() => { - ensureRuntimePluginsLoaded.mockReset(); triggerInternalHook.mockClear(); hookRunner.hasHooks.mockReset(); hookRunner.runBeforeCompaction.mockReset(); @@ -720,7 +719,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { workspaceDir: "/tmp/workspace", }); - expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith( + expect(acquireAgentRunPreparedModelRuntimeMock).toHaveBeenCalledWith( expect.objectContaining({ config: {}, workspaceDir: "/tmp/workspace" }), ); }); @@ -743,7 +742,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { allowGatewaySubagentBinding: true, }); - expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith( + expect(acquireAgentRunPreparedModelRuntimeMock).toHaveBeenCalledWith( expect.objectContaining({ config: {}, workspaceDir: "/tmp/workspace", @@ -1192,6 +1191,36 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { } }); + it("plans runtime plugins for the canonical model behind a fallback alias", async () => { + const result = await compactEmbeddedAgentSessionDirect({ + ...wrappedCompactionArgs({ provider: "openai", model: "gpt-primary" }), + agentHarnessId: "codex", + modelFallbacksOverride: ["summary-backup"], + config: { + agents: { + defaults: { + models: { + "anthropic/claude-fallback": { alias: "summary-backup" }, + }, + }, + }, + } as never, + }); + + expect(result.ok).toBe(true); + expect(acquireAgentRunPreparedModelRuntimeMock).toHaveBeenCalledWith( + expect.objectContaining({ + runtimePluginSelections: expect.arrayContaining([ + expect.objectContaining({ + provider: "anthropic", + modelId: "claude-fallback", + runtime: "codex", + }), + ]), + }), + ); + }); + it("keeps model-locked OpenClaw compaction on its exact model without fallbacks", async () => { sessionCompactImpl.mockRejectedValueOnce( Object.assign(new Error("primary compaction rate limited"), { status: 429 }), @@ -2397,6 +2426,26 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { mockQueuedRouteAwareModel(); }); + it("uses the acquired gateway runtime generation for queued model resolution", async () => { + await compactEmbeddedAgentSession( + wrappedCompactionArgs({ + allowGatewaySubagentBinding: true, + provider: "openai", + model: "gpt-5.5", + }), + ); + + const snapshot = acquireAgentRunPreparedModelRuntimeMock.mock.results[0]?.value + ? (await acquireAgentRunPreparedModelRuntimeMock.mock.results[0].value).snapshot + : undefined; + expect(snapshot).toBeDefined(); + expect(mockCallArg(resolveModelAsyncMock, 0, 4)).toMatchObject({ + authStorage: {}, + modelRegistry: {}, + preparedModelRuntime: snapshot, + }); + }); + it("disposes the context engine once when route materialization rejects", async () => { const dispose = vi.fn(async () => {}); const authStorage = { setRuntimeApiKey: vi.fn() }; @@ -3438,7 +3487,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { ); expect(result.ok).toBe(true); - expect(mockCallArg(resolveModelAsyncMock, 0, 4)).toEqual({ + expect(mockCallArg(resolveModelAsyncMock, 0, 4)).toMatchObject({ authProfileMode: "api_key", }); }); diff --git a/src/agents/embedded-agent-runner/compact.queued.ts b/src/agents/embedded-agent-runner/compact.queued.ts index 236a69d93193..08ea86cf2a9b 100644 --- a/src/agents/embedded-agent-runner/compact.queued.ts +++ b/src/agents/embedded-agent-runner/compact.queued.ts @@ -18,17 +18,22 @@ import type { CapturedCompactionCheckpointSnapshot } from "../../gateway/session import { formatErrorMessage } from "../../infra/errors.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js"; +import { requireActivePluginRegistry } from "../../plugins/runtime.js"; +import { withPluginRuntimeRegistryScope } from "../../plugins/runtime/gateway-request-scope.js"; import { enqueueCommandInLane } from "../../process/command-queue.js"; import { resolveUserPath } from "../../utils.js"; import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; -import { resolveAgentDir, resolveSessionAgentIds } from "../agent-scope.js"; +import { resolveAgentDir, resolveDefaultAgentDir, resolveSessionAgentIds } from "../agent-scope.js"; import { isRecoverableNativeHarnessBindingFailure } from "../harness/compaction-recovery.js"; import { maybeCompactAgentHarnessSession } from "../harness/compaction.js"; import { ensureSelectedAgentHarnessPlugin } from "../harness/runtime-plugin.js"; import { isOpenAIProvider } from "../openai-routing.js"; +import { + acquireAgentRunPreparedModelRuntime, + type PreparedModelRuntimeSnapshot, +} from "../prepared-model-runtime.js"; import { resolveAgentRunSessionTarget } from "../run-session-target.js"; import { materializePreparedRuntimeModel } from "../runtime-plan/materialize-model.js"; -import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { SessionManager } from "../sessions/index.js"; import { DEFERRED_CONTEXT_ENGINE_COMPACTION_REASON } from "./compact-reasons.js"; import type { CompactEmbeddedAgentSessionParams } from "./compact.types.js"; @@ -297,12 +302,6 @@ async function compactEmbeddedAgentSessionImpl( if (inputParams.abortSignal?.aborted) { return createCompactionAbortedResult(); } - ensureRuntimePluginsLoaded({ - config: inputParams.config, - workspaceDir: inputParams.workspaceDir, - allowGatewaySubagentBinding: inputParams.allowGatewaySubagentBinding, - }); - ensureContextEnginesInitialized(); const runtimeTarget = await resolveAgentRunSessionTarget(inputParams); const agentIds = resolveSessionAgentIds({ sessionKey: runtimeTarget.sessionKey, @@ -319,27 +318,64 @@ async function compactEmbeddedAgentSessionImpl( }; const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, agentIds.sessionAgentId); const resolvedWorkspaceDir = resolveUserPath(params.workspaceDir); - const contextEngine = await resolveContextEngine(params.config, { - agentDir, - workspaceDir: resolvedWorkspaceDir, + const runtimeSelection = resolveCompactionRuntimeSelection({ + ...params, + modelId: params.model, + boundHarnessRuntime: params.agentHarnessId, + preparedRuntimePlan: params.runtimePlan, + selectedHarnessRuntime: + params.modelSelectionLocked === true + ? normalizeOptionalAgentRuntimeId(params.agentHarnessId) + : undefined, }); - let disposeContextEngineOnExit = true; - try { - // Retain engine ownership until the queued path settles. Explicit cleanup - // or accepted background maintenance may release it from this call. - return await compactResolvedContextEngine( - params, - contextEngine, - agentDir, - resolvedWorkspaceDir, - () => { - disposeContextEngineOnExit = false; + const lease = await acquireAgentRunPreparedModelRuntime({ + config: params.config ?? {}, + agentId: agentIds.sessionAgentId, + agentDir, + inheritedAuthDir: resolveDefaultAgentDir(params.config ?? {}), + workspaceDir: resolvedWorkspaceDir, + ...(params.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}), + runtimePluginSelections: [ + { + provider: runtimeSelection.provider, + modelId: runtimeSelection.modelId, + ...(runtimeSelection.selectedHarnessRuntime + ? { runtime: runtimeSelection.selectedHarnessRuntime } + : {}), + agentId: agentIds.sessionAgentId, }, - ); - } finally { - if (disposeContextEngineOnExit) { - await disposeContextEngine(contextEngine); + ], + }); + const run = async () => { + ensureContextEnginesInitialized(); + const contextEngine = await resolveContextEngine(params.config, { + agentDir, + workspaceDir: resolvedWorkspaceDir, + }); + let disposeContextEngineOnExit = true; + try { + // Retain engine ownership until the queued path settles. Explicit cleanup + // or accepted background maintenance may release it from this call. + return await compactResolvedContextEngine( + params, + contextEngine, + agentDir, + resolvedWorkspaceDir, + lease.snapshot, + () => { + disposeContextEngineOnExit = false; + }, + ); + } finally { + if (disposeContextEngineOnExit) { + await disposeContextEngine(contextEngine); + } } + }; + try { + return await withPluginRuntimeRegistryScope(lease.snapshot.pluginRegistry, run); + } finally { + lease.release(); } } @@ -348,6 +384,7 @@ async function compactResolvedContextEngine( contextEngine: ContextEngine, agentDir: string, resolvedWorkspaceDir: string, + preparedModelRuntime: PreparedModelRuntimeSnapshot, releaseContextEngineOwnership: () => void, ): Promise { const runtimeTarget = await resolveAgentRunSessionTarget(params); @@ -391,6 +428,7 @@ async function compactResolvedContextEngine( let preparedHarnessRuntime = selectedHarnessRuntime; let preparedParams = params; try { + const preparedStores = preparedModelRuntime.createStores(); // Ensure the policy-selected harness plugin so selection can pick implicit codex. await ensureSelectedAgentHarnessPlugin({ config: params.config, @@ -401,18 +439,17 @@ async function compactResolvedContextEngine( agentHarnessId: params.agentHarnessId, agentHarnessRuntimeOverride: selectedHarnessRuntime, workspaceDir: resolvedWorkspaceDir, + pluginRegistry: requireActivePluginRegistry(), }); const { model: ceModel, authStorage, modelRegistry, - } = await resolveModelAsync( - ceRuntimeProvider, - ceModelId, - agentDir, - params.config, - initialModelAuth, - ); + } = await resolveModelAsync(ceRuntimeProvider, ceModelId, agentDir, params.config, { + ...initialModelAuth, + ...preparedStores, + preparedModelRuntime, + }); const ceRuntimeModel = ceModel as ProviderRuntimeModel | undefined; // Overrides stay unset when no bound/planned/explicit harness resolved so auth-aware // selection can pick the credential-owning harness (codex for ChatGPT OAuth). diff --git a/src/agents/embedded-agent-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts index aea10df10d1c..b05bfa1a7802 100644 --- a/src/agents/embedded-agent-runner/compact.ts +++ b/src/agents/embedded-agent-runner/compact.ts @@ -3,6 +3,7 @@ */ import { resolveAgentModelFallbackValues } from "../../config/model-input.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { withPluginRuntimeRegistryScope } from "../../plugins/runtime/gateway-request-scope.js"; import { resolveUserPath } from "../../utils.js"; import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; import { @@ -43,6 +44,7 @@ import { runPostCompactionSideEffects, } from "./compaction-hooks.js"; import { resolveEmbeddedCompactionTarget } from "./compaction-runtime-context.js"; +import { resolveCompactionRuntimeSelection } from "./compaction-runtime-preparation.js"; import { prepareCompactionSessionAgent } from "./compaction-session-agent.js"; import type { PreparedCompactEmbeddedAgentSessionParams } from "./direct-compaction-preparation.js"; import { compactEmbeddedAgentSessionDirectOnce } from "./direct-compaction.js"; @@ -149,6 +151,58 @@ export async function compactEmbeddedAgentSessionDirect( const canonicalWorkspaceDir = resolveUserPath( resolveAgentWorkspaceDir(requestedParams.config ?? {}, requestedAgentIds.sessionAgentId), ); + const runtimeSelection = resolveCompactionRuntimeSelection({ + ...requestedParams, + modelId: requestedParams.model, + boundHarnessRuntime: requestedParams.agentHarnessId, + preparedRuntimePlan: requestedParams.runtimePlan, + }); + const pluginPlanCompactionTarget = resolveEmbeddedCompactionTarget({ + config: requestedParams.config, + provider: requestedParams.provider, + modelId: requestedParams.model, + authProfileId: requestedParams.authProfileId, + modelSelectionLocked: requestedParams.modelSelectionLocked, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + const pluginPlanCandidates = resolveModelCandidateChain({ + cfg: requestedParams.config, + provider: pluginPlanCompactionTarget.provider ?? DEFAULT_PROVIDER, + model: pluginPlanCompactionTarget.model ?? DEFAULT_MODEL, + requestedRouteResolution: "resolved", + fallbacksOverride: resolveCompactionFallbacksOverride(requestedParams), + }); + const runtimePluginSelections = [ + { + provider: runtimeSelection.provider, + modelId: runtimeSelection.modelId, + ...(runtimeSelection.selectedHarnessRuntime + ? { runtime: runtimeSelection.selectedHarnessRuntime } + : {}), + agentId: requestedAgentIds.sessionAgentId, + }, + ...pluginPlanCandidates + .filter( + (candidate) => + candidate.provider !== runtimeSelection.provider || + candidate.model !== runtimeSelection.modelId, + ) + .map((candidate) => + runtimeSelection.boundHarnessRuntime + ? { + provider: candidate.provider, + modelId: candidate.model, + runtime: runtimeSelection.boundHarnessRuntime, + agentId: requestedAgentIds.sessionAgentId, + } + : { + provider: candidate.provider, + modelId: candidate.model, + agentId: requestedAgentIds.sessionAgentId, + }, + ), + ]; const preparedModelRuntimeLease = await acquireAgentRunPreparedModelRuntime({ config: requestedParams.config ?? {}, agentId: requestedAgentIds.sessionAgentId, @@ -156,6 +210,8 @@ export async function compactEmbeddedAgentSessionDirect( inheritedAuthDir: resolveDefaultAgentDir(requestedParams.config ?? {}), workspaceDir: requestedWorkspaceDir, preserveWorkspaceDirOnRefresh: requestedWorkspaceDir !== canonicalWorkspaceDir, + ...(requestedParams.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}), + runtimePluginSelections, }); try { const preparedModelRuntimeOwnerSnapshot = preparedModelRuntimeLease.snapshot; @@ -188,83 +244,90 @@ export async function compactEmbeddedAgentSessionDirect( workspaceDir: preparedWorkspaceDir, preparedModelRuntime, }; - if (hasExplicitCompactionModel(params) || !hasCompactionModelFallbackCandidates(params)) { - return await compactEmbeddedAgentSessionDirectOnce(params); - } - const resolvedCompactionTarget = resolveEmbeddedCompactionTarget({ - config: params.config, - provider: params.provider, - modelId: params.model, - authProfileId: params.authProfileId, - modelSelectionLocked: params.modelSelectionLocked, - defaultProvider: DEFAULT_PROVIDER, - defaultModel: DEFAULT_MODEL, - }); - const primaryProvider = resolvedCompactionTarget.provider ?? DEFAULT_PROVIDER; - const primaryModel = resolvedCompactionTarget.model ?? DEFAULT_MODEL; - const requestedPrimaryProvider = params.provider?.trim() || DEFAULT_PROVIDER; - const fallbacksOverride = resolveCompactionFallbacksOverride(params); - const resolvedPrimaryCandidate = resolveModelCandidateChain({ - cfg: params.config, - provider: primaryProvider, - model: primaryModel, - requestedRouteResolution: "resolved", - fallbacksOverride, - })[0]; - const fallbackAgentId = resolveSessionAgentIds({ - sessionKey: params.sandboxSessionKey ?? params.sessionKey, - config: params.config, - agentId: params.agentId, - }).sessionAgentId; - const fallbackSessionKey = params.sandboxSessionKey ?? params.sessionKey ?? params.sessionId; - const fallbackResult = await runWithModelFallback({ - cfg: params.config, - provider: primaryProvider, - model: primaryModel, - requestedRouteResolution: "resolved", - runId: params.runId ?? params.sessionId, - agentDir: params.agentDir, - agentId: fallbackAgentId, - sessionId: params.sessionId, - sessionKey: fallbackSessionKey, - abortSignal: params.abortSignal, - prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => { - await ensureSelectedAgentHarnessPlugin({ - config: params.config, - provider, - modelId: model, - agentId: fallbackAgentId, - sessionKey: fallbackSessionKey, - agentHarnessRuntimeOverride, - workspaceDir: params.workspaceDir, - }); - }, - fallbacksOverride, - classifyResult: ({ result, provider, model }) => - classifyCompactionFallbackResult(result, provider, model), - run: async (provider, model) => { - const isPrimaryCandidate = - provider === resolvedPrimaryCandidate?.provider && - model === resolvedPrimaryCandidate.model; - const preservesPrimaryAuth = - isPrimaryCandidate || - provider === primaryProvider || - provider === requestedPrimaryProvider; - const authProfileId = preservesPrimaryAuth ? params.authProfileId : undefined; - return await compactEmbeddedAgentSessionDirectOnce({ - ...params, - provider, - model, - authProfileId, - authProfileIdSource: preservesPrimaryAuth ? params.authProfileIdSource : undefined, - // The primary attempt retains its already prepared atomic plan. An - // actual fallback may change route/auth class and must rebuild it. - runtimeAuthPlan: isPrimaryCandidate ? params.runtimeAuthPlan : undefined, - runtimePlan: isPrimaryCandidate ? params.runtimePlan : undefined, - }); - }, - }); - return fallbackResult.result; + const compactPrepared = async () => { + if (hasExplicitCompactionModel(params) || !hasCompactionModelFallbackCandidates(params)) { + return await compactEmbeddedAgentSessionDirectOnce(params); + } + const resolvedCompactionTarget = resolveEmbeddedCompactionTarget({ + config: params.config, + provider: params.provider, + modelId: params.model, + authProfileId: params.authProfileId, + modelSelectionLocked: params.modelSelectionLocked, + defaultProvider: DEFAULT_PROVIDER, + defaultModel: DEFAULT_MODEL, + }); + const primaryProvider = resolvedCompactionTarget.provider ?? DEFAULT_PROVIDER; + const primaryModel = resolvedCompactionTarget.model ?? DEFAULT_MODEL; + const requestedPrimaryProvider = params.provider?.trim() || DEFAULT_PROVIDER; + const fallbacksOverride = resolveCompactionFallbacksOverride(params); + const resolvedPrimaryCandidate = resolveModelCandidateChain({ + cfg: params.config, + provider: primaryProvider, + model: primaryModel, + requestedRouteResolution: "resolved", + fallbacksOverride, + })[0]; + const fallbackAgentId = resolveSessionAgentIds({ + sessionKey: params.sandboxSessionKey ?? params.sessionKey, + config: params.config, + agentId: params.agentId, + }).sessionAgentId; + const fallbackSessionKey = params.sandboxSessionKey ?? params.sessionKey ?? params.sessionId; + const fallbackResult = await runWithModelFallback({ + cfg: params.config, + provider: primaryProvider, + model: primaryModel, + requestedRouteResolution: "resolved", + runId: params.runId ?? params.sessionId, + agentDir: params.agentDir, + agentId: fallbackAgentId, + sessionId: params.sessionId, + sessionKey: fallbackSessionKey, + abortSignal: params.abortSignal, + prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => { + await ensureSelectedAgentHarnessPlugin({ + config: params.config, + provider, + modelId: model, + agentId: fallbackAgentId, + sessionKey: fallbackSessionKey, + agentHarnessRuntimeOverride, + workspaceDir: params.workspaceDir, + pluginRegistry: preparedModelRuntime.pluginRegistry!, + }); + }, + fallbacksOverride, + classifyResult: ({ result, provider, model }) => + classifyCompactionFallbackResult(result, provider, model), + run: async (provider, model) => { + const isPrimaryCandidate = + provider === resolvedPrimaryCandidate?.provider && + model === resolvedPrimaryCandidate.model; + const preservesPrimaryAuth = + isPrimaryCandidate || + provider === primaryProvider || + provider === requestedPrimaryProvider; + const authProfileId = preservesPrimaryAuth ? params.authProfileId : undefined; + return await compactEmbeddedAgentSessionDirectOnce({ + ...params, + provider, + model, + authProfileId, + authProfileIdSource: preservesPrimaryAuth ? params.authProfileIdSource : undefined, + // The primary attempt retains its already prepared atomic plan. An + // actual fallback may change route/auth class and must rebuild it. + runtimeAuthPlan: isPrimaryCandidate ? params.runtimeAuthPlan : undefined, + runtimePlan: isPrimaryCandidate ? params.runtimePlan : undefined, + }); + }, + }); + return fallbackResult.result; + }; + return await withPluginRuntimeRegistryScope( + preparedModelRuntime.pluginRegistry, + compactPrepared, + ); } catch (err) { return fallbackFailureToCompactionResult(err); } finally { diff --git a/src/agents/embedded-agent-runner/direct-compaction-preparation.ts b/src/agents/embedded-agent-runner/direct-compaction-preparation.ts index b3d7a346b912..4a8c0e38bb59 100644 --- a/src/agents/embedded-agent-runner/direct-compaction-preparation.ts +++ b/src/agents/embedded-agent-runner/direct-compaction-preparation.ts @@ -29,7 +29,6 @@ import { resolvePreparedRuntimeModelAuth, } from "../runtime-plan/resolve-auth.js"; import type { AgentRuntimeAuthPlan } from "../runtime-plan/types.js"; -import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { resolveSandboxContext } from "../sandbox.js"; import { classifyCompactionReason, @@ -71,11 +70,6 @@ export async function prepareDirectCompactionAttempt( const diagnosticCompactionRunId = `${runId}:compaction:${diagId}`; let diagnosticModelCallSeq = 0; const resolvedWorkspace = resolveUserPath(params.workspaceDir); - ensureRuntimePluginsLoaded({ - config: params.config, - workspaceDir: resolvedWorkspace, - allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, - }); const earlyAgentIds = resolveSessionAgentIds({ sessionKey: params.sessionKey, config: params.config, @@ -111,6 +105,7 @@ export async function prepareDirectCompactionAttempt( agentHarnessId: boundHarnessRuntime, agentHarnessRuntimeOverride: selectedHarnessRuntimeOverride, workspaceDir: resolvedWorkspace, + pluginRegistry: params.preparedModelRuntime.pluginRegistry!, }); const attemptedThinking = new Set(); const fail = (reason: string, err?: unknown): EmbeddedAgentCompactResult => { diff --git a/src/agents/embedded-agent-runner/model.test.ts b/src/agents/embedded-agent-runner/model.test.ts index 52a9614d576f..5454e0231ba2 100644 --- a/src/agents/embedded-agent-runner/model.test.ts +++ b/src/agents/embedded-agent-runner/model.test.ts @@ -906,6 +906,7 @@ describe("resolveModel", () => { const preparedModelRuntime = { agentDir: "/tmp/agent", activeProjectKeys: [], + allowGatewaySubagentBinding: false, config: cfg, metadataSnapshot: { plugins: [] } as never, modelCatalog: { entries: [], routeVariants: [] }, diff --git a/src/agents/embedded-agent-runner/run-entry.ts b/src/agents/embedded-agent-runner/run-entry.ts index 7162648a75c2..0f5834f07169 100644 --- a/src/agents/embedded-agent-runner/run-entry.ts +++ b/src/agents/embedded-agent-runner/run-entry.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { requireActivePluginRegistry } from "../../plugins/runtime.js"; import { buildAgentRunTerminalOutcome } from "../agent-run-terminal-outcome.js"; import { ensureSelectedAgentHarnessPlugin } from "../harness/runtime-plugin.js"; import type { ModelFallbackResultClassification } from "../model-fallback-attempt.js"; @@ -226,6 +227,7 @@ export async function runEmbeddedAgentEntry( agentHarnessId: agentHarnessRuntimeOverride, agentHarnessRuntimeOverride, workspaceDir: params.harness.workspaceDir, + pluginRegistry: requireActivePluginRegistry(), }); if (params.harness.preparation.kind === "measured") { await params.harness.preparation.run(prepare); diff --git a/src/agents/embedded-agent-runner/run-orchestrator.ts b/src/agents/embedded-agent-runner/run-orchestrator.ts index 060124455693..1abce546de2b 100644 --- a/src/agents/embedded-agent-runner/run-orchestrator.ts +++ b/src/agents/embedded-agent-runner/run-orchestrator.ts @@ -20,13 +20,16 @@ import { buildAgentHookContextIdentityFields, } from "../../plugins/hook-agent-context.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; +import { withPluginRuntimeRegistryScope } from "../../plugins/runtime/gateway-request-scope.js"; import { resolveUserPath } from "../../utils.js"; import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; import { resolveAgentDir, resolveAgentWorkspaceDir, resolveDefaultAgentDir, + resolveRunModelFallbacksOverride, } from "../agent-scope.js"; +import { resolveModelCandidateChain } from "../model-fallback-candidates.js"; import { acquireAgentRunPreparedModelRuntime, acquireReadOnlyPreparedModelRuntime, @@ -36,7 +39,6 @@ import { applyAgentRunSessionTargetIdentity, resolveAgentRunSessionTarget, } from "../run-session-target.js"; -import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js"; import { resolveSessionSuspensionTarget, suspendSession, @@ -204,6 +206,40 @@ async function runEmbeddedAgentInternal( const requestedAgentDir = params.agentDir ?? resolveAgentDir(config, requestedWorkspaceResolution.agentId); const retainIdleRunOwner = params.config === undefined; + const requestedRuntimeSelection = resolveInitialEmbeddedRunModel({ + config, + agentId: requestedWorkspaceResolution.agentId, + provider: params.provider, + model: params.model, + }); + const requestedHarnessRuntime = params.agentHarnessId ?? params.agentHarnessRuntimeOverride; + const runtimePluginFallbacksOverride = + params.modelFallbacksOverride ?? + resolveRunModelFallbacksOverride({ + cfg: config, + agentId: requestedWorkspaceResolution.agentId, + sessionKey: params.sessionKey, + }); + const runtimePluginSelections = resolveModelCandidateChain({ + cfg: config, + provider: requestedRuntimeSelection.provider, + model: requestedRuntimeSelection.modelId, + requestedRouteResolution: "resolved", + fallbacksOverride: runtimePluginFallbacksOverride, + }).map((candidate) => + requestedHarnessRuntime + ? { + provider: candidate.provider, + modelId: candidate.model, + runtime: requestedHarnessRuntime, + agentId: requestedWorkspaceResolution.agentId, + } + : { + provider: candidate.provider, + modelId: candidate.model, + agentId: requestedWorkspaceResolution.agentId, + }, + ); const preparedInput = { config, agentId: requestedWorkspaceResolution.agentId, @@ -211,6 +247,8 @@ async function runEmbeddedAgentInternal( inheritedAuthDir: resolveDefaultAgentDir(config), workspaceDir: requestedWorkspaceResolution.workspaceDir, preserveWorkspaceDirOnRefresh: !requestedWorkspaceResolution.isCanonicalWorkspace, + ...(params.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}), + runtimePluginSelections, }; // Configless direct hosts reuse one bounded idle generation. Gateway and explicitly // configured runs release dynamic workspaces so one-off paths cannot accumulate owners. @@ -251,132 +289,131 @@ async function runEmbeddedAgentInternal( projectKey, activeProjectKeys, }); - const preparedAgentId = workspaceResolution.agentId; - const resolvedWorkspace = workspaceResolution.workspaceDir; - const agentDir = preparedModelRuntime.agentDir; - const progressController = createEmbeddedRunProgressController({ - attempt: params, - noteLaneTaskProgress, - startedAtMs: started, - }); - const { notifyExecutionPhase } = progressController; - const emitStartupStageSummary = createEmbeddedRunStageSummaryEmitter({ - label: "startup stages", - log, - runId: params.runId, - sessionId: params.sessionId, - tracker: startupStages, - }); - params.onExecutionStarted?.({ lifecycleGeneration }); - notifyExecutionPhase("runner_entered"); - const canonicalWorkspace = resolveUserPath( - resolveAgentWorkspaceDir(preparedModelRuntime.config, preparedAgentId), - ); - const isCanonicalWorkspace = canonicalWorkspace === resolvedWorkspace; - const redactedSessionId = redactRunIdentifier(params.sessionId); - const redactedSessionKey = redactRunIdentifier(params.sessionKey); - const redactedWorkspace = redactRunIdentifier(resolvedWorkspace); - if (requestedWorkspaceResolution.usedFallback) { - log.warn( - `[workspace-fallback] caller=runEmbeddedAgent reason=${requestedWorkspaceResolution.fallbackReason} run=${params.runId} session=${redactedSessionId} sessionKey=${redactedSessionKey} agent=${preparedAgentId} workspace=${redactedWorkspace}`, + const runPrepared = async () => { + const preparedAgentId = workspaceResolution.agentId; + const resolvedWorkspace = workspaceResolution.workspaceDir; + const agentDir = preparedModelRuntime.agentDir; + const progressController = createEmbeddedRunProgressController({ + attempt: params, + noteLaneTaskProgress, + startedAtMs: started, + }); + const { notifyExecutionPhase } = progressController; + const emitStartupStageSummary = createEmbeddedRunStageSummaryEmitter({ + label: "startup stages", + log, + runId: params.runId, + sessionId: params.sessionId, + tracker: startupStages, + }); + params.onExecutionStarted?.({ lifecycleGeneration }); + notifyExecutionPhase("runner_entered"); + const canonicalWorkspace = resolveUserPath( + resolveAgentWorkspaceDir(preparedModelRuntime.config, preparedAgentId), ); - } - startupStages.mark("workspace"); - notifyExecutionPhase("workspace"); - ensureRuntimePluginsLoaded({ - config: preparedModelRuntime.config, - workspaceDir: resolvedWorkspace, - ...(params.allowGatewaySubagentBinding !== undefined - ? { allowGatewaySubagentBinding: params.allowGatewaySubagentBinding } - : {}), - }); - startupStages.mark("runtime-plugins"); - notifyExecutionPhase("runtime_plugins"); + const isCanonicalWorkspace = canonicalWorkspace === resolvedWorkspace; + const redactedSessionId = redactRunIdentifier(params.sessionId); + const redactedSessionKey = redactRunIdentifier(params.sessionKey); + const redactedWorkspace = redactRunIdentifier(resolvedWorkspace); + if (requestedWorkspaceResolution.usedFallback) { + log.warn( + `[workspace-fallback] caller=runEmbeddedAgent reason=${requestedWorkspaceResolution.fallbackReason} run=${params.runId} session=${redactedSessionId} sessionKey=${redactedSessionKey} agent=${preparedAgentId} workspace=${redactedWorkspace}`, + ); + } + startupStages.mark("workspace"); + notifyExecutionPhase("workspace"); + startupStages.mark("runtime-plugins"); + notifyExecutionPhase("runtime_plugins"); - const { provider, modelId } = resolveInitialEmbeddedRunModel({ - config: params.config, - agentId: workspaceResolution.agentId, - provider: params.provider, - model: params.model, - }); - const normalizedSessionKey = params.sessionKey?.trim(); - const fallbackConfigured = hasEmbeddedRunConfiguredModelFallbacks({ - cfg: params.config, - agentId: params.agentId, - sessionKey: normalizedSessionKey, - modelFallbacksOverride: params.modelFallbacksOverride, - }); - const resolvedSessionKey = - normalizedSessionKey ?? params.sessionTarget?.sessionKey ?? params.sessionId; - const hookRunner = getGlobalHookRunner(); - const hookCtx = { - runId: params.runId, - jobId: params.jobId, - agentId: workspaceResolution.agentId, - sessionKey: resolvedSessionKey, - sessionId: params.sessionId, - workspaceDir: resolvedWorkspace, - activeProjectKeys: [...activeProjectKeys], - modelProviderId: provider, - modelId, - trigger: params.trigger, - ...buildAgentHookContextChannelFields(params), - ...buildAgentHookContextIdentityFields({ + const { provider, modelId } = resolveInitialEmbeddedRunModel({ + config: params.config, + agentId: workspaceResolution.agentId, + provider: params.provider, + model: params.model, + }); + const normalizedSessionKey = params.sessionKey?.trim(); + const fallbackConfigured = hasEmbeddedRunConfiguredModelFallbacks({ + cfg: params.config, + agentId: params.agentId, + sessionKey: normalizedSessionKey, + modelFallbacksOverride: params.modelFallbacksOverride, + }); + const resolvedSessionKey = + normalizedSessionKey ?? params.sessionTarget?.sessionKey ?? params.sessionId; + const hookRunner = getGlobalHookRunner(); + const hookCtx = { + runId: params.runId, + jobId: params.jobId, + agentId: workspaceResolution.agentId, + sessionKey: resolvedSessionKey, + sessionId: params.sessionId, + workspaceDir: resolvedWorkspace, + activeProjectKeys: [...activeProjectKeys], + modelProviderId: provider, + modelId, trigger: params.trigger, - senderId: params.senderId, - chatId: params.chatId, - channelContext: params.channelContext, - }), - }; - const hookResult = await runBeforeAgentReplyForTurn({ - runId: params.runId, - trigger: params.trigger, - event: { cleanedBody: params.prompt }, - context: hookCtx, - onDispatch: () => - notifyExecutionPhase("before_agent_reply", { provider, model: modelId }), - onDeclined: () => notifyExecutionPhase("runtime_plugins", { provider, model: modelId }), - }); - if (hookResult?.handled) { - return { - payloads: buildHandledBeforeAgentReplyPayloads(hookResult.reply), - meta: { - durationMs: Date.now() - started, - agentMeta: { - sessionId: params.sessionId, - provider, - model: modelId, - }, - finalAssistantVisibleText: hookResult.reply?.text ?? SILENT_REPLY_TOKEN, - finalAssistantRawText: hookResult.reply?.text ?? SILENT_REPLY_TOKEN, - }, + ...buildAgentHookContextChannelFields(params), + ...buildAgentHookContextIdentityFields({ + trigger: params.trigger, + senderId: params.senderId, + chatId: params.chatId, + channelContext: params.channelContext, + }), }; - } + const hookResult = await runBeforeAgentReplyForTurn({ + runId: params.runId, + trigger: params.trigger, + event: { cleanedBody: params.prompt }, + context: hookCtx, + onDispatch: () => + notifyExecutionPhase("before_agent_reply", { provider, model: modelId }), + onDeclined: () => notifyExecutionPhase("runtime_plugins", { provider, model: modelId }), + }); + if (hookResult?.handled) { + return { + payloads: buildHandledBeforeAgentReplyPayloads(hookResult.reply), + meta: { + durationMs: Date.now() - started, + agentMeta: { + sessionId: params.sessionId, + provider, + model: modelId, + }, + finalAssistantVisibleText: hookResult.reply?.text ?? SILENT_REPLY_TOKEN, + finalAssistantRawText: hookResult.reply?.text ?? SILENT_REPLY_TOKEN, + }, + }; + } - return await executePreparedEmbeddedRun({ - runParams: params, - provider, - modelId, - agentDir, - workspaceResolution, - workspaceDir: resolvedWorkspace, - isCanonicalWorkspace, - globalLane, - hookRunner, - hookContext: hookCtx, - fallbackConfigured, - isProbeSession, - resolvedSessionKey, - resolvedToolResultFormat, - startedAtMs: started, - startupStages, - emitStartupStageSummary, - progressController, - laneController, - lifecycleGeneration, - suspendForFailure, - preparedModelRuntime, - }); + return await executePreparedEmbeddedRun({ + runParams: params, + provider, + modelId, + agentDir, + workspaceResolution, + workspaceDir: resolvedWorkspace, + isCanonicalWorkspace, + globalLane, + hookRunner, + hookContext: hookCtx, + fallbackConfigured, + isProbeSession, + resolvedSessionKey, + resolvedToolResultFormat, + startedAtMs: started, + startupStages, + emitStartupStageSummary, + progressController, + laneController, + lifecycleGeneration, + suspendForFailure, + preparedModelRuntime, + }); + }; + return await withPluginRuntimeRegistryScope( + preparedModelRuntime.pluginRegistry, + runPrepared, + ); } finally { preparedModelRuntimeLease.release(); } diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts index b34e90f63948..86d99e853ae4 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts @@ -11,6 +11,7 @@ import type { PluginHookBeforeAgentFinalizeResult, } from "../../plugins/hook-types.js"; import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js"; +import { getActivePluginRegistry } from "../../plugins/runtime.js"; import type { PluginHookAgentContext, PluginHookBeforeAgentReplyResult, @@ -178,11 +179,32 @@ const mockedResolveContextEngineOwnerPluginId = vi.fn(() => undefined); export const mockedBuildAgentRuntimePlan = vi.fn<() => AgentRuntimePlan>( () => makeMockRuntimePlan() as AgentRuntimePlan, ); +export const mockedAcquireAgentRunPreparedModelRuntime = vi.fn( + async (input: Record) => { + const pluginRegistry = getActivePluginRegistry(); + return { + snapshot: { + agentId: input.agentId, + agentDir: input.agentDir, + config: input.config, + workspaceDir: input.workspaceDir, + pluginRegistry: pluginRegistry + ? { + ...pluginRegistry, + agentHarnesses: [...pluginRegistry.agentHarnesses], + } + : undefined, + metadataSnapshot: { ...emptyPluginMetadataSnapshot, workspaceDir: input.workspaceDir }, + createStores: () => ({ authStorage: {}, modelRegistry: {} }), + }, + release: vi.fn(), + }; + }, +); export const mockedRunPostCompactionSideEffects = vi.fn(async () => {}); export const mockedSleepWithAbort = vi.fn( async (_ms: number, _abortSignal?: AbortSignal) => undefined, ); -export const mockedEnsureRuntimePluginsLoaded = vi.fn<(params?: unknown) => void>(); function createMockAgentDiscoveryStores(): MockAgentDiscoveryStores { return { authStorage: { @@ -452,7 +474,6 @@ export function resetRunOverflowCompactionHarnessMocks(): void { return { assistant, ...(result.attemptUsage ? { usage: result.attemptUsage } : {}) }; }, }); - mockedGlobalHookRunner.hasHooks.mockReset(); mockedGlobalHookRunner.hasHooks.mockReturnValue(false); mockedGlobalHookRunner.runBeforeAgentReply.mockReset(); @@ -472,6 +493,7 @@ export function resetRunOverflowCompactionHarnessMocks(): void { mockedResolveContextEngine.mockReset(); mockedResolveContextEngine.mockResolvedValue(mockedContextEngine); mockedBuildAgentRuntimePlan.mockReset(); + mockedAcquireAgentRunPreparedModelRuntime.mockClear(); mockedBuildAgentRuntimePlan.mockImplementation(() => makeMockRuntimePlan() as AgentRuntimePlan); mockedCompactDirect.mockReset(); mockedCompactDirect.mockResolvedValue({ @@ -480,7 +502,6 @@ export function resetRunOverflowCompactionHarnessMocks(): void { reason: "nothing to compact", }); - mockedEnsureRuntimePluginsLoaded.mockReset(); mockedCreateEmptyAgentDiscoveryStores.mockReset(); mockedCreateEmptyAgentDiscoveryStores.mockImplementation(createMockAgentDiscoveryStores); mockedResolveModelAsync.mockReset(); @@ -667,10 +688,6 @@ export async function loadRunOverflowCompactionHarness(): Promise<{ resolveContextEngineOwnerPluginId: mockedResolveContextEngineOwnerPluginId, })); - vi.doMock("../runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded: mockedEnsureRuntimePluginsLoaded, - })); - vi.doMock("../harness/runtime-plugin.js", () => ({ ensureSelectedAgentHarnessPlugin: vi.fn(async () => {}), })); @@ -923,20 +940,7 @@ export async function loadRunOverflowCompactionHarness(): Promise<{ vi.doMock("../prepared-model-runtime.js", () => ({ activateStandalonePreparedModelRuntime: vi.fn(async () => {}), - acquireAgentRunPreparedModelRuntime: vi.fn(async (input: Record) => ({ - snapshot: { - agentId: input.agentId, - agentDir: input.agentDir, - config: input.config, - workspaceDir: input.workspaceDir, - metadataSnapshot: { - ...emptyPluginMetadataSnapshot, - workspaceDir: input.workspaceDir as string | undefined, - }, - createStores: () => ({ authStorage: {}, modelRegistry: {} }), - }, - release: vi.fn(), - })), + acquireAgentRunPreparedModelRuntime: mockedAcquireAgentRunPreparedModelRuntime, prepareModelRuntimeSnapshot: vi.fn(async () => ({ createStores: () => ({ authStorage: {}, modelRegistry: {} }), })), diff --git a/src/agents/embedded-agent-runner/run/model-setup.ts b/src/agents/embedded-agent-runner/run/model-setup.ts index 6b1f7560b753..a3c1330b3c3b 100644 --- a/src/agents/embedded-agent-runner/run/model-setup.ts +++ b/src/agents/embedded-agent-runner/run/model-setup.ts @@ -1,3 +1,4 @@ +import { requireActivePluginRegistry } from "../../../plugins/runtime.js"; import { resolveDefaultAgentDir } from "../../agent-scope.js"; import { FailoverError } from "../../failover-error.js"; import { ensureSelectedAgentHarnessPlugin } from "../../harness/runtime-plugin.js"; @@ -59,6 +60,7 @@ export async function resolveEmbeddedRunModelSetup(params: { agentHarnessRuntimeOverride: runParams.agentHarnessRuntimeOverride, requestTransportOverrides: requestStreamTransportOverrides, workspaceDir: params.workspaceDir, + pluginRegistry: params.preparedModelRuntime?.pluginRegistry ?? requireActivePluginRegistry(), }); const agentHarness = selectAgentHarness({ provider, diff --git a/src/agents/embedded-agent-runner/usage-reporting.test.ts b/src/agents/embedded-agent-runner/usage-reporting.test.ts index a183fe3bd4ea..e7ec95cd3a4d 100644 --- a/src/agents/embedded-agent-runner/usage-reporting.test.ts +++ b/src/agents/embedded-agent-runner/usage-reporting.test.ts @@ -5,9 +5,10 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; import { loadRunOverflowCompactionHarness, - mockedEnsureRuntimePluginsLoaded, + mockedAcquireAgentRunPreparedModelRuntime, mockedResolveModelAsync, mockedRunEmbeddedAttempt, + resetRunOverflowCompactionHarnessMocks, warmRunOverflowCompactionHarness, } from "./run.overflow-compaction.harness.js"; import type { EmbeddedRunAttemptResult } from "./run/types.js"; @@ -49,11 +50,20 @@ describe("runEmbeddedAgent usage reporting", () => { }); beforeEach(() => { - mockedEnsureRuntimePluginsLoaded.mockReset(); - mockedRunEmbeddedAttempt.mockReset(); + resetRunOverflowCompactionHarnessMocks(); }); it("bootstraps runtime plugins with the resolved workspace before running", async () => { + const config = { + agents: { + defaults: { + model: { + primary: "anthropic/test-model", + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + }; mockedRunEmbeddedAttempt.mockResolvedValueOnce( makeAttemptResult({ assistantTexts: ["Response 1"], @@ -68,12 +78,99 @@ describe("runEmbeddedAgent usage reporting", () => { prompt: "hello", timeoutMs: 30000, runId: "run-plugin-bootstrap", + config, }); - expect(mockedEnsureRuntimePluginsLoaded).toHaveBeenCalledWith({ - config: {}, + expect(mockedAcquireAgentRunPreparedModelRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + config, + workspaceDir: "/tmp/workspace", + runtimePluginSelections: expect.arrayContaining([ + expect.objectContaining({ provider: "openai", modelId: "gpt-5.5" }), + ]), + }), + expect.anything(), + ); + }); + + it("includes named-agent fallback owners in the runtime plugin plan", async () => { + const config = { + agents: { + defaults: { model: { primary: "anthropic/test-model" } }, + list: [ + { + id: "support", + model: { fallbacks: ["openai/gpt-5.5"] }, + }, + ], + }, + }; + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ assistantTexts: ["Response 1"] }), + ); + + await runEmbeddedAgent({ + sessionId: "test-session", + sessionKey: "agent:support:test-key", + sessionFile: "agent:support:test-key", + agentId: "support", workspaceDir: "/tmp/workspace", + prompt: "hello", + timeoutMs: 30000, + runId: "run-agent-fallback-plugin-bootstrap", + config, }); + + expect(mockedAcquireAgentRunPreparedModelRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "support", + runtimePluginSelections: expect.arrayContaining([ + expect.objectContaining({ provider: "openai", modelId: "gpt-5.5" }), + ]), + }), + expect.anything(), + ); + }); + + it("preserves an explicitly pinned harness across fallback plugin planning", async () => { + const config = { + agents: { + defaults: { + model: { + primary: "codex/test-model", + fallbacks: ["openai/gpt-5.5"], + }, + }, + }, + }; + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ assistantTexts: ["Response 1"] }), + ); + + await runEmbeddedAgent({ + sessionId: "test-session", + sessionKey: "test-key", + sessionFile: "test-key", + workspaceDir: "/tmp/workspace", + prompt: "hello", + timeoutMs: 30000, + runId: "run-pinned-fallback-plugin-bootstrap", + agentHarnessId: "codex", + config, + }); + + expect(mockedAcquireAgentRunPreparedModelRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + runtimePluginSelections: expect.arrayContaining([ + expect.objectContaining({ + provider: "openai", + modelId: "gpt-5.5", + runtime: "codex", + }), + ]), + }), + expect.anything(), + ); }); it("forwards gateway subagent binding opt-in to runtime plugin bootstrap", async () => { @@ -94,11 +191,14 @@ describe("runEmbeddedAgent usage reporting", () => { allowGatewaySubagentBinding: true, }); - expect(mockedEnsureRuntimePluginsLoaded).toHaveBeenCalledWith({ - config: {}, - workspaceDir: "/tmp/workspace", - allowGatewaySubagentBinding: true, - }); + expect(mockedAcquireAgentRunPreparedModelRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + config: {}, + workspaceDir: "/tmp/workspace", + allowGatewaySubagentBinding: true, + }), + expect.anything(), + ); expect(firstAttemptInput().allowGatewaySubagentBinding).toBe(true); }); diff --git a/src/agents/harness/runtime-plugin-load-plan.ts b/src/agents/harness/runtime-plugin-load-plan.ts new file mode 100644 index 000000000000..6015004af7db --- /dev/null +++ b/src/agents/harness/runtime-plugin-load-plan.ts @@ -0,0 +1,233 @@ +/** Builds deterministic plugin load plans for selected native harness and memory owners. */ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { withActivatedPluginIds } from "../../plugins/activation-context.js"; +import { resolveManifestActivationPlan } from "../../plugins/activation-planner.js"; +import { resolveEffectivePluginActivationState } from "../../plugins/config-state.js"; +import { isPluginEnabledByDefaultForPlatform } from "../../plugins/default-enablement.js"; +import { + loadPluginRegistrySnapshot, + normalizePluginsConfigWithRegistry, +} from "../../plugins/plugin-registry.js"; +import { + resolveActivatableProviderOwnerPluginIds, + resolveBundledProviderCompatPluginIds, + resolveOwningPluginIdsForProviderRef, +} from "../../plugins/providers.js"; +import { isDefaultAgentRuntimeId, OPENCLAW_AGENT_RUNTIME_ID } from "../agent-runtime-id.js"; +import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; +import { isCliRuntimeAliasForProvider } from "../model-runtime-aliases.js"; +import { resolveAgentHarnessPolicy } from "./policy.js"; + +export type AgentHarnessPluginSelection = { + provider: string; + modelId: string; + runtime?: string; + agentId?: string; +}; + +function dedupePluginIds(values: readonly string[]): string[] { + const result: string[] = []; + for (const value of values) { + const pluginId = value.trim(); + if (pluginId && !result.includes(pluginId)) { + result.push(pluginId); + } + } + return result; +} + +function restrictiveAllowlistOmitsPlugin(config: OpenClawConfig | undefined, pluginId: string) { + const allow = config?.plugins?.allow ?? []; + return allow.length > 0 && !allow.includes(pluginId); +} + +function resolveSelectedMemoryPluginIds(params: { + config: OpenClawConfig | undefined; + workspaceDir: string; +}): string[] { + const registry = loadPluginRegistrySnapshot(params); + const plugins = normalizePluginsConfigWithRegistry(params.config?.plugins, registry); + const memorySlot = plugins.slots.memory; + if ( + typeof memorySlot !== "string" || + restrictiveAllowlistOmitsPlugin(params.config, memorySlot) + ) { + return []; + } + const plugin = registry.plugins.find((entry) => entry.pluginId === memorySlot); + if (!plugin?.startup.memory) { + return []; + } + return resolveEffectivePluginActivationState({ + id: plugin.pluginId, + origin: plugin.origin, + config: plugins, + rootConfig: params.config, + enabledByDefault: isPluginEnabledByDefaultForPlatform(plugin), + }).activated + ? [plugin.pluginId] + : []; +} + +/** Resolve manifest owners required by one selected non-core harness runtime. */ +export function resolveAgentHarnessOwnerPluginIds(params: { + runtime: string; + provider: string; + config?: OpenClawConfig; + workspaceDir: string; +}): string[] { + const harnessPluginIds = resolveManifestActivationPlan({ + trigger: { kind: "agentHarness", runtime: params.runtime }, + config: params.config, + workspaceDir: params.workspaceDir, + requireExplicitManifestOwnerTrust: true, + }).entries.map((entry) => entry.pluginId); + if ( + harnessPluginIds.length === 0 || + params.runtime !== "codex" || + !harnessPluginIds.includes("codex") || + restrictiveAllowlistOmitsPlugin(params.config, "codex") + ) { + return harnessPluginIds; + } + const providerOwnerPluginIds = dedupePluginIds( + resolveOwningPluginIdsForProviderRef(params) ?? [], + ); + if (providerOwnerPluginIds.length === 0) { + return harnessPluginIds; + } + const safeProviderOwnerPluginIds = dedupePluginIds([ + ...resolveBundledProviderCompatPluginIds({ + config: params.config, + workspaceDir: params.workspaceDir, + onlyPluginIds: providerOwnerPluginIds, + }), + ...resolveActivatableProviderOwnerPluginIds({ + pluginIds: providerOwnerPluginIds, + config: params.config, + workspaceDir: params.workspaceDir, + }), + ]); + return dedupePluginIds([ + ...harnessPluginIds, + ...providerOwnerPluginIds.filter( + (pluginId) => pluginId !== "codex" && safeProviderOwnerPluginIds.includes(pluginId), + ), + ]); +} + +function withRuntimePluginIdsAllowed( + config: OpenClawConfig | undefined, + pluginIds: readonly string[], + materializeAllowlist: boolean, +): OpenClawConfig | undefined { + const existingAllowlist = config?.plugins?.allow ?? []; + if (pluginIds.length === 0 || (!materializeAllowlist && existingAllowlist.length === 0)) { + return config; + } + return { + ...config, + plugins: { + ...config?.plugins, + allow: dedupePluginIds([...existingAllowlist, ...pluginIds]), + }, + }; +} + +function resolveSelectedRuntime(selection: AgentHarnessPluginSelection, config?: OpenClawConfig) { + const requestedRuntime = normalizeOptionalAgentRuntimeId(selection.runtime); + return requestedRuntime && !isDefaultAgentRuntimeId(requestedRuntime) + ? requestedRuntime + : resolveAgentHarnessPolicy({ + provider: selection.provider, + modelId: selection.modelId, + config, + agentId: selection.agentId, + }).runtime; +} + +/** Returns whether a selection needs a plugin-owned harness in its prepared generation. */ +export function requiresAgentHarnessPluginSelection( + selection: AgentHarnessPluginSelection, + config?: OpenClawConfig, +): boolean { + const runtime = resolveSelectedRuntime(selection, config); + return ( + !isDefaultAgentRuntimeId(runtime) && + runtime !== OPENCLAW_AGENT_RUNTIME_ID && + !isCliRuntimeAliasForProvider({ runtime, provider: selection.provider, cfg: config }) + ); +} + +/** Folds selected harness and memory owners into one deterministic plugin load plan. */ +export function resolveAgentRuntimePluginLoadPlan(params: { + config?: OpenClawConfig; + workspaceDir: string; + basePluginIds?: readonly string[]; + selections: readonly AgentHarnessPluginSelection[]; +}): { config?: OpenClawConfig; pluginIds?: string[] } { + let config = params.config; + const memoryPluginIds = resolveSelectedMemoryPluginIds({ + config: params.config, + workspaceDir: params.workspaceDir, + }); + const basePluginIds = (params.basePluginIds ?? []).filter( + (pluginId) => !restrictiveAllowlistOmitsPlugin(params.config, pluginId), + ); + const pluginIds = [...basePluginIds, ...memoryPluginIds]; + const forceActivatedPluginIds = [...memoryPluginIds]; + for (const selection of params.selections) { + const runtime = resolveSelectedRuntime(selection, config); + if (!requiresAgentHarnessPluginSelection(selection, config)) { + continue; + } + const harnessPluginIds = resolveAgentHarnessOwnerPluginIds({ + runtime, + provider: selection.provider, + config, + workspaceDir: params.workspaceDir, + }); + pluginIds.push(...harnessPluginIds); + const allowedHarnessPluginIds = + runtime === "codex" + ? restrictiveAllowlistOmitsPlugin(params.config, "codex") + ? [] + : harnessPluginIds + : harnessPluginIds.filter( + (pluginId) => !restrictiveAllowlistOmitsPlugin(params.config, pluginId), + ); + forceActivatedPluginIds.push(...allowedHarnessPluginIds); + } + const scopedPluginIds = dedupePluginIds(pluginIds).toSorted((left, right) => + left.localeCompare(right), + ); + config = withRuntimePluginIdsAllowed( + config, + [...basePluginIds, ...forceActivatedPluginIds], + params.basePluginIds !== undefined, + ); + const activatedConfig = + withActivatedPluginIds({ config, pluginIds: forceActivatedPluginIds.toSorted() }) ?? config; + if ( + params.basePluginIds === undefined && + (params.config?.plugins?.allow?.length ?? 0) === 0 && + activatedConfig?.plugins + ) { + // A standalone full load must not turn forced owners into discovery policy. + const plugins = { ...activatedConfig.plugins }; + if (params.config?.plugins?.allow === undefined) { + delete plugins.allow; + } else { + plugins.allow = params.config.plugins.allow; + } + config = { ...activatedConfig, plugins }; + } else { + config = activatedConfig; + } + return { + ...(config ? { config } : {}), + ...(params.basePluginIds === undefined && scopedPluginIds.length === 0 + ? {} + : { pluginIds: scopedPluginIds }), + }; +} diff --git a/src/agents/harness/runtime-plugin.test.ts b/src/agents/harness/runtime-plugin.test.ts index 1109e040b50e..5077b5c8a19e 100644 --- a/src/agents/harness/runtime-plugin.test.ts +++ b/src/agents/harness/runtime-plugin.test.ts @@ -1,19 +1,20 @@ -// Verifies plugin loading needed before agent harness selection. -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +// Verifies harness ownership, payload availability, and run-owned registry lookup. +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; +import { resolveAgentRuntimePluginLoadPlan } from "./runtime-plugin-load-plan.js"; +import { + ensureSelectedAgentHarnessPlugin, + resolveAgentHarnessRuntimeAvailability, +} from "./runtime-plugin.js"; const mocks = vi.hoisted(() => ({ - ensurePluginRegistryLoaded: vi.fn(), resolveActivatableProviderOwnerPluginIds: vi.fn(), resolveBundledProviderCompatPluginIds: vi.fn(), resolveManifestActivationPlan: vi.fn(), resolveOwningPluginIdsForProvider: vi.fn(), })); -vi.mock("../../plugins/runtime/runtime-registry-loader.js", () => ({ - ensurePluginRegistryLoaded: mocks.ensurePluginRegistryLoaded, -})); - vi.mock("../../plugins/providers.js", () => ({ resolveActivatableProviderOwnerPluginIds: mocks.resolveActivatableProviderOwnerPluginIds, resolveBundledProviderCompatPluginIds: mocks.resolveBundledProviderCompatPluginIds, @@ -25,87 +26,129 @@ vi.mock("../../plugins/activation-planner.js", () => ({ resolveManifestActivationPlan: mocks.resolveManifestActivationPlan, })); -describe("ensureSelectedAgentHarnessPlugin", () => { - let ensureSelectedAgentHarnessPlugin: typeof import("./runtime-plugin.js").ensureSelectedAgentHarnessPlugin; - let resolveAgentHarnessRuntimeAvailability: typeof import("./runtime-plugin.js").resolveAgentHarnessRuntimeAvailability; - - beforeAll(async () => { - vi.resetModules(); - ({ ensureSelectedAgentHarnessPlugin, resolveAgentHarnessRuntimeAvailability } = - await import("./runtime-plugin.js")); - }); - +describe("harness runtime plugins", () => { beforeEach(() => { - mocks.ensurePluginRegistryLoaded.mockReset(); - mocks.resolveActivatableProviderOwnerPluginIds.mockReset(); - mocks.resolveBundledProviderCompatPluginIds.mockReset(); - mocks.resolveManifestActivationPlan.mockReset(); - mocks.resolveOwningPluginIdsForProvider.mockReset(); - mocks.resolveManifestActivationPlan.mockImplementation( - ({ - trigger, - config, - }: { - trigger: { kind: "agentHarness"; runtime: string }; - config?: OpenClawConfig; - }) => { - const pluginId = trigger.runtime; - const allow = config?.plugins?.allow ?? []; - if ( - config?.plugins?.entries?.[pluginId]?.enabled === false || - (allow.length > 0 && !allow.includes(pluginId)) - ) { - return { entries: [] }; - } - return { - entries: - pluginId === "codex" || pluginId === "copilot" ? [{ pluginId, origin: "bundled" }] : [], - }; - }, - ); - mocks.resolveOwningPluginIdsForProvider.mockImplementation( - ({ provider }: { provider: string }) => (provider === "openai" ? ["openai"] : undefined), - ); - mocks.resolveBundledProviderCompatPluginIds.mockImplementation( - ({ onlyPluginIds }: { onlyPluginIds?: readonly string[] }) => - (onlyPluginIds ?? []).filter((pluginId) => pluginId === "openai"), - ); - mocks.resolveActivatableProviderOwnerPluginIds.mockImplementation( - ({ pluginIds }: { pluginIds: readonly string[] }) => - pluginIds.filter((pluginId) => pluginId === "memory-core"), - ); + mocks.resolveActivatableProviderOwnerPluginIds.mockReset().mockReturnValue([]); + mocks.resolveBundledProviderCompatPluginIds.mockReset().mockReturnValue([]); + mocks.resolveOwningPluginIdsForProvider.mockReset().mockReturnValue(undefined); + mocks.resolveManifestActivationPlan.mockReset().mockReturnValue({ + entries: [{ pluginId: "codex", origin: "bundled" }], + }); }); - it("loads Codex and the provider owner when an explicit runtime override forces the Codex harness", async () => { + it("looks up a selected harness in the run-owned registry without loading plugins", async () => { + const pluginRegistry = createEmptyPluginRegistry(); + pluginRegistry.agentHarnesses.push({ + pluginId: "codex", + source: "test", + harness: { + id: "codex", + label: "Codex", + supports: () => ({ supported: true }), + runAttempt: async () => { + throw new Error("unused"); + }, + }, + }); + await ensureSelectedAgentHarnessPlugin({ provider: "openai", modelId: "gpt-5.5", - config: { - models: { - providers: { - openai: { - baseUrl: "https://openai-compatible.example.test/v1", - models: [], - }, - }, - }, - } as OpenClawConfig, agentHarnessRuntimeOverride: "codex", workspaceDir: "/tmp/workspace", + pluginRegistry, }); - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( - expect.objectContaining({ - scope: "all", - workspaceDir: "/tmp/workspace", - onlyPluginIds: ["codex", "openai", "memory-core"], - }), - ); + expect(pluginRegistry.agentHarnesses).toHaveLength(1); + }); + + it("force-activates a default-disabled harness owner selected for a run", () => { + const plan = resolveAgentRuntimePluginLoadPlan({ + config: {}, + workspaceDir: "/tmp/workspace", + selections: [{ provider: "openai", modelId: "gpt-5.5", runtime: "codex" }], + }); + + expect(plan.pluginIds).toContain("codex"); + expect(plan.config?.plugins?.entries?.codex).toEqual({ enabled: true }); + }); + + it("keeps standalone activation unrestricted when no complete startup base exists", () => { + const plan = resolveAgentRuntimePluginLoadPlan({ + config: { + plugins: { + entries: { "custom-context-engine": { enabled: true } }, + }, + }, + workspaceDir: "/tmp/workspace", + selections: [{ provider: "openai", modelId: "gpt-5.5", runtime: "codex" }], + }); + + expect(plan.config?.plugins?.allow).toBeUndefined(); + expect(plan.config?.plugins?.entries).toMatchObject({ + "custom-context-engine": { enabled: true }, + codex: { enabled: true }, + }); + }); + + it("checks restrictive allowlists against the selected harness owner plugin id", () => { + mocks.resolveManifestActivationPlan.mockReturnValueOnce({ + entries: [{ pluginId: "custom-harness-plugin", origin: "workspace" }], + }); + const plan = resolveAgentRuntimePluginLoadPlan({ + config: { plugins: { allow: ["custom-harness-plugin"] } }, + workspaceDir: "/tmp/workspace", + selections: [ + { provider: "custom-provider", modelId: "custom-model", runtime: "custom-harness" }, + ], + }); + + expect(plan.pluginIds).toEqual(["custom-harness-plugin"]); + expect(plan.config?.plugins?.entries?.["custom-harness-plugin"]).toEqual({ enabled: true }); + }); + + it("preserves startup-scoped plugins when selected owners synthesize an allowlist", () => { + const plan = resolveAgentRuntimePluginLoadPlan({ + config: {}, + workspaceDir: "/tmp/workspace", + basePluginIds: ["telegram"], + selections: [{ provider: "openai", modelId: "gpt-5.5", runtime: "codex" }], + }); + + expect(plan.pluginIds).toEqual(["codex", "memory-core", "telegram"]); + expect(plan.config?.plugins?.allow).toEqual(["telegram", "memory-core", "codex"]); + }); + + it("does not restore stale startup plugins excluded by a restrictive reload allowlist", () => { + const plan = resolveAgentRuntimePluginLoadPlan({ + config: { plugins: { allow: ["codex"] } }, + workspaceDir: "/tmp/workspace", + basePluginIds: ["telegram"], + selections: [{ provider: "openai", modelId: "gpt-5.5", runtime: "codex" }], + }); + + expect(plan.pluginIds).toEqual(["codex"]); + expect(plan.config?.plugins?.allow).toEqual(["codex"]); + }); + + it("retains safe provider-owner dependencies for an explicitly allowed Codex harness", () => { + mocks.resolveOwningPluginIdsForProvider.mockReturnValueOnce(["openai"]); + mocks.resolveActivatableProviderOwnerPluginIds.mockReturnValueOnce(["openai"]); + const plan = resolveAgentRuntimePluginLoadPlan({ + config: { plugins: { allow: ["codex"] } }, + workspaceDir: "/tmp/workspace", + selections: [{ provider: "openai", modelId: "gpt-5.5", runtime: "codex" }], + }); + + expect(plan.pluginIds).toEqual(["codex", "openai"]); + expect(plan.config?.plugins?.allow).toEqual(["codex", "openai"]); + expect(plan.config?.plugins?.entries).toMatchObject({ + codex: { enabled: true }, + openai: { enabled: true }, + }); }); it("reports a manifest-owned harness as statically available", () => { - mocks.resolveOwningPluginIdsForProvider.mockReturnValueOnce(undefined); - expect( resolveAgentHarnessRuntimeAvailability({ runtime: "codex", @@ -115,10 +158,7 @@ describe("ensureSelectedAgentHarnessPlugin", () => { payloadCheckedPluginIds: ["codex"], selectedPluginRootDirs: new Map([["codex", "/tmp/plugins/codex"]]), }), - ).toEqual({ - status: "available", - ownerPluginIds: ["codex"], - }); + ).toEqual({ status: "available", ownerPluginIds: ["codex"] }); }); it("reports a harness unavailable when no enabled owner plugin can activate", () => { @@ -141,14 +181,17 @@ describe("ensureSelectedAgentHarnessPlugin", () => { }); }); - it("reports a harness unavailable when startup quarantined an owner payload", () => { - mocks.resolveOwningPluginIdsForProvider.mockReturnValueOnce(undefined); - + it("reports a quarantined owner payload and ignores stale artifacts", () => { + const base = { + runtime: "codex", + provider: "openai", + workspaceDir: "/tmp/workspace", + payloadCheckedPluginIds: ["codex"], + selectedPluginRootDirs: new Map([["codex", "/tmp/plugins/codex"]]), + }; expect( resolveAgentHarnessRuntimeAvailability({ - runtime: "codex", - provider: "openai", - workspaceDir: "/tmp/workspace", + ...base, payloadFailures: [ { pluginId: "codex", @@ -156,25 +199,11 @@ describe("ensureSelectedAgentHarnessPlugin", () => { reason: "missing-package-dir", }, ], - payloadCheckedPluginIds: ["codex"], - selectedPluginRootDirs: new Map([["codex", "/tmp/plugins/codex"]]), }), - ).toEqual({ - status: "unavailable", - ownerPluginIds: ["codex"], - reason: "owner-plugin-degraded", - detail: 'Agent harness "codex" owner plugin "codex" is unavailable (missing-package-dir).', - }); - }); - - it("ignores a payload failure from a stale artifact with the same plugin id", () => { - mocks.resolveOwningPluginIdsForProvider.mockReturnValueOnce(undefined); - + ).toMatchObject({ status: "unavailable", reason: "owner-plugin-degraded" }); expect( resolveAgentHarnessRuntimeAvailability({ - runtime: "codex", - provider: "openai", - workspaceDir: "/tmp/workspace", + ...base, payloadFailures: [ { pluginId: "codex", @@ -182,18 +211,11 @@ describe("ensureSelectedAgentHarnessPlugin", () => { reason: "missing-package-dir", }, ], - payloadCheckedPluginIds: ["codex"], - selectedPluginRootDirs: new Map([["codex", "/tmp/plugins/active-codex"]]), }), - ).toEqual({ - status: "available", - ownerPluginIds: ["codex"], - }); + ).toEqual({ status: "available", ownerPluginIds: ["codex"] }); }); - it("reports a selected owner unavailable when its payload was not checked", () => { - mocks.resolveOwningPluginIdsForProvider.mockReturnValueOnce(undefined); - + it("reports an owner whose payload was not checked", () => { expect( resolveAgentHarnessRuntimeAvailability({ runtime: "codex", @@ -203,432 +225,22 @@ describe("ensureSelectedAgentHarnessPlugin", () => { payloadCheckedPluginIds: [], selectedPluginRootDirs: new Map([["codex", "/tmp/plugins/codex"]]), }), - ).toEqual({ - status: "unavailable", - ownerPluginIds: ["codex"], - reason: "owner-plugin-unverified", - detail: 'Agent harness "codex" owner plugin "codex" payload was not verified.', - }); + ).toMatchObject({ status: "unavailable", reason: "owner-plugin-unverified" }); }); - it("loads a session-pinned Codex harness for an unrelated outer provider", async () => { - await ensureSelectedAgentHarnessPlugin({ - provider: "anthropic", - modelId: "claude-opus-4-6", - agentHarnessId: "codex", - workspaceDir: "/tmp/workspace", - }); - - expect(mocks.resolveManifestActivationPlan).toHaveBeenCalledWith({ - trigger: { kind: "agentHarness", runtime: "codex" }, - config: undefined, - workspaceDir: "/tmp/workspace", - requireExplicitManifestOwnerTrust: true, - }); - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( - expect.objectContaining({ - scope: "all", + it("keeps a restrictive allowlist authoritative", () => { + const config = { plugins: { allow: ["telegram"] } } as OpenClawConfig; + mocks.resolveManifestActivationPlan.mockReturnValueOnce({ entries: [] }); + expect( + resolveAgentHarnessRuntimeAvailability({ + runtime: "codex", + provider: "openai", + config, workspaceDir: "/tmp/workspace", - onlyPluginIds: expect.arrayContaining(["codex"]), + payloadFailures: [], + payloadCheckedPluginIds: [], + selectedPluginRootDirs: new Map(), }), - ); - }); - - it("loads Codex and the provider owner for the implicit official OpenAI runtime before selection", async () => { - await ensureSelectedAgentHarnessPlugin({ - provider: "openai", - modelId: "gpt-5.5", - config: { - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - models: [], - }, - }, - }, - } as OpenClawConfig, - workspaceDir: "/tmp/workspace", - }); - - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( - expect.objectContaining({ - scope: "all", - workspaceDir: "/tmp/workspace", - onlyPluginIds: ["codex", "openai", "memory-core"], - }), - ); - }); - - it("loads a configured Copilot harness plugin before selection", async () => { - await ensureSelectedAgentHarnessPlugin({ - provider: "github-copilot", - modelId: "gpt-4o", - config: { - models: { - providers: { - "github-copilot": { - agentRuntime: { id: "copilot" }, - baseUrl: "https://api.githubcopilot.com", - models: [], - }, - }, - }, - } as OpenClawConfig, - workspaceDir: "/tmp/workspace", - }); - - expect(mocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled(); - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( - expect.objectContaining({ - scope: "all", - workspaceDir: "/tmp/workspace", - onlyPluginIds: ["copilot", "memory-core"], - config: expect.objectContaining({ - plugins: expect.objectContaining({ - allow: ["copilot", "memory-core"], - entries: expect.objectContaining({ - copilot: expect.objectContaining({ enabled: true }), - }), - }), - }), - }), - ); - }); - - it("loads a manifest-owned custom harness runtime before selection", async () => { - mocks.resolveManifestActivationPlan.mockReturnValueOnce({ - entries: [{ pluginId: "custom-harness-plugin", origin: "workspace" }], - }); - - await ensureSelectedAgentHarnessPlugin({ - provider: "custom-provider", - modelId: "custom-model", - config: { - plugins: { - entries: { - "custom-harness-plugin": { enabled: true }, - }, - }, - } as OpenClawConfig, - agentHarnessRuntimeOverride: "custom-harness", - workspaceDir: "/tmp/workspace", - }); - - expect(mocks.resolveManifestActivationPlan).toHaveBeenCalledWith({ - trigger: { kind: "agentHarness", runtime: "custom-harness" }, - config: expect.any(Object), - workspaceDir: "/tmp/workspace", - requireExplicitManifestOwnerTrust: true, - }); - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( - expect.objectContaining({ - scope: "all", - workspaceDir: "/tmp/workspace", - onlyPluginIds: ["custom-harness-plugin", "memory-core"], - }), - ); - }); - - it("does not activate an untrusted workspace harness from manifest metadata alone", async () => { - mocks.resolveManifestActivationPlan.mockReturnValueOnce({ - entries: [], - }); - - await ensureSelectedAgentHarnessPlugin({ - provider: "custom-provider", - modelId: "custom-model", - agentHarnessRuntimeOverride: "custom-harness", - workspaceDir: "/tmp/workspace", - }); - - expect(mocks.resolveManifestActivationPlan).toHaveBeenCalledWith({ - trigger: { kind: "agentHarness", runtime: "custom-harness" }, - config: undefined, - workspaceDir: "/tmp/workspace", - requireExplicitManifestOwnerTrust: true, - }); - expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); - }); - - it("does not bypass a restrictive allowlist that omits a configured Copilot harness", async () => { - // A configured harness can request loading, but explicit plugin allowlists - // remain the operator's boundary and are not widened implicitly. - await ensureSelectedAgentHarnessPlugin({ - provider: "github-copilot", - modelId: "gpt-4o", - config: { - plugins: { - allow: ["telegram"], - entries: { - telegram: { enabled: true }, - }, - }, - models: { - providers: { - "github-copilot": { - agentRuntime: { id: "copilot" }, - baseUrl: "https://api.githubcopilot.com", - models: [], - }, - }, - }, - } as OpenClawConfig, - workspaceDir: "/tmp/workspace", - }); - - expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); - }); - - it("widens a scoped harness allowlist with the provider owner for openai models", async () => { - await ensureSelectedAgentHarnessPlugin({ - provider: "openai", - modelId: "gpt-5.5-pro", - config: { - plugins: { - allow: ["codex"], - entries: { - codex: { enabled: true }, - }, - }, - } as OpenClawConfig, - workspaceDir: "/tmp/workspace", - }); - - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( - expect.objectContaining({ - scope: "all", - workspaceDir: "/tmp/workspace", - onlyPluginIds: ["codex", "openai"], - config: expect.objectContaining({ - plugins: expect.objectContaining({ - allow: ["codex", "openai"], - entries: expect.objectContaining({ - codex: expect.objectContaining({ enabled: true }), - openai: expect.objectContaining({ enabled: true }), - }), - }), - }), - }), - ); - }); - - it("keeps an allowed memory slot plugin in Codex harness scoped loads", async () => { - await ensureSelectedAgentHarnessPlugin({ - provider: "openai", - modelId: "gpt-5.5-pro", - config: { - plugins: { - allow: ["codex", "openai", "memory-core"], - entries: { - codex: { enabled: true }, - openai: { enabled: true }, - }, - }, - } as OpenClawConfig, - workspaceDir: "/tmp/workspace", - }); - - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( - expect.objectContaining({ - scope: "all", - workspaceDir: "/tmp/workspace", - onlyPluginIds: ["codex", "openai", "memory-core"], - config: expect.objectContaining({ - plugins: expect.objectContaining({ - allow: ["codex", "openai", "memory-core"], - entries: expect.objectContaining({ - codex: expect.objectContaining({ enabled: true }), - openai: expect.objectContaining({ enabled: true }), - "memory-core": expect.objectContaining({ enabled: true }), - }), - }), - }), - }), - ); - }); - - it("does not auto-activate an untrusted workspace memory slot plugin", async () => { - await ensureSelectedAgentHarnessPlugin({ - provider: "openai", - modelId: "gpt-5.5-pro", - config: { - plugins: { - slots: { memory: "workspace-memory" }, - }, - } as OpenClawConfig, - workspaceDir: "/tmp/workspace", - }); - - expect(mocks.resolveActivatableProviderOwnerPluginIds).toHaveBeenCalledWith({ - pluginIds: ["openai"], - config: expect.any(Object), - workspaceDir: "/tmp/workspace", - }); - expect(mocks.resolveActivatableProviderOwnerPluginIds).not.toHaveBeenCalledWith( - expect.objectContaining({ pluginIds: ["workspace-memory"] }), - ); - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( - expect.objectContaining({ - scope: "all", - workspaceDir: "/tmp/workspace", - onlyPluginIds: ["codex", "openai"], - config: expect.objectContaining({ - plugins: expect.objectContaining({ - entries: expect.not.objectContaining({ - "workspace-memory": expect.anything(), - }), - }), - }), - }), - ); - }); - - it("does not auto-activate untrusted provider owners for Codex harness loads", async () => { - // Provider owner activation is limited to bundled-compatible/activatable - // owners so workspace plugins are not enabled just because Codex was chosen. - mocks.resolveOwningPluginIdsForProvider.mockReturnValueOnce(["openai", "workspace-openai"]); - mocks.resolveBundledProviderCompatPluginIds.mockReturnValueOnce(["openai"]); - mocks.resolveActivatableProviderOwnerPluginIds.mockReturnValueOnce([]); - - await ensureSelectedAgentHarnessPlugin({ - provider: "openai", - modelId: "gpt-5.5-pro", - config: { - plugins: { - allow: ["codex"], - entries: { - codex: { enabled: true }, - }, - }, - } as OpenClawConfig, - workspaceDir: "/tmp/workspace", - }); - - expect(mocks.resolveBundledProviderCompatPluginIds).toHaveBeenCalledWith({ - config: expect.any(Object), - workspaceDir: "/tmp/workspace", - onlyPluginIds: ["openai", "workspace-openai"], - }); - expect(mocks.resolveActivatableProviderOwnerPluginIds).toHaveBeenCalledWith({ - pluginIds: ["openai", "workspace-openai"], - config: expect.any(Object), - workspaceDir: "/tmp/workspace", - }); - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( - expect.objectContaining({ - scope: "all", - workspaceDir: "/tmp/workspace", - onlyPluginIds: ["codex", "openai"], - }), - ); - }); - - it("does not bypass a restrictive allowlist that omits the Codex harness", async () => { - await ensureSelectedAgentHarnessPlugin({ - provider: "openai", - modelId: "gpt-5.5-pro", - config: { - plugins: { - allow: ["telegram"], - entries: { - telegram: { enabled: true }, - }, - }, - } as OpenClawConfig, - workspaceDir: "/tmp/workspace", - }); - - expect(mocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled(); - expect(mocks.resolveBundledProviderCompatPluginIds).not.toHaveBeenCalled(); - expect(mocks.resolveActivatableProviderOwnerPluginIds).not.toHaveBeenCalled(); - expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); - }); - - it("keeps real bundled memory-core in a Codex scoped load when the provider has no owner plugin", async () => { - mocks.resolveOwningPluginIdsForProvider.mockReturnValueOnce(undefined); - - await ensureSelectedAgentHarnessPlugin({ - provider: "custom-provider", - modelId: "gpt-5.5", - agentHarnessRuntimeOverride: "codex", - workspaceDir: "/tmp/workspace", - }); - - expect(mocks.resolveBundledProviderCompatPluginIds).not.toHaveBeenCalled(); - expect(mocks.resolveActivatableProviderOwnerPluginIds).not.toHaveBeenCalled(); - expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith( - expect.objectContaining({ - scope: "all", - workspaceDir: "/tmp/workspace", - onlyPluginIds: ["codex", "memory-core"], - }), - ); - }); - - it("keeps custom OpenAI-compatible providers on embedded OpenClaw when no runtime override is set", async () => { - await ensureSelectedAgentHarnessPlugin({ - provider: "openai", - modelId: "gpt-5.5", - config: { - models: { - providers: { - openai: { - baseUrl: "https://openai-compatible.example.test/v1", - models: [], - }, - }, - }, - } as OpenClawConfig, - workspaceDir: "/tmp/workspace", - }); - - expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); - expect(mocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled(); - }); - - it("keeps official OpenAI providers on embedded OpenClaw when explicitly configured", async () => { - await ensureSelectedAgentHarnessPlugin({ - provider: "openai", - modelId: "gpt-5.2", - config: { - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - agentRuntime: { id: "openclaw" }, - models: [], - }, - }, - }, - } as OpenClawConfig, - workspaceDir: "/tmp/workspace", - }); - - expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); - expect(mocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled(); - }); - - it("does not treat CLI backend runtime aliases as plugin ids", async () => { - await ensureSelectedAgentHarnessPlugin({ - provider: "anthropic", - modelId: "claude-opus-4-7", - config: { - models: { - providers: { - anthropic: { - agentRuntime: { id: "claude-cli" }, - baseUrl: "https://api.anthropic.com", - models: [], - }, - }, - }, - } as OpenClawConfig, - workspaceDir: "/tmp/workspace", - }); - - expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); - expect(mocks.resolveOwningPluginIdsForProvider).not.toHaveBeenCalled(); - expect(mocks.resolveManifestActivationPlan).not.toHaveBeenCalled(); + ).toMatchObject({ status: "unavailable", ownerPluginIds: [] }); }); }); diff --git a/src/agents/harness/runtime-plugin.ts b/src/agents/harness/runtime-plugin.ts index 299093c40e12..01d0afd0e6de 100644 --- a/src/agents/harness/runtime-plugin.ts +++ b/src/agents/harness/runtime-plugin.ts @@ -1,21 +1,7 @@ -/** - * Ensures runtime plugins required by selected native harnesses are installed. - */ +/** Resolves the selected native harness from a run-owned plugin registry. */ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { ProviderRouteOverridePresence } from "../../plugin-sdk/provider-model-types.js"; -import { withActivatedPluginIds } from "../../plugins/activation-context.js"; -import { resolveManifestActivationPlan } from "../../plugins/activation-planner.js"; -import { resolveEffectivePluginActivationState } from "../../plugins/config-state.js"; -import { isPluginEnabledByDefaultForPlatform } from "../../plugins/default-enablement.js"; -import { - loadPluginRegistrySnapshot, - normalizePluginsConfigWithRegistry, -} from "../../plugins/plugin-registry.js"; -import { - resolveActivatableProviderOwnerPluginIds, - resolveBundledProviderCompatPluginIds, - resolveOwningPluginIdsForProviderRef, -} from "../../plugins/providers.js"; +import type { PluginRegistry } from "../../plugins/registry-types.js"; import { pluginInstallPathMatchesRoot, type PluginVerificationFailureReason, @@ -24,115 +10,9 @@ import { isDefaultAgentRuntimeId, OPENCLAW_AGENT_RUNTIME_ID } from "../agent-run import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; import { isCliRuntimeAliasForProvider } from "../model-runtime-aliases.js"; import { resolveAgentHarnessPolicy } from "./policy.js"; +import { resolveAgentHarnessOwnerPluginIds } from "./runtime-plugin-load-plan.js"; -function dedupePluginIds(values: readonly string[]): string[] { - const seen = new Set(); - const result: string[] = []; - for (const value of values) { - const pluginId = value.trim(); - if (!pluginId || seen.has(pluginId)) { - continue; - } - seen.add(pluginId); - result.push(pluginId); - } - return result; -} - -function restrictiveAllowlistOmitsPlugin(config: OpenClawConfig | undefined, pluginId: string) { - const allow = config?.plugins?.allow ?? []; - return allow.length > 0 && !allow.includes(pluginId); -} - -function resolveSelectedMemoryPluginIds(params: { - config: OpenClawConfig | undefined; - workspaceDir: string; -}): string[] { - const registry = loadPluginRegistrySnapshot({ - config: params.config, - workspaceDir: params.workspaceDir, - }); - const plugins = normalizePluginsConfigWithRegistry(params.config?.plugins, registry); - const memorySlot = plugins.slots.memory; - if ( - typeof memorySlot !== "string" || - memorySlot.trim().length === 0 || - restrictiveAllowlistOmitsPlugin(params.config, memorySlot) - ) { - return []; - } - const plugin = registry.plugins.find((entry) => entry.pluginId === memorySlot); - if (!plugin?.startup.memory) { - return []; - } - const activationState = resolveEffectivePluginActivationState({ - id: plugin.pluginId, - origin: plugin.origin, - config: plugins, - rootConfig: params.config, - enabledByDefault: isPluginEnabledByDefaultForPlatform(plugin), - }); - return activationState.activated ? [plugin.pluginId] : []; -} - -/** Resolve manifest owners required by one selected non-core harness runtime. */ -export function resolveAgentHarnessOwnerPluginIds(params: { - runtime: string; - provider: string; - config?: OpenClawConfig; - workspaceDir: string; -}): string[] { - const activationPlan = resolveManifestActivationPlan({ - trigger: { kind: "agentHarness", runtime: params.runtime }, - config: params.config, - workspaceDir: params.workspaceDir, - requireExplicitManifestOwnerTrust: true, - }); - const harnessPluginIds = activationPlan.entries.map((entry) => entry.pluginId); - if (harnessPluginIds.length === 0) { - return []; - } - if (params.runtime !== "codex") { - return harnessPluginIds; - } - if (!harnessPluginIds.includes("codex")) { - return harnessPluginIds; - } - if (restrictiveAllowlistOmitsPlugin(params.config, "codex")) { - // Respect a restrictive allowlist even when Codex would normally pull in provider owner - // plugins. Operators who set an allowlist expect no implicit plugin expansion. - return harnessPluginIds; - } - const providerOwnerPluginIds = dedupePluginIds( - resolveOwningPluginIdsForProviderRef({ - provider: params.provider, - config: params.config, - workspaceDir: params.workspaceDir, - }) ?? [], - ); - if (providerOwnerPluginIds.length === 0) { - return harnessPluginIds; - } - const safeProviderOwnerPluginIds = dedupePluginIds([ - ...resolveBundledProviderCompatPluginIds({ - config: params.config, - workspaceDir: params.workspaceDir, - onlyPluginIds: providerOwnerPluginIds, - }), - ...resolveActivatableProviderOwnerPluginIds({ - pluginIds: providerOwnerPluginIds, - config: params.config, - workspaceDir: params.workspaceDir, - }), - ]); - return dedupePluginIds([ - "codex", - ...harnessPluginIds, - ...providerOwnerPluginIds.filter( - (pluginId) => pluginId !== "codex" && safeProviderOwnerPluginIds.includes(pluginId), - ), - ]); -} +export { resolveAgentHarnessOwnerPluginIds } from "./runtime-plugin-load-plan.js"; export type AgentHarnessRuntimeAvailability = | { @@ -146,7 +26,7 @@ export type AgentHarnessRuntimeAvailability = detail: string; }; -export type AgentHarnessRuntimePayloadFailure = { +type AgentHarnessRuntimePayloadFailure = { pluginId: string; installPath?: string; reason: PluginVerificationFailureReason; @@ -210,28 +90,7 @@ export function resolveAgentHarnessRuntimeAvailability(params: { return { status: "available", ownerPluginIds }; } -function withRuntimePluginIdsAllowed(params: { - config?: OpenClawConfig; - requiredPluginId: string; - pluginIds: readonly string[]; -}): OpenClawConfig | undefined { - if (params.pluginIds.length === 0) { - return params.config; - } - if (restrictiveAllowlistOmitsPlugin(params.config, params.requiredPluginId)) { - return params.config; - } - const allow = dedupePluginIds([...(params.config?.plugins?.allow ?? []), ...params.pluginIds]); - return { - ...params.config, - plugins: { - ...params.config?.plugins, - allow, - }, - }; -} - -/** Ensures the plugin that owns the selected harness runtime is loaded before harness selection. */ +/** Resolves the selected harness from the run-owned registry without loading or activating. */ export async function ensureSelectedAgentHarnessPlugin(params: { provider: string; modelId: string; @@ -242,6 +101,7 @@ export async function ensureSelectedAgentHarnessPlugin(params: { agentHarnessRuntimeOverride?: string; requestTransportOverrides?: ProviderRouteOverridePresence; workspaceDir: string; + pluginRegistry: PluginRegistry | undefined; }): Promise { const pinnedHarnessId = normalizeOptionalAgentRuntimeId(params.agentHarnessId); const runtimeOverride = normalizeOptionalAgentRuntimeId(params.agentHarnessRuntimeOverride); @@ -270,41 +130,7 @@ export async function ensureSelectedAgentHarnessPlugin(params: { return; } - const { ensurePluginRegistryLoaded } = - await import("../../plugins/runtime/runtime-registry-loader.js"); - const pluginIds = resolveAgentHarnessOwnerPluginIds({ - runtime, - provider: params.provider, - config: params.config, - workspaceDir: params.workspaceDir, - }); - if (pluginIds.length === 0) { - return; + if (!params.pluginRegistry?.agentHarnesses.some((entry) => entry.harness.id === runtime)) { + throw new Error(`Agent harness runtime "${runtime}" is not present in the prepared registry.`); } - const memoryPluginIds = resolveSelectedMemoryPluginIds({ - config: params.config, - workspaceDir: params.workspaceDir, - }); - const scopedPluginIds = dedupePluginIds([...pluginIds, ...memoryPluginIds]); - const configWithAllowedRuntimePlugins = withRuntimePluginIdsAllowed({ - config: params.config, - requiredPluginId: runtime, - pluginIds: scopedPluginIds, - }); - const activatedConfig = - withActivatedPluginIds({ - config: configWithAllowedRuntimePlugins, - pluginIds: scopedPluginIds, - }) ?? configWithAllowedRuntimePlugins; - ensurePluginRegistryLoaded({ - scope: "all", - ...(activatedConfig - ? { - config: activatedConfig, - activationSourceConfig: activatedConfig, - } - : {}), - workspaceDir: params.workspaceDir, - onlyPluginIds: scopedPluginIds, - }); } diff --git a/src/agents/isolated-completion.test.ts b/src/agents/isolated-completion.test.ts index cbf58728e0b6..52e5cf5fbf4c 100644 --- a/src/agents/isolated-completion.test.ts +++ b/src/agents/isolated-completion.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AssistantMessage } from "../llm/types.js"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { mintSecretSentinel } from "../secrets/sentinel.js"; import type { AgentHarness } from "./harness/types.js"; const mocks = vi.hoisted(() => ({ + acquireAgentRunPreparedModelRuntime: vi.fn(), ensureSelectedAgentHarnessPlugin: vi.fn(async () => {}), getRegisteredAgentHarness: vi.fn(), isCliRuntimeAliasForProvider: vi.fn(() => false), @@ -40,6 +42,9 @@ vi.mock("./model-runtime-aliases.js", () => ({ isCliRuntimeAliasForProvider: mocks.isCliRuntimeAliasForProvider, resolveCliRuntimeExecutionProvider: mocks.resolveCliRuntimeExecutionProvider, })); +vi.mock("./prepared-model-runtime.js", () => ({ + acquireAgentRunPreparedModelRuntime: mocks.acquireAgentRunPreparedModelRuntime, +})); vi.mock("./simple-completion-runtime.js", () => ({ prepareSimpleCompletionModel: mocks.prepareSimpleCompletionModel, })); @@ -94,6 +99,10 @@ function request() { beforeEach(() => { vi.clearAllMocks(); + mocks.acquireAgentRunPreparedModelRuntime.mockResolvedValue({ + snapshot: { pluginRegistry: createEmptyPluginRegistry() }, + release: vi.fn(), + }); mocks.isCliRuntimeAliasForProvider.mockReturnValue(false); mocks.resolveCliRuntimeExecutionProvider.mockReturnValue(undefined); mocks.resolveEmbeddedCliBackendDispatchEligibility.mockReturnValue(undefined); diff --git a/src/agents/isolated-completion.ts b/src/agents/isolated-completion.ts index f00c9e4291fd..7eef0d29a640 100644 --- a/src/agents/isolated-completion.ts +++ b/src/agents/isolated-completion.ts @@ -11,6 +11,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { withTempWorkspace } from "../infra/private-temp-workspace.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import type { AssistantMessage } from "../llm/types.js"; +import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js"; import { resolveAgentDir, resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js"; import { resolveCliBackendConfig, resolveCliRuntimeCanonicalProvider } from "./cli-backends.js"; import { normalizeCliModel } from "./cli-runner/helpers.js"; @@ -22,6 +23,7 @@ import { isCliRuntimeAliasForProvider, resolveCliRuntimeExecutionProvider, } from "./model-runtime-aliases.js"; +import { acquireAgentRunPreparedModelRuntime } from "./prepared-model-runtime.js"; import { unwrapModelHeaderSentinelsForProviderEgress, unwrapSecretSentinelsForProviderEgress, @@ -317,93 +319,117 @@ export async function runIsolatedCompletion( config, includeSetupRegistry: true, }) ?? request.provider; - - await ensureSelectedAgentHarnessPlugin({ - provider, - modelId: request.model, + const lease = await acquireAgentRunPreparedModelRuntime({ config, agentId, - agentHarnessId: request.agentHarnessRuntimeOverride, - agentHarnessRuntimeOverride: request.agentHarnessRuntimeOverride, - workspaceDir, - }); - const runtime = - request.agentHarnessRuntimeOverride ?? - resolveEffectiveAgentRuntime({ cfg: config, provider, modelId: request.model, agentId }); - const cliOwner = resolveCliOwner({ - request, - provider, - runtime, - agentId, agentDir, workspaceDir, + runtimePluginSelections: [ + { + provider, + modelId: request.model, + ...(request.agentHarnessRuntimeOverride + ? { runtime: request.agentHarnessRuntimeOverride } + : {}), + agentId, + }, + ], }); - if (cliOwner) { - const completion = await runCliIsolatedCompletion({ - request, - provider: cliOwner, - modelProvider: provider, - agentId, - agentDir, - workspaceDir, - }); - return { - text: completion.text, - provider, - model: completion.model, - owner: { kind: "cli", id: cliOwner }, + const pluginRegistry = lease.snapshot.pluginRegistry; + try { + const run = async (): Promise => { + await ensureSelectedAgentHarnessPlugin({ + provider, + modelId: request.model, + config, + agentId, + agentHarnessId: request.agentHarnessRuntimeOverride, + agentHarnessRuntimeOverride: request.agentHarnessRuntimeOverride, + workspaceDir, + pluginRegistry, + }); + const runtime = + request.agentHarnessRuntimeOverride ?? + resolveEffectiveAgentRuntime({ cfg: config, provider, modelId: request.model, agentId }); + const cliOwner = resolveCliOwner({ + request, + provider, + runtime, + agentId, + agentDir, + workspaceDir, + }); + if (cliOwner) { + const completion = await runCliIsolatedCompletion({ + request, + provider: cliOwner, + modelProvider: provider, + agentId, + agentDir, + workspaceDir, + }); + return { + text: completion.text, + provider, + model: completion.model, + owner: { kind: "cli", id: cliOwner }, + }; + } + + const harness = await resolveHarness(runtime); + if (!harness.runIsolatedCompletion) { + throw new IsolatedCompletionError( + "unsupported", + `Agent harness ${harness.id} does not support isolated completion.`, + ); + } + const prepared = await prepareSimpleCompletionModel({ + cfg: config, + agentId, + provider, + modelId: request.model, + agentDir, + profileId: request.authProfileId, + allowMissingApiKeyModes: ["aws-sdk"], + allowBundledStaticCatalogFallback: true, + skipAgentDiscovery: true, + bindAuthOwner: true, + }); + if ("error" in prepared) { + throw new Error(`Isolated completion preparation failed: ${prepared.error}`); + } + const harnessParams: AgentHarnessIsolatedCompletionParams = { + provider, + modelId: request.model, + model: prepared.model, + auth: prepared.auth, + ...(prepared.sourceAuthFingerprint + ? { sourceAuthFingerprint: prepared.sourceAuthFingerprint } + : {}), + config, + agentId, + agentDir, + workspaceDir, + systemPrompt: request.systemPrompt, + prompt: request.prompt, + timeoutMs: request.timeoutMs, + abortSignal: request.abortSignal, + thinkLevel: request.thinkLevel, + streamParams: request.streamParams, + }; + const result = await harness.runIsolatedCompletion( + prepareIsolatedHarnessParams(harness, harnessParams), + ); + return { + text: requireIsolatedAssistantText(result.assistant), + provider: result.assistant.provider, + model: result.assistant.model, + owner: { kind: "harness", id: harness.id }, + usage: result.assistant.usage, + }; }; + return await withPluginRuntimeRegistryScope(pluginRegistry, run); + } finally { + lease.release(); } - - const harness = await resolveHarness(runtime); - if (!harness.runIsolatedCompletion) { - throw new IsolatedCompletionError( - "unsupported", - `Agent harness ${harness.id} does not support isolated completion.`, - ); - } - const prepared = await prepareSimpleCompletionModel({ - cfg: config, - agentId, - provider, - modelId: request.model, - agentDir, - profileId: request.authProfileId, - allowMissingApiKeyModes: ["aws-sdk"], - allowBundledStaticCatalogFallback: true, - skipAgentDiscovery: true, - bindAuthOwner: true, - }); - if ("error" in prepared) { - throw new Error(`Isolated completion preparation failed: ${prepared.error}`); - } - const harnessParams: AgentHarnessIsolatedCompletionParams = { - provider, - modelId: request.model, - model: prepared.model, - auth: prepared.auth, - ...(prepared.sourceAuthFingerprint - ? { sourceAuthFingerprint: prepared.sourceAuthFingerprint } - : {}), - config, - agentId, - agentDir, - workspaceDir, - systemPrompt: request.systemPrompt, - prompt: request.prompt, - timeoutMs: request.timeoutMs, - abortSignal: request.abortSignal, - thinkLevel: request.thinkLevel, - streamParams: request.streamParams, - }; - const result = await harness.runIsolatedCompletion( - prepareIsolatedHarnessParams(harness, harnessParams), - ); - return { - text: requireIsolatedAssistantText(result.assistant), - provider: result.assistant.provider, - model: result.assistant.model, - owner: { kind: "harness", id: harness.id }, - usage: result.assistant.usage, - }; } diff --git a/src/agents/main-session-restart-dispatch.ts b/src/agents/main-session-restart-dispatch.ts index a9ef1d008672..3c5efbaa2f4d 100644 --- a/src/agents/main-session-restart-dispatch.ts +++ b/src/agents/main-session-restart-dispatch.ts @@ -15,6 +15,7 @@ import type { GatewayRecoveryRuntime } from "../gateway/server-instance-runtime. import { getAgentEventLifecycleGeneration } from "../infra/agent-events.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { findRestartRecoveryUnsafeReplyHook } from "../plugins/restart-recovery-hook-safety.js"; +import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js"; import { CommandLane } from "../process/lanes.js"; import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { MAIN_SESSION_RESTART_RECOVERY_SOURCE_TOOL } from "../sessions/input-provenance.js"; @@ -39,7 +40,7 @@ import { type MainSessionRecoveryReservation, } from "./main-session-recovery-state.js"; import { commitMainSessionRecovery } from "./main-session-recovery-store.js"; -import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js"; +import { loadAgentRuntimePluginRegistryHandle } from "./runtime-plugins.js"; const log = createSubsystemLogger("main-session-restart-recovery"); const RESTART_RECOVERY_RESUME_MESSAGE = @@ -95,12 +96,13 @@ export function resolveRestartRecoveryResumeBlockReason(params: { if (!params.cfg) { return "pre-hook recovery runtime config is unavailable"; } + let pluginRegistry: ReturnType; try { const agentId = resolveAgentIdFromSessionKey( params.sessionKey, resolveDefaultAgentId(params.cfg), ); - ensureRuntimePluginsLoaded({ + pluginRegistry = loadAgentRuntimePluginRegistryHandle({ config: params.cfg, workspaceDir: resolveAgentWorkspaceDir(params.cfg, agentId), allowGatewaySubagentBinding: true, @@ -108,10 +110,15 @@ export function resolveRestartRecoveryResumeBlockReason(params: { } catch { return "pre-hook recovery runtime plugins could not be loaded"; } + if (!pluginRegistry) { + return "pre-hook recovery runtime plugins could not be loaded"; + } // A stored hook result proves that invocation completed, but not that the // same plugin code and config are still loaded after restart. Fail closed // until hook activation owns a stable cross-process implementation digest. - const unsafeHook = findRestartRecoveryUnsafeReplyHook({ trigger: "user" }); + const unsafeHook = withPluginRuntimeRegistryScope(pluginRegistry, () => + findRestartRecoveryUnsafeReplyHook({ trigger: "user" }), + ); return unsafeHook ? `pre-hook recovery cannot bypass the active ${unsafeHook} hook` : undefined; } diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index b4059581146c..16820774fcd1 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -32,6 +32,7 @@ import { import { addTestHook } from "../plugins/hooks.test-fixtures.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; import { getActiveGatewayRootWorkCount, resetGatewayWorkAdmission, @@ -87,8 +88,9 @@ const transcriptMocks = vi.hoisted(() => ({ appendAssistantMessageToSessionTranscript: vi.fn(), })); const runtimePluginMocks = vi.hoisted(() => ({ - ensureRuntimePluginsLoaded: vi.fn(), + loadAgentRuntimePluginRegistryHandle: vi.fn(), findRestartRecoveryUnsafeReplyHook: vi.fn<(ctx: { trigger?: string }) => string | undefined>(), + pluginRegistry: undefined as ReturnType | undefined, })); const discordDeliveryContext = { channel: "discord", @@ -148,14 +150,14 @@ vi.mock("../config/sessions/transcript.js", async (importOriginal) => { }; }); -vi.mock("./runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded, -})); - vi.mock("../plugins/restart-recovery-hook-safety.js", () => ({ findRestartRecoveryUnsafeReplyHook: runtimePluginMocks.findRestartRecoveryUnsafeReplyHook, })); +vi.mock("./runtime-plugins.js", () => ({ + loadAgentRuntimePluginRegistryHandle: runtimePluginMocks.loadAgentRuntimePluginRegistryHandle, +})); + let tmpDir: string; function loadSessionEntry( @@ -168,6 +170,10 @@ beforeEach(async () => { vi.clearAllMocks(); vi.mocked(callGateway).mockReset(); vi.mocked(callGateway).mockImplementation(async () => ({ runId: "run-resumed" })); + runtimePluginMocks.pluginRegistry = createEmptyPluginRegistry(); + runtimePluginMocks.loadAgentRuntimePluginRegistryHandle.mockReturnValue( + runtimePluginMocks.pluginRegistry, + ); runtimePluginMocks.findRestartRecoveryUnsafeReplyHook.mockReturnValue(undefined); resetAgentEventsForTest(); resetGatewayWorkAdmission(); @@ -3494,11 +3500,20 @@ describe("main-session-restart-recovery", () => { }, ]); + runtimePluginMocks.findRestartRecoveryUnsafeReplyHook.mockImplementationOnce(() => { + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe( + runtimePluginMocks.pluginRegistry, + ); + return undefined; + }); + await expectRecovery({ recovered: 1, failed: 0, skipped: 0 }, {}); - expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith( - expect.objectContaining({ config: {}, allowGatewaySubagentBinding: true }), - ); + expect(runtimePluginMocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledWith({ + config: {}, + workspaceDir: expect.any(String), + allowGatewaySubagentBinding: true, + }); expect(runtimePluginMocks.findRestartRecoveryUnsafeReplyHook).toHaveBeenCalledOnce(); expect(vi.mocked(callGateway).mock.calls[0]?.[0]).toMatchObject({ method: "agent" }); expect(gatewayParams()).toMatchObject({ @@ -3507,6 +3522,35 @@ describe("main-session-restart-recovery", () => { }); }); + it("fails closed when the restart recovery registry handle is unavailable", async () => { + const sessionsDir = await makeSessionsDir(); + const storePath = path.join(sessionsDir, "sessions.json"); + const sessionKey = "agent:main:main"; + runtimePluginMocks.loadAgentRuntimePluginRegistryHandle.mockReturnValue(undefined); + await writeMainSession({ + sessionsDir, + sessionKey, + restartRecoveryBeforeAgentReplyState: "admitted", + restartRecoveryDeliveryRequestFingerprint: "request-fingerprint", + restartRecoveryDeliveryRunId: "control-ui-run", + restartRecoveryDeliverySourceRunId: "control-ui-run", + restartRecoverySourceIngress: "control-ui", + }); + await writeTranscript(sessionsDir, "main-session", [ + { + role: "user", + content: "do the thing", + idempotencyKey: "control-ui-run:user", + }, + ]); + + await expectRecovery({ recovered: 0, failed: 1, skipped: 0 }, {}); + + expect(runtimePluginMocks.findRestartRecoveryUnsafeReplyHook).not.toHaveBeenCalled(); + expect(callGateway).not.toHaveBeenCalled(); + expect(loadSessionEntry({ sessionKey, storePath })?.status).toBe("failed"); + }); + it("fails a pre-hook Control UI recovery when a runtime hook is active", async () => { const sessionsDir = await makeSessionsDir(); const storePath = path.join(sessionsDir, "sessions.json"); diff --git a/src/agents/mcp-connection-resolver.test.ts b/src/agents/mcp-connection-resolver.test.ts index afa62c06b89b..326071f47aa1 100644 --- a/src/agents/mcp-connection-resolver.test.ts +++ b/src/agents/mcp-connection-resolver.test.ts @@ -569,6 +569,7 @@ describe("mcp connection resolver helpers", () => { await expect(gatewayReload.applyHotReload(reloadPlan, nextConfig)).resolves.toBeUndefined(); expect(refreshPreparedModelRuntimeSnapshots).toHaveBeenCalledWith(nextConfig, { + allowGatewaySubagentBinding: true, catalogMode: "static", }); expect(refreshContextWindowCache).toHaveBeenCalledWith(nextConfig); diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts index e34038b10cb0..f2034668d918 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.test-harness.ts @@ -236,7 +236,7 @@ export async function getSessionsSpawnTool(opts: CreateOpenClawToolsOpts) { getRuntimeConfig: () => hoisted.state.configOverride, cleanupBrowserSessionsForLifecycleEnd: async () => {}, ensureContextEnginesInitialized: () => {}, - ensureRuntimePluginsLoaded: () => {}, + loadAgentRuntimePluginRegistryHandle: () => undefined, persistSubagentRunsToDisk: () => { hoisted.notifyEventWaiters(); }, diff --git a/src/agents/prepared-model-catalog.ts b/src/agents/prepared-model-catalog.ts index 6449032b0955..aa6d00a4fe14 100644 --- a/src/agents/prepared-model-catalog.ts +++ b/src/agents/prepared-model-catalog.ts @@ -34,6 +34,7 @@ export type LoadPreparedModelCatalogParams = { workspaceDir?: string; env?: NodeJS.ProcessEnv; providerDiscoveryProviderIds?: readonly string[]; + allowGatewaySubagentBinding?: boolean; }; type PreparedModelCatalogConfigPolicy = "exact" | "published"; @@ -96,6 +97,7 @@ function resolveInputs(params: LoadPreparedModelCatalogParams = {}): { ...(params.env ? { env: params.env } : {}), inheritedAuthDir: resolveDefaultAgentDir(config, params.env), ...(explicitWorkspaceDir ? { workspaceDir: explicitWorkspaceDir } : {}), + ...(params.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}), }; const exact = params.readOnly ? { ...full, readOnly: true } : full; const activationFull = activationWorkspaceDir diff --git a/src/agents/prepared-model-runtime.build.ts b/src/agents/prepared-model-runtime.build.ts index 62924bc82715..b35e81fce15e 100644 --- a/src/agents/prepared-model-runtime.build.ts +++ b/src/agents/prepared-model-runtime.build.ts @@ -162,7 +162,8 @@ function createSnapshot( catalogAccess: PreparedModelRuntimeCatalogAccess, ): PreparedModelRuntimeSnapshot { const { credentials, input } = agentFacts; - const { mediaCapabilityProviders, messageToolCatalog, pluginMetadataSnapshot } = workspaceFacts; + const { mediaCapabilityProviders, messageToolCatalog, pluginMetadataSnapshot, pluginRegistry } = + workspaceFacts; const { configuredRuntimeModels, inlineProviderModels, modelCatalog, templateModelRegistry } = catalogFacts; const createStores = (): PreparedModelRuntimeStores => { @@ -179,6 +180,8 @@ function createSnapshot( ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), config: input.config, metadataSnapshot: pluginMetadataSnapshot, + allowGatewaySubagentBinding: input.allowGatewaySubagentBinding === true, + ...(pluginRegistry ? { pluginRegistry } : {}), ...(messageToolCatalog ? { messageToolCatalog } : {}), ...(mediaCapabilityProviders ? { mediaCapabilityProviders } : {}), modelCatalog, diff --git a/src/agents/prepared-model-runtime.facts.ts b/src/agents/prepared-model-runtime.facts.ts index 749e9c438b78..c7b910729674 100644 --- a/src/agents/prepared-model-runtime.facts.ts +++ b/src/agents/prepared-model-runtime.facts.ts @@ -15,6 +15,7 @@ import { } from "../plugins/prepared-message-tool-catalog.js"; import type { PreparedProviderStaticCatalog } from "../plugins/provider-discovery.js"; import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js"; +import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js"; import { resolveRuntimeSyntheticAuthProviderRefs } from "../plugins/synthetic-auth.runtime.js"; import type { ProviderPlugin } from "../plugins/types.js"; import type { AgentCredentialMap } from "./agent-auth-credentials.js"; @@ -56,7 +57,7 @@ import type { PreparedModelRuntimeCatalogMode, PreparedModelRuntimeInput, } from "./prepared-model-runtime.types.js"; -import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js"; +import { loadAgentRuntimePluginRegistryHandle } from "./runtime-plugins.js"; import type { AuthStorage, AuthStorageData } from "./sessions/auth-storage.js"; import type { ModelRegistry } from "./sessions/model-registry.js"; import { stableStringify } from "./stable-stringify.js"; @@ -87,6 +88,7 @@ export type PreparedModelRuntimeWorkspaceFacts = { providerStaticModelsComplete: boolean; inlineProviderModels: readonly InlineModelEntry[]; configuredCatalogEntries: readonly ModelCatalogEntry[]; + pluginRegistry?: import("../plugins/registry-types.js").PluginRegistry; }; export type PreparedModelRuntimeCatalogFacts = { @@ -195,6 +197,8 @@ export function preparedModelRuntimeWorkspaceFactsKey(input: PreparedModelRuntim env: hashRuntimeConfigValue(input.env ?? process.env), readOnly: input.readOnly === true, workspaceDir: input.workspaceDir, + allowGatewaySubagentBinding: input.allowGatewaySubagentBinding === true, + runtimePluginSelections: input.runtimePluginSelections, }); } @@ -221,211 +225,215 @@ export async function prepareWorkspaceBuildGroup( } const env = input.env ?? process.env; const runtimePluginStartedAt = performance.now(); - const runtimePluginRegistry = - catalogMode === "live" && !input.readOnly - ? ensureRuntimePluginsLoaded({ - config: input.config, - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - }) - : undefined; - const runtimePluginMs = performance.now() - runtimePluginStartedAt; - const pluginMetadataStartedAt = performance.now(); - const pluginMetadataSnapshot = resolvePluginMetadataSnapshot({ - config: input.config, - env, - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - }); - const pluginMetadataMs = performance.now() - pluginMetadataStartedAt; - const matchesStaticModelId = createStaticModelIdMatcher({ - manifestPlugins: pluginMetadataSnapshot.plugins, - }); - const mediaCapabilityProviders = - input.readOnly || !runtimePluginRegistry - ? undefined - : prepareMediaCapabilityProviders({ - cfg: input.config, - pluginMetadataSnapshot, - registry: runtimePluginRegistry, - }); - const messageToolCatalog = runtimePluginRegistry - ? getPreparedMessageToolCatalogForRegistry(runtimePluginRegistry) - : catalogMode === "live" - ? getPreparedMessageToolCatalog() - : undefined; - const resolveManifestStaticCatalogModel = createBundledStaticCatalogModelResolver({ - cfg: input.config, - env, - includeRuntimeDiscovery: true, - metadataSnapshot: pluginMetadataSnapshot, - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - }); - const configuredManifestModels = new Map(); - const resolveConfiguredManifestModel = (lookup: { provider: string; modelId: string }) => { - const key = `${normalizeProviderId(lookup.provider)}\0${lookup.modelId.trim().toLowerCase()}`; - if (configuredManifestModels.has(key)) { - return configuredManifestModels.get(key); - } - const model = resolveManifestStaticCatalogModel(lookup); - configuredManifestModels.set(key, model); - return model; - }; - const configuredProviderIds = [ - ...new Set([ - ...collectPreparedModelRuntimeProviderIds(input.config, {}, false), - ...(options.providerDiscoveryProviderIds ?? []).map(normalizeProviderId).filter(Boolean), - ]), - ].toSorted((left, right) => left.localeCompare(right)); - const staticCatalogProviderIds = [ - ...new Set([ - ...collectConfiguredProviderIdsNeedingStaticCatalog({ + const runtimePluginRegistry = !input.readOnly + ? loadAgentRuntimePluginRegistryHandle({ config: input.config, - matchesStaticModelId, - resolveStaticCatalogModel: resolveConfiguredManifestModel, - }), - ...(options.providerDiscoveryProviderIds ?? []).map(normalizeProviderId).filter(Boolean), - ]), - ].toSorted((left, right) => left.localeCompare(right)); - const staticProviderCatalogStartedAt = performance.now(); - const preparedStaticProviderCatalog = - catalogMode === "static" - ? await prepareImplicitProviderStaticCatalog({ + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + ...(input.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : {}), + selections: input.runtimePluginSelections, + }) + : undefined; + const runtimePluginMs = performance.now() - runtimePluginStartedAt; + return await withPluginRuntimeRegistryScope(runtimePluginRegistry, async () => { + const pluginMetadataStartedAt = performance.now(); + const pluginMetadataSnapshot = resolvePluginMetadataSnapshot({ + config: input.config, + env, + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }); + const pluginMetadataMs = performance.now() - pluginMetadataStartedAt; + const matchesStaticModelId = createStaticModelIdMatcher({ + manifestPlugins: pluginMetadataSnapshot.plugins, + }); + const mediaCapabilityProviders = + input.readOnly || !runtimePluginRegistry + ? undefined + : prepareMediaCapabilityProviders({ + cfg: input.config, + pluginMetadataSnapshot, + registry: runtimePluginRegistry, + }); + const messageToolCatalog = runtimePluginRegistry + ? getPreparedMessageToolCatalogForRegistry(runtimePluginRegistry) + : catalogMode === "live" + ? getPreparedMessageToolCatalog() + : undefined; + const resolveManifestStaticCatalogModel = createBundledStaticCatalogModelResolver({ + cfg: input.config, + env, + includeRuntimeDiscovery: true, + metadataSnapshot: pluginMetadataSnapshot, + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }); + const configuredManifestModels = new Map(); + const resolveConfiguredManifestModel = (lookup: { provider: string; modelId: string }) => { + const key = `${normalizeProviderId(lookup.provider)}\0${lookup.modelId.trim().toLowerCase()}`; + if (configuredManifestModels.has(key)) { + return configuredManifestModels.get(key); + } + const model = resolveManifestStaticCatalogModel(lookup); + configuredManifestModels.set(key, model); + return model; + }; + const configuredProviderIds = [ + ...new Set([ + ...collectPreparedModelRuntimeProviderIds(input.config, {}, false), + ...(options.providerDiscoveryProviderIds ?? []).map(normalizeProviderId).filter(Boolean), + ]), + ].toSorted((left, right) => left.localeCompare(right)); + const staticCatalogProviderIds = [ + ...new Set([ + ...collectConfiguredProviderIdsNeedingStaticCatalog({ config: input.config, - env, - pluginMetadataSnapshot, - providerDiscoveryProviderIds: configuredProviderIds, - staticCatalogProviderIds, - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - }) - : undefined; - const staticProviderCatalogMs = performance.now() - staticProviderCatalogStartedAt; - const preparedSyntheticAuthProviders = preparedStaticProviderCatalog?.providers ?? []; - // Static Gateway publication consumes provider discovery entrypoints without activating plugin - // runtimes. The run boundary already owns runtime activation for its exact workspace. - const ambientCredentialsStartedAt = performance.now(); - const ambientCredentials = resolveAmbientAgentCredentialsForDiscovery({ - config: input.config, - env, - syntheticAuthProviderRefs: + matchesStaticModelId, + resolveStaticCatalogModel: resolveConfiguredManifestModel, + }), + ...(options.providerDiscoveryProviderIds ?? []).map(normalizeProviderId).filter(Boolean), + ]), + ].toSorted((left, right) => left.localeCompare(right)); + const staticProviderCatalogStartedAt = performance.now(); + const preparedStaticProviderCatalog = catalogMode === "static" - ? listPreparedSyntheticAuthProviderRefs(preparedSyntheticAuthProviders) - : resolveRuntimeSyntheticAuthProviderRefs({ + ? await prepareImplicitProviderStaticCatalog({ config: input.config, env, - index: pluginMetadataSnapshot.index, - registryDiagnostics: pluginMetadataSnapshot.registryDiagnostics, - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - }), - ...(catalogMode === "static" - ? { - resolveSyntheticAuth: (provider: string) => - resolvePreparedSyntheticAuth({ - config: input.config, - provider, - providers: preparedSyntheticAuthProviders, - }), - } - : {}), - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - }); - const ambientCredentialsMs = performance.now() - ambientCredentialsStartedAt; - const agentFactsStartedAt = performance.now(); - const agentBaseFacts = inputs.map((candidate) => - prepareAgentFacts( - candidate, - catalogMode, - ambientCredentials, - options.providerDiscoveryProviderIds, - ), - ); - const agentFactsMs = performance.now() - agentFactsStartedAt; - const configuredProjectionStartedAt = performance.now(); - const providerStaticModels = - catalogMode === "static" - ? [] - : await loadBundledProviderStaticCatalogContextModels({ - cfg: input.config, - env, - metadataSnapshot: pluginMetadataSnapshot, - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - }); - // Provider definitions are process/config facts. Which refs are admitted remains agent-owned. - const inlineProviderModels = buildInlineProviderModels(input.config.models?.providers ?? {}, { - providerMetadataOwners: pluginMetadataSnapshot.owners, - }); - const configuredCatalogEntries = buildConfiguredModelCatalog({ - cfg: input.config, - manifestPlugins: pluginMetadataSnapshot.plugins, - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - }); - const agentFacts: PreparedModelRuntimeAgentFacts[] = []; - for (const facts of agentBaseFacts) { - const configuredRuntimeModels = prepareConfiguredRuntimeModels({ - config: facts.input.config, - configuredModelRefs: facts.configuredModelRefs, - metadataSnapshot: pluginMetadataSnapshot, - ...(preparedStaticProviderCatalog ? { preparedStaticProviderCatalog } : {}), - providerStaticModels, - matchesStaticModelId, - resolveStaticCatalogModel: resolveConfiguredManifestModel, - }); - const configuredEntryKeys = new Set(configuredCatalogEntries.map(modelCatalogEntryKey)); - for (const configured of configuredRuntimeModels) { - configuredEntryKeys.add( - modelCatalogEntryKey({ provider: configured.provider, id: configured.modelId }), - ); - } - const configuredGeneratedCatalogPluginIds = [ - ...new Set( - facts.configuredModelRefs.flatMap(({ value }) => { - const separator = value.indexOf("/"); - if (separator <= 0 || separator >= value.length - 1) { - return []; - } - const provider = normalizeProviderId(value.slice(0, separator)); - const modelId = value.slice(separator + 1).trim(); - if ( - !provider || - !modelId || - configuredEntryKeys.has(modelCatalogEntryKey({ provider, id: modelId })) - ) { - return []; - } - const pluginId = resolvePluginModelCatalogOwnerPluginId({ - providerId: provider, pluginMetadataSnapshot, - }); - return pluginId ? [pluginId] : []; - }), - ), - ].toSorted((left, right) => left.localeCompare(right)); - agentFacts.push({ - ...facts, - configuredRuntimeModels, - configuredGeneratedCatalogPluginIds, + providerDiscoveryProviderIds: configuredProviderIds, + staticCatalogProviderIds, + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }) + : undefined; + const staticProviderCatalogMs = performance.now() - staticProviderCatalogStartedAt; + const preparedSyntheticAuthProviders = preparedStaticProviderCatalog?.providers ?? []; + // Static Gateway publication consumes provider discovery entrypoints without activating plugin + // runtimes. The run boundary already owns runtime activation for its exact workspace. + const ambientCredentialsStartedAt = performance.now(); + const ambientCredentials = resolveAmbientAgentCredentialsForDiscovery({ + config: input.config, + env, + syntheticAuthProviderRefs: + catalogMode === "static" + ? listPreparedSyntheticAuthProviderRefs(preparedSyntheticAuthProviders) + : resolveRuntimeSyntheticAuthProviderRefs({ + config: input.config, + env, + index: pluginMetadataSnapshot.index, + registryDiagnostics: pluginMetadataSnapshot.registryDiagnostics, + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }), + ...(catalogMode === "static" + ? { + resolveSyntheticAuth: (provider: string) => + resolvePreparedSyntheticAuth({ + config: input.config, + provider, + providers: preparedSyntheticAuthProviders, + }), + } + : {}), + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), }); - } - const configuredProjectionMs = performance.now() - configuredProjectionStartedAt; - return { - agentFacts, - buildStats: { - runtimePluginMs, - pluginMetadataMs, - staticProviderCatalogMs, - ambientCredentialsMs, - agentFactsMs, - configuredProjectionMs, - }, - workspaceFacts: { - pluginMetadataSnapshot, - messageToolCatalog, - providerStaticModelsComplete: catalogMode === "live", - inlineProviderModels, - configuredCatalogEntries, - ...(mediaCapabilityProviders ? { mediaCapabilityProviders } : {}), - ...(preparedStaticProviderCatalog ? { preparedStaticProviderCatalog } : {}), - ...(providerStaticModels ? { providerStaticModels } : {}), - }, - }; + const ambientCredentialsMs = performance.now() - ambientCredentialsStartedAt; + const agentFactsStartedAt = performance.now(); + const agentBaseFacts = inputs.map((candidate) => + prepareAgentFacts( + candidate, + catalogMode, + ambientCredentials, + options.providerDiscoveryProviderIds, + ), + ); + const agentFactsMs = performance.now() - agentFactsStartedAt; + const configuredProjectionStartedAt = performance.now(); + const providerStaticModels = + catalogMode === "static" + ? [] + : await loadBundledProviderStaticCatalogContextModels({ + cfg: input.config, + env, + metadataSnapshot: pluginMetadataSnapshot, + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }); + // Provider definitions are process/config facts. Which refs are admitted remains agent-owned. + const inlineProviderModels = buildInlineProviderModels(input.config.models?.providers ?? {}, { + providerMetadataOwners: pluginMetadataSnapshot.owners, + }); + const configuredCatalogEntries = buildConfiguredModelCatalog({ + cfg: input.config, + manifestPlugins: pluginMetadataSnapshot.plugins, + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }); + const agentFacts: PreparedModelRuntimeAgentFacts[] = []; + for (const facts of agentBaseFacts) { + const configuredRuntimeModels = prepareConfiguredRuntimeModels({ + config: facts.input.config, + configuredModelRefs: facts.configuredModelRefs, + metadataSnapshot: pluginMetadataSnapshot, + ...(preparedStaticProviderCatalog ? { preparedStaticProviderCatalog } : {}), + providerStaticModels, + matchesStaticModelId, + resolveStaticCatalogModel: resolveConfiguredManifestModel, + }); + const configuredEntryKeys = new Set(configuredCatalogEntries.map(modelCatalogEntryKey)); + for (const configured of configuredRuntimeModels) { + configuredEntryKeys.add( + modelCatalogEntryKey({ provider: configured.provider, id: configured.modelId }), + ); + } + const configuredGeneratedCatalogPluginIds = [ + ...new Set( + facts.configuredModelRefs.flatMap(({ value }) => { + const separator = value.indexOf("/"); + if (separator <= 0 || separator >= value.length - 1) { + return []; + } + const provider = normalizeProviderId(value.slice(0, separator)); + const modelId = value.slice(separator + 1).trim(); + if ( + !provider || + !modelId || + configuredEntryKeys.has(modelCatalogEntryKey({ provider, id: modelId })) + ) { + return []; + } + const pluginId = resolvePluginModelCatalogOwnerPluginId({ + providerId: provider, + pluginMetadataSnapshot, + }); + return pluginId ? [pluginId] : []; + }), + ), + ].toSorted((left, right) => left.localeCompare(right)); + agentFacts.push({ + ...facts, + configuredRuntimeModels, + configuredGeneratedCatalogPluginIds, + }); + } + const configuredProjectionMs = performance.now() - configuredProjectionStartedAt; + return { + agentFacts, + buildStats: { + runtimePluginMs, + pluginMetadataMs, + staticProviderCatalogMs, + ambientCredentialsMs, + agentFactsMs, + configuredProjectionMs, + }, + workspaceFacts: { + pluginMetadataSnapshot, + messageToolCatalog, + providerStaticModelsComplete: catalogMode === "live", + inlineProviderModels, + configuredCatalogEntries, + ...(runtimePluginRegistry ? { pluginRegistry: runtimePluginRegistry } : {}), + ...(mediaCapabilityProviders ? { mediaCapabilityProviders } : {}), + ...(preparedStaticProviderCatalog ? { preparedStaticProviderCatalog } : {}), + ...(providerStaticModels ? { providerStaticModels } : {}), + }, + }; + }); } export async function prepareFullCatalogFacts( diff --git a/src/agents/prepared-model-runtime.lifecycle.test.ts b/src/agents/prepared-model-runtime.lifecycle.test.ts index 296651d798d9..57aa82fe84f1 100644 --- a/src/agents/prepared-model-runtime.lifecycle.test.ts +++ b/src/agents/prepared-model-runtime.lifecycle.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; type LoadStaticCatalog = typeof import("./embedded-agent-runner/model.static-catalog.js").loadBundledProviderStaticCatalogContextModels; @@ -29,7 +30,7 @@ const mocks = vi.hoisted(() => ({ entries: [], routeVariants: [], })), - ensureRuntimePluginsLoaded: vi.fn(), + loadAgentRuntimePluginRegistryHandle: vi.fn(), loadStaticCatalog: vi.fn(async () => []), prepareStaticCatalog: vi.fn(async (..._args: unknown[]) => ({ entries: [] })), resolveStaticCatalogModel: vi.fn(() => undefined), @@ -105,7 +106,8 @@ vi.mock("./models-config.providers.implicit.js", () => ({ })); vi.mock("./runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded: (...args: unknown[]) => mocks.ensureRuntimePluginsLoaded(...args), + loadAgentRuntimePluginRegistryHandle: (...args: unknown[]) => + mocks.loadAgentRuntimePluginRegistryHandle(...args), })); vi.mock("./embedded-agent-runner/model.static-catalog.js", () => ({ @@ -152,7 +154,9 @@ describe("prepared model runtime snapshots", () => { pluginCatalogs: [], })); mocks.buildPreparedModelCatalogSnapshot.mockClear(); - mocks.ensureRuntimePluginsLoaded.mockClear(); + mocks.loadAgentRuntimePluginRegistryHandle + .mockReset() + .mockReturnValue(createEmptyPluginRegistry()); mocks.loadStaticCatalog.mockClear(); mocks.prepareStaticCatalog.mockClear(); mocks.resolveStaticCatalogModel.mockClear(); @@ -811,7 +815,7 @@ describe("prepared model runtime snapshots", () => { refreshPreparedModelRuntimeSnapshots({}, { gatewayLifecycle: true, catalogMode: "static" }), ).resolves.toBeUndefined(); expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); - expect(mocks.ensureRuntimePluginsLoaded).not.toHaveBeenCalled(); + expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledOnce(); expect(mocks.discoverAuthStorage).toHaveBeenCalledOnce(); expect(mocks.discoverModels).toHaveBeenCalledOnce(); }); diff --git a/src/agents/prepared-model-runtime.owner-selection.test.ts b/src/agents/prepared-model-runtime.owner-selection.test.ts index 1b4f9399bb66..728901e02e41 100644 --- a/src/agents/prepared-model-runtime.owner-selection.test.ts +++ b/src/agents/prepared-model-runtime.owner-selection.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; type CreateStaticCatalogResolver = typeof import("./embedded-agent-runner/model.static-catalog.js").createBundledStaticCatalogModelResolver; @@ -30,7 +31,7 @@ const mocks = vi.hoisted(() => ({ agentDir: "/tmp/agent", wrote: false, })), - ensureRuntimePluginsLoaded: vi.fn(), + loadAgentRuntimePluginRegistryHandle: vi.fn(), planOpenClawModelsJsonSource: vi.fn(async (...args: unknown[]) => ({ agentDir: String(args[1]), modelsJsonContents: null, @@ -81,6 +82,10 @@ vi.mock("./agent-scope.js", () => ({ (agentId === "default" ? "/tmp/unused-workspace" : `/tmp/workspace-${agentId}`), resolveDefaultAgentDir: () => "/tmp/unused-agent", resolveDefaultAgentId: () => "default", + resolveSessionAgentIds: ({ agentId }: { agentId?: string }) => ({ + defaultAgentId: "default", + sessionAgentId: agentId ?? "default", + }), })); vi.mock("./auth-profiles/runtime-snapshots.js", () => ({ @@ -106,7 +111,8 @@ vi.mock("./models-config.providers.implicit.js", () => ({ })); vi.mock("./runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded: (...args: unknown[]) => mocks.ensureRuntimePluginsLoaded(...args), + loadAgentRuntimePluginRegistryHandle: (...args: unknown[]) => + mocks.loadAgentRuntimePluginRegistryHandle(...args), })); vi.mock("./embedded-agent-runner/model.static-catalog.js", () => ({ @@ -144,7 +150,9 @@ describe("prepared model runtime owner selection", () => { mocks.discoverModels.mockClear(); mocks.ensureOpenClawModelsJson.mockReset(); mocks.ensureOpenClawModelsJson.mockResolvedValue({ agentDir: "/tmp/agent", wrote: false }); - mocks.ensureRuntimePluginsLoaded.mockClear(); + mocks.loadAgentRuntimePluginRegistryHandle + .mockReset() + .mockReturnValue(createEmptyPluginRegistry()); mocks.modelRegistry.fork.mockClear(); mocks.planOpenClawModelsJsonSource.mockReset(); mocks.planOpenClawModelsJsonSource.mockImplementation(async (_config, agentDir) => ({ @@ -227,6 +235,44 @@ describe("prepared model runtime owner selection", () => { expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce(); }); + it("reuses the configured owner for selections that need no plugin harness", async () => { + mocks.configuredAgentIds = ["default"]; + const config = { agents: { defaults: { model: "openai/gpt-5.5" } } }; + await refreshPreparedModelRuntimeSnapshots(config, { + allowGatewaySubagentBinding: true, + catalogMode: "static", + gatewayLifecycle: true, + }); + const configured = getPreparedModelRuntimeSnapshot({ + agentId: "default", + agentDir: "/tmp/unused-agent", + allowGatewaySubagentBinding: true, + config, + workspaceDir: "/tmp/unused-workspace", + }); + + await expect( + prepareModelRuntimeSnapshot({ + agentId: "default", + agentDir: "/tmp/unused-agent", + allowGatewaySubagentBinding: true, + config, + runtimePluginSelections: [{ provider: "openai", modelId: "gpt-5.5", runtime: "openclaw" }], + workspaceDir: "/tmp/unused-workspace", + }), + ).resolves.toBe(configured); + await expect( + prepareModelRuntimeSnapshot({ + agentId: "default", + agentDir: "/tmp/unused-agent", + allowGatewaySubagentBinding: true, + config, + runtimePluginSelections: [{ provider: "openai", modelId: "gpt-5.5", runtime: "codex" }], + workspaceDir: "/tmp/unused-workspace", + }), + ).rejects.toThrow("prepared model runtime owner was not published"); + }); + it("does not substitute a configured owner captured from another environment", async () => { mocks.configuredAgentIds = ["default"]; const config = {}; @@ -343,7 +389,7 @@ describe("prepared model runtime owner selection", () => { }); expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); - expect(mocks.ensureRuntimePluginsLoaded).not.toHaveBeenCalled(); + expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledTimes(2); expect(mocks.resolveAmbientCredentials).toHaveBeenCalledTimes(2); expect(mocks.prepareStaticCatalog).toHaveBeenCalledTimes(2); expect(mocks.resolveStaticCatalogModel).toHaveBeenCalledTimes(2); @@ -569,7 +615,7 @@ describe("prepared model runtime owner selection", () => { catalogMode: "static", }); - expect(mocks.ensureRuntimePluginsLoaded).not.toHaveBeenCalled(); + expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledOnce(); expect(mocks.prepareStaticCatalog).toHaveBeenCalledOnce(); expect(mocks.discoverModels).toHaveBeenCalledTimes(2); const loadAgentCatalog = (agentId: string) => diff --git a/src/agents/prepared-model-runtime.owner.ts b/src/agents/prepared-model-runtime.owner.ts index d8157dfa6391..c7561c969bb8 100644 --- a/src/agents/prepared-model-runtime.owner.ts +++ b/src/agents/prepared-model-runtime.owner.ts @@ -9,6 +9,7 @@ import { resolveDefaultAgentDir, resolveDefaultAgentId, } from "./agent-scope.js"; +import { requiresAgentHarnessPluginSelection } from "./harness/runtime-plugin-load-plan.js"; import { startSerializedSnapshotBuild, startSerializedSnapshotBuildBatch, @@ -120,6 +121,9 @@ export function rebindInputToCommittedConfiguredOwner( env: owner.input.env, workspaceDir: preserveWorkspaceDir ? input.workspaceDir : owner.input.workspaceDir, preserveWorkspaceDirOnRefresh: preserveWorkspaceDir, + allowGatewaySubagentBinding: + input.allowGatewaySubagentBinding ?? owner.input.allowGatewaySubagentBinding, + runtimePluginSelections: input.runtimePluginSelections, }); } @@ -148,6 +152,7 @@ export function normalizePreparedModelRuntimeInput( const { inheritedAuthDir: _inheritedAuthDir, readOnly, + runtimePluginSelections: _runtimePluginSelections, skipCredentials, workspaceDir: _workspaceDir, ...rest @@ -157,6 +162,14 @@ export function normalizePreparedModelRuntimeInput( ); const workspaceDir = normalizeOptionalDir(input.workspaceDir); const env = input.env ? Object.freeze({ ...input.env }) : undefined; + const runtimePluginSelections = input.runtimePluginSelections + ? Object.freeze( + [...input.runtimePluginSelections] + .filter((selection) => requiresAgentHarnessPluginSelection(selection, input.config)) + .map((selection) => Object.freeze({ ...selection })) + .toSorted((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))), + ) + : undefined; return { ...rest, agentDir: path.resolve(input.agentDir), @@ -165,6 +178,8 @@ export function normalizePreparedModelRuntimeInput( ...(skipCredentials === true ? { skipCredentials: true } : {}), ...(workspaceDir ? { workspaceDir } : {}), ...(env ? { env } : {}), + ...(input.allowGatewaySubagentBinding === true ? { allowGatewaySubagentBinding: true } : {}), + ...(runtimePluginSelections?.length ? { runtimePluginSelections } : {}), }; } @@ -185,6 +200,8 @@ export function ownerKey(input: PreparedModelRuntimeInput): string { skipCredentials: input.skipCredentials === true, workspaceDir: input.workspaceDir, env: environmentFingerprint(input.env), + allowGatewaySubagentBinding: input.allowGatewaySubagentBinding === true, + runtimePluginSelections: input.runtimePluginSelections, config: input.readOnly ? hashRuntimeConfigValue(input.config) : undefined, }); } @@ -211,6 +228,9 @@ export function resolvePublishedOwner( owner.input.inheritedAuthDir === input.inheritedAuthDir && owner.input.readOnly === input.readOnly && owner.input.skipCredentials === input.skipCredentials && + owner.input.allowGatewaySubagentBinding === input.allowGatewaySubagentBinding && + JSON.stringify(owner.input.runtimePluginSelections) === + JSON.stringify(input.runtimePluginSelections) && (input.env === undefined || owner.environmentFingerprint === environmentFingerprint(input.env)) && (input.workspaceDir === undefined || owner.input.workspaceDir === input.workspaceDir), @@ -230,7 +250,9 @@ export function hasSameLifecycleInput( left.skipCredentials === right.skipCredentials && left.workspaceDir === right.workspaceDir && environmentFingerprint(left.env) === environmentFingerprint(right.env) && - left.preserveWorkspaceDirOnRefresh === right.preserveWorkspaceDirOnRefresh + left.preserveWorkspaceDirOnRefresh === right.preserveWorkspaceDirOnRefresh && + left.allowGatewaySubagentBinding === right.allowGatewaySubagentBinding && + JSON.stringify(left.runtimePluginSelections) === JSON.stringify(right.runtimePluginSelections) ); } @@ -254,6 +276,7 @@ export function createPreparedModelRuntimeReplacement(): PreparedModelRuntimeRep export function listConfiguredOwnerInputs( config: OpenClawConfig, defaultWorkspaceDir?: string, + allowGatewaySubagentBinding?: boolean, ): PreparedModelRuntimeInput[] { const inheritedAuthDir = resolveDefaultAgentDir(config); const defaultAgentId = resolveDefaultAgentId(config); @@ -268,6 +291,9 @@ export function listConfiguredOwnerInputs( ? defaultWorkspaceDir : resolveAgentWorkspaceDir(config, agentId), }; + if (allowGatewaySubagentBinding === true) { + input.allowGatewaySubagentBinding = true; + } if (preserveWorkspaceDirOnRefresh) { input.preserveWorkspaceDirOnRefresh = true; } diff --git a/src/agents/prepared-model-runtime.startup-static.test.ts b/src/agents/prepared-model-runtime.startup-static.test.ts index 9b4ef483b90f..afca95e5ba5d 100644 --- a/src/agents/prepared-model-runtime.startup-static.test.ts +++ b/src/agents/prepared-model-runtime.startup-static.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; type CreateStaticCatalogResolver = typeof import("./embedded-agent-runner/model.static-catalog.js").createBundledStaticCatalogModelResolver; @@ -57,7 +58,7 @@ const mocks = vi.hoisted(() => { }), ), buildPreparedModelCatalogSnapshot: vi.fn(async () => ({ entries: [], routeVariants: [] })), - ensureRuntimePluginsLoaded: vi.fn(), + loadAgentRuntimePluginRegistryHandle: vi.fn(), loadStaticCatalog: vi.fn(async () => []), prepareStaticCatalog: vi.fn(async (..._args: unknown[]) => ({ providers: [ @@ -149,7 +150,7 @@ vi.mock("./models-config.providers.implicit.js", () => ({ })); vi.mock("./runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded: mocks.ensureRuntimePluginsLoaded, + loadAgentRuntimePluginRegistryHandle: mocks.loadAgentRuntimePluginRegistryHandle, })); vi.mock("./embedded-agent-runner/model.static-catalog.js", () => ({ @@ -170,6 +171,9 @@ const { resetPreparedModelRuntimeSnapshotsForTest } = beforeEach(() => { resetPreparedModelRuntimeSnapshotsForTest(); + mocks.loadAgentRuntimePluginRegistryHandle + .mockReset() + .mockReturnValue(createEmptyPluginRegistry()); vi.clearAllMocks(); mocks.resolveStaticCatalogModel.mockReturnValue(undefined); }); @@ -312,7 +316,7 @@ describe("prepared model runtime Gateway catalog mode", () => { }); expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); - expect(mocks.ensureRuntimePluginsLoaded).not.toHaveBeenCalled(); + expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledOnce(); expect(mocks.prepareStaticCatalog).toHaveBeenCalledWith( expect.objectContaining({ providerDiscoveryProviderIds: ["openai"], @@ -359,8 +363,9 @@ describe("prepared model runtime Gateway catalog mode", () => { workspaceDir: "/tmp/prepared-static-workspace", }); expect(snapshot?.configuredRuntimeModels).toHaveLength(1); + expect(snapshot?.pluginRegistry).toBeDefined(); expect(snapshot?.messageToolCatalog).toBeUndefined(); - expect(snapshot?.mediaCapabilityProviders).toBeUndefined(); + expect(snapshot?.mediaCapabilityProviders).toBeDefined(); const fullCatalog = await snapshot?.loadFullModelCatalog?.(); expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledWith( @@ -376,8 +381,8 @@ describe("prepared model runtime Gateway catalog mode", () => { expect(mocks.buildPreparedModelCatalogSnapshot).toHaveBeenCalledWith( expect.objectContaining({ includeProviderPluginAugmentation: true }), ); - expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalledOnce(); - expect(mocks.ensureRuntimePluginsLoaded.mock.invocationCallOrder[0]).toBeLessThan( + expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledTimes(2); + expect(mocks.loadAgentRuntimePluginRegistryHandle.mock.invocationCallOrder[0]).toBeLessThan( mocks.buildPreparedModelCatalogSnapshot.mock.invocationCallOrder[0]!, ); expect(mocks.loadStaticCatalog).toHaveBeenCalledWith( diff --git a/src/agents/prepared-model-runtime.test.ts b/src/agents/prepared-model-runtime.test.ts index e41a704b092d..b962eaf9941e 100644 --- a/src/agents/prepared-model-runtime.test.ts +++ b/src/agents/prepared-model-runtime.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import { requireActivePluginRegistry } from "../plugins/runtime.js"; type LoadStaticCatalog = typeof import("./embedded-agent-runner/model.static-catalog.js").loadBundledProviderStaticCatalogContextModels; @@ -32,7 +34,7 @@ const mocks = vi.hoisted(() => ({ entries: [], routeVariants: [], })), - ensureRuntimePluginsLoaded: vi.fn(), + loadAgentRuntimePluginRegistryHandle: vi.fn(), loadStaticCatalog: vi.fn(async () => []), resolveStaticCatalogModel: vi.fn(() => undefined), createStaticCatalogResolver: vi.fn(), @@ -100,7 +102,8 @@ vi.mock("./models-config.js", () => ({ })); vi.mock("./runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded: (...args: unknown[]) => mocks.ensureRuntimePluginsLoaded(...args), + loadAgentRuntimePluginRegistryHandle: (...args: unknown[]) => + mocks.loadAgentRuntimePluginRegistryHandle(...args), })); vi.mock("./embedded-agent-runner/model.static-catalog.js", () => ({ @@ -143,7 +146,9 @@ describe("prepared model runtime snapshots", () => { mocks.discoverModels.mockClear(); mocks.ensureOpenClawModelsJson.mockClear(); mocks.buildPreparedModelCatalogSnapshot.mockClear(); - mocks.ensureRuntimePluginsLoaded.mockClear(); + mocks.loadAgentRuntimePluginRegistryHandle + .mockReset() + .mockReturnValue(createEmptyPluginRegistry()); mocks.loadStaticCatalog.mockClear(); mocks.resolveStaticCatalogModel.mockReset(); mocks.createStaticCatalogResolver.mockReset(); @@ -240,17 +245,24 @@ describe("prepared model runtime snapshots", () => { }); it("loads runtime plugins before discovering an immutable generation", async () => { - await publishPreparedModelRuntimeSnapshot({ + const pluginRegistry = createEmptyPluginRegistry(); + mocks.loadAgentRuntimePluginRegistryHandle.mockReturnValueOnce(pluginRegistry); + mocks.discoverAuthStorage.mockImplementationOnce(() => { + expect(requireActivePluginRegistry()).toBe(pluginRegistry); + }); + const snapshot = await publishPreparedModelRuntimeSnapshot({ config: {}, agentDir: "/tmp/prepared-model-runtime-plugin-order", workspaceDir: "/tmp/prepared-model-runtime-plugin-workspace", }); - expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledWith({ config: {}, workspaceDir: "/tmp/prepared-model-runtime-plugin-workspace", + selections: undefined, }); - expect(mocks.ensureRuntimePluginsLoaded.mock.invocationCallOrder[0]).toBeLessThan( + expect(snapshot.pluginRegistry).toBe(pluginRegistry); + expect(mocks.loadAgentRuntimePluginRegistryHandle.mock.invocationCallOrder[0]).toBeLessThan( mocks.discoverAuthStorage.mock.invocationCallOrder[0]!, ); }); @@ -627,7 +639,7 @@ describe("prepared model runtime snapshots", () => { expect(mocks.discoverModels).toHaveBeenCalledOnce(); expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled(); expect(mocks.planOpenClawModelsJsonSource).not.toHaveBeenCalled(); - expect(mocks.ensureRuntimePluginsLoaded).not.toHaveBeenCalled(); + expect(mocks.loadAgentRuntimePluginRegistryHandle).not.toHaveBeenCalled(); }); it("builds credential-free command owners separately from runtime owners", async () => { diff --git a/src/agents/prepared-model-runtime.ts b/src/agents/prepared-model-runtime.ts index 09cc213ebebc..793b4f425204 100644 --- a/src/agents/prepared-model-runtime.ts +++ b/src/agents/prepared-model-runtime.ts @@ -494,6 +494,7 @@ async function refreshPreparedModelRuntimeSnapshotsNow( options: PreparedModelRuntimeRefreshOptions, publicationEpoch: number, ): Promise { + const { defaultWorkspaceDir: workspace, allowGatewaySubagentBinding: bindings } = options; const catalogMode = options.catalogMode ?? "live"; gatewayLifecycleActive ||= options.gatewayLifecycle === true; const staleError = new Error("prepared model runtime owner is stale after config publication"); @@ -507,7 +508,7 @@ async function refreshPreparedModelRuntimeSnapshotsNow( const entries: Array<{ owner?: PreparedModelRuntimeOwner; input: PreparedModelRuntimeInput }> = []; const knownKeys = new Set(); - for (const rawInput of listConfiguredOwnerInputs(config, options.defaultWorkspaceDir)) { + for (const rawInput of listConfiguredOwnerInputs(config, workspace, bindings)) { let input = normalizePreparedModelRuntimeInput(rawInput); const preservedOwner = [...owners.values()].find( (owner) => diff --git a/src/agents/prepared-model-runtime.types.ts b/src/agents/prepared-model-runtime.types.ts index c465861f875d..50b22ad0ded2 100644 --- a/src/agents/prepared-model-runtime.types.ts +++ b/src/agents/prepared-model-runtime.types.ts @@ -2,7 +2,9 @@ import type { PreparedMessageToolCatalog } from "../channels/plugins/message-act import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { prepareMediaCapabilityProviders } from "../plugins/capability-provider-runtime.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js"; +import type { PluginRegistry } from "../plugins/registry-types.js"; import type { InlineModelEntry } from "./embedded-agent-runner/model.inline-provider.js"; +import type { AgentHarnessPluginSelection } from "./harness/runtime-plugin-load-plan.js"; import type { ModelCatalogSnapshot } from "./model-catalog.types.js"; import type { PreparedConfiguredRuntimeModel } from "./prepared-model-runtime.configured.js"; import type { AuthStorage } from "./sessions/auth-storage.js"; @@ -25,6 +27,9 @@ export type PreparedModelRuntimeSnapshot = Readonly<{ metadataSnapshot: PluginMetadataSnapshot; messageToolCatalog?: PreparedMessageToolCatalog; mediaCapabilityProviders?: ReturnType; + /** Registry value owned by this generation; omitted from read-only/static-catalog builds. */ + pluginRegistry?: PluginRegistry; + allowGatewaySubagentBinding: boolean; /** * Configured model projection used by turn admission and synchronous callers. * Full inventory discovery is deliberately outside the startup publication boundary. @@ -53,6 +58,8 @@ export type PreparedModelRuntimeInput = { readOnly?: boolean; skipCredentials?: boolean; env?: NodeJS.ProcessEnv; + allowGatewaySubagentBinding?: boolean; + runtimePluginSelections?: readonly AgentHarnessPluginSelection[]; config: OpenClawConfig; }; @@ -72,6 +79,7 @@ export type PreparedModelRuntimeRefreshOptions = { defaultWorkspaceDir?: string; catalogMode?: PreparedModelRuntimeCatalogMode; onBuildStats?: (stats: PreparedModelRuntimeBuildStats) => void; + allowGatewaySubagentBinding?: boolean; }; export type PreparedModelRuntimeBuildStats = Readonly<{ diff --git a/src/agents/runtime-plugins.test.ts b/src/agents/runtime-plugins.test.ts index 659de14fd050..0be85d559cc7 100644 --- a/src/agents/runtime-plugins.test.ts +++ b/src/agents/runtime-plugins.test.ts @@ -1,13 +1,12 @@ -// Verifies runtime plugin loading scope, disablement, and gateway-bindable mode. +// Verifies prepared-runtime handles and process-root runtime installation remain distinct. import { beforeEach, describe, expect, it, vi } from "vitest"; const hoisted = vi.hoisted(() => ({ getCurrentPluginMetadataSnapshot: vi.fn(), - ensureStandaloneRuntimePluginRegistryLoaded: vi.fn(), - getActivePluginRuntimeSubagentMode: vi.fn<() => "default" | "explicit" | "gateway-bindable">( - () => "default", - ), - getActivePluginRegistryWorkspaceDir: vi.fn<() => string | undefined>(() => undefined), + getActivePluginRuntimeSubagentMode: vi.fn<() => "default" | "explicit" | "gateway-bindable">(), + installRuntimePluginRegistryAtProcessRoot: vi.fn(), + loadRuntimePluginRegistryHandle: vi.fn(), + resolveAgentRuntimePluginLoadPlan: vi.fn(), })); vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({ @@ -15,208 +14,125 @@ vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({ })); vi.mock("../plugins/runtime/standalone-runtime-registry-loader.js", () => ({ - ensureStandaloneRuntimePluginRegistryLoaded: hoisted.ensureStandaloneRuntimePluginRegistryLoaded, + installRuntimePluginRegistryAtProcessRoot: hoisted.installRuntimePluginRegistryAtProcessRoot, + loadRuntimePluginRegistryHandle: hoisted.loadRuntimePluginRegistryHandle, })); vi.mock("../plugins/runtime.js", () => ({ getActivePluginRuntimeSubagentMode: hoisted.getActivePluginRuntimeSubagentMode, - getActivePluginRegistryWorkspaceDir: hoisted.getActivePluginRegistryWorkspaceDir, })); -describe("ensureRuntimePluginsLoaded", () => { - let ensureRuntimePluginsLoaded: typeof import("./runtime-plugins.js").ensureRuntimePluginsLoaded; +vi.mock("./harness/runtime-plugin-load-plan.js", () => ({ + resolveAgentRuntimePluginLoadPlan: hoisted.resolveAgentRuntimePluginLoadPlan, +})); - beforeEach(async () => { - // Reset modules so each case sees fresh mocked runtime-plugin dependencies. - hoisted.getCurrentPluginMetadataSnapshot.mockReset(); - hoisted.getCurrentPluginMetadataSnapshot.mockReturnValue(undefined); - hoisted.ensureStandaloneRuntimePluginRegistryLoaded.mockReset(); - hoisted.ensureStandaloneRuntimePluginRegistryLoaded.mockReturnValue(undefined); - hoisted.getActivePluginRuntimeSubagentMode.mockReset(); - hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("default"); - hoisted.getActivePluginRegistryWorkspaceDir.mockReset(); - hoisted.getActivePluginRegistryWorkspaceDir.mockReturnValue(undefined); - vi.resetModules(); - ({ ensureRuntimePluginsLoaded } = await import("./runtime-plugins.js")); +import { + installAgentRuntimePluginRegistryAtProcessRoot, + loadAgentRuntimePluginRegistryHandle, +} from "./runtime-plugins.js"; + +describe("agent runtime plugin registries", () => { + beforeEach(() => { + hoisted.getCurrentPluginMetadataSnapshot.mockReset().mockReturnValue(undefined); + hoisted.getActivePluginRuntimeSubagentMode.mockReset().mockReturnValue("default"); + hoisted.installRuntimePluginRegistryAtProcessRoot.mockReset().mockReturnValue({ root: true }); + hoisted.loadRuntimePluginRegistryHandle.mockReset().mockReturnValue({ handle: true }); + hoisted.resolveAgentRuntimePluginLoadPlan.mockReset().mockImplementation(({ config }) => ({ + config, + pluginIds: ["codex", "memory-core"], + })); }); - it("does not reactivate plugins when a process already has an active registry", () => { - hoisted.ensureStandaloneRuntimePluginRegistryLoaded.mockReturnValue({}); + it("returns a non-activating handle for a prepared runtime", () => { + const config = {} as never; + const selections = [{ provider: "openai", modelId: "gpt-5.5", runtime: "codex" }]; - ensureRuntimePluginsLoaded({ - config: {} as never, - workspaceDir: "/tmp/workspace", - allowGatewaySubagentBinding: true, - }); - - expect(hoisted.ensureStandaloneRuntimePluginRegistryLoaded).toHaveBeenCalledTimes(1); - }); - - it("resolves runtime plugins through the shared runtime helper", () => { - ensureRuntimePluginsLoaded({ - config: {} as never, - workspaceDir: "/tmp/workspace", - allowGatewaySubagentBinding: true, - }); - - expect(hoisted.ensureStandaloneRuntimePluginRegistryLoaded).toHaveBeenCalledWith({ - requiredPluginIds: undefined, - loadOptions: { - config: {} as never, + expect( + loadAgentRuntimePluginRegistryHandle({ + config, workspaceDir: "/tmp/workspace", - runtimeOptions: { - allowGatewaySubagentBinding: true, - }, + allowGatewaySubagentBinding: true, + selections, + }), + ).toEqual({ handle: true }); + expect(hoisted.resolveAgentRuntimePluginLoadPlan).toHaveBeenCalledWith({ + config, + workspaceDir: "/tmp/workspace", + selections, + }); + expect(hoisted.loadRuntimePluginRegistryHandle).toHaveBeenCalledWith({ + requiredPluginIds: ["codex", "memory-core"], + loadOptions: { + config, + activationSourceConfig: config, + workspaceDir: "/tmp/workspace", + runtimeOptions: { allowGatewaySubagentBinding: true }, }, }); + expect(hoisted.installRuntimePluginRegistryAtProcessRoot).not.toHaveBeenCalled(); }); - it("does not load runtime plugins when plugins are globally disabled", () => { - ensureRuntimePluginsLoaded({ - config: { - plugins: { - enabled: false, - }, - } as never, + it("installs only through the explicit process-root entry point", () => { + const config = {} as never; + hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("gateway-bindable"); + + expect( + installAgentRuntimePluginRegistryAtProcessRoot({ config, workspaceDir: "/tmp/workspace" }), + ).toEqual({ root: true }); + expect(hoisted.installRuntimePluginRegistryAtProcessRoot).toHaveBeenCalledWith( + expect.objectContaining({ + loadOptions: expect.objectContaining({ + runtimeOptions: { allowGatewaySubagentBinding: true }, + }), + }), + ); + expect(hoisted.loadRuntimePluginRegistryHandle).not.toHaveBeenCalled(); + }); + + it("installs an explicit empty registry when plugins are globally disabled", () => { + const params = { + config: { plugins: { enabled: false } } as never, workspaceDir: "/tmp/workspace", - allowGatewaySubagentBinding: true, + }; + expect(loadAgentRuntimePluginRegistryHandle(params)).toEqual({ handle: true }); + expect(installAgentRuntimePluginRegistryAtProcessRoot(params)).toEqual({ root: true }); + expect(hoisted.resolveAgentRuntimePluginLoadPlan).not.toHaveBeenCalled(); + expect(hoisted.loadRuntimePluginRegistryHandle).toHaveBeenCalledWith({ + requiredPluginIds: [], + loadOptions: { + activationSourceConfig: params.config, + config: params.config, + onlyPluginIds: [], + runtimeOptions: undefined, + workspaceDir: "/tmp/workspace", + }, }); - - expect(hoisted.getCurrentPluginMetadataSnapshot).not.toHaveBeenCalled(); - expect(hoisted.ensureStandaloneRuntimePluginRegistryLoaded).not.toHaveBeenCalled(); + expect(hoisted.installRuntimePluginRegistryAtProcessRoot).toHaveBeenCalledWith( + expect.objectContaining({ + requiredPluginIds: [], + loadOptions: expect.objectContaining({ onlyPluginIds: [] }), + }), + ); }); - it("scopes runtime plugin loading to the current gateway startup plan", () => { - // Startup metadata narrows runtime loading to plugins already planned for gateway startup. + it("preserves the gateway startup scope and ordering", () => { const config = {} as never; hoisted.getCurrentPluginMetadataSnapshot.mockReturnValue({ - startup: { - pluginIds: ["telegram", "memory-core"], - }, + startup: { pluginIds: ["telegram", "memory-core"] }, }); - ensureRuntimePluginsLoaded({ + loadAgentRuntimePluginRegistryHandle({ config, workspaceDir: "/tmp/workspace" }); + + expect(hoisted.resolveAgentRuntimePluginLoadPlan).toHaveBeenCalledWith({ config, workspaceDir: "/tmp/workspace", - allowGatewaySubagentBinding: true, - }); - - expect(hoisted.getCurrentPluginMetadataSnapshot).toHaveBeenCalledWith({ - config, - workspaceDir: "/tmp/workspace", - }); - expect(hoisted.ensureStandaloneRuntimePluginRegistryLoaded).toHaveBeenCalledWith({ - requiredPluginIds: ["telegram", "memory-core"], - loadOptions: { - config, - workspaceDir: "/tmp/workspace", - onlyPluginIds: ["telegram", "memory-core"], - forceFullRuntimeForChannelPlugins: true, - runtimeOptions: { - allowGatewaySubagentBinding: true, - }, - }, - }); - }); - - it("delegates startup-scope registry reuse to loader cache compatibility", () => { - hoisted.getCurrentPluginMetadataSnapshot.mockReturnValue({ - startup: { - pluginIds: ["telegram"], - }, - }); - hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("gateway-bindable"); - - ensureRuntimePluginsLoaded({ - config: {} as never, - workspaceDir: "/tmp/workspace", - allowGatewaySubagentBinding: true, - }); - - expect(hoisted.ensureStandaloneRuntimePluginRegistryLoaded).toHaveBeenCalledWith({ - requiredPluginIds: ["telegram"], - loadOptions: { - config: {} as never, - onlyPluginIds: ["telegram"], - workspaceDir: "/tmp/workspace", - forceFullRuntimeForChannelPlugins: true, - runtimeOptions: { - allowGatewaySubagentBinding: true, - }, - }, - }); - }); - - it("lets the loader decide when startup ids match but config changes", () => { - const config = { - plugins: { - config: { - telegram: { - replyMode: "changed", - }, - }, - }, - } as never; - hoisted.getCurrentPluginMetadataSnapshot.mockReturnValue({ - startup: { - pluginIds: ["telegram"], - }, - }); - hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("gateway-bindable"); - - ensureRuntimePluginsLoaded({ - config, - workspaceDir: "/tmp/workspace", - allowGatewaySubagentBinding: true, - }); - - expect(hoisted.ensureStandaloneRuntimePluginRegistryLoaded).toHaveBeenCalledWith({ - requiredPluginIds: ["telegram"], - loadOptions: { - config, - onlyPluginIds: ["telegram"], - workspaceDir: "/tmp/workspace", - forceFullRuntimeForChannelPlugins: true, - runtimeOptions: { - allowGatewaySubagentBinding: true, - }, - }, - }); - }); - - it("does not enable gateway subagent binding for normal runtime loads", () => { - ensureRuntimePluginsLoaded({ - config: {} as never, - workspaceDir: "/tmp/workspace", - }); - - expect(hoisted.ensureStandaloneRuntimePluginRegistryLoaded).toHaveBeenCalledWith({ - requiredPluginIds: undefined, - loadOptions: { - config: {} as never, - workspaceDir: "/tmp/workspace", - runtimeOptions: undefined, - }, - }); - }); - - it("inherits gateway-bindable mode from an active gateway registry", () => { - hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("gateway-bindable"); - - ensureRuntimePluginsLoaded({ - config: {} as never, - workspaceDir: "/tmp/workspace", - }); - - expect(hoisted.ensureStandaloneRuntimePluginRegistryLoaded).toHaveBeenCalledWith({ - requiredPluginIds: undefined, - loadOptions: { - config: {} as never, - workspaceDir: "/tmp/workspace", - runtimeOptions: { - allowGatewaySubagentBinding: true, - }, - }, + basePluginIds: ["telegram", "memory-core"], + selections: [], }); + expect(hoisted.loadRuntimePluginRegistryHandle).toHaveBeenCalledWith( + expect.objectContaining({ + loadOptions: expect.objectContaining({ forceFullRuntimeForChannelPlugins: true }), + }), + ); }); }); diff --git a/src/agents/runtime-plugins.ts b/src/agents/runtime-plugins.ts index b70611864a30..636bfcb78c0e 100644 --- a/src/agents/runtime-plugins.ts +++ b/src/agents/runtime-plugins.ts @@ -3,8 +3,16 @@ import { normalizePluginsConfig } from "../plugins/config-state.js"; import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; import type { PluginRegistry } from "../plugins/registry-types.js"; import { getActivePluginRuntimeSubagentMode } from "../plugins/runtime.js"; -import { ensureStandaloneRuntimePluginRegistryLoaded } from "../plugins/runtime/standalone-runtime-registry-loader.js"; +import { + installRuntimePluginRegistryAtProcessRoot, + loadRuntimePluginRegistryHandle, +} from "../plugins/runtime/standalone-runtime-registry-loader.js"; import { resolveUserPath } from "../utils.js"; +import { collectConfiguredAgentHarnessRuntimes } from "./harness-runtimes.js"; +import { + resolveAgentRuntimePluginLoadPlan, + type AgentHarnessPluginSelection, +} from "./harness/runtime-plugin-load-plan.js"; type StartupScopedPluginSnapshot = NonNullable< ReturnType @@ -29,36 +37,83 @@ function resolveStartupPluginIdsFromCurrentSnapshot(params: { return pluginIds.filter((pluginId): pluginId is string => typeof pluginId === "string"); } -/** Ensure standalone runtime plugins are loaded for the current agent context. */ -export function ensureRuntimePluginsLoaded(params: { +type AgentRuntimePluginRegistryParams = { config?: OpenClawConfig; workspaceDir?: string | null; allowGatewaySubagentBinding?: boolean; -}): PluginRegistry | undefined { - if (params.config && !normalizePluginsConfig(params.config.plugins).enabled) { - return undefined; - } + selections?: readonly AgentHarnessPluginSelection[]; +}; + +function resolveAgentRuntimePluginRegistryLoad(params: AgentRuntimePluginRegistryParams) { const workspaceDir = typeof params.workspaceDir === "string" && params.workspaceDir.trim() ? resolveUserPath(params.workspaceDir) : undefined; + if (params.config && !normalizePluginsConfig(params.config.plugins).enabled) { + return { + requiredPluginIds: [], + loadOptions: { + config: params.config, + activationSourceConfig: params.config, + workspaceDir, + onlyPluginIds: [], + runtimeOptions: params.allowGatewaySubagentBinding + ? { allowGatewaySubagentBinding: true } + : undefined, + }, + }; + } const startupPluginIds = resolveStartupPluginIdsFromCurrentSnapshot({ config: params.config, workspaceDir, }); - const allowGatewaySubagentBinding = - params.allowGatewaySubagentBinding === true || - getActivePluginRuntimeSubagentMode() === "gateway-bindable"; - return ensureStandaloneRuntimePluginRegistryLoaded({ - requiredPluginIds: startupPluginIds, + const plan = resolveAgentRuntimePluginLoadPlan({ + config: params.config, + workspaceDir: workspaceDir ?? process.cwd(), + ...(startupPluginIds === undefined ? {} : { basePluginIds: startupPluginIds }), + selections: [ + ...collectConfiguredAgentHarnessRuntimes(params.config ?? {}).map((runtime) => ({ + runtime, + provider: "", + modelId: "", + })), + ...(params.selections ?? []), + ], + }); + return { + requiredPluginIds: plan.pluginIds, loadOptions: { - config: params.config, + config: plan.config, + ...(plan.config ? { activationSourceConfig: plan.config } : {}), workspaceDir, - ...(startupPluginIds === undefined ? {} : { onlyPluginIds: startupPluginIds }), + ...(startupPluginIds === undefined || plan.pluginIds === undefined + ? {} + : { onlyPluginIds: plan.pluginIds }), ...(startupPluginIds === undefined ? {} : { forceFullRuntimeForChannelPlugins: true }), - runtimeOptions: allowGatewaySubagentBinding + runtimeOptions: params.allowGatewaySubagentBinding ? { allowGatewaySubagentBinding: true } : undefined, }, - }); + }; +} + +/** Loads the registry handle owned by an agent prepared-runtime generation. */ +export function loadAgentRuntimePluginRegistryHandle( + params: AgentRuntimePluginRegistryParams, +): PluginRegistry | undefined { + const load = resolveAgentRuntimePluginRegistryLoad(params); + return load ? loadRuntimePluginRegistryHandle(load) : undefined; +} + +/** Installs agent runtime plugins from a standalone/gateway process composition root. */ +export function installAgentRuntimePluginRegistryAtProcessRoot( + params: AgentRuntimePluginRegistryParams, +): PluginRegistry | undefined { + const load = resolveAgentRuntimePluginRegistryLoad({ + ...params, + allowGatewaySubagentBinding: + params.allowGatewaySubagentBinding === true || + getActivePluginRuntimeSubagentMode() === "gateway-bindable", + }); + return load ? installRuntimePluginRegistryAtProcessRoot(load) : undefined; } diff --git a/src/agents/subagent-control.test.ts b/src/agents/subagent-control.test.ts index 62f9bc10e15e..d38dadd9b35e 100644 --- a/src/agents/subagent-control.test.ts +++ b/src/agents/subagent-control.test.ts @@ -229,7 +229,7 @@ beforeEach(() => { subagentRegistryTesting.setDepsForTest({ cleanupBrowserSessionsForLifecycleEnd: async () => {}, ensureContextEnginesInitialized: () => {}, - ensureRuntimePluginsLoaded: () => {}, + loadAgentRuntimePluginRegistryHandle: () => undefined, getSubagentRunsSnapshotForRead: (runs) => new Map(runs), persistSubagentRunsToDisk: () => {}, persistSubagentRunsToDiskOrThrow: () => {}, @@ -1456,7 +1456,7 @@ describe("killAllControlledSubagentRuns", () => { subagentRegistryTesting.setDepsForTest({ cleanupBrowserSessionsForLifecycleEnd: async () => {}, ensureContextEnginesInitialized: () => {}, - ensureRuntimePluginsLoaded: () => {}, + loadAgentRuntimePluginRegistryHandle: () => undefined, getSubagentRunsSnapshotForRead: (runs) => new Map(runs), persistSubagentRunsToDisk: () => {}, persistSubagentRunsToDiskOrThrow: () => { diff --git a/src/agents/subagent-registry-context-cleanup.ts b/src/agents/subagent-registry-context-cleanup.ts index 2a0ce2fab3ae..02878c74131c 100644 --- a/src/agents/subagent-registry-context-cleanup.ts +++ b/src/agents/subagent-registry-context-cleanup.ts @@ -1,4 +1,5 @@ import { isFastTestRuntimeEnv } from "../infra/env.js"; +import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js"; import { removeInternalSessionEffectsSession } from "./internal-session-effects.js"; import { SUBAGENT_ENDED_OUTCOME_KILLED, @@ -11,7 +12,7 @@ import { resolveLifecycleOutcomeFromRunOutcome, } from "./subagent-registry-completion.js"; import { - ensureSubagentRegistryPluginRuntimeLoaded, + loadSubagentRegistryPluginRuntimeHandle, resolveSubagentRegistryContextEngine, type SubagentRegistryDeps, } from "./subagent-registry-deps.js"; @@ -33,16 +34,18 @@ export function createSubagentRegistryContextCleanup(config: { params: ContextEngineSubagentEndedParams, ): Promise { const cfg = deps().getRuntimeConfig(); - await ensureSubagentRegistryPluginRuntimeLoaded({ + const registry = await loadSubagentRegistryPluginRuntimeHandle({ config: cfg, - workspaceDir: params.workspaceDir, + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), allowGatewaySubagentBinding: true, }); - const engine = await resolveSubagentRegistryContextEngine(cfg, { - agentDir: params.agentDir, - workspaceDir: params.workspaceDir, + await withPluginRuntimeRegistryScope(registry, async () => { + const engine = await resolveSubagentRegistryContextEngine(cfg, { + agentDir: params.agentDir, + workspaceDir: params.workspaceDir, + }); + await engine.onSubagentEnded?.(params); }); - await engine.onSubagentEnded?.(params); } async function notifyContextEngineSubagentEnded( @@ -167,35 +170,37 @@ export function createSubagentRegistryContextCleanup(config: { return; } const cfg = deps().getRuntimeConfig(); - await ensureSubagentRegistryPluginRuntimeLoaded({ + const registry = await loadSubagentRegistryPluginRuntimeHandle({ config: cfg, - workspaceDir: params.entry.workspaceDir, + ...(params.entry.workspaceDir ? { workspaceDir: params.entry.workspaceDir } : {}), allowGatewaySubagentBinding: true, }); - if (params.entry.endedHookEmittedAt || params.isCurrent?.() === false) { - return; - } - // Plugin loading yields after the terminal lock is released. Resolve the - // event from the canonical row only after that boundary so an older callback - // cannot claim the exactly-once hook with a superseded timeout or error. - const reason = params.entry.endedReason ?? params.reason ?? SUBAGENT_ENDED_REASON_COMPLETE; - const outcome = - reason === SUBAGENT_ENDED_REASON_KILLED - ? SUBAGENT_ENDED_OUTCOME_KILLED - : resolveLifecycleOutcomeFromRunOutcome(params.entry.execution.outcome); - const error = - params.entry.execution.outcome?.status === "error" - ? params.entry.execution.outcome.error - : undefined; - await emitSubagentEndedHookOnce({ - entry: params.entry, - reason, - sendFarewell: params.sendFarewell, - accountId: params.accountId ?? params.entry.requesterOrigin?.accountId, - outcome, - error, - inFlightRunIds: endedHookInFlightRunIds, - persist, + await withPluginRuntimeRegistryScope(registry, async () => { + if (params.entry.endedHookEmittedAt || params.isCurrent?.() === false) { + return; + } + // Plugin loading yields after the terminal lock is released. Resolve the + // event from the canonical row only after that boundary so an older callback + // cannot claim the exactly-once hook with a superseded timeout or error. + const reason = params.entry.endedReason ?? params.reason ?? SUBAGENT_ENDED_REASON_COMPLETE; + const outcome = + reason === SUBAGENT_ENDED_REASON_KILLED + ? SUBAGENT_ENDED_OUTCOME_KILLED + : resolveLifecycleOutcomeFromRunOutcome(params.entry.execution.outcome); + const error = + params.entry.execution.outcome?.status === "error" + ? params.entry.execution.outcome.error + : undefined; + await emitSubagentEndedHookOnce({ + entry: params.entry, + reason, + sendFarewell: params.sendFarewell, + accountId: params.accountId ?? params.entry.requesterOrigin?.accountId, + outcome, + error, + inFlightRunIds: endedHookInFlightRunIds, + persist, + }); }); } diff --git a/src/agents/subagent-registry-deps.ts b/src/agents/subagent-registry-deps.ts index 4e3252fbc354..04aa57fbe40f 100644 --- a/src/agents/subagent-registry-deps.ts +++ b/src/agents/subagent-registry-deps.ts @@ -7,9 +7,9 @@ import { callGateway } from "../gateway/call.js"; import type { GatewayRecoveryRuntime } from "../gateway/server-instance-runtime.types.js"; import { getGatewayRecoveryRuntime } from "../gateway/server-recovery-runtime-context.js"; import { onAgentEvent, type AgentEventPayload } from "../infra/agent-events.js"; +import type { PluginRegistry } from "../plugins/registry-types.js"; import { createLazyImportLoader, createLazyPromiseLoader } from "../shared/lazy-promise.js"; import { importRuntimeModule } from "../shared/runtime-import.js"; -import type { ensureRuntimePluginsLoaded as ensureRuntimePluginsLoadedFn } from "./runtime-plugins.js"; import { getSubagentRunsSnapshotForChildSession, getSubagentRunsSnapshotForController, @@ -50,9 +50,11 @@ export type SubagentRegistryDeps = { runSubagentAnnounceFlow: SubagentAnnounceModule["runSubagentAnnounceFlow"]; maybeWakeRequesterAfterAllChildrenSettled: RequesterSettleWakeModule["maybeWakeRequesterAfterAllChildrenSettled"]; ensureContextEnginesInitialized?: () => void; - ensureRuntimePluginsLoaded?: ( - params: Parameters[0], - ) => void | Promise; + loadAgentRuntimePluginRegistryHandle?: (params: { + config: OpenClawConfig; + workspaceDir?: string; + allowGatewaySubagentBinding?: boolean; + }) => PluginRegistry | undefined; resolveContextEngine?: ( cfg?: OpenClawConfig, options?: ResolveContextEngineOptions, @@ -107,7 +109,6 @@ type SubagentRegistryRuntimeModule = { cfg?: OpenClawConfig, options?: ResolveContextEngineOptions, ) => Promise; - ensureRuntimePluginsLoaded: typeof ensureRuntimePluginsLoadedFn; }; const SUBAGENT_REGISTRY_RUNTIME_SPEC = ["./subagent-registry.runtime", ".js"] as const; @@ -119,18 +120,22 @@ const subagentRegistryRuntimeLoader = createLazyPromiseLoader(() => SUBAGENT_REGISTRY_RUNTIME_SPEC, ), ); +const subagentRegistryPluginRuntimeLoader = createLazyPromiseLoader( + () => import("./runtime-plugins.js"), +); -export async function ensureSubagentRegistryPluginRuntimeLoaded(params: { +export async function loadSubagentRegistryPluginRuntimeHandle(params: { config: OpenClawConfig; workspaceDir?: string; allowGatewaySubagentBinding?: boolean; -}) { - const ensureRuntimePluginsLoaded = subagentRegistryDeps.ensureRuntimePluginsLoaded; - if (ensureRuntimePluginsLoaded) { - await ensureRuntimePluginsLoaded(params); - return; +}): Promise { + const configuredLoader = subagentRegistryDeps.loadAgentRuntimePluginRegistryHandle; + if (configuredLoader) { + return configuredLoader(params); } - (await subagentRegistryRuntimeLoader.load()).ensureRuntimePluginsLoaded(params); + return (await subagentRegistryPluginRuntimeLoader.load()).loadAgentRuntimePluginRegistryHandle( + params, + ); } export async function resolveSubagentRegistryContextEngine( @@ -154,6 +159,7 @@ export function setSubagentRegistryDepsForTest(overrides?: Partial { mod.testing.setDepsForTest({ callGateway, getRuntimeConfig: loadConfigMock as typeof import("../config/config.js").getRuntimeConfig, - ensureRuntimePluginsLoaded: vi.fn(), + loadAgentRuntimePluginRegistryHandle: vi.fn(), maybeWakeRequesterAfterAllChildrenSettled: vi.fn(async (params) => { params.completeBatch([params.settledEntry.runId]); return false; @@ -230,7 +230,6 @@ describe("subagent registry archive behavior", () => { }); setRegistryTestDeps({ ensureContextEnginesInitialized: vi.fn(), - ensureRuntimePluginsLoaded: vi.fn(), resolveContextEngine: vi.fn(async () => ({ onSubagentEnded }) as never), }); @@ -668,7 +667,7 @@ describe("subagent registry archive behavior", () => { it("continues killed cleanup when ended hook loading fails", async () => { const now = Date.now(); setRegistryTestDeps({ - ensureRuntimePluginsLoaded: vi.fn(() => { + loadAgentRuntimePluginRegistryHandle: vi.fn(() => { throw new Error("plugin load failed"); }), }); diff --git a/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts b/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts index e8f72a190f70..3c1556c5b6a2 100644 --- a/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts +++ b/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts @@ -159,6 +159,7 @@ describe("subagent registry lifecycle error grace", () => { mod.testing.setDepsForTest({ callGateway: callGatewayMock as typeof import("../gateway/call.js").callGateway, getRuntimeConfig: loadConfigMock as typeof import("../config/config.js").getRuntimeConfig, + loadAgentRuntimePluginRegistryHandle: () => undefined, onAgentEvent: onAgentEventMock as unknown as typeof import("../infra/agent-events.js").onAgentEvent, }); diff --git a/src/agents/subagent-registry.persistence.test-support.ts b/src/agents/subagent-registry.persistence.test-support.ts index 29682653fd06..115966173ec6 100644 --- a/src/agents/subagent-registry.persistence.test-support.ts +++ b/src/agents/subagent-registry.persistence.test-support.ts @@ -109,7 +109,7 @@ export function createSubagentRegistryTestDeps( cleanupBrowserSessionsForLifecycleEnd: vi.fn(async () => {}), captureSubagentCompletionReply: vi.fn(async () => undefined), ensureContextEnginesInitialized: vi.fn(), - ensureRuntimePluginsLoaded: vi.fn(), + loadAgentRuntimePluginRegistryHandle: vi.fn(), getRuntimeConfig: vi.fn(() => ({})), getGatewayRecoveryRuntime: vi.fn(() => ({ dispatchAgent: vi.fn(), diff --git a/src/agents/subagent-registry.runtime.ts b/src/agents/subagent-registry.runtime.ts index 564d0a29db43..1c376ed93145 100644 --- a/src/agents/subagent-registry.runtime.ts +++ b/src/agents/subagent-registry.runtime.ts @@ -3,4 +3,3 @@ */ export { ensureContextEnginesInitialized } from "../context-engine/init.js"; export { resolveContextEngine } from "../context-engine/registry.js"; -export { ensureRuntimePluginsLoaded } from "./runtime-plugins.js"; diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts index 83b656817f2e..d8ae0c342358 100644 --- a/src/agents/subagent-registry.steer-restart.test.ts +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -187,7 +187,7 @@ describe("subagent registry steer restarts", () => { lifecycleHandler = undefined; mod.testing.setDepsForTest({ ensureContextEnginesInitialized: () => {}, - ensureRuntimePluginsLoaded: () => {}, + loadAgentRuntimePluginRegistryHandle: () => undefined, resolveContextEngine: async () => noopContextEngine, }); announceSpy.mockReset(); diff --git a/src/agents/subagent-registry.test-helpers.ts b/src/agents/subagent-registry.test-helpers.ts index 7dfe51819e72..e52b6eaa243c 100644 --- a/src/agents/subagent-registry.test-helpers.ts +++ b/src/agents/subagent-registry.test-helpers.ts @@ -56,7 +56,7 @@ type RegistryDeps = { runSubagentAnnounceFlow: typeof import("./subagent-announce.js").runSubagentAnnounceFlow; maybeWakeRequesterAfterAllChildrenSettled: typeof import("./subagent-announce.requester-settle-wake.js").maybeWakeRequesterAfterAllChildrenSettled; ensureContextEnginesInitialized?: () => void; - ensureRuntimePluginsLoaded?: typeof import("./runtime-plugins.js").ensureRuntimePluginsLoaded; + loadAgentRuntimePluginRegistryHandle?: import("./subagent-registry-deps.js").SubagentRegistryDeps["loadAgentRuntimePluginRegistryHandle"]; resolveContextEngine?: typeof import("../context-engine/registry.js").resolveContextEngine; }; diff --git a/src/agents/subagent-registry.test.ts b/src/agents/subagent-registry.test.ts index baf06dfc697e..15f3597a077f 100644 --- a/src/agents/subagent-registry.test.ts +++ b/src/agents/subagent-registry.test.ts @@ -17,6 +17,8 @@ import { } from "../config/sessions/transcript-write-context.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { AgentEventPayload } from "../infra/agent-events.js"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; +import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; import { getActiveGatewayRootWorkCount, markGatewayRestartDraining, @@ -42,7 +44,10 @@ import { SUBAGENT_ENDED_REASON_ERROR, SUBAGENT_ENDED_REASON_KILLED, } from "./subagent-lifecycle-events.js"; -import type { SubagentRunRecord } from "./subagent-registry.types.js"; +import type { + ContextEngineSubagentEndedParams, + SubagentRunRecord, +} from "./subagent-registry.types.js"; import { createSessionStore, createSubagentRunParams, @@ -181,8 +186,8 @@ const mocks = vi.hoisted(() => ({ }, ), getGlobalHookRunner: vi.fn(() => null), - ensureRuntimePluginsLoaded: vi.fn(), ensureContextEnginesInitialized: vi.fn(), + loadAgentRuntimePluginRegistryHandle: vi.fn(), resolveContextEngine: vi.fn(), onSubagentEnded: vi.fn< (params: { childSessionKey?: string }, context?: unknown) => Promise @@ -254,10 +259,6 @@ vi.mock("../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: mocks.getGlobalHookRunner, })); -vi.mock("./runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded: mocks.ensureRuntimePluginsLoaded, -})); - vi.mock("../context-engine/init.js", () => ({ ensureContextEnginesInitialized: mocks.ensureContextEnginesInitialized, })); @@ -337,6 +338,11 @@ describe("subagent registry seam flow", () => { mocks.resolveContextEngine.mockResolvedValue({ onSubagentEnded: mocks.onSubagentEnded, }); + const pluginRegistry = createEmptyPluginRegistry(); + mocks.loadAgentRuntimePluginRegistryHandle.mockReturnValue(pluginRegistry); + mocks.runSubagentEnded.mockImplementation(async () => { + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(pluginRegistry); + }); mocks.scheduleOrphanRecovery.mockReset(); mocks.resolveAgentTimeoutMs.mockReturnValue(1_000); mocks.restoreSubagentRunsFromDisk.mockReturnValue(0); @@ -369,7 +375,7 @@ describe("subagent registry seam flow", () => { // root-count drain assertions here. Wake behavior has its own suites. maybeWakeRequesterAfterAllChildrenSettled: mocks.maybeWakeRequesterAfterAllChildrenSettled, ensureContextEnginesInitialized: mocks.ensureContextEnginesInitialized, - ensureRuntimePluginsLoaded: mocks.ensureRuntimePluginsLoaded, + loadAgentRuntimePluginRegistryHandle: mocks.loadAgentRuntimePluginRegistryHandle, resolveContextEngine: mocks.resolveContextEngine, }); mod.resetSubagentRegistryForTests({ persist: false }); @@ -5054,9 +5060,10 @@ describe("subagent registry seam flow", () => { mockGatewayMethods(mocks.callGateway, { "agent.wait": { status: "pending" }, }); - mocks.ensureRuntimePluginsLoaded.mockRejectedValueOnce( - new Error("runtime unavailable during killed hook"), - ); + mocks.getGlobalHookRunner.mockReturnValue({ + hasHooks: (hookName: string) => hookName === "subagent_ended", + runSubagentEnded: vi.fn().mockRejectedValueOnce(new Error("ended hook unavailable")), + } as never); mod.registerSubagentRun({ runId: "run-killed-recovery", @@ -5089,11 +5096,8 @@ describe("subagent registry seam flow", () => { expect(run?.endedReason).toBe("subagent-killed"); expect(run?.suppressAnnounceReason).toBe("killed"); }); - expect(mocks.ensureRuntimePluginsLoaded).not.toHaveBeenCalled(); - await mod.testing.sweepOnceForTests(); await waitForFast(() => { - expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalled(); expect( mod .listSubagentRunsForRequester("agent:main:main") @@ -5123,7 +5127,14 @@ describe("subagent registry seam flow", () => { }); it("retries completion hooks before resuming ended cleanup", async () => { - mocks.ensureRuntimePluginsLoaded.mockRejectedValueOnce(new Error("runtime unavailable")); + const runSubagentEnded = vi + .fn() + .mockRejectedValueOnce(new Error("ended hook unavailable")) + .mockResolvedValue(undefined); + mocks.getGlobalHookRunner.mockReturnValue({ + hasHooks: (hookName: string) => hookName === "subagent_ended", + runSubagentEnded, + } as never); mod.registerSubagentRun({ runId: "run-hook-retry", @@ -5132,7 +5143,7 @@ describe("subagent registry seam flow", () => { }); await waitForFast(() => { - expect(mocks.ensureRuntimePluginsLoaded.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(runSubagentEnded.mock.calls.length).toBeGreaterThanOrEqual(2); const run = findRequesterRun("run-hook-retry"); expect(run?.cleanupCompletedAt).toBeTypeOf("number"); }); @@ -5192,56 +5203,6 @@ describe("subagent registry seam flow", () => { expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1); }); - it("emits the canonical ended hook when plugin loading overlaps a newer completion", async () => { - mocks.callGateway.mockImplementation(async (request: { method?: string }) => - request.method === "agent.wait" ? { status: "pending" } : {}, - ); - mocks.getGlobalHookRunner.mockReturnValue({ - hasHooks: (hookName: string) => hookName === "subagent_ended", - runSubagentEnded: mocks.runSubagentEnded, - } as never); - let releaseOldPluginLoad: (() => void) | undefined; - const oldPluginLoad = new Promise((resolve) => { - releaseOldPluginLoad = resolve; - }); - mocks.ensureRuntimePluginsLoaded.mockImplementationOnce(async () => { - await oldPluginLoad; - }); - - mod.registerSubagentRun({ - runId: "run-hook-timeout-then-ok", - childSessionKey: "agent:main:subagent:hook-timeout-then-ok", - task: "publish only the canonical hook", - expectsCompletionMessage: false, - }); - const lifecycleHandler = getLifecycleHandler(); - - lifecycleHandler?.({ - runId: "run-hook-timeout-then-ok", - stream: "lifecycle", - data: { phase: "end", startedAt: 100, endedAt: 200, aborted: true }, - }); - await vi.advanceTimersByTimeAsync(15_000); - await waitForFast(() => expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalledTimes(1)); - - lifecycleHandler?.({ - runId: "run-hook-timeout-then-ok", - stream: "lifecycle", - data: { phase: "end", startedAt: 100, endedAt: 250 }, - }); - await waitForFast(() => expect(mocks.runSubagentEnded).toHaveBeenCalledTimes(1)); - releaseOldPluginLoad?.(); - await Promise.resolve(); - await Promise.resolve(); - - expect(mocks.runSubagentEnded).toHaveBeenCalledTimes(1); - expectRecordFields( - getMockCallArg(mocks.runSubagentEnded, 0, 0, "canonical ended hook"), - { reason: "subagent-complete", outcome: "ok", error: undefined }, - "canonical ended hook", - ); - }); - it("deletes delete-mode completion runs when announce cleanup gives up after retry limit", async () => { mocks.runSubagentAnnounceFlow.mockResolvedValue(false); const endedAt = Date.parse("2026-03-24T12:00:00Z"); @@ -5557,10 +5518,7 @@ describe("subagent registry seam flow", () => { hasHooks: (hookName: string) => hookName === "subagent_ended", runSubagentEnded: mocks.runSubagentEnded, }; - mocks.getGlobalHookRunner.mockReturnValue(null); - mocks.ensureRuntimePluginsLoaded.mockImplementation(() => { - mocks.getGlobalHookRunner.mockReturnValue(endedHookRunner as never); - }); + mocks.getGlobalHookRunner.mockReturnValue(endedHookRunner as never); mod.registerSubagentRun({ runId: "run-killed-init", @@ -5587,20 +5545,8 @@ describe("subagent registry seam flow", () => { elapsedMs: 0, }); expect(mocks.runSubagentEnded).not.toHaveBeenCalled(); - mocks.ensureRuntimePluginsLoaded.mockClear(); - vi.setSystemTime(killedAt + 5 * 60_000); await mod.testing.sweepOnceForTests(); - await waitForFast(() => { - expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ - config: { - agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } }, - session: { mainKey: "main", scope: "per-sender" }, - }, - workspaceDir: "/tmp/killed-workspace", - allowGatewaySubagentBinding: true, - }); - }); await waitForFast(() => expect(mocks.runSubagentEnded).toHaveBeenCalled()); expectRecordFields( getMockCallArg(mocks.runSubagentEnded, 0, 0, "subagent ended hook"), @@ -6038,7 +5984,18 @@ describe("subagent registry seam flow", () => { }); }); - it("loads plugin and context-engine runtime before released end hooks", async () => { + it("loads context-engine runtime before released end hooks", async () => { + const pluginRegistry = createEmptyPluginRegistry(); + mocks.loadAgentRuntimePluginRegistryHandle.mockReturnValue(pluginRegistry); + mocks.resolveContextEngine.mockImplementationOnce(async () => { + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(pluginRegistry); + return { + onSubagentEnded: async (params: ContextEngineSubagentEndedParams) => { + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(pluginRegistry); + await mocks.onSubagentEnded(params); + }, + }; + }); mod.addSubagentRunForTests({ runId: "run-release-context-engine", childSessionKey: "agent:main:session:child", @@ -6068,14 +6025,6 @@ describe("subagent registry seam flow", () => { workspaceDir: "/tmp/workspace", }); }); - expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ - config: { - agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } }, - session: { mainKey: "main", scope: "per-sender" }, - }, - workspaceDir: "/tmp/workspace", - allowGatewaySubagentBinding: true, - }); expect(mocks.ensureContextEnginesInitialized).toHaveBeenCalledTimes(1); expect(mocks.resolveContextEngine).toHaveBeenCalledWith( { diff --git a/src/agents/subagent-spawn.in-process-gateway.test.ts b/src/agents/subagent-spawn.in-process-gateway.test.ts index bfdb9b93724b..0385916a0e23 100644 --- a/src/agents/subagent-spawn.in-process-gateway.test.ts +++ b/src/agents/subagent-spawn.in-process-gateway.test.ts @@ -103,10 +103,10 @@ describe("spawnSubagentDirect in-process Gateway collector launch", () => { clearRuntimeConfigSnapshot(); clearConfigCache(); subagentRegistryTesting.setDepsForTest({ + loadAgentRuntimePluginRegistryHandle: () => undefined, persistSubagentRunsToDisk: () => {}, persistSubagentRunsToDiskOrThrow: () => {}, restoreSubagentRunsFromDisk: () => 0, - ensureRuntimePluginsLoaded: () => {}, }); stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-swarm-gateway-")); diff --git a/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts b/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts index 4c9251fdb478..c093c1ff4278 100644 --- a/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts +++ b/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts @@ -51,7 +51,7 @@ export function installEmbeddedRunnerBaseE2eMocks(options?: { resolveContextEngineOwnerPluginId: vi.fn(() => undefined), })); vi.doMock("../runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded: vi.fn(), + loadAgentRuntimePluginRegistryHandle: vi.fn(() => ({ agentHarnesses: [] })), })); vi.doMock("../harness/runtime-plugin.js", () => ({ ensureSelectedAgentHarnessPlugin: vi.fn(async () => {}), diff --git a/src/agents/tools/swarm-tools.integration.test.ts b/src/agents/tools/swarm-tools.integration.test.ts index 6c39d1c6c01c..8a0739d502c7 100644 --- a/src/agents/tools/swarm-tools.integration.test.ts +++ b/src/agents/tools/swarm-tools.integration.test.ts @@ -131,7 +131,7 @@ describe("swarm tools integration", () => { restoreSubagentRunsFromDisk: vi.fn(() => 0), runSubagentAnnounceFlow: vi.fn(async () => true), ensureContextEnginesInitialized: vi.fn(), - ensureRuntimePluginsLoaded: vi.fn(), + loadAgentRuntimePluginRegistryHandle: vi.fn(), resolveContextEngine: vi.fn(async () => ({ info: { id: "test", name: "Test", version: "0.0.1" }, ingest: vi.fn(async () => ({ ingested: false })), diff --git a/src/auto-reply/reply/dispatch-from-config.base.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.base.test-utils.ts index 6efb26677abb..b8615952f093 100644 --- a/src/auto-reply/reply/dispatch-from-config.base.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.base.test-utils.ts @@ -57,7 +57,7 @@ beforeAll(globalBeforeAll0); describe("dispatchReplyFromConfig", () => { beforeEach(describe0BeforeEach0); - it("loads runtime plugins before reading inbound hook state", async () => { + it("loads a registry handle before reading inbound hook state", async () => { setNoAbort(); const cfg = emptyConfig; const dispatcher = createDispatcher(); @@ -70,12 +70,14 @@ describe("dispatchReplyFromConfig", () => { await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); const pluginLoadOptions = firstMockArg( - runtimePluginMocks.ensureRuntimePluginsLoaded, + runtimePluginMocks.loadAgentRuntimePluginRegistryHandle, "runtime plugin load", ) as { config?: unknown; workspaceDir?: unknown }; expect(pluginLoadOptions.config).toBe(cfg); expect(typeof pluginLoadOptions.workspaceDir).toBe("string"); - expect(runtimePluginMocks.ensureRuntimePluginsLoaded.mock.invocationCallOrder[0]).toBeLessThan( + expect( + runtimePluginMocks.loadAgentRuntimePluginRegistryHandle.mock.invocationCallOrder[0], + ).toBeLessThan( expectDefined( hookMocks.runner.hasHooks.mock.invocationCallOrder[0], "hookMocks.runner.hasHooks.mock.invocationCallOrder[0] test invariant", diff --git a/src/auto-reply/reply/dispatch-from-config.gather.ts b/src/auto-reply/reply/dispatch-from-config.gather.ts index 845cb0a2a4be..89632da6b1ed 100644 --- a/src/auto-reply/reply/dispatch-from-config.gather.ts +++ b/src/auto-reply/reply/dispatch-from-config.gather.ts @@ -22,6 +22,7 @@ import { import { createDiagnosticMessageLifecycle } from "../../logging/message-lifecycle.js"; import { stripLegacyMediaContextFields } from "../../media/media-facts.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; +import { withPluginRuntimeRegistryScope } from "../../plugins/runtime/gateway-request-scope.js"; import { normalizeTtsAutoMode } from "../../tts/tts-config.js"; import type { FinalizedRuntimeMsgContext as FinalizedMsgContext } from "../templating.js"; import { normalizeVerboseLevel } from "../thinking.js"; @@ -358,140 +359,150 @@ export async function gatherDispatchRequest( hasInboundAudio: () => inboundAudio || getDispatchReplyOperation()?.acceptedSteeredInboundAudio === true, }); - const { ensureRuntimePluginsLoaded } = await traceReplyPhase("reply.load_runtime_plugins", () => - loadRuntimePlugins(), + const { loadAgentRuntimePluginRegistryHandle } = await traceReplyPhase( + "reply.load_runtime_plugins", + loadRuntimePlugins, ); - await traceReplyPhase("reply.ensure_runtime_plugins", () => { - ensureRuntimePluginsLoaded({ config: cfg, workspaceDir }); - }); - const hookRunner = getGlobalHookRunner(); - // Extract message context for hooks (plugin and internal) - const timestamp = - typeof ctx.Timestamp === "number" && Number.isFinite(ctx.Timestamp) ? ctx.Timestamp : undefined; - const messageIdForHook = - ctx.MessageSidFull ?? ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast; - const hookCtx = { ...ctx }; - const buildHookState = (sourceCtx: FinalizedMsgContext) => { - const nextHookContext = deriveInboundMessageHookContext(sourceCtx, { - messageId: messageIdForHook, - }); - const inboundClaim = toPluginInboundClaimPair(nextHookContext, { - commandAuthorized: - typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : undefined, - wasMentioned: typeof ctx.WasMentioned === "boolean" ? ctx.WasMentioned : undefined, - }); - return { - hookContext: nextHookContext, - inboundClaimContext: inboundClaim.context, - inboundClaimEvent: inboundClaim.event, + const pluginRegistry = await traceReplyPhase("reply.load_runtime_plugin_registry_handle", () => + loadAgentRuntimePluginRegistryHandle({ + config: cfg, + workspaceDir, + allowGatewaySubagentBinding: true, + }), + ); + return await withPluginRuntimeRegistryScope(pluginRegistry, async () => { + const hookRunner = getGlobalHookRunner(); + // Extract message context for hooks (plugin and internal) + const timestamp = + typeof ctx.Timestamp === "number" && Number.isFinite(ctx.Timestamp) + ? ctx.Timestamp + : undefined; + const messageIdForHook = + ctx.MessageSidFull ?? ctx.MessageSid ?? ctx.MessageSidFirst ?? ctx.MessageSidLast; + const hookCtx = { ...ctx }; + const buildHookState = (sourceCtx: FinalizedMsgContext) => { + const nextHookContext = deriveInboundMessageHookContext(sourceCtx, { + messageId: messageIdForHook, + }); + const inboundClaim = toPluginInboundClaimPair(nextHookContext, { + commandAuthorized: + typeof ctx.CommandAuthorized === "boolean" ? ctx.CommandAuthorized : undefined, + wasMentioned: typeof ctx.WasMentioned === "boolean" ? ctx.WasMentioned : undefined, + }); + return { + hookContext: nextHookContext, + inboundClaimContext: inboundClaim.context, + inboundClaimEvent: inboundClaim.event, + }; }; - }; - const hookState = buildHookState(hookCtx); - const { isGroup, groupId } = hookState.hookContext; - let hookMediaPrepared = false; - let hookMediaMetadataStaged = false; - const prepareHookMediaMetadata = async () => { - if (hookMediaPrepared) { - return; - } - hookMediaPrepared = true; - // Plugin hooks may run in a different Codex cwd from core dispatch, so - // only actual hook/plugin-claim consumers get remote-cache media paths. - // Keep ctx unstaged for the normal get-reply single-stage path. - const staged = await traceReplyPhase("reply.stage_remote_media_for_dispatch", () => - stageRemoteInboundMediaIfNeeded({ - ctx: hookCtx, - cfg, - sessionKey: acpDispatchSessionKey, - workspaceDir, - remoteMediaMode: "cache", - }), - ); - if (staged) { - hookMediaMetadataStaged = true; - Object.assign(hookState, buildHookState(hookCtx)); - } - }; - const buildMessageReceivedHookContext = () => { - const mediaRemoteHost = normalizeOptionalString(ctx.MediaRemoteHost); - const { hookContext } = hookState; - const hasUnstagedRemoteMediaMetadata = Boolean(hookContext.media?.length); - if (hookMediaMetadataStaged || !mediaRemoteHost || !hasUnstagedRemoteMediaMetadata) { - return hookContext; - } - const messageReceivedCtx = { ...hookCtx }; - // message_received hooks run before normal get-reply staging, so remote - // host paths are not safe as live media. Keep originals as debug metadata. - stripLegacyMediaContextFields(messageReceivedCtx); - delete messageReceivedCtx.media; - return { - ...buildHookState(messageReceivedCtx).hookContext, - mediaRemoteHost, - mediaStagingPending: true, - originalMedia: hookContext.media?.map((entry) => ({ ...entry })), - originalMediaPath: hookContext.mediaPath, - originalMediaUrl: hookContext.mediaUrl, - originalMediaType: hookContext.mediaType, - originalMediaPaths: hookContext.mediaPaths, - originalMediaUrls: hookContext.mediaUrls, - originalMediaTypes: hookContext.mediaTypes, + const hookState = buildHookState(hookCtx); + const { isGroup, groupId } = hookState.hookContext; + let hookMediaPrepared = false; + let hookMediaMetadataStaged = false; + const prepareHookMediaMetadata = async () => { + if (hookMediaPrepared) { + return; + } + hookMediaPrepared = true; + // Plugin hooks may run in a different Codex cwd from core dispatch, so + // only actual hook/plugin-claim consumers get remote-cache media paths. + // Keep ctx unstaged for the normal get-reply single-stage path. + const staged = await traceReplyPhase("reply.stage_remote_media_for_dispatch", () => + stageRemoteInboundMediaIfNeeded({ + ctx: hookCtx, + cfg, + sessionKey: acpDispatchSessionKey, + workspaceDir, + remoteMediaMode: "cache", + }), + ); + if (staged) { + hookMediaMetadataStaged = true; + Object.assign(hookState, buildHookState(hookCtx)); + } }; - }; - const nextState = extendPreparedDispatchState(state, { - ctx, - cfg, - dispatcher, - sessionKey, - traceReplyPhase, - recordProcessed, - recordAgentDispatchStarted, - recordAgentDispatchCompleted, - markProcessing, - markIdle, - markInboundDedupeReplayUnsafe, - acpDispatchSessionKey, - markProgress, - sessionStoreEntry, - notePreparedSession, - resolvePreparedTranscriptBinding, - sessionAgentId, - shouldEmitVerboseProgress, - shouldEmitFullVerboseProgress, - replyRoute, - routeReplyThreadId, - inboundAudio, - sessionTtsAuto, - workspaceDir, - replyOperationRunState, - completeDispatchReplyOperation, - dispatchHookDispatcher, - ensureDispatchReplyOperation, - failDispatchReplyOperation, - getDispatchAbortOperation, - getDispatchAbortSignal, - getDispatchReplyOperation, - getObservedReplyDelivery, - getPreDispatchAbortSignal, - getReplyOptions, - isDispatchOperationAborted, - isPreDispatchOperationAborted, - markObservedReplyDelivery, - releasePreDispatchLifecycleAdmission, - runWithDispatchLifecycleAdmission, - throwIfDispatchOperationAborted, - trackDispatchLifecycleWork, - turnLedger, - maybeApplyTtsWithFinalizationLease, - hookRunner, - timestamp, - messageIdForHook, - isGroup, - groupId, - hookState, - prepareHookMediaMetadata, - buildMessageReceivedHookContext, + const buildMessageReceivedHookContext = () => { + const mediaRemoteHost = normalizeOptionalString(ctx.MediaRemoteHost); + const { hookContext } = hookState; + const hasUnstagedRemoteMediaMetadata = Boolean(hookContext.media?.length); + if (hookMediaMetadataStaged || !mediaRemoteHost || !hasUnstagedRemoteMediaMetadata) { + return hookContext; + } + const messageReceivedCtx = { ...hookCtx }; + // message_received hooks run before normal get-reply staging, so remote + // host paths are not safe as live media. Keep originals as debug metadata. + stripLegacyMediaContextFields(messageReceivedCtx); + delete messageReceivedCtx.media; + return { + ...buildHookState(messageReceivedCtx).hookContext, + mediaRemoteHost, + mediaStagingPending: true, + originalMedia: hookContext.media?.map((entry) => ({ ...entry })), + originalMediaPath: hookContext.mediaPath, + originalMediaUrl: hookContext.mediaUrl, + originalMediaType: hookContext.mediaType, + originalMediaPaths: hookContext.mediaPaths, + originalMediaUrls: hookContext.mediaUrls, + originalMediaTypes: hookContext.mediaTypes, + }; + }; + const nextState = extendPreparedDispatchState(state, { + ctx, + cfg, + dispatcher, + sessionKey, + traceReplyPhase, + recordProcessed, + recordAgentDispatchStarted, + recordAgentDispatchCompleted, + markProcessing, + markIdle, + markInboundDedupeReplayUnsafe, + acpDispatchSessionKey, + markProgress, + sessionStoreEntry, + notePreparedSession, + resolvePreparedTranscriptBinding, + sessionAgentId, + shouldEmitVerboseProgress, + shouldEmitFullVerboseProgress, + replyRoute, + routeReplyThreadId, + inboundAudio, + sessionTtsAuto, + workspaceDir, + pluginRegistry, + replyOperationRunState, + completeDispatchReplyOperation, + dispatchHookDispatcher, + ensureDispatchReplyOperation, + failDispatchReplyOperation, + getDispatchAbortOperation, + getDispatchAbortSignal, + getDispatchReplyOperation, + getObservedReplyDelivery, + getPreDispatchAbortSignal, + getReplyOptions, + isDispatchOperationAborted, + isPreDispatchOperationAborted, + markObservedReplyDelivery, + releasePreDispatchLifecycleAdmission, + runWithDispatchLifecycleAdmission, + throwIfDispatchOperationAborted, + trackDispatchLifecycleWork, + turnLedger, + maybeApplyTtsWithFinalizationLease, + hookRunner, + timestamp, + messageIdForHook, + isGroup, + groupId, + hookState, + prepareHookMediaMetadata, + buildMessageReceivedHookContext, + }); + return { status: "ready" as const, state: nextState }; }); - return { status: "ready" as const, state: nextState }; } type GatherDispatchRequestResult = Awaited>; diff --git a/src/auto-reply/reply/dispatch-from-config.lifecycle-and-bindings.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.lifecycle-and-bindings.test-utils.ts index 50e4883a303a..832538b9bbad 100644 --- a/src/auto-reply/reply/dispatch-from-config.lifecycle-and-bindings.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.lifecycle-and-bindings.test-utils.ts @@ -393,9 +393,9 @@ describe("dispatchReplyFromConfig", () => { ); }); - it("audits setup failures without replacing the dispatch error", async () => { + it("audits registry-load failures without exposing the setup error", async () => { setNoAbort(); - runtimePluginMocks.ensureRuntimePluginsLoaded.mockImplementationOnce(() => { + runtimePluginMocks.loadAgentRuntimePluginRegistryHandle.mockImplementationOnce(() => { throw new Error("setup failed"); }); diff --git a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts index b2ca7558b545..13375559a5ee 100644 --- a/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.reply-dispatch.test.ts @@ -2,6 +2,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { clearAgentHarnesses } from "../../agents/harness/registry.js"; import type { PluginHookReplyDispatchResult } from "../../plugins/hooks.test-fixtures.js"; +import { getPluginRuntimeGatewayRequestScope } from "../../plugins/runtime/gateway-request-scope.js"; import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js"; import { withReplyDispatcher } from "../dispatch-dispatcher.js"; import { setReplyPayloadMetadata } from "../reply-payload.js"; @@ -35,12 +36,6 @@ let resetReplyRunRegistry: typeof import("./reply-run-registry.test-support.js") const REPLY_RUN_FINALIZATION_SETTLE_TIMEOUT_MS = 60_000; -function firstRuntimeLoadCall() { - return runtimePluginMocks.ensureRuntimePluginsLoaded.mock.calls[0]?.[0] as - | { config?: unknown; workspaceDir?: unknown } - | undefined; -} - function firstReplyDispatchCall() { return hookMocks.runner.runReplyDispatch.mock.calls[0] as | [ @@ -154,15 +149,23 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { diagnosticMocks.logMessageProcessed.mockReset(); diagnosticMocks.logSessionStateChange.mockReset(); diagnosticMocks.markDiagnosticSessionProgress.mockReset(); - runtimePluginMocks.ensureRuntimePluginsLoaded.mockReset(); + runtimePluginMocks.loadAgentRuntimePluginRegistryHandle.mockReset(); + runtimePluginMocks.loadAgentRuntimePluginRegistryHandle.mockReturnValue( + runtimePluginMocks.pluginRegistry, + ); resetPluginTtsAndThreadMocks(); }); it("returns handled dispatch results from plugins", async () => { - hookMocks.runner.runReplyDispatch.mockResolvedValue({ - handled: true, - queuedFinal: true, - counts: { tool: 1, block: 2, final: 3 }, + hookMocks.runner.runReplyDispatch.mockImplementation(async () => { + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe( + runtimePluginMocks.pluginRegistry, + ); + return { + handled: true, + queuedFinal: true, + counts: { tool: 1, block: 2, final: 3 }, + }; }); const result = await dispatchReplyFromConfig({ @@ -175,12 +178,11 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => { replyResolver: async () => ({ text: "model reply" }), }); - expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledOnce(); - const runtimeLoadCall = firstRuntimeLoadCall(); - expect(runtimeLoadCall?.config).toBe(emptyConfig); - expect(typeof runtimeLoadCall?.workspaceDir).toBe("string"); - expect(String(runtimeLoadCall?.workspaceDir).length).toBeGreaterThan(0); - + expect(runtimePluginMocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledWith({ + config: emptyConfig, + workspaceDir: expect.any(String), + allowGatewaySubagentBinding: true, + }); expect(hookMocks.runner.runReplyDispatch).toHaveBeenCalledOnce(); const [replyDispatchEvent, replyDispatchRuntime] = firstReplyDispatchCall() ?? []; expect(replyDispatchEvent?.sessionKey).toBe("agent:test:session"); diff --git a/src/auto-reply/reply/dispatch-from-config.runtime-loaders.ts b/src/auto-reply/reply/dispatch-from-config.runtime-loaders.ts index d863849c596d..2eac238a3d80 100644 --- a/src/auto-reply/reply/dispatch-from-config.runtime-loaders.ts +++ b/src/auto-reply/reply/dispatch-from-config.runtime-loaders.ts @@ -6,12 +6,12 @@ const getReplyFromConfigRuntimeLoader = createLazyImportLoader( ); const abortRuntimeLoader = createLazyImportLoader(() => import("./abort.runtime.js")); const fastApproveRuntimeLoader = createLazyImportLoader(() => import("./fast-approve.runtime.js")); -const runtimePluginsLoader = createLazyImportLoader( - () => import("../../plugins/runtime-plugins.runtime.js"), -); const replyMediaPathsRuntimeLoader = createLazyImportLoader( () => import("./reply-media-paths.runtime.js"), ); +const runtimePluginsLoader = createLazyImportLoader( + () => import("../../agents/runtime-plugins.js"), +); export function loadRouteReplyRuntime() { return routeReplyRuntimeLoader.load(); @@ -29,10 +29,10 @@ export function loadFastApproveRuntime() { return fastApproveRuntimeLoader.load(); } -export function loadRuntimePlugins() { - return runtimePluginsLoader.load(); -} - export function loadReplyMediaPathsRuntime() { return replyMediaPathsRuntimeLoader.load(); } + +export function loadRuntimePlugins() { + return runtimePluginsLoader.load(); +} diff --git a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts index 7f613036d8ed..d1178c196fad 100644 --- a/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts +++ b/src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts @@ -279,7 +279,8 @@ const stageSandboxMediaMocks = vi.hoisted(() => ({ ), })); const runtimePluginMocks = vi.hoisted(() => ({ - ensureRuntimePluginsLoaded: vi.fn(), + pluginRegistry: { plugins: [], tools: [], diagnostics: [] }, + loadAgentRuntimePluginRegistryHandle: vi.fn(), })); const conversationBindingMocks = vi.hoisted(() => { type BindingMsgContext = { @@ -635,8 +636,8 @@ vi.mock("./reply-media-paths.runtime.js", () => ({ vi.mock("./stage-sandbox-media.runtime.js", () => ({ stageSandboxMedia: (params: unknown) => stageSandboxMediaMocks.stageSandboxMedia(params), })); -vi.mock("../../plugins/runtime-plugins.runtime.js", () => ({ - ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded, +vi.mock("../../agents/runtime-plugins.js", () => ({ + loadAgentRuntimePluginRegistryHandle: runtimePluginMocks.loadAgentRuntimePluginRegistryHandle, })); vi.mock("./conversation-binding-input.js", () => ({ resolveConversationBindingAccountIdFromMessage: diff --git a/src/auto-reply/reply/dispatch-from-config.stale-recovery.test.ts b/src/auto-reply/reply/dispatch-from-config.stale-recovery.test.ts index 271219108843..c48e5c2bbda5 100644 --- a/src/auto-reply/reply/dispatch-from-config.stale-recovery.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.stale-recovery.test.ts @@ -8,7 +8,6 @@ import { mocks, noAbortResult, resetPluginTtsAndThreadMocks, - runtimePluginMocks, } from "./dispatch-from-config.shared.test-harness.js"; import type { DispatchFromConfigParams } from "./dispatch-from-config.types.js"; import { buildTestCtx } from "./test-ctx.js"; @@ -59,7 +58,6 @@ describe("dispatchReplyFromConfig stale visible admission recovery", () => { replyRunTesting.resetReplyRunRegistry(); resetInboundDedupe(); resetPluginTtsAndThreadMocks(); - runtimePluginMocks.ensureRuntimePluginsLoaded.mockReset(); mocks.routeReply.mockReset(); mocks.routeReply.mockResolvedValue({ ok: true, delivered: true, messageId: "mock" }); mocks.tryFastAbortFromMessage.mockReset(); diff --git a/src/auto-reply/reply/dispatch-from-config.terminal-recovery.test.ts b/src/auto-reply/reply/dispatch-from-config.terminal-recovery.test.ts index 0519e406e08d..03636349c685 100644 --- a/src/auto-reply/reply/dispatch-from-config.terminal-recovery.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.terminal-recovery.test.ts @@ -7,7 +7,6 @@ import { mocks, noAbortResult, resetPluginTtsAndThreadMocks, - runtimePluginMocks, sessionStoreMocks, } from "./dispatch-from-config.shared.test-harness.js"; import type { DispatchFromConfigParams } from "./dispatch-from-config.types.js"; @@ -52,7 +51,6 @@ describe("dispatchReplyFromConfig terminal visible admission recovery", () => { replyRunTesting.resetReplyRunRegistry(); resetInboundDedupe(); resetPluginTtsAndThreadMocks(); - runtimePluginMocks.ensureRuntimePluginsLoaded.mockReset(); mocks.routeReply.mockReset(); mocks.routeReply.mockResolvedValue({ ok: true, delivered: true, messageId: "mock" }); mocks.tryFastAbortFromMessage.mockReset(); diff --git a/src/auto-reply/reply/dispatch-from-config.test-harness.ts b/src/auto-reply/reply/dispatch-from-config.test-harness.ts index f109519da81d..11a673bad3ef 100644 --- a/src/auto-reply/reply/dispatch-from-config.test-harness.ts +++ b/src/auto-reply/reply/dispatch-from-config.test-harness.ts @@ -11,7 +11,7 @@ import type { AcpRuntimeTurnInput, } from "../../plugin-sdk/acp-runtime.js"; import { clearPluginCommands } from "../../plugins/commands.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { getActivePluginRegistry, setActivePluginRegistry } from "../../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry, @@ -562,7 +562,10 @@ export const describe0BeforeEach0 = () => { transcriptMocks.appendAssistantMessageToSessionTranscript.mockClear(); stageSandboxMediaMocks.stageSandboxMedia.mockReset(); stageSandboxMediaMocks.stageSandboxMedia.mockResolvedValue({ staged: new Map() }); - runtimePluginMocks.ensureRuntimePluginsLoaded.mockClear(); + runtimePluginMocks.loadAgentRuntimePluginRegistryHandle.mockReset(); + runtimePluginMocks.loadAgentRuntimePluginRegistryHandle.mockImplementation( + () => getActivePluginRegistry() ?? runtimePluginMocks.pluginRegistry, + ); }; export const createHookCtx = (overrides: Partial = {}) => diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index c06d8b786a1a..5957c2a36ad5 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -1,4 +1,5 @@ /** Main reply dispatch pipeline from finalized config/context to delivery payloads. */ +import { withPluginRuntimeRegistryScope } from "../../plugins/runtime/gateway-request-scope.js"; import { isDispatchReplyOperationAbortedError } from "./dispatch-from-config.abort.js"; import { createInboundMessageAuditTerminal } from "./dispatch-from-config.audit.js"; import { chooseDispatchRoute } from "./dispatch-from-config.choose-route.js"; @@ -42,57 +43,59 @@ async function dispatchReplyFromConfigInner( return gathered.result; } - const delivery = await prepareDispatchDelivery(gathered.state); + return await withPluginRuntimeRegistryScope(gathered.state.pluginRegistry, async () => { + const delivery = await prepareDispatchDelivery(gathered.state); - const context = await prepareDispatchOperationContext(delivery.state); - if (context.status === "complete") { - return context.result; - } - - const errorState = context.state; - try { - const operation = await prepareDispatchOperation(context.state); - if (operation.status === "complete") { - return operation.result; + const context = await prepareDispatchOperationContext(delivery.state); + if (context.status === "complete") { + return context.result; } - const route = await chooseDispatchRoute(operation.state); - if (route.status === "complete") { - return route.result; - } - - const execution = await prepareDispatchExecution(route.state); - - const executed = await executeDispatch(execution.state); - if (executed.status === "complete") { - return executed.result; - } - - const finalized = await finalizeDispatchAndAudit(executed.state); - return finalized.result; - } catch (err) { - const { - failDispatchReplyOperation, - finishReplyOperationAbortedDispatch, - inboundDedupeClaim, - markIdle, - recordAgentDispatchCompleted, - recordProcessed, - } = errorState; - if (isDispatchReplyOperationAbortedError(err)) { - return finishReplyOperationAbortedDispatch(); - } - if (inboundDedupeClaim.status === "claimed") { - if (errorState.inboundDedupeReplayUnsafe) { - commitInboundDedupe(inboundDedupeClaim.key); - } else { - releaseInboundDedupe(inboundDedupeClaim.key); + const errorState = context.state; + try { + const operation = await prepareDispatchOperation(context.state); + if (operation.status === "complete") { + return operation.result; } + + const route = await chooseDispatchRoute(operation.state); + if (route.status === "complete") { + return route.result; + } + + const execution = await prepareDispatchExecution(route.state); + + const executed = await executeDispatch(execution.state); + if (executed.status === "complete") { + return executed.result; + } + + const finalized = await finalizeDispatchAndAudit(executed.state); + return finalized.result; + } catch (err) { + const { + failDispatchReplyOperation, + finishReplyOperationAbortedDispatch, + inboundDedupeClaim, + markIdle, + recordAgentDispatchCompleted, + recordProcessed, + } = errorState; + if (isDispatchReplyOperationAbortedError(err)) { + return finishReplyOperationAbortedDispatch(); + } + if (inboundDedupeClaim.status === "claimed") { + if (errorState.inboundDedupeReplayUnsafe) { + commitInboundDedupe(inboundDedupeClaim.key); + } else { + releaseInboundDedupe(inboundDedupeClaim.key); + } + } + recordAgentDispatchCompleted("error", { error: String(err) }); + recordProcessed("error", { error: String(err) }); + markIdle("message_error"); + failDispatchReplyOperation(err); + throw err; } - recordAgentDispatchCompleted("error", { error: String(err) }); - recordProcessed("error", { error: String(err) }); - markIdle("message_error"); - failDispatchReplyOperation(err); - throw err; - } + }); } diff --git a/src/cli/program/message/helpers.test.ts b/src/cli/program/message/helpers.test.ts index e24c2e33de06..f171eabb317a 100644 --- a/src/cli/program/message/helpers.test.ts +++ b/src/cli/program/message/helpers.test.ts @@ -16,10 +16,16 @@ vi.mock("../../../globals.js", () => ({ setVerbose: vi.fn(), })); -vi.mock("../../plugin-registry.js", () => ({ - ensurePluginRegistryLoaded: vi.fn(), +const loadPluginRegistryHandleMock = vi.fn(() => ({ gatewayHandlers: {} })); +vi.mock("../../../config/config.js", () => ({ getRuntimeConfig: () => ({}) })); +vi.mock("../../../plugins/channel-plugin-ids.js", () => ({ + resolveConfiguredChannelPluginIds: () => ["configured-channel"], + resolveDiscoverableScopedChannelPluginIds: (params: { channelIds: string[] }) => + params.channelIds, +})); +vi.mock("../../../plugins/loader.js", () => ({ + loadPluginRegistryHandle: loadPluginRegistryHandleMock, })); -const { ensurePluginRegistryLoaded } = await import("../../plugin-registry.js"); const hasHooksMock = vi.fn((_hookName: string) => false); const runGatewayStopMock = vi.fn( @@ -120,6 +126,12 @@ function expectMessageCommandOptions(expected: Record, callInde } } +function expectRegistryLoad(pluginIds: string[]): void { + expect(loadPluginRegistryHandleMock).toHaveBeenCalledWith( + expect.objectContaining({ onlyPluginIds: pluginIds, throwOnLoadError: true }), + ); +} + describe("runMessageAction", () => { beforeEach(() => { vi.clearAllMocks(); @@ -137,10 +149,7 @@ describe("runMessageAction", () => { it("calls exit(0) after successful message delivery", async () => { await runSendAction(); - expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({ - scope: "configured-channels", - onlyChannelIds: ["discord"], - }); + expectRegistryLoad(["discord"]); expect(exitMock).toHaveBeenCalledOnce(); expect(exitMock).toHaveBeenCalledWith(0); }); @@ -148,18 +157,13 @@ describe("runMessageAction", () => { it("loads configured channel plugins when no target channel is known yet", async () => { await runSendAction({ channel: undefined }); - expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({ - scope: "configured-channels", - }); + expectRegistryLoad(["configured-channel"]); }); it("narrows plugin loading from a channel-prefixed target", async () => { await runSendAction({ channel: undefined, target: "discord:channel:12345" }); - expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({ - scope: "configured-channels", - onlyChannelIds: ["discord"], - }); + expectRegistryLoad(["discord"]); }); it("skips local plugin preload for any gateway-owned scoped channel action", async () => { @@ -167,7 +171,7 @@ describe("runMessageAction", () => { await runSendAction({ target: "channel:12345" }); - expect(ensurePluginRegistryLoaded).not.toHaveBeenCalled(); + expect(loadPluginRegistryHandleMock).not.toHaveBeenCalled(); expectMessageCommandOptions({ action: "send", channel: "discord", @@ -187,10 +191,7 @@ describe("runMessageAction", () => { }), ).rejects.toThrow("exit"); - expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({ - scope: "configured-channels", - onlyChannelIds: ["telegram"], - }); + expectRegistryLoad(["telegram"]); expectMessageCommandOptions({ action: "broadcast", targets: ["telegram:1", "telegram:2"], @@ -209,10 +210,7 @@ describe("runMessageAction", () => { }), ).rejects.toThrow("exit"); - expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({ - scope: "configured-channels", - onlyChannelIds: ["discord"], - }); + expectRegistryLoad(["discord"]); expectMessageCommandOptions({ action: "custom-action" }); }); @@ -221,16 +219,13 @@ describe("runMessageAction", () => { await runSendAction({ target: "channel:12345" }); - expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({ - scope: "configured-channels", - onlyChannelIds: ["discord"], - }); + expectRegistryLoad(["discord"]); }); it("keeps target-prefixed Telegram sends from local plugin preload", async () => { await runSendAction({ channel: undefined, target: "telegram:12345" }); - expect(ensurePluginRegistryLoaded).not.toHaveBeenCalled(); + expect(loadPluginRegistryHandleMock).not.toHaveBeenCalled(); expectMessageCommandOptions({ action: "send", target: "telegram:12345", @@ -250,7 +245,7 @@ describe("runMessageAction", () => { forceDocument: true, }); - expect(ensurePluginRegistryLoaded).not.toHaveBeenCalled(); + expect(loadPluginRegistryHandleMock).not.toHaveBeenCalled(); expectMessageCommandOptions({ action: "send", channel: "telegram", @@ -273,10 +268,7 @@ describe("runMessageAction", () => { dryRun: true, }); - expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({ - scope: "configured-channels", - onlyChannelIds: ["telegram"], - }); + expectRegistryLoad(["telegram"]); expect(messageCommandMock).toHaveBeenCalledTimes(1); }); @@ -290,13 +282,11 @@ describe("runMessageAction", () => { }), ).rejects.toThrow("exit"); - expect(ensurePluginRegistryLoaded).toHaveBeenCalledWith({ - scope: "configured-channels", - }); + expectRegistryLoad(["configured-channel"]); }); it("exits with failure when plugin registry loading fails before dispatch", async () => { - vi.mocked(ensurePluginRegistryLoaded).mockImplementationOnce(() => { + loadPluginRegistryHandleMock.mockImplementationOnce(() => { throw new Error("plugin load failed"); }); @@ -326,7 +316,7 @@ describe("runMessageAction", () => { expect(errorMock).toHaveBeenCalledWith( "Error: --poll-anonymous and --poll-public are mutually exclusive.", ); - expect(ensurePluginRegistryLoaded).not.toHaveBeenCalled(); + expect(loadPluginRegistryHandleMock).not.toHaveBeenCalled(); expect(messageCommandMock).not.toHaveBeenCalled(); expect(exitMock).toHaveBeenCalledWith(1); expect(exitMock).not.toHaveBeenCalledWith(0); @@ -392,7 +382,7 @@ describe("runMessageAction", () => { const kind = NON_NEGATIVE_INTEGER_FLAGS.has(flag) ? "non-negative" : "positive"; expect(errorMock).toHaveBeenCalledWith(`Error: ${flag} must be a ${kind} integer.`); - expect(ensurePluginRegistryLoaded).not.toHaveBeenCalled(); + expect(loadPluginRegistryHandleMock).not.toHaveBeenCalled(); expect(messageCommandMock).not.toHaveBeenCalled(); expect(exitMock).toHaveBeenCalledWith(1); expect(exitMock).not.toHaveBeenCalledWith(0); diff --git a/src/cli/program/message/helpers.ts b/src/cli/program/message/helpers.ts index aecb9d90639f..e246e325faf4 100644 --- a/src/cli/program/message/helpers.ts +++ b/src/cli/program/message/helpers.ts @@ -7,17 +7,26 @@ import { } from "../../../channels/plugins/types.public.js"; import { resolveMessageSecretScope } from "../../../cli/message-secret-scope.js"; import { messageCommand } from "../../../commands/message.js"; +import { getRuntimeConfig } from "../../../config/config.js"; import { danger, setVerbose } from "../../../globals.js"; import { CHANNEL_TARGET_DESCRIPTION } from "../../../infra/outbound/channel-target.js"; import { parseStrictNonNegativeInteger, parseStrictPositiveInteger, } from "../../../infra/parse-finite-number.js"; +import { withActivatedPluginIds } from "../../../plugins/activation-context.js"; +import { + resolveConfiguredChannelPluginIds, + resolveDiscoverableScopedChannelPluginIds, +} from "../../../plugins/channel-plugin-ids.js"; import { runGlobalGatewayStopSafely } from "../../../plugins/hook-runner-global.js"; +import { loadPluginRegistryHandle } from "../../../plugins/loader.js"; +import type { PluginRegistry } from "../../../plugins/registry-types.js"; +import { withPluginRuntimeRegistryScope } from "../../../plugins/runtime/gateway-request-scope.js"; import { defaultRuntime } from "../../../runtime.js"; import { runCommandWithRuntime } from "../../cli-utils.js"; import { createDefaultDeps } from "../../deps.js"; -import { ensurePluginRegistryLoaded, type PluginRegistryScope } from "../../plugin-registry.js"; +import type { PluginRegistryScope } from "../../plugin-registry.js"; /** Shared helpers used by every message subcommand registration. */ export type MessageCliHelpers = { @@ -163,6 +172,7 @@ export function createMessageCliHelpers( const runMessageAction = async (action: string, opts: Record) => { setVerbose(Boolean(opts.verbose)); let failed = false; + let pluginRegistry: PluginRegistry | undefined; await runCommandWithRuntime( defaultRuntime, async () => { @@ -172,17 +182,39 @@ export function createMessageCliHelpers( } const preloadPlan = resolveMessagePluginPreloadPlan(action, opts); if (preloadPlan.preload) { - ensurePluginRegistryLoaded(preloadPlan.loadOptions); + const config = getRuntimeConfig(); + const requestedChannelIds = preloadPlan.loadOptions.onlyChannelIds; + const pluginIds = requestedChannelIds + ? resolveDiscoverableScopedChannelPluginIds({ + config, + activationSourceConfig: config, + channelIds: requestedChannelIds, + env: process.env, + }) + : resolveConfiguredChannelPluginIds({ + config, + activationSourceConfig: config, + env: process.env, + }); + const activatedConfig = withActivatedPluginIds({ config, pluginIds }) ?? config; + pluginRegistry = loadPluginRegistryHandle({ + config: activatedConfig, + activationSourceConfig: activatedConfig, + onlyPluginIds: pluginIds, + throwOnLoadError: true, + }); } const deps = createDefaultDeps(); - await messageCommand( - { - ...normalizeMessageOptions(opts), - action, - }, - deps, - defaultRuntime, - ); + const run = () => + messageCommand( + { + ...normalizeMessageOptions(opts), + action, + }, + deps, + defaultRuntime, + ); + await withPluginRuntimeRegistryScope(pluginRegistry, run); }, (err) => { failed = true; @@ -191,7 +223,7 @@ export function createMessageCliHelpers( ); // Outbound actions may start plugin-side resources; run bounded stop hooks even after failure. if (!ACTIONS_WITHOUT_STOP_HOOKS.has(action)) { - await runPluginStopHooks(); + await withPluginRuntimeRegistryScope(pluginRegistry, runPluginStopHooks); } defaultRuntime.exit(failed ? 1 : 0); }; diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index 6271c9071c43..5c10d2658696 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -82,9 +82,10 @@ vi.mock("../../plugins/bundled-sources.js", () => ({ resolveBundledPluginSources: (...args: unknown[]) => resolveBundledPluginSources(...args), })); -vi.mock("../../plugins/loader.js", () => ({ - loadOpenClawPlugins: vi.fn(), -})); +vi.mock("../../plugins/loader.js", () => { + const load = vi.fn(); + return { loadOpenClawPlugins: load, loadPluginRegistryHandle: load }; +}); const discoverOpenClawPlugins = vi.fn((_args?: unknown) => ({ candidates: [] as PluginCandidate[], @@ -751,7 +752,6 @@ describe("ensureChannelSetupPluginInstalled", () => { config: autoEnabledConfig, activationSourceConfig: cfg, autoEnabledReasons: {}, - activate: false, }); }); @@ -775,7 +775,6 @@ describe("ensureChannelSetupPluginInstalled", () => { cache: false, onlyPluginIds: ["@vendor/external-chat-plugin"], includeSetupOnlyChannelPlugins: true, - activate: false, }); expect(getChannelPluginCatalogEntry).toHaveBeenCalledWith("external-chat", { workspaceDir: "/tmp/openclaw-workspace", @@ -879,7 +878,6 @@ describe("ensureChannelSetupPluginInstalled", () => { cache: false, onlyPluginIds: ["custom-external-chat-plugin"], includeSetupOnlyChannelPlugins: true, - activate: false, }); }); @@ -1088,7 +1086,6 @@ describe("ensureChannelSetupPluginInstalled", () => { cache: false, onlyPluginIds: ["@vendor/external-chat-plugin"], includeSetupOnlyChannelPlugins: true, - activate: false, }); }); }); diff --git a/src/commands/channel-setup/plugin-install.ts b/src/commands/channel-setup/plugin-install.ts index fbd797e3f28c..abd858a8b276 100644 --- a/src/commands/channel-setup/plugin-install.ts +++ b/src/commands/channel-setup/plugin-install.ts @@ -8,7 +8,7 @@ import { resolveConfiguredChannelPluginIds, resolveDiscoverableScopedChannelPluginIds, } from "../../plugins/channel-plugin-ids.js"; -import { loadOpenClawPlugins } from "../../plugins/loader.js"; +import { loadPluginRegistryHandle } from "../../plugins/loader.js"; import { createPluginLoaderLogger } from "../../plugins/logger.js"; import type { PluginRegistry } from "../../plugins/registry.js"; import type { RuntimeEnv } from "../../runtime.js"; @@ -78,7 +78,6 @@ function loadChannelSetupPluginRegistry(params: { runtime: RuntimeEnv; workspaceDir?: string; onlyPluginIds?: string[]; - activate?: boolean; forceSetupOnlyChannelPlugins?: boolean; }): PluginRegistry { const autoEnabled = applyPluginAutoEnable({ config: params.cfg, env: process.env }); @@ -95,7 +94,7 @@ function loadChannelSetupPluginRegistry(params: { env: process.env, }); const log = createSubsystemLogger("plugins"); - return loadOpenClawPlugins({ + return loadPluginRegistryHandle({ config: resolvedConfig, activationSourceConfig: params.cfg, autoEnabledReasons: autoEnabled.autoEnabledReasons, @@ -105,7 +104,6 @@ function loadChannelSetupPluginRegistry(params: { onlyPluginIds, includeSetupOnlyChannelPlugins: true, forceSetupOnlyChannelPlugins: params.forceSetupOnlyChannelPlugins, - activate: params.activate, }); } @@ -159,6 +157,5 @@ export function loadChannelSetupPluginRegistrySnapshotForChannel(params: { return loadChannelSetupPluginRegistry({ ...params, ...(scopedPluginId ? { onlyPluginIds: [scopedPluginId] } : {}), - activate: false, }); } diff --git a/src/commands/channel-setup/workspace-shadow-bypass.test.ts b/src/commands/channel-setup/workspace-shadow-bypass.test.ts index c6d51adc43d1..19f6a3ae83d0 100644 --- a/src/commands/channel-setup/workspace-shadow-bypass.test.ts +++ b/src/commands/channel-setup/workspace-shadow-bypass.test.ts @@ -54,6 +54,7 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({ })); vi.mock("../../plugins/loader.js", () => ({ loadOpenClawPlugins: vi.fn(), + loadPluginRegistryHandle: vi.fn(), })); import { resolveChannelSetupEntries } from "./discovery.js"; diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index 0d0c8c655975..0d00a8cff21a 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -384,9 +384,9 @@ vi.mock("../skills/discovery/status.js", () => ({ })); vi.mock("../plugins/loader.js", () => ({ - getRuntimePluginRegistryForLoadOptions: () => null, isPluginRegistryLoadInFlight: () => false, loadOpenClawPlugins: () => createEmptyPluginRegistry(), + loadPluginRegistryHandle: () => createEmptyPluginRegistry(), resolveCompatibleRuntimePluginRegistry: () => null, resolveRuntimePluginRegistry: () => null, })); diff --git a/src/commands/doctor/shared/context-engine-host-compat.test.ts b/src/commands/doctor/shared/context-engine-host-compat.test.ts index 9fc29226f132..7a82de8f2d9a 100644 --- a/src/commands/doctor/shared/context-engine-host-compat.test.ts +++ b/src/commands/doctor/shared/context-engine-host-compat.test.ts @@ -38,10 +38,6 @@ vi.mock("../../../context-engine/init.js", () => ({ ensureContextEnginesInitialized: vi.fn(), })); -vi.mock("../../../plugins/runtime/runtime-registry-loader.js", () => ({ - ensurePluginRegistryLoaded: vi.fn(), -})); - let engineCounter = 0; function uniqueEngineId(): string { diff --git a/src/commands/doctor/shared/context-engine-host-compat.ts b/src/commands/doctor/shared/context-engine-host-compat.ts index 659bf7ce4790..998e252c3892 100644 --- a/src/commands/doctor/shared/context-engine-host-compat.ts +++ b/src/commands/doctor/shared/context-engine-host-compat.ts @@ -24,7 +24,9 @@ import { resolveContextEngine, } from "../../../context-engine/registry.js"; import type { ContextEngineInfo } from "../../../context-engine/types.js"; -import { ensurePluginRegistryLoaded } from "../../../plugins/runtime/runtime-registry-loader.js"; +import { loadPluginRegistryHandle } from "../../../plugins/loader.js"; +import type { PluginRegistry } from "../../../plugins/registry-types.js"; +import { withPluginRuntimeRegistryScope } from "../../../plugins/runtime/gateway-request-scope.js"; import { defaultSlotIdForKey } from "../../../plugins/slots.js"; import { isRecord, resolveUserPath } from "../../../utils.js"; @@ -248,16 +250,16 @@ async function resolveSelectedContextEngineInfo(params: { } ensureContextEnginesInitialized(); + let pluginRegistry: PluginRegistry | undefined; if (getContextEngineRegistration(engineId)?.lifecycle !== "runtime") { try { - ensurePluginRegistryLoaded({ - scope: "all", + pluginRegistry = loadPluginRegistryHandle({ config: params.cfg, env: params.env, onlyPluginIds: [engineId], }); } catch (error) { - if (getContextEngineRegistration(engineId)?.lifecycle !== "runtime") { + if (pluginRegistry?.contextEngines.get(engineId)?.lifecycle !== "runtime") { const message = error instanceof Error ? error.message : String(error); return { warnings: [ @@ -266,7 +268,7 @@ async function resolveSelectedContextEngineInfo(params: { }; } } - if (getContextEngineRegistration(engineId)?.lifecycle !== "runtime") { + if (pluginRegistry?.contextEngines.get(engineId)?.lifecycle !== "runtime") { return { warnings: [ `- plugins.slots.contextEngine: could not inspect context engine "${engineId}" host requirements because it is not registered.`, @@ -276,12 +278,14 @@ async function resolveSelectedContextEngineInfo(params: { } try { - const engine = await resolveContextEngine(params.cfg, { - agentDir: resolveDefaultAgentDir(params.cfg, params.env), - workspaceDir: params.cfg.agents?.defaults?.workspace - ? resolveUserPath(params.cfg.agents.defaults.workspace, params.env) - : undefined, - }); + const resolve = () => + resolveContextEngine(params.cfg, { + agentDir: resolveDefaultAgentDir(params.cfg, params.env), + workspaceDir: params.cfg.agents?.defaults?.workspace + ? resolveUserPath(params.cfg.agents.defaults.workspace, params.env) + : undefined, + }); + const engine = await withPluginRuntimeRegistryScope(pluginRegistry, resolve); return { info: engine.info, warnings: [] }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/cron/isolated-agent.mocks.ts b/src/cron/isolated-agent.mocks.ts index 92f5b7a9eb6a..8e33a75439f3 100644 --- a/src/cron/isolated-agent.mocks.ts +++ b/src/cron/isolated-agent.mocks.ts @@ -58,12 +58,12 @@ vi.mock("../agents/model-selection.js", async () => { }; }); -vi.mock("../agents/subagent-announce.js", () => ({ - runSubagentAnnounceFlow: vi.fn(), +vi.mock("../agents/runtime-plugins.js", () => ({ + loadAgentRuntimePluginRegistryHandle: vi.fn(), })); -vi.mock("../plugins/runtime-plugins.runtime.js", () => ({ - ensureRuntimePluginsLoaded: vi.fn(), +vi.mock("../agents/subagent-announce.js", () => ({ + runSubagentAnnounceFlow: vi.fn(), })); vi.mock("../gateway/call.js", () => ({ diff --git a/src/cron/isolated-agent.model-formatting.test.ts b/src/cron/isolated-agent.model-formatting.test.ts index 088aa3c05a03..367c8fcea391 100644 --- a/src/cron/isolated-agent.model-formatting.test.ts +++ b/src/cron/isolated-agent.model-formatting.test.ts @@ -358,7 +358,7 @@ describe("cron model formatting and precedence edge cases", () => { expect(loadModelCatalogMock).toHaveBeenCalledOnce(); expect(loadModelCatalogMock).toHaveBeenCalledWith({ config: callerConfig, - readOnly: true, + allowGatewaySubagentBinding: true, }); expect(resolveConfiguredModelRefMock).toHaveBeenCalledWith( expect.objectContaining({ cfg: expect.objectContaining(ownerConfig) }), diff --git a/src/cron/isolated-agent/model-selection.ts b/src/cron/isolated-agent/model-selection.ts index dddea265386f..af985bd26f45 100644 --- a/src/cron/isolated-agent/model-selection.ts +++ b/src/cron/isolated-agent/model-selection.ts @@ -97,7 +97,7 @@ export async function resolveCronModelSelectionOwner(params: { ...(params.agentId ? { agentId: params.agentId } : {}), ...(params.agentDir ? { agentDir: params.agentDir } : {}), ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), - readOnly: true, + allowGatewaySubagentBinding: true, }); if ( params.requiredAgentId && diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts index 83d5756eff59..05421839f642 100644 --- a/src/cron/isolated-agent/run-executor.ts +++ b/src/cron/isolated-agent/run-executor.ts @@ -17,6 +17,7 @@ import type { CliSessionBinding } from "../../config/sessions.js"; import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { SourceDeliveryPlan } from "../../infra/outbound/source-delivery-plan.js"; +import type { PluginRegistry } from "../../plugins/registry-types.js"; import { createUserTurnTranscriptRecorder, type UserTurnTranscriptRecorder, @@ -206,6 +207,7 @@ function createCronPromptExecutor(params: { runSessionKey: string; usesDetachedRunSession?: boolean; workspaceDir: string; + pluginRegistry?: PluginRegistry; lane?: string; resolvedVerboseLevel: VerboseLevel; immutableThinkLevel: ThinkLevel | undefined; @@ -364,6 +366,7 @@ function createCronPromptExecutor(params: { sessionKey: params.runSessionKey, agentHarnessRuntimeOverride, workspaceDir: params.workspaceDir, + pluginRegistry: params.pluginRegistry, }); }, fallbacksOverride: cronFallbacksOverride, @@ -729,6 +732,7 @@ export async function executeCronRun(params: { runTimeoutOverrideMs?: number; suppressExecNotifyOnExit: boolean; runStartedAt?: number; + pluginRegistry?: PluginRegistry; }): Promise { const resolvedVerboseLevel: VerboseLevel = normalizeVerboseLevel(params.cronSession.sessionEntry.verboseLevel) ?? @@ -749,6 +753,7 @@ export async function executeCronRun(params: { runSessionKey: params.runSessionKey, usesDetachedRunSession: params.usesDetachedRunSession, workspaceDir: params.workspaceDir, + pluginRegistry: params.pluginRegistry, lane: params.lane, resolvedVerboseLevel, immutableThinkLevel: params.immutableThinkLevel, @@ -898,4 +903,5 @@ export async function executeCronRun(params: { liveSelection: params.liveSelection, }; } + /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/cron/isolated-agent/run-prepare-runtime.ts b/src/cron/isolated-agent/run-prepare-runtime.ts index 8591bfbc49d1..5d6ebd279940 100644 --- a/src/cron/isolated-agent/run-prepare-runtime.ts +++ b/src/cron/isolated-agent/run-prepare-runtime.ts @@ -52,10 +52,6 @@ const cronAuthProfileRuntimeLoader = createLazyImportLoader( const cronModelPreflightRuntimeLoader = createLazyImportLoader( () => import("./model-preflight.runtime.js"), ); -const runtimePluginsLoader = createLazyImportLoader( - () => import("../../plugins/runtime-plugins.runtime.js"), -); - export async function loadSessionAccessorRuntime() { return await sessionAccessorRuntimeLoader.load(); } @@ -72,10 +68,6 @@ export async function loadCronModelPreflightRuntime() { return await cronModelPreflightRuntimeLoader.load(); } -export async function loadRuntimePlugins() { - return await runtimePluginsLoader.load(); -} - export function hasConfiguredAuthProfiles(cfg: OpenClawConfig): boolean { return ( Boolean(cfg.auth?.profiles && Object.keys(cfg.auth.profiles).length > 0) || diff --git a/src/cron/isolated-agent/run-prepare.ts b/src/cron/isolated-agent/run-prepare.ts index c35c7e2bf042..c2ef8a418463 100644 --- a/src/cron/isolated-agent/run-prepare.ts +++ b/src/cron/isolated-agent/run-prepare.ts @@ -3,12 +3,14 @@ import { isDeepStrictEqual } from "node:util"; import { hasAnyAuthProfileStoreSource } from "../../agents/auth-profiles/source-check.js"; import { findModelInCatalog } from "../../agents/model-catalog-lookup.js"; import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../../agents/openai-routing.js"; +import { loadAgentRuntimePluginRegistryHandle } from "../../agents/runtime-plugins.js"; import { resolveAgentModelPrimaryValue } from "../../config/model-input.js"; import type { SessionEntry } from "../../config/sessions.js"; import { resolveSessionWorkStartError } from "../../config/sessions/lifecycle.js"; import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { SourceDeliveryPlan } from "../../infra/outbound/source-delivery-plan.js"; +import type { PluginRegistry } from "../../plugins/registry-types.js"; import { isCronSessionKey } from "../../routing/session-key.js"; import { AGENT_HARNESS_SESSION_ID_LOCKED_MESSAGE, @@ -45,7 +47,6 @@ import { loadCronAuthProfileRuntime, loadCronExternalContentRuntime, loadCronModelPreflightRuntime, - loadRuntimePlugins, loadSessionAccessorRuntime, resolveCronAgentTurnMessage, retireRolledCronSessionMcpRuntime, @@ -128,6 +129,7 @@ export type PreparedCronRunContext = { * the LLM idle watchdog can honor the cron's per-run choice. */ runTimeoutOverrideMs?: number; + pluginRegistry?: PluginRegistry; }; type CronPreparationResult = @@ -171,11 +173,6 @@ export async function prepareCronRunContext(params: { defaults: runtimeCfg.agents?.defaults, agentConfigOverride, }); - const requestedCfgWithAgentDefaults: OpenClawConfig = { - ...runtimeCfg, - agents: Object.assign({}, runtimeCfg.agents, { defaults: agentCfg }), - }; - const baseSessionKey = (input.sessionKey?.trim() || `cron:${input.job.id}`).trim(); const currentBoundSourceKey = input.job.sessionTarget === "current" ? input.job.sessionKey?.trim() : undefined; @@ -213,13 +210,6 @@ export async function prepareCronRunContext(params: { }); const workspaceDir = workspace.dir; - const { ensureRuntimePluginsLoaded } = await loadRuntimePlugins(); - ensureRuntimePluginsLoaded({ - config: requestedCfgWithAgentDefaults, - workspaceDir, - allowGatewaySubagentBinding: true, - }); - const isGmailHook = hookExternalContentSource === "gmail"; const now = Date.now(); const cronSession = resolveCronSession({ @@ -653,6 +643,25 @@ export async function prepareCronRunContext(params: { ? cronSession.sessionEntry.authProfileOverrideSource : undefined, }; + const runtimePluginCandidates = + selectedPreflightCandidateIndex >= 0 + ? preflightCandidates.slice(selectedPreflightCandidateIndex) + : preflightCandidates; + const pluginRegistry = loadAgentRuntimePluginRegistryHandle({ + config: cfgWithAgentDefaults, + workspaceDir, + allowGatewaySubagentBinding: true, + selections: runtimePluginCandidates.map((candidate) => { + const runtime = resolveSessionRuntimeOverrideForProvider({ + provider: candidate.provider, + entry: cronSession.sessionEntry, + cfg: cfgWithAgentDefaults, + }); + return runtime + ? { provider: candidate.provider, modelId: candidate.model, runtime, agentId } + : { provider: candidate.provider, modelId: candidate.model, agentId }; + }), + }); const runContinuationSession = usesExactRunSession ? createCronRunContinuationSession({ cronSession, @@ -709,6 +718,7 @@ export async function prepareCronRunContext(params: { timeoutMs, preflightDiagnostics, runTimeoutOverrideMs, + ...(pluginRegistry ? { pluginRegistry } : {}), }, }; } catch (error) { diff --git a/src/cron/isolated-agent/run.cron-model-override-forwarding.test.ts b/src/cron/isolated-agent/run.cron-model-override-forwarding.test.ts index 5ff7d4e0ef1f..0ad100729dc4 100644 --- a/src/cron/isolated-agent/run.cron-model-override-forwarding.test.ts +++ b/src/cron/isolated-agent/run.cron-model-override-forwarding.test.ts @@ -5,7 +5,6 @@ import { clearFastTestEnv, getCliSessionBindingMock, ensureAgentWorkspaceMock, - ensureRuntimePluginsLoadedMock, isCliProviderMock, loadRunCronIsolatedAgentTurn, makeCronSession, @@ -171,17 +170,11 @@ describe("runCronIsolatedAgentTurn — cron model override forwarding (#58065)", expect(result.status).toBe("ok"); expect(loadModelCatalogOwnerMock).toHaveBeenCalledWith({ config: callerConfig, - readOnly: true, + allowGatewaySubagentBinding: true, }); expect(ensureAgentWorkspaceMock).toHaveBeenCalledWith( expect.objectContaining({ dir: "/tmp/replacement-workspace" }), ); - expect(ensureRuntimePluginsLoadedMock).toHaveBeenCalledWith( - expect.objectContaining({ - config: expect.objectContaining(ownerConfig), - workspaceDir: "/tmp/replacement-workspace", - }), - ); expect(resolveCronSessionMock).toHaveBeenCalledWith( expect.objectContaining({ cfg: ownerConfig, agentId: "main" }), ); diff --git a/src/cron/isolated-agent/run.runtime-plugins.test.ts b/src/cron/isolated-agent/run.runtime-plugins.test.ts index c7e1639c6782..79ed120de44c 100644 --- a/src/cron/isolated-agent/run.runtime-plugins.test.ts +++ b/src/cron/isolated-agent/run.runtime-plugins.test.ts @@ -1,48 +1,50 @@ -// Runtime plugin tests cover plugin availability during isolated cron runs. - -import { expectDefined } from "@openclaw/normalization-core"; +// Runtime plugin tests cover run-owned registry handles for isolated cron turns. import { describe, expect, it } from "vitest"; import { makeIsolatedAgentParamsFixture } from "./job-fixtures.js"; import { setupRunCronIsolatedAgentTurnSuite } from "./run.suite-helpers.js"; import { + loadAgentRuntimePluginRegistryHandleMock, + loadModelCatalogOwnerMock, loadRunCronIsolatedAgentTurn, - ensureRuntimePluginsLoadedMock, - resolveConfiguredModelRefMock, - resolveCronDeliveryPlanMock, } from "./run.test-harness.js"; const runCronIsolatedAgentTurn = await loadRunCronIsolatedAgentTurn(); -describe("runCronIsolatedAgentTurn runtime plugins loading", () => { +describe("runCronIsolatedAgentTurn runtime plugin owner", () => { setupRunCronIsolatedAgentTurnSuite(); - it("loads runtime plugins eagerly using the lazily loaded module", async () => { - const params = makeIsolatedAgentParamsFixture(); + it("carries a gateway-bindable selected registry handle into the run", async () => { + const params = makeIsolatedAgentParamsFixture({ + job: { + payload: { + kind: "agentTurn", + message: "test", + fallbacks: ["anthropic/claude-sonnet-4-6"], + }, + }, + }); - const result = await runCronIsolatedAgentTurn(params); - - expect(result.status).toBe("ok"); - expect(ensureRuntimePluginsLoadedMock).toHaveBeenCalledOnce(); - expect(ensureRuntimePluginsLoadedMock).toHaveBeenCalledWith({ - config: expect.objectContaining({ - agents: expect.objectContaining({ - defaults: expect.any(Object), - }), - }), - workspaceDir: "/tmp/workspace", // matches resolveAgentWorkspaceDir mock + await expect(runCronIsolatedAgentTurn(params)).resolves.toMatchObject({ status: "ok" }); + expect(loadModelCatalogOwnerMock).toHaveBeenCalledWith({ + config: params.cfg, allowGatewaySubagentBinding: true, }); - expect(ensureRuntimePluginsLoadedMock.mock.invocationCallOrder[0]).toBeLessThan( - expectDefined( - resolveConfiguredModelRefMock.mock.invocationCallOrder[0], - "resolveConfiguredModelRefMock.mock.invocationCallOrder[0] test invariant", - ), - ); - expect(ensureRuntimePluginsLoadedMock.mock.invocationCallOrder[0]).toBeLessThan( - expectDefined( - resolveCronDeliveryPlanMock.mock.invocationCallOrder[0], - "resolveCronDeliveryPlanMock.mock.invocationCallOrder[0] test invariant", - ), - ); + expect(loadAgentRuntimePluginRegistryHandleMock).toHaveBeenCalledWith({ + config: { agents: { defaults: {} } }, + workspaceDir: "/tmp/workspace", + allowGatewaySubagentBinding: true, + selections: [ + { + provider: "openai", + modelId: "gpt-5.4", + agentId: "default", + }, + { + provider: "anthropic", + modelId: "claude-sonnet-4-6", + agentId: "default", + }, + ], + }); }); }); diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index 7fbfb0fa0392..b2fd82c8dc1d 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -4,6 +4,7 @@ import { vi, type Mock } from "vitest"; import { resolveFastModeState as resolveFastModeStateImpl } from "../../agents/fast-mode.js"; import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js"; import { resolveAgentModelFallbackValues } from "../../config/model-input.js"; +import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; // Central mock harness for isolated cron agent run orchestration tests. type CronSessionEntry = { @@ -99,7 +100,6 @@ export const getChannelPluginMock = createMock(); export const retireSessionMcpRuntimeMock = createMock(); export const cleanupBrowserSessionsForLifecycleEndMock = createMock(); export const callGatewayMock = createMock(); -export const ensureRuntimePluginsLoadedMock = createMock(); export const hasUsableWebSearchProviderMock = createMock(); export const readSessionMessagesAsyncMock = createMock(); export const classifyEmbeddedAgentRunResultForModelFallbackMock = createMock(); @@ -128,8 +128,13 @@ const resolveHookExternalContentSourceMock = createMock(); const getSkillsSnapshotVersionMock = createMock(); export const loadModelCatalogMock = createMock(); export const loadModelCatalogOwnerMock = createMock(); +export const loadAgentRuntimePluginRegistryHandleMock = createMock(); const getRemoteSkillEligibilityMock = createMock(); +vi.mock("../../agents/runtime-plugins.js", () => ({ + loadAgentRuntimePluginRegistryHandle: loadAgentRuntimePluginRegistryHandleMock, +})); + vi.mock("./run.runtime.js", async () => ({ resolveAgentConfig: resolveAgentConfigMock, resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"), @@ -184,10 +189,6 @@ vi.mock("./run-context.runtime.js", () => ({ lookupContextTokens: lookupContextTokensMock, })); -vi.mock("../../plugins/runtime-plugins.runtime.js", () => ({ - ensureRuntimePluginsLoaded: ensureRuntimePluginsLoadedMock, -})); - vi.mock("../../web-search/runtime.js", () => ({ hasUsableWebSearchProvider: hasUsableWebSearchProviderMock, })); @@ -584,6 +585,7 @@ function resetRunConfigMocks(): void { resolveHookExternalContentSourceMock.mockReturnValue(undefined); getSkillsSnapshotVersionMock.mockReturnValue(42); loadModelCatalogMock.mockResolvedValue([]); + loadAgentRuntimePluginRegistryHandleMock.mockReturnValue(createEmptyPluginRegistry()); loadModelCatalogOwnerMock.mockImplementation( async (params: { agentId?: string; @@ -850,7 +852,6 @@ export function resetRunCronIsolatedAgentTurnHarness(): void { resetRunSessionMocks(); setSessionRuntimeModelMock.mockReturnValue(undefined); logWarnMock.mockReset(); - ensureRuntimePluginsLoadedMock.mockReset(); hasUsableWebSearchProviderMock.mockReset(); hasUsableWebSearchProviderMock.mockImplementation( (params?: { runtimeWebSearch?: { selectedProvider?: string } }) => diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index ca2a924aad44..deb8c8c83e8b 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -15,6 +15,7 @@ import { import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js"; import { isFastTestRuntimeEnv } from "../../infra/env.js"; import { createDiagnosticMessageLifecycle } from "../../logging/message-lifecycle.js"; +import { withPluginRuntimeRegistryScope } from "../../plugins/runtime/gateway-request-scope.js"; import { isCommandLaneTaskTimeoutError } from "../../process/command-queue.js"; import { CommandLane } from "../../process/lanes.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; @@ -247,10 +248,13 @@ export async function runCronIsolatedAgentTurn(params: { timeoutMs: prepared.context.timeoutMs, runTimeoutOverrideMs: prepared.context.runTimeoutOverrideMs, suppressExecNotifyOnExit: prepared.context.suppressExecNotifyOnExit, + pluginRegistry: prepared.context.pluginRegistry, }; const execution = await prepared.context.sessionWorkAdmission.run(() => withAgentRunLifecycleGeneration(runLifecycleGeneration, () => - executeCronRun(executionParams), + withPluginRuntimeRegistryScope(prepared.context.pluginRegistry, () => + executeCronRun(executionParams), + ), ), ); const finalized = await finalizeCronRun({ diff --git a/src/cron/trigger-script.ts b/src/cron/trigger-script.ts index 158fec5d2945..70a7f20cdd3a 100644 --- a/src/cron/trigger-script.ts +++ b/src/cron/trigger-script.ts @@ -26,7 +26,7 @@ import { applyEmbeddedAttemptToolsAllow, resolveEmbeddedAttemptToolConstructionPlan, } from "../agents/embedded-agent-runner/run/attempt-tool-construction-plan.js"; -import { ensureRuntimePluginsLoaded } from "../agents/runtime-plugins.js"; +import { loadAgentRuntimePluginRegistryHandle } from "../agents/runtime-plugins.js"; import { resolveSandboxContext } from "../agents/sandbox.js"; import { resolveScheduledToolPolicyContext, @@ -41,6 +41,8 @@ import type { AnyAgentTool } from "../agents/tools/common.js"; import { ensureAgentWorkspace } from "../agents/workspace.js"; import { parseDurationMs } from "../cli/parse-duration.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { PluginRegistry } from "../plugins/registry-types.js"; +import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js"; import { getPluginToolMeta } from "../plugins/tools.js"; import { normalizeAgentId } from "../routing/session-key.js"; import { @@ -78,6 +80,7 @@ type PreparedTriggerRuntime = { tools: AnyAgentTool[]; ctx: Omit; hookContext: Omit; + pluginRegistry?: PluginRegistry; }; type PrepareTriggerRuntime = (params: { @@ -135,78 +138,82 @@ async function prepareTriggerRuntime(params: { }); params.signal?.throwIfAborted(); const workspaceDir = workspace.dir; - ensureRuntimePluginsLoaded({ + const pluginRegistry = loadAgentRuntimePluginRegistryHandle({ config, workspaceDir, allowGatewaySubagentBinding: true, }); - const rawSessionKey = `cron:${params.jobId}:trigger`; - const sessionKey = resolveCronAgentSessionKey({ - sessionKey: rawSessionKey, - agentId, - mainKey: config.session?.mainKey, - cfg: config, - }); - const sandbox = await resolveSandboxContext({ - config, - sessionKey, - workspaceDir, - }); - params.signal?.throwIfAborted(); - const effectiveWorkspace = - sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? sandbox.workspaceDir : workspaceDir; - const toolPlan = resolveEmbeddedAttemptToolConstructionPlan({ - toolsEnabled: true, - toolsAllow: params.toolsAllow, - }); - // Bundle MCP tools are source:"mcp", which the headless bridge excludes. - // LSP runtimes are session-scoped and intentionally outside trigger v1. - const allTools = toolPlan.constructTools - ? createOpenClawCodingTools({ - agentId, - exec: { config }, - sandbox, - sessionKey, - trigger: "cron", - jobId: params.jobId, - agentDir, - cwd: effectiveWorkspace, - workspaceDir: effectiveWorkspace, - spawnWorkspaceDir: workspaceDir, - config, - allowGatewaySubagentBinding: true, - includeCoreTools: toolPlan.includeCoreTools, - runtimeToolAllowlist: toolPlan.runtimeToolAllowlist, - inheritRuntimeToolAllowlist: Boolean(toolPlan.runtimeToolAllowlist), - scheduledToolPolicy: resolveScheduledToolPolicyContext({ - toolsAllow: params.toolsAllow, - scheduledToolPolicy: params.scheduledToolPolicy, - }), - toolConstructionPlan: toolPlan.codingToolConstructionPlan, - }) - : []; - const tools = applyEmbeddedAttemptToolsAllow(allTools, params.toolsAllow, { - toolMeta: (tool) => getPluginToolMeta(tool), - }); - const hookContext: HookContext = { - agentId, - config, - cwd: effectiveWorkspace, - workspaceDir: effectiveWorkspace, - sessionKey, - loopDetection: resolveToolLoopDetectionConfig({ cfg: config, agentId }), - }; - return { - tools, - hookContext, - ctx: { - config, - runtimeConfig: config, + const prepare = async (): Promise => { + const rawSessionKey = `cron:${params.jobId}:trigger`; + const sessionKey = resolveCronAgentSessionKey({ + sessionKey: rawSessionKey, agentId, + mainKey: config.session?.mainKey, + cfg: config, + }); + const sandbox = await resolveSandboxContext({ + config, sessionKey, - }, + workspaceDir, + }); + params.signal?.throwIfAborted(); + const effectiveWorkspace = + sandbox?.enabled && sandbox.workspaceAccess !== "rw" ? sandbox.workspaceDir : workspaceDir; + const toolPlan = resolveEmbeddedAttemptToolConstructionPlan({ + toolsEnabled: true, + toolsAllow: params.toolsAllow, + }); + // Bundle MCP tools are source:"mcp", which the headless bridge excludes. + // LSP runtimes are session-scoped and intentionally outside trigger v1. + const allTools = toolPlan.constructTools + ? createOpenClawCodingTools({ + agentId, + exec: { config }, + sandbox, + sessionKey, + trigger: "cron", + jobId: params.jobId, + agentDir, + cwd: effectiveWorkspace, + workspaceDir: effectiveWorkspace, + spawnWorkspaceDir: workspaceDir, + config, + allowGatewaySubagentBinding: true, + includeCoreTools: toolPlan.includeCoreTools, + runtimeToolAllowlist: toolPlan.runtimeToolAllowlist, + inheritRuntimeToolAllowlist: Boolean(toolPlan.runtimeToolAllowlist), + scheduledToolPolicy: resolveScheduledToolPolicyContext({ + toolsAllow: params.toolsAllow, + scheduledToolPolicy: params.scheduledToolPolicy, + }), + toolConstructionPlan: toolPlan.codingToolConstructionPlan, + }) + : []; + const tools = applyEmbeddedAttemptToolsAllow(allTools, params.toolsAllow, { + toolMeta: (tool) => getPluginToolMeta(tool), + }); + const hookContext: HookContext = { + agentId, + config, + cwd: effectiveWorkspace, + workspaceDir: effectiveWorkspace, + sessionKey, + loopDetection: resolveToolLoopDetectionConfig({ cfg: config, agentId }), + }; + return { + tools, + hookContext, + ...(pluginRegistry ? { pluginRegistry } : {}), + ctx: { + config, + runtimeConfig: config, + agentId, + sessionKey, + }, + }; }; + return await withPluginRuntimeRegistryScope(pluginRegistry, prepare); } function triggerStateNamespace(state: unknown, streamBatch?: string): CodeModeNamespaceDescriptor { @@ -445,29 +452,35 @@ function createCronCodeModeRunner(deps: CronTriggerEvaluatorDeps) { signal: evaluationScope.signal, }); - const catalogRef = createToolSearchCatalogRef(); - const runId = `cron-trigger:${params.jobId}:${crypto.randomUUID()}`; - registerHeadlessToolSearchCatalog({ - catalogRef, - tools: runtime.tools, - hookContext: { ...runtime.hookContext, runId }, - }); - const remainingWallClockMs = evaluationScope.deadline - Date.now(); - if (remainingWallClockMs <= 0) { - throw new CodeModeHeadlessTimeoutError(`${params.label} timed out`); - } - const result = await runHeadless({ - ctx: { ...runtime.ctx, catalogRef, abortSignal: evaluationScope.signal }, - code: params.script, - wallClockMs: remainingWallClockMs, - maxToolCalls: params.maxToolCalls, - extraNamespaces: params.namespaces, - signal: evaluationScope.signal, - }); - if (result.status === "failed") { - return { kind: "error", code: result.code, error: result.error }; - } - return { kind: "completed", result }; + const evaluate = async (): Promise< + | { kind: "completed"; result: Extract } + | { kind: "error"; code: CronTriggerFailureCode; error: string } + > => { + const catalogRef = createToolSearchCatalogRef(); + const runId = `cron-trigger:${params.jobId}:${crypto.randomUUID()}`; + registerHeadlessToolSearchCatalog({ + catalogRef, + tools: runtime.tools, + hookContext: { ...runtime.hookContext, runId }, + }); + const remainingWallClockMs = evaluationScope.deadline - Date.now(); + if (remainingWallClockMs <= 0) { + throw new CodeModeHeadlessTimeoutError(`${params.label} timed out`); + } + const result = await runHeadless({ + ctx: { ...runtime.ctx, catalogRef, abortSignal: evaluationScope.signal }, + code: params.script, + wallClockMs: remainingWallClockMs, + maxToolCalls: params.maxToolCalls, + extraNamespaces: params.namespaces, + signal: evaluationScope.signal, + }); + if (result.status === "failed") { + return { kind: "error", code: result.code, error: result.error }; + } + return { kind: "completed", result }; + }; + return await withPluginRuntimeRegistryScope(runtime.pluginRegistry, evaluate); } catch (error) { return { kind: "error", diff --git a/src/gateway/methods/descriptor.ts b/src/gateway/methods/descriptor.ts index 94a730d24a0f..27f871c49b47 100644 --- a/src/gateway/methods/descriptor.ts +++ b/src/gateway/methods/descriptor.ts @@ -44,6 +44,8 @@ export type GatewayMethodDescriptorInput = Omit /** Read-only method registry view used by request dispatch and method listing. */ export type GatewayMethodRegistryView = { + /** Opaque registry handle carried into request scope by the gateway composition root. */ + pluginRegistry?: object; getHandler: (name: string) => GatewayMethodHandler | undefined; listMethods: () => string[]; listAdvertisedMethods: () => string[]; diff --git a/src/gateway/methods/registry.ts b/src/gateway/methods/registry.ts index 6bf4dd0986b8..7dc6610cf4fa 100644 --- a/src/gateway/methods/registry.ts +++ b/src/gateway/methods/registry.ts @@ -54,6 +54,7 @@ function normalizeDescriptor(input: GatewayMethodDescriptorInput): GatewayMethod /** Creates a read-only registry for gateway method lookup, listing, and policy metadata. */ export function createGatewayMethodRegistry( inputs: readonly GatewayMethodDescriptorInput[], + pluginRegistry?: PluginRegistry, ): GatewayMethodRegistry { const descriptors = inputs.map(normalizeDescriptor); const byName = new Map(); @@ -66,6 +67,7 @@ export function createGatewayMethodRegistry( byName.set(descriptor.name, descriptor); } return { + ...(pluginRegistry ? { pluginRegistry } : {}), getHandler: (name) => byName.get(name)?.handler, listMethods: () => descriptors.map((descriptor) => descriptor.name), listAdvertisedMethods: () => diff --git a/src/gateway/server-core-runtime.ts b/src/gateway/server-core-runtime.ts index 7fdcf6cf8a1a..529023b9a2cf 100644 --- a/src/gateway/server-core-runtime.ts +++ b/src/gateway/server-core-runtime.ts @@ -316,15 +316,18 @@ export async function startGatewayCoreRuntime(input: { (workerPlacementDispatchAvailable || descriptor.name !== "sessions.dispatch") && (workerPlacementControlAvailable || descriptor.name !== "sessions.reclaim"), ); - return createGatewayMethodRegistry([ - ...coreDescriptors, - ...createPluginGatewayMethodDescriptors(nextPluginRegistry), - ...createGatewayMethodDescriptorsFromHandlers({ - handlers: auxHandlers, - owner: { kind: "aux", area: "gateway-extra" }, - defaultScope: ADMIN_SCOPE, - }), - ]); + return createGatewayMethodRegistry( + [ + ...coreDescriptors, + ...createPluginGatewayMethodDescriptors(nextPluginRegistry), + ...createGatewayMethodDescriptorsFromHandlers({ + handlers: auxHandlers, + owner: { kind: "aux", area: "gateway-extra" }, + defaultScope: ADMIN_SCOPE, + }), + ], + nextPluginRegistry, + ); }; let attachedGatewayMethodRegistry = buildAttachedGatewayMethodRegistry(pluginRuntime.registry); const listAttachedGatewayMethods = () => { diff --git a/src/gateway/server-methods.plugin-gateway-dispatch.test.ts b/src/gateway/server-methods.plugin-gateway-dispatch.test.ts index 4a1e5481c77b..a1db51cbe6ea 100644 --- a/src/gateway/server-methods.plugin-gateway-dispatch.test.ts +++ b/src/gateway/server-methods.plugin-gateway-dispatch.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { pinActivePluginHttpRouteRegistry, + requireActivePluginRegistry, resetPluginRuntimeStateForTest, setActivePluginRegistry, } from "../plugins/runtime.js"; @@ -74,20 +75,25 @@ describe("handleGatewayRequest plugin gateway dispatch", () => { }); it("dispatches a method owned by the caller-attached registry even when global state lacks it (#94343)", async () => { + const attachedPluginRegistry = createEmptyPluginRegistry(); const handler = vi.fn(({ respond }) => { + expect(requireActivePluginRegistry()).toBe(attachedPluginRegistry); respond(true, { ok: true, source: "attached" }); }); // Active plugin registry does NOT carry the method; only the caller-attached // snapshot owns it, so dispatch must prefer the attached registry. setActivePluginRegistry(createEmptyPluginRegistry()); - const attachedRegistry = createGatewayMethodRegistry([ - createPluginGatewayMethodDescriptor({ - pluginId: "demo", - name: "demo.attached", - handler, - scope: WRITE_SCOPE, - }), - ]); + const attachedRegistry = createGatewayMethodRegistry( + [ + createPluginGatewayMethodDescriptor({ + pluginId: "demo", + name: "demo.attached", + handler, + scope: WRITE_SCOPE, + }), + ], + attachedPluginRegistry, + ); const respond = vi.fn(); await handleGatewayRequest({ req: { type: "req", id: "proof-94343", method: "demo.attached", params: {} }, diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 629aeac031f8..3767e50e4ae5 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -7,8 +7,11 @@ import { gatewayStartupUnavailableDetails, GATEWAY_STARTUP_RETRY_AFTER_MS, } from "../../packages/gateway-protocol/src/startup-unavailable.js"; -import { getActivePluginHttpRouteRegistry } from "../plugins/runtime.js"; -import { withPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; +import { getActivePluginHttpRouteRegistry, getActivePluginRegistry } from "../plugins/runtime.js"; +import { + getPluginRuntimeGatewayRequestScope, + withPluginRuntimeGatewayRequestScope, +} from "../plugins/runtime/gateway-request-scope.js"; import { getGatewaySuspendAdmissionPhase, isGatewayRestartDraining, @@ -941,15 +944,18 @@ function createRequestGatewayMethodRegistry( ([method]) => !pluginMethodNames.has(method) && !coreMethodNames.has(method), ), ); - return createGatewayMethodRegistry([ - ...coreDescriptors, - ...(gatewayPluginRegistry ? createPluginGatewayMethodDescriptors(gatewayPluginRegistry) : []), - ...createGatewayMethodDescriptorsFromHandlers({ - handlers: auxHandlers, - owner: { kind: "aux", area: "gateway-extra" }, - defaultScope: ADMIN_SCOPE, - }), - ]); + return createGatewayMethodRegistry( + [ + ...coreDescriptors, + ...(gatewayPluginRegistry ? createPluginGatewayMethodDescriptors(gatewayPluginRegistry) : []), + ...createGatewayMethodDescriptorsFromHandlers({ + handlers: auxHandlers, + owner: { kind: "aux", area: "gateway-extra" }, + defaultScope: ADMIN_SCOPE, + }), + ], + gatewayPluginRegistry ?? undefined, + ); } /** Authorizes and dispatches one gateway JSON-RPC-style request. */ @@ -1115,8 +1121,20 @@ export async function handleGatewayRequest( // The scope also carries caller identity into plugin-owned gateway methods. const invokeWithRequestScope = async () => { try { + const pluginRegistry = + (methodRegistry.pluginRegistry as + | NonNullable> + | undefined) ?? + getPluginRuntimeGatewayRequestScope()?.pluginRegistry ?? + getActivePluginRegistry() ?? + undefined; await withPluginRuntimeGatewayRequestScope( - { context, client, isWebchatConnect }, + { + context, + client, + isWebchatConnect, + ...(pluginRegistry ? { pluginRegistry } : {}), + }, invokeHandler, ); } catch (error) { diff --git a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts index 0edcecb81e61..fa7b90e89a6c 100644 --- a/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts +++ b/src/gateway/server-methods/agent.sessions-and-models.test-utils.ts @@ -377,8 +377,6 @@ describe("gateway agent handler", () => { useTestStateDir(root); resetTaskRegistryForTests(); resetSubagentRegistryForTests({ persist: false }); - // Route through the harness helper so the ensureRuntimePluginsLoaded - // pin survives this wholesale deps override. const persistSubagentRunsToDiskOrThrow = vi.fn(() => { throw new Error("disk full"); }); diff --git a/src/gateway/server-methods/agent.test-harness.ts b/src/gateway/server-methods/agent.test-harness.ts index 79479556976f..0e2e5c4db236 100644 --- a/src/gateway/server-methods/agent.test-harness.ts +++ b/src/gateway/server-methods/agent.test-harness.ts @@ -921,20 +921,15 @@ function toLintErrorObject(value: unknown, fallbackMessage: string): Error { } /** - * Pins subagent-registry deps for gateway handler tests, always keeping - * `ensureRuntimePluginsLoaded` a no-op. Real ended-run hooks reload the - * standalone plugin runtime in the background, and `loadOpenClawPlugins` - * starts by wiping process-wide plugin registrations — including the detached - * task lifecycle runtime a later test just installed via - * `setDetachedTaskLifecycleRuntime`. Without this pin, a prior test's async - * subagent completion can silently uninstall a later test's runtime seam - * between install and finalize, so the finalize spy is never called. + * Keep subagent registry dependencies deterministic across gateway tests. + * Real ended-run hooks load a plugin bundle in the background, which can + * replace registrations installed by the next test before it finalizes. */ export function applyGatewaySubagentRegistryTestDeps( overrides?: Parameters[0], ) { setSubagentRegistryDepsForTest({ - ensureRuntimePluginsLoaded: () => {}, + loadAgentRuntimePluginRegistryHandle: () => undefined, ...overrides, }); } diff --git a/src/gateway/server-model-catalog.types.ts b/src/gateway/server-model-catalog.types.ts index 34c2c9637136..35c3d7f8bde3 100644 --- a/src/gateway/server-model-catalog.types.ts +++ b/src/gateway/server-model-catalog.types.ts @@ -1,7 +1,10 @@ import type { ModelCatalogSnapshot } from "../agents/model-catalog.types.js"; import type { ResolvedPublishedModelCatalogOwner } from "../agents/prepared-model-catalog.types.js"; -export type GatewayModelCatalogOwnerSnapshot = ResolvedPublishedModelCatalogOwner; +export type GatewayModelCatalogOwnerSnapshot = Omit< + ResolvedPublishedModelCatalogOwner, + "pluginRegistry" +>; export type GatewayModelCatalogSnapshot = ModelCatalogSnapshot & Omit; diff --git a/src/gateway/server-plugins.test.ts b/src/gateway/server-plugins.test.ts index adaadd44ad3a..5eba9130201a 100644 --- a/src/gateway/server-plugins.test.ts +++ b/src/gateway/server-plugins.test.ts @@ -45,6 +45,7 @@ const handleGatewayRequest = vi.hoisted(() => ); vi.mock("../plugins/loader.js", () => ({ + loadAndActivateRootPluginRegistry: loadOpenClawPlugins, loadOpenClawPlugins, })); diff --git a/src/gateway/server-plugins.ts b/src/gateway/server-plugins.ts index 4d7e403fd3d4..31099788391d 100644 --- a/src/gateway/server-plugins.ts +++ b/src/gateway/server-plugins.ts @@ -10,7 +10,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizePluginsConfig } from "../plugins/config-state.js"; import { extractPluginInstallRecordsFromInstalledPluginIndex } from "../plugins/installed-plugin-index-install-records.js"; import { activatePluginRegistry } from "../plugins/loader-shared.js"; -import { loadOpenClawPlugins } from "../plugins/loader.js"; +import { loadAndActivateRootPluginRegistry } from "../plugins/loader.js"; import { loadPluginLookUpTable, type PluginLookUpTable } from "../plugins/plugin-lookup-table.js"; import { getPluginModuleLoaderStats } from "../plugins/plugin-module-loader-cache.js"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; @@ -652,7 +652,7 @@ export function loadGatewayPlugins(params: { } const beforeLoad = performance.now(); const loaderStatsBefore = getPluginModuleLoaderStats(); - const pluginRegistry = loadOpenClawPlugins({ + const pluginRegistry = loadAndActivateRootPluginRegistry({ config: resolvedConfig, activationSourceConfig: params.activationSourceConfig ?? params.cfg, autoEnabledReasons: autoEnabled.autoEnabledReasons, diff --git a/src/gateway/server-reload-handlers.test.ts b/src/gateway/server-reload-handlers.test.ts index b0b477315f1c..6eda06cf10bc 100644 --- a/src/gateway/server-reload-handlers.test.ts +++ b/src/gateway/server-reload-handlers.test.ts @@ -1300,6 +1300,7 @@ describe("gateway hot reload model state", () => { { waitForReplacement: true }, ); expect(hoisted.refreshPreparedModelRuntimeSnapshots).toHaveBeenCalledWith(nextConfig, { + allowGatewaySubagentBinding: true, catalogMode: "static", }); expect(hoisted.warmCurrentProviderAuthStateOffMainThread).toHaveBeenCalledWith(nextConfig); diff --git a/src/gateway/server-reload-hot.ts b/src/gateway/server-reload-hot.ts index 2b082a2635cf..3f71bb1ef0a6 100644 --- a/src/gateway/server-reload-hot.ts +++ b/src/gateway/server-reload-hot.ts @@ -491,7 +491,10 @@ export function createGatewayReloadHandlers(params: GatewayReloadHandlerParams) } try { - await refreshPreparedModelRuntimeSnapshots(nextConfig, { catalogMode: "static" }); + await refreshPreparedModelRuntimeSnapshots(nextConfig, { + catalogMode: "static", + allowGatewaySubagentBinding: true, + }); } catch (err) { scheduleRecoveryRestart("prepared model runtime reload", err); return; diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index c7fb7ee20231..3a6c9081ceda 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -64,7 +64,7 @@ const hoisted = vi.hoisted(() => { const refreshPreparedModelRuntimeSnapshots = vi.fn( async (_cfg?: unknown, _options?: unknown) => {}, ); - const ensureRuntimePluginsLoaded = vi.fn(); + const installAgentRuntimePluginRegistryAtProcessRoot = vi.fn(); const ensureContextWindowCacheLoaded = vi.fn(async () => {}); const scheduleGatewayHandlerPrewarm = vi.fn(() => ({ stop: vi.fn() })); const clearCurrentProviderAuthState = vi.fn(); @@ -104,7 +104,7 @@ const hoisted = vi.hoisted(() => { getModelRefStatus, prepareModelRuntimeSnapshot, refreshPreparedModelRuntimeSnapshots, - ensureRuntimePluginsLoaded, + installAgentRuntimePluginRegistryAtProcessRoot, ensureContextWindowCacheLoaded, scheduleGatewayHandlerPrewarm, clearCurrentProviderAuthState, @@ -211,7 +211,8 @@ vi.mock("../agents/prepared-model-runtime.js", () => ({ })); vi.mock("../agents/runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded: hoisted.ensureRuntimePluginsLoaded, + installAgentRuntimePluginRegistryAtProcessRoot: + hoisted.installAgentRuntimePluginRegistryAtProcessRoot, })); vi.mock("../agents/context.js", () => ({ @@ -365,7 +366,7 @@ describe("startGatewayPostAttachRuntime", () => { hoisted.prepareModelRuntimeSnapshot.mockResolvedValue({}); hoisted.refreshPreparedModelRuntimeSnapshots.mockReset(); hoisted.refreshPreparedModelRuntimeSnapshots.mockResolvedValue(undefined); - hoisted.ensureRuntimePluginsLoaded.mockReset(); + hoisted.installAgentRuntimePluginRegistryAtProcessRoot.mockReset(); hoisted.ensureContextWindowCacheLoaded.mockReset(); hoisted.ensureContextWindowCacheLoaded.mockResolvedValue(undefined); hoisted.scheduleGatewayHandlerPrewarm.mockClear(); @@ -1156,17 +1157,17 @@ describe("startGatewayPostAttachRuntime", () => { await new Promise((resolve) => { setImmediate(resolve); }); - expect(hoisted.ensureRuntimePluginsLoaded).not.toHaveBeenCalled(); + expect(hoisted.installAgentRuntimePluginRegistryAtProcessRoot).not.toHaveBeenCalled(); releaseGatewayReady(); await waitForGatewayTestState(() => { - expect(hoisted.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({ + expect(hoisted.installAgentRuntimePluginRegistryAtProcessRoot).toHaveBeenCalledWith({ config: currentConfig, workspaceDir: "/tmp/openclaw-workspace", allowGatewaySubagentBinding: true, }); }); - expect(hoisted.ensureRuntimePluginsLoaded).not.toHaveBeenCalledWith( + expect(hoisted.installAgentRuntimePluginRegistryAtProcessRoot).not.toHaveBeenCalledWith( expect.objectContaining({ config: startupConfig }), ); }); diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index c8cbe4f16ce6..97d0a207712f 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -328,12 +328,13 @@ function scheduleAgentRuntimePluginPrewarm(params: { return; } const started = performance.now(); - const { ensureRuntimePluginsLoaded } = await import("../agents/runtime-plugins.js"); + const { installAgentRuntimePluginRegistryAtProcessRoot } = + await import("../agents/runtime-plugins.js"); const cfg = params.getConfig(); if (isStopped()) { return; } - ensureRuntimePluginsLoaded({ + installAgentRuntimePluginRegistryAtProcessRoot({ config: cfg, workspaceDir: params.workspaceDir, allowGatewaySubagentBinding: true, @@ -554,6 +555,7 @@ async function publishConfiguredModelRuntimeSnapshots(params: { await refreshPreparedModelRuntimeSnapshots(params.cfg, { gatewayLifecycle: true, catalogMode: "static", + allowGatewaySubagentBinding: true, ...(params.workspaceDir ? { defaultWorkspaceDir: params.workspaceDir } : {}), ...(params.startupTrace ? { diff --git a/src/gateway/server-startup.test.ts b/src/gateway/server-startup.test.ts index 750f81916b8d..85cf91a8fa2b 100644 --- a/src/gateway/server-startup.test.ts +++ b/src/gateway/server-startup.test.ts @@ -12,6 +12,7 @@ const refreshPreparedModelRuntimeSnapshotsMock = vi.fn( gatewayLifecycle?: boolean; defaultWorkspaceDir?: string; catalogMode?: "live" | "static"; + allowGatewaySubagentBinding?: boolean; }, ) => {}, ); @@ -30,6 +31,7 @@ vi.mock("../agents/prepared-model-runtime.js", () => ({ gatewayLifecycle?: boolean; defaultWorkspaceDir?: string; catalogMode?: "live" | "static"; + allowGatewaySubagentBinding?: boolean; }, ) => refreshPreparedModelRuntimeSnapshotsMock(cfg, options), })); @@ -71,6 +73,7 @@ describe("gateway startup primary model warmup", () => { }); expect(refreshPreparedModelRuntimeSnapshotsMock).toHaveBeenCalledWith(cfg, { + allowGatewaySubagentBinding: true, gatewayLifecycle: true, catalogMode: "static", }); @@ -84,6 +87,7 @@ describe("gateway startup primary model warmup", () => { }); expect(refreshPreparedModelRuntimeSnapshotsMock).toHaveBeenCalledWith(cfg, { + allowGatewaySubagentBinding: true, gatewayLifecycle: true, catalogMode: "static", }); @@ -119,7 +123,10 @@ describe("gateway startup primary model warmup", () => { expect(refreshPreparedModelRuntimeSnapshotsMock).toHaveBeenCalledOnce(); expect(refreshPreparedModelRuntimeSnapshotsMock).toHaveBeenCalledWith( expect.any(Object), - expect.objectContaining({ defaultWorkspaceDir: "/tmp/skip-explicit-workspace" }), + expect.objectContaining({ + allowGatewaySubagentBinding: true, + defaultWorkspaceDir: "/tmp/skip-explicit-workspace", + }), ); expect(optionalPrewarm).not.toHaveBeenCalled(); } finally { @@ -140,6 +147,7 @@ describe("gateway startup primary model warmup", () => { await prewarmConfiguredPrimaryModel({ cfg, log: { warn: vi.fn() } }); expect(refreshPreparedModelRuntimeSnapshotsMock).toHaveBeenCalledWith(cfg, { + allowGatewaySubagentBinding: true, gatewayLifecycle: true, catalogMode: "static", }); @@ -154,6 +162,7 @@ describe("gateway startup primary model warmup", () => { }); expect(refreshPreparedModelRuntimeSnapshotsMock).toHaveBeenCalledWith(cfg, { + allowGatewaySubagentBinding: true, gatewayLifecycle: true, catalogMode: "static", defaultWorkspaceDir: "/tmp/explicit-workspace", diff --git a/src/gateway/server/plugins-http.ts b/src/gateway/server/plugins-http.ts index 724097126288..54a443b635c1 100644 --- a/src/gateway/server/plugins-http.ts +++ b/src/gateway/server/plugins-http.ts @@ -107,6 +107,7 @@ function canRunPluginHttpRouteWithoutAdmission(route: PluginHttpRouteRegistratio } function createPluginRouteRuntimeScope(params: { + registry: PluginRegistry; route: PluginHttpRouteRegistration; req: IncomingMessage; gatewayRequestContext?: GatewayRequestContext; @@ -131,6 +132,7 @@ function createPluginRouteRuntimeScope(params: { params.gatewayRequestClientIp, ); return { + pluginRegistry: params.registry, ...(params.gatewayRequestContext ? { context: params.gatewayRequestContext } : {}), client: runtimeClient, isWebchatConnect: () => false, @@ -254,6 +256,7 @@ export function createGatewayPluginRequestHandler(params: { const runRoute = async () => (await withPluginRuntimeGatewayRequestScope( createPluginRouteRuntimeScope({ + registry: params.registry, route, req, gatewayRequestContext, @@ -333,6 +336,7 @@ export function createGatewayPluginUpgradeHandler(params: { async () => (await withPluginRuntimeGatewayRequestScope( createPluginRouteRuntimeScope({ + registry: params.registry, route, req, gatewayRequestContext, diff --git a/src/gateway/worker-environments/inference-runtime.test.ts b/src/gateway/worker-environments/inference-runtime.test.ts index 40f1c88ec3c9..3dfdd8243ad2 100644 --- a/src/gateway/worker-environments/inference-runtime.test.ts +++ b/src/gateway/worker-environments/inference-runtime.test.ts @@ -178,6 +178,7 @@ function setup(entry: SessionEntry = sessionEntry) { const preparedModelRuntime = { agentDir: "/gateway-agent", activeProjectKeys: [], + allowGatewaySubagentBinding: true, workspaceDir: WORKSPACE, config, metadataSnapshot: { plugins: [] } as never, @@ -233,9 +234,10 @@ function setup(entry: SessionEntry = sessionEntry) { const acquireRuntimeLease = vi.fn(async (runtimeParams) => { scope.agentDir = runtimeParams.agentDir; scope.catalogWorkspace = WORKSPACE; - leasedPreparedModelRuntime = { ...preparedModelRuntime, agentDir: runtimeParams.agentDir }; + const leased = { ...preparedModelRuntime, agentDir: runtimeParams.agentDir }; + leasedPreparedModelRuntime = leased; return { - snapshot: leasedPreparedModelRuntime, + snapshot: leased, release: releaseRuntime, }; }); diff --git a/src/node-host/linux-node-plugin.integration.test.ts b/src/node-host/linux-node-plugin.integration.test.ts index 9e2cff22e6bb..48828849c6b5 100644 --- a/src/node-host/linux-node-plugin.integration.test.ts +++ b/src/node-host/linux-node-plugin.integration.test.ts @@ -4,8 +4,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { reconcileNodePairingOnConnect } from "../gateway/node-connect-reconcile.js"; import { resetPluginLoaderTestStateForTest } from "../plugins/loader.test-fixtures.js"; -import { testing as runtimeRegistryLoaderTesting } from "../plugins/runtime/runtime-registry-loader.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; import { listRegisteredNodeHostCapsAndCommands } from "./plugin-node-host.js"; +import { + getNodeHostPluginRegistry, + resetNodeHostPluginRegistry, +} from "./plugin-node-host.test-support.js"; import { prepareNodeHostRuntime } from "./runtime.js"; const LINUX_NODE_COMMANDS = [ @@ -18,7 +22,7 @@ const LINUX_NODE_COMMANDS = [ function resetPluginState(): void { resetPluginLoaderTestStateForTest(); - runtimeRegistryLoaderTesting.resetPluginRegistryLoadedForTests(); + resetNodeHostPluginRegistry(); } afterEach(() => { @@ -80,6 +84,7 @@ describe("linux-node node-host integration", () => { expect(prepared.manifest.commands).toEqual(expect.arrayContaining([...LINUX_NODE_COMMANDS])); const requestPairing = vi.fn(); + setActivePluginRegistry(getNodeHostPluginRegistry()!); const reconciliation = await reconcileNodePairingOnConnect({ cfg: config, connectParams: { diff --git a/src/node-host/plugin-node-host.test-support.ts b/src/node-host/plugin-node-host.test-support.ts new file mode 100644 index 000000000000..ea8d5ddb6496 --- /dev/null +++ b/src/node-host/plugin-node-host.test-support.ts @@ -0,0 +1,21 @@ +import type { PluginRegistry } from "../plugins/registry-types.js"; +import "./plugin-node-host.js"; + +type NodeHostPluginTestApi = { + getNodeHostPluginRegistry(): PluginRegistry | undefined; + resetNodeHostPluginRegistry(): void; +}; + +function getTestApi(): NodeHostPluginTestApi { + return (globalThis as Record)[ + Symbol.for("openclaw.nodeHostPluginTestApi") + ] as NodeHostPluginTestApi; +} + +export function getNodeHostPluginRegistry(): PluginRegistry | undefined { + return getTestApi().getNodeHostPluginRegistry(); +} + +export function resetNodeHostPluginRegistry(): void { + getTestApi().resetNodeHostPluginRegistry(); +} diff --git a/src/node-host/plugin-node-host.test.ts b/src/node-host/plugin-node-host.test.ts index e43ff34a728a..9c6432bf76ee 100644 --- a/src/node-host/plugin-node-host.test.ts +++ b/src/node-host/plugin-node-host.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; +import { getPluginRuntimeGatewayRequestScope } from "../plugins/runtime/gateway-request-scope.js"; import { invokeRegisteredNodeHostCommand, listRegisteredNodeHostCapsAndCommands, @@ -166,6 +167,7 @@ describe("plugin node-host registry", () => { let notify: (() => void) | undefined; const cleanup = vi.fn(); const onChange = vi.fn(); + const scopedRegistry = vi.fn(); const registry = createEmptyPluginRegistry(); registry.nodeHostCommands = [ { @@ -176,7 +178,11 @@ describe("plugin node-host registry", () => { cap: "browser", watchAvailability: (_context, callback) => { notify = callback; - return cleanup; + scopedRegistry(getPluginRuntimeGatewayRequestScope()?.pluginRegistry); + return () => { + scopedRegistry(getPluginRuntimeGatewayRequestScope()?.pluginRegistry); + cleanup(); + }; }, handle: vi.fn(async () => "{}"), }, @@ -185,15 +191,25 @@ describe("plugin node-host registry", () => { ]; setActivePluginRegistry(registry); - const stop = watchRegisteredNodeHostCommandAvailability(availabilityContext, onChange); + const stop = watchRegisteredNodeHostCommandAvailability(availabilityContext, () => { + scopedRegistry(getPluginRuntimeGatewayRequestScope()?.pluginRegistry); + onChange(); + }); notify?.(); expect(onChange).toHaveBeenCalledOnce(); stop(); expect(cleanup).toHaveBeenCalledOnce(); + expect(scopedRegistry).toHaveBeenCalledTimes(3); + expect(scopedRegistry).toHaveBeenNthCalledWith(1, registry); + expect(scopedRegistry).toHaveBeenNthCalledWith(2, registry); + expect(scopedRegistry).toHaveBeenNthCalledWith(3, registry); }); it("dispatches plugin-declared node-host commands", async () => { - const handle = vi.fn(async (paramsJSON?: string | null) => paramsJSON ?? ""); + const handle = vi.fn(async (paramsJSON?: string | null) => { + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(registry); + return paramsJSON ?? ""; + }); const registry = createEmptyPluginRegistry(); registry.nodeHostCommands = [ { diff --git a/src/node-host/plugin-node-host.ts b/src/node-host/plugin-node-host.ts index b3e1f19a3819..ee6fe8ec2d87 100644 --- a/src/node-host/plugin-node-host.ts +++ b/src/node-host/plugin-node-host.ts @@ -1,8 +1,12 @@ /** Plugin node-host bridge for loading plugin registry commands and dispatching node capabilities. */ import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/src/schema/nodes.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import type { PluginNodeHostCommandRegistration } from "../plugins/registry-types.js"; +import type { + PluginNodeHostCommandRegistration, + PluginRegistry, +} from "../plugins/registry-types.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; +import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js"; import type { OpenClawPluginNodeHostCommandAvailabilityContext, OpenClawPluginNodeHostCommandIo, @@ -18,16 +22,20 @@ import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; */ const loadPluginRegistryLoaderModule = createLazyRuntimeModule( - () => import("../plugins/runtime/runtime-registry-loader.js"), + () => import("../plugins/loader.js"), ); +let nodeHostPluginRegistry: PluginRegistry | undefined; + +function resolveNodeHostPluginRegistry() { + return nodeHostPluginRegistry ?? getActivePluginRegistry() ?? undefined; +} /** Ensure plugin registry data is loaded before node-host command dispatch. */ export async function ensureNodeHostPluginRegistry(params: { config: OpenClawConfig; env?: NodeJS.ProcessEnv; }): Promise { - (await loadPluginRegistryLoaderModule()).ensurePluginRegistryLoaded({ - scope: "all", + nodeHostPluginRegistry = (await loadPluginRegistryLoaderModule()).loadPluginRegistryHandle({ config: params.config, activationSourceConfig: params.config, env: params.env, @@ -43,36 +51,38 @@ export function listRegisteredNodeHostCapsAndCommands( commands: string[]; nodePluginTools: NodePluginToolDescriptor[]; } { - const registry = getActivePluginRegistry(); - const caps = new Set(); - const commands = new Set(); - const nodePluginTools = new Map(); - for (const entry of registry?.nodeHostCommands ?? []) { - if (entry.command.duplex === true && options.includeDuplex === false) { - continue; + const registry = resolveNodeHostPluginRegistry(); + return withPluginRuntimeRegistryScope(registry, () => { + const caps = new Set(); + const commands = new Set(); + const nodePluginTools = new Map(); + for (const entry of registry?.nodeHostCommands ?? []) { + if (entry.command.duplex === true && options.includeDuplex === false) { + continue; + } + // Availability belongs to the node-local plugin. Gateway policy still keeps + // the command registered so a differently configured remote node can expose it. + if (entry.command.isAvailable?.(context) === false) { + continue; + } + if (entry.command.cap) { + caps.add(entry.command.cap); + } + commands.add(entry.command.command); + const agentTool = buildNodePluginToolDescriptor(entry); + if (agentTool) { + nodePluginTools.set(`${agentTool.pluginId}\0${agentTool.name}`, agentTool); + } } - // Availability belongs to the node-local plugin. Gateway policy still keeps - // the command registered so a differently configured remote node can expose it. - if (entry.command.isAvailable?.(context) === false) { - continue; - } - if (entry.command.cap) { - caps.add(entry.command.cap); - } - commands.add(entry.command.command); - const agentTool = buildNodePluginToolDescriptor(entry); - if (agentTool) { - nodePluginTools.set(`${agentTool.pluginId}\0${agentTool.name}`, agentTool); - } - } - return { - caps: [...caps].toSorted((left, right) => left.localeCompare(right)), - commands: [...commands].toSorted((left, right) => left.localeCompare(right)), - nodePluginTools: [...nodePluginTools.values()].toSorted( - (left, right) => - left.pluginId.localeCompare(right.pluginId) || left.name.localeCompare(right.name), - ), - }; + return { + caps: [...caps].toSorted((left, right) => left.localeCompare(right)), + commands: [...commands].toSorted((left, right) => left.localeCompare(right)), + nodePluginTools: [...nodePluginTools.values()].toSorted( + (left, right) => + left.pluginId.localeCompare(right.pluginId) || left.name.localeCompare(right.name), + ), + }; + }); } /** Watch plugin-owned availability inputs that can change during this process. */ @@ -80,19 +90,24 @@ export function watchRegisteredNodeHostCommandAvailability( context: OpenClawPluginNodeHostCommandAvailabilityContext, onChange: () => void, ): () => void { - const registry = getActivePluginRegistry(); + const registry = resolveNodeHostPluginRegistry(); const cleanups: Array<() => void> = []; - for (const entry of registry?.nodeHostCommands ?? []) { - const cleanup = entry.command.watchAvailability?.(context, onChange); - if (cleanup) { - cleanups.push(cleanup); + withPluginRuntimeRegistryScope(registry, () => { + for (const entry of registry?.nodeHostCommands ?? []) { + const cleanup = entry.command.watchAvailability?.(context, () => + withPluginRuntimeRegistryScope(registry, onChange), + ); + if (cleanup) { + cleanups.push(cleanup); + } } - } - return () => { - for (const cleanup of cleanups.splice(0)) { - cleanup(); - } - }; + }); + return () => + withPluginRuntimeRegistryScope(registry, () => { + for (const cleanup of cleanups.splice(0)) { + cleanup(); + } + }); } function normalizeString(value: unknown): string { @@ -144,30 +159,43 @@ export async function invokeRegisteredNodeHostCommand( io?: OpenClawPluginNodeHostCommandIo, context?: OpenClawPluginNodeHostCommandContext, ): Promise { - const registry = getActivePluginRegistry(); + const registry = resolveNodeHostPluginRegistry(); const match = (registry?.nodeHostCommands ?? []).find( (entry) => entry.command.command === command, ); if (!match) { return null; } - if (match.command.duplex === true) { - if (!io) { - throw new Error(`node command requires duplex transport: ${command}`); + return await withPluginRuntimeRegistryScope(registry, async () => { + if (match.command.duplex === true) { + if (!io) { + throw new Error(`node command requires duplex transport: ${command}`); + } + return context + ? await match.command.handle(paramsJSON, io, context) + : await match.command.handle(paramsJSON, io); } return context - ? await match.command.handle(paramsJSON, io, context) - : await match.command.handle(paramsJSON, io); - } - return context - ? await match.command.handle(paramsJSON, undefined, context) - : await match.command.handle(paramsJSON); + ? await match.command.handle(paramsJSON, undefined, context) + : await match.command.handle(paramsJSON); + }); } export function isRegisteredNodeHostCommandDuplex(command: string): boolean { - const registry = getActivePluginRegistry(); + const registry = resolveNodeHostPluginRegistry(); return ( (registry?.nodeHostCommands ?? []).find((entry) => entry.command.command === command)?.command .duplex === true ); } + +function resetNodeHostPluginRegistry(): void { + nodeHostPluginRegistry = undefined; +} + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.nodeHostPluginTestApi")] = { + getNodeHostPluginRegistry: () => nodeHostPluginRegistry, + resetNodeHostPluginRegistry, + }; +} diff --git a/src/plugins/agent-tool-result-middleware-loader.ts b/src/plugins/agent-tool-result-middleware-loader.ts index 8db952244f5e..432ad330af13 100644 --- a/src/plugins/agent-tool-result-middleware-loader.ts +++ b/src/plugins/agent-tool-result-middleware-loader.ts @@ -6,7 +6,7 @@ import type { AgentToolResultMiddlewareRuntime, } from "./agent-tool-result-middleware-types.js"; import { listAgentToolResultMiddlewares } from "./agent-tool-result-middleware.js"; -import { loadOpenClawPlugins } from "./loader.js"; +import { loadPluginRegistryHandle } from "./loader.js"; import type { PluginAgentToolResultMiddlewareOwner, PluginRegistry } from "./registry-types.js"; import { getActivePluginRegistry } from "./runtime.js"; @@ -86,14 +86,13 @@ export async function loadAgentToolResultMiddlewaresForRuntime(params: { runtime: params.runtime, }) ? loadedRegistry - : loadOpenClawPlugins({ + : loadPluginRegistryHandle({ config: (await import("../config/config.js")).getRuntimeConfig(), onlyPluginIds: missingPluginIds, manifestRegistry: { plugins: missingOwners.map((owner) => owner.manifest), diagnostics: [], }, - activate: false, forceFullRuntimeForChannelPlugins: true, }); diff --git a/src/plugins/build-smoke-entry.ts b/src/plugins/build-smoke-entry.ts index a0d7c4fe4f93..1f7ac4108337 100644 --- a/src/plugins/build-smoke-entry.ts +++ b/src/plugins/build-smoke-entry.ts @@ -1,4 +1,4 @@ // Re-exports plugin modules used by build smoke checks. export { clearPluginCommands, executePluginCommand, matchPluginCommand } from "./commands.js"; export { getPluginCommandSpecs } from "./command-specs.js"; -export { loadOpenClawPlugins } from "./loader.js"; +export { loadOpenClawPlugins, loadPluginRegistryHandle } from "./loader.js"; diff --git a/src/plugins/cli-registry-loader.ts b/src/plugins/cli-registry-loader.ts index 8aaa3e6a3cd4..5a53a5528306 100644 --- a/src/plugins/cli-registry-loader.ts +++ b/src/plugins/cli-registry-loader.ts @@ -6,7 +6,7 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveManifestActivationPluginIds } from "./activation-planner.js"; import { createPluginCliGatewayNodesRuntime } from "./cli-gateway-nodes-runtime.js"; import type { PluginLoadOptions } from "./loader.js"; -import { loadOpenClawPluginCliRegistry, loadOpenClawPlugins } from "./loader.js"; +import { loadOpenClawPluginCliRegistry, loadPluginRegistryHandle } from "./loader.js"; import { createEmptyPluginRegistry } from "./registry-empty.js"; import type { PluginRegistry } from "./registry.js"; import { @@ -179,11 +179,10 @@ async function loadPluginCliCommandRegistryWithContext(params: { } return { ...params.context, - registry: loadOpenClawPlugins( + registry: loadPluginRegistryHandle( buildPluginRuntimeLoadOptions(params.context, { ...params.loaderOptions, ...(onlyPluginIds && onlyPluginIds.length > 0 ? { onlyPluginIds } : {}), - activate: false, cache: false, forceFullRuntimeForChannelPlugins: true, runtimeOptions: { diff --git a/src/plugins/loader.runtime-registry.test.ts b/src/plugins/loader.runtime-registry.test.ts index 914e26cbdab4..1018cc8439cc 100644 --- a/src/plugins/loader.runtime-registry.test.ts +++ b/src/plugins/loader.runtime-registry.test.ts @@ -11,7 +11,9 @@ import { import { resolvePluginLoadCacheContext } from "./loader-load-context.js"; import { clearPluginRegistryLoadCache, + loadAndActivateRootPluginRegistry, loadOpenClawPlugins, + loadPluginRegistryHandle, resolveRuntimePluginRegistry, } from "./loader.js"; import { makeTempDir, resetPluginLoaderTestStateForTest } from "./loader.test-fixtures.js"; @@ -22,12 +24,20 @@ import { import { buildMemoryPromptSection, registerMemoryCapability } from "./memory-state.js"; import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; import { createEmptyPluginRegistry } from "./registry.js"; -import { setActivePluginRegistry } from "./runtime.js"; +import { getActivePluginRegistry, setActivePluginRegistry } from "./runtime.js"; afterEach(() => { resetPluginLoaderTestStateForTest(); }); +it("keeps an empty scoped handle load from replacing the root registry", () => { + const root = loadAndActivateRootPluginRegistry({ cache: false, config: {} }); + const handle = loadPluginRegistryHandle({ cache: false, config: {}, onlyPluginIds: [] }); + + expect(handle).not.toBe(root); + expect(getActivePluginRegistry()).toBe(root); +}); + function requireMemoryEmbeddingProvider(providerId: string) { const provider = getRegisteredMemoryEmbeddingProvider(providerId)?.adapter; if (!provider) { diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index ae264b5eb75a..df7845ea72da 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -10,5 +10,18 @@ export { resolveCompatibleRuntimePluginRegistry, resolveRuntimePluginRegistry, } from "./loader-runtime-registry.js"; -export { loadOpenClawPlugins } from "./loader-runtime-load.js"; -export type { PluginLoadOptions } from "./loader-types.js"; +import { loadOpenClawPlugins } from "./loader-runtime-load.js"; +import type { PluginLoadOptions } from "./loader-types.js"; + +/** Loads a caller-owned registry value without changing the process-wide active registry. */ +export function loadPluginRegistryHandle(options: PluginLoadOptions = {}) { + return loadOpenClawPlugins({ ...options, activate: false }); +} + +/** Loads and installs the registry owned by a process composition root. */ +export function loadAndActivateRootPluginRegistry(options: PluginLoadOptions = {}) { + return loadOpenClawPlugins({ ...options, activate: true }); +} + +export { loadOpenClawPlugins }; +export type { PluginLoadOptions }; diff --git a/src/plugins/memory-runtime.test-support.ts b/src/plugins/memory-runtime.test-support.ts new file mode 100644 index 000000000000..3f4b9fba64b4 --- /dev/null +++ b/src/plugins/memory-runtime.test-support.ts @@ -0,0 +1,12 @@ +import "./memory-runtime.js"; + +type MemoryRuntimeTestApi = { + resetStandaloneMemoryRegistrySlot(): void; +}; + +export function resetStandaloneMemoryRegistrySlot(): void { + const api = (globalThis as Record)[ + Symbol.for("openclaw.memoryRuntimeTestApi") + ] as MemoryRuntimeTestApi; + api.resetStandaloneMemoryRegistrySlot(); +} diff --git a/src/plugins/memory-runtime.test.ts b/src/plugins/memory-runtime.test.ts index 9eb1c6484c2d..edaf08eea8b9 100644 --- a/src/plugins/memory-runtime.test.ts +++ b/src/plugins/memory-runtime.test.ts @@ -1,364 +1,177 @@ -/** Covers plugin memory provider runtime loading and registration contracts. */ +/** Covers non-activating memory registry handles and requesting-agent workspace ownership. */ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "./registry-empty.js"; +import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js"; -const resolveRuntimePluginRegistryMock = - vi.fn(); -const getLoadedRuntimePluginRegistryMock = - vi.fn(); -const ensureStandaloneRuntimePluginRegistryLoadedMock = vi.hoisted(() => - vi.fn< - typeof import("./runtime/standalone-runtime-registry-loader.js").ensureStandaloneRuntimePluginRegistryLoaded - >(), -); -const applyPluginAutoEnableMock = - vi.fn(); -const getMemoryRuntimeMock = vi.fn(); -const resolveAgentWorkspaceDirMock = - vi.fn(); -const resolveDefaultAgentIdMock = vi.fn< - typeof import("../agents/agent-scope.js").resolveDefaultAgentId ->(() => "default"); - -vi.mock("../config/plugin-auto-enable.js", () => ({ - applyPluginAutoEnable: applyPluginAutoEnableMock, +const mocks = vi.hoisted(() => ({ + getMemoryRuntime: vi.fn(), + loadRuntimePluginRegistryHandle: vi.fn(), + resolveAgentWorkspaceDir: vi.fn(), })); vi.mock("../agents/agent-scope.js", () => ({ - resolveAgentWorkspaceDir: resolveAgentWorkspaceDirMock, - resolveDefaultAgentId: resolveDefaultAgentIdMock, -})); - -vi.mock("./loader.js", () => ({ - resolveRuntimePluginRegistry: resolveRuntimePluginRegistryMock, -})); - -vi.mock("./active-runtime-registry.js", () => ({ - getLoadedRuntimePluginRegistry: getLoadedRuntimePluginRegistryMock, + resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, })); vi.mock("./runtime/standalone-runtime-registry-loader.js", () => ({ - ensureStandaloneRuntimePluginRegistryLoaded: ensureStandaloneRuntimePluginRegistryLoadedMock, + loadRuntimePluginRegistryHandle: mocks.loadRuntimePluginRegistryHandle, })); -vi.mock("./memory-state.js", () => ({ - getMemoryRuntime: () => getMemoryRuntimeMock(), -})); +vi.mock("./memory-state.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, getMemoryRuntime: mocks.getMemoryRuntime }; +}); -let getActiveMemorySearchManager: typeof import("./memory-runtime.js").getActiveMemorySearchManager; -let resolveActiveMemoryBackendConfig: typeof import("./memory-runtime.js").resolveActiveMemoryBackendConfig; -let closeActiveMemorySearchManager: typeof import("./memory-runtime.js").closeActiveMemorySearchManager; -let closeActiveMemorySearchManagers: typeof import("./memory-runtime.js").closeActiveMemorySearchManagers; +import { + closeActiveMemorySearchManager, + closeActiveMemorySearchManagers, + getActiveMemorySearchManager, + resolveActiveMemoryBackendConfig, +} from "./memory-runtime.js"; +import { resetStandaloneMemoryRegistrySlot } from "./memory-runtime.test-support.js"; -function createMemoryAutoEnableFixture() { - const rawConfig = { - plugins: {}, - channels: { memory: { enabled: true } }, - }; - const autoEnabledConfig = { - ...rawConfig, - plugins: { - entries: { - memory: { enabled: true }, - }, - }, - }; - return { rawConfig, autoEnabledConfig }; -} - -function createMemoryRuntimeFixture() { +function createRuntime() { return { getMemorySearchManager: vi.fn(async () => ({ manager: null, error: "no index" })), resolveMemoryBackendConfig: vi.fn(() => ({ backend: "builtin" as const })), closeMemorySearchManager: vi.fn(async () => {}), + closeAllMemorySearchManagers: vi.fn(async () => {}), }; } -function expectMemoryRuntimeLoaded( - config: unknown, - pluginIds: readonly string[] = ["memory-core"], -) { - expect(getLoadedRuntimePluginRegistryMock).toHaveBeenCalledWith({ - requiredPluginIds: pluginIds, +function createRegistry(runtime = createRuntime()) { + const registry = createEmptyPluginRegistry(); + registry.memoryCapabilities.push({ pluginId: "memory-core", capability: { runtime } }); + return { registry, runtime }; +} + +const memoryConfig = { + plugins: { slots: { memory: "memory-core" } }, +} as never; + +describe("memory runtime handles", () => { + beforeEach(() => { + resetStandaloneMemoryRegistrySlot(); + mocks.getMemoryRuntime.mockReset().mockReturnValue(undefined); + mocks.loadRuntimePluginRegistryHandle.mockReset(); + mocks.resolveAgentWorkspaceDir + .mockReset() + .mockImplementation((_cfg, agentId: string) => + agentId === "research" ? "/workspace/research" : "/workspace/main", + ); }); - expect(ensureStandaloneRuntimePluginRegistryLoadedMock).toHaveBeenCalledWith({ - requiredPluginIds: pluginIds, - loadOptions: { - config, - onlyPluginIds: pluginIds, - workspaceDir: "/resolved-workspace", - }, + + it("loads only the selected memory plugin into a non-activating handle", async () => { + const { registry, runtime } = createRegistry(); + runtime.getMemorySearchManager.mockImplementationOnce(async () => { + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(registry); + return { manager: null, error: "no index" }; + }); + runtime.resolveMemoryBackendConfig.mockImplementationOnce(() => { + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(registry); + return { backend: "builtin" }; + }); + mocks.loadRuntimePluginRegistryHandle.mockReturnValue(registry); + + await expect( + getActiveMemorySearchManager({ cfg: memoryConfig, agentId: "main" }), + ).resolves.toEqual({ manager: null, error: "no index" }); + + expect(mocks.loadRuntimePluginRegistryHandle).toHaveBeenCalledWith({ + requiredPluginIds: ["memory-core"], + loadOptions: { + activate: false, + config: memoryConfig, + onlyPluginIds: ["memory-core"], + workspaceDir: "/workspace/main", + }, + }); + expect(runtime.getMemorySearchManager).toHaveBeenCalledWith({ + cfg: memoryConfig, + agentId: "main", + }); + expect(resolveActiveMemoryBackendConfig({ cfg: memoryConfig, agentId: "main" })).toEqual({ + backend: "builtin", + }); }); -} -function expectMemoryAutoEnableApplied(rawConfig: unknown, autoEnabledConfig: unknown) { - expect(applyPluginAutoEnableMock).not.toHaveBeenCalled(); - expectMemoryRuntimeLoaded(rawConfig); - expect(rawConfig).not.toBe(autoEnabledConfig); -} + it("keys the single slot by the requesting agent workspace", () => { + const main = createRegistry(); + const research = createRegistry(); + mocks.loadRuntimePluginRegistryHandle + .mockReturnValueOnce(main.registry) + .mockReturnValueOnce(research.registry); -function setAutoEnabledMemoryRuntime() { - const { rawConfig, autoEnabledConfig } = createMemoryAutoEnableFixture(); - const runtime = createMemoryRuntimeFixture(); - applyPluginAutoEnableMock.mockReturnValue({ - config: autoEnabledConfig, - changes: [], - autoEnabledReasons: {}, - }); - getMemoryRuntimeMock - .mockReturnValueOnce(undefined) - .mockReturnValueOnce(undefined) - .mockReturnValue(runtime); - return { rawConfig, autoEnabledConfig, runtime }; -} + expect(resolveActiveMemoryBackendConfig({ cfg: memoryConfig, agentId: "main" })).toEqual({ + backend: "builtin", + }); + expect(resolveActiveMemoryBackendConfig({ cfg: memoryConfig, agentId: "main" })).toEqual({ + backend: "builtin", + }); + expect(resolveActiveMemoryBackendConfig({ cfg: memoryConfig, agentId: "research" })).toEqual({ + backend: "builtin", + }); -function expectNoMemoryRuntimeBootstrap() { - expect(applyPluginAutoEnableMock).not.toHaveBeenCalled(); - expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); - expect(getLoadedRuntimePluginRegistryMock).not.toHaveBeenCalled(); - expect(ensureStandaloneRuntimePluginRegistryLoadedMock).not.toHaveBeenCalled(); -} - -async function expectAutoEnabledMemoryRuntimeCase(params: { - run: (rawConfig: unknown) => Promise; - expectedResult: unknown; -}) { - const { rawConfig, autoEnabledConfig } = setAutoEnabledMemoryRuntime(); - const result = await params.run(rawConfig); - - if (params.expectedResult !== undefined) { - expect(result).toEqual(params.expectedResult); - } - expectMemoryAutoEnableApplied(rawConfig, autoEnabledConfig); -} - -async function expectCloseMemoryRuntimeCase(params: { - config: unknown; - setup: () => { closeAllMemorySearchManagers: ReturnType } | undefined; -}) { - const runtime = params.setup(); - await closeActiveMemorySearchManagers(params.config as never); - - if (runtime) { - expect(runtime.closeAllMemorySearchManagers).toHaveBeenCalledTimes(1); - } - expectNoMemoryRuntimeBootstrap(); -} - -describe("memory runtime auto-enable loading", () => { - beforeEach(async () => { - vi.resetModules(); - ({ - getActiveMemorySearchManager, - resolveActiveMemoryBackendConfig, - closeActiveMemorySearchManager, - closeActiveMemorySearchManagers, - } = await import("./memory-runtime.js")); - resolveRuntimePluginRegistryMock.mockReset(); - getLoadedRuntimePluginRegistryMock.mockReset(); - ensureStandaloneRuntimePluginRegistryLoadedMock.mockReset(); - applyPluginAutoEnableMock.mockReset(); - getMemoryRuntimeMock.mockReset(); - resolveAgentWorkspaceDirMock.mockReset(); - resolveDefaultAgentIdMock.mockClear(); - applyPluginAutoEnableMock.mockImplementation((params) => ({ - config: params.config ?? {}, - changes: [], - autoEnabledReasons: {}, - })); - resolveAgentWorkspaceDirMock.mockReturnValue("/resolved-workspace"); + expect(mocks.resolveAgentWorkspaceDir).toHaveBeenNthCalledWith(1, memoryConfig, "main"); + expect(mocks.resolveAgentWorkspaceDir).toHaveBeenLastCalledWith(memoryConfig, "research"); + expect(mocks.loadRuntimePluginRegistryHandle).toHaveBeenCalledTimes(2); }); it.each([ + { plugins: { enabled: false } }, + { plugins: { slots: { memory: "none" } } }, + { plugins: { slots: { memory: "memory-core" }, deny: ["memory-core"] } }, { - name: "loads memory runtime from the auto-enabled config snapshot", - run: async (rawConfig: unknown) => - getActiveMemorySearchManager({ - cfg: rawConfig as never, - agentId: "main", - }), - expectedResult: undefined, - }, - { - name: "reuses the same auto-enabled load path for backend config resolution", - run: async (rawConfig: unknown) => - resolveActiveMemoryBackendConfig({ - cfg: rawConfig as never, - agentId: "main", - }), - expectedResult: { backend: "builtin" }, - }, - ] as const)("$name", async ({ run, expectedResult }) => { - await expectAutoEnabledMemoryRuntimeCase({ run, expectedResult }); - }); - - it("loads only the configured memory slot plugin", async () => { - const rawConfig = { plugins: { - slots: { - memory: "memory-lancedb", - }, + slots: { memory: "memory-core" }, + entries: { "memory-core": { enabled: false } }, }, - }; - const runtime = createMemoryRuntimeFixture(); - applyPluginAutoEnableMock.mockReturnValue({ - config: rawConfig, - changes: [], - autoEnabledReasons: {}, - }); - getMemoryRuntimeMock - .mockReturnValueOnce(undefined) - .mockReturnValueOnce(undefined) - .mockReturnValue(runtime); - - await getActiveMemorySearchManager({ - cfg: rawConfig as never, - agentId: "main", - }); - - expectMemoryRuntimeLoaded(rawConfig, ["memory-lancedb"]); - }); - - it("does not fall back to broad plugin loading when the memory slot is disabled", async () => { - const rawConfig = { - plugins: { - slots: { - memory: "none", - }, - }, - }; - applyPluginAutoEnableMock.mockReturnValue({ - config: rawConfig, - changes: [], - autoEnabledReasons: {}, - }); - getMemoryRuntimeMock.mockReturnValue(undefined); - + }, + ])("does not load a disabled memory selection", async (cfg) => { await expect( - getActiveMemorySearchManager({ - cfg: rawConfig as never, - agentId: "main", - }), + getActiveMemorySearchManager({ cfg: cfg as never, agentId: "main" }), ).resolves.toEqual({ manager: null, error: "memory plugin unavailable" }); - - expect(applyPluginAutoEnableMock).not.toHaveBeenCalled(); - expect(resolveRuntimePluginRegistryMock).not.toHaveBeenCalled(); - expect(getLoadedRuntimePluginRegistryMock).not.toHaveBeenCalled(); - expect(ensureStandaloneRuntimePluginRegistryLoadedMock).not.toHaveBeenCalled(); + expect(mocks.loadRuntimePluginRegistryHandle).not.toHaveBeenCalled(); }); - it("does not standalone-load the memory plugin when plugins are globally disabled", async () => { - const rawConfig = { - plugins: { - enabled: false, - }, - }; - getMemoryRuntimeMock.mockReturnValue(undefined); + it("prefers an already-registered runtime", () => { + const runtime = createRuntime(); + mocks.getMemoryRuntime.mockReturnValue(runtime); - await expect( - getActiveMemorySearchManager({ - cfg: rawConfig as never, - agentId: "main", - }), - ).resolves.toEqual({ manager: null, error: "memory plugin unavailable" }); - - expectNoMemoryRuntimeBootstrap(); - }); - - it.each([ - { - name: "denied", - plugins: { - deny: ["memory-core"], - slots: { - memory: "memory-core", - }, - }, - }, - { - name: "entry-disabled", - plugins: { - entries: { - "memory-core": { enabled: false }, - }, - slots: { - memory: "memory-core", - }, - }, - }, - ] as const)("does not standalone-load a $name memory slot plugin", async ({ plugins }) => { - getMemoryRuntimeMock.mockReturnValue(undefined); - - await expect( - getActiveMemorySearchManager({ - cfg: { plugins } as never, - agentId: "main", - }), - ).resolves.toEqual({ manager: null, error: "memory plugin unavailable" }); - - expectNoMemoryRuntimeBootstrap(); - }); - - it("does not standalone-load plugins when the memory runtime is already registered", () => { - const rawConfig = { - plugins: { - slots: { - memory: "memory-core", - }, - }, - }; - const runtime = createMemoryRuntimeFixture(); - getLoadedRuntimePluginRegistryMock.mockReturnValue({} as never); - getMemoryRuntimeMock.mockReturnValueOnce(undefined).mockReturnValue(runtime); - - resolveActiveMemoryBackendConfig({ - cfg: rawConfig as never, - agentId: "main", + expect(resolveActiveMemoryBackendConfig({ cfg: memoryConfig, agentId: "main" })).toEqual({ + backend: "builtin", }); - - expect(getLoadedRuntimePluginRegistryMock).toHaveBeenCalled(); - expect(ensureStandaloneRuntimePluginRegistryLoadedMock).not.toHaveBeenCalled(); + expect(mocks.loadRuntimePluginRegistryHandle).not.toHaveBeenCalled(); }); - it.each([ - { - name: "does not bootstrap the memory runtime just to close managers", - config: { - plugins: {}, - channels: { memory: { enabled: true } }, - }, - setup: () => { - getMemoryRuntimeMock.mockReturnValue(undefined); - return undefined; - }, - }, - { - name: "closes an already-registered memory runtime without reloading plugins", - config: {}, - setup: () => { - const runtime = { - getMemorySearchManager: vi.fn(async () => ({ manager: null, error: "no index" })), - resolveMemoryBackendConfig: vi.fn(() => ({ backend: "builtin" as const })), - closeAllMemorySearchManagers: vi.fn(async () => {}), - }; - getMemoryRuntimeMock.mockReturnValue(runtime); - return runtime; - }, - }, - ] as const)("$name", async ({ config, setup }) => { - await expectCloseMemoryRuntimeCase({ config, setup }); - }); + it("closes managers through current and retired workspace handles without reloading", async () => { + const main = createRegistry(); + const research = createRegistry(); + for (const owner of [main, research]) { + owner.runtime.closeMemorySearchManager.mockImplementationOnce(async () => { + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(owner.registry); + }); + owner.runtime.closeAllMemorySearchManagers.mockImplementationOnce(async () => { + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(owner.registry); + }); + } + mocks.loadRuntimePluginRegistryHandle + .mockReturnValueOnce(main.registry) + .mockReturnValueOnce(research.registry); + resolveActiveMemoryBackendConfig({ cfg: memoryConfig, agentId: "main" }); + resolveActiveMemoryBackendConfig({ cfg: memoryConfig, agentId: "research" }); + mocks.loadRuntimePluginRegistryHandle.mockClear(); - it("delegates scoped cleanup to the loaded memory runtime without reloading plugins", async () => { - const runtime = createMemoryRuntimeFixture(); - const cfg = { plugins: {} }; - getMemoryRuntimeMock.mockReturnValue(runtime); + await closeActiveMemorySearchManager({ cfg: memoryConfig, agentId: "main" }); + await closeActiveMemorySearchManagers(memoryConfig); - await closeActiveMemorySearchManager({ cfg: cfg as never, agentId: "main" }); - - expect(runtime.closeMemorySearchManager).toHaveBeenCalledWith({ - cfg, - agentId: "main", - }); - expectNoMemoryRuntimeBootstrap(); + for (const { runtime } of [main, research]) { + expect(runtime.closeMemorySearchManager).toHaveBeenCalledWith({ + cfg: memoryConfig, + agentId: "main", + }); + expect(runtime.closeAllMemorySearchManagers).toHaveBeenCalledTimes(1); + } + expect(mocks.loadRuntimePluginRegistryHandle).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/memory-runtime.ts b/src/plugins/memory-runtime.ts index 831e35c993fb..95c0c8d08877 100644 --- a/src/plugins/memory-runtime.ts +++ b/src/plugins/memory-runtime.ts @@ -1,11 +1,21 @@ // Runtime bridge for plugin-owned memory hooks and state. -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveUserPath } from "../utils.js"; -import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js"; import { normalizePluginsConfig } from "./config-state.js"; -import { getMemoryRuntime } from "./memory-state.js"; -import { ensureStandaloneRuntimePluginRegistryLoaded } from "./runtime/standalone-runtime-registry-loader.js"; +import { resolvePluginRegistryLoadCacheKey } from "./loader.js"; +import { getMemoryRuntime, resolveMemoryCapabilityRegistration } from "./memory-state.js"; +import type { PluginRegistry } from "./registry-types.js"; +import { withPluginRuntimeRegistryScope } from "./runtime/gateway-request-scope.js"; +import { loadRuntimePluginRegistryHandle } from "./runtime/standalone-runtime-registry-loader.js"; + +type MemoryRuntime = NonNullable< + PluginRegistry["memoryCapabilities"][number]["capability"]["runtime"] +>; +type MemoryRuntimeOwner = { runtime: MemoryRuntime; registry?: PluginRegistry }; +let standaloneMemoryRegistrySlot: + | { key: string; registry: PluginRegistry; retiredRuntimes: Map } + | undefined; /** Resolves the configured memory slot to the single runtime plugin that may load memory. */ function resolveMemoryRuntimePluginIds(config: OpenClawConfig): string[] { @@ -21,8 +31,10 @@ function resolveMemoryRuntimePluginIds(config: OpenClawConfig): string[] { return [pluginId]; } -function resolveMemoryRuntimeWorkspaceDir(cfg: OpenClawConfig): string | undefined { - const agentId = resolveDefaultAgentId(cfg); +function resolveMemoryRuntimeWorkspaceDir( + cfg: OpenClawConfig, + agentId: string, +): string | undefined { const dir = resolveAgentWorkspaceDir(cfg, agentId); if (typeof dir !== "string" || !dir.trim()) { return undefined; @@ -30,29 +42,77 @@ function resolveMemoryRuntimeWorkspaceDir(cfg: OpenClawConfig): string | undefin return resolveUserPath(dir); } -function ensureMemoryRuntime(cfg?: OpenClawConfig) { +function resolveMemoryRuntimeFromRegistry(registry: PluginRegistry) { + return resolveMemoryCapabilityRegistration(registry.memoryCapabilities)?.capability.runtime; +} + +function listCurrentMemoryRuntimeOwners(): MemoryRuntimeOwner[] { const current = getMemoryRuntime(); - if (current || !cfg) { - return current; + const owners = new Map(); + for (const [runtime, registry] of standaloneMemoryRegistrySlot?.retiredRuntimes ?? []) { + owners.set(runtime, { runtime, registry }); } - const onlyPluginIds = resolveMemoryRuntimePluginIds(cfg); + if (current) { + owners.set(current, { runtime: current }); + } + if (standaloneMemoryRegistrySlot) { + const runtime = resolveMemoryRuntimeFromRegistry(standaloneMemoryRegistrySlot.registry); + if (runtime) { + owners.set(runtime, { runtime, registry: standaloneMemoryRegistrySlot.registry }); + } + } + return [...owners.values()]; +} + +function withMemoryRuntimeOwner( + owner: MemoryRuntimeOwner, + run: (runtime: MemoryRuntime) => T, +): T { + return withPluginRuntimeRegistryScope(owner.registry, () => run(owner.runtime)); +} + +function ensureMemoryRuntime(params?: { + cfg: OpenClawConfig; + agentId: string; +}): MemoryRuntimeOwner | undefined { + const current = getMemoryRuntime(); + if (current || !params) { + return current ? { runtime: current } : undefined; + } + const onlyPluginIds = resolveMemoryRuntimePluginIds(params.cfg); if (onlyPluginIds.length === 0) { - return getMemoryRuntime(); + return undefined; } - getLoadedRuntimePluginRegistry({ requiredPluginIds: onlyPluginIds }); - if (getMemoryRuntime()) { - return getMemoryRuntime(); + const workspaceDir = resolveMemoryRuntimeWorkspaceDir(params.cfg, params.agentId); + const loadOptions = { + config: params.cfg, + onlyPluginIds, + workspaceDir, + activate: false as const, + }; + const key = resolvePluginRegistryLoadCacheKey(loadOptions); + if (standaloneMemoryRegistrySlot?.key === key) { + const runtime = resolveMemoryRuntimeFromRegistry(standaloneMemoryRegistrySlot.registry); + return runtime ? { runtime, registry: standaloneMemoryRegistrySlot.registry } : undefined; } - const workspaceDir = resolveMemoryRuntimeWorkspaceDir(cfg); - ensureStandaloneRuntimePluginRegistryLoaded({ + const registry = loadRuntimePluginRegistryHandle({ requiredPluginIds: onlyPluginIds, - loadOptions: { - config: cfg, - onlyPluginIds, - workspaceDir, - }, + loadOptions, }); - return getMemoryRuntime(); + if (!registry) { + return undefined; + } + const runtime = resolveMemoryRuntimeFromRegistry(registry); + const previousSlot = standaloneMemoryRegistrySlot; + const retiredRuntimes = new Map(previousSlot?.retiredRuntimes); + const previousRuntime = previousSlot + ? resolveMemoryRuntimeFromRegistry(previousSlot.registry) + : undefined; + if (previousSlot && previousRuntime && previousRuntime !== runtime) { + retiredRuntimes.set(previousRuntime, previousSlot.registry); + } + standaloneMemoryRegistrySlot = { key, registry, retiredRuntimes }; + return runtime ? { runtime, registry } : undefined; } /** Returns the active plugin-backed memory search manager for an agent. */ @@ -61,23 +121,35 @@ export async function getActiveMemorySearchManager(params: { agentId: string; purpose?: "default" | "status" | "cli"; }) { - const runtime = ensureMemoryRuntime(params.cfg); - if (!runtime) { + const owner = ensureMemoryRuntime(params); + if (!owner) { return { manager: null, error: "memory plugin unavailable" }; } - return await runtime.getMemorySearchManager(params); + return await withMemoryRuntimeOwner( + owner, + async (runtime) => await runtime.getMemorySearchManager(params), + ); } /** Resolves current memory backend config without constructing a manager. */ export function resolveActiveMemoryBackendConfig(params: { cfg: OpenClawConfig; agentId: string }) { - return ensureMemoryRuntime(params.cfg)?.resolveMemoryBackendConfig(params) ?? null; + const owner = ensureMemoryRuntime(params); + return owner + ? withMemoryRuntimeOwner(owner, (runtime) => runtime.resolveMemoryBackendConfig(params)) + : null; } /** Closes all active plugin-backed memory search managers. */ export async function closeActiveMemorySearchManagers(cfg?: OpenClawConfig): Promise { void cfg; - const runtime = getMemoryRuntime(); - await runtime?.closeAllMemorySearchManagers?.(); + await Promise.all( + listCurrentMemoryRuntimeOwners().map((owner) => + withMemoryRuntimeOwner(owner, async (runtime) => { + await runtime.closeAllMemorySearchManagers?.(); + }), + ), + ); + standaloneMemoryRegistrySlot?.retiredRuntimes.clear(); } /** Closes the plugin-backed memory search manager for one agent. */ @@ -85,6 +157,21 @@ export async function closeActiveMemorySearchManager(params: { cfg: OpenClawConfig; agentId: string; }): Promise { - const runtime = getMemoryRuntime(); - await runtime?.closeMemorySearchManager?.(params); + await Promise.all( + listCurrentMemoryRuntimeOwners().map((owner) => + withMemoryRuntimeOwner(owner, async (runtime) => { + await runtime.closeMemorySearchManager?.(params); + }), + ), + ); +} + +function resetStandaloneMemoryRegistrySlot(): void { + standaloneMemoryRegistrySlot = undefined; +} + +if (process.env.VITEST || process.env.NODE_ENV === "test") { + (globalThis as Record)[Symbol.for("openclaw.memoryRuntimeTestApi")] = { + resetStandaloneMemoryRegistrySlot, + }; } diff --git a/src/plugins/migration-provider-runtime.test.ts b/src/plugins/migration-provider-runtime.test.ts index fa710f244440..88d14e25e227 100644 --- a/src/plugins/migration-provider-runtime.test.ts +++ b/src/plugins/migration-provider-runtime.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginRegistry } from "./registry-types.js"; import { createEmptyPluginRegistry } from "./registry.js"; +import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js"; type MockManifestRegistry = { plugins: Array>; @@ -42,11 +43,12 @@ const mocks = vi.hoisted(() => ({ snapshot: params?.index ?? createMockPluginIndex([]), diagnostics: [], })), - ensureStandaloneRuntimePluginRegistryLoaded: vi.fn(), + loadRuntimePluginRegistryHandle: vi.fn(), listBundledPluginMetadata: vi.fn(() => []), })); -vi.mock("./loader.js", () => ({ +vi.mock("./loader.js", async (importOriginal) => ({ + ...(await importOriginal()), resolveRuntimePluginRegistry: mocks.resolveRuntimePluginRegistry, })); @@ -72,7 +74,7 @@ vi.mock("./manifest-registry-installed.js", () => ({ })); vi.mock("./runtime/standalone-runtime-registry-loader.js", () => ({ - ensureStandaloneRuntimePluginRegistryLoaded: mocks.ensureStandaloneRuntimePluginRegistryLoaded, + loadRuntimePluginRegistryHandle: mocks.loadRuntimePluginRegistryHandle, })); vi.mock("./bundled-plugin-metadata.js", () => ({ @@ -106,10 +108,12 @@ function requireMockCallArg( describe("migration provider runtime", () => { beforeEach(async () => { + vi.resetModules(); vi.clearAllMocks(); mocks.resolveRuntimePluginRegistry.mockReturnValue(createEmptyPluginRegistry()); mocks.loadPluginManifestRegistry.mockReturnValue(createEmptyMockManifestRegistry()); mocks.loadPluginRegistrySnapshot.mockReturnValue(createMockPluginIndex([])); + mocks.loadRuntimePluginRegistryHandle.mockReturnValue(createEmptyPluginRegistry()); mocks.listBundledPluginMetadata.mockReturnValue([]); mocks.loadPluginRegistrySnapshotWithMetadata.mockImplementation( (params?: { index?: MockPluginIndex }) => ({ @@ -151,8 +155,8 @@ describe("migration provider runtime", () => { }); const standaloneParams = requireMockCallArg( - mocks.ensureStandaloneRuntimePluginRegistryLoaded, - "ensureStandaloneRuntimePluginRegistryLoaded", + mocks.loadRuntimePluginRegistryHandle, + "loadRuntimePluginRegistryHandle", ) as { surface?: unknown; requiredPluginIds?: unknown; @@ -164,7 +168,6 @@ describe("migration provider runtime", () => { }; expect(standaloneParams.surface).toBe("active"); expect(standaloneParams.requiredPluginIds).toEqual(["migrate-hermes"]); - expect(standaloneParams.loadOptions?.activate).toBe(false); expect(standaloneParams.loadOptions?.onlyPluginIds).toEqual(["migrate-hermes"]); expect(standaloneParams.loadOptions?.config?.plugins?.enabled).toBe(true); expect(standaloneParams.loadOptions?.config?.plugins?.entries).toEqual({ @@ -185,8 +188,8 @@ describe("migration provider runtime", () => { ensureStandaloneMigrationProviderRegistryLoaded({ providerId: "hermes" }); const standaloneParams = requireMockCallArg( - mocks.ensureStandaloneRuntimePluginRegistryLoaded, - "ensureStandaloneRuntimePluginRegistryLoaded", + mocks.loadRuntimePluginRegistryHandle, + "loadRuntimePluginRegistryHandle", ); expect(standaloneParams.requiredPluginIds).toEqual(["migrate-hermes"]); expect( @@ -194,7 +197,7 @@ describe("migration provider runtime", () => { ).toEqual(["migrate-hermes"]); }); - it("loads configured external migration-provider plugins from manifest contracts", () => { + it("loads configured external migration-provider plugins from manifest contracts", async () => { const cfg = { plugins: { entries: { "external-migration": { enabled: true } } }, } as OpenClawConfig; @@ -250,7 +253,12 @@ describe("migration provider runtime", () => { const resolved = resolvePluginMigrationProvider({ providerId: "external-import", cfg }); - expect(resolved).toBe(provider); + expect(resolved).not.toBe(provider); + provider.plan.mockImplementationOnce(() => { + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(loaded); + return {} as never; + }); + await resolved?.plan({} as never); expect(mocks.loadPluginRegistrySnapshotWithMetadata).toHaveBeenCalledWith({ config: cfg, env: process.env, @@ -301,7 +309,7 @@ describe("migration provider runtime", () => { const resolved = resolvePluginMigrationProvider({ providerId: "hermes" }); - expect(resolved).toBe(provider); + expect(resolved).not.toBe(provider); expect(mocks.listBundledPluginMetadata).toHaveBeenCalledWith({ includeChannelConfigs: false, }); @@ -310,6 +318,45 @@ describe("migration provider runtime", () => { }); }); + it("does not reuse a standalone handle after the migration owner or config changes", () => { + const cfgA = { plugins: { allow: ["migration-a"] } } as OpenClawConfig; + const cfgB = { plugins: { allow: ["migration-b"] } } as OpenClawConfig; + const provider = createMigrationProvider("shared-import"); + const loadedA = createEmptyPluginRegistry(); + loadedA.migrationProviders.push({ + pluginId: "migration-a", + pluginName: "Migration A", + source: "test", + provider, + } as never); + mocks.loadRuntimePluginRegistryHandle.mockReturnValue(loadedA); + mocks.listBundledPluginMetadata.mockReturnValue([ + { + manifest: { + id: "migration-a", + contracts: { migrationProviders: ["shared-import"] }, + }, + }, + ] as never); + + ensureStandaloneMigrationProviderRegistryLoaded({ + cfg: cfgA, + providerId: "shared-import", + }); + mocks.listBundledPluginMetadata.mockReturnValue([ + { + manifest: { + id: "migration-b", + contracts: { migrationProviders: ["shared-import"] }, + }, + }, + ] as never); + + expect( + resolvePluginMigrationProvider({ providerId: "shared-import", cfg: cfgB }), + ).toBeUndefined(); + }); + it("lists configured external migration providers alongside active providers", () => { const activeProvider = createMigrationProvider("active-import"); const externalProvider = createMigrationProvider("external-import"); diff --git a/src/plugins/migration-provider-runtime.ts b/src/plugins/migration-provider-runtime.ts index 0d601f2bcf5a..c328031d1188 100644 --- a/src/plugins/migration-provider-runtime.ts +++ b/src/plugins/migration-provider-runtime.ts @@ -7,7 +7,9 @@ import { } from "./bundled-compat.js"; import { listBundledPluginMetadata } from "./bundled-plugin-metadata.js"; import { resolveManifestContractRuntimePluginResolution } from "./manifest-contract-runtime.js"; -import { ensureStandaloneRuntimePluginRegistryLoaded } from "./runtime/standalone-runtime-registry-loader.js"; +import type { PluginRegistry } from "./registry-types.js"; +import { withPluginRuntimeRegistryScope } from "./runtime/gateway-request-scope.js"; +import { loadRuntimePluginRegistryHandle } from "./runtime/standalone-runtime-registry-loader.js"; import type { MigrationProviderPlugin } from "./types.js"; type MigrationProviderPluginResolution = { @@ -15,6 +17,18 @@ type MigrationProviderPluginResolution = { bundledCompatPluginIds: string[]; }; +let standaloneMigrationRegistrySlot: + | { + config: OpenClawConfig | undefined; + pluginIdsKey: string; + registry: PluginRegistry; + } + | undefined; + +function migrationPluginIdsKey(pluginIds: readonly string[]): string { + return JSON.stringify(pluginIds); +} + function findMigrationProviderById( entries: ReadonlyArray<{ provider: MigrationProviderPlugin }>, providerId: string, @@ -22,6 +36,28 @@ function findMigrationProviderById( return entries.find((entry) => entry.provider.id === providerId)?.provider; } +function bindMigrationProviderToRegistry( + provider: MigrationProviderPlugin, + registry: PluginRegistry, +): MigrationProviderPlugin { + return { + ...provider, + ...(provider.detect + ? { + detect: (ctx) => withPluginRuntimeRegistryScope(registry, () => provider.detect!(ctx)), + } + : {}), + ...(provider.prepareApply + ? { + prepareApply: (ctx) => + withPluginRuntimeRegistryScope(registry, () => provider.prepareApply!(ctx)), + } + : {}), + plan: (ctx) => withPluginRuntimeRegistryScope(registry, () => provider.plan(ctx)), + apply: (ctx, plan) => withPluginRuntimeRegistryScope(registry, () => provider.apply(ctx, plan)), + }; +} + function resolveMigrationProviderConfig(params: { cfg?: OpenClawConfig; bundledCompatPluginIds: readonly string[]; @@ -37,10 +73,17 @@ function resolveMigrationProviderConfig(params: { }); } -function resolveMigrationProviderRegistry(params: { pluginIds: string[] }) { - return getLoadedRuntimePluginRegistry({ - requiredPluginIds: params.pluginIds, - }); +function resolveMigrationProviderRegistry(params: { cfg?: OpenClawConfig; pluginIds: string[] }) { + const active = getLoadedRuntimePluginRegistry({ requiredPluginIds: params.pluginIds }); + if (active) { + return active; + } + const standalone = standaloneMigrationRegistrySlot; + return standalone && + standalone.config === params.cfg && + standalone.pluginIdsKey === migrationPluginIdsKey(params.pluginIds) + ? standalone.registry + : undefined; } function resolveMigrationProviderPluginResolution(params: { @@ -104,15 +147,21 @@ export function ensureStandaloneMigrationProviderRegistryLoaded( cfg: params.cfg, bundledCompatPluginIds: resolution.bundledCompatPluginIds, }); - ensureStandaloneRuntimePluginRegistryLoaded({ + const registry = loadRuntimePluginRegistryHandle({ surface: "active", requiredPluginIds: resolution.pluginIds, loadOptions: { ...(compatConfig === undefined ? {} : { config: compatConfig }), onlyPluginIds: resolution.pluginIds, - activate: false, }, }); + standaloneMigrationRegistrySlot = registry + ? { + config: params.cfg, + pluginIdsKey: migrationPluginIdsKey(resolution.pluginIds), + registry, + } + : undefined; } export function resolvePluginMigrationProvider(params: { @@ -137,9 +186,11 @@ export function resolvePluginMigrationProvider(params: { return undefined; } const registry = resolveMigrationProviderRegistry({ + cfg: params.cfg, pluginIds, }); - return findMigrationProviderById(registry?.migrationProviders ?? [], params.providerId); + const provider = findMigrationProviderById(registry?.migrationProviders ?? [], params.providerId); + return provider && registry ? bindMigrationProviderToRegistry(provider, registry) : undefined; } export function resolvePluginMigrationProviders( @@ -155,7 +206,13 @@ export function resolvePluginMigrationProviders( return mergeMigrationProviders(activeProviders, []); } const registry = resolveMigrationProviderRegistry({ + cfg: params.cfg, pluginIds, }); - return mergeMigrationProviders(activeProviders, registry?.migrationProviders ?? []); + const scopedProviders = registry + ? registry.migrationProviders.map(({ provider }) => ({ + provider: bindMigrationProviderToRegistry(provider, registry), + })) + : []; + return mergeMigrationProviders(activeProviders, scopedProviders); } diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index 45ae8968e9f7..ee67378345e0 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -52,6 +52,7 @@ import { resolveUsageHookProviderPluginContracts, } from "./providers.js"; import { getActivePluginRegistryWorkspaceDirFromState } from "./runtime-state.js"; +import { withPluginRuntimeRegistryScope } from "./runtime/gateway-request-scope.js"; import { resolveRuntimeTextTransforms } from "./text-transforms.runtime.js"; import type { ProviderAuthDoctorHintContext, @@ -685,20 +686,31 @@ export async function resolveProviderUsageSnapshotWithPlugin(params: { return undefined; } - let harness = getRegisteredAgentHarness(params.provider)?.harness; + const harness = getRegisteredAgentHarness(params.provider)?.harness; if (!harness) { const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState() ?? process.cwd(); + const { loadAgentRuntimePluginRegistryHandle } = await import("../agents/runtime-plugins.js"); const { ensureSelectedAgentHarnessPlugin } = await import("../agents/harness/runtime-plugin.js"); - await ensureSelectedAgentHarnessPlugin({ - provider: params.context.provider, - modelId: "", + const pluginRegistry = loadAgentRuntimePluginRegistryHandle({ config: params.config, - agentHarnessId: params.provider, workspaceDir, + selections: [{ provider: params.context.provider, modelId: "", runtime: params.provider }], + }); + return await withPluginRuntimeRegistryScope(pluginRegistry, async () => { + await ensureSelectedAgentHarnessPlugin({ + provider: params.context.provider, + modelId: "", + config: params.config, + agentHarnessId: params.provider, + workspaceDir, + pluginRegistry, + }); + return await getRegisteredAgentHarness(params.provider)?.harness.fetchUsageSnapshot?.( + params.context, + ); }); - harness = getRegisteredAgentHarness(params.provider)?.harness; } return await harness?.fetchUsageSnapshot?.(params.context); } diff --git a/src/plugins/provider-runtime.usage-harness.test.ts b/src/plugins/provider-runtime.usage-harness.test.ts index f880a6a75815..88a90b644c5f 100644 --- a/src/plugins/provider-runtime.usage-harness.test.ts +++ b/src/plugins/provider-runtime.usage-harness.test.ts @@ -2,6 +2,21 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { clearAgentHarnesses, registerAgentHarness } from "../agents/harness/registry.js"; import { resolveProviderUsageSnapshotWithPlugin } from "./provider-runtime.js"; +import { createEmptyPluginRegistry } from "./registry-empty.js"; +import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js"; + +const mocks = vi.hoisted(() => ({ + ensureSelectedAgentHarnessPlugin: vi.fn(), + loadAgentRuntimePluginRegistryHandle: vi.fn(), +})); + +vi.mock("../agents/runtime-plugins.js", () => ({ + loadAgentRuntimePluginRegistryHandle: mocks.loadAgentRuntimePluginRegistryHandle, +})); + +vi.mock("../agents/harness/runtime-plugin.js", () => ({ + ensureSelectedAgentHarnessPlugin: mocks.ensureSelectedAgentHarnessPlugin, +})); vi.mock("./provider-hook-runtime.js", async (importOriginal) => { const actual = await importOriginal(); @@ -11,6 +26,50 @@ vi.mock("./provider-hook-runtime.js", async (importOriginal) => { describe("provider runtime harness usage", () => { afterEach(() => { clearAgentHarnesses(); + mocks.ensureSelectedAgentHarnessPlugin.mockReset(); + mocks.loadAgentRuntimePluginRegistryHandle.mockReset(); + }); + + it("keeps a cold-loaded harness usage callback in its registry scope", async () => { + const pluginRegistry = createEmptyPluginRegistry(); + const fetchUsageSnapshot = vi.fn(async () => { + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(pluginRegistry); + return { + provider: "openai" as const, + displayName: "OpenAI", + windows: [{ label: "5h", usedPercent: 9 }], + }; + }); + mocks.loadAgentRuntimePluginRegistryHandle.mockReturnValue(pluginRegistry); + mocks.ensureSelectedAgentHarnessPlugin.mockImplementationOnce(async () => { + registerAgentHarness({ + id: "codex", + label: "Codex", + supports: () => ({ supported: true }), + runAttempt: async () => { + throw new Error("not used"); + }, + fetchUsageSnapshot, + }); + }); + + await expect( + resolveProviderUsageSnapshotWithPlugin({ + provider: "codex", + config: {}, + env: {}, + workspaceDir: process.cwd(), + context: { + config: {}, + env: {}, + provider: "openai", + token: "test-token-placeholder", + timeoutMs: 5_000, + fetchFn: fetch, + }, + }), + ).resolves.toMatchObject({ provider: "openai" }); + expect(fetchUsageSnapshot).toHaveBeenCalledOnce(); }); it("routes a synthetic hook id to the matching harness", async () => { diff --git a/src/plugins/runtime-plugins.runtime.ts b/src/plugins/runtime-plugins.runtime.ts deleted file mode 100644 index ce7ce681984f..000000000000 --- a/src/plugins/runtime-plugins.runtime.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Lazy runtime entrypoint for loading plugin runtime hooks from agent code. */ -export { ensureRuntimePluginsLoaded } from "../agents/runtime-plugins.js"; diff --git a/src/plugins/runtime.ts b/src/plugins/runtime.ts index 8333bdb5fa73..61719d68a1fd 100644 --- a/src/plugins/runtime.ts +++ b/src/plugins/runtime.ts @@ -16,6 +16,7 @@ import { type RegistryState, type RegistrySurfaceState, } from "./runtime-state.js"; +import { getPluginRuntimeGatewayRequestScope } from "./runtime/gateway-request-scope.js"; const log = createSubsystemLogger("plugins/runtime"); @@ -306,6 +307,10 @@ export function requireActivePluginRegistry(): PluginRegistry { if (state.registrationContext) { return state.registrationContext.registry; } + const scopedRegistry = getPluginRuntimeGatewayRequestScope()?.pluginRegistry; + if (scopedRegistry) { + return scopedRegistry; + } if (!state.activeRegistry) { state.activeRegistry = createEmptyPluginRegistry(); markPluginRegistryActive(state.activeRegistry); diff --git a/src/plugins/runtime/gateway-bindings.ts b/src/plugins/runtime/gateway-bindings.ts index f7cca5fce277..7548cac3d930 100644 --- a/src/plugins/runtime/gateway-bindings.ts +++ b/src/plugins/runtime/gateway-bindings.ts @@ -19,6 +19,9 @@ export const gatewaySubagentState = resolveGlobalSingleton }), ); +// PHASE2C: Remove this singleton after session-catalog owns its nodes runtime and prepared/request +// registry handles carry concrete gateway bindings across reload instead of using late proxies. + /** * Set the process-global gateway subagent runtime. * Called during gateway startup so that gateway-bindable plugin runtimes can diff --git a/src/plugins/runtime/gateway-request-scope.test.ts b/src/plugins/runtime/gateway-request-scope.test.ts index cde5bd594120..451a298bb46e 100644 --- a/src/plugins/runtime/gateway-request-scope.test.ts +++ b/src/plugins/runtime/gateway-request-scope.test.ts @@ -1,5 +1,11 @@ // Gateway request scope tests cover request-local plugin runtime context propagation. -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../registry-empty.js"; +import { + requireActivePluginRegistry, + resetPluginRuntimeStateForTest, + setActivePluginRegistry, +} from "../runtime.js"; import type { PluginRuntimeGatewayRequestScope } from "./gateway-request-scope.test-fixtures.js"; const TEST_SCOPE: PluginRuntimeGatewayRequestScope = { @@ -8,6 +14,7 @@ const TEST_SCOPE: PluginRuntimeGatewayRequestScope = { }; describe("gateway request scope", () => { + afterEach(() => resetPluginRuntimeStateForTest()); async function importGatewayRequestScopeModule() { return await import("./gateway-request-scope.js"); } @@ -63,4 +70,18 @@ describe("gateway request scope", () => { it("attaches plugin id to the active scope", async () => { await expectPluginIdScopedGatewayScope("voice-call"); }); + + it("resolves the owned registry while preserving gateway request facts", async () => { + const activeRegistry = createEmptyPluginRegistry(); + const requestRegistry = createEmptyPluginRegistry(); + setActivePluginRegistry(activeRegistry); + + await withTestGatewayScope(async (runtimeScope) => { + await runtimeScope.withPluginRuntimeRegistryScope(requestRegistry, async () => { + expect(requireActivePluginRegistry()).toBe(requestRegistry); + expectGatewayScope(runtimeScope, { ...TEST_SCOPE, pluginRegistry: requestRegistry }); + }); + expect(requireActivePluginRegistry()).toBe(activeRegistry); + }); + }); }); diff --git a/src/plugins/runtime/gateway-request-scope.ts b/src/plugins/runtime/gateway-request-scope.ts index d5ae5cd96a09..53eceaa1b48b 100644 --- a/src/plugins/runtime/gateway-request-scope.ts +++ b/src/plugins/runtime/gateway-request-scope.ts @@ -6,6 +6,7 @@ import type { } from "../../gateway/server-methods/types.js"; import { resolveGlobalSingleton } from "../../shared/global-singleton.js"; import type { PluginOrigin } from "../plugin-origin.types.js"; +import type { PluginRegistry } from "../registry-types.js"; type PluginRuntimeGatewayRequestScope = { context?: GatewayRequestContext; @@ -16,6 +17,7 @@ type PluginRuntimeGatewayRequestScope = { pluginOrigin?: PluginOrigin; pluginTrustedOfficialInstall?: boolean; gatewayMethodDispatchAllowed?: boolean; + pluginRegistry?: PluginRegistry; }; type PluginRuntimePluginScope = { @@ -46,6 +48,21 @@ export function withPluginRuntimeGatewayRequestScope( return pluginRuntimeGatewayRequestScope.run(scope, run); } +/** Runs work against an owned registry handle while preserving any gateway request facts. */ +export function withPluginRuntimeRegistryScope( + registry: PluginRegistry | undefined, + run: () => T, +): T { + if (!registry) { + return run(); + } + const current = pluginRuntimeGatewayRequestScope.getStore(); + return pluginRuntimeGatewayRequestScope.run( + { isWebchatConnect: () => false, ...current, pluginRegistry: registry }, + run, + ); +} + /** * Runs work under the current gateway request scope while attaching plugin identity. */ diff --git a/src/plugins/runtime/metadata-registry-loader.ts b/src/plugins/runtime/metadata-registry-loader.ts index 4562bb9c7941..206e021563ff 100644 --- a/src/plugins/runtime/metadata-registry-loader.ts +++ b/src/plugins/runtime/metadata-registry-loader.ts @@ -1,6 +1,6 @@ // Metadata registry loader builds plugin metadata registries without activating runtime barrels. import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { loadOpenClawPlugins } from "../loader.js"; +import { loadPluginRegistryHandle } from "../loader.js"; import type { PluginManifestRegistry } from "../manifest-registry.js"; import { hasExplicitPluginIdScope } from "../plugin-scope.js"; import type { PluginRegistry } from "../registry.js"; @@ -25,7 +25,7 @@ export function loadPluginMetadataRegistrySnapshot(options?: { }): PluginRegistry { const context = options?.runtimeContext ?? resolvePluginRuntimeLoadContext(options); - return loadOpenClawPlugins( + return loadPluginRegistryHandle( buildPluginRuntimeLoadOptions(context, { ...(options?.config !== undefined ? { config: options.config } : {}), ...(options?.activationSourceConfig !== undefined @@ -36,7 +36,6 @@ export function loadPluginMetadataRegistrySnapshot(options?: { ...(options?.logger !== undefined ? { logger: options.logger } : {}), throwOnLoadError: true, cache: false, - activate: false, mode: "validate", loadModules: options?.loadModules, ...(hasExplicitPluginIdScope(options?.onlyPluginIds) diff --git a/src/plugins/runtime/standalone-runtime-registry-loader.test.ts b/src/plugins/runtime/standalone-runtime-registry-loader.test.ts index fbf7e0b074de..b650d70203af 100644 --- a/src/plugins/runtime/standalone-runtime-registry-loader.test.ts +++ b/src/plugins/runtime/standalone-runtime-registry-loader.test.ts @@ -1,11 +1,6 @@ -// Standalone runtime registry loader tests cover registry loading outside gateway startup. -import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - cleanupPluginLoaderFixturesForTest, - clearPluginLoaderCache, -} from "../loader.test-fixtures.js"; +// Verifies scoped registry handles cannot install process-wide runtime state. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createEmptyPluginRegistry } from "../registry-empty.js"; -import type { PluginRegistry } from "../registry-types.js"; import { getActivePluginChannelRegistry, getActivePluginRegistry, @@ -15,129 +10,117 @@ import { } from "../runtime.js"; const loaderMocks = vi.hoisted(() => ({ - loadOpenClawPlugins: vi.fn(), + loadAndActivateRootPluginRegistry: vi.fn(), + loadPluginRegistryHandle: vi.fn(), })); vi.mock("../loader.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - loadOpenClawPlugins: (...args: Parameters) => - loaderMocks.loadOpenClawPlugins(...args), + loadAndActivateRootPluginRegistry: loaderMocks.loadAndActivateRootPluginRegistry, + loadPluginRegistryHandle: loaderMocks.loadPluginRegistryHandle, }; }); -const { ensureStandaloneRuntimePluginRegistryLoaded } = - await import("./standalone-runtime-registry-loader.js"); - -function createRegistryWithPlugin(pluginId: string): PluginRegistry { - const registry = createEmptyPluginRegistry(); - registry.plugins.push({ - id: pluginId, - status: "loaded", - } as never); - return registry; -} +import { + installRuntimePluginRegistryAtProcessRoot, + loadRuntimePluginRegistryHandle, +} from "./standalone-runtime-registry-loader.js"; beforeEach(() => { - loaderMocks.loadOpenClawPlugins.mockReset(); + loaderMocks.loadAndActivateRootPluginRegistry.mockReset(); + loaderMocks.loadPluginRegistryHandle.mockReset(); }); afterEach(() => { - clearPluginLoaderCache(); resetPluginRuntimeStateForTest(); }); -afterAll(() => { - cleanupPluginLoaderFixturesForTest(); -}); - -describe("ensureStandaloneRuntimePluginRegistryLoaded tool-discovery installs", () => { - it("does not replace active or pinned channel registries during tool discovery", () => { - const activeRegistry = createRegistryWithPlugin("provider-only"); +describe("standalone runtime registry ownership", () => { + it("returns a scoped handle without replacing active or pinned registries", () => { + const activeRegistry = createEmptyPluginRegistry(); + const channelRegistry = createEmptyPluginRegistry(); + const scopedRegistry = createEmptyPluginRegistry(); setActivePluginRegistry(activeRegistry, "active-key", "default", "/tmp/ws"); - const channelRegistry = createRegistryWithPlugin("channel-plugin"); pinActivePluginChannelRegistry(channelRegistry); - const toolRegistry = createRegistryWithPlugin("tool-plugin"); - loaderMocks.loadOpenClawPlugins.mockReturnValue(toolRegistry); + loaderMocks.loadPluginRegistryHandle.mockReturnValue(scopedRegistry); - ensureStandaloneRuntimePluginRegistryLoaded({ - surface: "channel", - forceLoad: true, - loadOptions: { - onlyPluginIds: ["tool-plugin"], - activate: false, - toolDiscovery: true, - workspaceDir: "/tmp/ws", - }, + expect( + loadRuntimePluginRegistryHandle({ + forceLoad: true, + surface: "channel", + loadOptions: { onlyPluginIds: ["tool-plugin"], workspaceDir: "/tmp/ws" }, + }), + ).toBe(scopedRegistry); + + expect(loaderMocks.loadPluginRegistryHandle).toHaveBeenCalledWith({ + activate: false, + cache: false, + onlyPluginIds: ["tool-plugin"], + workspaceDir: "/tmp/ws", }); - expect(getActivePluginRegistry()).toBe(activeRegistry); expect(getActivePluginChannelRegistry()).toBe(channelRegistry); }); - it("does not replace the active registry for a tool-discovery active load", () => { - const activeRegistry = createRegistryWithPlugin("provider-only"); + it("builds an explicit empty scope instead of reusing the active registry", () => { + const activeRegistry = createEmptyPluginRegistry(); + const emptyScopedRegistry = createEmptyPluginRegistry(); setActivePluginRegistry(activeRegistry, "active-key", "default", "/tmp/ws"); - const toolRegistry = createRegistryWithPlugin("tool-plugin"); - loaderMocks.loadOpenClawPlugins.mockReturnValue(toolRegistry); + loaderMocks.loadPluginRegistryHandle.mockReturnValue(emptyScopedRegistry); - const result = ensureStandaloneRuntimePluginRegistryLoaded({ - surface: "active", - forceLoad: true, - installRegistry: true, - loadOptions: { - onlyPluginIds: ["tool-plugin"], - activate: false, - toolDiscovery: true, - workspaceDir: "/tmp/ws", - }, + expect( + loadRuntimePluginRegistryHandle({ + requiredPluginIds: [], + loadOptions: { onlyPluginIds: [], workspaceDir: "/tmp/ws" }, + }), + ).toBe(emptyScopedRegistry); + + expect(loaderMocks.loadPluginRegistryHandle).toHaveBeenCalledWith({ + activate: false, + onlyPluginIds: [], + workspaceDir: "/tmp/ws", }); - - expect(result).toBe(toolRegistry); expect(getActivePluginRegistry()).toBe(activeRegistry); }); - it("still installs a non-tool-discovery active load (migration provider path)", () => { - const activeRegistry = createRegistryWithPlugin("provider-only"); - setActivePluginRegistry(activeRegistry, "active-key", "default", "/tmp/ws"); - const migrationRegistry = createRegistryWithPlugin("migration-plugin"); - loaderMocks.loadOpenClawPlugins.mockReturnValue(migrationRegistry); + it("uses the activating loader only at the process-root entry point", () => { + const rootRegistry = createEmptyPluginRegistry(); + loaderMocks.loadAndActivateRootPluginRegistry.mockReturnValue(rootRegistry); - ensureStandaloneRuntimePluginRegistryLoaded({ - surface: "active", - forceLoad: true, - installRegistry: true, - loadOptions: { - onlyPluginIds: ["migration-plugin"], - activate: false, - workspaceDir: "/tmp/ws", - }, + expect( + installRuntimePluginRegistryAtProcessRoot({ + forceLoad: true, + loadOptions: { + onlyPluginIds: ["gateway-plugin"], + workspaceDir: "/tmp/ws", + runtimeOptions: { allowGatewaySubagentBinding: true }, + }, + }), + ).toBe(rootRegistry); + + expect(loaderMocks.loadAndActivateRootPluginRegistry).toHaveBeenCalledWith({ + activate: true, + cache: false, + onlyPluginIds: ["gateway-plugin"], + workspaceDir: "/tmp/ws", + runtimeOptions: { allowGatewaySubagentBinding: true }, }); - - // Without toolDiscovery the load must still become the active registry, since the migration - // provider resolver reads migrationProviders off the active registry. - expect(getActivePluginRegistry()).toBe(migrationRegistry); + expect(loaderMocks.loadPluginRegistryHandle).not.toHaveBeenCalled(); }); - it("keeps runtime surfaces empty for a cold tool-discovery load", () => { - // Establish the cold-start precondition deterministically (no active registry). - resetPluginRuntimeStateForTest(); - const toolRegistry = createRegistryWithPlugin("tool-plugin"); - loaderMocks.loadOpenClawPlugins.mockReturnValue(toolRegistry); + it("pins an explicitly installed channel surface", () => { + const rootRegistry = createEmptyPluginRegistry(); + loaderMocks.loadAndActivateRootPluginRegistry.mockReturnValue(rootRegistry); - const result = ensureStandaloneRuntimePluginRegistryLoaded({ - surface: "channel", + installRuntimePluginRegistryAtProcessRoot({ forceLoad: true, - loadOptions: { - onlyPluginIds: ["tool-plugin"], - activate: false, - toolDiscovery: true, - workspaceDir: "/tmp/ws", - }, + surface: "channel", + loadOptions: { workspaceDir: "/tmp/ws" }, }); - expect(result).toBe(toolRegistry); - expect(getActivePluginRegistry()).toBeNull(); + expect(getActivePluginRegistry()).toBe(rootRegistry); + expect(getActivePluginChannelRegistry()).toBe(rootRegistry); }); }); diff --git a/src/plugins/runtime/standalone-runtime-registry-loader.ts b/src/plugins/runtime/standalone-runtime-registry-loader.ts index edf431004c2e..71a4e1483c82 100644 --- a/src/plugins/runtime/standalone-runtime-registry-loader.ts +++ b/src/plugins/runtime/standalone-runtime-registry-loader.ts @@ -1,10 +1,11 @@ -// Standalone runtime registry loader builds plugin runtime registries outside gateway startup. +// Runtime registry loader entry points distinguish process-root installation from scoped handles. import { type ActiveRuntimePluginRegistrySurface, getLoadedRuntimePluginRegistry, } from "../active-runtime-registry.js"; import { - loadOpenClawPlugins, + loadAndActivateRootPluginRegistry, + loadPluginRegistryHandle, resolvePluginRegistryLoadCacheKey, type PluginLoadOptions, } from "../loader.js"; @@ -27,7 +28,7 @@ function resolveRuntimeSubagentMode( return "default"; } -function installStandaloneRuntimePluginRegistry( +function installProcessRootRuntimePluginRegistry( registry: PluginRegistry, params: { loadOptions: PluginLoadOptions; @@ -49,13 +50,19 @@ function installStandaloneRuntimePluginRegistry( } } -export function ensureStandaloneRuntimePluginRegistryLoaded(params: { +type RuntimePluginRegistryLoadParams = { loadOptions: PluginLoadOptions; forceLoad?: boolean; - installRegistry?: boolean; requiredPluginIds?: readonly string[]; surface?: ActiveRuntimePluginRegistrySurface; -}): PluginRegistry | undefined { +}; + +function findLoadedRuntimePluginRegistry( + params: RuntimePluginRegistryLoadParams, +): PluginRegistry | undefined { + if (params.loadOptions.onlyPluginIds?.length === 0) { + return undefined; + } const requiredPluginIds = params.requiredPluginIds ?? params.loadOptions.onlyPluginIds; const surface = params.surface ?? "active"; if (!params.forceLoad) { @@ -71,36 +78,36 @@ export function ensureStandaloneRuntimePluginRegistryLoaded(params: { } } - const effectiveLoadOptions = params.forceLoad - ? { ...params.loadOptions, cache: false } - : params.loadOptions; - const registry = loadOpenClawPlugins(effectiveLoadOptions); - if (params.loadOptions.activate !== false) { - switch (surface) { - case "active": - break; - case "channel": - pinActivePluginChannelRegistry(registry); - break; - case "http-route": - pinActivePluginHttpRouteRegistry(registry); - break; - } + return undefined; +} + +/** Builds or reuses a registry value without changing any process-wide active surface. */ +export function loadRuntimePluginRegistryHandle( + params: RuntimePluginRegistryLoadParams, +): PluginRegistry | undefined { + const loadOptions = { ...params.loadOptions, activate: false }; + return ( + findLoadedRuntimePluginRegistry({ ...params, loadOptions }) ?? + loadPluginRegistryHandle(params.forceLoad ? { ...loadOptions, cache: false } : loadOptions) + ); +} + +/** Installs a registry from a process composition root. Never call from request/run scope. */ +export function installRuntimePluginRegistryAtProcessRoot( + params: RuntimePluginRegistryLoadParams, +): PluginRegistry | undefined { + const loadOptions = { ...params.loadOptions, activate: true }; + const registry = + findLoadedRuntimePluginRegistry({ ...params, loadOptions }) ?? + loadAndActivateRootPluginRegistry( + params.forceLoad ? { ...loadOptions, cache: false } : loadOptions, + ); + const surface = params.surface ?? "active"; + if (surface === "active") { return registry; } - - if (params.installRegistry === false) { - return registry; - } - - // Tool discovery returns a request-local snapshot. Installing it would replace live provider, - // channel, or HTTP-route registries with a registry that intentionally omits those surfaces. - if (params.loadOptions.toolDiscovery === true) { - return registry; - } - - installStandaloneRuntimePluginRegistry(registry, { - loadOptions: params.loadOptions, + installProcessRootRuntimePluginRegistry(registry, { + loadOptions, surface, }); return registry; diff --git a/src/plugins/status.ts b/src/plugins/status.ts index 1f00f7de4908..73c3e82ad4ea 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -20,7 +20,7 @@ import { type PluginCapabilityEntry, type PluginInspectShape, } from "./inspect-shape.js"; -import { loadOpenClawPlugins, resolveCompatibleRuntimePluginRegistry } from "./loader.js"; +import { loadPluginRegistryHandle, resolveCompatibleRuntimePluginRegistry } from "./loader.js"; import type { PluginDiagnostic } from "./manifest-types.js"; import { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.js"; import { loadPluginMetadataSnapshot } from "./plugin-metadata-snapshot.js"; @@ -269,14 +269,13 @@ function buildPluginReport( ? tracePluginLifecyclePhase( "runtime plugin registry load", () => - loadOpenClawPlugins( + loadPluginRegistryHandle( buildPluginRuntimeLoadOptions(context, { config: runtimeCompatConfig, activationSourceConfig: rawConfig, workspaceDir, env: params?.env, loadModules, - activate: false, cache: false, onlyPluginIds, }), diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 33bdd79dffdb..f9690c677f6e 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -24,6 +24,7 @@ const applyPluginAutoEnableMock = vi.fn(); vi.mock("./loader.js", () => ({ loadOpenClawPlugins: (params: unknown) => loadOpenClawPluginsMock(params), + loadPluginRegistryHandle: (params: unknown) => loadOpenClawPluginsMock(params), resolveCompatibleRuntimePluginRegistry: (params: unknown) => resolveRuntimePluginRegistryMock(params), resolvePluginRegistryLoadCacheKey: (params: unknown) => JSON.stringify(params), @@ -3196,7 +3197,7 @@ describe("resolvePluginTools optional tools", () => { expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); - it("loads a standalone registry when cached runtime registries lack matching tool entries", () => { + it("keeps a cold-loaded standalone registry scoped through tool callbacks", async () => { const config = { plugins: { enabled: true, @@ -3214,7 +3215,17 @@ describe("resolvePluginTools optional tools", () => { enabledByDefault: false, }), }); - const memorySearchFactory = vi.fn(() => [makeTool("memory_search"), makeTool("memory_get")]); + const memorySearchFactory = vi.fn(() => { + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(loadedRegistry); + return ["memory_search", "memory_get"].map((name) => { + const tool = makeTool(name); + tool.execute = async () => { + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(loadedRegistry); + return { content: [{ type: "text", text: "ok" }] }; + }; + return tool; + }); + }); const loadedRegistry = { plugins: [{ id: "memory-core", status: "loaded" }], tools: [ @@ -3257,6 +3268,10 @@ describe("resolvePluginTools optional tools", () => { expectResolvedToolNames(tools, ["memory_search", "memory_get"]); expect(memorySearchFactory).toHaveBeenCalledTimes(1); + await expect(tools[0]?.execute("call", {}, undefined)).resolves.toEqual({ + content: [{ type: "text", text: "ok" }], + }); + expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1); const loaderParams = mockCallParams(loadOpenClawPluginsMock) as { activate?: unknown; onlyPluginIds?: unknown; diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index fc200f8fbf1a..e27bcf213216 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -26,12 +26,15 @@ import { hasManifestToolAvailability } from "./manifest-tool-availability.js"; import type { PluginMetadataManifestView } from "./plugin-metadata-snapshot.types.js"; import type { PluginRegistry, PluginToolRegistration } from "./registry-types.js"; import { getPluginRegistryState } from "./runtime-state.js"; -import { withPluginRuntimePluginScope } from "./runtime/gateway-request-scope.js"; +import { + withPluginRuntimePluginScope, + withPluginRuntimeRegistryScope, +} from "./runtime/gateway-request-scope.js"; import { buildPluginRuntimeLoadOptions, resolvePluginRuntimeLoadContext, } from "./runtime/load-context.js"; -import { ensureStandaloneRuntimePluginRegistryLoaded } from "./runtime/standalone-runtime-registry-loader.js"; +import { loadRuntimePluginRegistryHandle } from "./runtime/standalone-runtime-registry-loader.js"; import { findUndeclaredPluginToolNames } from "./tool-contracts.js"; import { buildPluginToolDescriptorCacheKey, @@ -89,6 +92,8 @@ const PLUGIN_TOOL_FACTORY_SUMMARY_LIMIT = 20; const pluginToolMeta = new WeakMap(); const scopedPluginTools = new WeakMap>(); +const pluginRegistryScopeIds = new WeakMap(); +let nextPluginRegistryScopeId = 1; /** Attaches plugin ownership metadata to a concrete agent tool instance. */ export function setPluginToolMeta(tool: AnyAgentTool, meta: PluginToolMeta): void { @@ -108,17 +113,31 @@ export function copyPluginToolMeta(source: AnyAgentTool, target: AnyAgentTool): } } -function pluginToolScopeKey(entry: PluginToolRegistration): string { - return JSON.stringify([entry.pluginId, entry.source]); +function pluginToolScopeKey( + entry: PluginToolRegistration, + pluginRegistry: PluginRegistry | undefined, +): string { + let registryScopeId = 0; + if (pluginRegistry) { + registryScopeId = pluginRegistryScopeIds.get(pluginRegistry) ?? nextPluginRegistryScopeId++; + pluginRegistryScopeIds.set(pluginRegistry, registryScopeId); + } + return JSON.stringify([entry.pluginId, entry.source, registryScopeId]); } -function runWithPluginToolScope(entry: PluginToolRegistration, run: () => T): T { - return withPluginRuntimePluginScope( - { - pluginId: entry.pluginId, - ...(entry.source ? { pluginSource: entry.source } : {}), - }, - run, +function runWithPluginToolScope( + entry: PluginToolRegistration, + pluginRegistry: PluginRegistry | undefined, + run: () => T, +): T { + return withPluginRuntimeRegistryScope(pluginRegistry, () => + withPluginRuntimePluginScope( + { + pluginId: entry.pluginId, + ...(entry.source ? { pluginSource: entry.source } : {}), + }, + run, + ), ); } @@ -131,8 +150,12 @@ function isAgentTool(value: unknown): value is AnyAgentTool { ); } -function wrapPluginToolCallbacks(entry: PluginToolRegistration, tool: AnyAgentTool): AnyAgentTool { - const key = pluginToolScopeKey(entry); +function wrapPluginToolCallbacks( + entry: PluginToolRegistration, + pluginRegistry: PluginRegistry | undefined, + tool: AnyAgentTool, +): AnyAgentTool { + const key = pluginToolScopeKey(entry, pluginRegistry); const scopedByKey = scopedPluginTools.get(tool); const cached = scopedByKey?.get(key); if (cached) { @@ -142,7 +165,9 @@ function wrapPluginToolCallbacks(entry: PluginToolRegistration, tool: AnyAgentTo const prepareArguments = tool.prepareArguments; const scopedPrepareArguments = prepareArguments ? (args: unknown) => - runWithPluginToolScope(entry, () => Reflect.apply(prepareArguments, tool, [args])) + runWithPluginToolScope(entry, pluginRegistry, () => + Reflect.apply(prepareArguments, tool, [args]), + ) : undefined; const scopedExecute = ( toolCallId: string, @@ -152,6 +177,7 @@ function wrapPluginToolCallbacks(entry: PluginToolRegistration, tool: AnyAgentTo ) => runWithPluginToolScope( entry, + pluginRegistry, () => Reflect.apply(tool.execute, tool, [toolCallId, params, signal, onUpdate]) as ReturnType< AnyAgentTool["execute"] @@ -197,17 +223,24 @@ function wrapPluginToolCallbacks(entry: PluginToolRegistration, tool: AnyAgentTo function wrapPluginToolFactoryResult( entry: PluginToolRegistration, + pluginRegistry: PluginRegistry | undefined, result: PluginToolFactoryResult, ): PluginToolFactoryResult { if (Array.isArray(result)) { - return result.map((tool) => (isAgentTool(tool) ? wrapPluginToolCallbacks(entry, tool) : tool)); + return result.map((tool) => + isAgentTool(tool) ? wrapPluginToolCallbacks(entry, pluginRegistry, tool) : tool, + ); } - return isAgentTool(result) ? wrapPluginToolCallbacks(entry, result) : result; + return isAgentTool(result) ? wrapPluginToolCallbacks(entry, pluginRegistry, result) : result; } -function resolvePluginToolFactory(entry: PluginToolRegistration, ctx: OpenClawPluginToolContext) { - return runWithPluginToolScope(entry, () => - wrapPluginToolFactoryResult(entry, entry.factory(ctx)), +function resolvePluginToolFactory( + entry: PluginToolRegistration, + pluginRegistry: PluginRegistry | undefined, + ctx: OpenClawPluginToolContext, +) { + return runWithPluginToolScope(entry, pluginRegistry, () => + wrapPluginToolFactoryResult(entry, pluginRegistry, entry.factory(ctx)), ); } @@ -459,6 +492,7 @@ function createPluginToolFactoryTiming(params: { function resolvePluginToolFactoryEntry(params: { entry: PluginToolRegistration; + pluginRegistry: PluginRegistry | undefined; ctx: OpenClawPluginToolContext; declaredNames: string[]; factoryTimingStartedAt: number; @@ -473,7 +507,7 @@ function resolvePluginToolFactoryEntry(params: { const factoryStartedAt = Date.now(); try { - resolved = resolvePluginToolFactory(params.entry, params.ctx); + resolved = resolvePluginToolFactory(params.entry, params.pluginRegistry, params.ctx); } catch (err) { failed = true; params.logError(`plugin tool failed (${params.entry.pluginId}): ${String(err)}`); @@ -826,7 +860,7 @@ function createCachedDescriptorPluginTool(params: { ) { return undefined; } - const resolved = resolvePluginToolFactory(candidate, params.ctx); + const resolved = resolvePluginToolFactory(candidate, registry, params.ctx); const listRaw: unknown[] = Array.isArray(resolved) ? resolved : resolved ? [resolved] : []; for (const toolRaw of listRaw) { const malformedReason = describeMalformedPluginTool(toolRaw); @@ -1140,10 +1174,9 @@ function resolvePluginToolRegistry(params: { params.loadOptions.activate === false && params.loadOptions.toolDiscovery === true && params.onRetainRegistry !== undefined; - const standaloneRegistry = ensureStandaloneRuntimePluginRegistryLoaded({ + const standaloneRegistry = loadRuntimePluginRegistryHandle({ surface: "active", forceLoad: forceStandaloneLoad, - installRegistry: !forceStandaloneLoad, requiredPluginIds, loadOptions: requestedPluginIds === undefined @@ -1281,7 +1314,7 @@ export function ensureStandalonePluginToolRegistryLoaded(params: { if (!loadState) { return undefined; } - const registry = ensureStandaloneRuntimePluginRegistryLoaded({ + const registry = loadRuntimePluginRegistryHandle({ surface: "channel", requiredPluginIds: loadState.onlyPluginIds, loadOptions: loadState.loadOptions, @@ -1380,7 +1413,7 @@ export function resolvePluginTools(params: { // are not pinned to any active channel/surface registry until explicitly loaded. // Trigger a standalone load so their tool factories become available, then retry. try { - ensureStandaloneRuntimePluginRegistryLoaded({ + registry = loadRuntimePluginRegistryHandle({ surface: "channel", requiredPluginIds: runtimePluginIds, loadOptions, @@ -1393,10 +1426,7 @@ export function resolvePluginTools(params: { ); throw error; } - registry = resolvePluginToolRegistry({ - loadOptions, - onlyPluginIds: runtimePluginIds, - }); + registry ??= resolvePluginToolRegistry({ loadOptions, onlyPluginIds: runtimePluginIds }); if (!registry) { context.logger.warn( `plugin tool registry still unavailable after cold load for plugin ids [${runtimePluginIds.join( @@ -1501,6 +1531,7 @@ export function resolvePluginTools(params: { } const factoryResult = resolvePluginToolFactoryEntry({ entry, + pluginRegistry: registry, ctx: params.context, declaredNames, factoryTimingStartedAt, diff --git a/src/skills/workshop/experience-review.live.test.ts b/src/skills/workshop/experience-review.live.test.ts index eaf11eb6ce05..10a04906e667 100644 --- a/src/skills/workshop/experience-review.live.test.ts +++ b/src/skills/workshop/experience-review.live.test.ts @@ -94,8 +94,12 @@ describeLive("skill experience review live OpenAI eval", () => { // Warm the plugin runtime outside the review lane: the first load compiles // extensions synchronously and can exceed the lane's no-progress watchdog // on a loaded machine. - const { ensureRuntimePluginsLoaded } = await import("../../agents/runtime-plugins.js"); - ensureRuntimePluginsLoaded({ config: candidate("warmup", []).config ?? {}, workspaceDir }); + const { installAgentRuntimePluginRegistryAtProcessRoot } = + await import("../../agents/runtime-plugins.js"); + installAgentRuntimePluginRegistryAtProcessRoot({ + config: candidate("warmup", []).config ?? {}, + workspaceDir, + }); }, 600_000); afterAll(async () => { diff --git a/src/system-agent/revalidate-inference-owner.test.ts b/src/system-agent/revalidate-inference-owner.test.ts index 33de849e475a..cfbd6a5ba847 100644 --- a/src/system-agent/revalidate-inference-owner.test.ts +++ b/src/system-agent/revalidate-inference-owner.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../plugins/registry-empty.js"; import type { SystemAgentConfiguredRoute } from "./inference-route.js"; import { revalidateSetupInferenceOwner } from "./revalidate-inference-owner.js"; import type { SystemAgentVerifiedInferenceBinding } from "./verified-inference.js"; +const mocks = vi.hoisted(() => ({ loadAgentRuntimePluginRegistryHandle: vi.fn() })); +vi.mock("../agents/runtime-plugins.js", () => ({ + loadAgentRuntimePluginRegistryHandle: mocks.loadAgentRuntimePluginRegistryHandle, +})); + function embeddedRoute(agentHarnessRuntimeOverride: string): SystemAgentConfiguredRoute { return { runner: "embedded", @@ -23,11 +29,13 @@ function embeddedRoute(agentHarnessRuntimeOverride: string): SystemAgentConfigur } describe("revalidateSetupInferenceOwner", () => { - it("reloads a staged plugin harness before validating its runtime artifact", async () => { + it("validates a staged owner inside its registry handle", async () => { const order: string[] = []; const binding = {} as SystemAgentVerifiedInferenceBinding; - const ensureSelectedAgentHarnessPlugin = vi.fn(async () => { - order.push("ensure"); + const pluginRegistry = createEmptyPluginRegistry(); + mocks.loadAgentRuntimePluginRegistryHandle.mockImplementationOnce(() => { + order.push("load"); + return pluginRegistry; }); const createSystemAgentVerifiedInferenceBinding = vi.fn(async () => { order.push("validate"); @@ -43,38 +51,35 @@ describe("revalidateSetupInferenceOwner", () => { runtimeOwnerKind: "plugin-harness", }, deps: { - ensureSelectedAgentHarnessPlugin, createSystemAgentVerifiedInferenceBinding, }, }), ).resolves.toBe(binding); - expect(order).toEqual(["ensure", "validate"]); - expect(ensureSelectedAgentHarnessPlugin).toHaveBeenCalledWith({ - provider: "openai", - modelId: "gpt-5.6-sol", + expect(order).toEqual(["load", "validate"]); + expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledWith({ config: route.runConfig, - agentId: "main", - agentHarnessId: "codex", workspaceDir: "/tmp/openclaw-workspace", + selections: [ + { provider: "openai", modelId: "gpt-5.6-sol", runtime: "codex", agentId: "main" }, + ], }); }); it("does not reload the built-in OpenClaw harness", async () => { - const ensureSelectedAgentHarnessPlugin = vi.fn(async () => {}); const binding = {} as SystemAgentVerifiedInferenceBinding; + mocks.loadAgentRuntimePluginRegistryHandle.mockClear(); await expect( revalidateSetupInferenceOwner({ route: embeddedRoute("auto"), auth: { agentHarnessId: "openclaw", authFingerprint: "auth" }, deps: { - ensureSelectedAgentHarnessPlugin, createSystemAgentVerifiedInferenceBinding: vi.fn(async () => binding), }, }), ).resolves.toBe(binding); - expect(ensureSelectedAgentHarnessPlugin).not.toHaveBeenCalled(); + expect(mocks.loadAgentRuntimePluginRegistryHandle).not.toHaveBeenCalled(); }); }); diff --git a/src/system-agent/revalidate-inference-owner.ts b/src/system-agent/revalidate-inference-owner.ts index 4947bdf33367..a01f39d646e4 100644 --- a/src/system-agent/revalidate-inference-owner.ts +++ b/src/system-agent/revalidate-inference-owner.ts @@ -1,7 +1,9 @@ // Rebuilds an exact verified inference owner after a successful live probe. import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import type { AgentExecutionAuthBinding } from "../agents/execution-auth-binding.js"; -import type { ensureSelectedAgentHarnessPlugin } from "../agents/harness/runtime-plugin.js"; +import { loadAgentRuntimePluginRegistryHandle } from "../agents/runtime-plugins.js"; +import type { PluginRegistry } from "../plugins/registry-types.js"; +import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js"; import type { SystemAgentConfiguredRoute } from "./inference-route.js"; import { createSystemAgentVerifiedInferenceBinding, @@ -11,7 +13,6 @@ import { type RevalidationDeps = SystemAgentVerifiedInferenceDeps & { createSystemAgentVerifiedInferenceBinding?: typeof createSystemAgentVerifiedInferenceBinding; - ensureSelectedAgentHarnessPlugin?: typeof ensureSelectedAgentHarnessPlugin; }; export async function revalidateSetupInferenceOwner(params: { @@ -26,36 +27,42 @@ export async function revalidateSetupInferenceOwner(params: { const successfulHarnessId = params.auth.agentHarnessId?.trim() || (configuredHarnessId && configuredHarnessId !== "auto" ? configuredHarnessId : undefined); + let pluginRegistry: PluginRegistry | undefined; if ( params.route.runner === "embedded" && successfulHarnessId && successfulHarnessId !== "openclaw" ) { - // Another gateway run can replace the process-global plugin registry while - // setup probes. Reload the staged harness before validating its exact artifact. - const ensureHarness = - params.deps.ensureSelectedAgentHarnessPlugin ?? - (await import("../agents/harness/runtime-plugin.js")).ensureSelectedAgentHarnessPlugin; - await ensureHarness({ - provider: params.route.provider, - modelId: params.route.model, + const workspaceDir = resolveAgentWorkspaceDir( + params.route.runConfig, + params.route.agentId, + process.env, + ); + pluginRegistry = loadAgentRuntimePluginRegistryHandle({ config: params.route.runConfig, - agentId: params.route.agentId, - agentHarnessId: successfulHarnessId, - workspaceDir: resolveAgentWorkspaceDir( - params.route.runConfig, - params.route.agentId, - process.env, - ), + workspaceDir, + selections: [ + { + provider: params.route.provider, + modelId: params.route.model, + runtime: successfulHarnessId, + agentId: params.route.agentId, + }, + ], }); + if (!pluginRegistry) { + throw new Error(`Could not load the ${successfulHarnessId} runtime plugin.`); + } } const createBinding = params.deps.createSystemAgentVerifiedInferenceBinding ?? createSystemAgentVerifiedInferenceBinding; - return await createBinding({ - configuredRoute: params.route, - executionRoute: params.route, - auth: params.auth, - deps: params.deps, - }); + return await withPluginRuntimeRegistryScope(pluginRegistry, () => + createBinding({ + configuredRoute: params.route, + executionRoute: params.route, + auth: params.auth, + deps: params.deps, + }), + ); } diff --git a/src/system-agent/setup-inference-activate.ts b/src/system-agent/setup-inference-activate.ts index 7b8f7ef3583e..44ac661c8c9f 100644 --- a/src/system-agent/setup-inference-activate.ts +++ b/src/system-agent/setup-inference-activate.ts @@ -6,6 +6,7 @@ import { type CodexCliApiKeyCredential, readCodexCliActiveApiKey, } from "../agents/cli-credentials.js"; +import { loadAgentRuntimePluginRegistryHandle } from "../agents/runtime-plugins.js"; import { applyAutoLocalModelLean } from "../config/local-model-lean-auto.js"; import { createMergePatch } from "../config/merge-patch.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -281,18 +282,22 @@ async function activateSetupInferenceUnredacted( traceCommand: "openclaw-setup-probe", logger: { warn: (message) => (registryRefreshWarning = message) }, }); - const ensureHarnessPlugin = - deps.ensureSelectedAgentHarnessPlugin ?? - (await import("../agents/harness/runtime-plugin.js")).ensureSelectedAgentHarnessPlugin; try { - await ensureHarnessPlugin({ - provider: testPlan.provider, - modelId: testPlan.model, + const pluginRegistry = loadAgentRuntimePluginRegistryHandle({ config: testPlan.config, - agentId: testPlan.routeAgentId, - agentHarnessRuntimeOverride: "codex", workspaceDir: tempDir, + selections: [ + { + provider: testPlan.provider, + modelId: testPlan.model, + runtime: "codex", + agentId: testPlan.routeAgentId, + }, + ], }); + if (!pluginRegistry) { + throw new Error("The Codex runtime plugin registry is unavailable."); + } } catch (error) { const loadError = `Could not load the Codex runtime plugin: ${formatErrorMessage(error)}`; return { diff --git a/src/system-agent/setup-inference-core.ts b/src/system-agent/setup-inference-core.ts index 30dccb8b1fa7..a6453c4cb932 100644 --- a/src/system-agent/setup-inference-core.ts +++ b/src/system-agent/setup-inference-core.ts @@ -244,7 +244,6 @@ export type ActivateSetupInferenceDeps = { runEmbeddedAgent?: SetupInferenceRunEmbeddedAgent; runCliAgent?: typeof import("../agents/cli-runner.js").runCliAgent; ensureCodexRuntimePlugin?: typeof import("../commands/codex-runtime-plugin-install.js").ensureCodexRuntimePluginForModelSelection; - ensureSelectedAgentHarnessPlugin?: typeof import("../agents/harness/runtime-plugin.js").ensureSelectedAgentHarnessPlugin; transformConfigWithPendingPluginInstalls?: typeof import("../plugins/install-record-commit.js").transformConfigWithPendingPluginInstalls; refreshPluginRegistryAfterConfigMutation?: typeof import("../plugins/registry-refresh.js").refreshPluginRegistryAfterConfigMutation; ensurePluginRegistryLoaded?: typeof import("../plugins/runtime/runtime-registry-loader.js").ensurePluginRegistryLoaded; diff --git a/src/system-agent/setup-inference.test.ts b/src/system-agent/setup-inference.test.ts index 13e231b2d675..4d6e492dbefb 100644 --- a/src/system-agent/setup-inference.test.ts +++ b/src/system-agent/setup-inference.test.ts @@ -59,7 +59,7 @@ import { const mocks = vi.hoisted(() => ({ appendAudit: vi.fn(), - ensureSelectedAgentHarnessPlugin: vi.fn(), + loadAgentRuntimePluginRegistryHandle: vi.fn(), refreshPluginRegistryAfterConfigMutation: vi.fn(), })); @@ -67,9 +67,8 @@ vi.mock("./audit.js", () => ({ appendSystemAgentAuditEntry: mocks.appendAudit, })); -vi.mock("../agents/harness/runtime-plugin.js", async (importOriginal) => ({ - ...(await importOriginal()), - ensureSelectedAgentHarnessPlugin: mocks.ensureSelectedAgentHarnessPlugin, +vi.mock("../agents/runtime-plugins.js", () => ({ + loadAgentRuntimePluginRegistryHandle: mocks.loadAgentRuntimePluginRegistryHandle, })); vi.mock("../plugins/registry-refresh.js", () => ({ @@ -1182,7 +1181,9 @@ describe("activateSetupInference", () => { beforeEach(() => { mocks.appendAudit.mockReset(); - mocks.ensureSelectedAgentHarnessPlugin.mockReset().mockResolvedValue(undefined); + mocks.loadAgentRuntimePluginRegistryHandle + .mockReset() + .mockReturnValue(createEmptyPluginRegistry()); mocks.refreshPluginRegistryAfterConfigMutation.mockReset().mockResolvedValue(undefined); }); @@ -3844,11 +3845,15 @@ describe("activateSetupInference", () => { agentId: "ops", }), ); - expect(mocks.ensureSelectedAgentHarnessPlugin).toHaveBeenCalledWith( + expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledWith( expect.objectContaining({ - provider: "openai", - modelId: "gpt-5.6-sol", - agentHarnessRuntimeOverride: "codex", + selections: [ + expect.objectContaining({ + provider: "openai", + modelId: "gpt-5.6-sol", + runtime: "codex", + }), + ], }), ); expect(refreshPluginRegistry).toHaveBeenCalledWith( @@ -4007,7 +4012,6 @@ describe("activateSetupInference", () => { installed: true, status: "installed" as const, })); - const ensureSelectedAgentHarnessPlugin = vi.fn(async () => {}); const refreshPluginRegistryAfterConfigMutation = vi.fn( async (params: { logger?: { warn?: (message: string) => void } }) => { params.logger?.warn?.("best-effort refresh warning"); @@ -4015,7 +4019,7 @@ describe("activateSetupInference", () => { ); const runEmbeddedAgent = vi.fn(async (params: SuccessfulRunParams) => { expect(refreshPluginRegistryAfterConfigMutation).toHaveBeenCalledOnce(); - expect(ensureSelectedAgentHarnessPlugin).toHaveBeenCalledOnce(); + expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledOnce(); return successfulRun("openai", "gpt-5.4", params); }); const result = await activateCodexSetup({ @@ -4024,7 +4028,6 @@ describe("activateSetupInference", () => { deps: { readConfigFileSnapshot: mockConfigSnapshot(initialConfig, { includeMetadata: true }), ensureCodexRuntimePlugin: ensureCodex as never, - ensureSelectedAgentHarnessPlugin: ensureSelectedAgentHarnessPlugin as never, refreshPluginRegistryAfterConfigMutation: refreshPluginRegistryAfterConfigMutation as never, runEmbeddedAgent: runEmbeddedAgent as never, transformConfigWithPendingPluginInstalls: configHarness.transform as never, @@ -4033,11 +4036,11 @@ describe("activateSetupInference", () => { expect(result).toMatchObject({ ok: true, modelRef: "openai/gpt-5.4" }); expect(ensureCodex).toHaveBeenCalledWith(expect.objectContaining({ model: "openai/gpt-5.4" })); - expect(ensureSelectedAgentHarnessPlugin).toHaveBeenCalledWith( + expect(mocks.loadAgentRuntimePluginRegistryHandle).toHaveBeenCalledWith( expect.objectContaining({ - provider: "openai", - modelId: "gpt-5.4", - agentHarnessRuntimeOverride: "codex", + selections: [ + expect.objectContaining({ provider: "openai", modelId: "gpt-5.4", runtime: "codex" }), + ], }), ); expect(refreshPluginRegistryAfterConfigMutation).toHaveBeenCalledWith( diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts index 8b589e852ccf..4fd9da3baa86 100644 --- a/src/tui/embedded-backend.test.ts +++ b/src/tui/embedded-backend.test.ts @@ -26,7 +26,8 @@ const clearSessionGoalMock = vi.fn(); const getSessionGoalMock = vi.fn(); const updateSessionGoalObjectiveMock = vi.fn(); const updateSessionGoalStatusMock = vi.fn(); -const ensureRuntimePluginsLoadedMock = vi.fn(); +const loadAgentRuntimePluginRegistryHandleMock = vi.fn(); +const withPluginRuntimeRegistryScopeMock = vi.fn((_registry: unknown, run: () => unknown) => run()); const ensureContextWindowCacheLoadedMock = vi.fn(async () => undefined); const runSessionStartupMigrationMock = vi.fn<() => Promise>(async () => undefined); const createGatewaySessionMock = vi.fn(); @@ -141,7 +142,13 @@ vi.mock("../agents/agent-scope.js", () => ({ })); vi.mock("../agents/runtime-plugins.js", () => ({ - ensureRuntimePluginsLoaded: (...args: unknown[]) => ensureRuntimePluginsLoadedMock(...args), + loadAgentRuntimePluginRegistryHandle: (...args: unknown[]) => + loadAgentRuntimePluginRegistryHandleMock(...args), +})); + +vi.mock("../plugins/runtime/gateway-request-scope.js", () => ({ + withPluginRuntimeRegistryScope: (...args: [unknown, () => unknown]) => + withPluginRuntimeRegistryScopeMock(...args), })); vi.mock("../agents/context.js", () => ({ @@ -313,7 +320,8 @@ describe("EmbeddedTuiBackend", () => { status, tokensUsed: 0, })); - ensureRuntimePluginsLoadedMock.mockReset(); + loadAgentRuntimePluginRegistryHandleMock.mockReset(); + withPluginRuntimeRegistryScopeMock.mockClear(); ensureContextWindowCacheLoadedMock.mockReset(); ensureContextWindowCacheLoadedMock.mockResolvedValue(undefined); runSessionStartupMigrationMock.mockReset(); @@ -1045,14 +1053,14 @@ describe("EmbeddedTuiBackend", () => { await expect(backend.loadHistory({ sessionKey: "agent:main:main" })).resolves.toMatchObject({ runtimePluginsPrewarm: { status: "warmed" }, }); - expect(ensureRuntimePluginsLoadedMock).toHaveBeenCalledWith({ + expect(loadAgentRuntimePluginRegistryHandleMock).toHaveBeenCalledWith({ config: cfg, workspaceDir: "/tmp/openclaw-agent-main", }); }); it("returns embedded history when runtime plugin loading fails", async () => { - ensureRuntimePluginsLoadedMock.mockImplementationOnce(() => { + loadAgentRuntimePluginRegistryHandleMock.mockImplementationOnce(() => { throw new Error("runtime unavailable"); }); loadSessionEntryMock.mockReturnValue({ @@ -1072,6 +1080,56 @@ describe("EmbeddedTuiBackend", () => { }); }); + it("clears a prior runtime registry after plugins are disabled", async () => { + const registry = {}; + loadAgentRuntimePluginRegistryHandleMock + .mockReturnValueOnce(registry) + .mockReturnValueOnce(undefined); + loadSessionEntryMock.mockReturnValue({ + cfg: {}, + canonicalKey: "agent:main:main", + entry: {}, + }); + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + const backend = new EmbeddedTuiBackend(); + + await backend.loadHistory({ sessionKey: "agent:main:main" }); + await backend.loadHistory({ sessionKey: "agent:main:main" }); + withPluginRuntimeRegistryScopeMock.mockClear(); + await backend.listModels(); + + expect(withPluginRuntimeRegistryScopeMock).toHaveBeenCalledWith( + undefined, + expect.any(Function), + ); + }); + + it("clears a prior runtime registry after a later preload fails", async () => { + const registry = {}; + loadAgentRuntimePluginRegistryHandleMock + .mockReturnValueOnce(registry) + .mockImplementationOnce(() => { + throw new Error("runtime unavailable"); + }); + loadSessionEntryMock.mockReturnValue({ + cfg: {}, + canonicalKey: "agent:main:main", + entry: {}, + }); + const { EmbeddedTuiBackend } = await import("./embedded-backend.js"); + const backend = new EmbeddedTuiBackend(); + + await backend.loadHistory({ sessionKey: "agent:main:main" }); + await backend.loadHistory({ sessionKey: "agent:main:main" }); + withPluginRuntimeRegistryScopeMock.mockClear(); + await backend.listModels(); + + expect(withPluginRuntimeRegistryScopeMock).toHaveBeenCalledWith( + undefined, + expect.any(Function), + ); + }); + it("passes selected-agent global scope into local chat turns", async () => { agentCommandFromIngressMock.mockResolvedValueOnce({ payloads: [{ text: "done" }], diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts index 53dbe5c36700..e04b7457c471 100644 --- a/src/tui/embedded-backend.ts +++ b/src/tui/embedded-backend.ts @@ -26,7 +26,7 @@ import { buildConfiguredModelCatalog, resolveThinkingDefault, } from "../agents/model-selection.js"; -import { ensureRuntimePluginsLoaded } from "../agents/runtime-plugins.js"; +import { loadAgentRuntimePluginRegistryHandle } from "../agents/runtime-plugins.js"; import { readToolValidationErrorSummary } from "../agents/tool-error-summary.js"; import { resolveTextCommand } from "../auto-reply/commands-registry.js"; import { executeSessionGoalCommand, parseGoalCommand } from "../auto-reply/reply/commands-goal.js"; @@ -86,6 +86,8 @@ import { setEmbeddedPluginApprovalBroker, } from "../infra/embedded-plugin-approval-broker.js"; import { logInfo, logWarn } from "../logger.js"; +import type { PluginRegistry } from "../plugins/registry-types.js"; +import { withPluginRuntimeRegistryScope } from "../plugins/runtime/gateway-request-scope.js"; import { agentSessionKeysMatchByRequestKey, normalizeAgentId } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js"; @@ -190,14 +192,14 @@ function shouldLoadFullGatewayCatalogForReplaceMode(cfg: OpenClawConfig) { function ensureEmbeddedHistoryRuntimePluginsLoaded(params: { cfg: OpenClawConfig; sessionAgentId: string; -}): { status: "warmed" } | { status: "failed"; error: string } { +}): { status: "warmed"; registry?: PluginRegistry } | { status: "failed"; error: string } { try { const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.sessionAgentId); - ensureRuntimePluginsLoaded({ + const registry = loadAgentRuntimePluginRegistryHandle({ config: params.cfg, workspaceDir, }); - return { status: "warmed" }; + return { status: "warmed", ...(registry ? { registry } : {}) }; } catch (err) { return { status: "failed", error: formatTuiErrorMessage(err) }; } @@ -345,6 +347,11 @@ async function waitForQueuedLocalRun(previousRun: QueuedSessionRun, runId: strin } export class EmbeddedTuiBackend implements TuiBackend { + private runtimePluginRegistry?: PluginRegistry; + + private withRuntimePluginRegistry(run: () => T): T { + return withPluginRuntimeRegistryScope(this.runtimePluginRegistry, run); + } readonly connection = { url: "local embedded" }; onEvent?: (evt: TuiEvent) => void; @@ -611,6 +618,8 @@ export class EmbeddedTuiBackend implements TuiBackend { cfg, sessionAgentId, }); + this.runtimePluginRegistry = + runtimePluginsPrewarm.status === "warmed" ? runtimePluginsPrewarm.registry : undefined; const resolvedSessionModel = resolveSessionModelRef(cfg, entry, sessionAgentId); const max = Math.min(1000, typeof opts.limit === "number" ? opts.limit : 200); const maxHistoryBytes = getMaxChatHistoryMessagesBytes(); @@ -656,7 +665,7 @@ export class EmbeddedTuiBackend implements TuiBackend { let thinkingLevel = entry?.thinkingLevel; if (!thinkingLevel) { - const catalog = await loadEmbeddedTuiModelCatalog(cfg); + const catalog = await this.withRuntimePluginRegistry(() => loadEmbeddedTuiModelCatalog(cfg)); thinkingLevel = resolveThinkingDefault({ cfg, provider: resolvedSessionModel.provider, @@ -686,7 +695,10 @@ export class EmbeddedTuiBackend implements TuiBackend { thinkingLevel, fastMode: entry?.fastMode, verboseLevel: sessionInfo.verboseLevel, - runtimePluginsPrewarm, + runtimePluginsPrewarm: + runtimePluginsPrewarm.status === "warmed" + ? { status: "warmed" as const } + : runtimePluginsPrewarm, ...(inFlightRun ? { inFlightRun } : {}), }; } @@ -742,7 +754,8 @@ export class EmbeddedTuiBackend implements TuiBackend { storeKey: primaryKey, agentId: opts.agentId, patch: opts, - loadGatewayModelCatalog: () => loadEmbeddedTuiModelCatalog(cfg), + loadGatewayModelCatalog: () => + this.withRuntimePluginRegistry(() => loadEmbeddedTuiModelCatalog(cfg)), }), }); if (!applied.ok) { @@ -796,7 +809,8 @@ export class EmbeddedTuiBackend implements TuiBackend { creation: { via: "operator", actor: { type: "human" } }, emitCommandHooks: Boolean(opts.parentSessionKey), commandSource: "tui:embedded", - loadGatewayModelCatalog: () => loadEmbeddedTuiModelCatalog(cfg), + loadGatewayModelCatalog: () => + this.withRuntimePluginRegistry(() => loadEmbeddedTuiModelCatalog(cfg)), }); if (!result.ok) { throw new Error(result.error.message); @@ -880,7 +894,7 @@ export class EmbeddedTuiBackend implements TuiBackend { async listModels(): Promise { const cfg = getRuntimeConfig(); - const catalog = await loadEmbeddedTuiModelCatalog(cfg); + const catalog = await this.withRuntimePluginRegistry(() => loadEmbeddedTuiModelCatalog(cfg)); const { allowedCatalog } = buildAllowedModelSet({ cfg, catalog,