Merge latest origin/master into compact-post-step-overflow-recovery

# Conflicts:
#	docs/agent-lifecycle.md
#	docs/architecture.md
#	docs/cookbook/extension-cookbook.i18n.yaml
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/event-producer-consumer.md
#	docs/rfc/INDEX.md
#	docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.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
#	docs/rfc/implemented/feature/2026-07-07-session-prefix.md
#	packages/compact/compact-basic/README.md
#	packages/compact/compact-basic/src/config.ts
#	packages/compact/compact-basic/src/index.ts
#	packages/compact/compact-basic/src/summarizer.ts
#	packages/compact/compact-basic/tests/compact-basic.spec.ts
#	packages/compact/compact-basic/tests/compact-loop-repro.spec.ts
#	packages/compact/compact/README.md
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/src/loop.ts
#	packages/core/agent-loop/tests/cancel.spec.ts
#	packages/llm/llm-deepseek/src/adapter.ts
#	packages/llm/llm-pi-ai/README.md
#	packages/llm/llm-pi-ai/src/stream.ts
#	packages/llm/llm-pi-ai/tests/convert.spec.ts
#	packages/llm/llm/README.md
#	packages/llm/llm/src/index.ts
#	packages/llm/llm/tests/service.spec.ts
#	scripts/gen-doc-graphs.ts
This commit is contained in:
Tianyi Cui
2026-07-19 12:06:23 +08:00
814 files changed
+42348 -10067

No files matched your search

