Skip to content

feat: Switch compaction model from session model - #3

Open
ryangamerdev wants to merge 23 commits into
devfrom
feature/compaction-model
Open

feat: Switch compaction model from session model#3
ryangamerdev wants to merge 23 commits into
devfrom
feature/compaction-model

Conversation

@ryangamerdev

Copy link
Copy Markdown

Issue for this PR

Closes anomalyco#13946
Closes anomalyco#12135
Related: anomalyco#14368, anomalyco#14259, anomalyco#14233

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Adds a dedicated compaction model selector so users can run one model for chat and a different model for summarization. Run Claude Sonnet or Opus 4.6 for interactive coding while compacting with Zen Big Pickle (free) to eliminate compaction cost entirely, or GPT-5.3 Codex (400k context) for detail-oriented summaries that capture nuance your session model would lose — with headroom to compact sessions that have already exceeded their primary model's context limit.

For best results use this enhanced compaction prompt: anomalyco#14662

Why not just use agent.compaction.model in config?

The existing agent.compaction.model config option is static — it requires editing opencode.json and is fixed for the lifetime of the config. This PR adds runtime switching without touching config files, matching how the primary model switcher works:

  • Switch compaction models on the fly without restarting or editing config
  • Different sessions can use different compaction models in the same instance
  • Browse available models with the same familiar picker UI
  • See the active compaction model in the prompt footer at a glance

Both approaches coexist. Model resolution priority: TUI selection > config file (agent.compaction.model) > session model. When no TUI selection is made, the config-based approach works exactly as before. Nothing is broken.

Features:

  • Reuses existing provider infrastructure — same providers, API keys, and auth as the primary model picker
  • /compaction-models slash command and "Switch compaction model" in the command menu
  • Model resolution priority: compaction model override > agent config > session model
  • Compaction model preference stored via kv.signal in kv.json, matching how all other TUI settings are persisted
  • Prompt footer displays active compaction model when set
  • When unset, behavior is identical to upstream

Changes:

  • DialogModel accepts target="compaction" prop — no duplicate component
  • SessionCompaction.process() accepts and resolves the override model
  • CompactionPart schema extended with optional compactionModel field
  • compaction_model_list keybind added (default: none)
  • SDK regenerated via ./script/generate.ts

How did you verify your code works?

Tested with Claude Sonnet 4.5 (chat) + GPT-5.3 Codex (compaction). Confirmed correct model at each pipeline stage via [compaction-model] log entries. Compaction completed successfully, session resumed on the original model. Also tested auto-compaction with no override set to verify identical upstream behavior. Verified that existing agent.compaction.model config continues to work when no TUI override is set.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

image
image
image

@ryangamerdev
ryangamerdev force-pushed the feature/compaction-model branch 3 times, most recently from 00b3b7d to 80b6e35 Compare March 1, 2026 03:14
@ryangamerdev
ryangamerdev force-pushed the feature/compaction-model branch from 80b6e35 to 8756834 Compare March 9, 2026 02:30
ryangamerdev and others added 23 commits March 15, 2026 12:33
Collapse compaction mode:
- Selectively compresses oldest 65% of tokens instead of entire conversation
- Merges historical summaries for continuity (configurable: previousSummaries)
- Places summary at correct breakpoint position in timeline
- TUI toggle cycles standard -> collapse -> float via command palette
- insertTriggers=false prevents timestamp collision infinite loops
- Preserves real user messages; only deletes synthetic trigger messages

Float compaction mode:
- Sub-collapses oldest conversation chains before overflow evaluation
- Chain detection requires 2+ assistant messages (skips simple Q&A pairs)
- bookend algorithm by default; configurable per chainThreshold
- Soft-deletes sub-collapsed messages with flux=compacted (not hard-delete)
- summary:true flag on sub-collapse result prevents re-trigger loops
- detectChains skips already-processed messages (summary:true or flux set)
  to prevent infinite sub-collapse loop
- Token adjustment accounts for sub-collapse savings to prevent re-trigger
- Reloads TUI messages after sub-collapse via session.compacted event
- Re-parents orphaned chain messages after mid-chain split
- splitChainMinThreshold gate prevents processing chains too small for
  meaningful sub-collapse

