refactor: identify and freeze messages at creation
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
toolPairingBalancedBefore,
|
||||
} from '@deepseek-ai/dsh-compact'
|
||||
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { TokenMeasurement, TokenMeterService } from '@deepseek-ai/dsh-token-meter'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
@@ -132,10 +133,11 @@ export async function compactSurfaceRegion(
|
||||
throw new Error('compaction: session surface changed during summarization')
|
||||
}
|
||||
const framedSummary = frameSummary(summary)
|
||||
const framedSummaryTokenCount = dependencies.meter.estimateMessage({
|
||||
role: 'user',
|
||||
const checkpointMessage = createUserMessage({
|
||||
content: framedSummary,
|
||||
source: COMPACT_CHECKPOINT_SOURCE,
|
||||
})
|
||||
const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage)
|
||||
if (framedSummaryTokenCount >= shadowedTokenCount) {
|
||||
throw new Error(
|
||||
`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${shadowedTokenCount})`,
|
||||
@@ -151,10 +153,7 @@ export async function compactSurfaceRegion(
|
||||
model,
|
||||
...maxTokens === undefined ? {} : { maxTokens },
|
||||
})
|
||||
session.append('user/message', {
|
||||
content: framedSummary,
|
||||
source: COMPACT_CHECKPOINT_SOURCE,
|
||||
}, {
|
||||
session.append('user/message', checkpointMessage, {
|
||||
surfaceOp: { op: 'replace', start, end },
|
||||
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, BlockAssembler } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, FinishReason, GenerateOptions, Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
@@ -128,7 +128,10 @@ export async function summarizeWithLlm(
|
||||
const assembler = new BlockAssembler()
|
||||
const messages: Message[] = [
|
||||
...input.messages,
|
||||
{ role: 'user', content: [{ type: 'text', text: COMPACTION_INSTRUCTION }] },
|
||||
createUserMessage({
|
||||
content: [{ type: 'text', text: COMPACTION_INSTRUCTION }],
|
||||
source: { kind: 'plugin', plugin: 'dsh-compact-basic' },
|
||||
}),
|
||||
]
|
||||
const options: GenerateOptions = {
|
||||
provider: target.provider,
|
||||
@@ -145,7 +148,7 @@ export async function summarizeWithLlm(
|
||||
const error = finishError(assembler.finish)
|
||||
if (error !== undefined) throw error
|
||||
|
||||
const summary = textOnly(assembler.message().content)
|
||||
const summary = textOnly(assembler.blocks())
|
||||
if (!summary.some(block => block.text.trim().length > 0)) {
|
||||
throw new Error('summarization produced no text summary content')
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
resolveTargetPolicy,
|
||||
} 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 LlmService, { createUserMessage, CallId, CONTEXT_WINDOW_EXCEEDED_CODE, createToolResultMessage, LlmAdapter , createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
ContentBlock,
|
||||
GenerateOptions,
|
||||
@@ -94,7 +94,10 @@ function summarizedText(input: SummarizationInput): string {
|
||||
|
||||
/** A minimal replayed prefix carrying one user message of the given text. */
|
||||
function promptInput(text: string): SummarizationInput {
|
||||
return { messages: [{ role: 'user', content: [{ type: 'text', text }] }] }
|
||||
return { messages: [createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})] }
|
||||
}
|
||||
|
||||
/** Closed two-message turns followed by one open turn for durable compaction events. */
|
||||
@@ -102,10 +105,10 @@ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
|
||||
const session = new Session(SessionId(`conversation-${turns}`))
|
||||
for (let turn = 1; turn <= turns; turn += 1) {
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `${text} user ${turn}` }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
if (turn === 1) {
|
||||
session.append('request/header', {
|
||||
@@ -114,10 +117,16 @@ function conversation(turns = 4, text = 'fixture '.repeat(40).trim()): Session {
|
||||
})
|
||||
}
|
||||
session.append('assistant/message', {
|
||||
provenance: { provider: MODEL, model: MODEL },
|
||||
turn,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: `${text} assistant ${turn}` }],
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: `${text} assistant ${turn}` }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: MODEL, model: MODEL },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
@@ -134,10 +143,10 @@ function toolConversation(): Session {
|
||||
for (let turn = 1; turn <= 3; turn += 1) {
|
||||
const callId = CallId(`call-${turn}`)
|
||||
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `request ${turn} `.repeat(300) }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
if (turn === 1) {
|
||||
session.append('request/header', {
|
||||
@@ -146,21 +155,29 @@ function toolConversation(): Session {
|
||||
})
|
||||
}
|
||||
session.append('assistant/message', {
|
||||
provenance: { provider: MODEL, model: MODEL },
|
||||
turn,
|
||||
step: 1,
|
||||
content: [
|
||||
{ type: 'text', text: `calling ${turn} `.repeat(300) },
|
||||
{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' },
|
||||
],
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: `calling ${turn} `.repeat(300) },
|
||||
{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' },
|
||||
],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: MODEL, model: MODEL },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn, step: 1, callId, name: 'read', arguments: '{}' })
|
||||
session.append('tool/result', {
|
||||
turn,
|
||||
step: 1,
|
||||
callId,
|
||||
content: [{ type: 'text', text: `result ${turn} `.repeat(300) }],
|
||||
isError: false,
|
||||
message: createToolResultMessage({
|
||||
callId,
|
||||
content: [{ type: 'text', text: `result ${turn} `.repeat(300) }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
@@ -175,10 +192,10 @@ function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Sess
|
||||
const callId = CallId('oversized')
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
if (withCompactablePrompt) {
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'older history '.repeat(200) }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('request/header', {
|
||||
@@ -188,16 +205,24 @@ function oversizedToolResult(chars = 3_000, withCompactablePrompt = false): Sess
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
|
||||
provenance: { provider: MODEL, model: MODEL },
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: MODEL, model: MODEL },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId,
|
||||
content: [{ type: 'text', text: 'X'.repeat(chars) }],
|
||||
isError: false,
|
||||
message: createToolResultMessage({
|
||||
callId,
|
||||
content: [{ type: 'text', text: 'X'.repeat(chars) }],
|
||||
isError: false,
|
||||
}),
|
||||
meta: { presentation: 'preserved' },
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
@@ -539,18 +564,26 @@ describe('pressure measurement and retention', () => {
|
||||
reason: 'initial',
|
||||
})
|
||||
session.append('assistant/message', {
|
||||
provenance: { provider: MODEL, model: MODEL },
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }],
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: MODEL, model: MODEL },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: '{}' })
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId,
|
||||
content: [{ type: 'text', text: 'result' }],
|
||||
isError: false,
|
||||
message: createToolResultMessage({
|
||||
callId,
|
||||
content: [{ type: 'text', text: 'result' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
const generation = session.surface.replaceGeneration
|
||||
@@ -693,18 +726,26 @@ 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: '{}' }],
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: callId, name: 'read', arguments: '{}' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: MODEL, model: MODEL },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId, name: 'read', arguments: '{}' })
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId,
|
||||
content: [{ type: 'text', text: 'result' }],
|
||||
isError: false,
|
||||
message: createToolResultMessage({
|
||||
callId,
|
||||
content: [{ type: 'text', text: 'result' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
|
||||
@@ -778,7 +819,7 @@ describe('optional model-free tool-result pruning', () => {
|
||||
expect(await compactIfNeeded(compact, session)).not.toBeNull()
|
||||
expect(compact.calls).toHaveLength(1)
|
||||
const original = session.events.find(event => event.type === 'tool/result')
|
||||
expect(original?.type === 'tool/result' && original.data.content[0])
|
||||
expect(original?.type === 'tool/result' && original.data.message.content[0].content[0])
|
||||
.toEqual({ type: 'text', text: 'X'.repeat(3_000) })
|
||||
expect(session.events.filter(event =>
|
||||
event.type === 'tool/result' && event.surfaceOp !== 'append')).toHaveLength(0)
|
||||
@@ -897,10 +938,10 @@ describe('compaction region transaction', () => {
|
||||
it('rejects a session with no turn boundary at all', async () => {
|
||||
const compact = service()
|
||||
const session = new Session(SessionId('turnless'))
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'orphan' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
const node = session.surface.nodes[0]!
|
||||
|
||||
await expect(compact.compactRegion(
|
||||
@@ -982,10 +1023,10 @@ describe('compaction region transaction', () => {
|
||||
const compact = service()
|
||||
const session = conversation(2)
|
||||
compact.mutateDuringSummary = () => {
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'concurrent surface mutation' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
const nodes = session.surface.nodes
|
||||
|
||||
@@ -1018,16 +1059,22 @@ describe('compaction region transaction', () => {
|
||||
const compact = service()
|
||||
const session = new Session(SessionId('model-less-region'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'history '.repeat(100) }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { 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) }],
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'answer '.repeat(100) }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'historical', model: 'historical' },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
const nodes = session.surface.nodes
|
||||
@@ -1126,7 +1173,10 @@ describe('default one-shot summarizer', () => {
|
||||
it('replays the conversation prefix and appends the instruction as the final message', async () => {
|
||||
const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }])
|
||||
const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }]
|
||||
const prefix: Message = { role: 'user', content: [{ type: 'text', text: 'earlier turn' }] }
|
||||
const prefix: Message = createUserMessage({
|
||||
content: [{ type: 'text', text: 'earlier turn' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
await compact.runSummarize({
|
||||
system: 'REPLAYED SYSTEM',
|
||||
tools,
|
||||
@@ -1162,7 +1212,10 @@ describe('default one-shot summarizer', () => {
|
||||
)
|
||||
const policyAdapter = new ScriptedAdapter([{ type: 'text', text: 'policy summary' }])
|
||||
ctx.llm.registerAdapter(['policy-summary'], policyAdapter)
|
||||
const prefix: Message = { role: 'user', content: [{ type: 'text', text: 'warm prefix' }] }
|
||||
const prefix: Message = createUserMessage({
|
||||
content: [{ type: 'text', text: 'warm prefix' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
})
|
||||
|
||||
const output = await compact.runSummarize({
|
||||
system: 'WARM SYSTEM',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, resolveRetryPolicy , createMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
@@ -192,16 +192,22 @@ function overflowHistorySeed(): SessionEvent[] {
|
||||
turn,
|
||||
trigger: { kind: 'message', source: { kind: 'user' } },
|
||||
})
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: `${sentinel} ${'old context '.repeat(200)}` }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('step/start', { turn, step: 1 })
|
||||
session.append('assistant/message', {
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
turn,
|
||||
step: 1,
|
||||
content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }],
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: `historical response ${turn} ${'detail '.repeat(200)}` }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'mock' },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
@@ -220,7 +226,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
provider: 'unconfigured-agent-fallback',
|
||||
model: 'unconfigured-agent-fallback',
|
||||
})
|
||||
agent.followup({ content: [{ type: 'text', text: 'do a routed multi-step task' }], source: { kind: 'user' } })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do a routed multi-step task' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(agent.session.requestHeader()?.config.model).toBe('mock')
|
||||
@@ -238,7 +244,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
const { ctx } = await harness(8)
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId('post-step-order'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup({ content: [{ type: 'text', text: 'do tool work' }], source: { kind: 'user' } })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do tool work' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
@@ -270,7 +276,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
|
||||
const { ctx } = await harness(8)
|
||||
try {
|
||||
const agent = ctx.agentLoop.create(SessionId('repro'), { provider: 'mock', model: 'mock' })
|
||||
agent.followup({ content: [{ type: 'text', text: 'do a long multi-step task' }], source: { kind: 'user' } })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'do a long multi-step task' }], source: { kind: 'user' } }))
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const events = [...agent.session.events]
|
||||
@@ -331,7 +337,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
},
|
||||
})
|
||||
|
||||
agent.followup({ content: [{ type: 'text', text: 'continue from history' }], source: { kind: 'user' } })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue from history' }], source: { kind: 'user' } }))
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.conversationRequests).toHaveLength(2)
|
||||
@@ -402,7 +408,7 @@ describe('context-overflow recovery across the real loop and compact-basic', ()
|
||||
seed: overflowHistorySeed(),
|
||||
agentOptions: { provider: 'mock', model: 'mock' },
|
||||
})
|
||||
agent.followup({ content: [{ type: 'text', text: 'continue from history' }], source: { kind: 'user' } })
|
||||
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue from history' }], source: { kind: 'user' } }))
|
||||
await agent.whenIdle()
|
||||
|
||||
expect(adapter.conversationRequests).toHaveLength(3)
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { freezeMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, ToolResultMessage } from '@deepseek-ai/dsh-session'
|
||||
import { codePointLength, DEFAULTS, PRUNE_MARKER, resolveConfig } from './config.ts'
|
||||
import type {
|
||||
PrunedEntry,
|
||||
@@ -132,13 +133,21 @@ export class ToolResultPruneService extends Service {
|
||||
const pruned: PrunedEntry[] = []
|
||||
let charsRemoved = 0
|
||||
for (const { seq, event } of candidates) {
|
||||
const content = this.pruneContent(event.data.content)
|
||||
const result = event.data.message.content[0]
|
||||
const content = this.pruneContent(result.content)
|
||||
if (content === null) continue
|
||||
const charsBefore = this.measureContent(event.data.content)
|
||||
const charsBefore = this.measureContent(result.content)
|
||||
const charsAfter = this.measureContent(content)
|
||||
const message = freezeMessage<ToolResultMessage>({
|
||||
...event.data.message,
|
||||
content: [{
|
||||
...result,
|
||||
content,
|
||||
}] as [typeof result],
|
||||
})
|
||||
const replacement = session.append('tool/result', {
|
||||
...event.data,
|
||||
content,
|
||||
message,
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: seq, end: seq },
|
||||
sourceEventSeqs: [seq],
|
||||
@@ -146,7 +155,7 @@ export class ToolResultPruneService extends Service {
|
||||
pruned.push({
|
||||
originalSeq: seq,
|
||||
replacementSeq: replacement.seq,
|
||||
callId: event.data.callId,
|
||||
callId: event.data.message.source.callId,
|
||||
charsBefore,
|
||||
charsAfter,
|
||||
})
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, {
|
||||
Session,
|
||||
SessionId,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
@@ -41,16 +44,20 @@ function appendToolStep(
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
|
||||
provenance: { provider: MODEL, model: MODEL },
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: MODEL, model: MODEL },
|
||||
},
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('tool/call', { turn, step: 1, callId, name: 'bash', arguments: '{}' })
|
||||
const result = session.append('tool/result', {
|
||||
turn,
|
||||
step: 1,
|
||||
callId,
|
||||
content,
|
||||
isError: false,
|
||||
message: createToolResultMessage({ callId, content, isError: false }),
|
||||
...extra,
|
||||
}, { surfaceOp: 'append' })
|
||||
session.append('step/end', { turn, step: 1 })
|
||||
@@ -172,15 +179,24 @@ describe('ToolResultPruneService session transaction', () => {
|
||||
const replacement = session.events[entry.replacementSeq]! as SurfaceEvent
|
||||
expect(original).toMatchObject({
|
||||
type: 'tool/result',
|
||||
data: { content: [{ type: 'text', text: 'x'.repeat(100) }] },
|
||||
data: {
|
||||
message: {
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
content: [{ type: 'text', text: 'x'.repeat(100) }],
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(replacement).toMatchObject({
|
||||
type: 'tool/result',
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('one'),
|
||||
isError: true,
|
||||
message: {
|
||||
source: { kind: 'tool', callId: CallId('one') },
|
||||
},
|
||||
error: { name: 'ExitError', code: 'EXIT_1' },
|
||||
meta: { diff: ['a', 'b'] },
|
||||
futureField: { nested: true },
|
||||
|
||||
@@ -29,7 +29,7 @@ const balanceCacheBySession = new WeakMap<Session, BalanceCache>()
|
||||
function eventDelta(event: SessionEvent): number {
|
||||
switch (event.type) {
|
||||
case 'assistant/message':
|
||||
return event.data.content.filter(block => block.type === 'tool-call').length
|
||||
return event.data.message.content.filter(block => block.type === 'tool-call').length
|
||||
case 'tool/result':
|
||||
return -1
|
||||
default:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import {
|
||||
@@ -52,10 +53,10 @@ class StubCompactService extends CompactService {
|
||||
provider: 'mock',
|
||||
model: 'stub',
|
||||
})
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: summary,
|
||||
source: COMPACT_CHECKPOINT_SOURCE,
|
||||
}, {
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start, end },
|
||||
sourceEventSeqs: [startEvent.seq, summaryEvent.seq, ...shadowedSeqs],
|
||||
})
|
||||
@@ -103,10 +104,10 @@ describe('CompactService seam', () => {
|
||||
const ctx = new Context()
|
||||
const svc = new StubCompactService(ctx)
|
||||
const session = new Session(SessionId('s'))
|
||||
const original = session.append('user/message', {
|
||||
const original = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
const result = await svc.compactRegion(original.seq, original.seq, stubAgent(session, 'm'))
|
||||
|
||||
@@ -135,10 +136,10 @@ describe('CompactService seam', () => {
|
||||
const svc = new StubCompactService(ctx)
|
||||
const session = new Session(SessionId('s'))
|
||||
const controller = new AbortController()
|
||||
const original = session.append('user/message', {
|
||||
const original = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'original' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
await svc.compactRegion(original.seq, original.seq, stubAgent(session, 'm'), controller.signal)
|
||||
expect(svc.lastSignal).toBe(controller.signal)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
@@ -26,22 +26,30 @@ function after(session: Session, type: SessionEvent['type'], nth = 0): boolean {
|
||||
|
||||
function closedToolStep(): Session {
|
||||
const session = new Session(SessionId('closed-tool-step'))
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'go' }],
|
||||
source: { kind: 'user' },
|
||||
}, SURFACE)
|
||||
}), SURFACE)
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'mock' },
|
||||
},
|
||||
}),
|
||||
}, SURFACE)
|
||||
session.append('tool/result', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
isError: false,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c1'),
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
isError: false,
|
||||
}),
|
||||
}, SURFACE)
|
||||
return session
|
||||
}
|
||||
@@ -60,8 +68,14 @@ describe('tool-pairing boundaries', () => {
|
||||
open.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('open'), name: 'bash', arguments: '{}' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: CallId('open'), name: 'bash', arguments: '{}' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'mock' },
|
||||
},
|
||||
}),
|
||||
}, SURFACE)
|
||||
expect(toolPairingBalancedAfter(open, open.surface.nodes[0]!)).toBe(false)
|
||||
})
|
||||
@@ -71,17 +85,33 @@ describe('tool-pairing boundaries', () => {
|
||||
session.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' },
|
||||
{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' },
|
||||
],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' },
|
||||
{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' },
|
||||
],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'mock' },
|
||||
},
|
||||
}),
|
||||
}, SURFACE)
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c1'),
|
||||
content: [],
|
||||
isError: false,
|
||||
}),
|
||||
}, SURFACE)
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c2'), content: [], isError: false,
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c2'),
|
||||
content: [],
|
||||
isError: false,
|
||||
}),
|
||||
}, SURFACE)
|
||||
|
||||
expect(after(session, 'tool/result', 0)).toBe(false)
|
||||
@@ -93,24 +123,35 @@ describe('tool-pairing boundaries', () => {
|
||||
midStep.append('assistant/message', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'mock' },
|
||||
},
|
||||
}),
|
||||
}, SURFACE)
|
||||
midStep.append('user/message', {
|
||||
midStep.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'background update' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
}, SURFACE)
|
||||
}), SURFACE)
|
||||
midStep.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c1'),
|
||||
content: [],
|
||||
isError: false,
|
||||
}),
|
||||
}, SURFACE)
|
||||
expect(before(midStep, 'user/message')).toBe(false)
|
||||
expect(after(midStep, 'user/message')).toBe(false)
|
||||
|
||||
const free = new Session(SessionId('neutral-free'))
|
||||
free.append('user/message', {
|
||||
free.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'idle injection' }],
|
||||
source: { kind: 'user' },
|
||||
}, SURFACE)
|
||||
}), SURFACE)
|
||||
expect(before(free, 'user/message')).toBe(true)
|
||||
expect(after(free, 'user/message')).toBe(true)
|
||||
})
|
||||
@@ -123,10 +164,10 @@ describe('tool-pairing surface identity', () => {
|
||||
expect(toolPairingBalancedAfter(session, staleTail)).toBe(true)
|
||||
|
||||
const nodes = session.surface.nodes
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'checkpoint' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, {
|
||||
}), {
|
||||
surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes.at(-1)! },
|
||||
sourceEventSeqs: [...nodes],
|
||||
})
|
||||
@@ -151,10 +192,10 @@ describe('tool-pairing surface identity', () => {
|
||||
expect(() => toolPairingBalancedBefore(session, missing)).toThrow(/surface seq 999 not found/)
|
||||
expect(() => toolPairingBalancedAfter(session, missing)).toThrow(/surface seq 999 not found/)
|
||||
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'first node after empty cache' }],
|
||||
source: { kind: 'user' },
|
||||
}, SURFACE)
|
||||
}), SURFACE)
|
||||
expect(toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -164,7 +205,9 @@ describe('tool-pairing cache refresh', () => {
|
||||
const events: SessionEvent[] = [
|
||||
{
|
||||
type: 'user/message', seq: 0, time: 0,
|
||||
data: { content: [{ type: 'text', text: 'user' }], source: { kind: 'user' } },
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'user' }], source: { kind: 'user' },
|
||||
}),
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
@@ -172,14 +215,27 @@ describe('tool-pairing cache refresh', () => {
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'mock' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'tool/result', seq: 2, time: 2,
|
||||
data: { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false },
|
||||
data: {
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c1'),
|
||||
content: [],
|
||||
isError: false,
|
||||
}),
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
]
|
||||
@@ -224,7 +280,9 @@ describe('tool-pairing cache refresh', () => {
|
||||
|
||||
events.push({
|
||||
type: 'user/message', seq: 4, time: 4,
|
||||
data: { content: [{ type: 'text', text: 'tail' }], source: { kind: 'user' } },
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'tail' }], source: { kind: 'user' },
|
||||
}),
|
||||
surfaceOp: 'append',
|
||||
})
|
||||
nodes.push(4)
|
||||
@@ -238,14 +296,27 @@ describe('tool-pairing cache refresh', () => {
|
||||
data: {
|
||||
turn: 2,
|
||||
step: 1,
|
||||
content: [{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' }],
|
||||
provenance: { provider: 'mock', model: 'mock' },
|
||||
message: createMessage({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' }],
|
||||
source: {
|
||||
kind: 'model',
|
||||
...{ provider: 'mock', model: 'mock' },
|
||||
},
|
||||
}),
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'tool/result', seq: 6, time: 6,
|
||||
data: { turn: 2, step: 1, callId: CallId('c2'), content: [], isError: false },
|
||||
data: {
|
||||
turn: 2, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('c2'),
|
||||
content: [],
|
||||
isError: false,
|
||||
}),
|
||||
},
|
||||
surfaceOp: 'append',
|
||||
},
|
||||
)
|
||||
@@ -256,7 +327,9 @@ describe('tool-pairing cache refresh', () => {
|
||||
|
||||
events.push({
|
||||
type: 'user/message', seq: 7, time: 7,
|
||||
data: { content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' } },
|
||||
data: createUserMessage({
|
||||
content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' },
|
||||
}),
|
||||
surfaceOp: { op: 'replace', start: 0, end: 6 },
|
||||
})
|
||||
nodes.splice(0, nodes.length, 7)
|
||||
@@ -270,11 +343,15 @@ describe('tool-pairing cache refresh', () => {
|
||||
const events: SessionEvent[] = [
|
||||
{
|
||||
type: 'user/message', seq: 0, time: 0,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
data: createUserMessage({
|
||||
content: [], source: { kind: 'user' },
|
||||
}), surfaceOp: 'append',
|
||||
},
|
||||
{
|
||||
type: 'user/message', seq: 1, time: 1,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
data: createUserMessage({
|
||||
content: [], source: { kind: 'user' },
|
||||
}), surfaceOp: 'append',
|
||||
},
|
||||
]
|
||||
const nodes: number[] = [0, 1]
|
||||
@@ -292,19 +369,29 @@ describe('tool-pairing corrupt surfaces', () => {
|
||||
it('throws for an orphan result during a rebuild', () => {
|
||||
const session = new Session(SessionId('orphan-rebuild'))
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('orphan'), content: [], isError: false,
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('orphan'),
|
||||
content: [],
|
||||
isError: false,
|
||||
}),
|
||||
}, SURFACE)
|
||||
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toThrow(/no matching tool-call/)
|
||||
})
|
||||
|
||||
it('retries an orphan result in an appended tail without committing partial cache state', () => {
|
||||
const session = new Session(SessionId('orphan-tail'))
|
||||
session.append('user/message', {
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'safe head' }], source: { kind: 'user' },
|
||||
}, SURFACE)
|
||||
}), SURFACE)
|
||||
expect(toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toBe(true)
|
||||
session.append('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('orphan'), content: [], isError: false,
|
||||
turn: 1, step: 1,
|
||||
message: createToolResultMessage({
|
||||
callId: CallId('orphan'),
|
||||
content: [],
|
||||
isError: false,
|
||||
}),
|
||||
}, SURFACE)
|
||||
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[1]!)).toThrow(/no matching tool-call/)
|
||||
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[1]!)).toThrow(/no matching tool-call/)
|
||||
@@ -315,7 +402,9 @@ describe('tool-pairing corrupt surfaces', () => {
|
||||
const missing = {
|
||||
events: [{
|
||||
type: 'user/message', seq: 0, time: 0,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
data: createUserMessage({
|
||||
content: [], source: { kind: 'user' },
|
||||
}), surfaceOp: 'append',
|
||||
} satisfies SessionEvent],
|
||||
surface: { nodes: [missingSeq], replaceGeneration: 0 },
|
||||
} as unknown as Session
|
||||
@@ -325,7 +414,9 @@ describe('tool-pairing corrupt surfaces', () => {
|
||||
const mismatched = {
|
||||
events: [{
|
||||
type: 'user/message', seq: 99, time: 0,
|
||||
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
|
||||
data: createUserMessage({
|
||||
content: [], source: { kind: 'user' },
|
||||
}), surfaceOp: 'append',
|
||||
} satisfies SessionEvent],
|
||||
surface: { nodes: [mismatchedSeq], replaceGeneration: 0 },
|
||||
} as unknown as Session
|
||||
|
||||
Reference in New Issue
Block a user