summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-web-search/src/validate.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-21 13:11:29 +0900
committerAdam Malczewski <[email protected]>2026-06-21 13:11:29 +0900
commit8a4a624d16422467a8e85434c674bb591877e8ea (patch)
tree54052da00bbc580742913e5c031b7cc1b160db19 /packages/tool-web-search/src/validate.ts
parentd23de3254374d4d63c8e15c6ab9311c3c6f4da5b (diff)
downloaddispatch-8a4a624d16422467a8e85434c674bb591877e8ea.tar.gz
dispatch-8a4a624d16422467a8e85434c674bb591877e8ea.zip
feat(tool-web-search): Firecrawl-backed web search tool
New standard tool extension with one tool web_search supporting 4 modes (search, scrape, crawl, map) against a self-hosted Firecrawl instance. Pure core: validateArgs (discriminated union by mode) + format* functions + truncateOutput. Injected edge: FirecrawlClient (injectable fetchFn/sleep/now, AbortSignal.any for per-request timeout + caller cancellation). concurrencySafe true, capabilities network. 38 tests, zero vi.mock. Live-verified: umans-glm-5.2 called web_search → real Firecrawl results (also the first live Umans API call).
Diffstat (limited to 'packages/tool-web-search/src/validate.ts')
-rw-r--r--packages/tool-web-search/src/validate.ts212
1 files changed, 212 insertions, 0 deletions
diff --git a/packages/tool-web-search/src/validate.ts b/packages/tool-web-search/src/validate.ts
new file mode 100644
index 0000000..56bd356
--- /dev/null
+++ b/packages/tool-web-search/src/validate.ts
@@ -0,0 +1,212 @@
+/**
+ * Pure argument validation for the web_search tool — input → output, no I/O.
+ *
+ * Resolves the operation mode (explicit, or inferred from `query`/`url`),
+ * applies per-mode field requirements, clamps `limit`, and defaults `format`.
+ * Returns a discriminated union so the tool's dispatch narrows by `mode`.
+ */
+
+export const MODES = ["search", "scrape", "crawl", "map"] as const;
+export type Mode = (typeof MODES)[number];
+
+export const FORMATS = ["markdown", "text", "html"] as const;
+export type Format = (typeof FORMATS)[number];
+
+export const SEARCH_DEFAULT_LIMIT = 7;
+export const CRAWL_DEFAULT_LIMIT = 3;
+export const MAX_LIMIT = 10;
+
+interface BaseArgs {
+ readonly format: Format;
+}
+
+export interface SearchArgs extends BaseArgs {
+ readonly mode: "search";
+ readonly query: string;
+ readonly limit: number;
+ readonly scrape: boolean;
+ readonly lang?: string;
+ readonly country?: string;
+}
+
+export interface ScrapeArgs extends BaseArgs {
+ readonly mode: "scrape";
+ readonly url: string;
+}
+
+export interface CrawlArgs extends BaseArgs {
+ readonly mode: "crawl";
+ readonly url: string;
+ readonly limit: number;
+}
+
+export interface MapArgs extends BaseArgs {
+ readonly mode: "map";
+ readonly url: string;
+}
+
+export type ValidatedArgs = SearchArgs | ScrapeArgs | CrawlArgs | MapArgs;
+
+export type ValidationError = { readonly error: string };
+
+type Result<T> = { readonly value: T } | ValidationError;
+
+function resolveFormat(raw: unknown): Result<Format> {
+ if (raw === undefined || raw === null) {
+ return { value: "markdown" };
+ }
+ if (typeof raw === "string" && (FORMATS as readonly string[]).includes(raw)) {
+ return { value: raw as Format };
+ }
+ return {
+ error: `Error: Invalid format "${String(raw)}" (must be one of: markdown, text, html).`,
+ };
+}
+
+function resolveMode(raw: unknown, query: unknown, url: unknown): Result<Mode> {
+ if (raw === undefined || raw === null) {
+ const hasQuery = typeof query === "string" && query.trim().length > 0;
+ const hasUrl = typeof url === "string" && url.trim().length > 0;
+ return { value: hasQuery ? "search" : hasUrl ? "scrape" : "search" };
+ }
+ if (typeof raw === "string" && (MODES as readonly string[]).includes(raw)) {
+ return { value: raw as Mode };
+ }
+ return {
+ error: `Error: Invalid mode "${String(raw)}" (must be one of: search, scrape, crawl, map).`,
+ };
+}
+
+function optionalString(raw: unknown, name: string): Result<string | undefined> {
+ if (raw === undefined || raw === null) {
+ return { value: undefined };
+ }
+ if (typeof raw === "string") {
+ return { value: raw };
+ }
+ return { error: `Error: "${name}" must be a string.` };
+}
+
+function resolveLimit(raw: unknown, defaultLimit: number): Result<number> {
+ if (raw === undefined || raw === null) {
+ return { value: defaultLimit };
+ }
+ const n = Number(raw);
+ if (!Number.isFinite(n) || n < 1) {
+ return { error: 'Error: "limit" must be a positive number.' };
+ }
+ return { value: Math.min(Math.floor(n), MAX_LIMIT) };
+}
+
+function resolveBoolean(raw: unknown, name: string): Result<boolean> {
+ if (raw === undefined || raw === null) {
+ return { value: false };
+ }
+ if (typeof raw === "boolean") {
+ return { value: raw };
+ }
+ return { error: `Error: "${name}" must be a boolean.` };
+}
+
+/**
+ * Validate raw tool args and resolve a typed, mode-aware `ValidatedArgs`.
+ * Returns `{ error }` for invalid input — the tool surfaces it verbatim.
+ */
+export function validateArgs(args: unknown): ValidatedArgs | ValidationError {
+ if (args === null || args === undefined || typeof args !== "object") {
+ return { error: "Error: Arguments must be an object." };
+ }
+ const obj = args as Record<string, unknown>;
+
+ const format = resolveFormat(obj.format);
+ if ("error" in format) {
+ return format;
+ }
+
+ const mode = resolveMode(obj.mode, obj.query, obj.url);
+ if ("error" in mode) {
+ return mode;
+ }
+
+ const query = optionalString(obj.query, "query");
+ if ("error" in query) {
+ return query;
+ }
+
+ const url = optionalString(obj.url, "url");
+ if ("error" in url) {
+ return url;
+ }
+
+ switch (mode.value) {
+ case "search": {
+ if (query.value === undefined || query.value.trim().length === 0) {
+ return { error: "Error: query is required for search mode." };
+ }
+ const limit = resolveLimit(obj.limit, SEARCH_DEFAULT_LIMIT);
+ if ("error" in limit) {
+ return limit;
+ }
+ const scrape = resolveBoolean(obj.scrape, "scrape");
+ if ("error" in scrape) {
+ return scrape;
+ }
+ const lang = optionalString(obj.lang, "lang");
+ if ("error" in lang) {
+ return lang;
+ }
+ const country = optionalString(obj.country, "country");
+ if ("error" in country) {
+ return country;
+ }
+ const result: SearchArgs = {
+ mode: "search",
+ query: query.value,
+ limit: limit.value,
+ scrape: scrape.value,
+ format: format.value,
+ ...(lang.value !== undefined ? { lang: lang.value } : {}),
+ ...(country.value !== undefined ? { country: country.value } : {}),
+ };
+ return result;
+ }
+ case "scrape": {
+ if (url.value === undefined || url.value.trim().length === 0) {
+ return { error: "Error: url is required for scrape mode." };
+ }
+ const result: ScrapeArgs = {
+ mode: "scrape",
+ url: url.value,
+ format: format.value,
+ };
+ return result;
+ }
+ case "crawl": {
+ if (url.value === undefined || url.value.trim().length === 0) {
+ return { error: "Error: url is required for crawl mode." };
+ }
+ const limit = resolveLimit(obj.limit, CRAWL_DEFAULT_LIMIT);
+ if ("error" in limit) {
+ return limit;
+ }
+ const result: CrawlArgs = {
+ mode: "crawl",
+ url: url.value,
+ limit: limit.value,
+ format: format.value,
+ };
+ return result;
+ }
+ case "map": {
+ if (url.value === undefined || url.value.trim().length === 0) {
+ return { error: "Error: url is required for map mode." };
+ }
+ const result: MapArgs = {
+ mode: "map",
+ url: url.value,
+ format: format.value,
+ };
+ return result;
+ }
+ }
+}