From 45bc7f69f204aea60e1567b578fd225f79a89ac2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 12:04:33 +0100 Subject: [PATCH] fix(gateway): cache effective tool inventory --- CHANGELOG.md | 2 +- .../server-methods/tools-effective.runtime.ts | 1 + .../server-methods/tools-effective.test.ts | 93 ++++++- src/gateway/server-methods/tools-effective.ts | 229 ++++++++++++++++-- 4 files changed, 302 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3afaf64919b..a8ca15ab636 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,7 @@ Docs: https://docs.openclaw.ai - Memory-core: re-resolve the active runtime config whenever `memory_search` or `memory_get` executes, so provider changes made by `config.patch` stop leaving stale embedding backends behind in existing tool instances. Fixes #61098. Thanks @BradGroux and @Linux2010. - WebChat: keep bare `/new` and `/reset` startup instructions out of visible chat history while preserving `/reset ` as user-visible transcript text. Fixes #72369. Thanks @collynes and @haishmg. - CLI/doctor: remove dangling channel config, heartbeat targets, and channel model overrides when stale plugin repair removes a missing channel plugin, preventing Gateway boot loops after failed plugin reinstalls. Fixes #65293. Thanks @yidecode. -- Control UI/Gateway: reuse the gateway-bound plugin registry and avoid model/auth discovery while resolving effective tool inventory, so chat runs no longer stall Control UI requests on repeated plugin/model setup. Fixes #72365; supersedes #72558. Thanks @Gabiii2398 and @1yihui. +- Control UI/Gateway: cache, coalesce, and stale-refresh effective tool inventory while reusing the gateway-bound plugin registry and avoiding model/auth discovery, so chat runs no longer stall Control UI requests on repeated plugin/model setup. Fixes #72365; supersedes #72558. Thanks @Gabiii2398 and @1yihui. - Channels/setup: treat bundled channel plugins as already bundled during `channels add` and onboarding, enabling them without writing redundant `plugins.load.paths` entries or path install records. Fixes #72740. Thanks @iCodePoet. - WhatsApp: honor gateway `HTTPS_PROXY` / `HTTP_PROXY` env vars for QR-login WebSocket connections, while respecting `NO_PROXY`, so proxied networks no longer fall back to direct `mmg.whatsapp.net` connections that time out with 408. Fixes #72547; supersedes #72692. Thanks @mebusw and @SymbolStar. - Bonjour: default mDNS advertisements to the system hostname when it is DNS-safe, avoiding `openclaw.local` probing conflicts and Gateway restart loops on hosts such as `Lobster` or `ubuntu`. Fixes #72355 and #72689; supersedes #72694. Thanks @mscheuerlein-bot, @gcusms, @moyuwuhen601, @pavan987, @zml-0912, @hhq365, and @SymbolStar. diff --git a/src/gateway/server-methods/tools-effective.runtime.ts b/src/gateway/server-methods/tools-effective.runtime.ts index e464eff6ff2..dc99a3c2b75 100644 --- a/src/gateway/server-methods/tools-effective.runtime.ts +++ b/src/gateway/server-methods/tools-effective.runtime.ts @@ -2,5 +2,6 @@ export { listAgentIds, resolveSessionAgentId } from "../../agents/agent-scope.js export { resolveEffectiveToolInventory } from "../../agents/tools-effective-inventory.js"; export { resolveReplyToMode } from "../../auto-reply/reply/reply-threading.js"; export { loadConfig } from "../../config/config.js"; +export { getActivePluginRegistryVersion } from "../../plugins/runtime.js"; export { deliveryContextFromSession } from "../../utils/delivery-context.shared.js"; export { loadSessionEntry, resolveSessionModelRef } from "../session-utils.js"; diff --git a/src/gateway/server-methods/tools-effective.test.ts b/src/gateway/server-methods/tools-effective.test.ts index 58031e89bea..8a3464e6486 100644 --- a/src/gateway/server-methods/tools-effective.test.ts +++ b/src/gateway/server-methods/tools-effective.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ErrorCodes } from "../protocol/index.js"; -import { toolsEffectiveHandlers } from "./tools-effective.js"; +import { __testing, toolsEffectiveHandlers } from "./tools-effective.js"; const runtimeMocks = vi.hoisted(() => ({ deliveryContextFromSession: vi.fn(() => ({ @@ -29,6 +29,7 @@ const runtimeMocks = vi.hoisted(() => ({ model: "gpt-4.1", }, })), + getActivePluginRegistryVersion: vi.fn(() => 1), resolveEffectiveToolInventory: vi.fn(() => ({ agentId: "main", profile: "coding", @@ -77,6 +78,9 @@ function createInvokeParams(params: Record) { describe("tools.effective handler", () => { beforeEach(() => { vi.clearAllMocks(); + __testing.resetToolsEffectiveCacheForTest(); + __testing.resetToolsEffectiveNowForTest(); + runtimeMocks.getActivePluginRegistryVersion.mockReturnValue(1); }); it("rejects invalid params", async () => { @@ -167,6 +171,93 @@ describe("tools.effective handler", () => { ); }); + it("serves repeated requests from the fresh inventory cache", async () => { + const first = createInvokeParams({ sessionKey: "main:abc" }); + await first.invoke(); + const second = createInvokeParams({ sessionKey: "main:abc" }); + await second.invoke(); + + expect(runtimeMocks.resolveEffectiveToolInventory).toHaveBeenCalledTimes(1); + expect((first.respond.mock.calls[0] as RespondCall | undefined)?.[0]).toBe(true); + expect((second.respond.mock.calls[0] as RespondCall | undefined)?.[0]).toBe(true); + }); + + it("coalesces identical cache misses while inventory resolution is pending", async () => { + const first = createInvokeParams({ sessionKey: "main:abc" }); + const second = createInvokeParams({ sessionKey: "main:abc" }); + + await Promise.all([first.invoke(), second.invoke()]); + + expect(runtimeMocks.resolveEffectiveToolInventory).toHaveBeenCalledTimes(1); + expect((first.respond.mock.calls[0] as RespondCall | undefined)?.[0]).toBe(true); + expect((second.respond.mock.calls[0] as RespondCall | undefined)?.[0]).toBe(true); + }); + + it("returns stale cached inventory immediately while refreshing in the background", async () => { + let now = 1_000; + __testing.setToolsEffectiveNowForTest(() => now); + const stalePayload = { + agentId: "main", + profile: "coding", + groups: [ + { + id: "core", + label: "Built-in tools", + source: "core", + tools: [ + { + id: "read", + label: "Read", + description: "Read files", + rawDescription: "Read files", + source: "core", + }, + ], + }, + ], + }; + const refreshedPayload = { + agentId: "main", + profile: "coding", + groups: [ + { + id: "core", + label: "Built-in tools", + source: "core", + tools: [ + { + id: "exec", + label: "Exec", + description: "Run shell commands", + rawDescription: "Run shell commands", + source: "core", + }, + ], + }, + ], + }; + runtimeMocks.resolveEffectiveToolInventory + .mockReturnValueOnce(stalePayload) + .mockReturnValueOnce(refreshedPayload); + + const initial = createInvokeParams({ sessionKey: "main:abc" }); + await initial.invoke(); + now += 11_000; + + const stale = createInvokeParams({ sessionKey: "main:abc" }); + await stale.invoke(); + + expect((stale.respond.mock.calls[0] as RespondCall | undefined)?.[1]).toBe(stalePayload); + expect(runtimeMocks.resolveEffectiveToolInventory).toHaveBeenCalledTimes(1); + + await new Promise((resolve) => setImmediate(resolve)); + expect(runtimeMocks.resolveEffectiveToolInventory).toHaveBeenCalledTimes(2); + + const fresh = createInvokeParams({ sessionKey: "main:abc" }); + await fresh.invoke(); + expect((fresh.respond.mock.calls[0] as RespondCall | undefined)?.[1]).toBe(refreshedPayload); + }); + it("falls back to origin.threadId when delivery context omits thread metadata", async () => { runtimeMocks.loadSessionEntry.mockReturnValueOnce({ cfg: {}, diff --git a/src/gateway/server-methods/tools-effective.ts b/src/gateway/server-methods/tools-effective.ts index 4b6cf749b65..ae079f1838d 100644 --- a/src/gateway/server-methods/tools-effective.ts +++ b/src/gateway/server-methods/tools-effective.ts @@ -1,4 +1,6 @@ +import type { EffectiveToolInventoryResult } from "../../agents/tools-effective-inventory.types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { logDebug, logWarn } from "../../logger.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { ADMIN_SCOPE } from "../method-scopes.js"; import { @@ -9,6 +11,7 @@ import { } from "../protocol/index.js"; import { deliveryContextFromSession, + getActivePluginRegistryVersion, listAgentIds, loadConfig, loadSessionEntry, @@ -19,6 +22,39 @@ import { } from "./tools-effective.runtime.js"; import type { GatewayRequestHandlers, RespondFn } from "./types.js"; +const TOOLS_EFFECTIVE_FRESH_TTL_MS = 10_000; +const TOOLS_EFFECTIVE_STALE_TTL_MS = 120_000; +const TOOLS_EFFECTIVE_SLOW_LOG_MS = 250; +const TOOLS_EFFECTIVE_CACHE_LIMIT = 128; + +let nowForToolsEffectiveCache = () => Date.now(); +let configFingerprintCache = new WeakMap(); + +type TrustedToolsEffectiveContext = { + cfg: OpenClawConfig; + agentId: string; + sessionKey: string; + senderIsOwner: boolean; + modelProvider?: string; + modelId?: string; + messageProvider?: string; + accountId?: string; + currentChannelId?: string; + currentThreadTs?: string; + groupId?: string | null; + groupChannel?: string | null; + groupSpace?: string | null; + replyToMode?: "off" | "first" | "all" | "batched"; +}; + +type ToolsEffectiveCacheEntry = { + value: EffectiveToolInventoryResult; + createdAtMs: number; +}; + +const toolsEffectiveCache = new Map(); +const toolsEffectiveInflight = new Map>(); + function resolveRequestedAgentIdOrRespondError(params: { rawAgentId: unknown; cfg: OpenClawConfig; @@ -40,6 +76,146 @@ function resolveRequestedAgentIdOrRespondError(params: { return requestedAgentId; } +function hashCacheString(value: string): string { + let hash = 5381; + for (let i = 0; i < value.length; i += 1) { + hash = (hash * 33) ^ value.charCodeAt(i); + } + return `${value.length}:${(hash >>> 0).toString(36)}`; +} + +function configFingerprint(cfg: OpenClawConfig): string { + const existing = configFingerprintCache.get(cfg); + if (existing) { + return existing; + } + const serialized = JSON.stringify(cfg); + const fingerprint = hashCacheString(serialized); + configFingerprintCache.set(cfg, fingerprint); + return fingerprint; +} + +function optionalCacheString(value: string | undefined | null): string { + return value?.trim() ?? ""; +} + +function buildToolsEffectiveCacheKey(params: { + sessionKey: string; + context: TrustedToolsEffectiveContext; +}): string { + const context = params.context; + return JSON.stringify({ + v: 1, + config: configFingerprint(context.cfg), + pluginRegistry: getActivePluginRegistryVersion(), + sessionKey: params.sessionKey, + agentId: context.agentId, + senderIsOwner: context.senderIsOwner, + modelProvider: optionalCacheString(context.modelProvider), + modelId: optionalCacheString(context.modelId), + messageProvider: optionalCacheString(context.messageProvider), + accountId: optionalCacheString(context.accountId), + currentChannelId: optionalCacheString(context.currentChannelId), + currentThreadTs: optionalCacheString(context.currentThreadTs), + groupId: optionalCacheString(context.groupId), + groupChannel: optionalCacheString(context.groupChannel), + groupSpace: optionalCacheString(context.groupSpace), + replyToMode: optionalCacheString(context.replyToMode), + }); +} + +function trimToolsEffectiveCache(): void { + while (toolsEffectiveCache.size > TOOLS_EFFECTIVE_CACHE_LIMIT) { + const oldest = toolsEffectiveCache.keys().next().value; + if (typeof oldest !== "string") { + return; + } + toolsEffectiveCache.delete(oldest); + } +} + +function cacheToolsEffectiveResult(key: string, value: EffectiveToolInventoryResult): void { + toolsEffectiveCache.delete(key); + toolsEffectiveCache.set(key, { value, createdAtMs: nowForToolsEffectiveCache() }); + trimToolsEffectiveCache(); +} + +function scheduleToolsEffectiveRefresh( + key: string, + context: TrustedToolsEffectiveContext, +): Promise { + const existing = toolsEffectiveInflight.get(key); + if (existing) { + return existing; + } + const startedAt = nowForToolsEffectiveCache(); + const task = new Promise((resolve, reject) => { + setImmediate(() => { + try { + const value = resolveEffectiveToolInventory({ + cfg: context.cfg, + agentId: context.agentId, + sessionKey: context.sessionKey, + messageProvider: context.messageProvider, + modelProvider: context.modelProvider, + modelId: context.modelId, + senderIsOwner: context.senderIsOwner, + currentChannelId: context.currentChannelId, + currentThreadTs: context.currentThreadTs, + accountId: context.accountId, + groupId: context.groupId, + groupChannel: context.groupChannel, + groupSpace: context.groupSpace, + replyToMode: context.replyToMode, + }); + cacheToolsEffectiveResult(key, value); + const durationMs = nowForToolsEffectiveCache() - startedAt; + if (durationMs >= TOOLS_EFFECTIVE_SLOW_LOG_MS) { + logDebug( + `tools-effective: refresh durationMs=${durationMs} agent=${context.agentId} session=${context.sessionKey} tools=${value.groups.reduce((sum, group) => sum + group.tools.length, 0)}`, + ); + } + resolve(value); + } catch (err) { + reject(err); + } finally { + toolsEffectiveInflight.delete(key); + } + }); + }); + toolsEffectiveInflight.set(key, task); + return task; +} + +function refreshToolsEffectiveInBackground( + key: string, + context: TrustedToolsEffectiveContext, +): void { + void scheduleToolsEffectiveRefresh(key, context).catch((err) => { + logWarn(`tools-effective: background refresh failed: ${String(err)}`); + }); +} + +async function resolveCachedToolsEffective(params: { + sessionKey: string; + context: TrustedToolsEffectiveContext; +}): Promise { + const key = buildToolsEffectiveCacheKey(params); + const now = nowForToolsEffectiveCache(); + const cached = toolsEffectiveCache.get(key); + if (cached) { + const ageMs = now - cached.createdAtMs; + if (ageMs < TOOLS_EFFECTIVE_FRESH_TTL_MS) { + return cached.value; + } + if (ageMs < TOOLS_EFFECTIVE_STALE_TTL_MS) { + refreshToolsEffectiveInBackground(key, params.context); + return cached.value; + } + } + return scheduleToolsEffectiveRefresh(key, params.context); +} + function resolveTrustedToolsEffectiveContext(params: { sessionKey: string; requestedAgentId?: string; @@ -77,6 +253,7 @@ function resolveTrustedToolsEffectiveContext(params: { return { cfg: loaded.cfg, agentId: sessionAgentId, + sessionKey: params.sessionKey, senderIsOwner: params.senderIsOwner, modelProvider: resolvedModel.provider, modelId: resolvedModel.model, @@ -111,7 +288,7 @@ function resolveTrustedToolsEffectiveContext(params: { } export const toolsEffectiveHandlers: GatewayRequestHandlers = { - "tools.effective": ({ params, respond, client }) => { + "tools.effective": async ({ params, respond, client }) => { if (!validateToolsEffectiveParams(params)) { respond( false, @@ -143,25 +320,35 @@ export const toolsEffectiveHandlers: GatewayRequestHandlers = { if (!trustedContext) { return; } - respond( - true, - resolveEffectiveToolInventory({ - cfg: trustedContext.cfg, - agentId: trustedContext.agentId, - sessionKey: params.sessionKey, - messageProvider: trustedContext.messageProvider, - modelProvider: trustedContext.modelProvider, - modelId: trustedContext.modelId, - senderIsOwner: trustedContext.senderIsOwner, - currentChannelId: trustedContext.currentChannelId, - currentThreadTs: trustedContext.currentThreadTs, - accountId: trustedContext.accountId, - groupId: trustedContext.groupId, - groupChannel: trustedContext.groupChannel, - groupSpace: trustedContext.groupSpace, - replyToMode: trustedContext.replyToMode, - }), - undefined, - ); + try { + respond( + true, + await resolveCachedToolsEffective({ + sessionKey: params.sessionKey, + context: trustedContext, + }), + undefined, + ); + } catch (err) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, `tools.effective failed: ${String(err)}`), + ); + } }, }; + +export const __testing = { + resetToolsEffectiveCacheForTest() { + toolsEffectiveCache.clear(); + toolsEffectiveInflight.clear(); + configFingerprintCache = new WeakMap(); + }, + setToolsEffectiveNowForTest(now: () => number) { + nowForToolsEffectiveCache = now; + }, + resetToolsEffectiveNowForTest() { + nowForToolsEffectiveCache = () => Date.now(); + }, +} as const;