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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user