From c99790e39f5fbd94c05f3f43fcd71f8eafde082e Mon Sep 17 00:00:00 2001 From: NiTingKY <2514656615@qq.com> Date: Mon, 15 Jun 2026 11:30:24 +0800 Subject: [PATCH 1/2] Add Bocha web search example --- examples/with-bocha-search/.env.example | 8 ++ examples/with-bocha-search/README.md | 81 ++++++++++++ examples/with-bocha-search/package.json | 39 ++++++ examples/with-bocha-search/src/bocha.ts | 98 ++++++++++++++ examples/with-bocha-search/src/index.ts | 34 +++++ examples/with-bocha-search/src/tools.spec.ts | 63 +++++++++ examples/with-bocha-search/src/tools.ts | 128 +++++++++++++++++++ examples/with-bocha-search/tsconfig.json | 16 +++ examples/with-bocha-search/vitest.config.ts | 12 ++ pnpm-lock.yaml | 37 ++++++ 10 files changed, 516 insertions(+) create mode 100644 examples/with-bocha-search/.env.example create mode 100644 examples/with-bocha-search/README.md create mode 100644 examples/with-bocha-search/package.json create mode 100644 examples/with-bocha-search/src/bocha.ts create mode 100644 examples/with-bocha-search/src/index.ts create mode 100644 examples/with-bocha-search/src/tools.spec.ts create mode 100644 examples/with-bocha-search/src/tools.ts create mode 100644 examples/with-bocha-search/tsconfig.json create mode 100644 examples/with-bocha-search/vitest.config.ts diff --git a/examples/with-bocha-search/.env.example b/examples/with-bocha-search/.env.example new file mode 100644 index 000000000..31f19eed0 --- /dev/null +++ b/examples/with-bocha-search/.env.example @@ -0,0 +1,8 @@ +# OpenAI API Key (required) +OPENAI_API_KEY=your_openai_api_key_here + +# Bocha Search API Key (required) +BOCHA_SEARCH_API_KEY=your_bocha_search_api_key_here + +# Optional Bocha Search API URL +BOCHA_SEARCH_API_URL=https://api.bochaai.com/v1/web-search?utm_source=voltagent diff --git a/examples/with-bocha-search/README.md b/examples/with-bocha-search/README.md new file mode 100644 index 000000000..feb366928 --- /dev/null +++ b/examples/with-bocha-search/README.md @@ -0,0 +1,81 @@ +# VoltAgent with Bocha Web Search + +This example shows how to add Bocha Web Search to a VoltAgent agent as a typed tool. The agent can search the web for current, source-linked results and use those results to answer user questions. + +## Features + +- Web search through Bocha's search API +- Source-linked results with title, URL, snippet, source, and published date +- Optional freshness, result count, include-domain, and exclude-domain controls +- Clear missing-key and API error messages +- Unit-tested request and response mapping helpers + +## Prerequisites + +1. A Bocha Search API key. +2. An OpenAI API key for the agent model. + +## Setup + +Install dependencies from the repository root: + +```bash +pnpm install +``` + +Copy the example environment file: + +```bash +cp examples/with-bocha-search/.env.example examples/with-bocha-search/.env +``` + +Then set your keys: + +```env +OPENAI_API_KEY=your_actual_openai_api_key +BOCHA_SEARCH_API_KEY=your_actual_bocha_search_api_key +BOCHA_SEARCH_API_URL=https://api.bochaai.com/v1/web-search?utm_source=voltagent +``` + +`BOCHA_SEARCH_API_URL` is optional. The example defaults to the URL shown above. + +## Run + +```bash +pnpm --filter voltagent-example-with-bocha-search dev +``` + +The agent runs on VoltAgent's default local server and exposes a `searchAgent` with the `bochaSearch` tool. + +## Example Queries + +- "Search the web for the latest TypeScript agent framework news." +- "Find recent information about VoltAgent and summarize the sources." +- "Search only github.com for Bocha web search integrations." +- "Find current AI agent framework announcements from the last week." + +## Tool Parameters + +The `bochaSearch` tool accepts: + +- `query`: Search query string. +- `count`: Number of results to return, default 5 and maximum 10. +- `freshness`: Optional Bocha freshness value such as `noLimit` or `oneWeek`. +- `includeDomains`: Optional domains to include. +- `excludeDomains`: Optional domains to exclude. + +## Validation + +Run the focused tests: + +```bash +pnpm exec vitest run --config examples/with-bocha-search/vitest.config.ts +``` + +Build the example: + +```bash +pnpm --filter voltagent-example-with-bocha-search build +``` + +No real API keys are committed. Configure `BOCHA_SEARCH_API_KEY` only in your local environment. diff --git a/examples/with-bocha-search/package.json b/examples/with-bocha-search/package.json new file mode 100644 index 000000000..b00036771 --- /dev/null +++ b/examples/with-bocha-search/package.json @@ -0,0 +1,39 @@ +{ + "name": "voltagent-example-with-bocha-search", + "author": "", + "dependencies": { + "@voltagent/cli": "^0.1.21", + "@voltagent/core": "^2.7.7", + "@voltagent/internal": "^1.0.3", + "@voltagent/libsql": "^2.1.2", + "@voltagent/logger": "^2.0.2", + "@voltagent/server-hono": "^2.0.14", + "ai": "^6.0.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^24.2.1", + "tsx": "^4.21.0", + "typescript": "^5.8.2" + }, + "keywords": [ + "agent", + "ai", + "search", + "voltagent" + ], + "license": "MIT", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/VoltAgent/voltagent.git", + "directory": "examples/with-bocha-search" + }, + "scripts": { + "build": "tsc", + "dev": "tsx watch --env-file=.env ./src", + "start": "node dist/index.js", + "volt": "volt" + }, + "type": "module" +} diff --git a/examples/with-bocha-search/src/bocha.ts b/examples/with-bocha-search/src/bocha.ts new file mode 100644 index 000000000..668a8181b --- /dev/null +++ b/examples/with-bocha-search/src/bocha.ts @@ -0,0 +1,98 @@ +const DEFAULT_RESULT_COUNT = 5; +const MAX_RESULT_COUNT = 10; + +export type BochaSearchInput = { + query: string; + count?: number; + freshness?: string; + includeDomains?: string[]; + excludeDomains?: string[]; +}; + +export type BochaSearchResult = { + title: string; + link: string; + snippet: string; + source: string; + publishedDate: string | null; +}; + +type BochaWebPage = { + name?: unknown; + title?: unknown; + url?: unknown; + displayUrl?: unknown; + summary?: unknown; + snippet?: unknown; + description?: unknown; + siteName?: unknown; + datePublished?: unknown; + dateLastCrawled?: unknown; +}; + +export type BochaSearchResponse = { + data?: { + webPages?: { + value?: BochaWebPage[]; + }; + }; + webPages?: { + value?: BochaWebPage[]; + }; +}; + +export type BochaSearchRequest = { + query: string; + freshness: string; + summary: boolean; + count: number; + include?: string; + exclude?: string; +}; + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function domainsToPipeList(domains: string[] | undefined): string | undefined { + const cleaned = domains?.map((domain) => domain.trim()).filter(Boolean); + return cleaned && cleaned.length > 0 ? cleaned.join("|") : undefined; +} + +export function buildBochaSearchRequest(input: BochaSearchInput): BochaSearchRequest { + const count = Math.min(input.count ?? DEFAULT_RESULT_COUNT, MAX_RESULT_COUNT); + const request: BochaSearchRequest = { + query: input.query, + freshness: input.freshness ?? "noLimit", + summary: true, + count, + }; + + const include = domainsToPipeList(input.includeDomains); + const exclude = domainsToPipeList(input.excludeDomains); + + if (include) { + request.include = include; + } + if (exclude) { + request.exclude = exclude; + } + + return request; +} + +export function mapBochaSearchResponse(response: BochaSearchResponse): BochaSearchResult[] { + const pages = response.data?.webPages?.value ?? response.webPages?.value ?? []; + + return pages.map((page) => ({ + title: stringValue(page.name) ?? stringValue(page.title) ?? "Untitled result", + link: stringValue(page.url) ?? stringValue(page.displayUrl) ?? "", + snippet: + stringValue(page.summary) ?? + stringValue(page.snippet) ?? + stringValue(page.description) ?? + "No snippet available.", + source: stringValue(page.siteName) ?? "Bocha Web Search", + publishedDate: stringValue(page.datePublished) ?? stringValue(page.dateLastCrawled) ?? null, + })); +} diff --git a/examples/with-bocha-search/src/index.ts b/examples/with-bocha-search/src/index.ts new file mode 100644 index 000000000..cec368d87 --- /dev/null +++ b/examples/with-bocha-search/src/index.ts @@ -0,0 +1,34 @@ +import { Agent, Memory, VoltAgent } from "@voltagent/core"; +import { LibSQLMemoryAdapter } from "@voltagent/libsql"; +import { createPinoLogger } from "@voltagent/logger"; +import { honoServer } from "@voltagent/server-hono"; +import { bochaSearchTool } from "./tools"; + +const logger = createPinoLogger({ + name: "bocha-search-agent", + level: "info", +}); + +const memory = new Memory({ + storage: new LibSQLMemoryAdapter(), +}); + +const searchAgent = new Agent({ + name: "Bocha Web Search Agent", + instructions: `You are a web search agent powered by Bocha Web Search. + +Use the Bocha search tool when users ask for current information, source-linked web results, recent news, or fact verification. +Summarize findings clearly and include source links from the tool results. +If Bocha returns no relevant results, say so and suggest a more specific query.`, + model: "openai/gpt-4o-mini", + tools: [bochaSearchTool], + memory, +}); + +new VoltAgent({ + agents: { + searchAgent, + }, + logger, + server: honoServer(), +}); diff --git a/examples/with-bocha-search/src/tools.spec.ts b/examples/with-bocha-search/src/tools.spec.ts new file mode 100644 index 000000000..784ecd454 --- /dev/null +++ b/examples/with-bocha-search/src/tools.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { buildBochaSearchRequest, mapBochaSearchResponse } from "./bocha"; + +describe("Bocha search tool helpers", () => { + it("builds a conservative Bocha search request", () => { + expect( + buildBochaSearchRequest({ + query: "latest agent framework news", + count: 20, + freshness: "oneWeek", + includeDomains: ["github.com", "voltagent.dev"], + excludeDomains: ["example.com"], + }), + ).toEqual({ + query: "latest agent framework news", + freshness: "oneWeek", + summary: true, + count: 10, + include: "github.com|voltagent.dev", + exclude: "example.com", + }); + }); + + it("maps Bocha web page results into source-linked search results", () => { + const mapped = mapBochaSearchResponse({ + data: { + webPages: { + value: [ + { + name: "VoltAgent", + url: "https://voltagent.dev/", + summary: "Open-source TypeScript agent framework.", + siteName: "VoltAgent", + datePublished: "2026-06-01", + }, + { + title: "Fallback title field", + displayUrl: "https://example.com/result", + snippet: "Snippet fallback.", + }, + ], + }, + }, + }); + + expect(mapped).toEqual([ + { + title: "VoltAgent", + link: "https://voltagent.dev/", + snippet: "Open-source TypeScript agent framework.", + source: "VoltAgent", + publishedDate: "2026-06-01", + }, + { + title: "Fallback title field", + link: "https://example.com/result", + snippet: "Snippet fallback.", + source: "Bocha Web Search", + publishedDate: null, + }, + ]); + }); +}); diff --git a/examples/with-bocha-search/src/tools.ts b/examples/with-bocha-search/src/tools.ts new file mode 100644 index 000000000..40051e35c --- /dev/null +++ b/examples/with-bocha-search/src/tools.ts @@ -0,0 +1,128 @@ +import { createTool } from "@voltagent/core"; +import { safeStringify } from "@voltagent/internal"; +import { z } from "zod"; +import { type BochaSearchResponse, buildBochaSearchRequest, mapBochaSearchResponse } from "./bocha"; + +const DEFAULT_BOCHA_SEARCH_API_URL = "https://api.bochaai.com/v1/web-search?utm_source=voltagent"; +const MAX_RESULT_COUNT = 10; + +const bochaSearchResultSchema = z.object({ + title: z.string(), + link: z.string(), + snippet: z.string(), + source: z.string(), + publishedDate: z.string().nullable(), +}); + +const bochaSearchOutputSchema = z.object({ + success: z.boolean(), + results: z.array(bochaSearchResultSchema), + totalResults: z.number(), + query: z.string(), + message: z.string(), + error: z.string().optional(), +}); + +const bochaSearchParametersSchema = z.object({ + query: z.string().describe("Search query for current web information."), + count: z + .number() + .int() + .min(1) + .max(MAX_RESULT_COUNT) + .optional() + .describe("Number of results to return. Defaults to 5, maximum 10."), + freshness: z + .string() + .optional() + .describe("Freshness filter supported by Bocha, such as noLimit or oneWeek."), + includeDomains: z + .array(z.string()) + .optional() + .describe("Specific domains to include, for example ['github.com', 'voltagent.dev']."), + excludeDomains: z.array(z.string()).optional().describe("Domains to exclude from search."), +}); + +export const bochaSearchTool = createTool({ + name: "bochaSearch", + description: + "Search the web for current information using Bocha Web Search. Use this when the user needs up-to-date, source-linked web results or asks to verify facts online.", + parameters: bochaSearchParametersSchema, + outputSchema: bochaSearchOutputSchema, + execute: async (input, options) => { + const apiKey = process.env.BOCHA_SEARCH_API_KEY; + const apiUrl = process.env.BOCHA_SEARCH_API_URL ?? DEFAULT_BOCHA_SEARCH_API_URL; + + if (!apiKey) { + return { + success: false, + results: [], + totalResults: 0, + query: input.query, + error: "Bocha Search API key not configured", + message: + "Bocha Search API key is required. Please set the BOCHA_SEARCH_API_KEY environment variable.", + }; + } + + try { + options?.logger?.info("Searching with Bocha Web Search", { query: input.query }); + + const searchRequest = buildBochaSearchRequest(input); + const response = await fetch(apiUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: safeStringify(searchRequest), + signal: options?.abortSignal, + }); + + if (!response.ok) { + throw new Error(`Bocha API error: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as BochaSearchResponse; + const results = mapBochaSearchResponse(data).slice(0, searchRequest.count); + + if (results.length === 0) { + return { + success: true, + results: [ + { + title: "No Results Found", + link: "", + snippet: `No Bocha web search results found for "${input.query}". Try a different query or broaden the filters.`, + source: "System Notice", + publishedDate: null, + }, + ], + totalResults: 0, + query: input.query, + message: `No Bocha web search results found for "${input.query}".`, + }; + } + + return { + success: true, + results, + totalResults: results.length, + query: input.query, + message: `Found ${results.length} Bocha web search results for "${input.query}".`, + }; + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + options?.logger?.error("Bocha search failed", { error: message }); + + return { + success: false, + results: [], + totalResults: 0, + query: input.query, + error: message, + message: `Bocha search failed: ${message}. Please check your API key, endpoint, or network connection.`, + }; + } + }, +}); diff --git a/examples/with-bocha-search/tsconfig.json b/examples/with-bocha-search/tsconfig.json new file mode 100644 index 000000000..6561a6910 --- /dev/null +++ b/examples/with-bocha-search/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "dist", + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"] +} diff --git a/examples/with-bocha-search/vitest.config.ts b/examples/with-bocha-search/vitest.config.ts new file mode 100644 index 000000000..ebf87fa30 --- /dev/null +++ b/examples/with-bocha-search/vitest.config.ts @@ -0,0 +1,12 @@ +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const root = dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + root, + test: { + include: ["src/**/*.spec.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 591b06e08..2374ea90a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -744,6 +744,43 @@ importers: specifier: ^5.8.2 version: 5.9.2 + examples/with-bocha-search: + dependencies: + '@voltagent/cli': + specifier: ^0.1.21 + version: link:../../packages/cli + '@voltagent/core': + specifier: ^2.7.7 + version: link:../../packages/core + '@voltagent/internal': + specifier: ^1.0.3 + version: link:../../packages/internal + '@voltagent/libsql': + specifier: ^2.1.2 + version: link:../../packages/libsql + '@voltagent/logger': + specifier: ^2.0.2 + version: link:../../packages/logger + '@voltagent/server-hono': + specifier: ^2.0.14 + version: link:../../packages/server-hono + ai: + specifier: ^6.0.0 + version: 6.0.3(zod@3.25.76) + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^24.2.1 + version: 24.6.2 + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.8.2 + version: 5.9.3 + examples/with-cerbos: dependencies: '@cerbos/grpc': From 254c3d90ba3dc436f49044d9c02b7e631ce99f70 Mon Sep 17 00:00:00 2001 From: NiTingKY <2514656615@qq.com> Date: Mon, 15 Jun 2026 13:24:48 +0800 Subject: [PATCH 2/2] Fix Bocha search default endpoint --- examples/with-bocha-search/.env.example | 2 +- examples/with-bocha-search/README.md | 2 +- examples/with-bocha-search/src/tools.spec.ts | 56 +++++++++++++++++++- examples/with-bocha-search/src/tools.ts | 2 +- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/examples/with-bocha-search/.env.example b/examples/with-bocha-search/.env.example index 31f19eed0..9474b968a 100644 --- a/examples/with-bocha-search/.env.example +++ b/examples/with-bocha-search/.env.example @@ -5,4 +5,4 @@ OPENAI_API_KEY=your_openai_api_key_here BOCHA_SEARCH_API_KEY=your_bocha_search_api_key_here # Optional Bocha Search API URL -BOCHA_SEARCH_API_URL=https://api.bochaai.com/v1/web-search?utm_source=voltagent +BOCHA_SEARCH_API_URL=https://api.bochaai.com/v1/web-search diff --git a/examples/with-bocha-search/README.md b/examples/with-bocha-search/README.md index feb366928..f572ba14d 100644 --- a/examples/with-bocha-search/README.md +++ b/examples/with-bocha-search/README.md @@ -34,7 +34,7 @@ Then set your keys: ```env OPENAI_API_KEY=your_actual_openai_api_key BOCHA_SEARCH_API_KEY=your_actual_bocha_search_api_key -BOCHA_SEARCH_API_URL=https://api.bochaai.com/v1/web-search?utm_source=voltagent +BOCHA_SEARCH_API_URL=https://api.bochaai.com/v1/web-search ``` `BOCHA_SEARCH_API_URL` is optional. The example defaults to the URL shown above. diff --git a/examples/with-bocha-search/src/tools.spec.ts b/examples/with-bocha-search/src/tools.spec.ts index 784ecd454..b159a7a0a 100644 --- a/examples/with-bocha-search/src/tools.spec.ts +++ b/examples/with-bocha-search/src/tools.spec.ts @@ -1,5 +1,25 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { buildBochaSearchRequest, mapBochaSearchResponse } from "./bocha"; +import { bochaSearchTool } from "./tools"; + +const originalApiKey = process.env.BOCHA_SEARCH_API_KEY; +const originalApiUrl = process.env.BOCHA_SEARCH_API_URL; + +afterEach(() => { + if (originalApiKey === undefined) { + Reflect.deleteProperty(process.env, "BOCHA_SEARCH_API_KEY"); + } else { + process.env.BOCHA_SEARCH_API_KEY = originalApiKey; + } + + if (originalApiUrl === undefined) { + Reflect.deleteProperty(process.env, "BOCHA_SEARCH_API_URL"); + } else { + process.env.BOCHA_SEARCH_API_URL = originalApiUrl; + } + + vi.restoreAllMocks(); +}); describe("Bocha search tool helpers", () => { it("builds a conservative Bocha search request", () => { @@ -60,4 +80,38 @@ describe("Bocha search tool helpers", () => { }, ]); }); + + it("uses the Bocha web search endpoint by default", async () => { + process.env.BOCHA_SEARCH_API_KEY = "test-key"; + Reflect.deleteProperty(process.env, "BOCHA_SEARCH_API_URL"); + + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + JSON.stringify({ + data: { + webPages: { + value: [ + { + name: "Bocha result", + url: "https://example.com/result", + summary: "Result summary.", + }, + ], + }, + }, + }), + { status: 200 }, + ), + ); + + expect(bochaSearchTool.execute).toBeDefined(); + await bochaSearchTool.execute?.({ query: "agent search", count: 1 }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://api.bochaai.com/v1/web-search", + expect.objectContaining({ + method: "POST", + }), + ); + }); }); diff --git a/examples/with-bocha-search/src/tools.ts b/examples/with-bocha-search/src/tools.ts index 40051e35c..6c146a7bf 100644 --- a/examples/with-bocha-search/src/tools.ts +++ b/examples/with-bocha-search/src/tools.ts @@ -3,7 +3,7 @@ import { safeStringify } from "@voltagent/internal"; import { z } from "zod"; import { type BochaSearchResponse, buildBochaSearchRequest, mapBochaSearchResponse } from "./bocha"; -const DEFAULT_BOCHA_SEARCH_API_URL = "https://api.bochaai.com/v1/web-search?utm_source=voltagent"; +const DEFAULT_BOCHA_SEARCH_API_URL = "https://api.bochaai.com/v1/web-search"; const MAX_RESULT_COUNT = 10; const bochaSearchResultSchema = z.object({