fix(web-search): late bind managed runtime config

This commit is contained in:
Peter Steinberger
2026-05-02 03:29:11 +01:00
parent 5d9053e435
commit 44dd5d8494
4 changed files with 128 additions and 14 deletions

View File

@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
- Providers/configure: preserve the existing default model when adding or reauthing a provider whose plugin returns a default-model config patch. Fixes #50268. Thanks @rixcorp-oc.
- Slack/message tool: let `read` fetch an exact Slack message timestamp, including a specific thread reply when paired with `threadId`, instead of returning only the parent thread or recent channel history. Fixes #53943. Thanks @zomars.
- Web search: point missing-key errors to `web_fetch` for known URLs and the browser tool for interactive pages. Thanks @zhaoyang97.
- Web search: late-bind managed agent `web_search` calls to the current runtime config snapshot, so existing sessions do not keep stale unresolved SecretRefs after secrets reload. Fixes #75420. Thanks @richardmqq.
- Heartbeat: strip legacy `[TOOL_CALL]...[/TOOL_CALL]` and `[TOOL_RESULT]...[/TOOL_RESULT]` pseudo-call blocks from heartbeat replies before channel delivery. Fixes #54138. Thanks @Deniable9570.
- macOS/Voice Wake: send wake-word and Push-to-Talk transcripts through the selected macOS session target instead of always falling back to main WebChat. Fixes #51040. Thanks @carl-jeffrolc.
- Providers/xAI: give Grok `web_search` a 60s default timeout, harden malformed xAI Responses parsing, and return structured timeout errors instead of aborting the tool call. Fixes #58063 and #58733. Thanks @dnishimura, @marvcasasola-svg, and @Nanako0129.

View File

@@ -194,6 +194,7 @@ export function createOpenClawTools(
config: options?.config,
sandboxed: options?.sandboxed,
runtimeWebSearch: runtimeWebTools?.search,
lateBindRuntimeConfig: true,
});
const webFetchTool = createWebFetchTool({
config: options?.config,

View File

@@ -1,5 +1,6 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveManifestContractOwnerPluginId } from "../../plugins/plugin-registry.js";
import { getActiveRuntimeWebToolsMetadata } from "../../secrets/runtime-web-tools-state.js";
import type { RuntimeWebSearchMetadata } from "../../secrets/runtime-web-tools.types.js";
import { resolveWebSearchProviderId, runWebSearch } from "../../web-search/runtime.js";
import type { AnyAgentTool } from "./common.js";
@@ -72,20 +73,11 @@ export function createWebSearchTool(options?: {
config?: OpenClawConfig;
sandboxed?: boolean;
runtimeWebSearch?: RuntimeWebSearchMetadata;
lateBindRuntimeConfig?: boolean;
}): AnyAgentTool | null {
if (isWebSearchDisabled(options?.config)) {
return null;
}
const runtimeProviderId =
options?.runtimeWebSearch?.selectedProvider ?? options?.runtimeWebSearch?.providerConfigured;
const preferRuntimeProviders =
Boolean(runtimeProviderId) &&
!resolveManifestContractOwnerPluginId({
contract: "webSearchProviders",
value: runtimeProviderId,
origin: "bundled",
config: options?.config,
});
return {
label: "Web Search",
@@ -94,10 +86,25 @@ export function createWebSearchTool(options?: {
"Search the web. Returns provider-normalized results for current information lookup.",
parameters: WebSearchSchema,
execute: async (_toolCallId, args) => {
const runtimeWebSearch =
options?.lateBindRuntimeConfig === true
? getActiveRuntimeWebToolsMetadata()?.search
: options?.runtimeWebSearch;
const runtimeProviderId =
runtimeWebSearch?.selectedProvider ?? runtimeWebSearch?.providerConfigured;
const config = options?.lateBindRuntimeConfig === true ? undefined : options?.config;
const preferRuntimeProviders =
Boolean(runtimeProviderId) &&
!resolveManifestContractOwnerPluginId({
contract: "webSearchProviders",
value: runtimeProviderId,
origin: "bundled",
config,
});
const result = await runWebSearch({
config: options?.config,
config,
sandboxed: options?.sandboxed,
runtimeWebSearch: options?.runtimeWebSearch,
runtimeWebSearch,
preferRuntimeProviders,
args: asToolParamsRecord(args),
});

View File

@@ -1,17 +1,29 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import { clearActiveRuntimeWebToolsMetadata } from "../../secrets/runtime-web-tools-state.js";
import {
clearActiveRuntimeWebToolsMetadata,
setActiveRuntimeWebToolsMetadata,
} from "../../secrets/runtime-web-tools-state.js";
import { createWebFetchTool, createWebSearchTool } from "./web-tools.js";
const runWebSearchCalls = vi.hoisted(
() => [] as Array<{ config?: unknown; runtimeWebSearch?: unknown }>,
);
vi.mock("../../web-search/runtime.js", async () => {
const { getActivePluginRegistry } = await import("../../plugins/runtime.js");
const { getActiveRuntimeWebToolsMetadata } =
await import("../../secrets/runtime-web-tools-state.js");
const resolveRuntimeDefinition = (options?: {
config?: unknown;
runtimeWebSearch?: { selectedProvider?: string; providerConfigured?: string };
}) => {
const providerId =
options?.runtimeWebSearch?.selectedProvider ?? options?.runtimeWebSearch?.providerConfigured;
options?.runtimeWebSearch?.selectedProvider ??
options?.runtimeWebSearch?.providerConfigured ??
getActiveRuntimeWebToolsMetadata()?.search?.selectedProvider ??
getActiveRuntimeWebToolsMetadata()?.search?.providerConfigured;
const registration = getActivePluginRegistry()?.webSearchProviders.find(
(entry) => entry.provider.id === providerId,
);
@@ -33,9 +45,14 @@ vi.mock("../../web-search/runtime.js", async () => {
resolveWebSearchDefinition: resolveRuntimeDefinition,
resolveWebSearchProviderId: () => "",
runWebSearch: async (options: {
config?: unknown;
args: Record<string, unknown>;
runtimeWebSearch?: unknown;
}) => {
runWebSearchCalls.push({
config: options.config,
runtimeWebSearch: options.runtimeWebSearch,
});
const resolved = resolveRuntimeDefinition(options as never);
if (!resolved) {
throw new Error("web_search is disabled or no provider is available.");
@@ -51,6 +68,7 @@ vi.mock("../../web-search/runtime.js", async () => {
beforeEach(() => {
setActivePluginRegistry(createEmptyPluginRegistry());
clearActiveRuntimeWebToolsMetadata();
runWebSearchCalls.length = 0;
});
afterEach(() => {
@@ -114,4 +132,91 @@ describe("web tools defaults", () => {
expect(tool?.description).toContain("Search the web");
expect(result?.details).toMatchObject({ ok: true });
});
it("late-binds managed web_search execution to the current runtime snapshot", async () => {
const registry = createEmptyPluginRegistry();
registry.webSearchProviders.push(
{
pluginId: "stale-search",
pluginName: "Stale Search",
source: "test",
provider: {
id: "stale",
label: "Stale Search",
hint: "Stale runtime provider",
envVars: [],
placeholder: "stale-...",
signupUrl: "https://example.com/stale",
autoDetectOrder: 1,
credentialPath: "tools.web.search.stale.apiKey",
getCredentialValue: () => "configured",
setCredentialValue: () => {},
createTool: () => ({
description: "stale runtime tool",
parameters: {},
execute: async () => ({ provider: "stale" }),
}),
},
},
{
pluginId: "fresh-search",
pluginName: "Fresh Search",
source: "test",
provider: {
id: "fresh",
label: "Fresh Search",
hint: "Fresh runtime provider",
envVars: [],
placeholder: "fresh-...",
signupUrl: "https://example.com/fresh",
autoDetectOrder: 2,
credentialPath: "tools.web.search.fresh.apiKey",
getCredentialValue: () => "configured",
setCredentialValue: () => {},
createTool: () => ({
description: "fresh runtime tool",
parameters: {},
execute: async () => ({ provider: "fresh" }),
}),
},
},
);
setActivePluginRegistry(registry);
setActiveRuntimeWebToolsMetadata({
search: {
providerConfigured: "fresh",
providerSource: "configured",
selectedProvider: "fresh",
selectedProviderKeySource: "config",
diagnostics: [],
},
fetch: {
providerSource: "none",
diagnostics: [],
},
diagnostics: [],
});
const tool = createWebSearchTool({
config: { tools: { web: { search: { provider: "stale" } } } },
sandboxed: true,
runtimeWebSearch: {
providerConfigured: "stale",
providerSource: "configured",
selectedProvider: "stale",
selectedProviderKeySource: "config",
diagnostics: [],
},
lateBindRuntimeConfig: true,
});
const result = await tool?.execute?.("call-runtime-provider", {});
expect(result?.details).toMatchObject({ provider: "fresh" });
expect(runWebSearchCalls).toHaveLength(1);
expect(runWebSearchCalls[0]?.config).toBeUndefined();
expect(runWebSearchCalls[0]?.runtimeWebSearch).toMatchObject({
selectedProvider: "fresh",
});
});
});