// src/network/search-google.ts // Copyright (C) 2026 Rob Colbert // Licensed under the Apache License, Version 2.0 import { google } from "googleapis"; import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai"; import { formatError } from "@gadget/ai"; import { GadgetTool } from "../tool.ts"; export interface ISearchResult { title: string; link: string; snippet: string; image?: string; position?: number; displayLink?: string; } export interface ISearchOptions { num?: number; siteSearch?: string; dateRestrict?: string; fileType?: string; safe?: "active" | "off"; sort?: "relevance" | "date"; start?: number; } export class GoogleSearchTool extends GadgetTool { get name(): string { return "search_google"; } get category(): string { return "network"; } public definition: IToolDefinition = { type: "function", function: { name: this.name, description: "Perform a Google search for relevant information on the web.", parameters: { type: "object", properties: { query: { type: "string", description: "The search query string.", }, num_results: { type: "number", description: "Number of search results to return (default: 10, max: 10).", }, siteSearch: { type: "string", description: "Optional site to restrict the search to (e.g. github.com).", }, dateRestrict: { type: "string", description: "Restricts results to a date range. Examples: d1, d7, d30, d365.", }, fileType: { type: "string", description: "Restricts results to files of a specified extension. Examples: pdf, doc, xls, ppt.", }, sort: { type: "string", description: "Sort order for results. Values: relevance or date.", enum: ["relevance", "date"], }, start: { type: "number", description: "The index of the first result to return. Default: 1.", }, }, required: ["query"], }, }, }; public async execute(args: IToolArguments, logger: IAiLogger): Promise { const { query } = args; if (!query || typeof query !== "string" || query.trim().length === 0) { return formatError({ code: "MISSING_PARAMETER", message: "The 'query' parameter is required.", parameter: "query", expected: "A non-empty string containing the search query.", example: 'search_google(query: "latest AI news")', recoveryHint: "Provide a 'query' parameter with your search terms and try again.", }); } logger.debug("performing Google search for agent", { args }); try { const { num_results = 10, siteSearch, dateRestrict, fileType, sort, start, } = args; const results = await this.search(query, { num: Math.min(num_results as number, 10), siteSearch: siteSearch as string | undefined, dateRestrict: dateRestrict as string | undefined, fileType: fileType as string | undefined, safe: "active", sort: sort as "relevance" | "date" | undefined, start: start as number | undefined, }); if (!results.length) { return "No relevant search results found."; } const output: string[] = ["Here are some relevant search results I found:", ""]; for (const result of results) { output.push(`Title: ${result.title || ""}`); output.push(`Link: ${result.link || ""}`); if (result.displayLink) { output.push(`Source: ${result.displayLink}`); } output.push(`Snippet: ${result.snippet || ""}`); output.push(""); } return output.join("\n"); } catch (error) { return this.parseCseError(error); } } async search(query: string, options: ISearchOptions): Promise { const apiKey = this.toolbox.env.services?.google?.cse?.apiKey; const engineId = this.toolbox.env.services?.google?.cse?.engineId; if (!apiKey || !engineId) { throw new Error("Google CSE credentials not configured in environment"); } const customSearch = google.customsearch({ version: "v1", auth: apiKey, }); const params: Record = { q: query, cx: engineId, num: options.num || 10, }; if (options.siteSearch) params.siteSearch = options.siteSearch; if (options.dateRestrict) params.dateRestrict = options.dateRestrict; if (options.fileType) params.fileType = options.fileType; if (options.safe) params.safe = options.safe; if (options.sort) params.sort = options.sort; if (options.start) params.start = options.start; const response = await customSearch.cse.list(params); const results: ISearchResult[] = []; if (response.data.items) { response.data.items.forEach((item, index) => { const result: ISearchResult = { title: item.title || "", link: item.link || "", snippet: item.snippet || "", position: index + 1, displayLink: item.displayLink || "", }; const thumbnail = item.pagemap?.["cse_thumbnail"]?.[0]?.src; if (typeof thumbnail === "string") { result.image = thumbnail; } results.push(result); }); } return results; } private parseCseError(error: unknown): string { const err = error as { response?: { data?: { error?: { message?: string } }; status?: number; headers?: Record }; code?: string; message?: string; }; if (err.response?.data?.error) { const apiError = err.response.data.error; const statusCode = err.response.status; switch (statusCode) { case 401: return formatError({ code: "UNAUTHORIZED", message: `401 Unauthorized - Invalid API key. ${apiError.message || ""}`, }); case 403: return formatError({ code: "FORBIDDEN", message: `403 Forbidden - ${apiError.message || "Access denied"}. Check your Engine ID and API key permissions.`, }); case 429: return formatError({ code: "RATE_LIMIT_EXCEEDED", message: `429 Too Many Requests - rate limit exceeded. ${apiError.message || ""}`, }); default: return formatError({ code: "TOOL_EXECUTION_FAILED", message: `HTTP ${statusCode ?? "unknown"} - ${apiError.message || "Unknown error"}`, }); } } if (err.code === "ENOTFOUND") { return formatError({ code: "NETWORK_ERROR", message: "Network error - unable to reach Google API.", }); } return formatError({ code: "OPERATION_FAILED", message: `Failed to perform search: ${err.message || String(error)}`, recoveryHint: "Please try again or check your search query.", }); } }