special handling for summarized thinking

This commit is contained in:
tanzhenxin
2025-12-22 14:07:23 +08:00
parent fefc138485
commit 87d8d82be7
12 changed files with 187 additions and 42 deletions

View File

@@ -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';

View File

@@ -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:
// If the thought has a subject, it's a discrete status update rather than
// a streamed textual thought, so we update the thought state directly.
if (event.value.subject) {
setThought(event.value);
} else {
thoughtBuffer = handleThoughtEvent( thoughtBuffer = handleThoughtEvent(
event.value, event.value,
thoughtBuffer, thoughtBuffer,
userMessageTimestamp, 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,
], ],
); );

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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' }],
}, },
]); ]);
}); });

View File

@@ -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,7 +454,8 @@ 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
.filter(
(part) => (part) =>
!( !(
part && part &&
@@ -456,7 +463,20 @@ export class GeminiChat {
'thought' in part && 'thought' in part &&
part.thought part.thought
), ),
); )
.map((part) => {
if (
part &&
typeof part === 'object' &&
'thoughtSignature' in part
) {
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.
if (!this.config.getContentGenerator().useSummarizedThinking()) {
thoughtText = allModelParts
.filter((part) => part.thought)
.map((part) => part.text) .map((part) => part.text)
.join('') .join('')
.trim(); .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);
} }
} }

View File

@@ -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;
}
} }

View File

@@ -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[]

View File

@@ -154,4 +154,8 @@ export class OpenAIContentGenerator implements ContentGenerator {
); );
} }
} }
useSummarizedThinking(): boolean {
return false;
}
} }

View File

@@ -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;
} }

View File

@@ -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,
); );

View File

@@ -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