mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-10 01:50:20 -07:00
fix(context): Fix snapshot recovery across sessions. (#26939)
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deriveStableId } from './cryptoUtils.js';
|
||||
|
||||
describe('cryptoUtils', () => {
|
||||
describe('deriveStableId', () => {
|
||||
it('should be deterministic regardless of input order', () => {
|
||||
const id1 = deriveStableId(['a', 'b', 'c']);
|
||||
const id2 = deriveStableId(['c', 'b', 'a']);
|
||||
expect(id1).toBe(id2);
|
||||
expect(id1).toMatch(/^[0-9a-f]{32}$/);
|
||||
});
|
||||
|
||||
it('should produce different IDs for different inputs', () => {
|
||||
const id1 = deriveStableId(['a', 'b', 'c']);
|
||||
const id2 = deriveStableId(['a', 'b', 'd']);
|
||||
expect(id1).not.toBe(id2);
|
||||
});
|
||||
|
||||
it('should handle single inputs', () => {
|
||||
const id = deriveStableId(['only-one']);
|
||||
expect(id).toMatch(/^[0-9a-f]{32}$/);
|
||||
});
|
||||
|
||||
it('should be consistent across calls with same data', () => {
|
||||
const input = ['id-123', 'id-456'];
|
||||
expect(deriveStableId(input)).toBe(deriveStableId(input));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* Derives a stable, deterministic ID from a list of source IDs.
|
||||
* Used for synthetic turns like summaries to ensure that re-summarizing the same
|
||||
* content produces a consistent identity.
|
||||
*/
|
||||
export function deriveStableId(sourceIds: string[]): string {
|
||||
const sortedIds = [...sourceIds].sort();
|
||||
return createHash('sha256')
|
||||
.update(sortedIds.join('|'))
|
||||
.digest('hex')
|
||||
.slice(0, 32);
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
import type { Part, Content } from '@google/genai';
|
||||
import type { Config } from '../config/config.js';
|
||||
import { getFolderStructure } from './getFolderStructure.js';
|
||||
import type { HistoryTurn } from '../core/agentChatHistory.js';
|
||||
|
||||
export const INITIAL_HISTORY_LENGTH = 1;
|
||||
|
||||
@@ -81,8 +82,8 @@ ${environmentMemory}
|
||||
|
||||
export async function getInitialChatHistory(
|
||||
config: Config,
|
||||
extraHistory?: Content[],
|
||||
): Promise<Content[]> {
|
||||
extraHistory?: ReadonlyArray<Content | HistoryTurn>,
|
||||
): Promise<Array<Content | HistoryTurn>> {
|
||||
const envParts = await getEnvironmentContext(config);
|
||||
const envContextString = envParts.map((part) => part.text || '').join('\n\n');
|
||||
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
hardenHistory,
|
||||
SYNTHETIC_THOUGHT_SIGNATURE,
|
||||
} from './historyHardening.js';
|
||||
import type { HistoryTurn } from '../core/agentChatHistory.js';
|
||||
import { deriveStableId } from './cryptoUtils.js';
|
||||
import type { Part } from '@google/genai';
|
||||
|
||||
describe('hardenHistory', () => {
|
||||
it('should return an empty array if input is empty', () => {
|
||||
expect(hardenHistory([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('should coalesce adjacent turns of the same role', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{ id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },
|
||||
{ id: '2', content: { role: 'user', parts: [{ text: 'world' }] } },
|
||||
];
|
||||
const hardened = hardenHistory(history);
|
||||
expect(hardened.length).toBe(1);
|
||||
expect(hardened[0].content.parts).toEqual([
|
||||
{ text: 'hello' },
|
||||
{ text: 'world' },
|
||||
]);
|
||||
expect(hardened[0].id).toBe('1'); // Inherits ID of the first turn in the sequence
|
||||
});
|
||||
|
||||
it('should inject thoughtSignature into the first functionCall of a model turn if missing', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{ id: '1', content: { role: 'user', parts: [{ text: 'do it' }] } },
|
||||
{
|
||||
id: '2',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [{ functionCall: { name: 'myTool', args: {} } }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'myTool',
|
||||
response: { ok: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const hardened = hardenHistory(history);
|
||||
const modelPart = hardened[1].content.parts![0];
|
||||
expect(modelPart).toHaveProperty(
|
||||
'thoughtSignature',
|
||||
SYNTHETIC_THOUGHT_SIGNATURE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should inject a sentinel user turn if history ends with a model turn', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{ id: '1', content: { role: 'user', parts: [{ text: 'hello' }] } },
|
||||
{ id: '2', content: { role: 'model', parts: [{ text: 'hi' }] } },
|
||||
];
|
||||
|
||||
const hardened = hardenHistory(history);
|
||||
expect(hardened.length).toBe(3);
|
||||
expect(hardened[2].content.role).toBe('user');
|
||||
expect(hardened[2].content.parts![0]).toEqual({ text: 'Please continue.' });
|
||||
expect(hardened[2].id).toBe(deriveStableId(['2', 'sentinel_end']));
|
||||
});
|
||||
|
||||
it('should inject a sentinel user turn if history starts with a model turn', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{ id: '1', content: { role: 'model', parts: [{ text: 'hi' }] } },
|
||||
{ id: '2', content: { role: 'user', parts: [{ text: 'hello' }] } },
|
||||
];
|
||||
|
||||
const hardened = hardenHistory(history, {
|
||||
sentinels: { continuation: 'Custom start' },
|
||||
});
|
||||
expect(hardened.length).toBe(3);
|
||||
expect(hardened[0].content.role).toBe('user');
|
||||
expect(hardened[0].content.parts![0]).toEqual({ text: 'Custom start' });
|
||||
expect(hardened[0].id).toBe(deriveStableId(['1', 'sentinel_start']));
|
||||
});
|
||||
|
||||
it('should inject sentinel responses for missing functionResponses', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{ id: '1', content: { role: 'user', parts: [{ text: 'do it' }] } },
|
||||
{
|
||||
id: '2',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: 'call_1', name: 'toolA', args: {} },
|
||||
thoughtSignature: 'sig',
|
||||
},
|
||||
{ functionCall: { id: 'call_2', name: 'toolB', args: {} } },
|
||||
],
|
||||
},
|
||||
},
|
||||
// Note: Turn 3 is missing, so toolA and toolB have no responses
|
||||
];
|
||||
|
||||
const hardened = hardenHistory(history, {
|
||||
sentinels: { lostToolResponse: 'Lost.' },
|
||||
});
|
||||
|
||||
// The history should now be: User -> Model -> User (sentinel responses) -> User (sentinel end)
|
||||
// Wait, the sentinel responses turn will satisfy the "ends with user" rule.
|
||||
expect(hardened.length).toBe(3);
|
||||
expect(hardened[2].content.role).toBe('user');
|
||||
expect(hardened[2].content.parts).toHaveLength(2);
|
||||
|
||||
const resp1 = hardened[2].content.parts![0].functionResponse;
|
||||
expect(resp1?.id).toBe('call_1');
|
||||
expect(resp1?.response).toEqual({ error: 'Lost.' });
|
||||
|
||||
const resp2 = hardened[2].content.parts![1].functionResponse;
|
||||
expect(resp2?.id).toBe('call_2');
|
||||
expect(resp2?.response).toEqual({ error: 'Lost.' });
|
||||
|
||||
expect(hardened[2].id).toBe(deriveStableId(['2', 'sentinel_resp']));
|
||||
});
|
||||
|
||||
it('should successfully match parallel tool calls and responses even if responses are originally split across separate user turns', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{ id: '1', content: { role: 'user', parts: [{ text: 'do it' }] } },
|
||||
{
|
||||
id: '2',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: 'call_1', name: 'toolA', args: {} },
|
||||
thoughtSignature: 'sig',
|
||||
},
|
||||
{ functionCall: { id: 'call_2', name: 'toolB', args: {} } },
|
||||
],
|
||||
},
|
||||
},
|
||||
// Responses arrive as separate user turns
|
||||
{
|
||||
id: '3',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call_1',
|
||||
name: 'toolA',
|
||||
response: { ok: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: 'call_2',
|
||||
name: 'toolB',
|
||||
response: { ok: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// The hardener should coalesce Turn 3 and Turn 4 *before* it tries to pair them with Turn 2.
|
||||
// Otherwise, it would look at Turn 3, see 'call_2' is missing, inject a sentinel for 'call_2',
|
||||
// and then look at Turn 4 and consider 'call_2' to be orphaned.
|
||||
const hardened = hardenHistory(history);
|
||||
|
||||
// Total turns: User(1), Model(2), User(3+4 merged)
|
||||
expect(hardened.length).toBe(3);
|
||||
|
||||
const userResponseTurn = hardened[2];
|
||||
expect(userResponseTurn.content.role).toBe('user');
|
||||
expect(userResponseTurn.content.parts).toHaveLength(2);
|
||||
|
||||
// Verify no sentinels were injected and original responses were preserved
|
||||
expect(userResponseTurn.content.parts![0].functionResponse?.id).toBe(
|
||||
'call_1',
|
||||
);
|
||||
expect(userResponseTurn.content.parts![1].functionResponse?.id).toBe(
|
||||
'call_2',
|
||||
);
|
||||
|
||||
// Ensure no error properties exist
|
||||
expect(
|
||||
userResponseTurn.content.parts![0].functionResponse?.response,
|
||||
).toEqual({ ok: true });
|
||||
expect(
|
||||
userResponseTurn.content.parts![1].functionResponse?.response,
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('should synthesize a functionCall for a singleton orphaned functionResponse', () => {
|
||||
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: 'text is kept' },
|
||||
{
|
||||
functionResponse: { id: 'orphan_1', name: 'toolA', response: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const hardened = hardenHistory(history);
|
||||
// Turn 1: user, Turn 2: model (with synthetic call), Turn 3: user
|
||||
expect(hardened.length).toBe(3);
|
||||
|
||||
const modelTurn = hardened[1];
|
||||
expect(modelTurn.content.role).toBe('model');
|
||||
expect(modelTurn.content.parts).toHaveLength(2); // text + synthetic call
|
||||
expect(modelTurn.content.parts![1].functionCall).toBeDefined();
|
||||
expect(modelTurn.content.parts![1].functionCall?.id).toBe('orphan_1');
|
||||
expect(
|
||||
(modelTurn.content.parts![1] as unknown as { thoughtSignature: string })
|
||||
.thoughtSignature,
|
||||
).toBe(SYNTHETIC_THOUGHT_SIGNATURE);
|
||||
|
||||
const userTurn = hardened[2];
|
||||
expect(userTurn.content.parts).toHaveLength(2); // hoisted response + text
|
||||
expect(userTurn.content.parts![0].functionResponse?.id).toBe('orphan_1');
|
||||
expect(userTurn.content.parts![1]).toEqual({ text: 'text is kept' });
|
||||
});
|
||||
|
||||
it('should synthesize functionCalls for multiple orphaned functionResponses in parallel', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{
|
||||
id: '1',
|
||||
content: { role: 'user', parts: [{ text: 'Parallel action' }] },
|
||||
},
|
||||
// Previous model turn exists but has NO tool calls
|
||||
{
|
||||
id: '2',
|
||||
content: { role: 'model', parts: [{ text: 'I will do nothing' }] },
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: { id: 'orphan_A', name: 'toolA', response: {} },
|
||||
},
|
||||
{
|
||||
functionResponse: { id: 'orphan_B', name: 'toolB', response: {} },
|
||||
},
|
||||
{
|
||||
functionResponse: { id: 'orphan_C', name: 'toolC', response: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const hardened = hardenHistory(history);
|
||||
expect(hardened.length).toBe(3);
|
||||
|
||||
const modelTurn = hardened[1];
|
||||
expect(modelTurn.content.role).toBe('model');
|
||||
expect(modelTurn.content.parts).toHaveLength(4); // original text + 3 synthetic calls
|
||||
|
||||
// Only the FIRST function call should get the synthetic signature
|
||||
const callA = modelTurn.content.parts![1];
|
||||
expect(callA.functionCall?.id).toBe('orphan_A');
|
||||
expect(
|
||||
(callA as unknown as { thoughtSignature?: string }).thoughtSignature,
|
||||
).toBe(SYNTHETIC_THOUGHT_SIGNATURE);
|
||||
|
||||
const callB = modelTurn.content.parts![2];
|
||||
expect(callB.functionCall?.id).toBe('orphan_B');
|
||||
expect(
|
||||
(callB as unknown as { thoughtSignature?: string }).thoughtSignature,
|
||||
).toBeUndefined();
|
||||
|
||||
const callC = modelTurn.content.parts![3];
|
||||
expect(callC.functionCall?.id).toBe('orphan_C');
|
||||
expect(
|
||||
(callC as unknown as { thoughtSignature?: string }).thoughtSignature,
|
||||
).toBeUndefined();
|
||||
|
||||
const userTurn = hardened[2];
|
||||
expect(userTurn.content.parts).toHaveLength(3);
|
||||
expect(userTurn.content.parts![0].functionResponse?.id).toBe('orphan_A');
|
||||
expect(userTurn.content.parts![1].functionResponse?.id).toBe('orphan_B');
|
||||
expect(userTurn.content.parts![2].functionResponse?.id).toBe('orphan_C');
|
||||
});
|
||||
|
||||
it('should hoist and re-order tool responses to match functionCall order', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{ id: '1', content: { role: 'user', parts: [{ text: 'do it' }] } },
|
||||
{
|
||||
id: '2',
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: 'c1', name: 'toolA', args: {} },
|
||||
thoughtSignature: 'sig',
|
||||
},
|
||||
{ functionCall: { id: 'c2', name: 'toolB', args: {} } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ text: 'some text' },
|
||||
{ functionResponse: { id: 'c2', name: 'toolB', response: {} } },
|
||||
{ functionResponse: { id: 'c1', name: 'toolA', response: {} } },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const hardened = hardenHistory(history);
|
||||
expect(hardened[2].content.parts).toHaveLength(3);
|
||||
|
||||
// Order should be: resp(c1) -> resp(c2) -> text
|
||||
const p0 = hardened[2].content.parts![0];
|
||||
const p1 = hardened[2].content.parts![1];
|
||||
const p2 = hardened[2].content.parts![2];
|
||||
|
||||
expect(p0.functionResponse?.id).toBe('c1');
|
||||
expect(p1.functionResponse?.id).toBe('c2');
|
||||
expect(p2.text).toBe('some text');
|
||||
});
|
||||
|
||||
it('should scrub non-standard properties from parts', () => {
|
||||
const history: HistoryTurn[] = [
|
||||
{
|
||||
id: '1',
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: 'hello',
|
||||
extraProp: 'should be removed',
|
||||
} as unknown as Part,
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const hardened = hardenHistory(history);
|
||||
expect(hardened[0].content.parts![0]).not.toHaveProperty('extraProp');
|
||||
expect(hardened[0].content.parts![0]).toHaveProperty('text', 'hello');
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,10 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import { type Part } from '@google/genai';
|
||||
import { debugLogger } from './debugLogger.js';
|
||||
import { type HistoryTurn } from '../core/agentChatHistory.js';
|
||||
import { deriveStableId } from './cryptoUtils.js';
|
||||
|
||||
export const SYNTHETIC_THOUGHT_SIGNATURE = 'skip_thought_signature_validator';
|
||||
|
||||
@@ -35,9 +37,9 @@ const DEFAULT_SENTINELS = {
|
||||
* 5. Signatures: The first functionCall in a model turn must have a thoughtSignature.
|
||||
*/
|
||||
export function hardenHistory(
|
||||
history: Content[],
|
||||
history: HistoryTurn[],
|
||||
options: HardeningOptions = {},
|
||||
): Content[] {
|
||||
): HistoryTurn[] {
|
||||
if (history.length === 0) return history;
|
||||
|
||||
const sentinels = { ...DEFAULT_SENTINELS, ...options.sentinels };
|
||||
@@ -63,17 +65,20 @@ export function hardenHistory(
|
||||
/**
|
||||
* Combines adjacent turns with the same role and removes empty turns.
|
||||
*/
|
||||
function coalesce(history: Content[]): Content[] {
|
||||
const result: Content[] = [];
|
||||
function coalesce(history: HistoryTurn[]): HistoryTurn[] {
|
||||
const result: HistoryTurn[] = [];
|
||||
for (const turn of history) {
|
||||
if (!turn.parts || turn.parts.length === 0) continue;
|
||||
if (!turn.content.parts || turn.content.parts.length === 0) continue;
|
||||
|
||||
const last = result[result.length - 1];
|
||||
if (last && last.role === turn.role) {
|
||||
last.parts = [...(last.parts || []), ...(turn.parts || [])];
|
||||
if (last && last.content.role === turn.content.role) {
|
||||
last.content.parts = [
|
||||
...(last.content.parts || []),
|
||||
...(turn.content.parts || []),
|
||||
];
|
||||
} else {
|
||||
// Shallow clone the turn so we don't mutate the original history array structure
|
||||
result.push({ ...turn });
|
||||
// Shallow clone the turn and content so we don't mutate the original history array structure
|
||||
result.push({ id: turn.id, content: { ...turn.content } });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
@@ -83,10 +88,10 @@ function coalesce(history: Content[]): Content[] {
|
||||
* Ensures tool calls have matching responses and model turns have required signatures.
|
||||
*/
|
||||
function pairToolsAndEnforceSignatures(
|
||||
history: Content[],
|
||||
history: HistoryTurn[],
|
||||
sentinels: Required<NonNullable<HardeningOptions['sentinels']>>,
|
||||
): Content[] {
|
||||
const result: Content[] = [];
|
||||
): HistoryTurn[] {
|
||||
const result: HistoryTurn[] = [];
|
||||
|
||||
// We work on a copy to allow splicing in sentinel turns
|
||||
const work = [...history];
|
||||
@@ -94,8 +99,8 @@ function pairToolsAndEnforceSignatures(
|
||||
for (let i = 0; i < work.length; i++) {
|
||||
const turn = work[i];
|
||||
|
||||
if (turn.role === 'model') {
|
||||
const parts = turn.parts || [];
|
||||
if (turn.content.role === 'model') {
|
||||
const parts = turn.content.parts || [];
|
||||
|
||||
// A. Signatures
|
||||
let foundCall = false;
|
||||
@@ -123,8 +128,8 @@ function pairToolsAndEnforceSignatures(
|
||||
const name = call.functionCall!.name || 'unknown';
|
||||
|
||||
const hasResponse =
|
||||
nextTurn?.role === 'user' &&
|
||||
nextTurn.parts?.some(
|
||||
nextTurn?.content.role === 'user' &&
|
||||
nextTurn.content.parts?.some(
|
||||
(p) =>
|
||||
p.functionResponse?.id === id &&
|
||||
p.functionResponse?.name === name,
|
||||
@@ -143,17 +148,20 @@ function pairToolsAndEnforceSignatures(
|
||||
`[HistoryHardener] Detected ${missing.length} tool calls without responses. Injecting sentinel responses.`,
|
||||
);
|
||||
|
||||
let targetUserTurn: Content;
|
||||
if (nextTurn?.role === 'user') {
|
||||
let targetUserTurn: HistoryTurn;
|
||||
if (nextTurn?.content.role === 'user') {
|
||||
targetUserTurn = nextTurn;
|
||||
} else {
|
||||
targetUserTurn = { role: 'user', parts: [] };
|
||||
targetUserTurn = {
|
||||
id: deriveStableId([turn.id, 'sentinel_resp']),
|
||||
content: { role: 'user', parts: [] },
|
||||
};
|
||||
work.splice(i + 1, 0, targetUserTurn);
|
||||
}
|
||||
|
||||
for (const m of missing) {
|
||||
targetUserTurn.parts = targetUserTurn.parts || [];
|
||||
targetUserTurn.parts.push({
|
||||
targetUserTurn.content.parts = targetUserTurn.content.parts || [];
|
||||
targetUserTurn.content.parts.push({
|
||||
functionResponse: {
|
||||
name: m.name,
|
||||
id: m.id,
|
||||
@@ -165,20 +173,21 @@ function pairToolsAndEnforceSignatures(
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (turn.role === 'user') {
|
||||
} else if (turn.content.role === 'user') {
|
||||
// C. Orphaned Responses
|
||||
// A user response MUST follow a model call.
|
||||
const prevTurn = result[result.length - 1];
|
||||
const parts = turn.parts || [];
|
||||
const parts = turn.content.parts || [];
|
||||
const validParts: Part[] = [];
|
||||
const orphanedResponses: Part[] = [];
|
||||
|
||||
for (const p of parts) {
|
||||
if (p.functionResponse) {
|
||||
const id = p.functionResponse.id;
|
||||
const name = p.functionResponse.name;
|
||||
const hasCall =
|
||||
prevTurn?.role === 'model' &&
|
||||
prevTurn.parts?.some(
|
||||
prevTurn?.content.role === 'model' &&
|
||||
prevTurn.content.parts?.some(
|
||||
(cp) =>
|
||||
cp.functionCall?.id === id && cp.functionCall?.name === name,
|
||||
);
|
||||
@@ -187,17 +196,51 @@ function pairToolsAndEnforceSignatures(
|
||||
validParts.push(p);
|
||||
} else {
|
||||
debugLogger.log(
|
||||
`[HistoryHardener] Dropping orphaned functionResponse id='${id}' (name='${name}')`,
|
||||
`[HistoryHardener] Orphaned functionResponse id='${id}' (name='${name}'). Injecting synthetic functionCall.`,
|
||||
);
|
||||
orphanedResponses.push(p);
|
||||
validParts.push(p);
|
||||
}
|
||||
} else {
|
||||
validParts.push(p);
|
||||
}
|
||||
}
|
||||
turn.parts = validParts;
|
||||
|
||||
if (orphanedResponses.length > 0) {
|
||||
let targetModelTurn: HistoryTurn;
|
||||
if (prevTurn?.content.role === 'model') {
|
||||
targetModelTurn = prevTurn;
|
||||
} else {
|
||||
targetModelTurn = {
|
||||
id: deriveStableId([turn.id, 'sentinel_call']),
|
||||
content: { role: 'model', parts: [] },
|
||||
};
|
||||
result.push(targetModelTurn);
|
||||
}
|
||||
|
||||
for (const orph of orphanedResponses) {
|
||||
targetModelTurn.content.parts = targetModelTurn.content.parts || [];
|
||||
const hasExistingCall = targetModelTurn.content.parts.some(
|
||||
(p) => !!p.functionCall,
|
||||
);
|
||||
const callPart: Part = {
|
||||
functionCall: {
|
||||
name: orph.functionResponse!.name,
|
||||
id: orph.functionResponse!.id,
|
||||
args: {},
|
||||
},
|
||||
};
|
||||
if (!hasExistingCall) {
|
||||
callPart.thoughtSignature = SYNTHETIC_THOUGHT_SIGNATURE;
|
||||
}
|
||||
targetModelTurn.content.parts.push(callPart);
|
||||
}
|
||||
}
|
||||
|
||||
turn.content.parts = validParts;
|
||||
}
|
||||
|
||||
if (turn.parts && turn.parts.length > 0) {
|
||||
if (turn.content.parts && turn.content.parts.length > 0) {
|
||||
result.push(turn);
|
||||
}
|
||||
}
|
||||
@@ -208,21 +251,22 @@ function pairToolsAndEnforceSignatures(
|
||||
/**
|
||||
* Hoists and re-orders tool responses within user turns to match preceding model turns.
|
||||
*/
|
||||
function refineToolResponses(history: Content[]): Content[] {
|
||||
function refineToolResponses(history: HistoryTurn[]): HistoryTurn[] {
|
||||
for (let i = 1; i < history.length; i++) {
|
||||
const turn = history[i];
|
||||
const prev = history[i - 1];
|
||||
|
||||
if (turn.role === 'user' && prev.role === 'model') {
|
||||
if (turn.content.role === 'user' && prev.content.role === 'model') {
|
||||
const callOrder =
|
||||
prev.parts
|
||||
prev.content.parts
|
||||
?.filter((p) => !!p.functionCall)
|
||||
.map((p) => p.functionCall!.id) || [];
|
||||
|
||||
if (callOrder.length > 0) {
|
||||
const responseParts =
|
||||
turn.parts?.filter((p) => !!p.functionResponse) || [];
|
||||
const otherParts = turn.parts?.filter((p) => !p.functionResponse) || [];
|
||||
turn.content.parts?.filter((p) => !!p.functionResponse) || [];
|
||||
const otherParts =
|
||||
turn.content.parts?.filter((p) => !p.functionResponse) || [];
|
||||
|
||||
if (responseParts.length > 0) {
|
||||
// 1. Re-order: Sort responses to match the model's call order
|
||||
@@ -240,7 +284,7 @@ function refineToolResponses(history: Content[]): Content[] {
|
||||
});
|
||||
|
||||
// 2. Hoisting: Place all sorted responses BEFORE text or other parts
|
||||
turn.parts = [...responseParts, ...otherParts];
|
||||
turn.content.parts = [...responseParts, ...otherParts];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,36 +296,42 @@ function refineToolResponses(history: Content[]): Content[] {
|
||||
* Final pass to ensure start/end roles and alternation are correct.
|
||||
*/
|
||||
function enforceRoleConstraints(
|
||||
history: Content[],
|
||||
history: HistoryTurn[],
|
||||
sentinels: Required<NonNullable<HardeningOptions['sentinels']>>,
|
||||
): Content[] {
|
||||
): HistoryTurn[] {
|
||||
if (history.length === 0) return [];
|
||||
|
||||
// Re-coalesce first to catch any empty turns or adjacent roles introduced by pairing
|
||||
const base = coalesce(history);
|
||||
if (base.length === 0) return [];
|
||||
|
||||
const result: Content[] = [...base];
|
||||
const result: HistoryTurn[] = [...base];
|
||||
|
||||
// 1. Ensure starts with user
|
||||
if (result[0].role === 'model') {
|
||||
if (result[0].content.role === 'model') {
|
||||
debugLogger.log(
|
||||
'[HistoryHardener] Final history starts with model role. Prepending sentinel user turn.',
|
||||
);
|
||||
result.unshift({
|
||||
role: 'user',
|
||||
parts: [{ text: sentinels.continuation }],
|
||||
id: deriveStableId([result[0].id, 'sentinel_start']),
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: sentinels.continuation }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Ensure ends with user
|
||||
if (result[result.length - 1].role === 'model') {
|
||||
if (result[result.length - 1].content.role === 'model') {
|
||||
debugLogger.log(
|
||||
'[HistoryHardener] Final history ends with model role. Appending sentinel user turn.',
|
||||
);
|
||||
result.push({
|
||||
role: 'user',
|
||||
parts: [{ text: 'Please continue.' }],
|
||||
id: deriveStableId([result[result.length - 1].id, 'sentinel_end']),
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: [{ text: 'Please continue.' }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -293,10 +343,13 @@ function enforceRoleConstraints(
|
||||
* Deep-scrubs the history to remove any non-standard properties from Content and Part objects.
|
||||
* This ensures compatibility with strict APIs (like Vertex AI) that reject unknown fields.
|
||||
*/
|
||||
export function scrubHistory(history: Content[]): Content[] {
|
||||
return history.map((content) => ({
|
||||
role: content.role,
|
||||
parts: (content.parts || []).map(scrubPart),
|
||||
export function scrubHistory(history: HistoryTurn[]): HistoryTurn[] {
|
||||
return history.map((turn) => ({
|
||||
id: turn.id,
|
||||
content: {
|
||||
role: turn.content.role,
|
||||
parts: (turn.content.parts || []).map((p) => scrubPart(p)),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('convertSessionToClientHistory', () => {
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history).toEqual([
|
||||
expect(history.map((h) => h.content)).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{ role: 'model', parts: [{ text: 'Hi there' }] },
|
||||
]);
|
||||
@@ -58,7 +58,7 @@ describe('convertSessionToClientHistory', () => {
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history).toEqual([
|
||||
expect(history.map((h) => h.content)).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{
|
||||
role: 'model',
|
||||
@@ -100,7 +100,7 @@ describe('convertSessionToClientHistory', () => {
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history).toEqual([
|
||||
expect(history.map((h) => h.content)).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'Actual query' }] },
|
||||
]);
|
||||
});
|
||||
@@ -133,7 +133,7 @@ describe('convertSessionToClientHistory', () => {
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history).toEqual([
|
||||
expect(history.map((h) => h.content)).toEqual([
|
||||
{ role: 'user', parts: [{ text: 'List files' }] },
|
||||
{
|
||||
role: 'model',
|
||||
@@ -172,7 +172,7 @@ describe('convertSessionToClientHistory', () => {
|
||||
|
||||
const history = convertSessionToClientHistory(messages);
|
||||
|
||||
expect(history).toEqual([
|
||||
expect(history.map((h) => h.content)).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { type Part, type PartListUnion } from '@google/genai';
|
||||
import { type ConversationRecord } from '../services/chatRecordingService.js';
|
||||
import { partListUnionToString } from '../core/geminiRequest.js';
|
||||
import { type HistoryTurn } from '../core/agentChatHistory.js';
|
||||
|
||||
/**
|
||||
* Converts a PartListUnion into a normalized array of Part objects.
|
||||
@@ -29,8 +30,8 @@ function ensurePartArray(content: PartListUnion): Part[] {
|
||||
*/
|
||||
export function convertSessionToClientHistory(
|
||||
messages: ConversationRecord['messages'],
|
||||
): Array<{ role: 'user' | 'model'; parts: Part[] }> {
|
||||
const clientHistory: Array<{ role: 'user' | 'model'; parts: Part[] }> = [];
|
||||
): HistoryTurn[] {
|
||||
const clientHistory: HistoryTurn[] = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.type === 'info' || msg.type === 'error' || msg.type === 'warning') {
|
||||
@@ -47,8 +48,11 @@ export function convertSessionToClientHistory(
|
||||
}
|
||||
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: ensurePartArray(msg.content),
|
||||
id: msg.id,
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: ensurePartArray(msg.content),
|
||||
},
|
||||
});
|
||||
} else if (msg.type === 'gemini') {
|
||||
const modelParts: Part[] = [];
|
||||
@@ -85,8 +89,11 @@ export function convertSessionToClientHistory(
|
||||
}
|
||||
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: modelParts,
|
||||
id: msg.id,
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: modelParts,
|
||||
},
|
||||
});
|
||||
|
||||
const functionResponseParts: Part[] = [];
|
||||
@@ -117,8 +124,11 @@ export function convertSessionToClientHistory(
|
||||
|
||||
if (functionResponseParts.length > 0) {
|
||||
clientHistory.push({
|
||||
role: 'user',
|
||||
parts: functionResponseParts,
|
||||
id: `${msg.id}_response`,
|
||||
content: {
|
||||
role: 'user',
|
||||
parts: functionResponseParts,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -128,8 +138,11 @@ export function convertSessionToClientHistory(
|
||||
|
||||
if (modelParts.length > 0) {
|
||||
clientHistory.push({
|
||||
role: 'model',
|
||||
parts: modelParts,
|
||||
id: msg.id,
|
||||
content: {
|
||||
role: 'model',
|
||||
parts: modelParts,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user