Hermes Dashboard

Hermes Agent 是 Nous Research 的开源 Agent。它的 Dashboard(Hermes 内部叫 tui_gateway,TUI 与网页 Dashboard 共用)在一条 WebSocket 上跑 JSON-RPC,服务端保存会话与历史。

这一页说明怎么接 Hermes Dashboard:建连接、建会话、审批与追问卡片、会话级事件,以及这个协议要注意的地方。它和 Hermes(API server)是两套协议、两个入口(adapters/hermes-gateway 与 adapters/hermes),接 Dashboard 不需要开启 API server。请求与事件的字段见 Hermes Dashboard 协议参考。

结论

  • 适配器自带连接 createHermesGatewayConnection:请求与事件走一条 WebSocket(api/ws),历史走 Dashboard 的 REST(api/sessions/{id}/messages),审批与追问由服务端推送、客户端回答,能力函数不用接入方写。地址与外层鉴权由连接选项决定。
  • 整个应用一个连接、一个适配器实例;ChatKit 的 sessionId 是 Hermes 的持久会话编号(stored_session_id)。连接上的 live 编号由连接维护,断线重连后重新 session.resume。
  • 审批与追问挂在工具片段上,用工具渲染器画卡片,按钮调 connection.respond。
  • 只支持请求流模式;不支持续接。
能力接入方式说明
发送内置prompt.submit,固定带 queued: true:会话忙时排到正在执行的那一轮后面,不打断它
停止内置session.interrupt,会话级;等后端的 message.complete(interrupted)才结束
插话内置session.steer,指令附在下一次工具结果上,不单独显示为用户消息
历史内置REST 消息行,一次取完、不分页,与实时同一套片段编号
审批卡片自画回答 choices 里的一项(如 once / deny);需要实例的 approvals.mode 为 manual
追问卡片自画回答答案文本
阻塞请求应用处理sudo 密码、secret、读取终端:适配器不处理,经 connection.listen 拿到、connection.request 回答
续接不支持断线后这一轮标记连接中断,由会话层经历史确认结果
会话目录应用维护创建经 connection.createSession,其他方法经 connection.request(会话参数先 liveSessionId 换算)

建连接

hermes-gateway-connection.ts
import { createHermesGatewayAdapter, createHermesGatewayConnection } from '@volcengine/chatkit/adapters/hermes-gateway';

// Hermes Dashboard 的地址(可以带路径前缀,如走自己的网关);协议路径(`chat`、`api/ws`、`api/sessions/{id}/messages`)由连接追加
const BASE = 'https://example.com/hermes';

// 整个应用一个连接、一个适配器实例;会话 id 在每次调用时带上
export const connection = createHermesGatewayConnection({
  // kind 是 'http'(取 token 的页面、历史)或 'ws'(api/ws);外层鉴权参数放在地址上时在这里追加
  resolveUrl: (path, kind) => `${kind === 'ws' ? BASE.replace(/^http/, 'ws') : BASE}/${path}`,
  // 不给时每次建连从 `chat` 页面里提取 session token;token 由自己的服务下发时在这里返回
  // sessionToken: () => fetchTokenFromMyServer(),
});

export const adapter = createHermesGatewayAdapter({ connection });
选项作用
resolveUrl把协议路径解析成完整地址;kind 为 http(chat 页面、历史)或 ws(api/ws,要返回 ws(s) 地址)
sessionToken取 Dashboard 的 session token;缺省从 chat 页面里提取。每次建连都重新取,加在 WS 地址与历史请求上
fetch、WebSocket替换实现:要加请求头、走自己的代理时传
reconnectlisten 断线后自动重新订阅的退避间隔,缺省 1s 起、翻倍、最多 30s;false 关闭
onError连接内部异常的报告(帧解析、监听回调、重新订阅失败…),没给时打到控制台;不带地址与凭据

session token 能操作整个实例,生产环境建议走自己的服务转发并校验登录态,地址前缀写进 BASE。

会话与界面

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

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

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

// 会话由应用创建,返回的是 Hermes 的持久会话编号,存进自己的会话目录
export async function createSession(cwd: string): Promise<string> {
  const { sessionId } = await connection.createSession({ 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>
  );
}

会话目录由应用维护:connection.createSession(params) 把参数原样交给 session.create,返回持久编号 sessionId,存进自己的目录,交给 SessionList 与 useChatSessions。切到一个会话时,useChatSessions 经适配器取历史,一次取完。

Hermes 不保存客户端的轮次编号:同一页面里,历史的最后几轮按顺序对应回本地结束过的轮次;页面重新加载后按消息行 id 编号(hermes-row:{id})。

审批与追问

hermes-gateway-interaction.tsx
import { type ChatkitProps, type ChatkitToolViewProps, useCurrentSession } from '@volcengine/chatkit';
import {
  getHermesGatewayInteraction,
  type HermesGatewayInteraction,
} from '@volcengine/chatkit/adapters/hermes-gateway';
import { useState } from 'react';

import { connection } from './hermes-gateway-connection';

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

function InteractionCard({ interaction }: { interaction: HermesGatewayInteraction }) {
  const sessionId = useCurrentSession().session?.id;
  const [text, setText] = useState('');
  // 只有 pending 时能回答;answering 是已经点了、等后端确认
  const disabled = interaction.status !== 'pending' || !sessionId || !interaction.requestId;
  // 审批给 choices 里的一项,追问给答案文本
  const answer = (response: string) => {
    if (!sessionId || !interaction.requestId) return;
    void connection
      .respond({ sessionId, requestId: interaction.requestId, response })
      // 没送达时状态会退回 pending,可以再点一次
      .catch(error => console.error(error));
  };

  if (interaction.kind === 'approval')
    return (
      <div className="approval-card">
        <p>{interaction.description ?? '允许执行这条命令?'}</p>
        {interaction.command && <pre>{interaction.command}</pre>}
        {interaction.status === 'answered' ? (
          <p>已回答:{interaction.choice}</p>
        ) : (
          interaction.choices.map(choice => (
            <button key={choice} disabled={disabled} onClick={() => answer(choice)}>
              {choice}
            </button>
          ))
        )}
      </div>
    );

  return (
    <div className="question-card">
      <p>{interaction.question}</p>
      {interaction.status === 'answered' ? (
        <p>已回答:{interaction.answer}</p>
      ) : interaction.choices.length ? (
        interaction.choices.map(choice => (
          <button key={choice} disabled={disabled} onClick={() => answer(choice)}>
            {choice}
          </button>
        ))
      ) : (
        // choices 为空时是自由回答
        <form
          onSubmit={event => {
            event.preventDefault();
            answer(text);
          }}
        >
          <input value={text} disabled={disabled} onChange={event => setText(event.target.value)} />
          <button disabled={disabled || !text}>回答</button>
        </form>
      )}
    </div>
  );
}

// 审批挂在被拦下时正在执行的工具上(如 terminal),追问挂在 clarify 上:用 match 接住全部工具
export const interactionRenderers: ChatkitProps['renderers'] = {
  tools: { interactions: { match: () => true, render: ToolWithInteraction } },
};
交互挂在哪回答
审批被拦下时正在执行的工具;没有时新建 tool:approval:{n}response 为 choices 里的一项,缺省 once / deny
追问正在执行的 clarify 工具response 为答案文本;choices 为空时是自由回答

状态依次是 pending(等待回答)→ answering(已调用 respond,等后端确认)→ answered。回答没送达时 respond 抛错、状态退回 pending,可以重试;按钮在非 pending 时置灰即可。等待回答时这一轮的阶段是 awaiting-external。追问过期(clarify.expire)或这一轮结束时仍在等待的请求标为 cancelled。

  • 工具渲染器的参数里没有会话 id,卡片里用 useCurrentSession().session?.id 取。
  • Hermes 的审批没有编号,连接生成 approval:{n} 作为 requestId;追问用 Hermes 给的 request_id。
  • 实例的 approvals.mode 为缺省的 smart 时,审批由 Hermes 自动批准,不交给客户端。
  • 刷新后,已回答的追问从 clarify 工具结果还原;审批不进历史。

会话级事件

标题、会话信息、阻塞请求这类内容不在消息里。connection.listen(sessionId, listener) 收到这个会话的全部原始内容,用它驱动插槽或回答阻塞请求:

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

import { connection } from './hermes-gateway-connection';

/**
 * 会话标题与信息:跟着 session.title、session.info 事件更新。
 * 断线期间的变化不补发:先取一次、重连后再取一次。不带 title 调 session.title 返回当前标题,并推一次 session.info。
 */
export function useHermesSessionInfo(sessionId: string | undefined) {
  const [title, setTitle] = useState<string>();
  const [info, setInfo] = useState<Record<string, unknown>>();
  const [online, setOnline] = useState(true);
  useEffect(() => {
    if (!sessionId) return;
    let active = true;
    const refetch = async () => {
      // 方法参数里的 session_id 是这条连接上的 live 编号
      const live = await connection.liveSessionId(sessionId);
      const result = await connection.request<{ title?: string }>('session.title', { session_id: live });
      if (active) setTitle(result.title || undefined);
    };
    void refetch().catch(() => undefined);
    const off = connection.listen(sessionId, item => {
      if (item.kind === 'connection') {
        setOnline(item.status === 'reconnected');
        if (item.status === 'reconnected') void refetch().catch(() => undefined);
        return;
      }
      if (item.kind !== 'event') return;
      const { type, payload } = item.event;
      if (type === 'session.title' && typeof payload?.title === 'string') setTitle(payload.title);
      if (type === 'session.info') setInfo(payload);
    });
    return () => {
      active = false;
      off();
    };
  }, [sessionId]);
  return { title, info, online };
}

/** sudo 密码这类阻塞请求适配器不处理:经 listen 拿到,用 connection.request 回答;不回答时工具等到超时。 */
export function useHermesSudoPrompt(sessionId: string | undefined) {
  useEffect(() => {
    if (!sessionId) return;
    return connection.listen(sessionId, item => {
      if (item.kind !== 'event' || item.event.type !== 'sudo.request') return;
      const password = window.prompt('sudo 密码') ?? '';
      void connection
        .request('sudo.respond', { request_id: item.event.payload?.request_id, password })
        .catch(error => console.error(error));
    });
  }, [sessionId]);
}

连接断开时监听先收到 { kind: 'connection', status: 'disconnected' },连接自动重新取 token、建连并 session.resume 后收到 reconnected。断线期间的事件不补发,像上面这样在 reconnected 时补拉一次状态(不带 title 调 session.title 返回当前标题,并推一次 session.info)。阻塞请求的参数是 request_id 加 password / value / text,不回答时工具会等到超时。

业务内容

本轮内的事件(如 message.interim、subagent.*、message.complete 的 usage)也可以在 transformOutput 里转成 data 片段显示在回答里。刷新加载历史时,适配器把消息行回放成同形的事件逐条交给它,同一段逻辑在刷新后也能重建正文、思考与工具相关的内容;只在实时里推送的事件不在历史里。哪些事件属于某一轮、怎么拿,见协议参考的没有内置处理的事件。

每一轮的额外参数用 transformInput 加到 prompt.submit 的请求里,返回完整请求体;session_id 与 queued 由连接覆盖。

输入怎么放进请求

输入里的放在哪
正文prompt.submit 的 text
引用转成文本,按顺序拼进 text
附件只接受内联来源的图片(HERMES_GATEWAY_ACCEPTED_FILE_TYPES),发送前逐张经 image.attach_bytes 上传

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

错误

情况错误
请求返回 JSON-RPC 错误HermesGatewayRpcError(code 为 Hermes 的错误码)
WebSocket 断开、建连失败或 close() 之后HermesGatewayClosedError;正在读的轮次标记连接中断,不认定结束
prompt.submit 没受理、插话被拒、取不到 token、历史返回 404 以外的非 2xxError,消息不带地址
这一轮以失败结束(message.complete 的 error 状态,或 error 事件)错误码 HERMES_TURN_FAILED,说明取后端给的消息
message.complete 的 interrupted 状态这一轮按中断结束
工具结果带非空 error工具片段 failed(HERMES_TOOL_ERROR),不影响这一轮

已知限制

  • 只支持请求流模式:同一会话里由其他客户端发起的轮次不实时显示,刷新后从历史出现。
  • 不支持续接:断线后这一轮标记连接中断,由会话层经历史确认结果。
  • 历史在一轮结束时才写入,一次取完不分页;不记录轮次是否被中断或失败(刷新后都按完成显示)、被中断那一轮的思考与部分正文、审批记录、插话与用户消息里的图片。
  • 同一会话有别的客户端同时写入时,历史与本地轮次的对应可能错位。
  • tui_gateway 是 Hermes 的内部接口,随版本变化;适配器按 Hermes 0.20.0 核对。