diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts index 6881d3d20f99..a1ad731e229c 100644 --- a/src/cron/isolated-agent/run.test-harness.ts +++ b/src/cron/isolated-agent/run.test-harness.ts @@ -95,8 +95,7 @@ export const getChannelPluginMock = createMock(); export const retireSessionMcpRuntimeMock = createMock(); export const callGatewayMock = createMock(); export const ensureRuntimePluginsLoadedMock = createMock(); -export const listWebSearchProvidersMock = createMock(); -export const resolveWebSearchProviderIdMock = createMock(); +export const hasUsableWebSearchProviderMock = createMock(); export const classifyEmbeddedAgentRunResultForModelFallbackMock = createMock(); export const mergeEmbeddedAgentRunResultForModelFallbackExhaustionMock = createMock(); @@ -187,8 +186,7 @@ vi.mock("../../plugins/runtime-plugins.runtime.js", () => ({ })); vi.mock("../../web-search/runtime.js", () => ({ - listWebSearchProviders: listWebSearchProvidersMock, - resolveWebSearchProviderId: resolveWebSearchProviderIdMock, + hasUsableWebSearchProvider: hasUsableWebSearchProviderMock, })); vi.mock("../../skills/runtime/cron-snapshot.runtime.js", () => ({ @@ -814,10 +812,11 @@ export function resetRunCronIsolatedAgentTurnHarness(): void { setSessionRuntimeModelMock.mockReturnValue(undefined); logWarnMock.mockReset(); ensureRuntimePluginsLoadedMock.mockReset(); - listWebSearchProvidersMock.mockReset(); - listWebSearchProvidersMock.mockReturnValue([{ id: "duckduckgo" }]); - resolveWebSearchProviderIdMock.mockReset(); - resolveWebSearchProviderIdMock.mockReturnValue("duckduckgo"); + hasUsableWebSearchProviderMock.mockReset(); + hasUsableWebSearchProviderMock.mockImplementation( + (params?: { runtimeWebSearch?: { selectedProvider?: string } }) => + Boolean(params?.runtimeWebSearch?.selectedProvider), + ); } export function clearFastTestEnv(): string | undefined { diff --git a/src/cron/isolated-agent/run.tools-allow.test.ts b/src/cron/isolated-agent/run.tools-allow.test.ts index 90a715f38cfb..d3a8cd1c4b8e 100644 --- a/src/cron/isolated-agent/run.tools-allow.test.ts +++ b/src/cron/isolated-agent/run.tools-allow.test.ts @@ -2,13 +2,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import "../../agents/test-helpers/fast-coding-tools.js"; import { - listWebSearchProvidersMock, + clearActiveRuntimeWebToolsMetadata, + setActiveRuntimeWebToolsMetadata, +} from "../../secrets/runtime-web-tools-state.js"; +import { + hasUsableWebSearchProviderMock, loadModelCatalogMock, loadRunCronIsolatedAgentTurn, resolveConfiguredModelRefMock, resetRunCronIsolatedAgentTurnHarness, resolveDeliveryTargetMock, - resolveWebSearchProviderIdMock, runEmbeddedAgentMock, runWithModelFallbackMock, } from "./run.test-harness.js"; @@ -93,6 +96,7 @@ describe("runCronIsolatedAgentTurn toolsAllow passthrough", () => { previousFastTestEnv = process.env.OPENCLAW_TEST_FAST; vi.stubEnv("OPENCLAW_TEST_FAST", "1"); resetRunCronIsolatedAgentTurnHarness(); + clearActiveRuntimeWebToolsMetadata(); resolveDeliveryTargetMock.mockResolvedValue({ channel: "forum", to: "123", @@ -106,6 +110,7 @@ describe("runCronIsolatedAgentTurn toolsAllow passthrough", () => { }); afterEach(() => { + clearActiveRuntimeWebToolsMetadata(); if (previousFastTestEnv == null) { vi.unstubAllEnvs(); delete process.env.OPENCLAW_TEST_FAST; @@ -156,9 +161,6 @@ describe("runCronIsolatedAgentTurn toolsAllow passthrough", () => { "adds cron diagnostics when web_search is allowed without a selected provider", { timeout: RUN_TOOLS_ALLOW_TIMEOUT_MS }, async () => { - listWebSearchProvidersMock.mockReturnValue([{ id: "duckduckgo" }]); - resolveWebSearchProviderIdMock.mockReturnValue(""); - const result = await runCronIsolatedAgentTurn(makeParamsWithToolsAllow(["web_search"])); expect(result.status).toBe("ok"); @@ -178,12 +180,54 @@ describe("runCronIsolatedAgentTurn toolsAllow passthrough", () => { }, ); + it( + "uses the prepared provider selected from a plugin-scoped web search key", + { timeout: RUN_TOOLS_ALLOW_TIMEOUT_MS }, + async () => { + setActiveRuntimeWebToolsMetadata({ + search: { + providerSource: "auto-detect", + selectedProvider: "brave", + selectedProviderKeySource: "config", + diagnostics: [], + }, + fetch: { providerSource: "none", diagnostics: [] }, + diagnostics: [], + }); + const cfg = { + plugins: { + entries: { + brave: { + enabled: true, + config: { + webSearch: { apiKey: "token-oversized" }, + }, + }, + }, + }, + }; + + const result = await runCronIsolatedAgentTurn({ + ...makeParamsWithToolsAllow(["web_search"]), + cfg, + }); + + expect(result.status).toBe("ok"); + expect(result.diagnostics).toBeUndefined(); + expect(hasUsableWebSearchProviderMock).toHaveBeenCalledWith( + expect.objectContaining({ + agentDir: "/tmp/agent-dir", + preferRuntimeProviders: true, + runtimeWebSearch: expect.objectContaining({ selectedProvider: "brave" }), + }), + ); + }, + ); + it( "does not warn for default-derived toolsAllow that includes web_search", { timeout: RUN_TOOLS_ALLOW_TIMEOUT_MS }, async () => { - listWebSearchProvidersMock.mockReturnValue([]); - const result = await runCronIsolatedAgentTurn( makeParamsWithDefaultToolsAllow(["web_search"]), ); @@ -197,7 +241,6 @@ describe("runCronIsolatedAgentTurn toolsAllow passthrough", () => { "does not warn when native web_search suppresses the managed provider tool", { timeout: RUN_TOOLS_ALLOW_TIMEOUT_MS }, async () => { - listWebSearchProvidersMock.mockReturnValue([]); resolveConfiguredModelRefMock.mockReturnValue({ provider: "gateway", model: "gpt-5.5", @@ -237,8 +280,6 @@ describe("runCronIsolatedAgentTurn toolsAllow passthrough", () => { "keeps web_search provider diagnostics when the run aborts", { timeout: RUN_TOOLS_ALLOW_TIMEOUT_MS }, async () => { - listWebSearchProvidersMock.mockReturnValue([]); - resolveWebSearchProviderIdMock.mockReturnValue(""); runWithModelFallbackMock.mockResolvedValueOnce({ result: { payloads: [], diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index ccb324e2adb8..4ec4ed5a45a1 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -155,6 +155,9 @@ const runtimePluginsLoader = createLazyImportLoader( const codexNativeWebSearchLoader = createLazyImportLoader( () => import("../../agents/codex-native-web-search.js"), ); +const webToolRuntimeContextLoader = createLazyImportLoader( + () => import("../../agents/tools/web-tool-runtime-context.js"), +); const webSearchRuntimeLoader = createLazyImportLoader(() => import("../../web-search/runtime.js")); async function loadSessionAccessorRuntime() { @@ -197,6 +200,10 @@ async function loadCodexNativeWebSearch() { return await codexNativeWebSearchLoader.load(); } +async function loadWebToolRuntimeContext() { + return await webToolRuntimeContextLoader.load(); +} + async function loadWebSearchRuntime() { return await webSearchRuntimeLoader.load(); } @@ -389,21 +396,27 @@ async function createCronToolsAllowPreflightDiagnostics(params: { ) { return undefined; } - const { listWebSearchProviders, resolveWebSearchProviderId } = await loadWebSearchRuntime(); - const webSearchProviders = listWebSearchProviders({ config: params.cfg }); + const { resolveWebSearchToolRuntimeContext } = await loadWebToolRuntimeContext(); + const { config, preferRuntimeProviders, runtimeWebSearch } = resolveWebSearchToolRuntimeContext( + { + config: params.cfg, + lateBindRuntimeConfig: true, + }, + ); + const { hasUsableWebSearchProvider } = await loadWebSearchRuntime(); + const hasWebSearchProvider = hasUsableWebSearchProvider({ + config, + agentDir: params.agentDir, + runtimeWebSearch, + preferRuntimeProviders, + }); return createCronRunDiagnosticsFromMissingWebSearchProvider({ toolsAllow, - hasWebSearchProvider: Boolean( - resolveWebSearchProviderId({ - config: params.cfg, - agentDir: params.agentDir, - providers: webSearchProviders, - }), - ), + hasWebSearchProvider, }); } catch (error) { logWarn( - `[cron:${params.jobId}] Failed to inspect web_search providers for toolsAllow diagnostics: ${String(error)}`, + `[cron:${params.jobId}] Failed to inspect web_search provider state for toolsAllow diagnostics: ${String(error)}`, ); return undefined; } diff --git a/src/web-search/runtime.test.ts b/src/web-search/runtime.test.ts index 3564c5b1f59b..c3890c7a6498 100644 --- a/src/web-search/runtime.test.ts +++ b/src/web-search/runtime.test.ts @@ -165,6 +165,7 @@ function createDuckDuckGoSearchProvider( } describe("web search runtime", () => { + let hasUsableWebSearchProvider: typeof import("./runtime.js").hasUsableWebSearchProvider; let runWebSearch: typeof import("./runtime.js").runWebSearch; let activateSecretsRuntimeSnapshot: typeof import("../secrets/runtime.js").activateSecretsRuntimeSnapshot; let clearSecretsRuntimeSnapshot: typeof import("../secrets/runtime.js").clearSecretsRuntimeSnapshot; @@ -172,7 +173,7 @@ describe("web search runtime", () => { const tempDirs: string[] = []; beforeAll(async () => { - ({ runWebSearch } = await import("./runtime.js")); + ({ hasUsableWebSearchProvider, runWebSearch } = await import("./runtime.js")); ({ activateSecretsRuntimeSnapshot, clearSecretsRuntimeSnapshot } = await import("../secrets/runtime.js")); ({ setRuntimeConfigSnapshot } = await import("../config/config.js")); @@ -222,6 +223,23 @@ describe("web search runtime", () => { }); }); + it("accepts the prepared provider selection without rediscovering providers", () => { + expect( + hasUsableWebSearchProvider({ + config: {}, + runtimeWebSearch: { + providerSource: "auto-detect", + selectedProvider: "brave", + selectedProviderKeySource: "config", + diagnostics: [], + }, + preferRuntimeProviders: true, + }), + ).toBe(true); + expect(resolveRuntimeWebSearchProvidersMock).not.toHaveBeenCalled(); + expect(resolvePluginWebSearchProvidersMock).not.toHaveBeenCalled(); + }); + it("passes the run abort signal to provider execution", async () => { const controller = new AbortController(); const execute = vi.fn( @@ -474,18 +492,27 @@ describe("web search runtime", () => { provider, createDuckDuckGoSearchProvider(), ]); + const config = { + agents: { + list: [ + { id: "main", default: true, agentDir: defaultAgentDir }, + { id: "side", agentDir: activeAgentDir }, + ], + }, + } satisfies OpenClawConfig; + + expect( + hasUsableWebSearchProvider({ + agentDir: activeAgentDir, + config, + preferRuntimeProviders: true, + }), + ).toBe(true); await expect( runWebSearch({ agentDir: activeAgentDir, - config: { - agents: { - list: [ - { id: "main", default: true, agentDir: defaultAgentDir }, - { id: "side", agentDir: activeAgentDir }, - ], - }, - }, + config, args: { query: "active-agent oauth-backed web search" }, }), ).resolves.toEqual({ diff --git a/src/web-search/runtime.ts b/src/web-search/runtime.ts index e9c6de9b3249..7f66b0d5eec3 100644 --- a/src/web-search/runtime.ts +++ b/src/web-search/runtime.ts @@ -406,6 +406,16 @@ function resolveWebSearchCandidates( return orderedProviders; } +/** Reports whether web_search can use the prepared selection or resolve an agent-scoped provider. */ +export function hasUsableWebSearchProvider(options?: ResolveWebSearchDefinitionParams): boolean { + // Prepared metadata owns config/secret selection. Candidate resolution remains necessary for + // credentials scoped to the active agent, such as provider auth profiles. + if (normalizeOptionalLowercaseString(options?.runtimeWebSearch?.selectedProvider)) { + return true; + } + return resolveWebSearchCandidates(options).length > 0; +} + function hasExplicitWebSearchSelection(params: { search?: WebSearchConfig; runtimeWebSearch?: RuntimeWebSearchMetadata;