feat(session): add cross-session references
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user