refactor(compact): turn-agnostic retention + dedicated agent/pre-request seam
Reform the compaction blueprint so a runaway turn survives and the design
stops drifting across review rounds:
- Drop in-flight-turn protection ("layer 2"). Retention is a uniform tail→head
whole-unit walk; the only structural guard is step-alignment. A single turn
that alone exceeds the window now compacts its own early closed steps instead
of being retained verbatim (the failure mode that motivated this).
- Move auto-compaction off the agent/request waterfall onto a new awaited
agent/pre-request loop seam, fired before history derivation. Compaction
mutates the surface; the loop derives once from the result — no double-derive,
and a listener structurally cannot act on not-yet-derived messages.
- Tighten compactIfNeeded to required (session, system, model, signal).
- Enforce a single-pass convergence invariant in resolveConfig: reject configs
where summarizationMaxTokens + retainTokens exceeds the threshold, so a
compaction can never immediately re-trigger.
- Document the crash vs recoverable failure taxonomy; core session repair stays
compaction-agnostic (a log-only orphaned compact/start is inert).
- Wire dsh-compact-basic into examples/coding-agent and add a with-key
compaction e2e (compaction's first real-world exercise + runaway net).
- Rewrite the RFC to encode the blueprint and move it to implemented/.
The runaway-turn snapshot is a named deferred follow-up: dsh-llm-replay cannot
yet serve the interleaved summarization model call.
This commit is contained in:
@@ -8,4 +8,4 @@ A three-package capability seam (see [capability seams](../../docs/rfc/implement
|
||||
| `compact-basic/` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
|
||||
| `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool.
|
||||
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool.
|
||||
|
||||
@@ -2,18 +2,20 @@
|
||||
|
||||
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a char/4 token heuristic, token-budget retention, and `ctx.llm.stream()` summarization.
|
||||
|
||||
This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md) for the design.
|
||||
This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design.
|
||||
|
||||
## What it owns
|
||||
|
||||
The abstract contract states only WHAT compaction does; this backend owns every HOW decision:
|
||||
|
||||
- **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length).
|
||||
- **Retention policy** — `compactIfNeeded()` ALWAYS retains the in-flight turn's surface nodes verbatim (its initiating request and any mid-turn tool results — the exact input/observation the model is acting on, even if they exceed the budget), then walks the OLDER (closed-turn) nodes tail→head, summing per-node token estimates, and compacts everything older than the first node that overflows the `retainTokens` budget. The cutoff is snapped to a step boundary so the compacted region never splits a step's `assistant/message` tool-calls from their `tool/result`s (the budget is a soft target): it prefers snapping FORWARD to the next clean boundary, and falls back to snapping BACKWARD when the forward snap would reach the protected in-flight turn. If no step-aligned cutoff exists in the older range (e.g. its only content is an open tail step), it declines (returns `null`) and retries once an older step closes. `compactRegion()` enforces step-alignment strictly, throwing on a boundary that would split a step. Token-based (not turn-count) retention keeps more short turns and compacts tool-heavy turns sooner.
|
||||
- **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **step-alignment**: the compacted region always ends on a step boundary, so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large pasted `user/message`) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget — bounding an individual unit's size is a separate concern. `compactRegion()` enforces step-alignment strictly, throwing on a boundary that would split a step.
|
||||
- **Single-pass convergence** — `resolveConfig()` rejects (throws) any config where `summarizationMaxTokens + retainTokens > contextWindow * thresholdRatio`. The invariant guarantees the post-compaction history (the bounded summary plus the retained recent tail) is structurally below the threshold, so a compaction never immediately triggers another: consecutive re-compaction is impossible by construction.
|
||||
- **Summarization** — `summarize()`: a `ctx.llm.stream()` call assembled via `BlockAssembler` (the single model-call surface) with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) so the summarizer is told what existed rather than silently dropping it.
|
||||
- **Checkpoint framing** — the raw summary is not landed directly. `compactRegion()` wraps it in a checkpoint preamble (so a resuming model reads it as a checkpoint, not a fresh user request, and builds on the captured context rather than restating it) plus `<compacted-summary>…</compacted-summary>` tags. Because region compaction can be invoked manually, a surface may hold several checkpoints, so the framing does not claim everything after it is recent or verbatim. The tags make a prior checkpoint detectable in the transcript on the next compaction cycle: the summarization prompt then instructs the model to merge it in place (preserve still-true facts, drop stale ones) rather than re-summarize it verbatim — a cheap incremental merge that needs no extra log/event machinery. The unframed summary stays on the `compact/summary` provenance event.
|
||||
- **Surface mutation** — `compactRegion()` appends the `compact/start` → `compact/summary` → `compact/end` log records and the single `user/message` replace node carrying the framed summary (see the interface README).
|
||||
- **Auto-compaction** — an `agent/request` waterfall listener delegates to `compactIfNeeded()` before every model call (every step, not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts) and re-derives messages after compacting; the listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`).
|
||||
- **Auto-compaction** — an `agent/pre-request` listener delegates to `compactIfNeeded()` before every model call (every step, not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts, and per-step firing is the only moment to rescue it before overflow). `agent/pre-request` is an awaited surface-mutation checkpoint that fires BEFORE the loop derives the request history, so compaction mutates the surface and the loop derives once from the result — no double-derive, and the listener cannot see (or need to rewrite) an already-assembled `messages` array. The listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`).
|
||||
- **Failure handling** — the `compact/start … compact/end` bracket is a log-recorded lock: it makes a crash mid-summarization a detectable orphan (a `compact/start` with no `compact/end`), records provenance, and prevents a concurrent compaction. Two failure paths: a **crash** (the loop dies mid-summarization) leaves a dangling `compact/start` that is inert — `compact/*` events are log-only, the surface replacement never landed, so the full history derives fine and generic turn-repair closes the turn; a **recoverable** failure (summarization throws but the loop survives) appends `compact/end` with its `error` field set, leaving the surface untouched so the call proceeds with full history. Core session repair stays compaction-agnostic by design — it never learns about `compact/*`.
|
||||
|
||||
`estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing.
|
||||
|
||||
@@ -26,7 +28,7 @@ The abstract contract states only WHAT compaction does; this backend owns every
|
||||
| `retainTokens` | `20480` | Tokens of recent context to keep intact. |
|
||||
| `summarizationModel` | `''` | Model for summarization (empty → use the agent's model). |
|
||||
| `summarizationMaxTokens` | `2048` | Max tokens for the summary response. |
|
||||
| `auto` | `true` | Register the `agent/request` auto-compaction listener. Set `false` for manual-only. |
|
||||
| `auto` | `true` | Register the `agent/pre-request` auto-compaction listener. Set `false` for manual-only. |
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import { CompactService } from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { isStepAlignedStart, isStepAlignedEnd } from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
|
||||
@@ -170,40 +170,40 @@ export class BasicCompactService extends CompactService {
|
||||
|
||||
if (this.config.auto) {
|
||||
// Auto-compaction: delegate to compactIfNeeded before EVERY model call —
|
||||
// every step, not just the first. A tool-heavy ReAct turn appends an
|
||||
// assistant/message and a tool/result per step, so the surface (and the
|
||||
// derived token count) grows within a turn; gating to step 1 would let a
|
||||
// runaway turn overflow the window before the next turn's check. The
|
||||
// listener stays agnostic — it owns NO threshold logic; compactIfNeeded is
|
||||
// the single place that decides whether to compact, and its in-progress
|
||||
// lock serializes concurrent attempts.
|
||||
ctx.on('agent/request', async (agent: Agent, _turn, _step, request, next) => {
|
||||
const before = this.estimateTokens(request.messages, request.system)
|
||||
// every step, not just the first. This is LOAD-BEARING for runaway-turn
|
||||
// survival: a tool-heavy ReAct turn appends an assistant/message and a
|
||||
// tool/result per step, so the surface (and the derived token count) grows
|
||||
// WITHIN a turn. The only moment to rescue a turn that alone approaches the
|
||||
// window is the next step's pre-request; gating to a turn's first step
|
||||
// would let a runaway turn overflow before the next turn's check. The
|
||||
// listener owns NO threshold logic — compactIfNeeded is the single place
|
||||
// that decides whether to compact, and its in-progress lock serializes
|
||||
// concurrent attempts.
|
||||
//
|
||||
// It runs on `agent/pre-request` (a parallel surface-mutation checkpoint),
|
||||
// NOT `agent/request`: compaction mutates the session surface, and the loop
|
||||
// derives the request `messages` AFTER this fires — so a single derive
|
||||
// already reflects the compaction, with no double-derive and no need to
|
||||
// rewrite an already-assembled `messages` array.
|
||||
ctx.on('agent/pre-request', async (agent: Agent, _turn: number, _step: number, system: string, model: string, signal: AbortSignal) => {
|
||||
try {
|
||||
const result = await this.compactIfNeeded(agent.session, request.system, request.model, request.signal)
|
||||
const result = await this.compactIfNeeded(agent.session, system, model, signal)
|
||||
if (result) {
|
||||
// The surface has been mutated — re-derive messages for the call.
|
||||
const rederived = agent.session.deriveMessages()
|
||||
const afterTokens = this.estimateTokens(rederived, request.system)
|
||||
|
||||
const after = this.estimateTokens(agent.session.deriveMessages(), system)
|
||||
ctx.logger.info(
|
||||
`compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` +
|
||||
`(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` +
|
||||
`~${result.shadowedTokenCount} tokens) ` +
|
||||
`→ ${afterTokens} estimated tokens after compaction ` +
|
||||
`(pressure was ~${before})`,
|
||||
`→ ${after} estimated tokens after compaction`,
|
||||
)
|
||||
|
||||
request.messages = rederived
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// A failed compaction must not prevent the model call — proceed
|
||||
// with the original messages.
|
||||
// A failed compaction must not prevent the model call — the surface is
|
||||
// untouched on failure, so the loop derives the full history and the
|
||||
// call proceeds.
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
ctx.logger.warn(`compaction failed: ${msg}; proceeding with full history`)
|
||||
}
|
||||
|
||||
return next()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -312,89 +312,89 @@ export class BasicCompactService extends CompactService {
|
||||
// ---- Core API (implements the abstract contract) ----
|
||||
|
||||
/**
|
||||
* The sole token-pressure gate: estimate the current history, and if it
|
||||
* exceeds the threshold (`contextWindow * thresholdRatio`), compact the oldest
|
||||
* surface nodes outside the `retainTokens` budget. The auto-compaction listener
|
||||
* delegates here rather than pre-checking, so this is the only place the
|
||||
* decision lives.
|
||||
* The sole token-pressure gate: estimate the current surface-derived history,
|
||||
* and if it exceeds the threshold (`contextWindow * thresholdRatio`), compact
|
||||
* the oldest surface nodes outside the `retainTokens` budget. The auto-
|
||||
* compaction listener delegates here rather than pre-checking, so this is the
|
||||
* only place the decision lives.
|
||||
*
|
||||
* Retention is a UNIFORM tail→head walk over the whole surface — turn
|
||||
* boundaries play NO role. Walking node-by-node from the tail and summing
|
||||
* token estimates, once the retained total reaches `retainTokens` the cutoff
|
||||
* is rounded to a step-aligned boundary: if the walk stopped INSIDE a step,
|
||||
* it continues head-ward past that step's `step/start` so the whole step is
|
||||
* retained (never splitting a step's tool-calls from their results); if it
|
||||
* stopped on a free node (a node belonging to no step), that is already a
|
||||
* clean boundary. This always rounds toward retaining MORE (retained ≥
|
||||
* `retainTokens`) and is step-aligned by construction — no separate snap pass.
|
||||
*
|
||||
* The compacted range is always anchored at the surface HEAD (`nodes[0]`):
|
||||
* auto-compaction re-consolidates any prior head checkpoint into one fresh
|
||||
* checkpoint. Declines (`null`) when nothing is over threshold, when the whole
|
||||
* surface fits the retain budget, or when no step-aligned cutoff exists in the
|
||||
* compactable range (its only content is an open tail step — retry once it
|
||||
* closes).
|
||||
*/
|
||||
override async compactIfNeeded(
|
||||
session: Session,
|
||||
systemPrompt?: string,
|
||||
model?: string,
|
||||
signal?: AbortSignal,
|
||||
system: string,
|
||||
model: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null> {
|
||||
const messages = session.deriveMessages()
|
||||
const totalTokens = this.estimateTokens(messages, systemPrompt)
|
||||
const totalTokens = this.estimateTokens(messages, system)
|
||||
|
||||
const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio)
|
||||
if (totalTokens < threshold) return null
|
||||
|
||||
// Walk surface nodes tail→head, accumulating token estimates.
|
||||
const nodes = session.surface.nodes
|
||||
if (nodes.length === 0) return null
|
||||
|
||||
const events = session.events
|
||||
const retainBudget = this.config.retainTokens
|
||||
// ALWAYS retain the IN-FLIGHT turn's surface nodes verbatim — its initiating
|
||||
// user request and any mid-turn tool results are the exact input/observation
|
||||
// the model is acting on right now, even if they exceed the soft retain
|
||||
// budget. Compacting them would hand the model a lossy summary of its own
|
||||
// current task. Only nodes in PRIOR (closed) turns are eligible to compact;
|
||||
// `protectedIdx` is the first surface node of the open turn (or `nodes.length`
|
||||
// when the open turn has no surface nodes yet, e.g. before step 1).
|
||||
const protectedIdx = this._openTurnFirstSurfaceIdx(session, nodes)
|
||||
if (protectedIdx === 0) return null
|
||||
|
||||
// Walk tail→head summing per-node token estimates. `keepFromIdx` is the
|
||||
// index of the OLDEST node we retain verbatim; everything strictly older
|
||||
// (`[0, keepFromIdx - 1]`) is the compactable range.
|
||||
let accumulated = 0
|
||||
let cutoffIdx = -1
|
||||
// Seed the accumulator with the protected suffix so the retain budget is
|
||||
// measured against what actually stays, then look for a cutoff only among
|
||||
// the older (compactable) nodes.
|
||||
for (let i = nodes.length - 1; i >= protectedIdx; i--) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const event = session.events[nodes[i]!.seq]
|
||||
if (event) accumulated += this.estimateEventTokens(event)
|
||||
}
|
||||
|
||||
for (let i = protectedIdx - 1; i >= 0; i--) {
|
||||
// nodes[i] bounded by i >= 0 and i < nodes.length — never undefined.
|
||||
let keepFromIdx = nodes.length // nothing retained yet
|
||||
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const node = nodes[i]!
|
||||
const event = session.events[node.seq]
|
||||
const event = events[node.seq]
|
||||
/* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */
|
||||
if (!event) continue
|
||||
accumulated += this.estimateEventTokens(event)
|
||||
if (accumulated > retainBudget) {
|
||||
cutoffIdx = i
|
||||
break
|
||||
}
|
||||
if (event) accumulated += this.estimateEventTokens(event)
|
||||
keepFromIdx = i
|
||||
if (accumulated >= retainBudget) break
|
||||
}
|
||||
|
||||
// If we walked the entire compactable range without exceeding the budget,
|
||||
// everything outside the protected in-flight turn fits — no compaction
|
||||
// needed.
|
||||
if (cutoffIdx === -1) return null
|
||||
// The whole surface fits the retain budget — nothing to compact.
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
// Snap the cutoff to a step-aligned end so the compacted region never splits
|
||||
// a step (which would orphan a tool-call or its tool/result). The token
|
||||
// budget is a soft target. PREFER snapping FORWARD (compact slightly more
|
||||
// recent context to reach a clean boundary), but never into the protected
|
||||
// in-flight turn: if the forward snap would reach `protectedIdx`, fall back
|
||||
// to snapping BACKWARD to the previous step-aligned end (compact slightly
|
||||
// less), and decline only if no step-aligned end exists in the compactable
|
||||
// range at all.
|
||||
const events = session.events
|
||||
cutoffIdx = this._snapCutoff(events, nodes, cutoffIdx, protectedIdx)
|
||||
if (cutoffIdx === -1) return null
|
||||
// Round the cutoff to a step boundary: if `keepFromIdx` sits INSIDE a step,
|
||||
// extend the retained side head-ward until the boundary is a step-aligned
|
||||
// start, so the compacted range ends on a clean step edge. A node that
|
||||
// belongs to no step is already a valid start. Decline if no step-aligned
|
||||
// start exists at or below `keepFromIdx` (the compactable range is only an
|
||||
// un-splittable open tail step — retry once it closes).
|
||||
while (keepFromIdx > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (isStepAlignedStart(events, nodes[keepFromIdx]!.seq)) break
|
||||
keepFromIdx -= 1
|
||||
}
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
// nodes is non-empty (checked above) and cutoffIdx is a valid index.
|
||||
// The compacted range is [head … keepFromIdx - 1], anchored at the head.
|
||||
// The cutoff node `nodes[keepFromIdx - 1]` is necessarily a step-aligned END:
|
||||
// the retained start `nodes[keepFromIdx]` is a step-aligned START (a boundary
|
||||
// marker sits between them in the log), and that same boundary makes the node
|
||||
// before it a step-aligned end — so no separate end check is needed.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const firstSeq = nodes[0]!.seq
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const cutoffSeq = nodes[cutoffIdx]!.seq
|
||||
const resolvedModel = model ?? ''
|
||||
const cutoffSeq = nodes[keepFromIdx - 1]!.seq
|
||||
|
||||
return this.compactRegion(session, firstSeq, cutoffSeq, resolvedModel, signal)
|
||||
return this.compactRegion(session, firstSeq, cutoffSeq, model, signal)
|
||||
}
|
||||
|
||||
override async compactRegion(
|
||||
@@ -521,74 +521,6 @@ export class BasicCompactService extends CompactService {
|
||||
// ---- Internal helpers ----
|
||||
|
||||
/**
|
||||
* The index of the first surface node that belongs to the currently-open turn
|
||||
* — the boundary of the protected, never-compacted suffix. Returns
|
||||
* `nodes.length` when the open turn has contributed no verbatim surface node
|
||||
* yet (e.g. before step 1 appends anything), so the whole surface is
|
||||
* compaction-eligible up to the tail.
|
||||
*
|
||||
* The in-flight turn's verbatim nodes (its request, mid-turn assistant
|
||||
* messages, tool results — all `append` ops) form a CONTIGUOUS run at the TAIL
|
||||
* of the surface. A compaction replacement node, though also appended during
|
||||
* the open turn (seq > `turn/start`), lands at the position of the older range
|
||||
* it shadowed — earlier in the surface, NOT in the tail run — so it is itself
|
||||
* compaction-eligible (a later cycle can merge it). The protected suffix is
|
||||
* therefore the contiguous tail run of nodes whose seq exceeds the open turn's
|
||||
* `turn/start`, found by walking from the tail. With no open turn (a closed
|
||||
* session — only manual `compactRegion`, never the auto path), nothing is
|
||||
* protected and this returns `nodes.length`.
|
||||
*/
|
||||
private _openTurnFirstSurfaceIdx(session: Session, nodes: readonly SurfaceNode[]): number {
|
||||
const openTurn = this._openTurn(session)
|
||||
if (openTurn === null) return nodes.length
|
||||
// Find the open turn's turn/start seq (scanning back from the tail).
|
||||
let turnStartSeq = -1
|
||||
for (let i = session.events.length - 1; i >= 0; i--) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const e = session.events[i]!
|
||||
if (e.type === 'turn/start' && e.data.turn === openTurn) { turnStartSeq = e.seq; break }
|
||||
}
|
||||
/* v8 ignore next -- _openTurn returned non-null, so its turn/start exists */
|
||||
if (turnStartSeq === -1) return nodes.length
|
||||
// Walk from the tail while nodes belong to the open turn (seq > turn/start),
|
||||
// taking only the CONTIGUOUS run — a compaction summary node appended this
|
||||
// turn but sitting earlier in the surface stops the run and stays eligible.
|
||||
let idx = nodes.length
|
||||
while (idx > 0 && nodes[idx - 1]!.seq > turnStartSeq) idx -= 1 // eslint-disable-line @typescript-eslint/no-non-null-assertion
|
||||
return idx
|
||||
}
|
||||
|
||||
/**
|
||||
* Snap a raw token-budget cutoff index to a step-aligned end among the nodes
|
||||
* BELOW the protected suffix (`protectedIdx`, the first node of the in-flight
|
||||
* turn). Returns the snapped index, or `-1` if no step-aligned end exists in
|
||||
* the compactable range (e.g. it is empty, or its only content is an open tail
|
||||
* step).
|
||||
*
|
||||
* Prefers snapping FORWARD to the next step-aligned end (compact slightly more
|
||||
* recent context for a clean boundary); if the forward scan reaches
|
||||
* `protectedIdx` without finding one, falls back to scanning BACKWARD from the
|
||||
* raw cutoff (compact slightly less). The protected suffix is never returned —
|
||||
* it stays verbatim so the model sees its current task, not a summary.
|
||||
*/
|
||||
private _snapCutoff(
|
||||
events: readonly SessionEvent[],
|
||||
nodes: readonly SurfaceNode[],
|
||||
rawCutoffIdx: number,
|
||||
protectedIdx: number,
|
||||
): number {
|
||||
// Forward: the next step-aligned end strictly below the protected suffix.
|
||||
for (let i = rawCutoffIdx; i < protectedIdx; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (isStepAlignedEnd(events, nodes[i]!.seq)) return i
|
||||
}
|
||||
// Backward: the nearest step-aligned end at or below the raw cutoff.
|
||||
for (let i = rawCutoffIdx - 1; i >= 0; i--) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (isStepAlignedEnd(events, nodes[i]!.seq)) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
/**
|
||||
* Frame the raw summary blocks into the content that lands on the surface:
|
||||
|
||||
@@ -38,7 +38,35 @@ export const DEFAULTS: ResolvedConfig = {
|
||||
auto: true,
|
||||
}
|
||||
|
||||
/** Apply defaults to a partial config. */
|
||||
/**
|
||||
* Apply defaults to a partial config and enforce the single-pass convergence
|
||||
* invariant.
|
||||
*
|
||||
* `summarizationMaxTokens + retainTokens` must not exceed the compaction
|
||||
* threshold (`contextWindow * thresholdRatio`). The invariant guarantees that
|
||||
* after a compaction the derived history — the (bounded) summary plus the
|
||||
* retained recent tail — is structurally BELOW the threshold, so the very next
|
||||
* pre-request check passes and a second compaction cannot fire on the same
|
||||
* content. Without it, a too-large summary budget or retain budget would leave
|
||||
* the post-compaction history still over threshold, triggering compaction again
|
||||
* and again. Pre-release we reject rather than clamp: a config that cannot
|
||||
* guarantee convergence is a bug at the call site, not something to silently
|
||||
* paper over.
|
||||
*
|
||||
* @throws if `summarizationMaxTokens + retainTokens > contextWindow * thresholdRatio`.
|
||||
*/
|
||||
export function resolveConfig(config: BasicCompactConfig): ResolvedConfig {
|
||||
return { ...DEFAULTS, ...config }
|
||||
const resolved = { ...DEFAULTS, ...config }
|
||||
const threshold = Math.floor(resolved.contextWindow * resolved.thresholdRatio)
|
||||
const postCompactionFloor = resolved.summarizationMaxTokens + resolved.retainTokens
|
||||
if (postCompactionFloor > threshold) {
|
||||
throw new Error(
|
||||
`BasicCompactConfig: summarizationMaxTokens (${resolved.summarizationMaxTokens}) + `
|
||||
+ `retainTokens (${resolved.retainTokens}) = ${postCompactionFloor} exceeds the compaction `
|
||||
+ `threshold contextWindow * thresholdRatio = ${threshold}; post-compaction history would `
|
||||
+ 'stay over threshold and re-compact endlessly. Lower retainTokens/summarizationMaxTokens '
|
||||
+ 'or raise contextWindow/thresholdRatio.',
|
||||
)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import type { SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import * as Invariants from '@deepseek-ai/dsh-invariants'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
/** A never-aborted signal for the required `compactIfNeeded`/listener arg. */
|
||||
const SIGNAL = new AbortController().signal
|
||||
|
||||
/**
|
||||
* A BasicCompactService with summarize() stubbed (no real model call) and a
|
||||
* predictable token estimate, for deterministic unit tests of the algorithm.
|
||||
@@ -33,31 +36,14 @@ class TestCompactService extends BasicCompactService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a test service with a throwaway context (auto disabled — no model). */
|
||||
function createTestService(config: BasicCompactConfig = {}): TestCompactService {
|
||||
return new TestCompactService(new Context(), { auto: false, ...config })
|
||||
}
|
||||
|
||||
/**
|
||||
* A test service where specific surface seqs (in `bigSeqs`) weigh 1000 tokens
|
||||
* and every other message-producing event weighs 10 — for exercising the
|
||||
* "newest node alone exceeds retainTokens" retention path. summarize() is
|
||||
* stubbed (no model call).
|
||||
* Create a test service with a throwaway context (auto disabled — no model).
|
||||
* A small `summarizationMaxTokens` baseline keeps the convergence invariant
|
||||
* (`summarizationMaxTokens + retainTokens <= contextWindow * thresholdRatio`)
|
||||
* satisfied for the tiny windows these tests use; a test may override it.
|
||||
*/
|
||||
class TestCompactServiceVarTokens extends BasicCompactService {
|
||||
bigSeqs = new Set<number>()
|
||||
constructor(config: BasicCompactConfig = {}) {
|
||||
super(new Context(), { auto: false, ...config })
|
||||
}
|
||||
|
||||
override estimateEventTokens(event: SessionEvent): number {
|
||||
if (this.bigSeqs.has(event.seq)) return 1000
|
||||
return super.estimateEventTokens(event)
|
||||
}
|
||||
|
||||
override async summarize(): Promise<ContentBlock[]> {
|
||||
return [{ type: 'text', text: 'summary' }]
|
||||
}
|
||||
function createTestService(config: BasicCompactConfig = {}): TestCompactService {
|
||||
return new TestCompactService(new Context(), { auto: false, summarizationMaxTokens: 1, ...config })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,44 +174,48 @@ function expectNoOrphanToolResults(messages: Message[]): void {
|
||||
}
|
||||
|
||||
describe('BasicCompactService step-alignment (never split a tool-call/result pair)', () => {
|
||||
it('compactIfNeeded snaps the cutoff forward past a mid-step boundary (no orphaned tool-result)', async () => {
|
||||
// 3 turns, each one step = { assistant(tool-call) , tool/result }. Surface
|
||||
it('compactIfNeeded rounds the retained boundary head-ward to keep a whole step (no orphaned tool-result)', async () => {
|
||||
// 3 turns, each one step = { assistant(tool-call), tool/result }. Surface
|
||||
// (9 nodes): user1, asst1, res1, user2, asst2, res2, user3, asst3, res3 —
|
||||
// 10/20/10 tokens. With retainTokens=55 the tail→head walk overflows at
|
||||
// asst2 (idx4), so the RAW cutoff falls BETWEEN asst2 and its result res2
|
||||
// (idx5) — splitting turn 2's step. The fix snaps the cutoff forward to res2
|
||||
// so the whole step is compacted and no dangling result survives.
|
||||
// 10/20/10 tokens. The tail→head walk retains by whole units; the compacted
|
||||
// region always ends on a step boundary, so no step's tool-call is split
|
||||
// from its result. retainTokens=55 keeps the recent tail; the older steps
|
||||
// compact intact.
|
||||
const svc = createTestService({ contextWindow: 200, thresholdRatio: 0.5, retainTokens: 55 })
|
||||
const session = toolTurnSession(3)
|
||||
|
||||
const result = await svc.compactIfNeeded(session)
|
||||
const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL)
|
||||
expect(result).not.toBeNull()
|
||||
// res2 (idx5) was pulled into the compacted region by the snap, not stranded.
|
||||
expect(result!.shadowedSeqs.length).toBeGreaterThan(0)
|
||||
// No dangling tool-result: every compacted/retained step stayed whole.
|
||||
expectNoOrphanToolResults(session.deriveMessages())
|
||||
// Turn 3's step is retained intact (summary + user3 + asst3 + res3 = 4 msgs).
|
||||
expect(session.deriveMessages().length).toBe(4)
|
||||
// The most-recent step's result is retained verbatim (still on the surface).
|
||||
const lastResultSeq = session.events.findLast(e => e.type === 'tool/result')!.seq
|
||||
expect(result!.shadowedSeqs).not.toContain(lastResultSeq)
|
||||
})
|
||||
|
||||
it('compactIfNeeded returns null when the only cutoff would enter an open tail step', async () => {
|
||||
// A pre-step user/message then an OPEN step (assistant issued a tool-call, no
|
||||
// tool/result / step/end yet — mid-flight). The token walk wants to compact
|
||||
// into that open step, but its tool-call has no result yet; compacting it
|
||||
// would defer the orphan. With no safe step-aligned cutoff, compactIfNeeded
|
||||
// declines (returns null) rather than summarizing a pending tool-call away.
|
||||
const s = new Session(SessionId('open-step'))
|
||||
it('compactIfNeeded returns null when the only compactable region is an un-splittable single step', async () => {
|
||||
// The surface is exactly ONE step: [assistant(tool-call), tool/result]. Over
|
||||
// threshold (by the derived role overhead), the tail→head walk stops with the
|
||||
// retained boundary at the tool/result — which is NOT a step-aligned start (its
|
||||
// issuing assistant precedes it in the same step). Rounding head-ward to find a
|
||||
// clean boundary reaches index 0, so there is no step-aligned cutoff in the
|
||||
// compactable range: compactIfNeeded declines rather than splitting the step.
|
||||
const s = new Session(SessionId('one-step'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [{ type: 'text', text: 'calling' }, { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
// no tool/result, no step/end — the step is open at the tail.
|
||||
s.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' })
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, { surfaceOp: 'append' })
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
// Turn stays open.
|
||||
|
||||
const svc = createTestService({ contextWindow: 50, thresholdRatio: 0.5, retainTokens: 5 })
|
||||
const result = await svc.compactIfNeeded(s)
|
||||
const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 })
|
||||
const result = await svc.compactIfNeeded(s, '', 'm', SIGNAL)
|
||||
expect(result).toBeNull()
|
||||
// The open step's assistant survived — its tool-call is intact for the result.
|
||||
expect(s.events.some(e => e.type === 'compact/start')).toBe(false)
|
||||
})
|
||||
|
||||
@@ -516,107 +506,103 @@ describe('BasicCompactService.compactIfNeeded', () => {
|
||||
it('returns null when tokens are under threshold', async () => {
|
||||
const svc = createTestService({ contextWindow: 128000, thresholdRatio: 0.8 })
|
||||
const session = multiTurnSession(1, 1)
|
||||
expect(await svc.compactIfNeeded(session)).toBeNull()
|
||||
expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull()
|
||||
})
|
||||
|
||||
it('compacts when tokens exceed threshold', async () => {
|
||||
const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 })
|
||||
const session = multiTurnSession(3, 1) // 6 surface nodes, 10 tokens each = 60
|
||||
|
||||
const result = await svc.compactIfNeeded(session)
|
||||
const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.shadowedSeqs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('walks tail→head and retains nodes within token budget', async () => {
|
||||
const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 15 })
|
||||
const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.2, retainTokens: 15 })
|
||||
const session = multiTurnSession(5, 1) // 10 surface nodes = ~100 tokens
|
||||
|
||||
const result = await svc.compactIfNeeded(session)
|
||||
const result = await svc.compactIfNeeded(session, '', 'm', SIGNAL)
|
||||
expect(result).not.toBeNull()
|
||||
const nodes = session.surface.nodes
|
||||
expect(result!.shadowedSeqs.length).toBeGreaterThan(0)
|
||||
expect(result!.shadowedSeqs).not.toContain(nodes[nodes.length - 1]!.seq)
|
||||
})
|
||||
|
||||
it('returns null when total tokens fit within budget', async () => {
|
||||
const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 1000 })
|
||||
it('returns null when the whole surface fits the retain budget (over threshold by role/system overhead)', async () => {
|
||||
// threshold = floor(460*0.1) = 46. The 4 surface nodes weigh 10 each (raw 40
|
||||
// for the retention walk), but the derived estimate adds 4 role tokens per
|
||||
// message → 56 ≥ 46, so the threshold check passes and the walk runs. The
|
||||
// walk accumulates all 40 < retainTokens (45) without crossing the budget,
|
||||
// so keepFromIdx reaches 0 and compaction declines. The invariant holds:
|
||||
// summarizationMaxTokens (1) + retainTokens (45) = 46 ≤ threshold 46.
|
||||
const svc = createTestService({ contextWindow: 460, thresholdRatio: 0.1, retainTokens: 45 })
|
||||
const session = multiTurnSession(2, 1)
|
||||
expect(await svc.compactIfNeeded(session)).toBeNull()
|
||||
expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull()
|
||||
})
|
||||
|
||||
it('retains the in-flight turn verbatim even when its newest node exceeds retainTokens', async () => {
|
||||
// The current turn's first step has CLOSED (so its last node is step-aligned
|
||||
// and would otherwise be a valid compaction cutoff), and that node — a fresh
|
||||
// tool result — is larger than the whole retain budget. It must NOT be
|
||||
// compacted: it is the observation the model needs for the turn's next step.
|
||||
// Only the older closed turns are eligible.
|
||||
const svc = new TestCompactServiceVarTokens({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5 })
|
||||
const s = new Session(SessionId('big-tail'))
|
||||
// Two closed turns (compactable older context).
|
||||
for (const t of [1, 2]) {
|
||||
s.append('turn/start', { turn: t, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: t, step: 1 })
|
||||
s.append('user/message', { content: [{ type: 'text', text: `turn ${t}` }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { turn: t, step: 1, content: [{ type: 'text', text: `reply ${t}` }] }, { surfaceOp: 'append' })
|
||||
s.append('step/end', { turn: t, step: 1 })
|
||||
s.append('turn/end', { turn: t, reason: { kind: 'completed' } })
|
||||
it('compacts a runaway turn: its early CLOSED steps summarize while recent steps stay verbatim', async () => {
|
||||
// The REGRESSION that motivated dropping turn-protection. A single in-flight
|
||||
// (open) turn has grown past the threshold on its own: several CLOSED steps,
|
||||
// each [assistant(tool-call), tool/result]. Retention is turn-agnostic, so
|
||||
// the turn's OWN early closed steps are eligible — they compact while the
|
||||
// recent tail stays verbatim, and the harness survives.
|
||||
//
|
||||
// On the OLD layer-2 code this test FAILS: the entire open turn was retained
|
||||
// verbatim (protectedIdx = first open-turn node = 0), so compactIfNeeded
|
||||
// returned null and shadowedSeqs would be empty — the runaway turn could
|
||||
// never compact and the next model call would overflow the window.
|
||||
const svc = createTestService({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 25 })
|
||||
const s = new Session(SessionId('runaway'))
|
||||
// ONE open turn with 5 closed steps; each step is [asst(tool-call), result].
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'do a big multi-step task' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
for (let step = 1; step <= 5; step++) {
|
||||
s.append('step/start', { turn: 1, step })
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step,
|
||||
content: [{ type: 'text', text: `step ${step}` }, { type: 'tool-call', id: CallId(`c${step}`), name: 'bash', arguments: '{}' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
s.append('tool/call', { turn: 1, step, callId: CallId(`c${step}`), name: 'bash', arguments: '{}' })
|
||||
s.append('tool/result', { turn: 1, step, callId: CallId(`c${step}`), content: [{ type: 'text', text: `out ${step}` }], isError: false }, { surfaceOp: 'append' })
|
||||
s.append('step/end', { turn: 1, step })
|
||||
}
|
||||
// The in-flight turn 3: a user request, then a CLOSED step 1 whose tool
|
||||
// result is HUGE (1000 tokens). The step is closed (step/end), so the result
|
||||
// node is step-aligned — without the in-flight-turn protection the retention
|
||||
// walk would pick it as the cutoff and compact it away. The turn itself is
|
||||
// still open (no turn/end): the model is mid-turn, about to run step 2.
|
||||
s.append('turn/start', { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'current request' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('step/start', { turn: 3, step: 1 })
|
||||
s.append('assistant/message', { turn: 3, step: 1, content: [{ type: 'text', text: 'calling' }, { type: 'tool-call', id: CallId('huge'), name: 'bash', arguments: '{}' }] }, { surfaceOp: 'append' })
|
||||
s.append('tool/call', { turn: 3, step: 1, callId: CallId('huge'), name: 'bash', arguments: '{}' })
|
||||
const hugeSeq = s.append('tool/result', {
|
||||
turn: 3, step: 1, callId: CallId('huge'),
|
||||
content: [{ type: 'text', text: 'HUGE' }], isError: false,
|
||||
}, { surfaceOp: 'append' }).seq
|
||||
s.append('step/end', { turn: 3, step: 1 })
|
||||
svc.bigSeqs.add(hugeSeq) // make this node weigh 1000 tokens
|
||||
// The turn stays OPEN (no turn/end) — the model is mid-turn, about to run
|
||||
// step 6. Surface: user + 5×[asst, result] = 11 nodes.
|
||||
const nodesBefore = s.surface.nodes.length
|
||||
expect(nodesBefore).toBe(11)
|
||||
|
||||
const result = await svc.compactIfNeeded(s)
|
||||
const result = await svc.compactIfNeeded(s, '', 'm', SIGNAL)
|
||||
expect(result).not.toBeNull()
|
||||
// The in-flight turn's nodes — the request, the assistant, AND the huge
|
||||
// result — are retained: none shadowed, all survive on the surface verbatim.
|
||||
expect(result!.shadowedSeqs).not.toContain(hugeSeq)
|
||||
const survivingSeqs = new Set(s.surface.nodes.map(n => n.seq))
|
||||
expect(survivingSeqs.has(hugeSeq)).toBe(true)
|
||||
const requestSeq = s.events.find(e => e.type === 'user/message' && e.data.content.some(b => b.type === 'text' && b.text === 'current request'))!.seq
|
||||
expect(survivingSeqs.has(requestSeq)).toBe(true)
|
||||
// The older closed turns WERE compacted.
|
||||
// Early steps of the SAME open turn were shadowed (impossible under layer 2).
|
||||
expect(result!.shadowedSeqs.length).toBeGreaterThan(0)
|
||||
// The most-recent step's tool result is retained verbatim (still on surface).
|
||||
const lastResultSeq = s.events.findLast(e => e.type === 'tool/result')!.seq
|
||||
expect(result!.shadowedSeqs).not.toContain(lastResultSeq)
|
||||
expect(s.surface.nodes.some(n => n.seq === lastResultSeq)).toBe(true)
|
||||
// No orphaned tool-result survives (whole-step boundaries respected).
|
||||
expectNoOrphanToolResults(s.deriveMessages())
|
||||
})
|
||||
|
||||
it('returns null for an empty surface', async () => {
|
||||
const svc = createTestService({ contextWindow: 10, thresholdRatio: 0.1 })
|
||||
const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 })
|
||||
const session = new Session(SessionId('empty'))
|
||||
expect(await svc.compactIfNeeded(session)).toBeNull()
|
||||
expect(await svc.compactIfNeeded(session, '', 'm', SIGNAL)).toBeNull()
|
||||
})
|
||||
|
||||
it('compacts again within the same open turn (the prior summary node is still eligible)', async () => {
|
||||
// After the first compaction lands a replacement summary node, that node is
|
||||
// appended DURING the open turn (seq > turn/start) but sits earlier in the
|
||||
// surface (at the shadowed range's position), NOT in the verbatim tail run.
|
||||
// It must stay compaction-eligible: a second step in the SAME turn, still
|
||||
// over threshold, must be able to compact older context — protectedIdx must
|
||||
// not collapse to 0 and silently disable per-step auto-compaction.
|
||||
// retainTokens=25 leaves a couple of retained closed-turn nodes after the
|
||||
// first compaction (so the surface is [summary, …retained], not [summary]).
|
||||
const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1, retainTokens: 25 })
|
||||
it('compacts again after a prior summary node heads the surface (the summary stays eligible)', async () => {
|
||||
// After the first compaction lands a replacement summary node at the head,
|
||||
// a second compaction (still over threshold) re-consolidates it with newer
|
||||
// context — head-anchoring means the prior checkpoint is always re-included,
|
||||
// never stranded. retainTokens=25 leaves a couple of retained nodes after
|
||||
// the first compaction (so the surface is [summary, …retained], not just
|
||||
// [summary]).
|
||||
const svc = createTestService({ contextWindow: 300, thresholdRatio: 0.1, retainTokens: 25 })
|
||||
const s = multiTurnSession(4, 1) // turns 1-4 closed, turn 5 open (no surface yet)
|
||||
|
||||
const first = await svc.compactIfNeeded(s)
|
||||
const first = await svc.compactIfNeeded(s, '', 'm', SIGNAL)
|
||||
expect(first).not.toBeNull()
|
||||
// The summary node now heads the surface; the open turn has no verbatim tail
|
||||
// node yet, so the whole surface (incl. the summary) is eligible — the
|
||||
// protected suffix is the contiguous tail run of open-turn nodes (none yet).
|
||||
// The summary node's seq exceeds turn 5's turn/start, yet it sits at the
|
||||
// head (not the tail), so it must NOT be counted as protected.
|
||||
// The summary node now heads the surface with a fresh high seq.
|
||||
const summaryHeadSeq = s.surface.nodes[0]!.seq
|
||||
const turn5StartSeq = s.events.filter(e => e.type === 'turn/start').at(-1)!.seq
|
||||
expect(summaryHeadSeq).toBeGreaterThan(turn5StartSeq)
|
||||
@@ -629,7 +615,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
|
||||
s.append('assistant/message', { turn: 5, step: 1, content: [{ type: 'text', text: 'reply 5' }] }, { surfaceOp: 'append' })
|
||||
s.append('step/end', { turn: 5, step: 1 })
|
||||
|
||||
const second = await svc.compactIfNeeded(s)
|
||||
const second = await svc.compactIfNeeded(s, '', 'm', SIGNAL)
|
||||
expect(second).not.toBeNull()
|
||||
expect(second!.shadowedSeqs.length).toBeGreaterThan(0)
|
||||
// The fresh open-turn nodes were NOT compacted.
|
||||
@@ -751,6 +737,27 @@ describe('BasicCompactService HMR safety', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BasicCompactService convergence invariant (config)', () => {
|
||||
it('throws when summarizationMaxTokens + retainTokens exceeds the threshold', () => {
|
||||
// threshold = floor(1000 * 0.5) = 500; 200 + 400 = 600 > 500 → reject.
|
||||
expect(() => new BasicCompactService(new Context(), {
|
||||
auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 200,
|
||||
})).toThrow(/exceeds the compaction threshold/)
|
||||
})
|
||||
|
||||
it('accepts the boundary case (sum equals the threshold)', () => {
|
||||
// threshold = floor(1000 * 0.5) = 500; 100 + 400 = 500 ≤ 500 → allowed.
|
||||
expect(() => new BasicCompactService(new Context(), {
|
||||
auto: false, contextWindow: 1000, thresholdRatio: 0.5, retainTokens: 400, summarizationMaxTokens: 100,
|
||||
})).not.toThrow()
|
||||
})
|
||||
|
||||
it('the default config satisfies the invariant', () => {
|
||||
// 2048 + 20480 = 22528 ≤ floor(128000 * 0.8) = 102400.
|
||||
expect(() => new BasicCompactService(new Context(), { auto: false })).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
/** An adapter that emits a fixed summary text, for exercising the real summarize() path. */
|
||||
class ScriptedAdapter extends LlmAdapter {
|
||||
lastOptions: GenerateOptions | null = null
|
||||
@@ -876,81 +883,73 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('BasicCompactService auto-compaction (agent/request listener)', () => {
|
||||
/** Fire the agent/request waterfall as the loop does. */
|
||||
function fireRequest(ctx: Context, agent: Agent, step: number, options: GenerateOptions): Promise<GenerateOptions> {
|
||||
return ctx.waterfall('agent/request', agent, 1, step, options, () => Promise.resolve(options))
|
||||
describe('BasicCompactService auto-compaction (agent/pre-request listener)', () => {
|
||||
/** Fire the agent/pre-request parallel checkpoint as the loop does. */
|
||||
function firePreRequest(ctx: Context, agent: Agent, step: number, system: string, model: string): Promise<unknown> {
|
||||
return ctx.parallel('agent/pre-request', agent, 1, step, system, model, SIGNAL)
|
||||
}
|
||||
|
||||
it('compacts and rewrites request.messages when over threshold', async () => {
|
||||
// Tiny window so the (large) session is over threshold; char/4 estimate.
|
||||
it('compacts (mutating the surface) when over threshold', async () => {
|
||||
const { ctx } = await ctxWithModel('SUMMARY')
|
||||
const svc = new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20 })
|
||||
void new BasicCompactService(ctx, { contextWindow: 200, thresholdRatio: 0.5, retainTokens: 20, summarizationMaxTokens: 50 })
|
||||
const session = multiTurnSession(5, 1) // 10 surface nodes
|
||||
const agent = stubAgent(session, 'test-model')
|
||||
const before = session.surface.nodes.length
|
||||
|
||||
const messages = session.deriveMessages()
|
||||
const before = messages.length
|
||||
const options: GenerateOptions = { model: 'test-model', messages }
|
||||
await firePreRequest(ctx, agent, 1, '', 'test-model')
|
||||
|
||||
const out = await fireRequest(ctx, agent, 1, options)
|
||||
// The surface shrank — request.messages was re-derived to fewer entries.
|
||||
expect(out.messages.length).toBeLessThan(before)
|
||||
// The surface shrank in place, and a summary checkpoint landed.
|
||||
expect(session.surface.nodes.length).toBeLessThan(before)
|
||||
expect(session.events.some(e => e.type === 'compact/summary')).toBe(true)
|
||||
// Re-derived first message is the framed summary checkpoint.
|
||||
expect(out.messages[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' })
|
||||
expect(svc).toBeDefined()
|
||||
// The re-derived head message is the framed summary checkpoint.
|
||||
expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' })
|
||||
})
|
||||
|
||||
it('compacts mid-turn on steps after the first (the surface grows within a turn)', async () => {
|
||||
const { ctx } = await ctxWithModel('SUMMARY')
|
||||
void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10 })
|
||||
void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.5, retainTokens: 10, summarizationMaxTokens: 30 })
|
||||
const session = multiTurnSession(3, 1) // over the 0.5 threshold
|
||||
const agent = stubAgent(session, 'test-model')
|
||||
const options: GenerateOptions = { model: 'test-model', messages: session.deriveMessages() }
|
||||
|
||||
// A step-2 request (a tool-heavy turn's later step) must still compact — the
|
||||
// surface accumulated assistant/message + tool/result nodes since step 1.
|
||||
await fireRequest(ctx, agent, 2, options)
|
||||
// A step-2 checkpoint (a tool-heavy turn's later step) must still compact —
|
||||
// the surface accumulated assistant/message + tool/result nodes since step 1.
|
||||
await firePreRequest(ctx, agent, 2, '', 'test-model')
|
||||
expect(session.events.some(e => e.type === 'compact/start')).toBe(true)
|
||||
})
|
||||
|
||||
it('passes through unchanged when under threshold', async () => {
|
||||
it('does nothing when under threshold', async () => {
|
||||
const { ctx } = await ctxWithModel('SUMMARY')
|
||||
void new BasicCompactService(ctx, { contextWindow: 128000, thresholdRatio: 0.8 })
|
||||
const session = multiTurnSession(1, 1)
|
||||
const agent = stubAgent(session, 'test-model')
|
||||
const msgs = session.deriveMessages()
|
||||
const options: GenerateOptions = { model: 'test-model', messages: msgs }
|
||||
|
||||
const out = await fireRequest(ctx, agent, 1, options)
|
||||
expect(out.messages).toBe(msgs)
|
||||
await firePreRequest(ctx, agent, 1, '', 'test-model')
|
||||
expect(session.events.some(e => e.type === 'compact/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('proceeds with original history when compaction fails', async () => {
|
||||
// No adapter registered for this model → summarize() rejects → caught, proceeds.
|
||||
it('leaves the surface intact when compaction fails (summarize rejects)', async () => {
|
||||
// No adapter registered for this model → summarize() rejects → caught, the
|
||||
// surface is untouched (the loop derives the full history).
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
void new BasicCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.1, retainTokens: 10 })
|
||||
void new BasicCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10, summarizationMaxTokens: 1 })
|
||||
const session = multiTurnSession(3, 1)
|
||||
const agent = stubAgent(session, 'missing-model')
|
||||
const msgs = session.deriveMessages()
|
||||
const options: GenerateOptions = { model: 'missing-model', messages: msgs }
|
||||
const before = session.surface.nodes.length
|
||||
|
||||
const out = await fireRequest(ctx, agent, 1, options)
|
||||
// Listener swallowed the failure and left messages intact.
|
||||
expect(out.messages).toBe(msgs)
|
||||
await firePreRequest(ctx, agent, 1, '', 'missing-model')
|
||||
// No summary landed; the surface is unchanged.
|
||||
expect(session.events.some(e => e.type === 'compact/summary')).toBe(false)
|
||||
expect(session.surface.nodes.length).toBe(before)
|
||||
})
|
||||
|
||||
it('does not register the listener when auto is false', async () => {
|
||||
const { ctx } = await ctxWithModel('SUMMARY')
|
||||
void new BasicCompactService(ctx, { auto: false, contextWindow: 10, thresholdRatio: 0.1, retainTokens: 1 })
|
||||
void new BasicCompactService(ctx, { auto: false, contextWindow: 100, thresholdRatio: 0.1, retainTokens: 5, summarizationMaxTokens: 1 })
|
||||
const session = multiTurnSession(3, 1)
|
||||
const agent = stubAgent(session, 'test-model')
|
||||
const options: GenerateOptions = { model: 'test-model', messages: session.deriveMessages() }
|
||||
|
||||
await fireRequest(ctx, agent, 1, options)
|
||||
await firePreRequest(ctx, agent, 1, '', 'test-model')
|
||||
expect(session.events.some(e => e.type === 'compact/start')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1049,46 +1048,65 @@ describe('BasicCompactService edge cases', () => {
|
||||
expect(svc.estimateContentTokens([unknown])).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('compacts and re-derives without re-checking a post-compaction threshold', async () => {
|
||||
it('compacts once without re-checking a post-compaction threshold', async () => {
|
||||
const { ctx } = await ctxWithModel('SUMMARY')
|
||||
// Even with a window so tiny the post-compaction history still exceeds the
|
||||
// threshold, the agnostic listener does NOT re-gate or warn — it compacts
|
||||
// once (the single check lives in compactIfNeeded) and proceeds.
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn
|
||||
void new BasicCompactService(ctx, { contextWindow: 10, thresholdRatio: 0.1, retainTokens: 5 })
|
||||
void new BasicCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 5, summarizationMaxTokens: 5 })
|
||||
const session = multiTurnSession(4, 1)
|
||||
const agent = stubAgent(session, 'test-model')
|
||||
const options: GenerateOptions = { model: 'test-model', messages: session.deriveMessages() }
|
||||
|
||||
await ctx.waterfall('agent/request', agent, 1, 1, options, () => Promise.resolve(options))
|
||||
await ctx.parallel('agent/pre-request', agent, 1, 1, '', 'test-model', SIGNAL)
|
||||
expect(session.events.some(e => e.type === 'compact/summary')).toBe(true)
|
||||
// The surface was re-derived into the request; no cascade warning is emitted.
|
||||
expect(options.messages[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' })
|
||||
// The surface was mutated; the head message is the framed summary checkpoint.
|
||||
expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'SUMMARY' })
|
||||
// No cascade warning is emitted.
|
||||
expect(warnings.length).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects compaction when no turn is open (compaction events must be turn-enclosed)', async () => {
|
||||
const svc = createTestService()
|
||||
// A session with surface nodes but NO open turn — compaction's compact/* and
|
||||
// replacement events would be appended outside any turn, which the session-log
|
||||
// contract forbids.
|
||||
// A session whose only turn has CLOSED — scanning back from the tail hits
|
||||
// turn/end before any turn/start, so there is no open turn to enclose
|
||||
// compaction's compact/* + replacement events, which the log contract forbids.
|
||||
const s = new Session(SessionId('noturn'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'orphan' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'reply' }] }, { surfaceOp: 'append' })
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const nodes = s.surface.nodes
|
||||
|
||||
await expect(svc.compactRegion(s, nodes[0]!.seq, nodes[0]!.seq, 'm'))
|
||||
await expect(svc.compactRegion(s, nodes[0]!.seq, nodes[1]!.seq, 'm'))
|
||||
.rejects.toThrow(/no open turn/)
|
||||
// The lock was never acquired — no compact/start landed.
|
||||
expect(s.events.some(e => e.type === 'compact/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects compaction on a session with no turn boundaries at all', async () => {
|
||||
const svc = createTestService()
|
||||
// No turn events whatsoever — the open-turn scan falls through to the end
|
||||
// of the log and finds none, so compaction is rejected (its events have no
|
||||
// turn to enclose them).
|
||||
const s = new Session(SessionId('turnless'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'orphan' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
const nodes = s.surface.nodes
|
||||
|
||||
await expect(svc.compactRegion(s, nodes[0]!.seq, nodes[0]!.seq, 'm'))
|
||||
.rejects.toThrow(/no open turn/)
|
||||
expect(s.events.some(e => e.type === 'compact/start')).toBe(false)
|
||||
})
|
||||
|
||||
it('compactIfNeeded returns null for empty surface even when over threshold', async () => {
|
||||
const svc = createTestService({ contextWindow: 100, thresholdRatio: 0.1 })
|
||||
const svc = createTestService({ contextWindow: 1000, thresholdRatio: 0.1, retainTokens: 5 })
|
||||
const session = new Session(SessionId('empty-but-pressured'))
|
||||
// No surface nodes, but a large system prompt pushes the estimate over threshold.
|
||||
const bigPrompt = 'x'.repeat(400) // ceil(400/4) = 100 tokens >> threshold 10
|
||||
expect(await svc.compactIfNeeded(session, bigPrompt)).toBeNull()
|
||||
const bigPrompt = 'x'.repeat(800) // ceil(800/4) = 200 tokens >> threshold 100
|
||||
expect(await svc.compactIfNeeded(session, bigPrompt, 'm', SIGNAL)).toBeNull()
|
||||
})
|
||||
|
||||
it('compactRegion throws when end is not a surface node (start valid)', async () => {
|
||||
@@ -1116,15 +1134,16 @@ describe('BasicCompactService edge cases', () => {
|
||||
const { ctx } = await ctxWithModel('SUMMARY')
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((msg: string) => void warnings.push(msg)) as typeof ctx.logger.warn
|
||||
const svc = new TestCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.1, retainTokens: 10 })
|
||||
const svc = new TestCompactService(ctx, { contextWindow: 300, thresholdRatio: 0.1, retainTokens: 10, summarizationMaxTokens: 10 })
|
||||
svc.summarizeError = 'boom' as unknown as Error
|
||||
const session = multiTurnSession(3, 1)
|
||||
const agent = stubAgent(session, 'test-model')
|
||||
const msgs = session.deriveMessages()
|
||||
const options: GenerateOptions = { model: 'test-model', messages: msgs }
|
||||
const before = session.surface.nodes.length
|
||||
|
||||
const out = await ctx.waterfall('agent/request', agent, 1, 1, options, () => Promise.resolve(options))
|
||||
expect(out.messages).toBe(msgs) // proceeded with original history
|
||||
await ctx.parallel('agent/pre-request', agent, 1, 1, '', 'test-model', SIGNAL)
|
||||
// The failure was swallowed; the surface is untouched and a warning logged.
|
||||
expect(session.surface.nodes.length).toBe(before)
|
||||
expect(session.events.some(e => e.type === 'compact/summary')).toBe(false)
|
||||
expect(warnings.some(w => w.includes('compaction failed: boom'))).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1132,16 +1151,14 @@ describe('BasicCompactService edge cases', () => {
|
||||
const { ctx } = await ctxWithModel('SUMMARY')
|
||||
// A large system prompt pushes the listener's estimate over threshold, but
|
||||
// retainTokens is huge so compactIfNeeded walks everything and returns null.
|
||||
const svc = new TestCompactService(ctx, { contextWindow: 100, thresholdRatio: 0.1, retainTokens: 100000 })
|
||||
// threshold = floor(2000*0.1) = 200; invariant: 5 + 150 = 155 ≤ 200.
|
||||
const svc = new TestCompactService(ctx, { contextWindow: 2000, thresholdRatio: 0.1, retainTokens: 150, summarizationMaxTokens: 5 })
|
||||
const session = multiTurnSession(2, 1)
|
||||
const agent = stubAgent(session, 'test-model')
|
||||
const bigSystem = 'x'.repeat(400)
|
||||
const msgs = session.deriveMessages()
|
||||
const options: GenerateOptions = { model: 'test-model', messages: msgs, system: bigSystem }
|
||||
const bigSystem = 'x'.repeat(900) // ceil(900/4)=225 > threshold 200
|
||||
|
||||
const out = await ctx.waterfall('agent/request', agent, 1, 1, options, () => Promise.resolve(options))
|
||||
await ctx.parallel('agent/pre-request', agent, 1, 1, bigSystem, 'test-model', SIGNAL)
|
||||
expect(session.events.some(e => e.type === 'compact/start')).toBe(false)
|
||||
expect(out.messages).toBe(msgs)
|
||||
expect(svc.summarizeCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ This package is the interface tier of the compaction capability, split so each c
|
||||
| `@deepseek-ai/dsh-compact-basic` | a backend: char/4 estimation + token-budget retention + `llm.stream()` summarization |
|
||||
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
|
||||
|
||||
Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md).
|
||||
Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md).
|
||||
|
||||
## Service API (`ctx.compact`)
|
||||
|
||||
@@ -18,10 +18,10 @@ Both methods are **abstract** — the backend owns the entire strategy (token es
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `compactIfNeeded(session, systemPrompt?, model?, signal?)` | Estimate the 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. |
|
||||
| `compactIfNeeded(session, system, model, 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-request` checkpoint always supplies the assembled `system`, the `model`, and the turn `signal`. |
|
||||
| `compactRegion(session, start, end, model, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **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. |
|
||||
|
||||
Both methods take an optional `signal: AbortSignal`. 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 not a parameter — it is recoverable from the log (the currently-open turn), so the backend stamps it without the caller supplying it.
|
||||
`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 not a parameter — it is recoverable from the log (the currently-open turn), so the backend stamps it without the caller supplying it.
|
||||
|
||||
## Surface contract
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* depends on `dsh-session` and `dsh-llm`: the contract's verbs are defined over
|
||||
* a `Session` and its output is the `ContentBlock` vocabulary. That deviation
|
||||
* from the "interface depends only on cordis" guidance is intentional and
|
||||
* recorded in the [compaction capability-seam RFC](../../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md).
|
||||
* recorded in the [compaction capability-seam RFC](../../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact
|
||||
*/
|
||||
@@ -62,14 +62,31 @@ export abstract class CompactService extends Service {
|
||||
/**
|
||||
* Check token pressure and compact if the conversation is too large.
|
||||
*
|
||||
* Estimates the current history size (optionally including a system prompt),
|
||||
* and if it exceeds the backend's threshold, compacts an older range via
|
||||
* {@link compactRegion}, keeping recent context intact.
|
||||
* Estimates the current surface-derived history size (including the system
|
||||
* prompt), and if it exceeds the backend's threshold, compacts an older range
|
||||
* via {@link compactRegion}, keeping recent context intact. Returns `null`
|
||||
* when no compaction is needed.
|
||||
*
|
||||
* Scope and guarantees a backend MUST honor:
|
||||
* - **Surface-derived history only.** The decision is made against the history
|
||||
* derived from the session surface — the only thing compaction can act on.
|
||||
* Non-surface context injected downstream (into the request `messages` by a
|
||||
* later listener) is out of this accounting by construction.
|
||||
* - **Head-anchored, best-effort.** Auto-compaction consolidates from the
|
||||
* surface HEAD up to a step-aligned cutoff, so a prior head checkpoint is
|
||||
* re-summarized into one fresh checkpoint (the surface holds at most one
|
||||
* auto-generated checkpoint, always at the head). It is best-effort over
|
||||
* CLOSED steps: when the only compactable content left is an un-splittable
|
||||
* open tail step, it declines (`null`) and retries once that step closes.
|
||||
* - **Single-unit overflow is out of scope.** If a single retained unit (one
|
||||
* closed step, or a large free node such as a pasted `user/message`) ALONE
|
||||
* exceeds the budget, compaction cannot help and the call may go out
|
||||
* over-budget. Bounding an individual unit's size is a separate concern.
|
||||
*
|
||||
* @param session - the session whose surface may be compacted.
|
||||
* @param systemPrompt - optional system prompt, counted toward the estimate.
|
||||
* @param model - optional summarization model (falls back to backend config).
|
||||
* @param signal - optional cancellation signal. A backend that summarizes via
|
||||
* @param system - the assembled system prompt, counted toward the estimate.
|
||||
* @param model - the summarization model (a backend may override via config).
|
||||
* @param signal - cancellation signal. A backend summarizing via
|
||||
* `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal`
|
||||
* so an abort/dispose tears down the in-flight summarization rather than
|
||||
* leaving an orphaned model call running past the cancellation.
|
||||
@@ -77,9 +94,9 @@ export abstract class CompactService extends Service {
|
||||
*/
|
||||
abstract compactIfNeeded(
|
||||
session: Session,
|
||||
systemPrompt?: string,
|
||||
model?: string,
|
||||
signal?: AbortSignal,
|
||||
system: string,
|
||||
model: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null>
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* events are log-only markers (lock + provenance); only the five
|
||||
* surface-eligible types can carry `surfaceOp`. The actual surface mutation is
|
||||
* performed by a separate `user/message` event carrying the summary (see the
|
||||
* [compaction capability-seam RFC](../../../../docs/rfc/proposed/feature/2026-06-18-compaction-capability-seam.md)).
|
||||
* [compaction capability-seam RFC](../../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)).
|
||||
*
|
||||
* Configuration lives in the backend, not here: the contract states WHAT
|
||||
* compaction produces, while every tunable (context window, thresholds,
|
||||
|
||||
@@ -149,8 +149,9 @@ export interface LoopHandle {
|
||||
* drain steering → session('steering/message') ⟵ catches late steering
|
||||
* session('step/start'); emit agent/step-start ⟵ append before emit (the event-sourcing RFC)
|
||||
* assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble
|
||||
* await ctx.parallel('agent/pre-request') ⟵ surface mutation (compaction) BEFORE derive
|
||||
* req = {model, system, tools, messages: session.deriveMessages(), signal}
|
||||
* req = waterfall agent/request ⟵ hooks/compaction/model-switch
|
||||
* req = waterfall agent/request ⟵ hooks/model-switch
|
||||
* stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks)
|
||||
* session('assistant/chunk'); emit agent/stream-chunk
|
||||
* msg = waterfall agent/step-result ⟵ BEFORE the log append, so the
|
||||
@@ -565,6 +566,13 @@ async function runStep(
|
||||
.filter(text => text.length > 0)
|
||||
.join('\n\n')
|
||||
|
||||
// Surface-mutation checkpoint BEFORE deriving history: compaction shadows an
|
||||
// older range with a summary node here, and the single derive below reflects
|
||||
// it. Awaited (no veto) — a listener mutates the surface as a side effect.
|
||||
// `model` is resolved to '' when unset; a compaction listener that needs a
|
||||
// model falls back to its own config.
|
||||
await ctx.parallel('agent/pre-request', agent, turn, step, system, options.model ?? '', signal)
|
||||
|
||||
let request: GenerateOptions = {
|
||||
model: options.model ?? '',
|
||||
messages: session.deriveMessages(),
|
||||
|
||||
@@ -320,6 +320,65 @@ describe('agent loop', () => {
|
||||
expect(adapter.requests[0]!.model).toBe('other-model')
|
||||
})
|
||||
|
||||
it('agent/pre-request fires once per step before the request is derived', async () => {
|
||||
// Two steps (a tool call, then a final text turn) → two model calls → two
|
||||
// pre-request fires, each carrying the assembled system + model, BEFORE the
|
||||
// request messages are derived (the request the adapter sees reflects any
|
||||
// surface state at fire time).
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', {}, 'calling echo'),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo', description: 'echo', parameters: {},
|
||||
async execute() { return [{ type: 'text', text: 'echoed' }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const fires: { turn: number; step: number; model: string }[] = []
|
||||
ctx.on('agent/pre-request', (subject, turn, step, _system, model) => {
|
||||
if (subject === agent) fires.push({ turn, step, model })
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// One fire per step, in order, each with the agent's model.
|
||||
expect(fires).toEqual([
|
||||
{ turn: 1, step: 1, model: 'mock' },
|
||||
{ turn: 1, step: 2, model: 'mock' },
|
||||
])
|
||||
})
|
||||
|
||||
it('a surface mutation in agent/pre-request is reflected in the derived request (single derive)', async () => {
|
||||
// pre-request fires BEFORE deriveMessages(), so a listener that appends a
|
||||
// surface node there sees it land in the SAME step's request — proving the
|
||||
// loop derives once, after the checkpoint, with no stale pre-derive.
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let injected = false
|
||||
ctx.on('agent/pre-request', (subject, turn) => {
|
||||
if (subject === agent && !injected) {
|
||||
injected = true
|
||||
subject.session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'INJECTED-IN-PRE-REQUEST' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}, { surfaceOp: 'append' })
|
||||
void turn
|
||||
}
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The adapter's request includes the node injected during pre-request.
|
||||
const text = JSON.stringify(adapter.requests[0]!.messages)
|
||||
expect(text).toContain('INJECTED-IN-PRE-REQUEST')
|
||||
})
|
||||
|
||||
it('cancel() mid-stream ends the turn with reason aborted', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
|
||||
@@ -180,10 +180,32 @@ declare module 'cordis' {
|
||||
'agent/step-end'(agent: Agent, turn: number, step: number): void
|
||||
|
||||
// ---- interception seams (waterfall) ----
|
||||
/**
|
||||
* Awaited surface-mutation checkpoint, fired BEFORE the step's message
|
||||
* history is derived (and thus before {@link agent/request}). The loop
|
||||
* awaits `ctx.parallel('agent/pre-request', …)` after assembling the system
|
||||
* prompt but before `session.deriveMessages()`, then derives ONCE from
|
||||
* whatever the surface now holds. This is where compaction belongs: it
|
||||
* mutates the session surface in place (shadowing an older range with a
|
||||
* summary node), and the single subsequent derive reflects the mutation —
|
||||
* so there is no double-derive and no listener can see (or be expected to
|
||||
* act on) an assembled `messages` array that does not exist yet.
|
||||
*
|
||||
* Awaited (parallel), not a waterfall: a listener mutates the surface as a
|
||||
* side effect; there is nothing to transform or veto, but the loop must wait
|
||||
* for the mutation to complete before deriving. `system`/`model` are the
|
||||
* assembled values a listener needs to measure pressure (system counts
|
||||
* toward the budget) and to summarize (the model). `signal` cancels any
|
||||
* in-flight work a listener starts (e.g. a summarization model call).
|
||||
* @mode parallel
|
||||
*/
|
||||
'agent/pre-request'(agent: Agent, turn: number, step: number, system: string, model: string, signal: AbortSignal): Promise<void> | void
|
||||
/**
|
||||
* Waterfall: mutate the fully-assembled {@link GenerateOptions} before the
|
||||
* model call (hooks, compaction, model switching, tool filtering, …). Call
|
||||
* `next()` to delegate, or return without it to short-circuit.
|
||||
* model call (hooks, model switching, tool filtering, …). Call `next()` to
|
||||
* delegate, or return without it to short-circuit. For surface mutation that
|
||||
* must precede history derivation (compaction), use {@link agent/pre-request}
|
||||
* instead — by the time this fires, `options.messages` is already derived.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise<GenerateOptions>): Promise<GenerateOptions>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId, isSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Build a minimal session with turn boundaries and a single user message. */
|
||||
@@ -278,4 +278,21 @@ describe('Session.append surface opts', () => {
|
||||
// The string 'append' is a primitive — identity-preserving is fine.
|
||||
expect(event.surfaceOp).toBe('append')
|
||||
})
|
||||
|
||||
it('isSurfaceEvent rejects a surface-eligible type missing its surfaceOp marker', () => {
|
||||
// A raw event (not built via append, which mandates the marker) of a
|
||||
// surface-eligible type but with no surfaceOp must NOT narrow to a
|
||||
// SurfaceEvent — it would otherwise be silently dropped from the surface.
|
||||
const noMarker: SessionEvent = {
|
||||
type: 'user/message', seq: 0, time: 1,
|
||||
data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
|
||||
}
|
||||
expect(isSurfaceEvent(noMarker)).toBe(false)
|
||||
// A non-surface type is rejected too (the type gate).
|
||||
const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }
|
||||
expect(isSurfaceEvent(boundary)).toBe(false)
|
||||
// A properly-marked surface event narrows.
|
||||
const marked = { ...noMarker, surfaceOp: 'append' } as SurfaceEvent
|
||||
expect(isSurfaceEvent(marked)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user