perf: cache model resolution to avoid repeated plugin-provider loads

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 <noreply@anthropic.com>
This commit is contained in:
Jochen Roessner
2026-04-28 01:00:39 +02:00
committed by Peter Steinberger
parent a0c850d188
commit e9be25b554
2 changed files with 44 additions and 9 deletions

View File

@@ -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<string, { mtime: number | null; result: { agentDir: string; wrote: boolean } }>;
};
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();
}

View File

@@ -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;
}