+6 -5
View File
@@ -11,13 +11,13 @@ This backend owns the compaction policy:
- **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.
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, 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.
- **Lifecycle** — `compactRegion()` mutates `agent.session` and 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; overflow-recovery failure preserves the original provider error.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no replacement landed. A region failure records an error end and leaves the surface unchanged. 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 `ctx.tokenMeter`. The hook returns the summary blocks together with the call envelope it used (`{ summary, model, maxTokens? }`), which is logged on `compact/summary`.
The protected `summarize()` method 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, provider, model, maxTokens? }`), which is logged on `compact/summary`.
## Config (`BasicCompactConfig`)
@@ -27,7 +27,8 @@ Every setting is optional. The pressure and retention policy applies to the toke
|---|---|---|
| `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`. |
| `summarizationProvider` | no (default `''`) | Set together with `summarizationModel`; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. |
| `summarizationModel` | no (default `''`) | Set together with `summarizationProvider`; an empty pair resolves the latest logged request target, then the `AgentOptions` pair. |
| `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. |
| `maxOverflowRetries` | no (default `1`) | Maximum retries after canonical context-window overflow; `0` disables recovery only. |
+1 -1
View File
@@ -37,11 +37,11 @@
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
+12 -2
View File
@@ -18,6 +18,7 @@ const DEFAULT_RETAIN_RATIO = 0.16
const BASIC_COMPACT_CONFIG_KEYS: ReadonlySet<string> = new Set([
'thresholdRatio',
'retainTokens',
'summarizationProvider',
'summarizationModel',
'maxTokens',
'compactionRetries',
@@ -31,8 +32,8 @@ function validateConfigKeys(config: BasicCompactConfig): void {
if (!BASIC_COMPACT_CONFIG_KEYS.has(key)) {
throw new Error(
`BasicCompactConfig: unknown key "${key}" `
+ '(allowed: thresholdRatio, retainTokens, summarizationModel, maxTokens, '
+ 'compactionRetries, maxOverflowRetries, auto)',
+ '(allowed: thresholdRatio, retainTokens, summarizationProvider, summarizationModel, '
+ 'maxTokens, compactionRetries, maxOverflowRetries, auto)',
)
}
}
@@ -55,6 +56,7 @@ export function resolveConfig(
const resolved: ResolvedConfig = {
thresholdRatio,
retainTokens,
summarizationProvider: config.summarizationProvider ?? '',
summarizationModel: config.summarizationModel ?? '',
maxTokens: config.maxTokens ?? 8192,
compactionRetries: config.compactionRetries ?? 1,
@@ -73,9 +75,17 @@ export function resolveConfig(
assertPositiveInteger('maxTokens', resolved.maxTokens)
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
assertNonNegativeInteger('maxOverflowRetries', resolved.maxOverflowRetries)
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')
}
+8 -13
View File
@@ -20,7 +20,6 @@ import type {
ResolvedConfig,
} from './types.ts'
export { resolveConfig } from './config.ts'
export type {
BasicCompactConfig,
ResolvedConfig,
@@ -46,6 +45,7 @@ export class BasicCompactService extends CompactService {
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),
@@ -126,11 +126,11 @@ export class BasicCompactService extends CompactService {
* @param signal - optional cancellation forwarded to the adapter.
* @returns safe text summary blocks and exact auxiliary-call provenance.
*/
async summarize(
protected async summarize(
text: string,
agent: Agent,
signal?: AbortSignal,
): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> {
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
return summarizeWithLlm(this.ctx, this.config, text, agent, signal)
}
@@ -156,7 +156,7 @@ export class BasicCompactService extends CompactService {
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)
return this.compactRegion(range.start, range.end, agent, signal)
}
const threshold = Math.floor(meter.contextWindow * this.config.thresholdRatio)
@@ -172,7 +172,7 @@ export class BasicCompactService extends CompactService {
/* v8 ignore next -- paired with the defensive post-success branch above. */
break
}
result = await this.compactRegion(agent.session, range.start, range.end, agent, signal)
result = await this.compactRegion(range.start, range.end, agent, signal)
measurement = meter.measure(agent.session)
if (measurement.totalTokens < threshold) return result
}
@@ -184,10 +184,8 @@ export class BasicCompactService extends CompactService {
}
/**
* Compact one inclusive positional surface range using the effective
* 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`.
* 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.
@@ -195,15 +193,12 @@ export class BasicCompactService extends CompactService {
* @returns the successful durable compaction result.
*/
override async compactRegion(
session: Session,
start: number,
end: number,
agent: Agent,
signal?: AbortSignal,
): Promise<CompactionResult> {
if (session !== agent.session) {
throw new Error('compactRegion: agent.session must be the exact target session')
}
const session = agent.session
return compactSurfaceRegion({
meter: this.ctx.tokenMeter,
summarize: (text, owner, abort) => this.summarize(text, owner, abort),
+7 -6
View File
@@ -39,7 +39,7 @@ export function selectCompactableRange(
const surfaceNodes = session.surface.nodes
if (surfaceNodes.length !== pricedNodes.length
|| surfaceNodes.some((node, index) => node.seq !== pricedNodes[index]?.seq)) {
|| surfaceNodes.some((seq, index) => seq !== pricedNodes[index]?.seq)) {
throw new Error('compaction: token-meter surface does not match the current session surface')
}
@@ -64,7 +64,7 @@ export function selectCompactableRange(
const first = surfaceNodes[0]!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const cutoff = surfaceNodes[keepFromIdx - 1]!
return { start: first.seq, end: cutoff.seq }
return { start: first, end: cutoff }
}
/**
@@ -86,8 +86,8 @@ export async function compactSurfaceRegion(
signal?: AbortSignal,
): Promise<CompactionResult> {
const nodes = session.surface.nodes
const startIdx = nodes.findIndex(node => node.seq === start)
const endIdx = nodes.findIndex(node => node.seq === end)
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) {
@@ -110,7 +110,7 @@ export async function compactSurfaceRegion(
throw new Error('compactRegion: no open turn — compaction events must be enclosed in a turn')
}
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1).map(node => node.seq)
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
@@ -123,7 +123,7 @@ export async function compactSurfaceRegion(
}
const shadowedTokenCount = selected.reduce((total, node) => total + node.tokens, 0)
const text = renderTranscript(session.events, shadowedSeqs)
const { summary, model, maxTokens } = await dependencies.summarize(text, agent, signal)
const { summary, provider, model, maxTokens } = await dependencies.summarize(text, agent, signal)
const currentMeasurement = dependencies.meter.measure(session)
if (currentMeasurement.logRevision !== lockedMeasurement.logRevision) {
@@ -145,6 +145,7 @@ export async function compactSurfaceRegion(
shadowedRange: { start, end },
shadowedSeqs,
shadowedTokenCount,
provider,
model,
...maxTokens === undefined ? {} : { maxTokens },
})
@@ -58,6 +58,7 @@ const CHECKPOINT_PREAMBLE =
/** Safe summary content plus the exact auxiliary call envelope recorded in provenance. */
export interface SummaryResult {
summary: ContentBlock[]
provider: string
model: string
maxTokens?: number
}
@@ -78,17 +79,27 @@ export async function summarizeWithLlm(
agent: Agent,
signal?: AbortSignal,
): Promise<SummaryResult> {
const latestModel = agent.session.requestHeader()?.config.model
const model = config.summarizationModel || latestModel || agent.options.model || ''
if (model.length === 0) {
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 model available for summarization: set BasicCompactConfig.summarizationModel, route one request, or set AgentOptions.model',
'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 = {
model,
provider: target.provider,
model: target.model,
messages: [{
role: 'user',
content: [{ type: 'text', text: `Summarize this conversation history:\n\n${text}\n\nSummary:` }],
@@ -106,7 +117,12 @@ 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: options.model, maxTokens: config.maxTokens }
return {
summary,
provider: options.provider,
model: options.model,
maxTokens: config.maxTokens,
}
}
/**
+4 -1
View File
@@ -10,7 +10,9 @@ export interface BasicCompactConfig {
thresholdRatio?: number
/** Recent surface tokens retained verbatim. Defaults to `floor(contextWindow * 0.16)`. */
retainTokens?: number
/** Summary model; `''` resolves the latest routed model, then `AgentOptions.model`. Defaults to `''`. */
/** 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
@@ -26,6 +28,7 @@ export interface BasicCompactConfig {
export interface ResolvedConfig {
readonly thresholdRatio: number
readonly retainTokens: number
readonly summarizationProvider: string
readonly summarizationModel: string
readonly maxTokens: number
readonly compactionRetries: number
@@ -1,9 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import BasicCompactService, { resolveConfig } from '@deepseek-ai/dsh-compact-basic'
import BasicCompactService 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'
import { resolveConfig } from '@deepseek-ai/dsh-compact-basic/src/config.ts'
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'
@@ -21,7 +22,7 @@ function createContext(contextWindow = 1_000): Context {
}
function agent(session: Session, model?: string): Agent {
return { session, options: model === undefined ? {} : { model } } as Agent
return { session, options: model === undefined ? {} : { provider: model, model } } as Agent
}
/** Closed two-message turns followed by one open turn for durable compaction events. */
@@ -36,11 +37,12 @@ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
session.append('step/start', { turn, step: 1 })
if (turn === 1) {
session.append('request/header', {
header: { config: { model: MODEL } },
header: { config: { provider: MODEL, model: MODEL } },
reason: 'initial',
})
}
session.append('assistant/message', {
provenance: { provider: MODEL, model: MODEL },
turn,
step: 1,
content: [{ type: 'text', text: `${text} assistant ${turn}` }],
@@ -67,11 +69,12 @@ function toolConversation(): Session {
session.append('step/start', { turn, step: 1 })
if (turn === 1) {
session.append('request/header', {
header: { config: { model: MODEL } },
header: { config: { provider: MODEL, model: MODEL } },
reason: 'initial',
})
}
session.append('assistant/message', {
provenance: { provider: MODEL, model: MODEL },
turn,
step: 1,
content: [
@@ -96,6 +99,7 @@ function toolConversation(): Session {
class TestCompactService extends BasicCompactService {
summary: ContentBlock[] = [{ type: 'text', text: 'small checkpoint' }]
summaryProvider = 'summary-provider'
summaryModel = 'summary-model'
error: unknown
mutateDuringSummary: (() => void) | undefined
@@ -105,11 +109,16 @@ class TestCompactService extends BasicCompactService {
text: string,
_agent: Agent,
signal?: AbortSignal,
): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> {
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
this.calls.push({ text, signal })
this.mutateDuringSummary?.()
if (this.error !== undefined) throw this.error
return { summary: this.summary, model: this.summaryModel, maxTokens: 123 }
return {
summary: this.summary,
provider: this.summaryProvider,
model: this.summaryModel,
maxTokens: 123,
}
}
}
@@ -137,6 +146,7 @@ describe('compact configuration and defaults', () => {
expect(resolved).toEqual({
thresholdRatio: 0.8,
retainTokens: 160,
summarizationProvider: '',
summarizationModel: '',
maxTokens: 8192,
compactionRetries: 1,
@@ -172,7 +182,10 @@ describe('compact configuration and defaults', () => {
[{ compactionRetries: -1 }, /compactionRetries/],
[{ maxOverflowRetries: -1 }, /maxOverflowRetries/],
[{ auto: 'yes' }, /auto must be a boolean/],
[{ summarizationProvider: 1 }, /summarizationProvider must be a string/],
[{ summarizationModel: 1 }, /summarizationModel must be a string/],
[{ summarizationProvider: MODEL }, /must both be set or both be empty/],
[{ summarizationModel: MODEL }, /must both be set or both be empty/],
[{ thresholdRatio: 0 }, /number in \(0, 1\]/],
[{ thresholdRatio: 1.1 }, /number in \(0, 1\]/],
[{ retainTokens: -1 }, /non-negative integer/],
@@ -207,7 +220,7 @@ describe('pressure measurement and retention', () => {
const compact = service(compactConfig)
const session = conversation()
session.append('request/header', {
header: { config: { model: 'unlisted-model' } },
header: { config: { provider: 'unlisted-provider', model: 'unlisted-model' } },
reason: 'resume',
})
await expect(compactIfNeeded(compact, session))
@@ -221,10 +234,11 @@ describe('pressure measurement and retention', () => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('request/header', {
header: { config: { model: MODEL } },
header: { config: { provider: MODEL, model: MODEL } },
reason: 'initial',
})
session.append('assistant/message', {
provenance: { provider: MODEL, model: MODEL },
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }],
@@ -268,7 +282,7 @@ describe('pressure measurement and retention', () => {
const prefix = [{ role: 'user' as const, content: [{ type: 'text' as const, text: 'p'.repeat(600) }] }]
session.append('request/header', {
header: {
config: { model: MODEL },
config: { provider: MODEL, model: MODEL },
system: 's'.repeat(600),
messagePrefix: prefix,
},
@@ -289,7 +303,7 @@ describe('pressure measurement and retention', () => {
}, ctx)
const session = conversation(4)
session.append('request/header', {
header: { config: { model: 'actual' } },
header: { config: { provider: 'actual', model: 'actual' } },
reason: 'initial',
})
const measure = vi.spyOn(ctx.tokenMeter, 'measure')
@@ -305,14 +319,14 @@ describe('pressure measurement and retention', () => {
const empty = new Session(SessionId('empty'))
empty.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
empty.append('request/header', {
header: { config: { model: MODEL }, system: 'x'.repeat(100_000) },
header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) },
reason: 'initial',
})
expect(await compactIfNeeded(compact, empty)).toBeNull()
const retained = conversation(1)
retained.append('request/header', {
header: { config: { model: MODEL }, system: 'x'.repeat(100_000) },
header: { config: { provider: MODEL, model: MODEL }, system: 'x'.repeat(100_000) },
reason: 'resume',
})
expect(await compactIfNeeded(compact, retained)).toBeNull()
@@ -382,6 +396,7 @@ describe('pressure measurement and retention', () => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
provenance: { provider: MODEL, model: MODEL },
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }],
@@ -402,39 +417,18 @@ describe('pressure measurement and retention', () => {
})
describe('compaction region transaction', () => {
it('rejects an agent that does not own the exact target session before mutation', async () => {
const compact = service()
const target = conversation(2)
const owner = conversation(1)
const targetEvents = [...target.events]
const ownerEvents = [...owner.events]
const nodes = target.surface.nodes
await expect(compact.compactRegion(
target,
nodes[0]!.seq,
nodes[1]!.seq,
agent(owner),
)).rejects.toThrow('compactRegion: agent.session must be the exact target session')
expect(target.events).toEqual(targetEvents)
expect(owner.events).toEqual(ownerEvents)
expect(compact.calls).toEqual([])
})
it('lands a framed, replayable checkpoint with exact pricing provenance', async () => {
const compact = service()
const session = conversation(3)
const before = session.surface.nodes
const result = await compact.compactRegion(
session,
before[0]!.seq,
before[3]!.seq,
before[0]!,
before[3]!,
agent(session, MODEL),
SIGNAL,
)
expect(result.shadowedSeqs).toEqual(before.slice(0, 4).map(node => node.seq))
expect(result.shadowedSeqs).toEqual(before.slice(0, 4))
expect(result.shadowedTokenCount).toBeGreaterThan(0)
expect(compact.calls[0]).toMatchObject({ signal: SIGNAL })
expect(compact.calls[0]?.text).toContain('fixture user 1')
@@ -442,6 +436,7 @@ describe('compaction region transaction', () => {
expect(summary?.data).toMatchObject({
shadowedSeqs: result.shadowedSeqs,
shadowedTokenCount: result.shadowedTokenCount,
provider: 'summary-provider',
model: 'summary-model',
maxTokens: 123,
})
@@ -462,9 +457,8 @@ describe('compaction region transaction', () => {
const session = conversation(2)
const nodes = session.surface.nodes
await expect(compact.compactRegion(
session,
startOverride ?? nodes[0]!.seq,
endOverride ?? nodes[1]!.seq,
startOverride ?? nodes[0]!,
endOverride ?? nodes[1]!,
agent(session, MODEL),
)).rejects.toThrow(pattern)
})
@@ -474,24 +468,21 @@ describe('compaction region transaction', () => {
const plain = conversation(2)
const nodes = plain.surface.nodes
await expect(compact.compactRegion(
plain,
nodes[2]!.seq,
nodes[1]!.seq,
nodes[2]!,
nodes[1]!,
agent(plain, MODEL),
)).rejects.toThrow(/is after end/)
const tools = toolConversation()
const toolNodes = tools.surface.nodes
await expect(compact.compactRegion(
tools,
toolNodes[2]!.seq,
toolNodes[4]!.seq,
toolNodes[2]!,
toolNodes[4]!,
agent(tools, MODEL),
)).rejects.toThrow(/start seq .* not a balanced boundary/)
await expect(compact.compactRegion(
tools,
toolNodes[0]!.seq,
toolNodes[1]!.seq,
toolNodes[0]!,
toolNodes[1]!,
agent(tools, MODEL),
)).rejects.toThrow(/end seq .* not a balanced boundary/)
})
@@ -502,9 +493,8 @@ describe('compaction region transaction', () => {
closed.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
const nodes = closed.surface.nodes
await expect(compact.compactRegion(
closed,
nodes[0]!.seq,
nodes[1]!.seq,
nodes[0]!,
nodes[1]!,
agent(closed, MODEL),
)).rejects.toThrow(/no open turn/)
@@ -512,9 +502,8 @@ describe('compaction region transaction', () => {
locked.append('compact/start', { turn: 2 })
const lockedNodes = locked.surface.nodes
await expect(compact.compactRegion(
locked,
lockedNodes[0]!.seq,
lockedNodes[1]!.seq,
lockedNodes[0]!,
lockedNodes[1]!,
agent(locked, MODEL),
)).rejects.toThrow(/already in progress/)
})
@@ -529,9 +518,8 @@ describe('compaction region transaction', () => {
const node = session.surface.nodes[0]!
await expect(compact.compactRegion(
session,
node.seq,
node.seq,
node,
node,
agent(session, MODEL),
)).rejects.toThrow(/no open turn/)
})
@@ -549,9 +537,8 @@ describe('compaction region transaction', () => {
const nodes = session.surface.nodes
await expect(compact.compactRegion(
session,
nodes[0]!.seq,
nodes[2]!.seq,
nodes[0]!,
nodes[2]!,
agent(session, MODEL),
)).rejects.toThrow(/selected surface changed/)
})
@@ -563,9 +550,8 @@ describe('compaction region transaction', () => {
const before = session.surface.nodes
await expect(compact.compactRegion(
session,
before[0]!.seq,
before[2]!.seq,
before[0]!,
before[2]!,
agent(session, MODEL),
)).rejects.toThrow('summary unavailable')
expect(session.surface.nodes).toEqual(before)
@@ -579,9 +565,8 @@ describe('compaction region transaction', () => {
const session = conversation(2)
const nodes = session.surface.nodes
await expect(compact.compactRegion(
session,
nodes[0]!.seq,
nodes[2]!.seq,
nodes[0]!,
nodes[2]!,
agent(session, MODEL),
)).rejects.toBe('plain failure')
expect(session.events.findLast(event => event.type === 'compact/end')?.data)
@@ -593,16 +578,15 @@ describe('compaction region transaction', () => {
const session = conversation(2)
compact.mutateDuringSummary = () => {
session.append('request/header', {
header: { config: { model: MODEL } },
header: { config: { provider: MODEL, model: MODEL } },
reason: 'initial',
})
}
const nodes = session.surface.nodes
await expect(compact.compactRegion(
session,
nodes[0]!.seq,
nodes[2]!.seq,
nodes[0]!,
nodes[2]!,
agent(session, MODEL),
)).rejects.toThrow(/session log changed/)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
@@ -618,9 +602,8 @@ describe('compaction region transaction', () => {
const nodes = session.surface.nodes
await expect(compact.compactRegion(
session,
nodes[0]!.seq,
nodes[2]!.seq,
nodes[0]!,
nodes[2]!,
agent(session, MODEL),
)).rejects.toThrow(/summary is not smaller/)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
@@ -636,6 +619,7 @@ describe('compaction region transaction', () => {
}, { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
provenance: { provider: 'historical', model: 'historical' },
turn: 1,
step: 1,
content: [{ type: 'text', text: 'answer '.repeat(100) }],
@@ -643,11 +627,10 @@ describe('compaction region transaction', () => {
session.append('step/end', { turn: 1, step: 1 })
const nodes = session.surface.nodes
await expect(compact.compactRegion(
session,
nodes[0]!.seq,
nodes[1]!.seq,
nodes[0]!,
nodes[1]!,
agent(session),
)).resolves.toMatchObject({ shadowedSeqs: [nodes[0]!.seq, nodes[1]!.seq] })
)).resolves.toMatchObject({ shadowedSeqs: [nodes[0]!, nodes[1]!] })
})
})
@@ -677,18 +660,28 @@ class ScriptedAdapter extends LlmAdapter {
}
}
class ExposedCompactService extends BasicCompactService {
runSummarize(
text: string,
owner: Agent,
signal?: AbortSignal,
): Promise<{ summary: ContentBlock[]; provider: string; model: string; maxTokens?: number }> {
return this.summarize(text, owner, signal)
}
}
async function summarizerHarness(
blocks: readonly ContentBlock[],
finish?: (StreamChunk & { type: 'finish' })['reason'],
model = MODEL,
config: BasicCompactConfig = { auto: false },
): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: BasicCompactService }> {
): Promise<{ ctx: Context; adapter: ScriptedAdapter; compact: ExposedCompactService }> {
const ctx = new Context()
await ctx.plugin(LlmService)
void new TokenMeterService(ctx, { contextWindow: 1_000 })
const adapter = new ScriptedAdapter(blocks, finish)
ctx.llm.registerAdapter([model], adapter)
const compact = new BasicCompactService(ctx, config)
const compact = new ExposedCompactService(ctx, config)
return { ctx, adapter, compact }
}
@@ -700,18 +693,21 @@ describe('default one-shot summarizer', () => {
{ type: 'tool-call', id: CallId('unexpected'), name: 'x', arguments: '{}' },
], undefined, MODEL, {
auto: false,
summarizationProvider: MODEL,
summarizationModel: MODEL,
maxTokens: 321,
})
const session = conversation(1)
const output = await compact.summarize('transcript', agent(session, 'fallback'), SIGNAL)
const output = await compact.runSummarize('transcript', agent(session, 'fallback'), SIGNAL)
expect(output).toEqual({
summary: [{ type: 'text', text: 'public summary' }],
provider: MODEL,
model: MODEL,
maxTokens: 321,
})
expect(adapter.lastOptions).toMatchObject({
provider: MODEL,
model: MODEL,
maxTokens: 321,
signal: SIGNAL,
@@ -720,44 +716,49 @@ describe('default one-shot summarizer', () => {
expect(adapter.lastOptions?.system).toContain('## Primary Request and Intent')
})
it('resolves latest routed model before AgentOptions.model', async () => {
it('resolves the latest routed provider/model before the AgentOptions pair', async () => {
const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }], undefined, 'routed')
const session = conversation(1)
session.append('request/header', {
header: { config: { model: 'routed' } },
header: { config: { provider: 'routed', model: 'routed' } },
reason: 'initial',
})
const output = await compact.summarize('history', agent(session, 'fallback'))
const output = await compact.runSummarize('history', agent(session, 'fallback'))
expect(output.provider).toBe('routed')
expect(output.model).toBe('routed')
expect(adapter.lastOptions?.provider).toBe('routed')
expect(adapter.lastOptions?.model).toBe('routed')
})
it('records the model actually dispatched after one-shot stream routing', async () => {
const { ctx, compact } = await summarizerHarness([{ type: 'text', text: 'unused' }])
const routedAdapter = new ScriptedAdapter([{ type: 'text', text: 'routed summary' }])
ctx.llm.registerAdapter(['routed-summary-model'], routedAdapter)
ctx.llm.registerAdapter(['routed-summary-provider'], routedAdapter)
ctx.on('llm/stream', (options, next) => {
options.provider = 'routed-summary-provider'
options.model = 'routed-summary-model'
return next()
})
const session = conversation(3, 'large history '.repeat(500))
const nodes = session.surface.nodes
await compact.compactRegion(session, nodes[0]!.seq, nodes[3]!.seq, agent(session, MODEL), SIGNAL)
await compact.compactRegion(nodes[0]!, nodes[3]!, agent(session, MODEL), SIGNAL)
expect(session.events.findLast(event => event.type === 'compact/summary')?.data).toMatchObject({
summary: [{ type: 'text', text: 'routed summary' }],
provider: 'routed-summary-provider',
model: 'routed-summary-model',
})
expect(routedAdapter.lastOptions?.provider).toBe('routed-summary-provider')
expect(routedAdapter.lastOptions?.model).toBe('routed-summary-model')
})
it('fails clearly when no summarization model can be resolved', async () => {
it('fails clearly when no complete summarization target can be resolved', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
void new TokenMeterService(ctx)
const compact = new BasicCompactService(ctx, { auto: false })
await expect(compact.summarize('history', agent(new Session(SessionId('model-less')))))
.rejects.toThrow(/no model available for summarization/)
const compact = new ExposedCompactService(ctx, { auto: false })
await expect(compact.runSummarize('history', agent(new Session(SessionId('model-less')))))
.rejects.toThrow(/no provider\/model available for summarization/)
})
it.each([
@@ -771,7 +772,7 @@ describe('default one-shot summarizer', () => {
const { compact } = await summarizerHarness([], finish)
let thrown: unknown
try {
await compact.summarize('history', agent(conversation(1), MODEL))
await compact.runSummarize('history', agent(conversation(1), MODEL))
} catch (error: unknown) {
thrown = error
}
@@ -783,7 +784,7 @@ describe('default one-shot summarizer', () => {
it('rejects empty or reasoning-only successful output', async () => {
const { compact } = await summarizerHarness([{ type: 'reasoning', text: 'private' }])
await expect(compact.summarize('history', agent(conversation(1), MODEL)))
await expect(compact.runSummarize('history', agent(conversation(1), MODEL)))
.rejects.toThrow(/no text summary content/)
})
})
@@ -864,7 +865,7 @@ describe('automatic listener and loader composition', () => {
})
const session = conversation(3)
const beforeGeneration = session.surface.replaceGeneration
const retainedSeq = session.surface.nodes.at(-1)!.seq
const retainedSeq = session.surface.nodes.at(-1)!
const threshold = 10_000
expect(ctx.tokenMeter.measure(session).totalTokens).toBeLessThan(threshold)
const decision = await recover(ctx, agent(session, 'unconfigured-agent-fallback'), overflow())
@@ -872,7 +873,7 @@ describe('automatic listener and loader composition', () => {
expect(decision).toEqual({ action: 'retry' })
expect(session.surface.replaceGeneration).toBe(beforeGeneration + 1)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
expect(session.surface.nodes.some(node => node.seq === retainedSeq)).toBe(true)
expect(session.surface.nodes).toContain(retainedSeq)
})
it('preserves the newest whole tool-call/result pair during forced overflow compaction', async () => {
@@ -886,8 +887,8 @@ describe('automatic listener and loader composition', () => {
const newestResult = session.surface.nodes.at(-1)!
expect(await recover(ctx, agent(session, MODEL), overflow())).toEqual({ action: 'retry' })
const currentAssistant = session.surface.nodes.find(node => node.seq === newestAssistant.seq)
const currentResult = session.surface.nodes.find(node => node.seq === newestResult.seq)
const currentAssistant = session.surface.nodes.find(node => node === newestAssistant)
const currentResult = session.surface.nodes.find(node => node === newestResult)
expect(currentAssistant).toBeDefined()
expect(currentResult).toBeDefined()
expect(toolPairingBalancedBefore(session, currentAssistant!)).toBe(true)
@@ -981,7 +982,7 @@ describe('automatic listener and loader composition', () => {
void new TestCompactService(ctx)
const session = conversation(2)
session.append('request/header', {
header: { config: { model: 'unknown-routed-model' } },
header: { config: { provider: 'unknown-routed-provider', model: 'unknown-routed-model' } },
reason: 'resume',
})
expect(await recover(ctx, agent(session, MODEL), overflow('unlisted-model overflow')))
@@ -1,18 +1,17 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm'
import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic'
import TokenMeterService from '@deepseek-ai/dsh-token-meter'
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
import { SessionId, type SurfaceEvent } from '@deepseek-ai/dsh-session'
/**
* CBR-001 regression through the real loop. A replacement checkpoint has a high
@@ -22,8 +21,12 @@ import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
*/
class ReproCompactService extends BasicCompactService {
override async summarize(): Promise<{ summary: ContentBlock[]; model: string }> {
return { summary: [{ type: 'text', text: 'CHECKPOINT SUMMARY' }], model: 'stub' }
override async summarize(): Promise<{ summary: ContentBlock[]; provider: string; model: string }> {
return {
summary: [{ type: 'text', text: 'CHECKPOINT SUMMARY' }],
provider: 'mock',
model: 'stub',
}
}
}
@@ -74,7 +77,7 @@ class OverflowRecoveryAdapter extends LlmAdapter {
this.conversationRequests.push(options)
if (this.conversationRequests.length === 1) {
if (this.delivery === 'thrown') {
throw new LlmError('request too large for model context', CONTEXT_WINDOW_EXCEEDED_CODE, 400)
throw new LlmError('request too large for model context', CONTEXT_WINDOW_EXCEEDED_CODE)
}
yield {
type: 'finish',
@@ -94,12 +97,8 @@ class OverflowRecoveryAdapter extends LlmAdapter {
async function harness(toolSteps: number): Promise<{ ctx: Context; compact: ReproCompactService }> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(Invariants)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService, { contextWindow: 400 })
ctx.llm.registerAdapter(['mock'], new StepwiseToolAdapter(toolSteps))
@@ -124,7 +123,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr
return { ctx, compact }
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -138,9 +137,10 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () => {
it('uses the model actually routed by agent/request for post-step pressure', async () => {
const { ctx } = await harness(8)
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, model: 'mock' }))
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' }))
try {
const agent = ctx.agentLoop.create(AgentId('routed-pressure'), {
const agent = ctx.agentLoop.create(SessionId('routed-pressure'), {
provider: 'unconfigured-agent-fallback',
model: 'unconfigured-agent-fallback',
})
agent.send([{ type: 'text', text: 'do a routed multi-step task' }])
@@ -160,7 +160,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(8)
try {
const agent = ctx.agentLoop.create(AgentId('post-step-order'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'do tool work' }])
await waitForIdle(ctx, agent)
@@ -186,7 +186,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
it('the head checkpoint the loop lands is a balanced cut on both sides', async () => {
const { ctx } = await harness(8)
try {
const agent = ctx.agentLoop.create(AgentId('repro'), { model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' })
agent.send([{ type: 'text', text: 'do a long multi-step task' }])
await waitForIdle(ctx, agent)
@@ -203,12 +203,12 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
// its start and end cuts are balanced in surface order.
const nodes = agent.session.surface.nodes
for (const cp of checkpoints) {
const node = nodes.find(n => n.seq === cp.seq)
if (!node) continue // shadowed by a later checkpoint — no longer an edge.
expect(toolPairingBalancedBefore(agent.session, node),
`checkpoint seq ${node.seq} must be a balanced region START`).toBe(true)
expect(toolPairingBalancedAfter(agent.session, node),
`checkpoint seq ${node.seq} must be a balanced region END`).toBe(true)
const index = nodes.indexOf(cp.seq)
if (index === -1) continue // shadowed by a later checkpoint — no longer an edge.
expect(toolPairingBalancedBefore(agent.session, cp.seq),
`checkpoint seq ${cp.seq} must be a balanced region START`).toBe(true)
expect(toolPairingBalancedAfter(agent.session, cp.seq),
`checkpoint seq ${cp.seq} must be a balanced region END`).toBe(true)
}
} finally {
await ctx.fiber.dispose()
@@ -222,16 +222,12 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
async (delivery) => {
const ctx = new Context()
const adapter = new OverflowRecoveryAdapter(delivery)
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(Invariants)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TokenMeterService, { contextWindow: 128 })
ctx.llm.registerAdapter(['mock'], adapter)
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, model: 'mock' }))
ctx.on('agent/request', async (_agent, _turn, _step, config) => ({ ...config, provider: 'mock', model: 'mock' }))
await ctx.plugin(BasicCompactService, {
thresholdRatio: 1,
retainTokens: 100,
@@ -241,7 +237,8 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
})
try {
const agent = ctx.agentLoop.create(AgentId(`overflow-${delivery}`), {
const agent = ctx.agentLoop.create(SessionId(`overflow-${delivery}`), {
provider: 'unconfigured-agent-fallback',
model: 'unconfigured-agent-fallback',
})
for (let turn = 1; turn <= 2; turn += 1) {
@@ -256,6 +253,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
}, { surfaceOp: 'append' })
agent.session.append('step/start', { turn, step: 1 })
agent.session.append('assistant/message', {
provenance: { provider: 'mock', model: 'mock' },
turn,
step: 1,
content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }],