解构 Agent CLI:从 React 子进程到 Python 回调的通信协议

How Hermes Agent TUI and CLI Communicate with the Agent Loop — Architecture Deep Dive

上周团队在讨论 Agent 产品的交互方案。前端同学主张用 Web UI + WebSocket,理由是现代、可扩展、支持多端。后端同学说那不如直接嵌到现有产品里,用 HTTP REST API。

我打开了我们自己的 Agent 代码仓库,给他们看了两套实现——一套是纯 Python 的同进程架构,另一套是 React + Python 子进程通过 JSON-RPC 通信。

“我们同一个产品里,两套通信协议并存。”

他们沉默了几秒。“为什么?”

因为这不是架构设计的失误,而是交互范式的必然分化。CLI 要的是零延迟的本地体验,TUI 要的是跨进程的事件驱动架构。用一套协议去套两种场景,只会两头都不讨好。

今天我们就来解剖一下 Hermes Agent 的两种交互架构——看看 CLI 和 TUI 分别是怎么和 Agent Loop 通信的,协议是什么,为什么这么设计。

整体架构:三层模型

在深入细节之前,先建立全局视角。Hermes Agent 的交互层可以抽象为三层:

┌──────────────────────────────────────────────────────────────┐
  Layer 1: Frontend (UI)                                       
  CLI:  prompt_toolkit (Python)                               
  TUI:  Ink/React (Node.js/TypeScript)                        
├──────────────────────────────────────────────────────────────┤
  Layer 2: Transport                                           
  CLI:  同进程函数引用 + queue.Queue                            
  TUI:  JSON-RPC 2.0 over stdio (stdin/stdout)               
├──────────────────────────────────────────────────────────────┤
  Layer 3: AIAgent (run_agent.py)                              
  核心对话循环:chat() / run_conversation()                    
  回调注入:callbacks dict  直接调用或事件推送                  
└──────────────────────────────────────────────────────────────┘

Agent Loop 是同一个——run_agent.py 里的 AIAgent.run_conversation()。它不关心谁在调用它,只关心回调函数怎么注册。两种架构的差异完全在 Layer 1 到 Layer 2

CLI 架构:同进程直连

CLI 的核心理念是零中间层。用户启动 hermes 命令后,整个过程都在一个 Python 进程内完成。

关键组件

组件文件职责
HermesCLIcli.py (~10,570 行)REPL 循环、命令分发、状态管理
AIAgentrun_agent.py对话循环、工具调用、模型推理
prompt_toolkit第三方库终端 UI(输入框、补全、布局)

Agent 初始化:回调函数直接注入

# cli.py: _init_agent()
self.agent = AIAgent(
    model=effective_model,
    platform="cli",
    session_id=self.session_id,
    session_db=self._session_db,
    # 直接传递函数引用——不是序列化,不是网络调用
    clarify_callback=self._clarify_callback,
    reasoning_callback=self._current_reasoning_callback(),
    thinking_callback=self._on_thinking,
    status_callback=self._on_status,
    tool_start_callback=self._on_tool_start,
    tool_complete_callback=self._on_tool_complete,
    stream_delta_callback=self._stream_delta,
    tool_progress_callback=self._on_tool_progress,
)
# generated by hugo AI

这些是普通的 Python 函数对象。Agent Loop 在执行过程中直接调用它们,就像调用 print() 一样自然。没有任何序列化开销,没有任何网络延迟。

主循环:prompt_toolkit 事件循环

CLI 的 run() 方法构建了一个 prompt_toolkit Application,核心是 KeyBindings:

Enter 键 → 路由到正确的队列
  ├─ sudo/approval/clarify 状态中 → 回答放进 response_queue
  ├─ Agent 正在运行 → 放入 _interrupt_queue(中断)
  └─ Agent 空闲 → 放入 _pending_input(正常输入)

Ctrl+C → 优先级链
  ├─ 取消语音录制
  ├─ 取消当前模态提示(sudo/clarify/approval)
  ├─ Agent 运行中 → interrupt()
  └─ Agent 空闲 → 退出

chat() 的线程模型

def chat(self, message, images=None):
    # Agent 在后台线程运行
    agent_thread = Thread(target=run_agent, daemon=True)
    agent_thread.start()
    
    # 主线程监控中断队列
    while agent_thread.is_alive():
        try:
            interrupt_msg = self._interrupt_queue.get(timeout=0.1)
            if interrupt_msg:
                self.agent.interrupt(interrupt_msg)
                break
        except queue.Empty:
            self._invalidate()  # 刷新 prompt_toolkit 渲染
