161 lines
8.1 KiB
TypeScript
161 lines
8.1 KiB
TypeScript
/**
|
|
* The compaction service seam (`ctx.compact`): an abstract service defining
|
|
* WHAT compaction does — decide when to compact, summarize a range of
|
|
* conversation history into a single surface node — without saying HOW.
|
|
*
|
|
* Implementations subclass {@link CompactService}, implement
|
|
* {@link CompactService.compactIfNeeded} and {@link CompactService.compactRegion},
|
|
* and load as a plugin — registering as `ctx.compact` (one implementation per
|
|
* context). A tokenizer-, template-, or model-backed implementation can live
|
|
* as a sibling package; callers stay on the same `ctx.compact` seam without
|
|
* touching consumers.
|
|
*
|
|
* The split follows the capability-seams RFC — interface (this) /
|
|
* implementation (deferred) / consumer (a `/compact` tool, deferred) — modeled
|
|
* on the bash trio. Unlike `dsh-bash`, this interface necessarily
|
|
* 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/implemented/feature/2026-06-18-compaction-capability-seam.md).
|
|
*
|
|
* @module @deepseek-ai/dsh-compact
|
|
*/
|
|
|
|
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'
|
|
|
|
export type { CompactionResult } from './types.ts'
|
|
export { renderContentBlocks, renderTranscript } from './render.ts'
|
|
|
|
/** Minimal agent context compaction needs without depending on the agent package. */
|
|
export interface CompactAgentContext {
|
|
session: Session
|
|
options: { model?: string }
|
|
}
|
|
|
|
declare module 'cordis' {
|
|
interface Context {
|
|
compact: CompactService
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Abstract compaction service. Subclass implement the two abstract methods,
|
|
* and load the subclass as a plugin — it registers as `ctx.compact` (one
|
|
* implementation per context; loading a second throws, which is cordis'
|
|
* standard duplicate-service behavior).
|
|
*
|
|
* Both core methods are abstract: the contract states WHAT compaction does,
|
|
* while the entire strategy — token estimation, retention policy, event
|
|
* sequencing, summarization — is a HOW decision owned by the implementation.
|
|
*
|
|
* Implementations MUST honor:
|
|
* - **Surface contract**: a successful compaction shadows the compacted surface
|
|
* nodes with a SINGLE replacement node carrying the summary. Because
|
|
* `SurfaceEventType` is a closed union, that node is a `user/message` with
|
|
* `surfaceOp: { op:'replace', start, end }`; the `compact/*` events are
|
|
* log-only (lock + provenance).
|
|
* - **Blocking**: no compaction begins while another is in progress for the
|
|
* same session. The recommended mechanism is the log-recorded lock — append
|
|
* `compact/start` before the slow work and `compact/end` after (even on
|
|
* failure) — so the lock is visible to replay and crash recovery.
|
|
*/
|
|
export abstract class CompactService extends Service {
|
|
constructor(ctx: Context) {
|
|
super(ctx, 'compact')
|
|
}
|
|
|
|
/**
|
|
* Check token pressure and compact if the conversation is too large.
|
|
*
|
|
* Estimates the NEXT request's size — the session prefix, the
|
|
* surface-derived history, and 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:
|
|
* - **Compaction acts on surface-derived history only**, but the ESTIMATE
|
|
* counts everything the request carries: the loop composes the session
|
|
* prefix before the pre-step seam fires and hands it here, so the gate
|
|
* sees the prefix this instance will actually send (`EpochHeader.messagePrefix`
|
|
* — request-only, never derived history). 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 balanced tool-pairing 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 —
|
|
* as is a session prefix that alone approaches the window (a
|
|
* configuration error no compactor fixes: compaction cannot shrink the
|
|
* prefix).
|
|
*
|
|
* @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 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.
|
|
* @returns the compaction result, or `null` if no compaction was needed.
|
|
*/
|
|
abstract compactIfNeeded(
|
|
agent: CompactAgentContext,
|
|
fullSystemPrompt: string,
|
|
sessionPrefix: readonly Message[],
|
|
signal: AbortSignal,
|
|
): Promise<CompactionResult | null>
|
|
|
|
/**
|
|
* Forcibly compact a range of surface nodes into a single summary node.
|
|
*
|
|
* `start` and `end` are inclusive seqs of surface nodes to shadow; the backend
|
|
* summarizes their content and appends a replacement surface node. Used by the
|
|
* (future) `/compact` tool and internally by {@link compactIfNeeded}.
|
|
*
|
|
* The region MUST NOT split a step's `assistant/message` tool-calls from their
|
|
* `tool/result`s, leaving the rehydrated transcript with a dangling tool-call
|
|
* or an orphaned tool-result that every provider rejects. A region is safe iff
|
|
* both its edges are balanced cuts on the surface: the cut before `start` and
|
|
* the cut after `end` each have no unanswered tool-call before them. A node
|
|
* that belongs to no step (a pre-step user message, inter-step steering, or an
|
|
* injection context message) is a balanced (free) boundary; an `end` inside an
|
|
* open (unclosed) tail step is invalid — its tool-calls have no results yet.
|
|
* `dsh-session` exports `isToolPairingBalanced` for this check.
|
|
*
|
|
* @param session - the session whose surface is mutated.
|
|
* @param start - inclusive seq of the first surface node to compact.
|
|
* @param end - inclusive seq of the last surface node to compact.
|
|
* @param agent - agent context used by router-aware summarizers.
|
|
* @param signal - optional cancellation signal. A backend that summarizes 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.
|
|
* @throws if compaction is already in progress, if `start`/`end` are not
|
|
* valid surface nodes, if `start` is positioned after `end` on the surface
|
|
* (the range is a surface-POSITION span, not a numeric seq interval — a
|
|
* prior replace can leave the surface non-monotonic in seq order), or if
|
|
* either boundary is not a balanced tool-pairing cut (would split a step's
|
|
* tool-call/result pair).
|
|
* @returns what the compaction did (the replaced range and its summary node).
|
|
*/
|
|
abstract compactRegion(
|
|
session: Session,
|
|
start: number,
|
|
end: number,
|
|
agent: CompactAgentContext,
|
|
signal?: AbortSignal,
|
|
): Promise<CompactionResult>
|
|
}
|
|
|
|
export default CompactService
|