Skip to content

fix(think): preserve regeneration branch context - #2038

Open
ben-reitz wants to merge 2 commits into
mainfrom
fix/think-regeneration-context
Open

fix(think): preserve regeneration branch context#2038
ben-reitz wants to merge 2 commits into
mainfrom
fix/think-regeneration-context

Conversation

@ben-reitz

@ben-reitz ben-reitz commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Bug

Regeneration means “answer this user message again” while keeping the previous answer available. Think already stored the new answer as a sibling, but it generated that answer from the wrong history.

Stored conversation:

Earlier history → User message
                  └─ Old answer  ← active leaf

Expected model input: Earlier history → User message
Actual model input:   Earlier history → User message → Old answer → “Continue…”

The result looked like a new branch in storage, but the model had been asked to continue the old answer instead of producing an independent alternative.

Fix

Think now uses one server-validated branch point for both model input and output storage. For regeneration, model history ends at the selected user message, and the generated answer is stored beneath that same message.

Earlier history → User message
                  ├─ Old answer  ← preserved
                  └─ New answer  ← generated without seeing the old answer

The selected branch is also preserved through hooks, retries, context compaction, Durable Object eviction recovery, and partial-response reconstruction. Re-delivered recovery work is idempotent, so it cannot create duplicate alternatives.

Summary

  • generate regenerated responses from the server-selected branch point instead of the previous assistant leaf
  • preserve the selected branch through lifecycle hooks, retries, context-overflow compaction, and chat recovery
  • persist recovered responses as sibling branches and make branch-scoped recovery retries idempotent
  • add model-boundary, Session, and forced-eviction regression coverage

Fixes #2028

Testing

  • pnpm run check
  • pnpm --dir packages/agents run test:chat — 514 passed
  • pnpm --dir packages/think run test:workers — 923 passed

@changeset-bot

changeset-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 935bbb7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
agents Patch
@cloudflare/think Patch
@cloudflare/agent-think Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Aug 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@2038

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@2038

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@2038

hono-agents

npm i https://pkg.pr.new/hono-agents@2038

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@2038

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@2038

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@2038

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@2038

commit: 935bbb7

@ben-reitz
ben-reitz marked this pull request as ready for review August 5, 2026 08:44
devin-ai-integration[bot]

This comment was marked as resolved.

@ben-reitz
ben-reitz marked this pull request as draft August 5, 2026 13:03
@ben-reitz
ben-reitz marked this pull request as ready for review August 6, 2026 11:04
@ben-reitz
ben-reitz force-pushed the fix/think-regeneration-context branch 3 times, most recently from 24f92d0 to 48e57e5 Compare August 12, 2026 08:41
@ben-reitz
ben-reitz force-pushed the fix/think-regeneration-context branch from 48e57e5 to 935bbb7 Compare August 13, 2026 10:21
@ben-reitz ben-reitz added the ready-for-review-and-merge PR is ready for review and/or merge label Aug 13, 2026

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +778 to 787
// Iterative compaction extends only an overlay visible on this branch.
// Other sibling branches may have unrelated overlays in the same session.
const existing = (await this.getCompactions()).filter(
(compaction) =>
historyIds.has(`${COMPACTION_PREFIX}${compaction.id}`) ||
(historyIds.has(compaction.fromMessageId) &&
historyIds.has(compaction.toMessageId))
);
const fromId =
existing.length > 0 ? existing[0].fromMessageId : result.fromMessageId;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Compacting a second conversation branch silently un-compacts the first one

Summaries stored for one conversation branch are matched only by their starting message (the filter added at packages/agents/src/experimental/memory/session/session.ts:780-785 selects an overlay to extend), so once a second branch is summarized from the same starting point the first branch's summary stops being applied at all.

Impact: A conversation that was already shortened silently goes back to its full length, so the next request can blow the model's context window again even though compaction "succeeded".

Overlay selection matches on start id only and ignores whether the range ends on this path

