index.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
  1. /**
  2. * SDK-facing JSON-RPC plugin over stdio. The selected dsh profile decides
  3. * whether to load it; see the single-launch Agent Note and package README.
  4. * Stdout is reserved for protocol frames, so the tree must not load a stdout logger.
  5. * This plugin answers `shutdown`, disposes the complete root runtime, and exits 0; the app bin
  6. * owns EOF and signal exits. Keep named plugin exports with no default export so
  7. * Loader `unwrapExports` preserves `name`, `inject`, `Config`, and `apply`.
  8. *
  9. * @module @deepseek-ai/dsh-sdk-jsonrpc-server
  10. */
  11. import type { Context } from '@deepseek-ai/cordis'
  12. import type { Readable, Writable } from 'node:stream'
  13. import Schema from '@deepseek-ai/schemastery'
  14. import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol'
  15. import { HarnessSdkJsonRpcServer } from './server.ts'
  16. export * from './server.ts'
  17. export const name = 'sdk-jsonrpc-server'
  18. // Only the agent factory is required; initialize reads the optional LLM seam with ctx.get().
  19. export const inject = ['agents']
  20. /** JSON-RPC deployment config plus runtime-only test hooks. */
  21. export interface JsonRpcConfig {
  22. /** Report max-token turn/subagent termination as a successful SDK result. */
  23. maxTokensAsSuccess?: boolean
  24. /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */
  25. toolFilter?: {
  26. /** Global tool names that remain visible. */
  27. allow?: string[]
  28. /** Global tool names removed from visibility. */
  29. deny?: string[]
  30. }
  31. /** Transport input override; production uses `process.stdin`. */
  32. input?: Readable
  33. /** Transport output override; production uses `process.stdout`. */
  34. output?: Writable
  35. /** Process-exit override; production uses `process.exit`. */
  36. exit?: (code: number) => void
  37. }
  38. export const Config: Schema<JsonRpcConfig> = Schema.object({
  39. maxTokensAsSuccess: Schema.boolean().default(false),
  40. // Preserve omission; Schemastery's materialized empty object is not a valid restriction.
  41. toolFilter: Schema.object({
  42. allow: Schema.array(Schema.string()).default(undefined as unknown as string[]),
  43. deny: Schema.array(Schema.string()).default(undefined as unknown as string[]),
  44. }).default(undefined as unknown as { allow: string[]; deny: string[] }),
  45. })
  46. /**
  47. * Serve SDK requests over the configured streams. Effect disposal shuts down
  48. * SDK-created agents and closes the transport. A `shutdown` response is flushed
  49. * before the root runtime is disposed and the process exits 0; the app bin
  50. * owns root-context disposal for EOF and signals.
  51. */
  52. export function apply(ctx: Context, config: JsonRpcConfig): void {
  53. // Cordis applies the schema default before invoking the plugin.
  54. const resolvedConfig = config as JsonRpcConfig & { maxTokensAsSuccess: boolean }
  55. // Protocol shutdown owns the complete runtime process, so it must await the
  56. // root lifecycle (including persistence) before exiting.
  57. const rootFiber = ctx.root.fiber
  58. /* v8 ignore next -- production stdio wiring; tests always inject the runtime hooks */
  59. const input = config.input ?? process.stdin
  60. /* v8 ignore next -- production stdio wiring; tests always inject the runtime hooks */
  61. const output = config.output ?? process.stdout
  62. /* v8 ignore next -- production exit wiring; tests always inject the runtime hooks */
  63. const exit = config.exit ?? ((code: number): void => { process.exit(code) })
  64. const transport = new JsonRpcLineTransport(input, output)
  65. const server = new HarnessSdkJsonRpcServer(ctx, transport, {
  66. maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess,
  67. ...resolvedConfig.toolFilter === undefined ? {} : { toolFilter: resolvedConfig.toolFilter },
  68. })
  69. // Share one exit task so racing shutdown requests cannot dispose the root or
  70. // exit the process more than once.
  71. let exitTask: Promise<void> | undefined
  72. const disposeAndExit = (): Promise<void> => {
  73. exitTask ??= (async () => {
  74. await Promise.allSettled([Promise.resolve().then(() => transport.flush())])
  75. await Promise.allSettled([Promise.resolve().then(() => rootFiber.dispose())])
  76. exit(0)
  77. })()
  78. return exitTask
  79. }
  80. transport.onRequest(async (method, params) => {
  81. // `initialize` is the SDK's readiness boundary. This plugin can activate
  82. // before async sibling Loader entries (for example an MCP client's initial
  83. // tool discovery), so do not advertise a ready runtime until the complete
  84. // current tree has settled. Loader settlement joins entry imports, fiber
  85. // lifecycle work, and synchronous effect registration; no scheduler delay
  86. // is part of readiness. A hand-built context without Loader remains
  87. // immediately usable.
  88. if (method === 'initialize') {
  89. await ctx.get('loader')?.await()
  90. }
  91. const result = await server.handleRequest(method, params)
  92. if (method === 'shutdown') {
  93. // Run after the handler result is written; the task then flushes, disposes, and exits.
  94. setImmediate(() => { void disposeAndExit() })
  95. }
  96. return result
  97. })
  98. ctx.effect(() => {
  99. transport.start()
  100. return async () => {
  101. await server.shutdown()
  102. transport.close()
  103. }
  104. }, 'jsonrpc.serve')
  105. }