mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
fix(core): truncate large telemetry log entries (#16769)
This commit is contained in:
104
packages/core/src/telemetry/semantic.truncation.test.ts
Normal file
104
packages/core/src/telemetry/semantic.truncation.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { toInputMessages } from './semantic.js';
|
||||
import { type Content } from '@google/genai';
|
||||
|
||||
// 160KB limit for the total size of string content in a log entry.
|
||||
const GLOBAL_TEXT_LIMIT = 160 * 1024;
|
||||
const SUFFIX = '...[TRUNCATED]';
|
||||
|
||||
describe('Semantic Telemetry Truncation', () => {
|
||||
it('should not truncate a single part if it is within global limit', () => {
|
||||
// 150KB part -> Should fit in 160KB limit
|
||||
const textLen = 150 * 1024;
|
||||
const longText = 'a'.repeat(textLen);
|
||||
const contents: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: longText }],
|
||||
},
|
||||
];
|
||||
const result = toInputMessages(contents);
|
||||
// @ts-expect-error - testing internal state
|
||||
expect(result[0].parts[0].content.length).toBe(textLen);
|
||||
// @ts-expect-error - testing internal state
|
||||
expect(result[0].parts[0].content.endsWith(SUFFIX)).toBe(false);
|
||||
});
|
||||
|
||||
it('should truncate a single part if it exceeds global limit', () => {
|
||||
// 170KB part -> Should get truncated to ~160KB
|
||||
const textLen = 170 * 1024;
|
||||
const longText = 'a'.repeat(textLen);
|
||||
const contents: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: longText }],
|
||||
},
|
||||
];
|
||||
const result = toInputMessages(contents);
|
||||
// @ts-expect-error - testing internal state
|
||||
const content = result[0].parts[0].content;
|
||||
expect(content.length).toBeLessThan(textLen);
|
||||
// Because it's the only part, it gets the full budget of 160KB
|
||||
expect(content.length).toBe(GLOBAL_TEXT_LIMIT + SUFFIX.length);
|
||||
expect(content.endsWith(SUFFIX)).toBe(true);
|
||||
});
|
||||
|
||||
it('should fairly distribute budget among multiple large parts', () => {
|
||||
// Two 100KB parts (Total 200KB) -> Budget 160KB
|
||||
// Each should get roughly 80KB
|
||||
const partLen = 100 * 1024;
|
||||
const part1 = 'a'.repeat(partLen);
|
||||
const part2 = 'b'.repeat(partLen);
|
||||
const contents: Content[] = [
|
||||
{ role: 'user', parts: [{ text: part1 }] },
|
||||
{ role: 'model', parts: [{ text: part2 }] },
|
||||
];
|
||||
|
||||
const result = toInputMessages(contents);
|
||||
|
||||
// @ts-expect-error - testing internal state
|
||||
const c1 = result[0].parts[0].content;
|
||||
// @ts-expect-error - testing internal state
|
||||
const c2 = result[1].parts[0].content;
|
||||
|
||||
expect(c1.length).toBeLessThan(partLen);
|
||||
expect(c2.length).toBeLessThan(partLen);
|
||||
|
||||
// Budget is split evenly
|
||||
const expectedLen = Math.floor(GLOBAL_TEXT_LIMIT / 2) + SUFFIX.length;
|
||||
expect(c1.length).toBe(expectedLen);
|
||||
expect(c2.length).toBe(expectedLen);
|
||||
});
|
||||
|
||||
it('should not truncate small parts while truncating large ones', () => {
|
||||
// One 200KB part, one 1KB part.
|
||||
// 1KB part is small (below average), so it keeps its size.
|
||||
// 200KB part gets the remaining budget (128KB - 1KB = 127KB).
|
||||
const bigLen = 200 * 1024;
|
||||
const smallLen = 1 * 1024;
|
||||
const bigText = 'a'.repeat(bigLen);
|
||||
const smallText = 'b'.repeat(smallLen);
|
||||
|
||||
const contents: Content[] = [
|
||||
{ role: 'user', parts: [{ text: bigText }] },
|
||||
{ role: 'model', parts: [{ text: smallText }] },
|
||||
];
|
||||
|
||||
const result = toInputMessages(contents);
|
||||
// @ts-expect-error - testing internal state
|
||||
const cBig = result[0].parts[0].content;
|
||||
// @ts-expect-error - testing internal state
|
||||
const cSmall = result[1].parts[0].content;
|
||||
|
||||
expect(cSmall.length).toBe(smallLen); // Untouched
|
||||
expect(cBig.length).toBeLessThan(bigLen);
|
||||
|
||||
const expectedBigLen = GLOBAL_TEXT_LIMIT - smallLen + SUFFIX.length;
|
||||
expect(cBig.length).toBe(expectedBigLen);
|
||||
});
|
||||
});
|
||||
@@ -19,12 +19,111 @@ import type {
|
||||
Part,
|
||||
PartUnion,
|
||||
} from '@google/genai';
|
||||
import { truncateString } from '../utils/textUtils.js';
|
||||
|
||||
// 160KB limit for the total size of string content in a log entry.
|
||||
// The total log entry size limit is 256KB. We leave ~96KB (approx 37%) for JSON overhead (escaping, structure) and other fields.
|
||||
const GLOBAL_TEXT_LIMIT = 160 * 1024;
|
||||
|
||||
interface StringReference {
|
||||
get: () => string | undefined;
|
||||
set: (val: string) => void;
|
||||
len: () => number;
|
||||
}
|
||||
|
||||
function getStringReferences(parts: AnyPart[]): StringReference[] {
|
||||
const refs: StringReference[] = [];
|
||||
for (const part of parts) {
|
||||
if (part instanceof TextPart) {
|
||||
refs.push({
|
||||
get: () => part.content,
|
||||
set: (val: string) => (part.content = val),
|
||||
len: () => part.content.length,
|
||||
});
|
||||
} else if (part instanceof ReasoningPart) {
|
||||
refs.push({
|
||||
get: () => part.content,
|
||||
set: (val: string) => (part.content = val),
|
||||
len: () => part.content.length,
|
||||
});
|
||||
} else if (part instanceof ToolCallRequestPart) {
|
||||
if (part.arguments) {
|
||||
refs.push({
|
||||
get: () => part.arguments,
|
||||
set: (val: string) => (part.arguments = val),
|
||||
len: () => part.arguments?.length ?? 0,
|
||||
});
|
||||
}
|
||||
} else if (part instanceof ToolCallResponsePart) {
|
||||
if (part.response) {
|
||||
refs.push({
|
||||
get: () => part.response,
|
||||
set: (val: string) => (part.response = val),
|
||||
len: () => part.response?.length ?? 0,
|
||||
});
|
||||
}
|
||||
} else if (part instanceof GenericPart) {
|
||||
if (part.type === 'executableCode' && typeof part['code'] === 'string') {
|
||||
refs.push({
|
||||
get: () => part['code'] as string,
|
||||
set: (val: string) => (part['code'] = val),
|
||||
len: () => (part['code'] as string).length,
|
||||
});
|
||||
} else if (
|
||||
part.type === 'codeExecutionResult' &&
|
||||
typeof part['output'] === 'string'
|
||||
) {
|
||||
refs.push({
|
||||
get: () => part['output'] as string,
|
||||
set: (val: string) => (part['output'] = val),
|
||||
len: () => (part['output'] as string).length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function limitTotalLength(parts: AnyPart[]): void {
|
||||
const refs = getStringReferences(parts);
|
||||
const totalLength = refs.reduce((sum, ref) => sum + ref.len(), 0);
|
||||
|
||||
if (totalLength <= GLOBAL_TEXT_LIMIT) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the average budget per part for "large" parts.
|
||||
// We identify parts that are larger than the fair share (average) and truncate them.
|
||||
const averageSize = GLOBAL_TEXT_LIMIT / refs.length;
|
||||
|
||||
// Filter out parts that are already small enough to not need truncation
|
||||
const largeRefs = refs.filter((ref) => ref.len() > averageSize);
|
||||
const smallRefsLength = refs
|
||||
.filter((ref) => ref.len() <= averageSize)
|
||||
.reduce((sum, ref) => sum + ref.len(), 0);
|
||||
|
||||
// Distribute the remaining budget among large parts
|
||||
const remainingBudget = GLOBAL_TEXT_LIMIT - smallRefsLength;
|
||||
const budgetPerLargePart = Math.max(
|
||||
1,
|
||||
Math.floor(remainingBudget / largeRefs.length),
|
||||
);
|
||||
|
||||
for (const ref of largeRefs) {
|
||||
const original = ref.get();
|
||||
if (original) {
|
||||
ref.set(truncateString(original, budgetPerLargePart));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function toInputMessages(contents: Content[]): InputMessages {
|
||||
const messages: ChatMessage[] = [];
|
||||
for (const content of contents) {
|
||||
messages.push(toChatMessage(content));
|
||||
}
|
||||
const allParts = messages.flatMap((m) => m.parts);
|
||||
limitTotalLength(allParts);
|
||||
return messages;
|
||||
}
|
||||
|
||||
@@ -81,6 +180,7 @@ export function toSystemInstruction(
|
||||
}
|
||||
}
|
||||
}
|
||||
limitTotalLength(parts);
|
||||
return parts;
|
||||
}
|
||||
|
||||
@@ -94,6 +194,8 @@ export function toOutputMessages(candidates?: Candidate[]): OutputMessages {
|
||||
});
|
||||
}
|
||||
}
|
||||
const allParts = messages.flatMap((m) => m.parts);
|
||||
limitTotalLength(allParts);
|
||||
return messages;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { safeLiteralReplace } from './textUtils.js';
|
||||
import { safeLiteralReplace, truncateString } from './textUtils.js';
|
||||
|
||||
describe('safeLiteralReplace', () => {
|
||||
it('returns original string when oldString empty or not found', () => {
|
||||
@@ -77,3 +77,25 @@ describe('safeLiteralReplace', () => {
|
||||
expect(safeLiteralReplace('abc', 'b', '$$')).toBe('a$$c');
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateString', () => {
|
||||
it('should not truncate string shorter than maxLength', () => {
|
||||
expect(truncateString('abc', 5)).toBe('abc');
|
||||
});
|
||||
|
||||
it('should not truncate string equal to maxLength', () => {
|
||||
expect(truncateString('abcde', 5)).toBe('abcde');
|
||||
});
|
||||
|
||||
it('should truncate string longer than maxLength and append default suffix', () => {
|
||||
expect(truncateString('abcdef', 5)).toBe('abcde...[TRUNCATED]');
|
||||
});
|
||||
|
||||
it('should truncate string longer than maxLength and append custom suffix', () => {
|
||||
expect(truncateString('abcdef', 5, '...')).toBe('abcde...');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
expect(truncateString('', 5)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,3 +53,21 @@ export function isBinary(
|
||||
// If no NULL bytes were found in the sample, we assume it's text.
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates a string to a maximum length, appending a suffix if truncated.
|
||||
* @param str The string to truncate.
|
||||
* @param maxLength The maximum length of the string.
|
||||
* @param suffix The suffix to append if truncated (default: '...[TRUNCATED]').
|
||||
* @returns The truncated string.
|
||||
*/
|
||||
export function truncateString(
|
||||
str: string,
|
||||
maxLength: number,
|
||||
suffix = '...[TRUNCATED]',
|
||||
): string {
|
||||
if (str.length <= maxLength) {
|
||||
return str;
|
||||
}
|
||||
return str.slice(0, maxLength) + suffix;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user