elizaos--eliza
426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
317 行
11 KiB
TypeScript
317 行
11 KiB
TypeScript
/**
|
|
* Tavily-backed `WebSearchService` — the `ServiceType.WEB_SEARCH` implementation.
|
|
*
|
|
* Wraps `@tavily/core` to fulfil the `IWebSearchService` contract (search /
|
|
* news / images / videos / suggestions / trending / page-info), normalizing
|
|
* Tavily's responses to core's shared shape. Degrades gracefully: without
|
|
* `TAVILY_API_KEY` it boots inert and throws a descriptive error on first use
|
|
* rather than crashing boot. `getPageInfo` is a raw fetch + regex scrape (not
|
|
* Tavily-backed); videos reuse web search since Tavily has no video endpoint.
|
|
*/
|
|
|
|
import { type IAgentRuntime, IWebSearchService, logger, ServiceType } from "@elizaos/core";
|
|
import { tavily } from "@tavily/core";
|
|
|
|
import type {
|
|
ImageSearchOptions,
|
|
NewsSearchOptions,
|
|
SearchOptions,
|
|
SearchResponse,
|
|
VideoSearchOptions,
|
|
} from "../types";
|
|
|
|
export type TavilyClient = ReturnType<typeof tavily>;
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function parsePublishedDate(value: string | undefined): Date | undefined {
|
|
if (!value) return undefined;
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
}
|
|
|
|
function normalizeApiKey(value: unknown): string | undefined {
|
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
}
|
|
|
|
function validateSearchQuery(query: unknown): string {
|
|
if (typeof query !== "string" || !query.trim()) {
|
|
throw new Error("search query is required");
|
|
}
|
|
return query.trim();
|
|
}
|
|
|
|
function assertOptionalPositiveInteger(value: unknown, name: string): void {
|
|
if (
|
|
value !== undefined &&
|
|
(typeof value !== "number" ||
|
|
!Number.isFinite(value) ||
|
|
!Number.isInteger(value) ||
|
|
value < 1)
|
|
) {
|
|
throw new Error(`${name} must be a positive finite integer`);
|
|
}
|
|
}
|
|
|
|
function assertOptionalNonNegativeInteger(value: unknown, name: string): void {
|
|
if (
|
|
value !== undefined &&
|
|
(typeof value !== "number" ||
|
|
!Number.isFinite(value) ||
|
|
!Number.isInteger(value) ||
|
|
value < 0)
|
|
) {
|
|
throw new Error(`${name} must be a non-negative finite integer`);
|
|
}
|
|
}
|
|
|
|
function validateSearchOptions(options?: SearchOptions): void {
|
|
if (options === undefined) return;
|
|
if (!isRecord(options)) {
|
|
throw new Error("search options must be an object");
|
|
}
|
|
assertOptionalPositiveInteger(options.limit, "limit");
|
|
assertOptionalNonNegativeInteger(options.days, "days");
|
|
if (options.topic !== undefined && options.topic !== "general" && options.topic !== "news") {
|
|
throw new Error("topic must be general or news");
|
|
}
|
|
if (options.type !== undefined && options.type !== "general" && options.type !== "news") {
|
|
throw new Error("type must be general or news");
|
|
}
|
|
if (
|
|
options.searchDepth !== undefined &&
|
|
options.searchDepth !== "basic" &&
|
|
options.searchDepth !== "advanced"
|
|
) {
|
|
throw new Error("searchDepth must be basic or advanced");
|
|
}
|
|
if (options.includeAnswer !== undefined && typeof options.includeAnswer !== "boolean") {
|
|
throw new Error("includeAnswer must be a boolean");
|
|
}
|
|
if (options.includeImages !== undefined && typeof options.includeImages !== "boolean") {
|
|
throw new Error("includeImages must be a boolean");
|
|
}
|
|
}
|
|
|
|
function normalizeResponse(query: string, response: unknown): SearchResponse {
|
|
const payload = isRecord(response) ? response : {};
|
|
const rawResults = Array.isArray(payload.results) ? payload.results : [];
|
|
const results = rawResults.filter(isRecord).map((result) => {
|
|
const content = typeof result.content === "string" ? result.content : "";
|
|
return {
|
|
title: typeof result.title === "string" ? result.title : "Untitled",
|
|
url: typeof result.url === "string" ? result.url : "",
|
|
description: content,
|
|
content,
|
|
rawContent: typeof result.rawContent === "string" ? result.rawContent : undefined,
|
|
score:
|
|
typeof result.score === "number" && Number.isFinite(result.score)
|
|
? result.score
|
|
: 0,
|
|
publishedDate: parsePublishedDate(
|
|
typeof result.publishedDate === "string" ? result.publishedDate : undefined
|
|
),
|
|
};
|
|
});
|
|
const rawImages = Array.isArray(payload.images) ? payload.images : [];
|
|
const images = rawImages
|
|
.map((image) =>
|
|
typeof image === "string"
|
|
? { url: image }
|
|
: isRecord(image)
|
|
? {
|
|
url: typeof image.url === "string" ? image.url : "",
|
|
description:
|
|
typeof image.description === "string" ? image.description : undefined,
|
|
}
|
|
: { url: "" }
|
|
)
|
|
.filter((image) => image.url);
|
|
|
|
return {
|
|
answer: typeof payload.answer === "string" ? payload.answer : undefined,
|
|
query: typeof payload.query === "string" ? payload.query : query,
|
|
responseTime: typeof payload.responseTime === "number" ? payload.responseTime : undefined,
|
|
images,
|
|
results,
|
|
};
|
|
}
|
|
|
|
function uniqueResultTitles(response: SearchResponse, limit: number): string[] {
|
|
const seen = new Set<string>();
|
|
const titles: string[] = [];
|
|
for (const result of response.results) {
|
|
const title = result.title.trim();
|
|
if (!title || title === "Untitled") continue;
|
|
const key = title.toLocaleLowerCase();
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
titles.push(title);
|
|
if (titles.length >= limit) break;
|
|
}
|
|
return titles;
|
|
}
|
|
|
|
function freshnessToDays(freshness: NewsSearchOptions["freshness"]): number {
|
|
switch (freshness) {
|
|
case "day":
|
|
return 1;
|
|
case "week":
|
|
return 7;
|
|
case "month":
|
|
return 30;
|
|
default:
|
|
return 3;
|
|
}
|
|
}
|
|
|
|
export class WebSearchService extends IWebSearchService {
|
|
static override serviceType = ServiceType.WEB_SEARCH;
|
|
override capabilityDescription = "Web search and content discovery capabilities" as const;
|
|
|
|
tavilyClient: TavilyClient | undefined;
|
|
private configured = false;
|
|
|
|
static override async start(runtime: IAgentRuntime): Promise<WebSearchService> {
|
|
const service = new WebSearchService(runtime);
|
|
await service.initialize(runtime);
|
|
return service;
|
|
}
|
|
|
|
async stop(): Promise<void> {
|
|
// Tavily client is stateless HTTP; nothing to tear down.
|
|
}
|
|
|
|
private async initialize(runtime: IAgentRuntime): Promise<void> {
|
|
const apiKey = normalizeApiKey(runtime.getSetting("TAVILY_API_KEY"));
|
|
if (!apiKey) {
|
|
// Degrade gracefully instead of throwing, so the plugin can be
|
|
// installed unconfigured without crashing agent boot. The service
|
|
// stays inert and `search()` reports an honest, recoverable error
|
|
// until a TAVILY_API_KEY is provided.
|
|
this.configured = false;
|
|
logger.warn(
|
|
{ src: "plugin-web-search" },
|
|
"TAVILY_API_KEY not set — web search is inert until a key is provided"
|
|
);
|
|
return;
|
|
}
|
|
this.tavilyClient = tavily({ apiKey });
|
|
this.configured = true;
|
|
}
|
|
|
|
async search(query: string, options?: SearchOptions): Promise<SearchResponse> {
|
|
const normalizedQuery = validateSearchQuery(query);
|
|
validateSearchOptions(options);
|
|
if (!this.configured || !this.tavilyClient) {
|
|
throw new Error("Web search is not configured: set TAVILY_API_KEY to enable it.");
|
|
}
|
|
try {
|
|
const response = await this.tavilyClient.search(normalizedQuery, {
|
|
includeAnswer: options?.includeAnswer ?? true,
|
|
maxResults: options?.limit ?? 3,
|
|
topic: options?.topic ?? options?.type ?? "general",
|
|
searchDepth: options?.searchDepth ?? "basic",
|
|
includeImages: options?.includeImages ?? false,
|
|
days: options?.days ?? 3,
|
|
});
|
|
|
|
return normalizeResponse(normalizedQuery, response);
|
|
} catch (cause) {
|
|
const err = cause instanceof Error ? cause : new Error(String(cause));
|
|
logger.error({ src: "plugin-web-search", err }, "Web search error");
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async searchNews(query: string, options?: NewsSearchOptions): Promise<SearchResponse> {
|
|
return this.search(query, {
|
|
...options,
|
|
type: "news",
|
|
topic: "news",
|
|
days: freshnessToDays(options?.freshness),
|
|
});
|
|
}
|
|
|
|
async searchImages(query: string, options?: ImageSearchOptions): Promise<SearchResponse> {
|
|
return this.search(query, {
|
|
limit: options?.limit,
|
|
offset: options?.offset,
|
|
language: options?.language,
|
|
region: options?.region,
|
|
dateRange: options?.dateRange,
|
|
fileType: options?.fileType,
|
|
site: options?.site,
|
|
sortBy: options?.sortBy,
|
|
safeSearch: options?.safeSearch,
|
|
includeImages: true,
|
|
});
|
|
}
|
|
|
|
async searchVideos(query: string, options?: VideoSearchOptions): Promise<SearchResponse> {
|
|
const normalizedQuery = validateSearchQuery(query);
|
|
return this.search(`${normalizedQuery} video`, {
|
|
...options,
|
|
includeImages: true,
|
|
});
|
|
}
|
|
|
|
async getSuggestions(query: string): Promise<string[]> {
|
|
const response = await this.search(validateSearchQuery(query), {
|
|
includeAnswer: false,
|
|
limit: 5,
|
|
searchDepth: "basic",
|
|
});
|
|
return uniqueResultTitles(response, 5);
|
|
}
|
|
|
|
async getTrendingSearches(region?: string): Promise<string[]> {
|
|
const normalizedRegion = typeof region === "string" ? region.trim() : "";
|
|
const query = normalizedRegion ? `trending news in ${normalizedRegion}` : "trending news";
|
|
const response = await this.searchNews(query, {
|
|
freshness: "day",
|
|
limit: 5,
|
|
region: normalizedRegion || undefined,
|
|
});
|
|
return uniqueResultTitles(response, 5);
|
|
}
|
|
|
|
async getPageInfo(url: string): Promise<{
|
|
title: string;
|
|
description: string;
|
|
content: string;
|
|
metadata: Record<string, string>;
|
|
images: string[];
|
|
links: string[];
|
|
}> {
|
|
let parsedUrl: URL;
|
|
try {
|
|
parsedUrl = new URL(url);
|
|
} catch {
|
|
throw new Error("Invalid page info URL");
|
|
}
|
|
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
|
throw new Error("Page info URL must use http or https");
|
|
}
|
|
|
|
const response = await fetch(parsedUrl.toString());
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to fetch page info: ${response.status} ${response.statusText}`);
|
|
}
|
|
const content = await response.text();
|
|
const title = content.match(/<title[^>]*>(.*?)<\/title>/i)?.[1] ?? url;
|
|
const description =
|
|
content.match(/<meta\s+name=["']description["']\s+content=["']([^"']+)/i)?.[1] ?? "";
|
|
return {
|
|
title,
|
|
description,
|
|
content,
|
|
metadata: {},
|
|
images: [],
|
|
links: [],
|
|
};
|
|
}
|
|
}
|