mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-07 04:10:42 +00:00
# Conflicts: # docs/config-catalog.i18n.yaml # docs/config-catalog.md # docs/event-producer-consumer.i18n.yaml # docs/event-producer-consumer.md # docs/event-producer-consumer.zh.md # docs/module-graph.i18n.yaml # docs/module-graph.md # docs/module-graph.zh.md # docs/persistence-catalog.i18n.yaml # docs/persistence-catalog.md # docs/persistence-catalog.zh.md # packages/api/session-controller/src/commands.ts # packages/api/session-controller/src/index.ts # packages/api/session-controller/tests/client-contract.client.spec.ts # packages/api/session-controller/tests/session-pending-submissions.client.spec.ts # packages/client/ui-attachment/src/AttachmentRail.module.css # packages/client/ui-chat/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-trajectory/README.i18n.yaml # packages/client/ui-trajectory/README.md # packages/client/ui-trajectory/README.zh.md # packages/extensions/cordis-client-runner/src/client/api-catalog.ts # packages/llm/token-meter/src/surface-fold.ts # packages/session-query/session-log-export/src/archive.ts # packages/session-query/session-log-export/tests/archive.host.spec.ts
767 lines
30 KiB
TypeScript
767 lines
30 KiB
TypeScript
/** Session commands whose activation policy is explicit at each Remote method. */
|
|
|
|
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, 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 { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
|
import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
|
|
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'
|
|
import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time'
|
|
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
|
|
import type { Workspace } from '@deepseek-ai/dsh-workspace'
|
|
import {
|
|
ApiSessionAgentController,
|
|
ApiSessionCwdConflict,
|
|
ApiSessionNotFound,
|
|
ApiSessionPresetConflict,
|
|
ApiSessionSubagentOwnership,
|
|
apiSessionSubagentOwnershipError,
|
|
hasApiSessionSubagentOwner,
|
|
inspectApiSession,
|
|
} from './agent.ts'
|
|
import type {
|
|
SessionAttachmentRequest,
|
|
SessionAttachmentValue,
|
|
SessionCancelRequest,
|
|
SessionCancelValue,
|
|
SessionCreateRequest,
|
|
SessionCreateValue,
|
|
SessionForkRequest,
|
|
SessionForkValue,
|
|
FileUploadReceiptId,
|
|
SessionPromptRequest,
|
|
SessionPromptValue,
|
|
SessionRenameRequest,
|
|
SessionRenameValue,
|
|
SessionSelectModelRequest,
|
|
SessionSelectModelValue,
|
|
SessionUpdateQueueRequest,
|
|
SessionUpdateQueueValue,
|
|
SessionUploadFileRequest,
|
|
SessionUploadFileValue,
|
|
SessionRequestId,
|
|
} from './types.ts'
|
|
|
|
interface SessionReadState {
|
|
readonly id: SessionId
|
|
readonly header: SessionHeader
|
|
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.
|
|
* @param defaultCwd - project directory used when create names neither a Workspace nor a cwd.
|
|
*/
|
|
constructor(
|
|
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.
|
|
* @param request - requested identity, location, and Agent preset.
|
|
* @returns the Session identity and resolved preset when configured.
|
|
*/
|
|
async create(request: SessionCreateRequest): Promise<SessionCreateValue> {
|
|
if (request.workspaceId !== undefined && request.cwd !== undefined) {
|
|
throw new RemoteError('gateway/bad-request', 'session.create accepts workspaceId or cwd, not both', {})
|
|
}
|
|
const sessionId = request.sessionId ?? brandString<SessionId>(`session-${randomUUID()}`)
|
|
let workspace: Workspace | undefined
|
|
if (request.workspaceId !== undefined) {
|
|
workspace = this.ctx.workspaceRegistry.get(request.workspaceId)
|
|
if (workspace === undefined) {
|
|
throw new RemoteError('workspace/not-found', `workspace "${request.workspaceId}" not found`, {
|
|
workspaceId: request.workspaceId,
|
|
})
|
|
}
|
|
}
|
|
const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd
|
|
let adopted: Agent
|
|
try {
|
|
adopted = await this.agents.ensureSession(
|
|
sessionId,
|
|
cwd,
|
|
request.sessionId !== undefined,
|
|
request.agentPreset,
|
|
)
|
|
} catch (error) {
|
|
this.rejectCreation(sessionId, error)
|
|
}
|
|
if (workspace !== undefined) {
|
|
try {
|
|
await workspace.attachSession(sessionId)
|
|
} catch (error) {
|
|
throw new RemoteError(
|
|
'session/workspace-attach-failed',
|
|
`session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`,
|
|
{ sessionId, workspaceId: workspace.id },
|
|
)
|
|
}
|
|
}
|
|
const agentPreset = this.agents.presetForSession(adopted.session)
|
|
return { sessionId, ...(agentPreset === undefined ? {} : { agentPreset }) }
|
|
}
|
|
|
|
/**
|
|
* Validate and install one Session-local model selection.
|
|
* @param request - Session identity and requested model selection.
|
|
* @returns the normalized selection installed for the Session.
|
|
*/
|
|
async selectModel(request: SessionSelectModelRequest): Promise<SessionSelectModelValue> {
|
|
const agent = await this.resolveAgent(request.sessionId)
|
|
return this.agents.serializeImageAdmission(agent, async () => {
|
|
try {
|
|
const resolved = await this.ctx.llm.resolveCallConfig({
|
|
provider: request.provider,
|
|
model: request.model,
|
|
...(request.reasoningEffort === undefined
|
|
? {}
|
|
: { reasoningEffort: ReasoningEffortId(request.reasoningEffort) }),
|
|
})
|
|
const selected: AgentModelSelection = {
|
|
provider: resolved.provider,
|
|
model: resolved.model,
|
|
...(resolved.reasoningEffort === undefined
|
|
? {}
|
|
: { reasoningEffort: resolved.reasoningEffort }),
|
|
}
|
|
this.agents.selectForNextRequest(agent, selected)
|
|
try {
|
|
await this.ctx.agentDefaultModel.saveSelection(selected)
|
|
} catch (error) {
|
|
this.ctx.logger.warn(
|
|
`session-controller: model selection changed for the Session but the default was not saved: ${String(error)}`,
|
|
)
|
|
}
|
|
return { selected: { ...selected } }
|
|
} catch (error) {
|
|
if (remoteErrorOf(error) !== undefined) throw error
|
|
throw new RemoteError(
|
|
'session/model-unavailable',
|
|
error instanceof Error ? error.message : String(error),
|
|
{ provider: request.provider, model: request.model },
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Normalize and append a user-owned Session title.
|
|
* @param request - Session identity and proposed title.
|
|
* @returns the accepted title and durable event sequence.
|
|
*/
|
|
async rename(request: SessionRenameRequest): Promise<SessionRenameValue> {
|
|
const agent = await this.resolveAgent(request.sessionId)
|
|
const titles = this.ctx.get('sessionTitle')
|
|
if (titles === undefined) {
|
|
throw new RemoteError('gateway/internal', 'renaming is unavailable: this deployment mounts no session-title service', {})
|
|
}
|
|
try {
|
|
const accepted = titles.rename(agent.session, request.title)
|
|
return { title: accepted.title, seq: accepted.eventSeq }
|
|
} catch (error) {
|
|
if (error instanceof SessionTitleInvalidError) {
|
|
throw new RemoteError('session/title-invalid', error.message, { sessionId: request.sessionId })
|
|
}
|
|
throw new RemoteError(
|
|
'gateway/internal',
|
|
`failed to rename session "${request.sessionId}": ${String(error)}`,
|
|
{},
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new ordinary Session from one completed-turn prefix.
|
|
* @param request - source Session and optional event anchor.
|
|
* @returns the new Session identity.
|
|
*/
|
|
async fork(request: SessionForkRequest): Promise<SessionForkValue> {
|
|
let atSeq: ReturnType<typeof SessionSeq> | undefined
|
|
try {
|
|
atSeq = request.atSeq === undefined ? undefined : SessionSeq(request.atSeq)
|
|
} catch {
|
|
throw new RemoteError('gateway/bad-request', 'atSeq must be a non-negative safe integer', {})
|
|
}
|
|
let observed: SessionObservation
|
|
try {
|
|
observed = await this.ctx.sessionQuery.observeSession(request.sessionId)
|
|
} catch (error) {
|
|
if (error instanceof SessionQueryError
|
|
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
|
|
throw new RemoteError('session/not-found', `session "${request.sessionId}" not found`, {
|
|
sessionId: request.sessionId,
|
|
})
|
|
}
|
|
throw new RemoteError(
|
|
'gateway/internal',
|
|
`fork source unavailable for session "${request.sessionId}": ${String(error)}`,
|
|
{},
|
|
)
|
|
}
|
|
using source = observed
|
|
const lastSeq = source.events.at(-1)?.seq ?? -1
|
|
const anchoredBoundary = atSeq === undefined
|
|
? undefined
|
|
: source.events.find(event => event.type === 'turn/end' && event.seq >= atSeq)
|
|
const boundary = anchoredBoundary
|
|
?? (atSeq === undefined || atSeq > lastSeq
|
|
? source.events.findLast(event => event.type === 'turn/end')
|
|
: undefined)
|
|
if (boundary === undefined) {
|
|
throw new RemoteError(
|
|
'session/fork-unavailable',
|
|
atSeq !== undefined && atSeq <= lastSeq
|
|
? `session "${request.sessionId}" has not completed the turn containing event ${String(atSeq)}`
|
|
: `session "${request.sessionId}" has no completed turn to fork from`,
|
|
{ sessionId: request.sessionId },
|
|
)
|
|
}
|
|
let cut = SessionLogOffset(boundary.seq + 1)
|
|
while (cut < source.events.length && source.events[cut]?.type !== 'turn/start') {
|
|
cut = SessionLogOffset(cut + 1)
|
|
}
|
|
let workspace: Workspace | undefined
|
|
try {
|
|
workspace = await this.forkWorkspace(source.header)
|
|
} catch (error) {
|
|
throw new RemoteError(
|
|
'gateway/internal',
|
|
`failed to resolve fork workspace for session "${request.sessionId}": ${String(error)}`,
|
|
{},
|
|
)
|
|
}
|
|
const childId = brandString<SessionId>(`session-${randomUUID()}`)
|
|
const composition = await this.agents.composeAgent(this.agents.presetForObservation(source))
|
|
try {
|
|
const { provider, model } = this.ctx.agentDefaultModel.currentSelection()
|
|
await this.ctx.agents.create({
|
|
sessionId: childId,
|
|
seed: source.events.slice(0, cut),
|
|
inheritedEventCount: cut,
|
|
meta: {
|
|
...(source.header.cwd === undefined ? {} : { cwd: source.header.cwd }),
|
|
parentSession: source.header.id,
|
|
isSeeded: true,
|
|
...(composition.agentPreset === undefined
|
|
? {}
|
|
: { agentPreset: composition.agentPreset }),
|
|
},
|
|
agentOptions: { provider, model },
|
|
setup: composition.setup,
|
|
})
|
|
} catch (error) {
|
|
throw new RemoteError(
|
|
'gateway/internal',
|
|
`failed to fork session "${request.sessionId}": ${String(error)}`,
|
|
{},
|
|
)
|
|
}
|
|
if (workspace !== undefined) {
|
|
try {
|
|
await workspace.attachSession(childId)
|
|
} catch (error) {
|
|
throw new RemoteError(
|
|
'session/workspace-attach-failed',
|
|
`session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`,
|
|
{ sessionId: childId, workspaceId: workspace.id },
|
|
)
|
|
}
|
|
}
|
|
return { sessionId: childId }
|
|
}
|
|
|
|
/**
|
|
* Admit one browser prompt after explicit Agent resume and image validation.
|
|
* @param request - Session identity, prompt content, source metadata, and delivery mode.
|
|
* @returns acknowledgement that the Agent accepted the prompt.
|
|
*/
|
|
async prompt(request: SessionPromptRequest): Promise<SessionPromptValue> {
|
|
const clientTimeZone = request.clientTimeZone === undefined
|
|
? undefined
|
|
: canonicalClientTimeZone(request.clientTimeZone)
|
|
if (request.clientTimeZone !== undefined && clientTimeZone === undefined) {
|
|
throw new RemoteError(
|
|
'session/invalid-time-zone',
|
|
'clientTimeZone must be UTC or a valid IANA Area/Location name',
|
|
{ value: request.clientTimeZone },
|
|
)
|
|
}
|
|
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(
|
|
'session/model-unavailable',
|
|
`no adapter serves provider "${selection.provider}"; select a model for this session`,
|
|
{ provider: selection.provider, model: selection.model },
|
|
)
|
|
}
|
|
const source: MessageSource = {
|
|
kind: 'user',
|
|
rpcId: request.requestId,
|
|
...(clientTimeZone === undefined ? {} : { clientTimeZone }),
|
|
}
|
|
const hasImage = request.content.some(part => part.type === 'image')
|
|
const admit = async (): Promise<SessionPromptValue> => {
|
|
try {
|
|
if (hasImage) {
|
|
const current = this.agents.selectionFor(agent).current
|
|
const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model)
|
|
if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) {
|
|
throw new RemoteError(
|
|
'session/attachment-invalid',
|
|
`Model "${current.model}" does not support image input.`,
|
|
{ reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
|
|
)
|
|
}
|
|
}
|
|
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) {
|
|
throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code })
|
|
}
|
|
throw new RemoteError('session/agent-busy', 'prompt rejected', { reason: String(error) })
|
|
}
|
|
return { accepted: true }
|
|
}
|
|
return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit()
|
|
}
|
|
|
|
/**
|
|
* Read one durable image after proving the Session log references it.
|
|
* @param request - Session and attachment identities used for authorization.
|
|
* @returns the durable attachment reference and base64-encoded bytes.
|
|
*/
|
|
async attachment(request: SessionAttachmentRequest): Promise<SessionAttachmentValue> {
|
|
let source: SessionReadState
|
|
try {
|
|
source = await this.readSessionState(request.sessionId)
|
|
} catch (error) {
|
|
if (error instanceof ApiSessionNotFound) {
|
|
throw new RemoteError('session/not-found', error.message, { sessionId: request.sessionId })
|
|
}
|
|
throw new RemoteError(
|
|
'gateway/internal',
|
|
`attachment authorization unavailable for session "${request.sessionId}": ${String(error)}`,
|
|
{},
|
|
)
|
|
}
|
|
const ref = referencedImage(source.events, String(request.attachmentId))
|
|
if (ref === undefined) {
|
|
throw new RemoteError(
|
|
'session/attachment-invalid',
|
|
'Image is not referenced by this session.',
|
|
{ reason: 'ATTACHMENT_NOT_REFERENCED' },
|
|
)
|
|
}
|
|
try {
|
|
const stored = await this.ctx.attachments.readImage(ref)
|
|
return {
|
|
attachment: stored.ref,
|
|
data: Buffer.from(stored.data).toString('base64'),
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof AttachmentError) {
|
|
throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code })
|
|
}
|
|
throw new RemoteError('gateway/internal', 'Unable to read image attachment.', {})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Mutate one still-pending queue occurrence without resuming a cold Agent.
|
|
* @param request - Session, queue item, and requested mutation.
|
|
* @returns acknowledgement that the queue mutation was applied.
|
|
*/
|
|
updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue {
|
|
if (request.action.kind === 'edit'
|
|
&& request.action.content.some(block => block.type !== 'text')) {
|
|
throw new RemoteError(
|
|
'session/attachment-invalid',
|
|
'queue edits accept text content only',
|
|
{ reason: 'QUEUE_EDIT_NON_TEXT' },
|
|
)
|
|
}
|
|
const agent = this.ctx.agents.get(request.sessionId)
|
|
if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
|
|
throw apiSessionSubagentOwnershipError(request.sessionId)
|
|
}
|
|
if (agent === undefined) {
|
|
throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
|
|
}
|
|
const nextTurn = agent.inbox.nextTurn.find(message => message.id === request.itemId)
|
|
const nextStep = agent.inbox.nextStep.find(message => message.id === request.itemId)
|
|
const located = nextTurn === undefined
|
|
? nextStep === undefined ? undefined : { target: 'next-step' as const, message: nextStep }
|
|
: { target: 'next-turn' as const, message: nextTurn }
|
|
if (located === undefined) {
|
|
throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
|
|
}
|
|
const { target, message } = located
|
|
if (request.action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) {
|
|
throw new RemoteError('session/steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId })
|
|
}
|
|
if (request.action.kind === 'edit') {
|
|
agent.inbox.replace(request.itemId, freezeMessage<UserMessage>({
|
|
...message,
|
|
content: [...request.action.content],
|
|
}))
|
|
} 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 }
|
|
}
|
|
|
|
/**
|
|
* Cancel one live ordinary Agent while retaining pending inbox work.
|
|
* @param request - Session whose active Agent turn is cancelled.
|
|
* @returns acknowledgement that cancellation was requested.
|
|
*/
|
|
cancel(request: SessionCancelRequest): SessionCancelValue {
|
|
const agent = this.ctx.agents.get(request.sessionId)
|
|
if (agent === undefined) {
|
|
throw new RemoteError(
|
|
'session/not-found',
|
|
`session "${request.sessionId}" not found (not attached)`,
|
|
{ sessionId: request.sessionId },
|
|
)
|
|
}
|
|
if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
|
|
throw apiSessionSubagentOwnershipError(request.sessionId)
|
|
}
|
|
agent.cancel({ kind: 'user' }, { keepInbox: true })
|
|
return { accepted: true }
|
|
}
|
|
|
|
private async resolveAgent(sessionId: SessionId): Promise<Agent> {
|
|
const found = await this.agents.resolveAgent(sessionId)
|
|
if ('error' in found) throw found.error
|
|
return found.agent
|
|
}
|
|
|
|
private rejectCreation(sessionId: SessionId, error: unknown): never {
|
|
if (remoteErrorOf(error) !== undefined) throw error
|
|
if (error instanceof ApiSessionPresetConflict) {
|
|
throw new RemoteError('agent-preset/conflict', error.message, {
|
|
sessionId: error.sessionId,
|
|
requestedPreset: error.requestedPreset,
|
|
...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }),
|
|
})
|
|
}
|
|
if (error instanceof ApiSessionCwdConflict) {
|
|
throw new RemoteError('session/conflict', error.message, {
|
|
sessionId: error.sessionId,
|
|
requestedCwd: error.requestedCwd,
|
|
...(error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd }),
|
|
})
|
|
}
|
|
if (error instanceof ApiSessionSubagentOwnership) {
|
|
throw apiSessionSubagentOwnershipError(error.sessionId)
|
|
}
|
|
throw new RemoteError('gateway/internal', `failed to create session "${sessionId}": ${String(error)}`, {})
|
|
}
|
|
|
|
private async readSessionState(sessionId: SessionId): Promise<SessionReadState> {
|
|
const attached = this.ctx.sessions.get(sessionId)
|
|
if (attached !== undefined) {
|
|
return { id: attached.id, header: attached.header, events: attached.snapshotEvents() }
|
|
}
|
|
const inspected = await inspectApiSession(this.ctx, sessionId)
|
|
return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }
|
|
}
|
|
|
|
private async forkWorkspace(source: SessionHeader): Promise<Workspace | undefined> {
|
|
const workspaces = this.ctx.workspaceRegistry.list()
|
|
const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id))
|
|
if (direct !== undefined || source.origin !== 'subagent') return direct
|
|
const lineage = await this.ctx.sessionQuery.traceSession(source.id)
|
|
for (const ancestor of lineage.ancestors) {
|
|
const workspace = workspaces.find(candidate => candidate.sessionIds.includes(ancestor.header.id))
|
|
if (workspace !== undefined) return workspace
|
|
}
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
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,
|
|
): ImageAttachmentRef | undefined {
|
|
if (!Array.isArray(content)) return undefined
|
|
for (const value of content) {
|
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
|
|
const block = value as { readonly type?: unknown; readonly attachment?: unknown; readonly content?: unknown }
|
|
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
|
|
const ref = block.attachment as ImageAttachmentRef
|
|
if (match(ref)) return ref
|
|
}
|
|
if (block.type === 'tool-result') {
|
|
const nested = imageBlockIn(block.content, match)
|
|
if (nested !== undefined) return nested
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function imageInEvent(
|
|
event: SessionEvent,
|
|
match: (ref: ImageAttachmentRef) => boolean,
|
|
): ImageAttachmentRef | undefined {
|
|
const data = event.data as {
|
|
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
|
|
const message = imageBlockIn(data.message?.content, match)
|
|
if (message !== undefined) return message
|
|
for (const inserted of data.inserted ?? []) {
|
|
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
|
|
}
|
|
|
|
function referencedImage(
|
|
events: readonly SessionEvent[],
|
|
attachmentId: string,
|
|
): ImageAttachmentRef | undefined {
|
|
for (const event of events) {
|
|
const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId)
|
|
if (found !== undefined) return found
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function routeServed(ctx: Context, provider: string): boolean {
|
|
return ctx.llm.listProviders().some(entry => entry.id === provider)
|
|
}
|