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
24 changes: 23 additions & 1 deletion CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ Create `~/.pi/acp.json` (or `<project>/.pi/acp.json`) and drop in whichever keys
"compress": {
"maxContextLimit": "75%",
"emergencyThresholdPercent": "95%",
"nudgeGrowthTokens": 50000
"nudgeGrowthTokens": 50000,
"reasoning": { "drop": true, "threshold": 2048 }
}
}
```
Expand Down Expand Up @@ -141,6 +142,7 @@ All keys below are currently **ACTIVE**.
| `compress.maxContextLimit` | number \| string | `"75%"` | 🟢 ACTIVE | Context threshold that triggers forced compression nudges. |
| `compress.emergencyThresholdPercent` | number \| string | `"95%"` | 🟢 ACTIVE | Context threshold that triggers emergency truncation. |
| `compress.nudgeGrowthTokens` | number | `50000` | 🟢 ACTIVE | Token growth step for soft compression nudges. |
| `compress.reasoning` | object | `{ "drop": true, "threshold": 2048 }` | 🟢 ACTIVE | Drop oversized thinking from historical `compress` calls (request-time; persisted history untouched). |

**Prompts keys**

Expand Down Expand Up @@ -457,6 +459,26 @@ The flow is:
- **Description:** The token-growth threshold that controls the cadence of **soft** compression nudges. A soft nudge fires roughly every time this many tokens of new compressible content accumulate. A lower value means the model is nudged to compress more often; a higher value means less frequent nudges. This only governs *growth-driven* nudges — once usage crosses `compress.maxContextLimit`, forced nudges take over regardless of this setting. Maps to the kernel settings `nudge.growthFloor` and `nudge.growthCap`.
- **Same-turn re-inject:** within one user turn a nudge injects at most once, but once the context has since grown by a full growth floor (mirroring the kernel's anti-thrashing cadence: `max(minGrowthFloor, minGrowthRatio × adaptiveGrowth)` — 22.5K tokens with defaults) a fresh reminder re-injects in the same turn (issue #269: a model that ignored a 78% nudge used to stay silent until the 95% emergency truncation). After a successful compress the growth baseline re-anchors to the new (smaller) scale, so post-compress regrowth into the pressure band is not held against the pre-compress peak.

### `compress.reasoning`

- **Type:** `object` — `{ "drop": boolean, "threshold": number }`
- **Default:** `{ "drop": true, "threshold": 2048 }`
- **Status:** 🟢 ACTIVE
- **Description:** Config for dropping oversized reasoning (thinking) parts from historical `compress` tool calls — exact semantic alignment with [opencode-acp #377](https://github.com/ranxianglei/opencode-acp/pull/377). `compress` calls are hard-exempt from compression (their tool results anchor the block summaries), so their thinking rides along every request as an unreclaimable context floor. A request-time pass removes `thinking` parts from a message only when **all** gates hold:
1. **Closed turn** — the message is strictly before the last genuine user message; the active round is never touched (some providers require replaying the active round's thinking).
2. **Selector** — the message carries a `toolCall` part with name `compress` (only compress; other protected tools would need their own explicit config).
3. **Size** — the message's total reasoning length (chars, summed across parts of that message, never across messages) **strictly exceeds** `threshold`. `0` drops any non-empty reasoning.

Persisted history is never modified — the pass only rewrites the outgoing view, rebuilt fresh from the session log on every request. Pure, idempotent, fail-safe (any error leaves messages untouched). Merged field-wise (`drop`, `threshold` separately) across the three levels of `compress.providers`.

Fields:
- `drop` (`boolean`, default `true`) — master switch; `false` disables the pass (kill-switch).
- `threshold` (`number`, chars, default `2048`) — single-thinking size gate.

Providers whose thinking items are opaque and must round-trip unmodified (e.g. OpenAI encrypted reasoning) can opt out per-provider:\n ```json
{ "compress": { "providers": { "openai": { "reasoning": { "drop": false } } } } }
```

### `compress.providers` — per-provider & per-model overrides

- **Type:** object — a map of provider name → `{ ...<compress fields>, models: { modelId → <compress fields> } }`
Expand Down
25 changes: 24 additions & 1 deletion CONFIGURATION.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
"compress": {
"maxContextLimit": "75%",
"emergencyThresholdPercent": "95%",
"nudgeGrowthTokens": 50000
"nudgeGrowthTokens": 50000,
"reasoning": { "drop": true, "threshold": 2048 }
}
}
```
Expand Down Expand Up @@ -140,6 +141,7 @@
| `compress.maxContextLimit` | number \| string | `"75%"` | 🟢 ACTIVE | 触发强制压缩 nudge 的上下文阈值。 |
| `compress.emergencyThresholdPercent` | number \| string | `"95%"` | 🟢 ACTIVE | 触发紧急截断的上下文阈值。 |
| `compress.nudgeGrowthTokens` | number | `50000` | 🟢 ACTIVE | 软压缩 nudge 的 token 增长步长。 |
| `compress.reasoning` | object | `{ "drop": true, "threshold": 2048 }` | 🟢 ACTIVE | 请求时丢弃历史 `compress` 调用上的超大思考(不修改持久化历史)。 |

