143 lines
6.3 KiB
TypeScript
143 lines
6.3 KiB
TypeScript
/**
|
|
* The SDK-facing stdio JSON-RPC server plugin: mounting it wires a
|
|
* {@link JsonRpcLineTransport} over the process stdio and serves
|
|
* {@link HarnessSdkServer} (`initialize` → `session/prompt`* → `shutdown`,
|
|
* plus the `session.*`/`subagent.*` notifications) to an out-of-process SDK
|
|
* client (e.g. the Python `deepseek_harness` package). The structured
|
|
* SDK-client analogue of the `acp` bridge: a client-driver plugin over
|
|
* `ctx.agents`, not a loop change and not a capability seam. Which process
|
|
* actually serves this protocol is a `cordis.yml` decision — the tree that
|
|
* loads this plugin IS the SDK server (the `dsh-jsonrpc-agent` bin boots such
|
|
* a tree for the single-exe distribution; see
|
|
* docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
|
|
*
|
|
* stdout is the protocol: this plugin must run in a tree that loads NO stdout
|
|
* logger (the console logger writes to stdout and would corrupt the JSON-RPC
|
|
* frames). The guarantee is config-only — see the package README.
|
|
*
|
|
* Exit-lifecycle split: this plugin owns the PROTOCOL-level exit (the
|
|
* `shutdown` request answers first, then the plugin disposes its own fiber and
|
|
* exits 0 — see {@link apply}); process-level exits (stdin EOF, SIGTERM,
|
|
* SIGINT) belong to the app bin (`dsh-jsonrpc-agent`), which disposes the
|
|
* whole root context.
|
|
*
|
|
* Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default
|
|
* export — the cordis Loader's `unwrapExports` does `exports.default ??
|
|
* exports`, so a stray default would collapse the module to the bare `apply`
|
|
* and silently drop `inject`/`name`/`Config` (see docs/postmortem/0001).
|
|
*
|
|
* @module @deepseek-ai/dsh-jsonrpc
|
|
*/
|
|
|
|
import type { Context } from 'cordis'
|
|
import type { Readable, Writable } from 'node:stream'
|
|
import Schema from 'schemastery'
|
|
import { HarnessSdkServer } from './server.ts'
|
|
import { JsonRpcLineTransport } from './transport.ts'
|
|
|
|
export * from './server.ts'
|
|
export * from './transport.ts'
|
|
|
|
export const name = 'jsonrpc'
|
|
// The server programs against the agent factory only: `agents` is read on
|
|
// every `session/prompt` (get-or-create) and on `subagent/end` demux. The LLM
|
|
// seam is deliberately NOT injected — `initialize` reads it opportunistically
|
|
// via `ctx.get('llm')` (the topology-independent lookup for a non-injected
|
|
// service, per packages/AGENTS.md) to decide whether to lazily mount the
|
|
// DeepSeek adapter for the requested model.
|
|
export const inject = ['agents']
|
|
|
|
/**
|
|
* Plugin config. Every field is a runtime-only test seam — none is part of the
|
|
* schemastery {@link Config}, so nothing here is settable from a `cordis.yml`
|
|
* (production always serves the process stdio and exits via `process.exit`).
|
|
*/
|
|
export interface JsonRpcConfig {
|
|
/**
|
|
* Transport input override. Production omits this (the plugin reads
|
|
* `process.stdin`); tests inject an in-memory `Readable` to drive the server
|
|
* without a subprocess.
|
|
*/
|
|
input?: Readable
|
|
/**
|
|
* Transport output override. Production omits this (the plugin writes
|
|
* `process.stdout` — the protocol channel); tests inject an in-memory
|
|
* `Writable` to capture frames.
|
|
*/
|
|
output?: Writable
|
|
/**
|
|
* Process-exit override for the `shutdown` request path. Production omits
|
|
* this (`process.exit`); tests inject a recorder so a driven shutdown does
|
|
* not kill the test process.
|
|
*/
|
|
exit?: (code: number) => void
|
|
}
|
|
|
|
export const Config: Schema<JsonRpcConfig> = Schema.object({})
|
|
|
|
/**
|
|
* Mount the SDK server on the process stdio: build the line transport and
|
|
* {@link HarnessSdkServer}, dispatch incoming requests, and start reading
|
|
* frames. Disposal is an effect: disposing this plugin's fiber runs
|
|
* `server.shutdown()` (disposes every SDK-created agent to quiescence and
|
|
* detaches the event subscriptions) and `transport.close()`.
|
|
*
|
|
* The `shutdown` request's process-exit semantics live HERE, because the
|
|
* plugin owns the server and transport: the request is answered first
|
|
* (`setImmediate` lets the response frame flush), then the plugin disposes its
|
|
* OWN fiber and calls `exit(0)`. Own-fiber disposal is sufficient — the
|
|
* request's `server.shutdown()` already brought every SDK-created agent to
|
|
* quiescence (their session logs are flushed by the awaited agent-handle
|
|
* disposes), the fiber's effect disposer re-runs the idempotent shutdown and
|
|
* closes the transport, and the process exit that follows IS the teardown of
|
|
* the rest of the tree (the bin's EOF/signal handlers own root-context
|
|
* disposal for the process-level exits).
|
|
*/
|
|
export function apply(ctx: Context, config: JsonRpcConfig): void {
|
|
// Capture the fiber handle NOW, during apply(): the shutdown path runs LATER,
|
|
// from the transport's read loop, and must dispose exactly this plugin's
|
|
// fiber (cf. the injection-scope capture note in the acp bridge).
|
|
const fiber = ctx.fiber
|
|
/* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */
|
|
const input = config.input ?? process.stdin
|
|
/* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */
|
|
const output = config.output ?? process.stdout
|
|
/* v8 ignore next -- production exit wiring; tests always inject the runtime seams */
|
|
const exit = config.exit ?? ((code: number): void => { process.exit(code) })
|
|
|
|
const transport = new JsonRpcLineTransport(input, output)
|
|
const server = new HarnessSdkServer(ctx, transport)
|
|
|
|
// The shutdown-request exit path, exactly once (a second `shutdown` frame
|
|
// racing the dispose must not re-enter). `exit(0)` runs even if the dispose
|
|
// throws — the client was already answered, so exiting is the honest outcome.
|
|
let exiting = false
|
|
const disposeAndExit = async (): Promise<void> => {
|
|
if (exiting) return
|
|
exiting = true
|
|
try {
|
|
await fiber.dispose()
|
|
} finally {
|
|
exit(0)
|
|
}
|
|
}
|
|
|
|
transport.onRequest(async (method, params) => {
|
|
const result = await server.handleRequest(method, params)
|
|
if (method === 'shutdown') {
|
|
// Answer the request first (setImmediate lets the response frame
|
|
// flush), then dispose this plugin's fiber and exit 0 (see apply's doc).
|
|
setImmediate(() => { void disposeAndExit() })
|
|
}
|
|
return result
|
|
})
|
|
|
|
ctx.effect(() => {
|
|
transport.start()
|
|
return async () => {
|
|
await server.shutdown()
|
|
transport.close()
|
|
}
|
|
}, 'jsonrpc.serve')
|
|
}
|