Sub-collapse prompt:
- Extracts only information lost if messages were deleted (not replacement)
- Anti-repetition: user request and earlier summaries marked REFERENCE ONLY
- Structured extraction: final artifacts, critical determinations,
  non-obvious discoveries, final state
- Explicit discard list: intermediate attempts, debugging steps, narration
- Produces factual statements, not narrative or conversational response

Knowledge pack support:
- KPs stored as flux:knowledge user messages at time_created=1,2,...
- Loaded from ~/.config/opencode/llm_knowledge_packs/ as .yaml/.yml via Bun.YAML
- Keyed by name@version throughout; idempotent inject on every message send
- filterCompacted skips flux=knowledge messages to prevent duplication
- KP messages prepended explicitly via KnowledgePack.fromSession() before
  filterCompacted result (KPs sit before compaction breakpoint so
  filterCompacted never returns them)
- toModelMessages prepends KPs first, then === USER MESSAGE === delimiter,
  then skips flux-tagged messages in normal loop
- Trailing newline on delimiter prevents concatenation with first user message
- Sidebar KP section: collapsed view shows active packs, expanded shows full
  library with click-to-toggle enable/disable
- Sidebar reactively updates via kpMessageCount memo on sync store changes
- Dedicated API endpoints: GET/POST/DELETE /:sessionID/knowledge-packs

Config options added:
  compaction.method: standard | collapse | float
  compaction.trigger: overflow threshold (default 0.85)
  compaction.extractRatio: fraction to extract (default 0.65)
  compaction.recentRatio: recent context reference (default 0.15)
  compaction.summaryMaxTokens: target summary size (default 10000)
  compaction.previousSummaries: history to merge (default 3)
  compaction.insertTriggers: whether to create trigger messages (default false for collapse/float)
  compaction.float.chainThreshold: chains to maintain (default 3)
  compaction.float.algorithm: bookend | full | minimal (default bookend)
  compaction.float.subCollapseSummaryMaxTokens: target tokens for sub-collapse
Adds a `minFloat` parameter (default 0.6) that gates sub-collapse
evaluation in float compaction mode based on actual context window usage.

Two guard points are added:

1. Pre-check gate: before any chain detection, the initial token count
   (input + cache.read + cache.write + output from the last finished
   assistant message) is compared against the context limit. If usage is
   below minFloat the entire sub-collapse path is skipped and the
   function returns early with subCollapsed: false.

2. Per-chain gate: sub-collapse chains are processed one at a time via
   a recursive collapseNext() function. After each successful collapse,
   messages are reloaded and token usage is re-estimated via
   estimateMessageTokens(). If the estimated usage fraction drops below
   minFloat before the next chain is evaluated, recursion stops. This
   means five chains above threshold may result in only one or two
   collapses if the first brings usage below minFloat.

The recursive design replaces the previous single-chain execution and
avoids let/mutation in favour of the immutable patterns preferred by
the style guide.

Config schema adds minFloat to compaction.float in config.ts with a
0..1 range validator. Can be overridden in opencode.json:

  { "compaction": { "float": { "minFloat": 0.5 } } }

The call site in prompt.ts now passes tokens and contextLimit to
floatModePreCheck so the real API-reported token counts are used for
the initial check rather than an estimate.
Previously, after each sub-collapse in collapseNext(), the token count for
the next minFloat evaluation was re-estimated by summing estimateMessageTokens()
across the entire reloaded message list. This was flawed in two ways:

1. estimateMessageTokens() only counts text parts and completed tool parts.
   It silently ignores reasoning parts, step-start markers, compaction parts,
   and system/cache overhead — so the re-estimate was always lower than the
   real context usage, making the minFloat gate think context had dropped
   further than it actually had.

2. Because the re-estimate was consistently too low, the gate would pass on
   every iteration and sub-collapse would continue collapsing all qualifying
   chains in sequence regardless of whether the context had actually dropped
   below minFloat — defeating the purpose of the per-chain check entirely.

Fix: replace the full re-estimation with a delta calculation:

  nextTokenCount = tokenCount - chain.chainTokens + result.summaryTokens

