sync local pi changes
This commit is contained in:
@@ -1,30 +1,13 @@
|
||||
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 { registerWebSearchConfigCommand } from "./src/commands/web-search-config.ts";
|
||||
import { createWebSearchRuntime } from "./src/runtime.ts";
|
||||
import { createWebFetchTool } from "./src/tools/web-fetch.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 }));
|
||||
pi.registerTool(createWebFetchTool({ resolveProvider }));
|
||||
const runtime = createWebSearchRuntime();
|
||||
|
||||
pi.registerTool(createWebSearchTool({ executeSearch: runtime.search }));
|
||||
pi.registerTool(createWebFetchTool({ executeFetch: runtime.fetch }));
|
||||
registerWebSearchConfigCommand(pi);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -37,6 +37,30 @@ test("loadWebSearchConfig returns a normalized default provider and provider loo
|
||||
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",
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { Value } from "@sinclair/typebox/value";
|
||||
import { WebSearchConfigSchema, type ExaProviderConfig, type WebSearchConfig } from "./schema.ts";
|
||||
import {
|
||||
WebSearchConfigSchema,
|
||||
type WebSearchConfig,
|
||||
type WebSearchProviderConfig,
|
||||
} from "./schema.ts";
|
||||
|
||||
export interface ResolvedWebSearchConfig {
|
||||
path: string;
|
||||
defaultProviderName: string;
|
||||
defaultProvider: ExaProviderConfig;
|
||||
providers: ExaProviderConfig[];
|
||||
providersByName: Map<string, ExaProviderConfig>;
|
||||
defaultProvider: WebSearchProviderConfig;
|
||||
providers: WebSearchProviderConfig[];
|
||||
providersByName: Map<string, WebSearchProviderConfig>;
|
||||
}
|
||||
|
||||
export class WebSearchConfigError extends Error {
|
||||
@@ -26,10 +30,15 @@ export function getDefaultWebSearchConfigPath() {
|
||||
function exampleConfigSnippet() {
|
||||
return JSON.stringify(
|
||||
{
|
||||
defaultProvider: "exa-main",
|
||||
defaultProvider: "tavily-main",
|
||||
providers: [
|
||||
{
|
||||
name: "exa-main",
|
||||
name: "tavily-main",
|
||||
type: "tavily",
|
||||
apiKey: "tvly-...",
|
||||
},
|
||||
{
|
||||
name: "exa-fallback",
|
||||
type: "exa",
|
||||
apiKey: "exa_...",
|
||||
},
|
||||
@@ -41,7 +50,7 @@ function exampleConfigSnippet() {
|
||||
}
|
||||
|
||||
export function normalizeWebSearchConfig(config: WebSearchConfig, path: string): ResolvedWebSearchConfig {
|
||||
const providersByName = new Map<string, ExaProviderConfig>();
|
||||
const providersByName = new Map<string, WebSearchProviderConfig>();
|
||||
|
||||
for (const provider of config.providers) {
|
||||
if (!provider.apiKey.trim()) {
|
||||
@@ -69,19 +78,7 @@ export function normalizeWebSearchConfig(config: WebSearchConfig, path: string):
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadWebSearchConfig(path = getDefaultWebSearchConfigPath()) {
|
||||
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;
|
||||
}
|
||||
|
||||
function parseWebSearchConfig(raw: string, path: string) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
@@ -96,5 +93,35 @@ export async function loadWebSearchConfig(path = getDefaultWebSearchConfigPath()
|
||||
);
|
||||
}
|
||||
|
||||
return normalizeWebSearchConfig(parsed as WebSearchConfig, path);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,19 @@ 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", () => {
|
||||
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"]);
|
||||
});
|
||||
|
||||
@@ -21,6 +21,27 @@ test("formatSearchOutput renders a compact metadata-only list", () => {
|
||||
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");
|
||||
@@ -51,3 +72,26 @@ test("formatFetchOutput includes both successful and failed URLs", () => {
|
||||
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:/);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
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;
|
||||
@@ -7,14 +17,24 @@ export function truncateText(text: string, maxCharacters = 4000) {
|
||||
return `${text.slice(0, Math.max(0, maxCharacters - 1))}…`;
|
||||
}
|
||||
|
||||
export function formatSearchOutput(response: NormalizedSearchResponse) {
|
||||
if (response.results.length === 0) {
|
||||
return `No web results via ${response.providerName}.`;
|
||||
export function formatSearchOutput(response: NormalizedSearchResponse & { execution?: any }) {
|
||||
const lines: string[] = [];
|
||||
const fallbackLine = formatFallbackLine(response.execution);
|
||||
|
||||
if (fallbackLine) {
|
||||
lines.push(fallbackLine, "");
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`Found ${response.results.length} web result${response.results.length === 1 ? "" : "s"} via ${response.providerName}:`,
|
||||
];
|
||||
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)"}`);
|
||||
@@ -28,6 +48,14 @@ export function formatSearchOutput(response: NormalizedSearchResponse) {
|
||||
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");
|
||||
@@ -37,11 +65,16 @@ export interface FetchFormatOptions {
|
||||
maxCharactersPerResult?: number;
|
||||
}
|
||||
|
||||
export function formatFetchOutput(response: NormalizedFetchResponse, options: FetchFormatOptions = {}) {
|
||||
export function formatFetchOutput(response: NormalizedFetchResponse & { execution?: any }, options: FetchFormatOptions = {}) {
|
||||
const maxCharactersPerResult = options.maxCharactersPerResult ?? 4000;
|
||||
const lines = [
|
||||
`Fetched ${response.results.length} URL${response.results.length === 1 ? "" : "s"} via ${response.providerName}:`,
|
||||
];
|
||||
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("");
|
||||
@@ -66,6 +99,15 @@ export function formatFetchOutput(response: NormalizedFetchResponse, options: Fe
|
||||
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));
|
||||
|
||||
84
.pi/agent/extensions/web-search/src/providers/tavily.test.ts
Normal file
84
.pi/agent/extensions/web-search/src/providers/tavily.test.ts
Normal 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
.pi/agent/extensions/web-search/src/providers/tavily.ts
Normal file
107
.pi/agent/extensions/web-search/src/providers/tavily.ts
Normal 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,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,23 @@
|
||||
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;
|
||||
@@ -7,6 +27,7 @@ export interface NormalizedSearchRequest {
|
||||
endPublishedDate?: string;
|
||||
category?: string;
|
||||
provider?: string;
|
||||
tavily?: TavilySearchOptions;
|
||||
}
|
||||
|
||||
export interface NormalizedSearchResult {
|
||||
@@ -16,12 +37,16 @@ export interface NormalizedSearchResult {
|
||||
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[];
|
||||
}
|
||||
|
||||
@@ -32,6 +57,7 @@ export interface NormalizedFetchRequest {
|
||||
summary?: boolean;
|
||||
textMaxCharacters?: number;
|
||||
provider?: string;
|
||||
tavily?: TavilyFetchOptions;
|
||||
}
|
||||
|
||||
export interface NormalizedFetchResult {
|
||||
@@ -40,6 +66,8 @@ export interface NormalizedFetchResult {
|
||||
text?: string;
|
||||
highlights?: string[];
|
||||
summary?: string;
|
||||
images?: string[];
|
||||
favicon?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
85
.pi/agent/extensions/web-search/src/runtime.test.ts
Normal file
85
.pi/agent/extensions/web-search/src/runtime.test.ts
Normal 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
.pi/agent/extensions/web-search/src/runtime.ts
Normal file
139
.pi/agent/extensions/web-search/src/runtime.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -13,9 +13,43 @@ export const ExaProviderConfigSchema = Type.Object({
|
||||
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(ExaProviderConfigSchema, { minItems: 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({
|
||||
@@ -27,6 +61,7 @@ export const WebSearchParamsSchema = Type.Object({
|
||||
endPublishedDate: Type.Optional(Type.String()),
|
||||
category: Type.Optional(Type.String()),
|
||||
provider: Type.Optional(Type.String()),
|
||||
tavily: Type.Optional(TavilySearchToolOptionsSchema),
|
||||
});
|
||||
|
||||
export const WebFetchParamsSchema = Type.Object({
|
||||
@@ -36,10 +71,16 @@ export const WebFetchParamsSchema = Type.Object({
|
||||
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>;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createWebFetchTool } from "./web-fetch.ts";
|
||||
|
||||
test("web_fetch prepareArguments folds a single url into urls", () => {
|
||||
const tool = createWebFetchTool({
|
||||
resolveProvider: async () => {
|
||||
executeFetch: async () => {
|
||||
throw new Error("not used");
|
||||
},
|
||||
});
|
||||
@@ -15,43 +15,51 @@ test("web_fetch prepareArguments folds a single url into urls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("web_fetch defaults to text and returns formatted fetch results", async () => {
|
||||
let capturedRequest: Record<string, unknown> | undefined;
|
||||
test("web_fetch forwards nested Tavily extract options to the runtime", async () => {
|
||||
let capturedRequest: any;
|
||||
|
||||
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",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
}),
|
||||
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://exa.ai/docs"] }, undefined, undefined, undefined);
|
||||
const result = await tool.execute(
|
||||
"tool-1",
|
||||
{
|
||||
urls: ["https://pi.dev"],
|
||||
tavily: {
|
||||
query: "installation",
|
||||
extractDepth: "advanced",
|
||||
includeImages: true,
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
assert.equal(capturedRequest?.text, true);
|
||||
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/);
|
||||
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");
|
||||
executeFetch: async () => {
|
||||
throw new Error("should not execute fetch for invalid URLs");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { formatFetchOutput } from "../format.ts";
|
||||
import type { NormalizedFetchResponse, WebProvider } from "../providers/types.ts";
|
||||
import type { NormalizedFetchRequest, NormalizedFetchResponse } from "../providers/types.ts";
|
||||
import { WebFetchParamsSchema, type WebFetchParams } from "../schema.ts";
|
||||
|
||||
interface FetchToolDeps {
|
||||
resolveProvider(providerName?: string): Promise<WebProvider>;
|
||||
executeFetch(request: NormalizedFetchRequest): Promise<NormalizedFetchResponse & { execution?: unknown }>;
|
||||
}
|
||||
|
||||
function normalizeUrl(value: string) {
|
||||
@@ -28,10 +28,11 @@ function normalizeFetchParams(params: WebFetchParams & { url?: string }) {
|
||||
summary: params.summary ?? false,
|
||||
textMaxCharacters: params.textMaxCharacters,
|
||||
provider: params.provider,
|
||||
tavily: params.tavily,
|
||||
};
|
||||
}
|
||||
|
||||
export function createWebFetchTool({ resolveProvider }: FetchToolDeps) {
|
||||
export function createWebFetchTool({ executeFetch }: FetchToolDeps) {
|
||||
return {
|
||||
name: "web_fetch",
|
||||
label: "Web Fetch",
|
||||
@@ -56,8 +57,7 @@ export function createWebFetchTool({ resolveProvider }: FetchToolDeps) {
|
||||
|
||||
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);
|
||||
const response = await executeFetch(normalized);
|
||||
|
||||
return {
|
||||
content: [{ type: "text" as const, text: formatFetchOutput(response) }],
|
||||
|
||||
@@ -2,46 +2,49 @@ 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;
|
||||
test("web_search forwards nested Tavily options to the runtime", async () => {
|
||||
let capturedRequest: any;
|
||||
|
||||
const tool = createWebSearchTool({
|
||||
resolveProvider: async (providerName) => {
|
||||
resolvedProviderName = providerName;
|
||||
executeSearch: async (request) => {
|
||||
capturedRequest = request;
|
||||
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");
|
||||
},
|
||||
providerName: "tavily-main",
|
||||
results: [
|
||||
{
|
||||
title: "Docs",
|
||||
url: "https://pi.dev",
|
||||
},
|
||||
],
|
||||
execution: { actualProviderName: "tavily-main" },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool.execute("tool-1", { query: "exa docs" }, undefined, undefined, undefined);
|
||||
const result = await tool.execute(
|
||||
"tool-1",
|
||||
{
|
||||
query: "pi docs",
|
||||
tavily: {
|
||||
includeAnswer: true,
|
||||
includeRawContent: true,
|
||||
searchDepth: "advanced",
|
||||
},
|
||||
},
|
||||
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");
|
||||
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({
|
||||
resolveProvider: async () => {
|
||||
throw new Error("should not resolve provider for a blank query");
|
||||
executeSearch: async () => {
|
||||
throw new Error("should not execute search for a blank query");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Text } from "@mariozechner/pi-tui";
|
||||
import { formatSearchOutput } from "../format.ts";
|
||||
import type { NormalizedSearchResponse, WebProvider } from "../providers/types.ts";
|
||||
import type { NormalizedSearchRequest, NormalizedSearchResponse } from "../providers/types.ts";
|
||||
import { WebSearchParamsSchema, type WebSearchParams } from "../schema.ts";
|
||||
|
||||
interface SearchToolDeps {
|
||||
resolveProvider(providerName?: string): Promise<WebProvider>;
|
||||
executeSearch(request: NormalizedSearchRequest): Promise<NormalizedSearchResponse & { execution?: unknown }>;
|
||||
}
|
||||
|
||||
function normalizeSearchQuery(query: string) {
|
||||
@@ -15,7 +15,7 @@ function normalizeSearchQuery(query: string) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function createWebSearchTool({ resolveProvider }: SearchToolDeps) {
|
||||
export function createWebSearchTool({ executeSearch }: SearchToolDeps) {
|
||||
return {
|
||||
name: "web_search",
|
||||
label: "Web Search",
|
||||
@@ -24,8 +24,7 @@ export function createWebSearchTool({ resolveProvider }: SearchToolDeps) {
|
||||
|
||||
async execute(_toolCallId: string, params: WebSearchParams) {
|
||||
const query = normalizeSearchQuery(params.query);
|
||||
const provider = await resolveProvider(params.provider);
|
||||
const response = await provider.search({
|
||||
const response = await executeSearch({
|
||||
query,
|
||||
limit: params.limit,
|
||||
includeDomains: params.includeDomains,
|
||||
@@ -34,6 +33,7 @@ export function createWebSearchTool({ resolveProvider }: SearchToolDeps) {
|
||||
endPublishedDate: params.endPublishedDate,
|
||||
category: params.category,
|
||||
provider: params.provider,
|
||||
tavily: params.tavily,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user