Skip to content

Commit f8639d3

Browse files
committed
perf: use manifest catalog for agent allowlists
1 parent dfde770 commit f8639d3

5 files changed

Lines changed: 122 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ Docs: https://docs.openclaw.ai
161161

162162
- 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.
163163
- 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.
164+
- 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.
164165
- 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.
165166
- 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.
166167
- 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.

src/agents/agent-command.live-model-switch.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const state = vi.hoisted(() => ({
3636
clearSessionAuthProfileOverrideMock: vi.fn(),
3737
isThinkingLevelSupportedMock: vi.fn((_args: unknown) => true),
3838
resolveThinkingDefaultMock: vi.fn((_args: unknown) => "low"),
39+
loadManifestModelCatalogMock: vi.fn(() => []),
3940
authProfileStoreMock: { profiles: {} } as { profiles: Record<string, unknown> },
4041
sessionEntryMock: undefined as unknown,
4142
sessionStoreMock: undefined as unknown,
@@ -290,7 +291,7 @@ vi.mock("./lanes.js", () => ({
290291
}));
291292

292293
vi.mock("./model-catalog.js", () => ({
293-
loadModelCatalog: async () => [],
294+
loadManifestModelCatalog: (...args: unknown[]) => state.loadManifestModelCatalogMock(...args),
294295
}));
295296

296297
vi.mock("./model-selection.js", () => ({
@@ -480,6 +481,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
480481
state.runtimeConfigMock = undefined;
481482
state.isThinkingLevelSupportedMock.mockReturnValue(true);
482483
state.resolveThinkingDefaultMock.mockReturnValue("low");
484+
state.loadManifestModelCatalogMock.mockReturnValue([]);
483485
state.acpRunTurnMock.mockImplementation(async (params: unknown) => {
484486
const onEvent = (params as { onEvent?: (event: unknown) => void }).onEvent;
485487
onEvent?.({ type: "text_delta", stream: "output", text: "done" });

src/agents/agent-command.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
5151
import { resolveFastModeState } from "./fast-mode.js";
5252
import { AGENT_LANE_SUBAGENT } from "./lanes.js";
5353
import { LiveSessionModelSwitchError } from "./live-model-switch.js";
54-
import { loadModelCatalog } from "./model-catalog.js";
54+
import { loadManifestModelCatalog } from "./model-catalog.js";
5555
import { runWithModelFallback } from "./model-fallback.js";
5656
import {
5757
buildAllowedModelSet,
@@ -729,12 +729,12 @@ async function agentCommandInternal(
729729
}
730730
const needsModelCatalog = Boolean(hasAllowlist);
731731
let allowedModelKeys = new Set<string>();
732-
let allowedModelCatalog: Awaited<ReturnType<typeof loadModelCatalog>> = [];
733-
let modelCatalog: Awaited<ReturnType<typeof loadModelCatalog>> | null = null;
732+
let allowedModelCatalog: ReturnType<typeof loadManifestModelCatalog> = [];
733+
let modelCatalog: ReturnType<typeof loadManifestModelCatalog> | null = null;
734734
let allowAnyModel = !hasAllowlist;
735735

736736
if (needsModelCatalog) {
737-
modelCatalog = await loadModelCatalog({ config: cfg });
737+
modelCatalog = loadManifestModelCatalog({ config: cfg, workspaceDir });
738738
const allowed = buildAllowedModelSet({
739739
cfg,
740740
catalog: modelCatalog,

src/agents/model-catalog.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ type PiSdkModule = typeof import("./pi-model-discovery.js");
77
let __setModelCatalogImportForTest: typeof import("./model-catalog.js").__setModelCatalogImportForTest;
88
let findModelCatalogEntry: typeof import("./model-catalog.js").findModelCatalogEntry;
99
let findModelInCatalog: typeof import("./model-catalog.js").findModelInCatalog;
10+
let loadManifestModelCatalog: typeof import("./model-catalog.js").loadManifestModelCatalog;
1011
let loadModelCatalog: typeof import("./model-catalog.js").loadModelCatalog;
1112
let modelSupportsInput: typeof import("./model-catalog.js").modelSupportsInput;
1213
let resetModelCatalogCacheForTest: typeof import("./model-catalog.js").resetModelCatalogCacheForTest;
1314
let augmentCatalogMock: ReturnType<typeof vi.fn>;
1415
let ensureOpenClawModelsJsonMock: ReturnType<typeof vi.fn>;
16+
let currentPluginMetadataSnapshotMock: ReturnType<typeof vi.fn>;
17+
let loadPluginMetadataSnapshotMock: ReturnType<typeof vi.fn>;
1518

1619
vi.mock("./model-suppression.runtime.js", () => ({
1720
shouldSuppressBuiltInModel: (params: { provider?: string; id?: string }) =>
@@ -77,11 +80,21 @@ describe("loadModelCatalog", () => {
7780
vi.doMock("../plugins/provider-runtime.runtime.js", () => ({
7881
augmentModelCatalogWithProviderPlugins: vi.fn().mockResolvedValue([]),
7982
}));
83+
currentPluginMetadataSnapshotMock = vi.fn();
84+
loadPluginMetadataSnapshotMock = vi.fn();
85+
vi.doMock("../plugins/current-plugin-metadata-snapshot.js", () => ({
86+
getCurrentPluginMetadataSnapshot: (...args: unknown[]) =>
87+
currentPluginMetadataSnapshotMock(...args),
88+
}));
89+
vi.doMock("../plugins/plugin-metadata-snapshot.js", () => ({
90+
loadPluginMetadataSnapshot: (...args: unknown[]) => loadPluginMetadataSnapshotMock(...args),
91+
}));
8092

8193
({
8294
__setModelCatalogImportForTest,
8395
findModelCatalogEntry,
8496
findModelInCatalog,
97+
loadManifestModelCatalog,
8598
loadModelCatalog,
8699
modelSupportsInput,
87100
resetModelCatalogCacheForTest,
@@ -93,6 +106,9 @@ describe("loadModelCatalog", () => {
93106
beforeEach(() => {
94107
resetModelCatalogCacheForTest();
95108
ensureOpenClawModelsJsonMock.mockClear();
109+
augmentCatalogMock.mockClear();
110+
currentPluginMetadataSnapshotMock.mockReset();
111+
loadPluginMetadataSnapshotMock.mockReset();
96112
});
97113

98114
afterEach(() => {
@@ -105,6 +121,8 @@ describe("loadModelCatalog", () => {
105121
vi.doUnmock("./models-config.js");
106122
vi.doUnmock("./agent-paths.js");
107123
vi.doUnmock("../plugins/provider-runtime.runtime.js");
124+
vi.doUnmock("../plugins/current-plugin-metadata-snapshot.js");
125+
vi.doUnmock("../plugins/plugin-metadata-snapshot.js");
108126
});
109127

110128
it("retries after import failure without poisoning the cache", async () => {
@@ -367,6 +385,59 @@ describe("loadModelCatalog", () => {
367385
);
368386
});
369387

388+
it("loads manifest catalog rows from the current metadata snapshot without provider runtime", () => {
389+
const snapshot = {
390+
policyHash: "policy",
391+
index: {
392+
policyHash: "policy",
393+
plugins: [
394+
{
395+
pluginId: "external-provider",
396+
enabled: true,
397+
origin: "global",
398+
},
399+
],
400+
},
401+
plugins: [
402+
{
403+
id: "external-provider",
404+
origin: "global",
405+
modelCatalog: {
406+
providers: {
407+
external: {
408+
models: [
409+
{
410+
id: "external-fast",
411+
name: "External Fast",
412+
input: ["text", "image"],
413+
reasoning: true,
414+
contextWindow: 32000,
415+
},
416+
],
417+
},
418+
},
419+
},
420+
},
421+
],
422+
};
423+
currentPluginMetadataSnapshotMock.mockReturnValue(snapshot);
424+
425+
const result = loadManifestModelCatalog({ config: {} as OpenClawConfig });
426+
427+
expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled();
428+
expect(augmentCatalogMock).not.toHaveBeenCalled();
429+
expect(result).toEqual([
430+
{
431+
provider: "external",
432+
id: "external-fast",
433+
name: "External Fast",
434+
input: ["text", "image"],
435+
reasoning: true,
436+
contextWindow: 32000,
437+
},
438+
]);
439+
});
440+
370441
it("dedupes supplemental models against registry entries", async () => {
371442
mockSingleOpenAiCatalogModel();
372443
augmentCatalogMock.mockResolvedValueOnce([

src/agents/model-catalog.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { join } from "node:path";
22
import { getRuntimeConfig } from "../config/config.js";
33
import type { OpenClawConfig } from "../config/types.openclaw.js";
44
import { createSubsystemLogger } from "../logging/subsystem.js";
5+
import { planManifestModelCatalogRows } from "../model-catalog/manifest-planner.js";
6+
import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js";
7+
import { isManifestPluginAvailableForControlPlane } from "../plugins/manifest-contract-eligibility.js";
8+
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
59
import { augmentModelCatalogWithProviderPlugins } from "../plugins/provider-runtime.runtime.js";
610
import {
711
normalizeLowercaseStringOrEmpty,
@@ -105,6 +109,45 @@ function appendCatalogEntriesIfAbsent(
105109
}
106110
}
107111

112+
export function loadManifestModelCatalog(params: {
113+
config: OpenClawConfig;
114+
workspaceDir?: string;
115+
env?: NodeJS.ProcessEnv;
116+
}): ModelCatalogEntry[] {
117+
const snapshot =
118+
getCurrentPluginMetadataSnapshot({
119+
config: params.config,
120+
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
121+
}) ??
122+
loadPluginMetadataSnapshot({
123+
config: params.config,
124+
...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}),
125+
env: params.env ?? process.env,
126+
});
127+
const eligiblePlugins = snapshot.plugins.filter(
128+
(plugin) =>
129+
plugin.modelCatalog &&
130+
isManifestPluginAvailableForControlPlane({
131+
snapshot,
132+
plugin,
133+
config: params.config,
134+
}),
135+
);
136+
const plan = planManifestModelCatalogRows({
137+
registry: { plugins: eligiblePlugins },
138+
});
139+
return plan.rows.map((row) => ({
140+
id: row.id,
141+
name: row.name,
142+
provider: row.provider,
143+
...(row.contextWindow ? { contextWindow: row.contextWindow } : {}),
144+
...(row.contextTokens && !row.contextWindow ? { contextWindow: row.contextTokens } : {}),
145+
...(typeof row.reasoning === "boolean" ? { reasoning: row.reasoning } : {}),
146+
...(row.input?.length ? { input: [...row.input] } : {}),
147+
...(row.compat ? { compat: row.compat } : {}),
148+
}));
149+
}
150+
108151
export async function loadModelCatalog(params?: {
109152
config?: OpenClawConfig;
110153
useCache?: boolean;

0 commit comments

Comments
 (0)