/** * JSON-RPC method and notification surface for out-of-process harness SDKs. * The surrounding context owns plugins, persistence, and configured adapters. * * @module @deepseek-ai/dsh-jsonrpc/server */ import type { Context } from 'cordis' import { resolve } from 'node:path' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' import { findLastMessageTurnEnd, SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import type SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { InitializeParams, InitializeResult, JsonRpcTransportPeer, SessionEventNotification, SessionFinishedNotification, SessionPromptParams, SessionPromptResult, SubagentFinishedNotification, SubagentStartedNotification, } from '@deepseek-ai/dsh-sdk-protocol' interface SessionRecord { handle: AgentHandle lastTurnEnd: TurnEndReason | undefined activePrompt: boolean } /** Recover the delegating parent from the service-owned scoped carrier. */ function subagentParentOf(carrier: Scoped): Agent { return carrierKeyOf(carrier) as Agent } /** Deployment-specific status mapping for SDK turn and subagent outcomes. */ export interface HarnessSdkServerOptions { /** Report max-token termination as an accepted result instead of an infrastructure error. */ maxTokensAsSuccess?: boolean } function successStatus(reason: string, options: HarnessSdkServerOptions): 'ok' | 'error' { if (reason === 'completed') return 'ok' return reason === 'max-tokens' && options.maxTokensAsSuccess === true ? 'ok' : 'error' } /** * SDK server over one booted harness context and transport peer. Construction * subscribes to session, agent, and subagent lifecycle events until shutdown; * reinitialization is unsupported. */ export class HarnessSdkServer { private cwd = process.cwd() private provider = 'deepseek' private model = 'deepseek' private maxTokens: number | undefined private llmFiber: { dispose(): Promise } | undefined private readonly sessions = new Map() private readonly sessionCreations = new Map>() private readonly disposers: (() => void)[] = [] private shutdownTask: Promise> | undefined private shuttingDown = false constructor( private readonly ctx: Context, private readonly transport: JsonRpcTransportPeer, private readonly options: HarnessSdkServerOptions = {}, ) { const serverOptions = this.options this.disposers.push(ctx.on('session/event', (session, event) => { if (event.type === 'turn/end') { const rec = this.sessions.get(String(session.id)) if (rec && findLastMessageTurnEnd(session.events)?.seq === event.seq) { rec.lastTurnEnd = event.data.reason } } const payload: SessionEventNotification = { sessionId: String(session.id), event } this.transport.notify('session.event', payload) })) this.disposers.push(ctx.on('session/created', (session) => { const parentSession = session.header.parentSession if (parentSession === undefined) return const payload: SubagentStartedNotification = { parentSessionId: String(parentSession), childSessionId: String(session.id), } this.transport.notify('subagent.started', payload) })) this.disposers.push(ctx.on('subagent/end', function (this: Scoped, info: SubagentRunEndInfo) { const parent = subagentParentOf(this) // This protocol reports only in-process child sessions. The service // snapshots the provider's exact run provenance through child disposal; // matching ids or parent lineage alone never establishes locality. if (!info.local) return const payload: SubagentFinishedNotification = { provider: info.provider, agentId: String(info.id), parentSessionId: String(parent.session.id), childSessionId: String(info.id), status: successStatus(info.stopReason, serverOptions), stopReason: info.stopReason, ...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }), } transport.notify('subagent.finished', payload) })) } /** * Configure the SDK route, mounting the DeepSeek fallback only when unowned. * @param params - SDK handshake parameters. * @returns server identity for the handshake. */ async initialize(params: InitializeParams): Promise { if (params.maxTokens !== undefined && (!Number.isSafeInteger(params.maxTokens) || params.maxTokens <= 0)) { throw new TypeError('initialize maxTokens must be a positive safe integer') } this.cwd = resolve(params.cwd) this.provider = params.provider this.model = params.model this.maxTokens = params.maxTokens if (!this.hasAdapterFor(this.provider)) { if (this.provider !== 'deepseek') throw new Error(`no adapter registered for provider "${this.provider}"`) this.llmFiber = await this.ctx.plugin(LlmDeepSeek, {}) } return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } } } /** * Run one prompt to settlement; overlap on the same session fails. * @param params - target session and user content. * @returns acceptance after the turn settled. */ async prompt(params: SessionPromptParams): Promise { const rec = await this.getOrCreateSession(params.sessionId) if (rec.activePrompt) throw new Error(`session already has an active prompt: ${params.sessionId}`) // An agent-loop-only reload disposes the loop's agents while this record // survives; a retained agent accepts followup() silently, so validate the // record against the live registry before delivery (as the ACP bridge does). if (this.ctx.agents.get(rec.handle.agent.id) !== rec.handle.agent) { throw new Error(`session agent was disposed outside the server: ${params.sessionId}`) } rec.activePrompt = true try { rec.lastTurnEnd = undefined rec.handle.agent.followup(createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } })) await rec.handle.agent.whenIdle() const payload: SessionFinishedNotification = { sessionId: params.sessionId, status: this.finishedStatus(rec.lastTurnEnd), reason: rec.lastTurnEnd, } this.transport.notify('session.finished', payload) return { accepted: true } } finally { rec.activePrompt = false } } /** * Dispose server-owned agents, adapter, and subscriptions to quiescence. * The surrounding context remains running. * @returns empty JSON-RPC result. */ shutdown(): Promise> { this.shutdownTask ??= this.performShutdown() return this.shutdownTask } private async performShutdown(): Promise> { this.shuttingDown = true const pendingCreations = [...this.sessionCreations.values()] await Promise.allSettled(pendingCreations) this.sessionCreations.clear() const records = [...this.sessions.values()] this.sessions.clear() const failures: unknown[] = [] while (this.disposers.length > 0) { try { this.disposers.pop()?.() } catch (error) { failures.push(error) } } const teardownResults = await Promise.allSettled([ ...records.map(rec => Promise.resolve().then(() => rec.handle.dispose())), ...(this.llmFiber === undefined ? [] : [Promise.resolve().then(() => this.llmFiber?.dispose())]), ]) this.llmFiber = undefined failures.push(...teardownResults .filter((result): result is PromiseRejectedResult => result.status === 'rejected') .map(result => result.reason as unknown)) if (failures.length === 1) throw failures[0] if (failures.length > 1) throw new AggregateError(failures, 'SDK server teardown failed') return {} } /** * Dispatch one incoming JSON-RPC request to its typed handler. Throws (→ a * JSON-RPC error response) on an unknown method. * @param method - the JSON-RPC method name. * @param params - the raw params object from the wire. * @returns the handler's result, to be serialized as the response. */ async handleRequest(method: string, params: Record | undefined): Promise { switch (method) { case 'initialize': return this.initialize(params as unknown as InitializeParams) case 'session/prompt': return this.prompt(params as unknown as SessionPromptParams) case 'shutdown': return this.shutdown() default: throw new Error(`unknown DeepSeek Harness SDK runtime method: ${method}`) } } private async getOrCreateSession(sessionId: string): Promise { if (this.shuttingDown) throw new Error('SDK server is shutting down') const existing = this.sessions.get(sessionId) if (existing) return existing const pending = this.sessionCreations.get(sessionId) if (pending) return pending const creation = this.createSession(sessionId) this.sessionCreations.set(sessionId, creation) void creation.then( () => { this.sessionCreations.delete(sessionId) }, () => { this.sessionCreations.delete(sessionId) }, ) return creation } private async createSession(sessionId: string): Promise { const handle = await this.ctx.agents.create({ sessionId: SessionId(sessionId), meta: { cwd: this.cwd }, agentOptions: { provider: this.provider, model: this.model, ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens }, }, }) const rec: SessionRecord = { handle, lastTurnEnd: undefined, activePrompt: false } this.sessions.set(sessionId, rec) return rec } private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' { if (!reason) return 'error' return successStatus(reason.kind, this.options) } private hasAdapterFor(provider: string): boolean { return this.ctx.get('llm')?.listProviders().some(entry => entry.id === provider) ?? false } }