mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-08 04:01:59 +00:00
feat(session-controller): admit files across command flows
This commit is contained in:
@@ -81,6 +81,7 @@
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-file-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-jobs": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
@@ -119,6 +120,7 @@
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
"@deepseek-ai/dsh-file-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-jobs": "workspace:^",
|
||||
|
||||
@@ -4,12 +4,13 @@ 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, admitEncodedFile, admitPromptContent } from '@deepseek-ai/dsh-attachment'
|
||||
import type { CommandFileReceiptResolver } from '@deepseek-ai/dsh-commands'
|
||||
import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import {
|
||||
ReasoningEffortId, createUserMessage, freezeMessage,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import type { MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
|
||||
@@ -35,6 +36,7 @@ import type {
|
||||
SessionCreateValue,
|
||||
SessionForkRequest,
|
||||
SessionForkValue,
|
||||
FileUploadReceiptId,
|
||||
SessionPromptRequest,
|
||||
SessionPromptValue,
|
||||
SessionRenameRequest,
|
||||
@@ -43,6 +45,9 @@ import type {
|
||||
SessionSelectModelValue,
|
||||
SessionUpdateQueueRequest,
|
||||
SessionUpdateQueueValue,
|
||||
SessionUploadFileRequest,
|
||||
SessionUploadFileValue,
|
||||
SessionRequestId,
|
||||
} from './types.ts'
|
||||
|
||||
interface SessionReadState {
|
||||
@@ -51,8 +56,21 @@ interface SessionReadState {
|
||||
readonly events: readonly SessionEvent[]
|
||||
}
|
||||
|
||||
interface StagedFileUpload {
|
||||
readonly file: FileAttachmentRef
|
||||
/** Prompt that accepted this receipt; absent until successful admission. */
|
||||
requestId?: SessionRequestId
|
||||
}
|
||||
|
||||
/** Implements Session business commands delegated by the Session Controller Remote service. */
|
||||
export class SessionCommandController {
|
||||
/**
|
||||
* Staged file uploads awaiting a prompt, keyed by Session. Entries are the
|
||||
* prompt-time authority for file references: a prompt may only cite a file
|
||||
* previously uploaded for the same Session in this process.
|
||||
*/
|
||||
private readonly stagedFiles = new Map<SessionId, Map<FileUploadReceiptId, StagedFileUpload>>()
|
||||
|
||||
/**
|
||||
* @param ctx - Host context carrying Agent, model, attachment, title, and Workspace services.
|
||||
* @param agents - sole owner of create, resume, and Session-local model selection.
|
||||
@@ -62,7 +80,115 @@ export class SessionCommandController {
|
||||
private readonly ctx: Context,
|
||||
private readonly agents: ApiSessionAgentController,
|
||||
private readonly defaultCwd: string,
|
||||
) {}
|
||||
) {
|
||||
ctx.inject(['commands'], (commandCtx) => {
|
||||
const resolve: CommandFileReceiptResolver = (agent, receiptId) =>
|
||||
this.resolveStagedFile(agent.id, receiptId as FileUploadReceiptId)
|
||||
commandCtx.effect(
|
||||
() => commandCtx.commands.registerFileReceiptResolver(resolve),
|
||||
'session-controller: command file receipt resolver',
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one browser file upload verbatim and stage it for later prompts.
|
||||
* @param request - Session identity, base64 payload, and optional display name.
|
||||
* @returns an opaque per-upload receipt and the durable file reference.
|
||||
*/
|
||||
async uploadFile(request: SessionUploadFileRequest): Promise<SessionUploadFileValue> {
|
||||
const agent = await this.resolveAgent(request.sessionId)
|
||||
return this.commitFileUpload(agent, async () => admitEncodedFile(this.ctx.attachments, {
|
||||
data: request.data,
|
||||
...(request.name === undefined ? {} : { name: request.name }),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist raw upload chunks without collecting the complete file in memory.
|
||||
* @param request - Session identity, ordered exact bytes, cancellation, and optional display name.
|
||||
* @returns an opaque per-upload receipt and the durable file reference.
|
||||
*/
|
||||
async uploadFileStream(request: {
|
||||
readonly sessionId: SessionId
|
||||
readonly data: AsyncIterable<Uint8Array>
|
||||
readonly signal?: AbortSignal
|
||||
readonly name?: string
|
||||
}): Promise<SessionUploadFileValue> {
|
||||
const agent = await this.resolveAgent(request.sessionId)
|
||||
return this.commitFileUpload(agent, async () => this.ctx.attachments.saveFileStream({
|
||||
data: request.data,
|
||||
...(request.signal === undefined ? {} : { signal: request.signal }),
|
||||
...(request.name === undefined ? {} : { name: request.name }),
|
||||
}))
|
||||
}
|
||||
|
||||
private async commitFileUpload(
|
||||
agent: Agent,
|
||||
save: () => Promise<FileAttachmentRef>,
|
||||
): Promise<SessionUploadFileValue> {
|
||||
let file: FileAttachmentRef
|
||||
try {
|
||||
file = await save()
|
||||
} catch (error) {
|
||||
if (error instanceof AttachmentError) {
|
||||
throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code })
|
||||
}
|
||||
throw new RemoteError(
|
||||
'gateway/internal',
|
||||
`failed to store file upload: ${String(error)}`,
|
||||
{},
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
if (this.ctx.agents.get(agent.id) !== agent) {
|
||||
throw new RemoteError(
|
||||
'session/not-found',
|
||||
`session "${agent.id}" was disposed before its file upload completed`,
|
||||
{ sessionId: agent.id },
|
||||
)
|
||||
}
|
||||
let staged = this.stagedFiles.get(agent.id)
|
||||
if (staged === undefined) {
|
||||
staged = new Map()
|
||||
this.stagedFiles.set(agent.id, staged)
|
||||
}
|
||||
const receiptId = randomUUID() as FileUploadReceiptId
|
||||
staged.set(receiptId, { file })
|
||||
return { receiptId, file }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve one staged upload for the same Session without exposing the receipt table.
|
||||
* @param sessionId - receiving Session identity.
|
||||
* @param receiptId - Host-minted upload receipt.
|
||||
* @returns the durable file reference, or `undefined` when the receipt is absent or belongs elsewhere.
|
||||
*/
|
||||
resolveStagedFile(sessionId: SessionId, receiptId: FileUploadReceiptId): FileAttachmentRef | undefined {
|
||||
return this.stagedFiles.get(sessionId)?.get(receiptId)?.file
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire file receipts only after their accepted prompt becomes observable.
|
||||
* @param sessionId - Session whose log emitted the prompt.
|
||||
* @param requestId - browser prompt identity echoed by the event.
|
||||
*/
|
||||
retireObservedPrompt(sessionId: SessionId, requestId: SessionRequestId): void {
|
||||
const staged = this.stagedFiles.get(sessionId)
|
||||
if (staged === undefined) return
|
||||
for (const [receiptId, upload] of staged) {
|
||||
if (upload.requestId === requestId) staged.delete(receiptId)
|
||||
}
|
||||
if (staged.size === 0) this.stagedFiles.delete(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop one Session's staged uploads (the stored objects remain durable).
|
||||
* @param sessionId - Session leaving the live registry.
|
||||
*/
|
||||
releaseStagedFiles(sessionId: SessionId): void {
|
||||
this.stagedFiles.delete(sessionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or idempotently adopt one ordinary Session.
|
||||
@@ -292,6 +418,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(
|
||||
@@ -319,10 +446,42 @@ export class SessionCommandController {
|
||||
)
|
||||
}
|
||||
}
|
||||
const content = await admitPromptContent(this.ctx.attachments, request.content)
|
||||
const message: UserMessage = createUserMessage({ content, source })
|
||||
if (request.mode === 'steer') agent.steer(message)
|
||||
else agent.followup(message)
|
||||
const staged = this.stagedFiles.get(request.sessionId)
|
||||
const durable = await durablePromptContent(
|
||||
this.ctx,
|
||||
request.content,
|
||||
receiptId => staged?.get(receiptId)?.file,
|
||||
)
|
||||
const message: UserMessage = createUserMessage({ content: durable.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 },
|
||||
)
|
||||
}
|
||||
const bound = durable.receiptIds.map((receiptId) => {
|
||||
const upload = staged?.get(receiptId)
|
||||
if (upload === undefined) {
|
||||
throw new RemoteError(
|
||||
'session/attachment-invalid',
|
||||
'File was not uploaded for this session.',
|
||||
{ reason: 'FILE_NOT_STAGED' },
|
||||
)
|
||||
}
|
||||
return { upload, previous: upload.requestId }
|
||||
})
|
||||
for (const { upload } of bound) upload.requestId = request.requestId
|
||||
try {
|
||||
if (request.mode === 'steer') agent.steer(message)
|
||||
else agent.followup(message)
|
||||
} catch (error) {
|
||||
for (const { upload, previous } of bound) {
|
||||
if (previous === undefined) delete upload.requestId
|
||||
else upload.requestId = previous
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
if (remoteErrorOf(error) !== undefined) throw error
|
||||
if (error instanceof AttachmentError) {
|
||||
@@ -416,6 +575,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.retireObservedPrompt(request.sessionId, source.rpcId)
|
||||
}
|
||||
}
|
||||
if (request.action.kind === 'steer') agent.steer(message)
|
||||
}
|
||||
return { accepted: true }
|
||||
@@ -492,6 +657,51 @@ export class SessionCommandController {
|
||||
}
|
||||
}
|
||||
|
||||
async function durablePromptContent(
|
||||
ctx: Context,
|
||||
content: readonly SessionPromptRequest['content'][number][],
|
||||
stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined,
|
||||
): Promise<{ readonly content: ContentBlock[]; readonly receiptIds: readonly FileUploadReceiptId[] }> {
|
||||
const files = new Map<FileUploadReceiptId, FileAttachmentRef>()
|
||||
for (const part of content) {
|
||||
if (part.type !== 'file' || files.has(part.receiptId)) continue
|
||||
const file = stagedFile(part.receiptId)
|
||||
if (file === undefined) {
|
||||
throw new RemoteError(
|
||||
'session/attachment-invalid',
|
||||
'File was not uploaded for this session.',
|
||||
{ reason: 'FILE_NOT_STAGED' },
|
||||
)
|
||||
}
|
||||
files.set(part.receiptId, file)
|
||||
}
|
||||
type NonFilePart = Exclude<SessionPromptRequest['content'][number], { readonly type: 'file' }>
|
||||
const admitted = await admitPromptContent(
|
||||
ctx.attachments,
|
||||
content.filter((part): part is NonFilePart => part.type !== 'file'),
|
||||
)
|
||||
let next = 0
|
||||
const durable = content.map((part) => {
|
||||
if (part.type === 'file') {
|
||||
return { type: 'file' as const, attachment: files.get(part.receiptId) as FileAttachmentRef }
|
||||
}
|
||||
return admitted[next++] as ContentBlock
|
||||
})
|
||||
return { content: durable, receiptIds: [...files.keys()] }
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/** Raw Fetch file intake used by the browser background-upload carrier. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { SESSION_FILE_UPLOAD_PATH } from './file-upload-path.ts'
|
||||
import type { SessionUploadFileValue } from './types.ts'
|
||||
import type { SessionCommandController } from './commands.ts'
|
||||
|
||||
interface FileUploadConnection {
|
||||
readonly fetch: {
|
||||
register(route: {
|
||||
readonly path: string
|
||||
readonly methods: readonly ['POST']
|
||||
readonly requestBody: 'streaming'
|
||||
readonly fetch: (request: Request) => Promise<Response>
|
||||
}): () => Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
type FileUploadResult =
|
||||
| { readonly ok: true; readonly value: SessionUploadFileValue }
|
||||
| {
|
||||
readonly ok: false
|
||||
readonly error: { readonly code: string; readonly message: string; readonly details: object }
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the authenticated raw-byte route on Connection's shared Fetch registry.
|
||||
* @param ctx - Host context that provides Connection.
|
||||
* @param commands - Session command owner that validates and stages stored bytes.
|
||||
*/
|
||||
export function registerSessionFileUploadHttp(ctx: Context, commands: SessionCommandController): void {
|
||||
ctx.inject(['connection'], (connectionCtx) => {
|
||||
const connection = connectionCtx.get('connection') as FileUploadConnection
|
||||
connection.fetch.register({
|
||||
path: SESSION_FILE_UPLOAD_PATH,
|
||||
methods: ['POST'],
|
||||
requestBody: 'streaming',
|
||||
fetch: request => handleSessionFileUploadHttp(commands, request),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle one authenticated raw-byte upload after the physical carrier applies
|
||||
* its trust policy.
|
||||
* @param commands - Session command owner that validates and stages stored bytes.
|
||||
* @param request - Fetch request carrying the raw file body.
|
||||
* @returns JSON receipt or a precise validation response.
|
||||
*/
|
||||
export async function handleSessionFileUploadHttp(
|
||||
commands: SessionCommandController,
|
||||
request: Request,
|
||||
): Promise<Response> {
|
||||
if (request.method !== 'POST') {
|
||||
return new Response(null, { status: 405, headers: { allow: 'POST' } })
|
||||
}
|
||||
const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
|
||||
if (mediaType !== 'application/octet-stream') {
|
||||
return new Response('content type must be application/octet-stream', { status: 415 })
|
||||
}
|
||||
const url = new URL(request.url)
|
||||
const sessionId = url.searchParams.get('sessionId')
|
||||
if (sessionId === null || sessionId === '') {
|
||||
return new Response('sessionId is required', { status: 400 })
|
||||
}
|
||||
const name = url.searchParams.get('name') ?? undefined
|
||||
let result: FileUploadResult
|
||||
try {
|
||||
result = {
|
||||
ok: true,
|
||||
value: await commands.uploadFileStream({
|
||||
sessionId: SessionId(sessionId),
|
||||
data: requestBodyChunks(request.body),
|
||||
signal: request.signal,
|
||||
...(name === undefined ? {} : { name }),
|
||||
}),
|
||||
}
|
||||
} catch (error) {
|
||||
const failure = remoteErrorOf(error)
|
||||
result = {
|
||||
ok: false,
|
||||
error: failure !== undefined
|
||||
? { code: failure.code, message: failure.message, details: failure.details }
|
||||
: {
|
||||
code: 'gateway/internal',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
'cache-control': 'no-store',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function* requestBodyChunks(
|
||||
body: ReadableStream<Uint8Array> | null,
|
||||
): AsyncIterable<Uint8Array> {
|
||||
if (body === null) return
|
||||
const reader = body.getReader()
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read()
|
||||
if (chunk.done) return
|
||||
yield chunk.value
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Authenticated raw-byte upload route shared by Host and Client faces. */
|
||||
export const SESSION_FILE_UPLOAD_PATH = '/api/session/uploadFileBinary'
|
||||
@@ -16,6 +16,7 @@ import { SessionCommandController } from './commands.ts'
|
||||
import { SessionControlController } from './control.ts'
|
||||
import { SessionHistoryController } from './history.ts'
|
||||
import { SessionFileReferences } from './file-references.ts'
|
||||
import { registerSessionFileUploadHttp } from './file-upload-http.ts'
|
||||
import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts'
|
||||
import { buildModelCatalog } from './catalog.ts'
|
||||
import { installModelSelectionProjection } from './model-selection-projection.ts'
|
||||
@@ -49,6 +50,8 @@ import type {
|
||||
SessionSelectModelValue,
|
||||
SessionUpdateQueueRequest,
|
||||
SessionUpdateQueueValue,
|
||||
SessionUploadFileRequest,
|
||||
SessionUploadFileValue,
|
||||
} from './types.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
@@ -116,6 +119,7 @@ export class SessionController extends TypertRemoteService {
|
||||
installModelSelectionProjection(ctx)
|
||||
this.agents = new ApiSessionAgentController(ctx)
|
||||
this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
|
||||
registerSessionFileUploadHttp(ctx, this.commands)
|
||||
this.controlState = new SessionControlController(ctx)
|
||||
// Registered before history so reverse-order teardown closes every
|
||||
// follower before waiting for already-admitted promotions.
|
||||
@@ -137,6 +141,7 @@ export class SessionController extends TypertRemoteService {
|
||||
ctx.emit('api-session/added', this.listState.summaryFor(session))
|
||||
})
|
||||
ctx.on('session/disposed', (session) => {
|
||||
this.commands.releaseStagedFiles(session.id)
|
||||
ctx.emit('api-session/removed', session.id)
|
||||
})
|
||||
ctx.on('agent/status', ({ agent, status }) => {
|
||||
@@ -146,6 +151,10 @@ export class SessionController extends TypertRemoteService {
|
||||
ctx.emit('api-session/error', agent.id, errorChain(error))
|
||||
})
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (event.type === 'user/message' && event.data.source.kind === 'user'
|
||||
&& 'rpcId' in event.data.source) {
|
||||
this.commands.retireObservedPrompt(session.id, event.data.source.rpcId)
|
||||
}
|
||||
if (event.type === 'request/header') {
|
||||
const agent = ctx.agents.get(session.id)
|
||||
if (agent?.session === session) this.agents.consumeSelection(
|
||||
@@ -334,6 +343,19 @@ export class SessionController extends TypertRemoteService {
|
||||
return this.commands.attachment(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist one encoded file upload verbatim and stage it for a later prompt
|
||||
* on the same Session.
|
||||
* @param request - Session identity, base64 payload, and optional display name.
|
||||
* @param signal - caller cancellation before storage begins.
|
||||
* @returns an opaque per-upload receipt and the durable file reference.
|
||||
*/
|
||||
@Remote('uploadFile')
|
||||
uploadFile(request: SessionUploadFileRequest, signal: AbortSignal): Promise<SessionUploadFileValue> {
|
||||
signal.throwIfAborted()
|
||||
return this.commands.uploadFile(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate one still-pending queue occurrence on a live Agent.
|
||||
* @param request - Session, queue item, and requested mutation.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */
|
||||
|
||||
import type {
|
||||
AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType,
|
||||
AttachmentIdType, FileAttachmentRef, 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'
|
||||
@@ -68,7 +68,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 +81,7 @@ export type PromptContentPart =
|
||||
readonly data: string
|
||||
readonly name?: string
|
||||
}
|
||||
| { readonly type: 'file'; readonly receiptId: FileUploadReceiptId }
|
||||
|
||||
/** Complete model selection for one Session. */
|
||||
export interface ModelSelection {
|
||||
@@ -313,6 +318,22 @@ export interface SessionPromptValue {
|
||||
readonly accepted: true
|
||||
}
|
||||
|
||||
/** One base64 file upload staged for a later prompt on the same Session. */
|
||||
export interface SessionUploadFileRequest {
|
||||
readonly sessionId: SessionId
|
||||
/** Canonical base64 encoding of the exact file bytes. */
|
||||
readonly data: string
|
||||
/** Optional display name; the Host sanitizes it into the stored leaf name. */
|
||||
readonly name?: string
|
||||
}
|
||||
|
||||
/** Durable receipt for one staged file upload. */
|
||||
export interface SessionUploadFileValue {
|
||||
/** Per-upload authority consumed by a later prompt on the same Session. */
|
||||
readonly receiptId: FileUploadReceiptId
|
||||
readonly file: FileAttachmentRef
|
||||
}
|
||||
|
||||
/** Durable image read request. */
|
||||
export interface SessionAttachmentRequest {
|
||||
readonly sessionId: SessionId
|
||||
@@ -361,6 +382,9 @@ export interface SessionOpenWorkspacePathValue {
|
||||
/** Client-minted prompt identity used to reconcile optimistic and durable messages. */
|
||||
export type SessionRequestId = Branded<'session-request-id'>
|
||||
|
||||
/** Host-minted authority for one staged file upload on one Session. */
|
||||
export type FileUploadReceiptId = Branded<'file-upload-receipt-id'>
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
/** Browser prompt correlation and optional Host-validated time zone. */
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"src/commands.ts",
|
||||
"src/control.ts",
|
||||
"src/file-references.ts",
|
||||
"src/file-upload-path.ts",
|
||||
"src/file-upload-http.ts",
|
||||
"src/history.ts",
|
||||
"src/list.ts",
|
||||
"src/model-selection-projection.ts",
|
||||
|
||||
@@ -108,15 +108,15 @@ function missingGoal(action: string): CommandResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the invocation's admitted composer images as one model-visible user
|
||||
* message ahead of the goal's next round. The images precede a fixed text
|
||||
* Submit the invocation's admitted composer attachments as one model-visible user
|
||||
* message ahead of the goal's next round. The attachments precede a fixed text
|
||||
* block naming their role, so a later goal round reads them from ordinary
|
||||
* session history without the goal domain storing attachment state.
|
||||
*/
|
||||
function submitObjectiveAttachments(invocation: CommandInvocation): void {
|
||||
if (invocation.attachments.length === 0) return
|
||||
invocation.agent.followup(createUserMessage({
|
||||
content: [...invocation.attachments, { type: 'text', text: 'Reference images for the goal objective.' }],
|
||||
content: [...invocation.attachments, { type: 'text', text: 'Reference attachments for the goal objective.' }],
|
||||
source: { kind: 'user' },
|
||||
}))
|
||||
}
|
||||
@@ -127,7 +127,7 @@ function executeGoalCommand(ctx: Context, invocation: CommandInvocation): Comman
|
||||
if (invocation.attachments.length > 0 && command.kind !== 'create' && command.kind !== 'edit') {
|
||||
return {
|
||||
kind: 'error',
|
||||
text: 'Image attachments only accompany a goal objective: /goal <objective> or /goal edit <objective>.',
|
||||
text: 'Attachments only accompany a goal objective: /goal <objective> or /goal edit <objective>.',
|
||||
}
|
||||
}
|
||||
try {
|
||||
@@ -190,7 +190,7 @@ export function apply(ctx: Context): void {
|
||||
ctx.commands.register({
|
||||
name: 'goal',
|
||||
description: 'set or view the goal for a long-running task',
|
||||
input: { hint: '[<objective>|clear|edit <objective>|pause|resume]', images: true },
|
||||
input: { hint: '[<objective>|clear|edit <objective>|pause|resume]', attachments: true },
|
||||
handler: invocation => executeGoalCommand(ctx, invocation),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment'
|
||||
import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types'
|
||||
import type { ImageBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { EncodedImageAttachment, FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment/types'
|
||||
import type { FileBlock, ImageBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope'
|
||||
import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope'
|
||||
import type { Session, SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
CommandExecution,
|
||||
CommandInputDescriptor,
|
||||
CommandResult,
|
||||
CommandSubmitAttachment,
|
||||
} from './types.ts'
|
||||
|
||||
export { CommandId } from './brand.ts'
|
||||
@@ -28,8 +29,11 @@ export const name = 'commands'
|
||||
|
||||
const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u
|
||||
|
||||
/** Shared frozen attachments value for image-free invocations. */
|
||||
const NO_ATTACHMENTS: readonly ImageBlock[] = Object.freeze([])
|
||||
/** Shared frozen attachments value for attachment-free invocations. */
|
||||
const NO_ATTACHMENTS: readonly (ImageBlock | FileBlock)[] = Object.freeze([])
|
||||
|
||||
/** Host resolver for Session-scoped staged file-upload receipts. */
|
||||
export type CommandFileReceiptResolver = (agent: Agent, receiptId: string) => FileAttachmentRef | undefined
|
||||
|
||||
/** Invocation passed to one registered command handler. */
|
||||
export interface CommandInvocation {
|
||||
@@ -40,13 +44,13 @@ export interface CommandInvocation {
|
||||
/** Exact text following the registered command name, including separator whitespace. */
|
||||
readonly rawInput: string
|
||||
/**
|
||||
* Durably admitted image blocks accompanying this invocation, in submission
|
||||
* order; empty unless the definition declares `input.images`. The handler
|
||||
* Durably admitted image and file blocks accompanying this invocation, in submission
|
||||
* order; empty unless the definition declares `input.attachments`. The handler
|
||||
* owns their model-visible use — the registry never schedules them itself —
|
||||
* and a handler whose grammar cannot use them in this invocation returns an
|
||||
* error so the dispatching composer retains the originals.
|
||||
*/
|
||||
readonly attachments: readonly ImageBlock[]
|
||||
readonly attachments: readonly (ImageBlock | FileBlock)[]
|
||||
/** Cancellation signal owned by the dispatching UI request. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
@@ -191,12 +195,12 @@ function normalizeDefinition(definition: CommandDefinition): RegisteredCommand {
|
||||
if (rawInput.hint.trim().length === 0) {
|
||||
throw new TypeError(`command "${definition.name}" input hint must not be empty`)
|
||||
}
|
||||
if ('images' in rawInput && rawInput.images !== undefined && typeof rawInput.images !== 'boolean') {
|
||||
throw new TypeError(`command "${definition.name}" input images flag must be a boolean`)
|
||||
if ('attachments' in rawInput && rawInput.attachments !== undefined && typeof rawInput.attachments !== 'boolean') {
|
||||
throw new TypeError(`command "${definition.name}" input attachments flag must be a boolean`)
|
||||
}
|
||||
input = Object.freeze({
|
||||
hint: rawInput.hint,
|
||||
...('images' in rawInput && rawInput.images === true) ? { images: true } : {},
|
||||
...('attachments' in rawInput && rawInput.attachments === true) ? { attachments: true } : {},
|
||||
})
|
||||
}
|
||||
const normalized = Object.freeze({
|
||||
@@ -258,6 +262,8 @@ export class CommandRuntime extends TypertRemoteService {
|
||||
private commandSeq = 0
|
||||
/** Instance token keeping minted ids unique across process restarts over one resumed log. */
|
||||
private readonly instanceToken = randomUUID().slice(0, 8)
|
||||
/** Optional provider installed by the Session upload owner. */
|
||||
private readonly fileReceipts: { resolver: CommandFileReceiptResolver | undefined } = { resolver: undefined }
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'commands')
|
||||
@@ -277,6 +283,21 @@ export class CommandRuntime extends TypertRemoteService {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the sole authority that resolves staged file receipts for command submissions.
|
||||
* @param resolver - Session-aware receipt resolver.
|
||||
* @returns disposer that removes this exact resolver.
|
||||
*/
|
||||
registerFileReceiptResolver(resolver: CommandFileReceiptResolver): () => void {
|
||||
if (this.fileReceipts.resolver !== undefined) {
|
||||
throw new Error('commands: a file receipt resolver is already registered')
|
||||
}
|
||||
this.fileReceipts.resolver = resolver
|
||||
return () => {
|
||||
if (this.fileReceipts.resolver === resolver) this.fileReceipts.resolver = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List the effective immutable command descriptors for one agent.
|
||||
* @param agent - exact receiving agent and scoped-layer key.
|
||||
@@ -313,15 +334,17 @@ export class CommandRuntime extends TypertRemoteService {
|
||||
* handler-failure path is contained so the handler's own error stays the
|
||||
* reported failure.
|
||||
*
|
||||
* Image admission is enforced here, not in the composer: images sent to a
|
||||
* command that does not declare `input.images`, an absent attachment store,
|
||||
* and an exceeded attachment limit each settle as an error result before
|
||||
* the handler runs, and a rejected batch publishes no durable object.
|
||||
* Attachment admission is enforced here, not in the composer: attachments sent to a
|
||||
* command that does not declare `input.attachments`, an absent attachment store,
|
||||
* and an exceeded image limit each settle as an error result before
|
||||
* the handler runs. Validation rejection starts no attachment writes;
|
||||
* a storage failure can leave only unreachable content-addressed objects
|
||||
* for deferred collection.
|
||||
*
|
||||
* @param agent - exact receiving agent.
|
||||
* @param line - complete slash-command line.
|
||||
* @param images - base64-encoded composer images accompanying the line, in
|
||||
* submission order; empty for a plain invocation.
|
||||
* @param submittedAttachments - encoded images and staged file receipts accompanying the line,
|
||||
* in submission order; empty for a plain invocation.
|
||||
* @param signal - cancellation signal owned by the UI request.
|
||||
* @returns the settled execution (result + lifecycle pairing id), or
|
||||
* `undefined` when syntax or name does not resolve.
|
||||
@@ -330,7 +353,7 @@ export class CommandRuntime extends TypertRemoteService {
|
||||
async execute(
|
||||
agent: Agent,
|
||||
line: string,
|
||||
images: readonly EncodedImageAttachment[],
|
||||
submittedAttachments: readonly CommandSubmitAttachment[],
|
||||
signal: AbortSignal,
|
||||
): Promise<CommandExecution | undefined> {
|
||||
const parsed = parseCommand(line)
|
||||
@@ -355,18 +378,21 @@ export class CommandRuntime extends TypertRemoteService {
|
||||
})
|
||||
return Object.freeze({ commandId, result: Object.freeze(result) })
|
||||
}
|
||||
let attachments: readonly ImageBlock[] = NO_ATTACHMENTS
|
||||
if (images.length > 0) {
|
||||
if (command.definition.input?.images !== true) {
|
||||
return settle({ kind: 'error', text: `/${parsed.name} does not accept image attachments` })
|
||||
let attachments: readonly (ImageBlock | FileBlock)[] = NO_ATTACHMENTS
|
||||
if (submittedAttachments.length > 0) {
|
||||
if (command.definition.input?.attachments !== true) {
|
||||
return settle({ kind: 'error', text: `/${parsed.name} does not accept attachments` })
|
||||
}
|
||||
const store = this.ctx.get('attachments')
|
||||
if (store === undefined) {
|
||||
return settle({ kind: 'error', text: `/${parsed.name}: image attachments are unavailable because no attachment store is composed` })
|
||||
return settle({ kind: 'error', text: `/${parsed.name}: attachments are unavailable because no attachment store is composed` })
|
||||
}
|
||||
try {
|
||||
const refs = await admitEncodedImages(store, images)
|
||||
attachments = Object.freeze(refs.map(ref => Object.freeze({ type: 'image' as const, attachment: ref })))
|
||||
attachments = await admitCommandAttachments(
|
||||
store,
|
||||
submittedAttachments,
|
||||
receiptId => this.fileReceipts.resolver?.(agent, receiptId),
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof AttachmentError) {
|
||||
return settle({ kind: 'error', text: error.message })
|
||||
@@ -455,4 +481,45 @@ export class CommandRuntime extends TypertRemoteService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Admit a mixed command batch and restore its original image/file order. */
|
||||
async function admitCommandAttachments(
|
||||
store: Parameters<typeof admitEncodedImages>[0],
|
||||
attachments: readonly CommandSubmitAttachment[],
|
||||
resolveFileReceipt: (receiptId: string) => FileAttachmentRef | undefined,
|
||||
): Promise<readonly (ImageBlock | FileBlock)[]> {
|
||||
const files = new Map<string, FileAttachmentRef>()
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.type !== 'file' || files.has(attachment.receiptId)) continue
|
||||
const file = resolveFileReceipt(attachment.receiptId)
|
||||
if (file === undefined) {
|
||||
throw new AttachmentError('File upload receipt is unknown for this session.', 'ATTACHMENT_NOT_FOUND')
|
||||
}
|
||||
files.set(attachment.receiptId, file)
|
||||
}
|
||||
const images: EncodedImageAttachment[] = []
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.type !== 'image') continue
|
||||
images.push({
|
||||
mediaType: attachment.mediaType,
|
||||
data: attachment.data,
|
||||
...(attachment.name === undefined ? {} : { name: attachment.name }),
|
||||
})
|
||||
}
|
||||
const imageRefs = images.length === 0 ? [] : await admitEncodedImages(store, images)
|
||||
let imageIndex = 0
|
||||
const blocks: Array<ImageBlock | FileBlock> = []
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.type === 'image') {
|
||||
const ref = imageRefs[imageIndex] as ImageAttachmentRef
|
||||
imageIndex += 1
|
||||
blocks.push(Object.freeze({ type: 'image', attachment: ref }))
|
||||
continue
|
||||
}
|
||||
blocks.push(Object.freeze({
|
||||
type: 'file', attachment: files.get(attachment.receiptId) as FileAttachmentRef,
|
||||
}))
|
||||
}
|
||||
return Object.freeze(blocks)
|
||||
}
|
||||
|
||||
export default CommandRuntime
|
||||
|
||||
@@ -8,19 +8,25 @@
|
||||
*/
|
||||
|
||||
import type { CommandId } from './brand.ts'
|
||||
import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types'
|
||||
|
||||
/** One browser-submitted command attachment: encoded image input or a staged file receipt. */
|
||||
export type CommandSubmitAttachment =
|
||||
| ({ readonly type: 'image' } & EncodedImageAttachment)
|
||||
| { readonly type: 'file'; readonly receiptId: string }
|
||||
|
||||
/** Immutable metadata for a command's optional unstructured input. */
|
||||
export interface CommandInputDescriptor {
|
||||
/** Placeholder shown before the user supplies free-form input. */
|
||||
readonly hint: string
|
||||
/**
|
||||
* Whether composer image attachments may accompany an invocation. Absent or
|
||||
* false = the executor rejects an invocation carrying images and capable
|
||||
* Whether composer attachments may accompany an invocation. Absent or
|
||||
* false = the executor rejects an invocation carrying attachments and capable
|
||||
* composers refuse the submission before dispatch. A declaring command's
|
||||
* handler receives the admitted durable blocks and owns every further
|
||||
* grammar decision, including rejecting sub-commands that cannot use them.
|
||||
*/
|
||||
readonly images?: boolean
|
||||
readonly attachments?: boolean
|
||||
}
|
||||
|
||||
/** Expected command outcome rendered directly by the dispatching UI. */
|
||||
|
||||
@@ -225,11 +225,11 @@ export class PlanModeController extends Service {
|
||||
commandCtx.commands.register({
|
||||
name: 'plan',
|
||||
description: 'Enter or leave plan mode',
|
||||
input: { hint: '[off|message]', images: true },
|
||||
input: { hint: '[off|message]', attachments: true },
|
||||
handler: ({ agent, rawInput, attachments }) => {
|
||||
const message = rawInput.trim()
|
||||
if (message === 'off' && attachments.length > 0) {
|
||||
return { kind: 'error', text: 'Image attachments cannot accompany /plan off.' }
|
||||
return { kind: 'error', text: 'Attachments cannot accompany /plan off.' }
|
||||
}
|
||||
if (message === 'off') {
|
||||
switch (this.set(agent, false)) {
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"apps/web/tests/composer-draft-scroll.e2e.ts",
|
||||
"apps/web/tests/cordis-tool-round.e2e.ts",
|
||||
"apps/web/tests/web-search-round.e2e.ts",
|
||||
"apps/web/tests/file-upload-round.e2e.ts",
|
||||
"apps/web/tests/message-actions.e2e.ts",
|
||||
"apps/web/tests/message-feedback.e2e.ts",
|
||||
"apps/web/tests/message-feedback-layout.e2e.ts",
|
||||
|
||||
Reference in New Issue
Block a user