- chain.chainTokens is the token cost of the collapsed chain as measured by
  detectChains() using estimateMessageTokens() scoped to chain messages only.
  Using the same estimator for both the baseline and the deduction keeps the
  accounting consistent — any systematic under/over-count cancels out.

- result.summaryTokens is the actual output token count reported by the model
  for the summary it generated, so the added-back tokens are precise.

The reloaded message list is still fetched after each collapse so that
shouldFloatSubCollapse() -> detectChains() sees the updated conversation
state for chain detection. Only the token count tracking is changed — it
now uses the running delta rather than a full re-scan.

Added a log.info() line after each collapse emitting tokensBefore,
chainTokensRemoved, summaryTokensAdded, tokensAfter, and usedFractionAfter
so the accounting can be verified during testing.
The minFloat gate in collapseNext was comparing against stale token counts
after the first chain collapse because the delta reduction only existed in
memory — it was never written back to the database.

Root cause: process() (the main collapse compaction) works by finding the
chronologically last real finished assistant message after each collapse and
patching its stored tokens field directly in the database, stuffing the
post-collapse total into tokens.cache.read. This is what isOverflow() and
the prompt loop read on every iteration via lastFinished.tokens. collapseNext
was only tracking the reduction in memory via nextTokenCount. On the second
and subsequent collapseNext iterations, lastFinished.tokens was read fresh
from the database and still held the original pre-collapse values, so the
minFloat check always saw the same high initial usage and never stopped.

Fix: after each successful executeSubCollapse, load all session messages,
find the last real finished assistant message (excluding the new summary by
its ID, and requiring finish to be set), and patch its stored token counts
using the same formula process() uses:

  newTotal = currentTotal - chain.chainTokens + summaryTokens
  tokens.input = 0
  tokens.cache.read = max(0, newTotal - tokens.output)
  tokens.cache.write = 0
  tokens.output = unchanged
  tokens.reasoning = unchanged

Writing via Session.updateMessage() persists the reduction to SQLite and
fires message.updated to the TUI, so the sidebar context percentage now
updates after each chain collapse rather than only at the end. The
nextTokenCount delta is kept for the immediate recursive minFloat check
within the same collapseNext call since the database write is async and
the next iteration reads lastFinished fresh anyway.
When a subagent session is created via the Task tool (e.g. explore,
general, or any user-defined subagent), its context window starts empty.
The existing KP injection in loop() only auto-injects packs from the
filesystem default dir and config-declared packs — it has no visibility
into knowledge packs that were manually enabled in the parent session
via the sidebar (flux:knowledge messages stored in the parent session DB).

This change ensures subagent sessions inherit all active knowledge packs
from their parent session, giving them the same knowledge context the
parent LLM has.

Changes:

knowledge-pack.ts — add KnowledgePack.copyFromParent()
  Reads the parent session's active flux:knowledge messages via the
  existing fromSession() helper and writes them verbatim into the child
  session using the same Session.updateMessage / Session.updatePart
  pattern used by inject() and add(). Fully idempotent: deduplicates
  by agent key (kp:<name>@<version>) so packs already present in the
  child (e.g. auto-injected from defaultDir) are not duplicated. The
  rendered [KNOWLEDGE PACK: ...] content is copied exactly so the
  subagent sees the same formatted text as the parent.

prompt.ts — call copyFromParent() in loop() for child sessions
  Added after the existing KP injection block (defaultDir inject +
  config packs auto-enable) so all three sources are applied in order:
    1. inject()       — filesystem packs from defaultDir / config paths
    2. configPacks    — config-declared packs with enabled: true
    3. copyFromParent — manually sidebar-enabled parent session packs
  Gated on session.parentID so it only runs for child sessions and is
  a no-op for top-level sessions (no extra DB reads in the normal path).

Covers all subagent types (explore, general, user-defined mode:subagent
and mode:all agents) because they all go through the same Task tool ->
Session.create -> SessionPrompt.prompt -> loop() code path.
Knowledge packs can now declare per-agent system prompt overrides in
their YAML under the `agent` field:

  agent:
    explore:
      prompt: |-
        You are a coder tool specialist...

