Skip to content

[BUG] V2: Auto-compaction accepts task-empty summary after shortened transcript, silently losing active objective (Ollama/Qwen) #60663

Open

Description

@ivanfc

Description

Summary. On v2.2.3, an automatic compaction produced a summary that follows the template but states that no task exists. It replaced a valid summary that described an active task:

## Objective
- (none — no task has been established yet)
...
## Important Context
- No user request, task, or assistant output has occurred to summarize yet.

OpenCode stored it as status: "completed". The next assistant reply greeted the user as if the session had just started. The reply was in Spanish, roughly: "Hi! I'm your OpenCode agent. I see there is no active task yet." The active objective and its constraints were silently lost. The local model server did not truncate anything (see below), so the context was lost on the client side.

Environment

OpenCode @opencode/cli 2.2.3, no plugins, default compaction settings
Provider/model Ollama 0.40.13 (native discovery, OpenAI-compatible route), qwen3.8:27b Q4_K_M, fully on GPU (RTX 4090 24 GB)
Context Ollama serves 65,536 (n_ctx_slot = 65536); the model limit is overridden to context: 65536, output: 16384 because discovery reports 262,144
Ollama flash attention on, KV cache q8_0, 1 parallel slot
OS Windows 11 Pro (build 26300)

Expected behavior

  • A compaction keeps the active objective and its constraints.
  • A summary that loses them, or claims there is no task while earlier history clearly has one, does not silently replace a valid context. The previous summary/context is kept and the failure is surfaced.

Actual behavior. These are rows from the session's session_message table in opencode.db, matched to the Ollama server log. tokens.input excludes tokens.cache.read in these rows.

DB seq Row tokens.input cache.read output Notes
161 compaction #1 (auto, completed) 1,453 50,032 5,830 Good summary with full Objective, about 9 KB; no metadata.keepID
164 assistant (4 tool calls) 14,615 0 1,984 First request after compaction #1
187 assistant (4 tool calls) 38,963 16,598 3,240 About 55.5K prompt, past the auto-compaction ceiling
210 compaction #2 (auto, completed) 8,397 0 859 Task-empty summary shown above; no metadata.keepID
213 assistant 11,699 0 226 Welcome reply, then idle
224 user New user message (about 3.5K chars)
414 compaction #3 (auto, completed) 12,634 0 182 keepID → seq 224; summary again says no task ("user only greeted")
484+ later compactions about 707–1,215 17K–23K Normal again: reads the prompt cache, summaries keep the objective

There were no user messages between compaction #1 and compaction #2, only two assistant steps with tool calls.

Ollama log for the compaction #2 request:

selected slot by LRU, n_tokens = 58800
prompt with length 58800, lcp = 3, f_keep = 0.000, f_sim = 0.000
new prompt, n_ctx_slot = 65536, n_keep = 4, task.n_tokens = 8397
stop processing: n_tokens = 9255, truncated = 0

The slot held the previous 58,800-token conversation. The compaction request shared only 3 prompt tokens with it, was about 7× smaller, and nothing was truncated by the server.

Compaction #3 kept the new user message verbatim through keepID (seq 224), and the agent kept working from it. Its summary was nevertheless again task-empty. This looks like propagation from compaction #2: the history before seq 224 was the empty summary plus the welcome reply. We count it as one failure plus its downstream effect, not as two independent reproductions.

Source analysis (tag v2.2.3, packages/core/src/session/)

Confirmed from the code

  1. compaction.ts ~L468-469 checks the summary only structurally. The check is filled = text.split("\n").some(line => headings.includes(line.trim())): any reply containing one template heading such as ## Objective passes. When a previous summary exists (the update path), nothing checks that the new summary still contains its objective before it replaces it.
  2. recentStart (~L1005-1027): when there is no user message since the previous compaction, and the previous compaction has no stored recent, the split keeps nothing verbatim (keepID undefined). SessionRecentText.kept() returns "" whenever metadata.recent is absent (recent-text.ts L12-13). Compactions feat: compact and other improvements #1 and Roadmap & Existing Issues #2 carry neither keepID nor recent. From that point the objective survived only inside the previous summary text, with no verbatim fallback if the summarizer dropped it.
  3. flattenAndDropOldest (~L691-759) is the text-transcript fallback that deliver uses when the request does not fit. It sends [previous summary][one user message containing the transcript] with tools: []. That prompt prefix differs from the normal request, which matches cache.read = 0 / lcp = 3 for compactions Roadmap & Existing Issues #2 and feat: model config persistence #3. The normal compactions (484+) read 17K–23K from cache.

