mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-23 00:01:24 -07:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2507940524 | |||
| dd683875d8 | |||
| b5f691577d | |||
| 39c323da21 | |||
| 7d36c455ab | |||
| 80b4dde350 | |||
| 6b695e3c64 | |||
| 8d8841150f | |||
| 1be6076082 |
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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`] = `
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user