refactor: remove per-followup result attribution

This commit is contained in:
_Kerman
2026-07-30 16:48:28 +08:00
parent f6db60b52c
commit a6baddaaac
72 changed files with 586 additions and 1059 deletions

View File

@@ -2,5 +2,5 @@
# 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/sdk/sdk-client/README.md
README.md: 3ac4de540401f6f40dab3e84f7005f91d024aee8
README.zh.md: 95fb6b2887a7bc748a610f7fedae4be1aa2af623
README.md: 9c441e8538a62f7139f789eef78cf70d8008418a
README.zh.md: fbf638c9160d0356da0eae0566a2775146c47750

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level turns API, `HarnessClient` the lower-level protocol client. The package root enumerates the consumer interface: the two client layers, caller-facing types, and `JsonRpcResponseError`; source modules, normalization helpers, and subscription-delivery machinery are not consumer imports. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides.
The TypeScript client SDK for driving a DeepSeek Harness runtime as a subprocess over stdio JSON-RPC — the design twin of the [Python SDK](../../../python/README.md) (`deepseek-harness`), sharing the same runtime peer, protocol, and layering: `DeepSeekHarness` is the high-level owned-run API, `HarnessClient` the lower-level protocol client. The package root enumerates the consumer interface: the two client layers, caller-facing types, and `JsonRpcResponseError`; source modules, normalization helpers, and subscription-delivery machinery are not consumer imports. A pure library: it registers nothing on a Cordis context; the runtime process it spawns is a complete harness whose composition its own `cordis.yml` decides.
Unlike the Python SDK, the launch spec is fully explicit (`command`/`args`): this package is for repo-adjacent TypeScript consumers — the [`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) backend, tests, automation — which know which runtime they are launching. Bundled-runtime resolution (finding a packaged executable) remains the Python distribution's concern.
@@ -18,14 +18,16 @@ await using harness = new DeepSeekHarness({
maxTokens: 49_152,
})
const result = await harness.run('say hi')
console.log(result.status, result.finalResponse)
console.log(result.finalResponse)
```
The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route and optional positive `maxTokens` output cap); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). The cap applies to each root-agent request and is inherited by in-process descendants; compaction plugins own their separate summary limits. `session(id?)` opens a named or fresh session handle; `run(input, { sessionId?, onNotification? })` sends one prompt turn and settles when the paired `session.finished` arrives, returning a `TurnResult`: `status` (`ok`/`error` as the deployment maps it), the structured `reason` (`TurnEndReason`), `finalResponse` (last assistant message text), root-session `events`, and raw `notifications` for that session plus descendants discovered from `subagent.started`, all in wire order. Model-level failure is a `status: 'error'` result, never a rejection; rejections mean transport loss, timeout, or protocol violation.
The subprocess starts lazily on first use and stays owned by the instance across `run()` calls; `close()` (or `await using`) is required so the child is always reaped. `start()` memoizes the `initialize` handshake (the workspace cwd — resolved absolute before it crosses the wire — plus the provider/model route and optional positive `maxTokens` output cap); a failed handshake reaps the runtime and swaps in a fresh client, so a later call retries with a new subprocess (until `close()`, which is terminal). The cap applies to each root-agent request and is inherited by in-process descendants; compaction plugins own their separate summary limits. `session(id?)` opens a named or fresh session handle.
`run(input, { sessionId?, onNotification? })` owns one activity interval: it queues the prompt, waits until its `MessageId` appears in a durable `agent/inbox/spliced` receipt, then collects through the next whole-agent `idle`. It returns `RunResult { sessionId, finalResponse, events, notifications }`. `finalResponse` is the last committed root-session assistant text in that interval, not a response causally assigned to the prompt; steering, injected context, and other queued work may contribute before idle. `events` contains root-session events, while `notifications` also contains descendants discovered from `subagent.started`, all in wire order. The result carries no prompt-level status or turn reason. Transport loss, timeout, and protocol violations reject; model outcomes remain observable in the event stream without being attributed to one input.
## HarnessClient
The protocol client under the turns API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed and exported from this package: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed; there is no wire-level cancel, so the request keeps running server-side until close), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail).
The protocol client under the owned-run API: explicit `start()`/`initialize()`/`prompt()`/`request()`/`close()`, plus notification subscriptions. `prompt()` returns the queued message id as soon as the runtime accepts it; it never waits for agent activity. `subscribe(filter?)` returns a `NotificationSubscription` (awaitable `next()`, non-blocking `tryNext()`, async iteration); `subscribeSessionTree(id)` scopes to one session and the descendants discovered from `subagent.started` lineage edges — the runtime notifies for every session in its context, and scoping is client-side, exactly like the Python SDK. Error surfaces are typed and exported from this package: `JsonRpcResponseError` (wire error response, code/data preserved), `RequestTimeoutError` (a configured bound elapsed), `SdkProtocolError` (a response outside the documented protocol), `TransportClosedError` (the runtime is gone — message carries the exit code and a bounded stderr tail).
`close()` requests protocol `shutdown` (bounded by `shutdownTimeoutMs`, default 1000 ms), then walks a stdin-EOF → SIGTERM → SIGKILL ladder (`disposeEofGraceMs` default 6000, `disposeGraceMs` default 3000) until the process has actually exited. The ladder is private to this client: it runs outside any harness context, so it cannot ride the [`dsh-subprocess`](../../subprocess/README.md) service — the seam's documented exception for SDK-managed transports. It is idempotent, and a closed client refuses reuse.
@@ -33,7 +35,7 @@ The protocol client under the turns API: explicit `start()`/`initialize()`/`prom
## Testing
Keyless unit tests drive a scripted fake runtime subprocess (`tests/fake-runtime.ts`, protocol-only, env-scripted) over real stdio: turn loop, session-tree scoping, timeout/death/malformed-response surfaces, and the dispose ladder. The [SDK snapshot suite](../../../examples/jsonrpc-agent/tests/sdk.snapshot.ts) drives the real `dsh-jsonrpc-agent` runtime through this client keylessly via `llm-replay`, pinning the notification stream, the turn result, and the persisted logs; `DSH_SNAPSHOT=record` re-records against the live API.
Keyless unit tests drive a scripted fake runtime subprocess (`tests/fake-runtime.ts`, protocol-only, env-scripted) over real stdio: activity collection, session-tree scoping, timeout/death/malformed-response surfaces, and the dispose ladder. The [SDK snapshot suite](../../../examples/jsonrpc-agent/tests/sdk.snapshot.ts) drives the real `dsh-jsonrpc-agent` runtime through this client keylessly via `llm-replay`, pinning the notification stream, run result, and persisted logs; `DSH_SNAPSHOT=record` re-records against the live API.
## Model Experience
@@ -47,5 +49,5 @@ None; this package neither assembles nor sends a provider request.
- **No bundled-runtime resolution** — callers name the runtime executable explicitly; packaged-executable discovery stays Python-side until a TypeScript distribution consumer exists.
- **No mid-turn cancel** — the wire has no prompt-cancel method; abandoning a turn means closing the runtime (see the protocol's [Known Limitations](../sdk-protocol/README.md)).
- **One in-flight prompt per session** — a server-side rule this client surfaces as a `JsonRpcResponseError`; independent sessions run concurrently on one runtime.
- **No per-prompt result or cancel** — low-level `prompt()` returns only an enqueue receipt; high-level `run()` owns receipt-to-idle collection, and abandoning it means closing the runtime.
- **Client→server notifications and server→client requests are unimplemented** on both wire ends; the transport carries them for future approval flows.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层轮次 API,`HarnessClient` 是低层协议客户端。包(package)根枚举消费方接口:两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所 spawn 的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。
以子进程方式驱动 DeepSeek Harness 运行时、走 stdio JSON-RPC 的 TypeScript 客户端 SDK——[Python SDK](../../../python/README.md)(`deepseek-harness`)的设计孪生,共享同一个运行时对端、协议与分层:`DeepSeekHarness` 是高层自有运行 API,`HarnessClient` 是低层协议客户端。包(package)根枚举消费方接口:两层客户端、面向调用方的类型和 `JsonRpcResponseError`;源模块、规范化辅助函数与订阅投递机制不供消费方导入。纯库:不在任何 Cordis 上下文注册;它所 spawn 的运行时进程是一个完整 harness,其组成由自己的 `cordis.yml` 决定。
与 Python SDK 不同,启动规格完全显式(`command`/`args`):本包面向仓库近旁的 TypeScript 消费方——[`dsh-subagent-dsh-sdk`](../../subagent/subagent-dsh-sdk/README.md) 后端、测试、自动化——它们知道自己要启动哪个运行时。捆绑运行时解析(寻找打包可执行文件)仍归 Python 发行版负责。
@@ -18,14 +18,16 @@ await using harness = new DeepSeekHarness({
maxTokens: 49_152,
})
const result = await harness.run('say hi')
console.log(result.status, result.finalResponse)
console.log(result.finalResponse)
```
子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被回收。`start()` 记忆化 `initialize` 握手(工作区 cwd——在通过协议传输之前解析为绝对路径——加 provider/model 路由和可选的正整数 `maxTokens` 输出上限);握手失败会回收运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。该上限作用于根 agent(智能体)的每次请求,并由进程内后代继承;压缩(compaction)插件单独持有摘要上限。`session(id?)` 打开具名或全新的会话句柄;`run(input, { sessionId?, onNotification? })` 发送一个提示词轮次,在配对的 `session.finished` 到达时完成,并返回 `TurnResult`:`status`(按部署映射的 `ok`/`error`)、结构化 `reason`(`TurnEndReason`)、`finalResponse`(最后一条助手消息文本)、根会话的 `events`,以及该会话和通过 `subagent.started` 发现的后代的原始 `notifications`,均按协议传输顺序排列。模型层失败会返回 `status: 'error'` 的结果,绝不会导致 Promise 被拒绝;Promise 被拒绝意味着传输丢失、超时或协议违例。
子进程在首次使用时惰性启动,并在多次 `run()` 之间持续归实例所有;必须 `close()`(或 `await using`),子进程才总能被回收。`start()` 记忆化 `initialize` 握手(工作区 cwd——在通过协议传输之前解析为绝对路径——加 provider/model 路由和可选的正整数 `maxTokens` 输出上限);握手失败会回收运行时并换入全新客户端,后续调用用新子进程重试(直到终结性的 `close()`)。该上限作用于根 agent(智能体)的每次请求,并由进程内后代继承;压缩(compaction)插件单独持有摘要上限。`session(id?)` 打开具名或全新的会话句柄。
`run(input, { sessionId?, onNotification? })` 拥有一个活动区间:它将提示词排入队列,等待其 `MessageId` 出现在持久的 `agent/inbox/spliced` 回执中,然后持续收集到整个 agent 下一次进入 `idle`。它返回 `RunResult { sessionId, finalResponse, events, notifications }`。`finalResponse` 是该区间内根会话最后提交的助手文本,并非因果上归属于该提示词的响应;steering(中途引导)、注入的上下文和其他排队工作都可能在 idle 前参与其中。`events` 包含根会话事件,`notifications` 还包含通过 `subagent.started` 发现的后代,均按协议传输顺序排列。结果不携带提示词级状态或轮次原因。传输丢失、超时和协议违例会导致 Promise 被拒绝;模型结果仍可在事件流中观察,但不会归属于某一输入。
## HarnessClient
轮次 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。本包导出有明确类型的错误:`JsonRpcResponseError`(协议错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到;协议层没有取消机制,请求在服务端继续运行直到 close)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。
自有运行 API 之下的协议客户端:显式 `start()`/`initialize()`/`prompt()`/`request()`/`close()`,外加通知订阅。`prompt()` 在运行时接受排队消息后立即返回该消息的 ID,绝不等待 agent 活动。`subscribe(filter?)` 返回 `NotificationSubscription`(可等待的 `next()`、非阻塞 `tryNext()`、异步迭代);`subscribeSessionTree(id)` 把范围限定到一个会话及从 `subagent.started` 血缘边发现的后代——运行时对上下文内每个会话都发通知,范围限定在客户端完成,与 Python SDK 完全一致。本包导出有明确类型的错误:`JsonRpcResponseError`(协议错误响应,保留 code/data)、`RequestTimeoutError`(配置的时限已到)、`SdkProtocolError`(响应超出文档化协议)、`TransportClosedError`(运行时已消失——消息携带退出码与有界 stderr 尾部)。
`close()` 先请求协议 `shutdown`(受 `shutdownTimeoutMs` 约束,默认 1000 毫秒),然后走 stdin-EOF → SIGTERM → SIGKILL 阶梯(`disposeEofGraceMs` 默认 6000,`disposeGraceMs` 默认 3000)直到进程真正退出。该阶梯为本客户端私有:它运行在任何 harness 上下文之外,无法搭乘 [`dsh-subprocess`](../../subprocess/README.md) 服务——即该 seam 所记录的 SDK 托管传输例外。幂等,已关闭的客户端拒绝复用。
@@ -33,7 +35,7 @@ console.log(result.status, result.finalResponse)
## 测试
免密钥单元测试通过真实 stdio 驱动一个脚本化伪运行时子进程(`tests/fake-runtime.ts`,纯协议、环境变量脚本化):轮次循环、会话树范围限定、超时、进程死亡和响应畸形场景,以及 dispose(资源释放)阶梯。[SDK 快照套件](../../../examples/jsonrpc-agent/tests/sdk.snapshot.ts) 经由 `llm-replay` 免密钥地通过本客户端驱动真实 `dsh-jsonrpc-agent` 运行时,固定通知流、轮次结果与持久化日志;`DSH_SNAPSHOT=record` 对真实 API 重录。
免密钥单元测试通过真实 stdio 驱动一个脚本化伪运行时子进程(`tests/fake-runtime.ts`,纯协议、环境变量脚本化):活动收集、会话树范围限定、超时、进程死亡和响应畸形场景,以及 dispose(资源释放)阶梯。[SDK 快照套件](../../../examples/jsonrpc-agent/tests/sdk.snapshot.ts) 经由 `llm-replay` 免密钥地通过本客户端驱动真实 `dsh-jsonrpc-agent` 运行时,固定通知流、运行结果与持久化日志;`DSH_SNAPSHOT=record` 对真实 API 重录。
## 模型体验
@@ -47,5 +49,5 @@ console.log(result.status, result.finalResponse)
- **无捆绑运行时解析**——调用方显式指定运行时可执行文件;打包可执行文件的发现留在 Python 侧,直到出现 TypeScript 发行版消费方。
- **无轮次中取消**——协议层没有提示词取消方法;放弃轮次意味着关闭运行时(见协议的 [已知限制](../sdk-protocol/README.md))。
- **每会话同时只有一个在途提示词**——服务端规则,本客户端将其呈现为 `JsonRpcResponseError`;相互独立的会话可在同一运行时上并发。
- **没有逐提示词结果或取消**——低层 `prompt()` 只返回入队回执;高层 `run()` 负责从回执收集到 idle,放弃该过程意味着关闭运行时。
- **客户端→服务端通知与服务端→客户端请求**在协议两端都未实现;传输层为未来审批流保留了承载能力。

