feat(session): add cross-session references

This commit is contained in:
Yichen Jiang
2026-07-21 16:46:48 +08:00
parent 9a5c81f9e5
commit 32d786c439
81 changed files with 2837 additions and 160 deletions

View File

@@ -5,6 +5,7 @@
*/
import {
COMPACT_CHECKPOINT_SOURCE,
renderTranscript,
toolPairingBalancedAfter,
toolPairingBalancedBefore,
@@ -151,7 +152,7 @@ export async function compactSurfaceRegion(
})
session.append('user/message', {
content: framedSummary,
source: { kind: 'plugin', plugin: 'compact' },
source: COMPACT_CHECKPOINT_SOURCE,
}, {
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],

View File

@@ -6,7 +6,7 @@ This package is the interface tier of the compaction capability, split so each c
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + canonical checkpoint source + tool-pairing boundary helpers + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
| `@deepseek-ai/dsh-compact-basic` | a backend: `ctx.tokenMeter` pressure + token-budget retention + `llm.stream()` summarization |
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
@@ -19,7 +19,7 @@ Both methods are **abstract** — the backend owns trigger policy, retention, ev
| Member | Semantics |
|---|---|
| `compactIfNeeded(agent, trigger, signal)` | Consider automatic compaction for `trigger: 'pressure' \| 'context-overflow'`. A pressure trigger may apply the backend's threshold and retained-tail policy; a confirmed overflow may force a useful balanced reduction. Returns the `CompactionResult`, or `null` when no safe range exists. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. |
| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node whose source is `COMPACT_CHECKPOINT_SOURCE`. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. |
`CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/core-data-structures/compaction.md#compactionresult).
@@ -38,7 +38,7 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an
1. appends `compact/start` (log-only) — acquires the lock,
2. summarizes the range,
3. appends `compact/summary` (log-only) — provenance: summary, range, shadowed seqs, token count, and provider/model call envelope,
4. appends a single `user/message` with `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation in this operation**,
4. appends a single `user/message` with `source: COMPACT_CHECKPOINT_SOURCE` and `surfaceOp: { op: 'replace', start, end }` carrying the summary — **the only surface mutation in this operation**,
5. appends `compact/end` (log-only) — releases the lock.
The surface mutation (step 4) sits **inside** the lock bracket: `compact/end` is the last event, so the lock is never released before the mutation lands. A crash between `compact/start` and `compact/end` therefore leaves a detectable orphaned lock (a `compact/start` with no matching `compact/end`) rather than a `compact/end` that falsely claims compaction finished while the surface was never shadowed.
@@ -55,7 +55,7 @@ The `compact/*` events extend `SessionEventMap` (merge-extensible) via declarati
## Implementing a backend
Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter.
Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and load the subclass as a plugin — it registers as `ctx.compact`. Every successful backend uses `COMPACT_CHECKPOINT_SOURCE` on its replacement user message; `isCompactCheckpointSource()` recognizes the marker after persistence or cloning without depending on backend identity. A template- or model-backed implementation can live as a sibling package without changing callers or the shared token meter.
## Model Experience

View File

@@ -8,6 +8,7 @@
*/
import { Context, Service } from 'cordis'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import type { CompactionResult } from './types.ts'
@@ -15,6 +16,18 @@ export type { CompactionResult } from './types.ts'
export { renderContentBlocks, renderTranscript } from './render.ts'
export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts'
/** Canonical source for the replacement user message produced by every compaction backend. */
export const COMPACT_CHECKPOINT_SOURCE = Object.freeze({ kind: 'plugin', plugin: 'compact' } as const)
/**
* Test whether a persisted message source identifies a compaction checkpoint.
* @param source - source restored from a surface user message.
* @returns whether the source carries the backend-independent checkpoint marker.
*/
export function isCompactCheckpointSource(source: MessageSource): boolean {
return source.kind === 'plugin' && source.plugin === COMPACT_CHECKPOINT_SOURCE.plugin
}
/** Why automatic policy is asking a backend to consider compaction. */
export type CompactionTrigger = 'pressure' | 'context-overflow'
@@ -34,8 +47,10 @@ declare module 'cordis' {
* Abstract compaction service. Implementations own trigger policy, retention,
* and summarization, and may consume a separate measurement service. A
* successful run replaces the selected surface span with one summary node and
* prevents concurrent compaction of the same session. Load one implementation
* per context as `ctx.compact`.
* prevents concurrent compaction of the same session. The replacement user
* message uses {@link COMPACT_CHECKPOINT_SOURCE} so consumers recognize it
* independently of the backend. Load one implementation per context as
* `ctx.compact`.
*/
export abstract class CompactService extends Service {
constructor(ctx: Context) {
@@ -67,6 +82,7 @@ export abstract class CompactService extends Service {
* balanced so assistant tool calls remain paired with their results. A model-
* backed implementation forwards cancellation and rejects active, missing,
* reversed, or unbalanced ranges. The target session is `agent.session`.
* Its replacement user message must use {@link COMPACT_CHECKPOINT_SOURCE}.
* Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}
* for the edge checks.
*

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CompactService } from '@deepseek-ai/dsh-compact'
import {
COMPACT_CHECKPOINT_SOURCE,
CompactService,
isCompactCheckpointSource,
} from '@deepseek-ai/dsh-compact'
import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { CompactAgentContext } from '@deepseek-ai/dsh-compact'
@@ -33,16 +37,28 @@ class StubCompactService extends CompactService {
this.lastSignal = signal
const session = agent.session
const summary = [{ type: 'text' as const, text: 'stub' }]
const surface = session.surface.nodes
const startIndex = surface.indexOf(start)
const endIndex = surface.indexOf(end)
if (startIndex < 0 || endIndex < startIndex) throw new Error('stub compact range is invalid')
const shadowedSeqs = surface.slice(startIndex, endIndex + 1)
// Minimal stub honoring the lock + log-only event contract.
const startEvent = session.append('compact/start', { turn: 0 })
const summaryEvent = session.append('compact/summary', {
summary,
shadowedRange: { start, end },
shadowedSeqs: [],
shadowedSeqs,
shadowedTokenCount: 0,
provider: 'mock',
model: 'stub',
})
session.append('user/message', {
content: summary,
source: COMPACT_CHECKPOINT_SOURCE,
}, {
surfaceOp: { op: 'replace', start, end },
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
})
const endEvent = session.append('compact/end', { turn: 0 })
return {
startSeq: startEvent.seq,
@@ -50,7 +66,7 @@ class StubCompactService extends CompactService {
endSeq: endEvent.seq,
summary,
shadowedRange: { start, end },
shadowedSeqs: [],
shadowedSeqs,
shadowedTokenCount: 0,
}
}
@@ -87,8 +103,12 @@ describe('CompactService seam', () => {
const ctx = new Context()
const svc = new StubCompactService(ctx)
const session = new Session(SessionId('s'))
const original = session.append('user/message', {
content: [{ type: 'text', text: 'original' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const result = await svc.compactRegion(0, 0, stubAgent(session, 'm'))
const result = await svc.compactRegion(original.seq, original.seq, stubAgent(session, 'm'))
const startEvent = session.events.find(e => e.type === 'compact/start')
expect(startEvent).toBeDefined()
@@ -99,7 +119,13 @@ describe('CompactService seam', () => {
expect(result.summary).toEqual([{ type: 'text', text: 'stub' }])
expect(result.summarySeq).toBeGreaterThan(result.startSeq)
expect(result.endSeq).toBeGreaterThan(result.summarySeq)
expect(result.shadowedRange).toEqual({ start: 0, end: 0 })
expect(result.shadowedRange).toEqual({ start: original.seq, end: original.seq })
expect(result.shadowedSeqs).toEqual([original.seq])
const checkpoint = session.events.find(event => event.type === 'user/message'
&& isCompactCheckpointSource(event.data.source))
expect(checkpoint?.type === 'user/message' && checkpoint.data.source).toEqual(COMPACT_CHECKPOINT_SOURCE)
expect(isCompactCheckpointSource({ kind: 'plugin', plugin: 'other' })).toBe(false)
expect(isCompactCheckpointSource({ kind: 'user' })).toBe(false)
expect(session.events.filter(e => e.type.startsWith('compact/')).map(e => e.type))
.toEqual(['compact/start', 'compact/summary', 'compact/end'])
})
@@ -109,8 +135,12 @@ describe('CompactService seam', () => {
const svc = new StubCompactService(ctx)
const session = new Session(SessionId('s'))
const controller = new AbortController()
const original = session.append('user/message', {
content: [{ type: 'text', text: 'original' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
await svc.compactRegion(0, 0, stubAgent(session, 'm'), controller.signal)
await svc.compactRegion(original.seq, original.seq, stubAgent(session, 'm'), controller.signal)
expect(svc.lastSignal).toBe(controller.signal)
await svc.compactIfNeeded(stubAgent(session), 'context-overflow', controller.signal)