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
2 changes: 1 addition & 1 deletion .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ jobs:
uses: actions/checkout@v7
with:
repository: LeXwDeX/OpenCode-GraphAgent
ref: 743d99ff4b79bcafeffc4d5e8624060b3af6ca13
ref: 8d9972908c308da1836a004cebe27c7c23db1acc
path: .host/opencode

- name: Setup host runtime
Expand Down
17 changes: 7 additions & 10 deletions .specgit.yaml
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
version: 1
delivery: request-safe-compression
delivery: pruning-continuity
context:
kind: branch
branch: fix/34-request-safe-compression
branch: fix/38-pruning-continuity
issues:
- 34
- 35
- 36
- 38
- 39
issueKinds:
- issue: 34
- issue: 38
kind: kind::fix
- issue: 35
kind: kind::fix
- issue: 36
- issue: 39
kind: kind::test
pr: 37
pr: 40
12 changes: 9 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Recent protection uses native step markers, or a whole assistant message when ma

Estimation includes full inputs even when the corresponding output already has a compacted marker. Known compaction/subtask parts use the host's fixed rendered text; interrupted tool errors include the output carried in metadata. Unrecognized media/content yields an unknown estimate, not zero tokens. An unsatisfied budget is reported; it never permits deleting other kinds of information.

Known user reference markers (`file` with `text/plain` or `application/x-directory`, and `agent`) are omitted by the host serializer. Their expanded content is carried in separate text parts and remains fully counted. The markers themselves are preserved. Attachments retained on already-compacted successful tools do not reach the model, so they do not invalidate that cleared-output estimate. This exception does not extend to live media or unfamiliar part/role combinations.

## Host contract and its limits

