feat(compact): recover context overflow (PR3 phase 2)

This commit is contained in:
Hypatia May
2026-07-15 16:50:44 +08:00
parent e8d066f750
commit 12484104c8
46 changed files with 913 additions and 242 deletions
+41 -17
View File
@@ -1,12 +1,12 @@
/**
* Automatic pre-step pressure listener for compact-basic.
* Automatic post-step pressure and context-overflow recovery listeners.
*
* @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 { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
import {
TOKEN_METER_MODEL_UNCONFIGURED,
TokenMeterError,
@@ -14,10 +14,10 @@ import {
import type { Agent } from '@deepseek-ai/dsh-agent'
interface AutomaticCompactor {
readonly config: { readonly maxOverflowRetries: number }
compactIfNeeded(
agent: Agent,
fullSystemPrompt: string,
sessionPrefix: readonly Message[],
trigger: CompactionTrigger,
signal: AbortSignal,
): Promise<CompactionResult | null>
}
@@ -31,30 +31,54 @@ export function registerAutomaticCompaction(
ctx: Context,
service: AutomaticCompactor,
): void {
ctx.on('agent/pre-step', async (
const logResult = (result: CompactionResult, trigger: string): void => {
ctx.logger.info(
`compaction (${trigger}): shadowed ${result.shadowedSeqs.length} surface nodes `
+ `(seqs ${result.shadowedRange.start}-${result.shadowedRange.end}, `
+ `~${result.shadowedTokenCount} tokens)`,
)
}
ctx.on('agent/post-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)`,
)
}
const result = await service.compactIfNeeded(agent, 'pressure', signal)
if (result !== null) logResult(result, 'post-step pressure')
} catch (error: unknown) {
// A named routed model without a meter profile is configuration failure,
// not an optional operational compaction miss.
if (error instanceof TokenMeterError
&& error.code === TOKEN_METER_MODEL_UNCONFIGURED) throw error
const message = error instanceof Error ? error.message : String(error)
ctx.logger.warn(`compaction failed: ${message}; proceeding with full history`)
ctx.logger.warn(`post-step compaction failed: ${message}; continuing the turn`)
}
})
ctx.on('agent/request-error', async (agent, _turn, _step, error, retryAttempt, signal, next) => {
if (error.code !== CONTEXT_WINDOW_EXCEEDED_CODE
|| retryAttempt >= service.config.maxOverflowRetries
|| signal.aborted) return next()
let generation: number
let result: CompactionResult | null
try {
generation = agent.session.surface.replaceGeneration
result = await service.compactIfNeeded(agent, 'context-overflow', signal)
} catch (recoveryError: unknown) {
const message = recoveryError instanceof Error ? recoveryError.message : String(recoveryError)
ctx.logger.warn(
`context-overflow compaction failed: ${message}; preserving the original request error`,
)
return next()
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- signal can abort while compaction is awaited.
if (signal.aborted || result === null
|| agent.session.surface.replaceGeneration <= generation) return next()
logResult(result, 'context overflow recovery')
return { action: 'retry' }
})
}
@@ -47,6 +47,7 @@ export function resolveConfig(
summarizationModel: '',
maxTokens: 8192,
compactionRetries: 1,
maxOverflowRetries: 1,
auto: true,
}, meter)
}
@@ -56,10 +57,12 @@ export function resolveConfig(
summarizationModel: config.summarizationModel ?? '',
maxTokens: config.maxTokens ?? 8192,
compactionRetries: config.compactionRetries ?? 1,
maxOverflowRetries: config.maxOverflowRetries ?? 1,
auto: config.auto ?? true,
}
assertPositiveInteger('maxTokens', resolved.maxTokens)
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
assertNonNegativeInteger('maxOverflowRetries', resolved.maxOverflowRetries)
if (typeof resolved.summarizationModel !== 'string') {
throw new Error('BasicCompactConfig: summarizationModel must be a string')
}
+27 -37
View File
@@ -7,10 +7,9 @@
import { Context } from 'cordis'
import z from 'schemastery'
import { CompactService } from '@deepseek-ai/dsh-compact'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
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 { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
import type { Session } from '@deepseek-ai/dsh-session'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { ModelTokenMeter } from '@deepseek-ai/dsh-token-meter'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { registerAutomaticCompaction } from './automatic.ts'
@@ -36,24 +35,10 @@ function effectiveModel(agent: Agent): string | undefined {
return agent.session.requestHeader()?.config.model ?? agent.options.model
}
/**
* 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(
model: string,
session: Session,
fullSystemPrompt: string,
sessionPrefix: readonly Message[],
): EpochHeader {
const latest = session.requestHeader()
return canonicalHeader({
config: latest === undefined ? { model } : { ...latest.config, model },
...fullSystemPrompt.length === 0 ? {} : { system: fullSystemPrompt },
...latest?.tools === undefined ? {} : { tools: latest.tools },
...sessionPrefix.length === 0 ? {} : { messagePrefix: [...sessionPrefix] },
})
/** Resolve the exact model durably routed for the latest provider request. */
function routedModel(session: Session): string | undefined {
const model = session.requestHeader()?.config.model
return model === undefined || model.length === 0 ? undefined : model
}
/**
@@ -75,6 +60,7 @@ export class BasicCompactService extends CompactService {
summarizationModel: z.string().default(''),
maxTokens: z.number().step(1).min(1).default(8192),
compactionRetries: z.number().step(1).min(0).default(1),
maxOverflowRetries: z.number().step(1).min(0).default(1),
auto: z.boolean().default(true),
})
@@ -106,29 +92,33 @@ export class BasicCompactService extends CompactService {
}
/**
* Check replayed pressure for the provisional pre-step envelope and compact
* a tool-balanced head until it falls below the effective model threshold.
* A genuinely model-less router-first step skips this provisional check;
* naming an unconfigured model throws the token meter's typed error.
* @param agent - agent whose session and provisional 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.
* Compact for replayed post-step pressure or one provider-confirmed context
* overflow. Both triggers price the latest durable routed request model;
* overflow bypasses the normal threshold and retained-tail policy so it can
* force one useful balanced reduction.
* @param agent - agent whose latest durable routed request is measured.
* @param trigger - normal post-step pressure or context-overflow recovery.
* @param signal - live turn cancellation signal forwarded to summarization.
* @returns the latest compaction result, or `null` when no check/work applies.
*/
override async compactIfNeeded(
agent: Agent,
fullSystemPrompt: string,
sessionPrefix: readonly Message[],
trigger: CompactionTrigger,
signal: AbortSignal,
): Promise<CompactionResult | null> {
const model = effectiveModel(agent)
if (model === undefined || model.length === 0) return null
const model = routedModel(agent.session)
if (model === undefined) return null
const meter = this.ctx.tokenMeter.resolve(model)
const policy = this._modelConfig(meter)
const requestHeader = provisionalHeader(model, agent.session, fullSystemPrompt, sessionPrefix)
if (trigger === 'context-overflow') {
const surface = meter.measureSurface(agent.session)
const range = selectCompactableRange(agent.session, surface, 0)
if (range === null) return null
return this.compactRegion(agent.session, range.start, range.end, agent, signal)
}
const threshold = Math.floor(policy.contextWindow * policy.thresholdRatio)
let measurement = meter.measure(agent.session, requestHeader)
let measurement = meter.measure(agent.session)
if (measurement.totalTokens < threshold) return null
let result: CompactionResult | null = null
@@ -147,7 +137,7 @@ export class BasicCompactService extends CompactService {
break
}
result = await this.compactRegion(agent.session, range.start, range.end, agent, signal)
measurement = meter.measure(agent.session, requestHeader)
measurement = meter.measure(agent.session)
if (measurement.totalTokens < threshold) return result
}
@@ -106,7 +106,7 @@ export async function summarizeWithLlm(
if (!summary.some(block => block.text.trim().length > 0)) {
throw new Error('summarization produced no text summary content')
}
return { summary, model, maxTokens: config.maxTokens }
return { summary, model: options.model, maxTokens: config.maxTokens }
}
/**
+4 -1
View File
@@ -22,7 +22,9 @@ export interface BasicCompactConfig {
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`. */
/** Maximum retries after canonical context overflow; `0` disables recovery. Defaults to `1`. */
maxOverflowRetries?: number
/** Enable automatic post-step pressure and overflow-recovery listeners. Defaults to `true`. */
auto?: boolean
}
@@ -32,6 +34,7 @@ export interface ResolvedConfig {
readonly summarizationModel: string
readonly maxTokens: number
readonly compactionRetries: number
readonly maxOverflowRetries: number
readonly auto: boolean
}