From e457029830b913efe3e87cb019ebd2a96f17b174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 28 Jul 2026 23:17:28 -0700 Subject: [PATCH] =?UTF-8?q?fix(hermes):=20=E5=9B=9E=E5=BD=92=E6=A0=87?= =?UTF-8?q?=E5=87=86=20botmux=20send-=E4=BC=98=E5=85=88=E5=BC=95=E5=AF=BC?= =?UTF-8?q?=EF=BC=8C=E5=AF=B9=E9=BD=90=E5=85=B6=E5=AE=83=20CLI=20=E5=8D=8F?= =?UTF-8?q?=E4=BD=9C=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hermes 是 20+ 个 CLI 里唯一被反向引导的:其它非 Claude 系 CLI(codex/traex/ grok/gemini…)走 `buildBotmuxShellHints`,核心是「回复必须 botmux send」+ mention 硬门;唯独 hermes 走 `buildHermesBotmuxHints`,方向相反——「普通回复别用 botmux send,写 final 让 bridge 自动转发」,并把 botmux send 归为「特殊投递」。 反向引导来自 PR #365「修复 hermes 重复回复」。hermes 和 codex/traex/grok 一样是 structured-bridge,assistant final 本就会被 transcript bridge 自动转发到飞书。 当时 bug:hermes 的 SQLite user 行时间戳在 turn 末才 commit、晚于 turn 内的 botmux send marker → bridge-fallback 抑制窗口错位、去重失败 → 一条回复出现两次。 该 PR 做了两件事: - (a) 真正的修复 = `preserveMarkTimeMs`(保留 worker mark,把抑制窗口摆正); - (b) 顺手把 hermes 引导反转成「别 send」当额外冗余保险。 `preserveMarkTimeMs` 已经把去重的正解落地,反向引导是冗余的,而且有害:bridge 自动转发的 final 是纯文本、带不了 @,协作触发别的 bot 必须显式 `botmux send --mention`——但反向引导把 send 归为「特殊投递」而非协作必经路,导致 hermes 的多 agent 协作意识比其它 CLI 弱一档。 - session-manager.ts:hermes 不再特判,新话题走 `buildBotmuxShellHints`、 follow-up 走标准 `ai.followup.reminder`,与 codex/traex/grok 完全一致 - 删除 `buildHermesBotmuxHints` / `hermesFollowupReminder` 两个反向 helper - 更新两个 prompt-builder 测试断言:验证 hermes 现在拿到标准 send-优先 hints, 且不再含反向引导文案(防回归) - **保留** `preserveMarkTimeMs`(hermes-transcript.ts:116)——它才是 #365 的真修复, 单独就能防重复回复 - 跨 CLI:只动 session-manager 的 hermes 引导分支,`buildBotmuxShellHints` 共用逻辑 未改,codex/traex/grok 等其它 structured-bridge CLI 零影响 - 去重路径:`preserveMarkTimeMs` + bridge-fallback-gate 完全不动,hermes 去重行为 与改前一致(现在与 codex 走同一套 send-优先 + bridge 兜底模型) - 跨后端/会话类型:不涉及 PtyBackend/TmuxBackend、adopt/restore、sandbox 差异 - pnpm build 绿 - 相关测试全绿:prompt-builder(52)+ codex-bridge-queue(41)+ hermes-transcript(8) = 101 passed - grep 确认源码无反向引导残留,preserveMarkTimeMs 完整保留 - ⚠️ 待 live 验证:拆掉反向引导后不重现 #365 重复回复(即 preserveMarkTimeMs 单独够去重),将 switch:here + daemon:restart 后在飞书实测 Co-Authored-By: Riff --- src/core/session-manager.ts | 40 +++++++---------------------------- test/prompt-builder.test.ts | 42 +++++++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 724ccd980..ddf0f2996 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -533,30 +533,6 @@ function renderWhiteboardBlock(opts?: { whiteboardId?: string }): string { ].join('\n'); } -function buildHermesBotmuxHints(locale?: Locale): string[] { - if (locale === 'en') { - return [ - 'You are running in a Feishu/Lark chat through botmux. For ordinary text replies, write the user-facing answer as your final assistant message; botmux automatically forwards that final output to Feishu/Lark.', - 'Do not call `botmux send` for normal text answers. Use `botmux send` only for special delivery needs: files/images/videos, voice, cross-chat/top-level sends, or explicit mention routing to another person/bot.', - '`botmux send` / `botmux history` / `botmux quoted` / `botmux bots` are shell commands installed in $PATH; run them via Bash/terminal tools when needed.', - 'If you already used `botmux send` for special delivery in this turn, do not put a second copy of the answer, messageId, or send-success receipt in the final assistant message.', - ]; - } - return [ - '你运行在飞书(Lark)聊天中。普通文字回复请直接写在 assistant final 里,botmux 会自动把 final_output 转发到飞书。', - '普通文本答案不要调用 `botmux send`。只有需要图片/文件/视频/语音、跨群或顶层发送、显式 @ 某人/某 bot 等特殊投递能力时,才使用 `botmux send`。', - '`botmux send` / `botmux history` / `botmux quoted` / `botmux bots` 是已安装在 $PATH 的 shell 命令;需要时通过 Bash/terminal 工具执行。', - '如果本轮已经为了特殊投递调用过 `botmux send`,final 里不要再写第二份正文、messageId 或“发送成功/已处理”回执。', - ]; -} - -function hermesFollowupReminder(locale?: Locale): string { - if (locale === 'en') { - return 'For ordinary text replies, do not call `botmux send`; put the user-facing answer in final and botmux will forward it to Feishu/Lark. Use `botmux send` only for special delivery such as files/images/videos, voice, cross-chat/top-level sends, or explicit mention routing. If already used, do not add a second answer or send-success receipt in final.'; - } - return '普通文字回复不要调用 `botmux send`;直接把给用户看的答案写在 final,botmux 会自动转发到飞书。只有图片/文件/视频/语音、跨群/顶层发送、特殊 @ 路由等特殊投递才用 `botmux send`;如果本轮已经用过,不要在 final 里再写第二份答案或发送成功回执。'; -} - /** * Peer count at/below which the `` block inlines the full * roster (name + open_id). Above it the block collapses to a one-line pointer @@ -651,7 +627,7 @@ export function buildNewTopicPrompt( // (Claude Code builds its own via --append-system-prompt). Source hints // freshly from i18n so they respect the resolved locale instead of the // static `adapter.systemHints` array that was baked at module load. - const hints = adapter.injectsSessionContext ? [] : (cliId === 'hermes' ? buildHermesBotmuxHints(locale) : buildBotmuxShellHints(locale)); + const hints = adapter.injectsSessionContext ? [] : buildBotmuxShellHints(locale); const routingBlock = hints.length > 0 ? `\n${hints.join('\n')}\n` @@ -836,13 +812,13 @@ export function buildFollowUpContent( if (!skipSessionId) parts.push(`${xmlEscape(sessionId)}`); if (roleBlock) parts.push(roleBlock); if (opts?.cliId !== 'mira') { - // Non-hermes CLIs get the anti-resend variant only when the experimental - // dashboard toggle is on (config.noVisibleOutputHint, default OFF); otherwise - // the reminder is byte-for-byte the pre-feature baseline. Live-read so a - // Settings flip applies to the next follow-up turn without a daemon restart. - const reminder = opts?.cliId === 'hermes' - ? hermesFollowupReminder(opts?.locale) - : t(config.noVisibleOutputHint ? 'ai.followup.reminder_no_resend' : 'ai.followup.reminder', undefined, opts?.locale); + // All non-Mira CLIs — including Hermes, which no longer gets reverse + // send-first guidance (#653) and now shares this standard path — get the + // anti-resend variant only when the experimental dashboard toggle is on + // (config.noVisibleOutputHint, default OFF); otherwise the reminder is + // byte-for-byte the pre-feature baseline. Live-read so a Settings flip + // applies to the next follow-up turn without a daemon restart. + const reminder = t(config.noVisibleOutputHint ? 'ai.followup.reminder_no_resend' : 'ai.followup.reminder', undefined, opts?.locale); parts.push(`${reminder}`); } if (whiteboardBlock) parts.push(whiteboardBlock); diff --git a/test/prompt-builder.test.ts b/test/prompt-builder.test.ts index e07e37373..7219529fc 100644 --- a/test/prompt-builder.test.ts +++ b/test/prompt-builder.test.ts @@ -130,12 +130,16 @@ describe('buildNewTopicPrompt', () => { expect(routing).not.toContain('&lt;'); }); - it('uses final-output routing hints for Hermes instead of normal botmux send guidance', () => { + it('gives Hermes the standard botmux-send routing hints like other structured-bridge CLIs', () => { + // #365 previously steered Hermes AWAY from `botmux send` (reverse guidance) + // as a redundant belt-and-braces on top of the real dedup fix + // (preserveMarkTimeMs). That reverse hint weakened multi-agent collaboration + // (bridge-forwarded finals can't carry an @mention), so Hermes now uses the + // same send-first hints as codex/traex/grok. const prompt = buildNewTopicPrompt('hello', SESSION_ID, 'hermes'); - expect(prompt).toContain('普通文字回复请直接写在 assistant final'); - expect(prompt).toContain('普通文本答案不要调用 `botmux send`'); - expect(prompt).not.toContain("botmux send <<'EOF'"); - expect(prompt).not.toContain('回复必须 botmux send'); + expect(prompt).toContain('把消息发给用户(唯一方式)'); + expect(prompt).not.toContain('普通文本答案不要调用 `botmux send`'); + expect(prompt).not.toContain('botmux 会自动把 final_output 转发到飞书'); }); it('should NOT embed for CLIs with injectsSessionContext (claude-code)', () => { @@ -423,12 +427,32 @@ describe('buildFollowUpContent', () => { } }); - it('uses final-output reminder for Hermes follow-ups', () => { + it('gives Hermes the standard follow-up reminder like other CLIs (no reverse send guidance)', () => { + // #365 previously steered Hermes AWAY from `botmux send` (reverse guidance) + // as redundant belt-and-braces on top of the real dedup fix + // (preserveMarkTimeMs). That reverse hint weakened multi-agent collaboration + // (bridge-forwarded finals can't carry an @mention), so Hermes now shares + // the standard path. With the anti-resend toggle OFF (default) that is + // exactly #554's BOTMUX_NO_REPLY sentinel baseline — same as codex/traex. const content = buildFollowUpContent('hello', SESSION_ID, { cliId: 'hermes' }); - expect(content).toContain('普通文字回复不要调用 `botmux send`'); - expect(content).toContain('直接把给用户看的答案写在 final'); - expect(content).not.toContain('回复必须 botmux send'); + expect(content).toContain('需要回复时必须 botmux send;无需回复时不要解释沉默,final 只输出 BOTMUX_NO_REPLY'); + expect(content).not.toContain('普通文字回复不要调用 `botmux send`'); + expect(content).not.toContain('直接把给用户看的答案写在 final'); + }); + + it('routes Hermes through the shared anti-resend branch when noVisibleOutputHint is ON', () => { + // Codex-review guard for #653: prove Hermes really lands in the shared + // noVisibleOutputHint branch (not a special-cased bypass), so the toggle + // reaches it just like every other non-Mira CLI. + (config as { noVisibleOutputHint?: boolean }).noVisibleOutputHint = true; + try { + const content = buildFollowUpContent('hello', SESSION_ID, { cliId: 'hermes' }); + expect(content).toContain('final 只输出 BOTMUX_NO_REPLY'); + expect(content).toMatch(/[^<]*别因「无输出」提示重发[^<]*<\/botmux_reminder>/); + } finally { + delete (config as { noVisibleOutputHint?: boolean }).noVisibleOutputHint; + } }); it('places the short whiteboard hint before follow-up user content', () => {