Skip to content

feat(agent-runtime): describe a failed compaction's range without a model - #688

Open
zhangqingkun976 wants to merge 1 commit into
vastsa:mainfrom
zhangqingkun976:feat/deterministic-compaction-recovery
Open

zhangqingkun976 wants to merge 1 commit into
vastsa:mainfrom
zhangqingkun976:feat/deterministic-compaction-recovery

Conversation

@zhangqingkun976

@zhangqingkun976 zhangqingkun976 commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

When the summary request fails, the session drops to a carried-forward summary
plus a recovery notice: the range's real history is gone and the next window is
told nothing about what the work was. The failure this was written for is the
evidence — 750k tokens of work replaced by a notice and a list of file names,
with no statement of the goal, of what went wrong, or of what was left to do.

This adds one rung to that ladder, ahead of the notice. It is model-free: no
provider request, no clock, no filesystem, so it cannot fail on a provider and it
costs nothing to try. Everything it does is a pure function of the messages in
the range, which is what lets the whole degradation path be tested without a
provider stub.

New module packages/agent-runtime/src/compaction-trajectory.ts. It builds
a summary from the range mechanically:

  • Goal — the last user request, verbatim (bounded at 1,500 chars). Verbatim
    on purpose: a paraphrase is what lets a next window redo work whose wording it
    cannot tell apart from a different request.
  • Progress — how many messages and tool calls the range holds, and how many
    tool calls failed.
  • Blocked — failures in the range that nothing later repaired. An edit that
    landed for the same path afterwards resolves its failure and drops it, so a
    test run that failed and was then fixed is not listed; a failure that cannot be
    attributed to a path is always kept, because an unexplained failure is exactly
    what a next window needs to know about. Two attribution rules keep that from
    hiding a real failure: a call id is matched to the nearest preceding call
    with that id (OpenAI-compatible local servers emit 1, 2, … per response, so
    ids repeat inside a range), and a repair counts only when its own result came
    back without an error.
  • Next Steps — unchecked checkboxes and literal TODO lines from the last
    assistant message. Only markers that cannot appear by accident are read; a
    looser rule (any line under a "next steps" heading) would turn prose into a
    task list the model never committed to.

The section headings and the minimum length are the ones the existing summary
check enforces, so a trajectory checkpoint is structurally a real summary: the
next summarization request can carry it forward and every reader sees the same
shape regardless of which layer produced it.

Wiring in recoverCompactionFailure: the deterministic record is built and
persisted first, and the retained-tail notice stays exactly where it was as the
last resort — when the record does not fit the safe budget either, the notice
still runs, so a summary failure never ends without a checkpoint. The carried
summary is placed ahead of COMPACTION_FALLBACK_MARKER, the same layout the
notice uses, so a chained compaction recovers the real carried history and drops
this range's description instead of cementing it (#224). Its details carry
strategy: "trajectory", failureCode, retainedTailMode and a small
trajectory block (counts, open items, whether a goal was found) for the
session inspector; fallback is deliberately left unset, so the last resort
keeps its own identity and existing readers are unaffected. The retained-tail
selection both layers use is now one helper (retainedTailForDegradedCheckpoint)
rather than two copies.

Verification (Windows 11, re-run on the rebased head 09c26ba1, with
@pi-desktop/shared rebuilt first — see the correction comment below about a
stale local artifact):

pnpm --filter @pi-desktop/agent-runtime typecheck          exit 0
vitest run (full package suite)                            790 passed / 1 failed
node --test apps/desktop/test/context-compaction.test.mjs  13 passed / 0 failed
pnpm test:e2e (scripts/e2e-smoke.mjs)                      23 passed / 2 skipped

The one failing test is the pre-existing src/native-pi-session.test.ts
(native fork children > never deletes a foreign publication ...), which fails
identically on pristine main. The two skipped E2E steps
are gated on PI_DESKTOP_TEST_API_KEY (E2E-008-live-model, E2E-009-stream).

Three existing tests changed because the ladder changed, and their expectations
were tightened rather than relaxed: the summary-failure test now asserts the
record was persisted once, that its details.strategy is trajectory, that the
goal text and the ## Goal section are in the summary and that the marker is
still there; the two carried-summary tests assert the same while keeping their
original "the carried summary survives" checks. A fourth test was added for the
last resort: with the record forced oversized, the notice is written and it does
not carry the record's sections. Mutation-checked: forcing the record's persist
to report oversized turns the recovery tests red and restoring it turns them
green.

