diff --git a/apps/desktop/src/features/chat/transcript/AssistantTurn.tsx b/apps/desktop/src/features/chat/transcript/AssistantTurn.tsx
index f9e692be7..bbf95b96c 100644
--- a/apps/desktop/src/features/chat/transcript/AssistantTurn.tsx
+++ b/apps/desktop/src/features/chat/transcript/AssistantTurn.tsx
@@ -85,7 +85,8 @@ export function compactionMarksEqual(
previous.throughMessageId === next.throughMessageId &&
previous.generation === next.generation &&
previous.summaryTokens === next.summaryTokens &&
- previous.summarized === next.summarized
+ previous.summarized === next.summarized &&
+ previous.fallback === next.fallback
);
}
@@ -376,11 +377,13 @@ export function CompactionRow({ mark }: { mark: ContextCompactionMark }) {
{t("chat.compactionRow", { times: mark.generation })}
- {mark.summarized
- ? t("chat.compactionRowSummary", {
- tokens: formatCompactTokenCount(mark.summaryTokens),
- })
- : t("chat.compactionRowNoSummary")}
+ {mark.fallback
+ ? t("chat.compactionRowSummaryFailed")
+ : mark.summarized
+ ? t("chat.compactionRowSummary", {
+ tokens: formatCompactTokenCount(mark.summaryTokens),
+ })
+ : t("chat.compactionRowNoSummary")}
);
diff --git a/apps/desktop/src/lib/assistant-turns.ts b/apps/desktop/src/lib/assistant-turns.ts
index cf9c0bd2a..86fbc4c43 100644
--- a/apps/desktop/src/lib/assistant-turns.ts
+++ b/apps/desktop/src/lib/assistant-turns.ts
@@ -387,7 +387,8 @@ function reuseTranscriptEntry(
previous.mark.throughMessageId === next.mark.throughMessageId &&
previous.mark.generation === next.mark.generation &&
previous.mark.summaryTokens === next.mark.summaryTokens &&
- previous.mark.summarized === next.mark.summarized)
+ previous.mark.summarized === next.mark.summarized &&
+ previous.mark.fallback === next.mark.fallback)
? previous
: next;
}
diff --git a/apps/desktop/test/context-compaction.test.mjs b/apps/desktop/test/context-compaction.test.mjs
index e04c24a2a..2ce20be39 100644
--- a/apps/desktop/test/context-compaction.test.mjs
+++ b/apps/desktop/test/context-compaction.test.mjs
@@ -291,6 +291,10 @@ test("the transcript shows one row per compaction, the inspector the newest", ()
assert.match(transcript, /chat\.compactionRow/);
assert.match(transcript, /mark\.summarized/);
assert.match(transcript, /chat\.compactionRowNoSummary/);
+ // A retained-tail recovery is labelled as a failed summary, never as a
+ // summary of N tokens (#543).
+ assert.match(transcript, /mark\.fallback/);
+ assert.match(transcript, /chat\.compactionRowSummaryFailed/);
assert.match(styles, /\.transcript-compaction-row \{/);
// The inspector keeps its own line, now fed by the newest row.
assert.match(
diff --git a/docs/adr/0049-context-compaction-failure-recovery.md b/docs/adr/0049-context-compaction-failure-recovery.md
index 869a9941e..65c512d57 100644
--- a/docs/adr/0049-context-compaction-failure-recovery.md
+++ b/docs/adr/0049-context-compaction-failure-recovery.md
@@ -6,7 +6,9 @@
- Amended by: ADR 0061 (the fallback stays reserved for the blocking hard
boundary; a failed background build is discarded silently) / ADR 0064 (there
is no background build left to discard, and the `fresh_window` family issues
- no summary request, so this path cannot trigger there)
+ no summary request, so this path cannot trigger there) / ADR 0282 (the
+ summary request retries transient failures and the preflight guard sizes
+ the serialized prompt, with one reduced pass, before this fallback runs)
## Context
diff --git a/docs/adr/0282-compaction-summary-retry-and-sizing.md b/docs/adr/0282-compaction-summary-retry-and-sizing.md
new file mode 100644
index 000000000..4c5006897
--- /dev/null
+++ b/docs/adr/0282-compaction-summary-retry-and-sizing.md
@@ -0,0 +1,132 @@
+# ADR 0282: Retry and right-size the compaction summary before retained-tail recovery
+
+- Status: Accepted
+- Date: 2026-09-18
+- Deciders: PI-Desktop runtime maintainers
+- Amends: ADR 0049 (decision 1, the preflight guard; the "retry indefinitely"
+ rejection stands), D203 / ADR 0064 (the summary family only)
+- Related: issue #543 · PR #554 (superseded) ·
+ [03-runtime/02-agent-runtime](../spec/03-runtime/02-agent-runtime.md) ·
+ [03-runtime/01-ipc-protocol](../spec/03-runtime/01-ipc-protocol.md) ·
+ E2E-084
+
+## Context
+
+ADR 0049 made an automatic compaction failure survivable: when the summary
+request fails, the runtime writes a retained-tail checkpoint (previous summary
+if any, a fixed recovery notice, and a bounded tail) and the run continues.
+Issue #543 reports that on real long sessions this fallback is the common
+outcome rather than the exception: six of ten checkpoints on the reporter's
+machine were the ~112-token recovery notice, and the transcript row labelled
+every one of them `summary ≈112 tokens`.
+
+Three things in the summary path made a fallback far more likely than the
+provider's actual failure rate:
+
+1. **No retry on the summary request.** `compact()` was called with no
+ `RetryPolicy`, so pi-ai returned the first failed response as-is. The
+ main turn's provider requests already retry transient failures through the
+ `streamFn` wrapper (D186), but pi-agent-core's summary request goes through
+ `Models.completeSimple` and never reaches that wrapper. One dropped stream
+ or 503 discarded the whole summary.
+2. **The preflight guard measured the wrong thing.** It summed
+ `estimateTokens` over the raw messages, but pi-agent-core serializes the
+ conversation into one text prompt and caps every tool result at 2 000
+ characters while doing so. A tool-heavy session looked several times larger
+ than the prompt it would actually send, and the guard skipped the model for
+ summaries that would have fit. The reporter's `tokensBefore` values
+ (~200k–885k on a 200k window) are exactly this shape.
+3. **The UI could not tell a fallback from a summary.** `ContextCompactionMark`
+ only distinguishes the `fresh_window` rollover; a retained-tail checkpoint
+ rendered as a successful summary of N tokens.
+
+PR #554 proposed an outer retry loop around `buildCheckpoint` and a shrink step
+that dropped the oldest messages from the summary input. The loop retried every
+`recoverable` failure including deterministic ones (quota, auth, "no new
+context"), and dropping messages silently narrowed what the checkpoint claimed
+to summarize. Its direction — retry, then reduce, then fall back — is kept
+here; those two mechanisms are not.
+
+## Decision
+
+1. **The summary request retries transient failures, bounded.**
+ `generateCompaction` passes pi-ai a `RetryPolicy` of three retries with
+ 2 s / 4 s / 8 s backoff (`COMPACTION_SUMMARY_RETRY_POLICY`). pi-ai's own
+ classifier decides what is transient: overload, 429/5xx, dropped streams,
+ timeouts, and connection resets retry; quota, billing, auth, and malformed
+ requests return on the first attempt. The backoff sleeps honour the
+ compaction abort signal, so Stop still cancels immediately. A flapping
+ provider costs at most ~14 s before the ADR 0049 fallback runs; ADR 0049's
+ rejection of unbounded retry stands.
+2. **The preflight guard sizes the prompt pi will send.**
+ `compactionSummaryWouldExceedBudget` serializes the input with pi's own
+ `convertToLlm` + `serializeConversation` (tool results already capped) and
+ applies the four-characters-per-token heuristic the rest of the runtime
+ uses. A split turn counts the larger of its two requests. The limit itself
+ (window − output allowance − safety margin) is unchanged.
+3. **One bounded reduction before giving up.** When the full prompt still
+ exceeds the limit, the runtime tries exactly one reduced input: every tool
+ result cut to a 500-character prefix with a visible marker, assistant
+ thinking dropped. User text, assistant text, and tool-call arguments are
+ never touched, and no message is removed, so the summary still covers every
+ message the checkpoint files behind its boundary. If the reduced prompt
+ still does not fit, or nothing was reducible, the ADR 0049 fallback runs as
+ before. The checkpoint's `messagesToSummarize` and `retainedTail` are the
+ originals; only the request payload is reduced.
+4. **The mark says when a checkpoint is a fallback.** `ContextCompactionMark`
+ gains an optional `fallback?: "retained_tail"`, derived from the persisted
+ `details.fallback` the same way `summarized` is derived from
+ `details.strategy`. The transcript row renders such a mark as
+ "summary generation failed · recent context retained" instead of
+ `summary ≈N tokens`; the inspector line is unchanged. The field is
+ additive: older marks without it render exactly as before, and no record
+ schema, protocol version, or host-core change is needed.
+
+Manual `/compact` inherits the retry and sizing (it is the same request) and
+keeps its fail-fast, no-fallback semantics. The `fresh_window` family issues no
+summary request and is untouched.
+
+## Consequences
+
+- Sessions on a flapping provider keep a real model summary far more often;
+ the fallback is reserved for sustained failures and inputs that cannot be
+ reduced under the window.
+- Tool-heavy sessions no longer skip the summary because of a raw-size
+ estimate that pi's serialization would never have sent.
+- A compaction can now take up to ~14 s longer on a sustained outage before
+ the fallback lands. The compacting activity state already covers this; Stop
+ aborts the backoff immediately.
+- The reduced prompt can produce a thinner summary of tool output than the
+ full one would; it is still a model summary of the complete message range,
+ which is strictly better than the recovery notice it replaces.
+- The transcript row is honest about fallbacks. Users who saw
+ `summary ≈112 tokens` will now see the failure label on the same rows,
+ including historical ones, because the mark is derived from persisted
+ details on session open.
+
+## Alternatives
+
+### Outer retry loop around `buildCheckpoint` (PR #554)
+
+Rejected. It re-ran preparation and retried every recoverable failure
+including deterministic ones, and could not tell a transient provider error
+from "no new context to compact" without re-implementing pi-ai's classifier.
+The policy hook on `compact()` already exists for exactly this.
+
+### Shrink by dropping the oldest messages (PR #554)
+
+Rejected. The checkpoint's `throughMessageId` still covered the dropped
+messages, so the summary silently claimed a range it had not seen. Reducing
+tool output keeps the range intact.
+
+### Map-reduce summarization for oversized inputs
+
+Deferred. It is the right answer for inputs that do not fit even reduced, but
+it changes the summary prompt contract and needs its own budget model. The
+fallback remains for that case; #543's reported failures fit after reduction.
+
+### Retry inside the runtime with `provider-retry.ts`
+
+Rejected. That module wraps `streamFn` and classifies streamed events; the
+summary is a `completeSimple` call that pi-agent-core builds itself. Using
+pi-ai's policy keeps one retry implementation per request shape.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index 3777f7f90..d0278390f 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -70,7 +70,7 @@ Each ADR includes:
| 0046 | Categorized process log files | Accepted |
| 0047 | Context usage inspector with exact and estimated token sources | Accepted |
| 0048 | Lazy per-turn tool activation | Accepted |
-| 0049 | Recover automatic context compaction failures with a retained tail | Accepted |
+| 0049 | Recover automatic context compaction failures with a retained tail | Accepted (preflight guard amended by ADR 0282) |
| 0050 | Bounded provider stream recovery and diagnostics | Accepted |
| 0051 | Isolate host RPC stdio from the Tokio blocking pool | Accepted |
| 0052 | Plan operating state and approval boundary | Superseded by 0053 |
@@ -308,4 +308,5 @@ Each ADR includes:
| 0279 | [Resumable subagent delegations](0279-resumable-subagent-delegations.md) | Accepted for implementation (amends ADR 0062; ADR 0089; issue #513) |
| 0280 | [Plugin-owned UI localizes from the host locale](0280-plugin-owned-ui-localizes-from-host-locale.md) | Accepted (amends ADR 0267; ADR 0159) |
| 0281 | [Host speech capability](0281-host-speech-capability.md) | Accepted for implementation (amends ADR 0257) |
+| 0282 | [Retry and right-size the compaction summary before retained-tail recovery](0282-compaction-summary-retry-and-sizing.md) | Accepted (amends ADR 0049; issue #543) |
| turn-process-and-thinking-display | [Turn process and thinking presentation](turn-process-and-thinking-display.md) | Accepted |
diff --git a/docs/spec/03-runtime/01-ipc-protocol.md b/docs/spec/03-runtime/01-ipc-protocol.md
index cd209cb23..f35174436 100644
--- a/docs/spec/03-runtime/01-ipc-protocol.md
+++ b/docs/spec/03-runtime/01-ipc-protocol.md
@@ -670,7 +670,8 @@ type AgentEvent =
willRetry: boolean; fallback?: "retained_tail";
mark?: { id: string; throughMessageId: string;
generation: number; summaryTokens: number;
- summarized: boolean };
+ summarized: boolean;
+ fallback?: "retained_tail" };
error?: { code: string; message: string } }
| { type: "error"; error: AppError }
| { type: "status"; status: AgentStatus };
@@ -724,7 +725,9 @@ renderer's whole view of that compaction: `id`, the `throughMessageId` anchor th
transcript row sits after, `generation` (how many checkpoints this session has
installed), `summaryTokens` (the summary's estimated context cost), and
`summarized` (`false` when the window rolled over without asking the model for a
-summary). The record itself is not carried — its summary and retained tail are
+summary), and `fallback` (`"retained_tail"` when summary generation failed and
+the checkpoint carries only a recovery notice plus a retained tail; the row
+labels it as a failed summary, never as a summary of N tokens). The record itself is not carried — its summary and retained tail are
far larger than an event should be — and is instead read from
`SessionDetail.compactions` on session open or fork.
diff --git a/docs/spec/03-runtime/02-agent-runtime.md b/docs/spec/03-runtime/02-agent-runtime.md
index 666643d7d..a60a79856 100644
--- a/docs/spec/03-runtime/02-agent-runtime.md
+++ b/docs/spec/03-runtime/02-agent-runtime.md
@@ -435,18 +435,27 @@ tokens, capped at half the hard budget so retention alone cannot fill a small
window and leave the summary no room. None of these values are configurable.
The incoming user prompt participates in budgeting before the first provider
-request. If normal compaction fails during an automatic threshold or overflow
-recovery, the runtime persists a short recovery checkpoint with the previous
-summary (when available) and an aggressively bounded applicable tail. The
-complete transcript remains durable and visible, while the next model request
-receives only that recovery checkpoint and applicable tail. The lifecycle event marks
-this as `fallback: "retained_tail"` so the renderer can show a warning rather
-than a false success. If the fallback cannot be prepared, persisted, or kept
-below the safe budget, the user row and an assistant error remain durable and
-no provider request starts. Provider-reported context overflow is the last
-recovery layer: omit the failed assistant from model context, compact once,
-and retry once. A second overflow remains terminal. Bedrock's
-`prompt is too long: N tokens > M maximum` form maps to this path.
+request. The automatic summary request retries transient provider failures
+under a bounded pi-ai retry policy (3 retries, 2s/4s/8s backoff, cancelled by
+Stop); deterministic failures such as quota or auth return at once. The
+preflight guard sizes the prompt pi actually serializes — tool results already
+capped — rather than the raw messages, and when that prompt still exceeds the
+window it tries exactly one reduced input (tool results cut to a short prefix,
+thinking dropped, no message removed) before giving up on the summary (ADR
+0282). If normal compaction still fails during an automatic threshold or
+overflow recovery, the runtime persists a short recovery checkpoint with the
+previous summary (when available) and an aggressively bounded applicable tail.
+The complete transcript remains durable and visible, while the next model
+request receives only that recovery checkpoint and applicable tail. The
+lifecycle event marks this as `fallback: "retained_tail"`, and the checkpoint's
+mark carries the same `fallback`, so the renderer shows a warning and labels
+the transcript row as a failed summary rather than a false success. If the
+fallback cannot be prepared, persisted, or kept below the safe budget, the user
+row and an assistant error remain durable and no provider request starts.
+Provider-reported context overflow is the last recovery layer: omit the failed
+assistant from model context, compact once, and retry once. A second overflow
+remains terminal. Bedrock's `prompt is too long: N tokens > M maximum` form
+maps to this path.
Automatic protection is always enabled and is not user-configurable. The
runtime still accepts a construction-time override that disables it, used by
diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md
index c62cd048f..a1120783c 100644
--- a/docs/spec/06-delivery/04-e2e-test-plan.md
+++ b/docs/spec/06-delivery/04-e2e-test-plan.md
@@ -4966,8 +4966,15 @@ identify the platform validation still needed.
overflow.
- If automatic summary generation fails, a durable retained-tail fallback
checkpoint is appended, the run stays active, and one warning explains
- that older model context was reduced; if fallback persistence or the safe
- budget guard fails, `CONTEXT_COMPACTION_FAILED` is emitted once.
+ that older model context was reduced; the transcript row for that
+ checkpoint reads "summary generation failed · recent context retained",
+ never `summary ≈N tokens` (ADR 0282). Before that fallback, the summary
+ request retries transient provider failures up to three times with
+ 2s/4s/8s backoff, Stop cancels the backoff, deterministic failures do not
+ retry, and an input whose serialized prompt exceeds the window is sent
+ once more with tool results cut to a short prefix rather than skipping the
+ model. If fallback persistence or the safe budget guard fails,
+ `CONTEXT_COMPACTION_FAILED` is emitted once.
- If the newest checkpoint is already the transcript leaf when a follow-up
prompt crosses the hard budget, the runtime rebuilds a smaller tail from
the full transcript and carries the existing summary forward instead of
diff --git a/docs/spec/08-meta/decisions-log.md b/docs/spec/08-meta/decisions-log.md
index 6c22ec812..77ab22f52 100644
--- a/docs/spec/08-meta/decisions-log.md
+++ b/docs/spec/08-meta/decisions-log.md
@@ -320,6 +320,7 @@ Gold source: local Codex electron captures; latest row wins where rows conflict.
| D369 | Effective subagent thinking metadata | *(amended by D395)* **The immediate `Task` result, `SubagentRunResult`, and lifecycle snapshots carry the effective `modelId` and `thinkingLevel` passed to each child run; the topology node and side-dock header show the raw canonical non-`off` level after the model name, while `off`, `omit`, and unsupported reasoning stay model-only. No host protocol, storage schema, provider request, or lifecycle behavior change.** | Delegation cards need the level actually sent after the target model clamps it, not a value re-derived from the parent or the definition (ADR 0202, ADR 0221, E2E-219) |
| D395 | Canonical thinking-level values in the UI | **Amend D369 / ADR 0202: Composer, model configuration, and delegation surfaces render `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max` directly instead of translating them. Remove these values from every locale catalog; effective metadata, clamping, provider requests, protocol, and storage remain unchanged. See ADR 0221 and E2E-219.** | Thinking levels are stable protocol values, and locale-specific labels made the same provider/runtime setting vary across the application. |
+| D445 | Compaction summary retries and is right-sized before retained-tail recovery | **Amend ADR 0049 decision 1 / D203: the automatic summary request carries a bounded pi-ai `RetryPolicy` (3 retries, 2s/4s/8s backoff, abort-aware; pi-ai classifies what is transient). The preflight guard sizes the prompt pi actually serializes (tool results capped at 2 000 chars) instead of summing raw message estimates. When that prompt still exceeds the window, exactly one reduced input is tried (tool results cut to a 500-char prefix, thinking dropped, no message removed) before the retained-tail fallback runs. `ContextCompactionMark` gains an additive `fallback?: "retained_tail"` derived from persisted `details.fallback`; the transcript row labels such checkpoints as a failed summary instead of `summary ≈N tokens`. No record schema, protocol version, or host-core change. See ADR 0282 and E2E-084.** | Issue #543: six of ten checkpoints on a real long session were the ~112-token recovery notice. The summary request had no retry, the raw-size guard skipped summaries that would have fit, and the row presented every fallback as a successful summary. |
## N. Notification decisions
diff --git a/docs/zh-CN/adr/index.md b/docs/zh-CN/adr/index.md
index 96d848555..52994a0ad 100644
--- a/docs/zh-CN/adr/index.md
+++ b/docs/zh-CN/adr/index.md
@@ -116,7 +116,7 @@ ADR 记录那些不应被静默改变的架构选择。中文入口与英文索
| 0046 | [按类别拆分的进程日志文件](/adr/0046-categorized-process-logs) | 已接受 |
| 0047 | [带精确与估算 token 来源的上下文用量检查器](/adr/0047-context-usage-inspector) | 已接受 |
| 0048 | [按回合惰性激活工具](/adr/0048-lazy-per-turn-tool-activation) | 已接受 |
-| 0049 | [用保留尾部恢复自动上下文压缩失败](/adr/0049-context-compaction-failure-recovery) | 已接受 |
+| 0049 | [用保留尾部恢复自动上下文压缩失败](/adr/0049-context-compaction-failure-recovery) | 已接受(预检守卫由 ADR 0282 修订) |
| 0050 | [有界的 provider 流恢复与诊断](/adr/0050-bounded-provider-stream-recovery) | 已接受 |
| 0051 | [将 host RPC stdio 与 Tokio 阻塞池隔离](/adr/0051-host-rpc-stdio-resource-isolation) | 已接受 |
| 0052 | [Plan 运行状态与审批边界](/adr/0052-plan-operating-state-and-approval-boundary) | 已被 ADR 0053 取代 |
@@ -290,6 +290,7 @@ ADR 记录那些不应被静默改变的架构选择。中文入口与英文索
| 0278 | [规范应用 ID `net.aiuo.pi-desktop`](/adr/0278-canonical-application-id) | 已接受(D443;修订 D141 / D371 / ADR 0204;issue #524) |
| 0279 | [可恢复的子代理委托](/adr/0279-resumable-subagent-delegations) | 已接受待实现(修订 ADR 0062;ADR 0089;issue #513) |
| 0280 | [插件自有界面按宿主语言自行本地化](/adr/0280-plugin-owned-ui-localizes-from-host-locale) | 已接受(修订 ADR 0267;ADR 0159) |
+| 0282 | [压缩摘要先重试并按实际提示大小预检,再回退保留尾部](/adr/0282-compaction-summary-retry-and-sizing) | 已接受(修订 ADR 0049;issue #543) |
| turn-process-and-thinking-display | [回合过程与思考展示](/adr/turn-process-and-thinking-display) | 已接受 |
## 什么时候看 ADR
diff --git a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md
index 20dc8dff0..2d05815e4 100644
--- a/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md
+++ b/docs/zh-CN/spec/03-runtime/01-ipc-protocol.md
@@ -575,7 +575,8 @@ type AgentEvent =
willRetry: boolean; fallback?: "retained_tail";
mark?: { id: string; throughMessageId: string;
generation: number; summaryTokens: number;
- summarized: boolean };
+ summarized: boolean;
+ fallback?: "retained_tail" };
error?: { code: string; message: string } }
| { type: "error"; error: AppError }
| { type: "status"; status: AgentStatus };
@@ -606,7 +607,8 @@ type AgentEvent =
转录本行位于 `generation` 之后(此会话有多少个检查点
已安装)、`summaryTokens`(摘要的估计上下文成本)以及
`summarized`(当窗口滚动且未向模型询问时,`false`
-总结)。记录本身不被携带——它的摘要和保留尾部被携带
+总结)以及 `fallback`(摘要生成失败、检查点只带恢复说明和保留尾部时为
+`"retained_tail"`;转录行将其标为摘要生成失败,而不是 N tokens 的摘要)。记录本身不被携带——它的摘要和保留尾部被携带
远远大于事件应有的大小——而是从
`SessionDetail.compactions` 会话打开或分叉。
diff --git a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md
index e53e3e8f1..95771ad0d 100644
--- a/docs/zh-CN/spec/03-runtime/02-agent-runtime.md
+++ b/docs/zh-CN/spec/03-runtime/02-agent-runtime.md
@@ -347,12 +347,17 @@ Headroom 是 16,384 个代币储备底线的最大值,模型最大输出
可配置。
传入的用户提示先于第一个提供商参与预算
-请求。如果在自动阈值或溢出期间正常压缩失败
+请求。自动摘要请求在有界的 pi-ai 重试策略下重试瞬时的提供商失败
+(3 次重试,2s/4s/8s 退避,Stop 可取消);配额、鉴权等确定性失败立即返回。
+预检守卫按 pi 实际序列化的提示(工具结果已截断)估算大小,而不是按原始消息;
+若该提示仍超出窗口,会恰好尝试一次缩减输入(工具结果截为短前缀、去掉思考块、
+不删除任何消息),之后才放弃摘要(ADR 0282)。如果在自动阈值或溢出期间正常压缩仍然失败
恢复时,运行时会与之前的恢复检查点保持一个简短的恢复检查点
摘要(如果可用)和一个适用的积极限制尾部。的
完整的转录本保持持久且可见,而下一个模型请求
仅接收恢复检查点和尾部。生命周期事件标记
-这作为 `fallback: "retained_tail"` 因此渲染器可以显示警告
+这作为 `fallback: "retained_tail"`,检查点的 mark 也携带同样的 `fallback`,
+因此渲染器可以显示警告并把转录行标为摘要生成失败,
而不是虚假的成功。如果无法准备、持久或保留后备
低于安全预算,用户行和助理错误仍然持久并且
没有提供商请求开始。提供商报告的上下文溢出是最后一个
diff --git a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
index dec38ae2c..4af0a28e6 100644
--- a/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
+++ b/docs/zh-CN/spec/06-delivery/04-e2e-test-plan.md
@@ -3321,7 +3321,11 @@ IPC 请求无法关闭。
溢出。
- 如果自动摘要生成失败,则持久保留尾部回退
附加检查点,运行保持活动状态,并有一个警告解释
- 旧模型上下文被减少;如果后备持久性或安全
+ 旧模型上下文被减少;该检查点的转录行显示
+ 「摘要生成失败 · 已保留近期上下文」,绝不显示 `摘要 ≈N tokens`(ADR 0282)。
+ 在回退之前,摘要请求会对瞬时的提供商失败最多重试三次(2s/4s/8s 退避),
+ Stop 会取消退避,确定性失败不重试;序列化提示超出窗口的输入会把工具结果
+ 截为短前缀后再发送一次,而不是跳过模型。如果后备持久性或安全
预算保护失败,`CONTEXT_COMPACTION_FAILED` 被发出一次。
- 如果后续检查时最新的检查点已经是转录本叶子
提示超出硬预算,运行时会重建较小的尾部
diff --git a/docs/zh-CN/spec/08-meta/decisions-log.md b/docs/zh-CN/spec/08-meta/decisions-log.md
index 5f8c145c6..7466e0037 100644
--- a/docs/zh-CN/spec/08-meta/decisions-log.md
+++ b/docs/zh-CN/spec/08-meta/decisions-log.md
@@ -323,6 +323,7 @@
| D369 | 有效的子智能体思考元数据 | **(由 D395 修订)** 即时 `Task` 结果、`SubagentRunResult` 与生命周期快照携带传给每个子运行的有效 `modelId` 与 `thinkingLevel`;拓扑节点与侧栏标题在模型名后显示原始规范非 `off` 级别,`off`、`omit` 与不支持推理时仅显示模型。宿主协议、存储 schema、provider 请求与生命周期行为不变。** | 委托卡片需要目标模型钳制后实际发送的级别,而不是从父级或定义重新推导的值(ADR 0202、ADR 0221、E2E-219) |
| D395 | UI 中的规范思考等级值 | **修订 D369 / ADR 0202:Composer、模型配置和委派界面直接显示 `off`、`minimal`、`low`、`medium`、`high`、`xhigh` 和 `max`,不再翻译。所有语言目录移除这些值;有效元数据、钳位、provider 请求、协议和存储保持不变。见 ADR 0221 与 E2E-219。** | 思考等级是稳定的协议值,本地化标签会使同一个 provider/runtime 设置在不同应用语言下显示不同。 |
+| D445 | 压缩摘要先重试并按实际提示大小预检,再回退保留尾部 | **修订 ADR 0049 第 1 条 / D203:自动摘要请求携带有界的 pi-ai `RetryPolicy`(3 次重试,2s/4s/8s 退避,可被中止;由 pi-ai 判定哪些错误是瞬时的)。预检守卫按 pi 实际序列化的提示(工具结果已截至 2 000 字符)估算大小,不再对原始消息逐条求和。若该提示仍超出窗口,先尝试恰好一次缩减输入(工具结果截为 500 字符前缀、去掉思考块、不删除任何消息),再运行保留尾部回退。`ContextCompactionMark` 新增可选 `fallback?: "retained_tail"`,由持久化的 `details.fallback` 派生;转录行将此类检查点标为摘要生成失败,而不是 `摘要 ≈N tokens`。不改记录 schema、协议版本或 host-core。见 ADR 0282 与 E2E-084。** | Issue #543:真实长会话 10 次压缩里 6 次是约 112 token 的恢复说明。摘要请求没有重试、按原始大小的守卫跳过了本可放下的摘要、转录行把每次回退都显示成成功摘要。 |
## N. 通知决定
diff --git a/packages/agent-runtime/src/compaction-summary-input.test.ts b/packages/agent-runtime/src/compaction-summary-input.test.ts
new file mode 100644
index 000000000..4556b4d8c
--- /dev/null
+++ b/packages/agent-runtime/src/compaction-summary-input.test.ts
@@ -0,0 +1,176 @@
+import { describe, expect, it } from "vitest";
+import type { AgentMessage } from "@earendil-works/pi-agent-core";
+import {
+ COMPACTION_REDUCED_TOOL_RESULT_CHARS,
+ COMPACTION_SUMMARY_MAX_RETRIES,
+ COMPACTION_SUMMARY_RETRY_BASE_MS,
+ COMPACTION_SUMMARY_RETRY_POLICY,
+ estimateSummaryPromptTokens,
+ reduceSummaryInput,
+ type CompactionSummaryInput,
+} from "./compaction-summary-input.js";
+
+function user(text: string): AgentMessage {
+ return { role: "user", content: [{ type: "text", text }], timestamp: 1 };
+}
+
+function assistant(
+ content: Array<
+ | { type: "text"; text: string }
+ | { type: "thinking"; thinking: string }
+ | { type: "toolCall"; id: string; name: string; arguments: Record }
+ >,
+): AgentMessage {
+ return {
+ role: "assistant",
+ content,
+ api: "openai-completions",
+ provider: "local",
+ model: "local-model",
+ usage: {
+ input: 1,
+ output: 1,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 2,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+ },
+ stopReason: "stop",
+ timestamp: 2,
+ } as AgentMessage;
+}
+
+function toolResult(text: string, id = "tool-1"): AgentMessage {
+ return {
+ role: "toolResult",
+ toolCallId: id,
+ toolName: "Read",
+ content: [{ type: "text", text }],
+ isError: false,
+ timestamp: 3,
+ };
+}
+
+function input(
+ messagesToSummarize: AgentMessage[],
+ overrides: Partial = {},
+): CompactionSummaryInput {
+ return {
+ messagesToSummarize,
+ turnPrefixMessages: [],
+ isSplitTurn: false,
+ previousSummary: undefined,
+ ...overrides,
+ };
+}
+
+describe("COMPACTION_SUMMARY_RETRY_POLICY", () => {
+ it("is a bounded, enabled pi-ai retry policy", () => {
+ expect(COMPACTION_SUMMARY_RETRY_POLICY).toEqual({
+ enabled: true,
+ maxRetries: COMPACTION_SUMMARY_MAX_RETRIES,
+ baseDelayMs: COMPACTION_SUMMARY_RETRY_BASE_MS,
+ });
+ expect(COMPACTION_SUMMARY_MAX_RETRIES).toBeGreaterThan(0);
+ expect(COMPACTION_SUMMARY_MAX_RETRIES).toBeLessThanOrEqual(5);
+ });
+});
+
+describe("estimateSummaryPromptTokens", () => {
+ it("sizes the prompt pi serializes, not the raw messages", () => {
+ // pi caps every tool result at 2 000 characters when it serializes the
+ // conversation, so a 400 000-character result costs ~500 tokens of prompt,
+ // not the ~100 000 the raw message estimate would report.
+ const oversized = input([user("read it"), toolResult("x".repeat(400_000))]);
+ const tokens = estimateSummaryPromptTokens(oversized);
+ expect(tokens).toBeGreaterThan(400);
+ expect(tokens).toBeLessThan(1_000);
+ });
+
+ it("counts the previous summary as part of the history request", () => {
+ const base = input([user("ask")]);
+ const carried = input([user("ask")], { previousSummary: "s".repeat(4_000) });
+ expect(estimateSummaryPromptTokens(carried) - estimateSummaryPromptTokens(base)).toBe(
+ 1_000,
+ );
+ });
+
+ it("uses the larger of the two requests on a split turn", () => {
+ const history = [user("h".repeat(400))];
+ const prefix = [user("p".repeat(4_000))];
+ const split = input(history, { isSplitTurn: true, turnPrefixMessages: prefix });
+ expect(estimateSummaryPromptTokens(split)).toBe(
+ estimateSummaryPromptTokens(input(prefix)),
+ );
+ // The turn prefix only counts when pi will actually summarize it.
+ const unsplit = input(history, { isSplitTurn: false, turnPrefixMessages: prefix });
+ expect(estimateSummaryPromptTokens(unsplit)).toBe(estimateSummaryPromptTokens(input(history)));
+ });
+});
+
+describe("reduceSummaryInput", () => {
+ it("returns undefined when there is nothing to reduce", () => {
+ const small = input([
+ user("ask"),
+ assistant([{ type: "text", text: "answer" }]),
+ toolResult("short"),
+ ]);
+ expect(reduceSummaryInput(small)).toBeUndefined();
+ });
+
+ it("keeps a bounded prefix of each tool result and marks the cut", () => {
+ const reduced = reduceSummaryInput(
+ input([user("ask"), toolResult("a".repeat(10_000)), toolResult("b".repeat(10_000), "tool-2")]),
+ );
+ expect(reduced).toBeDefined();
+ const results = reduced!.messagesToSummarize.filter(
+ (message) => message.role === "toolResult",
+ );
+ expect(results).toHaveLength(2);
+ for (const result of results) {
+ const text = (result as { content: Array<{ text: string }> }).content[0].text;
+ expect(text.startsWith("a".repeat(10)) || text.startsWith("b".repeat(10))).toBe(true);
+ expect(text).toMatch(/truncated for the summary request\]$/);
+ expect(text.length).toBeLessThan(COMPACTION_REDUCED_TOOL_RESULT_CHARS + 100);
+ }
+ });
+
+ it("drops assistant thinking but keeps text and tool calls", () => {
+ const reduced = reduceSummaryInput(
+ input([
+ assistant([
+ { type: "thinking", thinking: "long private reasoning" },
+ { type: "text", text: "visible answer" },
+ { type: "toolCall", id: "t1", name: "Read", arguments: { path: "a.txt" } },
+ ]),
+ ]),
+ );
+ expect(reduced).toBeDefined();
+ const content = (reduced!.messagesToSummarize[0] as { content: Array<{ type: string }> })
+ .content;
+ expect(content.map((block) => block.type)).toEqual(["text", "toolCall"]);
+ });
+
+ it("does not mutate the input and never changes the message count", () => {
+ const original = input([user("ask"), toolResult("z".repeat(5_000))]);
+ const snapshot = JSON.stringify(original);
+ const reduced = reduceSummaryInput(original);
+ expect(JSON.stringify(original)).toBe(snapshot);
+ expect(reduced!.messagesToSummarize).toHaveLength(original.messagesToSummarize.length);
+ expect(reduced!.messagesToSummarize[0]).toBe(original.messagesToSummarize[0]);
+ });
+
+ it("reduces the turn prefix of a split turn as well", () => {
+ const reduced = reduceSummaryInput(
+ input([user("older")], {
+ isSplitTurn: true,
+ turnPrefixMessages: [toolResult("q".repeat(5_000))],
+ }),
+ );
+ expect(reduced).toBeDefined();
+ expect(
+ (reduced!.turnPrefixMessages[0] as { content: Array<{ text: string }> }).content[0].text
+ .length,
+ ).toBeLessThan(1_000);
+ });
+});
diff --git a/packages/agent-runtime/src/compaction-summary-input.ts b/packages/agent-runtime/src/compaction-summary-input.ts
new file mode 100644
index 000000000..924a08018
--- /dev/null
+++ b/packages/agent-runtime/src/compaction-summary-input.ts
@@ -0,0 +1,116 @@
+import {
+ convertToLlm,
+ serializeConversation,
+ type AgentMessage,
+ type CompactionPreparation,
+} from "@earendil-works/pi-agent-core";
+import type { RetryPolicy } from "@earendil-works/pi-ai";
+
+/**
+ * Sizing and retry policy for the automatic summary request (ADR 0282).
+ *
+ * pi-agent-core serializes the messages it summarizes into one text prompt and
+ * caps every tool result at 2 000 characters while doing so. The runtime's
+ * budget guard used to add up `estimateTokens` over the raw messages instead,
+ * so a session whose bulk was tool output looked several times larger than the
+ * prompt it would actually send and was routed to retained-tail recovery
+ * without ever asking the model (issue #543). These helpers size the prompt the
+ * way pi builds it, and shrink the input one bounded step before giving up.
+ */
+
+/**
+ * Retries after the first failed summary request. pi-ai only retries responses
+ * its classifier calls transient (overload, 5xx, dropped streams, timeouts);
+ * quota, auth, and malformed-request failures return on the first attempt.
+ * Waits are 2s, 4s, 8s, so a flapping provider costs at most ~14s of extra
+ * wall clock before the retained-tail fallback runs.
+ */
+export const COMPACTION_SUMMARY_MAX_RETRIES = 3;
+export const COMPACTION_SUMMARY_RETRY_BASE_MS = 2_000;
+
+export const COMPACTION_SUMMARY_RETRY_POLICY: RetryPolicy = {
+ enabled: true,
+ maxRetries: COMPACTION_SUMMARY_MAX_RETRIES,
+ baseDelayMs: COMPACTION_SUMMARY_RETRY_BASE_MS,
+};
+
+/**
+ * Per-tool-result character cap on the reduced input. pi already caps at 2 000
+ * when serializing; the reduced pass keeps a prefix a quarter of that so the
+ * model still sees what each call returned without the bulk.
+ */
+export const COMPACTION_REDUCED_TOOL_RESULT_CHARS = 500;
+
+const REDUCED_TOOL_RESULT_SUFFIX = "\n\n[... tool output truncated for the summary request]";
+
+export type CompactionSummaryInput = Pick<
+ CompactionPreparation,
+ "messagesToSummarize" | "turnPrefixMessages" | "isSplitTurn" | "previousSummary"
+>;
+
+/**
+ * Tokens the summary request(s) will carry for this input, using pi's own
+ * serialization and the four-characters-per-token heuristic the rest of the
+ * runtime uses. A split turn issues two requests (history, then turn prefix);
+ * the larger one is the one that has to fit.
+ */
+export function estimateSummaryPromptTokens(input: CompactionSummaryInput): number {
+ const historyChars =
+ serializeConversation(convertToLlm(input.messagesToSummarize)).length +
+ (input.previousSummary?.length ?? 0);
+ const turnPrefixChars =
+ input.isSplitTurn && input.turnPrefixMessages.length > 0
+ ? serializeConversation(convertToLlm(input.turnPrefixMessages)).length
+ : 0;
+ return Math.ceil(Math.max(historyChars, turnPrefixChars) / 4);
+}
+
+/**
+ * One bounded reduction of the summary input: tool results keep a short prefix
+ * and assistant thinking is dropped. User text, assistant text, and tool call
+ * arguments survive intact, so the summary still covers every message the
+ * checkpoint will file behind its boundary. Returns undefined when nothing was
+ * reducible, so the caller can fall back without a pointless second request.
+ */
+export function reduceSummaryInput(
+ input: T,
+): T | undefined {
+ let changed = false;
+ const reduce = (messages: AgentMessage[]) =>
+ messages.map((message) => {
+ const reduced = reduceMessage(message);
+ if (reduced !== message) changed = true;
+ return reduced;
+ });
+ const messagesToSummarize = reduce(input.messagesToSummarize);
+ const turnPrefixMessages = reduce(input.turnPrefixMessages);
+ if (!changed) return undefined;
+ return { ...input, messagesToSummarize, turnPrefixMessages };
+}
+
+function reduceMessage(message: AgentMessage): AgentMessage {
+ if (message.role === "toolResult") {
+ let changed = false;
+ const content = message.content.map((block) => {
+ if (block.type !== "text") return block;
+ const text = boundToolResultText(block.text);
+ if (text === block.text) return block;
+ changed = true;
+ return { ...block, text };
+ });
+ return changed ? { ...message, content } : message;
+ }
+ if (message.role === "assistant") {
+ if (!message.content.some((block) => block.type === "thinking")) return message;
+ return {
+ ...message,
+ content: message.content.filter((block) => block.type !== "thinking"),
+ };
+ }
+ return message;
+}
+
+function boundToolResultText(text: string): string {
+ if (text.length <= COMPACTION_REDUCED_TOOL_RESULT_CHARS) return text;
+ return text.slice(0, COMPACTION_REDUCED_TOOL_RESULT_CHARS) + REDUCED_TOOL_RESULT_SUFFIX;
+}
diff --git a/packages/agent-runtime/src/runtime.test.ts b/packages/agent-runtime/src/runtime.test.ts
index 4a191cd3b..535ba4858 100644
--- a/packages/agent-runtime/src/runtime.test.ts
+++ b/packages/agent-runtime/src/runtime.test.ts
@@ -12,6 +12,7 @@ import {
type RuntimeMatchConfig,
type RuntimeProviderConfig,
} from "./runtime.js";
+import { COMPACTION_SUMMARY_MAX_RETRIES } from "./compaction-summary-input.js";
import type { ProjectInstructions } from "./project-instructions.js";
import { classifyAgentError } from "./agent-errors.js";
import {
@@ -7616,3 +7617,305 @@ describe("DesktopAgentRuntime compaction request headers", () => {
await runtime.dispose();
});
});
+
+describe("DesktopAgentRuntime compaction summary retry and sizing (#543, ADR 0282)", () => {
+ /** The shape `prepareCompaction` returns for a single-turn history. */
+ function preparation(messagesToSummarize: unknown[] = [
+ {
+ role: "user",
+ content: [{ type: "text", text: "older task context" }],
+ timestamp: 1,
+ },
+ ]) {
+ return {
+ messagesToSummarize,
+ turnPrefixMessages: [],
+ retainedTail: [],
+ isSplitTurn: false,
+ tokensBefore: 240_000,
+ fileOps: {
+ read: new Set(),
+ edited: new Set(),
+ written: new Set(),
+ },
+ settings: { enabled: true, reserveTokens: 16_384, keepRecentTokens: 20_000 },
+ };
+ }
+
+ function providerError(errorMessage: string) {
+ return {
+ ...assistantMessage({ content: [], stopReason: "error" }),
+ errorMessage,
+ };
+ }
+
+ /** Scripted `completeSimple` responses; the recorder returns them in order. */
+ function scriptSummaryRequests(runtime: DesktopAgentRuntime, responses: unknown[]) {
+ const calls: unknown[] = [];
+ (runtime as any).models = {
+ completeSimple: async (_model: unknown, _context: unknown, options: unknown) => {
+ calls.push(options);
+ const next = responses.shift();
+ if (!next) throw new Error("unexpected summary request");
+ return next;
+ },
+ };
+ return calls;
+ }
+
+ function toolResultOf(text: string, index: number) {
+ return {
+ role: "toolResult" as const,
+ toolCallId: `tool-${index}`,
+ toolName: "Read",
+ content: [{ type: "text" as const, text }],
+ isError: false,
+ timestamp: index + 2,
+ };
+ }
+
+ it("retries a transient summary failure and installs the real summary", async () => {
+ vi.useFakeTimers();
+ try {
+ const runtime = createRuntime();
+ const calls = scriptSummaryRequests(runtime, [
+ providerError("503 Service Unavailable"),
+ assistantMessage({ content: [{ type: "text", text: "Older work." }] }),
+ ]);
+
+ const pending = (runtime as any).generateCompaction(
+ preparation(),
+ new AbortController().signal,
+ );
+ await vi.runAllTimersAsync();
+ const result = await pending;
+
+ expect(calls).toHaveLength(2);
+ expect(result.ok).toBe(true);
+ expect(result.value.summary).toContain("Older work.");
+ await runtime.dispose();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("does not retry a deterministic provider rejection", async () => {
+ const runtime = createRuntime();
+ const calls = scriptSummaryRequests(runtime, [
+ providerError("Invalid API key"),
+ ]);
+
+ const result = await (runtime as any).generateCompaction(
+ preparation(),
+ new AbortController().signal,
+ );
+
+ expect(calls).toHaveLength(1);
+ expect(result.ok).toBe(false);
+ expect(result.error.code).toBe("summarization_failed");
+ await runtime.dispose();
+ });
+
+ it("gives up after the bounded retry budget", async () => {
+ vi.useFakeTimers();
+ try {
+ const runtime = createRuntime();
+ const calls = scriptSummaryRequests(
+ runtime,
+ Array.from({ length: COMPACTION_SUMMARY_MAX_RETRIES + 1 }, () =>
+ providerError("upstream connect error or disconnect/reset before headers"),
+ ),
+ );
+
+ const pending = (runtime as any).generateCompaction(
+ preparation(),
+ new AbortController().signal,
+ );
+ await vi.runAllTimersAsync();
+ const result = await pending;
+
+ expect(calls).toHaveLength(COMPACTION_SUMMARY_MAX_RETRIES + 1);
+ expect(result.ok).toBe(false);
+ expect(result.error.code).toBe("summarization_failed");
+ await runtime.dispose();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("stops retrying when the compaction is aborted during the backoff", async () => {
+ vi.useFakeTimers();
+ try {
+ const runtime = createRuntime();
+ const calls = scriptSummaryRequests(runtime, [
+ providerError("503 Service Unavailable"),
+ assistantMessage({ content: [{ type: "text", text: "never sent" }] }),
+ ]);
+ const controller = new AbortController();
+
+ const pending = (runtime as any).generateCompaction(
+ preparation(),
+ controller.signal,
+ );
+ await vi.advanceTimersByTimeAsync(500);
+ controller.abort();
+ await vi.runAllTimersAsync();
+ const result = await pending;
+
+ expect(calls).toHaveLength(1);
+ expect(result.ok).toBe(false);
+ expect(result.error.code).toBe("aborted");
+ await runtime.dispose();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("sizes the budget guard on the serialized prompt, not the raw tool output", async () => {
+ const runtime = createRuntime();
+ // 128k window: the raw estimate of one 600k-character tool result is
+ // 150k tokens and used to fail the guard outright; pi caps the result at
+ // 2 000 characters when it serializes the prompt, so the request fits.
+ const budget = { hardLimit: 111_616, requestHeadroom: 16_384 };
+ const input = preparation([
+ { role: "user", content: "read the log", timestamp: 1 },
+ toolResultOf("x".repeat(600_000), 0),
+ ]);
+
+ expect((runtime as any).compactionSummaryWouldExceedBudget(input, budget)).toBe(
+ false,
+ );
+ expect((runtime as any).fitSummaryInputToBudget(input, budget)).toBe(input);
+ await runtime.dispose();
+ });
+
+ it("reduces an oversized prompt once before giving up on the summary", async () => {
+ const runtime = createRuntime();
+ // 8k window leaves ~4-6k tokens for the prompt; 15 capped tool results
+ // serialize to ~30k characters and overshoot, the reduced prefixes fit.
+ const budget = { hardLimit: 6_000, requestHeadroom: 2_000 };
+ const input = preparation(
+ Array.from({ length: 15 }, (_, index) => toolResultOf("y".repeat(5_000), index)),
+ );
+
+ expect((runtime as any).compactionSummaryWouldExceedBudget(input, budget)).toBe(true);
+ const fitted = (runtime as any).fitSummaryInputToBudget(input, budget);
+ expect(fitted).toBeDefined();
+ expect(fitted).not.toBe(input);
+ expect(fitted.messagesToSummarize).toHaveLength(15);
+ for (const message of fitted.messagesToSummarize) {
+ expect(message.content[0].text.length).toBeLessThan(600);
+ }
+ // The original preparation is untouched: the checkpoint still files the
+ // complete messages behind its boundary.
+ expect((input.messagesToSummarize[0] as any).content[0].text).toHaveLength(5_000);
+ await runtime.dispose();
+ });
+
+ it("falls back only when even the reduced prompt cannot fit", async () => {
+ const runtime = createRuntime();
+ const budget = { hardLimit: 6_000, requestHeadroom: 2_000 };
+ const userText = preparation(
+ Array.from({ length: 15 }, (_, index) => ({
+ role: "user",
+ content: "u".repeat(5_000),
+ timestamp: index + 1,
+ })),
+ );
+ // User text is never reduced, so there is no second attempt to make.
+ expect((runtime as any).fitSummaryInputToBudget(userText, budget)).toBeUndefined();
+
+ const tooManyResults = preparation(
+ Array.from({ length: 120 }, (_, index) => toolResultOf("z".repeat(5_000), index)),
+ );
+ expect(
+ (runtime as any).fitSummaryInputToBudget(tooManyResults, budget),
+ ).toBeUndefined();
+ await runtime.dispose();
+ });
+
+ it("sends the reduced prompt to the model instead of skipping the summary", async () => {
+ const constrainedProvider: RuntimeProviderConfig = {
+ ...provider,
+ modelConfig: {
+ ...provider.modelConfig!,
+ contextWindow: 32_000,
+ maxTokens: 4_096,
+ },
+ };
+ const host = { call: vi.fn().mockResolvedValue(undefined) };
+ const runtime = createRuntime({ host, provider: constrainedProvider });
+ const resultCount = 60;
+ const toolCalls = Array.from({ length: resultCount }, (_, index) => ({
+ type: "toolCall" as const,
+ id: `tool-${index}`,
+ name: "Read",
+ arguments: { path: `large-${index}.txt` },
+ }));
+ const carrier = {
+ ...assistantMessage({ content: toolCalls, stopReason: "toolUse" }),
+ usage: {
+ ...assistantMessage({ content: [] }).usage,
+ input: 80_000,
+ totalTokens: 80_000,
+ },
+ };
+ const results = Array.from({ length: resultCount }, (_, index) =>
+ toolResultOf("r".repeat(5_000), index),
+ );
+ (runtime as any).fullEntries = [
+ {
+ type: "message",
+ id: "old-user",
+ seq: 0,
+ parentId: null,
+ timestamp: Date.parse("2026-09-18T00:00:00Z"),
+ message: { role: "user", content: "inspect the repository", timestamp: 1 },
+ },
+ {
+ type: "message",
+ id: "carrier",
+ seq: 1,
+ parentId: "old-user",
+ timestamp: Date.parse("2026-09-18T00:00:01Z"),
+ message: carrier,
+ },
+ ...results.map((message, index) => ({
+ type: "message",
+ id: message.toolCallId,
+ seq: index + 2,
+ parentId: index === 0 ? "carrier" : results[index - 1].toolCallId,
+ timestamp: Date.parse("2026-09-18T00:00:02Z") + index,
+ message,
+ })),
+ ];
+ const generate = vi
+ .spyOn(runtime as any, "generateCompaction")
+ .mockResolvedValue({
+ ok: true,
+ value: { summary: "Sixty reads, summarized.", tokensBefore: 80_000 },
+ });
+
+ const build = await (runtime as any).buildCheckpoint(
+ new AbortController().signal,
+ "active_turn",
+ );
+
+ expect(build.ok).toBe(true);
+ expect(generate).toHaveBeenCalledTimes(1);
+ const sent = generate.mock.calls[0]?.[0] as any;
+ const sentResults = sent.messagesToSummarize.filter(
+ (message: any) => message.role === "toolResult",
+ );
+ expect(sentResults).toHaveLength(resultCount);
+ for (const message of sentResults) {
+ expect(message.content[0].text.length).toBeLessThan(600);
+ }
+ // The checkpoint itself still covers every message and carries no
+ // fallback marker: this was a real summary, not retained-tail recovery.
+ expect(build.checkpoint.throughMessageId).toBe(`tool-${resultCount - 1}`);
+ expect(build.checkpoint.summary).toContain("Sixty reads, summarized.");
+ expect(build.checkpoint.details).not.toHaveProperty("fallback");
+ await runtime.dispose();
+ });
+});
diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts
index 8e8ca661a..07dac6e8c 100644
--- a/packages/agent-runtime/src/runtime.ts
+++ b/packages/agent-runtime/src/runtime.ts
@@ -175,6 +175,11 @@ import {
withOpenCodeSessionHeaders,
} from "./opencode-session-headers.js";
import { withCompactionRequestHeaders } from "./compaction-request.js";
+import {
+ COMPACTION_SUMMARY_RETRY_POLICY,
+ estimateSummaryPromptTokens,
+ reduceSummaryInput,
+} from "./compaction-summary-input.js";
import {
mergeProviderHeaders,
providerHeadersEqual,
@@ -6120,15 +6125,32 @@ Delegation rules:
// The summary now covers the whole boundary range, so its input is the
// context that tripped the hard limit. On a window whose headroom leaves
// less room for the summary request than the hard limit allows, this is the
- // guard that routes the turn to retained-tail recovery instead.
- const historyTokens = preparation.messagesToSummarize.reduce(
- (total, message) => total + estimateTokens(message),
- 0,
- );
- const previousSummaryTokens = preparation.previousSummary
- ? Math.ceil(preparation.previousSummary.length / 4)
- : 0;
- return historyTokens + previousSummaryTokens >= summaryInputLimit;
+ // guard that routes the turn to retained-tail recovery instead. It sizes
+ // the prompt the way pi serializes it — tool results already capped —
+ // rather than the raw messages, which overstated tool-heavy sessions by
+ // several times and skipped summaries that would have fit (#543).
+ return estimateSummaryPromptTokens(preparation) >= summaryInputLimit;
+ }
+
+ /**
+ * Fit the summary input under the provider budget. The full input is tried
+ * first; when it is too large, one reduced pass (tool results cut to a short
+ * prefix, thinking dropped) is tried before giving up. The reduced input
+ * still covers every message the checkpoint files behind its boundary, so
+ * nothing is silently dropped from the summary's scope (ADR 0282).
+ */
+ private fitSummaryInputToBudget(
+ preparation: ShapedPreparation,
+ budget: { hardLimit: number; requestHeadroom: number },
+ ): ShapedPreparation | undefined {
+ if (!this.compactionSummaryWouldExceedBudget(preparation, budget)) {
+ return preparation;
+ }
+ const reduced = reduceSummaryInput(preparation);
+ if (!reduced || this.compactionSummaryWouldExceedBudget(reduced, budget)) {
+ return undefined;
+ }
+ return reduced;
}
private async persistCheckpoint(
@@ -6294,7 +6316,10 @@ Delegation rules:
this.model,
undefined,
this.thinkingLevel,
- undefined,
+ // Without a policy pi-ai returns the first failed response as-is, which
+ // made a single dropped stream or 503 discard the whole summary (#543).
+ // pi's classifier decides what is transient; the waits honour `signal`.
+ COMPACTION_SUMMARY_RETRY_POLICY,
undefined,
withAbortSignal(signal, BACKGROUND_CONTEXT),
);
@@ -6335,7 +6360,8 @@ Delegation rules:
return this.buildRolloverCheckpoint(entries, budget, preparation.value);
}
- if (this.compactionSummaryWouldExceedBudget(preparation.value, budget)) {
+ const summaryInput = this.fitSummaryInputToBudget(preparation.value, budget);
+ if (!summaryInput) {
return {
ok: false,
entries,
@@ -6349,7 +6375,7 @@ Delegation rules:
let result: Awaited>;
try {
- result = await this.generateCompaction(preparation.value, signal);
+ result = await this.generateCompaction(summaryInput, signal);
} catch (error) {
return {
ok: false,
diff --git a/packages/i18n/src/locales/de/index.ts b/packages/i18n/src/locales/de/index.ts
index 395e09081..85762001d 100644
--- a/packages/i18n/src/locales/de/index.ts
+++ b/packages/i18n/src/locales/de/index.ts
@@ -316,6 +316,7 @@ export const de = {
"compactionRow": "Kontext komprimiert · #{{times}}",
"compactionRowSummary": "Zusammenfassung ≈{{tokens}} Token",
"compactionRowNoSummary": "keine Zusammenfassung generiert",
+ "compactionRowSummaryFailed": "Zusammenfassung fehlgeschlagen · aktueller Kontext beibehalten",
"scrollToBottom": "Zum Neuesten springen",
"minimap": "Gesprächsübersicht",
"resultNeedsAttention": "Diese Aufgabe erfordert Aufmerksamkeit",
diff --git a/packages/i18n/src/locales/en/index.ts b/packages/i18n/src/locales/en/index.ts
index 1439d47e8..dd72f5410 100644
--- a/packages/i18n/src/locales/en/index.ts
+++ b/packages/i18n/src/locales/en/index.ts
@@ -323,6 +323,7 @@ export const en = {
compactionRow: "Context compacted · #{{times}}",
compactionRowSummary: "summary ≈{{tokens}} tokens",
compactionRowNoSummary: "no summary generated",
+ compactionRowSummaryFailed: "summary generation failed · recent context retained",
scrollToBottom: "Jump to latest",
minimap: "Conversation outline",
resultNeedsAttention: "This task needs attention",
diff --git a/packages/i18n/src/locales/es/index.ts b/packages/i18n/src/locales/es/index.ts
index 636204748..8d06b8730 100644
--- a/packages/i18n/src/locales/es/index.ts
+++ b/packages/i18n/src/locales/es/index.ts
@@ -316,6 +316,7 @@ export const es = {
"compactionRow": "Contexto compactado · #{{times}}",
"compactionRowSummary": "resumen ≈{{tokens}} tokens",
"compactionRowNoSummary": "no se generó ningún resumen",
+ "compactionRowSummaryFailed": "falló la generación del resumen · se conservó el contexto reciente",
"scrollToBottom": "Saltar a la última",
"minimap": "Esquema de la conversación",
"resultNeedsAttention": "Esta tarea necesita atención",
diff --git a/packages/i18n/src/locales/fr/index.ts b/packages/i18n/src/locales/fr/index.ts
index f15d7f610..516921344 100644
--- a/packages/i18n/src/locales/fr/index.ts
+++ b/packages/i18n/src/locales/fr/index.ts
@@ -316,6 +316,7 @@ export const fr = {
"compactionRow": "Contexte compacté · #{{times}}",
"compactionRowSummary": "résumé ≈{{tokens}} jetons",
"compactionRowNoSummary": "aucun résumé généré",
+ "compactionRowSummaryFailed": "échec de la génération du résumé · contexte récent conservé",
"scrollToBottom": "Passer au dernier",
"minimap": "Aperçu de la conversation",
"resultNeedsAttention": "Cette tâche nécessite une attention particulière",
diff --git a/packages/i18n/src/locales/ko/index.ts b/packages/i18n/src/locales/ko/index.ts
index 96d7381a7..d7a002902 100644
--- a/packages/i18n/src/locales/ko/index.ts
+++ b/packages/i18n/src/locales/ko/index.ts
@@ -325,6 +325,7 @@ export const ko = {
compactionRow: "컨텍스트 압축됨 · #{{times}}",
compactionRowSummary: "요약 약 {{tokens}}토큰",
compactionRowNoSummary: "생성된 요약 없음",
+ compactionRowSummaryFailed: "요약 생성 실패 · 최근 컨텍스트 유지됨",
scrollToBottom: "최신 항목으로 이동",
minimap: "대화 개요",
resultNeedsAttention: "이 작업을 확인해야 합니다",
diff --git a/packages/i18n/src/locales/tr/index.ts b/packages/i18n/src/locales/tr/index.ts
index 03a69505a..c6bec0655 100644
--- a/packages/i18n/src/locales/tr/index.ts
+++ b/packages/i18n/src/locales/tr/index.ts
@@ -325,6 +325,7 @@ export const tr = {
compactionRow: "Bağlam sıkıştırıldı · #{{times}}",
compactionRowSummary: "özet ≈{{tokens}} token",
compactionRowNoSummary: "özet oluşturulmadı",
+ compactionRowSummaryFailed: "özet oluşturulamadı · son bağlam korundu",
scrollToBottom: "En sona atla",
minimap: "Sohbet özeti",
resultNeedsAttention: "Bu görevin ilgiye ihtiyacı var",
diff --git a/packages/i18n/src/locales/zh-CN/index.ts b/packages/i18n/src/locales/zh-CN/index.ts
index 6d7e8d7a8..aa8cb8357 100644
--- a/packages/i18n/src/locales/zh-CN/index.ts
+++ b/packages/i18n/src/locales/zh-CN/index.ts
@@ -318,6 +318,7 @@ export const zhCN = {
compactionRow: "上下文已压缩 · 第 {{times}} 次",
compactionRowSummary: "摘要 ≈{{tokens}} tokens",
compactionRowNoSummary: "未生成摘要",
+ compactionRowSummaryFailed: "摘要生成失败 · 已保留近期上下文",
scrollToBottom: "回到最新",
minimap: "对话大纲",
resultNeedsAttention: "这次任务需要处理一下",
diff --git a/packages/i18n/src/locales/zh-TW/index.ts b/packages/i18n/src/locales/zh-TW/index.ts
index e28990527..de0201cae 100644
--- a/packages/i18n/src/locales/zh-TW/index.ts
+++ b/packages/i18n/src/locales/zh-TW/index.ts
@@ -318,6 +318,7 @@ export const zhTW = {
compactionRow: "上下文已壓縮 · 第 {{times}} 次",
compactionRowSummary: "摘要 ≈{{tokens}} tokens",
compactionRowNoSummary: "未生成摘要",
+ compactionRowSummaryFailed: "摘要生成失敗 · 已保留近期上下文",
scrollToBottom: "回到最新",
minimap: "對話大綱",
resultNeedsAttention: "這次任務需要處理一下",
diff --git a/packages/shared/src/context-compaction.test.ts b/packages/shared/src/context-compaction.test.ts
index 9df6e1c06..7935083fa 100644
--- a/packages/shared/src/context-compaction.test.ts
+++ b/packages/shared/src/context-compaction.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { ContextCompactionRecord } from "./types.js";
import {
+ checkpointFallback,
checkpointGeneration,
checkpointSummarized,
contextCompactionMark,
@@ -61,6 +62,37 @@ describe("contextCompactionMark", () => {
.summarized,
).toBe(false);
});
+
+ it("flags the retained-tail recovery so the row does not present it as a summary", () => {
+ const mark = contextCompactionMark(
+ record({
+ details: {
+ generation: 2,
+ fallback: "retained_tail",
+ failureCode: "CONTEXT_COMPACTION_FAILED",
+ },
+ }),
+ );
+ expect(mark.fallback).toBe("retained_tail");
+ expect(mark.generation).toBe(2);
+ // Any other value stays absent rather than leaking into the event.
+ expect(
+ contextCompactionMark(record({ details: { fallback: "something_else" } })),
+ ).not.toHaveProperty("fallback");
+ expect(contextCompactionMark(record({ details: { generation: 1 } }))).not.toHaveProperty(
+ "fallback",
+ );
+ });
+});
+
+describe("checkpointFallback", () => {
+ it("only recognizes the retained-tail recovery family", () => {
+ expect(checkpointFallback({ fallback: "retained_tail" })).toBe("retained_tail");
+ expect(checkpointFallback({ fallback: "other" })).toBeUndefined();
+ expect(checkpointFallback({})).toBeUndefined();
+ expect(checkpointFallback(undefined)).toBeUndefined();
+ expect(checkpointFallback("details")).toBeUndefined();
+ });
});
describe("estimateSummaryTokens", () => {
diff --git a/packages/shared/src/context-compaction.ts b/packages/shared/src/context-compaction.ts
index 4b0c029b6..55c553566 100644
--- a/packages/shared/src/context-compaction.ts
+++ b/packages/shared/src/context-compaction.ts
@@ -1,4 +1,5 @@
import type {
+ ContextCompactionFallback,
ContextCompactionMark,
ContextCompactionRecord,
} from "./types.js";
@@ -31,14 +32,28 @@ export function checkpointSummarized(details: unknown): boolean {
return value !== "fresh_window";
}
+/**
+ * Whether the checkpoint is the retained-tail recovery written after summary
+ * generation failed (ADR 0049). Its `summary` is a carried-forward earlier
+ * summary plus a fixed recovery notice, never a fresh model summary.
+ */
+export function checkpointFallback(
+ details: unknown,
+): ContextCompactionFallback | undefined {
+ const value = (details as { fallback?: unknown } | null | undefined)?.fallback;
+ return value === "retained_tail" ? value : undefined;
+}
+
export function contextCompactionMark(
record: ContextCompactionRecord,
): ContextCompactionMark {
+ const fallback = checkpointFallback(record.details);
return {
id: record.id,
throughMessageId: record.throughMessageId,
generation: checkpointGeneration(record.details),
summaryTokens: estimateSummaryTokens(record.summary ?? ""),
summarized: checkpointSummarized(record.details),
+ ...(fallback ? { fallback } : {}),
};
}
diff --git a/packages/shared/src/types/sessions.ts b/packages/shared/src/types/sessions.ts
index f793effd8..812eb9f98 100644
--- a/packages/shared/src/types/sessions.ts
+++ b/packages/shared/src/types/sessions.ts
@@ -106,6 +106,12 @@ export type ContextCompactionMark = ContextCompactionStatus & {
throughMessageId: string;
/** False when the window rolled over without asking for a summary. */
summarized: boolean;
+ /**
+ * Present when summary generation failed and the checkpoint carries only a
+ * recovery notice plus a retained tail; the row must not present that
+ * notice as a summary.
+ */
+ fallback?: ContextCompactionFallback;
};
export type ContextCompactionReason = "manual" | "threshold" | "overflow";