Skip to content

feat(providers): manual context compaction for every provider - #430

Open
rynfar wants to merge 6 commits into
pylonfrom
upstream/2026-09-09-context-compaction
Open

feat(providers): manual context compaction for every provider#430
rynfar wants to merge 6 commits into
pylonfrom
upstream/2026-09-09-context-compaction

Conversation

@rynfar

@rynfar rynfar commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Manual context compaction worked for exactly one provider in Pylon: Prime, through
its own richer control surface. Every other provider had no way to compact a thread.

This adopts upstream's compaction command, so /compact now works on Codex,
Claude, Cursor, Grok, OpenCode and Antigravity — natively where the adapter
supports it, and as the provider's own slash command everywhere else.

What changed

Adapters declare how they compact:

Provider Strategy
Codex, OpenCode native — the adapter's own compaction call
Claude, Grok, Antigravity slash-command /compact
Cursor slash-command /compress
Prime none — keeps its own session.compaction.updated control surface

ProviderService.compactThread routes to the declared strategy, watches the
runtime stream for the compacting turn, and settles when the provider reports a
compacted thread state or the turn ends. The /compact message never reaches the
provider as ordinary text; it becomes a compaction request in the reactor.

Compaction rows render as their own timeline entry on web and mobile, labelled
with token counts when they can be derived, and fold under a settled turn fold
alongside the rest of that turn's work.

Adopted from T3 Code

  • c5ba51d629b3813182cf3e161cc3f23b1e541dc3 (#9293) context compaction command
  • 5f878d2a85807618a4c8571cdef5daa3124672d6 (#9623) fold compaction under settled turn folds
  • 5fa35d211682ee02e34fba0711838ca431ed003b (#10112) adapters declare context compaction

Ported at the frozen upstream head as one change, so the adapter surface lands in
its final compaction shape rather than passing through #9293's intermediate
compactThread field.

Pylon divergences reconciled

Each of these is a place where a mechanical port compiled cleanly and behaved
wrongly at runtime.

  • Runtime events are incarnation-fenced. processRuntimeEvent drops any event
    whose sessionIncarnationId does not match the thread's current incarnation —
    that is what stops a replaced adapter writing into a new session. Upstream has
    no such gate, so its test fakes emit unstamped events. Left as-is, every
    compaction-terminal event was discarded and each compaction hung to its full
    timeout. The fakes now stamp their events the way real adapters do; the fence is
    untouched.
  • Compaction never holds a pending turn admission. Pylon's decider reserves an
    admission for every thread.turn.start, including /compact, but compaction
    never becomes a provider turn that could accept it. restoreCompaction now
    retires those fields when it returns the session to ready — without that, one
    /compact left the admission set forever and the decider rejected every later
    turn on the thread.
  • The busy check is ownership, not status. Pylon admits a turn before the
    reactor observes its intent event, so the thread already reads starting for
    the compaction's own request. Upstream's status === "starting" guard would
    reject every compaction; it is now an ownership test against activeTurnId and
    a differing pendingTurnRequestId.
  • Context-window totals are upserted, not appended. contextWindowActivityId
    is a stable per-thread key, so Pylon keeps one context-window.updated row
    instead of appending one per token tick. Upstream derives 899K → 19K by
    scanning two successive rows, which cannot work here. A bounded
    contextWindowHistoryByThreadId cache retains the last two totals instead, so
    the label survives without reintroducing a row per update.
  • Compaction interception respects Pylon's event tail. Upstream returns
    immediately after publishing; Pylon's processRuntimeEvent still has admission
    cleanup and session teardown to do, so the interception is a publish decorator
    rather than an early exit.

Fixed along the way

provider.session.stop.failed existed in the activity kind union but nothing ever
appended it, so a provider stop that failed was silent. Every stop path in the
reactor now runs through withSessionStopTracking, which records the failure with
its cause.

Verification

  • Tests: 629 server tests across 8 files, 484 client tests across 8 files.
  • Types: all seven affected packages clean.
  • Lint, format, unused exports: clean across the 57 changed files; knip:check clean.

Three upstream test expectations were adapted to Pylon's session model rather than
to weaken them, each commented in place: concurrent turns during compaction are
refused by Pylon's decider instead of recorded as a reactor activity, and Pylon's
decider persists the stopped transition in the same transaction as the stop
intent, so a session reads stopped where upstream still reads starting.

No migration. No new dependency.

Model and harness: Claude Opus 5 via Claude Code.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

maria-rcks and others added 4 commits September 9, 2026 01:33
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
(cherry picked from commit c5ba51d629b3813182cf3e161cc3f23b1e541dc3)
…623)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>
(cherry picked from commit 5f878d2a85807618a4c8571cdef5daa3124672d6)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 5fa35d211682ee02e34fba0711838ca431ed003b)
Compaction never becomes a provider turn, so it must not keep the turn
admission Pylon's decider reserves for every thread.turn.start; the busy
check becomes an ownership test because Pylon admits a turn before the
reactor sees its intent; and the compaction label reads a retained pair of
context-window totals because Pylon upserts one row per thread instead of
appending one per token tick.

Also records provider.session.stop.failed, which existed in the activity
kind union but was never appended, and stamps the test fakes' events with
their session incarnation the way real adapters do.
@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL labels Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 13.7 KiB 13.8 KiB +139 B (+1.0%) 15.1 KiB
Codex Thread snapshot wire 7.1 KiB 7.1 KiB −16 B (−0.2%) 7.3 KiB
Codex Live turn WebSocket wire 6.5 KiB 6.7 KiB +155 B (+2.3%) 7.8 KiB
Codex Live turn WebSocket decoded 56.5 KiB 57.3 KiB +866 B (+1.5%) 66.4 KiB
Codex Live turn messages 8 9 +1 (+12.5%) 21
Claude Total thread wire 13.7 KiB 13.8 KiB +100 B (+0.7%) 15.1 KiB
Claude Thread snapshot wire 7.1 KiB 7.1 KiB −11 B (−0.2%) 7.3 KiB
Claude Live turn WebSocket wire 6.6 KiB 6.7 KiB +111 B (+1.7%) 7.8 KiB
Claude Live turn WebSocket decoded 57.3 KiB 58.1 KiB +840 B (+1.4%) 66.4 KiB
Claude Live turn messages 8 8 0 (0.0%) 21

Baseline: 9928508 · PR result: a75fc57 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 111.2 KiB
  • Claude decoded thread snapshot: 111.9 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants