227 lines
9.3 KiB
TypeScript
227 lines
9.3 KiB
TypeScript
/**
|
|
* Surface retention selection and the log-recorded compaction transaction.
|
|
*
|
|
* @module @deepseek-ai/dsh-compact-basic/region
|
|
*/
|
|
|
|
import { isDeepStrictEqual } from 'node:util'
|
|
import {
|
|
COMPACT_CHECKPOINT_SOURCE,
|
|
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 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'
|
|
import { frameSummary } from './summarizer.ts'
|
|
import type { SummarizationInput, SummaryResult } from './summarizer.ts'
|
|
|
|
interface RegionDependencies {
|
|
readonly meter: TokenMeterService
|
|
summarize(input: SummarizationInput, agent: Agent, signal?: AbortSignal): Promise<SummaryResult>
|
|
}
|
|
|
|
/**
|
|
* Resolve the next head-anchored range while retaining a priced recent tail
|
|
* and never splitting an assistant tool-call/result pair.
|
|
* @param session - session supplying authoritative current surface positions.
|
|
* @param measurement - unified pressure and surface measurement from the conversation meter.
|
|
* @param retainTokens - minimum recent tail budget retained verbatim.
|
|
* @returns the inclusive positional seq range to compact, or `null`.
|
|
*/
|
|
export function selectCompactableRange(
|
|
session: Session,
|
|
measurement: TokenMeasurement,
|
|
retainTokens: number,
|
|
): { start: number; end: number } | null {
|
|
const pricedNodes = measurement.nodes
|
|
if (pricedNodes.length === 0) return null
|
|
|
|
const surfaceNodes = session.surface.nodes
|
|
if (surfaceNodes.length !== pricedNodes.length
|
|
|| surfaceNodes.some((seq, index) => seq !== pricedNodes[index]?.seq)) {
|
|
throw new Error('compaction: token-meter surface does not match the current session surface')
|
|
}
|
|
|
|
let accumulated = 0
|
|
let keepFromIdx = pricedNodes.length
|
|
for (let index = pricedNodes.length - 1; index >= 0; index -= 1) {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
accumulated += pricedNodes[index]!.tokens
|
|
keepFromIdx = index
|
|
if (accumulated >= retainTokens) break
|
|
}
|
|
if (keepFromIdx === 0) return null
|
|
|
|
while (keepFromIdx > 0) {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
if (toolPairingBalancedBefore(session, surfaceNodes[keepFromIdx]!)) break
|
|
keepFromIdx -= 1
|
|
}
|
|
if (keepFromIdx === 0) return null
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const first = surfaceNodes[0]!
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const cutoff = surfaceNodes[keepFromIdx - 1]!
|
|
return { start: first, end: cutoff }
|
|
}
|
|
|
|
/**
|
|
* Validate and compact one positional surface span.
|
|
* @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 signal - optional summarization cancellation signal.
|
|
* @returns the successful durable compaction result.
|
|
*/
|
|
export async function compactSurfaceRegion(
|
|
dependencies: RegionDependencies,
|
|
session: Session,
|
|
start: number,
|
|
end: number,
|
|
agent: Agent,
|
|
signal?: AbortSignal,
|
|
): Promise<CompactionResult> {
|
|
const nodes = session.surface.nodes
|
|
const startIdx = nodes.indexOf(start)
|
|
const endIdx = nodes.indexOf(end)
|
|
if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`)
|
|
if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`)
|
|
if (startIdx > endIdx) {
|
|
throw new Error(
|
|
`compactRegion: start seq ${start} (position ${startIdx}) is after end seq ${end} (position ${endIdx}) on the surface`,
|
|
)
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
if (!toolPairingBalancedBefore(session, nodes[startIdx]!)) {
|
|
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
if (!toolPairingBalancedAfter(session, nodes[endIdx]!)) {
|
|
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')
|
|
}
|
|
|
|
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1)
|
|
const startEvent = session.append('compact/start', { turn: tail.turn })
|
|
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, provider, model, maxTokens } = 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,
|
|
shadowedRange: { start, end },
|
|
shadowedSeqs,
|
|
shadowedTokenCount,
|
|
provider,
|
|
model,
|
|
...maxTokens === undefined ? {} : { maxTokens },
|
|
})
|
|
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,
|
|
}
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
session.append('compact/end', { turn: tail.turn, error: message })
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reconstruct the last routed request's cacheable prefix for the shadowed
|
|
* region: its system prompt and tool schemas, then the region's own derived
|
|
* messages in surface order. The summarizer appends only the compaction
|
|
* instruction after this, so the call is a genuine prefix of the conversation
|
|
* and reuses the provider's KV cache.
|
|
* @param session - session supplying the request header and per-node projection.
|
|
* @param shadowedSeqs - the surface-node seqs, in order, being compacted.
|
|
* @returns the replayed conversation prefix to condense.
|
|
*/
|
|
function buildSummarizationInput(
|
|
session: Session,
|
|
shadowedSeqs: readonly number[],
|
|
): SummarizationInput {
|
|
const header = session.requestHeader()
|
|
const events = session.events
|
|
const regionMessages = shadowedSeqs
|
|
// shadowedSeqs are current surface seqs, so each is a valid log index.
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
.map(seq => session.deriveEventMessage(events[seq]!))
|
|
.filter((message): message is Message => message !== null)
|
|
return {
|
|
...header?.system === undefined ? {} : { system: header.system },
|
|
...header?.tools === undefined ? {} : { tools: header.tools },
|
|
messages: regionMessages,
|
|
}
|
|
}
|
|
|
|
/** Inspect the current turn boundary and latest compaction bracket once. */
|
|
function inspectTurnTail(
|
|
events: readonly SessionEvent[],
|
|
): { turn: number | null; compactionInProgress: boolean } {
|
|
let compactionInProgress = false
|
|
let compactionStateKnown = false
|
|
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const event = events[index]!
|
|
if (!compactionStateKnown) {
|
|
if (event.type === 'compact/start') {
|
|
compactionInProgress = true
|
|
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 }
|
|
}
|
|
return { turn: null, compactionInProgress }
|
|
}
|