mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-11 04:00:38 +00:00
feat(agent): emit live assistant stream frames
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
/** Process-local assistant state retained for reconnecting Web followers. */
|
||||
|
||||
import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import type {
|
||||
SessionAssistantStreamAttempt,
|
||||
SessionAssistantStreamBaseline,
|
||||
} from './types.ts'
|
||||
|
||||
type ChunkFrame = Extract<AssistantStreamFrame, { type: 'chunk' }>
|
||||
|
||||
interface MutableAttempt {
|
||||
readonly attemptId: SessionAssistantStreamAttempt['attemptId']
|
||||
readonly startedTime: number
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
readonly chunks: ChunkFrame[]
|
||||
readonly legacyChunkSeqs: number[]
|
||||
}
|
||||
|
||||
const EMPTY_BASELINE: SessionAssistantStreamBaseline = { revision: 0, attempts: [] }
|
||||
|
||||
/**
|
||||
* Folds dense Agent frames and materializes one shared immutable reconnect
|
||||
* baseline per accepted revision.
|
||||
*/
|
||||
export class SessionAssistantStreamAccumulator {
|
||||
private readonly attempts = new Map<string, MutableAttempt>()
|
||||
private revision = 0
|
||||
private snapshotValue: SessionAssistantStreamBaseline = EMPTY_BASELINE
|
||||
private dirty = false
|
||||
|
||||
/**
|
||||
* Fold one trusted frame from the current attached Agent lifecycle.
|
||||
* @param frame - next dense process-local Assistant frame.
|
||||
*/
|
||||
accept(frame: AssistantStreamFrame): void {
|
||||
if (frame.type === 'start' && frame.revision === 1 && this.revision !== 0) {
|
||||
this.attempts.clear()
|
||||
this.revision = 0
|
||||
}
|
||||
if (frame.revision !== this.revision + 1) {
|
||||
this.attempts.clear()
|
||||
this.revision = frame.revision
|
||||
this.dirty = true
|
||||
return
|
||||
}
|
||||
this.revision = frame.revision
|
||||
switch (frame.type) {
|
||||
case 'start':
|
||||
this.attempts.set(String(frame.attemptId), {
|
||||
attemptId: frame.attemptId,
|
||||
startedTime: frame.startedTime,
|
||||
turn: frame.turn,
|
||||
step: frame.step,
|
||||
chunks: [],
|
||||
legacyChunkSeqs: [],
|
||||
})
|
||||
break
|
||||
case 'chunk': {
|
||||
const attempt = this.attempts.get(String(frame.attemptId))
|
||||
if (attempt === undefined || frame.index !== attempt.chunks.length) {
|
||||
this.attempts.clear()
|
||||
break
|
||||
}
|
||||
attempt.chunks.push(frame)
|
||||
attempt.legacyChunkSeqs.push(frame.legacyChunkSeq)
|
||||
break
|
||||
}
|
||||
case 'end':
|
||||
this.attempts.delete(String(frame.attemptId))
|
||||
break
|
||||
}
|
||||
this.dirty = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the cached reconnect baseline, materializing it after a state change.
|
||||
* @returns the identity-stable baseline for the latest accepted revision.
|
||||
*/
|
||||
snapshot(): SessionAssistantStreamBaseline {
|
||||
if (!this.dirty) return this.snapshotValue
|
||||
this.snapshotValue = {
|
||||
revision: this.revision,
|
||||
attempts: [...this.attempts.values()].map(attempt => ({
|
||||
attemptId: attempt.attemptId,
|
||||
startedTime: attempt.startedTime,
|
||||
turn: attempt.turn,
|
||||
step: attempt.step,
|
||||
chunks: attempt.chunks.map(frame => frame.chunk as JsonValue),
|
||||
legacyChunkSeqs: [...attempt.legacyChunkSeqs],
|
||||
})),
|
||||
}
|
||||
this.dirty = false
|
||||
return this.snapshotValue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/** Web presentation fold joining durable v1 events with transient assistant frames. */
|
||||
|
||||
import type {
|
||||
SessionAssistantStreamBaseline,
|
||||
SessionAssistantStreamFrame,
|
||||
} from '../../types.ts'
|
||||
import type {
|
||||
SessionEventLikeEntry,
|
||||
SessionLiveEventEntry,
|
||||
} from '../contract/events.ts'
|
||||
|
||||
interface ActiveAttempt {
|
||||
readonly startedTime: number
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
readonly legacyChunkSeqs: Set<number>
|
||||
nextIndex: number
|
||||
}
|
||||
|
||||
/** One Web publication decision from the assistant stream fold. */
|
||||
export type ClientAssistantStreamResult =
|
||||
| { readonly type: 'publish'; readonly entry: SessionLiveEventEntry }
|
||||
| { readonly type: 'rebaseline' }
|
||||
| undefined
|
||||
|
||||
function positionKey(turn: number, step: number): string {
|
||||
return `${String(turn)}:${String(step)}`
|
||||
}
|
||||
|
||||
function sameSeqs(left: readonly number[], right: readonly number[]): boolean {
|
||||
return left.length === right.length && left.every((seq, index) => seq === right[index])
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps transient Assistant presentation behind one small interface. Durable
|
||||
* chunks and final messages publish only at their matching live frame.
|
||||
*/
|
||||
export class ClientAssistantStream {
|
||||
private readonly attempts = new Map<string, ActiveAttempt>()
|
||||
private readonly pendingChunks = new Map<number, SessionLiveEventEntry>()
|
||||
private readonly pendingMessages = new Map<string, SessionLiveEventEntry>()
|
||||
private publishedSeqs = new Set<number>()
|
||||
|
||||
/**
|
||||
* Replace the durable Web window and adopt an optional reconnect baseline.
|
||||
* @param entries - complete event window from the journal replacement.
|
||||
* @param baseline - active process-local attempts for a follow opening.
|
||||
* @returns the same durable window; baseline seqs suppress later duplicate live appends.
|
||||
*/
|
||||
replace(
|
||||
entries: readonly SessionEventLikeEntry[],
|
||||
baseline?: SessionAssistantStreamBaseline,
|
||||
): readonly SessionEventLikeEntry[] {
|
||||
this.pendingChunks.clear()
|
||||
this.pendingMessages.clear()
|
||||
this.attempts.clear()
|
||||
if (baseline !== undefined) {
|
||||
for (const attempt of baseline.attempts) {
|
||||
this.attempts.set(String(attempt.attemptId), {
|
||||
startedTime: attempt.startedTime,
|
||||
turn: attempt.turn,
|
||||
step: attempt.step,
|
||||
legacyChunkSeqs: new Set(attempt.legacyChunkSeqs),
|
||||
nextIndex: attempt.chunks.length,
|
||||
})
|
||||
}
|
||||
}
|
||||
const visible = entries.filter((entry) => {
|
||||
if (entry.type !== 'event' || entry.event.type !== 'assistant/message'
|
||||
|| entry.event.surfaceOp !== 'append') return true
|
||||
const attempt = this.attemptForSettlement(entry.event)
|
||||
if (attempt === undefined) return true
|
||||
this.pendingMessages.set(positionKey(attempt.turn, attempt.step), entry)
|
||||
return false
|
||||
})
|
||||
this.publishedSeqs = new Set(visible.map(entry => entry.event.seq))
|
||||
return visible
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage one durable tail event when an active attempt owns its publication.
|
||||
* @param entry - next cursor-validated durable event.
|
||||
* @returns the entry for immediate publication, or undefined while staged.
|
||||
*/
|
||||
acceptDurable(entry: SessionLiveEventEntry): ClientAssistantStreamResult {
|
||||
const event = entry.event
|
||||
if (event.type === 'assistant/chunk') {
|
||||
const attempt = this.attemptFor(event.data.turn, event.data.step)
|
||||
if (attempt === undefined) return this.publish(entry)
|
||||
this.pendingChunks.set(event.seq, entry)
|
||||
return undefined
|
||||
}
|
||||
if (event.type === 'assistant/message') {
|
||||
if (event.surfaceOp !== 'append') return this.publish(entry)
|
||||
const attempt = this.attemptFor(event.data.turn, event.data.step)
|
||||
if (attempt === undefined) return this.publish(entry)
|
||||
this.pendingMessages.set(positionKey(event.data.turn, event.data.step), entry)
|
||||
return undefined
|
||||
}
|
||||
return this.publish(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one validated transient frame and release its matching durable event.
|
||||
* @param frame - next dense process-local frame.
|
||||
* @returns one durable event whose Web publication commits at this frame.
|
||||
*/
|
||||
acceptFrame(frame: SessionAssistantStreamFrame): ClientAssistantStreamResult {
|
||||
switch (frame.type) {
|
||||
case 'start':
|
||||
this.attempts.set(String(frame.attemptId), {
|
||||
startedTime: frame.startedTime,
|
||||
turn: frame.turn,
|
||||
step: frame.step,
|
||||
legacyChunkSeqs: new Set(),
|
||||
nextIndex: 0,
|
||||
})
|
||||
return undefined
|
||||
case 'chunk': {
|
||||
const attempt = this.attempts.get(String(frame.attemptId))
|
||||
if (attempt === undefined || frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
|
||||
attempt.nextIndex += 1
|
||||
attempt.legacyChunkSeqs.add(frame.legacyChunkSeq)
|
||||
if (this.publishedSeqs.has(frame.legacyChunkSeq)) return undefined
|
||||
const entry = this.pendingChunks.get(frame.legacyChunkSeq)
|
||||
if (entry === undefined) return { type: 'rebaseline' }
|
||||
this.pendingChunks.delete(frame.legacyChunkSeq)
|
||||
return this.publish(entry)
|
||||
}
|
||||
case 'end': {
|
||||
const attempt = this.attempts.get(String(frame.attemptId))
|
||||
this.attempts.delete(String(frame.attemptId))
|
||||
if (attempt === undefined) return { type: 'rebaseline' }
|
||||
if (frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
|
||||
if (!sameSeqs([...attempt.legacyChunkSeqs], frame.legacyChunkSeqs)) {
|
||||
return { type: 'rebaseline' }
|
||||
}
|
||||
const key = positionKey(attempt.turn, attempt.step)
|
||||
const entry = this.pendingMessages.get(key)
|
||||
if (entry === undefined) {
|
||||
return frame.outcome === 'aborted' ? undefined : { type: 'rebaseline' }
|
||||
}
|
||||
if (entry.event.type !== 'assistant/message') return { type: 'rebaseline' }
|
||||
const sourceEventSeqs = entry.event.sourceEventSeqs
|
||||
if (sourceEventSeqs === undefined || !sameSeqs(sourceEventSeqs, frame.legacyChunkSeqs)) {
|
||||
return { type: 'rebaseline' }
|
||||
}
|
||||
this.pendingMessages.delete(key)
|
||||
return this.publish(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private attemptFor(turn: number, step: number): ActiveAttempt | undefined {
|
||||
return [...this.attempts.values()].find(attempt => (
|
||||
attempt.turn === turn && attempt.step === step
|
||||
))
|
||||
}
|
||||
|
||||
private attemptForSettlement(
|
||||
event: Extract<SessionLiveEventEntry['event'], { type: 'assistant/message' }>,
|
||||
): ActiveAttempt | undefined {
|
||||
const sourceEventSeqs = event.sourceEventSeqs
|
||||
if (sourceEventSeqs === undefined) return undefined
|
||||
return [...this.attempts.values()].find(attempt => (
|
||||
attempt.turn === event.data.turn
|
||||
&& attempt.step === event.data.step
|
||||
&& sameSeqs([...attempt.legacyChunkSeqs], sourceEventSeqs)
|
||||
))
|
||||
}
|
||||
|
||||
private publish(entry: SessionLiveEventEntry): ClientAssistantStreamResult {
|
||||
this.publishedSeqs.add(entry.event.seq)
|
||||
return { type: 'publish', entry }
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
PromptContentPart,
|
||||
QueueAction,
|
||||
SessionAddress,
|
||||
SessionAssistantStreamBaseline,
|
||||
SessionControlFrame,
|
||||
SessionProjectionBaseline,
|
||||
SessionQueuedItem,
|
||||
@@ -35,6 +36,10 @@ import { ProjectionValueStore } from './projection-store.ts'
|
||||
import type { ProjectionsBaseline } from './projection-store.ts'
|
||||
import { resolvedClientTimeZone } from '../time-zone.ts'
|
||||
import { SessionQueueMirror } from './queue-mirror.ts'
|
||||
import {
|
||||
ClientAssistantStream,
|
||||
type ClientAssistantStreamResult,
|
||||
} from './assistant-stream.ts'
|
||||
|
||||
function projectionsBaseline(value: SessionProjectionBaseline): ProjectionsBaseline {
|
||||
return {
|
||||
@@ -95,6 +100,7 @@ export class Session implements SessionFace {
|
||||
private jumpPromise: Promise<void> | null = null
|
||||
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
|
||||
private readonly queueMirror = new SessionQueueMirror()
|
||||
private readonly assistantStream = new ClientAssistantStream()
|
||||
private running = false
|
||||
private address: SubagentAddress | undefined
|
||||
private parentAvailable: boolean | undefined
|
||||
@@ -620,27 +626,50 @@ export class Session implements SessionFace {
|
||||
change.entries,
|
||||
change.hasMore,
|
||||
change.page.projections === undefined ? undefined : projectionsBaseline(change.page.projections),
|
||||
change.page.assistantStream,
|
||||
)
|
||||
return
|
||||
case 'prepend':
|
||||
this.prependWindow(change.entries, change.hasMore)
|
||||
return
|
||||
case 'append':
|
||||
if (this.appendLive(change.entry)) this.notifier.markDirty()
|
||||
this.publishAssistantEntry(this.assistantStream.acceptDurable(change.entry))
|
||||
return
|
||||
case 'assistant-stream':
|
||||
this.publishAssistantEntry(this.assistantStream.acceptFrame(change.frame))
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace the complete contiguous window and apply page-owned projection metadata. */
|
||||
private installWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void {
|
||||
this.baseSeq = SessionLogOffset(entries[0]?.event.seq ?? 0)
|
||||
private installWindow(
|
||||
entries: readonly SessionEventLikeEntry[],
|
||||
hasMore: boolean,
|
||||
projections?: ProjectionsBaseline,
|
||||
assistantStream?: SessionAssistantStreamBaseline,
|
||||
): void {
|
||||
const visible = this.assistantStream.replace(entries, assistantStream)
|
||||
this.baseSeq = SessionLogOffset(visible[0]?.event.seq ?? 0)
|
||||
this.hasMore = hasMore
|
||||
if (entries.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
|
||||
if (visible.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false
|
||||
if (projections !== undefined) this.projections.seed(projections)
|
||||
this.eventSource.replace(entries, hasMore)
|
||||
for (const entry of entries) this.observeSubmissionEvent(entry.event)
|
||||
this.eventSource.replace(visible, hasMore)
|
||||
for (const entry of visible) this.observeSubmissionEvent(entry.event)
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
private publishAssistantEntry(result: ClientAssistantStreamResult): void {
|
||||
if (result?.type === 'rebaseline') {
|
||||
const events = this.events
|
||||
queueMicrotask(() => {
|
||||
if (events !== undefined && this.events === events) events.restart()
|
||||
})
|
||||
return
|
||||
}
|
||||
if (result?.type === 'publish' && this.appendLive(result.entry)) {
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepend one stream-validated history page. */
|
||||
private prependWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void {
|
||||
this.baseSeq = entries[0] === undefined ? this.baseSeq : SessionLogOffset(entries[0].event.seq)
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type {
|
||||
SessionAddress,
|
||||
SessionAssistantStreamBaseline,
|
||||
SessionAssistantStreamFrame,
|
||||
SessionControlFrame,
|
||||
SessionHistoryRecord,
|
||||
SessionPage,
|
||||
@@ -40,6 +42,7 @@ export type SessionRemote = ClientRemote['session']
|
||||
/** Opening metadata carried only by a follow snapshot, never by loadOlder pages. */
|
||||
interface SessionJournalPage extends SessionPage {
|
||||
readonly projections?: SessionProjectionBaseline
|
||||
readonly assistantStream?: SessionAssistantStreamBaseline
|
||||
}
|
||||
|
||||
/** One complete publication from the Session journal stream. */
|
||||
@@ -51,9 +54,12 @@ export type SessionJournalChange =
|
||||
readonly hasMore: boolean
|
||||
}
|
||||
| { readonly type: 'append'; readonly entry: SessionLiveEventEntry }
|
||||
| { readonly type: 'assistant-stream'; readonly frame: SessionAssistantStreamFrame }
|
||||
|
||||
function toSessionJournalChange(
|
||||
change: RemoteJournalChange<SessionJournalPage, SessionHistoryRecord>,
|
||||
change: RemoteJournalChange<
|
||||
SessionJournalPage, SessionHistoryRecord, SessionAssistantStreamFrame
|
||||
>,
|
||||
): SessionJournalChange {
|
||||
switch (change.type) {
|
||||
case 'replace':
|
||||
@@ -72,6 +78,8 @@ function toSessionJournalChange(
|
||||
entry: change.entry as unknown as SessionLiveEventEntry,
|
||||
}
|
||||
}
|
||||
case 'notification':
|
||||
return { type: 'assistant-stream', frame: change.notification }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +144,8 @@ export class SessionEventStream extends RemoteJournalStream<
|
||||
SessionJournalPage,
|
||||
SessionHistoryRecord,
|
||||
number,
|
||||
ClientSessionPageRequest
|
||||
ClientSessionPageRequest,
|
||||
SessionAssistantStreamFrame
|
||||
> {
|
||||
/**
|
||||
* @param remote - generated Session namespace and Gateway stream factory.
|
||||
@@ -169,12 +178,24 @@ export class SessionEventStream extends RemoteJournalStream<
|
||||
protected override async * follow(
|
||||
request: ClientSessionPageRequest,
|
||||
signal: AbortSignal,
|
||||
): AsyncIterable<RemoteJournalFrame<SessionHistoryRecord, number, SessionJournalPage>> {
|
||||
): AsyncIterable<RemoteJournalFrame<
|
||||
SessionHistoryRecord, number, SessionJournalPage, SessionAssistantStreamFrame
|
||||
>> {
|
||||
let assistantRevision: number | undefined
|
||||
for await (const frame of this.remote.session.follow({
|
||||
address: this.address,
|
||||
assistantStream: true,
|
||||
...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }),
|
||||
}, signal)) {
|
||||
if (frame.type === 'snapshot') {
|
||||
if (frame.assistantStream === undefined) {
|
||||
throw new RemoteError(
|
||||
'gateway/internal',
|
||||
'session assistant stream omitted its opted-in opening baseline',
|
||||
{},
|
||||
)
|
||||
}
|
||||
assistantRevision = frame.assistantStream.revision
|
||||
yield {
|
||||
type: 'opened',
|
||||
cursor: frame.cursor,
|
||||
@@ -182,10 +203,22 @@ export class SessionEventStream extends RemoteJournalStream<
|
||||
records: frame.records,
|
||||
hasMore: frame.hasMore,
|
||||
projections: frame.projections,
|
||||
assistantStream: frame.assistantStream,
|
||||
},
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (frame.type === 'assistant-stream') {
|
||||
const expected = (assistantRevision ?? 0) + 1
|
||||
if (frame.frame.revision !== expected) {
|
||||
throw new RemoteStreamCarrierError(
|
||||
`session assistant stream skipped revision ${String(expected)}`,
|
||||
)
|
||||
}
|
||||
assistantRevision = frame.frame.revision
|
||||
yield { type: 'notification', notification: frame.frame }
|
||||
continue
|
||||
}
|
||||
yield { type: 'entry', entry: frame }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { Deque } from '@deepseek-ai/dsh-deque'
|
||||
import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
isAppendSurfaceEvent,
|
||||
SessionLogOffset,
|
||||
@@ -18,8 +19,10 @@ import type {
|
||||
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,
|
||||
@@ -32,6 +35,7 @@ import type {
|
||||
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'])
|
||||
@@ -39,6 +43,7 @@ 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.
|
||||
@@ -48,6 +53,17 @@ export class SessionHistoryController {
|
||||
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()
|
||||
@@ -100,14 +116,22 @@ export class SessionHistoryController {
|
||||
* 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 event frames.
|
||||
* @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<SessionEvent>()
|
||||
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
|
||||
@@ -122,7 +146,7 @@ export class SessionHistoryController {
|
||||
this.closeFollowers.add(close)
|
||||
const disposeEvent = this.ctx.on('session/event', (session, event) => {
|
||||
if (session.id !== target) return
|
||||
buffered.pushBack(event)
|
||||
buffered.pushBack({ type: 'event', event })
|
||||
notify()
|
||||
}, { global: true })
|
||||
const disposeCreated = this.ctx.on('session/created', (session) => {
|
||||
@@ -134,10 +158,21 @@ export class SessionHistoryController {
|
||||
? session.firstLiveSeq
|
||||
: SessionLogOffset(snapshotCursor + 1))
|
||||
for (let index = suffix.length - 1; index >= 0; index -= 1) {
|
||||
buffered.pushFront(suffix[index] as SessionEvent)
|
||||
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 {
|
||||
@@ -147,6 +182,14 @@ export class SessionHistoryController {
|
||||
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),
|
||||
@@ -156,6 +199,7 @@ export class SessionHistoryController {
|
||||
projections: source.projections === undefined
|
||||
? { asOfSeq: cursor, values: {} }
|
||||
: projectionBlock(source.projections),
|
||||
...assistantStream === undefined ? {} : { assistantStream },
|
||||
}
|
||||
if (address.kind === 'session' && source.source === 'prepared') {
|
||||
const promotion = source.retain()
|
||||
@@ -173,19 +217,26 @@ export class SessionHistoryController {
|
||||
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.seq < expectedSeq) continue
|
||||
if (item.seq !== expectedSeq) {
|
||||
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)
|
||||
yield entryFor(item.event)
|
||||
}
|
||||
} finally {
|
||||
this.closeFollowers.delete(close)
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
disposeCreated()
|
||||
disposeEvent()
|
||||
disposeAssistantStream?.()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +276,14 @@ export class SessionHistoryController {
|
||||
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -446,15 +446,63 @@ export interface SessionPageRequest {
|
||||
export interface SessionFollowRequest {
|
||||
readonly address: SessionAddress
|
||||
readonly maxMessages?: number
|
||||
/** Include process-local assistant presentation frames for the Web client. */
|
||||
readonly assistantStream?: true
|
||||
}
|
||||
|
||||
/** One active assistant attempt in a reconnect opening snapshot. */
|
||||
export interface SessionAssistantStreamAttempt {
|
||||
readonly attemptId: LlmAttemptId
|
||||
/** Safe-integer wall-clock time copied from the attempt's start frame. */
|
||||
readonly startedTime: number
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
readonly chunks: readonly JsonValue[]
|
||||
/** Exact durable v1 chunk records already represented by {@link chunks}. */
|
||||
readonly legacyChunkSeqs: readonly number[]
|
||||
}
|
||||
|
||||
/** Complete process-local assistant state at one follow opening. */
|
||||
export interface SessionAssistantStreamBaseline {
|
||||
readonly revision: number
|
||||
readonly attempts: readonly SessionAssistantStreamAttempt[]
|
||||
}
|
||||
|
||||
/** Browser wire form of one process-local assistant frame. */
|
||||
export type SessionAssistantStreamFrame =
|
||||
| {
|
||||
readonly type: 'start'
|
||||
readonly attemptId: LlmAttemptId
|
||||
readonly revision: number
|
||||
readonly startedTime: number
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
}
|
||||
| {
|
||||
readonly type: 'chunk'
|
||||
readonly attemptId: LlmAttemptId
|
||||
readonly revision: number
|
||||
readonly index: number
|
||||
readonly chunk: JsonValue
|
||||
readonly legacyChunkSeq: number
|
||||
}
|
||||
| {
|
||||
readonly type: 'end'
|
||||
readonly attemptId: LlmAttemptId
|
||||
readonly revision: number
|
||||
/** Number of chunk frames represented by this terminal marker. */
|
||||
readonly index: number
|
||||
readonly outcome: 'committed' | 'aborted'
|
||||
readonly legacyChunkSeqs: readonly number[]
|
||||
}
|
||||
|
||||
/** One contiguous backwards page of a Session log. */
|
||||
export interface SessionPage {
|
||||
readonly records: readonly SessionHistoryRecord[]
|
||||
readonly hasMore: boolean
|
||||
}
|
||||
|
||||
/** Complete opening window followed by ordered events appended after its cursor. */
|
||||
/** Complete opening window followed by ordered durable events and opted-in assistant frames. */
|
||||
export type SessionFollowFrame =
|
||||
| {
|
||||
readonly type: 'snapshot'
|
||||
@@ -463,8 +511,10 @@ export type SessionFollowFrame =
|
||||
readonly records: readonly SessionHistoryRecord[]
|
||||
readonly hasMore: boolean
|
||||
readonly projections: SessionProjectionBaseline
|
||||
readonly assistantStream?: SessionAssistantStreamBaseline
|
||||
}
|
||||
| SessionEventEntry
|
||||
| { readonly type: 'assistant-stream'; readonly frame: SessionAssistantStreamFrame }
|
||||
|
||||
/** One pending inbox occurrence in the authoritative queue snapshot. */
|
||||
export interface SessionQueuedItem {
|
||||
|
||||
Reference in New Issue
Block a user