From 1143f7384233958b798a5de242b396d94b975b0c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 05:32:48 +0100 Subject: [PATCH] fix(web-search): honor provider abort signals --- CHANGELOG.md | 1 + .../src/gemini-web-search-provider.runtime.ts | 4 + .../google/src/gemini-web-search-provider.ts | 4 +- extensions/google/web-search-provider.test.ts | 28 +++++++ ...pi-tool-definition-adapter.logging.test.ts | 77 +++++++++++++++++++ src/agents/pi-tool-definition-adapter.ts | 7 -- .../tools/web-search-provider-common.ts | 4 + src/agents/tools/web-search.signal.test.ts | 35 +++++++++ src/agents/tools/web-search.ts | 3 +- .../provider-web-search-contract.ts | 2 + src/plugin-sdk/provider-web-search.ts | 2 + src/plugins/types.ts | 1 + src/plugins/web-provider-types.ts | 9 ++- src/web-search/runtime-types.ts | 1 + src/web-search/runtime.test.ts | 37 +++++++++ src/web-search/runtime.ts | 2 +- 16 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 src/agents/tools/web-search.signal.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index be6e036a9d6..c7d865f213e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai - Slack/mentions: resolve `` user-group mentions through Slack `usergroups.users.list` and treat them as explicit mentions only when the bot user is a member, so mention-gated agent channels wake for real user-group mentions without config-only allowlists. Fixes #73827. Thanks @CG-Intelligence-Agent-Jack. - 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. - PDF/Gemini: send native PDF analysis API keys in the `x-goog-api-key` header instead of the request URL, keeping secrets out of proxy and access logs. Supersedes #60600. Thanks @garagon. +- Web search/Gemini: route agent abort signals into provider fetches and log provider-side abort failures as normal tool errors instead of silently aborting the run. Fixes #72995. Thanks @RoseKongPS. - 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. - Web search/Gemini: reuse `models.providers.google.apiKey` and `models.providers.google.baseUrl` as lower-priority fallbacks for Gemini web search after dedicated search config and `GEMINI_API_KEY`. Supersedes #57496. Thanks @Aoiujz. diff --git a/extensions/google/src/gemini-web-search-provider.runtime.ts b/extensions/google/src/gemini-web-search-provider.runtime.ts index f57291517ca..5f071b5cf53 100644 --- a/extensions/google/src/gemini-web-search-provider.runtime.ts +++ b/extensions/google/src/gemini-web-search-provider.runtime.ts @@ -166,6 +166,7 @@ async function runGeminiSearch(params: { baseUrl: string; model: string; timeoutSeconds: number; + signal?: AbortSignal; timeRangeFilter?: GeminiTimeRangeFilter; }): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> { const endpoint = `${params.baseUrl}/models/${params.model}:generateContent`; @@ -176,6 +177,7 @@ async function runGeminiSearch(params: { { url: endpoint, timeoutSeconds: params.timeoutSeconds, + signal: params.signal, init: { method: "POST", headers: { @@ -245,6 +247,7 @@ async function runGeminiSearch(params: { export async function executeGeminiSearch( args: Record, searchConfig?: SearchConfigRecord, + context?: { signal?: AbortSignal }, ): Promise> { const unsupportedResponse = buildUnsupportedSearchFilterResponse( { @@ -299,6 +302,7 @@ export async function executeGeminiSearch( baseUrl, model, timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), + signal: context?.signal, timeRangeFilter: timeRange.timeRangeFilter, }); const payload = { diff --git a/extensions/google/src/gemini-web-search-provider.ts b/extensions/google/src/gemini-web-search-provider.ts index 0f2f98ff0d4..e06372088b1 100644 --- a/extensions/google/src/gemini-web-search-provider.ts +++ b/extensions/google/src/gemini-web-search-provider.ts @@ -59,9 +59,9 @@ function createGeminiToolDefinition( description: "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search.", parameters: GEMINI_TOOL_PARAMETERS, - execute: async (args) => { + execute: async (args, context) => { const { executeGeminiSearch } = await loadGeminiWebSearchRuntime(); - return await executeGeminiSearch(args, searchConfig); + return await executeGeminiSearch(args, searchConfig, context); }, }; } diff --git a/extensions/google/web-search-provider.test.ts b/extensions/google/web-search-provider.test.ts index db9bcc2b2be..a80034911bc 100644 --- a/extensions/google/web-search-provider.test.ts +++ b/extensions/google/web-search-provider.test.ts @@ -152,6 +152,34 @@ describe("google web search provider", () => { ); }); + it("passes provider execution abort signals into the Gemini fetch", async () => { + const mockFetch = installGeminiFetch(); + const controller = new AbortController(); + controller.abort(); + const provider = createGeminiWebSearchProvider(); + const tool = provider.createTool({ + config: { + plugins: { + entries: { + google: { + config: { + webSearch: { + apiKey: "AIza-plugin-test", + }, + }, + }, + }, + }, + }, + searchConfig: { provider: "gemini" }, + }); + + await tool?.execute({ query: "OpenClaw docs" }, { signal: controller.signal }); + + const init = mockFetch.mock.calls[0]?.[1] as { signal?: AbortSignal } | undefined; + expect(init?.signal?.aborted).toBe(true); + }); + it("reuses the Google model provider key when no web search key or env key is set", async () => { await withEnvAsync({ GEMINI_API_KEY: undefined }, async () => { const mockFetch = installGeminiFetch(); diff --git a/src/agents/pi-tool-definition-adapter.logging.test.ts b/src/agents/pi-tool-definition-adapter.logging.test.ts index 463e4019cc3..5dff8c87dd8 100644 --- a/src/agents/pi-tool-definition-adapter.logging.test.ts +++ b/src/agents/pi-tool-definition-adapter.logging.test.ts @@ -111,6 +111,83 @@ describe("pi tool definition adapter logging", () => { ); }); + it("logs provider AbortError failures when the agent run was not aborted", async () => { + const baseTool = { + name: "web_search", + label: "Web Search", + description: "searches", + parameters: Type.Object({ + query: Type.String(), + }), + execute: async () => { + const error = new Error("This operation was aborted"); + error.name = "AbortError"; + throw error; + }, + } satisfies AgentTool; + const [def] = toToolDefinitions([baseTool]); + if (!def) { + throw new Error("missing tool definition"); + } + + const result = await def.execute( + "call-web-search-abort", + { query: "OpenClaw" }, + undefined, + undefined, + extensionContext, + ); + + expect(result).toEqual( + expect.objectContaining({ + details: expect.objectContaining({ + status: "error", + tool: "web_search", + error: "This operation was aborted", + }), + }), + ); + expect(logError).toHaveBeenCalledWith( + expect.stringContaining("[tools] web_search failed: This operation was aborted"), + ); + }); + + it("rethrows AbortError failures when the agent run signal was aborted", async () => { + const baseTool = { + name: "web_search", + label: "Web Search", + description: "searches", + parameters: Type.Object({ + query: Type.String(), + }), + execute: async () => { + const error = new Error("This operation was aborted"); + error.name = "AbortError"; + throw error; + }, + } satisfies AgentTool; + const [def] = toToolDefinitions([baseTool]); + if (!def) { + throw new Error("missing tool definition"); + } + const controller = new AbortController(); + controller.abort(); + + await expect( + def.execute( + "call-web-search-agent-abort", + { query: "OpenClaw" }, + controller.signal, + undefined, + extensionContext, + ), + ).rejects.toMatchObject({ + name: "AbortError", + message: "This operation was aborted", + }); + expect(logError).not.toHaveBeenCalled(); + }); + it("accepts nested edits arrays for the current edit schema", async () => { const execute = vi.fn(async (_toolCallId: string, params: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(params) }], diff --git a/src/agents/pi-tool-definition-adapter.ts b/src/agents/pi-tool-definition-adapter.ts index 738cfdfda05..5e937877975 100644 --- a/src/agents/pi-tool-definition-adapter.ts +++ b/src/agents/pi-tool-definition-adapter.ts @@ -256,13 +256,6 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] { if (signal?.aborted) { throw err; } - const name = - err && typeof err === "object" && "name" in err - ? String((err as { name?: unknown }).name) - : ""; - if (name === "AbortError") { - throw err; - } if (isBeforeToolCallBlockedError(err)) { logDebug(`tools: ${normalizedName} blocked by before_tool_call: ${err.reason}`); return buildBlockedToolResult({ diff --git a/src/agents/tools/web-search-provider-common.ts b/src/agents/tools/web-search-provider-common.ts index 60c083666b0..7ab52e242b1 100644 --- a/src/agents/tools/web-search-provider-common.ts +++ b/src/agents/tools/web-search-provider-common.ts @@ -79,6 +79,7 @@ export async function withTrustedWebSearchEndpoint( url: string; timeoutSeconds: number; init: RequestInit; + signal?: AbortSignal; }, run: (response: Response) => Promise, ): Promise { @@ -88,6 +89,7 @@ export async function withTrustedWebSearchEndpoint( url: params.url, init: params.init, timeoutSeconds: params.timeoutSeconds, + signal: params.signal, }, async ({ response }) => run(response), ); @@ -102,6 +104,7 @@ export async function postTrustedWebToolsJson( errorLabel: string; maxErrorBytes?: number; extraHeaders?: Record; + signal?: AbortSignal; }, parseResponse: (response: Response) => Promise, ): Promise { @@ -110,6 +113,7 @@ export async function postTrustedWebToolsJson( { url: params.url, timeoutSeconds: params.timeoutSeconds, + signal: params.signal, init: { method: "POST", headers: { diff --git a/src/agents/tools/web-search.signal.test.ts b/src/agents/tools/web-search.signal.test.ts new file mode 100644 index 00000000000..c05d7ac99ac --- /dev/null +++ b/src/agents/tools/web-search.signal.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + runWebSearch: vi.fn(), +})); + +vi.mock("../../web-search/runtime.js", () => ({ + resolveWebSearchProviderId: vi.fn(() => "mock"), + runWebSearch: mocks.runWebSearch, +})); + +describe("web_search signal plumbing", () => { + beforeEach(() => { + mocks.runWebSearch.mockReset(); + mocks.runWebSearch.mockResolvedValue({ + provider: "mock", + result: { ok: true }, + }); + }); + + it("passes the agent abort signal into web search runtime execution", async () => { + const { createWebSearchTool } = await import("./web-search.js"); + const controller = new AbortController(); + const tool = createWebSearchTool({ config: {} }); + + await tool?.execute("call-search", { query: "openclaw" }, controller.signal); + + expect(mocks.runWebSearch).toHaveBeenCalledWith( + expect.objectContaining({ + args: { query: "openclaw" }, + signal: controller.signal, + }), + ); + }); +}); diff --git a/src/agents/tools/web-search.ts b/src/agents/tools/web-search.ts index bc10e8dd805..d671fa3d5e4 100644 --- a/src/agents/tools/web-search.ts +++ b/src/agents/tools/web-search.ts @@ -85,7 +85,7 @@ export function createWebSearchTool(options?: { description: "Search the web. Returns provider-normalized results for current information lookup.", parameters: WebSearchSchema, - execute: async (_toolCallId, args) => { + execute: async (_toolCallId, args, signal) => { const runtimeWebSearch = options?.lateBindRuntimeConfig === true ? getActiveRuntimeWebToolsMetadata()?.search @@ -107,6 +107,7 @@ export function createWebSearchTool(options?: { runtimeWebSearch, preferRuntimeProviders, args: asToolParamsRecord(args), + signal, }); return jsonResult({ ...result.result, diff --git a/src/plugin-sdk/provider-web-search-contract.ts b/src/plugin-sdk/provider-web-search-contract.ts index 854bceb7223..f1413fee128 100644 --- a/src/plugin-sdk/provider-web-search-contract.ts +++ b/src/plugin-sdk/provider-web-search-contract.ts @@ -6,6 +6,7 @@ import type { WebSearchProviderSetupContext, WebSearchProviderPlugin, WebSearchProviderToolDefinition, + WebSearchProviderToolExecutionContext, } from "../plugins/types.js"; import { enablePluginInConfig } from "./provider-enable-config.js"; import { @@ -27,6 +28,7 @@ export type { WebSearchProviderSetupContext, WebSearchProviderPlugin, WebSearchProviderToolDefinition, + WebSearchProviderToolExecutionContext, }; export type { CreateWebSearchProviderContractFieldsOptions, diff --git a/src/plugin-sdk/provider-web-search.ts b/src/plugin-sdk/provider-web-search.ts index 0d7756f8d77..35ef16e877e 100644 --- a/src/plugin-sdk/provider-web-search.ts +++ b/src/plugin-sdk/provider-web-search.ts @@ -5,6 +5,7 @@ import type { WebSearchProviderSetupContext, WebSearchProviderPlugin, WebSearchProviderToolDefinition, + WebSearchProviderToolExecutionContext, } from "../plugins/types.js"; export { jsonResult, @@ -66,6 +67,7 @@ export type { WebSearchProviderSetupContext, WebSearchProviderPlugin, WebSearchProviderToolDefinition, + WebSearchProviderToolExecutionContext, }; /** diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 17a256e6c6d..51d2b5dac95 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -261,6 +261,7 @@ export type { WebSearchProviderPlugin, WebSearchProviderSetupContext, WebSearchProviderToolDefinition, + WebSearchProviderToolExecutionContext, WebSearchRuntimeMetadataContext, } from "./web-provider-types.js"; export type { ProviderRuntimeModel } from "./provider-runtime-model.types.js"; diff --git a/src/plugins/web-provider-types.ts b/src/plugins/web-provider-types.ts index 01a6d0fb327..703ad31a5a4 100644 --- a/src/plugins/web-provider-types.ts +++ b/src/plugins/web-provider-types.ts @@ -14,7 +14,10 @@ export type WebFetchProviderId = string; export type WebSearchProviderToolDefinition = { description: string; parameters: TSchema; - execute: (args: Record) => Promise>; + execute: ( + args: Record, + context?: WebSearchProviderToolExecutionContext, + ) => Promise>; }; export type WebFetchProviderToolDefinition = { @@ -29,6 +32,10 @@ export type WebSearchProviderContext = { runtimeMetadata?: RuntimeWebSearchMetadata; }; +export type WebSearchProviderToolExecutionContext = { + signal?: AbortSignal; +}; + export type WebFetchProviderContext = { config?: OpenClawConfig; fetchConfig?: Record; diff --git a/src/web-search/runtime-types.ts b/src/web-search/runtime-types.ts index 641b9500b0f..f868b042dd5 100644 --- a/src/web-search/runtime-types.ts +++ b/src/web-search/runtime-types.ts @@ -21,6 +21,7 @@ export type ResolveWebSearchDefinitionParams = { export type RunWebSearchParams = ResolveWebSearchDefinitionParams & { args: Record; + signal?: AbortSignal; }; export type RunWebSearchResult = { diff --git a/src/web-search/runtime.test.ts b/src/web-search/runtime.test.ts index d349024c10f..00e75006077 100644 --- a/src/web-search/runtime.test.ts +++ b/src/web-search/runtime.test.ts @@ -137,6 +137,43 @@ describe("web search runtime", () => { }); }); + it("passes the run abort signal to provider execution", async () => { + const controller = new AbortController(); + const execute = vi.fn( + async (args: Record, context?: { signal?: AbortSignal }) => ({ + ...args, + aborted: context?.signal?.aborted ?? false, + sameSignal: context?.signal === controller.signal, + }), + ); + resolveRuntimeWebSearchProvidersMock.mockReturnValue([ + createCustomSearchProvider({ + credentialPath: "tools.web.search.custom.apiKey", + requiresCredential: false, + createTool: () => ({ + description: "custom", + parameters: {}, + execute, + }), + }), + ]); + + await expect( + runWebSearch({ + config: {}, + args: { query: "abort plumbing" }, + signal: controller.signal, + }), + ).resolves.toEqual({ + provider: "custom", + result: { query: "abort plumbing", aborted: false, sameSignal: true }, + }); + expect(execute).toHaveBeenCalledWith( + { query: "abort plumbing" }, + { signal: controller.signal }, + ); + }); + it("auto-detects a provider from canonical plugin-owned credentials", async () => { const provider = createCustomSearchProvider(); resolveRuntimeWebSearchProvidersMock.mockReturnValue([provider]); diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts index 806c16cb29e..f3db0738766 100644 --- a/src/web-search/runtime.ts +++ b/src/web-search/runtime.ts @@ -361,7 +361,7 @@ export async function runWebSearch(params: RunWebSearchParams): Promise