feat: add web_search tool
This commit is contained in:
28
.pi/agent/extensions/web-search/index.ts
Normal file
28
.pi/agent/extensions/web-search/index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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 { createWebSearchTool } from "./src/tools/web-search.ts";
|
||||||
|
|
||||||
|
async function resolveProvider(providerName?: string): Promise<WebProvider> {
|
||||||
|
const config = await loadWebSearchConfig();
|
||||||
|
const selectedName = providerName ?? config.defaultProviderName;
|
||||||
|
const providerConfig = config.providersByName.get(selectedName);
|
||||||
|
|
||||||
|
if (!providerConfig) {
|
||||||
|
throw new Error(
|
||||||
|
`Unknown web-search provider \"${selectedName}\". Configured providers: ${[...config.providersByName.keys()].join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (providerConfig.type) {
|
||||||
|
case "exa":
|
||||||
|
return createExaProvider(providerConfig);
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported web-search provider type: ${(providerConfig as { type: string }).type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function webSearch(pi: ExtensionAPI) {
|
||||||
|
pi.registerTool(createWebSearchTool({ resolveProvider }));
|
||||||
|
}
|
||||||
52
.pi/agent/extensions/web-search/src/tools/web-search.test.ts
Normal file
52
.pi/agent/extensions/web-search/src/tools/web-search.test.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createWebSearchTool } from "./web-search.ts";
|
||||||
|
|
||||||
|
test("web_search executes metadata-only search through the resolved provider", async () => {
|
||||||
|
let resolvedProviderName: string | undefined;
|
||||||
|
|
||||||
|
const tool = createWebSearchTool({
|
||||||
|
resolveProvider: async (providerName) => {
|
||||||
|
resolvedProviderName = providerName;
|
||||||
|
return {
|
||||||
|
name: "exa-main",
|
||||||
|
type: "exa",
|
||||||
|
async search(request) {
|
||||||
|
assert.equal(request.query, "exa docs");
|
||||||
|
return {
|
||||||
|
providerName: "exa-main",
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
title: "Exa Docs",
|
||||||
|
url: "https://exa.ai/docs",
|
||||||
|
score: 0.98,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async fetch() {
|
||||||
|
throw new Error("not used");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await tool.execute("tool-1", { query: "exa docs" }, undefined, undefined, undefined);
|
||||||
|
|
||||||
|
assert.equal(resolvedProviderName, undefined);
|
||||||
|
assert.match((result.content[0] as { text: string }).text, /Exa Docs/);
|
||||||
|
assert.equal((result.details as { results: Array<{ url: string }> }).results[0]?.url, "https://exa.ai/docs");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("web_search rejects a blank query before resolving a provider", async () => {
|
||||||
|
const tool = createWebSearchTool({
|
||||||
|
resolveProvider: async () => {
|
||||||
|
throw new Error("should not resolve provider for a blank query");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => tool.execute("tool-1", { query: " " }, undefined, undefined, undefined),
|
||||||
|
/non-empty query/,
|
||||||
|
);
|
||||||
|
});
|
||||||
68
.pi/agent/extensions/web-search/src/tools/web-search.ts
Normal file
68
.pi/agent/extensions/web-search/src/tools/web-search.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Text } from "@mariozechner/pi-tui";
|
||||||
|
import { formatSearchOutput } from "../format.ts";
|
||||||
|
import type { NormalizedSearchResponse, WebProvider } from "../providers/types.ts";
|
||||||
|
import { WebSearchParamsSchema, type WebSearchParams } from "../schema.ts";
|
||||||
|
|
||||||
|
interface SearchToolDeps {
|
||||||
|
resolveProvider(providerName?: string): Promise<WebProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearchQuery(query: string) {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error("web_search requires a non-empty query.");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWebSearchTool({ resolveProvider }: SearchToolDeps) {
|
||||||
|
return {
|
||||||
|
name: "web_search",
|
||||||
|
label: "Web Search",
|
||||||
|
description: "Search the web through the configured provider. Returns result metadata by default.",
|
||||||
|
parameters: WebSearchParamsSchema,
|
||||||
|
|
||||||
|
async execute(_toolCallId: string, params: WebSearchParams) {
|
||||||
|
const query = normalizeSearchQuery(params.query);
|
||||||
|
const provider = await resolveProvider(params.provider);
|
||||||
|
const response = await provider.search({
|
||||||
|
query,
|
||||||
|
limit: params.limit,
|
||||||
|
includeDomains: params.includeDomains,
|
||||||
|
excludeDomains: params.excludeDomains,
|
||||||
|
startPublishedDate: params.startPublishedDate,
|
||||||
|
endPublishedDate: params.endPublishedDate,
|
||||||
|
category: params.category,
|
||||||
|
provider: params.provider,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: formatSearchOutput(response) }],
|
||||||
|
details: response,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
renderCall(args: Partial<WebSearchParams>, theme: any) {
|
||||||
|
let text = theme.fg("toolTitle", theme.bold("web_search "));
|
||||||
|
text += theme.fg("muted", args.query ?? "");
|
||||||
|
return new Text(text, 0, 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
renderResult(result: { details?: NormalizedSearchResponse }, _options: unknown, theme: any) {
|
||||||
|
const details = result.details;
|
||||||
|
if (!details) {
|
||||||
|
return new Text("", 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [
|
||||||
|
`${theme.fg("success", "✓ ")}${details.results.length} result${details.results.length === 1 ? "" : "s"} via ${details.providerName}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [index, item] of details.results.slice(0, 5).entries()) {
|
||||||
|
lines.push(` ${theme.fg("muted", `${index + 1}.`)} ${item.title ?? "(untitled)"} ${theme.fg("dim", item.url)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Text(lines.join("\n"), 0, 0);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -922,7 +922,7 @@ git -C /home/alex/dotfiles commit -m "test: add web tool output formatting"
|
|||||||
- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-search.ts`
|
- Create: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-search.ts`
|
||||||
- Test: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-search.test.ts`
|
- Test: `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-search.test.ts`
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing `web_search` tool tests**
|
- [x] **Step 1: Write the failing `web_search` tool tests**
|
||||||
|
|
||||||
Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-search.test.ts` with this content:
|
Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-search.test.ts` with this content:
|
||||||
|
|
||||||
@@ -981,7 +981,7 @@ test("web_search rejects a blank query before resolving a provider", async () =>
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Run the `web_search` tests to verify they fail**
|
- [x] **Step 2: Run the `web_search` tests to verify they fail**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -992,7 +992,7 @@ npx tsx --test src/tools/web-search.test.ts
|
|||||||
|
|
||||||
Expected: `FAIL` with an `ERR_MODULE_NOT_FOUND` error for `./web-search.ts`.
|
Expected: `FAIL` with an `ERR_MODULE_NOT_FOUND` error for `./web-search.ts`.
|
||||||
|
|
||||||
- [ ] **Step 3: Write the minimal `web_search` tool implementation**
|
- [x] **Step 3: Write the minimal `web_search` tool implementation**
|
||||||
|
|
||||||
Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-search.ts` with this content:
|
Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/src/tools/web-search.ts` with this content:
|
||||||
|
|
||||||
@@ -1066,7 +1066,7 @@ export function createWebSearchTool({ resolveProvider }: SearchToolDeps) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 4: Run the `web_search` tests to verify they pass**
|
- [x] **Step 4: Run the `web_search` tests to verify they pass**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -1077,7 +1077,7 @@ npx tsx --test src/tools/web-search.test.ts
|
|||||||
|
|
||||||
Expected: `PASS` for both tests.
|
Expected: `PASS` for both tests.
|
||||||
|
|
||||||
- [ ] **Step 5: Create a real Exa config file for the live smoke test**
|
- [x] **Step 5: Create a real Exa config file for the live smoke test**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -1106,7 +1106,7 @@ unset EXA_KEY
|
|||||||
|
|
||||||
Expected: `/home/alex/.pi/agent/web-search.json` exists with mode `600`.
|
Expected: `/home/alex/.pi/agent/web-search.json` exists with mode `600`.
|
||||||
|
|
||||||
- [ ] **Step 6: Add a temporary entrypoint that registers `web_search` and manually verify it live**
|
- [x] **Step 6: Add a temporary entrypoint that registers `web_search` and manually verify it live**
|
||||||
|
|
||||||
Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/index.ts` with this content:
|
Create `/home/alex/dotfiles/.pi/agent/extensions/web-search/index.ts` with this content:
|
||||||
|
|
||||||
@@ -1162,7 +1162,7 @@ Expected manual checks:
|
|||||||
- the result contains metadata-only output, not fetched page text
|
- the result contains metadata-only output, not fetched page text
|
||||||
- at least one result includes `https://exa.ai/docs`
|
- at least one result includes `https://exa.ai/docs`
|
||||||
|
|
||||||
- [ ] **Step 7: Commit the `web_search` tool**
|
- [x] **Step 7: Commit the `web_search` tool**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user