/** Agent activation, composition, and model-selection policy owned by API Session. */ import { mkdir } from 'node:fs/promises' import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' import type { Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef, } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' import { resolveSessionPreset } from '@deepseek-ai/dsh-agent-presets' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-persistence' import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-typert-registry' import type { SessionError } from './types.ts' /** 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}"`, ) } } /** Explicit-id creation attempted to adopt a Session under another preset. */ export class ApiSessionPresetConflict extends Error { constructor( readonly sessionId: SessionId, readonly requestedPreset: string, readonly existingPreset: string | undefined, ) { super( existingPreset === undefined ? `session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"` : `session "${sessionId}" runs agent preset "${existingPreset}", not "${requestedPreset}"`, ) } } /** Failures produced while resolving one ordinary Session identity to its live Agent. */ export type ApiSessionAgentError = Extract< SessionError, { readonly code: 'session-not-found' | 'agent-busy' | 'internal' } > /** Result of resolving one ordinary Session identity to its live Agent. */ export type ApiSessionAgentResult = | { readonly agent: Agent } | { readonly error: ApiSessionAgentError } type InstalledSelection = ModelSelectionRef & { current: AgentModelSelection } /** * 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 { code: 'agent-busy', message: `session "${sessionId}" is owned by subagent routing`, details: { 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<{ meta: SessionHeader; events: SessionEvent[] }> { const persistence = ctx.get('sessionPersistence') if (persistence === undefined) { throw new Error('session persistence is not configured (load a dsh-session-persistence backend)') } const meta = (await persistence.list(signal)).find(candidate => candidate.id === sessionId) if (meta === undefined || meta.cwd === undefined) { throw new ApiSessionNotFound(`session "${sessionId}" not found`) } const inspected = await persistence.inspect(sessionId, signal) if (inspected.meta.cwd === undefined) { throw new ApiSessionNotFound(`session "${sessionId}" not found`) } return { meta: inspected.meta, events: [...inspected.events] } } /** Owns every operation that may create, resume, or configure a Web Agent. */ export class ApiSessionAgentController { private readonly resumes = new Map>() private readonly creations = new Map>() private readonly selections = new WeakMap() private readonly imageAdmissionChains = new WeakMap>() /** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */ constructor(private readonly ctx: Context) { ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => { const found = await this.resolveAgent(sessionId) if ('error' in found) throw new TypertLookupFailure(found.error) return found.agent }) ctx.typert.lookups.configure('session', async (sessionId: SessionId) => { const found = await this.resolveAgent(sessionId) if ('error' in found) throw new TypertLookupFailure(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 new TypertLookupFailure(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 { 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).finally(() => { this.resumes.delete(sessionId) }) this.resumes.set(sessionId, resume) } try { return { agent: await resume } } catch (error: unknown) { if (error instanceof ApiSessionNotFound) { return { error: { code: 'session-not-found', message: error.message, details: { 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) } } return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {}, }, } } } /** * Resolve one requested identity, creating or resuming it once. * @param sessionId - requested Session identity. * @param cwd - directory the Session must own. * @param checkPersistedIdentity - whether to inspect a cold identity before creation. * @param presetId - optional Agent preset the Session must own. * @returns the matching live ordinary Agent. */ async ensureSession( sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean, presetId?: string, ): Promise { let creation = this.creations.get(sessionId) if (creation === undefined) { creation = this.createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId) .catch((error: unknown) => { const live = this.ctx.agents.get(sessionId) if (live !== undefined) { if (hasApiSessionSubagentOwner(this.ctx, live.session, live)) { throw new ApiSessionSubagentOwnership(sessionId) } return live } const attached = this.ctx.sessions.get(sessionId) if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) { throw new ApiSessionSubagentOwnership(sessionId) } throw error }) .finally(() => { this.creations.delete(sessionId) }) this.creations.set(sessionId, creation) } const agent = await creation if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { throw new ApiSessionSubagentOwnership(sessionId) } this.assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session)) if (agent.session.header.cwd !== cwd) { throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd) } return agent } /** * Install or return the Session-local model selection used by prompt assembly. * @param agent - live Agent that owns the selection. * @returns the installed mutable selection reference. */ selectionFor(agent: Agent): InstalledSelection { const installed = this.selections.get(agent) if (installed !== undefined) return installed let picked: AgentModelSelection | undefined const defaultModel = this.ctx.agentDefaultModel const selection: InstalledSelection = { get current(): AgentModelSelection { if (picked !== undefined) return picked const logged = agent.session.requestHeader()?.config if (logged === undefined) return defaultModel.currentSelection() return { provider: logged.provider, model: logged.model, ...(logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort }), } }, set current(next: AgentModelSelection) { picked = next }, assembled: undefined, } installModelSelection(agent.ctx, selection) this.selections.set(agent, selection) return selection } /** * 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<{ readonly agentPreset?: string readonly setup: AgentSetup }> { const presets = this.ctx.get('agentPresets') if (presets === undefined) return { setup: (agentCtx) => { this.installSelection(agentCtx) } } const resolvedId = (await presets.resolve(presetId)).id return { agentPreset: resolvedId, setup: async (agentCtx) => { this.installSelection(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): Promise { const inspected = await inspectApiSession(this.ctx, sessionId) if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) { throw new ApiSessionSubagentOwnership(sessionId) } const composition = await this.composeAgent(resolveSessionPreset({ header: inspected.meta, events: inspected.events, })) 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 createOrAdopt( sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean, presetId: string | undefined, ): 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) return live const persistence = checkPersistedIdentity ? this.ctx.get('sessionPersistence') : undefined const stored = persistence === undefined ? undefined : (await persistence.list()).find(header => header.id === sessionId) if (persistence !== undefined && stored !== undefined) { const inspected = await persistence.inspect(sessionId) if (hasApiSessionSubagentOwner(this.ctx, { header: inspected.meta }, undefined)) { throw new ApiSessionSubagentOwnership(sessionId) } if (inspected.meta.cwd !== cwd) { throw new ApiSessionCwdConflict(sessionId, cwd, inspected.meta.cwd) } const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events }) this.assertPresetUnchanged(sessionId, presetId, storedPreset) const composition = await this.composeAgent(storedPreset) return (await this.ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: this.agentOptions(), setup: composition.setup, })).agent } try { await mkdir(cwd, { recursive: true }) } catch (error: unknown) { throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error }) } const composition = await this.composeAgent(presetId) 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 { provider, model } = this.ctx.agentDefaultModel.currentSelection() return { provider, model } } private installSelection(agentCtx: Context): void { const agent = agentCtx.agent if (agent === undefined) throw new Error('api-session: Agent setup has no scoped Agent') this.selectionFor(agent) } private assertPresetUnchanged( sessionId: SessionId, requested: string | undefined, existing: string | undefined, ): void { if (requested === undefined || requested === existing) return throw new ApiSessionPresetConflict(sessionId, requested, existing) } }