initial commit
This commit is contained in:
70
src/tools/web-fetch.test.ts
Normal file
70
src/tools/web-fetch.test.ts
Normal 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
90
src/tools/web-fetch.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
55
src/tools/web-search.test.ts
Normal file
55
src/tools/web-search.test.ts
Normal 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
68
src/tools/web-search.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user