diff --git a/.pi/agent/extensions/web-search/index.ts b/.pi/agent/extensions/web-search/index.ts index b2fc5d1..27a1cef 100644 --- a/.pi/agent/extensions/web-search/index.ts +++ b/.pi/agent/extensions/web-search/index.ts @@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { loadWebSearchConfig } from "./src/config.ts"; import { createExaProvider } from "./src/providers/exa.ts"; import type { WebProvider } from "./src/providers/types.ts"; +import { createWebFetchTool } from "./src/tools/web-fetch.ts"; import { createWebSearchTool } from "./src/tools/web-search.ts"; async function resolveProvider(providerName?: string): Promise { @@ -25,4 +26,5 @@ async function resolveProvider(providerName?: string): Promise { export default function webSearch(pi: ExtensionAPI) { pi.registerTool(createWebSearchTool({ resolveProvider })); + pi.registerTool(createWebFetchTool({ resolveProvider })); } diff --git a/.pi/agent/extensions/web-search/src/extension.test.ts b/.pi/agent/extensions/web-search/src/extension.test.ts new file mode 100644 index 0000000..49e0054 --- /dev/null +++ b/.pi/agent/extensions/web-search/src/extension.test.ts @@ -0,0 +1,15 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import webSearchExtension from "../index.ts"; + +test("the extension entrypoint registers both web_search and web_fetch", () => { + const registeredTools: string[] = []; + + webSearchExtension({ + registerTool(tool: { name: string }) { + registeredTools.push(tool.name); + }, + } as any); + + assert.deepEqual(registeredTools, ["web_search", "web_fetch"]); +}); diff --git a/.pi/agent/extensions/web-search/src/tools/web-fetch.test.ts b/.pi/agent/extensions/web-search/src/tools/web-fetch.test.ts new file mode 100644 index 0000000..b6f8ca5 --- /dev/null +++ b/.pi/agent/extensions/web-search/src/tools/web-fetch.test.ts @@ -0,0 +1,62 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createWebFetchTool } from "./web-fetch.ts"; + +test("web_fetch prepareArguments folds a single url into urls", () => { + const tool = createWebFetchTool({ + resolveProvider: async () => { + throw new Error("not used"); + }, + }); + + assert.deepEqual(tool.prepareArguments?.({ url: "https://exa.ai/docs" }), { + url: "https://exa.ai/docs", + urls: ["https://exa.ai/docs"], + }); +}); + +test("web_fetch defaults to text and returns formatted fetch results", async () => { + let capturedRequest: Record | undefined; + + const tool = createWebFetchTool({ + resolveProvider: async () => ({ + name: "exa-main", + type: "exa", + async search() { + throw new Error("not used"); + }, + async fetch(request) { + capturedRequest = request as unknown as Record; + return { + providerName: "exa-main", + results: [ + { + url: "https://exa.ai/docs", + title: "Docs", + text: "Body", + }, + ], + }; + }, + }), + }); + + const result = await tool.execute("tool-1", { urls: ["https://exa.ai/docs"] }, undefined, undefined, undefined); + + assert.equal(capturedRequest?.text, true); + assert.match((result.content[0] as { text: string }).text, /Body/); + assert.equal((result.details as { results: Array<{ title: string }> }).results[0]?.title, "Docs"); +}); + +test("web_fetch rejects malformed URLs", async () => { + const tool = createWebFetchTool({ + resolveProvider: async () => { + throw new Error("should not resolve provider for invalid URLs"); + }, + }); + + await assert.rejects( + () => tool.execute("tool-1", { urls: ["not-a-url"] }, undefined, undefined, undefined), + /Invalid URL/, + ); +}); diff --git a/.pi/agent/extensions/web-search/src/tools/web-fetch.ts b/.pi/agent/extensions/web-search/src/tools/web-fetch.ts new file mode 100644 index 0000000..82f5a30 --- /dev/null +++ b/.pi/agent/extensions/web-search/src/tools/web-fetch.ts @@ -0,0 +1,90 @@ +import { Text } from "@mariozechner/pi-tui"; +import { formatFetchOutput } from "../format.ts"; +import type { NormalizedFetchResponse, WebProvider } from "../providers/types.ts"; +import { WebFetchParamsSchema, type WebFetchParams } from "../schema.ts"; + +interface FetchToolDeps { + resolveProvider(providerName?: string): Promise; +} + +function normalizeUrl(value: string) { + try { + return new URL(value).toString(); + } catch { + throw new Error(`Invalid URL: ${value}`); + } +} + +function normalizeFetchParams(params: WebFetchParams & { url?: string }) { + const urls = (Array.isArray(params.urls) ? params.urls : []).map(normalizeUrl); + if (urls.length === 0) { + throw new Error("web_fetch requires at least one URL."); + } + + return { + urls, + text: params.text ?? (!params.highlights && !params.summary), + highlights: params.highlights ?? false, + summary: params.summary ?? false, + textMaxCharacters: params.textMaxCharacters, + provider: params.provider, + }; +} + +export function createWebFetchTool({ resolveProvider }: FetchToolDeps) { + return { + name: "web_fetch", + label: "Web Fetch", + description: "Fetch page contents through the configured provider. Returns text by default.", + parameters: WebFetchParamsSchema, + + prepareArguments(args: unknown) { + if (!args || typeof args !== "object") { + return args; + } + + const input = args as { url?: unknown; urls?: unknown }; + if (typeof input.url === "string" && !Array.isArray(input.urls)) { + return { + ...input, + urls: [input.url], + }; + } + + return args; + }, + + async execute(_toolCallId: string, params: WebFetchParams) { + const normalized = normalizeFetchParams(params as WebFetchParams & { url?: string }); + const provider = await resolveProvider(normalized.provider); + const response = await provider.fetch(normalized); + + return { + content: [{ type: "text" as const, text: formatFetchOutput(response) }], + details: response, + }; + }, + + renderCall(args: Partial & { url?: string }, theme: any) { + const urls = Array.isArray(args.urls) ? args.urls : typeof args.url === "string" ? [args.url] : []; + let text = theme.fg("toolTitle", theme.bold("web_fetch ")); + text += theme.fg("muted", `${urls.length} url${urls.length === 1 ? "" : "s"}`); + return new Text(text, 0, 0); + }, + + renderResult(result: { details?: NormalizedFetchResponse }, _options: unknown, theme: any) { + const details = result.details; + if (!details) { + return new Text("", 0, 0); + } + + const failed = details.results.filter((item) => item.error).length; + const succeeded = details.results.length - failed; + return new Text( + `${theme.fg("success", "✓ ")}${succeeded} ok${failed ? ` • ${theme.fg("warning", `${failed} failed`)}` : ""}`, + 0, + 0, + ); + }, + }; +} diff --git a/docs/superpowers/plans/2026-04-09-web-search-tools.md b/docs/superpowers/plans/2026-04-09-web-search-tools.md index 907e32b..b69f841 100644 --- a/docs/superpowers/plans/2026-04-09-web-search-tools.md +++ b/docs/superpowers/plans/2026-04-09-web-search-tools.md @@ -1186,7 +1186,7 @@ git -C /home/alex/dotfiles commit -m "feat: add web_search tool" - Test: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-fetch.test.ts` - Test: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/extension.test.ts` -- [ ] **Step 1: Write the failing `web_fetch` and extension registration tests** +- [x] **Step 1: Write the failing `web_fetch` and extension registration tests** Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-fetch.test.ts` with this content: @@ -1275,7 +1275,7 @@ test("the extension entrypoint registers both web_search and web_fetch", () => { }); ``` -- [ ] **Step 2: Run the new tests to verify they fail** +- [x] **Step 2: Run the new tests to verify they fail** Run: @@ -1286,7 +1286,7 @@ npx tsx --test src/tools/web-fetch.test.ts src/extension.test.ts Expected: `FAIL` with an `ERR_MODULE_NOT_FOUND` error for `./web-fetch.ts`. -- [ ] **Step 3: Write the minimal `web_fetch` tool implementation** +- [x] **Step 3: Write the minimal `web_fetch` tool implementation** Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-fetch.ts` with this content: @@ -1383,7 +1383,7 @@ export function createWebFetchTool({ resolveProvider }: FetchToolDeps) { } ``` -- [ ] **Step 4: Replace the temporary entrypoint with the final entrypoint that registers both tools** +- [x] **Step 4: Replace the temporary entrypoint with the final entrypoint that registers both tools** Replace `/home/alex/dotfiles/.pi/agent/extensions/web-search/index.ts` with this content: @@ -1420,7 +1420,7 @@ export default function webSearch(pi: ExtensionAPI) { } ``` -- [ ] **Step 5: Run the `web_fetch` and entrypoint smoke tests to verify they pass** +- [x] **Step 5: Run the `web_fetch` and entrypoint smoke tests to verify they pass** Run: @@ -1431,7 +1431,7 @@ npx tsx --test src/tools/web-fetch.test.ts src/extension.test.ts Expected: `PASS` for all four tests. -- [ ] **Step 6: Reload pi and manually verify search + single fetch + batch fetch** +- [x] **Step 6: Reload pi and manually verify search + single fetch + batch fetch** Run: @@ -1458,7 +1458,7 @@ Expected manual checks: - batch fetch output is clearly separated per URL - if one URL fails, the result still includes the successful URL output and a failure section for the bad URL -- [ ] **Step 7: Commit the completed web tools extension** +- [x] **Step 7: Commit the completed web tools extension** Run: