/** Agent activation, composition, and initial model route owned by API Session. */ import { randomUUID } from 'node:crypto' import { mkdir } from 'node:fs/promises' import type { Context } from '@deepseek-ai/cordis' import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' import { installModelSelection } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-preset-registry' import { brandString } from '@deepseek-ai/dsh-brand' import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-typert-registry' /** Cold Session identity absent from persistence. */ export class ApiSessionNotFound extends Error {} /** Session identity whose lifecycle belongs to subagent routing. */ export class ApiSessionSubagentOwnership extends Error { /** @param sessionId - identity reserved to subagent routing. */ constructor(readonly sessionId: SessionId) { super(`session "${sessionId}" is a subagent session; use subagent delivery`) } } /** Explicit-id creation attempted to adopt a Session under another cwd. */ export class ApiSessionCwdConflict extends Error { constructor( readonly sessionId: SessionId, readonly requestedCwd: string, readonly existingCwd: string | undefined, ) { super( existingCwd === undefined ? `session "${sessionId}" records no cwd and cannot be adopted for "${requestedCwd}"` : `session "${sessionId}" belongs to "${existingCwd}", not "${requestedCwd}"`, ) } } /** Reuse of a Session named an Agent preset while the deployment composes no preset roster. */ export class ApiSessionPresetConflict extends Error { constructor( readonly sessionId: SessionId, readonly requestedPreset: string, ) { super(`the deployment composes no agent preset roster, so session "${sessionId}" cannot run preset "${requestedPreset}"`) } } /** A blank Session chosen for reuse started a turn before this Host acquired its writer. */ class ApiSessionStartedSinceObserved extends Error {} /** Placement and Agent preset for one Session created for a new conversation. */ export interface ApiSessionCreateRequest { /** Blank Session to reuse, or the identity of a new Session when none is stored under it. */ readonly sessionId?: SessionId /** Directory the Session owns. */ readonly cwd: string /** Preset the Session runs; the configured default when omitted. */ readonly agentPreset?: string /** * Placement rule the caller owns, such as Workspace membership and archive state. * @param sessionId - existing Session named by the request. * @returns whether that Session may be reused. */ readonly reusable: (sessionId: SessionId) => boolean } /** The Session one creation request created or reused. */ export interface ApiSessionCreated { /** Live Agent of the Session. */ readonly agent: Agent /** Preset the Session ran when the request finished, before a later request for the same identity; absent without a roster. */ readonly agentPreset: string | undefined } /** Resolved preset and pre-publication Agent setup for one create or resume. */ interface ApiSessionComposition { readonly agentPreset?: string readonly setup: AgentSetup } /** Failures produced while resolving one ordinary Session identity to its live Agent. */ export type ApiSessionAgentError = RemoteError<'session/not-found' | 'session/agent-busy' | 'session/writer-held' | 'gateway/internal'> /** Result of resolving one ordinary Session identity to its live Agent. */ export type ApiSessionAgentResult = | { readonly agent: Agent } | { readonly error: ApiSessionAgentError } /** * Test whether generic Session routing must leave an identity to subagent routing. * @param ctx - Host context carrying the Agent ownership registry. * @param session - attached or live Session whose ownership is tested. * @param agent - live Agent when one exists for the Session. * @returns whether subagent routing owns the Session identity. */ export function hasApiSessionSubagentOwner( ctx: Context, session: Pick, agent: Agent | undefined, ): boolean { if (session.header.origin === 'subagent') return true const parentId = session.header.parentSession if (parentId === undefined || agent === undefined) return false const parent = ctx.agents.get(parentId) return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent) } /** * Build the stable caller-facing subagent ownership rejection. * @param sessionId - Session identity owned by subagent routing. * @returns a stable Session-domain failure. */ export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessionAgentError { return new RemoteError( 'session/agent-busy', `session "${sessionId}" is owned by subagent routing`, { reason: 'use subagent delivery for this child session' }, ) } /** * Inspect one cold Session without repairing, resuming, or publishing it. * @param ctx - Host context carrying Session persistence. * @param sessionId - durable Session identity. * @param signal - optional cancellation for persistence reads. * @returns the persisted header and complete event prefix. */ export async function inspectApiSession( ctx: Context, sessionId: SessionId, signal?: AbortSignal, ): Promise { try { using observation = await ctx.sessionQuery.observeSession(sessionId, { ...(signal === undefined ? {} : { signal }), projectionMode: 'none', }) if (observation.header.cwd === undefined) { throw new ApiSessionNotFound(`session "${sessionId}" not found`) } return { meta: observation.header, inheritedEventCount: observation.inheritedEventCount, events: [...observation.events], } } catch (error: unknown) { if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { throw new ApiSessionNotFound(`session "${sessionId}" not found`) } throw error } } /** Owns every operation that may create, resume, or configure a Web Agent. */ export class ApiSessionAgentController { private readonly resumes = new Map>() private readonly claims = new Map>() private readonly imageAdmissionChains = new WeakMap>() /** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */ constructor(private readonly ctx: Context) {} /** * Configure Session and Agent lookups for Typert. */ initialize(): void { const ctx = this.ctx ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => { const found = await this.resolveAgent(sessionId) if ('error' in found) throw found.error return found.agent }) ctx.typert.lookups.configure('session', async (sessionId: SessionId) => { const found = await this.resolveAgent(sessionId) if ('error' in found) throw found.error return found.agent.session }) ctx.typert.contexts.configureHost('agent', async (sessionId: SessionId) => { const found = await this.resolveAgent(sessionId) if ('error' in found) throw found.error return found.agent.ctx }) } /** * Resolve or resume one ordinary Session, deduplicating concurrent resumes. * @param sessionId - ordinary Session identity. * @returns the live Agent or a stable Session-domain failure. */ async resolveAgent(sessionId: SessionId): Promise { return this.resolve(sessionId) } /** * Resolve one ordinary Session from an already-retained exact observation. * @param observation - Host-owned observation whose preparation stays pinned through setup. * @returns the live Agent or a stable Session-domain failure. */ async resolveObservedAgent(observation: SessionObservation): Promise { return this.resolve(observation.header.id, observation) } private async resolve( sessionId: SessionId, observation?: SessionObservation, ): Promise { const live = this.liveAgent(sessionId) if (live !== undefined) return live const attached = this.ctx.sessions.get(sessionId) if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) { return { error: apiSessionSubagentOwnershipError(sessionId) } } let resume = this.resumes.get(sessionId) if (resume === undefined) { resume = this.resume(sessionId, observation).finally(() => { this.resumes.delete(sessionId) }) this.resumes.set(sessionId, resume) } try { const agent = await resume // A shared resume can publish an identity that subagent routing adopts // before every waiter observes it; apply the live ownership policy again. const published = this.liveAgent(sessionId) return published ?? { agent } } catch (error: unknown) { if (error instanceof ApiSessionNotFound) { return { error: new RemoteError('session/not-found', error.message, { sessionId }) } } if (error instanceof ApiSessionSubagentOwnership) { return { error: apiSessionSubagentOwnershipError(error.sessionId) } } const raced = this.liveAgent(sessionId) if (raced !== undefined) return raced const racedSession = this.ctx.sessions.get(sessionId) if (racedSession !== undefined && hasApiSessionSubagentOwner(this.ctx, racedSession, undefined)) { return { error: apiSessionSubagentOwnershipError(sessionId) } } if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') { return { error: new RemoteError('session/writer-held', error.message, { sessionId }) } } return { error: new RemoteError( 'gateway/internal', `resume failed for session "${sessionId}": ${String(error)}`, {}, ), } } } /** * Create a Session for a new conversation, reusing the named blank Session * while it is still reusable. A reused Session runs the requested preset, or * the configured default when the request names none; the preset it recorded * is not resolved, and a change is logged as `agent-preset/selected` before * the Agent is published. A named Session that has started a turn, fails the * caller's placement rule, or is written by another owner stays unchanged, * and a new Session is created instead. Requests naming one identity run in * arrival order, including their preset resolution, and check the named * Session's directory and ownership before resolving the preset. * @param request - optional Session to reuse, directory, preset, and placement rule. * @returns the reused or created Session and the preset it ran when this request finished. * @throws ApiSessionCwdConflict when the named Session belongs to another directory. * @throws ApiSessionSubagentOwnership when subagent routing owns the named Session. * @throws RemoteError `agent-preset/not-found` or `agent-preset/invalid` for the requested or default preset. * @throws ApiSessionPresetConflict when no roster is composed and the request names a preset for a reused Session. */ async createSession(request: ApiSessionCreateRequest): Promise { const named = request.sessionId if (named === undefined) return this.created(await this.createMinted(request.cwd, await this.composeAgent(request.agentPreset))) return this.inOrder(named, async () => this.created(await this.claim(named, request))) } /** * Read the current Agent preset from the Session projection. * @param session - live Session whose projection state is available. * @returns the current preset, or undefined without a preset service or recorded selection. */ presetForSession(session: Session): string | undefined { return this.ctx.get('agentPresets')?.presetForSession(session) ?? undefined } /** * Serialize image admission and model selection for one Agent. * @param agent - live Agent that owns the serialization chain. * @param operation - asynchronous operation admitted after prior work settles. * @returns the operation result or rejection. */ serializeImageAdmission(agent: Agent, operation: () => Promise): Promise { const result = (this.imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation) this.imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined)) return result } /** * Resolve the preset id and pre-publication Agent setup for a create or resume. * @param presetId - requested preset or the configured default when omitted. * @returns the resolved preset identity and Agent setup callback. */ async composeAgent(presetId: string | undefined): Promise { const presets = this.ctx.get('agentPresets') if (presets === undefined) { return { setup: (agentCtx) => { installModelSelection(agentCtx) } } } const resolvedId = (await presets.resolve(presetId)).id return { agentPreset: resolvedId, setup: async (agentCtx) => { installModelSelection(agentCtx) await presets.mount(agentCtx, resolvedId) }, } } private liveAgent(sessionId: SessionId): ApiSessionAgentResult | undefined { const agent = this.ctx.agents.get(sessionId) if (agent === undefined) return undefined return hasApiSessionSubagentOwner(this.ctx, agent.session, agent) ? { error: apiSessionSubagentOwnershipError(sessionId) } : { agent } } private async resume(sessionId: SessionId, supplied?: SessionObservation): Promise { if (supplied !== undefined) return this.resumeObserved(sessionId, supplied) try { using observation = await this.ctx.sessionQuery.observeSession(sessionId) return await this.resumeObserved(sessionId, observation) } catch (error: unknown) { if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { throw new ApiSessionNotFound(`session "${sessionId}" not found`) } throw error } } private async resumeObserved( sessionId: SessionId, observation: SessionObservation, ): Promise { if (observation.header.id !== sessionId || observation.header.cwd === undefined) { throw new ApiSessionNotFound(`session "${sessionId}" not found`) } if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) { throw new ApiSessionSubagentOwnership(sessionId) } const composition = await this.composeAgent(this.presetForObservation(observation)) const published = this.ctx.sessions.get(sessionId) const live = this.ctx.agents.get(sessionId) if (published !== undefined && hasApiSessionSubagentOwner(this.ctx, published, live)) { throw new ApiSessionSubagentOwnership(sessionId) } return (await this.ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: this.agentOptions(), setup: composition.setup, })).agent } private async inOrder(sessionId: SessionId, operation: () => Promise): Promise { const result = (this.claims.get(sessionId) ?? Promise.resolve()).then(operation) const settled = result.then(() => undefined, () => undefined) this.claims.set(sessionId, settled) try { return await result } finally { if (this.claims.get(sessionId) === settled) this.claims.delete(sessionId) } } /** @returns the reused Session, a new Session under `sessionId` when none is stored, or a new Session under a minted identity. */ private async claim(sessionId: SessionId, request: ApiSessionCreateRequest): Promise { const attached = this.ctx.sessions.get(sessionId) const live = this.ctx.agents.get(sessionId) if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) { throw new ApiSessionSubagentOwnership(sessionId) } if (live !== undefined) { if (live.session.header.cwd !== request.cwd) { throw new ApiSessionCwdConflict(live.id, request.cwd, live.session.header.cwd) } const composition = await this.composeAgent(request.agentPreset) return await this.reuseLive(live, request, composition) ?? this.createMinted(request.cwd, composition) } let observation: SessionObservation try { observation = await this.ctx.sessionQuery.observeSession(sessionId) } catch (error: unknown) { if (!(error instanceof SessionQueryError) || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error return this.createAgent(sessionId, request.cwd, await this.composeAgent(request.agentPreset)) } let reusable: boolean { using observed = observation if (hasApiSessionSubagentOwner(this.ctx, { header: observed.header }, undefined)) { throw new ApiSessionSubagentOwnership(sessionId) } if (observed.header.cwd !== request.cwd) { throw new ApiSessionCwdConflict(sessionId, request.cwd, observed.header.cwd) } reusable = request.reusable(sessionId) && !observed.events.some(event => event.type === 'turn/start') } const composition = await this.composeAgent(request.agentPreset) if (!reusable) return this.createMinted(request.cwd, composition) this.assertPresetApplicable(sessionId, request.agentPreset) try { return (await this.ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: this.agentOptions(), setup: this.reuseSetup(composition), })).agent } catch (error: unknown) { if (error instanceof ApiSessionStartedSinceObserved) return this.createMinted(request.cwd, composition) if (!(error instanceof Error) || error.name !== 'SessionAlreadyOwnedError') throw error // Another resume in this process may hold the writer for the Agent it published. return this.ctx.agents.get(sessionId) === undefined ? this.createMinted(request.cwd, composition) : this.claim(sessionId, request) } } /** @returns the live blank Session under the requested preset, or undefined when it cannot be reused. */ private async reuseLive( live: Agent, request: ApiSessionCreateRequest, composition: ApiSessionComposition, ): Promise { if (!request.reusable(live.id) || !this.isBlank(live.session)) return undefined this.assertPresetApplicable(live.id, request.agentPreset) const current = this.presetForSession(live.session) const presets = this.ctx.get('agentPresets') const target = composition.agentPreset if (presets === undefined || target === undefined || target === current) return live try { await presets.select(live, target) } catch (error: unknown) { if (remoteErrorOf(error)?.code !== 'agent-preset/locked') throw error return undefined } return live } /** Setup that re-checks blankness under the acquired writer, then records a preset change before publication. */ private reuseSetup(composition: ApiSessionComposition): AgentSetup { return async (agentCtx, agent) => { if (!this.isBlank(agent.session)) throw new ApiSessionStartedSinceObserved() const commit = await composition.setup(agentCtx, agent) const target = composition.agentPreset if (target !== undefined && this.presetForSession(agent.session) !== target) { agent.session.append('agent-preset/selected', { agentPreset: target }) } return commit } } /** Whether no turn has started, by the predicate the preset registry's `select` enforces. */ private isBlank(session: Session): boolean { const boundary = this.ctx.agents.turnBoundaryOf(session) return boundary.openTurnStartSeq === null && boundary.lastTurn === 0 } /** Without a roster, reuse cannot apply a named preset. */ private assertPresetApplicable(sessionId: SessionId, requested: string | undefined): void { if (requested === undefined || this.ctx.get('agentPresets') !== undefined) return throw new ApiSessionPresetConflict(sessionId, requested) } private created(agent: Agent): ApiSessionCreated { return { agent, agentPreset: this.presetForSession(agent.session) } } private createMinted(cwd: string, composition: ApiSessionComposition): Promise { return this.createAgent(brandString(`session-${randomUUID()}`), cwd, composition) } private async createAgent(sessionId: SessionId, cwd: string, composition: ApiSessionComposition): Promise { try { await mkdir(cwd, { recursive: true }) } catch (error: unknown) { throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error }) } return (await this.ctx.agents.create({ sessionId, agentOptions: this.agentOptions(), meta: { cwd, ...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }), }, setup: composition.setup, })).agent } private agentOptions(): AgentOptions { const defaults = this.ctx.agentDefaultModel // A Session that records no selection or request follows default changes. return { get provider() { return defaults.currentSelection().provider }, get model() { return defaults.currentSelection().model }, get reasoningEffort() { return defaults.currentSelection().reasoningEffort }, } } /** * Read the current Agent preset from an all-projections observation. * @param observation - exact Session observation carrying its projection snapshot. * @returns the current preset, or undefined when the capability is absent. */ presetForObservation(observation: SessionObservation): string | undefined { if (observation.projections === undefined) { throw new Error('api-session: Agent activation requires a projected Session observation') } return observation.projections.values.agentPreset ?? undefined } }