DSH

DeepSeek Harness(DSH)是一个编程 Agent,Web 服务在服务端保存会话、历史与执行状态。

这一页说明怎么接 DSH 的 Web 远程协议:建连接、建会话、审批与追问卡片、会话级事件,以及这个协议要注意的地方。请求与事件的字段见 DSH 协议参考。

结论

  • 适配器自带连接 createDshConnection:DSH 的流复用在一条 WebSocket(api/remote.mux)上,审批与追问是后端反过来等客户端回答的请求,能力函数不用接入方写。地址与鉴权由连接选项决定。
  • 整个应用一个连接、一个适配器实例;ChatKit 的 sessionId 就是 DSH 的会话 id。
  • 审批与追问挂在工具片段上,用工具渲染器画卡片,按钮调 connection.respond。
  • 只支持请求流模式;不支持续接。
能力接入方式说明
发送内置session/prompt(mode: queue),requestId 用 ChatKit 的 runId
停止内置session/cancel,会话级;等后端的 turn/end(aborted)才结束
插话内置session/prompt(mode: steer),插话的用户消息出现在同一轮
历史内置session/projections + session/page,从新到旧分页,与实时同一套消息、片段编号
审批卡片自画回答 allowed-once / rejected
追问卡片自画回答每个问题选中的选项
续接不支持断线后这一轮标记连接中断,由会话层经历史确认结果
会话目录应用维护创建经 connection.call('session/create', …),列表、改名、删除也经 connection.call

建连接

dsh-connection.ts
import { createDshAdapter, createDshConnection } from '@volcengine/chatkit/adapters/dsh';

// DSH Web 服务的地址(可以带路径前缀,如走自己的网关);协议路径(`api/session/prompt`、`api/remote.mux`)由连接追加
const BASE = 'https://example.com/dsh';

// 整个应用一个连接、一个适配器实例;会话 id 在每次调用时带上
export const connection = createDshConnection({
  // kind 是 'http'(单次调用)或 'ws'(remote.mux);鉴权参数放在地址上时在这里追加
  resolveUrl: (path, kind) => `${kind === 'ws' ? BASE.replace(/^http/, 'ws') : BASE}/${path}`,
  // 历史每页最多多少条用户与回答消息;不给时由后端决定
  historyMaxMessages: 50,
});

export const adapter = createDshAdapter({ connection });
选项作用
resolveUrl把协议路径解析成完整地址;kind 为 http(单次调用)或 ws(remote.mux)
fetch、WebSocket替换实现:要加请求头、走自己的代理时传
historyMaxMessages历史每页最多多少条用户与回答消息
reconnectlisten 断线后自动重新订阅的退避间隔,缺省 1s 起、翻倍、最多 30s;false 关闭
onError连接内部异常的报告(帧解析、监听回调、重新订阅失败…),没给时打到控制台;错误消息不带地址

浏览器直连时,DSH 服务要允许跨域与 WebSocket;生产环境建议走自己的服务转发并校验登录态,地址前缀写进 BASE。

会话与界面

dsh-app.tsx
import '@volcengine/chatkit/styles.css';

import { Chatkit, SessionList, type SessionListProps, useChatSessions } from '@volcengine/chatkit';

import { adapter, connection } from './dsh-connection';
import { interactionRenderers } from './dsh-interaction';

// 会话由应用创建,id 存进自己的会话目录
export async function createSession(cwd: string): Promise<string> {
  const { sessionId } = await connection.call<{ sessionId: string }>('session/create', { request: { cwd } });
  return sessionId;
}

export function App({
  sessions: items,
  activeSessionId,
  onSelect,
}: Pick<SessionListProps, 'sessions' | 'activeSessionId' | 'onSelect'>) {
  const sessions = useChatSessions({ activeSessionId, adapter });
  return (
    <div style={{ display: 'flex', height: '100vh' }}>
      <SessionList sessions={items} activeSessionId={activeSessionId} onSelect={onSelect} style={{ width: 260 }} />
      <div style={{ flex: 1, minWidth: 0 }}>
        <Chatkit sessions={sessions} renderers={interactionRenderers} />
      </div>
    </div>
  );
}

会话目录由应用维护:创建后把 sessionId 存进自己的目录,交给 SessionList 与 useChatSessions。切到一个会话时,useChatSessions 经适配器取历史;往上翻时按页取更早的轮次。

审批与追问

dsh-interaction.tsx
import { type ChatkitProps, type ChatkitToolViewProps, useCurrentSession } from '@volcengine/chatkit';
import { type DshInteraction, getDshInteraction } from '@volcengine/chatkit/adapters/dsh';

import { connection } from './dsh-connection';

/** 默认的工具行照常显示;工具上挂着等待回答的审批或追问时,在下面加一张卡片。 */
function ToolWithInteraction({ metadata, defaultNode }: ChatkitToolViewProps) {
  const interaction = getDshInteraction({ metadata });
  return (
    <>
      {defaultNode}
      {interaction && <InteractionCard interaction={interaction} />}
    </>
  );
}