When a pack containing agent overrides is active in a session (either
via config `knowledge.packs[].enabled: true` or enabled from the
sidebar), the matching agent's built-in system prompt is replaced with
the KP-supplied prompt for the duration of that session.

Both enablement paths produce identical flux:knowledge messages in the
session DB, which is the single source of truth agentPrompts() reads.
The global Agent registry is never mutated — the override is applied
per loop iteration as a shallow clone of the Agent.Info object.

Changes:

packages/opencode/src/session/knowledge-pack.ts
- KPFile internal type: add `agent?: Record<string, { prompt?: string }>`
  field so the YAML parser captures agent overrides at load time
- Pack exported type: add matching `agent` field so callers can
  inspect overrides after load()
- load(): forward kp.agent into the Pack object alongside existing fields
- agentPrompts(sessionID): new exported function that
  1. reads all flux:knowledge messages from the session to determine
     which packs are active (strips the "kp:" prefix to get name@version)
  2. loads pack files from both defaultDir() and libraryDir() so it
     covers auto-injected packs (kp/) and sidebar-enabled packs
     (llm_knowledge_packs/) in one pass
  3. returns a merged Record<agentName, prompt> where later packs in
     filesystem order win if multiple packs override the same agent

packages/opencode/src/session/prompt.ts
- loop(): resolve agentBase via Agent.get() then call
  KnowledgePack.agentPrompts(sessionID); if the resolved agent name has
  a KP override, shadow agentBase with a spread that replaces only the
  prompt field before passing agent into the rest of the loop iteration
  (insertReminders, resolveTools, processor.process)
After any onMouseDown in the sidebar, opentui clears
currentFocusedRenderable to null because sidebar box elements are not
focusable renderables. Nothing restores it:
  - autoFocus is false in the renderer (app.tsx:183)
  - the prompt's createEffect only re-focuses when visible changes,
    which sidebar clicks do not affect
  - dialog.refocus() only fires on dialog close/escape, not sidebar clicks
  - no sidebar onMouseDown handler called focus() on anything

Result: all keyboard input was silently discarded after any sidebar
click (toggle pack, expand/collapse section, dismiss getting-started).
The global useKeyboard handlers still fired but the textarea was
unfocused so typed characters never reached it, appearing as a total
freeze.

Fix: import usePromptRef in sidebar.tsx, add a refocusPrompt() helper
that calls promptRef.current?.focus(), and call it from every
onMouseDown handler in the component:
  - MCP section header collapse toggle
  - Knowledge Packs expand/collapse toggle
  - Knowledge Pack row enable/disable toggle
  - LSP section header collapse toggle
  - Todo section header collapse toggle
  - Modified Files section header collapse toggle
  - Getting Started dismiss button

PromptRefProvider wraps the entire App tree (app.tsx:157) so
usePromptRef() is always in scope from the sidebar.
The previous commit applied refocusPrompt() to all sidebar onMouseDown
handlers. Scope it back to only the two KP-specific handlers: the pack
row enable/disable toggle and the expand/collapse button. These are the
only new sections added as part of this feature. All other sidebar
sections are unchanged from upstream.
…lapse compaction

When a user types a message while the agent is still running, opencode
re-parents subsequent assistant messages to the new user message. This
caused detectChains to split what is logically one continuous work session
into multiple separate chains, leading float sub-collapse to summarize
them independently and lose cross-chain context.

Fix: instead of breaking the chain walk on any user message, absorb
mid-run user interjections (non-compaction-trigger user messages) into
the current chain by tracking all chain user IDs in a Set. Assistant
messages parented to any of those user IDs are still recognised as part
of the same chain. A compaction trigger (parts.some p.type===compaction)
still terminates the chain as before.

Affects both float compaction (shouldFloatSubCollapse) and collapse
compaction (blocking chain detection at extract boundary).
…ModelMessages

Knowledge pack messages were loaded via KnowledgePack.fromSession() and
stored in a sessionMessages variable that was never used. Both the plugin
transform hook and toModelMessages received msgs without KP messages,
causing KP content to be invisible to the LLM when plugins (like DCP)
were active.

Replace the dead sessionMessages clone with a direct unshift of KP
messages into msgs so they flow through the plugin transform hook and
into toModelMessages correctly.
Three changes to sidebar.tsx:

1. Remove double-refetch race in togglePack: the explicit refetchActive()
   call was redundant (kpMessageCount memo already triggers reactive
   refetch via sync store) and could destroy/recreate DOM elements while
   opentui's native layer was still processing the mouse event, corrupting
   the internal focus state machine and leaving currentFocusedRenderable
   permanently null.

2. Defer refocusPrompt with setTimeout(1) to match the dialog system's
   pattern -- opentui's native layer does post-callback processing (hover
   recheck, mouseUp dispatch) that can overwrite a synchronous focus().
   Add a 50ms safety net to catch focus loss from async re-renders.

3. Add refocusPrompt() to all sidebar onMouseDown handlers (MCP, LSP,
   Todo, Diff, Getting Started) that were missing it, preventing focus
   loss when clicking any sidebar section.
…tate

The previous fix removed refetchActive() entirely to prevent the DOM
destruction race, but this meant the UI never updated the enabled/disabled
visual state until the reactive kpMessageCount chain eventually propagated.

Now togglePack chains .then(() => setTimeout(() => refetchActive(), 1))
so the refetch happens after both the server response AND outside
opentui's mouse event processing window.
…emove

When the user toggles a knowledge pack from the sidebar, opencode writes
to the local project config. Since opencode does not merge the
knowledge.packs array between global and project configs (project array
fully overrides global), any globally-configured packs would be silently
lost once the project file defines that key.

Fix mirrors the approach used by --kp-add/--kp-remove in utils/coder:
- ADD: reads global config packs and seeds them into the project file
  first, then appends the new pack. Globally-enabled packs that are not
  yet in the project file are mirrored with a log message.
- REMOVE: reads only the project file and deletes the entry entirely.
  No global mirroring on remove (user only asked to remove one pack).

Added Config.getProject() to read the local project config file without
merging global config, required to inspect the current project state
before seeding.
Config.update() was writing to Instance.directory/config.json but the
config loader only scans for opencode.jsonc and opencode.json — it never
reads config.json. So knowledge pack changes written via the sidebar were
silently dropped on the next session.

Write to {worktree}/.opencode/opencode.json instead, which is the path
the loader walks. Filesystem.write already creates parent dirs recursively
so .opencode/ is created automatically on first write.

Also fix Config.getProject() to read from the same corrected path.
If the project config has no packs array, or the pack being removed is
not present in it, skip the write entirely. Avoids creating
.opencode/opencode.json with an empty packs array when the user
removes a globally-sourced pack that was never persisted locally.
Float sub-collapse was only reachable mid-chain (when finish=tool-calls)
because the prompt loop exits early on stop finish before reaching the
float pre-check at line 570. This meant complete chains accumulated
untouched until context hit the collapse trigger threshold (0.87).

Now the float pre-check runs inside the stop-exit branch before breaking,
so sub-collapse fires on complete chains at every turn boundary. The loop
still breaks after compacting -- we do not re-invoke the LLM -- leaving
the context trimmed for when the next user message arrives.
…n count

lastFinished was scanned backwards for the first assistant with any finish
value (stop, tool-calls, end-turn, etc.). In long agentic sessions the last
stop finish may be dozens of steps behind the current position -- e.g. 139k
tokens at 69.7% when the session is actually at 174k (87%). Passing those
stale tokens to floatModePreCheck caused the minFloat gate (0.7) to fail
even though the session was well into the float window.

Introduce lastWithTokens: the most recent assistant with non-zero total
tokens (skipping aborted messages with total=0). Use lastWithTokens.tokens
at all three token-sensitive sites: the stop-exit float pre-check, the
mid-chain float pre-check, and the isOverflow check. lastFinished is kept
for its existing use in the message-ordering filter at step > 1.
…low token count"

This reverts commit d199a331089f1df635b11454043897fef3b57099.
…tant has finish=tool-calls

Root cause of float compaction never firing (chainCount always 1):