shrinkSummarizeRange (retrying an oversized summary over a smaller range) is
deliberately not part of this PR. fitSummaryInputToBudget already handles
that case by reducing the prompt while keeping every message in scope (ADR 0282),
and dropping the oldest part of the range is the trade that decision made
against. If you would rather have the range shrink as a second rung after the
reduced prompt, it is a separate, smaller change and I can open it on its own.

Base and head

base 206085c07 · head 09c26ba1 · 5 files, +900 / −32. Rebased onto current main (82 commits
further on); it applied cleanly except for one same-point insertion in
ModelSelectionPanes.tsx on the branches that touch it, where both sides were
kept. The published tree is verified to be the same object the gates ran on:

local tree : 2a70d0d4bedf0c88a169a075c9f2c11126be8713
remote tree: 2a70d0d4bedf0c88a169a075c9f2c11126be8713

@zhangqingkun976

Copy link
Copy Markdown
Contributor Author

The Typecheck Desktop failure on this PR is inherited from main, not caused
by this change. Evidence:

  • CI #1197 on 166ada68 (this PR's base) fails at the same step with
    electron/main/user-login-path.ts(28,74): error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'PathLike'.
  • CI #1195 (967f3c2c) and CI #1192 (db16f9ca) fail the same way; the last
    green main run I can see is CI #1189 (4fedb8e4).
  • Locally, on a clean checkout of 166ada68 with
    pnpm --filter @pi-desktop/desktop typecheck, the same file/line/column
    fails. With only that one file patched (nothing from this PR), the command
    exits 0.

So the red gate will clear once the login-shell probe type error is fixed —
I opened #692 for exactly that, one line, and it is independent of this PR.

For this PR itself the relevant surfaces are green: tsc on the agent-runtime
package (exit 0), vitest run src/compaction-trajectory.test.ts (25/25),
vitest run src/runtime.test.ts (202/202), and the full package suite 43/44
files — the one failure being the pre-existing native-pi-session.test.ts case
that fails identically without this patch.

@zhangqingkun976
zhangqingkun976 force-pushed the feat/deterministic-compaction-recovery branch from 4620603 to dad2224 Compare September 20, 2026 11:01
@zhangqingkun976
zhangqingkun976 force-pushed the feat/deterministic-compaction-recovery branch from dad2224 to 71a52fe Compare September 20, 2026 11:14
@zhangqingkun976

Copy link
Copy Markdown
Contributor Author

The first CI run on this branch failed one test, and it was mine to fix rather
than flaky:

apps/desktop test: ✖ a failed compaction checkpoint still restores a non-empty context

apps/desktop/test/context-compaction.test.mjs is a source-contract test over
runtime.ts, and it pinned the exact inline expression this change extracted:

const retainedTail = preparation.retainedTail.length > 0 ? … : selectRetainedUserMessages(

That expression is now retainedTailForDegradedCheckpoint(preparation), shared
by both degraded layers, so the contract is updated rather than the code
reverted — and the updated assertion is stronger than the old one: it requires
the helper to contain the empty-tail fallback and requires exactly two call
sites, so the rule cannot hold in one path and be forgotten in the other.

Verified locally after the change:

node --test apps/desktop/test/context-compaction.test.mjs        12/12 pass

I also checked which other desktop tests read this source: only that file
matches retainedTail|recoverCompactionFailure|createFallbackCheckpoint| COMPACTION_FALLBACK|compaction-trajectory.

The branch is rebuilt on the current main (aad46adb), so the earlier
inherited Typecheck Desktop failure is gone (that fix, #692, has merged), and
the Rust job already passed on this branch.

@zhangqingkun976

Copy link
Copy Markdown
Contributor Author

CI status on the current head (71a52fe9), for the record:

  • JS build / typecheck / lint / architecture / test: success. The desktop
    source-contract failure from the first run is fixed (see the comment above).

  • Rust host-core format / lint / test: failure — and it is not from this
    change, which touches only TypeScript:

    test plugins::tests::an_install_reports_progress_and_honours_a_cancel ... FAILED
    thread '…' panicked at crates/host-core/src/plugins/tests.rs:2099:14
    test result: FAILED. 558 passed; 1 failed
    

    main's own CI on this PR's base (aad46adb, CI #1229) is green, so this
    looks like a flake in that install/cancel test rather than a regression. I
    have not touched anything in crates/ or that test — deliberately, since it
    is unrelated to this PR.

I can't re-run the job myself (the API answers 403 Must have admin rights to Repository), so if you agree it is a flake, a re-run of the failed job should
turn this branch green.

@zhangqingkun976

Copy link
Copy Markdown
Contributor Author

#688 / #700 / #705 are all included in #721

These three are the same work split small. I have assembled them (plus the
recall tools, the session ledger, the configurable gate/silent pass/sleep
digest and the single-tier wording) into one change: #721
feat/context-and-compaction-set.

Nothing in them changed; #721 is strictly a superset. Review whichever shape you
prefer �� if you would rather land the small ones one at a time, they are still
accurate as written and I will keep them updated against main. If you would
rather review the feature as a whole, #721 is the one to take and I will close
these three on request.

zhangqingkun976 added a commit to zhangqingkun976/PI-Desktop that referenced this pull request Sep 20, 2026
…r-model context settings

# Context and compaction, as one set

This assembles the context/compaction work into a single reviewable change: the
model can read its own history back, compaction stops being a one-way door, and
the two thresholds that decide *when* it happens are configurable per model.
It subsumes my three open PRs (vastsa#688, vastsa#700, vastsa#705) — those are the same code, split
differently; review whichever shape you prefer.

## 1. The model can read its own history (`recall`, `recall_project`)

**Host.** `recall_transcript` searches a session's complete transcript and ranks
candidates by match strength (how many query words, how often), and
`read_message_text` pages one message back in bounded character windows — which
is how a tool result (not in the word index) is read. `search_project_messages`
+ `read_project_messages` do the same across one project's sessions, and both
take a project id the host resolves from a path; a session bound to another
project reads as *not found*, so session existence never leaks across projects.
`SleepRecord` + `append_sleep` add a `sleep` line kind to the transcript: the
layout scan ignores it and it never counts as a message. Five RPC methods carry
this: `session.recall`, `session.readMessage`, `search.query`,
`session.readProject`, `session.appendSleep`.

**Model side.** `packages/agent-runtime/src/recall-tools.ts` holds both tools and
takes a two-method host adapter (so both are unit-testable without a session,
a provider or a process). Both are registered in the **core** tool set: a
capability the model has to go looking for is one it will not reach for while it
is trying to recover what a summary left out.

The query rule is the host's, not a preference, so the description states it
(split into words, every word must appear, unspaced Chinese matches literally)
and tells the model to write the query in the words of whoever wrote the
message. An empty answer says what to try instead; a bare "no matches" teaches
nothing.

## 2. Compaction stops being a one-way door

- **The projected summary carries a recall pointer**: pre-boundary messages are
  not deleted, and `recall` reads them back verbatim. The pointer is a promise,
  so it is only appended because the tool exists.
- **A session ledger** is built mechanically when a checkpoint is installed
  (files read/modified, commands, message and tool-call counts, plus the goal
  and the unresolved items) and stored on the checkpoint's opaque `details`.
  The projection renders it as a bounded block after the summary, so the next
  window keeps an account of what the boundary covered — text that is one
  `recall` call away. A checkpoint written before the ledger existed projects
  exactly the pointer and nothing else.
- **The wording now matches the mechanism.** The second budget reminder
  (Codex's `AutoCompactFallbackPrompt`: "unsummarized detail will not be
  available afterwards") is deleted rather than rephrased — that sentence is
  false here. The rollover text says where the messages went and how to read
  them again instead of telling the model the transcript is available "to the
  user".

## 3. Two thresholds, per model

| Setting | Default | What it does |
| --- | --- | --- |
| `dynamicContext.thresholdPercent` | window-aware (`100 − max(100k, 15%×window)/window`, clamped 40–95) | At this share of the hard limit, old tool results are shortened to a head plus a recovery pointer. Off means nothing is narrowed. |
| `earlyCompaction` `{thresholdPercent, delaySeconds, silent}` | 75 / 120 s / silent | After a run settles and the session stays idle that long, the pass compacts **in the background**. Any new prompt stands it down before it spends its summary request. |
| `sleepTime` `{enabled, maxRunsPerHour}` | off / 2 | On the idle arm, write a deterministic digest of where the work stands to the transcript (no model call), so a summary request that never returns still leaves the next window with the goal and the open items. |

The **coupling** is the point: the narrowing gate is what the outgoing request,
the hard-boundary check and the early pass all read, so what narrowing saves is
visible to compaction instead of compaction firing while real room remained. The
early pass is the one place that spends a summary request off the critical path,
and it is silent **only when it succeeded** — a degraded pass warns exactly like
the inline path, and the hard boundary always warns.

`compaction_end` reports `idle` and `silent`; the transcript row, the context
inspector and `recall` record the checkpoint either way, and the renderer skips
only the routine toast.

## Verification (Windows 11)

    cargo test -p host-core --bin pi-desktop-host-core     555 passed, 4 failed
    cargo fmt / clippy                                     clean (1 pre-existing warning)
    pnpm --filter @pi-desktop/shared typecheck|build        exit 0
    pnpm --filter @pi-desktop/agent-runtime typecheck       exit 0
    pnpm --filter @pi-desktop/agent-runtime test            45/46 files
    pnpm --filter @pi-desktop/desktop typecheck             exit 0

- The 4 Rust failures are pre-existing Windows path-separator assertions in
  `mcp_servers`, `user_skills`, `scheduled_rpc` and `scheduled_tools`; two of
  those files are untouched by this change (hash-verified).
- The 1 agent-runtime failure is the pre-existing
  `src/native-pi-session.test.ts` case ("never deletes a foreign publication…"),
  which fails identically without this change.
- New tests: 6 Rust (recall matching/ranking, non-ASCII folding, character
  paging, sleep append + layout, project search scoping, project read
  isolation), 10 for the recall tools (both tools, paging, the empty-answer
  advice, the output cap, project scoping, a host failure surfacing as text),
  2 for the summary projection (pointer + ledger; a pre-ledger checkpoint
  projects the pointer alone).
- Four existing tests were updated to the new contract, each strengthened rather
  than relaxed: the core tool list now includes both recall tools; the budget
  reminder is asserted to have **one** tier (and the sharper second one to be
  absent); the projected summary is asserted to carry the recall pointer; the
  dormant second-tier flag is gone.

## What is deliberately not here

- **The settings pane UI.** The three settings are typed, clamped, persisted with
  the model binding and carried on the launch payload, but the pane's controls
  and their translations are not in this change — so today they are set through
  the binding rather than the UI. That is the next piece.
- **Estimate calibration (vastsa#683).** The reviewer asked for a one-way (never
  lower) correction and a ratio-based unanchored term before it lands; that
  rework is in progress and will come as its own PR.
- **The semantic/embeddings recall channel.** Measured and frozen in my build:
  it needs a model asset and a host channel for a gain the lexical channel
  already gets on the queries that matter here.
@zhangqingkun976
zhangqingkun976 force-pushed the feat/deterministic-compaction-recovery branch from 71a52fe to e3bdd90 Compare September 21, 2026 00:49
@zhangqingkun976

Copy link
Copy Markdown
Contributor Author

Rebased onto main (79cd7ae8); it applied cleanly, no conflicts. New head e3bdd904.

This also re-runs the Rust job, which failed on the previous run only on plugins::tests::an_install_reports_progress_and_honours_a_cancel — a flake in the install/cancel test, not related to this change (it touches TypeScript only).

Local before pushing: agent-runtime 47/48 test files (the single failure is the pre-existing native-pi-session case, identical on pristine main), apps/desktop/test/context-compaction.test.mjs 13/13.

This PR is also part of #721, if you would rather review the feature as a whole.

@zhangqingkun976
zhangqingkun976 force-pushed the feat/deterministic-compaction-recovery branch 2 times, most recently from 73dd622 to bb5ecfe Compare September 21, 2026 05:30
@zhangqingkun976

Copy link
Copy Markdown
Contributor Author

Rebased onto main (ab39a9b4) so the new Head contains latest base gate passes. Head is now bb5ecfe5.

The only conflicts were append-vs-append — the E2E scenario catalog and the decisions log, where main and this branch each added an entry — and both sides are kept. The identifier bands are still free: upstream's newest ADR is 0299 and its newest decisions-log entry is D607 (the plugin crash report, which is this repository's own fix for issue #747 built on my #756).

@zhangqingkun976
zhangqingkun976 force-pushed the feat/deterministic-compaction-recovery branch from bb5ecfe to 182b289 Compare September 21, 2026 09:27
@zhangqingkun976

Copy link
Copy Markdown
Contributor Author

Rebased onto main (b71fcf05, 39 commits further on); it applied cleanly — main's own changes to runtime.ts and runtime.test.ts are in other regions. New head 182b2895.

Local gates on the rebased head:

  • pnpm --filter @pi-desktop/agent-runtime typecheck exit 0; full package suite 774 passed / 1 failed, the failure being the pre-existing native-pi-session case (never deletes a foreign publication…), identical on pristine main.
  • node --test apps/desktop/test/context-compaction.test.mjs 13/13.
  • pnpm test:e2e (scripts/e2e-smoke.mjs, the headless protocol suite) 23/23 passed, 2 skipped — the skips are the two steps that need PI_DESKTOP_TEST_API_KEY.
  • node scripts/check-pr-base-main.mjs passed: b71fcf05 is an ancestor of the head, so the base gate is green again.

The description now carries the base/head line and the E2E status in full.

@zhangqingkun976
zhangqingkun976 force-pushed the feat/deterministic-compaction-recovery branch from 182b289 to cdaab56 Compare September 21, 2026 12:33
@zhangqingkun976

Copy link
Copy Markdown
Contributor Author

Rebased onto main (43a37373, 24 commits further on). New head cdaab56e, one commit.

It applied cleanly, including the convertToLlm seam in runtime.ts that main has since rewritten: your tool-call-dedupe.ts refactor of my #780 now sits there, and git's three-way merge composed it with this change instead of dropping either side. That composition is verified rather than assumed — on the two branches that add a pass at that seam (#705 and #721, a superset of it) the seam now reads this.narrowToolResultsUnderPressure(this.dropDuplicateToolCalls(messages)), so the dedupe runs first and the tiering second. Both passes are identity functions when they have nothing to do, which keeps this PR's original promise that an ordinary request is byte-identical.

Local gates on the rebased head: node scripts/check-pr-base-main.mjs passed; cargo fmt --all -- --check 0 where the branch touches Rust; pnpm docs:check 79 EN/zh pairs, 499 pages on the branches that touch docs. mergeable=true, behind=0.

One thing to know when reading the JS job: main has landed image generation and its new suite is red on main itself. Pristine 43a37373 fails 8 tests across 4 files (native-pi-session, parent-host-proxy, image-generation, openai-images-contract); this branch fails exactly the same 8 and adds its own on top. I have not touched those files.

…odel

Rebased onto main (79cd7ae); the rebase applied cleanly, no conflicts.

When summary generation fails, the checkpoint is now built from the range it
covers — a deterministic description plus a retained tail — instead of a notice
that leaves the next window without anything to restore. Both degraded layers
share one helper, so an empty tail can never be persisted for a completed turn.

C:\Users\10470\.pi-desktop\scratch\cb9e2d41-8a55-4a18-899d-92ca2791ab51\commit-688-r2.txt
@zhangqingkun976
zhangqingkun976 force-pushed the feat/deterministic-compaction-recovery branch from cdaab56 to 09c26ba Compare September 22, 2026 01:34
@zhangqingkun976

Copy link
Copy Markdown
Contributor Author

Correction to my previous comment on this PR. I wrote that pristine main failed 8 tests across 4 files (native-pi-session, parent-host-proxy, image-generation, openai-images-contract) and that the image-generation failures were main's own. That was wrong, and the fault was mine, not main's.

What actually happened: my local packages/shared/dist was stale. main had landed image generation, which added exports to @pi-desktop/shared, and my built dist predated them — so agent-runtime imported names that did not exist in the artifact it was type-checking and running against. Six of the eight failures, and the three typecheck errors I saw, were produced by my environment.

After rebuilding @pi-desktop/shared and re-measuring on pristine main (206085c07):

  • pnpm --filter @pi-desktop/agent-runtime typecheckclean, 0 errors
  • pnpm --filter @pi-desktop/agent-runtime test763 passed, 2 failed

The two real baseline failures are:

  1. src/native-pi-session.test.ts > native fork children > never deletes a foreign publication and classifies the failure path-free — a long-standing Windows path assertion, unrelated to this change.
  2. src/hosted-search-contract.test.ts > forwards hosted_search_update as message_update — a 5 s timeout that is intermittent: it passes standalone and on reruns.

So main is not red, and image generation is not broken. I should have rebuilt the workspace dependency before drawing a conclusion from a failure I did not recognise; the fact that the failures appeared only after main gained a new feature should have pointed at my stale artifact first.

This head 09c26ba1 was measured the same way, with the dependency rebuilt: the suite fails exactly the single native-pi-session case above, and nothing else. No file in the image-generation or hosted-search area is touched by this PR.

Apologies for the noise — and for stating a baseline I had not verified. The CI panel on this head is the authoritative record, and it is green.

This branch was previously deployed

1 inactive deployment
Preview 09c26ba1 Deployed Sep 22, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant