initial commit

This commit is contained in:
pi
2026-04-10 23:11:21 +01:00
commit b9a395bcec
26 changed files with 7060 additions and 0 deletions

View File

@@ -0,0 +1,65 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
createDefaultWebSearchConfig,
removeProviderOrThrow,
renameProviderOrThrow,
setDefaultProviderOrThrow,
updateProviderOrThrow,
} from "./web-search-config.ts";
test("createDefaultWebSearchConfig builds a Tavily-first file", () => {
const config = createDefaultWebSearchConfig({
tavilyName: "tavily-main",
tavilyApiKey: "tvly-test-key",
});
assert.equal(config.defaultProvider, "tavily-main");
assert.equal(config.providers[0]?.type, "tavily");
});
test("renameProviderOrThrow updates defaultProvider when renaming the default", () => {
const config = createDefaultWebSearchConfig({
tavilyName: "tavily-main",
tavilyApiKey: "tvly-test-key",
});
const next = renameProviderOrThrow(config, "tavily-main", "tavily-primary");
assert.equal(next.defaultProvider, "tavily-primary");
assert.equal(next.providers[0]?.name, "tavily-primary");
});
test("removeProviderOrThrow rejects removing the last provider", () => {
const config = createDefaultWebSearchConfig({
tavilyName: "tavily-main",
tavilyApiKey: "tvly-test-key",
});
assert.throws(() => removeProviderOrThrow(config, "tavily-main"), /last provider/);
});
test("setDefaultProviderOrThrow requires an existing provider name", () => {
const config = createDefaultWebSearchConfig({
tavilyName: "tavily-main",
tavilyApiKey: "tvly-test-key",
});
assert.throws(() => setDefaultProviderOrThrow(config, "missing"), /Unknown provider/);
});
test("updateProviderOrThrow can change provider-specific options without changing type", () => {
const config = createDefaultWebSearchConfig({
tavilyName: "tavily-main",
tavilyApiKey: "tvly-test-key",
});
const next = updateProviderOrThrow(config, "tavily-main", {
apiKey: "tvly-next-key",
options: { defaultSearchLimit: 8 },
});
assert.equal(next.providers[0]?.apiKey, "tvly-next-key");
assert.equal(next.providers[0]?.options?.defaultSearchLimit, 8);
assert.equal(next.providers[0]?.type, "tavily");
});

View File

@@ -0,0 +1,229 @@
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import {
getDefaultWebSearchConfigPath,
readRawWebSearchConfig,
writeWebSearchConfig,
WebSearchConfigError,
} from "../config.ts";
import type { WebSearchConfig, WebSearchProviderConfig } from "../schema.ts";
export function createDefaultWebSearchConfig(input: { tavilyName: string; tavilyApiKey: string }): WebSearchConfig {
return {
defaultProvider: input.tavilyName,
providers: [
{
name: input.tavilyName,
type: "tavily",
apiKey: input.tavilyApiKey,
},
],
};
}
export function setDefaultProviderOrThrow(config: WebSearchConfig, providerName: string): WebSearchConfig {
if (!config.providers.some((provider) => provider.name === providerName)) {
throw new Error(`Unknown provider: ${providerName}`);
}
return { ...config, defaultProvider: providerName };
}
export function renameProviderOrThrow(
config: WebSearchConfig,
currentName: string,
nextName: string,
): WebSearchConfig {
if (!nextName.trim()) {
throw new Error("Provider name cannot be blank.");
}
if (config.providers.some((provider) => provider.name === nextName && provider.name !== currentName)) {
throw new Error(`Duplicate provider name: ${nextName}`);
}
return {
defaultProvider: config.defaultProvider === currentName ? nextName : config.defaultProvider,
providers: config.providers.map((provider) =>
provider.name === currentName ? { ...provider, name: nextName } : provider,
),
};
}
export function updateProviderOrThrow(
config: WebSearchConfig,
providerName: string,
patch: { apiKey?: string; options?: WebSearchProviderConfig["options"] },
): WebSearchConfig {
const existing = config.providers.find((provider) => provider.name === providerName);
if (!existing) {
throw new Error(`Unknown provider: ${providerName}`);
}
if (patch.apiKey !== undefined && !patch.apiKey.trim()) {
throw new Error("Provider apiKey cannot be blank.");
}
return {
...config,
providers: config.providers.map((provider) =>
provider.name === providerName
? {
...provider,
apiKey: patch.apiKey ?? provider.apiKey,
options: patch.options ?? provider.options,
}
: provider,
),
};
}
export function removeProviderOrThrow(config: WebSearchConfig, providerName: string): WebSearchConfig {
if (config.providers.length === 1) {
throw new Error("Cannot remove the last provider.");
}
if (config.defaultProvider === providerName) {
throw new Error("Cannot remove the default provider before selecting a new default.");
}
return {
...config,
providers: config.providers.filter((provider) => provider.name !== providerName),
};
}
function upsertProviderOrThrow(config: WebSearchConfig, nextProvider: WebSearchProviderConfig): WebSearchConfig {
if (!nextProvider.name.trim()) {
throw new Error("Provider name cannot be blank.");
}
if (!nextProvider.apiKey.trim()) {
throw new Error("Provider apiKey cannot be blank.");
}
const withoutSameName = config.providers.filter((provider) => provider.name !== nextProvider.name);
return {
...config,
providers: [...withoutSameName, nextProvider],
};
}
async function promptProviderOptions(ctx: any, provider: WebSearchProviderConfig) {
const defaultSearchLimit = await ctx.ui.input(
`Default search limit for ${provider.name}`,
provider.options?.defaultSearchLimit !== undefined ? String(provider.options.defaultSearchLimit) : "",
);
const defaultFetchTextMaxCharacters = await ctx.ui.input(
`Default fetch text max characters for ${provider.name}`,
provider.options?.defaultFetchTextMaxCharacters !== undefined
? String(provider.options.defaultFetchTextMaxCharacters)
: "",
);
const options = {
defaultSearchLimit: defaultSearchLimit ? Number(defaultSearchLimit) : undefined,
defaultFetchTextMaxCharacters: defaultFetchTextMaxCharacters
? Number(defaultFetchTextMaxCharacters)
: undefined,
};
return Object.values(options).some((value) => value !== undefined) ? options : undefined;
}
export function registerWebSearchConfigCommand(pi: ExtensionAPI) {
pi.registerCommand("web-search-config", {
description: "Configure Tavily/Exa providers for web_search and web_fetch",
handler: async (_args, ctx) => {
const path = getDefaultWebSearchConfigPath();
let config: WebSearchConfig;
try {
config = await readRawWebSearchConfig(path);
} catch (error) {
if (!(error instanceof WebSearchConfigError)) {
throw error;
}
const tavilyName = await ctx.ui.input("Create Tavily provider", "tavily-main");
const tavilyApiKey = await ctx.ui.input("Tavily API key", "tvly-...");
if (!tavilyName || !tavilyApiKey) {
return;
}
config = createDefaultWebSearchConfig({ tavilyName, tavilyApiKey });
}
const action = await ctx.ui.select("Web search config", [
"Set default provider",
"Add Tavily provider",
"Add Exa provider",
"Edit provider",
"Remove provider",
]);
if (!action) {
return;
}
if (action === "Set default provider") {
const nextDefault = await ctx.ui.select(
"Choose default provider",
config.providers.map((provider) => provider.name),
);
if (!nextDefault) {
return;
}
config = setDefaultProviderOrThrow(config, nextDefault);
}
if (action === "Add Tavily provider") {
const name = await ctx.ui.input("Provider name", "tavily-main");
const apiKey = await ctx.ui.input("Tavily API key", "tvly-...");
if (!name || !apiKey) {
return;
}
config = upsertProviderOrThrow(config, { name, type: "tavily", apiKey });
}
if (action === "Add Exa provider") {
const name = await ctx.ui.input("Provider name", "exa-fallback");
const apiKey = await ctx.ui.input("Exa API key", "exa_...");
if (!name || !apiKey) {
return;
}
config = upsertProviderOrThrow(config, { name, type: "exa", apiKey });
}
if (action === "Edit provider") {
const providerName = await ctx.ui.select(
"Choose provider",
config.providers.map((provider) => provider.name),
);
if (!providerName) {
return;
}
const existing = config.providers.find((provider) => provider.name === providerName)!;
const nextName = await ctx.ui.input("Provider name", existing.name);
const nextApiKey = await ctx.ui.input(`API key for ${existing.name}`, existing.apiKey);
if (!nextName || !nextApiKey) {
return;
}
config = renameProviderOrThrow(config, existing.name, nextName);
const renamed = config.providers.find((provider) => provider.name === nextName)!;
const nextOptions = await promptProviderOptions(ctx, renamed);
config = updateProviderOrThrow(config, nextName, {
apiKey: nextApiKey,
options: nextOptions,
});
}
if (action === "Remove provider") {
const providerName = await ctx.ui.select(
"Choose provider to remove",
config.providers.map((provider) => provider.name),
);
if (!providerName) {
return;
}
config = removeProviderOrThrow(config, providerName);
}
await writeWebSearchConfig(path, config);
ctx.ui.notify(`Saved web-search config to ${path}`, "info");
},
});
}

95
src/config.test.ts Normal file
View File

@@ -0,0 +1,95 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadWebSearchConfig, WebSearchConfigError } from "./config.ts";
async function writeTempConfig(contents: unknown) {
const dir = await mkdtemp(join(tmpdir(), "pi-web-search-config-"));
const file = join(dir, "web-search.json");
const body = typeof contents === "string" ? contents : JSON.stringify(contents, null, 2);
await writeFile(file, body, "utf8");
return file;
}
test("loadWebSearchConfig returns a normalized default provider and provider lookup", async () => {
const file = await writeTempConfig({
defaultProvider: "exa-main",
providers: [
{
name: "exa-main",
type: "exa",
apiKey: "exa-test-key",
options: {
defaultSearchLimit: 7,
defaultFetchTextMaxCharacters: 9000,
},
},
],
});
const config = await loadWebSearchConfig(file);
assert.equal(config.defaultProviderName, "exa-main");
assert.equal(config.defaultProvider.name, "exa-main");
assert.equal(config.providersByName.get("exa-main")?.apiKey, "exa-test-key");
assert.equal(config.providers[0]?.options?.defaultSearchLimit, 7);
});
test("loadWebSearchConfig normalizes a Tavily default with Exa fallback", async () => {
const file = await writeTempConfig({
defaultProvider: "tavily-main",
providers: [
{
name: "tavily-main",
type: "tavily",
apiKey: "tvly-test-key",
},
{
name: "exa-fallback",
type: "exa",
apiKey: "exa-test-key",
},
],
});
const config = await loadWebSearchConfig(file);
assert.equal(config.defaultProviderName, "tavily-main");
assert.equal(config.defaultProvider.type, "tavily");
assert.equal(config.providersByName.get("exa-fallback")?.type, "exa");
});
test("loadWebSearchConfig rejects a missing default provider target", async () => {
const file = await writeTempConfig({
defaultProvider: "missing",
providers: [
{
name: "exa-main",
type: "exa",
apiKey: "exa-test-key",
},
],
});
await assert.rejects(
() => loadWebSearchConfig(file),
(error) =>
error instanceof WebSearchConfigError &&
/defaultProvider \"missing\"/.test(error.message),
);
});
test("loadWebSearchConfig rejects a missing file with a helpful example message", async () => {
const file = join(tmpdir(), "pi-web-search-does-not-exist.json");
await assert.rejects(
() => loadWebSearchConfig(file),
(error) =>
error instanceof WebSearchConfigError &&
error.message.includes(file) &&
error.message.includes('"defaultProvider"') &&
error.message.includes('"providers"'),
);
});

127
src/config.ts Normal file
View File

@@ -0,0 +1,127 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { Value } from "@sinclair/typebox/value";
import {
WebSearchConfigSchema,
type WebSearchConfig,
type WebSearchProviderConfig,
} from "./schema.ts";
export interface ResolvedWebSearchConfig {
path: string;
defaultProviderName: string;
defaultProvider: WebSearchProviderConfig;
providers: WebSearchProviderConfig[];
providersByName: Map<string, WebSearchProviderConfig>;
}
export class WebSearchConfigError extends Error {
constructor(message: string) {
super(message);
this.name = "WebSearchConfigError";
}
}
export function getDefaultWebSearchConfigPath() {
return join(homedir(), ".pi", "agent", "web-search.json");
}
function exampleConfigSnippet() {
return JSON.stringify(
{
defaultProvider: "tavily-main",
providers: [
{
name: "tavily-main",
type: "tavily",
apiKey: "tvly-...",
},
{
name: "exa-fallback",
type: "exa",
apiKey: "exa_...",
},
],
},
null,
2,
);
}
export function normalizeWebSearchConfig(config: WebSearchConfig, path: string): ResolvedWebSearchConfig {
const providersByName = new Map<string, WebSearchProviderConfig>();
for (const provider of config.providers) {
if (!provider.apiKey.trim()) {
throw new WebSearchConfigError(`Provider \"${provider.name}\" in ${path} is missing a literal apiKey.`);
}
if (providersByName.has(provider.name)) {
throw new WebSearchConfigError(`Duplicate provider name \"${provider.name}\" in ${path}.`);
}
providersByName.set(provider.name, provider);
}
const defaultProvider = providersByName.get(config.defaultProvider);
if (!defaultProvider) {
throw new WebSearchConfigError(
`defaultProvider \"${config.defaultProvider}\" does not match any configured provider in ${path}.`,
);
}
return {
path,
defaultProviderName: config.defaultProvider,
defaultProvider,
providers: [...providersByName.values()],
providersByName,
};
}
function parseWebSearchConfig(raw: string, path: string) {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new WebSearchConfigError(`Invalid JSON in ${path}: ${(error as Error).message}`);
}
if (!Value.Check(WebSearchConfigSchema, parsed)) {
const [firstError] = [...Value.Errors(WebSearchConfigSchema, parsed)];
throw new WebSearchConfigError(
`Invalid web-search config at ${path}: ${firstError?.path ?? "/"} ${firstError?.message ?? "failed validation"}`,
);
}
return parsed as WebSearchConfig;
}
export async function readRawWebSearchConfig(path = getDefaultWebSearchConfigPath()): Promise<WebSearchConfig> {
let raw: string;
try {
raw = await readFile(path, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new WebSearchConfigError(
`Missing web-search config at ${path}.\nCreate ${path} with contents like:\n${exampleConfigSnippet()}`,
);
}
throw error;
}
return parseWebSearchConfig(raw, path);
}
export function stringifyWebSearchConfig(config: WebSearchConfig) {
return `${JSON.stringify(config, null, 2)}\n`;
}
export async function writeWebSearchConfig(path: string, config: WebSearchConfig) {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, stringifyWebSearchConfig(config), "utf8");
}
export async function loadWebSearchConfig(path = getDefaultWebSearchConfigPath()) {
const parsed = await readRawWebSearchConfig(path);
return normalizeWebSearchConfig(parsed, path);
}

20
src/extension.test.ts Normal file
View File

@@ -0,0 +1,20 @@
import test from "node:test";
import assert from "node:assert/strict";
import webSearchExtension from "../index.ts";
test("the extension entrypoint registers both tools and the config command", () => {
const registeredTools: string[] = [];
const registeredCommands: string[] = [];
webSearchExtension({
registerTool(tool: { name: string }) {
registeredTools.push(tool.name);
},
registerCommand(name: string) {
registeredCommands.push(name);
},
} as any);
assert.deepEqual(registeredTools, ["web_search", "web_fetch"]);
assert.deepEqual(registeredCommands, ["web-search-config"]);
});

97
src/format.test.ts Normal file
View File

@@ -0,0 +1,97 @@
import test from "node:test";
import assert from "node:assert/strict";
import { formatFetchOutput, formatSearchOutput, truncateText } from "./format.ts";
test("formatSearchOutput renders a compact metadata-only list", () => {
const output = formatSearchOutput({
providerName: "exa-main",
results: [
{
title: "Exa Docs",
url: "https://exa.ai/docs",
publishedDate: "2026-04-09",
author: "Exa",
score: 0.98,
},
],
});
assert.match(output, /Found 1 web result via exa-main:/);
assert.match(output, /Exa Docs/);
assert.match(output, /https:\/\/exa.ai\/docs/);
});
test("formatSearchOutput shows answer and fallback provider metadata", () => {
const output = formatSearchOutput({
providerName: "exa-fallback",
answer: "pi is a coding agent",
execution: {
actualProviderName: "exa-fallback",
failoverFromProviderName: "tavily-main",
},
results: [
{
title: "pi docs",
url: "https://pi.dev",
rawContent: "Very long raw content body",
},
],
} as any);
assert.match(output, /Answer: pi is a coding agent/);
assert.match(output, /Fallback: tavily-main -> exa-fallback/);
});
test("truncateText shortens long fetch bodies with an ellipsis", () => {
assert.equal(truncateText("abcdef", 4), "abc…");
assert.equal(truncateText("abc", 10), "abc");
});
test("formatFetchOutput includes both successful and failed URLs", () => {
const output = formatFetchOutput(
{
providerName: "exa-main",
results: [
{
url: "https://good.example",
title: "Good",
text: "This is a very long body that should be truncated in the final output.",
},
{
url: "https://bad.example",
title: null,
error: "429 rate limited",
},
],
},
{ maxCharactersPerResult: 20 },
);
assert.match(output, /Status: ok/);
assert.match(output, /Status: failed/);
assert.match(output, /429 rate limited/);
assert.match(output, /This is a very long…/);
});
test("formatFetchOutput shows fallback metadata and favicon/images when present", () => {
const output = formatFetchOutput({
providerName: "exa-fallback",
execution: {
actualProviderName: "exa-fallback",
failoverFromProviderName: "tavily-main",
},
results: [
{
url: "https://pi.dev",
title: "pi",
text: "Fetched body",
favicon: "https://pi.dev/favicon.ico",
images: ["https://pi.dev/logo.png"],
},
],
} as any);
assert.match(output, /Fallback: tavily-main -> exa-fallback/);
assert.match(output, /Favicon: https:\/\/pi.dev\/favicon.ico/);
assert.match(output, /Images:/);
});

118
src/format.ts Normal file
View File

@@ -0,0 +1,118 @@
import type { NormalizedFetchResponse, NormalizedSearchResponse } from "./providers/types.ts";
function formatFallbackLine(execution?: {
actualProviderName?: string;
failoverFromProviderName?: string;
}) {
if (!execution?.failoverFromProviderName || !execution.actualProviderName) {
return undefined;
}
return `Fallback: ${execution.failoverFromProviderName} -> ${execution.actualProviderName}`;
}
export function truncateText(text: string, maxCharacters = 4000) {
if (text.length <= maxCharacters) {
return text;
}
return `${text.slice(0, Math.max(0, maxCharacters - 1))}`;
}
export function formatSearchOutput(response: NormalizedSearchResponse & { execution?: any }) {
const lines: string[] = [];
const fallbackLine = formatFallbackLine(response.execution);
if (fallbackLine) {
lines.push(fallbackLine, "");
}
if (response.answer) {
lines.push(`Answer: ${response.answer}`, "");
}
if (response.results.length === 0) {
lines.push(`No web results via ${response.providerName}.`);
return lines.join("\n");
}
lines.push(`Found ${response.results.length} web result${response.results.length === 1 ? "" : "s"} via ${response.providerName}:`);
for (const [index, result] of response.results.entries()) {
lines.push(`${index + 1}. ${result.title ?? "(untitled)"}`);
lines.push(` URL: ${result.url}`);
const meta = [result.publishedDate, result.author].filter(Boolean);
if (meta.length > 0) {
lines.push(` Meta: ${meta.join(" • ")}`);
}
if (typeof result.score === "number") {
lines.push(` Score: ${result.score}`);
}
if (result.content) {
lines.push(` Snippet: ${truncateText(result.content, 500)}`);
}
if (result.rawContent) {
lines.push(` Raw content: ${truncateText(result.rawContent, 700)}`);
}
}
return lines.join("\n");
}
export interface FetchFormatOptions {
maxCharactersPerResult?: number;
}
export function formatFetchOutput(response: NormalizedFetchResponse & { execution?: any }, options: FetchFormatOptions = {}) {
const maxCharactersPerResult = options.maxCharactersPerResult ?? 4000;
const lines: string[] = [];
const fallbackLine = formatFallbackLine(response.execution);
if (fallbackLine) {
lines.push(fallbackLine, "");
}
lines.push(`Fetched ${response.results.length} URL${response.results.length === 1 ? "" : "s"} via ${response.providerName}:`);
for (const result of response.results) {
lines.push("");
lines.push(`URL: ${result.url}`);
if (result.error) {
lines.push("Status: failed");
lines.push(`Error: ${result.error}`);
continue;
}
lines.push("Status: ok");
if (result.title) {
lines.push(`Title: ${result.title}`);
}
if (result.summary) {
lines.push(`Summary: ${result.summary}`);
}
if (result.highlights?.length) {
lines.push("Highlights:");
for (const highlight of result.highlights) {
lines.push(`- ${highlight}`);
}
}
if (result.favicon) {
lines.push(`Favicon: ${result.favicon}`);
}
if (result.images?.length) {
lines.push("Images:");
for (const image of result.images) {
lines.push(`- ${image}`);
}
}
if (result.text) {
lines.push("Text:");
lines.push(truncateText(result.text, maxCharactersPerResult));
}
}
return lines.join("\n");
}

View File

@@ -0,0 +1,31 @@
import test from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const pkg = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
test("package.json exposes pi-web-search as a standalone pi package", () => {
assert.equal(pkg.name, "pi-web-search");
assert.equal(pkg.type, "module");
assert.ok(Array.isArray(pkg.keywords));
assert.ok(pkg.keywords.includes("pi-package"));
assert.deepEqual(pkg.pi, {
extensions: ["./index.ts"],
});
assert.equal(pkg.peerDependencies["@mariozechner/pi-coding-agent"], "*");
assert.equal(pkg.peerDependencies["@mariozechner/pi-tui"], "*");
assert.equal(pkg.peerDependencies["@sinclair/typebox"], "*");
assert.ok("exa-js" in (pkg.dependencies ?? {}));
assert.ok(!("@sinclair/typebox" in (pkg.dependencies ?? {})));
assert.equal(pkg.bundledDependencies, undefined);
assert.deepEqual(pkg.files, ["index.ts", "src"]);
assert.ok(existsSync(resolve(packageRoot, "index.ts")));
assert.ok(existsSync(resolve(packageRoot, "src/runtime.ts")));
assert.ok(existsSync(resolve(packageRoot, "src/tools/web-search.ts")));
assert.equal(existsSync(resolve(packageRoot, "bun.lock")), false);
});

110
src/providers/exa.test.ts Normal file
View File

@@ -0,0 +1,110 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createExaProvider } from "./exa.ts";
const baseConfig = {
name: "exa-main",
type: "exa" as const,
apiKey: "exa-test-key",
options: {
defaultSearchLimit: 7,
defaultFetchTextMaxCharacters: 9000,
defaultFetchHighlightsMaxCharacters: 1200,
},
};
test("createExaProvider maps generic search requests to Exa search with contents disabled", async () => {
let captured: { query: string; options: Record<string, unknown> } | undefined;
const provider = createExaProvider(baseConfig, () => ({
async search(query, options) {
captured = { query, options };
return {
requestId: "req-search-1",
searchTime: 123,
results: [
{
id: "doc-1",
title: "Exa Docs",
url: "https://exa.ai/docs",
publishedDate: "2026-04-09",
author: "Exa",
score: 0.98,
},
],
};
},
async getContents() {
throw new Error("not used");
},
}));
const result = await provider.search({
query: "exa docs",
includeDomains: ["exa.ai"],
});
assert.deepEqual(captured, {
query: "exa docs",
options: {
contents: false,
numResults: 7,
includeDomains: ["exa.ai"],
excludeDomains: undefined,
startPublishedDate: undefined,
endPublishedDate: undefined,
category: undefined,
},
});
assert.equal(result.providerName, "exa-main");
assert.equal(result.results[0]?.url, "https://exa.ai/docs");
});
test("createExaProvider fetch defaults to text and preserves per-url failures", async () => {
const calls: Array<{ urls: string[]; options: Record<string, unknown> }> = [];
const provider = createExaProvider(baseConfig, () => ({
async search() {
throw new Error("not used");
},
async getContents(urls, options) {
const requestUrls = Array.isArray(urls) ? urls : [urls];
calls.push({ urls: requestUrls, options });
if (requestUrls[0] === "https://bad.example") {
throw new Error("429 rate limited");
}
return {
requestId: `req-${calls.length}`,
results: [
{
url: requestUrls[0],
title: "Fetched page",
text: "Fetched body",
},
],
};
},
}));
const result = await provider.fetch({
urls: ["https://good.example", "https://bad.example"],
});
assert.equal((calls[0]?.options.text as { maxCharacters: number }).maxCharacters, 9000);
assert.deepEqual(result.results, [
{
url: "https://good.example",
title: "Fetched page",
text: "Fetched body",
highlights: undefined,
summary: undefined,
},
{
url: "https://bad.example",
title: null,
error: "429 rate limited",
},
]);
});

124
src/providers/exa.ts Normal file
View File

@@ -0,0 +1,124 @@
import Exa from "exa-js";
import type { ExaProviderConfig } from "../schema.ts";
import type {
NormalizedFetchRequest,
NormalizedFetchResponse,
NormalizedSearchRequest,
NormalizedSearchResponse,
WebProvider,
} from "./types.ts";
export interface ExaClientLike {
search(query: string, options?: Record<string, unknown>): Promise<any>;
getContents(urls: string[] | string, options?: Record<string, unknown>): Promise<any>;
}
export type ExaClientFactory = (apiKey: string) => ExaClientLike;
export function buildSearchOptions(config: ExaProviderConfig, request: NormalizedSearchRequest) {
return {
contents: false,
numResults: request.limit ?? config.options?.defaultSearchLimit ?? 5,
includeDomains: request.includeDomains,
excludeDomains: request.excludeDomains,
startPublishedDate: request.startPublishedDate,
endPublishedDate: request.endPublishedDate,
category: request.category,
};
}
export function buildFetchOptions(config: ExaProviderConfig, request: NormalizedFetchRequest) {
const text = request.text ?? (!request.highlights && !request.summary);
return {
...(text
? {
text: {
maxCharacters: request.textMaxCharacters ?? config.options?.defaultFetchTextMaxCharacters ?? 12000,
},
}
: {}),
...(request.highlights
? {
highlights: {
maxCharacters: config.options?.defaultFetchHighlightsMaxCharacters ?? 1000,
},
}
: {}),
...(request.summary ? { summary: true } : {}),
};
}
export function createExaProvider(
config: ExaProviderConfig,
createClient: ExaClientFactory = (apiKey) => new Exa(apiKey) as unknown as ExaClientLike,
): WebProvider {
const client = createClient(config.apiKey);
return {
name: config.name,
type: config.type,
async search(request: NormalizedSearchRequest): Promise<NormalizedSearchResponse> {
const response = await client.search(request.query, buildSearchOptions(config, request));
return {
providerName: config.name,
requestId: response.requestId,
searchTime: response.searchTime,
results: (response.results ?? []).map((item: any) => ({
id: item.id,
title: item.title ?? null,
url: item.url,
publishedDate: item.publishedDate,
author: item.author,
score: item.score,
})),
};
},
async fetch(request: NormalizedFetchRequest): Promise<NormalizedFetchResponse> {
const requestIds: string[] = [];
const options = buildFetchOptions(config, request);
const results = await Promise.all(
request.urls.map(async (url) => {
try {
const response = await client.getContents([url], options);
if (response.requestId) {
requestIds.push(response.requestId);
}
const item = response.results?.[0];
if (!item) {
return {
url,
title: null,
error: "No content returned",
};
}
return {
url: item.url ?? url,
title: item.title ?? null,
text: typeof item.text === "string" ? item.text : undefined,
highlights: Array.isArray(item.highlights) ? item.highlights : undefined,
summary: typeof item.summary === "string" ? item.summary : undefined,
};
} catch (error) {
return {
url,
title: null,
error: (error as Error).message,
};
}
}),
);
return {
providerName: config.name,
requestIds,
results,
};
},
};
}

View File

@@ -0,0 +1,84 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTavilyProvider } from "./tavily.ts";
const baseConfig = {
name: "tavily-main",
type: "tavily" as const,
apiKey: "tvly-test-key",
options: {
defaultSearchLimit: 6,
defaultFetchTextMaxCharacters: 8000,
},
};
test("createTavilyProvider maps search requests to Tavily REST params", async () => {
let captured: RequestInit | undefined;
const provider = createTavilyProvider(baseConfig, async (_url, init) => {
captured = init;
return new Response(
JSON.stringify({
answer: "pi is a coding agent",
results: [
{
title: "pi docs",
url: "https://pi.dev",
content: "pi docs summary",
raw_content: "long raw body",
},
],
}),
{ status: 200 },
);
});
const result = await provider.search({
query: "pi docs",
limit: 4,
tavily: {
includeAnswer: true,
includeRawContent: true,
searchDepth: "advanced",
},
});
const body = JSON.parse(String(captured?.body));
assert.equal(body.max_results, 4);
assert.equal(body.include_answer, true);
assert.equal(body.include_raw_content, true);
assert.equal(body.search_depth, "advanced");
assert.equal(result.answer, "pi is a coding agent");
assert.equal(result.results[0]?.rawContent, "long raw body");
});
test("createTavilyProvider maps extract responses into normalized fetch results", async () => {
const provider = createTavilyProvider(baseConfig, async () => {
return new Response(
JSON.stringify({
results: [
{
url: "https://pi.dev",
title: "pi",
raw_content: "Fetched body",
images: ["https://pi.dev/logo.png"],
favicon: "https://pi.dev/favicon.ico",
},
],
}),
{ status: 200 },
);
});
const result = await provider.fetch({
urls: ["https://pi.dev"],
tavily: {
includeImages: true,
includeFavicon: true,
},
});
assert.equal(result.results[0]?.text, "Fetched body");
assert.deepEqual(result.results[0]?.images, ["https://pi.dev/logo.png"]);
assert.equal(result.results[0]?.favicon, "https://pi.dev/favicon.ico");
});

107
src/providers/tavily.ts Normal file
View File

