mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
# Conflicts: # docs/event-producer-consumer.i18n.yaml # docs/event-producer-consumer.md # docs/event-producer-consumer.zh.md # packages/core/agent/src/runtime-types.ts
458 lines
16 KiB
TypeScript
458 lines
16 KiB
TypeScript
/** Cold Session history pagination and live-event source. */
|
|
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import { Deque } from '@deepseek-ai/dsh-deque'
|
|
import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
|
|
import {
|
|
isAppendSurfaceEvent,
|
|
isConversationReplacementEvent,
|
|
SessionLogOffset,
|
|
SessionSeq,
|
|
} from '@deepseek-ai/dsh-session'
|
|
import { foldConversation, isConversationSeqVisible } from '@deepseek-ai/dsh-session/conversation'
|
|
import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
|
|
import type {
|
|
SessionEvent,
|
|
SessionHeader,
|
|
SessionId,
|
|
SessionLogOffset as SessionLogOffsetType,
|
|
SessionSeqCursor,
|
|
} from '@deepseek-ai/dsh-session'
|
|
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
|
|
import type {} from '@deepseek-ai/dsh-subagent'
|
|
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
|
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
|
import type {
|
|
SessionAddress,
|
|
SessionAssistantStreamFrame,
|
|
SessionChunkRun,
|
|
SessionEventEntry,
|
|
SessionFollowRequest,
|
|
SessionFollowFrame,
|
|
SessionHistoryRecord,
|
|
SessionPage,
|
|
SessionPageRequest,
|
|
SessionProjectionBaseline,
|
|
SessionProjectionValues,
|
|
SessionWireHeader,
|
|
SessionWireEvent,
|
|
} from './types.ts'
|
|
import { SessionAssistantStreamAccumulator } from './assistant-stream.ts'
|
|
|
|
const DEFAULT_MAX_MESSAGES = 50
|
|
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
|
|
|
|
/** Implements cold-safe history operations delegated by the Session Controller. */
|
|
export class SessionHistoryController {
|
|
private readonly closeFollowers = new Set<() => void>()
|
|
private readonly assistantStreams = new Map<SessionId, SessionAssistantStreamAccumulator>()
|
|
|
|
/**
|
|
* @param ctx - Host context carrying Session query and projection services.
|
|
* @param promote - starts ordinary Session activation after snapshot delivery.
|
|
*/
|
|
constructor(
|
|
private readonly ctx: Context,
|
|
private readonly promote: (observation: SessionObservation) => void,
|
|
) {
|
|
ctx.on('agent/assistant-stream', ({ agent, frame }) => {
|
|
let stream = this.assistantStreams.get(agent.session.id)
|
|
if (stream === undefined) {
|
|
stream = new SessionAssistantStreamAccumulator()
|
|
this.assistantStreams.set(agent.session.id, stream)
|
|
}
|
|
stream.accept(frame)
|
|
}, { global: true })
|
|
ctx.on('agent/disposed', ({ agent }) => {
|
|
this.assistantStreams.delete(agent.session.id)
|
|
}, { global: true })
|
|
ctx.effect(() => () => {
|
|
for (const close of this.closeFollowers) close()
|
|
this.closeFollowers.clear()
|
|
}, 'session-controller.history')
|
|
}
|
|
|
|
/**
|
|
* Read one message-aligned history page without activating an Agent.
|
|
* @param request - durable address and backwards-page cursor.
|
|
* @param signal - caller cancellation for persistence reads.
|
|
* @returns a contiguous event page.
|
|
*/
|
|
async page(request: SessionPageRequest, signal: AbortSignal): Promise<SessionPage> {
|
|
validatePageRequest(request)
|
|
const throughSeq: SessionSeqCursor = request.throughSeq === -1
|
|
? -1
|
|
: SessionSeq(request.throughSeq)
|
|
const beforeSeq = request.beforeSeq === undefined
|
|
? undefined
|
|
: SessionLogOffset(request.beforeSeq)
|
|
using source = await this.sourceFor(request.address, signal, false)
|
|
signal.throwIfAborted()
|
|
const sourceLog = source.events
|
|
const sourceCursor: SessionSeqCursor = sourceLog.at(-1)?.seq ?? -1
|
|
if (throughSeq > sourceCursor) {
|
|
throw new RemoteError(
|
|
'gateway/bad-request',
|
|
`session page through seq ${String(throughSeq)} is past cursor ${String(sourceCursor)}`,
|
|
{},
|
|
)
|
|
}
|
|
/* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */
|
|
if (throughSeq >= 0 && sourceLog[throughSeq]?.seq !== throughSeq) {
|
|
throw new RemoteError('gateway/internal', `session log does not contain through seq ${String(throughSeq)}`, {})
|
|
}
|
|
const page = paginate(
|
|
sourceLog,
|
|
beforeSeq,
|
|
request.maxMessages ?? DEFAULT_MAX_MESSAGES,
|
|
throughSeq,
|
|
)
|
|
const records = pageRecords(page.events)
|
|
return {
|
|
records,
|
|
hasMore: page.hasMore,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Follow events appended after an initial cursor on one durable address.
|
|
* @param request - durable address and last committed sequence already held by the caller.
|
|
* @param signal - stream cancellation owned by the Remote carrier.
|
|
* @returns a complete opening snapshot followed by gap-free durable events and opted-in assistant frames.
|
|
*/
|
|
async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
|
|
validateFollowRequest(request)
|
|
const { address } = request
|
|
const target = addressId(address)
|
|
const buffered = new Deque<
|
|
| { readonly type: 'event'; readonly event: SessionEvent }
|
|
| {
|
|
readonly type: 'assistant-stream'
|
|
readonly frame: SessionAssistantStreamFrame
|
|
readonly ordinal: number
|
|
}
|
|
>()
|
|
let snapshotCursor: SessionSeqCursor | undefined
|
|
let assistantStreamOrdinal = 0
|
|
let wake: (() => void) | undefined
|
|
const notify = (): void => {
|
|
const resume = wake
|
|
wake = undefined
|
|
resume?.()
|
|
}
|
|
const follower = { closed: false }
|
|
const close = (): void => {
|
|
follower.closed = true
|
|
notify()
|
|
}
|
|
this.closeFollowers.add(close)
|
|
const disposeEvent = this.ctx.on('session/event', (session, event) => {
|
|
if (session.id !== target) return
|
|
buffered.pushBack({ type: 'event', event })
|
|
notify()
|
|
}, { global: true })
|
|
const disposeCreated = this.ctx.on('session/created', (session) => {
|
|
if (session.id !== target) return
|
|
// Constructor seed events have no session/event notification. Normally
|
|
// only the end-seed suffix is new; if persistence advanced after the
|
|
// opening observation, replay everything beyond that snapshot cursor.
|
|
const suffix = session.snapshotEvents(snapshotCursor === undefined
|
|
? session.firstLiveSeq
|
|
: SessionLogOffset(snapshotCursor + 1))
|
|
for (let index = suffix.length - 1; index >= 0; index -= 1) {
|
|
buffered.pushFront({ type: 'event', event: suffix[index] as SessionEvent })
|
|
}
|
|
notify()
|
|
}, { global: true })
|
|
const disposeAssistantStream = request.assistantStream !== true
|
|
? undefined
|
|
: this.ctx.on('agent/assistant-stream', ({ agent, frame }) => {
|
|
if (agent.session.id !== target) return
|
|
buffered.pushBack({
|
|
type: 'assistant-stream',
|
|
frame: wireAssistantStreamFrame(frame),
|
|
ordinal: ++assistantStreamOrdinal,
|
|
})
|
|
notify()
|
|
}, { global: true })
|
|
const onAbort = (): void => { notify() }
|
|
signal.addEventListener('abort', onAbort, { once: true })
|
|
try {
|
|
using source = await this.sourceFor(address, signal, true)
|
|
const events = source.events
|
|
signal.throwIfAborted()
|
|
const cursor = source.cursor
|
|
snapshotCursor = cursor
|
|
const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES)
|
|
const assistantStream = request.assistantStream === true
|
|
? this.assistantStreams.get(target)?.snapshot() ?? { revision: 0, attempts: [] }
|
|
: undefined
|
|
// The accumulator snapshot and this watermark are synchronous. Frames
|
|
// through the cut are represented or superseded by that baseline,
|
|
// including larger revisions from a retired Agent; later revision
|
|
// resets reach Client continuity validation.
|
|
const assistantStreamOrdinalCut = assistantStreamOrdinal
|
|
yield {
|
|
type: 'snapshot',
|
|
header: wireHeader(source.header, source.inheritedEventCount),
|
|
cursor,
|
|
records: pageRecords(page.events),
|
|
hasMore: page.hasMore,
|
|
projections: source.projections === undefined
|
|
? { asOfSeq: cursor, values: {} }
|
|
: projectionBlock(source.projections),
|
|
...assistantStream === undefined ? {} : { assistantStream },
|
|
}
|
|
if (address.kind === 'session' && source.source === 'prepared') {
|
|
const promotion = source.retain()
|
|
try {
|
|
this.promote(promotion)
|
|
} catch (error: unknown) {
|
|
promotion[Symbol.dispose]()
|
|
throw error
|
|
}
|
|
}
|
|
let nextOffset = SessionLogOffset(cursor + 1)
|
|
while (!follower.closed && !signal.aborted) {
|
|
const item = buffered.popFront()
|
|
if (item === undefined) {
|
|
await new Promise<void>((resolve) => { wake = resolve })
|
|
continue
|
|
}
|
|
if (item.type === 'assistant-stream') {
|
|
if (item.ordinal > assistantStreamOrdinalCut) {
|
|
yield { type: 'assistant-stream', frame: item.frame }
|
|
}
|
|
continue
|
|
}
|
|
const expectedSeq = SessionSeq(nextOffset)
|
|
if (item.event.seq < expectedSeq) continue
|
|
if (item.event.seq !== expectedSeq) {
|
|
throw new RemoteError('gateway/internal', `session event stream skipped seq ${String(expectedSeq)}`, {})
|
|
}
|
|
nextOffset = SessionLogOffset(nextOffset + 1)
|
|
yield entryFor(item.event)
|
|
}
|
|
} finally {
|
|
this.closeFollowers.delete(close)
|
|
signal.removeEventListener('abort', onAbort)
|
|
disposeCreated()
|
|
disposeEvent()
|
|
disposeAssistantStream?.()
|
|
}
|
|
}
|
|
|
|
private async sourceFor(
|
|
address: SessionAddress,
|
|
signal: AbortSignal,
|
|
withProjections: boolean,
|
|
): Promise<SessionObservation> {
|
|
const sessionId = addressId(address)
|
|
try {
|
|
const observation = await this.ctx.sessionQuery.observeSession(sessionId, {
|
|
signal,
|
|
projectionMode: withProjections || address.kind === 'subagent' ? 'all' : 'none',
|
|
})
|
|
if (observation.header.cwd === undefined) {
|
|
observation[Symbol.dispose]()
|
|
rejectNotFound(address)
|
|
}
|
|
try {
|
|
validateAddress(
|
|
address,
|
|
observation.header,
|
|
observation.inheritedEventCount,
|
|
observation.projections,
|
|
)
|
|
} catch (error: unknown) {
|
|
observation[Symbol.dispose]()
|
|
throw error
|
|
}
|
|
return observation
|
|
} catch (error: unknown) {
|
|
if (error instanceof SessionQueryError
|
|
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') rejectNotFound(address)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
function wireAssistantStreamFrame(frame: AssistantStreamFrame): SessionAssistantStreamFrame {
|
|
if (frame.type !== 'chunk') return frame
|
|
return {
|
|
...frame,
|
|
chunk: frame.chunk as JsonValue,
|
|
}
|
|
}
|
|
|
|
function projectionBlock(
|
|
snapshot: NonNullable<SessionObservation['projections']>,
|
|
): SessionProjectionBaseline {
|
|
return {
|
|
asOfSeq: snapshot.asOfSeq,
|
|
// Projection definitions validate whole JSON values before snapshot publication.
|
|
values: snapshot.values as SessionProjectionValues,
|
|
}
|
|
}
|
|
|
|
function validatePageRequest(request: SessionPageRequest): void {
|
|
if (!Number.isSafeInteger(request.throughSeq)
|
|
|| request.throughSeq < -1
|
|
|| Object.is(request.throughSeq, -0)) {
|
|
throw new RemoteError('gateway/bad-request', 'throughSeq must be an integer greater than or equal to -1', {})
|
|
}
|
|
if (request.beforeSeq !== undefined
|
|
&& (!Number.isSafeInteger(request.beforeSeq)
|
|
|| request.beforeSeq < 0
|
|
|| Object.is(request.beforeSeq, -0))) {
|
|
throw new RemoteError('gateway/bad-request', 'beforeSeq must be a non-negative safe integer', {})
|
|
}
|
|
if (request.maxMessages !== undefined
|
|
&& (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
|
|
throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {})
|
|
}
|
|
}
|
|
|
|
function validateFollowRequest(request: SessionFollowRequest): void {
|
|
if (request.maxMessages !== undefined
|
|
&& (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) {
|
|
throw new RemoteError('gateway/bad-request', 'maxMessages must be a positive safe integer', {})
|
|
}
|
|
}
|
|
|
|
function addressId(address: SessionAddress): SessionId {
|
|
return address.kind === 'session' ? address.sessionId : address.childSessionId
|
|
}
|
|
|
|
function validateAddress(
|
|
address: SessionAddress,
|
|
header: SessionHeader,
|
|
inheritedEventCount: SessionLogOffsetType,
|
|
projections: SessionObservation['projections'],
|
|
): void {
|
|
if (address.kind === 'session') {
|
|
if (header.origin === 'subagent') {
|
|
throw new RemoteError('session/agent-busy', 'subagent Sessions require their durable parent address', {
|
|
reason: 'use subagent delivery for this child session',
|
|
})
|
|
}
|
|
return
|
|
}
|
|
if (header.origin !== 'subagent' || header.parentSession !== address.parentSessionId) {
|
|
throw new RemoteError('subagent/unauthorized', 'subagent does not belong to the supplied parent', {
|
|
childSessionId: address.childSessionId,
|
|
})
|
|
}
|
|
const identity = projections?.values.subagent
|
|
if (identity === null) {
|
|
throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is corrupt', {
|
|
parentSessionId: address.parentSessionId,
|
|
childSessionId: address.childSessionId,
|
|
reason: 'corrupt',
|
|
})
|
|
}
|
|
if (identity === undefined || identity.seq < inheritedEventCount) {
|
|
throw new RemoteError('subagent/catalog-diagnostic', 'subagent descriptor is unavailable', {
|
|
parentSessionId: address.parentSessionId,
|
|
childSessionId: address.childSessionId,
|
|
reason: 'unsupported',
|
|
})
|
|
}
|
|
if (identity.mode !== address.mode) {
|
|
throw new RemoteError('subagent/unauthorized', 'subagent mode does not match the supplied address', {
|
|
childSessionId: address.childSessionId,
|
|
})
|
|
}
|
|
}
|
|
|
|
function rejectNotFound(address: SessionAddress): never {
|
|
if (address.kind === 'session') {
|
|
throw new RemoteError('session/not-found', `session "${address.sessionId}" not found`, { sessionId: address.sessionId })
|
|
}
|
|
throw new RemoteError('subagent/not-found', 'subagent is unavailable', {
|
|
parentSessionId: address.parentSessionId,
|
|
childSessionId: address.childSessionId,
|
|
})
|
|
}
|
|
|
|
function paginate(
|
|
events: readonly SessionEvent[],
|
|
beforeSeq: SessionLogOffsetType | undefined,
|
|
maxMessages: number,
|
|
throughSeq: SessionSeqCursor = events.at(-1)?.seq ?? -1,
|
|
): { readonly events: SessionEvent[]; readonly hasMore: boolean } {
|
|
const end = SessionLogOffset(Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1))
|
|
const conversation = foldConversation(events)
|
|
let count = 0
|
|
let cut = SessionLogOffset(0)
|
|
for (let index = end - 1; index >= 0; index--) {
|
|
const event = events[index] as SessionEvent
|
|
if (!isConversationSeqVisible(event.seq, conversation.hiddenRanges)) continue
|
|
if (!MESSAGE_TYPES.has(event.type)
|
|
|| (!isAppendSurfaceEvent(event) && !isConversationReplacementEvent(event))) continue
|
|
count++
|
|
const sources = isConversationReplacementEvent(event)
|
|
? undefined
|
|
: event.sourceEventSeqs
|
|
let groupStart = event.seq
|
|
if (sources !== undefined) {
|
|
for (const source of sources) {
|
|
if (source < groupStart) groupStart = source
|
|
}
|
|
}
|
|
if (count >= maxMessages) {
|
|
cut = SessionLogOffset(groupStart)
|
|
break
|
|
}
|
|
}
|
|
return { events: events.slice(cut, end), hasMore: cut > 0 }
|
|
}
|
|
|
|
/** Translate logical Session metadata to the unchanged v0 browser wire. */
|
|
function wireHeader(
|
|
header: SessionHeader,
|
|
inheritedEventCount: SessionLogOffsetType,
|
|
): SessionWireHeader {
|
|
const { isSeeded, ...wire } = header
|
|
return {
|
|
...wire,
|
|
...isSeeded ? { seedLength: inheritedEventCount } : {},
|
|
}
|
|
}
|
|
|
|
function entryFor(event: SessionEvent): SessionEventEntry {
|
|
return {
|
|
type: 'event',
|
|
// Session.append validates and freezes event data as JSON before publication.
|
|
event: event as unknown as SessionWireEvent,
|
|
}
|
|
}
|
|
|
|
function chunkEntryFor(row: ChunkRow): SessionChunkRun {
|
|
switch (row.type) {
|
|
case 'text-chunks':
|
|
return {
|
|
type: 'chunks',
|
|
event: { type: 'chunkrow/text-chunks', seq: row.seq0, time: row.time0, data: row.data },
|
|
}
|
|
case 'reasoning-chunks':
|
|
return {
|
|
type: 'chunks',
|
|
event: { type: 'chunkrow/reasoning-chunks', seq: row.seq0, time: row.time0, data: row.data },
|
|
}
|
|
case 'tool-call-chunks':
|
|
return {
|
|
type: 'chunks',
|
|
event: { type: 'chunkrow/tool-call-chunks', seq: row.seq0, time: row.time0, data: row.data },
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Encode one bounded logical page without changing its pagination cut. */
|
|
function pageRecords(events: readonly SessionEvent[]): SessionHistoryRecord[] {
|
|
return packChunkRuns(events).map(record => isChunkRow(record)
|
|
? chunkEntryFor(record)
|
|
: entryFor(record))
|
|
}
|