function InteractionCard({ interaction }: { interaction: DshInteraction }) {
  const sessionId = useCurrentSession().session?.id;
  // 只有 pending 时能回答;answering 是已经点了、等后端确认
  const disabled = interaction.status !== 'pending' || !sessionId;
  const answer = (response: Parameters<typeof connection.respond>[0]['response']) => {
    if (!sessionId) return;
    void connection
      .respond({
        sessionId,
        ...(interaction.requestId ? { requestId: interaction.requestId } : {}),
        ...(interaction.callId ? { callId: interaction.callId } : {}),
        response,
      })
      // 没送达时状态会退回 pending,可以再点一次
      .catch(error => console.error(error));
  };

  if (interaction.kind === 'approval')
    return (
      <div className="approval-card">
        <p>{interaction.reason ?? `允许执行 ${interaction.toolName}?`}</p>
        {interaction.status === 'answered' ? (
          <p>已回答:{interaction.decision}</p>
        ) : (
          <>
            <button disabled={disabled} onClick={() => answer('allowed-once')}>
              允许一次
            </button>
            <button disabled={disabled} onClick={() => answer('rejected')}>
              拒绝
            </button>
          </>
        )}
      </div>
    );

  return (
    <div className="question-card">
      {interaction.questions.map(question => (
        <div key={question.id}>
          <p>{question.question}</p>
          {question.options?.map(option => (
            <button
              key={option.label}
              disabled={disabled}
              onClick={() => answer({ answers: [{ id: question.id, selected: [option.label] }] })}
            >
              {option.label}
            </button>
          ))}
        </div>
      ))}
    </div>
  );
}

// 审批挂在被审批的那个工具上(write、bash…),追问挂在 ask_user_question 上:用 match 接住全部工具
export const interactionRenderers: ChatkitProps['renderers'] = {
  tools: { interactions: { match: () => true, render: ToolWithInteraction } },
};
交互挂在哪回答
审批被审批的那个工具(write、bash…)response: 'allowed-once' 或 'rejected'
追问ask_user_question 工具response: { answers: [{ id, selected: [...], custom? }] },每个问题一项

状态依次是 pending(等待回答)→ answering(已调用 respond,等后端确认)→ answered。回答没送达时 respond 抛错、状态退回 pending,可以重试;按钮在非 pending 时置灰即可。等待回答时这一轮的阶段是 awaiting-external。这一轮结束时仍在等待的请求标为 cancelled。审批被后端撤回(如别的客户端先做了决定)时也标为 cancelled,之后收到后端的结论再改成 answered。

  • 工具渲染器的参数里没有会话 id,卡片里用 useCurrentSession().session?.id 取。
  • 只有这个会话有轮次在读时,连接才认领审批与追问请求;否则交给其他客户端(如 DSH 自己的界面)。
  • 刷新后,已回答的追问从工具的参数与结果还原;还在等待的追问从会话投影还原,只有 callId,回答时连接改用 userQuestions/answer。审批的请求编号不进历史,刷新后审批不能再回答。

会话级事件

标题、待办、用量这类内容不在消息里。connection.listen(sessionId, listener) 收到这个会话的全部原始内容,用它驱动插槽:

dsh-listen.tsx
import { useEffect, useState } from 'react';

import { connection } from './dsh-connection';

/** 会话标题:先取投影,之后跟着 session/title 事件更新;断线重连后再取一次投影,补上断线期间的变化。 */
export function useDshSessionTitle(sessionId: string | undefined) {
  const [title, setTitle] = useState<string>();
  const [online, setOnline] = useState(true);
  useEffect(() => {
    if (!sessionId) return;
    let active = true;
    const refetch = () =>
      connection
        .call<{ values?: { title?: string | null } }>('session/projections', { request: { sessionId } })
        .then(result => active && setTitle(result.values?.title ?? undefined))
        .catch(() => undefined);
    void refetch();
    const off = connection.listen(sessionId, item => {
      if (item.kind === 'connection') {
        setOnline(item.status === 'reconnected');
        if (item.status === 'reconnected') void refetch();
        return;
      }
      if (item.kind === 'event' && item.event.type === 'session/title')
        setTitle((item.event.data as { title?: string }).title);
    });
    return () => {
      active = false;
      off();
    };
  }, [sessionId]);
  return { title, online };
}

连接断开时监听先收到 { kind: 'connection', status: 'disconnected' },连接自动重新订阅后收到 reconnected。断线期间的事件不补发,像上面这样在 reconnected 时补拉一次状态。

业务内容

本轮内的事件也可以在 transformOutput 里转成 data 片段显示在回答里。刷新加载历史时,适配器把历史回放成同形的会话事件逐条交给它,同一段逻辑在刷新后也能重建业务内容;流式帧与请求只在实时里有。哪些事件属于某一轮、怎么拿,见协议参考的没有内置处理的事件。

每一轮的额外参数(模型、模式…)用 transformInput 加到 session/prompt 的请求里,返回完整请求体。

输入怎么放进请求

输入里的放在哪
正文content 里的文本
引用转成文本合并进正文
附件只接受内联来源的 PNG / JPEG / WebP / GIF 图片(DSH_ACCEPTED_FILE_TYPES),作为图片内容

输入里带了不支持的附件时不发请求,这一轮以 UNSUPPORTED_ATTACHMENT 失败。

错误

情况错误
HTTP 调用返回非 2xxDshRequestError(status),消息只带方法名,不带地址
调用返回 ok: falseDshRemoteError(后端的 code 与 details)
WebSocket 断开或连接已关闭DshConnectionClosedError;正在读的轮次标记连接中断,不认定结束
这一轮以失败结束(turn/end)错误码 DSH_TURN_{原因}(如 DSH_TURN_ERROR),说明取后端给的 message

已知限制

  • 只支持请求流模式:同一会话里由其他客户端发起的轮次不实时显示,刷新后从历史出现。
  • 不支持续接:断线后这一轮标记连接中断,由会话层经历史确认结果。
  • 插话的用户消息实时显示在回答之后,刷新后按历史排在回答之前。
  • 历史不保留只在流式帧里出现过、没有落盘的内容,以及审批的请求编号。