**prompts 键**

Expand Down Expand Up @@ -449,6 +451,27 @@
- **说明:** 控制**软**压缩 nudge 频率的 token 增长阈值。每当积累约这么多新可压缩内容时,触发一次软 nudge。值越低模型被 nudge 压缩的频率越高;值越高频率越低。此设置只控制*基于增长的* nudge——用量越过 `compress.maxContextLimit` 后,强制 nudge 接管,不受此设置影响。映射到内核设置 `nudge.growthFloor` 和 `nudge.growthCap`。
- **同轮重注入:** 同一用户轮内 nudge 至多注入一次,但上下文自上次注入后又增长满一个增长门槛(镜像内核防抖 cadence:`max(minGrowthFloor, minGrowthRatio × adaptiveGrowth)`,默认 22.5K token)时,会在同轮重新注入新提醒(issue #269:模型忽略 78% nudge 后,原来会一直沉默到 95% emergency 机械截断)。成功 compress 后增长基线重锚到新(更小)刻度,压缩后重新长回压力带不会被压缩前峰值压制。

### `compress.reasoning`

- **类型:** `object` —— `{ "drop": boolean, "threshold": number }`
- **默认值:** `{ "drop": true, "threshold": 2048 }`
- **状态:** 🟢 ACTIVE
- **说明:** 控制从历史 `compress` 工具调用中丢弃超大 reasoning(思考)部分,与 [opencode-acp #377](https://github.com/ranxianglei/opencode-acp/pull/377) 完全对齐。`compress` 调用被硬排除在压缩之外(其工具结果是块摘要的锚点),其思考会随每次请求原样重发,形成无法回收的上下文底座。一个请求时 pass 只在**全部**门控满足时移除 `thinking` 部分:
1. **已闭合轮次** —— 消息严格位于最后一条真实用户消息之前;活跃轮永不触碰(部分 provider 要求回放活跃轮思考)。
2. **选择器** —— 消息携带 `toolCall` 部分且 name 为 `compress`(仅 compress;其他保护工具如需支持应单独显式配置)。
3. **大小** —— 该消息 reasoning 总长(字符数,仅对该消息各部分求和,不跨消息累计)**严格大于** `threshold` 才丢弃;`0` 表示丢弃任何非空 reasoning。

持久化历史从不被修改——pass 只改写出口视图,每次请求从会话日志全新重建。纯函数、幂等、fail-safe(任何错误原样返回)。在 `compress.providers` 三级间逐字段合并(`drop`、`threshold` 各自独立)。

字段:
- `drop`(`boolean`,默认 `true`)—— 总开关;`false` 完全禁用(kill-switch)。
- `threshold`(`number`,字符数,默认 `2048`)—— 单条思考大小门。

思考项不透明且必须原样回传的 provider(如 OpenAI 加密 reasoning)可按 provider 退出:
```json
{ "compress": { "providers": { "openai": { "reasoning": { "drop": false } } } } }
```

### `compress.providers` —— 按 provider / 按 model 覆盖

- **类型:** object —— provider 名 → `{ ...<compress 字段>, models: { modelId → <compress 字段> } }` 的映射
Expand Down
9 changes: 9 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defaultConfig, type Config, type Prompts } from "acp-kernel";
import type { CompressReasoningConfig } from "./reasoning-drop.js";
import type { ThrottleRetryConfig } from "./throttle-retry.js";
import { logWarn } from "./log.js";

Expand Down Expand Up @@ -129,6 +130,10 @@ export interface CompressSettings {
* window and would suppress every nudge. Maps to kernel
* nudge.minPressureBenefitTokens. */
minPressureBenefitTokens?: number;
/** [#336] Drop oversized reasoning (thinking) from historical `compress`
* tool calls — see CompressReasoningConfig in src/reasoning-drop.ts.
* Merged field-wise (drop, threshold) across the three levels. */
reasoning?: CompressReasoningConfig;
}

/** Per-provider compression overrides. Carries the same tuning fields as the
Expand Down Expand Up @@ -358,6 +363,10 @@ export function mergeCompress(
emergencyThresholdPercent: model?.emergencyThresholdPercent ?? provider?.emergencyThresholdPercent ?? global?.emergencyThresholdPercent,
nudgeGrowthTokens: model?.nudgeGrowthTokens ?? provider?.nudgeGrowthTokens ?? global?.nudgeGrowthTokens,
minPressureBenefitTokens: model?.minPressureBenefitTokens ?? provider?.minPressureBenefitTokens ?? global?.minPressureBenefitTokens,
reasoning: {
drop: model?.reasoning?.drop ?? provider?.reasoning?.drop ?? global?.reasoning?.drop,
threshold: model?.reasoning?.threshold ?? provider?.reasoning?.threshold ?? global?.reasoning?.threshold,
},
};
}

Expand Down
16 changes: 15 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { makeStatusTool } from "./status-tool.js";
import { makeDelegateTool, makeDelegateWaitTool, makeDelegateCancelTool, runningRunsSnapshot, resetDelegateUsage, setDelegateDisplayUsage, setDelegatePolicy, setDelegateDefaults, setDelegateNotifyIfRead, markDelegateResultRead, markDelegateRunReadByCommand } from "./delegate-tool.js";
import { makeCommands } from "./commands.js";
import { coreOutToAgentMessages, extractText } from "./messages.js";
import { dropCompressReasoning } from "./reasoning-drop.js";
import { buildAcpSystemPrompt, ACP_DELEGATE_PROMPT } from "./system-prompt.js";
import { delegateStatusWidget } from "./fleet-widget.js";
import { openFleetInspector } from "./fleet-inspector.js";
Expand Down Expand Up @@ -395,7 +396,20 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf
});

const originalById = collectOriginals(entries);
const rebuilt = coreOutToAgentMessages(turn.messages, originalById);
let rebuilt = coreOutToAgentMessages(turn.messages, originalById);
// [#336] Request-time reasoning drop, aligned with opencode-acp #377:
// compress calls are hard-exempt from compression, so their thinking
// rides along every request as an unreclaimable floor. Applied BEFORE the
// nudge push so a synthetic user-role nudge can never become the "last
// genuine user message" boundary and extend the closed zone over the
// active round. Persisted history is never modified — this only rewrites
// the outgoing view, rebuilt fresh from entries on every event.
const reasoningDrop = runtime.reasoningDropFor(ctx);
const droppedThinking = dropCompressReasoning(rebuilt, reasoningDrop);
if (droppedThinking !== rebuilt) {
debug.event("reasoning-drop", { sid, dropped: droppedThinking.length, drop: reasoningDrop.drop, threshold: reasoningDrop.threshold });
}
rebuilt = droppedThinking;
const debugOn = debug.enabled;

const turnKey = lastUserMessageId(entries) ?? sid;
Expand Down
100 changes: 100 additions & 0 deletions src/reasoning-drop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { SessionMessageEntry } from "@earendil-works/pi-coding-agent";
import { logWarn } from "./log.js";

type AgentMessage = SessionMessageEntry["message"];

/** [#336] Config for dropping oversized reasoning (thinking) parts from
* historical `compress` tool calls — exact alignment with opencode-acp #377.
* `compress` calls are hard-exempt from compression (their tool results are
* the anchors that keep block summaries addressable), so their thinking
* rides along every request as an unreclaimable context floor. This pass
* removes `thinking` parts at request time (persisted history is never
* modified) from closed-turn compress messages whose total reasoning length
* exceeds `threshold`. The active round (from the last genuine user message
* onward) is never touched. */
export interface CompressReasoningConfig {
/** Master switch. Default: true. `drop: false` disables the pass entirely
* (kill-switch; also the recipe for providers whose thinking items are
* opaque and must round-trip unmodified, e.g. set it per-provider under
* `compress.providers.<name>`). */
drop?: boolean;
/** Single-thinking size gate (chars): a closed-turn compress message's
* total reasoning length (summed across parts of that message) must
* STRICTLY EXCEED this to be dropped. Small thinkings are kept; lengths
* are NOT accumulated across messages. Default: 2048. `0` drops any
* non-empty reasoning (only zero-length reasoning survives). */
threshold?: number;
}

export const DEFAULT_COMPRESS_REASONING: Required<CompressReasoningConfig> = { drop: true, threshold: 2048 };

export function resolveReasoningDrop(cfg?: CompressReasoningConfig): Required<CompressReasoningConfig> {
let threshold = DEFAULT_COMPRESS_REASONING.threshold;
if (cfg?.threshold !== undefined) {
const t = cfg.threshold;
if (typeof t === "number" && Number.isFinite(t) && t >= 0) {
threshold = Math.floor(t);
} else {
logWarn("config", { event: "compress-reasoning-invalid", field: "threshold", value: t, fallback: DEFAULT_COMPRESS_REASONING.threshold });
}
}
return { drop: cfg?.drop !== false, threshold };
}

function isThinking(part: unknown): part is { type: "thinking"; thinking: string } {
const p = part as { type?: string; thinking?: unknown };
return p?.type === "thinking" && typeof p.thinking === "string";
}

function hasCompressCall(content: unknown): boolean {
if (!Array.isArray(content)) return false;
return content.some((p) => {
const b = p as { type?: string; name?: string };
return b?.type === "toolCall" && b.name === "compress";
});
}

function reasoningLength(content: unknown): number {
if (!Array.isArray(content)) return 0;
return content.reduce((n, p) => (isThinking(p) ? n + p.thinking.length : n), 0);
}

/** Request-time pass aligned with opencode-acp #377: remove `thinking` parts
* from a message only when ALL gates hold —
* 1. closed turn: the message is strictly before the last genuine user
* message (pi gives tool results their own `toolResult` role, so every
* `user` message is genuine);
* 2. selector: the message carries a `toolCall` part with name "compress"
* (only compress; other protected tools would need their own explicit
* config);
* 3. size: the message's total reasoning length strictly exceeds
* `threshold` chars (summed across parts, never across messages).
* Pure: never mutates the input; idempotent; fail-safe (any error returns
* the input unchanged). */
export function dropCompressReasoning(messages: AgentMessage[], cfg?: CompressReasoningConfig): AgentMessage[] {
const { drop, threshold } = resolveReasoningDrop(cfg);
if (!drop || messages.length === 0) return messages;
try {
let lastUser = -1;
for (let i = 0; i < messages.length; i++) {
if ((messages[i] as { role?: string }).role === "user") lastUser = i;
}
if (lastUser < 0) return messages;
let changed = false;
const out = messages.slice();
for (let i = 0; i < lastUser; i++) {
const msg = messages[i] as { role?: string; content?: unknown };
if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
if (!hasCompressCall(msg.content)) continue;
if (reasoningLength(msg.content) <= threshold) continue;
out[i] = {
...(msg as object),
content: (msg.content as unknown[]).filter((p) => !isThinking(p)),
} as AgentMessage;
changed = true;
}
return changed ? (out as AgentMessage[]) : messages;
} catch {
return messages;
}
}
14 changes: 12 additions & 2 deletions src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {
type Config,
type Prompts,
} from "acp-kernel";
import { resolveConfig, type AdapterConfig } from "./config.js";
import { resolveCompress, resolveConfig, type AdapterConfig } from "./config.js";
import { resolveReasoningDrop, type CompressReasoningConfig } from "./reasoning-drop.js";
import { entriesToCoreMessages, extractText, matchesStoredText, messageIdentity, messageRef } from "./messages.js";
import { SessionStateStore, type LiveRefOrigin } from "./state.js";
import { hasCompressHistory, rebuildStateFromLog } from "./state-rebuild.js";
Expand Down Expand Up @@ -92,6 +93,10 @@ export interface AcpRuntime {
clearCompressRetryTracking(): void;
liveContextLimit(ctx: ExtensionContext): number;
configFor(ctx: ExtensionContext): Config;
/** [#336] Effective compress.reasoning drop settings for the active model
* (three-level merge + defaults). Feeds the request-time pass in the
* context transform. */
reasoningDropFor(ctx: ExtensionContext): Required<CompressReasoningConfig>;
/** Re-read ~/.<dir>/acp.json + <cwd>/<dir>/acp.json and re-derive the adapter
* config when the contents change. Cheap no-op when unchanged. Called at
* session_start and on every context event so config edits apply live. */
Expand Down Expand Up @@ -381,6 +386,11 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime {
return resolveConfig(adapterRef, liveContextLimit(ctx), m?.provider, m?.id);
}

function reasoningDropFor(ctx: ExtensionContext): Required<CompressReasoningConfig> {
const m = ctx.model as { provider?: string; id?: string } | undefined;
return resolveReasoningDrop(resolveCompress(adapterRef.compress, m?.provider, m?.id).reasoning);
}

async function reloadConfig(cwd: string): Promise<void> {
let user;
try {
Expand Down Expand Up @@ -457,4 +467,4 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime {

let refused = false;
let refusalMessage: string | null = null;
return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k, t) => { nudgeShownTurns.add(k); if (t !== undefined) nudgeShownTokens.set(k, t); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), nudgeShownTokensFor: (k) => nudgeShownTokens.get(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); nudgeShownTokens.clear(); }, clearNudgeTokenStamps: () => nudgeShownTokens.clear(), noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale };}
return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k, t) => { nudgeShownTurns.add(k); if (t !== undefined) nudgeShownTokens.set(k, t); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), nudgeShownTokensFor: (k) => nudgeShownTokens.get(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); nudgeShownTokens.clear(); }, clearNudgeTokenStamps: () => nudgeShownTokens.clear(), noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reasoningDropFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale };}
Loading
Loading