Commit 41ded07b2ff5c2509cfe0c772e46d1c1c06bb2b1

Authored by yibo
1 parent d523d598

feat: 方案 D - 流式消费 hermes /v1/runs SSE,每个 tool 边界 → 一条独立微信消息

旧路径:/v1/chat/completions 同步阻塞拿完整 reply → 一条微信消息。
长 agent loop 用户体验差("稍等" 之后再无下文)。

新路径:
  1) POST /v1/runs { input, conversation_history, session_id, instructions }
     拿 run_id 立即返回
  2) GET /v1/runs/{run_id}/events SSE 长连接,消费事件:
     - message.delta { delta }  → 累积到 textBuf
     - tool.started             → flush(false): textBuf → onUtterance
     - run.completed { output } → flush(true): 最后一段
     - run.failed/cancelled     → reject

  3) onUtterance 在串行 Promise 队列(sendChain)里跑:
     - idx===0 的 utterance 带 quoteText + atSender(群里 @ 原消息)
     - 后续 idx>0 仅文本,不重复 quote/@
     - 发完后 await sleep(14000 或 9000) 等 A11y 跑完整一轮 chat-list-click
       → quote → @ → type → send,避免下一个 broadcast 打断当前 FSM 状态

handleMessage:
  - 移除旧的 this.replyGenerator + sendTextMessageBroadcast 同步路径
  - 改成 callHermesRunStream(messages, sessionId, onUtterance)
  - state.markReplied 在 run 整体完成后才标记(避免中间步骤已发但失败时重发漏掉)

诊断:增加 [STREAM EV] 日志统计事件类型 + textBuf 长度,
方便诊断"agent loop 跑很久但没 utterance"等情况(实测发现
深度探索时 hermes 几乎只调 tool 不发 text,多步分割效果依赖 LLM 行为)。

