From 1fc49b8f35f58d29cb48c8c268b4f6a6288b5f30 Mon Sep 17 00:00:00 2001 From: jonnyparris <6400000+jonnyparris@users.noreply.github.com> Date: Sun, 24 May 2026 21:29:37 +0100 Subject: [PATCH] fix(compaction): mid-loop trigger uses force=true; guard slice underflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small fixes uncovered by deploying PR #73 and watching wrangler tail on a Gemma-driven session that ran 6 tool-call iterations: 1. The mid-loop compaction trigger in onChatMessage()'s own-loop was the only one of the three triggers NOT passing { force: true } to maybeCompactContext(). Because maybeCompactContext early-returns when lastInputTokens === 0 (no persisted assistant message yet) *unless* force is set, the mid-loop call was a silent no-op on the very first user turn — exactly the scenario the own-loop budget check was designed for. The log line still fired so the failure was invisible. 2. maybeCompactContext built messagesToCompact via realMessages.slice(0, compactCount) and then indexed messagesToCompact[compactCount - 1], which throws TypeError when compactCount > realMessages.length (e.g. force=true on a turn with only 1 persisted message). Now guarded with an early return + log line and the toId lookup uses messagesToCompact.length - 1. Symptom in production: a Gemma session ran 6 tool-call iterations, hit the 58% mid-loop trigger, logged 'mid-loop compaction triggered', but compactionCount stayed at 0 and totalTokenInput climbed to 197k. Verified the silent-noop path via wrangler tail. Note: this fix alone does not fully unblock weak orchestrator models on the first turn — by design, compaction needs >=2 real persisted messages to have something to summarise. The deeper issue (single assistant turn ballooning before any tool result is persisted) is captured in memory/learnings/dodo-orchestrator-model-matters.md. Tests: 21 existing compaction tests still pass; typecheck clean. --- src/coding-agent.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/coding-agent.ts b/src/coding-agent.ts index 0c26e01..52da1fd 100644 --- a/src/coding-agent.ts +++ b/src/coding-agent.ts @@ -1056,7 +1056,15 @@ export class CodingAgent extends Think { if (budgetUsage >= MID_LOOP_COMPACTION_THRESHOLD && !compactionTriggered) { compactionTriggered = true; try { - await self.maybeCompactContext(); + // force=true so maybeCompactContext() bypasses the + // `lastInputTokens === 0` early-return guard. The mid-loop + // trigger fires on the FIRST user turn (before any assistant + // message is persisted), so lastInputTokens is always 0 here + // and the guard would silently no-op without `force`. The + // own-loop has already proved compaction is warranted by + // measuring cumulativeInputTokens directly — don't make + // maybeCompactContext re-decide. + await self.maybeCompactContext({ force: true }); // Refresh messages from storage and re-assemble with compaction summary. const thinkSessionId = self.getCurrentSessionId(); if (thinkSessionId) { @@ -4992,8 +5000,20 @@ export class CodingAgent extends Think { } const messagesToCompact = realMessages.slice(0, compactCount); + // Guard against the pathological case where realMessages has fewer + // entries than targetCompactCount (e.g. force=true on the very first + // turn, when Think hasn't persisted the assistant message yet and + // realMessages.length === 1). Without this we'd hit + // `messagesToCompact[1].id` → TypeError on undefined. + if (messagesToCompact.length < 2) { + log("info", "compaction: skipped — fewer than 2 real messages to compact", { + sessionId: this.sessionId(), + realMessageCount: realMessages.length, + }); + return; + } const fromMessageId = messagesToCompact[0].id; - const toMessageId = messagesToCompact[compactCount - 1].id; + const toMessageId = messagesToCompact[messagesToCompact.length - 1].id; // Check if this range is already compacted const existingCompactions = this.sessions.getCompactions(thinkSessionId);