# generated by hugo AI

关键设计:Agent 线程和 UI 主线程是分离的。Agent 线程阻塞在 API 调用或工具执行上,UI 线程继续处理键盘输入。这就是为什么你可以在 Agent 思考时随时打断它。

阻塞式交互:clarify 回调

当 Agent 需要用户澄清问题时,_clarify_callback 被调用:

def _clarify_callback(self, question, choices):
    response_queue = queue.Queue()
    
    # 1. 设置 UI 状态 → prompt_toolkit 切换为选择模式
    self._clarify_state = {
        "question": question,
        "choices": choices,
        "selected": 0,
        "response_queue": response_queue,
    }
    self._invalidate()  # 触发 UI 重绘
    
    # 2. 阻塞等待(在 Agent 线程中)
    while True:
        try:
            result = response_queue.get(timeout=1)  # ← 阻塞
            return result
        except queue.Empty:
            # 刷新倒计时显示
            if should_refresh():
                self._invalidate()
    
    # 3. 超时兜底
    return "The user did not respond. Use your best judgement."
# generated by hugo AI

精妙之处response_queue.get() 阻塞的是 Agent 线程,不是 UI 线程。prompt_toolkit 主事件循环继续运行,用户的 ↑↓ 选择、Enter 确认都正常响应。当用户按 Enter 时,KeyBinding 把答案 put 进 queue,Agent 线程被唤醒返回。

Agent 线程:  _clarify_callback() ──→ response_queue.get() [阻塞]
                                             
UI 主线程:   prompt_toolkit 事件循环 ──→ Enter  response_queue.put(answer)

CLI 回调类型汇总

回调触发时机阻塞方式返回类型
clarify_callbackAgent 需要澄清queue.get()str
thinking_callback思考过程流式输出非阻塞None
reasoning_callback推理过程输出非阻塞None
tool_progress_callback工具执行进度非阻塞None
stream_delta_callback模型 token 流式输出非阻塞None
status_callback状态变更非阻塞None

TUI 架构:跨进程 JSON-RPC

TUI 走的是完全不同的路线。它是一个 Node.js 进程(React/Ink) spawn 一个 Python 子进程,两者通过 stdio 传递 JSON 行。

关键组件

组件文件语言职责
GatewayClientui-tui/src/gatewayClient.tsTypeScript子进程管理、RPC 请求、事件分发
TUI Gatewaytui_gateway/server.py (~2,931 行)PythonRPC 方法处理、会话管理、Agent 工厂
SlashWorkertui_gateway/slash_worker.pyPython斜杠命令的子进程
AIAgentrun_agent.pyPython对话循环(同 CLI)

通信协议:JSON-RPC 2.0 over stdio

Node.js (stdin) ──JSON──→ Python
Node.js (stdout) ←─JSON── Python
Node.js (stderr) ←─文本── Python (日志)

请求格式(TUI → Python):

{"jsonrpc": "2.0", "id": "r1", "method": "prompt.submit", "params": {"session_id": "abc", "text": "hello"}}

响应格式(Python → TUI):

{"jsonrpc": "2.0", "id": "r1", "result": {"status": "streaming"}}

事件推送(Python → TUI,无 id):

{"jsonrpc": "2.0", "method": "event", "params": {"type": "message.delta", "session_id": "abc", "payload": {"text": "Hel"}}}

GatewayClient 的请求/响应机制

// gatewayClient.ts
request<T>(method: string, params: {}): Promise<T> {
    const id = `r${++this.reqId}`;
    
    // 写入 stdin
    this.proc.stdin.write(
        JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'
    );
    
    // 返回 Promise,等待响应
    return new Promise((resolve, reject) => {
        const timeout = setTimeout(() => reject(new Error('timeout')), 120000);
        this.pending.set(id, { resolve: v => { clearTimeout(timeout); resolve(v); }, reject });
    });
}

// 收到 stdout 行时分发
private dispatch(msg: Record<string, unknown>) {
    const id = msg.id as string | undefined;
    const p = id ? this.pending.get(id) : undefined;
    
    if (p) {
        // 有 id → RPC 响应 → resolve/reject Promise
        this.pending.delete(id);
        msg.error ? p.reject(new Error(msg.error.message)) : p.resolve(msg.result);
        return;
    }
    
    if (msg.method === 'event') {
        // method == "event" → 事件推送 → emit 给 React
        this.publish(msg.params);
    }
}

一句话:TUI 用 Map<id, Promise> 来匹配请求和响应,事件推送则通过 EventEmitter 广播给 UI 组件。

