billion-context-dsh 0.2.19 → 0.2.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [English](./README.en.md) | [中文](./README.md)
4
4
 
5
5
  > **⚠️ Beta notice — not for production use**
6
- > This project (**v0.2.19**) is a work-in-progress beta. The [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) itself is also in **public beta**. **Do not use either in engineering / production environments** — expect breaking changes and rough edges.
6
+ > This project (**v0.2.21**) is a work-in-progress beta. The [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) itself is also in **public beta**. **Do not use either in engineering / production environments** — expect breaking changes and rough edges.
7
7
 
8
8
  <p align="center">
9
9
  <strong>Built with gratitude on top of these projects</strong> — please give them a ⭐:
@@ -98,7 +98,7 @@ This only installs the package into your project/global store; it does **not** t
98
98
  **Install from the git source (`github:` spec — the form the plugin store shows).** The prebuilt `dist/` artifacts are committed to this repository, so a git-source install also works out of the box — **no build step needed**, and pnpm 11's default build-script blocking (`allowBuilds`) never applies to this package:
99
99
 
100
100
  ```bash
101
- dsh plugin --profile web add github:Tyan66666/billion-context-dsh#v0.2.19
101
+ dsh plugin --profile web add github:Tyan66666/billion-context-dsh#v0.2.21
102
102
  ```
103
103
 
