239 lines
7.1 KiB
TypeScript
239 lines
7.1 KiB
TypeScript
// src/network/search-google.ts
|
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
|
// 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<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 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<ISearchResult[]> {
|
|
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<string, unknown> = {
|
|
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<string, string> };
|
|
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.",
|
|
});
|
|
}
|
|
}
|