From c0fdf9923be2171ccaba37ee7924ee4476d8cdab Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 00:07:46 +0100 Subject: [PATCH] perf(agents): keep model resolution caches warm --- CHANGELOG.md | 1 + src/agents/models-config-state.ts | 14 ----- src/agents/models-config.ts | 2 +- .../models-config.write-serialization.test.ts | 39 +++++++++++++ src/plugins/provider-hook-runtime.ts | 57 ++++++------------- src/plugins/provider-runtime.test.ts | 46 ++++++++++++++- 6 files changed, 102 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 466ad10d07b..172209020b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai - Memory/Ollama: add `memorySearch.remote.nonBatchConcurrency` for inline embedding indexing, default Ollama non-batch indexing to one request at a time, and keep batch concurrency separate from non-batch concurrency so local embedding backfills avoid timeout storms on smaller hosts. Carries forward #57733. Thanks @itilys. - macOS app: update Peekaboo, ElevenLabsKit, and MLX TTS helper dependencies, make canvas file watching and config/exec-approval state writes reliable under concurrent app/test activity, and keep the app plus helper builds warning-free. Thanks @Blaizzy. - iOS app: refresh SwiftPM/XcodeGen source hygiene, make app, extension, watch, and curated shared Swift files pass the prebuild SwiftFormat and SwiftLint checks, move relay registration off deprecated StoreKit receipt APIs, and keep simulator builds and logic tests warning-free. Thanks @ngutman. +- Agents/models: keep `models.json` readiness and provider-hook caches warm across repeated agent and subagent model resolution while preserving external `models.json` invalidation, reducing repeated provider-plugin loads on slower ARM64 hosts. Fixes #73075. Thanks @jochen. - Docs/tools: clarify that `tools.profile: "messaging"` is intentionally narrow and that `tools.profile: "full"` is the unrestricted baseline for broader command/control access. Carries forward #39954. Thanks @posigit. - Control UI/Agents: redact tool-call args, partial/final results, derived exec output, and configured custom secret patterns before streaming tool events to the Control UI, so tool output cannot expose provider or channel credentials. Fixes #72283. (#72319) Thanks @volcano303 and @BunsDev. - Agents/sessions: keep `sessions_history` recall redaction enabled even when general log redaction is disabled, and clarify that safety-boundary UI/tool/diagnostic payloads still redact independently of `logging.redactSensitive`. Carries forward #72319. Thanks @volcano303 and @BunsDev. diff --git a/src/agents/models-config-state.ts b/src/agents/models-config-state.ts index 1986e8a003e..1216ce8c98d 100644 --- a/src/agents/models-config-state.ts +++ b/src/agents/models-config-state.ts @@ -6,14 +6,6 @@ type ModelsJsonState = { string, Promise<{ fingerprint: string; result: { agentDir: string; wrote: boolean } }> >; - /** - * Cross-config noop cache: when planOpenClawModelsJson returns "noop" (no write - * needed), the result is valid for all callers regardless of config differences, - * as long as models.json has not changed (same mtime). This avoids redundant - * planOpenClawModelsJson runs when multiple agents with slightly different configs - * (e.g. main agent vs subagent) call ensureOpenClawModelsJson concurrently. - */ - noopCache: Map; }; export const MODELS_JSON_STATE = (() => { @@ -27,18 +19,12 @@ export const MODELS_JSON_STATE = (() => { string, Promise<{ fingerprint: string; result: { agentDir: string; wrote: boolean } }> >(), - noopCache: new Map(), }; } - // Schema migration: add noopCache if missing (e.g. after in-process restart with old state) - if (!globalState[MODELS_JSON_STATE_KEY].noopCache) { - globalState[MODELS_JSON_STATE_KEY].noopCache = new Map(); - } return globalState[MODELS_JSON_STATE_KEY]; })(); export function resetModelsJsonReadyCacheForTest(): void { MODELS_JSON_STATE.writeLocks.clear(); MODELS_JSON_STATE.readyCache.clear(); - MODELS_JSON_STATE.noopCache.clear(); } diff --git a/src/agents/models-config.ts b/src/agents/models-config.ts index dd90e4f1347..9f2f2dd5f67 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -176,7 +176,7 @@ export async function ensureOpenClawModelsJson( getCurrentPluginMetadataSnapshot({ config: cfg, ...(workspaceDir ? { workspaceDir } : {}), - }); + }); const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveOpenClawAgentDir(); const targetPath = path.join(agentDir, "models.json"); const fingerprint = await buildModelsJsonFingerprint({ diff --git a/src/agents/models-config.write-serialization.test.ts b/src/agents/models-config.write-serialization.test.ts index 9790a339e3d..a44069f14bc 100644 --- a/src/agents/models-config.write-serialization.test.ts +++ b/src/agents/models-config.write-serialization.test.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { resolveInstalledPluginIndexPolicyHash } from "../plugins/installed-plugin-index-policy.js"; import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; +import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { CUSTOM_PROXY_MODELS_CONFIG, installModelsConfigTestHooks, @@ -136,6 +137,44 @@ describe("models-config write serialization", () => { }); }); + it("keeps the ready cache warm after models.json is written", async () => { + await withModelsTempHome(async () => { + await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); + await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); + + expect(planOpenClawModelsJsonMock).toHaveBeenCalledTimes(1); + }); + }); + + it("invalidates the ready cache when models.json changes externally", async () => { + await withModelsTempHome(async () => { + await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); + await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); + + const modelPath = path.join(resolveOpenClawAgentDir(), "models.json"); + await fs.writeFile(modelPath, `${JSON.stringify({ external: true })}\n`, "utf8"); + await ensureOpenClawModelsJson(CUSTOM_PROXY_MODELS_CONFIG); + + expect(planOpenClawModelsJsonMock).toHaveBeenCalledTimes(2); + }); + }); + + it("keeps distinct config fingerprints cached without evicting each other", async () => { + await withModelsTempHome(async () => { + planOpenClawModelsJsonMock.mockImplementation(async () => ({ action: "noop" })); + const first = structuredClone(CUSTOM_PROXY_MODELS_CONFIG); + const second = structuredClone(CUSTOM_PROXY_MODELS_CONFIG); + first.agents = { defaults: { model: "openai/gpt-5.4" } }; + second.agents = { defaults: { model: "anthropic/claude-sonnet-4-5" } }; + + await ensureOpenClawModelsJson(first); + await ensureOpenClawModelsJson(second); + await ensureOpenClawModelsJson(first); + + expect(planOpenClawModelsJsonMock).toHaveBeenCalledTimes(2); + }); + }); + it("serializes concurrent models.json writes to avoid overlap", async () => { await withModelsTempHome(async () => { const first = structuredClone(CUSTOM_PROXY_MODELS_CONFIG); diff --git a/src/plugins/provider-hook-runtime.ts b/src/plugins/provider-hook-runtime.ts index d1940155dc6..b2ec8d7407a 100644 --- a/src/plugins/provider-hook-runtime.ts +++ b/src/plugins/provider-hook-runtime.ts @@ -34,41 +34,26 @@ function matchesProviderLiteralId(provider: ProviderPlugin, providerId: string): return !!normalized && normalizeLowercaseStringOrEmpty(provider.id) === normalized; } -let cachedHookProvidersWithoutConfig = new WeakMap< - NodeJS.ProcessEnv, - Map ->(); -let cachedHookProvidersByConfig = new WeakMap< - OpenClawConfig, - WeakMap> ->(); +let cachedHookProviders = new WeakMap>(); -function resolveHookProviderCacheBucket(params: { - config?: OpenClawConfig; - env: NodeJS.ProcessEnv; -}) { - if (!params.config) { - let bucket = cachedHookProvidersWithoutConfig.get(params.env); - if (!bucket) { - bucket = new Map(); - cachedHookProvidersWithoutConfig.set(params.env, bucket); - } - return bucket; - } - - let envBuckets = cachedHookProvidersByConfig.get(params.config); - if (!envBuckets) { - envBuckets = new WeakMap>(); - cachedHookProvidersByConfig.set(params.config, envBuckets); - } - let bucket = envBuckets.get(params.env); +function resolveHookProviderCacheBucket(env: NodeJS.ProcessEnv) { + let bucket = cachedHookProviders.get(env); if (!bucket) { bucket = new Map(); - envBuckets.set(params.env, bucket); + cachedHookProviders.set(env, bucket); } return bucket; } +function resolveHookProviderConfigCacheShape(config: OpenClawConfig | undefined): unknown { + if (!config) { + return null; + } + return { + plugins: config.plugins, + }; +} + function buildHookProviderCacheKey(params: { config?: OpenClawConfig; workspaceDir?: string; @@ -81,18 +66,11 @@ function buildHookProviderCacheKey(params: { env: params.env, }); const onlyPluginIds = normalizePluginIdScope(params.onlyPluginIds); - return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(params.config ?? null)}::${serializePluginIdScope(onlyPluginIds)}::${JSON.stringify(params.providerRefs ?? [])}`; + return `${roots.workspace ?? ""}::${roots.global}::${roots.stock ?? ""}::${JSON.stringify(resolveHookProviderConfigCacheShape(params.config))}::${serializePluginIdScope(onlyPluginIds)}::${JSON.stringify(params.providerRefs ?? [])}`; } export function clearProviderRuntimeHookCache(): void { - cachedHookProvidersWithoutConfig = new WeakMap< - NodeJS.ProcessEnv, - Map - >(); - cachedHookProvidersByConfig = new WeakMap< - OpenClawConfig, - WeakMap> - >(); + cachedHookProviders = new WeakMap>(); } export function resetProviderRuntimeHookCacheForTest(): void { @@ -116,10 +94,7 @@ export function resolveProviderPluginsForHooks(params: { }): ProviderPlugin[] { const env = params.env ?? process.env; const workspaceDir = params.workspaceDir ?? getActivePluginRegistryWorkspaceDirFromState(); - const cacheBucket = resolveHookProviderCacheBucket({ - config: params.config, - env, - }); + const cacheBucket = resolveHookProviderCacheBucket(env); const cacheKey = buildHookProviderCacheKey({ config: params.config, workspaceDir, diff --git a/src/plugins/provider-runtime.test.ts b/src/plugins/provider-runtime.test.ts index bc9160b1586..31d6d204c9b 100644 --- a/src/plugins/provider-runtime.test.ts +++ b/src/plugins/provider-runtime.test.ts @@ -1,6 +1,6 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ModelProviderConfig } from "../config/types.js"; +import type { ModelProviderConfig, OpenClawConfig } from "../config/types.js"; import type { ProviderRuntimeModel } from "./provider-runtime-model.types.js"; import { expectAugmentedCodexCatalog, @@ -503,6 +503,50 @@ describe("provider-runtime", () => { expect(providerRuntimeWarnMock).not.toHaveBeenCalled(); }); + it("reuses catalog hook provider loads when only non-plugin config changes", () => { + resolveCatalogHookProviderPluginIdsMock.mockReturnValue(["demo"]); + resolvePluginProvidersMock.mockReturnValue([ + { + id: "demo", + label: "Demo", + auth: [], + suppressBuiltInModel: () => ({ suppress: true, errorMessage: "suppressed" }), + }, + ]); + const baseConfig = { + plugins: { + entries: { + demo: { enabled: true }, + }, + }, + } as OpenClawConfig; + const firstConfig = { + ...baseConfig, + agents: { defaults: { model: "openai/gpt-5.4" } }, + } as OpenClawConfig; + const secondConfig = { + ...baseConfig, + agents: { defaults: { model: "anthropic/claude-sonnet-4-5" } }, + } as OpenClawConfig; + + expect( + resolveProviderBuiltInModelSuppression({ + config: firstConfig, + env: process.env, + context: { config: firstConfig, env: process.env, provider: "openai", modelId: "demo" }, + })?.suppress, + ).toBe(true); + expect( + resolveProviderBuiltInModelSuppression({ + config: secondConfig, + env: process.env, + context: { config: secondConfig, env: process.env, provider: "openai", modelId: "demo" }, + })?.suppress, + ).toBe(true); + + expect(resolvePluginProvidersMock).toHaveBeenCalledTimes(1); + }); + it("returns provider-prepared runtime auth for the matched provider", async () => { const prepareRuntimeAuth = vi.fn(async () => ({ apiKey: "runtime-token",