Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions apps/desktop/src/features/chat/transcript/AssistantTurn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}

Expand Down Expand Up @@ -376,11 +377,13 @@ export function CompactionRow({ mark }: { mark: ContextCompactionMark }) {
{t("chat.compactionRow", { times: mark.generation })}
</span>
<span className="transcript-compaction-detail">
{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")}
</span>
</div>
);
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/lib/assistant-turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/test/context-compaction.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion docs/adr/0049-context-compaction-failure-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
132 changes: 132 additions & 0 deletions docs/adr/0282-compaction-summary-retry-and-sizing.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
7 changes: 5 additions & 2 deletions docs/spec/03-runtime/01-ipc-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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.

Expand Down
33 changes: 21 additions & 12 deletions docs/spec/03-runtime/02-agent-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions docs/spec/06-delivery/04-e2e-test-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/spec/08-meta/decisions-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading