From 6ae2e9e9dcea5e57c3657537402020cdcbe36d4f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 11:50:55 +0100 Subject: [PATCH] fix(gateway): keep effective tools on hot registry path --- CHANGELOG.md | 1 + src/agents/openclaw-plugin-tools.ts | 1 + ...w-tools.browser-plugin.integration.test.ts | 20 ++++++++++ src/agents/tools-effective-inventory.test.ts | 40 +++++++++++-------- src/agents/tools-effective-inventory.ts | 26 ++++++++---- 5 files changed, 63 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7d11ae7949..a06e6735f7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,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. - 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/agents/openclaw-plugin-tools.ts b/src/agents/openclaw-plugin-tools.ts index 43060640f02..a5acc30dc67 100644 --- a/src/agents/openclaw-plugin-tools.ts +++ b/src/agents/openclaw-plugin-tools.ts @@ -54,6 +54,7 @@ export function resolveOpenClawPluginToolsForOptions(params: { }), existingToolNames: params.existingToolNames ?? new Set(), toolAllowlist: params.options?.pluginToolAllowlist, + allowGatewaySubagentBinding: params.options?.allowGatewaySubagentBinding, }); return applyPluginToolDeliveryDefaults({ diff --git a/src/agents/openclaw-tools.browser-plugin.integration.test.ts b/src/agents/openclaw-tools.browser-plugin.integration.test.ts index a1c986dc072..8428a6cb113 100644 --- a/src/agents/openclaw-tools.browser-plugin.integration.test.ts +++ b/src/agents/openclaw-tools.browser-plugin.integration.test.ts @@ -120,6 +120,26 @@ describe("createOpenClawTools browser plugin integration", () => { expect(details.workspaceOnly).toBe(true); }); + it("forwards gateway subagent binding to plugin resolution", () => { + hoisted.resolvePluginTools.mockReturnValue([]); + const config = { + plugins: { + allow: ["browser"], + }, + } as OpenClawConfig; + + resolveOpenClawPluginToolsForOptions({ + options: { config, allowGatewaySubagentBinding: true }, + resolvedConfig: config, + }); + + expect(hoisted.resolvePluginTools).toHaveBeenCalledWith( + expect.objectContaining({ + allowGatewaySubagentBinding: true, + }), + ); + }); + it("does not pass a stale active snapshot as plugin runtime config for a resolved run config", () => { const staleSourceConfig = { plugins: { diff --git a/src/agents/tools-effective-inventory.test.ts b/src/agents/tools-effective-inventory.test.ts index 2ecc856fb00..86e9262a1f5 100644 --- a/src/agents/tools-effective-inventory.test.ts +++ b/src/agents/tools-effective-inventory.test.ts @@ -23,7 +23,6 @@ const effectiveInventoryState = vi.hoisted(() => ({ pluginMeta: {} as Record, channelMeta: {} as Record, effectivePolicy: {} as { profile?: string; providerProfile?: string }, - resolvedModelCompat: undefined as Record | undefined, createToolsMock: vi.fn( (_options) => [ @@ -48,16 +47,6 @@ vi.mock("./pi-tools.js", () => ({ effectiveInventoryState.createToolsMock(options), })); -vi.mock("./pi-embedded-runner/model.js", () => ({ - resolveModel: vi.fn(() => ({ - model: effectiveInventoryState.resolvedModelCompat - ? { compat: effectiveInventoryState.resolvedModelCompat } - : undefined, - authStorage: {} as never, - modelRegistry: {} as never, - })), -})); - vi.mock("../plugins/tools.js", () => ({ getPluginToolMeta: (tool: { name: string }) => effectiveInventoryState.pluginMeta[tool.name], })); @@ -79,7 +68,6 @@ async function loadHarness(options?: { pluginMeta?: Record; channelMeta?: Record; effectivePolicy?: { profile?: string; providerProfile?: string }; - resolvedModelCompat?: Record; }) { effectiveInventoryState.tools = options?.tools ?? [ mockTool({ name: "exec", label: "Exec", description: "Run shell commands" }), @@ -88,7 +76,6 @@ async function loadHarness(options?: { effectiveInventoryState.pluginMeta = options?.pluginMeta ?? {}; effectiveInventoryState.channelMeta = options?.channelMeta ?? {}; effectiveInventoryState.effectivePolicy = options?.effectivePolicy ?? {}; - effectiveInventoryState.resolvedModelCompat = options?.resolvedModelCompat; effectiveInventoryState.createToolsMock = options?.createToolsMock ?? vi.fn((_options) => effectiveInventoryState.tools); @@ -111,7 +98,6 @@ describe("resolveEffectiveToolInventory", () => { effectiveInventoryState.pluginMeta = {}; effectiveInventoryState.channelMeta = {}; effectiveInventoryState.effectivePolicy = {}; - effectiveInventoryState.resolvedModelCompat = undefined; effectiveInventoryState.createToolsMock = vi.fn( (_options) => effectiveInventoryState.tools, ); @@ -312,11 +298,31 @@ describe("resolveEffectiveToolInventory", () => { ]); const { resolveEffectiveToolInventory } = await loadHarness({ createToolsMock, - resolvedModelCompat: { supportsTools: true, supportsNativeWebSearch: true }, }); resolveEffectiveToolInventory({ - cfg: {}, + cfg: { + models: { + providers: { + xai: { + baseUrl: "https://api.x.ai/v1", + models: [ + { + id: "grok-test", + name: "Grok Test", + api: "openai-completions", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + compat: { supportsTools: true, nativeWebSearchTool: true }, + }, + ], + }, + }, + }, + }, agentDir: "/tmp/agents/main/agent", modelProvider: "xai", modelId: "grok-test", @@ -325,7 +331,7 @@ describe("resolveEffectiveToolInventory", () => { expect(createToolsMock).toHaveBeenCalledWith( expect.objectContaining({ allowGatewaySubagentBinding: true, - modelCompat: { supportsTools: true, supportsNativeWebSearch: true }, + modelCompat: { supportsTools: true, nativeWebSearchTool: true }, }), ); }); diff --git a/src/agents/tools-effective-inventory.ts b/src/agents/tools-effective-inventory.ts index 7c173b08830..7f5dff439b7 100644 --- a/src/agents/tools-effective-inventory.ts +++ b/src/agents/tools-effective-inventory.ts @@ -7,9 +7,10 @@ import { } from "../shared/string-coerce.js"; import { resolveAgentDir, resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js"; import { getChannelAgentToolMeta } from "./channel-tools.js"; -import { resolveModel } from "./pi-embedded-runner/model.js"; +import { normalizeStaticProviderModelId } from "./model-ref-shared.js"; import { createOpenClawCodingTools } from "./pi-tools.js"; import { resolveEffectiveToolPolicy } from "./pi-tools.policy.js"; +import { findNormalizedProviderValue, normalizeProviderId } from "./provider-id.js"; import { summarizeToolDescriptionText } from "./tool-description-summary.js"; import { resolveToolDisplay } from "./tool-display.js"; import { normalizeToolName } from "./tool-policy.js"; @@ -164,20 +165,30 @@ function disambiguateLabels(entries: EffectiveToolInventoryEntry[]): EffectiveTo function resolveEffectiveModelCompat(params: { cfg: OpenClawConfig; - agentDir: string; modelProvider?: string; modelId?: string; }) { - const provider = params.modelProvider?.trim(); - const modelId = params.modelId?.trim(); + const provider = normalizeProviderId(params.modelProvider ?? ""); + const modelId = params.modelId?.trim() ?? ""; if (!provider || !modelId) { return undefined; } - try { - return extractModelCompat(resolveModel(provider, modelId, params.agentDir, params.cfg).model); - } catch { + const providerConfig = findNormalizedProviderValue(params.cfg.models?.providers, provider); + const models = Array.isArray(providerConfig?.models) ? providerConfig.models : []; + if (models.length === 0) { return undefined; } + const normalizedModelId = normalizeStaticProviderModelId(provider, modelId); + const normalizedModelKey = normalizeLowercaseStringOrEmpty(normalizedModelId); + const providerPrefixedModelKey = normalizeLowercaseStringOrEmpty( + `${provider}/${normalizedModelId}`, + ); + const match = models.find((model) => { + const id = normalizeStaticProviderModelId(provider, model.id); + const key = normalizeLowercaseStringOrEmpty(id); + return key === normalizedModelKey || key === providerPrefixedModelKey; + }); + return extractModelCompat(match); } export function resolveEffectiveToolInventory( @@ -190,7 +201,6 @@ export function resolveEffectiveToolInventory( const agentDir = params.agentDir ?? resolveAgentDir(params.cfg, agentId); const modelCompat = resolveEffectiveModelCompat({ cfg: params.cfg, - agentDir, modelProvider: params.modelProvider, modelId: params.modelId, });