diff options
| author | Adam Malczewski <[email protected]> | 2026-06-21 13:11:29 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-21 13:11:29 +0900 |
| commit | 8a4a624d16422467a8e85434c674bb591877e8ea (patch) | |
| tree | 54052da00bbc580742913e5c031b7cc1b160db19 /packages/tool-web-search/src/tool.ts | |
| parent | d23de3254374d4d63c8e15c6ab9311c3c6f4da5b (diff) | |
| download | dispatch-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.ts | 142 |
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, + }; + } + }, + }; +} |
