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";