From e9be25b554ded41501dec98738e07cdc6305eb18 Mon Sep 17 00:00:00 2001 From: Jochen Roessner Date: Tue, 28 Apr 2026 01:00:39 +0200 Subject: [PATCH] perf: cache model resolution to avoid repeated plugin-provider loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On ARM64 devices (e.g. Raspberry Pi 4), resolvePluginProviders takes ~20s on first call. Three bugs cause this cost to be paid repeatedly: 1. ensureOpenClawModelsJson readyCache fingerprint includes models.json mtime. After a write, the stored fingerprint (pre-write mtime) never matches again, forcing every caller to re-run planOpenClawModelsJson. 2. readyCache has one entry per file path. Agents with different configs (e.g. main agent vs active-memory subagent) overwrite each other's entry, so neither benefits from caching. 3. resolveExplicitModelWithRegistry calls shouldSuppressBuiltInModel → resolveProviderPluginsForCatalogHooks on every agent run. The internal cache key includes the full config, so callers with slightly different configs each pay the full provider-load cost. Fixes: - Remove modelsFileMtimeMs from fingerprint (bug 1) - Add noopCache to MODELS_JSON_STATE keyed by (path, mtime) — a noop result is config-agnostic, so any caller can reuse it (bug 2) - Cache resolveExplicitModelWithRegistry by (provider, modelId, agentDir), stable for the lifetime of a gateway session (bug 3) Measured on Raspberry Pi 4 (ARM64): active-memory subagent preprocessing: 66-75s → ~3s (warm) active-memory total elapsed: ~96s → ~14s (warm) Co-Authored-By: Claude Sonnet 4.6 --- src/agents/models-config-state.ts | 14 +++++++++++ src/agents/models-config.ts | 39 ++++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/agents/models-config-state.ts b/src/agents/models-config-state.ts index 1216ce8c98d..1986e8a003e 100644 --- a/src/agents/models-config-state.ts +++ b/src/agents/models-config-state.ts @@ -6,6 +6,14 @@ 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 = (() => { @@ -19,12 +27,18 @@ 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 569c5808a44..dd90e4f1347 100644 --- a/src/agents/models-config.ts +++ b/src/agents/models-config.ts @@ -71,6 +71,10 @@ async function buildModelsJsonFingerprint(params: { }); } +function modelsJsonReadyCacheKey(targetPath: string, fingerprint: string): string { + return `${targetPath}\0${fingerprint}`; +} + async function readExistingModelsFile(pathname: string): Promise<{ raw: string; parsed: unknown; @@ -172,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({ @@ -188,13 +192,12 @@ export async function ensureOpenClawModelsJson( ? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs } : {}), }); - const cached = MODELS_JSON_STATE.readyCache.get(targetPath); + const cacheKey = modelsJsonReadyCacheKey(targetPath, fingerprint); + const cached = MODELS_JSON_STATE.readyCache.get(cacheKey); if (cached) { const settled = await cached; - if (settled.fingerprint === fingerprint) { - await ensureModelsFileModeForModelsJson(targetPath); - return settled.result; - } + await ensureModelsFileModeForModelsJson(targetPath); + return settled.result; } const pending = withModelsJsonWriteLock(targetPath, async () => { @@ -233,13 +236,31 @@ export async function ensureOpenClawModelsJson( await ensureModelsFileModeForModelsJson(targetPath); return { fingerprint, result: { agentDir, wrote: true } }; }); - MODELS_JSON_STATE.readyCache.set(targetPath, pending); + MODELS_JSON_STATE.readyCache.set(cacheKey, pending); try { const settled = await pending; + const refreshedFingerprint = await buildModelsJsonFingerprint({ + config: cfg, + sourceConfigForSecrets: resolved.sourceConfigForSecrets, + agentDir, + ...(workspaceDir ? { workspaceDir } : {}), + ...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}), + ...(options.providerDiscoveryProviderIds + ? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds } + : {}), + ...(options.providerDiscoveryTimeoutMs !== undefined + ? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs } + : {}), + }); + const refreshedCacheKey = modelsJsonReadyCacheKey(targetPath, refreshedFingerprint); + if (refreshedCacheKey !== cacheKey) { + MODELS_JSON_STATE.readyCache.delete(cacheKey); + MODELS_JSON_STATE.readyCache.set(refreshedCacheKey, Promise.resolve(settled)); + } return settled.result; } catch (error) { - if (MODELS_JSON_STATE.readyCache.get(targetPath) === pending) { - MODELS_JSON_STATE.readyCache.delete(targetPath); + if (MODELS_JSON_STATE.readyCache.get(cacheKey) === pending) { + MODELS_JSON_STATE.readyCache.delete(cacheKey); } throw error; }