round 1: implement bracket-first manual compaction

This commit is contained in:
Hypatia May
2026-07-30 17:40:25 +08:00
parent 86b95a3856
commit faac9b4fd5
101 changed files with 3452 additions and 295 deletions
+74 -9
View File
@@ -6,11 +6,12 @@
import { Context } from 'cordis'
import z from 'schemastery'
import { CompactService } from '@deepseek-ai/dsh-compact'
import { CompactService, ManualCompactionError } from '@deepseek-ai/dsh-compact'
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import type { Session } from '@deepseek-ai/dsh-session'
import { CONTEXT_WINDOW_EXCEEDED_CODE, assertNever } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, LlmCallConfig } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
// Type-only: makes the optional sibling service available to `ctx.get()`.
import type {} from '@deepseek-ai/dsh-compact-tool-result-prune'
@@ -20,9 +21,13 @@ import {
resolveTargetPolicy,
TargetPressureConfigError,
} from './config.ts'
import { compactSurfaceRegion, selectCompactableRange } from './region.ts'
import {
assertNoActiveCompaction,
compactSurfaceRegion,
selectCompactableRange,
} from './region.ts'
import { summarizeWithLlm } from './summarizer.ts'
import type { SummarizationInput } from './summarizer.ts'
import type { SummarizationInput, SummaryResult } from './summarizer.ts'
import type {
BasicCompactConfig,
ModelCompactPolicyConfig,
@@ -39,6 +44,9 @@ export type {
ResolvedTargetPolicy,
} from './types.ts'
/** The region transaction's view of this service's dynamically dispatched summarizer. */
type RegionSummarize = (input: SummarizationInput, agent: Agent, signal?: AbortSignal) => Promise<SummaryResult>
/** Resolve the exact provider/model durably routed for the latest request. */
function routedTarget(
session: Session,
@@ -92,7 +100,7 @@ const modelPolicy: z<ModelCompactPolicyConfig> = z.object({
* token meter.
*/
export class BasicCompactService extends CompactService {
static inject = ['llm', 'tokenMeter']
static inject = ['llm', 'tokenMeter', 'sessions']
static Config: z<BasicCompactConfig> = z.object({
thresholdRatio: thresholdRatioSchema,
@@ -235,7 +243,7 @@ export class BasicCompactService extends CompactService {
input: SummarizationInput,
agent: Agent,
signal?: AbortSignal,
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
): Promise<SummaryResult> {
const target = conversationTarget(agent)
const config = target === undefined
? this.config
@@ -289,6 +297,7 @@ export class BasicCompactService extends CompactService {
}
const context = (await this.ctx.llm.resolveModelInfo(target.provider, target.model, signal)).context
assertNoActiveCompaction(agent.session, 'automatic pressure compaction')
const targetKey = `${target.provider}/${target.model}`
if (context === undefined) {
throw new TargetPressureConfigError(
@@ -343,11 +352,67 @@ export class BasicCompactService extends CompactService {
agent: Agent,
signal?: AbortSignal,
): Promise<CompactionResult> {
const session = agent.session
return compactSurfaceRegion({
return compactSurfaceRegion(
this.regionDependencies(),
agent.session,
start,
end,
agent,
{ owner: 'current-turn', stability: 'whole-surface' },
signal,
)
}
/**
* Force one useful idle-session compaction below the pressure threshold, and
* resolve only after its standalone marker pair is durably checkpointed.
* @param agent - idle agent whose next-turn admission this call reserves.
* @param signal - command-owned cancellation forwarded to summarization.
* @returns the committed result, or `null` when no safe useful range exists.
*/
override async compactNow(
agent: Agent,
signal: AbortSignal,
): Promise<CompactionResult | null> {
signal.throwIfAborted()
const releaseTurnAdmission = agent.reserveTurnAdmission()
if (releaseTurnAdmission === undefined) {
throw new ManualCompactionError(
'busy',
'manual compaction requires an idle agent with no waking queued work',
)
}
try {
const range = selectCompactableRange(
agent.session,
this.ctx.tokenMeter.measure(agent.session),
0,
)
if (range === null) return null
return await compactSurfaceRegion(
this.regionDependencies(),
agent.session,
range.start,
range.end,
agent,
{
owner: null,
stability: 'selected-span',
flush: () => this.ctx.sessions.flush(agent.session),
},
signal,
)
} finally {
releaseTurnAdmission()
}
}
/** Bind the effective token meter and dynamically dispatched summarizer hook. */
private regionDependencies(): { meter: TokenMeterService; summarize: RegionSummarize } {
return {
meter: this.ctx.tokenMeter,
summarize: (input, owner, abort) => this.summarize(input, owner, abort),
}, session, start, end, agent, signal)
}
}
}
+357 -78
View File
@@ -1,5 +1,6 @@
/**
* Surface retention selection and the log-recorded compaction transaction.
* Surface retention selection and the shared log-recorded compaction
* transaction for automatic open-turn and manual idle-session compaction.
*
* @module @deepseek-ai/dsh-compact-basic/region
*/
@@ -7,12 +8,13 @@
import { isDeepStrictEqual } from 'node:util'
import {
COMPACT_CHECKPOINT_SOURCE,
ManualCompactionError,
toolPairingBalancedAfter,
toolPairingBalancedBefore,
} from '@deepseek-ai/dsh-compact'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Message } from '@deepseek-ai/dsh-llm'
import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm'
import type { Message, UserMessage } from '@deepseek-ai/dsh-llm'
import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -24,6 +26,62 @@ interface RegionDependencies {
summarize(input: SummarizationInput, agent: Agent, signal?: AbortSignal): Promise<SummaryResult>
}
/** One validated inclusive span of current surface positions. */
interface SurfaceSelection {
readonly start: number
readonly end: number
readonly startIdx: number
readonly endIdx: number
readonly shadowedSeqs: readonly number[]
}
/** A selection with its priced snapshot and the replay input built from it. */
interface PreparedCompaction extends SurfaceSelection {
readonly measurement: TokenMeasurement
readonly selectedNodes: TokenMeasurement['nodes']
readonly shadowedTokenCount: number
readonly input: SummarizationInput
}
interface SummarizedCompaction extends PreparedCompaction, SummaryResult {
readonly checkpointMessage: UserMessage
}
interface CompactionTransactionOptions {
/** `current-turn` derives a numbered owner; `null` writes a standalone bracket. */
readonly owner: 'current-turn' | null
/** Surface relationship that must survive asynchronous summarization. */
readonly stability: 'whole-surface' | 'selected-span'
/** Optional durability checkpoint after a successfully closed bracket. */
readonly flush?: () => Promise<void>
}
interface TurnTail {
readonly turn: number | null
readonly compactionStart: SessionEvent<'compact/start'> | undefined
readonly endSeedSeq: number | undefined
}
/**
* Rejects a summary whose replacement boundaries are no longer the ones it was
* built from, distinguished from summarizer and shrink failures so a manual
* caller can report the two causes differently.
*/
class SurfaceChangedError extends Error {}
/** Whether the summary may still replace the span it was built from. */
type StabilityCheck = (
dependencies: RegionDependencies,
session: Session,
prepared: PreparedCompaction,
) => void
/** Failure captured after `compact/start` has committed. */
interface TransactionFailure {
readonly error: unknown
readonly stage: 'summary' | 'commit'
}
/**
* Resolve the next head-anchored range while retaining a priced recent tail
* and never splitting an assistant tool-call/result pair.
@@ -71,12 +129,18 @@ export function selectCompactableRange(
}
/**
* Validate and compact one positional surface span.
* Run the single compaction transaction over one selected positional span.
* Selection and validation are read-only. Idle/log validation and
* `compact/start` are synchronously adjacent, so the durable opening marker is
* the compaction lock before summarization yields. Every later failure makes
* exactly one `compact/end` attempt; a failed close deliberately leaves the
* unmatched start detectable.
* @param dependencies - conversation meter and dynamically dispatched summarizer hook.
* @param session - session whose surface is mutated.
* @param start - inclusive first surface-node seq.
* @param end - inclusive last surface-node seq.
* @param agent - agent used by the summarizer.
* @param options - bracket owner, stability rule, and optional durability checkpoint.
* @param signal - optional summarization cancellation signal.
* @returns the successful durable compaction result.
*/
@@ -86,8 +150,142 @@ export async function compactSurfaceRegion(
start: number,
end: number,
agent: Agent,
options: CompactionTransactionOptions,
signal?: AbortSignal,
): Promise<CompactionResult> {
if (options.owner === null) signal?.throwIfAborted()
const selection = validateSurfaceRegion(session, start, end)
const tail = inspectTurnTail(session.events)
assertCompactionInactive(tail.compactionStart, tail.endSeedSeq, 'compaction')
let owner: number | null
if (options.owner === null) {
if (tail.turn !== null) {
throw new ManualCompactionError('busy', 'manual compaction: the session already has an open turn')
}
owner = null
} else {
if (tail.turn === null) {
throw new Error('compactRegion: no open turn — automatic compaction events must be enclosed in a turn')
}
owner = tail.turn
}
const startEvent = session.append('compact/start', { turn: owner })
const assertStable: StabilityCheck = options.stability === 'whole-surface'
? assertWholeSurfaceUnchanged
: assertSelectedSpanStable
let failure: TransactionFailure | undefined
let flushFailure: unknown
let result: CompactionResult | undefined
let closed = false
let closing = false
let stage: TransactionFailure['stage'] = 'summary'
try {
const prepared = prepareCompaction(dependencies, session, selection)
const summarized = await summarizeCompaction(dependencies, prepared, agent, signal)
if (options.owner === null) signal?.throwIfAborted()
assertStable(dependencies, session, summarized)
stage = 'commit'
const pending = commitCompactionBody(session, startEvent, summarized)
closing = true
const endEvent = session.append('compact/end', { turn: owner })
closed = true
result = completeCompaction(pending, endEvent)
} catch (error: unknown) {
failure = { error, stage: closing ? 'commit' : stage }
if (!closing) {
closing = true
try {
session.append('compact/end', { turn: owner, error: errorChain(error) })
closed = true
} catch (closeError: unknown) {
failure = { error: closeError, stage: 'commit' }
}
}
}
if (closed && options.flush !== undefined) {
try {
await options.flush()
} catch (error: unknown) {
flushFailure = error
}
}
if (options.owner === null) signal?.throwIfAborted()
if (failure !== undefined) {
if (options.owner === null) throwManualFailure(failure)
throw failure.error
}
if (flushFailure !== undefined) {
throw new ManualCompactionError(
'persistence',
'manual compaction durability checkpoint failed',
{ cause: flushFailure },
)
}
/* v8 ignore next -- every path without a result records and throws a failure above. */
if (result === undefined) throw new Error('compaction committed without a result')
return result
}
/** Classify one closed manual attempt without weakening cancellation precedence. */
function throwManualFailure(failure: TransactionFailure): never {
if (failure.stage === 'commit') {
throw new ManualCompactionError(
'commit',
'manual compaction did not commit cleanly',
{ cause: failure.error },
)
}
if (failure.error instanceof SurfaceChangedError) {
throw new ManualCompactionError(
'changed',
'the compacted history changed during manual compaction',
{ cause: failure.error },
)
}
throw new ManualCompactionError(
'summary',
'manual compaction could not produce a smaller summary',
{ cause: failure.error },
)
}
/**
* Reject a durable unmatched compaction marker unless a later constructor-seed
* boundary proves that its owner belongs to an earlier session lifecycle.
* @param compactionStart - latest unmatched opening marker, if any.
* @param endSeedSeq - newest constructor-seed boundary, if any.
* @param stage - operation label included in the busy diagnostic.
*/
function assertCompactionInactive(
compactionStart: SessionEvent<'compact/start'> | undefined,
endSeedSeq: number | undefined,
stage: string,
): void {
if (compactionStart === undefined
|| (endSeedSeq !== undefined && endSeedSeq > compactionStart.seq)) return
throw new ManualCompactionError(
'busy',
`${stage}: compaction already in progress; the session compaction lock is already active`,
)
}
/**
* Recheck the durable compaction lock after an asynchronous policy decision.
* @param session - session whose latest marker state is inspected.
* @param stage - operation label included in the busy diagnostic.
*/
export function assertNoActiveCompaction(session: Session, stage: string): void {
const tail = inspectTurnTail(session.events)
assertCompactionInactive(tail.compactionStart, tail.endSeedSeq, stage)
}
/** Validate one requested surface-position span before asynchronous work begins. */
function validateSurfaceRegion(session: Session, start: number, end: number): SurfaceSelection {
const nodes = session.surface.nodes
const startIdx = nodes.indexOf(start)
const endIdx = nodes.indexOf(end)
@@ -107,75 +305,145 @@ export async function compactSurfaceRegion(
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
}
const tail = inspectTurnTail(session.events)
if (tail.compactionInProgress) throw new Error('compaction already in progress')
if (tail.turn === null) {
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
}
return { start, end, startIdx, endIdx, shadowedSeqs: nodes.slice(startIdx, endIdx + 1) }
}
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1)
const startEvent = session.append('compact/start', { turn: tail.turn })
/** Snapshot pricing and replay input for a validated surface range. */
function prepareCompaction(
dependencies: RegionDependencies,
session: Session,
selection: SurfaceSelection,
): PreparedCompaction {
const measurement = dependencies.meter.measure(session)
const selectedNodes = measurement.nodes.slice(selection.startIdx, selection.endIdx + 1)
if (selectedNodes.length !== selection.shadowedSeqs.length
|| selectedNodes.some((node, index) => node.seq !== selection.shadowedSeqs[index])) {
throw new SurfaceChangedError('compaction: selected surface changed before summarization began')
}
return {
...selection,
measurement,
selectedNodes,
shadowedTokenCount: selectedNodes.reduce((total, node) => total + node.tokens, 0),
input: buildSummarizationInput(session, selection.shadowedSeqs),
}
}
/** Run the summarizer and frame its replacement checkpoint. */
async function summarizeCompaction(
dependencies: RegionDependencies,
prepared: PreparedCompaction,
agent: Agent,
signal?: AbortSignal,
): Promise<SummarizedCompaction> {
const summaryResult = await dependencies.summarize(prepared.input, agent, signal)
const checkpointMessage = createUserMessage({
content: frameSummary(summaryResult.summary),
source: COMPACT_CHECKPOINT_SOURCE,
})
const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage)
if (framedSummaryTokenCount >= prepared.shadowedTokenCount) {
throw new Error(
`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${prepared.shadowedTokenCount})`,
)
}
return {
...prepared,
...summaryResult,
checkpointMessage,
}
}
/** Reject a summary prepared against any earlier surface generation. */
function assertWholeSurfaceUnchanged(
dependencies: RegionDependencies,
session: Session,
prepared: PreparedCompaction,
): void {
const current = dependencies.meter.measure(session)
if (!isDeepStrictEqual(current.nodes, prepared.measurement.nodes)) {
throw new SurfaceChangedError('compaction: session surface changed during summarization')
}
}
/**
* Require only that the selected span remain the same present, contiguous,
* equally priced, balanced replacement target. Nodes added outside it remain
* visible and do not invalidate the summary.
*/
function assertSelectedSpanStable(
dependencies: RegionDependencies,
session: Session,
prepared: PreparedCompaction,
): void {
let current: SurfaceSelection
try {
// Capture after the lock event so a later surface mutation invalidates the
// async selection before replacement. Unrelated log-only facts may append.
const lockedMeasurement = dependencies.meter.measure(session)
const selected = lockedMeasurement.nodes.slice(startIdx, endIdx + 1)
if (selected.length !== shadowedSeqs.length
|| selected.some((node, index) => node.seq !== shadowedSeqs[index])) {
throw new Error('compaction: selected surface changed before summarization began')
}
const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0)
const summarizationInput = buildSummarizationInput(session, shadowedSeqs)
const {
summary, rawOutput, provider, model, maxTokens, usage,
} = await dependencies.summarize(summarizationInput, agent, signal)
const currentMeasurement = dependencies.meter.measure(session)
if (!isDeepStrictEqual(currentMeasurement.nodes, lockedMeasurement.nodes)) {
throw new Error('compaction: session surface changed during summarization')
}
const framedSummary = frameSummary(summary)
const checkpointMessage = createUserMessage({
content: framedSummary,
source: COMPACT_CHECKPOINT_SOURCE,
})
const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage)
if (framedSummaryTokenCount >= shadowedTokenCount) {
throw new Error(
`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`,
)
}
const summaryEvent = session.append('compact/summary', {
summary,
...rawOutput === undefined ? {} : { rawOutput },
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount,
provider,
model,
...maxTokens === undefined ? {} : { maxTokens },
...usage === undefined ? {} : { usage },
})
session.append('user/message', checkpointMessage, {
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
})
const endEvent = session.append('compact/end', { turn: tail.turn })
return {
startSeq: startEvent.seq,
summarySeq: summaryEvent.seq,
endSeq: endEvent.seq,
summary,
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount,
}
current = validateSurfaceRegion(session, prepared.start, prepared.end)
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
session.append('compact/end', { turn: tail.turn, error: message })
throw error
throw new SurfaceChangedError(
'compaction: the selected span is no longer a valid replacement target',
{ cause: error },
)
}
if (!isDeepStrictEqual([...current.shadowedSeqs], [...prepared.shadowedSeqs])) {
throw new SurfaceChangedError('compaction: the selected span changed during summarization')
}
const measured = dependencies.meter.measure(session).nodes.slice(current.startIdx, current.endIdx + 1)
if (!isDeepStrictEqual(measured, prepared.selectedNodes)) {
throw new SurfaceChangedError('compaction: the selected span was rewritten during summarization')
}
}
/** Append one already-summarized provenance and replacement body without yielding. */
function commitCompactionBody(
session: Session,
startEvent: SessionEvent<'compact/start'>,
summarized: SummarizedCompaction,
): Omit<CompactionResult, 'endSeq'> {
const {
start,
end,
shadowedSeqs,
shadowedTokenCount,
summary,
rawOutput,
provider,
model,
maxTokens,
usage,
checkpointMessage,
} = summarized
const summaryEvent = session.append('compact/summary', {
summary,
...rawOutput === undefined ? {} : { rawOutput },
shadowedRange: { start, end },
shadowedSeqs: [...shadowedSeqs],
shadowedTokenCount,
provider,
model,
...maxTokens === undefined ? {} : { maxTokens },
...usage === undefined ? {} : { usage },
})
session.append('user/message', checkpointMessage, {
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
})
return {
startSeq: startEvent.seq,
summarySeq: summaryEvent.seq,
summary,
shadowedRange: { start, end },
shadowedSeqs: [...shadowedSeqs],
shadowedTokenCount,
}
}
/** Attach the successfully appended close event to a pending result. */
function completeCompaction(
pending: Omit<CompactionResult, 'endSeq'>,
endEvent: SessionEvent<'compact/end'>,
): CompactionResult {
return { ...pending, endSeq: endEvent.seq }
}
/**
@@ -206,25 +474,36 @@ function buildSummarizationInput(
}
}
/** Inspect the current turn boundary and latest compaction bracket once. */
function inspectTurnTail(
events: readonly SessionEvent[],
): { turn: number | null; compactionInProgress: boolean } {
let compactionInProgress = false
/** Inspect turn state, unmatched compaction, and newest seed boundary independently. */
function inspectTurnTail(events: readonly SessionEvent[]): TurnTail {
let turn: number | null = null
let turnStateKnown = false
let compactionStart: SessionEvent<'compact/start'> | undefined
let compactionStateKnown = false
let endSeedSeq: number | undefined
for (let index = events.length - 1; index >= 0; index -= 1) {
// oxlint-disable-next-line typescript/no-non-null-assertion
const event = events[index]!
if (endSeedSeq === undefined && event.type === 'session/end-seed') {
endSeedSeq = event.seq
}
if (!compactionStateKnown) {
if (event.type === 'compact/start') {
compactionInProgress = true
compactionStart = event
compactionStateKnown = true
} else if (event.type === 'compact/end') {
compactionStateKnown = true
}
}
if (event.type === 'turn/start') return { turn: event.data.turn, compactionInProgress }
if (event.type === 'turn/end') return { turn: null, compactionInProgress }
if (!turnStateKnown) {
if (event.type === 'turn/start') {
turn = event.data.turn
turnStateKnown = true
} else if (event.type === 'turn/end') {
turnStateKnown = true
}
}
if (turnStateKnown && compactionStateKnown && endSeedSeq !== undefined) break
}
return { turn: null, compactionInProgress }
return { turn, compactionStart, endSeedSeq }
}