@@ -0,0 +1,107 @@
import type { TavilyProviderConfig } from "../schema.ts";
import type {
NormalizedFetchRequest,
NormalizedFetchResponse,
NormalizedSearchRequest,
NormalizedSearchResponse,
WebProvider,
} from "./types.ts";
export type TavilyFetchLike = (input: string, init?: RequestInit) => Promise<Response>;
async function readError(response: Response) {
const text = await response.text();
throw new Error(`Tavily ${response.status} ${response.statusText}: ${text.slice(0, 300)}`);
}
export function createTavilyProvider(
config: TavilyProviderConfig,
fetchImpl: TavilyFetchLike = fetch,
): WebProvider {
return {
name: config.name,
type: config.type,
async search(request: NormalizedSearchRequest): Promise<NormalizedSearchResponse> {
const response = await fetchImpl("https://api.tavily.com/search", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
query: request.query,
max_results: request.limit ?? config.options?.defaultSearchLimit ?? 5,
include_domains: request.includeDomains,
exclude_domains: request.excludeDomains,
start_date: request.startPublishedDate,
end_date: request.endPublishedDate,
topic: request.tavily?.topic,
search_depth: request.tavily?.searchDepth,
time_range: request.tavily?.timeRange,
days: request.tavily?.days,
chunks_per_source: request.tavily?.chunksPerSource,
include_answer: request.tavily?.includeAnswer,
include_raw_content: request.tavily?.includeRawContent,
include_images: request.tavily?.includeImages,
}),
});
if (!response.ok) {
await readError(response);
}
const data = (await response.json()) as any;
return {
providerName: config.name,
requestId: data.request_id,
answer: typeof data.answer === "string" ? data.answer : undefined,
results: (data.results ?? []).map((item: any) => ({
title: item.title ?? null,
url: item.url,
content: typeof item.content === "string" ? item.content : undefined,
rawContent: typeof item.raw_content === "string" ? item.raw_content : undefined,
images: Array.isArray(item.images) ? item.images : undefined,
score: item.score,
publishedDate: item.published_date,
})),
};
},
async fetch(request: NormalizedFetchRequest): Promise<NormalizedFetchResponse> {
const response = await fetchImpl("https://api.tavily.com/extract", {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
urls: request.urls,
query: request.tavily?.query,
extract_depth: request.tavily?.extractDepth,
chunks_per_source: request.tavily?.chunksPerSource,
include_images: request.tavily?.includeImages,
include_favicon: request.tavily?.includeFavicon,
format: request.tavily?.format,
}),
});
if (!response.ok) {
await readError(response);
}
const data = (await response.json()) as any;
return {
providerName: config.name,
requestIds: data.request_id ? [data.request_id] : [],
results: (data.results ?? []).map((item: any) => ({
url: item.url,
title: item.title ?? null,
text: typeof item.raw_content === "string" ? item.raw_content : undefined,
images: Array.isArray(item.images) ? item.images : undefined,
favicon: typeof item.favicon === "string" ? item.favicon : undefined,
})),
};
},
};
}

85
src/providers/types.ts Normal file
View File

@@ -0,0 +1,85 @@
export interface TavilySearchOptions {
searchDepth?: "advanced" | "basic" | "fast" | "ultra-fast";
topic?: "general" | "news" | "finance";
timeRange?: string;
days?: number;
chunksPerSource?: number;
includeAnswer?: boolean;
includeRawContent?: boolean;
includeImages?: boolean;
}
export interface TavilyFetchOptions {
query?: string;
extractDepth?: "basic" | "advanced";
chunksPerSource?: number;
includeImages?: boolean;
includeFavicon?: boolean;
format?: string;
}
export interface NormalizedSearchRequest {
query: string;
limit?: number;
includeDomains?: string[];
excludeDomains?: string[];
startPublishedDate?: string;
endPublishedDate?: string;
category?: string;
provider?: string;
tavily?: TavilySearchOptions;
}
export interface NormalizedSearchResult {
id?: string;
title: string | null;
url: string;
publishedDate?: string;
author?: string;
score?: number;
content?: string;
rawContent?: string;
images?: string[];
}
export interface NormalizedSearchResponse {
providerName: string;
requestId?: string;
searchTime?: number;
answer?: string;
results: NormalizedSearchResult[];
}
export interface NormalizedFetchRequest {
urls: string[];
text?: boolean;
highlights?: boolean;
summary?: boolean;
textMaxCharacters?: number;
provider?: string;
tavily?: TavilyFetchOptions;
}
export interface NormalizedFetchResult {
url: string;
title: string | null;
text?: string;
highlights?: string[];
summary?: string;
images?: string[];
favicon?: string;
error?: string;
}
export interface NormalizedFetchResponse {
providerName: string;
requestIds?: string[];
results: NormalizedFetchResult[];
}
export interface WebProvider {
name: string;
type: string;
search(request: NormalizedSearchRequest): Promise<NormalizedSearchResponse>;
fetch(request: NormalizedFetchRequest): Promise<NormalizedFetchResponse>;
}

85
src/runtime.test.ts Normal file
View File

@@ -0,0 +1,85 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createWebSearchRuntime } from "./runtime.ts";
function createProvider(name: string, type: string, handlers: Partial<any>) {
return {
name,
type,
async search(request: any) {
return handlers.search?.(request);
},
async fetch(request: any) {
return handlers.fetch?.(request);
},
};
}
test("search retries Tavily failures once with Exa", async () => {
const runtime = createWebSearchRuntime({
loadConfig: async () => ({
path: "test.json",
defaultProviderName: "tavily-main",
defaultProvider: { name: "tavily-main", type: "tavily", apiKey: "tvly" },
providers: [
{ name: "tavily-main", type: "tavily", apiKey: "tvly" },
{ name: "exa-fallback", type: "exa", apiKey: "exa" },
],
providersByName: new Map([
["tavily-main", { name: "tavily-main", type: "tavily", apiKey: "tvly" }],
["exa-fallback", { name: "exa-fallback", type: "exa", apiKey: "exa" }],
]),
}),
createProvider(providerConfig) {
if (providerConfig.type === "tavily") {
return createProvider(providerConfig.name, providerConfig.type, {
search: async () => {
throw new Error("503 upstream unavailable");
},
});
}
return createProvider(providerConfig.name, providerConfig.type, {
search: async () => ({
providerName: providerConfig.name,
results: [{ title: "Exa hit", url: "https://exa.ai" }],
}),
});
},
});
const result = await runtime.search({ query: "pi docs" });
assert.equal(result.execution.actualProviderName, "exa-fallback");
assert.equal(result.execution.failoverFromProviderName, "tavily-main");
assert.match(result.execution.failoverReason ?? "", /503/);
});
test("search does not retry when Exa was explicitly selected", async () => {
const runtime = createWebSearchRuntime({
loadConfig: async () => ({
path: "test.json",
defaultProviderName: "tavily-main",
defaultProvider: { name: "tavily-main", type: "tavily", apiKey: "tvly" },
providers: [
{ name: "tavily-main", type: "tavily", apiKey: "tvly" },
{ name: "exa-fallback", type: "exa", apiKey: "exa" },
],
providersByName: new Map([
["tavily-main", { name: "tavily-main", type: "tavily", apiKey: "tvly" }],
["exa-fallback", { name: "exa-fallback", type: "exa", apiKey: "exa" }],
]),
}),
createProvider(providerConfig) {
return createProvider(providerConfig.name, providerConfig.type, {
search: async () => {
throw new Error(`boom:${providerConfig.name}`);
},
});
},
});
await assert.rejects(
() => runtime.search({ query: "pi docs", provider: "exa-fallback" }),
/boom:exa-fallback/,
);
});

