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,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);
},
};
}