From 3295c1dbcb95e2dcd341d896d64b25beb611cc53 Mon Sep 17 00:00:00 2001 From: vastsa Date: Fri, 18 Sep 2026 02:48:40 +0800 Subject: [PATCH] fix(agent-runtime): retry and right-size the compaction summary before falling back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #543: on real long sessions most automatic compactions ended as the ~112-token retained-tail recovery notice, and the transcript row labelled each one "summary ≈112 tokens". Three things made the ADR 0049 fallback the common outcome instead of the exception: - compact() was called without a RetryPolicy, so pi-ai returned the first failed response as-is. The main turn retries transient failures through the streamFn wrapper, but the summary goes through Models.completeSimple and never reaches it; one dropped stream or 503 discarded the summary. - The preflight guard summed estimateTokens over the raw messages, while pi-agent-core serializes the conversation to text and caps every tool result at 2 000 characters. Tool-heavy sessions looked several times larger than the prompt that would be sent and skipped the model for summaries that would have fit. - ContextCompactionMark could not tell a fallback from a summary, so the row rendered the recovery notice as a successful summary of N tokens. The summary request now carries a bounded pi-ai RetryPolicy (3 retries, 2s/4s/8s, abort-aware; pi-ai's classifier decides what is transient, so quota and auth failures still return at once). The guard sizes the prompt pi actually serializes, and when that still exceeds the window it tries exactly one reduced input (tool results cut to a 500-char prefix, thinking dropped, no message removed) before the fallback runs. The mark gains an additive `fallback?: "retained_tail"` derived from persisted details, and the row labels such checkpoints as a failed summary in all locales. Supersedes PR #554, which retried every recoverable failure including deterministic ones and shrank the input by dropping the oldest messages while the checkpoint still claimed to cover them. Records ADR 0282 / D445 and updates E2E-084, the runtime spec, and the IPC mark shape. --- .../chat/transcript/AssistantTurn.tsx | 15 +- apps/desktop/src/lib/assistant-turns.ts | 3 +- apps/desktop/test/context-compaction.test.mjs | 4 + ...049-context-compaction-failure-recovery.md | 4 +- ...282-compaction-summary-retry-and-sizing.md | 132 ++++++++ docs/adr/README.md | 3 +- docs/spec/03-runtime/01-ipc-protocol.md | 7 +- docs/spec/03-runtime/02-agent-runtime.md | 33 +- docs/spec/06-delivery/04-e2e-test-plan.md | 11 +- docs/spec/08-meta/decisions-log.md | 1 + docs/zh-CN/adr/index.md | 3 +- docs/zh-CN/spec/03-runtime/01-ipc-protocol.md | 6 +- .../zh-CN/spec/03-runtime/02-agent-runtime.md | 9 +- .../spec/06-delivery/04-e2e-test-plan.md | 6 +- docs/zh-CN/spec/08-meta/decisions-log.md | 1 + .../src/compaction-summary-input.test.ts | 176 ++++++++++ .../src/compaction-summary-input.ts | 116 +++++++ packages/agent-runtime/src/runtime.test.ts | 303 ++++++++++++++++++ packages/agent-runtime/src/runtime.ts | 50 ++- packages/i18n/src/locales/de/index.ts | 1 + packages/i18n/src/locales/en/index.ts | 1 + packages/i18n/src/locales/es/index.ts | 1 + packages/i18n/src/locales/fr/index.ts | 1 + packages/i18n/src/locales/ko/index.ts | 1 + packages/i18n/src/locales/tr/index.ts | 1 + packages/i18n/src/locales/zh-CN/index.ts | 1 + packages/i18n/src/locales/zh-TW/index.ts | 1 + .../shared/src/context-compaction.test.ts | 32 ++ packages/shared/src/context-compaction.ts | 15 + packages/shared/src/types/sessions.ts | 6 + 30 files changed, 901 insertions(+), 43 deletions(-) create mode 100644 docs/adr/0282-compaction-summary-retry-and-sizing.md create mode 100644 packages/agent-runtime/src/compaction-summary-input.test.ts create mode 100644 packages/agent-runtime/src/compaction-summary-input.ts 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";