perf(test): narrow web tool imports

This commit is contained in:
Peter Steinberger
2026-04-20 12:51:14 +01:00
parent 6c711a64cb
commit 20c88ef5db
10 changed files with 140 additions and 52 deletions

View File

@@ -0,0 +1,32 @@
import { Type } from "@sinclair/typebox";
type StringEnumOptions<T extends readonly string[]> = {
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<T extends readonly string[]>(
values: T,
options: StringEnumOptions<T> = {},
) {
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<T[number]>({
type: "string",
...(enumValues.length > 0 ? { enum: [...enumValues] } : {}),
...options,
});
}
export function optionalStringEnum<T extends readonly string[]>(
values: T,
options: StringEnumOptions<T> = {},
) {
return Type.Optional(stringEnum(values, options));
}

View File

@@ -3,37 +3,7 @@ import {
CHANNEL_TARGET_DESCRIPTION,
CHANNEL_TARGETS_DESCRIPTION,
} from "../../infra/outbound/channel-target.js";
type StringEnumOptions<T extends readonly string[]> = {
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<T extends readonly string[]>(
values: T,
options: StringEnumOptions<T> = {},
) {
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<T[number]>({
type: "string",
...(enumValues.length > 0 ? { enum: [...enumValues] } : {}),
...options,
});
}
export function optionalStringEnum<T extends readonly string[]>(
values: T,
options: StringEnumOptions<T> = {},
) {
return Type.Optional(stringEnum(values, options));
}
export { optionalStringEnum, stringEnum } from "./string-enum.js";
export function channelTargetSchema(options?: { description?: string }) {
return Type.String({

View File

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

View File

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

View File

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

View File

@@ -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<OpenClawConfig["tools"]>["web"] extends infer
? Fetch
: undefined
: undefined;
type ResolveWebFetchDefinition =
(typeof import("../../web-fetch/runtime.js"))["resolveWebFetchDefinition"];
type WebFetchProviderFallback = ReturnType<ResolveWebFetchDefinition>;
type WebFetchRuntimeModule = Pick<
typeof import("../../web-fetch/runtime.js"),
"resolveWebFetchDefinition"
>;
type WebGuardedFetchModule = Pick<
typeof import("./web-guarded-fetch.js"),
"fetchWithWebToolsNetworkGuard"
>;
let webFetchRuntimePromise: Promise<WebFetchRuntimeModule> | null = null;
let webGuardedFetchPromise: Promise<WebGuardedFetchModule> | null = null;
async function loadWebFetchRuntime(): Promise<WebFetchRuntimeModule> {
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<WebFetchConfig> | undefined;
@@ -251,7 +275,7 @@ type WebFetchRuntimeParams = {
allowRfc2544BenchmarkRange?: boolean;
};
lookupFn?: LookupFn;
resolveProviderFallback: () => ReturnType<typeof resolveWebFetchDefinition>;
resolveProviderFallback: () => Promise<WebFetchProviderFallback>;
};
function normalizeProviderFinalUrl(value: unknown): string | undefined {
@@ -341,7 +365,7 @@ async function maybeFetchProviderWebFetchPayload(
tookMs: number;
},
): Promise<Record<string, unknown> | null> {
const providerFallback = params.resolveProviderFallback();
const providerFallback = await params.resolveProviderFallback();
if (!providerFallback) {
return null;
}
@@ -387,6 +411,7 @@ async function runWebFetch(params: WebFetchRuntimeParams): Promise<Record<string
let release: (() => Promise<void>) | 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<Record<string
extractor = "raw-html";
} else {
const providerLabel =
params.resolveProviderFallback()?.provider.label ?? "provider fallback";
(await params.resolveProviderFallback())?.provider.label ?? "provider fallback";
throw new Error(
`Web fetch extraction failed: Readability, ${providerLabel}, and basic HTML cleanup returned no content.`,
);
@@ -583,9 +608,10 @@ export function createWebFetchTool(options?: {
DEFAULT_FETCH_USER_AGENT;
const maxResponseBytes = resolveFetchMaxResponseBytes(fetch);
let providerFallbackResolved = false;
let providerFallbackCache: ReturnType<typeof resolveWebFetchDefinition>;
const resolveProviderFallback = () => {
let providerFallbackCache: WebFetchProviderFallback;
const resolveProviderFallback = async () => {
if (!providerFallbackResolved) {
const { resolveWebFetchDefinition } = await loadWebFetchRuntime();
providerFallbackCache = resolveWebFetchDefinition({
config: options?.config,
sandboxed: options?.sandboxed,

View File

@@ -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<WebGuardedFetchModule> | null = null;
async function loadTrustedWebToolsEndpoint(): Promise<
WebGuardedFetchModule["withTrustedWebToolsEndpoint"]
> {
webGuardedFetchPromise ??= import("./web-guarded-fetch.js");
return (await webGuardedFetchPromise).withTrustedWebToolsEndpoint;
}
export type SearchConfigRecord = (NonNullable<OpenClawConfig["tools"]>["web"] extends infer Web
? Web extends { search?: infer Search }
? Search
@@ -69,6 +82,7 @@ export async function withTrustedWebSearchEndpoint<T>(
},
run: (response: Response) => Promise<T>,
): Promise<T> {
const withTrustedWebToolsEndpoint = await loadTrustedWebToolsEndpoint();
return withTrustedWebToolsEndpoint(
{
url: params.url,
@@ -91,6 +105,7 @@ export async function postTrustedWebToolsJson<T>(
},
parseResponse: (response: Response) => Promise<T>,
): Promise<T> {
const withTrustedWebToolsEndpoint = await loadTrustedWebToolsEndpoint();
return withTrustedWebToolsEndpoint(
{
url: params.url,

View File

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

View File

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

View File

@@ -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 = `<!doctype html>
<html lang="en">