Compare commits

...

9 Commits

Author SHA1 Message Date
Bryan Morgan 2507940524 fix(core): update tests missed by preflight for benchmark changes
- Add isInteractive mock to loggers.test.ts cfg2 for GeminiClient
- Update config.test.ts shell timeout test for non-interactive default
  (600s) and add interactive case (300s)
- Update prompts.test.ts snapshots for new error recovery section
2026-02-22 10:21:50 -05:00
Bryan Morgan dd683875d8 feat(core): add non-interactive performance defaults
Shell timeout 10min (vs 5min interactive), MAX_TURNS 200 (vs 100
interactive) to give autonomous sessions more room to complete
complex tasks.
2026-02-22 10:09:53 -05:00
Bryan Morgan b5f691577d feat(core): increase context compression preservation
COMPRESSION_PRESERVE_THRESHOLD 0.3→0.4, FUNCTION_RESPONSE_TOKEN_BUDGET
50k→75k to retain more context for incremental tasks.
2026-02-22 10:09:42 -05:00
Bryan Morgan 39c323da21 feat(core): tune loop detection for earlier catch and alternating patterns
TOOL_CALL_LOOP_THRESHOLD 5→4, LLM_CHECK_AFTER_TURNS 30→20, and new
alternating-pattern detection (A→B→A→B) that the consecutive-only
check missed.
2026-02-22 10:09:31 -05:00
Bryan Morgan 7d36c455ab feat(core): add error recovery guidance to non-interactive system prompt
Teaches the agent to analyze errors before retrying, try alternatives
after 2 failures, and avoid fallback loops. Gated behind
!options.interactive.
2026-02-22 10:09:19 -05:00
Bryan Morgan 80b4dde350 feat(core): increase thought signature retry resilience
maxAttempts 2→3, initialDelayMs 500→1000 for "thought signature
chunk" errors to better handle transient API flakes.
2026-02-22 10:09:07 -05:00
Bryan Morgan 6b695e3c64 feat(core): increase sub-agent turn and time limits
DEFAULT_MAX_TURNS 15→30, DEFAULT_MAX_TIME_MINUTES 5→10 to reduce
timeouts on complex multi-step tasks.
2026-02-22 10:06:22 -05:00
Bryan Morgan 8d8841150f fix(core): process all URLs in web_fetch instead of only the first
The tool accepts up to 20 URLs but only processed urls[0] in both
execute and fallback paths. Now iterates all URLs for rate-limit
checks and fetches all in fallback mode.
2026-02-22 10:06:12 -05:00
Bryan Morgan 1be6076082 feat(core): enable retryFetchErrors by default
Transient "fetch failed" network errors are common in non-interactive
contexts; the retry infrastructure exists but was off by default.
2026-02-22 10:05:38 -05:00
17 changed files with 237 additions and 115 deletions
+1 -1
View File
@@ -302,7 +302,7 @@ const SETTINGS_SCHEMA = {
label: 'Retry Fetch Errors',
category: 'General',
requiresRestart: false,
default: false,
default: true,
description:
'Retry on "exception TypeError: fetch failed sending request" errors.',
showInDialog: false,
+2 -2
View File
@@ -43,12 +43,12 @@ export const DEFAULT_QUERY_STRING = 'Get Started!';
/**
* The default maximum number of conversational turns for an agent.
*/
export const DEFAULT_MAX_TURNS = 15;
export const DEFAULT_MAX_TURNS = 30;
/**
* The default maximum execution time for an agent in minutes.
*/
export const DEFAULT_MAX_TIME_MINUTES = 5;
export const DEFAULT_MAX_TIME_MINUTES = 10;
/**
* Represents the validated input parameters passed to an agent upon invocation.
+6 -1
View File
@@ -958,8 +958,13 @@ describe('Server Config (config.ts)', () => {
});
describe('Shell Tool Inactivity Timeout', () => {
it('should default to 300000ms (300 seconds) when not provided', () => {
it('should default to 600000ms (600 seconds) for non-interactive when not provided', () => {
const config = new Config(baseParams);
expect(config.getShellToolInactivityTimeout()).toBe(600000);
});
it('should default to 300000ms (300 seconds) for interactive when not provided', () => {
const config = new Config({ ...baseParams, interactive: true });
expect(config.getShellToolInactivityTimeout()).toBe(300000);
});
+3 -2
View File
@@ -852,8 +852,9 @@ export class Config {
this.continueOnFailedApiCall = params.continueOnFailedApiCall ?? true;
this.enableShellOutputEfficiency =
params.enableShellOutputEfficiency ?? true;
const defaultShellTimeout = this.interactive ? 300 : 600; // 5 min interactive, 10 min non-interactive
this.shellToolInactivityTimeout =
(params.shellToolInactivityTimeout ?? 300) * 1000; // 5 minutes
(params.shellToolInactivityTimeout ?? defaultShellTimeout) * 1000;
this.extensionManagement = params.extensionManagement ?? true;
this.enableExtensionReloading = params.enableExtensionReloading ?? false;
this.storage = new Storage(this.targetDir, this.sessionId);
@@ -877,7 +878,7 @@ export class Config {
this.outputSettings = {
format: params.output?.format ?? OutputFormat.TEXT,
};
this.retryFetchErrors = params.retryFetchErrors ?? false;
this.retryFetchErrors = params.retryFetchErrors ?? true;
this.disableYoloMode = params.disableYoloMode ?? false;
this.rawOutput = params.rawOutput ?? false;
this.acceptRawOutputRisk = params.acceptRawOutputRisk ?? false;
@@ -789,7 +789,14 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
## Interaction Details
- **Help Command:** The user can use '/help' to display help information.
- **Feedback:** To report a bug or provide feedback, please use the /bug command."
- **Feedback:** To report a bug or provide feedback, please use the /bug command.
## Error Recovery (Non-Interactive)
- **Don't blindly retry:** When a tool call fails, analyze the error before retrying. Do not immediately retry with the same arguments.
- **Web fetch failures:** If web_fetch fails, try simplifying the prompt or use google_web_search as an alternative to find the information.
- **Shell failures:** Check error codes and run diagnostic commands before retrying. For compilation errors, fix one issue at a time rather than attempting multiple fixes simultaneously.
- **Maximum retries:** Attempt the same approach at most 2 times. If it fails twice, try an alternative strategy or tool.
- **Avoid loops:** If you find yourself repeating the same sequence of actions, stop and reassess your approach."
`;
exports[`Core System Prompt (prompts.ts) > should handle CodebaseInvestigator with tools=grep_search,glob 1`] = `
@@ -911,7 +918,14 @@ Operate using a **Research -> Strategy -> Execution** lifecycle. For the Executi
## Interaction Details
- **Help Command:** The user can use '/help' to display help information.
- **Feedback:** To report a bug or provide feedback, please use the /bug command."
- **Feedback:** To report a bug or provide feedback, please use the /bug command.
## Error Recovery (Non-Interactive)
- **Don't blindly retry:** When a tool call fails, analyze the error before retrying. Do not immediately retry with the same arguments.
- **Web fetch failures:** If web_fetch fails, try simplifying the prompt or use google_web_search as an alternative to find the information.
- **Shell failures:** Check error codes and run diagnostic commands before retrying. For compilation errors, fix one issue at a time rather than attempting multiple fixes simultaneously.
- **Maximum retries:** Attempt the same approach at most 2 times. If it fails twice, try an alternative strategy or tool.
- **Avoid loops:** If you find yourself repeating the same sequence of actions, stop and reassess your approach."
`;
exports[`Core System Prompt (prompts.ts) > should handle git instructions when isGitRepository=false 1`] = `
+6 -7
View File
@@ -1207,7 +1207,7 @@ ${JSON.stringify(
eventCount++;
// Safety check to prevent actual infinite loop in test
if (eventCount > 200) {
if (eventCount > 400) {
abortController.abort();
throw new Error(
'Test exceeded expected event limit - possible actual infinite loop',
@@ -1219,13 +1219,12 @@ ${JSON.stringify(
expect(finalResult).toBeInstanceOf(Turn);
// If infinite loop protection is working, checkNextSpeaker should be called many times
// but stop at MAX_TURNS (100). Since each recursive call should trigger checkNextSpeaker,
// we expect it to be called multiple times before hitting the limit
// but stop at maxTurns (200 for non-interactive). Since each recursive call should trigger
// checkNextSpeaker, we expect it to be called multiple times before hitting the limit
expect(mockCheckNextSpeaker).toHaveBeenCalled();
// The stream should produce events and eventually terminate
expect(eventCount).toBeGreaterThanOrEqual(1);
expect(eventCount).toBeLessThan(200); // Should not exceed our safety limit
});
it('should yield MaxSessionTurns and stop when session turn limit is reached', async () => {
@@ -1347,9 +1346,9 @@ ${JSON.stringify(
const callCount = mockCheckNextSpeaker.mock.calls.length;
// With the fix: even when turns is set to a very high value,
// the loop should stop at MAX_TURNS (100)
expect(callCount).toBeLessThanOrEqual(100); // Should not exceed MAX_TURNS
expect(eventCount).toBeLessThanOrEqual(200); // Should have reasonable number of events
// the loop should stop at maxTurns (200 for non-interactive)
expect(callCount).toBeLessThanOrEqual(200); // Should not exceed maxTurns
expect(eventCount).toBeLessThanOrEqual(400); // Should have reasonable number of events
});
it('should yield ContextWindowWillOverflow when the context window is about to overflow', async () => {
+8 -3
View File
@@ -65,7 +65,8 @@ import { partToString } from '../utils/partUtils.js';
import { coreEvents, CoreEvent } from '../utils/events.js';
import type { LlmRole } from '../telemetry/types.js';
const MAX_TURNS = 100;
const MAX_TURNS_INTERACTIVE = 100;
const MAX_TURNS_NON_INTERACTIVE = 200;
type BeforeAgentHookReturn =
| {
@@ -86,6 +87,7 @@ export class GeminiClient {
private readonly loopDetector: LoopDetectionService;
private readonly compressionService: ChatCompressionService;
private readonly toolOutputMaskingService: ToolOutputMaskingService;
private readonly maxTurns: number;
private lastPromptId: string;
private currentSequenceModel: string | null = null;
private lastSentIdeContext: IdeContext | undefined;
@@ -102,6 +104,9 @@ export class GeminiClient {
this.compressionService = new ChatCompressionService();
this.toolOutputMaskingService = new ToolOutputMaskingService();
this.lastPromptId = this.config.getSessionId();
this.maxTurns = config.isInteractive()
? MAX_TURNS_INTERACTIVE
: MAX_TURNS_NON_INTERACTIVE;
coreEvents.on(CoreEvent.ModelChanged, this.handleModelChanged);
}
@@ -790,7 +795,7 @@ export class GeminiClient {
request: PartListUnion,
signal: AbortSignal,
prompt_id: string,
turns: number = MAX_TURNS,
turns?: number,
isInvalidStreamRetry: boolean = false,
displayContent?: PartListUnion,
): AsyncGenerator<ServerGeminiStreamEvent, Turn> {
@@ -838,7 +843,7 @@ export class GeminiClient {
}
}
const boundedTurns = Math.min(turns, MAX_TURNS);
const boundedTurns = Math.min(turns ?? this.maxTurns, this.maxTurns);
let turn = new Turn(this.getChat(), prompt_id);
try {
+3 -3
View File
@@ -1315,11 +1315,11 @@ describe('GeminiChat', () => {
}
}).rejects.toThrow(InvalidStreamError);
// Should be called 2 times (initial + 1 retry)
// Should be called 3 times (initial + 2 retries)
expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes(
2,
3,
);
expect(mockLogContentRetry).toHaveBeenCalledTimes(1);
expect(mockLogContentRetry).toHaveBeenCalledTimes(2);
expect(mockLogContentRetryFailure).toHaveBeenCalledTimes(1);
// History should still contain the user message.
+2 -2
View File
@@ -86,8 +86,8 @@ interface ContentRetryOptions {
}
const INVALID_CONTENT_RETRY_OPTIONS: ContentRetryOptions = {
maxAttempts: 2, // 1 initial call + 1 retry
initialDelayMs: 500,
maxAttempts: 3, // 1 initial call + 2 retries
initialDelayMs: 1000,
};
export const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
+11
View File
@@ -333,6 +333,7 @@ export function renderOperationalGuidelines(
## Interaction Details
- **Help Command:** The user can use '/help' to display help information.
- **Feedback:** To report a bug or provide feedback, please use the /bug command.
${!options.interactive ? nonInteractiveErrorRecovery() : ''}
`.trim();
}
@@ -674,6 +675,16 @@ function gitRepoKeepUserInformed(interactive: boolean): string {
: '';
}
function nonInteractiveErrorRecovery(): string {
return `
## Error Recovery (Non-Interactive)
- **Don't blindly retry:** When a tool call fails, analyze the error before retrying. Do not immediately retry with the same arguments.
- **Web fetch failures:** If web_fetch fails, try simplifying the prompt or use google_web_search as an alternative to find the information.
- **Shell failures:** Check error codes and run diagnostic commands before retrying. For compilation errors, fix one issue at a time rather than attempting multiple fixes simultaneously.
- **Maximum retries:** Attempt the same approach at most 2 times. If it fails twice, try an alternative strategy or tool.
- **Avoid loops:** If you find yourself repeating the same sequence of actions, stop and reassess your approach.`;
}
function formatToolName(name: string): string {
return `\`${name}\``;
}
@@ -718,8 +718,9 @@ describe('ChatCompressionService', () => {
it('should use high-fidelity original history for summarization when under the limit, but truncated version for active window', async () => {
// Large response in the "to compress" section (first message)
// 300,000 chars is ~75k tokens, well under the 1,000,000 summarizer limit.
const massiveText = 'a'.repeat(300000);
// 500,000 chars is ~125k tokens, well under the 1,000,000 summarizer limit
// but exceeds COMPRESSION_FUNCTION_RESPONSE_TOKEN_BUDGET (75k).
const massiveText = 'a'.repeat(500000);
const history: Content[] = [
{
role: 'user',
@@ -43,12 +43,12 @@ const DEFAULT_COMPRESSION_TOKEN_THRESHOLD = 0.5;
* The fraction of the latest chat history to keep. A value of 0.3
* means that only the last 30% of the chat history will be kept after compression.
*/
const COMPRESSION_PRESERVE_THRESHOLD = 0.3;
const COMPRESSION_PRESERVE_THRESHOLD = 0.4;
/**
* The budget for function response tokens in the preserved history.
*/
const COMPRESSION_FUNCTION_RESPONSE_TOKEN_BUDGET = 50_000;
const COMPRESSION_FUNCTION_RESPONSE_TOKEN_BUDGET = 75_000;
/**
* Returns the index of the oldest item to keep when compressing. May return
@@ -26,7 +26,7 @@ vi.mock('../telemetry/loggers.js', () => ({
logLlmLoopCheck: vi.fn(),
}));
const TOOL_CALL_LOOP_THRESHOLD = 5;
const TOOL_CALL_LOOP_THRESHOLD = 4;
const CONTENT_LOOP_THRESHOLD = 10;
const CONTENT_CHUNK_SIZE = 50;
@@ -806,15 +806,15 @@ describe('LoopDetectionService LLM Checks', () => {
};
it('should not trigger LLM check before LLM_CHECK_AFTER_TURNS', async () => {
await advanceTurns(29);
await advanceTurns(19);
expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
});
it('should trigger LLM check on the 30th turn', async () => {
it('should trigger LLM check on the 20th turn', async () => {
mockBaseLlmClient.generateJson = vi
.fn()
.mockResolvedValue({ unproductive_state_confidence: 0.1 });
await advanceTurns(30);
await advanceTurns(20);
expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(1);
expect(mockBaseLlmClient.generateJson).toHaveBeenCalledWith(
expect.objectContaining({
@@ -828,12 +828,12 @@ describe('LoopDetectionService LLM Checks', () => {
});
it('should detect a cognitive loop when confidence is high', async () => {
// First check at turn 30
// First check at turn 20
mockBaseLlmClient.generateJson = vi.fn().mockResolvedValue({
unproductive_state_confidence: 0.85,
unproductive_state_analysis: 'Repetitive actions',
});
await advanceTurns(30);
await advanceTurns(20);
expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(1);
expect(mockBaseLlmClient.generateJson).toHaveBeenCalledWith(
expect.objectContaining({
@@ -843,13 +843,13 @@ describe('LoopDetectionService LLM Checks', () => {
// The confidence of 0.85 will result in a low interval.
// The interval will be: 5 + (15 - 5) * (1 - 0.85) = 5 + 10 * 0.15 = 6.5 -> rounded to 7
await advanceTurns(6); // advance to turn 36
await advanceTurns(6); // advance to turn 26
mockBaseLlmClient.generateJson = vi.fn().mockResolvedValue({
unproductive_state_confidence: 0.95,
unproductive_state_analysis: 'Repetitive actions',
});
const finalResult = await service.turnStarted(abortController.signal); // This is turn 37
const finalResult = await service.turnStarted(abortController.signal); // This is turn 27
expect(finalResult).toBe(true);
expect(loggers.logLoopDetected).toHaveBeenCalledWith(
@@ -867,7 +867,7 @@ describe('LoopDetectionService LLM Checks', () => {
unproductive_state_confidence: 0.5,
unproductive_state_analysis: 'Looks okay',
});
await advanceTurns(30);
await advanceTurns(20);
const result = await service.turnStarted(abortController.signal);
expect(result).toBe(false);
expect(loggers.logLoopDetected).not.toHaveBeenCalled();
@@ -878,13 +878,13 @@ describe('LoopDetectionService LLM Checks', () => {
mockBaseLlmClient.generateJson = vi
.fn()
.mockResolvedValue({ unproductive_state_confidence: 0.0 });
await advanceTurns(30); // First check at turn 30
await advanceTurns(20); // First check at turn 20
expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(1);
await advanceTurns(14); // Advance to turn 44
await advanceTurns(14); // Advance to turn 34
expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(1);
await service.turnStarted(abortController.signal); // Turn 45
await service.turnStarted(abortController.signal); // Turn 35
expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(2);
});
@@ -892,7 +892,7 @@ describe('LoopDetectionService LLM Checks', () => {
mockBaseLlmClient.generateJson = vi
.fn()
.mockRejectedValue(new Error('API error'));
await advanceTurns(30);
await advanceTurns(20);
const result = await service.turnStarted(abortController.signal);
expect(result).toBe(false);
expect(loggers.logLoopDetected).not.toHaveBeenCalled();
@@ -901,7 +901,7 @@ describe('LoopDetectionService LLM Checks', () => {
it('should not trigger LLM check when disabled for session', async () => {
service.disableForSession();
expect(loggers.logLoopDetectionDisabled).toHaveBeenCalledTimes(1);
await advanceTurns(30);
await advanceTurns(20);
const result = await service.turnStarted(abortController.signal);
expect(result).toBe(false);
expect(mockBaseLlmClient.generateJson).not.toHaveBeenCalled();
@@ -924,7 +924,7 @@ describe('LoopDetectionService LLM Checks', () => {
.fn()
.mockResolvedValue({ unproductive_state_confidence: 0.1 });
await advanceTurns(30);
await advanceTurns(20);
expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(1);
const calledArg = vi.mocked(mockBaseLlmClient.generateJson).mock
@@ -949,7 +949,7 @@ describe('LoopDetectionService LLM Checks', () => {
unproductive_state_analysis: 'Main says loop',
});
await advanceTurns(30);
await advanceTurns(20);
// It should have called generateJson twice
expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(2);
@@ -989,7 +989,7 @@ describe('LoopDetectionService LLM Checks', () => {
unproductive_state_analysis: 'Main says no loop',
});
await advanceTurns(30);
await advanceTurns(20);
expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(2);
expect(mockBaseLlmClient.generateJson).toHaveBeenNthCalledWith(
@@ -1032,7 +1032,7 @@ describe('LoopDetectionService LLM Checks', () => {
unproductive_state_analysis: 'Flash says loop',
});
await advanceTurns(30);
await advanceTurns(20);
// It should have called generateJson only once
expect(mockBaseLlmClient.generateJson).toHaveBeenCalledTimes(1);
@@ -27,7 +27,7 @@ import {
} from '../utils/messageInspectors.js';
import { debugLogger } from '../utils/debugLogger.js';
const TOOL_CALL_LOOP_THRESHOLD = 5;
const TOOL_CALL_LOOP_THRESHOLD = 4;
const CONTENT_LOOP_THRESHOLD = 10;
const CONTENT_CHUNK_SIZE = 50;
const MAX_HISTORY_LENGTH = 5000;
@@ -40,7 +40,7 @@ const LLM_LOOP_CHECK_HISTORY_COUNT = 20;
/**
* The number of turns that must pass in a single prompt before the LLM-based loop check is activated.
*/
const LLM_CHECK_AFTER_TURNS = 30;
const LLM_CHECK_AFTER_TURNS = 20;
/**
* The default interval, in number of turns, at which the LLM-based loop check is performed.
@@ -105,6 +105,7 @@ export class LoopDetectionService {
// Tool call tracking
private lastToolCallKey: string | null = null;
private toolCallRepetitionCount: number = 0;
private recentToolCallKeys: string[] = [];
// Content streaming tracking
private streamContentHistory = '';
@@ -217,6 +218,53 @@ export class LoopDetectionService {
);
return true;
}
// Alternating pattern detection: track last 12 tool calls and detect
// when a pattern of 2-3 distinct calls repeats 3+ times.
this.recentToolCallKeys.push(key);
if (this.recentToolCallKeys.length > 12) {
this.recentToolCallKeys.shift();
}
if (this.detectAlternatingPattern()) {
logLoopDetected(
this.config,
new LoopDetectedEvent(
LoopType.CONSECUTIVE_IDENTICAL_TOOL_CALLS,
this.promptId,
),
);
return true;
}
return false;
}
/**
* Detects alternating patterns like A->B->A->B->A->B or A->B->C->A->B->C.
* Checks if a pattern of length 2 or 3 repeats at least 3 times at the
* end of the recent tool call history.
*/
private detectAlternatingPattern(): boolean {
const keys = this.recentToolCallKeys;
// Check patterns of length 2 and 3
for (const patternLen of [2, 3]) {
const minRequired = patternLen * 3; // Need at least 3 repetitions
if (keys.length < minRequired) continue;
const pattern = keys.slice(keys.length - patternLen);
let repetitions = 1;
for (let i = keys.length - patternLen * 2; i >= 0; i -= patternLen) {
const segment = keys.slice(i, i + patternLen);
if (segment.every((k, idx) => k === pattern[idx])) {
repetitions++;
} else {
break;
}
}
if (repetitions >= 3) {
return true;
}
}
return false;
}
@@ -613,6 +661,7 @@ export class LoopDetectionService {
private resetToolCallCount(): void {
this.lastToolCallKey = null;
this.toolCallRepetitionCount = 0;
this.recentToolCallKeys = [];
}
private resetContentTracking(resetHistory = true): void {
@@ -1106,6 +1106,7 @@ describe('loggers', () => {
new ToolRegistry(cfg1, {} as unknown as MessageBus),
getUserMemory: () => 'user-memory',
isInteractive: () => false,
} as unknown as Config;
const mockGeminiClient = new GeminiClient(cfg2);
+3 -1
View File
@@ -141,7 +141,7 @@ describe('WebFetchTool', () => {
setApprovalMode: vi.fn(),
getProxy: vi.fn(),
getGeminiClient: mockGetGeminiClient,
getRetryFetchErrors: vi.fn().mockReturnValue(false),
getRetryFetchErrors: vi.fn().mockReturnValue(true),
modelConfigService: {
getResolvedConfig: vi.fn().mockImplementation(({ model }) => ({
model,
@@ -208,6 +208,8 @@ describe('WebFetchTool', () => {
vi.spyOn(fetchUtils, 'fetchWithTimeout').mockRejectedValue(
new Error('fetch failed'),
);
// Disable retries so test doesn't timeout waiting for backoff
vi.mocked(mockConfig.getRetryFetchErrors).mockReturnValue(false);
const tool = new WebFetchTool(mockConfig, bus);
const params = { prompt: 'fetch https://private.ip' };
const invocation = tool.build(params);
+101 -67
View File
@@ -31,8 +31,8 @@ import { WEB_FETCH_DEFINITION } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { LRUCache } from 'mnemonist';
const URL_FETCH_TIMEOUT_MS = 10000;
const MAX_CONTENT_LENGTH = 100000;
const URL_FETCH_TIMEOUT_MS = 30000;
const MAX_CONTENT_LENGTH = 200000;
// Rate limiting configuration
const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
@@ -156,67 +156,97 @@ class WebFetchToolInvocation extends BaseToolInvocation<
super(params, messageBus, _toolName, _toolDisplayName);
}
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];
private async executeFallbackForUrl(
url: string,
_signal: AbortSignal,
): Promise<{ content: string; error?: string }> {
// Convert GitHub blob URL to raw URL
if (url.includes('github.com') && url.includes('/blob/')) {
url = url
let fetchUrl = url;
if (fetchUrl.includes('github.com') && fetchUrl.includes('/blob/')) {
fetchUrl = fetchUrl
.replace('github.com', 'raw.githubusercontent.com')
.replace('/blob/', '/');
}
const response = await retryWithBackoff(
async () => {
const res = await fetchWithTimeout(fetchUrl, URL_FETCH_TIMEOUT_MS);
if (!res.ok) {
const error = new Error(
`Request failed with status code ${res.status} ${res.statusText}`,
);
(error as ErrorWithStatus).status = res.status;
throw error;
}
return res;
},
{
retryFetchErrors: this.config.getRetryFetchErrors(),
},
);
const rawContent = await response.text();
const contentType = response.headers.get('content-type') || '';
let textContent: string;
// Only use html-to-text if content type is HTML, or if no content type is provided (assume HTML)
if (contentType.toLowerCase().includes('text/html') || contentType === '') {
textContent = convert(rawContent, {
wordwrap: false,
selectors: [
{ selector: 'a', options: { ignoreHref: true } },
{ selector: 'img', format: 'skip' },
],
});
} else {
// For other content types (text/plain, application/json, etc.), use raw text
textContent = rawContent;
}
// Per-URL content budget is the total budget divided by number of URLs
textContent = textContent.substring(0, MAX_CONTENT_LENGTH);
return { content: textContent };
}
private async executeFallback(signal: AbortSignal): Promise<ToolResult> {
const { validUrls: urls } = parsePrompt(this.params.prompt);
try {
const response = await retryWithBackoff(
async () => {
const res = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS);
if (!res.ok) {
const error = new Error(
`Request failed with status code ${res.status} ${res.statusText}`,
);
(error as ErrorWithStatus).status = res.status;
throw error;
}
return res;
},
{
retryFetchErrors: this.config.getRetryFetchErrors(),
},
);
const allContent: string[] = [];
const fetchedUrls: string[] = [];
const errors: string[] = [];
const rawContent = await response.text();
const contentType = response.headers.get('content-type') || '';
let textContent: string;
// Only use html-to-text if content type is HTML, or if no content type is provided (assume HTML)
if (
contentType.toLowerCase().includes('text/html') ||
contentType === ''
) {
textContent = convert(rawContent, {
wordwrap: false,
selectors: [
{ selector: 'a', options: { ignoreHref: true } },
{ selector: 'img', format: 'skip' },
],
});
} else {
// For other content types (text/plain, application/json, etc.), use raw text
textContent = rawContent;
for (const url of urls) {
try {
const result = await this.executeFallbackForUrl(url, signal);
allContent.push(`--- Content from ${url} ---\n${result.content}`);
fetchedUrls.push(url);
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const error = e as Error;
errors.push(`Error fetching ${url}: ${error.message}`);
}
}
textContent = textContent.substring(0, MAX_CONTENT_LENGTH);
if (allContent.length === 0) {
const errorMessage = `Error during fallback fetch: ${errors.join('; ')}`;
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.WEB_FETCH_FALLBACK_FAILED,
},
};
}
const combinedContent = allContent.join('\n\n');
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.
I was unable to access the URL(s) directly. Instead, I have fetched the raw content of the page(s). Please use the following content to answer the request. Do not attempt to access the URLs again.
---
${textContent}
---
${combinedContent}
`;
const result = await geminiClient.generateContent(
{ model: 'web-fetch-fallback' },
@@ -225,14 +255,15 @@ ${textContent}
LlmRole.UTILITY_TOOL,
);
const resultText = getResponseText(result) || '';
const displayUrls = fetchedUrls.join(', ');
return {
llmContent: resultText,
returnDisplay: `Content for ${url} processed using fallback fetch.`,
returnDisplay: `Content for ${displayUrls} processed using fallback fetch.`,
};
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const error = e as Error;
const errorMessage = `Error during fallback fetch for ${url}: ${error.message}`;
const errorMessage = `Error during fallback fetch: ${error.message}`;
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
@@ -289,25 +320,28 @@ ${textContent}
async execute(signal: AbortSignal): Promise<ToolResult> {
const userPrompt = this.params.prompt;
const { validUrls: urls } = parsePrompt(userPrompt);
const url = urls[0];
// Enforce rate limiting
const rateLimitResult = checkRateLimit(url);
if (!rateLimitResult.allowed) {
const waitTimeSecs = Math.ceil((rateLimitResult.waitTimeMs || 0) / 1000);
const errorMessage = `Rate limit exceeded for host. Please wait ${waitTimeSecs} seconds before trying again.`;
debugLogger.warn(`[WebFetchTool] Rate limit exceeded for ${url}`);
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.WEB_FETCH_PROCESSING_ERROR,
},
};
// Enforce rate limiting for all URLs
for (const url of urls) {
const rateLimitResult = checkRateLimit(url);
if (!rateLimitResult.allowed) {
const waitTimeSecs = Math.ceil(
(rateLimitResult.waitTimeMs || 0) / 1000,
);
const errorMessage = `Rate limit exceeded for host. Please wait ${waitTimeSecs} seconds before trying again.`;
debugLogger.warn(`[WebFetchTool] Rate limit exceeded for ${url}`);
return {
llmContent: `Error: ${errorMessage}`,
returnDisplay: `Error: ${errorMessage}`,
error: {
message: errorMessage,
type: ToolErrorType.WEB_FETCH_PROCESSING_ERROR,
},
};
}
}
const isPrivate = isPrivateIp(url);
const isPrivate = urls.some((url) => isPrivateIp(url));
if (isPrivate) {
logWebFetchFallbackAttempt(