keepalive.sh: HERMES_TIMEOUT 180s → 600s,长 agentic run 经常 3-5min。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
server/services/wechat/auto-reply.js
... ... @@ -136,7 +136,155 @@ function log(...args) {
136 136 // ═══════════════════════════════════════════════════════════════
137 137  
138 138 /**
139   - * 调用 hermes-agent API (OpenAI-compatible) 生成回复
  139 + * 调用 hermes-agent 的 /v1/runs 流式 API,按 agent loop 的 tool 边界
  140 + * 把每一段 assistant 文本作为独立"utterance"通过 onUtterance 回调推出。
  141 + *
  142 + * 事件流(SSE on /v1/runs/{id}/events):
  143 + * message.delta { delta: "字符片段" } → 累积到 textBuf
  144 + * tool.started { tool, preview } → flush textBuf 作为一条 utterance
  145 + * tool.completed → 下一个 turn 准备开始
  146 + * reasoning.available → 不转发(hermes 思考过程)
  147 + * run.completed { output, usage } → 最终 flush
  148 + * run.failed / run.cancelled → reject
  149 + *
  150 + * @param {Array} messages OpenAI 格式(含 system / user / assistant 多轮)
  151 + * @param {string} sessionId hermes session(每个 talker 独立)
  152 + * @param {Function} onUtterance async (text, index, isFinal) => Promise<void>
  153 + * 串行调用;返回的 Promise 完成前不会发下一条
  154 + * @returns {Promise<{output?, usage?}>}
  155 + */
  156 +async function callHermesRunStream(messages, sessionId, onUtterance) {
  157 + const userMsg = messages[messages.length - 1]?.content || '';
  158 + const systemMsg = messages.find(m => m.role === 'system');
  159 + const history = messages.slice(0, -1)
  160 + .filter(m => m.role !== 'system')
  161 + .map(m => ({ role: m.role, content: m.content }));
  162 +
  163 + // 1) POST /v1/runs 拿 run_id
  164 + const startBody = JSON.stringify({
  165 + input: userMsg,
  166 + conversation_history: history,
  167 + session_id: sessionId,
  168 + instructions: systemMsg?.content,
  169 + });
  170 +
  171 + const runId = await new Promise((resolve, reject) => {
  172 + const req = http.request({
  173 + hostname: CONFIG.hermesHost,
  174 + port: CONFIG.hermesPort,
  175 + path: '/v1/runs',
  176 + method: 'POST',
  177 + headers: {
  178 + 'Content-Type': 'application/json',
  179 + 'Content-Length': Buffer.byteLength(startBody),
  180 + },
  181 + timeout: 15000,
  182 + }, res => {
  183 + let buf = '';
  184 + res.on('data', c => buf += c.toString());
  185 + res.on('end', () => {
  186 + try {
  187 + const j = JSON.parse(buf);
  188 + if (j.run_id) return resolve(j.run_id);
  189 + reject(new Error('No run_id in response: ' + buf.substring(0, 200)));
  190 + } catch (e) { reject(e); }
  191 + });
  192 + });
  193 + req.on('error', reject);
  194 + req.on('timeout', () => { req.destroy(); reject(new Error('start timeout')); });
  195 + req.write(startBody);
  196 + req.end();
  197 + });
  198 +
  199 + log('[HERMES] Run started:', runId, 'session:', sessionId);
  200 +
  201 + // 2) GET /v1/runs/{runId}/events (SSE)
  202 + return new Promise((resolve, reject) => {
  203 + const req = http.get({
  204 + hostname: CONFIG.hermesHost,
  205 + port: CONFIG.hermesPort,
  206 + path: `/v1/runs/${runId}/events`,
  207 + timeout: CONFIG.hermesTimeoutMs,
  208 + }, res => {
  209 + let buf = '';
  210 + let textBuf = '';
  211 + let utteranceIdx = 0;
  212 + let sendChain = Promise.resolve(); // 串行队列:每个 flush 在前一个完成后执行
  213 + let finalResult = {};
  214 +
  215 + const flush = (isFinal) => {
  216 + const text = textBuf.trim();
  217 + textBuf = '';
  218 + if (!text) return;
  219 + const idx = utteranceIdx++;
  220 + // 关键:链式 append,不等待
  221 + sendChain = sendChain
  222 + .then(() => onUtterance(text, idx, !!isFinal))
  223 + .catch(e => log('[onUtterance error]', e.message));
  224 + };
  225 +
  226 + // diagnostic counters
  227 + const evCount = { 'message.delta': 0 };
  228 + let lastDiagLogAt = Date.now();
  229 + const diagLog = (evt) => {
  230 + evCount[evt] = (evCount[evt] || 0) + 1;
  231 + const now = Date.now();
  232 + if (evt !== 'message.delta' || now - lastDiagLogAt > 10000) {
  233 + log('[STREAM EV]', evt, 'counts=', JSON.stringify(evCount), 'textBuf.len=' + textBuf.length);
  234 + lastDiagLogAt = now;
  235 + }
  236 + };
  237 +
  238 + res.setEncoding('utf-8');
  239 + res.on('data', chunk => {
  240 + buf += chunk;
  241 + let sepIdx;
  242 + while ((sepIdx = buf.indexOf('\n\n')) >= 0) {
  243 + const block = buf.substring(0, sepIdx);
  244 + buf = buf.substring(sepIdx + 2);
  245 + for (const line of block.split('\n')) {
  246 + if (!line.startsWith('data: ')) continue;
  247 + let ev;
  248 + try { ev = JSON.parse(line.substring(6)); } catch (_) { continue; }
  249 + diagLog(ev.event);
  250 + switch (ev.event) {
  251 + case 'message.delta':
  252 + textBuf += (ev.delta || '');
  253 + break;
  254 + case 'tool.started':
  255 + // turn 边界:上一段 assistant 文本完成
  256 + flush(false);
  257 + break;
  258 + case 'run.completed':
  259 + flush(true);
  260 + finalResult = { output: ev.output, usage: ev.usage };
  261 + // 等串行队列 drain 完再 resolve
  262 + sendChain.then(() => resolve(finalResult));
  263 + break;
  264 + case 'run.failed':
  265 + reject(new Error(ev.error || 'run failed'));
  266 + break;
  267 + case 'run.cancelled':
  268 + reject(new Error('run cancelled'));
  269 + break;
  270 + // tool.completed / reasoning.available / approval.* 忽略
  271 + }
  272 + }
  273 + }
  274 + });
  275 + res.on('end', () => {
  276 + flush(true);
  277 + sendChain.then(() => resolve(finalResult));
  278 + });
  279 + res.on('error', reject);
  280 + });
  281 + req.on('error', reject);
  282 + req.on('timeout', () => { req.destroy(); reject(new Error('events stream timeout')); });
  283 + });
  284 +}
  285 +
  286 +/**
  287 + * 调用 hermes-agent API (OpenAI-compatible) 生成回复 — 老的非流式同步 API
140 288 *
141 289 * @param {Array<{role:string,content:string}>} messages - OpenAI messages 格式
142 290 * @param {string} sessionId - 会话 ID(每个 talker 独立,作为 X-Hermes-Session-Id)
... ... @@ -527,64 +675,64 @@ class AutoReplyBot {
527 675 log('[HISTORY] fetch failed:', e.message);
528 676 }
529 677  
530   - let replyText;
531   - try {
532   - replyText = await this.replyGenerator({
533   - type: replyType,
534   - talker,
535   - displayName,
536   - content,
537   - senderName,
538   - history,
539   - raw: msg.raw,
540   - });
541   - } catch (e) {
542   - log('[REPLY GEN ERROR]', e.message);
543   - return;
544   - }
545   -
546   - if (!replyText) {
547   - log('[NO REPLY] Generator returned null');
548   - return;
549   - }
550   -
551   - if (replyText.length > CONFIG.maxReplyLength) {
552   - replyText = replyText.substring(0, CONFIG.maxReplyLength) + '...';
553   - }
554   -
555   - // 群里被 @ 时,给 broadcast 带上 quoteText(引用触发消息)+ atSender(回复里 @ 对方)
556   - const sendOpts = { toUserDisplayName: displayName };
  678 + // 流式 hermes 调用直接在 onUtterance 里发送,不再先生成完整文本
  679 + // 群里被 @ 时,第一条回复带 quote + atSender;多步流式回复后续条仅文本
  680 + const baseSendOpts = { toUserDisplayName: displayName };
  681 + let firstSendOpts = { ...baseSendOpts };
557 682 if (replyType === 'group-at' || replyType === 'group-quote') {
558   - // 群消息 content 的格式是 "senderWxid:\n实际文本",提取实际文本作为 quote substring
559 683 let bubbleText = content;
560 684 const colonIdx = bubbleText.indexOf(':\n');
561 685 if (colonIdx > 0) bubbleText = bubbleText.substring(colonIdx + 2);
562   - // 取前若干字符作为 substring 匹配;含 XML/特殊字符的部分(如 refermsg)跳过
563 686 const trimmed = bubbleText.replace(/<[^>]+>/g, '').trim();
564   - if (trimmed) sendOpts.quoteText = trimmed.substring(0, 30);
565   - // senderName 是 wxid(如 'yibooo'),群里 @ 面板显示的是昵称(如 '火星人'),
566   - // 必须查 rcontact 拿到昵称,否则 A11y 在选人面板里搜不到
  687 + if (trimmed) firstSendOpts.quoteText = trimmed.substring(0, 30);
567 688 if (senderName) {
568 689 try {
569 690 const senderNick = await this.tracker.getContactNickname(senderName);
570   - sendOpts.atSender = senderNick || senderName;
  691 + firstSendOpts.atSender = senderNick || senderName;
571 692 } catch (_) {
572   - sendOpts.atSender = senderName;
  693 + firstSendOpts.atSender = senderName;
573 694 }
574 695 }
575   - log('[GROUP] quote=' + (sendOpts.quoteText || '-') + ' atSender=' + (sendOpts.atSender || '-'));
  696 + log('[GROUP] first-reply quote=' + (firstSendOpts.quoteText || '-')
  697 + + ' atSender=' + (firstSendOpts.atSender || '-'));
576 698 }
577 699  
578   - try {
579   - const result = await sendTextMessageBroadcast(talker, replyText, sendOpts);
580   - if (result.ok) {
581   - log('[SENT]', talker, `(as "${displayName}")`, replyText.substring(0, 60));
582   - this.state.markReplied(msgId, talker);
583   - } else {
584   - log('[SEND FAILED]', talker, result.error);
  700 + // 走流式:每个 hermes turn → 一条独立微信消息
  701 + // 第一条带 quote + atSender,后续条仅文本(避免重复引用 / @)
  702 + const onUtterance = async (text, idx, isFinal) => {
  703 + // 单条限长保护
  704 + let toSend = text;
  705 + if (toSend.length > CONFIG.maxReplyLength) {
  706 + toSend = toSend.substring(0, CONFIG.maxReplyLength) + '...';
  707 + }
  708 + const opts = (idx === 0) ? firstSendOpts : baseSendOpts;
  709 + log('[UTTER]', idx, isFinal ? '(final)' : '', toSend.substring(0, 60));
  710 + try {
  711 + const r = await sendTextMessageBroadcast(talker, toSend, opts);
  712 + if (r.ok) {
  713 + log('[SENT]', talker, `#${idx}`, toSend.substring(0, 60));
  714 + } else {
  715 + log('[SEND FAILED]', talker, r.error);
  716 + }
  717 + } catch (e) {
  718 + log('[SEND ERROR]', e.message);
585 719 }
  720 + // 等 A11y 跑完 chat-list-click → quote → @ → type → send 完整一轮
  721 + // 不等的话下一条 broadcast 会打断 A11y 当前状态
  722 + await sleep(idx === 0 ? 14000 : 9000); // 第一条带 quote+@ 多花时间
  723 + };
  724 +
  725 + try {
  726 + const sessionId = `wx-${talker}`;
  727 + const messages = buildHermesMessages({
  728 + type: replyType, talker, displayName, content, senderName, history, raw: msg.raw,
  729 + });
  730 + log('[HERMES] Streaming for', talker, 'turns=' + messages.length);
  731 + const result = await callHermesRunStream(messages, sessionId, onUtterance);
  732 + log('[HERMES] Run done. usage=' + JSON.stringify(result.usage || {}));
  733 + this.state.markReplied(msgId, talker);
586 734 } catch (e) {
587   - log('[SEND ERROR]', e.message);
  735 + log('[STREAM ERROR]', e.message);
588 736 }
589 737 }
590 738  
... ...
wechat_plain/scripts/local-testkit/keepalive.sh
... ... @@ -27,7 +27,7 @@ CHECK_INTERVAL_SEC=&quot;${CHECK_INTERVAL_SEC:-15}&quot;
27 27 # auto-reply 启动用的 env(避开 start-ar.sh 的 shell hook 污染)
28 28 MY_WXID="${MY_WXID:-wxid_51ap3m6p9xs722}"
29 29 MY_NICKNAME="${MY_NICKNAME:-小火子}"
30   -HERMES_TIMEOUT_MS="${HERMES_TIMEOUT_MS:-180000}"
  30 +HERMES_TIMEOUT_MS="${HERMES_TIMEOUT_MS:-600000}"
31 31 HISTORY_CONTEXT_SIZE="${HISTORY_CONTEXT_SIZE:-8}"
32 32  
33 33 log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
... ... @@ -108,7 +108,7 @@ start_auto_reply() {
108 108 MY_NICKNAME=\"${MY_NICKNAME:-小火子}\" \
109 109 HERMES_HOST=127.0.0.1 \
110 110 HERMES_PORT=$HERMES_PORT \
111   - HERMES_TIMEOUT=${HERMES_TIMEOUT_MS:-180000} \
  111 + HERMES_TIMEOUT=${HERMES_TIMEOUT_MS:-600000} \
112 112 NOTIFICATION_ENABLED=true \
113 113 HISTORY_CONTEXT_SIZE=${HISTORY_CONTEXT_SIZE:-8} \
114 114 nohup /data/data/com.termux/files/usr/bin/node auto-reply.js \
... ...