Files
gemini-cli/packages/core/src/tools/web-fetch.ts

419 lines
12 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
ToolCallConfirmationDetails,
ToolInvocation,
ToolResult,
} from './tools.js';
import {
BaseDeclarativeTool,
BaseToolInvocation,
Kind,
ToolConfirmationOutcome,
} from './tools.js';
import { ToolErrorType } from './tool-error.js';
import { getErrorMessage } from '../utils/errors.js';
import type { Config } from '../config/config.js';
import { ApprovalMode, DEFAULT_GEMINI_FLASH_MODEL } from '../config/config.js';
import { getResponseText } from '../utils/partUtils.js';
import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js';
import { convert } from 'html-to-text';
import { ProxyAgent, setGlobalDispatcher } from 'undici';
import {
logWebFetchFallbackAttempt,
WebFetchFallbackAttemptEvent,
} from '../telemetry/index.js';
const URL_FETCH_TIMEOUT_MS = 10000;
const MAX_CONTENT_LENGTH = 100000;
/**
* Parses a prompt to extract valid URLs and identify malformed ones.
*/
export function parsePrompt(text: string): {
validUrls: string[];
errors: string[];
} {
const tokens = text.split(/\s+/);
const validUrls: string[] = [];
const errors: string[] = [];
for (const token of tokens) {
if (!token) continue;
// Heuristic to check if the url appears to contain URL-like chars.
if (token.includes('://')) {
try {
// Validate with new URL()
const url = new URL(token);
// Allowlist protocols
if (['http:', 'https:'].includes(url.protocol)) {
validUrls.push(url.href);
} else {
errors.push(
`Unsupported protocol in URL: "${token}". Only http and https are supported.`,
);
}
} catch (_) {
// new URL() threw, so it's malformed according to WHATWG standard
errors.push(`Malformed URL detected: "${token}".`);
}
}
}
return { validUrls, errors };
}
// Interfaces for grounding metadata (similar to web-search.ts)
interface GroundingChunkWeb {
uri?: string;
title?: string;
}
interface GroundingChunkItem {
web?: GroundingChunkWeb;
}
interface GroundingSupportSegment {
startIndex: number;
endIndex: number;
text?: string;
}
interface GroundingSupportItem {
segment?: GroundingSupportSegment;
groundingChunkIndices?: number[];
}
/**
* Parameters for the WebFetch tool
*/
export interface WebFetchToolParams {
/**
* The prompt containing URL(s) (up to 20) and instructions for processing their content.
*/
prompt: string;
}
class WebFetchToolInvocation extends BaseToolInvocation<
WebFetchToolParams,
ToolResult
> {
constructor(
private readonly config: Config,
params: WebFetchToolParams,
) {
super(params);
}
private async executeFallback(signal: AbortSignal): Promise<ToolResult> {
const { validUrls: urls } = parsePrompt(this.params.prompt);
// For now, we only support one URL for fallback
let url = urls[0];
// Convert GitHub blob URL to raw URL
if (url.includes('github.com') && url.includes('/blob/')) {
url = url
.replace('github.com', 'raw.githubusercontent.com')
.replace('/blob/', '/');
}
try {
const response = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS);
if (!response.ok) {
throw new Error(
`Request failed with status code ${response.status} ${response.statusText}`,
);
}
const html = await response.text();
const textContent = convert(html, {
wordwrap: false,
selectors: [
{ selector: 'a', options: { ignoreHref: true } },
{ selector: 'img', format: 'skip' },
],
}).substring(0, MAX_CONTENT_LENGTH);
const geminiClient = this.config.getGeminiClient();
const fallbackPrompt = `The user requested the following: "${this.params.prompt}".
I was unable to access the URL directly. Instead, I have fetched the raw content of the page. Please use the following content to answer the request. Do not attempt to access the URL again.
---
${textContent}
---
`;
const result = await geminiClient.generateContent(
[{ role: 'user', parts: [{ text: fallbackPrompt }] }],
{},
signal,
DEFAULT_GEMINI_FLASH_MODEL,
);
const resultText = getResponseText(result) || '';
return {
llmContent: resultText,
returnDisplay: `Content for ${url} processed using fallback fetch.`,
};
} catch (e) {
const error = e as Error;
const errorMessage = `Error during fallback fetch for ${url}: ${error.message}`;
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.WEB_FETCH_FALLBACK_FAILED,
},
};
}
}
getDescription(): string {
const displayPrompt =
this.params.prompt.length > 100
? this.params.prompt.substring(0, 97) + '...'
: this.params.prompt;
return `Processing URLs and instructions from prompt: "${displayPrompt}"`;
}
override async shouldConfirmExecute(): Promise<
ToolCallConfirmationDetails | false
> {
if (this.config.getApprovalMode() === ApprovalMode.AUTO_EDIT) {
return false;
}
// Perform GitHub URL conversion here to differentiate between user-provided
// URL and the actual URL to be fetched.
const { validUrls } = parsePrompt(this.params.prompt);
const urls = validUrls.map((url) => {
if (url.includes('github.com') && url.includes('/blob/')) {
return url
.replace('github.com', 'raw.githubusercontent.com')
.replace('/blob/', '/');
}
return url;
});
const confirmationDetails: ToolCallConfirmationDetails = {
type: 'info',
title: `Confirm Web Fetch`,
prompt: this.params.prompt,
urls,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
}
},
};
return confirmationDetails;
}
async execute(signal: AbortSignal): Promise<ToolResult> {
const userPrompt = this.params.prompt;
const { validUrls: urls } = parsePrompt(userPrompt);
const url = urls[0];
const isPrivate = isPrivateIp(url);
if (isPrivate) {
logWebFetchFallbackAttempt(
this.config,
new WebFetchFallbackAttemptEvent('private_ip'),
);
return this.executeFallback(signal);
}
const geminiClient = this.config.getGeminiClient();
try {
const response = await geminiClient.generateContent(
[{ role: 'user', parts: [{ text: userPrompt }] }],
{ tools: [{ urlContext: {} }] },
signal, // Pass signal
DEFAULT_GEMINI_FLASH_MODEL,
);
console.debug(
`[WebFetchTool] Full response for prompt "${userPrompt.substring(
0,
50,
)}...":`,
JSON.stringify(response, null, 2),
);
let responseText = getResponseText(response) || '';
const urlContextMeta = response.candidates?.[0]?.urlContextMetadata;
const groundingMetadata = response.candidates?.[0]?.groundingMetadata;
const sources = groundingMetadata?.groundingChunks as
| GroundingChunkItem[]
| undefined;
const groundingSupports = groundingMetadata?.groundingSupports as
| GroundingSupportItem[]
| undefined;
// Error Handling
let processingError = false;
if (
urlContextMeta?.urlMetadata &&
urlContextMeta.urlMetadata.length > 0
) {
const allStatuses = urlContextMeta.urlMetadata.map(
(m) => m.urlRetrievalStatus,
);
if (allStatuses.every((s) => s !== 'URL_RETRIEVAL_STATUS_SUCCESS')) {
processingError = true;
}
} else if (!responseText.trim() && !sources?.length) {
// No URL metadata and no content/sources
processingError = true;
}
if (
!processingError &&
!responseText.trim() &&
(!sources || sources.length === 0)
) {
// Successfully retrieved some URL (or no specific error from urlContextMeta), but no usable text or grounding data.
processingError = true;
}
if (processingError) {
logWebFetchFallbackAttempt(
this.config,
new WebFetchFallbackAttemptEvent('primary_failed'),
);
return this.executeFallback(signal);
}
const sourceListFormatted: string[] = [];
if (sources && sources.length > 0) {
sources.forEach((source: GroundingChunkItem, index: number) => {
const title = source.web?.title || 'Untitled';
const uri = source.web?.uri || 'Unknown URI'; // Fallback if URI is missing
sourceListFormatted.push(`[${index + 1}] ${title} (${uri})`);
});
if (groundingSupports && groundingSupports.length > 0) {
const insertions: Array<{ index: number; marker: string }> = [];
groundingSupports.forEach((support: GroundingSupportItem) => {
if (support.segment && support.groundingChunkIndices) {
const citationMarker = support.groundingChunkIndices
.map((chunkIndex: number) => `[${chunkIndex + 1}]`)
.join('');
insertions.push({
index: support.segment.endIndex,
marker: citationMarker,
});
}
});
insertions.sort((a, b) => b.index - a.index);
const responseChars = responseText.split('');
insertions.forEach((insertion) => {
responseChars.splice(insertion.index, 0, insertion.marker);
});
responseText = responseChars.join('');
}
if (sourceListFormatted.length > 0) {
responseText += `
Sources:
${sourceListFormatted.join('\n')}`;
}
}
const llmContent = responseText;
console.debug(
`[WebFetchTool] Formatted tool response for prompt "${userPrompt}:\n\n":`,
llmContent,
);
return {
llmContent,
returnDisplay: `Content processed from prompt.`,
};
} catch (error: unknown) {
const errorMessage = `Error processing web content for prompt "${userPrompt.substring(
0,
50,
)}...": ${getErrorMessage(error)}`;
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.WEB_FETCH_PROCESSING_ERROR,
},
};
}
}
}
/**
* Implementation of the WebFetch tool logic
*/
export class WebFetchTool extends BaseDeclarativeTool<
WebFetchToolParams,
ToolResult
> {
static readonly Name: string = 'web_fetch';
constructor(private readonly config: Config) {
super(
WebFetchTool.Name,
'WebFetch',
"Processes content from URL(s), including local and private network addresses (e.g., localhost), embedded in a prompt. Include up to 20 URLs and instructions (e.g., summarize, extract specific data) directly in the 'prompt' parameter.",
Kind.Fetch,
{
properties: {
prompt: {
description:
'A comprehensive prompt that includes the URL(s) (up to 20) to fetch and specific instructions on how to process their content (e.g., "Summarize https://example.com/article and extract key points from https://another.com/data"). Must contain as least one URL starting with http:// or https://.',
type: 'string',
},
},
required: ['prompt'],
type: 'object',
},
);
const proxy = config.getProxy();
if (proxy) {
setGlobalDispatcher(new ProxyAgent(proxy as string));
}
}
protected override validateToolParamValues(
params: WebFetchToolParams,
): string | null {
if (!params.prompt || params.prompt.trim() === '') {
return "The 'prompt' parameter cannot be empty and must contain URL(s) and instructions.";
}
const { validUrls, errors } = parsePrompt(params.prompt);
if (errors.length > 0) {
return `Error(s) in prompt URLs:\n- ${errors.join('\n- ')}`;
}
if (validUrls.length === 0) {
return "The 'prompt' must contain at least one valid URL (starting with http:// or https://).";
}
return null;
}
protected createInvocation(
params: WebFetchToolParams,
): ToolInvocation<WebFetchToolParams, ToolResult> {
return new WebFetchToolInvocation(this.config, params);
}
}