Skip to content

feat(agent-runtime): tier old tool results when the outgoing view is under pressure - #705

Open
zhangqingkun976 wants to merge 1 commit into
vastsa:mainfrom
zhangqingkun976:feat/tool-result-tiering
Open

zhangqingkun976 wants to merge 1 commit into
vastsa:mainfrom
zhangqingkun976:feat/tool-result-tiering

Conversation

@zhangqingkun976

@zhangqingkun976 zhangqingkun976 commented Sep 20, 2026 •

Copy link
Copy Markdown
Contributor

Tool results are most of the context mass, and a single request can carry many
individually-legal ones: the per-result budgets in
docs/spec/03-runtime/16-tool-result-limits.md §2 bound each result as it is
produced (Read 128 KB, shell 96 KB, spill files), but nothing bounds the
aggregate a request carries, so a few old results stay in the window at full
size until the whole thing trips the compaction threshold and pays for a lossy
summary.

This adds the missing layer: under pressure, an outgoing view keeps the head of
an old tool result and names the way to read the rest. The stored messages are
never touched — the projection is rebuilt per request — so a later request with
room renders the full text again.

A recovery path or nothing. A result is narrowed only when this module can
name a real place the rest of it lives, and the pointer names that place
exactly:

  • a file-backed Read result points at the next line of that file
    (Continue with Read path="…" offset=N). N is exact rather than
    approximate: host-core renders read windows as N: line with the file's own
    line numbers, so the last row kept is where the continuation starts;
  • a shell result whose output the host spilled points at the spill file
    (Full output saved to …, spec §3a) and says to Read/Grep it;
  • everything else is left whole — a Grep or Glob result (re-running it is
    not guaranteed to return the same lines, so the pointer would not be exact), a
    shell result that was never spilled, a Read whose call named no path. A
    pointer the reader cannot act on is worse than a long result.

Four precision knobs, each with a documented default: keepMarkers ([keep],
matched as a case-insensitive plain substring — result text is user content and
its metacharacters must not be interpreted), excludeTools (subagent reports and
tool-discovery results are the least replaceable evidence), workingSetPaths
(the newest file-touching calls in the same batch stay whole: a file the session
just re-opened is still in play), and clearAtLeastChars (8 000 — rewriting the
view breaks the prompt cache for everything after it, so a small saving is not
worth taking).

Wiring, two places:

  • convertToLlm — the outgoing view is built there, so that is where the pass
    runs. Below the gate (0.6 × hard limit) it returns the same array, so an
    ordinary request is byte-identical to before.
  • automaticCompactionNeeded — the decision reads the same narrowed view, so
    the room the pass recovers is visible to it instead of compaction firing while
    real room remained.

Verification (Windows 11; re-run on the rebased head a90bcb18, with
@pi-desktop/shared rebuilt first):

pnpm --filter @pi-desktop/agent-runtime typecheck          exit 0
vitest run (full package suite)                            785 passed / 1 failed
  (the pre-existing native-pi-session case, identical on pristine main;
   src/tool-result-tier.test.ts is inside the passing files)
pnpm test:e2e (scripts/e2e-smoke.mjs)                      23 passed / 2 skipped

Mutation-checked, both directions: removing the pressure gate turns
leaves the view untouched below the pressure gate red; removing the
recovery-path refusal turns leaves a result whole when no recovery path can be named red; restoring each turns them green again.

Two honest characterizations:

  • In a session that has touched fewer than nine files, nothing narrows: every
    touched path is inside the working set, which is the point of that rule but
    also means this pass is inert in the single-file case. It targets the long
    session whose window is mostly old tool output.
  • Spec §4 says the per-result cap "does not bound a parallel batch in aggregate,
    and it does not need to during context compaction". That sentence stays true as
    written — compaction-time retention is untouched — but the request before
    compaction is no longer the same shape, so it may be worth a clause there. I
    left the spec alone to keep this change reviewable; happy to add it, in both
    languages, if you want it.

Base and head

base 206085c07 · head a90bcb18 · 4 files, +1048 / −2. 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 : 0f43121a5abffc2fe62c11310d896013832ad027
remote tree: 0f43121a5abffc2fe62c11310d896013832ad027

@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

Copy link
Copy Markdown
Contributor Author

Rebased onto main (79cd7ae8) — it merges cleanly again. The conflict was with #733's budget-formula extraction: ContextBudget and the retainedUserMessageBudget doc comment now live in context-budget.ts, so they stay out of runtime.ts, and narrowToolResultsUnderPressure sits beside the import.

New head a11b2d29. The standalone shape keeps the fixed TOOL_RESULT_TIER_PRESSURE share of the hard limit; the per-model configurable threshold, and the coupling with the early pass, are in #721.

Local before pushing: pnpm --filter @pi-desktop/agent-runtime build 0, agent-runtime tests 47/48 files (the single failure is the pre-existing native-pi-session case, which fails identically on pristine main).

@zhangqingkun976
zhangqingkun976 force-pushed the feat/tool-result-tiering branch 2 times, most recently from abac678 to 5d519fb Compare September 21, 2026 05:20
@zhangqingkun976

Copy link
Copy Markdown
Contributor Author

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

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

Copy link
Copy Markdown
Contributor Author

Rebased onto main (b71fcf05, 39 commits further on). New head a7ff76d4.

One real conflict, and it is worth a look. main had just added its own pass at the same convertToLlm seam — the tool-call-id dedupe from my #780, since refactored into tool-call-dedupe.ts and shared with subagents. The resolution keeps both passes, with the dedupe first and the tiering second:

convertToLlm: (messages) =>
  alignRetainedReasoningIdentity(
    convertToLlm(this.narrowToolResultsUnderPressure(this.dropDuplicateToolCalls(messages))),

Both passes are identity functions when they have nothing to do, so the composition is inert on an ordinary request — the same promise the PR made before the rebase.

Local gates on the rebased head:

  • agent-runtime typecheck exit 0; full package suite 770 passed / 1 failed (the pre-existing native-pi-session case, identical on pristine main); src/tool-result-tier.test.ts is inside the passing files.
  • pnpm test:e2e (scripts/e2e-smoke.mjs, the headless protocol suite) 23/23 passed, 2 skipped (the skips need a provider key). This pass lives inside the runtime, which that suite does not load, so it is a regression check rather than coverage.
  • node scripts/check-pr-base-main.mjs passed.

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

@zhangqingkun976

Copy link
Copy Markdown
Contributor Author

Rebased onto main (43a37373, 24 commits further on). New head 94a3e16e, 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.

…under pressure

Rebased onto main (79cd7ae). One conflict, from vastsa#733 extracting the budget
formula: `ContextBudget` and the `retainedUserMessageBudget` doc comment now
live in `context-budget.ts`, so they stay out of `runtime.ts`, and
`narrowToolResultsUnderPressure` sits beside the import.

Standalone shape: the gate is the fixed `TOOL_RESULT_TIER_PRESSURE` share of the
hard limit. The per-model configurable threshold is in vastsa#721, which contains this
change.

C:\Users\10470\.pi-desktop\scratch\cb9e2d41-8a55-4a18-899d-92ca2791ab51\commit-705-r2.txt
@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 typecheck → clean, 0 errors
  • pnpm --filter @pi-desktop/agent-runtime test → 763 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 a90bcb18 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 — a90bcb18 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