diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index 239b9cf..0362c47 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 3591ff9..f875b81 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 449d12e..3683b05 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 ed288af..fee6599 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,