fix(plugins): reduce startup provider registry reloads

This commit is contained in:
brokemac79
2026-04-29 00:02:59 +01:00
committed by Shakker
parent 71473e7448
commit f22ff47822
11 changed files with 124 additions and 3 deletions

View File

@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
- iMessage: normalize known leading attributedBody corruption markers on sent-message echo text keys so delayed reflected echoes with U+FFFD/U+FFFE/U+FFFF/FEFF prefixes are dropped without collapsing interior text. Fixes #59973; carries forward #59980 and #62191. Thanks @neeravmakwana and @maguilar631697.
- Security/audit: recognize dangerous node command IDs as valid `gateway.nodes.denyCommands` entries, so audit only warns on real typos or unsupported patterns. (#56923) Thanks @chziyue.
- Telegram/exec approvals: stop treating general Telegram chat allowlists and `defaultTo` routes as native exec approvers; Telegram now uses explicit `execApprovals.approvers` or owner identity from `commands.ownerAllowFrom`, matching the first-pairing owner bootstrap path. Thanks @pashpashpash.
- Plugins/providers: keep Gateway startup primary-model discovery on metadata-only provider entries and reuse active non-speech capability providers even with explicit plugin entries, avoiding unnecessary provider registry loads during startup and media capability checks. Fixes #73729, #73835, and #73793; carries forward #73853 and #73794. Thanks @sg1416-zg, @brokemac79, and @poolside-ventures.
- Chat commands: route sensitive group `/diagnostics` and `/export-trajectory` approvals and results to a private owner route, preferring same-surface DMs before falling back to the first configured owner route, so Discord group invocations can land in Telegram when that is the primary owner interface. Thanks @pashpashpash.
- Plugin SDK/Discord: restore a deprecated `openclaw/plugin-sdk/discord` compatibility facade and the legacy compat group-policy warning export for the published `@openclaw/discord@2026.3.13` package, covering its config, account, directory, status, and thread-binding imports while keeping new plugins on generic SDK subpaths. Fixes #73685; supersedes #73703. Thanks @rderickson9 and @SymbolStar.
- Channels/Discord: suppress duplicate gateway monitors when multiple enabled accounts resolve to the same bot token, preferring config tokens over default env fallback and reporting skipped duplicates as disabled. Supersedes #73608. Thanks @kagura-agent.

View File

@@ -24,6 +24,7 @@ export type ResolveImplicitProvidersForModelsJson = (params: {
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
}) => Promise<Record<string, ProviderConfig>>;
export type ModelsJsonPlan =
@@ -47,6 +48,7 @@ export async function resolveProvidersForModelsJsonWithDeps(
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
},
deps?: {
resolveImplicitProviders?: ResolveImplicitProvidersForModelsJson;
@@ -70,6 +72,7 @@ export async function resolveProvidersForModelsJsonWithDeps(
...(params.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: params.providerDiscoveryTimeoutMs }
: {}),
...(params.providerDiscoveryEntriesOnly === true ? { providerDiscoveryEntriesOnly: true } : {}),
});
return mergeProviders({
implicit: implicitProviders,
@@ -113,6 +116,7 @@ export async function planOpenClawModelsJsonWithDeps(
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
},
deps?: {
resolveImplicitProviders?: ResolveImplicitProvidersForModelsJson;
@@ -134,6 +138,9 @@ export async function planOpenClawModelsJsonWithDeps(
...(params.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: params.providerDiscoveryTimeoutMs }
: {}),
...(params.providerDiscoveryEntriesOnly === true
? { providerDiscoveryEntriesOnly: true }
: {}),
},
deps,
);

View File

@@ -98,4 +98,20 @@ describe("resolveImplicitProviders startup discovery scope", () => {
}),
);
});
it("can keep startup discovery on provider discovery entries only", async () => {
await resolveImplicitProviders({
agentDir: "/tmp/openclaw-agent",
config: {},
env: {} as NodeJS.ProcessEnv,
explicitProviders: {},
providerDiscoveryEntriesOnly: true,
});
expect(mocks.resolveRuntimePluginDiscoveryProviders).toHaveBeenCalledWith(
expect.objectContaining({
discoveryEntriesOnly: true,
}),
);
});
});

