server.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. /**
  2. * JSON-RPC methods and notifications for out-of-process harness SDKs.
  3. * The surrounding context owns plugins, persistence, and configured adapters.
  4. *
  5. * @module @deepseek-ai/dsh-jsonrpc/server
  6. */
  7. import type { Context } from '@deepseek-ai/cordis'
  8. import { resolve } from 'node:path'
  9. import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
  10. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  11. import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
  12. import { SessionId } from '@deepseek-ai/dsh-session'
  13. import type SubagentService from '@deepseek-ai/dsh-subagent'
  14. import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
  15. import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
  16. import type {
  17. InitializeParams,
  18. InitializeResult,
  19. JsonRpcTransportPeer,
  20. SessionEventNotification,
  21. SessionPromptParams,
  22. SessionPromptResult,
  23. SubagentFinishedNotification,
  24. SubagentStartedNotification,
  25. } from '@deepseek-ai/dsh-sdk-protocol'
  26. interface SessionRecord {
  27. handle: AgentHandle
  28. }
  29. /** Recover the delegating parent from the service-owned scoped carrier. */
  30. function subagentParentOf(carrier: Scoped<SubagentService>): Agent {
  31. return carrierKeyOf(carrier) as Agent
  32. }
  33. /** Deployment-specific status mapping for SDK turn and subagent outcomes. */
  34. export interface HarnessSdkServerOptions {
  35. /** Report max-token termination as an accepted result instead of an infrastructure error. */
  36. maxTokensAsSuccess?: boolean
  37. }
  38. function successStatus(reason: string, options: HarnessSdkServerOptions): 'ok' | 'error' {
  39. if (reason === 'completed') return 'ok'
  40. return reason === 'max-tokens' && options.maxTokensAsSuccess === true ? 'ok' : 'error'
  41. }
  42. /**
  43. * SDK server over one booted harness context and transport peer. Construction
  44. * subscribes to session, agent, and subagent lifecycle events until shutdown;
  45. * reinitialization is unsupported.
  46. */
  47. export class HarnessSdkServer {
  48. private cwd = process.cwd()
  49. private provider = 'deepseek-official'
  50. private model = 'deepseek-official'
  51. private maxTokens: number | undefined
  52. private llmFiber: { dispose(): Promise<void> } | undefined
  53. private readonly sessions = new Map<string, SessionRecord>()
  54. private readonly sessionCreations = new Map<string, Promise<SessionRecord>>()
  55. private readonly disposers: (() => void)[] = []
  56. private shutdownTask: Promise<Record<string, never>> | undefined
  57. private shuttingDown = false
  58. constructor(
  59. private readonly ctx: Context,
  60. private readonly transport: JsonRpcTransportPeer,
  61. private readonly options: HarnessSdkServerOptions = {},
  62. ) {
  63. const serverOptions = this.options
  64. this.disposers.push(ctx.on('session/event', (session, event) => {
  65. const payload: SessionEventNotification = { sessionId: String(session.id), event }
  66. this.transport.notify('session.event', payload)
  67. }))
  68. this.disposers.push(ctx.on('agent/status', ({ agent, status }) => {
  69. this.transport.notify('session.status', { sessionId: String(agent.session.id), status })
  70. }))
  71. this.disposers.push(ctx.on('session/created', (session) => {
  72. const parentSession = session.header.parentSession
  73. if (parentSession === undefined) return
  74. const payload: SubagentStartedNotification = {
  75. parentSessionId: String(parentSession),
  76. childSessionId: String(session.id),
  77. }
  78. this.transport.notify('subagent.started', payload)
  79. }))
  80. this.disposers.push(ctx.on('subagent/end', function (this: Scoped<SubagentService>, info: SubagentRunEndInfo) {
  81. const parent = subagentParentOf(this)
  82. // This protocol reports only in-process child sessions. The service
  83. // snapshots the provider name and local flag through child disposal;
  84. // matching ids or parent lineage alone never establishes locality.
  85. if (!info.local) return
  86. const payload: SubagentFinishedNotification = {
  87. provider: info.provider,
  88. agentId: String(info.id),
  89. parentSessionId: String(parent.session.id),
  90. childSessionId: String(info.id),
  91. status: successStatus(info.stopReason, serverOptions),
  92. stopReason: info.stopReason,
  93. ...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }),
  94. }
  95. transport.notify('subagent.finished', payload)
  96. }))
  97. }
  98. /**
  99. * Configure the SDK route, mounting the DeepSeek fallback only when unowned.
  100. * @param params - SDK handshake parameters.
  101. * @returns server identity for the handshake.
  102. */
  103. async initialize(params: InitializeParams): Promise<InitializeResult> {
  104. if (params.maxTokens !== undefined
  105. && (!Number.isSafeInteger(params.maxTokens) || params.maxTokens <= 0)) {
  106. throw new TypeError('initialize maxTokens must be a positive safe integer')
  107. }
  108. this.cwd = resolve(params.cwd)
  109. this.provider = params.provider
  110. this.model = params.model
  111. this.maxTokens = params.maxTokens
  112. if (!this.hasAdapterFor(this.provider)) {
  113. if (this.provider !== 'deepseek-official') throw new Error(`no adapter registered for provider "${this.provider}"`)
  114. this.llmFiber = await this.ctx.plugin(LlmDeepSeek, {})
  115. }
  116. return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }
  117. }
  118. /**
  119. * Queue one identified prompt without assigning later activity to it.
  120. * @param params - target session and user content.
  121. * @returns the durable message identity.
  122. */
  123. async prompt(params: SessionPromptParams): Promise<SessionPromptResult> {
  124. const rec = await this.getOrCreateSession(params.sessionId)
  125. // An agent-loop-only reload disposes the loop's agents while this record
  126. // survives; a retained agent accepts followup() silently, so validate the
  127. // record against the live registry before delivery (as the ACP bridge does).
  128. if (this.ctx.agents.get(rec.handle.agent.id) !== rec.handle.agent) {
  129. throw new Error(`session agent was disposed outside the server: ${params.sessionId}`)
  130. }
  131. const message = createUserMessage({ content: params.contentBlocks, source: { kind: 'user' } })
  132. rec.handle.agent.followup(message)
  133. return { messageId: message.id }
  134. }
  135. /**
  136. * Dispose server-owned agents, adapter, and subscriptions to quiescence.
  137. * The surrounding context remains running.
  138. * @returns empty JSON-RPC result.
  139. */
  140. shutdown(): Promise<Record<string, never>> {
  141. this.shutdownTask ??= this.performShutdown()
  142. return this.shutdownTask
  143. }
  144. private async performShutdown(): Promise<Record<string, never>> {
  145. this.shuttingDown = true
  146. const pendingCreations = [...this.sessionCreations.values()]
  147. await Promise.allSettled(pendingCreations)
  148. this.sessionCreations.clear()
  149. const records = [...this.sessions.values()]
  150. this.sessions.clear()
  151. const failures: unknown[] = []
  152. while (this.disposers.length > 0) {
  153. try {
  154. this.disposers.pop()?.()
  155. } catch (error) {
  156. failures.push(error)
  157. }
  158. }
  159. const teardownResults = await Promise.allSettled([
  160. ...records.map(rec => Promise.resolve().then(() => rec.handle.dispose())),
  161. ...(this.llmFiber === undefined ? [] : [Promise.resolve().then(() => this.llmFiber?.dispose())]),
  162. ])
  163. this.llmFiber = undefined
  164. failures.push(...teardownResults
  165. .filter((result): result is PromiseRejectedResult => result.status === 'rejected')
  166. .map(result => result.reason as unknown))
  167. if (failures.length === 1) throw failures[0]
  168. if (failures.length > 1) throw new AggregateError(failures, 'SDK server teardown failed')
  169. return {}
  170. }
  171. /**
  172. * Dispatch one incoming JSON-RPC request to its typed handler. Throws (→ a
  173. * JSON-RPC error response) on an unknown method.
  174. * @param method - the JSON-RPC method name.
  175. * @param params - the raw params object from the wire.
  176. * @returns the handler's result, to be serialized as the response.
  177. */
  178. async handleRequest(method: string, params: Record<string, unknown> | undefined): Promise<unknown> {
  179. switch (method) {
  180. case 'initialize':
  181. return this.initialize(params as unknown as InitializeParams)
  182. case 'session/prompt':
  183. return this.prompt(params as unknown as SessionPromptParams)
  184. case 'shutdown':
  185. return this.shutdown()
  186. default:
  187. throw new Error(`unknown DeepSeek Harness SDK runtime method: ${method}`)
  188. }
  189. }
  190. private async getOrCreateSession(sessionId: string): Promise<SessionRecord> {
  191. if (this.shuttingDown) throw new Error('SDK server is shutting down')
  192. const existing = this.sessions.get(sessionId)
  193. if (existing) return existing
  194. const pending = this.sessionCreations.get(sessionId)
  195. if (pending) return pending
  196. const creation = this.createSession(sessionId)
  197. this.sessionCreations.set(sessionId, creation)
  198. void creation.then(
  199. () => { this.sessionCreations.delete(sessionId) },
  200. () => { this.sessionCreations.delete(sessionId) },
  201. )
  202. return creation
  203. }
  204. private async createSession(sessionId: string): Promise<SessionRecord> {
  205. // No preset composition: this server's compositions keep the model-facing
  206. // rows in the host plane, so this agent reads them from the global layer. A
  207. // deployment that configures a roster has to join one here first
  208. // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent").
  209. const handle = await this.ctx.agents.create({
  210. sessionId: SessionId(sessionId),
  211. meta: { cwd: this.cwd },
  212. agentOptions: {
  213. provider: this.provider,
  214. model: this.model,
  215. ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
  216. },
  217. })
  218. const rec: SessionRecord = { handle }
  219. this.sessions.set(sessionId, rec)
  220. return rec
  221. }
  222. private hasAdapterFor(provider: string): boolean {
  223. return this.ctx.get('llm')?.listProviders().some(entry => entry.id === provider) ?? false
  224. }
  225. }