Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
416 changes: 416 additions & 0 deletions devlog/2026-06-07_model-decompress-tool/DESIGN.md

Large diffs are not rendered by default.

72 changes: 72 additions & 0 deletions devlog/2026-06-07_model-decompress-tool/REQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# REQ - Expose decompress tool to AI model

- Task ID: `2026-06-07_model-decompress-tool`
- Home Repo: `opencode-acp`
- Created: 2026-06-07
- Status: Draft
- Priority: P1
- Owner: ranxianglei
- References: https://github.com/ranxianglei/opencode-acp/issues/7

## 1. Background & Problem Statement

- **Context**: ACP currently exposes only the `compress` tool to the AI model. Decompression is available only via the `/acp decompress` slash command, requiring human intervention.
- **Current behavior (symptom)**: The model can compress conversation ranges but cannot restore them when it needs to reference original details. Users must manually run `/acp decompress` to restore compressed content.
- **Expected behavior**: The AI model should have a `decompress` tool that allows it to restore previously compressed blocks, enabling fully autonomous context management — compress AND decompress.
- **Impact**: Medium — improves model autonomy and reduces user intervention. The model can make more nuanced context decisions (compress early, decompress when original detail is needed).

## 2. Reproduction (if applicable)

- **Environment**: Any ACP-enabled session with compression active
- **Minimal reproduction steps**:
1. Start a session with ACP enabled
2. Let the model compress several conversation ranges
3. Later, the model needs to reference original compressed content
4. Model has no tool to restore it — user must manually run `/acp decompress <n>`
- **Relevant configuration**: Default ACP config (no special settings needed)

## 3. Constraints & Non-Goals

- **Constraints**:
- Backward compatibility: Must not change persisted state format, existing `/acp decompress` command must continue working
- Must not expose `recompress` to the model (out of scope — recompress is costlier and less predictable)
- Tool must respect existing permission system (`compress.permission` config)
- Must handle context inflation — model should be aware decompressing restores full content
- Must follow existing tool registration pattern from `compress` tool
- **Non-Goals** (explicitly out of scope):
- Exposing `recompress` to the model (can be considered later)
- Changing the existing `/acp decompress` slash command behavior
- Adding new config options beyond what's needed for the tool
- Modifying the GC system

## 4. Acceptance Criteria (must be testable)

- **Correctness**:
- [ ] New `decompress` tool is registered and callable by the model
- [ ] Tool accepts `blockId` parameter (block reference like "b0", "b1", etc.)
- [ ] Tool deactivates the specified block(s) and persists state
- [ ] Next message-transform pipeline restores original messages
- [ ] Nested blocks are handled correctly (same as `/acp decompress`)
- [ ] Tool returns informative result message (restored messages count, tokens)
- [ ] Tool is listed in `protectedTools` defaults so compress won't prune its output
- **Performance / Stability**:
- [ ] No race conditions with concurrent compress/decompress
- [ ] Tool execution is synchronous state mutation (same pattern as compress)
- **Regression**:
- [ ] All existing tests pass
- [ ] `npm run typecheck` passes
- [ ] `npm run build` passes
- [ ] `/acp decompress` command still works unchanged

## 5. Proposed Approach

- **Affected modules & entry files**:
- `lib/compress/decompress.ts` — NEW: decompress tool implementation
- `index.ts` — register decompress tool alongside compress
- `lib/prompts/system.ts` — update system prompt to mention decompress
- `lib/prompts/extensions/tool.ts` — add decompress format extension
- `lib/prompts/store.ts` — add decompress prompt to prompt store (optional)
- `lib/config.ts` — add decompress to protectedTools defaults
- `tests/` — new test file for decompress tool
- **Risks**: Low — core decompress logic already exists and is battle-tested in `decompress.ts` command
- **Rollback strategy**: Revert branch
79 changes: 79 additions & 0 deletions devlog/2026-06-07_model-decompress-tool/WORKLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# WORKLOG - Model-Exposed Decompress Tool

- Task ID: `2026-06-07_model-decompress-tool`
- Home Repo: `opencode-acp`
- Status: InProgress
- Updated: 2026-06-07 22:30

## 1. Summary

- **What was done**: Exposed `decompress` as a model-accessible tool alongside the existing `/acp decompress` command. Extracted shared logic from the command into `lib/compress/decompress-logic.ts` and created a new `lib/compress/decompress.ts` tool with decompress-specific prepare/finalize pipeline.
- **Why**: The model had asymmetric context management — it could compress but not decompress. When compressed details were needed, only human users could restore via `/acp decompress`. Now the model can autonomously restore compressed content.
- **Behavior / compatibility changes**: Yes — system prompt text changed ("ONLY tool" → "tools... are"), default `protectedTools` arrays gained `decompress`. Existing configs that override `protectedTools` won't auto-include `decompress`.
- **Risk level**: Medium — context inflation from decompress, GC interaction with reactivated blocks

## 2. Change Log

### Commits

| Commit | Description |
|--------|-------------|
| (pending) | feat: expose decompress tool to AI model |

### Key Files

- `lib/compress/decompress-logic.ts` — NEW: Shared decompress logic extracted from command (parseBlockIdArg, deactivateCompressionTarget, computeRestoredMessages, buildRestoredContentPreview)
- `lib/compress/decompress.ts` — NEW: Decompress tool with decompress-specific prepare/finalize, inline content preview
- `lib/compress/index.ts` — MODIFIED: Export createDecompressTool
- `lib/commands/decompress.ts` — MODIFIED: Refactored to use shared logic from decompress-logic.ts
- `index.ts` — MODIFIED: Register decompress tool + add to primary_tools
- `lib/prompts/system.ts` — MODIFIED: Base prompt updated to mention both tools
- `lib/prompts/extensions/system.ts` — MODIFIED: Added DECOMPRESS_SYSTEM_EXTENSION
- `lib/prompts/store.ts` — MODIFIED: Added decompressExtension to RuntimePrompts
- `lib/prompts/index.ts` — MODIFIED: renderSystemPrompt accepts decompress flag
- `lib/hooks.ts` — MODIFIED: Pass decompress=true when permission allows
- `lib/config.ts` — MODIFIED: Added "decompress" to DEFAULT_PROTECTED_TOOLS and COMPRESS_DEFAULT_PROTECTED_TOOLS
- `tests/decompress-logic.test.ts` — NEW: Tests for shared decompress logic

## 3. Design & Implementation Notes

- **Entry point / key function**: `createDecompressTool()` in `lib/compress/decompress.ts`
- **Key configuration items**: Reuses `compress.permission` for both tools
- **Key logic explanation**:
- Decompress-specific `prepareDecompressSession()` — NO dedup/purge, NO manual mode guard (decompress always allowed)
- Decompress-specific `finalizeDecompressSession()` — just saves state, NO compress notification
- Tool returns inline restored content preview (~2000 chars) so model can reason immediately without waiting for next turn
- Context usage before/after included in return value for model self-regulation
- Shared logic extracted to `decompress-logic.ts` — both command and tool use identical functions

## 4. Testing & Verification

### Build & Test Commands

```sh
npm run build
npm run typecheck
node --import tsx --test tests/*.test.ts
```

### Test Coverage

- New test files: `tests/decompress-logic.test.ts`
- Test count: 350 baseline + new tests
- Key scenarios verified: parseBlockIdArg, deactivateCompressionTarget, computeRestoredMessages, buildRestoredContentPreview, computeReactivatedBlockIds, findActiveParentBlockId

### Results

- **PASS**: typecheck clean, build success, all 350 baseline tests pass

## 5. Risk Assessment & Rollback

- **Risk points**: Context inflation from decompress, GC may truncate reactivated nested blocks sooner, model misuse (decompress without recompress)
- **Rollback method**: Revert commit, remove decompress tool registration
- **Compatibility notes**: System prompt text change may require prompt override update for users with custom prompts

## 7. Follow-ups

- [ ] Consider exposing recompress as model tool
- [ ] Consider resetting survivedCount on reactivated nested blocks
- [ ] Consider making inline content preview length configurable
9 changes: 7 additions & 2 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { Plugin } from "@opencode-ai/plugin"
import { getConfig } from "./lib/config"
import { createCompressMessageTool, createCompressRangeTool } from "./lib/compress"
import {
createCompressMessageTool,
createCompressRangeTool,
createDecompressTool,
} from "./lib/compress"
import {
compressDisabledByOpencode,
hasExplicitToolPermission,
Expand Down Expand Up @@ -84,6 +88,7 @@ const server: Plugin = (async (ctx) => {
config.compress.mode === "message"
? createCompressMessageTool(compressToolContext)
: createCompressRangeTool(compressToolContext),
decompress: createDecompressTool(compressToolContext),
}),
},
config: async (opencodeConfig) => {
Expand All @@ -104,7 +109,7 @@ const server: Plugin = (async (ctx) => {

const toolsToAdd: string[] = []
if (config.compress.permission !== "deny" && !config.experimental.allowSubAgents) {
toolsToAdd.push("compress")
toolsToAdd.push("compress", "decompress")
}

if (toolsToAdd.length > 0) {
Expand Down
119 changes: 16 additions & 103 deletions lib/commands/decompress.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import type { Logger } from "../logger"
import type { CompressionBlock, PruneMessagesState, SessionState, WithParts } from "../state"
import type { SessionState, WithParts } from "../state"
import { syncCompressionBlocks } from "../messages"
import { parseBlockRef } from "../message-ids"
import { getCurrentParams } from "../token-utils"
import { saveSessionState } from "../state/persistence"
import { sendIgnoredMessage } from "../ui/notification"
import { formatTokenCount } from "../ui/utils"
import {
getActiveCompressionTargets,
resolveCompressionTarget,
type CompressionTarget,
} from "./compression-targets"
import {
parseBlockIdArg,
findActiveAncestorBlockId,
snapshotActiveMessages,
deactivateCompressionTarget,
computeRestoredMessages,
computeReactivatedBlockIds,
} from "../compress/decompress-logic"
import { saveSessionState } from "../state/persistence"

export interface DecompressCommandContext {
client: any
Expand All @@ -21,78 +28,6 @@ export interface DecompressCommandContext {
args: string[]
}

function parseBlockIdArg(arg: string): number | null {
const normalized = arg.trim().toLowerCase()
const blockRef = parseBlockRef(normalized)
if (blockRef !== null) {
return blockRef
}

if (!/^[1-9]\d*$/.test(normalized)) {
return null
}

const parsed = Number.parseInt(normalized, 10)
return Number.isInteger(parsed) && parsed > 0 ? parsed : null
}

function findActiveParentBlockId(
messagesState: PruneMessagesState,
block: CompressionBlock,
): number | null {
const queue = [...block.parentBlockIds]
const visited = new Set<number>()

while (queue.length > 0) {
const parentBlockId = queue.shift()
if (parentBlockId === undefined || visited.has(parentBlockId)) {
continue
}
visited.add(parentBlockId)

const parent = messagesState.blocksById.get(parentBlockId)
if (!parent) {
continue
}

if (parent.active) {
return parent.blockId
}

for (const ancestorId of parent.parentBlockIds) {
if (!visited.has(ancestorId)) {
queue.push(ancestorId)
}
}
}

return null
}

function findActiveAncestorBlockId(
messagesState: PruneMessagesState,
target: CompressionTarget,
): number | null {
for (const block of target.blocks) {
const activeAncestorBlockId = findActiveParentBlockId(messagesState, block)
if (activeAncestorBlockId !== null) {
return activeAncestorBlockId
}
}

return null
}

function snapshotActiveMessages(messagesState: PruneMessagesState): Map<string, number> {
const activeMessages = new Map<string, number>()
for (const [messageId, entry] of messagesState.byMessageId) {
if (entry.activeBlockIds.length > 0) {
activeMessages.set(messageId, entry.tokenCount)
}
}
return activeMessages
}

function formatDecompressMessage(
target: CompressionTarget,
restoredMessageCount: number,
Expand Down Expand Up @@ -227,41 +162,19 @@ export async function handleDecompressCommand(ctx: DecompressCommandContext): Pr

const activeMessagesBefore = snapshotActiveMessages(messagesState)
const activeBlockIdsBefore = new Set(messagesState.activeBlockIds)
const deactivatedAt = Date.now()

for (const block of target.blocks) {
block.active = false
block.deactivatedByUser = true
block.deactivatedAt = deactivatedAt
block.deactivatedByBlockId = undefined

// [FIX Bug 10] Mark consumed inner blocks so syncCompressionBlocks won't re-activate them
for (const consumedId of block.consumedBlockIds) {
const consumedBlock = messagesState.blocksById.get(consumedId)
if (consumedBlock) {
consumedBlock.deactivatedByUser = true
}
}
}
deactivateCompressionTarget(messagesState, target)

syncCompressionBlocks(state, logger, messages)

let restoredMessageCount = 0
let restoredTokens = 0
for (const [messageId, tokenCount] of activeMessagesBefore) {
const entry = messagesState.byMessageId.get(messageId)
const isActiveNow = entry ? entry.activeBlockIds.length > 0 : false
if (!isActiveNow) {
restoredMessageCount++
restoredTokens += tokenCount
}
}
const { restoredMessageCount, restoredTokens } = computeRestoredMessages(
messagesState,
activeMessagesBefore,
)

state.stats.totalPruneTokens = Math.max(0, state.stats.totalPruneTokens - restoredTokens)

const reactivatedBlockIds = Array.from(messagesState.activeBlockIds)
.filter((blockId) => !activeBlockIdsBefore.has(blockId))
.sort((a, b) => a - b)
const reactivatedBlockIds = computeReactivatedBlockIds(messagesState, activeBlockIdsBefore)

await saveSessionState(state, logger)

Expand Down
Loading
Loading