feat: add web search tools extension
This commit is contained in:
@@ -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 }));
|
||||
}
|
||||
|
||||
15
.pi/agent/extensions/web-search/src/extension.test.ts
Normal file
15
.pi/agent/extensions/web-search/src/extension.test.ts
Normal 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"]);
|
||||
});
|
||||
62
.pi/agent/extensions/web-search/src/tools/web-fetch.test.ts
Normal file
62
.pi/agent/extensions/web-search/src/tools/web-fetch.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
90
.pi/agent/extensions/web-search/src/tools/web-fetch.ts
Normal file
90
.pi/agent/extensions/web-search/src/tools/web-fetch.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user