diff --git a/src/agents/embedded-agent-runner/compact.hooks.harness.ts b/src/agents/embedded-agent-runner/compact.hooks.harness.ts index 5d7f9bc9d5ca..db5a3f9056e7 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.harness.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.harness.ts @@ -653,6 +653,7 @@ export async function loadCompactHooksHarness(): Promise<{ getCurrentPluginMetadataSnapshot: () => emptyPluginMetadataSnapshot, resolvePluginMetadataControlPlaneFingerprint: vi.fn(() => "test-plugin-fingerprint"), setCurrentPluginMetadataSnapshot: vi.fn(), + withPluginMetadataSnapshotScope: (_snapshot: unknown, run: () => unknown) => run(), })); vi.doMock("../../plugins/command-registry-state.js", () => { diff --git a/src/agents/embedded-agent-runner/compact.queued.ts b/src/agents/embedded-agent-runner/compact.queued.ts index 85be4523eb47..f0a2bea5e05c 100644 --- a/src/agents/embedded-agent-runner/compact.queued.ts +++ b/src/agents/embedded-agent-runner/compact.queued.ts @@ -19,7 +19,7 @@ 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 { withPluginRuntimeGenerationScope } 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"; @@ -380,7 +380,7 @@ async function compactEmbeddedAgentSessionImpl( } }; try { - return await withPluginRuntimeRegistryScope(lease.snapshot.pluginRegistry, run); + return await withPluginRuntimeGenerationScope(lease.snapshot, run); } finally { lease.release(); } diff --git a/src/agents/embedded-agent-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts index 69404e7e4b1d..181d2a53376a 100644 --- a/src/agents/embedded-agent-runner/compact.ts +++ b/src/agents/embedded-agent-runner/compact.ts @@ -3,7 +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 { withPluginRuntimeGenerationScope } from "../../plugins/runtime/gateway-request-scope.js"; import { resolveUserPath } from "../../utils.js"; import { normalizeOptionalAgentRuntimeId } from "../agent-runtime-id.js"; import { @@ -336,10 +336,7 @@ export async function compactEmbeddedAgentSessionDirect( }); return fallbackResult.result; }; - return await withPluginRuntimeRegistryScope( - preparedModelRuntime.pluginRegistry, - compactPrepared, - ); + return await withPluginRuntimeGenerationScope(preparedModelRuntime, compactPrepared); } catch (err) { return fallbackFailureToCompactionResult(err); } finally { diff --git a/src/agents/embedded-agent-runner/run-orchestrator.ts b/src/agents/embedded-agent-runner/run-orchestrator.ts index 79ed3811fb30..4b611ec1145f 100644 --- a/src/agents/embedded-agent-runner/run-orchestrator.ts +++ b/src/agents/embedded-agent-runner/run-orchestrator.ts @@ -15,12 +15,13 @@ import { buildHandledBeforeAgentReplyPayloads, runBeforeAgentReplyForTurn, } from "../../plugins/before-agent-reply.js"; +import { getCurrentPluginMetadataSnapshot } from "../../plugins/current-plugin-metadata-snapshot.js"; import { buildAgentHookContextChannelFields, 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 { withPluginRuntimeGenerationScope } from "../../plugins/runtime/gateway-request-scope.js"; import { resolveUserPath } from "../../utils.js"; import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; import { @@ -226,8 +227,15 @@ async function runEmbeddedAgentInternal( agentId: requestedWorkspaceResolution.agentId, sessionKey: params.sessionKey, }); + const currentPluginMetadataSnapshot = getCurrentPluginMetadataSnapshot({ + config, + workspaceDir: requestedWorkspaceResolution.workspaceDir, + env: process.env, + allowWorkspaceScopedSnapshot: true, + }); const runtimePluginSelections = resolveModelCandidateChain({ cfg: config, + manifestPlugins: currentPluginMetadataSnapshot?.plugins, provider: requestedRuntimeSelection.provider, model: requestedRuntimeSelection.modelId, requestedRouteResolution: "resolved", @@ -424,10 +432,7 @@ async function runEmbeddedAgentInternal( preparedModelRuntime, }); }; - return await withPluginRuntimeRegistryScope( - preparedModelRuntime.pluginRegistry, - runPrepared, - ); + return await withPluginRuntimeGenerationScope(preparedModelRuntime, runPrepared); } finally { preparedModelRuntimeLease.release(); } diff --git a/src/agents/isolated-completion.test.ts b/src/agents/isolated-completion.test.ts index f1b385481710..e8e2c35e1660 100644 --- a/src/agents/isolated-completion.test.ts +++ b/src/agents/isolated-completion.test.ts @@ -13,6 +13,7 @@ import { type PreparedAgentRunAdmission, } from "./admitted-run-context.js"; import type { AgentHarness } from "./harness/types.js"; +import { createEmptyPluginMetadataSnapshot } from "./test-helpers/embedded-agent-runner-e2e-mocks.js"; type IsolatedCliRunParams = { preparedRunAdmission: PreparedAgentRunAdmission; @@ -145,7 +146,10 @@ beforeEach(() => { vi.clearAllMocks(); mocks.acquireAgentRunPreparedModelRuntime.mockResolvedValue({ snapshot: { + config: {}, + metadataSnapshot: createEmptyPluginMetadataSnapshot("/tmp/workspace"), pluginRegistry: createEmptyPluginRegistry(), + workspaceDir: "/tmp/workspace", createStores: () => ({ modelRegistry: {} }), }, release: vi.fn(), diff --git a/src/agents/isolated-completion.ts b/src/agents/isolated-completion.ts index 1fff7c9aac06..a7e567037706 100644 --- a/src/agents/isolated-completion.ts +++ b/src/agents/isolated-completion.ts @@ -13,7 +13,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 { withPluginRuntimeGenerationScope } from "../plugins/runtime/gateway-request-scope.js"; import { prepareSystemAgentRunAdmission } from "./admitted-run-context.js"; import { resolveAgentDir, resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js"; import { resolveCliBackendConfig, resolveCliRuntimeCanonicalProvider } from "./cli-backends.js"; @@ -682,7 +682,7 @@ export async function runIsolatedCompletion( usage: result.assistant.usage, }; }; - return await withPluginRuntimeRegistryScope(pluginRegistry, run); + return await withPluginRuntimeGenerationScope(lease.snapshot, run); } finally { lease.release(); } 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 1ec91e37fd6a..edd528451faf 100644 --- a/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts +++ b/src/agents/test-helpers/embedded-agent-runner-e2e-mocks.ts @@ -29,7 +29,7 @@ type EmbeddedRunnerBackoffMockOptions = { sleepWithAbort: (ms: number, abortSignal?: AbortSignal) => unknown; }; -function createEmptyPluginMetadataSnapshot(workspaceDir?: string): PluginMetadataSnapshot { +export function createEmptyPluginMetadataSnapshot(workspaceDir?: string): PluginMetadataSnapshot { return { policyHash: "", ...(workspaceDir !== undefined ? { workspaceDir } : {}), diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index 5e4a1fd7b31e..e09907ac0168 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -363,6 +363,10 @@ export function buildGatewaySessionRow(params: { const thinkingProvider = rowModelProvider ?? DEFAULT_PROVIDER; const thinkingModel = rowModel ?? DEFAULT_MODEL; + // Event/list rows must not rediscover plugin-backed configured catalog metadata. + // An owner-provided catalog remains authoritative; otherwise the lightweight + // projection uses built-in model-family policy only. + const thinkingModelCatalog = params.modelCatalog ?? (lightweight ? [] : undefined); const thinkingProjection = resolveGatewaySessionThinkingProjectionInternal({ cfg, agentId: sessionAgentId, @@ -370,7 +374,7 @@ export function buildGatewaySessionRow(params: { model: thinkingModel, sessionKey: acpSessionKey, entry, - modelCatalog: params.modelCatalog, + modelCatalog: thinkingModelCatalog, rowContext, }); const fastModeState = resolveFastModeState({ diff --git a/src/gateway/session-utils-search.ts b/src/gateway/session-utils-search.ts index edd7050ffd8a..eb89321c51cc 100644 --- a/src/gateway/session-utils-search.ts +++ b/src/gateway/session-utils-search.ts @@ -153,9 +153,10 @@ type LoadGatewaySessionRowOptions = { transcriptUsageMaxBytes?: number; }; -export function loadGatewaySessionLifecycleSnapshot( +function loadGatewaySessionSnapshot( sessionKey: string, options?: LoadGatewaySessionRowOptions, + lightweight = false, ): { lifecycleRunId?: string; row: GatewaySessionRow | null } { const now = options?.now ?? Date.now(); const { cfg, storePath, store, entry, canonicalKey } = loadGatewaySessionEntryReadOnly( @@ -189,16 +190,25 @@ export function loadGatewaySessionLifecycleSnapshot( includeLastMessage: options?.includeLastMessage, transcriptUsageMaxBytes: options?.transcriptUsageMaxBytes, storeChildSessionsByKey, + skipTranscriptUsageFallback: lightweight, + lightweightListRow: lightweight, ...(options?.agentId ? { agentId: options.agentId } : {}), }), }; } +export function loadGatewaySessionLifecycleSnapshot( + sessionKey: string, + options?: LoadGatewaySessionRowOptions, +): { lifecycleRunId?: string; row: GatewaySessionRow | null } { + return loadGatewaySessionSnapshot(sessionKey, options, true); +} + export function loadGatewaySessionRow( sessionKey: string, options?: LoadGatewaySessionRowOptions, ): GatewaySessionRow | null { - return loadGatewaySessionLifecycleSnapshot(sessionKey, options).row; + return loadGatewaySessionSnapshot(sessionKey, options).row; } export function buildGatewaySessionInfo(params: { diff --git a/src/gateway/session-utils.plugin-runtime.test.ts b/src/gateway/session-utils.plugin-runtime.test.ts index 2c858a5fbab5..78e49ad2561d 100644 --- a/src/gateway/session-utils.plugin-runtime.test.ts +++ b/src/gateway/session-utils.plugin-runtime.test.ts @@ -3,7 +3,9 @@ */ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import type { SessionEntry } from "../config/sessions.js"; +import { resolveSessionStorePathCore, type SessionEntry } from "../config/sessions.js"; +import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; +import { withStateDirEnv } from "../test-helpers/state-dir-env.js"; const normalizeProviderModelIdWithPluginMock = vi.fn(); const emptyPluginMetadataSnapshot = vi.hoisted(() => ({ @@ -86,4 +88,38 @@ describe("gateway session list plugin runtime normalization", () => { expect(row.model).toBe("custom-modern-model"); expect(normalizeProviderModelIdWithPluginMock).toHaveBeenCalled(); }); + + it("keeps lifecycle event rows lightweight without changing explicit detail rows", async () => { + await withStateDirEnv("openclaw-lifecycle-row-plugin-runtime-", async () => { + normalizeProviderModelIdWithPluginMock.mockImplementation( + ({ provider, context }: { provider?: string; context?: { modelId?: string } }) => + provider === "custom-provider" && context?.modelId === "custom-legacy-model" + ? "custom-modern-model" + : undefined, + ); + const cfg = { + agents: { + defaults: { model: { primary: "custom-provider/custom-legacy-model" } }, + }, + } as OpenClawConfig; + const configRuntime = await import("../config/config.js"); + configRuntime.resetConfigRuntimeState(); + configRuntime.setRuntimeConfigSnapshot(cfg, cfg); + const sessionKey = "agent:main:lifecycle-plugin-runtime"; + const storePath = resolveSessionStorePathCore(cfg.session?.store, { agentId: "main" }); + await replaceSessionEntry({ sessionKey, storePath }, { + sessionId: "lifecycle-plugin-runtime", + updatedAt: 1, + } satisfies SessionEntry); + + const lifecycle = sessionUtils.loadGatewaySessionLifecycleSnapshot(sessionKey); + + expect(lifecycle.row?.model).toBe("custom-legacy-model"); + expect(normalizeProviderModelIdWithPluginMock).not.toHaveBeenCalled(); + + expect(sessionUtils.loadGatewaySessionRow(sessionKey)?.model).toBe("custom-modern-model"); + expect(normalizeProviderModelIdWithPluginMock).toHaveBeenCalled(); + configRuntime.resetConfigRuntimeState(); + }); + }); }); diff --git a/src/plugins/current-plugin-metadata-snapshot.test.ts b/src/plugins/current-plugin-metadata-snapshot.test.ts index e643758d378c..38514e5b5d19 100644 --- a/src/plugins/current-plugin-metadata-snapshot.test.ts +++ b/src/plugins/current-plugin-metadata-snapshot.test.ts @@ -16,6 +16,11 @@ import { writePersistedInstalledPluginIndexSync } from "./installed-plugin-index import type { PluginManifestRecord } from "./manifest-registry.js"; import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.js"; +import { createEmptyPluginRegistry } from "./registry-empty.js"; +import { + getPluginRuntimeGatewayRequestScope, + withPluginRuntimeGenerationScope, +} from "./runtime/gateway-request-scope.js"; function createSnapshot( params: { @@ -160,6 +165,26 @@ describe("current plugin metadata snapshot", () => { ).toBe(globalSnapshot); }); + it("carries prepared metadata and registry as one runtime generation", async () => { + const config = { plugins: { allow: ["scoped"] } }; + const workspaceDir = "/workspace/scoped"; + const metadataSnapshot = createSnapshot({ config, workspaceDir }); + const pluginRegistry = createEmptyPluginRegistry(); + setCurrentPluginMetadataSnapshot(undefined); + + await withPluginRuntimeGenerationScope( + { config, metadataSnapshot, pluginRegistry, workspaceDir }, + async () => { + await Promise.resolve(); + expect(getCurrentPluginMetadataSnapshot({ config, workspaceDir })).toBe(metadataSnapshot); + expect(getPluginRuntimeGatewayRequestScope()?.pluginRegistry).toBe(pluginRegistry); + }, + ); + + expect(getCurrentPluginMetadataSnapshot({ config, workspaceDir })).toBeUndefined(); + expect(getPluginRuntimeGatewayRequestScope()).toBeUndefined(); + }); + it("lets configless nested readers inherit explicit owner discovery context", () => { const config = { plugins: { diff --git a/src/plugins/runtime/gateway-request-scope.ts b/src/plugins/runtime/gateway-request-scope.ts index 53eceaa1b48b..6aefb459175b 100644 --- a/src/plugins/runtime/gateway-request-scope.ts +++ b/src/plugins/runtime/gateway-request-scope.ts @@ -1,10 +1,13 @@ // Gateway request scope tracks request-local plugin runtime context across async work. import { AsyncLocalStorage } from "node:async_hooks"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { GatewayRequestContext, GatewayRequestOptions, } from "../../gateway/server-methods/types.js"; import { resolveGlobalSingleton } from "../../shared/global-singleton.js"; +import { withPluginMetadataSnapshotScope } from "../current-plugin-metadata-snapshot.js"; +import type { PluginMetadataSnapshot } from "../plugin-metadata-snapshot.types.js"; import type { PluginOrigin } from "../plugin-origin.types.js"; import type { PluginRegistry } from "../registry-types.js"; @@ -63,6 +66,26 @@ export function withPluginRuntimeRegistryScope( ); } +/** Carries one prepared plugin generation through all nested runtime lookups. */ +export function withPluginRuntimeGenerationScope( + generation: { + config: OpenClawConfig; + metadataSnapshot: PluginMetadataSnapshot; + pluginRegistry?: PluginRegistry; + workspaceDir?: string; + }, + run: () => T, +): T { + return withPluginMetadataSnapshotScope( + generation.metadataSnapshot, + () => withPluginRuntimeRegistryScope(generation.pluginRegistry, run), + { + config: generation.config, + ...(generation.workspaceDir ? { workspaceDir: generation.workspaceDir } : {}), + }, + ); +} + /** * Runs work under the current gateway request scope while attaching plugin identity. */ diff --git a/test/scripts/bench-gateway-concurrency.test.ts b/test/scripts/bench-gateway-concurrency.test.ts index b8a9a68e1d1b..192853a04299 100644 --- a/test/scripts/bench-gateway-concurrency.test.ts +++ b/test/scripts/bench-gateway-concurrency.test.ts @@ -128,6 +128,7 @@ describe("gateway concurrency benchmark script", () => { const sample = { controlUi: [], durationMs: 10, + pluginMetadataScans: { count: 0, durationMs: null, totalDurationMs: 0 }, probeWarmup: { durationMs: 2, samples: [] }, readyz: [], sessionsList: [],