View File

@@ -1,7 +1,7 @@
/**
* High-level turns API over {@link HarnessClient}: `DeepSeekHarness` owns one
* High-level run API over {@link HarnessClient}: `DeepSeekHarness` owns one
* runtime subprocess across many sessions; `HarnessSession.run` sends a
* prompt and settles with the final response once `session.finished` arrives.
* prompt and settles when the whole agent next becomes idle.
* Mirrors the Python SDK's `DeepSeekHarness`/`Session` pair.
*
* @module @deepseek-ai/dsh-sdk-client/api
@@ -9,9 +9,9 @@
import { randomUUID } from 'node:crypto'
import { resolve } from 'node:path'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { HarnessClient, isRecord, SdkProtocolError } from './client.ts'
import type { ContentBlock, DeepSeekHarnessOptions, HarnessClientOptions, HarnessNotification, TurnResult } from './types.ts'
import type { ContentBlock, DeepSeekHarnessOptions, HarnessClientOptions, HarnessNotification, RunResult } from './types.ts'
/**
* Reusable SDK for running DeepSeek Harness agent turns in a runtime
@@ -93,9 +93,9 @@ export class DeepSeekHarness implements AsyncDisposable {
* Run one prompt on a fresh (or named) session.
* @param input - prompt text, or content blocks sent verbatim.
* @param options - optional session id and per-notification observer.
* @returns the settled turn result.
* @returns the owned activity interval.
*/
run(input: string | ContentBlock[], options?: RunOptions): Promise<TurnResult> {
run(input: string | ContentBlock[], options?: RunOptions): Promise<RunResult> {
return this.session(options?.sessionId).run(input, options)
}
@@ -127,8 +127,7 @@ export interface RunOptions {
}
/**
* One SDK session: a stable id plus the turn loop that pairs a
* `session/prompt` with its `session.finished`.
* One SDK session: a stable id plus owned activity intervals.
*/
export class HarnessSession {
/**
@@ -138,27 +137,23 @@ export class HarnessSession {
constructor(readonly harness: DeepSeekHarness, readonly id: string) {}
/**
* Run one prompt turn to settlement.
* Queue one prompt, then observe the whole session through its next idle.
* @param input - prompt text, or content blocks sent verbatim.
* @param options - optional per-notification observer.
* @returns the settled turn result; rejects on transport loss, timeout, or
* a protocol error — never on a model-level failure (that is
* `status: 'error'` in the result).
* @returns the owned activity interval; rejects on transport loss, timeout,
* or a protocol error.
*/
async run(input: string | ContentBlock[], options?: Pick<RunOptions, 'onNotification'>): Promise<TurnResult> {
async run(input: string | ContentBlock[], options?: Pick<RunOptions, 'onNotification'>): Promise<RunResult> {
await this.harness.start()
const client = this.harness.client
const contentBlocks = normalizeInput(input)
const events: SessionEvent[] = []
const notifications: HarnessNotification[] = []
let status: TurnResult['status'] = 'error'
let reason: TurnEndReason | undefined
let finished = false
const subscription = client.subscribeSessionTree(this.id)
const collect = (notification: HarnessNotification): void => {
if (notification.method === 'session.event' && notification.params.sessionId === this.id) {
// Wire boundary: the envelope feeds the typed TurnResult, so a
// Wire boundary: the envelope feeds the typed RunResult, so a
// malformed runtime surfaces as a protocol error, not as type-invalid
// data (or a TypeError out of finalResponse).
const event = validatedSessionEvent(notification.params.event)
@@ -167,37 +162,31 @@ export class HarnessSession {
events.push(event)
return
}
if (notification.method === 'session.finished' && notification.params.sessionId === this.id) {
reason = validatedTurnEndReason(notification.params.reason)
notifications.push(notification)
options?.onNotification?.(notification)
status = notification.params.status === 'ok' ? 'ok' : 'error'
finished = true
return
}
notifications.push(notification)
options?.onNotification?.(notification)
}
const accepted = client.prompt(this.id, contentBlocks)
// Drain concurrently so observers see progress while the prompt request
// is still pending (its response arrives only after settlement).
const drain = (async () => {
while (!finished) collect(await subscription.next())
})()
try {
await Promise.all([accepted, drain])
const messageId = await client.prompt(this.id, contentBlocks)
let received = false
while (true) {
const notification = await subscription.next()
if (!received) {
if (notification.method !== 'session.event'
|| notification.params.sessionId !== this.id
|| !isInboxReceipt(notification.params.event, messageId)) continue
received = true
}
collect(notification)
if (notification.method === 'session.status'
&& notification.params.sessionId === this.id
&& notification.params.status === 'idle') break
}
} finally {
// On a prompt rejection the drain is still parked on next(); closing the
// subscription settles it, and the swallow keeps that secondary
// TransportClosedError from surfacing as an unhandled rejection.
subscription.close()
await drain.catch(() => {})
}
return {
sessionId: this.id,
status,
reason,
finalResponse: finalResponse(events),
events,
notifications,
@@ -232,18 +221,16 @@ function validatedSessionEvent(value: unknown): SessionEvent {
return value as unknown as SessionEvent
}
/** Validate a wire `session.finished` reason (absent, or a kind-tagged record). */
function validatedTurnEndReason(value: unknown): TurnEndReason | undefined {
if (value === undefined) return undefined
if (!isRecord(value) || typeof value.kind !== 'string') {
throw new SdkProtocolError(`session.finished carried a malformed reason: ${JSON.stringify(value)}`)
}
return value as unknown as TurnEndReason
/** Whether a raw session event is the durable enqueue receipt for `messageId`. */
function isInboxReceipt(value: unknown, messageId: string): boolean {
if (!isRecord(value) || value.type !== 'agent/inbox/spliced' || !isRecord(value.data)) return false
const inserted = value.data.inserted
return Array.isArray(inserted) && inserted.some(message => isRecord(message) && message.id === messageId)
}
/**
* Extract the concatenated text of the last assistant message.
* @param events - the turn's `session.event` payloads in wire order.
* @param events - the activity interval's `session.event` payloads in wire order.
* @returns the final response text, or `''` when no assistant message exists.
*/
export function finalResponse(events: SessionEvent[]): string {

View File

@@ -275,17 +275,18 @@ export class HarnessClient {
}
/**
* Run one prompt turn to settlement (the response arrives only after the
* turn settled; progress streams as notifications meanwhile).
* Queue one prompt and return its durable inbox identity.
* @param sessionId - target session; an unknown id creates it.
* @param contentBlocks - the user message, sent verbatim.
* @returns the queued message id.
*/
async prompt(sessionId: string, contentBlocks: ContentBlock[]): Promise<void> {
async prompt(sessionId: string, contentBlocks: ContentBlock[]): Promise<string> {
const params: SessionPromptParams = { sessionId, contentBlocks }
const result = await this.request('session/prompt', { ...params })
if (!isRecord(result) || result.accepted !== true) {
throw new SdkProtocolError(`session/prompt was not accepted: ${JSON.stringify(result)}`)
if (!isRecord(result) || typeof result.messageId !== 'string') {
throw new SdkProtocolError(`session/prompt returned no message id: ${JSON.stringify(result)}`)
}
return result.messageId
}
/**

View File

@@ -1,7 +1,7 @@
/**
* TypeScript client SDK for the DeepSeek Harness runtime: spawn the
* `dsh-jsonrpc-agent` runtime as a subprocess and drive agent turns over
* stdio JSON-RPC. `DeepSeekHarness` is the high-level turns API;
* stdio JSON-RPC. `DeepSeekHarness` is the high-level run API;
* `HarnessClient` is the lower-level protocol client. A pure library — it
* registers nothing on a Cordis context; the runtime process it spawns is a
* complete harness configured by its own `cordis.yml`.
@@ -25,5 +25,5 @@ export type {
HarnessClientOptions,
HarnessNotification,
NotificationFilter,
TurnResult,
RunResult,
} from './types.ts'

View File

@@ -1,17 +1,16 @@
/**
* Types for the TypeScript SDK client: launch options, notification shapes,
* and turn results.
* and owned activity results.
*
* @module @deepseek-ai/dsh-sdk-client/types
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SdkRunStatus } from '@deepseek-ai/dsh-sdk-protocol'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
/** One server-to-client notification as received off the wire. */
export interface HarnessNotification {
/** The JSON-RPC method name (`session.event`, `session.finished`, `subagent.started`, `subagent.finished`). */
/** The JSON-RPC notification method name. */
method: string
/** The raw params object; see `HarnessSdkNotificationMap` for the shapes per method. */
params: Record<string, unknown>
@@ -59,15 +58,11 @@ export interface DeepSeekHarnessOptions {
maxTokens?: number
}
/** The settled outcome of one {@link HarnessSession.run} turn. */
export interface TurnResult {
/** The session the turn ran on. */
/** One owned session activity interval, from enqueue receipt through idle. */
export interface RunResult {
/** The session the activity ran on. */
sessionId: string
/** Deployment-mapped turn outcome from `session.finished`. */
status: SdkRunStatus
/** Why the last message-triggered turn ended; `undefined` when no turn ran. */
reason: TurnEndReason | undefined
/** Concatenated text of the session's last assistant message (empty when none). */
/** Concatenated text of the interval's last assistant message (empty when none). */
finalResponse: string
/** Every `session.event` payload for the root session, in wire order. */
events: SessionEvent[]

View File

@@ -142,13 +142,6 @@ function runTurn(sessionId: string): void {
lastAssistantMessage: [{ type: 'text', text: 'child says hi' }],
})
}
notify('session.finished', {
sessionId,
status: env.FAKE_STATUS ?? 'ok',
...(env.FAKE_MALFORMED_REASON !== undefined
? { reason: 'not-a-record' }
: reasonKind === 'none' ? {} : { reason: { kind: reasonKind } }),
})
}
function sessionIdOf(params: Record<string, unknown> | undefined): string {
@@ -197,8 +190,20 @@ reader.on('line', (line) => {
respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } })
return
case 'session/prompt': {
const sessionId = sessionIdOf(frame.params)
const messageId = `fake-user-${seq}`
event(sessionId, 'agent/inbox/spliced', {
target: 'next-turn',
start: 0,
inserted: [{
id: messageId,
role: 'user',
content: [],
source: { kind: 'user' },
}],
})
notify('session.status', { sessionId, status: 'running' })
if (env.FAKE_STREAM_THEN_MALFORMED !== undefined) {
const sessionId = sessionIdOf(frame.params)
event(sessionId, 'assistant/chunk', { turn: 0, step: 0, chunk: { type: 'text-delta', index: 0, text: 'streamed then cut short' } })
respond({})
return
@@ -208,9 +213,9 @@ reader.on('line', (line) => {
respond({})
return
}
const sessionId = sessionIdOf(frame.params)
runTurn(sessionId)
respond({ accepted: true })
notify('session.status', { sessionId, status: 'idle' })
respond({ messageId })
return
}
case 'shutdown':

View File

@@ -56,14 +56,13 @@ describe('DeepSeekHarness', () => {
it('runs a turn end to end and reuses the runtime across sessions', async () => {
const harness = harnessWith({ FAKE_TEXT: 'turn answer' })
const first = await harness.run('say hi')
expect(first.status).toBe('ok')
expect(first.reason).toEqual({ kind: 'completed' })
expect(first.finalResponse).toBe('turn answer')
expect(first.events.map(event => event.type)).toEqual(['turn/start', 'assistant/chunk', 'assistant/message', 'turn/end'])
expect(first.events.map(event => event.type)).toEqual([
'agent/inbox/spliced', 'turn/start', 'assistant/chunk', 'assistant/message', 'turn/end',
])
// Same subprocess, second session: ids differ, protocol state is reusable.
const second = await harness.run([{ type: 'text', text: 'again' }])
expect(second.status).toBe('ok')
expect(second.sessionId).not.toBe(first.sessionId)
await harness.close()
})
@@ -76,13 +75,12 @@ describe('DeepSeekHarness', () => {
onNotification: (n) => { seen.push(n) },
})
expect(result.status).toBe('ok')
// The child session's events arrive through subagent.started lineage.
expect(seen.map(n => n.method)).toContain('subagent.started')
expect(seen.map(n => n.method)).toContain('subagent.finished')
const childEvents = seen.filter(n => n.method === 'session.event' && n.params.sessionId === 'parent-1-child')
expect(childEvents.length).toBeGreaterThan(0)
// TurnResult.events is the root session's typed stream; descendants retain
// RunResult.events is the root session's typed stream; descendants retain
// their session ids in the raw notification stream above.
expect(result.events.every(event => event.type !== 'assistant/message'
|| event.data.message.content[0]?.type !== 'text'
@@ -90,22 +88,6 @@ describe('DeepSeekHarness', () => {
await harness.close()
})
it('reports an error status with the turn-end reason', async () => {
const harness = harnessWith({ FAKE_STATUS: 'error', FAKE_REASON_KIND: 'max-tokens' })
const result = await harness.run('overflow')
expect(result.status).toBe('error')
expect(result.reason).toEqual({ kind: 'max-tokens' })
await harness.close()
})
it('omits the reason when the runtime settled without one', async () => {
const harness = harnessWith({ FAKE_STATUS: 'error', FAKE_REASON_KIND: 'none' })
const result = await harness.run('no turn')
expect(result.status).toBe('error')
expect(result.reason).toBeUndefined()
await harness.close()
})
it('sends the configured cwd/provider/model/maxTokens in the handshake exactly once', async () => {
const dir = await tempDir('sdk-client-init-')
const recordFile = join(dir, 'init.jsonl')
@@ -311,14 +293,14 @@ describe('HarnessClient', () => {
await client.initialize({ cwd: process.cwd(), provider: 'p', model: 'm' })
const all = client.subscribe()
const finishedOnly = client.subscribe(n => n.method === 'session.finished')
const idleOnly = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle')
await client.prompt('sub-test', normalizeInput('go'))
const first = await all.next()
expect(first.method).toBe('session.event')
const finished = await finishedOnly.next()
expect(finished.method).toBe('session.finished')
expect(finishedOnly.tryNext()).toBeUndefined()
const idle = await idleOnly.next()
expect(idle.method).toBe('session.status')
expect(idleOnly.tryNext()).toBeUndefined()
// A bare unbounded request with omitted params sends `{}` on the wire.
const identity = await client.request('initialize') as { serverInfo: { name: string } }
@@ -328,12 +310,12 @@ describe('HarnessClient', () => {
const collected: string[] = []
for await (const notification of all) {
collected.push(notification.method)
if (notification.method === 'session.finished') break
if (notification.method === 'session.status' && notification.params.status === 'idle') break
}
expect(collected.at(-1)).toBe('session.finished')
expect(collected.at(-1)).toBe('session.status')
all.close()
finishedOnly.close()
idleOnly.close()
await expect(all.next()).rejects.toThrow('notification subscription closed')
await client.close()
})
@@ -346,11 +328,11 @@ describe('HarnessClient', () => {
const broken = client.subscribe(() => { throw new Error('filter exploded') })
// A non-Error throw is normalized rather than crashing dispatch.
const brokenNonError = client.subscribe(() => { throw 'string boom' })
const healthy = client.subscribe(n => n.method === 'session.finished')
const healthy = client.subscribe(n => n.method === 'session.status' && n.params.status === 'idle')
await client.prompt('filter-contain', normalizeInput('go'))
// The sibling subscription and the read loop are undisturbed.
expect((await healthy.next()).method).toBe('session.finished')
expect((await healthy.next()).method).toBe('session.status')
// Each broken subscription failed with ITS OWN error and detached.
await expect(broken.next()).rejects.toThrow('filter exploded')
await expect(brokenNonError.next()).rejects.toThrow('string boom')
@@ -445,10 +427,6 @@ describe('wire payload validation', () => {
await expect(harness.run('no-data')).rejects.toThrow(SdkProtocolError)
})
it('rejects a malformed session.finished reason as a protocol error', async () => {
const harness = harnessWith({ FAKE_MALFORMED_REASON: '1' })
await expect(harness.run('bad-reason')).rejects.toThrow(SdkProtocolError)
})
})
describe('stderr tail bound', () => {

View File

@@ -2,5 +2,5 @@
# 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/sdk/sdk-protocol/README.md
README.md: 62b26d4a82d358fa4efcb7ab84036e5f4848057f
README.zh.md: 11677c6119c7da407d95ee38ad9f8f7a552c15de
README.md: 2120e6090fcc5d5f4a543424e9c5647e6009bad6
README.zh.md: 70b046cf1dedbf01031e3e0a4441f522d6153fbc

View File

@@ -15,14 +15,14 @@ The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delim
| Direction | Method | Types |
|---|---|---|
| client→server | `initialize` | `InitializeParams` → `InitializeResult` |
| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult` (answered only after turn settlement) |
| 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.finished` | `SessionFinishedNotification` (one per accepted prompt) |
| 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. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission leaves the provider default in control. 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`.
`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 leaves the provider default in control. 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

View File

@@ -15,14 +15,14 @@ DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按
| 方向 | 方法 | 类型 |
|---|---|---|
| client→server | `initialize` | `InitializeParams` → `InitializeResult` |
| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult`(仅在轮次结算完成后应答) |
| client→server | `session/prompt` | `SessionPromptParams` → `SessionPromptResult`(持久入队回执) |
| client→server | `shutdown` | 无参数 → `{}` |
| server→client | `session.event` | `SessionEventNotification`(运行时内每个会话,不过滤) |
| server→client | `session.finished` | `SessionFinishedNotification`(每个获准的提示词请求一条) |
| server→client | `session.status` | `SessionStatusNotification`(整个 agent(智能体)的 `running`/`idle` 转换) |
| server→client | `subagent.started` | `SubagentStartedNotification` |
| server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) |
`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent(智能体)及其进程内后代的每次对话模型输出;省略时由提供方默认值控制。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式契约的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。
`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`。
## 模型体验

View File

@@ -17,7 +17,7 @@ export type {
InitializeResult,
SdkRunStatus,
SessionEventNotification,
SessionFinishedNotification,
SessionStatusNotification,
SessionPromptParams,
SessionPromptResult,
SubagentFinishedNotification,

View File

@@ -9,7 +9,7 @@
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { SubagentStopReason } from '@deepseek-ai/dsh-subagent'
/** Parameters for the process-wide SDK handshake. */
@@ -38,10 +38,10 @@ export interface SessionPromptParams {
contentBlocks: ContentBlock[]
}
/** Prompt acceptance after turn settlement; outcome rides on `session.finished`. */
/** Durable enqueue receipt for one prompt. */
export interface SessionPromptResult {
/** Always `true`; the turn outcome is the paired `session.finished` notification. */
accepted: true
/** Identity of the queued user message. */
messageId: string
}
/** Deployment-mapped SDK outcome: `ok` for an accepted result, `error` otherwise. */
@@ -55,14 +55,12 @@ export interface SessionEventNotification {
event: SessionEvent
}
/** `session.finished` payload: one per accepted prompt, after turn settlement. */
export interface SessionFinishedNotification {
/** The settled session. */
/** Whole-agent lifecycle state for one session. */
export interface SessionStatusNotification {
/** Session whose live agent changed status. */
sessionId: string
/** Deployment-mapped turn outcome (see `maxTokensAsSuccess` on the server). */
status: SdkRunStatus
/** Why the last message-triggered turn ended; absent when no turn ran. */
reason: TurnEndReason | undefined
/** The whole-agent state after the transition. */
status: 'idle' | 'running'
}
/** `subagent.started` payload: an in-runtime child session was created. */
@@ -94,7 +92,7 @@ export interface SubagentFinishedNotification {
/** Server-to-client notifications by JSON-RPC method name. */
export interface HarnessSdkNotificationMap {
'session.event': SessionEventNotification
'session.finished': SessionFinishedNotification
'session.status': SessionStatusNotification
'subagent.started': SubagentStartedNotification
'subagent.finished': SubagentFinishedNotification
}

View File

@@ -29,11 +29,11 @@ describe('JsonRpcLineTransport', () => {
const response = await b.request('echo', { value: 42 })
expect(response).toEqual({ echoed: { value: 42 } })
a.notify('session.finished', { sessionId: 'main', status: 'ok' })
a.notify('session.status', { sessionId: 'main', status: 'idle' })
a.notify('heartbeat')
await new Promise(resolve => setTimeout(resolve, 10))
expect(notifications).toEqual([
{ method: 'session.finished', params: { sessionId: 'main', status: 'ok' } },
{ method: 'session.status', params: { sessionId: 'main', status: 'idle' } },
{ method: 'heartbeat', params: {} },
])