commit e28c27f70 introduced chainUserIds to handle genuine mid-run user
interjections -- cases where the user types while the agent is still
running tool calls, causing subsequent assistants to be re-parented to
the new user message instead of the original chain anchor. The intent
was correct: absorb those user messages into the current chain so the
parentID walk continues through the re-parented assistants.

However, the implementation had no guard on WHICH user messages qualify
as interjections. It absorbed every user message encountered during the
chain walk, regardless of whether the agent was actually mid-run or had
already finished. This caused detectChains to treat sequential
independent user turns (e.g. user sends a new task after agent stops)
as interjections, merging all of them into one giant chain.

Observed in session ses_33f374e7bffe (494 messages, post-compaction
section rows 329-494): detectChains produced chainCount=1 spanning the
entire session. With chainThreshold=2, shouldFloatSubCollapse returned
null on every call. Float never fired despite 76% context usage.

Confirmed from actual DB data: of the 11 user messages in rows 329-395,
only ONE is a genuine mid-run interjection (row 393, preceded by a
tool-calls assistant at row 392). All others are preceded by stop,
end-turn, or summary assistants -- independent turns, not interjections.

The fix: before absorbing a user message as an interjection, check that
messages[j-1] is an assistant with finish==='tool-calls'. Any other
predecessor (stop, end-turn, empty finish, summary) means the agent had
already completed its turn, so this user message starts a new
independent chain instead.

With this fix, detectChains correctly breaks at each independent turn
boundary, producing 7+ valid chains in the post-compaction section.
chains.length > chainThreshold(2) becomes true, shouldFloatSubCollapse
returns the oldest chain, and float sub-collapse fires as designed.

This fix works in tandem with 2f2c86cc1 (stop-exit float pre-check):
- This commit fixes chain counting so shouldFloatSubCollapse returns
  non-null (was the primary blocker)
- 2f2c86cc1 ensures floatModePreCheck runs at stop/end-turn exits, not
  only during tool-calls continuations (secondary coverage gap)
bea7aa2dd added diagnostic log statements to detectChains to make the
float compaction decision process greppable from dev.log:

  grep 'COLLAPSE detectChains' ~/.local/share/opencode/log/dev.log

Three tags were added:

  COLLAPSE detectChains chain start
    Logged when a new chain candidate begins (outer loop user message).
    Fields: userIdx, userId.

  COLLAPSE detectChains user boundary
    Logged at every inner-loop user message encountered during a chain
    walk. Shows whether it was absorbed as a mid-run interjection or
    broke the chain.
    Fields: userIdx, userId, prevRole, prevFinish, isInterjection.
    isInterjection=true means prev assistant had finish=tool-calls and
    the user message was absorbed into the current chain.
    isInterjection=false means the chain walk stopped here.

  COLLAPSE detectChains chain end
    Logged after the inner walk finishes, before the chain is accepted
    or discarded. Shows assistant count and whether the chain is valid
    (>= 2 assistants required).
    Fields: userIdx, userId, assistants, valid.

These logs were added to diagnose the float sub-collapse not firing.
The root fix is in 21f44b60b (interjection guard) and 2f2c86cc1
(stop-exit float pre-check). Compaction confirmed working: session
ses_33f374e7bffeJ9EzY5j3ZFDJ9y ran collapse at 87%, reduced from
174,350 to 134,429 tokens, then float correctly stayed idle below
minFloat=0.7 as context refilled.
Adds a dedicated compaction model selector so users can run one model
for chat and a different model for summarization (e.g. Claude Sonnet for
interactive coding, Zen Big Pickle free tier for zero-cost compaction).

Model resolution priority: TUI selection > agent.compaction.model config
> session model. When no TUI selection is set, behavior is identical to
upstream.

Changes:
- DialogModel gains target="compaction" prop — no duplicate component
- SessionCompaction.process() accepts optional compactionModel override
- CompactionPart schema extended with optional compactionModel field
- compaction_model_list keybind added (default: none)
- /compaction-models slash command and command menu entry
- local.model.compaction context backed by kv.signal('compaction_model')
- Prompt footer shows active compaction model when set
- SDK regenerated via ./script/generate.ts
@ryangamerdev
ryangamerdev force-pushed the feature/compaction-model branch from 8756834 to 4edb177 Compare August 26, 2026 01:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant