DSH 协议参考

这一页列出 DSH 适配器读写的 DeepSeek Harness(DSH)Web 远程协议字段,以及它们怎么映射成 ChatKit 的消息、片段和轮次结论。怎么接入见 DSH。

传输

通道相对路径用途
HTTP 单次调用api/{namespace}/{method}发送、插话、停止、翻页、回答请求;POST,请求和回复都是信封
WebSocket(remote.mux)api/remote.mux一条连接复用多个逻辑流,整个连接共用
逻辑流 session/followmux 上的 endpoint每个会话一条:先给快照,再推会话事件与模型回答的流式帧
逻辑流 $eventsmux 上的 endpoint整个连接一条:先给 ready(带 clientId),再推通知与需要客户端回答的请求
  • 连接只拼相对路径,交给 resolveUrl(path, kind) 变成完整地址:kind 为 http 时是 api/{method},为 ws 时是 api/remote.mux。地址前缀、鉴权参数都在这里决定;需要请求头或走代理时替换 fetch / WebSocket。
  • 单次调用的信封:请求 { type: 'client-request', rpcId, method, payload: { args } },rpcId 每次随机;回复 { type: 'server-response', rpcId, result },result.ok 为 true 时取 result.value。
  • mux 帧:客户端只发 { type: 'open', streamId, endpoint, payload: { args } };服务端回 item(带 value)、end、error。逻辑流在连接断开前不会主动关闭。
  • 发起一轮的顺序:先登记这一轮的监听 → 等这个会话的 session/follow 快照和 $events 的 ready → 再 POST session/prompt。开头的事件不会丢。
  • 快照之前到达的 follow 内容丢弃;$events 在 ready 之前到达的帧丢弃。

怎么认出属于这一轮的内容

同一会话的 follow 流里有所有 turn 的内容,连接按下面的规则只把这一轮的交给协议:

步骤规则
候选看到 turn/start 时把它的 data.turn 记为候选,开始缓冲;再来一个 turn/start 就换候选
认定候选 turn 里出现 source.kind === 'user' 且 source.rpcId === runId 的 user/message 时认定,缓冲一起放出
归属会话事件看 data.turn;流式帧看同一 attemptId 的 start 帧里的 turn;data.turn 不同的丢弃,没有 turn 的照常放行
请求$events 的请求没有 turn,只有在这个会话的轮次已认定、还没结束时才认领
结束读到认定的 turn 的 turn/end 为止;emit 通知永远不进轮次

不按"最近开始的 turn"猜。同一会话里由其他客户端发起的轮次不会实时显示,刷新后从历史出现。

核验版本与稳定性

按 DSH Web 0.x 的 dsh-api-gateway / dsh-client-connection(remote.mux)协议核对。DSH 仍是 developer preview,字段可能变化;升级后以适配器源码里的 wire.types.ts 为准重新核对。

发出的请求

发送:session/prompt(mode: queue)

POST api/session/prompt,payload.args 为 { request }:

字段含义来源transformInput 能改
requestId这条用户消息的请求编号ChatKit 的 runId;后端写到这条消息的 source.rpcId否,连接最后覆盖
sessionIdDSH 会话 id调用带的 sessionId否
modequeue:排到下一轮固定否
content消息内容统一输入编码,见下能
clientTimeZone客户端时区默认不发能(自己加)
其它字段原样带上transformInput 返回的额外字段能

transformInput 收到的 defaultInput 是 { content, mode: 'queue' },要返回完整请求体;mode 会被丢掉,requestId、sessionId、mode 由连接补上。

content 的编码:

输入放成
正文、引用相邻的合并成一段 { type: 'text', text };引用转成文本
内联图片附件{ type: 'image', mediaType, data, name },只接受 PNG / JPEG / WebP / GIF
其它附件不发请求,抛错,cause.code 为 UNSUPPORTED_ATTACHMENT

回复只看 result.ok。DSH 的 file 内容需要先上传拿 receiptId,适配器不发。

插话、停止、历史、回答

能力路径payload.args说明
插话api/session/prompt{ request: { ...body, requestId, sessionId, mode: 'steer' } }requestId 随机生成;body 经 transformInput(capability: 'steer')
停止api/session/cancel{ request: { sessionId } }会话级,不经 transformInput;受理后等 turn/end 收口
历史(投影)api/session/projections{ request: { sessionId } }只在第一页调;读 asOfSeq(当前位置,作为 throughSeq)与 values(投影)
历史(一页)api/session/page{ request: { address: { kind: 'session', sessionId }, throughSeq, beforeSeq?, maxMessages? } }throughSeq 取第一页时的 asOfSeq,不能超过后端当前位置;beforeSeq 取更早的一页
请求通道mux 打开 $events{}整个连接一次
回答请求api/$events/result{ clientId, eventId, outcome: { kind: 'result', value } }见审批与追问
不认领请求api/$events/result{ clientId, eventId, outcome: { kind: 'next' } }交给下一个回答者
追问补答api/userQuestions/answer{ agentId: sessionId, callId, answer }回复是布尔值,false 视为失败

maxMessages 来自连接选项 historyMaxMessages,不给就不带。

读取的事件

会话事件(follow 的 { type: 'event', event })

事件外层读 type、seq(事件编号、session/page 的位置)、time(消息时间)、data。

事件读取的字段映射成 ChatKit 的什么
turn/startturn阶段 sampling;历史里开始一轮,time 作为轮次开始时间
user/messageid、content、source.kind、source.rpcId只认 source.kind === 'user'。用户消息 user:{rpcId};rpcId === runId 的是本地已投影的那条,实时不重复;插话照常显示在这一轮
assistant/messagestep、message.content[]:text / reasoning 的 text,tool-call 的 id、name、arguments整步替换:正文 / 思考片段 s{step}:b{内容序号},工具片段 tool:{id};以它为准覆盖流式帧拼出的内容
tool/callcallId、name、arguments工具片段 tool:{callId},status: 'running',arguments 能解析成 JSON 对象时放进 args;阶段 tool
tool/resultmessage.toolCallId、message.content[].text、message.isError文本拼接为 result;isError 时 status: 'failed'、error.code 为 DSH_TOOL_ERROR,否则 completed;阶段 sampling;追问工具的交互标为 answered
approval/askedid、toolName、callId、reason在 tool:{callId}(没有时 tool:approval:{id})上挂审批交互,status: 'pending'、没有 requestId;阶段 awaiting-external
approval/decidedid、outcome交互 status: 'answered',decision 为 outcome;没有其它等待中的交互时阶段回到 tool
turn/endturn、reason.kind、reason.message、reason.error.message先收尾片段,再给终态,见结束与错误

流式帧(follow 的 { type: 'assistant-stream', frame })

帧 / chunk读取的字段映射
startattemptId、step、turn记下这个 attempt 属于哪一步、哪个 turn
block-startindex、blockTypetext / reasoning 建空片段 s{step}:b{index};其它类型不建
text-deltaindex、text追加到 s{step}:b{index},发 delta;片段不存在时先建
reasoning-deltaindex、text同上,思考片段
tool-call-deltaid、name、argumentsDelta首次建工具片段 tool:{id}(running),阶段 tool;参数增量累积,tool/call 到达时以它的 arguments 为准
endoutcome.kind、outcome.eventTypeabandoned 或 eventType 为 assistant/attempt:撤回这个 attempt 流出的片段(模型重试)

流式帧的事件编号是 stream:{attemptId}:{revision},会话事件是 seq:{seq}。

需要客户端回答的请求($events)

帧读取的字段映射
readyclientId回答请求时带上
waterfall,event: approval/requesteventId、agentId、request.toolName、callId、reason、displayReason审批交互,requestId 为 eventId;阶段 awaiting-external
waterfall,event: user-questions/requesteventId、agentId、request.questions、request.wait.callId追问交互;阶段 awaiting-external
canceleventId后端撤回了请求:交互去掉 requestId;审批标为 cancelled,追问回到 pending
emitevent、args[0](会话 id)不进轮次,只交给这个会话的 listen 监听

agentId 就是会话 id。只有这个会话有轮次已认定、还在读时才认领上面两种请求;否则、或是别的 event,立刻回 next。

没有内置处理的事件

