From 161355fd97528a545d18ffc2115b6c8c569c52df Mon Sep 17 00:00:00 2001 From: ceo Date: Fri, 21 Aug 2026 13:23:48 +0000 Subject: [PATCH] fix(session): prune SessionState.turns after each runTurn to bound memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #121: TUI crashes with heap OOM after 25+ tool-call steps because SessionState.turns grows unbounded — every assistant_tool_call, tool_result, and assistant_reply is appended forever. Large tool outputs (file reads, web fetches) push long sessions past the V8 heap limit. The TUI side (feed, messages, reasoning) was already bounded via pushRing with ringBufferSize. The real culprit was turns[]. Add pruneSessionTurns() which applies the same packConversation token-based windowing at the persistence boundary: after runTurn returns, old turns beyond the conversationMaxTokens budget are dropped and replaced with a compact summary line. The next prompt build sees the pruned state and packConversation computes a fresh summary from what remains. 5 new tests covering: within-budget passthrough, over-budget pruning, empty input, immutability guarantee, large-turn-list pruning. --- src/runtime/bootstrap.ts | 19 ++++++++- src/session/conversation-turn.test.ts | 57 +++++++++++++++++++++++++++ src/session/conversation-turn.ts | 36 +++++++++++++++++ src/session/index.ts | 1 + 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index 239b9cf2..0362c47e 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -157,6 +157,7 @@ import type { AgentLoopEvent, RunTurnResult } from "../agent/agent-loop.js"; import { SessionStore, createEmptySessionState, + pruneSessionTurns, type SessionState, } from "../session/index.js"; @@ -2101,8 +2102,22 @@ export async function createAgentRuntime( maxSteps: runOptions.maxSteps ?? config.agent.maxSteps, signal: runOptions.signal ?? new AbortController().signal, }); - sessionStore.save(result.session); - return result; + // Prune old turns to bound memory growth (issue #121). Without + // this, every tool_call / tool_result / assistant_reply is kept + // forever — large outputs (file reads, web fetches) push a long + // session past the V8 heap limit. packConversation already + // determines the visible window at prompt-build time; we apply + // the same windowing here so the persistent state stays bounded. + const prunedTurns = pruneSessionTurns( + result.session.turns, + config.agent.conversationMaxTokens, + ); + const prunedSession = + prunedTurns.length === result.session.turns.length + ? result.session + : { ...result.session, turns: prunedTurns }; + sessionStore.save(prunedSession); + return { ...result, session: prunedSession }; }); }; diff --git a/src/session/conversation-turn.test.ts b/src/session/conversation-turn.test.ts index 3591ff96..f875b81a 100644 --- a/src/session/conversation-turn.test.ts +++ b/src/session/conversation-turn.test.ts @@ -8,6 +8,7 @@ import { renderTurnForPrompt, toolResultTurn, trimTurnsToTokens, + pruneSessionTurns, userTurn, type ConversationTurn, } from "./conversation-turn.js"; @@ -374,3 +375,59 @@ describe("conversation-turn helpers", () => { }); }); }); + +describe("pruneSessionTurns", () => { + it("returns a copy of turns when within budget", () => { + const turns: ConversationTurn[] = [ + userTurn("hello", 1), + assistantReplyTurn("hi", 2), + ]; + const out = pruneSessionTurns(turns, 1000); + expect(out).toHaveLength(2); + expect(out).toEqual(turns); + }); + + it("prunes old turns and prepends a summary when over budget", () => { + const turns: ConversationTurn[] = [ + userTurn("old message " .repeat(50), 1), + assistantReplyTurn("old reply " .repeat(50), 2), + userTurn("new message", 3), + assistantReplyTurn("new reply", 4), + ]; + const out = pruneSessionTurns(turns, 50); + expect(out.length).toBeLessThan(turns.length); + // First turn should be the summary + expect(out[0]?.kind).toBe("user"); + expect(out[0]?.text).toMatch(/^summary: \d+ older turns dropped/); + // Last turns should be preserved + expect(out.at(-1)?.kind).toBe("assistant_reply"); + }); + + it("returns empty array for empty input", () => { + const out = pruneSessionTurns([], 100); + expect(out).toEqual([]); + }); + + it("does not mutate the input array", () => { + const turns: ConversationTurn[] = [ + userTurn("a ".repeat(100), 1), + assistantReplyTurn("b ".repeat(100), 2), + ]; + const original = [...turns]; + pruneSessionTurns(turns, 10); + expect(turns).toEqual(original); + }); + + it("keeps at least the visible turns from packConversation", () => { + const turns: ConversationTurn[] = Array.from({ length: 100 }, (_, i) => + i % 2 === 0 + ? userTurn(`message ${i} ` .repeat(10), i) + : assistantReplyTurn(`reply ${i} ` .repeat(10), i), + ); + const out = pruneSessionTurns(turns, 200); + expect(out.length).toBeLessThan(100); + // Should have summary + visible turns + expect(out[0]?.text).toMatch(/^summary:/); + expect(out.length).toBeGreaterThan(1); + }); +}); diff --git a/src/session/conversation-turn.ts b/src/session/conversation-turn.ts index 449d12e5..3683b056 100644 --- a/src/session/conversation-turn.ts +++ b/src/session/conversation-turn.ts @@ -354,3 +354,39 @@ export function appendTurn( ): ConversationTurn[] { return [...turns, next]; } + +/** + * Prune `state.turns` to a bounded size using the same token-based + * windowing that `packConversation` applies at prompt-build time. + * + * This prevents unbounded memory growth in long sessions (issue #121): + * every `assistant_tool_call`, `tool_result`, and `assistant_reply` is + * appended forever; large tool outputs (file reads, web fetches) can + * push a 25-step session past the V8 heap limit. + * + * The function is **pure** — it returns a new turns array without + * mutating the input. Call it before persisting the session state. + * + * @param turns The current (potentially unbounded) turn list. + * @param maxTokens Token budget for the conversation section. + * Typically `config.agent.conversationMaxTokens` (default 32 000). + * @returns A pruned turns array that fits within `maxTokens` when + * rendered by `packConversation`. When the total is already + * within budget the array is returned unchanged (zero copy). + */ +export function pruneSessionTurns( + turns: readonly ConversationTurn[], + maxTokens: number, +): ConversationTurn[] { + const packed = packConversation(turns, maxTokens); + if (packed.droppedCount === 0) return [...turns]; + // Replace the dropped prefix with a single summary turn so the + // prompt renderer and future packConversation calls still have a + // compact representation of the truncated history. + const summaryTurn: ConversationTurn = { + kind: "user", + text: packed.droppedSummary ?? "", + at: Date.now(), + }; + return [summaryTurn, ...packed.visibleTurns]; +} diff --git a/src/session/index.ts b/src/session/index.ts index ed288af9..fee6599a 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -32,6 +32,7 @@ export { trimTurnsToTokens, packConversation, appendTurn, + pruneSessionTurns, } from "./conversation-turn.js"; export type { ConversationTurn,