139
src/runtime.ts Normal file
View File

@@ -0,0 +1,139 @@
import { loadWebSearchConfig, type ResolvedWebSearchConfig } from "./config.ts";
import { createExaProvider } from "./providers/exa.ts";
import { createTavilyProvider } from "./providers/tavily.ts";
import type {
NormalizedFetchRequest,
NormalizedFetchResponse,
NormalizedSearchRequest,
NormalizedSearchResponse,
WebProvider,
} from "./providers/types.ts";
import type { WebSearchProviderConfig } from "./schema.ts";
export interface ProviderExecutionMeta {
requestedProviderName?: string;
actualProviderName: string;
failoverFromProviderName?: string;
failoverReason?: string;
}
export interface RuntimeSearchResponse extends NormalizedSearchResponse {
execution: ProviderExecutionMeta;
}
export interface RuntimeFetchResponse extends NormalizedFetchResponse {
execution: ProviderExecutionMeta;
}
export function createWebSearchRuntime(
deps: {
loadConfig?: () => Promise<ResolvedWebSearchConfig>;
createProvider?: (providerConfig: WebSearchProviderConfig) => WebProvider;
} = {},
) {
const loadConfig = deps.loadConfig ?? loadWebSearchConfig;
const createProvider = deps.createProvider ?? ((providerConfig: WebSearchProviderConfig) => {
switch (providerConfig.type) {
case "tavily":
return createTavilyProvider(providerConfig);
case "exa":
return createExaProvider(providerConfig);
}
});
async function resolveConfigAndProvider(providerName?: string) {
const config = await loadConfig();
const selectedName = providerName ?? config.defaultProviderName;
const selectedConfig = config.providersByName.get(selectedName);
if (!selectedConfig) {
throw new Error(
`Unknown web-search provider \"${selectedName}\". Configured providers: ${[...config.providersByName.keys()].join(", ")}`,
);
}
return {
config,
selectedName,
selectedConfig,
selectedProvider: createProvider(selectedConfig),
};
}
async function search(request: NormalizedSearchRequest): Promise<RuntimeSearchResponse> {
const { config, selectedName, selectedConfig, selectedProvider } = await resolveConfigAndProvider(request.provider);
try {
const response = await selectedProvider.search(request);
return {
...response,
execution: {
requestedProviderName: request.provider,
actualProviderName: selectedName,
},
};
} catch (error) {
if (selectedConfig.type !== "tavily") {
throw error;
}
const fallbackConfig = [...config.providersByName.values()].find((provider) => provider.type === "exa");
if (!fallbackConfig) {
throw error;
}
const fallbackProvider = createProvider(fallbackConfig);
const fallbackResponse = await fallbackProvider.search({ ...request, provider: fallbackConfig.name });
return {
...fallbackResponse,
execution: {
requestedProviderName: request.provider,
actualProviderName: fallbackConfig.name,
failoverFromProviderName: selectedName,
failoverReason: (error as Error).message,
},
};
}
}
async function fetch(request: NormalizedFetchRequest): Promise<RuntimeFetchResponse> {
const { config, selectedName, selectedConfig, selectedProvider } = await resolveConfigAndProvider(request.provider);
try {
const response = await selectedProvider.fetch(request);
return {
...response,
execution: {
requestedProviderName: request.provider,
actualProviderName: selectedName,
},
};
} catch (error) {
if (selectedConfig.type !== "tavily") {
throw error;
}
const fallbackConfig = [...config.providersByName.values()].find((provider) => provider.type === "exa");
if (!fallbackConfig) {
throw error;
}
const fallbackProvider = createProvider(fallbackConfig);
const fallbackResponse = await fallbackProvider.fetch({ ...request, provider: fallbackConfig.name });
return {
...fallbackResponse,
execution: {
requestedProviderName: request.provider,
actualProviderName: fallbackConfig.name,
failoverFromProviderName: selectedName,
failoverReason: (error as Error).message,
},
};
}
}
return {
search,
fetch,
};
}

86
src/schema.ts Normal file
View File

