feat: add web search tools extension

This commit is contained in:
alex wiesner
2026-04-09 11:16:53 +01:00
parent c2d7cd53ce
commit 472de4ebaf
5 changed files with 176 additions and 7 deletions

View File

@@ -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<WebProvider> {
@@ -25,4 +26,5 @@ async function resolveProvider(providerName?: string): Promise<WebProvider> {
export default function webSearch(pi: ExtensionAPI) {
pi.registerTool(createWebSearchTool({ resolveProvider }));
pi.registerTool(createWebFetchTool({ resolveProvider }));
}

View File

@@ -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"]);
});

View File

@@ -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<string, unknown> | 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<string, unknown>;
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/,
);
});

View File

@@ -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<WebProvider>;
}
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<WebFetchParams> & { 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,
);
},
};
}

View File

@@ -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: