- 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
274 lines
7.8 KiB
TypeScript
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",
|
|
});
|
|
}
|
|
}
|