Skip to content
Open
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
19 changes: 17 additions & 2 deletions src/runtime/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ import type { AgentLoopEvent, RunTurnResult } from "../agent/agent-loop.js";
import {
SessionStore,
createEmptySessionState,
pruneSessionTurns,
type SessionState,
} from "../session/index.js";

Expand Down Expand Up @@ -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 };
});
};

Expand Down
57 changes: 57 additions & 0 deletions src/session/conversation-turn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
renderTurnForPrompt,
toolResultTurn,
trimTurnsToTokens,
pruneSessionTurns,
userTurn,
type ConversationTurn,
} from "./conversation-turn.js";
Expand Down Expand Up @@ -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);
});
});
36 changes: 36 additions & 0 deletions src/session/conversation-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
1 change: 1 addition & 0 deletions src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export {
trimTurnsToTokens,
packConversation,
appendTurn,
pruneSessionTurns,
} from "./conversation-turn.js";
export type {
ConversationTurn,
Expand Down