fix(compact-basic): replay conversation prefix so summarization reuses KV cache
Automatic compaction fires mid-conversation, right after the loop warmed the provider's KV cache with the last routed request. The default summarizer then issued a separate request whose prefix shared nothing with that warm request — a bespoke summarizer system prompt followed by the older history flattened to one rendered transcript string — so a differing first token invalidated the entire cached prefix and every compaction re-processed the whole replayed history twice. Move the compaction directive from the FRONT (a fresh system prompt) to the END (a trailing user message), and replay the last routed request's own system prompt, tools, message prefix, and shadowed-region messages verbatim via session.requestHeader() + deriveEventMessage. The auxiliary call is now a genuine prefix-extension of the warm request, so the provider reuses the cached tokens up to the trailing instruction. SummarizationInput carries the replayed prefix instead of a flat string; the now-unused renderTranscript/renderContentBlocks path is removed with its spec. Cache reuse is best-effort (head compaction guarantees a hit; a mid-range compaction or a differently-routed summarizer forgoes it), correctness is not.
This commit is contained in:
@@ -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` + tool-pairing boundary helpers |
|
||||
| `@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` |
|
||||
|
||||
@@ -63,7 +63,7 @@ Subclass `CompactService`, implement `compactIfNeeded` and `compactRegion`, and
|
||||
|
||||
#### What the model sees
|
||||
|
||||
A successful implementation replaces an older surface range with one user-role summary checkpoint; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite.
|
||||
A successful implementation replaces an older surface range with one user-role summary checkpoint — a `user/message` carrying `surfaceOp: { op: 'replace', start, end }`; the raw events stay logged but stop appearing in derived model messages. The seam itself performs no rewrite.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -73,20 +73,6 @@ Zero direct tokens from this interface. A backend trades many retained history t
|
||||
|
||||
A successful backend replacement invalidates reuse from the first shadowed history token; the seam itself does not alter a request.
|
||||
|
||||
### Transcript supplied to a compaction consumer
|
||||
|
||||
#### What the model sees
|
||||
|
||||
`renderTranscript()` joins entries with one blank line and renders them exactly as `User: <content>`, `Assistant: <content>`, `Tool result (call <callId>): <content>`, `Tool error (call <callId>): <content>`, `[Context: <content>]`, or `[Steering: <content>]`. Non-text blocks render exactly as `[reasoning: <text>]`, `[tool-call: <name>(<arguments>)]`, `[tool-result: <content>]`, `[tool-result]`, or `[<block-type>]`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Data-dependent input tokens are paid only by the auxiliary model or consumer that requests this transcript; the conversation model does not receive a duplicate transcript.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No conversation-cache invalidation. A consumer's auxiliary request can reuse only the exact prefix produced by this rendering; changed or compacted entries invalidate reuse from their first difference.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No model-facing consumer tier yet** — `@deepseek-ai/dsh-tool-compact` (the `/compact` tool) is deferred; compaction is reachable only via direct `ctx.compact` calls or a backend's auto listener.
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { CompactionResult } from './types.ts'
|
||||
|
||||
export type { CompactionResult } from './types.ts'
|
||||
export { renderContentBlocks, renderTranscript } from './render.ts'
|
||||
export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts'
|
||||
|
||||
/** Why automatic policy is asking a backend to consider compaction. */
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Pure shared transcript projection for summarization and recall, so both
|
||||
* render the same log span byte-for-byte under replay.
|
||||
* @module @deepseek-ai/dsh-compact/render
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* Render text directly, reasoning as a tagged span, and every other block as a
|
||||
* type-tagged placeholder. Tool results recurse into nested content; empty
|
||||
* blocks contribute nothing and rendered blocks join with newlines.
|
||||
*
|
||||
* @param blocks - the content blocks to render.
|
||||
* @returns the newline-joined plain-text rendering; empty string when nothing renders.
|
||||
*/
|
||||
export function renderContentBlocks(blocks: readonly ContentBlock[]): string {
|
||||
const parts: string[] = []
|
||||
for (const block of blocks) {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
if (block.text) parts.push(block.text)
|
||||
break
|
||||
case 'reasoning':
|
||||
if (block.text) parts.push(`[reasoning: ${block.text}]`)
|
||||
break
|
||||
case 'tool-call':
|
||||
parts.push(`[tool-call: ${block.name}(${block.arguments})]`)
|
||||
break
|
||||
case 'tool-result': {
|
||||
const inner = renderContentBlocks(block.content)
|
||||
parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]')
|
||||
break
|
||||
}
|
||||
// ContentBlockMap is merge-extensible — render an unknown block as a
|
||||
// bare type-tagged placeholder so a plugin-added block type is still
|
||||
// signalled to the reader rather than dropped.
|
||||
default:
|
||||
parts.push(`[${(block as ContentBlock).type}]`)
|
||||
}
|
||||
}
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render message-producing events as a role-labeled transcript. `seqs` are
|
||||
* walked in caller-supplied surface order, which may differ from numeric log
|
||||
* order after replacement; non-surface and unknown merged events are skipped.
|
||||
*
|
||||
* @param events - the session log the seqs index into (`session.events`).
|
||||
* @param seqs - the surface-node seqs to render, in surface order.
|
||||
* @returns the transcript, entries joined by blank lines; empty string when nothing renders.
|
||||
*/
|
||||
export function renderTranscript(events: readonly SessionEvent[], seqs: readonly number[]): string {
|
||||
const lines: string[] = []
|
||||
|
||||
for (const seq of seqs) {
|
||||
const event = events[seq]
|
||||
if (!event) continue
|
||||
|
||||
switch (event.type) {
|
||||
case 'user/message': {
|
||||
const text = renderContentBlocks(event.data.content)
|
||||
if (text) lines.push(`User: ${text}`)
|
||||
break
|
||||
}
|
||||
case 'assistant/message': {
|
||||
const text = renderContentBlocks(event.data.content)
|
||||
if (text) lines.push(`Assistant: ${text}`)
|
||||
break
|
||||
}
|
||||
case 'tool/result': {
|
||||
const text = renderContentBlocks(event.data.content)
|
||||
const label = event.data.isError ? 'Tool error' : 'Tool result'
|
||||
if (text) lines.push(`${label} (call ${event.data.callId}): ${text}`)
|
||||
break
|
||||
}
|
||||
case 'context/message': {
|
||||
const text = renderContentBlocks(event.data.content)
|
||||
if (text) lines.push(`[Context: ${text}]`)
|
||||
break
|
||||
}
|
||||
case 'steering/message': {
|
||||
const text = renderContentBlocks(event.data.content)
|
||||
if (text) lines.push(`[Steering: ${text}]`)
|
||||
break
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n\n')
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderContentBlocks, renderTranscript } from '@deepseek-ai/dsh-compact'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
function session(): Session {
|
||||
return new Session(SessionId('render-spec'))
|
||||
}
|
||||
|
||||
describe('renderContentBlocks', () => {
|
||||
it('renders text blocks verbatim and skips empty ones', () => {
|
||||
expect(renderContentBlocks([
|
||||
{ type: 'text', text: 'hello' },
|
||||
{ type: 'text', text: '' },
|
||||
{ type: 'text', text: 'world' },
|
||||
])).toBe('hello\nworld')
|
||||
})
|
||||
|
||||
it('wraps reasoning, skipping empty reasoning', () => {
|
||||
expect(renderContentBlocks([
|
||||
{ type: 'reasoning', text: 'think' },
|
||||
{ type: 'reasoning', text: '' },
|
||||
])).toBe('[reasoning: think]')
|
||||
})
|
||||
|
||||
it('renders tool-call as a name(args) placeholder', () => {
|
||||
expect(renderContentBlocks([
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'read', arguments: '{"filePath":"a"}' },
|
||||
])).toBe('[tool-call: read({"filePath":"a"})]')
|
||||
})
|
||||
|
||||
it('renders tool-result with nested content, and bare when empty', () => {
|
||||
expect(renderContentBlocks([
|
||||
{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'ok' }] },
|
||||
{ type: 'tool-result', toolCallId: CallId('c2'), content: [] },
|
||||
])).toBe('[tool-result: ok]\n[tool-result]')
|
||||
})
|
||||
|
||||
it('renders an unknown (merge-extended) block type as a bare type tag', () => {
|
||||
const unknown = { type: 'image', data: 'zzz' } as unknown as ContentBlock
|
||||
expect(renderContentBlocks([unknown])).toBe('[image]')
|
||||
})
|
||||
|
||||
it('returns the empty string for no blocks', () => {
|
||||
expect(renderContentBlocks([])).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderTranscript', () => {
|
||||
it('renders each surface event type with its label, in the seq order given', () => {
|
||||
const s = session()
|
||||
const user = s.append('user/message', {
|
||||
content: [{ type: 'text', text: 'fix the bug' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const assistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 0, step: 0,
|
||||
content: [{ type: 'text', text: 'looking' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
const result = s.append('tool/result', {
|
||||
turn: 0, step: 0, callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'exit 0' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
const context = s.append('context/message', {
|
||||
content: [{ type: 'text', text: 'file changed' }],
|
||||
source: { kind: 'plugin', plugin: 'fs' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const steering = s.append('steering/message', {
|
||||
turn: 0,
|
||||
content: [{ type: 'text', text: 'stop that' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
expect(renderTranscript(s.events, [user.seq, assistant.seq, result.seq, context.seq, steering.seq])).toBe([
|
||||
'User: fix the bug',
|
||||
'Assistant: looking',
|
||||
'Tool result (call c1): exit 0',
|
||||
'[Context: file changed]',
|
||||
'[Steering: stop that]',
|
||||
].join('\n\n'))
|
||||
})
|
||||
|
||||
it('labels an error tool result "Tool error"', () => {
|
||||
const s = session()
|
||||
const result = s.append('tool/result', {
|
||||
turn: 0, step: 0, callId: CallId('c9'),
|
||||
content: [{ type: 'text', text: 'boom' }],
|
||||
isError: true,
|
||||
}, { surfaceOp: 'append' })
|
||||
expect(renderTranscript(s.events, [result.seq])).toBe('Tool error (call c9): boom')
|
||||
})
|
||||
|
||||
it('renders NON-log-order seqs in the order given (surface order after a replace)', () => {
|
||||
const s = session()
|
||||
const first = s.append('user/message', {
|
||||
content: [{ type: 'text', text: 'first' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const second = s.append('user/message', {
|
||||
content: [{ type: 'text', text: 'second' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
expect(renderTranscript(s.events, [second.seq, first.seq])).toBe('User: second\n\nUser: first')
|
||||
})
|
||||
|
||||
it('skips events that render to nothing, non-message events, and seqs with no event', () => {
|
||||
const s = session()
|
||||
const empty = s.append('user/message', {
|
||||
content: [{ type: 'text', text: '' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const emptyAssistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
|
||||
turn: 0, step: 0,
|
||||
content: [{ type: 'text', text: '' }],
|
||||
}, { surfaceOp: 'append' })
|
||||
const emptyResult = s.append('tool/result', {
|
||||
turn: 0, step: 0, callId: CallId('c3'),
|
||||
content: [{ type: 'text', text: '' }],
|
||||
isError: false,
|
||||
}, { surfaceOp: 'append' })
|
||||
const emptyContext = s.append('context/message', {
|
||||
content: [{ type: 'text', text: '' }],
|
||||
source: { kind: 'plugin', plugin: 'fs' },
|
||||
}, { surfaceOp: 'append' })
|
||||
const emptySteering = s.append('steering/message', {
|
||||
turn: 0,
|
||||
content: [{ type: 'text', text: '' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
// A log-only (non-surface) event type: contributes nothing to a transcript.
|
||||
const lock = s.append('compact/start', { turn: 0 })
|
||||
expect(renderTranscript(s.events, [
|
||||
empty.seq, emptyAssistant.seq, emptyResult.seq, emptyContext.seq, emptySteering.seq, lock.seq, 9999,
|
||||
])).toBe('')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user