View File

@@ -47,6 +47,7 @@ type ImplicitProviderParams = {
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
};
type ImplicitProviderContext = ImplicitProviderParams & {
@@ -482,6 +483,7 @@ export async function resolveImplicitProviders(
...(params.pluginMetadataSnapshot
? { pluginMetadataSnapshot: params.pluginMetadataSnapshot }
: {}),
...(params.providerDiscoveryEntriesOnly === true ? { discoveryEntriesOnly: true } : {}),
});
for (const order of PLUGIN_DISCOVERY_ORDERS) {

View File

@@ -49,6 +49,7 @@ async function buildModelsJsonFingerprint(params: {
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index">;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
}): Promise<string> {
const authProfilesMtimeMs = await readFileMtimeMs(
path.join(params.agentDir, "auth-profiles.json"),
@@ -68,6 +69,7 @@ async function buildModelsJsonFingerprint(params: {
pluginMetadataSnapshotIndexFingerprint,
providerDiscoveryProviderIds: params.providerDiscoveryProviderIds,
providerDiscoveryTimeoutMs: params.providerDiscoveryTimeoutMs,
providerDiscoveryEntriesOnly: params.providerDiscoveryEntriesOnly === true,
});
}
@@ -162,6 +164,7 @@ export async function ensureOpenClawModelsJson(
workspaceDir?: string;
providerDiscoveryProviderIds?: readonly string[];
providerDiscoveryTimeoutMs?: number;
providerDiscoveryEntriesOnly?: boolean;
} = {},
): Promise<{ agentDir: string; wrote: boolean }> {
const resolved = resolveModelsConfigInput(config);
@@ -191,6 +194,7 @@ export async function ensureOpenClawModelsJson(
...(options.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs }
: {}),
...(options.providerDiscoveryEntriesOnly === true ? { providerDiscoveryEntriesOnly: true } : {}),
});
const cacheKey = modelsJsonReadyCacheKey(targetPath, fingerprint);
const cached = MODELS_JSON_STATE.readyCache.get(cacheKey);
@@ -220,6 +224,9 @@ export async function ensureOpenClawModelsJson(
...(options.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs }
: {}),
...(options.providerDiscoveryEntriesOnly === true
? { providerDiscoveryEntriesOnly: true }
: {}),
});
if (plan.action === "skip") {
@@ -251,6 +258,9 @@ export async function ensureOpenClawModelsJson(
...(options.providerDiscoveryTimeoutMs !== undefined
? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs }
: {}),
...(options.providerDiscoveryEntriesOnly === true
? { providerDiscoveryEntriesOnly: true }
: {}),
});
const refreshedCacheKey = modelsJsonReadyCacheKey(targetPath, refreshedFingerprint);
if (refreshedCacheKey !== cacheKey) {

View File

@@ -153,6 +153,11 @@ vi.mock("../agents/agent-paths.js", () => ({
resolveOpenClawAgentDir: hoisted.resolveOpenClawAgentDir,
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/openclaw-workspace"),
resolveDefaultAgentId: vi.fn(() => "default"),
}));
vi.mock("../agents/defaults.js", () => ({
DEFAULT_MODEL: "gpt-5.4",
DEFAULT_PROVIDER: "openai",
@@ -357,6 +362,11 @@ describe("startGatewayPostAttachRuntime", () => {
await vi.waitFor(
() => {
expect(prewarmPrimaryModel).toHaveBeenCalledTimes(1);
expect(prewarmPrimaryModel).toHaveBeenCalledWith(
expect.objectContaining({
workspaceDir: "/tmp/openclaw-workspace",
}),
);
expect(startChannels).toHaveBeenCalledTimes(1);
},
{ timeout: 2_000 },

View File

@@ -106,6 +106,7 @@ async function waitForAcpRuntimeBackendReady(params: {
async function prewarmConfiguredPrimaryModel(params: {
cfg: OpenClawConfig;
workspaceDir?: string;
log: { warn: (msg: string) => void };
}): Promise<void> {
const { resolveAgentModelPrimaryValue } = await import("../config/model-input.js");
@@ -125,11 +126,13 @@ async function prewarmConfiguredPrimaryModel(params: {
}
const [
{ resolveOpenClawAgentDir },
{ resolveAgentWorkspaceDir, resolveDefaultAgentId },
{ DEFAULT_MODEL, DEFAULT_PROVIDER },
{ isCliProvider, resolveConfiguredModelRef },
{ resolveEmbeddedAgentRuntime },
] = await Promise.all([
import("../agents/agent-paths.js"),
import("../agents/agent-scope.js"),
import("../agents/defaults.js"),
import("../agents/model-selection.js"),
import("../agents/pi-embedded-runner/runtime.js"),
@@ -149,10 +152,14 @@ async function prewarmConfiguredPrimaryModel(params: {
// Keep startup prewarm metadata-only; resolving models can import provider runtimes and block readiness.
const { ensureOpenClawModelsJson } = await import("../agents/models-config.js");
const agentDir = resolveOpenClawAgentDir();
const workspaceDir =
params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg));
try {
await ensureOpenClawModelsJson(params.cfg, agentDir, {
workspaceDir,
providerDiscoveryProviderIds: [provider],
providerDiscoveryTimeoutMs: STARTUP_PROVIDER_DISCOVERY_TIMEOUT_MS,
providerDiscoveryEntriesOnly: true,
});
} catch (err) {
params.log.warn(`startup model warmup failed for ${provider}/${model}: ${String(err)}`);
@@ -162,6 +169,7 @@ async function prewarmConfiguredPrimaryModel(params: {
async function prewarmConfiguredPrimaryModelWithTimeout(
params: {
cfg: OpenClawConfig;
workspaceDir?: string;
log: { warn: (msg: string) => void };
timeoutMs?: number;
},
@@ -190,6 +198,7 @@ async function prewarmConfiguredPrimaryModelWithTimeout(
function schedulePrimaryModelPrewarm(
params: {
cfg: OpenClawConfig;
workspaceDir?: string;
log: { warn: (msg: string) => void };
startupTrace?: GatewayStartupTrace;
},
@@ -202,6 +211,7 @@ function schedulePrimaryModelPrewarm(
prewarmConfiguredPrimaryModelWithTimeout(
{
cfg: params.cfg,
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
log: params.log,
},
prewarm,
@@ -342,6 +352,7 @@ export async function startGatewaySidecars(params: {
schedulePrimaryModelPrewarm(
{
cfg: params.cfg,
workspaceDir: params.defaultWorkspaceDir,
log: params.log,
startupTrace: params.startupTrace,
},

View File

@@ -15,6 +15,11 @@ vi.mock("../agents/agent-paths.js", () => ({
resolveOpenClawAgentDir: () => "/tmp/agent",
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: () => "/tmp/workspace",
resolveDefaultAgentId: () => "default",
}));
vi.mock("../agents/models-config.js", () => ({
ensureOpenClawModelsJson: (config: unknown, agentDir: unknown, options?: unknown) =>
ensureOpenClawModelsJsonMock(config, agentDir, options),
@@ -68,8 +73,10 @@ describe("gateway startup primary model warmup", () => {
cfg,
"/tmp/agent",
expect.objectContaining({
workspaceDir: "/tmp/workspace",
providerDiscoveryProviderIds: ["openai-codex"],
providerDiscoveryTimeoutMs: 5000,
providerDiscoveryEntriesOnly: true,
}),
);
expect(piModelModuleLoadedMock).not.toHaveBeenCalled();
@@ -163,8 +170,10 @@ describe("gateway startup primary model warmup", () => {
cfg,
"/tmp/agent",
expect.objectContaining({
workspaceDir: "/tmp/workspace",
providerDiscoveryProviderIds: ["openai-codex"],
providerDiscoveryTimeoutMs: 5000,
providerDiscoveryEntriesOnly: true,
}),
);
expect(piModelModuleLoadedMock).not.toHaveBeenCalled();

View File

@@ -103,6 +103,7 @@ function expectBundledCompatLoadPath(params: {
config: params.enablementCompat,
onlyPluginIds: ["openai"],
activate: false,
onlyPluginIds: ["openai"],
});
}
@@ -403,6 +404,7 @@ describe("resolvePluginCapabilityProviders", () => {
}),
onlyPluginIds: ["microsoft"],
activate: false,
onlyPluginIds: ["microsoft"],
});
});
@@ -630,9 +632,52 @@ describe("resolvePluginCapabilityProviders", () => {
config: compatConfig,
onlyPluginIds: ["google"],
activate: false,
onlyPluginIds: ["openai"],
});
});
it("scopes media capability snapshot loads to manifest-derived bundled owners", () => {
const cfg = { plugins: { allow: ["openai", "minimax"] } } as OpenClawConfig;
mocks.loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "openai",
origin: "bundled",
contracts: {
imageGenerationProviders: ["openai"],
videoGenerationProviders: ["openai"],
},
},
{
id: "minimax",
origin: "bundled",
contracts: {
imageGenerationProviders: ["minimax"],
videoGenerationProviders: ["minimax"],
musicGenerationProviders: ["minimax"],
},
},
] as never,
diagnostics: [],
});
resolvePluginCapabilityProviders({ key: "imageGenerationProviders", cfg });
resolvePluginCapabilityProviders({ key: "videoGenerationProviders", cfg });
resolvePluginCapabilityProviders({ key: "musicGenerationProviders", cfg });
const snapshotLoadOptions = mocks.resolveRuntimePluginRegistry.mock.calls
.map(([options]) => options)
.filter(
(options): options is { activate: boolean; onlyPluginIds?: string[] } =>
Boolean(options && typeof options === "object" && "activate" in options),
);
expect(snapshotLoadOptions.map((options) => options.onlyPluginIds)).toEqual([
["minimax", "openai"],
["minimax", "openai"],
["minimax"],
]);
});
it("loads only the bundled owner plugin for a targeted provider lookup", () => {
const cfg = { plugins: { allow: ["custom-plugin"] } } as OpenClawConfig;
const allowlistCompat = {
@@ -696,6 +741,7 @@ describe("resolvePluginCapabilityProviders", () => {
config: enablementCompat,
onlyPluginIds: ["google"],
activate: false,
onlyPluginIds: ["google"],
});
});
});

View File

@@ -4,7 +4,6 @@ import {
withBundledPluginEnablementCompat,
withBundledPluginVitestCompat,
} from "./bundled-compat.js";
import { hasExplicitPluginConfig } from "./config-policy.js";
import { resolveRuntimePluginRegistry } from "./loader.js";
import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry.js";
import type { PluginRegistry } from "./registry-types.js";
@@ -242,8 +241,7 @@ export function resolvePluginCapabilityProviders<K extends CapabilityProviderReg
if (
activeProviders.length > 0 &&
params.key !== "memoryEmbeddingProviders" &&
params.key !== "speechProviders" &&
!hasExplicitPluginConfig(params.cfg?.plugins)
params.key !== "speechProviders"
) {
return activeProviders.map((entry) => entry.provider) as CapabilityProviderForKey<K>[];
}

View File

@@ -226,4 +226,15 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => {
]);
expect(mocks.resolvePluginProviders).not.toHaveBeenCalled();
});
it("does not fall back to full plugin loading when discovery entries are requested only", () => {
mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue({
plugins: [createManifestPluginWithoutDiscovery({ id: "deepseek" })],
diagnostics: [],
});
expect(resolvePluginDiscoveryProvidersRuntime({ discoveryEntriesOnly: true })).toEqual([]);
expect(resolvePluginDiscoveryProvidersRuntime({ discoveryEntriesOnly: true })).toEqual([]);
expect(mocks.resolvePluginProviders).not.toHaveBeenCalled();
});
});