Python 端的防污染设计

Python 进程有一个关键设计——把 stdout 重定向到 stderr,只用 _real_stdout 写 JSON-RPC:

# tui_gateway/server.py
_real_stdout = sys.stdout
sys.stdout = sys.stderr  # 防止第三方库 print() 污染协议

def write_json(obj: dict) -> bool:
    line = json.dumps(obj, ensure_ascii=False) + "\n"
    _real_stdout.write(line)
    _real_stdout.flush()
# generated by hugo AI

这意味着所有 print() 调用都会跑到 stderr(日志),只有 write_json() 写的内容才是协议数据。这是一个简单但极其有效的防协议污染策略。

RPC 方法体系

Session 生命周期 (14 个方法):
  session.create      session.list        session.resume
  session.close       session.title       session.undo
  session.compress    session.branch      session.interrupt
  session.steer       session.usage       session.history
  session.save        terminal.resize

Prompt 提交 (3 个方法):
  prompt.submit       prompt.background   prompt.btw

交互响应 (4 个方法):
  clarify.respond     approval.respond    sudo.respond
  secret.respond

配置 (2 个方法):
  config.get          config.set

其他:
  clipboard.paste     image.attach        input.detect_drop
  shell.exec          slash.exec          slash.complete
  skin.get            tools.configure     mcp.reload
  model.options       commands.catalog

事件推送体系

消息流:
  message.start  message.delta (流式)  message.complete

工具:
  tool.start  tool.progress  tool.complete
  tool.generating

思考/推理:
  thinking.delta  reasoning.delta  reasoning.available

交互:
  clarify.request  clarify.respond (用户回答)
  approval.request  approval.respond
  sudo.request  sudo.respond
  secret.request  secret.respond

子代理:
  subagent.start  subagent.thinking  subagent.tool  subagent.complete

状态:
  session.info   status.update   error
  background.complete   btw.complete
  gateway.ready   gateway.stderr   skin.changed

回调桥接:_agent_cbs()

TUI 中 Agent 的回调不是直接函数——它们被包装成 _emit() 调用,把事件推送给前端:

def _agent_cbs(sid: str) -> dict:
    return dict(
        tool_start_callback=lambda tc_id, name, args:
            _emit("tool.start", sid, {"tool_id": tc_id, "name": name}),
        tool_complete_callback=lambda tc_id, name, args, result:
            _emit("tool.complete", sid, {"tool_id": tc_id, "name": name}),
        thinking_callback=lambda text:
            _emit("thinking.delta", sid, {"text": text}),
        reasoning_callback=lambda text:
            _emit("reasoning.delta", sid, {"text": text}),
        # 阻塞式交互:发事件 + threading.Event 等待
        clarify_callback=lambda q, c:
            _block("clarify.request", sid, {"question": q, "choices": c}),
    )
# generated by hugo AI

阻塞式交互:_block()

TUI 的 clarify/sudo/secret 回调用 _block() 实现跨进程阻塞:

def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str:
    rid = uuid.uuid4().hex[:8]
    ev = threading.Event()
    _pending[rid] = ev            # 注册 pending
    payload["request_id"] = rid
    _emit(event, sid, payload)    # 推送事件给 TUI
    ev.wait(timeout=timeout)      # 阻塞等待
    _pending.pop(rid, None)
    return _answers.pop(rid, "")  # TUI 通过 clarify.respond 填入答案
# generated by hugo AI

当 TUI 收到 clarify.request 事件时,它渲染选择界面,用户确认后发送 clarify.respond RPC 请求:

@method("clarify.respond")
def _(rid, params):
    r = params.get("request_id", "")
    ev = _pending.get(r)
    if ev:
        _answers[r] = params.get("answer", "")
        ev.set()  # 唤醒 _block() 中的等待线程
# generated by hugo AI
Python Agent 线程:  _block() ──→ _emit("clarify.request") ──→ threading.Event.wait() [阻塞]
Python Gateway:      _methods["clarify.respond"] ──→ ev.set() ────┘
Node.js TUI:         收到 event → 渲染 UI → 用户选择 → request("clarify.respond")

两种架构的对比

维度CLITUI
进程模型单 Python 进程Node.js 进程 + Python 子进程
通信协议无——直接函数调用JSON-RPC 2.0 over stdio
UI 框架prompt_toolkit (Python)Ink/React (TypeScript)
回调类型同步函数引用异步事件推送
阻塞交互queue.Queue.get()threading.Event.wait() + RPC 响应
流式输出sys.stdout.write(delta)_emit("message.delta", ...) JSON 行
中断机制_interrupt_queue.put() + pollsession.interrupt RPC 方法
序列化JSON 序列化/反序列化
容错进程崩溃 = 全部崩溃子进程崩溃不影响 TUI 外壳
性能零开销每帧 ~0.1ms JSON 序列化

