fix(session): simplify reference byte limits

This commit is contained in:
Yichen Jiang
2026-07-21 20:16:18 +08:00
parent f03b01d8bc
commit 8c6e28cef8
10 changed files with 85 additions and 67 deletions

View File

@@ -18,12 +18,11 @@ The context source is `{ kind: 'plugin', plugin: 'session-reference' }`. Its met
| Key | Default | Contract |
|---|---:|---|
| `maxReferences` | `3` | Maximum distinct source sessions in one prepared message. |
| `maxReferences` | `3` | Maximum distinct source sessions in one prepared message; must be at most `3`. |
| `candidateLimit` | `50` | Default metadata candidate count returned to a host. |
| `maxReferenceBytes` | `65536` | Maximum serialized JSON bytes for one reference object. |
| `maxTotalBytes` | `196608` | Maximum complete prompt bytes, including fixed warning and tags. |
Retention keeps compact checkpoints and the newest message before dropping older non-checkpoint units. Oversized retained text uses `dsh-retention` head/tail truncation with an exact UTF-8 omission notice. The total budget is applied to the complete rendered prompt, including escaped JSON and fixed warning text; a snapshot whose fixed data cannot fit fails with `SESSION_REFERENCE_BUDGET_EXCEEDED`.
Retention applies `maxReferenceBytes` independently to each source, keeps compact checkpoints and the newest message before dropping older non-checkpoint units, and uses `dsh-retention` head/tail truncation with an exact UTF-8 omission notice. If one source's fixed serialized fields cannot fit, preparation fails with `SESSION_REFERENCE_BUDGET_EXCEEDED` instead of returning a partial context.
## Model Experience
@@ -35,7 +34,7 @@ The model sees the current message's readable `@label` plus one same-level user-
#### Token effect
Each referenced message adds the fixed warning plus the retained serialized snapshots, bounded by `maxReferenceBytes` and `maxTotalBytes`. The exact snapshot remains in target history until target compaction shadows or summarizes it; source-session changes add no further tokens.
Each referenced message adds the fixed warning plus up to three serialized snapshots, each independently bounded by `maxReferenceBytes`. The exact snapshot remains in target history until target compaction shadows or summarizes it; source-session changes add no further tokens.
#### KV Cache effect

View File

@@ -1,24 +1,20 @@
/** Configuration and stable diagnostics for session references. */
/** Default maximum references accepted by one message. */
export const DEFAULT_MAX_REFERENCES = 3
/** Hard maximum references accepted by one message. */
export const MAX_REFERENCES = 3
/** Default number of discovery candidates returned to a host. */
export const DEFAULT_CANDIDATE_LIMIT = 50
/** Default UTF-8 budget for one rendered reference JSON object. */
export const DEFAULT_MAX_REFERENCE_BYTES = 65_536
/** Default UTF-8 budget for the complete injected reference prompt. */
export const DEFAULT_MAX_TOTAL_BYTES = 196_608
/** Session-reference service configuration. */
export interface Config {
/** Maximum distinct source sessions referenced by one message. */
/** Maximum distinct source sessions referenced by one message, from one to three. */
maxReferences?: number
/** Default host candidate-list limit. */
candidateLimit?: number
/** Maximum rendered UTF-8 bytes for one source snapshot. */
maxReferenceBytes?: number
/** Maximum rendered UTF-8 bytes for the complete injected prompt. */
maxTotalBytes?: number
}
/** Stable failure codes exposed to host adapters. */

View File

@@ -13,9 +13,8 @@ import type { JsonValue, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
import {
DEFAULT_CANDIDATE_LIMIT,
DEFAULT_MAX_REFERENCES,
DEFAULT_MAX_REFERENCE_BYTES,
DEFAULT_MAX_TOTAL_BYTES,
MAX_REFERENCES,
SessionReferenceError,
type Config,
} from './config.ts'
@@ -27,9 +26,8 @@ export type * from './types.ts'
export type { Config, SessionReferenceErrorCode } from './config.ts'
export {
DEFAULT_CANDIDATE_LIMIT,
DEFAULT_MAX_REFERENCES,
DEFAULT_MAX_REFERENCE_BYTES,
DEFAULT_MAX_TOTAL_BYTES,
MAX_REFERENCES,
SessionReferenceError,
} from './config.ts'
export {
@@ -71,10 +69,9 @@ interface RenderedSource {
export class SessionReferenceService extends Service {
static inject = ['sessionQuery']
static Config: z<Config> = z.object({
maxReferences: z.number().step(1).min(1).default(DEFAULT_MAX_REFERENCES),
maxReferences: z.number().step(1).min(1).max(MAX_REFERENCES).default(MAX_REFERENCES),
candidateLimit: z.number().step(1).min(1).default(DEFAULT_CANDIDATE_LIMIT),
maxReferenceBytes: z.number().step(1).min(1).default(DEFAULT_MAX_REFERENCE_BYTES),
maxTotalBytes: z.number().step(1).min(1).default(DEFAULT_MAX_TOTAL_BYTES),
})
private readonly config: Required<Config>
@@ -82,10 +79,9 @@ export class SessionReferenceService extends Service {
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'sessionReferences')
this.config = {
maxReferences: config.maxReferences ?? DEFAULT_MAX_REFERENCES,
maxReferences: config.maxReferences ?? MAX_REFERENCES,
candidateLimit: config.candidateLimit ?? DEFAULT_CANDIDATE_LIMIT,
maxReferenceBytes: config.maxReferenceBytes ?? DEFAULT_MAX_REFERENCE_BYTES,
maxTotalBytes: config.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES,
}
for (const [name, value] of Object.entries(this.config)) {
if (!Number.isSafeInteger(value) || value <= 0) {
@@ -95,6 +91,12 @@ export class SessionReferenceService extends Service {
)
}
}
if (this.config.maxReferences > MAX_REFERENCES) {
throw new SessionReferenceError(
`session-reference: maxReferences must not exceed ${MAX_REFERENCES}`,
'SESSION_REFERENCE_INVALID_CONFIG',
)
}
}
/**
@@ -173,7 +175,7 @@ export class SessionReferenceService extends Service {
}
assertNotCancelled(signal)
const rendered = this.fitTotalBudget(prepared)
const rendered = this.renderSources(prepared)
const prompt = renderPrompt(rendered.map(source => source.data))
const meta = {
kind: 'session-reference',
@@ -194,32 +196,19 @@ export class SessionReferenceService extends Service {
return { content: acceptedContent, contexts: [context] }
}
private fitTotalBudget(sources: readonly PreparedSource[]): RenderedSource[] {
let low = 1
let high = this.config.maxReferenceBytes
let best: RenderedSource[] | undefined
while (low <= high) {
const cap = Math.floor((low + high) / 2)
const candidate = sources.map(source => retainReferencedSession(source.snapshot, source.input.label, cap))
if (candidate.some(source => source === undefined)) {
low = cap + 1
continue
}
const rendered = candidate as RenderedSource[]
if (Buffer.byteLength(renderPrompt(rendered.map(source => source.data)), 'utf8') <= this.config.maxTotalBytes) {
best = rendered
low = cap + 1
} else {
high = cap - 1
private renderSources(sources: readonly PreparedSource[]): RenderedSource[] {
const rendered: RenderedSource[] = []
for (const source of sources) {
const retained = retainReferencedSession(source.snapshot, source.input.label, this.config.maxReferenceBytes)
if (retained === undefined) {
throw new SessionReferenceError(
'referenced session snapshot cannot fit the configured byte budget',
'SESSION_REFERENCE_BUDGET_EXCEEDED',
)
}
rendered.push(retained)
}
if (best === undefined) {
throw new SessionReferenceError(
'referenced session snapshot cannot fit the configured byte budgets',
'SESSION_REFERENCE_BUDGET_EXCEEDED',
)
}
return best
return rendered
}
}

View File

@@ -358,8 +358,8 @@ describe('session reference discovery and preparation', () => {
.rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
})
it('retains compact checkpoints and latest messages within exact UTF-8 budgets', async () => {
const ctx = await harness({ maxReferenceBytes: 360, maxTotalBytes: 650 })
it('retains compact checkpoints and latest messages within an exact per-reference UTF-8 budget', async () => {
const ctx = await harness({ maxReferenceBytes: 360 })
const target = ctx.sessions.create(SessionId('target'))
const source = ctx.sessions.create(SessionId('source'))
appendConversation(source)
@@ -377,7 +377,6 @@ describe('session reference discovery and preparation', () => {
const prepared = await ctx.sessionReferences.prepare(fakeAgent(target), [{ type: 'text', text: 'go' }], [{ sessionId: source.id }])
const context = prepared.contexts[0]
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
expect(Buffer.byteLength(context.content[0].text, 'utf8')).toBeLessThanOrEqual(650)
const data = promptData(context.content[0].text) as unknown[]
expect(Buffer.byteLength(stringifyTagSafeJson(data[0]), 'utf8')).toBeLessThanOrEqual(360)
expect(context.content[0].text).toContain('checkpoint')
@@ -386,8 +385,41 @@ describe('session reference discovery and preparation', () => {
expect(context.meta).toMatchObject({ references: [{ truncated: true, compacted: true }] })
})
it('applies the full byte limit independently to each of three references', async () => {
const maxReferenceBytes = 360
const ctx = await harness({ maxReferenceBytes })
const target = ctx.sessions.create(SessionId('target'))
const sources = ['one', 'two', 'three'].map((id) => {
const source = ctx.sessions.create(SessionId(id))
source.append(
'user/message',
{ content: [{ type: 'text', text: `${id}-${'界'.repeat(400)}` }], source: COMPACT_CHECKPOINT_SOURCE },
{ surfaceOp: 'append' },
)
source.append(
'user/message',
{ content: [{ type: 'text', text: `${id}-tail` }], source: { kind: 'user' } },
{ surfaceOp: 'append' },
)
return source
})
const prepared = await ctx.sessionReferences.prepare(
fakeAgent(target),
[{ type: 'text', text: 'go' }],
sources.map(source => ({ sessionId: source.id })),
)
const context = prepared.contexts[0]
if (context?.content[0]?.type !== 'text') throw new Error('expected text context')
const data = promptData(context.content[0].text) as unknown[]
const sizes = data.map(source => Buffer.byteLength(stringifyTagSafeJson(source), 'utf8'))
expect(sizes).toHaveLength(3)
expect(sizes.every(size => size <= maxReferenceBytes)).toBe(true)
expect(sizes.reduce((sum, size) => sum + size, 0)).toBeGreaterThan(maxReferenceBytes * 2)
})
it('fails without producing a partial context when fixed prompt data cannot fit', async () => {
const ctx = await harness({ maxReferenceBytes: 16, maxTotalBytes: 32 })
const ctx = await harness({ maxReferenceBytes: 16 })
const target = ctx.sessions.create(SessionId('target'))
const source = ctx.sessions.create(SessionId('source'))
await expect(ctx.sessionReferences.prepare(fakeAgent(target), [{ type: 'text', text: 'go' }], [{ sessionId: source.id }]))
@@ -454,6 +486,12 @@ describe('session reference discovery and preparation', () => {
expect(() => new SessionReferenceService(ctx, { maxReferences: 0 }))
.toThrow(expectCode('SESSION_REFERENCE_INVALID_CONFIG'))
const oversizedCtx = new Context()
await oversizedCtx.plugin(SessionStore)
await oversizedCtx.plugin(SessionQueryService)
expect(() => new SessionReferenceService(oversizedCtx, { maxReferences: 4 }))
.toThrow(expectCode('SESSION_REFERENCE_INVALID_CONFIG'))
const defaultCtx = new Context()
await defaultCtx.plugin(SessionStore)
await defaultCtx.plugin(SessionQueryService)