mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Replace send/steer/inject with one Agent.send primitive over the (target × wakeup) matrix; followup/steer/inject become fixed-preset alias methods on the now-abstract Agent class. Coalesce context/message into user/message (injected context is a non-user source). Replace agent/queued with agent/inbox/enqueue/dequeue/discard, add cancel keepInbox, and add a FIFO-conservation invariant.
180 lines
7.2 KiB
TypeScript
180 lines
7.2 KiB
TypeScript
/** Current-surface projection and byte-bounded rendering. */
|
|
|
|
import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compact'
|
|
import { displayPromptContent } from '@deepseek-ai/dsh-session'
|
|
import type { SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
|
|
import { assertNever } from '@deepseek-ai/dsh-llm'
|
|
import { TextRetainer } from '@deepseek-ai/dsh-retention'
|
|
import { stringifyTagSafeJson } from './serialization.ts'
|
|
import type { ReferencedConversationItem } from './types.ts'
|
|
|
|
interface ProjectedItem extends ReferencedConversationItem {
|
|
checkpoint: boolean
|
|
originalText: string
|
|
omittedBytes: number
|
|
}
|
|
|
|
/** Snapshot data serialized inside the untrusted prompt. */
|
|
export interface ReferencedSessionData {
|
|
sessionId: string
|
|
label: string
|
|
cwd: string | null
|
|
capturedThroughSeq: number | null
|
|
conversation: ReferencedConversationItem[]
|
|
}
|
|
|
|
/** Retention facts stored beside the durable context. */
|
|
export interface ReferenceRetentionStats {
|
|
compacted: boolean
|
|
originalMessages: number
|
|
retainedMessages: number
|
|
omittedMessages: number
|
|
omittedBytes: number
|
|
truncated: boolean
|
|
}
|
|
|
|
/** Project current user/assistant conversation while excluding tools, reasoning, and injected context. */
|
|
function projectSessionConversation(snapshot: SessionSurfaceSnapshot): ProjectedItem[] {
|
|
const conversation: ProjectedItem[] = []
|
|
for (const event of snapshot.events) {
|
|
switch (event.type) {
|
|
case 'user/message': {
|
|
const checkpoint = isCompactCheckpointSource(event.data.source)
|
|
if (!checkpoint && event.data.source.kind !== 'user') break
|
|
const text = textContent(displayPromptContent(event.data))
|
|
if (text !== '') conversation.push({ role: 'user', text, checkpoint, originalText: text, omittedBytes: 0 })
|
|
break
|
|
}
|
|
case 'steering/message': {
|
|
if (event.data.source.kind !== 'user') break
|
|
const text = textContent(displayPromptContent(event.data))
|
|
if (text !== '') conversation.push({ role: 'user', text, checkpoint: false, originalText: text, omittedBytes: 0 })
|
|
break
|
|
}
|
|
case 'assistant/message': {
|
|
const text = textContent(event.data.content)
|
|
if (text !== '') conversation.push({ role: 'assistant', text, checkpoint: false, originalText: text, omittedBytes: 0 })
|
|
break
|
|
}
|
|
case 'tool/result':
|
|
break
|
|
/* v8 ignore next 2 -- SurfaceEventType is closed and every variant is handled above. */
|
|
default:
|
|
assertNever(event, 'session-reference surface event')
|
|
}
|
|
}
|
|
return conversation
|
|
}
|
|
|
|
/**
|
|
* Fit one projected snapshot into an exact rendered JSON-object byte cap.
|
|
* @param snapshot - current-surface source observation.
|
|
* @param label - host-provided display label serialized with the source.
|
|
* @param maxBytes - maximum UTF-8 bytes for the serialized data object.
|
|
* @returns retained data and stats, or `undefined` when fixed data cannot fit.
|
|
*/
|
|
export function retainReferencedSession(
|
|
snapshot: SessionSurfaceSnapshot,
|
|
label: string,
|
|
maxBytes: number,
|
|
): { data: ReferencedSessionData; stats: ReferenceRetentionStats } | undefined {
|
|
const original = projectSessionConversation(snapshot)
|
|
const retained = original.map(item => ({ ...item }))
|
|
let omittedMessages = 0
|
|
let droppedOmittedBytes = 0
|
|
const data = (): ReferencedSessionData => ({
|
|
sessionId: snapshot.session.id,
|
|
label,
|
|
cwd: snapshot.session.cwd ?? null,
|
|
capturedThroughSeq: snapshot.capturedThroughSeq,
|
|
conversation: retained.map(({ role, text }) => ({ role, text })),
|
|
})
|
|
const size = (): number => Buffer.byteLength(stringifyTagSafeJson(data()), 'utf8')
|
|
|
|
while (size() > maxBytes) {
|
|
const newestIndex = retained.length - 1
|
|
const dropIndex = retained.findIndex((item, index) => !item.checkpoint && index !== newestIndex)
|
|
if (dropIndex < 0) break
|
|
const removed = retained.splice(dropIndex, 1)[0]
|
|
/* v8 ignore next 3 -- dropIndex came from this exact array and is non-negative. */
|
|
if (removed === undefined) {
|
|
throw new Error('session-reference retention selected a missing message')
|
|
}
|
|
omittedMessages += 1
|
|
droppedOmittedBytes += Buffer.byteLength(removed.originalText, 'utf8')
|
|
}
|
|
|
|
while (size() > maxBytes) {
|
|
let longestIndex = -1
|
|
let longestBytes = 0
|
|
for (const [index, item] of retained.entries()) {
|
|
const bytes = Buffer.byteLength(item.text, 'utf8')
|
|
if (bytes > longestBytes) {
|
|
longestBytes = bytes
|
|
longestIndex = index
|
|
}
|
|
}
|
|
if (longestIndex < 0 || longestBytes === 0) return undefined
|
|
const overflow = size() - maxBytes
|
|
const target = Math.max(0, longestBytes - overflow)
|
|
const item = retained[longestIndex]
|
|
/* v8 ignore next 3 -- longestIndex was selected from this exact array's entries. */
|
|
if (item === undefined) {
|
|
throw new Error('session-reference retention selected a missing longest message')
|
|
}
|
|
const shortened = truncateWithNotice(item.originalText, target)
|
|
/* v8 ignore next -- strictly lowering the byte target must change a complete-string retention result. */
|
|
if (shortened.text === retained[longestIndex]?.text) return undefined
|
|
retained[longestIndex] = { ...item, text: shortened.text, omittedBytes: shortened.omittedBytes }
|
|
}
|
|
|
|
const compacted = original.some(item => item.checkpoint)
|
|
const retainedOmittedBytes = retained.reduce((sum, item) => sum + item.omittedBytes, 0)
|
|
const omittedBytes = retainedOmittedBytes + droppedOmittedBytes
|
|
return {
|
|
data: data(),
|
|
stats: {
|
|
compacted,
|
|
originalMessages: original.length,
|
|
retainedMessages: retained.length,
|
|
omittedMessages,
|
|
omittedBytes,
|
|
truncated: omittedMessages > 0 || omittedBytes > 0,
|
|
},
|
|
}
|
|
}
|
|
|
|
function textContent(content: readonly { type: string; text?: string }[]): string {
|
|
return content.flatMap(block => block.type === 'text' && typeof block.text === 'string' ? [block.text] : []).join('\n')
|
|
}
|
|
|
|
function truncateWithNotice(text: string, maxOutputBytes: number): { text: string; omittedBytes: number } {
|
|
/* v8 ignore next -- callers invoke this only with a target smaller than the selected original text. */
|
|
if (Buffer.byteLength(text, 'utf8') <= maxOutputBytes) return { text, omittedBytes: 0 }
|
|
let low = 0
|
|
let high = maxOutputBytes
|
|
let best = { text: '', omittedBytes: Buffer.byteLength(text, 'utf8') }
|
|
while (low <= high) {
|
|
const retainedBytes = Math.floor((low + high) / 2)
|
|
const headBytes = Math.ceil(retainedBytes / 2)
|
|
const tailBytes = Math.floor(retainedBytes / 2)
|
|
const retainer = new TextRetainer({ kind: 'headTail', headBytes, tailBytes })
|
|
retainer.push(text)
|
|
const result = retainer.finish()
|
|
// The complete source string was pushed before `finish()`, so omission is exact.
|
|
/* v8 ignore next 3 -- complete-string TextRetainer input cannot report a lower bound. */
|
|
if (result.omittedBytes.kind !== 'exact') {
|
|
throw new Error('session-reference retention did not report exact omitted bytes')
|
|
}
|
|
const omitted = result.omittedBytes.count
|
|
const candidate = `${result.text}\n[… omitted ${omitted} UTF-8 bytes …]`
|
|
if (Buffer.byteLength(candidate, 'utf8') <= maxOutputBytes) {
|
|
best = { text: candidate, omittedBytes: omitted }
|
|
low = retainedBytes + 1
|
|
} else {
|
|
high = retainedBytes - 1
|
|
}
|
|
}
|
|
return best
|
|
}
|