为什么两套并存

这不是历史包袱,而是工程取舍

CLI 选择同进程直连的原因

  1. 零延迟——没有序列化、没有 IPC 开销,工具执行和 UI 更新在同一进程内
  2. 简单——不需要处理进程生命周期、协议版本兼容、事件丢失
  3. 线程安全——prompt_toolkit 和 queue.Queue 的组合是经过充分验证的模式

TUI 选择跨进程 RPC 的原因

  1. 技术栈自由——React 生态的组件、hooks、状态管理远比 Python TUI 丰富
  2. 事件驱动——JSON-RPC 的事件推送天然适配 React 的状态更新模型
  3. 进程隔离——Python 进程崩溃不影响 TUI 外壳,可以自动重启
  4. 可扩展性——同样的协议可以被其他前端复用(未来可能有 Electron、Web 前端)

协议设计的三个关键原则

1. 单一写入者原则

# TUI Gateway: 只有 write_json() 写 _real_stdout
sys.stdout = sys.stderr  # 所有 print() 被重定向
# generated by hugo AI

任何多路复用的通信通道都必须只有一个写入者。TUI 把 Python 的默认 stdout 重定向到 stderr,确保 print() 永远不会污染 JSON-RPC 协议。这是很多初学者踩坑的地方——一个第三方库的 debug print 就能让整条通信链路崩溃。

2. 请求-事件分离原则

有 id 的 JSON-RPC → 请求/响应(同步语义)
无 id 的 JSON-RPC → 事件推送(异步语义)

JSON-RPC 2.0 规范中,Notification(无 id)和 Request(有 id)是不同的消息类型。TUI 利用这个区分来分离两种通信语义:用户操作(发 prompt、切换模型)用 Request/Response,Agent 主动推送(流式输出、工具执行)用 Event。

3. 回调注入原则

# CLI
agent = AIAgent(..., clarify_callback=self._clarify_callback, ...)

# TUI
agent = AIAgent(..., **_agent_cbs(sid))  # _agent_cbs 返回回调 dict
# generated by hugo AI

Agent Loop 不依赖任何具体的 UI 框架。它只接受一组回调函数,调用方决定这些回调是 queue.Queue.put() 还是 _emit("event", ...)。这就是为什么同一个 AIAgent 能同时服务于 CLI、TUI、Telegram、Discord——回调注入是解耦的关键。

架构图

CLI 数据流:
  用户输入 (Enter)
    
    
  prompt_toolkit KeyBindings ──→ _pending_input / _interrupt_queue
                                          
             chat() 监控 queue             
                                         
                                         
             AIAgent.run_conversation()   
                                         
        回调函数直接调用 ←─────────────────┘
         ├─ _stream_delta()  sys.stdout.write()
         ├─ _clarify_callback()  queue.get()  用户 Enter
         ├─ _on_tool_progress()  _cprint()
         └─ _on_thinking()  Rich 渲染
    
    
  终端显示

TUI 数据流:
  用户输入 (Enter)
    
    
  React Ink 组件 ──→ GatewayClient.request("prompt.submit", {text})
                            
                       JSON 写入 stdin
                            
                  tui_gateway/server.py
                       handle_request()
                            
                  AIAgent.run_conversation()
                       
        回调函数  _emit()  JSON 写入 stdout
         ├─ "message.delta"  TUI 流式渲染
         ├─ "tool.start"  TUI 工具状态更新
         ├─ "clarify.request"  TUI 渲染选择界面
         └─ "status.update"  TUI 状态栏更新
    
    
  React 状态更新  UI 重新渲染

总结

CLI 和 TUI 代表了两种不同的工程哲学:

  • CLI 是"嵌入式"思维——UI 和 Agent 在同一进程内,通信成本为零,代价是 UI 技术栈受限于 Python 生态
  • TUI 是"微服务"思维——UI 和 Agent 是独立进程,通过标准协议通信,代价是序列化开销和进程管理复杂度

两者并存不是冗余,而是场景适配。CLI 适合 SSH 远程、低资源、极客场景;TUI 适合本地开发、富 UI 交互、现代终端体验。

你在实际项目中会选哪种架构?或者两者都用?欢迎留言讨论。


See also