mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
perf(test): narrow web tool imports
This commit is contained in:
32
src/agents/schema/string-enum.ts
Normal file
32
src/agents/schema/string-enum.ts
Normal 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));
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user