fix(web-search): honor provider abort signals

This commit is contained in:
Peter Steinberger
2026-05-02 05:32:48 +01:00
parent 72c8764d32
commit 1143f73842
16 changed files with 205 additions and 12 deletions

View File

@@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai
- Slack/mentions: resolve `<!subteam^...>` 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.

View File

@@ -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<string, unknown>,
searchConfig?: SearchConfigRecord,
context?: { signal?: AbortSignal },
): Promise<Record<string, unknown>> {
const unsupportedResponse = buildUnsupportedSearchFilterResponse(
{
@@ -299,6 +302,7 @@ export async function executeGeminiSearch(
baseUrl,
model,
timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig),
signal: context?.signal,
timeRangeFilter: timeRange.timeRangeFilter,
});
const payload = {

View File

@@ -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);
},
};
}

View File

@@ -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();

View File

@@ -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) }],

View File

@@ -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({

View File

@@ -79,6 +79,7 @@ export async function withTrustedWebSearchEndpoint<T>(
url: string;
timeoutSeconds: number;
init: RequestInit;
signal?: AbortSignal;
},
run: (response: Response) => Promise<T>,
): Promise<T> {
@@ -88,6 +89,7 @@ export async function withTrustedWebSearchEndpoint<T>(
url: params.url,
init: params.init,
timeoutSeconds: params.timeoutSeconds,
signal: params.signal,
},
async ({ response }) => run(response),
);
@@ -102,6 +104,7 @@ export async function postTrustedWebToolsJson<T>(
errorLabel: string;
maxErrorBytes?: number;
extraHeaders?: Record<string, string>;
signal?: AbortSignal;
},
parseResponse: (response: Response) => Promise<T>,
): Promise<T> {
@@ -110,6 +113,7 @@ export async function postTrustedWebToolsJson<T>(
{
url: params.url,
timeoutSeconds: params.timeoutSeconds,
signal: params.signal,
init: {
method: "POST",
headers: {

View File

@@ -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,
}),
);
});
});

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
};
/**

View File

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

View File

@@ -14,7 +14,10 @@ export type WebFetchProviderId = string;
export type WebSearchProviderToolDefinition = {
description: string;
parameters: TSchema;
execute: (args: Record<string, unknown>) => Promise<Record<string, unknown>>;
execute: (
args: Record<string, unknown>,
context?: WebSearchProviderToolExecutionContext,
) => Promise<Record<string, unknown>>;
};
export type WebFetchProviderToolDefinition = {
@@ -29,6 +32,10 @@ export type WebSearchProviderContext = {
runtimeMetadata?: RuntimeWebSearchMetadata;
};
export type WebSearchProviderToolExecutionContext = {
signal?: AbortSignal;
};
export type WebFetchProviderContext = {
config?: OpenClawConfig;
fetchConfig?: Record<string, unknown>;

View File

@@ -21,6 +21,7 @@ export type ResolveWebSearchDefinitionParams = {
export type RunWebSearchParams = ResolveWebSearchDefinitionParams & {
args: Record<string, unknown>;
signal?: AbortSignal;
};
export type RunWebSearchResult = {

View File

@@ -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<string, unknown>, 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]);

View File

@@ -361,7 +361,7 @@ export async function runWebSearch(params: RunWebSearchParams): Promise<RunWebSe
sawUnavailableProvider = true;
continue;
}
const executed = await definition.execute(params.args);
const executed = await definition.execute(params.args, { signal: params.signal });
if (allowFallback && isStructuredAvailabilityError(executed)) {
lastError = new Error(`web_search provider "${candidate.id}" returned ${executed.error}`);
continue;