Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions packages/core/src/core/geminiChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -691,9 +691,13 @@ export class GeminiChat {
const history = curated
? extractCuratedHistory(this.history)
: this.history;
// Deep copy the history to avoid mutating the history outside of the
// chat session.
return structuredClone(history);
// Return a shallow copy of the array to prevent callers from mutating
// the internal history array (push/pop/splice). Content objects are
// shared references — callers MUST NOT mutate them in place.
// This replaces a prior structuredClone() which deep-copied the entire
// conversation on every call, causing O(n) memory pressure per turn
// that compounded into OOM crashes in long-running sessions.
return [...history];
}

/**
Expand Down
13 changes: 9 additions & 4 deletions packages/core/src/core/turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ export class Turn {
readonly pendingToolCalls: ToolCallRequestInfo[] = [];
private debugResponses: GenerateContentResponse[] = [];
private pendingCitations = new Set<string>();
private cachedResponseText: string | undefined = undefined;
finishReason: FinishReason | undefined = undefined;

constructor(
Expand Down Expand Up @@ -432,11 +433,15 @@ export class Turn {
/**
* Get the concatenated response text from all responses in this turn.
* This extracts and joins all text content from the model's responses.
* The result is cached since this is called multiple times per turn.
*/
getResponseText(): string {
return this.debugResponses
.map((response) => getResponseText(response))
.filter((text): text is string => text !== null)
.join(' ');
if (this.cachedResponseText === undefined) {
this.cachedResponseText = this.debugResponses
.map((response) => getResponseText(response))
.filter((text): text is string => text !== null)
.join(' ');
}
return this.cachedResponseText;
}
}
24 changes: 19 additions & 5 deletions packages/core/src/utils/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ type EventBacklogItem = {

export class CoreEventEmitter extends EventEmitter<CoreEvents> {
private _eventBacklog: EventBacklogItem[] = [];
private _backlogHead = 0;
private static readonly MAX_BACKLOG_SIZE = 10000;

constructor() {
Expand All @@ -243,8 +244,17 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
...args: CoreEvents[K]
): void {
if (this.listenerCount(event) === 0) {
if (this._eventBacklog.length >= CoreEventEmitter.MAX_BACKLOG_SIZE) {
this._eventBacklog.shift();
const backlogSize = this._eventBacklog.length - this._backlogHead;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is fine but not really needed as there should not be an enormous backlog in typical cases

if (backlogSize >= CoreEventEmitter.MAX_BACKLOG_SIZE) {
// Evict oldest entry. Use a head pointer instead of shift() to avoid
// O(n) array reindexing on every eviction at capacity.
(this._eventBacklog as unknown[])[this._backlogHead] = undefined;
this._backlogHead++;
// Compact once dead entries exceed half capacity to bound memory
if (this._backlogHead >= CoreEventEmitter.MAX_BACKLOG_SIZE / 2) {
this._eventBacklog = this._eventBacklog.slice(this._backlogHead);
this._backlogHead = 0;
}
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
this._eventBacklog.push({ event, args } as EventBacklogItem);
Expand Down Expand Up @@ -391,9 +401,13 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
* subscribes.
*/
drainBacklogs(): void {
const backlog = [...this._eventBacklog];
this._eventBacklog.length = 0; // Clear in-place
for (const item of backlog) {
const backlog = this._eventBacklog;
const head = this._backlogHead;
this._eventBacklog = [];
this._backlogHead = 0;
for (let i = head; i < backlog.length; i++) {
const item = backlog[i];
if (item === undefined) continue;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(this.emit as (event: keyof CoreEvents, ...args: unknown[]) => boolean)(
item.event,
Expand Down
1 change: 0 additions & 1 deletion packages/core/src/utils/nextSpeakerChecker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,6 @@ export async function checkNextSpeaker(
lastComprehensiveMessage.parts &&
lastComprehensiveMessage.parts.length === 0
) {
lastComprehensiveMessage.parts.push({ text: '' });
return {
reasoning:
'The last message was a filler model message with no content (nothing for user to act on), model should speak next.',
Expand Down
Loading