Merge pull request #610 from QwenLM/feat/skip-loop-detection
Add `skipLoopDetection` Configuration Option
This commit is contained in:
commit
0581344d48
6 changed files with 141 additions and 7 deletions
|
|
@ -311,6 +311,22 @@ In addition to a project settings file, a project's `.qwen` directory can contai
|
||||||
"showLineNumbers": false
|
"showLineNumbers": false
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- **`skipNextSpeakerCheck`** (boolean):
|
||||||
|
- **Description:** Skips the next speaker check after text responses. When enabled, the system bypasses analyzing whether the AI should continue speaking.
|
||||||
|
- **Default:** `false`
|
||||||
|
- **Example:**
|
||||||
|
```json
|
||||||
|
"skipNextSpeakerCheck": true
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`skipLoopDetection`** (boolean):
|
||||||
|
- **Description:** Disables all loop detection checks (streaming and LLM-based). Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions.
|
||||||
|
- **Default:** `false`
|
||||||
|
- **Example:**
|
||||||
|
```json
|
||||||
|
"skipLoopDetection": true
|
||||||
|
```
|
||||||
|
|
||||||
### Example `settings.json`:
|
### Example `settings.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
@ -338,6 +354,8 @@ In addition to a project settings file, a project's `.qwen` directory can contai
|
||||||
"usageStatisticsEnabled": true,
|
"usageStatisticsEnabled": true,
|
||||||
"hideTips": false,
|
"hideTips": false,
|
||||||
"hideBanner": false,
|
"hideBanner": false,
|
||||||
|
"skipNextSpeakerCheck": false,
|
||||||
|
"skipLoopDetection": false,
|
||||||
"maxSessionTurns": 10,
|
"maxSessionTurns": 10,
|
||||||
"summarizeToolOutput": {
|
"summarizeToolOutput": {
|
||||||
"run_shell_command": {
|
"run_shell_command": {
|
||||||
|
|
|
||||||
|
|
@ -604,6 +604,7 @@ export async function loadCliConfig(
|
||||||
trustedFolder,
|
trustedFolder,
|
||||||
shouldUseNodePtyShell: settings.shouldUseNodePtyShell,
|
shouldUseNodePtyShell: settings.shouldUseNodePtyShell,
|
||||||
skipNextSpeakerCheck: settings.skipNextSpeakerCheck,
|
skipNextSpeakerCheck: settings.skipNextSpeakerCheck,
|
||||||
|
skipLoopDetection: settings.skipLoopDetection ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -604,6 +604,15 @@ export const SETTINGS_SCHEMA = {
|
||||||
description: 'Skip the next speaker check.',
|
description: 'Skip the next speaker check.',
|
||||||
showInDialog: true,
|
showInDialog: true,
|
||||||
},
|
},
|
||||||
|
skipLoopDetection: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Skip Loop Detection',
|
||||||
|
category: 'General',
|
||||||
|
requiresRestart: false,
|
||||||
|
default: false,
|
||||||
|
description: 'Disable all loop detection checks (streaming and LLM).',
|
||||||
|
showInDialog: true,
|
||||||
|
},
|
||||||
enableWelcomeBack: {
|
enableWelcomeBack: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
label: 'Enable Welcome Back',
|
label: 'Enable Welcome Back',
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,7 @@ export interface ConfigParameters {
|
||||||
trustedFolder?: boolean;
|
trustedFolder?: boolean;
|
||||||
shouldUseNodePtyShell?: boolean;
|
shouldUseNodePtyShell?: boolean;
|
||||||
skipNextSpeakerCheck?: boolean;
|
skipNextSpeakerCheck?: boolean;
|
||||||
|
skipLoopDetection?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
|
|
@ -318,6 +319,7 @@ export class Config {
|
||||||
private readonly trustedFolder: boolean | undefined;
|
private readonly trustedFolder: boolean | undefined;
|
||||||
private readonly shouldUseNodePtyShell: boolean;
|
private readonly shouldUseNodePtyShell: boolean;
|
||||||
private readonly skipNextSpeakerCheck: boolean;
|
private readonly skipNextSpeakerCheck: boolean;
|
||||||
|
private readonly skipLoopDetection: boolean;
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
|
|
||||||
constructor(params: ConfigParameters) {
|
constructor(params: ConfigParameters) {
|
||||||
|
|
@ -399,6 +401,7 @@ export class Config {
|
||||||
this.trustedFolder = params.trustedFolder;
|
this.trustedFolder = params.trustedFolder;
|
||||||
this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false;
|
this.shouldUseNodePtyShell = params.shouldUseNodePtyShell ?? false;
|
||||||
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? false;
|
this.skipNextSpeakerCheck = params.skipNextSpeakerCheck ?? false;
|
||||||
|
this.skipLoopDetection = params.skipLoopDetection ?? false;
|
||||||
|
|
||||||
// Web search
|
// Web search
|
||||||
this.tavilyApiKey = params.tavilyApiKey;
|
this.tavilyApiKey = params.tavilyApiKey;
|
||||||
|
|
@ -861,6 +864,10 @@ export class Config {
|
||||||
return this.skipNextSpeakerCheck;
|
return this.skipNextSpeakerCheck;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSkipLoopDetection(): boolean {
|
||||||
|
return this.skipLoopDetection;
|
||||||
|
}
|
||||||
|
|
||||||
async getGitService(): Promise<GitService> {
|
async getGitService(): Promise<GitService> {
|
||||||
if (!this.gitService) {
|
if (!this.gitService) {
|
||||||
this.gitService = new GitService(this.targetDir);
|
this.gitService = new GitService(this.targetDir);
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,7 @@ describe('Gemini Client (client.ts)', () => {
|
||||||
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
getCliVersion: vi.fn().mockReturnValue('1.0.0'),
|
||||||
getChatCompression: vi.fn().mockReturnValue(undefined),
|
getChatCompression: vi.fn().mockReturnValue(undefined),
|
||||||
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
|
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
|
||||||
|
getSkipLoopDetection: vi.fn().mockReturnValue(false),
|
||||||
};
|
};
|
||||||
const MockedConfig = vi.mocked(Config, true);
|
const MockedConfig = vi.mocked(Config, true);
|
||||||
MockedConfig.mockImplementation(
|
MockedConfig.mockImplementation(
|
||||||
|
|
@ -1987,6 +1988,100 @@ ${JSON.stringify(
|
||||||
// Assert
|
// Assert
|
||||||
expect(mockCheckNextSpeaker).not.toHaveBeenCalled();
|
expect(mockCheckNextSpeaker).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not run loop checks when skipLoopDetection is true', async () => {
|
||||||
|
// Arrange
|
||||||
|
// Ensure config returns true for skipLoopDetection
|
||||||
|
vi.spyOn(client['config'], 'getSkipLoopDetection').mockReturnValue(true);
|
||||||
|
|
||||||
|
// Replace loop detector with spies
|
||||||
|
const ldMock = {
|
||||||
|
turnStarted: vi.fn().mockResolvedValue(false),
|
||||||
|
addAndCheck: vi.fn().mockReturnValue(false),
|
||||||
|
reset: vi.fn(),
|
||||||
|
};
|
||||||
|
// @ts-expect-error override private for testing
|
||||||
|
client['loopDetector'] = ldMock;
|
||||||
|
|
||||||
|
const mockStream = (async function* () {
|
||||||
|
yield { type: 'content', value: 'Hello' };
|
||||||
|
yield { type: 'content', value: 'World' };
|
||||||
|
})();
|
||||||
|
mockTurnRunFn.mockReturnValue(mockStream);
|
||||||
|
|
||||||
|
const mockChat: Partial<GeminiChat> = {
|
||||||
|
addHistory: vi.fn(),
|
||||||
|
getHistory: vi.fn().mockReturnValue([]),
|
||||||
|
};
|
||||||
|
client['chat'] = mockChat as GeminiChat;
|
||||||
|
|
||||||
|
const mockGenerator: Partial<ContentGenerator> = {
|
||||||
|
countTokens: vi.fn().mockResolvedValue({ totalTokens: 0 }),
|
||||||
|
generateContent: mockGenerateContentFn,
|
||||||
|
};
|
||||||
|
client['contentGenerator'] = mockGenerator as ContentGenerator;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const stream = client.sendMessageStream(
|
||||||
|
[{ text: 'Hi' }],
|
||||||
|
new AbortController().signal,
|
||||||
|
'prompt-id-loop-skip',
|
||||||
|
);
|
||||||
|
for await (const _ of stream) {
|
||||||
|
// consume
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert: methods not called due to skip
|
||||||
|
const ld = client['loopDetector'] as unknown as {
|
||||||
|
turnStarted: ReturnType<typeof vi.fn>;
|
||||||
|
addAndCheck: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
expect(ld.turnStarted).not.toHaveBeenCalled();
|
||||||
|
expect(ld.addAndCheck).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs loop checks when skipLoopDetection is false', async () => {
|
||||||
|
// Arrange
|
||||||
|
vi.spyOn(client['config'], 'getSkipLoopDetection').mockReturnValue(false);
|
||||||
|
|
||||||
|
const turnStarted = vi.fn().mockResolvedValue(false);
|
||||||
|
const addAndCheck = vi.fn().mockReturnValue(false);
|
||||||
|
const reset = vi.fn();
|
||||||
|
// @ts-expect-error override private for testing
|
||||||
|
client['loopDetector'] = { turnStarted, addAndCheck, reset };
|
||||||
|
|
||||||
|
const mockStream = (async function* () {
|
||||||
|
yield { type: 'content', value: 'Hello' };
|
||||||
|
yield { type: 'content', value: 'World' };
|
||||||
|
})();
|
||||||
|
mockTurnRunFn.mockReturnValue(mockStream);
|
||||||
|
|
||||||
|
const mockChat: Partial<GeminiChat> = {
|
||||||
|
addHistory: vi.fn(),
|
||||||
|
getHistory: vi.fn().mockReturnValue([]),
|
||||||
|
};
|
||||||
|
client['chat'] = mockChat as GeminiChat;
|
||||||
|
|
||||||
|
const mockGenerator: Partial<ContentGenerator> = {
|
||||||
|
countTokens: vi.fn().mockResolvedValue({ totalTokens: 0 }),
|
||||||
|
generateContent: mockGenerateContentFn,
|
||||||
|
};
|
||||||
|
client['contentGenerator'] = mockGenerator as ContentGenerator;
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const stream = client.sendMessageStream(
|
||||||
|
[{ text: 'Hi' }],
|
||||||
|
new AbortController().signal,
|
||||||
|
'prompt-id-loop-run',
|
||||||
|
);
|
||||||
|
for await (const _ of stream) {
|
||||||
|
// consume
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(turnStarted).toHaveBeenCalledTimes(1);
|
||||||
|
expect(addAndCheck).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('generateContent', () => {
|
describe('generateContent', () => {
|
||||||
|
|
|
||||||
|
|
@ -551,18 +551,22 @@ export class GeminiClient {
|
||||||
|
|
||||||
const turn = new Turn(this.getChat(), prompt_id);
|
const turn = new Turn(this.getChat(), prompt_id);
|
||||||
|
|
||||||
|
if (!this.config.getSkipLoopDetection()) {
|
||||||
const loopDetected = await this.loopDetector.turnStarted(signal);
|
const loopDetected = await this.loopDetector.turnStarted(signal);
|
||||||
if (loopDetected) {
|
if (loopDetected) {
|
||||||
yield { type: GeminiEventType.LoopDetected };
|
yield { type: GeminiEventType.LoopDetected };
|
||||||
return turn;
|
return turn;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const resultStream = turn.run(request, signal);
|
const resultStream = turn.run(request, signal);
|
||||||
for await (const event of resultStream) {
|
for await (const event of resultStream) {
|
||||||
|
if (!this.config.getSkipLoopDetection()) {
|
||||||
if (this.loopDetector.addAndCheck(event)) {
|
if (this.loopDetector.addAndCheck(event)) {
|
||||||
yield { type: GeminiEventType.LoopDetected };
|
yield { type: GeminiEventType.LoopDetected };
|
||||||
return turn;
|
return turn;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
yield event;
|
yield event;
|
||||||
if (event.type === GeminiEventType.Error) {
|
if (event.type === GeminiEventType.Error) {
|
||||||
return turn;
|
return turn;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue