mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-22 09:47:47 +00:00
special handling for summarized thinking
This commit is contained in:
@@ -4,12 +4,8 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Config ,
|
import type { Config, AuthType } from '@qwen-code/qwen-code-core';
|
||||||
AuthType} from '@qwen-code/qwen-code-core';
|
import { InputFormat, logUserPrompt } from '@qwen-code/qwen-code-core';
|
||||||
import {
|
|
||||||
InputFormat,
|
|
||||||
logUserPrompt,
|
|
||||||
} from '@qwen-code/qwen-code-core';
|
|
||||||
import { render } from 'ink';
|
import { render } from 'ink';
|
||||||
import dns from 'node:dns';
|
import dns from 'node:dns';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
|
|||||||
@@ -769,11 +769,17 @@ export const useGeminiStream = (
|
|||||||
for await (const event of stream) {
|
for await (const event of stream) {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case ServerGeminiEventType.Thought:
|
case ServerGeminiEventType.Thought:
|
||||||
thoughtBuffer = handleThoughtEvent(
|
// If the thought has a subject, it's a discrete status update rather than
|
||||||
event.value,
|
// a streamed textual thought, so we update the thought state directly.
|
||||||
thoughtBuffer,
|
if (event.value.subject) {
|
||||||
userMessageTimestamp,
|
setThought(event.value);
|
||||||
);
|
} else {
|
||||||
|
thoughtBuffer = handleThoughtEvent(
|
||||||
|
event.value,
|
||||||
|
thoughtBuffer,
|
||||||
|
userMessageTimestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case ServerGeminiEventType.Content:
|
case ServerGeminiEventType.Content:
|
||||||
geminiMessageBuffer = handleContentEvent(
|
geminiMessageBuffer = handleContentEvent(
|
||||||
@@ -844,6 +850,7 @@ export const useGeminiStream = (
|
|||||||
handleMaxSessionTurnsEvent,
|
handleMaxSessionTurnsEvent,
|
||||||
handleSessionTokenLimitExceededEvent,
|
handleSessionTokenLimitExceededEvent,
|
||||||
handleCitationEvent,
|
handleCitationEvent,
|
||||||
|
setThought,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import { ProxyAgent, setGlobalDispatcher } from 'undici';
|
|||||||
import type {
|
import type {
|
||||||
ContentGenerator,
|
ContentGenerator,
|
||||||
ContentGeneratorConfig,
|
ContentGeneratorConfig,
|
||||||
|
AuthType,
|
||||||
AuthType} from '../core/contentGenerator.js';
|
} from '../core/contentGenerator.js';
|
||||||
import type { FallbackModelHandler } from '../fallback/types.js';
|
import type { FallbackModelHandler } from '../fallback/types.js';
|
||||||
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
|
import type { MCPOAuthConfig } from '../mcp/oauth-provider.js';
|
||||||
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
|
import type { ShellExecutionConfig } from '../services/shellExecutionService.js';
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export interface ContentGenerator {
|
|||||||
countTokens(request: CountTokensParameters): Promise<CountTokensResponse>;
|
countTokens(request: CountTokensParameters): Promise<CountTokensResponse>;
|
||||||
|
|
||||||
embedContent(request: EmbedContentParameters): Promise<EmbedContentResponse>;
|
embedContent(request: EmbedContentParameters): Promise<EmbedContentResponse>;
|
||||||
|
|
||||||
|
useSummarizedThinking(): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AuthType {
|
export enum AuthType {
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ describe('GeminiChat', () => {
|
|||||||
countTokens: vi.fn(),
|
countTokens: vi.fn(),
|
||||||
embedContent: vi.fn(),
|
embedContent: vi.fn(),
|
||||||
batchEmbedContents: vi.fn(),
|
batchEmbedContents: vi.fn(),
|
||||||
|
useSummarizedThinking: vi.fn().mockReturnValue(false),
|
||||||
} as unknown as ContentGenerator;
|
} as unknown as ContentGenerator;
|
||||||
|
|
||||||
mockHandleFallback.mockClear();
|
mockHandleFallback.mockClear();
|
||||||
@@ -718,6 +719,99 @@ describe('GeminiChat', () => {
|
|||||||
1,
|
1,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle summarized thinking by conditionally including thoughts in history', async () => {
|
||||||
|
// Case 1: useSummarizedThinking is true -> thoughts NOT in history
|
||||||
|
vi.mocked(mockContentGenerator.useSummarizedThinking).mockReturnValue(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const stream1 = (async function* () {
|
||||||
|
yield {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ thought: true, text: 'T1' }, { text: 'A1' }],
|
||||||
|
},
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as GenerateContentResponse;
|
||||||
|
})();
|
||||||
|
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||||
|
stream1,
|
||||||
|
);
|
||||||
|
|
||||||
|
const res1 = await chat.sendMessageStream('m1', { message: 'h1' }, 'p1');
|
||||||
|
for await (const _ of res1);
|
||||||
|
|
||||||
|
const history1 = chat.getHistory();
|
||||||
|
expect(history1[1].parts).toEqual([{ text: 'A1' }]);
|
||||||
|
|
||||||
|
// Case 2: useSummarizedThinking is false -> thoughts ARE in history
|
||||||
|
chat.clearHistory();
|
||||||
|
vi.mocked(mockContentGenerator.useSummarizedThinking).mockReturnValue(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const stream2 = (async function* () {
|
||||||
|
yield {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ thought: true, text: 'T2' }, { text: 'A2' }],
|
||||||
|
},
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as GenerateContentResponse;
|
||||||
|
})();
|
||||||
|
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||||
|
stream2,
|
||||||
|
);
|
||||||
|
|
||||||
|
const res2 = await chat.sendMessageStream('m1', { message: 'h1' }, 'p2');
|
||||||
|
for await (const _ of res2);
|
||||||
|
|
||||||
|
const history2 = chat.getHistory();
|
||||||
|
expect(history2[1].parts).toEqual([
|
||||||
|
{ text: 'T2', thought: true },
|
||||||
|
{ text: 'A2' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep parts with thoughtSignature when consolidating history', async () => {
|
||||||
|
const stream = (async function* () {
|
||||||
|
yield {
|
||||||
|
candidates: [
|
||||||
|
{
|
||||||
|
content: {
|
||||||
|
role: 'model',
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
text: 'p1',
|
||||||
|
thoughtSignature: 's1',
|
||||||
|
} as unknown as { text: string; thoughtSignature: string },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
finishReason: 'STOP',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as GenerateContentResponse;
|
||||||
|
})();
|
||||||
|
vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
|
||||||
|
stream,
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await chat.sendMessageStream('m1', { message: 'h1' }, 'p1');
|
||||||
|
for await (const _ of res);
|
||||||
|
|
||||||
|
const history = chat.getHistory();
|
||||||
|
expect(history[1].parts![0]).toEqual({
|
||||||
|
text: 'p1',
|
||||||
|
thoughtSignature: 's1',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('addHistory', () => {
|
describe('addHistory', () => {
|
||||||
@@ -1532,7 +1626,7 @@ describe('GeminiChat', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('stripThoughtsFromHistory', () => {
|
describe('stripThoughtsFromHistory', () => {
|
||||||
it('should strip thought signatures', () => {
|
it('should strip thoughts and thought signatures, and remove empty content objects', () => {
|
||||||
chat.setHistory([
|
chat.setHistory([
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -1544,10 +1638,15 @@ describe('GeminiChat', () => {
|
|||||||
{ text: 'thinking...', thought: true },
|
{ text: 'thinking...', thought: true },
|
||||||
{ text: 'hi' },
|
{ text: 'hi' },
|
||||||
{
|
{
|
||||||
functionCall: { name: 'test', args: {} },
|
text: 'hidden metadata',
|
||||||
},
|
thoughtSignature: 'abc',
|
||||||
|
} as unknown as { text: string; thoughtSignature: string },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
role: 'model',
|
||||||
|
parts: [{ text: 'only thinking', thought: true }],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
chat.stripThoughtsFromHistory();
|
chat.stripThoughtsFromHistory();
|
||||||
@@ -1559,7 +1658,7 @@ describe('GeminiChat', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: 'model',
|
role: 'model',
|
||||||
parts: [{ text: 'hi' }, { functionCall: { name: 'test', args: {} } }],
|
parts: [{ text: 'hi' }, { text: 'hidden metadata' }],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -109,18 +109,24 @@ function isValidContent(content: Content): boolean {
|
|||||||
if (part === undefined || Object.keys(part).length === 0) {
|
if (part === undefined || Object.keys(part).length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (
|
if (!isValidContentPart(part)) {
|
||||||
!part.thought &&
|
|
||||||
part.text !== undefined &&
|
|
||||||
part.text === '' &&
|
|
||||||
part.functionCall === undefined
|
|
||||||
) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidContentPart(part: Part): boolean {
|
||||||
|
const isInvalid =
|
||||||
|
!part.thought &&
|
||||||
|
!part.thoughtSignature &&
|
||||||
|
part.text !== undefined &&
|
||||||
|
part.text === '' &&
|
||||||
|
part.functionCall === undefined;
|
||||||
|
|
||||||
|
return !isInvalid;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the history contains the correct roles.
|
* Validates the history contains the correct roles.
|
||||||
*
|
*
|
||||||
@@ -448,15 +454,29 @@ export class GeminiChat {
|
|||||||
if (!content.parts) return content;
|
if (!content.parts) return content;
|
||||||
|
|
||||||
// Filter out thought parts entirely
|
// Filter out thought parts entirely
|
||||||
const filteredParts = content.parts.filter(
|
const filteredParts = content.parts
|
||||||
(part) =>
|
.filter(
|
||||||
!(
|
(part) =>
|
||||||
|
!(
|
||||||
|
part &&
|
||||||
|
typeof part === 'object' &&
|
||||||
|
'thought' in part &&
|
||||||
|
part.thought
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((part) => {
|
||||||
|
if (
|
||||||
part &&
|
part &&
|
||||||
typeof part === 'object' &&
|
typeof part === 'object' &&
|
||||||
'thought' in part &&
|
'thoughtSignature' in part
|
||||||
part.thought
|
) {
|
||||||
),
|
const newPart = { ...part };
|
||||||
);
|
delete (newPart as { thoughtSignature?: string })
|
||||||
|
.thoughtSignature;
|
||||||
|
return newPart;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...content,
|
...content,
|
||||||
@@ -538,11 +558,15 @@ export class GeminiChat {
|
|||||||
yield chunk; // Yield every chunk to the UI immediately.
|
yield chunk; // Yield every chunk to the UI immediately.
|
||||||
}
|
}
|
||||||
|
|
||||||
const thoughtParts = allModelParts.filter((part) => part.thought);
|
let thoughtText = '';
|
||||||
const thoughtText = thoughtParts
|
// Only include thoughts if not using summarized thinking.
|
||||||
.map((part) => part.text)
|
if (!this.config.getContentGenerator().useSummarizedThinking()) {
|
||||||
.join('')
|
thoughtText = allModelParts
|
||||||
.trim();
|
.filter((part) => part.thought)
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join('')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
const contentParts = allModelParts.filter((part) => !part.thought);
|
const contentParts = allModelParts.filter((part) => !part.thought);
|
||||||
const consolidatedHistoryParts: Part[] = [];
|
const consolidatedHistoryParts: Part[] = [];
|
||||||
@@ -555,7 +579,7 @@ export class GeminiChat {
|
|||||||
isValidNonThoughtTextPart(part)
|
isValidNonThoughtTextPart(part)
|
||||||
) {
|
) {
|
||||||
lastPart.text += part.text;
|
lastPart.text += part.text;
|
||||||
} else {
|
} else if (isValidContentPart(part)) {
|
||||||
consolidatedHistoryParts.push(part);
|
consolidatedHistoryParts.push(part);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,4 +137,8 @@ export class GeminiContentGenerator implements ContentGenerator {
|
|||||||
): Promise<EmbedContentResponse> {
|
): Promise<EmbedContentResponse> {
|
||||||
return this.googleGenAI.models.embedContent(request);
|
return this.googleGenAI.models.embedContent(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useSummarizedThinking(): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,10 @@ export class LoggingContentGenerator implements ContentGenerator {
|
|||||||
return this.wrapped.embedContent(req);
|
return this.wrapped.embedContent(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useSummarizedThinking(): boolean {
|
||||||
|
return this.wrapped.useSummarizedThinking();
|
||||||
|
}
|
||||||
|
|
||||||
private toContents(contents: ContentListUnion): Content[] {
|
private toContents(contents: ContentListUnion): Content[] {
|
||||||
if (Array.isArray(contents)) {
|
if (Array.isArray(contents)) {
|
||||||
// it's a Content[] or a PartsUnion[]
|
// it's a Content[] or a PartsUnion[]
|
||||||
|
|||||||
@@ -154,4 +154,8 @@ export class OpenAIContentGenerator implements ContentGenerator {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useSummarizedThinking(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ import {
|
|||||||
toFriendlyError,
|
toFriendlyError,
|
||||||
} from '../utils/errors.js';
|
} from '../utils/errors.js';
|
||||||
import type { GeminiChat } from './geminiChat.js';
|
import type { GeminiChat } from './geminiChat.js';
|
||||||
import { getThoughtText, type ThoughtSummary } from '../utils/thoughtUtils.js';
|
import {
|
||||||
|
getThoughtText,
|
||||||
|
parseThought,
|
||||||
|
type ThoughtSummary,
|
||||||
|
} from '../utils/thoughtUtils.js';
|
||||||
|
|
||||||
// Define a structure for tools passed to the server
|
// Define a structure for tools passed to the server
|
||||||
export interface ServerTool {
|
export interface ServerTool {
|
||||||
@@ -266,11 +270,11 @@ export class Turn {
|
|||||||
this.currentResponseId = resp.responseId;
|
this.currentResponseId = resp.responseId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const thoughtPart = getThoughtText(resp);
|
const thoughtText = getThoughtText(resp);
|
||||||
if (thoughtPart) {
|
if (thoughtText) {
|
||||||
yield {
|
yield {
|
||||||
type: GeminiEventType.Thought,
|
type: GeminiEventType.Thought,
|
||||||
value: { subject: '', description: thoughtPart },
|
value: parseThought(thoughtText),
|
||||||
};
|
};
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ describe('checkNextSpeaker', () => {
|
|||||||
generateContentStream: vi.fn(),
|
generateContentStream: vi.fn(),
|
||||||
countTokens: vi.fn(),
|
countTokens: vi.fn(),
|
||||||
embedContent: vi.fn(),
|
embedContent: vi.fn(),
|
||||||
|
useSummarizedThinking: vi.fn().mockReturnValue(false),
|
||||||
} as ContentGenerator,
|
} as ContentGenerator,
|
||||||
{} as Config,
|
{} as Config,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function parseThought(rawText: string): ThoughtSummary {
|
|||||||
const startIndex = rawText.indexOf(START_DELIMITER);
|
const startIndex = rawText.indexOf(START_DELIMITER);
|
||||||
if (startIndex === -1) {
|
if (startIndex === -1) {
|
||||||
// No start delimiter found, the whole text is the description.
|
// No start delimiter found, the whole text is the description.
|
||||||
return { subject: '', description: rawText.trim() };
|
return { subject: '', description: rawText };
|
||||||
}
|
}
|
||||||
|
|
||||||
const endIndex = rawText.indexOf(
|
const endIndex = rawText.indexOf(
|
||||||
@@ -39,7 +39,7 @@ export function parseThought(rawText: string): ThoughtSummary {
|
|||||||
if (endIndex === -1) {
|
if (endIndex === -1) {
|
||||||
// Start delimiter found but no end delimiter, so it's not a valid subject.
|
// Start delimiter found but no end delimiter, so it's not a valid subject.
|
||||||
// Treat the entire string as the description.
|
// Treat the entire string as the description.
|
||||||
return { subject: '', description: rawText.trim() };
|
return { subject: '', description: rawText };
|
||||||
}
|
}
|
||||||
|
|
||||||
const subject = rawText
|
const subject = rawText
|
||||||
|
|||||||
Reference in New Issue
Block a user