feat(compact): recover context overflow (PR3 phase 2)

This commit is contained in:
Hypatia May
2026-07-15 16:50:44 +08:00
parent e8d066f750
commit 12484104c8
46 changed files with 913 additions and 242 deletions

View File

@@ -18,7 +18,7 @@ Both methods are **abstract** — the backend owns trigger policy, retention, ev
| Member | Semantics |
|---|---|
| `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint supplies the agent, assembled `fullSystemPrompt`, composed `sessionPrefix` (request-only messages every request carries but the derived history omits — the pressure estimate must count them), and turn `signal`. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. |
| `compactIfNeeded(agent, trigger, signal)` | Consider automatic compaction for `trigger: 'pressure' \| 'context-overflow'`. A pressure trigger may apply the backend's threshold and retained-tail policy; a confirmed overflow may force a useful balanced reduction. Returns the `CompactionResult`, or `null` when no safe range exists. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. |
| `compactRegion(session, start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. The agent must own the exact target (`session === agent.session`); a backend rejects mismatch before model resolution, lock acquisition, summarization, or log mutation. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The turn that the `compact/*` events belong to is recoverable from the owned session's log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value.
@@ -72,6 +72,5 @@ Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and
## Known Limitations and Deferred Work
- **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener.
- **Single-unit overflow is out of contract** — one retained unit (a closed step or a large pasted `user/message`) alone exceeding the budget cannot be compacted; the call may go out over-budget.
- **A session prefix that alone approaches the window is a configuration error no backend fixes** — compaction shrinks derived history, never the prefix.
- **Request context injected by downstream `agent/request` listeners sits outside pressure accounting** — `compactIfNeeded` counts prefix, derived history, and system prompt only.
- **Single-unit overflow is out of contract** — one indivisible unit (a closed tool pair or a large pasted `user/message`) alone exceeding the budget cannot be compacted.
- **An envelope that alone approaches the window is not surface-compaction work** — compaction shrinks derived history, never the system prompt, tools, or session prefix.

View File

@@ -8,7 +8,6 @@
*/
import { Context, Service } from 'cordis'
import type { Message } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import type { CompactionResult } from './types.ts'
@@ -16,6 +15,9 @@ export type { CompactionResult } from './types.ts'
export { renderContentBlocks, renderTranscript } from './render.ts'
export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts'
/** Why automatic policy is asking a backend to consider compaction. */
export type CompactionTrigger = 'pressure' | 'context-overflow'
/** Minimal agent context compaction needs without depending on the agent package. */
export interface CompactAgentContext {
session: Session
@@ -41,24 +43,20 @@ export abstract class CompactService extends Service {
}
/**
* Check token pressure and compact if the conversation is too large.
* Estimate the next request, including its session prefix, derived history,
* and system prompt. Above threshold, compact a head-anchored range ending at
* a balanced tool boundary and reconsolidate any prior automatic checkpoint.
* Return `null` when no compaction is needed or an open tail leaves no safe
* cutoff. A single oversized retained unit or prefix cannot be repaired here.
* Consider automatic compaction for one explicit trigger. Pressure policy
* uses the latest durable routed request, while context-overflow policy may
* force a useful balanced reduction even below the normal threshold. Return
* `null` when no safe range can be compacted. A single oversized retained
* unit or request envelope cannot be repaired through surface compaction.
*
* @param agent - agent context owning the session surface and model options.
* @param fullSystemPrompt - assembled system prompt, counted toward the estimate.
* @param sessionPrefix - the instance's composed session prefix, counted toward the
* estimate.
* @param trigger - normal pressure or provider-confirmed context overflow.
* @param signal - cancellation signal; model-backed implementations must forward it.
* @returns the compaction result, or `null` if no compaction was needed.
*/
abstract compactIfNeeded(
agent: CompactAgentContext,
fullSystemPrompt: string,
sessionPrefix: readonly Message[],
trigger: CompactionTrigger,
signal: AbortSignal,
): Promise<CompactionResult | null>

View File

@@ -1,8 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CompactService } from '@deepseek-ai/dsh-compact'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import type { Message } from '@deepseek-ai/dsh-llm'
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { CompactAgentContext } from '@deepseek-ai/dsh-compact'
@@ -18,8 +17,7 @@ class StubCompactService extends CompactService {
override async compactIfNeeded(
_agent: CompactAgentContext,
_fullSystemPrompt: string,
_sessionPrefix: readonly Message[],
_trigger: CompactionTrigger,
signal: AbortSignal,
): Promise<CompactionResult | null> {
this.lastSignal = signal
@@ -80,7 +78,7 @@ describe('CompactService seam', () => {
const ctx = new Context()
const svc = new StubCompactService(ctx)
const session = new Session(SessionId('s'))
expect(await svc.compactIfNeeded(stubAgent(session), '', [], new AbortController().signal)).toBeNull()
expect(await svc.compactIfNeeded(stubAgent(session), 'pressure', new AbortController().signal)).toBeNull()
})
it('compact/* events merge into SessionEventMap and are log-only', async () => {
@@ -109,7 +107,7 @@ describe('CompactService seam', () => {
await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), controller.signal)
expect(svc.lastSignal).toBe(controller.signal)
await svc.compactIfNeeded(stubAgent(session), '', [], controller.signal)
await svc.compactIfNeeded(stubAgent(session), 'context-overflow', controller.signal)
expect(svc.lastSignal).toBe(controller.signal)
})
})