Merge remote-tracking branch 'origin/token-meter-service' into compact-post-step-overflow-recovery
# Conflicts: # docs/core-data-structures/compaction.md # docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml # docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.md # docs/rfc/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md # docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md # examples/coding-agent/cordis.yml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/src/automatic.ts # packages/compact/compact-basic/src/config.ts # packages/compact/compact-basic/src/index.ts # packages/compact/compact-basic/tests/compact-basic.spec.ts
This commit is contained in:
36 files changed
+939
-1292
No files matched your search
@@ -8,25 +8,25 @@ This is the implementation tier of the compaction capability — see the [interf
|
||||
|
||||
This backend owns the compaction policy:
|
||||
|
||||
- **Measurement** — the latest durable routed request model's `ModelTokenMeter` prices the canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering.
|
||||
- **Measurement** — the singleton `ctx.tokenMeter` prices the latest canonical logged envelope and current surface at one consumed-log revision. Post-step pressure therefore includes the actual system prompt, tools, prefix, routing, assistant completion, tool results, buffered context, and steering.
|
||||
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope.
|
||||
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
|
||||
- **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
|
||||
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
|
||||
- **Lifecycle** — `compactRegion()` requires its agent to own the exact target session and rejects mismatch before resolution or mutation; a valid call records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
|
||||
- **Overflow recovery** — below-threshold overflow bypasses normal retention and attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized only when `surface.replaceGeneration` advances; no range, no replacement, recovery failure, an exhausted cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
|
||||
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Operational post-step failures warn and continue, while an actually routed model without a meter profile fails the otherwise-successful turn with the typed meter error.
|
||||
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. Operational post-step failures warn and continue; overflow-recovery failure preserves the original provider error.
|
||||
|
||||
`summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on the conversation model's meter. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`.
|
||||
`summarize()` is the sole subclass hook. A template- or remote-summarizer subclass can override it while pressure, retention, provenance, shrink validation, and shadowed-token accounting stay on `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`.
|
||||
|
||||
## Config (`BasicCompactConfig`)
|
||||
|
||||
Every common setting is optional. Every model known to `ctx.tokenMeter` receives the default compact policy lazily; named overrides merge only the fields supplied and must name a configured meter profile.
|
||||
Every setting is optional. The pressure and retention policy applies to the token meter's single context window. Unrecognized top-level keys are rejected.
|
||||
|
||||
| Key | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `models.<model>.thresholdRatio` | no (default `0.8`) | Compact at `floor(contextWindow × ratio)`. |
|
||||
| `models.<model>.retainTokens` | no (default `floor(contextWindow × 0.16)`) | Recent surface budget kept verbatim; must be below the threshold. |
|
||||
| `thresholdRatio` | no (default `0.8`) | Compact at `floor(contextWindow × ratio)`. |
|
||||
| `retainTokens` | no (default `floor(contextWindow × 0.16)`) | Recent surface budget kept verbatim; must be below the threshold. |
|
||||
| `summarizationModel` | no (default `''`) | Empty resolves the latest logged routed model, then `AgentOptions.model`. |
|
||||
| `maxTokens` | no (default `8192`) | Provider generation cap for the summarization call; may include reasoning tokens. |
|
||||
| `compactionRetries` | no (default `1`) | Extra attempts after the first when pressure remains above threshold. |
|
||||
@@ -117,7 +117,7 @@ Rules:
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Meter accuracy follows the selected profile** — missing provider usage falls back to the token meter's configured character density and structural overhead.
|
||||
- **Meter accuracy follows the fixed heuristic** — missing reusable provider usage falls back to character count plus structural overhead rather than exact tokenization.
|
||||
- **Overflow classification is adapter-maintained** — provider wording can change; both DeepSeek adapters normalize currently recognized context-limit failures to `CONTEXT_WINDOW_EXCEEDED`.
|
||||
- **Single-unit and envelope-only overflow remain outside surface compaction** — recovery cannot split one indivisible message/tool unit or shrink system/tools/prefix.
|
||||
- **`compactRegion` requires an open turn** — a manual call on a fully-closed session throws ("no open turn") rather than compacting.
|
||||
|
||||
@@ -1,65 +1,75 @@
|
||||
/**
|
||||
* Runtime defaulting and per-model policy validation for compact-basic.
|
||||
* Runtime defaulting and policy validation for compact-basic.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-compact-basic/config
|
||||
*/
|
||||
|
||||
import { deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import type { ModelTokenMeter, TokenMeterService } from '@deepseek-ai/dsh-token-meter'
|
||||
import type {
|
||||
BasicCompactConfig,
|
||||
ModelCompactConfig,
|
||||
ResolvedConfig,
|
||||
ResolvedModelCompactConfig,
|
||||
} from './types.ts'
|
||||
import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter'
|
||||
import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
|
||||
|
||||
/** Default request-pressure fraction for every metered model. */
|
||||
/** Default request-pressure fraction of the token meter's context window. */
|
||||
const DEFAULT_THRESHOLD_RATIO = 0.8
|
||||
|
||||
/** Default verbatim-tail fraction of a model's context window. */
|
||||
/** 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',
|
||||
'summarizationModel',
|
||||
'maxTokens',
|
||||
'compactionRetries',
|
||||
'maxOverflowRetries',
|
||||
'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, summarizationModel, maxTokens, '
|
||||
+ 'compactionRetries, maxOverflowRetries, auto)',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve common defaults and validate every named model override.
|
||||
* Resolve defaults and validate the service-wide compaction policy.
|
||||
* @param config - raw compact-basic configuration.
|
||||
* @param tokenMeter - owning meter service used to reject unknown override names.
|
||||
* @returns a detached deeply immutable top-level configuration.
|
||||
* @param tokenMeter - token meter supplying the context capacity.
|
||||
* @returns a detached deeply immutable configuration.
|
||||
*/
|
||||
export function resolveConfig(
|
||||
config: BasicCompactConfig = {},
|
||||
tokenMeter: TokenMeterService,
|
||||
): ResolvedConfig {
|
||||
const configuredModels: unknown = config.models
|
||||
const models = configuredModels === undefined ? {} : configuredModels
|
||||
if (typeof models !== 'object' || models === null || Array.isArray(models)) {
|
||||
throw new Error('BasicCompactConfig: models must be an object')
|
||||
}
|
||||
|
||||
const detachedModels: Record<string, ModelCompactConfig> = {}
|
||||
for (const [model, override] of Object.entries(models as Record<string, unknown>)) {
|
||||
if (typeof override !== 'object' || override === null || Array.isArray(override)) {
|
||||
throw new Error(`BasicCompactConfig: models.${model} must be an object`)
|
||||
}
|
||||
const meter = tokenMeter.resolve(model)
|
||||
detachedModels[model] = { ...override as ModelCompactConfig }
|
||||
resolveModelConfig({
|
||||
models: detachedModels,
|
||||
summarizationModel: '',
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
maxOverflowRetries: 1,
|
||||
auto: true,
|
||||
}, meter)
|
||||
}
|
||||
|
||||
validateConfigKeys(config)
|
||||
const thresholdRatio = config.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO
|
||||
const retainTokens = config.retainTokens
|
||||
?? Math.floor(tokenMeter.contextWindow * DEFAULT_RETAIN_RATIO)
|
||||
const resolved: ResolvedConfig = {
|
||||
models: detachedModels,
|
||||
thresholdRatio,
|
||||
retainTokens,
|
||||
summarizationModel: config.summarizationModel ?? '',
|
||||
maxTokens: config.maxTokens ?? 8192,
|
||||
compactionRetries: config.compactionRetries ?? 1,
|
||||
maxOverflowRetries: config.maxOverflowRetries ?? 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)
|
||||
assertNonNegativeInteger('maxOverflowRetries', resolved.maxOverflowRetries)
|
||||
@@ -69,36 +79,7 @@ export function resolveConfig(
|
||||
if (typeof resolved.auto !== 'boolean') {
|
||||
throw new Error('BasicCompactConfig: auto must be a boolean')
|
||||
}
|
||||
return deepFreeze(structuredClone(resolved))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one effective model's default policy plus optional field overrides.
|
||||
* @param config - validated compact-basic configuration.
|
||||
* @param meter - effective model's token-meter handle and context capacity.
|
||||
* @returns a detached immutable model policy.
|
||||
*/
|
||||
export function resolveModelConfig(
|
||||
config: ResolvedConfig,
|
||||
meter: ModelTokenMeter,
|
||||
): ResolvedModelCompactConfig {
|
||||
const override = config.models[meter.model]
|
||||
const thresholdRatio = override?.thresholdRatio ?? DEFAULT_THRESHOLD_RATIO
|
||||
const retainTokens = override?.retainTokens ?? Math.floor(meter.contextWindow * DEFAULT_RETAIN_RATIO)
|
||||
assertRatio(`models.${meter.model}.thresholdRatio`, thresholdRatio)
|
||||
assertNonNegativeInteger(`models.${meter.model}.retainTokens`, retainTokens)
|
||||
const thresholdTokens = Math.floor(meter.contextWindow * thresholdRatio)
|
||||
if (retainTokens >= thresholdTokens) {
|
||||
throw new Error(
|
||||
`BasicCompactConfig: models.${meter.model}.retainTokens (${retainTokens}) must be less than threshold tokens ${thresholdTokens}`,
|
||||
)
|
||||
}
|
||||
return deepFreeze({
|
||||
model: meter.model,
|
||||
contextWindow: meter.contextWindow,
|
||||
thresholdRatio,
|
||||
retainTokens,
|
||||
})
|
||||
return deepFreeze(resolved)
|
||||
}
|
||||
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
|
||||
@@ -11,34 +11,21 @@ import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compa
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import { CONTEXT_WINDOW_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import {
|
||||
TOKEN_METER_MODEL_UNCONFIGURED,
|
||||
TokenMeterError,
|
||||
} from '@deepseek-ai/dsh-token-meter'
|
||||
import type { ModelTokenMeter } from '@deepseek-ai/dsh-token-meter'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { resolveConfig, resolveModelConfig } from './config.ts'
|
||||
import { resolveConfig } from './config.ts'
|
||||
import { compactSurfaceRegion, selectCompactableRange } from './region.ts'
|
||||
import { summarizeWithLlm } from './summarizer.ts'
|
||||
import type {
|
||||
BasicCompactConfig,
|
||||
ResolvedConfig,
|
||||
ResolvedModelCompactConfig,
|
||||
} from './types.ts'
|
||||
|
||||
export { resolveConfig, resolveModelConfig } from './config.ts'
|
||||
export { resolveConfig } from './config.ts'
|
||||
export type {
|
||||
BasicCompactConfig,
|
||||
ModelCompactConfig,
|
||||
ResolvedConfig,
|
||||
ResolvedModelCompactConfig,
|
||||
} from './types.ts'
|
||||
|
||||
/** Resolve the latest actual routed model, then the agent's configured fallback. */
|
||||
function effectiveModel(agent: Agent): string | undefined {
|
||||
return agent.session.requestHeader()?.config.model ?? agent.options.model
|
||||
}
|
||||
|
||||
/** Resolve the exact model durably routed for the latest provider request. */
|
||||
function routedModel(session: Session): string | undefined {
|
||||
const model = session.requestHeader()?.config.model
|
||||
@@ -50,17 +37,15 @@ function routedModel(session: Session): string | undefined {
|
||||
* 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 one effective
|
||||
* conversation-model meter.
|
||||
* mutation strategy stays fixed so every pricing decision uses the singleton
|
||||
* token meter.
|
||||
*/
|
||||
export class BasicCompactService extends CompactService {
|
||||
static inject = ['llm', 'tokenMeter']
|
||||
|
||||
static Config: z<BasicCompactConfig> = z.object({
|
||||
models: z.dict(z.object({
|
||||
thresholdRatio: z.number(),
|
||||
retainTokens: z.number().step(1),
|
||||
})),
|
||||
thresholdRatio: z.number().default(0.8),
|
||||
retainTokens: z.number().step(1),
|
||||
summarizationModel: z.string().default(''),
|
||||
maxTokens: z.number().step(1).min(1).default(8192),
|
||||
compactionRetries: z.number().step(1).min(0).default(1),
|
||||
@@ -68,11 +53,9 @@ export class BasicCompactService extends CompactService {
|
||||
auto: z.boolean().default(true),
|
||||
})
|
||||
|
||||
/** Resolved and validated common configuration plus named partial overrides. */
|
||||
/** Resolved and validated compaction configuration. */
|
||||
readonly config: ResolvedConfig
|
||||
|
||||
private readonly modelConfigs = new Map<string, ResolvedModelCompactConfig>()
|
||||
|
||||
constructor(ctx: Context, config: BasicCompactConfig = {}) {
|
||||
super(ctx)
|
||||
this.config = resolveConfig(config, ctx.tokenMeter)
|
||||
@@ -105,10 +88,6 @@ export class BasicCompactService extends CompactService {
|
||||
const result = await this.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(`post-step compaction failed: ${message}; continuing the turn`)
|
||||
}
|
||||
@@ -157,7 +136,7 @@ export class BasicCompactService extends CompactService {
|
||||
|
||||
/**
|
||||
* Compact for replayed post-step pressure or one provider-confirmed context
|
||||
* overflow. Both triggers price the latest durable routed request model;
|
||||
* overflow. Both triggers price the latest durable routed request envelope;
|
||||
* 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.
|
||||
@@ -172,28 +151,21 @@ export class BasicCompactService extends CompactService {
|
||||
): Promise<CompactionResult | null> {
|
||||
const model = routedModel(agent.session)
|
||||
if (model === undefined) return null
|
||||
const meter = this.ctx.tokenMeter.resolve(model)
|
||||
const policy = this._modelConfig(meter)
|
||||
const meter = this.ctx.tokenMeter
|
||||
if (trigger === 'context-overflow') {
|
||||
const surface = meter.measureSurface(agent.session)
|
||||
const range = selectCompactableRange(agent.session, surface, 0)
|
||||
const measurement = meter.measure(agent.session)
|
||||
const range = selectCompactableRange(agent.session, measurement, 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)
|
||||
const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio)
|
||||
let measurement = meter.measure(agent.session)
|
||||
if (measurement.totalTokens < threshold) return null
|
||||
|
||||
let result: CompactionResult | null = null
|
||||
for (let attempt = 0; attempt <= this.config.compactionRetries; attempt += 1) {
|
||||
const surface = meter.measureSurface(agent.session)
|
||||
if (surface.logRevision !== measurement.logRevision) {
|
||||
throw new Error(
|
||||
`compaction: pressure revision ${measurement.logRevision} does not match surface revision ${surface.logRevision}`,
|
||||
)
|
||||
}
|
||||
const range = selectCompactableRange(agent.session, surface, policy.retainTokens)
|
||||
const range = selectCompactableRange(agent.session, measurement, this.config.retainTokens)
|
||||
if (range === null) {
|
||||
/* v8 ignore else -- concrete replacement preserves a compactable checkpoint; subclass hooks cannot mutate it. */
|
||||
if (result === null) return null
|
||||
@@ -213,12 +185,12 @@ export class BasicCompactService extends CompactService {
|
||||
|
||||
/**
|
||||
* Compact one inclusive positional surface range using the effective
|
||||
* conversation model for all retention and shrink pricing. Reject an agent
|
||||
* that does not own the exact target before any resolution or mutation.
|
||||
* token meter for all retention and shrink pricing. Reject an agent that does
|
||||
* not own the exact target before any mutation.
|
||||
* @param session - session whose surface is mutated; must equal `agent.session`.
|
||||
* @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 and model resolver.
|
||||
* @param agent - owner of the target session, used by the summarizer.
|
||||
* @param signal - optional summarization cancellation signal.
|
||||
* @returns the successful durable compaction result.
|
||||
*/
|
||||
@@ -232,27 +204,11 @@ export class BasicCompactService extends CompactService {
|
||||
if (session !== agent.session) {
|
||||
throw new Error('compactRegion: agent.session must be the exact target session')
|
||||
}
|
||||
const model = effectiveModel(agent)
|
||||
if (model === undefined || model.length === 0) {
|
||||
throw new Error('compactRegion: no routed or configured conversation model is available for token pricing')
|
||||
}
|
||||
const meter = this.ctx.tokenMeter.resolve(model)
|
||||
this._modelConfig(meter)
|
||||
return compactSurfaceRegion({
|
||||
meter,
|
||||
meter: this.ctx.tokenMeter,
|
||||
summarize: (text, owner, abort) => this.summarize(text, owner, abort),
|
||||
}, session, start, end, agent, signal)
|
||||
}
|
||||
|
||||
/** Resolve and memoize one lazy default/override model policy. */
|
||||
private _modelConfig(meter: ModelTokenMeter): ResolvedModelCompactConfig {
|
||||
let modelConfig = this.modelConfigs.get(meter.model)
|
||||
if (modelConfig === undefined) {
|
||||
modelConfig = resolveModelConfig(this.config, meter)
|
||||
this.modelConfigs.set(meter.model, modelConfig)
|
||||
}
|
||||
return modelConfig
|
||||
}
|
||||
}
|
||||
|
||||
export default BasicCompactService
|
||||
@@ -10,14 +10,14 @@ import {
|
||||
toolPairingBalancedBefore,
|
||||
} from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import type { ModelTokenMeter, TokenSurfaceMeasurement } from '@deepseek-ai/dsh-token-meter'
|
||||
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: ModelTokenMeter
|
||||
readonly meter: TokenMeterService
|
||||
summarize(text: string, agent: Agent, signal?: AbortSignal): Promise<SummaryResult>
|
||||
}
|
||||
|
||||
@@ -25,16 +25,16 @@ interface RegionDependencies {
|
||||
* 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 pricedSurface - same-revision surface measurement from the conversation meter.
|
||||
* @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,
|
||||
pricedSurface: TokenSurfaceMeasurement,
|
||||
measurement: TokenMeasurement,
|
||||
retainTokens: number,
|
||||
): { start: number; end: number } | null {
|
||||
const pricedNodes = pricedSurface.nodes
|
||||
const pricedNodes = measurement.nodes
|
||||
if (pricedNodes.length === 0) return null
|
||||
|
||||
const surfaceNodes = session.surface.nodes
|
||||
@@ -115,8 +115,8 @@ export async function compactSurfaceRegion(
|
||||
try {
|
||||
// Capture after the lock event so any later durable append, including a
|
||||
// log-only one, invalidates the async selection before replacement.
|
||||
const lockedSurface = dependencies.meter.measureSurface(session)
|
||||
const selected = lockedSurface.nodes.slice(startIdx, endIdx + 1)
|
||||
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')
|
||||
@@ -125,8 +125,8 @@ export async function compactSurfaceRegion(
|
||||
const text = renderTranscript(session.events, shadowedSeqs)
|
||||
const { summary, model, maxTokens } = await dependencies.summarize(text, agent, signal)
|
||||
|
||||
const currentSurface = dependencies.meter.measureSurface(session)
|
||||
if (currentSurface.logRevision !== lockedSurface.logRevision) {
|
||||
const currentMeasurement = dependencies.meter.measure(session)
|
||||
if (currentMeasurement.logRevision !== lockedMeasurement.logRevision) {
|
||||
throw new Error('compaction: session log changed during summarization')
|
||||
}
|
||||
const framedSummary = frameSummary(summary)
|
||||
|
||||
@@ -4,18 +4,12 @@
|
||||
* @module @deepseek-ai/dsh-compact-basic/types
|
||||
*/
|
||||
|
||||
/** Optional pressure and retention policy for one metered model. */
|
||||
export interface ModelCompactConfig {
|
||||
/** Compact at this fraction of the model's configured context window. Defaults to `0.8`. */
|
||||
/** Basic compaction configuration; every common field has a deployment default. */
|
||||
export interface BasicCompactConfig {
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** Basic compaction configuration; every common field has a deployment default. */
|
||||
export interface BasicCompactConfig {
|
||||
/** Field-wise pressure/retention overrides keyed by configured token-meter model name. */
|
||||
models?: Record<string, ModelCompactConfig>
|
||||
/** Summary model; `''` resolves the latest routed model, then `AgentOptions.model`. Defaults to `''`. */
|
||||
summarizationModel?: string
|
||||
/** Provider generation cap for summarization. Defaults to `8192`. */
|
||||
@@ -28,20 +22,13 @@ export interface BasicCompactConfig {
|
||||
auto?: boolean
|
||||
}
|
||||
|
||||
/** Validated top-level defaults plus detached per-model partial overrides. */
|
||||
/** Validated and detached compaction configuration. */
|
||||
export interface ResolvedConfig {
|
||||
readonly models: Readonly<Record<string, Readonly<ModelCompactConfig>>>
|
||||
readonly thresholdRatio: number
|
||||
readonly retainTokens: number
|
||||
readonly summarizationModel: string
|
||||
readonly maxTokens: number
|
||||
readonly compactionRetries: number
|
||||
readonly maxOverflowRetries: number
|
||||
readonly auto: boolean
|
||||
}
|
||||
|
||||
/** Fully resolved pressure/retention policy for one effective model. */
|
||||
export interface ResolvedModelCompactConfig {
|
||||
readonly model: string
|
||||
readonly contextWindow: number
|
||||
readonly thresholdRatio: number
|
||||
readonly retainTokens: number
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import BasicCompactService, {
|
||||
resolveConfig,
|
||||
resolveModelConfig,
|
||||
} from '@deepseek-ai/dsh-compact-basic'
|
||||
import BasicCompactService, { resolveConfig } from '@deepseek-ai/dsh-compact-basic'
|
||||
import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic'
|
||||
import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts'
|
||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
@@ -11,22 +8,15 @@ import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import LlmService, { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import TokenMeterService, {
|
||||
TOKEN_METER_MODEL_UNCONFIGURED,
|
||||
TokenMeterError,
|
||||
} from '@deepseek-ai/dsh-token-meter'
|
||||
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
const SIGNAL = new AbortController().signal
|
||||
const MODEL = 'test-model'
|
||||
|
||||
function createContext(
|
||||
models: Record<string, { contextWindow?: number; charsPerToken?: number }> = {
|
||||
[MODEL]: { contextWindow: 100, charsPerToken: 1_000 },
|
||||
},
|
||||
): Context {
|
||||
function createContext(contextWindow = 1_000): Context {
|
||||
const ctx = new Context()
|
||||
void new TokenMeterService(ctx, { models })
|
||||
void new TokenMeterService(ctx, { contextWindow })
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -35,7 +25,7 @@ function agent(session: Session, model?: string): Agent {
|
||||
}
|
||||
|
||||
/** Closed two-message turns followed by one open turn for durable compaction events. */
|
||||
function conversation(turns = 4, text = 'fixture'): Session {
|
||||
function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
|
||||
const session = new Session(SessionId(`conversation-${turns}`))
|
||||
for (let turn = 1; turn <= turns; turn += 1) {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
@@ -140,51 +130,42 @@ async function compactIfNeeded(
|
||||
}
|
||||
|
||||
describe('compact configuration and defaults', () => {
|
||||
it('uses low-friction common and per-profile defaults', () => {
|
||||
const ctx = createContext({
|
||||
[MODEL]: { contextWindow: 100, charsPerToken: 1_000 },
|
||||
large: { contextWindow: 1_000, charsPerToken: 4 },
|
||||
})
|
||||
it('uses low-friction service-wide defaults', () => {
|
||||
const ctx = createContext()
|
||||
const resolved = resolveConfig({}, ctx.tokenMeter)
|
||||
|
||||
expect(resolved).toEqual({
|
||||
models: {},
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 160,
|
||||
summarizationModel: '',
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
maxOverflowRetries: 1,
|
||||
auto: true,
|
||||
})
|
||||
expect(resolveModelConfig(resolved, ctx.tokenMeter.resolve(MODEL))).toEqual({
|
||||
model: MODEL,
|
||||
contextWindow: 100,
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 16,
|
||||
})
|
||||
expect(resolveModelConfig(resolved, ctx.tokenMeter.resolve('large')).retainTokens).toBe(160)
|
||||
expect(Object.isFrozen(resolved)).toBe(true)
|
||||
})
|
||||
|
||||
it('merges threshold and retention overrides field-wise', () => {
|
||||
it('resolves threshold and retention overrides independently', () => {
|
||||
const ctx = createContext()
|
||||
const thresholdOnly = resolveConfig({
|
||||
models: { [MODEL]: { thresholdRatio: 0.5 } },
|
||||
}, ctx.tokenMeter)
|
||||
expect(resolveModelConfig(thresholdOnly, ctx.tokenMeter.resolve(MODEL))).toMatchObject({
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 16,
|
||||
}, ctx.tokenMeter)
|
||||
expect(thresholdOnly).toMatchObject({
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 160,
|
||||
})
|
||||
|
||||
const retentionOnly = resolveConfig({
|
||||
models: { [MODEL]: { retainTokens: 7 } },
|
||||
retainTokens: 70,
|
||||
}, ctx.tokenMeter)
|
||||
expect(resolveModelConfig(retentionOnly, ctx.tokenMeter.resolve(MODEL))).toMatchObject({
|
||||
expect(retentionOnly).toMatchObject({
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 7,
|
||||
retainTokens: 70,
|
||||
})
|
||||
})
|
||||
|
||||
it('validates common values and model policy invariants', () => {
|
||||
it('validates common values and pressure-policy invariants', () => {
|
||||
const ctx = createContext()
|
||||
const bad = [
|
||||
[{ maxTokens: 0 }, /maxTokens/],
|
||||
@@ -192,33 +173,25 @@ describe('compact configuration and defaults', () => {
|
||||
[{ maxOverflowRetries: -1 }, /maxOverflowRetries/],
|
||||
[{ auto: 'yes' }, /auto must be a boolean/],
|
||||
[{ summarizationModel: 1 }, /summarizationModel must be a string/],
|
||||
[{ models: null }, /models must be an object/],
|
||||
[{ models: { [MODEL]: null } }, /must be an object/],
|
||||
[{ models: { [MODEL]: { thresholdRatio: 0 } } }, /number in \(0, 1\]/],
|
||||
[{ models: { [MODEL]: { thresholdRatio: 1.1 } } }, /number in \(0, 1\]/],
|
||||
[{ models: { [MODEL]: { retainTokens: -1 } } }, /non-negative integer/],
|
||||
[{ models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 50 } } }, /less than threshold/],
|
||||
[{ thresholdRatio: 0 }, /number in \(0, 1\]/],
|
||||
[{ thresholdRatio: 1.1 }, /number in \(0, 1\]/],
|
||||
[{ retainTokens: -1 }, /non-negative integer/],
|
||||
[{ thresholdRatio: 0.5, retainTokens: 500 }, /less than threshold/],
|
||||
[{ models: { [MODEL]: { retainTokens: 10 } } }, /BasicCompactConfig: unknown key "models"/],
|
||||
[{ thresholdRato: 0.5 }, /BasicCompactConfig: unknown key "thresholdRato"/],
|
||||
] as Array<[unknown, RegExp]>
|
||||
|
||||
for (const [config, pattern] of bad) {
|
||||
expect(() => resolveConfig(config as BasicCompactConfig, ctx.tokenMeter)).toThrow(pattern)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an override for an unknown meter profile with the exact typed error', () => {
|
||||
const ctx = createContext()
|
||||
expect(() => resolveConfig({ models: { missing: { retainTokens: 1 } } }, ctx.tokenMeter))
|
||||
.toThrow(expect.objectContaining({
|
||||
code: TOKEN_METER_MODEL_UNCONFIGURED,
|
||||
model: 'missing',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('pressure measurement and retention', () => {
|
||||
const compactConfig: BasicCompactConfig = {
|
||||
auto: false,
|
||||
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 180,
|
||||
}
|
||||
|
||||
it('skips when no durable routed model exists instead of using AgentOptions fallback', async () => {
|
||||
@@ -230,15 +203,15 @@ describe('pressure measurement and retention', () => {
|
||||
expect(compact.calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('throws for a named unconfigured model instead of swallowing it', async () => {
|
||||
it('meters any routed model without profile resolution', async () => {
|
||||
const compact = service(compactConfig)
|
||||
const session = conversation()
|
||||
session.append('request/header', {
|
||||
header: { config: { model: 'missing' } },
|
||||
header: { config: { model: 'unlisted-model' } },
|
||||
reason: 'resume',
|
||||
})
|
||||
await expect(compactIfNeeded(compact, session))
|
||||
.rejects.toMatchObject({ code: TOKEN_METER_MODEL_UNCONFIGURED, model: 'missing' })
|
||||
.resolves.not.toBeNull()
|
||||
})
|
||||
|
||||
it('declines forced overflow when the whole surface is one indivisible tool pair', async () => {
|
||||
@@ -286,16 +259,17 @@ describe('pressure measurement and retention', () => {
|
||||
it('counts the durable routed request envelope without putting its prefix on the surface', async () => {
|
||||
const compact = service({
|
||||
auto: false,
|
||||
models: { [MODEL]: { thresholdRatio: 0.7, retainTokens: 9 } },
|
||||
thresholdRatio: 0.9,
|
||||
retainTokens: 50,
|
||||
})
|
||||
const session = conversation(2, 'x'.repeat(2_000))
|
||||
const session = conversation(2, 'x'.repeat(600))
|
||||
expect(await compactIfNeeded(compact, session)).toBeNull()
|
||||
|
||||
const prefix = [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'p'.repeat(10_000) }] }]
|
||||
const prefix = [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'p'.repeat(600) }] }]
|
||||
session.append('request/header', {
|
||||
header: {
|
||||
config: { model: MODEL },
|
||||
system: 's'.repeat(5_000),
|
||||
system: 's'.repeat(600),
|
||||
messagePrefix: prefix,
|
||||
},
|
||||
reason: 'resume',
|
||||
@@ -306,23 +280,24 @@ describe('pressure measurement and retention', () => {
|
||||
expect(session.events.some(event => event.type === 'context/message')).toBe(false)
|
||||
})
|
||||
|
||||
it('uses the latest logged routed model instead of AgentOptions.model', async () => {
|
||||
const ctx = createContext({
|
||||
actual: { contextWindow: 100, charsPerToken: 1_000 },
|
||||
fallback: { contextWindow: 10_000, charsPerToken: 1_000 },
|
||||
})
|
||||
it('uses the latest logged request envelope without an AgentOptions override', async () => {
|
||||
const ctx = createContext()
|
||||
const compact = service({
|
||||
auto: false,
|
||||
models: { actual: { thresholdRatio: 0.5, retainTokens: 18 } },
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 180,
|
||||
}, ctx)
|
||||
const session = conversation(4)
|
||||
session.append('request/header', {
|
||||
header: { config: { model: 'actual' } },
|
||||
reason: 'initial',
|
||||
})
|
||||
const measure = vi.spyOn(ctx.tokenMeter, 'measure')
|
||||
|
||||
const result = await compactIfNeeded(compact, session, 'pressure', 'fallback')
|
||||
expect(result).not.toBeNull()
|
||||
expect(session.requestHeader()?.config.model).toBe('actual')
|
||||
expect(measure.mock.calls[0]).toEqual([session])
|
||||
})
|
||||
|
||||
it('declines when envelope pressure is high but the surface has no compactable range', async () => {
|
||||
@@ -343,24 +318,23 @@ describe('pressure measurement and retention', () => {
|
||||
expect(await compactIfNeeded(compact, retained)).toBeNull()
|
||||
})
|
||||
|
||||
it('detects scalar/surface revision disagreement', async () => {
|
||||
it('uses one unified measurement for each pressure-and-retention decision', async () => {
|
||||
const ctx = createContext()
|
||||
const meter = ctx.tokenMeter.resolve(MODEL)
|
||||
const original = meter.measureSurface.bind(meter)
|
||||
vi.spyOn(meter, 'measureSurface').mockImplementation((session) => {
|
||||
const measurement = original(session)
|
||||
return { ...measurement, logRevision: measurement.logRevision - 1 }
|
||||
})
|
||||
const compact = service(compactConfig, ctx)
|
||||
const measure = vi.spyOn(ctx.tokenMeter, 'measure')
|
||||
const stop = new Error('stop after first decision')
|
||||
vi.spyOn(compact, 'compactRegion').mockRejectedValueOnce(stop)
|
||||
|
||||
await expect(compactIfNeeded(compact, conversation(4))).rejects.toThrow(/revision/)
|
||||
await expect(compactIfNeeded(compact, conversation(4))).rejects.toBe(stop)
|
||||
expect(measure).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('bounds retries when a shrinking checkpoint remains above threshold', async () => {
|
||||
const compact = service({
|
||||
auto: false,
|
||||
compactionRetries: 0,
|
||||
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
|
||||
thresholdRatio: 0.3,
|
||||
retainTokens: 180,
|
||||
})
|
||||
compact.summary = Array.from({ length: 7 }, (_, index) => ({
|
||||
type: 'text',
|
||||
@@ -374,8 +348,9 @@ describe('pressure measurement and retention', () => {
|
||||
it('rounds a retention cut head-ward to preserve tool-call/result pairing', async () => {
|
||||
const compact = service({
|
||||
auto: false,
|
||||
models: { [MODEL]: { thresholdRatio: 0.8, retainTokens: 8 } },
|
||||
})
|
||||
thresholdRatio: 0.8,
|
||||
retainTokens: 80,
|
||||
}, createContext(4_000))
|
||||
const session = toolConversation()
|
||||
const result = await compactIfNeeded(compact, session)
|
||||
expect(result).not.toBeNull()
|
||||
@@ -393,7 +368,7 @@ describe('pressure measurement and retention', () => {
|
||||
it('rejects a priced surface that is not the current positional surface', () => {
|
||||
const ctx = createContext()
|
||||
const session = conversation(2)
|
||||
const priced = ctx.tokenMeter.resolve(MODEL).measureSurface(session)
|
||||
const priced = ctx.tokenMeter.measure(session)
|
||||
expect(() => selectCompactableRange(session, {
|
||||
...priced,
|
||||
nodes: priced.nodes.slice(1),
|
||||
@@ -421,7 +396,7 @@ describe('pressure measurement and retention', () => {
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
|
||||
const priced = ctx.tokenMeter.resolve(MODEL).measureSurface(session)
|
||||
const priced = ctx.tokenMeter.measure(session)
|
||||
expect(selectCompactableRange(session, priced, 1)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -563,9 +538,9 @@ describe('compaction region transaction', () => {
|
||||
|
||||
it('rejects a meter snapshot that changed before summarization began', async () => {
|
||||
const ctx = createContext()
|
||||
const meter = ctx.tokenMeter.resolve(MODEL)
|
||||
const original = meter.measureSurface.bind(meter)
|
||||
vi.spyOn(meter, 'measureSurface').mockImplementationOnce((session) => {
|
||||
const meter = ctx.tokenMeter
|
||||
const original = meter.measure.bind(meter)
|
||||
vi.spyOn(meter, 'measure').mockImplementationOnce((session) => {
|
||||
const measurement = original(session)
|
||||
return { ...measurement, nodes: measurement.nodes.slice(1) }
|
||||
})
|
||||
@@ -635,7 +610,7 @@ describe('compaction region transaction', () => {
|
||||
|
||||
it('rejects a non-shrinking framed summary under the conversation meter', async () => {
|
||||
const compact = service()
|
||||
compact.summary = Array.from({ length: 20 }, (_, index) => ({
|
||||
compact.summary = Array.from({ length: 100 }, (_, index) => ({
|
||||
type: 'text',
|
||||
text: `verbose ${index}`,
|
||||
}))
|
||||
@@ -651,19 +626,19 @@ describe('compaction region transaction', () => {
|
||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
|
||||
})
|
||||
|
||||
it('requires a conversation model for pricing', async () => {
|
||||
it('lets a model-independent custom summarizer compact without a conversation model', async () => {
|
||||
const compact = service()
|
||||
const session = new Session(SessionId('model-less-region'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'history' }],
|
||||
content: [{ type: 'text', text: 'history '.repeat(100) }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: 'answer' }],
|
||||
content: [{ type: 'text', text: 'answer '.repeat(100) }],
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
const nodes = session.surface.nodes
|
||||
@@ -672,7 +647,7 @@ describe('compaction region transaction', () => {
|
||||
nodes[0]!.seq,
|
||||
nodes[1]!.seq,
|
||||
agent(session),
|
||||
)).rejects.toThrow(/no routed or configured conversation model/)
|
||||
)).resolves.toMatchObject({ shadowedSeqs: [nodes[0]!.seq, nodes[1]!.seq] })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -710,7 +685,7 @@ async function summarizerHarness(
|
||||
): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: BasicCompactService }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
void new TokenMeterService(ctx, { models: { [model]: { contextWindow: 100 } } })
|
||||
void new TokenMeterService(ctx, { contextWindow: 1_000 })
|
||||
const adapter = new ScriptedAdapter(blocks, finish)
|
||||
ctx.llm.registerAdapter([model], adapter)
|
||||
const compact = new BasicCompactService(ctx, config)
|
||||
@@ -836,7 +811,8 @@ describe('automatic listener and loader composition', () => {
|
||||
it('compacts post-step above threshold using the durable routed model and remains idle below it', async () => {
|
||||
const ctx = createContext()
|
||||
const compact = new TestCompactService(ctx, {
|
||||
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 180,
|
||||
})
|
||||
const pressured = conversation(4)
|
||||
await postStep(ctx, agent(pressured, 'unconfigured-agent-fallback'))
|
||||
@@ -851,7 +827,8 @@ describe('automatic listener and loader composition', () => {
|
||||
it('skips post-step pressure when the step signal is already aborted', async () => {
|
||||
const ctx = createContext()
|
||||
const compact = new TestCompactService(ctx, {
|
||||
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 180,
|
||||
})
|
||||
const pressured = conversation(4)
|
||||
const compactIfNeeded = vi.spyOn(compact, 'compactIfNeeded')
|
||||
@@ -868,7 +845,8 @@ describe('automatic listener and loader composition', () => {
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: string) => void warnings.push(message)) as typeof ctx.logger.warn
|
||||
const compact = new TestCompactService(ctx, {
|
||||
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 180,
|
||||
})
|
||||
compact.error = 'temporary failure'
|
||||
const session = conversation(4)
|
||||
@@ -878,30 +856,17 @@ describe('automatic listener and loader composition', () => {
|
||||
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
|
||||
})
|
||||
|
||||
it('propagates a named unknown-model configuration failure', async () => {
|
||||
const ctx = createContext()
|
||||
void new TestCompactService(ctx)
|
||||
const session = conversation(4)
|
||||
session.append('request/header', {
|
||||
header: { config: { model: 'missing' } },
|
||||
reason: 'resume',
|
||||
})
|
||||
await expect(postStep(ctx, agent(session, MODEL))).rejects.toMatchObject({
|
||||
code: TOKEN_METER_MODEL_UNCONFIGURED,
|
||||
model: 'missing',
|
||||
})
|
||||
})
|
||||
|
||||
it('force-compacts below normal pressure for canonical overflow and retries only after replacement', async () => {
|
||||
const ctx = createContext()
|
||||
const ctx = createContext(10_000)
|
||||
void new TestCompactService(ctx, {
|
||||
models: { [MODEL]: { thresholdRatio: 1, retainTokens: 90 } },
|
||||
thresholdRatio: 1,
|
||||
retainTokens: 900,
|
||||
})
|
||||
const session = conversation(3)
|
||||
const beforeGeneration = session.surface.replaceGeneration
|
||||
const retainedSeq = session.surface.nodes.at(-1)!.seq
|
||||
const threshold = 100
|
||||
expect(ctx.tokenMeter.resolve(MODEL).measure(session).totalTokens).toBeLessThan(threshold)
|
||||
const threshold = 10_000
|
||||
expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(threshold)
|
||||
const decision = await recover(ctx, agent(session, 'unconfigured-agent-fallback'), overflow())
|
||||
|
||||
expect(decision).toEqual({ action: 'retry' })
|
||||
@@ -913,7 +878,8 @@ describe('automatic listener and loader composition', () => {
|
||||
it('preserves the newest whole tool-call/result pair during forced overflow compaction', async () => {
|
||||
const ctx = createContext()
|
||||
void new TestCompactService(ctx, {
|
||||
models: { [MODEL]: { thresholdRatio: 1, retainTokens: 90 } },
|
||||
thresholdRatio: 1,
|
||||
retainTokens: 90,
|
||||
})
|
||||
const session = toolConversation()
|
||||
const newestAssistant = session.surface.nodes.at(-2)!
|
||||
@@ -1010,7 +976,7 @@ describe('automatic listener and loader composition', () => {
|
||||
expect(warnings).toContainEqual(expect.stringContaining('non-error recovery failure'))
|
||||
})
|
||||
|
||||
it('delegates once and preserves the original overflow for an unknown routed meter model', async () => {
|
||||
it('recovers an overflow for an unlisted routed model', async () => {
|
||||
const ctx = createContext()
|
||||
void new TestCompactService(ctx)
|
||||
const session = conversation(2)
|
||||
@@ -1018,19 +984,8 @@ describe('automatic listener and loader composition', () => {
|
||||
header: { config: { model: 'unknown-routed-model' } },
|
||||
reason: 'resume',
|
||||
})
|
||||
const original = overflow('original unknown-model overflow')
|
||||
let delegations = 0
|
||||
|
||||
const decision = await recover(ctx, agent(session, MODEL), original, 0, SIGNAL, () => {
|
||||
delegations += 1
|
||||
return Promise.resolve({ action: 'fail' })
|
||||
})
|
||||
expect(decision).toEqual({ action: 'fail' })
|
||||
expect(delegations).toBe(1)
|
||||
expect(original).toMatchObject({
|
||||
message: 'original unknown-model overflow',
|
||||
code: CONTEXT_WINDOW_EXCEEDED_CODE,
|
||||
})
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow('unlisted-model overflow')))
|
||||
.toEqual({ action: 'retry' })
|
||||
})
|
||||
|
||||
it('honors retry caps, non-context failures, and cancellation', async () => {
|
||||
@@ -1065,7 +1020,8 @@ describe('automatic listener and loader composition', () => {
|
||||
const ctx = createContext()
|
||||
void new TestCompactService(ctx, {
|
||||
maxOverflowRetries: 0,
|
||||
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 180,
|
||||
})
|
||||
const session = conversation(4)
|
||||
await postStep(ctx, agent(session, MODEL))
|
||||
@@ -1079,7 +1035,8 @@ describe('automatic listener and loader composition', () => {
|
||||
const ctx = createContext()
|
||||
void new TestCompactService(ctx, {
|
||||
auto: false,
|
||||
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 180,
|
||||
})
|
||||
const session = conversation(4)
|
||||
await postStep(ctx, agent(session, MODEL))
|
||||
@@ -1093,7 +1050,7 @@ describe('automatic listener and loader composition', () => {
|
||||
const meterFiber = await ctx.plugin(TokenMeterService)
|
||||
const compactFiber = await ctx.plugin(BasicCompactService, { auto: false })
|
||||
|
||||
expect(ctx.tokenMeter.resolve('deepseek-v4-flash').contextWindow).toBe(128_000)
|
||||
expect(ctx.tokenMeter.contextWindow).toBe(128_000)
|
||||
expect(ctx.get('compact')).toBeInstanceOf(BasicCompactService)
|
||||
await compactFiber.dispose()
|
||||
expect(ctx.get('compact')).toBeUndefined()
|
||||
@@ -1104,11 +1061,10 @@ describe('automatic listener and loader composition', () => {
|
||||
it('removes its automatic listener with the plugin fiber', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(TokenMeterService, {
|
||||
models: { [MODEL]: { contextWindow: 100, charsPerToken: 1_000 } },
|
||||
})
|
||||
await ctx.plugin(TokenMeterService, { contextWindow: 1_000 })
|
||||
const fiber = await ctx.plugin(TestCompactService, {
|
||||
models: { [MODEL]: { thresholdRatio: 0.5, retainTokens: 18 } },
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 180,
|
||||
})
|
||||
await fiber.dispose()
|
||||
|
||||
@@ -1118,17 +1074,3 @@ describe('automatic listener and loader composition', () => {
|
||||
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'fail' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('typed unknown-model boundary', () => {
|
||||
it('uses TokenMeterError identity rather than message matching', () => {
|
||||
const ctx = createContext()
|
||||
let thrown: unknown
|
||||
try {
|
||||
ctx.tokenMeter.resolve('missing')
|
||||
} catch (error: unknown) {
|
||||
thrown = error
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(TokenMeterError)
|
||||
expect(thrown).toMatchObject({ code: TOKEN_METER_MODEL_UNCONFIGURED })
|
||||
})
|
||||
})
|
||||
@@ -101,9 +101,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeterService, {
|
||||
models: { mock: { contextWindow: 64, charsPerToken: 1_000 } },
|
||||
})
|
||||
await ctx.plugin(TokenMeterService, { contextWindow: 400 })
|
||||
ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'work',
|
||||
@@ -113,11 +111,12 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
|
||||
return [{ type: 'text', text: 'work result' }]
|
||||
},
|
||||
}))
|
||||
// Tiny window so a couple of tool steps cross the threshold and compaction
|
||||
// fires within the runaway turn.
|
||||
// Small window so several tool steps cross the threshold and compaction
|
||||
// fires within the runaway turn after enough history can shrink.
|
||||
const compact = new ReproCompactService(ctx, {
|
||||
auto: true,
|
||||
models: { mock: { thresholdRatio: 0.5, retainTokens: 20 } },
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 50,
|
||||
summarizationModel: '',
|
||||
maxTokens: 8192,
|
||||
compactionRetries: 1,
|
||||
@@ -159,7 +158,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
})
|
||||
|
||||
it('runs automatic pressure after the current tool result and before step/end', async () => {
|
||||
const { ctx } = await harness(4)
|
||||
const { ctx } = await harness(8)
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(AgentId('post-step-order'), { model: 'mock' })
|
||||
agent.send([{ type: 'text', text: 'do tool work' }])
|
||||
@@ -230,13 +229,12 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(TokenMeterService, {
|
||||
models: { mock: { contextWindow: 128, charsPerToken: 4 } },
|
||||
})
|
||||
await ctx.plugin(TokenMeterService, { contextWindow: 128 })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, model: 'mock' }))
|
||||
await ctx.plugin(BasicCompactService, {
|
||||
models: { mock: { thresholdRatio: 1, retainTokens: 100 } },
|
||||
thresholdRatio: 1,
|
||||
retainTokens: 100,
|
||||
maxTokens: 64,
|
||||
compactionRetries: 0,
|
||||
maxOverflowRetries: 1,
|
||||
|
||||
@@ -20,47 +20,75 @@ afterEach(async () => {
|
||||
root = undefined
|
||||
})
|
||||
|
||||
async function loadYaml(lines: readonly string[]): Promise<Context> {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-token-meter-loader-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [...lines, ''].join('\n'))
|
||||
|
||||
context = new Context()
|
||||
context.baseUrl = pathToFileURL(root).href + '/'
|
||||
await context.plugin(Loader)
|
||||
context.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-llm', LlmService],
|
||||
['@deepseek-ai/dsh-token-meter', TokenMeterService],
|
||||
['@deepseek-ai/dsh-compact-basic', BasicCompactService],
|
||||
])
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
await context.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(configPath).href },
|
||||
})
|
||||
await context.loader.await()
|
||||
return context
|
||||
}
|
||||
|
||||
describe('real Loader composition', () => {
|
||||
it('loads the zero-config token-meter then compact-basic YAML pair', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'dsh-token-meter-loader-'))
|
||||
const configPath = join(root, 'cordis.yml')
|
||||
await writeFile(configPath, [
|
||||
it('loads the flat token-meter and compact-basic YAML shape', async () => {
|
||||
const loaded = await loadYaml([
|
||||
"- name: '@deepseek-ai/dsh-llm'",
|
||||
"- name: '@deepseek-ai/dsh-token-meter'",
|
||||
' config:',
|
||||
' contextWindow: 4096',
|
||||
"- name: '@deepseek-ai/dsh-compact-basic'",
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
context = new Context()
|
||||
context.baseUrl = pathToFileURL(root).href + '/'
|
||||
await context.plugin(Loader)
|
||||
context.loader.builtins.include = Include
|
||||
const modules = new Map<string, unknown>([
|
||||
['@deepseek-ai/dsh-llm', LlmService],
|
||||
['@deepseek-ai/dsh-token-meter', TokenMeterService],
|
||||
['@deepseek-ai/dsh-compact-basic', BasicCompactService],
|
||||
' config:',
|
||||
' thresholdRatio: 0.5',
|
||||
' retainTokens: 512',
|
||||
' auto: false',
|
||||
])
|
||||
context.loader.internal = {
|
||||
version: 'v2',
|
||||
async import(specifier: string) {
|
||||
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
|
||||
return modules.get(specifier)
|
||||
},
|
||||
} as unknown as NonNullable<typeof context.loader.internal>
|
||||
await context.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(configPath).href },
|
||||
})
|
||||
await context.loader.await()
|
||||
|
||||
const unloaded = [...context.loader.entries()]
|
||||
const unloaded = [...loaded.loader.entries()]
|
||||
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
.map(entry => entry.options.name)
|
||||
expect(unloaded).toEqual([])
|
||||
expect(context.tokenMeter.resolve('deepseek-v4-flash')).toMatchObject({
|
||||
contextWindow: 128_000,
|
||||
charsPerToken: 4,
|
||||
expect(loaded.tokenMeter.contextWindow).toBe(4096)
|
||||
expect(loaded.get('compact')).toBeInstanceOf(BasicCompactService)
|
||||
expect((loaded.compact as BasicCompactService).config).toMatchObject({
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 512,
|
||||
auto: false,
|
||||
})
|
||||
expect(context.get('compact')).toBeInstanceOf(BasicCompactService)
|
||||
})
|
||||
|
||||
it('rejects stale token-meter config after Schemastery normalization', async () => {
|
||||
context = new Context()
|
||||
await expect(context.plugin(TokenMeterService, {
|
||||
models: { legacy: { contextWindow: 4096 } },
|
||||
} as never)).rejects.toThrow(/TokenMeterConfig: unknown key "models"/)
|
||||
})
|
||||
|
||||
it('rejects stale compact-basic config after Schemastery normalization', async () => {
|
||||
context = new Context()
|
||||
await context.plugin(LlmService)
|
||||
await context.plugin(TokenMeterService)
|
||||
await expect(context.plugin(BasicCompactService, {
|
||||
models: { legacy: { thresholdRatio: 0.5 } },
|
||||
} as never)).rejects.toThrow(/BasicCompactConfig: unknown key "models"/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user