fix(gateway): cache effective tool inventory

This commit is contained in:
Peter Steinberger
2026-04-27 12:04:33 +01:00
parent 496964fced
commit 45bc7f69f2
4 changed files with 302 additions and 23 deletions

View File

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

View File

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

View File

@@ -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<string, unknown>) {
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<void>((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: {},

View File

@@ -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<OpenClawConfig, string>();
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<string, ToolsEffectiveCacheEntry>();
const toolsEffectiveInflight = new Map<string, Promise<EffectiveToolInventoryResult>>();
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<EffectiveToolInventoryResult> {
const existing = toolsEffectiveInflight.get(key);
if (existing) {
return existing;
}
const startedAt = nowForToolsEffectiveCache();
const task = new Promise<EffectiveToolInventoryResult>((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<EffectiveToolInventoryResult> {
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<OpenClawConfig, string>();
},
setToolsEffectiveNowForTest(now: () => number) {
nowForToolsEffectiveCache = now;
},
resetToolsEffectiveNowForTest() {
nowForToolsEffectiveCache = () => Date.now();
},
} as const;