compact(leafId) now compacts a specific root-to-leaf branch and stores an overlay (fromMessageId, toMessageId). Because createCompactFunction always starts the summary at the first non-pinned message (packages/agents/src/experimental/memory/utils/compaction-helpers.ts:564), two sibling branches compacted independently produce two overlays with the SAME fromMessageId but toMessageIds on different branches.

Rendering in packages/agents/src/experimental/memory/session/providers/agent.ts:539-546 picks matching[matching.length - 1] — the newest overlay with that fromMessageId — without checking that its toMessageId is on the current path. When it is not (endIdx === -1), the branch renders with NO overlay at all, so the older, correct overlay for that branch is never applied.

The new filter in Session.compact then also fails to see an overlay id in historyIds, and falls back to the raw from/to clause, so a subsequent compaction on that branch keeps re-adding overlays that will keep losing the tie-break.

A fix belongs in applyCompactions: choose the widest matching compaction whose toMessageId is actually present on the path.

Prompt for agents
Session.compact(leafId) now supports compacting a specific branch, which makes it possible for two sibling branches to hold compaction overlays that share the same fromMessageId (createCompactFunction always starts at the first non-pinned message). AgentSessionProvider.applyCompactions selects overlays by fromMessageId and picks matching[matching.length - 1] (the newest) without verifying that its toMessageId exists on the path being rendered. When the newest overlay belongs to a sibling branch, endIdx is -1 and NO overlay is applied, so the branch that does have a valid overlay silently renders uncompacted. Consider filtering candidate compactions to those whose toMessageId is present in the current path before choosing the widest/newest one, and add a test covering two branches compacted from the same starting message.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 15828 to +15829
persistOrphanedStream: (streamId) =>
this._persistOrphanedStream(streamId),
this._persistOrphanedStream(streamId, this._activeTurnHistory?.leafId),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Rebuilt partial answer after hibernation can still be filed under the previous answer instead of beside it

The branch chosen for a regenerated answer is read from memory (this._activeTurnHistory?.leafId at packages/think/src/think.ts:15829) at a point that is only ever reached after the agent has restarted and that memory is empty, so a rebuilt partial answer is filed at the end of the current conversation rather than next to the previous answer.

Impact: A regeneration interrupted by a restart can reappear as a continuation of the old answer instead of an alternative to it, unless chat recovery happens to rebuild it first.

Why the in-memory selection is always undefined on this path

ResumeHandshake.handleResumeAck only calls persistOrphanedStream when replayChunks returns a stream id, which happens exclusively in the !this._isLive branch (packages/agents/src/chat/resumable-stream.ts:524-543) — i.e. the stream was restored from SQLite after hibernation and has no live reader. In that isolate _activeTurnHistory was never assigned (it is only set in _prepareInferenceInvocation, packages/think/src/think.ts:5632), so the argument is always undefined and _persistOrphanedStream appends at the latest leaf (the previous answer).

With the default chatRecovery = true the wake-time fiber recovery usually persists the orphan first (with snapshot.historyLeafId), masking this; with chatRecovery disabled there is no fiber, and the ACK path is the only persister, so the regenerated partial is attached under the previous answer. Carrying the branch leaf durably (e.g. on the stream metadata row, alongside message_id) would make this path work for real.

Prompt for agents
In Think._resumeHandshake the orphan-persist seam passes this._activeTurnHistory?.leafId as the parent for the reconstructed message. That callback only runs for an orphaned stream (ResumableStream.replayChunks returns a stream id only when !_isLive, i.e. the stream was restored from SQLite after hibernation), at which point the in-memory _activeTurnHistory is always undefined, so the branch parent is never applied and the partial is appended under the current active leaf (the previous answer) for a regeneration turn. Consider persisting the branch leaf durably with the stream (e.g. an extra column on cf_ai_chat_stream_metadata next to message_id, written in _startResumableStream) and reading it back in _persistOrphanedStream, so both the wake-recovery path and the resume-handshake path reconstruct the partial as a sibling branch.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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

Labels

ready-for-review-and-merge PR is ready for review and/or merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Think regeneration includes the previous response in the model prompt

1 participant