From f6134437555bbebd1a9fa5ef541db16ad55a7003 Mon Sep 17 00:00:00 2001 From: "sihyeon0706.lee" Date: Sun, 1 Mar 2026 14:34:23 +0900 Subject: [PATCH] fix(session): prevent infinite loop in auto-compaction when assistant ended its turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When auto-compaction triggers after the assistant has naturally ended its turn (finish reason is 'stop' or similar, not 'tool-calls'), the synthetic 'Continue...' user message should NOT be injected. Previously, this message was always injected during auto-compaction regardless of the assistant's state, causing an infinite loop: compaction → synthetic continue → agent response → overflow → compaction → … This is especially problematic when the assistant asked a question and was waiting for user input — the synthetic continue overrides the pending question, causing the agent to respond to itself indefinitely. The fix checks the last non-summary assistant message's finish reason before injecting the synthetic continue. Only when the assistant was in the middle of tool execution (finish === 'tool-calls' or 'unknown') is the continue message injected. If the assistant had ended its turn naturally (finish === 'stop'), the compaction completes without a synthetic continue, allowing the session loop to exit and wait for actual user input. Fixes infinite loop observed with oh-my-opencode plugin (Sisyphus/Ultraworker) where auto-compaction + synthetic continue created unbounded agent loops. --- packages/opencode/src/session/compaction.ts | 77 +++++++++++++++------ 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 9245426057c1..a3c9c2d0798f 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -200,28 +200,61 @@ When constructing the summary, try to stick to this template: }) if (result === "continue" && input.auto) { - const continueMsg = await Session.updateMessage({ - id: Identifier.ascending("message"), - role: "user", - sessionID: input.sessionID, - time: { - created: Date.now(), - }, - agent: userMessage.agent, - model: userMessage.model, - }) - await Session.updatePart({ - id: Identifier.ascending("part"), - messageID: continueMsg.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text: "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.", - time: { - start: Date.now(), - end: Date.now(), - }, - }) + // Only inject a synthetic continue when ALL of these conditions are met: + // 1. The assistant was in the middle of tool execution (finish === "tool-calls" or "unknown") + // 2. A previous compaction cycle hasn't already injected a synthetic continue + // + // Without check #2, the following infinite loop occurs: + // compaction → "Continue…" → agent responds (tool-calls) → overflow → compaction → "Continue…" → … + // By allowing at most one synthetic continue per real user turn, the loop is broken: + // compaction → "Continue…" → agent responds → overflow → compaction → (already continued) → stop + + const lastNonSummaryAssistant = [...input.messages] + .reverse() + .find((m) => m.info.role === "assistant" && !(m.info as MessageV2.Assistant).summary)?.info as + | MessageV2.Assistant + | undefined + + const wasUsingTools = + !lastNonSummaryAssistant?.finish || + lastNonSummaryAssistant.finish === "tool-calls" || + lastNonSummaryAssistant.finish === "unknown" + + // Check whether a previous compaction cycle already injected a synthetic continue. + // Find the last user message that has a text part (skip compaction-trigger messages + // which only carry a CompactionPart). If that message is synthetic, we already + // continued once since the user's last real input — don't inject another. + const lastUserWithText = [...input.messages] + .reverse() + .find((m) => m.info.role === "user" && m.parts.some((p) => p.type === "text")) + const alreadyContinued = lastUserWithText?.parts.some( + (p) => p.type === "text" && "synthetic" in p && (p as MessageV2.TextPart).synthetic === true, + ) + + if (wasUsingTools && !alreadyContinued) { + const continueMsg = await Session.updateMessage({ + id: Identifier.ascending("message"), + role: "user", + sessionID: input.sessionID, + time: { + created: Date.now(), + }, + agent: userMessage.agent, + model: userMessage.model, + }) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: continueMsg.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text: "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.", + time: { + start: Date.now(), + end: Date.now(), + }, + }) + } } if (processor.message.error) return "stop" Bus.publish(Event.Compacted, { sessionID: input.sessionID })