104
104
  Prefer a `#<tag>` ref to get artifacts identical to that npm release; without a ref you get the latest default-branch build. Only building the repo yourself (`npm run build`) requires approving build scripts. Background and trade-offs: [docs/git-source-install-design.md](docs/git-source-install-design.md) (issue #92).
@@ -179,7 +179,7 @@ DSH derives every model request from its append-only session log (the *surface*)
179
179
  | `acp_status` | CONTEXT BREAKDOWN (tool/text/summaries shares of the visible total) + compressed-block ledger + nudge decision line + a `Checkpoint seqs` row mapping each ACTIVE block's kernel ref (`bN`) to its checkpoint summary seq — compressing a checkpoint seq distills that block (issue #60); no context-window rows; scope/view/tool/sort/limit drilldown supported |
180
180
  | block state | in-memory kernel state + **log-rebuilt ledger** (no sidecar files) |
181
181
  | tiered distillation (T2/T3) | re-compressing a block's summary node distills that block (tier 2); distilling a tier-2 block yields tier 3. Tier + kernel block ids are persisted to the log, so kernel state rehydrates from the log after a restart and stays distillable |
182
- | compression accounting (shadow price) | `shadowedTokenCount` (what the host occupancy display deducts) is priced in the **host token-meter's vocabulary** (`ctx.tokenMeter.measure` preferred; exact mirror in `src/host-tokens.ts` as fallback) — never the plugin's internal CJK-aware estimate (that is display currency; mixing it into the host ledger can drive `messageTokens` negative and brick a CJK-heavy session, issue #54) |
182
+ | compression accounting (shadow price) | `shadowedTokenCount` (what the host occupancy display deducts) is priced with the **host token-meter's fixed-heuristic price** (`ctx.tokenMeter.measure` preferred, reading the `heuristicTokens ?? tokens` fixed-heuristic basis; exact mirror in `src/host-tokens.ts` as fallback) — never the plugin's internal CJK-aware estimate (that is display currency; mixing it into the host ledger can drive `messageTokens` negative and brick a CJK-heavy session, issue #54), and never the route-repriced `node.tokens` either (under 0.1.2+ image route pricing that is request-pressure currency; summing it overstates an image range's claim and folds the same ledger negative, issue #103) |
183
183
 
184
184
  The load-bearing compression guidance (tools, philosophy, summary rules, tier rules) is registered as a one-time system-prompt section; each nudge carries a condensed version (efficiency note + philosophy + context breakdown + HOW_TO_COMPRESS_RULES + range table + batch tip). There is deliberately **no automatic summarization**: automatic policy only nudges the model (`compactIfNeeded` returns null).
185
185
 
@@ -216,8 +216,8 @@ This project reuses `acp-kernel`'s compression core and `billion-context-pi`'s d
216
216
 
217
217
  | Key | Default | Meaning |
218
218
  |---|---|---|
219
- | `modelContextLimit` | auto-detected (fallback `128000`) | Context window used for the kernel's pressure decisions; an explicit value wins and skips detection. When omitted, the host session projection `contextPressure.contextWindow` is read first — the capacity disclosed for the **current real route** (a session that switched models follows automatically, no restart needed) — and the model API is probed only when the projection discloses no window |
220
- | `autoModelContextLimit` | `true` | Resolve the real context window automatically: the host projection first (`windowFor` → `projectedContextWindow`, `src/window.ts`), then the model API probe (`agent.ctx.llm.resolveModelInfo`); both are skipped when `autoModelContextLimit: false`. On probe failure it falls back to the default, and the `/acp` command shows the window source (the `acp_status` model tool carries no window info). A failed probe is surfaced in the host log and the `/acp` panel (`restart to re-probe`) — the failure is cached like a success, so fixing the gateway requires a restart or an explicit `modelContextLimit` before the probe retries |
219
+ | `modelContextLimit` | auto-detected (fallback `128000`) | Context window used for the kernel's pressure decisions; an explicit value wins and skips detection. When omitted, the host session projection `contextPressure.contextWindow` is read first — the capacity disclosed for the **current real route** (a session that switched models follows automatically, no restart needed) — and the model API is probed only when the projection discloses no window; an explicit value also skips the output-reservation subtraction (the operator owns the denominator) |
220
+ | `autoModelContextLimit` | `true` | Resolve the real context window automatically: the host projection first (`windowFor` → `projectedContextWindow`, `src/window.ts`), then the model API probe (`agent.ctx.llm.resolveModelInfo`); both are skipped when `autoModelContextLimit: false`. On probe failure it falls back to the default, and the `/acp` command shows the window source (the `acp_status` model tool carries no window info). A failed probe is surfaced in the host log and the `/acp` panel (`restart to re-probe`) — the failure is cached like a success, so fixing the gateway requires a restart or an explicit `modelContextLimit` before the probe retries. On a successful probe the adapter's per-request output cap (`defaultMaxTokens` — the output reservation the provider guarantees at the end of the window) is SUBTRACTED, so every downstream pressure decision (nudge tiers, truncate, growth) measures usage against the SUSTAINABLE input budget (window − reservation): a 96K window with a 16K cap carries at most 80K of input, and the raw denominator understated usage by cap/window (≈17% there — and the ratio is far higher on short-window models, where the same cap is a quarter or more of the window). When the cap is undisclosed, the limit is explicit, or the probe fails, the raw-window behavior is kept; `/acp status` shows the subtraction (raw − reservation) |
221
221
  | `nudgeMinContextLimitPct` | kernel default `0.45` | Nudge window lower bound (usage fraction) — validation only; the growth-driven trigger has no percentage floor — same default as billion-context-pi |
222
222
  | `nudgeMaxContextLimitPct` | engine default `0.70` (kernel/pi default `0.75`) | Over-limit line: above this the nudge fires regardless of growth — deliberately below the host compaction-basic 80% auto-compaction line so the forced nudge fires first; an explicit value wins (a same-name key in `coreOverrides.nudge` outranks it — see below) |
223
223
  | `nudgeEmergencyThresholdPct` | engine default `0.85` (kernel/pi default `0.95`) | Emergency nudge (bypasses the per-turn dedup) — lowered from `0.95`: at 95% the model has no room to act and the 80% auto-compaction line shadows it; an explicit value wins (a same-name key in `coreOverrides.nudge` outranks it — see below) |
@@ -250,7 +250,7 @@ src/
250
250
  ├── nudge.ts # M4: kernel pressure decision → injected advisory nudge
251
251
  ├── system-prompt.ts# M4: one-time ACP guidance section (keeps nudges short)
252
252
  ├── config.ts # kernel config assembly (thresholds + coreOverrides)
253
- ├── window.ts # auto context-window detection (session projection first, LLM runtime probe fallback, default 128000)
253
+ ├── window.ts # auto context-window detection (session projection first, LLM runtime probe fallback, default 128000) + output-reservation probe (defaultMaxTokens, subtracted in windowFor)
254
254
  └── commands.ts # M4: /acp slash command
255
255
  ```
256
256
 
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [中文](./README.md) | [English](./README.en.md)
4
4
 
5
5
  > **⚠️ 测试版声明——请勿用于生产环境**
6
- > 本项目(**v0.2.19**)仍处于开发中的测试版。[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 本身也处于**公开测试版**阶段。**请勿将两者用于工程化 / 生产环境**——预期会有破坏性变更与粗糙之处。
6
+ > 本项目(**v0.2.21**)仍处于开发中的测试版。[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 本身也处于**公开测试版**阶段。**请勿将两者用于工程化 / 生产环境**——预期会有破坏性变更与粗糙之处。
7
7
 
8
8
  <p align="center">
9
9
  <strong>衷心感谢以下项目——请给它们一个 ⭐:</strong>
@@ -97,7 +97,7 @@ npm install billion-context-dsh
97
97
  **git 源安装(`github:` 规格,插件商店展示的形态)。** 预构建产物 `dist/` 已提交到仓库,从 git 源安装同样开箱即用——**无需任何构建步骤**,pnpm 11 默认拦截构建脚本(`allowBuilds`)的机制对这个包不构成障碍:
98
98
 
99
99
  ```bash
100
- dsh plugin --profile web add github:Tyan66666/billion-context-dsh#v0.2.19
100
+ dsh plugin --profile web add github:Tyan66666/billion-context-dsh#v0.2.21
101
101
  ```
102
102
 
103
103
  建议带 `#<tag>` 安装,拿到与对应 npm 版本完全一致的产物;不带 ref 则装默认分支的最新构建。只有 clone 仓库自行从源码构建(`npm run build`)才需要放行构建。背景与方案取舍见 [docs/git-source-install-design.md](docs/git-source-install-design.md)(issue #92)。
@@ -176,7 +176,7 @@ DSH 的每个模型请求都派生自其 append-only 会话日志(*surface*)
176
176
  | `acp_status` | CONTEXT BREAKDOWN(tool/text/summaries 占可见总量)+ 压缩块账本 + nudge 决策行 + `Checkpoint seqs` 行(active 块的 `bN → seq` 映射——压缩某个 checkpoint seq 即蒸馏该块,issue #60);不含上下文窗口;支持 scope/view/tool/sort/limit 钻取 |
177
177
  | 块状态 | 内存内核状态 + **日志重建账本**(无旁车文件) |
178
178
  | 分层蒸馏(T2/T3) | 再次压缩某块的摘要节点 = 蒸馏该块(tier 2),蒸馏 tier-2 块得 tier 3;tier 与内核块 id 持久化进日志,重启后内核状态从日志再水合、可继续蒸馏 |
179
- | 压缩记账(影子价格) | `shadowedTokenCount`(宿主占用率据此扣减)**用宿主 token-meter 的词汇计价**(`ctx.tokenMeter.measure` 优先,`src/host-tokens.ts` 精确镜像兜底)——绝不混用插件内部的 CJK 感知估算(那是展示货币,混用会把宿主账本扣成负数、卡死中文会话,issue #54) |
179
+ | 压缩记账(影子价格) | `shadowedTokenCount`(宿主占用率据此扣减)**用宿主 token-meter 的固定启发价计价**(`ctx.tokenMeter.measure` 优先、按 `heuristicTokens ?? tokens` 读固定启发价基准,`src/host-tokens.ts` 精确镜像兜底)——绝不混用插件内部的 CJK 感知估算(那是展示货币,混用会把宿主账本扣成负数、卡死中文会话,issue #54),也不按路由重定价的 `node.tokens` 计价(0.1.2+ 图片路由计价下那是请求压力价,读它会让含图片区间的 claim 虚报视觉价、同样扣穿账本,issue #103) |
180
180
 
181
181
  承载性的压缩指引(工具、哲学、摘要规则、tier 蒸馏/浓缩规则)注册为一次性系统提示段;每条 nudge 携带精简版(效率提示 + 哲学 + 上下文分解 + 压缩规则 + 范围表 + 批量提示)。刻意**不做自动摘要**:自动策略只 nudge 模型(`compactIfNeeded` 返回 null)。
182
182
 
@@ -213,8 +213,8 @@ DSH 的每个模型请求都派生自其 append-only 会话日志(*surface*)
213
213
 
214
214
  | 键 | 默认值 | 含义 |
215
215
  |---|---|---|
216
- | `modelContextLimit` | 自动探测(回退 `128000`) | 用于内核压力决策的上下文窗口;显式配置时优先且跳过探测。省略时优先读宿主会话投影 `contextPressure.contextWindow`(按**当前真实路由**披露的新窗口,切模型会话自动跟随,无需重启),无投影时再从模型 API 探测 |
217
- | `autoModelContextLimit` | `true` | 从模型 API 自动探测真实窗口(`agent.ctx.llm.resolveModelInfo`);探测失败回退默认值,`/acp` 命令展示窗口来源(模型工具 `acp_status` 不含窗口信息)。省略时窗口先读宿主投影(`windowFor` → `projectedContextWindow`,`src/window.ts`)再走探测;投影与探测在 `autoModelContextLimit: false` 时均跳过。探测失败会在宿主日志与 `/acp` 面板提示(`restart to re-probe`)——失败结果同样被缓存,修复网关后需重启或显式设置 `modelContextLimit` 才会重新探测 |
216
+ | `modelContextLimit` | 自动探测(回退 `128000`) | 用于内核压力决策的上下文窗口;显式配置时优先且跳过探测。省略时优先读宿主会话投影 `contextPressure.contextWindow`(按**当前真实路由**披露的新窗口,切模型会话自动跟随,无需重启),无投影时再从模型 API 探测;显式配置同样跳过输出预留扣减(分母完全由操作者定义) |
217
+ | `autoModelContextLimit` | `true` | 从模型 API 自动探测真实窗口(`agent.ctx.llm.resolveModelInfo`);探测失败回退默认值,`/acp` 命令展示窗口来源(模型工具 `acp_status` 不含窗口信息)。省略时窗口先读宿主投影(`windowFor` → `projectedContextWindow`,`src/window.ts`)再走探测;投影与探测在 `autoModelContextLimit: false` 时均跳过。探测失败会在宿主日志与 `/acp` 面板提示(`restart to re-probe`)——失败结果同样被缓存,修复网关后需重启或显式设置 `modelContextLimit` 才会重新探测。探测成功后还会**扣减 adapter 的每请求输出上限**(`defaultMaxTokens`,窗口末端每请求保证的输出预留):所有下游压力决策(nudge 档位、truncate、growth)以「可持续输入预算」(窗口 − 输出预留)为分母——96K 窗口 + 16K 上限实际最多承载 80K 输入,原裸窗口分母会把用量低估 cap/window(此处 ≈17%;小上下文窗口模型比例更高);上限未披露、显式配置或探测失败时保持裸窗口行为,`/acp status` 展示扣减明细(raw − reservation) |
218
218
  | `nudgeMinContextLimitPct` | 内核默认 `0.45` | Nudge 窗口下界(用量占比)——仅作配置校验,增长路径的触发没有百分比下限——与 billion-context-pi 相同的默认值 |
219
219
  | `nudgeMaxContextLimitPct` | engine 默认 `0.70`(内核/pi 默认 `0.75`) | 过限线:超过此值则无论增长与否都触发 nudge——刻意低于宿主 compaction-basic 的 80% 自动压缩线,保证强制 nudge 先触发;显式配置优先(`coreOverrides.nudge` 同名键优先级更高,见下) |
220
220
  | `nudgeEmergencyThresholdPct` | engine 默认 `0.85`(内核/pi 默认 `0.95`) | 紧急 nudge(绕过每轮去重)——从 `0.95` 下调:95% 时模型已无操作空间且会被 80% 自动压缩线遮蔽;显式配置优先(`coreOverrides.nudge` 同名键优先级更高,见下) |
@@ -247,7 +247,7 @@ src/
247
247
  ├── nudge.ts # M4: 内核压力决策 → 注入的建议式 nudge
248
248
  ├── system-prompt.ts# M4: 一次性 ACP 指引段(让 nudge 保持简短)
249
249
  ├── config.ts # 内核配置组装(阈值 + coreOverrides)
250
- ├── window.ts # 自动上下文窗口探测(宿主投影优先,LLM 运行时探测回退,兜底 128000
250
+ ├── window.ts # 自动上下文窗口探测(宿主投影优先,LLM 运行时探测回退,兜底 128000)+ 输出预留探测(defaultMaxTokens,windowFor 内扣除)
251
251
  └── commands.ts # M4: /acp 斜杠命令
252
252
  ```
253
253
 
@@ -11,10 +11,20 @@
11
11
  * display currency, NEVER event currency).
12
12
  *
13
13
  * This module prices claims in the host's vocabulary: it prefers the live
14
- * meter's own per-node prices (`ctx.tokenMeter.measure(session).nodes`
15
- * exact by construction, follows host estimator changes automatically, the
16
- * same path the host's own `compaction-basic` uses) and falls back to an
14
+ * meter's own per-node FIXED-HEURISTIC prices (`ctx.tokenMeter.measure(session)`
15
+ * nodes' `heuristicTokens` the same basis the projection ledger accumulates
16
+ * appends with, so the claim is exact by construction) and falls back to an
17
17
  * exact mirror of the host's estimator when the meter is unreachable.
18
+ *
19
+ * Two vocabularies share the meter's node since DSH 0.1.2: `tokens` carries
20
+ * the measured route's request pressure (image occurrences re-priced with the
21
+ * route's declared visual tokens) while `heuristicTokens` keeps the fixed
22
+ * flat-4 heuristic the ledger prices appends with. The claim MUST read
23
+ * `heuristicTokens`: a routed `tokens` claim overstates the replaced range
24
+ * against its own ledger accumulation and folds `messageTokens` negative —
25
+ * the same session-bricking schema rejection as #54, through the image-route
26
+ * channel (issue #103). Older hosts (0.1.0/0.1.1 lines) expose a single
27
+ * `tokens` field that IS the fixed heuristic, so the fallback reads it.
18
28
  */
19
29
  import type { Session, SessionEvent } from '@deepseek-ai/dsh-session';
20
30
  /** The host's model-visible content block union (structural, mirror-side only). */
@@ -61,8 +71,17 @@ export declare function hostPriceEvent(event: SessionEvent): number;
61
71
  export declare function shadowedHostTokens(session: Session, seqs: readonly number[]): number;
62
72
  /**
63
73
  * Claim price for `seqs` in the host's vocabulary. Prefers the live meter's
64
- * own per-node prices when `ctx.tokenMeter` is reachable and covers every
65
- * shadowed seq (exact by construction, follows host estimator changes); ANY
74
+ * own per-node FIXED-HEURISTIC prices when `ctx.tokenMeter` is reachable and
75
+ * covers every shadowed seq (exact by construction the ledger's
76
+ * `foldSurfaceProjection` accumulates appends with the same fixed heuristic,
77
+ * so the claim and the ledger stay in agreement; follows host estimator
78
+ * changes automatically). `node.heuristicTokens` is that basis since DSH 0.1.2;
79
+ * `node.tokens` there is the measured route's REQUEST pressure (image
80
+ * occurrences carry the route's visual price via `priceSurface`) and MUST NOT
81
+ * be claimed — reading it overstates the claim and folds the host projection
82
+ * negative on image-containing ranges (issue #103, the image-route channel of
83
+ * the #54 brick). Older meters expose a single `tokens` field that IS the
84
+ * fixed heuristic, so `heuristicTokens ?? tokens` covers both shapes. ANY
66
85
  * failure — meter absent, `measure` throwing (e.g. a step-less log), or a seq
67
86
  * missing from the measurement — falls back to the exact mirror. Never returns
68
87
  * a `defaultCountTokens` price (rule 12).
package/dist/index.d.ts CHANGED
@@ -128,6 +128,8 @@ export declare class AcpCompactionEngine extends CompactionEngine {
128
128
  private readonly compressCallIdsToHide;
129
129
  /** Per provider/model route the resolved window (probe failures cached too). */
130
130
  private readonly windowCache;
131
+ /** Per route the adapter's per-request output cap (the output reservation); null = undisclosed. */
132
+ private readonly outputReservationCache;
131
133
  constructor(ctx: Context, config?: Partial<AcpConfig>);
132
134
  /**
133
135
  * Resolve the effective context window for an agent. An explicitly
@@ -138,9 +140,31 @@ export declare class AcpCompactionEngine extends CompactionEngine {
138
140
  * projectedContextWindow). Falls back to probing the model's real window
139
141
  * via `agent.ctx.llm.resolveModelInfo` (cached per provider/model route,
140
142
  * probe failures cached too) and finally to DEFAULT_CONTEXT_WINDOW when
141
- * auto-detection is disabled or unavailable.
143
+ * auto-detection is disabled or unavailable. On the auto-detected paths the
144
+ * adapter's per-request output cap is then SUBTRACTED from the window
145
+ * (applyReservation): every downstream usage computation must run against
146
+ * the SUSTAINABLE input budget (window minus output reservation), not the
147
+ * raw window — a 96K window with a 16K cap carries at most 80K of input,
148
+ * so the raw denominator understates usage by cap/window (≈17% there, and
149
+ * far worse on short-window models). An explicit limit keeps the operator's
150
+ * exact value (they own the denominator); a failed probe keeps the raw
151
+ * fallback.
142
152
  */
143
153
  windowFor(agent: Agent): Promise<AcpWindow>;
154
+ /**
155
+ * The adapter's per-request output cap for a route, from one
156
+ * probeModelWindow call (a local catalog lookup — no request is sent),
157
+ * cached per route like the window itself.
158
+ */
159
+ private outputCapFor;
160
+ /**
161
+ * Subtract the output reservation from a resolved window: `limit` becomes
162
+ * the SUSTAINABLE input budget (`rawLimit - outputReserved`) that every
163
+ * downstream usage computation (nudge tiers, truncate, growth) measures
164
+ * against. No-op when the cap is unknown or not smaller than the window
165
+ * (degenerate config) — the raw-window behavior is preserved.
166
+ */
167
+ private applyReservation;
144
168
  /** ACP is model-driven: automatic pressure policy never summarizes by itself. */
145
169
  compactIfNeeded(_agent: CompactionAgentContext, _trigger: CompactionTrigger, signal: AbortSignal): Promise<CompactionResult | null>;
146
170
  /** Explicit idle-session compaction: ACP leaves the decision to the model. */
package/dist/index.js CHANGED
@@ -2637,13 +2637,7 @@ function makePreview(text, query, len) {
2637
2637
 
2638
2638
  // src/region.ts
2639
2639
  import { randomUUID } from "crypto";
2640
- import {
2641
- CompactionId,
2642
- compactCheckpointSource,
2643
- toolPairingBalancedAfter,
2644
- toolPairingBalancedBefore
2645
- } from "@deepseek-ai/dsh-compaction";
2646
- import { createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
2640
+ import { CompactionId, compactCheckpointSource } from "@deepseek-ai/dsh-compaction";
2647
2641
 
2648
2642
  // src/session-events.ts
2649
2643
  function sessionEventsOf(session) {
@@ -2657,6 +2651,78 @@ function eventAtOf(session, seq) {
2657
2651
  return session.events[seq];
2658
2652
  }
2659
2653
 
2654
+ // src/tool-pairing.ts
2655
+ var balanceCacheBySession = /* @__PURE__ */ new WeakMap();
2656
+ function eventDelta(event) {
2657
+ if (event.type === "tool/result") return -1;
2658
+ if (event.type === "assistant/message") {
2659
+ const content = event.data.message?.content;
2660
+ if (!Array.isArray(content)) return 0;
2661
+ let calls = 0;
2662
+ for (const block of content) {
2663
+ if (block !== null && typeof block === "object" && block.type === "tool-call") calls += 1;
2664
+ }
2665
+ return calls;
2666
+ }
2667
+ return 0;
2668
+ }
2669
+ function eventForSeq(session, seq) {
2670
+ const event = eventAtOf(session, seq);
2671
+ if (event === void 0 || event.seq !== seq) {
2672
+ throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`);
2673
+ }
2674
+ return event;
2675
+ }
2676
+ function extendCache(session, cache2, seqs) {
2677
+ const processed = cache2.cutBalanced.length - 1;
2678
+ const tail = seqs.slice(processed);
2679
+ const pendingCuts = [];
2680
+ let inProgressToolCalls = cache2.inProgressToolCalls;
2681
+ for (const seq of tail) {
2682
+ inProgressToolCalls += eventDelta(eventForSeq(session, seq));
2683
+ if (inProgressToolCalls < 0) {
2684
+ throw new Error(`tool-pairing balance: tool/result at surface seq ${seq} has no matching tool-call (corrupt surface)`);
2685
+ }
2686
+ pendingCuts.push(inProgressToolCalls === 0);
2687
+ }
2688
+ tail.forEach((seq, offset) => cache2.indexBySeq.set(seq, processed + offset));
2689
+ cache2.cutBalanced = cache2.cutBalanced.concat(pendingCuts);
2690
+ cache2.inProgressToolCalls = inProgressToolCalls;
2691
+ return cache2;
2692
+ }
2693
+ function balanceCache(session) {
2694
+ const seqs = session.surface.nodes;
2695
+ const generation = session.surface.replaceGeneration;
2696
+ const cached = balanceCacheBySession.get(session);
2697
+ if (cached === void 0 || cached.generation !== generation || cached.cutBalanced.length - 1 > seqs.length) {
2698
+ const rebuilt = extendCache(session, {
2699
+ generation,
2700
+ cutBalanced: [true],
2701
+ indexBySeq: /* @__PURE__ */ new Map(),
2702
+ inProgressToolCalls: 0
2703
+ }, seqs);
2704
+ balanceCacheBySession.set(session, rebuilt);
2705
+ return rebuilt;
2706
+ }
2707
+ if (cached.cutBalanced.length - 1 < seqs.length) return extendCache(session, cached, seqs);
2708
+ return cached;
2709
+ }
2710
+ function cutBalance(cache2, seq, offset) {
2711
+ const index = cache2.indexBySeq.get(seq);
2712
+ const balanced = index === void 0 ? void 0 : cache2.cutBalanced[index + offset];
2713
+ if (balanced === void 0) throw new Error(`tool-pairing balance: surface seq ${seq} not found`);
2714
+ return balanced;
2715
+ }
2716
+ function toolPairingBalancedBefore(session, seq) {
2717
+ return cutBalance(balanceCache(session), seq, 0);
2718
+ }
2719
+ function toolPairingBalancedAfter(session, seq) {
2720
+ return cutBalance(balanceCache(session), seq, 1);
2721
+ }
2722
+
2723
+ // src/region.ts
2724
+ import { createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
2725
+
2660
2726
  // src/messages.ts
2661
2727
  function extractText(content) {
2662
2728
  if (typeof content === "string") return content;
@@ -2847,7 +2913,7 @@ function shadowedTokensViaMeter(session, seqs, ctx) {
2847
2913
  try {
2848
2914
  const meter = ctx?.get?.("tokenMeter");
2849
2915
  if (meter?.measure !== void 0) {
2850
- const bySeq = new Map(meter.measure(session).nodes.map((node) => [node.seq, node.tokens]));
2916
+ const bySeq = new Map(meter.measure(session).nodes.map((node) => [node.seq, node.heuristicTokens ?? node.tokens]));
2851
2917
  let total = 0;
2852
2918
  let missing = false;
2853
2919
  for (const seq of seqs) {
@@ -4383,18 +4449,24 @@ function projectedContextWindow(agent) {
4383
4449
  if (typeof window === "number" && Number.isInteger(window) && window > 0) return window;
4384
4450
  return null;
4385
4451
  }
4386
- async function detectContextWindow(agent, provider, model) {
4452
+ async function probeModelWindow(agent, provider, model) {
4387
4453
  const llm = agent.ctx?.get?.("llm");
4388
- if (llm?.resolveModelInfo === void 0) return null;
4454
+ if (llm?.resolveModelInfo === void 0) return { contextWindow: null, outputReservation: null };
4389
4455
  try {
4390
4456
  const info = await llm.resolveModelInfo(provider, model);
4391
4457
  const window = info?.context?.contextWindow;
4392
- if (typeof window === "number" && Number.isInteger(window) && window > 0) return window;
4393
- return null;
4458
+ const cap = info?.defaultMaxTokens;
4459
+ return {
4460
+ contextWindow: typeof window === "number" && Number.isInteger(window) && window > 0 ? window : null,
4461
+ outputReservation: typeof cap === "number" && Number.isInteger(cap) && cap > 0 ? cap : null
4462
+ };
4394
4463
  } catch {
4395
- return null;
4464
+ return { contextWindow: null, outputReservation: null };
4396
4465
  }
4397
4466
  }
4467
+ async function detectContextWindow(agent, provider, model) {
4468
+ return (await probeModelWindow(agent, provider, model)).contextWindow;
4469
+ }
4398
4470
 
4399
4471
  // src/commands.ts
4400
4472
  async function statusText(env, agent) {
@@ -4406,12 +4478,13 @@ async function statusText(env, agent) {
4406
4478
  const estimated = resolveTokenCount(agent, surfaceMessages);
4407
4479
  const window = await resolveEffectiveWindow(env, agent);
4408
4480
  const limit = window.limit;
4481
+ const windowLine = window.rawLimit !== void 0 && window.outputReserved !== void 0 ? ` context window: ${limit} (raw ${window.rawLimit} \u2212 ${window.outputReserved} output reservation; ${windowSourceLabel(window)})` : ` context window: ${limit} (${windowSourceLabel(window)})`;
4409
4482
  const lines = [
4410
4483
  `ACP status \u2014 session ${session.id}`,
4411
4484
  ` blocks: ${ledger.length}`,
4412
4485
  ` tokens compressed: ${totalTokens}`,
4413
4486
  ` estimated context: ${estimated} / ${limit} (${Math.round(estimated / limit * 100)}%)`,
4414
- ` context window: ${limit} (${windowSourceLabel(window)})`
4487
+ windowLine
4415
4488
  ];
4416
4489
  if (window.probeFailed === true) {
4417
4490
  lines.push(` \u26A0 window auto-detection failed \u2014 using the ${limit} fallback (restart to re-probe, or set modelContextLimit explicitly)`);
@@ -4540,6 +4613,8 @@ var AcpCompactionEngine = class extends CompactionEngine {
4540
4613
  compressCallIdsToHide = /* @__PURE__ */ new Set();
4541
4614
  /** Per provider/model route the resolved window (probe failures cached too). */
4542
4615
  windowCache = /* @__PURE__ */ new Map();
4616
+ /** Per route the adapter's per-request output cap (the output reservation); null = undisclosed. */
4617
+ outputReservationCache = /* @__PURE__ */ new Map();
4543
4618
  constructor(ctx, config = {}) {
4544
4619
  super(ctx);
4545
4620
  this.config = resolveAcpConfig(config);
@@ -4648,7 +4723,15 @@ var AcpCompactionEngine = class extends CompactionEngine {
4648
4723
  * projectedContextWindow). Falls back to probing the model's real window
4649
4724
  * via `agent.ctx.llm.resolveModelInfo` (cached per provider/model route,
4650
4725
  * probe failures cached too) and finally to DEFAULT_CONTEXT_WINDOW when
4651
- * auto-detection is disabled or unavailable.
4726
+ * auto-detection is disabled or unavailable. On the auto-detected paths the
4727
+ * adapter's per-request output cap is then SUBTRACTED from the window
4728
+ * (applyReservation): every downstream usage computation must run against
4729
+ * the SUSTAINABLE input budget (window minus output reservation), not the
4730
+ * raw window — a 96K window with a 16K cap carries at most 80K of input,
4731
+ * so the raw denominator understates usage by cap/window (≈17% there, and
4732
+ * far worse on short-window models). An explicit limit keeps the operator's
4733
+ * exact value (they own the denominator); a failed probe keeps the raw
4734
+ * fallback.
4652
4735
  */
4653
4736
  async windowFor(agent) {
4654
4737
  if (this.config.modelContextLimit !== void 0) {
@@ -4660,28 +4743,58 @@ var AcpCompactionEngine = class extends CompactionEngine {
4660
4743
  if (this.config.autoModelContextLimit) {
4661
4744
  const projected = projectedContextWindow(agent);
4662
4745
  if (projected !== null) {
4663
- return { limit: projected, source: "projection", provider, model };
4746
+ const cap2 = await this.outputCapFor(agent, provider, model);
4747
+ return this.applyReservation({ limit: projected, source: "projection", provider, model }, cap2);
4664
4748
  }
4665
4749
  }
4666
4750
  const cached = this.windowCache.get(key);
4667
4751
  if (cached !== void 0) return cached;
4668
4752
  let window;
4753
+ let cap = null;
4669
4754
  if (!this.config.autoModelContextLimit) {
4670
4755
  window = { limit: DEFAULT_CONTEXT_WINDOW, source: "default", provider, model };
4671
4756
  } else {
4672
- const detected = await detectContextWindow(agent, provider, model);
4673
- if (detected === null) {
4757
+ const probe = await probeModelWindow(agent, provider, model);
4758
+ cap = probe.outputReservation;
4759
+ if (probe.contextWindow === null) {
4674
4760
  this.ctx.logger.warn(
4675
4761
  `billion-context-dsh: context-window auto-detection failed for ${provider}/${model} \u2014 using the ${DEFAULT_CONTEXT_WINDOW} fallback (restart to re-probe, or set modelContextLimit explicitly)`
4676
4762
  );
4677
4763
  window = { limit: DEFAULT_CONTEXT_WINDOW, source: "default", provider, model, probeFailed: true };
4764
+ cap = null;
4678
4765
  } else {
4679
- window = { limit: detected, source: "auto", provider, model };
4766
+ window = { limit: probe.contextWindow, source: "auto", provider, model };
4680
4767
  }
4681
4768
  }
4769
+ window = this.applyReservation(window, cap);
4682
4770
  this.windowCache.set(key, window);
4683
4771
  return window;
4684
4772
  }
4773
+ /**
4774
+ * The adapter's per-request output cap for a route, from one
4775
+ * probeModelWindow call (a local catalog lookup — no request is sent),
4776
+ * cached per route like the window itself.
4777
+ */
4778
+ async outputCapFor(agent, provider, model) {
4779
+ if (provider === "" || model === "") return null;
4780
+ const key = `${provider}\0${model}`;
4781
+ const known = this.outputReservationCache.get(key);
4782
+ if (known !== void 0) return known;
4783
+ const cap = (await probeModelWindow(agent, provider, model)).outputReservation;
4784
+ this.outputReservationCache.set(key, cap);
4785
+ return cap;
4786
+ }
4787
+ /**
4788
+ * Subtract the output reservation from a resolved window: `limit` becomes
4789
+ * the SUSTAINABLE input budget (`rawLimit - outputReserved`) that every
4790
+ * downstream usage computation (nudge tiers, truncate, growth) measures
4791
+ * against. No-op when the cap is unknown or not smaller than the window
4792
+ * (degenerate config) — the raw-window behavior is preserved.
4793
+ */
4794
+ applyReservation(window, cap) {
4795
+ if (cap === null || cap >= window.limit) return window;
4796
+ return { ...window, rawLimit: window.limit, outputReserved: cap, limit: window.limit - cap };
4797
+ }
4685
4798
  /** ACP is model-driven: automatic pressure policy never summarizes by itself. */
4686
4799
  async compactIfNeeded(_agent, _trigger, signal) {
4687
4800
  signal.throwIfAborted();