/** Session Remote owner: cold reads, explicit Agent commands, and live control state. */ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { ApiSessionAgentController, inspectApiSession, type ApiSessionAgentResult, } from './agent.ts' import { SessionCommandController } from './commands.ts' import { SessionControlController } from './control.ts' import { SessionHistoryController } from './history.ts' import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts' import { installModelSelectionProjection } from './model-selection-projection.ts' import type { SessionAttachmentRequest, SessionAttachmentValue, SessionCancelRequest, SessionCancelValue, SessionControlFrame, SessionCreateRequest, SessionCreateValue, SessionFollowFrame, SessionFollowRequest, SessionForkRequest, SessionForkValue, SessionListRequest, SessionListValue, SessionPage, SessionPageRequest, SessionPromptRequest, SessionPromptValue, SessionRenameRequest, SessionRenameValue, SessionSearchRequest, SessionSearchValue, SessionSelectModelRequest, SessionSelectModelValue, SessionUpdateQueueRequest, SessionUpdateQueueValue, } from './types.ts' export type * from './types.ts' export { ApiSessionNotFound } from './agent.ts' declare module '@deepseek-ai/cordis' { interface Context { /** Host Session business API and Remote namespace owner. */ sessionController: SessionController } } /** Session Controller deployment policy. */ export interface Config { /** Maximum cold Session artifact size eligible for one full projection observation. */ readonly coldBlankProbeMaxBytes?: number } /** Host service backing the generated `ctx.remote.session` namespace. */ export class SessionController extends TypertRemoteService { static inject = [ 'agentDefaultModel', 'agents', 'attachments', 'llm', 'sessions', 'sessionProjections', 'sessionQuery', 'typert', 'workspaceRegistry', ] static Config: z = z.object({ coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES), }) private readonly agents: ApiSessionAgentController private readonly commands: SessionCommandController private readonly controlState: SessionControlController private readonly history: SessionHistoryController private readonly listState: ApiSessionList private readonly promotions = new Set>() /** * @param ctx - Host context containing the Session capability assembly. * @param config - cold-list observation policy. */ constructor(ctx: Context, config: Config) { super(ctx, 'sessionController', { namespace: 'session' }) installModelSelectionProjection(ctx) this.agents = new ApiSessionAgentController(ctx) this.commands = new SessionCommandController(ctx, this.agents, process.cwd()) this.controlState = new SessionControlController(ctx) // Registered before history so reverse-order teardown closes every // follower before waiting for already-admitted promotions. ctx.effect(() => async () => { await Promise.allSettled([...this.promotions]) }, 'session-controller.promotions') this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) this.listState = new ApiSessionList( ctx, config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES, ) ctx.on('session/created', (session) => { ctx.emit('api-session/added', this.listState.summaryFor(session)) }) ctx.on('session/disposed', (session) => { ctx.emit('api-session/removed', session.id) }) ctx.on('agent/status', ({ agent, status }) => { ctx.emit('api-session/status', agent.id, status === 'running') }) ctx.on('agent/error', ({ agent, error }) => { ctx.emit('api-session/error', agent.id, errorChain(error)) }) ctx.on('session/event', (session, event) => { if (event.type === 'request/header') { const agent = ctx.agents.get(session.id) if (agent?.session === session) this.agents.consumeSelection( agent, event.data.header.config.provider, event.data.header.config.model, event.data.header.config.reasoningEffort, ) } if (event.type !== 'user/message' || event.data.source.kind !== 'user') return ctx.emit('api-session/activity', session.id, event.time) }) } private promote(observation: SessionObservation): void { const sessionId = observation.header.id const task = (async () => { using ownedObservation = observation const result = await this.agents.resolveObservedAgent(ownedObservation) if ('error' in result) this.ctx.emit('api-session/error', sessionId, result.error.message) })().catch((error: unknown) => { this.ctx.logger.error(`session-controller: background activation for "${sessionId}" failed: ${errorChain(error)}`) }) this.promotions.add(task) void task.finally(() => { this.promotions.delete(task) }) } /** * Resolve or resume one ordinary Session for another Host API domain. * @param sessionId - Session identity whose Agent owns the operation. * @returns the live Agent or the stable Session-domain failure. */ resolveAgent(sessionId: SessionId): Promise { return this.agents.resolveAgent(sessionId) } /** * Inspect one attached or persisted Session without activating its Agent. * @param sessionId - durable Session identity. * @param signal - optional caller cancellation for persistence reads. * @returns the current attached state or persisted header and event prefix. */ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { const attached = this.ctx.sessions.get(sessionId) if (attached !== undefined) { return Promise.resolve({ meta: attached.header, events: [...attached.events] }) } return inspectApiSession(this.ctx, sessionId, signal) } /** * Read all visible Session rows without resuming an Agent. * @param _request - reserved empty list request. * @param signal - cancellation for persistence reads. * @returns visible Session summaries ordered by activity. */ @Remote('list') async list(_request: SessionListRequest, signal: AbortSignal): Promise { return { items: await this.listState.list(signal) } } /** * Search visible Session content without resuming an Agent. * @param request - literal message-content query. * @param signal - cancellation for list and search reads. * @returns authorized bounded Session search results. */ @Remote('search') search(request: SessionSearchRequest, signal: AbortSignal): Promise { return this.listState.search(request.query, signal) } /** * Create or idempotently adopt one ordinary Session. * @param request - requested identity, location, and Agent preset. * @returns the Session identity and resolved preset when configured. */ @Remote('create') create(request: SessionCreateRequest): Promise { return this.commands.create(request) } /** * Select one Session-local model after explicitly resuming the Session. * @param request - Session identity and requested model selection. * @returns the normalized selection installed for the Session. */ @Remote('selectModel') selectModel(request: SessionSelectModelRequest): Promise { return this.commands.selectModel(request) } /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. * @returns the accepted title and durable event sequence. */ @Remote('rename') rename(request: SessionRenameRequest): Promise { return this.commands.rename(request) } /** * Fork one cold-readable completed-turn prefix into a new Session. * @param request - source Session and optional event anchor. * @returns the new Session identity. */ @Remote('fork') fork(request: SessionForkRequest): Promise { return this.commands.fork(request) } /** * Admit one prompt after explicitly resuming its Session. * @param request - Session identity, prompt content, source metadata, and delivery mode. * @param signal - caller cancellation before prompt admission begins. * @returns acknowledgement that the Agent accepted the prompt. */ @Remote('prompt') prompt(request: SessionPromptRequest, signal: AbortSignal): Promise { signal.throwIfAborted() return this.commands.prompt(request) } /** * Read one image proven reachable from the addressed Session log. * @param request - Session and attachment identities used for authorization. * @returns the durable attachment reference and base64-encoded bytes. */ @Remote('attachment') attachment(request: SessionAttachmentRequest): Promise { return this.commands.attachment(request) } /** * Mutate one still-pending queue occurrence on a live Agent. * @param request - Session, queue item, and requested mutation. * @returns acknowledgement that the queue mutation was applied. */ @Remote('updateQueue') updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue { return this.commands.updateQueue(request) } /** * Cancel one active Agent turn without dropping its pending inbox. * @param request - Session whose active Agent turn is cancelled. * @returns acknowledgement that cancellation was requested. */ @Remote('cancel') cancel(request: SessionCancelRequest): SessionCancelValue { return this.commands.cancel(request) } /** * Read one cold-safe, message-aligned Session history page. * @param request - durable address, backward cursor, and page budget. * @param signal - cancellation for persistence reads. * @returns one chronological page. */ @Remote('page') page(request: SessionPageRequest, signal: AbortSignal): Promise { return this.history.page(request, signal) } /** * 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. */ @Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { return this.history.follow(request, signal) } /** * Stream a complete live-control baseline followed by replacement frames. * @param signal - cancellation owned by the Remote stream carrier. * @returns one complete baseline followed by live replacement frames. */ @Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable { return this.controlState.control(signal) } } export { buildModelCatalog } from './catalog.ts' export default SessionController