gadget/packages/ai/src/tools/search/google.ts
Rob Colbert f8dbb2e08a agent tool and toolbox
- created AiTool and AiToolbox for representing tools in the API
- add googleapis dependency
- integrate Google Search tool as first agent tool
- created IAiEnvironment to communicate AI environment vars around the
platform
2026-05-06 22:58:03 -04:00

274 lines
7.8 KiB
TypeScript

// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import { IAiLogger } from "../../api.ts";
import { formatError } from "../tool-error.ts";
import { AiTool, IToolArguments, IToolDefinition } from "../tool.ts";
import { google } from "googleapis";
// import SearchService, { SearchServiceError } from "../../services/search.js";
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 AiTool {
get name(): string {
return "search_google";
}
get category(): string {
return "search";
}
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 documents based on a date range. Examples: d1 (last day), d7 (last week), d30 (last month), d365 (last year).",
},
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' (default) or 'date'.",
enum: ["relevance", "date"],
},
start: {
type: "number",
description:
"The index of the first result to return (for pagination). Default: 1.",
},
},
required: ["query"],
},
},
};
public async execute(
args: IToolArguments,
logger: IAiLogger,
): Promise<string> {
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 user", { 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,
});
logger.debug("Google search results", { results });
let content = "";
if (results && results.length) {
content += `Here are some relevant search results I found:\n\n`;
for (const result of results) {
const title = JSON.stringify(result.title || "").slice(1, -1);
const link = JSON.stringify(result.link || "").slice(1, -1);
const snippet = JSON.stringify(result.snippet || "").slice(1, -1);
const displayLink = result.displayLink
? JSON.stringify(result.displayLink).slice(1, -1)
: "";
content += `Title: ${title}\n`;
content += `Link: ${link}\n`;
if (displayLink) {
content += `Source: ${displayLink}\n`;
}
content += `Snippet: ${snippet}\n\n`;
}
} else {
content += "No relevant search results found.";
}
return content;
} catch (error: any) {
// Generic error handling
return formatError({
code: "OPERATION_FAILED",
message: `Failed to perform search: ${error.message}`,
recoveryHint: "Please try again or check your search query.",
});
}
}
async search(
query: string,
options: ISearchOptions,
): Promise<ISearchResult[]> {
const customSearch = google.customsearch({
version: "v1",
auth: this.toolbox.env.google.cse.apiKey,
});
const params: any = {
q: query,
cx: this.toolbox.env.google.cse.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: any, index: number) => {
const result: ISearchResult = {
title: item.title || "",
link: item.link || "",
snippet: item.snippet || "",
position: item.position || index + 1,
displayLink: item.displayLink || "",
};
// Extract thumbnail if available
if (item.pagemap?.cse_thumbnail?.[0]?.src) {
result.image = item.pagemap.cse_thumbnail[0].src;
}
results.push(result);
});
}
return results;
}
/**
* Parse Google CSE API errors and provide meaningful messages
*/
private parseCseError(error: any): string {
// Handle Google API error structure
if (error.response?.data?.error) {
const apiError = error.response.data.error;
const statusCode = error.response.status;
switch (statusCode) {
case 401:
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:
const retryAfter = error.response.headers?.["retry-after"];
return formatError({
code: "RATE_LIMIT_EXCEEDED",
message: `429 Too Many Requests - Rate limit exceeded. ${retryAfter ? `Retry after: ${retryAfter} seconds.` : ""} ${apiError.message || ""}`,
});
default:
break;
}
return formatError({
code: "TOOL_EXECUTION_FAILED",
message: `HTTP ${statusCode} - ${apiError.message || "Unknown error"}`,
});
}
// Handle network errors
if (error.code === "ENOTFOUND") {
return formatError({
code: "NETWORK_ERROR",
message: `Network error (code: ${error.code}) - Unable to reach Google API`,
});
}
// Generic error
return formatError({
code: error.code || "UNKNOWN_ERROR",
message: error.message || "An unknown error occurred",
});
}
}