@@ -0,0 +1,86 @@
import { Type, type Static } from "@sinclair/typebox";
export const ProviderOptionsSchema = Type.Object({
defaultSearchLimit: Type.Optional(Type.Integer({ minimum: 1 })),
defaultFetchTextMaxCharacters: Type.Optional(Type.Integer({ minimum: 1 })),
defaultFetchHighlightsMaxCharacters: Type.Optional(Type.Integer({ minimum: 1 })),
});
export const ExaProviderConfigSchema = Type.Object({
name: Type.String({ minLength: 1 }),
type: Type.Literal("exa"),
apiKey: Type.String({ minLength: 1 }),
options: Type.Optional(ProviderOptionsSchema),
});
export const TavilyProviderOptionsSchema = Type.Object({
defaultSearchLimit: Type.Optional(Type.Integer({ minimum: 1, maximum: 20 })),
defaultFetchTextMaxCharacters: Type.Optional(Type.Integer({ minimum: 1 })),
});
export const TavilyProviderConfigSchema = Type.Object({
name: Type.String({ minLength: 1 }),
type: Type.Literal("tavily"),
apiKey: Type.String({ minLength: 1 }),
options: Type.Optional(TavilyProviderOptionsSchema),
});
export const WebSearchProviderConfigSchema = Type.Union([ExaProviderConfigSchema, TavilyProviderConfigSchema]);
export const WebSearchConfigSchema = Type.Object({
defaultProvider: Type.String({ minLength: 1 }),
providers: Type.Array(WebSearchProviderConfigSchema, { minItems: 1 }),
});
export const TavilySearchToolOptionsSchema = Type.Object({
searchDepth: Type.Optional(Type.String()),
topic: Type.Optional(Type.String()),
timeRange: Type.Optional(Type.String()),
days: Type.Optional(Type.Integer({ minimum: 1 })),
chunksPerSource: Type.Optional(Type.Integer({ minimum: 1 })),
includeAnswer: Type.Optional(Type.Boolean()),
includeRawContent: Type.Optional(Type.Boolean()),
includeImages: Type.Optional(Type.Boolean()),
});
export const TavilyFetchToolOptionsSchema = Type.Object({
query: Type.Optional(Type.String()),
extractDepth: Type.Optional(Type.String()),
chunksPerSource: Type.Optional(Type.Integer({ minimum: 1 })),
includeImages: Type.Optional(Type.Boolean()),
includeFavicon: Type.Optional(Type.Boolean()),
format: Type.Optional(Type.String()),
});
export const WebSearchParamsSchema = Type.Object({
query: Type.String({ minLength: 1, description: "Search query" }),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 25 })),
includeDomains: Type.Optional(Type.Array(Type.String())),
excludeDomains: Type.Optional(Type.Array(Type.String())),
startPublishedDate: Type.Optional(Type.String()),
endPublishedDate: Type.Optional(Type.String()),
category: Type.Optional(Type.String()),
provider: Type.Optional(Type.String()),
tavily: Type.Optional(TavilySearchToolOptionsSchema),
});
export const WebFetchParamsSchema = Type.Object({
urls: Type.Array(Type.String(), { minItems: 1 }),
text: Type.Optional(Type.Boolean()),
highlights: Type.Optional(Type.Boolean()),
summary: Type.Optional(Type.Boolean()),
textMaxCharacters: Type.Optional(Type.Integer({ minimum: 1 })),
provider: Type.Optional(Type.String()),
tavily: Type.Optional(TavilyFetchToolOptionsSchema),
});
export type ProviderOptions = Static<typeof ProviderOptionsSchema>;
export type TavilyProviderOptions = Static<typeof TavilyProviderOptionsSchema>;
export type ExaProviderConfig = Static<typeof ExaProviderConfigSchema>;
export type TavilyProviderConfig = Static<typeof TavilyProviderConfigSchema>;
export type WebSearchProviderConfig = Static<typeof WebSearchProviderConfigSchema>;
export type WebSearchConfig = Static<typeof WebSearchConfigSchema>;
export type TavilySearchToolOptions = Static<typeof TavilySearchToolOptionsSchema>;
export type TavilyFetchToolOptions = Static<typeof TavilyFetchToolOptionsSchema>;
export type WebSearchParams = Static<typeof WebSearchParamsSchema>;
export type WebFetchParams = Static<typeof WebFetchParamsSchema>;

View File

@@ -0,0 +1,70 @@
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({
executeFetch: 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 forwards nested Tavily extract options to the runtime", async () => {
let capturedRequest: any;
const tool = createWebFetchTool({
executeFetch: async (request) => {
capturedRequest = request;
return {
providerName: "tavily-main",
results: [
{
url: "https://pi.dev",
title: "Docs",
text: "Body",
},
],
execution: { actualProviderName: "tavily-main" },
};
},
});
const result = await tool.execute(
"tool-1",
{
urls: ["https://pi.dev"],
tavily: {
query: "installation",
extractDepth: "advanced",
includeImages: true,
},
},
undefined,
undefined,
undefined,
);
assert.equal(capturedRequest.tavily.query, "installation");
assert.equal(capturedRequest.tavily.extractDepth, "advanced");
assert.equal(capturedRequest.text, true);
assert.match((result.content[0] as { text: string }).text, /Body/);
});
test("web_fetch rejects malformed URLs", async () => {
const tool = createWebFetchTool({
executeFetch: async () => {
throw new Error("should not execute fetch for invalid URLs");
},
});
await assert.rejects(
() => tool.execute("tool-1", { urls: ["not-a-url"] }, undefined, undefined, undefined),
/Invalid URL/,
);
});

90
src/tools/web-fetch.ts Normal file
View File

@@ -0,0 +1,90 @@
import { Text } from "@mariozechner/pi-tui";
import { formatFetchOutput } from "../format.ts";
import type { NormalizedFetchRequest, NormalizedFetchResponse } from "../providers/types.ts";
import { WebFetchParamsSchema, type WebFetchParams } from "../schema.ts";
interface FetchToolDeps {
executeFetch(request: NormalizedFetchRequest): Promise<NormalizedFetchResponse & { execution?: unknown }>;
}
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,
tavily: params.tavily,
};
}
export function createWebFetchTool({ executeFetch }: 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 response = await executeFetch(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

@@ -0,0 +1,55 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createWebSearchTool } from "./web-search.ts";
test("web_search forwards nested Tavily options to the runtime", async () => {
let capturedRequest: any;
const tool = createWebSearchTool({
executeSearch: async (request) => {
capturedRequest = request;
return {
providerName: "tavily-main",
results: [
{
title: "Docs",
url: "https://pi.dev",
},
],
execution: { actualProviderName: "tavily-main" },
};
},
});
const result = await tool.execute(
"tool-1",
{
query: "pi docs",
tavily: {
includeAnswer: true,
includeRawContent: true,
searchDepth: "advanced",
},
},
undefined,
undefined,
undefined,
);
assert.equal(capturedRequest.tavily.includeAnswer, true);
assert.equal(capturedRequest.tavily.searchDepth, "advanced");
assert.match((result.content[0] as { text: string }).text, /Docs/);
});
test("web_search rejects a blank query before resolving a provider", async () => {
const tool = createWebSearchTool({
executeSearch: async () => {
throw new Error("should not execute search for a blank query");
},
});
await assert.rejects(
() => tool.execute("tool-1", { query: " " }, undefined, undefined, undefined),
/non-empty query/,
);
});

68
src/tools/web-search.ts Normal file
View File

@@ -0,0 +1,68 @@
import { Text } from "@mariozechner/pi-tui";
import { formatSearchOutput } from "../format.ts";
import type { NormalizedSearchRequest, NormalizedSearchResponse } from "../providers/types.ts";
import { WebSearchParamsSchema, type WebSearchParams } from "../schema.ts";
interface SearchToolDeps {
executeSearch(request: NormalizedSearchRequest): Promise<NormalizedSearchResponse & { execution?: unknown }>;
}
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({ executeSearch }: 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 response = await executeSearch({
query,
limit: params.limit,
includeDomains: params.includeDomains,
excludeDomains: params.excludeDomains,
startPublishedDate: params.startPublishedDate,
endPublishedDate: params.endPublishedDate,
category: params.category,
provider: params.provider,
tavily: params.tavily,
});
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);
},
};
}