mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge branch 'codex/simp-trim-hook-snapshot-noise' into codex/simp-compaction-surface
# Conflicts: # docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md # packages/compact/compact-basic/README.md # packages/compact/compact-basic/src/index.ts # packages/compact/compact-basic/tests/compact-basic.spec.ts # packages/compact/compact/README.md # packages/compact/compact/src/index.ts
This commit is contained in:
52
packages/compact/compact-basic/src/automatic.ts
Normal file
52
packages/compact/compact-basic/src/automatic.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Automatic pre-step pressure listener for compact-basic.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact-basic/automatic
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
interface AutomaticCompactor {
|
||||
compactIfNeeded(
|
||||
agent: Agent,
|
||||
fullSystemPrompt: string,
|
||||
sessionPrefix: readonly Message[],
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null>
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the implementation-owned automatic compaction listener.
|
||||
* @param ctx - context owning the listener effect and logger.
|
||||
* @param service - compactor whose public methods remain dynamically dispatched.
|
||||
*/
|
||||
export function registerAutomaticCompaction(
|
||||
ctx: Context,
|
||||
service: AutomaticCompactor,
|
||||
): void {
|
||||
ctx.on('agent/pre-step', async (
|
||||
agent: Agent,
|
||||
_turn: number,
|
||||
_step: number,
|
||||
fullSystemPrompt: string,
|
||||
sessionPrefix: readonly Message[],
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
try {
|
||||
const result = await service.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)
|
||||
if (result !== null) {
|
||||
ctx.logger.info(
|
||||
`compaction: shadowed ${result.shadowedSeqs.length} surface nodes `
|
||||
+ `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, `
|
||||
+ `~${result.shadowedTokenCount} tokens)`,
|
||||
)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
ctx.logger.warn(`compaction failed: ${message}; proceeding with full history`)
|
||||
}
|
||||
})
|
||||
}
|
||||
107
packages/compact/compact-basic/src/config.ts
Normal file
107
packages/compact/compact-basic/src/config.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Runtime defaulting and policy validation for compact-basic.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact-basic/config
|
||||
*/
|
||||
|
||||
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
|
||||
import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
|
||||
|
||||
/** Default request-pressure fraction of the token meter's context window. */
|
||||
const DEFAULT_THRESHOLD_RATIO = 0.8
|
||||
|
||||
/** Default verbatim-tail fraction of the token meter's context window. */
|
||||
const DEFAULT_RETAIN_RATIO = 0.16
|
||||
|
||||
/** Complete public configuration key set. */
|
||||
const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet<string> = new Set([
|
||||
'thresholdRatio',
|
||||
'retainTokens',
|
||||
'summarizationProvider',
|
||||
'summarizationModel',
|
||||
'maxTokens',
|
||||
'compactionRetries',
|
||||
'auto',
|
||||
])
|
||||
|
||||
/** Reject stale or misspelled keys before defaults can hide them. */
|
||||
function validateConfigKeys(config: BasicCompactConfig): void {
|
||||
for (const key of Object.keys(config)) {
|
||||
if (!BASIC_COMPACT_CONFIG_KEYS.has(key)) {
|
||||
throw new Error(
|
||||
`BasicCompactConfig: unknown key "${key}" `
|
||||
+ '(allowed: thresholdRatio, retainTokens, summarizationProvider, summarizationModel, maxTokens, compactionRetries, auto)',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve defaults and validate the service-wide compaction policy.
|
||||
* @param config - raw compact-basic configuration.
|
||||
* @param tokenMeter - token meter supplying the context capacity.
|
||||
* @returns a detached deeply immutable configuration.
|
||||
*/
|
||||
export function resolveConfig(
|
||||
config: BasicCompactConfig = {},
|
||||
tokenMeter: TokenMeterService,
|
||||
): ResolvedConfig {
|
||||
validateConfigKeys(config)
|
||||
const thresholdRatio = config.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO
|
||||
const retainTokens = config.retainTokens
|
||||
?? Math.floor(tokenMeter.contextWindow * DEFAULT_RETAIN_RATIO)
|
||||
const resolved: ResolvedConfig = {
|
||||
thresholdRatio,
|
||||
retainTokens,
|
||||
summarizationProvider: config.summarizationProvider ?? '',
|
||||
summarizationModel: config.summarizationModel ?? '',
|
||||
maxTokens: config.maxTokens ?? 8192,
|
||||
compactionRetries: config.compactionRetries ?? 1,
|
||||
auto: config.auto ?? true,
|
||||
}
|
||||
|
||||
assertRatio('thresholdRatio', resolved.thresholdRatio)
|
||||
assertNonNegativeInteger('retainTokens', resolved.retainTokens)
|
||||
const thresholdTokens = Math.floor(tokenMeter.contextWindow * resolved.thresholdRatio)
|
||||
if (resolved.retainTokens >= thresholdTokens) {
|
||||
throw new Error(
|
||||
`BasicCompactConfig: retainTokens (${resolved.retainTokens}) must be less than threshold tokens ${thresholdTokens}`,
|
||||
)
|
||||
}
|
||||
assertPositiveInteger('maxTokens', resolved.maxTokens)
|
||||
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
|
||||
if (typeof resolved.summarizationProvider !== 'string') {
|
||||
throw new Error('BasicCompactConfig: summarizationProvider must be a string')
|
||||
}
|
||||
if (typeof resolved.summarizationModel !== 'string') {
|
||||
throw new Error('BasicCompactConfig: summarizationModel must be a string')
|
||||
}
|
||||
if ((resolved.summarizationProvider.length === 0) !== (resolved.summarizationModel.length === 0)) {
|
||||
throw new Error(
|
||||
'BasicCompactConfig: summarizationProvider and summarizationModel must both be set or both be empty',
|
||||
)
|
||||
}
|
||||
if (typeof resolved.auto !== 'boolean') {
|
||||
throw new Error('BasicCompactConfig: auto must be a boolean')
|
||||
}
|
||||
return deepFreeze(resolved)
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonNegativeInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a non-negative integer`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRatio(name: string, value: number): void {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1]`)
|
||||
}
|
||||
}
|
||||
@@ -1,287 +1,117 @@
|
||||
/**
|
||||
* Basic compaction backend. It estimates request pressure, retains a recent
|
||||
* tool-balanced surface tail, summarizes the older head through a one-shot model
|
||||
* call, and replaces that head with one checkpoint. Auto-compaction runs before
|
||||
* every step so a growing turn can compact its earlier closed steps.
|
||||
* Basic replay-aware compaction backend.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact-basic
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { CompactService, renderTranscript } from '@deepseek-ai/dsh-compact'
|
||||
import z from 'schemastery'
|
||||
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 } from '@deepseek-ai/dsh-session'
|
||||
import { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
|
||||
import { canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { EpochHeader, Session } from '@deepseek-ai/dsh-session'
|
||||
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
|
||||
import { resolveConfig } from './types.ts'
|
||||
import { registerAutomaticCompaction } from './automatic.ts'
|
||||
import { resolveConfig } from './config.ts'
|
||||
import { compactSurfaceRegion, selectCompactableRange } from './region.ts'
|
||||
import { summarizeWithLlm } from './summarizer.ts'
|
||||
import type {
|
||||
BasicCompactConfig,
|
||||
ResolvedConfig,
|
||||
} from './types.ts'
|
||||
|
||||
export type { BasicCompactConfig, ResolvedConfig } from './types.ts'
|
||||
export type {
|
||||
BasicCompactConfig,
|
||||
ResolvedConfig,
|
||||
} from './types.ts'
|
||||
|
||||
/** Per-block structural overhead for JSON framing / type tag. */
|
||||
const BLOCK_OVERHEAD = 4
|
||||
|
||||
/** Role-field framing overhead added per message in the request estimator. */
|
||||
const ROLE_OVERHEAD = 4
|
||||
|
||||
/** Tags wrapping the structured summary inside the landed checkpoint node. */
|
||||
const SUMMARY_OPEN_TAG = '<compacted-summary>'
|
||||
const SUMMARY_CLOSE_TAG = '</compacted-summary>'
|
||||
|
||||
/**
|
||||
* Fixed summary structure for resumable checkpoints. A tagged prior checkpoint
|
||||
* is merged with newer history instead of copied forward verbatim.
|
||||
*/
|
||||
const SUMMARIZE_SYSTEM_PROMPT = [
|
||||
'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.',
|
||||
'',
|
||||
'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.',
|
||||
'',
|
||||
'## Primary Request and Intent',
|
||||
"- [the user's original and evolving goals; quote verbatim where the exact wording matters]",
|
||||
'',
|
||||
'## Key Technical Concepts',
|
||||
'- [technologies, frameworks, patterns, and conventions in play]',
|
||||
'',
|
||||
'## Files and Code',
|
||||
'- [exact path: why it matters, key changes or snippets]',
|
||||
'',
|
||||
'## Errors and Fixes',
|
||||
'- [error: how it was resolved, plus any related user feedback]',
|
||||
'',
|
||||
'## Pending Tasks',
|
||||
'- [explicitly requested work not yet completed]',
|
||||
'',
|
||||
'## Current Work',
|
||||
'- [precisely what was in progress at this checkpoint]',
|
||||
'',
|
||||
'## Next Step',
|
||||
'- [the single next action, directly in line with the most recent request, or "(none)"]',
|
||||
'',
|
||||
'## Critical Context',
|
||||
'- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
|
||||
'',
|
||||
'Rules:',
|
||||
'- Preserve exact file paths, commands, error strings, identifiers, and function signatures.',
|
||||
'- Capture user feedback and explicit instructions faithfully, especially corrections.',
|
||||
'- Do NOT mention this summarization process or that the context was compacted.',
|
||||
`- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`,
|
||||
].join('\n')
|
||||
|
||||
/** Framing that makes a landed summary established context rather than a new request. */
|
||||
const CHECKPOINT_PREAMBLE =
|
||||
'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
|
||||
|
||||
/**
|
||||
* Map a terminal summary failure to an error. A max-token finish is rejected
|
||||
* because committing an incomplete checkpoint would shadow the full history.
|
||||
*/
|
||||
function finishError(finish: FinishReason): Error | undefined {
|
||||
switch (finish.kind) {
|
||||
case 'error': {
|
||||
const error = new Error(finish.message) as Error & { code?: string }
|
||||
if (finish.code !== undefined) error.code = finish.code
|
||||
return error
|
||||
}
|
||||
case 'aborted': {
|
||||
const error = new Error('summarization stream aborted') as Error & { code?: string }
|
||||
error.code = 'ABORTED'
|
||||
return error
|
||||
}
|
||||
case 'max-tokens': {
|
||||
const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string }
|
||||
error.code = 'MAX_TOKENS'
|
||||
return error
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
/** Resolve the latest actual routed provider/model, then the complete agent fallback pair. */
|
||||
function effectiveTarget(agent: Agent): { provider: string; model: string } | undefined {
|
||||
const latest = agent.session.requestHeader()?.config
|
||||
if (latest !== undefined) return { provider: latest.provider, model: latest.model }
|
||||
const { provider, model } = agent.options
|
||||
if (provider === undefined || provider.length === 0 || model === undefined || model.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
return { provider, model }
|
||||
}
|
||||
|
||||
/**
|
||||
* Basic, dependency-light compaction backend: estimates the surface's token
|
||||
* footprint, summarizes the stale prefix through the model, and shadows it
|
||||
* behind a durable checkpoint. Every threshold/budget knob is required config
|
||||
* ({@link BasicCompactConfig}); the estimator's text density is the
|
||||
* `charsPerToken` knob.
|
||||
* Build the provisional pre-step request envelope. Prompt and prefix are exact;
|
||||
* tools and non-model call config come from the latest logged request because
|
||||
* later request middleware has not run yet.
|
||||
*/
|
||||
function provisionalHeader(
|
||||
target: { provider: string; model: string },
|
||||
session: Session,
|
||||
fullSystemPrompt: string,
|
||||
sessionPrefix: readonly Message[],
|
||||
): EpochHeader {
|
||||
const latest = session.requestHeader()
|
||||
return canonicalHeader({
|
||||
config: latest === undefined ? target : { ...latest.config, ...target },
|
||||
...fullSystemPrompt.length === 0 ? {} : { system: fullSystemPrompt },
|
||||
...latest?.tools === undefined ? {} : { tools: latest.tools },
|
||||
...sessionPrefix.length === 0 ? {} : { messagePrefix: [...sessionPrefix] },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependency-light compaction backend using `ctx.tokenMeter` for pressure,
|
||||
* retention, provenance, and summary-convergence pricing.
|
||||
*
|
||||
* `summarize()` is the sole subclass customization hook; the replay and durable
|
||||
* mutation strategy stays fixed so every pricing decision uses the singleton
|
||||
* token meter.
|
||||
*/
|
||||
export class BasicCompactService extends CompactService {
|
||||
static inject = ['llm']
|
||||
static inject = ['llm', 'tokenMeter']
|
||||
|
||||
/** Resolved configuration (`auto` defaulted). */
|
||||
static Config: z<BasicCompactConfig> = z.object({
|
||||
thresholdRatio: z.number().default(0.8),
|
||||
retainTokens: z.number().step(1),
|
||||
summarizationProvider: z.string().default(''),
|
||||
summarizationModel: z.string().default(''),
|
||||
maxTokens: z.number().step(1).min(1).default(8192),
|
||||
compactionRetries: z.number().step(1).min(0).default(1),
|
||||
auto: z.boolean().default(true),
|
||||
})
|
||||
|
||||
/** Resolved and validated compaction configuration. */
|
||||
readonly config: ResolvedConfig
|
||||
|
||||
constructor(ctx: Context, config: BasicCompactConfig) {
|
||||
constructor(ctx: Context, config: BasicCompactConfig = {}) {
|
||||
super(ctx)
|
||||
this.config = resolveConfig(config)
|
||||
|
||||
if (this.config.auto) {
|
||||
// Check before every step so a single growing turn can compact earlier closed steps.
|
||||
// This serial pre-step seam mutates the surface outside the pending step.
|
||||
ctx.on('agent/pre-step', async (agent: Agent, _turn: number, _step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal) => {
|
||||
try {
|
||||
const result = await this.compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)
|
||||
if (result) {
|
||||
const after = this.estimatePressure(agent.session, fullSystemPrompt, sessionPrefix)
|
||||
ctx.logger.info(
|
||||
`compaction: shadowed ${result.shadowedSeqs.length} surface nodes ` +
|
||||
`(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, ` +
|
||||
`~${result.shadowedTokenCount} tokens) ` +
|
||||
`→ ${after} estimated tokens after compaction`,
|
||||
)
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// 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`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Token estimation (overridable hooks) ----
|
||||
|
||||
// TODO: chars/charsPerToken is a coarse heuristic. Replace with an exact
|
||||
// count — a real tokenizer, or the provider's post-response `usage` (input
|
||||
// tokens) fed back as a correction — so threshold decisions match the
|
||||
// model's actual budget.
|
||||
/**
|
||||
* Estimate the token count of content blocks — chars divided by the
|
||||
* `charsPerToken` config, with per-block overhead. Override in a subclass to
|
||||
* plug in a real tokenizer.
|
||||
*
|
||||
* @param blocks - the blocks to estimate; `tool-result` blocks recurse into
|
||||
* their nested content, and unknown (merge-extended) types fall back to
|
||||
* their JSON-stringified length.
|
||||
* @returns the estimated token count.
|
||||
*/
|
||||
protected estimateContentTokens(blocks: readonly ContentBlock[]): number {
|
||||
const { charsPerToken } = this.config
|
||||
let tokens = 0
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
case 'reasoning':
|
||||
tokens += Math.ceil(block.text.length / charsPerToken) + BLOCK_OVERHEAD
|
||||
break
|
||||
case 'tool-call':
|
||||
tokens += Math.ceil(block.name.length / charsPerToken)
|
||||
+ Math.ceil(block.arguments.length / charsPerToken)
|
||||
+ BLOCK_OVERHEAD
|
||||
break
|
||||
case 'tool-result':
|
||||
tokens += this.estimateContentTokens(block.content) + BLOCK_OVERHEAD
|
||||
break
|
||||
default:
|
||||
// Unknown block types (merge-extensible ContentBlockMap):
|
||||
// estimate conservatively via JSON stringify.
|
||||
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / charsPerToken)
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
this.config = resolveConfig(config, ctx.tokenMeter)
|
||||
if (this.config.auto) registerAutomaticCompaction(ctx, this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate token count for a single session event. Returns 0 for non-message
|
||||
* event types (boundaries, chunks, usage, errors, compact markers).
|
||||
*
|
||||
* @param event - any session event; only the message-bearing types carry
|
||||
* content to count.
|
||||
* @returns the estimated token count of the event's content, or 0 for a
|
||||
* non-message event.
|
||||
*/
|
||||
private estimateEventTokens(event: SessionEvent): number {
|
||||
/* v8 ignore next -- callers traverse surface nodes, whose event types are the five cases below */
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
case 'assistant/message':
|
||||
case 'context/message':
|
||||
case 'steering/message':
|
||||
case 'tool/result':
|
||||
return this.estimateContentTokens(event.data.content)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate total tokens across a list of messages plus optional system prompt.
|
||||
*
|
||||
* @param messages - the derived conversation messages; each adds a fixed
|
||||
* role-framing overhead on top of its content estimate.
|
||||
* @param systemPrompt - counted at chars / `charsPerToken` when provided.
|
||||
* @returns the estimated token footprint of the whole request.
|
||||
*/
|
||||
private estimateTokens(messages: readonly Message[], systemPrompt?: string): number {
|
||||
let total = 0
|
||||
for (const msg of messages) {
|
||||
total += this.estimateContentTokens(msg.content)
|
||||
total += ROLE_OVERHEAD
|
||||
}
|
||||
if (systemPrompt) total += Math.ceil(systemPrompt.length / this.config.charsPerToken)
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize through a direct one-shot `ctx.llm.stream()` call, not an agent
|
||||
* step or `agent/request` dispatch. Failure finishes and truncated summaries
|
||||
* reject; the signal is forwarded and only text reaches the checkpoint.
|
||||
*
|
||||
* @param text - plain-text rendering of the conversation region to condense.
|
||||
* @param agent - supplies the fallback model and the session id stamped on
|
||||
* the call; throws when neither it nor the config names a model.
|
||||
* @param signal - optional abort signal, forwarded into the model call.
|
||||
* @returns the text-only summary blocks plus the call envelope used
|
||||
* (`model`, and `maxTokens` when the summarizer has a cap).
|
||||
* Summarize a rendered region through a direct one-shot `ctx.llm.stream()`
|
||||
* call. Override this sole hook for a template or remote summarizer.
|
||||
* @param text - plain-text conversation region to condense.
|
||||
* @param agent - supplies routed-model history, fallback model, and session id.
|
||||
* @param signal - optional cancellation forwarded to the adapter.
|
||||
* @returns safe text summary blocks and exact auxiliary-call provenance.
|
||||
*/
|
||||
protected async summarize(
|
||||
text: string, agent: Agent, signal?: AbortSignal,
|
||||
): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> {
|
||||
const assembler = new BlockAssembler()
|
||||
const options: GenerateOptions = {
|
||||
model: this.config.summarizationModel || agent.options.model || '',
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }],
|
||||
}],
|
||||
system: SUMMARIZE_SYSTEM_PROMPT,
|
||||
maxTokens: this.config.maxTokens,
|
||||
sessionId: agent.session.id,
|
||||
}
|
||||
// exactOptionalPropertyTypes: only set `signal` when present — assigning
|
||||
// `undefined` to an optional `signal?: AbortSignal` is a type error.
|
||||
if (signal) options.signal = signal
|
||||
if (!options.model) {
|
||||
throw new Error('no model available for summarization: set BasicCompactConfig.summarizationModel or AgentOptions.model')
|
||||
}
|
||||
for await (const chunk of this.ctx.llm.stream(options)) {
|
||||
assembler.push(chunk)
|
||||
}
|
||||
|
||||
const error = finishError(assembler.finish)
|
||||
if (error) throw error
|
||||
|
||||
const summary = this._textOnly(assembler.message().content)
|
||||
if (!summary.some(block => block.type === 'text' && block.text.trim().length > 0)) {
|
||||
throw new Error('summarization produced no text summary content')
|
||||
}
|
||||
|
||||
// config.maxTokens is required and validated positive, so this backend's
|
||||
// envelope always carries the cap; the return type's optionality exists
|
||||
// for overriding subclasses whose summarizer has none.
|
||||
return { summary, model: options.model, maxTokens: this.config.maxTokens }
|
||||
text: string,
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
|
||||
return summarizeWithLlm(this.ctx, this.config, text, agent, signal)
|
||||
}
|
||||
|
||||
// ---- Core API (implements the abstract contract) ----
|
||||
|
||||
/**
|
||||
* The sole pressure gate: count the next request's prefix, derived history,
|
||||
* and system prompt. Above threshold, retain a recent tool-balanced tail and
|
||||
* compact the head, reconsolidating any prior automatic checkpoint. Returns
|
||||
* `null` when no safe or necessary range exists.
|
||||
* Check replayed pressure for the provisional pre-step envelope and compact
|
||||
* a tool-balanced head until it falls below the service-wide threshold.
|
||||
* A genuinely model-less router-first step skips this provisional check.
|
||||
* @param agent - agent whose session and provisional provider/model are measured.
|
||||
* @param fullSystemPrompt - current assembled system prompt override.
|
||||
* @param sessionPrefix - current request-only prefix override.
|
||||
* @param signal - live step cancellation signal forwarded to summarization.
|
||||
* @returns the latest compaction result, or `null` when no check/work applies.
|
||||
*/
|
||||
override async compactIfNeeded(
|
||||
agent: Agent,
|
||||
@@ -289,47 +119,43 @@ export class BasicCompactService extends CompactService {
|
||||
sessionPrefix: readonly Message[],
|
||||
signal: AbortSignal,
|
||||
): Promise<CompactionResult | null> {
|
||||
const session = agent.session
|
||||
const threshold = Math.floor(this.config.contextWindow * this.config.thresholdRatio)
|
||||
let result: CompactionResult | null = null
|
||||
for (let attempt = 0; attempt <= this.config.compactionRetries; attempt++) {
|
||||
const totalTokens = this.estimatePressure(session, fullSystemPrompt, sessionPrefix)
|
||||
if (totalTokens < threshold) return result
|
||||
const target = effectiveTarget(agent)
|
||||
if (target === undefined) return null
|
||||
const meter = this.ctx.tokenMeter
|
||||
const requestHeader = provisionalHeader(target, agent.session, fullSystemPrompt, sessionPrefix)
|
||||
const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio)
|
||||
let measurement = meter.measure(agent.session, requestHeader)
|
||||
if (measurement.totalTokens < threshold) return null
|
||||
|
||||
const range = this._compactableRange(session)
|
||||
let result: CompactionResult | null = null
|
||||
for (let attempt = 0; attempt <= this.config.compactionRetries; attempt += 1) {
|
||||
const range = selectCompactableRange(agent.session, measurement, this.config.retainTokens)
|
||||
if (range === null) {
|
||||
/* v8 ignore else -- defensive for non-standard subclass mutations; the concrete replace keeps a compactable head checkpoint. */
|
||||
/* v8 ignore else -- concrete replacement preserves a compactable checkpoint; subclass hooks cannot mutate it. */
|
||||
if (result === null) return null
|
||||
/* v8 ignore next -- paired with the ignored defensive branch above. */
|
||||
/* v8 ignore next -- paired with the defensive post-success branch above. */
|
||||
break
|
||||
}
|
||||
|
||||
result = await this.compactRegion(range.start, range.end, agent, signal)
|
||||
measurement = meter.measure(agent.session, requestHeader)
|
||||
if (measurement.totalTokens < threshold) return result
|
||||
}
|
||||
|
||||
const totalTokens = this.estimatePressure(session, fullSystemPrompt, sessionPrefix)
|
||||
if (totalTokens < threshold) return result
|
||||
|
||||
throw new Error(
|
||||
`compaction still above threshold after ${this.config.compactionRetries + 1} compaction attempts `
|
||||
+ `(${totalTokens} estimated tokens >= threshold ${threshold})`,
|
||||
+ `(${measurement.totalTokens} estimated tokens >= threshold ${threshold})`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimated token pressure of the NEXT request: the session prefix
|
||||
* (`EpochHeader.messagePrefix` — request-only messages the loop sends in
|
||||
* front of the derived history, composed before the pre-step seam and
|
||||
* handed to the gate), the derived history, and the system prompt.
|
||||
* @param session - the session whose next request is being estimated.
|
||||
* @param fullSystemPrompt - the assembled system prompt (counts toward pressure).
|
||||
* @param sessionPrefix - the instance's composed session prefix (counts toward pressure).
|
||||
* @returns the estimated token total the next request will carry.
|
||||
* Compact one inclusive positional range from the agent-owned surface using
|
||||
* the effective token meter for all retention and shrink pricing.
|
||||
* @param start - inclusive first surface-node seq.
|
||||
* @param end - inclusive last surface-node seq.
|
||||
* @param agent - owner of the target session, used by the summarizer.
|
||||
* @param signal - optional summarization cancellation signal.
|
||||
* @returns the successful durable compaction result.
|
||||
*/
|
||||
private estimatePressure(session: Session, fullSystemPrompt: string, sessionPrefix: readonly Message[]): number {
|
||||
return this.estimateTokens([...sessionPrefix, ...session.deriveMessages()], fullSystemPrompt)
|
||||
}
|
||||
|
||||
override async compactRegion(
|
||||
start: number,
|
||||
end: number,
|
||||
@@ -337,212 +163,10 @@ export class BasicCompactService extends CompactService {
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompactionResult> {
|
||||
const session = agent.session
|
||||
// Resolve by surface position: a newer replacement seq may occupy an older slot.
|
||||
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`)
|
||||
}
|
||||
|
||||
// Both range edges must preserve assistant tool-call/result pairing.
|
||||
const events = session.events
|
||||
if (!isToolPairingBalanced(nodes, events, start)) {
|
||||
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
|
||||
}
|
||||
// The cut after `end` is named by `end`'s surface successor, or `null` when
|
||||
// `end` is the tail.
|
||||
const afterEnd = nodes[endIdx + 1] ?? null
|
||||
if (!isToolPairingBalanced(nodes, events, afterEnd)) {
|
||||
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
|
||||
}
|
||||
|
||||
if (this._isCompactionInProgress(session)) {
|
||||
throw new Error('compaction already in progress')
|
||||
}
|
||||
|
||||
// Compaction's events (compact/* and the replacement user/message) must be turn-enclosed:
|
||||
// the session-log contract rejects any plugin event appended outside an open turn.
|
||||
const openTurn = this._openTurn(session)
|
||||
if (openTurn === null) {
|
||||
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
|
||||
}
|
||||
// Slice the ordered surface nodes [startIdx, endIdx] inclusive — the
|
||||
// shadowed range is positional, so this is the set the replace op covers.
|
||||
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1)
|
||||
|
||||
// --- Acquire lock ---
|
||||
const startEvent = session.append('compact/start', { turn: openTurn })
|
||||
|
||||
try {
|
||||
// --- Extract text and summarize ---
|
||||
const text = renderTranscript(session.events, shadowedSeqs)
|
||||
const { summary, model, maxTokens } = await this.summarize(text, agent, signal)
|
||||
|
||||
// Estimate token count of the shadowed content for provenance.
|
||||
let shadowedTokenCount = 0
|
||||
for (const seq of shadowedSeqs) {
|
||||
// seq comes from a surface node — always a valid log index by construction.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
shadowedTokenCount += this.estimateEventTokens(session.events[seq]!)
|
||||
}
|
||||
const framedSummary = this._frameSummary(summary)
|
||||
const framedSummaryTokenCount = this.estimateContentTokens(framedSummary)
|
||||
if (framedSummaryTokenCount >= shadowedTokenCount) {
|
||||
throw new Error(
|
||||
`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`,
|
||||
)
|
||||
}
|
||||
// --- Provenance record (log-only) ---
|
||||
const summaryEvent = session.append('compact/summary', {
|
||||
summary,
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs,
|
||||
shadowedTokenCount,
|
||||
model,
|
||||
...maxTokens !== undefined ? { maxTokens } : {},
|
||||
})
|
||||
|
||||
// --- Surface replacement --- The user/message directly shadows all compacted surface
|
||||
// nodes with a single replace op.
|
||||
session.append('user/message', {
|
||||
content: framedSummary,
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start, end },
|
||||
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
|
||||
})
|
||||
|
||||
// --- Release lock (log-only) ---
|
||||
// Appended LAST so the lock brackets the WHOLE operation: a crash between
|
||||
// compact/start and here leaves a detectable orphaned lock (a compact/start
|
||||
// with no matching compact/end) rather than a compact/end that falsely
|
||||
// claims compaction finished before the surface replacement landed.
|
||||
const endEvent = session.append('compact/end', { turn: openTurn })
|
||||
|
||||
return {
|
||||
startSeq: startEvent.seq,
|
||||
summarySeq: summaryEvent.seq,
|
||||
endSeq: endEvent.seq,
|
||||
summary,
|
||||
shadowedRange: { start, end },
|
||||
shadowedSeqs,
|
||||
shadowedTokenCount,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// Always release the lock — append compact/end with the error so a
|
||||
// wedged lock is impossible.
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
session.append('compact/end', { turn: openTurn, error: msg })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Internal helpers ----
|
||||
|
||||
/**
|
||||
* Frame the raw summary blocks into the content that lands on the surface:
|
||||
* a checkpoint preamble (so a resuming model reads it as a checkpoint, not a
|
||||
* fresh user request) followed by the summary wrapped in
|
||||
* {@link SUMMARY_OPEN_TAG}/{@link SUMMARY_CLOSE_TAG}. The tags make a prior
|
||||
* checkpoint detectable in the transcript on the next compaction cycle, which
|
||||
* triggers the merge rule in the summarization prompt. The raw, unframed
|
||||
* `summary` is preserved separately on the `compact/summary` provenance event.
|
||||
*/
|
||||
private _frameSummary(summary: readonly ContentBlock[]): ContentBlock[] {
|
||||
return [
|
||||
{ type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` },
|
||||
...summary,
|
||||
{ type: 'text', text: SUMMARY_CLOSE_TAG },
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a compaction is currently in progress for `session` — an unmatched `compact/start`
|
||||
* (no later `compact/end`) WITHIN the current turn.
|
||||
*/
|
||||
private _isCompactionInProgress(session: Session): boolean {
|
||||
const events = session.events
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
// Index bounded by i >= 0 and i < events.length — never undefined.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const e = events[i]!
|
||||
if (e.type === 'compact/start') return true
|
||||
if (e.type === 'compact/end') break
|
||||
// A turn/end bounds the scan: anything before it belongs to a prior
|
||||
// (closed) turn and cannot be an in-progress compaction of THIS turn.
|
||||
if (e.type === 'turn/end') break
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Resolve the next head-anchored compactable surface range, or `null`. */
|
||||
private _compactableRange(session: Session): { start: number; end: number } | null {
|
||||
const nodes = session.surface.nodes
|
||||
if (nodes.length === 0) return null
|
||||
|
||||
const events = session.events
|
||||
const retainBudget = this.config.retainTokens
|
||||
|
||||
// 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 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 seq = nodes[i]!
|
||||
const event = events[seq]
|
||||
/* v8 ignore next -- seq is a surface event sequence, always a valid log index by construction */
|
||||
if (event) accumulated += this.estimateEventTokens(event)
|
||||
keepFromIdx = i
|
||||
if (accumulated >= retainBudget) break
|
||||
}
|
||||
|
||||
// The whole surface fits the retain budget — nothing to compact.
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
// Round the cutoff head-ward to a tool-pairing boundary; decline when no
|
||||
// safe compactable prefix exists.
|
||||
while (keepFromIdx > 0) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!)) break
|
||||
keepFromIdx -= 1
|
||||
}
|
||||
if (keepFromIdx === 0) return null
|
||||
|
||||
// The compacted range is [head … keepFromIdx - 1], anchored at the head.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const firstSeq = nodes[0]!
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const cutoffSeq = nodes[keepFromIdx - 1]!
|
||||
return { start: firstSeq, end: cutoffSeq }
|
||||
}
|
||||
|
||||
/** Keep only text; checkpoints cannot contain reasoning or orphan tool calls. */
|
||||
private _textOnly(blocks: readonly ContentBlock[]): ContentBlock[] {
|
||||
return blocks.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn number of the currently OPEN turn — a `turn/start` not yet
|
||||
* followed by its `turn/end` — or `null` if the session has no open turn.
|
||||
*
|
||||
* Compaction's events must be enclosed in a turn, so scanning back from the
|
||||
* tail: a `turn/start` means that turn is open (return it); a `turn/end` means
|
||||
* the most recent turn already closed (return null). The whole compaction
|
||||
* sequence (compact/start … compact/end) is stamped with this turn.
|
||||
*/
|
||||
private _openTurn(session: Session): number | null {
|
||||
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') return e.data.turn
|
||||
if (e.type === 'turn/end') return null
|
||||
}
|
||||
return null
|
||||
return compactSurfaceRegion({
|
||||
meter: this.ctx.tokenMeter,
|
||||
summarize: (text, owner, abort) => this.summarize(text, owner, abort),
|
||||
}, session, start, end, agent, signal)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
197
packages/compact/compact-basic/src/region.ts
Normal file
197
packages/compact/compact-basic/src/region.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Surface retention selection and the log-recorded compaction transaction.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact-basic/region
|
||||
*/
|
||||
|
||||
import {
|
||||
renderTranscript,
|
||||
toolPairingBalancedAfter,
|
||||
toolPairingBalancedBefore,
|
||||
} from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
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 { SummaryResult } from './summarizer.ts'
|
||||
|
||||
interface RegionDependencies {
|
||||
readonly meter: TokenMeterService
|
||||
summarize(text: string, 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 any later durable append, including a
|
||||
// log-only one, invalidates the async selection before replacement.
|
||||
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 text = renderTranscript(session.events, shadowedSeqs)
|
||||
const { summary, provider, model, maxTokens } = await dependencies.summarize(text, agent, signal)
|
||||
|
||||
const currentMeasurement = dependencies.meter.measure(session)
|
||||
if (currentMeasurement.logRevision !== lockedMeasurement.logRevision) {
|
||||
throw new Error('compaction: session log changed during summarization')
|
||||
}
|
||||
const framedSummary = frameSummary(summary)
|
||||
const framedSummaryTokenCount = dependencies.meter.estimateMessage({
|
||||
role: 'user',
|
||||
content: framedSummary,
|
||||
})
|
||||
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', {
|
||||
content: framedSummary,
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 }
|
||||
}
|
||||
169
packages/compact/compact-basic/src/summarizer.ts
Normal file
169
packages/compact/compact-basic/src/summarizer.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Default one-shot summarization and durable checkpoint framing.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact-basic/summarizer
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { ResolvedConfig } from './types.ts'
|
||||
|
||||
/** Tags wrapping the structured summary inside the landed checkpoint node. */
|
||||
const SUMMARY_OPEN_TAG = '<compacted-summary>'
|
||||
const SUMMARY_CLOSE_TAG = '</compacted-summary>'
|
||||
|
||||
/** Fixed structure required from the auxiliary summarization call. */
|
||||
const SUMMARIZE_SYSTEM_PROMPT = [
|
||||
'You are a compaction engine for an AI coding assistant. Condense the conversation transcript into a structured checkpoint that lets another model resume the work with no loss of essential context.',
|
||||
'',
|
||||
'Output EXACTLY the Markdown structure below: keep every section, in order. Use terse bullets, not prose paragraphs. Write "(none)" for an empty section — never drop a section.',
|
||||
'',
|
||||
'## Primary Request and Intent',
|
||||
"- [the user's original and evolving goals; quote verbatim where the exact wording matters]",
|
||||
'',
|
||||
'## Key Technical Concepts',
|
||||
'- [technologies, frameworks, patterns, and conventions in play]',
|
||||
'',
|
||||
'## Files and Code',
|
||||
'- [exact path: why it matters, key changes or snippets]',
|
||||
'',
|
||||
'## Errors and Fixes',
|
||||
'- [error: how it was resolved, plus any related user feedback]',
|
||||
'',
|
||||
'## Pending Tasks',
|
||||
'- [explicitly requested work not yet completed]',
|
||||
'',
|
||||
'## Current Work',
|
||||
'- [precisely what was in progress at this checkpoint]',
|
||||
'',
|
||||
'## Next Step',
|
||||
'- [the single next action, directly in line with the most recent request, or "(none)"]',
|
||||
'',
|
||||
'## Critical Context',
|
||||
'- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]',
|
||||
'',
|
||||
'Rules:',
|
||||
'- Preserve exact file paths, commands, error strings, identifiers, and function signatures.',
|
||||
'- Capture user feedback and explicit instructions faithfully, especially corrections.',
|
||||
'- Do NOT mention this summarization process or that the context was compacted.',
|
||||
`- If the transcript already contains a ${SUMMARY_OPEN_TAG} block, it is a PRIOR checkpoint. Do not copy it forward verbatim: preserve still-true facts, drop stale ones, and merge newer information into a single consolidated summary under the same structure.`,
|
||||
].join('\n')
|
||||
|
||||
/** Framing that makes the replacement user message established context. */
|
||||
const CHECKPOINT_PREAMBLE =
|
||||
'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.'
|
||||
|
||||
/** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */
|
||||
export interface SummaryResult {
|
||||
summary: ContentBlock[]
|
||||
provider: string
|
||||
model: string
|
||||
maxTokens?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the default direct `ctx.llm.stream()` summarization call.
|
||||
* @param ctx - context providing the LLM service.
|
||||
* @param config - resolved backend configuration.
|
||||
* @param text - rendered transcript region to summarize.
|
||||
* @param agent - supplies routed-model history, fallback model, and session id.
|
||||
* @param signal - optional cancellation forwarded to the adapter.
|
||||
* @returns safe text-only summary blocks and exact call provenance.
|
||||
*/
|
||||
export async function summarizeWithLlm(
|
||||
ctx: Context,
|
||||
config: ResolvedConfig,
|
||||
text: string,
|
||||
agent: Agent,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SummaryResult> {
|
||||
const latest = agent.session.requestHeader()?.config
|
||||
const configured = config.summarizationProvider.length === 0
|
||||
? undefined
|
||||
: { provider: config.summarizationProvider, model: config.summarizationModel }
|
||||
const agentTarget = agent.options.provider !== undefined
|
||||
&& agent.options.provider.length > 0
|
||||
&& agent.options.model !== undefined
|
||||
&& agent.options.model.length > 0
|
||||
? { provider: agent.options.provider, model: agent.options.model }
|
||||
: undefined
|
||||
const target = configured ?? latest ?? agentTarget
|
||||
if (target === undefined) {
|
||||
throw new Error(
|
||||
'no provider/model available for summarization: set both BasicCompactConfig summarization fields, route one request, or set both AgentOptions fields',
|
||||
)
|
||||
}
|
||||
|
||||
const assembler = new BlockAssembler()
|
||||
const options: GenerateOptions = {
|
||||
provider: target.provider,
|
||||
model: target.model,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }],
|
||||
}],
|
||||
system: SUMMARIZE_SYSTEM_PROMPT,
|
||||
maxTokens: config.maxTokens,
|
||||
sessionId: agent.session.id,
|
||||
...signal === undefined ? {} : { signal },
|
||||
}
|
||||
for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
|
||||
const error = finishError(assembler.finish)
|
||||
if (error !== undefined) throw error
|
||||
|
||||
const summary = textOnly(assembler.message().content)
|
||||
if (!summary.some(block => block.text.trim().length > 0)) {
|
||||
throw new Error('summarization produced no text summary content')
|
||||
}
|
||||
return {
|
||||
summary,
|
||||
provider: target.provider,
|
||||
model: target.model,
|
||||
maxTokens: config.maxTokens,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap raw summary blocks in the durable checkpoint framing.
|
||||
* @param summary - safe text-only model output.
|
||||
* @returns content for the synthesized replacement user message.
|
||||
*/
|
||||
export function frameSummary(summary: readonly ContentBlock[]): ContentBlock[] {
|
||||
return [
|
||||
{ type: 'text', text: `${CHECKPOINT_PREAMBLE}\n\n${SUMMARY_OPEN_TAG}` },
|
||||
...summary,
|
||||
{ type: 'text', text: SUMMARY_CLOSE_TAG },
|
||||
]
|
||||
}
|
||||
|
||||
/** Map a terminal summarization finish to its fail-closed error. */
|
||||
function finishError(finish: FinishReason): Error | undefined {
|
||||
switch (finish.kind) {
|
||||
case 'error': {
|
||||
const error = new Error(finish.message) as Error & { code?: string }
|
||||
if (finish.code !== undefined) error.code = finish.code
|
||||
return error
|
||||
}
|
||||
case 'aborted': {
|
||||
const error = new Error('summarization stream aborted') as Error & { code?: string }
|
||||
error.code = 'ABORTED'
|
||||
return error
|
||||
}
|
||||
case 'max-tokens': {
|
||||
const error = new Error('summarization truncated at the token cap (incomplete checkpoint)') as Error & { code?: string }
|
||||
error.code = 'MAX_TOKENS'
|
||||
return error
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep only text blocks before synthesizing a user message. */
|
||||
function textOnly(
|
||||
blocks: readonly ContentBlock[],
|
||||
): Array<Extract<ContentBlock, { type: 'text' }>> {
|
||||
return blocks.filter((block): block is Extract<ContentBlock, { type: 'text' }> => block.type === 'text')
|
||||
}
|
||||
@@ -1,94 +1,34 @@
|
||||
/**
|
||||
* Configuration vocabulary for the basic compaction backend.
|
||||
*
|
||||
* Every tunable lives here, in the implementation — the abstract contract
|
||||
* (`@deepseek-ai/dsh-compact`) carries no config, because thresholds and
|
||||
* retention policy are HOW decisions a different backend would make
|
||||
* differently.
|
||||
* Configuration vocabulary for the replay-aware basic compaction backend.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact-basic/types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Backend configuration. Every knob is REQUIRED except `auto` and
|
||||
* `charsPerToken`: there is no concrete data yet to justify default
|
||||
* thresholds/budgets, so a consumer must state each value explicitly rather
|
||||
* than inherit a guessed default. `auto` alone defaults to `true`
|
||||
* (auto-compaction is the intended posture), and `charsPerToken` defaults to
|
||||
* the English-text heuristic its estimator was calibrated on.
|
||||
*/
|
||||
/** Basic compaction configuration; every common field has a deployment default. */
|
||||
export interface BasicCompactConfig {
|
||||
/** Context window size in tokens. */
|
||||
contextWindow: number
|
||||
/** Compact when estimated token usage exceeds this fraction of context window. */
|
||||
thresholdRatio: number
|
||||
/** Number of tokens of recent context to retain during compaction. */
|
||||
retainTokens: number
|
||||
/** Model to use for summarization (`''` — uses the agent's model). */
|
||||
summarizationModel: string
|
||||
/** Provider generation cap for the summarization call. */
|
||||
maxTokens: number
|
||||
/** Extra compaction attempts when the first compacted surface is still over threshold. */
|
||||
compactionRetries: number
|
||||
/** Enable automatic compaction on the `agent/pre-step` seam (default true). */
|
||||
/** Compact at this fraction of the token meter's context window. Defaults to `0.8`. */
|
||||
thresholdRatio?: number
|
||||
/** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */
|
||||
retainTokens?: number
|
||||
/** Summary provider; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */
|
||||
summarizationProvider?: string
|
||||
/** Summary model; `''` resolves the latest routed pair, then the agent pair. Defaults to `''`. */
|
||||
summarizationModel?: string
|
||||
/** Provider generation cap for summarization. Defaults to `8192`. */
|
||||
maxTokens?: number
|
||||
/** Extra attempts after the first compaction when pressure remains above threshold. Defaults to `1`. */
|
||||
compactionRetries?: number
|
||||
/** Enable the automatic `agent/pre-step` pressure listener. Defaults to `true`. */
|
||||
auto?: boolean
|
||||
/**
|
||||
* Text density for the token estimator: estimated tokens = chars /
|
||||
* `charsPerToken`. Defaults to 4 (typical English text). A CJK-heavy
|
||||
* deployment should set ~1-2 — CJK runs at roughly 1-2 chars per token, so
|
||||
* the default UNDERestimates several-fold and compaction fires far too late.
|
||||
* May be fractional.
|
||||
*/
|
||||
charsPerToken?: number
|
||||
}
|
||||
|
||||
/** Resolved config with `auto` and `charsPerToken` defaulted. */
|
||||
export type ResolvedConfig = Required<BasicCompactConfig>
|
||||
|
||||
/**
|
||||
* Default `auto`/`charsPerToken` when unset and reject nonsensical numeric knobs.
|
||||
*
|
||||
* @param config - the raw, unresolved backend config.
|
||||
* @returns the validated config with `auto` and `charsPerToken` defaulted.
|
||||
*/
|
||||
export function resolveConfig(config: BasicCompactConfig): ResolvedConfig {
|
||||
const resolved: ResolvedConfig = { auto: true, charsPerToken: 4, ...config }
|
||||
|
||||
assertPositiveInteger('contextWindow', resolved.contextWindow)
|
||||
assertRatio('thresholdRatio', resolved.thresholdRatio)
|
||||
assertNonNegativeInteger('retainTokens', resolved.retainTokens)
|
||||
assertPositiveInteger('maxTokens', resolved.maxTokens)
|
||||
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
|
||||
assertPositiveFinite('charsPerToken', resolved.charsPerToken)
|
||||
if (typeof resolved.summarizationModel !== 'string') {
|
||||
throw new Error('BasicCompactConfig: summarizationModel must be a string.')
|
||||
}
|
||||
if (typeof resolved.auto !== 'boolean') {
|
||||
throw new Error('BasicCompactConfig: auto must be a boolean.')
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive integer.`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNonNegativeInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a non-negative integer.`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertPositiveFinite(name: string, value: number): void {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive finite number.`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRatio(name: string, value: number): void {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) {
|
||||
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1].`)
|
||||
}
|
||||
/** Validated and detached compaction configuration. */
|
||||
export interface ResolvedConfig {
|
||||
readonly thresholdRatio: number
|
||||
readonly retainTokens: number
|
||||
readonly summarizationProvider: string
|
||||
readonly summarizationModel: string
|
||||
readonly maxTokens: number
|
||||
readonly compactionRetries: number
|
||||
readonly auto: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user