From 20c88ef5db22e7d75f5c30464548acc9f41b09cb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Apr 2026 12:51:14 +0100 Subject: [PATCH] perf(test): narrow web tool imports --- src/agents/schema/string-enum.ts | 32 +++++++++++++ src/agents/schema/typebox.ts | 32 +------------ ...agent-registry.announce-loop-guard.test.ts | 11 +++-- .../tools/web-fetch.cf-markdown.test.ts | 4 +- .../tools/web-fetch.provider-fallback.test.ts | 2 +- src/agents/tools/web-fetch.ts | 42 ++++++++++++---- .../tools/web-search-provider-common.ts | 17 ++++++- .../tools/web-tools.enabled-defaults.test.ts | 48 ++++++++++++++++++- src/agents/tools/web-tools.fetch.test.ts | 2 +- .../tools/web-tools.readability.test.ts | 2 +- 10 files changed, 140 insertions(+), 52 deletions(-) create mode 100644 src/agents/schema/string-enum.ts diff --git a/src/agents/schema/string-enum.ts b/src/agents/schema/string-enum.ts new file mode 100644 index 00000000000..9678de1a646 --- /dev/null +++ b/src/agents/schema/string-enum.ts @@ -0,0 +1,32 @@ +import { Type } from "@sinclair/typebox"; + +type StringEnumOptions = { + description?: string; + title?: string; + default?: T[number]; +}; + +// Avoid Type.Union([Type.Literal(...)]) which compiles to anyOf. +// Some providers reject anyOf in tool schemas; a flat string enum is safer. +export function stringEnum( + values: T, + options: StringEnumOptions = {}, +) { + const enumValues = Array.isArray(values) + ? values + : values && typeof values === "object" + ? Object.values(values).filter((value): value is T[number] => typeof value === "string") + : []; + return Type.Unsafe({ + type: "string", + ...(enumValues.length > 0 ? { enum: [...enumValues] } : {}), + ...options, + }); +} + +export function optionalStringEnum( + values: T, + options: StringEnumOptions = {}, +) { + return Type.Optional(stringEnum(values, options)); +} diff --git a/src/agents/schema/typebox.ts b/src/agents/schema/typebox.ts index c43f9ef14d3..5fdecf1c6ff 100644 --- a/src/agents/schema/typebox.ts +++ b/src/agents/schema/typebox.ts @@ -3,37 +3,7 @@ import { CHANNEL_TARGET_DESCRIPTION, CHANNEL_TARGETS_DESCRIPTION, } from "../../infra/outbound/channel-target.js"; - -type StringEnumOptions = { - description?: string; - title?: string; - default?: T[number]; -}; - -// NOTE: Avoid Type.Union([Type.Literal(...)]) which compiles to anyOf. -// Some providers reject anyOf in tool schemas; a flat string enum is safer. -export function stringEnum( - values: T, - options: StringEnumOptions = {}, -) { - const enumValues = Array.isArray(values) - ? values - : values && typeof values === "object" - ? Object.values(values).filter((value): value is T[number] => typeof value === "string") - : []; - return Type.Unsafe({ - type: "string", - ...(enumValues.length > 0 ? { enum: [...enumValues] } : {}), - ...options, - }); -} - -export function optionalStringEnum( - values: T, - options: StringEnumOptions = {}, -) { - return Type.Optional(stringEnum(values, options)); -} +export { optionalStringEnum, stringEnum } from "./string-enum.js"; export function channelTargetSchema(options?: { description?: string }) { return Type.String({ diff --git a/src/agents/subagent-registry.announce-loop-guard.test.ts b/src/agents/subagent-registry.announce-loop-guard.test.ts index 0baf831f6b3..4a5c39faf1b 100644 --- a/src/agents/subagent-registry.announce-loop-guard.test.ts +++ b/src/agents/subagent-registry.announce-loop-guard.test.ts @@ -53,11 +53,6 @@ vi.mock("../infra/agent-events.js", () => ({ onAgentEvent: mocks.onAgentEvent, })); -vi.mock("./subagent-announce.js", () => ({ - runSubagentAnnounceFlow: mocks.runSubagentAnnounceFlow, - captureSubagentCompletionReply: mocks.captureSubagentCompletionReply, -})); - vi.mock("./subagent-registry.store.js", () => ({ loadSubagentRegistryFromDisk: mocks.loadSubagentRegistryFromDisk, saveSubagentRegistryToDisk: mocks.saveSubagentRegistryToDisk, @@ -101,10 +96,16 @@ describe("announce loop guard (#18264)", () => { mocks.saveSubagentRegistryToDisk.mockClear(); mocks.updateSessionStore.mockClear(); registry.resetSubagentRegistryForTests({ persist: false }); + registry.__testing.setDepsForTest({ + captureSubagentCompletionReply: mocks.captureSubagentCompletionReply, + cleanupBrowserSessionsForLifecycleEnd: async () => {}, + runSubagentAnnounceFlow: mocks.runSubagentAnnounceFlow, + }); }); afterEach(() => { registry.resetSubagentRegistryForTests({ persist: false }); + registry.__testing.setDepsForTest(); vi.useRealTimers(); vi.clearAllMocks(); }); diff --git a/src/agents/tools/web-fetch.cf-markdown.test.ts b/src/agents/tools/web-fetch.cf-markdown.test.ts index b12df8ef42d..eae01ea2715 100644 --- a/src/agents/tools/web-fetch.cf-markdown.test.ts +++ b/src/agents/tools/web-fetch.cf-markdown.test.ts @@ -2,9 +2,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { LookupFn } from "../../infra/net/ssrf.js"; import * as logger from "../../logger.js"; import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; -import { createBaseWebFetchToolConfig, makeFetchHeaders } from "./web-fetch.test-harness.js"; +import { createWebFetchTool } from "./web-fetch.js"; import "./web-fetch.test-mocks.js"; -import { createWebFetchTool } from "./web-tools.js"; +import { createBaseWebFetchToolConfig, makeFetchHeaders } from "./web-fetch.test-harness.js"; const lookupMock = vi.fn(); const baseToolConfig = createBaseWebFetchToolConfig({ diff --git a/src/agents/tools/web-fetch.provider-fallback.test.ts b/src/agents/tools/web-fetch.provider-fallback.test.ts index e251bc760f9..92a90b34c93 100644 --- a/src/agents/tools/web-fetch.provider-fallback.test.ts +++ b/src/agents/tools/web-fetch.provider-fallback.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; -import { createWebFetchTool } from "./web-tools.js"; +import { createWebFetchTool } from "./web-fetch.js"; const { resolveWebFetchDefinitionMock } = vi.hoisted(() => ({ resolveWebFetchDefinitionMock: vi.fn(), diff --git a/src/agents/tools/web-fetch.ts b/src/agents/tools/web-fetch.ts index 61675743b03..6404c79d025 100644 --- a/src/agents/tools/web-fetch.ts +++ b/src/agents/tools/web-fetch.ts @@ -10,9 +10,8 @@ import { normalizeOptionalString, } from "../../shared/string-coerce.js"; import { isRecord } from "../../utils.js"; -import { resolveWebFetchDefinition } from "../../web-fetch/runtime.js"; import { resolveWebProviderConfig } from "../../web/provider-runtime-shared.js"; -import { stringEnum } from "../schema/typebox.js"; +import { stringEnum } from "../schema/string-enum.js"; import type { AnyAgentTool } from "./common.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js"; import { @@ -23,7 +22,6 @@ import { truncateText, type ExtractMode, } from "./web-fetch-utils.js"; -import { fetchWithWebToolsNetworkGuard } from "./web-guarded-fetch.js"; import { CacheEntry, DEFAULT_CACHE_TTL_MINUTES, @@ -73,6 +71,32 @@ type WebFetchConfig = NonNullable["web"] extends infer ? Fetch : undefined : undefined; +type ResolveWebFetchDefinition = + (typeof import("../../web-fetch/runtime.js"))["resolveWebFetchDefinition"]; +type WebFetchProviderFallback = ReturnType; +type WebFetchRuntimeModule = Pick< + typeof import("../../web-fetch/runtime.js"), + "resolveWebFetchDefinition" +>; +type WebGuardedFetchModule = Pick< + typeof import("./web-guarded-fetch.js"), + "fetchWithWebToolsNetworkGuard" +>; + +let webFetchRuntimePromise: Promise | null = null; +let webGuardedFetchPromise: Promise | null = null; + +async function loadWebFetchRuntime(): Promise { + webFetchRuntimePromise ??= import("../../web-fetch/runtime.js"); + return await webFetchRuntimePromise; +} + +async function loadWebGuardedFetch(): Promise< + WebGuardedFetchModule["fetchWithWebToolsNetworkGuard"] +> { + webGuardedFetchPromise ??= import("./web-guarded-fetch.js"); + return (await webGuardedFetchPromise).fetchWithWebToolsNetworkGuard; +} function resolveFetchConfig(cfg?: OpenClawConfig): WebFetchConfig { return resolveWebProviderConfig(cfg, "fetch") as NonNullable | undefined; @@ -251,7 +275,7 @@ type WebFetchRuntimeParams = { allowRfc2544BenchmarkRange?: boolean; }; lookupFn?: LookupFn; - resolveProviderFallback: () => ReturnType; + resolveProviderFallback: () => Promise; }; function normalizeProviderFinalUrl(value: unknown): string | undefined { @@ -341,7 +365,7 @@ async function maybeFetchProviderWebFetchPayload( tookMs: number; }, ): Promise | null> { - const providerFallback = params.resolveProviderFallback(); + const providerFallback = await params.resolveProviderFallback(); if (!providerFallback) { return null; } @@ -387,6 +411,7 @@ async function runWebFetch(params: WebFetchRuntimeParams): Promise Promise) | null = null; let finalUrl = params.url; try { + const fetchWithWebToolsNetworkGuard = await loadWebGuardedFetch(); const result = await fetchWithWebToolsNetworkGuard({ url: params.url, maxRedirects: params.maxRedirects, @@ -503,7 +528,7 @@ async function runWebFetch(params: WebFetchRuntimeParams): Promise; - const resolveProviderFallback = () => { + let providerFallbackCache: WebFetchProviderFallback; + const resolveProviderFallback = async () => { if (!providerFallbackResolved) { + const { resolveWebFetchDefinition } = await loadWebFetchRuntime(); providerFallbackCache = resolveWebFetchDefinition({ config: options?.config, sandboxed: options?.sandboxed, diff --git a/src/agents/tools/web-search-provider-common.ts b/src/agents/tools/web-search-provider-common.ts index 7141c39b27f..17da3943a04 100644 --- a/src/agents/tools/web-search-provider-common.ts +++ b/src/agents/tools/web-search-provider-common.ts @@ -2,7 +2,6 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { normalizeResolvedSecretInputString } from "../../config/types.secrets.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { normalizeSecretInput } from "../../utils/normalize-secret-input.js"; -import { withTrustedWebToolsEndpoint } from "./web-guarded-fetch.js"; import { CacheEntry, DEFAULT_CACHE_TTL_MINUTES, @@ -15,6 +14,20 @@ import { writeCache, } from "./web-shared.js"; +type WebGuardedFetchModule = Pick< + typeof import("./web-guarded-fetch.js"), + "withTrustedWebToolsEndpoint" +>; + +let webGuardedFetchPromise: Promise | null = null; + +async function loadTrustedWebToolsEndpoint(): Promise< + WebGuardedFetchModule["withTrustedWebToolsEndpoint"] +> { + webGuardedFetchPromise ??= import("./web-guarded-fetch.js"); + return (await webGuardedFetchPromise).withTrustedWebToolsEndpoint; +} + export type SearchConfigRecord = (NonNullable["web"] extends infer Web ? Web extends { search?: infer Search } ? Search @@ -69,6 +82,7 @@ export async function withTrustedWebSearchEndpoint( }, run: (response: Response) => Promise, ): Promise { + const withTrustedWebToolsEndpoint = await loadTrustedWebToolsEndpoint(); return withTrustedWebToolsEndpoint( { url: params.url, @@ -91,6 +105,7 @@ export async function postTrustedWebToolsJson( }, parseResponse: (response: Response) => Promise, ): Promise { + const withTrustedWebToolsEndpoint = await loadTrustedWebToolsEndpoint(); return withTrustedWebToolsEndpoint( { url: params.url, diff --git a/src/agents/tools/web-tools.enabled-defaults.test.ts b/src/agents/tools/web-tools.enabled-defaults.test.ts index 6bc2520c939..b0b6803a792 100644 --- a/src/agents/tools/web-tools.enabled-defaults.test.ts +++ b/src/agents/tools/web-tools.enabled-defaults.test.ts @@ -1,9 +1,53 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createEmptyPluginRegistry } from "../../plugins/registry.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { clearActiveRuntimeWebToolsMetadata } from "../../secrets/runtime-web-tools-state.js"; import { createWebFetchTool, createWebSearchTool } from "./web-tools.js"; +vi.mock("../../web-search/runtime.js", async () => { + const { getActivePluginRegistry } = await import("../../plugins/runtime.js"); + const resolveRuntimeDefinition = (options?: { + config?: unknown; + runtimeWebSearch?: { selectedProvider?: string; providerConfigured?: string }; + }) => { + const providerId = + options?.runtimeWebSearch?.selectedProvider ?? options?.runtimeWebSearch?.providerConfigured; + const registration = getActivePluginRegistry()?.webSearchProviders.find( + (entry) => entry.provider.id === providerId, + ); + const definition = registration?.provider.createTool({ + config: options?.config as never, + runtimeMetadata: options?.runtimeWebSearch as never, + }); + return registration && definition + ? { + provider: { + ...registration.provider, + pluginId: registration.pluginId, + }, + definition, + } + : null; + }; + return { + resolveWebSearchDefinition: resolveRuntimeDefinition, + resolveWebSearchProviderId: () => "", + runWebSearch: async (options: { + args: Record; + runtimeWebSearch?: unknown; + }) => { + const resolved = resolveRuntimeDefinition(options as never); + if (!resolved) { + throw new Error("web_search is disabled or no provider is available."); + } + return { + provider: resolved.provider.id, + result: await resolved.definition.execute(options.args), + }; + }, + }; +}); + beforeEach(() => { setActivePluginRegistry(createEmptyPluginRegistry()); clearActiveRuntimeWebToolsMetadata(); diff --git a/src/agents/tools/web-tools.fetch.test.ts b/src/agents/tools/web-tools.fetch.test.ts index b638a8d1d63..9cddaa87003 100644 --- a/src/agents/tools/web-tools.fetch.test.ts +++ b/src/agents/tools/web-tools.fetch.test.ts @@ -20,7 +20,7 @@ vi.mock("./web-fetch-utils.js", async () => { vi.mock("../../web-fetch/runtime.js", () => ({ resolveWebFetchDefinition: resolveWebFetchDefinitionMock, })); -import { createWebFetchTool } from "./web-tools.js"; +import { createWebFetchTool } from "./web-fetch.js"; const lookupMock = vi.fn(); diff --git a/src/agents/tools/web-tools.readability.test.ts b/src/agents/tools/web-tools.readability.test.ts index e907f16fa90..256353cc4c6 100644 --- a/src/agents/tools/web-tools.readability.test.ts +++ b/src/agents/tools/web-tools.readability.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { extractReadableContent } from "./web-tools.js"; +import { extractReadableContent } from "./web-fetch.js"; const SAMPLE_HTML = `