diff --git a/extensions/brave/src/brave-web-search-provider.runtime.ts b/extensions/brave/src/brave-web-search-provider.runtime.ts index 9248dbfd296d..bd8abada80b6 100644 --- a/extensions/brave/src/brave-web-search-provider.runtime.ts +++ b/extensions/brave/src/brave-web-search-provider.runtime.ts @@ -206,6 +206,7 @@ async function runBraveJsonRequest( apiKey: string; timeoutSeconds: number; diagnostics?: BraveHttpDiagnostics; + signal?: AbortSignal; configureUrl: (url: URL) => void; }, errorLabel: string, @@ -228,6 +229,7 @@ async function runBraveJsonRequest( { url: url.toString(), timeoutSeconds: params.timeoutSeconds, + signal: params.signal, init: { method: "GET", headers: { @@ -256,6 +258,7 @@ async function runBraveLlmContextSearch(params: { apiKey: string; timeoutSeconds: number; diagnostics?: BraveHttpDiagnostics; + signal?: AbortSignal; country?: string; search_lang?: string; freshness?: string; @@ -279,6 +282,7 @@ async function runBraveLlmContextSearch(params: { apiKey: params.apiKey, timeoutSeconds: params.timeoutSeconds, diagnostics: params.diagnostics, + signal: params.signal, configureUrl: (url) => { setBraveSearchUrlParams(url, params); }, @@ -296,6 +300,7 @@ async function runBraveWebSearch(params: { apiKey: string; timeoutSeconds: number; diagnostics?: BraveHttpDiagnostics; + signal?: AbortSignal; country?: string; search_lang?: string; ui_lang?: string; @@ -312,6 +317,7 @@ async function runBraveWebSearch(params: { apiKey: params.apiKey, timeoutSeconds: params.timeoutSeconds, diagnostics: params.diagnostics, + signal: params.signal, configureUrl: (url) => { setBraveSearchUrlParams(url, { ...params, @@ -346,6 +352,7 @@ export async function executeBraveSearch( searchConfig?: SearchConfigRecord, options?: { diagnosticsEnabled?: boolean; + signal?: AbortSignal; }, ): Promise> { const apiKey = resolveBraveApiKey(searchConfig); @@ -485,12 +492,14 @@ export async function executeBraveSearch( apiKey, timeoutSeconds, diagnostics, + signal: options?.signal, country: country ?? undefined, search_lang: normalizedLanguage.search_lang, freshness, dateAfter, dateBefore, }); + options?.signal?.throwIfAborted(); const payload = { query, provider: "brave", @@ -530,6 +539,7 @@ export async function executeBraveSearch( apiKey, timeoutSeconds, diagnostics, + signal: options?.signal, country: country ?? undefined, search_lang: normalizedLanguage.search_lang, ui_lang: normalizedLanguage.ui_lang, @@ -537,6 +547,7 @@ export async function executeBraveSearch( dateAfter, dateBefore, }); + options?.signal?.throwIfAborted(); const payload = { query, provider: "brave", diff --git a/extensions/brave/src/brave-web-search-provider.test.ts b/extensions/brave/src/brave-web-search-provider.test.ts index e1920546706b..2a80140157bc 100644 --- a/extensions/brave/src/brave-web-search-provider.test.ts +++ b/extensions/brave/src/brave-web-search-provider.test.ts @@ -182,6 +182,52 @@ describe("brave web search provider", () => { }); }); + it.each(["web", "llm-context"] as const)( + "does not start an already canceled %s search", + async (mode) => { + const fetchMock = vi.fn(async () => emptyWebSearchResponse()); + global.fetch = fetchMock as typeof global.fetch; + const tool = createBraveTool({ webSearch: { apiKey: "brave-test-key", mode } }); + const controller = new AbortController(); + controller.abort(new Error("Brave caller canceled")); + + await expect( + tool.execute({ query: `brave pre-canceled ${mode}` }, { signal: controller.signal }), + ).rejects.toThrow("Brave caller canceled"); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); + + it.each(["web", "llm-context"] as const)( + "aborts an in-flight %s request with the caller's reason", + async (mode) => { + const fetchMock = vi.fn( + async (_url: string, init?: RequestInit) => + await new Promise((_resolve, reject) => { + const signal = init?.signal; + if (!signal) { + reject(new Error("Brave request lost caller cancellation")); + return; + } + signal.addEventListener("abort", () => reject(signal.reason as Error), { once: true }); + }), + ); + global.fetch = fetchMock as typeof global.fetch; + const tool = createBraveTool({ webSearch: { apiKey: "brave-test-key", mode } }); + const controller = new AbortController(); + const result = tool.execute( + { query: `brave in-flight cancellation ${mode}` }, + { signal: controller.signal }, + ); + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + controller.abort(new Error("Brave request canceled in flight")); + + await expect(result).rejects.toThrow("Brave request canceled in flight"); + expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + }, + ); + it("normalizes brave language parameters and swaps reversed ui/search inputs", () => { expect( testing.normalizeBraveLanguageParams({ diff --git a/extensions/brave/src/brave-web-search-provider.ts b/extensions/brave/src/brave-web-search-provider.ts index cb9548299d7b..619b3243cec4 100644 --- a/extensions/brave/src/brave-web-search-provider.ts +++ b/extensions/brave/src/brave-web-search-provider.ts @@ -82,9 +82,13 @@ function createBraveToolDefinition( ? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding." : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.", parameters: BraveSearchSchema, - execute: async (args) => { + execute: async (args, context) => { + context?.signal?.throwIfAborted(); const { executeBraveSearch } = await loadBraveWebSearchRuntime(); - return await executeBraveSearch(args, searchConfig, { diagnosticsEnabled }); + return await executeBraveSearch(args, searchConfig, { + diagnosticsEnabled, + signal: context?.signal, + }); }, }; } diff --git a/extensions/duckduckgo/src/ddg-client.ts b/extensions/duckduckgo/src/ddg-client.ts index f5d484c66511..6a907750d789 100644 --- a/extensions/duckduckgo/src/ddg-client.ts +++ b/extensions/duckduckgo/src/ddg-client.ts @@ -126,6 +126,7 @@ export async function runDuckDuckGoSearch(params: { safeSearch?: DdgSafeSearch; timeoutSeconds?: number; cacheTtlMinutes?: number; + signal?: AbortSignal; }): Promise> { const count = resolveSearchCount(params.count, DEFAULT_SEARCH_COUNT); const region = params.region ?? resolveDdgRegion(params.config); @@ -163,6 +164,7 @@ export async function runDuckDuckGoSearch(params: { { url: url.toString(), timeoutSeconds, + signal: params.signal, init: { method: "GET", headers: { @@ -187,6 +189,7 @@ export async function runDuckDuckGoSearch(params: { }, ); + params.signal?.throwIfAborted(); const payload = { query: params.query, provider: "duckduckgo", diff --git a/extensions/duckduckgo/src/ddg-search-provider.test.ts b/extensions/duckduckgo/src/ddg-search-provider.test.ts index 53ab34d503d0..0451c9592bfb 100644 --- a/extensions/duckduckgo/src/ddg-search-provider.test.ts +++ b/extensions/duckduckgo/src/ddg-search-provider.test.ts @@ -15,6 +15,7 @@ vi.mock("./ddg-client.js", () => ({ describe("duckduckgo web search provider", () => { let createDuckDuckGoWebSearchProvider: typeof import("./ddg-search-provider.js").createDuckDuckGoWebSearchProvider; let ddgClientTesting: typeof import("./ddg-client.js").testing; + let runActualDuckDuckGoSearch: typeof import("./ddg-client.js").runDuckDuckGoSearch; afterAll(() => { vi.doUnmock("./ddg-client.js"); @@ -23,7 +24,7 @@ describe("duckduckgo web search provider", () => { beforeAll(async () => { ({ createDuckDuckGoWebSearchProvider } = await import("./ddg-search-provider.js")); - ({ testing: ddgClientTesting } = + ({ testing: ddgClientTesting, runDuckDuckGoSearch: runActualDuckDuckGoSearch } = await vi.importActual("./ddg-client.js")); await import("../index.js"); }); @@ -105,6 +106,66 @@ describe("duckduckgo web search provider", () => { expect(runDuckDuckGoSearch).not.toHaveBeenCalled(); }); + it("forwards caller cancellation without starting an already canceled search", async () => { + const tool = createDuckDuckGoWebSearchProvider().createTool({ config: {} }); + if (!tool) { + throw new Error("Expected tool definition"); + } + const active = new AbortController(); + + await tool.execute({ query: "duckduckgo cancellation forwarding" }, { signal: active.signal }); + + expect(runDuckDuckGoSearch).toHaveBeenCalledWith( + expect.objectContaining({ signal: active.signal }), + ); + runDuckDuckGoSearch.mockClear(); + const canceled = new AbortController(); + canceled.abort(new Error("DuckDuckGo caller canceled")); + + await expect( + tool.execute({ query: "duckduckgo pre-canceled" }, { signal: canceled.signal }), + ).rejects.toThrow("DuckDuckGo caller canceled"); + expect(runDuckDuckGoSearch).not.toHaveBeenCalled(); + }); + + it("aborts an in-flight DuckDuckGo request without caching its result", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation( + async (_url, init) => + await new Promise((_resolve, reject) => { + if (!init?.signal) { + reject(new Error("DuckDuckGo request lost caller cancellation")); + return; + } + init.signal.addEventListener("abort", () => reject(init.signal?.reason as Error), { + once: true, + }); + }), + ); + const controller = new AbortController(); + const result = runActualDuckDuckGoSearch({ + query: "duckduckgo in-flight cancellation", + signal: controller.signal, + }); + + try { + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + controller.abort(new Error("DuckDuckGo request canceled in flight")); + await expect(result).rejects.toThrow("DuckDuckGo request canceled in flight"); + expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + fetchMock.mockResolvedValueOnce( + new Response('Example', { + headers: { "content-type": "text/html" }, + }), + ); + + await runActualDuckDuckGoSearch({ query: "duckduckgo in-flight cancellation" }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + } finally { + fetchMock.mockRestore(); + } + }); + it("bounds successful DuckDuckGo HTML bodies without using response.text()", async () => { const streamed = createStreamingResponse({ chunkCount: 32, diff --git a/extensions/duckduckgo/src/ddg-search-provider.ts b/extensions/duckduckgo/src/ddg-search-provider.ts index df0b4157cd80..81239a9e0012 100644 --- a/extensions/duckduckgo/src/ddg-search-provider.ts +++ b/extensions/duckduckgo/src/ddg-search-provider.ts @@ -35,7 +35,8 @@ export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin { description: "Search the web using DuckDuckGo. Returns titles, URLs, and snippets with no API key required.", parameters: DuckDuckGoSearchSchema, - execute: async (args) => { + execute: async (args, context) => { + context?.signal?.throwIfAborted(); const { runDuckDuckGoSearch } = await loadDuckDuckGoClientModule(); return await runDuckDuckGoSearch({ config: ctx.config, @@ -50,6 +51,7 @@ export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin { | "moderate" | "off" | undefined, + ...(context?.signal ? { signal: context.signal } : {}), }); }, }), diff --git a/extensions/exa/src/exa-web-search-provider.runtime.ts b/extensions/exa/src/exa-web-search-provider.runtime.ts index 9c25cf2f8c7c..1245230bef28 100644 --- a/extensions/exa/src/exa-web-search-provider.runtime.ts +++ b/extensions/exa/src/exa-web-search-provider.runtime.ts @@ -392,6 +392,7 @@ async function runExaSearch(params: { type: ExaSearchType; contents?: ExaContentsArgs; timeoutSeconds: number; + signal?: AbortSignal; }): Promise { const body: Record = { query: params.query, @@ -413,6 +414,7 @@ async function runExaSearch(params: { { url: params.endpoint, timeoutSeconds: params.timeoutSeconds, + signal: params.signal, init: { method: "POST", headers: { @@ -471,6 +473,7 @@ function buildExaCacheKey(params: { export async function executeExaWebSearchProviderTool( ctx: { config?: Record; searchConfig?: SearchConfigRecord }, args: Record, + signal?: AbortSignal, ): Promise> { const searchConfig = mergeScopedSearchConfig( ctx.searchConfig, @@ -570,8 +573,10 @@ export async function executeExaWebSearchProviderTool( type, contents, timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), + signal, }); + signal?.throwIfAborted(); const payload = { query, provider: "exa", diff --git a/extensions/exa/src/exa-web-search-provider.test.ts b/extensions/exa/src/exa-web-search-provider.test.ts index 9c11e36b7b35..a0703ae02c49 100644 --- a/extensions/exa/src/exa-web-search-provider.test.ts +++ b/extensions/exa/src/exa-web-search-provider.test.ts @@ -54,6 +54,73 @@ function streamingJsonResponse(params: { chunkCount: number; chunkSize: number } } describe("exa web search provider", () => { + it("does not send or cache an already canceled search", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ results: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const tool = createExaWebSearchProvider().createTool({ + config: { + plugins: { entries: { exa: { config: { webSearch: { apiKey: "exa-test-key" } } } } }, + }, + searchConfig: {}, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + const controller = new AbortController(); + controller.abort(new Error("Exa caller canceled")); + + try { + await expect( + tool.execute({ query: "exa pre-canceled" }, { signal: controller.signal }), + ).rejects.toThrow("Exa caller canceled"); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + fetchMock.mockRestore(); + } + }); + + it("aborts the guarded Exa request without losing the caller's reason", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation( + async (_url, init) => + await new Promise((_resolve, reject) => { + if (!init?.signal) { + reject(new Error("Exa request lost caller cancellation")); + return; + } + init.signal.addEventListener("abort", () => reject(init.signal?.reason as Error), { + once: true, + }); + }), + ); + const tool = createExaWebSearchProvider().createTool({ + config: { + plugins: { entries: { exa: { config: { webSearch: { apiKey: "exa-test-key" } } } } }, + }, + searchConfig: {}, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + const controller = new AbortController(); + const result = tool.execute( + { query: "exa in-flight cancellation" }, + { signal: controller.signal }, + ); + + try { + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + controller.abort(new Error("Exa request canceled in flight")); + await expect(result).rejects.toThrow("Exa request canceled in flight"); + expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + } finally { + fetchMock.mockRestore(); + } + }); + it("exposes the expected metadata and selection wiring", () => { const provider = createExaWebSearchProvider(); if (!provider.applySelectionConfig) { diff --git a/extensions/exa/src/exa-web-search-provider.ts b/extensions/exa/src/exa-web-search-provider.ts index 195a75c3f3d2..7c7966f058f2 100644 --- a/extensions/exa/src/exa-web-search-provider.ts +++ b/extensions/exa/src/exa-web-search-provider.ts @@ -67,9 +67,10 @@ export function createExaWebSearchProvider(): WebSearchProviderPlugin { description: "Search the web using Exa AI. Supports neural or keyword search, publication date filters, and optional highlights or text extraction.", parameters: ExaSearchSchema, - execute: async (args) => { + execute: async (args, context) => { + context?.signal?.throwIfAborted(); const { executeExaWebSearchProviderTool } = await loadExaWebSearchRuntime(); - return await executeExaWebSearchProviderTool(ctx, args); + return await executeExaWebSearchProviderTool(ctx, args, context?.signal); }, }), }; diff --git a/extensions/minimax/src/minimax-web-search-provider.runtime.ts b/extensions/minimax/src/minimax-web-search-provider.runtime.ts index bf3d70f2d9a7..c900c79680af 100644 --- a/extensions/minimax/src/minimax-web-search-provider.runtime.ts +++ b/extensions/minimax/src/minimax-web-search-provider.runtime.ts @@ -127,6 +127,7 @@ async function runMiniMaxSearch(params: { apiKey: string; endpoint: string; timeoutSeconds: number; + signal?: AbortSignal; }): Promise<{ results: Array>; relatedSearches?: string[]; @@ -135,6 +136,7 @@ async function runMiniMaxSearch(params: { { url: params.endpoint, timeoutSeconds: params.timeoutSeconds, + signal: params.signal, init: { method: "POST", headers: { @@ -202,6 +204,7 @@ function missingMiniMaxKeyPayload() { export async function executeMiniMaxWebSearchProviderTool( ctx: { config?: Record; searchConfig?: SearchConfigRecord }, args: Record, + signal?: AbortSignal, ): Promise> { const searchConfig = mergeScopedSearchConfig( ctx.searchConfig, @@ -244,8 +247,10 @@ export async function executeMiniMaxWebSearchProviderTool( apiKey, endpoint, timeoutSeconds, + signal, }); + signal?.throwIfAborted(); const payload: Record = { query, provider: "minimax", diff --git a/extensions/minimax/src/minimax-web-search-provider.test.ts b/extensions/minimax/src/minimax-web-search-provider.test.ts index 2efa66b2f768..85bb6ff64b13 100644 --- a/extensions/minimax/src/minimax-web-search-provider.test.ts +++ b/extensions/minimax/src/minimax-web-search-provider.test.ts @@ -1,6 +1,7 @@ // Minimax tests cover minimax web search provider plugin behavior. -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { minimaxWebSearchTesting } from "../test-api.js"; +import { createMiniMaxWebSearchProvider } from "./minimax-web-search-provider.js"; const { MINIMAX_SEARCH_ENDPOINT_GLOBAL, @@ -42,6 +43,77 @@ describe("minimax web search provider", () => { restoreEnvValue("MINIMAX_API_KEY", originalApiKey); }); + it("does not send an already canceled MiniMax search", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ organic: [], base_resp: { status_code: 0 } }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const tool = createMiniMaxWebSearchProvider().createTool({ + config: { + plugins: { + entries: { minimax: { config: { webSearch: { apiKey: "minimax-test-key" } } } }, + }, + }, + searchConfig: {}, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + const controller = new AbortController(); + controller.abort(new Error("MiniMax caller canceled")); + + try { + await expect( + tool.execute({ query: "minimax pre-canceled" }, { signal: controller.signal }), + ).rejects.toThrow("MiniMax caller canceled"); + expect(fetchMock).not.toHaveBeenCalled(); + } finally { + fetchMock.mockRestore(); + } + }); + + it("aborts the guarded MiniMax request with the caller's reason", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation( + async (_url, init) => + await new Promise((_resolve, reject) => { + if (!init?.signal) { + reject(new Error("MiniMax request lost caller cancellation")); + return; + } + init.signal.addEventListener("abort", () => reject(init.signal?.reason as Error), { + once: true, + }); + }), + ); + const tool = createMiniMaxWebSearchProvider().createTool({ + config: { + plugins: { + entries: { minimax: { config: { webSearch: { apiKey: "minimax-test-key" } } } }, + }, + }, + searchConfig: {}, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + const controller = new AbortController(); + const result = tool.execute( + { query: "minimax in-flight cancellation" }, + { signal: controller.signal }, + ); + + try { + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()); + controller.abort(new Error("MiniMax request canceled in flight")); + await expect(result).rejects.toThrow("MiniMax request canceled in flight"); + expect(fetchMock.mock.calls[0]?.[1]?.signal?.aborted).toBe(true); + } finally { + fetchMock.mockRestore(); + } + }); + describe("resolveMiniMaxRegion", () => { it("returns global by default", () => { expect(resolveMiniMaxRegion()).toBe("global"); diff --git a/extensions/minimax/src/minimax-web-search-provider.ts b/extensions/minimax/src/minimax-web-search-provider.ts index 69bcbdd218f8..104ad551aebe 100644 --- a/extensions/minimax/src/minimax-web-search-provider.ts +++ b/extensions/minimax/src/minimax-web-search-provider.ts @@ -52,9 +52,10 @@ export function createMiniMaxWebSearchProvider(): WebSearchProviderPlugin { description: "Search the web using MiniMax Search API. Returns titles, URLs, snippets, and related search suggestions.", parameters: MiniMaxSearchSchema, - execute: async (args) => { + execute: async (args, context) => { + context?.signal?.throwIfAborted(); const { executeMiniMaxWebSearchProviderTool } = await loadMiniMaxWebSearchRuntime(); - return await executeMiniMaxWebSearchProviderTool(ctx, args); + return await executeMiniMaxWebSearchProviderTool(ctx, args, context?.signal); }, }), }; diff --git a/extensions/ollama/src/web-search-provider.test.ts b/extensions/ollama/src/web-search-provider.test.ts index a28756a22d8c..5e337c760c3f 100644 --- a/extensions/ollama/src/web-search-provider.test.ts +++ b/extensions/ollama/src/web-search-provider.test.ts @@ -116,6 +116,7 @@ async function runOllamaWebSearch( type GuardedRequest = { url: string; + signal?: AbortSignal; init: { method: string; headers: Record; @@ -227,6 +228,35 @@ describe("ollama web search provider", () => { expect(fetchRequest().url).toBe("http://ollama.local:11434/api/experimental/web_search"); }); + it("passes caller cancellation to the guard without replacing its owned timeout", async () => { + mockSuccessfulSearchResponse(); + const tool = createOllamaWebSearchProvider().createTool({ config: createOllamaConfig() }); + if (!tool) { + throw new Error("Expected tool definition"); + } + const controller = new AbortController(); + + await tool.execute({ query: "ollama active cancellation" }, { signal: controller.signal }); + + expect(fetchRequest().signal).toBe(controller.signal); + expect(fetchRequest().init.signal).toBeUndefined(); + }); + + it("does not start fallback attempts after caller cancellation", async () => { + mockSuccessfulSearchResponse(); + const tool = createOllamaWebSearchProvider().createTool({ config: createOllamaConfig() }); + if (!tool) { + throw new Error("Expected tool definition"); + } + const controller = new AbortController(); + controller.abort(new Error("Ollama caller canceled")); + + await expect( + tool.execute({ query: "ollama pre-canceled" }, { signal: controller.signal }), + ).rejects.toThrow("Ollama caller canceled"); + expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); + }); + it.each<[string, () => OpenClawConfig, string]>([ [ "prefers the plugin web search base URL over the model provider host", diff --git a/extensions/ollama/src/web-search-provider.ts b/extensions/ollama/src/web-search-provider.ts index 9a9bbe4fa11d..5b81dd2ca95e 100644 --- a/extensions/ollama/src/web-search-provider.ts +++ b/extensions/ollama/src/web-search-provider.ts @@ -189,6 +189,7 @@ async function runOllamaWebSearch(params: { config?: OpenClawConfig; query: string; count?: number; + signal?: AbortSignal; }): Promise> { const query = params.query.trim(); if (!query) { @@ -209,6 +210,7 @@ async function runOllamaWebSearch(params: { let payload: OllamaWebSearchResponse | undefined; let lastError: Error | undefined; for (const attempt of attempts) { + params.signal?.throwIfAborted(); const headers: Record = { "Content-Type": "application/json" }; if (attempt.apiKey) { headers.Authorization = `Bearer ${attempt.apiKey}`; @@ -222,6 +224,7 @@ async function runOllamaWebSearch(params: { }, // Guard-owned timeoutMs also bounds DNS/proxy preflight; init.signal does not. timeoutMs: DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS, + ...(params.signal ? { signal: params.signal } : {}), policy: buildOllamaBaseUrlSsrFPolicy(attempt.baseUrl), auditContext: "ollama-web-search.search", }); @@ -246,6 +249,7 @@ async function runOllamaWebSearch(params: { throw new Error(message); } payload = await readOllamaWebSearchResponse(response); + params.signal?.throwIfAborted(); break; } catch (error) { if (error instanceof Error) { @@ -358,15 +362,18 @@ export function createOllamaWebSearchProvider(): WebSearchProviderPlugin { description: "Search the web using Ollama's web search API. Returns titles, URLs, and snippets from the configured Ollama host.", parameters: OLLAMA_WEB_SEARCH_SCHEMA, - execute: async (args) => - await runOllamaWebSearch({ + execute: async (args, context) => { + context?.signal?.throwIfAborted(); + return await runOllamaWebSearch({ config: ctx.config, query: readStringParam(args, "query", { required: true }), count: readPositiveIntegerParam(args, "count", { max: 10, message: "count must be an integer from 1 to 10.", }), - }), + signal: context?.signal, + }); + }, }), }; } diff --git a/extensions/parallel/src/parallel-web-search-provider.runtime.ts b/extensions/parallel/src/parallel-web-search-provider.runtime.ts index 466a00a9e075..6f12d8ce6678 100644 --- a/extensions/parallel/src/parallel-web-search-provider.runtime.ts +++ b/extensions/parallel/src/parallel-web-search-provider.runtime.ts @@ -123,6 +123,7 @@ async function runParallelSearch(params: { sessionId?: string; clientModel?: string; timeoutSeconds: number; + signal?: AbortSignal; }): Promise { const body: Record = { search_queries: [...params.searchQueries], @@ -142,6 +143,7 @@ async function runParallelSearch(params: { { url: params.endpoint, timeoutSeconds: params.timeoutSeconds, + signal: params.signal, init: { method: "POST", headers: { @@ -170,6 +172,7 @@ async function runParallelSearch(params: { export async function executeParallelWebSearchProviderTool( ctx: { config?: Record; searchConfig?: SearchConfigRecord }, args: Record, + signal?: AbortSignal, ): Promise> { const searchConfig = mergeScopedSearchConfig( ctx.searchConfig, @@ -234,7 +237,9 @@ export async function executeParallelWebSearchProviderTool( sessionId, clientModel, timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), + signal, }); + signal?.throwIfAborted(); const results = mapParallelResults(response); const payload: Record = { diff --git a/extensions/parallel/src/parallel-web-search-provider.test.ts b/extensions/parallel/src/parallel-web-search-provider.test.ts index 766c95f5721b..f3813087289b 100644 --- a/extensions/parallel/src/parallel-web-search-provider.test.ts +++ b/extensions/parallel/src/parallel-web-search-provider.test.ts @@ -1,7 +1,12 @@ import { expectDefined } from "@openclaw/normalization-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createStreamingResponse } from "../../test-support/streaming-error-response.js"; -type EndpointCall = { url: string; timeoutSeconds: number; init: RequestInit }; +type EndpointCall = { + url: string; + timeoutSeconds: number; + init: RequestInit; + signal?: AbortSignal; +}; type JsonRecord = Record; type ToolParameters = { properties: Record< @@ -307,6 +312,30 @@ describe("parallel web search provider", () => { expect(result).not.toHaveProperty("objective"); expect(result).toMatchObject({ provider: "parallel" }); }); + it("forwards paid-search cancellation to the guarded endpoint", async () => { + enqueueJson(); + const controller = new AbortController(); + + await paidTool().execute( + { search_queries: ["parallel active cancellation"] }, + { signal: controller.signal }, + ); + + expect(endpointCall(0).signal).toBe(controller.signal); + }); + it("does not bill an already canceled paid search", async () => { + enqueueJson(); + const controller = new AbortController(); + controller.abort(new Error("Parallel caller canceled")); + + await expect( + paidTool().execute( + { search_queries: ["parallel pre-canceled"] }, + { signal: controller.signal }, + ), + ).rejects.toThrow("Parallel caller canceled"); + expect(endpointMockState.calls).toHaveLength(0); + }); it("returns an error payload when search_queries is missing or empty", async () => { const tool = paidTool(); expect(await tool.execute({ objective: "Find OpenClaw on GitHub" })).toMatchObject({ @@ -669,6 +698,19 @@ describe("runParallelMcpSearch", () => { }); }); describe("parallel-free web search provider", () => { + it("keeps caller cancellation attached to every free MCP handshake step", async () => { + pushMcpHandshake({ search_id: "free-cancellation", results: [] }); + const controller = new AbortController(); + + await freeTool().execute( + { search_queries: ["parallel free cancellation control"] }, + { signal: controller.signal }, + ); + + expect(endpointMockState.calls).toHaveLength(3); + expect(endpointMockState.calls.every((call) => call.signal === controller.signal)).toBe(true); + }); + it("exposes keyless metadata without claiming auto-detect fallback", () => { const provider = createParallelFreeWebSearchProvider(); expect(provider.id).toBe("parallel-free"); diff --git a/extensions/parallel/src/parallel-web-search-provider.ts b/extensions/parallel/src/parallel-web-search-provider.ts index 912a4607871b..b2ae3ef533f1 100644 --- a/extensions/parallel/src/parallel-web-search-provider.ts +++ b/extensions/parallel/src/parallel-web-search-provider.ts @@ -62,9 +62,10 @@ export function createParallelWebSearchProvider(): WebSearchProviderPlugin { description: "Search the web using Parallel. Returns ranked, LLM-optimized dense excerpts from web sources. Pass an `objective` describing the underlying question along with 2-3 short keyword `search_queries` (Parallel's recommended pairing). For multi-step research, thread the prior result's `sessionId` back in as `session_id` to keep Parallel's context grouped.", parameters: ParallelSearchSchema, - execute: async (args) => { + execute: async (args, context) => { + context?.signal?.throwIfAborted(); const { executeParallelWebSearchProviderTool } = await loadParallelWebSearchRuntime(); - return await executeParallelWebSearchProviderTool(ctx, args); + return await executeParallelWebSearchProviderTool(ctx, args, context?.signal); }, }), }; diff --git a/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts b/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts index b6a6c171698d..cf76aed0c653 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts @@ -201,6 +201,7 @@ async function runPerplexitySearchApi(params: { apiKey: string; count: number; timeoutSeconds: number; + signal?: AbortSignal; country?: string; searchDomainFilter?: string[]; searchRecencyFilter?: string; @@ -243,6 +244,7 @@ async function runPerplexitySearchApi(params: { { url: PERPLEXITY_SEARCH_ENDPOINT, timeoutSeconds: params.timeoutSeconds, + signal: params.signal, init: { method: "POST", headers: buildPerplexityRequestHeaders(params.apiKey, true), @@ -274,6 +276,7 @@ async function runPerplexitySearch(params: { baseUrl: string; model: string; timeoutSeconds: number; + signal?: AbortSignal; freshness?: string; }): Promise<{ content: string; citations: string[] }> { const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`; @@ -289,6 +292,7 @@ async function runPerplexitySearch(params: { { url: endpoint, timeoutSeconds: params.timeoutSeconds, + signal: params.signal, init: { method: "POST", headers: buildPerplexityRequestHeaders(params.apiKey), @@ -311,6 +315,7 @@ async function runPerplexitySearch(params: { export async function executePerplexitySearch( args: Record, searchConfig?: SearchConfigRecord, + signal?: AbortSignal, ): Promise> { const perplexityConfig = resolvePerplexityConfig(searchConfig); const runtime = resolvePerplexityTransport(perplexityConfig); @@ -499,6 +504,7 @@ export async function executePerplexitySearch( baseUrl: runtime.baseUrl, model: runtime.model, timeoutSeconds, + signal, freshness, }); return { @@ -523,6 +529,7 @@ export async function executePerplexitySearch( apiKey: runtime.apiKey, count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT), timeoutSeconds, + signal, country: country ?? undefined, searchDomainFilter: domainFilter, searchRecencyFilter: freshness, @@ -541,6 +548,7 @@ export async function executePerplexitySearch( (payload as { tookMs: number }).tookMs = Date.now() - start; } + signal?.throwIfAborted(); writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); return payload; } diff --git a/extensions/perplexity/src/perplexity-web-search-provider.test.ts b/extensions/perplexity/src/perplexity-web-search-provider.test.ts index ef5956a14c7e..1d934953e0a0 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.test.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.test.ts @@ -43,6 +43,72 @@ describe("perplexity web search provider", () => { ); }); + it.each([ + { name: "native Search API", webSearch: { apiKey: "pplx-test" } }, + { + name: "chat completions", + webSearch: { apiKey: "pplx-test", baseUrl: "https://api.perplexity.ai" }, + }, + ])("does not start an already canceled $name request", async ({ webSearch }) => { + withTrustedWebSearchEndpointMock.mockReset(); + withTrustedWebSearchEndpointMock.mockResolvedValue({ results: [] }); + const tool = createPerplexityWebSearchProvider().createTool({ + config: { plugins: { entries: { perplexity: { config: { webSearch } } } } }, + searchConfig: {}, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + const controller = new AbortController(); + controller.abort(new Error("Perplexity caller canceled")); + + await expect( + tool.execute({ query: "perplexity pre-canceled" }, { signal: controller.signal }), + ).rejects.toThrow("Perplexity caller canceled"); + expect(withTrustedWebSearchEndpointMock).not.toHaveBeenCalled(); + }); + + it.each([ + { name: "native Search API", webSearch: { apiKey: "pplx-test" } }, + { + name: "chat completions", + webSearch: { apiKey: "pplx-test", baseUrl: "https://api.perplexity.ai" }, + }, + ])("cancels an in-flight $name request", async ({ name, webSearch }) => { + withTrustedWebSearchEndpointMock.mockReset(); + withTrustedWebSearchEndpointMock.mockImplementation( + async (params: { signal?: AbortSignal }) => + await new Promise((_resolve, reject) => { + if (!params.signal) { + reject(new Error("Perplexity request lost caller cancellation")); + return; + } + params.signal.addEventListener("abort", () => reject(params.signal?.reason as Error), { + once: true, + }); + }), + ); + const tool = createPerplexityWebSearchProvider().createTool({ + config: { plugins: { entries: { perplexity: { config: { webSearch } } } } }, + searchConfig: {}, + }); + if (!tool) { + throw new Error("Expected tool definition"); + } + const controller = new AbortController(); + const result = tool.execute( + { query: `perplexity in-flight cancellation ${name}` }, + { signal: controller.signal }, + ); + + await vi.waitFor(() => expect(withTrustedWebSearchEndpointMock).toHaveBeenCalledOnce()); + controller.abort(new Error("Perplexity request canceled in flight")); + + await expect(result).rejects.toThrow("Perplexity request canceled in flight"); + expect(withTrustedWebSearchEndpointMock.mock.calls[0]?.[0]?.signal).toBe(controller.signal); + withTrustedWebSearchEndpointMock.mockReset(); + }); + it("infers provider routing from api key prefixes", () => { expect(testing.inferPerplexityBaseUrlFromApiKey("pplx-abc")).toBe("direct"); expect(testing.inferPerplexityBaseUrlFromApiKey("sk-or-v1-abc")).toBe("openrouter"); diff --git a/extensions/perplexity/src/perplexity-web-search-provider.ts b/extensions/perplexity/src/perplexity-web-search-provider.ts index b181e51dd21c..ff17cdd24c3d 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.ts @@ -97,9 +97,10 @@ function createPerplexityToolDefinition( ? "Search the web using Perplexity Sonar via Perplexity/OpenRouter chat completions. Returns AI-synthesized answers with citations from web-grounded search." : "Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path.", parameters: createPerplexityParameters(schemaTransport), - execute: async (args) => { + execute: async (args, context) => { + context?.signal?.throwIfAborted(); const { executePerplexitySearch } = await loadPerplexityWebSearchRuntime(); - return await executePerplexitySearch(args, searchConfig); + return await executePerplexitySearch(args, searchConfig, context?.signal); }, }; } diff --git a/extensions/qa-lab/src/qa-web-search-provider.test.ts b/extensions/qa-lab/src/qa-web-search-provider.test.ts index 9aab3071d783..879bb559439c 100644 --- a/extensions/qa-lab/src/qa-web-search-provider.test.ts +++ b/extensions/qa-lab/src/qa-web-search-provider.test.ts @@ -46,6 +46,19 @@ describe("qa-lab web search provider", () => { expect(JSON.stringify(result)).toContain("Deterministic QA Lab web_search result"); }); + it("preserves caller cancellation instead of returning a fixture result", async () => { + const tool = createQaLabWebSearchProvider().createTool({}); + if (!tool) { + throw new Error("expected QA Lab web search tool"); + } + const controller = new AbortController(); + controller.abort(new Error("QA Lab caller canceled")); + + await expect( + tool.execute({ query: "qa pre-canceled" }, { signal: controller.signal }), + ).rejects.toThrow("QA Lab caller canceled"); + }); + it("keeps malformed failure-path calls as tool failures", async () => { const provider = createQaLabWebSearchProvider(); const tool = provider.createTool({}); diff --git a/extensions/qa-lab/src/qa-web-search-provider.ts b/extensions/qa-lab/src/qa-web-search-provider.ts index 3906c5c97fe4..ac10c5bb2949 100644 --- a/extensions/qa-lab/src/qa-web-search-provider.ts +++ b/extensions/qa-lab/src/qa-web-search-provider.ts @@ -52,7 +52,8 @@ export function createQaLabWebSearchProvider(): WebSearchProviderPlugin { description: "Search a deterministic QA Lab fixture corpus. This provider is for QA runtime parity only and never calls the public web.", parameters: QaLabWebSearchSchema, - execute: async (args) => { + execute: async (args, context) => { + context?.signal?.throwIfAborted(); const query = readStringParam(args, "query", { required: true }); if (query === QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY) { throw new Error("QA Lab web_search denied input sentinel");