diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index ac1ced0482..170f24e67c 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -1092,19 +1092,21 @@ export class Task { logger.info( `[Task] Adding ${completedTools.length} tool responses to history without generating a new response.`, ); - const responsesToAdd = completedTools.flatMap( - (toolCall) => toolCall.response.responseParts, - ); - - for (const response of responsesToAdd) { - let parts: genAiPart[]; - if (Array.isArray(response)) { - parts = response; - } else if (typeof response === 'string') { - parts = [{ text: response }]; - } else { - parts = [response]; + const parts: genAiPart[] = []; + for (const toolCall of completedTools) { + const response = toolCall.response?.responseParts; + if (!response) { + continue; } + if (Array.isArray(response)) { + parts.push(...response); + } else if (typeof response === 'string') { + parts.push({ text: response }); + } else { + parts.push(response); + } + } + if (parts.length > 0) { // eslint-disable-next-line @typescript-eslint/no-floating-promises this.geminiClient.addHistory({ role: 'user', diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 07fe800f3b..4483585202 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -21,6 +21,7 @@ import { type StreamEvent, stripToolCallIdPrefixes, type HistoryTurn, + coalesceConsecutiveRoles, } from './geminiChat.js'; import { type CompletedToolCall, @@ -3137,4 +3138,62 @@ describe('GeminiChat', () => { expect(stripped[1].parts![0].functionResponse!.id).toBe('call_123'); }); }); + + describe('coalesceConsecutiveRoles', () => { + it('should return empty history if empty array is passed', () => { + expect(coalesceConsecutiveRoles([])).toEqual([]); + }); + + it('should not modify history when roles alternate correctly', () => { + const history: HistoryTurn[] = [ + { id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } }, + { id: '2', content: { role: 'model', parts: [{ text: 'hi' }] } }, + { + id: '3', + content: { role: 'user', parts: [{ text: 'how are you?' }] }, + }, + ]; + expect(coalesceConsecutiveRoles(history)).toEqual(history); + }); + + it('should coalesce consecutive user turns', () => { + const history: HistoryTurn[] = [ + { id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } }, + { id: '2', content: { role: 'user', parts: [{ text: 'world' }] } }, + ]; + expect(coalesceConsecutiveRoles(history)).toEqual([ + { + id: '1', + content: { + role: 'user', + parts: [{ text: 'hello' }, { text: 'world' }], + }, + }, + ]); + }); + + it('should handle undefined or missing parts gracefully', () => { + const history: HistoryTurn[] = [ + { id: '1', content: { role: 'user' } }, + { id: '2', content: { role: 'user', parts: [{ text: 'world' }] } }, + ]; + expect(coalesceConsecutiveRoles(history)).toEqual([ + { + id: '1', + content: { + role: 'user', + parts: [{ text: 'world' }], + }, + }, + ]); + }); + + it('should not coalesce turns if roles are undefined', () => { + const history: HistoryTurn[] = [ + { id: '1', content: { parts: [{ text: 'hello' }] } }, + { id: '2', content: { parts: [{ text: 'world' }] } }, + ]; + expect(coalesceConsecutiveRoles(history)).toEqual(history); + }); + }); }); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index a95188e1f4..5104f42ff5 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -683,10 +683,13 @@ export class GeminiChat { ): Promise> { // Last mile scrubbing to remove internal tracking properties (e.g. callIndex) // before sending to the Gemini API. This whitelists only standard Gemini fields. - const scrubbedHistory = this.context.config.isContextManagementEnabled() + let scrubbedHistory = this.context.config.isContextManagementEnabled() ? scrubHistory([...requestHistory]) : [...requestHistory]; + // Always coalesce consecutive roles to prevent 400 Bad Request errors + scrubbedHistory = coalesceConsecutiveRoles(scrubbedHistory); + const scrubbedContents = scrubbedHistory.map((h) => h.content); const requestContents = apiHistoryOverride @@ -1472,3 +1475,31 @@ export function stripToolCallIdPrefixes(contents: Content[]): Content[] { }), })); } + +export function coalesceConsecutiveRoles( + history: HistoryTurn[], +): HistoryTurn[] { + const result: HistoryTurn[] = []; + for (const turn of history) { + const lastIdx = result.length - 1; + const last = result[lastIdx]; + if (last && last.content.role && last.content.role === turn.content.role) { + const hasParts = last.content.parts || turn.content.parts; + result[lastIdx] = { + id: last.id, + content: { + ...last.content, + parts: hasParts + ? [...(last.content.parts || []), ...(turn.content.parts || [])] + : undefined, + }, + }; + } else { + result.push({ + id: turn.id, + content: { ...turn.content }, + }); + } + } + return result; +}