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
This commit is contained in:
Peter Steinberger
2026-08-08 22:48:40 -07:00
committed by GitHub
parent 562129d5df
commit 7a8eee4a36
50 changed files with 422 additions and 69 deletions
@@ -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: [],
@@ -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<ModelCatalogSnapshot> => ({
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",
@@ -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: [],
+9 -8
View File
@@ -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,
+1
View File
@@ -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,
+1
View File
@@ -3,4 +3,5 @@ export { loadManifestModelCatalog } from "./model-catalog.js";
export {
loadPreparedModelCatalog,
loadPreparedModelCatalogSnapshot,
loadProviderScopedThinkingCatalog,
} from "./prepared-model-catalog.js";
+1
View File
@@ -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 }
@@ -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,
@@ -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,
}));
@@ -200,6 +200,7 @@ function createConfigModuleMock() {
function createModelCatalogModuleMock() {
return {
loadProviderScopedThinkingCatalog: async () => [],
loadPreparedModelCatalog: async () => [
{
provider: "anthropic",
@@ -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<Record<string, unknown>> => []);
const scopedStaticMock = vi.fn(
async (..._args: unknown[]): Promise<Record<string, unknown>> => ({
entries: [],
routeVariants: [],
}),
);
const scopedLiveMock = vi.fn(
async (..._args: unknown[]): Promise<Record<string, unknown>> => ({
entries: [],
routeVariants: [],
}),
);
vi.mock("./model-catalog.js", () => ({
loadManifestModelCatalog: (...args: unknown[]) => manifestCatalogMock(...args),
}));
vi.mock("./prepared-model-runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./prepared-model-runtime.js")>();
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);
});
});
+63 -4
View File
@@ -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<ModelCatalogEntry[]> {
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,
);
}
+15 -42
View File
@@ -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) =>
@@ -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
);
}
@@ -105,6 +105,7 @@ vi.mock("../agents/embedded-agent.runtime.js", () => ({
}));
vi.mock("../agents/prepared-model-catalog.js", () => ({
loadProviderScopedThinkingCatalog: vi.fn(async () => []),
loadPreparedModelCatalog: loadModelCatalogMock,
}));
+1
View File
@@ -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),
}));
@@ -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);
@@ -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 };
@@ -18,6 +18,7 @@ const mocks = vi.hoisted(() => ({
registerGetReplyRuntimeOverrides(mocks);
vi.mock("../../agents/prepared-model-catalog.js", () => ({
loadProviderScopedThinkingCatalog: vi.fn(async () => []),
loadResolvedPublishedModelCatalogOwner: mocks.loadResolvedPublishedModelCatalogOwner,
}));
@@ -65,6 +65,7 @@ vi.mock("./commands-status.js", () => ({
}));
vi.mock("../../agents/prepared-model-catalog.js", () => ({
loadProviderScopedThinkingCatalog: vi.fn(async () => []),
loadPreparedModelCatalog: mocks.loadModelCatalog,
}));
@@ -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,
}));
@@ -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,
}));
+1
View File
@@ -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" },
+1
View File
@@ -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,
}));
+1
View File
@@ -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: [],
+1
View File
@@ -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 };
+1
View File
@@ -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);
@@ -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,
}));
@@ -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", () => ({
+1
View File
@@ -20,6 +20,7 @@ vi.mock("../../agents/model-suppression.js", () => ({
}));
vi.mock("../../agents/prepared-model-catalog.js", () => ({
loadProviderScopedThinkingCatalog: vi.fn(async () => []),
loadPreparedModelCatalogSnapshot: mocks.loadModelCatalogSnapshot,
}));
+1
View File
@@ -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 };
+3
View File
@@ -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(),
@@ -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<Array<Record<string, unknown>>> => [],
);
vi.mock("./run-model-selection.runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./run-model-selection.runtime.js")>();
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();
});
});
+10 -9
View File
@@ -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,
}),
);
}
@@ -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";
@@ -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(),
@@ -21,6 +21,7 @@ vi.mock("../agents/model-catalog.js", () => ({
}));
vi.mock("../agents/prepared-model-catalog.js", () => ({
loadProviderScopedThinkingCatalog: vi.fn(async () => []),
loadPreparedModelCatalog: mocks.loadModelCatalog,
}));
@@ -27,6 +27,7 @@ vi.mock("../agents/model-catalog.js", () => ({
}));
vi.mock("../agents/prepared-model-catalog.js", () => ({
loadProviderScopedThinkingCatalog: vi.fn(async () => []),
loadPreparedModelCatalog: mocks.loadModelCatalog,
}));
+1
View File
@@ -37,6 +37,7 @@ const mocks = vi.hoisted(() => ({
}));
vi.mock("../agents/prepared-model-catalog.js", () => ({
loadProviderScopedThinkingCatalog: vi.fn(async () => []),
loadPreparedModelCatalog: mocks.loadModelCatalog,
}));
@@ -394,6 +394,7 @@ vi.mock("../agents/model-catalog.js", () => ({
}));
vi.mock("../agents/prepared-model-catalog.js", () => ({
loadProviderScopedThinkingCatalog: vi.fn(async () => []),
loadPreparedModelCatalog: mocks.loadModelCatalog,
}));
@@ -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,
}));
@@ -197,6 +197,7 @@ vi.mock("../infra/update-startup.js", () => ({
}));
vi.mock("../agents/prepared-model-catalog.js", () => ({
loadProviderScopedThinkingCatalog: vi.fn(async () => []),
loadPreparedModelCatalog: hoisted.loadModelCatalog,
}));
@@ -60,6 +60,7 @@ vi.mock("../agents/model-catalog.js", async () => {
});
vi.mock("../agents/prepared-model-catalog.js", () => ({
loadProviderScopedThinkingCatalog: vi.fn(async () => []),
loadPreparedModelCatalog: loadModelCatalog,
}));
@@ -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),
}));
+5
View File
@@ -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);
+33
View File
@@ -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;
}
+1
View File
@@ -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 () => []),
+1
View File
@@ -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 };
@@ -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),
}));
@@ -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", () => ({