From 7a8eee4a363b6fd097a40d221aedcff14e61cc8c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 8 Aug 2026 22:48:40 -0700 Subject: [PATCH] perf(agents): keep turn-path model catalog reads off the full live build (#120834) * perf(agents): keep turn-path model catalog reads off the full live build First agent turns (embedded and cron) resolved thinking capability through loadPreparedModelCatalogSnapshot without readOnly, which materialized the full live model-runtime catalog: ambient synthetic-auth discovery fanned out to every registered provider and loaded plugin discovery modules through jiti source transform (3,172 TS modules, 36s event-loop block, +600MB heap, 58.7s model-selection on a cold gateway). - add loadProviderScopedThinkingCatalog: manifest metadata first, then a provider-scoped read-only static catalog, then scoped live discovery only for runtime-discovery providers (preserves #116584 Ollama semantics) - route scopedLiveProviderDiscovery through the scoped read-only loader - scope live-mode ambient synthetic-auth refs to the requested providers - bound the last-resort synthetic-auth sweep to discovery entry modules - memoize per-turn plugin skill dir resolution/republish (single-slot, lifecycle-cleared; was a full walk + symlink republish every turn) Cold first turn 72.7s -> ~22s wall (remaining cost is provider prefill of the ~19.5k-token default prompt); model-selection 58,726ms -> 124ms. * test(agents): align model-catalog.runtime mocks with scoped thinking catalog seam Explicit vi.mock factories must export every binding prod touches; the new loadProviderScopedThinkingCatalog export is now mocked everywhere the module is stubbed, and the live-model-switch Ollama hydration test asserts the new provider-scoped seam instead of the retired unscoped snapshot call shape. * test(agents): export scoped thinking catalog from every prepared-catalog mock; split synthetic-auth helpers - add loadProviderScopedThinkingCatalog to all explicit prepared-model-catalog and model-catalog.runtime mock factories (vi.mock factories must export every binding prod touches) - move synthetic-auth ref scoping/resolution into prepared-model-runtime.synthetic-auth.ts; keeps facts under the max-lines cap * test(agents): prove scoped thinking hydration for runtime-only models Boundary proof for the ClawSweeper review gap: the three-tier helper stops at manifest or scoped-static when they resolve, and runs provider-scoped live discovery (no broad fanout) only for runtime-only models; cron selection hydrates through the same scoped helper and skips it entirely for thinking=off. * test(agents): accept rest args in scoped thinking catalog mocks --- .../agent-command.compaction-rotation.test.ts | 1 + .../agent-command.live-model-switch.test.ts | 20 +++- .../assistant-transcript-repair.test.ts | 1 + src/agents/command/model-selection.ts | 17 +-- src/agents/context.lookup.test.ts | 1 + src/agents/model-catalog.runtime.ts | 1 + src/agents/model-provider-auth.test.ts | 1 + src/agents/model-provider-auth.worker.test.ts | 1 + .../model-selection.plugin-runtime.test.ts | 1 + .../openclaw-tools.session-status.test.ts | 1 + ...ared-model-catalog.scoped-thinking.test.ts | 106 ++++++++++++++++++ src/agents/prepared-model-catalog.ts | 67 ++++++++++- src/agents/prepared-model-runtime.facts.ts | 57 +++------- .../prepared-model-runtime.synthetic-auth.ts | 53 +++++++++ ....directive.directive-behavior.e2e-mocks.ts | 1 + src/auto-reply/reply.test-harness.ts | 1 + src/auto-reply/reply/commands-models.test.ts | 1 + .../reply/directive-handling.model.test.ts | 1 + .../reply/get-reply.config-override.test.ts | 1 + .../reply/get-reply.fast-path.test.ts | 1 + src/auto-reply/reply/model-selection.test.ts | 1 + .../reply/session-reset-model.test.ts | 1 + src/auto-reply/reply/session.test.ts | 1 + src/cli/capability-cli.test.ts | 1 + src/commands/agent-command.test-mocks.ts | 1 + src/commands/model-picker.test.ts | 1 + src/commands/models.list.e2e.test.ts | 1 + src/commands/models/list.configured.test.ts | 1 + .../models/list.probe.targets.test.ts | 1 + src/commands/models/list.rows.test.ts | 1 + src/commands/models/list.status.test.ts | 1 + src/cron/isolated-agent.mocks.ts | 3 + .../model-selection.scoped-thinking.test.ts | 70 ++++++++++++ src/cron/isolated-agent/model-selection.ts | 19 ++-- .../run-model-selection.runtime.ts | 2 +- src/cron/isolated-agent/run.test-harness.ts | 1 + .../doctor-core-checks.runtime-errors.test.ts | 1 + src/flows/doctor-core-checks.runtime.test.ts | 1 + src/flows/doctor-core-checks.test.ts | 1 + src/flows/doctor-health-contributions.test.ts | 1 + .../model-picker.provider-catalog.test.ts | 1 + .../server-startup-post-attach.test.ts | 1 + .../runner.vision-skip.test.ts | 1 + ...ent-runtime-model-catalog-contract.test.ts | 1 + src/plugins/provider-runtime.ts | 5 + src/skills/loading/plugin-skills.ts | 33 ++++++ src/system-agent/tui-backend.test.ts | 1 + src/wizard/setup.finalize.test.ts | 1 + .../media/vision-routing-core.e2e.test.ts | 1 + .../trigger-handling-test-harness.ts | 2 + 50 files changed, 422 insertions(+), 69 deletions(-) create mode 100644 src/agents/prepared-model-catalog.scoped-thinking.test.ts create mode 100644 src/agents/prepared-model-runtime.synthetic-auth.ts create mode 100644 src/cron/isolated-agent/model-selection.scoped-thinking.test.ts diff --git a/src/agents/agent-command.compaction-rotation.test.ts b/src/agents/agent-command.compaction-rotation.test.ts index ee72a7ff2858..3cb3b17908a4 100644 --- a/src/agents/agent-command.compaction-rotation.test.ts +++ b/src/agents/agent-command.compaction-rotation.test.ts @@ -87,6 +87,7 @@ vi.mock("./model-catalog.js", () => ({ })); vi.mock("./model-catalog.runtime.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalogSnapshot: vi.fn(async () => ({ entries: [], routeVariants: [], diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 4b90ef2d0325..c297232cb6a4 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -97,6 +97,7 @@ const state = vi.hoisted(() => ({ resolveSupportedThinkingLevelMock: vi.fn(({ level }: { level?: string }) => level), resolveThinkingDefaultMock: vi.fn((_args: unknown) => "low"), loadManifestModelCatalogMock: vi.fn(() => []), + loadProviderScopedThinkingCatalogMock: vi.fn((_params: unknown) => undefined), loadPreparedModelCatalogSnapshotMock: vi.fn( async (): Promise => ({ entries: [], @@ -551,6 +552,11 @@ vi.mock("./model-catalog.js", () => ({ })); vi.mock("./model-catalog.runtime.js", () => ({ + // The scoped thinking catalog hydrates from the same runtime snapshot the test controls. + loadProviderScopedThinkingCatalog: async (params: unknown) => { + state.loadProviderScopedThinkingCatalogMock(params); + return (await state.loadPreparedModelCatalogSnapshotMock()).entries; + }, loadPreparedModelCatalogSnapshot: state.loadPreparedModelCatalogSnapshotMock, })); @@ -3523,11 +3529,15 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { allowModelOverride: true, }); - expect(state.loadPreparedModelCatalogSnapshotMock).toHaveBeenCalledWith({ - config: state.runtimeConfigMock, - agentId: "default", - workspaceDir: "/tmp/workspace", - }); + expect(state.loadProviderScopedThinkingCatalogMock).toHaveBeenCalledWith( + expect.objectContaining({ + config: state.runtimeConfigMock, + provider: "ollama", + model: "minimax-m3:cloud", + agentId: "default", + workspaceDir: "/tmp/workspace", + }), + ); const thinkingArgs = requireRecord( mockCallArg(state.isThinkingLevelSupportedMock), "thinking args", diff --git a/src/agents/command/assistant-transcript-repair.test.ts b/src/agents/command/assistant-transcript-repair.test.ts index 66a88e027cbd..db047ca46911 100644 --- a/src/agents/command/assistant-transcript-repair.test.ts +++ b/src/agents/command/assistant-transcript-repair.test.ts @@ -96,6 +96,7 @@ vi.mock("../model-catalog.js", () => ({ })); vi.mock("../model-catalog.runtime.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalogSnapshot: vi.fn(async () => ({ entries: [], routeVariants: [], diff --git a/src/agents/command/model-selection.ts b/src/agents/command/model-selection.ts index f56f7ebebeb2..8ca4827e8ceb 100644 --- a/src/agents/command/model-selection.ts +++ b/src/agents/command/model-selection.ts @@ -512,15 +512,16 @@ export async function resolveEmbeddedModelSelection(params: { primaryConfiguredThinkLevel !== "off" && !hasResolvedThinkingCatalogEntry({ catalog: catalogForThinking, provider, model }) ) { - const { loadPreparedModelCatalogSnapshot } = await import("../model-catalog.runtime.js"); + // Thinking capability is a per-model fact; never materialize the full live catalog here. + const { loadProviderScopedThinkingCatalog } = await import("../model-catalog.runtime.js"); const runtimeCatalog = normalizeThinkingCatalogProviders( - ( - await loadPreparedModelCatalogSnapshot({ - config: params.cfg, - agentId: params.sessionAgentId, - workspaceDir: params.workspaceDir, - }) - ).entries, + await loadProviderScopedThinkingCatalog({ + config: params.cfg, + provider, + model, + ...(params.sessionAgentId ? { agentId: params.sessionAgentId } : {}), + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + }), ); const allowedRuntimeCatalog = createModelVisibilityPolicy({ cfg: params.cfg, diff --git a/src/agents/context.lookup.test.ts b/src/agents/context.lookup.test.ts index 3e46be4c24de..25cfd75f0798 100644 --- a/src/agents/context.lookup.test.ts +++ b/src/agents/context.lookup.test.ts @@ -65,6 +65,7 @@ vi.mock("../config/runtime-source-projection.js", () => ({ })); vi.mock("./prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalogOwnerSnapshot: contextTestState.loadModelCatalogOwnerSnapshot, getPublishedPreparedModelCatalogOwnerSnapshot: contextTestState.getPublishedModelCatalogOwnerSnapshot, diff --git a/src/agents/model-catalog.runtime.ts b/src/agents/model-catalog.runtime.ts index 7a1997acfed4..de95ba5dabca 100644 --- a/src/agents/model-catalog.runtime.ts +++ b/src/agents/model-catalog.runtime.ts @@ -3,4 +3,5 @@ export { loadManifestModelCatalog } from "./model-catalog.js"; export { loadPreparedModelCatalog, loadPreparedModelCatalogSnapshot, + loadProviderScopedThinkingCatalog, } from "./prepared-model-catalog.js"; diff --git a/src/agents/model-provider-auth.test.ts b/src/agents/model-provider-auth.test.ts index e4710d5614ba..08b7e3dfeebf 100644 --- a/src/agents/model-provider-auth.test.ts +++ b/src/agents/model-provider-auth.test.ts @@ -66,6 +66,7 @@ const authProfilesMocks = vi.hoisted(() => ({ })); vi.mock("./prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalogOwnerSnapshot: async (params?: unknown) => ({ ...(modelCatalogMocks.ownerWorkspaceDir ? { workspaceDir: modelCatalogMocks.ownerWorkspaceDir } diff --git a/src/agents/model-provider-auth.worker.test.ts b/src/agents/model-provider-auth.worker.test.ts index 7eceaf0cd0c9..f302b92f0037 100644 --- a/src/agents/model-provider-auth.worker.test.ts +++ b/src/agents/model-provider-auth.worker.test.ts @@ -15,6 +15,7 @@ import { runProviderAuthWarmWorkerInput } from "./model-provider-auth.worker.js" const tempDirs: string[] = []; vi.mock("./prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalogOwnerSnapshot: vi.fn( async (params: { agentDir: string; agentId?: string; config: OpenClawConfig }) => ({ agentDir: params.agentDir, diff --git a/src/agents/model-selection.plugin-runtime.test.ts b/src/agents/model-selection.plugin-runtime.test.ts index a4811d619c4e..8988c45b7a7b 100644 --- a/src/agents/model-selection.plugin-runtime.test.ts +++ b/src/agents/model-selection.plugin-runtime.test.ts @@ -32,6 +32,7 @@ vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({ vi.mock("./model-catalog.runtime.js", () => ({ loadManifestModelCatalog: () => [], + loadProviderScopedThinkingCatalog: async () => [], loadPreparedModelCatalog: async () => [], loadPreparedModelCatalogSnapshot: loadPreparedModelCatalogSnapshotMock, })); diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index 96f3ef915ee1..b7e1fd9dc8a8 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -200,6 +200,7 @@ function createConfigModuleMock() { function createModelCatalogModuleMock() { return { + loadProviderScopedThinkingCatalog: async () => [], loadPreparedModelCatalog: async () => [ { provider: "anthropic", diff --git a/src/agents/prepared-model-catalog.scoped-thinking.test.ts b/src/agents/prepared-model-catalog.scoped-thinking.test.ts new file mode 100644 index 000000000000..baa003a7ad32 --- /dev/null +++ b/src/agents/prepared-model-catalog.scoped-thinking.test.ts @@ -0,0 +1,106 @@ +// Boundary proof for the turn-path thinking fallback: manifest first, then a provider-scoped +// static catalog, then scoped live discovery only for runtime-only models (e.g. Ollama). +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const manifestCatalogMock = vi.fn((..._args: unknown[]): Array> => []); +const scopedStaticMock = vi.fn( + async (..._args: unknown[]): Promise> => ({ + entries: [], + routeVariants: [], + }), +); +const scopedLiveMock = vi.fn( + async (..._args: unknown[]): Promise> => ({ + entries: [], + routeVariants: [], + }), +); + +vi.mock("./model-catalog.js", () => ({ + loadManifestModelCatalog: (...args: unknown[]) => manifestCatalogMock(...args), +})); + +vi.mock("./prepared-model-runtime.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // No published lifecycle owner: force the scoped read-only builders to run. + prepareModelRuntimeSnapshot: vi.fn(async (input: { agentDir: string }) => { + throw new actual.PreparedModelRuntimeOwnerNotPublishedError( + `not published for test (${input.agentDir})`, + ); + }), + }; +}); + +vi.mock("./prepared-model-runtime.scoped-catalog.js", () => ({ + prepareScopedReadOnlyModelCatalog: (...args: unknown[]) => scopedStaticMock(...args), + prepareScopedReadOnlyLiveModelCatalog: (...args: unknown[]) => scopedLiveMock(...args), +})); + +const ollamaEntry = { + provider: "ollama", + id: "minimax-m3:cloud", + name: "minimax-m3:cloud", + reasoning: true, +}; + +describe("loadProviderScopedThinkingCatalog", () => { + beforeEach(() => { + vi.clearAllMocks(); + manifestCatalogMock.mockReturnValue([]); + scopedStaticMock.mockResolvedValue({ entries: [], routeVariants: [] }); + scopedLiveMock.mockResolvedValue({ entries: [], routeVariants: [] }); + }); + + it("resolves manifest-backed models without any scoped catalog build", async () => { + manifestCatalogMock.mockReturnValue([ + { provider: "openai", id: "gpt-5.6-luna", reasoning: true }, + ]); + const { loadProviderScopedThinkingCatalog } = await import("./prepared-model-catalog.js"); + const catalog = await loadProviderScopedThinkingCatalog({ + config: {}, + provider: "openai", + model: "gpt-5.6-luna", + }); + expect(catalog).toEqual([ + expect.objectContaining({ provider: "openai", id: "gpt-5.6-luna", reasoning: true }), + ]); + expect(scopedStaticMock).not.toHaveBeenCalled(); + expect(scopedLiveMock).not.toHaveBeenCalled(); + }); + + it("stops at the scoped static catalog when it resolves the entry", async () => { + scopedStaticMock.mockResolvedValue({ + entries: [{ provider: "acme", id: "static-model", reasoning: false }], + routeVariants: [], + }); + const { loadProviderScopedThinkingCatalog } = await import("./prepared-model-catalog.js"); + const catalog = await loadProviderScopedThinkingCatalog({ + config: {}, + provider: "acme", + model: "static-model", + }); + expect(catalog).toEqual([ + expect.objectContaining({ provider: "acme", id: "static-model", reasoning: false }), + ]); + expect(scopedStaticMock).toHaveBeenCalledTimes(1); + expect(scopedStaticMock).toHaveBeenCalledWith(expect.anything(), ["acme"]); + expect(scopedLiveMock).not.toHaveBeenCalled(); + }); + + it("runs provider-scoped live discovery for runtime-only models and keeps their thinking", async () => { + scopedLiveMock.mockResolvedValue({ entries: [ollamaEntry], routeVariants: [] }); + const { loadProviderScopedThinkingCatalog } = await import("./prepared-model-catalog.js"); + const catalog = await loadProviderScopedThinkingCatalog({ + config: {}, + provider: "ollama", + model: "minimax-m3:cloud", + }); + expect(catalog).toEqual([expect.objectContaining(ollamaEntry)]); + // Live discovery stays scoped to the requested provider: no broad plugin fanout. + expect(scopedLiveMock).toHaveBeenCalledTimes(1); + expect(scopedLiveMock).toHaveBeenCalledWith(expect.anything(), ["ollama"]); + expect(scopedStaticMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/agents/prepared-model-catalog.ts b/src/agents/prepared-model-catalog.ts index 24ce2888e519..49b5fc2b9cf7 100644 --- a/src/agents/prepared-model-catalog.ts +++ b/src/agents/prepared-model-catalog.ts @@ -24,7 +24,14 @@ import { type PreparedModelRuntimeInput, type PreparedModelRuntimeSnapshot, } from "./prepared-model-runtime.js"; -import { prepareScopedReadOnlyModelCatalog } from "./prepared-model-runtime.scoped-catalog.js"; +import { + prepareScopedReadOnlyLiveModelCatalog, + prepareScopedReadOnlyModelCatalog, +} from "./prepared-model-runtime.scoped-catalog.js"; +import { + hasResolvedThinkingCatalogEntry, + normalizeThinkingCatalogProviders, +} from "./thinking-runtime.js"; export type LoadPreparedModelCatalogParams = { agentId?: string; @@ -34,6 +41,8 @@ export type LoadPreparedModelCatalogParams = { workspaceDir?: string; env?: NodeJS.ProcessEnv; providerDiscoveryProviderIds?: readonly string[]; + /** Scoped read-only loads may run live discovery for the scoped providers only. */ + scopedLiveProviderDiscovery?: boolean; allowGatewaySubagentBinding?: boolean; }; @@ -287,9 +296,59 @@ async function loadScopedReadOnlyModelCatalog( } } } - return prepareScopedReadOnlyModelCatalog( - activationExact, - params.providerDiscoveryProviderIds ?? [], + const prepareScoped = + params.scopedLiveProviderDiscovery === true + ? prepareScopedReadOnlyLiveModelCatalog + : prepareScopedReadOnlyModelCatalog; + return prepareScoped(activationExact, params.providerDiscoveryProviderIds ?? []); +} + +/** + * Turn-path capability reads (thinking levels and similar per-model facts) must stay off the + * full live catalog build: manifest metadata first, then a provider-scoped read-only catalog, + * then scoped live discovery only for providers whose models exist solely at runtime. + */ +export async function loadProviderScopedThinkingCatalog(params: { + config: OpenClawConfig; + provider: string; + model: string; + agentId?: string; + agentDir?: string; + workspaceDir?: string; +}): Promise { + const { loadManifestModelCatalog } = await import("./model-catalog.js"); + const manifestCatalog = normalizeThinkingCatalogProviders( + loadManifestModelCatalog({ + config: params.config, + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + }), + ); + const scopedParams = { + config: params.config, + ...(params.agentId ? { agentId: params.agentId } : {}), + ...(params.agentDir ? { agentDir: params.agentDir } : {}), + ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + readOnly: true, + providerDiscoveryProviderIds: [params.provider], + } satisfies LoadPreparedModelCatalogParams; + const entryResolved = (catalog: readonly ModelCatalogEntry[]) => + hasResolvedThinkingCatalogEntry({ catalog, provider: params.provider, model: params.model }); + if (entryResolved(manifestCatalog)) { + return manifestCatalog; + } + const scopedStatic = normalizeThinkingCatalogProviders( + (await loadPreparedModelCatalogSnapshot(scopedParams)).entries, + ); + if (entryResolved(scopedStatic)) { + return scopedStatic; + } + return normalizeThinkingCatalogProviders( + ( + await loadPreparedModelCatalogSnapshot({ + ...scopedParams, + scopedLiveProviderDiscovery: true, + }) + ).entries, ); } diff --git a/src/agents/prepared-model-runtime.facts.ts b/src/agents/prepared-model-runtime.facts.ts index f3bcd5f0da3c..94bdb21f1bb0 100644 --- a/src/agents/prepared-model-runtime.facts.ts +++ b/src/agents/prepared-model-runtime.facts.ts @@ -16,7 +16,6 @@ import type { PreparedProviderStaticCatalog } from "../plugins/provider-discover 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"; import { resolveAmbientAgentCredentialsForDiscovery } from "./agent-auth-discovery.js"; import { @@ -56,6 +55,11 @@ import { type PreparedInboundRegistryLoader, } from "./prepared-model-runtime.inbound-registry.js"; import { prepareOwnedPluginLoadContext } from "./prepared-model-runtime.plugin-context.js"; +import { + listPreparedSyntheticAuthProviderRefs, + resolvePreparedSyntheticAuth, + scopeSyntheticAuthProviderRefs, +} from "./prepared-model-runtime.synthetic-auth.js"; import type { PreparedModelRuntimeBuildStats, PreparedModelRuntimeCatalogMode, @@ -158,40 +162,6 @@ function prepareAgentFacts( }; } -function listPreparedSyntheticAuthProviderRefs(providers: readonly ProviderPlugin[]): string[] { - return [ - ...new Set( - providers.flatMap((provider) => - typeof provider.resolveSyntheticAuth === "function" - ? [provider.id, ...(provider.aliases ?? []), ...(provider.hookAliases ?? [])] - : [], - ), - ), - ].toSorted((left, right) => left.localeCompare(right)); -} - -function resolvePreparedSyntheticAuth(params: { - config: PreparedModelRuntimeInput["config"]; - provider: string; - providers: readonly ProviderPlugin[]; -}): { apiKey?: string } | undefined { - const normalizedProvider = normalizeProviderId(params.provider); - const providerPlugin = params.providers.find((candidate) => - [candidate.id, ...(candidate.aliases ?? []), ...(candidate.hookAliases ?? [])].some( - (ref) => normalizeProviderId(ref) === normalizedProvider, - ), - ); - return ( - providerPlugin?.resolveSyntheticAuth?.({ - config: params.config, - provider: params.provider, - providerConfig: Object.entries(params.config.models?.providers ?? {}).find( - ([providerId]) => normalizeProviderId(providerId) === normalizedProvider, - )?.[1], - }) ?? undefined - ); -} - export async function prepareWorkspaceBuildGroup( inputs: readonly PreparedModelRuntimeInput[], catalogMode: PreparedModelRuntimeCatalogMode, @@ -302,13 +272,16 @@ export async function prepareWorkspaceBuildGroup( syntheticAuthProviderRefs: catalogMode === "static" ? listPreparedSyntheticAuthProviderRefs(preparedSyntheticAuthProviders) - : resolveRuntimeSyntheticAuthProviderRefs({ - config: input.config, - env, - index: pluginMetadataSnapshot.index, - registryDiagnostics: pluginMetadataSnapshot.registryDiagnostics, - ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), - }), + : scopeSyntheticAuthProviderRefs( + resolveRuntimeSyntheticAuthProviderRefs({ + config: input.config, + env, + index: pluginMetadataSnapshot.index, + registryDiagnostics: pluginMetadataSnapshot.registryDiagnostics, + ...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}), + }), + options.providerDiscoveryProviderIds, + ), ...(catalogMode === "static" ? { resolveSyntheticAuth: (provider: string) => diff --git a/src/agents/prepared-model-runtime.synthetic-auth.ts b/src/agents/prepared-model-runtime.synthetic-auth.ts new file mode 100644 index 000000000000..a3a9e4a30d05 --- /dev/null +++ b/src/agents/prepared-model-runtime.synthetic-auth.ts @@ -0,0 +1,53 @@ +/** Synthetic-auth provider ref selection and prepared-catalog resolution for model-runtime builds. */ +import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { ProviderPlugin } from "../plugins/types.js"; + +// Provider-scoped live builds must not fan ambient synthetic-auth discovery out to every +// registered provider; each unscoped ref can force a full plugin module load on the read path. +export function scopeSyntheticAuthProviderRefs( + refs: readonly string[], + providerDiscoveryProviderIds: readonly string[] | undefined, +): string[] { + if (!providerDiscoveryProviderIds) { + return [...refs]; + } + const scoped = new Set(providerDiscoveryProviderIds.map((id) => normalizeProviderId(id))); + return refs.filter((ref) => scoped.has(normalizeProviderId(ref))); +} + +export function listPreparedSyntheticAuthProviderRefs( + providers: readonly ProviderPlugin[], +): string[] { + return [ + ...new Set( + providers.flatMap((provider) => + typeof provider.resolveSyntheticAuth === "function" + ? [provider.id, ...(provider.aliases ?? []), ...(provider.hookAliases ?? [])] + : [], + ), + ), + ].toSorted((left, right) => left.localeCompare(right)); +} + +export function resolvePreparedSyntheticAuth(params: { + config: OpenClawConfig; + provider: string; + providers: readonly ProviderPlugin[]; +}): { apiKey?: string } | undefined { + const normalizedProvider = normalizeProviderId(params.provider); + const providerPlugin = params.providers.find((candidate) => + [candidate.id, ...(candidate.aliases ?? []), ...(candidate.hookAliases ?? [])].some( + (ref) => normalizeProviderId(ref) === normalizedProvider, + ), + ); + return ( + providerPlugin?.resolveSyntheticAuth?.({ + config: params.config, + provider: params.provider, + providerConfig: Object.entries(params.config.models?.providers ?? {}).find( + ([providerId]) => normalizeProviderId(providerId) === normalizedProvider, + )?.[1], + }) ?? undefined + ); +} diff --git a/src/auto-reply/reply.directive.directive-behavior.e2e-mocks.ts b/src/auto-reply/reply.directive.directive-behavior.e2e-mocks.ts index 0eefd7c3ea9f..5ba4fbdc5429 100644 --- a/src/auto-reply/reply.directive.directive-behavior.e2e-mocks.ts +++ b/src/auto-reply/reply.directive.directive-behavior.e2e-mocks.ts @@ -105,6 +105,7 @@ vi.mock("../agents/embedded-agent.runtime.js", () => ({ })); vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalog: loadModelCatalogMock, })); diff --git a/src/auto-reply/reply.test-harness.ts b/src/auto-reply/reply.test-harness.ts index 98633c99014b..66b26235e927 100644 --- a/src/auto-reply/reply.test-harness.ts +++ b/src/auto-reply/reply.test-harness.ts @@ -33,6 +33,7 @@ vi.mock("../agents/embedded-agent.js", () => ({ })); vi.mock("../agents/model-catalog.runtime.js", () => ({ + loadProviderScopedThinkingCatalog: async () => [], loadPreparedModelCatalog: (...args: unknown[]) => replyRuntimeMockState.mocks.loadModelCatalog(...args), })); diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-models.test.ts index 56c0a5b02778..357840155a5c 100644 --- a/src/auto-reply/reply/commands-models.test.ts +++ b/src/auto-reply/reply/commands-models.test.ts @@ -112,6 +112,7 @@ function setFastModelsCliBackendDeps(): void { } vi.mock("../../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalog: modelCatalogMocks.loadModelCatalog, loadPreparedModelCatalogSnapshot: async (...args: unknown[]) => { const entries = await modelCatalogMocks.loadModelCatalog(...args); diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index fbb682a7f25b..805fcdb60458 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -356,6 +356,7 @@ vi.mock("../../agents/prepared-model-catalog.js", () => { ]); return { loadPreparedModelCatalog: loadModelCatalog, + loadProviderScopedThinkingCatalog: loadModelCatalog, loadPreparedModelCatalogSnapshot: async () => { const entries = await loadModelCatalog(); return { entries, routeVariants: entries }; diff --git a/src/auto-reply/reply/get-reply.config-override.test.ts b/src/auto-reply/reply/get-reply.config-override.test.ts index 4d784d56e898..0872ccd270c3 100644 --- a/src/auto-reply/reply/get-reply.config-override.test.ts +++ b/src/auto-reply/reply/get-reply.config-override.test.ts @@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({ registerGetReplyRuntimeOverrides(mocks); vi.mock("../../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadResolvedPublishedModelCatalogOwner: mocks.loadResolvedPublishedModelCatalogOwner, })); diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index 91ab577d016a..62ed93634a87 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -65,6 +65,7 @@ vi.mock("./commands-status.js", () => ({ })); vi.mock("../../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalog: mocks.loadModelCatalog, })); diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index f255049d592d..cbc5fec1aa90 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -45,6 +45,7 @@ const catalogRuntimeMocks = vi.hoisted(() => { vi.mock("../../agents/model-catalog.runtime.js", () => ({ loadManifestModelCatalog: vi.fn(() => []), + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalog: catalogRuntimeMocks.loadModelCatalog, loadPreparedModelCatalogSnapshot: catalogRuntimeMocks.loadModelCatalogSnapshot, })); diff --git a/src/auto-reply/reply/session-reset-model.test.ts b/src/auto-reply/reply/session-reset-model.test.ts index f78ef16368ce..19d6884b21f0 100644 --- a/src/auto-reply/reply/session-reset-model.test.ts +++ b/src/auto-reply/reply/session-reset-model.test.ts @@ -13,6 +13,7 @@ import type { ModelAliasIndex } from "./model-selection-directive.js"; const loadPreparedModelCatalog = vi.hoisted(() => vi.fn(async () => modelCatalog)); vi.mock("../../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalog, })); diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index dde709958e08..deb37ef69a3a 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -134,6 +134,7 @@ vi.mock("../../agents/session-write-lock.js", async () => { }); vi.mock("../../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalog: vi.fn(async () => [ { provider: "minimax", id: "m2.7", name: "M2.7" }, { provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" }, diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index b89bbc35eacf..190f9ea2c8fc 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -284,6 +284,7 @@ vi.mock("../agents/agent-scope.js", () => ({ })); vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalog: mocks.loadModelCatalog as typeof import("../agents/prepared-model-catalog.js").loadPreparedModelCatalog, })); diff --git a/src/commands/agent-command.test-mocks.ts b/src/commands/agent-command.test-mocks.ts index b1dc468068b3..f907053eff5e 100644 --- a/src/commands/agent-command.test-mocks.ts +++ b/src/commands/agent-command.test-mocks.ts @@ -67,6 +67,7 @@ vi.mock("../agents/model-catalog.js", () => ({ })); vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalog: vi.fn(), loadPreparedModelCatalogSnapshot: vi.fn(async () => ({ entries: [], diff --git a/src/commands/model-picker.test.ts b/src/commands/model-picker.test.ts index 251d35f0de65..f824e0af9d11 100644 --- a/src/commands/model-picker.test.ts +++ b/src/commands/model-picker.test.ts @@ -21,6 +21,7 @@ const modelCatalogRouteVariants = vi.hoisted(() => ({ value: undefined as readonly ModelCatalogEntry[] | undefined, })); vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalogSnapshot: async (...args: unknown[]) => { const entries = await loadModelCatalog(...args); return { entries, routeVariants: modelCatalogRouteVariants.value ?? entries }; diff --git a/src/commands/models.list.e2e.test.ts b/src/commands/models.list.e2e.test.ts index bd312d599ba1..23192f9603c2 100644 --- a/src/commands/models.list.e2e.test.ts +++ b/src/commands/models.list.e2e.test.ts @@ -136,6 +136,7 @@ vi.mock("../agents/model-auth.js", async (importOriginal) => { }); vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalog: loadModelCatalog, loadPreparedModelCatalogOwnerSnapshot: async (params: { agentDir?: string; config?: object }) => { const entries = await loadModelCatalog(params); diff --git a/src/commands/models/list.configured.test.ts b/src/commands/models/list.configured.test.ts index 1687bbf48ce4..77e599391939 100644 --- a/src/commands/models/list.configured.test.ts +++ b/src/commands/models/list.configured.test.ts @@ -26,6 +26,7 @@ vi.mock("../../plugins/current-plugin-metadata-snapshot.js", () => ({ })); vi.mock("../../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalogSnapshot: mocks.loadPreparedModelCatalogSnapshot, })); diff --git a/src/commands/models/list.probe.targets.test.ts b/src/commands/models/list.probe.targets.test.ts index 0a09a9436d84..7e0a56183449 100644 --- a/src/commands/models/list.probe.targets.test.ts +++ b/src/commands/models/list.probe.targets.test.ts @@ -20,6 +20,7 @@ const resolveAuthProfileEligibilityMock = vi.fn< const resolveSecretRefStringMock = vi.fn(async () => "resolved-secret"); vi.mock("../../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalog: loadModelCatalogMock, })); vi.mock("../../agents/model-auth.js", () => ({ diff --git a/src/commands/models/list.rows.test.ts b/src/commands/models/list.rows.test.ts index 50c60dcdbf59..8fa2a4db789f 100644 --- a/src/commands/models/list.rows.test.ts +++ b/src/commands/models/list.rows.test.ts @@ -20,6 +20,7 @@ vi.mock("../../agents/model-suppression.js", () => ({ })); vi.mock("../../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalogSnapshot: mocks.loadModelCatalogSnapshot, })); diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index 5dba576987b2..cf226144f994 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -301,6 +301,7 @@ vi.mock("../../cli/update-cli/plugin-payload-validation.js", () => ({ runPluginPayloadSmokeCheckForManifestRecords: mocks.runPluginPayloadSmokeCheckForManifestRecords, })); vi.mock("../../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalogSnapshot: async (...args: unknown[]) => { const entries = await mocks.loadModelCatalog(...args); return { entries, routeVariants: mocks.modelCatalogRouteVariants ?? entries }; diff --git a/src/cron/isolated-agent.mocks.ts b/src/cron/isolated-agent.mocks.ts index 8e33a75439f3..a24b99858c9b 100644 --- a/src/cron/isolated-agent.mocks.ts +++ b/src/cron/isolated-agent.mocks.ts @@ -21,6 +21,9 @@ vi.mock("../agents/prepared-model-catalog.js", async () => { entries: (await loadPreparedModelCatalog(params)) ?? [], routeVariants: [], })), + loadProviderScopedThinkingCatalog: vi.fn( + async (params) => (await loadPreparedModelCatalog(params)) ?? [], + ), loadPublishedPreparedModelCatalog: loadPreparedModelCatalog, publishedModelCatalogOwnerMatchesAgent: (owner: { agentId: string }, agentId: string) => owner.agentId === agentId.trim().toLowerCase(), diff --git a/src/cron/isolated-agent/model-selection.scoped-thinking.test.ts b/src/cron/isolated-agent/model-selection.scoped-thinking.test.ts new file mode 100644 index 000000000000..9a9ff3ceda2e --- /dev/null +++ b/src/cron/isolated-agent/model-selection.scoped-thinking.test.ts @@ -0,0 +1,70 @@ +// Cron turns must hydrate runtime-only model thinking through the provider-scoped helper, +// never through a full live catalog build. +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const scopedThinkingCatalogMock = vi.fn( + async (..._args: unknown[]): Promise>> => [], +); + +vi.mock("./run-model-selection.runtime.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadProviderScopedThinkingCatalog: (...args: unknown[]) => scopedThinkingCatalogMock(...args), + }; +}); + +const owner = { + agentId: "main", + agentDir: "/tmp/cron-agent", + workspaceDir: "/tmp/cron-workspace", + config: {}, + modelCatalog: { entries: [], routeVariants: [] }, +} as never; + +describe("resolveCronThinkingSelection scoped hydration", () => { + beforeEach(() => { + vi.clearAllMocks(); + scopedThinkingCatalogMock.mockResolvedValue([]); + }); + + it("hydrates a runtime-only model through the provider-scoped helper", async () => { + scopedThinkingCatalogMock.mockResolvedValue([ + { provider: "ollama", id: "minimax-m3:cloud", reasoning: true }, + ]); + const { resolveCronThinkingSelection } = await import("./model-selection.js"); + const selection = await resolveCronThinkingSelection({ + cfg: {}, + owner, + provider: "ollama", + model: "minimax-m3:cloud", + jobThinking: "medium", + }); + expect(selection.requestedThinkLevel).toBe("medium"); + expect(selection.catalog).toEqual([ + expect.objectContaining({ provider: "ollama", id: "minimax-m3:cloud", reasoning: true }), + ]); + expect(scopedThinkingCatalogMock).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "ollama", + model: "minimax-m3:cloud", + agentId: "main", + agentDir: "/tmp/cron-agent", + workspaceDir: "/tmp/cron-workspace", + }), + ); + }); + + it("keeps the owner catalog and skips hydration when thinking is off", async () => { + const { resolveCronThinkingSelection } = await import("./model-selection.js"); + const selection = await resolveCronThinkingSelection({ + cfg: {}, + owner, + provider: "ollama", + model: "minimax-m3:cloud", + jobThinking: "off", + }); + expect(selection.requestedThinkLevel).toBe("off"); + expect(scopedThinkingCatalogMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cron/isolated-agent/model-selection.ts b/src/cron/isolated-agent/model-selection.ts index af985bd26f45..48a350aa718d 100644 --- a/src/cron/isolated-agent/model-selection.ts +++ b/src/cron/isolated-agent/model-selection.ts @@ -16,7 +16,7 @@ import { DEFAULT_PROVIDER, getModelRefStatus, loadResolvedPublishedModelCatalogOwner, - loadPreparedModelCatalogSnapshot, + loadProviderScopedThinkingCatalog, normalizeModelSelection, publishedModelCatalogOwnerMatchesAgent, resolveAgentConfig, @@ -125,15 +125,16 @@ async function resolveCronThinkingCatalog(params: { ) { return catalog; } + // Thinking capability is a per-model fact; never materialize the full live catalog on cron turns. return normalizeThinkingCatalogProviders( - ( - await loadPreparedModelCatalogSnapshot({ - config: params.owner.config, - agentId: params.owner.agentId, - agentDir: params.owner.agentDir, - workspaceDir: params.owner.workspaceDir, - }) - ).entries, + await loadProviderScopedThinkingCatalog({ + config: params.owner.config, + provider: params.provider, + model: params.model, + agentId: params.owner.agentId, + agentDir: params.owner.agentDir, + workspaceDir: params.owner.workspaceDir, + }), ); } diff --git a/src/cron/isolated-agent/run-model-selection.runtime.ts b/src/cron/isolated-agent/run-model-selection.runtime.ts index 33457ef300e4..281fee5a9a03 100644 --- a/src/cron/isolated-agent/run-model-selection.runtime.ts +++ b/src/cron/isolated-agent/run-model-selection.runtime.ts @@ -4,7 +4,7 @@ export { resolveSubagentModelConfigSelectionResult } from "../../agents/agent-sc export { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../../agents/defaults.js"; export { publishedModelCatalogOwnerMatchesAgent } from "../../agents/prepared-model-catalog-owner.js"; export { - loadPreparedModelCatalogSnapshot, + loadProviderScopedThinkingCatalog, loadResolvedPublishedModelCatalogOwner, } from "../../agents/prepared-model-catalog.js"; export type { ResolvedPublishedModelCatalogOwner } from "../../agents/prepared-model-catalog.types.js"; diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index 06a80edcda60..014451c685d4 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -238,6 +238,7 @@ vi.mock("./run-model-selection.runtime.js", () => ({ entries: await loadModelCatalogMock(params), routeVariants: [], }), + loadProviderScopedThinkingCatalog: async (params: unknown) => await loadModelCatalogMock(params), loadResolvedPublishedModelCatalogOwner: loadModelCatalogOwnerMock, publishedModelCatalogOwnerMatchesAgent: (owner: { agentId: string }, agentId: string) => owner.agentId === agentId.trim().toLowerCase(), diff --git a/src/flows/doctor-core-checks.runtime-errors.test.ts b/src/flows/doctor-core-checks.runtime-errors.test.ts index 5c273f3e1226..52d841dac094 100644 --- a/src/flows/doctor-core-checks.runtime-errors.test.ts +++ b/src/flows/doctor-core-checks.runtime-errors.test.ts @@ -21,6 +21,7 @@ vi.mock("../agents/model-catalog.js", () => ({ })); vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalog: mocks.loadModelCatalog, })); diff --git a/src/flows/doctor-core-checks.runtime.test.ts b/src/flows/doctor-core-checks.runtime.test.ts index 2cccf6230d9e..70880f07ab1d 100644 --- a/src/flows/doctor-core-checks.runtime.test.ts +++ b/src/flows/doctor-core-checks.runtime.test.ts @@ -27,6 +27,7 @@ vi.mock("../agents/model-catalog.js", () => ({ })); vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalog: mocks.loadModelCatalog, })); diff --git a/src/flows/doctor-core-checks.test.ts b/src/flows/doctor-core-checks.test.ts index fa3e7141fb8a..631641883f08 100644 --- a/src/flows/doctor-core-checks.test.ts +++ b/src/flows/doctor-core-checks.test.ts @@ -37,6 +37,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalog: mocks.loadModelCatalog, })); diff --git a/src/flows/doctor-health-contributions.test.ts b/src/flows/doctor-health-contributions.test.ts index ce8f986d7f67..b24f41e0c568 100644 --- a/src/flows/doctor-health-contributions.test.ts +++ b/src/flows/doctor-health-contributions.test.ts @@ -394,6 +394,7 @@ vi.mock("../agents/model-catalog.js", () => ({ })); vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalog: mocks.loadModelCatalog, })); diff --git a/src/flows/model-picker.provider-catalog.test.ts b/src/flows/model-picker.provider-catalog.test.ts index 89cb5a4a1d2a..987e0223ea0c 100644 --- a/src/flows/model-picker.provider-catalog.test.ts +++ b/src/flows/model-picker.provider-catalog.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ loadPreparedModelCatalogOwnerSnapshot: vi.fn() })); vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalogOwnerSnapshot: mocks.loadPreparedModelCatalogOwnerSnapshot, })); diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index 318e767ec3d4..4f3d0e698059 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -197,6 +197,7 @@ vi.mock("../infra/update-startup.js", () => ({ })); vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalog: hoisted.loadModelCatalog, })); diff --git a/src/media-understanding/runner.vision-skip.test.ts b/src/media-understanding/runner.vision-skip.test.ts index f1e999f97db9..7d3b8c5f069b 100644 --- a/src/media-understanding/runner.vision-skip.test.ts +++ b/src/media-understanding/runner.vision-skip.test.ts @@ -60,6 +60,7 @@ vi.mock("../agents/model-catalog.js", async () => { }); vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalog: loadModelCatalog, })); diff --git a/src/plugin-sdk/agent-runtime-model-catalog-contract.test.ts b/src/plugin-sdk/agent-runtime-model-catalog-contract.test.ts index 25d3dcf91d87..95ae6b660d8e 100644 --- a/src/plugin-sdk/agent-runtime-model-catalog-contract.test.ts +++ b/src/plugin-sdk/agent-runtime-model-catalog-contract.test.ts @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), getPreparedModelCatalogSnapshot: (...args: unknown[]) => mocks.getSnapshot(...args), loadPreparedModelCatalog: (...args: unknown[]) => mocks.loadCatalog(...args), })); diff --git a/src/plugins/provider-runtime.ts b/src/plugins/provider-runtime.ts index defc24d413d8..d402cfc90b47 100644 --- a/src/plugins/provider-runtime.ts +++ b/src/plugins/provider-runtime.ts @@ -1038,10 +1038,15 @@ export function resolveProviderSyntheticAuthWithPlugin(params: { } } if (providerRefs.length === 1) { + // Last-resort match for custom provider ids with no resolvable owning plugin (e.g. Ollama + // aliases). Entry modules only: a full plugin-runtime sweep here costs seconds per ref on + // source checkouts and belongs to explicit control-plane loads. return resolvePluginDiscoveryProvidersRuntime({ config: params.config, workspaceDir: params.workspaceDir, env: params.env, + discoveryEntriesOnly: true, + includeSyntheticAuthProviders: true, }) .find((provider) => matchesAnyProviderPluginRef(provider, providerRefs)) ?.resolveSyntheticAuth?.(params.context); diff --git a/src/skills/loading/plugin-skills.ts b/src/skills/loading/plugin-skills.ts index 73407cec42ae..34aa691c7753 100644 --- a/src/skills/loading/plugin-skills.ts +++ b/src/skills/loading/plugin-skills.ts @@ -11,6 +11,7 @@ import { resolveEffectivePluginActivationState, resolveMemorySlotDecision, } from "../../plugins/config-policy.js"; +import { registerPluginMetadataProcessMemoLifecycleClear } from "../../plugins/plugin-metadata-lifecycle.js"; import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; import { hasKind } from "../../plugins/slots.js"; import { isPathInsideWithRealpath } from "../../security/scan-paths.js"; @@ -20,6 +21,20 @@ const log = createSubsystemLogger("skills"); type PluginSkillLinkType = "dir" | "junction"; +// Plugin metadata is process-stable while the gateway runs, but this resolver sits on the +// per-turn skills-refresh path. The single-slot memo keeps repeat turns from re-walking and +// re-publishing every plugin skill dir; lifecycle clears evict it on plugin reload/install. +let pluginSkillDirsMemo: { + workspaceDir: string; + config: OpenClawConfig | undefined; + snapshot: unknown; + dirs: string[]; +} | null = null; + +registerPluginMetadataProcessMemoLifecycleClear(() => { + pluginSkillDirsMemo = null; +}); + export function resolvePluginSkillDirs(params: { workspaceDir: string | undefined; config?: OpenClawConfig; @@ -40,6 +55,16 @@ export function resolvePluginSkillDirs(params: { env: process.env, allowWorkspaceScopedCurrent: true, }); + const canMemoize = params.pluginSkillsDir === undefined; + if ( + canMemoize && + pluginSkillDirsMemo && + pluginSkillDirsMemo.workspaceDir === workspaceDir && + pluginSkillDirsMemo.config === params.config && + pluginSkillDirsMemo.snapshot === metadataSnapshot + ) { + return pluginSkillDirsMemo.dirs; + } const registry = metadataSnapshot.manifestRegistry; if (registry.plugins.length === 0) { publishPluginSkills([], { @@ -117,6 +142,14 @@ export function resolvePluginSkillDirs(params: { pluginSkillsDir: params.pluginSkillsDir, }); + if (canMemoize) { + pluginSkillDirsMemo = { + workspaceDir, + config: params.config, + snapshot: metadataSnapshot, + dirs: resolved, + }; + } return resolved; } diff --git a/src/system-agent/tui-backend.test.ts b/src/system-agent/tui-backend.test.ts index 6a03510d58ec..f7f4ed788ffa 100644 --- a/src/system-agent/tui-backend.test.ts +++ b/src/system-agent/tui-backend.test.ts @@ -20,6 +20,7 @@ vi.mock("../plugins/providers.js", () => ({ })); vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), // These tests exercise the TUI boundary, not filesystem-backed catalog discovery. getPreparedModelCatalogSnapshot: vi.fn(() => undefined), loadPreparedModelCatalog: vi.fn(async () => []), diff --git a/src/wizard/setup.finalize.test.ts b/src/wizard/setup.finalize.test.ts index 33d3e24bc6be..81ad8108fe75 100644 --- a/src/wizard/setup.finalize.test.ts +++ b/src/wizard/setup.finalize.test.ts @@ -269,6 +269,7 @@ vi.mock("../commands/auth-choice.js", () => ({ })); vi.mock("../agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalogSnapshot: async (...args: unknown[]) => { const entries = await loadModelCatalog(...args); return { entries, routeVariants: entries }; diff --git a/test/e2e/qa-lab/media/vision-routing-core.e2e.test.ts b/test/e2e/qa-lab/media/vision-routing-core.e2e.test.ts index 3da654eb438b..18037260d15e 100644 --- a/test/e2e/qa-lab/media/vision-routing-core.e2e.test.ts +++ b/test/e2e/qa-lab/media/vision-routing-core.e2e.test.ts @@ -28,6 +28,7 @@ const preparedVisionCatalog = vi.hoisted(() => [ ]); vi.mock("../../../../src/agents/prepared-model-catalog.js", () => ({ + loadProviderScopedThinkingCatalog: vi.fn(async () => []), loadPreparedModelCatalog: vi.fn(async () => preparedVisionCatalog), })); diff --git a/test/helpers/auto-reply/trigger-handling-test-harness.ts b/test/helpers/auto-reply/trigger-handling-test-harness.ts index e878a60488d4..9b8a2417c5a4 100644 --- a/test/helpers/auto-reply/trigger-handling-test-harness.ts +++ b/test/helpers/auto-reply/trigger-handling-test-harness.ts @@ -145,6 +145,8 @@ vi.doMock("../../../src/agents/model-catalog.runtime.js", () => ({ const entries = await modelCatalogMocks.loadPreparedModelCatalog(...args); return { entries, routeVariants: entries, authoritative: true }; }, + loadProviderScopedThinkingCatalog: async (...args: unknown[]) => + await modelCatalogMocks.loadPreparedModelCatalog(...args), })); vi.doMock("../../../src/plugins/provider-runtime.runtime.js", () => ({