feat(coding-agent): added async compaction and in-place handoff

- Added compaction.asyncEnabled (Async Compaction, default on): when
  context enters the pre-threshold band [threshold - lead, threshold)
  with lead = clamp(threshold * 0.125, 8192, 32000), maintenance
  speculatively summarizes in the background off a branch snapshot
  (first configured LLM-backed method: remote, handoff, or soft) using
  a side session id isolated from the live turn. Crossing the threshold
  splices the armed result in instantly instead of blocking on a
  summarization round-trip. Armed results are invalidated by branch
  changes, reset boundaries, model switches that strand provider-native
  replay payloads, and context growth past keepRecentTokens (which
  re-speculates); extensions registering session_before_compact keep
  exact blocking semantics (speculation disabled).
- Reworked handoff to commit in place: /handoff and the auto handoff
  method now write the generated document as a regular compaction entry
  on the current session (summary = document + <files> tag, cut from
  prepareCompaction) instead of starting a new session. SessionHandoff
  shrank to a document generator; session_before_switch/session_switch
  no longer fire with reason "handoff"; mid-turn maintenance no longer
  suppresses the handoff preference; overflow recovery can apply an
  armed handoff result.
- Extracted the shared auto-compaction commit tail
  (#commitAutoCompactionResult / #commitCompactionEntry) used by the
  blocking production path, the armed speculative apply, manual
  compaction, and manual handoff.
- Status line pulses the auto-compact icon while a speculation runs and
  holds it in accent once a result is armed.
- Exported remotePreserveReusable from pi-agent-core/compaction for
  apply-time validation of speculative remote results.
This commit is contained in:
can1357
2026-08-20 03:45:44 +02:00
parent eced7ab08a
commit 2f54e760c1
26 changed files with 1218 additions and 1043 deletions
+51 -113
View File
@@ -1,21 +1,21 @@
# `/handoff` generation pipeline
This document describes how the coding-agent implements `/handoff`: trigger path, oneshot generation, session switch, context reinjection, persistence, and UI behavior.
This document describes how the coding-agent implements `/handoff`: trigger path, oneshot generation, in-session compaction commit, persistence, and UI behavior.
## Scope
Covers:
- Interactive `/handoff` command dispatch
- `AgentSession.handoff()` lifecycle and state transitions
- `generateHandoffFromContext(...)` request shape and compatibility retry
- How old/new sessions persist handoff data differently
- `AgentSession.handoff()` → `SessionMaintenance.handoff()` lifecycle
- `SessionHandoff.generateDocument(...)` and `generateHandoffFromContext(...)` request shape and compatibility retry
- How the handoff document is committed as a compaction entry
- UI behavior for success, cancel, and failure
Does not cover:
- Generic tree navigation/branch internals
- Non-handoff session commands (`/new`, `/fork`, `/resume`)
- Session commands (`/new`, `/fork`, `/resume`)
## Implementation files
@@ -23,6 +23,7 @@ Does not cover:
- [`src/modes/controllers/command-controller.ts`](../packages/coding-agent/src/modes/controllers/command-controller.ts)
- [`src/modes/controllers/input-controller.ts`](../packages/coding-agent/src/modes/controllers/input-controller.ts)
- [`src/session/session-handoff.ts`](../packages/coding-agent/src/session/session-handoff.ts)
- [`src/session/session-maintenance.ts`](../packages/coding-agent/src/session/session-maintenance.ts)
- [`src/session/agent-session.ts`](../packages/coding-agent/src/session/agent-session.ts)
- [`packages/agent/src/compaction/compaction.ts`](../packages/agent/src/compaction/compaction.ts)
- [`src/session/session-manager.ts`](../packages/coding-agent/src/session/session-manager.ts)
@@ -34,27 +35,30 @@ Does not cover:
3. `CommandController.handleHandoffCommand` refuses while the current response is streaming, then counts `type === "message"` entries.
4. If the count is `< 2`, it warns `Nothing to hand off (no messages yet)` and returns.
The same minimum-content guard exists inside `SessionHandoff.handoff()` and throws if violated. RPC separately refuses a handoff while streaming. Direct SDK callers must avoid invoking the session method during an active response.
The same minimum-content guard exists inside `SessionMaintenance.handoff()` and throws if violated. RPC separately refuses a handoff while streaming. Direct SDK callers must avoid invoking the session method during an active response.
## End-to-end lifecycle
### 1) Start handoff generation
### 1) Prepare the commit
`AgentSession.handoff()` delegates to `SessionHandoff.handoff(customInstructions?, options?)`:
`AgentSession.handoff()` delegates to `SessionMaintenance.handoff(customInstructions?, options?)`:
- Throws `Compaction already in progress` while manual or automatic maintenance is active, and cancels any background speculative compaction.
- Reads the current branch, validates at least two message entries, and runs `prepareCompaction(...)` with the handoff method settings to compute `firstKeptEntryId` and `tokensBefore`; an empty preparation (e.g. right after a compaction) throws `Nothing to hand off (already compacted)`.
### 2) Generate the document
`SessionHandoff.generateDocument(customInstructions?, options?)` owns generation and the abort controller (`isGeneratingHandoff`):
- Rejects session transitions while vibe mode is active.
- Reads the current branch and validates at least two message entries.
- Creates `#handoffAbortController` and links any caller-provided abort signal to it.
- Requires a selected model and an API key/resolver for that model.
- Builds the handoff request through the **same side-request pipeline a live turn uses**, shared with ephemeral turns:
1. Renders the handoff prompt (`renderHandoffPrompt(...)` with optional focus, after secret obfuscation) and appends it as an agent-attributed `user` message to a snapshot of `agent.state.messages`.
2. Converts the snapshot with `convertMessagesToLlm(...)` (session `transformContext`, LLM conversion, and obfuscation).
3. Builds provider `Context` with `agent.buildSideRequestContext(llmMessages, baseSystemPrompt)` — normalized tools and provider-context transforms matching the loop. The base system prompt is pinned, so the fresh session does not inherit a per-turn `before_agent_start` override.
3. Builds provider `Context` with `agent.buildSideRequestContext(llmMessages, baseSystemPrompt)` — normalized tools and provider-context transforms matching the loop. The base system prompt is pinned, so the committed summary does not inherit a per-turn `before_agent_start` override.
4. Builds simple-stream options with the live provider cache key, a unique side `sessionId` (`<sid>:side:<snowflake>`), service tier/payload hooks, `preferWebsockets: false`, `initiatorOverride: "agent"`, and the abort signal.
- Obfuscates the final provider context and calls `generateHandoffFromContext(...)` through the host side-stream transport.
- Deobfuscates the returned handoff text before persistence or display.
### 2) Generate and capture output
- Deobfuscates the returned handoff text.
- For auto-triggered generations with `compaction.handoffSaveToDisk`, writes a timestamped `handoff-*.md` artifact under the session's artifacts directory.
`generateHandoffFromContext(...)` lives in `packages/agent/src/compaction/compaction.ts` next to summarization. It issues an OTEL-instrumented `completeSimple`-equivalent oneshot against the caller-built `Context`, overriding the supplied stream options with clamped compaction reasoning and `toolChoice: "none"`.
@@ -83,92 +87,34 @@ Capture is direct from the oneshot response; no agent-loop events or latest-assi
### 3) Cancellation checks
An explicit user cancellation throws `Error("Handoff cancelled")`. Harness-initiated aborts preserve a supplied reason, or surface `Handoff aborted by session` when none is supplied. A manual handoff whose generation is empty/whitespace-only throws `Handoff generation produced no content`; auto-handoff returns `undefined` so maintenance can fall back to context-full compaction.
An explicit user cancellation throws `Error("Handoff cancelled")`. Harness-initiated aborts preserve a supplied reason, or surface `Handoff aborted by session` when none is supplied. A manual handoff whose generation is empty/whitespace-only throws `Handoff generation produced no content`; auto-handoff returns `undefined` so maintenance can advance to the next configured method.
- caller signal aborts `#handoffAbortController` and forwards its reason
- caller signal aborts the handoff controller and forwards its reason
- `completeSimple(...)` receives the abort signal
- direct `abortHandoff()` or an unreasoned caller signal is normalized to `Error("Handoff cancelled")`
- harness abort reasons and provider failures (including provider `AbortError`s) surface verbatim
`AgentSession.handoff()` always clears `#handoffAbortController` in `finally`.
`SessionHandoff.generateDocument()` always clears the abort controller in `finally`.
### 4) New session creation
### 4) Commit as a compaction entry
If text was generated and not aborted:
If text was generated and not aborted, `SessionMaintenance.handoff()` commits the document on the **current** session:
1. Emit `session_before_switch` with reason `handoff`; an extension may cancel the switch, in which case no new session is created.
2. Flush pending bash output and the current session writer.
3. Drain/detach advisor recorders while they still point at the old session.
4. Begin a bash session transition and cancel session-owned async jobs.
5. Start a brand-new session with `parentSession` pointing at the previous session file when one exists.
6. Clear advisor cost, session-scoped tool/checkpoint state, and stale provider-session state.
7. Preserve steering and follow-up queues across `agent.reset()` so messages arriving during handoff survive into the new session.
8. Rebind the agent session id, rekey/reset memory tracking, clear queued next-turn context, and reset the todo cycle.
1. Wraps the document as a compaction summary: `upsertFileOperations(document, readFiles, modifiedFiles, …)` appends the cumulative `<files>` tag from the preparation's file operations; `{ readFiles, modifiedFiles }` becomes the entry `details`.
2. Appends a regular `CompactionEntry` (`appendCompaction(summary, undefined, firstKeptEntryId, tokensBefore, details, false, undefined)`).
3. Rebuilds the display context, replaces live agent messages, re-anchors stats (`rebaseAfterCompaction`), resets the plan reference, advisor runtimes (`"handoff"`), and todo phases, and closes provider sessions whose history was rewritten.
4. Emits the `session_compact` extension hook with the saved entry.
5. Returns `{ document, savedPath? }`.
### 5) Handoff-context injection
The generated handoff document is wrapped by coding-agent session glue and appended to the new session as a `custom_message` entry:
```text
<handoff-context>
...handoff text...
</handoff-context>
The above is a handoff document from a previous session. Use this context to continue the work seamlessly.
```
Insertion call:
```ts
this.sessionManager.appendCustomMessageEntry(
"handoff",
handoffContent,
true,
undefined,
"agent",
);
```
Semantics:
- `customType`: `"handoff"`
- `display`: `true` (visible in TUI rebuild)
- attribution: `"agent"`
- Entry type: `custom_message` (participates in LLM context)
### 6) Rebuild active agent context
After injection:
1. `buildDisplaySessionContext()` resolves messages for the new leaf.
2. `agent.replaceMessages(sessionContext.messages)` activates the injected handoff context.
3. Advisor runtime state and todo phases reset for the new branch.
4. Emit `session_switch` with reason `handoff` and the previous session file.
5. Return `{ document: handoffText, savedPath? }`.
At this point, the active LLM context in the new session contains the injected handoff message, not the old transcript.
## Persistence model: old session vs new session
### Old session
Handoff generation is a oneshot request, not a visible agent turn. The generated handoff text is not appended to the old session as an assistant message.
Result: the original session keeps its prior transcript unchanged except for data already persisted before handoff began.
### New session
After session reset, handoff is persisted as `custom_message` with `customType: "handoff"`.
`buildSessionContext()` converts this entry into a runtime custom/user-context message via `createCustomMessage(...)`, so it is included in future prompts from the new session.
Auto-triggered handoffs can additionally write a timestamped `handoff-*.md` artifact under the **new** session's artifacts directory when `compaction.handoffSaveToDisk` is enabled. Manual `/handoff` does not write that artifact. The injected custom message is forced on disk before the method returns.
The session id, session file, transcript scrollback, and provider prompt-cache key are all unchanged. Recent history from `firstKeptEntryId` onward is kept verbatim, exactly like every other compaction method; only the summarized prefix is replaced by the document.
### Automatic handoff
Manual `/handoff` works regardless of the context-maintenance method order. To use this pipeline automatically, include `handoff` in `compaction.methodOrder` (the default order is `remote`, `snapcompact`, `handoff`, `shake`, `soft`). Normal threshold-triggered handoffs defer to a post-prompt task; an `incomplete` output recovery may hand off inline. Input `overflow` skips handoff because the request would carry the same oversized input.
Manual `/handoff` works regardless of the context-maintenance method order. To use this pipeline automatically, include `handoff` in `compaction.methodOrder` (the default order is `remote`, `snapcompact`, `handoff`, `shake`, `soft`). Normal threshold-triggered handoffs defer document generation to a post-prompt task; pre-prompt, mid-turn, and `incomplete` recovery run inline. Input `overflow` skips handoff generation because the request would carry the same oversized input — but an already-armed speculative handoff result can still be applied during overflow recovery.
If auto generation returns no document, maintenance advances to the next configured method. An abort or a `session_before_switch` hook cancellation does not trigger that fallback. `compaction.handoffSaveToDisk` defaults to `false`; when enabled, only auto-triggered handoffs write the extra markdown artifact.
Async compaction (`compaction.asyncEnabled`) may also generate the handoff document speculatively in the pre-threshold band and commit it instantly when the threshold is crossed; see `docs/compaction.md`.
If auto generation returns no document, maintenance advances to the next configured method. `compaction.handoffSaveToDisk` defaults to `false`; when enabled, only auto-triggered handoffs write the extra markdown artifact.
## Controller/UI behavior
@@ -179,17 +125,17 @@ If auto generation returns no document, maintenance advances to the next configu
- Calls `await session.handoff(customInstructions)`.
- If result is `undefined`: `showError("Handoff cancelled")`.
- On success:
- clears transient session UI and renders the new session messages, including the injected handoff
- clears transient session UI and re-renders the session, which now shows the handoff compaction divider
- invalidates status line and editor border
- reloads todos
- appends `New session started with handoff context`
- appends `Context handed off and compacted in place`
- shows `savedPath` when the result includes one (manual `/handoff` normally has none)
- On exception:
- if message is `"Handoff cancelled"`: `showError("Handoff cancelled")`
- otherwise: logs the error and calls `showError("Handoff failed: <message>")`
- Stops the loader, clears the status container, and requests render at end.
Manual `/handoff` no longer streams the generated document into chat. A cancellable loader remains visible while the oneshot request runs, and the chat is rebuilt after generation completes.
Manual `/handoff` does not stream the generated document into chat. A cancellable loader remains visible while the oneshot request runs, and the chat is rebuilt after the commit completes.
## Cancellation semantics
@@ -197,14 +143,14 @@ Manual `/handoff` no longer streams the generated document into chat. A cancella
`AgentSession` exposes:
- `abortHandoff()` → aborts `#handoffAbortController`
- `isGeneratingHandoff` → true while controller exists
- `abortHandoff()` → aborts the generation controller
- `isGeneratingHandoff` → true while generation is in flight
Direct `abortHandoff()` passes an unreasoned abort signal to `completeSimple(...)`; `handoff()` normalizes it to `Error("Handoff cancelled")`, and command controller maps it to cancellation UI. `AgentSession.abort(...)` instead aborts the handoff first with its harness reason (or `Handoff aborted by session`), so subsequent compaction cancellation cannot mask that failure as a user cancellation.
Direct `abortHandoff()` passes an unreasoned abort signal to `completeSimple(...)`; generation normalizes it to `Error("Handoff cancelled")`, and command controller maps it to cancellation UI. `AgentSession.abort(...)` instead aborts the handoff first with its harness reason (or `Handoff aborted by session`), so subsequent compaction cancellation cannot mask that failure as a user cancellation.
### Interactive `/handoff` path
`InputController`'s global `editor.onEscape` handler dispatches on live session state instead of swapping handlers: while `isGeneratingHandoff` is true, pressing Escape calls `session.abortHandoff()`, which aborts the `completeSimple(...)` request through `#handoffAbortController`.
`InputController`'s global `editor.onEscape` handler dispatches on live session state instead of swapping handlers: while `isGeneratingHandoff` is true, pressing Escape calls `session.abortHandoff()`, which aborts the `completeSimple(...)` request.
## Aborted vs failed handoff
@@ -215,19 +161,17 @@ Current UI classification:
- an unreasoned caller signal also triggers `"Handoff cancelled"`
- UI shows `Handoff cancelled`
- **Failed**
- a harness abort reason, an empty manual generation, or any thrown provider/session-transition error
- a harness abort reason, an empty manual generation, or any thrown provider error
- UI logs the error and shows `Handoff failed: ...`
An extension-cancelled `session_before_switch` returns `undefined`, which the interactive controller reports as **cancelled**. Empty generation is not an extension cancellation: manual handoff throws; auto-handoff returns `undefined` only for its context-full fallback.
Empty generation on the manual path throws; auto-handoff returns `undefined` only for its next-method fallback.
## Short-session and minimum-content guardrails
Two guards prevent low-signal handoffs:
- UI layer (`handleHandoffCommand`): warns and returns early for `< 2` message entries
- Session layer (`handoff()`): throws the same condition as an error
This avoids creating a new session with empty/near-empty handoff context.
- Session layer (`SessionMaintenance.handoff()`): throws the same condition as an error
## State transition summary
@@ -235,23 +179,17 @@ High-level state flow:
1. Interactive slash command dispatched by the builtin registry.
2. Streaming and message-count preflight guards.
3. `#handoffAbortController` created (`isGeneratingHandoff = true`).
4. `generateHandoffFromContext(...)` sends one cache-aligned side request, with a one-time `"auto"` tool-choice compatibility retry when required.
3. `prepareCompaction(...)` computes the cut (`firstKeptEntryId`, `tokensBefore`).
4. Generation controller created (`isGeneratingHandoff = true`); `generateHandoffFromContext(...)` sends one cache-aligned side request, with a one-time `"auto"` tool-choice compatibility retry when required.
5. Assistant text blocks are joined; tool-call blocks are discarded; secret placeholders are restored locally.
6. If missing text or an extension cancels the switch → return `undefined`; if aborted → cancellation error.
7. If present:
- flush bash/session persistence and detach advisor recorders
- cancel async jobs and create a new child session
- reset runtime/tool/checkpoint/memory state while preserving steering/follow-up queues
- append and persist `custom_message(handoff)`
- optionally save an auto-triggered handoff artifact
- rebuild agent context, advisors, and todos, then emit `session_switch`
6. If missing text → manual throws / auto returns `undefined`; if aborted → cancellation error.
7. If present: append the `CompactionEntry`, rebuild the agent context, reset plan/advisor/todo runtime state, close rewritten provider sessions, emit `session_compact`.
8. Controller rebuilds chat UI and announces success.
9. `#handoffAbortController` clears in `finally`; failed pre-commit transitions reattach advisor recorder feeds.
9. The generation controller clears in `finally`.
## Known assumptions and limitations
- No structural validation checks that generated markdown follows the requested section format.
- Missing text and extension-cancelled switches are reported as cancellation in the interactive controller.
- Manual handoff has no streaming visibility; a cancellable loader is shown until the UI updates.
- Auto-triggered artifact write failure is logged and does not fail the already-created handoff session.
- Auto-triggered artifact write failure is logged and does not fail the handoff.
- Sessions created by older versions may still contain `custom_message` entries with `customType: "handoff"` from the previous new-session pipeline; they render and participate in context unchanged.