The V1 transform hook currently receives an empty input object. For ordinary requests, supported hosts resolve the model from the latest ordinary user's explicit model reference before invoking the transform. DCP reads that same provider/model from the host's configured catalog. Conflicting or absent session identity, absent model references, failed catalog reads and invalid limits all retain the original request.
Expand All @@ -34,6 +36,8 @@ min(model.limit.input ?? model.limit.context,

The output-token ceiling defaults to 32,000, honoring the host-process environment override when valid. The default `targetRatio` leaves headroom for system and tool definitions added later. This is an estimate, not the final provider-token count: later plugins may change model options, and system/tool definitions are not exposed by this hook. Native overflow handling remains necessary.

The host can act on the previous response's reported usage before invoking the next ordinary transform. Pruning is therefore not guaranteed an extra rescue pass before native compaction. In a small window, the protected recent steps plus system/tool overhead may already consume the usable capacity; preserving those steps can leave no opportunity to prune. Automatic compaction and continuation must be tested with increasing reported usage as well as deterministic projection tests. A successful continuation alone does not prove that every tool completed: assert tool status and exit code too.

Native compaction calls `experimental.session.compacting` before its messages transform. The plugin sets and consumes a session-specific skip before doing any catalog lookup or projection. The later compaction `chat.params` clears a guard left by an empty/unidentified summary history; it never supplies a budget for future chat. If compaction aborts before either call, the next identified request skips once and clears the guard. This may send extra history, but never a DCP-folded summary input.

A guard must never be evicted while projection continues. If pending-control capacity cannot retain a required compaction guard, projection fails open for the plugin instance and emits a diagnostic; reload the host instance to resume. Ordinary request execution continues. This protects summary fidelity with bounded memory.
Expand All @@ -53,11 +57,11 @@ npm run check:package

They cover long single-user tasks, complete-step protection, independent read pages and repeated calls, failed and interrupted tools, unknown inputs, model switching, ambiguous identity, one-request controls, capacity and commit failures.

The real-host suite pins [OpenCode-GraphAgent](https://github.com/LeXwDeX/OpenCode-GraphAgent) at `743d99ff4b79bcafeffc4d5e8624060b3af6ca13`. The source revision is checked before execution. Prepare a separate checkout:
The real-host suite pins [OpenCode-GraphAgent](https://github.com/LeXwDeX/OpenCode-GraphAgent) at `8d9972908c308da1836a004cebe27c7c23db1acc`. The source revision is checked before execution. Prepare a separate checkout:

```sh
git clone https://github.com/LeXwDeX/OpenCode-GraphAgent.git /tmp/dcp-host
git -C /tmp/dcp-host checkout 743d99ff4b79bcafeffc4d5e8624060b3af6ca13
git -C /tmp/dcp-host checkout 8d9972908c308da1836a004cebe27c7c23db1acc
cd /tmp/dcp-host
bun install --frozen-lockfile --ignore-scripts --filter './packages/opencode'
cd /path/to/opencode-dynamic-context-pruning
Expand All @@ -66,7 +70,9 @@ OPENCODE_SOURCE_ROOT=/tmp/dcp-host npm run test:host

Node runs the tests; Bun executes the real host workers, matching the host runtime. Component contract tests use real plugin loading/dispatch, message hydration, SQLite storage, provider transforms and SDK serialization. They seed deterministic history and substitute selected service boundaries to inspect exact hook behavior; the compaction component test records processor input instead of executing a model request.

The public HTTP test starts the host's complete default service graph through `Server.listen` and loads the built plugin directly from `opencode.json`. Only the external model HTTP/SSE endpoint is replaced. Public session APIs drive two concurrent 100-step read loops, small/large model budgets, model switching, native summarization and continuation. Assertions inspect actual outgoing model requests and publicly read persisted history. This covers lifecycle behavior that direct hook tests cannot prove.
The public HTTP scenarios start the host's complete default service graph through `Server.listen` and load the built plugin directly from `opencode.json`. Only the external model HTTP/SSE endpoint is replaced. The original concurrent 100-step scenario uses fixed low reported usage and disables automatic compaction to isolate model switching, manual native summarization and history fidelity.

Additional scenarios retain the host's automatic compaction/pruning defaults and report usage proportional to outgoing request size. At 64K, both ordinary prompts and file-reference prompts must exhibit DCP pruning before the first native summary, then finish after automatic continuation. At 32K, protected recent content may leave no room for DCP; repeated native summaries must still preserve successful tool execution. The Native LLM scenario reports high usage alongside a slow shell call and requires its successful exit before compaction, then separately checks that explicit cancellation still stops a longer command. Assertions inspect actual outgoing model requests, successful tool outputs/exit codes and publicly read persisted history. This covers lifecycle behavior that direct hook tests cannot prove; simulated usage does not certify a provider's tokenizer or hard context limit.

This is a pinned host contract test, not a claim that every future host or model provider has been exercised. CI also typechecks/builds/imports against the minimum and latest V1 plugin/SDK versions. The required `opencode-compatibility` aggregate includes the real-host job; SpecGit policy is unchanged.

Expand Down
4 changes: 4 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ Eligible tools are known `read`, `grep`, `glob`, and `bash` with an explicit zer

User instructions, assistant text, reasoning signatures, tool inputs, errors, message/part counts, identities and ordering remain unchanged. There is no topic inference, synthetic digest, input reduction, structural merging, or deduplication. Projection is prepared independently and committed only on success.

Host markers for ordinary file, directory, and agent references do not disable pruning. Their expanded text is counted and the markers remain intact. Already-compacted tools are estimated using the host's cleared output, even when stored history retains attachments. Media that still reaches the model and unfamiliar content keep the request unchanged rather than receiving a guessed token cost.

**Folding is lossy output cleanup.** Original outputs remain in stored history. Protected steps, long inputs and system instructions may themselves exceed the budget; DCP then leaves the protection rules intact and lets the host handle native compaction.

The host may start automatic compaction from the previous response's reported usage before the next pruning hook runs. A small context window or large system/tool definitions can therefore trigger a native summary before any old steps become eligible. `targetRatio` limits estimated history, not the final provider request, and insufficient capacity never lowers recent-step protection automatically. Successful summary continuation and settlement of running tools remain host execution contracts.

## Controls

The model-facing `dcp_prune` tool requests one fold on the next ordinary request, subject to the same protections. It returns immediately and does not permanently change policy.
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ DCP 在 OpenCode 发送模型请求之前,按当前模型预算折叠较旧的

用户消息、助手文本、推理及签名、工具输入和错误内容逐字保留。消息和 parts 的数量、顺序、身份及调用配对保持不变。没有话题猜测、机械摘要、输入缩减、调用合并或去重。

普通 `@文件`、`@目录` 和 `@agent` 引用的宿主标记不会阻止剪枝:引用展开后的文本照常计算,标记原样保留。已被宿主压缩的工具按清理后的输出估算,即使原始历史仍保存附件。实际仍会发送给模型的媒体或未知内容继续保留原文,不猜测其 token 数。

**折叠是有损的工具输出清理。**过去的输出细节会从本次模型请求中消失,仍可在原始会话中查阅。DCP 不承诺任意长对话都能装入窗口:近期步骤、长输入、受保护内容或系统提示本身可能过大,此时保留保护规则,由宿主原生压缩处理。

宿主可能根据上一轮真实用量,在下一次剪枝入口之前启动自动压缩。因此,窗口较小或系统提示、工具定义较大时,可能先发生原生摘要,DCP 尚未有可折叠的旧步骤。`targetRatio` 是历史预算比例,不是最终模型请求的硬上限;近期保护不会因窗口不足而自动降低。原生摘要成功后是否续跑、正在执行的工具如何结算,属于宿主执行契约。

## 手动控制与原生压缩

模型可调用 `dcp_prune`,请求**下一次普通模型请求**主动折叠符合条件的历史输出。工具立即返回,同样遵守近期和内容保护;请求消费后不影响未来策略,不保存永久加深等级。可向助手提出“调用 dcp_prune 压缩旧工具输出”。
Expand Down
26 changes: 20 additions & 6 deletions lib/dtc/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@ function hasAttachments(value: unknown): boolean {
return Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null
}

function attachmentsPresent(part: PartLike): boolean {
function attachmentsPresent(part: PartLike, clearedStateAttachments = false): boolean {
return (
hasAttachments(part.attachments) ||
hasAttachments(part.state?.attachments) ||
(!clearedStateAttachments && hasAttachments(part.state?.attachments)) ||
hasAttachments(part.state?.metadata?.attachments)
)
}
Expand All @@ -83,8 +83,17 @@ function jsonTokens(value: unknown): number | undefined {
}

/** Unknown media or host content is not assigned an invented token cost. */
function estimatePart(part: PartLike): number | undefined {
function estimatePart(part: PartLike, role: string | undefined): number | undefined {
if (!part || typeof part !== "object") return undefined
// User references retain UI markers after the host expands their content
// into separate text parts. These markers are omitted from model requests.
if (
role === "user" &&
(part.type === "agent" ||
(part.type === "file" &&
(part.mime === "text/plain" || part.mime === "application/x-directory")))
)
return 0
if (STRUCTURAL_PARTS.has(part.type ?? "")) return 0
// These host-owned user parts serialize as fixed text. Their presence
// after native compaction must not disable every future projection.
Expand All @@ -98,8 +107,13 @@ function estimatePart(part: PartLike): number | undefined {
if (typeof part.text !== "string") return undefined
return PART_OVERHEAD + estimateTokens(part.text)
}
if (part.type !== "tool" || !part.state || attachmentsPresent(part)) return undefined
if (part.type !== "tool" || !part.state) return undefined
const state = part.state
// The host omits only state.attachments for already-cleared completed
// assistant tools. Inputs, output shape and unfamiliar metadata still count.
const clearedStateAttachments =
role === "assistant" && state.status === "completed" && !!state.time?.compacted
if (attachmentsPresent(part, clearedStateAttachments)) return undefined
const inputTokens = jsonTokens(state.input ?? {})
if (inputTokens === undefined) return undefined
let tokens =
Expand Down Expand Up @@ -140,7 +154,7 @@ export function estimateMessages(messages: readonly MessageLike[]): number | und
}
total += MESSAGE_OVERHEAD
for (const part of message.parts) {
const tokens = estimatePart(part)
const tokens = estimatePart(part, message.info?.role)
if (tokens === undefined) return undefined
total += tokens
}
Expand All @@ -162,7 +176,7 @@ function toolSteps(messages: readonly MessageLike[]): ToolStep[] {
steps.push(current)
current = { tools: [], tokens: MESSAGE_OVERHEAD }
}
current.tokens += estimatePart(part) ?? 0
current.tokens += estimatePart(part, message.info?.role) ?? 0
if (part.type === "tool") current.tools.push({ message: messageIndex, part: partIndex })
}
if (current.tools.length > 0) steps.push(current)
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@lexwdex-org/opencode-dcp",
"version": "6.0.0",
"version": "6.0.1",
"type": "module",
"description": "Request-scoped compression of old successful OpenCode tool outputs",
"main": "./dist/index.js",
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-host.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"

// This is the host implementation under test, not an SDK-only compatibility check.
export const HOST_COMMIT = "743d99ff4b79bcafeffc4d5e8624060b3af6ca13"
export const HOST_COMMIT = "8d9972908c308da1836a004cebe27c7c23db1acc"

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..")
const host = process.env.OPENCODE_SOURCE_ROOT && resolve(process.env.OPENCODE_SOURCE_ROOT)
Expand Down
40 changes: 34 additions & 6 deletions tests/host/contract.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@ test("host components: filterCompacted checkpoint prefix remains intact", () =>
hostScenario("post-compaction-prefix")
})

test("public host HTTP: concurrent real tool loops, model switches, history and native compaction", () => {
function publicHostScenario(scenario = "public-host-loop") {
const result = spawnSync(
"bun",
[fileURLToPath(new URL("./public-loop.mjs", import.meta.url))],
[fileURLToPath(new URL("./public-loop.mjs", import.meta.url)), scenario],
{
encoding: "utf8",
timeout: 180_000,
Expand All @@ -47,8 +47,36 @@ test("public host HTTP: concurrent real tool loops, model switches, history and
)
assert.ifError(result.error)
assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`)
assert.deepEqual(JSON.parse(result.stdout.trim().split("\n").at(-1)), {
scenario: "public-host-loop",
ok: true,
})
const report = JSON.parse(result.stdout.trim().split("\n").at(-1))
assert.equal(report.scenario, scenario)
assert.equal(report.ok, true)
return report
}

test("public host HTTP: concurrent real tool loops, model switches, history and native compaction", () => {
publicHostScenario()
})

test("public host HTTP: default 64K auto compaction follows DCP with plain and file-reference prompts", () => {
const { metrics } = publicHostScenario("automatic-64k")
assert.equal(metrics.length, 2)
for (const result of metrics) {
assert.ok(result.prunedBeforeSummary > 0)
assert.ok(result.summaries > 0)
assert.equal(result.completed, result.tools)
}
})

test("public host HTTP: default 32K recent protection permits repeated native summaries and completion", () => {
const { metrics } = publicHostScenario("automatic-32k")
assert.equal(metrics.length, 1)
assert.ok(metrics[0].summaries >= 2)
assert.equal(metrics[0].completed, metrics[0].tools)
})

test("public host HTTP: native automatic compaction settles a slow bash; explicit abort still cancels", () => {
const { metrics } = publicHostScenario("native-slow-tool")
assert.equal(metrics.length, 2)
assert.equal(metrics[0].completed, metrics[0].tools)
assert.equal(metrics[1].explicitlyCancelled, true)
})
Loading
Loading