mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
fix(web-search): honor provider abort signals
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) }],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
35
src/agents/tools/web-search.signal.test.ts
Normal file
35
src/agents/tools/web-search.signal.test.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -21,6 +21,7 @@ export type ResolveWebSearchDefinitionParams = {
|
||||
|
||||
export type RunWebSearchParams = ResolveWebSearchDefinitionParams & {
|
||||
args: Record<string, unknown>;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type RunWebSearchResult = {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user