From f8639d3429b60f3fd6aef394e97d5bbfac7cc206 Mon Sep 17 00:00:00 2001 From: Shakker Date: Fri, 1 May 2026 20:21:16 +0100 Subject: [PATCH] perf: use manifest catalog for agent allowlists --- CHANGELOG.md | 1 + .../agent-command.live-model-switch.test.ts | 4 +- src/agents/agent-command.ts | 8 +-- src/agents/model-catalog.test.ts | 71 +++++++++++++++++++ src/agents/model-catalog.ts | 43 +++++++++++ 5 files changed, 122 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83d981eaac3..a3c997e6217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,6 +161,7 @@ Docs: https://docs.openclaw.ai - Agents/tools: skip unavailable media generation and PDF tool factories from the live reply path when Gateway metadata and the active auth store prove no configured provider can back them, while keeping explicit config and auth-backed providers on the normal factory path. Thanks @shakkernerd. - Agents/runtime: reuse the Gateway metadata startup plan when ensuring reply runtime plugins are loaded, so live agent turns do not broad-load plugin runtimes after the Gateway already scoped startup activation. Thanks @shakkernerd. +- Agents/runtime: validate agent model allowlists against manifest model catalog metadata during reply startup, avoiding broad provider runtime catalog loading before the agent run lane starts. Thanks @shakkernerd. - Agents/tools: route media and generation capability lookups through the Gateway plugin metadata snapshot during reply tool registration, avoiding repeated manifest registry reloads on the live reply path. Thanks @shakkernerd. - Agents/tools: reuse the auth profile store already loaded for the active run when deciding media and generation tool availability, avoiding repeated provider-auth runtime discovery during reply startup. Thanks @shakkernerd. - Agents/tools: keep image, video, and music generation tool registration on manifest/auth control-plane checks instead of loading runtime provider registries during reply startup, reducing live-path tool-prep blocking while leaving provider runtime resolution for execution and list actions. Thanks @shakkernerd. diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index 38ad3238603..12135a20a7a 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -36,6 +36,7 @@ const state = vi.hoisted(() => ({ clearSessionAuthProfileOverrideMock: vi.fn(), isThinkingLevelSupportedMock: vi.fn((_args: unknown) => true), resolveThinkingDefaultMock: vi.fn((_args: unknown) => "low"), + loadManifestModelCatalogMock: vi.fn(() => []), authProfileStoreMock: { profiles: {} } as { profiles: Record }, sessionEntryMock: undefined as unknown, sessionStoreMock: undefined as unknown, @@ -290,7 +291,7 @@ vi.mock("./lanes.js", () => ({ })); vi.mock("./model-catalog.js", () => ({ - loadModelCatalog: async () => [], + loadManifestModelCatalog: (...args: unknown[]) => state.loadManifestModelCatalogMock(...args), })); vi.mock("./model-selection.js", () => ({ @@ -480,6 +481,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { state.runtimeConfigMock = undefined; state.isThinkingLevelSupportedMock.mockReturnValue(true); state.resolveThinkingDefaultMock.mockReturnValue("low"); + state.loadManifestModelCatalogMock.mockReturnValue([]); state.acpRunTurnMock.mockImplementation(async (params: unknown) => { const onEvent = (params as { onEvent?: (event: unknown) => void }).onEvent; onEvent?.({ type: "text_delta", stream: "output", text: "done" }); diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 20db2876531..053a87fdcaf 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -51,7 +51,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js"; import { resolveFastModeState } from "./fast-mode.js"; import { AGENT_LANE_SUBAGENT } from "./lanes.js"; import { LiveSessionModelSwitchError } from "./live-model-switch.js"; -import { loadModelCatalog } from "./model-catalog.js"; +import { loadManifestModelCatalog } from "./model-catalog.js"; import { runWithModelFallback } from "./model-fallback.js"; import { buildAllowedModelSet, @@ -729,12 +729,12 @@ async function agentCommandInternal( } const needsModelCatalog = Boolean(hasAllowlist); let allowedModelKeys = new Set(); - let allowedModelCatalog: Awaited> = []; - let modelCatalog: Awaited> | null = null; + let allowedModelCatalog: ReturnType = []; + let modelCatalog: ReturnType | null = null; let allowAnyModel = !hasAllowlist; if (needsModelCatalog) { - modelCatalog = await loadModelCatalog({ config: cfg }); + modelCatalog = loadManifestModelCatalog({ config: cfg, workspaceDir }); const allowed = buildAllowedModelSet({ cfg, catalog: modelCatalog, diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index 4dbf81e4376..847ec223a2b 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -7,11 +7,14 @@ type PiSdkModule = typeof import("./pi-model-discovery.js"); let __setModelCatalogImportForTest: typeof import("./model-catalog.js").__setModelCatalogImportForTest; let findModelCatalogEntry: typeof import("./model-catalog.js").findModelCatalogEntry; let findModelInCatalog: typeof import("./model-catalog.js").findModelInCatalog; +let loadManifestModelCatalog: typeof import("./model-catalog.js").loadManifestModelCatalog; let loadModelCatalog: typeof import("./model-catalog.js").loadModelCatalog; let modelSupportsInput: typeof import("./model-catalog.js").modelSupportsInput; let resetModelCatalogCacheForTest: typeof import("./model-catalog.js").resetModelCatalogCacheForTest; let augmentCatalogMock: ReturnType; let ensureOpenClawModelsJsonMock: ReturnType; +let currentPluginMetadataSnapshotMock: ReturnType; +let loadPluginMetadataSnapshotMock: ReturnType; vi.mock("./model-suppression.runtime.js", () => ({ shouldSuppressBuiltInModel: (params: { provider?: string; id?: string }) => @@ -77,11 +80,21 @@ describe("loadModelCatalog", () => { vi.doMock("../plugins/provider-runtime.runtime.js", () => ({ augmentModelCatalogWithProviderPlugins: vi.fn().mockResolvedValue([]), })); + currentPluginMetadataSnapshotMock = vi.fn(); + loadPluginMetadataSnapshotMock = vi.fn(); + vi.doMock("../plugins/current-plugin-metadata-snapshot.js", () => ({ + getCurrentPluginMetadataSnapshot: (...args: unknown[]) => + currentPluginMetadataSnapshotMock(...args), + })); + vi.doMock("../plugins/plugin-metadata-snapshot.js", () => ({ + loadPluginMetadataSnapshot: (...args: unknown[]) => loadPluginMetadataSnapshotMock(...args), + })); ({ __setModelCatalogImportForTest, findModelCatalogEntry, findModelInCatalog, + loadManifestModelCatalog, loadModelCatalog, modelSupportsInput, resetModelCatalogCacheForTest, @@ -93,6 +106,9 @@ describe("loadModelCatalog", () => { beforeEach(() => { resetModelCatalogCacheForTest(); ensureOpenClawModelsJsonMock.mockClear(); + augmentCatalogMock.mockClear(); + currentPluginMetadataSnapshotMock.mockReset(); + loadPluginMetadataSnapshotMock.mockReset(); }); afterEach(() => { @@ -105,6 +121,8 @@ describe("loadModelCatalog", () => { vi.doUnmock("./models-config.js"); vi.doUnmock("./agent-paths.js"); vi.doUnmock("../plugins/provider-runtime.runtime.js"); + vi.doUnmock("../plugins/current-plugin-metadata-snapshot.js"); + vi.doUnmock("../plugins/plugin-metadata-snapshot.js"); }); it("retries after import failure without poisoning the cache", async () => { @@ -367,6 +385,59 @@ describe("loadModelCatalog", () => { ); }); + it("loads manifest catalog rows from the current metadata snapshot without provider runtime", () => { + const snapshot = { + policyHash: "policy", + index: { + policyHash: "policy", + plugins: [ + { + pluginId: "external-provider", + enabled: true, + origin: "global", + }, + ], + }, + plugins: [ + { + id: "external-provider", + origin: "global", + modelCatalog: { + providers: { + external: { + models: [ + { + id: "external-fast", + name: "External Fast", + input: ["text", "image"], + reasoning: true, + contextWindow: 32000, + }, + ], + }, + }, + }, + }, + ], + }; + currentPluginMetadataSnapshotMock.mockReturnValue(snapshot); + + const result = loadManifestModelCatalog({ config: {} as OpenClawConfig }); + + expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled(); + expect(augmentCatalogMock).not.toHaveBeenCalled(); + expect(result).toEqual([ + { + provider: "external", + id: "external-fast", + name: "External Fast", + input: ["text", "image"], + reasoning: true, + contextWindow: 32000, + }, + ]); + }); + it("dedupes supplemental models against registry entries", async () => { mockSingleOpenAiCatalogModel(); augmentCatalogMock.mockResolvedValueOnce([ diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index fe576e58fe8..627ceeaa99f 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -2,6 +2,10 @@ import { join } from "node:path"; import { getRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { planManifestModelCatalogRows } from "../model-catalog/manifest-planner.js"; +import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; +import { isManifestPluginAvailableForControlPlane } from "../plugins/manifest-contract-eligibility.js"; +import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; import { augmentModelCatalogWithProviderPlugins } from "../plugins/provider-runtime.runtime.js"; import { normalizeLowercaseStringOrEmpty, @@ -105,6 +109,45 @@ function appendCatalogEntriesIfAbsent( } } +export function loadManifestModelCatalog(params: { + config: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): ModelCatalogEntry[] { + const snapshot = + getCurrentPluginMetadataSnapshot({ + config: params.config, + ...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), + }) ?? + loadPluginMetadataSnapshot({ + config: params.config, + ...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), + env: params.env ?? process.env, + }); + const eligiblePlugins = snapshot.plugins.filter( + (plugin) => + plugin.modelCatalog && + isManifestPluginAvailableForControlPlane({ + snapshot, + plugin, + config: params.config, + }), + ); + const plan = planManifestModelCatalogRows({ + registry: { plugins: eligiblePlugins }, + }); + return plan.rows.map((row) => ({ + id: row.id, + name: row.name, + provider: row.provider, + ...(row.contextWindow ? { contextWindow: row.contextWindow } : {}), + ...(row.contextTokens && !row.contextWindow ? { contextWindow: row.contextTokens } : {}), + ...(typeof row.reasoning === "boolean" ? { reasoning: row.reasoning } : {}), + ...(row.input?.length ? { input: [...row.input] } : {}), + ...(row.compat ? { compat: row.compat } : {}), + })); +} + export async function loadModelCatalog(params?: { config?: OpenClawConfig; useCache?: boolean;