这些内容适配器不变成聊天内容,接入方可以自己拿:

  • 属于某一轮的(带这一轮的 data.turn,或在读这一轮期间到达、不带 turn),会进 transformOutput,收到原始的 DshFeedItem,可以返回 OutputChange[] 补 data 片段。
  • 不属于任何一轮的、emit 通知、或用于插槽的,用 connection.listen(sessionId, listener):它收到这个会话的全部原始内容(会话事件、流式帧、请求、emit、本地的回答状态)。
事件 / 字段含义是否属于某一轮怎么拿
step/start、step/end一步的开始、结束是,带 turn、steptransformOutput 或 listen
system/message、developer/message系统 / 开发者提示system/message 带 turntransformOutput 或 listen
user/message(source.kind 不是 user)后端注入的上下文,如运行时上下文、技能目录不带 turn,读这一轮期间到达的会进轮次transformOutput 或 listen
request/header、request/context这一步发给模型的请求头、上下文窗口不带 turnlisten;读这一轮期间到达的也进 transformOutput
session/title、session/title-llm-request会话标题变化不带 turnlisten(历史只读快照投影里的 title)
agent/inbox/spliced排队输入并入下一轮 / 下一步不带 turnlisten
command/run、command/done原生命令的执行不带 turnlisten
permission/preset、sandbox/mode、approval/policy会话的权限、沙箱、审批策略不带 turnlisten
assistant/attempt作废的一次模型尝试是transformOutput;片段的撤回已内置(流式帧 end)
todo/write、plan/mode、goal/change、compaction/*、llm/retry、llm/retry-started、hook/invoked、hook/result、subagent/*、team/*、tool-workflow/*、tool/ptc-dispatch*、deliverables/presented、workspace/changes、feedback/*、model/selection 等上游列出的其余会话事件,含义见上游看 data.turn,适配器没有逐个实测同上两条规则
流式 chunk block-end、usage、finish块结束、用量、结束原因是transformOutput(kind: 'stream')
assistant/message 的 usage、interrupted、source;tool/result 的 meta、error用量、中断标记、模型来源、工具附加信息是transformOutput
emit:api-session/status、api-session/activity 等会话运行状态、最近活动否listen(args[0] 为会话 id 时才分发)
快照的 header、projections(todos、plan、tokenUsage、goal、sessionStats、subagent 等)、assistantStream会话元信息与投影否快照不分发给 listen;历史第一页带 projections(connection.history 的返回),不经 transformOutput;随时查用 connection.call('session/projections', { request: { sessionId } })
其它 waterfall 请求需要别的客户端回答的请求否连接直接回 next,不分发

listen 驱动插槽:

import type { DshRawItem } from '@volcengine/chatkit/adapters/dsh';

const off = connection.listen(sessionId, (item: DshRawItem) => {
  if (item.kind === 'emit') statusStore.push(item.event, item.args);
  else if (item.kind === 'event' && item.event.type === 'session/title') {
    const { title } = item.event.data as { title?: string };
    if (title) setTitle(title);
  }
});
// 卸载时 off()

transformOutput 把一步的结束变成 data 片段:

import type { OutputChange } from '@volcengine/chatkit/core';
import { createDshAdapter, type DshFeedItem } from '@volcengine/chatkit/adapters/dsh';

const adapter = createDshAdapter({
  connection,
  transformOutput: (item: DshFeedItem, context) => {
    if (item.kind !== 'event' || item.event.type !== 'step/end' || !context.messageId) return undefined;
    const { step } = item.event.data as { step: number };
    const change: OutputChange = {
      type: 'set-part',
      messageId: context.messageId,
      part: { id: `acme:step:${step}`, type: 'data', kind: 'acme.step-end', data: { step } },
    };
    return [...context.defaultChanges, change];
  },
});

刷新加载历史时,适配器把历史回放成与实时同形的会话事件(kind: 'event')逐条交给 transformOutput(context.source 为 history,带这一轮的 runId 与 messageId),从这一轮出现回答之后开始。流式帧和请求只在实时里有。上面的写法实时与刷新后都会生成同样的 data 片段。

审批与追问

上游请求

请求读取的字段
approval/requesttoolName、callId?、reason?、displayReason?(按语言的展示文案,如 en、zh)
user-questions/requestquestions[](id、question、header?、detail?、options?[].label、options?[].description、multiSelect?)、wait.callId?

挂在哪个工具片段上:

请求片段
审批tool:{callId};没有 callId 时 tool:approval:{eventId}
追问有 wait.callId 时 tool:{callId};否则最近一个还没结果、也没在等回答的 ask_user_question;都没有时新建 tool:question:{eventId}(不带 callId)

片段上的交互:metadata['chatkit.interaction']

用 getDshInteraction(part) 读。

字段审批(kind: 'approval')追问(kind: 'question')
requestId请求的 eventId;只来自 approval/asked 时没有请求的 eventId;历史里没有
status见下见下
callId被审批的工具调用 idask_user_question 的调用 id
toolName被审批的工具名—
reason后端给的原因—
displayReason多语言展示文案—
decisions['allowed-once', 'rejected']—
decision已回答时为 approval/decided 的 outcome—
questions—请求里的 questions;历史里取工具参数
answers—已回答时从工具结果解析出的 answers
状态什么时候
pending收到请求或 approval/asked;追问被后端撤回(cancel)后也回到这里,但去掉 requestId
answering调用了 respond,等后端确认;回答没送达时退回 pending,可以重试
answered审批:approval/decided(撤回后才到的也算);追问:工具有了结果。去掉 requestId
cancelled审批被后端撤回(cancel,只能按请求编号回答,撤回后答不了);或这一轮结束(turn/end)时仍是 pending / answering,在终态之前标记

connection.respond

connection.respond({ sessionId, requestId, callId, response });
参数含义
sessionId会话 id
requestId交互上的 requestId
callId追问的工具调用 id;请求已失效时按它补答
response审批:'allowed-once' 或 'rejected';追问:{ answers: [{ id, selected, custom? }] }

发出的原生回答:

情况请求
连接里还有这个 requestId 的请求api/$events/result,args { clientId, eventId: requestId, outcome: { kind: 'result', value: response } }
请求不在了(刷新后、被撤回)且是追问api/userQuestions/answer,args { agentId: sessionId, callId, answer: response }
其它不发请求,抛错"这个请求已经不能回答了";审批刷新后不能再回答

历史

项目说明
来源第一页先调 session/projections 取投影与当前位置 asOfSeq,再调 session/page;更早的页按游标调 session/page。加载历史不打开 follow
原始结构{ records: [{ type: 'event', event }], hasMore, cursor?, projections? }(投影只在第一页),只读 type === 'event' 的记录,按 seq 排序
分轮turn/start 到 turn/end 是一轮;第一个 turn/start 之前的事件不进消息
轮次编号这一轮第一条用户输入的 source.rpcId(本地发起时就是 runId),没有时 dsh-turn:{turn}
轮次结果turn/end 给出 completed / interrupted / failed,origin: 'remote',position 为 turn/end 的 seq,开始、结束时间取两端的 time;还没结束的一轮只给消息
同构与实时共用同一套组装:消息 user:{rpcId}、assistant:{runId},片段 s{step}:b{n}、tool:{callId},用户片段与本地投影同规则
追问历史里没有追问请求:ask_user_question 有结果时从参数和结果补出 answered 交互;projections.userQuestions.active 里的补成 pending(只有 callId)
标题projections.title 放进 session.title
分页session/page 按消息条数(maxMessages)截页,可能切在一轮中间:页首不完整的那一轮留给更早的一页,一页里没有 turn/start 时继续往前取。还有更早内容时给游标 {throughSeq}:{第一个 turn/start 的 seq},下一页作为 beforeSeq。不给 activeRun(不支持续接)

不保留的内容:

  • 只在流式帧里出现过、没有落盘的内容(被撤回的 attempt)。
  • 审批的 requestId 与 displayReason;刷新后审批不能回答。
  • source.kind 不是 user 的用户消息。
  • 发送时合并进文本的引用,还原后只是文本;图片的 size 按 base64 长度估算。
  • 同一轮的用户消息(含插话)在历史里都排在回答之前。

编号

ChatKit 编号来源
sessionIdDSH 会话 id(session/create 返回),即 $events 的 agentId
runId实时:ChatKit 生成,发送时作为 requestId;历史:首条用户输入的 source.rpcId,或 dsh-turn:{turn}
用户消息 iduser:{source.rpcId},没有 rpcId 时 user:{data.id}
回答消息 idassistant:{runId},一轮一条
正文 / 思考片段 ids{step}:b{n}:流式时 n 是块的 index,落盘时是 content 里的序号
工具片段 idtool:{callId};只有审批没有调用时 tool:approval:{id},只有追问时 tool:question:{eventId}
toolCallIdtool/call 的 callId、tool-call 内容的 id、tool/result 的 message.toolCallId
交互 requestId$events 请求的 eventId
事件编号 eventId会话事件 seq:{seq};流式帧 stream:{attemptId}:{revision};请求 request:{eventId}

结束与错误

只有读到这一轮的 turn/end 才结束。结束前先把仍在等待的交互标为 cancelled;没有正常完成时,还在执行的工具片段标为 cancelled。

reason.kind终态错误码
completedcompleted—
abortedinterrupted—(历史里 interruptReason: 'protocol')
error、hook、max-tokens、其它failedDSH_TURN_{KIND},如 DSH_TURN_ERROR、DSH_TURN_MAX_TOKENS

失败的 message 取 reason.message,其次 reason.error.message;都没有时 max-tokens 为"回答超出长度上限",其它为"Agent 没能完成这一轮"。cause 是整个 reason。

错误类什么时候字段
DshRemoteErrorserver-response 的 ok: false,或 mux 的 error 帧code(缺省 DSH_REMOTE_ERROR)、details
DshRequestErrorHTTP 非 2xx,或回复不是约定的信封status
DshConnectionClosedErrorWebSocket 断开、连接没能建立、follow 流结束、调用了 close()—

工具失败的片段错误码是 DSH_TOOL_ERROR;不支持的附件见上文"发送"一节。错误消息不带地址。

断线:WebSocket 断开时全部逻辑流失效,正在读的轮次抛 DshConnectionClosedError,由 Core 标记连接中断;不认定执行结束,不自动重发,经历史恢复。下次使用时重新连接;有 listen 监听时自动重连,见下文。停止是会话级的 session/cancel,受理只代表请求送达,以 turn/end(aborted)为准。

listen 断线重连

连接断开(或这个会话的订阅单独结束)时,有 listen 监听的会话:

  1. 监听先收到 { kind: 'connection', status: 'disconnected' };
  2. 连接按退避自动重连并重新订阅(重新打开 session/follow 与 $events):第一次等 reconnect.minDelayMs(缺省 1000),之后翻倍,最多等 reconnect.maxDelayMs(缺省 30000);
  3. 订阅恢复后收到 { kind: 'connection', status: 'reconnected' }。断线期间的事件不会补发,需要时自行补拉状态(如 session/projections)。

每次重新订阅失败经 onError('listen', error) 报告,之后继续重试。取消全部监听或调用 close() 后停止;连接选项 reconnect: false 关闭自动重连(仍会收到 disconnected)。正在进行的一轮不续接,仍由会话层经历史确认终态。

示例

follow 流里的一条会话事件(mux item 帧):

{
  "type": "item",
  "streamId": "<follow 流>",
  "value": {
    "type": "event",
    "event": {
      "type": "tool/call",
      "seq": 33,
      "time": 1700000000033,
      "data": { "turn": 3, "step": 1, "callId": "call_1", "name": "bash", "arguments": "{\"command\":\"printf ok\"}" }
    }
  }
}

审批请求,以及 respond({ sessionId, requestId: 'wf-1', response: 'allowed-once' }) 发出的回答:

{
  "type": "waterfall",
  "event": "approval/request",
  "eventId": "wf-1",
  "agentId": "<sessionId>",
  "request": { "toolName": "write", "callId": "call_w", "reason": "escalate" }
}
{
  "type": "client-request",
  "rpcId": "<随机>",
  "method": "$events/result",
  "payload": {
    "args": {
      "clientId": "<ready 给的 clientId>",
      "eventId": "wf-1",
      "outcome": { "kind": "result", "value": "allowed-once" }
    }
  }
}

其余字段见上游。