mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-13 04:03:30 +00:00
Merge remote-tracking branch 'origin/master' into xtr/explicit-agent-context
# Conflicts: # packages/subagent/subagent/src/continuation.ts
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
/** Process-local assistant state retained for reconnecting Web followers. */
|
||||
|
||||
import type { AssistantStreamFrame } from '@deepseek-ai/dsh-agent'
|
||||
import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionSeqCursor } from '@deepseek-ai/dsh-session'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import type {
|
||||
SessionAssistantStreamAttempt,
|
||||
SessionAssistantStreamBaseline,
|
||||
} from './types.ts'
|
||||
|
||||
interface MutableAttempt {
|
||||
readonly attemptId: SessionAssistantStreamAttempt['attemptId']
|
||||
readonly startedAfterSeq: SessionSeqCursor
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
readonly stream: AssistantStreamAccumulator
|
||||
nextIndex: number
|
||||
}
|
||||
|
||||
const EMPTY_BASELINE: SessionAssistantStreamBaseline = { revision: 0 }
|
||||
|
||||
/**
|
||||
* Folds dense Agent frames and materializes one shared immutable reconnect
|
||||
* baseline per accepted revision.
|
||||
*/
|
||||
export class SessionAssistantStreamAccumulator {
|
||||
private activeAttempt: MutableAttempt | undefined
|
||||
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.
|
||||
* @param durableCursor - last committed Session seq when this frame was observed.
|
||||
*/
|
||||
accept(frame: AssistantStreamFrame, durableCursor: SessionSeqCursor): void {
|
||||
if (frame.type === 'start' && frame.revision === 1 && this.revision !== 0) {
|
||||
this.activeAttempt = undefined
|
||||
this.revision = 0
|
||||
}
|
||||
if (frame.revision !== this.revision + 1) {
|
||||
this.activeAttempt = undefined
|
||||
this.revision = frame.revision
|
||||
this.dirty = true
|
||||
return
|
||||
}
|
||||
this.revision = frame.revision
|
||||
switch (frame.type) {
|
||||
case 'start':
|
||||
this.activeAttempt = {
|
||||
attemptId: frame.attemptId,
|
||||
startedAfterSeq: durableCursor,
|
||||
turn: frame.turn,
|
||||
step: frame.step,
|
||||
stream: new AssistantStreamAccumulator(),
|
||||
nextIndex: 0,
|
||||
}
|
||||
break
|
||||
case 'chunk': {
|
||||
const attempt = this.activeAttempt
|
||||
if (attempt === undefined
|
||||
|| attempt.attemptId !== frame.attemptId
|
||||
|| frame.index !== attempt.nextIndex) {
|
||||
this.activeAttempt = undefined
|
||||
break
|
||||
}
|
||||
attempt.stream.push({ time: frame.time, chunk: frame.chunk })
|
||||
attempt.nextIndex += 1
|
||||
break
|
||||
}
|
||||
case 'end':
|
||||
this.activeAttempt = undefined
|
||||
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,
|
||||
...this.activeAttempt === undefined ? {} : {
|
||||
activeAttempt: {
|
||||
attemptId: this.activeAttempt.attemptId,
|
||||
startedAfterSeq: this.activeAttempt.startedAfterSeq,
|
||||
turn: this.activeAttempt.turn,
|
||||
step: this.activeAttempt.step,
|
||||
nextIndex: this.activeAttempt.nextIndex,
|
||||
stream: this.activeAttempt.stream.snapshot() as unknown as readonly JsonValue[],
|
||||
},
|
||||
},
|
||||
}
|
||||
this.dirty = false
|
||||
return this.snapshotValue
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,38 @@
|
||||
/** Observable contiguous Session event window consumed by domain assemblers. */
|
||||
import { notifySubscribers, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { LlmAttemptId, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ChunkRowEvent } from '../../types.ts'
|
||||
|
||||
/** Standard Session event or compact historical Assistant run. */
|
||||
export type SessionEventLike = SessionEvent | ChunkRowEvent
|
||||
/** Client-only live chunk presentation; `seq` orders the transient row between durable Session seqs. */
|
||||
export interface AssistantLiveChunkEvent {
|
||||
readonly type: 'assistant/live-chunk'
|
||||
readonly seq: number
|
||||
readonly time: number
|
||||
readonly data: {
|
||||
readonly attemptId: LlmAttemptId
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
readonly chunk: StreamChunk
|
||||
}
|
||||
}
|
||||
|
||||
/** Current durable Session event or one client-only live chunk presentation. */
|
||||
export type SessionEventLike = SessionEvent | AssistantLiveChunkEvent
|
||||
|
||||
/** Client history entry retaining its coarse transport discriminator. */
|
||||
export type SessionEventLikeEntry =
|
||||
| { readonly type: 'event'; readonly event: SessionEvent }
|
||||
| { readonly type: 'chunks'; readonly event: ChunkRowEvent }
|
||||
| { readonly type: 'transient'; readonly event: AssistantLiveChunkEvent }
|
||||
|
||||
/** Scalar live entry accepted by append-only Client paths. */
|
||||
export type SessionLiveEventEntry = Extract<SessionEventLikeEntry, { readonly type: 'event' }>
|
||||
/** Durable Assistant event that atomically supersedes one attempt's transient rows. */
|
||||
export interface SessionAssistantSettlementEntry {
|
||||
readonly type: 'event'
|
||||
readonly event: SessionEvent<'assistant/message'> | SessionEvent<'assistant/attempt'>
|
||||
}
|
||||
/** Client-only Assistant frame admitted outside durable cursor algebra. */
|
||||
export type SessionTransientEventEntry = Extract<SessionEventLikeEntry, { readonly type: 'transient' }>
|
||||
|
||||
interface EventWindowLeaf {
|
||||
readonly kind: 'leaf'
|
||||
@@ -78,7 +98,12 @@ function windowSnapshot(
|
||||
export type SessionEventChange =
|
||||
| { readonly kind: 'replace'; readonly entries: readonly SessionEventLikeEntry[] }
|
||||
| { readonly kind: 'prepend'; readonly entries: readonly SessionEventLikeEntry[] }
|
||||
| { readonly kind: 'append'; readonly entries: readonly SessionLiveEventEntry[] }
|
||||
| { readonly kind: 'append'; readonly entries: readonly SessionEventLikeEntry[] }
|
||||
| {
|
||||
readonly kind: 'settle-assistant'
|
||||
readonly attemptId: LlmAttemptId
|
||||
readonly entry?: SessionAssistantSettlementEntry
|
||||
}
|
||||
|
||||
/** Current contiguous event window and its latest synchronous delta. */
|
||||
export interface SessionEventWindow {
|
||||
@@ -139,7 +164,7 @@ export class MutableSessionEventSource implements SessionEventSource {
|
||||
* Append one contiguous live entry.
|
||||
* @param entry - live tail entry.
|
||||
*/
|
||||
append(entry: SessionLiveEventEntry): void {
|
||||
append(entry: SessionEventLikeEntry): void {
|
||||
const entries = [entry]
|
||||
this.window = concat(this.window, leaf(entries))
|
||||
this.publish(this.snapshot.hasMore, {
|
||||
@@ -148,6 +173,28 @@ export class MutableSessionEventSource implements SessionEventSource {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace one attempt's transient rows with its committed durable settlement.
|
||||
* @param attemptId - process-local attempt whose live rows are now redundant.
|
||||
* @param entry - durable settlement committed for that attempt.
|
||||
*/
|
||||
settleAssistant(attemptId: LlmAttemptId, entry?: SessionAssistantSettlementEntry): void {
|
||||
const entries = materialize(this.window).filter(candidate => (
|
||||
candidate.type !== 'transient' || candidate.event.data.attemptId !== attemptId
|
||||
))
|
||||
if (entry !== undefined) {
|
||||
const index = entries.findIndex(candidate => candidate.event.seq > entry.event.seq)
|
||||
if (index < 0) entries.push(entry)
|
||||
else entries.splice(index, 0, entry)
|
||||
}
|
||||
this.window = leaf(entries)
|
||||
this.publish(this.snapshot.hasMore, {
|
||||
kind: 'settle-assistant',
|
||||
attemptId,
|
||||
...(entry === undefined ? {} : { entry }),
|
||||
})
|
||||
}
|
||||
|
||||
private publish(
|
||||
hasMore: boolean,
|
||||
change: SessionEventChange,
|
||||
|
||||
@@ -7,22 +7,25 @@
|
||||
* must stub); implementation-internal entry points (history staging, wire-frame
|
||||
* dispatch) stay on the class, invisible out here.
|
||||
*/
|
||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts'
|
||||
import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts'
|
||||
import type { PendingSubmissionAttachment, SessionSnapshot } from './snapshot.ts'
|
||||
|
||||
/**
|
||||
* Why a local submission echo left the snapshot: `observed` when its durable
|
||||
* `user/message` event or host queue occurrence arrived (with the admitted
|
||||
* image references in prompt order), `failed` when the prompt was rejected,
|
||||
* attachment references in prompt order), `failed` when the prompt was rejected,
|
||||
* threw, or was aborted before acceptance.
|
||||
*/
|
||||
export type PendingSubmissionRetirement =
|
||||
| { readonly reason: 'observed'; readonly attachments: readonly ImageAttachmentRef[] }
|
||||
| {
|
||||
readonly reason: 'observed'
|
||||
readonly attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[]
|
||||
}
|
||||
| { readonly reason: 'failed' }
|
||||
|
||||
/** Input registering one local submission echo ahead of its prompt call. */
|
||||
@@ -31,8 +34,8 @@ export interface BeginSubmissionInput {
|
||||
readonly mode: 'queue' | 'steer'
|
||||
/** Prompt text exactly as the upcoming prompt will send it. */
|
||||
readonly text: string
|
||||
/** Ordered image previews matching the upcoming prompt's image parts. */
|
||||
readonly images: readonly PendingSubmissionImage[]
|
||||
/** Ordered image previews and durable file metadata matching the upcoming prompt attachments. */
|
||||
readonly attachments: readonly PendingSubmissionAttachment[]
|
||||
/** Settlement callback fired exactly once when the echo retires. */
|
||||
readonly onRetire?: (retirement: PendingSubmissionRetirement) => void
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Session-owned observable state excluding Conversation target data. */
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
|
||||
@@ -30,6 +31,23 @@ export interface PendingSubmissionImage {
|
||||
readonly height?: number
|
||||
}
|
||||
|
||||
/** Image branch of a local submission echo attachment. */
|
||||
export interface PendingSubmissionImageAttachment {
|
||||
readonly type: 'image'
|
||||
readonly value: PendingSubmissionImage
|
||||
}
|
||||
|
||||
/** File branch of a local submission echo attachment. */
|
||||
export interface PendingSubmissionFileAttachment {
|
||||
readonly type: 'file'
|
||||
readonly value: FileAttachmentRef
|
||||
}
|
||||
|
||||
/** One attachment displayed by a local submission echo, in prompt order. */
|
||||
export type PendingSubmissionAttachment =
|
||||
| PendingSubmissionImageAttachment
|
||||
| PendingSubmissionFileAttachment
|
||||
|
||||
/** Client surface selected when a local submission begins. */
|
||||
export type PendingSubmissionPlacement = 'transcript' | 'queued' | 'steering'
|
||||
|
||||
@@ -48,8 +66,8 @@ export interface PendingSubmission {
|
||||
readonly time: number
|
||||
/** Prompt text exactly as it will be sent (one text block). */
|
||||
readonly text: string
|
||||
/** Ordered image previews matching the prompt's image parts. */
|
||||
readonly images: readonly PendingSubmissionImage[]
|
||||
/** Ordered image previews and durable file metadata matching the prompt attachments. */
|
||||
readonly attachments: readonly PendingSubmissionAttachment[]
|
||||
}
|
||||
|
||||
/** History-open lifecycle of a Session event window. */
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-agent/types'
|
||||
import type {} from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-file-upload/client'
|
||||
import { createSessionControlStream } from './transport.ts'
|
||||
import { ClientSessions } from './sessions/service.ts'
|
||||
import type { SessionRemotes } from './sessions/remotes.ts'
|
||||
@@ -49,17 +51,23 @@ export type {
|
||||
export type { ISessions } from './contract/sessions.ts'
|
||||
export { MutableSessionEventSource } from './contract/events.ts'
|
||||
export type {
|
||||
AssistantLiveChunkEvent,
|
||||
SessionAssistantSettlementEntry,
|
||||
SessionEventChange,
|
||||
SessionEventLike,
|
||||
SessionEventLikeEntry,
|
||||
SessionEventSource,
|
||||
SessionEventWindow,
|
||||
SessionLiveEventEntry,
|
||||
SessionTransientEventEntry,
|
||||
} from './contract/events.ts'
|
||||
export type {
|
||||
OpenState,
|
||||
PendingSubmission,
|
||||
PendingSubmissionAttachment,
|
||||
PendingSubmissionFileAttachment,
|
||||
PendingSubmissionImage,
|
||||
PendingSubmissionImageAttachment,
|
||||
PendingSubmissionPlacement,
|
||||
PromptError,
|
||||
QueuedMessage,
|
||||
@@ -75,6 +83,8 @@ declare module '@deepseek-ai/cordis' {
|
||||
|
||||
/** Required Remote and Context projection services. */
|
||||
export const inject = [
|
||||
'connection',
|
||||
'fileUpload',
|
||||
'typert',
|
||||
'remote',
|
||||
'remote.commands',
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/** Web presentation fold joining transient Assistant frames to one durable v2 settlement. */
|
||||
|
||||
import type {
|
||||
SessionAssistantStreamBaseline,
|
||||
SessionAssistantStreamFrame,
|
||||
} from '../../types.ts'
|
||||
import { expandAssistantStream } from '@deepseek-ai/dsh-llm/assistant-stream'
|
||||
import type { AssistantStreamRecord } from '@deepseek-ai/dsh-llm/assistant-stream'
|
||||
import type { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type {
|
||||
SessionAssistantSettlementEntry,
|
||||
SessionEventLikeEntry,
|
||||
SessionLiveEventEntry,
|
||||
SessionTransientEventEntry,
|
||||
} from '../contract/events.ts'
|
||||
|
||||
interface ActiveAttempt {
|
||||
readonly attemptId: LlmAttemptId
|
||||
readonly startedAfterSeq: number
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
nextIndex: number
|
||||
}
|
||||
|
||||
/** One Web publication decision from the assistant stream fold. */
|
||||
export type ClientAssistantStreamResult =
|
||||
| { readonly type: 'publish'; readonly entry: SessionLiveEventEntry }
|
||||
| {
|
||||
readonly type: 'settlement'
|
||||
readonly attemptId: LlmAttemptId
|
||||
readonly entry: SessionAssistantSettlementEntry
|
||||
}
|
||||
| { readonly type: 'abandonment'; readonly attemptId: LlmAttemptId }
|
||||
| { readonly type: 'transient'; readonly entry: SessionTransientEventEntry }
|
||||
| { readonly type: 'rebaseline' }
|
||||
| undefined
|
||||
|
||||
/** Keeps transient Assistant presentation behind one settlement-aware interface. */
|
||||
export class ClientAssistantStream {
|
||||
private activeAttempt: ActiveAttempt | undefined
|
||||
private readonly pending = new Map<number, SessionAssistantSettlementEntry>()
|
||||
private publishedSeqs = new Set<number>()
|
||||
private durableCursor = -1
|
||||
private transientInGap = 0
|
||||
|
||||
/**
|
||||
* Replace the durable Web window and adopt an optional reconnect baseline.
|
||||
* @param entries - durable entries in the replacement window.
|
||||
* @param baseline - compact prefix for an Assistant attempt that is still live.
|
||||
* @returns immediately visible durable entries plus reconstructed transient chunks.
|
||||
*/
|
||||
replace(
|
||||
entries: readonly SessionEventLikeEntry[],
|
||||
baseline?: SessionAssistantStreamBaseline,
|
||||
): readonly SessionEventLikeEntry[] {
|
||||
this.pending.clear()
|
||||
this.transientInGap = 0
|
||||
this.activeAttempt = undefined
|
||||
const opening = baseline?.activeAttempt
|
||||
if (opening !== undefined) {
|
||||
this.activeAttempt = {
|
||||
attemptId: opening.attemptId,
|
||||
startedAfterSeq: opening.startedAfterSeq,
|
||||
turn: opening.turn,
|
||||
step: opening.step,
|
||||
nextIndex: opening.nextIndex,
|
||||
}
|
||||
}
|
||||
const visible: SessionEventLikeEntry[] = [...entries]
|
||||
this.publishedSeqs = new Set(visible.map(entry => entry.event.seq))
|
||||
this.durableCursor = visible.reduce((cursor, entry) => Math.max(cursor, entry.event.seq), -1)
|
||||
if (opening !== undefined) {
|
||||
for (const [index, member] of expandAssistantStream(
|
||||
opening.stream as unknown as readonly AssistantStreamRecord[],
|
||||
).entries()) {
|
||||
this.transientInGap += 1
|
||||
visible.push({
|
||||
type: 'transient',
|
||||
event: {
|
||||
type: 'assistant/live-chunk',
|
||||
seq: this.durableCursor + 1 - 1 / (this.transientInGap + 1),
|
||||
time: member.time,
|
||||
data: {
|
||||
attemptId: opening.attemptId,
|
||||
turn: opening.turn,
|
||||
step: opening.step,
|
||||
chunk: member.chunk,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (index + 1 >= opening.nextIndex) break
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage one durable v2 settlement while its matching live attempt is open.
|
||||
* @param entry - newly followed durable entry.
|
||||
* @returns a publication decision, or `undefined` when no entry becomes visible.
|
||||
*/
|
||||
acceptDurable(entry: SessionLiveEventEntry): ClientAssistantStreamResult {
|
||||
const event = entry.event
|
||||
this.durableCursor = Math.max(this.durableCursor, event.seq)
|
||||
this.transientInGap = 0
|
||||
const settlement = assistantSettlementEntry(entry)
|
||||
if (settlement !== undefined && this.attemptForSettlement(settlement.event) !== undefined) {
|
||||
if (this.pending.has(event.seq)) return { type: 'rebaseline' }
|
||||
this.pending.set(event.seq, settlement)
|
||||
return undefined
|
||||
}
|
||||
return this.publish(entry)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold one dense transient frame and release its named durable settlement.
|
||||
* @param frame - next Assistant stream frame received by the follow connection.
|
||||
* @returns a transient, publication, or rebaseline decision, or `undefined` when no entry becomes visible.
|
||||
*/
|
||||
acceptFrame(frame: SessionAssistantStreamFrame): ClientAssistantStreamResult {
|
||||
switch (frame.type) {
|
||||
case 'start':
|
||||
if (this.activeAttempt !== undefined || this.pending.size > 0) return { type: 'rebaseline' }
|
||||
this.pending.clear()
|
||||
this.activeAttempt = {
|
||||
attemptId: frame.attemptId,
|
||||
startedAfterSeq: frame.startedAfterSeq,
|
||||
turn: frame.turn,
|
||||
step: frame.step,
|
||||
nextIndex: 0,
|
||||
}
|
||||
return undefined
|
||||
case 'chunk': {
|
||||
const attempt = this.activeAttempt
|
||||
// A controller mounted after the Host saw this attempt has no start
|
||||
// frame to reconstruct. Its durable settlement publishes directly;
|
||||
// ignore the transient suffix until the next known start.
|
||||
if (attempt === undefined || attempt.attemptId !== frame.attemptId) return undefined
|
||||
if (frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
|
||||
attempt.nextIndex += 1
|
||||
this.transientInGap += 1
|
||||
return {
|
||||
type: 'transient',
|
||||
entry: {
|
||||
type: 'transient',
|
||||
event: {
|
||||
type: 'assistant/live-chunk',
|
||||
seq: this.durableCursor + 1 - 1 / (this.transientInGap + 1),
|
||||
time: frame.time,
|
||||
data: {
|
||||
attemptId: frame.attemptId,
|
||||
turn: attempt.turn,
|
||||
step: attempt.step,
|
||||
chunk: frame.chunk as never,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
case 'end': {
|
||||
const attempt = this.activeAttempt
|
||||
if (attempt === undefined || attempt.attemptId !== frame.attemptId) {
|
||||
return undefined
|
||||
}
|
||||
this.activeAttempt = undefined
|
||||
if (frame.index !== attempt.nextIndex) return { type: 'rebaseline' }
|
||||
if (frame.outcome.kind === 'abandoned') {
|
||||
return this.pending.size === 0
|
||||
? { type: 'abandonment', attemptId: attempt.attemptId }
|
||||
: { type: 'rebaseline' }
|
||||
}
|
||||
if (this.publishedSeqs.has(frame.outcome.seq)) return undefined
|
||||
const entry = this.pending.get(frame.outcome.seq)
|
||||
if (entry === undefined
|
||||
|| entry.event.type !== frame.outcome.eventType) {
|
||||
return { type: 'rebaseline' }
|
||||
}
|
||||
this.pending.delete(frame.outcome.seq)
|
||||
this.publishedSeqs.add(entry.event.seq)
|
||||
return { type: 'settlement', attemptId: attempt.attemptId, entry }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private attemptForSettlement(
|
||||
event: SessionAssistantSettlementEntry['event'],
|
||||
): ActiveAttempt | undefined {
|
||||
const attempt = this.activeAttempt
|
||||
if (attempt === undefined
|
||||
|| (event.type === 'assistant/message' && event.surfaceOp !== 'append')
|
||||
|| event.seq <= attempt.startedAfterSeq
|
||||
|| attempt.turn !== event.data.turn
|
||||
|| attempt.step !== event.data.step) return undefined
|
||||
return attempt
|
||||
}
|
||||
|
||||
private publish(entry: SessionLiveEventEntry): ClientAssistantStreamResult {
|
||||
this.publishedSeqs.add(entry.event.seq)
|
||||
return { type: 'publish', entry }
|
||||
}
|
||||
}
|
||||
|
||||
function assistantSettlementEntry(
|
||||
entry: SessionLiveEventEntry,
|
||||
): SessionAssistantSettlementEntry | undefined {
|
||||
return entry.event.type === 'assistant/message' || entry.event.type === 'assistant/attempt'
|
||||
? entry as SessionAssistantSettlementEntry
|
||||
: undefined
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export function historyEntries(
|
||||
|
||||
/**
|
||||
* Read the first logical sequence represented by one wire record.
|
||||
* @param record - validated scalar event or packed Assistant delta run.
|
||||
* @param record - validated Session event.
|
||||
* @returns inclusive first Session sequence.
|
||||
*/
|
||||
export function historyRecordFirstSeq(record: SessionHistoryRecord): number {
|
||||
@@ -27,13 +27,9 @@ export function historyRecordFirstSeq(record: SessionHistoryRecord): number {
|
||||
|
||||
/**
|
||||
* Read the final logical sequence represented by one wire record.
|
||||
* @param record - validated scalar event or packed Assistant delta run.
|
||||
* @param record - validated Session event.
|
||||
* @returns inclusive final Session sequence.
|
||||
*/
|
||||
export function historyRecordLastSeq(record: SessionHistoryRecord): number {
|
||||
if (record.type === 'event') return record.event.seq
|
||||
const length = record.event.type === 'chunkrow/tool-call-chunks'
|
||||
? record.event.data.args.length
|
||||
: record.event.data.texts.length
|
||||
return record.event.seq + length - 1
|
||||
return record.event.seq
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@ import type { QueuedMessage } from '../contract/snapshot.ts'
|
||||
|
||||
const QUEUE_PREVIEW_CHARS = 200
|
||||
|
||||
// Image blocks are excluded: queue presentation renders them as thumbnails
|
||||
// from `content`, so the text preview covers only what has no visual form.
|
||||
// Attachment blocks are excluded: queue presentation renders them from
|
||||
// `content`, so the text preview covers only what has no visual form.
|
||||
function previewOf(content: readonly ContentBlock[]): string {
|
||||
const flat = content
|
||||
.filter(block => block.type !== 'image')
|
||||
.filter(block => block.type !== 'image' && block.type !== 'file')
|
||||
.map(block => (block.type === 'text' ? block.text : `[${block.type}]`))
|
||||
.join(' ').replace(/\s+/g, ' ').trim()
|
||||
const chars = Array.from(flat)
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* @module @deepseek-ai/dsh-api-session-controller/client/sessions/remotes
|
||||
*/
|
||||
|
||||
import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types'
|
||||
import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import type { CommandSubmitAttachment } from '@deepseek-ai/dsh-commands/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, SubagentPromptRequest,
|
||||
@@ -19,7 +19,7 @@ export interface SessionCommandsRemote {
|
||||
execute(
|
||||
agentId: SessionId,
|
||||
line: string,
|
||||
images: readonly EncodedImageAttachment[],
|
||||
attachments: readonly CommandSubmitAttachment[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<RemoteResult<object | undefined>>
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { AttachmentIdType, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import { SessionLogOffset, SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
PromptContentPart,
|
||||
QueueAction,
|
||||
SessionAddress,
|
||||
SessionAssistantStreamBaseline,
|
||||
SessionControlFrame,
|
||||
SessionProjectionBaseline,
|
||||
SessionQueuedItem,
|
||||
@@ -29,12 +30,17 @@ import type {
|
||||
} from '../contract/events.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { SessionRemotes } from './remotes.ts'
|
||||
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 +101,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
|
||||
@@ -204,7 +211,7 @@ export class Session implements SessionFace {
|
||||
: 'transcript',
|
||||
time: Date.now(),
|
||||
text: input.text,
|
||||
images: input.images,
|
||||
attachments: input.attachments,
|
||||
}]
|
||||
this.submissionSettlements.set(requestId, { onRetire: input.onRetire, retiring: false })
|
||||
// The blank → engaging edge flips here, ahead of prompt(): the composer
|
||||
@@ -216,7 +223,7 @@ export class Session implements SessionFace {
|
||||
|
||||
/**
|
||||
* Send (queue/steer passed through 1:1); failures land in the snapshot's promptError.
|
||||
* @param content - text plus browser-owned temporary image uploads.
|
||||
* @param content - text, browser-owned temporary image uploads, and staged-file receipts.
|
||||
* @param mode - queue appends after the current turn; steer interrupts it.
|
||||
* @param signal - optional caller cancellation for the complete admission round-trip.
|
||||
* @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo.
|
||||
@@ -246,13 +253,25 @@ export class Session implements SessionFace {
|
||||
content,
|
||||
clientTimeZone,
|
||||
}, signal)
|
||||
} else if (content.some(part => part.type === 'file')) {
|
||||
result = {
|
||||
ok: false,
|
||||
error: new RemoteError(
|
||||
'subagent/attachment-invalid',
|
||||
'subagent continuation does not accept files',
|
||||
{ reason: 'SUBAGENT_FILE_UNSUPPORTED' },
|
||||
),
|
||||
}
|
||||
} else {
|
||||
// The preceding branch rejects file parts before the narrower subagent
|
||||
// wire type is used; this array is not filtered or reordered.
|
||||
const routedContent = content as Exclude<PromptContentPart, { readonly type: 'file' }>[]
|
||||
const routed = await this.remote.subagents.prompt({
|
||||
requestId: randomUUID() as SessionRequestId,
|
||||
parentSessionId: this.address.parentSessionId,
|
||||
childSessionId: this.address.childSessionId,
|
||||
mode: 'continuable',
|
||||
content,
|
||||
content: routedContent,
|
||||
clientTimeZone: resolvedClientTimeZone(),
|
||||
}, signal)
|
||||
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
|
||||
@@ -620,27 +639,67 @@ 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 {
|
||||
private installWindow(
|
||||
entries: readonly SessionEventLikeEntry[],
|
||||
hasMore: boolean,
|
||||
projections?: ProjectionsBaseline,
|
||||
assistantStream?: SessionAssistantStreamBaseline,
|
||||
): void {
|
||||
// A durable gap-repair page has no assistant baseline. Clearing transient
|
||||
// attempts makes a held notification reopen follow once for an atomic
|
||||
// page/baseline pair instead of applying it to an unrelated repair cut.
|
||||
const visible = this.assistantStream.replace(entries, assistantStream)
|
||||
this.baseSeq = SessionLogOffset(entries[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 === 'settlement') {
|
||||
this.eventSource.settleAssistant(result.attemptId, result.entry)
|
||||
this.observeSubmissionEvent(result.entry.event)
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
if (result?.type === 'abandonment') {
|
||||
this.eventSource.settleAssistant(result.attemptId)
|
||||
this.notifier.markDirty()
|
||||
return
|
||||
}
|
||||
if (result?.type === 'publish' && this.appendLive(result.entry)) {
|
||||
this.notifier.markDirty()
|
||||
} else if (result?.type === 'transient') {
|
||||
this.eventSource.append(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)
|
||||
@@ -671,7 +730,7 @@ export class Session implements SessionFace {
|
||||
const data = event.data as { readonly source?: unknown; readonly content?: unknown } | undefined
|
||||
const source = data?.source as { readonly kind?: unknown; readonly rpcId?: unknown } | undefined
|
||||
if (source?.kind !== 'user' || typeof source.rpcId !== 'string') return
|
||||
this.scheduleObservedRetirement(source.rpcId as SessionRequestId, imageRefsIn(data?.content))
|
||||
this.scheduleObservedRetirement(source.rpcId as SessionRequestId, attachmentRefsIn(data?.content))
|
||||
}
|
||||
|
||||
/** Retire echoes whose prompts landed in the host inbox instead of the log (running-turn submissions). */
|
||||
@@ -679,7 +738,7 @@ export class Session implements SessionFace {
|
||||
if (this.submissionSettlements.size === 0) return
|
||||
for (const item of items) {
|
||||
if (item.rpcId !== undefined) {
|
||||
this.scheduleObservedRetirement(item.rpcId, imageRefsIn(item.message.content))
|
||||
this.scheduleObservedRetirement(item.rpcId, attachmentRefsIn(item.message.content))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -692,7 +751,7 @@ export class Session implements SessionFace {
|
||||
*/
|
||||
private scheduleObservedRetirement(
|
||||
requestId: SessionRequestId,
|
||||
attachments: readonly ImageAttachmentRef[],
|
||||
attachments: readonly (ImageAttachmentRef | FileAttachmentRef)[],
|
||||
): void {
|
||||
const settlement = this.submissionSettlements.get(requestId)
|
||||
if (settlement === undefined || settlement.retiring) return
|
||||
@@ -770,15 +829,16 @@ function scheduleFrame(fn: () => void): void {
|
||||
else setTimeout(fn, 0)
|
||||
}
|
||||
|
||||
/** Image attachment references in one structurally-read content block list, in block order. */
|
||||
function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] {
|
||||
/** Attachment references in one structurally-read content block list, in block order. */
|
||||
function attachmentRefsIn(content: unknown): readonly (ImageAttachmentRef | FileAttachmentRef)[] {
|
||||
if (!Array.isArray(content)) return []
|
||||
const refs: ImageAttachmentRef[] = []
|
||||
const refs: Array<ImageAttachmentRef | FileAttachmentRef> = []
|
||||
for (const block of content) {
|
||||
if (typeof block !== 'object' || block === null) continue
|
||||
const candidate = block as { readonly type?: unknown; readonly attachment?: unknown }
|
||||
if (candidate.type === 'image' && typeof candidate.attachment === 'object' && candidate.attachment !== null) {
|
||||
refs.push(candidate.attachment as ImageAttachmentRef)
|
||||
if ((candidate.type === 'image' || candidate.type === 'file')
|
||||
&& typeof candidate.attachment === 'object' && candidate.attachment !== null) {
|
||||
refs.push(candidate.attachment as ImageAttachmentRef | FileAttachmentRef)
|
||||
}
|
||||
}
|
||||
return refs
|
||||
|
||||
@@ -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,27 +54,25 @@ 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':
|
||||
case 'prepend':
|
||||
return { ...change, entries: historyEntries(change.entries) }
|
||||
case 'append': {
|
||||
if (change.entry.type !== 'event') {
|
||||
throw new RemoteError(
|
||||
'gateway/internal',
|
||||
'session live stream emitted a packed history record',
|
||||
{},
|
||||
)
|
||||
}
|
||||
return {
|
||||
type: 'append',
|
||||
entry: change.entry as unknown as SessionLiveEventEntry,
|
||||
}
|
||||
}
|
||||
case 'notification':
|
||||
return { type: 'assistant-stream', frame: change.notification }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +137,8 @@ export class SessionEventStream extends RemoteJournalStream<
|
||||
SessionJournalPage,
|
||||
SessionHistoryRecord,
|
||||
number,
|
||||
ClientSessionPageRequest
|
||||
ClientSessionPageRequest,
|
||||
SessionAssistantStreamFrame
|
||||
> {
|
||||
/**
|
||||
* @param remote - generated Session namespace and Gateway stream factory.
|
||||
@@ -169,12 +171,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 +196,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,14 @@ import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { brandString } from '@deepseek-ai/dsh-brand'
|
||||
import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
import { AttachmentError, admitPromptContent } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import { AttachmentError } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentRef,
|
||||
} from '@deepseek-ai/dsh-attachment'
|
||||
import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types'
|
||||
import type {} from '@deepseek-ai/dsh-client-file-upload'
|
||||
import {
|
||||
ReasoningEffortId, createUserMessage, freezeMessage,
|
||||
ReasoningEffortId, assistantStreamChunks, createUserMessage, freezeMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
@@ -44,6 +48,7 @@ import type {
|
||||
SessionSelectModelValue,
|
||||
SessionUpdateQueueRequest,
|
||||
SessionUpdateQueueValue,
|
||||
SessionRequestId,
|
||||
} from './types.ts'
|
||||
|
||||
interface SessionReadState {
|
||||
@@ -297,6 +302,7 @@ export class SessionCommandController {
|
||||
)
|
||||
}
|
||||
const agent = await this.resolveAgent(request.sessionId)
|
||||
if (hasPromptRequest(agent, request.requestId)) return { accepted: true }
|
||||
const selection = this.agents.selectionFor(agent).current
|
||||
if (!routeServed(this.ctx, selection.provider)) {
|
||||
throw new RemoteError(
|
||||
@@ -324,10 +330,23 @@ export class SessionCommandController {
|
||||
)
|
||||
}
|
||||
}
|
||||
const content = await admitPromptContent(this.ctx.attachments, request.content)
|
||||
const admission = resolvePromptFileReceipts(
|
||||
request.content,
|
||||
receiptId => this.ctx.fileUploads.resolve(agent, receiptId),
|
||||
)
|
||||
const content = await this.ctx.attachments.admitPromptContent(admission.content)
|
||||
const message: UserMessage = createUserMessage({ content, source })
|
||||
if (this.ctx.agents.get(agent.id) !== agent) {
|
||||
throw new RemoteError(
|
||||
'session/not-found',
|
||||
`session "${agent.id}" was disposed during prompt admission`,
|
||||
{ sessionId: agent.id },
|
||||
)
|
||||
}
|
||||
using binding = this.ctx.fileUploads.bindPrompt(agent, admission.receiptIds, request.requestId)
|
||||
if (request.mode === 'steer') agent.steer(message)
|
||||
else agent.followup(message)
|
||||
binding.commit()
|
||||
} catch (error) {
|
||||
if (remoteErrorOf(error) !== undefined) throw error
|
||||
if (error instanceof AttachmentError) {
|
||||
@@ -421,6 +440,12 @@ export class SessionCommandController {
|
||||
}))
|
||||
} else {
|
||||
agent.inbox.remove(request.itemId)
|
||||
if (request.action.kind === 'remove') {
|
||||
const source = message.source
|
||||
if (source.kind === 'user' && 'rpcId' in source) {
|
||||
this.ctx.fileUploads.retirePrompt(agent, source.rpcId)
|
||||
}
|
||||
}
|
||||
if (request.action.kind === 'steer') agent.steer(message)
|
||||
}
|
||||
return { accepted: true }
|
||||
@@ -497,6 +522,39 @@ export class SessionCommandController {
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePromptFileReceipts(
|
||||
content: SessionPromptRequest['content'],
|
||||
stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined,
|
||||
): { readonly content: AttachmentAdmissionPart[]; readonly receiptIds: readonly FileUploadReceiptId[] } {
|
||||
const receiptIds = new Set<FileUploadReceiptId>()
|
||||
const resolved = content.map((part): AttachmentAdmissionPart => {
|
||||
if (part.type !== 'file') return part
|
||||
const attachment = stagedFile(part.receiptId)
|
||||
if (attachment === undefined) {
|
||||
throw new RemoteError(
|
||||
'session/attachment-invalid',
|
||||
'File was not uploaded for this session.',
|
||||
{ reason: 'FILE_NOT_STAGED' },
|
||||
)
|
||||
}
|
||||
receiptIds.add(part.receiptId)
|
||||
return { type: 'file', attachment }
|
||||
})
|
||||
return { content: resolved, receiptIds: [...receiptIds] }
|
||||
}
|
||||
|
||||
function hasPromptRequest(agent: Agent, requestId: SessionRequestId): boolean {
|
||||
const matches = (message: UserMessage): boolean => {
|
||||
const source = message.source
|
||||
return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId
|
||||
}
|
||||
if (agent.inbox.nextTurn.some(matches) || agent.inbox.nextStep.some(matches)) return true
|
||||
return agent.session.snapshotEvents().some((event) => {
|
||||
if (event.type !== 'user/message') return false
|
||||
const source = event.data.source
|
||||
return source.kind === 'user' && 'rpcId' in source && source.rpcId === requestId
|
||||
})
|
||||
}
|
||||
function imageBlockIn(
|
||||
content: unknown,
|
||||
match: (ref: ImageAttachmentRef) => boolean,
|
||||
@@ -525,7 +583,6 @@ function imageInEvent(
|
||||
readonly content?: unknown
|
||||
readonly message?: { readonly content?: unknown }
|
||||
readonly inserted?: readonly { readonly content?: unknown }[]
|
||||
readonly chunk?: { readonly type?: unknown; readonly block?: unknown }
|
||||
}
|
||||
const direct = imageBlockIn(data.content, match)
|
||||
if (direct !== undefined) return direct
|
||||
@@ -535,9 +592,13 @@ function imageInEvent(
|
||||
const found = imageBlockIn(inserted.content, match)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
return event.type === 'assistant/chunk' && data.chunk?.type === 'block-end'
|
||||
? imageBlockIn([data.chunk.block], match)
|
||||
: undefined
|
||||
if (event.type === 'assistant/message' || event.type === 'assistant/attempt') {
|
||||
for (const chunk of assistantStreamChunks(event.data.stream, 'block-end')) {
|
||||
const found = imageBlockIn([chunk.block], match)
|
||||
if (found !== undefined) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function referencedImage(
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
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,
|
||||
SessionSeq,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
|
||||
import type {
|
||||
SessionEvent,
|
||||
SessionHeader,
|
||||
@@ -18,9 +18,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,
|
||||
SessionChunkRun,
|
||||
SessionAssistantStreamFrame,
|
||||
SessionEventEntry,
|
||||
SessionFollowRequest,
|
||||
SessionFollowFrame,
|
||||
@@ -32,6 +33,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 +41,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 +51,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, cursorBeforeNext(agent.session.seq))
|
||||
}, { 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 +114,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 +144,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 +156,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, cursorBeforeNext(agent.session.seq)),
|
||||
ordinal: ++assistantStreamOrdinal,
|
||||
})
|
||||
notify()
|
||||
}, { global: true })
|
||||
const onAbort = (): void => { notify() }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
try {
|
||||
@@ -147,15 +180,24 @@ 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 }
|
||||
: 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),
|
||||
header: wireHeader(source.header),
|
||||
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()
|
||||
@@ -173,19 +215,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 +274,22 @@ export class SessionHistoryController {
|
||||
|
||||
}
|
||||
|
||||
function cursorBeforeNext(nextSeq: SessionLogOffsetType): SessionSeqCursor {
|
||||
return nextSeq === 0 ? -1 : SessionSeq(nextSeq - 1)
|
||||
}
|
||||
|
||||
function wireAssistantStreamFrame(
|
||||
frame: AssistantStreamFrame,
|
||||
durableCursor: SessionSeqCursor,
|
||||
): SessionAssistantStreamFrame {
|
||||
if (frame.type === 'start') return { ...frame, startedAfterSeq: durableCursor }
|
||||
if (frame.type === 'end') return frame
|
||||
return {
|
||||
...frame,
|
||||
chunk: frame.chunk as JsonValue,
|
||||
}
|
||||
}
|
||||
|
||||
function projectionBlock(
|
||||
snapshot: NonNullable<SessionObservation['projections']>,
|
||||
): SessionProjectionBaseline {
|
||||
@@ -343,16 +408,9 @@ function paginate(
|
||||
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 } : {},
|
||||
}
|
||||
/** Translate current logical Session metadata to the browser wire. */
|
||||
function wireHeader(header: SessionHeader): SessionWireHeader {
|
||||
return { ...header }
|
||||
}
|
||||
|
||||
function entryFor(event: SessionEvent): SessionEventEntry {
|
||||
@@ -363,29 +421,7 @@ function entryFor(event: SessionEvent): SessionEventEntry {
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
return events.map(entryFor)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-client-file-upload'
|
||||
import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
|
||||
@@ -17,11 +18,7 @@ import { SessionCommandController } from './commands.ts'
|
||||
import { SessionControlController } from './control.ts'
|
||||
import { SessionHistoryController } from './history.ts'
|
||||
import { SessionFileReferences } from './file-references.ts'
|
||||
import {
|
||||
ApiSessionList,
|
||||
DEFAULT_COLD_BLANK_PROBE_MAX_BYTES,
|
||||
DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS,
|
||||
} from './list.ts'
|
||||
import { ApiSessionList } from './list.ts'
|
||||
import { buildModelCatalog } from './catalog.ts'
|
||||
import { installModelSelectionProjection } from './model-selection-projection.ts'
|
||||
import { SessionSkillCatalog } from './skill-catalog.ts'
|
||||
@@ -70,10 +67,6 @@ declare module '@deepseek-ai/cordis' {
|
||||
|
||||
/** Session Controller deployment policy. */
|
||||
export interface Config {
|
||||
/** Maximum stat-reported event count eligible for one full cold projection observation; `0` disables the event-count gate. */
|
||||
readonly coldBlankProbeMaxEvents?: number
|
||||
/** Maximum stat-reported artifact byte size eligible for one full cold projection observation; `0` disables the byte-size gate. */
|
||||
readonly coldBlankProbeMaxBytes?: number
|
||||
/** Override platform desktop-opener detection. */
|
||||
readonly nativeOpen?: boolean
|
||||
}
|
||||
@@ -92,6 +85,7 @@ export class SessionController extends TypertRemoteService {
|
||||
'agentDefaultModel',
|
||||
'agents',
|
||||
'attachments',
|
||||
'fileUploads',
|
||||
'llm',
|
||||
'sessions',
|
||||
'sessionProjections',
|
||||
@@ -101,8 +95,6 @@ export class SessionController extends TypertRemoteService {
|
||||
]
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
coldBlankProbeMaxEvents: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS),
|
||||
coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES),
|
||||
nativeOpen: z.boolean(),
|
||||
})
|
||||
|
||||
@@ -117,7 +109,7 @@ export class SessionController extends TypertRemoteService {
|
||||
|
||||
/**
|
||||
* @param ctx - Host context containing the Session capability assembly.
|
||||
* @param config - cold-list observation and native-opener deployment policy.
|
||||
* @param config - native-opener deployment policy.
|
||||
* @param internals - host integrations replaceable by direct unit tests.
|
||||
*/
|
||||
constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) {
|
||||
@@ -125,6 +117,11 @@ export class SessionController extends TypertRemoteService {
|
||||
installModelSelectionProjection(ctx)
|
||||
this.agents = new ApiSessionAgentController(ctx)
|
||||
this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
|
||||
ctx.effect(() => ctx.fileUploads.registerAgentResolver(async (sessionId) => {
|
||||
const result = await this.agents.resolveAgent(sessionId)
|
||||
if ('error' in result) throw result.error
|
||||
return result.agent
|
||||
}), 'session-controller: file-upload Agent resolver')
|
||||
this.controlState = new SessionControlController(ctx)
|
||||
// Registered before history so reverse-order teardown closes every
|
||||
// follower before waiting for already-admitted promotions.
|
||||
@@ -132,10 +129,7 @@ export class SessionController extends TypertRemoteService {
|
||||
await Promise.allSettled([...this.promotions])
|
||||
}, 'session-controller.promotions')
|
||||
this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) })
|
||||
this.listState = new ApiSessionList(ctx, {
|
||||
coldBlankProbeMaxEvents: config.coldBlankProbeMaxEvents ?? DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS,
|
||||
coldBlankProbeMaxBytes: config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES,
|
||||
})
|
||||
this.listState = new ApiSessionList(ctx)
|
||||
this.openPath = internals.openPath ?? openNativePath
|
||||
this.canOpenPath = internals.canOpenPath
|
||||
?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
|
||||
@@ -382,7 +376,8 @@ export class SessionController extends TypertRemoteService {
|
||||
* Follow one Session log from its opening or resume cursor.
|
||||
* @param request - durable address and last committed sequence already held by the caller.
|
||||
* @param signal - cancellation owned by the Remote stream carrier.
|
||||
* @returns a complete opening snapshot followed by gap-free event frames.
|
||||
* @returns a complete opening snapshot followed by gap-free durable event
|
||||
* frames and optional cursorless assistant-stream frames.
|
||||
*/
|
||||
@Remote({ mode: 'stream' })
|
||||
follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable<SessionFollowFrame> {
|
||||
|
||||
@@ -19,21 +19,6 @@ import type {
|
||||
SessionSearchValue, SessionSummary,
|
||||
} from './types.ts'
|
||||
|
||||
/** Default maximum stat-reported event count eligible for one cold projection observation. */
|
||||
export const DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS = 16
|
||||
|
||||
/** Default maximum stat-reported artifact size eligible for one cold projection observation. */
|
||||
export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024
|
||||
|
||||
/** Resolved cold-blank probe policy: each threshold gates its stat metric; `0` disables that gate. */
|
||||
export interface ColdBlankProbePolicy {
|
||||
/** Maximum stat-reported `eventCount` eligible for a full observation. */
|
||||
readonly coldBlankProbeMaxEvents: number
|
||||
/** Maximum stat-reported `sizeBytes` eligible for a full observation. */
|
||||
readonly coldBlankProbeMaxBytes: number
|
||||
}
|
||||
|
||||
const COLD_SUMMARY_BATCH_SIZE = 16
|
||||
const SEARCH_PROVIDER_CALL_LIMIT = 100
|
||||
const SESSION_SEARCH_QUERY_MAX_CHARS = 500
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
|
||||
@@ -90,14 +75,8 @@ export function truncateUnicodeCodePoints(value: string, maximum: number): strin
|
||||
|
||||
/** Owns list projection registration, bounded cold summaries, and authorized search. */
|
||||
export class ApiSessionList {
|
||||
/**
|
||||
* @param ctx - Host context carrying Session, query, persistence, and projection services.
|
||||
* @param probe - stat-metadata thresholds gating a full cold observation.
|
||||
*/
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly probe: ColdBlankProbePolicy,
|
||||
) {
|
||||
/** @param ctx - Host context carrying Session, query, persistence, and projection services. */
|
||||
constructor(private readonly ctx: Context) {
|
||||
ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({
|
||||
key: 'sessionListMetadata',
|
||||
stateSchema: sessionListMetadataSchema,
|
||||
@@ -159,28 +138,13 @@ export class ApiSessionList {
|
||||
if (record.header.cwd === undefined) continue
|
||||
cold.push(record.header)
|
||||
}
|
||||
for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) {
|
||||
const settled = await Promise.allSettled(cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE)
|
||||
.map(header => this.summarizeCold(header, signal)))
|
||||
for (const result of settled) {
|
||||
if (result.status === 'rejected') throw result.reason
|
||||
items.push(result.value)
|
||||
}
|
||||
}
|
||||
for (const header of cold) items.push(this.summarizeCold(header))
|
||||
items.sort((left, right) => right.updatedAt - left.updatedAt)
|
||||
return items
|
||||
}
|
||||
|
||||
private async summarizeCold(
|
||||
header: SessionHeader,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<SessionSummary> {
|
||||
const cached = this.projectionsFor(header, undefined)
|
||||
const projections = cached?.values.sessionListMetadata?.blank === false
|
||||
? cached
|
||||
: await this.probeSmallCold(header, signal) ?? cached
|
||||
const raced = this.ctx.sessions.get(header.id)
|
||||
if (raced !== undefined) return this.summaryFor(raced)
|
||||
private summarizeCold(header: SessionHeader): SessionSummary {
|
||||
const projections = this.projectionsFor(header, undefined)
|
||||
const metadata = projections?.values.sessionListMetadata
|
||||
return {
|
||||
sessionId: header.id,
|
||||
@@ -193,54 +157,6 @@ export class ApiSessionList {
|
||||
}
|
||||
}
|
||||
|
||||
private async probeSmallCold(
|
||||
header: SessionHeader,
|
||||
signal: AbortSignal | undefined,
|
||||
): Promise<SessionProjectionHints | undefined> {
|
||||
const { coldBlankProbeMaxEvents, coldBlankProbeMaxBytes } = this.probe
|
||||
if (coldBlankProbeMaxEvents === 0 && coldBlankProbeMaxBytes === 0) return undefined
|
||||
const persistence = this.ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) return undefined
|
||||
signal?.throwIfAborted()
|
||||
let snapshot: Awaited<ReturnType<typeof persistence.stat>>
|
||||
try {
|
||||
snapshot = await persistence.stat(header.id, signal === undefined ? {} : { signal })
|
||||
} catch (error: unknown) {
|
||||
// An unreadable single session degrades to unknown state instead of
|
||||
// failing the whole list request.
|
||||
signal?.throwIfAborted()
|
||||
this.ctx.logger.warn(
|
||||
`api-session.list: cold stat for "${header.id}" failed; serving it as visible: ${String(error)}`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
if (snapshot === undefined) return undefined
|
||||
if (snapshot.eventCount !== undefined) {
|
||||
if (coldBlankProbeMaxEvents === 0 || snapshot.eventCount > coldBlankProbeMaxEvents) return undefined
|
||||
} else if (snapshot.sizeBytes !== undefined) {
|
||||
if (coldBlankProbeMaxBytes === 0 || snapshot.sizeBytes > coldBlankProbeMaxBytes) return undefined
|
||||
} else {
|
||||
// The backend offers no cheap size hint, so a full observation is unbounded work.
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
using observation = await this.ctx.sessionQuery.observeSession(header.id, {
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
projectionMode: 'all',
|
||||
})
|
||||
const block = observation.projections
|
||||
return block === undefined
|
||||
? undefined
|
||||
: { asOfSeq: block.asOfSeq, values: block.values as SessionProjectionValues }
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
this.ctx.logger.warn(
|
||||
`api-session.list: small cold observation for "${header.id}" failed; serving it as visible: ${String(error)}`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search current visible message content without activating any matching Session.
|
||||
* @param query - literal message-content query.
|
||||
@@ -354,10 +270,12 @@ export class ApiSessionList {
|
||||
session: Session | undefined,
|
||||
): SessionProjectionHints | undefined {
|
||||
try {
|
||||
const cache = this.ctx.get('sessionProjectionCache')
|
||||
const block = session === undefined
|
||||
? header.isSeeded
|
||||
? undefined
|
||||
: this.ctx.get('sessionProjectionCache')?.cachedSnapshot(header, SessionLogOffset(0))
|
||||
: cache?.cachedSnapshot(header, SessionLogOffset(0))
|
||||
?? cache?.cachedPredecessorTitle(header, SessionLogOffset(0))
|
||||
: this.ctx.sessionProjections.cachedSnapshot(session)
|
||||
return block !== undefined && Object.keys(block.values).length > 0
|
||||
? {
|
||||
|
||||
@@ -4,10 +4,9 @@ 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 { 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'
|
||||
import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
@@ -68,7 +67,11 @@ export interface SessionProjectionBaseline {
|
||||
export type SessionProjectionValues = Partial<SessionProjectionMap>
|
||||
& Readonly<Record<string, SessionProjectionValue>>
|
||||
|
||||
/** Browser-submitted prompt content; the Host promotes image bytes to durable references. */
|
||||
/**
|
||||
* Browser-submitted prompt content; the Host promotes image bytes to durable
|
||||
* references. File parts carry the opaque receipt returned by a preceding
|
||||
* `uploadFile` call on the same Session.
|
||||
*/
|
||||
export type PromptContentPart =
|
||||
| { readonly type: 'text'; readonly text: string }
|
||||
| {
|
||||
@@ -77,6 +80,7 @@ export type PromptContentPart =
|
||||
readonly data: string
|
||||
readonly name?: string
|
||||
}
|
||||
| { readonly type: 'file'; readonly receiptId: Branded<'file-upload-receipt-id'> }
|
||||
|
||||
/** Complete model selection for one Session. */
|
||||
export interface ModelSelection {
|
||||
@@ -384,15 +388,15 @@ export interface SessionEventEntry {
|
||||
readonly event: SessionWireEvent
|
||||
}
|
||||
|
||||
/** v0-compatible Session metadata carried on the browser wire. */
|
||||
/** Current logical Session metadata carried on the browser wire. */
|
||||
export interface SessionWireHeader {
|
||||
readonly version: number
|
||||
readonly id: SessionId
|
||||
readonly createdAt: number
|
||||
readonly cwd?: string
|
||||
readonly parentSession?: SessionId
|
||||
/** Exact inherited prefix length; absent for an unseeded Session. */
|
||||
readonly seedLength?: number
|
||||
/** Whether the Session contains a fork-inherited prefix. */
|
||||
readonly isSeeded: boolean
|
||||
readonly origin?: 'subagent'
|
||||
readonly delegationDepth?: number
|
||||
readonly agentPreset?: string
|
||||
@@ -403,24 +407,8 @@ export type SessionWireSurfaceOp =
|
||||
| 'append'
|
||||
| { readonly op: 'replace'; readonly start: number; readonly end: number }
|
||||
|
||||
/** Event-shaped wire representation of one packed chunk row. */
|
||||
export type ChunkRowEvent = {
|
||||
[Kind in ChunkRow['type']]: {
|
||||
readonly type: `chunkrow/${Kind}`
|
||||
readonly seq: number
|
||||
readonly time: number
|
||||
readonly data: Extract<ChunkRow, { readonly type: Kind }>['data']
|
||||
}
|
||||
}[ChunkRow['type']]
|
||||
|
||||
/** One lossless run of consecutive Assistant delta events in a history page. */
|
||||
export interface SessionChunkRun {
|
||||
readonly type: 'chunks'
|
||||
readonly event: ChunkRowEvent
|
||||
}
|
||||
|
||||
/** One history-page record: a raw event or a packed Assistant delta run. */
|
||||
export type SessionHistoryRecord = SessionEventEntry | SessionChunkRun
|
||||
/** One history-page record. V2 embeds compact Assistant streams inside events. */
|
||||
export type SessionHistoryRecord = SessionEventEntry
|
||||
|
||||
/** Session event wire form; durable readers own recognition of merge-extensible event names. */
|
||||
export interface SessionWireEvent {
|
||||
@@ -446,15 +434,69 @@ 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
|
||||
/** Last durable Session seq observed when this attempt started. */
|
||||
readonly startedAfterSeq: SessionSeqCursor
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
/** Dense position expected for the next live chunk frame. */
|
||||
readonly nextIndex: number
|
||||
/** Compact detached stream accumulated at this opening revision. */
|
||||
readonly stream: readonly JsonValue[]
|
||||
}
|
||||
|
||||
/** Complete process-local assistant state at one follow opening. */
|
||||
export interface SessionAssistantStreamBaseline {
|
||||
readonly revision: number
|
||||
readonly activeAttempt?: SessionAssistantStreamAttempt
|
||||
}
|
||||
|
||||
/** Browser wire form of one process-local assistant frame. */
|
||||
export type SessionAssistantStreamFrame =
|
||||
| {
|
||||
readonly type: 'start'
|
||||
readonly attemptId: LlmAttemptId
|
||||
readonly revision: number
|
||||
readonly startedAfterSeq: SessionSeqCursor
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
}
|
||||
| {
|
||||
readonly type: 'chunk'
|
||||
readonly attemptId: LlmAttemptId
|
||||
readonly revision: number
|
||||
readonly index: number
|
||||
readonly time: number
|
||||
readonly chunk: JsonValue
|
||||
}
|
||||
| {
|
||||
readonly type: 'end'
|
||||
readonly attemptId: LlmAttemptId
|
||||
readonly revision: number
|
||||
/** Number of chunk frames represented by this terminal marker. */
|
||||
readonly index: number
|
||||
readonly outcome:
|
||||
| {
|
||||
readonly kind: 'committed'
|
||||
readonly eventType: 'assistant/message' | 'assistant/attempt'
|
||||
readonly seq: number
|
||||
}
|
||||
| { readonly kind: 'abandoned' }
|
||||
}
|
||||
|
||||
/** 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 +505,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