refactor(packages): dissolve ui/ and rename sdk/ to scaffold/
git mv per the regrouping RFC: the five human-collaboration seams and tui join packages/interaction/, app-boot becomes packages/boot/, and jsonrpc joins the renamed scaffold/ (formerly sdk/) as its server half beside client/protocol/create-sdk/helper/scripts/telemetry, whose folders drop the legacy sdk- prefix. Three new group README triplets replace the ui/ and sdk/ ones; tsconfig references/paths/globs, knip keys, vitest globs, gate scripts, catalogs, docs, and the lockfile follow. Adds the four settled FIXME rename markers (dsh-sdk-server, dsh-sdk-telemetry, dsh-sdk-helper, dsh-sdk-scripts). The scaffold folders diverge from their npm names until those renames land, so tsconfig.base.json maps the three affected names explicitly beside the group wildcard. Also repairs two pre-existing stale-path classes the strengthened sweep surfaced: docs/web-styling.md's retired web-ui host package and type-model spec fixture-literal joins. app-boot's three Loader-composition specs time out at the default 5s under full-suite parallel load on this filesystem (pre-existing; pass isolated with --testTimeout=30000); interaction/scaffold/boot suites otherwise green (687 passed).
This commit is contained in:
6
packages/scaffold/protocol/README.i18n.yaml
Normal file
6
packages/scaffold/protocol/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/scaffold/protocol/README.md
|
||||
README.md: dc42f385f6e7fd6327ca79887d3cf22d7c34ccdf
|
||||
README.zh.md: a292120be8b5b09aaadb05f9dbc272a51bd74831
|
||||
39
packages/scaffold/protocol/README.md
Normal file
39
packages/scaffold/protocol/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @deepseek-ai/dsh-sdk-protocol
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delimited JSON-RPC 2.0 transport class plus the named request, result, and notification types both wire ends speak. The package root enumerates the protocol consumer interface; source modules are not exported as deep imports. The server side is the [`dsh-jsonrpc`](../../scaffold/server/README.md) plugin; clients are [`dsh-sdk-client`](../client/README.md) (TypeScript) and the [Python SDK](../../../python/README.md) (which mirrors these shapes but does not import them). A pure library — no plugin, no Config, no registration.
|
||||
|
||||
## Transport
|
||||
|
||||
`JsonRpcLineTransport` frames JSON-RPC 2.0 over caller-owned byte streams, one compact JSON frame per `\n`-terminated line. Frames with `id` and `method` are requests, `id` alone is a response, `method` alone is a notification; malformed JSON lines are ignored. `start()` attaches stream listeners, `close()` detaches them and rejects pending requests without destroying the streams. Missing request handlers answer `-32601`; handler rejections answer `-32603` with the error message. An error response rejects the pending `request()` with `JsonRpcResponseError`, which preserves the wire `code` and optional `data`. `JsonRpcTransportPeer` is the outbound surface (request/notify) the server class is typed against.
|
||||
|
||||
## Wire types
|
||||
|
||||
`types.ts` names every payload of the protocol served by `HarnessSdkServer`:
|
||||
|
||||
| Direction | Method | Types |
|
||||
|---|---|---|
|
||||
| client→server | `initialize` | `InitializeParams` → `InitializeResult` |
|
||||
| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult` (durable enqueue receipt) |
|
||||
| client→server | `shutdown` | no params → `{}` |
|
||||
| server→client | `session.event` | `SessionEventNotification` (every session in the runtime, unfiltered) |
|
||||
| server→client | `session.status` | `SessionStatusNotification` (whole-agent `running`/`idle` transition) |
|
||||
| server→client | `subagent.started` | `SubagentStartedNotification` |
|
||||
| server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) |
|
||||
|
||||
`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this package defines the client-facing wire protocol; the model-visible surfaces belong to the runtime plugins composed behind the serving [`dsh-jsonrpc`](../../scaffold/server/README.md) entry.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No protocol-version negotiation** — the handshake carries only `serverInfo.version` (`0.0.1`, unvalidated by clients); pre-release stance, no compatibility promise.
|
||||
- **No cancel or session-close methods** — a client abandons a turn by closing the runtime process; see the [`dsh-jsonrpc` README](../../scaffold/server/README.md).
|
||||
- **Server→client requests are dead capability** — the transport supports them, but the server never sends one; the Python SDK's responder surface exists for future approval flows.
|
||||
39
packages/scaffold/protocol/README.zh.md
Normal file
39
packages/scaffold/protocol/README.zh.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @deepseek-ai/dsh-sdk-protocol
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按换行分帧的 JSON-RPC 2.0 传输类,加上协议两端共同使用的具名请求、结果与通知类型。包根枚举协议消费方接口;源模块不支持深层导入。服务端是 [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) 插件;客户端是 [`dsh-sdk-client`](../sdk-client/README.md)(TypeScript)与 [Python SDK](../../../python/README.md)(后者复现这些结构但不导入它们)。纯库——无插件、无 Config、无注册。
|
||||
|
||||
## 传输
|
||||
|
||||
`JsonRpcLineTransport` 在调用方持有的字节流上为 JSON-RPC 2.0 分帧,每行一个紧凑 JSON 帧、以 `\n` 结尾。带 `id` 与 `method` 的帧是请求,仅 `id` 是响应,仅 `method` 是通知;非法 JSON 行被忽略。`start()` 挂接流监听器,`close()` 移除监听器并拒绝挂起请求,但不销毁流。缺失请求处理器时应答 `-32601`;处理器返回的 Promise 被拒绝时,则应答携带错误消息的 `-32603`。错误响应会以 `JsonRpcResponseError` 拒绝挂起的 `request()` Promise,并保留协议格式中的 `code` 与可选 `data`。`JsonRpcTransportPeer` 是服务器类据以进行类型声明的出站接口(request/notify)。
|
||||
|
||||
## 协议类型
|
||||
|
||||
`types.ts` 为 `HarnessSdkServer` 所服务协议的每个载荷命名:
|
||||
|
||||
| 方向 | 方法 | 类型 |
|
||||
|---|---|---|
|
||||
| client→server | `initialize` | `InitializeParams` → `InitializeResult` |
|
||||
| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult`(持久入队回执) |
|
||||
| client→server | `shutdown` | 无参数 → `{}` |
|
||||
| server→client | `session.event` | `SessionEventNotification`(运行时内每个会话,不过滤) |
|
||||
| server→client | `session.status` | `SessionStatusNotification`(整个 agent(智能体)的 `running`/`idle` 转换) |
|
||||
| server→client | `subagent.started` | `SubagentStartedNotification` |
|
||||
| server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) |
|
||||
|
||||
`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status`。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式契约的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为此包定义面向客户端的协议格式;模型可见接口属于组合在对外服务入口 [`dsh-jsonrpc`](../../ui/jsonrpc/README.md) 后方的运行时插件。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;此包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **无协议版本协商**——握手只携带 `serverInfo.version`(`0.0.1`,客户端不校验);处于预发布阶段,无兼容承诺。
|
||||
- **无取消与会话关闭方法**——客户端放弃轮次的方式是关闭运行时进程;见 [`dsh-jsonrpc` README](../../ui/jsonrpc/README.md)。
|
||||
- **server→client 请求是未使用的功能**——传输层支持,但服务器从不发送;Python SDK 的应答接口为未来审批流程预留。
|
||||
40
packages/scaffold/protocol/package.json
Normal file
40
packages/scaffold/protocol/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-sdk-protocol",
|
||||
"description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-subagent": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
25
packages/scaffold/protocol/src/index.ts
Normal file
25
packages/scaffold/protocol/src/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Shared wire protocol for the DeepSeek Harness SDK runtime: the
|
||||
* newline-delimited JSON-RPC stdio transport plus the named request, result,
|
||||
* and notification types both wire ends speak. The runtime server plugin
|
||||
* (`@deepseek-ai/dsh-jsonrpc`) serves this protocol; SDK clients
|
||||
* (`@deepseek-ai/dsh-sdk-client`, the Python SDK) drive it.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sdk-protocol
|
||||
*/
|
||||
|
||||
export { JsonRpcLineTransport, JsonRpcResponseError } from './transport.ts'
|
||||
export type { JsonRpcTransportPeer } from './transport.ts'
|
||||
export type {
|
||||
HarnessSdkNotificationMap,
|
||||
HarnessSdkRequestMap,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
SdkRunStatus,
|
||||
SessionEventNotification,
|
||||
SessionStatusNotification,
|
||||
SessionPromptParams,
|
||||
SessionPromptResult,
|
||||
SubagentFinishedNotification,
|
||||
SubagentStartedNotification,
|
||||
} from './types.ts'
|
||||
31
packages/scaffold/protocol/src/invariant.ts
Normal file
31
packages/scaffold/protocol/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-sdk-protocol`.
|
||||
* @module @deepseek-ai/dsh-sdk-protocol/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-protocol'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'sdk-protocol-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a pure wire library (transport class + type
|
||||
* declarations) with no event stream or mutable data relation of its own;
|
||||
* both wire ends own their protocol behavior.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
279
packages/scaffold/protocol/src/transport.ts
Normal file
279
packages/scaffold/protocol/src/transport.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Newline-delimited JSON-RPC 2.0 over byte streams. Frames with `id` and
|
||||
* `method` are requests, `id` alone is a response, and `method` alone is a
|
||||
* notification. Malformed lines are ignored; handler failures become error frames.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sdk-protocol/transport
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import { StringDecoder } from 'node:string_decoder'
|
||||
|
||||
type JsonRpcId = string | number
|
||||
type RequestHandler = (method: string, params: Record<string, unknown>) => Promise<unknown>
|
||||
type NotificationHandler = (method: string, params: Record<string, unknown>) => void
|
||||
|
||||
/** A JSON-RPC error response, preserving the wire `code` and optional `data`. */
|
||||
export class JsonRpcResponseError extends Error {
|
||||
/**
|
||||
* @param code - the wire error code, or `undefined` when the peer sent none.
|
||||
* @param message - the wire error message.
|
||||
* @param data - the optional structured error payload, verbatim.
|
||||
*/
|
||||
constructor(readonly code: number | undefined, message: string, readonly data?: unknown) {
|
||||
super(message)
|
||||
this.name = 'JsonRpcResponseError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbound request and notification surface used by the runtime server and
|
||||
* SDK clients.
|
||||
*/
|
||||
export interface JsonRpcTransportPeer {
|
||||
/**
|
||||
* Send a request and await its response.
|
||||
* @param method - the JSON-RPC method name.
|
||||
* @param params - the request parameters object.
|
||||
* @returns the result; rejects with {@link JsonRpcResponseError} on an error
|
||||
* response, and with a plain `Error` on a write failure or closure.
|
||||
*/
|
||||
request(method: string, params: object): Promise<unknown>
|
||||
/**
|
||||
* Send a notification; omitted params produce no `params` member.
|
||||
* @param method - the JSON-RPC method name.
|
||||
* @param params - the optional notification parameters object.
|
||||
*/
|
||||
notify(method: string, params?: object): void
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Line-delimited endpoint over caller-owned streams. {@link start} attaches
|
||||
* listeners; {@link close} detaches them and rejects pending requests without
|
||||
* destroying the streams. Missing request handlers return `-32601`; handler
|
||||
* failures return `-32603`. Notifications without a handler are dropped.
|
||||
*/
|
||||
export class JsonRpcLineTransport implements JsonRpcTransportPeer {
|
||||
private buffer = ''
|
||||
private readonly decoder = new StringDecoder('utf8')
|
||||
private started = false
|
||||
private requestHandler: RequestHandler | undefined
|
||||
private notificationHandler: NotificationHandler | undefined
|
||||
private readonly pending = new Map<JsonRpcId, PendingRequest>()
|
||||
|
||||
constructor(
|
||||
private readonly input: Readable,
|
||||
private readonly output: Writable,
|
||||
) {}
|
||||
|
||||
/** Attach the input listeners and begin reading frames. Idempotent. */
|
||||
start(): void {
|
||||
if (this.started) return
|
||||
this.started = true
|
||||
this.input.on('data', this.onData)
|
||||
this.input.on('error', this.onInputError)
|
||||
this.input.on('end', this.onInputEnd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach listeners and reject pending requests. Safe before {@link start}.
|
||||
*/
|
||||
close(): void {
|
||||
this.input.off('data', this.onData)
|
||||
this.input.off('error', this.onInputError)
|
||||
this.input.off('end', this.onInputEnd)
|
||||
this.failPending(new Error('JSON-RPC transport closed'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the request handler, replacing any prior handler.
|
||||
* @param handler - resolves to the response `result`; a rejection becomes a
|
||||
* `-32603` error response carrying the message.
|
||||
*/
|
||||
onRequest(handler: RequestHandler): void {
|
||||
this.requestHandler = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the notification handler, replacing any prior handler.
|
||||
* @param handler - invoked per notification with the method and normalized
|
||||
* params object.
|
||||
*/
|
||||
onNotification(handler: NotificationHandler): void {
|
||||
this.notificationHandler = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request and await its response.
|
||||
* @param method - the JSON-RPC method name.
|
||||
* @param params - the request parameters object.
|
||||
* @param signal - optional abandonment signal: aborting removes the pending
|
||||
* entry (no state is retained for a response that may never come) and
|
||||
* rejects with the signal's reason.
|
||||
* @returns the result; rejects per {@link JsonRpcTransportPeer.request}.
|
||||
*/
|
||||
request(method: string, params: object, signal?: AbortSignal): Promise<unknown> {
|
||||
const id = `req_${randomUUID().replaceAll('-', '')}`
|
||||
const message = { jsonrpc: '2.0', id, method, params }
|
||||
return new Promise((resolve, reject) => {
|
||||
let detach = (): void => {}
|
||||
if (signal !== undefined) {
|
||||
if (signal.aborted) {
|
||||
reject(abortError(signal.reason))
|
||||
return
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
this.pending.delete(id)
|
||||
reject(abortError(signal.reason))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
detach = () => { signal.removeEventListener('abort', onAbort) }
|
||||
}
|
||||
this.pending.set(id, {
|
||||
resolve: (value) => {
|
||||
detach()
|
||||
resolve(value)
|
||||
},
|
||||
reject: (error) => {
|
||||
detach()
|
||||
reject(error)
|
||||
},
|
||||
})
|
||||
try {
|
||||
this.write(message)
|
||||
} catch (error) {
|
||||
this.pending.delete(id)
|
||||
detach()
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
notify(method: string, params?: object): void {
|
||||
this.write(params === undefined ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', method, params })
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for prior frame write callbacks. The empty barrier emits no bytes.
|
||||
* @returns a promise that settles with the output write callback.
|
||||
*/
|
||||
flush(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.output.write('', (error) => {
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private readonly onData = (chunk: Buffer | string): void => {
|
||||
this.buffer += typeof chunk === 'string' ? chunk : this.decoder.write(chunk)
|
||||
this.drainLines()
|
||||
}
|
||||
|
||||
private drainLines(): void {
|
||||
for (;;) {
|
||||
const newline = this.buffer.indexOf('\n')
|
||||
if (newline < 0) break
|
||||
const line = this.buffer.slice(0, newline).trim()
|
||||
this.buffer = this.buffer.slice(newline + 1)
|
||||
if (!line) continue
|
||||
void this.handleLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
private readonly onInputError = (error: Error): void => {
|
||||
this.failPending(error)
|
||||
}
|
||||
|
||||
private readonly onInputEnd = (): void => {
|
||||
this.buffer += this.decoder.end()
|
||||
this.drainLines()
|
||||
this.failPending(new Error('JSON-RPC input closed'))
|
||||
}
|
||||
|
||||
private async handleLine(line: string): Promise<void> {
|
||||
let message: unknown
|
||||
try {
|
||||
message = JSON.parse(line)
|
||||
} catch {
|
||||
// Only JSON syntax errors reach this catch; malformed peer lines are ignored.
|
||||
return
|
||||
}
|
||||
if (!message || typeof message !== 'object') return
|
||||
const frame = message as Record<string, unknown>
|
||||
const id = frame.id
|
||||
const method = frame.method
|
||||
if ((typeof id === 'string' || typeof id === 'number') && typeof method === 'string') {
|
||||
await this.handleIncomingRequest(id, method, objectParams(frame.params))
|
||||
return
|
||||
}
|
||||
if (typeof id === 'string' || typeof id === 'number') {
|
||||
this.handleIncomingResponse(id, frame)
|
||||
return
|
||||
}
|
||||
if (typeof method === 'string') {
|
||||
this.notificationHandler?.(method, objectParams(frame.params))
|
||||
}
|
||||
}
|
||||
|
||||
private async handleIncomingRequest(id: JsonRpcId, method: string, params: Record<string, unknown>): Promise<void> {
|
||||
const handler = this.requestHandler
|
||||
if (!handler) {
|
||||
this.writeError(id, -32601, `method not found: ${method}`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result = await handler(method, params)
|
||||
this.write({ jsonrpc: '2.0', id, result })
|
||||
} catch (error) {
|
||||
this.writeError(id, -32603, error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
}
|
||||
|
||||
private handleIncomingResponse(id: JsonRpcId, frame: Record<string, unknown>): void {
|
||||
const pending = this.pending.get(id)
|
||||
if (!pending) return
|
||||
this.pending.delete(id)
|
||||
if (frame.error && typeof frame.error === 'object') {
|
||||
const error = frame.error as Record<string, unknown>
|
||||
pending.reject(new JsonRpcResponseError(
|
||||
typeof error.code === 'number' ? error.code : undefined,
|
||||
typeof error.message === 'string' ? error.message : 'JSON-RPC error',
|
||||
error.data,
|
||||
))
|
||||
return
|
||||
}
|
||||
pending.resolve(frame.result)
|
||||
}
|
||||
|
||||
private writeError(id: JsonRpcId, code: number, message: string): void {
|
||||
this.write({ jsonrpc: '2.0', id, error: { code, message } })
|
||||
}
|
||||
|
||||
private write(message: Record<string, unknown>): void {
|
||||
this.output.write(`${JSON.stringify(message)}\n`)
|
||||
}
|
||||
|
||||
private failPending(error: Error): void {
|
||||
const pending = [...this.pending.values()]
|
||||
this.pending.clear()
|
||||
for (const waiter of pending) waiter.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Normalize JSON-RPC `params` to a plain object (arrays and scalars collapse to `{}`). */
|
||||
function objectParams(params: unknown): Record<string, unknown> {
|
||||
return params && typeof params === 'object' && !Array.isArray(params) ? params as Record<string, unknown> : {}
|
||||
}
|
||||
|
||||
/** Normalize an abort reason into the rejection Error (a non-Error reason is stringified). */
|
||||
function abortError(reason: unknown): Error {
|
||||
return reason instanceof Error ? reason : new Error(`JSON-RPC request aborted: ${String(reason)}`)
|
||||
}
|
||||
105
packages/scaffold/protocol/src/types.ts
Normal file
105
packages/scaffold/protocol/src/types.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Named wire types for the DeepSeek Harness SDK runtime protocol: the three
|
||||
* request/result pairs and the four server-to-client notification payloads
|
||||
* exchanged over the newline-delimited JSON-RPC stdio transport. The server
|
||||
* plugin (`@deepseek-ai/dsh-jsonrpc`) and SDK clients share these shapes;
|
||||
* `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-sdk-protocol/types
|
||||
*/
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SubagentStopReason } from '@deepseek-ai/dsh-subagent'
|
||||
|
||||
/** Parameters for the process-wide SDK handshake. */
|
||||
export interface InitializeParams {
|
||||
/** Working directory recorded on every SDK-created session's header. */
|
||||
cwd: string
|
||||
/** Provider route every SDK-created agent runs on. */
|
||||
provider: string
|
||||
/** Model name every SDK-created agent runs on (the server may mount a fallback adapter; see `HarnessSdkServer.initialize`). */
|
||||
model: string
|
||||
/** Optional positive output-token cap inherited by SDK-created agents and their in-process descendants. */
|
||||
maxTokens?: number
|
||||
}
|
||||
|
||||
/** Wire-stable server identity returned by initialization. */
|
||||
export interface InitializeResult {
|
||||
/** Wire-stable server identity (`deepseek-harness-sdk-runtime`) and version. */
|
||||
serverInfo: { name: string; version: string }
|
||||
}
|
||||
|
||||
/** One user turn on one SDK session. */
|
||||
export interface SessionPromptParams {
|
||||
/** The SDK-side session id; an unknown id lazily creates the agent+session pair. */
|
||||
sessionId: string
|
||||
/** The prompt content blocks, sent verbatim as the user message. */
|
||||
contentBlocks: ContentBlock[]
|
||||
}
|
||||
|
||||
/** Durable enqueue receipt for one prompt. */
|
||||
export interface SessionPromptResult {
|
||||
/** Identity of the queued user message. */
|
||||
messageId: string
|
||||
}
|
||||
|
||||
/** Deployment-mapped SDK outcome: `ok` for an accepted result, `error` otherwise. */
|
||||
export type SdkRunStatus = 'ok' | 'error'
|
||||
|
||||
/** `session.event` payload: one session-log event, streamed as it is recorded. */
|
||||
export interface SessionEventNotification {
|
||||
/** Session the event belongs to (every session in the runtime, not only SDK-created ones). */
|
||||
sessionId: string
|
||||
/** The full session-log event envelope. */
|
||||
event: SessionEvent
|
||||
}
|
||||
|
||||
/** Whole-agent lifecycle state for one session. */
|
||||
export interface SessionStatusNotification {
|
||||
/** Session whose live agent changed status. */
|
||||
sessionId: string
|
||||
/** The whole-agent state after the transition. */
|
||||
status: 'idle' | 'running'
|
||||
}
|
||||
|
||||
/** `subagent.started` payload: an in-runtime child session was created. */
|
||||
export interface SubagentStartedNotification {
|
||||
/** The delegating session. */
|
||||
parentSessionId: string
|
||||
/** The new child session. */
|
||||
childSessionId: string
|
||||
}
|
||||
|
||||
/** `subagent.finished` payload: an in-process subagent run ended (remote runs are not reported). */
|
||||
export interface SubagentFinishedNotification {
|
||||
/** Subagent provider name that ran the child. */
|
||||
provider: string
|
||||
/** The child agent's id (equals {@link childSessionId} for local runs). */
|
||||
agentId: string
|
||||
/** The delegating session. */
|
||||
parentSessionId: string
|
||||
/** The child session. */
|
||||
childSessionId: string
|
||||
/** Deployment-mapped run outcome. */
|
||||
status: SdkRunStatus
|
||||
/** The provider-reported stop reason. */
|
||||
stopReason: SubagentStopReason
|
||||
/** The child's final assistant message, when it produced one. */
|
||||
lastAssistantMessage?: ContentBlock[]
|
||||
}
|
||||
|
||||
/** Server-to-client notifications by JSON-RPC method name. */
|
||||
export interface HarnessSdkNotificationMap {
|
||||
'session.event': SessionEventNotification
|
||||
'session.status': SessionStatusNotification
|
||||
'subagent.started': SubagentStartedNotification
|
||||
'subagent.finished': SubagentFinishedNotification
|
||||
}
|
||||
|
||||
/** Client-to-server request methods with their param and result shapes. */
|
||||
export interface HarnessSdkRequestMap {
|
||||
'initialize': { params: InitializeParams; result: InitializeResult }
|
||||
'session/prompt': { params: SessionPromptParams; result: SessionPromptResult }
|
||||
'shutdown': { params: undefined; result: Record<string, never> }
|
||||
}
|
||||
307
packages/scaffold/protocol/tests/transport.spec.ts
Normal file
307
packages/scaffold/protocol/tests/transport.spec.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import { once } from 'node:events'
|
||||
import { PassThrough, Writable } from 'node:stream'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { JsonRpcLineTransport, JsonRpcResponseError } from '../src/index.ts'
|
||||
|
||||
function transportPair() {
|
||||
const aToB = new PassThrough()
|
||||
const bToA = new PassThrough()
|
||||
const a = new JsonRpcLineTransport(bToA, aToB)
|
||||
const b = new JsonRpcLineTransport(aToB, bToA)
|
||||
return { a, b, aToB, bToA }
|
||||
}
|
||||
|
||||
describe('JsonRpcLineTransport', () => {
|
||||
it('supports bidirectional requests and notifications over newline-delimited JSON-RPC', async () => {
|
||||
const { a, b } = transportPair()
|
||||
const notifications: Record<string, unknown>[] = []
|
||||
|
||||
a.onRequest(async (method, params) => {
|
||||
expect(method).toBe('echo')
|
||||
return { echoed: params }
|
||||
})
|
||||
b.onNotification((method, params) => {
|
||||
notifications.push({ method, params })
|
||||
})
|
||||
a.start()
|
||||
b.start()
|
||||
|
||||
const response = await b.request('echo', { value: 42 })
|
||||
expect(response).toEqual({ echoed: { value: 42 } })
|
||||
|
||||
a.notify('session.status', { sessionId: 'main', status: 'idle' })
|
||||
a.notify('heartbeat')
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(notifications).toEqual([
|
||||
{ method: 'session.status', params: { sessionId: 'main', status: 'idle' } },
|
||||
{ method: 'heartbeat', params: {} },
|
||||
])
|
||||
|
||||
a.close()
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('reports JSON-RPC request errors from the remote peer with their wire code', async () => {
|
||||
const { a, b } = transportPair()
|
||||
a.onRequest(async () => {
|
||||
throw new Error('handler boom')
|
||||
})
|
||||
a.start()
|
||||
b.start()
|
||||
|
||||
const failure = await b.request('explode', {}).then(
|
||||
() => { throw new Error('request unexpectedly succeeded') },
|
||||
(error: unknown) => error,
|
||||
)
|
||||
expect(failure).toBeInstanceOf(JsonRpcResponseError)
|
||||
expect(failure).toMatchObject({ message: 'handler boom', code: -32603, data: undefined })
|
||||
|
||||
a.close()
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('rejects immediately on a pre-aborted signal without registering pending state', async () => {
|
||||
const { b } = transportPair()
|
||||
b.start()
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('already gone'))
|
||||
await expect(b.request('never-sent', {}, controller.signal)).rejects.toThrow('already gone')
|
||||
expect((b as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0)
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('abandons a pending request on abort, stringifying a non-Error reason', async () => {
|
||||
const { b } = transportPair()
|
||||
b.start()
|
||||
const controller = new AbortController()
|
||||
const pending = b.request('never-answered', {}, controller.signal)
|
||||
controller.abort('plain-string-reason')
|
||||
await expect(pending).rejects.toThrow('JSON-RPC request aborted: plain-string-reason')
|
||||
// The abandonment removed the pending entry — nothing is retained for a
|
||||
// response that may never come.
|
||||
expect((b as unknown as { pending: Map<string, unknown> }).pending.size).toBe(0)
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('preserves structured error data from an error response frame', async () => {
|
||||
const { aToB, bToA, b } = transportPair()
|
||||
b.start()
|
||||
|
||||
const pending = b.request('remote-error-data', {})
|
||||
const requestChunk = (await once(bToA, 'data'))[0] as Buffer | string
|
||||
const request = JSON.parse(String(requestChunk)) as { id: string }
|
||||
aToB.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: 7, message: 'structured', data: { detail: 'x' } } })}\n`)
|
||||
|
||||
const failure = await pending.then(
|
||||
() => { throw new Error('request unexpectedly succeeded') },
|
||||
(error: unknown) => error,
|
||||
)
|
||||
expect(failure).toBeInstanceOf(JsonRpcResponseError)
|
||||
expect(failure).toMatchObject({ code: 7, message: 'structured', data: { detail: 'x' } })
|
||||
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('stringifies non-Error request handler failures', async () => {
|
||||
const { a, b } = transportPair()
|
||||
a.onRequest(async () => {
|
||||
throw 'string boom'
|
||||
})
|
||||
a.start()
|
||||
b.start()
|
||||
|
||||
await expect(b.request('explode-string', {})).rejects.toThrow('string boom')
|
||||
|
||||
a.close()
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('reports method-not-found when no request handler is installed', async () => {
|
||||
const { a, b } = transportPair()
|
||||
a.start()
|
||||
b.start()
|
||||
|
||||
await expect(b.request('missing', {})).rejects.toThrow('method not found: missing')
|
||||
|
||||
a.close()
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('normalizes non-object request params and ignores notifications without a handler', async () => {
|
||||
const { aToB, bToA, b } = transportPair()
|
||||
const seen: Record<string, unknown>[] = []
|
||||
b.onRequest(async (method, params) => {
|
||||
seen.push({ method, params })
|
||||
return { ok: true }
|
||||
})
|
||||
b.start()
|
||||
|
||||
aToB.write('{"jsonrpc":"2.0","method":"ignored"}\n')
|
||||
aToB.write('{"jsonrpc":"2.0","id":7,"method":"array-params","params":[]}\n')
|
||||
const chunk = (await once(bToA, 'data'))[0] as Buffer | string
|
||||
|
||||
expect(seen).toEqual([{ method: 'array-params', params: {} }])
|
||||
expect(JSON.parse(String(chunk))).toEqual({ jsonrpc: '2.0', id: 7, result: { ok: true } })
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('ignores malformed frames and accepts notifications without params', async () => {
|
||||
const { aToB, b } = transportPair()
|
||||
const notifications: Record<string, unknown>[] = []
|
||||
b.onNotification((method, params) => {
|
||||
notifications.push({ method, params })
|
||||
})
|
||||
b.start()
|
||||
b.start()
|
||||
|
||||
aToB.write('not json\n')
|
||||
aToB.write('\n')
|
||||
aToB.write('null\n')
|
||||
aToB.write('{"jsonrpc":"2.0","params":{}}\n')
|
||||
aToB.write('{"jsonrpc":"2.0","method":"tick"}\n')
|
||||
aToB.emit('data', '{"jsonrpc":"2.0","method":"string-chunk"}\n')
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
|
||||
expect(notifications).toEqual([
|
||||
{ method: 'tick', params: {} },
|
||||
{ method: 'string-chunk', params: {} },
|
||||
])
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('preserves multibyte UTF-8 characters split across Buffer chunks', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = new PassThrough()
|
||||
const transport = new JsonRpcLineTransport(input, output)
|
||||
const notifications: Record<string, unknown>[] = []
|
||||
transport.onNotification((method, params) => { notifications.push({ method, params }) })
|
||||
transport.start()
|
||||
|
||||
const frame = Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', method: 'message', params: { text: '你好' } })}\n`)
|
||||
const character = Buffer.from('你')
|
||||
const characterStart = frame.indexOf(character)
|
||||
expect(characterStart).toBeGreaterThanOrEqual(0)
|
||||
input.write(frame.subarray(0, characterStart + 1))
|
||||
input.write(frame.subarray(characterStart + 1))
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
|
||||
expect(notifications).toEqual([{ method: 'message', params: { text: '你好' } }])
|
||||
transport.close()
|
||||
})
|
||||
|
||||
it('flush waits for all earlier output writes', async () => {
|
||||
const events: string[] = []
|
||||
const output = new Writable({
|
||||
write(chunk: Buffer, _encoding, callback) {
|
||||
const label = chunk.length === 0 ? 'barrier' : 'frame'
|
||||
events.push(`start:${label}`)
|
||||
setTimeout(() => {
|
||||
events.push(`finish:${label}`)
|
||||
callback()
|
||||
}, 5)
|
||||
},
|
||||
})
|
||||
const transport = new JsonRpcLineTransport(new PassThrough(), output)
|
||||
|
||||
transport.notify('tick')
|
||||
await transport.flush()
|
||||
|
||||
expect(events).toEqual([
|
||||
'start:frame',
|
||||
'finish:frame',
|
||||
'start:barrier',
|
||||
'finish:barrier',
|
||||
])
|
||||
transport.close()
|
||||
})
|
||||
|
||||
it('reports an output callback failure from flush', async () => {
|
||||
const output = {
|
||||
write(_chunk: string, callback?: (error?: Error) => void) {
|
||||
callback?.(new Error('flush failed'))
|
||||
return true
|
||||
},
|
||||
}
|
||||
const transport = new JsonRpcLineTransport(new PassThrough(), output as never)
|
||||
|
||||
await expect(transport.flush()).rejects.toThrow('flush failed')
|
||||
})
|
||||
|
||||
it('rejects pending requests when the input closes', async () => {
|
||||
const { aToB, b } = transportPair()
|
||||
b.start()
|
||||
|
||||
const pending = b.request('never-replies', {})
|
||||
aToB.end()
|
||||
|
||||
await expect(pending).rejects.toThrow('JSON-RPC input closed')
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('rejects pending requests when the input errors', async () => {
|
||||
const { aToB, b } = transportPair()
|
||||
b.start()
|
||||
|
||||
const pending = b.request('never-replies', {})
|
||||
aToB.emit('error', new Error('input broke'))
|
||||
|
||||
await expect(pending).rejects.toThrow('input broke')
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('rejects pending requests when the transport closes', async () => {
|
||||
const { b } = transportPair()
|
||||
|
||||
const pending = b.request('never-replies', {})
|
||||
b.close()
|
||||
|
||||
await expect(pending).rejects.toThrow('JSON-RPC transport closed')
|
||||
})
|
||||
|
||||
it('rejects a request when writing the frame throws', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = {
|
||||
write() {
|
||||
throw new Error('write exploded')
|
||||
},
|
||||
}
|
||||
const transport = new JsonRpcLineTransport(input, output as never)
|
||||
|
||||
await expect(transport.request('write-fails', {})).rejects.toThrow('write exploded')
|
||||
})
|
||||
|
||||
it('stringifies non-Error write failures', async () => {
|
||||
const input = new PassThrough()
|
||||
const output = {
|
||||
write() {
|
||||
throw 'write string'
|
||||
},
|
||||
}
|
||||
const transport = new JsonRpcLineTransport(input, output as never)
|
||||
|
||||
await expect(transport.request('write-fails', {})).rejects.toThrow('write string')
|
||||
})
|
||||
|
||||
it('uses a fallback message for malformed JSON-RPC error responses', async () => {
|
||||
const { aToB, bToA, b } = transportPair()
|
||||
b.start()
|
||||
|
||||
const pending = b.request('remote-error', {})
|
||||
const requestChunk = (await once(bToA, 'data'))[0] as Buffer | string
|
||||
const request = JSON.parse(String(requestChunk)) as { id: string }
|
||||
aToB.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: {} })}\n`)
|
||||
|
||||
await expect(pending).rejects.toThrow('JSON-RPC error')
|
||||
b.close()
|
||||
})
|
||||
|
||||
it('ignores responses that do not match a pending request', async () => {
|
||||
const { aToB, b } = transportPair()
|
||||
b.start()
|
||||
|
||||
aToB.write('{"jsonrpc":"2.0","id":"unknown","result":{"ignored":true}}\n')
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
|
||||
b.close()
|
||||
})
|
||||
})
|
||||
30
packages/scaffold/protocol/tsconfig.json
Normal file
30
packages/scaffold/protocol/tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user