Working hypothesis (not proven). The prompt that produced compaction #2 did not effectively present the previous summary's objective to the model. Two candidate mechanisms:

  • (a) The flattened request lost or diluted the previous summary.
  • (b) The model received it but ignored it under the "update the existing checkpoint" prompt.

Either way, mechanism (1) then accepted the result. One detail does not add up from the outside: 8,397 tokens is much smaller than both the 58.8K prior prompt and the room summaryRoom should leave at a 65,536/16,384 limit. Either flattenAndDropOldest received a much lower target than expected, or content was removed elsewhere. We have not located the exact line that drops it and do not claim one.

Suggested fix (proposals, untested)

  1. Validate before replacing. Treat a summary as failed when the previous summary had a non-empty ## Objective and the new one is empty or "none". Then keep the previous compaction and surface an error or retry instead of storing completed.
  2. Carry the objective forward deterministically. Copy the previous Objective/Requirements when the new summary omits them, or always keep the last user request verbatim, even when no user message exists since the last compaction.
  3. Keep the old context on failure. A failed or suspicious compaction never replaces a valid one.
  4. Diagnostics. Log which deliver path was used (unchanged, history, or flattened), the target, the request estimate, and whether the previous summary was included. Log no content.
  5. Measurements. Check that the token measurement used right after a compaction (the trigger at seq 187) describes the current, post-compaction context, so a stale count cannot trigger a premature second compaction.

Steps to reproduce

Observed (real session). This was one long agentic session with a local Qwen model at a 64K window and large tool outputs:

  1. Compaction feat: compact and other improvements #1 succeeds.
  2. With no new user message, two tool-heavy assistant steps push the prompt to about 55K, past the auto-compaction ceiling.
  3. Compaction Roadmap & Existing Issues #2 goes through a reduced, uncached request and produces the task-empty summary.

Proposed synthetic regression test (not yet built). It needs no private data:

  1. Session with model limit {context: 65536, output: 16384}, served by Ollama at 64K or by a mock provider.
  2. User message stating an explicit objective and constraints; force or trigger compaction feat: compact and other improvements #1.
  3. Without any new user message, run assistant steps whose tool results total about 40K tokens, so the next auto-compaction must use the flattened transcript path.
  4. Assert that compaction Roadmap & Existing Issues #2's request contains the previous summary's Objective text and that the stored summary's ## Objective still matches it.
  5. Add a unit test where the summarizer stub returns a template-valid but task-empty summary; expect it to be rejected while a previous objective exists.

Related issues (similar symptoms, not claimed to be the same cause)

Reproduction status

The failure was observed once in a real session. A second, task-empty compaction followed in the same session, and we treat it as propagation, not an independent reproduction. It is corroborated by local DB token counts and the Ollama server log. No independent minimal reproduction exists yet. I can run a synthetic test or share redacted rows (types, token counts, reason/status, timestamps) on request. Private transcripts will not be shared.

Plugins

None

OpenCode version

2.2.3

Operating System

Windows 11 Pro (build 26300)

Activity

  1. opencodejr commented on Oct 30, 2026

    @opencodejr

    Thanks for the detailed write-up and the token and Ollama data, they made this much easier to trace.

    I rebuilt your scenario on the latest dev (cb1e58f) with a simulated model at your limits (65,536 context, 16,384 output):

    • A user message sets an objective, and compaction feat: compact and other improvements #1 writes a summary with a full ## Objective.
    • With no new user message, a large tool step triggers automatic compaction Roadmap & Existing Issues #2. It goes through the shortened text path (flattenAndDropOldest).
    • The scripted model replies with a task-empty summary.

    What I found:

    • The previous summary is sent. The compaction Roadmap & Existing Issues #2 request starts with the previous summary, Objective included, inside <conversation-checkpoint>. Then come the shortened transcript and the "Update the existing checkpoint…" prompt. So on our side the objective isn't dropped. In your session, the model received it and ignored it (your mechanism b).
    • This likely explains the small 8,397-token request. With no user message since the last compaction, the whole history counts as one exchange. Shortening works one exchange at a time, so every tool output in it is cut to 2,000 characters at each end in one go, even though much more room was left. That is coarse, but it is what the code was written to do. It is not lost content.
    • The empty summary is accepted. It is stored as completed because OpenCode only checks that the reply follows the template (any required heading). It doesn't check that the content is meaningful.

    The last point is how it was built rather than a malfunction, so it may be better filed as a feature request. For example: reject or retry a summary that drops a previous non-empty Objective, or carry the Objective forward. If you can share the first lines of the actual compaction #2 request, or show that its previous summary was missing, please reply and we'll look again.

    Related: #57240 (empty checkpoint replies marked succeeded), #50816 (stale summary accepted), #44080 (empty summary accepted on v1).

  2. ivanfc commented on Oct 30, 2026

    @ivanfc
    Author

    I disagree that this is a feature request. From the user's side the result is silent data loss: the active objective disappears, the compaction is stored as "completed", and the agent restarts as if the session were new, with no warning and no way to notice except that it suddenly greets you. "Working as built" doesn't make that acceptable behavior; a summary that throws away a previously recorded objective should never silently replace a valid context.

    For the record: on this machine all 17 automatic compactions so far are from local Qwen at a 64K window, and 2 of them came out task-empty. That's not a rare edge case for local models, which are exactly the ones most likely to ignore a checkpoint inside a long "update" prompt.

    The minimum fix I'd expect as a bug fix, not an enhancement: if the previous summary has a non-empty ## Objective and the new one doesn't, don't mark it completed: retry once, and if it still fails, keep the previous compaction and surface an error. Please keep this open as a bug.

  3. John-Rood commented on Oct 31, 2026

    @John-Rood

    Three things in this thread are still open at the code level, and all three resolve in the v2.2.3 tree (082ac62, unchanged on current dev). Two of them come out your way.

    Where the 8,397 tokens went

    The target was not small. For an automatic compaction the budget is calculateCeiling (compaction.ts:1134). With your limits, context 65,536 and output 16,384, that is 65,536 - 16,000 = 49,536, and summaryRoom (:1148) returns the same number because 15% of the window is 9,830, under RESERVE_MIN. The summary request aimed at about 48.5k once prompt overhead is subtracted.

    What collapsed is the transcript, and the exact line is the level loop.

    TRANSCRIPT_CLIP = 2_000 (:150) with clip() (:1074) keeps 1,000 characters from the start and 1,000 from the end of a cut value, so it is 2,000 total, not 2,000 at each end.

    Shortening walks whole levels over each exchange (:742-751): whole, tool outputs clipped, tool outputs removed, user text only, omitted. There is no state between those levels.

    With no user message after compaction #1, recentStart (:1005) keeps nothing, splitConversation (:985) returns keepID: undefined, and flattenAndDropOldest sees one group, because grouping starts a new group when the list is empty or the message is a user message (:715). Your entire post-checkpoint history is that one group.

    Level 0 is the full 55k, over room. The next reachable state is OUTPUTS_CLIPPED, which cuts every tool result to 2,000 characters in a single pass. Eight tool results across the two steps is 16,000 characters, about 4,000 tokens, sitting in front of the roughly 9 KB checkpoint that lead rebuilds (:702-704) at about 2,250 tokens, plus the summary prompt and the system message. 8,397 is what that adds up to. Nothing is removed elsewhere.

    That also answers your point 5. The count behind compaction #2 was 38,963 input + 16,598 cache read + 3,240 output = 58,801, against Ollama's n_tokens = 58800. estimatePrompt (:1103) takes the last measured assistant row and adds an estimate of what came after it, so the figure was current to within a token, and due (:422) fires at >= ceiling. There is no stale trigger to fix.

    The checkpoint was in the request

    Not only in your rebuild. lead is built from previousCompaction (:967) and rendered by to-llm-message.ts:293 as <conversation-checkpoint><summary>. No branch on the flattened path drops it. So your mechanism (b) is the one that happened: the model was given the objective and did not repeat it, and the code then accepted the result.

    The acceptance check is the defect

    filled (:469) is text.split("\n").some((line) => headings.includes(line.trim())). One line that equals a template heading is enough, so a summary whose Objective reads "(none, no task has been established yet)" passes on the first attempt and publish stores it as completed (:921).

    Keeping the previous context on failure is already the behaviour, one line further down. A compaction that is not completed renders as no messages at all (to-llm-message.ts:294), and history loads from the latest completed one, so a rejected summary leaves the old checkpoint as the lead. The auto path then fails the step (runner/llm.ts:243), which surfaces as a compaction error instead of a greeting.

    Your point 1 and point 3 are one change, and it is a predicate.

    The predicate

    One condition keeps it from failing legitimate compactions: only enforce it when no user turn has arrived since the checkpoint. After a user turn the update prompt allows the objective to change ("Newer history always takes precedence over the existing checkpoint", buildPrompt :233).

    const objectiveOf = (text: string) => {
      const lines = text.split("\n")
      const start = lines.findIndex((line) => line.trim() === "## Objective")
      if (start < 0) return ""
      const body: string[] = []
      for (const line of lines.slice(start + 1)) {
        if (line.trim().startsWith("## ")) break
        body.push(line)
      }
      return body.join("\n").replace(/^\s*[-*]\s*/gm, "").trim()
    }
    const emptyObjective = (value: string) => value === "" || /^\(?\s*none\b/i.test(value)
    
    // inside summarize(), beside `previous`
    const userSince = messages.slice(previous === undefined ? 0 : 1).some((message) => message.type === "user")
    const keepsObjective = (text: string) =>
      previous === undefined ||
      userSince ||
      emptyObjective(objectiveOf(previous.summary)) ||
      !emptyObjective(objectiveOf(text))
    
    const filled = (text: string) =>
      keepsObjective(text) && text.split("\n").some((line) => headings.includes(line.trim()))

    The existing send already provides the retry you asked for. A reply that fails filled gets NUDGE once, and a second failure becomes compaction.failed. With the predicate in place your task-empty summary gets one more attempt, and if it comes back empty again the turn fails visibly. Worth giving that case its own failure string so "did not match the required template" does not describe it.

    What I ran

    I pulled the real SUMMARY_TEMPLATE and the real filled out of the tag instead of retyping them, then ran six cases: your summary passes today and is rejected with the predicate; a healthy summary still passes; a first compaction with no previous checkpoint is unaffected; a checkpoint whose objective is already "(none)" is unaffected; a compaction after a user turn is unaffected; a reply carrying only a legacy heading is still rejected. Six of six.

    A second bug worth its own issue

    The coarse step. When the newest exchange is the only exchange, the loop cannot shorten anything older first, so it wipes detail inside the exchange the summary most needs while about 40k tokens of room go unused. Your incident has exactly that shape, which is why the compaction request looks nothing like the conversation it summarized. Shortening within an exchange, clipping the largest tool results before the rest, would recover the room. It is not required to stop the silent loss, but it is a real second defect and it is the one that made the request numbers look impossible.

    Keep this open as a bug. The predicate cannot tell a usable summary from one that says there is no task, and the failure path you asked for already exists, so changing one boolean is not a design decision.

    The cross-session version of the same loss is the problem I built MemoryRouter for: the conversation kept outside the context window and across compaction, so a checkpoint that comes back thin is not the only copy of the work. https://docs.memoryrouter.ai/coding-tools?utm_source=github&utm_medium=issue&utm_campaign=anomalyco-opencode

  4. opencodejr commented on Nov 2, 2026

    @opencodejr

    Closing this as not planned, since we haven't heard back. On the latest dev (cb1e58f), the shortened compaction request still put the previous summary's ## Objective in front of the model. The task-empty summary was the model ignoring it. OpenCode stored it as completed because it only checks the template structure, which is how it was built.

    If you'd like OpenCode to reject or carry forward a summary that drops a previous Objective, please open a feature request. If you can show the compaction #2 request from your session was missing the previous summary, comment here or open a new issue with that and we'll look again. Related: #57240, #50816.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions