summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-web-search/src/tool.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/tool.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/tool.ts')
-rw-r--r--packages/tool-web-search/src/tool.ts142
1 files changed, 142 insertions, 0 deletions
diff --git a/packages/tool-web-search/src/tool.ts b/packages/tool-web-search/src/tool.ts
new file mode 100644
index 0000000..751278d
--- /dev/null
+++ b/packages/tool-web-search/src/tool.ts
@@ -0,0 +1,142 @@
+/**
+ * web_search tool factory — the imperative shell that binds the pure
+ * validate/format functions to the injected FirecrawlClient edge.
+ *
+ * Mirrors the tool-shell pattern: factory + injected dep + pure helpers +
+ * a `ToolResult` returned per call. Errors surface as `{ isError: true }`
+ * rather than thrown, so the model can react to the message.
+ */
+
+import type { ToolContract, ToolExecuteContext, ToolResult } from "@dispatch/kernel";
+import type { FirecrawlClient } from "./client.js";
+import {
+ formatCrawlResults,
+ formatMapResults,
+ formatScrapeResult,
+ formatSearchResults,
+ truncateOutput,
+} from "./format.js";
+import type { ValidatedArgs } from "./validate.js";
+import { validateArgs } from "./validate.js";
+
+const OUTPUT_CAP = 50_000;
+
+export interface WebSearchToolDeps {
+ readonly client: FirecrawlClient;
+ readonly outputCap?: number;
+}
+
+/** Dispatch validated args to the right client method and format the result. */
+async function runMode(
+ validated: ValidatedArgs,
+ client: FirecrawlClient,
+ signal: AbortSignal,
+): Promise<string> {
+ switch (validated.mode) {
+ case "search": {
+ const hits = await client.search(
+ {
+ query: validated.query,
+ limit: validated.limit,
+ ...(validated.scrape
+ ? { scrapeOptions: { formats: ["markdown"], onlyMainContent: true } }
+ : {}),
+ ...(validated.lang !== undefined ? { lang: validated.lang } : {}),
+ ...(validated.country !== undefined ? { country: validated.country } : {}),
+ },
+ signal,
+ );
+ return formatSearchResults(hits);
+ }
+ case "scrape": {
+ const result = await client.scrape(
+ { url: validated.url, formats: [validated.format] },
+ signal,
+ );
+ return formatScrapeResult(result);
+ }
+ case "crawl": {
+ const pages = await client.crawl(
+ { url: validated.url, limit: validated.limit, formats: [validated.format] },
+ signal,
+ );
+ return formatCrawlResults(pages);
+ }
+ case "map": {
+ const links = await client.map(validated.url, signal);
+ return formatMapResults(links);
+ }
+ }
+}
+
+/**
+ * Create the `web_search` tool. `concurrencySafe: true` — web search is
+ * idempotent and safe to run alongside other tools. The `network` capability
+ * is declared on the extension manifest (not the tool contract).
+ */
+export function createWebSearchTool(deps: WebSearchToolDeps): ToolContract {
+ const client = deps.client;
+ const cap = deps.outputCap ?? OUTPUT_CAP;
+
+ return {
+ name: "web_search",
+ description:
+ "Access the web via a self-hosted Firecrawl instance. Supports search, " +
+ "single-page scrape, site crawling, and sitemap discovery.",
+ parameters: {
+ type: "object",
+ properties: {
+ query: { type: "string", description: "The search query (search mode)." },
+ url: { type: "string", description: "A URL to scrape, crawl, or map." },
+ mode: {
+ type: "string",
+ enum: ["search", "scrape", "crawl", "map"],
+ description:
+ "Operation mode. 'search' (default when query present), 'scrape' " +
+ "(default when url present), 'crawl' (recursively scrape pages from a site), " +
+ "'map' (discover URLs on a site).",
+ },
+ limit: {
+ type: "number",
+ description: "Max results. Search: default 7, max 10. Crawl: default 3, max 10.",
+ },
+ scrape: {
+ type: "boolean",
+ description: "When searching, also scrape full markdown content of each result page.",
+ },
+ lang: {
+ type: "string",
+ description: 'Language code to filter search results (e.g. "en", "ja").',
+ },
+ country: {
+ type: "string",
+ description: 'Country code to filter search results (e.g. "us", "jp").',
+ },
+ format: {
+ type: "string",
+ enum: ["markdown", "text", "html"],
+ description: "Format for scrape/crawl output (default: markdown).",
+ },
+ },
+ },
+ concurrencySafe: true,
+ async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> {
+ const validated = validateArgs(args);
+ if ("error" in validated) {
+ return { content: validated.error, isError: true };
+ }
+ const span = ctx.log.span("web_search.execute", { mode: validated.mode });
+ try {
+ const output = await runMode(validated, client, ctx.signal);
+ span.end();
+ return { content: truncateOutput(output, cap) };
+ } catch (err: unknown) {
+ span.end({ err });
+ return {
+ content: `Error: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+ },
+ };
+}