perf(agents): keep model resolution caches warm

This commit is contained in:
Peter Steinberger
2026-04-28 00:07:46 +01:00
parent e9be25b554
commit c0fdf9923b
6 changed files with 102 additions and 57 deletions

View File

@@ -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.

View File

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

View File

@@ -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({

View File

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

View File

@@ -34,41 +34,26 @@ function matchesProviderLiteralId(provider: ProviderPlugin, providerId: string):
return !!normalized && normalizeLowercaseStringOrEmpty(provider.id) === normalized;
}
let cachedHookProvidersWithoutConfig = new WeakMap<
NodeJS.ProcessEnv,
Map<string, ProviderPlugin[]>
>();
let cachedHookProvidersByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>
>();
let cachedHookProviders = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
function resolveHookProviderCacheBucket(params: {
config?: OpenClawConfig;
env: NodeJS.ProcessEnv;
}) {
if (!params.config) {
let bucket = cachedHookProvidersWithoutConfig.get(params.env);
if (!bucket) {
bucket = new Map<string, ProviderPlugin[]>();
cachedHookProvidersWithoutConfig.set(params.env, bucket);
}
return bucket;
}
let envBuckets = cachedHookProvidersByConfig.get(params.config);
if (!envBuckets) {
envBuckets = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
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<string, ProviderPlugin[]>();
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<string, ProviderPlugin[]>
>();
cachedHookProvidersByConfig = new WeakMap<
OpenClawConfig,
WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>
>();
cachedHookProviders = new WeakMap<NodeJS.ProcessEnv, Map<string, ProviderPlugin[]>>();
}
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,

View File

@@ -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",